mirror of
https://github.com/remnawave/frontend.git
synced 2026-05-13 12:16:40 +00:00
wip
Co-authored-by: Ivan <84693047+exact01@users.noreply.github.com>
This commit is contained in:
parent
ffc6534c3a
commit
34435c4009
40 changed files with 814 additions and 5883 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -132,4 +132,3 @@ dist
|
|||
.DS_Store
|
||||
.vercel
|
||||
stats.html
|
||||
|
||||
|
|
|
|||
|
|
@ -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
8
package-lock.json
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
1
src/app/layouts/auth/index.ts
Normal file
1
src/app/layouts/auth/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './auth.layout'
|
||||
|
|
@ -28,3 +28,8 @@
|
|||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.logoWrapper {
|
||||
padding: var(--mantine-spacing-xl) var(--mantine-spacing-xl);
|
||||
margin-bottom: var(--mantine-spacing-md);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -1,2 +1,2 @@
|
|||
export * from './root'
|
||||
export * from './sidebar'
|
||||
export * from './root/dashboard.layout'
|
||||
export * from './sidebar/sidebar.layout'
|
||||
|
|
|
|||
1
src/app/layouts/dashboard/mobile-sidebar/index.ts
Normal file
1
src/app/layouts/dashboard/mobile-sidebar/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './mobile-sidebar.layout'
|
||||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
54
src/app/layouts/dashboard/root/dashboard.layout.tsx
Normal file
54
src/app/layouts/dashboard/root/dashboard.layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 }
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export * from './auth-store'
|
||||
export * from './interfaces'
|
||||
|
|
@ -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>
|
||||
}
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export * from './action.interface.js'
|
||||
export * from './state.interface.js'
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
import { LoginCommand } from '@remnawave/backend-contract'
|
||||
|
||||
export interface IState {
|
||||
isLoading: boolean
|
||||
loginResponse: LoginCommand.Response['response'] | null
|
||||
}
|
||||
|
|
@ -66,7 +66,7 @@ export const UserActionGroupFeature = (props: IProps) => {
|
|||
size="xs"
|
||||
variant="default"
|
||||
>
|
||||
Create new user
|
||||
New user
|
||||
</Button>
|
||||
</Group>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import cuid2 from '@paralleldrive/cuid2'
|
||||
import { Grid } from '@mantine/core'
|
||||
|
||||
import { useNodesStoreIsNodesLoading } from '@entitites/dashboard/nodes/nodes-store/nodes-store'
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
export * from './color-scheme-toggler'
|
||||
export * from './loading-screen'
|
||||
export * from './logo'
|
||||
export * from './page'
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export function LoadingScreen({
|
|||
{text && <Text size="lg">{text}</Text>}
|
||||
<Progress
|
||||
animated
|
||||
color="red"
|
||||
color="cyan"
|
||||
maw="32rem"
|
||||
radius="xs"
|
||||
striped
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
4842
stats.html
File diff suppressed because one or more lines are too long
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue