diff --git a/packages/server/script-sandbox.js b/packages/server/script-sandbox.js index d03b28a..f01ffa7 100644 --- a/packages/server/script-sandbox.js +++ b/packages/server/script-sandbox.js @@ -291,6 +291,10 @@ function createScriptSandbox({ log, script, workflowName }) { return sandbox; } +function compileScriptSource(source, filename) { + return new vm.Script(wrapScriptSource(source, filename), { filename }); +} + function loadCompiledScript(script) { const filePath = path.join(SCRIPTS_DIR, script); const { mtimeMs } = fs.statSync(filePath); @@ -300,9 +304,7 @@ function loadCompiledScript(script) { } const source = fs.readFileSync(filePath, "utf8"); - const compiled = new vm.Script(wrapScriptSource(source, script), { - filename: filePath, - }); + const compiled = compileScriptSource(source, filePath); scriptCache.set(script, { compiled, mtimeMs }); return compiled; } @@ -320,3 +322,18 @@ export async function runScript(script, ctx, { log, workflowName }) { const fn = compiled.runInContext(sandbox); return await fn(ctx); } + +/** + * Evaluate script source in memory (dry-run). Does not read from disk or use the disk cache. + * + * @param {string} script + * @param {string} source + * @param {unknown} ctx + * @param {{ log: import("pino").Logger, workflowName: string }} opts + */ +export async function runScriptSource(script, source, ctx, { log, workflowName }) { + const compiled = compileScriptSource(source, script); + const sandbox = createScriptSandbox({ log, script, workflowName }); + const fn = compiled.runInContext(sandbox); + return await fn(ctx); +} diff --git a/packages/server/scripts/fetch-binary.js b/packages/server/scripts/fetch-binary.js new file mode 100644 index 0000000..d765fde --- /dev/null +++ b/packages/server/scripts/fetch-binary.js @@ -0,0 +1,70 @@ +function ensureDataObject(ctx) { + if (ctx.data == null || typeof ctx.data !== "object" || Array.isArray(ctx.data)) { + ctx.data = {}; + } +} + +function filenameFromUrl(url) { + try { + const name = new URL(url).pathname.split("/").filter(Boolean).pop(); + return name || "attachment"; + } catch { + return "attachment"; + } +} + +function resolveUrl(ctx) { + const fromConfig = ctx.config?.url; + if (typeof fromConfig === "string" && fromConfig.length > 0) { + return fromConfig; + } + + const urlVar = ctx.config?.urlVar; + if (typeof urlVar === "string" && urlVar.length > 0) { + const value = ctx.data?.[urlVar]; + if (typeof value === "string" && value.length > 0) { + return value; + } + } + + for (const key of ["attach", "url"]) { + const value = ctx.data?.[key]; + if (typeof value === "string" && value.length > 0) { + return value; + } + } + + return null; +} + +export default async function fetchBinary(ctx) { + ensureDataObject(ctx); + + const url = resolveUrl(ctx); + if (!url) { + throw new Error( + "fetch-binary: url is required (ctx.config.url, ctx.config.urlVar, ctx.data.attach, or ctx.data.url)", + ); + } + + const outputVar = typeof ctx.config?.outputVar === "string" && ctx.config.outputVar.length > 0 + ? ctx.config.outputVar + : "file"; + + log.info({ url, outputVar }, "fetch-binary: fetching"); + const response = await $axios.get(url, { responseType: "arraybuffer" }); + const file = Buffer.from(response.data ?? []); + const contentType = String(response.headers?.["content-type"] ?? "application/octet-stream"); + const filename = ctx.config?.filename || ctx.data.filename || filenameFromUrl(url); + + ctx.data[outputVar] = file; + ctx.data.filename = filename; + ctx.data.contentType = contentType; + + log.info( + { outputVar, filename, contentType, length: file.length }, + "fetch-binary: saved", + ); + + return ctx; +} diff --git a/packages/server/scripts/ntfy.js b/packages/server/scripts/ntfy.js index 065c4fe..d3e974b 100644 --- a/packages/server/scripts/ntfy.js +++ b/packages/server/scripts/ntfy.js @@ -1,20 +1,66 @@ +function ntfyHeaders(ctx) { + const headers = {}; + + if (ctx.data?.title) { + log.info("ntfy: setting title %s", ctx.data.title); + headers.Title = ctx.data.title; + } + + return headers; +} + export default async function ntfy(ctx) { - log.info({ ctx }, "ntfy incoming context"); - const headers = {} - - if(ctx.data?.title) { - log.info("ntfy: setting title %s", ctx.data.title); - headers.Title = ctx.data.title + const file = ctx.data?.file; + const hasFile = Buffer.isBuffer(file) || file instanceof Uint8Array; + + log.info( + { + title: ctx.data?.title, + message: ctx.data?.message, + filename: ctx.data?.filename, + hasFile, + fileLength: hasFile ? file.length : 0, + attach: ctx.data?.attach, + }, + "ntfy incoming context", + ); + + const headers = ntfyHeaders(ctx); + const ntfyUrl = ctx.config?.url || "https://ntfy.sh/scrunner"; + + if (hasFile) { + const filename = ctx.data?.filename || "attachment"; + headers.Filename = filename; + if (ctx.data?.message) { + headers.Message = ctx.data.message; + } + if (ctx.data?.contentType) { + headers["Content-Type"] = ctx.data.contentType; } - const ntfyUrl = ctx.config?.url || "https://ntfy.sh/scrunner"; + log.info( + { ntfyUrl, filename, length: file.length }, + "ntfy uploading file", + ); + await $axios.put(ntfyUrl, file, { + headers, + maxBodyLength: Infinity, + maxContentLength: Infinity, + }); + return { sent: "true" }; + } - log.info("ntfy sending message to %s", ntfyUrl); - const truncatedMessage = ctx.data?.message?.substring(0, 100); - log.info("ntfy messsage: %s", truncatedMessage); + if (ctx.data?.attach) { + log.info("ntfy: setting attach %s", ctx.data.attach); + headers.Attach = ctx.data.attach; + } - await $axios.post(ntfyUrl, ctx.data?.message || "Hello from scrunner", { - headers: headers - }) - return {sent: "true"} + const truncatedMessage = ctx.data?.message?.substring(0, 100); + log.info("ntfy sending message to %s", ntfyUrl); + log.info("ntfy messsage: %s", truncatedMessage); + + await $axios.post(ntfyUrl, ctx.data?.message || "Hello from scrunner", { + headers, + }); + return { sent: "true" }; } diff --git a/packages/server/src/api/dry-run-logger.js b/packages/server/src/api/dry-run-logger.js new file mode 100644 index 0000000..939ee34 --- /dev/null +++ b/packages/server/src/api/dry-run-logger.js @@ -0,0 +1,78 @@ +import pino from "pino"; + +const LEVEL_TO_NUM = { + trace: 10, + debug: 20, + info: 30, + warn: 40, + error: 50, + fatal: 60, +}; + +/** + * Pino logger that collects log lines in memory (no SQLite / process logger). + * + * @returns {{ log: import("pino").Logger, logs: Array<{ ts: string, level: number, msg?: string | null, payload?: unknown }> }} + */ +export function createDryRunLogger() { + /** @type {Array<{ ts: string, level: number, msg?: string | null, payload?: unknown }>} */ + const logs = []; + + const dest = { + write(line) { + let record; + try { + record = JSON.parse(typeof line === "string" ? line : String(line)); + } catch { + return; + } + + const ts = + typeof record.time === "number" + ? new Date(record.time).toISOString() + : typeof record.time === "string" + ? record.time + : new Date().toISOString(); + + const level = + typeof record.level === "number" + ? record.level + : LEVEL_TO_NUM[record.level] ?? LEVEL_TO_NUM.info; + + const { time: _time, level: _level, msg = null, ...rest } = record; + logs.push({ + ts, + level, + msg, + payload: Object.keys(rest).length ? rest : null, + }); + }, + }; + + const log = pino({ level: "trace" }, dest); + return { log, logs }; +} + +/** + * @param {unknown} value + */ +export function safeSerialize(value) { + const seen = new WeakSet(); + try { + return JSON.parse( + JSON.stringify(value, (_key, v) => { + if (typeof v === "bigint") return v.toString(); + if (typeof v === "object" && v !== null) { + if (seen.has(v)) return "[Circular]"; + seen.add(v); + } + return v; + }), + ); + } catch (err) { + return { + error: "output could not be serialized", + detail: err instanceof Error ? err.message : String(err), + }; + } +} diff --git a/packages/server/src/api/scripts.js b/packages/server/src/api/scripts.js index 4c06684..48644a2 100644 --- a/packages/server/src/api/scripts.js +++ b/packages/server/src/api/scripts.js @@ -1,5 +1,6 @@ -import { clearScriptCache } from "../../script-sandbox.js"; +import { clearScriptCache, runScriptSource } from "../../script-sandbox.js"; import * as fsStore from "../../fs-store.js"; +import { createDryRunLogger, safeSerialize } from "./dry-run-logger.js"; /** * @param {{ referencedScripts: () => Set }} registry @@ -60,5 +61,51 @@ export default function scriptsPluginFactory(registry) { clearScriptCache(); return { ok: true }; }); + + fastify.post("/scripts/:name/dry-run", async (req, reply) => { + const { name } = /** @type {{ name: string }} */ (req.params); + try { + fsStore.assertScriptName(name); + } catch (err) { + return reply.code(err.statusCode ?? 400).send({ error: err.message }); + } + + const body = /** @type {{ content?: string, data?: unknown, config?: unknown }} */ ( + req.body ?? {} + ); + if (typeof body.content !== "string") { + return reply.code(400).send({ error: "content is required" }); + } + + const ctx = { + data: body.data ?? null, + config: body.config ?? null, + }; + + const { log, logs } = createDryRunLogger(); + const started = Date.now(); + + try { + const output = await runScriptSource(name, body.content, ctx, { + log, + workflowName: "dry-run", + }); + return { + status: "success", + output: safeSerialize(output), + error: null, + logs, + durationMs: Date.now() - started, + }; + } catch (err) { + return { + status: "failed", + output: null, + error: err instanceof Error ? err.message : String(err), + logs, + durationMs: Date.now() - started, + }; + } + }); }; } diff --git a/packages/server/workflows/default/comic-monkeyuser-to-ntfy.yaml b/packages/server/workflows/default/comic-monkeyuser-to-ntfy.yaml new file mode 100644 index 0000000..345725f --- /dev/null +++ b/packages/server/workflows/default/comic-monkeyuser-to-ntfy.yaml @@ -0,0 +1,22 @@ +name: Comic - monkeyuser to ntfy +scripts: + - script: fetch-html.js + config: + url: "https://www.monkeyuser.com/" + outputVar: "httpResponse" + selector: ".comic img" + jsonata: | + {"url": "https://www.monkeyuser.com" & [attributes.src][0], "title": [attributes.title][0]} + - script: jsonata.js + config: + expression: | + {"data": {"title": httpResponse.title, "message": httpResponse.title, "attach": httpResponse.url}} + - fetch-binary.js + - script: ntfy.js + config: + url: https://ntfy.sh/scrunner + +triggers: + - type: HTTP + method: POST + path: /new diff --git a/packages/server/workflows/default/registers.yaml b/packages/server/workflows/default/registers.yaml index 192c92e..d73f2f8 100644 --- a/packages/server/workflows/default/registers.yaml +++ b/packages/server/workflows/default/registers.yaml @@ -4,3 +4,4 @@ scripts: - manual-trigger.yaml - cron-example.yaml - fetch-devto.yaml + - comic-monkeyuser-to-ntfy.yaml diff --git a/packages/web/src/App.jsx b/packages/web/src/App.jsx index 1ac8ed1..1348da9 100644 --- a/packages/web/src/App.jsx +++ b/packages/web/src/App.jsx @@ -6,6 +6,8 @@ import { Layout } from "./components/Layout.jsx"; import { AuthPage } from "./pages/AuthPage.jsx"; import { HomePage } from "./pages/HomePage.jsx"; import { ScriptsPage } from "./pages/ScriptsPage.jsx"; +import { ScriptDryRunPage } from "./pages/ScriptDryRunPage.jsx"; +import { ScriptEditPage, ScriptNewPage } from "./pages/ScriptEditPage.jsx"; import { WorkflowsPage } from "./pages/WorkflowsPage.jsx"; import { EventsPage } from "./pages/EventsPage.jsx"; import { EventDetailPage } from "./pages/EventDetailPage.jsx"; @@ -45,6 +47,9 @@ export function App() { } /> } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/packages/web/src/api/hooks.js b/packages/web/src/api/hooks.js index 44517cd..c0d7d32 100644 --- a/packages/web/src/api/hooks.js +++ b/packages/web/src/api/hooks.js @@ -105,6 +105,19 @@ export function useDeleteScript() { }); } +export function useDryRunScript() { + return useMutation({ + mutationFn: async ({ name, content, data, config }) => + ( + await api.post(`/scripts/${encodeURIComponent(name)}/dry-run`, { + content, + data, + config, + }) + ).data, + }); +} + export function useWorkflows(owner) { return useQuery({ queryKey: ["workflows", owner ?? "all"], diff --git a/packages/web/src/components/CodeEditor.jsx b/packages/web/src/components/CodeEditor.jsx index 234e771..9640d6a 100644 --- a/packages/web/src/components/CodeEditor.jsx +++ b/packages/web/src/components/CodeEditor.jsx @@ -1,19 +1,20 @@ import Editor from "@monaco-editor/react"; import { useTheme } from "../theme.jsx"; -export function CodeEditor({ language, value, onChange, height = "50vh" }) { +export function CodeEditor({ language, value, onChange, height = "50vh", readOnly = false }) { const { theme } = useTheme(); const isMobile = typeof window !== "undefined" && window.innerWidth < 768; return ( -
+
onChange(v ?? "")} + onChange={readOnly ? undefined : (v) => onChange(v ?? "")} theme={theme === "dark" ? "vs-dark" : "light"} options={{ + readOnly, minimap: { enabled: !isMobile }, lineNumbers: "on", tabSize: 2, diff --git a/packages/web/src/components/LogViewer.jsx b/packages/web/src/components/LogViewer.jsx new file mode 100644 index 0000000..85dfb57 --- /dev/null +++ b/packages/web/src/components/LogViewer.jsx @@ -0,0 +1,49 @@ +import { useState } from "react"; +import { levelName } from "../lib/format.jsx"; + +export function LogViewer({ logs = [], className = "" }) { + const [wordWrap, setWordWrap] = useState(true); + + return ( +
+
+

Logs

+ +
+
+ {logs.length === 0 ? ( +
+ — + no logs +
+ ) : ( + logs.map((l, i) => ( +
+ + {levelName(l.level)} + + + {l.ts} {l.msg ?? ""} + {l.payload ? ` ${JSON.stringify(l.payload)}` : ""} + +
+ )) + )} +
+
+ ); +} diff --git a/packages/web/src/components/MermaidDiagram.jsx b/packages/web/src/components/MermaidDiagram.jsx index 93f748f..717d79d 100644 --- a/packages/web/src/components/MermaidDiagram.jsx +++ b/packages/web/src/components/MermaidDiagram.jsx @@ -46,7 +46,7 @@ export function MermaidDiagram({ chart, scriptIds = {} }) { el.style.cursor = "pointer"; el.setAttribute("title", script); el.addEventListener("click", () => { - navigate(`/scripts?edit=${encodeURIComponent(script)}`); + navigate(`/scripts/${encodeURIComponent(script)}/edit`); }); } }) diff --git a/packages/web/src/lib/script.js b/packages/web/src/lib/script.js new file mode 100644 index 0000000..2b9f957 --- /dev/null +++ b/packages/web/src/lib/script.js @@ -0,0 +1,25 @@ +export const NEW_SCRIPT_TEMPLATE = `export default async function main(ctx) { + return ctx; +} +`; + +export const DEFAULT_INPUT_CONTEXT = `{ + "data": {}, + "config": {} +} +`; + +export function normalizeScriptName(name) { + const trimmed = name.trim(); + if (!trimmed) return ""; + return trimmed.endsWith(".js") ? trimmed : `${trimmed}.js`; +} + +export function prettyJson(value) { + if (value === null || value === undefined) return ""; + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} diff --git a/packages/web/src/pages/EventDetailPage.jsx b/packages/web/src/pages/EventDetailPage.jsx index f7b1467..5537753 100644 --- a/packages/web/src/pages/EventDetailPage.jsx +++ b/packages/web/src/pages/EventDetailPage.jsx @@ -1,13 +1,12 @@ -import { useState } from "react"; import { Link, useParams } from "react-router-dom"; import { LuArrowLeft } from "react-icons/lu"; import { useRun } from "../api/hooks.js"; -import { formatTime, levelName, StatusBadge } from "../lib/format.jsx"; +import { LogViewer } from "../components/LogViewer.jsx"; +import { formatTime, StatusBadge } from "../lib/format.jsx"; export function EventDetailPage() { const { id } = useParams(); const { data: run, isLoading, error } = useRun(id); - const [wordWrap, setWordWrap] = useState(true); if (isLoading) return ; if (error || !run) return

Event not found

; @@ -68,7 +67,7 @@ export function EventDetailPage() { {s.step_index} - + {s.script} @@ -84,46 +83,7 @@ export function EventDetailPage() {
-
-
-

Logs

- -
-
- {(run.logs ?? []).length === 0 ? ( -
- — - no logs -
- ) : ( - run.logs.map((l) => ( -
- - {levelName(l.level)} - - - {l.ts} {l.msg ?? ""} - {l.payload ? ` ${JSON.stringify(l.payload)}` : ""} - -
- )) - )} -
-
+
); } diff --git a/packages/web/src/pages/ScriptDryRunPage.jsx b/packages/web/src/pages/ScriptDryRunPage.jsx new file mode 100644 index 0000000..15cef0c --- /dev/null +++ b/packages/web/src/pages/ScriptDryRunPage.jsx @@ -0,0 +1,214 @@ +import { useEffect, useMemo, useState } from "react"; +import { Link, useLocation, useParams } from "react-router-dom"; +import { LuArrowLeft, LuPlay, LuSave } from "react-icons/lu"; +import { errorMessage } from "../api/client.js"; +import { + useDryRunScript, + useSaveScript, + useScript, +} from "../api/hooks.js"; +import { CodeEditor } from "../components/CodeEditor.jsx"; +import { LogViewer } from "../components/LogViewer.jsx"; +import { StatusBadge } from "../lib/format.jsx"; +import { + DEFAULT_INPUT_CONTEXT, + NEW_SCRIPT_TEMPLATE, + normalizeScriptName, + prettyJson, +} from "../lib/script.js"; + +export function ScriptDryRunPage() { + const { name: rawName } = useParams(); + const name = decodeURIComponent(rawName ?? ""); + const location = useLocation(); + const stateContent = location.state?.content; + + const existing = useScript(name, stateContent == null); + const dryRun = useDryRunScript(); + const save = useSaveScript(); + + const [content, setContent] = useState(""); + const [inputJson, setInputJson] = useState(DEFAULT_INPUT_CONTEXT); + const [outputJson, setOutputJson] = useState(""); + const [logs, setLogs] = useState([]); + const [runStatus, setRunStatus] = useState(null); + const [parseError, setParseError] = useState(null); + const [contentReady, setContentReady] = useState(false); + + useEffect(() => { + if (stateContent != null) { + setContent(stateContent); + setContentReady(true); + return; + } + if (existing.isLoading) return; + if (existing.data?.content != null) { + setContent(existing.data.content); + setContentReady(true); + return; + } + if (existing.isError) { + setContent(NEW_SCRIPT_TEMPLATE); + setContentReady(true); + } + }, [stateContent, existing.data, existing.isLoading, existing.isError]); + + const backHref = `/scripts/${encodeURIComponent(name)}/edit`; + + const lastRun = dryRun.data; + + useEffect(() => { + if (!lastRun) return; + setRunStatus(lastRun.status); + setLogs(lastRun.logs ?? []); + if (lastRun.status === "success") { + setOutputJson(prettyJson(lastRun.output)); + } else { + setOutputJson(prettyJson({ error: lastRun.error ?? "run failed" })); + } + }, [lastRun]); + + const runDisabledReason = useMemo(() => { + if (!contentReady) return "loading script"; + if (!name) return "missing script name"; + if (dryRun.isPending) return "running"; + return null; + }, [contentReady, name, dryRun.isPending]); + + function parseInputContext() { + let parsed; + try { + parsed = JSON.parse(inputJson); + } catch (err) { + throw new Error(err instanceof Error ? err.message : "invalid JSON"); + } + if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error('input context must be a JSON object with "data" and/or "config"'); + } + return { + data: "data" in parsed ? parsed.data : null, + config: "config" in parsed ? parsed.config : null, + }; + } + + function onRun() { + setParseError(null); + let ctx; + try { + ctx = parseInputContext(); + } catch (err) { + setParseError(err instanceof Error ? err.message : String(err)); + return; + } + + dryRun.mutate({ + name, + content, + data: ctx.data, + config: ctx.config, + }); + } + + function onSave() { + save.mutate({ name, content }); + } + + return ( +
+
+ + + +

{name}

+ dry run + {runStatus ? : null} + {lastRun?.durationMs != null ? ( + {lastRun.durationMs}ms + ) : null} +
+ + +
+ + {parseError ? ( +

{parseError}

+ ) : null} + {dryRun.isError ? ( +

{errorMessage(dryRun.error)}

+ ) : null} + {save.isError ? ( +

{errorMessage(save.error)}

+ ) : null} + {save.isSuccess ? ( +

Script saved

+ ) : null} + +
+
+

Input context

+
+ +
+
+ +
+

Script

+
+ {!contentReady ? ( +
+ +
+ ) : ( + + )} +
+
+ +
+

Output

+
+ {}} + readOnly + height="100%" + /> +
+
+
+ + +
+ ); +} diff --git a/packages/web/src/pages/ScriptEditPage.jsx b/packages/web/src/pages/ScriptEditPage.jsx new file mode 100644 index 0000000..e5ca75b --- /dev/null +++ b/packages/web/src/pages/ScriptEditPage.jsx @@ -0,0 +1,168 @@ +import { useEffect, useState } from "react"; +import { Link, useLocation, useNavigate, useParams } from "react-router-dom"; +import { LuArrowLeft, LuPlay, LuSave } from "react-icons/lu"; +import { errorMessage } from "../api/client.js"; +import { useSaveScript, useScript } from "../api/hooks.js"; +import { CodeEditor } from "../components/CodeEditor.jsx"; +import { NEW_SCRIPT_TEMPLATE, normalizeScriptName } from "../lib/script.js"; + +export function ScriptNewPage() { + const navigate = useNavigate(); + const location = useLocation(); + const [name, setName] = useState(""); + const [content, setContent] = useState( + location.state?.content ?? NEW_SCRIPT_TEMPLATE, + ); + const save = useSaveScript(); + + function onSave(e) { + e.preventDefault(); + const file = normalizeScriptName(name); + if (!file) return; + save.mutate( + { name: file, content }, + { + onSuccess: () => navigate(`/scripts/${encodeURIComponent(file)}/edit`), + }, + ); + } + + function openDryRun() { + const file = normalizeScriptName(name); + if (!file) return; + navigate(`/scripts/${encodeURIComponent(file)}/dry-run`, { + state: { content }, + }); + } + + return ( +
+
+ + + +

New script

+
+ + +
+ +
+ setName(e.target.value)} + required + /> +
+ +
+ {save.isError ? ( +

{errorMessage(save.error)}

+ ) : null} +
+
+ ); +} + +export function ScriptEditPage() { + const { name: rawName } = useParams(); + const name = decodeURIComponent(rawName ?? ""); + const navigate = useNavigate(); + const existing = useScript(name); + const save = useSaveScript(); + const [content, setContent] = useState(""); + const [contentReady, setContentReady] = useState(false); + + useEffect(() => { + if (existing.isLoading) return; + if (existing.data?.content != null) { + setContent(existing.data.content); + setContentReady(true); + } + }, [existing.data, existing.isLoading]); + + function onSave(e) { + e.preventDefault(); + save.mutate({ name, content }); + } + + function openDryRun() { + navigate(`/scripts/${encodeURIComponent(name)}/dry-run`, { + state: { content }, + }); + } + + if (existing.isLoading) { + return ( +
+ +
+ ); + } + + if (existing.isError) { + return ( +
+ + + Back + +

Script not found

+
+ ); + } + + return ( +
+
+ + + +

{name}

+
+ + +
+ +
+ + + + {save.isError ? ( +

{errorMessage(save.error)}

+ ) : null} + {save.isSuccess ? ( +

Script saved

+ ) : null} +
+ ); +} diff --git a/packages/web/src/pages/ScriptsPage.jsx b/packages/web/src/pages/ScriptsPage.jsx index 8ca9307..909a845 100644 --- a/packages/web/src/pages/ScriptsPage.jsx +++ b/packages/web/src/pages/ScriptsPage.jsx @@ -1,89 +1,29 @@ -import { useEffect, useState } from "react"; -import { useSearchParams } from "react-router-dom"; -import { LuPencil, LuPlus, LuSave, LuTrash2, LuX } from "react-icons/lu"; +import { useState } from "react"; +import { Link, Navigate, useNavigate, useSearchParams } from "react-router-dom"; +import { LuPencil, LuPlay, LuPlus, LuTrash2 } from "react-icons/lu"; import { errorMessage } from "../api/client.js"; -import { - useDeleteScript, - useSaveScript, - useScript, - useScripts, -} from "../api/hooks.js"; -import { CodeEditor } from "../components/CodeEditor.jsx"; - -const NEW_TEMPLATE = `export default async function main(ctx) { - return ctx; -} -`; +import { useDeleteScript, useScripts } from "../api/hooks.js"; export function ScriptsPage() { - const [params, setParams] = useSearchParams(); + const [params] = useSearchParams(); + const navigate = useNavigate(); const editName = params.get("edit"); const { data: scripts = [], isLoading } = useScripts(); - const [mode, setMode] = useState(null); - const [name, setName] = useState(""); - const [content, setContent] = useState(""); const [confirmDelete, setConfirmDelete] = useState(null); - - const existing = useScript(mode === "edit" ? name : null, mode === "edit"); - const save = useSaveScript(); const del = useDeleteScript(); - useEffect(() => { - if (editName) { - setMode("edit"); - setName(editName); - } - }, [editName]); - - useEffect(() => { - if (mode === "edit" && existing.data?.content != null) { - setContent(existing.data.content); - } - }, [mode, existing.data]); - - function openAdd() { - setMode("add"); - setName(""); - setContent(NEW_TEMPLATE); - setParams({}); - } - - function openEdit(script) { - setMode("edit"); - setName(script); - setParams({ edit: script }); - } - - function closeForm() { - setMode(null); - setName(""); - setContent(""); - setParams({}); - } - - function onSave(e) { - e.preventDefault(); - const file = name.endsWith(".js") ? name : `${name}.js`; - save.mutate( - { name: file, content }, - { - onSuccess: () => { - setMode("edit"); - setName(file); - setParams({ edit: file }); - }, - }, - ); + if (editName) { + return ; } return (

Scripts

- +
{isLoading ? ( @@ -94,22 +34,35 @@ export function ScriptsPage() { Name - + {scripts.map((s) => ( - {s} + + + {s} + + + openEdit(s)} > - + -
- {mode === "add" ? ( - setName(e.target.value)} - required - /> - ) : null} - {mode === "edit" && existing.isLoading ? ( - - ) : ( - - )} - {save.isError ? ( -

{errorMessage(save.error)}

- ) : null} - - - ) : null} - {confirmDelete ? (
@@ -175,10 +96,7 @@ export function ScriptsPage() { disabled={del.isPending} onClick={() => del.mutate(confirmDelete, { - onSuccess: () => { - if (name === confirmDelete) closeForm(); - setConfirmDelete(null); - }, + onSuccess: () => setConfirmDelete(null), }) } >