diff options
| author | lolka1333 <xtrafcyz@gmail.com> | 2026-05-05 18:27:49 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-05-05 18:27:49 +0300 |
| commit | 8177f6dc667f2edca66073e433baf5cff36cda41 (patch) | |
| tree | 7fddbec4848c3c0ab0fc39e3dc45703c368e9340 /web/html | |
| parent | 77d94b25d054bd6cf7ace029571db9c58ae87fa9 (diff) | |
* 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>
Diffstat (limited to 'web/html')
| -rw-r--r-- | web/html/component/aClientTable.html | 54 | ||||
| -rw-r--r-- | web/html/component/aTableSortable.html | 424 | ||||
| -rw-r--r-- | web/html/inbounds.html | 410 | ||||
| -rw-r--r-- | web/html/settings/xray/outbounds.html | 183 | ||||
| -rw-r--r-- | web/html/settings/xray/routing.html | 296 | ||||
| -rw-r--r-- | web/html/xray.html | 718 |
6 files changed, 1351 insertions, 734 deletions
diff --git a/web/html/component/aClientTable.html b/web/html/component/aClientTable.html index 6e525396..977638f2 100644 --- a/web/html/component/aClientTable.html +++ b/web/html/component/aClientTable.html @@ -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">∞</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">∞</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> diff --git a/web/html/component/aTableSortable.html b/web/html/component/aTableSortable.html index 925adbb5..fdbc247e 100644 --- a/web/html/component/aTableSortable.html +++ b/web/html/component/aTableSortable.html @@ -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; - }, - resetSortableIndex(e, index) { - this.sortingElementIndex = null; - this.newElementIndex = null; - this.$memoSort = {}; - }, - setCurrentSortableIndex(e, index) { - this.sortingElementIndex = index; + 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; }, - dragStartHandler(e, index) { - if (!this.isDragging()) { - e.preventDefault(); - return; + startDrag(e, sourceIndex) { + // Primary button only (mouse left / first touch). + if (e.button != null && e.button !== 0) return; + e.preventDefault(); + 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 hideDragImage = this.$el.cloneNode(true); - hideDragImage.id = "hideDragImage-hide"; - hideDragImage.style.opacity = 0; - e.dataTransfer.setDragImage(hideDragImage, 0, 0); + this.attachPointerListeners(); }, - dragStopHandler(e, index) { - const hideDragImage = document.getElementById('hideDragImage-hide'); - if (hideDragImage) hideDragImage.remove(); - this.resetSortableIndex(e, index); + 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); }, - dragOverHandler(e, index) { - if (!this.isDragging()) { - return; - } - e.preventDefault(); - const currentIndex = this.sortingElementIndex; - if (index === currentIndex) { - this.newElementIndex = null; - return; - } - 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); + 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; } - - .ant-table-is-sorting .draggable-row td { - background-color: #ffffff !important; + .sortable-icon:hover { + color: rgba(255, 255, 255, 0.85); + background: rgba(255, 255, 255, 0.06); } - - .dark .ant-table-is-sorting .draggable-row td { - background-color: var(--dark-color-surface-100) !important; + .sortable-icon:active { cursor: grabbing; } + .sortable-icon:focus-visible { + outline: 2px solid #008771; + outline-offset: 2px; } - .ant-table-is-sorting .dragging td { - background-color: rgb(232 244 242) !important; - color: rgba(0, 0, 0, 0.3); + .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 .dragging td { - background-color: var(--dark-color-table-hover) !important; - color: rgba(255, 255, 255, 0.3); + /* 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 { - opacity: 1; - box-shadow: 1px -2px 2px #008771; - transition: all 0.2s; + .sortable-table-dragging .sortable-source-row .routing-index, + .sortable-table-dragging .sortable-source-row .outbound-index { + opacity: 0.45; } - - .ant-table-is-sorting .dragging .ant-table-row-index { - opacity: 0.3; + .sortable-table-dragging .sortable-row > td { + transition: background-color 0.18s ease; + } + /* 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}}
\ No newline at end of file +{{end}} diff --git a/web/html/inbounds.html b/web/html/inbounds.html index cde66137..e0a80b2b 100644 --- a/web/html/inbounds.html +++ b/web/html/inbounds.html @@ -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 + // unde
|
