Merge pull request 'feat(scripts): כפתור "הרץ" מ-UI לסקריפטי read-only (קטגוריה B #4)' (#283) from worktree-scripts-run-ui into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m34s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 10s

This commit was merged in pull request #283.
This commit is contained in:
2026-06-17 04:31:19 +00:00
5 changed files with 315 additions and 25 deletions

View File

@@ -3,10 +3,16 @@
import { useMemo, useState } from "react";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { ChevronDown, ChevronLeft } from "lucide-react";
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,
@@ -15,7 +21,11 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { fetchScriptsCatalog } from "@/lib/api/scripts";
import {
fetchScriptsCatalog,
useRunScript,
type ScriptRunResult,
} from "@/lib/api/scripts";
/*
* /scripts — catalog of everything under scripts/, rendered as the
@@ -150,7 +160,15 @@ function StatusChip({ status }: { status: ScriptStatus }) {
);
}
function ScriptTable({ rows, giteaBase }: { rows: ScriptRow[]; giteaBase: string | null }) {
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>
@@ -195,24 +213,42 @@ function ScriptTable({ rows, giteaBase }: { rows: ScriptRow[]; giteaBase: string
<StatusChip status={s.status} />
</TableCell>
<TableCell className="px-5 py-3.5 text-end">
{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 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>
);
@@ -225,8 +261,16 @@ function ScriptTable({ rows, giteaBase }: { rows: ScriptRow[]; giteaBase: string
/** 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,
}: { title: string; rows: ScriptRow[]; giteaBase: string | null; defaultOpen: boolean }) {
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">
@@ -248,7 +292,15 @@ function ScriptGroup({
{rows.length}
</span>
</button>
{open ? <ScriptTable rows={rows} giteaBase={giteaBase} /> : null}
{open ? (
<ScriptTable
rows={rows}
giteaBase={giteaBase}
runnable={runnable}
runningName={runningName}
onRun={onRun}
/>
) : null}
</Card>
);
}
@@ -276,6 +328,36 @@ export default function ScriptsPage() {
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">
@@ -314,6 +396,9 @@ export default function ScriptsPage() {
rows={g.rows}
giteaBase={giteaBase}
defaultOpen={g.title !== ARCHIVE_GROUP && g.title !== DELETED_GROUP}
runnable={runnable}
runningName={runningName}
onRun={handleRun}
/>
))}
{lastModified ? (
@@ -324,6 +409,46 @@ export default function ScriptsPage() {
</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>
);
}

View File

@@ -1,3 +1,4 @@
import { useMutation } from "@tanstack/react-query";
import { apiRequest } from "./client";
// ── Scripts catalog ───────────────────────────────────────────────
@@ -11,8 +12,32 @@ export type ScriptsCatalog = {
bytes: number;
last_modified: number;
gitea_url: string;
// #4 — basenames the UI may offer a "הרץ" button for (read-only allowlist,
// enforced host-side). Optional so an older backend (pre-deploy) degrades to
// "מקור"-only with no run buttons.
runnable_scripts?: string[];
};
export function fetchScriptsCatalog(signal?: AbortSignal) {
return apiRequest<ScriptsCatalog>("/api/scripts/catalog", { signal });
}
// ── Run a read-only script (#4) ───────────────────────────────────
// Proxies to the court-fetch host bridge; only allowlisted read-only scripts
// run, with a fixed safe argv. Exit code + captured output are relayed verbatim.
export type ScriptRunResult = {
ok: boolean;
exit_code: number;
stdout: string;
stderr: string;
};
export function useRunScript() {
return useMutation({
mutationFn: (name: string) =>
apiRequest<ScriptRunResult>(
`/api/scripts/${encodeURIComponent(name)}/run`,
{ method: "POST" },
),
});
}