@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.
Files changed (40) hide show
  1. package/CHANGELOG.md +133 -0
  2. package/package.json +10 -9
  3. package/src/api.ts +6 -1
  4. package/src/components/CatalogConfigPage.tsx +337 -271
  5. package/src/components/CatalogPage.tsx +172 -11
  6. package/src/components/EnvironmentEditor.tsx +220 -0
  7. package/src/components/EnvironmentPreviewPicker.tsx +61 -0
  8. package/src/components/SystemDetailPage.tsx +47 -34
  9. package/src/components/SystemEditor.tsx +6 -0
  10. package/src/components/SystemEnvironmentsEditor.tsx +98 -0
  11. package/src/components/browse/CatalogBrowseHealth.tsx +36 -0
  12. package/src/components/browse/CatalogBrowseToolbar.tsx +173 -0
  13. package/src/components/browse/CatalogGroupSection.tsx +165 -0
  14. package/src/components/browse/CatalogSystemRow.tsx +63 -0
  15. package/src/components/browse/browseState.logic.test.ts +125 -0
  16. package/src/components/browse/browseState.logic.ts +158 -0
  17. package/src/components/browse/filterEntities.logic.test.ts +479 -0
  18. package/src/components/browse/filterEntities.logic.ts +360 -0
  19. package/src/components/browse/healthRollup.logic.test.ts +126 -0
  20. package/src/components/browse/healthRollup.logic.ts +120 -0
  21. package/src/components/browse/healthStatuses.logic.test.ts +39 -0
  22. package/src/components/browse/healthStatuses.logic.ts +29 -0
  23. package/src/components/environment-fields.logic.test.ts +111 -0
  24. package/src/components/environment-fields.logic.ts +98 -0
  25. package/src/components/environment-preview.logic.test.ts +76 -0
  26. package/src/components/environment-preview.logic.ts +61 -0
  27. package/src/components/manage/AssignMenu.tsx +78 -0
  28. package/src/components/manage/EnvironmentsTab.tsx +230 -0
  29. package/src/components/manage/GroupsTab.tsx +274 -0
  30. package/src/components/manage/SystemsTab.tsx +430 -0
  31. package/src/hooks/useCatalogBrowseState.ts +107 -0
  32. package/src/hooks/useDebouncedValue.ts +21 -0
  33. package/src/index.tsx +32 -20
  34. package/src/utils/formatDate.logic.test.ts +44 -0
  35. package/src/utils/formatDate.logic.ts +27 -0
  36. package/src/utils/normalizeMetadata.logic.test.ts +67 -0
  37. package/src/utils/normalizeMetadata.logic.ts +53 -0
  38. package/src/components/DraggableSystem.tsx +0 -200
  39. package/src/components/DroppableGroup.tsx +0 -174
  40. package/src/components/UserMenuItems.tsx +0 -31
