@checkstack/maintenance-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.
@@ -0,0 +1,176 @@
1
+ import React, { useEffect, useState, useCallback } from "react";
2
+ import { Link } from "react-router-dom";
3
+ import { useApi, type SlotContext } from "@checkstack/frontend-api";
4
+ import { useSignal } from "@checkstack/signal-frontend";
5
+ import { resolveRoute } from "@checkstack/common";
6
+ import { SystemDetailsSlot } from "@checkstack/catalog-common";
7
+ import { maintenanceApiRef } from "../api";
8
+ import {
9
+ maintenanceRoutes,
10
+ MAINTENANCE_UPDATED,
11
+ type MaintenanceWithSystems,
12
+ } from "@checkstack/maintenance-common";
13
+ import {
14
+ Card,
15
+ CardHeader,
16
+ CardTitle,
17
+ CardContent,
18
+ Badge,
19
+ LoadingSpinner,
20
+ Button,
21
+ } from "@checkstack/ui";
22
+ import { Wrench, Clock, Calendar, History, ChevronRight } from "lucide-react";
23
+ import { formatDistanceToNow, format } from "date-fns";
24
+
25
+ type Props = SlotContext<typeof SystemDetailsSlot>;
26
+
27
+ /**
28
+ * Panel shown on system detail pages displaying active/upcoming maintenances.
29
+ * Listens for realtime updates via signals.
30
+ */
31
+ export const SystemMaintenancePanel: React.FC<Props> = ({ system }) => {
32
+ const api = useApi(maintenanceApiRef);
33
+ const [maintenances, setMaintenances] = useState<MaintenanceWithSystems[]>(
34
+ []
35
+ );
36
+ const [loading, setLoading] = useState(true);
37
+
38
+ const refetch = useCallback(() => {
39
+ if (!system?.id) return;
40
+
41
+ api
42
+ .getMaintenancesForSystem({ systemId: system.id })
43
+ .then(setMaintenances)
44
+ .catch(console.error)
45
+ .finally(() => setLoading(false));
46
+ }, [system?.id, api]);
47
+
48
+ // Initial fetch
49
+ useEffect(() => {
50
+ refetch();
51
+ }, [refetch]);
52
+
53
+ // Listen for realtime maintenance updates
54
+ useSignal(MAINTENANCE_UPDATED, ({ systemIds }) => {
55
+ if (system?.id && systemIds.includes(system.id)) {
56
+ refetch();
57
+ }
58
+ });
59
+
60
+ if (loading) {
61
+ return (
62
+ <Card>
63
+ <CardContent className="p-6 flex justify-center">
64
+ <LoadingSpinner />
65
+ </CardContent>
66
+ </Card>
67
+ );
68
+ }
69
+
70
+ if (maintenances.length === 0) {
71
+ // Show a subtle card with just the history button when no active maintenances
72
+ return (
73
+ <Card className="border-border/50">
74
+ <CardContent className="p-4">
75
+ <div className="flex items-center justify-between">
76
+ <div className="flex items-center gap-2 text-muted-foreground">
77
+ <Wrench className="h-4 w-4" />
78
+ <span className="text-sm">No active maintenances</span>
79
+ </div>
80
+ <Button variant="ghost" size="sm" asChild>
81
+ <Link
82
+ to={resolveRoute(maintenanceRoutes.routes.systemHistory, {
83
+ systemId: system.id,
84
+ })}
85
+ >
86
+ <History className="h-4 w-4 mr-1" />
87
+ View History
88
+ </Link>
89
+ </Button>
90
+ </div>
91
+ </CardContent>
92
+ </Card>
93
+ );
94
+ }
95
+
96
+ const getStatusBadge = (status: string) => {
97
+ switch (status) {
98
+ case "in_progress": {
99
+ return <Badge variant="warning">In Progress</Badge>;
100
+ }
101
+ case "scheduled": {
102
+ return <Badge variant="info">Scheduled</Badge>;
103
+ }
104
+ default: {
105
+ return <Badge>{status}</Badge>;
106
+ }
107
+ }
108
+ };
109
+
110
+ return (
111
+ <Card className="border-warning/30 bg-warning/5">
112
+ <CardHeader className="border-b border-border bg-warning/10">
113
+ <div className="flex items-center justify-between">
114
+ <div className="flex items-center gap-2">
115
+ <Wrench className="h-5 w-5 text-warning" />
116
+ <CardTitle className="text-lg font-semibold">
117
+ Planned Maintenance
118
+ </CardTitle>
119
+ </div>
120
+ <Button variant="ghost" size="sm" asChild>
121
+ <Link
122
+ to={resolveRoute(maintenanceRoutes.routes.systemHistory, {
123
+ systemId: system.id,
124
+ })}
125
+ >
126
+ <History className="h-4 w-4 mr-1" />
127
+ View History
128
+ </Link>
129
+ </Button>
130
+ </div>
131
+ </CardHeader>
132
+ <CardContent className="p-4 space-y-3">
133
+ {maintenances.map((m) => (
134
+ <Link
135
+ key={m.id}
136
+ to={`${resolveRoute(maintenanceRoutes.routes.detail, {
137
+ maintenanceId: m.id,
138
+ })}?from=${system.id}`}
139
+ className="block p-3 rounded-lg border border-border bg-background hover:bg-muted/50 transition-colors"
140
+ >
141
+ <div className="flex items-start justify-between mb-2">
142
+ <div className="flex items-center gap-2">
143
+ <h4 className="font-medium text-foreground">{m.title}</h4>
144
+ <ChevronRight className="h-4 w-4 text-muted-foreground" />
145
+ </div>
146
+ {getStatusBadge(m.status)}
147
+ </div>
148
+ {m.description && (
149
+ <p className="text-sm text-muted-foreground mb-2">
150
+ {m.description}
151
+ </p>
152
+ )}
153
+ <div className="flex gap-4 text-xs text-muted-foreground">
154
+ <div className="flex items-center gap-1">
155
+ <Calendar className="h-3 w-3" />
156
+ <span>{format(new Date(m.startAt), "MMM d, HH:mm")}</span>
157
+ </div>
158
+ <div className="flex items-center gap-1">
159
+ <Clock className="h-3 w-3" />
160
+ <span>
161
+ {m.status === "scheduled"
162
+ ? `Starts ${formatDistanceToNow(new Date(m.startAt), {
163
+ addSuffix: true,
164
+ })}`
165
+ : `Ends ${formatDistanceToNow(new Date(m.endAt), {
166
+ addSuffix: true,
167
+ })}`}
168
+ </span>
169
+ </div>
170
+ </div>
171
+ </Link>
172
+ ))}
173
+ </CardContent>
174
+ </Card>
175
+ );
176
+ };
package/src/index.tsx ADDED
@@ -0,0 +1,71 @@
1
+ import {
2
+ createFrontendPlugin,
3
+ createSlotExtension,
4
+ rpcApiRef,
5
+ type ApiRef,
6
+ UserMenuItemsSlot,
7
+ } from "@checkstack/frontend-api";
8
+ import { maintenanceApiRef, type MaintenanceApiClient } from "./api";
9
+ import {
10
+ maintenanceRoutes,
11
+ MaintenanceApi,
12
+ pluginMetadata,
13
+ permissions,
14
+ } from "@checkstack/maintenance-common";
15
+ import {
16
+ SystemDetailsTopSlot,
17
+ SystemStateBadgesSlot,
18
+ } from "@checkstack/catalog-common";
19
+ import { MaintenanceConfigPage } from "./pages/MaintenanceConfigPage";
20
+ import { SystemMaintenanceHistoryPage } from "./pages/SystemMaintenanceHistoryPage";
21
+ import { MaintenanceDetailPage } from "./pages/MaintenanceDetailPage";
22
+ import { SystemMaintenancePanel } from "./components/SystemMaintenancePanel";
23
+ import { SystemMaintenanceBadge } from "./components/SystemMaintenanceBadge";
24
+ import { MaintenanceMenuItems } from "./components/MaintenanceMenuItems";
25
+
26
+ export default createFrontendPlugin({
27
+ metadata: pluginMetadata,
28
+ routes: [
29
+ {
30
+ route: maintenanceRoutes.routes.config,
31
+ element: <MaintenanceConfigPage />,
32
+ title: "Maintenances",
33
+ permission: permissions.maintenanceManage,
34
+ },
35
+ {
36
+ route: maintenanceRoutes.routes.systemHistory,
37
+ element: <SystemMaintenanceHistoryPage />,
38
+ title: "System Maintenance History",
39
+ },
40
+ {
41
+ route: maintenanceRoutes.routes.detail,
42
+ element: <MaintenanceDetailPage />,
43
+ title: "Maintenance Details",
44
+ },
45
+ ],
46
+ apis: [
47
+ {
48
+ ref: maintenanceApiRef,
49
+ factory: (deps: {
50
+ get: <T>(ref: ApiRef<T>) => T;
51
+ }): MaintenanceApiClient => {
52
+ const rpcApi = deps.get(rpcApiRef);
53
+ return rpcApi.forPlugin(MaintenanceApi);
54
+ },
55
+ },
56
+ ],
57
+ extensions: [
58
+ createSlotExtension(UserMenuItemsSlot, {
59
+ id: "maintenance.user-menu.items",
60
+ component: MaintenanceMenuItems,
61
+ }),
62
+ createSlotExtension(SystemStateBadgesSlot, {
63
+ id: "maintenance.system-maintenance-badge",
64
+ component: SystemMaintenanceBadge,
65
+ }),
66
+ createSlotExtension(SystemDetailsTopSlot, {
67
+ id: "maintenance.system-details-top.panel",
68
+ component: SystemMaintenancePanel,
69
+ }),
70
+ ],
71
+ });
@@ -0,0 +1,347 @@
1
+ import React, { useEffect, useState, useMemo } from "react";
2
+ import { useSearchParams } from "react-router-dom";
3
+ import {
4
+ useApi,
5
+ rpcApiRef,
6
+ permissionApiRef,
7
+ wrapInSuspense,
8
+ } from "@checkstack/frontend-api";
9
+ import { maintenanceApiRef } from "../api";
10
+ import type {
11
+ MaintenanceWithSystems,
12
+ MaintenanceStatus,
13
+ } from "@checkstack/maintenance-common";
14
+ import { CatalogApi, type System } from "@checkstack/catalog-common";
15
+ import {
16
+ Card,
17
+ CardHeader,
18
+ CardTitle,
19
+ CardContent,
20
+ Button,
21
+ LoadingSpinner,
22
+ EmptyState,
23
+ Table,
24
+ TableHeader,
25
+ TableRow,
26
+ TableHead,
27
+ TableBody,
28
+ TableCell,
29
+ Select,
30
+ SelectTrigger,
31
+ SelectValue,
32
+ SelectContent,
33
+ SelectItem,
34
+ useToast,
35
+ ConfirmationModal,
36
+ PageLayout,
37
+ } from "@checkstack/ui";
38
+ import {
39
+ Plus,
40
+ Wrench,
41
+ Calendar,
42
+ Trash2,
43
+ Edit2,
44
+ Clock,
45
+ CheckCircle2,
46
+ } from "lucide-react";
47
+ import { format } from "date-fns";
48
+ import { MaintenanceEditor } from "../components/MaintenanceEditor";
49
+ import { getMaintenanceStatusBadge } from "../utils/badges";
50
+
51
+ const MaintenanceConfigPageContent: React.FC = () => {
52
+ const api = useApi(maintenanceApiRef);
53
+ const rpcApi = useApi(rpcApiRef);
54
+ const permissionApi = useApi(permissionApiRef);
55
+ const [searchParams, setSearchParams] = useSearchParams();
56
+
57
+ const catalogApi = useMemo(() => rpcApi.forPlugin(CatalogApi), [rpcApi]);
58
+ const toast = useToast();
59
+
60
+ const { allowed: canManage, loading: permissionLoading } =
61
+ permissionApi.useResourcePermission("maintenance", "manage");
62
+
63
+ const [maintenances, setMaintenances] = useState<MaintenanceWithSystems[]>(
64
+ []
65
+ );
66
+ const [systems, setSystems] = useState<System[]>([]);
67
+ const [loading, setLoading] = useState(true);
68
+ const [statusFilter, setStatusFilter] = useState<MaintenanceStatus | "all">(
69
+ "all"
70
+ );
71
+
72
+ // Editor state
73
+ const [editorOpen, setEditorOpen] = useState(false);
74
+ const [editingMaintenance, setEditingMaintenance] = useState<
75
+ MaintenanceWithSystems | undefined
76
+ >();
77
+
78
+ // Delete confirmation state
79
+ const [deleteId, setDeleteId] = useState<string | undefined>();
80
+ const [isDeleting, setIsDeleting] = useState(false);
81
+
82
+ // Complete confirmation state
83
+ const [completeId, setCompleteId] = useState<string | undefined>();
84
+ const [isCompleting, setIsCompleting] = useState(false);
85
+
86
+ const loadData = async () => {
87
+ setLoading(true);
88
+ try {
89
+ const [maintenanceList, systemList] = await Promise.all([
90
+ api.listMaintenances(
91
+ statusFilter === "all" ? undefined : { status: statusFilter }
92
+ ),
93
+ catalogApi.getSystems(),
94
+ ]);
95
+ setMaintenances(maintenanceList);
96
+ setSystems(systemList);
97
+ } catch (error) {
98
+ const message = error instanceof Error ? error.message : "Failed to load";
99
+ toast.error(message);
100
+ } finally {
101
+ setLoading(false);
102
+ }
103
+ };
104
+
105
+ useEffect(() => {
106
+ loadData();
107
+ }, [statusFilter]);
108
+
109
+ // Handle ?action=create URL parameter (from command palette)
110
+ useEffect(() => {
111
+ if (searchParams.get("action") === "create" && canManage) {
112
+ setEditingMaintenance(undefined);
113
+ setEditorOpen(true);
114
+ // Clear the URL param after opening
115
+ searchParams.delete("action");
116
+ setSearchParams(searchParams, { replace: true });
117
+ }
118
+ }, [searchParams, canManage, setSearchParams]);
119
+
120
+ const handleCreate = () => {
121
+ setEditingMaintenance(undefined);
122
+ setEditorOpen(true);
123
+ };
124
+
125
+ const handleEdit = (m: MaintenanceWithSystems) => {
126
+ setEditingMaintenance(m);
127
+ setEditorOpen(true);
128
+ };
129
+
130
+ const handleDelete = async () => {
131
+ if (!deleteId) return;
132
+
133
+ setIsDeleting(true);
134
+ try {
135
+ await api.deleteMaintenance({ id: deleteId });
136
+ toast.success("Maintenance deleted");
137
+ loadData();
138
+ } catch (error) {
139
+ const message =
140
+ error instanceof Error ? error.message : "Failed to delete";
141
+ toast.error(message);
142
+ } finally {
143
+ setIsDeleting(false);
144
+ setDeleteId(undefined);
145
+ }
146
+ };
147
+
148
+ const handleComplete = async () => {
149
+ if (!completeId) return;
150
+
151
+ setIsCompleting(true);
152
+ try {
153
+ await api.closeMaintenance({ id: completeId });
154
+ toast.success("Maintenance completed");
155
+ loadData();
156
+ } catch (error) {
157
+ const message =
158
+ error instanceof Error ? error.message : "Failed to complete";
159
+ toast.error(message);
160
+ } finally {
161
+ setIsCompleting(false);
162
+ setCompleteId(undefined);
163
+ }
164
+ };
165
+
166
+ const handleSave = () => {
167
+ setEditorOpen(false);
168
+ loadData();
169
+ };
170
+
171
+ const getSystemNames = (systemIds: string[]): string => {
172
+ const names = systemIds
173
+ .map((id) => systems.find((s) => s.id === id)?.name ?? id)
174
+ .slice(0, 3);
175
+ if (systemIds.length > 3) {
176
+ names.push(`+${systemIds.length - 3} more`);
177
+ }
178
+ return names.join(", ");
179
+ };
180
+
181
+ const canComplete = (status: MaintenanceStatus) =>
182
+ status !== "completed" && status !== "cancelled";
183
+
184
+ return (
185
+ <PageLayout
186
+ title="Planned Maintenances"
187
+ subtitle="Manage scheduled maintenance windows for systems"
188
+ loading={permissionLoading}
189
+ allowed={canManage}
190
+ actions={
191
+ <Button onClick={handleCreate}>
192
+ <Plus className="h-4 w-4 mr-2" />
193
+ Create Maintenance
194
+ </Button>
195
+ }
196
+ >
197
+ <Card>
198
+ <CardHeader className="border-b border-border">
199
+ <div className="flex items-center justify-between">
200
+ <div className="flex items-center gap-2">
201
+ <Wrench className="h-5 w-5 text-muted-foreground" />
202
+ <CardTitle>Maintenances</CardTitle>
203
+ </div>
204
+ <Select
205
+ value={statusFilter}
206
+ onValueChange={(v) =>
207
+ setStatusFilter(v as MaintenanceStatus | "all")
208
+ }
209
+ >
210
+ <SelectTrigger className="w-40">
211
+ <SelectValue placeholder="Filter by status" />
212
+ </SelectTrigger>
213
+ <SelectContent>
214
+ <SelectItem value="all">All Statuses</SelectItem>
215
+ <SelectItem value="scheduled">Scheduled</SelectItem>
216
+ <SelectItem value="in_progress">In Progress</SelectItem>
217
+ <SelectItem value="completed">Completed</SelectItem>
218
+ <SelectItem value="cancelled">Cancelled</SelectItem>
219
+ </SelectContent>
220
+ </Select>
221
+ </div>
222
+ </CardHeader>
223
+ <CardContent className="p-0">
224
+ {loading ? (
225
+ <div className="p-12 flex justify-center">
226
+ <LoadingSpinner />
227
+ </div>
228
+ ) : maintenances.length === 0 ? (
229
+ <EmptyState
230
+ title="No maintenances found"
231
+ description="Create your first planned maintenance to get started."
232
+ />
233
+ ) : (
234
+ <Table>
235
+ <TableHeader>
236
+ <TableRow>
237
+ <TableHead>Title</TableHead>
238
+ <TableHead>Status</TableHead>
239
+ <TableHead>Systems</TableHead>
240
+ <TableHead>Schedule</TableHead>
241
+ <TableHead className="w-32">Actions</TableHead>
242
+ </TableRow>
243
+ </TableHeader>
244
+ <TableBody>
245
+ {maintenances.map((m) => (
246
+ <TableRow key={m.id}>
247
+ <TableCell>
248
+ <div>
249
+ <p className="font-medium">{m.title}</p>
250
+ {m.description && (
251
+ <p className="text-sm text-muted-foreground truncate max-w-xs">
252
+ {m.description}
253
+ </p>
254
+ )}
255
+ </div>
256
+ </TableCell>
257
+ <TableCell>{getMaintenanceStatusBadge(m.status)}</TableCell>
258
+ <TableCell className="text-sm text-muted-foreground">
259
+ {getSystemNames(m.systemIds)}
260
+ </TableCell>
261
+ <TableCell>
262
+ <div className="text-sm space-y-1">
263
+ <div className="flex items-center gap-1 text-muted-foreground">
264
+ <Calendar className="h-3 w-3" />
265
+ <span>
266
+ {format(new Date(m.startAt), "MMM d, HH:mm")}
267
+ </span>
268
+ </div>
269
+ <div className="flex items-center gap-1 text-muted-foreground">
270
+ <Clock className="h-3 w-3" />
271
+ <span>
272
+ {format(new Date(m.endAt), "MMM d, HH:mm")}
273
+ </span>
274
+ </div>
275
+ </div>
276
+ </TableCell>
277
+ <TableCell>
278
+ <div className="flex gap-2">
279
+ <Button
280
+ variant="ghost"
281
+ size="sm"
282
+ onClick={() => handleEdit(m)}
283
+ >
284
+ <Edit2 className="h-4 w-4" />
285
+ </Button>
286
+ {canComplete(m.status) && (
287
+ <Button
288
+ variant="ghost"
289
+ size="sm"
290
+ onClick={() => setCompleteId(m.id)}
291
+ >
292
+ <CheckCircle2 className="h-4 w-4 text-success" />
293
+ </Button>
294
+ )}
295
+ <Button
296
+ variant="ghost"
297
+ size="sm"
298
+ onClick={() => setDeleteId(m.id)}
299
+ >
300
+ <Trash2 className="h-4 w-4 text-destructive" />
301
+ </Button>
302
+ </div>
303
+ </TableCell>
304
+ </TableRow>
305
+ ))}
306
+ </TableBody>
307
+ </Table>
308
+ )}
309
+ </CardContent>
310
+ </Card>
311
+
312
+ <MaintenanceEditor
313
+ open={editorOpen}
314
+ onOpenChange={setEditorOpen}
315
+ maintenance={editingMaintenance}
316
+ systems={systems}
317
+ onSave={handleSave}
318
+ />
319
+
320
+ <ConfirmationModal
321
+ isOpen={!!deleteId}
322
+ onClose={() => setDeleteId(undefined)}
323
+ title="Delete Maintenance"
324
+ message="Are you sure you want to delete this maintenance? This action cannot be undone."
325
+ confirmText="Delete"
326
+ variant="danger"
327
+ onConfirm={handleDelete}
328
+ isLoading={isDeleting}
329
+ />
330
+
331
+ <ConfirmationModal
332
+ isOpen={!!completeId}
333
+ onClose={() => setCompleteId(undefined)}
334
+ title="Complete Maintenance"
335
+ message="Are you sure you want to mark this maintenance as completed?"
336
+ confirmText="Complete"
337
+ variant="info"
338
+ onConfirm={handleComplete}
339
+ isLoading={isCompleting}
340
+ />
341
+ </PageLayout>
342
+ );
343
+ };
344
+
345
+ export const MaintenanceConfigPage = wrapInSuspense(
346
+ MaintenanceConfigPageContent
347
+ );