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>
227 lines
5.8 KiB
TypeScript
227 lines
5.8 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useRef } from "react";
|
||
import Link from "next/link";
|
||
|
||
export function PageHeader({
|
||
title,
|
||
subtitle,
|
||
action,
|
||
backHref,
|
||
backLabel,
|
||
}: {
|
||
title: string;
|
||
subtitle?: string;
|
||
action?: React.ReactNode;
|
||
backHref?: string;
|
||
backLabel?: string;
|
||
}) {
|
||
return (
|
||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||
<div className="min-w-0">
|
||
{backHref && (
|
||
<Link
|
||
href={backHref}
|
||
className="mb-1 inline-block text-xs text-muted hover:text-accent"
|
||
>
|
||
← {backLabel || "Back"}
|
||
</Link>
|
||
)}
|
||
<h1 className="text-2xl font-semibold">{title}</h1>
|
||
{subtitle && <p className="mt-1 break-words text-sm text-muted">{subtitle}</p>}
|
||
</div>
|
||
{action && <div className="shrink-0">{action}</div>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function Spinner({ className = "" }: { className?: string }) {
|
||
return (
|
||
<span
|
||
className={`inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent ${className}`}
|
||
aria-hidden="true"
|
||
/>
|
||
);
|
||
}
|
||
|
||
export function SkeletonRows({ rows = 3 }: { rows?: number }) {
|
||
return (
|
||
<div className="space-y-3" aria-hidden="true">
|
||
{Array.from({ length: rows }).map((_, i) => (
|
||
<div key={i} className="h-16 animate-pulse rounded-2xl border border-edge bg-panel2/50" />
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function EmptyState({
|
||
title,
|
||
description,
|
||
action,
|
||
}: {
|
||
title: string;
|
||
description?: string;
|
||
action?: React.ReactNode;
|
||
}) {
|
||
return (
|
||
<div className="card flex flex-col items-center gap-2 py-10 text-center">
|
||
<div className="text-sm font-medium">{title}</div>
|
||
{description && <div className="max-w-sm text-sm text-muted">{description}</div>}
|
||
{action && <div className="mt-2">{action}</div>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function LoadError({
|
||
message = "Couldn’t load data.",
|
||
onRetry,
|
||
}: {
|
||
message?: string;
|
||
onRetry?: () => void;
|
||
}) {
|
||
return (
|
||
<div className="card border-bad/40 text-center">
|
||
<div className="text-sm text-bad">{message}</div>
|
||
{onRetry && (
|
||
<button className="btn-ghost btn-sm mt-3" onClick={onRetry}>
|
||
Retry
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function FormError({ message }: { message?: string | null }) {
|
||
if (!message) return null;
|
||
return <div className="text-sm text-bad">{message}</div>;
|
||
}
|
||
|
||
const STATUS_STYLES: Record<string, string> = {
|
||
queued: "bg-slate-500/20 text-slate-300",
|
||
running: "bg-accent/20 text-accent",
|
||
waiting_approval: "bg-warn/20 text-warn",
|
||
succeeded: "bg-good/20 text-good",
|
||
failed: "bg-bad/20 text-bad",
|
||
cancelled: "bg-slate-500/20 text-slate-400",
|
||
loaded: "bg-good/20 text-good",
|
||
error: "bg-bad/20 text-bad",
|
||
starting: "bg-warn/20 text-warn",
|
||
cloning: "bg-accent/20 text-accent",
|
||
stopped: "bg-slate-500/20 text-slate-400",
|
||
};
|
||
|
||
export function StatusBadge({ status }: { status: string }) {
|
||
return (
|
||
<span className={`badge ${STATUS_STYLES[status] || "bg-slate-500/20 text-slate-300"}`}>
|
||
{status.replace(/_/g, " ")}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
export function Modal({
|
||
open,
|
||
onClose,
|
||
title,
|
||
children,
|
||
}: {
|
||
open: boolean;
|
||
onClose: () => void;
|
||
title: string;
|
||
children: React.ReactNode;
|
||
}) {
|
||
const ref = useRef<HTMLDivElement>(null);
|
||
const onCloseRef = useRef(onClose);
|
||
onCloseRef.current = onClose;
|
||
|
||
// Focus the dialog only when it opens (not on every parent re-render).
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
ref.current?.focus();
|
||
}, [open]);
|
||
|
||
// Escape-to-close + body scroll lock while open.
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
const onKey = (e: KeyboardEvent) => {
|
||
if (e.key === "Escape") onCloseRef.current();
|
||
};
|
||
document.addEventListener("keydown", onKey);
|
||
const prev = document.body.style.overflow;
|
||
document.body.style.overflow = "hidden";
|
||
return () => {
|
||
document.removeEventListener("keydown", onKey);
|
||
document.body.style.overflow = prev;
|
||
};
|
||
}, [open]);
|
||
|
||
if (!open) return null;
|
||
return (
|
||
<div
|
||
className="fixed inset-0 z-50 grid place-items-center bg-black/60 p-4"
|
||
onClick={onClose}
|
||
>
|
||
<div
|
||
ref={ref}
|
||
tabIndex={-1}
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-label={title}
|
||
className="max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-2xl border border-edge bg-panel p-6 shadow-2xl focus:outline-none"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<div className="mb-4 flex items-center justify-between">
|
||
<h2 className="text-lg font-semibold">{title}</h2>
|
||
<button
|
||
onClick={onClose}
|
||
aria-label="Close dialog"
|
||
className="rounded-lg px-2 text-muted hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
{children}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function ConfirmDialog({
|
||
open,
|
||
title,
|
||
message,
|
||
confirmLabel = "Confirm",
|
||
cancelLabel = "Cancel",
|
||
destructive = false,
|
||
busy = false,
|
||
onConfirm,
|
||
onCancel,
|
||
}: {
|
||
open: boolean;
|
||
title: string;
|
||
message?: string;
|
||
confirmLabel?: string;
|
||
cancelLabel?: string;
|
||
destructive?: boolean;
|
||
busy?: boolean;
|
||
onConfirm: () => void;
|
||
onCancel: () => void;
|
||
}) {
|
||
return (
|
||
<Modal open={open} onClose={onCancel} title={title}>
|
||
{message && <p className="mb-5 text-sm text-muted">{message}</p>}
|
||
<div className="flex justify-end gap-2">
|
||
<button className="btn-ghost" onClick={onCancel} disabled={busy}>
|
||
{cancelLabel}
|
||
</button>
|
||
<button
|
||
className={destructive ? "btn-danger" : "btn-primary"}
|
||
onClick={onConfirm}
|
||
disabled={busy}
|
||
>
|
||
{busy ? "Working…" : confirmLabel}
|
||
</button>
|
||
</div>
|
||
</Modal>
|
||
);
|
||
}
|