הרצת-סקריפט-מ-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>
455 lines
16 KiB
TypeScript
455 lines
16 KiB
TypeScript
"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>
|
||
);
|
||
}
|