Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,604 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
ArrowUpDown,
|
||||
BarChart3,
|
||||
Brain,
|
||||
Cpu,
|
||||
RefreshCw,
|
||||
TrendingUp,
|
||||
} from "lucide-react";
|
||||
import { api } from "@/lib/api";
|
||||
import type {
|
||||
AnalyticsResponse,
|
||||
AnalyticsDailyEntry,
|
||||
AnalyticsModelEntry,
|
||||
AnalyticsSkillEntry,
|
||||
} from "@/lib/api";
|
||||
import { timeAgo } from "@/lib/utils";
|
||||
import { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { Spinner } from "@nous-research/ui/ui/components/spinner";
|
||||
import { Stats } from "@nous-research/ui/ui/components/stats";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@nous-research/ui/ui/components/card";
|
||||
import { usePageHeader } from "@/contexts/usePageHeader";
|
||||
import { useI18n } from "@/i18n";
|
||||
import { PluginSlot } from "@/plugins";
|
||||
|
||||
const PERIODS = [
|
||||
{ label: "7d", days: 7 },
|
||||
{ label: "30d", days: 30 },
|
||||
{ label: "90d", days: 90 },
|
||||
] as const;
|
||||
|
||||
const CHART_HEIGHT_PX = 160;
|
||||
|
||||
function formatTokens(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
function formatDate(day: string): string {
|
||||
try {
|
||||
const d = new Date(day + "T00:00:00");
|
||||
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||
} catch {
|
||||
return day;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sorting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function useTableSort<T>(
|
||||
data: T[],
|
||||
defaultKey: keyof T & string,
|
||||
defaultDir: "asc" | "desc" = "desc",
|
||||
) {
|
||||
const [sortKey, setSortKey] = useState<string>(defaultKey);
|
||||
const [sortDir, setSortDir] = useState<"asc" | "desc">(defaultDir);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
return [...data].sort((a, b) => {
|
||||
const aVal = a[sortKey as keyof T];
|
||||
const bVal = b[sortKey as keyof T];
|
||||
// Nulls always last regardless of direction
|
||||
if (aVal === null || aVal === undefined) return 1;
|
||||
if (bVal === null || bVal === undefined) return -1;
|
||||
if (aVal === bVal) return 0;
|
||||
const cmp = aVal > bVal ? 1 : -1;
|
||||
return sortDir === "asc" ? cmp : -cmp;
|
||||
});
|
||||
}, [data, sortKey, sortDir]);
|
||||
|
||||
const toggle = useCallback(
|
||||
(key: string) => {
|
||||
if (key === sortKey) {
|
||||
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
|
||||
} else {
|
||||
setSortKey(key);
|
||||
setSortDir("desc");
|
||||
}
|
||||
},
|
||||
[sortKey],
|
||||
);
|
||||
|
||||
return { sorted, sortKey, sortDir, toggle };
|
||||
}
|
||||
|
||||
function SortHeader({
|
||||
label,
|
||||
col,
|
||||
sortKey,
|
||||
sortDir,
|
||||
toggle,
|
||||
className,
|
||||
}: {
|
||||
label: string;
|
||||
col: string;
|
||||
sortKey: string;
|
||||
sortDir: "asc" | "desc";
|
||||
toggle: (key: string) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const active = col === sortKey;
|
||||
return (
|
||||
<th
|
||||
onClick={() => toggle(col)}
|
||||
className={`cursor-pointer select-none ${className ?? ""}`}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5 rounded px-1 -mx-1 py-0.5 hover:bg-muted/40 transition-colors">
|
||||
{label}
|
||||
{active ? (
|
||||
sortDir === "asc" ? (
|
||||
<ArrowUp className="h-3.5 w-3.5 text-foreground/80 shrink-0" />
|
||||
) : (
|
||||
<ArrowDown className="h-3.5 w-3.5 text-foreground/80 shrink-0" />
|
||||
)
|
||||
) : (
|
||||
<ArrowUpDown className="h-3 w-3 text-text-tertiary shrink-0" />
|
||||
)}
|
||||
</span>
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) {
|
||||
const { t } = useI18n();
|
||||
if (daily.length === 0) return null;
|
||||
|
||||
const maxTokens = Math.max(
|
||||
...daily.map((d) => d.input_tokens + d.output_tokens),
|
||||
1,
|
||||
);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart3 className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-base">
|
||||
{t.analytics.dailyTokenUsage}
|
||||
</CardTitle>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 font-mondwest normal-case text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div
|
||||
className="h-2.5 w-2.5"
|
||||
style={{ backgroundColor: "var(--series-input-token)" }}
|
||||
/>
|
||||
{t.analytics.input}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div
|
||||
className="h-2.5 w-2.5"
|
||||
style={{ backgroundColor: "var(--series-output-token)" }}
|
||||
/>
|
||||
{t.analytics.output}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div
|
||||
className="flex items-end gap-[2px]"
|
||||
style={{ height: CHART_HEIGHT_PX }}
|
||||
>
|
||||
{daily.map((d) => {
|
||||
const total = d.input_tokens + d.output_tokens;
|
||||
const inputH = Math.round(
|
||||
(d.input_tokens / maxTokens) * CHART_HEIGHT_PX,
|
||||
);
|
||||
const outputH = Math.round(
|
||||
(d.output_tokens / maxTokens) * CHART_HEIGHT_PX,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={d.day}
|
||||
className="flex-1 min-w-0 group relative flex flex-col justify-end"
|
||||
style={{ height: CHART_HEIGHT_PX }}
|
||||
>
|
||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 hidden group-hover:block z-10 pointer-events-none">
|
||||
<div className="font-mondwest normal-case bg-card border border-border px-2.5 py-1.5 text-xs text-foreground shadow-lg whitespace-nowrap">
|
||||
<div className="font-medium">{formatDate(d.day)}</div>
|
||||
<div>
|
||||
{t.analytics.input}: {formatTokens(d.input_tokens)}
|
||||
</div>
|
||||
<div>
|
||||
{t.analytics.output}: {formatTokens(d.output_tokens)}
|
||||
</div>
|
||||
<div>
|
||||
{t.analytics.total}: {formatTokens(total)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="w-full"
|
||||
style={{
|
||||
backgroundColor:
|
||||
"color-mix(in srgb, var(--series-input-token) 70%, transparent)",
|
||||
height: Math.max(inputH, total > 0 ? 1 : 0),
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="w-full"
|
||||
style={{
|
||||
backgroundColor:
|
||||
"color-mix(in srgb, var(--series-output-token) 70%, transparent)",
|
||||
height: Math.max(outputH, d.output_tokens > 0 ? 1 : 0),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between mt-2 font-mondwest normal-case text-xs text-text-tertiary">
|
||||
<span>{daily.length > 0 ? formatDate(daily[0].day) : ""}</span>
|
||||
{daily.length > 2 && (
|
||||
<span>{formatDate(daily[Math.floor(daily.length / 2)].day)}</span>
|
||||
)}
|
||||
<span>
|
||||
{daily.length > 1 ? formatDate(daily[daily.length - 1].day) : ""}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function DailyTable({ daily }: { daily: AnalyticsDailyEntry[] }) {
|
||||
const { t } = useI18n();
|
||||
const { sorted, sortKey, sortDir, toggle } = useTableSort(daily, "day", "desc");
|
||||
|
||||
if (daily.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingUp className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-base">
|
||||
{t.analytics.dailyBreakdown}
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full font-mondwest normal-case text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-muted-foreground text-xs">
|
||||
<SortHeader label={t.analytics.date} col="day" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-left py-2 pr-4 font-medium" />
|
||||
<SortHeader label={t.sessions.title} col="sessions" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-right py-2 px-4 font-medium" />
|
||||
<SortHeader label={t.analytics.input} col="input_tokens" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-right py-2 px-4 font-medium" />
|
||||
<SortHeader label={t.analytics.output} col="output_tokens" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-right py-2 pl-4 font-medium" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map((d) => (
|
||||
<tr
|
||||
key={d.day}
|
||||
className="border-b border-border/50 hover:bg-secondary/20 transition-colors"
|
||||
>
|
||||
<td className="py-2 pr-4 font-medium">
|
||||
{formatDate(d.day)}
|
||||
</td>
|
||||
<td className="text-right py-2 px-4 text-muted-foreground">
|
||||
{d.sessions}
|
||||
</td>
|
||||
<td className="text-right py-2 px-4">
|
||||
<span style={{ color: "var(--series-input-token)" }}>
|
||||
{formatTokens(d.input_tokens)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="text-right py-2 pl-4">
|
||||
<span style={{ color: "var(--series-output-token)" }}>
|
||||
{formatTokens(d.output_tokens)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelTable({ models }: { models: AnalyticsModelEntry[] }) {
|
||||
const { t } = useI18n();
|
||||
const { sorted, sortKey, sortDir, toggle } = useTableSort(models, "input_tokens", "desc");
|
||||
|
||||
if (models.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Cpu className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-base">
|
||||
{t.analytics.perModelBreakdown}
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full font-mondwest normal-case text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-muted-foreground text-xs">
|
||||
<SortHeader label={t.analytics.model} col="model" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-left py-2 pr-4 font-medium" />
|
||||
<SortHeader label={t.sessions.title} col="sessions" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-right py-2 px-4 font-medium" />
|
||||
<SortHeader label={t.analytics.tokens} col="input_tokens" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-right py-2 pl-4 font-medium" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map((m) => (
|
||||
<tr
|
||||
key={m.model}
|
||||
className="border-b border-border/50 hover:bg-secondary/20 transition-colors"
|
||||
>
|
||||
<td className="py-2 pr-4">
|
||||
<span className="font-mono-ui text-xs">{m.model}</span>
|
||||
</td>
|
||||
<td className="text-right py-2 px-4 text-muted-foreground">
|
||||
{m.sessions}
|
||||
</td>
|
||||
<td className="text-right py-2 pl-4">
|
||||
<span style={{ color: "var(--series-input-token)" }}>
|
||||
{formatTokens(m.input_tokens)}
|
||||
</span>
|
||||
{" / "}
|
||||
<span style={{ color: "var(--series-output-token)" }}>
|
||||
{formatTokens(m.output_tokens)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillTable({ skills }: { skills: AnalyticsSkillEntry[] }) {
|
||||
const { t } = useI18n();
|
||||
const { sorted, sortKey, sortDir, toggle } = useTableSort(skills, "total_count", "desc");
|
||||
|
||||
if (skills.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Brain className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-base">{t.analytics.topSkills}</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full font-mondwest normal-case text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-muted-foreground text-xs">
|
||||
<SortHeader label={t.analytics.skill} col="skill" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-left py-2 pr-4 font-medium" />
|
||||
<SortHeader label={t.analytics.loads} col="view_count" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-right py-2 px-4 font-medium" />
|
||||
<SortHeader label={t.analytics.edits} col="manage_count" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-right py-2 px-4 font-medium" />
|
||||
<SortHeader label={t.analytics.total} col="total_count" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-right py-2 px-4 font-medium" />
|
||||
<SortHeader label={t.analytics.lastUsed} col="last_used_at" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-right py-2 pl-4 font-medium" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map((skill) => (
|
||||
<tr
|
||||
key={skill.skill}
|
||||
className="border-b border-border/50 hover:bg-secondary/20 transition-colors"
|
||||
>
|
||||
<td className="py-2 pr-4">
|
||||
<span className="font-mono-ui text-xs">{skill.skill}</span>
|
||||
</td>
|
||||
<td className="text-right py-2 px-4 text-muted-foreground">
|
||||
{skill.view_count}
|
||||
</td>
|
||||
<td className="text-right py-2 px-4 text-muted-foreground">
|
||||
{skill.manage_count}
|
||||
</td>
|
||||
<td className="text-right py-2 px-4">{skill.total_count}</td>
|
||||
<td className="text-right py-2 pl-4 text-muted-foreground">
|
||||
{skill.last_used_at ? timeAgo(skill.last_used_at) : "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AnalyticsPage() {
|
||||
const [days, setDays] = useState(30);
|
||||
const [data, setData] = useState<AnalyticsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Gated on `dashboard.show_token_analytics` (default off). When off the
|
||||
// page renders an explanation card instead of fetching analytics — the
|
||||
// local token counts exclude auxiliary calls and provider retries, so
|
||||
// they diverge from provider billing in ways that mislead users.
|
||||
const [showTokens, setShowTokens] = useState<boolean | null>(null);
|
||||
const { t } = useI18n();
|
||||
const { setAfterTitle, setEnd } = usePageHeader();
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.getConfig()
|
||||
.then((cfg) => {
|
||||
const dash = (cfg?.dashboard ?? {}) as { show_token_analytics?: unknown };
|
||||
setShowTokens(dash.show_token_analytics === true);
|
||||
})
|
||||
.catch(() => setShowTokens(false));
|
||||
}, []);
|
||||
|
||||
const load = useCallback(() => {
|
||||
if (!showTokens) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
api
|
||||
.getAnalytics(days)
|
||||
.then(setData)
|
||||
.catch((err) => setError(String(err)))
|
||||
.finally(() => setLoading(false));
|
||||
}, [days, showTokens]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
// Period selector + refresh both live in afterTitle so the controls
|
||||
// sit immediately next to the page title instead of being pinned to
|
||||
// the far-right `end` slot. The active period is conveyed by the
|
||||
// filled (non-outlined) button — no redundant period badge.
|
||||
setAfterTitle(
|
||||
showTokens === false ? null : (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{PERIODS.map((p) => (
|
||||
<Button
|
||||
key={p.label}
|
||||
type="button"
|
||||
size="sm"
|
||||
outlined={days !== p.days}
|
||||
onClick={() => setDays(p.days)}
|
||||
>
|
||||
{p.label}
|
||||
</Button>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
ghost
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={load}
|
||||
disabled={loading}
|
||||
aria-label={t.common.refresh}
|
||||
>
|
||||
{loading ? <Spinner /> : <RefreshCw />}
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
);
|
||||
setEnd(null);
|
||||
return () => {
|
||||
setAfterTitle(null);
|
||||
setEnd(null);
|
||||
};
|
||||
}, [days, loading, load, setAfterTitle, setEnd, t.common.refresh, showTokens]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<PluginSlot name="analytics:top" />
|
||||
|
||||
{showTokens === false && (
|
||||
<Card>
|
||||
<CardContent className="py-12">
|
||||
<div className="mx-auto flex max-w-2xl flex-col gap-3 text-sm text-muted-foreground">
|
||||
<h2 className="font-mondwest text-display text-base tracking-wider text-foreground">
|
||||
Token analytics hidden
|
||||
</h2>
|
||||
<p>
|
||||
The token, cost, and per-day analytics on this page are a
|
||||
local debug estimate. They only count successful main-agent
|
||||
responses with a usable <span className="font-mono">usage</span>{" "}
|
||||
block, and silently exclude auxiliary calls (context
|
||||
compression, title generation, vision, session search, web
|
||||
extract, smart approvals, MCP routing, plugin LLM access)
|
||||
plus provider-side retries and fallback attempts. Cache
|
||||
writes are missing entirely.
|
||||
</p>
|
||||
<p>
|
||||
On models with heavy auxiliary traffic (Kimi K2.6, MiniMax
|
||||
M2.7) the local total can be 10x–100x lower than what your
|
||||
provider bills. Hiding these numbers is safer than letting
|
||||
them look authoritative.
|
||||
</p>
|
||||
<p>
|
||||
Check your provider dashboard (OpenRouter, Anthropic, etc.)
|
||||
for actual usage and billing. To re-enable the local debug
|
||||
estimate anyway, set{" "}
|
||||
<span className="font-mono">
|
||||
dashboard.show_token_analytics: true
|
||||
</span>{" "}
|
||||
in <a href="/config" className="underline">Config</a>.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{showTokens && loading && !data && (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<Spinner className="text-2xl text-primary" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showTokens && error && (
|
||||
<Card>
|
||||
<CardContent className="py-6">
|
||||
<p className="text-sm text-destructive text-center">{error}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{showTokens && data && (
|
||||
<>
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardContent className="py-6">
|
||||
<Stats
|
||||
items={[
|
||||
{
|
||||
label: t.analytics.totalTokens,
|
||||
value: formatTokens(
|
||||
data.totals.total_input + data.totals.total_output,
|
||||
),
|
||||
},
|
||||
{
|
||||
label: t.analytics.input,
|
||||
value: formatTokens(data.totals.total_input),
|
||||
},
|
||||
{
|
||||
label: t.analytics.output,
|
||||
value: formatTokens(data.totals.total_output),
|
||||
},
|
||||
{
|
||||
label: t.analytics.totalSessions,
|
||||
value: `${data.totals.total_sessions} (~${(data.totals.total_sessions / days).toFixed(1)}${t.analytics.perDayAvg})`,
|
||||
},
|
||||
{
|
||||
label: t.analytics.apiCalls,
|
||||
value: String(
|
||||
data.totals.total_api_calls ??
|
||||
data.daily.reduce((sum, d) => sum + d.sessions, 0),
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<TokenBarChart daily={data.daily} />
|
||||
</div>
|
||||
|
||||
<DailyTable daily={data.daily} />
|
||||
<ModelTable models={data.by_model} />
|
||||
<SkillTable skills={data.skills.top_skills} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{data &&
|
||||
data.daily.length === 0 &&
|
||||
data.by_model.length === 0 &&
|
||||
data.skills.top_skills.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="py-12">
|
||||
<div className="flex flex-col items-center text-muted-foreground">
|
||||
<BarChart3 className="h-8 w-8 mb-3 opacity-40" />
|
||||
<p className="text-sm font-medium">{t.analytics.noUsageData}</p>
|
||||
<p className="text-xs mt-1 text-text-tertiary">
|
||||
{t.analytics.startSession}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
<PluginSlot name="analytics:bottom" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,459 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, type ReactNode } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { PTY_TICKET_TIMEOUT_MS } from "@/lib/pty-reconnect";
|
||||
|
||||
class FakeFitAddon {
|
||||
fit() {}
|
||||
}
|
||||
|
||||
class FakeWebglAddon {
|
||||
onContextLoss() {
|
||||
return { dispose() {} };
|
||||
}
|
||||
}
|
||||
|
||||
class FakeTerminal {
|
||||
options: Record<string, unknown>;
|
||||
rows = 24;
|
||||
cols = 80;
|
||||
parser = {
|
||||
registerOscHandler: vi.fn(),
|
||||
};
|
||||
unicode = { activeVersion: "" };
|
||||
|
||||
constructor(options: Record<string, unknown>) {
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
attachCustomKeyEventHandler() {
|
||||
return true;
|
||||
}
|
||||
|
||||
attachCustomWheelEventHandler() {
|
||||
return true;
|
||||
}
|
||||
|
||||
clearSelection() {}
|
||||
|
||||
dispose() {}
|
||||
|
||||
focus() {}
|
||||
|
||||
getSelection() {
|
||||
return "";
|
||||
}
|
||||
|
||||
loadAddon() {}
|
||||
|
||||
onData() {
|
||||
return { dispose() {} };
|
||||
}
|
||||
|
||||
onResize() {
|
||||
return { dispose() {} };
|
||||
}
|
||||
|
||||
onScroll() {
|
||||
return { dispose() {} };
|
||||
}
|
||||
|
||||
get buffer() {
|
||||
// Minimal active-buffer surface for the resume follow-scroll pin
|
||||
// (isViewportPinnedToBottom reads viewportY/baseY).
|
||||
return { active: { baseY: 0, viewportY: 0 } };
|
||||
}
|
||||
|
||||
scrollToBottom() {}
|
||||
|
||||
open() {}
|
||||
|
||||
paste() {}
|
||||
|
||||
refresh() {}
|
||||
|
||||
write() {}
|
||||
}
|
||||
|
||||
const maybeReloadForLoopbackWsAuthFailure = vi.fn(() => false);
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
buildWsUrl: vi.fn(async () => "ws://localhost/api/pty?channel=chat-1"),
|
||||
}));
|
||||
|
||||
vi.mock("@xterm/addon-fit", () => ({ FitAddon: FakeFitAddon }));
|
||||
vi.mock("@xterm/addon-unicode11", () => ({ Unicode11Addon: class {} }));
|
||||
vi.mock("@xterm/addon-web-links", () => ({ WebLinksAddon: class {} }));
|
||||
vi.mock("@xterm/addon-webgl", () => ({ WebglAddon: FakeWebglAddon }));
|
||||
vi.mock("@xterm/xterm", () => ({ Terminal: FakeTerminal }));
|
||||
vi.mock("@/components/ChatSidebar", () => ({
|
||||
ChatSidebar: () => null,
|
||||
}));
|
||||
vi.mock("@/components/ChatSessionList", () => ({
|
||||
ChatSessionList: () => null,
|
||||
}));
|
||||
vi.mock("@/components/Backdrop", () => ({ Backdrop: () => null }));
|
||||
vi.mock("@/plugins", () => ({
|
||||
PluginSlot: () => null,
|
||||
}));
|
||||
vi.mock("@/contexts/usePageHeader", () => ({
|
||||
usePageHeader: () => ({ setEnd: vi.fn(), setTitle: vi.fn() }),
|
||||
}));
|
||||
vi.mock("@/contexts/useProfileScope", () => ({
|
||||
useProfileScope: () => ({ profile: "" }),
|
||||
}));
|
||||
vi.mock("@/themes", () => ({
|
||||
useTheme: () => ({ theme: { terminalBackground: "#000000" } }),
|
||||
}));
|
||||
vi.mock("@/i18n", () => ({
|
||||
useI18n: () => ({
|
||||
t: {
|
||||
app: {
|
||||
closeModelTools: "Close model tools",
|
||||
modelToolsSheetSubtitle: "Tools",
|
||||
modelToolsSheetTitle: "Model",
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/lib/dashboard-auth-reload", () => ({
|
||||
maybeReloadForLoopbackWsAuthFailure,
|
||||
}));
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: apiMocks,
|
||||
buildWsUrl: apiMocks.buildWsUrl,
|
||||
}));
|
||||
|
||||
class FakeWebSocket {
|
||||
static instances: FakeWebSocket[] = [];
|
||||
static OPEN = 1;
|
||||
|
||||
binaryType = "blob";
|
||||
onclose: ((event: CloseEventLike) => void) | null = null;
|
||||
onmessage: ((event: { data: ArrayBuffer | string }) => void) | null = null;
|
||||
onopen: (() => void) | null = null;
|
||||
readyState = FakeWebSocket.OPEN;
|
||||
url: string;
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
FakeWebSocket.instances.push(this);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.readyState = 3;
|
||||
}
|
||||
|
||||
send() {}
|
||||
}
|
||||
|
||||
type CloseEventLike = {
|
||||
code: number;
|
||||
reason: string;
|
||||
wasClean: boolean;
|
||||
};
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
// jsdom runs without an origin here (per-file @vitest-environment jsdom on a
|
||||
// node-default config), so localStorage is undefined. Stub it so components
|
||||
// that persist UI state (side panel collapse) can be exercised.
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, string> = {};
|
||||
return {
|
||||
getItem: (key: string) => store[key] ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
store[key] = String(value);
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
delete store[key];
|
||||
},
|
||||
clear: () => {
|
||||
store = {};
|
||||
},
|
||||
};
|
||||
})();
|
||||
|
||||
// React only routes updates through act() when this flag is set; without it
|
||||
// the isActive re-renders in the keyboard-inset gate test warn.
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
||||
true;
|
||||
|
||||
async function render(ui: ReactNode) {
|
||||
container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
await act(async () => root.render(ui));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
FakeWebSocket.instances = [];
|
||||
maybeReloadForLoopbackWsAuthFailure.mockClear();
|
||||
apiMocks.buildWsUrl.mockReset();
|
||||
apiMocks.buildWsUrl.mockResolvedValue("ws://localhost/api/pty?channel=chat-1");
|
||||
vi.stubGlobal("WebSocket", FakeWebSocket);
|
||||
vi.stubGlobal(
|
||||
"ResizeObserver",
|
||||
class {
|
||||
disconnect() {}
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
},
|
||||
);
|
||||
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
|
||||
cb(0);
|
||||
return 1;
|
||||
});
|
||||
vi.stubGlobal("cancelAnimationFrame", () => {});
|
||||
vi.stubGlobal("matchMedia", () => ({
|
||||
addEventListener() {},
|
||||
matches: false,
|
||||
media: "",
|
||||
removeEventListener() {},
|
||||
}));
|
||||
vi.stubGlobal("crypto", {
|
||||
getRandomValues: (values: Uint8Array) => {
|
||||
values.fill(7);
|
||||
return values;
|
||||
},
|
||||
randomUUID: () => "chat-test-id",
|
||||
});
|
||||
|
||||
Object.defineProperty(window, "visualViewport", {
|
||||
configurable: true,
|
||||
value: { addEventListener() {}, removeEventListener() {}, width: 1280 },
|
||||
});
|
||||
Object.defineProperty(window, "__HERMES_SESSION_TOKEN__", {
|
||||
configurable: true,
|
||||
value: "stale-token",
|
||||
writable: true,
|
||||
});
|
||||
Object.defineProperty(window, "__HERMES_AUTH_REQUIRED__", {
|
||||
configurable: true,
|
||||
value: false,
|
||||
writable: true,
|
||||
});
|
||||
Object.defineProperty(window.navigator, "clipboard", {
|
||||
configurable: true,
|
||||
value: {
|
||||
readText: vi.fn(async () => ""),
|
||||
writeText: vi.fn(async () => {}),
|
||||
},
|
||||
});
|
||||
sessionStorage.clear();
|
||||
vi.stubGlobal("localStorage", localStorageMock);
|
||||
localStorageMock.clear();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root?.unmount());
|
||||
container?.remove();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("ChatPage", () => {
|
||||
it("treats loopback 4401 closes as stale-token reload candidates", async () => {
|
||||
const { default: ChatPage } = await import("./ChatPage");
|
||||
|
||||
await render(
|
||||
<MemoryRouter initialEntries={["/chat"]}>
|
||||
<ChatPage isActive />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
await vi.waitFor(() => expect(FakeWebSocket.instances).toHaveLength(1));
|
||||
|
||||
FakeWebSocket.instances[0].onclose?.({
|
||||
code: 4401,
|
||||
reason: "auth: token_mismatch",
|
||||
wasClean: true,
|
||||
});
|
||||
|
||||
expect(maybeReloadForLoopbackWsAuthFailure).toHaveBeenCalledWith(4401);
|
||||
});
|
||||
|
||||
it("attaches visualViewport keyboard-inset listeners only while the chat tab is active", async () => {
|
||||
// NS-434 follow-up: ChatPage stays mounted (hidden) on every dashboard
|
||||
// route. The keyboard-inset/scroll-pin listeners must only be live while
|
||||
// /chat is the active tab, or the scroll pin fires when a soft keyboard
|
||||
// opens on Settings etc.
|
||||
const addEventListener = vi.fn();
|
||||
const removeEventListener = vi.fn();
|
||||
Object.defineProperty(window, "visualViewport", {
|
||||
configurable: true,
|
||||
value: { addEventListener, removeEventListener, width: 1280 },
|
||||
});
|
||||
|
||||
const { default: ChatPage } = await import("./ChatPage");
|
||||
|
||||
await render(
|
||||
<MemoryRouter initialEntries={["/chat"]}>
|
||||
<ChatPage isActive={false} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
expect(addEventListener).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MemoryRouter initialEntries={["/chat"]}>
|
||||
<ChatPage isActive />
|
||||
</MemoryRouter>,
|
||||
),
|
||||
);
|
||||
expect(addEventListener.mock.calls.map((c) => c[0]).sort()).toEqual([
|
||||
"resize",
|
||||
"scroll",
|
||||
]);
|
||||
expect(removeEventListener).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MemoryRouter initialEntries={["/chat"]}>
|
||||
<ChatPage isActive={false} />
|
||||
</MemoryRouter>,
|
||||
),
|
||||
);
|
||||
expect(removeEventListener.mock.calls.map((c) => c[0]).sort()).toEqual([
|
||||
"resize",
|
||||
"scroll",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatPage side panel collapse", () => {
|
||||
async function renderChat() {
|
||||
const { default: ChatPage } = await import("./ChatPage");
|
||||
await render(
|
||||
<MemoryRouter initialEntries={["/chat"]}>
|
||||
<ChatPage isActive />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
it("collapses the desktop side panel and persists the choice", async () => {
|
||||
localStorage.clear();
|
||||
await renderChat();
|
||||
await vi.waitFor(() => expect(FakeWebSocket.instances).toHaveLength(1));
|
||||
|
||||
const collapseButton = container.querySelector(
|
||||
'[aria-label="Collapse chat side panel"]',
|
||||
);
|
||||
expect(collapseButton).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
collapseButton!.dispatchEvent(
|
||||
new MouseEvent("click", { bubbles: true }),
|
||||
);
|
||||
});
|
||||
|
||||
expect(localStorage.getItem("hermes-chat-panel-collapsed")).toBe("1");
|
||||
expect(
|
||||
container.querySelector('[aria-label="Collapse chat side panel"]'),
|
||||
).toBeNull();
|
||||
expect(
|
||||
container.querySelector('[aria-label="Show chat side panel"]'),
|
||||
).not.toBeNull();
|
||||
|
||||
// Reopening restores the panel and clears the persisted flag.
|
||||
await act(async () => {
|
||||
container
|
||||
.querySelector('[aria-label="Show chat side panel"]')!
|
||||
.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(localStorage.getItem("hermes-chat-panel-collapsed")).toBe("0");
|
||||
expect(
|
||||
container.querySelector('[aria-label="Collapse chat side panel"]'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// The gated-mode ticket request runs before any socket exists, so a rejection
|
||||
// or a hang emits no `close` event and never arms PTY_CONNECTING_TIMEOUT_MS
|
||||
// (that timer is set after `new WebSocket`). Without its own deadline the tab
|
||||
// strands on "connecting" with no retry. Mirrors the ChatSidebar events-feed
|
||||
// coverage in src/components/ChatSidebar.test.tsx.
|
||||
describe("ChatPage PTY ticket connect deadline", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
async function renderChat() {
|
||||
const { default: ChatPage } = await import("./ChatPage");
|
||||
await render(
|
||||
<MemoryRouter initialEntries={["/chat"]}>
|
||||
<ChatPage isActive />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
/** Advance timers and flush the async connect that fires on the tick. */
|
||||
async function advance(ms: number) {
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(ms);
|
||||
});
|
||||
}
|
||||
|
||||
it("retries when the ticket request rejects", async () => {
|
||||
apiMocks.buildWsUrl.mockRejectedValueOnce(
|
||||
new Error("ticket endpoint unavailable"),
|
||||
);
|
||||
|
||||
await renderChat();
|
||||
await advance(0);
|
||||
expect(FakeWebSocket.instances).toHaveLength(0);
|
||||
|
||||
// First backoff step is 250ms; the retry must mint a fresh ticket.
|
||||
await advance(250);
|
||||
expect(apiMocks.buildWsUrl).toHaveBeenCalledTimes(2);
|
||||
expect(FakeWebSocket.instances).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("times out a stalled ticket request and retries", async () => {
|
||||
let resolveStalledRequest!: (url: string) => void;
|
||||
apiMocks.buildWsUrl.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<string>((resolve) => {
|
||||
resolveStalledRequest = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
await renderChat();
|
||||
await advance(0);
|
||||
expect(FakeWebSocket.instances).toHaveLength(0);
|
||||
|
||||
await advance(PTY_TICKET_TIMEOUT_MS);
|
||||
expect(FakeWebSocket.instances).toHaveLength(0);
|
||||
|
||||
// A late ticket from the timed-out attempt must not open a socket behind
|
||||
// the replacement the deadline scheduled.
|
||||
await act(async () => {
|
||||
resolveStalledRequest("ws://localhost/api/pty?channel=stale");
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(FakeWebSocket.instances).toHaveLength(0);
|
||||
|
||||
await advance(250);
|
||||
expect(FakeWebSocket.instances).toHaveLength(1);
|
||||
expect(FakeWebSocket.instances[0].url).not.toContain("channel=stale");
|
||||
});
|
||||
|
||||
it("leaves a settled ticket's socket to the CONNECTING timer", async () => {
|
||||
await renderChat();
|
||||
await advance(0);
|
||||
await vi.waitFor(() => expect(FakeWebSocket.instances).toHaveLength(1));
|
||||
|
||||
// NS-591 regression: once the socket exists the ticket deadline is
|
||||
// disarmed, so PTY_CONNECTING_TIMEOUT_MS stays the only thing that may
|
||||
// force-close a wedged handshake — the two must not both fire.
|
||||
await advance(PTY_TICKET_TIMEOUT_MS);
|
||||
expect(apiMocks.buildWsUrl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,679 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState, useMemo } from "react";
|
||||
import {
|
||||
Code,
|
||||
Download,
|
||||
FormInput,
|
||||
RotateCcw,
|
||||
Search,
|
||||
Upload,
|
||||
X,
|
||||
Settings2,
|
||||
FileText,
|
||||
Settings,
|
||||
Bot,
|
||||
Monitor,
|
||||
Palette,
|
||||
Users,
|
||||
Brain,
|
||||
Package,
|
||||
Lock,
|
||||
Globe,
|
||||
Mic,
|
||||
Volume2,
|
||||
Ear,
|
||||
ClipboardList,
|
||||
MessageCircle,
|
||||
Wrench,
|
||||
FileQuestion,
|
||||
Filter,
|
||||
Cloud,
|
||||
Sparkles,
|
||||
LayoutDashboard,
|
||||
BookOpen,
|
||||
Route,
|
||||
History,
|
||||
Shield,
|
||||
FileOutput,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import { api } from "@/lib/api";
|
||||
import { getNestedValue, setNestedValue } from "@/lib/nested";
|
||||
import { useToast } from "@nous-research/ui/hooks/use-toast";
|
||||
import { Toast } from "@nous-research/ui/ui/components/toast";
|
||||
import { AutoField } from "@/components/AutoField";
|
||||
import { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { ListItem } from "@nous-research/ui/ui/components/list-item";
|
||||
import { Spinner } from "@nous-research/ui/ui/components/spinner";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@nous-research/ui/ui/components/card";
|
||||
import { ConfirmDialog } from "@nous-research/ui/ui/components/confirm-dialog";
|
||||
import { Input } from "@nous-research/ui/ui/components/input";
|
||||
import { Badge } from "@nous-research/ui/ui/components/badge";
|
||||
import { useI18n } from "@/i18n";
|
||||
import { usePageHeader } from "@/contexts/usePageHeader";
|
||||
import { PluginSlot } from "@/plugins";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const CATEGORY_ICONS: Record<
|
||||
string,
|
||||
React.ComponentType<{ className?: string }>
|
||||
> = {
|
||||
general: Settings,
|
||||
agent: Bot,
|
||||
terminal: Monitor,
|
||||
display: Palette,
|
||||
delegation: Users,
|
||||
memory: Brain,
|
||||
compression: Package,
|
||||
security: Lock,
|
||||
browser: Globe,
|
||||
voice: Mic,
|
||||
tts: Volume2,
|
||||
stt: Ear,
|
||||
logging: ClipboardList,
|
||||
discord: MessageCircle,
|
||||
auxiliary: Wrench,
|
||||
bedrock: Cloud,
|
||||
curator: Sparkles,
|
||||
kanban: LayoutDashboard,
|
||||
model_catalog: BookOpen,
|
||||
openrouter: Route,
|
||||
sessions: History,
|
||||
tool_loop_guardrails: Shield,
|
||||
tool_output: FileOutput,
|
||||
updates: RefreshCw,
|
||||
};
|
||||
|
||||
function CategoryIcon({
|
||||
category,
|
||||
className,
|
||||
}: {
|
||||
category: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const Icon = CATEGORY_ICONS[category] ?? FileQuestion;
|
||||
return <Icon className={className ?? "h-4 w-4"} />;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Component */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export default function ConfigPage() {
|
||||
const [config, setConfig] = useState<Record<string, unknown> | null>(null);
|
||||
const [schema, setSchema] = useState<Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
> | null>(null);
|
||||
const [categoryOrder, setCategoryOrder] = useState<string[]>([]);
|
||||
const [defaults, setDefaults] = useState<Record<string, unknown> | null>(
|
||||
null,
|
||||
);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [yamlMode, setYamlMode] = useState(false);
|
||||
const [yamlText, setYamlText] = useState("");
|
||||
const [yamlLoading, setYamlLoading] = useState(false);
|
||||
const [yamlSaving, setYamlSaving] = useState(false);
|
||||
const [configPath, setConfigPath] = useState<string | null>(null);
|
||||
const [activeCategory, setActiveCategory] = useState<string>("");
|
||||
const [confirmReset, setConfirmReset] = useState(false);
|
||||
const { toast, showToast } = useToast();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { t } = useI18n();
|
||||
const { setEnd } = usePageHeader();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!config || !schema) {
|
||||
setEnd(null);
|
||||
return;
|
||||
}
|
||||
setEnd(
|
||||
<div className="relative w-full min-w-0 sm:max-w-xs">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
className="h-8 pl-8 pr-7 text-xs"
|
||||
placeholder={t.common.search}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<Button
|
||||
ghost
|
||||
size="xs"
|
||||
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setSearchQuery("")}
|
||||
aria-label={t.common.clear}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
)}
|
||||
</div>,
|
||||
);
|
||||
return () => setEnd(null);
|
||||
}, [config, schema, searchQuery, setEnd, t.common.clear, t.common.search]);
|
||||
|
||||
function prettyCategoryName(cat: string): string {
|
||||
const key = cat as keyof typeof t.config.categories;
|
||||
if (t.config.categories[key]) return t.config.categories[key];
|
||||
return cat.charAt(0).toUpperCase() + cat.slice(1);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.getConfig()
|
||||
.then(setConfig)
|
||||
.catch(() => {});
|
||||
api
|
||||
.getSchema()
|
||||
.then((resp) => {
|
||||
// memory.provider has a dedicated management UI on the Plugins page
|
||||
// (provider cards + guided setup/switch flow). Hide it from the
|
||||
// generic config form so the two surfaces don't fight; the schema
|
||||
// keeps the field for other consumers (Desktop settings).
|
||||
const fields = { ...resp.fields } as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>;
|
||||
delete fields["memory.provider"];
|
||||
setSchema(fields);
|
||||
setCategoryOrder(resp.category_order ?? []);
|
||||
})
|
||||
.catch(() => {});
|
||||
api
|
||||
.getDefaults()
|
||||
.then(setDefaults)
|
||||
.catch(() => {});
|
||||
// getConfigRaw is profile-scoped (fetchJSON appends ?profile=), so its
|
||||
// `path` reflects the switched profile's config.yaml. /api/status's
|
||||
// config_path is machine-global (the dashboard's own profile) — wrong
|
||||
// header under the global profile switcher, so it's only a fallback.
|
||||
api
|
||||
.getConfigRaw()
|
||||
.then((resp) => {
|
||||
if (resp.path) setConfigPath(resp.path);
|
||||
})
|
||||
.catch(() => {});
|
||||
api
|
||||
.getStatus()
|
||||
.then((resp) => setConfigPath((prev) => prev ?? resp.config_path))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Set active category when categories load
|
||||
useEffect(() => {
|
||||
if (categoryOrder.length > 0 && !activeCategory) {
|
||||
setActiveCategory(categoryOrder[0]);
|
||||
}
|
||||
}, [categoryOrder, activeCategory]);
|
||||
|
||||
// Load YAML when switching to YAML mode
|
||||
useEffect(() => {
|
||||
if (yamlMode) {
|
||||
setYamlLoading(true);
|
||||
api
|
||||
.getConfigRaw()
|
||||
.then((resp) => setYamlText(resp.yaml))
|
||||
.catch(() => showToast(t.config.failedToLoadRaw, "error"))
|
||||
.finally(() => setYamlLoading(false));
|
||||
}
|
||||
}, [yamlMode]);
|
||||
|
||||
/* ---- Categories ---- */
|
||||
const categories = useMemo(() => {
|
||||
if (!schema) return [];
|
||||
const allCats = [
|
||||
...new Set(
|
||||
Object.values(schema).map((s) => String(s.category ?? "general")),
|
||||
),
|
||||
];
|
||||
const ordered = categoryOrder.filter((c) => allCats.includes(c));
|
||||
const extra = allCats.filter((c) => !categoryOrder.includes(c)).sort();
|
||||
return [...ordered, ...extra];
|
||||
}, [schema, categoryOrder]);
|
||||
|
||||
/* ---- Category field counts ---- */
|
||||
const categoryCounts = useMemo(() => {
|
||||
if (!schema) return {};
|
||||
const counts: Record<string, number> = {};
|
||||
for (const s of Object.values(schema)) {
|
||||
const cat = String(s.category ?? "general");
|
||||
counts[cat] = (counts[cat] || 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}, [schema]);
|
||||
|
||||
/* ---- Search ---- */
|
||||
const isSearching = searchQuery.trim().length > 0;
|
||||
const lowerSearch = searchQuery.toLowerCase();
|
||||
|
||||
const searchMatchedFields = useMemo(() => {
|
||||
if (!isSearching || !schema) return [];
|
||||
return Object.entries(schema).filter(([key, s]) => {
|
||||
const label = key.split(".").pop() ?? key;
|
||||
const humanLabel = label.replace(/_/g, " ");
|
||||
return (
|
||||
key.toLowerCase().includes(lowerSearch) ||
|
||||
humanLabel.toLowerCase().includes(lowerSearch) ||
|
||||
String(s.category ?? "")
|
||||
.toLowerCase()
|
||||
.includes(lowerSearch) ||
|
||||
String(s.description ?? "")
|
||||
.toLowerCase()
|
||||
.includes(lowerSearch)
|
||||
);
|
||||
});
|
||||
}, [isSearching, lowerSearch, schema]);
|
||||
|
||||
/* ---- Active tab fields ---- */
|
||||
const activeFields = useMemo(() => {
|
||||
if (!schema || isSearching) return [];
|
||||
return Object.entries(schema).filter(
|
||||
([, s]) => String(s.category ?? "general") === activeCategory,
|
||||
);
|
||||
}, [schema, activeCategory, isSearching]);
|
||||
|
||||
/* ---- Handlers ---- */
|
||||
const handleSave = async () => {
|
||||
if (!config) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.saveConfig(config);
|
||||
showToast(t.config.configSaved, "success");
|
||||
} catch (e) {
|
||||
showToast(`${t.config.failedToSave}: ${e}`, "error");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleYamlSave = async () => {
|
||||
setYamlSaving(true);
|
||||
try {
|
||||
await api.saveConfigRaw(yamlText);
|
||||
showToast(t.config.yamlConfigSaved, "success");
|
||||
api
|
||||
.getConfig()
|
||||
.then(setConfig)
|
||||
.catch(() => {});
|
||||
} catch (e) {
|
||||
showToast(`${t.config.failedToSaveYaml}: ${e}`, "error");
|
||||
} finally {
|
||||
setYamlSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
if (!defaults || !config) return;
|
||||
// Scope the reset to what the user is currently looking at:
|
||||
// - search mode → the matched fields
|
||||
// - form mode → the active category's fields
|
||||
// Resetting the whole config here was a footgun (issue reported by @ykmfb001):
|
||||
// the button sits next to the category tabs and users reasonably assumed
|
||||
// "reset this tab", not "wipe my entire config.yaml".
|
||||
const scopedFields = isSearching ? searchMatchedFields : activeFields;
|
||||
if (scopedFields.length === 0) return;
|
||||
setConfirmReset(true);
|
||||
};
|
||||
|
||||
const executeReset = () => {
|
||||
if (!defaults || !config) return;
|
||||
setConfirmReset(false);
|
||||
const scopedFields = isSearching ? searchMatchedFields : activeFields;
|
||||
if (scopedFields.length === 0) return;
|
||||
const scopeLabel = isSearching
|
||||
? t.config.searchResults
|
||||
: prettyCategoryName(activeCategory);
|
||||
let next: Record<string, unknown> = config;
|
||||
for (const [key] of scopedFields) {
|
||||
next = setNestedValue(next, key, getNestedValue(defaults, key));
|
||||
}
|
||||
setConfig(next);
|
||||
showToast(
|
||||
t.config.resetScopeToast.replace("{scope}", scopeLabel),
|
||||
"success",
|
||||
);
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
if (!config) return;
|
||||
const blob = new Blob([JSON.stringify(config, null, 2)], {
|
||||
type: "application/json",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "hermes-config.json";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleImport = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
try {
|
||||
const imported = JSON.parse(reader.result as string);
|
||||
setConfig(imported);
|
||||
showToast(t.config.configImported, "success");
|
||||
} catch {
|
||||
showToast(t.config.invalidJson, "error");
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
/* ---- Loading ---- */
|
||||
if (!config || !schema) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<Spinner className="text-2xl text-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---- Render field list (shared between search & normal) ---- */
|
||||
const renderFields = (
|
||||
fields: [string, Record<string, unknown>][],
|
||||
showCategory = false,
|
||||
) => {
|
||||
let lastSection = "";
|
||||
let lastCat = "";
|
||||
return fields.map(([key, s]) => {
|
||||
const parts = key.split(".");
|
||||
const section = parts.length > 1 ? parts[0] : "";
|
||||
const cat = String(s.category ?? "general");
|
||||
const showCatBadge = showCategory && cat !== lastCat;
|
||||
const showSection =
|
||||
!showCategory &&
|
||||
section &&
|
||||
section !== lastSection &&
|
||||
section !== activeCategory;
|
||||
lastSection = section;
|
||||
lastCat = cat;
|
||||
|
||||
return (
|
||||
<div key={key}>
|
||||
{showCatBadge && (
|
||||
<div className="flex items-center gap-2 pt-4 pb-2 first:pt-0">
|
||||
<CategoryIcon
|
||||
category={cat}
|
||||
className="h-4 w-4 text-muted-foreground"
|
||||
/>
|
||||
<span className="font-mondwest text-display text-xs font-semibold tracking-wider text-muted-foreground">
|
||||
{prettyCategoryName(cat)}
|
||||
</span>
|
||||
<div className="flex-1 border-t border-border" />
|
||||
</div>
|
||||
)}
|
||||
{showSection && (
|
||||
<div className="flex items-center gap-2 pt-4 pb-2 first:pt-0">
|
||||
<span className="font-mondwest text-display text-xs font-semibold tracking-wider text-muted-foreground">
|
||||
{section.replace(/_/g, " ")}
|
||||
</span>
|
||||
<div className="flex-1 border-t border-border" />
|
||||
</div>
|
||||
)}
|
||||
<div className="py-1">
|
||||
<AutoField
|
||||
schemaKey={key}
|
||||
schema={s}
|
||||
value={getNestedValue(config, key)}
|
||||
onChange={(v) => setConfig(setNestedValue(config, key, v))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<PluginSlot name="config:top" />
|
||||
<Toast toast={toast} />
|
||||
|
||||
<div className="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="flex min-w-0 items-center gap-2 sm:flex-1">
|
||||
<Settings2 className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<code className="min-w-0 flex-1 break-words text-xs text-muted-foreground bg-muted/50 px-2 py-0.5">
|
||||
{configPath ?? t.config.configPath}
|
||||
</code>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1.5 sm:shrink-0">
|
||||
<Button
|
||||
ghost
|
||||
size="icon"
|
||||
onClick={handleExport}
|
||||
title={t.config.exportConfig}
|
||||
aria-label={t.config.exportConfig}
|
||||
>
|
||||
<Download />
|
||||
</Button>
|
||||
<Button
|
||||
ghost
|
||||
size="icon"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
title={t.config.importConfig}
|
||||
aria-label={t.config.importConfig}
|
||||
>
|
||||
<Upload />
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json"
|
||||
className="hidden"
|
||||
onChange={handleImport}
|
||||
/>
|
||||
{!yamlMode &&
|
||||
(() => {
|
||||
const resetScopeLabel = isSearching
|
||||
? t.config.searchResults
|
||||
: prettyCategoryName(activeCategory);
|
||||
const resetTitle = t.config.resetScopeTooltip.replace(
|
||||
"{scope}",
|
||||
resetScopeLabel,
|
||||
);
|
||||
return (
|
||||
<Button
|
||||
ghost
|
||||
size="icon"
|
||||
onClick={handleReset}
|
||||
title={resetTitle}
|
||||
aria-label={resetTitle}
|
||||
>
|
||||
<RotateCcw />
|
||||
</Button>
|
||||
);
|
||||
})()}
|
||||
|
||||
<div className="w-px h-5 bg-border mx-1" />
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
outlined={!yamlMode}
|
||||
onClick={() => setYamlMode(!yamlMode)}
|
||||
prefix={yamlMode ? <FormInput /> : <Code />}
|
||||
>
|
||||
{yamlMode ? t.common.form : "YAML"}
|
||||
</Button>
|
||||
|
||||
{yamlMode ? (
|
||||
<Button
|
||||
size="sm"
|
||||
className="uppercase"
|
||||
onClick={handleYamlSave}
|
||||
disabled={yamlSaving}
|
||||
>
|
||||
{yamlSaving ? t.common.saving : t.common.save}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
className="uppercase"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? t.common.saving : t.common.save}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{yamlMode ? (
|
||||
<Card>
|
||||
<CardHeader className="py-3 px-4">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<FileText className="h-4 w-4" />
|
||||
{t.config.rawYaml}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{yamlLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Spinner className="text-xl text-primary" />
|
||||
</div>
|
||||
) : (
|
||||
<textarea
|
||||
className="flex min-h-[600px] w-full bg-transparent px-4 py-3 text-sm font-mono leading-relaxed placeholder:text-muted-foreground focus-visible:outline-none border-t border-border"
|
||||
value={yamlText}
|
||||
onChange={(e) => setYamlText(e.target.value)}
|
||||
spellCheck={false}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<aside aria-label={t.config.filters} className="sm:w-56 sm:shrink-0">
|
||||
<div className="sm:sticky sm:top-4">
|
||||
<div className="flex flex-col border border-border bg-muted/20">
|
||||
<div className="hidden sm:flex items-center gap-2 px-3 py-2 border-b border-border">
|
||||
<Filter className="h-3 w-3 text-text-tertiary" />
|
||||
<span className="font-mondwest text-display text-xs tracking-[0.12em] text-text-secondary">
|
||||
{t.config.filters}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="hidden sm:block px-3 pt-2 pb-1 font-mondwest text-display text-xs tracking-[0.12em] text-text-tertiary">
|
||||
{t.config.sections}
|
||||
</div>
|
||||
|
||||
<div className="flex sm:flex-col gap-1 sm:gap-px p-2 sm:pt-1 overflow-x-auto sm:overflow-x-visible scrollbar-none sm:max-h-[calc(100vh-260px)] sm:overflow-y-auto">
|
||||
{categories.map((cat) => {
|
||||
const isActive = !isSearching && activeCategory === cat;
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
key={cat}
|
||||
active={isActive}
|
||||
onClick={() => {
|
||||
setSearchQuery("");
|
||||
setActiveCategory(cat);
|
||||
}}
|
||||
className="rounded-none whitespace-nowrap px-2 py-1 text-xs"
|
||||
>
|
||||
<CategoryIcon
|
||||
category={cat}
|
||||
className="h-3.5 w-3.5 shrink-0"
|
||||
/>
|
||||
<span className="flex-1 truncate">
|
||||
{prettyCategoryName(cat)}
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs tabular-nums ${
|
||||
isActive
|
||||
? "text-text-secondary"
|
||||
: "text-text-tertiary"
|
||||
}`}
|
||||
>
|
||||
{categoryCounts[cat] || 0}
|
||||
</span>
|
||||
</ListItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
{isSearching ? (
|
||||
<Card>
|
||||
<CardHeader className="py-3 px-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<Search className="h-4 w-4" />
|
||||
{t.config.searchResults}
|
||||
</CardTitle>
|
||||
<Badge tone="secondary" className="text-xs">
|
||||
{searchMatchedFields.length}{" "}
|
||||
{t.config.fields.replace(
|
||||
"{s}",
|
||||
searchMatchedFields.length !== 1 ? "s" : "",
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-2 px-4 pb-4">
|
||||
{searchMatchedFields.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
{t.config.noFieldsMatch.replace("{query}", searchQuery)}
|
||||
</p>
|
||||
) : (
|
||||
renderFields(searchMatchedFields, true)
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
/* Active category */
|
||||
<Card>
|
||||
<CardHeader className="py-3 px-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<CategoryIcon
|
||||
category={activeCategory}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
{prettyCategoryName(activeCategory)}
|
||||
</CardTitle>
|
||||
<Badge tone="secondary" className="text-xs">
|
||||
{activeFields.length}{" "}
|
||||
{t.config.fields.replace(
|
||||
"{s}",
|
||||
activeFields.length !== 1 ? "s" : "",
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-2 px-4 pb-4">
|
||||
{renderFields(activeFields)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<PluginSlot name="config:bottom" />
|
||||
<ConfirmDialog
|
||||
open={confirmReset}
|
||||
onCancel={() => setConfirmReset(false)}
|
||||
onConfirm={executeReset}
|
||||
title={t.config.confirmResetScope.replace(
|
||||
"{scope}",
|
||||
isSearching
|
||||
? t.config.searchResults
|
||||
: prettyCategoryName(activeCategory),
|
||||
)}
|
||||
description={`This will reset ${
|
||||
(isSearching ? searchMatchedFields : activeFields).length
|
||||
} field(s) to their default values.`}
|
||||
destructive
|
||||
confirmLabel={t.config.resetDefaults}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,69 @@
|
||||
import { useLayoutEffect } from "react";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { useI18n } from "@/i18n";
|
||||
import { usePageHeader } from "@/contexts/usePageHeader";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PluginSlot } from "@/plugins";
|
||||
|
||||
export const HERMES_DOCS_URL = "https://hermes-agent.nousresearch.com/docs/";
|
||||
|
||||
const DS_BUTTON_OUTLINED_LINK_CN = cn(
|
||||
"group relative inline-grid grid-cols-[auto_1fr_auto] items-center",
|
||||
"px-[.9em_.75em] py-[1.25em] gap-2",
|
||||
"leading-0 font-bold tracking-[0.2em] uppercase",
|
||||
"text-midground bg-transparent shadow-midground",
|
||||
"shadow-[inset_-1px_-1px_0_0_#00000080,inset_1px_1px_0_0_#ffffff80]",
|
||||
);
|
||||
|
||||
export default function DocsPage() {
|
||||
const { t } = useI18n();
|
||||
const { setEnd } = usePageHeader();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
setEnd(
|
||||
<a
|
||||
href={HERMES_DOCS_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={DS_BUTTON_OUTLINED_LINK_CN}
|
||||
>
|
||||
<ExternalLink className="size-3.5" />
|
||||
{t.app.openDocumentation}
|
||||
</a>,
|
||||
);
|
||||
return () => {
|
||||
setEnd(null);
|
||||
};
|
||||
}, [setEnd, t]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-0 w-full min-w-0 flex-1 flex-col",
|
||||
"pt-1 sm:pt-2",
|
||||
)}
|
||||
>
|
||||
<PluginSlot name="docs:top" />
|
||||
<iframe
|
||||
title={t.app.nav.documentation}
|
||||
src={HERMES_DOCS_URL}
|
||||
className={cn(
|
||||
"min-h-0 w-full min-w-0 flex-1",
|
||||
"rounded-sm border border-current/20",
|
||||
// Docusaurus paints over a transparent <html> / <body> and
|
||||
// relies on the browser's canvas color (light by default) to
|
||||
// fill the viewport. Inheriting the dashboard's dark color
|
||||
// scheme makes that canvas dark, so the docs body text — which
|
||||
// is tuned for a light canvas — becomes near-invisible. Force a
|
||||
// light color scheme + white background on the iframe element so
|
||||
// the docs render cleanly regardless of the active dashboard
|
||||
// theme or the user's prefers-color-scheme.
|
||||
"[color-scheme:light] bg-white",
|
||||
)}
|
||||
sandbox="allow-scripts allow-same-origin allow-popups allow-forms"
|
||||
referrerPolicy="no-referrer-when-downgrade"
|
||||
/>
|
||||
<PluginSlot name="docs:bottom" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,525 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type DragEvent as ReactDragEvent,
|
||||
} from "react";
|
||||
import {
|
||||
ArrowUp,
|
||||
Download,
|
||||
FileIcon,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
FolderPlus,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@nous-research/ui/ui/components/badge";
|
||||
import { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { Card, CardContent } from "@nous-research/ui/ui/components/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@nous-research/ui/ui/components/dialog";
|
||||
import { Input } from "@nous-research/ui/ui/components/input";
|
||||
import { Spinner } from "@nous-research/ui/ui/components/spinner";
|
||||
import { Toast } from "@nous-research/ui/ui/components/toast";
|
||||
import { useToast } from "@nous-research/ui/hooks/use-toast";
|
||||
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
|
||||
import { usePageHeader } from "@/contexts/usePageHeader";
|
||||
import { api } from "@/lib/api";
|
||||
import type { ManagedFileEntry, ManagedFilesResponse } from "@/lib/api";
|
||||
import { PluginSlot } from "@/plugins";
|
||||
|
||||
const DATE_FORMAT = new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
});
|
||||
|
||||
function joinPath(base: string, name: string): string {
|
||||
const cleanName = name.trim().replace(/^[\\/]+/, "");
|
||||
if (!cleanName) return base;
|
||||
const separator = base.includes("\\") && !base.includes("/") ? "\\" : "/";
|
||||
if (!base || base.endsWith("/") || base.endsWith("\\")) return `${base}${cleanName}`;
|
||||
return `${base}${separator}${cleanName}`;
|
||||
}
|
||||
|
||||
function formatBytes(size: number | null): string {
|
||||
if (size === null) return "-";
|
||||
if (size < 1024) return `${size} B`;
|
||||
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
|
||||
if (size < 1024 * 1024 * 1024) return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(size / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||
}
|
||||
|
||||
function downloadDataUrl(dataUrl: string, name: string) {
|
||||
const link = document.createElement("a");
|
||||
link.href = dataUrl;
|
||||
link.download = name || "download";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
}
|
||||
|
||||
function displayPath(path: string | null | undefined): string {
|
||||
return path?.trim() || "Files";
|
||||
}
|
||||
|
||||
function transferHasFiles(event: ReactDragEvent<HTMLElement>): boolean {
|
||||
return Array.from(event.dataTransfer.types).includes("Files");
|
||||
}
|
||||
|
||||
export default function FilesPage() {
|
||||
const { toast, showToast } = useToast();
|
||||
const { setAfterTitle, setEnd } = usePageHeader();
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const dragDepthRef = useRef(0);
|
||||
const [currentPath, setCurrentPath] = useState<string | undefined>(undefined);
|
||||
const [pathInput, setPathInput] = useState("");
|
||||
const [listing, setListing] = useState<ManagedFilesResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [draggingFiles, setDraggingFiles] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [folderName, setFolderName] = useState("");
|
||||
const [pendingDelete, setPendingDelete] = useState<ManagedFileEntry | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const activePath = listing?.path ?? currentPath ?? "";
|
||||
const canChangePath = listing?.can_change_path ?? false;
|
||||
const canUpload = Boolean(activePath) && !uploading;
|
||||
const headerPath = displayPath(listing?.locked_root ?? listing?.path ?? currentPath);
|
||||
|
||||
const load = useCallback(
|
||||
async (path = currentPath) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await api.listFiles(path);
|
||||
setListing(result);
|
||||
setCurrentPath(result.path);
|
||||
setPathInput(result.path);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[currentPath],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Existing dashboard data pages fetch from effects; keep this local and explicit
|
||||
// until the shared lint profile is updated for async page loaders.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
void load(currentPath);
|
||||
}, [currentPath]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
setAfterTitle(
|
||||
<Badge tone="outline" className="max-w-[22rem] truncate text-xs" title={headerPath}>
|
||||
{headerPath}
|
||||
</Badge>,
|
||||
);
|
||||
setEnd(
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
ghost
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() => void load()}
|
||||
disabled={loading}
|
||||
aria-label="Refresh files"
|
||||
>
|
||||
{loading ? <Spinner /> : <RefreshCw />}
|
||||
</Button>
|
||||
</div>,
|
||||
);
|
||||
return () => {
|
||||
setAfterTitle(null);
|
||||
setEnd(null);
|
||||
};
|
||||
}, [headerPath, load, loading, setAfterTitle, setEnd]);
|
||||
|
||||
const openDirectory = (entry: ManagedFileEntry) => {
|
||||
if (entry.is_directory) {
|
||||
setCurrentPath(entry.path);
|
||||
}
|
||||
};
|
||||
|
||||
const goToPath = async () => {
|
||||
const nextPath = pathInput.trim();
|
||||
if (!nextPath) {
|
||||
showToast("Path required", "error");
|
||||
return;
|
||||
}
|
||||
await load(nextPath);
|
||||
};
|
||||
|
||||
const createDirectory = async () => {
|
||||
const name = folderName.trim();
|
||||
if (!activePath) {
|
||||
showToast("Directory unavailable", "error");
|
||||
return;
|
||||
}
|
||||
if (!name) {
|
||||
showToast("Folder name required", "error");
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
try {
|
||||
await api.createDirectory(joinPath(activePath, name));
|
||||
setFolderName("");
|
||||
setCreateDialogOpen(false);
|
||||
showToast("Folder created", "success");
|
||||
await load();
|
||||
} catch (e) {
|
||||
showToast(`Create failed: ${e}`, "error");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const uploadFiles = async (files: FileList | null) => {
|
||||
if (!files?.length) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
for (const file of Array.from(files)) {
|
||||
await api.uploadFile(joinPath(activePath, file.name), file, true);
|
||||
}
|
||||
showToast(`${files.length} file${files.length === 1 ? "" : "s"} uploaded`, "success");
|
||||
await load();
|
||||
} catch (e) {
|
||||
showToast(`Upload failed: ${e}`, "error");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragEnter = (event: ReactDragEvent<HTMLElement>) => {
|
||||
if (!canUpload || !transferHasFiles(event)) return;
|
||||
event.preventDefault();
|
||||
dragDepthRef.current += 1;
|
||||
setDraggingFiles(true);
|
||||
};
|
||||
|
||||
const handleDragOver = (event: ReactDragEvent<HTMLElement>) => {
|
||||
if (!canUpload || !transferHasFiles(event)) return;
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "copy";
|
||||
};
|
||||
|
||||
const handleDragLeave = (event: ReactDragEvent<HTMLElement>) => {
|
||||
if (!canUpload || !transferHasFiles(event)) return;
|
||||
event.preventDefault();
|
||||
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
|
||||
if (dragDepthRef.current === 0) {
|
||||
setDraggingFiles(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (event: ReactDragEvent<HTMLElement>) => {
|
||||
if (!canUpload) return;
|
||||
event.preventDefault();
|
||||
dragDepthRef.current = 0;
|
||||
setDraggingFiles(false);
|
||||
void uploadFiles(event.dataTransfer.files);
|
||||
};
|
||||
|
||||
const downloadFile = async (entry: ManagedFileEntry) => {
|
||||
if (entry.is_directory) return;
|
||||
try {
|
||||
const file = await api.readFile(entry.path);
|
||||
downloadDataUrl(file.data_url, file.name);
|
||||
} catch (e) {
|
||||
showToast(`Download failed: ${e}`, "error");
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!pendingDelete) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await api.deleteFile(pendingDelete.path, pendingDelete.is_directory);
|
||||
showToast("Deleted", "success");
|
||||
setPendingDelete(null);
|
||||
await load();
|
||||
} catch (e) {
|
||||
showToast(`Delete failed: ${e}`, "error");
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 max-w-full flex-col gap-4">
|
||||
<Toast toast={toast} />
|
||||
<PluginSlot name="files:top" />
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(event) => void uploadFiles(event.currentTarget.files)}
|
||||
/>
|
||||
|
||||
<div className="flex min-w-0 flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
|
||||
{canChangePath ? (
|
||||
<form
|
||||
className="flex min-w-0 flex-1 items-center gap-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void goToPath();
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
value={pathInput}
|
||||
onChange={(event) => setPathInput(event.target.value)}
|
||||
aria-label="Path"
|
||||
placeholder="Path"
|
||||
className="h-9 min-w-0 flex-1 font-mono"
|
||||
/>
|
||||
<Button type="submit" size="sm" outlined className="uppercase">
|
||||
Go
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<div className="min-w-0 truncate font-mono text-sm text-text-secondary" title={activePath}>
|
||||
{activePath}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={!canUpload}
|
||||
size="sm"
|
||||
outlined
|
||||
className="uppercase"
|
||||
prefix={uploading ? <Spinner /> : <Upload />}
|
||||
>
|
||||
Upload
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setCreateDialogOpen(true)}
|
||||
disabled={!activePath}
|
||||
size="sm"
|
||||
outlined
|
||||
className="uppercase"
|
||||
prefix={<FolderPlus />}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => canUpload && fileInputRef.current?.click()}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
disabled={!canUpload}
|
||||
aria-label="Upload files"
|
||||
className={`flex min-h-20 w-full min-w-0 items-center justify-between gap-4 border border-dashed px-4 py-3 text-left transition ${
|
||||
draggingFiles
|
||||
? "border-primary bg-primary/10 text-foreground"
|
||||
: "border-border bg-background/20 text-text-secondary hover:border-text-tertiary hover:bg-background/35"
|
||||
} disabled:cursor-not-allowed disabled:opacity-60`}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-3">
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center border border-border bg-background/45 text-text-tertiary">
|
||||
{uploading ? <Spinner /> : <Upload className="h-4 w-4" />}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-semibold uppercase tracking-[0.08em] text-foreground">
|
||||
{uploading ? "Uploading" : draggingFiles ? "Release to upload" : "Drop files here"}
|
||||
</span>
|
||||
<span className="block truncate font-mono text-xs text-text-secondary" title={activePath}>
|
||||
{activePath || "Loading"}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="hidden shrink-0 text-xs font-semibold uppercase tracking-[0.08em] text-text-tertiary sm:block">
|
||||
Choose files
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<Card className="min-w-0 max-w-full overflow-hidden">
|
||||
<CardContent className="overflow-x-auto p-0">
|
||||
{error && (
|
||||
<div className="border-b border-destructive/20 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid min-w-[42rem] grid-cols-[minmax(12rem,1fr)_7rem_10rem_5.5rem] items-center gap-3 border-b border-border px-4 py-2 text-xs font-semibold uppercase tracking-[0.08em] text-text-tertiary">
|
||||
<span>Name</span>
|
||||
<span>Size</span>
|
||||
<span>Modified</span>
|
||||
<span className="text-right">Actions</span>
|
||||
</div>
|
||||
|
||||
{listing?.parent && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentPath(listing.parent ?? undefined)}
|
||||
className="grid w-full min-w-[42rem] grid-cols-[minmax(12rem,1fr)_7rem_10rem_5.5rem] items-center gap-3 border-b border-border/60 px-4 py-2 text-left text-sm transition hover:bg-background/40"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2 font-mono text-text-secondary">
|
||||
<ArrowUp className="h-4 w-4 shrink-0 text-text-tertiary" />
|
||||
..
|
||||
</span>
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{loading && !listing ? (
|
||||
<div className="flex items-center justify-center gap-2 py-12 text-sm text-muted-foreground">
|
||||
<Spinner />
|
||||
Loading files...
|
||||
</div>
|
||||
) : listing && listing.entries.length === 0 ? (
|
||||
<div className="py-12 text-center text-sm text-muted-foreground">No files</div>
|
||||
) : (
|
||||
listing?.entries.map((entry) => (
|
||||
<div
|
||||
key={entry.path}
|
||||
className="grid min-w-[42rem] grid-cols-[minmax(12rem,1fr)_7rem_10rem_5.5rem] items-center gap-3 border-b border-border/60 px-4 py-2 text-sm last:border-b-0 hover:bg-background/35"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => (entry.is_directory ? openDirectory(entry) : void downloadFile(entry))}
|
||||
className="flex min-w-0 items-center gap-2 text-left font-mono text-foreground"
|
||||
>
|
||||
{entry.is_directory ? (
|
||||
<Folder className="h-4 w-4 shrink-0 text-warning" />
|
||||
) : (
|
||||
<FileIcon className="h-4 w-4 shrink-0 text-text-tertiary" />
|
||||
)}
|
||||
<span className="truncate">{entry.name}</span>
|
||||
</button>
|
||||
<span className="text-xs tabular-nums text-text-secondary">{formatBytes(entry.size)}</span>
|
||||
<span className="truncate text-xs text-text-secondary">
|
||||
{Number.isFinite(entry.mtime) ? DATE_FORMAT.format(entry.mtime * 1000) : "-"}
|
||||
</span>
|
||||
<span className="flex justify-end gap-1">
|
||||
{entry.is_directory ? (
|
||||
<Button
|
||||
ghost
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() => openDirectory(entry)}
|
||||
aria-label={`Open ${entry.name}`}
|
||||
>
|
||||
<FolderOpen />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
ghost
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() => void downloadFile(entry)}
|
||||
aria-label={`Download ${entry.name}`}
|
||||
>
|
||||
<Download />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
ghost
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() => setPendingDelete(entry)}
|
||||
aria-label={`Delete ${entry.name}`}
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<PluginSlot name="files:bottom" />
|
||||
|
||||
<Dialog
|
||||
open={createDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (creating) return;
|
||||
setCreateDialogOpen(open);
|
||||
if (!open) setFolderName("");
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create folder</DialogTitle>
|
||||
<DialogDescription>
|
||||
Target: {activePath || "Loading"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="p-4">
|
||||
<Input
|
||||
autoFocus
|
||||
value={folderName}
|
||||
onChange={(event) => setFolderName(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") void createDirectory();
|
||||
}}
|
||||
placeholder="Folder name"
|
||||
disabled={creating}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
outlined
|
||||
onClick={() => {
|
||||
setCreateDialogOpen(false);
|
||||
setFolderName("");
|
||||
}}
|
||||
disabled={creating}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void createDirectory()}
|
||||
disabled={creating}
|
||||
prefix={creating ? <Spinner /> : <FolderPlus />}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<DeleteConfirmDialog
|
||||
open={Boolean(pendingDelete)}
|
||||
loading={deleting}
|
||||
onCancel={() => setPendingDelete(null)}
|
||||
onConfirm={() => void confirmDelete()}
|
||||
title={pendingDelete ? `Delete ${pendingDelete.name}?` : "Delete item?"}
|
||||
description={
|
||||
pendingDelete?.is_directory
|
||||
? "This removes the folder and everything inside it."
|
||||
: "This removes the file."
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import {
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useState,
|
||||
useCallback,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { FileText, RefreshCw } from "lucide-react";
|
||||
import { api } from "@/lib/api";
|
||||
import { Badge } from "@nous-research/ui/ui/components/badge";
|
||||
import { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { FilterGroup, Segmented } from "@nous-research/ui/ui/components/segmented";
|
||||
import { Spinner } from "@nous-research/ui/ui/components/spinner";
|
||||
import { Switch } from "@nous-research/ui/ui/components/switch";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@nous-research/ui/ui/components/card";
|
||||
import { Label } from "@nous-research/ui/ui/components/label";
|
||||
import { useI18n } from "@/i18n";
|
||||
import { usePageHeader } from "@/contexts/usePageHeader";
|
||||
import { PluginSlot } from "@/plugins";
|
||||
// Level classification is unit-tested in @/lib/log-classify; it prefers the
|
||||
// structured level token and falls back to word-boundary matching so payload
|
||||
// text like "parse_errors=0" can't render an INFO line red.
|
||||
import { classifyLine } from "@/lib/log-classify";
|
||||
|
||||
const FILES = ["agent", "errors", "gateway"] as const;
|
||||
const LEVELS = ["ALL", "DEBUG", "INFO", "WARNING", "ERROR"] as const;
|
||||
const COMPONENTS = ["all", "gateway", "agent", "tools", "cli", "cron"] as const;
|
||||
const LINE_COUNTS = [50, 100, 200, 500] as const;
|
||||
|
||||
const LINE_COLORS: Record<string, string> = {
|
||||
error: "text-destructive",
|
||||
warning: "text-warning",
|
||||
info: "text-foreground",
|
||||
debug: "text-text-tertiary",
|
||||
};
|
||||
|
||||
const formatFilterLabel = (value: string) => value.toUpperCase();
|
||||
|
||||
const toSegmentOptions = <T extends string>(values: readonly T[]) =>
|
||||
values.map((v) => ({ value: v, label: formatFilterLabel(v) }));
|
||||
|
||||
const filterGroupClass =
|
||||
"flex min-w-0 w-full flex-col items-start gap-1.5 sm:w-auto sm:max-w-full sm:flex-row sm:items-center";
|
||||
|
||||
const segmentedClass =
|
||||
"w-fit max-w-full flex-wrap justify-start self-start";
|
||||
|
||||
export default function LogsPage() {
|
||||
const [file, setFile] = useState<(typeof FILES)[number]>("agent");
|
||||
const [level, setLevel] = useState<(typeof LEVELS)[number]>("ALL");
|
||||
const [component, setComponent] =
|
||||
useState<(typeof COMPONENTS)[number]>("all");
|
||||
const [lineCount, setLineCount] = useState<(typeof LINE_COUNTS)[number]>(100);
|
||||
const [autoRefresh, setAutoRefresh] = useState(false);
|
||||
const [lines, setLines] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const { t } = useI18n();
|
||||
const { setAfterTitle, setEnd } = usePageHeader();
|
||||
|
||||
const fetchLogs = useCallback(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
api
|
||||
.getLogs({ file, lines: lineCount, level, component })
|
||||
.then((resp) => {
|
||||
setLines(resp.lines);
|
||||
setTimeout(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, 50);
|
||||
})
|
||||
.catch((err) => setError(String(err)))
|
||||
.finally(() => setLoading(false));
|
||||
}, [file, lineCount, level, component]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
setAfterTitle(
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Badge tone="secondary" className="text-xs">
|
||||
{formatFilterLabel(file)} · {formatFilterLabel(level)} ·{" "}
|
||||
{formatFilterLabel(component)}
|
||||
</Badge>
|
||||
<Button
|
||||
type="button"
|
||||
ghost
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={fetchLogs}
|
||||
disabled={loading}
|
||||
aria-label={t.common.refresh}
|
||||
>
|
||||
{loading ? <Spinner /> : <RefreshCw />}
|
||||
</Button>
|
||||
</span>,
|
||||
);
|
||||
setEnd(
|
||||
<div className="flex w-full min-w-0 flex-wrap items-center justify-start gap-2 sm:justify-end sm:gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="logs-auto-refresh" className="text-xs cursor-pointer">
|
||||
{t.logs.autoRefresh}
|
||||
</Label>
|
||||
<Switch
|
||||
checked={autoRefresh}
|
||||
onCheckedChange={setAutoRefresh}
|
||||
id="logs-auto-refresh"
|
||||
/>
|
||||
{autoRefresh && (
|
||||
<Badge tone="success" className="text-xs">
|
||||
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
|
||||
{t.common.live}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
);
|
||||
return () => {
|
||||
setAfterTitle(null);
|
||||
setEnd(null);
|
||||
};
|
||||
}, [
|
||||
autoRefresh,
|
||||
component,
|
||||
file,
|
||||
level,
|
||||
loading,
|
||||
setAfterTitle,
|
||||
setEnd,
|
||||
t.common.live,
|
||||
t.common.refresh,
|
||||
t.logs.autoRefresh,
|
||||
fetchLogs,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogs();
|
||||
}, [fetchLogs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoRefresh) return;
|
||||
const interval = setInterval(fetchLogs, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, [autoRefresh, fetchLogs]);
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 max-w-full flex-col gap-4">
|
||||
<PluginSlot name="logs:top" />
|
||||
<div
|
||||
role="toolbar"
|
||||
aria-label={t.logs.title}
|
||||
className="flex min-w-0 max-w-full flex-col items-start gap-3 sm:flex-row sm:flex-wrap sm:items-start sm:gap-x-6 sm:gap-y-3"
|
||||
>
|
||||
<FilterGroup label={t.logs.file} className={filterGroupClass}>
|
||||
<Segmented
|
||||
className={segmentedClass}
|
||||
value={file}
|
||||
onChange={setFile}
|
||||
options={toSegmentOptions(FILES)}
|
||||
/>
|
||||
</FilterGroup>
|
||||
|
||||
<FilterGroup label={t.logs.level} className={filterGroupClass}>
|
||||
<Segmented
|
||||
className={segmentedClass}
|
||||
value={level}
|
||||
onChange={setLevel}
|
||||
options={toSegmentOptions(LEVELS)}
|
||||
/>
|
||||
</FilterGroup>
|
||||
|
||||
<FilterGroup label={t.logs.component} className={filterGroupClass}>
|
||||
<Segmented
|
||||
className={segmentedClass}
|
||||
value={component}
|
||||
onChange={setComponent}
|
||||
options={toSegmentOptions(COMPONENTS)}
|
||||
/>
|
||||
</FilterGroup>
|
||||
|
||||
<FilterGroup label={t.logs.lines} className={filterGroupClass}>
|
||||
<Segmented
|
||||
className={segmentedClass}
|
||||
value={String(lineCount)}
|
||||
onChange={(v) =>
|
||||
setLineCount(Number(v) as (typeof LINE_COUNTS)[number])
|
||||
}
|
||||
options={LINE_COUNTS.map((n) => ({
|
||||
value: String(n),
|
||||
label: String(n),
|
||||
}))}
|
||||
/>
|
||||
</FilterGroup>
|
||||
</div>
|
||||
|
||||
<Card className="min-w-0 max-w-full overflow-hidden">
|
||||
<CardHeader className="py-3 px-4">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<FileText className="h-4 w-4" />
|
||||
{file}.log
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{error && (
|
||||
<div className="bg-destructive/10 border-b border-destructive/20 p-3">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="max-w-full min-h-[400px] max-h-[calc(100vh-220px)] overflow-auto p-4 font-mono-ui text-xs leading-5 break-words"
|
||||
>
|
||||
{lines.length === 0 && !loading && (
|
||||
<p className="text-muted-foreground text-center py-8">
|
||||
{t.logs.noLogLines}
|
||||
</p>
|
||||
)}
|
||||
{lines.map((line, i) => {
|
||||
const cls = classifyLine(line);
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`${LINE_COLORS[cls]} hover:bg-secondary/20 px-1 -mx-1`}
|
||||
>
|
||||
{line}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<PluginSlot name="logs:bottom" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,902 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useState } from "react";
|
||||
import { KeyRound, Package, Power, Server, Trash2, X, Zap } from "lucide-react";
|
||||
import { Badge } from "@nous-research/ui/ui/components/badge";
|
||||
import { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
|
||||
import { Spinner } from "@nous-research/ui/ui/components/spinner";
|
||||
import { H2 } from "@nous-research/ui/ui/components/typography/h2";
|
||||
import { api } from "@/lib/api";
|
||||
import type {
|
||||
McpCatalogDiagnostic,
|
||||
McpCatalogEntry,
|
||||
McpHttpAuth,
|
||||
McpServer,
|
||||
McpTestResult,
|
||||
} from "@/lib/api";
|
||||
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
|
||||
import { useToast } from "@nous-research/ui/hooks/use-toast";
|
||||
import { useConfirmDelete } from "@nous-research/ui/hooks/use-confirm-delete";
|
||||
import { useModalBehavior } from "@/hooks/useModalBehavior";
|
||||
import { Toast } from "@nous-research/ui/ui/components/toast";
|
||||
import { Card, CardContent } from "@nous-research/ui/ui/components/card";
|
||||
import { Input } from "@nous-research/ui/ui/components/input";
|
||||
import { Label } from "@nous-research/ui/ui/components/label";
|
||||
import { usePageHeader } from "@/contexts/usePageHeader";
|
||||
import { cn, themedBody } from "@/lib/utils";
|
||||
import {
|
||||
buildMcpServerCreate,
|
||||
type McpTransport,
|
||||
} from "@/lib/mcp-server-create";
|
||||
import { completeMcpDashboardOAuth } from "@/lib/mcp-dashboard-oauth";
|
||||
|
||||
function isHttpUrl(value: string): boolean {
|
||||
return /^https?:\/\//i.test(value.trim());
|
||||
}
|
||||
|
||||
function truncateText(value: string, maxLength: number): string {
|
||||
return value.length > maxLength ? value.slice(0, maxLength) + "..." : value;
|
||||
}
|
||||
|
||||
const TRANSPORT_TONE: Record<string, "success" | "warning" | "secondary"> = {
|
||||
http: "success",
|
||||
stdio: "warning",
|
||||
unknown: "secondary",
|
||||
};
|
||||
|
||||
export default function McpPage() {
|
||||
const [servers, setServers] = useState<McpServer[]>([]);
|
||||
const [catalog, setCatalog] = useState<McpCatalogEntry[]>([]);
|
||||
const [diagnostics, setDiagnostics] = useState<McpCatalogDiagnostic[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { toast, showToast } = useToast();
|
||||
const { setEnd } = usePageHeader();
|
||||
|
||||
// Add server modal state
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [transport, setTransport] = useState<McpTransport>("http");
|
||||
const [url, setUrl] = useState("");
|
||||
const [httpAuth, setHttpAuth] = useState<McpHttpAuth>("none");
|
||||
const [bearerToken, setBearerToken] = useState("");
|
||||
const [command, setCommand] = useState("");
|
||||
const [args, setArgs] = useState("");
|
||||
const [env, setEnv] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
const closeCreateModal = useCallback(() => {
|
||||
setBearerToken("");
|
||||
setCreateModalOpen(false);
|
||||
}, []);
|
||||
const createModalRef = useModalBehavior({
|
||||
open: createModalOpen,
|
||||
onClose: closeCreateModal,
|
||||
});
|
||||
|
||||
// Test results keyed by server name
|
||||
const [testing, setTesting] = useState<string | null>(null);
|
||||
const [authenticating, setAuthenticating] = useState<string | null>(null);
|
||||
const [testResults, setTestResults] = useState<Record<string, McpTestResult>>(
|
||||
{},
|
||||
);
|
||||
|
||||
// Enable/disable state
|
||||
const [togglingName, setTogglingName] = useState<string | null>(null);
|
||||
const [restartNote, setRestartNote] = useState<string | null>(null);
|
||||
|
||||
// Catalog install modal state
|
||||
const [installEntry, setInstallEntry] = useState<McpCatalogEntry | null>(
|
||||
null,
|
||||
);
|
||||
const [installEnv, setInstallEnv] = useState<Record<string, string>>({});
|
||||
const [installingName, setInstallingName] = useState<string | null>(null);
|
||||
const closeInstallModal = useCallback(() => setInstallEntry(null), []);
|
||||
const installModalRef = useModalBehavior({
|
||||
open: installEntry !== null,
|
||||
onClose: closeInstallModal,
|
||||
});
|
||||
|
||||
const loadServers = useCallback(() => {
|
||||
return api
|
||||
.getMcpServers()
|
||||
.then((res) => setServers(res.servers))
|
||||
.catch((e) => showToast(`Error: ${e}`, "error"));
|
||||
}, [showToast]);
|
||||
|
||||
const loadCatalog = useCallback(() => {
|
||||
return api
|
||||
.getMcpCatalog()
|
||||
.then((res) => {
|
||||
setCatalog(res.entries);
|
||||
setDiagnostics(res.diagnostics);
|
||||
})
|
||||
.catch((e) => showToast(`Error: ${e}`, "error"));
|
||||
}, [showToast]);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([loadServers(), loadCatalog()]).finally(() =>
|
||||
setLoading(false),
|
||||
);
|
||||
}, [loadServers, loadCatalog]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
let body;
|
||||
try {
|
||||
body = buildMcpServerCreate({
|
||||
name,
|
||||
transport,
|
||||
url,
|
||||
httpAuth,
|
||||
bearerToken,
|
||||
command,
|
||||
args,
|
||||
env,
|
||||
});
|
||||
} catch (error) {
|
||||
showToast(
|
||||
error instanceof Error ? error.message : "Invalid MCP server",
|
||||
"error",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setCreating(true);
|
||||
try {
|
||||
await api.addMcpServer(body);
|
||||
showToast(
|
||||
transport === "http" && httpAuth === "oauth"
|
||||
? "Added — authenticate with OAuth"
|
||||
: "Add ✓",
|
||||
"success",
|
||||
);
|
||||
setName("");
|
||||
setUrl("");
|
||||
setHttpAuth("none");
|
||||
setBearerToken("");
|
||||
setCommand("");
|
||||
setArgs("");
|
||||
setEnv("");
|
||||
setTransport("http");
|
||||
setCreateModalOpen(false);
|
||||
loadServers();
|
||||
} catch (e) {
|
||||
showToast(`Failed to add: ${e}`, "error");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTest = async (server: McpServer) => {
|
||||
setTesting(server.name);
|
||||
try {
|
||||
const result = await api.testMcpServer(server.name);
|
||||
setTestResults((prev) => ({ ...prev, [server.name]: result }));
|
||||
if (result.ok) {
|
||||
showToast(`${server.name}: ${result.tools.length} tool(s)`, "success");
|
||||
} else {
|
||||
showToast(`${server.name}: ${result.error ?? "Failed"}`, "error");
|
||||
}
|
||||
} catch (e) {
|
||||
showToast(`Error: ${e}`, "error");
|
||||
} finally {
|
||||
setTesting(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAuthenticate = async (server: McpServer) => {
|
||||
setAuthenticating(server.name);
|
||||
try {
|
||||
const result = await completeMcpDashboardOAuth({
|
||||
serverName: server.name,
|
||||
start: api.authMcpServer,
|
||||
status: api.getMcpOAuthFlow,
|
||||
open: window.open.bind(window),
|
||||
});
|
||||
setTestResults((prev) => ({
|
||||
...prev,
|
||||
[server.name]: { ok: true, tools: result.tools ?? [] },
|
||||
}));
|
||||
showToast(`${server.name}: OAuth authentication complete`, "success");
|
||||
} catch (e) {
|
||||
showToast(`OAuth error: ${e}`, "error");
|
||||
} finally {
|
||||
setAuthenticating(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleEnabled = async (server: McpServer) => {
|
||||
const next = !server.enabled;
|
||||
setTogglingName(server.name);
|
||||
try {
|
||||
await api.setMcpServerEnabled(server.name, next);
|
||||
setServers((prev) =>
|
||||
prev.map((s) => (s.name === server.name ? { ...s, enabled: next } : s)),
|
||||
);
|
||||
setRestartNote(
|
||||
"Enable/disable takes effect on the next gateway restart.",
|
||||
);
|
||||
} catch (e) {
|
||||
showToast(`Error: ${e}`, "error");
|
||||
} finally {
|
||||
setTogglingName(null);
|
||||
}
|
||||
};
|
||||
|
||||
const serverDelete = useConfirmDelete({
|
||||
onDelete: useCallback(
|
||||
async (serverName: string) => {
|
||||
try {
|
||||
await api.removeMcpServer(serverName);
|
||||
showToast(`Delete: "${truncateText(serverName, 30)}"`, "success");
|
||||
setTestResults((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[serverName];
|
||||
return next;
|
||||
});
|
||||
loadServers();
|
||||
} catch (e) {
|
||||
showToast(`Error: ${e}`, "error");
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
[loadServers, showToast],
|
||||
),
|
||||
});
|
||||
|
||||
// ── Catalog install ──────────────────────────────────────────────────
|
||||
const runInstall = useCallback(
|
||||
async (entry: McpCatalogEntry, envMap: Record<string, string>) => {
|
||||
setInstallingName(entry.name);
|
||||
try {
|
||||
const res = await api.installMcpCatalogEntry(entry.name, envMap, true);
|
||||
if (res.background) {
|
||||
showToast("Installing in background…", "success");
|
||||
} else {
|
||||
showToast(`Installed: "${truncateText(entry.name, 30)}"`, "success");
|
||||
}
|
||||
setInstallEntry(null);
|
||||
setInstallEnv({});
|
||||
await Promise.all([loadServers(), loadCatalog()]);
|
||||
} catch (e) {
|
||||
showToast(`Failed to install: ${e}`, "error");
|
||||
} finally {
|
||||
setInstallingName(null);
|
||||
}
|
||||
},
|
||||
[loadServers, loadCatalog, showToast],
|
||||
);
|
||||
|
||||
const handleInstallClick = (entry: McpCatalogEntry) => {
|
||||
if (entry.required_env.length > 0) {
|
||||
const initial: Record<string, string> = {};
|
||||
entry.required_env.forEach((item) => {
|
||||
initial[item.name] = "";
|
||||
});
|
||||
setInstallEnv(initial);
|
||||
setInstallEntry(entry);
|
||||
} else {
|
||||
void runInstall(entry, {});
|
||||
}
|
||||
};
|
||||
|
||||
const handleInstallSubmit = () => {
|
||||
if (!installEntry) return;
|
||||
const missing = installEntry.required_env.filter(
|
||||
(item) => item.required && !(installEnv[item.name] ?? "").trim(),
|
||||
);
|
||||
if (missing.length > 0) {
|
||||
showToast(`${missing[0].prompt} required`, "error");
|
||||
return;
|
||||
}
|
||||
const envMap: Record<string, string> = {};
|
||||
Object.entries(installEnv).forEach(([k, v]) => {
|
||||
if (v.trim()) envMap[k] = v.trim();
|
||||
});
|
||||
void runInstall(installEntry, envMap);
|
||||
};
|
||||
|
||||
// Put "Add Server" button in page header
|
||||
useLayoutEffect(() => {
|
||||
setEnd(
|
||||
<Button
|
||||
className="uppercase"
|
||||
size="sm"
|
||||
onClick={() => setCreateModalOpen(true)}
|
||||
>
|
||||
Add Server
|
||||
</Button>,
|
||||
);
|
||||
return () => {
|
||||
setEnd(null);
|
||||
};
|
||||
}, [setEnd, loading]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<Spinner className="text-2xl text-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const diagnosticsByName: Record<string, McpCatalogDiagnostic[]> = {};
|
||||
diagnostics.forEach((d) => {
|
||||
(diagnosticsByName[d.name] ??= []).push(d);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<Toast toast={toast} />
|
||||
|
||||
<DeleteConfirmDialog
|
||||
open={serverDelete.isOpen}
|
||||
onCancel={serverDelete.cancel}
|
||||
onConfirm={serverDelete.confirm}
|
||||
title="Remove MCP server"
|
||||
description={
|
||||
serverDelete.pendingId
|
||||
? `"${truncateText(serverDelete.pendingId, 40)}" — this will remove the server.`
|
||||
: "This will remove the server."
|
||||
}
|
||||
loading={serverDelete.isDeleting}
|
||||
/>
|
||||
|
||||
{/* Add server modal */}
|
||||
{createModalOpen && (
|
||||
<div
|
||||
ref={createModalRef}
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 p-4"
|
||||
onClick={(e) => e.target === e.currentTarget && closeCreateModal()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="create-mcp-title"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
themedBody,
|
||||
"relative w-full max-w-lg border border-border bg-card shadow-2xl flex flex-col",
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
ghost
|
||||
size="icon"
|
||||
onClick={closeCreateModal}
|
||||
className="absolute right-2 top-2 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
|
||||
<header className="p-5 pb-3 border-b border-border">
|
||||
<h2
|
||||
id="create-mcp-title"
|
||||
className="font-mondwest text-display text-base tracking-wider"
|
||||
>
|
||||
Add MCP server
|
||||
</h2>
|
||||
</header>
|
||||
|
||||
<div className="p-5 grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="mcp-name">Name</Label>
|
||||
<Input
|
||||
id="mcp-name"
|
||||
autoFocus
|
||||
placeholder="my-server"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="mcp-transport">Transport</Label>
|
||||
<Select
|
||||
id="mcp-transport"
|
||||
value={transport}
|
||||
onValueChange={(value) => {
|
||||
const nextTransport = value as McpTransport;
|
||||
setTransport(nextTransport);
|
||||
if (nextTransport === "stdio") setBearerToken("");
|
||||
}}
|
||||
>
|
||||
<SelectOption value="http">HTTP/SSE</SelectOption>
|
||||
<SelectOption value="stdio">stdio</SelectOption>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{transport === "http" ? (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="mcp-url">URL</Label>
|
||||
<Input
|
||||
id="mcp-url"
|
||||
placeholder="https://example.com/mcp"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="mcp-auth">Authentication</Label>
|
||||
<Select
|
||||
id="mcp-auth"
|
||||
value={httpAuth}
|
||||
onValueChange={(value) => {
|
||||
const nextAuth = value as McpHttpAuth;
|
||||
setHttpAuth(nextAuth);
|
||||
if (nextAuth !== "header") setBearerToken("");
|
||||
}}
|
||||
>
|
||||
<SelectOption value="none">None</SelectOption>
|
||||
<SelectOption value="header">Bearer token</SelectOption>
|
||||
<SelectOption value="oauth">OAuth</SelectOption>
|
||||
</Select>
|
||||
</div>
|
||||
{httpAuth === "header" && (
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="mcp-bearer-token">Bearer token</Label>
|
||||
<Input
|
||||
id="mcp-bearer-token"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder="Token or Bearer token"
|
||||
value={bearerToken}
|
||||
onChange={(e) => setBearerToken(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Stored in this profile's .env; config.yaml keeps
|
||||
only an environment-variable reference.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{httpAuth === "oauth" && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Add the server, then use Authenticate. Hermes opens the
|
||||
OAuth browser on the machine running the Dashboard
|
||||
backend.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="mcp-command">Command</Label>
|
||||
<Input
|
||||
id="mcp-command"
|
||||
placeholder="npx"
|
||||
value={command}
|
||||
onChange={(e) => setCommand(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="mcp-args">Args</Label>
|
||||
<Input
|
||||
id="mcp-args"
|
||||
placeholder="-y @modelcontextprotocol/server-foo"
|
||||
value={args}
|
||||
onChange={(e) => setArgs(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="mcp-env">
|
||||
Environment (KEY=VALUE per line)
|
||||
</Label>
|
||||
<textarea
|
||||
id="mcp-env"
|
||||
className="flex min-h-[80px] w-full border border-border bg-background/40 px-3 py-2 text-sm font-courier shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30 focus-visible:border-foreground/25"
|
||||
placeholder={"API_KEY=secret\nDEBUG=1"}
|
||||
value={env}
|
||||
onChange={(e) => setEnv(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
className="uppercase"
|
||||
size="sm"
|
||||
onClick={handleCreate}
|
||||
disabled={creating}
|
||||
prefix={creating ? <Spinner /> : undefined}
|
||||
>
|
||||
{creating ? "Adding..." : "Add"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Catalog install modal (required env vars) */}
|
||||
{installEntry && (
|
||||
<div
|
||||
ref={installModalRef}
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 p-4"
|
||||
onClick={(e) => e.target === e.currentTarget && setInstallEntry(null)}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="install-mcp-title"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
themedBody,
|
||||
"relative w-full max-w-lg border border-border bg-card shadow-2xl flex flex-col",
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
ghost
|
||||
size="icon"
|
||||
onClick={() => setInstallEntry(null)}
|
||||
className="absolute right-2 top-2 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
|
||||
<header className="p-5 pb-3 border-b border-border">
|
||||
<h2
|
||||
id="install-mcp-title"
|
||||
className="font-mondwest text-display text-base tracking-wider"
|
||||
>
|
||||
Install {installEntry.name}
|
||||
</h2>
|
||||
</header>
|
||||
|
||||
<div className="p-5 grid gap-4">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This MCP requires the following values to be configured.
|
||||
</p>
|
||||
{installEntry.required_env.map((item) => (
|
||||
<div className="grid gap-2" key={item.name}>
|
||||
<Label htmlFor={`install-env-${item.name}`}>
|
||||
{item.prompt}
|
||||
{item.required ? " *" : ""}
|
||||
</Label>
|
||||
<Input
|
||||
id={`install-env-${item.name}`}
|
||||
type="password"
|
||||
placeholder={item.name}
|
||||
value={installEnv[item.name] ?? ""}
|
||||
onChange={(e) =>
|
||||
setInstallEnv((prev) => ({
|
||||
...prev,
|
||||
[item.name]: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
className="uppercase"
|
||||
size="sm"
|
||||
onClick={handleInstallSubmit}
|
||||
disabled={installingName === installEntry.name}
|
||||
prefix={
|
||||
installingName === installEntry.name ? (
|
||||
<Spinner />
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{installingName === installEntry.name
|
||||
? "Installing..."
|
||||
: "Install"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Your MCP servers ── */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<H2
|
||||
variant="sm"
|
||||
className="flex items-center gap-2 text-muted-foreground"
|
||||
>
|
||||
<Server className="h-4 w-4" />
|
||||
Your MCP servers ({servers.length})
|
||||
</H2>
|
||||
</div>
|
||||
|
||||
{restartNote && <p className="text-xs text-warning">{restartNote}</p>}
|
||||
|
||||
{servers.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
||||
No MCP servers configured.
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{servers.map((server) => {
|
||||
const envCount = Object.keys(server.env ?? {}).length;
|
||||
const result = testResults[server.name];
|
||||
|
||||
return (
|
||||
<Card key={server.name}>
|
||||
<CardContent
|
||||
className={cn(
|
||||
"flex items-start gap-4 py-4",
|
||||
!server.enabled && "opacity-60",
|
||||
)}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium text-sm truncate">
|
||||
{server.name}
|
||||
</span>
|
||||
<Badge
|
||||
tone={TRANSPORT_TONE[server.transport] ?? "secondary"}
|
||||
>
|
||||
{server.transport}
|
||||
</Badge>
|
||||
{server.auth && (
|
||||
<Badge tone="outline">
|
||||
auth:{" "}
|
||||
{server.auth === "header" ? "bearer" : server.auth}
|
||||
</Badge>
|
||||
)}
|
||||
{!server.enabled && <Badge tone="outline">disabled</Badge>}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
{server.transport === "http" ? (
|
||||
<span className="font-mono truncate">
|
||||
{server.url ?? "—"}
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-mono truncate">
|
||||
{[server.command, ...(server.args ?? [])]
|
||||
.filter(Boolean)
|
||||
.join(" ") || "—"}
|
||||
</span>
|
||||
)}
|
||||
{envCount > 0 && (
|
||||
<span>
|
||||
{envCount} env var{envCount === 1 ? "" : "s"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{result && (
|
||||
<div className="mt-2 text-xs">
|
||||
{result.ok ? (
|
||||
<p className="text-success">
|
||||
{result.tools.length === 0
|
||||
? "Connected — no tools"
|
||||
: `Tools: ${result.tools
|
||||
.map((tool) => tool.name)
|
||||
.join(", ")}`}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-destructive">
|
||||
{result.error ?? "Connection failed"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{server.auth === "oauth" && (
|
||||
<Button
|
||||
ghost
|
||||
size="sm"
|
||||
title="Authenticate with OAuth"
|
||||
onClick={() => handleAuthenticate(server)}
|
||||
disabled={authenticating === server.name}
|
||||
prefix={
|
||||
authenticating === server.name ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<KeyRound />
|
||||
)
|
||||
}
|
||||
>
|
||||
Authenticate
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
ghost
|
||||
size="sm"
|
||||
title={server.enabled ? "Disable" : "Enable"}
|
||||
aria-label={server.enabled ? "Disable" : "Enable"}
|
||||
onClick={() => handleToggleEnabled(server)}
|
||||
disabled={togglingName === server.name}
|
||||
prefix={
|
||||
togglingName === server.name ? <Spinner /> : <Power />
|
||||
}
|
||||
className={server.enabled ? "text-success" : undefined}
|
||||
>
|
||||
{server.enabled ? "Disable" : "Enable"}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
ghost
|
||||
size="icon"
|
||||
title="Test connection"
|
||||
aria-label="Test connection"
|
||||
onClick={() => handleTest(server)}
|
||||
disabled={testing === server.name}
|
||||
>
|
||||
{testing === server.name ? <Spinner /> : <Zap />}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
ghost
|
||||
destructive
|
||||
size="icon"
|
||||
title="Delete"
|
||||
aria-label="Delete"
|
||||
onClick={() => serverDelete.requestDelete(server.name)}
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ── Catalog ── */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<H2
|
||||
variant="sm"
|
||||
className="flex items-center gap-2 text-muted-foreground"
|
||||
>
|
||||
<Package className="h-4 w-4" />
|
||||
Catalog ({catalog.length})
|
||||
</H2>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Browse Nous-approved MCP servers and install them with one click.
|
||||
</p>
|
||||
|
||||
{catalog.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
||||
No catalog entries available.
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{catalog.map((entry) => {
|
||||
const entryDiags = diagnosticsByName[entry.name] ?? [];
|
||||
const isInstalling = installingName === entry.name;
|
||||
|
||||
return (
|
||||
<Card key={entry.name}>
|
||||
<CardContent className="flex items-start gap-4 py-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<span className="font-medium text-sm truncate">
|
||||
{entry.name}
|
||||
</span>
|
||||
<Badge
|
||||
tone={TRANSPORT_TONE[entry.transport] ?? "secondary"}
|
||||
>
|
||||
{entry.transport}
|
||||
</Badge>
|
||||
<Badge tone="outline">auth: {entry.auth_type}</Badge>
|
||||
{isHttpUrl(entry.source) ? (
|
||||
<a
|
||||
href={entry.source}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-primary underline underline-offset-2 hover:opacity-80"
|
||||
>
|
||||
source ↗
|
||||
</a>
|
||||
) : (
|
||||
entry.source && (
|
||||
<Badge tone="outline">{entry.source}</Badge>
|
||||
)
|
||||
)}
|
||||
{entry.installed && <Badge tone="success">Installed</Badge>}
|
||||
{entry.installed && !entry.enabled && (
|
||||
<Badge tone="outline">disabled</Badge>
|
||||
)}
|
||||
</div>
|
||||
{entry.description && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{entry.description}
|
||||
</p>
|
||||
)}
|
||||
{/* Connection detail: what the agent actually talks to. */}
|
||||
{entry.transport === "http" && entry.url && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
<span className="font-medium">Endpoint:</span>{" "}
|
||||
<code className="font-mono">{entry.url}</code>
|
||||
</p>
|
||||
)}
|
||||
{entry.transport === "stdio" && entry.command && (
|
||||
<p className="mt-1 text-xs text-muted-foreground break-all">
|
||||
<span className="font-medium">Runs:</span>{" "}
|
||||
<code className="font-mono">
|
||||
{[entry.command, ...entry.args].join(" ")}
|
||||
</code>
|
||||
</p>
|
||||
)}
|
||||
{/* Git bootstrap — surfaced so users see what gets cloned/run
|
||||
before they install (matches the docs trust model). */}
|
||||
{entry.install_url && (
|
||||
<p className="mt-1 text-xs text-muted-foreground break-all">
|
||||
<span className="font-medium">Installs from:</span>{" "}
|
||||
{isHttpUrl(entry.install_url) ? (
|
||||
<a
|
||||
href={entry.install_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline underline-offset-2 hover:opacity-80"
|
||||
>
|
||||
{entry.install_url}
|
||||
</a>
|
||||
) : (
|
||||
<code className="font-mono">{entry.install_url}</code>
|
||||
)}
|
||||
{entry.install_ref && <span> @ {entry.install_ref}</span>}
|
||||
</p>
|
||||
)}
|
||||
{entry.bootstrap.length > 0 && (
|
||||
<details className="mt-1 text-xs text-muted-foreground">
|
||||
<summary className="cursor-pointer select-none">
|
||||
Bootstrap commands ({entry.bootstrap.length})
|
||||
</summary>
|
||||
<ul className="mt-1 ml-3 list-disc space-y-0.5">
|
||||
{entry.bootstrap.map((cmd, i) => (
|
||||
<li
|
||||
key={`${entry.name}-bs-${i}`}
|
||||
className="break-all"
|
||||
>
|
||||
<code className="font-mono">{cmd}</code>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
{entry.post_install && (
|
||||
<details className="mt-1 text-xs text-muted-foreground">
|
||||
<summary className="cursor-pointer select-none">
|
||||
Setup notes
|
||||
</summary>
|
||||
<p className="mt-1 whitespace-pre-wrap">
|
||||
{entry.post_install.trim()}
|
||||
</p>
|
||||
</details>
|
||||
)}
|
||||
{entryDiags.map((d, i) => (
|
||||
<p
|
||||
key={`${entry.name}-diag-${i}`}
|
||||
className="text-xs text-warning mt-1"
|
||||
>
|
||||
{d.message}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{entry.installed ? (
|
||||
<Badge tone="success">Installed</Badge>
|
||||
) : (
|
||||
<Button
|
||||
className="uppercase"
|
||||
size="sm"
|
||||
onClick={() => handleInstallClick(entry)}
|
||||
disabled={isInstalling}
|
||||
prefix={isInstalling ? <Spinner /> : undefined}
|
||||
>
|
||||
{isInstalling ? "Installing..." : "Install"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,273 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useState } from "react";
|
||||
import { Check, ShieldCheck, Trash2, Users, X } from "lucide-react";
|
||||
import { Badge } from "@nous-research/ui/ui/components/badge";
|
||||
import { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { Spinner } from "@nous-research/ui/ui/components/spinner";
|
||||
import { H2 } from "@nous-research/ui/ui/components/typography/h2";
|
||||
import { api } from "@/lib/api";
|
||||
import type { PairingResponse, PairingUser } from "@/lib/api";
|
||||
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
|
||||
import { useToast } from "@nous-research/ui/hooks/use-toast";
|
||||
import { useConfirmDelete } from "@nous-research/ui/hooks/use-confirm-delete";
|
||||
import { Toast } from "@nous-research/ui/ui/components/toast";
|
||||
import { Card, CardContent } from "@nous-research/ui/ui/components/card";
|
||||
import { usePageHeader } from "@/contexts/usePageHeader";
|
||||
|
||||
function getUserKey(user: PairingUser): string {
|
||||
return `${user.platform}:${user.user_id}`;
|
||||
}
|
||||
|
||||
function splitUserKey(key: string): { platform: string; user_id: string } {
|
||||
const idx = key.indexOf(":");
|
||||
if (idx === -1) return { platform: "", user_id: key };
|
||||
return { platform: key.slice(0, idx), user_id: key.slice(idx + 1) };
|
||||
}
|
||||
|
||||
function getUserLabel(user: PairingUser): string {
|
||||
return user.user_name || user.user_id;
|
||||
}
|
||||
|
||||
export default function PairingPage() {
|
||||
const [pending, setPending] = useState<PairingUser[]>([]);
|
||||
const [approved, setApproved] = useState<PairingUser[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [approving, setApproving] = useState<string | null>(null);
|
||||
const [clearing, setClearing] = useState(false);
|
||||
const { toast, showToast } = useToast();
|
||||
const { setEnd } = usePageHeader();
|
||||
|
||||
const loadPairing = useCallback(() => {
|
||||
api
|
||||
.getPairing()
|
||||
.then((res: PairingResponse) => {
|
||||
setPending(res.pending);
|
||||
setApproved(res.approved);
|
||||
})
|
||||
.catch(() => showToast("Failed to load pairing requests", "error"))
|
||||
.finally(() => setLoading(false));
|
||||
}, [showToast]);
|
||||
|
||||
useEffect(() => {
|
||||
loadPairing();
|
||||
}, [loadPairing]);
|
||||
|
||||
const handleApprove = async (user: PairingUser) => {
|
||||
if (!user.request_id) {
|
||||
showToast("Missing pairing request", "error");
|
||||
return;
|
||||
}
|
||||
const key = getUserKey(user);
|
||||
setApproving(key);
|
||||
try {
|
||||
await api.approvePairing(user.platform, user.request_id);
|
||||
showToast(`Approved: "${getUserLabel(user)}"`, "success");
|
||||
loadPairing();
|
||||
} catch (e) {
|
||||
showToast(`Error: ${e}`, "error");
|
||||
} finally {
|
||||
setApproving(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearPending = async () => {
|
||||
if (!window.confirm("Clear all pending pairing requests?")) return;
|
||||
setClearing(true);
|
||||
try {
|
||||
const res = await api.clearPendingPairing();
|
||||
showToast(`Cleared ${res.cleared} pending request(s)`, "success");
|
||||
loadPairing();
|
||||
} catch (e) {
|
||||
showToast(`Error: ${e}`, "error");
|
||||
} finally {
|
||||
setClearing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const userRevoke = useConfirmDelete({
|
||||
onDelete: useCallback(
|
||||
async (key: string) => {
|
||||
const { platform, user_id } = splitUserKey(key);
|
||||
const user = approved.find((u) => getUserKey(u) === key);
|
||||
try {
|
||||
await api.revokePairing(platform, user_id);
|
||||
showToast(
|
||||
`Revoked: "${user ? getUserLabel(user) : user_id}"`,
|
||||
"success",
|
||||
);
|
||||
loadPairing();
|
||||
} catch (e) {
|
||||
showToast(`Error: ${e}`, "error");
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
[approved, loadPairing, showToast],
|
||||
),
|
||||
});
|
||||
|
||||
// Put "Clear pending" button in page header
|
||||
useLayoutEffect(() => {
|
||||
setEnd(
|
||||
<Button
|
||||
className="uppercase"
|
||||
size="sm"
|
||||
onClick={handleClearPending}
|
||||
disabled={clearing}
|
||||
prefix={clearing ? <Spinner /> : <Trash2 className="h-4 w-4" />}
|
||||
>
|
||||
Clear pending
|
||||
</Button>,
|
||||
);
|
||||
return () => {
|
||||
setEnd(null);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [setEnd, clearing]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<Spinner className="text-2xl text-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const pendingRevokeUser = userRevoke.pendingId
|
||||
? approved.find((u) => getUserKey(u) === userRevoke.pendingId)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<Toast toast={toast} />
|
||||
|
||||
<DeleteConfirmDialog
|
||||
open={userRevoke.isOpen}
|
||||
onCancel={userRevoke.cancel}
|
||||
onConfirm={userRevoke.confirm}
|
||||
title="Revoke access"
|
||||
description={
|
||||
pendingRevokeUser
|
||||
? `"${getUserLabel(pendingRevokeUser)}" will lose access. This cannot be undone.`
|
||||
: "This user will lose access. This cannot be undone."
|
||||
}
|
||||
confirmLabel="Revoke"
|
||||
loading={userRevoke.isDeleting}
|
||||
/>
|
||||
|
||||
{/* Pending requests */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<H2
|
||||
variant="sm"
|
||||
className="flex items-center gap-2 text-muted-foreground"
|
||||
>
|
||||
<Users className="h-4 w-4" />
|
||||
Pending requests ({pending.length})
|
||||
</H2>
|
||||
|
||||
{pending.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
||||
No pending pairing requests
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{pending.map((user) => {
|
||||
const key = getUserKey(user);
|
||||
return (
|
||||
<Card key={key}>
|
||||
<CardContent className="flex items-start gap-4 py-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Badge tone="outline">{user.platform}</Badge>
|
||||
<span className="font-medium text-sm truncate">
|
||||
{getUserLabel(user)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span className="truncate">{user.user_id}</span>
|
||||
{typeof user.age_minutes === "number" && (
|
||||
<span>{user.age_minutes}m ago</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
className="uppercase"
|
||||
onClick={() => handleApprove(user)}
|
||||
disabled={approving === key || !user.request_id}
|
||||
prefix={
|
||||
approving === key ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<Check className="h-4 w-4" />
|
||||
)
|
||||
}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Approved users */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<H2
|
||||
variant="sm"
|
||||
className="flex items-center gap-2 text-muted-foreground"
|
||||
>
|
||||
<ShieldCheck className="h-4 w-4" />
|
||||
Approved users ({approved.length})
|
||||
</H2>
|
||||
|
||||
{approved.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
||||
No approved users
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{approved.map((user) => {
|
||||
const key = getUserKey(user);
|
||||
return (
|
||||
<Card key={key}>
|
||||
<CardContent className="flex items-start gap-4 py-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Badge tone="outline">{user.platform}</Badge>
|
||||
<span className="font-medium text-sm truncate">
|
||||
{user.user_id}
|
||||
</span>
|
||||
</div>
|
||||
{user.user_name && (
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{user.user_name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
ghost
|
||||
size="icon"
|
||||
title="Revoke"
|
||||
aria-label="Revoke"
|
||||
className="text-destructive"
|
||||
onClick={() => userRevoke.requestDelete(key)}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,833 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { H2 } from "@nous-research/ui/ui/components/typography/h2";
|
||||
import { Card, CardContent } from "@nous-research/ui/ui/components/card";
|
||||
import { Badge } from "@nous-research/ui/ui/components/badge";
|
||||
import { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { Input } from "@nous-research/ui/ui/components/input";
|
||||
import { Label } from "@nous-research/ui/ui/components/label";
|
||||
import { Checkbox } from "@nous-research/ui/ui/components/checkbox";
|
||||
import { Toast } from "@nous-research/ui/ui/components/toast";
|
||||
import { useToast } from "@nous-research/ui/hooks/use-toast";
|
||||
import { api } from "@/lib/api";
|
||||
import type {
|
||||
McpHttpAuth,
|
||||
McpServerCreate,
|
||||
SkillInfo,
|
||||
SkillHubResult,
|
||||
} from "@/lib/api";
|
||||
import {
|
||||
buildMcpServerCreate,
|
||||
emptyMcpServerDraft,
|
||||
type McpServerDraft,
|
||||
type McpTransport,
|
||||
} from "@/lib/mcp-server-create";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Profile name rule mirrors the backend (`^[a-z0-9][a-z0-9_-]{0,63}$`).
|
||||
const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
||||
|
||||
type StepId = "identity" | "model" | "skills" | "mcp" | "review";
|
||||
|
||||
const STEPS: { id: StepId; label: string }[] = [
|
||||
{ id: "identity", label: "Identity" },
|
||||
{ id: "model", label: "Model" },
|
||||
{ id: "skills", label: "Skills" },
|
||||
{ id: "mcp", label: "MCPs" },
|
||||
{ id: "review", label: "Review" },
|
||||
];
|
||||
|
||||
interface ModelChoice {
|
||||
provider: string;
|
||||
model: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard-native, full-featured profile builder.
|
||||
*
|
||||
* Composes the same elements the standalone Models / Skills / MCP pages
|
||||
* manage — Name, Description, Model+Provider, Skills (built-in/optional +
|
||||
* hub), MCP servers — into one stepped create flow. Nothing is written to
|
||||
* disk until "Create profile" on the final step; the single POST /api/profiles
|
||||
* call commits model + MCPs + skill selection synchronously and spawns any
|
||||
* hub-skill installs (which the success toast reports as in-progress).
|
||||
*
|
||||
* Skills use REPLACE semantics: the default bundle is seeded server-side, then
|
||||
* every seeded skill the user did NOT keep is disabled. The "Start from full
|
||||
* bundle" toggle keeps everything (sends no keep list).
|
||||
*/
|
||||
export default function ProfileBuilderPage() {
|
||||
const navigate = useNavigate();
|
||||
const { toast, showToast } = useToast();
|
||||
|
||||
const [step, setStep] = useState<StepId>("identity");
|
||||
|
||||
// ── Step 1: identity ──────────────────────────────────────────────
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
|
||||
// ── Step 2: model ─────────────────────────────────────────────────
|
||||
const [modelChoices, setModelChoices] = useState<ModelChoice[] | null>(null);
|
||||
const [modelChoice, setModelChoice] = useState(""); // `${provider}\u0000${model}`
|
||||
const [modelFilter, setModelFilter] = useState("");
|
||||
const modelLoading = useRef(false);
|
||||
|
||||
// ── Step 3: skills ────────────────────────────────────────────────
|
||||
const [skills, setSkills] = useState<SkillInfo[] | null>(null);
|
||||
// keepAll = true: don't send a keep list (full bundle stays active).
|
||||
const [keepAll, setKeepAll] = useState(true);
|
||||
const [keptSkills, setKeptSkills] = useState<Set<string>>(new Set());
|
||||
const [skillFilter, setSkillFilter] = useState("");
|
||||
const skillsLoading = useRef(false);
|
||||
// Hub search
|
||||
const [hubQuery, setHubQuery] = useState("");
|
||||
const [hubResults, setHubResults] = useState<SkillHubResult[]>([]);
|
||||
const [hubSearching, setHubSearching] = useState(false);
|
||||
const [hubSkills, setHubSkills] = useState<SkillHubResult[]>([]);
|
||||
|
||||
// ── Step 4: MCPs ──────────────────────────────────────────────────
|
||||
const [mcpServers, setMcpServers] = useState<McpServerCreate[]>([]);
|
||||
const [mcpDraft, setMcpDraft] = useState<McpServerDraft>(emptyMcpServerDraft);
|
||||
|
||||
// ── Submit ────────────────────────────────────────────────────────
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const nameValid = PROFILE_NAME_RE.test(name.trim());
|
||||
|
||||
// Lazy-load model choices when the model step is first shown.
|
||||
const loadModels = useCallback(() => {
|
||||
if (modelChoices !== null || modelLoading.current) return;
|
||||
modelLoading.current = true;
|
||||
api
|
||||
.getModelOptions()
|
||||
.then((res) => {
|
||||
const flat: ModelChoice[] = [];
|
||||
for (const prov of res.providers ?? []) {
|
||||
for (const m of prov.models ?? []) {
|
||||
flat.push({
|
||||
provider: prov.slug,
|
||||
model: m,
|
||||
label: `${prov.name} · ${m}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
setModelChoices(flat);
|
||||
})
|
||||
.catch(() => setModelChoices([]))
|
||||
.finally(() => {
|
||||
modelLoading.current = false;
|
||||
});
|
||||
}, [modelChoices]);
|
||||
|
||||
const loadSkills = useCallback(() => {
|
||||
if (skills !== null || skillsLoading.current) return;
|
||||
skillsLoading.current = true;
|
||||
api
|
||||
.getSkills()
|
||||
.then((res) => {
|
||||
setSkills(res);
|
||||
// Default keep = all currently-enabled skills (matches the seeded set).
|
||||
setKeptSkills(new Set(res.filter((s) => s.enabled).map((s) => s.name)));
|
||||
})
|
||||
.catch(() => setSkills([]))
|
||||
.finally(() => {
|
||||
skillsLoading.current = false;
|
||||
});
|
||||
}, [skills]);
|
||||
|
||||
useEffect(() => {
|
||||
if (step === "model") loadModels();
|
||||
if (step === "skills") loadSkills();
|
||||
}, [step, loadModels, loadSkills]);
|
||||
|
||||
const runHubSearch = useCallback(() => {
|
||||
const q = hubQuery.trim();
|
||||
if (!q) return;
|
||||
setHubSearching(true);
|
||||
api
|
||||
.searchSkillsHub(q, "all", 20)
|
||||
.then((res) => setHubResults(res.results ?? []))
|
||||
.catch(() => setHubResults([]))
|
||||
.finally(() => setHubSearching(false));
|
||||
}, [hubQuery]);
|
||||
|
||||
const toggleKeep = (skillName: string) => {
|
||||
setKeptSkills((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(skillName)) next.delete(skillName);
|
||||
else next.add(skillName);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const addHubSkill = (r: SkillHubResult) => {
|
||||
setHubSkills((prev) =>
|
||||
prev.some((x) => x.identifier === r.identifier) ? prev : [...prev, r],
|
||||
);
|
||||
};
|
||||
const removeHubSkill = (identifier: string) =>
|
||||
setHubSkills((prev) => prev.filter((x) => x.identifier !== identifier));
|
||||
|
||||
const addMcpDraft = () => {
|
||||
let entry: McpServerCreate;
|
||||
try {
|
||||
entry = buildMcpServerCreate(mcpDraft);
|
||||
} catch (error) {
|
||||
showToast(
|
||||
error instanceof Error ? error.message : "Invalid MCP server",
|
||||
"error",
|
||||
);
|
||||
return;
|
||||
}
|
||||
setMcpServers((prev) => [
|
||||
...prev.filter((server) => server.name !== entry.name),
|
||||
entry,
|
||||
]);
|
||||
setMcpDraft(emptyMcpServerDraft());
|
||||
};
|
||||
const removeMcp = (n: string) =>
|
||||
setMcpServers((prev) => prev.filter((s) => s.name !== n));
|
||||
|
||||
const setMcpTransport = (transport: McpTransport) => {
|
||||
setMcpDraft((draft) =>
|
||||
transport === "http"
|
||||
? { ...draft, transport, command: "", args: "", env: "" }
|
||||
: {
|
||||
...draft,
|
||||
transport,
|
||||
url: "",
|
||||
httpAuth: "none",
|
||||
bearerToken: "",
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const setMcpHttpAuth = (httpAuth: McpHttpAuth) => {
|
||||
setMcpDraft((draft) => ({
|
||||
...draft,
|
||||
httpAuth,
|
||||
bearerToken: httpAuth === "header" ? draft.bearerToken : "",
|
||||
}));
|
||||
};
|
||||
|
||||
const filteredModels = useMemo(() => {
|
||||
if (!modelChoices) return [];
|
||||
const f = modelFilter.trim().toLowerCase();
|
||||
if (!f) return modelChoices;
|
||||
return modelChoices.filter((c) => c.label.toLowerCase().includes(f));
|
||||
}, [modelChoices, modelFilter]);
|
||||
|
||||
const filteredSkills = useMemo(() => {
|
||||
if (!skills) return [];
|
||||
const f = skillFilter.trim().toLowerCase();
|
||||
if (!f) return skills;
|
||||
return skills.filter(
|
||||
(s) =>
|
||||
s.name.toLowerCase().includes(f) ||
|
||||
(s.description || "").toLowerCase().includes(f) ||
|
||||
(s.category || "").toLowerCase().includes(f),
|
||||
);
|
||||
}, [skills, skillFilter]);
|
||||
|
||||
const pickedModel = useMemo(
|
||||
() =>
|
||||
modelChoice
|
||||
? modelChoices?.find(
|
||||
(c) => `${c.provider}\u0000${c.model}` === modelChoice,
|
||||
)
|
||||
: undefined,
|
||||
[modelChoice, modelChoices],
|
||||
);
|
||||
|
||||
const handleCreate = async () => {
|
||||
const n = name.trim();
|
||||
if (!PROFILE_NAME_RE.test(n)) {
|
||||
showToast("Invalid profile name (lowercase, digits, - and _)", "error");
|
||||
setStep("identity");
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
try {
|
||||
const res = await api.createProfile({
|
||||
name: n,
|
||||
clone_from: null,
|
||||
description: description.trim() || undefined,
|
||||
provider: pickedModel?.provider,
|
||||
model: pickedModel?.model,
|
||||
mcp_servers: mcpServers.length ? mcpServers : undefined,
|
||||
keep_skills: keepAll ? undefined : Array.from(keptSkills),
|
||||
hub_skills: hubSkills.length
|
||||
? hubSkills.map((s) => s.identifier)
|
||||
: undefined,
|
||||
});
|
||||
const pending = (res.hub_installs ?? []).filter((h) => h.pid).length;
|
||||
showToast(
|
||||
pending
|
||||
? `Profile "${n}" created — ${pending} hub skill${pending === 1 ? "" : "s"} installing`
|
||||
: `Profile "${n}" created`,
|
||||
"success",
|
||||
);
|
||||
navigate("/profiles");
|
||||
} catch (e) {
|
||||
showToast(`Create failed: ${e}`, "error");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const stepIndex = STEPS.findIndex((s) => s.id === step);
|
||||
const canAdvance = step !== "identity" || nameValid;
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-3xl space-y-6 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<H2>New profile</H2>
|
||||
<Button ghost onClick={() => navigate("/profiles")}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Stepper */}
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
{STEPS.map((s, i) => (
|
||||
<button
|
||||
key={s.id}
|
||||
// Identity must be valid before jumping ahead.
|
||||
disabled={i > 0 && !nameValid}
|
||||
onClick={() => setStep(s.id)}
|
||||
className={cn(
|
||||
"rounded-full px-3 py-1 transition-colors",
|
||||
s.id === step
|
||||
? "bg-primary text-primary-foreground"
|
||||
: i <= stepIndex
|
||||
? "bg-muted text-foreground"
|
||||
: "text-muted-foreground",
|
||||
i > 0 && !nameValid && "cursor-not-allowed opacity-50",
|
||||
)}
|
||||
>
|
||||
{i + 1}. {s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="space-y-4 p-5">
|
||||
{step === "identity" && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="pb-name">Profile name</Label>
|
||||
<Input
|
||||
id="pb-name"
|
||||
placeholder="coder"
|
||||
value={name}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setName(e.target.value)
|
||||
}
|
||||
/>
|
||||
{name && !nameValid && (
|
||||
<p className="text-xs text-destructive">
|
||||
Lowercase letters, digits, hyphens and underscores; must
|
||||
start with a letter or digit.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="pb-desc">Description (optional)</Label>
|
||||
<Input
|
||||
id="pb-desc"
|
||||
placeholder="What this agent profile is for"
|
||||
value={description}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setDescription(e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "model" && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Pick the model+provider for this profile. Skip to use the
|
||||
default.
|
||||
</p>
|
||||
<Input
|
||||
placeholder="Filter models…"
|
||||
value={modelFilter}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setModelFilter(e.target.value)
|
||||
}
|
||||
/>
|
||||
{modelChoices === null ? (
|
||||
<p className="text-sm text-muted-foreground">Loading models…</p>
|
||||
) : (
|
||||
<div className="max-h-72 space-y-1 overflow-y-auto">
|
||||
<button
|
||||
onClick={() => setModelChoice("")}
|
||||
className={cn(
|
||||
"block w-full rounded px-3 py-2 text-left text-sm",
|
||||
modelChoice === "" ? "bg-primary/10" : "hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
Use default (set later)
|
||||
</button>
|
||||
{filteredModels.map((c) => {
|
||||
const key = `${c.provider}\u0000${c.model}`;
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setModelChoice(key)}
|
||||
className={cn(
|
||||
"block w-full rounded px-3 py-2 text-left text-sm",
|
||||
modelChoice === key
|
||||
? "bg-primary/10"
|
||||
: "hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
{c.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "skills" && (
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={keepAll}
|
||||
onCheckedChange={(v) => setKeepAll(Boolean(v))}
|
||||
/>
|
||||
Start from the full default skill bundle (recommended)
|
||||
</label>
|
||||
{!keepAll && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Choose which built-in / optional skills to keep active.
|
||||
Unchecked skills are disabled in the new profile.
|
||||
</p>
|
||||
<Input
|
||||
placeholder="Filter skills…"
|
||||
value={skillFilter}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setSkillFilter(e.target.value)
|
||||
}
|
||||
/>
|
||||
{skills === null ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Loading skills…
|
||||
</p>
|
||||
) : (
|
||||
<div className="max-h-56 space-y-1 overflow-y-auto">
|
||||
{filteredSkills.map((s) => (
|
||||
<label
|
||||
key={s.name}
|
||||
className="flex items-start gap-2 rounded px-2 py-1.5 text-sm hover:bg-muted"
|
||||
>
|
||||
<Checkbox
|
||||
checked={keptSkills.has(s.name)}
|
||||
onCheckedChange={() => toggleKeep(s.name)}
|
||||
/>
|
||||
<span className="flex-1">
|
||||
<span className="font-medium">{s.name}</span>
|
||||
{s.category && (
|
||||
<Badge tone="secondary" className="ml-2">
|
||||
{s.category}
|
||||
</Badge>
|
||||
)}
|
||||
{s.description && (
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{s.description}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Skills hub */}
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
<Label>Add from the skills hub</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Search the hub (e.g. linear, hyperliquid)…"
|
||||
value={hubQuery}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setHubQuery(e.target.value)
|
||||
}
|
||||
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") runHubSearch();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
outlined
|
||||
onClick={runHubSearch}
|
||||
disabled={hubSearching}
|
||||
>
|
||||
{hubSearching ? "Searching…" : "Search"}
|
||||
</Button>
|
||||
</div>
|
||||
{hubResults.length > 0 && (
|
||||
<div className="max-h-48 space-y-1 overflow-y-auto">
|
||||
{hubResults.map((r) => (
|
||||
<div
|
||||
key={r.identifier}
|
||||
className="flex items-center justify-between rounded px-2 py-1.5 text-sm hover:bg-muted"
|
||||
>
|
||||
<span className="flex-1">
|
||||
<span className="font-medium">{r.name}</span>
|
||||
<Badge tone="secondary" className="ml-2">
|
||||
{r.source}
|
||||
</Badge>
|
||||
{r.description && (
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{r.description}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<Button size="sm" ghost onClick={() => addHubSkill(r)}>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{hubSkills.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
{hubSkills.map((r) => (
|
||||
<Badge key={r.identifier} className="gap-1">
|
||||
{r.name}
|
||||
<button
|
||||
className="ml-1 text-xs"
|
||||
onClick={() => removeHubSkill(r.identifier)}
|
||||
aria-label={`Remove ${r.name}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "mcp" && (
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-expanded text-base font-bold tracking-[0.04em]">
|
||||
MCP servers
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Add MCP servers to give this profile access to external
|
||||
tools and data.
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className="text-xs text-muted-foreground"
|
||||
aria-live="polite"
|
||||
>
|
||||
{mcpServers.length} configured
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 border border-border bg-background/20 p-4 md:p-5">
|
||||
<h4 className="font-medium">Add server</h4>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="pb-mcp-name">Server name</Label>
|
||||
<Input
|
||||
id="pb-mcp-name"
|
||||
placeholder="Enter server name"
|
||||
value={mcpDraft.name}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setMcpDraft({ ...mcpDraft, name: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label>Transport</Label>
|
||||
<div
|
||||
className="grid grid-cols-2 border border-border bg-background/30 p-0.5"
|
||||
role="group"
|
||||
aria-label="MCP transport"
|
||||
>
|
||||
{(
|
||||
[
|
||||
["http", "HTTP/SSE"],
|
||||
["stdio", "stdio"],
|
||||
] as const
|
||||
).map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
aria-pressed={mcpDraft.transport === value}
|
||||
className={cn(
|
||||
"px-3 py-2 text-sm font-medium transition-colors",
|
||||
mcpDraft.transport === value
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
onClick={() => setMcpTransport(value)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mcpDraft.transport === "http" ? (
|
||||
<>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="pb-mcp-url">URL</Label>
|
||||
<Input
|
||||
id="pb-mcp-url"
|
||||
placeholder="https://example.com/mcp"
|
||||
value={mcpDraft.url}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setMcpDraft({ ...mcpDraft, url: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label>Authentication</Label>
|
||||
<div
|
||||
className="grid grid-cols-3 border border-border bg-background/30 p-0.5 md:max-w-md"
|
||||
role="group"
|
||||
aria-label="HTTP authentication"
|
||||
>
|
||||
{(
|
||||
[
|
||||
["none", "None"],
|
||||
["header", "Bearer token"],
|
||||
["oauth", "OAuth"],
|
||||
] as const
|
||||
).map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
aria-pressed={mcpDraft.httpAuth === value}
|
||||
className={cn(
|
||||
"px-2 py-2 text-sm font-medium transition-colors",
|
||||
mcpDraft.httpAuth === value
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
onClick={() => setMcpHttpAuth(value)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{mcpDraft.httpAuth === "header" && (
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="pb-mcp-bearer-token">
|
||||
Bearer token
|
||||
</Label>
|
||||
<Input
|
||||
id="pb-mcp-bearer-token"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder="Token or Bearer token"
|
||||
value={mcpDraft.bearerToken}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setMcpDraft({
|
||||
...mcpDraft,
|
||||
bearerToken: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Stored in the new profile's .env; config.yaml
|
||||
keeps only an environment-variable reference.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{mcpDraft.httpAuth === "oauth" && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
After creating the profile, open its MCP page and use
|
||||
Authenticate to complete OAuth.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="pb-mcp-command">Command</Label>
|
||||
<Input
|
||||
id="pb-mcp-command"
|
||||
placeholder="npx"
|
||||
value={mcpDraft.command}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setMcpDraft({
|
||||
...mcpDraft,
|
||||
command: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="pb-mcp-args">Arguments</Label>
|
||||
<Input
|
||||
id="pb-mcp-args"
|
||||
placeholder="-y @modelcontextprotocol/server"
|
||||
value={mcpDraft.args}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setMcpDraft({ ...mcpDraft, args: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="pb-mcp-env">
|
||||
Environment (KEY=VALUE per line)
|
||||
</Label>
|
||||
<textarea
|
||||
id="pb-mcp-env"
|
||||
className="flex min-h-[80px] w-full border border-border bg-background/40 px-3 py-2 text-sm font-courier shadow-sm placeholder:text-muted-foreground focus-visible:border-foreground/25 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30"
|
||||
placeholder={"API_KEY=secret\nDEBUG=1"}
|
||||
value={mcpDraft.env}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
|
||||
setMcpDraft({ ...mcpDraft, env: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={addMcpDraft}>Add server</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mcpServers.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{mcpServers.map((s) => (
|
||||
<div
|
||||
key={s.name}
|
||||
className="flex items-center justify-between gap-4 border border-border bg-muted/40 p-4 text-sm"
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{s.name}</span>
|
||||
<Badge tone="outline">
|
||||
{s.url ? "HTTP" : "stdio"}
|
||||
</Badge>
|
||||
{s.auth && (
|
||||
<Badge tone="outline">
|
||||
auth: {s.auth === "header" ? "bearer" : s.auth}
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
<span className="mt-1 block break-all text-xs text-muted-foreground">
|
||||
{s.url || [s.command, ...(s.args || [])].join(" ")}
|
||||
</span>
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
ghost
|
||||
destructive
|
||||
className="shrink-0"
|
||||
onClick={() => removeMcp(s.name)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{step === "review" && (
|
||||
<div className="space-y-3 text-sm">
|
||||
<ReviewRow label="Name" value={name.trim() || "—"} />
|
||||
<ReviewRow
|
||||
label="Description"
|
||||
value={description.trim() || "—"}
|
||||
/>
|
||||
<ReviewRow
|
||||
label="Model"
|
||||
value={pickedModel ? pickedModel.label : "Default (set later)"}
|
||||
/>
|
||||
<ReviewRow
|
||||
label="Skills"
|
||||
value={
|
||||
keepAll
|
||||
? "Full default bundle"
|
||||
: `${keptSkills.size} built-in/optional kept` +
|
||||
(hubSkills.length ? ` + ${hubSkills.length} hub` : "")
|
||||
}
|
||||
/>
|
||||
{!keepAll && hubSkills.length > 0 && (
|
||||
<p className="pl-24 text-xs text-muted-foreground">
|
||||
Hub: {hubSkills.map((s) => s.name).join(", ")}
|
||||
</p>
|
||||
)}
|
||||
{keepAll && hubSkills.length > 0 && (
|
||||
<ReviewRow
|
||||
label="Hub skills"
|
||||
value={hubSkills.map((s) => s.name).join(", ")}
|
||||
/>
|
||||
)}
|
||||
<ReviewRow
|
||||
label="MCP servers"
|
||||
value={
|
||||
mcpServers.length
|
||||
? mcpServers.map((s) => s.name).join(", ")
|
||||
: "None"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Nav buttons */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
ghost
|
||||
disabled={stepIndex === 0}
|
||||
onClick={() => setStep(STEPS[Math.max(0, stepIndex - 1)].id)}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
{step === "review" ? (
|
||||
<Button onClick={handleCreate} disabled={creating || !nameValid}>
|
||||
{creating ? "Creating…" : "Create profile"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
disabled={!canAdvance}
|
||||
onClick={() =>
|
||||
setStep(STEPS[Math.min(STEPS.length - 1, stepIndex + 1)].id)
|
||||
}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Toast toast={toast} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
<span className="w-24 shrink-0 text-muted-foreground">{label}</span>
|
||||
<span className="flex-1 break-words">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,154 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
getSessions: vi.fn(),
|
||||
getSessionMessages: vi.fn(),
|
||||
getEmptySessionsCount: vi.fn(),
|
||||
getStatus: vi.fn(),
|
||||
searchSessions: vi.fn(),
|
||||
importSessions: vi.fn(),
|
||||
exportSessionUrl: vi.fn(),
|
||||
renameSession: vi.fn(),
|
||||
pruneSessions: vi.fn(),
|
||||
deleteSession: vi.fn(),
|
||||
deleteEmptySessions: vi.fn(),
|
||||
bulkDeleteSessions: vi.fn(),
|
||||
getProfiles: vi.fn(),
|
||||
getActiveProfile: vi.fn(),
|
||||
getSessionStats: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: apiMocks,
|
||||
// ProfileProvider mirrors its selection into the api module.
|
||||
setManagementProfile: vi.fn(),
|
||||
getManagementProfile: vi.fn(() => ""),
|
||||
}));
|
||||
vi.mock("@/components/PlatformsCard", () => ({ PlatformsCard: () => null }));
|
||||
vi.mock("@/components/Markdown", () => ({ Markdown: () => null }));
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
async function waitFor(cond: () => boolean, timeoutMs = 5000) {
|
||||
const start = Date.now();
|
||||
while (!cond()) {
|
||||
if (Date.now() - start > timeoutMs) throw new Error("waitFor: condition never became true");
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function click(el: Element | null) {
|
||||
if (!el) throw new Error("element not rendered");
|
||||
el.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
}
|
||||
|
||||
const button = (label: string) => document.querySelector(`button[aria-label="${label}"]`);
|
||||
|
||||
async function renderSessionsPage(rows: Record<string, unknown>[]) {
|
||||
// Page list uses limit 20; the overview tab's recent-cards fetch uses 50 —
|
||||
// keep the overview empty so the list view (with row actions) renders.
|
||||
apiMocks.getSessions.mockImplementation(async (limit: number) => ({
|
||||
sessions: limit >= 50 ? [] : rows,
|
||||
total: limit >= 50 ? 0 : rows.length,
|
||||
limit,
|
||||
offset: 0,
|
||||
}));
|
||||
const [{ default: SessionsPage }, { I18nProvider }, { SystemActionsProvider }, { ProfileProvider }, { PageHeaderProvider }] =
|
||||
await Promise.all([
|
||||
import("./SessionsPage"),
|
||||
import("@/i18n"),
|
||||
import("@/contexts/SystemActions"),
|
||||
import("@/contexts/ProfileProvider"),
|
||||
import("@/contexts/PageHeaderProvider"),
|
||||
]);
|
||||
container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<I18nProvider>
|
||||
<MemoryRouter>
|
||||
<SystemActionsProvider>
|
||||
<ProfileProvider>
|
||||
<PageHeaderProvider pluginTabs={[]}>
|
||||
<SessionsPage />
|
||||
</PageHeaderProvider>
|
||||
</ProfileProvider>
|
||||
</SystemActionsProvider>
|
||||
</MemoryRouter>
|
||||
</I18nProvider>,
|
||||
),
|
||||
);
|
||||
await waitFor(() => Boolean(button("Delete session")));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
for (const fn of Object.values(apiMocks)) fn.mockReset();
|
||||
apiMocks.getStatus.mockResolvedValue({});
|
||||
apiMocks.getEmptySessionsCount.mockResolvedValue({ count: 0 });
|
||||
apiMocks.getProfiles.mockResolvedValue({ profiles: [] });
|
||||
// active === current keeps the management profile "" — the precondition
|
||||
// under which an unstamped request hits the process's own store.
|
||||
apiMocks.getActiveProfile.mockResolvedValue({ current: "default", active: "default" });
|
||||
apiMocks.getSessionStats.mockResolvedValue({ by_source: {} });
|
||||
apiMocks.getSessionMessages.mockResolvedValue({ messages: [] });
|
||||
apiMocks.deleteSession.mockResolvedValue({ ok: true });
|
||||
apiMocks.renameSession.mockResolvedValue({ ok: true, title: "Renamed" });
|
||||
apiMocks.exportSessionUrl.mockReturnValue("/api/sessions/x/export");
|
||||
vi.stubGlobal("fetch", vi.fn(async () => ({ ok: false, status: 500 })));
|
||||
vi.stubGlobal("ResizeObserver", class { disconnect() {} observe() {} unobserve() {} });
|
||||
// gsap ticks through rAF; a synchronous callback recurses to death.
|
||||
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => setTimeout(() => cb(0), 0) as unknown as number);
|
||||
vi.stubGlobal("cancelAnimationFrame", (id: number) => clearTimeout(id));
|
||||
vi.stubGlobal("matchMedia", () => ({ addEventListener() {}, matches: false, media: "", removeEventListener() {} }));
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root?.unmount());
|
||||
container?.remove();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("SessionsPage per-row profile routing (#99387)", () => {
|
||||
it("sends every per-row request to the row's owning profile, not the management default", async () => {
|
||||
await renderSessionsPage([
|
||||
{ id: "sid-guanli", profile: "guanli", source: "cli", model: null, title: "Managed", started_at: 1, ended_at: null,
|
||||
last_active: 1, is_active: false, message_count: 2, tool_call_count: 0, input_tokens: 1, output_tokens: 1, preview: "hi" },
|
||||
]);
|
||||
|
||||
// expand → transcript read
|
||||
await act(async () => click(button("Delete session")!.closest("div.cursor-pointer")));
|
||||
await waitFor(() => apiMocks.getSessionMessages.mock.calls.length > 0);
|
||||
expect(apiMocks.getSessionMessages).toHaveBeenCalledWith("sid-guanli", "guanli");
|
||||
|
||||
await act(async () => click(button("Export session")));
|
||||
expect(apiMocks.exportSessionUrl).toHaveBeenCalledWith("sid-guanli", "guanli");
|
||||
|
||||
await act(async () => click(button("Rename session")));
|
||||
const input = document.querySelector<HTMLInputElement>('input[placeholder="Session title"]');
|
||||
if (!input) throw new Error("rename input not rendered");
|
||||
await act(async () => {
|
||||
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, "Renamed");
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
await act(async () => click(button("Save title")));
|
||||
expect(apiMocks.renameSession).toHaveBeenCalledWith("sid-guanli", "Renamed", "guanli");
|
||||
|
||||
await act(async () => click(button("Delete session")));
|
||||
await waitFor(() => Boolean(document.querySelector('[role="alertdialog"]')));
|
||||
const confirm = Array.from(document.querySelectorAll('[role="alertdialog"] button')).find(
|
||||
(b) => b.textContent?.trim() === "Delete",
|
||||
);
|
||||
await act(async () => click(confirm ?? null));
|
||||
expect(apiMocks.deleteSession).toHaveBeenCalledWith("sid-guanli", "guanli");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,612 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useState } from "react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
Copy,
|
||||
Plus,
|
||||
RotateCw,
|
||||
Trash2,
|
||||
Webhook,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@nous-research/ui/ui/components/badge";
|
||||
import { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
|
||||
import { Spinner } from "@nous-research/ui/ui/components/spinner";
|
||||
import { H2 } from "@nous-research/ui/ui/components/typography/h2";
|
||||
import { api } from "@/lib/api";
|
||||
import type { WebhookRoute, WebhooksResponse } from "@/lib/api";
|
||||
import { copyTextToClipboard } from "@/lib/clipboard";
|
||||
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
|
||||
import { useToast } from "@nous-research/ui/hooks/use-toast";
|
||||
import { useConfirmDelete } from "@nous-research/ui/hooks/use-confirm-delete";
|
||||
import { useModalBehavior } from "@/hooks/useModalBehavior";
|
||||
import { Toast } from "@nous-research/ui/ui/components/toast";
|
||||
import { Card, CardContent } from "@nous-research/ui/ui/components/card";
|
||||
import { Input } from "@nous-research/ui/ui/components/input";
|
||||
import { Label } from "@nous-research/ui/ui/components/label";
|
||||
import { usePageHeader } from "@/contexts/usePageHeader";
|
||||
import { cn, themedBody } from "@/lib/utils";
|
||||
|
||||
interface CreatedWebhook {
|
||||
url: string;
|
||||
secret: string;
|
||||
}
|
||||
|
||||
function CopyButton({ value }: { value: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const handleCopy = useCallback(() => {
|
||||
void copyTextToClipboard(value).then((copied) => {
|
||||
if (!copied) return;
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1500);
|
||||
});
|
||||
}, [value]);
|
||||
return (
|
||||
<Button
|
||||
ghost
|
||||
size="icon"
|
||||
title="Copy"
|
||||
aria-label="Copy"
|
||||
onClick={handleCopy}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{copied ? <Check /> : <Copy />}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WebhooksPage() {
|
||||
const [data, setData] = useState<WebhooksResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [enabling, setEnabling] = useState(false);
|
||||
const [restartNeeded, setRestartNeeded] = useState(false);
|
||||
const [restartMessage, setRestartMessage] = useState<string | null>(null);
|
||||
const [restartError, setRestartError] = useState<string | null>(null);
|
||||
const [restarting, setRestarting] = useState(false);
|
||||
const { toast, showToast } = useToast();
|
||||
const { setEnd } = usePageHeader();
|
||||
|
||||
// New subscription modal state
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [events, setEvents] = useState("");
|
||||
const [deliver, setDeliver] = useState("log");
|
||||
const [deliverOnly, setDeliverOnly] = useState(false);
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [created, setCreated] = useState<CreatedWebhook | null>(null);
|
||||
|
||||
const closeCreateModal = useCallback(() => {
|
||||
setCreateModalOpen(false);
|
||||
setCreated(null);
|
||||
}, []);
|
||||
const createModalRef = useModalBehavior({
|
||||
open: createModalOpen,
|
||||
onClose: closeCreateModal,
|
||||
});
|
||||
|
||||
const enabled = data?.enabled ?? false;
|
||||
const subscriptions = data?.subscriptions ?? [];
|
||||
|
||||
const loadWebhooks = useCallback(() => {
|
||||
return api
|
||||
.getWebhooks()
|
||||
.then(setData)
|
||||
.catch(() => showToast("Failed to load webhooks", "error"))
|
||||
.finally(() => setLoading(false));
|
||||
}, [showToast]);
|
||||
|
||||
useEffect(() => {
|
||||
loadWebhooks();
|
||||
}, [loadWebhooks]);
|
||||
|
||||
const watchRestartOutcome = useCallback(async () => {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
try {
|
||||
const st = await api.getActionStatus("gateway-restart", 5);
|
||||
if (st.running) continue;
|
||||
if (st.exit_code !== 0 && st.exit_code !== null) {
|
||||
setRestartMessage(null);
|
||||
setRestartNeeded(true);
|
||||
setRestartError(`Gateway restart failed with exit ${st.exit_code}.`);
|
||||
showToast(
|
||||
`Gateway restart failed (exit ${st.exit_code}) — restart manually`,
|
||||
"error",
|
||||
);
|
||||
} else {
|
||||
setRestartMessage(null);
|
||||
setRestartNeeded(false);
|
||||
setRestartError(null);
|
||||
}
|
||||
return;
|
||||
} catch {
|
||||
// The dashboard may briefly lose its connection while the gateway restarts.
|
||||
}
|
||||
}
|
||||
setRestartMessage(null);
|
||||
}, [showToast]);
|
||||
|
||||
const handleRestart = useCallback(async () => {
|
||||
setRestarting(true);
|
||||
try {
|
||||
await api.restartGateway();
|
||||
setRestartNeeded(false);
|
||||
setRestartError(null);
|
||||
setRestartMessage("Gateway restarting…");
|
||||
showToast("Gateway restarting…", "success");
|
||||
setTimeout(() => void loadWebhooks(), 4000);
|
||||
void watchRestartOutcome();
|
||||
} catch (e) {
|
||||
setRestartNeeded(true);
|
||||
setRestartError(String(e));
|
||||
showToast(`Failed to restart: ${e}`, "error");
|
||||
} finally {
|
||||
setRestarting(false);
|
||||
}
|
||||
}, [loadWebhooks, showToast, watchRestartOutcome]);
|
||||
|
||||
const handleEnableWebhooks = useCallback(async () => {
|
||||
setEnabling(true);
|
||||
setRestartNeeded(false);
|
||||
setRestartError(null);
|
||||
try {
|
||||
const result = await api.enableWebhooks();
|
||||
await loadWebhooks();
|
||||
if (result.restart_started) {
|
||||
setRestartMessage("Webhooks enabled; gateway restarting…");
|
||||
showToast("Webhooks enabled; gateway restarting…", "success");
|
||||
setTimeout(() => void loadWebhooks(), 4000);
|
||||
void watchRestartOutcome();
|
||||
} else {
|
||||
const detail = result.restart_error ? `: ${result.restart_error}` : ".";
|
||||
setRestartMessage(null);
|
||||
setRestartNeeded(true);
|
||||
setRestartError(`Gateway restart failed${detail}`);
|
||||
showToast(`Webhooks enabled; gateway restart failed${detail}`, "error");
|
||||
}
|
||||
} catch (e) {
|
||||
showToast(`Failed to enable webhooks: ${e}`, "error");
|
||||
} finally {
|
||||
setEnabling(false);
|
||||
}
|
||||
}, [loadWebhooks, showToast, watchRestartOutcome]);
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setName("");
|
||||
setDescription("");
|
||||
setEvents("");
|
||||
setDeliver("log");
|
||||
setDeliverOnly(false);
|
||||
setPrompt("");
|
||||
}, []);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!name.trim()) {
|
||||
showToast("Name required", "error");
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
try {
|
||||
const eventsList = events
|
||||
.split(",")
|
||||
.map((e) => e.trim())
|
||||
.filter(Boolean);
|
||||
const res = await api.createWebhook({
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
events: eventsList.length ? eventsList : undefined,
|
||||
deliver,
|
||||
deliver_only: deliverOnly,
|
||||
prompt: prompt.trim() || undefined,
|
||||
});
|
||||
showToast("Created ✓", "success");
|
||||
setCreated({ url: res.url, secret: res.secret });
|
||||
resetForm();
|
||||
loadWebhooks();
|
||||
} catch (e) {
|
||||
showToast(`Failed to create: ${e}`, "error");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const [togglingName, setTogglingName] = useState<string | null>(null);
|
||||
|
||||
const handleToggleEnabled = useCallback(
|
||||
async (subName: string, nextEnabled: boolean) => {
|
||||
setTogglingName(subName);
|
||||
try {
|
||||
await api.setWebhookEnabled(subName, nextEnabled);
|
||||
showToast(
|
||||
nextEnabled ? `Enabled: "${subName}"` : `Disabled: "${subName}"`,
|
||||
"success",
|
||||
);
|
||||
loadWebhooks();
|
||||
} catch (e) {
|
||||
showToast(`Error: ${e}`, "error");
|
||||
} finally {
|
||||
setTogglingName(null);
|
||||
}
|
||||
},
|
||||
[loadWebhooks, showToast],
|
||||
);
|
||||
|
||||
const webhookDelete = useConfirmDelete({
|
||||
onDelete: useCallback(
|
||||
async (name: string) => {
|
||||
try {
|
||||
await api.deleteWebhook(name);
|
||||
showToast(`Deleted: "${name}"`, "success");
|
||||
loadWebhooks();
|
||||
} catch (e) {
|
||||
showToast(`Error: ${e}`, "error");
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
[loadWebhooks, showToast],
|
||||
),
|
||||
});
|
||||
|
||||
// Put "New subscription" button in page header
|
||||
useLayoutEffect(() => {
|
||||
setEnd(
|
||||
<Button
|
||||
className="uppercase"
|
||||
size="sm"
|
||||
disabled={!enabled || enabling}
|
||||
prefix={<Plus />}
|
||||
onClick={() => {
|
||||
setCreated(null);
|
||||
setCreateModalOpen(true);
|
||||
}}
|
||||
>
|
||||
New subscription
|
||||
</Button>,
|
||||
);
|
||||
return () => {
|
||||
setEnd(null);
|
||||
};
|
||||
}, [setEnd, enabled, enabling, loading]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<Spinner className="text-2xl text-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const pendingName = webhookDelete.pendingId ?? "";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<Toast toast={toast} />
|
||||
|
||||
<DeleteConfirmDialog
|
||||
open={webhookDelete.isOpen}
|
||||
onCancel={webhookDelete.cancel}
|
||||
onConfirm={webhookDelete.confirm}
|
||||
title="Delete webhook"
|
||||
description={
|
||||
pendingName
|
||||
? `"${pendingName}" — this will permanently remove this webhook subscription.`
|
||||
: "This will permanently remove this webhook subscription."
|
||||
}
|
||||
loading={webhookDelete.isDeleting}
|
||||
/>
|
||||
|
||||
{/* Create subscription modal */}
|
||||
{createModalOpen && (
|
||||
<div
|
||||
ref={createModalRef}
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 p-4"
|
||||
onClick={(e) => e.target === e.currentTarget && closeCreateModal()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="create-webhook-title"
|
||||
>
|
||||
<div className={cn(themedBody, "relative w-full max-w-lg border border-border bg-card shadow-2xl flex flex-col max-h-[90vh] overflow-y-auto")}>
|
||||
<Button
|
||||
ghost
|
||||
size="icon"
|
||||
onClick={closeCreateModal}
|
||||
className="absolute right-2 top-2 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
|
||||
<header className="p-5 pb-3 border-b border-border">
|
||||
<h2
|
||||
id="create-webhook-title"
|
||||
className="font-mondwest text-display text-base tracking-wider"
|
||||
>
|
||||
New subscription
|
||||
</h2>
|
||||
</header>
|
||||
|
||||
{created ? (
|
||||
<div className="p-5 grid gap-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Subscription created. Copy the secret now — it is only shown
|
||||
once.
|
||||
</p>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>Webhook URL</Label>
|
||||
<div className="flex items-center gap-2 border border-border bg-background/40 px-3 py-2">
|
||||
<span className="flex-1 min-w-0 truncate font-mono text-xs">
|
||||
{created.url}
|
||||
</span>
|
||||
<CopyButton value={created.url} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>Secret (shown once)</Label>
|
||||
<div className="flex items-center gap-2 border border-warning/40 bg-warning/10 px-3 py-2">
|
||||
<span className="flex-1 min-w-0 truncate font-mono text-xs">
|
||||
{created.secret}
|
||||
</span>
|
||||
<CopyButton value={created.secret} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
className="uppercase"
|
||||
size="sm"
|
||||
onClick={closeCreateModal}
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-5 grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="webhook-name">Name</Label>
|
||||
<Input
|
||||
id="webhook-name"
|
||||
autoFocus
|
||||
placeholder="e.g. github-push"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="webhook-description">Description</Label>
|
||||
<Input
|
||||
id="webhook-description"
|
||||
placeholder="What this webhook does (optional)"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="webhook-events">Events</Label>
|
||||
<Input
|
||||
id="webhook-events"
|
||||
placeholder="comma-separated, leave empty for all"
|
||||
value={events}
|
||||
onChange={(e) => setEvents(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="webhook-deliver">Deliver to</Label>
|
||||
<Select
|
||||
id="webhook-deliver"
|
||||
value={deliver}
|
||||
onValueChange={(v) => setDeliver(v)}
|
||||
>
|
||||
<SelectOption value="log">Log</SelectOption>
|
||||
<SelectOption value="telegram">Telegram</SelectOption>
|
||||
<SelectOption value="discord">Discord</SelectOption>
|
||||
<SelectOption value="slack">Slack</SelectOption>
|
||||
<SelectOption value="email">Email</SelectOption>
|
||||
<SelectOption value="github_comment">
|
||||
GitHub comment
|
||||
</SelectOption>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="webhook-deliver-only">Deliver only</Label>
|
||||
<label className="flex items-center gap-2 text-sm text-muted-foreground h-9">
|
||||
<input
|
||||
id="webhook-deliver-only"
|
||||
type="checkbox"
|
||||
checked={deliverOnly}
|
||||
onChange={(e) => setDeliverOnly(e.target.checked)}
|
||||
/>
|
||||
Skip the agent, deliver payload directly
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="webhook-prompt">Prompt</Label>
|
||||
<textarea
|
||||
id="webhook-prompt"
|
||||
className="flex min-h-[80px] w-full border border-border bg-background/40 px-3 py-2 text-sm font-courier shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30 focus-visible:border-foreground/25"
|
||||
placeholder="Instructions for the agent when this webhook fires (optional)"
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
className="uppercase"
|
||||
size="sm"
|
||||
onClick={handleCreate}
|
||||
disabled={creating}
|
||||
prefix={creating ? <Spinner /> : undefined}
|
||||
>
|
||||
{creating ? "Creating…" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!enabled && (
|
||||
<Card className="border-warning/50">
|
||||
<CardContent className="flex flex-col gap-4 py-6 text-sm sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<Webhook className="h-5 w-5 shrink-0 text-warning" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium">Webhook receiver disabled</span>
|
||||
<span className="text-muted-foreground">
|
||||
Webhooks are their own gateway platform. Enable them here to
|
||||
accept incoming HTTP events; chat channels are only needed
|
||||
when a subscription delivers to Telegram, Discord, Slack, or
|
||||
another channel.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="uppercase shrink-0"
|
||||
onClick={handleEnableWebhooks}
|
||||
disabled={enabling}
|
||||
prefix={enabling ? <Spinner /> : <Webhook className="h-4 w-4" />}
|
||||
>
|
||||
{enabling ? "Enabling…" : "Enable webhooks"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{restartMessage && !restartNeeded && (
|
||||
<Card className="border-border">
|
||||
<CardContent className="flex items-center gap-2 p-4 text-sm text-muted-foreground">
|
||||
<RotateCw className="h-4 w-4 shrink-0 text-warning" />
|
||||
<span>{restartMessage}</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{restartNeeded && (
|
||||
<Card className="border-warning/50">
|
||||
<CardContent className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-start gap-2 text-sm">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-warning" />
|
||||
<span>
|
||||
{restartError ??
|
||||
"Webhooks are enabled, but the gateway still needs a restart before the receiver can come online."}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="uppercase shrink-0"
|
||||
onClick={handleRestart}
|
||||
disabled={restarting}
|
||||
prefix={restarting ? <Spinner /> : <RotateCw className="h-4 w-4" />}
|
||||
>
|
||||
{restarting ? "Restarting…" : "Restart gateway"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<H2
|
||||
variant="sm"
|
||||
className="flex items-center gap-2 text-muted-foreground"
|
||||
>
|
||||
<Webhook className="h-4 w-4" />
|
||||
Subscriptions ({subscriptions.length})
|
||||
</H2>
|
||||
|
||||
<p className="text-xs text-muted-foreground -mt-1">
|
||||
Subscription changes hot-reload once the webhook receiver is running.
|
||||
Disabled subscriptions reject incoming events.
|
||||
</p>
|
||||
|
||||
{subscriptions.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
||||
No webhook subscriptions yet.
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{subscriptions.map((sub: WebhookRoute) => (
|
||||
<Card key={sub.name}>
|
||||
<CardContent className="flex items-start gap-4 py-4">
|
||||
<div className={cn("flex-1 min-w-0", !sub.enabled && "opacity-60")}>
|
||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<span className="font-medium text-sm truncate">
|
||||
{sub.name}
|
||||
</span>
|
||||
<Badge tone="outline">{sub.deliver}</Badge>
|
||||
{sub.deliver_only && (
|
||||
<Badge tone="secondary">deliver only</Badge>
|
||||
)}
|
||||
{!sub.enabled && <Badge tone="warning">disabled</Badge>}
|
||||
</div>
|
||||
|
||||
{sub.description && (
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
{sub.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1 flex-wrap mb-2">
|
||||
{sub.events.length === 0 ? (
|
||||
<Badge tone="secondary">(all)</Badge>
|
||||
) : (
|
||||
sub.events.map((evt) => (
|
||||
<Badge key={evt} tone="secondary">
|
||||
{evt}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="flex-1 min-w-0 truncate font-mono">
|
||||
{sub.url}
|
||||
</span>
|
||||
<CopyButton value={sub.url} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
ghost
|
||||
size="sm"
|
||||
className="uppercase"
|
||||
disabled={togglingName === sub.name}
|
||||
onClick={() => handleToggleEnabled(sub.name, !sub.enabled)}
|
||||
>
|
||||
{sub.enabled ? "Disable" : "Enable"}
|
||||
</Button>
|
||||
<Button
|
||||
ghost
|
||||
destructive
|
||||
size="icon"
|
||||
title="Delete"
|
||||
aria-label="Delete"
|
||||
onClick={() => webhookDelete.requestDelete(sub.name)}
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user