style(ui): redesign outbounds table for visual consistency

This commit is contained in:
lolka1333 2026-04-29 08:51:52 +02:00
parent 7792715dc4
commit 6166a406b3
2 changed files with 462 additions and 87 deletions

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>
@ -13,7 +13,7 @@
@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>
@ -24,106 +24,141 @@
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"
<a-table :columns="outboundColumns" :row-key="r => r.key"
:data-source="outboundData"
:scroll="isMobile ? {} : { x: 800 }" :pagination="false"
: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.inbounds.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.inbounds.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>
<template slot="title">{{ i18n "pages.xray.outbound.test" }}</template>
<a-button
type="primary"
shape="circle"
icon="thunderbolt"
:loading="outboundTestStates[index] && outboundTestStates[index].testing"
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>
<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="outboundTestStates[index].result.error">
<a-tag color="red">
Failed
</a-tag>
: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

@ -202,13 +202,16 @@
];
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: 180, 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' } },
{ 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 = [
@ -556,13 +559,79 @@
}
return true;
},
findOutboundTraffic(o) {
for (const otraffic of this.outboundsTraffic) {
if (otraffic.tag == o.tag) {
return `↑ ${SizeFormatter.sizeFormat(otraffic.up)} / ${SizeFormatter.sizeFormat(otraffic.down)} ↓`
}
// 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; },
// Visual class for the protocol pill — keeps the colour palette consistent
// across rows and matches the tone used elsewhere in the panel.
outboundProtocolTone(protocol) {
switch ((protocol || '').toLowerCase()) {
case 'freedom': return 'tone-emerald';
case 'blackhole': return 'tone-violet';
case 'wireguard': return 'tone-cyan';
case 'vmess': return 'tone-blue';
case 'vless': return 'tone-sky';
case 'trojan': return 'tone-amber';
case 'shadowsocks': return 'tone-pink';
case 'http':
case 'socks': return 'tone-slate';
case 'dns': return 'tone-indigo';
default: return 'tone-slate';
}
return `${SizeFormatter.sizeFormat(0)} / ${SizeFormatter.sizeFormat(0)}`
},
// Distinct palette for the transport (network) pill so it never collides
// with the protocol tone in the same row.
outboundNetworkTone(network) {
switch ((network || '').toLowerCase()) {
case 'tcp': return 'tone-slate';
case 'ws': return 'tone-cyan';
case 'xhttp': return 'tone-amber';
case 'h2':
case 'http': return 'tone-indigo';
case 'grpc': return 'tone-pink';
case 'quic': return 'tone-violet';
case 'kcp': return 'tone-emerald';
case 'httpupgrade': return 'tone-rose';
default: return 'tone-slate';
}
},
// TLS / reality / none tone — visually tied to the security model used.
outboundSecurityTone(security) {
switch ((security || '').toLowerCase()) {
case 'tls': return 'tone-emerald';
case 'reality': return 'tone-mint';
default: return 'tone-slate';
}
},
// 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';
},
findOutboundAddress(o) {
serverObj = null;
@ -1626,4 +1695,275 @@
},
});
</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 — monospace, single line, 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-family: ui-monospace, SFMono-Regular, "JetBrains Mono", Menlo, monospace;
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;
height: 22px;
padding: 0 9px;
border-radius: 11px;
font-size: 12px;
font-weight: 500;
line-height: 1;
letter-spacing: 0.01em;
border: 1px solid transparent;
white-space: nowrap;
flex: 0 0 auto;
}
.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-mint { background: rgba(72, 222, 128, 0.14); color: #7ee2a4; border-color: rgba(72, 222, 128, 0.30); }
.xray-page .outbound-pill.tone-violet { background: rgba(155, 89, 219, 0.16); color: #b489e8; border-color: rgba(155, 89, 219, 0.32); }
.xray-page .outbound-pill.tone-cyan { background: rgba(34, 184, 207, 0.16); color: #6ed3e3; border-color: rgba(34, 184, 207, 0.32); }
.xray-page .outbound-pill.tone-sky { background: rgba(56, 189, 248, 0.16); color: #7dd3fc; border-color: rgba(56, 189, 248, 0.32); }
.xray-page .outbound-pill.tone-blue { background: rgba(56, 116, 230, 0.16); color: #82a7ee; border-color: rgba(56, 116, 230, 0.32); }
.xray-page .outbound-pill.tone-amber { background: rgba(231, 154, 47, 0.16); color: #f0b56a; border-color: rgba(231, 154, 47, 0.32); }
.xray-page .outbound-pill.tone-pink { background: rgba(225, 79, 153, 0.16); color: #ec85b6; border-color: rgba(225, 79, 153, 0.32); }
.xray-page .outbound-pill.tone-rose { background: rgba(244, 63, 94, 0.14); color: #fb7185; border-color: rgba(244, 63, 94, 0.30); }
.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-indigo { background: rgba(99, 102, 241, 0.16); color: #9ea0ee; border-color: rgba(99, 102, 241, 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;
}
</style>
{{ template "page/body_end" .}}