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

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