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:
354
frontend/components/task-detail-panels.tsx
Normal file
354
frontend/components/task-detail-panels.tsx
Normal file
@@ -0,0 +1,354 @@
|
||||
"use client";
|
||||
|
||||
import type React from "react";
|
||||
import { StatusBadge } from "@/components/ui";
|
||||
import { TimelineEvent } from "@/components/TaskLiveStatus";
|
||||
import { humanToolName, TaskEvent } from "@/lib/task-types";
|
||||
|
||||
export type LlmUsage = {
|
||||
step: string;
|
||||
provider: string;
|
||||
backend: string;
|
||||
model: string;
|
||||
display: string;
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
cost_usd: number;
|
||||
};
|
||||
|
||||
export type RunHistory = {
|
||||
run_number: number;
|
||||
follow_up?: string | null;
|
||||
summary?: string;
|
||||
resolved?: boolean;
|
||||
run_cost_usd?: number;
|
||||
llm_usage?: LlmUsage[];
|
||||
};
|
||||
|
||||
export function formatCost(usd: number | undefined | null) {
|
||||
if (usd === undefined || usd === null) return "$0.0000";
|
||||
if (usd === 0) return "$0.00 (local)";
|
||||
return `$${usd.toFixed(4)}`;
|
||||
}
|
||||
|
||||
export function TaskScopeCard({ task, report }: { task: any; report: any }) {
|
||||
const rule = report?.matched_rule;
|
||||
const checked = report?.devices_checked || [];
|
||||
return (
|
||||
<div className="card">
|
||||
<h2 className="mb-3 font-semibold">Scope</h2>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div>
|
||||
<div className="label">Requested</div>
|
||||
<p className="text-muted">{task.issue}</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="label">Devices selected</div>
|
||||
<div className="mt-1 flex flex-wrap gap-2">
|
||||
{checked.length ? (
|
||||
checked.map((device: string) => (
|
||||
<span key={device} className="badge bg-accent/10 text-accent">
|
||||
{device}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="text-muted">Selection pending</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{rule && (
|
||||
<div>
|
||||
<div className="label">Rule</div>
|
||||
<p className="text-muted">{rule.name || rule.id}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskTimelinePanel({
|
||||
events,
|
||||
wsConnected,
|
||||
pinnedToBottom,
|
||||
scrollBoxRef,
|
||||
endRef,
|
||||
onScroll,
|
||||
onJump,
|
||||
loading,
|
||||
}: {
|
||||
events: TaskEvent[];
|
||||
wsConnected: boolean;
|
||||
pinnedToBottom: boolean;
|
||||
scrollBoxRef: React.RefObject<HTMLDivElement>;
|
||||
endRef: React.RefObject<HTMLDivElement>;
|
||||
onScroll: () => void;
|
||||
onJump: () => void;
|
||||
loading: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold">Run Timeline</h2>
|
||||
<p className="mt-1 text-xs text-muted">Plain-language progress; expand events only when evidence is needed.</p>
|
||||
</div>
|
||||
<span className={`badge text-xs ${wsConnected ? "bg-good/15 text-good" : "bg-warn/15 text-warn"}`}>
|
||||
{wsConnected ? "live" : "reconnecting"}
|
||||
</span>
|
||||
</div>
|
||||
<div ref={scrollBoxRef} onScroll={onScroll} className="max-h-[520px] space-y-2 overflow-y-auto pr-1">
|
||||
{events.map((ev, i) => (
|
||||
<TimelineEvent key={`${ev.id || ""}-${ev.ts || ev.created_at || ""}-${ev.kind}-${i}`} ev={ev} />
|
||||
))}
|
||||
{events.length === 0 && <div className="text-sm text-muted">{loading ? "Loading..." : "Waiting for events..."}</div>}
|
||||
<div ref={endRef} />
|
||||
</div>
|
||||
{!pinnedToBottom && events.length > 0 && (
|
||||
<button className="btn-ghost btn-sm mt-2" onClick={onJump}>
|
||||
Jump to latest
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MarkdownTable({ text }: { text: string }) {
|
||||
const lines = text.trim().split("\n");
|
||||
const tableLines = lines.filter((line) => line.trim().startsWith("|") && line.trim().endsWith("|"));
|
||||
if (tableLines.length < 2) return null;
|
||||
const rows = tableLines
|
||||
.filter((line) => !/^\|\s*-+/.test(line))
|
||||
.map((line) => line.split("|").slice(1, -1).map((cell) => cell.trim()));
|
||||
const [head, ...body] = rows;
|
||||
if (!head || body.length === 0) return null;
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-edge text-muted">
|
||||
{head.map((cell, i) => (
|
||||
<th key={i} className="py-2 pr-3 font-medium">
|
||||
{cell}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{body.map((row, i) => (
|
||||
<tr key={i} className="border-b border-edge/50">
|
||||
{row.map((cell, j) => (
|
||||
<td key={j} className="py-2 pr-3 align-top">
|
||||
{cell}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DiagnosticEvidence({ diagnostic }: { diagnostic: any }) {
|
||||
const text = diagnostic.formatted || diagnostic.summary || diagnostic.output || "_No output_";
|
||||
const table = typeof text === "string" ? <MarkdownTable text={text} /> : null;
|
||||
return (
|
||||
<details className="rounded-lg bg-panel2 p-3">
|
||||
<summary className="cursor-pointer">
|
||||
<span className="mr-2 font-mono text-xs text-accent2">
|
||||
[{diagnostic.device}] {humanToolName(diagnostic.tool || "tool")}
|
||||
</span>
|
||||
<StatusBadge status={diagnostic.ok ? "succeeded" : "failed"} />
|
||||
{diagnostic.output_chars ? <span className="ml-2 text-xs text-muted">{diagnostic.output_chars} chars</span> : null}
|
||||
</summary>
|
||||
<div className="mt-3">
|
||||
{table || (
|
||||
<pre className="max-h-80 overflow-auto whitespace-pre-wrap rounded bg-ink/70 p-2 font-mono text-xs text-muted">
|
||||
{text}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskReportPanel({ report, pendingApprovals }: { report: any; pendingApprovals: number }) {
|
||||
const summary = report.executive_summary || report.summary;
|
||||
const findings = report.findings || [];
|
||||
const scope = report.scope_checked || report.devices_checked || [];
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<h2 className="font-semibold">Report</h2>
|
||||
<StatusBadge status={pendingApprovals > 0 ? "waiting_approval" : report.resolved ? "succeeded" : "running"} />
|
||||
</div>
|
||||
<div className="space-y-4 text-sm">
|
||||
{summary && (
|
||||
<section>
|
||||
<div className="label">Summary</div>
|
||||
<p>{summary}</p>
|
||||
</section>
|
||||
)}
|
||||
{scope.length > 0 && (
|
||||
<section>
|
||||
<div className="label">Scope checked</div>
|
||||
<div className="mt-1 flex flex-wrap gap-2">
|
||||
{scope.map((item: any) => (
|
||||
<span key={typeof item === "string" ? item : item.device} className="badge bg-panel2 text-xs text-accent">
|
||||
{typeof item === "string" ? item : item.device}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{findings.length > 0 ? (
|
||||
<section>
|
||||
<div className="label">Findings</div>
|
||||
<div className="space-y-2">
|
||||
{findings.map((finding: any, i: number) => (
|
||||
<div key={i} className="rounded-lg bg-panel2 p-3">
|
||||
<div className="font-medium">{finding.title || finding.summary || String(finding)}</div>
|
||||
{finding.description && <p className="mt-1 text-muted">{finding.description}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<section>
|
||||
<div className="label">Finding</div>
|
||||
<p>{report.root_cause || report.resolution || "No notable finding recorded."}</p>
|
||||
</section>
|
||||
)}
|
||||
{report.recommendations?.length > 0 && (
|
||||
<section>
|
||||
<div className="label">Recommendations</div>
|
||||
<ul className="list-disc space-y-1 pl-5">
|
||||
{report.recommendations.map((r: { description?: string }, i: number) => (
|
||||
<li key={i}>{r.description || JSON.stringify(r)}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
{report.actions_taken?.length > 0 && (
|
||||
<details>
|
||||
<summary className="cursor-pointer text-sm font-medium text-muted">Actions taken</summary>
|
||||
<ul className="mt-2 list-disc space-y-1 pl-5">
|
||||
{report.actions_taken.map((s: string, i: number) => (
|
||||
<li key={i}>{s}</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
{report.diagnostics?.length > 0 && (
|
||||
<section>
|
||||
<div className="label">Evidence</div>
|
||||
<div className="space-y-2">
|
||||
{report.diagnostics.map((d: any, i: number) => (
|
||||
<DiagnosticEvidence key={i} diagnostic={d} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskArtifactsPanel({ task, report }: { task: any; report: any }) {
|
||||
return (
|
||||
<div className="card">
|
||||
<h2 className="mb-3 font-semibold">Saved Artifacts</h2>
|
||||
<div className="space-y-2 text-sm">
|
||||
<ArtifactRow label="Memory" value={task?.memory_id || report?.memory_id} detail={report?.memory_project_id || "agentic-os-task"} />
|
||||
<ArtifactRow
|
||||
label="Obsidian"
|
||||
value={task?.obsidian_path || report?.obsidian_path}
|
||||
detail={report?.obsidian_push_error ? "push failed" : "pushed"}
|
||||
bad={!!report?.obsidian_push_error}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ArtifactRow({ label, value, detail, bad = false }: { label: string; value?: string | null; detail?: string; bad?: boolean }) {
|
||||
return (
|
||||
<div className="rounded-lg bg-panel2 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="font-medium">{label}</span>
|
||||
<span className={`text-xs ${bad ? "text-bad" : "text-muted"}`}>{detail}</span>
|
||||
</div>
|
||||
<div className="mt-1 break-all font-mono text-xs text-muted">{value || "Not saved"}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function providerLabel(provider: string) {
|
||||
return provider === "local" ? "Local" : "API";
|
||||
}
|
||||
|
||||
export function TaskCostPanel({ report, llmUsage }: { report: any; llmUsage: LlmUsage[] }) {
|
||||
return (
|
||||
<div className="card">
|
||||
<h2 className="mb-3 font-semibold">Models & Cost</h2>
|
||||
<div className="mb-3 flex flex-wrap gap-4 text-sm">
|
||||
<div>
|
||||
<div className="label">This run</div>
|
||||
<div className="font-mono">{formatCost(report.run_cost_usd)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="label">Total</div>
|
||||
<div className="font-mono">{formatCost(report.total_cost_usd)}</div>
|
||||
</div>
|
||||
</div>
|
||||
{llmUsage.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-edge text-muted">
|
||||
<th className="py-2 pr-3">Step</th>
|
||||
<th className="py-2 pr-3">Type</th>
|
||||
<th className="py-2 pr-3">Model</th>
|
||||
<th className="py-2">Cost</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{llmUsage.map((u, i) => (
|
||||
<tr key={i} className="border-b border-edge/50">
|
||||
<td className="py-2 pr-3 capitalize">{u.step}</td>
|
||||
<td className="py-2 pr-3">{providerLabel(u.provider)}</td>
|
||||
<td className="py-2 pr-3 font-mono">{u.display || u.model}</td>
|
||||
<td className="py-2 font-mono">{formatCost(u.cost_usd)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted">No LLM usage recorded.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskRunHistoryPanel({ runs }: { runs: RunHistory[] }) {
|
||||
if (runs.length <= 1) return null;
|
||||
return (
|
||||
<div className="card">
|
||||
<h2 className="mb-3 font-semibold">Run History</h2>
|
||||
<div className="space-y-3">
|
||||
{runs.map((r) => (
|
||||
<div key={r.run_number} className="rounded-lg bg-panel2 p-3 text-sm">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium">Run {r.run_number}</span>
|
||||
<span className="text-xs text-muted">{formatCost(r.run_cost_usd)}</span>
|
||||
</div>
|
||||
{r.follow_up && <p className="mt-1 text-muted">Follow-up: {r.follow_up}</p>}
|
||||
{r.summary && <p className="mt-1">{r.summary}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user