mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
feat(ui): agent-bridge page + server card + agent list (F7)
Add AgentBridge full UI at /dashboard/tools/agent-bridge: - page.tsx (Server Component, fetches state + providers check) - AgentBridgePageClient.tsx (orchestrator, all mutations) - AgentBridgeServerCard: Start/Stop/Restart/TrustCert/Download/RegenCert - AgentList: grid + All/Active/Setup/Investigating filter + search - AgentCard: expandable with DNS toggle, model mappings, setup wizard - SetupWizard: 3-step modal (Verify → DNS → Mappings) - ModelMappingTable + ModelSelectorModal: source→target inline editing - BypassListEditor: default + user bypass patterns textarea/chips - UpstreamCaField: path + Test TLS + Save - EmptyStateNoProviders: shown when zero providers configured (D15) - RiskNoticeBanner: amber dismissible (localStorage persistence) - shared/: DnsStatusBadge, CertStatusIcon, AgentIcon - hooks/useAgentBridgeState: polling fetch (no SWR dependency) - src/shared/components/RiskNoticeModal.tsx: generic risk modal (D16)
This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { RiskNoticeBanner } from "./components/RiskNoticeBanner";
|
||||
import { AgentBridgeServerCard } from "./components/AgentBridgeServerCard";
|
||||
import { AgentList } from "./components/AgentList";
|
||||
import { EmptyStateNoProviders } from "./components/EmptyStateNoProviders";
|
||||
import { useAgentBridgeState } from "./hooks/useAgentBridgeState";
|
||||
import type { MitmTarget } from "@/mitm/types";
|
||||
import type { MappingRow } from "./components/ModelMappingTable";
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface AgentStateEntry {
|
||||
agent_id: string;
|
||||
dns_enabled: boolean;
|
||||
cert_trusted: boolean;
|
||||
setup_completed: boolean;
|
||||
last_started_at: string | null;
|
||||
last_error: string | null;
|
||||
}
|
||||
|
||||
export interface AgentBridgeServerState {
|
||||
running: boolean;
|
||||
port: number;
|
||||
certTrusted: boolean;
|
||||
upstreamCa: string | null;
|
||||
lastStartedAt: string | null;
|
||||
activeConns: number;
|
||||
interceptedCount: number;
|
||||
}
|
||||
|
||||
export type AgentMappingsMap = Record<string, MappingRow[]>;
|
||||
|
||||
export interface AgentBridgePageData {
|
||||
serverState: AgentBridgeServerState;
|
||||
agentStates: AgentStateEntry[];
|
||||
bypassPatterns: string[];
|
||||
mappings: AgentMappingsMap;
|
||||
}
|
||||
|
||||
interface AgentBridgePageClientProps {
|
||||
initialData: AgentBridgePageData;
|
||||
targets: MitmTarget[];
|
||||
hasProviders: boolean;
|
||||
}
|
||||
|
||||
// ── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function AgentBridgePageClient({
|
||||
initialData,
|
||||
targets,
|
||||
hasProviders,
|
||||
}: AgentBridgePageClientProps) {
|
||||
const t = useTranslations("agentBridge");
|
||||
const { data, refresh } = useAgentBridgeState({ initialData });
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
|
||||
// ── Server actions ────────────────────────────────────────────────────────
|
||||
|
||||
const handleServerAction = useCallback(
|
||||
async (action: "start" | "stop" | "restart" | "trust-cert" | "regenerate-cert") => {
|
||||
setActionError(null);
|
||||
try {
|
||||
const res = await fetch("/api/tools/agent-bridge/server", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = (await res.json().catch(() => ({ error: { message: `HTTP ${res.status}` } }))) as {
|
||||
error?: { message?: string };
|
||||
};
|
||||
throw new Error(err.error?.message ?? `HTTP ${res.status}`);
|
||||
}
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : "Unknown error");
|
||||
}
|
||||
},
|
||||
[refresh]
|
||||
);
|
||||
|
||||
// ── Upstream CA ───────────────────────────────────────────────────────────
|
||||
|
||||
const handleUpstreamCaSave = useCallback(async (path: string) => {
|
||||
setActionError(null);
|
||||
try {
|
||||
const res = await fetch("/api/tools/agent-bridge/upstream-ca", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : "Unknown error");
|
||||
}
|
||||
}, [refresh]);
|
||||
|
||||
// ── Bypass list ───────────────────────────────────────────────────────────
|
||||
|
||||
const handleBypassSave = useCallback(async (patterns: string[]) => {
|
||||
setActionError(null);
|
||||
try {
|
||||
const res = await fetch("/api/tools/agent-bridge/bypass", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ patterns }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : "Unknown error");
|
||||
}
|
||||
}, [refresh]);
|
||||
|
||||
// ── DNS toggle ────────────────────────────────────────────────────────────
|
||||
|
||||
const handleDnsToggle = useCallback(
|
||||
async (agentId: string, enabled: boolean) => {
|
||||
setActionError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/tools/agent-bridge/agents/${agentId}/dns`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enabled }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : "Unknown error");
|
||||
}
|
||||
},
|
||||
[refresh]
|
||||
);
|
||||
|
||||
// ── Mappings save ─────────────────────────────────────────────────────────
|
||||
|
||||
const handleMappingsSave = useCallback(
|
||||
async (agentId: string, mappings: MappingRow[]) => {
|
||||
setActionError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/tools/agent-bridge/agents/${agentId}/mappings`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ mappings }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : "Unknown error");
|
||||
}
|
||||
},
|
||||
[refresh]
|
||||
);
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
{/* Risk banner */}
|
||||
<RiskNoticeBanner />
|
||||
|
||||
{/* Error alert */}
|
||||
{actionError && (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex items-center gap-2 rounded-xl border border-red-500/30 bg-red-500/5 px-4 py-3 text-sm text-red-600 dark:text-red-400"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">error</span>
|
||||
{actionError}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActionError(null)}
|
||||
className="ml-auto text-red-500 hover:text-red-400"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">close</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state: no providers */}
|
||||
{!hasProviders ? (
|
||||
<EmptyStateNoProviders />
|
||||
) : (
|
||||
<>
|
||||
{/* Server card */}
|
||||
<AgentBridgeServerCard
|
||||
serverState={data.serverState}
|
||||
onAction={handleServerAction}
|
||||
onUpstreamCaSave={handleUpstreamCaSave}
|
||||
onBypassSave={handleBypassSave}
|
||||
bypassPatterns={data.bypassPatterns}
|
||||
/>
|
||||
|
||||
{/* Agent list */}
|
||||
<AgentList
|
||||
targets={targets}
|
||||
agentStates={data.agentStates}
|
||||
serverRunning={data.serverState.running}
|
||||
mappingsMap={data.mappings}
|
||||
onDnsToggle={handleDnsToggle}
|
||||
onMappingsSave={handleMappingsSave}
|
||||
/>
|
||||
|
||||
{/* Quick links */}
|
||||
<div className="rounded-xl border border-border/40 bg-card px-5 py-4">
|
||||
<h3 className="text-xs font-semibold text-text-muted mb-2 uppercase tracking-wide">
|
||||
{t("quickLinks") || "Quick links"}
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link
|
||||
href="/dashboard/providers"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-primary hover:underline"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">dns</span>
|
||||
{t("quickLinkProviders") || "Configure providers"}
|
||||
</Link>
|
||||
<Link
|
||||
href="/dashboard/tools/traffic-inspector"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-primary hover:underline"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">network_check</span>
|
||||
{t("quickLinkInspector") || "View traffic in Traffic Inspector"}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { CertStatusIcon } from "./shared/CertStatusIcon";
|
||||
import { UpstreamCaField } from "./UpstreamCaField";
|
||||
import { BypassListEditor } from "./BypassListEditor";
|
||||
import type { AgentBridgeServerState } from "../AgentBridgePageClient";
|
||||
|
||||
interface AgentBridgeServerCardProps {
|
||||
serverState: AgentBridgeServerState;
|
||||
onAction: (action: "start" | "stop" | "restart" | "trust-cert" | "regenerate-cert") => Promise<void>;
|
||||
onUpstreamCaSave: (path: string) => Promise<void>;
|
||||
onBypassSave: (patterns: string[]) => Promise<void>;
|
||||
bypassPatterns: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Global server card — status + action buttons + CA field + bypass list.
|
||||
* Matches plan 11 §3 AgentBridge Server layout.
|
||||
*/
|
||||
export function AgentBridgeServerCard({
|
||||
serverState,
|
||||
onAction,
|
||||
onUpstreamCaSave,
|
||||
onBypassSave,
|
||||
bypassPatterns,
|
||||
}: AgentBridgeServerCardProps) {
|
||||
const t = useTranslations("agentBridge");
|
||||
const [loading, setLoading] = useState<string | null>(null);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [upstreamCa, setUpstreamCa] = useState(serverState.upstreamCa ?? "");
|
||||
|
||||
const runAction = async (action: "start" | "stop" | "restart" | "trust-cert" | "regenerate-cert") => {
|
||||
setLoading(action);
|
||||
try {
|
||||
await onAction(action);
|
||||
} finally {
|
||||
setLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const isRunning = serverState.running;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border/60 bg-card overflow-hidden">
|
||||
{/* Header row */}
|
||||
<div className="flex items-center justify-between px-5 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-primary/10">
|
||||
<span className="material-symbols-outlined text-[20px] text-primary">link</span>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-text-main flex items-center gap-2">
|
||||
{t("serverCardTitle") || "AgentBridge Server"}
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
isRunning
|
||||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
|
||||
: "bg-zinc-500/10 text-zinc-500 dark:text-zinc-400"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`h-1.5 w-1.5 rounded-full ${isRunning ? "bg-emerald-500 animate-pulse" : "bg-zinc-400"}`}
|
||||
/>
|
||||
{isRunning ? t("statusRunning") || "Running" : t("statusStopped") || "Stopped"}
|
||||
</span>
|
||||
</h2>
|
||||
<div className="flex items-center gap-3 mt-0.5 text-xs text-text-muted">
|
||||
<span>
|
||||
{t("serverPort") || "Port"}: {serverState.port ?? 443}
|
||||
</span>
|
||||
<CertStatusIcon trusted={serverState.certTrusted ?? false} />
|
||||
{serverState.activeConns !== undefined && (
|
||||
<span>
|
||||
{t("serverConns") || "Connections"}: {serverState.activeConns}
|
||||
</span>
|
||||
)}
|
||||
{serverState.interceptedCount !== undefined && (
|
||||
<span>
|
||||
{t("serverIntercepted") || "Intercepted"}: {serverState.interceptedCount.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
{serverState.lastStartedAt && (
|
||||
<span>
|
||||
{t("serverLastStarted") || "Last started"}:{" "}
|
||||
{new Date(serverState.lastStartedAt).toLocaleTimeString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="text-text-muted hover:text-text-main transition-colors"
|
||||
aria-label={expanded ? "Collapse" : "Expand"}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">
|
||||
{expanded ? "expand_less" : "expand_more"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex flex-wrap gap-2 px-5 pb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => runAction("start")}
|
||||
disabled={isRunning || loading !== null}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-emerald-500/10 text-emerald-600 px-3 py-1.5 text-xs font-medium hover:bg-emerald-500/20 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">play_arrow</span>
|
||||
{loading === "start" ? t("starting") || "Starting…" : t("startServer") || "Start"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => runAction("stop")}
|
||||
disabled={!isRunning || loading !== null}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-red-500/10 text-red-600 px-3 py-1.5 text-xs font-medium hover:bg-red-500/20 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">stop</span>
|
||||
{loading === "stop" ? t("stopping") || "Stopping…" : t("stopServer") || "Stop"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => runAction("restart")}
|
||||
disabled={loading !== null}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-amber-500/10 text-amber-600 px-3 py-1.5 text-xs font-medium hover:bg-amber-500/20 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">refresh</span>
|
||||
{loading === "restart" ? t("restarting") || "Restarting…" : t("restartServer") || "Restart"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => runAction("trust-cert")}
|
||||
disabled={loading !== null}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-blue-500/10 text-blue-600 px-3 py-1.5 text-xs font-medium hover:bg-blue-500/20 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">security</span>
|
||||
{loading === "trust-cert" ? t("trusting") || "Trusting…" : t("trustCert") || "Trust Cert"}
|
||||
</button>
|
||||
|
||||
<a
|
||||
href="/api/tools/agent-bridge/cert/download"
|
||||
download
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-violet-500/10 text-violet-600 px-3 py-1.5 text-xs font-medium hover:bg-violet-500/20 transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">download</span>
|
||||
{t("downloadCert") || "Download Cert"}
|
||||
</a>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => runAction("regenerate-cert")}
|
||||
disabled={loading !== null}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-zinc-500/10 text-text-muted px-3 py-1.5 text-xs font-medium hover:bg-zinc-500/20 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">autorenew</span>
|
||||
{loading === "regenerate-cert"
|
||||
? t("regenerating") || "Regenerating…"
|
||||
: t("regenerateCert") || "Regenerate Cert"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Expanded: CA + Bypass */}
|
||||
{expanded && (
|
||||
<div className="px-5 pb-5 border-t border-border/30 pt-4 flex flex-col gap-5">
|
||||
<UpstreamCaField
|
||||
value={upstreamCa}
|
||||
onChange={setUpstreamCa}
|
||||
onSave={onUpstreamCaSave}
|
||||
/>
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-text-main mb-2">
|
||||
{t("bypassSectionTitle") || "Bypass List"}
|
||||
</h4>
|
||||
<p className="text-xs text-text-muted mb-3">
|
||||
{t("bypassSectionDesc") ||
|
||||
"Hosts matching these patterns are tunneled directly (no TLS decryption). Defaults include banks, .gov, and corporate SSO."}
|
||||
</p>
|
||||
<BypassListEditor
|
||||
patterns={bypassPatterns}
|
||||
onSave={onBypassSave}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { AgentIcon } from "./shared/AgentIcon";
|
||||
import { DnsStatusBadge } from "./shared/DnsStatusBadge";
|
||||
import { ModelMappingTable } from "./ModelMappingTable";
|
||||
import { SetupWizard } from "./SetupWizard";
|
||||
import type { MitmTarget } from "@/mitm/types";
|
||||
import type { AgentStateEntry } from "../AgentBridgePageClient";
|
||||
import type { MappingRow } from "./ModelMappingTable";
|
||||
|
||||
interface AgentCardProps {
|
||||
target: MitmTarget;
|
||||
agentState: AgentStateEntry | undefined;
|
||||
serverRunning: boolean;
|
||||
mappings: MappingRow[];
|
||||
onDnsToggle: (agentId: string, enabled: boolean) => Promise<void>;
|
||||
onMappingsSave: (agentId: string, mappings: MappingRow[]) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expandable card for a single IDE agent.
|
||||
*/
|
||||
export function AgentCard({
|
||||
target,
|
||||
agentState,
|
||||
serverRunning,
|
||||
mappings,
|
||||
onDnsToggle,
|
||||
onMappingsSave,
|
||||
}: AgentCardProps) {
|
||||
const t = useTranslations("agentBridge");
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [wizardOpen, setWizardOpen] = useState(false);
|
||||
const [dnsLoading, setDnsLoading] = useState(false);
|
||||
|
||||
const dnsEnabled = agentState?.dns_enabled ?? false;
|
||||
const setupCompleted = agentState?.setup_completed ?? false;
|
||||
const certTrusted = agentState?.cert_trusted ?? false;
|
||||
const isInvestigating = target.viability === "investigating";
|
||||
|
||||
const getStatusBadge = () => {
|
||||
if (isInvestigating) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-zinc-500/10 text-zinc-500 dark:text-zinc-400 text-xs font-medium">
|
||||
<span className="material-symbols-outlined text-[12px]">search</span>
|
||||
{t("statusInvestigating") || "Investigating"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (setupCompleted && dnsEnabled) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 text-xs font-medium">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
|
||||
{t("statusActive") || "Active"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (!setupCompleted) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-zinc-500/10 text-zinc-500 text-xs font-medium">
|
||||
<span className="material-symbols-outlined text-[12px]">settings</span>
|
||||
{t("statusSetupRequired") || "Setup required"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-amber-500/10 text-amber-600 dark:text-amber-400 text-xs font-medium">
|
||||
<span className="material-symbols-outlined text-[12px]">warning</span>
|
||||
{t("statusDnsOff") || "DNS off"}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const handleDnsToggle = async () => {
|
||||
setDnsLoading(true);
|
||||
try {
|
||||
await onDnsToggle(target.id, !dnsEnabled);
|
||||
} finally {
|
||||
setDnsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="rounded-xl border border-border/50 bg-card overflow-hidden transition-all hover:border-border/80"
|
||||
style={{ borderLeftWidth: 3, borderLeftColor: target.color }}
|
||||
>
|
||||
{/* Card header */}
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center justify-between gap-3 px-4 py-3 text-left hover:bg-surface/30 transition-colors"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<AgentIcon icon={target.icon} color={target.color} size={18} />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-text-main truncate">{target.name}</p>
|
||||
<p className="text-xs text-text-muted truncate">
|
||||
{target.hosts.slice(0, 2).join(", ")}
|
||||
{target.hosts.length > 2 && ` +${target.hosts.length - 2}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{getStatusBadge()}
|
||||
<DnsStatusBadge enabled={dnsEnabled} />
|
||||
<span className="material-symbols-outlined text-[16px] text-text-muted">
|
||||
{expanded ? "expand_less" : "expand_more"}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Expanded content */}
|
||||
{expanded && (
|
||||
<div className="px-4 pb-4 border-t border-border/20 pt-4 flex flex-col gap-4">
|
||||
{/* Hosts */}
|
||||
<div>
|
||||
<p className="text-xs font-medium text-text-muted mb-1.5">
|
||||
{t("agentHosts") || "Intercepted hosts"}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{target.hosts.map((h) => (
|
||||
<span
|
||||
key={h}
|
||||
className="inline-flex items-center px-2 py-0.5 rounded-full bg-surface text-xs font-mono text-text-muted border border-border/40"
|
||||
>
|
||||
{h}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cert status */}
|
||||
<div className="flex items-center gap-2 text-xs text-text-muted">
|
||||
<span
|
||||
className={`material-symbols-outlined text-[14px] ${certTrusted ? "text-emerald-500" : "text-zinc-400"}`}
|
||||
>
|
||||
{certTrusted ? "verified_user" : "lock_open"}
|
||||
</span>
|
||||
{certTrusted
|
||||
? t("certTrusted") || "Certificate trusted"
|
||||
: t("certNotTrusted") || "Certificate not trusted"}
|
||||
</div>
|
||||
|
||||
{/* Investigating notice */}
|
||||
{isInvestigating && (
|
||||
<div className="rounded-lg border border-zinc-500/20 bg-zinc-500/5 p-3">
|
||||
<p className="text-xs text-text-muted">
|
||||
{t("investigatingNotice") ||
|
||||
"This agent is under investigation. Hosts and API surface are still being confirmed. Setup will be available once the upstream API is documented."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Model mappings */}
|
||||
{!isInvestigating && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-text-muted mb-2">
|
||||
{t("modelMappingsLabel") || "Model mappings"}
|
||||
</p>
|
||||
<ModelMappingTable
|
||||
agentId={target.id}
|
||||
mappings={mappings}
|
||||
onSave={onMappingsSave}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{!isInvestigating && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setWizardOpen(true)}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-primary/10 text-primary px-3 py-1.5 text-xs font-medium hover:bg-primary/20 transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">play_arrow</span>
|
||||
{t("setupWizard") || "Setup wizard"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!isInvestigating && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDnsToggle}
|
||||
disabled={dnsLoading}
|
||||
className={`inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-50 ${
|
||||
dnsEnabled
|
||||
? "bg-red-500/10 text-red-600 hover:bg-red-500/20"
|
||||
: "bg-emerald-500/10 text-emerald-600 hover:bg-emerald-500/20"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
{dnsEnabled ? "stop" : "play_arrow"}
|
||||
</span>
|
||||
{dnsLoading
|
||||
? t("toggling") || "Toggling…"
|
||||
: dnsEnabled
|
||||
? t("stopDns") || "Stop DNS"
|
||||
: t("startDns") || "Start DNS"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<a
|
||||
href={`/dashboard/tools/traffic-inspector?agent=${target.id}`}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-zinc-500/10 text-text-muted px-3 py-1.5 text-xs font-medium hover:bg-zinc-500/20 transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">network_check</span>
|
||||
{t("viewTraffic") || "View traffic"}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{wizardOpen && (
|
||||
<SetupWizard
|
||||
target={target}
|
||||
agentState={agentState}
|
||||
serverRunning={serverRunning}
|
||||
onClose={() => setWizardOpen(false)}
|
||||
onDnsToggle={onDnsToggle}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { AgentCard } from "./AgentCard";
|
||||
import type { MitmTarget } from "@/mitm/types";
|
||||
import type { AgentStateEntry, AgentMappingsMap } from "../AgentBridgePageClient";
|
||||
import type { MappingRow } from "./ModelMappingTable";
|
||||
|
||||
interface AgentListProps {
|
||||
targets: MitmTarget[];
|
||||
agentStates: AgentStateEntry[];
|
||||
serverRunning: boolean;
|
||||
mappingsMap: AgentMappingsMap;
|
||||
onDnsToggle: (agentId: string, enabled: boolean) => Promise<void>;
|
||||
onMappingsSave: (agentId: string, mappings: MappingRow[]) => Promise<void>;
|
||||
}
|
||||
|
||||
type SetupFilter = "all" | "active" | "setup-required" | "investigating";
|
||||
|
||||
/**
|
||||
* Grid of agent cards with filter + search controls.
|
||||
* Matches plan 11 §3 IDE Agents section.
|
||||
*/
|
||||
export function AgentList({
|
||||
targets,
|
||||
agentStates,
|
||||
serverRunning,
|
||||
mappingsMap,
|
||||
onDnsToggle,
|
||||
onMappingsSave,
|
||||
}: AgentListProps) {
|
||||
const t = useTranslations("agentBridge");
|
||||
const [filter, setFilter] = useState<SetupFilter>("all");
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const stateByAgent = Object.fromEntries(agentStates.map((s) => [s.agent_id, s]));
|
||||
|
||||
const filtered = targets.filter((target) => {
|
||||
// Search filter
|
||||
if (search) {
|
||||
const q = search.toLowerCase();
|
||||
if (
|
||||
!target.name.toLowerCase().includes(q) &&
|
||||
!target.id.toLowerCase().includes(q) &&
|
||||
!target.hosts.some((h) => h.toLowerCase().includes(q))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const state = stateByAgent[target.id];
|
||||
|
||||
// Setup status filter
|
||||
if (filter === "active") {
|
||||
return state?.dns_enabled && state?.setup_completed;
|
||||
}
|
||||
if (filter === "setup-required") {
|
||||
return !state?.setup_completed && target.viability !== "investigating";
|
||||
}
|
||||
if (filter === "investigating") {
|
||||
return target.viability === "investigating";
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const filterOptions: { id: SetupFilter; label: string }[] = [
|
||||
{ id: "all", label: t("filterAll") || "All" },
|
||||
{ id: "active", label: t("filterActive") || "Active" },
|
||||
{ id: "setup-required", label: t("filterSetupRequired") || "Setup required" },
|
||||
{ id: "investigating", label: t("filterInvestigating") || "Investigating" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border/60 bg-card overflow-hidden">
|
||||
{/* Controls */}
|
||||
<div className="flex flex-wrap items-center gap-3 px-5 py-4 border-b border-border/30">
|
||||
<h2 className="text-sm font-semibold text-text-main mr-auto">
|
||||
{t("agentListTitle") || "IDE Agents"}{" "}
|
||||
<span className="text-text-muted font-normal">({targets.length})</span>
|
||||
</h2>
|
||||
|
||||
{/* Filter buttons */}
|
||||
<div className="flex gap-1">
|
||||
{filterOptions.map((opt) => (
|
||||
<button
|
||||
key={opt.id}
|
||||
type="button"
|
||||
onClick={() => setFilter(opt.id)}
|
||||
className={`px-2.5 py-1 rounded-lg text-xs font-medium transition-colors ${
|
||||
filter === opt.id
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-text-muted hover:text-text-main hover:bg-surface"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<span className="material-symbols-outlined absolute left-2 top-1/2 -translate-y-1/2 text-[16px] text-text-muted pointer-events-none">
|
||||
search
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
className="rounded-lg border border-border/50 bg-surface pl-8 pr-3 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
placeholder={t("searchAgents") || "Search agents…"}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
<div className="p-5 flex flex-col gap-3">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="py-8 text-center text-text-muted">
|
||||
<span className="material-symbols-outlined text-[36px] block mb-2 text-text-muted/40">
|
||||
search_off
|
||||
</span>
|
||||
<p className="text-sm">{t("noAgentsMatch") || "No agents match the current filter"}</p>
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((target) => (
|
||||
<AgentCard
|
||||
key={target.id}
|
||||
target={target}
|
||||
agentState={stateByAgent[target.id]}
|
||||
serverRunning={serverRunning}
|
||||
mappings={mappingsMap[target.id] ?? []}
|
||||
onDnsToggle={onDnsToggle}
|
||||
onMappingsSave={onMappingsSave}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
const DEFAULT_BYPASS_PATTERNS = [
|
||||
"*.bank.*",
|
||||
"*.gov.*",
|
||||
"*.okta.com",
|
||||
"*.auth0.com",
|
||||
];
|
||||
|
||||
interface BypassListEditorProps {
|
||||
patterns: string[];
|
||||
onSave: (patterns: string[]) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Textarea / chip editor for user-defined bypass patterns.
|
||||
* Shows read-only defaults + editable user list.
|
||||
*/
|
||||
export function BypassListEditor({ patterns, onSave }: BypassListEditorProps) {
|
||||
const t = useTranslations("agentBridge");
|
||||
const [userInput, setUserInput] = useState(patterns.join("\n"));
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const parsed = userInput
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
await onSave(parsed);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-text-muted mb-1.5">
|
||||
{t("bypassDefaultsLabel") || "Default bypass patterns (read-only)"}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{DEFAULT_BYPASS_PATTERNS.map((p) => (
|
||||
<span
|
||||
key={p}
|
||||
className="inline-flex items-center px-2 py-0.5 rounded-full bg-surface text-xs text-text-muted border border-border/40"
|
||||
>
|
||||
{p}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-text-muted mb-1.5 block">
|
||||
{t("bypassUserLabel") || "Custom bypass patterns (one per line, glob or regex)"}
|
||||
</label>
|
||||
<textarea
|
||||
className="w-full min-h-[80px] rounded-lg border border-border/50 bg-card px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
placeholder="*.internal.corp sso.example.com"
|
||||
value={userInput}
|
||||
onChange={(e) => setUserInput(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="rounded-lg bg-primary/10 text-primary px-4 py-2 text-sm font-medium hover:bg-primary/20 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving ? t("saving") || "Saving…" : t("saveBypassList") || "Save bypass list"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
|
||||
/**
|
||||
* Empty state shown when no providers are configured.
|
||||
* Matches plan 11 §7.
|
||||
*/
|
||||
export function EmptyStateNoProviders() {
|
||||
const t = useTranslations("agentBridge");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border/60 bg-card/50 px-8 py-14 text-center gap-4">
|
||||
<div className="p-4 rounded-2xl bg-primary/10">
|
||||
<span className="material-symbols-outlined text-[48px] text-primary">
|
||||
dns
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-text-main mb-1">
|
||||
{t("emptyNoProvidersTitle") || "No providers configured yet"}
|
||||
</h3>
|
||||
<p className="text-sm text-text-muted max-w-sm">
|
||||
{t("emptyNoProvidersBody") ||
|
||||
"To use AgentBridge, first connect at least one provider. It will be the destination where IDE requests are routed."}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/dashboard/providers"
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-primary px-5 py-2.5 text-sm font-medium text-white hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">arrow_forward</span>
|
||||
{t("emptyGoToProviders") || "Go to Providers"}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ModelSelectorModal } from "./ModelSelectorModal";
|
||||
|
||||
export interface MappingRow {
|
||||
source: string;
|
||||
target: string;
|
||||
}
|
||||
|
||||
interface ModelMappingTableProps {
|
||||
agentId: string;
|
||||
mappings: MappingRow[];
|
||||
onSave: (agentId: string, mappings: MappingRow[]) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Editable table: source model → target OmniRoute model.
|
||||
*/
|
||||
export function ModelMappingTable({ agentId, mappings, onSave }: ModelMappingTableProps) {
|
||||
const t = useTranslations("agentBridge");
|
||||
const [rows, setRows] = useState<MappingRow[]>(mappings);
|
||||
const [selectorOpen, setSelectorOpen] = useState<number | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const updateTarget = (index: number, target: string) => {
|
||||
setRows((prev) => prev.map((r, i) => (i === index ? { ...r, target } : r)));
|
||||
setSelectorOpen(null);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave(agentId, rows);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<p className="text-xs text-text-muted italic">
|
||||
{t("noMappings") || "No model mappings configured. Run setup wizard to auto-detect models."}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="rounded-lg border border-border/40 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border/40 bg-surface/60">
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-text-muted">
|
||||
{t("sourceModel") || "Source model (agent native)"}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-text-muted">
|
||||
{t("targetModel") || "Target model (OmniRoute)"}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, i) => (
|
||||
<tr key={i} className="border-b border-border/20 last:border-0">
|
||||
<td className="px-3 py-2">
|
||||
<span className="font-mono text-xs text-text-muted">{row.source}</span>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectorOpen(i)}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-border/40 bg-card px-2.5 py-1 text-xs hover:bg-surface transition-colors font-mono"
|
||||
>
|
||||
{row.target || (
|
||||
<span className="text-text-muted italic">{t("selectModel") || "Select…"}</span>
|
||||
)}
|
||||
<span className="material-symbols-outlined text-[12px] text-text-muted">
|
||||
expand_more
|
||||
</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="rounded-lg bg-primary/10 text-primary px-4 py-1.5 text-sm font-medium hover:bg-primary/20 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving ? t("saving") || "Saving…" : t("saveMappings") || "Save mappings"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{selectorOpen !== null && (
|
||||
<ModelSelectorModal
|
||||
open
|
||||
currentModel={rows[selectorOpen]?.target ?? ""}
|
||||
onSelect={(model) => updateTarget(selectorOpen, model)}
|
||||
onClose={() => setSelectorOpen(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface ProviderModel {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface ModelSelectorModalProps {
|
||||
open: boolean;
|
||||
currentModel: string;
|
||||
onSelect: (model: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal for picking an OmniRoute target model for model-mapping.
|
||||
*/
|
||||
export function ModelSelectorModal({
|
||||
open,
|
||||
currentModel,
|
||||
onSelect,
|
||||
onClose,
|
||||
}: ModelSelectorModalProps) {
|
||||
const t = useTranslations("agentBridge");
|
||||
const [models, setModels] = useState<ProviderModel[]>([]);
|
||||
const [search, setSearch] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setLoading(true);
|
||||
fetch("/api/v1/models")
|
||||
.then((r) => r.json())
|
||||
.then((d: { data?: ProviderModel[] }) => setModels(Array.isArray(d.data) ? d.data : []))
|
||||
.catch(() => setModels([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", handler);
|
||||
return () => document.removeEventListener("keydown", handler);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const filtered = models.filter(
|
||||
(m) =>
|
||||
m.id.toLowerCase().includes(search.toLowerCase()) ||
|
||||
m.name.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div className="w-full max-w-sm rounded-xl border border-border/60 bg-card shadow-xl flex flex-col max-h-[70vh]">
|
||||
<div className="flex items-center justify-between px-4 pt-4 pb-3 border-b border-border/30">
|
||||
<h3 className="text-sm font-semibold text-text-main">
|
||||
{t("modelSelectorTitle") || "Select target model"}
|
||||
</h3>
|
||||
<button type="button" onClick={onClose} aria-label="Close">
|
||||
<span className="material-symbols-outlined text-[18px] text-text-muted hover:text-text-main">
|
||||
close
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-2">
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
className="w-full rounded-lg border border-border/50 bg-surface px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
placeholder={t("modelSelectorSearch") || "Search models…"}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-4 pb-4 flex flex-col gap-1">
|
||||
{loading && (
|
||||
<p className="text-xs text-text-muted py-4 text-center">
|
||||
{t("loading") || "Loading models…"}
|
||||
</p>
|
||||
)}
|
||||
{!loading && filtered.length === 0 && (
|
||||
<p className="text-xs text-text-muted py-4 text-center">
|
||||
{t("noModelsFound") || "No models found"}
|
||||
</p>
|
||||
)}
|
||||
{filtered.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(m.id)}
|
||||
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition-colors ${
|
||||
m.id === currentModel
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "hover:bg-surface text-text-main"
|
||||
}`}
|
||||
>
|
||||
<span className="font-mono text-xs">{m.id}</span>
|
||||
{m.name !== m.id && (
|
||||
<span className="ml-2 text-text-muted text-xs">{m.name}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
const STORAGE_KEY = "omniroute-agentbridge-risk-dismissed";
|
||||
|
||||
/**
|
||||
* Amber dismissable banner shown at the top of the AgentBridge page.
|
||||
* Persisted via localStorage so it only shows once per user.
|
||||
*/
|
||||
export function RiskNoticeBanner() {
|
||||
const t = useTranslations("agentBridge");
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const dismissed = localStorage.getItem(STORAGE_KEY);
|
||||
if (!dismissed) setVisible(true);
|
||||
} catch {
|
||||
setVisible(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const dismiss = () => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, "true");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setVisible(false);
|
||||
};
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex items-start gap-3 rounded-xl border border-amber-500/30 bg-amber-500/5 px-4 py-3"
|
||||
>
|
||||
<span className="material-symbols-outlined text-amber-500 shrink-0 mt-0.5">warning</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold text-amber-700 dark:text-amber-400">
|
||||
{t("riskBannerTitle") || "Use at your own risk"}
|
||||
</p>
|
||||
<p className="text-xs text-amber-600/80 dark:text-amber-300/70 mt-0.5">
|
||||
{t("riskBannerBody") ||
|
||||
"AgentBridge intercepts HTTPS traffic from IDE agents. By activating it you accept responsibility for compliance with the terms of service of each agent. Never use on devices or networks where TLS inspection is prohibited."}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={dismiss}
|
||||
aria-label={t("riskBannerDismiss") || "Dismiss"}
|
||||
className="shrink-0 text-amber-500 hover:text-amber-400 transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">close</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { AgentStateEntry } from "../AgentBridgePageClient";
|
||||
import type { MitmTarget } from "@/mitm/types";
|
||||
|
||||
interface SetupWizardProps {
|
||||
target: MitmTarget;
|
||||
agentState: AgentStateEntry | undefined;
|
||||
serverRunning: boolean;
|
||||
onClose: () => void;
|
||||
onDnsToggle: (agentId: string, enabled: boolean) => Promise<void>;
|
||||
}
|
||||
|
||||
type Step = "verify" | "dns" | "mappings";
|
||||
|
||||
/**
|
||||
* 3-step setup wizard for a single agent.
|
||||
* Step 1: Verify server + cert
|
||||
* Step 2: Enable DNS
|
||||
* Step 3: Model mappings prompt
|
||||
*/
|
||||
export function SetupWizard({
|
||||
target,
|
||||
agentState,
|
||||
serverRunning,
|
||||
onClose,
|
||||
onDnsToggle,
|
||||
}: SetupWizardProps) {
|
||||
const t = useTranslations("agentBridge");
|
||||
const [step, setStep] = useState<Step>("verify");
|
||||
const [enablingDns, setEnablingDns] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", handler);
|
||||
return () => document.removeEventListener("keydown", handler);
|
||||
}, [onClose]);
|
||||
|
||||
const certTrusted = agentState?.cert_trusted ?? false;
|
||||
const dnsEnabled = agentState?.dns_enabled ?? false;
|
||||
|
||||
const handleEnableDns = async () => {
|
||||
setEnablingDns(true);
|
||||
try {
|
||||
await onDnsToggle(target.id, true);
|
||||
setStep("mappings");
|
||||
} finally {
|
||||
setEnablingDns(false);
|
||||
}
|
||||
};
|
||||
|
||||
const steps: { id: Step; label: string }[] = [
|
||||
{ id: "verify", label: t("wizardStep1Label") || "Verify" },
|
||||
{ id: "dns", label: t("wizardStep2Label") || "DNS" },
|
||||
{ id: "mappings", label: t("wizardStep3Label") || "Mappings" },
|
||||
];
|
||||
|
||||
const stepIndex = steps.findIndex((s) => s.id === step);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div className="w-full max-w-lg rounded-xl border border-border/60 bg-card shadow-xl flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 pt-5 pb-4 border-b border-border/30">
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className="material-symbols-outlined text-[20px]"
|
||||
style={{ color: target.color }}
|
||||
>
|
||||
{target.icon}
|
||||
</span>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-text-main">
|
||||
{t("wizardTitle") || "Setup wizard"} — {target.name}
|
||||
</h3>
|
||||
<p className="text-xs text-text-muted">{t("wizardSubtitle") || "3-step setup"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" onClick={onClose} aria-label="Close">
|
||||
<span className="material-symbols-outlined text-[18px] text-text-muted hover:text-text-main">
|
||||
close
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Step indicator */}
|
||||
<div className="flex px-5 pt-4 gap-2">
|
||||
{steps.map((s, i) => (
|
||||
<div key={s.id} className="flex items-center gap-1.5 flex-1">
|
||||
<div
|
||||
className={`flex h-6 w-6 items-center justify-center rounded-full text-xs font-medium shrink-0 ${
|
||||
i < stepIndex
|
||||
? "bg-emerald-500 text-white"
|
||||
: i === stepIndex
|
||||
? "bg-primary text-white"
|
||||
: "bg-surface text-text-muted border border-border/50"
|
||||
}`}
|
||||
>
|
||||
{i < stepIndex ? (
|
||||
<span className="material-symbols-outlined text-[12px]">check</span>
|
||||
) : (
|
||||
i + 1
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`text-xs ${i === stepIndex ? "text-text-main font-medium" : "text-text-muted"}`}
|
||||
>
|
||||
{s.label}
|
||||
</span>
|
||||
{i < steps.length - 1 && (
|
||||
<div className="flex-1 h-px bg-border/30 ml-1" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Step content */}
|
||||
<div className="px-5 py-5 flex flex-col gap-4 min-h-[180px]">
|
||||
{step === "verify" && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-sm text-text-muted">
|
||||
{t("wizardStep1Desc") || "Confirm the server is running and the certificate is installed."}
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span
|
||||
className={`material-symbols-outlined text-[16px] ${serverRunning ? "text-emerald-500" : "text-red-500"}`}
|
||||
>
|
||||
{serverRunning ? "check_circle" : "cancel"}
|
||||
</span>
|
||||
<span>
|
||||
{t("wizardServerCheck") || "AgentBridge server"}{" "}
|
||||
{serverRunning
|
||||
? t("wizardRunning") || "running"
|
||||
: t("wizardNotRunning") || "not running"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span
|
||||
className={`material-symbols-outlined text-[16px] ${certTrusted ? "text-emerald-500" : "text-amber-500"}`}
|
||||
>
|
||||
{certTrusted ? "verified_user" : "warning"}
|
||||
</span>
|
||||
<span>
|
||||
{t("wizardCertCheck") || "Certificate"}{" "}
|
||||
{certTrusted
|
||||
? t("wizardTrusted") || "trusted"
|
||||
: t("wizardNotTrusted") || "not yet trusted — use Trust Cert button"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tutorial steps */}
|
||||
{target.setupTutorial.steps.length > 0 && (
|
||||
<div className="mt-2 p-3 rounded-lg bg-surface/50 border border-border/30">
|
||||
<p className="text-xs font-medium text-text-muted mb-2">
|
||||
{t("wizardTutorialTitle") || "Setup instructions:"}
|
||||
</p>
|
||||
<ol className="flex flex-col gap-1">
|
||||
{target.setupTutorial.steps.map((step, i) => (
|
||||
<li key={i} className="text-xs text-text-muted flex items-start gap-1.5">
|
||||
<span className="shrink-0 text-primary font-medium">{i + 1}.</span>
|
||||
{step}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "dns" && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-sm text-text-muted">
|
||||
{t("wizardStep2Desc") || "The following entries will be added to /etc/hosts to redirect traffic through AgentBridge:"}
|
||||
</p>
|
||||
<div className="rounded-lg bg-surface/50 border border-border/30 p-3 font-mono text-xs flex flex-col gap-1">
|
||||
{target.hosts.map((host) => (
|
||||
<div key={host} className="text-text-muted">
|
||||
<span className="text-primary">127.0.0.1</span> {host}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{dnsEnabled && (
|
||||
<div className="flex items-center gap-2 text-sm text-emerald-500">
|
||||
<span className="material-symbols-outlined text-[16px]">check_circle</span>
|
||||
{t("wizardDnsAlreadyEnabled") || "DNS already enabled for this agent"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "mappings" && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2 text-emerald-500">
|
||||
<span className="material-symbols-outlined text-[20px]">check_circle</span>
|
||||
<p className="text-sm font-medium">
|
||||
{t("wizardStep3Success") || "Agent is configured!"}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-text-muted">
|
||||
{t("wizardStep3Desc") || "You can now configure model mappings in the agent card. Restart the IDE to apply changes."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-5 pb-5 pt-0 border-t border-border/30 mt-0 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (step === "dns") setStep("verify");
|
||||
else if (step === "mappings") setStep("dns");
|
||||
else onClose();
|
||||
}}
|
||||
className="rounded-lg border border-border/50 bg-card px-4 py-2 text-sm text-text-muted hover:bg-surface transition-colors"
|
||||
>
|
||||
{step === "verify" ? t("cancel") || "Cancel" : t("back") || "Back"}
|
||||
</button>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{step === "verify" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep("dns")}
|
||||
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
{t("next") || "Next"}{" "}
|
||||
<span className="material-symbols-outlined text-[14px] ml-1">arrow_forward</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{step === "dns" && (
|
||||
<>
|
||||
{dnsEnabled ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep("mappings")}
|
||||
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
{t("next") || "Next"}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleEnableDns}
|
||||
disabled={enablingDns}
|
||||
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{enablingDns
|
||||
? t("enablingDns") || "Enabling…"
|
||||
: t("wizardEnableDns") || "Add /etc/hosts entries"}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === "mappings" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-lg bg-emerald-500 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-400 transition-colors"
|
||||
>
|
||||
{t("done") || "Done"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface UpstreamCaFieldProps {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
onSave: (path: string) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input + Test button for the optional upstream CA certificate path.
|
||||
* Used for corporate networks that intercept TLS upstream.
|
||||
*/
|
||||
export function UpstreamCaField({ value, onChange, onSave }: UpstreamCaFieldProps) {
|
||||
const t = useTranslations("agentBridge");
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<"ok" | "error" | null>(null);
|
||||
|
||||
const handleTest = async () => {
|
||||
if (!value.trim()) return;
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
try {
|
||||
const res = await fetch("/api/tools/agent-bridge/upstream-ca/test", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path: value.trim() }),
|
||||
});
|
||||
setTestResult(res.ok ? "ok" : "error");
|
||||
} catch {
|
||||
setTestResult("error");
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
await onSave(value.trim());
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs font-medium text-text-muted">
|
||||
{t("upstreamCaLabel") || "Upstream CA Certificate (corporate)"}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
className="flex-1 rounded-lg border border-border/50 bg-card px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
placeholder={t("upstreamCaPlaceholder") || "/etc/ssl/certs/corp-ca.pem"}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTest}
|
||||
disabled={testing || !value.trim()}
|
||||
className="shrink-0 rounded-lg border border-border/50 bg-card px-3 py-2 text-xs font-medium hover:bg-surface transition-colors disabled:opacity-50"
|
||||
>
|
||||
{testing ? "Testing…" : t("upstreamCaTest") || "Test TLS"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={!value.trim()}
|
||||
className="shrink-0 rounded-lg bg-primary/10 text-primary px-3 py-2 text-xs font-medium hover:bg-primary/20 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{t("save") || "Save"}
|
||||
</button>
|
||||
</div>
|
||||
{testResult === "ok" && (
|
||||
<p className="text-xs text-emerald-500">
|
||||
<span className="material-symbols-outlined text-[12px] mr-1">check_circle</span>
|
||||
{t("upstreamCaTestOk") || "TLS test passed"}
|
||||
</p>
|
||||
)}
|
||||
{testResult === "error" && (
|
||||
<p className="text-xs text-red-500">
|
||||
<span className="material-symbols-outlined text-[12px] mr-1">error</span>
|
||||
{t("upstreamCaTestError") || "TLS test failed — check the path and CA file"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
interface AgentIconProps {
|
||||
icon: string;
|
||||
color: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export function AgentIcon({ icon, color, size = 20 }: AgentIconProps) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center rounded-lg shrink-0"
|
||||
style={{
|
||||
backgroundColor: `${color}20`,
|
||||
width: size + 12,
|
||||
height: size + 12,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="material-symbols-outlined"
|
||||
style={{ fontSize: size, color }}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
interface CertStatusIconProps {
|
||||
trusted: boolean;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export function CertStatusIcon({ trusted, size = 16 }: CertStatusIconProps) {
|
||||
return trusted ? (
|
||||
<span
|
||||
className="material-symbols-outlined text-emerald-500"
|
||||
style={{ fontSize: size }}
|
||||
title="Certificate trusted"
|
||||
>
|
||||
verified_user
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className="material-symbols-outlined text-zinc-400"
|
||||
style={{ fontSize: size }}
|
||||
title="Certificate not trusted"
|
||||
>
|
||||
lock_open
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
interface DnsStatusBadgeProps {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export function DnsStatusBadge({ enabled }: DnsStatusBadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
enabled
|
||||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
|
||||
: "bg-zinc-500/10 text-zinc-500 dark:text-zinc-400"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`h-1.5 w-1.5 rounded-full ${enabled ? "bg-emerald-500" : "bg-zinc-400"}`}
|
||||
/>
|
||||
{enabled ? "DNS on" : "DNS off"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { AgentBridgePageData } from "../AgentBridgePageClient";
|
||||
|
||||
interface UseAgentBridgeStateOptions {
|
||||
initialData: AgentBridgePageData;
|
||||
pollingInterval?: number;
|
||||
}
|
||||
|
||||
interface UseAgentBridgeStateReturn {
|
||||
data: AgentBridgePageData;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for fetching and revalidating AgentBridge page data.
|
||||
* Uses fetch + polling (no SWR dependency) — project pattern from cloud-agents.
|
||||
*/
|
||||
export function useAgentBridgeState({
|
||||
initialData,
|
||||
pollingInterval = 5000,
|
||||
}: UseAgentBridgeStateOptions): UseAgentBridgeStateReturn {
|
||||
const [data, setData] = useState<AgentBridgePageData>(initialData);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
abortRef.current?.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortRef.current = ctrl;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/tools/agent-bridge/state", {
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const json = (await res.json()) as AgentBridgePageData;
|
||||
if (!ctrl.signal.aborted) setData(json);
|
||||
} catch (err) {
|
||||
if (!ctrl.signal.aborted) {
|
||||
setError(err instanceof Error ? err.message : "Unknown error");
|
||||
}
|
||||
} finally {
|
||||
if (!ctrl.signal.aborted) setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Auto-poll
|
||||
useEffect(() => {
|
||||
if (!pollingInterval || pollingInterval <= 0) return;
|
||||
const id = setInterval(() => {
|
||||
refresh().catch(() => {/* swallow background errors */});
|
||||
}, pollingInterval);
|
||||
return () => clearInterval(id);
|
||||
}, [pollingInterval, refresh]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { data, loading, error, refresh };
|
||||
}
|
||||
61
src/app/(dashboard)/dashboard/tools/agent-bridge/page.tsx
Normal file
61
src/app/(dashboard)/dashboard/tools/agent-bridge/page.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { getProviderConnections } from "@/lib/db/providers";
|
||||
import { ALL_TARGETS } from "@/mitm/targets/index";
|
||||
import AgentBridgePageClient from "./AgentBridgePageClient";
|
||||
import type { AgentBridgePageData } from "./AgentBridgePageClient";
|
||||
|
||||
/**
|
||||
* AgentBridge page — Server Component entry point.
|
||||
* Fetches initial state from the backend API and passes to client orchestrator.
|
||||
*/
|
||||
export default async function AgentBridgePage() {
|
||||
// Check if any providers are configured (D15)
|
||||
let hasProviders = false;
|
||||
try {
|
||||
const connections = await getProviderConnections();
|
||||
hasProviders = Array.isArray(connections) && connections.length > 0;
|
||||
} catch {
|
||||
// If DB not ready yet, show empty state gracefully
|
||||
hasProviders = false;
|
||||
}
|
||||
|
||||
// Fetch initial AgentBridge state from the REST API
|
||||
// Falls back to a safe default if the API isn't ready yet
|
||||
let initialData: AgentBridgePageData = {
|
||||
serverState: {
|
||||
running: false,
|
||||
port: 443,
|
||||
certTrusted: false,
|
||||
upstreamCa: null,
|
||||
lastStartedAt: null,
|
||||
activeConns: 0,
|
||||
interceptedCount: 0,
|
||||
},
|
||||
agentStates: [],
|
||||
bypassPatterns: [],
|
||||
mappings: {},
|
||||
};
|
||||
|
||||
try {
|
||||
const base =
|
||||
process.env.OMNIROUTE_BASE_URL ??
|
||||
`http://127.0.0.1:${process.env.PORT ?? 20128}`;
|
||||
const res = await fetch(`${base}/api/tools/agent-bridge/state`, {
|
||||
cache: "no-store",
|
||||
headers: { "x-internal-fetch": "1" },
|
||||
});
|
||||
if (res.ok) {
|
||||
const json = (await res.json()) as AgentBridgePageData;
|
||||
initialData = json;
|
||||
}
|
||||
} catch {
|
||||
// Backend not yet available — use defaults; client will poll
|
||||
}
|
||||
|
||||
return (
|
||||
<AgentBridgePageClient
|
||||
initialData={initialData}
|
||||
targets={ALL_TARGETS}
|
||||
hasProviders={hasProviders}
|
||||
/>
|
||||
);
|
||||
}
|
||||
81
src/shared/components/RiskNoticeModal.tsx
Normal file
81
src/shared/components/RiskNoticeModal.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/shared/components/Button";
|
||||
|
||||
export interface RiskNoticeModalProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
body: string;
|
||||
dontShowAgainKey: string;
|
||||
onAccept: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic risk notice modal (D16).
|
||||
* Persists "don't show again" preference to localStorage using `dontShowAgainKey`.
|
||||
*/
|
||||
export function RiskNoticeModal({
|
||||
open,
|
||||
title,
|
||||
body,
|
||||
dontShowAgainKey,
|
||||
onAccept,
|
||||
onCancel,
|
||||
}: RiskNoticeModalProps) {
|
||||
const t = useTranslations("common");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onCancel();
|
||||
};
|
||||
document.addEventListener("keydown", handler);
|
||||
return () => document.removeEventListener("keydown", handler);
|
||||
}, [open, onCancel]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const handleAccept = () => {
|
||||
try {
|
||||
localStorage.setItem(dontShowAgainKey, "true");
|
||||
} catch {
|
||||
// ignore storage errors
|
||||
}
|
||||
onAccept();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="risk-modal-title"
|
||||
>
|
||||
<div className="w-full max-w-md rounded-xl border border-amber-500/30 bg-card p-6 shadow-xl">
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<div className="p-2 rounded-lg bg-amber-500/10 text-amber-500 shrink-0">
|
||||
<span className="material-symbols-outlined text-[20px]">warning</span>
|
||||
</div>
|
||||
<h2 id="risk-modal-title" className="text-base font-semibold text-text-main pt-1">
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-text-muted mb-6 leading-relaxed">{body}</p>
|
||||
|
||||
<div className="flex gap-3 justify-end">
|
||||
<Button variant="ghost" onClick={onCancel}>
|
||||
{t("cancel") || "Cancel"}
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleAccept}>
|
||||
<span className="material-symbols-outlined text-[14px] mr-1">check</span>
|
||||
{t("understand") || "I understand"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user