@checkstack/automation-frontend 0.2.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 +664 -0
- package/package.json +38 -0
- package/src/components/AutomationMenuItems.tsx +37 -0
- package/src/editor/ActionEditor.tsx +367 -0
- package/src/editor/ActionListEditor.tsx +203 -0
- package/src/editor/AddActionDialog.tsx +225 -0
- package/src/editor/AutomationDefinitionContext.tsx +37 -0
- package/src/editor/AutomationDefinitionEditor.tsx +99 -0
- package/src/editor/ConditionEditor.tsx +218 -0
- package/src/editor/ConditionsEditor.tsx +89 -0
- package/src/editor/ItemPicker.tsx +147 -0
- package/src/editor/TriggersEditor.tsx +269 -0
- package/src/editor/action-composite-cards.tsx +390 -0
- package/src/editor/action-helpers.ts +365 -0
- package/src/editor/action-leaf-cards.tsx +426 -0
- package/src/editor/editor-validation.test.ts +95 -0
- package/src/editor/editor-validation.tsx +200 -0
- package/src/editor/registry-context.tsx +192 -0
- package/src/editor/template-completion.test.ts +412 -0
- package/src/editor/template-completion.ts +664 -0
- package/src/editor/template-helpers.test.ts +145 -0
- package/src/editor/template-helpers.ts +95 -0
- package/src/editor/trigger-helpers.test.ts +58 -0
- package/src/editor/trigger-helpers.ts +67 -0
- package/src/editor/useConnectionOptionResolvers.ts +80 -0
- package/src/editor/yaml-markers.ts +116 -0
- package/src/index.tsx +95 -0
- package/src/pages/AutomationEditPage.tsx +567 -0
- package/src/pages/AutomationListPage.tsx +304 -0
- package/src/pages/RunDetailPage.tsx +333 -0
- package/src/pages/RunsPage.tsx +233 -0
- package/src/pages/TemplatePlaygroundPage.tsx +224 -0
- package/src/script-context.test.ts +247 -0
- package/src/script-context.ts +218 -0
- package/tsconfig.json +29 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the scope → completion-field flattening + shell-env derivation.
|
|
3
|
+
*
|
|
4
|
+
* The key invariant: completion fields carry both the canonical `path`
|
|
5
|
+
* (used ONLY for shell-env names, which must match the backend's
|
|
6
|
+
* path-based injection) and the runtime-parseable `templateRef` (what
|
|
7
|
+
* gets inserted into `{{ }}`).
|
|
8
|
+
*/
|
|
9
|
+
import { describe, expect, it } from "bun:test";
|
|
10
|
+
import type { VariableScope } from "@checkstack/automation-common";
|
|
11
|
+
import { fieldsToShellEnvVars, flattenScopeToFields } from "./template-helpers";
|
|
12
|
+
|
|
13
|
+
const scope: VariableScope = {
|
|
14
|
+
entries: [
|
|
15
|
+
{
|
|
16
|
+
path: "trigger",
|
|
17
|
+
templateRef: "trigger",
|
|
18
|
+
type: "object",
|
|
19
|
+
children: [
|
|
20
|
+
{
|
|
21
|
+
path: "trigger.payload",
|
|
22
|
+
templateRef: "trigger.payload",
|
|
23
|
+
type: "object",
|
|
24
|
+
children: [
|
|
25
|
+
{
|
|
26
|
+
path: "trigger.payload.severity",
|
|
27
|
+
templateRef: "trigger.payload.severity",
|
|
28
|
+
type: "string",
|
|
29
|
+
jsonSchema: { type: "string", enum: ["low", "high"] },
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
path: "artifact",
|
|
37
|
+
templateRef: "artifacts",
|
|
38
|
+
type: "object",
|
|
39
|
+
children: [
|
|
40
|
+
{
|
|
41
|
+
path: "artifact.integration-jira.issue",
|
|
42
|
+
templateRef: 'artifacts["integration-jira.issue"]',
|
|
43
|
+
type: "object",
|
|
44
|
+
children: [
|
|
45
|
+
{
|
|
46
|
+
path: "artifact.integration-jira.issue.issueKey",
|
|
47
|
+
templateRef: 'artifacts["integration-jira.issue"].issueKey',
|
|
48
|
+
type: "string",
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const arrayScope: VariableScope = {
|
|
58
|
+
entries: [
|
|
59
|
+
{
|
|
60
|
+
path: "artifact.integration-jira.issue",
|
|
61
|
+
templateRef: 'artifacts["integration-jira.issue"]',
|
|
62
|
+
type: "object",
|
|
63
|
+
children: [
|
|
64
|
+
{
|
|
65
|
+
path: "artifact.integration-jira.issue.tags",
|
|
66
|
+
templateRef: 'artifacts["integration-jira.issue"].tags',
|
|
67
|
+
type: "string[]",
|
|
68
|
+
referenceable: true,
|
|
69
|
+
children: [
|
|
70
|
+
{
|
|
71
|
+
path: "artifact.integration-jira.issue.tags[0]",
|
|
72
|
+
templateRef: 'artifacts["integration-jira.issue"].tags[0]',
|
|
73
|
+
type: "string",
|
|
74
|
+
},
|
|
75
|
+
],
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
describe("flattenScopeToFields", () => {
|
|
83
|
+
it("emits both the whole-array field and its element field", () => {
|
|
84
|
+
const fields = flattenScopeToFields(arrayScope);
|
|
85
|
+
const refs = fields.map((f) => f.templateRef);
|
|
86
|
+
// Whole array (referenceable) AND the element are both offered.
|
|
87
|
+
expect(refs).toContain('artifacts["integration-jira.issue"].tags');
|
|
88
|
+
expect(refs).toContain('artifacts["integration-jira.issue"].tags[0]');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("derives shell env names from the canonical path for array fields", () => {
|
|
92
|
+
const fields = flattenScopeToFields(arrayScope);
|
|
93
|
+
const vars = fieldsToShellEnvVars(fields);
|
|
94
|
+
for (const v of vars) {
|
|
95
|
+
expect(v.name).toMatch(/^CHECKSTACK_/);
|
|
96
|
+
expect(v.name).not.toContain("[");
|
|
97
|
+
expect(v.name).not.toContain("]");
|
|
98
|
+
expect(v.name).not.toContain('"');
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("carries both path and templateRef on each leaf", () => {
|
|
103
|
+
const fields = flattenScopeToFields(scope);
|
|
104
|
+
const severity = fields.find(
|
|
105
|
+
(f) => f.path === "trigger.payload.severity",
|
|
106
|
+
);
|
|
107
|
+
expect(severity?.templateRef).toBe("trigger.payload.severity");
|
|
108
|
+
expect(severity?.enumValues).toEqual(["low", "high"]);
|
|
109
|
+
|
|
110
|
+
const issueKey = fields.find(
|
|
111
|
+
(f) => f.path === "artifact.integration-jira.issue.issueKey",
|
|
112
|
+
);
|
|
113
|
+
expect(issueKey?.templateRef).toBe(
|
|
114
|
+
'artifacts["integration-jira.issue"].issueKey',
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("falls back to path when an entry has no templateRef", () => {
|
|
119
|
+
const legacyScope: VariableScope = {
|
|
120
|
+
entries: [{ path: "var.foo", type: "string" }],
|
|
121
|
+
};
|
|
122
|
+
const [field] = flattenScopeToFields(legacyScope);
|
|
123
|
+
expect(field?.templateRef).toBe("var.foo");
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("fieldsToShellEnvVars", () => {
|
|
128
|
+
it("derives env names from the canonical dotted path, NOT the bracket templateRef", () => {
|
|
129
|
+
const fields = flattenScopeToFields(scope);
|
|
130
|
+
const vars = fieldsToShellEnvVars(fields);
|
|
131
|
+
const names = vars.map((v) => v.name);
|
|
132
|
+
|
|
133
|
+
// Artifact env var must come from the dotted path, producing a clean
|
|
134
|
+
// CHECKSTACK_ARTIFACT_* name — never from the bracket/quote form.
|
|
135
|
+
const artifactVar = names.find((n) => n.includes("ARTIFACT"));
|
|
136
|
+
expect(artifactVar).toBeDefined();
|
|
137
|
+
expect(artifactVar).toMatch(/^CHECKSTACK_/);
|
|
138
|
+
// No bracket / quote leakage from the templateRef into the env name.
|
|
139
|
+
for (const name of names) {
|
|
140
|
+
expect(name).not.toContain("[");
|
|
141
|
+
expect(name).not.toContain('"');
|
|
142
|
+
expect(name).not.toContain("artifacts");
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { VariableEntry, VariableScope } from "@checkstack/automation-common";
|
|
2
|
+
import { toShellEnvKey } from "@checkstack/automation-common";
|
|
3
|
+
import type { ShellEnvVar } from "@checkstack/ui";
|
|
4
|
+
import type { CompletionField, CompletionFilter } from "./template-completion";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Built-in template filters shipped by the engine. Mirrors the registry
|
|
8
|
+
* built in `@checkstack/template-engine` → `createDefaultFilterRegistry`.
|
|
9
|
+
* Drives the "filter" stage of staged completion (after a `|`).
|
|
10
|
+
*
|
|
11
|
+
* Hand-written rather than auto-derived so we can supply human-readable
|
|
12
|
+
* signatures + short descriptions (the engine stores only the function).
|
|
13
|
+
*/
|
|
14
|
+
export const TEMPLATE_FILTERS: readonly CompletionFilter[] = [
|
|
15
|
+
{ name: "default", signature: "fallback", description: "Substitute when null / undefined / empty.", hasArgs: true },
|
|
16
|
+
{ name: "upper", description: "Uppercase a string." },
|
|
17
|
+
{ name: "lower", description: "Lowercase a string." },
|
|
18
|
+
{ name: "capitalize", description: "Capitalise the first letter." },
|
|
19
|
+
{ name: "trim", description: "Strip leading + trailing whitespace." },
|
|
20
|
+
{ name: "truncate", signature: "length", description: "Truncate to N chars, append …", hasArgs: true },
|
|
21
|
+
{ name: "length", description: "Length of a string / array / object." },
|
|
22
|
+
{ name: "json", description: "Stringify a value as JSON." },
|
|
23
|
+
{ name: "iso", description: "Format a date as ISO-8601." },
|
|
24
|
+
{ name: "date", signature: "format", description: 'Format: "ISO" | "date" | "time" | "unix" | "rfc2822".', hasArgs: true },
|
|
25
|
+
{ name: "join", signature: "separator", description: 'Join an array (default ", ").', hasArgs: true },
|
|
26
|
+
{ name: "replace", signature: "search, replacement", description: "Replace every occurrence.", hasArgs: true },
|
|
27
|
+
{ name: "not", description: "Negate truthiness." },
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Map the in-scope completion fields to the `$CHECKSTACK_*` env vars a
|
|
32
|
+
* shell script action receives. Uses the shared {@link toShellEnvKey}
|
|
33
|
+
* rule so the `$` names suggested here are exactly the ones the backend
|
|
34
|
+
* injects at run time.
|
|
35
|
+
*/
|
|
36
|
+
export function fieldsToShellEnvVars(
|
|
37
|
+
fields: readonly CompletionField[],
|
|
38
|
+
): ShellEnvVar[] {
|
|
39
|
+
// Derive from the canonical dotted `path`, NOT `templateRef`. The
|
|
40
|
+
// backend's `toShellEnvKey` injection is path-based, so deriving from
|
|
41
|
+
// `path` keeps the `$CHECKSTACK_*` names suggested here byte-identical
|
|
42
|
+
// to what the runtime injects (bracket-notation templateRefs would
|
|
43
|
+
// produce different — and wrong — env var names).
|
|
44
|
+
return fields.map((field) => ({
|
|
45
|
+
name: toShellEnvKey(field.path),
|
|
46
|
+
description: field.type ? `${field.path} (${field.type})` : field.path,
|
|
47
|
+
}));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Flatten a resolved `VariableScope` to the fields the completion
|
|
52
|
+
* provider offers in its "field" stage. Object-typed parents are
|
|
53
|
+
* skipped — operators interpolate leaf values, not whole objects — but
|
|
54
|
+
* `referenceable` nodes (arrays) are emitted alongside their element
|
|
55
|
+
* children so both `{{ ...tags }}` and `{{ ...tags[0] }}` are offered.
|
|
56
|
+
* Each field carries its `enumValues` (when the schema declared an
|
|
57
|
+
* `enum`) so the provider's "value" stage can suggest concrete
|
|
58
|
+
* comparisons.
|
|
59
|
+
*/
|
|
60
|
+
export function flattenScopeToFields(scope: VariableScope): CompletionField[] {
|
|
61
|
+
const out: CompletionField[] = [];
|
|
62
|
+
const visit = (entries: readonly VariableEntry[]): void => {
|
|
63
|
+
for (const entry of entries) {
|
|
64
|
+
// Emit a field for every leaf, plus any node explicitly flagged
|
|
65
|
+
// `referenceable` (arrays — both the whole array and its elements are
|
|
66
|
+
// insertable). Always recurse into children regardless.
|
|
67
|
+
if (!entry.children?.length || entry.referenceable) {
|
|
68
|
+
out.push({
|
|
69
|
+
path: entry.path,
|
|
70
|
+
templateRef: entry.templateRef ?? entry.path,
|
|
71
|
+
type: entry.type,
|
|
72
|
+
description: entry.description,
|
|
73
|
+
enumValues: extractEnum(entry.jsonSchema),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
if (entry.children?.length) visit(entry.children);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
visit(scope.entries);
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function extractEnum(
|
|
84
|
+
jsonSchema: Record<string, unknown> | undefined,
|
|
85
|
+
): Array<string | number | boolean> | undefined {
|
|
86
|
+
const candidate = jsonSchema?.enum;
|
|
87
|
+
if (!Array.isArray(candidate)) return undefined;
|
|
88
|
+
const usable = candidate.filter(
|
|
89
|
+
(v): v is string | number | boolean =>
|
|
90
|
+
typeof v === "string" ||
|
|
91
|
+
typeof v === "number" ||
|
|
92
|
+
typeof v === "boolean",
|
|
93
|
+
);
|
|
94
|
+
return usable.length > 0 ? usable : undefined;
|
|
95
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import type { Trigger } from "@checkstack/automation-common";
|
|
3
|
+
import {
|
|
4
|
+
assignDefaultTriggerIds,
|
|
5
|
+
collectTriggerIds,
|
|
6
|
+
defaultTriggerId,
|
|
7
|
+
} from "./trigger-helpers";
|
|
8
|
+
|
|
9
|
+
describe("trigger-helpers", () => {
|
|
10
|
+
it("derives a default id from the event when none is set", () => {
|
|
11
|
+
expect(defaultTriggerId({ event: "incident.created" }, new Set())).toBe(
|
|
12
|
+
"incident_created",
|
|
13
|
+
);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("dedupes against taken ids with numeric suffixes", () => {
|
|
17
|
+
const trigger: Trigger = { event: "incident.created" };
|
|
18
|
+
expect(defaultTriggerId(trigger, new Set(["incident_created"]))).toBe(
|
|
19
|
+
"incident_created_2",
|
|
20
|
+
);
|
|
21
|
+
expect(
|
|
22
|
+
defaultTriggerId(
|
|
23
|
+
trigger,
|
|
24
|
+
new Set(["incident_created", "incident_created_2"]),
|
|
25
|
+
),
|
|
26
|
+
).toBe("incident_created_3");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("collects only non-empty ids", () => {
|
|
30
|
+
const triggers: Trigger[] = [
|
|
31
|
+
{ event: "a", id: "x" },
|
|
32
|
+
{ event: "b" },
|
|
33
|
+
{ event: "c", id: "" },
|
|
34
|
+
];
|
|
35
|
+
expect([...collectTriggerIds(triggers)]).toEqual(["x"]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("assigns unique ids to triggers missing them, preserving existing", () => {
|
|
39
|
+
const out = assignDefaultTriggerIds([
|
|
40
|
+
{ event: "incident.created", id: "primary" },
|
|
41
|
+
{ event: "incident.created" },
|
|
42
|
+
{ event: "maintenance.created" },
|
|
43
|
+
]);
|
|
44
|
+
expect(out[0]!.id).toBe("primary");
|
|
45
|
+
expect(out[1]!.id).toBe("incident_created");
|
|
46
|
+
expect(out[2]!.id).toBe("maintenance_created");
|
|
47
|
+
expect(new Set(out.map((t) => t.id)).size).toBe(3);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("makes two triggers on the same event distinguishable", () => {
|
|
51
|
+
const out = assignDefaultTriggerIds([
|
|
52
|
+
{ event: "incident.created" },
|
|
53
|
+
{ event: "incident.created" },
|
|
54
|
+
]);
|
|
55
|
+
expect(out[0]!.id).toBe("incident_created");
|
|
56
|
+
expect(out[1]!.id).toBe("incident_created_2");
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { Trigger } from "@checkstack/automation-common";
|
|
2
|
+
import { deriveTriggerId } from "@checkstack/automation-common";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Auto-id helpers for triggers, mirroring `action-helpers` for actions.
|
|
6
|
+
*
|
|
7
|
+
* A trigger's `id` is what `trigger.id` resolves to at runtime and the
|
|
8
|
+
* discriminator that distinguishes triggers in templates / scripts - including
|
|
9
|
+
* two triggers subscribed to the same `event`. The editor assigns a unique,
|
|
10
|
+
* log-friendly default so the operator never has to, but can rename it.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Base id for a trigger: derived from its event id, falling back to `trigger`. */
|
|
14
|
+
function suggestTriggerIdBase(trigger: Trigger): string {
|
|
15
|
+
return deriveTriggerId(trigger.event) || "trigger";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Collect every assigned (non-empty) trigger id into a set. */
|
|
19
|
+
export function collectTriggerIds(
|
|
20
|
+
triggers: Trigger[],
|
|
21
|
+
into: Set<string> = new Set(),
|
|
22
|
+
): Set<string> {
|
|
23
|
+
for (const trigger of triggers) {
|
|
24
|
+
if (typeof trigger.id === "string" && trigger.id.length > 0) {
|
|
25
|
+
into.add(trigger.id);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return into;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Make `base` unique against `taken`, appending `_2`, `_3`, ... as needed. */
|
|
32
|
+
function uniqueTriggerId(base: string, taken: Set<string>): string {
|
|
33
|
+
if (!taken.has(base)) return base;
|
|
34
|
+
let n = 2;
|
|
35
|
+
while (taken.has(`${base}_${n}`)) n += 1;
|
|
36
|
+
return `${base}_${n}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A single identifier-safe, unique default id for `trigger`, deduped against
|
|
41
|
+
* `taken`. Used to seed a new trigger and to re-fill the Id field when the
|
|
42
|
+
* operator clears it.
|
|
43
|
+
*/
|
|
44
|
+
export function defaultTriggerId(
|
|
45
|
+
trigger: Trigger,
|
|
46
|
+
taken: Set<string>,
|
|
47
|
+
): string {
|
|
48
|
+
return uniqueTriggerId(suggestTriggerIdBase(trigger), taken);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Return a copy of `triggers` with a stable, unique id assigned to every
|
|
53
|
+
* trigger that does not already have one. Existing ids are preserved and seed
|
|
54
|
+
* the uniqueness set. Used when seeding the starter automation so the id is
|
|
55
|
+
* shown immediately rather than appearing blank.
|
|
56
|
+
*/
|
|
57
|
+
export function assignDefaultTriggerIds(
|
|
58
|
+
triggers: Trigger[],
|
|
59
|
+
taken: Set<string> = collectTriggerIds(triggers),
|
|
60
|
+
): Trigger[] {
|
|
61
|
+
return triggers.map((trigger) => {
|
|
62
|
+
if (typeof trigger.id === "string" && trigger.id.length > 0) return trigger;
|
|
63
|
+
const id = uniqueTriggerId(suggestTriggerIdBase(trigger), taken);
|
|
64
|
+
taken.add(id);
|
|
65
|
+
return { ...trigger, id };
|
|
66
|
+
});
|
|
67
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { usePluginClient } from "@checkstack/frontend-api";
|
|
3
|
+
import { IntegrationApi } from "@checkstack/integration-common";
|
|
4
|
+
import type { OptionsResolver } from "@checkstack/ui";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Resolver name for the connection picker itself. Mirrors the
|
|
8
|
+
* `CONNECTION_OPTIONS` constant every integration provider defines
|
|
9
|
+
* (`provider.ts` → `*_RESOLVERS.CONNECTION_OPTIONS`). The bridge resolves
|
|
10
|
+
* this one via `listConnections` (no connection is selected yet) and every
|
|
11
|
+
* other resolver name via `getConnectionOptions` (cascading dropdowns that
|
|
12
|
+
* depend on the chosen `connectionId`).
|
|
13
|
+
*/
|
|
14
|
+
const CONNECTION_RESOLVER_NAME = "connectionOptions";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Bridges a connection-backed action's `x-options-resolver` schema fields to
|
|
18
|
+
* the integration RPC contract so the automation editor's `DynamicForm` can
|
|
19
|
+
* render a working connection picker plus cascading provider dropdowns.
|
|
20
|
+
*
|
|
21
|
+
* Returns a stable, name-agnostic resolver map: the `connectionOptions`
|
|
22
|
+
* resolver lists the provider's connections, and any other resolver name is
|
|
23
|
+
* forwarded to `getConnectionOptions` for the currently selected connection,
|
|
24
|
+
* passing the live form values as `context` for dependent fields.
|
|
25
|
+
*
|
|
26
|
+
* When `connectionProviderId` is undefined (a non-connection action such as
|
|
27
|
+
* Log or Run Script), an empty map is returned so nothing tries to resolve.
|
|
28
|
+
*/
|
|
29
|
+
export function useConnectionOptionResolvers(
|
|
30
|
+
connectionProviderId?: string,
|
|
31
|
+
): Record<string, OptionsResolver> {
|
|
32
|
+
const client = usePluginClient(IntegrationApi);
|
|
33
|
+
|
|
34
|
+
return React.useMemo(() => {
|
|
35
|
+
if (!connectionProviderId) return {};
|
|
36
|
+
const providerId = connectionProviderId;
|
|
37
|
+
|
|
38
|
+
// A Proxy lets us serve a resolver for any resolver name a provider's
|
|
39
|
+
// schema references without enumerating them here; the field component
|
|
40
|
+
// only ever reads `optionsResolvers[resolverName]`.
|
|
41
|
+
const handler: ProxyHandler<Record<string, OptionsResolver>> = {
|
|
42
|
+
get: (_target, prop) => {
|
|
43
|
+
if (typeof prop !== "string") return;
|
|
44
|
+
const resolverName = prop;
|
|
45
|
+
|
|
46
|
+
const resolver: OptionsResolver = async (formValues) => {
|
|
47
|
+
if (resolverName === CONNECTION_RESOLVER_NAME) {
|
|
48
|
+
const connections = await client.listConnections.call({
|
|
49
|
+
providerId,
|
|
50
|
+
});
|
|
51
|
+
return connections.map((connection) => ({
|
|
52
|
+
value: connection.id,
|
|
53
|
+
label: connection.name,
|
|
54
|
+
}));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const connectionId = formValues.connectionId;
|
|
58
|
+
if (typeof connectionId !== "string" || connectionId.length === 0) {
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const options = await client.getConnectionOptions.call({
|
|
63
|
+
providerId,
|
|
64
|
+
connectionId,
|
|
65
|
+
resolverName,
|
|
66
|
+
context: formValues,
|
|
67
|
+
});
|
|
68
|
+
return options.map((option) => ({
|
|
69
|
+
value: option.value,
|
|
70
|
+
label: option.label,
|
|
71
|
+
}));
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
return resolver;
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
return new Proxy<Record<string, OptionsResolver>>({}, handler);
|
|
79
|
+
}, [client, connectionProviderId]);
|
|
80
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { parseDocument } from "yaml";
|
|
2
|
+
import type { EditorMarker } from "@checkstack/ui";
|
|
3
|
+
|
|
4
|
+
export interface DefinitionIssue {
|
|
5
|
+
path: Array<string | number>;
|
|
6
|
+
message: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Map definition validation issues (and YAML syntax errors) onto inline
|
|
11
|
+
* Monaco markers so the editor squiggles the exact offending node
|
|
12
|
+
* instead of listing errors in a side panel.
|
|
13
|
+
*
|
|
14
|
+
* For each semantic issue we navigate the YAML document to the node at
|
|
15
|
+
* the issue's `path` and use its source range. When the node doesn't
|
|
16
|
+
* exist (e.g. a missing required field), we walk up the path to the
|
|
17
|
+
* nearest existing ancestor and mark that — so the operator is pointed
|
|
18
|
+
* at the right block even when the key itself is absent.
|
|
19
|
+
*
|
|
20
|
+
* Syntax errors come straight from the parser's own `errors` (which
|
|
21
|
+
* carry source offsets), so malformed YAML is squiggled too.
|
|
22
|
+
*/
|
|
23
|
+
export function computeYamlMarkers(
|
|
24
|
+
yamlText: string,
|
|
25
|
+
issues: DefinitionIssue[],
|
|
26
|
+
): EditorMarker[] {
|
|
27
|
+
const doc = parseDocument(yamlText, { keepSourceTokens: true });
|
|
28
|
+
const markers: EditorMarker[] = [];
|
|
29
|
+
|
|
30
|
+
// 1. Syntax errors from the parser.
|
|
31
|
+
for (const error of doc.errors) {
|
|
32
|
+
const [start, end] = error.pos;
|
|
33
|
+
markers.push(rangeToMarker(yamlText, start, end, error.message));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// 2. Semantic issues mapped to their node's range.
|
|
37
|
+
for (const issue of issues) {
|
|
38
|
+
const range = resolveRange(doc, issue.path);
|
|
39
|
+
if (range) {
|
|
40
|
+
markers.push(
|
|
41
|
+
rangeToMarker(yamlText, range[0], range[1], issue.message),
|
|
42
|
+
);
|
|
43
|
+
} else {
|
|
44
|
+
// No node anywhere along the path — fall back to the document start.
|
|
45
|
+
markers.push({
|
|
46
|
+
startLineNumber: 1,
|
|
47
|
+
startColumn: 1,
|
|
48
|
+
endLineNumber: 1,
|
|
49
|
+
endColumn: 2,
|
|
50
|
+
message: `${issue.path.join(".") || "(root)"}: ${issue.message}`,
|
|
51
|
+
severity: "error",
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return markers;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
type ParsedDocument = ReturnType<typeof parseDocument>;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Find a source range for `path`, walking up to the nearest existing
|
|
63
|
+
* ancestor when the exact node is missing.
|
|
64
|
+
*/
|
|
65
|
+
function resolveRange(
|
|
66
|
+
doc: ParsedDocument,
|
|
67
|
+
path: Array<string | number>,
|
|
68
|
+
): [number, number] | null {
|
|
69
|
+
for (let length = path.length; length >= 0; length--) {
|
|
70
|
+
const node =
|
|
71
|
+
length === 0
|
|
72
|
+
? doc.contents
|
|
73
|
+
: (doc.getIn(path.slice(0, length), true) as
|
|
74
|
+
| { range?: [number, number, number] | null }
|
|
75
|
+
| undefined);
|
|
76
|
+
const range = node?.range;
|
|
77
|
+
if (range) return [range[0], range[1]];
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function rangeToMarker(
|
|
83
|
+
text: string,
|
|
84
|
+
start: number,
|
|
85
|
+
end: number,
|
|
86
|
+
message: string,
|
|
87
|
+
): EditorMarker {
|
|
88
|
+
const from = offsetToLineCol(text, start);
|
|
89
|
+
// Guarantee a non-empty highlight even for zero-width ranges.
|
|
90
|
+
const to = offsetToLineCol(text, Math.max(end, start + 1));
|
|
91
|
+
return {
|
|
92
|
+
startLineNumber: from.line,
|
|
93
|
+
startColumn: from.column,
|
|
94
|
+
endLineNumber: to.line,
|
|
95
|
+
endColumn: to.column,
|
|
96
|
+
message,
|
|
97
|
+
severity: "error",
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Convert a 0-based character offset to a 1-based Monaco line/column. */
|
|
102
|
+
function offsetToLineCol(
|
|
103
|
+
text: string,
|
|
104
|
+
offset: number,
|
|
105
|
+
): { line: number; column: number } {
|
|
106
|
+
const clamped = Math.max(0, Math.min(offset, text.length));
|
|
107
|
+
let line = 1;
|
|
108
|
+
let lastLineStart = 0;
|
|
109
|
+
for (let i = 0; i < clamped; i++) {
|
|
110
|
+
if (text[i] === "\n") {
|
|
111
|
+
line += 1;
|
|
112
|
+
lastLineStart = i + 1;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return { line, column: clamped - lastLineStart + 1 };
|
|
116
|
+
}
|
package/src/index.tsx
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createFrontendPlugin,
|
|
3
|
+
createSlotExtension,
|
|
4
|
+
UserMenuItemsSlot,
|
|
5
|
+
} from "@checkstack/frontend-api";
|
|
6
|
+
import {
|
|
7
|
+
automationRoutes,
|
|
8
|
+
pluginMetadata,
|
|
9
|
+
automationAccess,
|
|
10
|
+
} from "@checkstack/automation-common";
|
|
11
|
+
import { AutomationListPage } from "./pages/AutomationListPage";
|
|
12
|
+
import { AutomationEditPage } from "./pages/AutomationEditPage";
|
|
13
|
+
import { RunsPage } from "./pages/RunsPage";
|
|
14
|
+
import { RunDetailPage } from "./pages/RunDetailPage";
|
|
15
|
+
import { TemplatePlaygroundPage } from "./pages/TemplatePlaygroundPage";
|
|
16
|
+
import { AutomationMenuItems } from "./components/AutomationMenuItems";
|
|
17
|
+
|
|
18
|
+
export {
|
|
19
|
+
generateAutomationContextTypes,
|
|
20
|
+
type GenerateAutomationContextTypesInput,
|
|
21
|
+
type GenerateAutomationContextTypesResult,
|
|
22
|
+
} from "./script-context";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Frontend plugin for the automation platform.
|
|
26
|
+
*
|
|
27
|
+
* Routes:
|
|
28
|
+
*
|
|
29
|
+
* - `/automation/` → list view
|
|
30
|
+
* - `/automation/new` → blank edit page (create)
|
|
31
|
+
* - `/automation/:automationId` → edit page
|
|
32
|
+
* - `/automation/:automationId/runs` → run history
|
|
33
|
+
* - `/automation/:automationId/runs/:runId` → single run drill-down
|
|
34
|
+
* - `/automation/playground` → template playground
|
|
35
|
+
*
|
|
36
|
+
* Run history pages and the playground are gated on `automation.read`;
|
|
37
|
+
* everything that mutates state (create/edit/delete/toggle, manual run,
|
|
38
|
+
* cancel run) further requires `automation.manage`. The edit page
|
|
39
|
+
* downgrades gracefully — viewers can read the YAML but the Save / Run
|
|
40
|
+
* Now / Delete controls disappear.
|
|
41
|
+
*
|
|
42
|
+
* No `foreignSignals` declared: every signal the automation domain
|
|
43
|
+
* emits (`AUTOMATION_DEFINITION_CHANGED`, `AUTOMATION_RUN_*`) is owned
|
|
44
|
+
* by this plugin, so the auto-invalidator wires it up for free. If
|
|
45
|
+
* cross-domain signals matter later (e.g. invalidating the run list
|
|
46
|
+
* when a remote incident closes), declare them here.
|
|
47
|
+
*/
|
|
48
|
+
export default createFrontendPlugin({
|
|
49
|
+
metadata: pluginMetadata,
|
|
50
|
+
routes: [
|
|
51
|
+
{
|
|
52
|
+
route: automationRoutes.routes.list,
|
|
53
|
+
element: <AutomationListPage />,
|
|
54
|
+
title: "Automations",
|
|
55
|
+
accessRule: automationAccess.read,
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
route: automationRoutes.routes.create,
|
|
59
|
+
element: <AutomationEditPage />,
|
|
60
|
+
title: "New automation",
|
|
61
|
+
accessRule: automationAccess.manage,
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
route: automationRoutes.routes.edit,
|
|
65
|
+
element: <AutomationEditPage />,
|
|
66
|
+
title: "Edit automation",
|
|
67
|
+
accessRule: automationAccess.read,
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
route: automationRoutes.routes.runs,
|
|
71
|
+
element: <RunsPage />,
|
|
72
|
+
title: "Run history",
|
|
73
|
+
accessRule: automationAccess.read,
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
route: automationRoutes.routes.runDetail,
|
|
77
|
+
element: <RunDetailPage />,
|
|
78
|
+
title: "Run details",
|
|
79
|
+
accessRule: automationAccess.read,
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
route: automationRoutes.routes.playground,
|
|
83
|
+
element: <TemplatePlaygroundPage />,
|
|
84
|
+
title: "Template playground",
|
|
85
|
+
accessRule: automationAccess.read,
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
extensions: [
|
|
89
|
+
createSlotExtension(UserMenuItemsSlot, {
|
|
90
|
+
id: "automation.user-menu.items",
|
|
91
|
+
component: AutomationMenuItems,
|
|
92
|
+
metadata: { group: "Automation" },
|
|
93
|
+
}),
|
|
94
|
+
],
|
|
95
|
+
});
|