@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 +27 -0
- package/package.json +35 -0
- package/src/Dashboard.tsx +418 -0
- package/src/components/SearchDialog.tsx +241 -0
- package/src/index.tsx +20 -0
- package/tsconfig.json +6 -0
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;
|