ws/inbounds: realtime fixes + perf for 10k+ client inbounds (#4123)

* ws/inbounds: realtime fixes + perf for 10k+ client inbounds

- hub: dedup, throttle, panic-restart, deadlock fix, race tests
- client: backoff cap + slow-retry instead of giving up
- broadcast: delta-only payload, count-based invalidate fallback
- filter: fix empty online list (Inbound has no .id, use dbInbound.toInbound)
- perf: O(N²)→O(N) traffic merge, bulk delete, /setEnable endpoint
- traffic: monotonic all_time + UI clamp + propagate in delta handler
- session: persist on update/logout (fixes logout-after-password-change)
- ui: protocol tags flex, traffic bar normalize

* Remove hub_test.go file

* fix: ws hub, inbound service, and frontend correctness

- propagate DelInbound error on disable path in SetInboundEnable
- skip empty emails in updateClientTraffics to avoid constraint violations
- use consistent IN ? clause, drop redundant ErrRecordNotFound guards
- Hub.Unregister: direct removeClient fallback when channel is full
- applyClientStatsDelta: O(1) email lookup via per-inbound Map cache
- WS payload size check: Blob.size instead of .length for real byte count

* fix: chunk large IN ? queries and fix IPv6 same-origin check

* fix: chunk large IN ? queries and fix IPv6 same-origin check

* fix: unify clientStats cache, throttle clarity, hub constants

* fix(ui): align traffic/expiry cell columns across all rows

* style(ui): redesign outbounds table for visual consistency

* style(ui): redesign routing table for visual consistency

* fix:

* fix:

* fix:

* fix:

* fix:

* fix: font

* refactor: simplify outbound tone functions for consistency and maintainability

---------

Co-authored-by: lolka1333 <test123@gmail.com>
This commit is contained in:
lolka1333
2026-05-05 18:27:49 +03:00
committed by GitHub
parent 77d94b25d0
commit 8177f6dc66
16 changed files with 2373 additions and 1399 deletions

View File

@@ -93,27 +93,22 @@
</tr>
</table>
</template>
<table>
<tr class="tr-table-box">
<td class="tr-table-rt"> [[ SizeFormatter.sizeFormat(getSumStats(record, client.email)) ]] </td>
<td class="tr-table-bar" v-if="!client.enable">
<a-progress :stroke-color="themeSwitcher.isDarkTheme ? 'rgb(72 84 105)' : '#bcbcbc'" :show-info="false"
:percent="statsProgress(record, client.email)" />
</td>
<td class="tr-table-bar" v-else-if="client.totalGB > 0">
<a-progress :stroke-color="clientStatsColor(record, client.email)" :show-info="false"
:status="isClientDepleted(record, client.email)? 'exception' : ''"
:percent="statsProgress(record, client.email)" />
</td>
<td v-else class="infinite-bar tr-table-bar">
<a-progress :show-info="false" :percent="100"></a-progress>
</td>
<td class="tr-table-lt">
<template v-if="client.totalGB > 0">[[ client._totalGB + "GB" ]]</template>
<span v-else class="tr-infinity-ch">&infin;</span>
</td>
</tr>
</table>
<div class="tr-table-box">
<div class="tr-table-rt">[[ SizeFormatter.sizeFormat(getSumStats(record, client.email)) ]]</div>
<div class="tr-table-bar" v-if="!client.enable">
<a-progress :stroke-color="themeSwitcher.isDarkTheme ? 'rgb(72 84 105)' : '#bcbcbc'" :show-info="false" :percent="statsProgress(record, client.email)" />
</div>
<div class="tr-table-bar" v-else-if="client.totalGB > 0">
<a-progress :stroke-color="clientStatsColor(record, client.email)" :show-info="false" :status="isClientDepleted(record, client.email)? 'exception' : ''" :percent="statsProgress(record, client.email)" />
</div>
<div v-else class="infinite-bar tr-table-bar">
<a-progress :show-info="false" :percent="100"></a-progress>
</div>
<div class="tr-table-lt">
<template v-if="client.totalGB > 0">[[ client._totalGB + "GB" ]]</template>
<span v-else class="tr-infinity-ch">&infin;</span>
</div>
</div>
</a-popover>
</template>
@@ -127,16 +122,13 @@
<span v-if="client.expiryTime < 0">{{ i18n "pages.client.delayedStart" }}</span>
<span v-else>[[ IntlUtil.formatDate(client.expiryTime) ]]</span>
</template>
<table>
<tr class="tr-table-box">
<td class="tr-table-rt"> [[ IntlUtil.formatRelativeTime(client.expiryTime) ]] </td>
<td class="infinite-bar tr-table-bar">
<a-progress :show-info="false" :status="isClientDepleted(record, client.email)? 'exception' : ''"
:percent="expireProgress(client.expiryTime, client.reset)" />
</td>
<td class="tr-table-lt">[[ client.reset + "d" ]]</td>
</tr>
</table>
<div class="tr-table-box">
<div class="tr-table-rt">[[ IntlUtil.formatRelativeTime(client.expiryTime) ]]</div>
<div class="infinite-bar tr-table-bar">
<a-progress :show-info="false" :status="isClientDepleted(record, client.email)? 'exception' : ''" :percent="expireProgress(client.expiryTime, client.reset)" />
</div>
<div class="tr-table-lt">[[ client.reset + "d" ]]</div>
</div>
</a-popover>
</template>
<template v-else>

View File

@@ -1,238 +1,302 @@
{{define "component/sortableTableTrigger"}}
<a-icon type="drag" class="sortable-icon" :style="{ cursor: 'move' }" @mouseup="mouseUpHandler"
@mousedown="mouseDownHandler" @click="clickHandler" />
<a-icon type="drag" class="sortable-icon"
role="button" tabindex="0"
:aria-label="ariaLabel"
@pointerdown="onPointerDown"
@keydown="onKeyDown" />
{{end}}
{{define "component/aTableSortable"}}
<script>
const DRAGGABLE_ROW_CLASS = 'draggable-row';
const findParentRowElement = (el) => {
if (!el || !el.tagName) {
return null;
} else if (el.classList.contains(DRAGGABLE_ROW_CLASS)) {
return el;
} else if (el.parentNode) {
return findParentRowElement(el.parentNode);
} else {
return null;
}
}
/**
* Sortable a-table — drag-to-reorder rows using Pointer Events.
*
* Why a rewrite:
* - Old impl set `draggable: true` on every row, which (a) broke text
* selection inside cells, (b) let HTML5 start a drag from anywhere on
* the row even when the state machine wasn't primed, producing
* "phantom drags" that didn't reorder anything.
* - HTML5 drag has no touch support on most mobile browsers and no
* keyboard fallback at all.
* - The drag-image hack cloned the entire table — slow on big lists.
*
* New design:
* - Only the explicit drag handle initiates a drag, via Pointer Events
* (one API for mouse + touch + pen). Rows are not draggable.
* - During drag, `data-source` is reordered live: the source row visually
* slides into the target slot and other rows shift around it. The live
* reorder IS the visual feedback — no separate floating preview.
* - On commit, emits `onsort(sourceIndex, targetIndex)` — same event name
* and signature as before, so existing call sites stay unchanged.
* - Keyboard support: the handle is focusable; ArrowUp / ArrowDown move
* the row by one; Escape cancels a pointer-drag in progress.
*/
const ROW_CLASS = 'sortable-row';
Vue.component('a-table-sortable', {
data() {
return {
sortingElementIndex: null,
newElementIndex: null,
// null when idle. While dragging:
// { sourceIndex, targetIndex, pointerId, sourceKey }
drag: null,
};
},
props: {
'data-source': {
type: undefined,
required: false,
},
'customRow': {
type: undefined,
required: false,
}
'data-source': { type: undefined, required: false },
'customRow': { type: undefined, required: false },
'row-key': { type: undefined, required: false },
},
inheritAttrs: false,
provide() {
const sortable = {}
Object.defineProperty(sortable, "setSortableIndex", {
const sortable = {};
// Methods exposed to the trigger child via inject. Defined as getters
// so `this` binds to the component instance, not the plain object.
Object.defineProperty(sortable, 'startDrag', {
enumerable: true,
get: () => this.setCurrentSortableIndex,
get: () => this.startDrag,
});
Object.defineProperty(sortable, "resetSortableIndex", {
Object.defineProperty(sortable, 'moveByKeyboard', {
enumerable: true,
get: () => this.resetSortableIndex,
get: () => this.moveByKeyboard,
});
return {
sortable,
}
return { sortable };
},
render: function(createElement) {
return createElement('a-table', {
class: {
'ant-table-is-sorting': this.isDragging(),
},
props: {
...this.$attrs,
'data-source': this.records,
customRow: (record, index) => this.customRowRender(record, index),
},
on: this.$listeners,
nativeOn: {
drop: (e) => this.dropHandler(e),
},
scopedSlots: this.$scopedSlots,
locale: {
filterConfirm: `{{ i18n "confirm" }}`,
filterReset: `{{ i18n "reset" }}`,
emptyText: `{{ i18n "noData" }}`
}
}, this.$slots.default, )
},
created() {
this.$memoSort = {};
beforeDestroy() {
this.detachPointerListeners();
},
methods: {
isDragging() {
const currentIndex = this.sortingElementIndex;
return currentIndex !== null && currentIndex !== undefined;
isDragging() { return this.drag !== null; },
// Resolve the row key for a record. Used to identify the source row
// even after data-source is reordered live during drag.
keyOf(record, fallback) {
const rk = this.rowKey;
if (typeof rk === 'function') return rk(record);
if (typeof rk === 'string') return record && record[rk];
return fallback;
},
resetSortableIndex(e, index) {
this.sortingElementIndex = null;
this.newElementIndex = null;
this.$memoSort = {};
},
setCurrentSortableIndex(e, index) {
this.sortingElementIndex = index;
},
dragStartHandler(e, index) {
if (!this.isDragging()) {
e.preventDefault();
return;
}
const hideDragImage = this.$el.cloneNode(true);
hideDragImage.id = "hideDragImage-hide";
hideDragImage.style.opacity = 0;
e.dataTransfer.setDragImage(hideDragImage, 0, 0);
},
dragStopHandler(e, index) {
const hideDragImage = document.getElementById('hideDragImage-hide');
if (hideDragImage) hideDragImage.remove();
this.resetSortableIndex(e, index);
},
dragOverHandler(e, index) {
if (!this.isDragging()) {
return;
}
startDrag(e, sourceIndex) {
// Primary button only (mouse left / first touch).
if (e.button != null && e.button !== 0) return;
e.preventDefault();
const currentIndex = this.sortingElementIndex;
if (index === currentIndex) {
this.newElementIndex = null;
return;
const record = this.dataSource && this.dataSource[sourceIndex];
this.drag = {
sourceIndex,
targetIndex: sourceIndex,
pointerId: e.pointerId,
sourceKey: this.keyOf(record, sourceIndex),
};
// Capture the pointer so move/up keep firing even if the cursor leaves
// the icon. Try/catch because some older browsers throw on capture.
if (e.target && typeof e.target.setPointerCapture === 'function' && e.pointerId != null) {
try { e.target.setPointerCapture(e.pointerId); } catch (_) {}
}
const row = findParentRowElement(e.target);
if (!row) {
return;
}
const rect = row.getBoundingClientRect();
const offsetTop = e.pageY - rect.top;
if (offsetTop < rect.height / 2) {
this.newElementIndex = Math.max(index - 1, 0);
this.attachPointerListeners();
},
attachPointerListeners() {
this._onMove = (ev) => this.onPointerMove(ev);
this._onUp = (ev) => this.onPointerUp(ev);
this._onCancel = (ev) => this.cancelDrag(ev);
document.addEventListener('pointermove', this._onMove, true);
document.addEventListener('pointerup', this._onUp, true);
document.addEventListener('pointercancel', this._onCancel, true);
document.addEventListener('keydown', this._onCancel, true);
},
detachPointerListeners() {
if (!this._onMove) return;
document.removeEventListener('pointermove', this._onMove, true);
document.removeEventListener('pointerup', this._onUp, true);
document.removeEventListener('pointercancel', this._onCancel, true);
document.removeEventListener('keydown', this._onCancel, true);
this._onMove = this._onUp = this._onCancel = null;
},
onPointerMove(e) {
if (!this.drag) return;
if (this.drag.pointerId != null && e.pointerId !== this.drag.pointerId) return;
// Hit-test: find which row the pointer Y is inside (or closest to).
const rows = this.$el.querySelectorAll('tr.' + ROW_CLASS);
if (!rows.length) return;
const y = e.clientY;
const firstRect = rows[0].getBoundingClientRect();
const lastRect = rows[rows.length - 1].getBoundingClientRect();
let target = this.drag.targetIndex;
if (y < firstRect.top) {
target = 0;
} else if (y > lastRect.bottom) {
target = rows.length - 1;
} else {
this.newElementIndex = index;
for (let i = 0; i < rows.length; i++) {
const rect = rows[i].getBoundingClientRect();
if (y >= rect.top && y <= rect.bottom) {
target = i;
break;
}
}
}
if (target !== this.drag.targetIndex) {
this.drag = Object.assign({}, this.drag, { targetIndex: target });
}
},
dropHandler(e) {
if (this.isDragging()) {
this.$emit('onsort', this.sortingElementIndex, this.newElementIndex);
onPointerUp(e) {
if (!this.drag) return;
if (this.drag.pointerId != null && e.pointerId !== this.drag.pointerId) return;
this.commitDrag();
},
commitDrag() {
const d = this.drag;
this.detachPointerListeners();
this.drag = null;
if (d && d.sourceIndex !== d.targetIndex) {
this.$emit('onsort', d.sourceIndex, d.targetIndex);
}
},
cancelDrag(e) {
// Triggered by pointercancel and keydown handlers. For keydown, only
// act on Escape; otherwise let the event flow to other listeners.
if (e && e.type === 'keydown' && e.key !== 'Escape') return;
this.detachPointerListeners();
this.drag = null;
},
// Keyboard reorder: commit immediately by emitting onsort. No "preview"
// state needed since the move is one row up or down.
moveByKeyboard(direction, sourceIndex) {
const target = sourceIndex + direction;
if (target < 0 || target >= (this.dataSource || []).length) return;
this.$emit('onsort', sourceIndex, target);
},
customRowRender(record, index) {
const parentMethodResult = this.customRow?.(record, index) || {};
const newIndex = this.newElementIndex;
const currentIndex = this.sortingElementIndex;
return {
...parentMethodResult,
attrs: {
...(parentMethodResult?.attrs || {}),
draggable: true,
},
on: {
...(parentMethodResult?.on || {}),
dragstart: (e) => this.dragStartHandler(e, index),
dragend: (e) => this.dragStopHandler(e, index),
dragover: (e) => this.dragOverHandler(e, index),
},
class: {
...(parentMethodResult?.class || {}),
[DRAGGABLE_ROW_CLASS]: true,
['dragging']: this.isDragging() ? (newIndex === null ? index === currentIndex : index === newIndex) :
false,
},
};
}
const parent = (typeof this.customRow === 'function')
? (this.customRow(record, index) || {})
: {};
const d = this.drag;
const isSource = d && this.keyOf(record, index) === d.sourceKey;
return Object.assign({}, parent, {
// CRITICAL: no `draggable: true`. Drag is initiated only by the
// handle icon. Leaves text-selection on cells working normally.
attrs: Object.assign({}, parent.attrs || {}),
class: Object.assign({}, parent.class || {}, {
[ROW_CLASS]: true,
'sortable-source-row': !!isSource,
}),
});
},
},
computed: {
// Render-data: dataSource with the source row spliced into targetIndex.
// When idle or when target equals source, returns the original list
// unchanged so Ant Design's table treats this as a stable reference.
records() {
const newIndex = this.newElementIndex;
const currentIndex = this.sortingElementIndex;
if (!this.isDragging() || newIndex === null || currentIndex === newIndex) {
return this.dataSource;
}
if (this.$memoSort.newIndex === newIndex) {
return this.$memoSort.list;
}
let list = [...this.dataSource];
list.splice(newIndex, 0, list.splice(currentIndex, 1)[0]);
this.$memoSort = {
newIndex,
list,
};
const d = this.drag;
if (!d || d.sourceIndex === d.targetIndex) return this.dataSource;
const list = (this.dataSource || []).slice();
const [item] = list.splice(d.sourceIndex, 1);
list.splice(d.targetIndex, 0, item);
return list;
}
}
},
},
render(h) {
return h('a-table', {
class: { 'sortable-table': true, 'sortable-table-dragging': this.isDragging() },
props: Object.assign({}, this.$attrs, {
'data-source': this.records,
'row-key': this.rowKey,
customRow: (record, index) => this.customRowRender(record, index),
locale: {
filterConfirm: `{{ i18n "confirm" }}`,
filterReset: `{{ i18n "reset" }}`,
emptyText: `{{ i18n "noData" }}`,
},
}),
on: this.$listeners,
scopedSlots: this.$scopedSlots,
}, this.$slots.default);
},
});
Vue.component('a-table-sort-trigger', {
template: `{{template "component/sortableTableTrigger" .}}`,
props: {
'item-index': {
type: undefined,
required: false
}
'item-index': { type: undefined, required: false },
},
inject: ['sortable'],
computed: {
ariaLabel() {
// Localised label is overkill for an internal a11y string; English is
// fine here and matches screen-reader expectations across locales.
return 'Drag to reorder row ' + (((this.itemIndex == null ? 0 : this.itemIndex) + 1));
},
},
methods: {
mouseDownHandler(e) {
if (this.sortable) {
this.sortable.setSortableIndex(e, this.itemIndex);
onPointerDown(e) {
if (this.sortable && this.sortable.startDrag) {
this.sortable.startDrag(e, this.itemIndex);
}
},
mouseUpHandler(e) {
if (this.sortable) {
this.sortable.resetSortableIndex(e, this.itemIndex);
onKeyDown(e) {
if (!this.sortable || !this.sortable.moveByKeyboard) return;
if (e.key === 'ArrowUp') {
e.preventDefault();
this.sortable.moveByKeyboard(-1, this.itemIndex);
} else if (e.key === 'ArrowDown') {
e.preventDefault();
this.sortable.moveByKeyboard(+1, this.itemIndex);
}
},
clickHandler(e) {
e.preventDefault();
},
}
})
},
});
</script>
<style>
@media only screen and (max-width: 767px) {
.sortable-icon {
display: none;
}
/* Drag handle — focusable, keyboard-accessible, touch-friendly hit area.
`touch-action: none` is critical: it tells the browser not to interpret
touch on the icon as a scroll/zoom gesture, so pointermove fires for
drag-tracking. Without it, mobile browsers eat the pointer events. */
.sortable-icon {
display: inline-flex;
align-items: center;
justify-content: center;
cursor: grab;
padding: 6px;
border-radius: 6px;
color: rgba(255, 255, 255, 0.5);
transition: background-color 0.15s ease, color 0.15s ease;
user-select: none;
touch-action: none;
}
.sortable-icon:hover {
color: rgba(255, 255, 255, 0.85);
background: rgba(255, 255, 255, 0.06);
}
.sortable-icon:active { cursor: grabbing; }
.sortable-icon:focus-visible {
outline: 2px solid #008771;
outline-offset: 2px;
}
.ant-table-is-sorting .draggable-row td {
background-color: #ffffff !important;
.light .sortable-icon { color: rgba(0, 0, 0, 0.45); }
.light .sortable-icon:hover {
color: rgba(0, 0, 0, 0.85);
background: rgba(0, 0, 0, 0.05);
}
.dark .ant-table-is-sorting .draggable-row td {
background-color: var(--dark-color-surface-100) !important;
/* While dragging: the source row gets a soft green wash so the user can
track which row is being moved. Other rows transition smoothly as the
data-source is reordered. */
.sortable-table-dragging .sortable-source-row > td {
background: rgba(0, 135, 113, 0.10) !important;
transition: background-color 0.18s ease;
}
.ant-table-is-sorting .dragging td {
background-color: rgb(232 244 242) !important;
color: rgba(0, 0, 0, 0.3);
.sortable-table-dragging .sortable-source-row .routing-index,
.sortable-table-dragging .sortable-source-row .outbound-index {
opacity: 0.45;
}
.dark .ant-table-is-sorting .dragging td {
background-color: var(--dark-color-table-hover) !important;
color: rgba(255, 255, 255, 0.3);
.sortable-table-dragging .sortable-row > td {
transition: background-color 0.18s ease;
}
.ant-table-is-sorting .dragging {
opacity: 1;
box-shadow: 1px -2px 2px #008771;
transition: all 0.2s;
}
.ant-table-is-sorting .dragging .ant-table-row-index {
opacity: 0.3;
/* Disable text selection across the whole table while a drag is in
progress — selection during drag is never useful and looks broken. */
.sortable-table-dragging,
.sortable-table-dragging * {
user-select: none;
}
</style>
{{end}}
{{end}}

View File

@@ -282,16 +282,15 @@
</a-dropdown>
</template>
<template slot="protocol" slot-scope="text, dbInbound">
<a-tag :style="{ margin: '0' }" color="purple">[[
dbInbound.protocol ]]</a-tag>
<template v-if="dbInbound.isVMess || dbInbound.isVLess || dbInbound.isTrojan || dbInbound.isSS">
<a-tag :style="{ margin: '0' }" color="green">[[
dbInbound.toInbound().stream.network ]]</a-tag>
<a-tag :style="{ margin: '0' }" v-if="dbInbound.toInbound().stream.isTls"
color="blue">TLS</a-tag>
<a-tag :style="{ margin: '0' }" v-if="dbInbound.toInbound().stream.isReality"
color="blue">Reality</a-tag>
</template>
<div class="protocol-tags">
<a-tag color="purple">[[ dbInbound.protocol ]]</a-tag>
<template
v-if="dbInbound.isVMess || dbInbound.isVLess || dbInbound.isTrojan || dbInbound.isSS">
<a-tag color="green">[[ dbInbound.toInbound().stream.network ]]</a-tag>
<a-tag v-if="dbInbound.toInbound().stream.isTls" color="blue">TLS</a-tag>
<a-tag v-if="dbInbound.toInbound().stream.isReality" color="blue">Reality</a-tag>
</template>
</div>
</template>
<template slot="clients" slot-scope="text, dbInbound">
<template v-if="clientCount[dbInbound.id]">
@@ -644,8 +643,11 @@
</a-popover>
</template>
<template slot="expandedRowRender" slot-scope="record">
<a-table :row-key="client => client.id" :columns="isMobile ? innerMobileColumns : innerColumns"
:data-source="getInboundClients(record)" :pagination=pagination(getInboundClients(record))
<a-table :row-key="client => client.id"
:columns="isMobile ? innerMobileColumns : innerColumns"
:data-source="getInboundClients(record)"
:pagination=pagination(getInboundClients(record))
:scroll="isMobile ? {} : { x: 'max-content' }"
:style="{ margin: `-10px ${isMobile ? '2px' : '22px'} -11px` }">
{{template "component/aClientTable" .}}
</a-table>
@@ -986,58 +988,14 @@
},
}];
const innerColumns = [{
title: '{{ i18n "pages.inbounds.operate" }}',
width: 70,
scopedSlots: {
customRender: 'actions'
}
},
{
title: '{{ i18n "pages.inbounds.enable" }}',
width: 30,
scopedSlots: {
customRender: 'enable'
}
},
{
title: '{{ i18n "online" }}',
width: 32,
scopedSlots: {
customRender: 'online'
}
},
{
title: '{{ i18n "pages.inbounds.client" }}',
width: 80,
scopedSlots: {
customRender: 'client'
}
},
{
title: '{{ i18n "pages.inbounds.traffic" }}',
width: 80,
align: 'center',
scopedSlots: {
customRender: 'traffic'
}
},
{
title: '{{ i18n "pages.inbounds.allTimeTraffic" }}',
width: 60,
align: 'center',
scopedSlots: {
customRender: 'allTime'
}
},
{
title: '{{ i18n "pages.inbounds.expireDate" }}',
width: 80,
align: 'center',
scopedSlots: {
customRender: 'expiryTime'
}
},
const innerColumns = [
{ title: '{{ i18n "pages.inbounds.operate" }}', width: 140, scopedSlots: { customRender: 'actions' } },
{ title: '{{ i18n "pages.inbounds.enable" }}', width: 60, scopedSlots: { customRender: 'enable' } },
{ title: '{{ i18n "online" }}', width: 80, scopedSlots: { customRender: 'online' } },
{ title: '{{ i18n "pages.inbounds.client" }}', width: 160, scopedSlots: { customRender: 'client' } },
{ title: '{{ i18n "pages.inbounds.traffic" }}', width: 200, align: 'center', scopedSlots: { customRender: 'traffic' } },
{ title: '{{ i18n "pages.inbounds.allTimeTraffic" }}', width: 110, align: 'center', scopedSlots: { customRender: 'allTime' } },
{ title: '{{ i18n "pages.inbounds.expireDate" }}', width: 180, align: 'center', scopedSlots: { customRender: 'expiryTime' } },
];
const innerMobileColumns = [{
@@ -1087,7 +1045,7 @@
trafficDiff: 0,
defaultCert: '',
defaultKey: '',
clientCount: [],
clientCount: {},
onlineClients: [],
lastOnlineMap: {},
isRefreshEnabled: localStorage.getItem("isRefreshEnabled") === "true" ? true : false,
@@ -1111,6 +1069,71 @@
loading(spinning = true) {
this.loadingStates.spinning = spinning;
},
// applyClientStatsDelta updates client traffic counters and inbound totals
// in-place from a WebSocket delta payload. Avoids full-list re-fetch and
// re-render — critical at 10k+ client scale.
applyClientStatsDelta(payload) {
if (!payload || typeof payload !== 'object') return;
const inboundsById = new Map();
this.dbInbounds.forEach(ib => inboundsById.set(ib.id, ib));
const touched = new Set();
if (Array.isArray(payload.clients) && payload.clients.length > 0) {
for (const stat of payload.clients) {
const dbInbound = inboundsById.get(stat.inboundId);
if (!dbInbound || !Array.isArray(dbInbound.clientStats)) continue;
const cs = this.getClientStats(dbInbound, stat.email);
if (!cs) continue;
cs.up = stat.up;
cs.down = stat.down;
// allTime is the cumulative-historical counter shown in the
// "Общий трафик" column. The previous handler updated up/down/
// total but skipped allTime, so that column stayed frozen at
// its initial-page-load value until a manual refresh.
if (stat.allTime !== undefined) cs.allTime = stat.allTime;
if (stat.total !== undefined) cs.total = stat.total;
if (stat.expiryTime !== undefined) cs.expiryTime = stat.expiryTime;
if (stat.lastOnline !== undefined) cs.lastOnline = stat.lastOnline;
if (stat.enable !== undefined) cs.enable = stat.enable;
touched.add(stat.inboundId);
}
}
if (Array.isArray(payload.inbounds) && payload.inbounds.length > 0) {
for (const summary of payload.inbounds) {
const dbInbound = inboundsById.get(summary.id);
if (!dbInbound) continue;
dbInbound.up = summary.up;
dbInbound.down = summary.down;
if (summary.total !== undefined) dbInbound.total = summary.total;
if (summary.allTime !== undefined) dbInbound.allTime = summary.allTime;
if (summary.enable !== undefined) dbInbound.enable = summary.enable;
}
}
// Recompute clientCount for inbounds whose stats changed. The cached
// parsed Inbound is fetched via dbInbound.toInbound() — earlier
// versions used `this.inbounds.find(ib => ib.id === id)` which
// ALWAYS returned undefined (the Inbound class has no id field), so
// this branch silently never ran and depleted/expiring/online filters
// never refreshed from delta updates.
if (touched.size > 0) {
for (const id of touched) {
const dbInbound = inboundsById.get(id);
if (dbInbound) {
this.$set(this.clientCount, id, this.getClientCounts(dbInbound, dbInbound.toInbound()));
}
}
}
// Re-run filter/search so the displayed slice picks up updated values.
if (this.enableFilter) {
this.filterInbounds();
} else {
this.searchInbounds(this.searchKey);
}
},
async getDBInbounds() {
this.refreshing = true;
const msg = await HttpUtil.get('/panel/api/inbounds/list');
@@ -1165,7 +1188,11 @@
setInbounds(dbInbounds) {
this.inbounds.splice(0);
this.dbInbounds.splice(0);
this.clientCount.splice(0);
// Drop every existing key — Vue.delete keeps it reactive so any
// template expression watching clientCount[id] re-renders cleanly.
for (const key of Object.keys(this.clientCount)) {
this.$delete(this.clientCount, key);
}
for (const inbound of dbInbounds) {
const dbInbound = new DBInbound(inbound);
to_inbound = dbInbound.toInbound()
@@ -1176,7 +1203,9 @@
if (dbInbound.isSS && (!to_inbound.isSSMultiUser)) {
continue;
}
this.clientCount[inbound.id] = this.getClientCounts(inbound, to_inbound);
// Reactive add — direct assignment on the map would not trigger
// template updates in Vue 2.
this.$set(this.clientCount, inbound.id, this.getClientCounts(inbound, to_inbound));
}
}
if (!this.loadingStates.fetched) {
@@ -1681,39 +1710,29 @@
newDbInbound = this.checkFallback(dbInbound);
infoModal.show(newDbInbound, index);
},
switchEnable(dbInboundId, state) {
let dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
// switchEnable toggles inbound.enable through a dedicated lightweight
// endpoint. The previous implementation re-submitted the entire
// inbound settings JSON (every client) just to flip a boolean — on a
// 7000+ client inbound that meant a multi-MB request, an O(N) traffic
// diff and a full xray-config rebuild for every click of the switch.
async switchEnable(dbInboundId, state) {
const dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
if (!dbInbound) return;
dbInbound.enable = state;
let inbound = dbInbound.toInbound();
const data = {
up: dbInbound.up,
down: dbInbound.down,
total: dbInbound.total,
remark: dbInbound.remark,
enable: dbInbound.enable,
expiryTime: dbInbound.expiryTime,
trafficReset: dbInbound.trafficReset,
lastTrafficResetTime: dbInbound.lastTrafficResetTime,
listen: inbound.listen,
port: inbound.port,
protocol: inbound.protocol,
settings: inbound.settings.toString(),
};
if (inbound.canEnableStream()) {
data.streamSettings = inbound.stream.toString();
} else if (inbound.stream?.sockopt) {
data.streamSettings = JSON.stringify({
sockopt: inbound.stream.sockopt.toJson()
}, null, 2);
}
data.sniffing = inbound.sniffing.toString();
const previous = dbInbound.enable;
dbInbound.enable = state; // optimistic: UI reflects the click immediately
const formData = new FormData();
Object.keys(data).forEach(key => formData.append(key, data[key]));
formData.append('enable', String(state));
this.submit(`/panel/api/inbounds/update/${dbInboundId}`, formData);
try {
const msg = await HttpUtil.post(`/panel/api/inbounds/setEnable/${dbInboundId}`, formData);
if (!msg || !msg.success) {
dbInbound.enable = previous;
}
} catch (e) {
dbInbound.enable = previous;
}
},
async switchEnableClient(dbInboundId, client, state) {
this.loading();
@@ -1796,15 +1815,18 @@
isExpiry(dbInbound, index) {
return dbInbound.toInbound().isExpiry(index);
},
// getClientStats returns the cached email→clientStat lookup for an
// inbound, building it lazily. The cache is invalidated when the
// underlying clientStats array reference changes (full re-fetch),
// so delta updates and post-refetch lookups never see stale entries.
// This is the single source of truth — applyClientStatsDelta uses it too.
getClientStats(dbInbound, email) {
if (!dbInbound) return null;
if (!dbInbound._clientStatsMap) {
dbInbound._clientStatsMap = new Map();
if (dbInbound.clientStats && Array.isArray(dbInbound.clientStats)) {
for (const stats of dbInbound.clientStats) {
dbInbound._clientStatsMap.set(stats.email, stats);
}
}
if (!dbInbound || !Array.isArray(dbInbound.clientStats)) return null;
if (!dbInbound._clientStatsMap || dbInbound._clientStatsMapSrc !== dbInbound.clientStats) {
const map = new Map();
for (const cs of dbInbound.clientStats) map.set(cs.email, cs);
dbInbound._clientStatsMap = map;
dbInbound._clientStatsMapSrc = dbInbound.clientStats;
}
return dbInbound._clientStatsMap.get(email);
},
@@ -1825,9 +1847,15 @@
},
getAllTimeClient(dbInbound, email) {
if (!email || email.length == 0) return 0;
let clientStats = this.getClientStats(dbInbound, email);
const clientStats = this.getClientStats(dbInbound, email);
if (!clientStats) return 0;
return clientStats.allTime || (clientStats.up + clientStats.down);
// allTime represents cumulative historical usage and must never
// appear smaller than the currently-tracked counters. If a stale
// row drifts below up+down (manual edits, partial migrations) we
// surface the live total instead of the misleading historical one.
const current = (clientStats.up || 0) + (clientStats.down || 0);
const allTime = clientStats.allTime || 0;
return allTime > current ? allTime : current;
},
getRemStats(dbInbound, email) {
if (!email || email.length == 0) return 0;
@@ -2039,13 +2067,18 @@
this.loading();
this.getDefaultSettings();
// Initial data fetch
// Bootstrap from REST first, then attach WebSocket subscriptions.
// Doing this in order eliminates a race where an early `inbounds` push
// fires getClientCounts() before this.onlineClients is populated,
// leaving online[] empty for every inbound and breaking the filter.
this.getDBInbounds().then(() => {
this.loading(false);
});
// Setup WebSocket for real-time updates
if (window.wsClient) {
if (!window.wsClient) {
// Fallback to polling if WebSocket is not available
if (this.isRefreshEnabled) this.startDataRefreshLoop();
return;
}
window.wsClient.connect();
// Listen for inbounds updates
@@ -2056,12 +2089,13 @@
}
});
// Listen for invalidate signals (sent when payload is too large for WebSocket)
// The server sends a lightweight notification and we re-fetch via REST API
// Listen for invalidate signals — last-resort safety only.
// Under normal operation the server pushes 'client_stats' deltas
// instead, so this fires only when an admin mutation produces an
// oversized full-list payload.
let invalidateTimer = null;
window.wsClient.on('invalidate', (payload) => {
if (payload && (payload.type === 'inbounds' || payload.type === 'traffic')) {
// Debounce to avoid flooding the REST API with multiple invalidate signals
if (invalidateTimer) clearTimeout(invalidateTimer);
invalidateTimer = setTimeout(() => {
invalidateTimer = null;
@@ -2070,15 +2104,36 @@
}
});
// Listen for traffic updates
window.wsClient.on('traffic', (payload) => {
// Note: Do NOT update total consumed traffic (stats.up, stats.down) from this event
// because clientTraffics contains delta/incremental values, not total accumulated values.
// Total traffic is updated via the 'inbounds' WebSocket event (or 'invalidate' fallback for large panels).
// Real-time delta updates: per-client absolute counters + inbound
// totals applied in-place. Replaces the periodic full-list refresh
// and scales to 10k+ clients without REST fallback.
window.wsClient.on('client_stats', (payload) => {
if (!payload) return;
this.applyClientStatsDelta(payload);
});
// Update online clients list in real-time
if (payload && Array.isArray(payload.onlineClients)) {
const nextOnlineClients = payload.onlineClients;
// Listen for traffic updates.
// Note: clientTraffics contains DELTA values (incremental since last
// tick), not absolute totals. Absolute counters are updated through
// the 'client_stats' event in applyClientStatsDelta.
window.wsClient.on('traffic', (payload) => {
if (!payload || typeof payload !== 'object') return;
// Normalize onlineClients: server marshals a nil []string slice as
// JSON null when nobody is online. Treat null/undefined/missing as
// an empty array so the "everyone went offline" transition still
// updates the UI — without this fix, the last set of online users
// stayed visible (and the online filter kept showing them) until
// someone came back online.
const hasOnlinePayload =
'onlineClients' in payload &&
(Array.isArray(payload.onlineClients) || payload.onlineClients == null);
if (hasOnlinePayload) {
const nextOnlineClients = Array.isArray(payload.onlineClients)
? payload.onlineClients
: [];
// Detect change in either direction: length differs OR sets differ.
let onlineChanged = this.onlineClients.length !== nextOnlineClients.length;
if (!onlineChanged) {
const prevSet = new Set(this.onlineClients);
@@ -2089,18 +2144,24 @@
}
}
}
this.onlineClients = nextOnlineClients;
if (onlineChanged) {
// Recalculate client counts to update online status
// Use $set for Vue 2 reactivity — direct array index assignment is not reactive
// Recompute clientCount for every inbound whose stats can host
// online clients. `dbInbound.toInbound()` returns the cached
// parsed Inbound (with the .clients array) — using it directly
// avoids a brittle `this.inbounds.find(ib => ib.id === ...)`
// lookup that ALWAYS failed because the Inbound class has no
// `id` field. That silent failure was the real cause of the
// online filter showing an empty list while a client was
// clearly online elsewhere on the page.
this.dbInbounds.forEach(dbInbound => {
const inbound = this.inbounds.find(ib => ib.id === dbInbound.id);
if (inbound && this.clientCount[dbInbound.id]) {
this.$set(this.clientCount, dbInbound.id, this.getClientCounts(dbInbound, inbound));
}
const inbound = dbInbound.toInbound();
this.$set(this.clientCount, dbInbound.id, this.getClientCounts(dbInbound, inbound));
});
// Always trigger UI refresh — not just when filter is enabled
// Re-run filter/search so the UI reflects the new state — both
// when clients come online and when they go offline.
if (this.enableFilter) {
this.filterInbounds();
} else {
@@ -2109,9 +2170,9 @@
}
}
// Update last online map in real-time
// Replace entirely (server sends the full map) to avoid unbounded growth from deleted clients
if (payload && payload.lastOnlineMap && typeof payload.lastOnlineMap === 'object') {
// Update last-online map. Server sends the full map (not delta) so
// we can replace entirely without growing unbounded from deleted clients.
if (payload.lastOnlineMap && typeof payload.lastOnlineMap === 'object') {
this.lastOnlineMap = payload.lastOnlineMap;
}
});
@@ -2132,12 +2193,7 @@
}
}
});
} else {
// Fallback to polling if WebSocket is not available
if (this.isRefreshEnabled) {
this.startDataRefreshLoop();
}
}
});
},
computed: {
total() {
@@ -2186,5 +2242,89 @@
left: 50vw !important;
}
}
/* Protocol cell — wrap tags into a flex grid with consistent gap so
vless/xhttp/Reality line up cleanly instead of stacking awkwardly. */
.inbounds-page .protocol-tags {
display: inline-flex;
flex-wrap: wrap;
gap: 4px;
align-items: center;
max-width: 100%;
}
.inbounds-page .protocol-tags .ant-tag {
margin: 0;
line-height: 20px;
}
/* Traffic / expiry cell — flex layout:
- Side text (.tr-table-rt / .tr-table-lt) sizes to content.
- Progress bar (.tr-table-bar) takes whatever's left and is allowed to
shrink (min-width: 0) so the cell never visually overflows its column,
no matter how long the surrounding values are ("999.99 GB", "365d").
A flex <div> replaces the previous <table>/<tr>/<td> hack — table layout
ignored width: 100% on the row, so the row grew to its content width and
pushed past the a-table column boundary. */
.inbounds-page .tr-table-box {
display: flex;
gap: 8px;
align-items: center;
width: 100%;
min-width: 0;
box-sizing: border-box;
padding: 2px 10px;
border-radius: 100px;
background: rgba(255, 255, 255, 0.04);
}
/* Fixed widths so the bar starts/ends at the same X position across all
rows — without this, "126.45 MB" and "0 B" pushed the bar to different
spots, which read as misalignment in the column. */
.inbounds-page .tr-table-rt,
.inbounds-page .tr-table-lt {
flex: 0 0 auto;
white-space: nowrap;
font-variant-numeric: tabular-nums;
overflow: hidden;
text-overflow: ellipsis;
}
.inbounds-page .tr-table-rt {
text-align: end;
flex-basis: 70px;
min-width: 70px;
}
.inbounds-page .tr-table-lt {
text-align: start;
flex-basis: 28px;
min-width: 28px;
}
.inbounds-page .tr-table-bar {
flex: 1 1 0;
min-width: 0;
overflow: hidden;
display: block;
}
/* Make the progress widget fill its flex cell, and align the inner fill
pill with the outer track pill (the "two pills" drift was caused by
box-sizing: content-box plus a 1px border on .ant-progress-bg). */
.inbounds-page .tr-table-bar .ant-progress,
.inbounds-page .tr-table-bar .ant-progress-outer,
.inbounds-page .tr-table-bar .ant-progress-inner {
display: block;
width: 100%;
margin: 0;
padding: 0;
}
.inbounds-page .infinite-bar .ant-progress-inner,
.inbounds-page .tr-table-bar .ant-progress-inner {
box-sizing: border-box;
border-radius: 100px;
overflow: hidden;
}
.inbounds-page .infinite-bar .ant-progress-inner .ant-progress-bg,
.inbounds-page .tr-table-bar .ant-progress-inner .ant-progress-bg {
box-sizing: border-box;
border: 0 !important;
}
</style>
{{ template "page/body_end" .}}

View File

@@ -1,8 +1,8 @@
{{define "settings/xray/outbounds"}}
<a-space direction="vertical" size="middle">
<a-row>
<a-col :xs="12" :sm="12" :lg="12">
<a-space direction="horizontal" size="small">
<a-space direction="vertical" size="middle" class="outbounds-modern">
<a-row :gutter="[12, 12]" align="middle" justify="space-between">
<a-col :xs="24" :sm="14" :lg="14">
<a-space direction="horizontal" size="small" class="outbounds-toolbar">
<a-button type="primary" icon="plus" @click="addOutbound">
<span v-if="!isMobile">{{ i18n
"pages.xray.outbound.addOutbound" }}</span>
@@ -11,7 +11,7 @@
<a-button type="primary" icon="api" @click="showNord()">NordVPN</a-button>
</a-space>
</a-col>
<a-col :xs="12" :sm="12" :lg="12" :style="{ textAlign: 'right' }">
<a-col :xs="24" :sm="10" :lg="10" class="outbounds-toolbar-right">
<a-button-group>
<a-button icon="sync" @click="refreshOutboundTraffic()" :loading="refreshing"></a-button>
<a-popconfirm placement="topRight" @confirm="resetOutboundTraffic(-1)"
@@ -19,92 +19,141 @@
:overlay-class-name="themeSwitcher.currentTheme" ok-text='{{ i18n "reset"}}'
cancel-text='{{ i18n "cancel"}}'>
<a-icon slot="icon" type="question-circle-o"
:style="{ color: themeSwitcher.isDarkTheme ? '#008771' : '#008771' }"></a-icon>
:style="{ color: '#008771' }"></a-icon>
<a-button icon="retweet"></a-button>
</a-popconfirm>
</a-button-group>
</a-col>
</a-row>
<a-table :columns="outboundColumns" bordered :row-key="r => r.key" :data-source="outboundData"
:scroll="isMobile ? {} : { x: 800 }" :pagination="false" :indent-size="0"
<a-table :columns="outboundColumns" :row-key="r => r.key"
:data-source="outboundData"
:scroll="isMobile ? { x: 720 } : {}"
:pagination="false"
:indent-size="0"
class="outbounds-table"
:locale='{ filterConfirm: `{{ i18n "confirm" }}`, filterReset: `{{ i18n "reset" }}` }'>
<template slot="action" slot-scope="text, outbound, index">
<span>[[ index+1 ]]</span>
<a-dropdown :trigger="['click']">
<a-icon @click="e => e.preventDefault()" type="more"
:style="{ fontSize: '16px', textDecoration: 'bold' }"></a-icon>
<a-menu slot="overlay" :theme="themeSwitcher.currentTheme">
<a-menu-item v-if="index>0" @click="setFirstOutbound(index)">
<a-icon type="vertical-align-top"></a-icon>
<span>{{ i18n "pages.xray.rules.first"}}</span>
</a-menu-item>
<a-menu-item @click="editOutbound(index)">
<a-icon type="edit"></a-icon>
<span>{{ i18n "edit" }}</span>
</a-menu-item>
<a-menu-item @click="resetOutboundTraffic(index)">
<span>
<div class="outbound-action-cell">
<span class="outbound-index">[[ index+1 ]]</span>
<a-dropdown :trigger="['click']">
<a-button shape="circle" size="small" class="outbound-action-btn"
@click="e => e.preventDefault()">
<a-icon type="more"></a-icon>
</a-button>
<a-menu slot="overlay" :theme="themeSwitcher.currentTheme">
<a-menu-item v-if="index>0"
@click="setFirstOutbound(index)">
<a-icon type="vertical-align-top"></a-icon>
<span>{{ i18n "pages.xray.rules.first"}}</span>
</a-menu-item>
<a-menu-item @click="editOutbound(index)">
<a-icon type="edit"></a-icon>
<span>{{ i18n "edit" }}</span>
</a-menu-item>
<a-menu-item @click="resetOutboundTraffic(index)">
<a-icon type="retweet"></a-icon>
<span>{{ i18n "pages.inbounds.resetTraffic"}}</span>
</a-menu-item>
<a-menu-item @click="deleteOutbound(index)">
<span :style="{ color: '#FF4D4F' }">
<a-icon type="delete"></a-icon>
<span>{{ i18n "delete"}}</span>
</span>
</a-menu-item>
</a-menu>
</a-dropdown>
</div>
</template>
<template slot="identity" slot-scope="text, outbound">
<div class="outbound-identity-cell">
<a-tooltip :title="outbound.tag" :overlay-class-name="themeSwitcher.currentTheme">
<span class="outbound-tag">[[ outbound.tag ]]</span>
</a-tooltip>
<div class="outbound-protocol-cell">
<span class="outbound-pill"
:class="outboundProtocolTone(outbound.protocol)">
[[ outbound.protocol ]]
</span>
<template
v-if="[Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks].includes(outbound.protocol)">
<span class="outbound-pill"
:class="outboundNetworkTone(outbound.streamSettings.network)">
[[ outbound.streamSettings.network ]]
</span>
</a-menu-item>
<a-menu-item @click="deleteOutbound(index)">
<span :style="{ color: '#FF4D4F' }">
<a-icon type="delete"></a-icon>
<span>{{ i18n "delete"}}</span>
<span class="outbound-pill"
:class="outboundSecurityTone(outbound.streamSettings.security)"
v-if="isOutboundSecurityVisible(outbound.streamSettings.security)">
[[ outbound.streamSettings.security ]]
</span>
</a-menu-item>
</a-menu>
</a-dropdown>
</template>
</div>
</div>
</template>
<template slot="address" slot-scope="text, outbound, index">
<p :style="{ margin: '0 5px' }" v-for="addr in findOutboundAddress(outbound)">[[ addr ]]</p>
<template slot="address" slot-scope="text, outbound">
<div class="outbound-address-list">
<a-tooltip
v-for="addr in outboundAddresses(outbound)"
:key="addr"
:title="addr"
:overlay-class-name="themeSwitcher.currentTheme">
<span class="outbound-address-pill">[[ addr ]]</span>
</a-tooltip>
<span class="outbound-address-empty"
v-if="outboundAddresses(outbound).length === 0"></span>
</div>
</template>
<template slot="protocol" slot-scope="text, outbound, index">
<a-tag :style="{ margin: '0' }" color="purple">[[ outbound.protocol
]]</a-tag>
<template
v-if="[Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks].includes(outbound.protocol)">
<a-tag :style="{ margin: '0' }" color="blue">[[
outbound.streamSettings.network ]]</a-tag>
<a-tag :style="{ margin: '0' }" v-if="outbound.streamSettings.security=='tls'" color="green">tls</a-tag>
<a-tag :style="{ margin: '0' }" v-if="outbound.streamSettings.security=='reality'"
color="green">reality</a-tag>
</template>
</template>
<template slot="traffic" slot-scope="text, outbound, index">
<a-tag color="green">[[ findOutboundTraffic(outbound) ]]</a-tag>
<template slot="traffic" slot-scope="text, outbound">
<div class="outbound-traffic-cell">
<span class="outbound-traffic-up" :title='`{{ i18n "pages.index.upload" }}`'>
<a-icon type="arrow-up"></a-icon>
[[ SizeFormatter.sizeFormat(findOutboundUp(outbound)) ]]
</span>
<span class="outbound-traffic-sep" aria-hidden="true"></span>
<span class="outbound-traffic-down" :title='`{{ i18n "pages.index.download" }}`'>
<a-icon type="arrow-down"></a-icon>
[[ SizeFormatter.sizeFormat(findOutboundDown(outbound)) ]]
</span>
</div>
</template>
<template slot="test" slot-scope="text, outbound, index">
<a-tooltip>
<template slot="title">{{ i18n "pages.xray.outbound.test"
}}</template>
<a-button type="primary" shape="circle" icon="thunderbolt"
:loading="outboundTestStates[index] && outboundTestStates[index].testing"
<template slot="title">{{ i18n "pages.xray.outbound.test" }}</template>
<a-button
type="primary"
shape="circle"
icon="thunderbolt"
class="outbound-test-btn"
:loading="isOutboundTesting(index)"
@click="testOutbound(index)"
:disabled="(outbound.protocol === 'blackhole' || outbound.tag === 'blocked') || (outboundTestStates[index] && outboundTestStates[index].testing)">
:disabled="isOutboundUntestable(outbound) || isOutboundTesting(index)">
</a-button>
</a-tooltip>
</template>
<template slot="testResult" slot-scope="text, outbound, index">
<div v-if="outboundTestStates[index] && outboundTestStates[index].result">
<a-tag v-if="outboundTestStates[index].result.success" color="green">
[[ outboundTestStates[index].result.delay ]]ms
<span v-if="outboundTestStates[index].result.statusCode">
([[ outboundTestStates[index].result.statusCode
]])</span>
</a-tag>
<a-tooltip v-else :title="outboundTestStates[index].result.error">
<a-tag color="red">
Failed
</a-tag>
<div class="outbound-result-cell" v-if="outboundTestResult(index)">
<span v-if="outboundTestResult(index).success"
class="outbound-result-pill outbound-result-ok">
<a-icon type="check-circle" theme="filled"></a-icon>
[[ outboundTestResult(index).delay ]]&nbsp;ms
<span class="outbound-result-status"
v-if="outboundTestResult(index).statusCode">
· [[ outboundTestResult(index).statusCode ]]
</span>
</span>
<a-tooltip v-else
:title="outboundTestResult(index).error"
:overlay-class-name="themeSwitcher.currentTheme">
<span class="outbound-result-pill outbound-result-fail">
<a-icon type="close-circle" theme="filled"></a-icon>
{{ i18n "pages.xray.outbound.testFailed" }}
</span>
</a-tooltip>
</div>
<span v-else-if="outboundTestStates[index] && outboundTestStates[index].testing">
<a-icon type="loading" />
<span class="outbound-result-loading" v-else-if="isOutboundTesting(index)">
<a-icon type="loading"></a-icon>
</span>
<span v-else>-</span>
<span class="outbound-result-idle" v-else></span>
</template>
</a-table>
</a-space>
{{end}}
{{end}}

View File

@@ -1,123 +1,193 @@
{{define "settings/xray/routing"}}
<a-space direction="vertical" size="middle">
<a-button type="primary" icon="plus" @click="addRule">{{ i18n "pages.xray.rules.add" }}</a-button>
<a-table-sortable :columns="isMobile ? rulesMobileColumns : rulesColumns" bordered :row-key="r => r.key"
:data-source="routingRuleData" :scroll="isMobile ? {} : { x: 1000 }" :pagination="false" :indent-size="0"
<a-space direction="vertical" size="middle" class="routing-modern">
<a-button type="primary" icon="plus" @click="addRule">{{ i18n
"pages.xray.rules.add" }}</a-button>
<a-table-sortable :columns="isMobile ? rulesMobileColumns : rulesColumns"
:row-key="r => r.key"
:data-source="routingRuleData"
:scroll="{}"
:pagination="false"
:indent-size="0"
class="routing-table"
v-on:onSort="replaceRule">
<template slot="action" slot-scope="text, rule, index">
<a-table-sort-trigger :item-index="index"></a-table-sort-trigger>
<span class="ant-table-row-index"> [[ index+1 ]] </span>
<a-dropdown :trigger="['click']">
<a-icon @click="e => e.preventDefault()" type="more"
:style="{ fontSize: '16px', textDecoration: 'bold' }"></a-icon>
<a-menu slot="overlay" :theme="themeSwitcher.currentTheme">
<a-menu-item v-if="index>0" @click="replaceRule(index,0)">
<a-icon type="vertical-align-top"></a-icon>
{{ i18n "pages.xray.rules.first"}}
</a-menu-item>
<a-menu-item v-if="index>0" @click="replaceRule(index,index-1)">
<a-icon type="arrow-up"></a-icon>
{{ i18n "pages.xray.rules.up"}}
</a-menu-item>
<a-menu-item v-if="index<routingRuleData.length-1" @click="replaceRule(index,index+1)">
<a-icon type="arrow-down"></a-icon>
{{ i18n "pages.xray.rules.down"}}
</a-menu-item>
<a-menu-item v-if="index<routingRuleData.length-1"
@click="replaceRule(index,routingRuleData.length-1)">
<a-icon type="vertical-align-bottom"></a-icon>
{{ i18n "pages.xray.rules.last"}}
</a-menu-item>
<a-menu-item @click="editRule(index)">
<a-icon type="edit"></a-icon>
{{ i18n "edit" }}
</a-menu-item>
<a-menu-item @click="deleteRule(index)">
<span :style="{ color: '#FF4D4F' }">
<a-icon type="delete"></a-icon> {{ i18n "delete"}}
</span>
</a-menu-item>
</a-menu>
</a-dropdown>
<div class="routing-action-cell">
<a-table-sort-trigger :item-index="index"></a-table-sort-trigger>
<span class="routing-index">[[ index+1 ]]</span>
<a-dropdown :trigger="['click']">
<a-button shape="circle" size="small" class="routing-action-btn"
@click="e => e.preventDefault()">
<a-icon type="more"></a-icon>
</a-button>
<a-menu slot="overlay" :theme="themeSwitcher.currentTheme">
<a-menu-item @click="editRule(index)">
<a-icon type="edit"></a-icon>
<span>{{ i18n "edit" }}</span>
</a-menu-item>
<a-menu-item @click="deleteRule(index)">
<span :style="{ color: '#FF4D4F' }">
<a-icon type="delete"></a-icon>
<span>{{ i18n "delete" }}</span>
</span>
</a-menu-item>
</a-menu>
</a-dropdown>
</div>
</template>
<template slot="inbound" slot-scope="text, rule, index">
<a-popover :overlay-class-name="themeSwitcher.currentTheme">
<template slot="content">
<p v-if="rule.inboundTag">Inbound Tag: [[ rule.inboundTag ]]</p>
<p v-if="rule.user">User email: [[ rule.user ]]</p>
</template>
[[ [rule.inboundTag,rule.user].join('\n') ]]
</a-popover>
<template slot="source" slot-scope="text, rule">
<div class="criterion-flow">
<a-tooltip v-if="rule.sourceIP"
:title="'Source IP: ' + joinCsv(rule.sourceIP)"
:overlay-class-name="themeSwitcher.currentTheme"
:trigger="['hover', 'click']">
<span class="criterion-row">
<span class="criterion-label">IP</span>
<span class="criterion-value">[[ csv(rule.sourceIP)[0] ]]</span>
<span v-if="csv(rule.sourceIP).length > 1" class="criterion-more">+[[ csv(rule.sourceIP).length - 1 ]]</span>
</span>
</a-tooltip>
<a-tooltip v-if="rule.sourcePort"
:title="'Source Port: ' + joinCsv(rule.sourcePort)"
:overlay-class-name="themeSwitcher.currentTheme"
:trigger="['hover', 'click']">
<span class="criterion-row">
<span class="criterion-label">Port</span>
<span class="criterion-value">[[ csv(rule.sourcePort)[0] ]]</span>
<span v-if="csv(rule.sourcePort).length > 1" class="criterion-more">+[[ csv(rule.sourcePort).length - 1 ]]</span>
</span>
</a-tooltip>
<a-tooltip v-if="rule.vlessRoute"
:title="'VLESS Route: ' + joinCsv(rule.vlessRoute)"
:overlay-class-name="themeSwitcher.currentTheme"
:trigger="['hover', 'click']">
<span class="criterion-row">
<span class="criterion-label">VLESS</span>
<span class="criterion-value">[[ csv(rule.vlessRoute)[0] ]]</span>
<span v-if="csv(rule.vlessRoute).length > 1" class="criterion-more">+[[ csv(rule.vlessRoute).length - 1 ]]</span>
</span>
</a-tooltip>
<span class="routing-criteria-empty"
v-if="!rule.sourceIP && !rule.sourcePort && !rule.vlessRoute"></span>
</div>
</template>
<template slot="outbound" slot-scope="text, rule, index">
<a-popover :overlay-class-name="themeSwitcher.currentTheme">
<template slot="content">
<p v-if="rule.outboundTag">Outbound Tag: [[ rule.outboundTag ]]</p>
</template>
[[ rule.outboundTag ]]
</a-popover>
<template slot="network" slot-scope="text, rule">
<div class="criterion-flow">
<a-tooltip v-if="rule.network"
:title="'L4: ' + joinCsv(rule.network)"
:overlay-class-name="themeSwitcher.currentTheme"
:trigger="['hover', 'click']">
<span class="criterion-row">
<span class="criterion-label">L4</span>
<span class="criterion-value">[[ csv(rule.network)[0] ]]</span>
<span v-if="csv(rule.network).length > 1" class="criterion-more">+[[ csv(rule.network).length - 1 ]]</span>
</span>
</a-tooltip>
<a-tooltip v-if="rule.protocol"
:title="'Protocol: ' + joinCsv(rule.protocol)"
:overlay-class-name="themeSwitcher.currentTheme"
:trigger="['hover', 'click']">
<span class="criterion-row">
<span class="criterion-label">Protocol</span>
<span class="criterion-value">[[ csv(rule.protocol)[0] ]]</span>
<span v-if="csv(rule.protocol).length > 1" class="criterion-more">+[[ csv(rule.protocol).length - 1 ]]</span>
</span>
</a-tooltip>
<a-tooltip v-if="rule.attrs"
:title="'Attrs: ' + joinCsv(rule.attrs)"
:overlay-class-name="themeSwitcher.currentTheme"
:trigger="['hover', 'click']">
<span class="criterion-row">
<span class="criterion-label">Attrs</span>
<span class="criterion-value">[[ csv(rule.attrs)[0] ]]</span>
<span v-if="csv(rule.attrs).length > 1" class="criterion-more">+[[ csv(rule.attrs).length - 1 ]]</span>
</span>
</a-tooltip>
<span class="routing-criteria-empty"
v-if="!rule.network && !rule.protocol && !rule.attrs"></span>
</div>
</template>
<template slot="balancer" slot-scope="text, rule, index">
<a-popover :overlay-class-name="themeSwitcher.currentTheme">
<template slot="content">
<p v-if="rule.balancerTag">Balancer Tag: [[ rule.balancerTag ]]</p>
</template>
[[ rule.balancerTag ]]
</a-popover>
<template slot="destination" slot-scope="text, rule">
<div class="criterion-flow">
<a-tooltip v-if="rule.ip"
:title="'Destination IP: ' + joinCsv(rule.ip)"
:overlay-class-name="themeSwitcher.currentTheme"
:trigger="['hover', 'click']">
<span class="criterion-row">
<span class="criterion-label">IP</span>
<span class="criterion-value">[[ csv(rule.ip)[0] ]]</span>
<span v-if="csv(rule.ip).length > 1" class="criterion-more">+[[ csv(rule.ip).length - 1 ]]</span>
</span>
</a-tooltip>
<a-tooltip v-if="rule.domain"
:title="'Domain: ' + joinCsv(rule.domain)"
:overlay-class-name="themeSwitcher.currentTheme"
:trigger="['hover', 'click']">
<span class="criterion-row">
<span class="criterion-label">Domain</span>
<span class="criterion-value">[[ csv(rule.domain)[0] ]]</span>
<span v-if="csv(rule.domain).length > 1" class="criterion-more">+[[ csv(rule.domain).length - 1 ]]</span>
</span>
</a-tooltip>
<a-tooltip v-if="rule.port"
:title="'Destination Port: ' + joinCsv(rule.port)"
:overlay-class-name="themeSwitcher.currentTheme"
:trigger="['hover', 'click']">
<span class="criterion-row">
<span class="criterion-label">Port</span>
<span class="criterion-value">[[ csv(rule.port)[0] ]]</span>
<span v-if="csv(rule.port).length > 1" class="criterion-more">+[[ csv(rule.port).length - 1 ]]</span>
</span>
</a-tooltip>
<span class="routing-criteria-empty"
v-if="!rule.ip && !rule.domain && !rule.port"></span>
</div>
</template>
<template slot="info" slot-scope="text, rule, index">
<a-popover placement="bottomRight"
v-if="(rule.sourceIP+rule.sourcePort+rule.vlessRoute+rule.network+rule.protocol+rule.attrs+rule.ip+rule.domain+rule.port).length>0"
:overlay-class-name="themeSwitcher.currentTheme" trigger="click">
<template slot="content">
<table cellpadding="2" :style="{ maxWidth: '300px' }">
<tr v-if="rule.sourceIP">
<td>Source IP</td>
<td><a-tag color="blue" v-for="r in rule.sourceIP.split(',')">[[ r ]]</a-tag></td>
</tr>
<tr v-if="rule.sourcePort">
<td>Source Port</td>
<td><a-tag color="green" v-for="r in rule.sourcePort.split(',')">[[ r ]]</a-tag></td>
</tr>
<tr v-if="rule.vlessRoute">
<td>VLESS Route</td>
<td><a-tag color="geekblue" v-for="r in rule.vlessRoute.split(',')">[[ r ]]</a-tag></td>
</tr>
<tr v-if="rule.network">
<td>Network</td>
<td><a-tag color="blue" v-for="r in rule.network.split(',')">[[ r ]]</a-tag></td>
</tr>
<tr v-if="rule.protocol">
<td>Protocol</td>
<td><a-tag color="green" v-for="r in rule.protocol.split(',')">[[ r ]]</a-tag></td>
</tr>
<tr v-if="rule.attrs">
<td>Attrs</td>
<td><a-tag color="blue" v-for="r in rule.attrs.split(',')">[[ r ]]</a-tag></td>
</tr>
<tr v-if="rule.ip">
<td>IP</td>
<td><a-tag color="green" v-for="r in rule.ip.split(',')">[[ r ]]</a-tag></td>
</tr>
<tr v-if="rule.domain">
<td>Domain</td>
<td><a-tag color="blue" v-for="r in rule.domain.split(',')">[[ r ]]</a-tag></td>
</tr>
<tr v-if="rule.port">
<td>Port</td>
<td><a-tag color="green" v-for="r in rule.port.split(',')">[[ r ]]</a-tag></td>
</tr>
<tr v-if="rule.balancerTag">
<td>Balancer Tag</td>
<td><a-tag color="blue">[[ rule.balancerTag ]]</a-tag></td>
</tr>
</table>
</template>
<a-button shape="round" size="small" :style="{ fontSize: '14px', padding: '0 10px' }">
<a-icon type="info"></a-icon>
</a-button>
</a-popover>
<template slot="inbound" slot-scope="text, rule">
<div class="criterion-flow">
<a-tooltip v-if="rule.inboundTag"
:title="'Inbound Tag: ' + joinCsv(rule.inboundTag)"
:overlay-class-name="themeSwitcher.currentTheme"
:trigger="['hover', 'click']">
<span class="criterion-row">
<span class="criterion-label">Tag</span>
<span class="criterion-value">[[ csv(rule.inboundTag)[0] ]]</span>
<span v-if="csv(rule.inboundTag).length > 1" class="criterion-more">+[[ csv(rule.inboundTag).length - 1 ]]</span>
</span>
</a-tooltip>
<a-tooltip v-if="rule.user"
:title="'Client: ' + joinCsv(rule.user)"
:overlay-class-name="themeSwitcher.currentTheme"
:trigger="['hover', 'click']">
<span class="criterion-row">
<span class="criterion-label">User</span>
<span class="criterion-value">[[ csv(rule.user)[0] ]]</span>
<span v-if="csv(rule.user).length > 1" class="criterion-more">+[[ csv(rule.user).length - 1 ]]</span>
</span>
</a-tooltip>
<span class="routing-criteria-empty"
v-if="!rule.inboundTag && !rule.user"></span>
</div>
</template>
<template slot="target" slot-scope="text, rule">
<div class="routing-target-cell">
<div class="routing-target-row" v-if="rule.outboundTag">
<a-icon type="export" class="routing-target-icon"></a-icon>
<span class="outbound-pill tone-emerald">[[ rule.outboundTag ]]</span>
</div>
<div class="routing-target-row" v-if="rule.balancerTag">
<a-icon type="cluster" class="routing-target-icon"></a-icon>
<span class="outbound-pill tone-violet">[[ rule.balancerTag ]]</span>
</div>
<span class="routing-criteria-empty"
v-if="!rule.outboundTag && !rule.balancerTag"></span>
</div>
</template>
</a-table-sortable>
</a-space>
{{end}}
{{end}}

View File

@@ -143,211 +143,45 @@
{{template "modals/warpModal" .}}
{{template "modals/nordModal" .}}
<script>
const rulesColumns = [{
title: "#",
align: 'center',
width: 15,
scopedSlots: {
customRender: 'action'
}
},
{
title: '{{ i18n "pages.xray.rules.source"}}',
children: [{
title: 'IP',
dataIndex: "sourceIP",
align: 'center',
width: 20,
ellipsis: true
},
{
title: '{{ i18n "pages.inbounds.port" }}',
dataIndex: 'sourcePort',
align: 'center',
width: 10,
ellipsis: true
},
{
title: 'VLESS Route',
dataIndex: 'vlessRoute',
align: 'center',
width: 15,
ellipsis: true
}
]
},
{
title: '{{ i18n "pages.inbounds.network"}}',
children: [{
title: 'L4',
dataIndex: 'network',
align: 'center',
width: 10
},
{
title: '{{ i18n "protocol" }}',
dataIndex: 'protocol',
align: 'center',
width: 15,
ellipsis: true
},
{
title: 'Attrs',
dataIndex: 'attrs',
align: 'center',
width: 10,
ellipsis: true
}
]
},
{
title: '{{ i18n "pages.xray.rules.dest"}}',
children: [{
title: 'IP',
dataIndex: 'ip',
align: 'center',
width: 20,
ellipsis: true
},
{
title: '{{ i18n "pages.xray.outbound.domain" }}',
dataIndex: 'domain',
align: 'center',
width: 20,
ellipsis: true
},
{
title: '{{ i18n "pages.inbounds.port" }}',
dataIndex: 'port',
align: 'center',
width: 10,
ellipsis: true
}
]
},
{
title: '{{ i18n "pages.xray.rules.inbound"}}',
children: [{
title: '{{ i18n "pages.xray.outbound.tag" }}',
dataIndex: 'inboundTag',
align: 'center',
width: 15,
ellipsis: true
},
{
title: '{{ i18n "pages.inbounds.client" }}',
dataIndex: 'user',
align: 'center',
width: 20,
ellipsis: true
}
]
},
{
title: '{{ i18n "pages.xray.rules.outbound"}}',
dataIndex: 'outboundTag',
align: 'center',
width: 17
},
{
title: '{{ i18n "pages.xray.rules.balancer"}}',
dataIndex: 'balancerTag',
align: 'center',
width: 15
},
// Modernised rules layout — 6 cells (#, source, network, destination,
// inbound, target). Each criterion renders as a single self-labelled
// pill that shows the first value plus a "+N" remainder badge for the
// rest; the full list is surfaced via tooltip on hover. The destination
// column has no fixed width and absorbs leftover horizontal space so the
// table fits typical viewports without a horizontal scrollbar.
const rulesColumns = [
{ title: '#', align: 'center', width: 70, scopedSlots: { customRender: 'action' } },
{ title: '{{ i18n "pages.xray.rules.source"}}', align: 'left', width: 180, scopedSlots: { customRender: 'source' } },
{ title: '{{ i18n "pages.inbounds.network"}}', align: 'left', width: 180, scopedSlots: { customRender: 'network' } },
{ title: '{{ i18n "pages.xray.rules.dest"}}', align: 'left', scopedSlots: { customRender: 'destination' } },
{ title: '{{ i18n "pages.xray.rules.inbound"}}', align: 'left', width: 180, scopedSlots: { customRender: 'inbound' } },
{ title: '{{ i18n "pages.xray.rules.outbound"}}', align: 'left', width: 170, scopedSlots: { customRender: 'target' } },
];
const rulesMobileColumns = [{
title: "#",
align: 'center',
width: 20,
scopedSlots: {
customRender: 'action'
}
},
{
title: '{{ i18n "pages.xray.rules.inbound"}}',
align: 'center',
width: 50,
ellipsis: true,
scopedSlots: {
customRender: 'inbound'
}
},
{
title: '{{ i18n "pages.xray.rules.outbound"}}',
align: 'center',
width: 50,
ellipsis: true,
scopedSlots: {
customRender: 'outbound'
}
},
{
title: '{{ i18n "pages.xray.rules.info"}}',
align: 'center',
width: 50,
ellipsis: true,
scopedSlots: {
customRender: 'info'
}
},
// Mobile: 3-column table — #, Inbound, Outbound. Source / Network /
// Destination criteria are dropped to keep the table readable on
// narrow viewports. Users see the rule's identity (Inbound) and
// what it does (Outbound) at a glance; full criteria are accessible
// by tapping Edit in the actions menu.
// # column is wider than desktop (110 vs 70) to fit the touch-friendly
// drag handle (padding: 6px → ~28px) alongside the index and dropdown.
const rulesMobileColumns = [
{ title: '#', align: 'center', width: 110, scopedSlots: { customRender: 'action' } },
{ title: '{{ i18n "pages.xray.rules.inbound"}}', align: 'left', scopedSlots: { customRender: 'inbound' } },
{ title: '{{ i18n "pages.xray.rules.outbound"}}', align: 'left', width: 140, scopedSlots: { customRender: 'target' } },
];
const outboundColumns = [{
title: "#",
align: 'center',
width: 60,
scopedSlots: {
customRender: 'action'
}
},
{
title: '{{ i18n "pages.xray.outbound.tag"}}',
dataIndex: 'tag',
align: 'center',
width: 50
},
{
title: '{{ i18n "protocol"}}',
align: 'center',
width: 50,
scopedSlots: {
customRender: 'protocol'
}
},
{
title: '{{ i18n "pages.xray.outbound.address"}}',
align: 'center',
width: 50,
scopedSlots: {
customRender: 'address'
}
},
{
title: '{{ i18n "pages.inbounds.traffic" }}',
align: 'center',
width: 50,
scopedSlots: {
customRender: 'traffic'
}
},
{
title: '{{ i18n "pages.xray.outbound.testResult" }}',
align: 'center',
width: 120,
scopedSlots: {
customRender: 'testResult'
}
},
{
title: '{{ i18n "pages.xray.outbound.test" }}',
align: 'center',
width: 60,
scopedSlots: {
customRender: 'test'
}
},
const outboundColumns = [
{ title: '#', align: 'center', width: 70, scopedSlots: { customRender: 'action' } },
// Combined "Tag / Protocol" — saves a column. Tag stays on top, protocol +
// network + security pills sit underneath it. Width chosen so the three
// longest tonal pills (e.g. vless + httpupgrade + reality) fit on a
// single line without wrapping.
{ title: '{{ i18n "pages.xray.outbound.tag"}}', align: 'left', width: 280, scopedSlots: { customRender: 'identity' } },
{ title: '{{ i18n "pages.xray.outbound.address"}}', align: 'left', scopedSlots: { customRender: 'address' } },
{ title: '{{ i18n "pages.inbounds.traffic" }}', align: 'left', width: 190, scopedSlots: { customRender: 'traffic' } },
{ title: '{{ i18n "pages.xray.outbound.testResult" }}', align: 'left', width: 130, scopedSlots: { customRender: 'testResult' } },
{ title: '{{ i18n "pages.xray.outbound.test" }}', align: 'center', width: 70, scopedSlots: { customRender: 'test' } },
];
const reverseColumns = [{
@@ -923,13 +757,64 @@
}
return true;
},
findOutboundTraffic(o) {
for (const otraffic of this.outboundsTraffic) {
if (otraffic.tag == o.tag) {
return `${SizeFormatter.sizeFormat(otraffic.up)} / ${SizeFormatter.sizeFormat(otraffic.down)}`
}
}
return `${SizeFormatter.sizeFormat(0)} / ${SizeFormatter.sizeFormat(0)}`
// outboundTrafficFor returns {up, down} for an outbound by tag,
// defaulting to zeros when no traffic row has been reported yet.
// Templates use the up/down accessors below — keeping the lookup in
// one place avoids drift if the data shape changes.
outboundTrafficFor(o) {
const t = this.outboundsTraffic.find(t => t.tag == o.tag);
return { up: t ? t.up : 0, down: t ? t.down : 0 };
},
findOutboundUp(o) { return this.outboundTrafficFor(o).up; },
findOutboundDown(o) { return this.outboundTrafficFor(o).down; },
// One tone per category instead of per-value. Adding a new protocol or
// transport inherits the category colour — no styling work required.
// Hierarchy: emerald (protocol — primary identity, matches brand) →
// slate (network — transport is plumbing, sits back) → violet (security —
// accent, only rendered for tls/reality so a stand-out hue is earned).
outboundProtocolTone() { return 'tone-emerald'; },
outboundNetworkTone() { return 'tone-slate'; },
outboundSecurityTone() { return 'tone-violet'; },
// Whether the security label is one we render as a pill in the table.
isOutboundSecurityVisible(security) {
return security === 'tls' || security === 'reality';
},
// Null-safe accessor for the address list — collapses null/undefined
// returns from findOutboundAddress() into an empty array so the template
// can rely on .length and v-for without extra guards.
outboundAddresses(o) {
return this.findOutboundAddress(o) || [];
},
// Test-state accessors — sparse arrays + per-row state make raw checks
// verbose; these helpers keep the template readable and consistent.
isOutboundTesting(index) {
const s = this.outboundTestStates[index];
return !!(s && s.testing);
},
outboundTestResult(index) {
const s = this.outboundTestStates[index];
return s ? s.result : null;
},
isOutboundUntestable(outbound) {
return outbound.protocol === 'blackhole' || outbound.tag === 'blocked';
},
// csv splits a comma-separated rule field into trimmed non-empty values.
// Routing rule data uses CSV strings for multi-value criteria (e.g.
// sourceIP "1.2.3.0/24,4.5.6.0/24"); the modern table renders each
// criterion as a single summary pill, so values are normally re-joined
// via joinCsv() but this helper is kept for callers that need an array.
csv(value) {
if (!value) return [];
return String(value)
.split(',')
.map(v => v.trim())
.filter(v => v.length > 0);
},
// joinCsv normalises a CSV-style rule field into a single comma-space
// separated string suitable for tooltips. Returns '' for empty inputs
// so v-if guards can short-circuit on the raw rule field.
joinCsv(value) {
return this.csv(value).join(', ');
},
findOutboundAddress(o) {
serverObj = null;
@@ -2136,4 +2021,421 @@
},
});
</script>
<style>
/* ───────── Modern outbounds table ─────────
Visual goals:
• flat surface, no inner cell borders, only subtle row dividers
• rounded pill badges for protocol / tag / addresses
• dual-arrow traffic widget that aligns across rows
• consistent hover/loading/result states
Scoped under .xray-page .outbounds-modern so it doesn't bleed into other tables. */
.xray-page .outbounds-modern { width: 100%; }
.xray-page .outbounds-toolbar-right { text-align: right; }
/* Table chrome */
.xray-page .outbounds-table .ant-table {
background: transparent;
border-radius: 14px;
overflow: hidden;
}
.xray-page .outbounds-table .ant-table-thead > tr > th {
background: rgba(255, 255, 255, 0.025);
color: rgba(255, 255, 255, 0.55);
font-weight: 500;
font-size: 12px;
letter-spacing: 0.04em;
text-transform: uppercase;
white-space: nowrap;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
padding: 14px 18px;
}
.light .xray-page .outbounds-table .ant-table-thead > tr > th {
background: rgba(0, 0, 0, 0.02);
color: rgba(0, 0, 0, 0.55);
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.xray-page .outbounds-table .ant-table-tbody > tr > td {
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
padding: 16px 18px;
transition: background-color 0.15s ease;
vertical-align: middle;
}
/* Force every cell to honour its column width — long content (especially
long tags) must clip via cell-level ellipsis instead of pushing the row
taller. */
.xray-page .outbounds-table .ant-table-tbody > tr > td,
.xray-page .outbounds-table .ant-table-thead > tr > th {
overflow: hidden;
}
.light .xray-page .outbounds-table .ant-table-tbody > tr > td {
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
}
.xray-page .outbounds-table .ant-table-tbody > tr:last-child > td {
border-bottom: none;
}
.xray-page .outbounds-table .ant-table-tbody > tr:hover > td {
background: rgba(255, 255, 255, 0.035) !important;
}
.light .xray-page .outbounds-table .ant-table-tbody > tr:hover > td {
background: rgba(0, 0, 0, 0.025) !important;
}
/* Index + actions column */
.xray-page .outbound-action-cell {
display: inline-flex;
align-items: center;
gap: 8px;
}
.xray-page .outbound-index {
font-weight: 600;
color: rgba(255, 255, 255, 0.7);
font-variant-numeric: tabular-nums;
min-width: 18px;
text-align: end;
}
.light .xray-page .outbound-index { color: rgba(0, 0, 0, 0.7); }
.xray-page .outbound-action-btn {
border: none;
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.75);
transition: background 0.15s ease;
}
.xray-page .outbound-action-btn:hover {
background: rgba(255, 255, 255, 0.12);
color: #fff;
}
.light .xray-page .outbound-action-btn {
background: rgba(0, 0, 0, 0.05);
color: rgba(0, 0, 0, 0.75);
}
.light .xray-page .outbound-action-btn:hover {
background: rgba(0, 0, 0, 0.1);
color: #000;
}
/* Identity cell — tag on top, protocol/network/security pills underneath.
Combining the two columns lets the table fit common viewports without
a horizontal scrollbar. */
.xray-page .outbound-identity-cell {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
/* Tag — inherits the table's font for visual parity, single line with
ellipsis on overflow. A long tag (e.g. "vless_jphttp-ksjpnggl") would
otherwise wrap and inflate the row's height; the inline tooltip surfaces
the full value on hover. */
.xray-page .outbound-tag {
font-size: 13px;
color: rgba(255, 255, 255, 0.92);
font-weight: 500;
display: block;
max-width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.light .xray-page .outbound-tag { color: rgba(0, 0, 0, 0.85); }
/* Address pills (monospace, monoline) */
.xray-page .outbound-address-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.xray-page .outbound-address-pill {
font-family: ui-monospace, SFMono-Regular, "JetBrains Mono", Menlo, monospace;
font-size: 12px;
padding: 3px 10px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.045);
color: rgba(255, 255, 255, 0.78);
line-height: 1.5;
border: 1px solid rgba(255, 255, 255, 0.06);
display: inline-block;
max-width: 240px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: middle;
}
.light .xray-page .outbound-address-pill {
background: rgba(0, 0, 0, 0.035);
color: rgba(0, 0, 0, 0.78);
border: 1px solid rgba(0, 0, 0, 0.06);
}
.xray-page .outbound-address-empty {
color: rgba(255, 255, 255, 0.3);
font-style: italic;
}
/* Protocol/network/tls pills — shared "outbound-pill" with tonal modifiers.
The pill row stays on a single line; if the column is somehow too narrow
for all pills it overflows out of view (rare — column width is sized to
fit the worst case) but never pushes the row taller. */
.xray-page .outbound-protocol-cell {
display: flex;
flex-wrap: nowrap;
gap: 6px;
align-items: center;
overflow: hidden;
}
.xray-page .outbound-pill {
display: inline-flex;
align-items: center;
min-height: 22px;
padding: 2px 9px;
border-radius: 11px;
font-size: 12px;
font-weight: 500;
line-height: 1.4;
letter-spacing: 0.01em;
border: 1px solid transparent;
white-space: nowrap;
flex: 0 0 auto;
}
/* Outbound pill tones: emerald = protocol, slate = network, violet = security.
tone-emerald and tone-violet are also consumed by routing.html for the
outboundTag / balancerTag pills. */
.xray-page .outbound-pill.tone-emerald { background: rgba(0, 191, 165, 0.14); color: #4dd4be; border-color: rgba(0, 191, 165, 0.28); }
.xray-page .outbound-pill.tone-slate { background: rgba(160, 174, 192, 0.14); color: #b8c2d0; border-color: rgba(160, 174, 192, 0.26); }
.xray-page .outbound-pill.tone-violet { background: rgba(155, 89, 219, 0.16); color: #b489e8; border-color: rgba(155, 89, 219, 0.32); }
/* Traffic — dual arrow widget, fixed columns so all rows align */
.xray-page .outbound-traffic-cell {
display: inline-grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 10px;
padding: 5px 12px;
border-radius: 100px;
background: rgba(255, 255, 255, 0.04);
font-variant-numeric: tabular-nums;
font-size: 13px;
min-width: 0;
}
.light .xray-page .outbound-traffic-cell {
background: rgba(0, 0, 0, 0.035);
}
.xray-page .outbound-traffic-up,
.xray-page .outbound-traffic-down {
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.xray-page .outbound-traffic-up { justify-content: flex-end; color: #4dd4be; }
.xray-page .outbound-traffic-down { justify-content: flex-start; color: #82a7ee; }
.xray-page .outbound-traffic-up .anticon,
.xray-page .outbound-traffic-down .anticon { font-size: 11px; }
.xray-page .outbound-traffic-sep {
width: 1px;
height: 14px;
background: rgba(255, 255, 255, 0.12);
border-radius: 1px;
}
.light .xray-page .outbound-traffic-sep { background: rgba(0, 0, 0, 0.12); }
/* Test result pills */
.xray-page .outbound-result-cell { display: inline-flex; }
.xray-page .outbound-result-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 100px;
font-size: 12px;
font-weight: 500;
font-variant-numeric: tabular-nums;
white-space: nowrap;
border: 1px solid transparent;
}
.xray-page .outbound-result-pill .anticon { font-size: 12px; }
.xray-page .outbound-result-ok {
background: rgba(0, 191, 165, 0.14);
color: #4dd4be;
border-color: rgba(0, 191, 165, 0.28);
}
.xray-page .outbound-result-fail {
background: rgba(255, 77, 79, 0.14);
color: #ff7a7c;
border-color: rgba(255, 77, 79, 0.32);
}
.xray-page .outbound-result-status { opacity: 0.75; }
.xray-page .outbound-result-loading,
.xray-page .outbound-result-idle {
color: rgba(255, 255, 255, 0.4);
font-size: 13px;
}
.light .xray-page .outbound-result-loading,
.light .xray-page .outbound-result-idle { color: rgba(0, 0, 0, 0.4); }
/* Test button — sleek circular with subtle glow */
.xray-page .outbound-test-btn {
box-shadow: 0 2px 8px rgba(0, 191, 165, 0.18);
transition: transform 0.12s ease, box-shadow 0.18s ease;
}
.xray-page .outbound-test-btn:hover:not([disabled]) {
transform: translateY(-1px);
box-shadow: 0 4px 14px rgba(0, 191, 165, 0.32);
}
.xray-page .outbound-test-btn[disabled] {
box-shadow: none;
opacity: 0.45;
}
/* ───────── Modern routing-rules table ─────────
Reuses the .outbound-pill tonal primitive (identical visual) so the
routing tab feels like the same panel as outbounds. Each cell groups
a routing criterion (Source / Network / Destination / Inbound) and
shows its values as labelled pills. */
.xray-page .routing-modern { width: 100%; }
.xray-page .routing-table .ant-table {
background: transparent;
border-radius: 14px;
overflow: hidden;
}
.xray-page .routing-table .ant-table-thead > tr > th {
background: rgba(255, 255, 255, 0.025);
color: rgba(255, 255, 255, 0.55);
font-weight: 500;
font-size: 12px;
letter-spacing: 0.04em;
text-transform: uppercase;
white-space: nowrap;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
padding: 14px 18px;
}
.light .xray-page .routing-table .ant-table-thead > tr > th {
background: rgba(0, 0, 0, 0.02);
color: rgba(0, 0, 0, 0.55);
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.xray-page .routing-table .ant-table-tbody > tr > td {
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
padding: 16px 18px;
transition: background-color 0.15s ease;
vertical-align: top;
}
.light .xray-page .routing-table .ant-table-tbody > tr > td {
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
}
.xray-page .routing-table .ant-table-tbody > tr:last-child > td {
border-bottom: none;
}
.xray-page .routing-table .ant-table-tbody > tr:hover > td {
background: rgba(255, 255, 255, 0.035) !important;
}
.light .xray-page .routing-table .ant-table-tbody > tr:hover > td {
background: rgba(0, 0, 0, 0.025) !important;
}
/* Sort handle / # / actions */
.xray-page .routing-action-cell {
display: inline-flex;
align-items: center;
gap: 8px;
}
.xray-page .routing-index {
font-weight: 600;
color: rgba(255, 255, 255, 0.7);
font-variant-numeric: tabular-nums;
min-width: 18px;
text-align: end;
}
.light .xray-page .routing-index { color: rgba(0, 0, 0, 0.7); }
.xray-page .routing-action-btn {
border: none;
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.75);
transition: background 0.15s ease;
}
.xray-page .routing-action-btn:hover {
background: rgba(255, 255, 255, 0.12);
color: #fff;
}
.light .xray-page .routing-action-btn {
background: rgba(0, 0, 0, 0.05);
color: rgba(0, 0, 0, 0.75);
}
.light .xray-page .routing-action-btn:hover {
background: rgba(0, 0, 0, 0.1);
color: #000;
}
/* Plain-text criterion rows — replaces pill primitives in condition
columns. Each criterion is a row of "label value (+N)" with form-label
styling on the label. No bg, no border, no color tones — keeps cells
light and lets the column header carry the type semantic. The cell's
visual weight is now proportional only to the data length, not to
decoration. The single colored pill in Outbound/Balancer remains as
the row's focal point. */
.xray-page .criterion-flow {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.xray-page .criterion-row {
display: flex;
align-items: baseline;
gap: 8px;
min-width: 0;
font-size: 13px;
line-height: 1.5;
}
.xray-page .criterion-label {
flex: 0 0 auto;
font-size: 11px;
color: rgba(255, 255, 255, 0.42);
font-weight: 400;
letter-spacing: 0;
text-transform: none;
}
.light .xray-page .criterion-label { color: rgba(0, 0, 0, 0.45); }
.xray-page .criterion-value {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: rgba(255, 255, 255, 0.85);
}
.light .xray-page .criterion-value { color: rgba(0, 0, 0, 0.85); }
.xray-page .criterion-more {
flex: 0 0 auto;
font-size: 11px;
color: rgba(255, 255, 255, 0.42);
font-weight: 500;
}
.light .xray-page .criterion-more { color: rgba(0, 0, 0, 0.45); }
.xray-page .routing-criteria-empty {
color: rgba(255, 255, 255, 0.3);
font-style: italic;
}
.light .xray-page .routing-criteria-empty { color: rgba(0, 0, 0, 0.3); }
/* Target cell (outbound / balancer) — vertically stacked rows of icon + pill */
.xray-page .routing-target-cell {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.xray-page .routing-target-row {
display: inline-flex;
align-items: center;
gap: 8px;
}
.xray-page .routing-target-icon {
color: rgba(255, 255, 255, 0.45);
font-size: 13px;
}
.light .xray-page .routing-target-icon { color: rgba(0, 0, 0, 0.45); }
</style>
{{ template "page/body_end" .}}