diff --git a/periscope/src/frontend/src/app/(app)/admin/page.tsx b/periscope/src/frontend/src/app/(app)/admin/page.tsx new file mode 100644 index 0000000..44dff55 --- /dev/null +++ b/periscope/src/frontend/src/app/(app)/admin/page.tsx @@ -0,0 +1,1780 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { + Activity, + Check, + ChevronDown, + ChevronRight, + Clock, + Copy, + Cpu, + Database, + DollarSign, + ExternalLink, + FolderOpen, + Gauge, + Loader2, + MessageSquareWarning, + MoreVertical, + Package, + RotateCw, + Search, + Settings, + Shield, + SlidersHorizontal, + Trash2, + Users, + Zap, +} from "lucide-react"; +import { useOptionalUser } from "@/hooks/use-optional-auth"; +import { + adminAdjustCredits, + adminMarkProjectComplete, + deleteAdminComponent, + deleteAdminFinding, + fetchAdminComponentJson, + fetchAdminComponents, + fetchAdminFeedback, + fetchAdminProjects, + fetchAdminRuns, + fetchAdminSettings, + fetchAdminUsage, + fetchAdminUsers, + regenPipeline, + restartPipeline, + searchAdminUsers, + setMinModelVersion, + updateAdminFeedback, + type AdminComponents, + type AdminPipelineRun, + type AdminProject, + type AdminSettings, + type AdminUsage, + type AdminUsageUser, + type AdminUser, + type FeedbackTicket, +} from "@/lib/api"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { cn } from "@/lib/utils"; + +type Tab = + | "projects" + | "runs" + | "components" + | "users" + | "usage" + | "overrides" + | "feedback" + | "settings"; + +const TABS: { id: Tab; label: string; icon: typeof Database }[] = [ + { id: "projects", label: "Projects", icon: FolderOpen }, + { id: "runs", label: "Pipeline Runs", icon: Activity }, + { id: "components", label: "Components", icon: Database }, + { id: "users", label: "Users & Credits", icon: Users }, + { id: "usage", label: "API Usage", icon: DollarSign }, + { id: "overrides", label: "Overrides", icon: SlidersHorizontal }, + { id: "feedback", label: "Feedback", icon: MessageSquareWarning }, + { id: "settings", label: "Settings", icon: Settings }, +]; + +const PROJECT_STATUS_COLORS: Record = { + draft: "bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400", + running: "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300", + complete: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300", + error: "bg-rose-100 text-rose-700 dark:bg-rose-900 dark:text-rose-300", + cancelled: "bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300", + paused_insufficient_credits: "bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300", + paused_by_user: "bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300", +}; + +const STAGE_LABELS: Record = { + bom_parse: "Parsing BOM", + ic_extraction: "IC Extraction", + simple_extraction: "Specs Extraction", + passive_extraction: "Passive Extraction", + digikey_resolve: "DigiKey Resolve", + graph_build: "Building Graph", + bom_summary: "BOM Summary", + derating: "Derating", + validation: "Validation", +}; + +const FEEDBACK_STATUS_STYLES: Record = { + open: "bg-blue-500/15 text-blue-600 dark:text-blue-400 border-blue-500/30", + acknowledged: "bg-amber-500/15 text-amber-600 dark:text-amber-400 border-amber-500/30", + resolved: "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/30", +}; + +function formatCostShort(usd: number | null): string { + if (!usd) return "-"; + if (usd < 0.01) return "<$0.01"; + return "$" + usd.toFixed(2); +} + +function formatCost(usd: number): string { + if (usd === 0) return "$0.00"; + if (usd < 0.01) return "<$0.01"; + return "$" + usd.toFixed(2); +} + +function formatRelativeTime(iso: string): string { + const mins = Math.floor((Date.now() - new Date(iso).getTime()) / 60000); + if (mins < 1) return "just now"; + if (mins < 60) return `${mins}m ago`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h ago`; + return `${Math.floor(hours / 24)}d ago`; +} + +function formatDuration(seconds: number): string { + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return m === 0 ? `${s}s` : `${m}m ${s}s`; +} + +function LoadingLine({ label }: { label: string }) { + return ( +
+ {label} +
+ ); +} + +export default function AdminPage() { + const { user, isLoaded } = useOptionalUser(); + const router = useRouter(); + const [activeTab, setActiveTab] = useState("projects"); + const isAdmin = user?.isAdmin ?? false; + + useEffect(() => { + if (isLoaded && !isAdmin) router.replace("/dashboard"); + }, [isLoaded, isAdmin, router]); + + if (!isLoaded) { + return ( +
+ +
+ ); + } + if (!isAdmin) return null; + + return ( +
+
+
+ +

Admin

+
+

+ Manage library components and user settings +

+
+
+ +
+ {activeTab === "projects" && } + {activeTab === "runs" && } + {activeTab === "components" && } + {activeTab === "users" && } + {activeTab === "usage" && } + {activeTab === "overrides" && } + {activeTab === "feedback" && } + {activeTab === "settings" && } +
+
+
+ ); +} + +function ProjectsPanel() { + const router = useRouter(); + const [projects, setProjects] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [filter, setFilter] = useState(""); + const [busyId, setBusyId] = useState(null); + + useEffect(() => { + fetchAdminProjects() + .then(setProjects) + .catch((e) => setError(e.message)) + .finally(() => setLoading(false)); + }, []); + + function markRunning(projectId: string) { + setProjects((prev) => + prev.map((p) => (p.id === projectId ? { ...p, status: "running", pipeline_state: null } : p)), + ); + } + + async function handleRestart(projectId: string) { + setBusyId(projectId); + try { + await restartPipeline(projectId); + markRunning(projectId); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to restart pipeline"); + } finally { + setBusyId(null); + } + } + + async function handleRegen(projectId: string, stages: string[]) { + setBusyId(projectId); + try { + await regenPipeline(projectId, stages); + markRunning(projectId); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to start regen"); + } finally { + setBusyId(null); + } + } + + function handleCloneAsNew(projectId: string) { + window.sessionStorage.setItem("periscopex:cloneAsNewProjectId", projectId); + router.push("/dashboard"); + } + + async function handleMarkComplete(projectId: string) { + setBusyId(projectId); + try { + await adminMarkProjectComplete(projectId); + setProjects((prev) => + prev.map((p) => (p.id === projectId ? { ...p, status: "complete" } : p)), + ); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to mark complete"); + } finally { + setBusyId(null); + } + } + + if (loading) return ; + if (error) return

Error: {error}

; + + const lf = filter.toLowerCase(); + const filtered = projects + .filter( + (p) => + p.name.toLowerCase().includes(lf) || + (p.owner_name ?? "").toLowerCase().includes(lf) || + (p.owner_email ?? "").toLowerCase().includes(lf) || + p.status.toLowerCase().includes(lf), + ) + .sort((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime()); + + const statusCounts = projects.reduce>((acc, p) => { + acc[p.status] = (acc[p.status] || 0) + 1; + return acc; + }, {}); + + return ( +
+
+
+ + {projects.length} + total +
+ {Object.entries(statusCounts).map(([status, count]) => ( +
+ + {count} + {status} +
+ ))} +
+ setFilter(e.target.value)} + className="w-72" + /> +
+ + {filtered.length === 0 ? ( +
+ +

{filter ? "No projects match your filter" : "No projects yet"}

+
+ ) : ( +
+ + + + + + + + + + + + + + {filtered.map((p) => { + const errorMsg = + p.status === "error" && p.pipeline_state + ? (p.pipeline_state as Record).error + : null; + const paused = + p.status === "paused_insufficient_credits" || p.status === "paused_by_user"; + return ( + router.push(`/project/${p.id}`)} + > + + + + + + + + + ); + })} + +
ProjectOwnerStatusCostCreatedErrorActions
+
{p.name}
+
+ {p.id.slice(0, 8)} +
+
+
+ {p.owner_name ?
{p.owner_name}
: null} +
+ {p.owner_email || p.user_id.slice(0, 12)} +
+
+
+ + {p.status} + + + {formatCostShort(p.total_cost_usd)} + + {formatRelativeTime(p.created)} + + {errorMsg ? ( + + {errorMsg} + + ) : ( + - + )} + e.stopPropagation()}> + {p.has_bom && p.has_netlist && ( + busyId === p.id ? ( + + ) : ( + + + + + + {paused && ( + <> +
+ Paused run +
+ handleMarkComplete(p.id)}> + + Mark as complete + + + + )} +
+ Free — not charged to user +
+ handleRestart(p.id)}> + + Rerun full pipeline + + +
+ Regen only +
+ handleRegen(p.id, ["derating"])}> + + Regen derating + + +
+ Clone +
+ handleCloneAsNew(p.id)}> + + Rerun as new project + +
+
+ ) + )} +
+
+ )} +
+ ); +} + +function RunsPanel() { + const router = useRouter(); + const [runs, setRuns] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchRuns = useCallback(() => { + fetchAdminRuns() + .then(setRuns) + .catch((e) => setError(e.message)) + .finally(() => setLoading(false)); + }, []); + + useEffect(() => { + fetchRuns(); + const interval = setInterval(fetchRuns, 5000); + return () => clearInterval(interval); + }, [fetchRuns]); + + if (loading) return ; + if (error) return

Error: {error}

; + + if (runs.length === 0) { + return ( +
+
+ + Auto-refreshes every 5 seconds +
+
+ +

No pipelines currently running

+
+
+ ); + } + + return ( +
+
+
+ + {runs.length} + running +
+
+ + Auto-refreshes every 5s +
+
+
+ + + + + + + + + + + + {runs.map((run) => ( + router.push(`/project/${run.project_id}/progress`)} + > + + + + + + + + ))} + +
ProjectOwnerStageSubstepDuration +
{run.project_name} +
+ {run.owner_name ? ( +
{run.owner_name}
+ ) : null} +
+ {run.owner_email || run.user_id.slice(0, 12)} +
+
+
+ {run.current_stage ? ( + + {STAGE_LABELS[run.current_stage] || run.current_stage} + + ) : ( + Starting... + )} + + {run.current_substep || "-"} + + {formatDuration(run.duration_seconds)} + + +
+
+
+ ); +} + +function ComponentsPanel() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [filter, setFilter] = useState(""); + const [deleting, setDeleting] = useState(null); + const [selected, setSelected] = useState<{ + type: "ic" | "passive" | "simple"; + name: string; + } | null>(null); + const [componentJson, setComponentJson] = useState | null>(null); + const [jsonLoading, setJsonLoading] = useState(false); + + function reload() { + setError(null); + fetchAdminComponents() + .then(setData) + .catch((e) => setError(e.message)) + .finally(() => setLoading(false)); + } + + useEffect(() => { + reload(); + }, []); + + async function handleDelete(type: "ic" | "passive" | "simple", name: string) { + const label = + type === "ic" ? "IC extraction" : type === "passive" ? "passive pattern" : "component specs"; + if (!confirm(`Delete ${label} "${name}"? This cannot be undone.`)) return; + setDeleting(name); + setError(null); + try { + await deleteAdminComponent(type, name); + setData((prev) => { + if (!prev) return prev; + if (type === "ic") return { ...prev, ics: prev.ics.filter((c) => c.mpn !== name) }; + if (type === "passive") + return { ...prev, passives: prev.passives.filter((c) => c.mpn !== name) }; + return { + ...prev, + simple: prev.simple.filter((c) => c.mpn !== name), + passive_parts: (prev.passive_parts ?? []).filter((c) => c.mpn !== name), + }; + }); + reload(); + } catch (e) { + setError(e instanceof Error ? e.message : "Delete failed"); + } finally { + setDeleting(null); + } + } + + async function handleRowClick(type: "ic" | "passive" | "simple", name: string) { + setSelected({ type, name }); + setComponentJson(null); + setJsonLoading(true); + try { + setComponentJson(await fetchAdminComponentJson(type, name)); + } catch { + setComponentJson({ error: "Failed to load component JSON" }); + } finally { + setJsonLoading(false); + } + } + + if (loading) return ; + if (error) return

