Add a Scripts tab for listing, running and writing diagnostic scripts - #90
Add a Scripts tab for listing, running and writing diagnostic scripts#90bburda wants to merge 8 commits into
Conversation
…e wiring Types re-exported from the generated client, narrowing helpers for the free-form execution result and error fields, pure reducers for the client-side execution history, per-entity-type dispatch helpers for the eight scripts endpoints, a unit-tested polling cycle, and the store state and actions that tie them together. The gateway has no endpoint listing executions, so the UI remembers the ids it started and one interval in the store polls them. History trimming never drops an execution that is still active, because losing its id would orphan the process for good.
…iting Adds the panel, the expandable script row with its parameter form, the execution status card and the upload dialog, which can either take a file or let the user write a script in a lazily loaded CodeMirror editor. The tab appears only when the gateway reports capabilities.scripts, and only on apps and components, which are the entity types the gateway registers script routes for. Playwright ships as a development dependency here as well, ahead of the end-to-end harness that uses it.
…ct Mode The stored server URL was passed to connect() from inside a setTimeout, and Strict Mode's mount-cleanup-remount cycle cleared that timer before it fired. The ref guard then blocked the second attempt, so a persisted URL never reconnected in development. Calling connect() directly keeps the guard's single-attempt behaviour without the cancellable deferral.
There was a problem hiding this comment.
Pull request overview
Adds end-to-end “diagnostic scripts” support to the SOVD entity UI by wiring new scripts endpoints into the domain layer/store, adding polling for script executions, and introducing a new Scripts tab (gated by capabilities.scripts) on apps/components with UI for listing, uploading/writing, running, and controlling executions. Also includes a small App.tsx reconnect fix and test-environment hardening around localStorage.
Changes:
- Introduces scripts domain/types, API dispatch helpers, and Zustand store actions/state for script listing, upload, execution start/control, and execution polling/history.
- Adds new Scripts UI (tab + panels/cards/dialog/editor) with capability-based gating and comprehensive unit tests.
- Adds Playwright test scripts/deps (ahead of an e2e follow-up) and fixes dev auto-reconnect in React Strict Mode.
Reviewed changes
Copilot reviewed 31 out of 32 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| src/test/setup.ts | Stabilizes test localStorage/sessionStorage when Node’s experimental globals are broken. |
| src/lib/types.ts | Adds script-related type re-exports and UI/store-facing script types. |
| src/lib/store.ts | Adds scripts capability flag, execution history, store actions, and polling interval management. |
| src/lib/store-scripts.test.ts | Tests refreshScriptExecution store wiring around the lost flag behavior. |
| src/lib/scripts.ts | Adds scripts domain helpers (error typing, status helpers, output/failure parsing, history reducers). |
| src/lib/scripts.test.ts | Unit tests for scripts domain helpers and reducers. |
| src/lib/scripts-polling.ts | Implements a testable polling “single tick” for active script executions. |
| src/lib/scripts-polling.test.ts | Unit tests for polling behavior and error-code handling. |
| src/lib/script-language.ts | Adds filename→language mapping and editor templates for write mode. |
| src/lib/script-language.test.ts | Unit tests for language detection, extension validation, and templates. |
| src/lib/schema-utils.test.ts | Adds coverage for JSON Schema → TopicSchema conversion behaviors used by script parameter forms. |
| src/lib/api-dispatch.ts | Adds scripts endpoint wrappers (list/get/upload/delete/start/control/delete execution). |
| src/lib/api-dispatch.test.ts | Tests scripts endpoint dispatch path/params/body wiring. |
| src/components/ScriptUploadDialog.tsx | Adds dialog to upload or write a script in-browser, with validation and inline errors. |
| src/components/ScriptUploadDialog.test.tsx | Tests dialog behavior for both upload and write modes (validation, submit, errors). |
| src/components/ScriptsPanel.tsx | Adds scripts list panel, error/empty states, and upload/reload orchestration. |
| src/components/ScriptsPanel.test.tsx | Tests scripts list loading, aborting stale requests, reload triggers, and execution filtering per row. |
| src/components/ScriptRow.tsx | Adds per-script expandable row with params form/JSON fallback, run/delete controls, and execution cards. |
| src/components/ScriptRow.test.tsx | Tests ScriptRow form/JSON behavior, run/delete wiring, and rerender stability during polling. |
| src/components/ScriptExecutionCard.tsx | Adds execution status/progress/output rendering plus stop/force/remove/refresh controls. |
| src/components/ScriptExecutionCard.test.tsx | Tests execution card rendering and control actions (including “lost” handling). |
| src/components/ScriptEditor.tsx | Adds CodeMirror-based editor with language switching and dark-mode awareness. |
| src/components/ResourceTabs.tsx | Adds scripts as a tab id and a SCRIPTS_TAB config for app/component panels to append conditionally. |
| src/components/ResourceTabs.test.tsx | Tests scripts tab ID handling and scripts tab content rendering rules. |
| src/components/EntityResourceTabs.tsx | Extends loaded-tab tracking to include scripts. |
| src/components/EntityDetailPanel.tsx | Conditionally appends Scripts tab based on scriptsSupported, and resets active tab if capability disappears. |
| src/components/EntityDetailPanel.test.tsx | Tests Scripts tab gating and content routing in component view. |
| src/components/AppsPanel.tsx | Conditionally appends Scripts tab based on scriptsSupported, with fallback if capability disappears. |
| src/components/AppsPanel.test.tsx | Tests Scripts tab gating and rendering for apps. |
| src/App.tsx | Fixes auto-connect behavior under React Strict Mode by removing setTimeout deferral. |
| package.json | Adds CodeMirror and Playwright dependencies and e2e test scripts. |
| package-lock.json | Locks newly added dependencies. |
| /** | ||
| * The gateway picks the interpreter from the uploaded file's extension: | ||
| * `.py` runs under python3, `.bash` under bash, and everything else - including | ||
| * no extension at all - under sh. `languageForFilename` mirrors that split so | ||
| * the editor's syntax highlighting matches what will actually execute. | ||
| */ |
The comment claimed languageForFilename mirrors the gateway's three-way interpreter split one-to-one, but it deliberately does not: an unrecognised extension returns plain rather than shell, since guessing shell highlighting for arbitrary content would be wrong more often than it would help, even though the gateway still falls back to sh for those files at execution time.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 31 out of 32 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (2)
src/lib/scripts.ts:136
- This doc comment says "Trim to MAX_EXECUTION_HISTORY", but the implementation can return more than MAX_EXECUTION_HISTORY entries when many executions are still active (since active ones are never dropped). It would help future maintainers if the comment matched that behavior explicitly.
/**
* Trim to MAX_EXECUTION_HISTORY, dropping the oldest *inactive* records first.
* Dropping a running execution would orphan the process: the gateway has no
* endpoint to list executions, so its id could never be recovered.
*/
src/lib/scripts.ts:128
- The comment says this is an "Upper bound on tracked executions", but trimHistory intentionally never drops active executions. If > MAX_EXECUTION_HISTORY executions are active concurrently, history can exceed this value, so the comment is misleading (and could hide a potential memory-growth scenario).
This issue also appears on line 132 of the same file.
/** Upper bound on tracked executions per entity; a record can hold a full stdout dump. */
export const MAX_EXECUTION_HISTORY = 20;
| * A leading dot alone (`.bashrc`) does not count: the gateway needs a real | ||
| * suffix to pick an interpreter, not a hidden-file marker. | ||
| */ | ||
| export function hasExtension(filename: string): boolean { |
There was a problem hiding this comment.
major - the write-mode file name is never validated as a basename, and this is the only validator in the repo.
handleWriteSubmit (ScriptUploadDialog.tsx:167) gates on hasExtension and then does new File([writeContent], trimmedFileName) / form.append('file', file, file.name). Nothing strips path separators. Verified on node 22 with the same undici FormData the browser uses:
new File(['x'], '../../etc/cron.d/evil.sh').name === '../../etc/cron.d/evil.sh'
Content-Disposition: form-data; name="file"; filename="../../etc/cron.d/evil.sh"
So .. and / reach the gateway verbatim, on the one endpoint whose job is writing an executable file onto a robot. Whether the gateway joins that onto its scripts dir is not verifiable from this repo, but the client passes it straight through.
Second hole in the same function: extensionOf splits on the last dot with no awareness of separators, so my.dir/check reports extension /check and hasExtension returns true. The check that is supposed to guarantee the gateway can pick an interpreter passes for a name that has no extension at all.
Reject anything that is not a plain basename (no /, no \, no ./.. segments), and take the extension from the last path segment only.
There was a problem hiding this comment.
Fixed in f347df7. Both holes were real. extensionOf now takes the last path segment before looking for the dot, so my.dir/check reports no extension instead of dir/check, and a new isPlainBasename rejects empty names, ., .. and anything containing a forward or back slash. handleWriteSubmit runs the basename check before the extension check, so a path is rejected on its own terms rather than incidentally. Covered by tests for my.dir/check, ../../etc/cron.d/evil.sh, backslashes and the bare dot names, each verified to fail against the previous code.
| > | ||
| <DialogContent | ||
| className="max-h-[80vh] overflow-y-auto" | ||
| onEscapeKeyDown={(e) => submitting && e.preventDefault()} |
There was a problem hiding this comment.
minor - written script content is discarded on a stray click or Escape.
onOpenChange, onEscapeKeyDown, onPointerDownOutside and onInteractOutside all block dismissal only while submitting. Once a user has typed 80 lines into the editor and is not mid-upload, one click on the overlay or one Escape closes the dialog, the !open effect at line 78 clears writeContent, and the script is gone with no undo and no warning.
contentTouchedRef already holds exactly the bit needed: when mode === 'write' && contentTouchedRef.current, require a confirmation before closing (or preserve the content and restore it on reopen).
There was a problem hiding this comment.
Fixed in 651bbbd. All four dismissal paths now go through one guard: when the mode is write and contentTouchedRef is set, dismissal asks for confirmation before the reset effect runs. Declining leaves the dialog open with the content intact.
| const capsController = new AbortController(); | ||
| const capsTimeout = setTimeout(() => capsController.abort(), 5000); | ||
| const { data: root } = await client | ||
| .GET('/', { signal: capsController.signal }) |
There was a problem hiding this comment.
minor - the capability probe stalls the entity tree for up to 5s, which is what the comment above says it avoids.
The timeout bounds the wait, it does not remove it. This await sits between set({isConnected: true, ...}) and await get().loadRootEntities(). A gateway that answers /health and then hangs on / leaves the UI showing "connected" with an empty tree for a full 5 seconds, on every connect and reconnect.
Run it concurrently with loadRootEntities (Promise.all), or fire it without awaiting - the get().client === client guard already makes a late result safe.
There was a problem hiding this comment.
Fixed in 4711b3d. The probe no longer sits between marking the session connected and loading the tree - it runs concurrently, and the existing client-identity guard keeps a late result from writing into a newer session. Connect no longer waits on it at all.
|
|
||
| const key = scriptEntityKey(entityType, entityId); | ||
| const record: ScriptExecutionRecord = { | ||
| execution: data as ScriptExecution, |
There was a problem hiding this comment.
minor - data as ScriptExecution is a cast, not a check, and an empty 2xx body poisons the store.
openapi-fetch yields data === undefined for an empty body on a 2xx. This stores {execution: undefined, ...}. The very next statement calls startScriptPolling, whose collectActiveExecutions reads record.execution.status and throws. The bad record is already in the store, so ScriptExecutionCard destructures record.execution on render (line 61) and trips the ErrorBoundary, replacing the panel - while ScriptRow's catch shows "Failed to start the script" for a script that is in fact running.
A 202 with an empty body is a normal shape, and the comments in this PR say plugin backends are free-form. Guard it: if (!data || typeof data.id !== 'string') throw new ScriptsApiError(...). Same cast, same exposure, at lines 2094 and 2161.
There was a problem hiding this comment.
Fixed in d981893. All three sites now validate the payload before it reaches the store and raise a ScriptsApiError when it is unusable, so an empty 2xx body surfaces as a normal failure instead of a record whose execution is undefined. You were right that the polling loop is what turns it into a crash rather than a bad row.
| // Clear whatever the previous entity (or previous attempt) rendered so | ||
| // it never lingers under the new heading while the fresh request is | ||
| // in flight. | ||
| setScripts([]); |
There was a problem hiding this comment.
minor - the reload path unmounts every row and drops whatever the user has typed.
setScripts([]) plus setIsLoading(true) run synchronously before the fetch, so the row list is swapped for the spinner and every ScriptRow unmounts, losing its local state.
Concretely: expand a script, fill in the parameter form (or the raw JSON textarea), then upload a second script (onUploaded -> reload) or press the panel's Refresh button. The row comes back collapsed and empty.
Keep the current list rendered while the refetch is in flight; clear only when entityId/entityType actually change.
There was a problem hiding this comment.
Fixed in 4711b3d. The list now stays mounted while a same-entity refetch is in flight; it is cleared only when the entity actually changes. The existing guarantee that a late response from a previous entity is never rendered still holds and still has its test.
| const trimmed = jsonText.trim(); | ||
| if (trimmed) { | ||
| try { | ||
| parameters = JSON.parse(trimmed) as Record<string, unknown>; |
There was a problem hiding this comment.
minor - any JSON value passes as parameters, not just an object.
JSON.parse(trimmed) as Record<string, unknown> is a cast. Typing [1,2], "hello", 42 or null parses fine, jsonError stays null, and the value is sent as parameters - which StartScriptExecutionRequest declares as Record<string, unknown>. The user gets a gateway 400 instead of the inline error this field already knows how to render.
After parsing, require a plain object (typeof === 'object' && !== null && !Array.isArray) and otherwise set jsonError and return.
There was a problem hiding this comment.
Fixed in 651bbbd. After parsing, the value must be a plain object - not an array, not null, not a scalar - otherwise the existing inline error path fires and nothing is sent.
| if (scriptErrorCode(error) === SCRIPT_ERROR_CODE.resourceNotFound) { | ||
| set({ scriptExecutions: markExecutionLost(get().scriptExecutions, key, executionId) }); | ||
| } | ||
| return; |
There was a problem hiding this comment.
minor - Refresh can never report a failure.
Every error branch here returns normally and the surrounding catch swallows the throw path, so refreshScriptExecution never rejects. That makes ScriptExecutionCard.handleRefresh's toast.error('Failed to refresh the execution') unreachable.
A user clicking Refresh against a gateway returning 500 (or entity-not-found) sees the button flick busy and nothing else: no update, no error, no signal the request happened at all. Rethrow for anything other than resource-not-found so the card can surface it.
There was a problem hiding this comment.
Fixed in d981893. Anything other than the gateway saying the execution is gone is now rethrown, so the card can surface it. The toast you flagged as unreachable is now reachable and has a test.
| <Button variant="default" size="sm" disabled={isStarting} onClick={() => void handleRun()}> | ||
| Run | ||
| </Button> | ||
| {script.managed !== true && ( |
There was a problem hiding this comment.
minor - deleting a script from the robot is irreversible, one click away, and unconfirmed.
There is no confirmation and no undo, and the button sits in the same row as Run. The repo's other irreversible delete gates on it - UpdatesDashboard.tsx:85: window.confirm('Delete update "..."? This cannot be undone.').
Same gate before deleteScript would match the existing pattern.
There was a problem hiding this comment.
Fixed in 651bbbd, matching the UpdatesDashboard pattern you pointed at, wording included. Worth noting for the stacked pull request: this broke the end-to-end delete scenarios, since Playwright dismisses native dialogs by default. Handled there in c9ce8a7, which accepts the confirmation and asserts it actually appeared, so removing the guard later fails a test rather than passing silently.
|
|
||
| {output?.kind === 'stdout' && ( | ||
| <pre className="text-xs bg-muted rounded-md p-2 overflow-x-auto overflow-y-auto max-h-[200px] whitespace-pre-wrap"> | ||
| {output.text} |
There was a problem hiding this comment.
minor - script stdout is rendered with no cap.
scriptOutput returns the whole parameters.stdout string and it lands in one <pre> text node. max-h-[200px] limits the box, not the DOM. MAX_EXECUTION_HISTORY is 20, so up to 20 complete stdout dumps per entity are held in the store and rendered simultaneously.
A script that prints a few MB (a log dump, a find /, a stuck loop) is enough to lock the tab, and the content is entirely gateway-controlled. Truncate in scriptOutput - keep the first/last N KB behind a "truncated" marker - rather than trusting the response size.
There was a problem hiding this comment.
Fixed in 4711b3d. scriptOutput now truncates, keeping a head and a tail behind an explicit marker, so the cap applies to what enters the DOM rather than only to the height of the box.
Write mode built the upload's multipart filename directly from the typed file name, so a value like ../../etc/cron.d/evil.sh passed through unchanged into the request. hasExtension also read the extension from the whole string rather than the final path segment, so a name such as my.dir/check reported an extension it did not actually have. File names are now required to be a plain basename (no separators, no "." or ".."), checked independently of and before the extension check, and the extension itself is now read from the last path segment only. The dialog also now asks for confirmation before discarding write-mode content the user actually typed, on every way it can be dismissed (Escape, an outside click, the close button, and Cancel), instead of silently dropping it.
…rrors startScriptExecutionAction, the control action and refreshScriptExecution all cast an openapi-fetch response straight to ScriptExecution. An empty 2xx body (legitimate for a 202) or any other malformed payload could end up stored as a record with an undefined execution, which then crashed the next poll (collectActiveExecutions reading its status) or the card's render. All three now validate the payload through toScriptExecution and throw before touching the store when it is not usable. refreshScriptExecution also used to swallow every error behind a try/catch that only logged to the console, so its "Failed to refresh" toast could never fire. Only a resource-not-found response is treated as "the execution is gone" now; everything else, including a bare disconnect, is rethrown so the card can surface it. The capability probe that gates the Scripts tab used to be awaited before loading the entity tree, so a gateway that answered /health and then hung on GET / left the UI showing "connected" over an empty tree for the full 5s timeout. It now runs without blocking connect(), still under its own timeout and the existing client-identity guard. Also caps rendered stdout to a head and a tail behind an explicit truncation marker instead of trusting the gateway's response size, and corrects the trimHistory comment to say what it actually guarantees: a cap on inactive records, not on the list as a whole.
…lete The raw JSON parameter textarea accepted any value JSON.parse could produce, cast straight to Record<string, unknown>. An array, string, number or null all parsed successfully and were sent as parameters, earning a gateway 400 instead of the inline error this field already shows for invalid JSON. Parsed values are now required to be a plain object. Deleting a script from the robot is irreversible and sits one click away from Run in the same row. It now asks for confirmation first, matching the pattern already used for the other irreversible delete in this codebase (UpdatesDashboard).
The list was cleared and the loading spinner shown synchronously on every reload, including a manual Refresh or the reload triggered after an upload or a delete, not just an actual entity switch. That unmounted every row on screen for the duration of the refetch, dropping whatever the user was doing inside one, such as an expanded parameter form. An entity-key ref now tells an actual entity switch apart from a same-entity reload. Only the former clears the list and shows the spinner; the latter keeps the current rows rendered until the refetch resolves.
Pull Request
Summary
Adds a Scripts tab to the entity view: list the diagnostic scripts available on an entity, run one with parameters, follow its status, stop or force-kill it, upload a script file or write one in the browser, and delete scripts that are no longer needed.
The tab appears only when the gateway reports
capabilities.scriptsinGET /, and only on apps and components, which are the entity types the gateway registers script routes for.Three commits, each of which builds on its own: the domain layer with the gateway helpers and the store wiring, the user interface, and one unrelated fix explained below.
Notable behaviour, because the gateway shapes it:
argsget command-line arguments. The starter template in the editor shows this, because it is the least guessable part of writing a first script..pyruns under python3,.bashunder bash, anything else under sh. That is why the file name is a required field in the editor and why the hint sits under it.Limitations worth knowing before review:
src/lib/api-dispatch.tscarries two documentedas unknown ascasts. The gateway's OpenAPI spec declares the start-execution body as a baretype: objectand the multipart upload body as a free-form object, so the generated client types them asRecord<string, never>and an index signature. The runtime payloads are correct; fixing this properly means correcting the spec in the gateway.npm run typecheckchecks nothing in this repository: the root config has"files": []andtsc --noEmitignores project references.npm run buildrunstsc -band is the real gate. This is now stated in CONTRIBUTING (in the follow-up).src/App.tsxcarries a fix unrelated to scripts, kept in its own commit: the stored server URL was passed toconnect()from inside asetTimeout, and Strict Mode's mount-cleanup-remount cycle cleared that timer before it fired, so a persisted URL never reconnected in development. It surfaced while building the end-to-end harness, which depends on that reconnect.Issue
Type
Testing
576 unit tests, 28 files. The domain layer, the polling cycle, the dispatch helpers and all five components are covered, including the cases that are easy to get wrong: a progress bar at zero, a stopped execution rendered as stopped rather than failed, output that is only stdout versus output that is structured, the raw-JSON fallback for a schema that cannot become a form, and a parameter form that survives the once-a-second re-render while a script runs.
End-to-end coverage against a real gateway comes in the follow-up pull request, which builds on this branch. It exercises this feature: running a script and reading what it received on stdin, a failing script's exit code and stderr, stopping a long one, and writing a bash script and a python script in the browser and running them.
Manually: point the app at a gateway with
scripts.scripts_dirconfigured. Without it the gateway reports no capability and the tab stays hidden, which is the intended behaviour.Checklist
npm run lint)npm run build)