mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-20 06:02:14 +03:00
Merge release/v3.8.8 into refactor/pages-v3-14 (CLI pages redesign)
Conflicts: CLAUDE.md base; i18n deep-merge (costsSection=Custos); .source regenerated (fumadocs-mdx, +1 doc); openapi regenerated. CLIToolsPageClient.tsx: accepted #2839 deletion (redesign replaced cli-tools/ with cli-code/cli-agents/acp-agents; base #2858 only removed obsolete MITM cards; AgentBridge reachable via sidebar; 0 orphan refs). sidebar-visibility test passes (cli items + agent-bridge merged).
This commit is contained in:
125
src/app/(dashboard)/dashboard/activity/ActivityFeedClient.tsx
Normal file
125
src/app/(dashboard)/dashboard/activity/ActivityFeedClient.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { AuditLogEntry } from "@/lib/compliance/index";
|
||||
import ActivityFeed from "./components/ActivityFeed";
|
||||
import EventTypeFilter, {
|
||||
type EventCategory,
|
||||
matchesCategory,
|
||||
} from "./components/EventTypeFilter";
|
||||
|
||||
const FEED_LIMIT = 200;
|
||||
|
||||
export default function ActivityFeedClient() {
|
||||
const t = useTranslations("activity");
|
||||
const [allEntries, setAllEntries] = useState<AuditLogEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [category, setCategory] = useState<EventCategory>("all");
|
||||
const referenceNowMs = useRef<number>(Date.now());
|
||||
|
||||
const fetchEntries = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
level: "high",
|
||||
limit: String(FEED_LIMIT),
|
||||
});
|
||||
const res = await fetch(`/api/compliance/audit-log?${params.toString()}`);
|
||||
if (!res.ok) {
|
||||
throw new Error(t("description"));
|
||||
}
|
||||
const data = (await res.json()) as AuditLogEntry[];
|
||||
// Reset reference time on fresh load so relative timestamps are stable
|
||||
referenceNowMs.current = Date.now();
|
||||
setAllEntries(Array.isArray(data) ? data : []);
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : "Failed to fetch activity";
|
||||
setError(msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchEntries();
|
||||
}, [fetchEntries]);
|
||||
|
||||
const filtered =
|
||||
category === "all"
|
||||
? allEntries
|
||||
: allEntries.filter((e) => {
|
||||
const action = typeof e.action === "string" ? e.action : "";
|
||||
return matchesCategory(action, category);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-[var(--color-text-main)]">{t("title")}</h1>
|
||||
<p className="text-sm text-[var(--color-text-muted)] mt-1">{t("description")}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fetchEntries()}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-text-main)] hover:bg-[var(--color-bg-alt)] transition-colors disabled:opacity-50"
|
||||
aria-label="Refresh activity feed"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span
|
||||
className="material-symbols-outlined text-[16px] animate-spin"
|
||||
aria-hidden="true"
|
||||
>
|
||||
progress_activity
|
||||
</span>
|
||||
Loading
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">
|
||||
refresh
|
||||
</span>
|
||||
Refresh
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filter */}
|
||||
<EventTypeFilter value={category} onChange={setCategory} />
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div
|
||||
className="p-4 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400 text-sm"
|
||||
role="alert"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Feed */}
|
||||
<div className="rounded-xl border border-[var(--color-border)] overflow-hidden bg-[var(--color-surface)]">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20 text-[var(--color-text-muted)]">
|
||||
<span
|
||||
className="material-symbols-outlined text-[32px] animate-spin mr-3"
|
||||
aria-hidden="true"
|
||||
>
|
||||
progress_activity
|
||||
</span>
|
||||
<span className="text-sm">Loading activity…</span>
|
||||
</div>
|
||||
) : (
|
||||
<ActivityFeed entries={filtered} referenceNowMs={referenceNowMs.current} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { groupByDay } from "@/lib/audit/timeline";
|
||||
import type { AuditLogEntry } from "@/lib/compliance/index";
|
||||
import DayHeader from "./DayHeader";
|
||||
import ActivityItem from "./ActivityItem";
|
||||
|
||||
interface ActivityFeedProps {
|
||||
entries: AuditLogEntry[];
|
||||
referenceNowMs?: number;
|
||||
}
|
||||
|
||||
export default function ActivityFeed({ entries, referenceNowMs }: ActivityFeedProps) {
|
||||
const t = useTranslations("activity");
|
||||
|
||||
if (!entries.length) {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center py-20 text-center"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[48px] text-[var(--color-text-muted)] mb-4" aria-hidden="true">
|
||||
timeline
|
||||
</span>
|
||||
<h3 className="text-base font-semibold text-[var(--color-text-main)] mb-1">
|
||||
{t("emptyTitle")}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--color-text-muted)] max-w-sm">{t("emptyDescription")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const groups = groupByDay(entries, referenceNowMs);
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-[var(--color-border)]">
|
||||
{groups.map((group) => (
|
||||
<section key={group.dayKey} aria-label={group.label}>
|
||||
<DayHeader label={group.label} dayKey={group.dayKey} />
|
||||
<ul className="divide-y divide-[var(--color-border)]">
|
||||
{group.entries.map((entry, idx) => (
|
||||
<ActivityItem
|
||||
key={typeof entry.id === "number" ? entry.id : `${group.dayKey}-${idx}`}
|
||||
entry={entry}
|
||||
referenceNowMs={referenceNowMs}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations, useLocale } from "next-intl";
|
||||
import { getActivityIcon } from "@/lib/audit/activityIcons";
|
||||
import { relativeTime } from "@/lib/audit/timeline";
|
||||
import type { AuditLogEntry } from "@/lib/compliance/index";
|
||||
|
||||
interface ActivityItemProps {
|
||||
entry: AuditLogEntry;
|
||||
referenceNowMs?: number;
|
||||
}
|
||||
|
||||
export default function ActivityItem({ entry, referenceNowMs }: ActivityItemProps) {
|
||||
const t = useTranslations("activity");
|
||||
const locale = useLocale();
|
||||
|
||||
const action = typeof entry.action === "string" ? entry.action : "";
|
||||
const actor = typeof entry.actor === "string" ? entry.actor : "system";
|
||||
const target = typeof entry.target === "string" ? entry.target : "";
|
||||
const timestamp = typeof entry.timestamp === "string" ? entry.timestamp : "";
|
||||
|
||||
const { icon, i18nKeyVerb } = getActivityIcon(action);
|
||||
|
||||
const safeLocale = locale === "pt-BR" ? "pt-BR" : "en";
|
||||
const timeAgo = timestamp ? relativeTime(timestamp, safeLocale, referenceNowMs) : "";
|
||||
|
||||
// Build human phrase — fall back to raw action if key not found
|
||||
let phrase: string;
|
||||
try {
|
||||
phrase = t(`eventVerb.${i18nKeyVerb}`, { actor, target: target || action });
|
||||
} catch {
|
||||
phrase = `${actor} — ${action}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<li className="flex items-start gap-3 px-4 py-3 hover:bg-[var(--color-bg-alt)] transition-colors">
|
||||
<span
|
||||
className="material-symbols-outlined flex-shrink-0 mt-0.5 text-[20px] text-[var(--color-accent)]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-[var(--color-text-main)] truncate" title={phrase}>
|
||||
{phrase}
|
||||
</p>
|
||||
{target && (
|
||||
<p className="text-xs text-[var(--color-text-muted)] truncate mt-0.5">{target}</p>
|
||||
)}
|
||||
</div>
|
||||
<time
|
||||
dateTime={timestamp}
|
||||
className="flex-shrink-0 text-xs text-[var(--color-text-muted)] whitespace-nowrap mt-0.5"
|
||||
title={timestamp}
|
||||
>
|
||||
{timeAgo}
|
||||
</time>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface DayHeaderProps {
|
||||
label: string;
|
||||
dayKey: string;
|
||||
}
|
||||
|
||||
export default function DayHeader({ label, dayKey }: DayHeaderProps) {
|
||||
const t = useTranslations("activity");
|
||||
|
||||
const displayLabel =
|
||||
label === "today"
|
||||
? t("todayHeader")
|
||||
: label === "yesterday"
|
||||
? t("yesterdayHeader")
|
||||
: label;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="sticky top-0 z-10 flex items-center gap-3 py-2 px-4 bg-[var(--color-bg)] border-b border-[var(--color-border)]"
|
||||
aria-label={displayLabel}
|
||||
>
|
||||
<span className="text-xs font-semibold uppercase tracking-widest text-[var(--color-text-muted)]">
|
||||
{displayLabel}
|
||||
</span>
|
||||
{label !== "today" && label !== "yesterday" && (
|
||||
<span className="text-xs text-[var(--color-text-muted)] opacity-60">{dayKey}</span>
|
||||
)}
|
||||
<div className="flex-1 h-px bg-[var(--color-border)]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export type EventCategory =
|
||||
| "all"
|
||||
| "providers"
|
||||
| "combos"
|
||||
| "apikeys"
|
||||
| "settings"
|
||||
| "quota"
|
||||
| "auth"
|
||||
| "system";
|
||||
|
||||
interface EventTypeFilterProps {
|
||||
value: EventCategory;
|
||||
onChange: (category: EventCategory) => void;
|
||||
}
|
||||
|
||||
const CATEGORIES: EventCategory[] = [
|
||||
"all",
|
||||
"providers",
|
||||
"combos",
|
||||
"apikeys",
|
||||
"settings",
|
||||
"quota",
|
||||
"auth",
|
||||
"system",
|
||||
];
|
||||
|
||||
const CATEGORY_PREFIXES: Record<EventCategory, string[]> = {
|
||||
all: [],
|
||||
providers: ["provider."],
|
||||
combos: ["combo."],
|
||||
apikeys: ["apikey."],
|
||||
settings: ["setting."],
|
||||
quota: ["quota.", "budget."],
|
||||
auth: ["auth."],
|
||||
system: ["update.", "deploy.", "skill.", "cloud_agent.", "mcp.", "webhook."],
|
||||
};
|
||||
|
||||
export function matchesCategory(action: string, category: EventCategory): boolean {
|
||||
if (category === "all") return true;
|
||||
const prefixes = CATEGORY_PREFIXES[category];
|
||||
return prefixes.some((prefix) => action.startsWith(prefix));
|
||||
}
|
||||
|
||||
export default function EventTypeFilter({ value, onChange }: EventTypeFilterProps) {
|
||||
const t = useTranslations("activity");
|
||||
|
||||
const labelKey: Record<EventCategory, string> = {
|
||||
all: "filterAll",
|
||||
providers: "filterProviders",
|
||||
combos: "filterCombos",
|
||||
apikeys: "filterApiKeys",
|
||||
settings: "filterSettings",
|
||||
quota: "filterQuota",
|
||||
auth: "filterAuth",
|
||||
system: "filterSystem",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-wrap gap-2"
|
||||
role="group"
|
||||
aria-label="Filter by event type"
|
||||
>
|
||||
{CATEGORIES.map((cat) => (
|
||||
<button
|
||||
key={cat}
|
||||
type="button"
|
||||
onClick={() => onChange(cat)}
|
||||
aria-pressed={value === cat}
|
||||
className={[
|
||||
"px-3 py-1 rounded-full text-xs font-medium border transition-colors",
|
||||
value === cat
|
||||
? "bg-[var(--color-accent)] text-white border-[var(--color-accent)]"
|
||||
: "bg-[var(--color-surface)] text-[var(--color-text-muted)] border-[var(--color-border)] hover:bg-[var(--color-bg-alt)]",
|
||||
].join(" ")}
|
||||
>
|
||||
{t(labelKey[cat])}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
7
src/app/(dashboard)/dashboard/activity/page.tsx
Normal file
7
src/app/(dashboard)/dashboard/activity/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import ActivityFeedClient from "./ActivityFeedClient";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function ActivityPage() {
|
||||
return <ActivityFeedClient />;
|
||||
}
|
||||
@@ -16,6 +16,14 @@ import {
|
||||
} from "./apiManagerPageUtils";
|
||||
import type { KeyStatus, KeyType } from "./apiManagerPageUtils";
|
||||
import { readActiveOnlyPreference, writeActiveOnlyPreference } from "./apiManagerPageStorage";
|
||||
import {
|
||||
buildApiKeyCreateScopes,
|
||||
mergeApiKeyPermissionScopes,
|
||||
} from "./apiManagerScopes";
|
||||
import {
|
||||
SELF_ACCOUNT_QUOTA_SCOPE,
|
||||
SELF_USAGE_SCOPE,
|
||||
} from "@/shared/constants/selfServiceScopes";
|
||||
|
||||
// Constants for validation
|
||||
const MAX_KEY_NAME_LENGTH = 200;
|
||||
@@ -130,6 +138,8 @@ export default function ApiManagerPageClient() {
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [newKeyName, setNewKeyName] = useState("");
|
||||
const [newKeyManageEnabled, setNewKeyManageEnabled] = useState(false);
|
||||
const [newKeySelfUsageEnabled, setNewKeySelfUsageEnabled] = useState(true);
|
||||
const [newKeyAccountQuotaEnabled, setNewKeyAccountQuotaEnabled] = useState(false);
|
||||
const [createdKey, setCreatedKey] = useState<string | null>(null);
|
||||
const [editingKey, setEditingKey] = useState<ApiKey | null>(null);
|
||||
const [showPermissionsModal, setShowPermissionsModal] = useState(false);
|
||||
@@ -351,7 +361,11 @@ export default function ApiManagerPageClient() {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: sanitizedName,
|
||||
scopes: newKeyManageEnabled ? ["manage"] : [],
|
||||
scopes: buildApiKeyCreateScopes({
|
||||
manageEnabled: newKeyManageEnabled,
|
||||
selfUsageEnabled: newKeySelfUsageEnabled,
|
||||
selfAccountQuotaEnabled: newKeyAccountQuotaEnabled,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
@@ -361,6 +375,8 @@ export default function ApiManagerPageClient() {
|
||||
await fetchData();
|
||||
setNewKeyName("");
|
||||
setNewKeyManageEnabled(false);
|
||||
setNewKeySelfUsageEnabled(true);
|
||||
setNewKeyAccountQuotaEnabled(false);
|
||||
setShowAddModal(false);
|
||||
} else {
|
||||
setCreateError(data.error || t("failedCreateKey"));
|
||||
@@ -999,6 +1015,8 @@ export default function ApiManagerPageClient() {
|
||||
setShowAddModal(false);
|
||||
setNewKeyName("");
|
||||
setNewKeyManageEnabled(false);
|
||||
setNewKeySelfUsageEnabled(true);
|
||||
setNewKeyAccountQuotaEnabled(false);
|
||||
setNameError(null);
|
||||
setCreateError(null);
|
||||
}}
|
||||
@@ -1041,6 +1059,58 @@ export default function ApiManagerPageClient() {
|
||||
{newKeyManageEnabled ? tc("enabled") : tc("disabled")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 p-3 rounded-lg border border-border bg-surface/40">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-medium text-text-main">{t("selfServiceVisibility")}</p>
|
||||
<p className="text-xs text-text-muted">{t("selfServiceVisibilityDesc")}</p>
|
||||
</div>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm text-text-main">{t("ownUsageVisibility")}</p>
|
||||
<p className="text-xs text-text-muted">{t("ownUsageVisibilityDesc")}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={newKeySelfUsageEnabled}
|
||||
onClick={() =>
|
||||
setNewKeySelfUsageEnabled((prev) => {
|
||||
if (prev) setNewKeyAccountQuotaEnabled(false);
|
||||
return !prev;
|
||||
})
|
||||
}
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-semibold transition-colors shrink-0 ${
|
||||
newKeySelfUsageEnabled
|
||||
? "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 border border-emerald-500/30"
|
||||
: "bg-black/5 dark:bg-white/5 text-text-muted border border-border"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">query_stats</span>
|
||||
{newKeySelfUsageEnabled ? tc("enabled") : tc("disabled")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm text-text-main">{t("sharedAccountQuotaVisibility")}</p>
|
||||
<p className="text-xs text-text-muted">{t("sharedAccountQuotaVisibilityDesc")}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={newKeyAccountQuotaEnabled}
|
||||
disabled={!newKeySelfUsageEnabled}
|
||||
onClick={() => setNewKeyAccountQuotaEnabled((prev) => !prev)}
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-semibold transition-colors shrink-0 ${
|
||||
newKeyAccountQuotaEnabled
|
||||
? "bg-amber-500/15 text-amber-700 dark:text-amber-300 border border-amber-500/30"
|
||||
: "bg-black/5 dark:bg-white/5 text-text-muted border border-border"
|
||||
} ${!newKeySelfUsageEnabled ? "opacity-50 cursor-not-allowed" : ""}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">account_balance</span>
|
||||
{newKeyAccountQuotaEnabled ? tc("enabled") : tc("disabled")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{createError && (
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-red-500/10 border border-red-500/30">
|
||||
<span className="material-symbols-outlined text-red-500 text-sm">error</span>
|
||||
@@ -1053,6 +1123,8 @@ export default function ApiManagerPageClient() {
|
||||
setShowAddModal(false);
|
||||
setNewKeyName("");
|
||||
setNewKeyManageEnabled(false);
|
||||
setNewKeySelfUsageEnabled(true);
|
||||
setNewKeyAccountQuotaEnabled(false);
|
||||
setNameError(null);
|
||||
setCreateError(null);
|
||||
}}
|
||||
@@ -1196,6 +1268,12 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
const [manageEnabled, setManageEnabled] = useState(
|
||||
Array.isArray(apiKey?.scopes) && apiKey.scopes.includes("manage")
|
||||
);
|
||||
const [selfUsageEnabled, setSelfUsageEnabled] = useState(
|
||||
Array.isArray(apiKey?.scopes) && apiKey.scopes.includes(SELF_USAGE_SCOPE)
|
||||
);
|
||||
const [selfAccountQuotaEnabled, setSelfAccountQuotaEnabled] = useState(
|
||||
Array.isArray(apiKey?.scopes) && apiKey.scopes.includes(SELF_ACCOUNT_QUOTA_SCOPE)
|
||||
);
|
||||
const [maxSessions, setMaxSessions] = useState(
|
||||
typeof apiKey?.maxSessions === "number" && apiKey.maxSessions > 0 ? apiKey.maxSessions : 0
|
||||
);
|
||||
@@ -1370,7 +1448,11 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
maxSessions,
|
||||
schedule,
|
||||
rateLimits.length > 0 ? rateLimits : null,
|
||||
manageEnabled ? ["manage"] : [],
|
||||
mergeApiKeyPermissionScopes(apiKey?.scopes, {
|
||||
manageEnabled,
|
||||
selfUsageEnabled,
|
||||
selfAccountQuotaEnabled,
|
||||
}),
|
||||
allowAllEndpoints ? [] : selectedEndpoints
|
||||
);
|
||||
}, [
|
||||
@@ -1390,6 +1472,8 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
expiresAt,
|
||||
maxSessions,
|
||||
manageEnabled,
|
||||
selfUsageEnabled,
|
||||
selfAccountQuotaEnabled,
|
||||
scheduleEnabled,
|
||||
scheduleFrom,
|
||||
scheduleUntil,
|
||||
@@ -1398,6 +1482,7 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
rateLimits,
|
||||
allowAllEndpoints,
|
||||
selectedEndpoints,
|
||||
apiKey?.scopes,
|
||||
t,
|
||||
]);
|
||||
|
||||
@@ -1842,6 +1927,48 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
{manageEnabled ? tc("enabled") : tc("disabled")}
|
||||
</button>
|
||||
</div>
|
||||
{/* Self-service Visibility */}
|
||||
<div className="flex flex-col gap-3 p-3 rounded-lg border border-border bg-surface/40">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-medium text-text-main">{t("selfServiceVisibility")}</p>
|
||||
<p className="text-xs text-text-muted">{t("selfServiceVisibilityDesc")}</p>
|
||||
</div>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={selfUsageEnabled}
|
||||
onClick={() =>
|
||||
setSelfUsageEnabled((prev) => {
|
||||
if (prev) setSelfAccountQuotaEnabled(false);
|
||||
return !prev;
|
||||
})
|
||||
}
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-semibold transition-colors ${
|
||||
selfUsageEnabled
|
||||
? "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 border border-emerald-500/30"
|
||||
: "bg-black/5 dark:bg-white/5 text-text-muted border border-border"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">query_stats</span>
|
||||
{t("ownUsageVisibility")} - {selfUsageEnabled ? tc("enabled") : tc("disabled")}
|
||||
</button>
|
||||
<p className="text-xs text-text-muted">{t("ownUsageVisibilityDesc")}</p>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={selfAccountQuotaEnabled}
|
||||
disabled={!selfUsageEnabled}
|
||||
onClick={() => setSelfAccountQuotaEnabled((prev) => !prev)}
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-semibold transition-colors ${
|
||||
selfAccountQuotaEnabled
|
||||
? "bg-amber-500/15 text-amber-700 dark:text-amber-300 border border-amber-500/30"
|
||||
: "bg-black/5 dark:bg-white/5 text-text-muted border border-border"
|
||||
} ${!selfUsageEnabled ? "opacity-50 cursor-not-allowed" : ""}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">account_balance</span>
|
||||
{t("sharedAccountQuotaVisibility")} -{" "}
|
||||
{selfAccountQuotaEnabled ? tc("enabled") : tc("disabled")}
|
||||
</button>
|
||||
<p className="text-xs text-text-muted">{t("sharedAccountQuotaVisibilityDesc")}</p>
|
||||
</div>
|
||||
|
||||
{/* Selected Models Summary (only in restrict mode) */}
|
||||
{!allowAll && selectedCount > 0 && (
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
SELF_ACCOUNT_QUOTA_SCOPE,
|
||||
SELF_USAGE_SCOPE,
|
||||
} from "@/shared/constants/selfServiceScopes";
|
||||
|
||||
const MANAGEMENT_SCOPE = "manage";
|
||||
|
||||
export interface CreateScopeOptions {
|
||||
manageEnabled: boolean;
|
||||
selfUsageEnabled?: boolean;
|
||||
selfAccountQuotaEnabled?: boolean;
|
||||
}
|
||||
|
||||
export interface PermissionScopeOptions {
|
||||
manageEnabled: boolean;
|
||||
selfUsageEnabled: boolean;
|
||||
selfAccountQuotaEnabled: boolean;
|
||||
}
|
||||
|
||||
export function buildApiKeyCreateScopes(options: CreateScopeOptions): string[] {
|
||||
const scopes: string[] = [];
|
||||
const selfUsageEnabled = options.selfUsageEnabled ?? true;
|
||||
if (options.manageEnabled) scopes.push(MANAGEMENT_SCOPE);
|
||||
if (selfUsageEnabled) scopes.push(SELF_USAGE_SCOPE);
|
||||
if (selfUsageEnabled && options.selfAccountQuotaEnabled === true) {
|
||||
scopes.push(SELF_ACCOUNT_QUOTA_SCOPE);
|
||||
}
|
||||
return scopes;
|
||||
}
|
||||
|
||||
export function mergeApiKeyPermissionScopes(
|
||||
currentScopes: readonly string[] | null | undefined,
|
||||
options: PermissionScopeOptions
|
||||
): string[] {
|
||||
const scopes = new Set((currentScopes ?? []).filter((scope) => typeof scope === "string"));
|
||||
|
||||
setScope(scopes, MANAGEMENT_SCOPE, options.manageEnabled);
|
||||
setScope(scopes, SELF_USAGE_SCOPE, options.selfUsageEnabled);
|
||||
setScope(
|
||||
scopes,
|
||||
SELF_ACCOUNT_QUOTA_SCOPE,
|
||||
options.selfUsageEnabled && options.selfAccountQuotaEnabled
|
||||
);
|
||||
|
||||
return [...scopes];
|
||||
}
|
||||
|
||||
function setScope(scopes: Set<string>, scope: string, enabled: boolean): void {
|
||||
if (enabled) {
|
||||
scopes.add(scope);
|
||||
} else {
|
||||
scopes.delete(scope);
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,7 @@ export default function ComplianceTab() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [eventType, setEventType] = useState("");
|
||||
const [actor, setActor] = useState("");
|
||||
const [severity, setSeverity] = useState<"all" | Severity>("all");
|
||||
const [from, setFrom] = useState("");
|
||||
const [to, setTo] = useState("");
|
||||
@@ -85,6 +86,7 @@ export default function ComplianceTab() {
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (actor) params.set("actor", actor);
|
||||
params.set("limit", String(PAGE_SIZE));
|
||||
params.set("offset", String(offset));
|
||||
if (eventType) params.set("action", eventType);
|
||||
@@ -105,7 +107,7 @@ export default function ComplianceTab() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [eventType, from, offset, t, to]);
|
||||
}, [actor, eventType, from, offset, t, to]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchEntries();
|
||||
@@ -120,10 +122,15 @@ export default function ComplianceTab() {
|
||||
return Array.from(new Set(entries.map((entry) => entry.action).filter(Boolean))).sort();
|
||||
}, [entries]);
|
||||
|
||||
const actors = useMemo(() => {
|
||||
return Array.from(new Set(entries.map((entry) => entry.actor).filter(Boolean))).sort();
|
||||
}, [entries]);
|
||||
|
||||
const canGoNext = offset + PAGE_SIZE < totalCount;
|
||||
|
||||
const resetFilters = () => {
|
||||
setEventType("");
|
||||
setActor("");
|
||||
setSeverity("all");
|
||||
setFrom("");
|
||||
setTo("");
|
||||
@@ -186,7 +193,7 @@ export default function ComplianceTab() {
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-5">
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-6">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-text-muted">
|
||||
{t("eventType")}
|
||||
@@ -207,6 +214,26 @@ export default function ComplianceTab() {
|
||||
))}
|
||||
</datalist>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-text-muted">
|
||||
{t("actor")}
|
||||
</span>
|
||||
<input
|
||||
list="compliance-actors"
|
||||
value={actor}
|
||||
onChange={(event) => {
|
||||
setOffset(0);
|
||||
setActor(event.target.value);
|
||||
}}
|
||||
placeholder={t("actorPlaceholder")}
|
||||
className="w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/40"
|
||||
/>
|
||||
<datalist id="compliance-actors">
|
||||
{actors.map((a) => (
|
||||
<option key={a} value={a} />
|
||||
))}
|
||||
</datalist>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-text-muted">
|
||||
{t("severity")}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { PoolAllocation } from "@/lib/quota/dimensions";
|
||||
import type { PoolUsageSnapshot } from "@/lib/quota/types";
|
||||
|
||||
interface AllocationTableProps {
|
||||
allocations: PoolAllocation[];
|
||||
usage: PoolUsageSnapshot | null;
|
||||
/** Map from apiKeyId to display name */
|
||||
keyLabels: Record<string, string>;
|
||||
}
|
||||
|
||||
const SLICE_PALETTE = [
|
||||
"#a78bfa",
|
||||
"#60a5fa",
|
||||
"#34d399",
|
||||
"#fbbf24",
|
||||
"#f87171",
|
||||
"#22d3ee",
|
||||
"#f472b6",
|
||||
"#94a3b8",
|
||||
];
|
||||
|
||||
export default function AllocationTable({ allocations, usage, keyLabels }: AllocationTableProps) {
|
||||
const t = useTranslations("quotaShare");
|
||||
|
||||
if (allocations.length === 0) {
|
||||
return (
|
||||
<div className="text-[11px] text-text-muted italic py-3 text-center bg-bg-subtle/40 rounded-md">
|
||||
{t("noAllocations")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Build per-key consumption lookup from first dimension (primary)
|
||||
const primaryDim = usage?.dimensions?.[0];
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[11px]">
|
||||
<thead>
|
||||
<tr className="text-[10px] uppercase tracking-wide text-text-muted border-b border-border/40">
|
||||
<th className="text-left py-1 pr-2 font-semibold">API Key</th>
|
||||
<th className="text-right py-1 pr-2 font-semibold">Weight</th>
|
||||
<th className="text-right py-1 pr-2 font-semibold">{t("realConsumedColumn")}</th>
|
||||
<th className="text-right py-1 pr-2 font-semibold">{t("deficitColumn")}</th>
|
||||
<th className="text-right py-1 font-semibold">Policy</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{allocations.map((alloc, i) => {
|
||||
const color = SLICE_PALETTE[i % SLICE_PALETTE.length];
|
||||
const label = keyLabels[alloc.apiKeyId] || alloc.apiKeyId.slice(0, 12) + "…";
|
||||
|
||||
const perKeyData = primaryDim?.perKey?.find((k) => k.apiKeyId === alloc.apiKeyId);
|
||||
const consumed = perKeyData?.consumed ?? null;
|
||||
const fairShare = perKeyData?.fairShare ?? null;
|
||||
const deficit = perKeyData !== undefined ? perKeyData.deficit : null;
|
||||
const borrowing = perKeyData?.borrowing ?? false;
|
||||
|
||||
return (
|
||||
<tr key={alloc.apiKeyId} className="border-b border-border/20 last:border-0">
|
||||
<td className="py-1.5 pr-2">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span
|
||||
className="inline-block w-2.5 h-2.5 rounded-sm shrink-0"
|
||||
style={{ background: color }}
|
||||
/>
|
||||
<span className="font-mono truncate text-text-main">{label}</span>
|
||||
{borrowing && (
|
||||
<span
|
||||
className="text-[9px] px-1 py-0.5 rounded bg-amber-500/15 text-amber-400 font-bold shrink-0"
|
||||
title={t("borrowingIndicator")}
|
||||
>
|
||||
{t("borrowingIndicator")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-1.5 pr-2 text-right font-bold tabular-nums" style={{ color }}>
|
||||
{alloc.weight}%
|
||||
</td>
|
||||
<td className="py-1.5 pr-2 text-right tabular-nums text-text-muted">
|
||||
{consumed !== null ? consumed.toLocaleString() : "—"}
|
||||
</td>
|
||||
<td className="py-1.5 pr-2 text-right tabular-nums">
|
||||
{deficit !== null ? (
|
||||
<span
|
||||
className={
|
||||
deficit > 0 ? "text-red-400" : deficit < 0 ? "text-emerald-400" : "text-text-muted"
|
||||
}
|
||||
>
|
||||
{deficit > 0 ? "+" : ""}{deficit.toLocaleString()}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-text-muted">—</span>
|
||||
)}
|
||||
{fairShare !== null && (
|
||||
<span className="text-[9px] text-text-muted ml-1">
|
||||
(fair: {fairShare.toLocaleString()})
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-1.5 text-right">
|
||||
<span
|
||||
className={`text-[9px] px-1.5 py-0.5 rounded font-semibold ${
|
||||
alloc.policy === "hard"
|
||||
? "bg-red-500/10 text-red-400"
|
||||
: alloc.policy === "soft"
|
||||
? "bg-amber-500/10 text-amber-400"
|
||||
: "bg-emerald-500/10 text-emerald-400"
|
||||
}`}
|
||||
>
|
||||
{alloc.policy}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { PoolUsageSnapshot } from "@/lib/quota/types";
|
||||
|
||||
// Lazy-load recharts — do NOT import at module level (B28)
|
||||
const RechartsLineChart = dynamic(
|
||||
() => import("recharts").then((m) => ({ default: m.LineChart })),
|
||||
{ ssr: false }
|
||||
);
|
||||
const RechartsLine = dynamic(() => import("recharts").then((m) => ({ default: m.Line })), {
|
||||
ssr: false,
|
||||
});
|
||||
const RechartsXAxis = dynamic(() => import("recharts").then((m) => ({ default: m.XAxis })), {
|
||||
ssr: false,
|
||||
});
|
||||
const RechartsYAxis = dynamic(() => import("recharts").then((m) => ({ default: m.YAxis })), {
|
||||
ssr: false,
|
||||
});
|
||||
const RechartsTooltip = dynamic(() => import("recharts").then((m) => ({ default: m.Tooltip })), {
|
||||
ssr: false,
|
||||
});
|
||||
const RechartsResponsiveContainer = dynamic(
|
||||
() => import("recharts").then((m) => ({ default: m.ResponsiveContainer })),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
export interface BurnRateChartProps {
|
||||
usage: PoolUsageSnapshot | null;
|
||||
}
|
||||
|
||||
export default function BurnRateChart({ usage }: BurnRateChartProps) {
|
||||
const t = useTranslations("quotaShare");
|
||||
// Capture mount time once — avoids impure Date.now() call on every render
|
||||
const [nowMs] = useState(() => Date.now());
|
||||
|
||||
const burnRate = usage?.burnRate;
|
||||
const hasData = burnRate && burnRate.tokensPerSecond > 0;
|
||||
|
||||
if (!hasData) {
|
||||
return (
|
||||
<div className="h-20 flex items-center justify-center rounded-md bg-bg-subtle/30 border border-border/30">
|
||||
<p className="text-[11px] text-text-muted italic">{t("burnRateTitle")} — no data yet</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { tokensPerSecond, timeToExhaustionMs } = burnRate;
|
||||
|
||||
// Build a simple 6-point projection line
|
||||
const pointCount = 6;
|
||||
const intervalMs = timeToExhaustionMs ? timeToExhaustionMs / pointCount : 60_000 * 60;
|
||||
|
||||
const primaryDim = usage?.dimensions?.[0];
|
||||
const currentConsumed = primaryDim?.consumedTotal ?? 0;
|
||||
const limit = primaryDim?.limit ?? 0;
|
||||
|
||||
const data = Array.from({ length: pointCount + 1 }, (_, i) => {
|
||||
const t2 = nowMs + i * intervalMs;
|
||||
const projected = Math.min(currentConsumed + tokensPerSecond * ((i * intervalMs) / 1000), limit);
|
||||
return {
|
||||
time: new Date(t2).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }),
|
||||
consumed: Math.round(projected),
|
||||
};
|
||||
});
|
||||
|
||||
const exhaustionLabel = timeToExhaustionMs
|
||||
? `${t("burnRateExhaustsIn")} ${fmtDuration(timeToExhaustionMs)}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between text-[10px] text-text-muted">
|
||||
<span className="font-semibold uppercase tracking-wide">{t("burnRateTitle")}</span>
|
||||
{exhaustionLabel && <span className="text-amber-400 font-semibold">{exhaustionLabel}</span>}
|
||||
</div>
|
||||
<div className="h-24">
|
||||
<RechartsResponsiveContainer width="100%" height="100%">
|
||||
<RechartsLineChart data={data}>
|
||||
<RechartsXAxis dataKey="time" tick={{ fontSize: 9 }} tickLine={false} axisLine={false} />
|
||||
<RechartsYAxis hide />
|
||||
<RechartsTooltip
|
||||
contentStyle={{
|
||||
background: "var(--bg-surface, #1e1e2e)",
|
||||
border: "1px solid var(--border)",
|
||||
fontSize: 10,
|
||||
}}
|
||||
/>
|
||||
<RechartsLine
|
||||
type="monotone"
|
||||
dataKey="consumed"
|
||||
stroke="#a78bfa"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
strokeDasharray="4 2"
|
||||
/>
|
||||
</RechartsLineChart>
|
||||
</RechartsResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function fmtDuration(ms: number): string {
|
||||
const h = Math.floor(ms / 3_600_000);
|
||||
const m = Math.floor((ms % 3_600_000) / 60_000);
|
||||
if (h >= 24) {
|
||||
const d = Math.floor(h / 24);
|
||||
return `${d}d ${h % 24}h`;
|
||||
}
|
||||
return `${h}h ${m}m`;
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button, Modal } from "@/shared/components";
|
||||
import type { QuotaPool, Policy, QuotaDimension } from "@/lib/quota/dimensions";
|
||||
|
||||
interface Connection {
|
||||
id: string;
|
||||
provider: string;
|
||||
name?: string;
|
||||
displayName?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
interface PlanInfo {
|
||||
dimensions: QuotaDimension[];
|
||||
source: "auto" | "manual";
|
||||
}
|
||||
|
||||
interface CreatePoolModalProps {
|
||||
connections: Connection[];
|
||||
plans: Record<string, PlanInfo>;
|
||||
existingPools: QuotaPool[];
|
||||
onClose: () => void;
|
||||
onCreate: (pool: Omit<QuotaPool, "id" | "createdAt">) => Promise<void>;
|
||||
}
|
||||
|
||||
export default function CreatePoolModal({
|
||||
connections,
|
||||
plans,
|
||||
existingPools,
|
||||
onClose,
|
||||
onCreate,
|
||||
}: CreatePoolModalProps) {
|
||||
const t = useTranslations("quotaShare");
|
||||
const [connectionId, setConnectionId] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [defaultPolicy, setDefaultPolicy] = useState<Policy>("hard");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const usedConnectionIds = useMemo(
|
||||
() => new Set(existingPools.map((p) => p.connectionId)),
|
||||
[existingPools]
|
||||
);
|
||||
|
||||
const selectedConn = connections.find((c) => c.id === connectionId);
|
||||
const planInfo = connectionId ? plans[connectionId] : undefined;
|
||||
const hasPlan = planInfo && planInfo.dimensions.length > 0;
|
||||
|
||||
const connLabel = (c: Connection) =>
|
||||
`${c.provider} / ${c.name || c.email || c.displayName || c.id.slice(0, 12)}`;
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!selectedConn) return;
|
||||
if (usedConnectionIds.has(connectionId)) {
|
||||
setError(t("duplicatePoolError"));
|
||||
return;
|
||||
}
|
||||
const poolName = name.trim() || connLabel(selectedConn);
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onCreate({
|
||||
connectionId,
|
||||
name: poolName,
|
||||
allocations: [],
|
||||
});
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to create pool");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen onClose={onClose} title={t("newPoolTitle")}>
|
||||
<div className="space-y-3">
|
||||
{/* Connection selector */}
|
||||
<div>
|
||||
<label className="text-[11px] uppercase tracking-wide text-text-muted font-semibold block mb-1">
|
||||
{t("providerConnection")}
|
||||
</label>
|
||||
<select
|
||||
value={connectionId}
|
||||
onChange={(e) => {
|
||||
setConnectionId(e.target.value);
|
||||
setName("");
|
||||
}}
|
||||
className="w-full px-3 py-2 rounded border border-border bg-bg-base text-sm"
|
||||
>
|
||||
<option value="">{t("selectConnection")}</option>
|
||||
{connections.map((c) => (
|
||||
<option key={c.id} value={c.id} disabled={usedConnectionIds.has(c.id)}>
|
||||
{connLabel(c)} {usedConnectionIds.has(c.id) ? t("alreadyUsedSuffix") : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{connections.length === 0 && (
|
||||
<p className="text-[10px] text-amber-400 mt-1">{t("noEligibleConnections")}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pool name */}
|
||||
{connectionId && (
|
||||
<div>
|
||||
<label className="text-[11px] uppercase tracking-wide text-text-muted font-semibold block mb-1">
|
||||
Pool name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={selectedConn ? connLabel(selectedConn) : "My quota pool"}
|
||||
className="w-full px-3 py-2 rounded border border-border bg-bg-base text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Default policy */}
|
||||
{connectionId && (
|
||||
<div>
|
||||
<label className="text-[11px] uppercase tracking-wide text-text-muted font-semibold block mb-1">
|
||||
{t("policyLabel")}
|
||||
</label>
|
||||
<div className="flex gap-1">
|
||||
{(["hard", "soft", "burst"] as Policy[]).map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
onClick={() => setDefaultPolicy(p)}
|
||||
className={`px-3 py-1.5 rounded-md border text-xs cursor-pointer transition-colors ${
|
||||
defaultPolicy === p
|
||||
? "bg-primary/15 border-primary/40 text-primary font-semibold"
|
||||
: "border-border text-text-muted hover:text-text-main"
|
||||
}`}
|
||||
>
|
||||
{p === "hard" ? t("policyHard") : p === "soft" ? t("policySoft") : t("policyBurst")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Plan info */}
|
||||
{connectionId && hasPlan && (
|
||||
<div className="rounded-md border border-border/40 bg-bg-subtle/30 p-3 text-[11px] text-text-muted">
|
||||
<div className="font-semibold text-text-main mb-1">
|
||||
{t("multiDimensionLabel")} ({planInfo.source})
|
||||
</div>
|
||||
{planInfo.dimensions.map((d, i) => (
|
||||
<div key={i}>
|
||||
{d.unit} / {d.window}: {d.limit}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cap absolute notice */}
|
||||
{connectionId && (
|
||||
<div className="text-[10px] text-text-muted">
|
||||
<span className="font-semibold">{t("policyCapAbsoluteLabel")}:</span>{" "}
|
||||
{t("policyCapAbsolutePlaceholder")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-[11px] text-red-400 bg-red-500/10 px-3 py-2 rounded">{error}</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2 border-t border-border/40">
|
||||
<Button variant="secondary" size="sm" onClick={onClose} disabled={saving}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleCreate}
|
||||
disabled={!selectedConn || saving}
|
||||
>
|
||||
{saving ? t("loading") : t("createPool")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { QuotaDimension } from "@/lib/quota/dimensions";
|
||||
|
||||
interface DimensionBarProps {
|
||||
dimension: QuotaDimension;
|
||||
consumedTotal: number;
|
||||
/** ISO string for next reset, or null */
|
||||
resetAt?: string | null;
|
||||
}
|
||||
|
||||
function fmtCountdown(ms: number): string {
|
||||
if (ms <= 0) return "now";
|
||||
const h = Math.floor(ms / 3_600_000);
|
||||
const m = Math.floor((ms % 3_600_000) / 60_000);
|
||||
if (h >= 24) {
|
||||
const d = Math.floor(h / 24);
|
||||
return `${d}d ${h % 24}h`;
|
||||
}
|
||||
return `${h}h ${m}m`;
|
||||
}
|
||||
|
||||
export default function DimensionBar({ dimension, consumedTotal, resetAt }: DimensionBarProps) {
|
||||
const t = useTranslations("quotaShare");
|
||||
// Capture mount time once — avoids impure Date.now() call on every render
|
||||
const [now] = useState(() => Date.now());
|
||||
const usedPct =
|
||||
dimension.limit > 0 ? Math.min((consumedTotal / dimension.limit) * 100, 100) : 0;
|
||||
|
||||
const barColor =
|
||||
usedPct >= 90
|
||||
? "bg-red-500"
|
||||
: usedPct >= 70
|
||||
? "bg-amber-400"
|
||||
: "bg-primary";
|
||||
|
||||
const resetMs = resetAt ? new Date(resetAt).getTime() - now : null;
|
||||
const countdown = resetMs !== null && resetMs > 0 ? fmtCountdown(resetMs) : null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<div className="flex items-center justify-between text-[10px] text-text-muted">
|
||||
<span className="font-semibold uppercase tracking-wide">
|
||||
{dimension.unit} / {dimension.window}
|
||||
</span>
|
||||
<span className="tabular-nums font-bold" style={{ color: usedPct >= 90 ? "#f87171" : usedPct >= 70 ? "#fbbf24" : undefined }}>
|
||||
{Math.round(usedPct)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 rounded-sm bg-black/6 dark:bg-white/6 overflow-hidden">
|
||||
<div className={`h-full rounded-sm transition-all ${barColor}`} style={{ width: `${usedPct}%` }} />
|
||||
</div>
|
||||
{countdown && (
|
||||
<div className="text-[10px] text-text-muted">
|
||||
{t("dimensionResetIn")} {countdown}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button, Modal } from "@/shared/components";
|
||||
import type { QuotaPool, PoolAllocation, Policy } from "@/lib/quota/dimensions";
|
||||
|
||||
interface ApiKey {
|
||||
id: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
interface EditAllocationsModalProps {
|
||||
pool: QuotaPool;
|
||||
apiKeys: ApiKey[];
|
||||
onClose: () => void;
|
||||
onSave: (allocations: PoolAllocation[]) => Promise<void>;
|
||||
}
|
||||
|
||||
function shortId(id: string, max = 12) {
|
||||
return id.length > max ? `${id.slice(0, max)}…` : id;
|
||||
}
|
||||
|
||||
const SLICE_PALETTE = [
|
||||
"#a78bfa",
|
||||
"#60a5fa",
|
||||
"#34d399",
|
||||
"#fbbf24",
|
||||
"#f87171",
|
||||
"#22d3ee",
|
||||
"#f472b6",
|
||||
"#94a3b8",
|
||||
];
|
||||
|
||||
export default function EditAllocationsModal({
|
||||
pool,
|
||||
apiKeys,
|
||||
onClose,
|
||||
onSave,
|
||||
}: EditAllocationsModalProps) {
|
||||
const t = useTranslations("quotaShare");
|
||||
const [drafts, setDrafts] = useState<PoolAllocation[]>(pool.allocations);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const totalWeight = drafts.reduce(
|
||||
(s, a) => s + (Number.isFinite(a.weight) ? a.weight : 0),
|
||||
0
|
||||
);
|
||||
|
||||
const availableKeys = apiKeys.filter((k) => !drafts.some((a) => a.apiKeyId === k.id));
|
||||
|
||||
const keyLabel = (id: string) => apiKeys.find((k) => k.id === id)?.name || shortId(id);
|
||||
|
||||
const addKey = (id: string) => {
|
||||
setDrafts((prev) => [...prev, { apiKeyId: id, weight: 0, policy: "hard" }]);
|
||||
};
|
||||
|
||||
const updateWeight = (id: string, value: number) => {
|
||||
setDrafts((prev) =>
|
||||
prev.map((a) =>
|
||||
a.apiKeyId === id ? { ...a, weight: Math.max(0, Math.min(100, value)) } : a
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const updatePolicy = (id: string, policy: Policy) => {
|
||||
setDrafts((prev) => prev.map((a) => (a.apiKeyId === id ? { ...a, policy } : a)));
|
||||
};
|
||||
|
||||
const updateCapValue = (id: string, capValue: number | undefined) => {
|
||||
setDrafts((prev) => prev.map((a) => (a.apiKeyId === id ? { ...a, capValue } : a)));
|
||||
};
|
||||
|
||||
const removeKey = (id: string) => {
|
||||
setDrafts((prev) => prev.filter((a) => a.apiKeyId !== id));
|
||||
};
|
||||
|
||||
const equalSplit = () => {
|
||||
if (drafts.length === 0) return;
|
||||
const each = Math.floor(100 / drafts.length);
|
||||
const remainder = 100 - each * drafts.length;
|
||||
setDrafts((prev) => prev.map((a, i) => ({ ...a, weight: each + (i < remainder ? 1 : 0) })));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onSave(drafts);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to save");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen onClose={onClose} title={t("editTitle")} size="lg">
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs text-text-muted">
|
||||
{t("pool")}: <strong className="text-text-main">{pool.name}</strong>
|
||||
</div>
|
||||
|
||||
{drafts.length === 0 ? (
|
||||
<div className="text-[12px] text-text-muted italic py-4 text-center bg-bg-subtle/40 rounded-md">
|
||||
{t("noKeysAdded")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{drafts.map((a, i) => {
|
||||
const color = SLICE_PALETTE[i % SLICE_PALETTE.length];
|
||||
return (
|
||||
<div
|
||||
key={a.apiKeyId}
|
||||
className="grid items-center gap-2"
|
||||
style={{ gridTemplateColumns: "12px minmax(0,1fr) 70px 80px 90px 24px" }}
|
||||
>
|
||||
<span
|
||||
className="inline-block w-3 h-3 rounded-sm"
|
||||
style={{ background: color }}
|
||||
/>
|
||||
<span className="text-[12px] font-mono truncate">{keyLabel(a.apiKeyId)}</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={a.weight}
|
||||
onChange={(e) => updateWeight(a.apiKeyId, Number(e.target.value))}
|
||||
className="px-2 py-1 rounded border border-border bg-bg-base text-sm text-right tabular-nums"
|
||||
title="Weight %"
|
||||
/>
|
||||
{/* Cap absolute */}
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={a.capValue ?? ""}
|
||||
onChange={(e) =>
|
||||
updateCapValue(a.apiKeyId, e.target.value ? Number(e.target.value) : undefined)
|
||||
}
|
||||
placeholder={t("policyCapAbsolutePlaceholder")}
|
||||
className="px-2 py-1 rounded border border-border bg-bg-base text-xs tabular-nums"
|
||||
title={t("policyCapAbsoluteLabel")}
|
||||
/>
|
||||
{/* Policy per key */}
|
||||
<select
|
||||
value={a.policy}
|
||||
onChange={(e) => updatePolicy(a.apiKeyId, e.target.value as Policy)}
|
||||
className="px-1 py-1 rounded border border-border bg-bg-base text-xs"
|
||||
>
|
||||
<option value="hard">{t("policyHard")}</option>
|
||||
<option value="soft">{t("policySoft")}</option>
|
||||
<option value="burst">{t("policyBurst")}</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeKey(a.apiKeyId)}
|
||||
className="p-0.5 rounded hover:bg-red-500/10 text-text-muted hover:text-red-400 cursor-pointer"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">close</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between text-[11px] pt-2 border-t border-border/40">
|
||||
<span
|
||||
className={`font-bold tabular-nums ${
|
||||
totalWeight === 100
|
||||
? "text-emerald-400"
|
||||
: totalWeight > 100
|
||||
? "text-red-400"
|
||||
: "text-amber-400"
|
||||
}`}
|
||||
>
|
||||
{t("totalLabel", { percent: totalWeight })}{" "}
|
||||
{totalWeight > 100 && t("totalExceeded")}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{availableKeys.length > 0 && (
|
||||
<select
|
||||
value=""
|
||||
onChange={(e) => e.target.value && addKey(e.target.value)}
|
||||
className="px-2 py-1 rounded border border-border bg-bg-base text-xs"
|
||||
>
|
||||
<option value="">{t("addKey")}</option>
|
||||
{availableKeys.map((k) => (
|
||||
<option key={k.id} value={k.id}>
|
||||
{k.name || shortId(k.id)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={equalSplit}
|
||||
disabled={drafts.length === 0}
|
||||
>
|
||||
{t("equalSplit")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-[11px] text-red-400 bg-red-500/10 px-3 py-2 rounded">{error}</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2 border-t border-border/40">
|
||||
<Button variant="secondary" size="sm" onClick={onClose} disabled={saving}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={totalWeight > 100 || saving}
|
||||
>
|
||||
{saving ? t("loading") : t("save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import Card from "@/shared/components/Card";
|
||||
import ProviderIcon from "@/shared/components/ProviderIcon";
|
||||
import type { QuotaPool } from "@/lib/quota/dimensions";
|
||||
import type { PoolUsageSnapshot } from "@/lib/quota/types";
|
||||
import DimensionBar from "./DimensionBar";
|
||||
import AllocationTable from "./AllocationTable";
|
||||
import BurnRateChart from "./BurnRateChart";
|
||||
import StackedAllocationBar from "./StackedAllocationBar";
|
||||
|
||||
export interface PoolCardProps {
|
||||
pool: QuotaPool;
|
||||
usage: PoolUsageSnapshot | null;
|
||||
/** Map from apiKeyId to display name */
|
||||
keyLabels: Record<string, string>;
|
||||
/** Connection display label */
|
||||
connectionLabel: string;
|
||||
/** Provider identifier */
|
||||
provider: string;
|
||||
onEdit: () => void;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
function computeStatus(usage: PoolUsageSnapshot | null): "green" | "amber" | "red" {
|
||||
if (!usage || usage.dimensions.length === 0) return "green";
|
||||
const utilizations = usage.dimensions.map((d) =>
|
||||
d.limit > 0 ? (d.consumedTotal / d.limit) * 100 : 0
|
||||
);
|
||||
const avg = utilizations.reduce((s, u) => s + u, 0) / utilizations.length;
|
||||
if (avg > 80) return "red";
|
||||
if (avg > 50) return "amber";
|
||||
return "green";
|
||||
}
|
||||
|
||||
const STATUS_ICONS = {
|
||||
green: { icon: "check_circle", cls: "text-emerald-400" },
|
||||
amber: { icon: "warning", cls: "text-amber-400" },
|
||||
red: { icon: "error", cls: "text-red-400" },
|
||||
};
|
||||
|
||||
export default function PoolCard({
|
||||
pool,
|
||||
usage,
|
||||
keyLabels,
|
||||
connectionLabel,
|
||||
provider,
|
||||
onEdit,
|
||||
onRemove,
|
||||
}: PoolCardProps) {
|
||||
const t = useTranslations("quotaShare");
|
||||
const status = computeStatus(usage);
|
||||
const { icon: statusIcon, cls: statusCls } = STATUS_ICONS[status];
|
||||
|
||||
// Check for plan dimensions from usage
|
||||
const hasDimensions = usage && usage.dimensions.length > 0;
|
||||
|
||||
return (
|
||||
<Card padding="md">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-3 mb-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="w-7 h-7 rounded-md flex items-center justify-center overflow-hidden shrink-0 bg-bg-subtle">
|
||||
<ProviderIcon providerId={provider} size={28} type="color" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`material-symbols-outlined text-[16px] shrink-0 ${statusCls}`}>
|
||||
{statusIcon}
|
||||
</span>
|
||||
<span className="text-sm font-bold text-text-main truncate">
|
||||
{pool.name} · {connectionLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-text-muted">
|
||||
{t("allocationsCount", { count: pool.allocations.length })} · ID: {pool.id.slice(0, 12)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
title={t("editAllocations")}
|
||||
className="p-1.5 rounded-md hover:bg-bg-subtle text-text-muted hover:text-text-main cursor-pointer"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">edit</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
title={t("removePool")}
|
||||
className="p-1.5 rounded-md hover:bg-red-500/10 text-text-muted hover:text-red-400 cursor-pointer"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">delete</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dimensions side-by-side */}
|
||||
{hasDimensions ? (
|
||||
<div
|
||||
className="grid gap-3 mb-3"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${Math.min(usage.dimensions.length, 3)}, 1fr)`,
|
||||
}}
|
||||
>
|
||||
{usage.dimensions.map((dim, i) => (
|
||||
<DimensionBar
|
||||
key={`${dim.unit}-${dim.window}-${i}`}
|
||||
dimension={{ unit: dim.unit, window: dim.window, limit: dim.limit }}
|
||||
consumedTotal={dim.consumedTotal}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-[11px] text-text-muted italic mb-3">
|
||||
{t("multiDimensionLabel")} — {t("loading")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stacked allocation bar — per-key slices */}
|
||||
<StackedAllocationBar
|
||||
allocations={pool.allocations}
|
||||
usage={usage}
|
||||
keyLabels={keyLabels}
|
||||
/>
|
||||
|
||||
{/* Allocation table */}
|
||||
<div className="mb-3">
|
||||
<h4 className="text-[10px] uppercase tracking-wide font-bold text-text-muted mb-1.5">
|
||||
Allocations
|
||||
</h4>
|
||||
<AllocationTable
|
||||
allocations={pool.allocations}
|
||||
usage={usage}
|
||||
keyLabels={keyLabels}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Burn rate chart */}
|
||||
{usage && (
|
||||
<div className="pt-2 border-t border-border/30">
|
||||
<BurnRateChart usage={usage} />
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Card from "@/shared/components/Card";
|
||||
|
||||
export default function QuotaConceptCard() {
|
||||
const t = useTranslations("quotaShare");
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<Card padding="md">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center justify-between gap-2 cursor-pointer"
|
||||
onClick={() => setExpanded((p) => !p)}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[20px] text-primary">info</span>
|
||||
<span className="text-sm font-semibold text-text-main">{t("conceptTitle")}</span>
|
||||
</div>
|
||||
<span className="material-symbols-outlined text-[18px] text-text-muted">
|
||||
{expanded ? "expand_less" : "expand_more"}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="mt-3 space-y-2 text-xs text-text-muted leading-relaxed">
|
||||
<p>{t("conceptIntro")}</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 pt-1">
|
||||
<ConceptItem icon="balance" text={t("conceptFairShare")} />
|
||||
<ConceptItem icon="trending_up" text={t("conceptBorrowing")} />
|
||||
<ConceptItem icon="lock" text={t("conceptGlobalCap")} />
|
||||
<ConceptItem icon="schedule" text={t("conceptWindows")} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ConceptItem({ icon, text }: { icon: string; text: string }) {
|
||||
return (
|
||||
<div className="flex items-start gap-1.5 rounded-md bg-bg-subtle/40 p-2">
|
||||
<span className="material-symbols-outlined text-[16px] text-primary shrink-0 mt-0.5">
|
||||
{icon}
|
||||
</span>
|
||||
<span>{text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { PoolAllocation } from "@/lib/quota/dimensions";
|
||||
import type { PoolUsageSnapshot } from "@/lib/quota/types";
|
||||
|
||||
export interface StackedAllocationBarProps {
|
||||
allocations: PoolAllocation[];
|
||||
usage: PoolUsageSnapshot | null;
|
||||
keyLabels: Record<string, string>;
|
||||
/** When usage has multiple dimensions, which one to display in this bar.
|
||||
* Default: the first dimension. */
|
||||
dimensionIndex?: number;
|
||||
}
|
||||
|
||||
const PALETTE = [
|
||||
"#a78bfa",
|
||||
"#60a5fa",
|
||||
"#34d399",
|
||||
"#fbbf24",
|
||||
"#f87171",
|
||||
"#22d3ee",
|
||||
"#f472b6",
|
||||
"#94a3b8",
|
||||
];
|
||||
|
||||
export default function StackedAllocationBar({
|
||||
allocations,
|
||||
usage,
|
||||
keyLabels,
|
||||
dimensionIndex = 0,
|
||||
}: StackedAllocationBarProps): JSX.Element | null {
|
||||
const t = useTranslations("quotaShare");
|
||||
|
||||
if (allocations.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build a map of apiKeyId → { consumed, fairShare } from the relevant dimension
|
||||
const perKeyMap: Record<string, { consumed: number; fairShare: number }> = {};
|
||||
if (usage) {
|
||||
const dim = usage.dimensions[dimensionIndex];
|
||||
if (dim) {
|
||||
for (const entry of dim.perKey) {
|
||||
perKeyMap[entry.apiKeyId] = { consumed: entry.consumed, fairShare: entry.fairShare };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-3">
|
||||
<h4 className="text-[10px] uppercase tracking-wide font-bold text-text-muted mb-1.5">
|
||||
{t("stackedBarTitle")}
|
||||
</h4>
|
||||
|
||||
{/* Stacked bar */}
|
||||
<div className="flex h-3 rounded overflow-hidden w-full mb-2">
|
||||
{allocations.map((alloc, i) => {
|
||||
const color = PALETTE[i % PALETTE.length];
|
||||
const keyUsage = perKeyMap[alloc.apiKeyId];
|
||||
let consumedPercent: number | null = null;
|
||||
if (keyUsage && keyUsage.fairShare > 0) {
|
||||
consumedPercent = Math.round((keyUsage.consumed / keyUsage.fairShare) * 100);
|
||||
}
|
||||
const label = keyLabels[alloc.apiKeyId] ?? alloc.apiKeyId;
|
||||
const tooltipText =
|
||||
consumedPercent !== null
|
||||
? `${label}: ${alloc.weight}% (${t("usedSuffix", { percent: consumedPercent })})`
|
||||
: `${label}: ${alloc.weight}%`;
|
||||
return (
|
||||
<div
|
||||
key={alloc.apiKeyId}
|
||||
style={{ width: `${alloc.weight}%`, backgroundColor: color }}
|
||||
title={tooltipText}
|
||||
aria-label={tooltipText}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Labels */}
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1">
|
||||
{allocations.map((alloc, i) => {
|
||||
const color = PALETTE[i % PALETTE.length];
|
||||
const keyUsage = perKeyMap[alloc.apiKeyId];
|
||||
let consumedPercent: number | null = null;
|
||||
if (keyUsage && keyUsage.fairShare > 0) {
|
||||
consumedPercent = Math.round((keyUsage.consumed / keyUsage.fairShare) * 100);
|
||||
}
|
||||
const label = keyLabels[alloc.apiKeyId] ?? alloc.apiKeyId;
|
||||
return (
|
||||
<span
|
||||
key={alloc.apiKeyId}
|
||||
className="flex items-center gap-1 text-[10px] text-text-muted"
|
||||
>
|
||||
<span
|
||||
className="inline-block w-2 h-2 rounded-sm shrink-0"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<span>
|
||||
{label} {alloc.weight}%
|
||||
{consumedPercent !== null && (
|
||||
<span className="text-text-muted/70">
|
||||
{" "}
|
||||
({t("usedSuffix", { percent: consumedPercent })})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import type { QuotaPool, PoolAllocation, Policy } from "@/lib/quota/dimensions";
|
||||
|
||||
const LS_KEY = "omniroute:quota-share:pools";
|
||||
|
||||
// Shape of a legacy localStorage pool (QuotaSharePageClient.tsx old format)
|
||||
interface LsPool {
|
||||
id?: string;
|
||||
connectionId?: string;
|
||||
provider?: string;
|
||||
accountLabel?: string;
|
||||
window?: string;
|
||||
policy?: string;
|
||||
allocations?: Array<{
|
||||
apiKeyId?: string;
|
||||
percent?: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface PoolCreate {
|
||||
connectionId: string;
|
||||
name: string;
|
||||
allocations: Array<{
|
||||
apiKeyId: string;
|
||||
weight: number;
|
||||
capValue?: number;
|
||||
capUnit?: string;
|
||||
policy: Policy;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function adaptLsPoolToApiSchema(lsPool: LsPool): PoolCreate {
|
||||
const connectionId = lsPool.connectionId || "";
|
||||
const name =
|
||||
lsPool.accountLabel ||
|
||||
lsPool.provider ||
|
||||
lsPool.connectionId?.slice(0, 12) ||
|
||||
"Migrated pool";
|
||||
const policy: Policy =
|
||||
lsPool.policy === "soft" || lsPool.policy === "burst"
|
||||
? (lsPool.policy as Policy)
|
||||
: "hard";
|
||||
|
||||
const allocations: PoolAllocation[] = (lsPool.allocations || [])
|
||||
.filter((a) => a.apiKeyId)
|
||||
.map((a) => ({
|
||||
apiKeyId: a.apiKeyId as string,
|
||||
weight: typeof a.percent === "number" ? Math.max(0, Math.min(100, a.percent)) : 0,
|
||||
policy,
|
||||
}));
|
||||
|
||||
return { connectionId, name, allocations };
|
||||
}
|
||||
|
||||
export interface UseLocalStoragePoolMigrationInput {
|
||||
pools: QuotaPool[];
|
||||
mutate: () => Promise<unknown>;
|
||||
}
|
||||
|
||||
export function useLocalStoragePoolMigration({
|
||||
pools,
|
||||
mutate,
|
||||
}: UseLocalStoragePoolMigrationInput): void {
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const raw = window.localStorage.getItem(LS_KEY);
|
||||
if (!raw) return;
|
||||
|
||||
// Idempotency: if DB already has pools, do not migrate
|
||||
if (pools.length > 0) {
|
||||
// Leave localStorage key intact (safety — let user verify before cleanup)
|
||||
return;
|
||||
}
|
||||
|
||||
let lsPools: unknown[] = [];
|
||||
try {
|
||||
lsPools = JSON.parse(raw) as unknown[];
|
||||
} catch {
|
||||
window.localStorage.removeItem(LS_KEY);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(lsPools) || lsPools.length === 0) {
|
||||
window.localStorage.removeItem(LS_KEY);
|
||||
return;
|
||||
}
|
||||
|
||||
// POST batch — migrate all pools
|
||||
Promise.all(
|
||||
lsPools.map((p) =>
|
||||
fetch("/api/quota/pools", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(adaptLsPoolToApiSchema(p as LsPool)),
|
||||
}).then((r) => r.ok)
|
||||
)
|
||||
)
|
||||
.then((results) => {
|
||||
if (results.every(Boolean)) {
|
||||
window.localStorage.removeItem(LS_KEY);
|
||||
void mutate();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// fail silent — try again on next load
|
||||
});
|
||||
}, [pools.length, mutate]);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { PoolUsageSnapshot } from "@/lib/quota/types";
|
||||
|
||||
export interface UsePoolUsageResult {
|
||||
usage: PoolUsageSnapshot | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function usePoolUsage(poolId: string, pollIntervalMs = 15_000): UsePoolUsageResult {
|
||||
const [usage, setUsage] = useState<PoolUsageSnapshot | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
const fetchUsage = useCallback(async () => {
|
||||
if (!poolId) return;
|
||||
try {
|
||||
const res = await fetch(`/api/quota/pools/${poolId}/usage`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = (await res.json()) as PoolUsageSnapshot;
|
||||
if (!mountedRef.current) return;
|
||||
setUsage(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
if (!mountedRef.current) return;
|
||||
setError(err instanceof Error ? err.message : "Failed to load usage");
|
||||
} finally {
|
||||
if (mountedRef.current) setLoading(false);
|
||||
}
|
||||
}, [poolId]);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
void fetchUsage();
|
||||
|
||||
const interval = setInterval(() => {
|
||||
void fetchUsage();
|
||||
}, pollIntervalMs);
|
||||
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [fetchUsage, pollIntervalMs]);
|
||||
|
||||
return { usage, loading, error };
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { QuotaPool } from "@/lib/quota/dimensions";
|
||||
|
||||
export interface UsePoolsResult {
|
||||
pools: QuotaPool[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
mutate: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function usePools(): UsePoolsResult {
|
||||
const [pools, setPools] = useState<QuotaPool[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
const fetchPools = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/quota/pools");
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
const data: unknown = await res.json();
|
||||
if (!mountedRef.current) return;
|
||||
const list = Array.isArray(data)
|
||||
? (data as QuotaPool[])
|
||||
: Array.isArray((data as { pools?: QuotaPool[] }).pools)
|
||||
? (data as { pools: QuotaPool[] }).pools
|
||||
: [];
|
||||
setPools(list);
|
||||
} catch (err) {
|
||||
if (!mountedRef.current) return;
|
||||
setError(err instanceof Error ? err.message : "Failed to load pools");
|
||||
} finally {
|
||||
if (mountedRef.current) setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
void fetchPools();
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, [fetchPools]);
|
||||
|
||||
const mutate = useCallback(async () => {
|
||||
await fetchPools();
|
||||
}, [fetchPools]);
|
||||
|
||||
return { pools, loading, error, mutate };
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import type { QuotaPool } from "@/lib/quota/dimensions";
|
||||
import type { PoolUsageSnapshot } from "@/lib/quota/types";
|
||||
|
||||
export interface PoolsUsageAggregate {
|
||||
avgUtilizationPercent: number; // 0-100
|
||||
borrowingKeyCount: number;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const POLL_MS = 15_000;
|
||||
|
||||
export function usePoolsUsageAggregate(pools: QuotaPool[]): PoolsUsageAggregate {
|
||||
const [state, setState] = useState<PoolsUsageAggregate>({
|
||||
avgUtilizationPercent: 0,
|
||||
borrowingKeyCount: 0,
|
||||
loading: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
const ids = pools.map((p) => p.id);
|
||||
if (ids.length === 0) {
|
||||
setState({ avgUtilizationPercent: 0, borrowingKeyCount: 0, loading: false, error: null });
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchAll = async () => {
|
||||
try {
|
||||
const snapshots = await Promise.all(
|
||||
ids.map((id) => fetch(`/api/quota/pools/${id}/usage`).then((r) => (r.ok ? r.json() : null)))
|
||||
);
|
||||
if (!mounted) return;
|
||||
const valid = snapshots.filter((s): s is { usage: PoolUsageSnapshot } => s !== null && !!s.usage);
|
||||
let totalUtil = 0;
|
||||
let utilCount = 0;
|
||||
let borrowing = 0;
|
||||
for (const { usage } of valid) {
|
||||
for (const dim of usage.dimensions) {
|
||||
if (dim.limit > 0) {
|
||||
totalUtil += (dim.consumedTotal / dim.limit) * 100;
|
||||
utilCount += 1;
|
||||
}
|
||||
for (const key of dim.perKey) {
|
||||
if (key.borrowing) borrowing += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
setState({
|
||||
avgUtilizationPercent: utilCount > 0 ? totalUtil / utilCount : 0,
|
||||
borrowingKeyCount: borrowing,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
} catch (err) {
|
||||
if (mounted) {
|
||||
setState((s) => ({ ...s, loading: false, error: err instanceof Error ? err.message : "fetch failed" }));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void fetchAll();
|
||||
const interval = setInterval(fetchAll, POLL_MS);
|
||||
return () => {
|
||||
mounted = false;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [pools.map((p) => p.id).join(",")]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return state;
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/shared/components";
|
||||
import ProviderIcon from "@/shared/components/ProviderIcon";
|
||||
import { knownProviders, getKnownPlan } from "@/lib/quota/planRegistry";
|
||||
import type { QuotaDimension, QuotaUnit, QuotaWindow } from "@/lib/quota/dimensions";
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Types
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Connection {
|
||||
id: string;
|
||||
provider: string;
|
||||
name?: string;
|
||||
displayName?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
interface ProviderPlanOverride {
|
||||
connectionId: string;
|
||||
provider: string;
|
||||
dimensions: QuotaDimension[];
|
||||
source: "auto" | "manual";
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Constants
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const UNIT_OPTIONS: QuotaUnit[] = ["percent", "requests", "tokens", "usd"];
|
||||
const WINDOW_OPTIONS: QuotaWindow[] = ["5h", "hourly", "daily", "weekly", "monthly"];
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Component
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ProviderPlanConfigClient() {
|
||||
const t = useTranslations("quotaPlans");
|
||||
|
||||
const [connections, setConnections] = useState<Connection[]>([]);
|
||||
const [selectedConnectionId, setSelectedConnectionId] = useState("");
|
||||
const [overrides, setOverrides] = useState<Record<string, ProviderPlanOverride>>({});
|
||||
const [editDimensions, setEditDimensions] = useState<QuotaDimension[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [reverting, setReverting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [successMsg, setSuccessMsg] = useState<string | null>(null);
|
||||
|
||||
// ── Load connections and existing overrides ───────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
fetch("/api/providers/client")
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.catch(() => null),
|
||||
fetch("/api/quota/plans")
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.catch(() => null),
|
||||
])
|
||||
.then(([connsData, plansData]) => {
|
||||
const conns: Connection[] = Array.isArray(connsData?.connections)
|
||||
? connsData.connections
|
||||
: [];
|
||||
setConnections(conns);
|
||||
|
||||
if (Array.isArray(plansData)) {
|
||||
const map: Record<string, ProviderPlanOverride> = {};
|
||||
for (const p of plansData as ProviderPlanOverride[]) {
|
||||
if (p.connectionId) map[p.connectionId] = p;
|
||||
}
|
||||
setOverrides(map);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setError("Failed to load data");
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
// ── Derived: selected connection and plan info ────────────────────────────
|
||||
|
||||
const selectedConn = connections.find((c) => c.id === selectedConnectionId);
|
||||
const selectedProvider = selectedConn?.provider || "";
|
||||
|
||||
const existingOverride = selectedConnectionId ? overrides[selectedConnectionId] : undefined;
|
||||
const catalogPlan = selectedProvider ? getKnownPlan(selectedProvider) : null;
|
||||
|
||||
const detectedSource = existingOverride?.source || (catalogPlan ? "auto" : null);
|
||||
|
||||
const connLabel = (c: Connection) =>
|
||||
`${c.provider} / ${c.name || c.email || c.displayName || c.id.slice(0, 12)}`;
|
||||
|
||||
// ── When connection changes, populate edit dimensions ─────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedConnectionId) {
|
||||
setEditDimensions([]);
|
||||
return;
|
||||
}
|
||||
// Priority: manual override > catalog
|
||||
if (existingOverride && existingOverride.source === "manual") {
|
||||
setEditDimensions(existingOverride.dimensions);
|
||||
} else if (catalogPlan) {
|
||||
setEditDimensions([...catalogPlan.dimensions]);
|
||||
} else {
|
||||
setEditDimensions([]);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedConnectionId]);
|
||||
|
||||
// ── Dimension editors ─────────────────────────────────────────────────────
|
||||
|
||||
const addDimension = () => {
|
||||
setEditDimensions((prev) => [...prev, { unit: "percent", window: "daily", limit: 100 }]);
|
||||
};
|
||||
|
||||
const removeDimension = (i: number) => {
|
||||
setEditDimensions((prev) => prev.filter((_, idx) => idx !== i));
|
||||
};
|
||||
|
||||
const updateDimension = (i: number, patch: Partial<QuotaDimension>) => {
|
||||
setEditDimensions((prev) => prev.map((d, idx) => (idx === i ? { ...d, ...patch } : d)));
|
||||
};
|
||||
|
||||
// ── Save override ─────────────────────────────────────────────────────────
|
||||
|
||||
const handleSaveOverride = useCallback(async () => {
|
||||
if (!selectedConnectionId) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setSuccessMsg(null);
|
||||
try {
|
||||
const res = await fetch(`/api/quota/plans/${selectedConnectionId}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ dimensions: editDimensions }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
// Refresh overrides
|
||||
const data = (await res.json()) as ProviderPlanOverride;
|
||||
setOverrides((prev) => ({ ...prev, [selectedConnectionId]: data }));
|
||||
setSuccessMsg(t("saveOverrideButton") + " — saved");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Save failed");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [selectedConnectionId, editDimensions, t]);
|
||||
|
||||
// ── Revert to catalog ─────────────────────────────────────────────────────
|
||||
|
||||
const handleRevertToCatalog = useCallback(async () => {
|
||||
if (!selectedConnectionId) return;
|
||||
setReverting(true);
|
||||
setError(null);
|
||||
setSuccessMsg(null);
|
||||
try {
|
||||
const res = await fetch(`/api/quota/plans/${selectedConnectionId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
setOverrides((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[selectedConnectionId];
|
||||
return next;
|
||||
});
|
||||
// Reset edit dims to catalog
|
||||
if (catalogPlan) setEditDimensions([...catalogPlan.dimensions]);
|
||||
else setEditDimensions([]);
|
||||
setSuccessMsg(t("revertToCatalogButton") + " — reverted");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Revert failed");
|
||||
} finally {
|
||||
setReverting(false);
|
||||
}
|
||||
}, [selectedConnectionId, catalogPlan, t]);
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-text-main flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[24px] text-primary">fact_check</span>
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="text-sm text-text-muted mt-0.5">{t("description")}</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-text-muted text-sm py-10 text-center animate-pulse">Loading…</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[320px_1fr] gap-4">
|
||||
{/* Left: connection selector */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<div>
|
||||
<label className="text-[11px] uppercase tracking-wide text-text-muted font-semibold block mb-1">
|
||||
{t("providerLabel")}
|
||||
</label>
|
||||
<select
|
||||
value={selectedConnectionId}
|
||||
onChange={(e) => setSelectedConnectionId(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded border border-border bg-bg-base text-sm"
|
||||
>
|
||||
<option value="">— {t("providerLabel")} —</option>
|
||||
{connections.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{connLabel(c)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Catalog known plans */}
|
||||
<div className="rounded-lg border border-border/40 bg-bg-subtle/20 p-3">
|
||||
<div className="text-[10px] uppercase tracking-wide font-bold text-text-muted mb-2">
|
||||
{t("catalogTitle")}
|
||||
</div>
|
||||
<p className="text-[11px] text-text-muted mb-2">{t("catalogDescription")}</p>
|
||||
<div className="space-y-1.5">
|
||||
{knownProviders().map((prov) => {
|
||||
const plan = getKnownPlan(prov);
|
||||
if (!plan) return null;
|
||||
return (
|
||||
<div
|
||||
key={prov}
|
||||
className="flex items-start gap-2 text-[11px] rounded-md bg-bg-subtle/30 px-2 py-1.5"
|
||||
>
|
||||
<div className="w-4 h-4 mt-0.5 rounded-sm overflow-hidden shrink-0">
|
||||
<ProviderIcon providerId={prov} size={16} type="color" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold text-text-main capitalize">{prov}</div>
|
||||
{plan.dimensions.map((d, i) => (
|
||||
<div key={i} className="text-text-muted">
|
||||
{d.unit}/{d.window}: {d.limit}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: plan config */}
|
||||
{selectedConnectionId ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Status badge */}
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
{selectedProvider && (
|
||||
<div className="w-5 h-5 rounded-sm overflow-hidden">
|
||||
<ProviderIcon providerId={selectedProvider} size={20} type="color" />
|
||||
</div>
|
||||
)}
|
||||
<span className="font-semibold text-text-main">{connLabel(selectedConn!)}</span>
|
||||
{detectedSource === "auto" && (
|
||||
<span className="px-2 py-0.5 rounded bg-emerald-500/10 text-emerald-400 text-[10px] font-bold">
|
||||
{t("detectedPlanLabel")} (auto)
|
||||
</span>
|
||||
)}
|
||||
{detectedSource === "manual" && (
|
||||
<span className="px-2 py-0.5 rounded bg-blue-500/10 text-blue-400 text-[10px] font-bold">
|
||||
{t("manualPlanLabel")}
|
||||
</span>
|
||||
)}
|
||||
{!detectedSource && (
|
||||
<span className="px-2 py-0.5 rounded bg-amber-500/10 text-amber-400 text-[10px] font-bold">
|
||||
{t("unconfiguredLabel")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dimensions editor */}
|
||||
<div className="rounded-lg border border-border/40 bg-bg-subtle/10 p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[11px] uppercase tracking-wide font-bold text-text-muted">
|
||||
{t("dimensionLabel")}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addDimension}
|
||||
className="text-[11px] text-primary hover:underline cursor-pointer flex items-center gap-1"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">add</span>
|
||||
{t("addDimension")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{editDimensions.length === 0 && (
|
||||
<div className="text-[11px] text-text-muted italic py-3 text-center">
|
||||
{t("unconfiguredLabel")} — {t("addDimension")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{editDimensions.map((dim, i) => (
|
||||
<div key={i} className="grid items-center gap-2" style={{ gridTemplateColumns: "1fr 1fr 90px 24px" }}>
|
||||
<select
|
||||
value={dim.unit}
|
||||
onChange={(e) => updateDimension(i, { unit: e.target.value as QuotaUnit })}
|
||||
className="px-2 py-1.5 rounded border border-border bg-bg-base text-xs"
|
||||
>
|
||||
{UNIT_OPTIONS.map((u) => (
|
||||
<option key={u} value={u}>
|
||||
{t(`unitOptions.${u}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={dim.window}
|
||||
onChange={(e) => updateDimension(i, { window: e.target.value as QuotaWindow })}
|
||||
className="px-2 py-1.5 rounded border border-border bg-bg-base text-xs"
|
||||
>
|
||||
{WINDOW_OPTIONS.map((w) => (
|
||||
<option key={w} value={w}>
|
||||
{t(`windowOptions.${w}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={dim.limit}
|
||||
onChange={(e) => updateDimension(i, { limit: Number(e.target.value) })}
|
||||
placeholder={t("limitLabel")}
|
||||
className="px-2 py-1.5 rounded border border-border bg-bg-base text-xs tabular-nums text-right"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeDimension(i)}
|
||||
className="p-0.5 rounded hover:bg-red-500/10 text-text-muted hover:text-red-400 cursor-pointer"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">close</span>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error / success */}
|
||||
{error && (
|
||||
<p className="text-[11px] text-red-400 bg-red-500/10 px-3 py-2 rounded">{error}</p>
|
||||
)}
|
||||
{successMsg && (
|
||||
<p className="text-[11px] text-emerald-400 bg-emerald-500/10 px-3 py-2 rounded">
|
||||
{successMsg}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleSaveOverride}
|
||||
disabled={saving || editDimensions.length === 0}
|
||||
>
|
||||
{saving ? "Saving…" : t("saveOverrideButton")}
|
||||
</Button>
|
||||
{existingOverride && existingOverride.source === "manual" && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleRevertToCatalog}
|
||||
disabled={reverting}
|
||||
>
|
||||
{reverting ? "Reverting…" : t("revertToCatalogButton")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-16 text-text-muted text-sm">
|
||||
{t("unknownProviderNotice")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import ProviderPlanConfigClient from "./ProviderPlanConfigClient";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function PlansPage() {
|
||||
return <ProviderPlanConfigClient />;
|
||||
}
|
||||
@@ -1,379 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Audit Log Tab — Embedded version of the audit-log page for the Logs dashboard.
|
||||
* Fetches from /api/compliance/audit-log with filter support.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface AuditEntry {
|
||||
id: number;
|
||||
timestamp: string;
|
||||
action: string;
|
||||
actor: string;
|
||||
target?: string | null;
|
||||
details?: unknown;
|
||||
metadata?: unknown;
|
||||
ip_address?: string | null;
|
||||
resourceType?: string | null;
|
||||
status?: string | null;
|
||||
requestId?: string | null;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
export default function AuditLogTab() {
|
||||
const [entries, setEntries] = useState<AuditEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [actionFilter, setActionFilter] = useState("");
|
||||
const [actorFilter, setActorFilter] = useState("");
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [selectedEntry, setSelectedEntry] = useState<AuditEntry | null>(null);
|
||||
const t = useTranslations("logs");
|
||||
|
||||
const fetchEntries = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (actionFilter) params.set("action", actionFilter);
|
||||
if (actorFilter) params.set("actor", actorFilter);
|
||||
params.set("limit", String(PAGE_SIZE + 1));
|
||||
params.set("offset", String(offset));
|
||||
|
||||
const res = await fetch(`/api/compliance/audit-log?${params.toString()}`);
|
||||
if (!res.ok) throw new Error(t("failedFetchAuditLog"));
|
||||
const data = (await res.json()) as AuditEntry[];
|
||||
const total = Number(res.headers.get("x-total-count") || "0");
|
||||
|
||||
setHasMore(data.length > PAGE_SIZE);
|
||||
setEntries(data.slice(0, PAGE_SIZE));
|
||||
setTotalCount(Number.isFinite(total) ? total : 0);
|
||||
} catch (err: any) {
|
||||
setError(err.message || t("failedFetchAuditLog"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [actionFilter, actorFilter, offset, t]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchEntries();
|
||||
}, [fetchEntries]);
|
||||
|
||||
const handleSearch = () => {
|
||||
if (offset === 0) {
|
||||
fetchEntries();
|
||||
return;
|
||||
}
|
||||
setOffset(0);
|
||||
};
|
||||
|
||||
const formatTimestamp = (ts: string) => {
|
||||
try {
|
||||
return new Date(ts).toLocaleString();
|
||||
} catch {
|
||||
return ts;
|
||||
}
|
||||
};
|
||||
|
||||
const actionBadgeColor = (action: string) => {
|
||||
if (action === "provider.warning") return "bg-amber-500/15 text-amber-300 border-amber-500/20";
|
||||
if (action.includes("delete") || action.includes("remove"))
|
||||
return "bg-red-500/15 text-red-400 border-red-500/20";
|
||||
if (action.includes("create") || action.includes("add"))
|
||||
return "bg-green-500/15 text-green-400 border-green-500/20";
|
||||
if (action.includes("update") || action.includes("change"))
|
||||
return "bg-blue-500/15 text-blue-400 border-blue-500/20";
|
||||
if (action.includes("login") || action.includes("auth"))
|
||||
return "bg-purple-500/15 text-purple-400 border-purple-500/20";
|
||||
return "bg-gray-500/15 text-gray-400 border-gray-500/20";
|
||||
};
|
||||
|
||||
const statusBadgeColor = (status?: string | null) => {
|
||||
if (!status) return "bg-gray-500/15 text-gray-400 border-gray-500/20";
|
||||
if (status === "success") return "bg-green-500/15 text-green-400 border-green-500/20";
|
||||
if (status === "warning" || status === "blocked")
|
||||
return "bg-amber-500/15 text-amber-300 border-amber-500/20";
|
||||
if (status === "error" || status === "failed")
|
||||
return "bg-red-500/15 text-red-400 border-red-500/20";
|
||||
return "bg-blue-500/15 text-blue-400 border-blue-500/20";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-[var(--color-text-main)]">{t("auditLog")}</h2>
|
||||
<p className="text-sm text-[var(--color-text-muted)] mt-1">{t("auditLogDesc")}</p>
|
||||
<p className="mt-2 text-xs text-[var(--color-text-muted)]">
|
||||
{t("totalEntries", { count: totalCount })}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchEntries}
|
||||
disabled={loading}
|
||||
aria-label={t("refreshAuditLogAria")}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-text-main)] hover:bg-[var(--color-bg-alt)] transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? t("loading") : t("refresh")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex flex-wrap gap-3 p-4 rounded-xl bg-[var(--color-surface)] border border-[var(--color-border)]"
|
||||
role="search"
|
||||
aria-label={t("filterEntriesAria")}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t("filterByAction")}
|
||||
value={actionFilter}
|
||||
onChange={(e) => setActionFilter(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
aria-label={t("filterByActionTypeAria")}
|
||||
className="flex-1 min-w-[180px] px-3 py-2 rounded-lg text-sm bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-text-main)] placeholder:text-[var(--color-text-muted)] focus:outline-2 focus:outline-[var(--color-accent)]"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t("filterByActor")}
|
||||
value={actorFilter}
|
||||
onChange={(e) => setActorFilter(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
aria-label={t("filterByActorAria")}
|
||||
className="flex-1 min-w-[180px] px-3 py-2 rounded-lg text-sm bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-text-main)] placeholder:text-[var(--color-text-muted)] focus:outline-2 focus:outline-[var(--color-accent)]"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium bg-[var(--color-accent)] text-white hover:bg-[var(--color-accent-hover)] transition-colors focus:outline-2 focus:outline-offset-2 focus:outline-[var(--color-accent)]"
|
||||
>
|
||||
{t("search")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div
|
||||
className="p-4 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400 text-sm"
|
||||
role="alert"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto rounded-xl border border-[var(--color-border)]">
|
||||
<table className="w-full text-sm" role="table" aria-label={t("tableAria")}>
|
||||
<thead>
|
||||
<tr className="bg-[var(--color-bg-alt)] border-b border-[var(--color-border)]">
|
||||
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
|
||||
{t("timestamp")}
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
|
||||
{t("action")}
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
|
||||
{t("status")}
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
|
||||
{t("actor")}
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
|
||||
{t("target")}
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
|
||||
{t("resourceType")}
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
|
||||
{t("ipAddress")}
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
|
||||
{t("requestId")}
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
|
||||
{t("details")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.length === 0 && !loading ? (
|
||||
<tr>
|
||||
<td colSpan={9} className="px-4 py-8 text-center text-[var(--color-text-muted)]">
|
||||
{t("noEntries")}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
entries.map((entry) => (
|
||||
<tr
|
||||
key={entry.id}
|
||||
className="border-b border-[var(--color-border)] hover:bg-[var(--color-bg-alt)] transition-colors"
|
||||
>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-[var(--color-text-muted)] font-mono text-xs">
|
||||
{formatTimestamp(entry.timestamp)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded-md text-xs font-medium border ${actionBadgeColor(entry.action)}`}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{entry.action === "provider.warning" && (
|
||||
<span className="material-symbols-outlined text-[14px]">warning</span>
|
||||
)}
|
||||
{entry.action}
|
||||
</span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded-md text-xs font-medium border ${statusBadgeColor(entry.status)}`}
|
||||
>
|
||||
{entry.status || t("notAvailable")}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[var(--color-text-main)]">{entry.actor}</td>
|
||||
<td className="px-4 py-3 text-[var(--color-text-muted)] max-w-[200px] truncate">
|
||||
{entry.target || t("notAvailable")}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[var(--color-text-muted)] whitespace-nowrap">
|
||||
{entry.resourceType || t("notAvailable")}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[var(--color-text-muted)] font-mono text-xs whitespace-nowrap">
|
||||
{entry.ip_address || t("notAvailable")}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[var(--color-text-muted)] font-mono text-xs whitespace-nowrap">
|
||||
{entry.requestId || t("notAvailable")}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedEntry(entry)}
|
||||
className="rounded-md border border-[var(--color-border)] px-3 py-1.5 text-xs font-medium text-[var(--color-text-main)] transition-colors hover:bg-[var(--color-bg-alt)]"
|
||||
>
|
||||
{t("viewDetails")}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-[var(--color-text-muted)]">
|
||||
{t("showing", { count: entries.length, offset })}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}
|
||||
disabled={offset === 0}
|
||||
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-text-main)] hover:bg-[var(--color-bg-alt)] disabled:opacity-30 transition-colors"
|
||||
>
|
||||
← {t("previous")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setOffset(offset + PAGE_SIZE)}
|
||||
disabled={!hasMore}
|
||||
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-text-main)] hover:bg-[var(--color-bg-alt)] disabled:opacity-30 transition-colors"
|
||||
>
|
||||
{t("next")} →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedEntry && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 px-4 py-6">
|
||||
<div className="flex max-h-[85vh] w-full max-w-5xl flex-col overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] shadow-2xl">
|
||||
<div className="flex items-start justify-between gap-4 border-b border-[var(--color-border)] px-6 py-5">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[var(--color-text-main)]">
|
||||
{selectedEntry.action}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
||||
{t("auditModalSubtitle", {
|
||||
actor: selectedEntry.actor || t("notAvailable"),
|
||||
target: selectedEntry.target || t("notAvailable"),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedEntry(null)}
|
||||
className="rounded-full border border-[var(--color-border)] p-2 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-bg-alt)] hover:text-[var(--color-text-main)]"
|
||||
aria-label={t("close")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">close</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto px-6 py-5">
|
||||
{selectedEntry.action === "provider.warning" && (
|
||||
<div className="mb-5 rounded-xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-amber-100">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="material-symbols-outlined text-[18px]">warning</span>
|
||||
<div>
|
||||
<p className="font-medium">{t("providerWarningTitle")}</p>
|
||||
<p className="mt-1 text-amber-200">{t("providerWarningDesc")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-bg)] p-4">
|
||||
<h4 className="mb-3 text-sm font-semibold text-[var(--color-text-main)]">
|
||||
{t("eventMetadata")}
|
||||
</h4>
|
||||
<dl className="space-y-2 text-sm">
|
||||
<div className="flex justify-between gap-3">
|
||||
<dt className="text-[var(--color-text-muted)]">{t("timestamp")}</dt>
|
||||
<dd className="text-[var(--color-text-main)]">
|
||||
{formatTimestamp(selectedEntry.timestamp)}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-3">
|
||||
<dt className="text-[var(--color-text-muted)]">{t("status")}</dt>
|
||||
<dd className="text-[var(--color-text-main)]">
|
||||
{selectedEntry.status || t("notAvailable")}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-3">
|
||||
<dt className="text-[var(--color-text-muted)]">{t("resourceType")}</dt>
|
||||
<dd className="text-[var(--color-text-main)]">
|
||||
{selectedEntry.resourceType || t("notAvailable")}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-3">
|
||||
<dt className="text-[var(--color-text-muted)]">{t("requestId")}</dt>
|
||||
<dd className="font-mono text-[var(--color-text-main)]">
|
||||
{selectedEntry.requestId || t("notAvailable")}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-3">
|
||||
<dt className="text-[var(--color-text-muted)]">{t("ipAddress")}</dt>
|
||||
<dd className="font-mono text-[var(--color-text-main)]">
|
||||
{selectedEntry.ip_address || t("notAvailable")}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-bg)] p-4">
|
||||
<h4 className="mb-3 text-sm font-semibold text-[var(--color-text-main)]">
|
||||
{t("eventPayload")}
|
||||
</h4>
|
||||
<pre className="overflow-x-auto rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] p-3 text-xs text-[var(--color-text-muted)]">
|
||||
{JSON.stringify(selectedEntry.metadata || selectedEntry.details || {}, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,7 +24,7 @@ interface LogEntry {
|
||||
}
|
||||
|
||||
export default function CompressionLogTab() {
|
||||
const t = useTranslations("settings");
|
||||
const t = useTranslations("logs");
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"use client";
|
||||
import { permanentRedirect } from "next/navigation";
|
||||
|
||||
import AuditLogTab from "../AuditLogTab";
|
||||
|
||||
export default function LogsActivityPage() {
|
||||
return <AuditLogTab />;
|
||||
export default function LogsActivityRedirect() {
|
||||
permanentRedirect("/dashboard/activity");
|
||||
}
|
||||
|
||||
@@ -93,6 +93,54 @@ export const WEB_SESSION_CREDENTIAL_REQUIREMENTS = {
|
||||
placeholder: "token_value user@example.com",
|
||||
acceptsFullCookieHeader: false,
|
||||
},
|
||||
"duckduckgo-web": {
|
||||
kind: "none",
|
||||
credentialName: "",
|
||||
placeholder: "",
|
||||
acceptsFullCookieHeader: false,
|
||||
},
|
||||
huggingchat: {
|
||||
kind: "cookie",
|
||||
credentialName: "hf-chat",
|
||||
placeholder: "hf-chat=... or full Cookie header from huggingface.co",
|
||||
acceptsFullCookieHeader: true,
|
||||
},
|
||||
phind: {
|
||||
kind: "cookie",
|
||||
credentialName: "phind_session",
|
||||
placeholder: "phind_session=... or full Cookie header from phind.com",
|
||||
acceptsFullCookieHeader: true,
|
||||
},
|
||||
"poe-web": {
|
||||
kind: "cookie",
|
||||
credentialName: "p-b",
|
||||
placeholder: "p-b=... or full Cookie header from poe.com",
|
||||
acceptsFullCookieHeader: true,
|
||||
},
|
||||
"venice-web": {
|
||||
kind: "cookie",
|
||||
credentialName: "session",
|
||||
placeholder: "session=... or full Cookie header from venice.ai",
|
||||
acceptsFullCookieHeader: true,
|
||||
},
|
||||
"v0-vercel-web": {
|
||||
kind: "cookie",
|
||||
credentialName: "__vercel_session",
|
||||
placeholder: "__vercel_session=... or full Cookie header from v0.dev",
|
||||
acceptsFullCookieHeader: true,
|
||||
},
|
||||
"kimi-web": {
|
||||
kind: "cookie",
|
||||
credentialName: "session",
|
||||
placeholder: "session=... or full Cookie header from kimi.moonshot.cn",
|
||||
acceptsFullCookieHeader: true,
|
||||
},
|
||||
"doubao-web": {
|
||||
kind: "cookie",
|
||||
credentialName: "session",
|
||||
placeholder: "session=... or full Cookie header from doubao.com",
|
||||
acceptsFullCookieHeader: true,
|
||||
},
|
||||
} satisfies Record<keyof typeof WEB_COOKIE_PROVIDERS, WebSessionCredentialRequirement>;
|
||||
|
||||
export function getWebSessionCredentialRequirement(
|
||||
|
||||
@@ -39,6 +39,7 @@ export const SUPPORTED_WIZARD_OAUTH_PROVIDER_IDS = new Set([
|
||||
"codex",
|
||||
"gemini-cli",
|
||||
"antigravity",
|
||||
"agy",
|
||||
"qwen",
|
||||
"kimi-coding",
|
||||
"github",
|
||||
|
||||
@@ -97,6 +97,7 @@ export default function ComboDefaultsTab() {
|
||||
stickyRoundRobinLimit: 3,
|
||||
resetAwareQuotaCacheTtlMs: 0,
|
||||
resetAwareQuotaCacheMaxStaleMs: 0,
|
||||
zeroLatencyOptimizationsEnabled: false,
|
||||
});
|
||||
const [codexSessionAffinityTtlMs, setCodexSessionAffinityTtlMs] = useState(0);
|
||||
const [providerOverrides, setProviderOverrides] = useState<any>({});
|
||||
@@ -555,6 +556,29 @@ export default function ComboDefaultsTab() {
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-sm">
|
||||
{translateOrFallback(t, "zeroLatencyOptimizations", "Zero-latency optimizations")}
|
||||
</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
{translateOrFallback(
|
||||
t,
|
||||
"zeroLatencyOptimizationsDesc",
|
||||
"Opt in to hedging, predictive TTFT skips, and proactive fallback compression. Leave off to prevent these latency features from racing targets or compressing fallback requests."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={comboDefaults.zeroLatencyOptimizationsEnabled === true}
|
||||
onChange={() =>
|
||||
setComboDefaults((prev) => ({
|
||||
...prev,
|
||||
zeroLatencyOptimizationsEnabled: prev.zeroLatencyOptimizationsEnabled !== true,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Provider Overrides */}
|
||||
|
||||
@@ -1,6 +1,40 @@
|
||||
import { redirect } from "next/navigation";
|
||||
"use client";
|
||||
|
||||
export default function MitmProxyPage() {
|
||||
// MITM Proxy será movido para Tools/AgentBridge (plano 11)
|
||||
redirect("/dashboard/system/proxy");
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
/**
|
||||
* MITM Proxy page — moved to AgentBridge (plan 11 §12).
|
||||
* Shows a "page moved" banner for 2.5 s then redirects.
|
||||
*/
|
||||
export default function MitmProxyMovedPage() {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("agentBridge.pageMoved");
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
router.replace("/dashboard/tools/agent-bridge");
|
||||
}, 2500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-8">
|
||||
<div className="rounded-xl border border-amber-500/40 bg-amber-900/20 p-8 text-center max-w-md w-full space-y-4">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<span className="material-symbols-outlined text-amber-400 text-[28px]">info</span>
|
||||
<h1 className="text-lg font-semibold text-amber-200">{t("title")}</h1>
|
||||
</div>
|
||||
<p className="text-sm text-amber-300/80">{t("message")}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.replace("/dashboard/tools/agent-bridge")}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-amber-500/20 text-amber-200 px-4 py-2 text-sm font-medium hover:bg-amber-500/30 transition-colors"
|
||||
>
|
||||
{t("goNow")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,201 @@
|
||||
"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}
|
||||
aria-label={t("startServer")}
|
||||
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}
|
||||
aria-label={t("stopServer")}
|
||||
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}
|
||||
aria-label={t("restartServer")}
|
||||
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}
|
||||
aria-label={t("trustCert")}
|
||||
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
|
||||
aria-label={t("downloadCert")}
|
||||
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}
|
||||
aria-label={t("regenerateCert")}
|
||||
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,268 @@
|
||||
"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 { RiskNoticeModal } from "@/shared/components/RiskNoticeModal";
|
||||
import type { MitmTarget } from "@/mitm/types";
|
||||
import type { AgentStateEntry } from "../AgentBridgePageClient";
|
||||
import type { MappingRow } from "./ModelMappingTable";
|
||||
|
||||
const RISK_STORAGE_KEY_PREFIX = "omniroute-agentbridge-risk-dismissed-";
|
||||
|
||||
function hasAcceptedRisk(agentId: string): boolean {
|
||||
try {
|
||||
return localStorage.getItem(RISK_STORAGE_KEY_PREFIX + agentId) === "true";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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 [riskModalOpen, setRiskModalOpen] = 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 reallyToggleDns = async (enabled: boolean) => {
|
||||
setDnsLoading(true);
|
||||
try {
|
||||
await onDnsToggle(target.id, enabled);
|
||||
} finally {
|
||||
setDnsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDnsToggle = async () => {
|
||||
const enabling = !dnsEnabled;
|
||||
if (enabling && !hasAcceptedRisk(target.id)) {
|
||||
setRiskModalOpen(true);
|
||||
return;
|
||||
}
|
||||
await reallyToggleDns(enabling);
|
||||
};
|
||||
|
||||
const handleRiskAccept = async () => {
|
||||
setRiskModalOpen(false);
|
||||
await reallyToggleDns(true);
|
||||
};
|
||||
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
|
||||
<RiskNoticeModal
|
||||
open={riskModalOpen}
|
||||
title={t("riskNoticeTitle")}
|
||||
body={t("riskNoticeBody")}
|
||||
dontShowAgainKey={RISK_STORAGE_KEY_PREFIX + target.id}
|
||||
onAccept={handleRiskAccept}
|
||||
onCancel={() => setRiskModalOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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,127 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, 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);
|
||||
|
||||
const loadModels = useCallback(async () => {
|
||||
try {
|
||||
const r = await fetch("/api/v1/models");
|
||||
const d = (await r.json()) as { data?: ProviderModel[] };
|
||||
setModels(Array.isArray(d.data) ? d.data : []);
|
||||
} catch {
|
||||
setModels([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
loadModels();
|
||||
}, [open, loadModels]);
|
||||
|
||||
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 { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
const STORAGE_KEY = "omniroute-agentbridge-risk-dismissed";
|
||||
|
||||
function isNotDismissed(): boolean {
|
||||
try {
|
||||
return !localStorage.getItem(STORAGE_KEY);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Amber dismissable banner shown at the top of the AgentBridge page.
|
||||
* Persisted via localStorage so it only shows once per user.
|
||||
* Uses lazy useState initializer to read localStorage without useEffect.
|
||||
*/
|
||||
export function RiskNoticeBanner() {
|
||||
const t = useTranslations("agentBridge");
|
||||
const [visible, setVisible] = useState<boolean>(isNotDismissed);
|
||||
|
||||
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,29 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface CertStatusIconProps {
|
||||
trusted: boolean;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export function CertStatusIcon({ trusted, size = 16 }: CertStatusIconProps) {
|
||||
const t = useTranslations("agentBridge");
|
||||
return trusted ? (
|
||||
<span
|
||||
className="material-symbols-outlined text-emerald-500"
|
||||
style={{ fontSize: size }}
|
||||
title={t("certTrusted")}
|
||||
>
|
||||
verified_user
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className="material-symbols-outlined text-zinc-400"
|
||||
style={{ fontSize: size }}
|
||||
title={t("certNotTrusted")}
|
||||
>
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { InterceptedRequest } from "@/mitm/inspector/types";
|
||||
import { useTrafficStream } from "./hooks/useTrafficStream";
|
||||
import { useTrafficFilters } from "./hooks/useTrafficFilters";
|
||||
import { useResizablePanels } from "./hooks/useResizablePanels";
|
||||
import { useSessionRecorder } from "./hooks/useSessionRecorder";
|
||||
import { useSystemProxyExitGuard } from "./hooks/useSystemProxyExitGuard";
|
||||
import { CaptureModesToolbar } from "./components/CaptureModesToolbar";
|
||||
import { TopBarControls } from "./components/TopBarControls";
|
||||
import { RequestStreamingList } from "./components/RequestStreamingList";
|
||||
import { DetailsPanel } from "./components/DetailsPanel";
|
||||
import { HistoricSessionBanner } from "./components/session/HistoricSessionBanner";
|
||||
|
||||
const BUFFER_MAX = 1000;
|
||||
|
||||
export function TrafficInspectorPageClient() {
|
||||
const [containerHeight, setContainerHeight] = useState(600);
|
||||
const listContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [selectedRequest, setSelectedRequest] = useState<InterceptedRequest | null>(null);
|
||||
const { filters, setProfile, setHost, setAgent, setStatus, setSessionId, setSameContext } =
|
||||
useTrafficFilters();
|
||||
const [{ listWidth, collapsed }, { startDrag, toggleCollapse }] = useResizablePanels();
|
||||
const [streamState, streamActions] = useTrafficStream(filters);
|
||||
const recorder = useSessionRecorder();
|
||||
const [captureModes, setCaptureModes] = useState<{ systemProxy?: { applied: boolean } } | null>(
|
||||
null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetch("/api/tools/traffic-inspector/capture-modes")
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((data: { systemProxy?: { applied: boolean } } | null) => {
|
||||
if (!cancelled) setCaptureModes(data);
|
||||
})
|
||||
.catch(() => {
|
||||
/* best-effort */
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useSystemProxyExitGuard({ applied: captureModes?.systemProxy?.applied ?? false });
|
||||
|
||||
const listContainerCallback = useCallback((el: HTMLDivElement | null) => {
|
||||
listContainerRef.current = el;
|
||||
if (el) setContainerHeight(el.clientHeight);
|
||||
}, []);
|
||||
|
||||
const exportHar = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/tools/traffic-inspector/export.har");
|
||||
if (!res.ok) return;
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `traffic-${Date.now()}.har`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSessionSelect = useCallback(
|
||||
(id: string | undefined) => {
|
||||
setSessionId(id);
|
||||
},
|
||||
[setSessionId]
|
||||
);
|
||||
|
||||
const handleRecordStart = useCallback(() => {
|
||||
void recorder.start();
|
||||
}, [recorder]);
|
||||
|
||||
const handleRecordStop = useCallback(() => {
|
||||
void recorder.stop();
|
||||
}, [recorder]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
{/* Capture modes toolbar */}
|
||||
<div className="shrink-0 px-4 pt-4 pb-2">
|
||||
<CaptureModesToolbar customHostCount={0} />
|
||||
</div>
|
||||
|
||||
{/* Historic session banner */}
|
||||
{filters.sessionId !== undefined && (
|
||||
<div className="shrink-0 px-4 pb-2">
|
||||
<HistoricSessionBanner
|
||||
sessionName={
|
||||
recorder.sessions.find((s) => s.id === filters.sessionId)?.name ?? null
|
||||
}
|
||||
onBackToLive={() => setSessionId(undefined)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top bar filter/controls */}
|
||||
<div className="shrink-0">
|
||||
<TopBarControls
|
||||
filters={filters}
|
||||
onProfileChange={setProfile}
|
||||
onHostChange={setHost}
|
||||
onAgentChange={setAgent}
|
||||
onStatusChange={setStatus}
|
||||
paused={streamState.paused}
|
||||
onPause={streamActions.pause}
|
||||
onResume={streamActions.resume}
|
||||
onClear={streamActions.clear}
|
||||
onExport={exportHar}
|
||||
connected={streamState.connected}
|
||||
total={streamState.total}
|
||||
maxSize={BUFFER_MAX}
|
||||
pendingCount={streamState.pendingCount}
|
||||
recording={recorder.recording}
|
||||
session={recorder.session}
|
||||
elapsed={recorder.elapsed}
|
||||
sessions={recorder.sessions}
|
||||
onRecordStart={handleRecordStart}
|
||||
onRecordStop={handleRecordStop}
|
||||
onSessionSelect={handleSessionSelect}
|
||||
onSessionDelete={recorder.deleteSession}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Split pane */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* List pane */}
|
||||
<div
|
||||
ref={listContainerCallback}
|
||||
className="shrink-0 overflow-hidden border-r border-border flex flex-col"
|
||||
style={{ width: listWidth }}
|
||||
>
|
||||
<div className="flex items-center justify-between px-2 py-1 border-b border-border bg-bg-subtle shrink-0">
|
||||
<span className="text-xs text-text-muted font-medium">
|
||||
{streamState.total} requests
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleCollapse}
|
||||
className="text-text-muted hover:text-text-main focus-ring rounded"
|
||||
aria-label={collapsed ? "Expand list" : "Collapse list"}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">
|
||||
{collapsed ? "chevron_right" : "chevron_left"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!collapsed && (
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<RequestStreamingList
|
||||
requests={streamState.requests}
|
||||
selectedId={selectedRequest?.id ?? null}
|
||||
onSelect={setSelectedRequest}
|
||||
containerHeight={containerHeight}
|
||||
onSameContext={setSameContext}
|
||||
sameContextKey={filters.sameContextKey}
|
||||
onClearContextFilter={() => setSameContext(undefined)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{collapsed && (
|
||||
<div className="flex-1 flex items-start justify-center pt-4">
|
||||
<span className="text-xs text-text-muted font-mono" style={{ writingMode: "vertical-rl" }}>
|
||||
{streamState.total} reqs
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Drag handle */}
|
||||
<div
|
||||
onMouseDown={startDrag}
|
||||
className="w-1 bg-border hover:bg-blue-500 cursor-col-resize shrink-0 transition-colors"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Detail pane */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<DetailsPanel request={selectedRequest} allRequests={streamState.requests} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { cn } from "@/shared/utils/cn";
|
||||
import { CustomHostsManager } from "./CustomHostsManager";
|
||||
import { HttpProxySnippetCard } from "./HttpProxySnippetCard";
|
||||
|
||||
interface CaptureModeState {
|
||||
agentBridge: boolean; // always on, cannot disable
|
||||
customHosts: boolean;
|
||||
httpProxy: boolean;
|
||||
systemWide: boolean;
|
||||
}
|
||||
|
||||
interface CaptureModesToolbarProps {
|
||||
customHostCount: number;
|
||||
}
|
||||
|
||||
export function CaptureModesToolbar({ customHostCount }: CaptureModesToolbarProps) {
|
||||
const t = useTranslations("trafficInspector");
|
||||
const [modes, setModes] = useState<CaptureModeState>({
|
||||
agentBridge: true,
|
||||
customHosts: false,
|
||||
httpProxy: false,
|
||||
systemWide: false,
|
||||
});
|
||||
const [showHosts, setShowHosts] = useState(false);
|
||||
const [showProxy, setShowProxy] = useState(false);
|
||||
const [proxyPort] = useState(8080);
|
||||
|
||||
const toggleMode = (key: keyof CaptureModeState) => {
|
||||
if (key === "agentBridge") return; // always on
|
||||
setModes((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
};
|
||||
|
||||
const buttons: Array<{
|
||||
key: keyof CaptureModeState;
|
||||
label: string;
|
||||
alwaysOn?: boolean;
|
||||
warn?: boolean;
|
||||
extra?: React.ReactNode;
|
||||
}> = [
|
||||
{ key: "agentBridge", label: t("agentBridgeMode"), alwaysOn: true },
|
||||
{
|
||||
key: "customHosts",
|
||||
label: `${t("customHostsMode")} (${customHostCount})`,
|
||||
},
|
||||
{
|
||||
key: "httpProxy",
|
||||
label: `${t("httpProxyMode")} :${proxyPort}`,
|
||||
},
|
||||
{
|
||||
key: "systemWide",
|
||||
label: t("systemWideMode"),
|
||||
warn: true,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-border bg-bg-subtle px-3 py-2">
|
||||
{buttons.map(({ key, label, alwaysOn, warn }) => {
|
||||
const active = modes[key];
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => toggleMode(key)}
|
||||
disabled={alwaysOn}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded border px-2.5 py-1 text-xs font-medium transition-colors",
|
||||
"focus-ring disabled:cursor-default",
|
||||
active
|
||||
? warn
|
||||
? "border-amber-500/50 bg-amber-900/30 text-amber-300"
|
||||
: "border-green-500/50 bg-green-900/30 text-green-300"
|
||||
: "border-border text-text-muted hover:text-text-main hover:bg-surface"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-1.5 w-1.5 rounded-full",
|
||||
active ? (warn ? "bg-amber-400" : "bg-green-400") : "bg-gray-600"
|
||||
)}
|
||||
/>
|
||||
{label}
|
||||
{warn && <span className="text-amber-400">⚠</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowHosts(true)}
|
||||
className="text-xs text-text-muted hover:text-text-main focus-ring rounded"
|
||||
>
|
||||
⚙ {t("manageHosts")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowProxy(true)}
|
||||
className="text-xs text-text-muted hover:text-text-main focus-ring rounded"
|
||||
>
|
||||
⬇ {t("copySnippet")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showHosts && <CustomHostsManager onClose={() => setShowHosts(false)} />}
|
||||
{showProxy && <HttpProxySnippetCard port={proxyPort} onClose={() => setShowProxy(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { z } from "zod";
|
||||
|
||||
interface CustomHost {
|
||||
host: string;
|
||||
enabled: boolean;
|
||||
label?: string | null;
|
||||
kind: "llm" | "app" | "custom";
|
||||
}
|
||||
|
||||
interface CustomHostsManagerProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function CustomHostsManager({ onClose }: CustomHostsManagerProps) {
|
||||
const t = useTranslations("trafficInspector");
|
||||
const [hosts, setHosts] = useState<CustomHost[]>([]);
|
||||
const [input, setInput] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const HostInputSchema = z.string().min(1).max(253).regex(/^[a-z0-9.-]+$/i, "Invalid hostname");
|
||||
|
||||
const fetchHosts = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/tools/traffic-inspector/custom-hosts");
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as { hosts: CustomHost[] };
|
||||
setHosts(data.hosts ?? []);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void fetchHosts();
|
||||
}, []);
|
||||
|
||||
const addHost = async () => {
|
||||
setError(null);
|
||||
const parsed = HostInputSchema.safeParse(input.trim());
|
||||
if (!parsed.success) {
|
||||
setError(parsed.error.errors[0]?.message ?? "Invalid host");
|
||||
return;
|
||||
}
|
||||
const host = parsed.data;
|
||||
try {
|
||||
const res = await fetch("/api/tools/traffic-inspector/custom-hosts", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ host, enabled: true }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => ({}))) as { error?: { message?: string } };
|
||||
setError(body?.error?.message ?? "Failed to add host");
|
||||
return;
|
||||
}
|
||||
setInput("");
|
||||
await fetchHosts();
|
||||
} catch {
|
||||
setError("Network error");
|
||||
}
|
||||
};
|
||||
|
||||
const deleteHost = async (host: string) => {
|
||||
try {
|
||||
await fetch(`/api/tools/traffic-inspector/custom-hosts/${encodeURIComponent(host)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
await fetchHosts();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="w-full max-w-md rounded-xl border border-border bg-surface shadow-xl p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-base font-semibold text-text-main">{t("customHostsTitle")}</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-text-muted hover:text-text-main focus-ring rounded"
|
||||
aria-label="Close"
|
||||
>
|
||||
<span className="material-symbols-outlined" aria-hidden="true">close</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mb-4">
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && addHost()}
|
||||
placeholder={t("hostPlaceholder")}
|
||||
className="flex-1 rounded border border-border bg-bg-subtle px-3 py-1.5 text-sm text-text-main focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addHost}
|
||||
className="rounded border border-border bg-blue-600 px-3 py-1.5 text-sm text-white hover:bg-blue-700 focus-ring"
|
||||
>
|
||||
{t("addHost")}
|
||||
</button>
|
||||
</div>
|
||||
{error && <p className="text-xs text-red-400 mb-2">{error}</p>}
|
||||
|
||||
<div className="space-y-1 max-h-60 overflow-y-auto">
|
||||
{loading && <p className="text-sm text-text-muted">{t("loading")}</p>}
|
||||
{!loading && hosts.length === 0 && (
|
||||
<p className="text-sm text-text-muted italic">{t("noHostsYet")}</p>
|
||||
)}
|
||||
{hosts.map((h) => (
|
||||
<div
|
||||
key={h.host}
|
||||
className="flex items-center justify-between rounded border border-border/50 bg-bg-subtle px-3 py-1.5"
|
||||
>
|
||||
<span className="text-sm font-mono text-text-main">{h.host}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deleteHost(h.host)}
|
||||
className="text-text-muted hover:text-red-400 focus-ring rounded"
|
||||
aria-label={`Remove ${h.host}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">
|
||||
delete
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { InterceptedRequest } from "@/mitm/inspector/types";
|
||||
import { cn } from "@/shared/utils/cn";
|
||||
import { HeadersTab } from "./tabs/HeadersTab";
|
||||
import { RequestBodyTab } from "./tabs/RequestBodyTab";
|
||||
import { ResponseBodyTab } from "./tabs/ResponseBodyTab";
|
||||
import { TimingTab } from "./tabs/TimingTab";
|
||||
import { LlmDetailsTab } from "./tabs/LlmDetailsTab";
|
||||
import { ConversationTab } from "./tabs/ConversationTab";
|
||||
import { StatsTab } from "./tabs/StatsTab";
|
||||
import { AnnotationField } from "./shared/AnnotationField";
|
||||
|
||||
type TabId = "conversation" | "headers" | "request" | "response" | "timing" | "llm" | "stats";
|
||||
|
||||
interface Tab {
|
||||
id: TabId;
|
||||
label: string;
|
||||
icon: string;
|
||||
llmOnly?: boolean;
|
||||
}
|
||||
|
||||
const TABS: Tab[] = [
|
||||
{ id: "conversation", label: "Conversation", icon: "chat_bubble" },
|
||||
{ id: "headers", label: "Headers", icon: "list" },
|
||||
{ id: "request", label: "Request", icon: "upload" },
|
||||
{ id: "response", label: "Response", icon: "download" },
|
||||
{ id: "timing", label: "Timing", icon: "timer" },
|
||||
{ id: "llm", label: "LLM", icon: "psychology", llmOnly: true },
|
||||
{ id: "stats", label: "Stats", icon: "bar_chart" },
|
||||
];
|
||||
|
||||
interface DetailsPanelProps {
|
||||
request: InterceptedRequest | null;
|
||||
allRequests: InterceptedRequest[];
|
||||
}
|
||||
|
||||
export function DetailsPanel({ request, allRequests }: DetailsPanelProps) {
|
||||
const [activeTab, setActiveTab] = useState<TabId>("conversation");
|
||||
|
||||
if (!request) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center text-text-muted">
|
||||
<div className="text-center space-y-2">
|
||||
<span
|
||||
className="material-symbols-outlined text-[36px] block"
|
||||
aria-hidden="true"
|
||||
>
|
||||
info
|
||||
</span>
|
||||
<p className="text-sm">Select a request to inspect it.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isLlm = request.detectedKind === "llm";
|
||||
const visibleTabs = TABS.filter((t) => !t.llmOnly || isLlm);
|
||||
|
||||
// Ensure active tab is valid
|
||||
const currentTab = visibleTabs.find((t) => t.id === activeTab) ? activeTab : "conversation";
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-hidden">
|
||||
{/* Tab bar */}
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="Request details"
|
||||
className="flex flex-wrap items-center gap-0.5 border-b border-border px-2 pt-1 bg-bg-subtle shrink-0"
|
||||
>
|
||||
{visibleTabs.map((tab) => {
|
||||
const selected = currentTab === tab.id;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={selected}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 h-8 px-2 text-xs rounded-t border-b-2 transition-colors focus-ring",
|
||||
selected
|
||||
? "border-blue-500 text-blue-400 bg-surface"
|
||||
: "border-transparent text-text-muted hover:text-text-main hover:bg-surface/50"
|
||||
)}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[13px]" aria-hidden="true">
|
||||
{tab.icon}
|
||||
</span>
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{currentTab === "conversation" && <ConversationTab request={request} />}
|
||||
{currentTab === "headers" && <HeadersTab request={request} />}
|
||||
{currentTab === "request" && <RequestBodyTab request={request} />}
|
||||
{currentTab === "response" && <ResponseBodyTab request={request} />}
|
||||
{currentTab === "timing" && <TimingTab request={request} />}
|
||||
{currentTab === "llm" && isLlm && <LlmDetailsTab request={request} />}
|
||||
{currentTab === "stats" && <StatsTab requests={allRequests} />}
|
||||
</div>
|
||||
|
||||
{/* Annotation footer */}
|
||||
<div className="shrink-0 border-t border-border px-3 py-2 bg-bg-subtle">
|
||||
<AnnotationField requestId={request.id} initialValue={request.annotation ?? ""} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { cn } from "@/shared/utils/cn";
|
||||
|
||||
interface HttpProxySnippetCardProps {
|
||||
port: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type Lang = "bash" | "python" | "node";
|
||||
|
||||
export function HttpProxySnippetCard({ port, onClose }: HttpProxySnippetCardProps) {
|
||||
const t = useTranslations("trafficInspector");
|
||||
const [lang, setLang] = useState<Lang>("bash");
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const snippets: Record<Lang, string> = {
|
||||
bash: `export HTTP_PROXY=http://127.0.0.1:${port}\nexport HTTPS_PROXY=http://127.0.0.1:${port}\nexport NODE_TLS_REJECT_UNAUTHORIZED=0\n# then run your command:\ncurl https://api.openai.com/v1/models`,
|
||||
python: `import os\nos.environ["HTTP_PROXY"] = "http://127.0.0.1:${port}"\nos.environ["HTTPS_PROXY"] = "http://127.0.0.1:${port}"\nos.environ["NODE_TLS_REJECT_UNAUTHORIZED"] = "0"\n# then use requests or httpx as usual`,
|
||||
node: `process.env.HTTP_PROXY = "http://127.0.0.1:${port}";\nprocess.env.HTTPS_PROXY = "http://127.0.0.1:${port}";\nprocess.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";\n// then use fetch / axios / undici as usual`,
|
||||
};
|
||||
|
||||
const copy = async () => {
|
||||
await navigator.clipboard.writeText(snippets[lang]);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="w-full max-w-lg rounded-xl border border-border bg-surface shadow-xl p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-base font-semibold text-text-main">
|
||||
{t("httpProxyTitle", { port })}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-text-muted hover:text-text-main focus-ring rounded"
|
||||
aria-label="Close"
|
||||
>
|
||||
<span className="material-symbols-outlined" aria-hidden="true">close</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 mb-3">
|
||||
{(["bash", "python", "node"] as Lang[]).map((l) => (
|
||||
<button
|
||||
key={l}
|
||||
type="button"
|
||||
onClick={() => setLang(l)}
|
||||
className={cn(
|
||||
"px-3 py-1 text-xs rounded border focus-ring",
|
||||
lang === l
|
||||
? "border-blue-500 bg-blue-900/30 text-blue-300"
|
||||
: "border-border text-text-muted hover:text-text-main"
|
||||
)}
|
||||
>
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<pre className="rounded bg-bg-subtle border border-border p-3 text-xs font-mono text-text-main overflow-x-auto whitespace-pre">
|
||||
{snippets[lang]}
|
||||
</pre>
|
||||
|
||||
<div className="mt-3 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={copy}
|
||||
className="inline-flex items-center gap-1.5 rounded border border-border px-3 py-1.5 text-xs text-text-main hover:bg-bg-subtle focus-ring"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]" aria-hidden="true">
|
||||
{copied ? "check" : "content_copy"}
|
||||
</span>
|
||||
{copied ? t("copied") : t("copy")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/shared/utils/cn";
|
||||
import type { InterceptedRequest } from "@/mitm/inspector/types";
|
||||
import { ContextColorBar } from "./shared/ContextColorBar";
|
||||
import { AgentEmoji } from "./shared/AgentEmoji";
|
||||
|
||||
interface RequestRowProps {
|
||||
request: InterceptedRequest;
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
onSameContext?: (contextKey: string) => void;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
function statusColor(status: InterceptedRequest["status"]): string {
|
||||
if (status === "in-flight") return "text-gray-400";
|
||||
if (status === "error") return "text-red-400";
|
||||
if (typeof status === "number") {
|
||||
if (status < 300) return "text-green-400";
|
||||
if (status < 400) return "text-yellow-400";
|
||||
if (status < 500) return "text-orange-400";
|
||||
return "text-red-400";
|
||||
}
|
||||
return "text-text-muted";
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
|
||||
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
||||
return `${bytes}B`;
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleTimeString("en", { hour12: false });
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function RequestRow({ request, selected, onClick, onSameContext, style }: RequestRowProps) {
|
||||
const pathShort = request.path.length > 32 ? `…${request.path.slice(-30)}` : request.path;
|
||||
const sc = statusColor(request.status);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onClick}
|
||||
onKeyDown={(e) => (e.key === "Enter" || e.key === " ") && onClick()}
|
||||
style={style}
|
||||
className={cn(
|
||||
"flex items-stretch gap-1 border-b border-border/40 cursor-pointer hover:bg-bg-subtle",
|
||||
"focus:outline-none focus-visible:ring-1 focus-visible:ring-blue-500",
|
||||
selected && "bg-surface"
|
||||
)}
|
||||
>
|
||||
<ContextColorBar contextKey={request.contextKey} />
|
||||
<div className="flex-1 min-w-0 px-2 py-1.5">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-text-muted shrink-0 font-mono">{formatTime(request.timestamp)}</span>
|
||||
<span className="font-mono font-medium text-text-main shrink-0">{request.method}</span>
|
||||
<span className={cn("font-mono font-bold shrink-0", sc)}>
|
||||
{String(request.status)}
|
||||
</span>
|
||||
<span className="text-text-muted shrink-0">{formatSize(request.responseSize)}</span>
|
||||
<span className="shrink-0">
|
||||
<AgentEmoji agentId={request.agent} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-text-muted truncate font-mono mt-0.5">
|
||||
{request.host}
|
||||
<span className="text-text-main">{pathShort}</span>
|
||||
</div>
|
||||
{request.contextKey && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-[10px] text-text-muted font-mono opacity-60 hover:opacity-100 hover:text-blue-400 focus:outline-none focus-visible:ring-1 focus-visible:ring-blue-500 rounded"
|
||||
title="Filter by this context"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSameContext?.(request.contextKey as string);
|
||||
}}
|
||||
>
|
||||
ctx #{request.contextKey.slice(0, 6)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import type { InterceptedRequest } from "@/mitm/inspector/types";
|
||||
import { useVirtualList } from "../hooks/useVirtualList";
|
||||
import { RequestRow } from "./RequestRow";
|
||||
|
||||
interface RequestStreamingListProps {
|
||||
requests: InterceptedRequest[];
|
||||
selectedId: string | null;
|
||||
onSelect: (req: InterceptedRequest) => void;
|
||||
containerHeight: number;
|
||||
onSameContext?: (contextKey: string) => void;
|
||||
sameContextKey?: string;
|
||||
onClearContextFilter?: () => void;
|
||||
}
|
||||
|
||||
export function RequestStreamingList({
|
||||
requests,
|
||||
selectedId,
|
||||
onSelect,
|
||||
containerHeight,
|
||||
onSameContext,
|
||||
sameContextKey,
|
||||
onClearContextFilter,
|
||||
}: RequestStreamingListProps) {
|
||||
const { virtualItems, totalHeight, containerRef, rowRef } = useVirtualList(
|
||||
requests,
|
||||
containerHeight
|
||||
);
|
||||
|
||||
if (requests.length === 0) {
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{sameContextKey && (
|
||||
<div className="shrink-0 flex items-center gap-2 px-2 py-1 bg-blue-900/30 border-b border-blue-500/40 text-xs text-blue-300 font-mono">
|
||||
<span>Filtering: context {sameContextKey.slice(0, 6)}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearContextFilter}
|
||||
className="ml-1 underline hover:text-blue-100 focus:outline-none focus-visible:ring-1 focus-visible:ring-blue-400"
|
||||
>
|
||||
[clear]
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex-1 flex items-center justify-center text-sm text-text-muted"
|
||||
>
|
||||
<div className="text-center space-y-2">
|
||||
<span
|
||||
className="material-symbols-outlined text-[36px] text-text-muted block"
|
||||
aria-hidden="true"
|
||||
>
|
||||
network_check
|
||||
</span>
|
||||
<p>No requests captured yet.</p>
|
||||
<p className="text-xs">Make sure AgentBridge is running or enable another capture mode.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{sameContextKey && (
|
||||
<div className="shrink-0 flex items-center gap-2 px-2 py-1 bg-blue-900/30 border-b border-blue-500/40 text-xs text-blue-300 font-mono">
|
||||
<span>Filtering: context {sameContextKey.slice(0, 6)}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearContextFilter}
|
||||
className="ml-1 underline hover:text-blue-100 focus:outline-none focus-visible:ring-1 focus-visible:ring-blue-400"
|
||||
>
|
||||
[clear]
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={containerRef as React.RefObject<HTMLDivElement>}
|
||||
className="flex-1 overflow-y-auto relative"
|
||||
style={{ contain: "strict" }}
|
||||
>
|
||||
<div style={{ height: totalHeight, position: "relative" }}>
|
||||
{virtualItems.map(({ index, item, top }) => (
|
||||
<div
|
||||
key={item.id}
|
||||
ref={rowRef(index)}
|
||||
style={{ position: "absolute", top, left: 0, right: 0 }}
|
||||
>
|
||||
<RequestRow
|
||||
request={item}
|
||||
selected={item.id === selectedId}
|
||||
onClick={() => onSelect(item)}
|
||||
onSameContext={onSameContext}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { ListFilters } from "@/mitm/inspector/types";
|
||||
import type { AgentId } from "@/mitm/types";
|
||||
import { cn } from "@/shared/utils/cn";
|
||||
import { SessionRecorderBar } from "./session/SessionRecorderBar";
|
||||
import { SessionPicker } from "./session/SessionPicker";
|
||||
import type { SessionInfo } from "../hooks/useSessionRecorder";
|
||||
|
||||
type Profile = "llm" | "custom" | "all";
|
||||
|
||||
// PROFILES labels are resolved inside the component via useTranslations
|
||||
const PROFILE_IDS: Profile[] = ["llm", "custom", "all"];
|
||||
|
||||
interface TopBarControlsProps {
|
||||
filters: ListFilters;
|
||||
onProfileChange: (p: Profile) => void;
|
||||
onHostChange: (h: string | undefined) => void;
|
||||
onAgentChange: (a: AgentId | undefined) => void;
|
||||
onStatusChange: (s: ListFilters["status"]) => void;
|
||||
paused: boolean;
|
||||
onPause: () => void;
|
||||
onResume: () => void;
|
||||
onClear: () => void;
|
||||
onExport: () => void;
|
||||
connected: boolean;
|
||||
total: number;
|
||||
maxSize?: number;
|
||||
pendingCount?: number;
|
||||
// session recorder
|
||||
recording: boolean;
|
||||
session: SessionInfo | null;
|
||||
elapsed: number;
|
||||
sessions: SessionInfo[];
|
||||
onRecordStart: () => void;
|
||||
onRecordStop: () => void;
|
||||
onSessionSelect: (id: string | undefined) => void;
|
||||
onSessionDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
|
||||
export function TopBarControls({
|
||||
filters,
|
||||
onProfileChange,
|
||||
onHostChange,
|
||||
onAgentChange,
|
||||
onStatusChange,
|
||||
paused,
|
||||
onPause,
|
||||
onResume,
|
||||
onClear,
|
||||
onExport,
|
||||
connected,
|
||||
total,
|
||||
maxSize = 1000,
|
||||
pendingCount = 0,
|
||||
recording,
|
||||
session,
|
||||
elapsed,
|
||||
sessions,
|
||||
onRecordStart,
|
||||
onRecordStop,
|
||||
onSessionSelect,
|
||||
onSessionDelete,
|
||||
}: TopBarControlsProps) {
|
||||
const t = useTranslations("trafficInspector");
|
||||
const profile: Profile = (filters.profile as Profile) ?? "llm";
|
||||
|
||||
const profileLabels: Record<Profile, string> = {
|
||||
llm: t("profileLlmOnly"),
|
||||
custom: t("profileCustom"),
|
||||
all: t("profileAll"),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-border bg-bg-subtle px-3 py-2">
|
||||
{/* Profile selector */}
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Traffic profile"
|
||||
className="flex items-center gap-1 rounded border border-border bg-surface p-0.5"
|
||||
>
|
||||
{PROFILE_IDS.map((id) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={profile === id}
|
||||
onClick={() => onProfileChange(id)}
|
||||
className={cn(
|
||||
"px-2 py-0.5 text-xs rounded focus-ring",
|
||||
profile === id
|
||||
? "bg-blue-600 text-white"
|
||||
: "text-text-muted hover:text-text-main"
|
||||
)}
|
||||
>
|
||||
{profileLabels[id]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Host filter */}
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t("filterHost")}
|
||||
defaultValue={filters.host ?? ""}
|
||||
onChange={(e) => onHostChange(e.target.value || undefined)}
|
||||
className="rounded border border-border bg-bg-subtle px-2 py-1 text-xs text-text-main w-32 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
|
||||
{/* Status filter */}
|
||||
<select
|
||||
value={filters.status ?? ""}
|
||||
onChange={(e) =>
|
||||
onStatusChange((e.target.value as ListFilters["status"]) || undefined)
|
||||
}
|
||||
className="rounded border border-border bg-bg-subtle px-2 py-1 text-xs text-text-main focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">{t("anyStatus")}</option>
|
||||
<option value="2xx">2xx</option>
|
||||
<option value="3xx">3xx</option>
|
||||
<option value="4xx">4xx</option>
|
||||
<option value="5xx">5xx</option>
|
||||
<option value="error">error</option>
|
||||
</select>
|
||||
|
||||
{/* Action buttons */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={paused ? onResume : onPause}
|
||||
className="inline-flex items-center gap-1 rounded border border-border px-2 py-1 text-xs text-text-muted hover:text-text-main focus-ring"
|
||||
title={paused ? t("resumeBtn") : t("pauseBtn")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]" aria-hidden="true">
|
||||
{paused ? "play_arrow" : "pause"}
|
||||
</span>
|
||||
{paused ? t("resumeBtn") : t("pauseBtn")}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
className="inline-flex items-center gap-1 rounded border border-border px-2 py-1 text-xs text-text-muted hover:text-red-400 focus-ring"
|
||||
title={t("clearBtn")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]" aria-hidden="true">
|
||||
delete_sweep
|
||||
</span>
|
||||
{t("clearBtn")}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onExport}
|
||||
className="inline-flex items-center gap-1 rounded border border-border px-2 py-1 text-xs text-text-muted hover:text-text-main focus-ring"
|
||||
title={t("exportHar")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]" aria-hidden="true">
|
||||
download
|
||||
</span>
|
||||
{t("exportHar")}
|
||||
</button>
|
||||
|
||||
{/* Session controls */}
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<SessionPicker
|
||||
sessions={sessions}
|
||||
selectedId={filters.sessionId}
|
||||
onSelect={onSessionSelect}
|
||||
onDelete={onSessionDelete}
|
||||
/>
|
||||
<SessionRecorderBar
|
||||
recording={recording}
|
||||
session={session}
|
||||
elapsed={elapsed}
|
||||
onStart={onRecordStart}
|
||||
onStop={onRecordStop}
|
||||
/>
|
||||
|
||||
{/* Live indicator */}
|
||||
<div className="flex items-center gap-1.5 text-xs text-text-muted">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-2 w-2 rounded-full",
|
||||
connected ? "bg-green-400 animate-pulse" : "bg-gray-500"
|
||||
)}
|
||||
/>
|
||||
{connected ? t("liveBadge") : t("offlineBadge")}
|
||||
<span className="text-text-muted font-mono">
|
||||
{total}/{maxSize}
|
||||
</span>
|
||||
{paused && pendingCount > 0 && (
|
||||
<span className="inline-flex items-center rounded bg-yellow-500/20 px-1.5 py-0.5 text-[10px] font-semibold text-yellow-400 border border-yellow-500/40">
|
||||
{t("pausedNewBadge", { count: pendingCount })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { NormalizedTurn } from "@/mitm/inspector/types";
|
||||
import { cn } from "@/shared/utils/cn";
|
||||
import { MessageContent } from "./MessageContent";
|
||||
|
||||
interface ChatBubbleProps {
|
||||
turn: NormalizedTurn;
|
||||
}
|
||||
|
||||
const ROLE_STYLES: Record<NormalizedTurn["role"], string> = {
|
||||
system: "border border-red-500/40 bg-red-900/20 text-red-200",
|
||||
user: "ml-auto bg-blue-600/30 border border-blue-500/30 text-blue-100",
|
||||
assistant: "bg-purple-900/30 border border-purple-500/30 text-purple-100",
|
||||
tool: "bg-gray-800 border border-gray-600/30 text-gray-200",
|
||||
};
|
||||
|
||||
const ROLE_LABEL: Record<NormalizedTurn["role"], string> = {
|
||||
system: "System",
|
||||
user: "User",
|
||||
assistant: "Assistant",
|
||||
tool: "Tool",
|
||||
};
|
||||
|
||||
export function ChatBubble({ turn }: ChatBubbleProps) {
|
||||
const [collapsed, setCollapsed] = useState(turn.role === "system");
|
||||
|
||||
const isSystem = turn.role === "system";
|
||||
const isUser = turn.role === "user";
|
||||
|
||||
return (
|
||||
<div className={cn("max-w-[85%] rounded-lg px-3 py-2", isUser ? "ml-auto" : "mr-auto", ROLE_STYLES[turn.role])}>
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<span className="text-xs font-medium opacity-70">{ROLE_LABEL[turn.role]}</span>
|
||||
{isSystem && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed((c) => !c)}
|
||||
className="text-xs opacity-70 hover:opacity-100 focus-ring rounded"
|
||||
>
|
||||
{collapsed ? "Expand" : "Collapse"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{!collapsed && <MessageContent blocks={turn.blocks} />}
|
||||
{collapsed && isSystem && (
|
||||
<p className="text-xs opacity-60 italic">System prompt hidden — click to expand</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import type { NormalizedBlock } from "@/mitm/inspector/types";
|
||||
import { ToolCallBlock } from "./ToolCallBlock";
|
||||
import { ToolResultBlock } from "./ToolResultBlock";
|
||||
|
||||
interface MessageContentProps {
|
||||
blocks: NormalizedBlock[];
|
||||
}
|
||||
|
||||
export function MessageContent({ blocks }: MessageContentProps) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{blocks.map((block, i) => {
|
||||
if (block.type === "text") {
|
||||
return (
|
||||
<p key={i} className="text-sm text-text-main whitespace-pre-wrap break-words">
|
||||
{block.text}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (block.type === "tool_use") {
|
||||
return (
|
||||
<ToolCallBlock key={i} id={block.id} name={block.name} input={block.input} />
|
||||
);
|
||||
}
|
||||
if (block.type === "tool_result") {
|
||||
return (
|
||||
<ToolResultBlock
|
||||
key={i}
|
||||
toolUseId={block.tool_use_id}
|
||||
content={block.content}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { JsonViewer } from "../shared/JsonViewer";
|
||||
|
||||
interface ToolCallBlockProps {
|
||||
id: string;
|
||||
name: string;
|
||||
input: unknown;
|
||||
}
|
||||
|
||||
export function ToolCallBlock({ id, name, input }: ToolCallBlockProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="rounded border border-amber-500/40 bg-amber-900/20 px-3 py-2 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((e) => !e)}
|
||||
className="flex w-full items-center gap-2 text-left focus-ring rounded"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px] text-amber-400" aria-hidden="true">
|
||||
{expanded ? "expand_less" : "expand_more"}
|
||||
</span>
|
||||
<span className="text-amber-300 font-mono font-medium">{name}</span>
|
||||
<span className="text-text-muted text-xs font-mono ml-auto">{id.slice(0, 8)}</span>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="mt-2 border-t border-amber-500/20 pt-2">
|
||||
<JsonViewer data={input} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { JsonViewer } from "../shared/JsonViewer";
|
||||
|
||||
interface ToolResultBlockProps {
|
||||
toolUseId: string;
|
||||
content: unknown;
|
||||
}
|
||||
|
||||
export function ToolResultBlock({ toolUseId, content }: ToolResultBlockProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="rounded border border-green-500/40 bg-green-900/20 px-3 py-2 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((e) => !e)}
|
||||
className="flex w-full items-center gap-2 text-left focus-ring rounded"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px] text-green-400" aria-hidden="true">
|
||||
{expanded ? "expand_less" : "expand_more"}
|
||||
</span>
|
||||
<span className="text-green-300 font-mono font-medium text-xs">tool_result</span>
|
||||
<span className="text-text-muted text-xs font-mono ml-auto">{toolUseId.slice(0, 8)}</span>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="mt-2 border-t border-green-500/20 pt-2">
|
||||
<JsonViewer data={content} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface HistoricSessionBannerProps {
|
||||
sessionName: string | null;
|
||||
onBackToLive: () => void;
|
||||
}
|
||||
|
||||
export function HistoricSessionBanner({ sessionName, onBackToLive }: HistoricSessionBannerProps) {
|
||||
const t = useTranslations("trafficInspector");
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 rounded border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-sm text-amber-200">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">
|
||||
history
|
||||
</span>
|
||||
<span>
|
||||
{t("viewingRecordedSession")} —{" "}
|
||||
<strong>{sessionName ?? t("untitledSession")}</strong>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBackToLive}
|
||||
className="rounded border border-amber-500/40 px-2 py-0.5 text-xs hover:bg-amber-500/20 focus-ring"
|
||||
>
|
||||
{t("backToLive")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { SessionInfo } from "../../hooks/useSessionRecorder";
|
||||
|
||||
interface SessionPickerProps {
|
||||
sessions: SessionInfo[];
|
||||
selectedId?: string;
|
||||
onSelect: (id: string | undefined) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
export function SessionPicker({ sessions, selectedId, onSelect, onDelete }: SessionPickerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const selected = sessions.find((s) => s.id === selectedId);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="flex items-center gap-1 rounded border border-border bg-bg-subtle px-2 py-1 text-xs text-text-main hover:bg-surface focus-ring"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]" aria-hidden="true">
|
||||
folder_open
|
||||
</span>
|
||||
{selected ? selected.name ?? `Session ${selected.id.slice(0, 6)}` : "Sessions"}
|
||||
<span className="material-symbols-outlined text-[12px] ml-1" aria-hidden="true">
|
||||
{open ? "expand_less" : "expand_more"}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute left-0 top-full z-50 mt-1 min-w-[200px] rounded-lg border border-border bg-surface shadow-lg py-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { onSelect(undefined); setOpen(false); }}
|
||||
className="w-full text-left px-3 py-1.5 text-xs text-text-muted hover:bg-bg-subtle focus-ring"
|
||||
>
|
||||
All traffic (no session)
|
||||
</button>
|
||||
{sessions.length === 0 && (
|
||||
<p className="px-3 py-2 text-xs text-text-muted italic">No sessions yet</p>
|
||||
)}
|
||||
{sessions.map((s) => (
|
||||
<div key={s.id} className="flex items-center group">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { onSelect(s.id); setOpen(false); }}
|
||||
className={`flex-1 text-left px-3 py-1.5 text-xs hover:bg-bg-subtle focus-ring ${
|
||||
selectedId === s.id ? "text-blue-400 font-medium" : "text-text-main"
|
||||
}`}
|
||||
>
|
||||
{s.name ?? `Session ${s.id.slice(0, 6)}`}
|
||||
<span className="text-text-muted ml-1">({s.requestCount} reqs)</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { onDelete(s.id); if (selectedId === s.id) onSelect(undefined); }}
|
||||
className="px-2 text-text-muted hover:text-red-400 opacity-0 group-hover:opacity-100 focus-ring rounded"
|
||||
aria-label="Delete session"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]" aria-hidden="true">
|
||||
delete
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { cn } from "@/shared/utils/cn";
|
||||
import type { SessionInfo } from "../../hooks/useSessionRecorder";
|
||||
|
||||
interface SessionRecorderBarProps {
|
||||
recording: boolean;
|
||||
session: SessionInfo | null;
|
||||
elapsed: number;
|
||||
onStart: (name?: string) => void;
|
||||
onStop: () => void;
|
||||
}
|
||||
|
||||
function formatElapsed(s: number): string {
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
const sec = s % 60;
|
||||
if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`;
|
||||
return `${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function SessionRecorderBar({
|
||||
recording,
|
||||
session,
|
||||
elapsed,
|
||||
onStart,
|
||||
onStop,
|
||||
}: SessionRecorderBarProps) {
|
||||
const t = useTranslations("trafficInspector");
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-lg px-3 py-1.5 text-sm border",
|
||||
recording
|
||||
? "border-red-500/40 bg-red-900/20 text-red-200"
|
||||
: "border-border bg-bg-subtle text-text-muted"
|
||||
)}
|
||||
>
|
||||
{recording ? (
|
||||
<>
|
||||
<span className="inline-block h-2 w-2 rounded-full bg-red-500 animate-pulse" />
|
||||
<span className="font-mono text-xs">{formatElapsed(elapsed)}</span>
|
||||
{session?.name && (
|
||||
<span className="text-xs opacity-70 truncate max-w-[120px]">{session.name}</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onStop}
|
||||
aria-label={t("stopSession")}
|
||||
className="ml-auto rounded border border-red-500/50 px-2 py-0.5 text-xs hover:bg-red-800/30 focus-ring"
|
||||
>
|
||||
{t("stopSession")}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="inline-block h-2 w-2 rounded-full bg-gray-500" />
|
||||
<span className="text-xs">{t("notRecording")}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onStart()}
|
||||
aria-label={t("recordSession")}
|
||||
className="ml-auto rounded border border-border px-2 py-0.5 text-xs hover:bg-surface focus-ring"
|
||||
>
|
||||
{t("recordSession")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import type { AgentId } from "@/mitm/types";
|
||||
|
||||
const AGENT_COLORS: Record<AgentId, { emoji: string; label: string; color: string }> = {
|
||||
antigravity: { emoji: "🔵", label: "AG", color: "text-blue-400" },
|
||||
kiro: { emoji: "🟠", label: "KR", color: "text-orange-400" },
|
||||
copilot: { emoji: "🟢", label: "CP", color: "text-green-400" },
|
||||
codex: { emoji: "🟣", label: "CD", color: "text-purple-400" },
|
||||
cursor: { emoji: "🔶", label: "CU", color: "text-yellow-400" },
|
||||
zed: { emoji: "🔷", label: "ZD", color: "text-sky-400" },
|
||||
"claude-code": { emoji: "🟡", label: "CC", color: "text-yellow-300" },
|
||||
"open-code": { emoji: "⚪", label: "OC", color: "text-gray-400" },
|
||||
trae: { emoji: "⬛", label: "TR", color: "text-gray-500" },
|
||||
};
|
||||
|
||||
interface AgentEmojiProps {
|
||||
agentId?: AgentId | string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AgentEmoji({ agentId, className }: AgentEmojiProps) {
|
||||
if (!agentId) return <span className={`text-sm ${className ?? ""}`}>🌐</span>;
|
||||
const info = AGENT_COLORS[agentId as AgentId];
|
||||
if (!info) return <span className={`text-sm ${className ?? ""}`}>🌐</span>;
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-0.5 text-xs font-mono ${info.color} ${className ?? ""}`}
|
||||
title={agentId}
|
||||
>
|
||||
{info.emoji} {info.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useAnnotations } from "../../hooks/useAnnotations";
|
||||
|
||||
interface AnnotationFieldProps {
|
||||
requestId: string | null;
|
||||
initialValue?: string;
|
||||
}
|
||||
|
||||
export function AnnotationField({ requestId, initialValue = "" }: AnnotationFieldProps) {
|
||||
const [value, setValue] = useState(initialValue);
|
||||
const { save, saving } = useAnnotations(requestId);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setValue(e.target.value);
|
||||
save(e.target.value);
|
||||
},
|
||||
[save]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
placeholder="Add a note…"
|
||||
rows={3}
|
||||
maxLength={10_000}
|
||||
className="w-full rounded border border-border bg-bg-subtle px-3 py-2 text-sm text-text-main resize-none focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
{saving && (
|
||||
<span className="absolute right-2 bottom-2 text-xs text-text-muted animate-pulse">
|
||||
Saving…
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
interface ContextColorBarProps {
|
||||
contextKey?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function hashToHue(key: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < key.length; i++) {
|
||||
hash = (hash * 31 + key.charCodeAt(i)) & 0xffffff;
|
||||
}
|
||||
return (hash * 137.5) % 360;
|
||||
}
|
||||
|
||||
export function ContextColorBar({ contextKey, className }: ContextColorBarProps) {
|
||||
const hue = contextKey ? hashToHue(contextKey) : 0;
|
||||
const color = contextKey ? `hsl(${hue}, 70%, 50%)` : "transparent";
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={{ width: 3, minWidth: 3, backgroundColor: color, borderRadius: 2 }}
|
||||
title={contextKey ? `ctx #${contextKey.slice(0, 6)}` : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
interface HeaderTableProps {
|
||||
headers: Record<string, string>;
|
||||
}
|
||||
|
||||
export function HeaderTable({ headers }: HeaderTableProps) {
|
||||
const [masked, setMasked] = useState(true);
|
||||
const SENSITIVE = /authorization|cookie|x-api-key|bearer/i;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="text-xs text-text-muted">Sensitive headers</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMasked((m) => !m)}
|
||||
className="text-xs text-blue-400 hover:text-blue-300 focus-ring rounded"
|
||||
>
|
||||
{masked ? "Show" : "Hide"}
|
||||
</button>
|
||||
</div>
|
||||
<table className="w-full text-xs font-mono border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<th className="text-left px-2 py-1 text-text-muted font-medium">Name</th>
|
||||
<th className="text-left px-2 py-1 text-text-muted font-medium">Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(headers).map(([name, value]) => {
|
||||
const isSensitive = SENSITIVE.test(name);
|
||||
const display = masked && isSensitive ? "••••••••" : value;
|
||||
return (
|
||||
<tr key={name} className="border-b border-border/50 hover:bg-bg-subtle">
|
||||
<td className="px-2 py-1 text-text-muted select-text">{name}</td>
|
||||
<td
|
||||
className={`px-2 py-1 break-all select-text ${isSensitive ? "text-amber-400" : "text-text-main"}`}
|
||||
>
|
||||
{display}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/shared/utils/cn";
|
||||
|
||||
interface JsonViewerProps {
|
||||
data: unknown;
|
||||
depth?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function JsonNode({ data, depth = 0 }: { data: unknown; depth?: number }) {
|
||||
const [expanded, setExpanded] = useState(depth < 2);
|
||||
|
||||
if (data === null) return <span className="text-text-muted">null</span>;
|
||||
if (typeof data === "boolean") return <span className="text-amber-400">{String(data)}</span>;
|
||||
if (typeof data === "number") return <span className="text-blue-400">{String(data)}</span>;
|
||||
if (typeof data === "string") return <span className="text-green-400">"{data}"</span>;
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
if (data.length === 0) return <span className="text-text-muted">[]</span>;
|
||||
return (
|
||||
<span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((e) => !e)}
|
||||
className="text-text-muted hover:text-text-main font-mono text-xs focus-ring rounded"
|
||||
>
|
||||
{expanded ? "▼" : "▶"} [{data.length}]
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="ml-4 border-l border-border pl-2">
|
||||
{data.map((item, i) => (
|
||||
<div key={i} className="flex gap-1 text-xs font-mono">
|
||||
<span className="text-text-muted">{i}:</span>
|
||||
<JsonNode data={item} depth={depth + 1} />
|
||||
{i < data.length - 1 && <span className="text-text-muted">,</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof data === "object" && data !== null) {
|
||||
const entries = Object.entries(data as Record<string, unknown>);
|
||||
if (entries.length === 0) return <span className="text-text-muted">{"{}"}</span>;
|
||||
return (
|
||||
<span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((e) => !e)}
|
||||
className="text-text-muted hover:text-text-main font-mono text-xs focus-ring rounded"
|
||||
>
|
||||
{expanded ? "▼" : "▶"} {"{"}
|
||||
{entries.length}
|
||||
{"}"}
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="ml-4 border-l border-border pl-2">
|
||||
{entries.map(([k, v], i) => (
|
||||
<div key={k} className="flex gap-1 text-xs font-mono">
|
||||
<span className="text-text-main">"{k}"</span>
|
||||
<span className="text-text-muted">:</span>
|
||||
<JsonNode data={v} depth={depth + 1} />
|
||||
{i < entries.length - 1 && <span className="text-text-muted">,</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return <span className="text-text-main font-mono text-xs">{String(data)}</span>;
|
||||
}
|
||||
|
||||
export function JsonViewer({ data, className }: JsonViewerProps) {
|
||||
return (
|
||||
<div className={cn("overflow-auto font-mono text-xs", className)}>
|
||||
<JsonNode data={data} depth={0} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
interface SecretMaskToggleProps {
|
||||
masked: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
export function SecretMaskToggle({ masked, onToggle }: SecretMaskToggleProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="inline-flex items-center gap-1 text-xs text-text-muted hover:text-text-main focus-ring rounded px-2 py-0.5 border border-border"
|
||||
title={masked ? "Unmask secrets" : "Mask secrets"}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]" aria-hidden="true">
|
||||
{masked ? "visibility_off" : "visibility"}
|
||||
</span>
|
||||
{masked ? "Show secrets" : "Mask secrets"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import type { SseEvent } from "@/mitm/inspector/sseMerger";
|
||||
|
||||
interface SseEventListProps {
|
||||
events: SseEvent[];
|
||||
}
|
||||
|
||||
export function SseEventList({ events }: SseEventListProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1 font-mono text-xs overflow-auto max-h-full">
|
||||
{events.map((ev, i) => (
|
||||
<div key={i} className="flex gap-2 border-b border-border/30 pb-1">
|
||||
<span className="text-text-muted shrink-0 w-8 text-right">{i + 1}</span>
|
||||
<span className="text-amber-400 shrink-0">{ev.event ?? "data"}</span>
|
||||
<span className="text-text-main break-all">{ev.data}</span>
|
||||
</div>
|
||||
))}
|
||||
{events.length === 0 && (
|
||||
<p className="text-text-muted italic">No SSE events</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { InterceptedRequest } from "@/mitm/inspector/types";
|
||||
|
||||
interface TimingWaterfallProps {
|
||||
request: InterceptedRequest;
|
||||
}
|
||||
|
||||
export function TimingWaterfall({ request }: TimingWaterfallProps) {
|
||||
const t = useTranslations("trafficInspector");
|
||||
const { proxyLatencyMs, upstreamLatencyMs, totalLatencyMs } = request;
|
||||
const total = totalLatencyMs ?? (proxyLatencyMs ?? 0) + (upstreamLatencyMs ?? 0);
|
||||
|
||||
if (!total) {
|
||||
return <p className="text-sm text-text-muted">{t("timingNoData")}</p>;
|
||||
}
|
||||
|
||||
const segments: Array<{ label: string; ms: number; color: string }> = [
|
||||
{
|
||||
label: t("timingProxyOverhead"),
|
||||
ms: proxyLatencyMs ?? 0,
|
||||
color: "bg-blue-500",
|
||||
},
|
||||
{
|
||||
label: t("timingUpstreamResponse"),
|
||||
ms: upstreamLatencyMs ?? 0,
|
||||
color: "bg-green-500",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
{segments.map((seg) => {
|
||||
const pct = total > 0 ? (seg.ms / total) * 100 : 0;
|
||||
return (
|
||||
<div key={seg.label} className="space-y-1">
|
||||
<div className="flex justify-between text-xs text-text-muted">
|
||||
<span>{seg.label}</span>
|
||||
<span>{seg.ms}ms ({pct.toFixed(1)}%)</span>
|
||||
</div>
|
||||
<div className="h-4 w-full rounded bg-bg-subtle">
|
||||
<div
|
||||
className={`h-full rounded ${seg.color}`}
|
||||
style={{ width: `${Math.max(pct, 0.5)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex justify-between text-xs font-medium text-text-main border-t border-border pt-2">
|
||||
<span>{t("timingTotalLatency")}</span>
|
||||
<span>{total}ms</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
interface TokenBadgeProps {
|
||||
tokensIn?: number | null;
|
||||
tokensOut?: number | null;
|
||||
}
|
||||
|
||||
export function TokenBadge({ tokensIn, tokensOut }: TokenBadgeProps) {
|
||||
if (!tokensIn && !tokensOut) return null;
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 rounded bg-purple-900/40 px-2 py-0.5 text-xs text-purple-300 font-mono">
|
||||
<span className="material-symbols-outlined text-[12px]" aria-hidden="true">
|
||||
token
|
||||
</span>
|
||||
{tokensIn != null && <span>{tokensIn}↑</span>}
|
||||
{tokensOut != null && <span>{tokensOut}↓</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { InterceptedRequest } from "@/mitm/inspector/types";
|
||||
import { normalizeConversation } from "@/mitm/inspector/conversationNormalizer";
|
||||
import { ChatBubble } from "../chat/ChatBubble";
|
||||
|
||||
interface ConversationTabProps {
|
||||
request: InterceptedRequest;
|
||||
}
|
||||
|
||||
export function ConversationTab({ request }: ConversationTabProps) {
|
||||
const t = useTranslations("trafficInspector");
|
||||
const conversation = normalizeConversation(request);
|
||||
|
||||
if (!conversation) {
|
||||
return (
|
||||
<div className="p-4 text-sm text-text-muted">{t("conversationNotAvailable")}</div>
|
||||
);
|
||||
}
|
||||
|
||||
const allTurns = [...conversation.request, ...conversation.response];
|
||||
|
||||
if (allTurns.length === 0) {
|
||||
return (
|
||||
<div className="p-4 text-sm text-text-muted">{t("conversationNoMessages")}</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto p-3 space-y-2">
|
||||
{conversation.contextKey && (
|
||||
<div className="text-xs text-text-muted mb-2">
|
||||
{t("contextFingerprint")}{" "}
|
||||
<span className="font-mono text-blue-400">#{conversation.contextKey.slice(0, 12)}</span>
|
||||
</div>
|
||||
)}
|
||||
{conversation.request.length > 0 && (
|
||||
<>
|
||||
<div className="flex items-center gap-2 mt-2 mb-1 text-[11px] uppercase tracking-wider text-text-muted font-semibold">
|
||||
<span className="h-px flex-1 bg-border" aria-hidden="true" />
|
||||
<span>{t("contextHistory")}</span>
|
||||
<span className="h-px flex-1 bg-border" aria-hidden="true" />
|
||||
</div>
|
||||
{conversation.request.map((turn, i) => (
|
||||
<ChatBubble key={`req-${i}`} turn={turn} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{conversation.response.length > 0 && (
|
||||
<>
|
||||
<div className="flex items-center gap-2 mt-3 mb-1 text-[11px] uppercase tracking-wider text-text-muted font-semibold">
|
||||
<span className="h-px flex-1 bg-border" aria-hidden="true" />
|
||||
<span>{t("modelResponse")}</span>
|
||||
<span className="h-px flex-1 bg-border" aria-hidden="true" />
|
||||
</div>
|
||||
{conversation.response.map((turn, i) => (
|
||||
<ChatBubble key={`res-${i}`} turn={turn} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import type { InterceptedRequest } from "@/mitm/inspector/types";
|
||||
import { HeaderTable } from "../shared/HeaderTable";
|
||||
|
||||
interface HeadersTabProps {
|
||||
request: InterceptedRequest;
|
||||
}
|
||||
|
||||
export function HeadersTab({ request }: HeadersTabProps) {
|
||||
return (
|
||||
<div className="space-y-4 overflow-auto h-full p-2">
|
||||
<section>
|
||||
<h3 className="text-xs font-medium text-text-muted uppercase tracking-wider mb-2">
|
||||
Request Headers
|
||||
</h3>
|
||||
<HeaderTable headers={request.requestHeaders} />
|
||||
</section>
|
||||
<section>
|
||||
<h3 className="text-xs font-medium text-text-muted uppercase tracking-wider mb-2">
|
||||
Response Headers
|
||||
</h3>
|
||||
<HeaderTable headers={request.responseHeaders} />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import type { InterceptedRequest } from "@/mitm/inspector/types";
|
||||
import { extractLlmMetadata } from "@/mitm/inspector/llmMetadataExtractor";
|
||||
import { TokenBadge } from "../shared/TokenBadge";
|
||||
|
||||
interface LlmDetailsTabProps {
|
||||
request: InterceptedRequest;
|
||||
}
|
||||
|
||||
export function LlmDetailsTab({ request }: LlmDetailsTabProps) {
|
||||
const meta = extractLlmMetadata(request);
|
||||
|
||||
if (!meta) {
|
||||
return (
|
||||
<div className="p-4 text-sm text-text-muted">
|
||||
LLM metadata not available for this request.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const rows: Array<{ label: string; value: string | null | undefined }> = [
|
||||
{ label: "Detected provider", value: meta.provider },
|
||||
{ label: "API kind", value: meta.apiKind },
|
||||
{ label: "Model", value: meta.model },
|
||||
{ label: "Messages", value: meta.messages > 0 ? String(meta.messages) : null },
|
||||
{ label: "Stream", value: meta.streamed ? "yes (SSE)" : "no" },
|
||||
{ label: "Mapped to", value: meta.mappedTo },
|
||||
{
|
||||
label: "Cost estimate",
|
||||
value: meta.costEstimateUsd != null ? `$${meta.costEstimateUsd.toFixed(6)}` : null,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-4 h-full overflow-auto space-y-4">
|
||||
<div className="rounded border border-border bg-bg-subtle">
|
||||
<table className="w-full text-sm">
|
||||
<tbody>
|
||||
{rows.map(({ label, value }) => (
|
||||
<tr key={label} className="border-b border-border/50 last:border-b-0">
|
||||
<td className="px-3 py-2 text-text-muted font-medium w-[40%]">{label}</td>
|
||||
<td className="px-3 py-2 text-text-main font-mono">{value ?? "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<TokenBadge tokensIn={meta.tokensIn} tokensOut={meta.tokensOut} />
|
||||
{(meta.tokensIn != null || meta.tokensOut != null) && (
|
||||
<span className="text-xs text-text-muted">
|
||||
Total: {(meta.tokensIn ?? 0) + (meta.tokensOut ?? 0)} tokens
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { InterceptedRequest } from "@/mitm/inspector/types";
|
||||
import { JsonViewer } from "../shared/JsonViewer";
|
||||
import { SecretMaskToggle } from "../shared/SecretMaskToggle";
|
||||
|
||||
interface RequestBodyTabProps {
|
||||
request: InterceptedRequest;
|
||||
}
|
||||
|
||||
const MASK_PATTERNS = [/sk-[A-Za-z0-9]+/g, /Bearer [A-Za-z0-9._-]+/g, /eyJ[A-Za-z0-9._-]+/g];
|
||||
|
||||
function maskSecrets(text: string): string {
|
||||
let out = text;
|
||||
for (const p of MASK_PATTERNS) {
|
||||
out = out.replace(p, "••••");
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function RequestBodyTab({ request }: RequestBodyTabProps) {
|
||||
const [masked, setMasked] = useState(true);
|
||||
const [raw, setRaw] = useState(false);
|
||||
|
||||
const body = request.requestBody;
|
||||
if (!body) {
|
||||
return <p className="p-4 text-sm text-text-muted">No request body.</p>;
|
||||
}
|
||||
|
||||
const display = masked ? maskSecrets(body) : body;
|
||||
let parsed: unknown = null;
|
||||
try {
|
||||
parsed = JSON.parse(display);
|
||||
} catch {
|
||||
// not JSON
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col gap-2 p-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<SecretMaskToggle masked={masked} onToggle={() => setMasked((m) => !m)} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRaw((r) => !r)}
|
||||
className="text-xs text-text-muted hover:text-text-main border border-border rounded px-2 py-0.5 focus-ring"
|
||||
>
|
||||
{raw ? "Formatted" : "Raw"}
|
||||
</button>
|
||||
<span className="ml-auto text-xs text-text-muted">{request.requestSize} B</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto bg-bg-subtle rounded border border-border p-2">
|
||||
{raw || !parsed ? (
|
||||
<pre className="text-xs font-mono text-text-main whitespace-pre-wrap break-all">{display}</pre>
|
||||
) : (
|
||||
<JsonViewer data={parsed} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { InterceptedRequest } from "@/mitm/inspector/types";
|
||||
import { parseSseStream, mergeStream } from "@/mitm/inspector/sseMerger";
|
||||
import { JsonViewer } from "../shared/JsonViewer";
|
||||
import { SseEventList } from "../shared/SseEventList";
|
||||
|
||||
interface ResponseBodyTabProps {
|
||||
request: InterceptedRequest;
|
||||
}
|
||||
|
||||
export function ResponseBodyTab({ request }: ResponseBodyTabProps) {
|
||||
const [showRaw, setShowRaw] = useState(false);
|
||||
|
||||
const body = request.responseBody;
|
||||
if (!body) {
|
||||
return <p className="p-4 text-sm text-text-muted">No response body.</p>;
|
||||
}
|
||||
|
||||
const isSSE = body.startsWith("data:") || body.includes("\ndata:");
|
||||
const events = isSSE ? parseSseStream(body) : [];
|
||||
const merged = isSSE && !showRaw ? mergeStream(events) : null;
|
||||
|
||||
let parsed: unknown = null;
|
||||
if (!isSSE) {
|
||||
try {
|
||||
parsed = JSON.parse(body);
|
||||
} catch {
|
||||
// not JSON
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col gap-2 p-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{isSSE && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowRaw((r) => !r)}
|
||||
className="text-xs text-text-muted hover:text-text-main border border-border rounded px-2 py-0.5 focus-ring"
|
||||
>
|
||||
{showRaw ? "Merged view" : "Raw events"}
|
||||
</button>
|
||||
)}
|
||||
<span className="ml-auto text-xs text-text-muted">{request.responseSize} B</span>
|
||||
{request.status === "in-flight" && (
|
||||
<span className="text-xs text-amber-400 animate-pulse">streaming…</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto bg-bg-subtle rounded border border-border p-2">
|
||||
{isSSE && showRaw ? (
|
||||
<SseEventList events={events} />
|
||||
) : isSSE && merged ? (
|
||||
<div className="space-y-2">
|
||||
{merged.text && (
|
||||
<pre className="text-xs font-mono text-text-main whitespace-pre-wrap break-words">{merged.text}</pre>
|
||||
)}
|
||||
{merged.toolCalls && merged.toolCalls.length > 0 && (
|
||||
<JsonViewer data={merged.toolCalls} />
|
||||
)}
|
||||
</div>
|
||||
) : parsed ? (
|
||||
<JsonViewer data={parsed} />
|
||||
) : (
|
||||
<pre className="text-xs font-mono text-text-main whitespace-pre-wrap break-all">{body}</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
LineChart,
|
||||
Line,
|
||||
} from "recharts";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { InterceptedRequest } from "@/mitm/inspector/types";
|
||||
|
||||
interface StatsChartsProps {
|
||||
requests: InterceptedRequest[];
|
||||
}
|
||||
|
||||
export default function StatsCharts({ requests }: StatsChartsProps) {
|
||||
const t = useTranslations("trafficInspector");
|
||||
|
||||
const statusDist = requests.reduce<Record<string, number>>((acc, r) => {
|
||||
const key =
|
||||
typeof r.status === "number" ? `${Math.floor(r.status / 100)}xx` : String(r.status);
|
||||
acc[key] = (acc[key] ?? 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
const statusData = Object.entries(statusDist).map(([name, count]) => ({ name, count }));
|
||||
|
||||
const latencyData = requests
|
||||
.filter((r) => r.totalLatencyMs != null)
|
||||
.slice(-50)
|
||||
.map((r, i) => ({ i, ms: r.totalLatencyMs }));
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto p-4 space-y-6">
|
||||
<div>
|
||||
<h3 className="text-xs font-medium text-text-muted uppercase tracking-wider mb-3">
|
||||
{t("statsStatusDistribution")}
|
||||
</h3>
|
||||
<div style={{ height: 160 }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={statusData}>
|
||||
<XAxis dataKey="name" tick={{ fontSize: 11 }} />
|
||||
<YAxis tick={{ fontSize: 11 }} />
|
||||
<Tooltip />
|
||||
<Bar dataKey="count" fill="#6366f1" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{latencyData.length > 1 && (
|
||||
<div>
|
||||
<h3 className="text-xs font-medium text-text-muted uppercase tracking-wider mb-3">
|
||||
{t("statsLatency")}
|
||||
</h3>
|
||||
<div style={{ height: 160 }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={latencyData}>
|
||||
<XAxis dataKey="i" hide />
|
||||
<YAxis tick={{ fontSize: 11 }} unit="ms" />
|
||||
<Tooltip formatter={(v: unknown) => [`${String(v)}ms`, "latency"]} />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="ms"
|
||||
stroke="#10b981"
|
||||
dot={false}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-3 gap-3 text-sm">
|
||||
<div className="rounded border border-border bg-bg-subtle p-3">
|
||||
<div className="text-2xl font-bold text-text-main">{requests.length}</div>
|
||||
<div className="text-xs text-text-muted mt-1">{t("statsTotalRequests")}</div>
|
||||
</div>
|
||||
<div className="rounded border border-border bg-bg-subtle p-3">
|
||||
<div className="text-2xl font-bold text-green-400">
|
||||
{requests.filter((r) => typeof r.status === "number" && r.status < 400).length}
|
||||
</div>
|
||||
<div className="text-xs text-text-muted mt-1">{t("statsSuccessful")}</div>
|
||||
</div>
|
||||
<div className="rounded border border-border bg-bg-subtle p-3">
|
||||
<div className="text-2xl font-bold text-red-400">
|
||||
{
|
||||
requests.filter(
|
||||
(r) =>
|
||||
r.status === "error" || (typeof r.status === "number" && r.status >= 400),
|
||||
).length
|
||||
}
|
||||
</div>
|
||||
<div className="text-xs text-text-muted mt-1">{t("statsErrors")}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { InterceptedRequest } from "@/mitm/inspector/types";
|
||||
|
||||
interface StatsTabProps {
|
||||
requests: InterceptedRequest[];
|
||||
}
|
||||
|
||||
// Recharts bundle is split via Next.js dynamic() — not included in the initial page chunk.
|
||||
const StatsCharts = dynamic(() => import("./StatsCharts"), {
|
||||
ssr: false,
|
||||
loading: () => <LoadingCharts />,
|
||||
});
|
||||
|
||||
function LoadingCharts() {
|
||||
const t = useTranslations("trafficInspector");
|
||||
return <div className="p-4 text-sm text-muted-foreground">{t("loadingCharts")}</div>;
|
||||
}
|
||||
|
||||
export function StatsTab({ requests }: StatsTabProps) {
|
||||
const t = useTranslations("trafficInspector");
|
||||
if (requests.length === 0) {
|
||||
return (
|
||||
<div className="p-4 text-sm text-text-muted">{t("statsNoData")}</div>
|
||||
);
|
||||
}
|
||||
return <StatsCharts requests={requests} />;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { InterceptedRequest } from "@/mitm/inspector/types";
|
||||
import { TimingWaterfall } from "../shared/TimingWaterfall";
|
||||
|
||||
interface TimingTabProps {
|
||||
request: InterceptedRequest;
|
||||
}
|
||||
|
||||
export function TimingTab({ request }: TimingTabProps) {
|
||||
const t = useTranslations("trafficInspector");
|
||||
return (
|
||||
<div className="p-4 h-full overflow-auto space-y-4">
|
||||
<TimingWaterfall request={request} />
|
||||
<div className="border-t border-border pt-3 space-y-1 text-xs text-text-muted">
|
||||
<div className="flex justify-between">
|
||||
<span>{t("timingTimestamp")}</span>
|
||||
<span className="font-mono">{request.timestamp}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t("timingMethod")}</span>
|
||||
<span className="font-mono">{request.method}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t("timingStatus")}</span>
|
||||
<span className="font-mono">{String(request.status)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t("timingRequestSize")}</span>
|
||||
<span className="font-mono">{request.requestSize} B</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t("timingResponseSize")}</span>
|
||||
<span className="font-mono">{request.responseSize} B</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
|
||||
const DEBOUNCE_MS = 500;
|
||||
|
||||
export function useAnnotations(requestId: string | null) {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const save = useCallback(
|
||||
(annotation: string) => {
|
||||
if (!requestId) return;
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/tools/traffic-inspector/requests/${encodeURIComponent(requestId)}/annotation`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ annotation }),
|
||||
}
|
||||
);
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => ({}))) as { error?: { message?: string } };
|
||||
setError(body?.error?.message ?? "Failed to save annotation");
|
||||
}
|
||||
} catch {
|
||||
setError("Network error saving annotation");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, DEBOUNCE_MS);
|
||||
},
|
||||
[requestId]
|
||||
);
|
||||
|
||||
return { save, saving, error };
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import type { InterceptedRequest } from "@/mitm/inspector/types";
|
||||
|
||||
export function useReplay() {
|
||||
const [replaying, setReplaying] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const replay = useCallback(async (req: InterceptedRequest) => {
|
||||
setReplaying(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/tools/traffic-inspector/requests/${encodeURIComponent(req.id)}/replay`,
|
||||
{ method: "POST" }
|
||||
);
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => ({}))) as { error?: { message?: string } };
|
||||
setError(body?.error?.message ?? "Replay failed");
|
||||
}
|
||||
} catch {
|
||||
setError("Network error during replay");
|
||||
} finally {
|
||||
setReplaying(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { replay, replaying, error };
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
const STORAGE_KEY = "inspector.listWidth";
|
||||
const MIN_WIDTH = 280;
|
||||
const MAX_WIDTH = 720;
|
||||
const COLLAPSED_RAIL = 48;
|
||||
const DEFAULT_WIDTH = 360;
|
||||
|
||||
export interface ResizablePanelsState {
|
||||
listWidth: number;
|
||||
collapsed: boolean;
|
||||
}
|
||||
|
||||
export interface ResizablePanelsActions {
|
||||
startDrag: (e: React.MouseEvent) => void;
|
||||
toggleCollapse: () => void;
|
||||
}
|
||||
|
||||
export function useResizablePanels(): [ResizablePanelsState, ResizablePanelsActions] {
|
||||
const [listWidth, setListWidth] = useState<number>(() => {
|
||||
if (typeof window === "undefined") return DEFAULT_WIDTH;
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
const parsed = stored ? Number(stored) : NaN;
|
||||
return isNaN(parsed) ? DEFAULT_WIDTH : Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, parsed));
|
||||
});
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
const draggingRef = useRef(false);
|
||||
const startXRef = useRef(0);
|
||||
const startWidthRef = useRef(DEFAULT_WIDTH);
|
||||
// Store handler refs to avoid stale closure issues
|
||||
const onMouseMoveRef = useRef<(e: MouseEvent) => void>(() => {});
|
||||
const onMouseUpRef = useRef<() => void>(() => {});
|
||||
|
||||
useEffect(() => {
|
||||
if (!collapsed) {
|
||||
localStorage.setItem(STORAGE_KEY, String(listWidth));
|
||||
}
|
||||
}, [listWidth, collapsed]);
|
||||
|
||||
useEffect(() => {
|
||||
onMouseMoveRef.current = (e: MouseEvent) => {
|
||||
if (!draggingRef.current) return;
|
||||
const delta = e.clientX - startXRef.current;
|
||||
const next = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, startWidthRef.current + delta));
|
||||
setListWidth(next);
|
||||
setCollapsed(false);
|
||||
};
|
||||
|
||||
onMouseUpRef.current = () => {
|
||||
draggingRef.current = false;
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
window.removeEventListener("mousemove", onMouseMoveRef.current);
|
||||
window.removeEventListener("mouseup", onMouseUpRef.current);
|
||||
};
|
||||
});
|
||||
|
||||
const startDrag = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
draggingRef.current = true;
|
||||
startXRef.current = e.clientX;
|
||||
startWidthRef.current = collapsed ? COLLAPSED_RAIL : listWidth;
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
window.addEventListener("mousemove", onMouseMoveRef.current);
|
||||
window.addEventListener("mouseup", onMouseUpRef.current);
|
||||
},
|
||||
[collapsed, listWidth]
|
||||
);
|
||||
|
||||
const toggleCollapse = useCallback(() => {
|
||||
setCollapsed((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const effectiveWidth = collapsed ? COLLAPSED_RAIL : listWidth;
|
||||
|
||||
return [{ listWidth: effectiveWidth, collapsed }, { startDrag, toggleCollapse }];
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { WsEvent } from "@/mitm/inspector/types";
|
||||
|
||||
const WS_PATH = "/api/tools/traffic-inspector/ws";
|
||||
const SNAPSHOT_FLUSH_MS = 500;
|
||||
const SNAPSHOT_FLUSH_BATCH = 10;
|
||||
|
||||
export interface SessionInfo {
|
||||
id: string;
|
||||
name?: string;
|
||||
startedAt: string;
|
||||
requestCount: number;
|
||||
}
|
||||
|
||||
async function fetchSessionsRemote(): Promise<SessionInfo[]> {
|
||||
const res = await fetch("/api/tools/traffic-inspector/sessions");
|
||||
if (!res.ok) return [];
|
||||
const data = (await res.json()) as { sessions: SessionInfo[] };
|
||||
return data.sessions ?? [];
|
||||
}
|
||||
|
||||
export function useSessionRecorder() {
|
||||
const [recording, setRecording] = useState(false);
|
||||
const [session, setSession] = useState<SessionInfo | null>(null);
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const [sessions, setSessions] = useState<SessionInfo[]>([]);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const startTimeRef = useRef<number>(0);
|
||||
const mountedRef = useRef(true);
|
||||
const recordingWsRef = useRef<WebSocket | null>(null);
|
||||
const recordingSessionRef = useRef<SessionInfo | null>(null);
|
||||
const pendingSnapshotsRef = useRef<string[]>([]);
|
||||
const flushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const fetchSessions = useCallback(async () => {
|
||||
try {
|
||||
const list = await fetchSessionsRemote();
|
||||
if (mountedRef.current) setSessions(list);
|
||||
} catch {
|
||||
// silently ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fetch sessions on mount — use an async wrapper to avoid direct setState in effect
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetchSessionsRemote()
|
||||
.then((list) => {
|
||||
if (!cancelled) setSessions(list);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const flushSnapshots = useCallback(async (sessionId: string) => {
|
||||
if (pendingSnapshotsRef.current.length === 0) return;
|
||||
const batch = pendingSnapshotsRef.current.splice(0, pendingSnapshotsRef.current.length);
|
||||
for (const payload of batch) {
|
||||
try {
|
||||
await fetch(
|
||||
`/api/tools/traffic-inspector/sessions/${encodeURIComponent(sessionId)}/requests`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ payload }),
|
||||
}
|
||||
);
|
||||
} catch {
|
||||
// best-effort: don't break recording UI on network failure
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const scheduleFlush = useCallback(
|
||||
(sessionId: string) => {
|
||||
if (flushTimerRef.current) return;
|
||||
flushTimerRef.current = setTimeout(() => {
|
||||
flushTimerRef.current = null;
|
||||
void flushSnapshots(sessionId);
|
||||
}, SNAPSHOT_FLUSH_MS);
|
||||
},
|
||||
[flushSnapshots]
|
||||
);
|
||||
|
||||
const stopRecordingWs = useCallback(() => {
|
||||
if (flushTimerRef.current) {
|
||||
clearTimeout(flushTimerRef.current);
|
||||
flushTimerRef.current = null;
|
||||
}
|
||||
if (recordingWsRef.current) {
|
||||
recordingWsRef.current.onclose = null;
|
||||
recordingWsRef.current.close();
|
||||
recordingWsRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const start = useCallback(
|
||||
async (name?: string) => {
|
||||
try {
|
||||
const res = await fetch("/api/tools/traffic-inspector/sessions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const data = (await res.json()) as { session: SessionInfo };
|
||||
const newSession = data.session;
|
||||
setSession(newSession);
|
||||
recordingSessionRef.current = newSession;
|
||||
setRecording(true);
|
||||
startTimeRef.current = Date.now();
|
||||
setElapsed(0);
|
||||
timerRef.current = setInterval(() => {
|
||||
setElapsed(Math.floor((Date.now() - startTimeRef.current) / 1000));
|
||||
}, 1000);
|
||||
|
||||
// Open a dedicated WS to capture traffic events during the recording window
|
||||
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const wsUrl = `${proto}//${window.location.host}${WS_PATH}`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
recordingWsRef.current = ws;
|
||||
|
||||
ws.onmessage = (ev: MessageEvent) => {
|
||||
if (!mountedRef.current) return;
|
||||
let event: WsEvent;
|
||||
try {
|
||||
event = JSON.parse(ev.data as string) as WsEvent;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (event.type !== "new") return;
|
||||
const sid = recordingSessionRef.current?.id;
|
||||
if (!sid) return;
|
||||
pendingSnapshotsRef.current.push(JSON.stringify(event.data));
|
||||
if (pendingSnapshotsRef.current.length >= SNAPSHOT_FLUSH_BATCH) {
|
||||
void flushSnapshots(sid);
|
||||
} else {
|
||||
scheduleFlush(sid);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => ws.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
[flushSnapshots, scheduleFlush]
|
||||
);
|
||||
|
||||
const stop = useCallback(async () => {
|
||||
if (!session) return;
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
setRecording(false);
|
||||
// Flush any remaining pending snapshots before stopping
|
||||
const sid = session.id;
|
||||
stopRecordingWs();
|
||||
if (pendingSnapshotsRef.current.length > 0) {
|
||||
await flushSnapshots(sid);
|
||||
}
|
||||
recordingSessionRef.current = null;
|
||||
try {
|
||||
await fetch(`/api/tools/traffic-inspector/sessions/${encodeURIComponent(sid)}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "stop" }),
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
await fetchSessions();
|
||||
setSession(null);
|
||||
}, [session, fetchSessions, stopRecordingWs, flushSnapshots]);
|
||||
|
||||
const deleteSession = useCallback(async (id: string) => {
|
||||
try {
|
||||
await fetch(`/api/tools/traffic-inspector/sessions/${encodeURIComponent(id)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
await fetchSessions();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [fetchSessions]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
if (flushTimerRef.current) clearTimeout(flushTimerRef.current);
|
||||
if (recordingWsRef.current) {
|
||||
recordingWsRef.current.onclose = null;
|
||||
recordingWsRef.current.close();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
recording,
|
||||
session,
|
||||
elapsed,
|
||||
sessions,
|
||||
start,
|
||||
stop,
|
||||
deleteSession,
|
||||
fetchSessions,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
interface UseSystemProxyExitGuardOpts {
|
||||
applied: boolean; // current state (from GET capture-modes)
|
||||
endpoint?: string; // POST /capture-modes/system-proxy
|
||||
}
|
||||
|
||||
/**
|
||||
* On unmount / page hide / beforeunload, if system proxy is applied,
|
||||
* silently fires a revert request via navigator.sendBeacon (best-effort,
|
||||
* survives unload) AND attaches a beforeunload listener that prompts the
|
||||
* user with a native confirm dialog (browser default — text is ignored
|
||||
* by most browsers but the prompt itself appears).
|
||||
*/
|
||||
export function useSystemProxyExitGuard(opts: UseSystemProxyExitGuardOpts): void {
|
||||
// 1. Track latest 'applied' in a ref so the listener always sees fresh value
|
||||
const appliedRef = useRef(opts.applied);
|
||||
useEffect(() => {
|
||||
appliedRef.current = opts.applied;
|
||||
}, [opts.applied]);
|
||||
|
||||
useEffect(() => {
|
||||
const endpoint =
|
||||
opts.endpoint ?? "/api/tools/traffic-inspector/capture-modes/system-proxy";
|
||||
const body = JSON.stringify({ action: "revert" });
|
||||
const blob = new Blob([body], { type: "application/json" });
|
||||
|
||||
const beforeUnload = (e: BeforeUnloadEvent) => {
|
||||
if (!appliedRef.current) return;
|
||||
// Best-effort revert via sendBeacon (survives navigation)
|
||||
try {
|
||||
navigator.sendBeacon(endpoint, blob);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
// Show confirmation prompt
|
||||
e.preventDefault();
|
||||
e.returnValue = "System-wide proxy still active — leave page anyway?";
|
||||
return e.returnValue;
|
||||
};
|
||||
|
||||
window.addEventListener("beforeunload", beforeUnload);
|
||||
return () => {
|
||||
window.removeEventListener("beforeunload", beforeUnload);
|
||||
// On component unmount (SPA navigation), fire revert too
|
||||
if (appliedRef.current) {
|
||||
try {
|
||||
navigator.sendBeacon(endpoint, blob);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [opts.endpoint]);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import type { ListFilters } from "@/mitm/inspector/types";
|
||||
|
||||
export interface FiltersState extends ListFilters {
|
||||
sameContextKey?: string;
|
||||
}
|
||||
|
||||
export function useTrafficFilters() {
|
||||
const [filters, setFilters] = useState<FiltersState>({ profile: "llm" });
|
||||
|
||||
const setProfile = useCallback((profile: ListFilters["profile"]) => {
|
||||
setFilters((prev) => ({ ...prev, profile }));
|
||||
}, []);
|
||||
|
||||
const setHost = useCallback((host: string | undefined) => {
|
||||
setFilters((prev) => ({ ...prev, host: host || undefined }));
|
||||
}, []);
|
||||
|
||||
const setAgent = useCallback((agent: ListFilters["agent"]) => {
|
||||
setFilters((prev) => ({ ...prev, agent }));
|
||||
}, []);
|
||||
|
||||
const setStatus = useCallback((status: ListFilters["status"]) => {
|
||||
setFilters((prev) => ({ ...prev, status }));
|
||||
}, []);
|
||||
|
||||
const setSource = useCallback((source: ListFilters["source"]) => {
|
||||
setFilters((prev) => ({ ...prev, source }));
|
||||
}, []);
|
||||
|
||||
const setSessionId = useCallback((sessionId: string | undefined) => {
|
||||
setFilters((prev) => ({ ...prev, sessionId }));
|
||||
}, []);
|
||||
|
||||
const setSameContext = useCallback((contextKey: string | undefined) => {
|
||||
setFilters((prev) => ({ ...prev, sameContextKey: contextKey }));
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setFilters({ profile: "llm" });
|
||||
}, []);
|
||||
|
||||
return {
|
||||
filters,
|
||||
setProfile,
|
||||
setHost,
|
||||
setAgent,
|
||||
setStatus,
|
||||
setSource,
|
||||
setSessionId,
|
||||
setSameContext,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { InterceptedRequest, ListFilters, WsEvent } from "@/mitm/inspector/types";
|
||||
import type { FiltersState } from "./useTrafficFilters";
|
||||
|
||||
const WS_PATH = "/api/tools/traffic-inspector/ws";
|
||||
const INITIAL_BACKOFF_MS = 500;
|
||||
const MAX_BACKOFF_MS = 30_000;
|
||||
const BACKOFF_MULTIPLIER = 2;
|
||||
|
||||
export interface TrafficStreamState {
|
||||
requests: InterceptedRequest[];
|
||||
connected: boolean;
|
||||
paused: boolean;
|
||||
total: number;
|
||||
pendingCount: number;
|
||||
}
|
||||
|
||||
export interface TrafficStreamActions {
|
||||
pause: () => void;
|
||||
resume: () => void;
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
export function useTrafficStream(
|
||||
filters: FiltersState | ListFilters
|
||||
): [TrafficStreamState, TrafficStreamActions] {
|
||||
const [requests, setRequests] = useState<InterceptedRequest[]>([]);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [paused, setPaused] = useState(false);
|
||||
const [pendingCount, setPendingCount] = useState(0);
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const backoffRef = useRef(INITIAL_BACKOFF_MS);
|
||||
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
const pausedRef = useRef(false);
|
||||
const pendingRef = useRef<InterceptedRequest[]>([]);
|
||||
const filtersRef = useRef(filters);
|
||||
// connectRef breaks the circular dep between connect's closure and onclose
|
||||
const connectRef = useRef<() => void>(() => {});
|
||||
|
||||
// Keep filtersRef in sync without triggering re-render (effect runs after render)
|
||||
useEffect(() => {
|
||||
filtersRef.current = filters;
|
||||
});
|
||||
|
||||
const applyFilter = useCallback((req: InterceptedRequest): boolean => {
|
||||
const f = filtersRef.current as FiltersState;
|
||||
if (f.profile === "llm" && req.detectedKind !== "llm") return false;
|
||||
if (f.profile === "custom" && req.source !== "custom-host") return false;
|
||||
if (f.host && !req.host.includes(f.host)) return false;
|
||||
if (f.agent && req.agent !== f.agent) return false;
|
||||
if (f.source && req.source !== f.source) return false;
|
||||
if (f.sessionId && req.sessionId !== f.sessionId) return false;
|
||||
if (f.sameContextKey && req.contextKey !== f.sameContextKey) return false;
|
||||
if (f.status) {
|
||||
const s = req.status;
|
||||
if (typeof s === "number") {
|
||||
const cat = `${Math.floor(s / 100)}xx`;
|
||||
if (cat !== f.status) return false;
|
||||
} else if (f.status === "error" && s !== "error") {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
|
||||
const connect = () => {
|
||||
if (!mountedRef.current) return;
|
||||
if (wsRef.current && wsRef.current.readyState < WebSocket.CLOSING) {
|
||||
wsRef.current.close();
|
||||
}
|
||||
|
||||
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const url = `${proto}//${window.location.host}${WS_PATH}`;
|
||||
const ws = new WebSocket(url);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
if (!mountedRef.current) return;
|
||||
backoffRef.current = INITIAL_BACKOFF_MS;
|
||||
setConnected(true);
|
||||
};
|
||||
|
||||
ws.onmessage = (ev: MessageEvent) => {
|
||||
if (!mountedRef.current) return;
|
||||
let event: WsEvent;
|
||||
try {
|
||||
event = JSON.parse(ev.data as string) as WsEvent;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pausedRef.current) {
|
||||
if (event.type === "new") {
|
||||
pendingRef.current.push(event.data);
|
||||
setPendingCount(pendingRef.current.length);
|
||||
}
|
||||
if (event.type === "update") {
|
||||
const idx = pendingRef.current.findIndex((r) => r.id === event.data.id);
|
||||
if (idx !== -1) pendingRef.current[idx] = event.data;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === "snapshot") {
|
||||
setRequests(event.data.filter(applyFilter));
|
||||
} else if (event.type === "new") {
|
||||
if (applyFilter(event.data)) {
|
||||
setRequests((prev) => [event.data, ...prev].slice(0, 1000));
|
||||
}
|
||||
} else if (event.type === "update") {
|
||||
setRequests((prev) =>
|
||||
prev.map((r) => (r.id === event.data.id ? event.data : r))
|
||||
);
|
||||
} else if (event.type === "clear") {
|
||||
setRequests([]);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (!mountedRef.current) return;
|
||||
setConnected(false);
|
||||
const delay = Math.min(backoffRef.current, MAX_BACKOFF_MS);
|
||||
backoffRef.current = Math.min(
|
||||
backoffRef.current * BACKOFF_MULTIPLIER,
|
||||
MAX_BACKOFF_MS
|
||||
);
|
||||
reconnectTimerRef.current = setTimeout(() => {
|
||||
// Use ref so we always call the current connect version
|
||||
connectRef.current();
|
||||
}, delay);
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
ws.close();
|
||||
};
|
||||
};
|
||||
|
||||
// Store in ref for reconnect callback
|
||||
connectRef.current = connect;
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current);
|
||||
wsRef.current?.close();
|
||||
};
|
||||
}, [applyFilter]);
|
||||
|
||||
const pause = useCallback(() => {
|
||||
pausedRef.current = true;
|
||||
setPaused(true);
|
||||
}, []);
|
||||
|
||||
const resume = useCallback(() => {
|
||||
pausedRef.current = false;
|
||||
setPaused(false);
|
||||
if (pendingRef.current.length > 0) {
|
||||
const pending = pendingRef.current.filter(applyFilter);
|
||||
pendingRef.current = [];
|
||||
setPendingCount(0);
|
||||
setRequests((prev) => [...pending, ...prev].slice(0, 1000));
|
||||
}
|
||||
}, [applyFilter]);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setRequests([]);
|
||||
pendingRef.current = [];
|
||||
setPendingCount(0);
|
||||
}, []);
|
||||
|
||||
const state: TrafficStreamState = {
|
||||
requests,
|
||||
connected,
|
||||
paused,
|
||||
total: requests.length,
|
||||
pendingCount,
|
||||
};
|
||||
|
||||
return [state, { pause, resume, clear }];
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
const ESTIMATED_ROW_HEIGHT = 48;
|
||||
const OVERSCAN = 5;
|
||||
|
||||
export interface VirtualListState<T> {
|
||||
virtualItems: Array<{ index: number; item: T; top: number; height: number }>;
|
||||
totalHeight: number;
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
rowRef: (index: number) => (el: HTMLDivElement | null) => void;
|
||||
}
|
||||
|
||||
export function useVirtualList<T>(items: T[], containerHeight: number): VirtualListState<T> {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [scrollTop, setScrollTop] = useState(0);
|
||||
// Heights stored in state so reads during render are tracked by React
|
||||
const [heights, setHeights] = useState<Map<number, number>>(new Map());
|
||||
const observersRef = useRef<Map<number, ResizeObserver>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const handler = () => setScrollTop(el.scrollTop);
|
||||
el.addEventListener("scroll", handler, { passive: true });
|
||||
return () => el.removeEventListener("scroll", handler);
|
||||
}, []);
|
||||
|
||||
// Cleanup observers on unmount
|
||||
useEffect(() => {
|
||||
const observers = observersRef.current;
|
||||
return () => {
|
||||
observers.forEach((obs) => obs.disconnect());
|
||||
};
|
||||
}, []);
|
||||
|
||||
const rowRef = useCallback((index: number) => (el: HTMLDivElement | null) => {
|
||||
const observers = observersRef.current;
|
||||
if (el) {
|
||||
const existing = observers.get(index);
|
||||
if (existing) existing.disconnect();
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const h = entry.contentRect.height;
|
||||
if (h > 0) {
|
||||
setHeights((prev) => {
|
||||
if (prev.get(index) === h) return prev;
|
||||
const next = new Map(prev);
|
||||
next.set(index, h);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
ro.observe(el);
|
||||
observers.set(index, ro);
|
||||
} else {
|
||||
const existing = observers.get(index);
|
||||
if (existing) {
|
||||
existing.disconnect();
|
||||
observers.delete(index);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Compute cumulative offsets — reads heights from state (not a ref)
|
||||
const offsets: number[] = [];
|
||||
let total = 0;
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
offsets.push(total);
|
||||
total += heights.get(i) ?? ESTIMATED_ROW_HEIGHT;
|
||||
}
|
||||
const totalHeight = total;
|
||||
|
||||
// Find visible range
|
||||
let startIdx = 0;
|
||||
let endIdx = items.length - 1;
|
||||
for (let i = 0; i < offsets.length; i++) {
|
||||
if ((offsets[i] ?? 0) + (heights.get(i) ?? ESTIMATED_ROW_HEIGHT) < scrollTop) {
|
||||
startIdx = i + 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (let i = startIdx; i < offsets.length; i++) {
|
||||
if ((offsets[i] ?? 0) > scrollTop + containerHeight) {
|
||||
endIdx = i - 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
startIdx = Math.max(0, startIdx - OVERSCAN);
|
||||
endIdx = Math.min(items.length - 1, endIdx + OVERSCAN);
|
||||
|
||||
const virtualItems: Array<{ index: number; item: T; top: number; height: number }> = [];
|
||||
for (let i = startIdx; i <= endIdx; i++) {
|
||||
virtualItems.push({
|
||||
index: i,
|
||||
item: items[i] as T,
|
||||
top: offsets[i] ?? 0,
|
||||
height: heights.get(i) ?? ESTIMATED_ROW_HEIGHT,
|
||||
});
|
||||
}
|
||||
|
||||
return { virtualItems, totalHeight, containerRef, rowRef };
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { TrafficInspectorPageClient } from "./TrafficInspectorPageClient";
|
||||
|
||||
export const metadata = {
|
||||
title: "Traffic Inspector — OmniRoute",
|
||||
description: "Monitor LLM calls + debug any application's HTTPS traffic",
|
||||
};
|
||||
|
||||
export default function TrafficInspectorPage() {
|
||||
return <TrafficInspectorPageClient />;
|
||||
}
|
||||
@@ -1,171 +1,279 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useCallback, useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { Badge, Card, SegmentedControl } from "@/shared/components";
|
||||
import PlaygroundMode from "./components/PlaygroundMode";
|
||||
import ChatTesterMode from "./components/ChatTesterMode";
|
||||
import TestBenchMode from "./components/TestBenchMode";
|
||||
import LiveMonitorMode from "./components/LiveMonitorMode";
|
||||
import StreamTransformerMode from "./components/StreamTransformerMode";
|
||||
import TranslatorConceptCard from "./components/TranslatorConceptCard";
|
||||
import TranslateTab from "./components/TranslateTab";
|
||||
import MonitorTab from "./components/MonitorTab";
|
||||
import AdvancedSection from "./components/advanced/AdvancedSection";
|
||||
import RawJsonPanel from "./components/advanced/RawJsonPanel";
|
||||
import PipelineView from "./components/advanced/PipelineView";
|
||||
import type { PipelineStep } from "./components/advanced/PipelineView";
|
||||
import StreamTransformerAccordion from "./components/advanced/StreamTransformerAccordion";
|
||||
import TestBenchAccordion from "./components/advanced/TestBenchAccordion";
|
||||
import CompressionPreviewAccordion from "./components/advanced/CompressionPreviewAccordion";
|
||||
import { useTranslateDeepLink } from "./hooks/useTranslateDeepLink";
|
||||
import { useTranslateSession } from "./hooks/useTranslateSession";
|
||||
import type { AdvancedSlug, TranslatorTab } from "./types";
|
||||
|
||||
export default function TranslatorPageClient() {
|
||||
return (
|
||||
<Suspense fallback={<div className="p-8 text-text-muted">Loading…</div>}>
|
||||
<TranslatorPageClientInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function TranslatorPageClientInner() {
|
||||
const t = useTranslations("translator");
|
||||
const [showFeatures, setShowFeatures] = useState(false);
|
||||
const translateOrFallback = useCallback(
|
||||
(key: string, fallback: string) => {
|
||||
const [sharedInputContent, setSharedInputContent] = useState("");
|
||||
const { state, setTab, setAdvanced } = useTranslateDeepLink();
|
||||
|
||||
// Lift session to shell so PipelineView can receive real steps
|
||||
const session = useTranslateSession();
|
||||
|
||||
const makeOpenHandler = (slug: AdvancedSlug) => (open: boolean) => {
|
||||
if (open) {
|
||||
setAdvanced(slug);
|
||||
} else if (state.advanced === slug) {
|
||||
setAdvanced(null);
|
||||
}
|
||||
};
|
||||
|
||||
const tr = useCallback(
|
||||
(key: string, fallback: string): string => {
|
||||
try {
|
||||
const translated = t(key);
|
||||
return translated === key || translated === `translator.${key}` ? fallback : translated;
|
||||
const v = t(key as Parameters<typeof t>[0]);
|
||||
if (v === key || v === `translator.${key}`) return fallback;
|
||||
return v as string;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
},
|
||||
[t]
|
||||
[t],
|
||||
);
|
||||
const [mode, setMode] = useState("playground");
|
||||
const modes = [
|
||||
{ value: "playground", label: translateOrFallback("playground", "Playground"), icon: "code" },
|
||||
{
|
||||
value: "chat-tester",
|
||||
label: translateOrFallback("chatTester", "Chat Tester"),
|
||||
icon: "chat",
|
||||
},
|
||||
{
|
||||
value: "test-bench",
|
||||
label: translateOrFallback("testBench", "Test Bench"),
|
||||
icon: "science",
|
||||
},
|
||||
{
|
||||
value: "stream-transformer",
|
||||
label: translateOrFallback("streamTransformer", "Stream Transformer"),
|
||||
icon: "swap_horiz",
|
||||
},
|
||||
{
|
||||
value: "live-monitor",
|
||||
label: translateOrFallback("liveMonitor", "Live Monitor"),
|
||||
icon: "monitoring",
|
||||
},
|
||||
|
||||
// Build PipelineStep[] from session.result so PipelineView reflects real state
|
||||
const pipelineSteps = useMemo<PipelineStep[]>(() => {
|
||||
const r = session.result;
|
||||
if (r.status === "idle") return [];
|
||||
|
||||
const steps: PipelineStep[] = [];
|
||||
|
||||
// Step 1 — Client Request (always present once started)
|
||||
steps.push({
|
||||
id: "1",
|
||||
name: tr("pipelineStepClientRequest", "Client Request"),
|
||||
description: tr("pipelineStepClientRequestDesc", "Request received in client format"),
|
||||
format: r.detected ?? "openai",
|
||||
content: sharedInputContent.slice(0, 500),
|
||||
status: r.status === "error" ? "error" : "done",
|
||||
});
|
||||
|
||||
// Step 2 — Format Detected
|
||||
steps.push({
|
||||
id: "2",
|
||||
name: tr("pipelineStepFormatDetected", "Format Detected"),
|
||||
description: tr("pipelineStepFormatDetectedDesc", "Auto-detected source format"),
|
||||
format: r.detected ?? null,
|
||||
content: r.detected ? JSON.stringify({ detectedFormat: r.detected, confidence: "high" }, null, 2) : "",
|
||||
status: r.detected ? "done" : r.status === "translating" ? "active" : "pending",
|
||||
});
|
||||
|
||||
// Step 3 — OpenAI Intermediate (only when hub-and-spoke)
|
||||
if (r.pipelinePath === "hub-and-spoke") {
|
||||
steps.push({
|
||||
id: "3",
|
||||
name: tr("pipelineStepOpenAIIntermediate", "OpenAI Intermediate"),
|
||||
description: tr("pipelineStepOpenAIIntermediateDesc", "Translated to OpenAI hub format"),
|
||||
format: "openai",
|
||||
content: r.intermediateJson ?? "",
|
||||
status: r.intermediateJson ? "done" : r.status === "translating" ? "active" : "pending",
|
||||
});
|
||||
}
|
||||
|
||||
// Step 4 — Provider Format (translated result)
|
||||
steps.push({
|
||||
id: r.pipelinePath === "hub-and-spoke" ? "4" : "3",
|
||||
name: tr("pipelineStepProviderFormat", "Provider Format"),
|
||||
description: tr("pipelineStepProviderFormatDesc", "Translated to provider target format"),
|
||||
format: r.target,
|
||||
content: r.translatedJson ?? "",
|
||||
status: r.translatedJson ? "done" : r.status === "translating" ? "active" : "pending",
|
||||
});
|
||||
|
||||
// Step 5 — Provider Response (only when mode=send and response present)
|
||||
if (r.responsePreview !== null) {
|
||||
steps.push({
|
||||
id: r.pipelinePath === "hub-and-spoke" ? "5" : "4",
|
||||
name: tr("pipelineStepProviderResponse", "Provider Response"),
|
||||
description: tr("pipelineStepProviderResponseDesc", "Streaming response from provider"),
|
||||
format: "openai",
|
||||
content: r.responsePreview,
|
||||
status: r.status === "ok" ? "done" : r.status === "sending" ? "active" : "pending",
|
||||
});
|
||||
}
|
||||
|
||||
return steps;
|
||||
}, [session.result, sharedInputContent, tr]);
|
||||
|
||||
const advancedSlot = (
|
||||
<AdvancedSection forceOpenSlug={state.advanced}>
|
||||
<RawJsonPanel
|
||||
slug="rawjson"
|
||||
forceOpen={state.advanced === "rawjson"}
|
||||
onOpenChange={makeOpenHandler("rawjson")}
|
||||
/>
|
||||
<PipelineView
|
||||
slug="pipeline"
|
||||
forceOpen={state.advanced === "pipeline"}
|
||||
onOpenChange={makeOpenHandler("pipeline")}
|
||||
pipelineSteps={pipelineSteps.length > 0 ? pipelineSteps : undefined}
|
||||
/>
|
||||
<StreamTransformerAccordion
|
||||
forceOpen={state.advanced === "streamtransform"}
|
||||
onOpenChange={makeOpenHandler("streamtransform")}
|
||||
/>
|
||||
<TestBenchAccordion
|
||||
forceOpen={state.advanced === "testbench"}
|
||||
onOpenChange={makeOpenHandler("testbench")}
|
||||
/>
|
||||
<CompressionPreviewAccordion
|
||||
forceOpen={state.advanced === "compression"}
|
||||
onOpenChange={makeOpenHandler("compression")}
|
||||
inputContent={sharedInputContent}
|
||||
/>
|
||||
</AdvancedSection>
|
||||
);
|
||||
|
||||
const tabOptions = [
|
||||
{ value: "translate", label: t("tabTranslate"), icon: "translate" },
|
||||
{ value: "monitor", label: t("tabMonitor"), icon: "monitoring" },
|
||||
];
|
||||
const modeDescriptions: Record<string, string> = {
|
||||
playground: translateOrFallback(
|
||||
"modeDescriptionPlayground",
|
||||
"Inspect request translation step-by-step between API formats."
|
||||
),
|
||||
"chat-tester": translateOrFallback(
|
||||
"modeDescriptionChatTester",
|
||||
"Send a real prompt through the selected provider and inspect every translation stage."
|
||||
),
|
||||
"test-bench": translateOrFallback(
|
||||
"modeDescriptionTestBench",
|
||||
"Run compatibility scenarios across source formats and target providers."
|
||||
),
|
||||
"stream-transformer": translateOrFallback(
|
||||
"modeDescriptionStreamTransformer",
|
||||
"Transform Chat Completions SSE into Responses API SSE and inspect emitted events."
|
||||
),
|
||||
"live-monitor": translateOrFallback(
|
||||
"modeDescriptionLiveMonitor",
|
||||
"Watch translation events in real time as requests flow through OmniRoute."
|
||||
),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 min-w-0">
|
||||
<TranslatorConceptCard />
|
||||
|
||||
<AutoFeaturesCard />
|
||||
|
||||
<div className="flex justify-end min-w-0 overflow-x-auto">
|
||||
<SegmentedControl
|
||||
options={modes}
|
||||
value={mode}
|
||||
onChange={setMode}
|
||||
options={tabOptions}
|
||||
value={state.tab}
|
||||
onChange={(v) => setTab(v as TranslatorTab)}
|
||||
size="md"
|
||||
aria-label={t("tabTranslateAriaLabel")}
|
||||
className="min-w-max"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Card className="border-primary/10 bg-primary/5">
|
||||
<button
|
||||
onClick={() => setShowFeatures((prev) => !prev)}
|
||||
className="flex w-full items-center justify-between p-4 text-left"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[20px] text-primary">
|
||||
auto_fix_high
|
||||
</span>
|
||||
<h3 className="text-sm font-semibold text-text-main">{t("autoFeaturesTitle")}</h3>
|
||||
<Badge variant="primary" size="sm">
|
||||
{t("autoFeaturesCount")}
|
||||
</Badge>
|
||||
</div>
|
||||
<span className="material-symbols-outlined text-[18px] text-text-muted">
|
||||
{showFeatures ? "expand_less" : "expand_more"}
|
||||
</span>
|
||||
</button>
|
||||
{state.tab === "translate" && (
|
||||
<TranslateTab
|
||||
forceOpenAdvancedSlug={state.advanced}
|
||||
onAdvancedSlugChange={(slug) => setAdvanced(slug)}
|
||||
session={session}
|
||||
onInputChange={setSharedInputContent}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showFeatures && (
|
||||
<div className="grid grid-cols-1 gap-3 px-4 pb-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<FeatureChip
|
||||
icon="psychology"
|
||||
title={t("featureReasoningCache")}
|
||||
description={t("featureReasoningCacheDesc")}
|
||||
color="purple"
|
||||
/>
|
||||
<FeatureChip
|
||||
icon="schema"
|
||||
title={t("featureSchemaCoercion")}
|
||||
description={t("featureSchemaCoercionDesc")}
|
||||
color="blue"
|
||||
/>
|
||||
<FeatureChip
|
||||
icon="swap_vert"
|
||||
title={t("featureRoleNormalization")}
|
||||
description={t("featureRoleNormalizationDesc")}
|
||||
color="amber"
|
||||
/>
|
||||
<FeatureChip
|
||||
icon="fingerprint"
|
||||
title={t("featureToolCallIds")}
|
||||
description={t("featureToolCallIdsDesc")}
|
||||
color="emerald"
|
||||
/>
|
||||
<FeatureChip
|
||||
icon="add_circle"
|
||||
title={t("featureMissingToolResponse")}
|
||||
description={t("featureMissingToolResponseDesc")}
|
||||
color="cyan"
|
||||
/>
|
||||
<FeatureChip
|
||||
icon="tune"
|
||||
title={t("featureThinkingBudget")}
|
||||
description={t("featureThinkingBudgetDesc")}
|
||||
color="orange"
|
||||
/>
|
||||
<FeatureChip
|
||||
icon="alt_route"
|
||||
title={t("featureDirectPaths")}
|
||||
description={t("featureDirectPathsDesc")}
|
||||
color="pink"
|
||||
/>
|
||||
<FeatureChip
|
||||
icon="photo_size_select_large"
|
||||
title={t("featureImageMapping")}
|
||||
description={t("featureImageMappingDesc")}
|
||||
color="indigo"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
{state.tab === "translate" && advancedSlot}
|
||||
|
||||
{/* Mode Content */}
|
||||
{mode === "playground" && <PlaygroundMode />}
|
||||
{mode === "chat-tester" && <ChatTesterMode />}
|
||||
{mode === "test-bench" && <TestBenchMode />}
|
||||
{mode === "stream-transformer" && <StreamTransformerMode />}
|
||||
{mode === "live-monitor" && <LiveMonitorMode />}
|
||||
{state.tab === "monitor" && (
|
||||
<MonitorTab onGoToTranslate={() => setTab("translate")} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AutoFeaturesCard() {
|
||||
const t = useTranslations("translator");
|
||||
const [showFeatures, setShowFeatures] = useState(false);
|
||||
|
||||
return (
|
||||
<Card className="border-primary/10 bg-primary/5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowFeatures((prev) => !prev)}
|
||||
aria-expanded={showFeatures}
|
||||
aria-controls="auto-features-grid"
|
||||
className="flex w-full items-center justify-between p-4 text-left"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[20px] text-primary">
|
||||
auto_fix_high
|
||||
</span>
|
||||
<h3 className="text-sm font-semibold text-text-main">{t("autoFeaturesTitle")}</h3>
|
||||
<Badge variant="primary" size="sm">
|
||||
{t("autoFeaturesCount")}
|
||||
</Badge>
|
||||
</div>
|
||||
<span className="material-symbols-outlined text-[18px] text-text-muted">
|
||||
{showFeatures ? "expand_less" : "expand_more"}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{showFeatures && (
|
||||
<div
|
||||
id="auto-features-grid"
|
||||
className="grid grid-cols-1 gap-3 px-4 pb-4 sm:grid-cols-2 lg:grid-cols-4"
|
||||
data-testid="auto-features-grid"
|
||||
>
|
||||
<FeatureChip
|
||||
icon="psychology"
|
||||
title={t("featureReasoningCache")}
|
||||
description={t("featureReasoningCacheDesc")}
|
||||
color="purple"
|
||||
/>
|
||||
<FeatureChip
|
||||
icon="schema"
|
||||
title={t("featureSchemaCoercion")}
|
||||
description={t("featureSchemaCoercionDesc")}
|
||||
color="blue"
|
||||
/>
|
||||
<FeatureChip
|
||||
icon="swap_vert"
|
||||
title={t("featureRoleNormalization")}
|
||||
description={t("featureRoleNormalizationDesc")}
|
||||
color="amber"
|
||||
/>
|
||||
<FeatureChip
|
||||
icon="fingerprint"
|
||||
title={t("featureToolCallIds")}
|
||||
description={t("featureToolCallIdsDesc")}
|
||||
color="emerald"
|
||||
/>
|
||||
<FeatureChip
|
||||
icon="add_circle"
|
||||
title={t("featureMissingToolResponse")}
|
||||
description={t("featureMissingToolResponseDesc")}
|
||||
color="cyan"
|
||||
/>
|
||||
<FeatureChip
|
||||
icon="tune"
|
||||
title={t("featureThinkingBudget")}
|
||||
description={t("featureThinkingBudgetDesc")}
|
||||
color="orange"
|
||||
/>
|
||||
<FeatureChip
|
||||
icon="alt_route"
|
||||
title={t("featureDirectPaths")}
|
||||
description={t("featureDirectPathsDesc")}
|
||||
color="pink"
|
||||
/>
|
||||
<FeatureChip
|
||||
icon="photo_size_select_large"
|
||||
title={t("featureImageMapping")}
|
||||
description={t("featureImageMappingDesc")}
|
||||
color="indigo"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function FeatureChip({
|
||||
icon,
|
||||
title,
|
||||
@@ -213,7 +321,7 @@ function FeatureChip({
|
||||
}[color];
|
||||
|
||||
return (
|
||||
<div className={`rounded-lg border p-3 ${colorMap.shell}`}>
|
||||
<div className={`rounded-lg border p-3 ${colorMap.shell}`} data-testid="feature-chip">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<span className={`material-symbols-outlined text-[16px] ${colorMap.icon}`}>{icon}</span>
|
||||
<p className="text-xs font-semibold text-text-main">{title}</p>
|
||||
|
||||
@@ -1,543 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Card, Button, Select, Badge } from "@/shared/components";
|
||||
import { FORMAT_META, FORMAT_OPTIONS } from "../exampleTemplates";
|
||||
import { useProviderOptions } from "../hooks/useProviderOptions";
|
||||
import { useAvailableModels } from "../hooks/useAvailableModels";
|
||||
import Editor from "@/shared/components/MonacoEditor";
|
||||
|
||||
/**
|
||||
* Chat Tester Mode:
|
||||
* - Left: Chat interface (send messages as a specific client format)
|
||||
* - Right: {t("pipelineVisualization")} showing each translation step
|
||||
*
|
||||
* How it works:
|
||||
* 1. You type a message and select a "Client Format" (how the request is structured)
|
||||
* 2. The message is built into a request body matching the client format
|
||||
* 3. OmniRoute detects the format, translates it through the pipeline, and sends to the provider
|
||||
* 4. Each pipeline step is shown on the right: Client → Detect → OpenAI → Provider → Response
|
||||
*/
|
||||
|
||||
export default function ChatTesterMode() {
|
||||
const t = useTranslations("translator");
|
||||
const { provider, setProvider, providerOptions } = useProviderOptions("openai");
|
||||
const { model, setModel, availableModels, pickModelForFormat } = useAvailableModels();
|
||||
const [clientFormat, setClientFormat] = useState("openai");
|
||||
const [message, setMessage] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [chatHistory, setChatHistory] = useState([]);
|
||||
const [pipeline, setPipeline] = useState(null);
|
||||
const [expandedStep, setExpandedStep] = useState(null);
|
||||
const messagesEndRef = useRef(null);
|
||||
|
||||
// Pick a smart default model when format changes or models finish loading
|
||||
useEffect(() => {
|
||||
const picked = pickModelForFormat(clientFormat);
|
||||
if (picked) setModel(picked);
|
||||
}, [clientFormat, pickModelForFormat, setModel]);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
};
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!message.trim() || sending) return;
|
||||
|
||||
const userMessage = message.trim();
|
||||
setMessage("");
|
||||
setSending(true);
|
||||
setChatHistory((prev) => [...prev, { role: "user", content: userMessage }]);
|
||||
|
||||
const steps = [];
|
||||
|
||||
try {
|
||||
// Build the messages array
|
||||
const allMessages = [
|
||||
...chatHistory.map((m) => ({ role: m.role, content: m.content })),
|
||||
{ role: "user", content: userMessage },
|
||||
];
|
||||
|
||||
// Step 1: Build client request in the chosen format
|
||||
let clientRequest;
|
||||
if (clientFormat === "claude") {
|
||||
clientRequest = {
|
||||
model,
|
||||
max_tokens: 1024,
|
||||
messages: allMessages,
|
||||
stream: true,
|
||||
};
|
||||
} else if (clientFormat === "gemini") {
|
||||
clientRequest = {
|
||||
model,
|
||||
contents: allMessages.map((m) => ({
|
||||
role: m.role === "assistant" ? "model" : "user",
|
||||
parts: [{ text: m.content }],
|
||||
})),
|
||||
};
|
||||
} else if (clientFormat === "antigravity") {
|
||||
clientRequest = {
|
||||
request: {
|
||||
contents: allMessages.map((m) => ({
|
||||
role: m.role === "assistant" ? "model" : "user",
|
||||
parts: [{ text: m.content }],
|
||||
})),
|
||||
},
|
||||
model,
|
||||
userAgent: "antigravity",
|
||||
};
|
||||
} else if (clientFormat === "openai-responses") {
|
||||
clientRequest = {
|
||||
model,
|
||||
input: allMessages.map((m) => ({
|
||||
type: "message",
|
||||
role: m.role,
|
||||
content: [{ type: "input_text", text: m.content }],
|
||||
})),
|
||||
stream: true,
|
||||
};
|
||||
} else if (clientFormat === "cursor" || clientFormat === "kiro") {
|
||||
clientRequest = {
|
||||
model,
|
||||
messages: allMessages,
|
||||
stream: true,
|
||||
};
|
||||
} else {
|
||||
clientRequest = {
|
||||
model,
|
||||
messages: allMessages,
|
||||
stream: true,
|
||||
};
|
||||
}
|
||||
|
||||
steps.push({
|
||||
id: 1,
|
||||
name: t("clientRequest"),
|
||||
description: t("clientRequestDescription"),
|
||||
format: clientFormat,
|
||||
content: JSON.stringify(clientRequest, null, 2),
|
||||
status: "done",
|
||||
});
|
||||
|
||||
// Step 2: Detect source format
|
||||
const detectRes = await fetch("/api/translator/detect", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ body: clientRequest }),
|
||||
});
|
||||
const detectData = await detectRes.json();
|
||||
const detectedFormat = detectData.format || clientFormat;
|
||||
|
||||
steps.push({
|
||||
id: 2,
|
||||
name: t("formatDetected"),
|
||||
description: t("formatDetectedDescription"),
|
||||
format: detectedFormat,
|
||||
content: JSON.stringify(
|
||||
{ detectedFormat, clientFormat, match: detectedFormat === clientFormat },
|
||||
null,
|
||||
2
|
||||
),
|
||||
status: "done",
|
||||
});
|
||||
|
||||
// Step 3: Translate to OpenAI intermediate
|
||||
const toOpenaiRes = await fetch("/api/translator/translate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
step: "direct",
|
||||
sourceFormat: detectedFormat,
|
||||
targetFormat: "openai",
|
||||
body: clientRequest,
|
||||
}),
|
||||
});
|
||||
const toOpenaiData = await toOpenaiRes.json();
|
||||
|
||||
steps.push({
|
||||
id: 3,
|
||||
name: t("openaiIntermediate"),
|
||||
description: t("openaiIntermediateDescription"),
|
||||
format: "openai",
|
||||
content: JSON.stringify(toOpenaiData.result || toOpenaiData, null, 2),
|
||||
status: toOpenaiData.success ? "done" : "error",
|
||||
});
|
||||
|
||||
// Step 4: Translate to provider target format
|
||||
const providerTargetRes = await fetch("/api/translator/translate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
step: "direct",
|
||||
sourceFormat: "openai",
|
||||
provider,
|
||||
body: toOpenaiData.result,
|
||||
}),
|
||||
});
|
||||
const providerTargetData = await providerTargetRes.json();
|
||||
const targetFmt = providerTargetData.targetFormat || "openai";
|
||||
|
||||
steps.push({
|
||||
id: 4,
|
||||
name: t("providerFormat"),
|
||||
description: t("providerFormatDescription"),
|
||||
format: targetFmt,
|
||||
content: JSON.stringify(providerTargetData.result || providerTargetData, null, 2),
|
||||
status: providerTargetData.success ? "done" : "error",
|
||||
});
|
||||
|
||||
// Step 5: Send to provider
|
||||
const sendRes = await fetch("/api/translator/send", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ provider, body: providerTargetData.result || toOpenaiData.result }),
|
||||
});
|
||||
|
||||
if (!sendRes.ok) {
|
||||
const errData = await sendRes.json().catch(() => ({ error: t("requestFailed") }));
|
||||
steps.push({
|
||||
id: 5,
|
||||
name: t("providerResponse"),
|
||||
description: t("providerResponseRawDescription"),
|
||||
format: targetFmt,
|
||||
content: JSON.stringify(errData, null, 2),
|
||||
status: "error",
|
||||
});
|
||||
setChatHistory((prev) => [
|
||||
...prev,
|
||||
{
|
||||
role: "assistant",
|
||||
content: t("errorMessage", { message: errData.error || t("requestFailed") }),
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
// Read streaming response
|
||||
const reader = sendRes.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let fullResponse = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
fullResponse += decoder.decode(value, { stream: true });
|
||||
}
|
||||
|
||||
steps.push({
|
||||
id: 5,
|
||||
name: t("providerResponse"),
|
||||
description: t("providerResponseSseDescription"),
|
||||
format: targetFmt,
|
||||
content:
|
||||
fullResponse.slice(0, 5000) + (fullResponse.length > 5000 ? "\n... (truncated)" : ""),
|
||||
status: "done",
|
||||
});
|
||||
|
||||
// Extract assistant text from SSE
|
||||
const assistantText = extractAssistantText(fullResponse);
|
||||
setChatHistory((prev) => [
|
||||
...prev,
|
||||
{ role: "assistant", content: assistantText || t("noTextExtracted") },
|
||||
]);
|
||||
}
|
||||
} catch (err) {
|
||||
steps.push({
|
||||
id: steps.length + 1,
|
||||
name: t("error"),
|
||||
description: t("unexpectedError"),
|
||||
format: "error",
|
||||
content: JSON.stringify({ error: err.message }, null, 2),
|
||||
status: "error",
|
||||
});
|
||||
setChatHistory((prev) => [
|
||||
...prev,
|
||||
{ role: "assistant", content: t("errorMessage", { message: err.message }) },
|
||||
]);
|
||||
}
|
||||
|
||||
setPipeline(steps);
|
||||
setExpandedStep(steps.length > 0 ? steps[steps.length - 1].id : null);
|
||||
setSending(false);
|
||||
setTimeout(scrollToBottom, 100);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 min-w-0">
|
||||
{/* Info Banner */}
|
||||
<div className="flex items-start gap-3 px-4 py-3 rounded-lg bg-primary/5 border border-primary/10 text-sm text-text-muted">
|
||||
<span className="material-symbols-outlined text-primary text-[20px] mt-0.5 shrink-0">
|
||||
info
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-medium text-text-main mb-0.5">{t("pipelineDebugger")}</p>
|
||||
<p>{t("chatTesterDescription")}</p>
|
||||
<p>
|
||||
<strong className="text-text-main">{t("chatTesterFlow")}</strong>.{" "}
|
||||
{t("clickStepToInspect")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 min-w-0">
|
||||
{/* Left: Chat Interface */}
|
||||
<div className="space-y-4">
|
||||
{/* Controls */}
|
||||
<Card>
|
||||
<div className="p-4 flex flex-col gap-3">
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs font-medium text-text-muted mb-1 uppercase tracking-wider">
|
||||
{t("clientFormat")}
|
||||
</label>
|
||||
<Select
|
||||
value={clientFormat}
|
||||
onChange={(e) => setClientFormat(e.target.value)}
|
||||
options={FORMAT_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs font-medium text-text-muted mb-1 uppercase tracking-wider">
|
||||
{t("provider")}
|
||||
</label>
|
||||
<Select
|
||||
value={provider}
|
||||
onChange={(e) => setProvider(e.target.value)}
|
||||
options={providerOptions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-muted mb-1 uppercase tracking-wider">
|
||||
{t("model")}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
list="model-suggestions"
|
||||
placeholder={t("modelPlaceholder")}
|
||||
className="w-full bg-bg-subtle border border-border rounded-lg px-3 py-2 text-sm text-text-main placeholder:text-text-muted focus:outline-none focus:border-primary transition-colors"
|
||||
/>
|
||||
<datalist id="model-suggestions">
|
||||
{availableModels.map((m) => (
|
||||
<option key={m} value={m} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Chat Messages */}
|
||||
<Card className="min-h-[400px] flex flex-col">
|
||||
<div className="p-4 flex-1 overflow-y-auto max-h-[500px] space-y-3">
|
||||
{chatHistory.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center h-full text-text-muted py-12">
|
||||
<span className="material-symbols-outlined text-[48px] mb-3 opacity-30">
|
||||
chat
|
||||
</span>
|
||||
<p className="text-sm font-medium mb-1">{t("sendMessageToSeePipeline")}</p>
|
||||
<p className="text-xs text-center max-w-xs">
|
||||
{t("chatMessageHintPrefix")} <strong>{FORMAT_META[clientFormat]?.label}</strong>{" "}
|
||||
{t("chatMessageHintSuffix")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{chatHistory.map((msg, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-[85%] rounded-lg px-3 py-2 text-sm ${
|
||||
msg.role === "user"
|
||||
? "bg-primary/10 text-text-main border border-primary/20"
|
||||
: "bg-bg-subtle text-text-main border border-border"
|
||||
}`}
|
||||
>
|
||||
<p className="text-[10px] font-semibold text-text-muted mb-1 uppercase">
|
||||
{msg.role === "user"
|
||||
? t("youWithFormat", { format: FORMAT_META[clientFormat]?.label })
|
||||
: t("assistant")}
|
||||
</p>
|
||||
<p className="whitespace-pre-wrap break-words">{msg.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="p-3 border-t border-border">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && !e.shiftKey && handleSend()}
|
||||
placeholder={t("typeMessage")}
|
||||
className="flex-1 bg-bg-subtle border border-border rounded-lg px-3 py-2 text-sm text-text-main placeholder:text-text-muted focus:outline-none focus:border-primary transition-colors"
|
||||
disabled={sending}
|
||||
/>
|
||||
<Button
|
||||
icon="send"
|
||||
onClick={handleSend}
|
||||
loading={sending}
|
||||
disabled={!message.trim() || sending}
|
||||
>
|
||||
{t("send")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Right: Pipeline Visualization */}
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<div className="p-4 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[18px] text-primary">
|
||||
account_tree
|
||||
</span>
|
||||
<h3 className="text-sm font-semibold text-text-main">{t("translationPipeline")}</h3>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted">{t("clickStepToInspect")}</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{!pipeline ? (
|
||||
<Card>
|
||||
<div className="p-8 flex flex-col items-center justify-center text-text-muted">
|
||||
<span className="material-symbols-outlined text-[48px] mb-3 opacity-30">
|
||||
account_tree
|
||||
</span>
|
||||
<p className="text-sm font-medium mb-1">{t("pipelineVisualization")}</p>
|
||||
<p className="text-xs text-center max-w-xs">{t("pipelineVisualizationHint")}</p>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{pipeline.map((step, i) => {
|
||||
const meta = FORMAT_META[step.format] || {
|
||||
label: step.format,
|
||||
color: "gray",
|
||||
icon: "code",
|
||||
};
|
||||
const isExpanded = expandedStep === step.id;
|
||||
|
||||
return (
|
||||
<div key={step.id}>
|
||||
{/* Connector line */}
|
||||
{i > 0 && (
|
||||
<div className="flex justify-center py-1">
|
||||
<div className="w-px h-4 bg-border" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card
|
||||
className={
|
||||
step.status === "error"
|
||||
? "border-red-500/30"
|
||||
: isExpanded
|
||||
? "border-primary/30"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<button
|
||||
onClick={() => setExpandedStep(isExpanded ? null : step.id)}
|
||||
className="w-full p-3 flex items-center gap-3 text-left"
|
||||
>
|
||||
{/* Step number */}
|
||||
<div
|
||||
className={`flex items-center justify-center w-7 h-7 rounded-full text-xs font-bold ${
|
||||
step.status === "error"
|
||||
? "bg-red-500/10 text-red-500"
|
||||
: step.status === "done"
|
||||
? `bg-${meta.color}-500/10 text-${meta.color}-500`
|
||||
: "bg-bg-subtle text-text-muted"
|
||||
}`}
|
||||
>
|
||||
{step.status === "error" ? "!" : step.id}
|
||||
</div>
|
||||
|
||||
{/* Step info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-text-main">{step.name}</p>
|
||||
{step.description && (
|
||||
<p className="text-[10px] text-text-muted truncate">
|
||||
{step.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Format badge */}
|
||||
<Badge variant={step.status === "error" ? "error" : "default"} size="sm">
|
||||
{meta.label}
|
||||
</Badge>
|
||||
|
||||
{/* Expand icon */}
|
||||
<span className="material-symbols-outlined text-[18px] text-text-muted">
|
||||
{isExpanded ? "expand_less" : "expand_more"}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Expanded content */}
|
||||
{isExpanded && (
|
||||
<div className="px-3 pb-3">
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<Editor
|
||||
height="250px"
|
||||
defaultLanguage="json"
|
||||
value={step.content}
|
||||
theme="vs-dark"
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 11,
|
||||
lineNumbers: "on",
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: "on",
|
||||
automaticLayout: true,
|
||||
readOnly: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Extract assistant text from SSE stream */
|
||||
function extractAssistantText(sseText) {
|
||||
let text = "";
|
||||
const lines = sseText.split("\n");
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith("data: ")) continue;
|
||||
const payload = line.slice(6).trim();
|
||||
if (payload === "[DONE]") break;
|
||||
try {
|
||||
const parsed = JSON.parse(payload);
|
||||
// OpenAI format
|
||||
const delta = parsed.choices?.[0]?.delta;
|
||||
if (delta?.content) text += delta.content;
|
||||
// Claude format
|
||||
if (parsed.type === "content_block_delta" && parsed.delta?.text) {
|
||||
text += parsed.delta.text;
|
||||
}
|
||||
} catch {
|
||||
/* not JSON, skip */
|
||||
}
|
||||
}
|
||||
return text || sseText.slice(0, 500);
|
||||
}
|
||||
@@ -1,19 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { Card, Badge } from "@/shared/components";
|
||||
import { Card, Badge, EmptyState } from "@/shared/components";
|
||||
import { FORMAT_META } from "../exampleTemplates";
|
||||
|
||||
interface MonitorTabProps {
|
||||
// F9 passes callback for empty state CTA.
|
||||
onGoToTranslate?: () => void;
|
||||
}
|
||||
|
||||
interface TranslationEvent {
|
||||
id?: string;
|
||||
timestamp?: string | number;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
sourceFormat?: string;
|
||||
targetFormat?: string;
|
||||
status?: string;
|
||||
statusCode?: number | string;
|
||||
latency?: number;
|
||||
endpoint?: string;
|
||||
isComboRouted?: boolean;
|
||||
routeEndpoint?: string;
|
||||
routeProvider?: string;
|
||||
routeCombo?: string;
|
||||
routeConnectionShortId?: string;
|
||||
}
|
||||
|
||||
interface StatCardProps {
|
||||
icon: string;
|
||||
label: string;
|
||||
value: string | number;
|
||||
color: "blue" | "green" | "red" | "purple" | "amber" | "cyan";
|
||||
}
|
||||
|
||||
const COLOR_MAP: Record<
|
||||
StatCardProps["color"],
|
||||
{ shell: string; icon: string }
|
||||
> = {
|
||||
blue: { shell: "bg-blue-500/10", icon: "text-blue-500" },
|
||||
green: { shell: "bg-green-500/10", icon: "text-green-500" },
|
||||
red: { shell: "bg-red-500/10", icon: "text-red-500" },
|
||||
purple: { shell: "bg-purple-500/10", icon: "text-purple-500" },
|
||||
amber: { shell: "bg-amber-500/10", icon: "text-amber-500" },
|
||||
cyan: { shell: "bg-cyan-500/10", icon: "text-cyan-500" },
|
||||
};
|
||||
|
||||
function StatCard({ icon, label, value, color }: StatCardProps) {
|
||||
const resolved = COLOR_MAP[color] ?? COLOR_MAP.blue;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="p-4 flex items-center gap-3">
|
||||
<div className={`flex items-center justify-center w-10 h-10 rounded-lg ${resolved.shell}`}>
|
||||
<span
|
||||
className={`material-symbols-outlined text-[22px] ${resolved.icon}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-bold text-text-main">{value}</p>
|
||||
<p className="text-[10px] text-text-muted uppercase tracking-wider">{label}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Live Monitor Mode:
|
||||
* Shows recent translation activity from the proxy in real-time.
|
||||
* Polls /api/translator/history for translation events.
|
||||
* MonitorTab
|
||||
*
|
||||
* Refactor of LiveMonitorMode with 100% functional parity + additions:
|
||||
* - monitorOriginHint header always visible (explains event origin)
|
||||
* - empty state CTA with "Ir para Translate" button (onGoToTranslate)
|
||||
* - preserves 3s polling, auto-refresh toggle, 6 stat cards, events table
|
||||
* - cleanup useEffect: clearInterval on unmount
|
||||
*/
|
||||
export default function LiveMonitorMode() {
|
||||
export default function MonitorTab({ onGoToTranslate }: MonitorTabProps) {
|
||||
const t = useTranslations("translator");
|
||||
const tc = useTranslations("common");
|
||||
|
||||
const translateOrFallback = useCallback(
|
||||
(key: string, fallback: string, values?: Record<string, unknown>) => {
|
||||
try {
|
||||
@@ -23,72 +92,80 @@ export default function LiveMonitorMode() {
|
||||
return fallback;
|
||||
}
|
||||
},
|
||||
[t]
|
||||
[t],
|
||||
);
|
||||
const [events, setEvents] = useState([]);
|
||||
|
||||
const [events, setEvents] = useState<TranslationEvent[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
const intervalRef = useRef(null);
|
||||
const notAvailable = t("notAvailableSymbol");
|
||||
const formatLatency = (value) => t("millisecondsShort", { value });
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const fetchHistory = async () => {
|
||||
const notAvailable = t("notAvailableSymbol");
|
||||
const formatLatency = (value: number) => t("millisecondsShort", { value });
|
||||
|
||||
const fetchHistory = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/translator/history?limit=50");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setEvents(data.events || []);
|
||||
const data = (await res.json()) as { events?: TranslationEvent[] };
|
||||
setEvents(data.events ?? []);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
// ignore fetch errors in polling context — do not leak stack traces
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchHistory();
|
||||
void fetchHistory();
|
||||
if (autoRefresh) {
|
||||
intervalRef.current = setInterval(fetchHistory, 3000);
|
||||
intervalRef.current = setInterval(() => {
|
||||
void fetchHistory();
|
||||
}, 3000);
|
||||
}
|
||||
return () => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [autoRefresh]);
|
||||
}, [autoRefresh, fetchHistory]);
|
||||
|
||||
// Stats
|
||||
// Computed stats
|
||||
const successCount = events.filter((e) => e.status === "success").length;
|
||||
const errorCount = events.filter((e) => e.status === "error").length;
|
||||
const comboCount = events.filter((e) => e.isComboRouted).length;
|
||||
const uniqueEndpoints = new Set(events.map((e) => e.routeEndpoint || e.endpoint).filter(Boolean))
|
||||
.size;
|
||||
const uniqueEndpoints = new Set(
|
||||
events.map((e) => e.routeEndpoint ?? e.endpoint).filter(Boolean),
|
||||
).size;
|
||||
const avgLatency =
|
||||
events.length > 0
|
||||
? Math.round(events.reduce((sum, e) => sum + (e.latency || 0), 0) / events.length)
|
||||
? Math.round(events.reduce((sum, e) => sum + (e.latency ?? 0), 0) / events.length)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-5 min-w-0">
|
||||
{/* Info Banner */}
|
||||
<div className="flex items-start gap-3 px-4 py-3 rounded-lg bg-primary/5 border border-primary/10 text-sm text-text-muted">
|
||||
{/* Origin hint — always visible (monitorOriginHint) */}
|
||||
<div
|
||||
className="flex items-start gap-3 px-4 py-3 rounded-lg bg-primary/5 border border-primary/10 text-sm text-text-muted"
|
||||
data-testid="monitor-origin-hint"
|
||||
>
|
||||
<span
|
||||
className="material-symbols-outlined text-primary text-[20px] mt-0.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
info
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-medium text-text-main mb-0.5">{t("realtime")}</p>
|
||||
<p>
|
||||
{t("liveMonitorDescriptionPrefix")}{" "}
|
||||
<strong className="text-text-main">{t("chatTester")}</strong>,{" "}
|
||||
<strong className="text-text-main">{t("testBench")}</strong>
|
||||
{t("liveMonitorDescriptionSuffix")}
|
||||
</p>
|
||||
</div>
|
||||
<p>
|
||||
{translateOrFallback(
|
||||
"monitorOriginHint",
|
||||
"Eventos gerados pelo Translate ou pelo pipeline principal aparecem aqui em tempo real.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
{/* Stat Cards — 6 cards: total, success, errors, avg latency, combo-routed, unique endpoints */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-3">
|
||||
<StatCard
|
||||
icon="translate"
|
||||
@@ -118,6 +195,7 @@ export default function LiveMonitorMode() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Memory note */}
|
||||
<div className="flex items-center gap-2 rounded-lg border border-amber-500/10 bg-amber-500/5 px-3 py-2 text-xs text-amber-600 dark:text-amber-400">
|
||||
<span className="material-symbols-outlined text-[14px]">memory</span>
|
||||
<p>
|
||||
@@ -126,7 +204,7 @@ export default function LiveMonitorMode() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
{/* Auto-refresh controls */}
|
||||
<Card>
|
||||
<div className="p-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -137,25 +215,44 @@ export default function LiveMonitorMode() {
|
||||
{autoRefresh ? "radio_button_checked" : "radio_button_unchecked"}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setAutoRefresh(!autoRefresh)}
|
||||
type="button"
|
||||
onClick={() => setAutoRefresh((prev) => !prev)}
|
||||
className="text-sm text-text-main hover:text-primary transition-colors"
|
||||
aria-label={
|
||||
autoRefresh
|
||||
? translateOrFallback("pauseAutoRefresh", "Pause auto-refresh")
|
||||
: translateOrFallback("resumeAutoRefresh", "Resume auto-refresh")
|
||||
}
|
||||
data-testid="auto-refresh-toggle"
|
||||
>
|
||||
{autoRefresh ? t("liveAutoRefreshing") : t("paused")}
|
||||
{autoRefresh
|
||||
? translateOrFallback("liveAutoRefreshing", "Atualizando ao vivo")
|
||||
: translateOrFallback("paused", "Pausado")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Live/Paused badge */}
|
||||
<Badge variant={autoRefresh ? "success" : "default"} size="sm" dot>
|
||||
{autoRefresh
|
||||
? translateOrFallback("live", "Live")
|
||||
: translateOrFallback("paused", "Paused")}
|
||||
</Badge>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void fetchHistory()}
|
||||
className="flex items-center gap-1 text-xs text-text-muted hover:text-primary transition-colors"
|
||||
aria-label={tc("refresh")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">
|
||||
refresh
|
||||
</span>
|
||||
{tc("refresh")}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchHistory}
|
||||
className="flex items-center gap-1 text-xs text-text-muted hover:text-primary transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">
|
||||
refresh
|
||||
</span>
|
||||
{tc("refresh")}
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Events Table */}
|
||||
{/* Events table */}
|
||||
<Card>
|
||||
<div className="p-4">
|
||||
<h3 className="text-sm font-semibold text-text-main mb-3">{t("recentTranslations")}</h3>
|
||||
@@ -168,47 +265,28 @@ export default function LiveMonitorMode() {
|
||||
{tc("loading")}
|
||||
</div>
|
||||
) : events.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-text-muted">
|
||||
<span
|
||||
className="material-symbols-outlined text-[48px] mb-3 opacity-30"
|
||||
aria-hidden="true"
|
||||
>
|
||||
monitoring
|
||||
</span>
|
||||
<p className="text-sm font-medium mb-1">{t("noTranslations")}</p>
|
||||
<p className="text-xs text-center max-w-sm">{t("eventsAppearHint")}</p>
|
||||
<div className="mt-3 rounded-lg border border-border/40 bg-bg-subtle/50 px-4 py-3 text-left">
|
||||
<p className="text-[10px] font-semibold text-text-muted">
|
||||
{t("eventSourcesLabel")}
|
||||
</p>
|
||||
<ul className="mt-1 space-y-1 text-[10px] text-text-muted">
|
||||
<li>{t("eventSourceTranslatorPage")}</li>
|
||||
<li>{t("eventSourceMainPipeline")}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mt-3 text-xs">
|
||||
<span className="px-2 py-1 rounded-md bg-bg-subtle border border-border">
|
||||
{t("chatTesterTab")}
|
||||
</span>
|
||||
<span className="px-2 py-1 rounded-md bg-bg-subtle border border-border">
|
||||
{t("testBenchTab")}
|
||||
</span>
|
||||
<span className="px-2 py-1 rounded-md bg-bg-subtle border border-border">
|
||||
{t("externalApiCalls")}
|
||||
</span>
|
||||
<span className="px-2 py-1 rounded-md bg-bg-subtle border border-border">
|
||||
{t("ideCliIntegrations")}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[10px] mt-3 text-text-muted/70">{t("inMemoryNote")}</p>
|
||||
/* Empty state with CTA (new in MonitorTab) */
|
||||
<div data-testid="monitor-empty-state">
|
||||
<EmptyState
|
||||
icon="📊"
|
||||
title={translateOrFallback("noTranslations", "Nenhuma tradução ainda")}
|
||||
description={translateOrFallback(
|
||||
"monitorEmptyCta",
|
||||
"Volte para a aba Translate e envie um request — ele aparecerá aqui.",
|
||||
)}
|
||||
actionLabel={translateOrFallback("monitorOpenTranslateButton", "Ir para Translate")}
|
||||
onAction={onGoToTranslate ?? null}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<div className="overflow-x-auto" data-testid="monitor-events-table">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-text-muted border-b border-border">
|
||||
<th className="pb-2 pr-4">{t("time")}</th>
|
||||
<th className="pb-2 pr-4">{translateOrFallback("routeDetails", "Route")}</th>
|
||||
<th className="pb-2 pr-4">
|
||||
{translateOrFallback("routeDetails", "Route")}
|
||||
</th>
|
||||
<th className="pb-2 pr-4">{t("source")}</th>
|
||||
<th className="pb-2 pr-4">{t("target")}</th>
|
||||
<th className="pb-2 pr-4">{t("model")}</th>
|
||||
@@ -218,19 +296,20 @@ export default function LiveMonitorMode() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{events.map((event, i) => {
|
||||
const srcMeta = FORMAT_META[event.sourceFormat] || {
|
||||
label: event.sourceFormat || "?",
|
||||
const srcMeta = FORMAT_META[event.sourceFormat as keyof typeof FORMAT_META] ?? {
|
||||
label: event.sourceFormat ?? "?",
|
||||
color: "gray",
|
||||
};
|
||||
const tgtMeta = FORMAT_META[event.targetFormat] || {
|
||||
label: event.targetFormat || "?",
|
||||
const tgtMeta = FORMAT_META[event.targetFormat as keyof typeof FORMAT_META] ?? {
|
||||
label: event.targetFormat ?? "?",
|
||||
color: "gray",
|
||||
};
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={event.id || i}
|
||||
key={event.id ?? i}
|
||||
className="border-b border-border/50 hover:bg-bg-subtle/50 transition-colors"
|
||||
data-testid="monitor-event-row"
|
||||
>
|
||||
<td className="py-2 pr-4 text-xs text-text-muted whitespace-nowrap">
|
||||
{event.timestamp
|
||||
@@ -241,7 +320,7 @@ export default function LiveMonitorMode() {
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Badge variant="default" size="sm">
|
||||
{event.routeProvider || event.provider || notAvailable}
|
||||
{event.routeProvider ?? event.provider ?? notAvailable}
|
||||
</Badge>
|
||||
{event.routeCombo ? (
|
||||
<Badge variant="primary" size="sm">
|
||||
@@ -252,7 +331,7 @@ export default function LiveMonitorMode() {
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-text-muted">
|
||||
<span>
|
||||
{translateOrFallback("routeEndpointLabel", "Endpoint")}:{" "}
|
||||
{event.routeEndpoint || event.endpoint || notAvailable}
|
||||
{event.routeEndpoint ?? event.endpoint ?? notAvailable}
|
||||
</span>
|
||||
{event.routeConnectionShortId ? (
|
||||
<span>
|
||||
@@ -274,7 +353,7 @@ export default function LiveMonitorMode() {
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-xs font-mono text-text-muted break-all">
|
||||
{event.model || notAvailable}
|
||||
{event.model ?? notAvailable}
|
||||
</td>
|
||||
<td className="py-2 pr-4">
|
||||
{event.status === "success" ? (
|
||||
@@ -283,7 +362,7 @@ export default function LiveMonitorMode() {
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="error" size="sm" dot>
|
||||
{event.statusCode || t("errorShort")}
|
||||
{event.statusCode ?? t("errorShort")}
|
||||
</Badge>
|
||||
)}
|
||||
</td>
|
||||
@@ -302,34 +381,3 @@ export default function LiveMonitorMode() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ icon, label, value, color }) {
|
||||
const colorMap = {
|
||||
blue: { shell: "bg-blue-500/10", icon: "text-blue-500" },
|
||||
green: { shell: "bg-green-500/10", icon: "text-green-500" },
|
||||
red: { shell: "bg-red-500/10", icon: "text-red-500" },
|
||||
purple: { shell: "bg-purple-500/10", icon: "text-purple-500" },
|
||||
amber: { shell: "bg-amber-500/10", icon: "text-amber-500" },
|
||||
cyan: { shell: "bg-cyan-500/10", icon: "text-cyan-500" },
|
||||
};
|
||||
const resolved = colorMap[color as keyof typeof colorMap] || colorMap.blue;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="p-4 flex items-center gap-3">
|
||||
<div className={`flex items-center justify-center w-10 h-10 rounded-lg ${resolved.shell}`}>
|
||||
<span
|
||||
className={`material-symbols-outlined text-[22px] ${resolved.icon}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-bold text-text-main">{value}</p>
|
||||
<p className="text-[10px] text-text-muted uppercase tracking-wider">{label}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,587 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { useState, useCallback, useEffect, useMemo } from "react";
|
||||
import { Card, Button, Select, Badge } from "@/shared/components";
|
||||
import { getExampleTemplates, FORMAT_META, FORMAT_OPTIONS } from "../exampleTemplates";
|
||||
import Editor from "@/shared/components/MonacoEditor";
|
||||
|
||||
interface CompressionPreviewResult {
|
||||
originalTokens: number;
|
||||
compressedTokens: number;
|
||||
tokensSaved: number;
|
||||
savingsPct: number;
|
||||
techniquesUsed: string[];
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export default function PlaygroundMode() {
|
||||
const t = useTranslations("translator");
|
||||
const tc = useTranslations("common");
|
||||
const [sourceFormat, setSourceFormat] = useState("claude");
|
||||
const [targetFormat, setTargetFormat] = useState("openai");
|
||||
const [inputContent, setInputContent] = useState("");
|
||||
const [outputContent, setOutputContent] = useState("");
|
||||
const [intermediateContent, setIntermediateContent] = useState("");
|
||||
const [translationPath, setTranslationPath] = useState("");
|
||||
const [detectedFormat, setDetectedFormat] = useState(null);
|
||||
const [translating, setTranslating] = useState(false);
|
||||
const [detecting, setDetecting] = useState(false);
|
||||
const [activeTemplate, setActiveTemplate] = useState(null);
|
||||
|
||||
// Compression preview state
|
||||
const [compressionMode, setCompressionMode] = useState<string>("standard");
|
||||
const [compressionResult, setCompressionResult] = useState<CompressionPreviewResult | null>(null);
|
||||
const [compressionLoading, setCompressionLoading] = useState(false);
|
||||
const [compressionError, setCompressionError] = useState<string | null>(null);
|
||||
const [showCompressionPanel, setShowCompressionPanel] = useState(false);
|
||||
|
||||
const templates = useMemo(() => getExampleTemplates(t), [t]);
|
||||
|
||||
// Auto-detect format when input changes
|
||||
const detectFormatFromInput = useCallback(async (content) => {
|
||||
if (!content || content.trim().length < 5) {
|
||||
setDetectedFormat(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
setDetecting(true);
|
||||
const res = await fetch("/api/translator/detect", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ body: parsed }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setDetectedFormat(data.format);
|
||||
setSourceFormat(data.format);
|
||||
}
|
||||
} catch {
|
||||
// Not valid JSON yet, ignore
|
||||
} finally {
|
||||
setDetecting(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Debounced auto-detect
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
detectFormatFromInput(inputContent);
|
||||
}, 600);
|
||||
return () => clearTimeout(timer);
|
||||
}, [inputContent, detectFormatFromInput]);
|
||||
|
||||
const handleTranslate = async () => {
|
||||
if (!inputContent.trim()) return;
|
||||
|
||||
setTranslating(true);
|
||||
setOutputContent("");
|
||||
setIntermediateContent("");
|
||||
setTranslationPath("");
|
||||
try {
|
||||
const parsed = JSON.parse(inputContent);
|
||||
|
||||
if (sourceFormat === targetFormat) {
|
||||
setOutputContent(JSON.stringify(parsed, null, 2));
|
||||
setTranslationPath("passthrough");
|
||||
setTranslating(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let intermediate = parsed;
|
||||
let hasIntermediate = false;
|
||||
|
||||
if (sourceFormat !== "openai" && targetFormat !== "openai") {
|
||||
const step1 = await fetch("/api/translator/translate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
step: "direct",
|
||||
sourceFormat,
|
||||
targetFormat: "openai",
|
||||
body: parsed,
|
||||
}),
|
||||
});
|
||||
const step1Data = await step1.json();
|
||||
if (!step1Data.success) {
|
||||
setOutputContent(JSON.stringify({ error: step1Data.error }, null, 2));
|
||||
return;
|
||||
}
|
||||
intermediate = step1Data.result;
|
||||
setIntermediateContent(JSON.stringify(intermediate, null, 2));
|
||||
hasIntermediate = true;
|
||||
}
|
||||
|
||||
const res = await fetch("/api/translator/translate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
step: "direct",
|
||||
sourceFormat: hasIntermediate ? "openai" : sourceFormat,
|
||||
targetFormat,
|
||||
body: hasIntermediate ? intermediate : parsed,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setOutputContent(JSON.stringify(data.result, null, 2));
|
||||
setTranslationPath(hasIntermediate ? "hub-and-spoke" : "direct");
|
||||
} else {
|
||||
setOutputContent(JSON.stringify({ error: data.error }, null, 2));
|
||||
}
|
||||
} catch (err) {
|
||||
setOutputContent(
|
||||
JSON.stringify({ error: err instanceof Error ? err.message : String(err) }, null, 2)
|
||||
);
|
||||
} finally {
|
||||
setTranslating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadTemplate = (template) => {
|
||||
const formatData = template.formats[sourceFormat] || template.formats.openai;
|
||||
setInputContent(JSON.stringify(formatData, null, 2));
|
||||
setActiveTemplate(template.id);
|
||||
setOutputContent("");
|
||||
setIntermediateContent("");
|
||||
setTranslationPath("");
|
||||
};
|
||||
|
||||
const handleCopy = async (text) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch {
|
||||
/* silent */
|
||||
}
|
||||
};
|
||||
|
||||
const handleSwapFormats = () => {
|
||||
setSourceFormat(targetFormat);
|
||||
setTargetFormat(sourceFormat);
|
||||
setInputContent(outputContent);
|
||||
setOutputContent("");
|
||||
setIntermediateContent("");
|
||||
setTranslationPath("");
|
||||
setDetectedFormat(null);
|
||||
};
|
||||
|
||||
const handleCompressionPreview = async () => {
|
||||
if (!inputContent.trim()) return;
|
||||
let messages;
|
||||
try {
|
||||
const parsed = JSON.parse(inputContent);
|
||||
messages = parsed.messages ?? [{ role: "user", content: inputContent }];
|
||||
} catch {
|
||||
messages = [{ role: "user", content: inputContent }];
|
||||
}
|
||||
setCompressionLoading(true);
|
||||
setCompressionError(null);
|
||||
try {
|
||||
const res = await fetch("/api/compression/preview", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ messages, mode: compressionMode }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error ?? "Preview failed");
|
||||
setCompressionResult(data);
|
||||
} catch (e: unknown) {
|
||||
setCompressionError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setCompressionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const srcMeta = FORMAT_META[sourceFormat] || FORMAT_META.openai;
|
||||
const tgtMeta = FORMAT_META[targetFormat] || FORMAT_META.openai;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Info Banner */}
|
||||
<div className="flex items-start gap-3 px-4 py-3 rounded-lg bg-primary/5 border border-primary/10 text-sm text-text-muted">
|
||||
<span className="material-symbols-outlined text-primary text-[20px] mt-0.5 shrink-0">
|
||||
info
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-medium text-text-main mb-0.5">{t("formatConverter")}</p>
|
||||
<p>{t("formatConverterDescription")}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* Format Controls Bar */}
|
||||
<Card>
|
||||
<div className="p-4 flex flex-col sm:flex-row items-center gap-4">
|
||||
{/* Source Format */}
|
||||
<div className="flex-1 w-full">
|
||||
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
|
||||
{t("source")}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`material-symbols-outlined text-[20px] text-${srcMeta.color}-500`}>
|
||||
{srcMeta.icon}
|
||||
</span>
|
||||
<Select
|
||||
value={sourceFormat}
|
||||
onChange={(e) => {
|
||||
setSourceFormat(e.target.value);
|
||||
setDetectedFormat(null);
|
||||
}}
|
||||
options={FORMAT_OPTIONS}
|
||||
className="flex-1"
|
||||
/>
|
||||
{detectedFormat && (
|
||||
<Badge variant="primary" size="sm" icon="auto_awesome">
|
||||
{t("auto")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Swap Button */}
|
||||
<button
|
||||
onClick={handleSwapFormats}
|
||||
className="p-2 rounded-full hover:bg-primary/10 text-text-muted hover:text-primary transition-all mt-4 sm:mt-5"
|
||||
title={t("swapFormats")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[24px]">swap_horiz</span>
|
||||
</button>
|
||||
|
||||
{/* Target Format */}
|
||||
<div className="flex-1 w-full">
|
||||
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
|
||||
{t("target")}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`material-symbols-outlined text-[20px] text-${tgtMeta.color}-500`}>
|
||||
{tgtMeta.icon}
|
||||
</span>
|
||||
<Select
|
||||
value={targetFormat}
|
||||
onChange={(e) => setTargetFormat(e.target.value)}
|
||||
options={FORMAT_OPTIONS}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Translate Button */}
|
||||
<div className="pt-0 sm:pt-5">
|
||||
<Button
|
||||
icon="arrow_forward"
|
||||
onClick={handleTranslate}
|
||||
loading={translating}
|
||||
disabled={!inputContent.trim() || translating}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{t("translateAction")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{translationPath && (
|
||||
<div className="flex items-center gap-2 text-xs text-text-muted">
|
||||
<span className="material-symbols-outlined text-[14px]">route</span>
|
||||
{translationPath === "hub-and-spoke" ? (
|
||||
<span>
|
||||
{t("translationPathHubSpoke", {
|
||||
source: FORMAT_META[sourceFormat]?.label || sourceFormat,
|
||||
target: FORMAT_META[targetFormat]?.label || targetFormat,
|
||||
})}
|
||||
</span>
|
||||
) : translationPath === "direct" ? (
|
||||
<span>
|
||||
{t("translationPathDirect", {
|
||||
source: FORMAT_META[sourceFormat]?.label || sourceFormat,
|
||||
target: FORMAT_META[targetFormat]?.label || targetFormat,
|
||||
})}
|
||||
</span>
|
||||
) : (
|
||||
<span>{t("translationPathPassthrough")}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Split Editor View */}
|
||||
<div
|
||||
className={`grid grid-cols-1 gap-4 ${
|
||||
intermediateContent ? "xl:grid-cols-3" : "lg:grid-cols-2"
|
||||
}`}
|
||||
>
|
||||
{/* Input Panel */}
|
||||
<Card>
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[18px] text-text-muted">input</span>
|
||||
<h3 className="text-sm font-semibold text-text-main">{t("input")}</h3>
|
||||
{detectedFormat && (
|
||||
<Badge variant="info" size="sm" dot>
|
||||
{FORMAT_META[detectedFormat]?.label || detectedFormat}
|
||||
</Badge>
|
||||
)}
|
||||
{detecting && (
|
||||
<span className="material-symbols-outlined text-[14px] text-text-muted animate-spin">
|
||||
progress_activity
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => handleCopy(inputContent)}
|
||||
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
|
||||
title={tc("copy")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">content_copy</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setInputContent("");
|
||||
setOutputContent("");
|
||||
setDetectedFormat(null);
|
||||
setActiveTemplate(null);
|
||||
}}
|
||||
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
|
||||
title={t("clear")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">delete</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<Editor
|
||||
height="400px"
|
||||
defaultLanguage="json"
|
||||
value={inputContent}
|
||||
onChange={(value) => setInputContent(value || "")}
|
||||
theme="vs-dark"
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 12,
|
||||
lineNumbers: "on",
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: "on",
|
||||
automaticLayout: true,
|
||||
formatOnPaste: true,
|
||||
placeholder: t("inputPlaceholder"),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Intermediate Panel */}
|
||||
{intermediateContent && (
|
||||
<Card>
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[18px] text-amber-500">hub</span>
|
||||
<h3 className="text-sm font-semibold text-text-main">
|
||||
{t("openaiIntermediatePanel")}
|
||||
</h3>
|
||||
<Badge variant="warning" size="sm">
|
||||
Hub
|
||||
</Badge>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleCopy(intermediateContent)}
|
||||
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
|
||||
title={tc("copy")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">content_copy</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<Editor
|
||||
height="400px"
|
||||
defaultLanguage="json"
|
||||
value={intermediateContent}
|
||||
theme="vs-dark"
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 12,
|
||||
lineNumbers: "on",
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: "on",
|
||||
automaticLayout: true,
|
||||
readOnly: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Output Panel */}
|
||||
<Card>
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[18px] text-text-muted">
|
||||
output
|
||||
</span>
|
||||
<h3 className="text-sm font-semibold text-text-main">{t("output")}</h3>
|
||||
{outputContent && (
|
||||
<Badge variant="success" size="sm" dot>
|
||||
{FORMAT_META[targetFormat]?.label || targetFormat}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => handleCopy(outputContent)}
|
||||
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
|
||||
title={tc("copy")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">content_copy</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<Editor
|
||||
height="400px"
|
||||
defaultLanguage="json"
|
||||
value={outputContent}
|
||||
theme="vs-dark"
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 12,
|
||||
lineNumbers: "on",
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: "on",
|
||||
automaticLayout: true,
|
||||
readOnly: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* {t("exampleTemplates")} */}
|
||||
<Card>
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[18px] text-primary">
|
||||
library_books
|
||||
</span>
|
||||
<h3 className="text-sm font-semibold text-text-main">{t("exampleTemplates")}</h3>
|
||||
<span className="text-xs text-text-muted">{t("exampleTemplatesHint")}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-2">
|
||||
{templates.map((template) => (
|
||||
<button
|
||||
key={template.id}
|
||||
onClick={() => loadTemplate(template)}
|
||||
className={`
|
||||
group flex flex-col items-center gap-1.5 p-3 rounded-lg border transition-all text-center
|
||||
${
|
||||
activeTemplate === template.id
|
||||
? "border-primary bg-primary/5 text-primary"
|
||||
: "border-border hover:border-primary/30 hover:bg-primary/5 text-text-muted hover:text-text-main"
|
||||
}
|
||||
`}
|
||||
>
|
||||
<span
|
||||
className={`material-symbols-outlined text-[22px] ${activeTemplate === template.id ? "text-primary" : "text-text-muted group-hover:text-primary"} transition-colors`}
|
||||
>
|
||||
{template.icon}
|
||||
</span>
|
||||
<span className="text-xs font-medium leading-tight">{template.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{activeTemplate && (
|
||||
<div className="flex items-center gap-2 text-xs text-text-muted">
|
||||
<span className="material-symbols-outlined text-[14px]">info</span>
|
||||
{t("templateLoadHint", {
|
||||
format: FORMAT_META[sourceFormat]?.label || sourceFormat,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Compression Preview Panel */}
|
||||
<Card>
|
||||
<button
|
||||
className="flex items-center gap-2 w-full text-left p-4 font-medium text-text"
|
||||
onClick={() => setShowCompressionPanel((v) => !v)}
|
||||
>
|
||||
<span className="material-symbols-outlined text-primary text-[20px]">compress</span>
|
||||
Compression Preview
|
||||
<span className="material-symbols-outlined ml-auto text-text-muted text-[18px]">
|
||||
{showCompressionPanel ? "expand_less" : "expand_more"}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{showCompressionPanel && (
|
||||
<div className="p-4 space-y-4 border-t border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<Select
|
||||
value={compressionMode}
|
||||
onChange={(e) => setCompressionMode(e.target.value)}
|
||||
options={[
|
||||
{ value: "off", label: "Off" },
|
||||
{ value: "lite", label: "Lite" },
|
||||
{ value: "standard", label: "Standard" },
|
||||
{ value: "aggressive", label: "Aggressive" },
|
||||
{ value: "ultra", label: "Ultra" },
|
||||
]}
|
||||
className="text-sm"
|
||||
/>
|
||||
<Button
|
||||
icon="play_arrow"
|
||||
onClick={handleCompressionPreview}
|
||||
loading={compressionLoading}
|
||||
disabled={compressionLoading || !inputContent.trim()}
|
||||
className="text-sm"
|
||||
>
|
||||
{compressionLoading ? "Previewing…" : "Preview Compression"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{compressionError && <div className="text-sm text-red-500">{compressionError}</div>}
|
||||
|
||||
{compressionResult && (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div className="card p-3 text-center bg-black/5 dark:bg-white/5 rounded-lg border border-border">
|
||||
<div className="text-xs text-text-muted">Original</div>
|
||||
<div className="text-lg font-bold">{compressionResult.originalTokens}</div>
|
||||
<div className="text-xs text-text-muted">tokens</div>
|
||||
</div>
|
||||
<div className="card p-3 text-center bg-black/5 dark:bg-white/5 rounded-lg border border-border">
|
||||
<div className="text-xs text-text-muted">Compressed</div>
|
||||
<div className="text-lg font-bold">{compressionResult.compressedTokens}</div>
|
||||
<div className="text-xs text-text-muted">tokens</div>
|
||||
</div>
|
||||
<div className="card p-3 text-center bg-black/5 dark:bg-white/5 rounded-lg border border-border">
|
||||
<div className="text-xs text-text-muted">Saved</div>
|
||||
<div className="text-lg font-bold text-green-500">
|
||||
{compressionResult.tokensSaved}
|
||||
</div>
|
||||
<div className="text-xs text-text-muted">{compressionResult.savingsPct}%</div>
|
||||
</div>
|
||||
<div className="card p-3 text-center bg-black/5 dark:bg-white/5 rounded-lg border border-border">
|
||||
<div className="text-xs text-text-muted">Duration</div>
|
||||
<div className="text-lg font-bold">{compressionResult.durationMs}</div>
|
||||
<div className="text-xs text-text-muted">ms</div>
|
||||
</div>
|
||||
</div>
|
||||
{compressionResult.techniquesUsed.length > 0 && (
|
||||
<div className="text-xs text-text-muted">
|
||||
<span className="font-semibold">{t("techniques")}</span>{" "}
|
||||
{compressionResult.techniquesUsed.join(", ")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Badge, Button, Card } from "@/shared/components";
|
||||
import type { AdvancedSlug, TranslateNarratedResult } from "../types";
|
||||
import { FORMAT_META } from "../exampleTemplates";
|
||||
|
||||
interface ResultNarratedProps {
|
||||
result: TranslateNarratedResult;
|
||||
onSeeTranslatedJson: () => void;
|
||||
onSeePipeline: () => void;
|
||||
}
|
||||
|
||||
// Resolve a display label for a FormatId
|
||||
function formatLabel(id: string | null): string {
|
||||
if (!id) return "—";
|
||||
const meta = (FORMAT_META as Record<string, { label: string }>)[id];
|
||||
return meta?.label ?? id;
|
||||
}
|
||||
|
||||
// Ensure stack traces are never surfaced — safety net on top of hook sanitization
|
||||
function safeErrorMessage(raw: string | null): string {
|
||||
if (!raw) return "Unknown error";
|
||||
return raw
|
||||
.replace(/\sat\s\/[^\s]*/g, "")
|
||||
.replace(/sk-[A-Za-z0-9_-]{16,}/g, "[REDACTED]")
|
||||
.replace(/Bearer\s+[A-Za-z0-9_.-]+/g, "Bearer [REDACTED]");
|
||||
}
|
||||
|
||||
export default function ResultNarrated({
|
||||
result,
|
||||
onSeeTranslatedJson,
|
||||
onSeePipeline,
|
||||
}: ResultNarratedProps) {
|
||||
const t = useTranslations("translator");
|
||||
|
||||
const tr = useCallback(
|
||||
(key: string, fallback: string, params?: Record<string, string | number>): string => {
|
||||
try {
|
||||
const translated = t(key as Parameters<typeof t>[0], params as Parameters<typeof t>[1]);
|
||||
if (translated === key || translated === `translator.${key}`) {
|
||||
// i18n key not found — use fallback with param substitution
|
||||
if (params) {
|
||||
return Object.entries(params).reduce(
|
||||
(acc, [k, v]) => acc.replace(`{${k}}`, String(v)),
|
||||
fallback
|
||||
);
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
return translated;
|
||||
} catch {
|
||||
if (params && fallback) {
|
||||
return Object.entries(params).reduce(
|
||||
(acc, [k, v]) => acc.replace(`{${k}}`, String(v)),
|
||||
fallback
|
||||
);
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
const isSpinning = result.status === "translating" || result.status === "sending";
|
||||
|
||||
return (
|
||||
<Card className="flex flex-col gap-4 p-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[20px] text-primary" aria-hidden="true">
|
||||
translate
|
||||
</span>
|
||||
<h3 className="text-sm font-semibold text-text-main">
|
||||
{tr("simpleResultPanelTitle", "Translation + Response")}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Status area — aria-live for screen-reader announcements (D20) */}
|
||||
<div
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
className="flex flex-1 flex-col gap-3"
|
||||
>
|
||||
{/* idle */}
|
||||
{result.status === "idle" && (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-8 text-center">
|
||||
<span className="material-symbols-outlined text-[40px] text-text-muted/40" aria-hidden="true">
|
||||
info
|
||||
</span>
|
||||
<p className="text-sm text-text-muted">
|
||||
{tr("simpleStartWithExamplePlaceholder", "Select a ready-made example")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* translating or sending */}
|
||||
{isSpinning && (
|
||||
<div className="flex items-center gap-3 py-6">
|
||||
<span className="material-symbols-outlined animate-spin text-[24px] text-primary" aria-hidden="true">
|
||||
progress_activity
|
||||
</span>
|
||||
<span className="text-sm text-text-muted">
|
||||
{result.status === "translating"
|
||||
? tr("narratedTranslating", "Translating to {target}...", {
|
||||
target: formatLabel(result.target),
|
||||
})
|
||||
: tr("narratedSending", "Sending to {target}...", {
|
||||
target: formatLabel(result.target),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ok */}
|
||||
{result.status === "ok" && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Detection badge */}
|
||||
{result.detected && (
|
||||
<Badge variant="success">
|
||||
{tr("narratedDetected", "✓ Detected: {format}", {
|
||||
format: formatLabel(result.detected),
|
||||
})}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* Narrated success line */}
|
||||
<p className="text-sm text-text-main">
|
||||
{tr("narratedSuccess", "→ translated to {target} · response in {latency}ms", {
|
||||
target: formatLabel(result.target),
|
||||
latency: result.latencyMs ?? 0,
|
||||
})}
|
||||
</p>
|
||||
|
||||
{/* Response preview */}
|
||||
{result.responsePreview && (
|
||||
<div className="rounded-md border border-black/10 bg-black/5 p-3 dark:border-white/10 dark:bg-white/5">
|
||||
<pre className="overflow-x-auto whitespace-pre-wrap break-words font-mono text-xs text-text-main">
|
||||
{result.responsePreview.slice(0, 500)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Secondary action buttons */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{result.translatedJson && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon="code"
|
||||
onClick={onSeeTranslatedJson}
|
||||
aria-label={tr("narratedSeeTranslatedJson", "see translated JSON")}
|
||||
>
|
||||
{tr("narratedSeeTranslatedJson", "see translated JSON")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon="account_tree"
|
||||
onClick={onSeePipeline}
|
||||
aria-label={tr("narratedSeePipeline", "see pipeline")}
|
||||
>
|
||||
{tr("narratedSeePipeline", "see pipeline")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* error */}
|
||||
{result.status === "error" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Badge variant="error">
|
||||
<span className="material-symbols-outlined text-[14px]" aria-hidden="true">
|
||||
error
|
||||
</span>
|
||||
Error
|
||||
</Badge>
|
||||
<p className="text-sm text-text-main">
|
||||
{tr("narratedError", "Failed: {reason}", {
|
||||
reason: safeErrorMessage(result.errorMessage),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button, Select, SegmentedControl } from "@/shared/components";
|
||||
import { InfoTooltip } from "@/shared/components";
|
||||
import { FORMAT_OPTIONS, FORMAT_META, getExampleTemplates } from "../exampleTemplates";
|
||||
import type { FormatId, TranslateMode } from "../types";
|
||||
|
||||
interface SimpleControlsProps {
|
||||
source: FormatId;
|
||||
target: FormatId;
|
||||
provider: string;
|
||||
inputText: string;
|
||||
mode: TranslateMode;
|
||||
onSourceChange: (source: FormatId) => void;
|
||||
onTargetChange: (target: FormatId) => void;
|
||||
onProviderChange: (provider: string) => void;
|
||||
onInputChange: (text: string) => void;
|
||||
onModeChange: (mode: TranslateMode) => void;
|
||||
onSubmit: () => void;
|
||||
onOpenAdvanced: () => void;
|
||||
isLoading?: boolean;
|
||||
providerOptions: Array<{ value: string; label: string }>;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export default function SimpleControls({
|
||||
source,
|
||||
target,
|
||||
provider,
|
||||
inputText,
|
||||
mode,
|
||||
onSourceChange,
|
||||
onTargetChange,
|
||||
onProviderChange,
|
||||
onInputChange,
|
||||
onModeChange,
|
||||
onSubmit,
|
||||
onOpenAdvanced,
|
||||
isLoading = false,
|
||||
providerOptions,
|
||||
loading = false,
|
||||
}: SimpleControlsProps) {
|
||||
const t = useTranslations("translator");
|
||||
|
||||
const tr = useCallback(
|
||||
(key: string, fallback: string): string => {
|
||||
try {
|
||||
const translated = t(key as Parameters<typeof t>[0]);
|
||||
return translated === key || translated === `translator.${key}` ? fallback : translated;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
const examples = getExampleTemplates(t as (key: string) => string);
|
||||
|
||||
// Map provider string to a FormatId when a provider is selected
|
||||
const providerToFormatId = useCallback((prov: string): FormatId => {
|
||||
const normalized = prov.toLowerCase();
|
||||
if (normalized.includes("gemini")) return "gemini";
|
||||
if (normalized.includes("claude") || normalized.includes("anthropic")) return "claude";
|
||||
if (normalized.includes("cursor")) return "cursor";
|
||||
if (normalized.includes("kiro")) return "kiro";
|
||||
if (normalized.includes("antigravity")) return "antigravity";
|
||||
// Check FORMAT_META directly
|
||||
const metaKeys = Object.keys(FORMAT_META) as FormatId[];
|
||||
const exactMatch = metaKeys.find((k) => k === normalized);
|
||||
if (exactMatch) return exactMatch;
|
||||
return "openai";
|
||||
}, []);
|
||||
|
||||
const handleProviderChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const prov = e.target.value;
|
||||
onProviderChange(prov);
|
||||
onTargetChange(providerToFormatId(prov));
|
||||
},
|
||||
[onProviderChange, onTargetChange, providerToFormatId]
|
||||
);
|
||||
|
||||
const handleExampleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const selectedId = e.target.value;
|
||||
if (selectedId === "__custom__") {
|
||||
onOpenAdvanced();
|
||||
return;
|
||||
}
|
||||
const template = examples.find((ex) => ex.id === selectedId);
|
||||
if (!template) return;
|
||||
// Load template body for the current source format
|
||||
const body =
|
||||
template.formats[source] ??
|
||||
template.formats["openai"] ??
|
||||
Object.values(template.formats)[0];
|
||||
if (body) {
|
||||
onInputChange(JSON.stringify(body, null, 2));
|
||||
}
|
||||
},
|
||||
[examples, source, onInputChange, onOpenAdvanced]
|
||||
);
|
||||
|
||||
const modeOptions = [
|
||||
{ value: "preview", label: tr("simpleModePreview", "Preview translation only") },
|
||||
{ value: "send", label: tr("simpleModeSend", "Send and see response") },
|
||||
];
|
||||
|
||||
const exampleSelectOptions = [
|
||||
...examples.map((ex) => ({ value: ex.id, label: ex.name })),
|
||||
{ value: "__custom__", label: tr("simpleStartWithCustomOption", "Paste your request (advanced)") },
|
||||
];
|
||||
|
||||
const sourceOptions = FORMAT_OPTIONS;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Row 1: source format + provider (destination) */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-sm font-medium text-text-main">
|
||||
{tr("simpleAppUsesLabel", "My app uses")}
|
||||
</span>
|
||||
<InfoTooltip text={tr("simpleAppUsesHint", "The API format your app speaks (e.g. Anthropic SDK = claude).")} />
|
||||
</div>
|
||||
<Select
|
||||
aria-label={tr("simpleAppUsesLabel", "My app uses")}
|
||||
options={sourceOptions}
|
||||
value={source}
|
||||
onChange={(e) => onSourceChange(e.target.value as FormatId)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="hidden items-center pt-8 sm:flex">
|
||||
<span className="material-symbols-outlined text-[20px] text-text-muted" aria-hidden="true">
|
||||
arrow_forward
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-sm font-medium text-text-main">
|
||||
{tr("simpleSendToLabel", "Send to")}
|
||||
</span>
|
||||
<InfoTooltip text={tr("simpleSendToHint", "Where to actually send the request (a provider connected in OmniRoute).")} />
|
||||
</div>
|
||||
<Select
|
||||
aria-label={tr("simpleSendToLabel", "Send to")}
|
||||
options={providerOptions.length > 0 ? providerOptions : [{ value: provider, label: provider }]}
|
||||
value={provider}
|
||||
onChange={handleProviderChange}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: example picker */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm font-medium text-text-main">
|
||||
{tr("simpleStartWithLabel", "Start with")}
|
||||
</span>
|
||||
<Select
|
||||
aria-label={tr("simpleStartWithLabel", "Start with")}
|
||||
options={exampleSelectOptions}
|
||||
value=""
|
||||
onChange={handleExampleChange}
|
||||
placeholder={tr("simpleStartWithExamplePlaceholder", "Select a ready-made example")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Row 3: mode segmented control */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm font-medium text-text-main">
|
||||
{tr("simpleModeLabel", "Mode")}
|
||||
</span>
|
||||
<SegmentedControl
|
||||
options={modeOptions}
|
||||
value={mode}
|
||||
onChange={(v) => onModeChange(v as TranslateMode)}
|
||||
aria-label={tr("simpleModeLabel", "Mode")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Row 4: textarea */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm font-medium text-text-main">
|
||||
{tr("simpleInputPanelTitle", "Input")}
|
||||
</span>
|
||||
<textarea
|
||||
aria-label={tr("simpleInputPanelTitle", "Input")}
|
||||
rows={6}
|
||||
value={inputText}
|
||||
onChange={(e) => onInputChange(e.target.value)}
|
||||
placeholder={tr("simpleInputPanelHint", "Free-text message or ready-made example")}
|
||||
className="w-full resize-y rounded-lg border border-black/10 bg-white px-3 py-2 font-mono text-sm text-text-main placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/40 dark:border-white/10 dark:bg-white/5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Row 5: footer actions */}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onSubmit}
|
||||
disabled={!inputText.trim() || isLoading}
|
||||
loading={isLoading}
|
||||
aria-label={tr("simpleModeSend", "Send and see response")}
|
||||
>
|
||||
{mode === "preview"
|
||||
? tr("simpleModePreview", "Preview translation only")
|
||||
: tr("simpleModeSend", "Send and see response")}
|
||||
</Button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenAdvanced}
|
||||
aria-label={tr("simpleAdvancedToggle", "Advanced")}
|
||||
className="inline-flex items-center gap-1 rounded-md px-3 py-1.5 text-sm text-text-muted transition-colors hover:bg-black/5 hover:text-text-main dark:hover:bg-white/5"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">tune</span>
|
||||
{tr("simpleAdvancedToggle", "Advanced")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,295 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { Button, Card } from "@/shared/components";
|
||||
import { copyToClipboard } from "@/shared/utils/clipboard";
|
||||
|
||||
const TEXT_SAMPLE = `data: {"id":"chatcmpl_demo","object":"chat.completion.chunk","created":1745366400,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"},"finish_reason":null}]}
|
||||
|
||||
data: {"id":"chatcmpl_demo","object":"chat.completion.chunk","created":1745366400,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"content":" from OmniRoute"},"finish_reason":null}]}
|
||||
|
||||
data: {"id":"chatcmpl_demo","object":"chat.completion.chunk","created":1745366400,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":12,"completion_tokens":4,"total_tokens":16}}
|
||||
|
||||
data: [DONE]
|
||||
`;
|
||||
|
||||
const TOOL_SAMPLE = `data: {"id":"chatcmpl_tool","object":"chat.completion.chunk","created":1745366400,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_123","type":"function","function":{"name":"lookup_weather","arguments":"{\\"city\\":\\"Tok"}}]},"finish_reason":null}]}
|
||||
|
||||
data: {"id":"chatcmpl_tool","object":"chat.completion.chunk","created":1745366400,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"yo\\"}"}}]},"finish_reason":null}]}
|
||||
|
||||
data: {"id":"chatcmpl_tool","object":"chat.completion.chunk","created":1745366400,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":23,"completion_tokens":9,"total_tokens":32}}
|
||||
|
||||
data: [DONE]
|
||||
`;
|
||||
|
||||
function getFramePreview(data: unknown): string {
|
||||
if (typeof data === "string") return data;
|
||||
if (!data || typeof data !== "object") return "";
|
||||
|
||||
const record = data as Record<string, unknown>;
|
||||
const delta = record.delta;
|
||||
if (typeof delta === "string") return delta;
|
||||
|
||||
const item = record.item;
|
||||
if (item && typeof item === "object") {
|
||||
const itemRecord = item as Record<string, unknown>;
|
||||
const type = itemRecord.type;
|
||||
const text = itemRecord.text;
|
||||
const name = itemRecord.name;
|
||||
if (typeof text === "string" && text) return text;
|
||||
if (typeof name === "string" && name) return `${type || "item"}: ${name}`;
|
||||
if (typeof type === "string" && type) return type;
|
||||
}
|
||||
|
||||
const text = record.text;
|
||||
if (typeof text === "string" && text) return text;
|
||||
|
||||
return JSON.stringify(data).slice(0, 140);
|
||||
}
|
||||
|
||||
function parseSseFrames(rawSse: string): Array<{ event: string; preview: string }> {
|
||||
return rawSse
|
||||
.split("\n\n")
|
||||
.map((frame) => frame.trim())
|
||||
.filter(Boolean)
|
||||
.map((frame) => {
|
||||
const eventLine = frame
|
||||
.split("\n")
|
||||
.find((line) => line.startsWith("event:"))
|
||||
?.replace(/^event:\s*/, "")
|
||||
.trim();
|
||||
const dataLine = frame
|
||||
.split("\n")
|
||||
.find((line) => line.startsWith("data:"))
|
||||
?.replace(/^data:\s*/, "");
|
||||
|
||||
if (dataLine === "[DONE]") {
|
||||
return { event: "done", preview: "[DONE]" };
|
||||
}
|
||||
|
||||
let parsedData: unknown = dataLine || "";
|
||||
try {
|
||||
parsedData = dataLine ? JSON.parse(dataLine) : "";
|
||||
} catch {
|
||||
parsedData = dataLine || "";
|
||||
}
|
||||
|
||||
return {
|
||||
event: eventLine || "message",
|
||||
preview: getFramePreview(parsedData),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export default function StreamTransformerMode() {
|
||||
const t = useTranslations("translator");
|
||||
const translateOrFallback = useCallback(
|
||||
(key: string, fallback: string, values?: Record<string, unknown>) => {
|
||||
try {
|
||||
const translated = t(key, values);
|
||||
return translated === key || translated === `translator.${key}` ? fallback : translated;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
const [rawSse, setRawSse] = useState(TEXT_SAMPLE);
|
||||
const [transformedSse, setTransformedSse] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [copiedField, setCopiedField] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const transformedFrames = useMemo(() => parseSseFrames(transformedSse), [transformedSse]);
|
||||
const eventCount = transformedFrames.length;
|
||||
const uniqueEventCount = new Set(transformedFrames.map((frame) => frame.event)).size;
|
||||
|
||||
const handleCopy = async (value: string, field: string) => {
|
||||
await copyToClipboard(value);
|
||||
setCopiedField(field);
|
||||
setTimeout(() => setCopiedField(null), 2000);
|
||||
};
|
||||
|
||||
const runTransform = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/translator/transform-stream", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ rawSse }),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok || !data.success) {
|
||||
throw new Error(data.error || translateOrFallback("requestFailed", "Request failed"));
|
||||
}
|
||||
|
||||
setTransformedSse(data.transformed || "");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to transform stream");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5 min-w-0">
|
||||
<div className="flex items-start gap-3 px-4 py-3 rounded-lg bg-primary/5 border border-primary/10 text-sm text-text-muted">
|
||||
<span className="material-symbols-outlined text-primary text-[20px] mt-0.5 shrink-0">
|
||||
swap_horiz
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-medium text-text-main mb-0.5">
|
||||
{translateOrFallback("streamTransformerTitle", "Responses Stream Transformer")}
|
||||
</p>
|
||||
<p>
|
||||
{translateOrFallback(
|
||||
"streamTransformerDescription",
|
||||
"Paste a chat completions SSE stream, run it through OmniRoute's Responses transformer, and inspect the emitted response.* events before wiring a client."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<div className="p-4 flex flex-col gap-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setRawSse(TEXT_SAMPLE)}>
|
||||
{translateOrFallback("loadTextSample", "Load text sample")}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setRawSse(TOOL_SAMPLE)}>
|
||||
{translateOrFallback("loadToolSample", "Load tool-call sample")}
|
||||
</Button>
|
||||
<Button size="sm" icon="play_arrow" onClick={runTransform} loading={loading}>
|
||||
{translateOrFallback("transformToResponses", "Transform to Responses")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-600 dark:text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold text-text-main">
|
||||
{translateOrFallback("rawChatSseInput", "Raw chat completions SSE")}
|
||||
</h3>
|
||||
<Button variant="ghost" size="sm" onClick={() => handleCopy(rawSse, "input")}>
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
{copiedField === "input" ? "check" : "content_copy"}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<textarea
|
||||
value={rawSse}
|
||||
onChange={(e) => setRawSse(e.target.value)}
|
||||
className="min-h-[360px] w-full rounded-lg border border-border bg-bg-secondary px-3 py-3 text-xs font-mono text-text-main focus:outline-none focus:ring-1 focus:ring-primary/50"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold text-text-main">
|
||||
{translateOrFallback("transformedResponsesSse", "Transformed Responses API SSE")}
|
||||
</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleCopy(transformedSse, "output")}
|
||||
disabled={!transformedSse}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
{copiedField === "output" ? "check" : "content_copy"}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="min-h-[360px] overflow-auto rounded-lg border border-border bg-bg-secondary px-3 py-3 text-xs font-mono whitespace-pre-wrap break-all">
|
||||
{transformedSse || translateOrFallback("noResultsYet", "No results yet")}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<MiniStat
|
||||
label={translateOrFallback("transformedEvents", "Transformed events")}
|
||||
value={eventCount}
|
||||
/>
|
||||
<MiniStat
|
||||
label={translateOrFallback("uniqueEventTypes", "Unique event types")}
|
||||
value={uniqueEventCount}
|
||||
/>
|
||||
<MiniStat
|
||||
label={translateOrFallback("inputLines", "Input lines")}
|
||||
value={rawSse.split("\n").length}
|
||||
/>
|
||||
<MiniStat
|
||||
label={translateOrFallback("outputLines", "Output lines")}
|
||||
value={transformedSse ? transformedSse.split("\n").length : 0}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<div className="p-4 space-y-3">
|
||||
<h3 className="text-sm font-semibold text-text-main">
|
||||
{translateOrFallback("transformedEventTimeline", "Transformed event timeline")}
|
||||
</h3>
|
||||
|
||||
{transformedFrames.length === 0 ? (
|
||||
<p className="text-sm text-text-muted">
|
||||
{translateOrFallback(
|
||||
"transformerTimelineHint",
|
||||
"Run the transformer to inspect emitted response.output_* events in order."
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-text-muted border-b border-border">
|
||||
<th className="pb-2 pr-4">#</th>
|
||||
<th className="pb-2 pr-4">{translateOrFallback("eventType", "Event type")}</th>
|
||||
<th className="pb-2">{translateOrFallback("eventPreview", "Preview")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{transformedFrames.map((frame, index) => (
|
||||
<tr
|
||||
key={`${frame.event}_${index}`}
|
||||
className="border-b border-border/50 align-top"
|
||||
>
|
||||
<td className="py-2 pr-4 text-xs text-text-muted">{index + 1}</td>
|
||||
<td className="py-2 pr-4 font-mono text-xs text-primary">{frame.event}</td>
|
||||
<td className="py-2 text-xs text-text-muted break-all">{frame.preview}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MiniStat({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="p-4">
|
||||
<p className="text-lg font-bold text-text-main">{value}</p>
|
||||
<p className="text-[10px] uppercase tracking-wider text-text-muted">{label}</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, type ReactNode } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Tooltip from "@/shared/components/Tooltip";
|
||||
|
||||
interface FlowNodeProps {
|
||||
icon: string;
|
||||
color: "primary" | "orange" | "blue" | "emerald" | "amber" | "purple" | "cyan" | "pink";
|
||||
title: string;
|
||||
example: string;
|
||||
tooltipContent?: string;
|
||||
}
|
||||
|
||||
const COLOR_MAP: Record<
|
||||
FlowNodeProps["color"],
|
||||
{ border: string; bg: string; text: string }
|
||||
> = {
|
||||
primary: { border: "border-primary/30", bg: "bg-primary/5", text: "text-primary" },
|
||||
orange: { border: "border-orange-500/30", bg: "bg-orange-500/5", text: "text-orange-500" },
|
||||
blue: { border: "border-blue-500/30", bg: "bg-blue-500/5", text: "text-blue-500" },
|
||||
emerald: {
|
||||
border: "border-emerald-500/30",
|
||||
bg: "bg-emerald-500/5",
|
||||
text: "text-emerald-500",
|
||||
},
|
||||
amber: { border: "border-amber-500/30", bg: "bg-amber-500/5", text: "text-amber-500" },
|
||||
purple: {
|
||||
border: "border-purple-500/30",
|
||||
bg: "bg-purple-500/5",
|
||||
text: "text-purple-500",
|
||||
},
|
||||
cyan: { border: "border-cyan-500/30", bg: "bg-cyan-500/5", text: "text-cyan-500" },
|
||||
pink: { border: "border-pink-500/30", bg: "bg-pink-500/5", text: "text-pink-500" },
|
||||
};
|
||||
|
||||
function FlowNode({ icon, color, title, example, tooltipContent }: FlowNodeProps) {
|
||||
const c = COLOR_MAP[color];
|
||||
const node: ReactNode = (
|
||||
<div
|
||||
className={`flex flex-col items-center gap-1 rounded-lg border ${c.border} ${c.bg} px-3 py-2 text-center min-w-0`}
|
||||
>
|
||||
<span className={`material-symbols-outlined text-[20px] ${c.text}`} aria-hidden="true">
|
||||
{icon}
|
||||
</span>
|
||||
<p className="text-[11px] font-semibold text-text-main leading-tight">{title}</p>
|
||||
<p className="text-[10px] text-text-muted leading-tight">{example}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
return tooltipContent ? (
|
||||
<Tooltip content={tooltipContent} position="top" multiline>
|
||||
{node}
|
||||
</Tooltip>
|
||||
) : (
|
||||
node
|
||||
);
|
||||
}
|
||||
|
||||
function FlowArrow({ label }: { label?: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center text-text-muted">
|
||||
<span
|
||||
className="material-symbols-outlined text-[20px] rotate-90 sm:rotate-0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
arrow_forward
|
||||
</span>
|
||||
{label && (
|
||||
<span className="text-[9px] uppercase tracking-wide mt-0.5">{label}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TranslateFlowDiagram() {
|
||||
const t = useTranslations("translator");
|
||||
const tr = useCallback(
|
||||
(key: string, fallback: string) => {
|
||||
try {
|
||||
const translated = t(key);
|
||||
return translated === key || translated === `translator.${key}` ? fallback : translated;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-[1fr_auto_1fr_auto_1fr_auto_1fr] gap-2 sm:gap-3 sm:items-stretch">
|
||||
<FlowNode
|
||||
icon="smart_toy"
|
||||
color="primary"
|
||||
title={tr("conceptDiagramAppLabel", "Sua app")}
|
||||
example={tr("conceptDiagramExampleApp", "ex: SDK Anthropic")}
|
||||
/>
|
||||
<FlowArrow label={tr("conceptDiagramArrow1", "fala")} />
|
||||
<FlowNode
|
||||
icon="psychology"
|
||||
color="orange"
|
||||
title={tr("conceptDiagramSourceLabel", "Formato origem")}
|
||||
example={tr("conceptDiagramExampleSource", "claude")}
|
||||
tooltipContent={tr(
|
||||
"conceptDiagramSourceTooltip",
|
||||
"Formato do protocolo de API que sua app fala (ex: Anthropic Messages, OpenAI Chat Completions, Gemini).",
|
||||
)}
|
||||
/>
|
||||
<FlowArrow label={tr("conceptDiagramArrow2", "Translator")} />
|
||||
<FlowNode
|
||||
icon="hub"
|
||||
color="emerald"
|
||||
title={tr("conceptDiagramHubLabel", "OpenAI (hub)")}
|
||||
example={tr("conceptDiagramExampleHub", "formato pivô")}
|
||||
tooltipContent={tr(
|
||||
"conceptDiagramHubTooltip",
|
||||
"Hub intermediário usado pelo translator para converter entre formatos não-compatíveis diretamente. Todos os formatos passam por OpenAI como pivô.",
|
||||
)}
|
||||
/>
|
||||
<FlowArrow label={tr("conceptDiagramArrow3", "→")} />
|
||||
<FlowNode
|
||||
icon="auto_awesome"
|
||||
color="blue"
|
||||
title={tr("conceptDiagramTargetLabel", "Provider destino")}
|
||||
example={tr("conceptDiagramExampleTarget", "Gemini")}
|
||||
tooltipContent={tr(
|
||||
"conceptDiagramTargetTooltip",
|
||||
"Provider conectado em OmniRoute que vai responder de verdade (ex: Google Gemini, Anthropic, etc).",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Card } from "@/shared/components";
|
||||
import { useTranslateSession } from "../hooks/useTranslateSession";
|
||||
import type { UseTranslateSessionReturn } from "../hooks/useTranslateSession";
|
||||
import { useProviderOptions } from "../hooks/useProviderOptions";
|
||||
import SimpleControls from "./SimpleControls";
|
||||
import ResultNarrated from "./ResultNarrated";
|
||||
import type { AdvancedSlug, FormatId, TranslateMode } from "../types";
|
||||
|
||||
interface TranslateTabProps {
|
||||
/**
|
||||
* F9 integration: tells TranslateTab to open a specific advanced accordion.
|
||||
* When null, no accordion is forced open.
|
||||
*/
|
||||
forceOpenAdvancedSlug?: AdvancedSlug | null;
|
||||
/**
|
||||
* F9 integration: called when an advanced accordion slug should change
|
||||
* (open or close). F9 syncs this with the URL query string.
|
||||
*/
|
||||
onAdvancedSlugChange?: (slug: AdvancedSlug | null) => void;
|
||||
/**
|
||||
* Optional session lifted from shell (TranslatorPageClient) so PipelineView
|
||||
* can read the result at the shell level. When undefined, an internal session
|
||||
* is used (isolated rendering mode, e.g. tests).
|
||||
*/
|
||||
session?: UseTranslateSessionReturn;
|
||||
/**
|
||||
* Callback to sync internal inputText with the shell-level sharedInputContent (GAP-NOVO-2).
|
||||
* When provided, called every time inputText changes so CompressionPreviewAccordion
|
||||
* and pipeline Step 1 see the real input text.
|
||||
*/
|
||||
onInputChange?: (text: string) => void;
|
||||
}
|
||||
|
||||
export default function TranslateTab({
|
||||
forceOpenAdvancedSlug = null,
|
||||
onAdvancedSlugChange,
|
||||
session: sessionProp,
|
||||
onInputChange,
|
||||
}: TranslateTabProps) {
|
||||
// Internal simple-mode state
|
||||
const [source, setSource] = useState<FormatId>("claude");
|
||||
const [inputText, setInputText] = useState<string>("");
|
||||
const [mode, setMode] = useState<TranslateMode>("send");
|
||||
|
||||
// Unified input change handler — keeps internal state and notifies shell (GAP-NOVO-2)
|
||||
const handleInputChange = (text: string) => {
|
||||
setInputText(text);
|
||||
onInputChange?.(text);
|
||||
};
|
||||
|
||||
// Provider/target state: derive from useProviderOptions
|
||||
// GAP-3: useProviderOptions lives only here; SimpleControls receives it as props
|
||||
const { provider, setProvider, providerOptions, loading } = useProviderOptions("openai");
|
||||
// target FormatId mirrors provider selection; managed via SimpleControls callback
|
||||
const [target, setTarget] = useState<FormatId>("openai");
|
||||
|
||||
// Rules of Hooks: always call unconditionally; fall back to prop when provided
|
||||
const internalSession = useTranslateSession();
|
||||
const { result, run } = sessionProp ?? internalSession;
|
||||
|
||||
const handleSubmit = () => {
|
||||
run({ source, target, provider, inputText, mode });
|
||||
};
|
||||
|
||||
const handleOpenAdvanced = (slug: AdvancedSlug = "rawjson") => {
|
||||
if (onAdvancedSlugChange) {
|
||||
onAdvancedSlugChange(slug);
|
||||
}
|
||||
// Restore scroll-into-view after URL change (UX polish — was lost in GAP-5 cleanup)
|
||||
if (typeof document !== "undefined") {
|
||||
const advancedEl = document.getElementById("translator-advanced-section");
|
||||
if (advancedEl && typeof advancedEl.scrollIntoView === "function") {
|
||||
// Defer to next tick so React commits the open state first
|
||||
requestAnimationFrame(() => {
|
||||
advancedEl.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSeeTranslatedJson = () => {
|
||||
handleOpenAdvanced("rawjson");
|
||||
};
|
||||
|
||||
const handleSeePipeline = () => {
|
||||
handleOpenAdvanced("pipeline");
|
||||
};
|
||||
|
||||
// Sync provider options: when providerOptions loads, keep provider in sync
|
||||
// (useProviderOptions handles this internally; we just need to expose setProvider)
|
||||
const handleProviderChange = (prov: string) => {
|
||||
setProvider(prov);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* 2-column grid: SimpleControls (left) + ResultNarrated (right) */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
{/* Left: controls */}
|
||||
<Card className="p-4">
|
||||
<SimpleControls
|
||||
source={source}
|
||||
target={target}
|
||||
provider={provider}
|
||||
inputText={inputText}
|
||||
mode={mode}
|
||||
onSourceChange={setSource}
|
||||
onTargetChange={setTarget}
|
||||
onProviderChange={handleProviderChange}
|
||||
onInputChange={handleInputChange}
|
||||
onModeChange={setMode}
|
||||
onSubmit={handleSubmit}
|
||||
onOpenAdvanced={() => handleOpenAdvanced("rawjson")}
|
||||
isLoading={result.status === "translating" || result.status === "sending"}
|
||||
providerOptions={providerOptions}
|
||||
loading={loading}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Right: narrated result */}
|
||||
<ResultNarrated
|
||||
result={result}
|
||||
onSeeTranslatedJson={handleSeeTranslatedJson}
|
||||
onSeePipeline={handleSeePipeline}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card } from "@/shared/components";
|
||||
import TranslateFlowDiagram from "./TranslateFlowDiagram";
|
||||
|
||||
export default function TranslatorConceptCard() {
|
||||
const t = useTranslations("translator");
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const tr = useCallback(
|
||||
(key: string, fallback: string) => {
|
||||
try {
|
||||
const translated = t(key);
|
||||
return translated === key || translated === `translator.${key}` ? fallback : translated;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className="border-primary/10 bg-primary/5">
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<span
|
||||
className="material-symbols-outlined text-primary text-[22px] mt-0.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
info
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-sm font-semibold text-text-main mb-1">
|
||||
{tr(
|
||||
"conceptHeadline",
|
||||
'Sua app fala o "idioma" de uma API. O Translator converte para usar outro provider.',
|
||||
)}
|
||||
</h2>
|
||||
<p className="text-xs text-text-muted">
|
||||
{tr(
|
||||
"friendlySubtitle",
|
||||
"Use sua app existente com qualquer provider — sem reescrever código.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TranslateFlowDiagram />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
aria-controls="translator-concept-how-it-works"
|
||||
className="flex items-center gap-2 text-xs font-medium text-primary hover:text-primary/80 transition-colors w-full justify-start py-1 rounded"
|
||||
>
|
||||
<span>{tr("conceptHowItWorksToggle", "Como funciona")}</span>
|
||||
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">
|
||||
{open ? "expand_less" : "expand_more"}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
id="translator-concept-how-it-works"
|
||||
className="text-xs text-text-muted leading-relaxed border-t border-border pt-3"
|
||||
>
|
||||
{tr(
|
||||
"conceptHowItWorksBody",
|
||||
"Sua app envia um pedido no formato dela. O Translator detecta o formato, converte via OpenAI como hub intermediário (ou direto, quando há tradutor direto disponível), envia ao provider escolhido e devolve a resposta convertida de volta no formato da sua app.",
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card } from "@/shared/components";
|
||||
import type { AdvancedSlug } from "../../types";
|
||||
|
||||
export interface AdvancedSectionProps {
|
||||
/** Slug to force-open on initial mount (deep-link from URL). */
|
||||
forceOpenSlug?: AdvancedSlug | null;
|
||||
/** F9 passes the 5 accordions as children, each with a slug prop. */
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Container for the 5 Advanced accordions.
|
||||
* Does NOT implement lazy-render itself — each accordion (RawJsonPanel,
|
||||
* PipelineView, StreamTransformerAccordion, TestBenchAccordion,
|
||||
* CompressionPreviewAccordion) controls its own mount guard (D7).
|
||||
*
|
||||
* forceOpenSlug is forwarded as data-slug on the wrapper div so each
|
||||
* accordion child can read it via props passed down by F9's TranslateTab.
|
||||
*/
|
||||
export default function AdvancedSection({
|
||||
forceOpenSlug,
|
||||
children,
|
||||
}: AdvancedSectionProps) {
|
||||
const t = useTranslations("translator");
|
||||
|
||||
/** Safe i18n with inline fallback — pattern from TranslatorPageClient. */
|
||||
const tr = (key: string, fallback: string): string => {
|
||||
try {
|
||||
const v = t(key as Parameters<typeof t>[0]);
|
||||
// When next-intl returns the key itself (missing key), use fallback.
|
||||
if (v === key || v === `translator.${key}`) return fallback;
|
||||
return v as string;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card id="translator-advanced-section" className="border-amber-500/10 bg-amber-500/[0.02]">
|
||||
<div className="p-4 space-y-3">
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-3">
|
||||
<span
|
||||
className="material-symbols-outlined text-amber-500 text-[20px] mt-0.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
tune
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-semibold text-text-main">
|
||||
{tr("advancedSectionTitle", "Advanced")}
|
||||
</h3>
|
||||
<p className="text-xs text-text-muted">
|
||||
{tr(
|
||||
"advancedSectionSubtitle",
|
||||
"Raw JSON, pipeline e ferramentas técnicas. Tudo aqui é igual às tabs antigas — apenas reorganizado.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Accordion slots — children provided by F9 (TranslateTab) */}
|
||||
<div
|
||||
className="space-y-2"
|
||||
data-advanced-container="true"
|
||||
data-slug={forceOpenSlug ?? "none"}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button, Select } from "@/shared/components";
|
||||
import { cn } from "@/shared/utils/cn";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface CompressionPreviewResult {
|
||||
originalTokens: number;
|
||||
compressedTokens: number;
|
||||
tokensSaved: number;
|
||||
savingsPct: number;
|
||||
techniquesUsed: string[];
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export interface CompressionPreviewAccordionProps {
|
||||
/** Force the accordion open on mount (used by deep-link). */
|
||||
forceOpen?: boolean;
|
||||
/** Called whenever the open state changes (used for URL sync). */
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
/**
|
||||
* Content to compress. If provided (from TranslateTab state), the accordion
|
||||
* uses it directly. If absent or empty, shows an empty-state hint.
|
||||
*/
|
||||
inputContent?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Sanitize an error message: strip Node stack-trace lines (e.g. "at /home/…"). */
|
||||
function sanitizeError(e: unknown): string {
|
||||
const raw = e instanceof Error ? e.message : String(e);
|
||||
// Remove stack-trace lines that start with "at " followed by a path
|
||||
return raw.replace(/\s+at\s+[^\n]+/g, "").trim();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const COMPRESSION_MODES = [
|
||||
{ value: "off", label: "Off" },
|
||||
{ value: "lite", label: "Lite" },
|
||||
{ value: "standard", label: "Standard" },
|
||||
{ value: "aggressive", label: "Aggressive" },
|
||||
{ value: "ultra", label: "Ultra" },
|
||||
] as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inner content (always mounted when hasOpened is true)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function CompressionPreviewContent({ inputContent = "" }: { inputContent?: string }) {
|
||||
const t = useTranslations("translator");
|
||||
|
||||
const [compressionMode, setCompressionMode] = useState<string>("standard");
|
||||
const [compressionResult, setCompressionResult] = useState<CompressionPreviewResult | null>(
|
||||
null,
|
||||
);
|
||||
const [compressionLoading, setCompressionLoading] = useState(false);
|
||||
const [compressionError, setCompressionError] = useState<string | null>(null);
|
||||
|
||||
const hasInput = inputContent.trim().length > 0;
|
||||
|
||||
const handleCompressionPreview = useCallback(async () => {
|
||||
if (!hasInput) return;
|
||||
|
||||
let messages: Array<{ role: string; content: string }>;
|
||||
try {
|
||||
const parsed: Record<string, unknown> = JSON.parse(inputContent);
|
||||
messages = Array.isArray(parsed.messages)
|
||||
? (parsed.messages as Array<{ role: string; content: string }>)
|
||||
: [{ role: "user", content: inputContent }];
|
||||
} catch {
|
||||
messages = [{ role: "user", content: inputContent }];
|
||||
}
|
||||
|
||||
setCompressionLoading(true);
|
||||
setCompressionError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/compression/preview", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ messages, mode: compressionMode }),
|
||||
});
|
||||
const data: CompressionPreviewResult & { error?: string } = await res.json();
|
||||
if (!res.ok) throw new Error(data.error ?? "Preview failed");
|
||||
setCompressionResult(data);
|
||||
} catch (e: unknown) {
|
||||
setCompressionError(sanitizeError(e));
|
||||
} finally {
|
||||
setCompressionLoading(false);
|
||||
}
|
||||
}, [hasInput, inputContent, compressionMode]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Empty state */}
|
||||
{!hasInput && (
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-md bg-black/5 dark:bg-white/5 text-sm text-text-muted">
|
||||
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">
|
||||
info
|
||||
</span>
|
||||
<span>
|
||||
{t("compressionEmptyHint") ||
|
||||
"Preencha o campo de entrada na aba Translate (Simple Controls ou Raw JSON) para habilitar o preview."}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<Select
|
||||
value={compressionMode}
|
||||
onChange={(e) => setCompressionMode(e.target.value)}
|
||||
options={COMPRESSION_MODES}
|
||||
className="text-sm"
|
||||
aria-label={t("compressionModeLabel") || "Modo de compressão"}
|
||||
/>
|
||||
<Button
|
||||
icon="play_arrow"
|
||||
onClick={handleCompressionPreview}
|
||||
loading={compressionLoading}
|
||||
disabled={compressionLoading || !hasInput}
|
||||
className="text-sm"
|
||||
>
|
||||
{compressionLoading
|
||||
? t("compressionPreviewing") || "Previewing…"
|
||||
: t("compressionPreviewButton") || "Preview Compression"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{compressionError && (
|
||||
<div className="text-sm text-red-500" role="alert">
|
||||
{compressionError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Result grid — 4 cards */}
|
||||
{compressionResult && (
|
||||
<div className="space-y-3">
|
||||
<div
|
||||
className="grid grid-cols-2 md:grid-cols-4 gap-3"
|
||||
data-testid="compression-result-grid"
|
||||
>
|
||||
<div className="card p-3 text-center bg-black/5 dark:bg-white/5 rounded-lg border border-border">
|
||||
<div className="text-xs text-text-muted">Original</div>
|
||||
<div className="text-lg font-bold">{compressionResult.originalTokens}</div>
|
||||
<div className="text-xs text-text-muted">tokens</div>
|
||||
</div>
|
||||
<div className="card p-3 text-center bg-black/5 dark:bg-white/5 rounded-lg border border-border">
|
||||
<div className="text-xs text-text-muted">Compressed</div>
|
||||
<div className="text-lg font-bold">{compressionResult.compressedTokens}</div>
|
||||
<div className="text-xs text-text-muted">tokens</div>
|
||||
</div>
|
||||
<div className="card p-3 text-center bg-black/5 dark:bg-white/5 rounded-lg border border-border">
|
||||
<div className="text-xs text-text-muted">Saved</div>
|
||||
<div className="text-lg font-bold text-green-500">
|
||||
{compressionResult.tokensSaved}
|
||||
</div>
|
||||
<div className="text-xs text-text-muted">{compressionResult.savingsPct}%</div>
|
||||
</div>
|
||||
<div className="card p-3 text-center bg-black/5 dark:bg-white/5 rounded-lg border border-border">
|
||||
<div className="text-xs text-text-muted">Duration</div>
|
||||
<div className="text-lg font-bold">{compressionResult.durationMs}</div>
|
||||
<div className="text-xs text-text-muted">ms</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{compressionResult.techniquesUsed.length > 0 && (
|
||||
<div className="text-xs text-text-muted">
|
||||
<span className="font-semibold">{t("techniques") || "Técnicas:"}</span>{" "}
|
||||
{compressionResult.techniquesUsed.join(", ")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Accordion wrapper — owns open state to support D7 lazy-render + onOpenChange
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* CompressionPreviewAccordion — F7
|
||||
*
|
||||
* Extracted from PlaygroundMode.tsx lines 506-584 (Compression Preview Panel).
|
||||
* Uses a self-contained collapsible header (matches Collapsible visual style)
|
||||
* with an explicit `open` state so we can implement:
|
||||
* - D7 lazy-render guard (mount content only after first open)
|
||||
* - `onOpenChange` callback for deep-link URL sync
|
||||
* - `forceOpen` prop for deep-link initial state
|
||||
*
|
||||
* Note: We manage open state here rather than delegating to Collapsible because
|
||||
* Collapsible is purely uncontrolled (no onOpenChange prop). D7 requires knowing
|
||||
* when the accordion opens to set hasOpened, which requires controlled state.
|
||||
*/
|
||||
export default function CompressionPreviewAccordion({
|
||||
forceOpen = false,
|
||||
onOpenChange,
|
||||
inputContent,
|
||||
}: CompressionPreviewAccordionProps) {
|
||||
const t = useTranslations("translator");
|
||||
|
||||
// Lazy-render guard (D7): track whether the accordion has ever been opened.
|
||||
const [hasOpened, setHasOpened] = useState(forceOpen);
|
||||
const [open, setOpen] = useState(forceOpen);
|
||||
// Track previous forceOpen so the effect only reacts to false→true transitions.
|
||||
// Without this, a manual close while forceOpen stays true would re-open the accordion
|
||||
// on the very next render (the test "toggle closes accordion again" guards this).
|
||||
const prevForceOpen = useRef(forceOpen);
|
||||
|
||||
// Sync forceOpen changes from parent after mount (deep-link / back-forward navigation).
|
||||
useEffect(() => {
|
||||
const prev = prevForceOpen.current;
|
||||
prevForceOpen.current = Boolean(forceOpen);
|
||||
if (!prev && forceOpen) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- syncing deep-link prop into local state
|
||||
setOpen(true);
|
||||
setHasOpened(true);
|
||||
onOpenChange?.(true);
|
||||
}
|
||||
}, [forceOpen, onOpenChange]);
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
const next = !open;
|
||||
if (next && !hasOpened) {
|
||||
setHasOpened(true);
|
||||
}
|
||||
setOpen(next);
|
||||
onOpenChange?.(next);
|
||||
}, [open, hasOpened, onOpenChange]);
|
||||
|
||||
// i18n with inline EN fallbacks (D19 pattern).
|
||||
const title = t("advancedCompressionTitle") || "Compression Preview";
|
||||
const subtitle =
|
||||
t("advancedCompressionSubtitle") || "Estime economia de tokens em diferentes modos.";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg border border-black/5 dark:border-white/5 bg-surface w-full"
|
||||
data-testid="compression-accordion"
|
||||
>
|
||||
{/* Header row — matches Collapsible visual style */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-3 p-4 hover:bg-black/[0.02] dark:hover:bg-white/[0.02] transition-colors",
|
||||
open && "border-b border-black/5 dark:border-white/5",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggle}
|
||||
aria-expanded={open}
|
||||
aria-controls="compression-preview-content"
|
||||
className="flex items-center gap-3 flex-1 min-w-0 text-left -m-1 p-1 rounded"
|
||||
>
|
||||
<span
|
||||
className="material-symbols-outlined text-text-muted text-[20px] shrink-0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{open ? "expand_more" : "chevron_right"}
|
||||
</span>
|
||||
<span
|
||||
className="material-symbols-outlined text-text-muted text-[18px] shrink-0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
compress
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-text-main truncate">{title}</div>
|
||||
<div className="text-xs text-text-muted truncate">{subtitle}</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content — D7 lazy-render */}
|
||||
{open && (
|
||||
<div id="compression-preview-content" className="p-4">
|
||||
{/* hasOpened is set to true before we set open=true, so this is always true when open */}
|
||||
{hasOpened && <CompressionPreviewContent inputContent={inputContent} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user