mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-27 17:03:49 +08:00
feat(web): add global egress IP top banner
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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() ? "已配置 · 所有目标流量经此代理出网" : "未配置 · 目标流量直连出网"}
|
||||
|
||||
Reference in new issue
Block a user