Self-hosted, Docker-based agentic troubleshooting platform: FastAPI backend + LangGraph agent, Next.js UI, tiered LLM routing (local Ollama -> Gemini -> DeepSeek -> OpenRouter), MCP server manager, encrypted device credentials, RBAC, audit log, project-memory + Obsidian integrations, and editable troubleshooting decision rules tuned for the GeneseasX vessel stack. Co-authored-by: Cursor <cursoragent@cursor.com>
200 lines
7.2 KiB
TypeScript
200 lines
7.2 KiB
TypeScript
"use client";
|
||
|
||
import { useState } from "react";
|
||
import { useRouter } from "next/navigation";
|
||
import useSWR from "swr";
|
||
import AppShell from "@/components/AppShell";
|
||
import { EmptyState, LoadError, Modal, PageHeader, SkeletonRows, StatusBadge } from "@/components/ui";
|
||
import { useToast } from "@/components/toast";
|
||
import { api, fetcher } from "@/lib/api";
|
||
import { useAuth, roleAtLeast } from "@/lib/auth";
|
||
|
||
type Mcp = {
|
||
name: string;
|
||
status: string;
|
||
error: string | null;
|
||
source?: string;
|
||
tools: { name: string; description: string | null }[];
|
||
};
|
||
|
||
export default function McpPage() {
|
||
const router = useRouter();
|
||
const { user } = useAuth();
|
||
const isAdmin = roleAtLeast(user?.role, "admin");
|
||
const toast = useToast();
|
||
const {
|
||
data: mcps,
|
||
error: mcpError,
|
||
isLoading,
|
||
mutate,
|
||
} = useSWR<Mcp[]>("/api/mcp/servers", fetcher, { refreshInterval: 15000 });
|
||
const [expanded, setExpanded] = useState<string | null>(null);
|
||
const [busy, setBusy] = useState<string | null>(null);
|
||
const [developOpen, setDevelopOpen] = useState<string | null>(null);
|
||
const [developIssue, setDevelopIssue] = useState("");
|
||
|
||
const run = async (key: string, label: string, fn: () => Promise<void>) => {
|
||
setBusy(key);
|
||
try {
|
||
await fn();
|
||
toast.success(`${label} — status will refresh shortly.`);
|
||
mutate();
|
||
} catch (err: unknown) {
|
||
toast.error(err instanceof Error ? err.message : "Action failed");
|
||
} finally {
|
||
setBusy(null);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<AppShell>
|
||
<PageHeader
|
||
title="MCP Servers"
|
||
subtitle="Device integrations — reload or upgrade when tools fail or repos change"
|
||
action={
|
||
isAdmin && (
|
||
<button
|
||
className="btn-ghost text-sm"
|
||
disabled={!!busy}
|
||
onClick={() =>
|
||
run("all", "Reload all queued", async () => {
|
||
await api("/api/mcp/reload-all", { method: "POST" });
|
||
})
|
||
}
|
||
>
|
||
{busy === "all" ? "Reloading…" : "Reload all"}
|
||
</button>
|
||
)
|
||
}
|
||
/>
|
||
|
||
{isLoading && !mcps ? (
|
||
<SkeletonRows rows={4} />
|
||
) : mcpError ? (
|
||
<LoadError message="Couldn’t load MCP servers." onRetry={() => mutate()} />
|
||
) : (mcps || []).length === 0 ? (
|
||
<EmptyState
|
||
title="No MCP servers reported yet"
|
||
description="The worker clones and launches them on startup; check worker logs if this persists."
|
||
/>
|
||
) : (
|
||
<div className="space-y-4">
|
||
{(mcps || []).map((m) => (
|
||
<div key={m.name} className="card">
|
||
<div className="flex w-full flex-wrap items-center justify-between gap-3">
|
||
<button
|
||
className="flex flex-1 items-center gap-3 text-left"
|
||
onClick={() => setExpanded(expanded === m.name ? null : m.name)}
|
||
>
|
||
<span className="font-medium">{m.name}</span>
|
||
<span className="text-xs text-muted">
|
||
{m.tools?.length || 0} tools · {m.source || "git"}
|
||
</span>
|
||
</button>
|
||
<div className="flex items-center gap-2">
|
||
{isAdmin && (
|
||
<>
|
||
<button
|
||
className="btn-ghost btn-sm"
|
||
disabled={!!busy}
|
||
onClick={() =>
|
||
run(`reload-${m.name}`, "Reload queued", async () => {
|
||
await api(`/api/mcp/servers/${m.name}/reload`, { method: "POST" });
|
||
})
|
||
}
|
||
>
|
||
{busy === `reload-${m.name}` ? "…" : "Reload"}
|
||
</button>
|
||
<button
|
||
className="btn-ghost btn-sm text-accent2"
|
||
disabled={!!busy}
|
||
onClick={() =>
|
||
run(`upgrade-${m.name}`, "Upgrade queued", async () => {
|
||
await api(`/api/mcp/servers/${m.name}/upgrade`, { method: "POST" });
|
||
})
|
||
}
|
||
>
|
||
{busy === `upgrade-${m.name}` ? "…" : "Upgrade"}
|
||
</button>
|
||
<button
|
||
className="btn-ghost btn-sm text-accent"
|
||
disabled={!!busy}
|
||
onClick={() => {
|
||
setDevelopIssue("");
|
||
setDevelopOpen(m.name);
|
||
}}
|
||
>
|
||
Develop
|
||
</button>
|
||
</>
|
||
)}
|
||
<StatusBadge status={m.status} />
|
||
</div>
|
||
</div>
|
||
{m.error && <div className="mt-2 text-sm text-bad">{m.error}</div>}
|
||
{expanded === m.name && m.tools?.length > 0 && (
|
||
<div className="mt-4 grid grid-cols-1 gap-2 md:grid-cols-2">
|
||
{m.tools.map((t) => (
|
||
<div key={t.name} className="rounded-lg bg-panel2 px-3 py-2">
|
||
<div className="font-mono text-xs text-accent2">{t.name}</div>
|
||
{t.description && (
|
||
<div className="mt-1 line-clamp-2 text-xs text-muted">{t.description}</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<Modal
|
||
open={!!developOpen}
|
||
onClose={() => setDevelopOpen(null)}
|
||
title={`Develop ${developOpen || ""}`}
|
||
>
|
||
<p className="text-sm text-muted">
|
||
Describe the missing tool or bug. A monitor task opens automatically so you can watch
|
||
analyze → patch → reload → git push in the task timeline. Push to Gitea needs approval
|
||
unless auto-push is enabled.
|
||
</p>
|
||
<textarea
|
||
className="input mt-4 min-h-[120px]"
|
||
value={developIssue}
|
||
onChange={(e) => setDevelopIssue(e.target.value)}
|
||
placeholder="e.g. add pfsense_list_firewall_rules_by_interface or fix NameError in get_firewall_log"
|
||
/>
|
||
<div className="mt-4 flex justify-end gap-2">
|
||
<button className="btn-ghost" onClick={() => setDevelopOpen(null)}>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
className="btn-primary"
|
||
disabled={!developIssue.trim() || !!busy}
|
||
onClick={() => {
|
||
const server = developOpen;
|
||
if (!server) return;
|
||
run(`develop-${server}`, "Develop started", async () => {
|
||
const res = await api<{ task_id?: number; summary?: string }>(
|
||
`/api/mcp/servers/${server}/develop`,
|
||
{
|
||
method: "POST",
|
||
body: JSON.stringify({ issue: developIssue.trim() }),
|
||
}
|
||
);
|
||
setDevelopOpen(null);
|
||
if (res.task_id) {
|
||
router.push(`/tasks/${res.task_id}`);
|
||
}
|
||
});
|
||
}}
|
||
>
|
||
{busy === `develop-${developOpen}` ? "Starting…" : "Run & monitor"}
|
||
</button>
|
||
</div>
|
||
</Modal>
|
||
</AppShell>
|
||
);
|
||
}
|