@checkstack/incident-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,240 @@
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 { SystemDetailsTopSlot } from "@checkstack/catalog-common";
7
+ import { incidentApiRef } from "../api";
8
+ import {
9
+ incidentRoutes,
10
+ INCIDENT_UPDATED,
11
+ type IncidentWithSystems,
12
+ } from "@checkstack/incident-common";
13
+ import {
14
+ Card,
15
+ CardHeader,
16
+ CardTitle,
17
+ CardContent,
18
+ Badge,
19
+ LoadingSpinner,
20
+ Button,
21
+ } from "@checkstack/ui";
22
+ import { AlertTriangle, Clock, History, ChevronRight } from "lucide-react";
23
+ import { formatDistanceToNow } from "date-fns";
24
+
25
+ type Props = SlotContext<typeof SystemDetailsTopSlot>;
26
+
27
+ const SEVERITY_WEIGHTS = { critical: 3, major: 2, minor: 1 } as const;
28
+
29
+ function getSeverityColor(severity: string): string {
30
+ switch (severity) {
31
+ case "critical": {
32
+ return "border-destructive/30 bg-destructive/5";
33
+ }
34
+ case "major": {
35
+ return "border-warning/30 bg-warning/5";
36
+ }
37
+ default: {
38
+ return "border-info/30 bg-info/5";
39
+ }
40
+ }
41
+ }
42
+
43
+ function getSeverityHeaderColor(severity: string): string {
44
+ switch (severity) {
45
+ case "critical": {
46
+ return "bg-destructive/10";
47
+ }
48
+ case "major": {
49
+ return "bg-warning/10";
50
+ }
51
+ default: {
52
+ return "bg-info/10";
53
+ }
54
+ }
55
+ }
56
+
57
+ function findMostSevereIncident(
58
+ incidents: IncidentWithSystems[]
59
+ ): IncidentWithSystems {
60
+ let mostSevere = incidents[0];
61
+ for (const incident of incidents) {
62
+ const currentWeight =
63
+ SEVERITY_WEIGHTS[incident.severity as keyof typeof SEVERITY_WEIGHTS] || 0;
64
+ const mostWeight =
65
+ SEVERITY_WEIGHTS[mostSevere.severity as keyof typeof SEVERITY_WEIGHTS] ||
66
+ 0;
67
+ if (currentWeight > mostWeight) {
68
+ mostSevere = incident;
69
+ }
70
+ }
71
+ return mostSevere;
72
+ }
73
+
74
+ /**
75
+ * Panel shown on system detail pages displaying active incidents.
76
+ * Listens for realtime updates via signals.
77
+ */
78
+ export const SystemIncidentPanel: React.FC<Props> = ({ system }) => {
79
+ const api = useApi(incidentApiRef);
80
+ const [incidents, setIncidents] = useState<IncidentWithSystems[]>([]);
81
+ const [loading, setLoading] = useState(true);
82
+
83
+ const refetch = useCallback(() => {
84
+ if (!system?.id) return;
85
+
86
+ api
87
+ .getIncidentsForSystem({ systemId: system.id })
88
+ .then(setIncidents)
89
+ .catch(console.error)
90
+ .finally(() => setLoading(false));
91
+ }, [system?.id, api]);
92
+
93
+ // Initial fetch
94
+ useEffect(() => {
95
+ refetch();
96
+ }, [refetch]);
97
+
98
+ // Listen for realtime incident updates
99
+ useSignal(INCIDENT_UPDATED, ({ systemIds }) => {
100
+ if (system?.id && systemIds.includes(system.id)) {
101
+ refetch();
102
+ }
103
+ });
104
+
105
+ if (loading) {
106
+ return (
107
+ <Card>
108
+ <CardContent className="p-6 flex justify-center">
109
+ <LoadingSpinner />
110
+ </CardContent>
111
+ </Card>
112
+ );
113
+ }
114
+
115
+ if (incidents.length === 0) {
116
+ // Show a subtle card with just the history button when no active incidents
117
+ return (
118
+ <Card className="border-border/50">
119
+ <CardContent className="p-4">
120
+ <div className="flex items-center justify-between">
121
+ <div className="flex items-center gap-2 text-muted-foreground">
122
+ <AlertTriangle className="h-4 w-4" />
123
+ <span className="text-sm">No active incidents</span>
124
+ </div>
125
+ <Button variant="ghost" size="sm" asChild>
126
+ <Link
127
+ to={resolveRoute(incidentRoutes.routes.systemHistory, {
128
+ systemId: system.id,
129
+ })}
130
+ >
131
+ <History className="h-4 w-4 mr-1" />
132
+ View History
133
+ </Link>
134
+ </Button>
135
+ </div>
136
+ </CardContent>
137
+ </Card>
138
+ );
139
+ }
140
+
141
+ const getStatusBadge = (status: string) => {
142
+ switch (status) {
143
+ case "investigating": {
144
+ return <Badge variant="destructive">Investigating</Badge>;
145
+ }
146
+ case "identified": {
147
+ return <Badge variant="warning">Identified</Badge>;
148
+ }
149
+ case "fixing": {
150
+ return <Badge variant="warning">Fixing</Badge>;
151
+ }
152
+ case "monitoring": {
153
+ return <Badge variant="info">Monitoring</Badge>;
154
+ }
155
+ default: {
156
+ return <Badge>{status}</Badge>;
157
+ }
158
+ }
159
+ };
160
+
161
+ // Use the most severe incident for the card styling
162
+ const mostSevere = findMostSevereIncident(incidents);
163
+
164
+ return (
165
+ <Card className={getSeverityColor(mostSevere.severity)}>
166
+ <CardHeader
167
+ className={`border-b border-border ${getSeverityHeaderColor(
168
+ mostSevere.severity
169
+ )}`}
170
+ >
171
+ <div className="flex items-center justify-between">
172
+ <div className="flex items-center gap-2">
173
+ <AlertTriangle className="h-5 w-5 text-destructive" />
174
+ <CardTitle className="text-lg font-semibold">
175
+ Active Incidents ({incidents.length})
176
+ </CardTitle>
177
+ </div>
178
+ <Button variant="ghost" size="sm" asChild>
179
+ <Link
180
+ to={resolveRoute(incidentRoutes.routes.systemHistory, {
181
+ systemId: system.id,
182
+ })}
183
+ >
184
+ <History className="h-4 w-4 mr-1" />
185
+ View History
186
+ </Link>
187
+ </Button>
188
+ </div>
189
+ </CardHeader>
190
+ <CardContent className="p-4 space-y-3">
191
+ {incidents.map((i) => (
192
+ <Link
193
+ key={i.id}
194
+ to={`${resolveRoute(incidentRoutes.routes.detail, {
195
+ incidentId: i.id,
196
+ })}?from=${system.id}`}
197
+ className="block p-3 rounded-lg border border-border bg-background hover:bg-muted/50 transition-colors"
198
+ >
199
+ <div className="flex items-start justify-between mb-2">
200
+ <div className="flex items-center gap-2">
201
+ <h4 className="font-medium text-foreground">{i.title}</h4>
202
+ <ChevronRight className="h-4 w-4 text-muted-foreground" />
203
+ </div>
204
+ <div className="flex items-center gap-2">
205
+ <Badge
206
+ variant={
207
+ i.severity === "critical"
208
+ ? "destructive"
209
+ : i.severity === "major"
210
+ ? "warning"
211
+ : "secondary"
212
+ }
213
+ >
214
+ {i.severity}
215
+ </Badge>
216
+ {getStatusBadge(i.status)}
217
+ </div>
218
+ </div>
219
+ {i.description && (
220
+ <p className="text-sm text-muted-foreground mb-2">
221
+ {i.description}
222
+ </p>
223
+ )}
224
+ <div className="flex gap-4 text-xs text-muted-foreground">
225
+ <div className="flex items-center gap-1">
226
+ <Clock className="h-3 w-3" />
227
+ <span>
228
+ Started{" "}
229
+ {formatDistanceToNow(new Date(i.createdAt), {
230
+ addSuffix: true,
231
+ })}
232
+ </span>
233
+ </div>
234
+ </div>
235
+ </Link>
236
+ ))}
237
+ </CardContent>
238
+ </Card>
239
+ );
240
+ };
package/src/index.tsx ADDED
@@ -0,0 +1,69 @@
1
+ import {
2
+ createFrontendPlugin,
3
+ createSlotExtension,
4
+ rpcApiRef,
5
+ type ApiRef,
6
+ UserMenuItemsSlot,
7
+ } from "@checkstack/frontend-api";
8
+ import { incidentApiRef, type IncidentApiClient } from "./api";
9
+ import {
10
+ incidentRoutes,
11
+ IncidentApi,
12
+ pluginMetadata,
13
+ permissions,
14
+ } from "@checkstack/incident-common";
15
+ import {
16
+ SystemDetailsTopSlot,
17
+ SystemStateBadgesSlot,
18
+ } from "@checkstack/catalog-common";
19
+ import { IncidentConfigPage } from "./pages/IncidentConfigPage";
20
+ import { IncidentDetailPage } from "./pages/IncidentDetailPage";
21
+ import { SystemIncidentHistoryPage } from "./pages/SystemIncidentHistoryPage";
22
+ import { SystemIncidentPanel } from "./components/SystemIncidentPanel";
23
+ import { SystemIncidentBadge } from "./components/SystemIncidentBadge";
24
+ import { IncidentMenuItems } from "./components/IncidentMenuItems";
25
+
26
+ export default createFrontendPlugin({
27
+ metadata: pluginMetadata,
28
+ routes: [
29
+ {
30
+ route: incidentRoutes.routes.config,
31
+ element: <IncidentConfigPage />,
32
+ title: "Incidents",
33
+ permission: permissions.incidentManage,
34
+ },
35
+ {
36
+ route: incidentRoutes.routes.detail,
37
+ element: <IncidentDetailPage />,
38
+ title: "Incident Details",
39
+ },
40
+ {
41
+ route: incidentRoutes.routes.systemHistory,
42
+ element: <SystemIncidentHistoryPage />,
43
+ title: "System Incident History",
44
+ },
45
+ ],
46
+ apis: [
47
+ {
48
+ ref: incidentApiRef,
49
+ factory: (deps: { get: <T>(ref: ApiRef<T>) => T }): IncidentApiClient => {
50
+ const rpcApi = deps.get(rpcApiRef);
51
+ return rpcApi.forPlugin(IncidentApi);
52
+ },
53
+ },
54
+ ],
55
+ extensions: [
56
+ createSlotExtension(UserMenuItemsSlot, {
57
+ id: "incident.user-menu.items",
58
+ component: IncidentMenuItems,
59
+ }),
60
+ createSlotExtension(SystemStateBadgesSlot, {
61
+ id: "incident.system-incident-badge",
62
+ component: SystemIncidentBadge,
63
+ }),
64
+ createSlotExtension(SystemDetailsTopSlot, {
65
+ id: "incident.system-details-top.panel",
66
+ component: SystemIncidentPanel,
67
+ }),
68
+ ],
69
+ });
@@ -0,0 +1,387 @@
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 { incidentApiRef } from "../api";
10
+ import type {
11
+ IncidentWithSystems,
12
+ IncidentStatus,
13
+ } from "@checkstack/incident-common";
14
+ import { CatalogApi, type System } from "@checkstack/catalog-common";
15
+ import {
16
+ Card,
17
+ CardHeader,
18
+ CardTitle,
19
+ CardContent,
20
+ Button,
21
+ Badge,
22
+ LoadingSpinner,
23
+ EmptyState,
24
+ Table,
25
+ TableHeader,
26
+ TableRow,
27
+ TableHead,
28
+ TableBody,
29
+ TableCell,
30
+ Select,
31
+ SelectTrigger,
32
+ SelectValue,
33
+ SelectContent,
34
+ SelectItem,
35
+ useToast,
36
+ ConfirmationModal,
37
+ PageLayout,
38
+ } from "@checkstack/ui";
39
+ import {
40
+ Plus,
41
+ AlertTriangle,
42
+ Trash2,
43
+ Edit2,
44
+ Clock,
45
+ CheckCircle2,
46
+ } from "lucide-react";
47
+ import { formatDistanceToNow } from "date-fns";
48
+ import { IncidentEditor } from "../components/IncidentEditor";
49
+
50
+ const IncidentConfigPageContent: React.FC = () => {
51
+ const api = useApi(incidentApiRef);
52
+ const rpcApi = useApi(rpcApiRef);
53
+ const permissionApi = useApi(permissionApiRef);
54
+ const [searchParams, setSearchParams] = useSearchParams();
55
+
56
+ const catalogApi = useMemo(() => rpcApi.forPlugin(CatalogApi), [rpcApi]);
57
+ const toast = useToast();
58
+
59
+ const { allowed: canManage, loading: permissionLoading } =
60
+ permissionApi.useResourcePermission("incident", "manage");
61
+
62
+ const [incidents, setIncidents] = useState<IncidentWithSystems[]>([]);
63
+ const [systems, setSystems] = useState<System[]>([]);
64
+ const [loading, setLoading] = useState(true);
65
+ const [statusFilter, setStatusFilter] = useState<IncidentStatus | "all">(
66
+ "all"
67
+ );
68
+ const [showResolved, setShowResolved] = useState(false);
69
+
70
+ // Editor state
71
+ const [editorOpen, setEditorOpen] = useState(false);
72
+ const [editingIncident, setEditingIncident] = useState<
73
+ IncidentWithSystems | undefined
74
+ >();
75
+
76
+ // Delete confirmation state
77
+ const [deleteId, setDeleteId] = useState<string | undefined>();
78
+ const [isDeleting, setIsDeleting] = useState(false);
79
+
80
+ // Resolve confirmation state
81
+ const [resolveId, setResolveId] = useState<string | undefined>();
82
+ const [isResolving, setIsResolving] = useState(false);
83
+
84
+ const loadData = async () => {
85
+ setLoading(true);
86
+ try {
87
+ const [incidentList, systemList] = await Promise.all([
88
+ api.listIncidents(
89
+ statusFilter === "all"
90
+ ? { includeResolved: showResolved }
91
+ : { status: statusFilter, includeResolved: showResolved }
92
+ ),
93
+ catalogApi.getSystems(),
94
+ ]);
95
+ setIncidents(incidentList);
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, showResolved]);
108
+
109
+ // Handle ?action=create URL parameter (from command palette)
110
+ useEffect(() => {
111
+ if (searchParams.get("action") === "create" && canManage) {
112
+ setEditingIncident(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
+ setEditingIncident(undefined);
122
+ setEditorOpen(true);
123
+ };
124
+
125
+ const handleEdit = (i: IncidentWithSystems) => {
126
+ setEditingIncident(i);
127
+ setEditorOpen(true);
128
+ };
129
+
130
+ const handleDelete = async () => {
131
+ if (!deleteId) return;
132
+
133
+ setIsDeleting(true);
134
+ try {
135
+ await api.deleteIncident({ id: deleteId });
136
+ toast.success("Incident 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 handleResolve = async () => {
149
+ if (!resolveId) return;
150
+
151
+ setIsResolving(true);
152
+ try {
153
+ await api.resolveIncident({ id: resolveId });
154
+ toast.success("Incident resolved");
155
+ loadData();
156
+ } catch (error) {
157
+ const message =
158
+ error instanceof Error ? error.message : "Failed to resolve";
159
+ toast.error(message);
160
+ } finally {
161
+ setIsResolving(false);
162
+ setResolveId(undefined);
163
+ }
164
+ };
165
+
166
+ const handleSave = () => {
167
+ setEditorOpen(false);
168
+ loadData();
169
+ };
170
+
171
+ const getStatusBadge = (status: IncidentStatus) => {
172
+ switch (status) {
173
+ case "investigating": {
174
+ return <Badge variant="destructive">Investigating</Badge>;
175
+ }
176
+ case "identified": {
177
+ return <Badge variant="warning">Identified</Badge>;
178
+ }
179
+ case "fixing": {
180
+ return <Badge variant="warning">Fixing</Badge>;
181
+ }
182
+ case "monitoring": {
183
+ return <Badge variant="info">Monitoring</Badge>;
184
+ }
185
+ case "resolved": {
186
+ return <Badge variant="success">Resolved</Badge>;
187
+ }
188
+ default: {
189
+ return <Badge>{status}</Badge>;
190
+ }
191
+ }
192
+ };
193
+
194
+ const getSeverityBadge = (severity: string) => {
195
+ switch (severity) {
196
+ case "critical": {
197
+ return <Badge variant="destructive">Critical</Badge>;
198
+ }
199
+ case "major": {
200
+ return <Badge variant="warning">Major</Badge>;
201
+ }
202
+ default: {
203
+ return <Badge variant="secondary">Minor</Badge>;
204
+ }
205
+ }
206
+ };
207
+
208
+ const getSystemNames = (systemIds: string[]): string => {
209
+ const names = systemIds
210
+ .map((id) => systems.find((s) => s.id === id)?.name ?? id)
211
+ .slice(0, 3);
212
+ if (systemIds.length > 3) {
213
+ names.push(`+${systemIds.length - 3} more`);
214
+ }
215
+ return names.join(", ");
216
+ };
217
+
218
+ return (
219
+ <PageLayout
220
+ title="Incident Management"
221
+ subtitle="Track and manage incidents affecting your systems"
222
+ loading={permissionLoading}
223
+ allowed={canManage}
224
+ actions={
225
+ <Button onClick={handleCreate}>
226
+ <Plus className="h-4 w-4 mr-2" />
227
+ Report Incident
228
+ </Button>
229
+ }
230
+ >
231
+ <Card>
232
+ <CardHeader className="border-b border-border">
233
+ <div className="flex items-center justify-between">
234
+ <div className="flex items-center gap-2">
235
+ <AlertTriangle className="h-5 w-5 text-muted-foreground" />
236
+ <CardTitle>Incidents</CardTitle>
237
+ </div>
238
+ <div className="flex items-center gap-4">
239
+ <label className="flex items-center gap-2 text-sm">
240
+ <input
241
+ type="checkbox"
242
+ checked={showResolved}
243
+ onChange={(e) => setShowResolved(e.target.checked)}
244
+ className="rounded border-border"
245
+ />
246
+ Show resolved
247
+ </label>
248
+ <Select
249
+ value={statusFilter}
250
+ onValueChange={(v) =>
251
+ setStatusFilter(v as IncidentStatus | "all")
252
+ }
253
+ >
254
+ <SelectTrigger className="w-40">
255
+ <SelectValue placeholder="Filter by status" />
256
+ </SelectTrigger>
257
+ <SelectContent>
258
+ <SelectItem value="all">All Statuses</SelectItem>
259
+ <SelectItem value="investigating">Investigating</SelectItem>
260
+ <SelectItem value="identified">Identified</SelectItem>
261
+ <SelectItem value="fixing">Fixing</SelectItem>
262
+ <SelectItem value="monitoring">Monitoring</SelectItem>
263
+ <SelectItem value="resolved">Resolved</SelectItem>
264
+ </SelectContent>
265
+ </Select>
266
+ </div>
267
+ </div>
268
+ </CardHeader>
269
+ <CardContent className="p-0">
270
+ {loading ? (
271
+ <div className="p-12 flex justify-center">
272
+ <LoadingSpinner />
273
+ </div>
274
+ ) : incidents.length === 0 ? (
275
+ <EmptyState
276
+ title="No incidents found"
277
+ description="No incidents match your current filters."
278
+ />
279
+ ) : (
280
+ <Table>
281
+ <TableHeader>
282
+ <TableRow>
283
+ <TableHead>Title</TableHead>
284
+ <TableHead>Severity</TableHead>
285
+ <TableHead>Status</TableHead>
286
+ <TableHead>Systems</TableHead>
287
+ <TableHead>Duration</TableHead>
288
+ <TableHead className="w-32">Actions</TableHead>
289
+ </TableRow>
290
+ </TableHeader>
291
+ <TableBody>
292
+ {incidents.map((i) => (
293
+ <TableRow key={i.id}>
294
+ <TableCell>
295
+ <div>
296
+ <p className="font-medium">{i.title}</p>
297
+ {i.description && (
298
+ <p className="text-sm text-muted-foreground truncate max-w-xs">
299
+ {i.description}
300
+ </p>
301
+ )}
302
+ </div>
303
+ </TableCell>
304
+ <TableCell>{getSeverityBadge(i.severity)}</TableCell>
305
+ <TableCell>{getStatusBadge(i.status)}</TableCell>
306
+ <TableCell className="text-sm text-muted-foreground">
307
+ {getSystemNames(i.systemIds)}
308
+ </TableCell>
309
+ <TableCell>
310
+ <div className="flex items-center gap-1 text-sm text-muted-foreground">
311
+ <Clock className="h-3 w-3" />
312
+ <span>
313
+ {formatDistanceToNow(new Date(i.createdAt), {
314
+ addSuffix: false,
315
+ })}
316
+ </span>
317
+ </div>
318
+ </TableCell>
319
+ <TableCell>
320
+ <div className="flex gap-2">
321
+ <Button
322
+ variant="ghost"
323
+ size="sm"
324
+ onClick={() => handleEdit(i)}
325
+ >
326
+ <Edit2 className="h-4 w-4" />
327
+ </Button>
328
+ {i.status !== "resolved" && (
329
+ <Button
330
+ variant="ghost"
331
+ size="sm"
332
+ onClick={() => setResolveId(i.id)}
333
+ >
334
+ <CheckCircle2 className="h-4 w-4 text-success" />
335
+ </Button>
336
+ )}
337
+ <Button
338
+ variant="ghost"
339
+ size="sm"
340
+ onClick={() => setDeleteId(i.id)}
341
+ >
342
+ <Trash2 className="h-4 w-4 text-destructive" />
343
+ </Button>
344
+ </div>
345
+ </TableCell>
346
+ </TableRow>
347
+ ))}
348
+ </TableBody>
349
+ </Table>
350
+ )}
351
+ </CardContent>
352
+ </Card>
353
+
354
+ <IncidentEditor
355
+ open={editorOpen}
356
+ onOpenChange={setEditorOpen}
357
+ incident={editingIncident}
358
+ systems={systems}
359
+ onSave={handleSave}
360
+ />
361
+
362
+ <ConfirmationModal
363
+ isOpen={!!deleteId}
364
+ onClose={() => setDeleteId(undefined)}
365
+ title="Delete Incident"
366
+ message="Are you sure you want to delete this incident? This action cannot be undone."
367
+ confirmText="Delete"
368
+ variant="danger"
369
+ onConfirm={handleDelete}
370
+ isLoading={isDeleting}
371
+ />
372
+
373
+ <ConfirmationModal
374
+ isOpen={!!resolveId}
375
+ onClose={() => setResolveId(undefined)}
376
+ title="Resolve Incident"
377
+ message="Are you sure you want to mark this incident as resolved?"
378
+ confirmText="Resolve"
379
+ variant="info"
380
+ onConfirm={handleResolve}
381
+ isLoading={isResolving}
382
+ />
383
+ </PageLayout>
384
+ );
385
+ };
386
+
387
+ export const IncidentConfigPage = wrapInSuspense(IncidentConfigPageContent);