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>
224 lines
10 KiB
TypeScript
224 lines
10 KiB
TypeScript
"use client";
|
|
|
|
import useSWR from "swr";
|
|
import Link from "next/link";
|
|
import AppShell from "@/components/AppShell";
|
|
import { PageHeader, StatusBadge } from "@/components/ui";
|
|
import { fetcher } from "@/lib/api";
|
|
import { isActiveStatus, TaskOverview } from "@/lib/task-types";
|
|
|
|
type Balance = {
|
|
provider: string;
|
|
remaining: number | null;
|
|
total_credits: number | null;
|
|
total_usage: number | null;
|
|
currency: string;
|
|
error: string | null;
|
|
};
|
|
type Mcp = { name: string; status: string; tools: { name: string }[] };
|
|
|
|
export default function Dashboard() {
|
|
const { data: balances } = useSWR<Balance[]>("/api/balance", fetcher, { refreshInterval: 60000 });
|
|
const { data: mcps } = useSWR<Mcp[]>("/api/mcp/servers", fetcher, { refreshInterval: 30000 });
|
|
const { data: tasks } = useSWR<TaskOverview[]>("/api/tasks/overview", fetcher, {
|
|
refreshInterval: (data) =>
|
|
(data || []).some((t) => isActiveStatus(t.status)) ? 8000 : 30000,
|
|
});
|
|
|
|
const taskList = tasks || [];
|
|
const active = taskList.filter((t) => isActiveStatus(t.status));
|
|
const approvals = taskList.filter((t) => t.status === "waiting_approval");
|
|
const failed = taskList.filter((t) => t.status === "failed");
|
|
const artifactWarnings = taskList.filter((t) => t.obsidian_push_error);
|
|
const lowBalance = (balances || []).filter((b) => b.error || (b.remaining != null && b.remaining < 1));
|
|
const attention = [...approvals, ...failed, ...artifactWarnings].filter(
|
|
(task, index, arr) => arr.findIndex((t) => t.id === task.id) === index
|
|
);
|
|
const recentReports = taskList.filter((t) => t.summary || t.key_finding).slice(0, 5);
|
|
const mcpReady = (mcps || []).filter((m) => m.status === "loaded").length;
|
|
const mcpTotal = (mcps || []).length;
|
|
|
|
return (
|
|
<AppShell>
|
|
<PageHeader
|
|
title="Operations Dashboard"
|
|
subtitle="Active troubleshooting, attention queue, recent findings, and platform readiness"
|
|
action={
|
|
<Link href="/tasks" className="btn-primary">
|
|
New / view tasks
|
|
</Link>
|
|
}
|
|
/>
|
|
|
|
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
|
<MetricCard label="Active" value={active.length} tone={active.length ? "accent" : "muted"} />
|
|
<MetricCard label="Needs approval" value={approvals.length} tone={approvals.length ? "warn" : "muted"} />
|
|
<MetricCard label="Failed" value={failed.length} tone={failed.length ? "bad" : "muted"} />
|
|
<MetricCard label="MCP ready" value={`${mcpReady}/${mcpTotal || "—"}`} tone={mcpReady === mcpTotal ? "good" : "warn"} />
|
|
</div>
|
|
|
|
<div className="mt-6 grid grid-cols-1 gap-6 xl:grid-cols-[1.3fr_0.9fr]">
|
|
<div className="card">
|
|
<div className="mb-4 flex items-center justify-between">
|
|
<h2 className="font-semibold">Active Tasks</h2>
|
|
<span className="text-xs text-muted">queued, running, approvals</span>
|
|
</div>
|
|
<div className="space-y-2">
|
|
{active.map((t) => (
|
|
<Link key={t.id} href={`/tasks/${t.id}`} className="block rounded-xl bg-panel2 p-3 hover:ring-1 hover:ring-accent/40">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<div className="truncate text-sm font-medium">{t.title}</div>
|
|
<div className="mt-1 text-xs text-muted">
|
|
{t.vessel_name || "No vessel"} · {t.devices_checked?.join(", ") || "scope pending"}
|
|
</div>
|
|
</div>
|
|
<StatusBadge status={t.status} />
|
|
</div>
|
|
<p className="mt-2 line-clamp-2 text-sm text-muted">{t.summary || t.issue}</p>
|
|
</Link>
|
|
))}
|
|
{active.length === 0 && (
|
|
<div className="rounded-xl bg-panel2 p-4 text-sm text-muted">No active tasks right now.</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="card">
|
|
<div className="mb-4 flex items-center justify-between">
|
|
<h2 className="font-semibold">Attention Needed</h2>
|
|
<Link href="/tasks" className="text-xs text-accent hover:underline">
|
|
View all
|
|
</Link>
|
|
</div>
|
|
<div className="space-y-2">
|
|
{attention.map((t) => (
|
|
<Link key={t.id} href={`/tasks/${t.id}`} className="block rounded-xl bg-panel2 p-3 hover:ring-1 hover:ring-warn/40">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<span className="truncate text-sm font-medium">{t.title}</span>
|
|
<StatusBadge status={t.status} />
|
|
</div>
|
|
<p className="mt-1 text-xs text-muted">
|
|
{t.obsidian_push_error
|
|
? "Obsidian push failed"
|
|
: t.status === "waiting_approval"
|
|
? "Approval required before changes continue"
|
|
: t.status === "failed"
|
|
? t.key_finding || "Task failed"
|
|
: t.issue}
|
|
</p>
|
|
</Link>
|
|
))}
|
|
{lowBalance.map((b) => (
|
|
<div key={b.provider} className="rounded-xl bg-panel2 p-3">
|
|
<div className="text-sm font-medium capitalize text-warn">{b.provider} budget</div>
|
|
<div className="mt-1 text-xs text-muted">{b.error || `Low balance: $${b.remaining?.toFixed(2)}`}</div>
|
|
</div>
|
|
))}
|
|
{attention.length === 0 && lowBalance.length === 0 && (
|
|
<div className="rounded-xl bg-panel2 p-4 text-sm text-muted">Nothing needs attention.</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-6 grid grid-cols-1 gap-6 xl:grid-cols-[1.2fr_0.8fr]">
|
|
<div className="card">
|
|
<div className="mb-4 flex items-center justify-between">
|
|
<h2 className="font-semibold">Recent Findings</h2>
|
|
<Link href="/tasks" className="text-xs text-accent hover:underline">
|
|
All reports
|
|
</Link>
|
|
</div>
|
|
<div className="space-y-3">
|
|
{recentReports.map((t) => (
|
|
<Link key={t.id} href={`/tasks/${t.id}`} className="block rounded-xl bg-panel2 p-3 hover:ring-1 hover:ring-accent/40">
|
|
<div className="mb-1 flex flex-wrap items-center gap-2">
|
|
<span className="text-sm font-medium">{t.title}</span>
|
|
{t.vessel_name && <span className="badge bg-panel text-xs text-accent">{t.vessel_name}</span>}
|
|
<StatusBadge status={t.status} />
|
|
</div>
|
|
<p className="line-clamp-2 text-sm text-muted">{t.key_finding || t.summary}</p>
|
|
<div className="mt-2 flex flex-wrap gap-2 text-[11px] text-muted">
|
|
{t.devices_checked?.length > 0 && <span>checked: {t.devices_checked.join(", ")}</span>}
|
|
{t.memory_id && <span>memory saved</span>}
|
|
{t.obsidian_path && <span>obsidian saved</span>}
|
|
</div>
|
|
</Link>
|
|
))}
|
|
{recentReports.length === 0 && <div className="text-sm text-muted">No completed findings yet.</div>}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-6">
|
|
<div className="card">
|
|
<div className="mb-4 flex items-center justify-between">
|
|
<h2 className="font-semibold">Budgets</h2>
|
|
<span className="text-xs text-muted">API balances</span>
|
|
</div>
|
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-1">
|
|
{(balances || []).map((b) => (
|
|
<div key={b.provider} className="rounded-xl bg-panel2 p-3">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm font-medium capitalize">{b.provider}</span>
|
|
<span className="text-xs text-muted">{b.currency}</span>
|
|
</div>
|
|
{b.error ? (
|
|
<div className="mt-2 text-sm text-bad">{b.error}</div>
|
|
) : (
|
|
<div className="mt-2 text-2xl font-semibold">
|
|
{b.remaining != null ? `$${b.remaining.toFixed(2)}` : "—"}
|
|
</div>
|
|
)}
|
|
{b.total_usage != null && <div className="mt-1 text-xs text-muted">used ${b.total_usage.toFixed(2)}</div>}
|
|
</div>
|
|
))}
|
|
{(!balances || balances.length === 0) && <div className="text-sm text-muted">No balance data yet.</div>}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="card">
|
|
<div className="mb-4 flex items-center justify-between">
|
|
<h2 className="font-semibold">Platform Readiness</h2>
|
|
<Link href="/mcp" className="text-xs text-accent hover:underline">
|
|
MCP details
|
|
</Link>
|
|
</div>
|
|
<div className="space-y-2">
|
|
{(mcps || []).map((m) => (
|
|
<div key={m.name} className="flex items-center justify-between rounded-xl bg-panel2 px-3 py-2">
|
|
<div>
|
|
<div className="text-sm font-medium">{m.name}</div>
|
|
<div className="text-xs text-muted">{m.tools?.length || 0} tools</div>
|
|
</div>
|
|
<StatusBadge status={m.status} />
|
|
</div>
|
|
))}
|
|
{(!mcps || mcps.length === 0) && <div className="text-sm text-muted">Worker is starting MCP servers.</div>}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</AppShell>
|
|
);
|
|
}
|
|
|
|
function MetricCard({ label, value, tone }: { label: string; value: number | string; tone: "accent" | "warn" | "bad" | "good" | "muted" }) {
|
|
const toneClass =
|
|
tone === "accent"
|
|
? "text-accent"
|
|
: tone === "warn"
|
|
? "text-warn"
|
|
: tone === "bad"
|
|
? "text-bad"
|
|
: tone === "good"
|
|
? "text-good"
|
|
: "text-muted";
|
|
return (
|
|
<div className="card">
|
|
<div className="text-xs uppercase tracking-wide text-muted">{label}</div>
|
|
<div className={`mt-2 text-3xl font-semibold ${toneClass}`}>{value}</div>
|
|
</div>
|
|
);
|
|
}
|