From cae308359a004fcbc78688c15ff3ae812e4e9650 Mon Sep 17 00:00:00 2001 From: Nasyarobby Putra Date: Sat, 15 Aug 2026 21:21:24 +0700 Subject: [PATCH] feat(scripts): add sidecar icons and tags in the admin UI Serve optional PNG/JPG next to each script and show meta tags in lists, cards, and the editor. Co-authored-by: Cursor --- packages/server/fs-store.js | 31 +++++ packages/server/scripts/fetch-html.js | 1 + packages/server/scripts/fetch-http.js | 1 + packages/server/scripts/fetch-rss-feed.js | 1 + packages/server/scripts/trigger-workflow.js | 1 + packages/server/src/api/scripts.js | 21 ++- packages/web/src/components/ScriptIcon.jsx | 30 ++++ .../web/src/components/ScriptMetaPanel.jsx | 12 ++ packages/web/src/components/TagBadge.jsx | 17 +++ .../components/workflow/AddScriptDialog.jsx | 16 ++- .../src/components/workflow/ScriptCard.jsx | 6 +- packages/web/src/lib/script.js | 33 +++++ packages/web/src/pages/ScriptDryRunPage.jsx | 2 + packages/web/src/pages/ScriptEditPage.jsx | 2 + packages/web/src/pages/ScriptsPage.jsx | 129 ++++++++++++------ 15 files changed, 256 insertions(+), 47 deletions(-) create mode 100644 packages/web/src/components/ScriptIcon.jsx create mode 100644 packages/web/src/components/TagBadge.jsx diff --git a/packages/server/fs-store.js b/packages/server/fs-store.js index 23061d1..2f2c842 100644 --- a/packages/server/fs-store.js +++ b/packages/server/fs-store.js @@ -55,11 +55,42 @@ export function writeScript(name, content) { fs.writeFileSync(path.join(SCRIPTS_DIR, name), content, "utf8"); } +/** + * Icon next to the script: `fetch-html.js` → `fetch-html.png` or `.jpg`. + * @returns {{ filePath: string, contentType: string } | null} + */ +export function resolveScriptIcon(name) { + assertScriptName(name); + const base = name.slice(0, -3); + for (const { ext, contentType } of [ + { ext: "png", contentType: "image/png" }, + { ext: "jpg", contentType: "image/jpeg" }, + ]) { + const filePath = path.join(SCRIPTS_DIR, `${base}.${ext}`); + if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) { + return { filePath, contentType }; + } + } + return null; +} + +export function scriptHasIcon(name) { + return resolveScriptIcon(name) != null; +} + export function deleteScript(name) { assertScriptName(name); const filePath = path.join(SCRIPTS_DIR, name); if (!fs.existsSync(filePath)) return false; + const icon = resolveScriptIcon(name); fs.unlinkSync(filePath); + if (icon) { + try { + fs.unlinkSync(icon.filePath); + } catch { + // ignore missing icon + } + } return true; } diff --git a/packages/server/scripts/fetch-html.js b/packages/server/scripts/fetch-html.js index 6947e8f..3b7be2e 100644 --- a/packages/server/scripts/fetch-html.js +++ b/packages/server/scripts/fetch-html.js @@ -87,6 +87,7 @@ async function fetchHtml(ctx) { fetchHtml.meta = { description: "Fetch HTML and optionally select elements or transform with JSONata", previewConfigKey: "url", + tags: ["HTTP"], config: { url: { type: "string", required: true, description: "Page URL" }, selector: { type: "string", required: false, description: "CSS selector" }, diff --git a/packages/server/scripts/fetch-http.js b/packages/server/scripts/fetch-http.js index 63d5af9..1246036 100644 --- a/packages/server/scripts/fetch-http.js +++ b/packages/server/scripts/fetch-http.js @@ -116,6 +116,7 @@ async function fetchHttp(ctx) { fetchHttp.meta = { description: "Fetch a URL and store the response body.", previewConfigKey: "url", + tags: ["HTTP"], config: { url: { type: "string", required: true, description: "Request URL" }, method: { diff --git a/packages/server/scripts/fetch-rss-feed.js b/packages/server/scripts/fetch-rss-feed.js index ecce454..afcb6a5 100644 --- a/packages/server/scripts/fetch-rss-feed.js +++ b/packages/server/scripts/fetch-rss-feed.js @@ -69,6 +69,7 @@ async function fetchRssFeed(ctx) { fetchRssFeed.meta = { description: "Fetch an RSS/Atom feed and optionally transform it with JSONata", previewConfigKey: "url", + tags: ["RSS"], config: { url: { type: "string", required: false, description: "Feed URL (or pass data.url)" }, outputVar: { diff --git a/packages/server/scripts/trigger-workflow.js b/packages/server/scripts/trigger-workflow.js index ff8bdab..2f478e2 100644 --- a/packages/server/scripts/trigger-workflow.js +++ b/packages/server/scripts/trigger-workflow.js @@ -29,6 +29,7 @@ triggerWorkflow.meta = { description: "Fire-and-forget another workflow by YAML name (same owner). Destination must declare triggers: [{ type: workflow }]. Optionally reshape ctx.data with JSONata before sending.", previewConfigKey: "name", + tags: ["trigger"], config: { name: { type: "string", diff --git a/packages/server/src/api/scripts.js b/packages/server/src/api/scripts.js index 939ade7..43772b4 100644 --- a/packages/server/src/api/scripts.js +++ b/packages/server/src/api/scripts.js @@ -1,3 +1,4 @@ +import fs from "fs"; import { clearScriptCache, inspectScriptSource, @@ -21,11 +22,27 @@ export default function scriptsPluginFactory(registry) { content == null ? { meta: null, metaError: "script not found" } : inspectScriptSource(name, content); - return { name, ...inspected }; + return { name, hasIcon: fsStore.scriptHasIcon(name), ...inspected }; }); return { scripts }; }); + fastify.get("/scripts/:name/icon", 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 icon = fsStore.resolveScriptIcon(name); + if (icon == null) return reply.code(404).send({ error: "icon not found" }); + const body = fs.readFileSync(icon.filePath); + return reply + .type(icon.contentType) + .header("Cache-Control", "private, max-age=60") + .send(body); + }); + fastify.get("/scripts/:name", async (req, reply) => { const { name } = /** @type {{ name: string }} */ (req.params); try { @@ -35,7 +52,7 @@ export default function scriptsPluginFactory(registry) { } const content = fsStore.readScript(name); if (content == null) return reply.code(404).send({ error: "script not found" }); - return { name, content, ...inspectScriptSource(name, content) }; + return { name, content, hasIcon: fsStore.scriptHasIcon(name), ...inspectScriptSource(name, content) }; }); fastify.put("/scripts/:name", async (req, reply) => { diff --git a/packages/web/src/components/ScriptIcon.jsx b/packages/web/src/components/ScriptIcon.jsx new file mode 100644 index 0000000..e543f39 --- /dev/null +++ b/packages/web/src/components/ScriptIcon.jsx @@ -0,0 +1,30 @@ +import { useEffect, useState } from "react"; +import { TbFileTypeJs } from "react-icons/tb"; + +export function scriptIconSrc(name) { + return `/api/scripts/${encodeURIComponent(name)}/icon`; +} + +/** Same-name PNG/JPG next to the script, or a JS document icon. */ +export function ScriptIcon({ name, hasIcon, className = "size-10" }) { + const [failed, setFailed] = useState(false); + + useEffect(() => { + setFailed(false); + }, [name]); + + const showFile = Boolean(name) && hasIcon !== false && !failed; + + if (!showFile) { + return ; + } + + return ( + setFailed(true)} + /> + ); +} diff --git a/packages/web/src/components/ScriptMetaPanel.jsx b/packages/web/src/components/ScriptMetaPanel.jsx index 670b48d..8bdea86 100644 --- a/packages/web/src/components/ScriptMetaPanel.jsx +++ b/packages/web/src/components/ScriptMetaPanel.jsx @@ -1,3 +1,6 @@ +import { scriptTags } from "../lib/script.js"; +import { TagBadge } from "./TagBadge.jsx"; + function FieldTable({ title, fields }) { const entries = Object.entries(fields ?? {}); if (entries.length === 0) return null; @@ -56,9 +59,18 @@ export function ScriptMetaPanel({ meta, metaError, className = "" }) { ); } + const tags = scriptTags(meta); + return (
{meta.description ?

{meta.description}

: null} + {tags.length ? ( +
+ {tags.map((tag) => ( + + ))} +
+ ) : null}
diff --git a/packages/web/src/components/TagBadge.jsx b/packages/web/src/components/TagBadge.jsx new file mode 100644 index 0000000..b1b0770 --- /dev/null +++ b/packages/web/src/components/TagBadge.jsx @@ -0,0 +1,17 @@ +import { tagColor } from "../lib/script.js"; + +export function TagBadge({ tag, className = "", onClick, title }) { + const { bg, text } = tagColor(tag); + const Comp = onClick ? "button" : "span"; + return ( + + {tag} + + ); +} diff --git a/packages/web/src/components/workflow/AddScriptDialog.jsx b/packages/web/src/components/workflow/AddScriptDialog.jsx index a8c2b3c..8ed261c 100644 --- a/packages/web/src/components/workflow/AddScriptDialog.jsx +++ b/packages/web/src/components/workflow/AddScriptDialog.jsx @@ -1,5 +1,7 @@ import { useMemo, useState } from "react"; import { useScripts } from "../../api/hooks.js"; +import { ScriptIcon } from "../ScriptIcon.jsx"; +import { scriptTags } from "../../lib/script.js"; export function AddScriptDialog({ open, onClose, onPick }) { const { data: scripts = [], isLoading } = useScripts(); @@ -12,12 +14,15 @@ export function AddScriptDialog({ open, onClose, onPick }) { description: typeof s === "string" ? "" : s.meta?.description ?? "", meta: typeof s === "string" ? null : s.meta, metaError: typeof s === "string" ? null : s.metaError, + hasIcon: typeof s === "string" ? undefined : s.hasIcon, + tags: typeof s === "string" ? [] : scriptTags(s.meta), })); if (!term) return list; return list.filter( (s) => s.name.toLowerCase().includes(term) || - s.description.toLowerCase().includes(term), + s.description.toLowerCase().includes(term) || + s.tags.some((t) => t.toLowerCase().includes(term)), ); }, [scripts, q]); @@ -61,9 +66,12 @@ export function AddScriptDialog({ open, onClose, onPick }) { setQ(""); }} > - {s.name} - - {s.metaError ? s.metaError : s.description || "No description"} + + + {s.name} + + {s.metaError ? s.metaError : s.description || "No description"} + diff --git a/packages/web/src/components/workflow/ScriptCard.jsx b/packages/web/src/components/workflow/ScriptCard.jsx index c4f1630..9aae03b 100644 --- a/packages/web/src/components/workflow/ScriptCard.jsx +++ b/packages/web/src/components/workflow/ScriptCard.jsx @@ -10,6 +10,7 @@ import { StatusBadge } from "../../lib/format.jsx"; import { contextFromMeta, prettyJson } from "../../lib/script.js"; import { ConfigFields } from "./ConfigFields.jsx"; import { ConfigTooltip, configValueText, FieldLabel, previewConfigValue, SchemaTooltip } from "./FieldHelp.jsx"; +import { ScriptIcon } from "../ScriptIcon.jsx"; export function ScriptCard({ step, @@ -66,6 +67,9 @@ export function ScriptCard({ > + {step.kind === "script" && step.script ? ( + + ) : null}