Files
Agentic-OS/frontend/app/tasks/page.tsx
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

395 lines
13 KiB
TypeScript
Raw 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 { 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="Couldnt 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 &quot;health status&quot; 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>
);
}