Files
legal-ai/web-ui/src/app/scripts/page.tsx
Chaim 221975fe23
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 11s
feat(scripts): כפתור "הרץ" מ-UI לסקריפטי read-only (קטגוריה B #4)
הרצת-סקריפט-מ-UI ב-/scripts, רק לסקריפטי קריאה-בלבד/אודיט, דרך גשר-המארח
הקיים (court-fetch) — שיכפול דפוס /adapter-migration.

אבטחה:
- allowlist בצד-המארח (services/script_runner.py, מקור-אמת יחיד): name→argv
  קבוע ובטוח. 5 סקריפטים מאומתים: leak_guard, check_undefined_names,
  storage_leak_tripwire, audit_training_corpus, audit_corpus_integrity --no-notify.
- הגשר (court_fetch_service/server.py): endpoint POST /run-script, Bearer-auth,
  ולידציית-allowlist, create_subprocess_exec (ללא shell), timeout 600s,
  audit-log; מתעלם מארגומנטים מהבקשה (argv מה-allowlist בלבד).
- קונטיינר (web/app.py): POX /api/scripts/{name}/run proxy ל-גשר + pre-check
  allowlist; הרחבת /api/scripts/catalog ב-runnable_scripts.
- UI: כפתור "הרץ" (ירוק) רק לשורות-runnable + דיאלוג-פלט (exit-code+stdout);
  שאר השורות "מקור" בלבד. confirm לפני הרצה.

מאושר דרך שער-העיצוב (מוקאפ 16-scripts עודכן עם כפתור "הרץ").
G12: leak_guard עובר (אין סמלי-פלטפורמה בשכבת-האינטליגנציה).

Deploy דו-שלבי: גשר-המארח דורש git pull בעותק-המארח + pm2 restart
legal-court-fetch-service; הקונטיינר נפרס דרך Coolify כרגיל.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 04:30:43 +00:00

455 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { ChevronDown, ChevronLeft, Play, Loader2 } from "lucide-react";
import { AppShell } from "@/components/app-shell";
import { Card } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
fetchScriptsCatalog,
useRunScript,
type ScriptRunResult,
} from "@/lib/api/scripts";
/*
* /scripts — catalog of everything under scripts/, rendered as the
* approved IA-redesign table (name mono · role · status chip · run/source
* ghost button).
*
* The single source of truth is still `scripts/SCRIPTS.md` (CLAUDE.md mandates
* updating it on every script change), served verbatim by
* GET /api/scripts/catalog. We parse its markdown tables into structured rows
* for display — editing remains git/Gitea only, so the per-row "מקור" button
* deep-links to the file in Gitea rather than inventing a run-from-UI mutation.
*/
type ScriptStatus = "active" | "once" | "archive" | "deleted";
type ScriptRow = {
name: string;
role: string;
status: ScriptStatus;
group: string;
};
const STATUS_LABEL: Record<ScriptStatus, string> = {
active: "פעיל",
once: "חד-פעמי",
archive: "ארכיון",
deleted: "נמחק",
};
const STATUS_TONE: Record<ScriptStatus, { wrap: string; dot: string }> = {
active: { wrap: "bg-success-bg text-success", dot: "bg-success" },
once: { wrap: "bg-info-bg text-info", dot: "bg-info" },
archive: { wrap: "bg-rule-soft text-ink-muted", dot: "bg-ink-muted" },
deleted: { wrap: "bg-danger-bg text-danger", dot: "bg-danger" },
};
// "חד-פעמי" / "one-shot" markers inside the Scheduled column of an active row.
const ONCE_RE = /חד-?פעמי|one-?shot|בוצע/;
// Archived/deleted rows get their own collapsible groups; active rows are
// grouped by the `### <sub-topic>` headers maintained in SCRIPTS.md (#11).
const ARCHIVE_GROUP = "ארכיון";
const DELETED_GROUP = "נמחקו";
/**
* Parse SCRIPTS.md markdown tables into typed rows. The file has three
* sections; the active section is further split into `### <sub-topic>` blocks.
* We read the first two columns (name + role), derive status from the section +
* scheduling note, and carry the sub-topic header as the row's display group.
*/
function parseScripts(md: string): ScriptRow[] {
const lines = md.split("\n");
const rows: ScriptRow[] = [];
let section: ScriptStatus = "active";
let category = "";
for (const raw of lines) {
const line = raw.trim();
if (line.startsWith("## ")) {
if (line.includes(".archive") || line.includes("הושלמו")) section = "archive";
else if (line.includes("נמחק")) section = "deleted";
else section = "active";
category = "";
continue;
}
if (line.startsWith("### ")) {
category = line.slice(4).trim();
continue;
}
if (!line.startsWith("|")) continue;
// skip header + separator rows
const cells = line
.split("|")
.slice(1, -1)
.map((c) => c.trim());
if (cells.length < 2) continue;
if (/^-+$/.test(cells[0].replace(/[-:]/g, "-"))) continue; // separator
if (cells[0] === "Script") continue; // header
if (!cells[0]) continue;
const name = cells[0].replace(/`/g, "");
if (!name) continue;
let status: ScriptStatus = section;
if (section === "active") {
const scheduled = cells[3] ?? "";
status = ONCE_RE.test(scheduled) ? "once" : "active";
}
// role: active = Purpose (col 2), archive = Original Purpose (col 1),
// deleted = Reason (col 1).
const role =
section === "active" ? cells[2] ?? cells[1] ?? "" : cells[1] ?? "";
const group =
section === "archive" ? ARCHIVE_GROUP
: section === "deleted" ? DELETED_GROUP
: category || "כללי";
rows.push({ name, role: stripMd(role), status, group });
}
return rows;
}
/** Group rows by their display group, preserving first-seen order. */
function groupScripts(rows: ScriptRow[]): { title: string; rows: ScriptRow[] }[] {
const order: string[] = [];
const byGroup = new Map<string, ScriptRow[]>();
for (const r of rows) {
if (!byGroup.has(r.group)) {
byGroup.set(r.group, []);
order.push(r.group);
}
byGroup.get(r.group)!.push(r);
}
return order.map((title) => ({ title, rows: byGroup.get(title)! }));
}
// Strip bold/inline-code markdown so the role reads as plain text in a cell.
function stripMd(s: string): string {
return s.replace(/\*\*/g, "").replace(/`/g, "");
}
function StatusChip({ status }: { status: ScriptStatus }) {
const tone = STATUS_TONE[status];
return (
<span
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-[3px] text-[0.75rem] font-semibold ${tone.wrap}`}
>
<span className={`h-1.5 w-1.5 rounded-full ${tone.dot}`} aria-hidden />
{STATUS_LABEL[status]}
</span>
);
}
function ScriptTable({
rows, giteaBase, runnable, runningName, onRun,
}: {
rows: ScriptRow[];
giteaBase: string | null;
runnable: Set<string>;
runningName: string | null;
onRun: (name: string) => void;
}) {
return (
<Table>
<TableHeader>
<TableRow className="bg-parchment hover:bg-parchment border-rule">
<TableHead className="text-start text-[0.75rem] font-semibold text-ink-muted px-5 py-3">
שם הסקריפט
</TableHead>
<TableHead className="text-start text-[0.75rem] font-semibold text-ink-muted px-5 py-3">
תפקיד
</TableHead>
<TableHead className="text-start text-[0.75rem] font-semibold text-ink-muted px-5 py-3">
סטטוס
</TableHead>
<TableHead className="text-end text-[0.75rem] font-semibold text-ink-muted px-5 py-3">
פעולה
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((s) => {
const disabled = s.status === "archive" || s.status === "deleted";
const href = giteaBase
? `${giteaBase.replace(/\/$/, "")}/${s.name}`
: null;
return (
<TableRow
key={s.name}
className="border-rule-soft hover:bg-gold-wash align-middle"
>
<TableCell className="px-5 py-3.5">
<code
className="font-mono text-[0.81rem] font-semibold text-navy"
dir="ltr"
>
{s.name}
</code>
</TableCell>
<TableCell className="px-5 py-3.5 text-ink-soft text-[0.84rem] leading-snug max-w-xl whitespace-normal">
<span className="line-clamp-2">{s.role}</span>
</TableCell>
<TableCell className="px-5 py-3.5">
<StatusChip status={s.status} />
</TableCell>
<TableCell className="px-5 py-3.5 text-end">
<span className="inline-flex items-center gap-2 justify-end">
{/* "הרץ" — only on read-only/audit scripts in the host allowlist (#4) */}
{!disabled && runnable.has(s.name) ? (
<button
type="button"
disabled={runningName !== null}
onClick={() => onRun(s.name)}
className="inline-flex items-center gap-1.5 rounded-lg border border-success bg-success px-3.5 py-1.5 text-[0.81rem] font-semibold text-white hover:bg-success/90 disabled:opacity-50 transition-colors"
>
{runningName === s.name ? (
<Loader2 className="size-3.5 animate-spin" aria-hidden />
) : (
<Play className="size-3.5" aria-hidden />
)}
הרץ
</button>
) : null}
{disabled || !href ? (
<button
type="button"
disabled
className="rounded-lg border border-rule-soft px-4 py-1.5 text-[0.81rem] font-semibold text-ink-muted cursor-default"
>
מקור
</button>
) : (
<a
href={href}
target="_blank"
rel="noreferrer"
className="inline-block rounded-lg border border-rule px-4 py-1.5 text-[0.81rem] font-semibold text-gold-deep hover:bg-gold-wash hover:border-gold transition-colors"
>
מקור
</a>
)}
</span>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
);
}
/** Collapsible sub-topic block (#11, mockup 16): parchment header with a
* chevron + title + count, and the group's table beneath. */
function ScriptGroup({
title, rows, giteaBase, defaultOpen, runnable, runningName, onRun,
}: {
title: string;
rows: ScriptRow[];
giteaBase: string | null;
defaultOpen: boolean;
runnable: Set<string>;
runningName: string | null;
onRun: (name: string) => void;
}) {
const [open, setOpen] = useState(defaultOpen);
return (
<Card className="bg-surface border-rule shadow-sm overflow-hidden p-0">
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
className={`flex w-full items-center gap-2.5 bg-parchment px-5 py-3 text-start transition-colors hover:bg-gold-wash/50 ${
open ? "border-b border-rule" : ""
}`}
>
{open ? (
<ChevronDown className="size-4 text-ink-muted" aria-hidden />
) : (
<ChevronLeft className="size-4 text-ink-muted" aria-hidden />
)}
<h2 className="m-0 text-[0.95rem] font-semibold text-navy">{title}</h2>
<span className="ms-auto rounded-full bg-rule-soft px-2.5 py-0.5 text-[0.78rem] text-ink-muted tabular-nums">
{rows.length}
</span>
</button>
{open ? (
<ScriptTable
rows={rows}
giteaBase={giteaBase}
runnable={runnable}
runningName={runningName}
onRun={onRun}
/>
) : null}
</Card>
);
}
export default function ScriptsPage() {
const { data, isLoading, isError, error } = useQuery({
queryKey: ["scripts-catalog"],
queryFn: ({ signal }) => fetchScriptsCatalog(signal),
});
const rows = useMemo(
() => (data?.content ? parseScripts(data.content) : []),
[data],
);
const groups = useMemo(() => groupScripts(rows), [rows]);
const lastModified =
data?.last_modified != null
? new Date(data.last_modified * 1000).toLocaleDateString("he-IL", {
year: "numeric",
month: "long",
day: "numeric",
})
: null;
const giteaBase = data?.gitea_url ?? null;
// #4 — read-only scripts the host allowlist permits running; the "הרץ" button
// surfaces only for these. A run opens an output dialog with exit code + stdout.
const runnable = useMemo(
() => new Set(data?.runnable_scripts ?? []),
[data],
);
const run = useRunScript();
const [runState, setRunState] = useState<{
name: string;
running: boolean;
result?: ScriptRunResult;
error?: string;
} | null>(null);
const runningName = runState?.running ? runState.name : null;
async function handleRun(name: string) {
if (!window.confirm(`להריץ את ${name}? (סקריפט קריאה-בלבד)`)) return;
setRunState({ name, running: true });
try {
const result = await run.mutateAsync(name);
setRunState({ name, running: false, result });
} catch (e) {
setRunState({
name,
running: false,
error: e instanceof Error ? e.message : "שגיאה בהרצה",
});
}
}
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-sm text-ink-muted mt-1 max-w-2xl">
סקריפטי-תחזוקה ותפעול. מקור-האמת הוא{" "}
<code className="rounded bg-rule-soft px-1 py-0.5 font-mono text-[0.78rem]">
scripts/SCRIPTS.md
</code>{" "}
עריכה דרך git, לא מכאן.
</p>
</header>
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />
{isLoading ? (
<Card className="bg-surface border-rule px-6 py-5 text-sm text-ink-muted">
טוען קטלוג
</Card>
) : isError ? (
<Card className="bg-danger-bg border-danger/40 px-6 py-5 text-sm text-danger">
שגיאה בטעינת הקטלוג: {(error as Error)?.message ?? "לא ידוע"}
</Card>
) : (
<div className="space-y-3.5">
{groups.map((g) => (
<ScriptGroup
key={g.title}
title={g.title}
rows={g.rows}
giteaBase={giteaBase}
defaultOpen={g.title !== ARCHIVE_GROUP && g.title !== DELETED_GROUP}
runnable={runnable}
runningName={runningName}
onRun={handleRun}
/>
))}
{lastModified ? (
<p className="px-1 pt-1 text-xs text-ink-muted">
עודכן לאחרונה: {lastModified}
</p>
) : null}
</div>
)}
</section>
{/* run-output dialog (#4) — opens when a run completes (exit code + output) */}
<Dialog
open={!!runState && !runState.running}
onOpenChange={(o) => !o && setRunState(null)}
>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 flex-wrap">
<span className="text-ink-soft text-sm font-normal">הרצה:</span>
<code dir="ltr" className="font-mono text-sm text-navy">
{runState?.name}
</code>
{runState?.result ? (
<span
className={`ms-auto rounded-full px-2.5 py-0.5 text-xs font-semibold ${
runState.result.ok
? "bg-success-bg text-success"
: "bg-danger-bg text-danger"
}`}
>
{runState.result.ok ? "✓" : "✗"} exit {runState.result.exit_code}
</span>
) : null}
</DialogTitle>
</DialogHeader>
{runState?.error ? (
<p className="text-danger text-sm">{runState.error}</p>
) : runState?.result ? (
<pre
dir="ltr"
className="text-[0.72rem] font-mono whitespace-pre-wrap max-h-72 overflow-auto rounded-md border border-rule-soft bg-parchment p-3 leading-relaxed"
>
{(runState.result.stdout || "") +
(runState.result.stderr ? `\n${runState.result.stderr}` : "") ||
"—"}
</pre>
) : null}
</DialogContent>
</Dialog>
</AppShell>
);
}