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:
226
frontend/components/ui.tsx
Normal file
226
frontend/components/ui.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user