@checkstack/ui 1.8.3 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +83 -0
- package/package.json +6 -2
- package/scripts/generate-stdlib-types.ts +90 -0
- package/src/components/CodeEditor/CodeEditor.tsx +7 -0
- package/src/components/CodeEditor/MonacoEditor.tsx +203 -117
- package/src/components/CodeEditor/generateTypeDefinitions.ts +19 -26
- package/src/components/CodeEditor/generated/stdlib-types.json +1 -0
- package/src/components/CodeEditor/index.ts +7 -0
- package/src/components/CodeEditor/monacoStdlib.ts +62 -0
- package/src/components/CodeEditor/monacoWorkers.ts +118 -0
- package/src/components/CodeEditor/scriptContext.test.ts +280 -0
- package/src/components/CodeEditor/scriptContext.ts +467 -0
- package/src/components/CodeEditor/shellEnvVarMatcher.test.ts +95 -0
- package/src/components/CodeEditor/shellEnvVarMatcher.ts +70 -0
- package/src/components/DynamicForm/DynamicForm.tsx +6 -0
- package/src/components/DynamicForm/FormField.tsx +15 -0
- package/src/components/DynamicForm/MultiTypeEditorField.tsx +111 -6
- package/src/components/DynamicForm/index.ts +2 -0
- package/src/components/DynamicForm/starterTemplateSelector.test.ts +96 -0
- package/src/components/DynamicForm/starterTemplateSelector.ts +32 -0
- package/src/components/DynamicForm/types.ts +34 -1
- package/src/components/ListEmptyState.tsx +51 -0
- package/src/components/QueryErrorState.tsx +64 -0
- package/src/components/ResponsiveTable.tsx +92 -0
- package/src/components/Skeleton.tsx +39 -0
- package/src/hooks/useInitOnceForKey.test.ts +127 -0
- package/src/hooks/useInitOnceForKey.ts +87 -0
- package/src/index.ts +6 -0
- package/src/utils/toastTemplates.test.ts +82 -0
- package/src/utils/toastTemplates.ts +47 -0
- package/stories/ListEmptyState.stories.tsx +48 -0
- package/stories/QueryErrorState.stories.tsx +40 -0
- package/stories/ResponsiveTable.stories.tsx +93 -0
- package/stories/Skeleton.stories.tsx +53 -0
- package/stories/toastTemplates.stories.tsx +60 -0
- package/tsconfig.json +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,88 @@
|
|
|
1
1
|
# @checkstack/ui
|
|
2
2
|
|
|
3
|
+
## 1.10.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- f23f3c9: Add five additive shared UI primitives for list / query state surfaces:
|
|
8
|
+
|
|
9
|
+
- `ListEmptyState` - thin wrapper around `EmptyState` with the
|
|
10
|
+
canonical `"No {resource} yet"` headline and an `Inbox` default icon.
|
|
11
|
+
- `QueryErrorState` - inline error UI for failed queries; renders an
|
|
12
|
+
`error`-variant `Alert` with `extractErrorMessage` + a Retry button.
|
|
13
|
+
- `Skeleton` - pulsing placeholder block that drops its animation when
|
|
14
|
+
`usePerformance().isLowPower` is true.
|
|
15
|
+
- `ResponsiveTable` + `MobileCardList` - dual-layout pair for tabular
|
|
16
|
+
data that swaps to a stacked card layout below the `sm` breakpoint
|
|
17
|
+
(pure CSS, no JS media-query gating).
|
|
18
|
+
- `toastSuccess` / `toastError` - canonical verb-phrase and
|
|
19
|
+
`{action}: {message}` (truncated at 100 chars) toast helpers.
|
|
20
|
+
|
|
21
|
+
Each primitive ships with Storybook stories and unit tests. No
|
|
22
|
+
existing component or behaviour is changed - Phases 5-7 of the v1
|
|
23
|
+
polishing plan will retrofit consumer pages onto these primitives in
|
|
24
|
+
follow-up PRs. Phase 7 will use the existing `usePerformance()` hook
|
|
25
|
+
directly for low-power gating rather than introducing a separate
|
|
26
|
+
className-composition helper.
|
|
27
|
+
|
|
28
|
+
### Patch Changes
|
|
29
|
+
|
|
30
|
+
- Updated dependencies [f23f3c9]
|
|
31
|
+
- Updated dependencies [f23f3c9]
|
|
32
|
+
- Updated dependencies [f23f3c9]
|
|
33
|
+
- @checkstack/common@0.11.0
|
|
34
|
+
- @checkstack/frontend-api@0.5.2
|
|
35
|
+
|
|
36
|
+
## 1.9.0
|
|
37
|
+
|
|
38
|
+
### Minor Changes
|
|
39
|
+
|
|
40
|
+
- a06b899: Overhaul shell + inline-script health checks with real shell semantics, real ESM execution, and upstream Node/Bun IntelliSense.
|
|
41
|
+
|
|
42
|
+
**BREAKING CHANGES**
|
|
43
|
+
|
|
44
|
+
- **Shell collector** — the `Execute Script` collector now takes a single `script` string instead of `{ command, args }`. Existing configs are auto-migrated to v2: `command` + `args` are joined with POSIX single-quote escaping into the new `script` field, so behaviour is preserved. Custom UIs that hard-coded `command`/`args` field names need to switch to `script`.
|
|
45
|
+
- **Inline collector** — scripts are now executed as real ES modules in a Bun subprocess (was: `new Function()` inside a Web Worker). The legacy `return X;` style still works (it's auto-wrapped in an async IIFE), but mixed scripts that `import` _and_ `return` at the top level need to use `export default` for their result.
|
|
46
|
+
|
|
47
|
+
**FIXES**
|
|
48
|
+
|
|
49
|
+
- Shell scripts containing pipes, redirects, `awk`, command substitution, conditionals etc. no longer fail with `ENOENT`. The collector now runs through `sh -c <script>` instead of passing the full expression as `Bun.spawn`'s argv[0]. This was the original `awk … failed with ENOENT` regression.
|
|
50
|
+
- Inline scripts can now `import { loadavg } from "node:os"` (and any other `node:*` or `bun` import). They could not before, because the executor wrapped user code inside `new Function(...)` and ran it in a Web Worker that had no Node module access; the wrapper also made top-level `import` syntactically invalid (`Unexpected token '{'`).
|
|
51
|
+
- Healthcheck editor fields no longer reset while you're editing. The page was re-running its form-state init `useEffect` on every refetch of the configuration query — and that query is invalidated on every realtime `HEALTH_CHECK_RUN_COMPLETED` signal across the platform, so in-progress edits got wiped within seconds. Replaced the naive `useEffect([existingConfig])` with a new `useInitOnceForKey` hook from `@checkstack/ui` that initialises the form only on first load per healthcheck id and ignores background refetches. The hook's decision logic is a pure function (`shouldInitForKey`) and is unit-tested in `useInitOnceForKey.test.ts`.
|
|
52
|
+
- Switching between healthcheck collectors no longer mis-applies the previous collector's tokenizer / language service to the new editor. `MultiTypeEditorField` was reusing the same React instance across collector switches (same `key="script"` in both `DynamicForm` renders) and `selectedType` was initialised from `useState` only once on first mount. After a shell→typescript switch the new collector's TS content rendered through the shell branch (no TS highlighting, no IntelliSense); the reverse direction tokenised shell content through TS and surfaced nonsense errors like `2304 "Cannot find name 'and'"` on shell comments. Now a `useEffect` re-derives `selectedType` whenever `editorTypes` changes to a set that doesn't contain the current selection.
|
|
53
|
+
- Monaco workers are now bundled locally via Vite `?worker` imports and wired up through `MonacoEnvironment.getWorker` in a new `monacoWorkers.ts` module. The default `@monaco-editor/loader` CDN path silently failed CORS on worker scripts in some browsers, leaving Monaco's TS service with only the generic `editorWorkerService` — which is enough for tokenizer-only languages like shell but breaks TypeScript's semantic features entirely. Same module configures the TS service singleton (compiler options, eager-model-sync, diagnostics-options-ignore-1108) at module load instead of inside per-editor `onMount`, so the service starts pre-configured regardless of which language opens first. Migrated from the deprecated `monaco.languages.typescript.*` path to `monaco.typescript.*` (the old path is marked `{ deprecated: true }` in monaco-editor 0.55).
|
|
54
|
+
- `defineIntegration` / `defineHealthCheck` callback parameters are now typed against the schema. Previously the virtual module declared them as `(ctx: unknown) => …`, so writing `defineIntegration(async (context) => { console.log(context.event.eventId) })` produced `'context' is of type 'unknown'. (18046)`. The result type and the shared `IntegrationScriptContext` / `HealthCheckScriptContext` interfaces are now generated together in `scriptContext.ts`, so both the function-arg form and the ambient `declare const context` reference the same schema-typed shape.
|
|
55
|
+
- The shell starter template no longer uses Linux-only `/proc/loadavg` (which fails on macOS satellites with `awk: can't open file /proc/loadavg`). It now reads the 1-minute load average via `uptime` and parses both the Linux (`load average: 0.00, 0.01, 0.05`) and macOS (`load averages: 0.45 0.55 0.65`) output formats with a portable `sed`/`awk`/`tr` pipeline.
|
|
56
|
+
- Starter-template seeding is now self-healing. `DynamicForm`'s schema-defaults `useEffect` fires AFTER child seed effects in React's child-before-parent order, so the previous one-shot seed got clobbered back to `""` by the defaults call on first mount and never re-fired. Replaced the `[]`-deps effect with a two-effect pattern: an observer that latches `hasSeededRef = true` the first time `value` is observed non-empty, and a seed effect that keeps re-installing the starter while the latch is open. Once the seed sticks the latch closes; subsequent edits and realtime refetches don't re-trigger.
|
|
57
|
+
|
|
58
|
+
**NEW**
|
|
59
|
+
|
|
60
|
+
- The Monaco editor for inline scripts now mounts the real upstream `@types/node` + `bun-types` declarations as a virtual filesystem (lazy-loaded as its own JS chunk), so IntelliSense covers the full Node/Bun stdlib, the `Bun` global, `process.env`, `Buffer`, etc. DOM types are deliberately excluded so suggestions stay focused on the backend surface. `context.config` is typed from the collector's own JSON Schema.
|
|
61
|
+
- New `healthcheckScriptContext` / `integrationScriptContext` helpers (exported from `@checkstack/ui`) build a complete editor bundle in one call: TS declarations (`context.config` / `context.event.payload` + the virtual `@checkstack/healthcheck` / `@checkstack/integration` result-type modules), starter templates per language, and the shell env-var list (with platform-injected `EVENT_ID` / `DELIVERY_ID` / `PAYLOAD_*` for integrations). Both call sites — `CollectorSection.tsx` and `CreateSubscriptionDialog.tsx` — were rewired to use them, fixing a long-standing wiring gap where IntelliSense for injected values silently never reached the editor.
|
|
62
|
+
- Inline scripts can now `import { defineHealthCheck } from "@checkstack/healthcheck"` (or `defineIntegration` for integrations) for a typed return-shape assertion. The editor catches `{ success: "yes" }` as a type error against `HealthCheckScriptResult`. The runtime is just an identity function — the collector rewrites the import to a sibling helper file in the temp dir before executing.
|
|
63
|
+
- Shell editors now autocomplete env-vars after `$` and `${`. The completion list is supplied by `healthcheckScriptContext` (safe-vars whitelist) and `integrationScriptContext` (whitelist + `EVENT_ID` etc. + `PAYLOAD_*` flattened from the event's payload schema). The matcher is pure and unit-tested in `shellEnvVarMatcher.test.ts` so regex regressions are caught locally.
|
|
64
|
+
- Empty editor fields are now seeded with a working starter template per language (inline TS uses `defineHealthCheck`, inline shell does the `awk` load-average check, integration TS shows `defineIntegration` with `context.event`, integration shell lists the `$EVENT_ID` / `$PAYLOAD_*` env vars). Users see a runnable example instead of a blank canvas; once they edit, we leave their content alone.
|
|
65
|
+
- Hardened concurrency + cleanup model documented and tested: each invocation gets its own `mkdtemp` directory + UUID result marker; the `finally` block clears the timeout handle, kills any surviving subprocess (idempotent), and removes the temp directory on success, throw, _and_ timeout. New `concurrency.test.ts` proves 20 parallel inline scripts don't cross wires and that the temp-dir count returns to baseline after throws and timeouts.
|
|
66
|
+
|
|
67
|
+
**TESTING**
|
|
68
|
+
|
|
69
|
+
Tight unit tests added so changes to the editor surface don't need smoke testing:
|
|
70
|
+
|
|
71
|
+
- `scriptContext.test.ts` — 18 tests covering the generated type declarations (including explicit regression guards for `defineIntegration` / `defineHealthCheck` callback params being typed against the shared context interface rather than `unknown`), starter templates (including a guard that the shell starter doesn't depend on Linux-only `/proc/loadavg`), shell env-vars for both healthcheck + integration flavours, plus the schema-flattening utility.
|
|
72
|
+
- `shellEnvVarMatcher.test.ts` — 12 tests covering the bare `$` / braced `${` / partial-name / case-insensitive matching logic that powers Monaco's shell completion.
|
|
73
|
+
- `inline-script-normaliser.test.ts` — 13 tests covering the legacy `return X;` → IIFE wrap path, the ESM-passthrough path, and the `@checkstack/healthcheck` import rewriter.
|
|
74
|
+
- `inline-script-collector.test.ts` — 18 tests including ones that actually execute a script importing `defineHealthCheck` (named-import form) AND using the global `defineHealthCheck` (no import) to prove both code paths resolve at runtime.
|
|
75
|
+
- `concurrency.test.ts` — 4 tests proving 20 parallel runs don't collide and that the temp-dir count returns to baseline after success, throw, and timeout.
|
|
76
|
+
- `useInitOnceForKey.test.ts` — 10 tests proving the healthcheck-editor form state isn't reset when react-query refetches in the background (the original "fields reset while I'm typing" regression).
|
|
77
|
+
- `starterTemplateSelector.test.ts` — 7 tests for the pure decision function powering empty-field seeding.
|
|
78
|
+
- `security.test.ts` — added an integration test that actually executes the portable load-average pipeline through `Bun.spawn` on the current OS, catching `/proc/loadavg`-style regressions at CI time on macOS runners.
|
|
79
|
+
|
|
80
|
+
**SECURITY**
|
|
81
|
+
|
|
82
|
+
- Same env-var whitelist as before (`PATH`, `HOME`, `USER`, `LANG`, `LC_ALL`, `LC_CTYPE`, `TZ`, `TMPDIR`, `HOSTNAME`, `SHELL`). Backend secrets in the satellite process's environment remain invisible to user scripts.
|
|
83
|
+
|
|
84
|
+
See `docs/src/content/docs/backend/script-healthchecks.md` for the full user-facing guide.
|
|
85
|
+
|
|
3
86
|
## 1.8.3
|
|
4
87
|
|
|
5
88
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/ui",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.0",
|
|
4
4
|
"license": "Elastic-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -39,9 +39,12 @@
|
|
|
39
39
|
"@storybook/react": "^10.3.6",
|
|
40
40
|
"@storybook/react-vite": "^10.3.6",
|
|
41
41
|
"@testing-library/react": "^16.0.0",
|
|
42
|
+
"@types/bun": "^1.3.14",
|
|
43
|
+
"@types/node": "^20.19.27",
|
|
42
44
|
"@types/react": "^18.2.0",
|
|
43
45
|
"@types/react-dom": "^18.2.0",
|
|
44
46
|
"@vitejs/plugin-react": "^6.0.1",
|
|
47
|
+
"bun-types": "^1.3.14",
|
|
45
48
|
"autoprefixer": "^10.4.18",
|
|
46
49
|
"postcss": "^8.4.35",
|
|
47
50
|
"storybook": "^10.3.6",
|
|
@@ -56,7 +59,8 @@
|
|
|
56
59
|
"lint:code": "eslint . --max-warnings 0",
|
|
57
60
|
"test": "bun test",
|
|
58
61
|
"storybook": "storybook dev -p 6006",
|
|
59
|
-
"storybook:build": "storybook build -o storybook-static"
|
|
62
|
+
"storybook:build": "storybook build -o storybook-static",
|
|
63
|
+
"generate:monaco-types": "bun run scripts/generate-stdlib-types.ts"
|
|
60
64
|
},
|
|
61
65
|
"checkstack": {
|
|
62
66
|
"type": "tooling"
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Bundle the real `@types/node` and `bun-types` ambient declarations into a
|
|
4
|
+
* single JSON record so Monaco's TypeScript service can mount them as a
|
|
5
|
+
* virtual filesystem. The user's inline-script editor uses these for
|
|
6
|
+
* IntelliSense — `import { loadavg } from "node:os"` should autocomplete
|
|
7
|
+
* because the actual upstream `os.d.ts` is reachable, not a hand-rolled
|
|
8
|
+
* approximation.
|
|
9
|
+
*
|
|
10
|
+
* The result is keyed by virtual filesystem path so Monaco resolves
|
|
11
|
+
* cross-file `/// <reference>` directives correctly:
|
|
12
|
+
*
|
|
13
|
+
* {
|
|
14
|
+
* "node_modules/@types/node/index.d.ts": "...",
|
|
15
|
+
* "node_modules/@types/node/os.d.ts": "...",
|
|
16
|
+
* "node_modules/bun-types/index.d.ts": "...",
|
|
17
|
+
* ...
|
|
18
|
+
* }
|
|
19
|
+
*
|
|
20
|
+
* Run with `bun run generate:monaco-types` from `core/ui`. The output JSON
|
|
21
|
+
* lives at `src/components/CodeEditor/generated/stdlib-types.json` and is
|
|
22
|
+
* lazy-imported by MonacoEditor (so the ~3 MB payload is code-split into
|
|
23
|
+
* its own chunk and never blocks initial page load).
|
|
24
|
+
*/
|
|
25
|
+
import { createRequire } from "node:module";
|
|
26
|
+
import { readdir, readFile, mkdir, writeFile } from "node:fs/promises";
|
|
27
|
+
import path from "node:path";
|
|
28
|
+
|
|
29
|
+
const require = createRequire(import.meta.url);
|
|
30
|
+
|
|
31
|
+
async function walkDts(
|
|
32
|
+
dir: string,
|
|
33
|
+
pathPrefix: string,
|
|
34
|
+
): Promise<Record<string, string>> {
|
|
35
|
+
const out: Record<string, string> = {};
|
|
36
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
37
|
+
for (const entry of entries) {
|
|
38
|
+
const abs = path.join(dir, entry.name);
|
|
39
|
+
const rel = path.relative(dir, abs);
|
|
40
|
+
const virtualPath = `${pathPrefix}/${rel}`;
|
|
41
|
+
if (entry.isDirectory()) {
|
|
42
|
+
Object.assign(out, await walkDts(abs, virtualPath));
|
|
43
|
+
} else if (entry.name.endsWith(".d.ts")) {
|
|
44
|
+
out[virtualPath] = await readFile(abs, "utf8");
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function bundle(pkg: string, virtualPrefix: string) {
|
|
51
|
+
const pkgJsonPath = require.resolve(`${pkg}/package.json`);
|
|
52
|
+
const pkgRoot = path.dirname(pkgJsonPath);
|
|
53
|
+
console.log(` • ${pkg} ← ${pkgRoot}`);
|
|
54
|
+
return walkDts(pkgRoot, virtualPrefix);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
console.log("📦 Bundling Monaco stdlib types...");
|
|
58
|
+
|
|
59
|
+
// Build the file map immutably, then merge — eslint's
|
|
60
|
+
// `unicorn/no-immediate-mutation` flags `Object.assign(literal, ...)`.
|
|
61
|
+
const nodeFiles = await bundle("@types/node", "node_modules/@types/node");
|
|
62
|
+
const bunFiles = await bundle("bun-types", "node_modules/bun-types");
|
|
63
|
+
|
|
64
|
+
const files: Record<string, string> = {
|
|
65
|
+
...nodeFiles,
|
|
66
|
+
...bunFiles,
|
|
67
|
+
// `@types/bun` is just a one-line wrapper that triple-slash-references
|
|
68
|
+
// bun-types. Inline it so consumers can `import "bun"` and have Monaco
|
|
69
|
+
// resolve it without needing the wrapper package.
|
|
70
|
+
"node_modules/@types/bun/index.d.ts": `/// <reference types="bun-types" />\n`,
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const outDir = path.join(
|
|
74
|
+
import.meta.dir,
|
|
75
|
+
"..",
|
|
76
|
+
"src",
|
|
77
|
+
"components",
|
|
78
|
+
"CodeEditor",
|
|
79
|
+
"generated",
|
|
80
|
+
);
|
|
81
|
+
const outFile = path.join(outDir, "stdlib-types.json");
|
|
82
|
+
|
|
83
|
+
await mkdir(outDir, { recursive: true });
|
|
84
|
+
// Compact JSON — this is consumed by code, not humans.
|
|
85
|
+
await writeFile(outFile, JSON.stringify(files), "utf8");
|
|
86
|
+
|
|
87
|
+
const totalBytes = Object.values(files).reduce((acc, c) => acc + c.length, 0);
|
|
88
|
+
console.log(
|
|
89
|
+
`✅ Wrote ${Object.keys(files).length} files (${(totalBytes / 1024 / 1024).toFixed(2)} MB) → ${path.relative(process.cwd(), outFile)}`,
|
|
90
|
+
);
|
|
@@ -6,9 +6,16 @@ export {
|
|
|
6
6
|
type CodeEditorProps,
|
|
7
7
|
type CodeEditorLanguage,
|
|
8
8
|
type TemplateProperty,
|
|
9
|
+
type ShellEnvVar,
|
|
9
10
|
} from "./MonacoEditor";
|
|
10
11
|
|
|
11
12
|
export {
|
|
12
13
|
generateTypeDefinitions,
|
|
13
14
|
type GenerateTypesOptions,
|
|
14
15
|
} from "./generateTypeDefinitions";
|
|
16
|
+
|
|
17
|
+
export {
|
|
18
|
+
healthcheckScriptContext,
|
|
19
|
+
integrationScriptContext,
|
|
20
|
+
type ScriptEditorContext,
|
|
21
|
+
} from "./scriptContext";
|
|
@@ -1,17 +1,34 @@
|
|
|
1
|
+
// Side-effect import: bundles Monaco's per-language workers via Vite
|
|
2
|
+
// `?worker` imports and points `@monaco-editor/react`'s loader at the
|
|
3
|
+
// local Monaco rather than its CDN default. Must run before any
|
|
4
|
+
// `<Editor>` mount. See monacoWorkers.ts for the full explanation
|
|
5
|
+
// (TL;DR: without local workers, the TS service can't spawn `ts.worker`,
|
|
6
|
+
// so TypeScript editors silently degrade to no-language-service mode
|
|
7
|
+
// while shell editors keep working because they only need the generic
|
|
8
|
+
// editor worker).
|
|
9
|
+
import "./monacoWorkers";
|
|
10
|
+
|
|
1
11
|
import React from "react";
|
|
2
12
|
import Editor, {
|
|
3
|
-
loader,
|
|
4
13
|
type OnMount,
|
|
5
14
|
type Monaco,
|
|
6
15
|
} from "@monaco-editor/react";
|
|
7
16
|
import type { editor, Position } from "monaco-editor";
|
|
8
17
|
import { detectOpenTemplate, detectAutoClosedBraces } from "./templateUtils";
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
18
|
+
import { ensureMonacoStdlib } from "./monacoStdlib";
|
|
19
|
+
import {
|
|
20
|
+
matchShellEnvVarTrigger,
|
|
21
|
+
buildShellEnvVarInsertText,
|
|
22
|
+
} from "./shellEnvVarMatcher";
|
|
23
|
+
|
|
24
|
+
// Note on loader config: previously this module called
|
|
25
|
+
// loader.config({ "vs/nls": { availableLanguages: { "*": "en" } } })
|
|
26
|
+
// to keep Monaco in English when the loader fetched it from a CDN. We
|
|
27
|
+
// now bundle Monaco locally (see monacoWorkers.ts → loader.config({
|
|
28
|
+
// monaco })), which short-circuits the AMD loader entirely — the NLS
|
|
29
|
+
// config can't apply because no remote Monaco is ever fetched. English
|
|
30
|
+
// is the default for the locally bundled `monaco-editor` package, so
|
|
31
|
+
// removing the call has no observable effect.
|
|
15
32
|
|
|
16
33
|
export type CodeEditorLanguage =
|
|
17
34
|
| "json"
|
|
@@ -34,6 +51,19 @@ export interface TemplateProperty {
|
|
|
34
51
|
description?: string;
|
|
35
52
|
}
|
|
36
53
|
|
|
54
|
+
/**
|
|
55
|
+
* A single environment variable available to shell scripts. Surfaced as
|
|
56
|
+
* a Monaco completion item after the user types `$` or `${` in shell mode.
|
|
57
|
+
*/
|
|
58
|
+
export interface ShellEnvVar {
|
|
59
|
+
/** Variable name without the leading `$`, e.g. `EVENT_ID`. */
|
|
60
|
+
name: string;
|
|
61
|
+
/** Optional description shown in the completion popup. */
|
|
62
|
+
description?: string;
|
|
63
|
+
/** Optional example value shown in the completion item detail. */
|
|
64
|
+
example?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
37
67
|
export interface CodeEditorProps {
|
|
38
68
|
/** Unique identifier for the editor */
|
|
39
69
|
id?: string;
|
|
@@ -59,6 +89,13 @@ export interface CodeEditorProps {
|
|
|
59
89
|
* When provided, typing "{{" triggers autocomplete with available template variables.
|
|
60
90
|
*/
|
|
61
91
|
templateProperties?: TemplateProperty[];
|
|
92
|
+
/**
|
|
93
|
+
* Optional environment-variable hints for shell mode. When provided and
|
|
94
|
+
* `language === "shell"`, Monaco autocompletes them after `$` and `${`.
|
|
95
|
+
* Use this to surface platform-injected vars like `EVENT_ID` or
|
|
96
|
+
* `PAYLOAD_*` without forcing users to memorise the names.
|
|
97
|
+
*/
|
|
98
|
+
shellEnvVars?: ShellEnvVar[];
|
|
62
99
|
}
|
|
63
100
|
|
|
64
101
|
// Map language names to Monaco language IDs
|
|
@@ -72,76 +109,60 @@ const languageMap: Record<string, string> = {
|
|
|
72
109
|
shell: "shell",
|
|
73
110
|
};
|
|
74
111
|
|
|
112
|
+
/**
|
|
113
|
+
* File-extension hint used in the per-editor Monaco model URI. Monaco
|
|
114
|
+
* decides which language service to use partly from the URI's extension,
|
|
115
|
+
* so a path like `<id>.ts` reliably yields the TypeScript service even
|
|
116
|
+
* if the `language` prop is still being reconciled by the React wrapper
|
|
117
|
+
* during a fast tab-switch. This is what fixes the "TS check now has
|
|
118
|
+
* shell highlighting after I created an SSH check" bug: without
|
|
119
|
+
* different paths, every `<CodeEditor>` mount reuses Monaco's _default_
|
|
120
|
+
* model — and the model's previous language sticks around.
|
|
121
|
+
*/
|
|
122
|
+
const LANGUAGE_EXTENSIONS: Record<string, string> = {
|
|
123
|
+
json: "json",
|
|
124
|
+
yaml: "yaml",
|
|
125
|
+
xml: "xml",
|
|
126
|
+
markdown: "md",
|
|
127
|
+
javascript: "js",
|
|
128
|
+
typescript: "ts",
|
|
129
|
+
shell: "sh",
|
|
130
|
+
"json-template": "json",
|
|
131
|
+
};
|
|
132
|
+
|
|
75
133
|
// Track if we've registered the json-template language
|
|
76
134
|
let jsonTemplateLanguageRegistered = false;
|
|
77
135
|
|
|
78
136
|
/**
|
|
79
|
-
* Default type definitions
|
|
80
|
-
*
|
|
81
|
-
*
|
|
137
|
+
* Default type definitions injected on top of the bundled Node/Bun stdlib.
|
|
138
|
+
*
|
|
139
|
+
* `ensureMonacoStdlib` mounts the real upstream `@types/node` + `bun-types`
|
|
140
|
+
* d.ts files into Monaco's virtual filesystem, so `import { loadavg } from
|
|
141
|
+
* "node:os"`, `fetch`, `console`, `URL`, etc. are all already declared.
|
|
142
|
+
* This block only adds what's _specific_ to the inline-script runner —
|
|
143
|
+
* the `context` global and the expected return shape.
|
|
144
|
+
*
|
|
145
|
+
* Used when no custom typeDefinitions prop is passed.
|
|
82
146
|
*/
|
|
83
147
|
const DEFAULT_BACKEND_TYPE_DEFINITIONS = `
|
|
84
|
-
/** Expected return
|
|
148
|
+
/** Expected return shape for inline-script health checks. */
|
|
85
149
|
interface HealthCheckScriptResult {
|
|
86
|
-
/** Whether the health check passed */
|
|
150
|
+
/** Whether the health check passed. */
|
|
87
151
|
success: boolean;
|
|
88
|
-
/** Optional status message */
|
|
152
|
+
/** Optional status message — surfaces in the run detail. */
|
|
89
153
|
message?: string;
|
|
90
|
-
/** Optional numeric value
|
|
154
|
+
/** Optional numeric value — feeds the value chart + anomaly detection. */
|
|
91
155
|
value?: number;
|
|
92
156
|
}
|
|
93
157
|
|
|
94
|
-
/**
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
error(...args: unknown[]): void;
|
|
102
|
-
/** Log an info message */
|
|
103
|
-
info(...args: unknown[]): void;
|
|
158
|
+
/**
|
|
159
|
+
* Runtime context exposed by the inline-script runner.
|
|
160
|
+
* Available as a global, so just reference \`context.config\`.
|
|
161
|
+
*/
|
|
162
|
+
declare const context: {
|
|
163
|
+
/** The raw collector configuration object. */
|
|
164
|
+
readonly config: Record<string, unknown>;
|
|
104
165
|
};
|
|
105
|
-
|
|
106
|
-
/** HTTP Request configuration */
|
|
107
|
-
interface RequestInit {
|
|
108
|
-
method?: string;
|
|
109
|
-
headers?: Record<string, string> | Headers;
|
|
110
|
-
body?: string | FormData | URLSearchParams;
|
|
111
|
-
mode?: 'cors' | 'no-cors' | 'same-origin';
|
|
112
|
-
credentials?: 'omit' | 'same-origin' | 'include';
|
|
113
|
-
cache?: 'default' | 'no-store' | 'reload' | 'no-cache' | 'force-cache';
|
|
114
|
-
redirect?: 'follow' | 'error' | 'manual';
|
|
115
|
-
signal?: AbortSignal;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/** HTTP Response */
|
|
119
|
-
interface Response {
|
|
120
|
-
readonly ok: boolean;
|
|
121
|
-
readonly status: number;
|
|
122
|
-
readonly statusText: string;
|
|
123
|
-
readonly headers: Headers;
|
|
124
|
-
readonly url: string;
|
|
125
|
-
readonly redirected: boolean;
|
|
126
|
-
json(): Promise<unknown>;
|
|
127
|
-
text(): Promise<string>;
|
|
128
|
-
blob(): Promise<Blob>;
|
|
129
|
-
arrayBuffer(): Promise<ArrayBuffer>;
|
|
130
|
-
clone(): Response;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/** HTTP Headers */
|
|
134
|
-
interface Headers {
|
|
135
|
-
get(name: string): string | null;
|
|
136
|
-
has(name: string): boolean;
|
|
137
|
-
set(name: string, value: string): void;
|
|
138
|
-
append(name: string, value: string): void;
|
|
139
|
-
delete(name: string): void;
|
|
140
|
-
forEach(callback: (value: string, key: string) => void): void;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/** Fetch API for making HTTP requests */
|
|
144
|
-
declare function fetch(input: string | URL, init?: RequestInit): Promise<Response>;
|
|
145
166
|
`;
|
|
146
167
|
|
|
147
168
|
/**
|
|
@@ -158,6 +179,7 @@ export const CodeEditor: React.FC<CodeEditorProps> = ({
|
|
|
158
179
|
placeholder,
|
|
159
180
|
typeDefinitions,
|
|
160
181
|
templateProperties,
|
|
182
|
+
shellEnvVars,
|
|
161
183
|
}) => {
|
|
162
184
|
const editorRef = React.useRef<editor.IStandaloneCodeEditor | null>(null);
|
|
163
185
|
const monacoRef = React.useRef<Monaco | null>(null);
|
|
@@ -216,39 +238,24 @@ export const CodeEditor: React.FC<CodeEditorProps> = ({
|
|
|
216
238
|
monacoRef.current = monaco;
|
|
217
239
|
setIsEditorMounted(true);
|
|
218
240
|
|
|
219
|
-
// Configure TypeScript/JavaScript to exclude DOM types for backend scripts
|
|
220
|
-
// This prevents Web API autocompletions (AudioContext, Canvas, etc.)
|
|
221
|
-
if (language === "typescript" || language === "javascript") {
|
|
222
|
-
const defaults =
|
|
223
|
-
language === "typescript"
|
|
224
|
-
? monaco.languages.typescript.typescriptDefaults
|
|
225
|
-
: monaco.languages.typescript.javascriptDefaults;
|
|
226
|
-
|
|
227
|
-
// Configure compiler options to exclude DOM types but keep ES libs
|
|
228
|
-
// This gives us Promise, Array, etc. but not AudioContext, Canvas, etc.
|
|
229
|
-
defaults.setCompilerOptions({
|
|
230
|
-
target: monaco.languages.typescript.ScriptTarget.ESNext,
|
|
231
|
-
module: monaco.languages.typescript.ModuleKind.ESNext,
|
|
232
|
-
lib: ["esnext"], // Include ES libs but NOT DOM
|
|
233
|
-
allowNonTsExtensions: true,
|
|
234
|
-
noEmit: true,
|
|
235
|
-
strict: true,
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
// Suppress certain diagnostics that don't apply to our script context
|
|
239
|
-
// - 1108: "A 'return' statement can only be used within a function body"
|
|
240
|
-
// Our scripts are wrapped in async function at runtime, so return is valid
|
|
241
|
-
defaults.setDiagnosticsOptions({
|
|
242
|
-
diagnosticCodesToIgnore: [1108],
|
|
243
|
-
});
|
|
244
241
|
|
|
245
|
-
// Disable fetching default lib content (prevents DOM types loading)
|
|
246
|
-
defaults.setEagerModelSync(true);
|
|
247
242
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
243
|
+
// Per-editor TS/JS setup. All GLOBAL TS-service config (compiler
|
|
244
|
+
// options, diagnostics options, eager model sync) is done once at
|
|
245
|
+
// module load in monacoWorkers.ts — doing it here, after the
|
|
246
|
+
// service may have already initialised lazily from a previous
|
|
247
|
+
// mount, was the root cause of the "open shell first, TS service
|
|
248
|
+
// is broken" bug.
|
|
249
|
+
//
|
|
250
|
+
// The per-editor `addExtraLib` for THIS editor's `context.d.ts` is
|
|
251
|
+
// handled by the useEffect below (so it can also refresh when the
|
|
252
|
+
// `typeDefinitions` prop changes), keyed by `modelIdRef.current` so
|
|
253
|
+
// two TS editors with different schemas don't share a virtual path.
|
|
254
|
+
//
|
|
255
|
+
// Here we only kick off the lazy stdlib bundle so the ~3 MB chunk
|
|
256
|
+
// starts streaming on the first TS editor mount.
|
|
257
|
+
if (language === "typescript" || language === "javascript") {
|
|
258
|
+
void ensureMonacoStdlib(monaco);
|
|
252
259
|
}
|
|
253
260
|
|
|
254
261
|
// Handle validation for template syntax in JSON
|
|
@@ -432,41 +439,86 @@ export const CodeEditor: React.FC<CodeEditorProps> = ({
|
|
|
432
439
|
};
|
|
433
440
|
}, [templateProperties, language, isEditorMounted]);
|
|
434
441
|
|
|
435
|
-
//
|
|
442
|
+
// Install (and refresh) the per-editor context.d.ts.
|
|
443
|
+
//
|
|
444
|
+
// - Includes `isEditorMounted` in the deps so the effect runs after
|
|
445
|
+
// `monacoRef.current` is wired up by `handleEditorDidMount`.
|
|
446
|
+
// - Uses a unique virtual path keyed by this editor instance
|
|
447
|
+
// (`context-<modelId>.d.ts`) so two TS editors with different
|
|
448
|
+
// collector schemas can't overwrite each other's context types.
|
|
449
|
+
// - Returns a cleanup that disposes the registered lib, both on
|
|
450
|
+
// unmount and when `typeDefinitions` / `language` change.
|
|
436
451
|
React.useEffect(() => {
|
|
452
|
+
if (!isEditorMounted) return;
|
|
437
453
|
if (!monacoRef.current) return;
|
|
438
454
|
if (language !== "typescript" && language !== "javascript") return;
|
|
439
455
|
|
|
440
456
|
const monaco = monacoRef.current;
|
|
441
457
|
const defaults =
|
|
442
458
|
language === "typescript"
|
|
443
|
-
? monaco.
|
|
444
|
-
: monaco.
|
|
445
|
-
|
|
446
|
-
// Configure compiler options to exclude DOM types but keep ES libs
|
|
447
|
-
defaults.setCompilerOptions({
|
|
448
|
-
target: monaco.languages.typescript.ScriptTarget.ESNext,
|
|
449
|
-
module: monaco.languages.typescript.ModuleKind.ESNext,
|
|
450
|
-
lib: ["esnext"], // Include ES libs but NOT DOM
|
|
451
|
-
allowNonTsExtensions: true,
|
|
452
|
-
noEmit: true,
|
|
453
|
-
strict: true,
|
|
454
|
-
});
|
|
459
|
+
? monaco.typescript.typescriptDefaults
|
|
460
|
+
: monaco.typescript.javascriptDefaults;
|
|
455
461
|
|
|
456
|
-
// Suppress certain diagnostics that don't apply to our script context
|
|
457
|
-
defaults.setDiagnosticsOptions({
|
|
458
|
-
diagnosticCodesToIgnore: [1108], // "A 'return' statement can only be used within a function body"
|
|
459
|
-
});
|
|
460
|
-
|
|
461
|
-
// Add type definitions (custom or default)
|
|
462
462
|
const definitions = typeDefinitions ?? DEFAULT_BACKEND_TYPE_DEFINITIONS;
|
|
463
|
-
const
|
|
464
|
-
|
|
463
|
+
const contextLibPath = `file:///context-${modelIdRef.current}.d.ts`;
|
|
464
|
+
const lib = defaults.addExtraLib(definitions, contextLibPath);
|
|
465
465
|
|
|
466
466
|
return () => {
|
|
467
467
|
lib.dispose();
|
|
468
468
|
};
|
|
469
|
-
}, [typeDefinitions, language]);
|
|
469
|
+
}, [typeDefinitions, language, isEditorMounted]);
|
|
470
|
+
|
|
471
|
+
// Shell env-var completion. When the caller passes `shellEnvVars`, we
|
|
472
|
+
// register a Monaco provider that suggests variable names after the
|
|
473
|
+
// user types `$` or `${` — and also brace-closes `${name}` correctly.
|
|
474
|
+
// Re-registers when the var list or language changes.
|
|
475
|
+
React.useEffect(() => {
|
|
476
|
+
if (!isEditorMounted) return;
|
|
477
|
+
if (!monacoRef.current) return;
|
|
478
|
+
if (language !== "shell") return;
|
|
479
|
+
if (!shellEnvVars || shellEnvVars.length === 0) return;
|
|
480
|
+
|
|
481
|
+
const monaco = monacoRef.current;
|
|
482
|
+
const provider = monaco.languages.registerCompletionItemProvider("shell", {
|
|
483
|
+
triggerCharacters: ["$", "{"],
|
|
484
|
+
provideCompletionItems: (model: editor.ITextModel, position: Position) => {
|
|
485
|
+
const lineText = model.getLineContent(position.lineNumber);
|
|
486
|
+
const textBefore = lineText.slice(0, position.column - 1);
|
|
487
|
+
const match = matchShellEnvVarTrigger(textBefore);
|
|
488
|
+
if (!match) return { suggestions: [] };
|
|
489
|
+
|
|
490
|
+
const startColumn = position.column - match.prefixLength;
|
|
491
|
+
|
|
492
|
+
const suggestions = shellEnvVars
|
|
493
|
+
.filter(
|
|
494
|
+
(v) =>
|
|
495
|
+
match.query === "" ||
|
|
496
|
+
v.name.toUpperCase().includes(match.query),
|
|
497
|
+
)
|
|
498
|
+
.map((v, index) => ({
|
|
499
|
+
label: `$${v.name}`,
|
|
500
|
+
kind: monaco.languages.CompletionItemKind.Variable,
|
|
501
|
+
detail: v.example ? `e.g. ${v.example}` : "shell env var",
|
|
502
|
+
documentation: v.description,
|
|
503
|
+
insertText: buildShellEnvVarInsertText(match, v.name),
|
|
504
|
+
sortText: ` ${String(index).padStart(4, "0")}`,
|
|
505
|
+
filterText: `$${v.name}`,
|
|
506
|
+
range: {
|
|
507
|
+
startLineNumber: position.lineNumber,
|
|
508
|
+
startColumn,
|
|
509
|
+
endLineNumber: position.lineNumber,
|
|
510
|
+
endColumn: position.column,
|
|
511
|
+
},
|
|
512
|
+
}));
|
|
513
|
+
|
|
514
|
+
return { suggestions, incomplete: false };
|
|
515
|
+
},
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
return () => {
|
|
519
|
+
provider.dispose();
|
|
520
|
+
};
|
|
521
|
+
}, [shellEnvVars, language, isEditorMounted]);
|
|
470
522
|
|
|
471
523
|
// Calculate height from minHeight
|
|
472
524
|
const heightValue = minHeight.replace("px", "");
|
|
@@ -479,6 +531,39 @@ export const CodeEditor: React.FC<CodeEditorProps> = ({
|
|
|
479
531
|
? "json-template"
|
|
480
532
|
: (languageMap[language] ?? language);
|
|
481
533
|
|
|
534
|
+
// Stable, unique-per-instance model path. We need this for two reasons:
|
|
535
|
+
//
|
|
536
|
+
// 1. Without a `path`, all `@monaco-editor/react` instances share the
|
|
537
|
+
// single default in-memory model. When you switch between a TS
|
|
538
|
+
// inline-script check and an SSH (shell) check, the new mount
|
|
539
|
+
// inherits the previous model's language, producing visibly wrong
|
|
540
|
+
// syntax highlighting until something else triggers a refresh.
|
|
541
|
+
//
|
|
542
|
+
// 2. The extension portion (`.ts`, `.sh`, `.json`, ...) is one of the
|
|
543
|
+
// signals Monaco's language service uses to decide which tokenizer
|
|
544
|
+
// and worker to apply, so encoding the effective language in the
|
|
545
|
+
// path makes the language choice unambiguous.
|
|
546
|
+
//
|
|
547
|
+
// We deliberately use a fresh UUID per mount (in a `useRef`, lazy
|
|
548
|
+
// init) rather than `useId`: `useId`'s output is derived from the
|
|
549
|
+
// call-site's position in the React tree, so a remount of the same
|
|
550
|
+
// `<CodeEditor>` slot — typical when the user switches between two
|
|
551
|
+
// healthcheck pages that React Router reuses — can return the same
|
|
552
|
+
// id as before. `@monaco-editor/react` then reuses the previous
|
|
553
|
+
// model (and its language) from Monaco's registry, which is exactly
|
|
554
|
+
// the "TS check now has shell highlighting" bug.
|
|
555
|
+
const modelIdRef = React.useRef<string | null>(null);
|
|
556
|
+
if (modelIdRef.current === null) {
|
|
557
|
+
modelIdRef.current = `cs-editor-${
|
|
558
|
+
typeof crypto !== "undefined" && "randomUUID" in crypto
|
|
559
|
+
? crypto.randomUUID()
|
|
560
|
+
: Math.random().toString(36).slice(2, 11)
|
|
561
|
+
}`;
|
|
562
|
+
}
|
|
563
|
+
const pathExtension =
|
|
564
|
+
LANGUAGE_EXTENSIONS[effectiveLanguage] ?? effectiveLanguage;
|
|
565
|
+
const modelPath = `${modelIdRef.current}.${pathExtension}`;
|
|
566
|
+
|
|
482
567
|
return (
|
|
483
568
|
<div
|
|
484
569
|
id={id}
|
|
@@ -488,6 +573,7 @@ export const CodeEditor: React.FC<CodeEditorProps> = ({
|
|
|
488
573
|
<Editor
|
|
489
574
|
height={`${Math.max(numericHeight, 100)}px`}
|
|
490
575
|
language={effectiveLanguage}
|
|
576
|
+
path={modelPath}
|
|
491
577
|
value={value}
|
|
492
578
|
onChange={(newValue) => onChange(newValue ?? "")}
|
|
493
579
|
beforeMount={handleEditorWillMount}
|