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>
135 lines
4.8 KiB
TypeScript
135 lines
4.8 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import useSWR from "swr";
|
|
import AppShell from "@/components/AppShell";
|
|
import { PageHeader } from "@/components/ui";
|
|
import { api, fetcher } from "@/lib/api";
|
|
import { useAuth } from "@/lib/auth";
|
|
|
|
type RulesResponse = {
|
|
yaml: string;
|
|
parsed: { rules?: { id: string; name: string; priority?: number }[] };
|
|
path_hint: string;
|
|
};
|
|
|
|
export default function RulesPage() {
|
|
const { user } = useAuth();
|
|
const isAdmin = user?.role === "admin";
|
|
const { data, mutate } = useSWR<RulesResponse>("/api/rules/troubleshooting", fetcher);
|
|
const [yaml, setYaml] = useState<string | null>(null);
|
|
const [previewTitle, setPreviewTitle] = useState("Asterisk health status");
|
|
const [previewIssue, setPreviewIssue] = useState("Check voip pjsip registration on GeneseasX");
|
|
const [preview, setPreview] = useState<{ matched: Record<string, unknown> | null } | null>(null);
|
|
const [saving, setSaving] = useState(false);
|
|
const [msg, setMsg] = useState<string | null>(null);
|
|
|
|
const content = yaml ?? data?.yaml ?? "";
|
|
|
|
const [msgKind, setMsgKind] = useState<"ok" | "err">("ok");
|
|
|
|
const save = async () => {
|
|
setSaving(true);
|
|
setMsg(null);
|
|
try {
|
|
await api("/api/rules/troubleshooting", { method: "PUT", body: JSON.stringify({ yaml: content }) });
|
|
setMsgKind("ok");
|
|
setMsg("Rules saved — next task run will use them.");
|
|
setYaml(null);
|
|
mutate();
|
|
} catch (err: unknown) {
|
|
setMsgKind("err");
|
|
setMsg(err instanceof Error ? err.message : "Save failed");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const runPreview = async () => {
|
|
setPreview(null);
|
|
try {
|
|
const res = await api<{ matched: Record<string, unknown> | null }>("/api/rules/troubleshooting/preview", {
|
|
method: "POST",
|
|
body: JSON.stringify({ title: previewTitle, issue: previewIssue }),
|
|
});
|
|
setPreview(res);
|
|
} catch (err: unknown) {
|
|
setMsg(err instanceof Error ? err.message : "Preview failed");
|
|
}
|
|
};
|
|
|
|
return (
|
|
<AppShell>
|
|
<PageHeader
|
|
title="Troubleshooting rules"
|
|
subtitle="Standardize which devices to check and investigation order — YAML editable by admins"
|
|
/>
|
|
|
|
<div className="mb-6 grid gap-4 lg:grid-cols-2">
|
|
<div className="card">
|
|
<h3 className="mb-2 text-sm font-semibold">Rule preview</h3>
|
|
<p className="mb-3 text-xs text-muted">Test which rule matches a task title + issue.</p>
|
|
<input
|
|
className="input mb-2 w-full"
|
|
value={previewTitle}
|
|
onChange={(e) => setPreviewTitle(e.target.value)}
|
|
placeholder="Task title"
|
|
/>
|
|
<textarea
|
|
className="input mb-2 min-h-[80px] w-full"
|
|
value={previewIssue}
|
|
onChange={(e) => setPreviewIssue(e.target.value)}
|
|
placeholder="Issue description"
|
|
/>
|
|
<button type="button" className="btn-secondary" onClick={runPreview}>
|
|
Preview match
|
|
</button>
|
|
{preview?.matched && (
|
|
<pre className="mt-3 overflow-x-auto rounded-lg bg-panel2 p-3 text-xs">
|
|
{JSON.stringify(preview.matched, null, 2)}
|
|
</pre>
|
|
)}
|
|
{preview && !preview.matched && (
|
|
<p className="mt-3 text-sm text-muted">No specific rule — keyword fallback / all devices.</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="card">
|
|
<h3 className="mb-2 text-sm font-semibold">Defined rules</h3>
|
|
<ul className="space-y-2 text-sm">
|
|
{(data?.parsed?.rules || []).map((r) => (
|
|
<li key={r.id} className="flex justify-between gap-2 border-b border-edge/50 py-2">
|
|
<span>{r.name}</span>
|
|
<span className="text-xs text-muted">priority {r.priority ?? 0}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
<p className="mt-3 text-xs text-muted">File: {data?.path_hint}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="card">
|
|
<div className="mb-2 flex items-center justify-between">
|
|
<h3 className="text-sm font-semibold">YAML editor</h3>
|
|
{!isAdmin && <span className="text-xs text-muted">Read-only (admin can edit)</span>}
|
|
</div>
|
|
<textarea
|
|
className="input min-h-[420px] w-full font-mono text-xs leading-relaxed"
|
|
value={content}
|
|
onChange={(e) => isAdmin && setYaml(e.target.value)}
|
|
readOnly={!isAdmin}
|
|
spellCheck={false}
|
|
/>
|
|
{msg && (
|
|
<p className={`mt-2 text-sm ${msgKind === "ok" ? "text-good" : "text-bad"}`}>{msg}</p>
|
|
)}
|
|
{isAdmin && (
|
|
<button type="button" className="btn-primary mt-4" disabled={saving} onClick={save}>
|
|
{saving ? "Saving…" : "Save rules"}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</AppShell>
|
|
);
|
|
}
|