Files
nearxos 6185b9b85a 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>
2026-06-14 22:11:07 +03:00

454 lines
15 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}