feat: added template innbounding feature

This commit is contained in:
Home
2025-10-18 17:38:41 +03:00
parent 781d7439af
commit f572b28711
15 changed files with 433 additions and 44 deletions

View File

@@ -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>
)
}

View File

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

View File

@@ -0,0 +1,9 @@
import type { Protocol } from '../../duck/dto'
export const protocolOptions: Record<Protocol, string> = {
vless: 'VLESS',
vmess: 'VMess',
trojan: 'Trojan',
shadowsocks: 'Shadowsocks'
}

View File

@@ -0,0 +1,2 @@
export * from './add-template'
export * from './template-list'

View File

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

View File

@@ -0,0 +1,122 @@
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 ServerEditProps {
templateId: string;
isOpen: boolean;
onOpenChange: () => void;
}
export const TemplateEdit: FC<ServerEditProps> = (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),
};
console.log({data})
dispatch(
updateTemplate({
id: templateId,
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>
<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>
);
};

View File

@@ -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>
);
};

View File

@@ -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}
/>
</>
);
};

View File

@@ -1,8 +1,10 @@
import { templatesSlice } from './slice';
import { getTemplates } from './api';
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';
@@ -27,3 +29,74 @@ export const fetchTemplates = createAsyncThunk(
}
},
);
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;
server: EditTemplateDTO;
},
{ dispatch },
) => {
try {
await patchTemplate(params.id, params.server);
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: 'Server deleted',
type: 'success',
});
dispatch(fetchTemplates());
} catch (e) {
appNotificator.add({
type: 'error',
message:
e instanceof Error
? `Delete error: ${e.message}`
: `Unknown error in ${PREFFIX}/deleteTemplate`,
});
}
},
);

View File

@@ -1,5 +1,23 @@
import type { AxiosResponse } from 'axios';
import { api } from '../../../api/api';
import type { TemplateDTO } from './dto';
import type { TemplateDTO, CreateTemplateDTO, EditTemplateDTO } from './dto';
export const getTemplates = () => api.get<never, AxiosResponse<TemplateDTO[]>>('/templates');
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, server: EditTemplateDTO) =>
api.put(`/templates/${id}`, server, {
headers: { 'Content-Type': 'application/json' },
});
export const deleteTemplate = (id: string) => api.delete(`/templates/${id}`);

View File

@@ -1 +1,33 @@
export interface TemplateDTO {}
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
}

View File

@@ -1 +1,2 @@
export * from './duck'
export * from './duck'
export * from './components'

View 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
}

View File

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

View File

@@ -1,53 +1,32 @@
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>
<form id="templateForm">
<div className="form-group">
<label>Name:</label>
<input type="text" id="templateName" required />
</div>
<div className="form-group">
<label>Protocol:</label>
<select id="templateProtocol" required>
<option value="vless">VLESS</option>
<option value="vmess">VMess</option>
<option value="trojan">Trojan</option>
<option value="shadowsocks">Shadowsocks</option>
</select>
</div>
<div className="form-group">
<label>Default Port:</label>
<input type="number" id="templatePort" value="443" required />
</div>
<div className="form-group">
<label>
<input type="checkbox" id="templateTls" /> Requires TLS
</label>
</div>
<div className="form-group">
<label>Configuration Template:</label>
<textarea
id="templateConfig"
rows={6}
style={{
width: '300px',
}}
></textarea>
</div>
<button type="submit" className="btn btn-primary">
Add Template
</button>
</form>
<AddTemplate />
</div>
<div className="section">
<h2>Templates List</h2>
<div id="templatesList" className="loading">
Loading...
{loading && 'Loading...'}
{templates.length ? (
<TemplateList templates={templates}/>
) : (
<p>No templates found</p>
)}
</div>
</div>
</div>
@@ -57,4 +36,4 @@ export const InboundTemplates = () => {
export const InboundTemplatesRoute: RouteObject = {
path: '/inbound-templates',
Component: InboundTemplates,
};
};