Co-authored-by: Ivan <84693047+exact01@users.noreply.github.com>
This commit is contained in:
kastov 2024-11-30 21:13:21 +03:00
parent 802ffc6240
commit 9968d80b29
20 changed files with 1366 additions and 1002 deletions

View file

@ -1,5 +1,4 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
@ -7,7 +6,8 @@ module.exports = {
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
'plugin:storybook/recommended',
'prettier',
'plugin:perfectionist/recommended-natural-legacy',
'prettier'
],
ignorePatterns: ['dist', '.eslintrc.cjs', 'plop', 'plop/**', 'plopfile.js', '.stylelintrc.js'],
parser: '@typescript-eslint/parser',
@ -15,16 +15,43 @@ module.exports = {
settings: {
'import/parsers': {
'@typescript-eslint/parser': ['.ts', '.tsx'],
'@typescript-eslint/parser': ['.ts', '.tsx']
},
'import/resolver': {
node: true,
typescript: {
project: '.',
},
},
project: '.'
}
}
},
rules: {
'perfectionist/sort-imports': [
'error',
{
type: 'line-length',
order: 'asc',
ignoreCase: true,
specialCharacters: 'keep',
internalPattern: ['^~/.+'],
partitionByComment: false,
partitionByNewLine: false,
newlinesBetween: 'always',
maxLineLength: undefined,
groups: [
'type',
['builtin', 'external'],
'internal-type',
'internal',
['parent-type', 'sibling-type', 'index-type'],
['parent', 'sibling', 'index'],
'object',
'unknown'
],
customGroups: { type: {}, value: {} },
environment: 'node'
}
],
'perfectionist/sort-objects': ['off'],
indent: ['error', 4, { SwitchCase: 1 }],
'max-classes-per-file': 'off',
'import/no-extraneous-dependencies': ['off'],
@ -34,12 +61,6 @@ module.exports = {
'no-bitwise': 'off',
'no-plusplus': 'off',
'no-restricted-syntax': ['off', 'ForInStatement'],
'import/order': [
'error',
{
'newlines-between': 'never',
},
],
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
'no-shadow': ['off'],
'arrow-body-style': ['off'],
@ -52,8 +73,8 @@ module.exports = {
allowAfterThis: true,
allowAfterSuper: true,
allowAfterThisConstructor: true,
enforceInMethodNames: false,
},
enforceInMethodNames: false
}
],
semi: ['error', 'never'],
'comma-dangle': ['off'],
@ -65,9 +86,9 @@ module.exports = {
'error',
{
types: {
'{}': false,
},
},
],
},
'{}': false
}
}
]
}
}

View file

@ -4,22 +4,5 @@
"tabWidth": 4,
"printWidth": 100,
"semi": false,
"trailingComma": "none",
"plugins": [
"@ianvs/prettier-plugin-sort-imports"
],
"importOrderCaseSensitive": false,
"importOrder": [
"^react$",
"",
"^@mantine/(.*)$",
"^@mantinex/(.*)$",
"<THIRD_PARTY_MODULES>",
"^(@)(/.*)$",
"^../(?!.*.css$).*$",
"^./(?!.*.css$).*$",
"\\.css$",
"",
"^[.]"
]
"trailingComma": "none"
}

4
global.d.ts vendored
View file

@ -1,8 +1,10 @@
declare global {
interface Window {
Go: any
XrayParseConfig: (config: string) => string | null
onWasmInitialized?: () => void
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Go: typeof window.Go
}
}

1746
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -51,7 +51,7 @@
"zustand": "^5.0.1"
},
"devDependencies": {
"@eslint/js": "^9.9.1",
"@eslint/js": "^9.16.0",
"@ianvs/prettier-plugin-sort-imports": "^4.3.1",
"@types/byte-size": "^8.1.2",
"@types/bytes": "^3.1.4",
@ -60,21 +60,19 @@
"@types/node": "^20.11.19",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^8.4.0",
"@typescript-eslint/parser": "^8.4.0",
"@typescript-eslint/eslint-plugin": "^7.0.2",
"@typescript-eslint/parser": "^7.0.2",
"@vitejs/plugin-react": "^4.3.1",
"@vitejs/plugin-react-swc": "^3.7.0",
"eslint": "^9.9.1",
"eslint-config-airbnb": "19.0.4",
"eslint-config-airbnb-typescript": "^18.0.0",
"eslint-config-mantine": "4.0.2",
"eslint": "^8.56.0",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-import": "^2.30.0",
"eslint-plugin-jsx-a11y": "^6.10.0",
"eslint-import-resolver-typescript": "^3.6.1",
"eslint-plugin-import": "^2.29.0",
"eslint-plugin-perfectionist": "^4.1.2",
"eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-react": "^7.35.2",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"eslint-plugin-storybook": "^0.8.0",
"identity-obj-proxy": "^3.0.0",
"jsdom": "^25.0.0",
"postcss": "^8.4.45",
@ -85,7 +83,8 @@
"rollup-plugin-visualizer": "^5.12.0",
"stylelint": "^16.9.0",
"stylelint-config-standard-scss": "^13.1.0",
"typescript": "^5.5.4",
"typescript": "^5.7.2",
"typescript-eslint": "^8.16.0",
"vite": "6.0.1",
"vite-plugin-javascript-obfuscator": "^3.1.0",
"vite-plugin-preload": "^0.4.0",

View file

@ -1,16 +1,17 @@
import { GetXrayConfigCommand, UpdateXrayConfigCommand } from '@remnawave/backend-contract'
import { instance } from '@shared/api'
import { create } from '@shared/hocs/store-wrapper'
import { AxiosError } from 'axios'
import { instance } from '@shared/api'
import { devtools } from 'zustand/middleware'
import { create } from '@shared/hocs/store-wrapper'
import { GetXrayConfigCommand, UpdateXrayConfigCommand } from '@remnawave/backend-contract'
import { IActions, IState } from './interfaces'
const initialState: IState = {
isConfigLoading: false,
config: null
config: null,
isConfigLoading: false
}
export const useConfigStore = create<IState & IActions>()(
export const useConfigStore = create<IActions & IState>()(
devtools(
(set, getState) => ({
...initialState,
@ -56,20 +57,20 @@ export const useConfigStore = create<IState & IActions>()(
return false
}
},
setConfig: (config: string) => {
set({ config })
},
getInitialState: () => {
return initialState
},
setConfig: (config: string) => {
set({ config })
},
resetState: async () => {
set({ ...initialState })
}
}
}),
{
name: 'configStore',
anonymousActionType: 'configStore'
anonymousActionType: 'configStore',
name: 'configStore'
}
)
)

