mirror of
https://github.com/remnawave/frontend.git
synced 2026-05-13 04:09:03 +00:00
wip
Co-authored-by: Ivan <84693047+exact01@users.noreply.github.com>
This commit is contained in:
parent
802ffc6240
commit
9968d80b29
20 changed files with 1366 additions and 1002 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
19
.prettierrc
19
.prettierrc
|
|
@ -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
4
global.d.ts
vendored
|
|
@ -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
1746
package-lock.json
generated
File diff suppressed because it is too large
Load diff
25
package.json
25
package.json
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -1,3 +1 @@
|
|||
import { GetInboundsCommand } from '@remnawave/backend-contract'
|
||||
|
||||
export interface IProps {}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue