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:
2026-06-14 22:11:07 +03:00
commit 6185b9b85a
126 changed files with 14565 additions and 0 deletions

View File

@@ -0,0 +1,453 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useParams } from "next/navigation";
import useSWR from "swr";
import AppShell from "@/components/AppShell";
import TaskLiveStatus from "@/components/TaskLiveStatus";
import { ConfirmDialog, Modal, PageHeader, StatusBadge } from "@/components/ui";
import { useToast } from "@/components/toast";
import { api, fetcher, wsUrl } from "@/lib/api";
import { useAuth, roleAtLeast } from "@/lib/auth";
import { TaskEvent } from "@/lib/task-types";
import {
LlmUsage,
RunHistory,
TaskArtifactsPanel,
TaskCostPanel,
TaskReportPanel,
TaskRunHistoryPanel,
TaskScopeCard,
TaskTimelinePanel,
} from "@/components/task-detail-panels";
const MAX_EVENTS = 600;
const ACTIVE_STATUSES = ["running", "queued", "waiting_approval"];
type Event = TaskEvent;
type Approval = {
id: number;
tool_name: string;
tool_args: any;
risk: string | null;
status: string;
};
type Task = {
id: number;
title: string;
issue: string;
status: string;
report: any;
error: string | null;
memory_id: string | null;
obsidian_path: string | null;
vessel_id: number | null;
vessel_name?: string | null;
events?: Event[];
};
export default function TaskDetailPage() {
const params = useParams();
const id = params.id as string;
const { user } = useAuth();
const canAct = roleAtLeast(user?.role, "support");
const toast = useToast();
// Poll only while the task is active; stop once it settles (WS drives live updates).
const taskActive = (data?: Task) => !!data && ACTIVE_STATUSES.includes(data.status);
const { data: task, error: taskError, isLoading: taskLoading, mutate } = useSWR<Task>(
`/api/tasks/${id}`,
fetcher,
{ refreshInterval: (d) => (taskActive(d) ? 8000 : 0) }
);
const { data: approvals, mutate: mutateApprovals } = useSWR<Approval[]>(
`/api/tasks/${id}/approvals`,
fetcher,
{ refreshInterval: (d) => ((d || []).some((a) => a.status === "pending") ? 8000 : 0) }
);
const [events, setEvents] = useState<Event[]>([]);
const [wsConnected, setWsConnected] = useState(false);
const [continueOpen, setContinueOpen] = useState(false);
const [retryOpen, setRetryOpen] = useState(false);
const [cancelOpen, setCancelOpen] = useState(false);
const [followUp, setFollowUp] = useState("");
const [actionError, setActionError] = useState<string | null>(null);
const [acting, setActing] = useState(false);
const [decidingId, setDecidingId] = useState<number | null>(null);
const [pinnedToBottom, setPinnedToBottom] = useState(true);
const endRef = useRef<HTMLDivElement>(null);
const scrollBoxRef = useRef<HTMLDivElement>(null);
const appendEvent = useCallback((ev: Event) => {
setEvents((prev) => {
const key = `${ev.id || ""}-${ev.ts || ev.created_at || ""}-${ev.kind}-${ev.message || ""}`;
if (prev.some((item) => `${item.id || ""}-${item.ts || item.created_at || ""}-${item.kind}-${item.message || ""}` === key)) {
return prev;
}
const next = [...prev, ev];
return next.length > MAX_EVENTS ? next.slice(next.length - MAX_EVENTS) : next;
});
}, []);
useEffect(() => {
if (!task?.events?.length) return;
setEvents((prev) => {
if (prev.length > 0) return prev;
return task.events!.slice(-MAX_EVENTS);
});
}, [task?.events]);
// Hydrate historical events once (so joining mid/post-run shows context),
// then open a WebSocket with exponential-backoff reconnect.
useEffect(() => {
let ws: WebSocket | null = null;
let closed = false;
let retry = 0;
let timer: ReturnType<typeof setTimeout>;
setEvents([]);
const connect = () => {
ws = new WebSocket(wsUrl(`/api/tasks/${id}/stream`));
ws.onopen = () => {
retry = 0;
setWsConnected(true);
};
ws.onmessage = (msg) => {
try {
const ev = JSON.parse(msg.data) as Event;
appendEvent(ev);
if (ev.kind === "approval") {
mutateApprovals();
mutate();
}
if (ev.kind === "report" || ev.kind === "error" || ev.kind === "cancelled") {
mutate();
}
} catch {
// ignore malformed frames
}
};
ws.onclose = () => {
setWsConnected(false);
if (closed) return;
retry += 1;
const delay = Math.min(1000 * 2 ** retry, 15000);
timer = setTimeout(connect, delay);
};
ws.onerror = () => ws?.close();
};
connect();
return () => {
closed = true;
clearTimeout(timer);
ws?.close();
};
}, [id, appendEvent, mutate, mutateApprovals]);
useEffect(() => {
if (pinnedToBottom) endRef.current?.scrollIntoView({ behavior: "smooth" });
}, [events, pinnedToBottom]);
const onScroll = () => {
const el = scrollBoxRef.current;
if (!el) return;
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
setPinnedToBottom(nearBottom);
};
const decide = async (approvalId: number, approve: boolean) => {
setDecidingId(approvalId);
try {
await api(`/api/tasks/${id}/approvals/${approvalId}`, {
method: "POST",
body: JSON.stringify({ approve }),
});
toast.success(approve ? "Approved" : "Rejected");
mutateApprovals();
mutate();
} catch (err: any) {
toast.error(err.message || "Action failed");
} finally {
setDecidingId(null);
}
};
const continueTask = async (e: React.FormEvent) => {
e.preventDefault();
setActionError(null);
setActing(true);
try {
await api(`/api/tasks/${id}/continue`, {
method: "POST",
body: JSON.stringify({ follow_up: followUp }),
});
setContinueOpen(false);
setFollowUp("");
setEvents([]);
toast.success("Follow-up run queued");
mutate();
} catch (err: any) {
setActionError(err.message);
toast.error(err.message || "Could not continue task");
} finally {
setActing(false);
}
};
const retryTask = async () => {
setActing(true);
try {
await api(`/api/tasks/${id}/retry`, { method: "POST" });
setEvents([]);
setRetryOpen(false);
toast.success("Task restarted");
mutate();
} catch (err: any) {
setActionError(err.message);
toast.error(err.message || "Could not restart task");
} finally {
setActing(false);
}
};
const cancelTask = async () => {
setActing(true);
try {
await api(`/api/tasks/${id}/cancel`, { method: "POST" });
setCancelOpen(false);
toast.success("Task stopped");
mutate();
} catch (err: any) {
setActionError(err.message);
toast.error(err.message || "Could not stop task");
} finally {
setActing(false);
}
};
const report = task?.report;
const llmUsage: LlmUsage[] = report?.llm_usage || [];
const runs: RunHistory[] = report?.runs || [];
const pendingApprovals = (approvals || []).filter((a) => a.status === "pending");
const canContinue =
canAct &&
task &&
!["running", "queued"].includes(task.status);
const isRunning = task?.status === "running";
const canStop = canAct && task && ["running", "queued"].includes(task.status);
const escalateLlm = async (tier: "economy" | "premium") => {
setActionError(null);
setActing(true);
try {
await api(`/api/tasks/${id}/escalate-llm`, {
method: "POST",
body: JSON.stringify({ tier }),
});
mutate();
} catch (err: any) {
setActionError(err.message);
} finally {
setActing(false);
}
};
return (
<AppShell>
<PageHeader
backHref="/tasks"
backLabel="Tasks"
title={task?.title || `Task #${id}`}
subtitle={
task
? [
task.vessel_name && `Vessel: ${task.vessel_name}`,
task.report?.vessel_public_ip && `(${task.report.vessel_public_ip})`,
task.issue,
]
.filter(Boolean)
.join(" · ")
: undefined
}
action={
task && (
<div className="flex flex-wrap items-center gap-2">
<StatusBadge status={task.status} />
{report?.run_number > 1 && (
<span className="badge bg-panel2 text-xs text-muted">Run {report.run_number}</span>
)}
{isRunning && canAct && (
<>
<button
className="btn-ghost btn-sm border border-warn/40 text-warn"
onClick={() => escalateLlm("economy")}
disabled={acting}
title="Skip local LLM for remaining reasoning; use DeepSeek economy API"
>
Use Economy API
</button>
<button
className="btn-ghost btn-sm border border-accent2/40 text-accent2"
onClick={() => escalateLlm("premium")}
disabled={acting}
title="Skip local LLM for remaining reasoning; use premium cloud API"
>
Use Premium API
</button>
</>
)}
{canStop && (
<button
className="btn-danger btn-sm"
onClick={() => setCancelOpen(true)}
disabled={acting}
title="Stop this task if it is stuck or no longer needed"
>
Stop task
</button>
)}
{canContinue && (
<>
<button className="btn-primary btn-sm" onClick={() => setContinueOpen(true)} disabled={acting}>
Continue investigation
</button>
<button className="btn-ghost btn-sm" onClick={() => setRetryOpen(true)} disabled={acting}>
Restart from scratch
</button>
</>
)}
</div>
)
}
/>
{actionError && <div className="mb-4 text-sm text-bad">{actionError}</div>}
{taskError && !task && (
<div className="mb-4 text-sm text-bad">Couldnt load this task. Retrying</div>
)}
{task && <TaskLiveStatus events={events} taskStatus={task.status} />}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<TaskTimelinePanel
events={events}
wsConnected={wsConnected}
pinnedToBottom={pinnedToBottom}
scrollBoxRef={scrollBoxRef}
endRef={endRef}
onScroll={onScroll}
loading={taskLoading}
onJump={() => {
setPinnedToBottom(true);
endRef.current?.scrollIntoView({ behavior: "smooth" });
}}
/>
<div className="space-y-6">
{pendingApprovals.length > 0 && (
<div className="card border-warn/40">
<h2 className="mb-1 font-semibold text-warn">Approvals required</h2>
<p className="mb-3 text-xs text-muted">
Config-changing actions proposed by the agent. Approve before they can be applied.
</p>
<div className="space-y-3">
{pendingApprovals.map((a) => (
<div key={a.id} className="rounded-lg bg-panel2 p-3">
<div className="font-mono text-sm text-accent2">{a.tool_name}</div>
{a.risk && <div className="mt-1 text-sm text-muted">{a.risk}</div>}
{canAct && (
<div className="mt-3 flex gap-2">
<button
className="btn-primary btn-sm"
onClick={() => decide(a.id, true)}
disabled={decidingId === a.id}
>
{decidingId === a.id ? "Working…" : "Approve"}
</button>
<button
className="btn-danger btn-sm"
onClick={() => decide(a.id, false)}
disabled={decidingId === a.id}
>
Reject
</button>
</div>
)}
</div>
))}
</div>
</div>
)}
{report && (
<>
<TaskScopeCard task={task} report={report} />
<TaskReportPanel report={report} pendingApprovals={pendingApprovals.length} />
<TaskArtifactsPanel task={task} report={report} />
<TaskCostPanel report={report} llmUsage={llmUsage} />
<TaskRunHistoryPanel runs={runs} />
</>
)}
{task?.error && (
<div className="card border-bad/40">
<h2 className="mb-2 font-semibold text-bad">Error</h2>
<p className="text-sm">{task.error}</p>
</div>
)}
</div>
</div>
<Modal open={continueOpen} onClose={() => setContinueOpen(false)} title="Continue investigation">
<form onSubmit={continueTask} className="space-y-4">
<p className="text-sm text-muted">
The agent will use prior findings and run additional checks. Describe what to investigate next
or why the issue is still unresolved.
</p>
<div>
<label htmlFor="follow-up" className="label">
Follow-up instructions
</label>
<textarea
id="follow-up"
className="input min-h-[120px]"
value={followUp}
onChange={(e) => setFollowUp(e.target.value)}
placeholder="e.g. check asterisk SIP registrations and pfsense NAT rules for port 5060"
required
/>
</div>
{actionError && <div className="text-sm text-bad">{actionError}</div>}
<button className="btn-primary w-full" disabled={acting}>
{acting ? "Queuing…" : "Run follow-up"}
</button>
</form>
</Modal>
<ConfirmDialog
open={cancelOpen}
title="Stop this task?"
message="The agent will stop at the next checkpoint. You can restart or continue the investigation later."
confirmLabel="Stop task"
destructive
busy={acting}
onConfirm={cancelTask}
onCancel={() => setCancelOpen(false)}
/>
<ConfirmDialog
open={retryOpen}
title="Restart from scratch?"
message="Run history and cost totals will be cleared, and the task will run again from the beginning."
confirmLabel="Restart"
destructive
busy={acting}
onConfirm={retryTask}
onCancel={() => setRetryOpen(false)}
/>
</AppShell>
);
}