feat(web): add global egress IP top banner

This commit is contained in:
RuoJi6 committed 2026-10-27 12:30:36 +08:00
1 parent 5feddf296d
commit 251dccb921
3 files changed
+239 -23

No files matched your search

@@ -0,0 +1,179 @@
"use client";
import * as React from "react";
import { Globe2Icon, Loader2Icon, XIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { api } from "@/lib/api";
import type { EgressCheck, EgressReport } from "@/lib/types";
import { cn } from "@/lib/utils";
const DISMISSED_STORAGE_KEY = "artex-egress-ip-banner-dismissed";
const REPORT_STORAGE_KEY = "artex-egress-ip-banner-report";
type ProbeOptions = {
globalProxy: string;
probeUrl?: string;
};
type EgressIpBannerContextValue = {
probeEgress: (options: ProbeOptions) => Promise<EgressReport>;
isLoading: boolean;
report: EgressReport | null;
error: string | null;
dismissed: boolean;
dismiss: () => void;
};
const EgressIpBannerContext = React.createContext<EgressIpBannerContextValue | null>(null);
function readDismissed() {
if (typeof window === "undefined") return false;
try {
return window.sessionStorage.getItem(DISMISSED_STORAGE_KEY) === "true";
} catch {
return false;
}
}
function setDismissedStorage(dismissed: boolean) {
try {
if (dismissed) window.sessionStorage.setItem(DISMISSED_STORAGE_KEY, "true");
else window.sessionStorage.removeItem(DISMISSED_STORAGE_KEY);
} catch {
// sessionStorage can be unavailable in privacy-restricted browsers.
}
}
function readStoredReport(): EgressReport | null {
if (typeof window === "undefined") return null;
try {
const raw = window.sessionStorage.getItem(REPORT_STORAGE_KEY);
if (!raw) return null;
const parsed: unknown = JSON.parse(raw);
if (!parsed || typeof parsed !== "object" || !Array.isArray((parsed as { checks?: unknown }).checks)) {
return null;
}
return parsed as EgressReport;
} catch {
return null;
}
}
function setStoredReport(report: EgressReport | null) {
try {
if (report) window.sessionStorage.setItem(REPORT_STORAGE_KEY, JSON.stringify(report));
else window.sessionStorage.removeItem(REPORT_STORAGE_KEY);
} catch {
// sessionStorage can be unavailable in privacy-restricted browsers.
}
}
export function EgressIpBannerProvider({ children }: { children: React.ReactNode }) {
const [report, setReport] = React.useState<EgressReport | null>(readStoredReport);
const [error, setError] = React.useState<string | null>(null);
const [isLoading, setIsLoading] = React.useState(false);
const [dismissed, setDismissed] = React.useState(readDismissed);
const probeEgress = React.useCallback(async ({ globalProxy, probeUrl }: ProbeOptions) => {
setDismissed(false);
setDismissedStorage(false);
setError(null);
setIsLoading(true);
try {
const nextReport = await api.testEgressProxy({
global_proxy: globalProxy,
...(probeUrl ? { probe_url: probeUrl } : {}),
});
setReport(nextReport);
setStoredReport(nextReport);
return nextReport;
} catch (cause) {
setReport(null);
setStoredReport(null);
const message = cause instanceof Error ? cause.message : "未知错误";
setError(message);
throw cause;
} finally {
setIsLoading(false);
}
}, []);
const dismiss = React.useCallback(() => {
setDismissed(true);
setDismissedStorage(true);
}, []);
const value = React.useMemo(
() => ({ probeEgress, isLoading, report, error, dismissed, dismiss }),
[dismiss, dismissed, error, isLoading, probeEgress, report],
);
return <EgressIpBannerContext.Provider value={value}>{children}</EgressIpBannerContext.Provider>;
}
export function useEgressIpBanner() {
const context = React.useContext(EgressIpBannerContext);
if (!context) throw new Error("useEgressIpBanner must be used within EgressIpBannerProvider");
return context;
}
function checkMessage(check: EgressCheck | undefined) {
if (!check) return "未返回结果";
if (check.skipped) return "未配置 / 已跳过";
if (check.result.ok) return check.result.exit_ip || "已连通,但未返回 IP";
return `探测失败:${check.result.probes.find((probe) => probe.error)?.error || check.result.error || "未知错误"}`;
}
function IpRow({ label, check }: { label: string; check: EgressCheck | undefined }) {
const failed = !!check && !check.skipped && !check.result.ok;
return (
<div className="flex min-w-0 items-baseline gap-2">
<span className="shrink-0 text-muted-foreground">{label}</span>
<span className={cn("min-w-0 truncate font-mono", failed && "text-destructive")}>{checkMessage(check)}</span>
</div>
);
}
export function EgressIpBanner() {
const { report, error, isLoading, dismissed, dismiss } = useEgressIpBanner();
if (dismissed) return null;
if (!isLoading && !report && !error) return null;
const direct = report?.checks.find((check) => check.key === "direct");
const global = report?.checks.find((check) => check.key === "global");
let statusContent: React.ReactNode;
if (isLoading) {
statusContent = (
<div className="mt-1 flex items-center gap-2 text-muted-foreground">
<Loader2Icon className="size-3.5 animate-spin" aria-hidden="true" />
正在探测直连和全局代理出口 IP…
</div>
);
} else if (error) {
statusContent = <div className="mt-1 text-destructive">探测失败:{error}</div>;
} else {
statusContent = (
<div className="mt-1 grid gap-x-6 gap-y-1 sm:grid-cols-2">
<IpRow label="直连 IP" check={direct} />
<IpRow label="全局代理 IP" check={global} />
</div>
);
}
return (
<div className="border-b bg-primary/5 px-4 py-2.5 md:px-6" role="status" aria-live="polite">
<div className="flex items-start gap-3">
<Globe2Icon className="mt-0.5 size-4 shrink-0 text-primary" aria-hidden="true" />
<div className="min-w-0 flex-1 text-sm">
<div className="font-medium">出口 IP</div>
{statusContent}
</div>
<Button type="button" variant="ghost" size="icon-sm" onClick={dismiss} aria-label="关闭出口 IP 提示">
<XIcon aria-hidden="true" />
</Button>
</div>
</div>
);
}
+25 -21
View File
@@ -16,6 +16,7 @@ import {
import { PREFERENCE_DEFAULTS } from "@/lib/preferences/preferences-config";
import { cn } from "@/lib/utils";
import { EgressIpBanner, EgressIpBannerProvider } from "./_components/egress-ip-banner";
import { MainContent } from "./_components/main-content";
// Reads a layout-critical preference from the browser cookie (falls back to the
@@ -60,27 +61,30 @@ export default function Layout({ children }: Readonly<{ children: ReactNode }>)
if (!authed) return null;
return (
<SidebarProvider
defaultOpen={defaultOpen}
style={
{
"--sidebar-width": "calc(var(--spacing) * 68)",
} as React.CSSProperties
}
>
<AppSidebar variant={variant} collapsible={collapsible} />
<SidebarInset
className={cn(
"[html[data-content-layout=centered]_&>*]:mx-auto",
"[html[data-content-layout=centered]_&>*]:w-full",
"[html[data-content-layout=centered]_&>*]:max-w-screen-2xl",
"peer-data-[variant=inset]:border",
"[--dashboard-header-height:--spacing(12)]",
"min-w-0 overflow-x-hidden",
)}
<EgressIpBannerProvider>
<SidebarProvider
defaultOpen={defaultOpen}
style={
{
"--sidebar-width": "calc(var(--spacing) * 68)",
} as React.CSSProperties
}
>
<MainContent>{children}</MainContent>
</SidebarInset>
</SidebarProvider>
<AppSidebar variant={variant} collapsible={collapsible} />
<SidebarInset
className={cn(
"[html[data-content-layout=centered]_&>*]:mx-auto",
"[html[data-content-layout=centered]_&>*]:w-full",
"[html[data-content-layout=centered]_&>*]:max-w-screen-2xl",
"peer-data-[variant=inset]:border",
"[--dashboard-header-height:--spacing(12)]",
"min-w-0 overflow-x-hidden",
)}
>
<EgressIpBanner />
<MainContent>{children}</MainContent>
</SidebarInset>
</SidebarProvider>
</EgressIpBannerProvider>
);
}
+35 -2
View File
@@ -16,6 +16,7 @@ import { api } from "@/lib/api";
import { CHAT_SEND_MODE_OPTIONS, type ChatSendMode, setChatSendMode, useChatSendMode } from "@/lib/chat-send-mode";
import type { EgressReport, LLMProfile, Settings, WorkerPool } from "@/lib/types";
import { useEgressIpBanner } from "../../_components/egress-ip-banner";
import { BasicAuthCard } from "./_components/basic-auth-card";
import { UpdateCard } from "./_components/update-card";
@@ -38,6 +39,7 @@ export default function SystemSettingsPage() {
const [savingProxy, setSavingProxy] = React.useState(false);
const [globalProxyInput, setGlobalProxyInput] = React.useState("");
const [savingGlobalProxy, setSavingGlobalProxy] = React.useState(false);
const [savedGlobalProxy, setSavedGlobalProxy] = React.useState("");
// 出口探测:测一次真实请求,拿到"直连 / 经全局代理"两条出口 IP。
const [egressTesting, setEgressTesting] = React.useState(false);
const [egressReport, setEgressReport] = React.useState<EgressReport | null>(null);
@@ -60,6 +62,7 @@ export default function SystemSettingsPage() {
const [noaCompaction, setNoaCompaction] = React.useState(false);
// 纯前端偏好:不走 /api/settings,直接读写 localStorage。
const sendMode = useChatSendMode();
const { probeEgress: probeEgressForBanner, isLoading: egressBannerLoading } = useEgressIpBanner();
const apply = React.useCallback((s: Settings) => {
setCredentialAgent(!!s.credential_agent_enabled);
@@ -72,7 +75,9 @@ export default function SystemSettingsPage() {
setBraveKeySet(!!s.brave_key_set);
setTavilyKeySet(!!s.tavily_key_set);
setProxyInput(s.web_search_proxy ?? "");
setGlobalProxyInput(s.global_proxy ?? "");
const savedProxy = s.global_proxy ?? "";
setGlobalProxyInput(savedProxy);
setSavedGlobalProxy(savedProxy);
setPyInterp(s.python_interpreter ?? "");
setWorkers(String(s.workers ?? 3));
setWorkerPools(s.worker_pools ?? []);
@@ -335,6 +340,25 @@ export default function SystemSettingsPage() {
.finally(() => setEgressTesting(false));
};
// 发布到站点顶栏时始终使用已保存的 global_proxy,避免把输入框里的草稿
// 当成 Agent 当前实际使用的代理。
const showEgressIp = async () => {
if (!settingsLoaded) {
toast.error("设置尚未加载完成,请稍后再试");
return;
}
try {
const rep = await probeEgressForBanner({
globalProxy: savedGlobalProxy.trim(),
...(egressProbeUrl.trim() ? { probeUrl: egressProbeUrl.trim() } : {}),
});
if (rep.ok) toast.success("已在顶栏显示出口 IP");
else toast.error("出口探测完成,但部分结果不可用");
} catch (e) {
toast.error("出口探测失败:" + (e as Error).message);
}
};
// Run a real "test" search ("test") against the CURRENT form values (backend +
// proxy + entered key), falling back to saved values server-side. Toasts result.
const runTest = () => {
@@ -463,9 +487,10 @@ export default function SystemSettingsPage() {
<Label htmlFor="global-proxy" className="text-sm font-normal text-muted-foreground">
代理地址
</Label>
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
<Input
id="global-proxy"
className="min-w-0 flex-1"
autoComplete="off"
placeholder="socks5://user:pass@host:1080 或 http://host:port(留空=直连)"
value={globalProxyInput}
@@ -488,6 +513,14 @@ export default function SystemSettingsPage() {
>
{egressTesting ? "测试中…" : "测试"}
</Button>
<Button
type="button"
variant="secondary"
onClick={showEgressIp}
disabled={!loaded || egressBannerLoading}
>
{egressBannerLoading ? "探测中…" : "显示出口 IP"}
</Button>
</div>
<p className="text-muted-foreground text-xs">
{globalProxyInput.trim() ? "已配置 · 所有目标流量经此代理出网" : "未配置 · 目标流量直连出网"}