Initial commit: Agentic OS troubleshooting platform
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>
This commit is contained in:
199
frontend/app/mcp/page.tsx
Normal file
199
frontend/app/mcp/page.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user