Error: {error}

; + if (!data) return null; + + const lf = filter.toLowerCase(); + const filteredICs = data.ics.filter( + (c) => c.mpn.toLowerCase().includes(lf) || c.subtype.toLowerCase().includes(lf), + ); + const filteredPassives = data.passives.filter( + (c) => + c.mpn.toLowerCase().includes(lf) || + c.subtype.toLowerCase().includes(lf) || + c.description.toLowerCase().includes(lf), + ); + const filteredPassiveParts = (data.passive_parts ?? []).filter( + (c) => + c.mpn.toLowerCase().includes(lf) || + c.subtype.toLowerCase().includes(lf) || + c.specs_type.toLowerCase().includes(lf), + ); + const filteredSimple = (data.simple ?? []).filter( + (c) => + c.mpn.toLowerCase().includes(lf) || + c.subtype.toLowerCase().includes(lf) || + c.specs_type.toLowerCase().includes(lf), + ); + + function DeleteBtn({ + type, + name, + }: { + type: "ic" | "passive" | "simple"; + name: string; + }) { + return ( + + ); + } + + return ( +
+
+
+ + {data.ics.length} + ICs +
+
+ + {data.passives.length} + Passive Patterns +
+ {(data.passive_parts?.length ?? 0) > 0 && ( +
+ + {data.passive_parts?.length} + Passive MPNs +
+ )} + {(data.simple?.length ?? 0) > 0 && ( +
+ + {data.simple.length} + Component Specs +
+ )} +
+ setFilter(e.target.value)} + className="w-64" + /> +
+ + {filteredICs.length > 0 && ( +
+

IC Extractions

+
+ + + + + + + + + + + {filteredICs.map((ic) => ( + handleRowClick("ic", ic.mpn)} + > + + + + + + + ))} + +
MPNSubtypePinsAbs Max +
{ic.mpn} + {ic.subtype ? ( + {ic.subtype} + ) : ( + - + )} + {ic.pin_count} + {ic.has_ratings ? ( + + ) : ( + - + )} + + +
+
+
+ )} + + {filteredPassives.length > 0 && ( +
+

Passive Patterns

+
+ + + + + + + + + + + {filteredPassives.map((p) => ( + handleRowClick("passive", p.mpn)} + > + + + + + + + ))} + +
NameTypeDescriptionRegex +
{p.mpn} + {p.subtype ? ( + {p.subtype} + ) : ( + - + )} + + {p.description || "-"} + + {p.regex || "-"} + + +
+
+
+ )} + + {filteredPassiveParts.length > 0 && ( +
+

Passive MPNs

+
+ + + + + + + + + + + {filteredPassiveParts.map((s) => ( + handleRowClick("simple", s.mpn)} + > + + + + + + + ))} + +
MPNTypeSubtypeParams +
{s.mpn} + {s.specs_type || "passive"} + + {s.subtype ? ( + {s.subtype} + ) : ( + - + )} + {s.param_count} + +
+
+
+ )} + + {filteredSimple.length > 0 && ( +
+

Component Specs

+
+ + + + + + + + + + + {filteredSimple.map((s) => ( + handleRowClick("simple", s.mpn)} + > + + + + + + + ))} + +
MPNTypeSubtypeParams +
{s.mpn} + {s.specs_type} + + {s.subtype ? ( + {s.subtype} + ) : ( + - + )} + {s.param_count} + +
+
+
+ )} + + {filteredICs.length === 0 && + filteredPassives.length === 0 && + filteredPassiveParts.length === 0 && + filteredSimple.length === 0 && ( +
+ +

+ {filter ? "No components match your filter" : "No components in the library yet"} +

+
+ )} + + { + if (!open) setSelected(null); + }} + > + + + {selected?.name} + +
+ {jsonLoading ? ( +
+ Loading... +
+ ) : componentJson ? ( +
+                {JSON.stringify(componentJson, null, 2)}
+              
+ ) : null} +
+ +
+
+
+ ); +} + +function UsersTable({ users, onAdjust }: { users: AdminUser[]; onAdjust: (u: AdminUser) => void }) { + return ( +
+ + + + + + + + + + {users.map((u) => ( + + + + + + + ))} + +
UserProjectsBalance +
+
+ {u.image_url ? ( + + ) : ( +
+ +
+ )} +
+ {u.name ? ( + <> +
{u.name}
+ {u.email && ( +
{u.email}
+ )} + + ) : ( +
+ {u.email || u.user_id} +
+ )} +
+
+
{u.project_count} + {u.balance.toFixed(2)} + + +
+
+ ); +} + +function UsersPanel() { + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [adjusting, setAdjusting] = useState(null); + const [query, setQuery] = useState(""); + const [searching, setSearching] = useState(false); + const [searchError, setSearchError] = useState(null); + const [results, setResults] = useState(null); + + const reload = useCallback(() => { + setLoading(true); + fetchAdminUsers() + .then(setUsers) + .catch((e) => setError(e.message)) + .finally(() => setLoading(false)); + }, []); + + useEffect(() => { + reload(); + }, [reload]); + + const runSearch = useCallback(async () => { + const email = query.trim(); + if (!email) { + setResults(null); + setSearchError(null); + return; + } + setSearching(true); + setSearchError(null); + try { + setResults(await searchAdminUsers(email)); + } catch (e) { + setSearchError(e instanceof Error ? e.message : "Search failed"); + setResults(null); + } finally { + setSearching(false); + } + }, [query]); + + const afterAdjust = useCallback(() => { + setAdjusting(null); + reload(); + if (results !== null) runSearch(); + }, [reload, results, runSearch]); + + if (loading) return ; + if (error) return

Error: {error}

; + + const showingSearch = results !== null; + const list = showingSearch ? results : users; + + return ( +
+
+ setQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") runSearch(); + }} + placeholder="Find any user by email…" + className="max-w-xs" + /> + + {showingSearch && ( + + )} +
+ {searchError &&

{searchError}

} +

+ {showingSearch ? `Search results (${list.length})` : `Users (${list.length})`} +

+ {list.length === 0 ? ( +
+ +

{showingSearch ? "No user found with that email" : "No users yet"}

+
+ ) : ( + + )} + {adjusting && ( + setAdjusting(null)} + onAdjusted={afterAdjust} + /> + )} +
+ ); +} + +function AdjustCreditsDialog({ + user, + onClose, + onAdjusted, +}: { + user: AdminUser; + onClose: () => void; + onAdjusted: () => void; +}) { + const [delta, setDelta] = useState("0"); + const [note, setNote] = useState(""); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const parsed = parseFloat(delta); + const valid = !Number.isNaN(parsed) && parsed !== 0 && note.trim().length > 0; + + async function handleSave() { + if (!valid) return; + setSaving(true); + setError(null); + try { + await adminAdjustCredits(user.user_id, parsed, note.trim()); + onAdjusted(); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed"); + setSaving(false); + } + } + + return ( + !o && onClose()}> + + + Adjust credits — {user.name || user.email || user.user_id} + +
+
+
Current balance
+
{user.balance.toFixed(2)}
+
+
+ + setDelta(e.target.value)} + className="font-mono mt-1" + /> +
+
+ + setNote(e.target.value)} + placeholder="e.g. goodwill / correction" + className="mt-1" + /> +
+ {error &&

{error}

} +
+ + + + +
+
+ ); +} + +function UsagePanel() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [expandedUser, setExpandedUser] = useState(null); + + useEffect(() => { + fetchAdminUsage() + .then(setData) + .catch((e) => setError(e.message)) + .finally(() => setLoading(false)); + }, []); + + if (loading) return ; + if (error) return

