Add a Scripts tab for listing, running and writing diagnostic scripts - #90
Conversation
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. |
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;
…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.
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.
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.
AppsPanel and EntityDetailPanel carry the entity lifecycle control, so their tests need the store keys it reads and a TooltipProvider around the render, the way App.tsx provides one. Without them the panels threw on mount and the Scripts tab assertions could not run.
4711b3d to
412e662
Compare
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)