Co-authored-by: Ivan <84693047+exact01@users.noreply.github.com>
This commit is contained in:
kastov 2024-12-05 23:53:32 +03:00
parent ffc6534c3a
commit 34435c4009
40 changed files with 814 additions and 5883 deletions

1
.gitignore vendored
View file

@ -132,4 +132,3 @@ dist
.DS_Store
.vercel
stats.html

View file

@ -19,7 +19,7 @@
<script src="https://remnawave.github.io/xray-monaco-editor/wasm_exec.js"></script>
<meta name="color-scheme" content="dark only" />
<meta name="theme-color" content="#151B22" />
<meta name="theme-color" content="#161B23" />
<meta
name="viewport"

8
package-lock.json generated
View file

@ -22,7 +22,7 @@
"@mantine/nprogress": "^7.12.2",
"@monaco-editor/react": "^4.6.0",
"@paralleldrive/cuid2": "github:paralleldrive/cuid2",
"@remnawave/backend-contract": "^0.0.47",
"@remnawave/backend-contract": "^0.0.50",
"@tabler/icons-react": "^3.24.0",
"@tanstack/react-query": "^5.62.2",
"@tanstack/react-query-devtools": "^5.62.2",
@ -2962,9 +2962,9 @@
}
},
"node_modules/@remnawave/backend-contract": {
"version": "0.0.47",
"resolved": "https://registry.npmjs.org/@remnawave/backend-contract/-/backend-contract-0.0.47.tgz",
"integrity": "sha512-fiE58VMxxVY7BmUqKPZRbVVowpbjeIdKVU6zgG/B53VGdACoNjLGZaOj/fwrFUttxrcVaIISn1oT9Or97BU75w==",
"version": "0.0.50",
"resolved": "https://registry.npmjs.org/@remnawave/backend-contract/-/backend-contract-0.0.50.tgz",
"integrity": "sha512-aGjynNW3GvUYasEzi1oH4lCIuYY2oVA5nvK9wFBnrN3ci/IOqAw/e1sNrSVcvKU/OCqywp/KMub0c2WRf17yjQ==",
"license": "ISC",
"dependencies": {
"zod": "^3.22.4"

View file

@ -33,7 +33,7 @@
"@mantine/nprogress": "^7.12.2",
"@monaco-editor/react": "^4.6.0",
"@paralleldrive/cuid2": "github:paralleldrive/cuid2",
"@remnawave/backend-contract": "^0.0.47",
"@remnawave/backend-contract": "^0.0.50",
"@tabler/icons-react": "^3.24.0",
"@tanstack/react-query": "^5.62.2",
"@tanstack/react-query-devtools": "^5.62.2",

View file

@ -0,0 +1 @@
export * from './auth.layout'

View file

@ -28,3 +28,8 @@
display: none;
}
}
.logoWrapper {
padding: var(--mantine-spacing-xl) var(--mantine-spacing-xl);
margin-bottom: var(--mantine-spacing-md);
}

View file

