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:
453
frontend/app/tasks/[id]/page.tsx
Normal file
453
frontend/app/tasks/[id]/page.tsx
Normal 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">Couldn’t 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>
|
||||
);
|
||||
}
|
||||
394
frontend/app/tasks/page.tsx
Normal file
394
frontend/app/tasks/page.tsx
Normal file
@@ -0,0 +1,394 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import useSWR from "swr";
|
||||
import { useRouter } from "next/navigation";
|
||||
import AppShell from "@/components/AppShell";
|
||||
import {
|
||||
EmptyState,
|
||||
FormError,
|
||||
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";
|
||||
import { TaskPreview } from "@/lib/task-types";
|
||||
|
||||
const ACTIVE_STATUSES = ["running", "queued", "waiting_approval"];
|
||||
const STATUS_FILTERS = ["all", "queued", "running", "waiting_approval", "succeeded", "failed", "cancelled"];
|
||||
const TEMPLATES = [
|
||||
{
|
||||
label: "Proxmox + pfSense status",
|
||||
title: "Proxmox and pfSense status",
|
||||
issue: "check status of proxmox and pfsense",
|
||||
},
|
||||
{
|
||||
label: "Full vessel health",
|
||||
title: "Full vessel health check",
|
||||
issue: "run a full stack health check on all systems",
|
||||
},
|
||||
{
|
||||
label: "Firewall rules",
|
||||
title: "pfSense firewall rules",
|
||||
issue: "list firewall rules, NAT, gateways, and interfaces for pfsense",
|
||||
},
|
||||
{
|
||||
label: "VoIP registration",
|
||||
title: "VoIP registration check",
|
||||
issue: "check pfsense SIP/NAT and asterisk registrations",
|
||||
},
|
||||
];
|
||||
|
||||
type Task = {
|
||||
id: number;
|
||||
title: string;
|
||||
issue: string;
|
||||
status: string;
|
||||
vessel_id: number | null;
|
||||
vessel_name?: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
type VesselDetail = {
|
||||
id: number;
|
||||
name: string;
|
||||
devices?: { catalog_key: string | null; device_type: string; name: string }[];
|
||||
};
|
||||
|
||||
export default function TasksPage() {
|
||||
const { user } = useAuth();
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
const {
|
||||
data: tasks,
|
||||
error: tasksError,
|
||||
isLoading,
|
||||
mutate,
|
||||
} = useSWR<Task[]>("/api/tasks", fetcher, {
|
||||
refreshInterval: (data) =>
|
||||
(data || []).some((t) => ACTIVE_STATUSES.includes(t.status)) ? 5000 : 0,
|
||||
});
|
||||
const { data: vessels } = useSWR<VesselDetail[]>("/api/vessels", fetcher);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [form, setForm] = useState({ title: "", issue: "", vessel_id: "" });
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [preview, setPreview] = useState<TaskPreview | null>(null);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const canCreate = roleAtLeast(user?.role, "support");
|
||||
|
||||
const selectedVessel = useMemo(
|
||||
() => vessels?.find((v) => String(v.id) === form.vessel_id),
|
||||
[vessels, form.vessel_id]
|
||||
);
|
||||
|
||||
const filteredTasks = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return (tasks || []).filter((t) => {
|
||||
if (statusFilter !== "all" && t.status !== statusFilter) return false;
|
||||
if (!q) return true;
|
||||
return (
|
||||
t.title.toLowerCase().includes(q) ||
|
||||
t.issue.toLowerCase().includes(q) ||
|
||||
(t.vessel_name || "").toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
}, [tasks, query, statusFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
const issue = form.issue.trim();
|
||||
if (!open || !form.vessel_id || issue.length < 4) {
|
||||
setPreview(null);
|
||||
setPreviewLoading(false);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const timer = setTimeout(async () => {
|
||||
setPreviewLoading(true);
|
||||
try {
|
||||
const data = await api<TaskPreview>("/api/tasks/preview", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
title: form.title,
|
||||
issue,
|
||||
vessel_id: Number(form.vessel_id),
|
||||
}),
|
||||
});
|
||||
if (!cancelled) setPreview(data);
|
||||
} catch {
|
||||
if (!cancelled) setPreview(null);
|
||||
} finally {
|
||||
if (!cancelled) setPreviewLoading(false);
|
||||
}
|
||||
}, 350);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [form.issue, form.title, form.vessel_id, open]);
|
||||
|
||||
const create = async (e: React.FormEvent | null, watch = true) => {
|
||||
e?.preventDefault();
|
||||
setError(null);
|
||||
if (!form.vessel_id) {
|
||||
setError("Select a vessel");
|
||||
return;
|
||||
}
|
||||
if (form.issue.trim().length < 5) {
|
||||
setError("Describe the issue in a bit more detail");
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
try {
|
||||
const payload = {
|
||||
title: form.title,
|
||||
issue: form.issue,
|
||||
vessel_id: Number(form.vessel_id),
|
||||
};
|
||||
const created = await api<Task>("/api/tasks", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
setOpen(false);
|
||||
setForm({ title: "", issue: "", vessel_id: "" });
|
||||
setPreview(null);
|
||||
toast.success("Task created");
|
||||
mutate();
|
||||
router.push(watch ? `/tasks/${created.id}` : "/");
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
toast.error(err.message || "Could not create task");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<PageHeader
|
||||
title="Tasks"
|
||||
subtitle="Pick a vessel and describe the issue — the agent chooses which onboard devices to check"
|
||||
action={
|
||||
canCreate && (
|
||||
<button className="btn-primary" onClick={() => setOpen(true)}>
|
||||
+ New task
|
||||
</button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
{tasks && tasks.length > 0 && (
|
||||
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<input
|
||||
className="input sm:max-w-xs"
|
||||
placeholder="Search title, issue, or vessel…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
aria-label="Search tasks"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{STATUS_FILTERS.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setStatusFilter(s)}
|
||||
className={`badge btn-sm ${
|
||||
statusFilter === s ? "bg-accent/20 text-accent" : "bg-panel2 text-muted"
|
||||
}`}
|
||||
>
|
||||
{s.replace(/_/g, " ")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && !tasks ? (
|
||||
<SkeletonRows rows={4} />
|
||||
) : tasksError ? (
|
||||
<LoadError message="Couldn’t load tasks." onRetry={() => mutate()} />
|
||||
) : (tasks || []).length === 0 ? (
|
||||
<EmptyState
|
||||
title="No tasks yet"
|
||||
description="Create a troubleshooting task and the agent will diagnose the selected vessel."
|
||||
action={
|
||||
canCreate && (
|
||||
<button className="btn-primary" onClick={() => setOpen(true)}>
|
||||
+ New task
|
||||
</button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : filteredTasks.length === 0 ? (
|
||||
<EmptyState title="No matching tasks" description="Try a different search or status filter." />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
{filteredTasks.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => router.push(`/tasks/${t.id}`)}
|
||||
aria-label={`Task ${t.title}, status ${t.status.replace(/_/g, " ")}`}
|
||||
className="card flex items-center justify-between gap-3 text-left hover:border-accent/50"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{t.title}</span>
|
||||
<span className="text-xs text-muted">#{t.id}</span>
|
||||
{t.vessel_name && (
|
||||
<span className="badge bg-panel2 text-xs text-accent">{t.vessel_name}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 truncate text-sm text-muted">{t.issue}</div>
|
||||
</div>
|
||||
<StatusBadge status={t.status} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal open={open} onClose={() => setOpen(false)} title="New troubleshooting task">
|
||||
<form onSubmit={(e) => create(e, true)} className="space-y-4">
|
||||
<div>
|
||||
<div className="label">Quick templates</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{TEMPLATES.map((template) => (
|
||||
<button
|
||||
key={template.label}
|
||||
type="button"
|
||||
className="badge bg-panel2 text-xs text-muted hover:text-accent"
|
||||
onClick={() =>
|
||||
setForm({
|
||||
...form,
|
||||
title: template.title,
|
||||
issue: template.issue,
|
||||
})
|
||||
}
|
||||
>
|
||||
{template.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="task-title" className="label">
|
||||
Title
|
||||
</label>
|
||||
<input
|
||||
id="task-title"
|
||||
className="input"
|
||||
value={form.title}
|
||||
onChange={(e) => setForm({ ...form, title: e.target.value })}
|
||||
placeholder="e.g. GeneseasX health check"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="task-issue" className="label">
|
||||
Describe the issue
|
||||
</label>
|
||||
<textarea
|
||||
id="task-issue"
|
||||
className="input min-h-[120px]"
|
||||
value={form.issue}
|
||||
onChange={(e) => setForm({ ...form, issue: e.target.value })}
|
||||
placeholder="e.g. check proxmox health, pfsense gateway status, and asterisk registrations"
|
||||
required
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted">
|
||||
Mention device types (proxmox, pfsense, asterisk, docker…) or say "health status" to
|
||||
check all onboard devices.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="task-vessel" className="label">
|
||||
Vessel
|
||||
</label>
|
||||
<select
|
||||
id="task-vessel"
|
||||
className="input"
|
||||
value={form.vessel_id}
|
||||
onChange={(e) => setForm({ ...form, vessel_id: e.target.value })}
|
||||
required
|
||||
>
|
||||
<option value="">— select vessel —</option>
|
||||
{(vessels || []).map((v) => (
|
||||
<option key={v.id} value={v.id}>
|
||||
{v.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{selectedVessel?.devices && selectedVessel.devices.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{selectedVessel.devices.map((d) => (
|
||||
<span
|
||||
key={d.catalog_key || d.name}
|
||||
className="badge bg-panel2 text-xs text-muted"
|
||||
>
|
||||
{d.catalog_key || d.device_type}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-xl border border-edge bg-panel2 p-3">
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<div className="font-medium">What will be checked</div>
|
||||
{previewLoading && <span className="text-xs text-muted">detecting…</span>}
|
||||
</div>
|
||||
{!form.vessel_id ? (
|
||||
<p className="text-sm text-muted">Select a vessel to preview onboard devices.</p>
|
||||
) : !form.issue.trim() ? (
|
||||
<p className="text-sm text-muted">Describe the issue to preview device scope.</p>
|
||||
) : preview?.warning ? (
|
||||
<p className="text-sm text-warn">{preview.warning}</p>
|
||||
) : preview?.devices.length ? (
|
||||
<div className="space-y-3">
|
||||
{preview.rule_name && (
|
||||
<div className="text-xs text-muted">
|
||||
Rule: <span className="text-accent">{preview.rule_name}</span>
|
||||
{preview.severity ? ` · ${preview.severity}` : ""}
|
||||
</div>
|
||||
)}
|
||||
{preview.devices.map((device) => (
|
||||
<div key={device.catalog_key} className="rounded-lg bg-panel px-3 py-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium">{device.label}</span>
|
||||
<span className="badge bg-panel2 text-xs text-muted">{device.catalog_key}</span>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{device.checks.map((check) => (
|
||||
<span key={check} className="badge bg-accent/10 text-[11px] text-accent">
|
||||
{check}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted">No preview yet.</p>
|
||||
)}
|
||||
</div>
|
||||
<FormError message={error} />
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<button className="btn-primary w-full" disabled={creating}>
|
||||
{creating ? "Creating…" : "Create & watch live"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost w-full"
|
||||
disabled={creating}
|
||||
onClick={() => create(null, false)}
|
||||
>
|
||||
Run in background
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user