feat: added server workflow

This commit is contained in:
Home
2025-10-11 19:24:30 +03:00
parent 894dd4da95
commit 781d7439af
25 changed files with 6106 additions and 147 deletions

1
client/.npmrc Normal file
View File

@@ -0,0 +1 @@
public-hoist-pattern[]=*@heroui/*

View File

@@ -5,6 +5,7 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>client</title>
<link href="/src/style.css" rel="stylesheet">
</head>
<body>
<div id="root"></div>

3987
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -13,14 +13,19 @@
"preview": "vite preview"
},
"dependencies": {
"@heroui/react": "^2.8.5",
"@reduxjs/toolkit": "^2.9.0",
"@tailwindcss/vite": "^4.1.14",
"axios": "^1.12.2",
"clsx": "^2.1.1",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"framer-motion": "^12.23.24",
"motion": "^12.23.24",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.64.0",
"react-redux": "^9.2.0",
"react-router": "^7.9.3"
"react-router": "^7.9.3",
"tailwindcss": "^4.1.14"
},
"devDependencies": {
"@eslint/js": "^9.36.0",

View File

@@ -0,0 +1,33 @@
import {addToast, type ToastProps} from "@heroui/toast";
import { useEffect } from 'react';
import {
appNotificator,
type Notice,
type NoticeType,
} from '../../../utils/notification/app-notificator';
const colorMap = new Map<NoticeType, string>([
['success', 'Success'],
['error', 'Danger'],
['warn', 'Warning'],
]);
const paramsMappers = (notice: Notice): Partial<ToastProps> => {
const { type, message } = notice;
const color = colorMap.get(type);
return {
description: message,
color: color?.toLowerCase() as ToastProps['color'],
};
};
export const ApplyNotificator = () => {
useEffect(() => {
appNotificator.applyProvider({
paramsMappers,
show: (params: Partial<ToastProps>) => addToast(params),
});
}, []);
return <></>;
};

View File

@@ -0,0 +1 @@
export * from './apply-notificator'

View File

@@ -1,7 +1,7 @@
import { useForm, type SubmitHandler } from 'react-hook-form';
import { createServerAction } from '../../duck';
import { useAppDispatch } from '../../../../common/hooks';
import type { CreateServerForm } from './types';
import type { CreateServerForm } from '../../types';

View File

@@ -0,0 +1,94 @@
import { useEffect, type FC } from 'react';
import { useForm } from 'react-hook-form';
import {
Modal,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
Button,
} from '@heroui/react';
import type { CreateServerForm } from '../../types';
import { getServer } from '../../duck/api';
import { useAppDispatch } from '../../../../common/hooks';
import { updateServer } from '../../duck';
export interface ServerEditProps {
serverId: string;
isOpen: boolean;
onOpenChange: () => void;
}
export const ServerEdit: FC<ServerEditProps> = (props) => {
const dispatch = useAppDispatch();
const { serverId, isOpen, onOpenChange } = props;
const { register, handleSubmit, reset } = useForm<CreateServerForm>();
useEffect(() => {
getServer(serverId).then((response) => {
const { data } = response;
reset({
...data,
grpc_port: String(data.grpc_port),
});
});
}, [serverId]);
const onSubmit = (values: CreateServerForm) => {
const data = {
...values,
grpc_port: parseInt(values.grpc_port),
};
dispatch(
updateServer({
id: serverId,
server: data,
}),
).then(() => {
onOpenChange();
});
};
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<form onSubmit={handleSubmit(onSubmit)}>
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">
Modal Title
</ModalHeader>
<ModalBody>
<div className="form-group">
<label>Name:</label>
<input {...register('name', { required: true })} />
</div>
<div className="form-group">
<label>Hostname:</label>
<input {...register('hostname', { required: true })} />
</div>
<div className="form-group">
<label>gRPC Port:</label>
<input
type="number"
{...register('grpc_port', { required: true })}
/>
</div>
</ModalBody>
<ModalFooter>
<Button color="primary" type="submit">
Save
</Button>
<Button color="danger" variant="light" onPress={onClose}>
Close
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</form>
</Modal>
);
};

View File

@@ -0,0 +1,60 @@
import type { FC } from 'react';
import { useDisclosure } from '@heroui/react';
import { deleteServerAction, type ServerDTO } from '../../duck';
import { testServer } from '../../duck/api';
import { appNotificator } from '../../../../utils/notification/app-notificator';
import { useAppDispatch } from '../../../../common/hooks';
import { ServerEdit } from './server-edit';
export interface ServerViewProps {
server: ServerDTO;
}
export const ServerView: FC<ServerViewProps> = ({ server }) => {
const dispatch = useAppDispatch();
const handleTestServer = () => {
testServer(server.id).then((result) => {
const { connected } = result.data;
appNotificator.add({
message: connected ? 'Connection OK' : 'Connection failed',
type: connected ? 'success' : 'error',
});
});
};
const handleDeleteServer = () => {
if (confirm('Delete server?')) {
dispatch(deleteServerAction(server.id));
}
};
const { isOpen, onOpen, onOpenChange } = useDisclosure();
return (
<>
<tr>
<td>{server.name}</td>
<td>{server.hostname}</td>
<td>{server.grpc_port}</td>
<td>{server.status}</td>
<td>
<button className="btn btn-success" onClick={handleTestServer}>
Test
</button>
<button className="btn btn-primary" onClick={onOpen}>
Edit
</button>
<button className="btn btn-danger" onClick={handleDeleteServer}>
Delete
</button>
</td>
</tr>
<ServerEdit
serverId={server.id}
isOpen={isOpen}
onOpenChange={onOpenChange}
/>
</>
);
};

View File

@@ -1,5 +1,6 @@
import type { FC } from 'react';
import type { ServerDTO } from '../../duck';
import { ServerView } from './server-view';
export interface ServersListProps {
servers: ServerDTO[];
@@ -10,41 +11,20 @@ export const ServersList: FC<ServersListProps> = (props) => {
return (
<table>
<tr>
<th>Name</th>
<th>Hostname</th>
<th>Port</th>
<th>Status</th>
<th>Actions</th>
</tr>
{servers.map((s) => (
<thead>
<tr>
<td>{s.name}</td>
<td>{s.hostname}</td>
<td>{s.grpc_port}</td>
<td>{s.status}</td>
<td>
<button
className="btn btn-success"
// onclick="testServer('${s.id}')"
>
Test
</button>
<button
className="btn btn-primary"
// onclick="editServer('${s.id}')"
>
Edit
</button>
<button
className="btn btn-danger"
// onclick="deleteServer('${s.id}')"
>
Delete
</button>
</td>
<th>Name</th>
<th>Hostname</th>
<th>Port</th>
<th>Status</th>
<th>Actions</th>
</tr>
))}
</thead>
<tbody>
{servers.map((server) => (
<ServerView key={server.id} server={server} />
))}
</tbody>
</table>
);
};

View File

@@ -1,12 +1,12 @@
import { serversSlice } from './slice';
import { createServer, getServers } from './api';
import { createServer, deleteServer, getServers, patchServer } from './api';
import { createAsyncThunk } from '@reduxjs/toolkit';
import { getServersState } from './selectors';
import type { RootState } from '../../../store';
import type { CreateServerDTO } from './dto';
import { appNotificator } from '../../../utils/notification/app-notificator';
const PREFFIX = 'servers'
const PREFFIX = 'servers';
export const fetchServers = createAsyncThunk(
`${PREFFIX}/fetchAll`,
@@ -20,7 +20,6 @@ export const fetchServers = createAsyncThunk(
dispatch(serversSlice.actions.setLoading(true));
const response = await getServers().then(({ data }) => data);
dispatch(serversSlice.actions.setServers(response));
} catch (e) {
const message =
e instanceof Error ? e.message : `Unknown error in ${PREFFIX}/fetchAll`;
@@ -34,14 +33,67 @@ export const fetchServers = createAsyncThunk(
export const createServerAction = createAsyncThunk(
`${PREFFIX}/createServer`,
async (params: CreateServerDTO, { dispatch }) => {
try{
await createServer(params)
dispatch(fetchServers())
} catch(e){
try {
await createServer(params);
dispatch(fetchServers());
} catch (e) {
appNotificator.add({
message: e instanceof Error ? e.message : `Unknown error in ${PREFFIX}/createServer`,
type: 'error'
})
message:
e instanceof Error
? e.message
: `Unknown error in ${PREFFIX}/createServer`,
type: 'error',
});
}
}
)
},
);
export const deleteServerAction = createAsyncThunk(
`${PREFFIX}/deleteServer`,
async (id: string, { dispatch }) => {
try {
await deleteServer(id);
appNotificator.add({
message: 'Server deleted',
type: 'success',
});
dispatch(fetchServers());
} catch (e) {
appNotificator.add({
type: 'error',
message:
e instanceof Error
? `Delete error: ${e.message}`
: `Unknown error in ${PREFFIX}/deleteServer`,
});
}
},
);
export const updateServer = createAsyncThunk(
`${PREFFIX}/updateServer`,
async (
params: {
id: string;
server: CreateServerDTO;
},
{ dispatch },
) => {
try {
await patchServer(params.id, params.server);
dispatch(fetchServers());
appNotificator.add({
message: 'Server updated',
type: 'success',
});
} catch (e) {
appNotificator.add({
type: 'error',
message:
e instanceof Error
? `Error updating: ${e.message}`
: `Unknown error in ${PREFFIX}/deleteServer`,
});
}
},
);

View File

@@ -1,13 +1,28 @@
import type { AxiosResponse } from 'axios';
import { api } from '../../../api/api';
import type { ServerDTO, CreateServerDTO } from './dto';
import type { ServerDTO, CreateServerDTO, TestServerDTO } from './dto';
export const getServers = () =>
api.get<never, AxiosResponse<ServerDTO[]>>('/servers');
export const createServer = (params: CreateServerDTO ) =>
export const createServer = (params: CreateServerDTO) =>
api.post<AxiosResponse>('servers', params, {
headers: {
'Content-Type': 'application/json',
},
});
export const testServer = (id: string) =>
api.post<TestServerDTO>(`/servers/${id}/test`);
export const deleteServer = (id: string) => api.delete(`/servers/${id}`);
export const getServer = (id: string) =>
api.get<string, AxiosResponse<ServerDTO>>(`/servers/${id}`);
export const patchServer = (id: string, server: CreateServerDTO) =>
api.put(`/servers/${id}`, server, {
headers: { 'Content-Type': 'application/json' },
});

View File

@@ -1,5 +1,5 @@
export interface ServerDTO {
id: number | string
id: string
name: string
hostname: string
grpc_port: number
@@ -11,3 +11,8 @@ export interface CreateServerDTO {
hostname: string;
grpc_port: number;
}
export interface TestServerDTO {
connected: boolean,
endpoint: string
}

View File

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

2
client/src/hero.ts Normal file
View File

@@ -0,0 +1,2 @@
import { heroui } from "@heroui/react";
export default heroui();

View File

@@ -0,0 +1,5 @@
@import "tailwindcss";
@plugin './hero.ts';
@source '../node_modules/@heroui/theme/dist/**/*.{js,ts,jsx,tsx}';
@custom-variant dark (&:is(.dark *));