@ -3,14 +3,14 @@ import { Group } from '@mantine/core'
import { HeaderButtons } from '@features/ui/dashboard/header-buttons'
import { StickyHeader } from '@shared/ui/sticky-header'
import { SidebarButton } from './sidebar-button'
import { MobileSidebarLayout } from '../mobile-sidebar'
import classes from './header.module.css'
export function Header() {
return (
<StickyHeader className={classes.root}>
<div className={classes.rightContent}>
<SidebarButton />
<MobileSidebarLayout />
</div>
<Group>

View file

@ -1,2 +1,2 @@
export * from './root'
export * from './sidebar'
export * from './root/dashboard.layout'
export * from './sidebar/sidebar.layout'

View file

@ -0,0 +1 @@
export * from './mobile-sidebar.layout'

View file

@ -1,14 +1,15 @@
import { ActionIcon, Code, Drawer } from '@mantine/core'
import { useDisclosure } from '@mantine/hooks'
import { useLocation } from 'react-router-dom'
import { Drawer } from '@mantine/core'
import { PiSquareHalf } from 'react-icons/pi'
import { useEffect } from 'react'
import { HamburgerButton } from '@shared/ui/hamburger-button'
import { Logo } from '@shared/ui/logo'
import { Sidebar } from '../sidebar'
import { SidebarLayout } from '../sidebar/sidebar.layout'
import packageJson from '../../../../../package.json'
export function SidebarButton() {
export function MobileSidebarLayout() {
const location = useLocation()
const [opened, { open, close }] = useDisclosure(false)
@ -22,14 +23,19 @@ export function SidebarButton() {
<Drawer.Overlay />
<Drawer.Content>
<Drawer.Header mb="md" px="1.725rem">
<Logo c="red" color="red" w="3rem" />
<Logo c="cyan" w="3rem" />
<Code c="cyan" fw={700}>
{`v${packageJson.version}`}
</Code>
</Drawer.Header>
<Drawer.Body>
<Sidebar />
<SidebarLayout />
</Drawer.Body>
</Drawer.Content>
</Drawer.Root>
<HamburgerButton display={{ xl: 'none' }} onClick={open} />
<ActionIcon display={{ xl: 'none' }} onClick={open} variant="transparent">
<PiSquareHalf size={'1.8rem'} />
</ActionIcon>
</>
)
}

View file

@ -0,0 +1,54 @@
import { Code, Divider, Group, Image, Paper, ScrollArea, Stack } from '@mantine/core'
import { Outlet } from 'react-router-dom'
import { PiLink } from 'react-icons/pi'
import { Logo } from '@shared/ui/logo'
import { app } from '@/config'
import { SidebarLayout } from '../sidebar/sidebar.layout'
import packageJson from '../../../../../package.json'
import classes from './dashboard.module.css'
import { Header } from '../header/header'
export function DashboardLayout() {
return (
<div className={classes.root}>
<Paper className={classes.sidebarWrapper} radius="md" withBorder>
<Group className={classes.logoWrapper} justify="space-between">
<Logo c="cyan" color="red" w="3rem" />
<Code c="cyan" fw={700}>
{`v${packageJson.version}`}
</Code>
</Group>
<ScrollArea flex="1" px="md">
<SidebarLayout />
</ScrollArea>
<Divider
label={<PiLink color={'var(--mantine-color-cyan-6)'} size={'1.4rem'} />}
labelPosition="center"
variant="dashed"
/>
<Stack align="center" justify="center" pt="md">
<Image
h="auto"
onClick={() => window.open(app.githubOrg, '_blank')}
radius="lg"
src="https://img.shields.io/github/stars/remnawave?style=for-the-badge&logo=github&logoColor=fffff&label=Stars&labelColor=21262d&color=30363d&cacheSeconds=1"
style={{ cursor: 'pointer' }}
w="10rem"
/>
</Stack>
</Paper>
<div className={classes.content}>
<Header />
<main className={classes.main}>
<Outlet />
</main>
</div>
</div>
)
}

View file

@ -1,30 +0,0 @@
import { Paper, ScrollArea } from '@mantine/core'
import { Outlet } from 'react-router-dom'
import { Logo } from '@shared/ui/logo'
import classes from './root.module.css'
import { Sidebar } from '../sidebar'
import { Header } from '../header'
export function DashboardLayout() {
return (
<div className={classes.root}>
<Paper className={classes.sidebarWrapper} radius="md" withBorder>
<div className={classes.logoWrapper}>
<Logo c="red" color="red" w="3rem" />
</div>
<ScrollArea flex="1" px="md">
<Sidebar />
</ScrollArea>
</Paper>
<div className={classes.content}>
<Header />
<main className={classes.main}>
<Outlet />
</main>
</div>
</div>
)
}

View file

@ -1,6 +1,7 @@
import {
PiBookmarksDuotone,
PiComputerTowerDuotone,
PiCookie,
PiGearDuotone,
PiStarDuotone,
PiUsersDuotone
@ -27,7 +28,8 @@ export const menu: MenuItem[] = [
{ name: 'Users', href: ROUTES.DASHBOARD.USERS, icon: PiUsersDuotone },
{ name: 'Hosts', href: ROUTES.DASHBOARD.HOSTS, icon: PiBookmarksDuotone },
{ name: 'Nodes', href: ROUTES.DASHBOARD.NODES, icon: PiComputerTowerDuotone },
{ name: 'Config', href: ROUTES.DASHBOARD.CONFIG, icon: PiGearDuotone }
{ name: 'Config', href: ROUTES.DASHBOARD.CONFIG, icon: PiGearDuotone },
{ name: 'API Keys', href: ROUTES.DASHBOARD.CONFIG, icon: PiCookie }
]
},
{

View file

@ -4,7 +4,7 @@ import { NavLink, Stack, Title } from '@mantine/core'
import classes from './sidebar.module.css'
import { menu } from './menu-sections'
export function Sidebar() {
export function SidebarLayout() {
const { pathname } = useLocation()
return (

View file

@ -15,9 +15,9 @@ import { NodesPageConnector } from '@pages/dashboard/nodes/ui/connectors'
import { HomePageConnector } from '@pages/dashboard/home/connectors'
import { ErrorBoundaryHoc } from '@/shared/hocs/error-boundary'
import { AuthGuard } from '@/shared/hocs/guards/auth-guard'
import { AuthLayout } from '@/app/layouts/auth/auth.layout'
import { DashboardLayout } from '@/app/layouts/dashboard'
import { ErrorPageComponent } from '@pages/error'
import { AuthLayout } from '@/app/layouts/auth'
import { LoginPage } from '@pages/auth/login'
import { ROUTES } from '../../shared/constants'

View file

@ -1,69 +0,0 @@
import { LoginCommand } from '@remnawave/backend-contract'
import { notifications } from '@mantine/notifications'
import { devtools } from 'zustand/middleware'
import { AxiosError } from 'axios'
import { create } from 'zustand'
import { instance } from '@shared/api'
import type { IActions, IState } from './interfaces'
import { removeToken, setToken } from '../session-store/use-session-store'
const initialState: IState = {
isLoading: false,
loginResponse: null
}
const useAuthStore = create<IActions & IState>()(
devtools(
(set, getState) => ({
...initialState,
actions: {
login: async (data: LoginCommand.Request): Promise<void> => {
try {
set({ isLoading: true })
const response = await instance.post<LoginCommand.Response>(
LoginCommand.url,
data
)
const {
data: { response: dataResponse }
} = response
const { accessToken } = dataResponse
setToken({ token: accessToken })
set({ loginResponse: dataResponse })
notifications.show({
title: 'Welcome back!',
message: 'You have successfully logged in'
})
} catch (e) {
if (e instanceof AxiosError) {
notifications.show({ message: e.message, color: 'red' })
throw e
}
} finally {
set({ isLoading: false })
}
},
setToken: async (token: string) => {
setToken({ token })
},
resetState: async () => {
removeToken()
set({ ...initialState })
}
}
}),
{
name: 'loginPageStore',
anonymousActionType: 'loginPageStore'
}
)
)
export const useAuthStoreIsLoading = () => useAuthStore((store) => store.isLoading)
export const useLoginResponse = () => useAuthStore((state) => state.loginResponse)
export const useLoginPageStoreActions = () => useAuthStore((store) => store.actions)

View file

@ -1,2 +0,0 @@
export * from './auth-store'
export * from './interfaces'

View file

@ -1,9 +0,0 @@
import { LoginCommand } from '@remnawave/backend-contract'
export interface IActions {
actions: {
login: (data: LoginCommand.Request) => Promise<void>
resetState: () => Promise<void>
setToken: (token: string) => Promise<void>
}
}

View file

@ -1,2 +0,0 @@
export * from './action.interface.js'
export * from './state.interface.js'

View file

@ -1,6 +0,0 @@
import { LoginCommand } from '@remnawave/backend-contract'
export interface IState {
isLoading: boolean
loginResponse: LoginCommand.Response['response'] | null
}

View file

@ -66,7 +66,7 @@ export const UserActionGroupFeature = (props: IProps) => {
size="xs"
variant="default"
>
Create new user
New user
</Button>
</Group>
)

View file

@ -11,17 +11,17 @@ export const LoginPage = () => {
<Page title="Login">
<Stack align="center" gap="xl">
<Group align="center" justify="center">
<Logo c="red" size="2.5rem" />
<Logo c="cyan" w="3rem" />
<Title order={1}>
<Text component="span" fw="inherit" fz="inherit" pos="relative">
Remnawave
<UnderlineShape
bottom="-1rem"
c="red"
c="cyan"
h="0.625rem"
left="0"
pos="absolute"
w="7rem"
w="7.852rem"
/>
</Text>
</Title>

View file

@ -1,4 +1,3 @@
import cuid2 from '@paralleldrive/cuid2'
import { Grid } from '@mantine/core'
import { useNodesStoreIsNodesLoading } from '@entitites/dashboard/nodes/nodes-store/nodes-store'

View file

@ -1,48 +0,0 @@
import {
ActionIcon,
ActionIconProps,
ElementProps,
MantineColorScheme,
Tooltip,
useMantineColorScheme
} from '@mantine/core'
import {
PiMoonDuotone as DarkIcon,
PiSunDimDuotone as LightIcon,
PiDesktop as SystemIcon
} from 'react-icons/pi'
import { match } from '@shared/utils/match'
type ColorSchemeTogglerProps = ElementProps<'button', keyof ActionIconProps> &
Omit<ActionIconProps, 'c' | 'children' | 'onClick' | 'size'>
export function ColorSchemeToggler(props: ColorSchemeTogglerProps) {
const { colorScheme, setColorScheme } = useMantineColorScheme()
const { label, icon: Icon } = match(
[colorScheme === 'auto', { label: 'System', icon: SystemIcon }],
[colorScheme === 'dark', { label: 'Dark', icon: DarkIcon }],
[colorScheme === 'light', { label: 'Light', icon: LightIcon }],
[true, { label: 'Dark', icon: DarkIcon }]
)
const handleSchemeChange = () => {
const nextColorScheme = match<MantineColorScheme>(
[colorScheme === 'auto', 'dark'],
[colorScheme === 'dark', 'light'],
[colorScheme === 'light', 'auto'],
[true, 'dark']
)
setColorScheme(nextColorScheme)
}
return (
<Tooltip label={label}>
<ActionIcon c="inherit" onClick={handleSchemeChange} variant="transparent" {...props}>
<Icon size="100%" />
</ActionIcon>
</Tooltip>
)
}

View file

@ -1,16 +0,0 @@
.root {
margin-inline-end: 0.75rem;
height: auto;
width: auto;
padding: 0;
color: inherit;
@media (min-width: $mantine-breakpoint-sm) {
margin-inline-end: 1rem;
}
}
.icon {
width: 1.5rem;
height: 1.5rem;
}

View file

@ -1,28 +0,0 @@
import { ActionIcon, ActionIconProps, ElementProps } from '@mantine/core'
import clsx from 'clsx'
import classes from './hamburger-button.module.css'
type HamburgerButtonProps = ElementProps<'button', keyof ActionIconProps> &
Omit<ActionIconProps, 'children' | 'variant'>
export function HamburgerButton({ className, ...props }: HamburgerButtonProps) {
return (
<ActionIcon className={clsx(classes.root, className)} variant="transparent" {...props}>
<svg
className={classes.icon}
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3.75 6.75h16.5M3.75 12H12m-8.25 5.25h16.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</ActionIcon>
)
}

View file

@ -1,4 +1,3 @@
export * from './color-scheme-toggler'
export * from './loading-screen'
export * from './logo'
export * from './page'

View file

@ -15,7 +15,7 @@ export function LoadingScreen({
{text && <Text size="lg">{text}</Text>}
<Progress
animated
color="red"
color="cyan"
maw="32rem"
radius="xs"
striped

View file

@ -1,77 +0,0 @@
import { Input, type InputProps, type InputWrapperProps, Text } from '@mantine/core'
import { useUncontrolled } from '@mantine/hooks'
import { IMaskInput } from 'react-imask'
import { forwardRef } from 'react'
const maskProps = {
mask: Number,
thousandsSeparator: ' ',
radix: '.',
normalizeZeros: true
}
export interface MoneyInputProps
extends InputProps,
Pick<InputWrapperProps, 'description' | 'error' | 'label' | 'required'> {
currency?: string
name?: string
onChange?: (value: number | string) => void
placeholder?: string
value?: number | string
}
export const MoneyInput = forwardRef<HTMLDivElement, MoneyInputProps>(
(
{
name,
placeholder,
error,
label,
description,
required,
value,
onChange,
currency = 'USD',
...rest
},
ref
) => {
const [uncontrolledValue, handleUncontrolledValueChange] = useUncontrolled({
value,
defaultValue: value,
onChange
})
const handleChange = (unmaskedNewValue: string) => {
// Since money is typed in major units (100 USD), we need to convert
// it to minor units (10000 cents) before sending it to the backend.
const minorUnits = Math.round(Number(unmaskedNewValue) * 100)
handleUncontrolledValueChange(minorUnits)
}
const majorUnitsValue = String(uncontrolledValue ? Number(uncontrolledValue) / 100 : '')
return (
<Input.Wrapper
description={description}
error={error}
label={label}
ref={ref}
required={required}
>
<Input
autoComplete="none"
component={IMaskInput}
name={name}
onAccept={handleChange}
placeholder={placeholder}
rightSection={<Text mr="md">{currency}</Text>}
unmask
value={majorUnitsValue}
{...maskProps}
{...rest}
/>
</Input.Wrapper>
)
}
)

View file

@ -96,141 +96,149 @@ export const CreateHostModalWidget = () => {
centered
onClose={handleClose}
opened={isModalOpen}
title={<Text fw={500}>Create host</Text>}
title={<Text fw={500}>New host</Text>}
>
<form onSubmit={handleSubmit}>
<Group align="flex-start" grow={false}>
<Stack gap="md" w={400}>
<Group gap="xs" justify="space-between" w="100%"></Group>
<Stack gap="md">
<TextInput
key={form.key('remark')}
label="Remark"
leftSection={<RemarkInfoPopoverWidget />}
placeholder="e.g. 📊 {{TRAFFIC_USED}}"
required
{...form.getInputProps('remark')}
/>
<TextInput
key={form.key('remark')}
label="Remark"
leftSection={<RemarkInfoPopoverWidget />}
placeholder="e.g. 📊 {{TRAFFIC_USED}}"
required
{...form.getInputProps('remark')}
/>
<Stack gap="md">
<Group
gap="xs"
grow
justify="space-between"
preventGrowOverflow={false}
w="100%"
>
<TextInput
key={form.key('address')}
label="Address"
{...form.getInputProps('address')}
placeholder="e.g. example.com"
required
w="75%"
/>
<Stack gap="md" w={400}>
<Group gap="xs" justify="space-between" w="100%">
<NumberInput
key={form.key('port')}
label="Port"
{...form.getInputProps('port')}
allowDecimal={false}
allowNegative={false}
clampBehavior="strict"
decimalScale={0}
hideControls
max={65535}
min={1}
placeholder="e.g. 443"
required
w="20%"
/>
</Group>
<Group
gap="xs"
grow
justify="space-between"
preventGrowOverflow={false}
w="100%"
>
<Select
data={Object.values(inbounds ?? {}).map((inbound) => ({
label: inbound.tag,
value: inbound.uuid
}))}
key={form.key('inboundUuid')}
label="Inbound"
{...form.getInputProps('inboundUuid')}
allowDeselect={false}
placeholder="Select inbound"
required
w="75%"
/>
<Switch
color="teal.8"
key={form.key('isDisabled')}
mt={25}
radius="md"
size="xl"
w="20%"
{...form.getInputProps('isDisabled', { type: 'checkbox' })}
/>
</Group>
<Button
onClick={() => setAdvancedOpened((o) => !o)}
rightSection={
advancedOpened ? (
<PiCaretUp size="1rem" />
) : (
<PiCaretDown size="1rem" />
)
}
variant="subtle"
>
Advanced options
</Button>
<Collapse in={advancedOpened}>
<Stack gap="md">
<TextInput
key={form.key('address')}
label="Address"
{...form.getInputProps('address')}
placeholder="e.g. example.com"
required
w="75%"
key={form.key('sni')}
label="SNI"
placeholder="SNI (e.g. example.com)"
{...form.getInputProps('sni')}
/>
<NumberInput
key={form.key('port')}
label="Port"
{...form.getInputProps('port')}
allowDecimal={false}
allowNegative={false}
clampBehavior="strict"
decimalScale={0}
hideControls
max={65535}
min={1}
placeholder="e.g. 443"
required
w="20%"
<TextInput
key={form.key('requestHost')}
label="Request Host"
placeholder="Host (e.g. example.com)"
{...form.getInputProps('requestHost')}
/>
<TextInput
key={form.key('path')}
label="Path"
placeholder="path (e.g. /ws)"
{...form.getInputProps('path')}
/>
</Group>
<Group gap="xs" justify="space-between" w="100%">
<Select
data={Object.values(inbounds ?? {}).map((inbound) => ({
label: inbound.tag,
value: inbound.uuid
clearable
data={Object.values(ALPN).map((alpn) => ({
label: alpn,
value: alpn
}))}
key={form.key('inboundUuid')}
label="Inbound"
{...form.getInputProps('inboundUuid')}
allowDeselect={false}
placeholder="Select inbound"
required
w="75%"
key={form.key('alpn')}
label="ALPN"
placeholder="ALPN (e.g. h2)"
{...form.getInputProps('alpn')}
/>
<Switch
color="teal.8"
key={form.key('isDisabled')}
mt={25}
radius="md"
size="xl"
w="20%"
{...form.getInputProps('isDisabled', { type: 'checkbox' })}
<Select
clearable
data={Object.values(FINGERPRINTS).map((fingerprint) => ({
label: fingerprint,
value: fingerprint
}))}
key={form.key('fingerprint')}
label="Fingerprint"
placeholder="Fingerprint (e.g. chrome)"
{...form.getInputProps('fingerprint')}
/>
</Group>
<Button
onClick={() => setAdvancedOpened((o) => !o)}
rightSection={
advancedOpened ? (
<PiCaretUp size="1rem" />
) : (
<PiCaretDown size="1rem" />
)
}
variant="subtle"
>
Advanced options
</Button>
<Collapse in={advancedOpened}>
<Stack gap="md">
<TextInput
key={form.key('sni')}
label="SNI"
placeholder="SNI (e.g. example.com)"
{...form.getInputProps('sni')}
/>
<TextInput
key={form.key('requestHost')}
label="Request Host"
placeholder="Host (e.g. example.com)"
{...form.getInputProps('requestHost')}
/>
<TextInput
key={form.key('path')}
label="Path"
placeholder="path (e.g. /ws)"
{...form.getInputProps('path')}
/>
<Select
clearable
data={Object.values(ALPN).map((alpn) => ({
label: alpn,
value: alpn
}))}
key={form.key('alpn')}
label="ALPN"
placeholder="ALPN (e.g. h2)"
{...form.getInputProps('alpn')}
/>
<Select
clearable
data={Object.values(FINGERPRINTS).map((fingerprint) => ({
label: fingerprint,
value: fingerprint
}))}
key={form.key('fingerprint')}
label="Fingerprint"
placeholder="Fingerprint (e.g. chrome)"
{...form.getInputProps('fingerprint')}
/>
</Stack>
</Collapse>
</Stack>
</Stack>
</Collapse>
</Stack>
</Group>
</Stack>
<Group gap="xs" justify="space-between" pt={15} w="100%">
<ActionIcon.Group>

View file

@ -118,138 +118,146 @@ export const EditHostModalWidget = () => {
title={<Text fw={500}>Edit host</Text>}
>
<form onSubmit={handleSubmit}>
<Group align="flex-start" grow={false}>
<Stack gap="md" w={400}>
<Group gap="xs" justify="space-between" w="100%"></Group>
<Stack gap="md">
<TextInput
key={form.key('remark')}
label="Remark"
{...form.getInputProps('remark')}
leftSection={<RemarkInfoPopoverWidget />}
required
/>
<TextInput
key={form.key('remark')}
label="Remark"
{...form.getInputProps('remark')}
leftSection={<RemarkInfoPopoverWidget />}
required
/>
<Stack gap="md">
<Group
gap="xs"
grow
justify="space-between"
preventGrowOverflow={false}
w="100%"
>
<TextInput
key={form.key('address')}
label="Address"
{...form.getInputProps('address')}
placeholder="e.g. example.com"
required
w="75%"
/>
<Stack gap="md" w={400}>
<Group gap="xs" justify="space-between" w="100%">
<NumberInput
key={form.key('port')}
label="Port"
{...form.getInputProps('port')}
allowDecimal={false}
allowNegative={false}
clampBehavior="strict"
decimalScale={0}
hideControls
max={65535}
min={1}
placeholder="e.g. 443"
required
w="20%"
/>
</Group>
<Group
gap="xs"
grow
justify="space-between"
preventGrowOverflow={false}
w="100%"
>
<Select
data={Object.values(inbounds ?? {}).map((inbound) => ({
label: inbound.tag,
value: inbound.uuid
}))}
key={form.key('inboundUuid')}
label="Inbound"
{...form.getInputProps('inboundUuid')}
allowDeselect={false}
defaultValue={host?.inboundUuid}
placeholder="Select inbound"
required
w="75%"
/>
<Switch
color="teal.8"
key={form.key('isDisabled')}
mt={25}
radius="md"
size="xl"
w="20%"
{...form.getInputProps('isDisabled', { type: 'checkbox' })}
/>
</Group>
<Button
onClick={() => setAdvancedOpened((o) => !o)}
rightSection={
advancedOpened ? (
<PiCaretUp size="1rem" />
) : (
<PiCaretDown size="1rem" />
)
}
variant="subtle"
>
Advanced options
</Button>
<Collapse in={advancedOpened}>
<Stack gap="md">
<TextInput
key={form.key('address')}
label="Address"
{...form.getInputProps('address')}
placeholder="e.g. example.com"
required
w="75%"
key={form.key('sni')}
label="SNI"
placeholder="SNI (e.g. example.com)"
{...form.getInputProps('sni')}
/>
<NumberInput
key={form.key('port')}
label="Port"
{...form.getInputProps('port')}
allowDecimal={false}
allowNegative={false}
clampBehavior="strict"
decimalScale={0}
hideControls
max={65535}
min={1}
placeholder="e.g. 443"
required
w="20%"
<TextInput
key={form.key('requestHost')}
label="Request Host"
placeholder="Host (e.g. example.com)"
{...form.getInputProps('requestHost')}
/>
<TextInput
key={form.key('path')}
label="Path"
placeholder="path (e.g. /ws)"
{...form.getInputProps('path')}
/>
</Group>
<Group gap="xs" justify="space-between" w="100%">
<Select
data={Object.values(inbounds ?? {}).map((inbound) => ({
label: inbound.tag,
value: inbound.uuid
clearable
data={Object.values(ALPN).map((alpn) => ({
label: alpn,
value: alpn
}))}
key={form.key('inboundUuid')}
label="Inbound"
{...form.getInputProps('inboundUuid')}
allowDeselect={false}
defaultValue={host?.inboundUuid}
placeholder="Select inbound"
required
w="75%"
key={form.key('alpn')}
label="ALPN"
placeholder="ALPN (e.g. h2)"
{...form.getInputProps('alpn')}
/>
<Switch
color="teal.8"
key={form.key('isDisabled')}
mt={25}
radius="md"
size="xl"
w="20%"
{...form.getInputProps('isDisabled', { type: 'checkbox' })}
<Select
clearable
data={Object.values(FINGERPRINTS).map((fingerprint) => ({
label: fingerprint,
value: fingerprint
}))}
key={form.key('fingerprint')}
label="Fingerprint"
placeholder="Fingerprint (e.g. chrome)"
{...form.getInputProps('fingerprint')}
/>
</Group>
<Button
onClick={() => setAdvancedOpened((o) => !o)}
rightSection={
advancedOpened ? (
<PiCaretUp size="1rem" />
) : (
<PiCaretDown size="1rem" />
)
}
variant="subtle"
>
Advanced options
</Button>
<Collapse in={advancedOpened}>
<Stack gap="md">
<TextInput
key={form.key('sni')}
label="SNI"
placeholder="SNI (e.g. example.com)"
{...form.getInputProps('sni')}
/>
<TextInput
key={form.key('requestHost')}
label="Request Host"
placeholder="Host (e.g. example.com)"
{...form.getInputProps('requestHost')}
/>
<TextInput
key={form.key('path')}
label="Path"
placeholder="path (e.g. /ws)"
{...form.getInputProps('path')}
/>
<Select
clearable
data={Object.values(ALPN).map((alpn) => ({
label: alpn,
value: alpn
}))}
key={form.key('alpn')}
label="ALPN"
placeholder="ALPN (e.g. h2)"
{...form.getInputProps('alpn')}
/>
<Select
clearable
data={Object.values(FINGERPRINTS).map((fingerprint) => ({
label: fingerprint,
value: fingerprint
}))}
key={form.key('fingerprint')}
label="Fingerprint"
placeholder="Fingerprint (e.g. chrome)"
{...form.getInputProps('fingerprint')}
/>
</Stack>
</Collapse>
</Stack>
</Stack>
</Collapse>
</Stack>
</Group>
</Stack>
<Group gap="xs" justify="space-between" pt={15} w="100%">
<ActionIcon.Group>

View file

@ -1,13 +1,8 @@
import {
PiDotsSixVertical,
PiLock,
PiPencil,
PiProhibitDuotone,
PiPulseDuotone
} from 'react-icons/pi'
import { Badge, Button, Group, Text } from '@mantine/core'
import { PiDotsSixVertical, PiLock, PiProhibit, PiPulse } from 'react-icons/pi'
import { ActionIcon, Badge, Box, Group, Text } from '@mantine/core'
import { Draggable } from '@hello-pangea/dnd'
import ColorHash from 'color-hash'
import { useState } from 'react'
import cx from 'clsx'
import { useHostsStoreActions, useHostsStoreSelectedInboundTag } from '@entitites/dashboard'
@ -21,6 +16,7 @@ export function HostCardWidget(props: IProps) {
const inbounds = useDSInboundsHashMap()
const selectedInboundTag = useHostsStoreSelectedInboundTag()
const actions = useHostsStoreActions()
const [isHovered, setIsHovered] = useState(false)
if (!inbounds) {
return null
}
@ -49,83 +45,72 @@ export function HostCardWidget(props: IProps) {
key={item.uuid}
>
{(provided, snapshot) => (
<>
<div
className={cx(classes.item, {
[classes.itemDragging]: snapshot.isDragging,
[classes.filteredItem]: isFiltered
})}
ref={provided.innerRef}
{...provided.draggableProps}
>
<div {...provided.dragHandleProps} className={classes.dragHandle}>
{!isFiltered && <PiDotsSixVertical color="white" size="2rem" />}
{isFiltered && (
<PiLock color="white" size="2rem" style={{ opacity: 0.5 }} />
)}
</div>
<div>
<Group gap="xs">
<Badge
autoContrast
color={ch.hex(item.inboundUuid)}
miw={'15ch'}
radius="md"
size="lg"
variant="light"
>
{inbound.tag}
</Badge>
<Text className={classes.label} fw={400} miw={'40ch'}>
{item.remark}
</Text>
<Text className={classes.hostInfoLabel} miw={'40ch'}>
{item.address}
{item.port ? `:${item.port}` : ''}
</Text>
<Group gap="xs" justify="flex-end">
<Button
color="teal"
leftSection={<PiPencil size="1rem" />}
onClick={handleEdit}
radius="md"
size="xs"
variant="outline"
>
Edit
</Button>
<Badge
color={isHostActive ? 'teal' : 'gray'}
leftSection={
isHostActive ? (
<PiPulseDuotone
size={18}
style={{
color: 'var(--mantine-color-teal-6)'
}}
/>
) : (
<PiProhibitDuotone
size={18}
style={{
color: 'var(--mantine-color-gray-6)'
}}
/>
)
}
size="lg"
variant="outline"
>
{isHostActive ? 'Visible' : 'Disabled'}
</Badge>
</Group>
</Group>
</div>
<Box
className={cx(classes.item, {
[classes.itemDragging]: snapshot.isDragging || isHovered,
[classes.filteredItem]: isFiltered
})}
ref={provided.innerRef}
{...provided.draggableProps}
>
<div {...provided.dragHandleProps} className={classes.dragHandle}>
{!isFiltered && <PiDotsSixVertical color="white" size="2rem" />}
{isFiltered && (
<PiLock color="white" size="2rem" style={{ opacity: 0.5 }} />
)}
</div>
</>
<Box
onClick={handleEdit}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
style={{ cursor: 'pointer' }}
>
<Group gap="xs">
<ActionIcon
color={isHostActive ? 'teal' : 'gray'}
radius="md"
variant="light"
>
{isHostActive ? (
<PiPulse
size={18}
style={{
color: 'var(--mantine-color-teal-6)'
}}
/>
) : (
<PiProhibit
size={18}
style={{
color: 'var(--mantine-color-gray-6)'
}}
/>
)}
</ActionIcon>
<Badge
autoContrast
color={ch.hex(item.inboundUuid)}
miw={'15ch'}
radius="md"
size="lg"
variant="light"
>
{inbound.tag}
</Badge>
<Text className={classes.label} fw={400} miw={'40ch'}>
{item.remark}
</Text>
<Text className={classes.hostInfoLabel} miw={'40ch'}>
{item.address}
{item.port ? `:${item.port}` : ''}
</Text>
</Group>
</Box>
</Box>
)}
</Draggable>
)

View file

@ -110,170 +110,174 @@ export const CreateNodeModalWidget = () => {
}
>
<form onSubmit={handleSubmit}>
<Group align="flex-start" grow={false}>
<Stack gap="md" w={400}>
<Group gap="xs" justify="space-between" w="100%"></Group>
<Stack gap="md">
<Accordion radius="md" variant="contained">
<Accordion.Item value="info">
<Accordion.Control icon={<PiInfo color="gray" size={'1.50rem'} />}>
Important note
</Accordion.Control>
<Accordion.Panel>
<Stack gap={'0'}>
<Text>
In order to connect node, you need to run Remnawave Node
with the following{' '}
<Code color="var(--mantine-color-blue-light)">.env</Code>{' '}
value.
</Text>
<Group justify="flex-end">
<CopyButton
value={`SSL_CERT="${pubKey?.pubKey.trimEnd()}"`}
>
{({ copied, copy }) => (
<ActionIcon
color={copied ? 'teal' : 'blue'}
onClick={copy}
radius="md"
size="lg"
variant="outline"
>
{copied ? (
<PiCheck size="1rem" />
) : (
<PiCopy size="1rem" />
)}
</ActionIcon>
)}
</CopyButton>
</Group>
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
<Accordion radius="md" variant="contained">
<Accordion.Item value="info">
<Accordion.Control icon={<PiInfo color="gray" size={'1.50rem'} />}>
Important note
</Accordion.Control>
<Accordion.Panel>
<Stack gap={'0'}>
<Text>
In order to connect node, you need to run Remnawave Node
with the following{' '}
<Code color="var(--mantine-color-blue-light)">
.env
</Code>{' '}
value.
</Text>
<Group justify="flex-end">
<CopyButton
value={`SSL_CERT="${pubKey?.pubKey.trimEnd()}"`}
>
{({ copied, copy }) => (
<ActionIcon
color={copied ? 'teal' : 'blue'}
onClick={copy}
radius="md"
size="lg"
variant="outline"
>
{copied ? (
<PiCheck size="1rem" />
) : (
<PiCopy size="1rem" />
)}
</ActionIcon>
)}
</CopyButton>
</Group>
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
<TextInput
key={form.key('name')}
label="Internal name"
{...form.getInputProps('name')}
required
/>
<TextInput
key={form.key('name')}
label="Internal name"
{...form.getInputProps('name')}
required
<Stack gap="md">
<Group
gap="xs"
grow
justify="space-between"
preventGrowOverflow={false}
w="100%"
>
<TextInput
key={form.key('address')}
label="Address"
{...form.getInputProps('address')}
placeholder="e.g. example.com"
required
w="75%"
/>
<NumberInput
key={form.key('port')}
label="Port"
{...form.getInputProps('port')}
allowDecimal={false}
allowNegative={false}
clampBehavior="strict"
decimalScale={0}
hideControls
max={65535}
placeholder="e.g. 443"
required
w="20%"
/>
</Group>
<Switch
key={form.key('isTrafficTrackingActive')}
{...form.getInputProps('isTrafficTrackingActive', {
type: 'checkbox'
})}
label="Traffic tracking"
onClick={() => setAdvancedOpened((o) => !o)}
size="md"
thumbIcon={
advancedOpened ? (
<PiCheckDuotone
color={'teal'}
style={{ width: rem(12), height: rem(12) }}
/>
) : (
<PiXDuotone
color="red.6"
style={{ width: rem(12), height: rem(12) }}
/>
)
}
/>
<Stack gap="md" w={400}>
<Group gap="xs" justify="space-between" w="100%">
<TextInput
key={form.key('address')}
label="Address"
{...form.getInputProps('address')}
placeholder="e.g. example.com"
required
w="75%"
<Collapse in={advancedOpened}>
<Group
gap="md"
grow
justify="space-between"
preventGrowOverflow={false}
w="100%"
>
<NumberInput
allowDecimal={false}
decimalScale={0}
defaultValue={0}
hideControls
key={form.key('trafficLimitBytes')}
label="Limit"
leftSection={
<>
<Text
display="flex"
size="0.75rem"
style={{ justifyContent: 'center' }}
ta="center"
w={26}
>
GB
</Text>
<Divider orientation="vertical" />
</>
}
{...form.getInputProps('trafficLimitBytes')}
w="30%"
/>
<NumberInput
key={form.key('port')}
label="Port"
{...form.getInputProps('port')}
key={form.key('trafficResetDay')}
label="Reset day"
{...form.getInputProps('trafficResetDay')}
allowDecimal={false}
allowNegative={false}
clampBehavior="strict"
decimalScale={0}
hideControls
max={65535}
placeholder="e.g. 443"
required
w="20%"
max={31}
min={1}
placeholder="e.g. 1-31"
w="30%"
/>
<NumberInput
key={form.key('notifyPercent')}
label="Notify percent"
{...form.getInputProps('notifyPercent')}
allowDecimal={false}
allowNegative={false}
clampBehavior="strict"
decimalScale={0}
hideControls
max={100}
placeholder="e.g. 50"
w="30%"
/>
</Group>
<Switch
key={form.key('isTrafficTrackingActive')}
{...form.getInputProps('isTrafficTrackingActive', {
type: 'checkbox'
})}
label="Traffic tracking"
onClick={() => setAdvancedOpened((o) => !o)}
size="md"
thumbIcon={
advancedOpened ? (
<PiCheckDuotone
color={'teal'}
style={{ width: rem(12), height: rem(12) }}
/>
) : (
<PiXDuotone
color="red.6"
style={{ width: rem(12), height: rem(12) }}
/>
)
}
/>
<Collapse in={advancedOpened}>
<Stack gap="md">
<Group gap="xs" justify="space-between" w="100%">
<NumberInput
allowDecimal={false}
decimalScale={0}
defaultValue={0}
hideControls
key={form.key('trafficLimitBytes')}
label="Traffic limit"
leftSection={
<>
<Text
display="flex"
size="0.75rem"
style={{ justifyContent: 'center' }}
ta="center"
w={26}
>
GB
</Text>
<Divider orientation="vertical" />
</>
}
{...form.getInputProps('trafficLimitBytes')}
w="30%"
/>
<NumberInput
key={form.key('trafficResetDay')}
label="Traffic reset day"
{...form.getInputProps('trafficResetDay')}
allowDecimal={false}
allowNegative={false}
clampBehavior="strict"
decimalScale={0}
hideControls
max={31}
min={1}
placeholder="e.g. 1-31"
w="30%"
/>
<NumberInput
key={form.key('notifyPercent')}
label="Notify percent"
{...form.getInputProps('notifyPercent')}
allowDecimal={false}
allowNegative={false}
clampBehavior="strict"
decimalScale={0}
hideControls
max={100}
placeholder="e.g. 50"
w="30%"
/>
</Group>
</Stack>
</Collapse>
</Stack>
</Collapse>
</Stack>
</Group>
</Stack>
<Group gap="xs" justify="flex-end" pt={15} w="100%">
<Button

View file

@ -31,6 +31,7 @@ import { useForm, zodResolver } from '@mantine/form'
import { useInterval } from '@mantine/hooks'
import { useEffect, useState } from 'react'
import consola from 'consola/browser'
import { wrap } from 'module'
import { z } from 'zod'
import {
@ -150,198 +151,200 @@ export const EditNodeModalWidget = () => {
}
>
<form onSubmit={handleSubmit}>
<Group align="flex-start" grow={false}>
<Stack gap="md" w={400}>
<Group gap="xs" justify="space-between" w="100%"></Group>
<Accordion
defaultValue={
node &&
node.lastStatusMessage !== null &&
node.lastStatusMessage !== '' &&
node.lastStatusChange !== undefined
? 'error'
: undefined
}
radius="md"
variant="contained"
>
<Accordion.Item value="info">
<Accordion.Control icon={<PiInfo color="gray" size={'1.50rem'} />}>
Important note
</Accordion.Control>
<Accordion.Panel>
<Stack gap={'0'}>
<Text>
In order to connect node, you need to run Remnawave Node
with the following{' '}
<Code color="var(--mantine-color-blue-light)">
.env
</Code>{' '}
value.
</Text>
<Group justify="flex-end">
<CopyButton
value={`SSL_CERT="${pubKey?.pubKey.trimEnd()}"`}
>
{({ copied, copy }) => (
<ActionIcon
color={copied ? 'teal' : 'blue'}
onClick={copy}
radius="md"
size="lg"
variant="outline"
>
{copied ? (
<PiCheck size="1rem" />
) : (
<PiCopy size="1rem" />
)}
</ActionIcon>
)}
</CopyButton>
</Group>
</Stack>
</Accordion.Panel>
</Accordion.Item>
{node &&
node.lastStatusMessage !== null &&
node.lastStatusMessage !== '' &&
node.lastStatusChange !== undefined && (
<Accordion.Item value="error">
<Accordion.Control
icon={
<PiNetworkSlash color="#FF8787" size={'1.50rem'} />
}
<Stack gap="md">
<Accordion
defaultValue={
node &&
node.lastStatusMessage !== null &&
node.lastStatusMessage !== '' &&
node.lastStatusChange !== undefined
? 'error'
: undefined
}
radius="md"
variant="contained"
>
<Accordion.Item value="info">
<Accordion.Control icon={<PiInfo color="gray" size={'1.50rem'} />}>
Important note
</Accordion.Control>
<Accordion.Panel>
<Stack gap={'0'}>
<Text>
In order to connect node, you need to run Remnawave Node
with the following{' '}
<Code color="var(--mantine-color-blue-light)">.env</Code>{' '}
value.
</Text>
<Group justify="flex-end">
<CopyButton
value={`SSL_CERT="${pubKey?.pubKey.trimEnd()}"`}
>
<Text fw={600}>Last error message</Text>
</Accordion.Control>
<Accordion.Panel>
<Code block>{node.lastStatusMessage}</Code>
</Accordion.Panel>
</Accordion.Item>
)}
</Accordion>
{({ copied, copy }) => (
<ActionIcon
color={copied ? 'teal' : 'blue'}
onClick={copy}
radius="md"
size="lg"
variant="outline"
>
{copied ? (
<PiCheck size="1rem" />
) : (
<PiCopy size="1rem" />
)}
</ActionIcon>
)}
</CopyButton>
</Group>
</Stack>
</Accordion.Panel>
</Accordion.Item>
{node &&
node.lastStatusMessage !== null &&
node.lastStatusMessage !== '' &&
node.lastStatusChange !== undefined && (
<Accordion.Item value="error">
<Accordion.Control
icon={<PiNetworkSlash color="#FF8787" size={'1.50rem'} />}
>
<Text fw={600}>Last error message</Text>
</Accordion.Control>
<Accordion.Panel>
<Code block>{node.lastStatusMessage}</Code>
</Accordion.Panel>
</Accordion.Item>
)}
</Accordion>
<TextInput
key={form.key('name')}
label="Internal name"
{...form.getInputProps('name')}
required
<TextInput
key={form.key('name')}
label="Internal name"
{...form.getInputProps('name')}
required
/>
<Stack gap="md">
<Group
gap="xs"
grow
justify="space-between"
preventGrowOverflow={false}
w="100%"
>
<TextInput
key={form.key('address')}
label="Address"
{...form.getInputProps('address')}
placeholder="e.g. example.com"
required
w="75%"
/>
<NumberInput
key={form.key('port')}
label="Port"
{...form.getInputProps('port')}
allowDecimal={false}
allowNegative={false}
clampBehavior="strict"
decimalScale={0}
hideControls
max={65535}
placeholder="e.g. 443"
required
w="20%"
/>
</Group>
<Switch
key={form.key('isTrafficTrackingActive')}
{...form.getInputProps('isTrafficTrackingActive', {
type: 'checkbox'
})}
label="Traffic tracking"
onClick={() => setAdvancedOpened((o) => !o)}
size="md"
thumbIcon={
advancedOpened ? (
<PiCheckDuotone
color={'teal'}
style={{ width: rem(12), height: rem(12) }}
/>
) : (
<PiXDuotone
color="red.6"
style={{ width: rem(12), height: rem(12) }}
/>
)
}
/>
<Stack gap="md" w={400}>
<Group gap="xs" justify="space-between" w="100%">
<TextInput
key={form.key('address')}
label="Address"
{...form.getInputProps('address')}
placeholder="e.g. example.com"
required
w="75%"
<Collapse in={advancedOpened}>
<Group
gap="md"
grow
justify="space-between"
preventGrowOverflow={false}
w="100%"
>
<NumberInput
allowDecimal={false}
decimalScale={0}
defaultValue={0}
hideControls
key={form.key('trafficLimitBytes')}
label="Limit"
leftSection={
<>
<Text
display="flex"
size="0.75rem"
style={{ justifyContent: 'center' }}
ta="center"
w={26}
>
GB
</Text>
<Divider orientation="vertical" />
</>
}
{...form.getInputProps('trafficLimitBytes')}
w={'30%'}
/>
<NumberInput
key={form.key('port')}
label="Port"
{...form.getInputProps('port')}
key={form.key('trafficResetDay')}
label="Reset day"
{...form.getInputProps('trafficResetDay')}
allowDecimal={false}
allowNegative={false}
clampBehavior="strict"
decimalScale={0}
hideControls
max={65535}
placeholder="e.g. 443"
required
w="20%"
max={31}
min={1}
placeholder="e.g. 1-31"
w={'30%'}
/>
<NumberInput
key={form.key('notifyPercent')}
label="Notify percent"
{...form.getInputProps('notifyPercent')}
allowDecimal={false}
allowNegative={false}
clampBehavior="strict"
decimalScale={0}
hideControls
max={100}
placeholder="e.g. 50"
w={'30%'}
/>
</Group>
<Switch
key={form.key('isTrafficTrackingActive')}
{...form.getInputProps('isTrafficTrackingActive', {
type: 'checkbox'
})}
label="Traffic tracking"
onClick={() => setAdvancedOpened((o) => !o)}
size="md"
thumbIcon={
advancedOpened ? (
<PiCheckDuotone
color={'teal'}
style={{ width: rem(12), height: rem(12) }}
/>
) : (
<PiXDuotone
color="red.6"
style={{ width: rem(12), height: rem(12) }}
/>
)
}
/>
<Collapse in={advancedOpened}>
<Stack gap="md">
<Group gap="xs" justify="space-between" w="100%">
<NumberInput
allowDecimal={false}
decimalScale={0}
defaultValue={0}
hideControls
key={form.key('trafficLimitBytes')}
label="Traffic limit"
leftSection={
<>
<Text
display="flex"
size="0.75rem"
style={{ justifyContent: 'center' }}
ta="center"
w={26}
>
GB
</Text>
<Divider orientation="vertical" />
</>
}
{...form.getInputProps('trafficLimitBytes')}
w="30%"
/>
<NumberInput
key={form.key('trafficResetDay')}
label="Traffic reset day"
{...form.getInputProps('trafficResetDay')}
allowDecimal={false}
allowNegative={false}
clampBehavior="strict"
decimalScale={0}
hideControls
max={31}
min={1}
placeholder="e.g. 1-31"
w="30%"
/>
<NumberInput
key={form.key('notifyPercent')}
label="Notify percent"
{...form.getInputProps('notifyPercent')}
allowDecimal={false}
allowNegative={false}
clampBehavior="strict"
decimalScale={0}
hideControls
max={100}
placeholder="e.g. 50"
w="30%"
/>
</Group>
</Stack>
</Collapse>
</Stack>
</Collapse>
</Stack>
</Group>
</Stack>
<Group gap="xs" justify="space-between" pt={15} w="100%">
<ActionIcon.Group>

View file

@ -53,90 +53,77 @@ export function NodeCardWidget(props: IProps) {
}
return (
<>
<UnstyledButton onClick={handleViewNode} w={'100%'}>
<Container
className={clsx(classes.item, { [classes.itemHover]: hovered })}
fluid
ref={ref}
>
<Group gap="xs">
<NodeStatusBadgeWidget node={node} style={{ cursor: 'pointer' }} />
<UnstyledButton onClick={handleViewNode} w={'100%'}>
<Container
className={clsx(classes.item, { [classes.itemHover]: hovered })}
fluid
ref={ref}
>
<Group gap="xs" grow preventGrowOverflow={false}>
<NodeStatusBadgeWidget node={node} style={{ cursor: 'pointer' }} />
<Badge
autoContrast
color={ch.hex(node.uuid)}
miw={'15ch'}
<Badge
autoContrast
color={ch.hex(node.uuid)}
miw={'15ch'}
radius="md"
size="lg"
style={{ cursor: 'pointer' }}
variant="light"
>
{node.name}
</Badge>
<Text
className={classes.hostInfoLabel}
maw={'22ch'}
miw={'22ch'}
onClick={handleCopy}
style={{ cursor: 'copy' }}
truncate="end"
>
{node.address}
{node.port ? `:${node.port}` : ''}
</Text>
<Badge
autoContrast
color={'gray'}
ff={'monospace'}
miw={'15ch'}
radius="md"
size="lg"
style={{ cursor: 'pointer' }}
variant="outline"
>
{`${prettyUsedData} / ${maxData}`}
</Badge>
{percentage >= 0 && node.isTrafficTrackingActive && (
<Progress
color={percentage > 95 ? 'red.9' : 'green.9'}
radius="md"
size="lg"
style={{ cursor: 'pointer' }}
variant="light"
>
{node.name}
</Badge>
<Text
className={classes.hostInfoLabel}
maw={'22ch'}
miw={'22ch'}
onClick={handleCopy}
style={{ cursor: 'copy' }}
truncate="end"
>
{node.address}
{node.port ? `:${node.port}` : ''}
</Text>
size="25"
striped
value={percentage}
w={'10ch'}
/>
)}
{node.isTrafficTrackingActive && (
<Badge
autoContrast
color={'gray'}
miw={'7ch'}
color="gray"
leftSection={<PiArrowsCounterClockwise size={18} />}
maw={'20ch'}
radius="md"
size="lg"
style={{ cursor: 'pointer' }}
variant="outline"
>
{node.xrayVersion ?? '-'}
{getNodeResetDaysUtil(node.trafficResetDay ?? 1)}
</Badge>
<Badge
autoContrast
color={'gray'}
ff={'monospace'}
miw={'15ch'}
radius="md"
size="lg"
style={{ cursor: 'pointer' }}
variant="outline"
>
{`${prettyUsedData} / ${maxData}`}
</Badge>
{percentage >= 0 && node.isTrafficTrackingActive && (
<Progress
color={percentage > 95 ? 'red.9' : 'green.9'}
radius="md"
size="25"
striped
value={percentage}
w={'10ch'}
/>
)}
{node.isTrafficTrackingActive && (
<Badge
color="gray"
leftSection={<PiArrowsCounterClockwise size={18} />}
radius="md"
size="lg"
style={{ cursor: 'pointer' }}
variant="outline"
>
{getNodeResetDaysUtil(node.trafficResetDay ?? 1)}
</Badge>
)}
</Group>
</Container>
</UnstyledButton>
</>
)}
</Group>
</Container>
</UnstyledButton>
)
}

View file

@ -7,7 +7,6 @@ import {
MRT_SortingState,
useMantineReactTable
} from 'mantine-react-table'
import { useInterval } from '@mantine/hooks'
import { useState } from 'react'
import { UserActionGroupFeature } from '@features/dashboard/users/users-action-group/action-group.feature'

4842
stats.html

File diff suppressed because one or more lines are too long

View file

@ -24,7 +24,7 @@ export default defineConfig({
name: 'Remnawave',
short_name: 'Remnawave',
description: 'Remnawave Dashboard',
theme_color: '#151B22',
theme_color: '#161B23',
icons: [
{
src: 'pwa-64x64.png',