View file

@ -1,14 +1,15 @@
import { AxiosError } from 'axios'
import { instance } from '@shared/api'
import { devtools } from 'zustand/middleware'
import { create } from '@shared/hocs/store-wrapper'
import { getUserTimezoneUtil } from '@/shared/utils/time-utils'
import { IInboundsHashMap } from '@/entitites/dashboard/dashboard-store/interfaces/inbounds-hash-map.interface'
import {
GetAllUsersCommand,
GetInboundsCommand,
GetStatsCommand
} from '@remnawave/backend-contract'
import { instance } from '@shared/api'
import { create } from '@shared/hocs/store-wrapper'
import { AxiosError } from 'axios'
import { devtools } from 'zustand/middleware'
import { IInboundsHashMap } from '@/entitites/dashboard/dashboard-store/interfaces/inbounds-hash-map.interface'
import { getUserTimezoneUtil } from '@/shared/utils/time-utils'
import { IActions, IState, IUsersParams } from './interfaces'
const initialState: IState = {
@ -22,13 +23,14 @@ const initialState: IState = {
orderBy: 'createdAt',
orderDir: 'desc'
},
totalUsers: 0,
inbounds: null,
isInboundsLoading: false,
inboundsHashMap: null
}
export const useDashboardStore = create<IState & IActions>()(
export const useDashboardStore = create<IActions & IState>()(
devtools(
(set, getState) => ({
...initialState,

View file

@ -1,9 +1,9 @@
import { useState } from 'react'
import { Button, Group } from '@mantine/core'
import { notifications } from '@mantine/notifications'
import { useNodesStoreActions } from '@entitites/dashboard/nodes/nodes-store/nodes-store'
import { PiArrowsClockwise, PiPlus, PiSpiral } from 'react-icons/pi'
import { useNodesStoreActions } from '@entitites/dashboard/nodes/nodes-store/nodes-store'
import { IProps } from './interfaces'
export const NodesHeaderActionButtonsFeature = (props: IProps) => {
@ -51,31 +51,31 @@ export const NodesHeaderActionButtonsFeature = (props: IProps) => {
return (
<Group>
<Button
variant="default"
size="xs"
leftSection={<PiArrowsClockwise size="1rem" />}
onClick={handleUpdate}
loading={isLoading}
onClick={handleUpdate}
size="xs"
variant="default"
>
Update
</Button>
<Button
variant="default"
size="xs"
c="teal"
leftSection={<PiSpiral size="1rem" />}
onClick={handleRestart}
loading={isRestartLoading}
onClick={handleRestart}
size="xs"
variant="default"
>
Restart all nodes
</Button>
<Button
variant="default"
size="xs"
leftSection={<PiPlus size="1rem" />}
onClick={handleCreate}
size="xs"
variant="default"
>
Create new host
</Button>

View file

@ -1,3 +1 @@
import { GetInboundsCommand } from '@remnawave/backend-contract'
export interface IProps {}

View file

@ -1,12 +1,12 @@
import { useState } from 'react'
import { PiTrashDuotone } from 'react-icons/pi'
import { ActionIcon, Tooltip } from '@mantine/core'
import { notifications } from '@mantine/notifications'
import { PiTrashDuotone } from 'react-icons/pi'
import {
useUserModalStore,
useUserModalStoreUser
} from '@/entitites/dashboard/user-modal-store/user-modal-store'
import { IProps } from './interfaces'
export function DeleteUserFeature(props: IProps) {
@ -40,7 +40,7 @@ export function DeleteUserFeature(props: IProps) {
return (
<Tooltip label="Delete user">
<ActionIcon size="xl" color="red" onClick={handleDeleteUser} loading={isLoading}>
<ActionIcon color="red" loading={isLoading} onClick={handleDeleteUser} size="xl">
<PiTrashDuotone size="1.5rem" />
</ActionIcon>
</Tooltip>

View file

@ -15,11 +15,11 @@ export const LoginPage = () => {
Remnawave
<UnderlineShape
c="red"
w="7rem"
left="0"
pos="absolute"
h="0.625rem"
bottom="-1rem"
w="7rem"
/>
</Text>
</Title>

View file

@ -1,19 +1,19 @@
import axios from 'axios'
import dayjs from 'dayjs'
import { Page } from '@shared/ui/page'
import { useEffect, useRef, useState } from 'react'
import { Box, Button, Code, Group, Paper } from '@mantine/core'
import Editor, { Monaco } from '@monaco-editor/react'
import { notifications } from '@mantine/notifications'
import { LoadingScreen, PageHeader } from '@/shared/ui'
import { Box, Button, Code, Group, Paper } from '@mantine/core'
import { PiCheckSquareOffset, PiFloppyDisk } from 'react-icons/pi'
import { monacoTheme } from '@/shared/utils/monaco-theme/monaco-theme'
import {
useConfigStoreActions,
useConfigStoreConfig,
useConfigStoreIsConfigLoading
} from '@entitites/dashboard/config/config-store/config-store'
import Editor, { Monaco } from '@monaco-editor/react'
import { Page } from '@shared/ui/page'
import axios from 'axios'
import dayjs from 'dayjs'
import { PiCheckSquareOffset, PiFloppyDisk } from 'react-icons/pi'
import { LoadingScreen, PageHeader } from '@/shared/ui'
import { monacoTheme } from '@/shared/utils/monaco-theme/monaco-theme'
import { BREADCRUMBS } from './constant'
export const ConfigPageComponent = () => {
@ -27,8 +27,8 @@ export const ConfigPageComponent = () => {
const isConfigLoading = useConfigStoreIsConfigLoading()
const [downloadProgress, setDownloadProgress] = useState(0)
const editorRef = useRef<any>(null)
const monacoRef = useRef<any>(null)
const editorRef = useRef<unknown>(null)
const monacoRef = useRef<unknown>(null)
useEffect(() => {
actions.getConfig()
@ -43,17 +43,17 @@ export const ConfigPageComponent = () => {
const schema = await response.data
monacoRef.current.languages.json.jsonDefaults.setDiagnosticsOptions({
validate: true,
allowComments: false,
enableSchemaRequest: true,
schemaRequest: 'warning',
schemas: [
{
uri: 'https://xray-config-schema.json',
fileMatch: ['*'],
schema
schema,
uri: 'https://xray-config-schema.json'
}
],
enableSchemaRequest: true,
schemaRequest: 'warning'
validate: true
})
} catch (error) {
console.error('Failed to load JSON schema:', error)
@ -75,6 +75,7 @@ export const ConfigPageComponent = () => {
}
})
// eslint-disable-next-line no-use-before-define
const wasmBytes = await fetchWithProgress('/main.wasm')
const { instance } = await WebAssembly.instantiate(wasmBytes, go.importObject)
@ -104,7 +105,6 @@ export const ConfigPageComponent = () => {
const fetchWithProgress = async (url: string) => {
try {
const response = await axios.get(url, {
responseType: 'arraybuffer',
onDownloadProgress: (progressEvent) => {
if (progressEvent.total) {
const progress = Math.round(
@ -114,7 +114,8 @@ export const ConfigPageComponent = () => {
} else {
setDownloadProgress(100)
}
}
},
responseType: 'arraybuffer'
})
return response.data
@ -125,7 +126,14 @@ export const ConfigPageComponent = () => {
}
const handleSave = () => {
const currentValue = editorRef.current?.getValue()
if (!editorRef.current) return
if (!monacoRef.current) return
if (typeof editorRef.current !== 'object') return
if (typeof monacoRef.current !== 'object') return
if (!('getValue' in editorRef.current)) return
if (typeof editorRef.current.getValue !== 'function') return
const currentValue = editorRef.current.getValue()
if (currentValue) {
setIsSaving(true)
@ -133,17 +141,17 @@ export const ConfigPageComponent = () => {
actions.updateConfig(JSON.parse(currentValue))
notifications.show({
title: 'Success',
color: 'green',
message: 'Config updated successfully. All nodes will be restarted.',
color: 'green'
title: 'Success'
})
} catch (err) {
setResult(`Error: ${(err as Error).message}`)
notifications.show({
title: 'Error',
color: 'red',
message: `Error: ${(err as Error).message}`,
color: 'red'
title: 'Error'
})
} finally {
setTimeout(() => {
@ -154,12 +162,24 @@ export const ConfigPageComponent = () => {
}
const formatDocument = () => {
if (!editorRef.current) return
if (typeof editorRef.current !== 'object') return
if (!('getAction' in editorRef.current)) return
if (typeof editorRef.current.getAction !== 'function') return
editorRef.current.getAction('editor.action.formatDocument').run()
}
const handleValidate = () => {
try {
const currentValue = editorRef.current?.getValue()
if (!editorRef.current) return
if (!monacoRef.current) return
if (typeof editorRef.current !== 'object') return
if (typeof monacoRef.current !== 'object') return
if (!('getValue' in editorRef.current)) return
if (typeof editorRef.current.getValue !== 'function') return
const currentValue = editorRef.current.getValue()
const validationResult = window.XrayParseConfig(currentValue)
setResult(
@ -179,69 +199,69 @@ export const ConfigPageComponent = () => {
}
if (isLoading || isConfigLoading || !config) {
return <LoadingScreen value={downloadProgress} text={`WASM module is loading...`} />
return <LoadingScreen text={`WASM module is loading...`} value={downloadProgress} />
}
return (
<Page title="Config">
<PageHeader title="Xray Config Editor" breadcrumbs={BREADCRUMBS} />
<PageHeader breadcrumbs={BREADCRUMBS} title="Xray Config Editor" />
<Box>
<Paper withBorder p={0} mb="md" radius="xs">
<Paper mb="md" p={0} radius="xs" withBorder>
<Editor
loading={'Loading editor...'}
height="400px"
defaultLanguage="json"
value={JSON.stringify(config, null, 2)}
theme={'GithubDark'}
onChange={handleValidate}
onValidate={handleValidate}
beforeMount={handleEditorDidMount}
defaultLanguage="json"
height="400px"
loading={'Loading editor...'}
onChange={handleValidate}
onMount={(editor, monaco) => {
editorRef.current = editor
monacoRef.current = monaco
handleValidate()
}}
onValidate={handleValidate}
options={{
minimap: { enabled: false },
autoClosingBrackets: 'always',
autoClosingQuotes: 'always',
autoIndent: 'full',
automaticLayout: true,
bracketPairColorization: true,
detectIndentation: true,
folding: true,
foldingStrategy: 'indentation',
fontSize: 14,
formatOnPaste: true,
formatOnType: true,
scrollBeyondLastLine: false,
automaticLayout: true,
quickSuggestions: true,
folding: true,
foldingStrategy: 'indentation',
autoIndent: 'full',
autoClosingBrackets: 'always',
autoClosingQuotes: 'always',
tabSize: 2,
detectIndentation: true,
insertSpaces: true,
bracketPairColorization: true,
guides: {
bracketPairs: true,
indentation: true
}
},
insertSpaces: true,
minimap: { enabled: false },
quickSuggestions: true,
scrollBeyondLastLine: false,
tabSize: 2
}}
theme={'GithubDark'}
value={JSON.stringify(config, null, 2)}
/>
</Paper>
<Group>
<Button
onClick={formatDocument}
mb="md"
leftSection={<PiCheckSquareOffset size={16} />}
mb="md"
onClick={formatDocument}
>
Format
</Button>
<Button
onClick={handleSave}
mb="md"
loading={isSaving}
leftSection={<PiFloppyDisk size={16} />}
disabled={!isConfigValid}
leftSection={<PiFloppyDisk size={16} />}
loading={isSaving}
mb="md"
onClick={handleSave}
>
Save
</Button>

View file

@ -1,24 +1,25 @@
import { Group, JsonInput, SimpleGrid, Stack, Text } from '@mantine/core'
import { Page } from '@shared/ui/page'
import dayjs from 'dayjs'
import {
PiChartBarDuotone,
PiChartDonutDuotone,
PiClockCountdownDuotone,
PiClockDuotone,
PiChartDonutDuotone,
PiClockUserDuotone,
PiCpuDuotone,
PiChartBarDuotone,
PiProhibitDuotone,
PiDevicesDuotone,
PiMemoryDuotone,
PiProhibitDuotone,
PiClockDuotone,
PiPulseDuotone,
PiUsersDuotone
PiUsersDuotone,
PiCpuDuotone
} from 'react-icons/pi'
import { MetricWithIcon } from '@/widgets/dashboard/home/metric-with-icons'
import { SimpleGrid, JsonInput, Group, Stack, Text } from '@mantine/core'
import { LoadingScreen, PageHeader } from '@/shared/ui'
import { prettyBytesUtil } from '@/shared/utils/bytes'
import { MetricWithTrend } from '@/shared/ui/metrics'
import { formatInt } from '@/shared/utils'
import { prettyBytesUtil } from '@/shared/utils/bytes'
import { MetricWithIcon } from '@/widgets/dashboard/home/metric-with-icons'
import { Page } from '@shared/ui/page'
import dayjs from 'dayjs'
import { BREADCRUMBS } from './constant'
import { IProps } from './interfaces'
@ -29,87 +30,87 @@ export const HomePage = (props: IProps) => {
return <LoadingScreen />
}
const { users, memory, stats } = systemInfo
const { memory, stats, users } = systemInfo
const totalRamGB = prettyBytesUtil(memory.total) ?? 0
const usedRamGB = prettyBytesUtil(memory.active) ?? 0
const simpleMetrics = [
{
value: prettyBytesUtil(Number(users.totalTrafficBytes)) ?? 0,
icon: PiChartBarDuotone,
title: 'Total traffic',
value: prettyBytesUtil(Number(users.totalTrafficBytes)) ?? 0,
color: 'green'
},
{
value: `${usedRamGB} / ${totalRamGB}`,
icon: PiMemoryDuotone,
title: 'RAM usage',
value: `${usedRamGB} / ${totalRamGB}`,
color: 'cyan'
},
{
icon: PiClockDuotone,
title: 'System uptime',
value: dayjs.duration(systemInfo.uptime, 'seconds').humanize(false),
title: 'System uptime',
icon: PiClockDuotone,
color: 'gray'
}
]
const usersMetrics = [
{
value: formatInt(users.onlineLastMinute) ?? 0,
icon: PiDevicesDuotone,
title: 'Online users',
value: formatInt(users.onlineLastMinute) ?? 0,
color: 'teal'
},
{
value: formatInt(users.totalUsers) ?? 0,
icon: PiUsersDuotone,
title: 'Total users',
value: formatInt(users.totalUsers) ?? 0,
color: 'blue'
},
{
icon: PiPulseDuotone,
title: 'Active users',
value: formatInt(users.statusCounts.ACTIVE) ?? 0,
title: 'Active users',
icon: PiPulseDuotone,
color: 'teal'
},
{
value: formatInt(users.statusCounts.EXPIRED) ?? 0,
icon: PiClockUserDuotone,
title: 'Expired users',
value: formatInt(users.statusCounts.EXPIRED) ?? 0,
color: 'red'
},
{
value: formatInt(users.statusCounts.LIMITED) ?? 0,
icon: PiClockCountdownDuotone,
title: 'Limited users',
value: formatInt(users.statusCounts.LIMITED) ?? 0,
color: 'orange'
},
{
value: formatInt(users.statusCounts.DISABLED) ?? 0,
icon: PiProhibitDuotone,
title: 'Disabled users',
value: formatInt(users.statusCounts.DISABLED) ?? 0,
color: 'gray'
}
]
return (
<Page title="Home">
<PageHeader title="Short stats" breadcrumbs={BREADCRUMBS} />
<PageHeader breadcrumbs={BREADCRUMBS} title="Short stats" />
<Stack gap="sm" mb="xl">
<Text fw={600}>Bandwidth</Text>
<SimpleGrid cols={{ base: 1, sm: 2, xl: 3 }}>
<MetricWithTrend
title="Today's nodes usage"
icon={
<PiChartDonutDuotone color="var(--mantine-color-blue-6)" size="2rem" />
}
percentage={stats.nodesUsageLastTwoDays.percentage}
value={stats.nodesUsageLastTwoDays.current}
color="var(--mantine-color-blue-6)"
percentage={stats.nodesUsageLastTwoDays.percentage}
title="Today's nodes usage"
period="from yesterday"
icon={
<PiChartDonutDuotone size="2rem" color="var(--mantine-color-blue-6)" />
}
/>
</SimpleGrid>

View file

@ -1,21 +1,22 @@
import { Grid } from '@mantine/core'
import { useHostsStoreIsHostsLoading } from '@entitites/dashboard'
import { CreateHostModalWidget } from '@widgets/dashboard/hosts/create-host-modal'
import { EditHostModalWidget } from '@widgets/dashboard/hosts/edit-host-modal'
import { HostsPageHeaderWidget } from '@widgets/dashboard/hosts/hosts-page-header'
import { LoadingScreen, Page, PageHeader } from '@/shared/ui'
import { EditHostModalWidget } from '@widgets/dashboard/hosts/edit-host-modal'
import { HostsTableWidget } from '@/widgets/dashboard/hosts/hosts-table'
import { useHostsStoreIsHostsLoading } from '@entitites/dashboard'
import { LoadingScreen, PageHeader, Page } from '@/shared/ui'
import { Grid } from '@mantine/core'
import { BREADCRUMBS } from './constants'
import { IProps } from './interfaces'
export default function HostsPageComponent(props: IProps) {
const { hosts, inbounds } = props
const { inbounds, hosts } = props
const isHostsLoading = useHostsStoreIsHostsLoading()
return (
<Page title="Hosts">
<PageHeader title="Hosts" breadcrumbs={BREADCRUMBS} />
<PageHeader breadcrumbs={BREADCRUMBS} title="Hosts" />
<Grid>
<Grid.Col span={12}>
@ -24,7 +25,7 @@ export default function HostsPageComponent(props: IProps) {
{isHostsLoading ? (
<LoadingScreen height="60vh" />
) : (
<HostsTableWidget hosts={hosts} inbounds={inbounds} />
<HostsTableWidget inbounds={inbounds} hosts={hosts} />
)}
</Grid.Col>
</Grid>

View file

@ -1,5 +1,3 @@
import { useEffect } from 'react'
import {
useDashboardStoreActions,
useDSInbounds
@ -9,6 +7,7 @@ import {
useHostsStoreHosts
} from '@entitites/dashboard/hosts/hosts-store/hosts-store'
import HostsPageComponent from '@pages/dashboard/hosts/ui/components/hosts.page.component'
import { useEffect } from 'react'
export function HostsPageConnector() {
const actions = useHostsStoreActions()

View file

@ -2,26 +2,25 @@ import { Center, Progress, Stack, Text } from '@mantine/core'
export function LoadingScreen({
height = '100%',
value = 100,
text = undefined
text = undefined,
value = 100
}: {
height?: string
value?: number
text?: string
value?: number
}) {
return (
<Center h={height}>
<Stack w="100%" align="center" gap="xs">
<Stack align="center" gap="xs" w="100%">
{text && <Text size="lg">{text}</Text>}
<Progress
color="red"
radius="xs"
value={value}
striped
w="80%"
animated
color="red"
maw="32rem"
radius="xs"
striped
value={value}
w="80%"
/>
</Stack>
</Center>

View file

@ -1,5 +1,7 @@
import { useEffect, useState } from 'react'
import { useDSInbounds } from '@/entitites/dashboard/dashboard-store/dashboard-store'
import { handleFormErrors } from '@/shared/utils'
import { useHostsStoreActions, useHostsStoreCreateModalIsOpen } from '@entitites/dashboard'
import { DeleteHostFeature } from '@features/ui/dashboard/hosts/delete-host'
import {
ActionIcon,
Button,
@ -15,13 +17,11 @@ import {
} from '@mantine/core'
import { useForm, zodResolver } from '@mantine/form'
import { notifications } from '@mantine/notifications'
import { useHostsStoreActions, useHostsStoreCreateModalIsOpen } from '@entitites/dashboard'
import { DeleteHostFeature } from '@features/ui/dashboard/hosts/delete-host'
import { ALPN, CreateHostCommand, FINGERPRINTS } from '@remnawave/backend-contract'
import { useState } from 'react'
import { PiCaretDown, PiCaretUp, PiFloppyDiskDuotone } from 'react-icons/pi'
import { z } from 'zod'
import { useDSInbounds } from '@/entitites/dashboard/dashboard-store/dashboard-store'
import { handleFormErrors } from '@/shared/utils'
import { RemarkInfoPopoverWidget } from '../popovers/remark-info/remark-info.widget'
export const CreateHostModalWidget = () => {
@ -35,11 +35,20 @@ export const CreateHostModalWidget = () => {
const [isDataSubmitting, setIsDataSubmitting] = useState(false)
const form = useForm<CreateHostCommand.Request>({
name: 'create-host-form',
mode: 'uncontrolled',
name: 'create-host-form',
validate: zodResolver(CreateHostCommand.RequestSchema)
})
const handleClose = () => {
actions.toggleCreateModal(false)
setAdvancedOpened(false)
form.reset()
form.resetDirty()
form.resetTouched()
}
const handleSubmit = form.onSubmit(async (values) => {
try {
if (!values.inboundUuid) {
@ -55,9 +64,9 @@ export const CreateHostModalWidget = () => {
})
notifications.show({
title: 'Success',
color: 'green',
message: 'Host created successfully',
color: 'green'
title: 'Success'
})
} catch (error) {
if (error instanceof z.ZodError) {
@ -69,9 +78,9 @@ export const CreateHostModalWidget = () => {
}
handleFormErrors(form, error)
notifications.show({
title: 'Error',
color: 'red',
message: error instanceof Error ? error.message : 'Failed to create host',
color: 'red'
title: 'Error'
})
} finally {
setIsDataSubmitting(false)
@ -80,21 +89,12 @@ export const CreateHostModalWidget = () => {
}
})
const handleClose = () => {
actions.toggleCreateModal(false)
setAdvancedOpened(false)
form.reset()
form.resetDirty()
form.resetTouched()
}
return (
<Modal
opened={isModalOpen}
onClose={handleClose}
title={<Text fw={500}>Create host</Text>}
centered
onClose={handleClose}
opened={isModalOpen}
title={<Text fw={500}>Create host</Text>}
>
<form onSubmit={handleSubmit}>
<Group align="flex-start" grow={false}>
@ -102,70 +102,69 @@ export const CreateHostModalWidget = () => {
<Group gap="xs" justify="space-between" w="100%"></Group>
<TextInput
label="Remark"
key={form.key('remark')}
{...form.getInputProps('remark')}
label="Remark"
leftSection={<RemarkInfoPopoverWidget />}
placeholder="e.g. 📊 {{TRAFFIC_USED}}"
required
leftSection={<RemarkInfoPopoverWidget />}
{...form.getInputProps('remark')}
/>
<Stack gap="md" w={400}>
<Group gap="xs" w="100%" justify="space-between">
<Group gap="xs" justify="space-between" w="100%">
<TextInput
label="Address"
key={form.key('address')}
label="Address"
{...form.getInputProps('address')}
placeholder="e.g. example.com"
w="75%"
required
w="75%"
/>
<NumberInput
label="Port"
key={form.key('port')}
label="Port"
{...form.getInputProps('port')}
min={1}
hideControls
allowNegative={false}
allowDecimal={false}
decimalScale={0}
allowNegative={false}
clampBehavior="strict"
decimalScale={0}
hideControls
max={65535}
min={1}
placeholder="e.g. 443"
w="20%"
required
w="20%"
/>
</Group>
<Group gap="xs" w="100%" justify="space-between">
<Group gap="xs" justify="space-between" w="100%">
<Select
label="Inbound"
key={form.key('inboundUuid')}
data={Object.values(inbounds ?? {}).map((inbound) => ({
label: inbound.tag,
value: inbound.uuid
}))}
key={form.key('inboundUuid')}
label="Inbound"
{...form.getInputProps('inboundUuid')}
placeholder="Select inbound"
w="75%"
allowDeselect={false}
placeholder="Select inbound"
required
w="75%"
/>
<Switch
w="20%"
size="xl"
radius="md"
color="teal.8"
mt={25}
key={form.key('isDisabled')}
mt={25}
radius="md"
size="xl"
w="20%"
{...form.getInputProps('isDisabled', { type: 'checkbox' })}
/>
</Group>
<Button
variant="subtle"
onClick={() => setAdvancedOpened((o) => !o)}
rightSection={
advancedOpened ? (
@ -174,6 +173,7 @@ export const CreateHostModalWidget = () => {
<PiCaretDown size="1rem" />
)
}
variant="subtle"
>
Advanced options
</Button>
@ -181,47 +181,47 @@ export const CreateHostModalWidget = () => {
<Collapse in={advancedOpened}>
<Stack gap="md">
<TextInput
key={form.key('sni')}
label="SNI"
placeholder="SNI (e.g. example.com)"
key={form.key('sni')}
{...form.getInputProps('sni')}
/>
<TextInput
label="Request Host"
key={form.key('requestHost')}
label="Request Host"
placeholder="Host (e.g. example.com)"
{...form.getInputProps('requestHost')}
/>
<TextInput
label="Path"
key={form.key('path')}
label="Path"
placeholder="path (e.g. /ws)"
{...form.getInputProps('path')}
/>
<Select
label="ALPN"
placeholder="ALPN (e.g. h2)"
key={form.key('alpn')}
clearable
data={Object.values(ALPN).map((alpn) => ({
label: alpn,
value: alpn
}))}
clearable
key={form.key('alpn')}
label="ALPN"
placeholder="ALPN (e.g. h2)"
{...form.getInputProps('alpn')}
/>
<Select
label="Fingerprint"
key={form.key('fingerprint')}
placeholder="Fingerprint (e.g. chrome)"
clearable
data={Object.values(FINGERPRINTS).map((fingerprint) => ({
label: fingerprint,
value: fingerprint
}))}
clearable
key={form.key('fingerprint')}
label="Fingerprint"
placeholder="Fingerprint (e.g. chrome)"
{...form.getInputProps('fingerprint')}
/>
</Stack>
@ -230,18 +230,18 @@ export const CreateHostModalWidget = () => {
</Stack>
</Group>
<Group gap="xs" w="100%" pt={15} justify="space-between">
<Group gap="xs" justify="space-between" pt={15} w="100%">
<ActionIcon.Group>
<DeleteHostFeature />
</ActionIcon.Group>
<Button
type="submit"
color="blue"
leftSection={<PiFloppyDiskDuotone size="1rem" />}
variant="outline"
size="md"
loading={isDataSubmitting}
size="md"
type="submit"
variant="outline"
>
Save
</Button>

View file

@ -1,35 +1,35 @@
import { useEffect } from 'react'
import { useListState } from '@mantine/hooks'
import { DragDropContext, Droppable, DropResult } from '@hello-pangea/dnd'
import { useHostsStoreActions } from '@/entitites/dashboard'
import { HostCardWidget } from '@/widgets/dashboard/hosts/host-card'
import { DragDropContext, Droppable, DropResult } from '@hello-pangea/dnd'
import { useListState } from '@mantine/hooks'
import { useEffect } from 'react'
import { IProps } from './interfaces'
export function HostsTableWidget(props: IProps) {
const { hosts, inbounds } = props
if (!hosts || !inbounds) {
return null
}
const actions = useHostsStoreActions()
const [state, handlers] = useListState(hosts)
const [state, handlers] = useListState(hosts || [])
const handleDragEnd = async (result: DropResult) => {
const { source, destination } = result
const { destination, source } = result
handlers.reorder({ from: source.index, to: destination?.index || 0 })
}
useEffect(() => {
;(async () => {
if (!hosts || !state) {
return
}
const updatedHosts = hosts.map((host) => ({
uuid: host.uuid,
viewPosition: state.findIndex((stateItem) => stateItem.uuid === host.uuid)
}))
// Проверяем, изменился ли порядок
const hasOrderChanged = hosts.some((host, index) => host.uuid !== state[index].uuid)
const hasOrderChanged = hosts?.some((host, index) => host.uuid !== state[index].uuid)
if (hasOrderChanged) {
await actions.reorderHosts(updatedHosts)
@ -37,13 +37,17 @@ export function HostsTableWidget(props: IProps) {
})()
}, [state])
if (!hosts || !inbounds) {
return null
}
return (
<DragDropContext onDragEnd={handleDragEnd}>
<Droppable droppableId="dnd-list" direction="vertical">
<Droppable direction="vertical" droppableId="dnd-list">
{(provided) => (
<div {...provided.droppableProps} ref={provided.innerRef}>
{state.map((item, index) => (
<HostCardWidget item={item} index={index} />
<HostCardWidget index={index} item={item} />
))}
{provided.placeholder}
</div>

View file

@ -1,15 +1,16 @@
import { Badge, Container, Group, Progress, Text, UnstyledButton } from '@mantine/core'
import { UnstyledButton, Container, Progress, Badge, Group, Text } from '@mantine/core'
import { useNodesStoreActions } from '@entitites/dashboard/nodes'
import { getNodeResetDaysUtil } from '@/shared/utils/time-utils'
import { prettyBytesToAnyUtil } from '@/shared/utils/bytes'
import { PiArrowsCounterClockwise } from 'react-icons/pi'
import { useClipboard, useHover } from '@mantine/hooks'
import { notifications } from '@mantine/notifications'
import { useNodesStoreActions } from '@entitites/dashboard/nodes'
import clsx from 'clsx'
import ColorHash from 'color-hash'
import { PiArrowsCounterClockwise } from 'react-icons/pi'
import { prettyBytesToAnyUtil } from '@/shared/utils/bytes'
import { getNodeResetDaysUtil } from '@/shared/utils/time-utils'
import clsx from 'clsx'
import { NodeStatusBadgeWidget } from '../node-status-badge'
import { IProps } from './interfaces'
import classes from './NodeCard.module.css'
import { IProps } from './interfaces'
export function NodeCardWidget(props: IProps) {
const { node } = props
@ -39,8 +40,8 @@ export function NodeCardWidget(props: IProps) {
e.stopPropagation()
clipboard.copy(`${node.address}`)
notifications.show({
title: 'Copied',
message: `${node.address}`,
title: 'Copied',
color: 'teal'
})
}
@ -52,59 +53,59 @@ export function NodeCardWidget(props: IProps) {
return (
<>
<UnstyledButton w={'100%'} onClick={handleViewNode}>
<UnstyledButton onClick={handleViewNode} w={'100%'}>
<Container
className={clsx(classes.item, { [classes.itemHover]: hovered })}
ref={ref}
fluid
className={clsx(classes.item, { [classes.itemHover]: hovered })}
>
<Group gap="xs">
<NodeStatusBadgeWidget style={{ cursor: 'pointer' }} node={node} />
<Badge
miw={'15ch'}
size="lg"
autoContrast
variant="light"
radius="md"
style={{ cursor: 'pointer' }}
color={ch.hex(node.uuid)}
variant="light"
miw={'15ch'}
autoContrast
radius="md"
size="lg"
>
{node.name}
</Badge>
<Text
miw={'22ch'}
className={classes.hostInfoLabel}
maw={'22ch'}
truncate="end"
style={{ cursor: 'copy' }}
onClick={handleCopy}
truncate="end"
miw={'22ch'}
maw={'22ch'}
>
{node.address}
{node.port ? `:${node.port}` : ''}
</Text>
<Badge
miw={'7ch'}
size="lg"
autoContrast
style={{ cursor: 'pointer' }}
variant="outline"
radius="md"
color={'gray'}
autoContrast
miw={'7ch'}
radius="md"
size="lg"
>
{node.xrayVersion ?? '-'}
</Badge>
<Badge
miw={'15ch'}
color={'gray'}
size="lg"
autoContrast
style={{ cursor: 'pointer' }}
variant="outline"
radius="md"
ff={'monospace'}
color={'gray'}
miw={'15ch'}
autoContrast
radius="md"
size="lg"
>
{`${prettyUsedData} / ${maxData}`}
</Badge>
@ -112,22 +113,22 @@ export function NodeCardWidget(props: IProps) {
{percentage >= 0 && node.isTrafficTrackingActive && (
<Progress
color={percentage > 95 ? 'red.9' : 'green.9'}
striped
radius="md"
size="25"
value={percentage}
radius="md"
w={'10ch'}
size="25"
striped
/>
)}
{node.isTrafficTrackingActive && (
<Badge
leftSection={<PiArrowsCounterClockwise size={18} />}
style={{ cursor: 'pointer' }}
variant="outline"
color="gray"
radius="md"
size="lg"
style={{ cursor: 'pointer' }}
variant="outline"
leftSection={<PiArrowsCounterClockwise size={18} />}
>
{getNodeResetDaysUtil(node.trafficResetDay ?? 1)}
</Badge>

View file

@ -6,6 +6,7 @@ import {
PiWarningCircle,
PiWarningCircleDuotone
} from 'react-icons/pi'
import { IProps } from './interface'
export function NodeStatusBadgeWidget(props: IProps) {
@ -34,7 +35,7 @@ export function NodeStatusBadgeWidget(props: IProps) {
}
return (
<Badge color={color} leftSection={icon} size="lg" miw={'18ch'} {...props}>
<Badge color={color} leftSection={icon} miw={'18ch'} size="lg" {...props}>
{status}
</Badge>
)