@@ -0,0 +1,107 @@
1
+ import { useCallback, useMemo } from "react";
2
+ import { useSearchParams } from "react-router-dom";
3
+ import {
4
+ parseBrowseState,
5
+ serializeBrowseState,
6
+ type BrowseState,
7
+ type Density,
8
+ type HealthFilter,
9
+ } from "../components/browse/browseState.logic";
10
+ import { useDebouncedValue } from "./useDebouncedValue";
11
+
12
+ const QUERY_DEBOUNCE_MS = 150;
13
+
14
+ export interface CatalogBrowseStateApi {
15
+ /** Live state reflecting the URL (query updates immediately for the input). */
16
+ state: BrowseState;
17
+ /** Query debounced for filtering, so typing stays smooth on large lists. */
18
+ debouncedQuery: string;
19
+ setQuery: (query: string) => void;
20
+ setGroup: (group: string | null) => void;
21
+ setHealth: (health: HealthFilter) => void;
22
+ setTag: (tag: string | null) => void;
23
+ setDensity: (density: Density) => void;
24
+ /** Force a section's open/closed state (overrides the default policy). */
25
+ setSectionOpen: (id: string, open: boolean) => void;
26
+ /** Clear every filter (query/group/health/tag) but keep density + open. */
27
+ clearFilters: () => void;
28
+ }
29
+
30
+ /**
31
+ * Bridge between the browse URL params and the page. All persistence logic
32
+ * lives in `browseState.logic.ts` (DOM-free, tested); this hook only wires it
33
+ * to `useSearchParams` and adds query debouncing.
34
+ */
35
+ export function useCatalogBrowseState(): CatalogBrowseStateApi {
36
+ const [searchParams, setSearchParams] = useSearchParams();
37
+
38
+ const state = useMemo(
39
+ () => parseBrowseState(searchParams),
40
+ [searchParams],
41
+ );
42
+
43
+ const debouncedQuery = useDebouncedValue(state.query, QUERY_DEBOUNCE_MS);
44
+
45
+ const commit = useCallback(
46
+ (next: BrowseState) => {
47
+ const serialized = serializeBrowseState(next);
48
+ setSearchParams(
49
+ (prev) => {
50
+ const updated = new URLSearchParams(prev);
51
+ for (const [key, value] of Object.entries(serialized)) {
52
+ if (value.length > 0) {
53
+ updated.set(key, value);
54
+ } else {
55
+ updated.delete(key);
56
+ }
57
+ }
58
+ return updated;
59
+ },
60
+ { replace: true },
61
+ );
62
+ },
63
+ [setSearchParams],
64
+ );
65
+
66
+ const setQuery = useCallback(
67
+ (query: string) => commit({ ...state, query }),
68
+ [commit, state],
69
+ );
70
+ const setGroup = useCallback(
71
+ (group: string | null) => commit({ ...state, group }),
72
+ [commit, state],
73
+ );
74
+ const setHealth = useCallback(
75
+ (health: HealthFilter) => commit({ ...state, health }),
76
+ [commit, state],
77
+ );
78
+ const setTag = useCallback(
79
+ (tag: string | null) => commit({ ...state, tag }),
80
+ [commit, state],
81
+ );
82
+ const setDensity = useCallback(
83
+ (density: Density) => commit({ ...state, density }),
84
+ [commit, state],
85
+ );
86
+ const setSectionOpen = useCallback(
87
+ (id: string, open: boolean) =>
88
+ commit({ ...state, open: { ...state.open, [id]: open } }),
89
+ [commit, state],
90
+ );
91
+ const clearFilters = useCallback(
92
+ () => commit({ ...state, query: "", group: null, health: "all", tag: null }),
93
+ [commit, state],
94
+ );
95
+
96
+ return {
97
+ state,
98
+ debouncedQuery,
99
+ setQuery,
100
+ setGroup,
101
+ setHealth,
102
+ setTag,
103
+ setDensity,
104
+ setSectionOpen,
105
+ clearFilters,
106
+ };
107
+ }
@@ -0,0 +1,21 @@
1
+ import React from "react";
2
+
3
+ /**
4
+ * Return `value` debounced by `delayMs`: the returned value only updates after
5
+ * `value` has stopped changing for `delayMs`. Used to throttle the live browse
6
+ * search so a fast typist doesn't re-filter a large catalog on every keystroke.
7
+ *
8
+ * Copied (not imported) from script-packages-frontend per the code-style guide —
9
+ * there is no shared `useDebounce` in `@checkstack/ui` yet, and adding one
10
+ * unilaterally is out of scope.
11
+ */
12
+ export function useDebouncedValue<T>(value: T, delayMs: number): T {
13
+ const [debounced, setDebounced] = React.useState(value);
14
+
15
+ React.useEffect(() => {
16
+ const timer = setTimeout(() => setDebounced(value), delayMs);
17
+ return () => clearTimeout(timer);
18
+ }, [value, delayMs]);
19
+
20
+ return debounced;
21
+ }
package/src/index.tsx CHANGED
@@ -1,8 +1,4 @@
1
- import {
2
- UserMenuItemsSlot,
3
- createSlotExtension,
4
- createFrontendPlugin,
5
- } from "@checkstack/frontend-api";
1
+ import { createFrontendPlugin } from "@checkstack/frontend-api";
6
2
  import {
7
3
  catalogRoutes,
8
4
  pluginMetadata,
@@ -12,11 +8,6 @@ import {
12
8
  import { Server, FolderTree } from "lucide-react";
13
9
  import { registerSubjectKind } from "@checkstack/notification-frontend";
14
10
 
15
- import { CatalogPage } from "./components/CatalogPage";
16
- import { CatalogConfigPage } from "./components/CatalogConfigPage";
17
- import { CatalogUserMenuItems } from "./components/UserMenuItems";
18
- import { SystemDetailPage } from "./components/SystemDetailPage";
19
-
20
11
  // Notification subject kinds emitted by catalog (see catalog-common's
21
12
  // `createSystemSubject` / `createGroupSubject`). Registered at module load
22
13
  // so the notification bell + page render kind-appropriate icons.
@@ -36,25 +27,46 @@ export const catalogPlugin = createFrontendPlugin({
36
27
  routes: [
37
28
  {
38
29
  route: catalogRoutes.routes.home,
39
- element: <CatalogPage />,
30
+ load: () =>
31
+ import("./components/CatalogPage").then((m) => ({
32
+ default: m.CatalogPage,
33
+ })),
34
+ title: "Catalog",
35
+ nav: {
36
+ group: "Workspace",
37
+ icon: Server,
38
+ // Visible to anyone who can view systems in the catalog.
39
+ accessRule: catalogAccess.system.read,
40
+ },
40
41
  },
41
42
  {
42
43
  route: catalogRoutes.routes.config,
43
- element: <CatalogConfigPage />,
44
+ load: () =>
45
+ import("./components/CatalogConfigPage").then((m) => ({
46
+ default: m.CatalogConfigPage,
47
+ })),
44
48
  accessRule: catalogAccess.system.manage,
45
49
  },
46
50
  {
47
51
  route: catalogRoutes.routes.systemDetail,
48
- element: <SystemDetailPage />,
52
+ load: () =>
53
+ import("./components/SystemDetailPage").then((m) => ({
54
+ default: m.SystemDetailPage,
55
+ })),
49
56
  },
50
57
  ],
51
- extensions: [
52
- createSlotExtension(UserMenuItemsSlot, {
53
- id: "catalog.user-menu.items",
54
- component: CatalogUserMenuItems,
55
- metadata: { group: "Workspace" },
56
- }),
57
- ],
58
+ extensions: [],
58
59
  });
59
60
 
60
61
  export * from "./api";
62
+
63
+ // Reusable "Preview as: <environment>" picker + its DOM-free helpers, so host
64
+ // plugins can let config authors preview `x-templatable` fields against a
65
+ // catalog environment's custom fields.
66
+ export { EnvironmentPreviewPicker } from "./components/EnvironmentPreviewPicker";
67
+ export {
68
+ toPreviewOptions,
69
+ environmentToPreviewFields,
70
+ findSelectedEnvironment,
71
+ type EnvironmentPreviewOption,
72
+ } from "./components/environment-preview.logic";
@@ -0,0 +1,44 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { formatDate } from "./formatDate.logic";
3
+
4
+ describe("formatDate", () => {
5
+ test("returns '' for null", () => {
6
+ expect(formatDate(null)).toBe("");
7
+ });
8
+
9
+ test("returns '' for undefined", () => {
10
+ expect(formatDate(undefined)).toBe("");
11
+ });
12
+
13
+ test("returns '' for an invalid date string", () => {
14
+ expect(formatDate("not-a-date")).toBe("");
15
+ });
16
+
17
+ test("returns '' for an invalid Date object", () => {
18
+ expect(formatDate(new Date("not-a-date"))).toBe("");
19
+ });
20
+
21
+ test("does not embed 'en-US' locale in the output", () => {
22
+ // Run formatDate with a fixed date and verify the result does NOT contain
23
+ // a hardcoded locale marker. We can't assert the exact locale-formatted
24
+ // string since it depends on the runtime environment, but we can verify
25
+ // it produces a non-empty result and does not throw.
26
+ const result = formatDate(new Date("2025-01-05T00:00:00Z"));
27
+ expect(result.length).toBeGreaterThan(0);
28
+ });
29
+
30
+ test("accepts a Date object and produces a non-empty string", () => {
31
+ const date = new Date(2025, 0, 5); // Jan 5, 2025 local time
32
+ expect(formatDate(date).length).toBeGreaterThan(0);
33
+ });
34
+
35
+ test("accepts an ISO string and produces a non-empty string", () => {
36
+ expect(formatDate("2025-06-01T12:00:00.000Z").length).toBeGreaterThan(0);
37
+ });
38
+
39
+ test("a known fixed date produces output containing the year", () => {
40
+ const result = formatDate(new Date(2025, 5, 1)); // Jun 1, 2025
41
+ // The year should always appear regardless of locale
42
+ expect(result).toContain("2025");
43
+ });
44
+ });
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Date formatting helpers for catalog-frontend.
3
+ *
4
+ * Uses the browser/runtime locale (undefined) instead of a hardcoded "en-US"
5
+ * so dates render in the user's preferred locale.
6
+ */
7
+
8
+ /** Options used for human-readable catalog dates (e.g. "Jan 5, 2025"). */
9
+ const DATE_FORMAT_OPTIONS: Intl.DateTimeFormatOptions = {
10
+ year: "numeric",
11
+ month: "short",
12
+ day: "numeric",
13
+ };
14
+
15
+ /**
16
+ * Format a date value as a short human-readable string using the runtime
17
+ * locale. Accepts a `Date` object or an ISO string.
18
+ *
19
+ * Returns an empty string for invalid or absent dates so the caller never
20
+ * shows "Invalid Date" in the UI.
21
+ */
22
+ export function formatDate(value: Date | string | null | undefined): string {
23
+ if (!value) return "";
24
+ const date = value instanceof Date ? value : new Date(value);
25
+ if (Number.isNaN(date.getTime())) return "";
26
+ return date.toLocaleDateString(undefined, DATE_FORMAT_OPTIONS);
27
+ }
@@ -0,0 +1,67 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { normalizeMetadata } from "./normalizeMetadata.logic";
3
+
4
+ describe("normalizeMetadata", () => {
5
+ test("returns [] for null", () => {
6
+ expect(normalizeMetadata(null)).toEqual([]);
7
+ });
8
+
9
+ test("returns [] for undefined", () => {
10
+ expect(normalizeMetadata(undefined)).toEqual([]);
11
+ });
12
+
13
+ test("returns [] for an empty object", () => {
14
+ expect(normalizeMetadata({})).toEqual([]);
15
+ });
16
+
17
+ test("renders a string value inline", () => {
18
+ expect(normalizeMetadata({ owner: "platform-team" })).toEqual([
19
+ { key: "owner", displayValue: "platform-team" },
20
+ ]);
21
+ });
22
+
23
+ test("renders a number value inline", () => {
24
+ expect(normalizeMetadata({ priority: 1 })).toEqual([
25
+ { key: "priority", displayValue: "1" },
26
+ ]);
27
+ });
28
+
29
+ test("renders a boolean value inline", () => {
30
+ expect(normalizeMetadata({ critical: true })).toEqual([
31
+ { key: "critical", displayValue: "true" },
32
+ ]);
33
+ });
34
+
35
+ test("renders null value as 'null'", () => {
36
+ expect(normalizeMetadata({ deprecated: null })).toEqual([
37
+ { key: "deprecated", displayValue: "null" },
38
+ ]);
39
+ });
40
+
41
+ test("renders an object value as compact JSON", () => {
42
+ const result = normalizeMetadata({ links: { docs: "https://example.com" } });
43
+ expect(result).toEqual([
44
+ { key: "links", displayValue: '{"docs":"https://example.com"}' },
45
+ ]);
46
+ });
47
+
48
+ test("renders an array value as compact JSON", () => {
49
+ const result = normalizeMetadata({ tags: ["backend", "api"] });
50
+ expect(result).toEqual([
51
+ { key: "tags", displayValue: '["backend","api"]' },
52
+ ]);
53
+ });
54
+
55
+ test("handles multiple entries and preserves insertion order", () => {
56
+ const result = normalizeMetadata({
57
+ team: "payments",
58
+ version: 2,
59
+ active: false,
60
+ });
61
+ expect(result).toEqual([
62
+ { key: "team", displayValue: "payments" },
63
+ { key: "version", displayValue: "2" },
64
+ { key: "active", displayValue: "false" },
65
+ ]);
66
+ });
67
+ });
@@ -0,0 +1,53 @@
1
+ import { z } from "zod";
2
+
3
+ /** Validated shape of a system's metadata field. */
4
+ export const MetadataSchema = z.record(z.string(), z.unknown());
5
+
6
+ /** A single displayable key/value pair derived from raw metadata. */
7
+ export interface MetadataEntry {
8
+ key: string;
9
+ /** Human-readable value string: primitives as-is, objects/arrays as compact JSON. */
10
+ displayValue: string;
11
+ }
12
+
13
+ /**
14
+ * Normalise a raw `Record<string, unknown>` metadata object into a flat list
15
+ * of `{ key, displayValue }` pairs suitable for key/value rendering.
16
+ *
17
+ * - Primitives (`string`, `number`, `boolean`) are displayed as their string
18
+ * representation.
19
+ * - `null` is displayed as `"null"`.
20
+ * - Objects and arrays are displayed as compact `JSON.stringify` output inside
21
+ * a `<code>` element, so structure is preserved without a raw JSON blob.
22
+ * - An absent or empty metadata object yields `[]`.
23
+ */
24
+ export function normalizeMetadata(
25
+ meta: Record<string, unknown> | null | undefined,
26
+ ): MetadataEntry[] {
27
+ if (!meta || typeof meta !== "object") return [];
28
+
29
+ const parsed = MetadataSchema.safeParse(meta);
30
+ if (!parsed.success) return [];
31
+
32
+ return Object.entries(parsed.data).map(([key, value]) => ({
33
+ key,
34
+ displayValue: valueToDisplay(value),
35
+ }));
36
+ }
37
+
38
+ function valueToDisplay(value: unknown): string {
39
+ if (value === null) return "null";
40
+ if (value === undefined) return "";
41
+ if (
42
+ typeof value === "string" ||
43
+ typeof value === "number" ||
44
+ typeof value === "boolean"
45
+ ) {
46
+ return String(value);
47
+ }
48
+ try {
49
+ return JSON.stringify(value);
50
+ } catch {
51
+ return String(value);
52
+ }
53
+ }
@@ -1,200 +0,0 @@
1
- import { useState } from "react";
2
- import { useDraggable } from "@dnd-kit/core";
3
- import { GripVertical, Edit, Trash2, FolderPlus } from "lucide-react";
4
- import { Button } from "@checkstack/ui";
5
- import { ExtensionSlot } from "@checkstack/frontend-api";
6
- import { CatalogSystemActionsSlot } from "@checkstack/catalog-common";
7
- import {
8
- useProvenanceLock,
9
- GitOpsSourceBadge,
10
- } from "@checkstack/gitops-frontend";
11
- import type { Group, System } from "../api";
12
-
13
- interface DraggableSystemProps {
14
- system: System;
15
- groups: Group[];
16
- assignedGroupIds: string[];
17
- onEdit: (system: System) => void;
18
- onDelete: (id: string) => void;
19
- onAddToGroup: (systemId: string, groupId: string) => void;
20
- }
21
-
22
- /**
23
- * A draggable system card for the Catalog Management page.
24
- *
25
- * Layout: two-row design so the system name is never truncated.
26
- * Row 1: grip handle + full-width name + description
27
- * Row 2 (footer): extension slot (Health Checks etc.) + action buttons
28
- *
29
- * On touch / small screens, the + button opens an inline group picker
30
- * since drag-and-drop can be imprecise on small viewports.
31
- *
32
- * We intentionally do NOT apply the useDraggable transform to the element —
33
- * DragOverlay handles the moving visual, keeping the original in-place.
34
- */
35
- export const DraggableSystem = ({
36
- system,
37
- groups,
38
- assignedGroupIds,
39
- onEdit,
40
- onDelete,
41
- onAddToGroup,
42
- }: DraggableSystemProps) => {
43
- const [isPickerOpen, setIsPickerOpen] = useState(false);
44
-
45
- const { isLocked, provenance } = useProvenanceLock({
46
- kind: "System",
47
- entityId: system.id,
48
- });
49
-
50
- const { attributes, listeners, setNodeRef } = useDraggable({
51
- id: system.id,
52
- disabled: isLocked,
53
- });
54
-
55
- const availableGroups = groups.filter((g) => !assignedGroupIds.includes(g.id));
56
-
57
- return (
58
- <div
59
- ref={setNodeRef}
60
- className="bg-muted/30 rounded-lg border border-border transition-all duration-150"
61
- >
62
- {/* Main row: grip + name/description */}
63
- <div className="flex items-start gap-2 p-3 pb-2">
64
- {/* Grip handle — only this element triggers the drag.
65
- When GitOps-locked, swap in the source badge so users can click
66
- through to the file that owns this system. */}
67
- {isLocked && provenance ? (
68
- <div className="flex-shrink-0 mt-0.5">
69
- <GitOpsSourceBadge provenance={provenance} />
70
- </div>
71
- ) : (
72
- <div
73
- {...listeners}
74
- {...attributes}
75
- className="flex-shrink-0 mt-0.5 text-muted-foreground/40 touch-none cursor-grab active:cursor-grabbing hover:text-muted-foreground"
76
- aria-label={`Drag ${system.name}`}
77
- >
78
- <GripVertical className="w-4 h-4" />
79
- </div>
80
- )}
81
-
82
- {/* Name + description — gets all remaining width, never truncated */}
83
- <div className="flex-1 min-w-0">
84
- <p className="font-medium text-foreground leading-snug">
85
- {system.name}
86
- </p>
87
- <p className="text-xs text-muted-foreground mt-0.5">
88
- {system.description || "No description"}
89
- </p>
90
- </div>
91
- </div>
92
-
93
- {/* Footer row: unassigned badge (left) + all action buttons (right) */}
94
- <div className="flex items-center justify-between gap-2 px-3 pb-2 pl-9">
95
- {/* Left: unassigned badge */}
96
- <div className="min-w-0">
97
- {assignedGroupIds.length === 0 && (
98
- <span className="flex-shrink-0 text-[10px] px-1.5 py-0.5 rounded bg-warning/15 text-warning-foreground font-mono">
99
- unassigned
100
- </span>
101
- )}
102
- </div>
103
-
104
- {/* Right: extension slot + action buttons, all grouped together */}
105
- <div className="flex items-center gap-1 flex-shrink-0">
106
- <ExtensionSlot
107
- slot={CatalogSystemActionsSlot}
108
- context={{ systemId: system.id, systemName: system.name }}
109
- />
110
-
111
- {/* Group picker */}
112
- <div className="relative">
113
- <Button
114
- variant="ghost"
115
- size="sm"
116
- className="h-7 w-7 p-0"
117
- title={isLocked ? "Managed by GitOps" : `Add ${system.name} to a group`}
118
- onClick={() => setIsPickerOpen((v) => !v)}
119
- disabled={availableGroups.length === 0 || isLocked}
120
- aria-expanded={isPickerOpen}
121
- aria-haspopup="listbox"
122
- aria-label={`Add ${system.name} to group`}
123
- >
124
- <FolderPlus className="w-3.5 h-3.5" />
125
- </Button>
126
-
127
- {isPickerOpen && availableGroups.length > 0 && (
128
- <>
129
- {/* Backdrop */}
130
- <div
131
- className="fixed inset-0 z-40"
132
- onClick={() => setIsPickerOpen(false)}
133
- />
134
- <div
135
- role="listbox"
136
- aria-label="Select a group"
137
- className="absolute right-0 bottom-full mb-1 z-50 min-w-[160px] rounded-md border border-border bg-popover shadow-md py-1"
138
- >
139
- {availableGroups.map((group) => (
140
- <button
141
- key={group.id}
142
- role="option"
143
- aria-selected={false}
144
- className="w-full text-left px-3 py-2 text-sm hover:bg-muted transition-colors"
145
- onClick={() => {
146
- onAddToGroup(system.id, group.id);
147
- setIsPickerOpen(false);
148
- }}
149
- >
150
- {group.name}
151
- </button>
152
- ))}
153
- </div>
154
- </>
155
- )}
156
- </div>
157
-
158
- <Button
159
- variant="ghost"
160
- size="sm"
161
- className="h-7 w-7 p-0"
162
- onClick={() => onEdit(system)}
163
- disabled={isLocked}
164
- title={isLocked ? "Managed by GitOps" : undefined}
165
- aria-label={`Edit ${system.name}`}
166
- >
167
- <Edit className="w-3.5 h-3.5" />
168
- </Button>
169
-
170
- <Button
171
- variant="ghost"
172
- size="sm"
173
- className="text-destructive hover:text-destructive/90 hover:bg-destructive/10 h-7 w-7 p-0"
174
- onClick={() => onDelete(system.id)}
175
- disabled={isLocked}
176
- title={isLocked ? "Managed by GitOps" : undefined}
177
- aria-label={`Delete ${system.name}`}
178
- >
179
- <Trash2 className="w-3.5 h-3.5" />
180
- </Button>
181
- </div>
182
- </div>
183
- </div>
184
- );
185
- };
186
-
187
- /**
188
- * A non-interactive ghost card shown in the DragOverlay while dragging.
189
- */
190
- export const SystemDragOverlay = ({ system }: { system: System }) => (
191
- <div className="flex items-center gap-2 p-3 bg-card rounded-lg border border-primary/50 shadow-lg cursor-grabbing w-full">
192
- <GripVertical className="w-4 h-4 text-muted-foreground/40 flex-shrink-0" />
193
- <div className="flex-1 min-w-0">
194
- <p className="font-medium text-foreground">{system.name}</p>
195
- <p className="text-xs text-muted-foreground">
196
- {system.description || "No description"}
197
- </p>
198
- </div>
199
- </div>
200
- );