"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"; import { formatDateLong } from "@/lib/format-date"; /* * /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; section: ScriptStatus; // which `## ` section the row lives in (= its status) group: string; // the `### ` sub-topic within the section }; 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" }, }; // Archived/deleted rows get their own collapsible groups; active + one-time rows // are grouped by the `### ` headers maintained in SCRIPTS.md, under // their respective `## ` section ("סקריפטים פעילים" / "חד פעמי", #11 + one-time split). const ARCHIVE_GROUP = "ארכיון"; const DELETED_GROUP = "נמחקו"; // Top-level sections in render order; "active"/"once" carry a section heading. const SECTION_ORDER: ScriptStatus[] = ["active", "once", "archive", "deleted"]; const SECTION_META: Record = { active: { label: "פעילים", note: "סקריפטים חוזרים / קבועי-תפעול, מקובצים לפי תת-נושא." }, once: { label: "חד פעמי", note: "סקריפטים שהורצו פעם-אחת (מיגרציות / backfills / POCs), נשמרים לרפרנס.", }, archive: null, deleted: null, }; /** * Parse SCRIPTS.md markdown tables into typed rows. The file has four `## ` * sections (פעילים / חד פעמי / .archive / נמחקו); the first two are split into * `### ` blocks. The row's `section` is its status (and which heading * it renders under); `group` is the sub-topic. */ 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 if (line.includes("חד פעמי")) section = "once"; 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; // role: active/once = Purpose (col 2), archive = Original Purpose (col 1), // deleted = Reason (col 1). const role = section === "active" || section === "once" ? cells[2] ?? cells[1] ?? "" : cells[1] ?? ""; const group = section === "archive" ? ARCHIVE_GROUP : section === "deleted" ? DELETED_GROUP : category || "כללי"; rows.push({ name, role: stripMd(role), status: section, section, group }); } return rows; } type SectionBlock = { key: ScriptStatus; groups: { title: string; rows: ScriptRow[] }[]; }; /** Group rows by section, then by sub-topic within each section, preserving * first-seen order; sections themselves come back in SECTION_ORDER. */ function sectionize(rows: ScriptRow[]): SectionBlock[] { const bySection = new Map>(); for (const r of rows) { let cats = bySection.get(r.section); if (!cats) { cats = new Map(); bySection.set(r.section, cats); } let g = cats.get(r.group); if (!g) { g = []; cats.set(r.group, g); } g.push(r); } return SECTION_ORDER.filter((k) => bySection.has(k)).map((key) => ({ key, groups: [...bySection.get(key)!.entries()].map(([title, rs]) => ({ title, rows: rs, })), })); } // 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"; // giteaBase is the SCRIPTS.md *file* URL (…/scripts/SCRIPTS.md); the // per-script "מקור" link needs the scripts/ *directory*, so strip the // trailing filename before appending the script name (else the link // becomes …/scripts/SCRIPTS.md/ and 404s on Gitea). 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, indented, }: { title: string; rows: ScriptRow[]; giteaBase: string | null; defaultOpen: boolean; runnable: Set; runningName: string | null; onRun: (name: string) => void; indented?: boolean; }) { 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 sections = useMemo(() => sectionize(rows), [rows]); const lastModified = data?.last_modified != null ? formatDateLong(data.last_modified * 1000) : 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 ?? "לא ידוע"} ) : (
{sections.map((sec) => { const meta = SECTION_META[sec.key]; return (
{meta ? (

{meta.label}

{meta.note}
) : null} {sec.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}
); }