mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-31 18:24:10 +00:00
feat(clients): add top-level Clients tab and CRUD API
Adds /panel/api/clients endpoints (list, get, add, update, del, attach, detach) backed by ClientService methods that orchestrate the per-inbound Add/Update/Del flows so a single client row is created once and attached to many inbounds in one operation. The frontend gains a dedicated Clients page (frontend/clients.html + src/pages/clients/) with an AntD table, multi-inbound attach modal, and full CRUD. Axios interceptor learns to honour Content-Type: application/json so the JSON endpoints work alongside the legacy form-encoded ones. The legacy per-inbound client modal stays untouched in this PR — both flows now write to the same source of truth. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
ba3c581372
commit
2bcf287cf1
15 changed files with 1221 additions and 4 deletions
13
frontend/clients.html
Normal file
13
frontend/clients.html
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Clients</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="message"></div>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/entries/clients.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -76,7 +76,14 @@ export function setupAxios() {
|
||||||
if (config.data instanceof FormData) {
|
if (config.data instanceof FormData) {
|
||||||
config.headers['Content-Type'] = 'multipart/form-data';
|
config.headers['Content-Type'] = 'multipart/form-data';
|
||||||
} else {
|
} else {
|
||||||
config.data = qs.stringify(config.data, { arrayFormat: 'repeat' });
|
const declaredType = String(config.headers['Content-Type'] || config.headers['content-type'] || '');
|
||||||
|
if (declaredType.toLowerCase().startsWith('application/json')) {
|
||||||
|
if (config.data !== undefined && typeof config.data !== 'string') {
|
||||||
|
config.data = JSON.stringify(config.data);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
config.data = qs.stringify(config.data, { arrayFormat: 'repeat' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return config;
|
return config;
|
||||||
},
|
},
|
||||||
|
|
@ -104,9 +111,14 @@ export function setupAxios() {
|
||||||
if (token) {
|
if (token) {
|
||||||
cfg.headers = cfg.headers || {};
|
cfg.headers = cfg.headers || {};
|
||||||
cfg.headers['X-CSRF-Token'] = token;
|
cfg.headers['X-CSRF-Token'] = token;
|
||||||
// axios re-stringifies on retry, so unwind our qs.stringify before
|
const declaredType = String(cfg.headers['Content-Type'] || cfg.headers['content-type'] || '');
|
||||||
// letting the same request flow through the interceptor again.
|
if (typeof cfg.data === 'string') {
|
||||||
if (typeof cfg.data === 'string') cfg.data = qs.parse(cfg.data);
|
if (declaredType.toLowerCase().startsWith('application/json')) {
|
||||||
|
try { cfg.data = JSON.parse(cfg.data); } catch (_e) { /* keep as-is */ }
|
||||||
|
} else {
|
||||||
|
cfg.data = qs.parse(cfg.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
return axios(cfg);
|
return axios(cfg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n';
|
||||||
import {
|
import {
|
||||||
DashboardOutlined,
|
DashboardOutlined,
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
|
TeamOutlined,
|
||||||
SettingOutlined,
|
SettingOutlined,
|
||||||
ToolOutlined,
|
ToolOutlined,
|
||||||
ClusterOutlined,
|
ClusterOutlined,
|
||||||
|
|
@ -30,6 +31,7 @@ const props = defineProps({
|
||||||
const iconByName = {
|
const iconByName = {
|
||||||
dashboard: DashboardOutlined,
|
dashboard: DashboardOutlined,
|
||||||
user: UserOutlined,
|
user: UserOutlined,
|
||||||
|
team: TeamOutlined,
|
||||||
setting: SettingOutlined,
|
setting: SettingOutlined,
|
||||||
tool: ToolOutlined,
|
tool: ToolOutlined,
|
||||||
cluster: ClusterOutlined,
|
cluster: ClusterOutlined,
|
||||||
|
|
@ -42,6 +44,7 @@ const prefix = props.basePath?.startsWith('/') ? props.basePath : `/${props.base
|
||||||
const tabs = computed(() => [
|
const tabs = computed(() => [
|
||||||
{ key: `${prefix}panel/`, icon: 'dashboard', title: t('menu.dashboard') },
|
{ key: `${prefix}panel/`, icon: 'dashboard', title: t('menu.dashboard') },
|
||||||
{ key: `${prefix}panel/inbounds`, icon: 'user', title: t('menu.inbounds') },
|
{ key: `${prefix}panel/inbounds`, icon: 'user', title: t('menu.inbounds') },
|
||||||
|
{ key: `${prefix}panel/clients`, icon: 'team', title: t('menu.clients') },
|
||||||
{ key: `${prefix}panel/nodes`, icon: 'cluster', title: t('menu.nodes') },
|
{ key: `${prefix}panel/nodes`, icon: 'cluster', title: t('menu.nodes') },
|
||||||
{ key: `${prefix}panel/settings`, icon: 'setting', title: t('menu.settings') },
|
{ key: `${prefix}panel/settings`, icon: 'setting', title: t('menu.settings') },
|
||||||
{ key: `${prefix}panel/xray`, icon: 'tool', title: t('menu.xray') },
|
{ key: `${prefix}panel/xray`, icon: 'tool', title: t('menu.xray') },
|
||||||
|
|
|
||||||
21
frontend/src/entries/clients.js
Normal file
21
frontend/src/entries/clients.js
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { createApp } from 'vue';
|
||||||
|
import Antd, { message } from 'ant-design-vue';
|
||||||
|
import 'ant-design-vue/dist/reset.css';
|
||||||
|
|
||||||
|
import { setupAxios } from '@/api/axios-init.js';
|
||||||
|
import '@/composables/useTheme.js';
|
||||||
|
import { i18n, readyI18n } from '@/i18n/index.js';
|
||||||
|
import { applyDocumentTitle } from '@/utils';
|
||||||
|
import ClientsPage from '@/pages/clients/ClientsPage.vue';
|
||||||
|
|
||||||
|
setupAxios();
|
||||||
|
applyDocumentTitle();
|
||||||
|
|
||||||
|
const messageContainer = document.getElementById('message');
|
||||||
|
if (messageContainer) {
|
||||||
|
message.config({ getContainer: () => messageContainer });
|
||||||
|
}
|
||||||
|
|
||||||
|
readyI18n().then(() => {
|
||||||
|
createApp(ClientsPage).use(Antd).use(i18n).mount('#app');
|
||||||
|
});
|
||||||
|
|
@ -494,6 +494,85 @@ export const sections = [
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'clients',
|
||||||
|
title: 'Clients',
|
||||||
|
description:
|
||||||
|
'Manage clients as first-class entities that can be attached to one or more inbounds. A single client row drives the settings.clients entry in every inbound it belongs to. Endpoints live under /panel/api/clients.',
|
||||||
|
endpoints: [
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/panel/api/clients/list',
|
||||||
|
summary: 'List every client with its attached inbound IDs and traffic record.',
|
||||||
|
response:
|
||||||
|
'{\n "success": true,\n "obj": [\n {\n "id": 1,\n "email": "alice@example.com",\n "subId": "abcd1234",\n "uuid": "...",\n "totalGB": 53687091200,\n "expiryTime": 1735689600000,\n "enable": true,\n "inboundIds": [3, 5],\n "traffic": { "up": 1024, "down": 4096, "enable": true }\n }\n ]\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/panel/api/clients/get/:id',
|
||||||
|
summary: 'Fetch one client by its numeric id, including the inbound IDs it is attached to.',
|
||||||
|
params: [
|
||||||
|
{ name: 'id', in: 'path', type: 'integer', desc: 'Numeric client id from the clients table.' },
|
||||||
|
],
|
||||||
|
response:
|
||||||
|
'{\n "success": true,\n "obj": {\n "client": { "id": 1, "email": "alice@example.com", ... },\n "inboundIds": [3, 5]\n }\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/clients/add',
|
||||||
|
summary: 'Create a new client and attach it to one or more inbounds in a single call. Body is JSON.',
|
||||||
|
params: [
|
||||||
|
{ name: 'client', in: 'body (json)', type: 'object', desc: 'Client fields: email, subId, id (uuid), password, auth, totalGB, expiryTime, limitIp, comment, enable.' },
|
||||||
|
{ name: 'inboundIds', in: 'body (json)', type: 'integer[]', desc: 'Inbound IDs to attach the client to. At least one required.' },
|
||||||
|
],
|
||||||
|
body: '{\n "client": {\n "email": "alice@example.com",\n "totalGB": 53687091200,\n "expiryTime": 1735689600000\n },\n "inboundIds": [3, 5]\n}',
|
||||||
|
response: '{\n "success": true,\n "msg": "Client added"\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/clients/update/:id',
|
||||||
|
summary: 'Update an existing client. Changes propagate to every attached inbound. Body is the JSON client payload.',
|
||||||
|
params: [
|
||||||
|
{ name: 'id', in: 'path', type: 'integer', desc: 'Numeric client id.' },
|
||||||
|
],
|
||||||
|
body: '{\n "email": "alice@example.com",\n "totalGB": 107374182400,\n "expiryTime": 1767225600000,\n "enable": true\n}',
|
||||||
|
response: '{\n "success": true,\n "msg": "Client updated"\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/clients/del/:id',
|
||||||
|
summary: 'Delete a client. Removes it from every attached inbound and drops its traffic record unless keepTraffic=1 is passed.',
|
||||||
|
params: [
|
||||||
|
{ name: 'id', in: 'path', type: 'integer', desc: 'Numeric client id.' },
|
||||||
|
{ name: 'keepTraffic', in: 'query', type: 'integer', desc: 'Pass 1 to retain the xray_client_traffic row after deletion.' },
|
||||||
|
],
|
||||||
|
response: '{\n "success": true,\n "msg": "Client deleted"\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/clients/:id/attach',
|
||||||
|
summary: 'Attach an existing client to one or more additional inbounds. Body is JSON.',
|
||||||
|
params: [
|
||||||
|
{ name: 'id', in: 'path', type: 'integer', desc: 'Numeric client id.' },
|
||||||
|
{ name: 'inboundIds', in: 'body (json)', type: 'integer[]', desc: 'Inbound IDs to attach.' },
|
||||||
|
],
|
||||||
|
body: '{\n "inboundIds": [7, 9]\n}',
|
||||||
|
response: '{\n "success": true\n}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/clients/:id/detach',
|
||||||
|
summary: 'Detach a client from one or more inbounds without deleting the client.',
|
||||||
|
params: [
|
||||||
|
{ name: 'id', in: 'path', type: 'integer', desc: 'Numeric client id.' },
|
||||||
|
{ name: 'inboundIds', in: 'body (json)', type: 'integer[]', desc: 'Inbound IDs to detach.' },
|
||||||
|
],
|
||||||
|
body: '{\n "inboundIds": [5]\n}',
|
||||||
|
response: '{\n "success": true\n}',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
id: 'nodes',
|
id: 'nodes',
|
||||||
title: 'Nodes',
|
title: 'Nodes',
|
||||||
|
|
|
||||||
230
frontend/src/pages/clients/ClientFormModal.vue
Normal file
230
frontend/src/pages/clients/ClientFormModal.vue
Normal file
|
|
@ -0,0 +1,230 @@
|
||||||
|
<script setup>
|
||||||
|
import { computed, reactive, ref, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { RandomUtil } from '@/utils';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
open: { type: Boolean, default: false },
|
||||||
|
mode: { type: String, default: 'add' },
|
||||||
|
client: { type: Object, default: null },
|
||||||
|
inbounds: { type: Array, default: () => [] },
|
||||||
|
attachedIds: { type: Array, default: () => [] },
|
||||||
|
save: { type: Function, required: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:open']);
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const submitting = ref(false);
|
||||||
|
const form = reactive(emptyForm());
|
||||||
|
|
||||||
|
function emptyForm() {
|
||||||
|
return {
|
||||||
|
email: '',
|
||||||
|
subId: '',
|
||||||
|
uuid: '',
|
||||||
|
password: '',
|
||||||
|
auth: '',
|
||||||
|
totalGB: 0,
|
||||||
|
expiryTime: null,
|
||||||
|
limitIp: 0,
|
||||||
|
comment: '',
|
||||||
|
enable: true,
|
||||||
|
inboundIds: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const isEdit = computed(() => props.mode === 'edit');
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.open,
|
||||||
|
(next) => {
|
||||||
|
if (!next) return;
|
||||||
|
Object.assign(form, emptyForm());
|
||||||
|
if (isEdit.value && props.client) {
|
||||||
|
form.email = props.client.email || '';
|
||||||
|
form.subId = props.client.subId || '';
|
||||||
|
form.uuid = props.client.uuid || '';
|
||||||
|
form.password = props.client.password || '';
|
||||||
|
form.auth = props.client.auth || '';
|
||||||
|
form.totalGB = bytesToGB(props.client.totalGB || 0);
|
||||||
|
form.expiryTime = props.client.expiryTime ? dayjs(props.client.expiryTime) : null;
|
||||||
|
form.limitIp = props.client.limitIp || 0;
|
||||||
|
form.comment = props.client.comment || '';
|
||||||
|
form.enable = !!props.client.enable;
|
||||||
|
form.inboundIds = Array.isArray(props.attachedIds) ? [...props.attachedIds] : [];
|
||||||
|
} else {
|
||||||
|
form.uuid = RandomUtil.randomUUID();
|
||||||
|
form.subId = RandomUtil.randomLowerAndNum(16);
|
||||||
|
form.password = RandomUtil.randomLowerAndNum(16);
|
||||||
|
form.auth = RandomUtil.randomLowerAndNum(16);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function bytesToGB(bytes) {
|
||||||
|
if (!bytes || bytes <= 0) return 0;
|
||||||
|
return Math.round((bytes / (1024 * 1024 * 1024)) * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function gbToBytes(gb) {
|
||||||
|
if (!gb || gb <= 0) return 0;
|
||||||
|
return Math.round(gb * 1024 * 1024 * 1024);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inboundOptions = computed(() =>
|
||||||
|
(props.inbounds || []).map((ib) => ({
|
||||||
|
label: `${ib.remark || `#${ib.id}`} · ${ib.protocol}:${ib.port}`,
|
||||||
|
value: ib.id,
|
||||||
|
title: `${ib.remark || ''} (${ib.protocol}:${ib.port})`,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
emit('update:open', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function regenerateUUID() {
|
||||||
|
form.uuid = RandomUtil.randomUUID();
|
||||||
|
}
|
||||||
|
|
||||||
|
function regeneratePassword() {
|
||||||
|
form.password = RandomUtil.randomLowerAndNum(16);
|
||||||
|
}
|
||||||
|
|
||||||
|
function regenerateAuth() {
|
||||||
|
form.auth = RandomUtil.randomLowerAndNum(16);
|
||||||
|
}
|
||||||
|
|
||||||
|
function regenerateSubId() {
|
||||||
|
form.subId = RandomUtil.randomLowerAndNum(16);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit() {
|
||||||
|
if (!form.email || form.email.trim() === '') {
|
||||||
|
message.error(t('pages.inbounds.client.email') + ' *');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isEdit.value && (!form.inboundIds || form.inboundIds.length === 0)) {
|
||||||
|
message.error(t('pages.clients.selectInbound'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const clientPayload = {
|
||||||
|
email: form.email.trim(),
|
||||||
|
subId: form.subId,
|
||||||
|
id: form.uuid,
|
||||||
|
password: form.password,
|
||||||
|
auth: form.auth,
|
||||||
|
totalGB: gbToBytes(form.totalGB),
|
||||||
|
expiryTime: form.expiryTime ? form.expiryTime.valueOf() : 0,
|
||||||
|
limitIp: Number(form.limitIp) || 0,
|
||||||
|
comment: form.comment,
|
||||||
|
enable: !!form.enable,
|
||||||
|
};
|
||||||
|
|
||||||
|
submitting.value = true;
|
||||||
|
try {
|
||||||
|
let msg;
|
||||||
|
if (isEdit.value) {
|
||||||
|
msg = await props.save(clientPayload, { isEdit: true, id: props.client.id });
|
||||||
|
} else {
|
||||||
|
msg = await props.save(
|
||||||
|
{ client: clientPayload, inboundIds: form.inboundIds },
|
||||||
|
{ isEdit: false },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (msg?.success) close();
|
||||||
|
} finally {
|
||||||
|
submitting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a-modal :open="open" :title="isEdit ? t('pages.clients.editTitle') : t('pages.clients.addTitle')"
|
||||||
|
:destroy-on-close="true" :ok-text="isEdit ? t('save') : t('add')" :cancel-text="t('cancel')"
|
||||||
|
:ok-button-props="{ loading: submitting }" :width="720" @ok="onSubmit" @cancel="close">
|
||||||
|
<a-form layout="vertical" :model="form">
|
||||||
|
<a-row :gutter="16">
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item :label="t('pages.inbounds.client.email')" required>
|
||||||
|
<a-input v-model:value="form.email" :placeholder="t('pages.inbounds.client.email')" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item :label="t('pages.inbounds.client.subId') || 'subId'">
|
||||||
|
<a-input-group compact style="display: flex">
|
||||||
|
<a-input v-model:value="form.subId" style="flex: 1" />
|
||||||
|
<a-button @click="regenerateSubId">↻</a-button>
|
||||||
|
</a-input-group>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<a-row :gutter="16">
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item label="UUID">
|
||||||
|
<a-input-group compact style="display: flex">
|
||||||
|
<a-input v-model:value="form.uuid" style="flex: 1" />
|
||||||
|
<a-button @click="regenerateUUID">↻</a-button>
|
||||||
|
</a-input-group>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item :label="t('pages.inbounds.client.password') || 'Password'">
|
||||||
|
<a-input-group compact style="display: flex">
|
||||||
|
<a-input v-model:value="form.password" style="flex: 1" />
|
||||||
|
<a-button @click="regeneratePassword">↻</a-button>
|
||||||
|
</a-input-group>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<a-row :gutter="16">
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item label="Auth (Hysteria)">
|
||||||
|
<a-input-group compact style="display: flex">
|
||||||
|
<a-input v-model:value="form.auth" style="flex: 1" />
|
||||||
|
<a-button @click="regenerateAuth">↻</a-button>
|
||||||
|
</a-input-group>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item :label="t('pages.inbounds.client.limitIp') || 'IP limit'">
|
||||||
|
<a-input-number v-model:value="form.limitIp" :min="0" style="width: 100%" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<a-row :gutter="16">
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item :label="t('pages.inbounds.client.totalGB') || 'Total (GB, 0 = unlimited)'">
|
||||||
|
<a-input-number v-model:value="form.totalGB" :min="0" :step="0.1" style="width: 100%" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item :label="t('pages.inbounds.client.expiryTime') || 'Expiry'">
|
||||||
|
<a-date-picker v-model:value="form.expiryTime" show-time style="width: 100%" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<a-form-item :label="t('pages.inbounds.client.comment') || 'Comment'">
|
||||||
|
<a-input v-model:value="form.comment" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-if="!isEdit" :label="t('pages.clients.attachedInbounds') || 'Attach to inbounds'" required>
|
||||||
|
<a-select v-model:value="form.inboundIds" mode="multiple" :options="inboundOptions" :show-search="true"
|
||||||
|
:placeholder="t('pages.clients.selectInbound') || 'Select one or more inbounds'"
|
||||||
|
:filter-option="(input, option) => (option.label || '').toLowerCase().includes(input.toLowerCase())" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item>
|
||||||
|
<a-switch v-model:checked="form.enable" />
|
||||||
|
<span style="margin-left: 8px">{{ t('enable') }}</span>
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
217
frontend/src/pages/clients/ClientsPage.vue
Normal file
217
frontend/src/pages/clients/ClientsPage.vue
Normal file
|
|
@ -0,0 +1,217 @@
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { Modal, message } from 'ant-design-vue';
|
||||||
|
import { PlusOutlined, UserOutlined } from '@ant-design/icons-vue';
|
||||||
|
|
||||||
|
import { theme as themeState, antdThemeConfig } from '@/composables/useTheme.js';
|
||||||
|
import { useMediaQuery } from '@/composables/useMediaQuery.js';
|
||||||
|
import AppSidebar from '@/components/AppSidebar.vue';
|
||||||
|
import { SizeFormatter, IntlUtil } from '@/utils';
|
||||||
|
import { useClients } from './useClients.js';
|
||||||
|
import ClientFormModal from './ClientFormModal.vue';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const {
|
||||||
|
clients,
|
||||||
|
inbounds,
|
||||||
|
loading,
|
||||||
|
fetched,
|
||||||
|
create,
|
||||||
|
update,
|
||||||
|
remove,
|
||||||
|
} = useClients();
|
||||||
|
|
||||||
|
const { isMobile } = useMediaQuery();
|
||||||
|
const basePath = window.X_UI_BASE_PATH || '';
|
||||||
|
const requestUri = window.location.pathname;
|
||||||
|
|
||||||
|
const formOpen = ref(false);
|
||||||
|
const formMode = ref('add');
|
||||||
|
const editingClient = ref(null);
|
||||||
|
const editingAttachedIds = ref([]);
|
||||||
|
|
||||||
|
const inboundsById = computed(() => {
|
||||||
|
const out = {};
|
||||||
|
for (const ib of inbounds.value) out[ib.id] = ib;
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
|
function inboundLabel(id) {
|
||||||
|
const ib = inboundsById.value[id];
|
||||||
|
if (!ib) return `#${id}`;
|
||||||
|
return ib.remark ? `${ib.remark} (${ib.protocol}:${ib.port})` : `${ib.protocol}:${ib.port}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAdd() {
|
||||||
|
formMode.value = 'add';
|
||||||
|
editingClient.value = null;
|
||||||
|
editingAttachedIds.value = [];
|
||||||
|
formOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onEdit(row) {
|
||||||
|
formMode.value = 'edit';
|
||||||
|
editingClient.value = { ...row };
|
||||||
|
editingAttachedIds.value = Array.isArray(row.inboundIds) ? [...row.inboundIds] : [];
|
||||||
|
formOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDelete(row) {
|
||||||
|
Modal.confirm({
|
||||||
|
title: t('pages.clients.deleteConfirmTitle', { email: row.email }) || `Delete ${row.email}?`,
|
||||||
|
content: t('pages.clients.deleteConfirmContent')
|
||||||
|
|| 'This removes the client from every attached inbound and drops its traffic record.',
|
||||||
|
okText: t('delete'),
|
||||||
|
okType: 'danger',
|
||||||
|
cancelText: t('cancel'),
|
||||||
|
onOk: async () => {
|
||||||
|
const msg = await remove(row.id);
|
||||||
|
if (msg?.success) message.success(t('pages.clients.toasts.deleted') || 'Client deleted');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSave(payload, meta) {
|
||||||
|
if (meta?.isEdit) {
|
||||||
|
return update(meta.id, payload);
|
||||||
|
}
|
||||||
|
return create(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
function trafficLabel(row) {
|
||||||
|
const t0 = row.traffic;
|
||||||
|
if (!t0) return '-';
|
||||||
|
const used = (t0.up || 0) + (t0.down || 0);
|
||||||
|
const total = row.totalGB || 0;
|
||||||
|
if (total <= 0) return `${SizeFormatter.sizeFormat(used)} / ∞`;
|
||||||
|
return `${SizeFormatter.sizeFormat(used)} / ${SizeFormatter.sizeFormat(total)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function expiryLabel(row) {
|
||||||
|
if (!row.expiryTime || row.expiryTime <= 0) return '-';
|
||||||
|
return IntlUtil.formatDate(row.expiryTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = computed(() => [
|
||||||
|
{ title: t('pages.inbounds.client.email') || 'Email', dataIndex: 'email', key: 'email' },
|
||||||
|
{ title: 'subId', dataIndex: 'subId', key: 'subId' },
|
||||||
|
{ title: t('pages.clients.attachedInbounds') || 'Attached inbounds', key: 'inboundIds' },
|
||||||
|
{ title: t('pages.inbounds.traffic') || 'Traffic', key: 'traffic' },
|
||||||
|
{ title: t('pages.inbounds.client.expiryTime') || 'Expiry', key: 'expiryTime' },
|
||||||
|
{ title: t('enable'), key: 'enable', width: 90 },
|
||||||
|
{ title: t('actions') || 'Actions', key: 'actions', width: 160 },
|
||||||
|
]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a-config-provider :theme="antdThemeConfig">
|
||||||
|
<a-layout class="clients-page" :class="{ 'is-dark': themeState.isDark, 'is-ultra': themeState.isUltra }">
|
||||||
|
<AppSidebar :base-path="basePath" :request-uri="requestUri" />
|
||||||
|
|
||||||
|
<a-layout class="content-shell">
|
||||||
|
<a-layout-content id="content-layout" class="content-area">
|
||||||
|
<a-spin :spinning="!fetched" :delay="200" tip="Loading…" size="large">
|
||||||
|
<div v-if="!fetched" class="loading-spacer" />
|
||||||
|
|
||||||
|
<a-row v-else :gutter="[isMobile ? 8 : 16, isMobile ? 8 : 12]">
|
||||||
|
<a-col :span="24">
|
||||||
|
<a-card size="small" :title="t('menu.clients') || 'Clients'">
|
||||||
|
<template #extra>
|
||||||
|
<a-button type="primary" @click="onAdd">
|
||||||
|
<template #icon>
|
||||||
|
<PlusOutlined />
|
||||||
|
</template>
|
||||||
|
{{ t('add') }}
|
||||||
|
</a-button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<a-table :columns="columns" :data-source="clients" :loading="loading" row-key="id" :pagination="{ pageSize: 20, showSizeChanger: true, pageSizeOptions: ['10','20','50','100'] }" size="small">
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<template v-if="column.key === 'inboundIds'">
|
||||||
|
<a-tag v-for="id in record.inboundIds" :key="id" color="blue" style="margin: 2px">
|
||||||
|
{{ inboundLabel(id) }}
|
||||||
|
</a-tag>
|
||||||
|
<span v-if="!record.inboundIds || record.inboundIds.length === 0" style="color: rgba(0,0,0,0.45)">—</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'traffic'">
|
||||||
|
{{ trafficLabel(record) }}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'expiryTime'">
|
||||||
|
{{ expiryLabel(record) }}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'enable'">
|
||||||
|
<a-tag :color="record.enable ? 'green' : 'default'">
|
||||||
|
{{ record.enable ? t('enable') : t('disable') }}
|
||||||
|
</a-tag>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'actions'">
|
||||||
|
<a-space>
|
||||||
|
<a-button size="small" @click="onEdit(record)">{{ t('edit') }}</a-button>
|
||||||
|
<a-button size="small" danger @click="onDelete(record)">{{ t('delete') }}</a-button>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #emptyText>
|
||||||
|
<div style="padding: 32px 0; color: rgba(0, 0, 0, 0.45); text-align: center">
|
||||||
|
<UserOutlined style="font-size: 32px; margin-bottom: 8px" />
|
||||||
|
<div>{{ t('pages.clients.empty') || 'No clients yet.' }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
</a-card>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
</a-spin>
|
||||||
|
</a-layout-content>
|
||||||
|
</a-layout>
|
||||||
|
|
||||||
|
<ClientFormModal v-model:open="formOpen" :mode="formMode" :client="editingClient"
|
||||||
|
:attached-ids="editingAttachedIds" :inbounds="inbounds" :save="onSave" />
|
||||||
|
</a-layout>
|
||||||
|
</a-config-provider>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.clients-page {
|
||||||
|
--bg-page: #e6e8ec;
|
||||||
|
--bg-card: #ffffff;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--bg-page);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clients-page.is-dark {
|
||||||
|
--bg-page: #1e1e1e;
|
||||||
|
--bg-card: #252526;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clients-page.is-dark.is-ultra {
|
||||||
|
--bg-page: #050505;
|
||||||
|
--bg-card: #0c0e12;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clients-page :deep(.ant-layout),
|
||||||
|
.clients-page :deep(.ant-layout-content) {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-shell {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-area {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.content-area {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spacer {
|
||||||
|
min-height: calc(100vh - 120px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
78
frontend/src/pages/clients/useClients.js
Normal file
78
frontend/src/pages/clients/useClients.js
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
import { onMounted, ref, shallowRef } from 'vue';
|
||||||
|
import { HttpUtil } from '@/utils';
|
||||||
|
|
||||||
|
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } };
|
||||||
|
|
||||||
|
export function useClients() {
|
||||||
|
const clients = shallowRef([]);
|
||||||
|
const inbounds = shallowRef([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const fetched = ref(false);
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const [clientsMsg, inboundsMsg] = await Promise.all([
|
||||||
|
HttpUtil.get('/panel/api/clients/list'),
|
||||||
|
HttpUtil.get('/panel/api/inbounds/list'),
|
||||||
|
]);
|
||||||
|
if (clientsMsg?.success) {
|
||||||
|
clients.value = Array.isArray(clientsMsg.obj) ? clientsMsg.obj : [];
|
||||||
|
}
|
||||||
|
if (inboundsMsg?.success) {
|
||||||
|
inbounds.value = Array.isArray(inboundsMsg.obj) ? inboundsMsg.obj : [];
|
||||||
|
}
|
||||||
|
fetched.value = true;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function create(payload) {
|
||||||
|
const msg = await HttpUtil.post('/panel/api/clients/add', payload, JSON_HEADERS);
|
||||||
|
if (msg?.success) await refresh();
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function update(id, client) {
|
||||||
|
const msg = await HttpUtil.post(`/panel/api/clients/update/${id}`, client, JSON_HEADERS);
|
||||||
|
if (msg?.success) await refresh();
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id, keepTraffic = false) {
|
||||||
|
const url = keepTraffic
|
||||||
|
? `/panel/api/clients/del/${id}?keepTraffic=1`
|
||||||
|
: `/panel/api/clients/del/${id}`;
|
||||||
|
const msg = await HttpUtil.post(url);
|
||||||
|
if (msg?.success) await refresh();
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attach(id, inboundIds) {
|
||||||
|
const msg = await HttpUtil.post(`/panel/api/clients/${id}/attach`, { inboundIds }, JSON_HEADERS);
|
||||||
|
if (msg?.success) await refresh();
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function detach(id, inboundIds) {
|
||||||
|
const msg = await HttpUtil.post(`/panel/api/clients/${id}/detach`, { inboundIds }, JSON_HEADERS);
|
||||||
|
if (msg?.success) await refresh();
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(refresh);
|
||||||
|
|
||||||
|
return {
|
||||||
|
clients,
|
||||||
|
inbounds,
|
||||||
|
loading,
|
||||||
|
fetched,
|
||||||
|
refresh,
|
||||||
|
create,
|
||||||
|
update,
|
||||||
|
remove,
|
||||||
|
attach,
|
||||||
|
detach,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -22,6 +22,8 @@ const BASE_MIGRATED_ROUTES = {
|
||||||
'panel/settings/': '/settings.html',
|
'panel/settings/': '/settings.html',
|
||||||
'panel/inbounds': '/inbounds.html',
|
'panel/inbounds': '/inbounds.html',
|
||||||
'panel/inbounds/': '/inbounds.html',
|
'panel/inbounds/': '/inbounds.html',
|
||||||
|
'panel/clients': '/clients.html',
|
||||||
|
'panel/clients/': '/clients.html',
|
||||||
'panel/xray': '/xray.html',
|
'panel/xray': '/xray.html',
|
||||||
'panel/xray/': '/xray.html',
|
'panel/xray/': '/xray.html',
|
||||||
'panel/nodes': '/nodes.html',
|
'panel/nodes': '/nodes.html',
|
||||||
|
|
@ -150,6 +152,7 @@ export default defineConfig({
|
||||||
login: path.resolve(__dirname, 'login.html'),
|
login: path.resolve(__dirname, 'login.html'),
|
||||||
settings: path.resolve(__dirname, 'settings.html'),
|
settings: path.resolve(__dirname, 'settings.html'),
|
||||||
inbounds: path.resolve(__dirname, 'inbounds.html'),
|
inbounds: path.resolve(__dirname, 'inbounds.html'),
|
||||||
|
clients: path.resolve(__dirname, 'clients.html'),
|
||||||
xray: path.resolve(__dirname, 'xray.html'),
|
xray: path.resolve(__dirname, 'xray.html'),
|
||||||
nodes: path.resolve(__dirname, 'nodes.html'),
|
nodes: path.resolve(__dirname, 'nodes.html'),
|
||||||
apiDocs: path.resolve(__dirname, 'api-docs.html'),
|
apiDocs: path.resolve(__dirname, 'api-docs.html'),
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,9 @@ func (a *APIController) initRouter(g *gin.RouterGroup, customGeo *service.Custom
|
||||||
inbounds := api.Group("/inbounds")
|
inbounds := api.Group("/inbounds")
|
||||||
a.inboundController = NewInboundController(inbounds)
|
a.inboundController = NewInboundController(inbounds)
|
||||||
|
|
||||||
|
clients := api.Group("/clients")
|
||||||
|
NewClientController(clients)
|
||||||
|
|
||||||
// Server API
|
// Server API
|
||||||
server := api.Group("/server")
|
server := api.Group("/server")
|
||||||
a.serverController = NewServerController(server)
|
a.serverController = NewServerController(server)
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,8 @@ func TestAPIRoutesDocumented(t *testing.T) {
|
||||||
basePath = "/panel/api"
|
basePath = "/panel/api"
|
||||||
case "inbound.go":
|
case "inbound.go":
|
||||||
basePath = "/panel/api/inbounds"
|
basePath = "/panel/api/inbounds"
|
||||||
|
case "client.go":
|
||||||
|
basePath = "/panel/api/clients"
|
||||||
case "server.go":
|
case "server.go":
|
||||||
basePath = "/panel/api/server"
|
basePath = "/panel/api/server"
|
||||||
case "node.go":
|
case "node.go":
|
||||||
|
|
@ -127,6 +129,7 @@ func TestAPIRoutesDocumented(t *testing.T) {
|
||||||
// Skip SPA page routes (these are UI pages, not API endpoints)
|
// Skip SPA page routes (these are UI pages, not API endpoints)
|
||||||
spaPages := map[string]bool{
|
spaPages := map[string]bool{
|
||||||
"/": true, "/panel/": true, "/panel/inbounds": true,
|
"/": true, "/panel/": true, "/panel/inbounds": true,
|
||||||
|
"/panel/clients": true,
|
||||||
"/panel/nodes": true, "/panel/settings": true,
|
"/panel/nodes": true, "/panel/settings": true,
|
||||||
"/panel/xray": true, "/panel/api-docs": true,
|
"/panel/xray": true, "/panel/api-docs": true,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
165
web/controller/client.go
Normal file
165
web/controller/client.go
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/web/service"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ClientController struct {
|
||||||
|
clientService service.ClientService
|
||||||
|
inboundService service.InboundService
|
||||||
|
xrayService service.XrayService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClientController(g *gin.RouterGroup) *ClientController {
|
||||||
|
a := &ClientController{}
|
||||||
|
a.initRouter(g)
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ClientController) initRouter(g *gin.RouterGroup) {
|
||||||
|
g.GET("/list", a.list)
|
||||||
|
g.GET("/get/:id", a.get)
|
||||||
|
g.POST("/add", a.create)
|
||||||
|
g.POST("/update/:id", a.update)
|
||||||
|
g.POST("/del/:id", a.delete)
|
||||||
|
g.POST("/:id/attach", a.attach)
|
||||||
|
g.POST("/:id/detach", a.detach)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ClientController) list(c *gin.Context) {
|
||||||
|
rows, err := a.clientService.List()
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonObj(c, rows, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ClientController) get(c *gin.Context) {
|
||||||
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "get"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rec, err := a.clientService.GetByID(id)
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "get"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
inboundIds, err := a.clientService.GetInboundIdsForRecord(id)
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "get"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonObj(c, gin.H{"client": rec, "inboundIds": inboundIds}, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ClientController) create(c *gin.Context) {
|
||||||
|
var payload service.ClientCreatePayload
|
||||||
|
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
needRestart, err := a.clientService.Create(&a.inboundService, &payload)
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientAddSuccess"), nil)
|
||||||
|
if needRestart {
|
||||||
|
a.xrayService.SetToNeedRestart()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ClientController) update(c *gin.Context) {
|
||||||
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var updated model.Client
|
||||||
|
if err := c.ShouldBindJSON(&updated); err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
needRestart, err := a.clientService.Update(&a.inboundService, id, updated)
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientUpdateSuccess"), nil)
|
||||||
|
if needRestart {
|
||||||
|
a.xrayService.SetToNeedRestart()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ClientController) delete(c *gin.Context) {
|
||||||
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
keepTraffic := c.Query("keepTraffic") == "1"
|
||||||
|
needRestart, err := a.clientService.Delete(&a.inboundService, id, keepTraffic)
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientDeleteSuccess"), nil)
|
||||||
|
if needRestart {
|
||||||
|
a.xrayService.SetToNeedRestart()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type attachDetachBody struct {
|
||||||
|
InboundIds []int `json:"inboundIds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ClientController) attach(c *gin.Context) {
|
||||||
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body attachDetachBody
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
needRestart, err := a.clientService.Attach(&a.inboundService, id, body.InboundIds)
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientAddSuccess"), nil)
|
||||||
|
if needRestart {
|
||||||
|
a.xrayService.SetToNeedRestart()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ClientController) detach(c *gin.Context) {
|
||||||
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body attachDetachBody
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
needRestart, err := a.clientService.Detach(&a.inboundService, id, body.InboundIds)
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientDeleteSuccess"), nil)
|
||||||
|
if needRestart {
|
||||||
|
a.xrayService.SetToNeedRestart()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -33,6 +33,7 @@ func (a *XUIController) initRouter(g *gin.RouterGroup) {
|
||||||
|
|
||||||
g.GET("/", a.index)
|
g.GET("/", a.index)
|
||||||
g.GET("/inbounds", a.inbounds)
|
g.GET("/inbounds", a.inbounds)
|
||||||
|
g.GET("/clients", a.clients)
|
||||||
g.GET("/nodes", a.nodes)
|
g.GET("/nodes", a.nodes)
|
||||||
g.GET("/settings", a.settings)
|
g.GET("/settings", a.settings)
|
||||||
g.GET("/xray", a.xraySettings)
|
g.GET("/xray", a.xraySettings)
|
||||||
|
|
@ -62,6 +63,10 @@ func (a *XUIController) inbounds(c *gin.Context) {
|
||||||
serveDistPage(c, "inbounds.html")
|
serveDistPage(c, "inbounds.html")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *XUIController) clients(c *gin.Context) {
|
||||||
|
serveDistPage(c, "clients.html")
|
||||||
|
}
|
||||||
|
|
||||||
// nodes renders the multi-panel nodes management page.
|
// nodes renders the multi-panel nodes management page.
|
||||||
func (a *XUIController) nodes(c *gin.Context) {
|
func (a *XUIController) nodes(c *gin.Context) {
|
||||||
serveDistPage(c, "nodes.html")
|
serveDistPage(c, "nodes.html")
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,42 @@
|
||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
"github.com/mhsanaei/3x-ui/v3/database"
|
"github.com/mhsanaei/3x-ui/v3/database"
|
||||||
"github.com/mhsanaei/3x-ui/v3/database/model"
|
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/util/common"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/xray"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type ClientWithAttachments struct {
|
||||||
|
model.ClientRecord
|
||||||
|
InboundIds []int `json:"inboundIds"`
|
||||||
|
Traffic *xray.ClientTraffic `json:"traffic,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func clientKeyForProtocol(p model.Protocol, rec *model.ClientRecord) string {
|
||||||
|
if rec == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
switch p {
|
||||||
|
case model.Trojan:
|
||||||
|
return rec.Password
|
||||||
|
case model.Shadowsocks:
|
||||||
|
return rec.Email
|
||||||
|
case model.Hysteria, model.Hysteria2:
|
||||||
|
return rec.Auth
|
||||||
|
default:
|
||||||
|
return rec.UUID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type ClientService struct{}
|
type ClientService struct{}
|
||||||
|
|
||||||
func (s *ClientService) SyncInbound(tx *gorm.DB, inboundId int, clients []model.Client) error {
|
func (s *ClientService) SyncInbound(tx *gorm.DB, inboundId int, clients []model.Client) error {
|
||||||
|
|
@ -141,3 +168,347 @@ func (s *ClientService) GetInboundIdsForEmail(tx *gorm.DB, email string) ([]int,
|
||||||
}
|
}
|
||||||
return ids, nil
|
return ids, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *ClientService) GetByID(id int) (*model.ClientRecord, error) {
|
||||||
|
row := &model.ClientRecord{}
|
||||||
|
if err := database.GetDB().Where("id = ?", id).First(row).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return row, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ClientService) GetInboundIdsForRecord(id int) ([]int, error) {
|
||||||
|
var ids []int
|
||||||
|
err := database.GetDB().Table("client_inbounds").
|
||||||
|
Where("client_id = ?", id).
|
||||||
|
Order("inbound_id ASC").
|
||||||
|
Pluck("inbound_id", &ids).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return ids, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ClientService) List() ([]ClientWithAttachments, error) {
|
||||||
|
db := database.GetDB()
|
||||||
|
var rows []model.ClientRecord
|
||||||
|
if err := db.Order("id ASC").Find(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return []ClientWithAttachments{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
clientIds := make([]int, 0, len(rows))
|
||||||
|
emails := make([]string, 0, len(rows))
|
||||||
|
for i := range rows {
|
||||||
|
clientIds = append(clientIds, rows[i].Id)
|
||||||
|
if rows[i].Email != "" {
|
||||||
|
emails = append(emails, rows[i].Email)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var links []model.ClientInbound
|
||||||
|
if err := db.Where("client_id IN ?", clientIds).Find(&links).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
attachments := make(map[int][]int, len(rows))
|
||||||
|
for _, l := range links {
|
||||||
|
attachments[l.ClientId] = append(attachments[l.ClientId], l.InboundId)
|
||||||
|
}
|
||||||
|
|
||||||
|
trafficByEmail := make(map[string]*xray.ClientTraffic, len(emails))
|
||||||
|
if len(emails) > 0 {
|
||||||
|
var stats []xray.ClientTraffic
|
||||||
|
if err := db.Where("email IN ?", emails).Find(&stats).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for i := range stats {
|
||||||
|
trafficByEmail[stats[i].Email] = &stats[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]ClientWithAttachments, 0, len(rows))
|
||||||
|
for i := range rows {
|
||||||
|
out = append(out, ClientWithAttachments{
|
||||||
|
ClientRecord: rows[i],
|
||||||
|
InboundIds: attachments[rows[i].Id],
|
||||||
|
Traffic: trafficByEmail[rows[i].Email],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClientCreatePayload struct {
|
||||||
|
Client model.Client `json:"client"`
|
||||||
|
InboundIds []int `json:"inboundIds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreatePayload) (bool, error) {
|
||||||
|
if payload == nil {
|
||||||
|
return false, common.NewError("empty payload")
|
||||||
|
}
|
||||||
|
client := payload.Client
|
||||||
|
if strings.TrimSpace(client.Email) == "" {
|
||||||
|
return false, common.NewError("client email is required")
|
||||||
|
}
|
||||||
|
if len(payload.InboundIds) == 0 {
|
||||||
|
return false, common.NewError("at least one inbound is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if client.SubID == "" {
|
||||||
|
client.SubID = uuid.NewString()
|
||||||
|
}
|
||||||
|
if !client.Enable {
|
||||||
|
client.Enable = true
|
||||||
|
}
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
if client.CreatedAt == 0 {
|
||||||
|
client.CreatedAt = now
|
||||||
|
}
|
||||||
|
client.UpdatedAt = now
|
||||||
|
|
||||||
|
existing := &model.ClientRecord{}
|
||||||
|
err := database.GetDB().Where("email = ?", client.Email).First(existing).Error
|
||||||
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
emailTaken := !errors.Is(err, gorm.ErrRecordNotFound)
|
||||||
|
if emailTaken {
|
||||||
|
if existing.SubID == "" || existing.SubID != client.SubID {
|
||||||
|
return false, common.NewError("email already in use:", client.Email)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
needRestart := false
|
||||||
|
for _, ibId := range payload.InboundIds {
|
||||||
|
inbound, getErr := inboundSvc.GetInbound(ibId)
|
||||||
|
if getErr != nil {
|
||||||
|
return needRestart, getErr
|
||||||
|
}
|
||||||
|
if err := s.fillProtocolDefaults(&client, inbound.Protocol); err != nil {
|
||||||
|
return needRestart, err
|
||||||
|
}
|
||||||
|
settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {client}})
|
||||||
|
if mErr != nil {
|
||||||
|
return needRestart, mErr
|
||||||
|
}
|
||||||
|
nr, addErr := inboundSvc.AddInboundClient(&model.Inbound{
|
||||||
|
Id: ibId,
|
||||||
|
Settings: string(settingsPayload),
|
||||||
|
})
|
||||||
|
if addErr != nil {
|
||||||
|
return needRestart, addErr
|
||||||
|
}
|
||||||
|
if nr {
|
||||||
|
needRestart = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return needRestart, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ClientService) fillProtocolDefaults(c *model.Client, p model.Protocol) error {
|
||||||
|
switch p {
|
||||||
|
case model.VMESS, model.VLESS:
|
||||||
|
if c.ID == "" {
|
||||||
|
c.ID = uuid.NewString()
|
||||||
|
}
|
||||||
|
case model.Trojan, model.Shadowsocks:
|
||||||
|
if c.Password == "" {
|
||||||
|
c.Password = strings.ReplaceAll(uuid.NewString(), "-", "")
|
||||||
|
}
|
||||||
|
case model.Hysteria, model.Hysteria2:
|
||||||
|
if c.Auth == "" {
|
||||||
|
c.Auth = strings.ReplaceAll(uuid.NewString(), "-", "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model.Client) (bool, error) {
|
||||||
|
existing, err := s.GetByID(id)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
inboundIds, err := s.GetInboundIdsForRecord(id)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(updated.Email) == "" {
|
||||||
|
return false, common.NewError("client email is required")
|
||||||
|
}
|
||||||
|
if updated.SubID == "" {
|
||||||
|
updated.SubID = existing.SubID
|
||||||
|
}
|
||||||
|
if updated.SubID == "" {
|
||||||
|
updated.SubID = uuid.NewString()
|
||||||
|
}
|
||||||
|
updated.UpdatedAt = time.Now().UnixMilli()
|
||||||
|
if updated.CreatedAt == 0 {
|
||||||
|
updated.CreatedAt = existing.CreatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
needRestart := false
|
||||||
|
for _, ibId := range inboundIds {
|
||||||
|
inbound, getErr := inboundSvc.GetInbound(ibId)
|
||||||
|
if getErr != nil {
|
||||||
|
return needRestart, getErr
|
||||||
|
}
|
||||||
|
oldKey := clientKeyForProtocol(inbound.Protocol, existing)
|
||||||
|
if oldKey == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := s.fillProtocolDefaults(&updated, inbound.Protocol); err != nil {
|
||||||
|
return needRestart, err
|
||||||
|
}
|
||||||
|
settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {updated}})
|
||||||
|
if mErr != nil {
|
||||||
|
return needRestart, mErr
|
||||||
|
}
|
||||||
|
nr, upErr := inboundSvc.UpdateInboundClient(&model.Inbound{
|
||||||
|
Id: ibId,
|
||||||
|
Settings: string(settingsPayload),
|
||||||
|
}, oldKey)
|
||||||
|
if upErr != nil {
|
||||||
|
return needRestart, upErr
|
||||||
|
}
|
||||||
|
if nr {
|
||||||
|
needRestart = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return needRestart, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ClientService) Delete(inboundSvc *InboundService, id int, keepTraffic bool) (bool, error) {
|
||||||
|
existing, err := s.GetByID(id)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
inboundIds, err := s.GetInboundIdsForRecord(id)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
needRestart := false
|
||||||
|
for _, ibId := range inboundIds {
|
||||||
|
inbound, getErr := inboundSvc.GetInbound(ibId)
|
||||||
|
if getErr != nil {
|
||||||
|
return needRestart, getErr
|
||||||
|
}
|
||||||
|
key := clientKeyForProtocol(inbound.Protocol, existing)
|
||||||
|
if key == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
nr, delErr := inboundSvc.DelInboundClient(ibId, key)
|
||||||
|
if delErr != nil {
|
||||||
|
return needRestart, delErr
|
||||||
|
}
|
||||||
|
if nr {
|
||||||
|
needRestart = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
db := database.GetDB()
|
||||||
|
if err := db.Where("client_id = ?", id).Delete(&model.ClientInbound{}).Error; err != nil {
|
||||||
|
return needRestart, err
|
||||||
|
}
|
||||||
|
if !keepTraffic && existing.Email != "" {
|
||||||
|
if err := db.Where("email = ?", existing.Email).Delete(&xray.ClientTraffic{}).Error; err != nil {
|
||||||
|
return needRestart, err
|
||||||
|
}
|
||||||
|
if err := db.Where("client_email = ?", existing.Email).Delete(&model.InboundClientIps{}).Error; err != nil {
|
||||||
|
return needRestart, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := db.Delete(&model.ClientRecord{}, id).Error; err != nil {
|
||||||
|
return needRestart, err
|
||||||
|
}
|
||||||
|
return needRestart, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ClientService) Attach(inboundSvc *InboundService, id int, inboundIds []int) (bool, error) {
|
||||||
|
existing, err := s.GetByID(id)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
currentIds, err := s.GetInboundIdsForRecord(id)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
have := make(map[int]struct{}, len(currentIds))
|
||||||
|
for _, x := range currentIds {
|
||||||
|
have[x] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
clientWire := existing.ToClient()
|
||||||
|
clientWire.UpdatedAt = time.Now().UnixMilli()
|
||||||
|
|
||||||
|
needRestart := false
|
||||||
|
for _, ibId := range inboundIds {
|
||||||
|
if _, attached := have[ibId]; attached {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
inbound, getErr := inboundSvc.GetInbound(ibId)
|
||||||
|
if getErr != nil {
|
||||||
|
return needRestart, getErr
|
||||||
|
}
|
||||||
|
copyClient := *clientWire
|
||||||
|
if err := s.fillProtocolDefaults(©Client, inbound.Protocol); err != nil {
|
||||||
|
return needRestart, err
|
||||||
|
}
|
||||||
|
settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {copyClient}})
|
||||||
|
if mErr != nil {
|
||||||
|
return needRestart, mErr
|
||||||
|
}
|
||||||
|
nr, addErr := inboundSvc.AddInboundClient(&model.Inbound{
|
||||||
|
Id: ibId,
|
||||||
|
Settings: string(settingsPayload),
|
||||||
|
})
|
||||||
|
if addErr != nil {
|
||||||
|
return needRestart, addErr
|
||||||
|
}
|
||||||
|
if nr {
|
||||||
|
needRestart = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return needRestart, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ClientService) Detach(inboundSvc *InboundService, id int, inboundIds []int) (bool, error) {
|
||||||
|
existing, err := s.GetByID(id)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
currentIds, err := s.GetInboundIdsForRecord(id)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
have := make(map[int]struct{}, len(currentIds))
|
||||||
|
for _, x := range currentIds {
|
||||||
|
have[x] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
needRestart := false
|
||||||
|
for _, ibId := range inboundIds {
|
||||||
|
if _, attached := have[ibId]; !attached {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
inbound, getErr := inboundSvc.GetInbound(ibId)
|
||||||
|
if getErr != nil {
|
||||||
|
return needRestart, getErr
|
||||||
|
}
|
||||||
|
key := clientKeyForProtocol(inbound.Protocol, existing)
|
||||||
|
if key == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
nr, delErr := inboundSvc.DelInboundClient(ibId, key)
|
||||||
|
if delErr != nil {
|
||||||
|
return needRestart, delErr
|
||||||
|
}
|
||||||
|
if nr {
|
||||||
|
needRestart = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return needRestart, nil
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,7 @@
|
||||||
"ultraDark": "Ultra Dark",
|
"ultraDark": "Ultra Dark",
|
||||||
"dashboard": "Overview",
|
"dashboard": "Overview",
|
||||||
"inbounds": "Inbounds",
|
"inbounds": "Inbounds",
|
||||||
|
"clients": "Clients",
|
||||||
"nodes": "Nodes",
|
"nodes": "Nodes",
|
||||||
"settings": "Panel Settings",
|
"settings": "Panel Settings",
|
||||||
"xray": "Xray Configs",
|
"xray": "Xray Configs",
|
||||||
|
|
@ -397,6 +398,19 @@
|
||||||
"renew": "Auto Renew",
|
"renew": "Auto Renew",
|
||||||
"renewDesc": "Auto-renewal after expiration. (0 = disable)(unit: day)"
|
"renewDesc": "Auto-renewal after expiration. (0 = disable)(unit: day)"
|
||||||
},
|
},
|
||||||
|
"clients": {
|
||||||
|
"title": "Clients",
|
||||||
|
"addTitle": "Add Client",
|
||||||
|
"editTitle": "Edit Client",
|
||||||
|
"attachedInbounds": "Attached inbounds",
|
||||||
|
"selectInbound": "Select one or more inbounds",
|
||||||
|
"empty": "No clients yet — add one to get started.",
|
||||||
|
"deleteConfirmTitle": "Delete client {email}?",
|
||||||
|
"deleteConfirmContent": "This removes the client from every attached inbound and drops its traffic record. This cannot be undone.",
|
||||||
|
"toasts": {
|
||||||
|
"deleted": "Client deleted"
|
||||||
|
}
|
||||||
|
},
|
||||||
"nodes": {
|
"nodes": {
|
||||||
"title": "Nodes",
|
"title": "Nodes",
|
||||||
"addNode": "Add Node",
|
"addNode": "Add Node",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue