diff --git a/web/html/settings/xray/outbounds.html b/web/html/settings/xray/outbounds.html
index 232fe55e..ca2af35c 100644
--- a/web/html/settings/xray/outbounds.html
+++ b/web/html/settings/xray/outbounds.html
@@ -1,8 +1,8 @@
{{define "settings/xray/outbounds"}}
-
-
-
-
+
+
+
+
{{ i18n
"pages.xray.outbound.addOutbound" }}
@@ -13,7 +13,7 @@
@click="showNord()">NordVPN
-
+
@@ -24,106 +24,141 @@
ok-text='{{ i18n "reset"}}'
cancel-text='{{ i18n "cancel"}}'>
+ :style="{ color: '#008771' }">
-
- [[ index+1 ]]
-
- e.preventDefault()" type="more"
- :style="{ fontSize: '16px', textDecoration: 'bold' }">
-
-
-
- {{ i18n "pages.xray.rules.first"}}
-
-
-
- {{ i18n "edit" }}
-
-
-
+
+
[[ index+1 ]]
+
+ e.preventDefault()">
+
+
+
+
+
+ {{ i18n "pages.xray.rules.first"}}
+
+
+
+ {{ i18n "edit" }}
+
+
{{ i18n "pages.inbounds.resetTraffic"}}
+
+
+
+
+ {{ i18n "delete"}}
+
+
+
+
+
+
+
+
+
+ [[ outbound.tag ]]
+
+
+
+ [[ outbound.protocol ]]
+
+
+
+ [[ outbound.streamSettings.network ]]
-
-
-
-
- {{ i18n "delete"}}
+
+ [[ outbound.streamSettings.security ]]
-
-
-
+
+
+
-
- [[ addr ]]
+
+
-
- [[ outbound.protocol
- ]]
-
- [[
- outbound.streamSettings.network ]]
- tls
- reality
-
-
-
- [[ findOutboundTraffic(outbound) ]]
+
+
+
+
+ [[ SizeFormatter.sizeFormat(findOutboundUp(outbound)) ]]
+
+
+
+
+ [[ SizeFormatter.sizeFormat(findOutboundDown(outbound)) ]]
+
+
- {{ i18n "pages.xray.outbound.test"
- }}
+ {{ i18n "pages.xray.outbound.test" }}
+ :disabled="isOutboundUntestable(outbound) || isOutboundTesting(index)">
-
-
- [[ outboundTestStates[index].result.delay ]]ms
-
- ([[ outboundTestStates[index].result.statusCode
- ]])
-
+
+
+
+ [[ outboundTestResult(index).delay ]] ms
+
+ · [[ outboundTestResult(index).statusCode ]]
+
+
-
- Failed
-
+ :title="outboundTestResult(index).error"
+ :overlay-class-name="themeSwitcher.currentTheme">
+
+
+ {{ i18n "pages.xray.outbound.testFailed" }}
+
-
-
+
+
- -
+ —
-{{end}}
\ No newline at end of file
+{{end}}
diff --git a/web/html/xray.html b/web/html/xray.html
index 3dfbdd33..cc3a505a 100644
--- a/web/html/xray.html
+++ b/web/html/xray.html
@@ -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 @@
},
});
+
{{ template "page/body_end" .}}
\ No newline at end of file