From 30f6bc183354bddee0f6ecd0371e8e2de97a45de Mon Sep 17 00:00:00 2001 From: isultanov99 Date: Sun, 12 Jul 2026 15:09:52 +0200 Subject: [PATCH] feat: Add outbound egress metadata (IP + country) (#5886) * Add outbound egress metadata Show egress IP and country information for outbound HTTP tests. The probe reuses the temporary SOCKS route from the existing HTTP test and fetches Cloudflare trace metadata after the reachability check succeeds. The outbound list now adds separate Egress and Country columns, hides egress IPs until the user reveals them, and marks Cloudflare WARP results with an orange cloud pill. Mobile cards keep the same data compact by placing the country and IPv4/IPv6 values on separate lines. Validation: npm run typecheck; npm run lint; npm run build; go test ./internal/web/service/outbound * Use context-aware DNS lookup for egress trace * Address outbound egress review feedback Restore the Real Delay selector and TCP default so the egress metadata change does not remove an existing test mode. Keep HTTP probe tests hermetic by stubbing egress trace lookups, run IPv4 and IPv6 trace fetches concurrently with a shorter diagnostic timeout, scope mobile IP reveal state per row, support keyboard activation for reveal toggles, and treat WARP+ trace values as WARP-like. --- .../src/pages/xray/outbounds/CountryPill.tsx | 18 ++ .../pages/xray/outbounds/OutboundCardList.tsx | 58 ++++++- .../src/pages/xray/outbounds/OutboundsTab.css | 92 +++++++++++ .../xray/outbounds/outbounds-tab-helpers.ts | 16 ++ .../xray/outbounds/useOutboundColumns.tsx | 79 ++++++++- frontend/src/schemas/xray.ts | 9 + .../web/service/outbound/egress_trace_test.go | 17 ++ internal/web/service/outbound/outbound.go | 10 ++ internal/web/service/outbound/probe_http.go | 154 +++++++++++++++++- .../web/service/outbound/probe_http_test.go | 20 +++ internal/web/translation/ar-EG.json | 2 + internal/web/translation/en-US.json | 2 + internal/web/translation/es-ES.json | 2 + internal/web/translation/fa-IR.json | 2 + internal/web/translation/id-ID.json | 2 + internal/web/translation/ja-JP.json | 2 + internal/web/translation/pt-BR.json | 2 + internal/web/translation/ru-RU.json | 2 + internal/web/translation/tr-TR.json | 2 + internal/web/translation/uk-UA.json | 2 + internal/web/translation/vi-VN.json | 2 + internal/web/translation/zh-CN.json | 2 + internal/web/translation/zh-TW.json | 2 + 23 files changed, 494 insertions(+), 5 deletions(-) create mode 100644 frontend/src/pages/xray/outbounds/CountryPill.tsx create mode 100644 internal/web/service/outbound/egress_trace_test.go 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": "類型",