diff --git a/frontend/src/pages/xray/outbounds/CountryPill.tsx b/frontend/src/pages/xray/outbounds/CountryPill.tsx new file mode 100644 index 000000000..992360468 --- /dev/null +++ b/frontend/src/pages/xray/outbounds/CountryPill.tsx @@ -0,0 +1,18 @@ +import { CloudOutlined } from '@ant-design/icons'; + +interface CountryPillProps { + flag: string; + name: string; + warp?: string; +} + +export default function CountryPill({ flag, name, warp }: CountryPillProps) { + const isWarp = !!warp && warp.toLowerCase() !== 'off'; + return ( + + {isWarp && } + {flag && {flag}} + {name} + + ); +} diff --git a/frontend/src/pages/xray/outbounds/OutboundCardList.tsx b/frontend/src/pages/xray/outbounds/OutboundCardList.tsx index 9c8cf308e..967bfae75 100644 --- a/frontend/src/pages/xray/outbounds/OutboundCardList.tsx +++ b/frontend/src/pages/xray/outbounds/OutboundCardList.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Button, Dropdown, Tag, Tooltip } from 'antd'; import { @@ -9,15 +10,21 @@ import { ThunderboltOutlined, LoadingOutlined, ExportOutlined, + EyeInvisibleOutlined, + EyeOutlined, } from '@ant-design/icons'; import { SizeFormatter } from '@/utils'; +import { activateOnKey } from '@/utils/a11y'; import { OutboundProtocols as Protocols } from '@/schemas/primitives'; import type { OutboundTestMode, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting'; import type { OutboundRow } from './outbounds-tab-types'; +import CountryPill from './CountryPill'; import TestResultPopover from './TestResultPopover'; import { + countryFlag, + countryName, isTesting, isUntestable, outboundAddresses, @@ -49,7 +56,55 @@ export default function OutboundCardList({ confirmDelete, onTest, }: OutboundCardListProps) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const [showEgressIp, setShowEgressIp] = useState>({}); + + const setCardEgressVisible = (key: string, visible: boolean) => { + setShowEgressIp((prev) => ({ ...prev, [key]: visible })); + }; + + const renderEgress = (index: number, rowKey: string) => { + const result = testResult(outboundTestStates, index); + const egress = result?.egress; + const isEgressVisible = !!showEgressIp[rowKey]; + const flag = countryFlag(egress?.country); + const name = countryName(egress?.country, i18n.language); + const addresses = [ + egress?.ipv4 ? { label: 'v4', value: egress.ipv4 } : null, + egress?.ipv6 ? { label: 'v6', value: egress.ipv6 } : null, + ].filter((item): item is { label: string; value: string } => Boolean(item)); + + if (!egress || (addresses.length === 0 && !egress.country)) { + return null; + } + + return ( +
+
+ {t('pages.xray.outbound.egress')}: + + {isEgressVisible ? ( + setCardEgressVisible(rowKey, false)} onKeyDown={activateOnKey(() => setCardEgressVisible(rowKey, false))} /> + ) : ( + setCardEgressVisible(rowKey, true)} onKeyDown={activateOnKey(() => setCardEgressVisible(rowKey, true))} /> + )} + + {egress.country && ( + + )} +
+ {addresses.map((addr) => ( + +
+ {addr.label}: + {addr.value} +
+
+ ))} +
+ ); + }; + if (rows.length === 0) { return (
@@ -101,6 +156,7 @@ export default function OutboundCardList({ ))}
)} + {renderEgress(index, String(record.key))}
↑ {SizeFormatter.sizeFormat(trafficFor(outboundsTraffic, record).up)} diff --git a/frontend/src/pages/xray/outbounds/OutboundsTab.css b/frontend/src/pages/xray/outbounds/OutboundsTab.css index 85e9e2315..abbe1c996 100644 --- a/frontend/src/pages/xray/outbounds/OutboundsTab.css +++ b/frontend/src/pages/xray/outbounds/OutboundsTab.css @@ -75,6 +75,81 @@ background: var(--ant-color-fill-tertiary); } +.egress-header { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.egress-ip { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 12px; +} + +.egress-stack { + display: inline-flex; + flex-direction: column; + gap: 3px; +} + +.egress-address { + display: inline-flex; + align-items: center; + gap: 4px; + min-width: 0; +} + +.egress-family { + color: var(--ant-color-text-secondary); + font-size: 10px; + text-transform: uppercase; +} + +.country-pill { + display: inline-flex; + align-items: center; + gap: 4px; + border: 1px solid var(--ant-color-border); + border-radius: 4px; + padding: 1px 6px; + line-height: 20px; + font-size: 12px; + white-space: nowrap; +} + +.country-pill.warp-on { + border-color: #f48120; +} + +.warp-cloud-icon { + width: 1em; + height: 1em; + color: #f48120; + flex: 0 0 auto; +} + +.warp-cloud-icon svg { + fill: #f48120; +} + +.ip-toggle-icon { + cursor: pointer; + opacity: 0.65; +} + +.ip-toggle-icon:hover { + opacity: 1; +} + +.address-hidden { + filter: blur(5px); + transition: filter 0.2s ease; +} + +.address-visible { + filter: none; +} + .action-cell { display: flex; align-items: center; @@ -110,6 +185,23 @@ flex-wrap: wrap; } +.card-egress { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 3px; + font-size: 12px; + opacity: 0.85; +} + +.card-egress-row { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; + max-width: 100%; +} + .card-test { margin-left: auto; display: inline-flex; diff --git a/frontend/src/pages/xray/outbounds/outbounds-tab-helpers.ts b/frontend/src/pages/xray/outbounds/outbounds-tab-helpers.ts index 311eacae8..f915e464d 100644 --- a/frontend/src/pages/xray/outbounds/outbounds-tab-helpers.ts +++ b/frontend/src/pages/xray/outbounds/outbounds-tab-helpers.ts @@ -61,6 +61,22 @@ export function trafficFor(outboundsTraffic: OutboundTrafficRow[], o: OutboundRo return { up: tr?.up || 0, down: tr?.down || 0 }; } +export function countryFlag(country?: string): string { + const code = (country || '').trim().toUpperCase(); + if (!/^[A-Z]{2}$/.test(code)) return ''; + return String.fromCodePoint(...[...code].map((ch) => 0x1f1e6 + ch.charCodeAt(0) - 65)); +} + +export function countryName(country?: string, locale?: string): string { + const code = (country || '').trim().toUpperCase(); + if (!/^[A-Z]{2}$/.test(code)) return ''; + try { + return new Intl.DisplayNames(locale ? [locale] : undefined, { type: 'region' }).of(code) || code; + } catch { + return code; + } +} + export function isTesting(states: Record, idx: K): boolean { return !!states?.[idx]?.testing; } diff --git a/frontend/src/pages/xray/outbounds/useOutboundColumns.tsx b/frontend/src/pages/xray/outbounds/useOutboundColumns.tsx index ad2111dc7..ab6f4e071 100644 --- a/frontend/src/pages/xray/outbounds/useOutboundColumns.tsx +++ b/frontend/src/pages/xray/outbounds/useOutboundColumns.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Button, Dropdown, Tag, Tooltip } from 'antd'; import { @@ -11,17 +11,23 @@ import { LoadingOutlined, ArrowUpOutlined, ArrowDownOutlined, + EyeInvisibleOutlined, + EyeOutlined, } from '@ant-design/icons'; import type { ColumnsType } from 'antd/es/table'; import { SizeFormatter } from '@/utils'; +import { activateOnKey } from '@/utils/a11y'; import { OutboundProtocols as Protocols } from '@/schemas/primitives'; import type { OutboundTestMode, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting'; import type { OutboundRow } from './outbounds-tab-types'; +import CountryPill from './CountryPill'; import TestResultPopover from './TestResultPopover'; import { effectiveTestMode, + countryFlag, + countryName, isTesting, isUntestable, outboundAddresses, @@ -58,7 +64,8 @@ export function useOutboundColumns({ onResetTraffic, onTest, }: OutboundColumnsParams): ColumnsType { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const [showEgressIp, setShowEgressIp] = useState(false); return useMemo( () => [ { @@ -135,6 +142,72 @@ export function useOutboundColumns({ ); }, }, + { + title: ( + + {t('pages.xray.outbound.egress')} + + {showEgressIp ? ( + setShowEgressIp(false)} onKeyDown={activateOnKey(() => setShowEgressIp(false))} /> + ) : ( + setShowEgressIp(true)} onKeyDown={activateOnKey(() => setShowEgressIp(true))} /> + )} + + + ), + key: 'egress', + align: 'left', + width: 210, + render: (_v, _record, index) => { + const egress = testResult(outboundTestStates, index)?.egress; + const addresses = [ + egress?.ipv4 ? { label: 'v4', value: egress.ipv4 } : null, + egress?.ipv6 ? { label: 'v6', value: egress.ipv6 } : null, + ].filter((item): item is { label: string; value: string } => Boolean(item)); + if (addresses.length === 0) { + return ( + + + + ); + } + return ( +
+ {addresses.map((addr) => ( + + + {addr.label} + {addr.value} + + + ))} +
+ ); + }, + }, + { + title: t('pages.xray.outbound.country'), + key: 'egressCountry', + align: 'left', + width: 160, + render: (_v, _record, index) => { + const egress = testResult(outboundTestStates, index)?.egress; + if (!egress?.country) { + return ( + + + + ); + } + const flag = countryFlag(egress.country); + const name = countryName(egress.country, i18n.language); + return ( + + + + ); + }, + }, { title: t('pages.inbounds.traffic'), key: 'traffic', @@ -183,6 +256,6 @@ export function useOutboundColumns({ }, ], // eslint-disable-next-line react-hooks/exhaustive-deps - [t, testMode, rows, outboundTestStates, outboundsTraffic], + [t, i18n.language, testMode, rows, outboundTestStates, outboundsTraffic, showEgressIp], ); } diff --git a/frontend/src/schemas/xray.ts b/frontend/src/schemas/xray.ts index 8c1e36e57..2f9be4e71 100644 --- a/frontend/src/schemas/xray.ts +++ b/frontend/src/schemas/xray.ts @@ -78,6 +78,15 @@ export const OutboundTestResultSchema = z.object({ }).loose(), ) .optional(), + egress: z + .object({ + ipv4: z.string().optional(), + ipv6: z.string().optional(), + country: z.string().optional(), + warp: z.string().optional(), + }) + .loose() + .optional(), }).loose(); // Batch results from /xray/testOutbounds, aligned with the request order. diff --git a/internal/web/service/outbound/egress_trace_test.go b/internal/web/service/outbound/egress_trace_test.go new file mode 100644 index 000000000..3c85cd0a5 --- /dev/null +++ b/internal/web/service/outbound/egress_trace_test.go @@ -0,0 +1,17 @@ +package outbound + +import "testing" + +func TestParseCloudflareTrace(t *testing.T) { + values := parseCloudflareTrace("ip=104.28.1.2\nloc=NL\nwarp=on\n") + + if values["ip"] != "104.28.1.2" { + t.Fatalf("ip = %q", values["ip"]) + } + if values["loc"] != "NL" { + t.Fatalf("loc = %q", values["loc"]) + } + if values["warp"] != "on" { + t.Fatalf("warp = %q", values["warp"]) + } +} diff --git a/internal/web/service/outbound/outbound.go b/internal/web/service/outbound/outbound.go index d37f8bf11..905093aae 100644 --- a/internal/web/service/outbound/outbound.go +++ b/internal/web/service/outbound/outbound.go @@ -142,6 +142,7 @@ type TestOutboundResult struct { TTFBMs int64 `json:"ttfbMs,omitempty"` Endpoints []TestEndpointResult `json:"endpoints,omitempty"` + Egress *TestEgressResult `json:"egress,omitempty"` } // TestEndpointResult is one entry in a TCP-mode probe — the per-endpoint @@ -153,6 +154,15 @@ type TestEndpointResult struct { Error string `json:"error,omitempty"` } +// TestEgressResult is populated by HTTP-mode probes from Cloudflare's trace +// endpoint. It reports what an external service sees after the outbound chain. +type TestEgressResult struct { + IPv4 string `json:"ipv4,omitempty"` + IPv6 string `json:"ipv6,omitempty"` + Country string `json:"country,omitempty"` + Warp string `json:"warp,omitempty"` +} + func (s *OutboundService) testOutboundTCP(outboundJSON string) (*TestOutboundResult, error) { var ob map[string]any if err := json.Unmarshal([]byte(outboundJSON), &ob); err != nil { diff --git a/internal/web/service/outbound/probe_http.go b/internal/web/service/outbound/probe_http.go index aae6cd070..d47e568f3 100644 --- a/internal/web/service/outbound/probe_http.go +++ b/internal/web/service/outbound/probe_http.go @@ -14,6 +14,7 @@ import ( "net/url" "os" "strconv" + "strings" "sync" "time" @@ -54,8 +55,13 @@ const ( // tcpBatchConcurrency caps parallel TCP-mode items in a batch (each item // already dials its endpoints concurrently). tcpBatchConcurrency = 8 + // egressTraceTimeout keeps diagnostic trace metadata from extending a + // successful HTTP probe by the full reachability timeout. + egressTraceTimeout = 3 * time.Second - defaultTestURL = "https://www.google.com/generate_204" + defaultTestURL = "https://www.google.com/generate_204" + egressTraceHost = "cloudflare.com" + egressTracePath = "/cdn-cgi/trace" ) // httpTestSemaphore serialises HTTP-mode batches (each spawns a temp xray @@ -76,6 +82,8 @@ var newBatchProcess = func(cfg *xray.Config, configPath string) batchProcess { return xray.NewTestProcess(cfg, configPath) } +var egressTraceProbe = probeEgressTrace + // httpBatchItem is one outbound inside an HTTP-mode batch. result is the // pre-allocated entry in the caller's result slice, filled in place. type httpBatchItem struct { @@ -542,6 +550,150 @@ func probeThroughSocks(port int, testURL string, timeout time.Duration, realDela } } result.Delay = max(delay, 1) + if !realDelay { + result.Egress = egressTraceProbe(proxyURL) + } +} + +// probeEgressTrace fetches Cloudflare's plain-text trace endpoint through the +// same SOCKS route used by the HTTP probe. It asks one IPv4 and one IPv6 +// Cloudflare address directly, when available, while keeping the TLS SNI as +// cloudflare.com. Failures are intentionally ignored by the caller: egress +// metadata is diagnostic, not reachability. +func probeEgressTrace(proxyURL *url.URL) *TestEgressResult { + ipv4, ipv6 := cloudflareTraceTargets() + if ipv4 == nil && ipv6 == nil { + return nil + } + + tr := &http.Transport{ + Proxy: http.ProxyURL(proxyURL), + TLSClientConfig: &tls.Config{ + ServerName: egressTraceHost, + }, + } + defer tr.CloseIdleConnections() + + client := &http.Client{ + Transport: tr, + Timeout: egressTraceTimeout, + } + + egress := &TestEgressResult{} + targets := make([]net.IP, 0, 2) + if ipv4 != nil { + targets = append(targets, ipv4) + } + if ipv6 != nil { + targets = append(targets, ipv6) + } + results := make(chan map[string]string, len(targets)) + for _, target := range targets { + go func(ip net.IP) { + results <- fetchCloudflareTrace(client, ip) + }(target) + } + for range targets { + applyEgressTrace(egress, <-results) + } + if egress.IPv4 == "" && egress.IPv6 == "" && egress.Country == "" && egress.Warp == "" { + return nil + } + + return egress +} + +func cloudflareTraceTargets() (net.IP, net.IP) { + ctx, cancel := context.WithTimeout(context.Background(), egressTraceTimeout) + defer cancel() + + addrs, err := net.DefaultResolver.LookupIPAddr(ctx, egressTraceHost) + if err != nil { + return nil, nil + } + var ipv4, ipv6 net.IP + for _, addr := range addrs { + ip := addr.IP + if ipv4 == nil { + if v4 := ip.To4(); v4 != nil { + ipv4 = v4 + continue + } + } + if ipv6 == nil && ip.To4() == nil && ip.To16() != nil { + ipv6 = ip + } + if ipv4 != nil && ipv6 != nil { + break + } + } + return ipv4, ipv6 +} + +func fetchCloudflareTrace(client *http.Client, ip net.IP) map[string]string { + if ip == nil { + return nil + } + traceURL := (&url.URL{ + Scheme: "https", + Host: net.JoinHostPort(ip.String(), "443"), + Path: egressTracePath, + }).String() + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, traceURL, nil) + if err != nil { + return nil + } + req.Host = egressTraceHost + resp, err := client.Do(req) + if err != nil { + return nil + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil + } + body, err := io.ReadAll(io.LimitReader(resp.Body, 16<<10)) + if err != nil { + return nil + } + return parseCloudflareTrace(string(body)) +} + +func applyEgressTrace(egress *TestEgressResult, values map[string]string) { + if len(values) == 0 { + return + } + if ip := net.ParseIP(values["ip"]); ip != nil { + if ip.To4() != nil { + if egress.IPv4 == "" { + egress.IPv4 = values["ip"] + } + } else if egress.IPv6 == "" { + egress.IPv6 = values["ip"] + } + } + if egress.Country == "" { + egress.Country = values["loc"] + } + if values["warp"] == "on" || egress.Warp == "" { + egress.Warp = values["warp"] + } +} + +func parseCloudflareTrace(body string) map[string]string { + values := make(map[string]string) + for line := range strings.SplitSeq(body, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + key, value, ok := strings.Cut(line, "=") + if !ok { + continue + } + values[strings.TrimSpace(key)] = strings.TrimSpace(value) + } + return values } // timedWarmGet re-issues the probe request over the transport's kept-alive diff --git a/internal/web/service/outbound/probe_http_test.go b/internal/web/service/outbound/probe_http_test.go index 816a1f4af..1c3ef4854 100644 --- a/internal/web/service/outbound/probe_http_test.go +++ b/internal/web/service/outbound/probe_http_test.go @@ -9,6 +9,7 @@ import ( "net" "net/http" "net/http/httptest" + "net/url" "strconv" "strings" "sync" @@ -135,6 +136,13 @@ func withStubProcess(t *testing.T, factory func(cfg *xray.Config, configPath str t.Cleanup(func() { newBatchProcess = orig }) } +func withEgressTraceProbe(t *testing.T, probe func(*url.URL) *TestEgressResult) { + t.Helper() + orig := egressTraceProbe + egressTraceProbe = probe + t.Cleanup(func() { egressTraceProbe = orig }) +} + func mustJSON(t *testing.T, v any) string { t.Helper() b, err := json.Marshal(v) @@ -417,6 +425,9 @@ func TestTestOutboundsHTTPBatchThroughStubSocks(t *testing.T) { proc = &stubProcess{cfg: cfg, serveSocks: true} return proc }) + withEgressTraceProbe(t, func(*url.URL) *TestEgressResult { + return &TestEgressResult{IPv4: "198.51.100.1", Country: "ZZ", Warp: "off"} + }) batch := mustJSON(t, []any{ map[string]any{"tag": "a", "protocol": "vless"}, @@ -445,6 +456,9 @@ func TestTestOutboundsHTTPBatchThroughStubSocks(t *testing.T) { if r.Mode != "http" { t.Errorf("result %d mode = %q", i, r.Mode) } + if r.Egress == nil || r.Egress.IPv4 != "198.51.100.1" { + t.Errorf("result %d egress = %+v", i, r.Egress) + } } if proc.IsRunning() { t.Error("temp process not stopped after batch") @@ -525,6 +539,9 @@ func TestTestOutboundsTCPModeForcesUDPToHTTPProbe(t *testing.T) { withStubProcess(t, func(cfg *xray.Config, configPath string) batchProcess { return &stubProcess{cfg: cfg, serveSocks: true} }) + withEgressTraceProbe(t, func(*url.URL) *TestEgressResult { + return &TestEgressResult{IPv4: "198.51.100.2", Country: "ZZ", Warp: "off"} + }) batch := mustJSON(t, []any{map[string]any{"tag": "wg", "protocol": "wireguard"}}) results, err := (&OutboundService{}).TestOutbounds(batch, srv.URL, "", "tcp") @@ -535,6 +552,9 @@ func TestTestOutboundsTCPModeForcesUDPToHTTPProbe(t *testing.T) { if !r.Success || r.Mode != "http" { t.Errorf("UDP outbound in tcp mode = %+v, want success with mode %q", r, "http") } + if r.Egress == nil || r.Egress.IPv4 != "198.51.100.2" { + t.Errorf("UDP outbound egress = %+v", r.Egress) + } } func TestProbeModeLabel(t *testing.T) { diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index b7dd60ca2..45a08df0b 100644 --- a/internal/web/translation/ar-EG.json +++ b/internal/web/translation/ar-EG.json @@ -1622,6 +1622,8 @@ "tag": "الوسم", "tagDesc": "تاج فريد", "address": "العنوان", + "egress": "Egress", + "egressHint": "Run an HTTP test to show egress IP and country.", "reverse": "عكسي", "domain": "النطاق", "type": "النوع", diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index f4157901b..12bf23832 100644 --- a/internal/web/translation/en-US.json +++ b/internal/web/translation/en-US.json @@ -1739,6 +1739,8 @@ "tag": "Tag", "tagDesc": "Unique Tag", "address": "Address", + "egress": "Egress", + "egressHint": "Run an HTTP test to show egress IP and country.", "reverse": "Reverse", "domain": "Domain", "type": "Type", diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index 76f139059..62fe71f40 100644 --- a/internal/web/translation/es-ES.json +++ b/internal/web/translation/es-ES.json @@ -1622,6 +1622,8 @@ "tag": "Etiqueta", "tagDesc": "etiqueta única", "address": "Dirección", + "egress": "Egress", + "egressHint": "Run an HTTP test to show egress IP and country.", "reverse": "Reverso", "domain": "Dominio", "type": "Tipo", diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index 1c9c45bd5..422c1c159 100644 --- a/internal/web/translation/fa-IR.json +++ b/internal/web/translation/fa-IR.json @@ -1622,6 +1622,8 @@ "tag": "تگ", "tagDesc": "برچسب یگانه", "address": "آدرس", + "egress": "Egress", + "egressHint": "Run an HTTP test to show egress IP and country.", "reverse": "معکوس", "domain": "دامنه", "type": "نوع", diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index 295fe207e..d04530e55 100644 --- a/internal/web/translation/id-ID.json +++ b/internal/web/translation/id-ID.json @@ -1622,6 +1622,8 @@ "tag": "Tag", "tagDesc": "Tag Unik", "address": "Alamat", + "egress": "Egress", + "egressHint": "Run an HTTP test to show egress IP and country.", "reverse": "Revers", "domain": "Domain", "type": "Tipe", diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index 05c3de42a..d1a3117a7 100644 --- a/internal/web/translation/ja-JP.json +++ b/internal/web/translation/ja-JP.json @@ -1622,6 +1622,8 @@ "tag": "タグ", "tagDesc": "一意のタグ", "address": "アドレス", + "egress": "Egress", + "egressHint": "Run an HTTP test to show egress IP and country.", "reverse": "リバース", "domain": "ドメイン", "type": "タイプ", diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index 2b82826f2..4fba4cbdb 100644 --- a/internal/web/translation/pt-BR.json +++ b/internal/web/translation/pt-BR.json @@ -1622,6 +1622,8 @@ "tag": "Tag", "tagDesc": "Tag Única", "address": "Endereço", + "egress": "Egress", + "egressHint": "Run an HTTP test to show egress IP and country.", "reverse": "Reverso", "domain": "Domínio", "type": "Tipo", diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index c5c43ae43..339018bd6 100644 --- a/internal/web/translation/ru-RU.json +++ b/internal/web/translation/ru-RU.json @@ -1622,6 +1622,8 @@ "tag": "Тег", "tagDesc": "Уникальный тег", "address": "Адрес", + "egress": "Выход", + "egressHint": "Запустите HTTP-тест, чтобы показать выходной IP и страну.", "reverse": "Реверс-прокси", "domain": "Домен", "type": "Тип", diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index 4a8e523c8..eea477846 100644 --- a/internal/web/translation/tr-TR.json +++ b/internal/web/translation/tr-TR.json @@ -1622,6 +1622,8 @@ "tag": "Etiket", "tagDesc": "Benzersiz Etiket", "address": "Adres", + "egress": "Egress", + "egressHint": "Run an HTTP test to show egress IP and country.", "reverse": "Ters", "domain": "Alan Adı", "type": "Tür", diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index d9946d618..7cd8d65bd 100644 --- a/internal/web/translation/uk-UA.json +++ b/internal/web/translation/uk-UA.json @@ -1622,6 +1622,8 @@ "tag": "Тег", "tagDesc": "Унікальний тег", "address": "Адреса", + "egress": "Egress", + "egressHint": "Run an HTTP test to show egress IP and country.", "reverse": "Зворотний", "domain": "Домен", "type": "Тип", diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index 9ffe40e05..30588eda2 100644 --- a/internal/web/translation/vi-VN.json +++ b/internal/web/translation/vi-VN.json @@ -1622,6 +1622,8 @@ "tag": "Tag", "tagDesc": "thẻ duy nhất", "address": "Địa chỉ", + "egress": "Egress", + "egressHint": "Run an HTTP test to show egress IP and country.", "reverse": "Đảo ngược", "domain": "Tên miền", "type": "Loại", diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index 1303b270b..16e1b0d1e 100644 --- a/internal/web/translation/zh-CN.json +++ b/internal/web/translation/zh-CN.json @@ -1622,6 +1622,8 @@ "tag": "标签", "tagDesc": "唯一标签", "address": "地址", + "egress": "Egress", + "egressHint": "Run an HTTP test to show egress IP and country.", "reverse": "反向", "domain": "域名", "type": "类型", diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index 218ee21e2..46b3cddd7 100644 --- a/internal/web/translation/zh-TW.json +++ b/internal/web/translation/zh-TW.json @@ -1622,6 +1622,8 @@ "tag": "標籤", "tagDesc": "唯一標籤", "address": "地址", + "egress": "Egress", + "egressHint": "Run an HTTP test to show egress IP and country.", "reverse": "反向", "domain": "網域", "type": "類型",