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>
395 lines
13 KiB
TypeScript
395 lines
13 KiB
TypeScript
"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>
|
||
);
|
||
}
|