@checkmate-monitor/dashboard-frontend 0.0.2

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 ADDED
@@ -0,0 +1,27 @@
1
+ # @checkmate-monitor/dashboard-frontend
2
+
3
+ ## 0.0.2
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [eff5b4e]
8
+ - Updated dependencies [ffc28f6]
9
+ - Updated dependencies [4dd644d]
10
+ - Updated dependencies [ae19ff6]
11
+ - Updated dependencies [0babb9c]
12
+ - Updated dependencies [32f2535]
13
+ - Updated dependencies [b55fae6]
14
+ - Updated dependencies [b354ab3]
15
+ - @checkmate-monitor/maintenance-common@0.1.0
16
+ - @checkmate-monitor/ui@0.1.0
17
+ - @checkmate-monitor/common@0.1.0
18
+ - @checkmate-monitor/catalog-common@0.1.0
19
+ - @checkmate-monitor/notification-common@0.1.0
20
+ - @checkmate-monitor/incident-common@0.1.0
21
+ - @checkmate-monitor/healthcheck-common@0.1.0
22
+ - @checkmate-monitor/auth-frontend@0.1.0
23
+ - @checkmate-monitor/signal-frontend@0.1.0
24
+ - @checkmate-monitor/catalog-frontend@0.0.2
25
+ - @checkmate-monitor/command-common@0.0.2
26
+ - @checkmate-monitor/command-frontend@0.0.2
27
+ - @checkmate-monitor/frontend-api@0.0.2
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@checkmate-monitor/dashboard-frontend",
3
+ "version": "0.0.2",
4
+ "type": "module",
5
+ "main": "src/index.tsx",
6
+ "scripts": {
7
+ "typecheck": "tsc --noEmit",
8
+ "lint": "bun run lint:code",
9
+ "lint:code": "eslint . --max-warnings 0"
10
+ },
11
+ "dependencies": {
12
+ "@checkmate-monitor/frontend-api": "workspace:*",
13
+ "@checkmate-monitor/auth-frontend": "workspace:*",
14
+ "@checkmate-monitor/common": "workspace:*",
15
+ "@checkmate-monitor/command-frontend": "workspace:*",
16
+ "@checkmate-monitor/command-common": "workspace:*",
17
+ "@checkmate-monitor/notification-common": "workspace:*",
18
+ "@checkmate-monitor/catalog-common": "workspace:*",
19
+ "@checkmate-monitor/incident-common": "workspace:*",
20
+ "@checkmate-monitor/maintenance-common": "workspace:*",
21
+ "@checkmate-monitor/healthcheck-common": "workspace:*",
22
+ "@checkmate-monitor/signal-frontend": "workspace:*",
23
+ "@checkmate-monitor/ui": "workspace:*",
24
+ "@checkmate-monitor/catalog-frontend": "workspace:*",
25
+ "react": "^18.2.0",
26
+ "react-router-dom": "^6.22.0",
27
+ "lucide-react": "^0.344.0"
28
+ },
29
+ "devDependencies": {
30
+ "typescript": "^5.0.0",
31
+ "@types/react": "^18.2.0",
32
+ "@checkmate-monitor/tsconfig": "workspace:*",
33
+ "@checkmate-monitor/scripts": "workspace:*"
34
+ }
35
+ }
@@ -0,0 +1,418 @@
1
+ import React, { useEffect, useState, useCallback } from "react";
2
+ import { useNavigate } from "react-router-dom";
3
+ import {
4
+ useApi,
5
+ rpcApiRef,
6
+ ExtensionSlot,
7
+ } from "@checkmate-monitor/frontend-api";
8
+ import { catalogApiRef } from "@checkmate-monitor/catalog-frontend";
9
+ import {
10
+ catalogRoutes,
11
+ SystemStateBadgesSlot,
12
+ System,
13
+ Group,
14
+ } from "@checkmate-monitor/catalog-common";
15
+ import { resolveRoute } from "@checkmate-monitor/common";
16
+ import {
17
+ NotificationApi,
18
+ type EnrichedSubscription,
19
+ } from "@checkmate-monitor/notification-common";
20
+ import { IncidentApi } from "@checkmate-monitor/incident-common";
21
+ import { MaintenanceApi } from "@checkmate-monitor/maintenance-common";
22
+ import { HEALTH_CHECK_RUN_COMPLETED } from "@checkmate-monitor/healthcheck-common";
23
+ import { useSignal } from "@checkmate-monitor/signal-frontend";
24
+ import {
25
+ Card,
26
+ CardHeader,
27
+ CardTitle,
28
+ CardContent,
29
+ SectionHeader,
30
+ StatusCard,
31
+ EmptyState,
32
+ LoadingSpinner,
33
+ SubscribeButton,
34
+ useToast,
35
+ CommandPalette,
36
+ AnimatedCounter,
37
+ TerminalFeed,
38
+ type TerminalEntry,
39
+ } from "@checkmate-monitor/ui";
40
+ import {
41
+ LayoutGrid,
42
+ Server,
43
+ Activity,
44
+ ChevronRight,
45
+ AlertTriangle,
46
+ Wrench,
47
+ Terminal,
48
+ } from "lucide-react";
49
+ import { authApiRef } from "@checkmate-monitor/auth-frontend/api";
50
+ import { SearchDialog } from "./components/SearchDialog";
51
+
52
+ const CATALOG_PLUGIN_ID = "catalog";
53
+ const MAX_TERMINAL_ENTRIES = 8;
54
+
55
+ const getGroupId = (groupId: string) => `${CATALOG_PLUGIN_ID}.group.${groupId}`;
56
+
57
+ interface GroupWithSystems extends Group {
58
+ systems: System[];
59
+ }
60
+
61
+ // Map health check status to terminal entry variant
62
+ const statusToVariant = (
63
+ status: "healthy" | "degraded" | "unhealthy"
64
+ ): TerminalEntry["variant"] => {
65
+ switch (status) {
66
+ case "healthy": {
67
+ return "success";
68
+ }
69
+ case "degraded": {
70
+ return "warning";
71
+ }
72
+ case "unhealthy": {
73
+ return "error";
74
+ }
75
+ }
76
+ };
77
+
78
+ export const Dashboard: React.FC = () => {
79
+ const catalogApi = useApi(catalogApiRef);
80
+ const rpcApi = useApi(rpcApiRef);
81
+ const notificationApi = rpcApi.forPlugin(NotificationApi);
82
+ const incidentApi = rpcApi.forPlugin(IncidentApi);
83
+ const maintenanceApi = rpcApi.forPlugin(MaintenanceApi);
84
+ const navigate = useNavigate();
85
+ const toast = useToast();
86
+ const authApi = useApi(authApiRef);
87
+ const { data: session } = authApi.useSession();
88
+
89
+ const [groupsWithSystems, setGroupsWithSystems] = useState<
90
+ GroupWithSystems[]
91
+ >([]);
92
+ const [loading, setLoading] = useState(true);
93
+
94
+ // Overview statistics state
95
+ const [systemsCount, setSystemsCount] = useState(0);
96
+ const [activeIncidentsCount, setActiveIncidentsCount] = useState(0);
97
+ const [activeMaintenancesCount, setActiveMaintenancesCount] = useState(0);
98
+
99
+ // Terminal feed entries from real healthcheck signals
100
+ const [terminalEntries, setTerminalEntries] = useState<TerminalEntry[]>([]);
101
+
102
+ // Search dialog state
103
+ const [searchOpen, setSearchOpen] = useState(false);
104
+
105
+ // Subscription state
106
+ const [subscriptions, setSubscriptions] = useState<EnrichedSubscription[]>(
107
+ []
108
+ );
109
+ const [subscriptionLoading, setSubscriptionLoading] = useState<
110
+ Record<string, boolean>
111
+ >({});
112
+
113
+ // Listen for health check runs and add to terminal feed
114
+ useSignal(
115
+ HEALTH_CHECK_RUN_COMPLETED,
116
+ ({ systemName, configurationName, status, latencyMs }) => {
117
+ const newEntry: TerminalEntry = {
118
+ id: `${configurationName}-${Date.now()}`,
119
+ timestamp: new Date(),
120
+ content: `${systemName} (${configurationName}) → ${status}`,
121
+ variant: statusToVariant(status),
122
+ suffix: latencyMs === undefined ? undefined : `${latencyMs}ms`,
123
+ };
124
+
125
+ setTerminalEntries((prev) =>
126
+ [newEntry, ...prev].slice(0, MAX_TERMINAL_ENTRIES)
127
+ );
128
+ }
129
+ );
130
+
131
+ // Global keyboard shortcut for search
132
+ useEffect(() => {
133
+ const handleKeyDown = (e: KeyboardEvent) => {
134
+ if ((e.metaKey || e.ctrlKey) && e.key === "k") {
135
+ e.preventDefault();
136
+ setSearchOpen(true);
137
+ }
138
+ };
139
+
140
+ document.addEventListener("keydown", handleKeyDown);
141
+ return () => document.removeEventListener("keydown", handleKeyDown);
142
+ }, []);
143
+
144
+ useEffect(() => {
145
+ if (session) {
146
+ notificationApi.getSubscriptions().then(setSubscriptions);
147
+ }
148
+ }, [session, notificationApi]);
149
+
150
+ useEffect(() => {
151
+ Promise.all([
152
+ catalogApi.getGroups(),
153
+ catalogApi.getSystems(),
154
+ incidentApi.listIncidents({ includeResolved: false }),
155
+ maintenanceApi.listMaintenances({ status: "in_progress" }),
156
+ ])
157
+ .then(([groups, systems, incidents, maintenances]) => {
158
+ // Set overview statistics
159
+ setSystemsCount(systems.length);
160
+ setActiveIncidentsCount(incidents.length);
161
+ setActiveMaintenancesCount(maintenances.length);
162
+
163
+ // Create a map of system IDs to systems
164
+ const systemMap = new Map(systems.map((s) => [s.id, s]));
165
+
166
+ // Map groups to include their systems
167
+ const groupsData: GroupWithSystems[] = groups.map((group) => {
168
+ const groupSystems = (group.systemIds || [])
169
+ .map((id) => systemMap.get(id))
170
+ .filter((s): s is System => s !== undefined);
171
+
172
+ return {
173
+ ...group,
174
+ systems: groupSystems,
175
+ };
176
+ });
177
+
178
+ setGroupsWithSystems(groupsData);
179
+ })
180
+ .catch(console.error)
181
+ .finally(() => setLoading(false));
182
+ }, [catalogApi, incidentApi, maintenanceApi]);
183
+
184
+ const handleSystemClick = (systemId: string) => {
185
+ navigate(resolveRoute(catalogRoutes.routes.systemDetail, { systemId }));
186
+ };
187
+
188
+ const isSubscribed = (groupId: string) => {
189
+ const fullId = getGroupId(groupId);
190
+ return subscriptions.some((s) => s.groupId === fullId);
191
+ };
192
+
193
+ const handleSubscribe = useCallback(
194
+ async (groupId: string) => {
195
+ const fullId = getGroupId(groupId);
196
+ setSubscriptionLoading((prev) => ({ ...prev, [groupId]: true }));
197
+ try {
198
+ await notificationApi.subscribe({ groupId: fullId });
199
+ setSubscriptions((prev) => [
200
+ ...prev,
201
+ {
202
+ groupId: fullId,
203
+ groupName: "",
204
+ groupDescription: "",
205
+ ownerPlugin: CATALOG_PLUGIN_ID,
206
+ subscribedAt: new Date(),
207
+ },
208
+ ]);
209
+ toast.success("Subscribed to group notifications");
210
+ } catch (error) {
211
+ const message =
212
+ error instanceof Error ? error.message : "Failed to subscribe";
213
+ toast.error(message);
214
+ } finally {
215
+ setSubscriptionLoading((prev) => ({ ...prev, [groupId]: false }));
216
+ }
217
+ },
218
+ [notificationApi, toast]
219
+ );
220
+
221
+ const handleUnsubscribe = useCallback(
222
+ async (groupId: string) => {
223
+ const fullId = getGroupId(groupId);
224
+ setSubscriptionLoading((prev) => ({ ...prev, [groupId]: true }));
225
+ try {
226
+ await notificationApi.unsubscribe({ groupId: fullId });
227
+ setSubscriptions((prev) => prev.filter((s) => s.groupId !== fullId));
228
+ toast.success("Unsubscribed from group notifications");
229
+ } catch (error) {
230
+ const message =
231
+ error instanceof Error ? error.message : "Failed to unsubscribe";
232
+ toast.error(message);
233
+ } finally {
234
+ setSubscriptionLoading((prev) => ({ ...prev, [groupId]: false }));
235
+ }
236
+ },
237
+ [notificationApi, toast]
238
+ );
239
+
240
+ const renderGroupsContent = () => {
241
+ if (loading) {
242
+ return <LoadingSpinner />;
243
+ }
244
+
245
+ if (groupsWithSystems.length === 0) {
246
+ return (
247
+ <EmptyState
248
+ title="No system groups found"
249
+ description="Visit the Catalog to create your first group."
250
+ icon={<Server className="w-12 h-12" />}
251
+ />
252
+ );
253
+ }
254
+
255
+ return (
256
+ <div className="space-y-4">
257
+ {groupsWithSystems.map((group) => (
258
+ <Card
259
+ key={group.id}
260
+ className="border-border shadow-sm hover:shadow-md transition-shadow"
261
+ >
262
+ <CardHeader className="border-b border-border bg-muted/30">
263
+ <div className="flex items-center gap-2">
264
+ <LayoutGrid className="h-5 w-5 text-muted-foreground" />
265
+ <CardTitle className="text-lg font-semibold text-foreground">
266
+ {group.name}
267
+ </CardTitle>
268
+ <span className="ml-auto text-sm text-muted-foreground mr-2">
269
+ {group.systems.length}{" "}
270
+ {group.systems.length === 1 ? "system" : "systems"}
271
+ </span>
272
+ {session && (
273
+ <SubscribeButton
274
+ isSubscribed={isSubscribed(group.id)}
275
+ onSubscribe={() => handleSubscribe(group.id)}
276
+ onUnsubscribe={() => handleUnsubscribe(group.id)}
277
+ loading={subscriptionLoading[group.id] || false}
278
+ />
279
+ )}
280
+ </div>
281
+ </CardHeader>
282
+ <CardContent className="p-4">
283
+ {group.systems.length === 0 ? (
284
+ <div className="py-8 text-center">
285
+ <p className="text-sm text-muted-foreground">
286
+ No systems in this group yet
287
+ </p>
288
+ </div>
289
+ ) : (
290
+ <div
291
+ className={`grid gap-3 ${
292
+ group.systems.length === 1
293
+ ? "grid-cols-1"
294
+ : "grid-cols-1 sm:grid-cols-2"
295
+ }`}
296
+ >
297
+ {group.systems.map((system) => (
298
+ <button
299
+ key={system.id}
300
+ onClick={() => handleSystemClick(system.id)}
301
+ className="flex items-center justify-between gap-3 rounded-lg border border-border bg-card px-4 py-3 transition-all cursor-pointer hover:border-border/80 hover:shadow-sm text-left"
302
+ >
303
+ <div className="flex items-center gap-3 min-w-0 flex-1">
304
+ <Activity className="h-4 w-4 text-muted-foreground flex-shrink-0" />
305
+ <p className="text-sm font-medium text-foreground truncate">
306
+ {system.name}
307
+ </p>
308
+ </div>
309
+ <ExtensionSlot
310
+ slot={SystemStateBadgesSlot}
311
+ context={{ system }}
312
+ />
313
+ <ChevronRight className="h-4 w-4 text-muted-foreground flex-shrink-0" />
314
+ </button>
315
+ ))}
316
+ </div>
317
+ )}
318
+ </CardContent>
319
+ </Card>
320
+ ))}
321
+ </div>
322
+ );
323
+ };
324
+
325
+ return (
326
+ <>
327
+ {/* Search Dialog */}
328
+ <SearchDialog open={searchOpen} onOpenChange={setSearchOpen} />
329
+
330
+ <div className="space-y-8 animate-in fade-in duration-500">
331
+ {/* Command Palette Hero */}
332
+ <section>
333
+ <CommandPalette
334
+ onClick={() => setSearchOpen(true)}
335
+ placeholder="Search systems, incidents, or run commands..."
336
+ />
337
+ </section>
338
+
339
+ {/* Overview Section */}
340
+ <section>
341
+ <SectionHeader
342
+ title="Overview"
343
+ icon={<Activity className="w-5 h-5" />}
344
+ />
345
+ <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
346
+ <StatusCard
347
+ title="Total Systems"
348
+ value={loading ? "..." : <AnimatedCounter value={systemsCount} />}
349
+ description="Monitored systems in your catalog"
350
+ icon={<Server className="w-4 h-4" />}
351
+ />
352
+
353
+ <StatusCard
354
+ variant={activeIncidentsCount > 0 ? "gradient" : "default"}
355
+ title="Active Incidents"
356
+ value={
357
+ loading ? (
358
+ "..."
359
+ ) : (
360
+ <AnimatedCounter value={activeIncidentsCount} />
361
+ )
362
+ }
363
+ description={
364
+ activeIncidentsCount === 0
365
+ ? "All systems operating normally"
366
+ : "Unresolved issues requiring attention"
367
+ }
368
+ icon={<AlertTriangle className="w-4 h-4" />}
369
+ />
370
+
371
+ <StatusCard
372
+ title="Active Maintenances"
373
+ value={
374
+ loading ? (
375
+ "..."
376
+ ) : (
377
+ <AnimatedCounter value={activeMaintenancesCount} />
378
+ )
379
+ }
380
+ description={
381
+ activeMaintenancesCount === 0
382
+ ? "No scheduled maintenance"
383
+ : "Ongoing or scheduled maintenance windows"
384
+ }
385
+ icon={<Wrench className="w-4 h-4" />}
386
+ />
387
+ </div>
388
+ </section>
389
+
390
+ {/* Terminal Feed and System Groups - Two Column Layout */}
391
+ <div className="grid gap-8 lg:grid-cols-3">
392
+ {/* Terminal Feed */}
393
+ <section className="lg:col-span-1">
394
+ <SectionHeader
395
+ title="Recent Activity"
396
+ icon={<Terminal className="w-5 h-5" />}
397
+ />
398
+ <TerminalFeed
399
+ entries={terminalEntries}
400
+ maxEntries={MAX_TERMINAL_ENTRIES}
401
+ maxHeight="350px"
402
+ title="checkmate status --watch"
403
+ />
404
+ </section>
405
+
406
+ {/* System Groups */}
407
+ <section className="lg:col-span-2">
408
+ <SectionHeader
409
+ title="System Groups"
410
+ icon={<LayoutGrid className="w-5 h-5" />}
411
+ />
412
+ {renderGroupsContent()}
413
+ </section>
414
+ </div>
415
+ </div>
416
+ </>
417
+ );
418
+ };
@@ -0,0 +1,241 @@
1
+ import React, { useState, useEffect, useCallback, useRef } from "react";
2
+ import { useNavigate } from "react-router-dom";
3
+ import { Dialog, DialogContent, Input } from "@checkmate-monitor/ui";
4
+ import {
5
+ useDebouncedSearch,
6
+ useFormatShortcut,
7
+ } from "@checkmate-monitor/command-frontend";
8
+ import type { SearchResult } from "@checkmate-monitor/command-common";
9
+ import {
10
+ Activity,
11
+ Search,
12
+ ArrowUp,
13
+ ArrowDown,
14
+ CornerDownLeft,
15
+ AlertCircle,
16
+ Wrench,
17
+ Command,
18
+ } from "lucide-react";
19
+
20
+ interface SearchDialogProps {
21
+ open: boolean;
22
+ onOpenChange: (open: boolean) => void;
23
+ }
24
+
25
+ // Icon mapping for different result types
26
+ const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
27
+ Activity,
28
+ AlertCircle,
29
+ Wrench,
30
+ Command,
31
+ };
32
+
33
+ export const SearchDialog: React.FC<SearchDialogProps> = ({
34
+ open,
35
+ onOpenChange,
36
+ }) => {
37
+ const navigate = useNavigate();
38
+ const formatShortcut = useFormatShortcut();
39
+ const { results, loading, search, reset } = useDebouncedSearch(300);
40
+
41
+ const [query, setQuery] = useState("");
42
+ const [selectedIndex, setSelectedIndex] = useState(0);
43
+
44
+ const inputRef = useRef<HTMLInputElement>(null);
45
+
46
+ // Trigger search when dialog opens or query changes
47
+ useEffect(() => {
48
+ if (open) {
49
+ search(query);
50
+ // Focus input after dialog opens
51
+ setTimeout(() => inputRef.current?.focus(), 50);
52
+ } else {
53
+ // Reset state when dialog closes
54
+ setQuery("");
55
+ setSelectedIndex(0);
56
+ reset();
57
+ }
58
+ }, [open, query, search, reset]);
59
+
60
+ // Group results by category
61
+ const groupedResults: Record<string, SearchResult[]> = {};
62
+ for (const result of results) {
63
+ const category = result.category;
64
+ if (!groupedResults[category]) {
65
+ groupedResults[category] = [];
66
+ }
67
+ groupedResults[category].push(result);
68
+ }
69
+
70
+ // Flatten for navigation
71
+ const flatResults = Object.values(groupedResults).flat();
72
+
73
+ // Reset selection when results change
74
+ useEffect(() => {
75
+ setSelectedIndex(0);
76
+ }, [results]);
77
+
78
+ const handleSelect = useCallback(
79
+ (result: SearchResult) => {
80
+ onOpenChange(false);
81
+ if (result.route) {
82
+ navigate(result.route);
83
+ }
84
+ },
85
+ [navigate, onOpenChange]
86
+ );
87
+
88
+ // Keyboard navigation
89
+ const handleKeyDown = useCallback(
90
+ (e: React.KeyboardEvent) => {
91
+ switch (e.key) {
92
+ case "ArrowDown": {
93
+ e.preventDefault();
94
+ setSelectedIndex((prev) =>
95
+ Math.min(prev + 1, flatResults.length - 1)
96
+ );
97
+ break;
98
+ }
99
+ case "ArrowUp": {
100
+ e.preventDefault();
101
+ setSelectedIndex((prev) => Math.max(prev - 1, 0));
102
+ break;
103
+ }
104
+ case "Enter": {
105
+ e.preventDefault();
106
+ if (flatResults[selectedIndex]) {
107
+ handleSelect(flatResults[selectedIndex]);
108
+ }
109
+ break;
110
+ }
111
+ case "Escape": {
112
+ e.preventDefault();
113
+ onOpenChange(false);
114
+ break;
115
+ }
116
+ }
117
+ },
118
+ [flatResults, selectedIndex, handleSelect, onOpenChange]
119
+ );
120
+
121
+ // Render a single result item
122
+ const renderResult = (result: SearchResult, globalIndex: number) => {
123
+ const IconComponent = iconMap[result.iconName ?? ""] ?? Activity;
124
+ const isSelected = globalIndex === selectedIndex;
125
+
126
+ return (
127
+ <button
128
+ key={result.id}
129
+ onClick={() => handleSelect(result)}
130
+ onMouseEnter={() => setSelectedIndex(globalIndex)}
131
+ className={`w-full flex items-center gap-3 px-4 py-2 text-left transition-colors ${
132
+ isSelected
133
+ ? "bg-primary/10 text-foreground"
134
+ : "text-muted-foreground hover:bg-muted/50"
135
+ }`}
136
+ >
137
+ <IconComponent className="w-4 h-4 flex-shrink-0" />
138
+ <div className="flex-1 min-w-0">
139
+ <span className="block truncate">{result.title}</span>
140
+ {result.subtitle && (
141
+ <span className="block text-xs text-muted-foreground truncate">
142
+ {result.subtitle}
143
+ </span>
144
+ )}
145
+ </div>
146
+ {/* Show shortcuts for commands */}
147
+ {result.type === "command" &&
148
+ result.shortcuts &&
149
+ result.shortcuts.length > 0 && (
150
+ <div className="flex gap-1">
151
+ {result.shortcuts.slice(0, 1).map((shortcut) => (
152
+ <kbd
153
+ key={shortcut}
154
+ className="px-1.5 py-0.5 text-xs rounded bg-muted border border-border font-mono"
155
+ >
156
+ {formatShortcut(shortcut)}
157
+ </kbd>
158
+ ))}
159
+ </div>
160
+ )}
161
+ {isSelected && (
162
+ <CornerDownLeft className="w-4 h-4 text-muted-foreground flex-shrink-0" />
163
+ )}
164
+ </button>
165
+ );
166
+ };
167
+
168
+ // Track global index for selection
169
+ let globalIndex = 0;
170
+
171
+ return (
172
+ <Dialog open={open} onOpenChange={onOpenChange}>
173
+ <DialogContent
174
+ size="lg"
175
+ className="p-0 gap-0 overflow-hidden"
176
+ onKeyDown={handleKeyDown}
177
+ >
178
+ {/* Search input */}
179
+ <div className="flex items-center gap-3 px-4 py-3 border-b border-border">
180
+ <Search className="w-5 h-5 text-muted-foreground flex-shrink-0" />
181
+ <Input
182
+ ref={inputRef}
183
+ value={query}
184
+ onChange={(e) => setQuery(e.target.value)}
185
+ placeholder="Search commands and systems..."
186
+ className="border-0 bg-transparent focus-visible:ring-0 px-0 text-base"
187
+ />
188
+ </div>
189
+
190
+ {/* Results */}
191
+ <div className="max-h-[300px] overflow-y-auto py-2">
192
+ {loading ? (
193
+ <div className="px-4 py-8 text-center text-muted-foreground">
194
+ Searching...
195
+ </div>
196
+ ) : flatResults.length === 0 ? (
197
+ <div className="px-4 py-8 text-center text-muted-foreground">
198
+ {query ? "No results found" : "Start typing to search..."}
199
+ </div>
200
+ ) : (
201
+ Object.entries(groupedResults).map(
202
+ ([category, categoryResults]) => (
203
+ <div key={category}>
204
+ {/* Category header */}
205
+ <div className="px-4 py-1.5 text-xs font-medium text-muted-foreground uppercase tracking-wider bg-muted/30">
206
+ {category} ({categoryResults.length})
207
+ </div>
208
+ {/* Category results */}
209
+ {categoryResults.map((result) => {
210
+ const element = renderResult(result, globalIndex);
211
+ globalIndex++;
212
+ return element;
213
+ })}
214
+ </div>
215
+ )
216
+ )
217
+ )}
218
+ </div>
219
+
220
+ {/* Footer with keyboard hints */}
221
+ <div className="flex items-center gap-4 px-4 py-2 border-t border-border bg-muted/30 text-xs text-muted-foreground">
222
+ <div className="flex items-center gap-1">
223
+ <ArrowUp className="w-3 h-3" />
224
+ <ArrowDown className="w-3 h-3" />
225
+ <span>Navigate</span>
226
+ </div>
227
+ <div className="flex items-center gap-1">
228
+ <CornerDownLeft className="w-3 h-3" />
229
+ <span>Select</span>
230
+ </div>
231
+ <div className="flex items-center gap-1">
232
+ <kbd className="px-1 rounded bg-muted border border-border font-mono">
233
+ esc
234
+ </kbd>
235
+ <span>Close</span>
236
+ </div>
237
+ </div>
238
+ </DialogContent>
239
+ </Dialog>
240
+ );
241
+ };
package/src/index.tsx ADDED
@@ -0,0 +1,20 @@
1
+ import { FrontendPlugin, DashboardSlot } from "@checkmate-monitor/frontend-api";
2
+ import { definePluginMetadata } from "@checkmate-monitor/common";
3
+ import { Dashboard } from "./Dashboard";
4
+
5
+ const pluginMetadata = definePluginMetadata({
6
+ pluginId: "dashboard",
7
+ });
8
+
9
+ export const dashboardPlugin: FrontendPlugin = {
10
+ metadata: pluginMetadata,
11
+ extensions: [
12
+ {
13
+ id: "dashboard-main",
14
+ slot: DashboardSlot,
15
+ component: Dashboard as React.ComponentType<unknown>,
16
+ },
17
+ ],
18
+ };
19
+
20
+ export default dashboardPlugin;
package/tsconfig.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "extends": "@checkmate-monitor/tsconfig/frontend.json",
3
+ "include": [
4
+ "src"
5
+ ]
6
+ }