mirror of
https://github.com/house-of-vanity/OutFleet.git
synced 2025-12-17 01:37:57 +00:00
Compare commits
10 Commits
RUST
...
rust-react
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
743ca72965 | ||
|
|
f572b28711 | ||
|
|
781d7439af | ||
|
|
894dd4da95 | ||
|
|
45c21cca82 | ||
|
|
de6f4bc6f9 | ||
|
|
d264968cc8 | ||
|
|
1a42dc9d4c | ||
|
|
bfa2878109 | ||
|
|
8472e21955 |
3
client/.env
Normal file
3
client/.env
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
VITE_API_BASE=/api
|
||||||
|
VITE_API_HOST=http://localhost
|
||||||
|
VITE_API_PORT=8081
|
||||||
3
client/.env.example
Normal file
3
client/.env.example
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
VITE_API_BASE=/api
|
||||||
|
VITE_API_HOST=https://localhost
|
||||||
|
VITE_API_PORT=8081
|
||||||
24
client/.gitignore
vendored
Normal file
24
client/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
1
client/.npmrc
Normal file
1
client/.npmrc
Normal file
@@ -0,0 +1 @@
|
|||||||
|
public-hoist-pattern[]=*@heroui/*
|
||||||
23
client/.prettierrc
Normal file
23
client/.prettierrc
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"arrowParens": "always",
|
||||||
|
"bracketSameLine": false,
|
||||||
|
"objectWrap": "preserve",
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"semi": true,
|
||||||
|
"experimentalOperatorPosition": "end",
|
||||||
|
"experimentalTernaries": false,
|
||||||
|
"singleQuote": true,
|
||||||
|
"jsxSingleQuote": false,
|
||||||
|
"quoteProps": "as-needed",
|
||||||
|
"trailingComma": "all",
|
||||||
|
"singleAttributePerLine": false,
|
||||||
|
"htmlWhitespaceSensitivity": "css",
|
||||||
|
"vueIndentScriptAndStyle": false,
|
||||||
|
"proseWrap": "preserve",
|
||||||
|
"insertPragma": false,
|
||||||
|
"printWidth": 80,
|
||||||
|
"requirePragma": false,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"useTabs": false,
|
||||||
|
"embeddedLanguageFormatting": "auto"
|
||||||
|
}
|
||||||
73
client/README.md
Normal file
73
client/README.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
|
||||||
|
// Remove tseslint.configs.recommended and replace with this
|
||||||
|
tseslint.configs.recommendedTypeChecked,
|
||||||
|
// Alternatively, use this for stricter rules
|
||||||
|
tseslint.configs.strictTypeChecked,
|
||||||
|
// Optionally, add this for stylistic rules
|
||||||
|
tseslint.configs.stylisticTypeChecked,
|
||||||
|
|
||||||
|
// Other configs...
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// eslint.config.js
|
||||||
|
import reactX from 'eslint-plugin-react-x'
|
||||||
|
import reactDom from 'eslint-plugin-react-dom'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
// Enable lint rules for React
|
||||||
|
reactX.configs['recommended-typescript'],
|
||||||
|
// Enable lint rules for React DOM
|
||||||
|
reactDom.configs.recommended,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
23
client/eslint.config.js
Normal file
23
client/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs['recommended-latest'],
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
14
client/index.html
Normal file
14
client/index.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<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>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
8160
client/package-lock.json
generated
Normal file
8160
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
49
client/package.json
Normal file
49
client/package.json
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
{
|
||||||
|
"name": "client",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"engines": {
|
||||||
|
"node": "^20"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"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",
|
||||||
|
"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",
|
||||||
|
"tailwindcss": "^4.1.14"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.36.0",
|
||||||
|
"@types/node": "^24.6.0",
|
||||||
|
"@types/react": "^19.1.16",
|
||||||
|
"@types/react-dom": "^19.1.9",
|
||||||
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
|
"eslint": "^9.36.0",
|
||||||
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.22",
|
||||||
|
"globals": "^16.4.0",
|
||||||
|
"lint-staged": "^16.2.3",
|
||||||
|
"prettier": "^3.6.2",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"typescript-eslint": "^8.45.0",
|
||||||
|
"vite": "^7.1.7"
|
||||||
|
},
|
||||||
|
"lint-staged": {
|
||||||
|
"**/*": "prettier --write --ignore-unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
client/public/vite.svg
Normal file
1
client/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
9
client/src/api/api.ts
Normal file
9
client/src/api/api.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const VITE_API_BASE = import.meta.env.VITE_API_BASE;
|
||||||
|
const VITE_API_HOST = import.meta.env.VITE_API_HOST;
|
||||||
|
const VITE_API_PORT = import.meta.env.VITE_API_PORT;
|
||||||
|
|
||||||
|
export const api = axios.create({
|
||||||
|
baseURL: `${VITE_API_HOST}:${VITE_API_PORT}${VITE_API_BASE}`,
|
||||||
|
});
|
||||||
1
client/src/api/index.ts
Normal file
1
client/src/api/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './api'
|
||||||
@@ -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 <></>;
|
||||||
|
};
|
||||||
1
client/src/common/components/apply-notificator/index.ts
Normal file
1
client/src/common/components/apply-notificator/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './apply-notificator'
|
||||||
2
client/src/common/hooks/index.ts
Normal file
2
client/src/common/hooks/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './use-app-dispatch'
|
||||||
|
export * from './use-app-selector'
|
||||||
4
client/src/common/hooks/use-app-dispatch.ts
Normal file
4
client/src/common/hooks/use-app-dispatch.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { useDispatch } from 'react-redux'
|
||||||
|
import type { AppDispatch } from '../../store'
|
||||||
|
|
||||||
|
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
|
||||||
4
client/src/common/hooks/use-app-selector.ts
Normal file
4
client/src/common/hooks/use-app-selector.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { useSelector } from 'react-redux'
|
||||||
|
import type { RootState } from '../../store'
|
||||||
|
|
||||||
|
export const useAppSelector = useSelector.withTypes<RootState>()
|
||||||
1
client/src/components/index.ts
Normal file
1
client/src/components/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './nav-menu';
|
||||||
1
client/src/components/nav-menu/index.ts
Normal file
1
client/src/components/nav-menu/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './nav-menu';
|
||||||
32
client/src/components/nav-menu/nav-menu.tsx
Normal file
32
client/src/components/nav-menu/nav-menu.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { Link, useLocation } from 'react-router';
|
||||||
|
import { clsx } from 'clsx';
|
||||||
|
|
||||||
|
interface NavMenuItems {
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NavMenuProps {
|
||||||
|
items: NavMenuItems[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NavMenu = (props: NavMenuProps) => {
|
||||||
|
const { items } = props;
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="tabs">
|
||||||
|
{items.map(({ href, label }) => (
|
||||||
|
<Link
|
||||||
|
key={label}
|
||||||
|
className={clsx('tab', {
|
||||||
|
active: href === pathname,
|
||||||
|
})}
|
||||||
|
to={href}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import type { FC } from 'react';
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
ModalContent,
|
||||||
|
ModalHeader,
|
||||||
|
ModalBody,
|
||||||
|
ModalFooter,
|
||||||
|
Button,
|
||||||
|
} from '@heroui/react';
|
||||||
|
import type { CertificateDTO } from '../../duck';
|
||||||
|
|
||||||
|
export interface CertificateDetailProps {
|
||||||
|
cetificate: CertificateDTO;
|
||||||
|
isOpen: boolean;
|
||||||
|
onOpenChange: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CertificateDetail: FC<CertificateDetailProps> = (props) => {
|
||||||
|
const { cetificate, isOpen, onOpenChange } = props;
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||||
|
<ModalContent>
|
||||||
|
{(onClose) => (
|
||||||
|
<>
|
||||||
|
<ModalHeader className="flex flex-col gap-1">
|
||||||
|
Modal Title
|
||||||
|
</ModalHeader>
|
||||||
|
<ModalBody>
|
||||||
|
<div>
|
||||||
|
<h4>Basic Information</h4>
|
||||||
|
<p>
|
||||||
|
<strong>Name:</strong> ${cetificate.name}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Domain:</strong> ${cetificate.domain}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Type:</strong> ${cetificate.cert_type}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Auto Renew:</strong>
|
||||||
|
{cetificate.auto_renew ? 'Yes' : 'No'}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Created:</strong>
|
||||||
|
{new Date(cetificate.created_at).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Expires:</strong>
|
||||||
|
{new Date(cetificate.expires_at).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h4>Certificate PEM</h4>
|
||||||
|
<div className="cert-details">
|
||||||
|
{cetificate.certificate_pem || 'Not available'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4>Private Key</h4>
|
||||||
|
<div className="cert-details">
|
||||||
|
{cetificate.has_private_key
|
||||||
|
? '[Hidden for security]'
|
||||||
|
: 'Not available'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button color="danger" variant="light" onPress={onClose}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { useEffect, type FC } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
ModalContent,
|
||||||
|
ModalHeader,
|
||||||
|
ModalBody,
|
||||||
|
ModalFooter,
|
||||||
|
Button,
|
||||||
|
} from '@heroui/react';
|
||||||
|
import { useAppDispatch } from '../../../../common/hooks';
|
||||||
|
import { getCertificate } from '../../duck/api';
|
||||||
|
import { updateCertificate, type EditCertificateDTO } from '../../duck';
|
||||||
|
|
||||||
|
export interface CertificateEditProps {
|
||||||
|
certificateId: string;
|
||||||
|
isOpen: boolean;
|
||||||
|
onOpenChange: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CertificateEdit: FC<CertificateEditProps> = (props) => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { certificateId, isOpen, onOpenChange } = props;
|
||||||
|
const { register, handleSubmit, reset } = useForm<EditCertificateDTO>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getCertificate(certificateId).then((response) => {
|
||||||
|
const { data } = response;
|
||||||
|
reset({
|
||||||
|
...data,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [certificateId]);
|
||||||
|
|
||||||
|
const onSubmit = (values: EditCertificateDTO) => {
|
||||||
|
dispatch(
|
||||||
|
updateCertificate({
|
||||||
|
id: certificateId,
|
||||||
|
certificate: values
|
||||||
|
}),
|
||||||
|
).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>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Name:</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
{...register('name', { required: true })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Domain:</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
{...register('domain', { required: true })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" {...register('auto_renew')} /> Auto
|
||||||
|
Renew
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button color="primary" type="submit">
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<Button color="danger" variant="light" onPress={onClose}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ModalContent>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { FC } from 'react';
|
||||||
|
import type { CertificateDTO } from '../../duck';
|
||||||
|
import { CertificateView } from './certificate-view';
|
||||||
|
|
||||||
|
export interface CertificateList {
|
||||||
|
certificates: CertificateDTO[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CertificateList: FC<CertificateList> = ({ certificates }) => {
|
||||||
|
return (
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Domain</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Expires</th>
|
||||||
|
<th>Auto Renew</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
{certificates
|
||||||
|
.map((certificate)=><CertificateView certificate={certificate} key={certificate.id}/>)}
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import type { FC } from 'react';
|
||||||
|
import { deleteCertificateAction, type CertificateDTO } from '../../duck';
|
||||||
|
import { useDisclosure } from '@heroui/react';
|
||||||
|
import { CertificateDetail } from './certificate-details';
|
||||||
|
import { CertificateEdit } from './certificate-edit';
|
||||||
|
import { useAppDispatch } from '../../../../common/hooks';
|
||||||
|
|
||||||
|
export interface CertificateViewProps {
|
||||||
|
certificate: CertificateDTO;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CertificateView: FC<CertificateViewProps> = ({ certificate }) => {
|
||||||
|
const dispatch = useAppDispatch()
|
||||||
|
const detailDisclosure = useDisclosure();
|
||||||
|
const editDisclosure = useDisclosure();
|
||||||
|
|
||||||
|
const handleDeleteCertificate = () => {
|
||||||
|
if (confirm('Delete certificate?')) {
|
||||||
|
dispatch(deleteCertificateAction(certificate.id));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<tr>
|
||||||
|
<td>{certificate.name}</td>
|
||||||
|
<td>{certificate.domain}</td>
|
||||||
|
<td>{certificate.cert_type}</td>
|
||||||
|
<td>{new Date(certificate.expires_at).toLocaleDateString()}</td>
|
||||||
|
<td>{certificate.auto_renew ? 'Yes' : 'No'}</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={detailDisclosure.onOpenChange}
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={editDisclosure.onOpenChange}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-danger"
|
||||||
|
onClick={handleDeleteCertificate}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<CertificateDetail cetificate={certificate} {...detailDisclosure} />
|
||||||
|
<CertificateEdit {...editDisclosure} certificateId={certificate.id} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './certificate-list'
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import type { FC } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { createCertificateAction, type CreateCertificateDTO } from '../../duck';
|
||||||
|
import { useAppDispatch } from '../../../../common/hooks';
|
||||||
|
|
||||||
|
export const CreateCertificate: FC = () => {
|
||||||
|
const { handleSubmit, register, reset } = useForm<CreateCertificateDTO>();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const onSubmit = (values: CreateCertificateDTO) => {
|
||||||
|
dispatch(createCertificateAction(values)).then(() => {
|
||||||
|
reset();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form id="certificateForm" onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Name:</label>
|
||||||
|
<input type="text" {...register('name', { required: true })} />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Domain:</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="example.com"
|
||||||
|
{...register('domain', { required: true })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Certificate Type:</label>
|
||||||
|
<select id="certType" {...register('cert_type', { required: true })}>
|
||||||
|
<option value="self_signed">Self-Signed</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="certAutoRenew"
|
||||||
|
{...register('auto_renew')}
|
||||||
|
/>{' '}
|
||||||
|
Auto Renew
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button type="submit" className="btn btn-primary">
|
||||||
|
Generate Certificate
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './create-certificate'
|
||||||
1
client/src/features/certificates/components/index.ts
Normal file
1
client/src/features/certificates/components/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './create-certificate'
|
||||||
99
client/src/features/certificates/duck/actions.ts
Normal file
99
client/src/features/certificates/duck/actions.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { certificateSlice } from './slice';
|
||||||
|
import { createCertificate, deleteCertificate, getCertificates, patchCertificate } from './api';
|
||||||
|
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||||
|
import { getCertificatesState } from './selectors';
|
||||||
|
import type { RootState } from '../../../store';
|
||||||
|
import { appNotificator } from '../../../utils/notification/app-notificator';
|
||||||
|
import type { CreateCertificateDTO, EditCertificateDTO } from './dto';
|
||||||
|
|
||||||
|
const PREFFIX = 'certificates';
|
||||||
|
|
||||||
|
export const fetchCertificates = createAsyncThunk(
|
||||||
|
`${PREFFIX}/fetchAll`,
|
||||||
|
async (_, { dispatch, getState }) => {
|
||||||
|
const { loading } = getCertificatesState(getState() as RootState);
|
||||||
|
try {
|
||||||
|
if (loading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch(certificateSlice.actions.setLoading(true));
|
||||||
|
const response = await getCertificates().then(({ data }) => data);
|
||||||
|
dispatch(certificateSlice.actions.setUsers(response));
|
||||||
|
} catch (e) {
|
||||||
|
const message =
|
||||||
|
e instanceof Error ? e.message : `Unknown error in ${PREFFIX}/fetchAll`;
|
||||||
|
dispatch(certificateSlice.actions.setError(message));
|
||||||
|
} finally {
|
||||||
|
dispatch(certificateSlice.actions.setLoading(false));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const createCertificateAction = createAsyncThunk(
|
||||||
|
`${PREFFIX}/createCertificates`,
|
||||||
|
async (params: CreateCertificateDTO, { dispatch }) => {
|
||||||
|
try {
|
||||||
|
await createCertificate(params);
|
||||||
|
dispatch(fetchCertificates());
|
||||||
|
} catch (e) {
|
||||||
|
appNotificator.add({
|
||||||
|
message:
|
||||||
|
e instanceof Error
|
||||||
|
? e.message
|
||||||
|
: `Unknown error in ${PREFFIX}/createCertificates`,
|
||||||
|
type: 'error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const updateCertificate = createAsyncThunk(
|
||||||
|
`${PREFFIX}/updateCertificate`,
|
||||||
|
async (
|
||||||
|
params: {
|
||||||
|
id: string;
|
||||||
|
certificate: EditCertificateDTO;
|
||||||
|
},
|
||||||
|
{ dispatch },
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
await patchCertificate(params.id, params.certificate);
|
||||||
|
dispatch(fetchCertificates());
|
||||||
|
appNotificator.add({
|
||||||
|
message: 'Template updated',
|
||||||
|
type: 'success',
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
appNotificator.add({
|
||||||
|
type: 'error',
|
||||||
|
message:
|
||||||
|
e instanceof Error
|
||||||
|
? `Error updating: ${e.message}`
|
||||||
|
: `Unknown error in ${PREFFIX}/updateTemplate`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const deleteCertificateAction = createAsyncThunk(
|
||||||
|
`${PREFFIX}/deleteCertificate`,
|
||||||
|
async (id: string, { dispatch }) => {
|
||||||
|
try {
|
||||||
|
await deleteCertificate(id);
|
||||||
|
appNotificator.add({
|
||||||
|
message: 'Certificate deleted',
|
||||||
|
type: 'success',
|
||||||
|
});
|
||||||
|
dispatch(fetchCertificates());
|
||||||
|
} catch (e) {
|
||||||
|
appNotificator.add({
|
||||||
|
type: 'error',
|
||||||
|
message:
|
||||||
|
e instanceof Error
|
||||||
|
? `Delete error: ${e.message}`
|
||||||
|
: `Unknown error in ${PREFFIX}/deleteCertificate`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
20
client/src/features/certificates/duck/api.ts
Normal file
20
client/src/features/certificates/duck/api.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import type { AxiosResponse } from 'axios';
|
||||||
|
import { api } from '../../../api/api';
|
||||||
|
import type { CertificateDTO, CreateCertificateDTO, EditCertificateDTO } from './dto';
|
||||||
|
|
||||||
|
export const getCertificates = () =>
|
||||||
|
api.get<never, AxiosResponse<CertificateDTO[]>>('/certificates');
|
||||||
|
|
||||||
|
export const createCertificate = (params: CreateCertificateDTO) =>
|
||||||
|
api.post<AxiosResponse>('/certificates', params, {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getCertificate = (id: string) => api.get<never, AxiosResponse<CertificateDTO>>(`/certificates/${id}`)
|
||||||
|
|
||||||
|
export const patchCertificate = (id: string, certificate: EditCertificateDTO) =>
|
||||||
|
api.put(`/certificates/${id}`, certificate, {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
export const deleteCertificate = (id: string) => api.delete(`/certificates/${id}`);
|
||||||
24
client/src/features/certificates/duck/dto.ts
Normal file
24
client/src/features/certificates/duck/dto.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
export interface CertificateDTO {
|
||||||
|
name: string;
|
||||||
|
domain: string;
|
||||||
|
cert_type: string;
|
||||||
|
expires_at: string;
|
||||||
|
auto_renew: boolean;
|
||||||
|
id: string
|
||||||
|
created_at: string
|
||||||
|
certificate_pem: string
|
||||||
|
has_private_key: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateCertificateDTO {
|
||||||
|
name: string;
|
||||||
|
domain: string;
|
||||||
|
cert_type: string;
|
||||||
|
auto_renew: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EditCertificateDTO {
|
||||||
|
name: string
|
||||||
|
domain: string
|
||||||
|
auto_renew: boolean
|
||||||
|
}
|
||||||
4
client/src/features/certificates/duck/index.ts
Normal file
4
client/src/features/certificates/duck/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './actions'
|
||||||
|
export * from './dto'
|
||||||
|
export * from './slice'
|
||||||
|
export * from './selectors'
|
||||||
3
client/src/features/certificates/duck/selectors.ts
Normal file
3
client/src/features/certificates/duck/selectors.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import type { RootState } from '../../../store';
|
||||||
|
|
||||||
|
export const getCertificatesState = (state: RootState) => state.certificates;
|
||||||
32
client/src/features/certificates/duck/slice.ts
Normal file
32
client/src/features/certificates/duck/slice.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { createSlice } from '@reduxjs/toolkit';
|
||||||
|
import type { PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
import type { CertificateDTO } from './dto';
|
||||||
|
|
||||||
|
export interface CertificatesState {
|
||||||
|
loading: boolean;
|
||||||
|
certificates: CertificateDTO[]
|
||||||
|
error: null | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: CertificatesState = {
|
||||||
|
loading: false,
|
||||||
|
certificates: [],
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const certificateSlice = createSlice({
|
||||||
|
name: 'certificates',
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
setLoading: (state, action: PayloadAction<boolean>) => {
|
||||||
|
state.loading = action.payload
|
||||||
|
},
|
||||||
|
setUsers: (state, action: PayloadAction<CertificateDTO[]>) => {
|
||||||
|
state.certificates = action.payload
|
||||||
|
},
|
||||||
|
setError: (state, action: PayloadAction<string>) => {
|
||||||
|
state.error = action.payload
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
2
client/src/features/certificates/index.ts
Normal file
2
client/src/features/certificates/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './duck'
|
||||||
|
export * from './components'
|
||||||
4
client/src/features/index.ts
Normal file
4
client/src/features/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './servers'
|
||||||
|
export * from './templates'
|
||||||
|
export * from './users'
|
||||||
|
export * from './certificates'
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { useForm, type SubmitHandler } from 'react-hook-form';
|
||||||
|
import { createServerAction } from '../../duck';
|
||||||
|
import { useAppDispatch } from '../../../../common/hooks';
|
||||||
|
import type { CreateServerForm } from '../../types';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const AddServer = () => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { register, handleSubmit, reset } = useForm<CreateServerForm>();
|
||||||
|
|
||||||
|
const onSubmit: SubmitHandler<CreateServerForm> = (values) => {
|
||||||
|
const data = {
|
||||||
|
...values,
|
||||||
|
grpc_port: parseInt(values.grpc_port)
|
||||||
|
}
|
||||||
|
dispatch(createServerAction(data)).then(() => {
|
||||||
|
reset();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="section">
|
||||||
|
<h2>Add Server</h2>
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} id="serverForm">
|
||||||
|
<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 {...register('grpc_port', { required: true })} />
|
||||||
|
</div>
|
||||||
|
<button type="submit" className="btn btn-primary">
|
||||||
|
Add Server
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './servers-list'
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { FC } from 'react';
|
||||||
|
import type { ServerDTO } from '../../duck';
|
||||||
|
import { ServerView } from './server-view';
|
||||||
|
|
||||||
|
export interface ServersListProps {
|
||||||
|
servers: ServerDTO[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ServersList: FC<ServersListProps> = (props) => {
|
||||||
|
const { servers } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
};
|
||||||
99
client/src/features/servers/duck/actions.ts
Normal file
99
client/src/features/servers/duck/actions.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { serversSlice } from './slice';
|
||||||
|
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';
|
||||||
|
|
||||||
|
export const fetchServers = createAsyncThunk(
|
||||||
|
`${PREFFIX}/fetchAll`,
|
||||||
|
async (_, { dispatch, getState }) => {
|
||||||
|
const { loading } = getServersState(getState() as RootState);
|
||||||
|
try {
|
||||||
|
if (loading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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`;
|
||||||
|
dispatch(serversSlice.actions.setError(message));
|
||||||
|
} finally {
|
||||||
|
dispatch(serversSlice.actions.setLoading(false));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const createServerAction = createAsyncThunk(
|
||||||
|
`${PREFFIX}/createServer`,
|
||||||
|
async (params: CreateServerDTO, { dispatch }) => {
|
||||||
|
try {
|
||||||
|
await createServer(params);
|
||||||
|
dispatch(fetchServers());
|
||||||
|
} catch (e) {
|
||||||
|
appNotificator.add({
|
||||||
|
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`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
28
client/src/features/servers/duck/api.ts
Normal file
28
client/src/features/servers/duck/api.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import type { AxiosResponse } from 'axios';
|
||||||
|
import { api } from '../../../api/api';
|
||||||
|
import type { ServerDTO, CreateServerDTO, TestServerDTO } from './dto';
|
||||||
|
|
||||||
|
export const getServers = () =>
|
||||||
|
api.get<never, AxiosResponse<ServerDTO[]>>('/servers');
|
||||||
|
|
||||||
|
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' },
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
18
client/src/features/servers/duck/dto.ts
Normal file
18
client/src/features/servers/duck/dto.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
export interface ServerDTO {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
hostname: string
|
||||||
|
grpc_port: number
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateServerDTO {
|
||||||
|
name: string;
|
||||||
|
hostname: string;
|
||||||
|
grpc_port: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TestServerDTO {
|
||||||
|
connected: boolean,
|
||||||
|
endpoint: string
|
||||||
|
}
|
||||||
4
client/src/features/servers/duck/index.ts
Normal file
4
client/src/features/servers/duck/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './actions'
|
||||||
|
export * from './dto'
|
||||||
|
export * from './slice'
|
||||||
|
export * from './selectors'
|
||||||
3
client/src/features/servers/duck/selectors.ts
Normal file
3
client/src/features/servers/duck/selectors.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import type { RootState } from '../../../store';
|
||||||
|
|
||||||
|
export const getServersState = (state: RootState) => state.servers;
|
||||||
32
client/src/features/servers/duck/slice.ts
Normal file
32
client/src/features/servers/duck/slice.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { createSlice } from '@reduxjs/toolkit';
|
||||||
|
import type { PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
import type { ServerDTO } from './dto';
|
||||||
|
|
||||||
|
export interface ServersState {
|
||||||
|
loading: boolean;
|
||||||
|
servers: ServerDTO[];
|
||||||
|
error: null | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: ServersState = {
|
||||||
|
loading: false,
|
||||||
|
servers: [],
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const serversSlice = createSlice({
|
||||||
|
name: 'servers',
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
setLoading: (state, action: PayloadAction<boolean>) => {
|
||||||
|
state.loading = action.payload
|
||||||
|
},
|
||||||
|
setServers: (state, action: PayloadAction<ServerDTO[]>) => {
|
||||||
|
state.servers = action.payload
|
||||||
|
},
|
||||||
|
setError: (state, action: PayloadAction<string>) => {
|
||||||
|
state.error = action.payload
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
1
client/src/features/servers/index.ts
Normal file
1
client/src/features/servers/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './duck'
|
||||||
5
client/src/features/servers/types/form.ts
Normal file
5
client/src/features/servers/types/form.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export interface CreateServerForm {
|
||||||
|
name: string;
|
||||||
|
hostname: string;
|
||||||
|
grpc_port: string;
|
||||||
|
}
|
||||||
1
client/src/features/servers/types/index.ts
Normal file
1
client/src/features/servers/types/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './form'
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { useForm, type SubmitHandler } from 'react-hook-form'
|
||||||
|
import type { CreateTemplateForm } from '../../types';
|
||||||
|
import { useAppDispatch } from '../../../../common/hooks';
|
||||||
|
import { protocolOptions } from './util'
|
||||||
|
import type { CreateTemplateDTO } from '../../duck/dto';
|
||||||
|
import { createTemplateAction } from '../../duck';
|
||||||
|
|
||||||
|
|
||||||
|
export const AddTemplate = () => {
|
||||||
|
const dispatch = useAppDispatch()
|
||||||
|
const { register, handleSubmit, reset } = useForm<CreateTemplateForm>({
|
||||||
|
defaultValues: {
|
||||||
|
default_port: '443'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit: SubmitHandler<CreateTemplateForm> = (values) => {
|
||||||
|
const data: CreateTemplateDTO = {
|
||||||
|
...values,
|
||||||
|
default_port: parseInt(values.default_port),
|
||||||
|
config_template: ''
|
||||||
|
}
|
||||||
|
dispatch(createTemplateAction(data)).then(() => {
|
||||||
|
reset();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} id="templateForm">
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Name:</label>
|
||||||
|
<input type="text" {...register('name', { required: true })} />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Protocol:</label>
|
||||||
|
<select {...register('protocol', {required: true})}>
|
||||||
|
{Object.entries(protocolOptions).map((protocolTupple)=> (
|
||||||
|
<option value={protocolTupple[0]}>{protocolTupple[1]}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Default Port:</label>
|
||||||
|
<input type="number" {...register('default_port', {required: true})} />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" {...register('requires_tls')}/> Requires TLS
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button type="submit" className="btn btn-primary">
|
||||||
|
Add Template
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './add-template'
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { Protocol } from '../../duck/dto'
|
||||||
|
|
||||||
|
export const protocolOptions: Record<Protocol, string> = {
|
||||||
|
vless: 'VLESS',
|
||||||
|
vmess: 'VMess',
|
||||||
|
trojan: 'Trojan',
|
||||||
|
shadowsocks: 'Shadowsocks'
|
||||||
|
}
|
||||||
|
|
||||||
2
client/src/features/templates/components/index.ts
Normal file
2
client/src/features/templates/components/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './add-template'
|
||||||
|
export * from './template-list'
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './template-list'
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { useEffect, type FC } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
ModalContent,
|
||||||
|
ModalHeader,
|
||||||
|
ModalBody,
|
||||||
|
ModalFooter,
|
||||||
|
Button,
|
||||||
|
} from '@heroui/react';
|
||||||
|
import type { EditTemplateForm } from '../../types';
|
||||||
|
import { useAppDispatch } from '../../../../common/hooks';
|
||||||
|
import { getTemplateById } from '../../duck/api';
|
||||||
|
import { protocolOptions } from '../add-template/util';
|
||||||
|
import { updateTemplate } from '../../duck';
|
||||||
|
|
||||||
|
export interface TemplateEditProps {
|
||||||
|
templateId: string;
|
||||||
|
isOpen: boolean;
|
||||||
|
onOpenChange: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TemplateEdit: FC<TemplateEditProps> = (props) => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { templateId, isOpen, onOpenChange } = props;
|
||||||
|
const { register, handleSubmit, reset } = useForm<EditTemplateForm>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getTemplateById(templateId).then((response) => {
|
||||||
|
const { data } = response;
|
||||||
|
reset({
|
||||||
|
...data,
|
||||||
|
default_port: String(data.default_port),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [templateId]);
|
||||||
|
|
||||||
|
const onSubmit = (values: EditTemplateForm) => {
|
||||||
|
const data = {
|
||||||
|
...values,
|
||||||
|
default_port: parseInt(values.default_port),
|
||||||
|
};
|
||||||
|
|
||||||
|
dispatch(
|
||||||
|
updateTemplate({
|
||||||
|
id: templateId,
|
||||||
|
template: 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>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Name:</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
{...register('name', { required: true })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Protocol:</label>
|
||||||
|
<select
|
||||||
|
id="editProtocol"
|
||||||
|
{...register('protocol', { required: true })}
|
||||||
|
>
|
||||||
|
{Object.entries(protocolOptions).map((protocolTupple) => (
|
||||||
|
<option value={protocolTupple[0]}>
|
||||||
|
{protocolTupple[1]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Default Port:</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
{...register('default_port', {required: true})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" {...register('requires_tls')} />{' '}
|
||||||
|
Requires TLS
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" {...register('is_active')} />{' '}
|
||||||
|
Active
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button color="primary" type="submit">
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<Button color="danger" variant="light" onPress={onClose}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ModalContent>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { FC } from 'react';
|
||||||
|
import type { TemplateDTO } from '../../duck';
|
||||||
|
import { TemplateView } from './template-view';
|
||||||
|
|
||||||
|
export interface TemplateListProps {
|
||||||
|
templates: TemplateDTO[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TemplateList: FC<TemplateListProps> = ({ templates }) => {
|
||||||
|
return (
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Protocol</th>
|
||||||
|
<th>Port</th>
|
||||||
|
<th>TLS</th>
|
||||||
|
<th>Active</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{templates.map((template) => (
|
||||||
|
<TemplateView template={template} key={template.id}/>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import type { FC } from 'react';
|
||||||
|
import { deleteTemplateAction, type TemplateDTO } from '../../duck';
|
||||||
|
import { useDisclosure } from '@heroui/react';
|
||||||
|
import { TemplateEdit } from './template-edit';
|
||||||
|
import { useAppDispatch } from '../../../../common/hooks';
|
||||||
|
|
||||||
|
export interface TemplateViewProps {
|
||||||
|
template: TemplateDTO;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TemplateView: FC<TemplateViewProps> = ({ template }) => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { isOpen, onOpen, onOpenChange } = useDisclosure();
|
||||||
|
|
||||||
|
const handleDeleteTemplate = () => {
|
||||||
|
if (confirm('Delete template?')) {
|
||||||
|
dispatch(deleteTemplateAction(template.id));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<tr>
|
||||||
|
<td>{template.name}</td>
|
||||||
|
<td>{template.protocol}</td>
|
||||||
|
<td>{template.default_port}</td>
|
||||||
|
<td>{template.requires_tls ? 'Yes' : 'No'}</td>
|
||||||
|
<td>{template.is_active ? 'Yes' : 'No'}</td>
|
||||||
|
<td>
|
||||||
|
<button className="btn btn-primary" onClick={onOpen}>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-danger"
|
||||||
|
onClick={handleDeleteTemplate}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<TemplateEdit
|
||||||
|
templateId={template.id}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
isOpen={isOpen}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
102
client/src/features/templates/duck/actions.ts
Normal file
102
client/src/features/templates/duck/actions.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { templatesSlice } from './slice';
|
||||||
|
import { getTemplates, createTemplate, patchTemplate, deleteTemplate } from './api';
|
||||||
|
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||||
|
import { getTemplatesState } from './selectors';
|
||||||
|
import type { RootState } from '../../../store';
|
||||||
|
import type { CreateTemplateDTO, EditTemplateDTO } from './dto';
|
||||||
|
import { appNotificator } from '../../../utils/notification/app-notificator';
|
||||||
|
|
||||||
|
const PREFFIX = 'templates';
|
||||||
|
|
||||||
|
export const fetchTemplates = createAsyncThunk(
|
||||||
|
`${PREFFIX}/fetchAll`,
|
||||||
|
async (_, { dispatch, getState }) => {
|
||||||
|
const { loading } = getTemplatesState(getState() as RootState);
|
||||||
|
try {
|
||||||
|
if (loading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch(templatesSlice.actions.setLoading(true));
|
||||||
|
const response = await getTemplates().then(({ data }) => data);
|
||||||
|
dispatch(templatesSlice.actions.setTemplates(response));
|
||||||
|
} catch (e) {
|
||||||
|
const message =
|
||||||
|
e instanceof Error ? e.message : `Unknown error in ${PREFFIX}/fetchAll`;
|
||||||
|
dispatch(templatesSlice.actions.setError(message));
|
||||||
|
} finally {
|
||||||
|
dispatch(templatesSlice.actions.setLoading(false));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
export const createTemplateAction = createAsyncThunk(
|
||||||
|
`${PREFFIX}/createTemplate`,
|
||||||
|
async (params: CreateTemplateDTO, { dispatch }) => {
|
||||||
|
try {
|
||||||
|
await createTemplate(params);
|
||||||
|
dispatch(fetchTemplates());
|
||||||
|
} catch (e) {
|
||||||
|
appNotificator.add({
|
||||||
|
message:
|
||||||
|
e instanceof Error
|
||||||
|
? e.message
|
||||||
|
: `Unknown error in ${PREFFIX}/createTemplate`,
|
||||||
|
type: 'error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const updateTemplate = createAsyncThunk(
|
||||||
|
`${PREFFIX}/updateTemplate`,
|
||||||
|
async (
|
||||||
|
params: {
|
||||||
|
id: string;
|
||||||
|
template: EditTemplateDTO;
|
||||||
|
},
|
||||||
|
{ dispatch },
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
await patchTemplate(params.id, params.template);
|
||||||
|
dispatch(fetchTemplates());
|
||||||
|
appNotificator.add({
|
||||||
|
message: 'Template updated',
|
||||||
|
type: 'success',
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
appNotificator.add({
|
||||||
|
type: 'error',
|
||||||
|
message:
|
||||||
|
e instanceof Error
|
||||||
|
? `Error updating: ${e.message}`
|
||||||
|
: `Unknown error in ${PREFFIX}/updateTemplate`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const deleteTemplateAction = createAsyncThunk(
|
||||||
|
`${PREFFIX}/deleteTemplate`,
|
||||||
|
async (id: string, { dispatch }) => {
|
||||||
|
try {
|
||||||
|
await deleteTemplate(id);
|
||||||
|
appNotificator.add({
|
||||||
|
message: 'Template deleted',
|
||||||
|
type: 'success',
|
||||||
|
});
|
||||||
|
dispatch(fetchTemplates());
|
||||||
|
} catch (e) {
|
||||||
|
appNotificator.add({
|
||||||
|
type: 'error',
|
||||||
|
message:
|
||||||
|
e instanceof Error
|
||||||
|
? `Delete error: ${e.message}`
|
||||||
|
: `Unknown error in ${PREFFIX}/deleteTemplate`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
23
client/src/features/templates/duck/api.ts
Normal file
23
client/src/features/templates/duck/api.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import type { AxiosResponse } from 'axios';
|
||||||
|
import { api } from '../../../api/api';
|
||||||
|
import type { TemplateDTO, CreateTemplateDTO, EditTemplateDTO } from './dto';
|
||||||
|
|
||||||
|
export const getTemplates = () =>
|
||||||
|
api.get<never, AxiosResponse<TemplateDTO[]>>('/templates');
|
||||||
|
|
||||||
|
export const createTemplate = (params: CreateTemplateDTO) =>
|
||||||
|
api.post<AxiosResponse>('templates', params, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getTemplateById = (id: string) =>
|
||||||
|
api.get<string, AxiosResponse<TemplateDTO>>(`/templates/${id}`);
|
||||||
|
|
||||||
|
export const patchTemplate = (id: string, template: EditTemplateDTO) =>
|
||||||
|
api.put(`/templates/${id}`, template, {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
export const deleteTemplate = (id: string) => api.delete(`/templates/${id}`);
|
||||||
33
client/src/features/templates/duck/dto.ts
Normal file
33
client/src/features/templates/duck/dto.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
export type Protocol = 'vless' | 'vmess' | 'trojan' | 'shadowsocks';
|
||||||
|
|
||||||
|
export interface TemplateDTO {
|
||||||
|
base_settings: Record<string, unknown>; // TODO define unknown
|
||||||
|
created_at: string;
|
||||||
|
default_port: number;
|
||||||
|
description: string;
|
||||||
|
id: string;
|
||||||
|
is_active: boolean;
|
||||||
|
name: string;
|
||||||
|
protocol: Protocol;
|
||||||
|
requires_domain: boolean;
|
||||||
|
requires_tls: boolean;
|
||||||
|
stream_settings: Record<string, unknown>; // TOD define unknown
|
||||||
|
updated_at: string;
|
||||||
|
variables: unknown[]; // TOD define unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTemplateDTO {
|
||||||
|
name: string;
|
||||||
|
protocol: Protocol;
|
||||||
|
default_port: number;
|
||||||
|
requires_tls: boolean;
|
||||||
|
config_template: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EditTemplateDTO {
|
||||||
|
name: string,
|
||||||
|
protocol: Protocol
|
||||||
|
default_port: number
|
||||||
|
requires_tls: boolean
|
||||||
|
is_active: boolean
|
||||||
|
}
|
||||||
4
client/src/features/templates/duck/index.ts
Normal file
4
client/src/features/templates/duck/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './actions'
|
||||||
|
export * from './dto'
|
||||||
|
export * from './slice'
|
||||||
|
export * from './selectors'
|
||||||
3
client/src/features/templates/duck/selectors.ts
Normal file
3
client/src/features/templates/duck/selectors.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import type { RootState } from '../../../store';
|
||||||
|
|
||||||
|
export const getTemplatesState = (state: RootState) => state.templates;
|
||||||
32
client/src/features/templates/duck/slice.ts
Normal file
32
client/src/features/templates/duck/slice.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { createSlice } from '@reduxjs/toolkit';
|
||||||
|
import type { PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
import type { TemplateDTO } from './dto';
|
||||||
|
|
||||||
|
export interface TemplateState {
|
||||||
|
loading: boolean;
|
||||||
|
templates: TemplateDTO[];
|
||||||
|
error: null | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: TemplateState = {
|
||||||
|
loading: false,
|
||||||
|
templates: [],
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const templatesSlice = createSlice({
|
||||||
|
name: 'templates',
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
setLoading: (state, action: PayloadAction<boolean>) => {
|
||||||
|
state.loading = action.payload
|
||||||
|
},
|
||||||
|
setTemplates: (state, action: PayloadAction<TemplateDTO[]>) => {
|
||||||
|
state.templates = action.payload
|
||||||
|
},
|
||||||
|
setError: (state, action: PayloadAction<string>) => {
|
||||||
|
state.error = action.payload
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
2
client/src/features/templates/index.ts
Normal file
2
client/src/features/templates/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './duck'
|
||||||
|
export * from './components'
|
||||||
16
client/src/features/templates/types/form.ts
Normal file
16
client/src/features/templates/types/form.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import type { Protocol } from "../duck/dto"
|
||||||
|
|
||||||
|
export interface CreateTemplateForm {
|
||||||
|
name: string,
|
||||||
|
protocol: Protocol
|
||||||
|
default_port: string
|
||||||
|
requires_tls: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EditTemplateForm {
|
||||||
|
name: string,
|
||||||
|
protocol: Protocol
|
||||||
|
default_port: string
|
||||||
|
requires_tls: boolean
|
||||||
|
is_active: boolean
|
||||||
|
}
|
||||||
1
client/src/features/templates/types/index.ts
Normal file
1
client/src/features/templates/types/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './form'
|
||||||
30
client/src/features/users/duck/actions.ts
Normal file
30
client/src/features/users/duck/actions.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { usersSlice } from './slice';
|
||||||
|
import { getUsers } from './api';
|
||||||
|
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||||
|
import { getUsersState } from './selectors';
|
||||||
|
import type { RootState } from '../../../store';
|
||||||
|
|
||||||
|
const PREFFIX = 'users'
|
||||||
|
|
||||||
|
export const fetchUsers = createAsyncThunk(
|
||||||
|
`${PREFFIX}/fetchAll`,
|
||||||
|
async (_, { dispatch, getState }) => {
|
||||||
|
const { loading } = getUsersState(getState() as RootState);
|
||||||
|
try {
|
||||||
|
if (loading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch(usersSlice.actions.setLoading(true));
|
||||||
|
const response = await getUsers().then(({ data }) => data);
|
||||||
|
dispatch(usersSlice.actions.setUsers(response));
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
const message =
|
||||||
|
e instanceof Error ? e.message : `Unknown error in ${PREFFIX}/fetchAll`;
|
||||||
|
dispatch(usersSlice.actions.setError(message));
|
||||||
|
} finally {
|
||||||
|
dispatch(usersSlice.actions.setLoading(false));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
5
client/src/features/users/duck/api.ts
Normal file
5
client/src/features/users/duck/api.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import type { AxiosResponse } from 'axios';
|
||||||
|
import { api } from '../../../api/api';
|
||||||
|
import type { UserDTO } from './dto';
|
||||||
|
|
||||||
|
export const getUsers = () => api.get<never, AxiosResponse<UserDTO[]>>('/users');
|
||||||
8
client/src/features/users/duck/dto.ts
Normal file
8
client/src/features/users/duck/dto.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export interface User {}
|
||||||
|
|
||||||
|
export interface UserDTO {
|
||||||
|
page: number
|
||||||
|
per_page: number
|
||||||
|
total: number
|
||||||
|
users: User[]
|
||||||
|
}
|
||||||
4
client/src/features/users/duck/index.ts
Normal file
4
client/src/features/users/duck/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './actions'
|
||||||
|
export * from './dto'
|
||||||
|
export * from './slice'
|
||||||
|
export * from './selectors'
|
||||||
3
client/src/features/users/duck/selectors.ts
Normal file
3
client/src/features/users/duck/selectors.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import type { RootState } from '../../../store';
|
||||||
|
|
||||||
|
export const getUsersState = (state: RootState) => state.users;
|
||||||
32
client/src/features/users/duck/slice.ts
Normal file
32
client/src/features/users/duck/slice.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { createSlice } from '@reduxjs/toolkit';
|
||||||
|
import type { PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
import type { UserDTO, User } from './dto';
|
||||||
|
|
||||||
|
export interface UsersState {
|
||||||
|
loading: boolean;
|
||||||
|
users: User[]
|
||||||
|
error: null | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: UsersState = {
|
||||||
|
loading: false,
|
||||||
|
users: [],
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const usersSlice = createSlice({
|
||||||
|
name: 'certificate',
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
setLoading: (state, action: PayloadAction<boolean>) => {
|
||||||
|
state.loading = action.payload
|
||||||
|
},
|
||||||
|
setUsers: (state, action: PayloadAction<UserDTO[]>) => {
|
||||||
|
state.users = action.payload.users
|
||||||
|
},
|
||||||
|
setError: (state, action: PayloadAction<string>) => {
|
||||||
|
state.error = action.payload
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
1
client/src/features/users/index.ts
Normal file
1
client/src/features/users/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './duck'
|
||||||
2
client/src/hero.ts
Normal file
2
client/src/hero.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
import { heroui } from "@heroui/react";
|
||||||
|
export default heroui();
|
||||||
5
client/src/index.css
Normal file
5
client/src/index.css
Normal 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 *));
|
||||||
22
client/src/main.tsx
Normal file
22
client/src/main.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { StrictMode } from 'react';
|
||||||
|
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}>
|
||||||
|
<HeroUIProvider>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
<ToastProvider/>
|
||||||
|
<ApplyNotificator/>
|
||||||
|
</HeroUIProvider>
|
||||||
|
</Provider>
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
38
client/src/pages/certificates/certificates.tsx
Normal file
38
client/src/pages/certificates/certificates.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import type { RouteObject } from 'react-router';
|
||||||
|
import { useAppDispatch, useAppSelector } from '../../common/hooks';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { fetchCertificates, getCertificatesState } from '../../features';
|
||||||
|
import { CreateCertificate } from '../../features/certificates';
|
||||||
|
import { CertificateList } from '../../features/certificates/components/certificate-list';
|
||||||
|
|
||||||
|
export const Certificates = () => {
|
||||||
|
const dispatch = useAppDispatch()
|
||||||
|
|
||||||
|
const { loading, certificates } = useAppSelector(getCertificatesState)
|
||||||
|
|
||||||
|
useEffect(()=>{
|
||||||
|
dispatch(fetchCertificates())
|
||||||
|
}, [dispatch])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div id="certificates" className="tab-content active">
|
||||||
|
<div className="section">
|
||||||
|
<h2>Add Certificate</h2>
|
||||||
|
<CreateCertificate/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="section">
|
||||||
|
<h2>Certificates List</h2>
|
||||||
|
<div id="certificatesList" className="loading">
|
||||||
|
{ loading && 'Loading...' }
|
||||||
|
{ certificates.length ? <CertificateList certificates={certificates}/> : <p>No certificates found</p> }
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CertificatesRoute: RouteObject = {
|
||||||
|
path: '/certificates',
|
||||||
|
Component: Certificates,
|
||||||
|
};
|
||||||
1
client/src/pages/certificates/index.ts
Normal file
1
client/src/pages/certificates/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './certificates';
|
||||||
72
client/src/pages/dashboard/dashboard.tsx
Normal file
72
client/src/pages/dashboard/dashboard.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import type { RouteObject } from 'react-router';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useAppDispatch, useAppSelector } from '../../common/hooks';
|
||||||
|
import {
|
||||||
|
fetchServers,
|
||||||
|
getServersState,
|
||||||
|
fetchTemplates,
|
||||||
|
getTemplatesState,
|
||||||
|
fetchUsers,
|
||||||
|
getUsersState,
|
||||||
|
getCertificatesState,
|
||||||
|
fetchCertificates,
|
||||||
|
} from '../../features';
|
||||||
|
|
||||||
|
export const Dashboard = () => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { loading: serverLoading, servers } = useAppSelector(getServersState);
|
||||||
|
const { loading: usersLoading, users } = useAppSelector(getUsersState);
|
||||||
|
const { loading: certificatesLoading, certificates } =
|
||||||
|
useAppSelector(getCertificatesState);
|
||||||
|
const { loading: templatesLoading, templates } =
|
||||||
|
useAppSelector(getTemplatesState);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(fetchServers());
|
||||||
|
dispatch(fetchTemplates());
|
||||||
|
dispatch(fetchUsers());
|
||||||
|
dispatch(fetchCertificates());
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div id="dashboard" className="tab-content active">
|
||||||
|
<div className="section">
|
||||||
|
<h2>Statistics</h2>
|
||||||
|
<p>
|
||||||
|
Servers:{' '}
|
||||||
|
<span id="serverCount">
|
||||||
|
{serverLoading === true && 'Loading...'}
|
||||||
|
{servers && String(servers.length)}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Templates:{' '}
|
||||||
|
<span id="templateCount">
|
||||||
|
{templatesLoading && 'Loading...'}
|
||||||
|
{templates && String(templates.length)}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Certificates:{' '}
|
||||||
|
<span id="certCount">
|
||||||
|
{certificatesLoading && 'Loading...'}
|
||||||
|
{certificates && String(certificates.length)}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Users:{' '}
|
||||||
|
<span id="userCount">
|
||||||
|
{usersLoading && 'Loading...'}
|
||||||
|
{users && String(users.length)}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DashboardRoute: RouteObject = {
|
||||||
|
index: true,
|
||||||
|
path: '/',
|
||||||
|
Component: Dashboard,
|
||||||
|
};
|
||||||
1
client/src/pages/dashboard/index.ts
Normal file
1
client/src/pages/dashboard/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './dashboard';
|
||||||
238
client/src/pages/home/home.css
Normal file
238
client/src/pages/home/home.css
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
margin: 20px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.section {
|
||||||
|
background: white;
|
||||||
|
margin: 20px 0;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
color: #666;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
padding: 8px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
background: #f9f9f9;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
padding: 6px 12px;
|
||||||
|
margin: 2px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
background: #007bff;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.btn-success {
|
||||||
|
background: #28a745;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.btn-danger {
|
||||||
|
background: #dc3545;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.btn-secondary {
|
||||||
|
background: #6c757d;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.form-group {
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.form-group input,
|
||||||
|
.form-group select {
|
||||||
|
width: 300px;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
/* Toast notifications */
|
||||||
|
.toast-container {
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
z-index: 9999;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.toast {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
min-width: 300px;
|
||||||
|
max-width: 400px;
|
||||||
|
position: relative;
|
||||||
|
transform: translateX(100%);
|
||||||
|
transition:
|
||||||
|
transform 0.3s ease-in-out,
|
||||||
|
opacity 0.3s ease-in-out;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: auto;
|
||||||
|
border-left: 4px solid #007bff;
|
||||||
|
}
|
||||||
|
.toast.show {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.toast.success {
|
||||||
|
border-left-color: #28a745;
|
||||||
|
}
|
||||||
|
.toast.error {
|
||||||
|
border-left-color: #dc3545;
|
||||||
|
}
|
||||||
|
.toast-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.toast-title {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.toast-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 18px;
|
||||||
|
color: #999;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
.toast-close:hover {
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
.toast-body {
|
||||||
|
color: #666;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.toast.success .toast-title {
|
||||||
|
color: #155724;
|
||||||
|
}
|
||||||
|
.toast.error .toast-title {
|
||||||
|
color: #721c24;
|
||||||
|
}
|
||||||
|
.tabs {
|
||||||
|
border-bottom: 1px solid #ddd;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.tab {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 10px 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
}
|
||||||
|
.tab.active {
|
||||||
|
border-bottom-color: #007bff;
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
.tab-content {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.tab-content.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal styles */
|
||||||
|
.modal {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
z-index: 10000;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
.modal.show {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.modal-content {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
max-width: 600px;
|
||||||
|
width: 90%;
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
.modal-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.modal-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 24px;
|
||||||
|
color: #999;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.modal-close:hover {
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
.modal-body {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.modal-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
padding-top: 15px;
|
||||||
|
}
|
||||||
|
.cert-details {
|
||||||
|
font-family: monospace;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 15px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-x: auto;
|
||||||
|
max-height: 300px;
|
||||||
|
}
|
||||||
82
client/src/pages/home/home.tsx
Normal file
82
client/src/pages/home/home.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { Outlet } from 'react-router';
|
||||||
|
import './home.css';
|
||||||
|
import { NavMenu } from '../../components/nav-menu/nav-menu';
|
||||||
|
import { navItems } from './utils';
|
||||||
|
|
||||||
|
export const Home = () => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="container">
|
||||||
|
<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} />
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* <!-- Modal dialogs --> */}
|
||||||
|
<div id="editModal" className="modal">
|
||||||
|
<div className="modal-content">
|
||||||
|
<div className="modal-header">
|
||||||
|
<div className="modal-title" id="editModalTitle">
|
||||||
|
Edit Item
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="modal-close"
|
||||||
|
// onClick="closeModal('editModal')"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body" id="editModalBody">
|
||||||
|
{/* <!-- Content will be dynamically loaded --> */}
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
// onClick="closeModal('editModal')"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
id="saveEditBtn"
|
||||||
|
// onClick="saveEdit()"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="viewModal" className="modal">
|
||||||
|
<div className="modal-content">
|
||||||
|
<div className="modal-header">
|
||||||
|
<div className="modal-title" id="viewModalTitle">
|
||||||
|
View Details
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="modal-close"
|
||||||
|
//onClick="closeModal('viewModal')"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body" id="viewModalBody">
|
||||||
|
{/* <!-- Content will be dynamically loaded --> */}
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
// onClick="closeModal('viewModal')"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
1
client/src/pages/home/index.ts
Normal file
1
client/src/pages/home/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './home';
|
||||||
26
client/src/pages/home/utils.ts
Normal file
26
client/src/pages/home/utils.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
export const navItems = [
|
||||||
|
{
|
||||||
|
href: '/',
|
||||||
|
label: 'Dashboard',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/servers',
|
||||||
|
label: 'Servers',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/inbound-templates',
|
||||||
|
label: 'Inbound Templates',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/certificates',
|
||||||
|
label: 'Certificates',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/inbound-binding',
|
||||||
|
label: 'Inbound Binding',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/users',
|
||||||
|
label: 'Users',
|
||||||
|
},
|
||||||
|
];
|
||||||
55
client/src/pages/inbound-binding/inbound-binding.tsx
Normal file
55
client/src/pages/inbound-binding/inbound-binding.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import type { RouteObject } from 'react-router';
|
||||||
|
|
||||||
|
export const InboundBinding = () => {
|
||||||
|
return (
|
||||||
|
<div id="inbounds" className="tab-content active">
|
||||||
|
<div className="section">
|
||||||
|
<h2>Bind Template to Server</h2>
|
||||||
|
<form id="inboundForm">
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Server:</label>
|
||||||
|
<select id="inboundServer" required>
|
||||||
|
<option value="">Select Server...</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Template:</label>
|
||||||
|
<select id="inboundTemplate" required>
|
||||||
|
<option value="">Select Template...</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Port:</label>
|
||||||
|
<input type="number" id="inboundPort" value="443" required />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Certificate:</label>
|
||||||
|
<select id="inboundCertificate">
|
||||||
|
<option value="">No Certificate</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" id="inboundActive" checked /> Active
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button type="submit" className="btn btn-primary">
|
||||||
|
Bind Template
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="section">
|
||||||
|
<h2>Server Inbounds</h2>
|
||||||
|
<div id="inboundsList" className="loading">
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const InboundBindingRoute: RouteObject = {
|
||||||
|
path: '/inbound-binding',
|
||||||
|
Component: InboundBinding,
|
||||||
|
};
|
||||||
1
client/src/pages/inbound-binding/index.ts
Normal file
1
client/src/pages/inbound-binding/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './inbound-binding';
|
||||||
39
client/src/pages/inbound-templates/inboud-templates.tsx
Normal file
39
client/src/pages/inbound-templates/inboud-templates.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import type { RouteObject } from 'react-router';
|
||||||
|
import { AddTemplate, fetchTemplates, getTemplatesState, TemplateList } from '../../features/templates';
|
||||||
|
import { useAppDispatch, useAppSelector } from '../../common/hooks';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
export const InboundTemplates = () => {
|
||||||
|
const dispatch = useAppDispatch()
|
||||||
|
const { loading, templates } = useAppSelector(getTemplatesState);
|
||||||
|
|
||||||
|
useEffect(()=>{
|
||||||
|
dispatch(fetchTemplates())
|
||||||
|
}, [dispatch])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div id="templates" className="tab-content active">
|
||||||
|
<div className="section">
|
||||||
|
<h2>Add Template</h2>
|
||||||
|
<AddTemplate />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="section">
|
||||||
|
<h2>Templates List</h2>
|
||||||
|
<div id="templatesList" className="loading">
|
||||||
|
{loading && 'Loading...'}
|
||||||
|
{templates.length ? (
|
||||||
|
<TemplateList templates={templates}/>
|
||||||
|
) : (
|
||||||
|
<p>No templates found</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const InboundTemplatesRoute: RouteObject = {
|
||||||
|
path: '/inbound-templates',
|
||||||
|
Component: InboundTemplates,
|
||||||
|
};
|
||||||
1
client/src/pages/inbound-templates/index.ts
Normal file
1
client/src/pages/inbound-templates/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './inboud-templates';
|
||||||
7
client/src/pages/index.ts
Normal file
7
client/src/pages/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export * from './home';
|
||||||
|
export * from './certificates';
|
||||||
|
export * from './dashboard';
|
||||||
|
export * from './inbound-binding';
|
||||||
|
export * from './inbound-templates';
|
||||||
|
export * from './servers';
|
||||||
|
export * from './users';
|
||||||
1
client/src/pages/servers/index.ts
Normal file
1
client/src/pages/servers/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './servers';
|
||||||
39
client/src/pages/servers/servers.tsx
Normal file
39
client/src/pages/servers/servers.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import type { RouteObject } from 'react-router';
|
||||||
|
import { AddServer } from '../../features/servers/components/add-server/add-server';
|
||||||
|
import { fetchServers, getServersState } from '../../features';
|
||||||
|
import { useAppDispatch, useAppSelector } from '../../common/hooks';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { ServersList } from '../../features/servers/components/servers-list';
|
||||||
|
|
||||||
|
export const Servers = () => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { loading, servers } = useAppSelector(getServersState);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(fetchServers());
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div id="servers" className="tab-content active">
|
||||||
|
<AddServer />
|
||||||
|
|
||||||
|
<div className="section">
|
||||||
|
<h2>Servers List</h2>
|
||||||
|
<div id="serversList" className={clsx({ loading: loading })}>
|
||||||
|
{loading && 'Loading...'}
|
||||||
|
{servers.length ? (
|
||||||
|
<ServersList servers={servers} />
|
||||||
|
) : (
|
||||||
|
<p>No servers found</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ServersRoute: RouteObject = {
|
||||||
|
path: '/servers',
|
||||||
|
Component: Servers,
|
||||||
|
};
|
||||||
1
client/src/pages/users/index.ts
Normal file
1
client/src/pages/users/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './users';
|
||||||
40
client/src/pages/users/users.tsx
Normal file
40
client/src/pages/users/users.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import type { RouteObject } from 'react-router';
|
||||||
|
|
||||||
|
export const Users = () => {
|
||||||
|
return (
|
||||||
|
<div id="users" className="tab-content active">
|
||||||
|
<div className="section">
|
||||||
|
<h2>Add User</h2>
|
||||||
|
<form id="userForm">
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Name:</label>
|
||||||
|
<input type="text" id="userName" required />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Comment:</label>
|
||||||
|
<input type="text" id="userComment" />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Telegram ID:</label>
|
||||||
|
<input type="number" id="userTelegram" />
|
||||||
|
</div>
|
||||||
|
<button type="submit" className="btn btn-primary">
|
||||||
|
Add User
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="section">
|
||||||
|
<h2>Users List</h2>
|
||||||
|
<div id="usersList" className="loading">
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const UsersRoute: RouteObject = {
|
||||||
|
path: '/users',
|
||||||
|
Component: Users,
|
||||||
|
};
|
||||||
1
client/src/router/index.ts
Normal file
1
client/src/router/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './router';
|
||||||
25
client/src/router/router.tsx
Normal file
25
client/src/router/router.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { createBrowserRouter } from 'react-router';
|
||||||
|
import {
|
||||||
|
Home,
|
||||||
|
DashboardRoute,
|
||||||
|
ServersRoute,
|
||||||
|
InboundTemplatesRoute,
|
||||||
|
CertificatesRoute,
|
||||||
|
InboundBindingRoute,
|
||||||
|
UsersRoute,
|
||||||
|
} from '../pages';
|
||||||
|
|
||||||
|
export const router = createBrowserRouter([
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
Component: Home,
|
||||||
|
children: [
|
||||||
|
DashboardRoute,
|
||||||
|
ServersRoute,
|
||||||
|
InboundTemplatesRoute,
|
||||||
|
CertificatesRoute,
|
||||||
|
InboundBindingRoute,
|
||||||
|
UsersRoute,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
1
client/src/store/index.ts
Normal file
1
client/src/store/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './store'
|
||||||
16
client/src/store/store.ts
Normal file
16
client/src/store/store.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { configureStore, combineReducers } from '@reduxjs/toolkit'
|
||||||
|
import {serversSlice, templatesSlice, certificateSlice, usersSlice} from '../features'
|
||||||
|
|
||||||
|
export const store = configureStore({
|
||||||
|
reducer: combineReducers({
|
||||||
|
servers: serversSlice.reducer,
|
||||||
|
templates: templatesSlice.reducer,
|
||||||
|
users: usersSlice.reducer,
|
||||||
|
certificates: certificateSlice.reducer
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Infer the `RootState` and `AppDispatch` types from the store itself
|
||||||
|
export type RootState = ReturnType<typeof store.getState>
|
||||||
|
// Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState}
|
||||||
|
export type AppDispatch = typeof store.dispatch
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user