View File

@@ -3,13 +3,20 @@ import { createRoot } from 'react-dom/client';
import { RouterProvider } from 'react-router/dom';
import { store } from './store/store';
import { Provider } from 'react-redux';
import { HeroUIProvider } from '@heroui/react';
import {ToastProvider} from "@heroui/toast";
import { router } from './router';
import './index.css';
import { ApplyNotificator } from './common/components/apply-notificator';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<Provider store={store}>
<RouterProvider router={router} />
<HeroUIProvider>
<RouterProvider router={router} />
<ToastProvider/>
<ApplyNotificator/>
</HeroUIProvider>
</Provider>
</StrictMode>,
);

View File

@@ -7,26 +7,13 @@ export const Home = () => {
return (
<div>
<div className="container">
<h1>Xray Admin Panel - Test Interface</h1>
<h1 className="text-3xl font-bold underline">Xray Admin Panel - Test Interface</h1>
{/* <!-- Toast notifications container --> */}
<div className="toast-container" id="toastContainer"></div>
<NavMenu items={navItems} />
{/* <!-- Dashboard --> */}
<Outlet />
{/* <!-- Servers --> */}
{/* <!-- Templates --> */}
{/* <!-- Certificates --> */}
{/* <!-- Server Inbounds --> */}
{/* <!-- Users --> */}
</div>
{/* <!-- Modal dialogs --> */}

View File

@@ -9,8 +9,15 @@ export interface Notificator {
remove: (id: number) => void;
getAll: () => Notice[];
}
export interface INotificatorViewProvider<T = Record<string, unknown>> {
paramsMappers: (notice: Notice) => T;
show: (params: T) => void;
}
class AppNotificator implements Notificator {
public list: Map<number, Notice> = new Map();
private viewProvider: INotificatorViewProvider | null = null;
add = (notice: Notice) => {
const id = Date.now();
@@ -20,6 +27,12 @@ class AppNotificator implements Notificator {
return id;
};
applyProvider = <T extends Record<string, unknown>>(
provider: INotificatorViewProvider<T>,
) => {
(this.viewProvider as INotificatorViewProvider<T>) = provider;
};
remove = (id: number) => {
this.list.delete(id);
};
@@ -29,11 +42,16 @@ class AppNotificator implements Notificator {
};
show = (notice: Notice, id?: number) => {
// TODO
alert(JSON.stringify(notice));
if (this.viewProvider) {
const { paramsMappers, show } = this.viewProvider;
const params = paramsMappers(notice);
show(params);
} else {
alert(JSON.stringify(notice));
}
if (id) {
setTimeout(() => {
this.remove(id)
this.remove(id);
}, 300);
}
};

14
client/tailwind.config.js Normal file
View File

@@ -0,0 +1,14 @@
// tailwind.config.js
const {heroui} = require("@heroui/react");
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./node_modules/@heroui/theme/dist/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
darkMode: "class",
plugins: [heroui()],
};

View File

@@ -24,5 +24,5 @@
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
"include": ["src", "hero.ts"]
}

View File

@@ -1,7 +1,11 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
plugins: [
tailwindcss(),
react(),
],
})

1832
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

5
package.json Normal file
View File

@@ -0,0 +1,5 @@
{
"dependencies": {
"@fluentui/react-components": "^9.72.2"
}
}