mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-05 20:54:14 +00:00
Merge branch 'main' into feat/api-token-install
This commit is contained in:
commit
f656dd37af
32 changed files with 1670 additions and 449 deletions
|
|
@ -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
|
||||||
553
frontend/package-lock.json
generated
553
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -16,8 +16,11 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons-vue": "^7.0.1",
|
"@ant-design/icons-vue": "^7.0.1",
|
||||||
|
"@codemirror/lang-json": "^6.0.2",
|
||||||
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
"ant-design-vue": "^4.2.6",
|
"ant-design-vue": "^4.2.6",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
|
"codemirror": "^6.0.2",
|
||||||
"dayjs": "^1.11.20",
|
"dayjs": "^1.11.20",
|
||||||
"otpauth": "^9.5.1",
|
"otpauth": "^9.5.1",
|
||||||
"qs": "^6.13.1",
|
"qs": "^6.13.1",
|
||||||
|
|
|
||||||
185
frontend/src/components/JsonEditor.vue
Normal file
185
frontend/src/components/JsonEditor.vue
Normal file
|
|
@ -0,0 +1,185 @@
|
||||||
|
<script setup>
|
||||||
|
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
|
import { EditorView, basicSetup } from 'codemirror';
|
||||||
|
import { EditorState, Compartment } from '@codemirror/state';
|
||||||
|
import { json, jsonParseLinter } from '@codemirror/lang-json';
|
||||||
|
import { lintGutter, linter } from '@codemirror/lint';
|
||||||
|
import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
|
||||||
|
import { syntaxHighlighting } from '@codemirror/language';
|
||||||
|
import { keymap } from '@codemirror/view';
|
||||||
|
import { indentWithTab } from '@codemirror/commands';
|
||||||
|
|
||||||
|
import { theme as themeState } from '@/composables/useTheme.js';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
value: { type: String, default: '' },
|
||||||
|
minHeight: { type: String, default: '320px' },
|
||||||
|
maxHeight: { type: String, default: '600px' },
|
||||||
|
readonly: { type: Boolean, default: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:value', 'change']);
|
||||||
|
|
||||||
|
const host = ref(null);
|
||||||
|
let view = null;
|
||||||
|
const themeCompartment = new Compartment();
|
||||||
|
const readonlyCompartment = new Compartment();
|
||||||
|
|
||||||
|
function buildDarkTheme({ bg, panelBg, activeBg, border, selection }) {
|
||||||
|
return EditorView.theme(
|
||||||
|
{
|
||||||
|
'&': { color: '#dcdcdc', backgroundColor: bg },
|
||||||
|
'.cm-content': { caretColor: '#dcdcdc' },
|
||||||
|
'.cm-cursor, .cm-dropCursor': { borderLeftColor: '#dcdcdc' },
|
||||||
|
'.cm-gutters': {
|
||||||
|
backgroundColor: bg,
|
||||||
|
borderRight: `1px solid ${border}`,
|
||||||
|
color: '#6a6a6a',
|
||||||
|
},
|
||||||
|
'.cm-activeLine': { backgroundColor: activeBg },
|
||||||
|
'.cm-activeLineGutter': { backgroundColor: activeBg, color: '#dcdcdc' },
|
||||||
|
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection':
|
||||||
|
{ backgroundColor: selection },
|
||||||
|
'.cm-panels': { backgroundColor: panelBg, color: '#dcdcdc' },
|
||||||
|
'.cm-panels.cm-panels-top': { borderBottom: `1px solid ${border}` },
|
||||||
|
'.cm-panels.cm-panels-bottom': { borderTop: `1px solid ${border}` },
|
||||||
|
'.cm-tooltip': {
|
||||||
|
backgroundColor: panelBg,
|
||||||
|
border: `1px solid ${border}`,
|
||||||
|
color: '#dcdcdc',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ dark: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const darkTheme = buildDarkTheme({
|
||||||
|
bg: '#1e1e1e',
|
||||||
|
panelBg: '#2d2d30',
|
||||||
|
activeBg: '#252526',
|
||||||
|
border: '#3a3a3c',
|
||||||
|
selection: '#3a3a3c',
|
||||||
|
});
|
||||||
|
|
||||||
|
const ultraDarkTheme = buildDarkTheme({
|
||||||
|
bg: '#0a0a0a',
|
||||||
|
panelBg: '#141414',
|
||||||
|
activeBg: '#141414',
|
||||||
|
border: '#1f1f1f',
|
||||||
|
selection: '#2a2a2a',
|
||||||
|
});
|
||||||
|
|
||||||
|
function themeExtension() {
|
||||||
|
if (!themeState.isDark) return [];
|
||||||
|
const chrome = themeState.isUltra ? ultraDarkTheme : darkTheme;
|
||||||
|
return [chrome, syntaxHighlighting(oneDarkHighlightStyle)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function readonlyExtension() {
|
||||||
|
return EditorState.readOnly.of(props.readonly);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const updateListener = EditorView.updateListener.of((u) => {
|
||||||
|
if (!u.docChanged) return;
|
||||||
|
const next = u.state.doc.toString();
|
||||||
|
if (next === props.value) return;
|
||||||
|
emit('update:value', next);
|
||||||
|
emit('change', next);
|
||||||
|
});
|
||||||
|
|
||||||
|
view = new EditorView({
|
||||||
|
parent: host.value,
|
||||||
|
state: EditorState.create({
|
||||||
|
doc: props.value || '',
|
||||||
|
extensions: [
|
||||||
|
basicSetup,
|
||||||
|
keymap.of([indentWithTab]),
|
||||||
|
json(),
|
||||||
|
linter(jsonParseLinter()),
|
||||||
|
lintGutter(),
|
||||||
|
EditorView.lineWrapping,
|
||||||
|
updateListener,
|
||||||
|
themeCompartment.of(themeExtension()),
|
||||||
|
readonlyCompartment.of(readonlyExtension()),
|
||||||
|
EditorView.theme({
|
||||||
|
'&': { height: '100%' },
|
||||||
|
'.cm-scroller': {
|
||||||
|
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
||||||
|
fontSize: '12px',
|
||||||
|
minHeight: props.minHeight,
|
||||||
|
maxHeight: props.maxHeight,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(() => props.value, (next) => {
|
||||||
|
if (!view) return;
|
||||||
|
const current = view.state.doc.toString();
|
||||||
|
if (next === current) return;
|
||||||
|
view.dispatch({
|
||||||
|
changes: { from: 0, to: current.length, insert: next || '' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[() => themeState.isDark, () => themeState.isUltra],
|
||||||
|
() => {
|
||||||
|
if (!view) return;
|
||||||
|
view.dispatch({ effects: themeCompartment.reconfigure(themeExtension()) });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.readonly,
|
||||||
|
() => {
|
||||||
|
if (!view) return;
|
||||||
|
view.dispatch({ effects: readonlyCompartment.reconfigure(readonlyExtension()) });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
view?.destroy();
|
||||||
|
view = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
focus: () => view?.focus(),
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="host" class="json-editor-host" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.json-editor-host {
|
||||||
|
border: 1px solid var(--ant-color-border, #d9d9d9);
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--ant-color-bg-container, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-editor-host :deep(.cm-editor),
|
||||||
|
.json-editor-host :deep(.cm-editor.cm-focused) {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-editor-host:focus-within {
|
||||||
|
border-color: var(--ant-color-primary, #1677ff);
|
||||||
|
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(body.dark) .json-editor-host {
|
||||||
|
border-color: #3a3a3c;
|
||||||
|
background: #1e1e1e;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html[data-theme="ultra-dark"]) .json-editor-host {
|
||||||
|
border-color: #1f1f1f;
|
||||||
|
background: #0a0a0a;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
<div v-if="client.comment && client.comment.trim()" class="client-comment-line">
|
<a-modal :open="!!statsClient" :footer="null" :width="360" centered
|
||||||
{{ client.comment.length > 80 ? client.comment.substring(0, 77) + '…' : client.comment }}
|
:title="statsClient ? statsClient.email || t('info') : ''" @cancel="closeStats">
|
||||||
</div>
|
<div v-if="statsClient" class="client-card-foot">
|
||||||
|
<div v-if="statsClient.comment && statsClient.comment.trim()" class="client-comment-line">
|
||||||
<div class="client-card-foot">
|
{{ 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"
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,59 +571,68 @@ 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'">
|
||||||
<a-dropdown :trigger="['click']">
|
<div class="action-buttons">
|
||||||
<MoreOutlined class="row-action-trigger" @click.prevent />
|
<a-button type="text" size="small" @click.prevent="emit('row-action', {key: 'edit', dbInbound: record})">
|
||||||
<template #overlay>
|
<template #icon>
|
||||||
<a-menu @click="(a) => emit('row-action', { key: a.key, dbInbound: record })">
|
<EditOutlined />
|
||||||
<a-menu-item key="edit">
|
</template>
|
||||||
<EditOutlined /> {{ t('edit') }}
|
</a-button>
|
||||||
</a-menu-item>
|
|
||||||
<a-menu-item v-if="showQrCodeMenu(record)" key="qrcode">
|
<a-dropdown :trigger="['click']">
|
||||||
<QrcodeOutlined /> {{ t('qrCode') }}
|
<a-button type="text" size="small" @click.prevent>
|
||||||
</a-menu-item>
|
<template #icon>
|
||||||
<template v-if="record.isMultiUser()">
|
<MoreOutlined />
|
||||||
<a-menu-item key="addClient">
|
|
||||||
<UserAddOutlined /> {{ t('pages.client.add') }}
|
|
||||||
</a-menu-item>
|
|
||||||
<a-menu-item key="addBulkClient">
|
|
||||||
<UsergroupAddOutlined /> {{ t('pages.client.bulk') }}
|
|
||||||
</a-menu-item>
|
|
||||||
<a-menu-item key="copyClients">
|
|
||||||
<CopyOutlined /> {{ t('pages.client.copyFromInbound') }}
|
|
||||||
</a-menu-item>
|
|
||||||
<a-menu-item key="resetClients">
|
|
||||||
<FileDoneOutlined /> {{ t('pages.inbounds.resetInboundClientTraffics') }}
|
|
||||||
</a-menu-item>
|
|
||||||
<a-menu-item key="export">
|
|
||||||
<ExportOutlined /> {{ t('pages.inbounds.export') }}
|
|
||||||
</a-menu-item>
|
|
||||||
<a-menu-item v-if="subEnable" key="subs">
|
|
||||||
<ExportOutlined /> {{ t('pages.inbounds.export') }} — {{ t('pages.settings.subSettings') }}
|
|
||||||
</a-menu-item>
|
|
||||||
<a-menu-item key="delDepletedClients" class="danger-item">
|
|
||||||
<RestOutlined /> {{ t('pages.inbounds.delDepletedClients') }}
|
|
||||||
</a-menu-item>
|
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
</a-button>
|
||||||
<a-menu-item key="showInfo">
|
<template #overlay>
|
||||||
<InfoCircleOutlined /> {{ t('info') }}
|
<a-menu @click="(a) => emit('row-action', { key: a.key, dbInbound: record })">
|
||||||
|
<a-menu-item v-if="showQrCodeMenu(record)" key="qrcode">
|
||||||
|
<QrcodeOutlined /> {{ t('qrCode') }}
|
||||||
</a-menu-item>
|
</a-menu-item>
|
||||||
</template>
|
<template v-if="record.isMultiUser()">
|
||||||
<a-menu-item key="clipboard">
|
<a-menu-item key="addClient">
|
||||||
<CopyOutlined /> {{ t('pages.inbounds.exportInbound') }}
|
<UserAddOutlined /> {{ t('pages.client.add') }}
|
||||||
</a-menu-item>
|
</a-menu-item>
|
||||||
<a-menu-item key="resetTraffic">
|
<a-menu-item key="addBulkClient">
|
||||||
<RetweetOutlined /> {{ t('pages.inbounds.resetTraffic') }}
|
<UsergroupAddOutlined /> {{ t('pages.client.bulk') }}
|
||||||
</a-menu-item>
|
</a-menu-item>
|
||||||
<a-menu-item key="clone">
|
<a-menu-item key="copyClients">
|
||||||
<BlockOutlined /> {{ t('pages.inbounds.clone') }}
|
<CopyOutlined /> {{ t('pages.client.copyFromInbound') }}
|
||||||
</a-menu-item>
|
</a-menu-item>
|
||||||
<a-menu-item key="delete" class="danger-item">
|
<a-menu-item key="resetClients">
|
||||||
<DeleteOutlined /> {{ t('delete') }}
|
<FileDoneOutlined /> {{ t('pages.inbounds.resetInboundClientTraffics') }}
|
||||||
</a-menu-item>
|
</a-menu-item>
|
||||||
</a-menu>
|
<a-menu-item key="export">
|
||||||
</template>
|
<ExportOutlined /> {{ t('pages.inbounds.export') }}
|
||||||
</a-dropdown>
|
</a-menu-item>
|
||||||
|
<a-menu-item v-if="subEnable" key="subs">
|
||||||
|
<ExportOutlined /> {{ t('pages.inbounds.export') }} — {{ t('pages.settings.subSettings') }}
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item key="delDepletedClients" class="danger-item">
|
||||||
|
<RestOutlined /> {{ t('pages.inbounds.delDepletedClients') }}
|
||||||
|
</a-menu-item>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<a-menu-item key="showInfo">
|
||||||
|
<InfoCircleOutlined /> {{ t('info') }}
|
||||||
|
</a-menu-item>
|
||||||
|
</template>
|
||||||
|
<a-menu-item key="clipboard">
|
||||||
|
<CopyOutlined /> {{ t('pages.inbounds.exportInbound') }}
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item key="resetTraffic">
|
||||||
|
<RetweetOutlined /> {{ t('pages.inbounds.resetTraffic') }}
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item key="clone">
|
||||||
|
<BlockOutlined /> {{ t('pages.inbounds.clone') }}
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item key="delete" class="danger-item">
|
||||||
|
<DeleteOutlined /> {{ t('delete') }}
|
||||||
|
</a-menu-item>
|
||||||
|
</a-menu>
|
||||||
|
</template>
|
||||||
|
</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;
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -228,39 +228,49 @@ onBeforeUnmount(() => {
|
||||||
</a-col>
|
</a-col>
|
||||||
|
|
||||||
<a-col :span="24">
|
<a-col :span="24">
|
||||||
<a-tabs :active-key="activeTabKey" @change="onTabChange">
|
<a-tabs :active-key="activeTabKey" :class="{ 'icons-only': isMobile }" @change="onTabChange">
|
||||||
<a-tab-pane key="1" class="tab-pane">
|
<a-tab-pane key="1" class="tab-pane">
|
||||||
<template #tab>
|
<template #tab>
|
||||||
<SettingOutlined />
|
<a-tooltip :title="isMobile ? t('pages.settings.panelSettings') : null">
|
||||||
<span>{{ t('pages.settings.panelSettings') }}</span>
|
<SettingOutlined />
|
||||||
|
</a-tooltip>
|
||||||
|
<span v-if="!isMobile">{{ t('pages.settings.panelSettings') }}</span>
|
||||||
</template>
|
</template>
|
||||||
<GeneralTab :all-setting="allSetting" />
|
<GeneralTab :all-setting="allSetting" />
|
||||||
</a-tab-pane>
|
</a-tab-pane>
|
||||||
<a-tab-pane key="2" class="tab-pane">
|
<a-tab-pane key="2" class="tab-pane">
|
||||||
<template #tab>
|
<template #tab>
|
||||||
<SafetyOutlined />
|
<a-tooltip :title="isMobile ? t('pages.settings.securitySettings') : null">
|
||||||
<span>{{ t('pages.settings.securitySettings') }}</span>
|
<SafetyOutlined />
|
||||||
|
</a-tooltip>
|
||||||
|
<span v-if="!isMobile">{{ t('pages.settings.securitySettings') }}</span>
|
||||||
</template>
|
</template>
|
||||||
<SecurityTab :all-setting="allSetting" />
|
<SecurityTab :all-setting="allSetting" />
|
||||||
</a-tab-pane>
|
</a-tab-pane>
|
||||||
<a-tab-pane key="3" class="tab-pane">
|
<a-tab-pane key="3" class="tab-pane">
|
||||||
<template #tab>
|
<template #tab>
|
||||||
<MessageOutlined />
|
<a-tooltip :title="isMobile ? t('pages.settings.TGBotSettings') : null">
|
||||||
<span>{{ t('pages.settings.TGBotSettings') }}</span>
|
<MessageOutlined />
|
||||||
|
</a-tooltip>
|
||||||
|
<span v-if="!isMobile">{{ t('pages.settings.TGBotSettings') }}</span>
|
||||||
</template>
|
</template>
|
||||||
<TelegramTab :all-setting="allSetting" />
|
<TelegramTab :all-setting="allSetting" />
|
||||||
</a-tab-pane>
|
</a-tab-pane>
|
||||||
<a-tab-pane key="4" class="tab-pane">
|
<a-tab-pane key="4" class="tab-pane">
|
||||||
<template #tab>
|
<template #tab>
|
||||||
<CloudServerOutlined />
|
<a-tooltip :title="isMobile ? t('pages.settings.subSettings') : null">
|
||||||
<span>{{ t('pages.settings.subSettings') }}</span>
|
<CloudServerOutlined />
|
||||||
|
</a-tooltip>
|
||||||
|
<span v-if="!isMobile">{{ t('pages.settings.subSettings') }}</span>
|
||||||
</template>
|
</template>
|
||||||
<SubscriptionGeneralTab :all-setting="allSetting" />
|
<SubscriptionGeneralTab :all-setting="allSetting" />
|
||||||
</a-tab-pane>
|
</a-tab-pane>
|
||||||
<a-tab-pane v-if="allSetting.subJsonEnable || allSetting.subClashEnable" key="5" class="tab-pane">
|
<a-tab-pane v-if="allSetting.subJsonEnable || allSetting.subClashEnable" key="5" class="tab-pane">
|
||||||
<template #tab>
|
<template #tab>
|
||||||
<CodeOutlined />
|
<a-tooltip :title="isMobile ? `${t('pages.settings.subSettings')} (Formats)` : null">
|
||||||
<span>{{ t('pages.settings.subSettings') }} (Formats)</span>
|
<CodeOutlined />
|
||||||
|
</a-tooltip>
|
||||||
|
<span v-if="!isMobile">{{ t('pages.settings.subSettings') }} (Formats)</span>
|
||||||
</template>
|
</template>
|
||||||
<SubscriptionFormatsTab :all-setting="allSetting" />
|
<SubscriptionFormatsTab :all-setting="allSetting" />
|
||||||
</a-tab-pane>
|
</a-tab-pane>
|
||||||
|
|
@ -333,4 +343,33 @@ onBeforeUnmount(() => {
|
||||||
.tab-pane {
|
.tab-pane {
|
||||||
padding-top: 20px;
|
padding-top: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.icons-only :deep(.ant-tabs-nav) {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icons-only :deep(.ant-tabs-nav-wrap) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icons-only :deep(.ant-tabs-nav-list) {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icons-only :deep(.ant-tabs-tab) {
|
||||||
|
flex: 1 1 0;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icons-only :deep(.ant-tabs-tab .anticon) {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icons-only :deep(.ant-tabs-nav-operations) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ async function copyToken() {
|
||||||
<p>{{ t('pages.settings.security.twoFactorModalFirstStep') }}</p>
|
<p>{{ t('pages.settings.security.twoFactorModalFirstStep') }}</p>
|
||||||
<div class="qr-wrap">
|
<div class="qr-wrap">
|
||||||
<a-qrcode class="qr-code" :value="qrValue" :size="180" type="svg" :bordered="false"
|
<a-qrcode class="qr-code" :value="qrValue" :size="180" type="svg" :bordered="false"
|
||||||
error-level="L" :title="t('copy')" @click="copyToken" />
|
color="#000000" bg-color="#ffffff" error-level="L" :title="t('copy')" @click="copyToken" />
|
||||||
<span class="qr-token">{{ token }}</span>
|
<span class="qr-token">{{ token }}</span>
|
||||||
</div>
|
</div>
|
||||||
<a-divider />
|
<a-divider />
|
||||||
|
|
|
||||||
|
|
@ -204,7 +204,7 @@ const themeClass = computed(() => ({
|
||||||
<div class="qr-box">
|
<div class="qr-box">
|
||||||
<a-tag color="purple" class="qr-tag">{{ t('pages.settings.subSettings') }}</a-tag>
|
<a-tag color="purple" class="qr-tag">{{ t('pages.settings.subSettings') }}</a-tag>
|
||||||
<a-qrcode class="qr-code" :value="subUrl" :size="QR_SIZE" type="svg" :bordered="false"
|
<a-qrcode class="qr-code" :value="subUrl" :size="QR_SIZE" type="svg" :bordered="false"
|
||||||
:title="t('copy')" @click="copy(subUrl)" />
|
color="#000000" bg-color="#ffffff" :title="t('copy')" @click="copy(subUrl)" />
|
||||||
</div>
|
</div>
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col v-if="subJsonUrl" :xs="24" :sm="12" class="qr-col">
|
<a-col v-if="subJsonUrl" :xs="24" :sm="12" class="qr-col">
|
||||||
|
|
@ -213,14 +213,14 @@ const themeClass = computed(() => ({
|
||||||
{{ t('pages.settings.subSettings') }} JSON
|
{{ t('pages.settings.subSettings') }} JSON
|
||||||
</a-tag>
|
</a-tag>
|
||||||
<a-qrcode class="qr-code" :value="subJsonUrl" :size="QR_SIZE" type="svg" :bordered="false"
|
<a-qrcode class="qr-code" :value="subJsonUrl" :size="QR_SIZE" type="svg" :bordered="false"
|
||||||
:title="t('copy')" @click="copy(subJsonUrl)" />
|
color="#000000" bg-color="#ffffff" :title="t('copy')" @click="copy(subJsonUrl)" />
|
||||||
</div>
|
</div>
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col v-if="subClashUrl" :xs="24" :sm="12" class="qr-col">
|
<a-col v-if="subClashUrl" :xs="24" :sm="12" class="qr-col">
|
||||||
<div class="qr-box">
|
<div class="qr-box">
|
||||||
<a-tag color="purple" class="qr-tag">Clash / Mihomo</a-tag>
|
<a-tag color="purple" class="qr-tag">Clash / Mihomo</a-tag>
|
||||||
<a-qrcode class="qr-code" :value="subClashUrl" :size="QR_SIZE" type="svg" :bordered="false"
|
<a-qrcode class="qr-code" :value="subClashUrl" :size="QR_SIZE" type="svg" :bordered="false"
|
||||||
:title="t('copy')" @click="copy(subClashUrl)" />
|
color="#000000" bg-color="#ffffff" :title="t('copy')" @click="copy(subClashUrl)" />
|
||||||
</div>
|
</div>
|
||||||
</a-col>
|
</a-col>
|
||||||
</a-row>
|
</a-row>
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
import { Modal } from 'ant-design-vue';
|
import { Modal } from 'ant-design-vue';
|
||||||
|
|
||||||
import BalancerFormModal from './BalancerFormModal.vue';
|
import BalancerFormModal from './BalancerFormModal.vue';
|
||||||
|
import JsonEditor from '@/components/JsonEditor.vue';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
|
@ -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,25 +268,39 @@ 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'">
|
||||||
<span class="row-index">{{ index + 1 }}</span>
|
<div class="action-cell">
|
||||||
<a-dropdown :trigger="['click']">
|
<span class="row-index">{{ index + 1 }}</span>
|
||||||
<a-button shape="circle" size="small" class="action-btn">
|
|
||||||
<MoreOutlined />
|
<div :class="!isMobile ? 'action-buttons' : ''">
|
||||||
</a-button>
|
<a-button v-if="!isMobile" shape="circle" size="small" @click="openEdit(index)">
|
||||||
<template #overlay>
|
<template #icon>
|
||||||
<a-menu>
|
<EditOutlined />
|
||||||
<a-menu-item @click="openEdit(index)">
|
</template>
|
||||||
<EditOutlined /> {{ t('edit') }}
|
</a-button>
|
||||||
</a-menu-item>
|
|
||||||
<a-menu-item class="danger" @click="confirmDelete(index)">
|
<a-dropdown :trigger="['click']">
|
||||||
<DeleteOutlined /> {{ t('delete') }}
|
<a-button shape="circle" size="small">
|
||||||
</a-menu-item>
|
<template #icon>
|
||||||
</a-menu>
|
<MoreOutlined />
|
||||||
</template>
|
</template>
|
||||||
</a-dropdown>
|
</a-button>
|
||||||
|
<template #overlay>
|
||||||
|
<a-menu>
|
||||||
|
<a-menu-item v-if="isMobile" @click="openEdit(index)">
|
||||||
|
<EditOutlined /> {{ t('edit') }}
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item class="danger" @click="confirmDelete(index)">
|
||||||
|
<DeleteOutlined /> {{ t('delete') }}
|
||||||
|
</a-menu-item>
|
||||||
|
</a-menu>
|
||||||
|
</template>
|
||||||
|
</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>
|
||||||
|
|
|
||||||
|
|
@ -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. */
|
||||||
|
|
|
||||||
|
|
@ -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,33 +322,41 @@ 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>
|
||||||
<a-dropdown :trigger="['click']">
|
|
||||||
<a-button shape="circle" size="small">
|
<div class="action-buttons">
|
||||||
<MoreOutlined />
|
<a-button shape="circle" size="small" @click="openEdit(index)">
|
||||||
</a-button>
|
<template #icon>
|
||||||
<template #overlay>
|
<EditOutlined />
|
||||||
<a-menu>
|
</template>
|
||||||
<a-menu-item v-if="index > 0" @click="setFirst(index)">
|
</a-button>
|
||||||
<VerticalAlignTopOutlined /> Move to top
|
|
||||||
</a-menu-item>
|
<a-dropdown :trigger="['click']">
|
||||||
<a-menu-item @click="openEdit(index)">
|
<a-button shape="circle" size="small">
|
||||||
<EditOutlined /> Edit
|
<template #icon>
|
||||||
</a-menu-item>
|
<MoreOutlined />
|
||||||
<a-menu-item :disabled="index === 0" @click="moveUp(index)">
|
</template>
|
||||||
<ArrowUpOutlined />
|
</a-button>
|
||||||
</a-menu-item>
|
<template #overlay>
|
||||||
<a-menu-item :disabled="index === rows.length - 1" @click="moveDown(index)">
|
<a-menu>
|
||||||
<ArrowDownOutlined />
|
<a-menu-item v-if="index > 0" @click="setFirst(index)">
|
||||||
</a-menu-item>
|
<VerticalAlignTopOutlined /> Move to top
|
||||||
<a-menu-item @click="emit('reset-traffic', record.tag || '')">
|
</a-menu-item>
|
||||||
<RetweetOutlined /> Reset traffic
|
<a-menu-item :disabled="index === 0" @click="moveUp(index)">
|
||||||
</a-menu-item>
|
<ArrowUpOutlined />
|
||||||
<a-menu-item class="danger" @click="confirmDelete(index)">
|
</a-menu-item>
|
||||||
<DeleteOutlined /> Delete
|
<a-menu-item :disabled="index === rows.length - 1" @click="moveDown(index)">
|
||||||
</a-menu-item>
|
<ArrowDownOutlined />
|
||||||
</a-menu>
|
</a-menu-item>
|
||||||
</template>
|
<a-menu-item @click="emit('reset-traffic', record.tag || '')">
|
||||||
</a-dropdown>
|
<RetweetOutlined /> Reset traffic
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item class="danger" @click="confirmDelete(index)">
|
||||||
|
<DeleteOutlined /> Delete
|
||||||
|
</a-menu-item>
|
||||||
|
</a-menu>
|
||||||
|
</template>
|
||||||
|
</a-dropdown>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,27 +340,38 @@ 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>
|
||||||
<a-dropdown :trigger="['click']">
|
|
||||||
<a-button shape="circle" size="small">
|
<div :class="!isMobile ? 'action-buttons' : ''">
|
||||||
<MoreOutlined />
|
<a-button v-if="!isMobile" shape="circle" size="small" @click="openEdit(index)">
|
||||||
|
<template #icon>
|
||||||
|
<EditOutlined />
|
||||||
|
</template>
|
||||||
</a-button>
|
</a-button>
|
||||||
<template #overlay>
|
|
||||||
<a-menu>
|
<a-dropdown :trigger="['click']">
|
||||||
<a-menu-item @click="openEdit(index)">
|
<a-button shape="circle" size="small">
|
||||||
<EditOutlined /> {{ t('edit') }}
|
<template #icon>
|
||||||
</a-menu-item>
|
<MoreOutlined />
|
||||||
<a-menu-item :disabled="index === 0" @click="moveUp(index)">
|
</template>
|
||||||
<ArrowUpOutlined />
|
</a-button>
|
||||||
</a-menu-item>
|
<template #overlay>
|
||||||
<a-menu-item :disabled="index === rows.length - 1" @click="moveDown(index)">
|
<a-menu>
|
||||||
<ArrowDownOutlined />
|
<a-menu-item v-if="isMobile" @click="openEdit(index)">
|
||||||
</a-menu-item>
|
<EditOutlined /> {{ t('edit') }}
|
||||||
<a-menu-item class="danger" @click="confirmDelete(index)">
|
</a-menu-item>
|
||||||
<DeleteOutlined /> {{ t('delete') }}
|
<a-menu-item :disabled="index === 0" @click="moveUp(index)">
|
||||||
</a-menu-item>
|
<ArrowUpOutlined />
|
||||||
</a-menu>
|
</a-menu-item>
|
||||||
</template>
|
<a-menu-item :disabled="index === rows.length - 1" @click="moveDown(index)">
|
||||||
</a-dropdown>
|
<ArrowDownOutlined />
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item class="danger" @click="confirmDelete(index)">
|
||||||
|
<DeleteOutlined /> {{ t('delete') }}
|
||||||
|
</a-menu-item>
|
||||||
|
</a-menu>
|
||||||
|
</template>
|
||||||
|
</a-dropdown>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
@click="updateLicense">Update</a-button>
|
<a-button type="primary" :disabled="warpPlus.length < 26" :loading="loading"
|
||||||
|
@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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -43,36 +43,6 @@ func (a *atomicBool) takeAndReset() bool {
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
type emailSet struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
m map[string]struct{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func newEmailSet() *emailSet { return &emailSet{m: make(map[string]struct{})} }
|
|
||||||
|
|
||||||
func (s *emailSet) addAll(emails []string) {
|
|
||||||
if len(emails) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.mu.Lock()
|
|
||||||
for _, e := range emails {
|
|
||||||
if e != "" {
|
|
||||||
s.m[e] = struct{}{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
s.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *emailSet) slice() []string {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
out := make([]string, 0, len(s.m))
|
|
||||||
for e := range s.m {
|
|
||||||
out = append(out, e)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewNodeTrafficSyncJob() *NodeTrafficSyncJob {
|
func NewNodeTrafficSyncJob() *NodeTrafficSyncJob {
|
||||||
return &NodeTrafficSyncJob{}
|
return &NodeTrafficSyncJob{}
|
||||||
}
|
}
|
||||||
|
|
@ -97,7 +67,6 @@ func (j *NodeTrafficSyncJob) Run() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
touched := newEmailSet()
|
|
||||||
sem := make(chan struct{}, nodeTrafficSyncConcurrency)
|
sem := make(chan struct{}, nodeTrafficSyncConcurrency)
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
for _, n := range nodes {
|
for _, n := range nodes {
|
||||||
|
|
@ -109,7 +78,7 @@ func (j *NodeTrafficSyncJob) Run() {
|
||||||
go func(n *model.Node) {
|
go func(n *model.Node) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
defer func() { <-sem }()
|
defer func() { <-sem }()
|
||||||
j.syncOne(mgr, n, touched)
|
j.syncOne(mgr, n)
|
||||||
}(n)
|
}(n)
|
||||||
}
|
}
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|
@ -135,12 +104,10 @@ func (j *NodeTrafficSyncJob) Run() {
|
||||||
})
|
})
|
||||||
|
|
||||||
clientStats := map[string]any{}
|
clientStats := map[string]any{}
|
||||||
if emails := touched.slice(); len(emails) > 0 {
|
if stats, err := j.inboundService.GetAllClientTraffics(); err != nil {
|
||||||
if stats, err := j.inboundService.GetActiveClientTraffics(emails); err != nil {
|
logger.Warning("node traffic sync: get all client traffics for websocket failed:", err)
|
||||||
logger.Warning("node traffic sync: get client traffics for websocket failed:", err)
|
} else if len(stats) > 0 {
|
||||||
} else if len(stats) > 0 {
|
clientStats["clients"] = stats
|
||||||
clientStats["clients"] = stats
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if summary, err := j.inboundService.GetInboundsTrafficSummary(); err != nil {
|
if summary, err := j.inboundService.GetInboundsTrafficSummary(); err != nil {
|
||||||
logger.Warning("node traffic sync: get inbounds summary for websocket failed:", err)
|
logger.Warning("node traffic sync: get inbounds summary for websocket failed:", err)
|
||||||
|
|
@ -156,7 +123,7 @@ func (j *NodeTrafficSyncJob) Run() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, touched *emailSet) {
|
func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), nodeTrafficSyncRequestTimeout)
|
ctx, cancel := context.WithTimeout(context.Background(), nodeTrafficSyncRequestTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
|
@ -179,16 +146,4 @@ func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, touche
|
||||||
if changed {
|
if changed {
|
||||||
j.structural.set()
|
j.structural.set()
|
||||||
}
|
}
|
||||||
for _, ib := range snap.Inbounds {
|
|
||||||
if ib == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
emails := make([]string, 0, len(ib.ClientStats))
|
|
||||||
for _, cs := range ib.ClientStats {
|
|
||||||
if cs.Email != "" {
|
|
||||||
emails = append(emails, cs.Email)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
touched.addAll(emails)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -95,18 +95,16 @@ func (j *XrayTrafficJob) Run() {
|
||||||
"lastOnlineMap": lastOnlineMap,
|
"lastOnlineMap": lastOnlineMap,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Compact delta payload: per-client absolute counters for clients active
|
// Full snapshot every cycle: absolute per-client counters and inbound
|
||||||
// this cycle, plus inbound-level absolute totals. Frontend applies both
|
// totals. Frontend overwrites both in place. The previous delta path
|
||||||
// in-place — typical payload ~10–50KB even for 10k+ client deployments.
|
// (activeEmails -> GetActiveClientTraffics) silently omitted the
|
||||||
// Replaces the old full-inbound-list broadcast that hit WS size limits
|
// clients array whenever nobody moved bytes in the cycle, leaving the
|
||||||
// (5–10MB) and forced the frontend into a REST refetch.
|
// client rows in the UI stuck at stale traffic/remained/all-time.
|
||||||
clientStatsPayload := map[string]any{}
|
clientStatsPayload := map[string]any{}
|
||||||
if activeEmails := activeEmails(clientTraffics); len(activeEmails) > 0 {
|
if stats, err := j.inboundService.GetAllClientTraffics(); err != nil {
|
||||||
if stats, err := j.inboundService.GetActiveClientTraffics(activeEmails); err != nil {
|
logger.Warning("get all client traffics for websocket failed:", err)
|
||||||
logger.Warning("get active client traffics for websocket failed:", err)
|
} else if len(stats) > 0 {
|
||||||
} else if len(stats) > 0 {
|
clientStatsPayload["clients"] = stats
|
||||||
clientStatsPayload["clients"] = stats
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if inboundSummary, err := j.inboundService.GetInboundsTrafficSummary(); err != nil {
|
if inboundSummary, err := j.inboundService.GetInboundsTrafficSummary(); err != nil {
|
||||||
logger.Warning("get inbounds traffic summary for websocket failed:", err)
|
logger.Warning("get inbounds traffic summary for websocket failed:", err)
|
||||||
|
|
@ -126,26 +124,6 @@ func (j *XrayTrafficJob) Run() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// activeEmails returns the set of client emails that had non-zero traffic in
|
|
||||||
// the current collection window. Idle clients are skipped — no need to push
|
|
||||||
// their (unchanged) counters to the frontend.
|
|
||||||
func activeEmails(clientTraffics []*xray.ClientTraffic) []string {
|
|
||||||
if len(clientTraffics) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
emails := make([]string, 0, len(clientTraffics))
|
|
||||||
for _, ct := range clientTraffics {
|
|
||||||
if ct == nil || ct.Email == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if ct.Up == 0 && ct.Down == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
emails = append(emails, ct.Email)
|
|
||||||
}
|
|
||||||
return emails
|
|
||||||
}
|
|
||||||
|
|
||||||
func (j *XrayTrafficJob) informTrafficToExternalAPI(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) {
|
func (j *XrayTrafficJob) informTrafficToExternalAPI(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) {
|
||||||
informURL, err := j.settingService.GetExternalTrafficInformURI()
|
informURL, err := j.settingService.GetExternalTrafficInformURI()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}],
|
}],
|
||||||
|
|
|
||||||
|
|
@ -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"`
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue