Phase 5: secondary screens (diagnostics, skills, training)
Port the remaining read views from the vanilla UI to Next.js:
- /diagnostics — system health snapshot (DB connected, table counts,
active tasks, failed and stuck documents). Uses the existing
/api/system/diagnostics payload with a 10s refetchInterval so the
page self-updates while the user watches.
- /skills — Paperclip skill inventory with sync status (DB-only,
disk-only, synced, not-synced) as a card grid driven by
/api/admin/skills.
- /training — Dafna's style portrait as three tabs on one page:
* Report: corpus KPIs + CSS conic-gradient subject donut
(SubjectDonut ported from index.html renderHero) + horizontal
anatomy bars + top-12 signature phrases.
* Corpus: TanStack Table of style_corpus rows with an inline
delete mutation (useDeleteCorpusEntry invalidates both the
corpus list and the style report so KPIs update).
* Compare: two-decision selector backed by /api/training/compare,
side-by-side panels plus shared / only-A / only-B pattern
lists.
New API modules: lib/api/system.ts, lib/api/skills.ts,
lib/api/training.ts. All three use TanStack Query with staleTime
profiles tuned per endpoint (10s for diagnostics, 30s for skills,
60s for training reports).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
219
web-ui/src/app/diagnostics/page.tsx
Normal file
219
web-ui/src/app/diagnostics/page.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { AlertTriangle, CheckCircle2, Clock, Database } from "lucide-react";
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useDiagnostics, type DiagDoc } from "@/lib/api/system";
|
||||
|
||||
const TABLE_LABELS: Record<string, string> = {
|
||||
cases: "תיקים",
|
||||
documents: "מסמכים",
|
||||
document_chunks: "chunks",
|
||||
style_corpus: "קורפוס סגנון",
|
||||
style_patterns: "דפוסי סגנון",
|
||||
};
|
||||
|
||||
function formatRelativeTime(iso: string | null) {
|
||||
if (!iso) return "—";
|
||||
const then = new Date(iso);
|
||||
const diffMs = Date.now() - then.getTime();
|
||||
const min = Math.floor(diffMs / 60000);
|
||||
if (min < 1) return "עכשיו";
|
||||
if (min < 60) return `לפני ${min} דקות`;
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr < 24) return `לפני ${hr} שעות`;
|
||||
const days = Math.floor(hr / 24);
|
||||
if (days < 30) return `לפני ${days} ימים`;
|
||||
return then.toLocaleDateString("he-IL");
|
||||
}
|
||||
|
||||
function DocRow({ doc, tone }: { doc: DiagDoc; tone: "danger" | "warn" }) {
|
||||
const cls =
|
||||
tone === "danger" ? "bg-danger-bg/60 border-danger/30" : "bg-warn-bg/60 border-warn/30";
|
||||
return (
|
||||
<li
|
||||
className={`rounded border px-3 py-2 flex items-center gap-3 text-sm ${cls}`}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-ink font-medium truncate" title={doc.title}>
|
||||
{doc.title || "(ללא כותרת)"}
|
||||
</div>
|
||||
<div className="text-[0.72rem] text-ink-muted flex gap-3 mt-0.5">
|
||||
{doc.case_number && (
|
||||
<Link href={`/cases/${doc.case_number}`} className="hover:text-gold-deep">
|
||||
ערר {doc.case_number}
|
||||
</Link>
|
||||
)}
|
||||
<span>{formatRelativeTime(doc.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-[0.7rem]">{doc.status}</Badge>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DiagnosticsPage() {
|
||||
const { data, isPending, error } = useDiagnostics();
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<section className="space-y-6">
|
||||
<header>
|
||||
<nav className="text-[0.78rem] text-ink-muted mb-1">
|
||||
<Link href="/" className="hover:text-gold-deep">בית</Link>
|
||||
<span aria-hidden> · </span>
|
||||
<span className="text-navy">אבחון מערכת</span>
|
||||
</nav>
|
||||
<h1 className="text-navy mb-0">אבחון מערכת</h1>
|
||||
<p className="text-ink-muted text-sm mt-1 max-w-2xl">
|
||||
מצב ה-DB, מסמכים שנכשלו או תקועים, ומשימות רקע פעילות. מתעדכן כל 10
|
||||
שניות.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />
|
||||
|
||||
{error ? (
|
||||
<Card className="bg-danger-bg border-danger/40">
|
||||
<CardContent className="px-6 py-6 text-center text-danger">
|
||||
{error.message}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
{/* DB status + table counts */}
|
||||
<div className="grid gap-4 md:grid-cols-[240px_1fr]">
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-5 py-4 flex flex-col items-start gap-2">
|
||||
<div className="flex items-center gap-2 text-ink-muted text-[0.72rem] uppercase tracking-wider">
|
||||
<Database className="w-3.5 h-3.5" />
|
||||
מצב DB
|
||||
</div>
|
||||
{isPending ? (
|
||||
<Skeleton className="h-6 w-24" />
|
||||
) : data?.db_ok ? (
|
||||
<div className="flex items-center gap-2 text-success font-semibold">
|
||||
<CheckCircle2 className="w-5 h-5" />
|
||||
<span>מחובר</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-danger font-semibold">
|
||||
<AlertTriangle className="w-5 h-5" />
|
||||
<span>מנותק</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-5 py-4">
|
||||
<div className="text-ink-muted text-[0.72rem] uppercase tracking-wider mb-3">
|
||||
ספירת טבלאות
|
||||
</div>
|
||||
<dl className="grid grid-cols-2 md:grid-cols-5 gap-y-2 gap-x-4">
|
||||
{Object.keys(TABLE_LABELS).map((key) => (
|
||||
<div key={key} className="space-y-0.5">
|
||||
<dt className="text-[0.72rem] text-ink-muted">
|
||||
{TABLE_LABELS[key]}
|
||||
</dt>
|
||||
<dd className="font-display font-bold text-navy text-xl tabular-nums">
|
||||
{isPending ? "—" : (data?.tables[key] ?? "—")}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Active tasks */}
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-5">
|
||||
<h2 className="text-navy text-lg mb-3 flex items-center gap-2">
|
||||
<Clock className="w-4 h-4" />
|
||||
משימות רקע פעילות
|
||||
<Badge variant="outline" className="text-[0.7rem] tabular-nums">
|
||||
{data?.active_tasks.length ?? 0}
|
||||
</Badge>
|
||||
</h2>
|
||||
{isPending ? (
|
||||
<Skeleton className="h-16 w-full" />
|
||||
) : data?.active_tasks.length === 0 ? (
|
||||
<p className="text-ink-muted text-sm">אין משימות פעילות</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{data?.active_tasks.map((t) => (
|
||||
<li
|
||||
key={t.task_id}
|
||||
className="flex items-center gap-3 text-sm rounded bg-rule-soft/60 border border-rule px-3 py-2"
|
||||
>
|
||||
<span className="flex-1 text-ink truncate">
|
||||
{t.filename || t.task_id}
|
||||
</span>
|
||||
<Badge variant="outline" className="text-[0.7rem]">
|
||||
{t.step || t.status}
|
||||
</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Failed + stuck docs */}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-5">
|
||||
<h2 className="text-danger text-lg mb-3 flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
מסמכים שנכשלו
|
||||
<Badge variant="outline" className="text-[0.7rem] tabular-nums">
|
||||
{data?.failed_documents.length ?? 0}
|
||||
</Badge>
|
||||
</h2>
|
||||
{isPending ? (
|
||||
<Skeleton className="h-20 w-full" />
|
||||
) : data?.failed_documents.length === 0 ? (
|
||||
<p className="text-ink-muted text-sm">אין כשלונות</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{data?.failed_documents.map((d) => (
|
||||
<DocRow key={d.id} doc={d} tone="danger" />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-5">
|
||||
<h2 className="text-warn text-lg mb-3 flex items-center gap-2">
|
||||
<Clock className="w-4 h-4" />
|
||||
תקועים (> 10 דק׳)
|
||||
<Badge variant="outline" className="text-[0.7rem] tabular-nums">
|
||||
{data?.stuck_documents.length ?? 0}
|
||||
</Badge>
|
||||
</h2>
|
||||
{isPending ? (
|
||||
<Skeleton className="h-20 w-full" />
|
||||
) : data?.stuck_documents.length === 0 ? (
|
||||
<p className="text-ink-muted text-sm">אין מסמכים תקועים</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{data?.stuck_documents.map((d) => (
|
||||
<DocRow key={d.id} doc={d} tone="warn" />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
128
web-ui/src/app/skills/page.tsx
Normal file
128
web-ui/src/app/skills/page.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Plug, HardDrive, Database, FileText } from "lucide-react";
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useSkills, type Skill } from "@/lib/api/skills";
|
||||
|
||||
function formatSize(bytes: number | null) {
|
||||
if (bytes == null) return "—";
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function statusBadge(s: Skill) {
|
||||
if (s.not_in_db) {
|
||||
return <Badge variant="outline" className="bg-warn-bg text-warn border-warn/40">לא סונכרן</Badge>;
|
||||
}
|
||||
if (s.db_markdown_chars > 0 && s.disk_exists) {
|
||||
return <Badge variant="outline" className="bg-success-bg text-success border-success/40">מסונכרן</Badge>;
|
||||
}
|
||||
if (s.db_markdown_chars > 0) {
|
||||
return <Badge variant="outline" className="bg-info-bg text-info border-info/40">DB בלבד</Badge>;
|
||||
}
|
||||
return <Badge variant="outline">לא ידוע</Badge>;
|
||||
}
|
||||
|
||||
function SkillCard({ skill }: { skill: Skill }) {
|
||||
const fileCount = skill.file_inventory?.length ?? 0;
|
||||
return (
|
||||
<Card className="bg-surface border-rule shadow-sm hover:shadow-md transition-shadow">
|
||||
<CardContent className="px-5 py-4">
|
||||
<div className="flex items-start justify-between gap-3 mb-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Plug className="w-4 h-4 text-gold-deep shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-navy font-semibold text-base mb-0 truncate">
|
||||
{skill.name || skill.slug}
|
||||
</h3>
|
||||
<code className="text-[0.72rem] text-ink-muted tabular-nums">
|
||||
{skill.slug}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
{statusBadge(skill)}
|
||||
</div>
|
||||
<dl className="grid grid-cols-3 gap-2 text-[0.72rem] text-ink-muted mt-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<FileText className="w-3 h-3" />
|
||||
<span className="tabular-nums">{fileCount}</span>
|
||||
<span>קבצים</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Database className="w-3 h-3" />
|
||||
<span className="tabular-nums">
|
||||
{(skill.db_markdown_chars / 1000).toFixed(1)}K
|
||||
</span>
|
||||
<span>תווים</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<HardDrive className="w-3 h-3" />
|
||||
<span className="tabular-nums">
|
||||
{formatSize(skill.disk_skill_md_bytes)}
|
||||
</span>
|
||||
</div>
|
||||
</dl>
|
||||
{skill.updated_at && (
|
||||
<p className="text-[0.7rem] text-ink-light mt-2">
|
||||
עודכן: {new Date(skill.updated_at).toLocaleDateString("he-IL")}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SkillsPage() {
|
||||
const { data, isPending, error } = useSkills();
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<section className="space-y-6">
|
||||
<header>
|
||||
<nav className="text-[0.78rem] text-ink-muted mb-1">
|
||||
<Link href="/" className="hover:text-gold-deep">בית</Link>
|
||||
<span aria-hidden> · </span>
|
||||
<span className="text-navy">מיומנויות</span>
|
||||
</nav>
|
||||
<h1 className="text-navy mb-0">מיומנויות Paperclip</h1>
|
||||
<p className="text-ink-muted text-sm mt-1 max-w-2xl">
|
||||
רשימת ה-skills המותקנים במערכת Paperclip ומצב הסנכרון שלהם בין ה-DB
|
||||
לדיסק.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />
|
||||
|
||||
{error ? (
|
||||
<Card className="bg-danger-bg border-danger/40">
|
||||
<CardContent className="px-6 py-6 text-center text-danger">
|
||||
{error.message}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : isPending ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-32 w-full rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : data?.length === 0 ? (
|
||||
<Card className="bg-surface border-rule">
|
||||
<CardContent className="px-6 py-12 text-center text-ink-muted">
|
||||
<div className="text-gold text-3xl mb-2" aria-hidden>❦</div>
|
||||
אין skills מותקנים
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{data?.map((s) => <SkillCard key={s.slug} skill={s} />)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
56
web-ui/src/app/training/page.tsx
Normal file
56
web-ui/src/app/training/page.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { StyleReportPanel } from "@/components/training/style-report-panel";
|
||||
import { CorpusPanel } from "@/components/training/corpus-panel";
|
||||
import { ComparePanel } from "@/components/training/compare-panel";
|
||||
|
||||
export default function TrainingPage() {
|
||||
return (
|
||||
<AppShell>
|
||||
<section className="space-y-6">
|
||||
<header>
|
||||
<nav className="text-[0.78rem] text-ink-muted mb-1">
|
||||
<Link href="/" className="hover:text-gold-deep">בית</Link>
|
||||
<span aria-hidden> · </span>
|
||||
<span className="text-navy">אימון סגנון</span>
|
||||
</nav>
|
||||
<h1 className="text-navy mb-0">הפורטרט הסגנוני של דפנה</h1>
|
||||
<p className="text-ink-muted text-sm mt-1 max-w-2xl">
|
||||
לוח בקרה של קורפוס האימון — סטטיסטיקות, אנטומיית החלטה ממוצעת,
|
||||
ביטויי חתימה, וכלי השוואה בין שתי החלטות.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />
|
||||
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-5">
|
||||
<Tabs defaultValue="report" dir="rtl">
|
||||
<TabsList className="bg-rule-soft/60">
|
||||
<TabsTrigger value="report">פורטרט סגנון</TabsTrigger>
|
||||
<TabsTrigger value="corpus">קורפוס</TabsTrigger>
|
||||
<TabsTrigger value="compare">השוואה</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="report" className="mt-5">
|
||||
<StyleReportPanel />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="corpus" className="mt-5">
|
||||
<CorpusPanel />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="compare" className="mt-5">
|
||||
<ComparePanel />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user