@checkstack/automation-frontend 0.12.7 → 0.13.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 +172 -0
- package/package.json +17 -17
- package/src/components/RunAsServiceAccountPicker.tsx +1 -1
- package/src/editor/action-leaf-cards.tsx +1 -1
- package/src/pages/AutomationEditPage.tsx +1 -1
- package/src/pages/AutomationListPage.tsx +61 -21
- package/src/pages/AutomationTemplatePickerPage.tsx +1 -1
- package/src/pages/RunDetailPage.tsx +1 -1
- package/src/pages/RunsPage.tsx +73 -37
- package/src/pages/automation-grouping.test.ts +38 -1
- package/src/pages/automation-grouping.ts +29 -0
- package/src/pages/run-status-pill.tsx +18 -45
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,177 @@
|
|
|
1
1
|
# @checkstack/automation-frontend
|
|
2
2
|
|
|
3
|
+
## 0.13.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 56e5375: Migrate the frontend from react-router-dom v7 to react-router v8
|
|
8
|
+
|
|
9
|
+
Resolves GHSA-qwww-vcr4-c8h2 (HIGH): React Router before 8.3.0 has an RSC-mode
|
|
10
|
+
CSRF bypass that lets an action execute before the 400 response. Checkstack runs
|
|
11
|
+
a client-side SPA (`<BrowserRouter>`) and does not use RSC mode, so the platform
|
|
12
|
+
was not exploitable through it - but the advisory kept the dependency-graph
|
|
13
|
+
security gate red on every pull request, and the fix is only available in the 8.x
|
|
14
|
+
line, which the auto-remediation deliberately will not reach (it refuses major
|
|
15
|
+
bumps).
|
|
16
|
+
|
|
17
|
+
`react-router-dom` has no v8: it was folded into `react-router` in v7 and v8
|
|
18
|
+
ships as `react-router` only. So this is a package swap rather than a range bump:
|
|
19
|
+
|
|
20
|
+
- 31 packages now depend on `react-router@^8.3.0` instead of
|
|
21
|
+
`react-router-dom@^7.16.0`, and 97 source files import from `react-router`.
|
|
22
|
+
- The Module Federation host share, `optimizeDeps` and `dedupe` entries move to
|
|
23
|
+
`react-router` (shared singleton `requiredVersion` `^8.0.0`). Remotes never
|
|
24
|
+
shared the router, so the remote contract is unchanged.
|
|
25
|
+
- The syncpack unified-range group tracks `react-router`, keeping the enforced
|
|
26
|
+
single-range guarantee that a past four-range regression motivated.
|
|
27
|
+
|
|
28
|
+
The API surface Checkstack uses is unchanged between v7 and v8 - `BrowserRouter`,
|
|
29
|
+
`MemoryRouter`, `Routes`, `Route`, `Link`, `NavLink`, `useLocation`,
|
|
30
|
+
`useNavigate`, `useParams` and `useSearchParams` are all exported by v8 with the
|
|
31
|
+
same signatures - so no routing code changed beyond the import specifier. v8
|
|
32
|
+
requires React >= 19.2.7, which the workspace already pins.
|
|
33
|
+
|
|
34
|
+
### Patch Changes
|
|
35
|
+
|
|
36
|
+
- Updated dependencies [88f4333]
|
|
37
|
+
- Updated dependencies [1deaac5]
|
|
38
|
+
- Updated dependencies [88f4333]
|
|
39
|
+
- Updated dependencies [88f4333]
|
|
40
|
+
- Updated dependencies [88f4333]
|
|
41
|
+
- Updated dependencies [88f4333]
|
|
42
|
+
- Updated dependencies [88f4333]
|
|
43
|
+
- Updated dependencies [88f4333]
|
|
44
|
+
- Updated dependencies [88f4333]
|
|
45
|
+
- Updated dependencies [1deaac5]
|
|
46
|
+
- Updated dependencies [56e5375]
|
|
47
|
+
- Updated dependencies [88f4333]
|
|
48
|
+
- Updated dependencies [88f4333]
|
|
49
|
+
- @checkstack/auth-frontend@0.16.0
|
|
50
|
+
- @checkstack/common@0.24.0
|
|
51
|
+
- @checkstack/auth-common@0.17.0
|
|
52
|
+
- @checkstack/automation-common@0.10.3
|
|
53
|
+
- @checkstack/ui@1.31.0
|
|
54
|
+
- @checkstack/frontend-api@0.18.0
|
|
55
|
+
- @checkstack/gitops-frontend@0.8.0
|
|
56
|
+
- @checkstack/script-packages-frontend@0.5.0
|
|
57
|
+
- @checkstack/secrets-frontend@0.4.0
|
|
58
|
+
- @checkstack/ai-common@0.6.8
|
|
59
|
+
- @checkstack/catalog-common@2.8.2
|
|
60
|
+
- @checkstack/integration-common@0.9.11
|
|
61
|
+
- @checkstack/template-engine@0.4.13
|
|
62
|
+
- @checkstack/signal-frontend@0.3.8
|
|
63
|
+
|
|
64
|
+
## 0.12.8
|
|
65
|
+
|
|
66
|
+
### Patch Changes
|
|
67
|
+
|
|
68
|
+
- be74b01: Migrate the automation surfaces onto the shared filter bar, and dedupe useDebouncedValue
|
|
69
|
+
|
|
70
|
+
Follows the native `DataTable` facet API with the first wave of migrations.
|
|
71
|
+
|
|
72
|
+
- `DataTableFacet` gains `kind: "select" | "pills"`. A segmented pill row is the
|
|
73
|
+
right control for two or three short options a reader benefits from seeing at
|
|
74
|
+
a glance, and several surfaces had independently built one - so the shared bar
|
|
75
|
+
renders that variant rather than forcing every list into a dropdown. Both
|
|
76
|
+
variants share one state, sentinel and URL round-trip, and the pills set
|
|
77
|
+
`aria-pressed`, which two of the hand-rolled groups they replace had omitted.
|
|
78
|
+
- `parsedFacetValue` reads a facet's selection back as a domain value by parsing
|
|
79
|
+
it against the schema that defines it. Facet state is stringly-typed because
|
|
80
|
+
it round-trips through the URL, but a server-side filter needs the narrow union
|
|
81
|
+
its query input declares; parsing rather than casting means a stale link
|
|
82
|
+
degrades to unconstrained instead of smuggling an unknown value into a request.
|
|
83
|
+
- The automation list and run-history pages drop their hand-rolled status pill
|
|
84
|
+
rows for the shared bar. Their filters now persist to the URL, so a link to
|
|
85
|
+
"the failed runs of this automation" reopens filtered. The run-history table
|
|
86
|
+
also gains the `surface={false}` it was missing, fixing a panel-in-panel.
|
|
87
|
+
- `useDebouncedValue` had been copied verbatim into six plugin packages, each
|
|
88
|
+
with a comment noting no shared version existed. All six now import the one in
|
|
89
|
+
`@checkstack/ui` and the copies are deleted.
|
|
90
|
+
|
|
91
|
+
- be74b01: Move every table's filters into the table itself
|
|
92
|
+
|
|
93
|
+
The earlier migration unified how filter controls are BUILT but left several
|
|
94
|
+
rendering above their table as a detached bar, justified by the filtering
|
|
95
|
+
running server-side. That justification was wrong: where the narrowing runs says
|
|
96
|
+
nothing about where the control belongs, and a bar floating above a card reads
|
|
97
|
+
as unrelated to the list under it.
|
|
98
|
+
|
|
99
|
+
Now in the table's own bar:
|
|
100
|
+
|
|
101
|
+
- **Incidents** and **maintenances** - the Status column declares `filterValue`,
|
|
102
|
+
so the control sits with the column it filters. The selection still narrows
|
|
103
|
+
the list query, which is what actually reduces the fetch; the column filter
|
|
104
|
+
re-applying it over already-scoped rows is a harmless no-op.
|
|
105
|
+
- **Automation run history** - same, with the status pills.
|
|
106
|
+
- **Health-check list** - search, strategy and status move onto their columns.
|
|
107
|
+
The assigned-system control has no row to read (selecting a system swaps the
|
|
108
|
+
data source, which is what makes the catalog's per-system link work without
|
|
109
|
+
health-check grants), so it rides in as a control-only facet.
|
|
110
|
+
- **Health-check drawer** - the run-status control moves into the runs table.
|
|
111
|
+
|
|
112
|
+
`DataTable`'s `facets` now accepts a control WITHOUT a row accessor, rendered but
|
|
113
|
+
not applied. That is what lets a server-applied dimension stay in the table's bar
|
|
114
|
+
instead of forcing a second bar onto the page.
|
|
115
|
+
|
|
116
|
+
Fixes a trap the move exposed: with server-side filtering an empty `data` means
|
|
117
|
+
either "none exist" or "none match", and three of these pages rendered their
|
|
118
|
+
onboarding empty state either way - automation's run history replaced the whole
|
|
119
|
+
table, taking the filter controls with it, so a filter matching nothing could not
|
|
120
|
+
be cleared. Each now suppresses its `emptyState` while a filter is active and
|
|
121
|
+
offers a "no matches, clear filters" state instead.
|
|
122
|
+
|
|
123
|
+
Three surfaces deliberately keep an external bar, each narrowing more than one
|
|
124
|
+
list: the catalog toolbar (a browse grid plus three manage tabs), the automation
|
|
125
|
+
list (one table per accordion group), and the health-check drawer's source
|
|
126
|
+
control (it scopes the charts as well as the runs). The history detail page's
|
|
127
|
+
list is not a `DataTable` at all.
|
|
128
|
+
|
|
129
|
+
- be74b01: Let you search the incident, maintenance and automation lists
|
|
130
|
+
|
|
131
|
+
These three management lists had their search box switched off, on the
|
|
132
|
+
assumption that you find a record by status rather than by typing its name. That
|
|
133
|
+
was wrong: every one of them shows a title or name column, and none of the
|
|
134
|
+
underlying queries paginate, so the full set is already in the browser and there
|
|
135
|
+
was nothing to gain by withholding search.
|
|
136
|
+
|
|
137
|
+
- **Incidents** gain a search box and a **severity filter**. Severity had a
|
|
138
|
+
column and a sort but no filter, so "show me the criticals" needed reading the
|
|
139
|
+
whole table. Its options are ordered by impact, matching how the column sorts;
|
|
140
|
+
deriving them would have sorted alphabetically as critical / major / minor.
|
|
141
|
+
- **Maintenances** gain a search box.
|
|
142
|
+
- **Automations** gain a search box. It is page-level rather than table-level
|
|
143
|
+
because that list renders one table per group - a table-owned box would only
|
|
144
|
+
ever search its own group, so a match inside a collapsed group could never
|
|
145
|
+
surface. Filtering ahead of the grouping also makes groups with no match
|
|
146
|
+
disappear instead of leaving a wall of empty accordions.
|
|
147
|
+
|
|
148
|
+
All three searches match the title/name AND the second line the row renders (a
|
|
149
|
+
description, or the group label), so a search matches the words you can actually
|
|
150
|
+
see on the row.
|
|
151
|
+
|
|
152
|
+
- Updated dependencies [be74b01]
|
|
153
|
+
- Updated dependencies [be5c907]
|
|
154
|
+
- Updated dependencies [be74b01]
|
|
155
|
+
- Updated dependencies [be74b01]
|
|
156
|
+
- Updated dependencies [be74b01]
|
|
157
|
+
- Updated dependencies [be74b01]
|
|
158
|
+
- Updated dependencies [be74b01]
|
|
159
|
+
- Updated dependencies [be74b01]
|
|
160
|
+
- Updated dependencies [be74b01]
|
|
161
|
+
- Updated dependencies [be74b01]
|
|
162
|
+
- Updated dependencies [be74b01]
|
|
163
|
+
- Updated dependencies [be74b01]
|
|
164
|
+
- Updated dependencies [be74b01]
|
|
165
|
+
- Updated dependencies [be74b01]
|
|
166
|
+
- @checkstack/ui@1.30.0
|
|
167
|
+
- @checkstack/auth-frontend@0.15.0
|
|
168
|
+
- @checkstack/script-packages-frontend@0.4.18
|
|
169
|
+
- @checkstack/gitops-frontend@0.7.9
|
|
170
|
+
- @checkstack/secrets-frontend@0.3.17
|
|
171
|
+
- @checkstack/auth-common@0.16.0
|
|
172
|
+
- @checkstack/frontend-api@0.17.0
|
|
173
|
+
- @checkstack/catalog-common@2.8.1
|
|
174
|
+
|
|
3
175
|
## 0.12.7
|
|
4
176
|
|
|
5
177
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/automation-frontend",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"license": "Elastic-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": [
|
|
@@ -16,33 +16,33 @@
|
|
|
16
16
|
"lint:code": "eslint . --max-warnings 0"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@checkstack/auth-common": "0.
|
|
20
|
-
"@checkstack/auth-frontend": "0.
|
|
21
|
-
"@checkstack/automation-common": "0.10.
|
|
22
|
-
"@checkstack/ai-common": "0.6.
|
|
23
|
-
"@checkstack/catalog-common": "2.8.
|
|
24
|
-
"@checkstack/common": "0.
|
|
25
|
-
"@checkstack/frontend-api": "0.
|
|
26
|
-
"@checkstack/gitops-frontend": "0.
|
|
27
|
-
"@checkstack/integration-common": "0.9.
|
|
28
|
-
"@checkstack/script-packages-frontend": "0.
|
|
29
|
-
"@checkstack/secrets-frontend": "0.
|
|
30
|
-
"@checkstack/signal-frontend": "0.3.
|
|
31
|
-
"@checkstack/template-engine": "0.4.
|
|
32
|
-
"@checkstack/ui": "1.
|
|
19
|
+
"@checkstack/auth-common": "0.17.0",
|
|
20
|
+
"@checkstack/auth-frontend": "0.16.0",
|
|
21
|
+
"@checkstack/automation-common": "0.10.3",
|
|
22
|
+
"@checkstack/ai-common": "0.6.8",
|
|
23
|
+
"@checkstack/catalog-common": "2.8.2",
|
|
24
|
+
"@checkstack/common": "0.24.0",
|
|
25
|
+
"@checkstack/frontend-api": "0.18.0",
|
|
26
|
+
"@checkstack/gitops-frontend": "0.8.0",
|
|
27
|
+
"@checkstack/integration-common": "0.9.11",
|
|
28
|
+
"@checkstack/script-packages-frontend": "0.5.0",
|
|
29
|
+
"@checkstack/secrets-frontend": "0.4.0",
|
|
30
|
+
"@checkstack/signal-frontend": "0.3.8",
|
|
31
|
+
"@checkstack/template-engine": "0.4.13",
|
|
32
|
+
"@checkstack/ui": "1.31.0",
|
|
33
33
|
"@dnd-kit/core": "^6.3.1",
|
|
34
34
|
"@dnd-kit/sortable": "^8.0.0",
|
|
35
35
|
"@dnd-kit/utilities": "^3.2.2",
|
|
36
36
|
"date-fns": "^4.4.0",
|
|
37
37
|
"lucide-react": "^1.17.0",
|
|
38
38
|
"react": "19.2.7",
|
|
39
|
-
"react-router
|
|
39
|
+
"react-router": "^8.3.0",
|
|
40
40
|
"yaml": "^2.6.1"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"typescript": "^5.0.0",
|
|
44
44
|
"@types/react": "^19.0.0",
|
|
45
45
|
"@checkstack/tsconfig": "0.0.7",
|
|
46
|
-
"@checkstack/scripts": "0.7.
|
|
46
|
+
"@checkstack/scripts": "0.7.7"
|
|
47
47
|
}
|
|
48
48
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
-
import { Link } from "react-router
|
|
2
|
+
import { Link } from "react-router";
|
|
3
3
|
import { ExternalLink, KeyRound, ShieldCheck } from "lucide-react";
|
|
4
4
|
import { usePluginClient } from "@checkstack/frontend-api";
|
|
5
5
|
import { AuthApi, authRoutes } from "@checkstack/auth-common";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
-
import { Link, useNavigate } from "react-router
|
|
2
|
+
import { Link, useNavigate } from "react-router";
|
|
3
3
|
import { Workflow, Plus, FlaskConical, Trash2, History } from "lucide-react";
|
|
4
4
|
import {
|
|
5
5
|
usePluginClient,
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
automationAccess,
|
|
14
14
|
automationRoutes,
|
|
15
15
|
automationResourceTypes,
|
|
16
|
+
AutomationStatusSchema,
|
|
16
17
|
type Automation,
|
|
17
18
|
} from "@checkstack/automation-common";
|
|
18
19
|
import {
|
|
@@ -25,7 +26,11 @@ import {
|
|
|
25
26
|
Badge,
|
|
26
27
|
Toggle,
|
|
27
28
|
DataTable,
|
|
29
|
+
DataTableFilterBar,
|
|
30
|
+
useDataTableFilters,
|
|
31
|
+
parsedFacetValue,
|
|
28
32
|
type DataTableColumn,
|
|
33
|
+
type DataTableFacet,
|
|
29
34
|
RowActions,
|
|
30
35
|
RowAction,
|
|
31
36
|
LoadingSpinner,
|
|
@@ -42,7 +47,10 @@ import {
|
|
|
42
47
|
} from "@checkstack/ui";
|
|
43
48
|
import { resolveRoute } from "@checkstack/common";
|
|
44
49
|
import { formatDistanceToNow } from "date-fns";
|
|
45
|
-
import {
|
|
50
|
+
import {
|
|
51
|
+
filterAutomationsByQuery,
|
|
52
|
+
groupAutomations,
|
|
53
|
+
} from "./automation-grouping";
|
|
46
54
|
|
|
47
55
|
/**
|
|
48
56
|
* Left status-accent class for an automation's enabled/disabled state. Reuses
|
|
@@ -67,6 +75,23 @@ const automationAccent = (status: Automation["status"]): string =>
|
|
|
67
75
|
* Cache shape of the `listAutomations` loader: the paginated wrapper around the
|
|
68
76
|
* automation rows. Used to type the optimistic snapshot/patch on the toggle.
|
|
69
77
|
*/
|
|
78
|
+
/**
|
|
79
|
+
* Enabled/disabled, as pills: two short options a reader benefits from seeing
|
|
80
|
+
* at a glance. `value` mirrors what the server filters on, so the facet stays
|
|
81
|
+
* meaningful if this list ever filters client-side.
|
|
82
|
+
*/
|
|
83
|
+
const AUTOMATION_STATUS_FACET: DataTableFacet<Automation> = {
|
|
84
|
+
id: "status",
|
|
85
|
+
label: "Status",
|
|
86
|
+
anyLabel: "All",
|
|
87
|
+
kind: "pills",
|
|
88
|
+
options: [
|
|
89
|
+
{ value: "enabled", label: "Enabled" },
|
|
90
|
+
{ value: "disabled", label: "Disabled" },
|
|
91
|
+
],
|
|
92
|
+
value: (automation) => automation.status,
|
|
93
|
+
};
|
|
94
|
+
|
|
70
95
|
type AutomationsQueryData = {
|
|
71
96
|
items: Automation[];
|
|
72
97
|
};
|
|
@@ -93,9 +118,16 @@ const AutomationListContent: React.FC = () => {
|
|
|
93
118
|
AutomationApi.contract.createAutomation,
|
|
94
119
|
);
|
|
95
120
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
121
|
+
// Filtering is server-side (it narrows the list query's input), so the table
|
|
122
|
+
// itself declares no facets - the shared filter bar drives the query. The
|
|
123
|
+
// state still lives in the URL, so a filtered list is shareable and survives
|
|
124
|
+
// opening an automation and coming back.
|
|
125
|
+
const filters = useDataTableFilters({ facetIds: [AUTOMATION_STATUS_FACET.id] });
|
|
126
|
+
const statusFilter = parsedFacetValue({
|
|
127
|
+
filters: filters.state,
|
|
128
|
+
facetId: AUTOMATION_STATUS_FACET.id,
|
|
129
|
+
schema: AutomationStatusSchema,
|
|
130
|
+
});
|
|
99
131
|
const [deleteId, setDeleteId] = React.useState<string | undefined>();
|
|
100
132
|
|
|
101
133
|
// The loader input depends on the status filter, so the cache key changes
|
|
@@ -106,7 +138,7 @@ const AutomationListContent: React.FC = () => {
|
|
|
106
138
|
() => ({
|
|
107
139
|
limit: 100,
|
|
108
140
|
offset: 0,
|
|
109
|
-
...(statusFilter ===
|
|
141
|
+
...(statusFilter === undefined ? {} : { status: statusFilter }),
|
|
110
142
|
}),
|
|
111
143
|
[statusFilter],
|
|
112
144
|
);
|
|
@@ -189,9 +221,21 @@ const AutomationListContent: React.FC = () => {
|
|
|
189
221
|
});
|
|
190
222
|
|
|
191
223
|
// Collapsible sections, sorted alphabetically with "Ungrouped" last.
|
|
224
|
+
// Search narrows BEFORE grouping, so a match inside a collapsed group still
|
|
225
|
+
// surfaces and groups left with nothing simply disappear. Debounced: a fast
|
|
226
|
+
// typist should not re-group a long list on every keystroke.
|
|
227
|
+
const matchedAutomations = React.useMemo(
|
|
228
|
+
() =>
|
|
229
|
+
filterAutomationsByQuery({
|
|
230
|
+
automations,
|
|
231
|
+
query: filters.debounced.query,
|
|
232
|
+
}),
|
|
233
|
+
[automations, filters.debounced.query],
|
|
234
|
+
);
|
|
235
|
+
|
|
192
236
|
const groups = React.useMemo(
|
|
193
|
-
() => groupAutomations({ automations }),
|
|
194
|
-
[
|
|
237
|
+
() => groupAutomations({ automations: matchedAutomations }),
|
|
238
|
+
[matchedAutomations],
|
|
195
239
|
);
|
|
196
240
|
// All sections expanded by default so nothing is hidden on first load.
|
|
197
241
|
const allGroupKeys = React.useMemo(() => groups.map((g) => g.key), [groups]);
|
|
@@ -478,19 +522,15 @@ const AutomationListContent: React.FC = () => {
|
|
|
478
522
|
<CardHeader className="border-b">
|
|
479
523
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
|
480
524
|
<CardTitle className="text-base">All automations</CardTitle>
|
|
481
|
-
<
|
|
482
|
-
{
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
{option}
|
|
491
|
-
</Button>
|
|
492
|
-
))}
|
|
493
|
-
</div>
|
|
525
|
+
<DataTableFilterBar
|
|
526
|
+
filters={filters.state}
|
|
527
|
+
onFiltersChange={filters.setState}
|
|
528
|
+
facets={[AUTOMATION_STATUS_FACET]}
|
|
529
|
+
// The list groups into several tables, so there is no single one
|
|
530
|
+
// to search; the status facet is the whole control here.
|
|
531
|
+
searchable={false}
|
|
532
|
+
className="md:justify-end"
|
|
533
|
+
/>
|
|
494
534
|
</div>
|
|
495
535
|
</CardHeader>
|
|
496
536
|
<CardContent className="p-0">
|
package/src/pages/RunsPage.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
-
import { Link, useParams, useNavigate } from "react-router
|
|
2
|
+
import { Link, useParams, useNavigate } from "react-router";
|
|
3
3
|
import { History, ChevronLeft, ArrowUpRight } from "lucide-react";
|
|
4
4
|
import {
|
|
5
5
|
usePluginClient,
|
|
@@ -11,8 +11,9 @@ import {
|
|
|
11
11
|
AutomationApi,
|
|
12
12
|
automationAccess,
|
|
13
13
|
automationRoutes,
|
|
14
|
+
RunStatusSchema,
|
|
14
15
|
} from "@checkstack/automation-common";
|
|
15
|
-
import type {
|
|
16
|
+
import type { AutomationRun } from "@checkstack/automation-common";
|
|
16
17
|
import {
|
|
17
18
|
PageLayout,
|
|
18
19
|
Card,
|
|
@@ -21,7 +22,10 @@ import {
|
|
|
21
22
|
CardTitle,
|
|
22
23
|
Button,
|
|
23
24
|
DataTable,
|
|
25
|
+
useDataTableFilters,
|
|
26
|
+
parsedFacetValue,
|
|
24
27
|
type DataTableColumn,
|
|
28
|
+
type DataTableFacetOption,
|
|
25
29
|
RowActions,
|
|
26
30
|
RowAction,
|
|
27
31
|
LoadingSpinner,
|
|
@@ -33,6 +37,20 @@ import { formatDistanceToNow } from "date-fns";
|
|
|
33
37
|
import { RunStatusPill } from "./run-status-pill";
|
|
34
38
|
import { formatDuration } from "./run-duration";
|
|
35
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Run outcome, as pills: the whole point of this page is scanning for the runs
|
|
42
|
+
* that went wrong, so every outcome stays one click away rather than behind a
|
|
43
|
+
* dropdown. Options mirror `RunStatusSchema`, which is what the query filters on.
|
|
44
|
+
*/
|
|
45
|
+
const RUN_STATUS_FACET_ID = "status";
|
|
46
|
+
const RUN_STATUS_OPTIONS: readonly DataTableFacetOption[] = [
|
|
47
|
+
{ value: "running", label: "Running" },
|
|
48
|
+
{ value: "success", label: "Success" },
|
|
49
|
+
{ value: "failed", label: "Failed" },
|
|
50
|
+
{ value: "cancelled", label: "Cancelled" },
|
|
51
|
+
{ value: "waiting", label: "Waiting" },
|
|
52
|
+
];
|
|
53
|
+
|
|
36
54
|
/**
|
|
37
55
|
* Run history for a single automation. Status filter pinned to the top;
|
|
38
56
|
* rows link to the run detail page. We also surface a `← Back to
|
|
@@ -47,9 +65,15 @@ const RunsPageContent: React.FC = () => {
|
|
|
47
65
|
const { allowed, loading: accessLoading } = accessApi.useAccess(
|
|
48
66
|
automationAccess.read,
|
|
49
67
|
);
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
68
|
+
// The Status column owns the control; the selection ALSO narrows the runs
|
|
69
|
+
// query, which is what reduces the fetch. URL-backed, so a link to "the failed
|
|
70
|
+
// runs of this automation" reopens filtered.
|
|
71
|
+
const filters = useDataTableFilters({ facetIds: [RUN_STATUS_FACET_ID] });
|
|
72
|
+
const statusFilter = parsedFacetValue({
|
|
73
|
+
filters: filters.state,
|
|
74
|
+
facetId: RUN_STATUS_FACET_ID,
|
|
75
|
+
schema: RunStatusSchema,
|
|
76
|
+
});
|
|
53
77
|
|
|
54
78
|
const automationQuery = client.getAutomation.useQuery(
|
|
55
79
|
{ id: automationId ?? "" },
|
|
@@ -63,7 +87,7 @@ const RunsPageContent: React.FC = () => {
|
|
|
63
87
|
{
|
|
64
88
|
automationId: automationId ?? "",
|
|
65
89
|
limit: 50,
|
|
66
|
-
...(statusFilter ===
|
|
90
|
+
...(statusFilter === undefined ? {} : { status: statusFilter }),
|
|
67
91
|
},
|
|
68
92
|
{ enabled: Boolean(automationId) },
|
|
69
93
|
);
|
|
@@ -75,6 +99,12 @@ const RunsPageContent: React.FC = () => {
|
|
|
75
99
|
id: "status",
|
|
76
100
|
header: "Status",
|
|
77
101
|
sortValue: (run) => run.status,
|
|
102
|
+
filterValue: (run) => run.status,
|
|
103
|
+
filterOptions: RUN_STATUS_OPTIONS,
|
|
104
|
+
// Pills: the whole point of this page is scanning for the runs that went
|
|
105
|
+
// wrong, so every outcome stays one click away.
|
|
106
|
+
filterKind: "pills",
|
|
107
|
+
filterAnyLabel: "All",
|
|
78
108
|
cell: (run) => <RunStatusPill status={run.status} />,
|
|
79
109
|
},
|
|
80
110
|
{
|
|
@@ -162,31 +192,7 @@ const RunsPageContent: React.FC = () => {
|
|
|
162
192
|
>
|
|
163
193
|
<Card>
|
|
164
194
|
<CardHeader className="border-b">
|
|
165
|
-
<
|
|
166
|
-
<CardTitle className="text-base">Runs</CardTitle>
|
|
167
|
-
<div className="flex flex-wrap items-center gap-1">
|
|
168
|
-
{(
|
|
169
|
-
[
|
|
170
|
-
"all",
|
|
171
|
-
"running",
|
|
172
|
-
"success",
|
|
173
|
-
"failed",
|
|
174
|
-
"cancelled",
|
|
175
|
-
"waiting",
|
|
176
|
-
] as const
|
|
177
|
-
).map((option) => (
|
|
178
|
-
<Button
|
|
179
|
-
key={option}
|
|
180
|
-
size="sm"
|
|
181
|
-
variant={statusFilter === option ? "primary" : "outline"}
|
|
182
|
-
onClick={() => setStatusFilter(option)}
|
|
183
|
-
className="capitalize"
|
|
184
|
-
>
|
|
185
|
-
{option}
|
|
186
|
-
</Button>
|
|
187
|
-
))}
|
|
188
|
-
</div>
|
|
189
|
-
</div>
|
|
195
|
+
<CardTitle className="text-base">Runs</CardTitle>
|
|
190
196
|
</CardHeader>
|
|
191
197
|
<CardContent className="p-0">
|
|
192
198
|
{runsQuery.isLoading ? (
|
|
@@ -198,18 +204,48 @@ const RunsPageContent: React.FC = () => {
|
|
|
198
204
|
error={runsQuery.error}
|
|
199
205
|
onRetry={() => runsQuery.refetch()}
|
|
200
206
|
/>
|
|
201
|
-
) : runs.length === 0 ? (
|
|
202
|
-
<EmptyState
|
|
203
|
-
icon={<History className="h-8 w-8 text-muted-foreground" />}
|
|
204
|
-
title="No runs match this filter"
|
|
205
|
-
description="Manually trigger the automation from the edit page to generate a run."
|
|
206
|
-
/>
|
|
207
207
|
) : (
|
|
208
208
|
<DataTable
|
|
209
209
|
data={runs}
|
|
210
210
|
columns={columns}
|
|
211
211
|
getRowId={(run) => run.id}
|
|
212
|
+
// The Status column declares its own filter, so the pills sit in
|
|
213
|
+
// the table's bar. Runs are identified by time and outcome, not
|
|
214
|
+
// by a name worth typing, so there is no search box.
|
|
215
|
+
filters={filters.state}
|
|
216
|
+
onFiltersChange={filters.setState}
|
|
217
|
+
onClearFilters={filters.clear}
|
|
212
218
|
searchable={false}
|
|
219
|
+
// The list arrives already narrowed by the server, so an empty
|
|
220
|
+
// `data` means either outcome. Suppressing `emptyState` while a
|
|
221
|
+
// filter is active is what lets the table tell them apart - and
|
|
222
|
+
// rendering both INSIDE the table keeps the pills on screen, so a
|
|
223
|
+
// filter that matches nothing can still be cleared.
|
|
224
|
+
emptyState={
|
|
225
|
+
filters.active ? undefined : (
|
|
226
|
+
<EmptyState
|
|
227
|
+
icon={<History className="h-8 w-8 text-muted-foreground" />}
|
|
228
|
+
title="No runs yet"
|
|
229
|
+
description="Manually trigger the automation from the edit page to generate a run."
|
|
230
|
+
/>
|
|
231
|
+
)
|
|
232
|
+
}
|
|
233
|
+
noResultsState={
|
|
234
|
+
<EmptyState
|
|
235
|
+
icon={<History className="h-8 w-8 text-muted-foreground" />}
|
|
236
|
+
title="No runs match this filter"
|
|
237
|
+
description="No run of this automation has that outcome."
|
|
238
|
+
actions={
|
|
239
|
+
<Button variant="outline" onClick={filters.clear}>
|
|
240
|
+
Clear filter
|
|
241
|
+
</Button>
|
|
242
|
+
}
|
|
243
|
+
/>
|
|
244
|
+
}
|
|
245
|
+
// Nested inside the page's opaque Card, so the default bg-card
|
|
246
|
+
// surface would create a panel-in-panel; the enclosing Card
|
|
247
|
+
// already provides the opaque background.
|
|
248
|
+
surface={false}
|
|
213
249
|
renderMobileCard={(run) => (
|
|
214
250
|
<div className="rounded-md border bg-surface p-4">
|
|
215
251
|
<div className="flex items-start justify-between gap-2">
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { describe, expect, it } from "bun:test";
|
|
1
|
+
import { describe, expect, it, test } from "bun:test";
|
|
2
2
|
import type { Automation, AutomationDefinition } from "@checkstack/automation-common";
|
|
3
3
|
import {
|
|
4
|
+
filterAutomationsByQuery,
|
|
4
5
|
groupAutomations,
|
|
5
6
|
UNGROUPED_LABEL,
|
|
6
7
|
} from "./automation-grouping";
|
|
@@ -85,3 +86,39 @@ describe("groupAutomations", () => {
|
|
|
85
86
|
expect(groups.some((g) => g.items.length === 0)).toBe(false);
|
|
86
87
|
});
|
|
87
88
|
});
|
|
89
|
+
|
|
90
|
+
describe("filterAutomationsByQuery", () => {
|
|
91
|
+
const rows = [
|
|
92
|
+
{ name: "Nightly backup", group: "Ops" },
|
|
93
|
+
{ name: "Deploy notifier", group: "Release" },
|
|
94
|
+
{ name: "Cleanup", group: undefined },
|
|
95
|
+
] as Automation[];
|
|
96
|
+
|
|
97
|
+
const names = (query: string) =>
|
|
98
|
+
filterAutomationsByQuery({ automations: rows, query }).map((a) => a.name);
|
|
99
|
+
|
|
100
|
+
test("matches the name, case-insensitively on a substring", () => {
|
|
101
|
+
expect(names("NIGHT")).toEqual(["Nightly backup"]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("matches the group label too", () => {
|
|
105
|
+
// The group is a heading the reader can see, so it is fair to search.
|
|
106
|
+
expect(names("release")).toEqual(["Deploy notifier"]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("an ungrouped automation is still searchable by name", () => {
|
|
110
|
+
expect(names("cleanup")).toEqual(["Cleanup"]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("an empty or whitespace query returns the same reference", () => {
|
|
114
|
+
// An idle search box must not re-group the list on every render.
|
|
115
|
+
expect(filterAutomationsByQuery({ automations: rows, query: "" })).toBe(rows);
|
|
116
|
+
expect(filterAutomationsByQuery({ automations: rows, query: " " })).toBe(
|
|
117
|
+
rows,
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("a query matching nothing yields an empty list, not every row", () => {
|
|
122
|
+
expect(names("nonexistent")).toEqual([]);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -63,3 +63,32 @@ export function groupAutomations({
|
|
|
63
63
|
|
|
64
64
|
return groups;
|
|
65
65
|
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Narrow a flat automation list by a free-text query, matched against the name
|
|
69
|
+
* and the group label - the two things the list actually shows above the fold.
|
|
70
|
+
*
|
|
71
|
+
* Search lives here rather than on the table because the list renders ONE table
|
|
72
|
+
* per group: a table-owned search box would filter its own group only, so a
|
|
73
|
+
* query could never surface a match sitting in a collapsed group. Filtering
|
|
74
|
+
* before `groupAutomations` also makes empty groups disappear, so the result
|
|
75
|
+
* reads as "here is what matched" instead of a wall of empty accordions.
|
|
76
|
+
*
|
|
77
|
+
* An empty or whitespace-only query returns the SAME array reference, so an
|
|
78
|
+
* idle search box costs no re-grouping.
|
|
79
|
+
*/
|
|
80
|
+
export function filterAutomationsByQuery({
|
|
81
|
+
automations,
|
|
82
|
+
query,
|
|
83
|
+
}: {
|
|
84
|
+
automations: Automation[];
|
|
85
|
+
query: string;
|
|
86
|
+
}): Automation[] {
|
|
87
|
+
const needle = query.trim().toLowerCase();
|
|
88
|
+
if (!needle) return automations;
|
|
89
|
+
return automations.filter((automation) =>
|
|
90
|
+
`${automation.name} ${automation.group ?? ""}`
|
|
91
|
+
.toLowerCase()
|
|
92
|
+
.includes(needle),
|
|
93
|
+
);
|
|
94
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
-
import { cn } from "@checkstack/ui";
|
|
2
|
+
import { cn, pillToneStyles, StatusPill } from "@checkstack/ui";
|
|
3
3
|
import type { RunStatus } from "@checkstack/automation-common";
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -21,36 +21,16 @@ export const RUN_STATUS_TONE: Record<RunStatus, RunStatusTone> = {
|
|
|
21
21
|
};
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
|
-
* Per-tone pill + dot + left-accent classes
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* timeline rows so the stripe always matches this pill.
|
|
24
|
+
* Per-tone pill + dot + left-accent classes, taken from the shared
|
|
25
|
+
* `pillToneStyles` table rather than a private copy. The `accent` class drives
|
|
26
|
+
* the left status stripe on cards and timeline rows so the stripe always
|
|
27
|
+
* matches the pill.
|
|
29
28
|
*/
|
|
30
|
-
export const RUN_TONE_STYLES:
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
pill: "bg-status-ok/10 text-status-ok",
|
|
36
|
-
dot: "bg-status-ok",
|
|
37
|
-
accent: "bg-status-ok",
|
|
38
|
-
},
|
|
39
|
-
warn: {
|
|
40
|
-
pill: "bg-status-warn/10 text-status-warn",
|
|
41
|
-
dot: "bg-status-warn",
|
|
42
|
-
accent: "bg-status-warn",
|
|
43
|
-
},
|
|
44
|
-
down: {
|
|
45
|
-
pill: "bg-status-down/10 text-status-down",
|
|
46
|
-
dot: "bg-status-down",
|
|
47
|
-
accent: "bg-status-down",
|
|
48
|
-
},
|
|
49
|
-
unknown: {
|
|
50
|
-
pill: "bg-status-unknown/10 text-status-unknown",
|
|
51
|
-
dot: "bg-status-unknown",
|
|
52
|
-
accent: "bg-status-unknown",
|
|
53
|
-
},
|
|
29
|
+
export const RUN_TONE_STYLES: Pick<typeof pillToneStyles, RunStatusTone> = {
|
|
30
|
+
ok: pillToneStyles.ok,
|
|
31
|
+
warn: pillToneStyles.warn,
|
|
32
|
+
down: pillToneStyles.down,
|
|
33
|
+
unknown: pillToneStyles.unknown,
|
|
54
34
|
};
|
|
55
35
|
|
|
56
36
|
/**
|
|
@@ -61,18 +41,11 @@ export const RUN_TONE_STYLES: Record<
|
|
|
61
41
|
export const RunStatusPill: React.FC<{
|
|
62
42
|
status: RunStatus;
|
|
63
43
|
className?: string;
|
|
64
|
-
}> = ({ status, className }) =>
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
)}
|
|
73
|
-
>
|
|
74
|
-
<span className={cn("size-1.5 rounded-full", styles.dot)} aria-hidden />
|
|
75
|
-
{status}
|
|
76
|
-
</span>
|
|
77
|
-
);
|
|
78
|
-
};
|
|
44
|
+
}> = ({ status, className }) => (
|
|
45
|
+
<StatusPill
|
|
46
|
+
tone={RUN_STATUS_TONE[status]}
|
|
47
|
+
className={cn("capitalize", className)}
|
|
48
|
+
>
|
|
49
|
+
{status}
|
|
50
|
+
</StatusPill>
|
|
51
|
+
);
|