@checkstack/catalog-frontend 0.10.7 → 0.11.1
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 +149 -0
- package/package.json +16 -15
- package/src/api.ts +6 -1
- package/src/components/CatalogConfigPage.tsx +337 -271
- package/src/components/CatalogPage.tsx +172 -11
- package/src/components/EnvironmentEditor.tsx +220 -0
- package/src/components/EnvironmentPreviewPicker.tsx +61 -0
- package/src/components/SystemDetailPage.tsx +47 -34
- package/src/components/SystemEditor.tsx +6 -0
- package/src/components/SystemEnvironmentsEditor.tsx +98 -0
- package/src/components/browse/CatalogBrowseHealth.tsx +36 -0
- package/src/components/browse/CatalogBrowseToolbar.tsx +173 -0
- package/src/components/browse/CatalogGroupSection.tsx +165 -0
- package/src/components/browse/CatalogSystemRow.tsx +63 -0
- package/src/components/browse/browseState.logic.test.ts +125 -0
- package/src/components/browse/browseState.logic.ts +158 -0
- package/src/components/browse/filterEntities.logic.test.ts +479 -0
- package/src/components/browse/filterEntities.logic.ts +360 -0
- package/src/components/browse/healthRollup.logic.test.ts +126 -0
- package/src/components/browse/healthRollup.logic.ts +120 -0
- package/src/components/browse/healthStatuses.logic.test.ts +39 -0
- package/src/components/browse/healthStatuses.logic.ts +29 -0
- package/src/components/environment-fields.logic.test.ts +111 -0
- package/src/components/environment-fields.logic.ts +98 -0
- package/src/components/environment-preview.logic.test.ts +76 -0
- package/src/components/environment-preview.logic.ts +61 -0
- package/src/components/manage/AssignMenu.tsx +78 -0
- package/src/components/manage/EnvironmentsTab.tsx +230 -0
- package/src/components/manage/GroupsTab.tsx +274 -0
- package/src/components/manage/SystemsTab.tsx +430 -0
- package/src/hooks/useCatalogBrowseState.ts +107 -0
- package/src/hooks/useDebouncedValue.ts +21 -0
- package/src/index.tsx +32 -20
- package/src/utils/formatDate.logic.test.ts +44 -0
- package/src/utils/formatDate.logic.ts +27 -0
- package/src/utils/normalizeMetadata.logic.test.ts +67 -0
- package/src/utils/normalizeMetadata.logic.ts +53 -0
- package/src/components/DraggableSystem.tsx +0 -200
- package/src/components/DroppableGroup.tsx +0 -174
- package/src/components/UserMenuItems.tsx +0 -31
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
System,
|
|
3
|
+
Group,
|
|
4
|
+
CatalogHealthStatuses,
|
|
5
|
+
} from "@checkstack/catalog-common";
|
|
6
|
+
import {
|
|
7
|
+
type BrowseState,
|
|
8
|
+
type Density,
|
|
9
|
+
UNGROUPED_ID,
|
|
10
|
+
} from "./browseState.logic";
|
|
11
|
+
import { normalizeMetadata } from "../../utils/normalizeMetadata.logic";
|
|
12
|
+
import {
|
|
13
|
+
computeGroupRollup,
|
|
14
|
+
matchesHealth,
|
|
15
|
+
type GroupHealthRollup,
|
|
16
|
+
} from "./healthRollup.logic";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Pure, DOM-free grouping + filtering logic for the catalog browse view.
|
|
20
|
+
*
|
|
21
|
+
* Filtering is client-side over the already-loaded `getEntities` set (systems
|
|
22
|
+
* and groups), matching the command palette's server-side `includes` matching
|
|
23
|
+
* but reproduced on the client at operator scale (thousands of rows, not
|
|
24
|
+
* millions — see the plan §5). No new backend endpoint is needed.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** A system together with the metadata-derived filter tokens it exposes. */
|
|
28
|
+
export interface SystemView {
|
|
29
|
+
system: System;
|
|
30
|
+
/** `"key=value"` and `"key"` tokens for string-valued metadata entries. */
|
|
31
|
+
tags: string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** One rendered group section: the group (or synthetic Ungrouped) + members. */
|
|
35
|
+
export interface GroupSection {
|
|
36
|
+
/** Group id, or `UNGROUPED_ID` for the synthetic ungrouped section. */
|
|
37
|
+
id: string;
|
|
38
|
+
/** Display name. */
|
|
39
|
+
name: string;
|
|
40
|
+
/** `true` for the synthetic "Ungrouped" section. */
|
|
41
|
+
isUngrouped: boolean;
|
|
42
|
+
/** Member systems that survived filtering, in input order. */
|
|
43
|
+
systems: System[];
|
|
44
|
+
/** Total member count BEFORE filtering (the header count). */
|
|
45
|
+
totalCount: number;
|
|
46
|
+
/** Whether this section is open given URL overrides + default policy. */
|
|
47
|
+
open: boolean;
|
|
48
|
+
/**
|
|
49
|
+
* Health rollup for the header, derived from ALL members (pre-filter) and the
|
|
50
|
+
* reported status map — NOT from the filtered rows or rendered badges. When no
|
|
51
|
+
* health source filled the slot, `rollup.hasData` is `false` and the header
|
|
52
|
+
* shows counts only.
|
|
53
|
+
*/
|
|
54
|
+
rollup: GroupHealthRollup;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The fully-derived browse model the page renders. */
|
|
58
|
+
export interface BrowseModel {
|
|
59
|
+
sections: GroupSection[];
|
|
60
|
+
/** `true` when every section is empty after filtering (filtered-to-empty). */
|
|
61
|
+
isFilteredEmpty: boolean;
|
|
62
|
+
/** `true` when the catalog has no systems and no groups at all. */
|
|
63
|
+
isEmptyCatalog: boolean;
|
|
64
|
+
density: Density;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Case-insensitive substring match, null-safe. */
|
|
68
|
+
function matchesText(haystack: string | null | undefined, needle: string): boolean {
|
|
69
|
+
if (!needle) return true;
|
|
70
|
+
if (!haystack) return false;
|
|
71
|
+
return haystack.toLowerCase().includes(needle.toLowerCase());
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Derive the metadata filter tokens for a system. Only string-valued entries
|
|
76
|
+
* are surfaced (matching the plan §3.2 / §4.3 metadata rendering), each as
|
|
77
|
+
* both a bare `"key"` token and a `"key=value"` token so a tag filter can
|
|
78
|
+
* match either form.
|
|
79
|
+
*/
|
|
80
|
+
export function systemTags(system: System): string[] {
|
|
81
|
+
const entries = normalizeMetadata(system.metadata);
|
|
82
|
+
const tags: string[] = [];
|
|
83
|
+
for (const { key, displayValue } of entries) {
|
|
84
|
+
tags.push(key);
|
|
85
|
+
if (displayValue.length > 0) tags.push(`${key}=${displayValue}`);
|
|
86
|
+
}
|
|
87
|
+
return tags;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Collect the distinct `key=value` tag tokens across a set of systems, sorted
|
|
92
|
+
* for stable rendering. Used to populate the browse "Tags" filter dropdown.
|
|
93
|
+
* Only `key=value` form is offered (the bare-key tokens are matching aliases).
|
|
94
|
+
*/
|
|
95
|
+
export function collectTagOptions(systems: System[]): string[] {
|
|
96
|
+
const seen = new Set<string>();
|
|
97
|
+
for (const system of systems) {
|
|
98
|
+
for (const tag of systemTags(system)) {
|
|
99
|
+
if (tag.includes("=")) seen.add(tag);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return [...seen].toSorted((a, b) => a.localeCompare(b));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Does a system pass the search-query filter (name OR description)? */
|
|
106
|
+
export function matchesQuery(system: System, query: string): boolean {
|
|
107
|
+
if (!query) return true;
|
|
108
|
+
return (
|
|
109
|
+
matchesText(system.name, query) || matchesText(system.description, query)
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Does a system pass the tag filter? */
|
|
114
|
+
export function matchesTag(system: System, tag: string | null): boolean {
|
|
115
|
+
if (!tag) return true;
|
|
116
|
+
const lower = tag.toLowerCase();
|
|
117
|
+
return systemTags(system).some((t) => t.toLowerCase() === lower);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Does a group's own name match the search query? (`Group` has no description.) */
|
|
121
|
+
function groupMatchesQuery(group: Group, query: string): boolean {
|
|
122
|
+
if (!query) return true;
|
|
123
|
+
return matchesText(group.name, query);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Build the grouped, filtered browse model.
|
|
128
|
+
*
|
|
129
|
+
* - Groups partition systems by membership; a system in N groups appears under
|
|
130
|
+
* each (matches the management many-to-many model). A synthetic "Ungrouped"
|
|
131
|
+
* section collects systems in no group.
|
|
132
|
+
* - The `group` filter narrows to a single group section (the synthetic
|
|
133
|
+
* ungrouped section is selectable via `UNGROUPED_ID`).
|
|
134
|
+
* - Search + tag + health filters compose (AND) and apply per-system. The health
|
|
135
|
+
* filter reads the reported `statuses` map (Phase 4); when the slot is unfilled
|
|
136
|
+
* every system is `"unknown"`.
|
|
137
|
+
* - A query causes groups with zero surviving members to drop out entirely,
|
|
138
|
+
* and forces matching groups open (search auto-expand) unless the URL state
|
|
139
|
+
* explicitly forced them closed.
|
|
140
|
+
* - Default-open policy (no query): all-healthy groups (derived from the status
|
|
141
|
+
* DATA, not badges) start collapsed so attention goes to what needs it; any
|
|
142
|
+
* group with a non-healthy or unknown member starts expanded. With no health
|
|
143
|
+
* data at all (slot unfilled) every group starts expanded. The URL `open` state
|
|
144
|
+
* always overrides the computed default.
|
|
145
|
+
*/
|
|
146
|
+
export function buildBrowseModel({
|
|
147
|
+
systems,
|
|
148
|
+
groups,
|
|
149
|
+
state,
|
|
150
|
+
statuses,
|
|
151
|
+
}: {
|
|
152
|
+
systems: System[];
|
|
153
|
+
groups: Group[];
|
|
154
|
+
state: BrowseState;
|
|
155
|
+
/**
|
|
156
|
+
* Per-system health from the `CatalogBrowseHealthSlot` filler, or undefined
|
|
157
|
+
* when no health source is installed (group rollups show counts only and the
|
|
158
|
+
* health filter resolves everything to `"unknown"`).
|
|
159
|
+
*/
|
|
160
|
+
statuses?: CatalogHealthStatuses;
|
|
161
|
+
}): BrowseModel {
|
|
162
|
+
const isEmptyCatalog = systems.length === 0 && groups.length === 0;
|
|
163
|
+
|
|
164
|
+
const passesSystemFilters = (system: System): boolean =>
|
|
165
|
+
matchesQuery(system, state.query) &&
|
|
166
|
+
matchesTag(system, state.tag) &&
|
|
167
|
+
matchesHealth({ systemId: system.id, health: state.health, statuses });
|
|
168
|
+
|
|
169
|
+
const systemById = new Map<string, System>();
|
|
170
|
+
for (const system of systems) systemById.set(system.id, system);
|
|
171
|
+
|
|
172
|
+
const hasQuery = state.query.trim().length > 0;
|
|
173
|
+
|
|
174
|
+
const resolveOpen = ({
|
|
175
|
+
id,
|
|
176
|
+
hasMatch,
|
|
177
|
+
allHealthy,
|
|
178
|
+
}: {
|
|
179
|
+
id: string;
|
|
180
|
+
hasMatch: boolean;
|
|
181
|
+
allHealthy: boolean;
|
|
182
|
+
}): boolean => {
|
|
183
|
+
const override = state.open[id];
|
|
184
|
+
if (override !== undefined) return override;
|
|
185
|
+
// Search auto-expands any group with a surviving match.
|
|
186
|
+
if (hasQuery && hasMatch) return true;
|
|
187
|
+
// Default policy: all-healthy groups (derived from health DATA) start
|
|
188
|
+
// collapsed so attention goes to what needs it; everything else (degraded,
|
|
189
|
+
// unhealthy, unknown, or no health data) starts expanded.
|
|
190
|
+
return !allHealthy;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const sections: GroupSection[] = [];
|
|
194
|
+
|
|
195
|
+
// Real group sections.
|
|
196
|
+
const groupedSystemIds = new Set<string>();
|
|
197
|
+
for (const group of groups) {
|
|
198
|
+
const memberIds = group.systemIds ?? [];
|
|
199
|
+
for (const id of memberIds) groupedSystemIds.add(id);
|
|
200
|
+
|
|
201
|
+
// A group filter hides every other group section.
|
|
202
|
+
if (state.group && state.group !== group.id) continue;
|
|
203
|
+
|
|
204
|
+
const members = memberIds
|
|
205
|
+
.map((id) => systemById.get(id))
|
|
206
|
+
.filter((s): s is System => s !== undefined);
|
|
207
|
+
|
|
208
|
+
const filtered = members.filter((s) => passesSystemFilters(s));
|
|
209
|
+
|
|
210
|
+
// Under an active query, drop groups with no surviving member.
|
|
211
|
+
if (hasQuery && filtered.length === 0) continue;
|
|
212
|
+
|
|
213
|
+
// Rollup is over ALL members (pre-filter), from the status DATA.
|
|
214
|
+
const rollup = computeGroupRollup({
|
|
215
|
+
memberIds: members.map((s) => s.id),
|
|
216
|
+
statuses,
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
sections.push({
|
|
220
|
+
id: group.id,
|
|
221
|
+
name: group.name,
|
|
222
|
+
isUngrouped: false,
|
|
223
|
+
systems: filtered,
|
|
224
|
+
totalCount: members.length,
|
|
225
|
+
open: resolveOpen({
|
|
226
|
+
id: group.id,
|
|
227
|
+
hasMatch: filtered.length > 0,
|
|
228
|
+
allHealthy: rollup.allHealthy,
|
|
229
|
+
}),
|
|
230
|
+
rollup,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Synthetic Ungrouped section: systems belonging to no group.
|
|
235
|
+
const showUngrouped = !state.group || state.group === UNGROUPED_ID;
|
|
236
|
+
if (showUngrouped) {
|
|
237
|
+
const ungroupedMembers = systems.filter((s) => !groupedSystemIds.has(s.id));
|
|
238
|
+
const filtered = ungroupedMembers.filter((s) => passesSystemFilters(s));
|
|
239
|
+
const include = ungroupedMembers.length > 0 && !(hasQuery && filtered.length === 0);
|
|
240
|
+
if (include) {
|
|
241
|
+
const rollup = computeGroupRollup({
|
|
242
|
+
memberIds: ungroupedMembers.map((s) => s.id),
|
|
243
|
+
statuses,
|
|
244
|
+
});
|
|
245
|
+
sections.push({
|
|
246
|
+
id: UNGROUPED_ID,
|
|
247
|
+
name: "Ungrouped",
|
|
248
|
+
isUngrouped: true,
|
|
249
|
+
systems: filtered,
|
|
250
|
+
totalCount: ungroupedMembers.length,
|
|
251
|
+
open: resolveOpen({
|
|
252
|
+
id: UNGROUPED_ID,
|
|
253
|
+
hasMatch: filtered.length > 0,
|
|
254
|
+
allHealthy: rollup.allHealthy,
|
|
255
|
+
}),
|
|
256
|
+
rollup,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const hasAnyFilter =
|
|
262
|
+
hasQuery || state.tag !== null || state.group !== null || state.health !== "all";
|
|
263
|
+
const isFilteredEmpty =
|
|
264
|
+
!isEmptyCatalog &&
|
|
265
|
+
hasAnyFilter &&
|
|
266
|
+
sections.every((section) => section.systems.length === 0);
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
sections,
|
|
270
|
+
isFilteredEmpty,
|
|
271
|
+
isEmptyCatalog,
|
|
272
|
+
density: state.density,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** The filtered flat lists the management page renders. */
|
|
277
|
+
export interface ManagementListsModel {
|
|
278
|
+
/** Systems surviving the search/tag/group/health filters, in input order. */
|
|
279
|
+
systems: System[];
|
|
280
|
+
/** Groups surviving the search/group filters, in input order. */
|
|
281
|
+
groups: Group[];
|
|
282
|
+
/** `true` when the catalog has no systems and no groups at all. */
|
|
283
|
+
isEmptyCatalog: boolean;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Filter the management page's two flat lists (systems + groups) using the SAME
|
|
288
|
+
* search/tag/group/health state and the SAME per-system matchers as the browse
|
|
289
|
+
* model — so browse and manage share one filter implementation (plan §2.2). The
|
|
290
|
+
* management page renders flat draggable/droppable lists rather than collapsible
|
|
291
|
+
* sections, so this returns plain arrays instead of a `BrowseModel`.
|
|
292
|
+
*
|
|
293
|
+
* Semantics:
|
|
294
|
+
* - **Systems**: pass query+tag, and (no group filter) OR belong to the selected
|
|
295
|
+
* group (or are ungrouped when the synthetic `UNGROUPED_ID` is selected).
|
|
296
|
+
* - **Groups**: pass the group filter (id match, or none / `UNGROUPED_ID` shows
|
|
297
|
+
* no real group), and under an active query are kept when the group name
|
|
298
|
+
* matches OR the group contains a system that matches query+tag+health.
|
|
299
|
+
* - The health filter reads the reported `statuses` map (Phase 4); when the slot
|
|
300
|
+
* is unfilled every system is `"unknown"` and only `"all"` matches anything.
|
|
301
|
+
*/
|
|
302
|
+
export function filterManagementLists({
|
|
303
|
+
systems,
|
|
304
|
+
groups,
|
|
305
|
+
state,
|
|
306
|
+
statuses,
|
|
307
|
+
}: {
|
|
308
|
+
systems: System[];
|
|
309
|
+
groups: Group[];
|
|
310
|
+
state: BrowseState;
|
|
311
|
+
/** Per-system health from the slot filler, or undefined when unfilled. */
|
|
312
|
+
statuses?: CatalogHealthStatuses;
|
|
313
|
+
}): ManagementListsModel {
|
|
314
|
+
const isEmptyCatalog = systems.length === 0 && groups.length === 0;
|
|
315
|
+
const hasQuery = state.query.trim().length > 0;
|
|
316
|
+
|
|
317
|
+
const passesSystemFilters = (system: System): boolean =>
|
|
318
|
+
matchesQuery(system, state.query) &&
|
|
319
|
+
matchesTag(system, state.tag) &&
|
|
320
|
+
matchesHealth({ systemId: system.id, health: state.health, statuses });
|
|
321
|
+
|
|
322
|
+
// Map each group id to the set of member ids for membership checks.
|
|
323
|
+
const groupById = new Map<string, Group>();
|
|
324
|
+
for (const group of groups) groupById.set(group.id, group);
|
|
325
|
+
|
|
326
|
+
const groupedSystemIds = new Set<string>();
|
|
327
|
+
for (const group of groups) {
|
|
328
|
+
for (const id of group.systemIds ?? []) groupedSystemIds.add(id);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const inSelectedGroup = (system: System): boolean => {
|
|
332
|
+
if (!state.group) return true;
|
|
333
|
+
if (state.group === UNGROUPED_ID) return !groupedSystemIds.has(system.id);
|
|
334
|
+
return (groupById.get(state.group)?.systemIds ?? []).includes(system.id);
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
const filteredSystems = systems.filter(
|
|
338
|
+
(system) => passesSystemFilters(system) && inSelectedGroup(system),
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
const groupContainsMatch = (group: Group): boolean =>
|
|
342
|
+
(group.systemIds ?? [])
|
|
343
|
+
.map((id) => systems.find((s) => s.id === id))
|
|
344
|
+
.filter((s): s is System => s !== undefined)
|
|
345
|
+
.some((s) => passesSystemFilters(s));
|
|
346
|
+
|
|
347
|
+
const filteredGroups = groups.filter((group) => {
|
|
348
|
+
// The synthetic "Ungrouped" selection hides every real group.
|
|
349
|
+
if (state.group === UNGROUPED_ID) return false;
|
|
350
|
+
if (state.group && state.group !== group.id) return false;
|
|
351
|
+
if (!hasQuery) return true;
|
|
352
|
+
return groupMatchesQuery(group, state.query) || groupContainsMatch(group);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
return {
|
|
356
|
+
systems: filteredSystems,
|
|
357
|
+
groups: filteredGroups,
|
|
358
|
+
isEmptyCatalog,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { CatalogHealthStatuses } from "@checkstack/catalog-common";
|
|
3
|
+
import {
|
|
4
|
+
computeGroupRollup,
|
|
5
|
+
matchesHealth,
|
|
6
|
+
resolveSystemHealth,
|
|
7
|
+
} from "./healthRollup.logic";
|
|
8
|
+
|
|
9
|
+
describe("resolveSystemHealth", () => {
|
|
10
|
+
test("returns the reported status", () => {
|
|
11
|
+
const statuses: CatalogHealthStatuses = { a: "degraded" };
|
|
12
|
+
expect(resolveSystemHealth({ systemId: "a", statuses })).toBe("degraded");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("absent system is 'unknown', never healthy", () => {
|
|
16
|
+
expect(resolveSystemHealth({ systemId: "x", statuses: {} })).toBe("unknown");
|
|
17
|
+
expect(
|
|
18
|
+
resolveSystemHealth({ systemId: "x", statuses: undefined }),
|
|
19
|
+
).toBe("unknown");
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("computeGroupRollup", () => {
|
|
24
|
+
test("all members healthy → allHealthy, derived from data not badges", () => {
|
|
25
|
+
const statuses: CatalogHealthStatuses = { a: "healthy", b: "healthy" };
|
|
26
|
+
const rollup = computeGroupRollup({ memberIds: ["a", "b"], statuses });
|
|
27
|
+
expect(rollup.allHealthy).toBe(true);
|
|
28
|
+
expect(rollup.hasData).toBe(true);
|
|
29
|
+
expect(rollup.worst).toBe("healthy");
|
|
30
|
+
expect(rollup.degraded).toBe(0);
|
|
31
|
+
expect(rollup.unhealthy).toBe(0);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("one degraded → warning rollup with count, not allHealthy", () => {
|
|
35
|
+
const statuses: CatalogHealthStatuses = { a: "healthy", b: "degraded" };
|
|
36
|
+
const rollup = computeGroupRollup({ memberIds: ["a", "b"], statuses });
|
|
37
|
+
expect(rollup.allHealthy).toBe(false);
|
|
38
|
+
expect(rollup.worst).toBe("degraded");
|
|
39
|
+
expect(rollup.degraded).toBe(1);
|
|
40
|
+
expect(rollup.unhealthy).toBe(0);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("unhealthy outranks degraded for worst-of", () => {
|
|
44
|
+
const statuses: CatalogHealthStatuses = {
|
|
45
|
+
a: "degraded",
|
|
46
|
+
b: "unhealthy",
|
|
47
|
+
c: "degraded",
|
|
48
|
+
};
|
|
49
|
+
const rollup = computeGroupRollup({
|
|
50
|
+
memberIds: ["a", "b", "c"],
|
|
51
|
+
statuses,
|
|
52
|
+
});
|
|
53
|
+
expect(rollup.worst).toBe("unhealthy");
|
|
54
|
+
expect(rollup.unhealthy).toBe(1);
|
|
55
|
+
expect(rollup.degraded).toBe(2);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("no data at all → hasData false, worst unknown, not allHealthy", () => {
|
|
59
|
+
const rollup = computeGroupRollup({
|
|
60
|
+
memberIds: ["a", "b"],
|
|
61
|
+
statuses: undefined,
|
|
62
|
+
});
|
|
63
|
+
expect(rollup.hasData).toBe(false);
|
|
64
|
+
expect(rollup.worst).toBe("unknown");
|
|
65
|
+
expect(rollup.allHealthy).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("mixed healthy + unknown is NOT allHealthy (absence ≠ healthy)", () => {
|
|
69
|
+
const statuses: CatalogHealthStatuses = { a: "healthy" };
|
|
70
|
+
const rollup = computeGroupRollup({ memberIds: ["a", "b"], statuses });
|
|
71
|
+
expect(rollup.allHealthy).toBe(false);
|
|
72
|
+
expect(rollup.hasData).toBe(true);
|
|
73
|
+
// No degraded/unhealthy, but b is unknown → worst is healthy (a), unknown
|
|
74
|
+
// ranks below healthy.
|
|
75
|
+
expect(rollup.worst).toBe("healthy");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("empty group is not allHealthy", () => {
|
|
79
|
+
const rollup = computeGroupRollup({ memberIds: [], statuses: {} });
|
|
80
|
+
expect(rollup.allHealthy).toBe(false);
|
|
81
|
+
expect(rollup.hasData).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("matchesHealth", () => {
|
|
86
|
+
const statuses: CatalogHealthStatuses = {
|
|
87
|
+
h: "healthy",
|
|
88
|
+
d: "degraded",
|
|
89
|
+
u: "unhealthy",
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
test("'all' matches everything", () => {
|
|
93
|
+
for (const id of ["h", "d", "u", "missing"]) {
|
|
94
|
+
expect(matchesHealth({ systemId: id, health: "all", statuses })).toBe(
|
|
95
|
+
true,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("'degraded' hides healthy rows, keeps degraded", () => {
|
|
101
|
+
expect(matchesHealth({ systemId: "d", health: "degraded", statuses })).toBe(
|
|
102
|
+
true,
|
|
103
|
+
);
|
|
104
|
+
expect(matchesHealth({ systemId: "h", health: "degraded", statuses })).toBe(
|
|
105
|
+
false,
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("'unknown' matches only no-data systems", () => {
|
|
110
|
+
expect(
|
|
111
|
+
matchesHealth({ systemId: "missing", health: "unknown", statuses }),
|
|
112
|
+
).toBe(true);
|
|
113
|
+
expect(matchesHealth({ systemId: "h", health: "unknown", statuses })).toBe(
|
|
114
|
+
false,
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("non-'all' filter with undefined statuses → everything unknown", () => {
|
|
119
|
+
expect(
|
|
120
|
+
matchesHealth({ systemId: "h", health: "healthy", statuses: undefined }),
|
|
121
|
+
).toBe(false);
|
|
122
|
+
expect(
|
|
123
|
+
matchesHealth({ systemId: "h", health: "unknown", statuses: undefined }),
|
|
124
|
+
).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CatalogHealthStatus,
|
|
3
|
+
CatalogHealthStatuses,
|
|
4
|
+
} from "@checkstack/catalog-common";
|
|
5
|
+
import type { HealthFilter } from "./browseState.logic";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Pure, DOM-free health-rollup logic for the catalog browse view.
|
|
9
|
+
*
|
|
10
|
+
* The group rollup and the health filter are BOTH derived from the per-system
|
|
11
|
+
* status DATA the `CatalogBrowseHealthSlot` filler reports (a
|
|
12
|
+
* `CatalogHealthStatuses` map) — NEVER from rendered badge output. This matters
|
|
13
|
+
* because a healthy system emits no badge (see `SystemHealthBadge`), so the only
|
|
14
|
+
* way to know a group is "all healthy" is to read every member's reported status
|
|
15
|
+
* from the data path. A system absent from the map is `"unknown"`, never healthy.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** The resolved rollup for one group section's header. */
|
|
19
|
+
export interface GroupHealthRollup {
|
|
20
|
+
/** Worst status among members, or `"unknown"` when no member has data. */
|
|
21
|
+
worst: CatalogHealthStatus | "unknown";
|
|
22
|
+
/** Count of degraded members. */
|
|
23
|
+
degraded: number;
|
|
24
|
+
/** Count of unhealthy members. */
|
|
25
|
+
unhealthy: number;
|
|
26
|
+
/** `true` when every member reported `"healthy"` (derived from data, not badges). */
|
|
27
|
+
allHealthy: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* `true` when at least one member has a reported status. When `false` the
|
|
30
|
+
* group header shows counts only (no health source / unfilled slot).
|
|
31
|
+
*/
|
|
32
|
+
hasData: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Severity ranking: higher = worse. `unknown` ranks below `healthy` (no signal). */
|
|
36
|
+
const SEVERITY: Record<CatalogHealthStatus | "unknown", number> = {
|
|
37
|
+
unknown: 0,
|
|
38
|
+
healthy: 1,
|
|
39
|
+
degraded: 2,
|
|
40
|
+
unhealthy: 3,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Resolve a single system's effective health from the reported map. A system the
|
|
45
|
+
* filler did not report is `"unknown"` (NOT healthy).
|
|
46
|
+
*/
|
|
47
|
+
export function resolveSystemHealth({
|
|
48
|
+
systemId,
|
|
49
|
+
statuses,
|
|
50
|
+
}: {
|
|
51
|
+
systemId: string;
|
|
52
|
+
statuses: CatalogHealthStatuses | undefined;
|
|
53
|
+
}): CatalogHealthStatus | "unknown" {
|
|
54
|
+
return statuses?.[systemId] ?? "unknown";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Compute the rollup for a group from its member ids and the reported statuses.
|
|
59
|
+
*
|
|
60
|
+
* - `worst` is the worst member status by severity (unhealthy > degraded >
|
|
61
|
+
* healthy > unknown). When no member has data, `worst` is `"unknown"`.
|
|
62
|
+
* - `allHealthy` is true only when there is at least one member, every member has
|
|
63
|
+
* a reported status, and every reported status is `"healthy"`. Derived purely
|
|
64
|
+
* from the data map.
|
|
65
|
+
*/
|
|
66
|
+
export function computeGroupRollup({
|
|
67
|
+
memberIds,
|
|
68
|
+
statuses,
|
|
69
|
+
}: {
|
|
70
|
+
memberIds: string[];
|
|
71
|
+
statuses: CatalogHealthStatuses | undefined;
|
|
72
|
+
}): GroupHealthRollup {
|
|
73
|
+
let degraded = 0;
|
|
74
|
+
let unhealthy = 0;
|
|
75
|
+
let worst: CatalogHealthStatus | "unknown" = "unknown";
|
|
76
|
+
let reportedCount = 0;
|
|
77
|
+
let healthyCount = 0;
|
|
78
|
+
|
|
79
|
+
for (const id of memberIds) {
|
|
80
|
+
const status = resolveSystemHealth({ systemId: id, statuses });
|
|
81
|
+
if (status !== "unknown") reportedCount += 1;
|
|
82
|
+
if (status === "healthy") healthyCount += 1;
|
|
83
|
+
if (status === "degraded") degraded += 1;
|
|
84
|
+
if (status === "unhealthy") unhealthy += 1;
|
|
85
|
+
if (SEVERITY[status] > SEVERITY[worst]) worst = status;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const hasData = reportedCount > 0;
|
|
89
|
+
const allHealthy =
|
|
90
|
+
memberIds.length > 0 &&
|
|
91
|
+
reportedCount === memberIds.length &&
|
|
92
|
+
healthyCount === memberIds.length;
|
|
93
|
+
|
|
94
|
+
return { worst, degraded, unhealthy, allHealthy, hasData };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Does a system pass the health filter? Derived from the reported status map.
|
|
99
|
+
*
|
|
100
|
+
* - `"all"` matches everything.
|
|
101
|
+
* - `"healthy" | "degraded" | "unhealthy"` match the system's reported status.
|
|
102
|
+
* - `"unknown"` matches systems with no reported status (no health source / no
|
|
103
|
+
* checks wired) — so an absent entry is `"unknown"`, never silently healthy.
|
|
104
|
+
*
|
|
105
|
+
* When `statuses` is undefined entirely (slot unfilled), every system resolves to
|
|
106
|
+
* `"unknown"`; the page disables the filter in that case, but the predicate still
|
|
107
|
+
* behaves correctly if a non-`all` value lingers in the URL.
|
|
108
|
+
*/
|
|
109
|
+
export function matchesHealth({
|
|
110
|
+
systemId,
|
|
111
|
+
health,
|
|
112
|
+
statuses,
|
|
113
|
+
}: {
|
|
114
|
+
systemId: string;
|
|
115
|
+
health: HealthFilter;
|
|
116
|
+
statuses: CatalogHealthStatuses | undefined;
|
|
117
|
+
}): boolean {
|
|
118
|
+
if (health === "all") return true;
|
|
119
|
+
return resolveSystemHealth({ systemId, statuses }) === health;
|
|
120
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { CatalogHealthStatuses } from "@checkstack/catalog-common";
|
|
3
|
+
import { healthStatusesEqual } from "./healthStatuses.logic";
|
|
4
|
+
|
|
5
|
+
describe("healthStatusesEqual", () => {
|
|
6
|
+
test("first report (prev null) is always a change", () => {
|
|
7
|
+
expect(healthStatusesEqual(null, {})).toBe(false);
|
|
8
|
+
expect(healthStatusesEqual(null, { a: "healthy" })).toBe(false);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("same values in same shape are equal (ignores object identity)", () => {
|
|
12
|
+
const prev: CatalogHealthStatuses = { a: "healthy", b: "degraded" };
|
|
13
|
+
const next: CatalogHealthStatuses = { a: "healthy", b: "degraded" };
|
|
14
|
+
expect(prev).not.toBe(next);
|
|
15
|
+
expect(healthStatusesEqual(prev, next)).toBe(true);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("two empty maps are equal", () => {
|
|
19
|
+
expect(healthStatusesEqual({}, {})).toBe(true);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("a changed status value is not equal", () => {
|
|
23
|
+
expect(
|
|
24
|
+
healthStatusesEqual({ a: "healthy" }, { a: "unhealthy" }),
|
|
25
|
+
).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("an added system is not equal", () => {
|
|
29
|
+
expect(
|
|
30
|
+
healthStatusesEqual({ a: "healthy" }, { a: "healthy", b: "healthy" }),
|
|
31
|
+
).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("a removed system is not equal", () => {
|
|
35
|
+
expect(
|
|
36
|
+
healthStatusesEqual({ a: "healthy", b: "healthy" }, { a: "healthy" }),
|
|
37
|
+
).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { CatalogHealthStatuses } from "@checkstack/catalog-common";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Value-equality for two {@link CatalogHealthStatuses} maps.
|
|
5
|
+
*
|
|
6
|
+
* The browse health filler (e.g. healthcheck-frontend) re-reports a freshly
|
|
7
|
+
* built statuses object on every render/poll - a new object identity even when
|
|
8
|
+
* the per-system values are unchanged. If the browse page stored every report,
|
|
9
|
+
* the derived browse model (and therefore every system row) would re-render and
|
|
10
|
+
* remount continuously, making the list impossible to interact with. The page
|
|
11
|
+
* uses this comparison to ignore no-op reports and keep a stable state object.
|
|
12
|
+
*
|
|
13
|
+
* `prev` is `null` until the first report; a first report (even an empty map,
|
|
14
|
+
* meaning "a health source is installed but nothing to report") is always a
|
|
15
|
+
* real change.
|
|
16
|
+
*/
|
|
17
|
+
export function healthStatusesEqual(
|
|
18
|
+
prev: CatalogHealthStatuses | null,
|
|
19
|
+
next: CatalogHealthStatuses,
|
|
20
|
+
): boolean {
|
|
21
|
+
if (prev === null) return false;
|
|
22
|
+
const prevKeys = Object.keys(prev);
|
|
23
|
+
const nextKeys = Object.keys(next);
|
|
24
|
+
if (prevKeys.length !== nextKeys.length) return false;
|
|
25
|
+
for (const key of prevKeys) {
|
|
26
|
+
if (prev[key] !== next[key]) return false;
|
|
27
|
+
}
|
|
28
|
+
return true;
|
|
29
|
+
}
|