Merge branch 'main' into feat/api-token-install

This commit is contained in:
Sanaei 2026-05-14 10:24:10 +02:00 committed by GitHub
commit f656dd37af
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 1670 additions and 449 deletions

View file

@ -12,5 +12,6 @@ services:
XRAY_VMESS_AEAD_FORCED: "false" XRAY_VMESS_AEAD_FORCED: "false"
XUI_ENABLE_FAIL2BAN: "true" XUI_ENABLE_FAIL2BAN: "true"
tty: true tty: true
network_mode: host ports:
- "2053:2053"
restart: unless-stopped restart: unless-stopped

File diff suppressed because it is too large Load diff

View file

@ -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",

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

View file

@ -1595,6 +1595,10 @@ export class Inbound extends XrayCommonClass {
}); });
} }
if (typeof xhttp.mode === 'string' && xhttp.mode.length > 0) {
extra.mode = xhttp.mode;
}
const stringFields = [ const stringFields = [
"sessionPlacement", "sessionKey", "sessionPlacement", "sessionKey",
"seqPlacement", "seqKey", "seqPlacement", "seqKey",
@ -2967,37 +2971,45 @@ Inbound.HysteriaSettings.Hysteria = class extends Inbound.ClientBase {
Inbound.TunnelSettings = class extends Inbound.Settings { Inbound.TunnelSettings = class extends Inbound.Settings {
constructor( constructor(
protocol, protocol,
address, rewriteAddress,
port, rewritePort,
portMap = [], portMap = [],
network = 'tcp,udp', allowedNetwork = 'tcp,udp',
followRedirect = false followRedirect = false
) { ) {
super(protocol); super(protocol);
this.address = address; this.rewriteAddress = rewriteAddress;
this.port = port; this.rewritePort = rewritePort;
this.portMap = portMap; this.portMap = portMap;
this.network = network; this.allowedNetwork = allowedNetwork;
this.followRedirect = followRedirect; this.followRedirect = followRedirect;
} }
addPortMap(port = '', target = '') {
this.portMap.push({ name: port, value: target });
}
removePortMap(index) {
this.portMap.splice(index, 1);
}
static fromJson(json = {}) { static fromJson(json = {}) {
return new Inbound.TunnelSettings( return new Inbound.TunnelSettings(
Protocols.TUNNEL, Protocols.TUNNEL,
json.address, json.rewriteAddress,
json.port, json.rewritePort,
XrayCommonClass.toHeaders(json.portMap), XrayCommonClass.toHeaders(json.portMap),
json.network, json.allowedNetwork,
json.followRedirect, json.followRedirect,
); );
} }
toJson() { toJson() {
return { return {
address: this.address, rewriteAddress: this.rewriteAddress,
port: this.port, rewritePort: this.rewritePort,
portMap: XrayCommonClass.toV2Headers(this.portMap, false), portMap: XrayCommonClass.toV2Headers(this.portMap, false),
network: this.network, allowedNetwork: this.allowedNetwork,
followRedirect: this.followRedirect, followRedirect: this.followRedirect,
}; };
} }

View file

@ -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;
@ -217,6 +222,14 @@ watch(clients, (list) => {
if (next.size !== selected.value.size) selected.value = next; if (next.size !== selected.value.size) selected.value = next;
}); });
const statsClient = ref(null);
function openStats(client) {
statsClient.value = client;
}
function closeStats() {
statsClient.value = null;
}
function confirmBulkDelete() { function confirmBulkDelete() {
const picked = clients.value.filter((c) => selected.value.has(rowKey(c))); const picked = clients.value.filter((c) => selected.value.has(rowKey(c)));
if (picked.length === 0) return; if (picked.length === 0) return;
@ -433,6 +446,9 @@ function confirmBulkDelete() {
<span class="client-email">{{ client.email }}</span> <span class="client-email">{{ client.email }}</span>
</a-tooltip> </a-tooltip>
<div class="client-card-actions"> <div class="client-card-actions">
<a-tooltip :title="t('info')">
<InfoCircleOutlined class="row-icon" @click="openStats(client)" />
</a-tooltip>
<a-switch :checked="client.enable" size="small" <a-switch :checked="client.enable" size="small"
@change="(next) => emit('toggle-enable-client', { dbInbound, client, next })" /> @change="(next) => emit('toggle-enable-client', { dbInbound, client, next })" />
<a-dropdown :trigger="['click']" placement="bottomRight"> <a-dropdown :trigger="['click']" placement="bottomRight">
@ -459,52 +475,55 @@ function confirmBulkDelete() {
</a-dropdown> </a-dropdown>
</div> </div>
</div> </div>
<div v-if="client.comment && client.comment.trim()" class="client-comment-line">
{{ client.comment.length > 80 ? client.comment.substring(0, 77) + '…' : client.comment }}
</div> </div>
<div class="client-card-foot"> <a-modal :open="!!statsClient" :footer="null" :width="360" centered
:title="statsClient ? statsClient.email || t('info') : ''" @cancel="closeStats">
<div v-if="statsClient" class="client-card-foot">
<div v-if="statsClient.comment && statsClient.comment.trim()" class="client-comment-line">
{{ statsClient.comment }}
</div>
<div class="stat-row"> <div class="stat-row">
<span class="stat-label">{{ t('pages.inbounds.traffic') }}</span> <span class="stat-label">{{ t('pages.inbounds.traffic') }}</span>
<a-tag :color="clientStatsColor(client.email)"> <a-tag :color="clientStatsColor(statsClient.email)">
{{ SizeFormatter.sizeFormat(getSum(client.email)) }} / {{ SizeFormatter.sizeFormat(getSum(statsClient.email)) }} /
<InfinityIcon v-if="isUnlimitedTotal(client)" /> <InfinityIcon v-if="isUnlimitedTotal(statsClient)" />
<template v-else>{{ totalGbDisplay(client) }}</template> <template v-else>{{ totalGbDisplay(statsClient) }}</template>
</a-tag> </a-tag>
</div> </div>
<div class="stat-row"> <div class="stat-row">
<span class="stat-label">{{ t('remained') }}</span> <span class="stat-label">{{ t('remained') }}</span>
<a-tag v-if="isUnlimitedTotal(client)" color="purple" :style="{ border: 'none' }" class="infinite-tag"> <a-tag v-if="isUnlimitedTotal(statsClient)" color="purple" :style="{ border: 'none' }" class="infinite-tag">
<InfinityIcon /> <InfinityIcon />
</a-tag> </a-tag>
<a-tag v-else :color="isClientDepleted(client.email) ? 'red' : ''"> <a-tag v-else :color="isClientDepleted(statsClient.email) ? 'red' : ''">
{{ SizeFormatter.sizeFormat(getRem(client.email)) }} {{ SizeFormatter.sizeFormat(getRem(statsClient.email)) }}
</a-tag> </a-tag>
</div> </div>
<div class="stat-row"> <div class="stat-row">
<span class="stat-label">{{ t('pages.inbounds.allTimeTraffic') }}</span> <span class="stat-label">{{ t('pages.inbounds.allTimeTraffic') }}</span>
<a-tag>{{ SizeFormatter.sizeFormat(getAllTime(client.email)) }}</a-tag> <a-tag>{{ SizeFormatter.sizeFormat(getAllTime(statsClient.email)) }}</a-tag>
</div> </div>
<div class="stat-row"> <div class="stat-row">
<span class="stat-label">{{ t('online') }}</span> <span class="stat-label">{{ t('online') }}</span>
<a-tag v-if="client.enable && isClientOnline(client.email)" color="green">{{ t('online') }}</a-tag> <a-tag v-if="statsClient.enable && isClientOnline(statsClient.email)" color="green">{{ t('online') }}</a-tag>
<a-tag v-else>{{ t('offline') }}</a-tag> <a-tag v-else>{{ t('offline') }}</a-tag>
</div> </div>
<div class="stat-row"> <div class="stat-row">
<span class="stat-label">{{ t('pages.inbounds.expireDate') }}</span> <span class="stat-label">{{ t('pages.inbounds.expireDate') }}</span>
<a-tag v-if="client.expiryTime > 0" :color="ColorUtils.userExpiryColor(expireDiff, client, isDarkTheme)"> <a-tag v-if="statsClient.expiryTime > 0"
{{ IntlUtil.formatRelativeTime(client.expiryTime) }} :color="ColorUtils.userExpiryColor(expireDiff, statsClient, isDarkTheme)">
{{ IntlUtil.formatRelativeTime(statsClient.expiryTime) }}
</a-tag> </a-tag>
<a-tag v-else-if="client.expiryTime < 0" color="green"> <a-tag v-else-if="statsClient.expiryTime < 0" color="green">
{{ -client.expiryTime / 86400000 }}d ({{ t('pages.client.delayedStart') }}) {{ -statsClient.expiryTime / 86400000 }}d ({{ t('pages.client.delayedStart') }})
</a-tag> </a-tag>
<a-tag v-else color="purple"> <a-tag v-else color="purple">
<InfinityIcon /> <InfinityIcon />
</a-tag> </a-tag>
</div> </div>
</div> </div>
</div> </a-modal>
</template> </template>
<a-pagination v-if="pageSize > 0 && clients.length > pageSize" v-model:current="currentPage" <a-pagination v-if="pageSize > 0 && clients.length > pageSize" v-model:current="currentPage"

View file

@ -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();
@ -679,10 +680,7 @@ watch(
</a-tab-pane> </a-tab-pane>
<!-- ============================== PROTOCOL ============================== --> <!-- ============================== PROTOCOL ============================== -->
<!-- TUN has no per-protocol form yet (interface/mtu/gateway live in <a-tab-pane key="protocol" :tab="t('pages.inbounds.protocol')">
settings JSON), so the tab would render empty hide it until
a TUN form is added. -->
<a-tab-pane v-if="protocol !== Protocols.TUN" key="protocol" :tab="t('pages.inbounds.protocol')">
<!-- Multi-user inbounds: in add mode embed the first client form, <!-- Multi-user inbounds: in add mode embed the first client form,
in edit mode show a count summary. --> in edit mode show a count summary. -->
<template v-if="isMultiUser"> <template v-if="isMultiUser">
@ -895,24 +893,126 @@ watch(
<!-- Tunnel --> <!-- Tunnel -->
<a-form v-if="protocol === Protocols.TUNNEL" :colon="false" :label-col="{ sm: { span: 8 } }" <a-form v-if="protocol === Protocols.TUNNEL" :colon="false" :label-col="{ sm: { span: 8 } }"
:wrapper-col="{ sm: { span: 14 } }" class="mt-12"> :wrapper-col="{ sm: { span: 14 } }" class="mt-12">
<a-form-item label="Address"> <a-form-item label="Rewrite address">
<a-input v-model:value="inbound.settings.address" /> <a-input v-model:value="inbound.settings.rewriteAddress" />
</a-form-item> </a-form-item>
<a-form-item label="Destination port"> <a-form-item label="Rewrite port">
<a-input-number v-model:value="inbound.settings.port" :min="1" :max="65535" /> <a-input-number v-model:value="inbound.settings.rewritePort" :min="0" :max="65535" />
</a-form-item> </a-form-item>
<a-form-item label="Network"> <a-form-item label="Allowed network">
<a-select v-model:value="inbound.settings.network"> <a-select v-model:value="inbound.settings.allowedNetwork">
<a-select-option value="tcp,udp">TCP, UDP</a-select-option> <a-select-option value="tcp,udp">TCP, UDP</a-select-option>
<a-select-option value="tcp">TCP</a-select-option> <a-select-option value="tcp">TCP</a-select-option>
<a-select-option value="udp">UDP</a-select-option> <a-select-option value="udp">UDP</a-select-option>
</a-select> </a-select>
</a-form-item> </a-form-item>
<a-form-item label="Port map">
<a-button size="small" @click="inbound.settings.addPortMap('', '')">
<template #icon>
<PlusOutlined />
</template>
</a-button>
</a-form-item>
<a-form-item v-if="inbound.settings.portMap.length > 0" :wrapper-col="{ span: 24 }">
<a-input-group v-for="(pm, idx) in inbound.settings.portMap" :key="`pm-${idx}`" compact class="mb-8">
<a-input :style="{ width: '30%' }" v-model:value="pm.name" placeholder="5555">
<template #addonBefore>{{ idx + 1 }}</template>
</a-input>
<a-input :style="{ width: '60%' }" v-model:value="pm.value" placeholder="1.1.1.1:7777" />
<a-button @click="inbound.settings.removePortMap(idx)">
<template #icon>
<MinusOutlined />
</template>
</a-button>
</a-input-group>
</a-form-item>
<a-form-item label="Follow redirect"> <a-form-item label="Follow redirect">
<a-switch v-model:checked="inbound.settings.followRedirect" /> <a-switch v-model:checked="inbound.settings.followRedirect" />
</a-form-item> </a-form-item>
</a-form> </a-form>
<!-- TUN -->
<a-form v-if="protocol === Protocols.TUN" :colon="false" :label-col="{ sm: { span: 8 } }"
:wrapper-col="{ sm: { span: 14 } }" class="mt-12">
<a-form-item label="Interface name">
<a-input v-model:value="inbound.settings.name" placeholder="xray0" />
</a-form-item>
<a-form-item label="MTU">
<a-input-number v-model:value="inbound.settings.mtu" :min="0" />
</a-form-item>
<a-form-item label="Gateway">
<a-button size="small" @click="inbound.settings.gateway.push('')">
<template #icon>
<PlusOutlined />
</template>
</a-button>
<a-input v-for="(_ip, j) in inbound.settings.gateway" :key="`tun-gw-${j}`"
v-model:value="inbound.settings.gateway[j]" class="mt-4"
:placeholder="j === 0 ? '10.0.0.1/16' : 'fc00::1/64'">
<template #addonAfter>
<a-button size="small" @click="inbound.settings.gateway.splice(j, 1)">
<template #icon>
<MinusOutlined />
</template>
</a-button>
</template>
</a-input>
</a-form-item>
<a-form-item label="DNS">
<a-button size="small" @click="inbound.settings.dns.push('')">
<template #icon>
<PlusOutlined />
</template>
</a-button>
<a-input v-for="(_ip, j) in inbound.settings.dns" :key="`tun-dns-${j}`"
v-model:value="inbound.settings.dns[j]" class="mt-4" :placeholder="j === 0 ? '1.1.1.1' : '8.8.8.8'">
<template #addonAfter>
<a-button size="small" @click="inbound.settings.dns.splice(j, 1)">
<template #icon>
<MinusOutlined />
</template>
</a-button>
</template>
</a-input>
</a-form-item>
<a-form-item label="User level">
<a-input-number v-model:value="inbound.settings.userLevel" :min="0" />
</a-form-item>
<a-form-item>
<template #label>
<a-tooltip
title="Windows-only. CIDRs added to the system routing table automatically so matching traffic goes through TUN.">
Auto system routes
</a-tooltip>
</template>
<a-button size="small" @click="inbound.settings.autoSystemRoutingTable.push('')">
<template #icon>
<PlusOutlined />
</template>
</a-button>
<a-input v-for="(_ip, j) in inbound.settings.autoSystemRoutingTable" :key="`tun-rt-${j}`"
v-model:value="inbound.settings.autoSystemRoutingTable[j]" class="mt-4"
:placeholder="j === 0 ? '0.0.0.0/0' : '::/0'">
<template #addonAfter>
<a-button size="small" @click="inbound.settings.autoSystemRoutingTable.splice(j, 1)">
<template #icon>
<MinusOutlined />
</template>
</a-button>
</template>
</a-input>
</a-form-item>
<a-form-item>
<template #label>
<a-tooltip
title='Physical interface for outbound traffic. Use "auto" to detect; auto-enabled when Auto system routes is set.'>
Auto outbounds interface
</a-tooltip>
</template>
<a-input v-model:value="inbound.settings.autoOutboundsInterface" placeholder="auto" />
</a-form-item>
</a-form>
<!-- WireGuard --> <!-- WireGuard -->
<a-form v-if="protocol === Protocols.WIREGUARD" :colon="false" :label-col="{ sm: { span: 8 } }" <a-form v-if="protocol === Protocols.WIREGUARD" :colon="false" :label-col="{ sm: { span: 8 } }"
:wrapper-col="{ sm: { span: 14 } }" class="mt-12"> :wrapper-col="{ sm: { span: 14 } }" class="mt-12">
@ -1723,9 +1823,33 @@ watch(
</a-form-item> </a-form-item>
</template> </template>
<!-- ====== Hysteria Masquerade ====== --> <!-- ====== Hysteria stream settings ====== -->
<!-- Per https://xtls.github.io/config/transports/hysteria.html#masqobject --> <!-- Per https://xtls.github.io/config/transports/hysteria.html -->
<template v-if="protocol === Protocols.HYSTERIA"> <template v-if="protocol === Protocols.HYSTERIA">
<a-form-item>
<template #label>
<a-tooltip title="Hysteria protocol version. Currently must be 2.">
Version
</a-tooltip>
</template>
<a-input-number v-model:value="inbound.stream.hysteria.version" :min="2" :max="2" />
</a-form-item>
<a-form-item>
<template #label>
<a-tooltip title="Obfuscation password. Must match between server and client.">
Obfs password
</a-tooltip>
</template>
<a-input v-model:value="inbound.stream.hysteria.auth" />
</a-form-item>
<a-form-item>
<template #label>
<a-tooltip title="Idle timeout (seconds) for a single QUIC native UDP connection.">
UDP idle timeout
</a-tooltip>
</template>
<a-input-number v-model:value="inbound.stream.hysteria.udpIdleTimeout" :min="0" />
</a-form-item>
<a-form-item label="Masquerade"> <a-form-item label="Masquerade">
<a-switch v-model:checked="inbound.stream.hysteria.masqueradeSwitch" /> <a-switch v-model:checked="inbound.stream.hysteria.masqueradeSwitch" />
</a-form-item> </a-form-item>
@ -1833,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>
@ -1892,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;

View file

@ -585,19 +585,50 @@ const showSubscriptionTab = computed(
</tbody> </tbody>
</table> </table>
<!-- TUN -->
<dl v-if="inbound.protocol === Protocols.TUN" class="info-list info-list-block">
<div class="info-row">
<dt>Interface name</dt>
<dd><a-tag color="green" class="value-tag">{{ inbound.settings.name }}</a-tag></dd>
</div>
<div class="info-row">
<dt>MTU</dt>
<dd><a-tag color="green">{{ inbound.settings.mtu }}</a-tag></dd>
</div>
<div v-if="inbound.settings.gateway?.length" class="info-row">
<dt>Gateway</dt>
<dd><a-tag v-for="(ip, j) in inbound.settings.gateway" :key="`tun-i-gw-${j}`" color="green"
class="value-tag">{{ ip }}</a-tag></dd>
</div>
<div v-if="inbound.settings.dns?.length" class="info-row">
<dt>DNS</dt>
<dd><a-tag v-for="(ip, j) in inbound.settings.dns" :key="`tun-i-dns-${j}`" color="green">{{ ip }}</a-tag>
</dd>
</div>
<div class="info-row">
<dt>Outbounds interface</dt>
<dd><a-tag color="green">{{ inbound.settings.autoOutboundsInterface || 'auto' }}</a-tag></dd>
</div>
<div v-if="inbound.settings.autoSystemRoutingTable?.length" class="info-row">
<dt>Auto system routes</dt>
<dd><a-tag v-for="(cidr, j) in inbound.settings.autoSystemRoutingTable" :key="`tun-i-rt-${j}`"
color="green">{{ cidr }}</a-tag></dd>
</div>
</dl>
<!-- Tunnel --> <!-- Tunnel -->
<dl v-if="inbound.protocol === Protocols.TUNNEL" class="info-list info-list-block"> <dl v-if="inbound.protocol === Protocols.TUNNEL" class="info-list info-list-block">
<div class="info-row"> <div class="info-row">
<dt>{{ t('pages.inbounds.targetAddress') }}</dt> <dt>{{ t('pages.inbounds.targetAddress') }}</dt>
<dd><a-tag color="green" class="value-tag">{{ inbound.settings.address }}</a-tag></dd> <dd><a-tag color="green" class="value-tag">{{ inbound.settings.rewriteAddress }}</a-tag></dd>
</div> </div>
<div class="info-row"> <div class="info-row">
<dt>{{ t('pages.inbounds.destinationPort') }}</dt> <dt>{{ t('pages.inbounds.destinationPort') }}</dt>
<dd><a-tag color="green">{{ inbound.settings.port }}</a-tag></dd> <dd><a-tag color="green">{{ inbound.settings.rewritePort }}</a-tag></dd>
</div> </div>
<div class="info-row"> <div class="info-row">
<dt>{{ t('pages.inbounds.network') }}</dt> <dt>{{ t('pages.inbounds.network') }}</dt>
<dd><a-tag color="green">{{ inbound.settings.network }}</a-tag></dd> <dd><a-tag color="green">{{ inbound.settings.allowedNetwork }}</a-tag></dd>
</div> </div>
<div class="info-row"> <div class="info-row">
<dt>FollowRedirect</dt> <dt>FollowRedirect</dt>

View file

@ -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([
@ -229,7 +230,7 @@ const hasAnyRemark = computed(() =>
const desktopColumns = computed(() => { const desktopColumns = computed(() => {
const cols = [ const cols = [
sortableCol({ title: 'ID', dataIndex: 'id', key: 'id', align: 'right', width: 30 }, 'id'), sortableCol({ title: 'ID', dataIndex: 'id', key: 'id', align: 'right', width: 30 }, 'id'),
{ title: t('pages.inbounds.operate'), key: 'action', align: 'center', width: 30 }, { title: t('pages.inbounds.operate'), key: 'action', align: 'center', width: 60 },
sortableCol({ title: t('pages.inbounds.enable'), key: 'enable', align: 'center', width: 35 }, 'enable'), sortableCol({ title: t('pages.inbounds.enable'), key: 'enable', align: 'center', width: 35 }, 'enable'),
]; ];
if (hasAnyRemark.value) { if (hasAnyRemark.value) {
@ -263,6 +264,14 @@ function isExpanded(id) {
return expandedIds.value.has(id); return expandedIds.value.has(id);
} }
const statsRecord = ref(null);
function openStats(record) {
statsRecord.value = record;
}
function closeStats() {
statsRecord.value = null;
}
// ============ Pagination ============================================ // ============ Pagination ============================================
function paginationFor(rows) { function paginationFor(rows) {
const size = props.pageSize > 0 ? props.pageSize : rows.length || 1; const size = props.pageSize > 0 ? props.pageSize : rows.length || 1;
@ -388,13 +397,16 @@ function showQrCodeMenu(dbInbound) {
<div v-if="visibleInbounds.length === 0" class="card-empty"></div> <div v-if="visibleInbounds.length === 0" class="card-empty"></div>
<div v-for="record in sortedInbounds" :key="record.id" class="inbound-card"> <div v-for="record in sortedInbounds" :key="record.id" class="inbound-card">
<!-- Header: chevron (multi-user only) + remark + enable + actions --> <!-- Header: chevron (multi-user only) + id + remark + info + enable + actions -->
<div class="card-head" @click="record.isMultiUser() && toggleExpanded(record.id)"> <div class="card-head" @click="record.isMultiUser() && toggleExpanded(record.id)">
<RightOutlined v-if="record.isMultiUser()" class="card-expand" <RightOutlined v-if="record.isMultiUser()" class="card-expand"
:class="{ 'is-expanded': isExpanded(record.id) }" /> :class="{ 'is-expanded': isExpanded(record.id) }" />
<span class="card-id">#{{ record.id }}</span> <span class="card-id">#{{ record.id }}</span>
<span class="tag-name">{{ record.remark }}</span> <span class="tag-name">{{ record.remark }}</span>
<div class="card-actions" @click.stop> <div class="card-actions" @click.stop>
<a-tooltip :title="t('info')">
<InfoCircleOutlined class="row-action-trigger" @click="openStats(record)" />
</a-tooltip>
<a-switch :checked="record.enable" size="small" @change="(next) => onSwitchEnable(record, next)" /> <a-switch :checked="record.enable" size="small" @change="(next) => onSwitchEnable(record, next)" />
<a-dropdown :trigger="['click']" placement="bottomRight"> <a-dropdown :trigger="['click']" placement="bottomRight">
<MoreOutlined class="row-action-trigger" @click.prevent /> <MoreOutlined class="row-action-trigger" @click.prevent />
@ -452,74 +464,12 @@ function showQrCodeMenu(dbInbound) {
</div> </div>
</div> </div>
<!-- 2-column labelled stat grid: protocol/port/node + traffic/clients/expiry -->
<div class="card-stats">
<div class="stat-row">
<span class="stat-label">{{ t('pages.inbounds.protocol') }}</span>
<a-tag color="purple">{{ record.protocol }}</a-tag>
<template v-if="record.isVMess || record.isVLess || record.isTrojan || record.isSS || record.isHysteria">
<a-tag color="green">{{ record.isHysteria ? 'UDP' : record.toInbound().stream.network }}</a-tag>
<a-tag v-if="record.toInbound().stream.isTls" color="blue">TLS</a-tag>
<a-tag v-if="record.toInbound().stream.isReality" color="blue">Reality</a-tag>
</template>
</div>
<div class="stat-row">
<span class="stat-label">{{ t('pages.inbounds.port') }}</span>
<a-tag>{{ record.port }}</a-tag>
</div>
<div v-if="hasActiveNode" class="stat-row">
<span class="stat-label">{{ t('pages.inbounds.node') }}</span>
<a-tag v-if="record.nodeId == null" color="default">
{{ t('pages.inbounds.localPanel') }}
</a-tag>
<a-tag v-else-if="nodesById.get(record.nodeId)"
:color="nodesById.get(record.nodeId).status === 'online' ? 'blue' : 'red'">
{{ nodesById.get(record.nodeId).name }}
</a-tag>
<a-tag v-else color="orange">#{{ record.nodeId }}</a-tag>
</div>
<div class="stat-row">
<span class="stat-label">{{ t('pages.inbounds.traffic') }}</span>
<a-tag :color="ColorUtils.usageColor(record.up + record.down, trafficDiff, record.total)">
{{ SizeFormatter.sizeFormat(record.up + record.down) }} /
<template v-if="record.total > 0">{{ SizeFormatter.sizeFormat(record.total) }}</template>
<InfinityIcon v-else />
</a-tag>
</div>
<div class="stat-row">
<span class="stat-label">{{ t('pages.inbounds.allTimeTraffic') }}</span>
<a-tag>{{ SizeFormatter.sizeFormat(record.allTime || 0) }}</a-tag>
</div>
<div v-if="clientCount[record.id]" class="stat-row">
<span class="stat-label">{{ t('clients') }}</span>
<a-tag color="green" class="client-count-tag">{{ clientCount[record.id].clients }}</a-tag>
<a-tag v-if="clientCount[record.id].online.length" color="blue">
{{ clientCount[record.id].online.length }} {{ t('online') }}
</a-tag>
<a-tag v-if="clientCount[record.id].depleted.length" color="red">
{{ clientCount[record.id].depleted.length }} {{ t('depleted') }}
</a-tag>
<a-tag v-if="clientCount[record.id].expiring.length" color="orange">
{{ clientCount[record.id].expiring.length }} {{ t('depletingSoon') }}
</a-tag>
</div>
<div class="stat-row">
<span class="stat-label">{{ t('pages.inbounds.expireDate') }}</span>
<a-tag v-if="record.expiryTime > 0"
:color="ColorUtils.usageColor(Date.now(), expireDiff, record._expiryTime)">
{{ IntlUtil.formatRelativeTime(record.expiryTime) }}
</a-tag>
<a-tag v-else color="purple">
<InfinityIcon />
</a-tag>
</div>
</div>
<!-- Expanded client list (multi-user only) --> <!-- Expanded client list (multi-user only) -->
<div v-if="record.isMultiUser() && isExpanded(record.id)" class="card-clients"> <div v-if="record.isMultiUser() && isExpanded(record.id)" class="card-clients">
<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)"
@ -530,6 +480,73 @@ function showQrCodeMenu(dbInbound) {
</div> </div>
</div> </div>
<!-- ====================== Mobile: info modal ====================== -->
<a-modal v-if="isMobile" :open="!!statsRecord" :footer="null" :width="360" centered
:title="statsRecord ? `#${statsRecord.id} ${statsRecord.remark || ''}`.trim() : ''" @cancel="closeStats">
<div v-if="statsRecord" class="card-stats">
<div class="stat-row">
<span class="stat-label">{{ t('pages.inbounds.protocol') }}</span>
<a-tag color="purple">{{ statsRecord.protocol }}</a-tag>
<template
v-if="statsRecord.isVMess || statsRecord.isVLess || statsRecord.isTrojan || statsRecord.isSS || statsRecord.isHysteria">
<a-tag color="green">{{ statsRecord.isHysteria ? 'UDP' : statsRecord.toInbound().stream.network }}</a-tag>
<a-tag v-if="statsRecord.toInbound().stream.isTls" color="blue">TLS</a-tag>
<a-tag v-if="statsRecord.toInbound().stream.isReality" color="blue">Reality</a-tag>
</template>
</div>
<div class="stat-row">
<span class="stat-label">{{ t('pages.inbounds.port') }}</span>
<a-tag>{{ statsRecord.port }}</a-tag>
</div>
<div v-if="hasActiveNode" class="stat-row">
<span class="stat-label">{{ t('pages.inbounds.node') }}</span>
<a-tag v-if="statsRecord.nodeId == null" color="default">
{{ t('pages.inbounds.localPanel') }}
</a-tag>
<a-tag v-else-if="nodesById.get(statsRecord.nodeId)"
:color="nodesById.get(statsRecord.nodeId).status === 'online' ? 'blue' : 'red'">
{{ nodesById.get(statsRecord.nodeId).name }}
</a-tag>
<a-tag v-else color="orange">#{{ statsRecord.nodeId }}</a-tag>
</div>
<div class="stat-row">
<span class="stat-label">{{ t('pages.inbounds.traffic') }}</span>
<a-tag :color="ColorUtils.usageColor(statsRecord.up + statsRecord.down, trafficDiff, statsRecord.total)">
{{ SizeFormatter.sizeFormat(statsRecord.up + statsRecord.down) }} /
<template v-if="statsRecord.total > 0">{{ SizeFormatter.sizeFormat(statsRecord.total) }}</template>
<InfinityIcon v-else />
</a-tag>
</div>
<div class="stat-row">
<span class="stat-label">{{ t('pages.inbounds.allTimeTraffic') }}</span>
<a-tag>{{ SizeFormatter.sizeFormat(statsRecord.allTime || 0) }}</a-tag>
</div>
<div v-if="clientCount[statsRecord.id]" class="stat-row">
<span class="stat-label">{{ t('clients') }}</span>
<a-tag color="green" class="client-count-tag">{{ clientCount[statsRecord.id].clients }}</a-tag>
<a-tag v-if="clientCount[statsRecord.id].online.length" color="blue">
{{ clientCount[statsRecord.id].online.length }} {{ t('online') }}
</a-tag>
<a-tag v-if="clientCount[statsRecord.id].depleted.length" color="red">
{{ clientCount[statsRecord.id].depleted.length }} {{ t('depleted') }}
</a-tag>
<a-tag v-if="clientCount[statsRecord.id].expiring.length" color="orange">
{{ clientCount[statsRecord.id].expiring.length }} {{ t('depletingSoon') }}
</a-tag>
</div>
<div class="stat-row">
<span class="stat-label">{{ t('pages.inbounds.expireDate') }}</span>
<a-tag v-if="statsRecord.expiryTime > 0"
:color="ColorUtils.usageColor(Date.now(), expireDiff, statsRecord._expiryTime)">
{{ IntlUtil.formatRelativeTime(statsRecord.expiryTime) }}
</a-tag>
<a-tag v-else color="purple">
<InfinityIcon />
</a-tag>
</div>
</div>
</a-modal>
<!-- ====================== Desktop: a-table ======================== --> <!-- ====================== Desktop: a-table ======================== -->
<a-table v-else :columns="columns" :data-source="sortedInbounds" :row-key="(r) => r.id" <a-table v-else :columns="columns" :data-source="sortedInbounds" :row-key="(r) => r.id"
:pagination="paginationFor(sortedInbounds)" :scroll="{ x: 1000 }" :style="{ marginTop: '10px' }" size="small" :pagination="paginationFor(sortedInbounds)" :scroll="{ x: 1000 }" :style="{ marginTop: '10px' }" size="small"
@ -542,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)"
@ -553,13 +571,21 @@ function showQrCodeMenu(dbInbound) {
<template #bodyCell="{ column, record }"> <template #bodyCell="{ column, record }">
<!-- ============== Action dropdown ============== --> <!-- ============== Action dropdown ============== -->
<template v-if="column.key === 'action'"> <template v-if="column.key === 'action'">
<div class="action-buttons">
<a-button type="text" size="small" @click.prevent="emit('row-action', {key: 'edit', dbInbound: record})">
<template #icon>
<EditOutlined />
</template>
</a-button>
<a-dropdown :trigger="['click']"> <a-dropdown :trigger="['click']">
<MoreOutlined class="row-action-trigger" @click.prevent /> <a-button type="text" size="small" @click.prevent>
<template #icon>
<MoreOutlined />
</template>
</a-button>
<template #overlay> <template #overlay>
<a-menu @click="(a) => emit('row-action', { key: a.key, dbInbound: record })"> <a-menu @click="(a) => emit('row-action', { key: a.key, dbInbound: record })">
<a-menu-item key="edit">
<EditOutlined /> {{ t('edit') }}
</a-menu-item>
<a-menu-item v-if="showQrCodeMenu(record)" key="qrcode"> <a-menu-item v-if="showQrCodeMenu(record)" key="qrcode">
<QrcodeOutlined /> {{ t('qrCode') }} <QrcodeOutlined /> {{ t('qrCode') }}
</a-menu-item> </a-menu-item>
@ -606,6 +632,7 @@ function showQrCodeMenu(dbInbound) {
</a-menu> </a-menu>
</template> </template>
</a-dropdown> </a-dropdown>
</div>
</template> </template>
<!-- ============== Enable switch (desktop) ============== --> <!-- ============== Enable switch (desktop) ============== -->
@ -746,6 +773,13 @@ function showQrCodeMenu(dbInbound) {
margin-bottom: 4px; margin-bottom: 4px;
} }
.action-buttons {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
}
.protocol-tags { .protocol-tags {
display: inline-flex; display: inline-flex;
flex-wrap: wrap; flex-wrap: wrap;

View file

@ -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"

View file

@ -98,7 +98,6 @@ watch(() => props.open, (next) => {
} }
const open = []; const open = [];
if (subLink.value) open.push('sub'); if (subLink.value) open.push('sub');
if (subJsonLink.value) open.push('sub-json');
activeKeys.value = open; activeKeys.value = open;
}); });

View file

@ -11,7 +11,7 @@ const props = defineProps({
value: { type: String, required: true }, value: { type: String, required: true },
remark: { type: String, default: '' }, remark: { type: String, default: '' },
downloadName: { type: String, default: '' }, downloadName: { type: String, default: '' },
size: { type: Number, default: 240 }, size: { type: Number, default: 360 },
showQr: { type: Boolean, default: true }, showQr: { type: Boolean, default: true },
}); });
@ -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>
@ -82,8 +82,15 @@ function download() {
.qr-panel-canvas .qr-code { .qr-panel-canvas .qr-code {
cursor: pointer; cursor: pointer;
padding: 0 !important;
background: #fff; background: #fff;
border-radius: 4px; border-radius: 4px;
line-height: 0;
}
.qr-panel-canvas .qr-code :deep(svg) {
display: block;
width: 100%;
height: auto;
max-width: 360px;
} }
</style> </style>

View file

@ -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,

View file

@ -9,6 +9,9 @@ import {
ExclamationCircleOutlined, ExclamationCircleOutlined,
EyeOutlined, EyeOutlined,
EyeInvisibleOutlined, EyeInvisibleOutlined,
InfoCircleOutlined,
MoreOutlined,
RightOutlined,
} from '@ant-design/icons-vue'; } from '@ant-design/icons-vue';
import NodeHistoryPanel from './NodeHistoryPanel.vue'; import NodeHistoryPanel from './NodeHistoryPanel.vue';
@ -72,6 +75,25 @@ function formatPct(p) {
if (typeof p !== 'number' || isNaN(p)) return '-'; if (typeof p !== 'number' || isNaN(p)) return '-';
return `${p.toFixed(1)}%`; return `${p.toFixed(1)}%`;
} }
const statsNode = ref(null);
function openStats(node) {
statsNode.value = node;
}
function closeStats() {
statsNode.value = null;
}
const expandedIds = ref(new Set());
function toggleExpanded(id) {
const next = new Set(expandedIds.value);
if (next.has(id)) next.delete(id);
else next.add(id);
expandedIds.value = next;
}
function isExpanded(id) {
return expandedIds.value.has(id);
}
</script> </script>
<template> <template>
@ -85,7 +107,103 @@ function formatPct(p) {
</a-button> </a-button>
</div> </div>
<a-table :data-source="dataSource" :pagination="false" :loading="loading" :scroll="{ x: 'max-content' }" <!-- ====================== Mobile: card list ======================= -->
<div v-if="isMobile" class="node-cards">
<div v-if="dataSource.length === 0" class="card-empty"></div>
<div v-for="record in dataSource" :key="record.id" class="node-card">
<div class="card-head" @click="toggleExpanded(record.id)">
<RightOutlined class="card-expand" :class="{ 'is-expanded': isExpanded(record.id) }" />
<a-badge
:status="statusColor(record.status) === 'green' ? 'success' : (statusColor(record.status) === 'red' ? 'error' : 'default')" />
<span class="node-name">{{ record.name }}</span>
<div class="card-actions" @click.stop>
<a-tooltip :title="t('info')">
<InfoCircleOutlined class="row-action-trigger" @click="openStats(record)" />
</a-tooltip>
<a-switch :checked="record.enable" size="small" @change="(v) => emit('toggle-enable', record, v)" />
<a-dropdown :trigger="['click']" placement="bottomRight">
<MoreOutlined class="row-action-trigger" @click.prevent />
<template #overlay>
<a-menu>
<a-menu-item key="probe" @click="emit('probe', record)">
<ThunderboltOutlined /> {{ t('pages.nodes.probe') }}
</a-menu-item>
<a-menu-item key="edit" @click="emit('edit', record)">
<EditOutlined /> {{ t('edit') }}
</a-menu-item>
<a-menu-item key="delete" class="danger-item" @click="emit('delete', record)">
<DeleteOutlined /> {{ t('delete') }}
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</div>
<div v-if="isExpanded(record.id)" class="card-history">
<NodeHistoryPanel :node="record" />
</div>
</div>
</div>
<a-modal v-if="isMobile" :open="!!statsNode" :footer="null" :width="360" centered
:title="statsNode ? statsNode.name : ''" @cancel="closeStats">
<div v-if="statsNode" class="card-stats">
<div v-if="statsNode.remark" class="stat-row">
<span class="stat-label">{{ t('pages.nodes.name') }}</span>
<span>{{ statsNode.remark }}</span>
</div>
<div class="stat-row">
<span class="stat-label">{{ t('pages.nodes.address') }}</span>
<a :href="statsNode.url" target="_blank" rel="noopener noreferrer"
:class="showAddress ? 'address-visible' : 'address-hidden'">{{ statsNode.url }}</a>
<a-tooltip :title="t('pages.index.toggleIpVisibility')">
<component :is="showAddress ? EyeOutlined : EyeInvisibleOutlined" class="ip-toggle-icon"
@click="showAddress = !showAddress" />
</a-tooltip>
</div>
<div class="stat-row">
<span class="stat-label">{{ t('pages.nodes.status') }}</span>
<a-badge
:status="statusColor(statsNode.status) === 'green' ? 'success' : (statusColor(statsNode.status) === 'red' ? 'error' : 'default')" />
<span>{{ t(`pages.nodes.statusValues.${statsNode.status || 'unknown'}`) }}</span>
<a-tooltip v-if="statsNode.lastError" :title="statsNode.lastError">
<ExclamationCircleOutlined style="color: #faad14" />
</a-tooltip>
</div>
<div class="stat-row">
<span class="stat-label">{{ t('pages.nodes.cpu') }}</span>
<a-tag>{{ formatPct(statsNode.cpuPct) }}</a-tag>
</div>
<div class="stat-row">
<span class="stat-label">{{ t('pages.nodes.mem') }}</span>
<a-tag>{{ formatPct(statsNode.memPct) }}</a-tag>
</div>
<div class="stat-row">
<span class="stat-label">{{ t('pages.nodes.xrayVersion') }}</span>
<a-tag>{{ statsNode.xrayVersion || '-' }}</a-tag>
</div>
<div class="stat-row">
<span class="stat-label">{{ t('pages.nodes.uptime') }}</span>
<a-tag>{{ formatUptime(statsNode.uptimeSecs) }}</a-tag>
</div>
<div class="stat-row">
<span class="stat-label">{{ t('pages.nodes.latency') }}</span>
<a-tag>
<template v-if="statsNode.latencyMs > 0">{{ statsNode.latencyMs }} ms</template>
<template v-else>-</template>
</a-tag>
</div>
<div class="stat-row">
<span class="stat-label">{{ t('pages.nodes.lastHeartbeat') }}</span>
<a-tag>{{ relativeTime(statsNode.lastHeartbeat) }}</a-tag>
</div>
</div>
</a-modal>
<!-- ====================== Desktop: a-table ======================== -->
<a-table v-else :data-source="dataSource" :pagination="false" :loading="loading" :scroll="{ x: 'max-content' }"
size="middle" row-key="id"> size="middle" row-key="id">
<template #expandedRowRender="{ record }"> <template #expandedRowRender="{ record }">
<NodeHistoryPanel :node="record" /> <NodeHistoryPanel :node="record" />
@ -240,4 +358,108 @@ function formatPct(p) {
.address-visible { .address-visible {
filter: none; filter: none;
} }
.node-cards {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 4px;
}
.node-card {
border: 1px solid rgba(128, 128, 128, 0.2);
border-radius: 10px;
padding: 12px;
background: rgba(255, 255, 255, 0.02);
display: flex;
flex-direction: column;
gap: 8px;
}
:global(body.dark) .node-card {
background: rgba(255, 255, 255, 0.03);
border-color: rgba(255, 255, 255, 0.1);
}
.card-head {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
}
.card-expand {
font-size: 12px;
opacity: 0.6;
transition: transform 150ms ease;
flex-shrink: 0;
}
.card-expand.is-expanded {
transform: rotate(90deg);
}
.node-name {
font-weight: 600;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.row-action-trigger {
font-size: 20px;
cursor: pointer;
}
.card-stats {
display: flex;
flex-direction: column;
gap: 6px;
}
.stat-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
}
.stat-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.04em;
opacity: 0.6;
min-width: 96px;
flex-shrink: 0;
}
.card-stats :deep(.ant-tag) {
margin: 0;
}
.card-history {
margin-top: 4px;
padding-top: 8px;
border-top: 1px solid rgba(128, 128, 128, 0.15);
}
.card-empty {
text-align: center;
opacity: 0.4;
padding: 20px 0;
}
.danger-item {
color: #ff4d4f;
}
</style> </style>

View file

@ -108,33 +108,33 @@ async function onToggleEnable(node, next) {
<a-spin :spinning="!fetched" :delay="200" tip="Loading…" size="large"> <a-spin :spinning="!fetched" :delay="200" tip="Loading…" size="large">
<div v-if="!fetched" class="loading-spacer" /> <div v-if="!fetched" class="loading-spacer" />
<a-row v-else :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]"> <a-row v-else :gutter="[isMobile ? 8 : 16, isMobile ? 8 : 12]">
<!-- Summary statistics card --> <!-- Summary statistics card -->
<a-col :span="24"> <a-col :span="24">
<a-card size="small" hoverable class="summary-card"> <a-card size="small" hoverable class="summary-card">
<a-row :gutter="[16, 12]"> <a-row :gutter="[16, isMobile ? 16 : 12]">
<a-col :sm="12" :md="6"> <a-col :xs="12" :sm="12" :md="6">
<CustomStatistic :title="t('pages.nodes.totalNodes')" :value="String(totals.total)"> <CustomStatistic :title="t('pages.nodes.totalNodes')" :value="String(totals.total)">
<template #prefix> <template #prefix>
<CloudServerOutlined /> <CloudServerOutlined />
</template> </template>
</CustomStatistic> </CustomStatistic>
</a-col> </a-col>
<a-col :sm="12" :md="6"> <a-col :xs="12" :sm="12" :md="6">
<CustomStatistic :title="t('pages.nodes.onlineNodes')" :value="String(totals.online)"> <CustomStatistic :title="t('pages.nodes.onlineNodes')" :value="String(totals.online)">
<template #prefix> <template #prefix>
<CheckCircleOutlined style="color: #52c41a" /> <CheckCircleOutlined style="color: #52c41a" />
</template> </template>
</CustomStatistic> </CustomStatistic>
</a-col> </a-col>
<a-col :sm="12" :md="6"> <a-col :xs="12" :sm="12" :md="6">
<CustomStatistic :title="t('pages.nodes.offlineNodes')" :value="String(totals.offline)"> <CustomStatistic :title="t('pages.nodes.offlineNodes')" :value="String(totals.offline)">
<template #prefix> <template #prefix>
<CloseCircleOutlined style="color: #ff4d4f" /> <CloseCircleOutlined style="color: #ff4d4f" />
</template> </template>
</CustomStatistic> </CustomStatistic>
</a-col> </a-col>
<a-col :sm="12" :md="6"> <a-col :xs="12" :sm="12" :md="6">
<CustomStatistic :title="t('pages.nodes.avgLatency')" <CustomStatistic :title="t('pages.nodes.avgLatency')"
:value="totals.avgLatency > 0 ? `${totals.avgLatency} ms` : '-'"> :value="totals.avgLatency > 0 ? `${totals.avgLatency} ms` : '-'">
<template #prefix> <template #prefix>

View file

@ -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>
<a-tooltip :title="isMobile ? t('pages.settings.panelSettings') : null">
<SettingOutlined /> <SettingOutlined />
<span>{{ t('pages.settings.panelSettings') }}</span> </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>
<a-tooltip :title="isMobile ? t('pages.settings.securitySettings') : null">
<SafetyOutlined /> <SafetyOutlined />
<span>{{ t('pages.settings.securitySettings') }}</span> </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>
<a-tooltip :title="isMobile ? t('pages.settings.TGBotSettings') : null">
<MessageOutlined /> <MessageOutlined />
<span>{{ t('pages.settings.TGBotSettings') }}</span> </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>
<a-tooltip :title="isMobile ? t('pages.settings.subSettings') : null">
<CloudServerOutlined /> <CloudServerOutlined />
<span>{{ t('pages.settings.subSettings') }}</span> </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>
<a-tooltip :title="isMobile ? `${t('pages.settings.subSettings')} (Formats)` : null">
<CodeOutlined /> <CodeOutlined />
<span>{{ t('pages.settings.subSettings') }} (Formats)</span> </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>

View file

@ -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 />

View file

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

View file

@ -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();
@ -21,6 +22,7 @@ const { t } = useI18n();
const props = defineProps({ const props = defineProps({
templateSettings: { type: Object, default: null }, templateSettings: { type: Object, default: null },
clientReverseTags: { type: Array, default: () => [] }, clientReverseTags: { type: Array, default: () => [] },
isMobile: { type: Boolean, default: false },
}); });
const STRATEGY_LABELS = { const STRATEGY_LABELS = {
@ -196,7 +198,7 @@ function confirmDelete(idx) {
} }
const columns = computed(() => [ const columns = computed(() => [
{ title: '#', key: 'action', align: 'center', width: 80 }, { title: '#', key: 'action', align: 'center', width: 100 },
{ title: 'Tag', dataIndex: 'tag', key: 'tag', align: 'center', width: 160 }, { title: 'Tag', dataIndex: 'tag', key: 'tag', align: 'center', width: 160 },
{ title: 'Strategy', key: 'strategy', align: 'center', width: 140 }, { title: 'Strategy', key: 'strategy', align: 'center', width: 140 },
{ title: 'Selector', key: 'selector', align: 'center' }, { title: 'Selector', key: 'selector', align: 'center' },
@ -266,17 +268,29 @@ const obsText = computed({
{{ t('pages.xray.Balancers') }} {{ t('pages.xray.Balancers') }}
</a-button> </a-button>
<a-table :columns="columns" :data-source="rows" :row-key="(r) => r.key" :pagination="false" size="small" bordered> <a-table :columns="columns" :data-source="rows" :row-key="(r) => r.key" :pagination="false"
size="small" :scroll="{ x: 400 }">
<template #bodyCell="{ column, record, index }"> <template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'action'"> <template v-if="column.key === 'action'">
<div class="action-cell">
<span class="row-index">{{ index + 1 }}</span> <span class="row-index">{{ index + 1 }}</span>
<div :class="!isMobile ? 'action-buttons' : ''">
<a-button v-if="!isMobile" shape="circle" size="small" @click="openEdit(index)">
<template #icon>
<EditOutlined />
</template>
</a-button>
<a-dropdown :trigger="['click']"> <a-dropdown :trigger="['click']">
<a-button shape="circle" size="small" class="action-btn"> <a-button shape="circle" size="small">
<template #icon>
<MoreOutlined /> <MoreOutlined />
</template>
</a-button> </a-button>
<template #overlay> <template #overlay>
<a-menu> <a-menu>
<a-menu-item @click="openEdit(index)"> <a-menu-item v-if="isMobile" @click="openEdit(index)">
<EditOutlined /> {{ t('edit') }} <EditOutlined /> {{ t('edit') }}
</a-menu-item> </a-menu-item>
<a-menu-item class="danger" @click="confirmDelete(index)"> <a-menu-item class="danger" @click="confirmDelete(index)">
@ -285,6 +299,8 @@ const obsText = computed({
</a-menu> </a-menu>
</template> </template>
</a-dropdown> </a-dropdown>
</div>
</div>
</template> </template>
<template v-else-if="column.key === 'strategy'"> <template v-else-if="column.key === 'strategy'">
@ -305,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>
@ -316,23 +331,29 @@ const obsText = computed({
</template> </template>
<style scoped> <style scoped>
.action-cell {
display: flex;
align-items: center;
gap: 6px;
}
.row-index { .row-index {
font-weight: 500; font-weight: 500;
opacity: 0.7; opacity: 0.7;
margin-right: 6px; min-width: 18px;
text-align: right;
} }
.action-btn { .action-buttons {
vertical-align: middle; display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
margin-left: auto;
} }
.danger { .danger {
color: #ff4d4f; color: #ff4d4f;
} }
.json-editor {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
margin-top: 8px;
}
</style> </style>

View file

@ -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. */

View file

@ -157,9 +157,9 @@ function hasBreakdown(r) {
// === Columns ======================================================== // === Columns ========================================================
// Computed so titles re-render after a locale swap. // Computed so titles re-render after a locale swap.
const columns = computed(() => [ const columns = computed(() => [
{ title: '#', key: 'action', align: 'center', width: 70 }, { title: '#', key: 'action', align: 'center', width: 100 },
{ title: 'Tag', key: 'identity', align: 'left', width: 220 }, { title: 'Tag', key: 'identity', align: 'left' },
{ title: t('pages.inbounds.address'), key: 'address', align: 'left', width: 230 }, { title: t('pages.inbounds.address'), key: 'address', align: 'left' },
{ title: t('pages.inbounds.traffic'), key: 'traffic', align: 'left', width: 200 }, { title: t('pages.inbounds.traffic'), key: 'traffic', align: 'left', width: 200 },
{ title: t('pages.xray.latency') !== 'pages.xray.latency' ? t('pages.xray.latency') : 'Latency', key: 'testResult', align: 'left', width: 140 }, { title: t('pages.xray.latency') !== 'pages.xray.latency' ? t('pages.xray.latency') : 'Latency', key: 'testResult', align: 'left', width: 140 },
{ title: t('check'), key: 'test', align: 'center', width: 80 }, { title: t('check'), key: 'test', align: 'center', width: 80 },
@ -322,18 +322,25 @@ const rows = computed(() => {
<template v-if="column.key === 'action'"> <template v-if="column.key === 'action'">
<div class="action-cell"> <div class="action-cell">
<span class="row-index">{{ index + 1 }}</span> <span class="row-index">{{ index + 1 }}</span>
<div class="action-buttons">
<a-button shape="circle" size="small" @click="openEdit(index)">
<template #icon>
<EditOutlined />
</template>
</a-button>
<a-dropdown :trigger="['click']"> <a-dropdown :trigger="['click']">
<a-button shape="circle" size="small"> <a-button shape="circle" size="small">
<template #icon>
<MoreOutlined /> <MoreOutlined />
</template>
</a-button> </a-button>
<template #overlay> <template #overlay>
<a-menu> <a-menu>
<a-menu-item v-if="index > 0" @click="setFirst(index)"> <a-menu-item v-if="index > 0" @click="setFirst(index)">
<VerticalAlignTopOutlined /> Move to top <VerticalAlignTopOutlined /> Move to top
</a-menu-item> </a-menu-item>
<a-menu-item @click="openEdit(index)">
<EditOutlined /> Edit
</a-menu-item>
<a-menu-item :disabled="index === 0" @click="moveUp(index)"> <a-menu-item :disabled="index === 0" @click="moveUp(index)">
<ArrowUpOutlined /> <ArrowUpOutlined />
</a-menu-item> </a-menu-item>
@ -350,6 +357,7 @@ const rows = computed(() => {
</template> </template>
</a-dropdown> </a-dropdown>
</div> </div>
</div>
</template> </template>
<template v-else-if="column.key === 'identity'"> <template v-else-if="column.key === 'identity'">
@ -444,6 +452,11 @@ const rows = computed(() => {
justify-content: flex-end; justify-content: flex-end;
} }
.toolbar-right :global(.ant-space),
.header-actions :global(.ant-space) {
margin-bottom: 0 !important;
}
.card-empty { .card-empty {
text-align: center; text-align: center;
opacity: 0.4; opacity: 0.4;
@ -526,6 +539,14 @@ const rows = computed(() => {
text-align: right; text-align: right;
} }
.action-buttons {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
margin-left: auto;
}
.identity-cell { .identity-cell {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View file

@ -188,9 +188,9 @@ function onDragPointerMove(ev) {
dragMoved = true; dragMoved = true;
const el = document.elementFromPoint(ev.clientX, ev.clientY); const el = document.elementFromPoint(ev.clientX, ev.clientY);
if (!el) return; if (!el) return;
const tr = el.closest('tr[data-row-key]'); const target = el.closest('[data-row-key]');
if (!tr) return; if (!target) return;
const idx = Number(tr.getAttribute('data-row-key')); const idx = Number(target.getAttribute('data-row-key'));
if (Number.isFinite(idx)) dropTargetIndex.value = idx; if (Number.isFinite(idx)) dropTargetIndex.value = idx;
} }
@ -220,7 +220,7 @@ function rowProps(_record, index) {
// === Columns ========================================================= // === Columns =========================================================
// Computed so titles re-render after a locale swap. // Computed so titles re-render after a locale swap.
const desktopColumns = computed(() => [ const desktopColumns = computed(() => [
{ title: '#', align: 'center', width: 70, key: 'action' }, { title: '#', align: 'center', width: 100, key: 'action' },
{ title: 'Source', align: 'left', width: 180, key: 'source' }, { title: 'Source', align: 'left', width: 180, key: 'source' },
{ title: t('pages.inbounds.network'), align: 'left', width: 180, key: 'network' }, { title: t('pages.inbounds.network'), align: 'left', width: 180, key: 'network' },
{ title: 'Destination', align: 'left', key: 'destination' }, { title: 'Destination', align: 'left', key: 'destination' },
@ -340,13 +340,23 @@ function chipPreview(value) {
<HolderOutlined class="drag-handle" :title="t('drag') || 'Drag to reorder'" <HolderOutlined class="drag-handle" :title="t('drag') || 'Drag to reorder'"
@pointerdown="onHandlePointerDown(index, $event)" /> @pointerdown="onHandlePointerDown(index, $event)" />
<span class="row-index">{{ index + 1 }}</span> <span class="row-index">{{ index + 1 }}</span>
<div :class="!isMobile ? 'action-buttons' : ''">
<a-button v-if="!isMobile" shape="circle" size="small" @click="openEdit(index)">
<template #icon>
<EditOutlined />
</template>
</a-button>
<a-dropdown :trigger="['click']"> <a-dropdown :trigger="['click']">
<a-button shape="circle" size="small"> <a-button shape="circle" size="small">
<template #icon>
<MoreOutlined /> <MoreOutlined />
</template>
</a-button> </a-button>
<template #overlay> <template #overlay>
<a-menu> <a-menu>
<a-menu-item @click="openEdit(index)"> <a-menu-item v-if="isMobile" @click="openEdit(index)">
<EditOutlined /> {{ t('edit') }} <EditOutlined /> {{ t('edit') }}
</a-menu-item> </a-menu-item>
<a-menu-item :disabled="index === 0" @click="moveUp(index)"> <a-menu-item :disabled="index === 0" @click="moveUp(index)">
@ -362,6 +372,7 @@ function chipPreview(value) {
</template> </template>
</a-dropdown> </a-dropdown>
</div> </div>
</div>
</template> </template>
<!-- ============== Source ============== --> <!-- ============== Source ============== -->
@ -550,6 +561,14 @@ function chipPreview(value) {
text-align: right; text-align: right;
} }
.action-buttons {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
margin-left: auto;
}
.criterion-flow { .criterion-flow {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View file

@ -27,6 +27,7 @@ const loading = ref(false);
const warpData = ref(null); const warpData = ref(null);
const warpConfig = ref(null); const warpConfig = ref(null);
const warpPlus = ref(''); const warpPlus = ref('');
const licenseError = ref('');
// Held in memory so the parent's add/reset handlers receive the same // Held in memory so the parent's add/reset handlers receive the same
// object the modal computed from getConfig(). // object the modal computed from getConfig().
const stagedOutbound = ref(null); const stagedOutbound = ref(null);
@ -41,6 +42,7 @@ watch(() => props.open, (next) => {
if (!next) return; if (!next) return;
warpConfig.value = null; warpConfig.value = null;
stagedOutbound.value = null; stagedOutbound.value = null;
licenseError.value = '';
fetchData(); fetchData();
}); });
@ -89,12 +91,15 @@ async function getConfig() {
async function updateLicense() { async function updateLicense() {
if (warpPlus.value.length < 26) return; if (warpPlus.value.length < 26) return;
loading.value = true; loading.value = true;
licenseError.value = '';
try { try {
const msg = await HttpUtil.post('/panel/xray/warp/license', { license: warpPlus.value }); const msg = await HttpUtil.post('/panel/xray/warp/license', { license: warpPlus.value });
if (msg?.success) { if (msg?.success) {
warpData.value = JSON.parse(msg.obj); warpData.value = JSON.parse(msg.obj);
warpConfig.value = null; warpConfig.value = null;
warpPlus.value = ''; warpPlus.value = '';
} else {
licenseError.value = msg?.msg || 'Failed to set WARP license.';
} }
} finally { } finally {
loading.value = false; loading.value = false;
@ -233,9 +238,12 @@ const hasConfig = computed(() => !ObjectUtil.isEmpty(warpConfig.value));
<a-collapse-panel header="WARP / WARP+ license key"> <a-collapse-panel header="WARP / WARP+ license key">
<a-form :colon="false" :label-col="{ md: { span: 6 } }" :wrapper-col="{ md: { span: 14 } }"> <a-form :colon="false" :label-col="{ md: { span: 6 } }" :wrapper-col="{ md: { span: 14 } }">
<a-form-item label="Key"> <a-form-item label="Key">
<a-input v-model:value="warpPlus" placeholder="26-char WARP+ key" /> <a-input v-model:value="warpPlus" placeholder="26-char WARP+ key" @update:value="licenseError = ''" />
<a-button type="primary" class="mt-8" :disabled="warpPlus.length < 26" :loading="loading" <div class="license-actions mt-8">
<a-button type="primary" :disabled="warpPlus.length < 26" :loading="loading"
@click="updateLicense">Update</a-button> @click="updateLicense">Update</a-button>
<a-alert v-if="licenseError" :message="licenseError" type="error" show-icon class="license-error" />
</div>
</a-form-item> </a-form-item>
</a-form> </a-form>
</a-collapse-panel> </a-collapse-panel>
@ -358,4 +366,16 @@ const hasConfig = computed(() => !ObjectUtil.isEmpty(warpConfig.value));
.ml-8 { .ml-8 {
margin-left: 8px; margin-left: 8px;
} }
.license-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.license-error {
flex: 1;
min-width: 0;
}
</style> </style>

View file

@ -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,21 +344,31 @@ 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" :client-reverse-tags="clientReverseTags" /> <BalancersTab :template-settings="templateSettings"
:client-reverse-tags="clientReverseTags" :is-mobile="isMobile" />
</a-tab-pane> </a-tab-pane>
<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"
@ -358,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>
@ -446,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>

View file

@ -265,6 +265,14 @@ func (s *SubJsonService) streamData(stream string) map[string]any {
streamSettings["wsSettings"] = s.removeAcceptProxy(streamSettings["wsSettings"]) streamSettings["wsSettings"] = s.removeAcceptProxy(streamSettings["wsSettings"])
case "httpupgrade": case "httpupgrade":
streamSettings["httpupgradeSettings"] = s.removeAcceptProxy(streamSettings["httpupgradeSettings"]) streamSettings["httpupgradeSettings"] = s.removeAcceptProxy(streamSettings["httpupgradeSettings"])
case "xhttp":
streamSettings["xhttpSettings"] = s.removeAcceptProxy(streamSettings["xhttpSettings"])
if xhttp, ok := streamSettings["xhttpSettings"].(map[string]any); ok {
delete(xhttp, "noSSEHeader")
delete(xhttp, "scMaxBufferedPosts")
delete(xhttp, "scStreamUpServerSecs")
delete(xhttp, "serverMaxHeaderBytes")
}
} }
return streamSettings return streamSettings
} }

View file

@ -1025,6 +1025,10 @@ func buildXhttpExtra(xhttp map[string]any) map[string]any {
} }
} }
if mode, ok := xhttp["mode"].(string); ok && len(mode) > 0 {
extra["mode"] = mode
}
stringFields := []string{ stringFields := []string{
"sessionPlacement", "sessionKey", "sessionPlacement", "sessionKey",
"seqPlacement", "seqKey", "seqPlacement", "seqKey",

View file

@ -181,7 +181,7 @@ func (j *CheckClientIpJob) processLogFile() bool {
var timestamp int64 var timestamp int64
timestampMatches := timestampRegex.FindStringSubmatch(line) timestampMatches := timestampRegex.FindStringSubmatch(line)
if len(timestampMatches) >= 2 { if len(timestampMatches) >= 2 {
t, err := time.Parse("2006/01/02 15:04:05", timestampMatches[1]) t, err := time.ParseInLocation("2006/01/02 15:04:05", timestampMatches[1], time.Local)
if err == nil { if err == nil {
timestamp = t.Unix() timestamp = t.Unix()
} else { } else {

View file

@ -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,13 +104,11 @@ 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)
} else if len(summary) > 0 { } else if len(summary) > 0 {
@ -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)
}
} }

View file

@ -95,19 +95,17 @@ 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 ~1050KB 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
// (510MB) 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)
} else if len(inboundSummary) > 0 { } else if len(inboundSummary) > 0 {
@ -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 {

View file

@ -12,7 +12,7 @@
"port": 62789, "port": 62789,
"protocol": "tunnel", "protocol": "tunnel",
"settings": { "settings": {
"address": "127.0.0.1" "rewriteAddress": "127.0.0.1"
}, },
"tag": "api" "tag": "api"
}], }],

View file

@ -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"`

View file

@ -152,13 +152,8 @@ func (s *WarpService) SetWarpLicense(license string) (string, error) {
if err := json.Unmarshal(body, &response); err != nil { if err := json.Unmarshal(body, &response); err != nil {
return "", err return "", err
} }
if success, _ := response["success"].(bool); !success { if _, ok := response["id"].(string); !ok {
if errorArr, ok := response["errors"].([]any); ok && len(errorArr) > 0 { return "", common.NewErrorf("warp set license failed: unexpected response: %s", string(body))
if errorObj, ok := errorArr[0].(map[string]any); ok {
return "", common.NewError(errorObj["code"], errorObj["message"])
}
}
return "", common.NewError("warp set license failed: unknown error")
} }
warpData["license_key"] = license warpData["license_key"] = license
@ -202,8 +197,26 @@ func doWarpRequest(req *http.Request) ([]byte, error) {
return nil, err return nil, err
} }
if resp.StatusCode < 200 || resp.StatusCode >= 300 { if resp.StatusCode < 200 || resp.StatusCode >= 300 {
if msg := parseWarpError(body); msg != "" {
return nil, common.NewError(msg)
}
return nil, common.NewErrorf("warp api %s %s returned status %d: %s", return nil, common.NewErrorf("warp api %s %s returned status %d: %s",
req.Method, req.URL.Path, resp.StatusCode, string(body)) req.Method, req.URL.Path, resp.StatusCode, string(body))
} }
return body, nil return body, nil
} }
func parseWarpError(body []byte) string {
var env struct {
Errors []struct {
Message string `json:"message"`
} `json:"errors"`
}
if err := json.Unmarshal(body, &env); err != nil {
return ""
}
if len(env.Errors) == 0 || env.Errors[0].Message == "" {
return ""
}
return env.Errors[0].Message
}