@checkstack/catalog-frontend 0.10.7 → 0.11.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 +133 -0
- package/package.json +10 -9
- 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,111 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
metadataToRows,
|
|
4
|
+
rowsToMetadata,
|
|
5
|
+
hasDuplicateKeys,
|
|
6
|
+
emptyRow,
|
|
7
|
+
toggleSelectedId,
|
|
8
|
+
type CustomFieldRow,
|
|
9
|
+
} from "./environment-fields.logic";
|
|
10
|
+
|
|
11
|
+
const row = (key: string, value: string): CustomFieldRow => ({
|
|
12
|
+
rowId: `r-${key}-${value}`,
|
|
13
|
+
key,
|
|
14
|
+
value,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("metadataToRows", () => {
|
|
18
|
+
it("returns no rows for null/undefined/empty", () => {
|
|
19
|
+
expect(metadataToRows(null)).toEqual([]);
|
|
20
|
+
expect(metadataToRows(undefined)).toEqual([]);
|
|
21
|
+
expect(metadataToRows({})).toEqual([]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("expands string fields verbatim", () => {
|
|
25
|
+
const rows = metadataToRows({ baseUrl: "https://x", region: "eu" });
|
|
26
|
+
expect(rows.map((r) => [r.key, r.value])).toEqual([
|
|
27
|
+
["baseUrl", "https://x"],
|
|
28
|
+
["region", "eu"],
|
|
29
|
+
]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("stringifies non-string values", () => {
|
|
33
|
+
const rows = metadataToRows({ tier: 1, enabled: true, list: ["a"] });
|
|
34
|
+
const byKey = Object.fromEntries(rows.map((r) => [r.key, r.value]));
|
|
35
|
+
expect(byKey.tier).toBe("1");
|
|
36
|
+
expect(byKey.enabled).toBe("true");
|
|
37
|
+
expect(byKey.list).toBe('["a"]');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("assigns unique row ids", () => {
|
|
41
|
+
const rows = metadataToRows({ a: "1", b: "2" });
|
|
42
|
+
expect(rows[0].rowId).not.toBe(rows[1].rowId);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("emptyRow", () => {
|
|
47
|
+
it("creates a blank row with a fresh id", () => {
|
|
48
|
+
const r1 = emptyRow();
|
|
49
|
+
const r2 = emptyRow();
|
|
50
|
+
expect(r1.key).toBe("");
|
|
51
|
+
expect(r1.value).toBe("");
|
|
52
|
+
expect(r1.rowId).not.toBe(r2.rowId);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("rowsToMetadata", () => {
|
|
57
|
+
it("collapses rows into a record", () => {
|
|
58
|
+
expect(rowsToMetadata([row("baseUrl", "https://x"), row("tier", "1")])).toEqual({
|
|
59
|
+
baseUrl: "https://x",
|
|
60
|
+
tier: "1",
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("drops rows with a blank/whitespace key", () => {
|
|
65
|
+
expect(rowsToMetadata([row("", "ignored"), row(" ", "x"), row("k", "v")])).toEqual({
|
|
66
|
+
k: "v",
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("trims keys but keeps values verbatim", () => {
|
|
71
|
+
expect(rowsToMetadata([row(" region ", " eu ")])).toEqual({
|
|
72
|
+
region: " eu ",
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("last duplicate key wins", () => {
|
|
77
|
+
expect(rowsToMetadata([row("k", "first"), row("k", "second")])).toEqual({
|
|
78
|
+
k: "second",
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("toggleSelectedId", () => {
|
|
84
|
+
it("adds an id that is not selected", () => {
|
|
85
|
+
expect(toggleSelectedId({ selected: ["a"], id: "b" })).toEqual(["a", "b"]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("removes an id that is already selected", () => {
|
|
89
|
+
expect(toggleSelectedId({ selected: ["a", "b"], id: "a" })).toEqual(["b"]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("does not mutate the input array", () => {
|
|
93
|
+
const selected = ["a"];
|
|
94
|
+
toggleSelectedId({ selected, id: "b" });
|
|
95
|
+
expect(selected).toEqual(["a"]);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("hasDuplicateKeys", () => {
|
|
100
|
+
it("is false when keys are unique", () => {
|
|
101
|
+
expect(hasDuplicateKeys([row("a", "1"), row("b", "2")])).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("ignores blank keys", () => {
|
|
105
|
+
expect(hasDuplicateKeys([row("", "1"), row("", "2"), row("a", "3")])).toBe(false);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("detects duplicate non-blank keys (after trim)", () => {
|
|
109
|
+
expect(hasDuplicateKeys([row("a ", "1"), row(" a", "2")])).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DOM-free helpers for the environment custom-fields editor.
|
|
3
|
+
*
|
|
4
|
+
* The editor renders free-form key/value rows. These helpers convert
|
|
5
|
+
* between the stored `metadata` record and the editable row list, and
|
|
6
|
+
* collapse the rows back into a record on save. Kept pure so they can be
|
|
7
|
+
* unit-tested without a DOM (CI runs `bun test` from the repo root without
|
|
8
|
+
* happy-dom).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface CustomFieldRow {
|
|
12
|
+
/** Stable client-side id so React keys survive reordering/removal. */
|
|
13
|
+
rowId: string;
|
|
14
|
+
key: string;
|
|
15
|
+
value: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let rowCounter = 0;
|
|
19
|
+
function nextRowId(): string {
|
|
20
|
+
rowCounter += 1;
|
|
21
|
+
return `field-${rowCounter}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Expand a stored `metadata` record into editable rows. Non-string values
|
|
26
|
+
* are stringified so the free-form editor can round-trip them as text
|
|
27
|
+
* (v1 custom fields are free-form, string-ish values).
|
|
28
|
+
*/
|
|
29
|
+
export function metadataToRows(
|
|
30
|
+
metadata: Record<string, unknown> | null | undefined,
|
|
31
|
+
): CustomFieldRow[] {
|
|
32
|
+
if (!metadata) return [];
|
|
33
|
+
return Object.entries(metadata).map(([key, value]) => ({
|
|
34
|
+
rowId: nextRowId(),
|
|
35
|
+
key,
|
|
36
|
+
value: stringifyValue(value),
|
|
37
|
+
}));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function stringifyValue(value: unknown): string {
|
|
41
|
+
if (value === null || value === undefined) return "";
|
|
42
|
+
if (typeof value === "string") return value;
|
|
43
|
+
if (typeof value === "number" || typeof value === "boolean") {
|
|
44
|
+
return String(value);
|
|
45
|
+
}
|
|
46
|
+
return JSON.stringify(value);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Create a fresh empty row (for the "add field" action). */
|
|
50
|
+
export function emptyRow(): CustomFieldRow {
|
|
51
|
+
return { rowId: nextRowId(), key: "", value: "" };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Collapse editable rows back into a metadata record. Rows with a blank
|
|
56
|
+
* (after-trim) key are dropped. On duplicate keys, the LAST row wins
|
|
57
|
+
* (consistent with how a JS object literal resolves duplicate keys).
|
|
58
|
+
* Keys are trimmed; values are kept verbatim.
|
|
59
|
+
*/
|
|
60
|
+
export function rowsToMetadata(
|
|
61
|
+
rows: ReadonlyArray<CustomFieldRow>,
|
|
62
|
+
): Record<string, string> {
|
|
63
|
+
const out: Record<string, string> = {};
|
|
64
|
+
for (const row of rows) {
|
|
65
|
+
const key = row.key.trim();
|
|
66
|
+
if (key === "") continue;
|
|
67
|
+
out[key] = row.value;
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Toggle an environment id in/out of a selected-id set. Returns a new
|
|
74
|
+
* array (does not mutate). Used by the per-system environment picker.
|
|
75
|
+
*/
|
|
76
|
+
export function toggleSelectedId(props: {
|
|
77
|
+
selected: ReadonlyArray<string>;
|
|
78
|
+
id: string;
|
|
79
|
+
}): string[] {
|
|
80
|
+
const { selected, id } = props;
|
|
81
|
+
return selected.includes(id)
|
|
82
|
+
? selected.filter((existing) => existing !== id)
|
|
83
|
+
: [...selected, id];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** True when any row has a non-blank key that duplicates another row's key. */
|
|
87
|
+
export function hasDuplicateKeys(
|
|
88
|
+
rows: ReadonlyArray<CustomFieldRow>,
|
|
89
|
+
): boolean {
|
|
90
|
+
const seen = new Set<string>();
|
|
91
|
+
for (const row of rows) {
|
|
92
|
+
const key = row.key.trim();
|
|
93
|
+
if (key === "") continue;
|
|
94
|
+
if (seen.has(key)) return true;
|
|
95
|
+
seen.add(key);
|
|
96
|
+
}
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
toPreviewOptions,
|
|
4
|
+
environmentToPreviewFields,
|
|
5
|
+
findSelectedEnvironment,
|
|
6
|
+
} from "./environment-preview.logic";
|
|
7
|
+
import type { Environment } from "@checkstack/catalog-common";
|
|
8
|
+
|
|
9
|
+
const env = (over: Partial<Environment> & Pick<Environment, "id" | "name">): Environment => ({
|
|
10
|
+
description: null,
|
|
11
|
+
systemIds: [],
|
|
12
|
+
metadata: null,
|
|
13
|
+
createdAt: new Date(0),
|
|
14
|
+
updatedAt: new Date(0),
|
|
15
|
+
...over,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("toPreviewOptions", () => {
|
|
19
|
+
it("maps id + name preserving order", () => {
|
|
20
|
+
const options = toPreviewOptions([
|
|
21
|
+
env({ id: "e1", name: "Staging" }),
|
|
22
|
+
env({ id: "e2", name: "Production" }),
|
|
23
|
+
]);
|
|
24
|
+
expect(options).toEqual([
|
|
25
|
+
{ id: "e1", name: "Staging" },
|
|
26
|
+
{ id: "e2", name: "Production" },
|
|
27
|
+
]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("returns empty for empty input", () => {
|
|
31
|
+
expect(toPreviewOptions([])).toEqual([]);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("environmentToPreviewFields", () => {
|
|
36
|
+
it("returns the environment metadata verbatim", () => {
|
|
37
|
+
const fields = environmentToPreviewFields(
|
|
38
|
+
env({ id: "e1", name: "Staging", metadata: { baseUrl: "https://staging.example.com" } }),
|
|
39
|
+
);
|
|
40
|
+
expect(fields).toEqual({ baseUrl: "https://staging.example.com" });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("returns an empty object when metadata is null", () => {
|
|
44
|
+
expect(environmentToPreviewFields(env({ id: "e1", name: "Staging" }))).toEqual({});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("returns an empty object for null/undefined environment", () => {
|
|
48
|
+
expect(environmentToPreviewFields(null)).toEqual({});
|
|
49
|
+
expect(environmentToPreviewFields(undefined)).toEqual({});
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("findSelectedEnvironment", () => {
|
|
54
|
+
const environments = [
|
|
55
|
+
env({ id: "e1", name: "Staging" }),
|
|
56
|
+
env({ id: "e2", name: "Production" }),
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
it("finds the selected environment by id", () => {
|
|
60
|
+
expect(
|
|
61
|
+
findSelectedEnvironment({ environments, selectedId: "e2" })?.name,
|
|
62
|
+
).toBe("Production");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("returns null when no id is selected", () => {
|
|
66
|
+
expect(findSelectedEnvironment({ environments, selectedId: null })).toBeNull();
|
|
67
|
+
expect(findSelectedEnvironment({ environments, selectedId: undefined })).toBeNull();
|
|
68
|
+
expect(findSelectedEnvironment({ environments, selectedId: "" })).toBeNull();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("returns null when the id is no longer present", () => {
|
|
72
|
+
expect(
|
|
73
|
+
findSelectedEnvironment({ environments, selectedId: "gone" }),
|
|
74
|
+
).toBeNull();
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DOM-free helpers backing the "Preview as: <environment>" picker.
|
|
3
|
+
*
|
|
4
|
+
* Host plugins (e.g. the health-check collector editor) let an author pick a
|
|
5
|
+
* catalog environment to preview `x-templatable` config fields against. These
|
|
6
|
+
* helpers map a catalog `Environment` onto the `environment` slice of a
|
|
7
|
+
* template-preview context and pick a selected environment by id. Kept pure so
|
|
8
|
+
* they can be unit-tested without a DOM (CI runs `bun test` from the repo root
|
|
9
|
+
* without happy-dom).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { Environment } from "@checkstack/catalog-common";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Option shown in the environment picker. `name` is the catalog environment's
|
|
16
|
+
* display name; `id` is its stable id (the picker's `value`).
|
|
17
|
+
*/
|
|
18
|
+
export interface EnvironmentPreviewOption {
|
|
19
|
+
id: string;
|
|
20
|
+
name: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Map catalog environments to picker options, preserving input order. The
|
|
25
|
+
* environment's display name is used verbatim; ids are assumed unique (the
|
|
26
|
+
* catalog guarantees this).
|
|
27
|
+
*/
|
|
28
|
+
export function toPreviewOptions(
|
|
29
|
+
environments: ReadonlyArray<Environment>,
|
|
30
|
+
): EnvironmentPreviewOption[] {
|
|
31
|
+
return environments.map((env) => ({ id: env.id, name: env.name }));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The custom-field record a chosen environment contributes to the preview
|
|
36
|
+
* context's `environment` slice. Mirrors the run-time semantics exactly:
|
|
37
|
+
* the backend exposes `environment.fields = env.metadata ?? {}` to the
|
|
38
|
+
* `x-templatable` render pass (see `effective-environments.ts`), so the
|
|
39
|
+
* editor preview must use the same source to never diverge.
|
|
40
|
+
*/
|
|
41
|
+
export function environmentToPreviewFields(
|
|
42
|
+
environment: Environment | null | undefined,
|
|
43
|
+
): Record<string, unknown> {
|
|
44
|
+
return environment?.metadata ?? {};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Resolve the selected environment from a list by id. Returns `null` when no
|
|
49
|
+
* id is selected or the id is no longer present (e.g. the environment was
|
|
50
|
+
* removed since selection). Callers treat `null` as "no preview environment".
|
|
51
|
+
*/
|
|
52
|
+
export function findSelectedEnvironment({
|
|
53
|
+
environments,
|
|
54
|
+
selectedId,
|
|
55
|
+
}: {
|
|
56
|
+
environments: ReadonlyArray<Environment>;
|
|
57
|
+
selectedId: string | null | undefined;
|
|
58
|
+
}): Environment | null {
|
|
59
|
+
if (!selectedId) return null;
|
|
60
|
+
return environments.find((env) => env.id === selectedId) ?? null;
|
|
61
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { useState, type ReactNode } from "react";
|
|
2
|
+
import { Popover, PopoverTrigger, PopoverContent, cn } from "@checkstack/ui";
|
|
3
|
+
|
|
4
|
+
export interface AssignMenuItem {
|
|
5
|
+
id: string;
|
|
6
|
+
label: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface AssignMenuProps {
|
|
10
|
+
/** Trigger button content (an icon and/or text). */
|
|
11
|
+
trigger: ReactNode;
|
|
12
|
+
items: AssignMenuItem[];
|
|
13
|
+
onSelect: (id: string) => void;
|
|
14
|
+
disabled?: boolean;
|
|
15
|
+
/** Accessible label for the trigger. */
|
|
16
|
+
triggerLabel: string;
|
|
17
|
+
/** Empty-state text when there are no items to choose. */
|
|
18
|
+
emptyLabel?: string;
|
|
19
|
+
className?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A compact "assign to …" picker (add a system to a group, a group to a system,
|
|
24
|
+
* attach an environment, bulk-assign). Built on the Radix `Popover` so the menu
|
|
25
|
+
* is PORTALED out of the table's `overflow-auto` wrapper - a plain absolute
|
|
26
|
+
* popover would be clipped (and unclickable) inside a table cell.
|
|
27
|
+
*/
|
|
28
|
+
export function AssignMenu({
|
|
29
|
+
trigger,
|
|
30
|
+
items,
|
|
31
|
+
onSelect,
|
|
32
|
+
disabled,
|
|
33
|
+
triggerLabel,
|
|
34
|
+
emptyLabel = "Nothing to add",
|
|
35
|
+
className,
|
|
36
|
+
}: AssignMenuProps): React.ReactElement {
|
|
37
|
+
const [open, setOpen] = useState(false);
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
41
|
+
<PopoverTrigger asChild>
|
|
42
|
+
<button
|
|
43
|
+
type="button"
|
|
44
|
+
disabled={disabled}
|
|
45
|
+
aria-label={triggerLabel}
|
|
46
|
+
title={triggerLabel}
|
|
47
|
+
className={cn(
|
|
48
|
+
"inline-flex items-center gap-1 rounded-md border border-dashed border-border px-2 py-0.5 text-xs text-muted-foreground transition-colors hover:border-border hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-50",
|
|
49
|
+
className,
|
|
50
|
+
)}
|
|
51
|
+
>
|
|
52
|
+
{trigger}
|
|
53
|
+
</button>
|
|
54
|
+
</PopoverTrigger>
|
|
55
|
+
<PopoverContent align="start" className="max-h-64 w-56 overflow-y-auto p-1">
|
|
56
|
+
{items.length === 0 ? (
|
|
57
|
+
<p className="px-2 py-1.5 text-xs text-muted-foreground">
|
|
58
|
+
{emptyLabel}
|
|
59
|
+
</p>
|
|
60
|
+
) : (
|
|
61
|
+
items.map((item) => (
|
|
62
|
+
<button
|
|
63
|
+
key={item.id}
|
|
64
|
+
type="button"
|
|
65
|
+
className="block w-full truncate rounded-sm px-2 py-1.5 text-left text-sm hover:bg-muted"
|
|
66
|
+
onClick={() => {
|
|
67
|
+
onSelect(item.id);
|
|
68
|
+
setOpen(false);
|
|
69
|
+
}}
|
|
70
|
+
>
|
|
71
|
+
{item.label}
|
|
72
|
+
</button>
|
|
73
|
+
))
|
|
74
|
+
)}
|
|
75
|
+
</PopoverContent>
|
|
76
|
+
</Popover>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import {
|
|
3
|
+
Table,
|
|
4
|
+
TableBody,
|
|
5
|
+
TableCell,
|
|
6
|
+
TableHead,
|
|
7
|
+
TableHeader,
|
|
8
|
+
TableRow,
|
|
9
|
+
Button,
|
|
10
|
+
EmptyState,
|
|
11
|
+
ListEmptyState,
|
|
12
|
+
} from "@checkstack/ui";
|
|
13
|
+
import { Plus, Boxes, Pencil, Trash2, X } from "lucide-react";
|
|
14
|
+
import type { Environment, System } from "../../api";
|
|
15
|
+
import { AssignMenu } from "./AssignMenu";
|
|
16
|
+
|
|
17
|
+
export interface EnvironmentsTabProps {
|
|
18
|
+
/** Environments after search/filter. */
|
|
19
|
+
environments: Environment[];
|
|
20
|
+
totalCount: number;
|
|
21
|
+
/** Whether the user may create/edit/delete environment definitions. */
|
|
22
|
+
canManage: boolean;
|
|
23
|
+
allSystems: System[];
|
|
24
|
+
onAddSystemToEnvironment: (systemId: string, environmentId: string) => void;
|
|
25
|
+
onRemoveSystemFromEnvironment: (
|
|
26
|
+
systemId: string,
|
|
27
|
+
environmentId: string,
|
|
28
|
+
) => void;
|
|
29
|
+
onAddEnvironment: () => void;
|
|
30
|
+
onEditEnvironment: (environment: Environment) => void;
|
|
31
|
+
onDeleteEnvironment: (id: string) => void;
|
|
32
|
+
onClearFilters: () => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function EnvironmentsTab(
|
|
36
|
+
props: EnvironmentsTabProps,
|
|
37
|
+
): React.ReactElement {
|
|
38
|
+
const { environments, totalCount, canManage, allSystems, onAddEnvironment } =
|
|
39
|
+
props;
|
|
40
|
+
|
|
41
|
+
const systemsById = useMemo(() => {
|
|
42
|
+
const map = new Map<string, System>();
|
|
43
|
+
for (const system of allSystems) map.set(system.id, system);
|
|
44
|
+
return map;
|
|
45
|
+
}, [allSystems]);
|
|
46
|
+
|
|
47
|
+
const header = (
|
|
48
|
+
<div className="mb-4 flex items-center justify-between gap-2">
|
|
49
|
+
<div>
|
|
50
|
+
<h2 className="flex items-center gap-2 text-lg font-semibold">
|
|
51
|
+
<Boxes className="h-5 w-5 text-muted-foreground" />
|
|
52
|
+
Environments
|
|
53
|
+
<span className="text-sm font-normal text-muted-foreground">
|
|
54
|
+
{totalCount}
|
|
55
|
+
</span>
|
|
56
|
+
</h2>
|
|
57
|
+
<p className="mt-1 text-sm text-muted-foreground">
|
|
58
|
+
Instance-wide environments carry free-form custom fields and can be
|
|
59
|
+
attached to any system.
|
|
60
|
+
</p>
|
|
61
|
+
</div>
|
|
62
|
+
{canManage && (
|
|
63
|
+
<Button size="sm" onClick={onAddEnvironment}>
|
|
64
|
+
<Plus className="mr-2 h-4 w-4" />
|
|
65
|
+
Add Environment
|
|
66
|
+
</Button>
|
|
67
|
+
)}
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
if (totalCount === 0) {
|
|
72
|
+
return (
|
|
73
|
+
<div>
|
|
74
|
+
{header}
|
|
75
|
+
<EmptyState
|
|
76
|
+
icon={<Boxes className="size-10" />}
|
|
77
|
+
title="No environments yet"
|
|
78
|
+
description="Environments (prod, staging, eu-west, …) hold custom fields you can attach to systems and reference in templates."
|
|
79
|
+
actions={
|
|
80
|
+
canManage ? (
|
|
81
|
+
<Button onClick={onAddEnvironment}>
|
|
82
|
+
<Plus className="mr-2 h-4 w-4" />
|
|
83
|
+
Add your first environment
|
|
84
|
+
</Button>
|
|
85
|
+
) : undefined
|
|
86
|
+
}
|
|
87
|
+
/>
|
|
88
|
+
</div>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<div>
|
|
94
|
+
{header}
|
|
95
|
+
{environments.length === 0 ? (
|
|
96
|
+
<ListEmptyState
|
|
97
|
+
resource="environments"
|
|
98
|
+
description="No environments match the current search."
|
|
99
|
+
actions={
|
|
100
|
+
<Button variant="outline" onClick={props.onClearFilters}>
|
|
101
|
+
Clear filters
|
|
102
|
+
</Button>
|
|
103
|
+
}
|
|
104
|
+
/>
|
|
105
|
+
) : (
|
|
106
|
+
<div className="rounded-lg border border-border">
|
|
107
|
+
<Table>
|
|
108
|
+
<TableHeader>
|
|
109
|
+
<TableRow>
|
|
110
|
+
<TableHead className="w-48">Name</TableHead>
|
|
111
|
+
<TableHead>Systems</TableHead>
|
|
112
|
+
<TableHead className="w-20">Fields</TableHead>
|
|
113
|
+
<TableHead className="w-px text-right">Actions</TableHead>
|
|
114
|
+
</TableRow>
|
|
115
|
+
</TableHeader>
|
|
116
|
+
<TableBody>
|
|
117
|
+
{environments.map((environment) => {
|
|
118
|
+
const fieldCount = Object.keys(
|
|
119
|
+
environment.metadata ?? {},
|
|
120
|
+
).length;
|
|
121
|
+
const memberIds = environment.systemIds ?? [];
|
|
122
|
+
const members = memberIds
|
|
123
|
+
.map((id) => systemsById.get(id))
|
|
124
|
+
.filter((s): s is System => s !== undefined);
|
|
125
|
+
const available = allSystems.filter(
|
|
126
|
+
(s) => !memberIds.includes(s.id),
|
|
127
|
+
);
|
|
128
|
+
return (
|
|
129
|
+
<TableRow key={environment.id}>
|
|
130
|
+
<TableCell className="align-top">
|
|
131
|
+
<div className="font-medium text-foreground">
|
|
132
|
+
{environment.name}
|
|
133
|
+
</div>
|
|
134
|
+
{environment.description && (
|
|
135
|
+
<div className="text-xs text-muted-foreground">
|
|
136
|
+
{environment.description}
|
|
137
|
+
</div>
|
|
138
|
+
)}
|
|
139
|
+
</TableCell>
|
|
140
|
+
<TableCell>
|
|
141
|
+
<div className="flex flex-wrap items-center gap-1.5">
|
|
142
|
+
{members.length === 0 && (
|
|
143
|
+
<span className="text-xs text-muted-foreground">
|
|
144
|
+
No systems
|
|
145
|
+
</span>
|
|
146
|
+
)}
|
|
147
|
+
{members.map((system) => (
|
|
148
|
+
<span
|
|
149
|
+
key={system.id}
|
|
150
|
+
className="inline-flex items-center gap-1 rounded-full bg-secondary px-2 py-0.5 text-xs text-secondary-foreground"
|
|
151
|
+
>
|
|
152
|
+
{system.name}
|
|
153
|
+
<button
|
|
154
|
+
type="button"
|
|
155
|
+
aria-label={`Remove ${system.name} from ${environment.name}`}
|
|
156
|
+
title={`Remove ${system.name}`}
|
|
157
|
+
onClick={() =>
|
|
158
|
+
props.onRemoveSystemFromEnvironment(
|
|
159
|
+
system.id,
|
|
160
|
+
environment.id,
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
className="text-muted-foreground hover:text-foreground"
|
|
164
|
+
>
|
|
165
|
+
<X className="h-3 w-3" />
|
|
166
|
+
</button>
|
|
167
|
+
</span>
|
|
168
|
+
))}
|
|
169
|
+
<AssignMenu
|
|
170
|
+
disabled={available.length === 0}
|
|
171
|
+
triggerLabel={`Attach a system to ${environment.name}`}
|
|
172
|
+
trigger={
|
|
173
|
+
<>
|
|
174
|
+
<Plus className="h-3 w-3" />
|
|
175
|
+
System
|
|
176
|
+
</>
|
|
177
|
+
}
|
|
178
|
+
items={available.map((s) => ({
|
|
179
|
+
id: s.id,
|
|
180
|
+
label: s.name,
|
|
181
|
+
}))}
|
|
182
|
+
emptyLabel="All systems attached"
|
|
183
|
+
onSelect={(systemId) =>
|
|
184
|
+
props.onAddSystemToEnvironment(
|
|
185
|
+
systemId,
|
|
186
|
+
environment.id,
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
/>
|
|
190
|
+
</div>
|
|
191
|
+
</TableCell>
|
|
192
|
+
<TableCell className="align-top text-sm text-muted-foreground">
|
|
193
|
+
{fieldCount}
|
|
194
|
+
</TableCell>
|
|
195
|
+
<TableCell className="align-top">
|
|
196
|
+
{canManage && (
|
|
197
|
+
<div className="flex items-center justify-end gap-1">
|
|
198
|
+
<Button
|
|
199
|
+
variant="ghost"
|
|
200
|
+
size="sm"
|
|
201
|
+
className="h-7 w-7 p-0"
|
|
202
|
+
aria-label={`Edit ${environment.name}`}
|
|
203
|
+
onClick={() => props.onEditEnvironment(environment)}
|
|
204
|
+
>
|
|
205
|
+
<Pencil className="h-3.5 w-3.5" />
|
|
206
|
+
</Button>
|
|
207
|
+
<Button
|
|
208
|
+
variant="ghost"
|
|
209
|
+
size="sm"
|
|
210
|
+
className="h-7 w-7 p-0 text-destructive hover:bg-destructive/10 hover:text-destructive/90"
|
|
211
|
+
aria-label={`Delete ${environment.name}`}
|
|
212
|
+
onClick={() =>
|
|
213
|
+
props.onDeleteEnvironment(environment.id)
|
|
214
|
+
}
|
|
215
|
+
>
|
|
216
|
+
<Trash2 className="h-3.5 w-3.5" />
|
|
217
|
+
</Button>
|
|
218
|
+
</div>
|
|
219
|
+
)}
|
|
220
|
+
</TableCell>
|
|
221
|
+
</TableRow>
|
|
222
|
+
);
|
|
223
|
+
})}
|
|
224
|
+
</TableBody>
|
|
225
|
+
</Table>
|
|
226
|
+
</div>
|
|
227
|
+
)}
|
|
228
|
+
</div>
|
|
229
|
+
);
|
|
230
|
+
}
|