"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 = { active: "פעיל", once: "חד-פעמי", archive: "ארכיון", deleted: "נמחק", }; const STATUS_TONE: Record = { 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 `### ` 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 `### ` 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(); 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 ( {STATUS_LABEL[status]} ); } function ScriptTable({ rows, giteaBase, runnable, runningName, onRun, }: { rows: ScriptRow[]; giteaBase: string | null; runnable: Set; runningName: string | null; onRun: (name: string) => void; }) { return ( שם הסקריפט תפקיד סטטוס פעולה {rows.map((s) => { const disabled = s.status === "archive" || s.status === "deleted"; const href = giteaBase ? `${giteaBase.replace(/\/$/, "")}/${s.name}` : null; return ( {s.name} {s.role} {/* "הרץ" — only on read-only/audit scripts in the host allowlist (#4) */} {!disabled && runnable.has(s.name) ? ( ) : null} {disabled || !href ? ( ) : ( מקור )} ); })}
); } /** 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; runningName: string | null; onRun: (name: string) => void; }) { const [open, setOpen] = useState(defaultOpen); return ( {open ? ( ) : null} ); } 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 (

סקריפטים

סקריפטי-תחזוקה ותפעול. מקור-האמת הוא{" "} scripts/SCRIPTS.md {" "} — עריכה דרך git, לא מכאן.

{isLoading ? ( טוען קטלוג… ) : isError ? ( שגיאה בטעינת הקטלוג: {(error as Error)?.message ?? "לא ידוע"} ) : (
{groups.map((g) => ( ))} {lastModified ? (

עודכן לאחרונה: {lastModified}

) : null}
)}
{/* run-output dialog (#4) — opens when a run completes (exit code + output) */} !o && setRunState(null)} > הרצה: {runState?.name} {runState?.result ? ( {runState.result.ok ? "✓" : "✗"} exit {runState.result.exit_code} ) : null} {runState?.error ? (

{runState.error}

) : runState?.result ? (
              {(runState.result.stdout || "") +
                (runState.result.stderr ? `\n${runState.result.stderr}` : "") ||
                "—"}
            
) : null}
); }