mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-06 20:39:35 +00:00
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:
@@ -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" .}}
|
||||
Reference in New Issue
Block a user