Error: {error}

; + if (!data) return null; + + const sortedUsers = [...data.users].sort((a, b) => b.total_cost_usd - a.total_cost_usd); + + return ( +
+
+
+ + {formatCost(data.grand_total_usd)} + total spend +
+
+ + {data.users.length} + users +
+
+ {sortedUsers.length === 0 ? ( +
+ +

No usage data yet

+
+ ) : ( +
+

Usage by User

+
+ + + + + + + + + + {sortedUsers.map((u) => ( + setExpandedUser(expandedUser === u.user_id ? null : u.user_id)} + /> + ))} + +
+ UserProjectsCost
+
+
+ )} +
+ ); +} + +function UserUsageRow({ + user, + expanded, + onToggle, +}: { + user: AdminUsageUser; + expanded: boolean; + onToggle: () => void; +}) { + const sortedProjects = [...user.projects].sort((a, b) => b.cost_usd - a.cost_usd); + return ( + <> + + + {expanded ? : } + + +
+ {user.name ? ( + <> +
{user.name}
+ {user.email && ( +
{user.email}
+ )} + + ) : ( +
+ {user.email || user.user_id} +
+ )} +
+ + {user.project_count} + {formatCost(user.total_cost_usd)} + + {expanded && + sortedProjects.map((p) => ( + + + {p.name} + + + {p.status} + + + + {formatCost(p.cost_usd)} + + + ))} + + ); +} + +function OverridesPanel() { + const [projectId, setProjectId] = useState(""); + const [ruleId, setRuleId] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [result, setResult] = useState<{ + deleted: string; + project_id: string; + remaining: number; + } | null>(null); + + async function handleDelete() { + if (!projectId.trim() || !ruleId.trim()) return; + setSubmitting(true); + setError(null); + setResult(null); + try { + const res = await deleteAdminFinding(projectId.trim(), ruleId.trim()); + setResult({ deleted: res.deleted, project_id: res.project_id, remaining: res.remaining }); + setRuleId(""); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to delete rule"); + } finally { + setSubmitting(false); + } + } + + const canSubmit = projectId.trim() && ruleId.trim() && !submitting; + + return ( +
+
+

Overrides

+

+ Manual corrections to pipeline outputs. More override tools will land here over time. +

+
+
+
+
Delete rule from report
+
+ Removes a single finding from the project's report.json and refreshes the summary + counts. No-op if the rule is not in the report. +
+
+
+
+ + setProjectId(e.target.value)} + placeholder="e.g. 7f3a2c1e-..." + className="font-mono" + /> +
+
+ + setRuleId(e.target.value)} + placeholder="e.g. U3-001" + className="font-mono" + /> +
+ +
+ {error &&

{error}

} + {result && ( +
+
+ Deleted rule {result.deleted} from project{" "} + {result.project_id}. +
+
+ {result.remaining} finding{result.remaining === 1 ? "" : "s"} remaining in report. +
+
+ )} +
+
+ ); +} + +function SettingsPanel() { + const [settings, setSettings] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [editVersion, setEditVersion] = useState(""); + const [saving, setSaving] = useState(false); + const [saved, setSaved] = useState(false); + + useEffect(() => { + fetchAdminSettings() + .then((data) => { + setSettings(data); + setEditVersion(data.min_model_version); + }) + .catch((e) => setError(e.message)) + .finally(() => setLoading(false)); + }, []); + + async function handleSave() { + setSaving(true); + setError(null); + setSaved(false); + try { + await setMinModelVersion(editVersion); + setSettings((prev) => (prev ? { ...prev, min_model_version: editVersion } : prev)); + setSaved(true); + setTimeout(() => setSaved(false), 2000); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to save"); + } finally { + setSaving(false); + } + } + + const hasChanged = settings && editVersion !== settings.min_model_version; + const isValidSemver = /^\d+\.\d+\.\d+$/.test(editVersion); + + if (loading) return ; + if (!settings) return null; + + return ( +
+
+

Model Version

+

+ Control when cached library extractions are re-extracted with newer skills. +

+
+ {error &&

{error}

} +
+
+
+
Current extraction version
+
From skills_manifest.json (read-only)
+
+ + {settings.default_model_version} + +
+
+
+
Force refresh if older than
+
+ Library components with a model_version below this will be re-extracted on next + pipeline run. To refresh all existing extractions, set this to the current extraction + version above. Set to 0.0.0 to disable. +
+
+
+ setEditVersion(e.target.value)} + placeholder="e.g. 1.0.1" + className="w-32 font-mono" + /> + +
+ {editVersion && !isValidSemver && ( +

Must be a valid semver (e.g. 1.0.1)

+ )} +
+
+
+ ); +} + +function FeedbackPanel() { + const [tickets, setTickets] = useState([]); + const [loading, setLoading] = useState(true); + const [filter, setFilter] = useState(""); + const [statusFilter, setStatusFilter] = useState("all"); + const [selected, setSelected] = useState(null); + const [editStatus, setEditStatus] = useState(""); + const [editNotes, setEditNotes] = useState(""); + const [saving, setSaving] = useState(false); + + const load = useCallback(async () => { + try { + setTickets(await fetchAdminFeedback()); + } catch { + /* keep last list */ + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + load(); + }, [load]); + + const filtered = tickets.filter((t) => { + if (statusFilter !== "all" && t.status !== statusFilter) return false; + if (filter) { + const q = filter.toLowerCase(); + const searchable = [t.user_name, t.user_email, t.project_name, t.finding_id, t.message] + .filter(Boolean) + .join(" ") + .toLowerCase(); + if (!searchable.includes(q)) return false; + } + return true; + }); + + const counts = { + total: tickets.length, + open: tickets.filter((t) => t.status === "open").length, + acknowledged: tickets.filter((t) => t.status === "acknowledged").length, + resolved: tickets.filter((t) => t.status === "resolved").length, + }; + + async function handleSave() { + if (!selected) return; + setSaving(true); + try { + const updated = await updateAdminFeedback(selected.ticket_id, { + status: editStatus, + admin_notes: editNotes || undefined, + }); + setTickets((prev) => prev.map((t) => (t.ticket_id === updated.ticket_id ? updated : t))); + setSelected(null); + } catch { + /* keep dialog open */ + } finally { + setSaving(false); + } + } + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+
+ + Total: {counts.total} + + Open: {counts.open} + Acknowledged: {counts.acknowledged} + Resolved: {counts.resolved} +
+
+ setFilter(e.target.value)} + className="max-w-xs" + /> +
+ {["all", "open", "acknowledged", "resolved"].map((s) => ( + + ))} +
+
+ {filtered.length === 0 ? ( +

+ {tickets.length === 0 ? "No feedback tickets yet." : "No tickets match your filters."} +

+ ) : ( +
+ + + + + + + + + + + + + {filtered.map((t) => ( + { + setSelected(t); + setEditStatus(t.status); + setEditNotes(t.admin_notes ?? ""); + }} + className="border-b border-border last:border-0 hover:bg-accent/30 cursor-pointer transition-colors" + > + + + + + + + + ))} + +
UserFindingProjectMessageStatusCreated
+
{t.user_name ?? "—"}
+
+ {t.user_email ?? ""} +
+
+ {t.finding_id ?? "—"} + + {t.project_name ?? "—"} + + {t.message} + + + {t.status} + + + {formatRelativeTime(t.created_at)} +
+
+ )} + { + if (!open) setSelected(null); + }} + > + + + Feedback Detail + + {selected && ( +
+
+ + {selected.status} + + + by {selected.user_name ?? selected.user_email ?? selected.user_id} + + + {formatRelativeTime(selected.created_at)} + +
+ {selected.finding_id && ( +
+
+ {selected.finding_id} + {selected.finding_designator && ( + + {selected.finding_designator} + + )} + {selected.finding_mpn && ( + {selected.finding_mpn} + )} + {selected.finding_status && ( + + {selected.finding_status} + + )} +
+ {selected.finding_text &&

{selected.finding_text}

} +
+ )} + {selected.project_name && ( +

+ Project: {selected.project_name} +

+ )} +
+

{selected.message}

+
+
+ +
+ {(["open", "acknowledged", "resolved"] as const).map((s) => ( + + ))} +
+
+
+ +