mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-06 21:24:10 +00:00
Merge branch 'main' into feature/quick-edit
This commit is contained in:
commit
320f8705f4
18 changed files with 857 additions and 188 deletions
553
frontend/package-lock.json
generated
553
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -16,8 +16,11 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons-vue": "^7.0.1",
|
"@ant-design/icons-vue": "^7.0.1",
|
||||||
|
"@codemirror/lang-json": "^6.0.2",
|
||||||
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
"ant-design-vue": "^4.2.6",
|
"ant-design-vue": "^4.2.6",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
|
"codemirror": "^6.0.2",
|
||||||
"dayjs": "^1.11.20",
|
"dayjs": "^1.11.20",
|
||||||
"otpauth": "^9.5.1",
|
"otpauth": "^9.5.1",
|
||||||
"qs": "^6.13.1",
|
"qs": "^6.13.1",
|
||||||
|
|
|
||||||
185
frontend/src/components/JsonEditor.vue
Normal file
185
frontend/src/components/JsonEditor.vue
Normal file
|
|
@ -0,0 +1,185 @@
|
||||||
|
<script setup>
|
||||||
|
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
|
import { EditorView, basicSetup } from 'codemirror';
|
||||||
|
import { EditorState, Compartment } from '@codemirror/state';
|
||||||
|
import { json, jsonParseLinter } from '@codemirror/lang-json';
|
||||||
|
import { lintGutter, linter } from '@codemirror/lint';
|
||||||
|
import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
|
||||||
|
import { syntaxHighlighting } from '@codemirror/language';
|
||||||
|
import { keymap } from '@codemirror/view';
|
||||||
|
import { indentWithTab } from '@codemirror/commands';
|
||||||
|
|
||||||
|
import { theme as themeState } from '@/composables/useTheme.js';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
value: { type: String, default: '' },
|
||||||
|
minHeight: { type: String, default: '320px' },
|
||||||
|
maxHeight: { type: String, default: '600px' },
|
||||||
|
readonly: { type: Boolean, default: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:value', 'change']);
|
||||||
|
|
||||||
|
const host = ref(null);
|
||||||
|
let view = null;
|
||||||
|
const themeCompartment = new Compartment();
|
||||||
|
const readonlyCompartment = new Compartment();
|
||||||
|
|
||||||
|
function buildDarkTheme({ bg, panelBg, activeBg, border, selection }) {
|
||||||
|
return EditorView.theme(
|
||||||
|
{
|
||||||
|
'&': { color: '#dcdcdc', backgroundColor: bg },
|
||||||
|
'.cm-content': { caretColor: '#dcdcdc' },
|
||||||
|
'.cm-cursor, .cm-dropCursor': { borderLeftColor: '#dcdcdc' },
|
||||||
|
'.cm-gutters': {
|
||||||
|
backgroundColor: bg,
|
||||||
|
borderRight: `1px solid ${border}`,
|
||||||
|
color: '#6a6a6a',
|
||||||
|
},
|
||||||
|
'.cm-activeLine': { backgroundColor: activeBg },
|
||||||
|
'.cm-activeLineGutter': { backgroundColor: activeBg, color: '#dcdcdc' },
|
||||||
|
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection':
|
||||||
|
{ backgroundColor: selection },
|
||||||
|
'.cm-panels': { backgroundColor: panelBg, color: '#dcdcdc' },
|
||||||
|
'.cm-panels.cm-panels-top': { borderBottom: `1px solid ${border}` },
|
||||||
|
'.cm-panels.cm-panels-bottom': { borderTop: `1px solid ${border}` },
|
||||||
|
'.cm-tooltip': {
|
||||||
|
backgroundColor: panelBg,
|
||||||
|
border: `1px solid ${border}`,
|
||||||
|
color: '#dcdcdc',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ dark: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const darkTheme = buildDarkTheme({
|
||||||
|
bg: '#1e1e1e',
|
||||||
|
panelBg: '#2d2d30',
|
||||||
|
activeBg: '#252526',
|
||||||
|
border: '#3a3a3c',
|
||||||
|
selection: '#3a3a3c',
|
||||||
|
});
|
||||||
|
|
||||||
|
const ultraDarkTheme = buildDarkTheme({
|
||||||
|
bg: '#0a0a0a',
|
||||||
|
panelBg: '#141414',
|
||||||
|
activeBg: '#141414',
|
||||||
|
border: '#1f1f1f',
|
||||||
|
selection: '#2a2a2a',
|
||||||
|
});
|
||||||
|
|
||||||
|
function themeExtension() {
|
||||||
|
if (!themeState.isDark) return [];
|
||||||
|
const chrome = themeState.isUltra ? ultraDarkTheme : darkTheme;
|
||||||
|
return [chrome, syntaxHighlighting(oneDarkHighlightStyle)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function readonlyExtension() {
|
||||||
|
return EditorState.readOnly.of(props.readonly);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const updateListener = EditorView.updateListener.of((u) => {
|
||||||
|
if (!u.docChanged) return;
|
||||||
|
const next = u.state.doc.toString();
|
||||||
|
if (next === props.value) return;
|
||||||
|
emit('update:value', next);
|
||||||
|
emit('change', next);
|
||||||
|
});
|
||||||
|
|
||||||
|
view = new EditorView({
|
||||||
|
parent: host.value,
|
||||||
|
state: EditorState.create({
|
||||||
|
doc: props.value || '',
|
||||||
|
extensions: [
|
||||||
|
basicSetup,
|
||||||
|
keymap.of([indentWithTab]),
|
||||||
|
json(),
|
||||||
|
linter(jsonParseLinter()),
|
||||||
|
lintGutter(),
|
||||||
|
EditorView.lineWrapping,
|
||||||
|
updateListener,
|
||||||
|
themeCompartment.of(themeExtension()),
|
||||||
|
readonlyCompartment.of(readonlyExtension()),
|
||||||
|
EditorView.theme({
|
||||||
|
'&': { height: '100%' },
|
||||||
|
'.cm-scroller': {
|
||||||
|
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
||||||
|
fontSize: '12px',
|
||||||
|
minHeight: props.minHeight,
|
||||||
|
maxHeight: props.maxHeight,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(() => props.value, (next) => {
|
||||||
|
if (!view) return;
|
||||||
|
const current = view.state.doc.toString();
|
||||||
|
if (next === current) return;
|
||||||
|
view.dispatch({
|
||||||
|
changes: { from: 0, to: current.length, insert: next || '' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[() => themeState.isDark, () => themeState.isUltra],
|
||||||
|
() => {
|
||||||
|
if (!view) return;
|
||||||
|
view.dispatch({ effects: themeCompartment.reconfigure(themeExtension()) });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.readonly,
|
||||||
|
() => {
|
||||||
|
if (!view) return;
|
||||||
|
view.dispatch({ effects: readonlyCompartment.reconfigure(readonlyExtension()) });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
view?.destroy();
|
||||||
|
view = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
focus: () => view?.focus(),
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="host" class="json-editor-host" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.json-editor-host {
|
||||||
|
border: 1px solid var(--ant-color-border, #d9d9d9);
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--ant-color-bg-container, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-editor-host :deep(.cm-editor),
|
||||||
|
.json-editor-host :deep(.cm-editor.cm-focused) {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-editor-host:focus-within {
|
||||||
|
border-color: var(--ant-color-primary, #1677ff);
|
||||||
|
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(body.dark) .json-editor-host {
|
||||||
|
border-color: #3a3a3c;
|
||||||
|
background: #1e1e1e;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html[data-theme="ultra-dark"]) .json-editor-host {
|
||||||
|
border-color: #1f1f1f;
|
||||||
|
background: #0a0a0a;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -33,6 +33,7 @@ const props = defineProps({
|
||||||
isDarkTheme: { type: Boolean, default: false },
|
isDarkTheme: { type: Boolean, default: false },
|
||||||
pageSize: { type: Number, default: 0 },
|
pageSize: { type: Number, default: 0 },
|
||||||
totalClientCount: { type: Number, default: 0 },
|
totalClientCount: { type: Number, default: 0 },
|
||||||
|
statsVersion: { type: Number, default: 0 },
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits([
|
const emit = defineEmits([
|
||||||
|
|
@ -63,7 +64,11 @@ watch([clients, () => props.pageSize], () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// === Per-client stats lookup =======================================
|
// === Per-client stats lookup =======================================
|
||||||
|
// statsVersion bumps on every ws merge so this computed re-evaluates
|
||||||
|
// (DBInbound isn't reactive — the in-place stat mutations alone don't
|
||||||
|
// trigger Vue's tracking).
|
||||||
const statsMap = computed(() => {
|
const statsMap = computed(() => {
|
||||||
|
void props.statsVersion;
|
||||||
const m = new Map();
|
const m = new Map();
|
||||||
for (const cs of (props.dbInbound.clientStats || [])) m.set(cs.email, cs);
|
for (const cs of (props.dbInbound.clientStats || [])) m.set(cs.email, cs);
|
||||||
return m;
|
return m;
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ import {
|
||||||
import { DBInbound } from '@/models/dbinbound.js';
|
import { DBInbound } from '@/models/dbinbound.js';
|
||||||
import FinalMaskForm from '@/components/FinalMaskForm.vue';
|
import FinalMaskForm from '@/components/FinalMaskForm.vue';
|
||||||
import DateTimePicker from '@/components/DateTimePicker.vue';
|
import DateTimePicker from '@/components/DateTimePicker.vue';
|
||||||
|
import JsonEditor from '@/components/JsonEditor.vue';
|
||||||
import { useNodeList } from '@/composables/useNodeList.js';
|
import { useNodeList } from '@/composables/useNodeList.js';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
@ -1956,16 +1957,13 @@ watch(
|
||||||
class="mb-12" />
|
class="mb-12" />
|
||||||
<a-form layout="vertical">
|
<a-form layout="vertical">
|
||||||
<a-form-item label="settings (clients, encryption, fallbacks, …)">
|
<a-form-item label="settings (clients, encryption, fallbacks, …)">
|
||||||
<a-textarea v-model:value="advancedJson.settings" :auto-size="{ minRows: 10, maxRows: 24 }"
|
<JsonEditor v-model:value="advancedJson.settings" min-height="280px" max-height="520px" />
|
||||||
spellcheck="false" class="json-editor" />
|
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="streamSettings">
|
<a-form-item label="streamSettings">
|
||||||
<a-textarea v-model:value="advancedJson.stream" :auto-size="{ minRows: 10, maxRows: 24 }" spellcheck="false"
|
<JsonEditor v-model:value="advancedJson.stream" min-height="280px" max-height="520px" />
|
||||||
class="json-editor" />
|
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="sniffing (overrides the Sniffing tab when set)">
|
<a-form-item label="sniffing (overrides the Sniffing tab when set)">
|
||||||
<a-textarea v-model:value="advancedJson.sniffing" :auto-size="{ minRows: 6, maxRows: 16 }"
|
<JsonEditor v-model:value="advancedJson.sniffing" min-height="180px" max-height="360px" />
|
||||||
spellcheck="false" class="json-editor" />
|
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-form>
|
</a-form>
|
||||||
</a-tab-pane>
|
</a-tab-pane>
|
||||||
|
|
@ -2015,11 +2013,6 @@ watch(
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.json-editor {
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-summary {
|
.client-summary {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ const props = defineProps({
|
||||||
// inbound row can render its node name without an extra fetch.
|
// inbound row can render its node name without an extra fetch.
|
||||||
nodesById: { type: Map, default: () => new Map() },
|
nodesById: { type: Map, default: () => new Map() },
|
||||||
hasActiveNode: { type: Boolean, default: false },
|
hasActiveNode: { type: Boolean, default: false },
|
||||||
|
statsVersion: { type: Number, default: 0 },
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits([
|
const emit = defineEmits([
|
||||||
|
|
@ -468,6 +469,7 @@ function showQrCodeMenu(dbInbound) {
|
||||||
<ClientRowTable :db-inbound="record" :is-mobile="true" :traffic-diff="trafficDiff" :expire-diff="expireDiff"
|
<ClientRowTable :db-inbound="record" :is-mobile="true" :traffic-diff="trafficDiff" :expire-diff="expireDiff"
|
||||||
:online-clients="onlineClients" :last-online-map="lastOnlineMap" :is-dark-theme="isDarkTheme"
|
:online-clients="onlineClients" :last-online-map="lastOnlineMap" :is-dark-theme="isDarkTheme"
|
||||||
:page-size="pageSize" :total-client-count="clientCount[record.id]?.clients || 0"
|
:page-size="pageSize" :total-client-count="clientCount[record.id]?.clients || 0"
|
||||||
|
:stats-version="statsVersion"
|
||||||
@edit-client="(p) => emit('edit-client', p)" @qrcode-client="(p) => emit('qrcode-client', p)"
|
@edit-client="(p) => emit('edit-client', p)" @qrcode-client="(p) => emit('qrcode-client', p)"
|
||||||
@info-client="(p) => emit('info-client', p)"
|
@info-client="(p) => emit('info-client', p)"
|
||||||
@reset-traffic-client="(p) => emit('reset-traffic-client', p)"
|
@reset-traffic-client="(p) => emit('reset-traffic-client', p)"
|
||||||
|
|
@ -557,6 +559,7 @@ function showQrCodeMenu(dbInbound) {
|
||||||
:traffic-diff="trafficDiff" :expire-diff="expireDiff" :online-clients="onlineClients"
|
:traffic-diff="trafficDiff" :expire-diff="expireDiff" :online-clients="onlineClients"
|
||||||
:last-online-map="lastOnlineMap" :is-dark-theme="isDarkTheme" :page-size="pageSize"
|
:last-online-map="lastOnlineMap" :is-dark-theme="isDarkTheme" :page-size="pageSize"
|
||||||
:total-client-count="clientCount[record.id]?.clients || 0"
|
:total-client-count="clientCount[record.id]?.clients || 0"
|
||||||
|
:stats-version="statsVersion"
|
||||||
@edit-client="(p) => emit('edit-client', p)"
|
@edit-client="(p) => emit('edit-client', p)"
|
||||||
@qrcode-client="(p) => emit('qrcode-client', p)" @info-client="(p) => emit('info-client', p)"
|
@qrcode-client="(p) => emit('qrcode-client', p)" @info-client="(p) => emit('info-client', p)"
|
||||||
@reset-traffic-client="(p) => emit('reset-traffic-client', p)"
|
@reset-traffic-client="(p) => emit('reset-traffic-client', p)"
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ const {
|
||||||
ipLimitEnable,
|
ipLimitEnable,
|
||||||
remarkModel,
|
remarkModel,
|
||||||
lastOnlineMap,
|
lastOnlineMap,
|
||||||
|
statsVersion,
|
||||||
refresh,
|
refresh,
|
||||||
fetchDefaultSettings,
|
fetchDefaultSettings,
|
||||||
applyTrafficEvent,
|
applyTrafficEvent,
|
||||||
|
|
@ -648,6 +649,7 @@ function onRowAction({ key, dbInbound }) {
|
||||||
:last-online-map="lastOnlineMap" :is-dark-theme="themeState.isDark" :expire-diff="expireDiff"
|
:last-online-map="lastOnlineMap" :is-dark-theme="themeState.isDark" :expire-diff="expireDiff"
|
||||||
:traffic-diff="trafficDiff" :page-size="pageSize" :is-mobile="isMobile"
|
:traffic-diff="trafficDiff" :page-size="pageSize" :is-mobile="isMobile"
|
||||||
:sub-enable="subSettings.enable" :nodes-by-id="nodesById" :has-active-node="hasActiveNode"
|
:sub-enable="subSettings.enable" :nodes-by-id="nodesById" :has-active-node="hasActiveNode"
|
||||||
|
:stats-version="statsVersion"
|
||||||
@refresh="refresh"
|
@refresh="refresh"
|
||||||
@add-inbound="onAddInbound" @general-action="onGeneralAction" @row-action="onRowAction"
|
@add-inbound="onAddInbound" @general-action="onGeneralAction" @row-action="onRowAction"
|
||||||
@edit-client="onEditClient" @qrcode-client="onQrcodeClient" @info-client="onInfoClient"
|
@edit-client="onEditClient" @qrcode-client="onQrcodeClient" @info-client="onInfoClient"
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ function download() {
|
||||||
</div>
|
</div>
|
||||||
<div v-if="showQr" class="qr-panel-canvas">
|
<div v-if="showQr" class="qr-panel-canvas">
|
||||||
<a-qrcode class="qr-code" :value="value" :size="size" type="svg" :bordered="false"
|
<a-qrcode class="qr-code" :value="value" :size="size" type="svg" :bordered="false"
|
||||||
:title="t('copy')" @click="copy" />
|
color="#000000" bg-color="#ffffff" :title="t('copy')" @click="copy" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,11 @@ export function useInbounds() {
|
||||||
const clientCount = ref({});
|
const clientCount = ref({});
|
||||||
const onlineClients = ref([]);
|
const onlineClients = ref([]);
|
||||||
const lastOnlineMap = ref({});
|
const lastOnlineMap = ref({});
|
||||||
|
// Bumps on every client_stats merge so the per-inbound ClientRowTable
|
||||||
|
// child can re-render. DBInbound is a plain class instance, not reactive,
|
||||||
|
// so the in-place mutations on its clientStats array are invisible to
|
||||||
|
// Vue's tracking unless something else (this tick) signals the change.
|
||||||
|
const statsVersion = ref(0);
|
||||||
|
|
||||||
// Default-settings sidecar fields the table needs for color/expiry math.
|
// Default-settings sidecar fields the table needs for color/expiry math.
|
||||||
const expireDiff = ref(0);
|
const expireDiff = ref(0);
|
||||||
|
|
@ -173,9 +178,9 @@ export function useInbounds() {
|
||||||
rebuildClientCount();
|
rebuildClientCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
// The client_stats payload carries absolute traffic counters for the
|
// The client_stats payload carries absolute traffic counters for every
|
||||||
// clients that had activity in the latest window plus per-inbound
|
// client + per-inbound totals (full snapshot, not deltas). Both are
|
||||||
// totals. Both are absolute (not deltas), so we overwrite in place.
|
// overwritten in place.
|
||||||
function applyClientStatsEvent(payload) {
|
function applyClientStatsEvent(payload) {
|
||||||
if (!payload || typeof payload !== 'object') return;
|
if (!payload || typeof payload !== 'object') return;
|
||||||
let touched = false;
|
let touched = false;
|
||||||
|
|
@ -220,6 +225,7 @@ export function useInbounds() {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (touched) {
|
if (touched) {
|
||||||
|
statsVersion.value++;
|
||||||
dbInbounds.value = [...dbInbounds.value];
|
dbInbounds.value = [...dbInbounds.value];
|
||||||
rebuildClientCount();
|
rebuildClientCount();
|
||||||
}
|
}
|
||||||
|
|
@ -315,6 +321,7 @@ export function useInbounds() {
|
||||||
clientCount,
|
clientCount,
|
||||||
onlineClients,
|
onlineClients,
|
||||||
lastOnlineMap,
|
lastOnlineMap,
|
||||||
|
statsVersion,
|
||||||
totals,
|
totals,
|
||||||
expireDiff,
|
expireDiff,
|
||||||
trafficDiff,
|
trafficDiff,
|
||||||
|
|
|
||||||
|
|
@ -228,39 +228,49 @@ onBeforeUnmount(() => {
|
||||||
</a-col>
|
</a-col>
|
||||||
|
|
||||||
<a-col :span="24">
|
<a-col :span="24">
|
||||||
<a-tabs :active-key="activeTabKey" @change="onTabChange">
|
<a-tabs :active-key="activeTabKey" :class="{ 'icons-only': isMobile }" @change="onTabChange">
|
||||||
<a-tab-pane key="1" class="tab-pane">
|
<a-tab-pane key="1" class="tab-pane">
|
||||||
<template #tab>
|
<template #tab>
|
||||||
<SettingOutlined />
|
<a-tooltip :title="isMobile ? t('pages.settings.panelSettings') : null">
|
||||||
<span>{{ t('pages.settings.panelSettings') }}</span>
|
<SettingOutlined />
|
||||||
|
</a-tooltip>
|
||||||
|
<span v-if="!isMobile">{{ t('pages.settings.panelSettings') }}</span>
|
||||||
</template>
|
</template>
|
||||||
<GeneralTab :all-setting="allSetting" />
|
<GeneralTab :all-setting="allSetting" />
|
||||||
</a-tab-pane>
|
</a-tab-pane>
|
||||||
<a-tab-pane key="2" class="tab-pane">
|
<a-tab-pane key="2" class="tab-pane">
|
||||||
<template #tab>
|
<template #tab>
|
||||||
<SafetyOutlined />
|
<a-tooltip :title="isMobile ? t('pages.settings.securitySettings') : null">
|
||||||
<span>{{ t('pages.settings.securitySettings') }}</span>
|
<SafetyOutlined />
|
||||||
|
</a-tooltip>
|
||||||
|
<span v-if="!isMobile">{{ t('pages.settings.securitySettings') }}</span>
|
||||||
</template>
|
</template>
|
||||||
<SecurityTab :all-setting="allSetting" />
|
<SecurityTab :all-setting="allSetting" />
|
||||||
</a-tab-pane>
|
</a-tab-pane>
|
||||||
<a-tab-pane key="3" class="tab-pane">
|
<a-tab-pane key="3" class="tab-pane">
|
||||||
<template #tab>
|
<template #tab>
|
||||||
<MessageOutlined />
|
<a-tooltip :title="isMobile ? t('pages.settings.TGBotSettings') : null">
|
||||||
<span>{{ t('pages.settings.TGBotSettings') }}</span>
|
<MessageOutlined />
|
||||||
|
</a-tooltip>
|
||||||
|
<span v-if="!isMobile">{{ t('pages.settings.TGBotSettings') }}</span>
|
||||||
</template>
|
</template>
|
||||||
<TelegramTab :all-setting="allSetting" />
|
<TelegramTab :all-setting="allSetting" />
|
||||||
</a-tab-pane>
|
</a-tab-pane>
|
||||||
<a-tab-pane key="4" class="tab-pane">
|
<a-tab-pane key="4" class="tab-pane">
|
||||||
<template #tab>
|
<template #tab>
|
||||||
<CloudServerOutlined />
|
<a-tooltip :title="isMobile ? t('pages.settings.subSettings') : null">
|
||||||
<span>{{ t('pages.settings.subSettings') }}</span>
|
<CloudServerOutlined />
|
||||||
|
</a-tooltip>
|
||||||
|
<span v-if="!isMobile">{{ t('pages.settings.subSettings') }}</span>
|
||||||
</template>
|
</template>
|
||||||
<SubscriptionGeneralTab :all-setting="allSetting" />
|
<SubscriptionGeneralTab :all-setting="allSetting" />
|
||||||
</a-tab-pane>
|
</a-tab-pane>
|
||||||
<a-tab-pane v-if="allSetting.subJsonEnable || allSetting.subClashEnable" key="5" class="tab-pane">
|
<a-tab-pane v-if="allSetting.subJsonEnable || allSetting.subClashEnable" key="5" class="tab-pane">
|
||||||
<template #tab>
|
<template #tab>
|
||||||
<CodeOutlined />
|
<a-tooltip :title="isMobile ? `${t('pages.settings.subSettings')} (Formats)` : null">
|
||||||
<span>{{ t('pages.settings.subSettings') }} (Formats)</span>
|
<CodeOutlined />
|
||||||
|
</a-tooltip>
|
||||||
|
<span v-if="!isMobile">{{ t('pages.settings.subSettings') }} (Formats)</span>
|
||||||
</template>
|
</template>
|
||||||
<SubscriptionFormatsTab :all-setting="allSetting" />
|
<SubscriptionFormatsTab :all-setting="allSetting" />
|
||||||
</a-tab-pane>
|
</a-tab-pane>
|
||||||
|
|
@ -333,4 +343,33 @@ onBeforeUnmount(() => {
|
||||||
.tab-pane {
|
.tab-pane {
|
||||||
padding-top: 20px;
|
padding-top: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.icons-only :deep(.ant-tabs-nav) {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icons-only :deep(.ant-tabs-nav-wrap) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icons-only :deep(.ant-tabs-nav-list) {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icons-only :deep(.ant-tabs-tab) {
|
||||||
|
flex: 1 1 0;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icons-only :deep(.ant-tabs-tab .anticon) {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icons-only :deep(.ant-tabs-nav-operations) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ async function copyToken() {
|
||||||
<p>{{ t('pages.settings.security.twoFactorModalFirstStep') }}</p>
|
<p>{{ t('pages.settings.security.twoFactorModalFirstStep') }}</p>
|
||||||
<div class="qr-wrap">
|
<div class="qr-wrap">
|
||||||
<a-qrcode class="qr-code" :value="qrValue" :size="180" type="svg" :bordered="false"
|
<a-qrcode class="qr-code" :value="qrValue" :size="180" type="svg" :bordered="false"
|
||||||
error-level="L" :title="t('copy')" @click="copyToken" />
|
color="#000000" bg-color="#ffffff" error-level="L" :title="t('copy')" @click="copyToken" />
|
||||||
<span class="qr-token">{{ token }}</span>
|
<span class="qr-token">{{ token }}</span>
|
||||||
</div>
|
</div>
|
||||||
<a-divider />
|
<a-divider />
|
||||||
|
|
|
||||||
|
|
@ -204,7 +204,7 @@ const themeClass = computed(() => ({
|
||||||
<div class="qr-box">
|
<div class="qr-box">
|
||||||
<a-tag color="purple" class="qr-tag">{{ t('pages.settings.subSettings') }}</a-tag>
|
<a-tag color="purple" class="qr-tag">{{ t('pages.settings.subSettings') }}</a-tag>
|
||||||
<a-qrcode class="qr-code" :value="subUrl" :size="QR_SIZE" type="svg" :bordered="false"
|
<a-qrcode class="qr-code" :value="subUrl" :size="QR_SIZE" type="svg" :bordered="false"
|
||||||
:title="t('copy')" @click="copy(subUrl)" />
|
color="#000000" bg-color="#ffffff" :title="t('copy')" @click="copy(subUrl)" />
|
||||||
</div>
|
</div>
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col v-if="subJsonUrl" :xs="24" :sm="12" class="qr-col">
|
<a-col v-if="subJsonUrl" :xs="24" :sm="12" class="qr-col">
|
||||||
|
|
@ -213,14 +213,14 @@ const themeClass = computed(() => ({
|
||||||
{{ t('pages.settings.subSettings') }} JSON
|
{{ t('pages.settings.subSettings') }} JSON
|
||||||
</a-tag>
|
</a-tag>
|
||||||
<a-qrcode class="qr-code" :value="subJsonUrl" :size="QR_SIZE" type="svg" :bordered="false"
|
<a-qrcode class="qr-code" :value="subJsonUrl" :size="QR_SIZE" type="svg" :bordered="false"
|
||||||
:title="t('copy')" @click="copy(subJsonUrl)" />
|
color="#000000" bg-color="#ffffff" :title="t('copy')" @click="copy(subJsonUrl)" />
|
||||||
</div>
|
</div>
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col v-if="subClashUrl" :xs="24" :sm="12" class="qr-col">
|
<a-col v-if="subClashUrl" :xs="24" :sm="12" class="qr-col">
|
||||||
<div class="qr-box">
|
<div class="qr-box">
|
||||||
<a-tag color="purple" class="qr-tag">Clash / Mihomo</a-tag>
|
<a-tag color="purple" class="qr-tag">Clash / Mihomo</a-tag>
|
||||||
<a-qrcode class="qr-code" :value="subClashUrl" :size="QR_SIZE" type="svg" :bordered="false"
|
<a-qrcode class="qr-code" :value="subClashUrl" :size="QR_SIZE" type="svg" :bordered="false"
|
||||||
:title="t('copy')" @click="copy(subClashUrl)" />
|
color="#000000" bg-color="#ffffff" :title="t('copy')" @click="copy(subClashUrl)" />
|
||||||
</div>
|
</div>
|
||||||
</a-col>
|
</a-col>
|
||||||
</a-row>
|
</a-row>
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
import { Modal } from 'ant-design-vue';
|
import { Modal } from 'ant-design-vue';
|
||||||
|
|
||||||
import BalancerFormModal from './BalancerFormModal.vue';
|
import BalancerFormModal from './BalancerFormModal.vue';
|
||||||
|
import JsonEditor from '@/components/JsonEditor.vue';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
|
@ -320,8 +321,7 @@ const obsText = computed({
|
||||||
<a-radio-button v-if="hasObservatory" value="observatory">Observatory</a-radio-button>
|
<a-radio-button v-if="hasObservatory" value="observatory">Observatory</a-radio-button>
|
||||||
<a-radio-button v-if="hasBurstObservatory" value="burstObservatory">Burst Observatory</a-radio-button>
|
<a-radio-button v-if="hasBurstObservatory" value="burstObservatory">Burst Observatory</a-radio-button>
|
||||||
</a-radio-group>
|
</a-radio-group>
|
||||||
<a-textarea v-model:value="obsText" :auto-size="{ minRows: 8, maxRows: 24 }" spellcheck="false"
|
<JsonEditor v-model:value="obsText" min-height="220px" max-height="480px" />
|
||||||
class="json-editor" />
|
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -356,9 +356,4 @@ const obsText = computed({
|
||||||
color: #ff4d4f;
|
color: #ff4d4f;
|
||||||
}
|
}
|
||||||
|
|
||||||
.json-editor {
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
||||||
font-size: 12px;
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import {
|
||||||
DNSRuleActions,
|
DNSRuleActions,
|
||||||
} from '@/models/outbound.js';
|
} from '@/models/outbound.js';
|
||||||
import FinalMaskForm from '@/components/FinalMaskForm.vue';
|
import FinalMaskForm from '@/components/FinalMaskForm.vue';
|
||||||
|
import JsonEditor from '@/components/JsonEditor.vue';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
|
@ -988,8 +989,7 @@ function regenerateWgKeys() {
|
||||||
<a-button>Convert</a-button>
|
<a-button>Convert</a-button>
|
||||||
</template>
|
</template>
|
||||||
</a-input-search>
|
</a-input-search>
|
||||||
<a-textarea v-model:value="advancedJson" :auto-size="{ minRows: 14, maxRows: 30 }" spellcheck="false"
|
<JsonEditor v-model:value="advancedJson" min-height="360px" max-height="600px" />
|
||||||
class="json-editor" />
|
|
||||||
</a-space>
|
</a-space>
|
||||||
</a-tab-pane>
|
</a-tab-pane>
|
||||||
</a-tabs>
|
</a-tabs>
|
||||||
|
|
@ -1032,11 +1032,6 @@ function regenerateWgKeys() {
|
||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
}
|
}
|
||||||
|
|
||||||
.json-editor {
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* AD-Vue 4 renders a-checkbox children inside a-checkbox-group as
|
/* AD-Vue 4 renders a-checkbox children inside a-checkbox-group as
|
||||||
* inline-block, but inside a narrow form wrapper they can wrap
|
* inline-block, but inside a narrow form wrapper they can wrap
|
||||||
* inconsistently. Force a clean horizontal row with even gaps. */
|
* inconsistently. Force a clean horizontal row with even gaps. */
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import BalancersTab from './BalancersTab.vue';
|
||||||
import DnsTab from './DnsTab.vue';
|
import DnsTab from './DnsTab.vue';
|
||||||
import WarpModal from './WarpModal.vue';
|
import WarpModal from './WarpModal.vue';
|
||||||
import NordModal from './NordModal.vue';
|
import NordModal from './NordModal.vue';
|
||||||
|
import JsonEditor from '@/components/JsonEditor.vue';
|
||||||
import { useXraySetting } from './useXraySetting.js';
|
import { useXraySetting } from './useXraySetting.js';
|
||||||
import { useWebSocket } from '@/composables/useWebSocket.js';
|
import { useWebSocket } from '@/composables/useWebSocket.js';
|
||||||
|
|
||||||
|
|
@ -301,10 +302,13 @@ onBeforeUnmount(() => {
|
||||||
|
|
||||||
<!-- Tabs -->
|
<!-- Tabs -->
|
||||||
<a-col :span="24">
|
<a-col :span="24">
|
||||||
<a-tabs :active-key="activeTabKey" @change="onTabChange">
|
<a-tabs :active-key="activeTabKey" :class="{ 'icons-only': isMobile }" @change="onTabChange">
|
||||||
<a-tab-pane key="tpl-basic" class="tab-pane">
|
<a-tab-pane key="tpl-basic" class="tab-pane">
|
||||||
<template #tab>
|
<template #tab>
|
||||||
<SettingOutlined /> <span>{{ t('pages.xray.basicTemplate') }}</span>
|
<a-tooltip :title="isMobile ? t('pages.xray.basicTemplate') : null">
|
||||||
|
<SettingOutlined />
|
||||||
|
</a-tooltip>
|
||||||
|
<span v-if="!isMobile">{{ t('pages.xray.basicTemplate') }}</span>
|
||||||
</template>
|
</template>
|
||||||
<BasicsTab :template-settings="templateSettings" :outbound-test-url="outboundTestUrl"
|
<BasicsTab :template-settings="templateSettings" :outbound-test-url="outboundTestUrl"
|
||||||
:warp-exist="warpExist" :nord-exist="nordExist"
|
:warp-exist="warpExist" :nord-exist="nordExist"
|
||||||
|
|
@ -314,7 +318,10 @@ onBeforeUnmount(() => {
|
||||||
|
|
||||||
<a-tab-pane key="tpl-routing" class="tab-pane">
|
<a-tab-pane key="tpl-routing" class="tab-pane">
|
||||||
<template #tab>
|
<template #tab>
|
||||||
<SwapOutlined /> <span>{{ t('pages.xray.Routings') }}</span>
|
<a-tooltip :title="isMobile ? t('pages.xray.Routings') : null">
|
||||||
|
<SwapOutlined />
|
||||||
|
</a-tooltip>
|
||||||
|
<span v-if="!isMobile">{{ t('pages.xray.Routings') }}</span>
|
||||||
</template>
|
</template>
|
||||||
<RoutingTab :template-settings="templateSettings" :inbound-tags="inboundTags"
|
<RoutingTab :template-settings="templateSettings" :inbound-tags="inboundTags"
|
||||||
:client-reverse-tags="clientReverseTags" :is-mobile="isMobile" />
|
:client-reverse-tags="clientReverseTags" :is-mobile="isMobile" />
|
||||||
|
|
@ -322,7 +329,10 @@ onBeforeUnmount(() => {
|
||||||
|
|
||||||
<a-tab-pane key="tpl-outbound" class="tab-pane">
|
<a-tab-pane key="tpl-outbound" class="tab-pane">
|
||||||
<template #tab>
|
<template #tab>
|
||||||
<UploadOutlined /> <span>{{ t('pages.xray.Outbounds') }}</span>
|
<a-tooltip :title="isMobile ? t('pages.xray.Outbounds') : null">
|
||||||
|
<UploadOutlined />
|
||||||
|
</a-tooltip>
|
||||||
|
<span v-if="!isMobile">{{ t('pages.xray.Outbounds') }}</span>
|
||||||
</template>
|
</template>
|
||||||
<OutboundsTab :template-settings="templateSettings" :outbounds-traffic="outboundsTraffic"
|
<OutboundsTab :template-settings="templateSettings" :outbounds-traffic="outboundsTraffic"
|
||||||
:outbound-test-states="outboundTestStates" :testing-all="testingAll"
|
:outbound-test-states="outboundTestStates" :testing-all="testingAll"
|
||||||
|
|
@ -334,7 +344,10 @@ onBeforeUnmount(() => {
|
||||||
|
|
||||||
<a-tab-pane key="tpl-balancer" class="tab-pane">
|
<a-tab-pane key="tpl-balancer" class="tab-pane">
|
||||||
<template #tab>
|
<template #tab>
|
||||||
<ClusterOutlined /> <span>{{ t('pages.xray.Balancers') }}</span>
|
<a-tooltip :title="isMobile ? t('pages.xray.Balancers') : null">
|
||||||
|
<ClusterOutlined />
|
||||||
|
</a-tooltip>
|
||||||
|
<span v-if="!isMobile">{{ t('pages.xray.Balancers') }}</span>
|
||||||
</template>
|
</template>
|
||||||
<BalancersTab :template-settings="templateSettings"
|
<BalancersTab :template-settings="templateSettings"
|
||||||
:client-reverse-tags="clientReverseTags" :is-mobile="isMobile" />
|
:client-reverse-tags="clientReverseTags" :is-mobile="isMobile" />
|
||||||
|
|
@ -342,14 +355,20 @@ onBeforeUnmount(() => {
|
||||||
|
|
||||||
<a-tab-pane key="tpl-dns" class="tab-pane">
|
<a-tab-pane key="tpl-dns" class="tab-pane">
|
||||||
<template #tab>
|
<template #tab>
|
||||||
<DatabaseOutlined /> <span>DNS</span>
|
<a-tooltip :title="isMobile ? 'DNS' : null">
|
||||||
|
<DatabaseOutlined />
|
||||||
|
</a-tooltip>
|
||||||
|
<span v-if="!isMobile">DNS</span>
|
||||||
</template>
|
</template>
|
||||||
<DnsTab :template-settings="templateSettings" />
|
<DnsTab :template-settings="templateSettings" />
|
||||||
</a-tab-pane>
|
</a-tab-pane>
|
||||||
|
|
||||||
<a-tab-pane key="tpl-advanced" class="tab-pane">
|
<a-tab-pane key="tpl-advanced" class="tab-pane">
|
||||||
<template #tab>
|
<template #tab>
|
||||||
<CodeOutlined /> <span>{{ t('pages.xray.advancedTemplate') }}</span>
|
<a-tooltip :title="isMobile ? t('pages.xray.advancedTemplate') : null">
|
||||||
|
<CodeOutlined />
|
||||||
|
</a-tooltip>
|
||||||
|
<span v-if="!isMobile">{{ t('pages.xray.advancedTemplate') }}</span>
|
||||||
</template>
|
</template>
|
||||||
<a-list-item-meta :title="t('pages.xray.Template')" :description="t('pages.xray.TemplateDesc')" />
|
<a-list-item-meta :title="t('pages.xray.Template')" :description="t('pages.xray.TemplateDesc')" />
|
||||||
<a-radio-group v-model:value="advSettings" button-style="solid"
|
<a-radio-group v-model:value="advSettings" button-style="solid"
|
||||||
|
|
@ -359,8 +378,7 @@ onBeforeUnmount(() => {
|
||||||
<a-radio-button value="outboundSettings">{{ t('pages.xray.Outbounds') }}</a-radio-button>
|
<a-radio-button value="outboundSettings">{{ t('pages.xray.Outbounds') }}</a-radio-button>
|
||||||
<a-radio-button value="routingRuleSettings">{{ t('pages.xray.Routings') }}</a-radio-button>
|
<a-radio-button value="routingRuleSettings">{{ t('pages.xray.Routings') }}</a-radio-button>
|
||||||
</a-radio-group>
|
</a-radio-group>
|
||||||
<a-textarea v-model:value="advancedText" :auto-size="{ minRows: 18, maxRows: 40 }"
|
<JsonEditor v-model:value="advancedText" min-height="420px" max-height="720px" />
|
||||||
spellcheck="false" class="json-editor" />
|
|
||||||
</a-tab-pane>
|
</a-tab-pane>
|
||||||
</a-tabs>
|
</a-tabs>
|
||||||
</a-col>
|
</a-col>
|
||||||
|
|
@ -447,8 +465,32 @@ onBeforeUnmount(() => {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.json-editor {
|
.icons-only :deep(.ant-tabs-nav) {
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
margin-bottom: 8px;
|
||||||
font-size: 12px;
|
}
|
||||||
|
|
||||||
|
.icons-only :deep(.ant-tabs-nav-wrap) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icons-only :deep(.ant-tabs-nav-list) {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icons-only :deep(.ant-tabs-tab) {
|
||||||
|
flex: 1 1 0;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icons-only :deep(.ant-tabs-tab .anticon) {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icons-only :deep(.ant-tabs-nav-operations) {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -43,36 +43,6 @@ func (a *atomicBool) takeAndReset() bool {
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
type emailSet struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
m map[string]struct{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func newEmailSet() *emailSet { return &emailSet{m: make(map[string]struct{})} }
|
|
||||||
|
|
||||||
func (s *emailSet) addAll(emails []string) {
|
|
||||||
if len(emails) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.mu.Lock()
|
|
||||||
for _, e := range emails {
|
|
||||||
if e != "" {
|
|
||||||
s.m[e] = struct{}{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
s.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *emailSet) slice() []string {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
out := make([]string, 0, len(s.m))
|
|
||||||
for e := range s.m {
|
|
||||||
out = append(out, e)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewNodeTrafficSyncJob() *NodeTrafficSyncJob {
|
func NewNodeTrafficSyncJob() *NodeTrafficSyncJob {
|
||||||
return &NodeTrafficSyncJob{}
|
return &NodeTrafficSyncJob{}
|
||||||
}
|
}
|
||||||
|
|
@ -97,7 +67,6 @@ func (j *NodeTrafficSyncJob) Run() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
touched := newEmailSet()
|
|
||||||
sem := make(chan struct{}, nodeTrafficSyncConcurrency)
|
sem := make(chan struct{}, nodeTrafficSyncConcurrency)
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
for _, n := range nodes {
|
for _, n := range nodes {
|
||||||
|
|
@ -109,7 +78,7 @@ func (j *NodeTrafficSyncJob) Run() {
|
||||||
go func(n *model.Node) {
|
go func(n *model.Node) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
defer func() { <-sem }()
|
defer func() { <-sem }()
|
||||||
j.syncOne(mgr, n, touched)
|
j.syncOne(mgr, n)
|
||||||
}(n)
|
}(n)
|
||||||
}
|
}
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|
@ -135,12 +104,10 @@ func (j *NodeTrafficSyncJob) Run() {
|
||||||
})
|
})
|
||||||
|
|
||||||
clientStats := map[string]any{}
|
clientStats := map[string]any{}
|
||||||
if emails := touched.slice(); len(emails) > 0 {
|
if stats, err := j.inboundService.GetAllClientTraffics(); err != nil {
|
||||||
if stats, err := j.inboundService.GetActiveClientTraffics(emails); err != nil {
|
logger.Warning("node traffic sync: get all client traffics for websocket failed:", err)
|
||||||
logger.Warning("node traffic sync: get client traffics for websocket failed:", err)
|
} else if len(stats) > 0 {
|
||||||
} else if len(stats) > 0 {
|
clientStats["clients"] = stats
|
||||||
clientStats["clients"] = stats
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if summary, err := j.inboundService.GetInboundsTrafficSummary(); err != nil {
|
if summary, err := j.inboundService.GetInboundsTrafficSummary(); err != nil {
|
||||||
logger.Warning("node traffic sync: get inbounds summary for websocket failed:", err)
|
logger.Warning("node traffic sync: get inbounds summary for websocket failed:", err)
|
||||||
|
|
@ -156,7 +123,7 @@ func (j *NodeTrafficSyncJob) Run() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, touched *emailSet) {
|
func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), nodeTrafficSyncRequestTimeout)
|
ctx, cancel := context.WithTimeout(context.Background(), nodeTrafficSyncRequestTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
|
@ -179,16 +146,4 @@ func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, touche
|
||||||
if changed {
|
if changed {
|
||||||
j.structural.set()
|
j.structural.set()
|
||||||
}
|
}
|
||||||
for _, ib := range snap.Inbounds {
|
|
||||||
if ib == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
emails := make([]string, 0, len(ib.ClientStats))
|
|
||||||
for _, cs := range ib.ClientStats {
|
|
||||||
if cs.Email != "" {
|
|
||||||
emails = append(emails, cs.Email)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
touched.addAll(emails)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -95,18 +95,16 @@ func (j *XrayTrafficJob) Run() {
|
||||||
"lastOnlineMap": lastOnlineMap,
|
"lastOnlineMap": lastOnlineMap,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Compact delta payload: per-client absolute counters for clients active
|
// Full snapshot every cycle: absolute per-client counters and inbound
|
||||||
// this cycle, plus inbound-level absolute totals. Frontend applies both
|
// totals. Frontend overwrites both in place. The previous delta path
|
||||||
// in-place — typical payload ~10–50KB even for 10k+ client deployments.
|
// (activeEmails -> GetActiveClientTraffics) silently omitted the
|
||||||
// Replaces the old full-inbound-list broadcast that hit WS size limits
|
// clients array whenever nobody moved bytes in the cycle, leaving the
|
||||||
// (5–10MB) and forced the frontend into a REST refetch.
|
// client rows in the UI stuck at stale traffic/remained/all-time.
|
||||||
clientStatsPayload := map[string]any{}
|
clientStatsPayload := map[string]any{}
|
||||||
if activeEmails := activeEmails(clientTraffics); len(activeEmails) > 0 {
|
if stats, err := j.inboundService.GetAllClientTraffics(); err != nil {
|
||||||
if stats, err := j.inboundService.GetActiveClientTraffics(activeEmails); err != nil {
|
logger.Warning("get all client traffics for websocket failed:", err)
|
||||||
logger.Warning("get active client traffics for websocket failed:", err)
|
} else if len(stats) > 0 {
|
||||||
} else if len(stats) > 0 {
|
clientStatsPayload["clients"] = stats
|
||||||
clientStatsPayload["clients"] = stats
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if inboundSummary, err := j.inboundService.GetInboundsTrafficSummary(); err != nil {
|
if inboundSummary, err := j.inboundService.GetInboundsTrafficSummary(); err != nil {
|
||||||
logger.Warning("get inbounds traffic summary for websocket failed:", err)
|
logger.Warning("get inbounds traffic summary for websocket failed:", err)
|
||||||
|
|
@ -126,26 +124,6 @@ func (j *XrayTrafficJob) Run() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// activeEmails returns the set of client emails that had non-zero traffic in
|
|
||||||
// the current collection window. Idle clients are skipped — no need to push
|
|
||||||
// their (unchanged) counters to the frontend.
|
|
||||||
func activeEmails(clientTraffics []*xray.ClientTraffic) []string {
|
|
||||||
if len(clientTraffics) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
emails := make([]string, 0, len(clientTraffics))
|
|
||||||
for _, ct := range clientTraffics {
|
|
||||||
if ct == nil || ct.Email == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if ct.Up == 0 && ct.Down == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
emails = append(emails, ct.Email)
|
|
||||||
}
|
|
||||||
return emails
|
|
||||||
}
|
|
||||||
|
|
||||||
func (j *XrayTrafficJob) informTrafficToExternalAPI(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) {
|
func (j *XrayTrafficJob) informTrafficToExternalAPI(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) {
|
||||||
informURL, err := j.settingService.GetExternalTrafficInformURI()
|
informURL, err := j.settingService.GetExternalTrafficInformURI()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -3322,6 +3322,20 @@ func (s *InboundService) GetActiveClientTraffics(emails []string) ([]*xray.Clien
|
||||||
return traffics, nil
|
return traffics, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetAllClientTraffics returns the full set of client_traffics rows so the
|
||||||
|
// websocket broadcasters can ship a complete snapshot every cycle. The old
|
||||||
|
// delta-only path (GetActiveClientTraffics on activeEmails) silently dropped
|
||||||
|
// the per-client section whenever no client moved bytes in the cycle or a
|
||||||
|
// node sync failed, leaving client rows in the UI stuck at stale numbers.
|
||||||
|
func (s *InboundService) GetAllClientTraffics() ([]*xray.ClientTraffic, error) {
|
||||||
|
db := database.GetDB()
|
||||||
|
var traffics []*xray.ClientTraffic
|
||||||
|
if err := db.Model(xray.ClientTraffic{}).Find(&traffics).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return traffics, nil
|
||||||
|
}
|
||||||
|
|
||||||
type InboundTrafficSummary struct {
|
type InboundTrafficSummary struct {
|
||||||
Id int `json:"id"`
|
Id int `json:"id"`
|
||||||
Up int64 `json:"up"`
|
Up int64 `json:"up"`
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue