@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 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.12.7",
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.15.0",
20
- "@checkstack/auth-frontend": "0.14.0",
21
- "@checkstack/automation-common": "0.10.2",
22
- "@checkstack/ai-common": "0.6.7",
23
- "@checkstack/catalog-common": "2.8.0",
24
- "@checkstack/common": "0.23.0",
25
- "@checkstack/frontend-api": "0.16.1",
26
- "@checkstack/gitops-frontend": "0.7.8",
27
- "@checkstack/integration-common": "0.9.10",
28
- "@checkstack/script-packages-frontend": "0.4.17",
29
- "@checkstack/secrets-frontend": "0.3.16",
30
- "@checkstack/signal-frontend": "0.3.7",
31
- "@checkstack/template-engine": "0.4.12",
32
- "@checkstack/ui": "1.29.0",
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-dom": "^7.16.0",
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.6"
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-dom";
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 } from "react-router-dom";
2
+ import { Link } from "react-router";
3
3
  import { resolveRoute } from "@checkstack/common";
4
4
  import { integrationRoutes } from "@checkstack/integration-common";
5
5
  import {
@@ -1,5 +1,5 @@
1
1
  import React from "react";
2
- import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
2
+ import { Link, useNavigate, useParams, useSearchParams } from "react-router";
3
3
  import {
4
4
  ChevronLeft,
5
5
  History as HistoryIcon,
@@ -1,5 +1,5 @@
1
1
  import React from "react";
2
- import { Link, useNavigate } from "react-router-dom";
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 { groupAutomations } from "./automation-grouping";
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
- const [statusFilter, setStatusFilter] = React.useState<
97
- "all" | "enabled" | "disabled"
98
- >("all");
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 === "all" ? {} : { status: 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
- [automations],
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
- <div className="flex items-center gap-1">
482
- {(["all", "enabled", "disabled"] as const).map((option) => (
483
- <Button
484
- key={option}
485
- size="sm"
486
- variant={statusFilter === option ? "primary" : "outline"}
487
- onClick={() => setStatusFilter(option)}
488
- className="capitalize"
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">
@@ -1,5 +1,5 @@
1
1
  import React from "react";
2
- import { useNavigate } from "react-router-dom";
2
+ import { useNavigate } from "react-router";
3
3
  import { Workflow, FilePlus2, ArrowRight } from "lucide-react";
4
4
  import {
5
5
  usePluginClient,
@@ -1,5 +1,5 @@
1
1
  import React from "react";
2
- import { Link, useParams } from "react-router-dom";
2
+ import { Link, useParams } from "react-router";
3
3
  import {
4
4
  CheckCircle2,
5
5
  ChevronLeft,
@@ -1,5 +1,5 @@
1
1
  import React from "react";
2
- import { Link, useParams, useNavigate } from "react-router-dom";
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 { RunStatus, AutomationRun } from "@checkstack/automation-common";
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
- const [statusFilter, setStatusFilter] = React.useState<RunStatus | "all">(
51
- "all",
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 === "all" ? {} : { status: 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
- <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
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. Spelled out as full literal
25
- * strings (not interpolated) so Tailwind's JIT keeps them, driven by the
26
- * colorblind-safe status triad. Status reads as hue + dot + text label, never
27
- * color alone. The `accent` class drives the left status stripe on cards and
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: Record<
31
- RunStatusTone,
32
- { pill: string; dot: string; accent: string }
33
- > = {
34
- ok: {
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
- const styles = RUN_TONE_STYLES[RUN_STATUS_TONE[status]];
66
- return (
67
- <span
68
- className={cn(
69
- "inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium capitalize",
70
- styles.pill,
71
- className,
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
+ );