@checkmate-monitor/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,269 @@
1
+ import React, { useEffect, useState, useCallback, useMemo } from "react";
2
+ import { useParams } from "react-router-dom";
3
+ import {
4
+ useApi,
5
+ rpcApiRef,
6
+ permissionApiRef,
7
+ wrapInSuspense,
8
+ } from "@checkmate-monitor/frontend-api";
9
+ import { useSignal } from "@checkmate-monitor/signal-frontend";
10
+ import { resolveRoute } from "@checkmate-monitor/common";
11
+ import { incidentApiRef } from "../api";
12
+ import {
13
+ incidentRoutes,
14
+ INCIDENT_UPDATED,
15
+ type IncidentDetail,
16
+ } from "@checkmate-monitor/incident-common";
17
+ import { CatalogApi, type System } from "@checkmate-monitor/catalog-common";
18
+ import {
19
+ Card,
20
+ CardHeader,
21
+ CardTitle,
22
+ CardContent,
23
+ Button,
24
+ Badge,
25
+ LoadingSpinner,
26
+ BackLink,
27
+ useToast,
28
+ StatusUpdateTimeline,
29
+ } from "@checkmate-monitor/ui";
30
+ import {
31
+ AlertTriangle,
32
+ Clock,
33
+ Calendar,
34
+ MessageSquare,
35
+ CheckCircle2,
36
+ Server,
37
+ Plus,
38
+ } from "lucide-react";
39
+ import { format, formatDistanceToNow } from "date-fns";
40
+ import { IncidentUpdateForm } from "../components/IncidentUpdateForm";
41
+ import {
42
+ getIncidentStatusBadge,
43
+ getIncidentSeverityBadge,
44
+ } from "../utils/badges";
45
+
46
+ const IncidentDetailPageContent: React.FC = () => {
47
+ const { incidentId } = useParams<{ incidentId: string }>();
48
+ const api = useApi(incidentApiRef);
49
+ const rpcApi = useApi(rpcApiRef);
50
+ const permissionApi = useApi(permissionApiRef);
51
+ const toast = useToast();
52
+
53
+ const catalogApi = useMemo(() => rpcApi.forPlugin(CatalogApi), [rpcApi]);
54
+
55
+ const { allowed: canManage } = permissionApi.useResourcePermission(
56
+ "incident",
57
+ "manage"
58
+ );
59
+
60
+ const [incident, setIncident] = useState<IncidentDetail | undefined>();
61
+ const [systems, setSystems] = useState<System[]>([]);
62
+ const [loading, setLoading] = useState(true);
63
+ const [showUpdateForm, setShowUpdateForm] = useState(false);
64
+
65
+ const loadData = useCallback(async () => {
66
+ if (!incidentId) return;
67
+
68
+ try {
69
+ const [incidentData, systemList] = await Promise.all([
70
+ api.getIncident({ id: incidentId }),
71
+ catalogApi.getSystems(),
72
+ ]);
73
+ setIncident(incidentData ?? undefined);
74
+ setSystems(systemList);
75
+ } catch (error) {
76
+ console.error("Failed to load incident:", error);
77
+ } finally {
78
+ setLoading(false);
79
+ }
80
+ }, [incidentId, api, catalogApi]);
81
+
82
+ useEffect(() => {
83
+ loadData();
84
+ }, [loadData]);
85
+
86
+ // Listen for realtime updates
87
+ useSignal(INCIDENT_UPDATED, ({ incidentId: updatedId }) => {
88
+ if (incidentId === updatedId) {
89
+ loadData();
90
+ }
91
+ });
92
+
93
+ const handleUpdateSuccess = () => {
94
+ setShowUpdateForm(false);
95
+ loadData();
96
+ };
97
+
98
+ const handleResolve = async () => {
99
+ if (!incidentId) return;
100
+
101
+ try {
102
+ await api.resolveIncident({ id: incidentId });
103
+ toast.success("Incident resolved");
104
+ await loadData();
105
+ } catch (error) {
106
+ const message =
107
+ error instanceof Error ? error.message : "Failed to resolve";
108
+ toast.error(message);
109
+ }
110
+ };
111
+
112
+ if (loading) {
113
+ return (
114
+ <div className="p-12 flex justify-center">
115
+ <LoadingSpinner />
116
+ </div>
117
+ );
118
+ }
119
+
120
+ if (!incident) {
121
+ return (
122
+ <div className="p-12 text-center">
123
+ <p className="text-muted-foreground">Incident not found</p>
124
+ <BackLink
125
+ to={resolveRoute(incidentRoutes.routes.config, {})}
126
+ className="mt-4"
127
+ >
128
+ Back to Incidents
129
+ </BackLink>
130
+ </div>
131
+ );
132
+ }
133
+
134
+ const affectedSystems = systems.filter((s) =>
135
+ incident.systemIds.includes(s.id)
136
+ );
137
+
138
+ return (
139
+ <div className="space-y-6 p-6">
140
+ {/* Incident Header */}
141
+ <Card
142
+ className={
143
+ incident.severity === "critical"
144
+ ? "border-destructive/30 bg-destructive/5"
145
+ : incident.severity === "major"
146
+ ? "border-warning/30 bg-warning/5"
147
+ : ""
148
+ }
149
+ >
150
+ <CardHeader className="border-b border-border">
151
+ <div className="flex items-start justify-between">
152
+ <div className="flex items-center gap-3">
153
+ <AlertTriangle
154
+ className={`h-6 w-6 ${
155
+ incident.severity === "critical"
156
+ ? "text-destructive"
157
+ : incident.severity === "major"
158
+ ? "text-warning"
159
+ : "text-muted-foreground"
160
+ }`}
161
+ />
162
+ <div>
163
+ <CardTitle className="text-xl">{incident.title}</CardTitle>
164
+ <div className="flex items-center gap-2 mt-2">
165
+ {getIncidentSeverityBadge(incident.severity)}
166
+ {getIncidentStatusBadge(incident.status)}
167
+ </div>
168
+ </div>
169
+ </div>
170
+ {canManage && incident.status !== "resolved" && (
171
+ <Button variant="outline" onClick={handleResolve}>
172
+ <CheckCircle2 className="h-4 w-4 mr-2" />
173
+ Resolve Incident
174
+ </Button>
175
+ )}
176
+ <BackLink to={resolveRoute(incidentRoutes.routes.config, {})}>
177
+ Back to Incidents
178
+ </BackLink>
179
+ </div>
180
+ </CardHeader>
181
+ <CardContent className="p-4 space-y-4">
182
+ {incident.description && (
183
+ <p className="text-muted-foreground">{incident.description}</p>
184
+ )}
185
+
186
+ <div className="flex gap-6 text-sm text-muted-foreground">
187
+ <div className="flex items-center gap-1">
188
+ <Calendar className="h-4 w-4" />
189
+ <span>
190
+ Started {format(new Date(incident.createdAt), "MMM d, HH:mm")}
191
+ </span>
192
+ </div>
193
+ <div className="flex items-center gap-1">
194
+ <Clock className="h-4 w-4" />
195
+ <span>
196
+ Duration:{" "}
197
+ {formatDistanceToNow(new Date(incident.createdAt), {
198
+ addSuffix: false,
199
+ })}
200
+ </span>
201
+ </div>
202
+ </div>
203
+ </CardContent>
204
+ </Card>
205
+
206
+ {/* Affected Systems */}
207
+ <Card>
208
+ <CardHeader>
209
+ <div className="flex items-center gap-2">
210
+ <Server className="h-5 w-5 text-muted-foreground" />
211
+ <CardTitle className="text-lg">Affected Systems</CardTitle>
212
+ </div>
213
+ </CardHeader>
214
+ <CardContent>
215
+ <div className="flex flex-wrap gap-2">
216
+ {affectedSystems.map((system) => (
217
+ <Badge key={system.id} variant="outline">
218
+ {system.name}
219
+ </Badge>
220
+ ))}
221
+ </div>
222
+ </CardContent>
223
+ </Card>
224
+
225
+ {/* Status Updates Timeline */}
226
+ <Card>
227
+ <CardHeader className="border-b border-border">
228
+ <div className="flex items-center justify-between">
229
+ <div className="flex items-center gap-2">
230
+ <MessageSquare className="h-5 w-5 text-muted-foreground" />
231
+ <CardTitle className="text-lg">Status Updates</CardTitle>
232
+ </div>
233
+ {canManage && !showUpdateForm && (
234
+ <Button
235
+ variant="outline"
236
+ size="sm"
237
+ onClick={() => setShowUpdateForm(true)}
238
+ >
239
+ <Plus className="h-4 w-4 mr-1" />
240
+ Add Update
241
+ </Button>
242
+ )}
243
+ </div>
244
+ </CardHeader>
245
+ <CardContent className="p-4">
246
+ {/* Add Update Form */}
247
+ {showUpdateForm && incidentId && (
248
+ <div className="mb-4">
249
+ <IncidentUpdateForm
250
+ incidentId={incidentId}
251
+ onSuccess={handleUpdateSuccess}
252
+ onCancel={() => setShowUpdateForm(false)}
253
+ />
254
+ </div>
255
+ )}
256
+
257
+ <StatusUpdateTimeline
258
+ updates={incident.updates}
259
+ renderStatusBadge={getIncidentStatusBadge}
260
+ emptyTitle="No status updates"
261
+ emptyDescription="No status updates have been posted yet."
262
+ />
263
+ </CardContent>
264
+ </Card>
265
+ </div>
266
+ );
267
+ };
268
+
269
+ export const IncidentDetailPage = wrapInSuspense(IncidentDetailPageContent);
@@ -0,0 +1,200 @@
1
+ import React, { useEffect, useState, useMemo } from "react";
2
+ import { useParams, Link } from "react-router-dom";
3
+ import {
4
+ useApi,
5
+ rpcApiRef,
6
+ wrapInSuspense,
7
+ } from "@checkmate-monitor/frontend-api";
8
+ import { useSignal } from "@checkmate-monitor/signal-frontend";
9
+ import { resolveRoute } from "@checkmate-monitor/common";
10
+ import { incidentApiRef } from "../api";
11
+ import {
12
+ incidentRoutes,
13
+ INCIDENT_UPDATED,
14
+ type IncidentWithSystems,
15
+ type IncidentStatus,
16
+ } from "@checkmate-monitor/incident-common";
17
+ import {
18
+ CatalogApi,
19
+ type System,
20
+ catalogRoutes,
21
+ } from "@checkmate-monitor/catalog-common";
22
+ import {
23
+ Card,
24
+ CardHeader,
25
+ CardTitle,
26
+ CardContent,
27
+ Badge,
28
+ LoadingSpinner,
29
+ EmptyState,
30
+ BackLink,
31
+ } from "@checkmate-monitor/ui";
32
+ import { AlertTriangle, Clock, ChevronRight } from "lucide-react";
33
+ import { formatDistanceToNow } from "date-fns";
34
+
35
+ const SystemIncidentHistoryPageContent: React.FC = () => {
36
+ const { systemId } = useParams<{ systemId: string }>();
37
+ const api = useApi(incidentApiRef);
38
+ const rpcApi = useApi(rpcApiRef);
39
+
40
+ const catalogApi = useMemo(() => rpcApi.forPlugin(CatalogApi), [rpcApi]);
41
+
42
+ const [incidents, setIncidents] = useState<IncidentWithSystems[]>([]);
43
+ const [system, setSystem] = useState<System | undefined>();
44
+ const [loading, setLoading] = useState(true);
45
+
46
+ const loadData = async () => {
47
+ if (!systemId) return;
48
+
49
+ setLoading(true);
50
+ try {
51
+ const [incidentList, systemList] = await Promise.all([
52
+ api.listIncidents({ systemId, includeResolved: true }),
53
+ catalogApi.getSystems(),
54
+ ]);
55
+ const systemData = systemList.find((s) => s.id === systemId);
56
+ setIncidents(incidentList);
57
+ setSystem(systemData);
58
+ } catch (error) {
59
+ console.error("Failed to load incidents:", error);
60
+ } finally {
61
+ setLoading(false);
62
+ }
63
+ };
64
+
65
+ useEffect(() => {
66
+ loadData();
67
+ }, [systemId]);
68
+
69
+ // Listen for realtime updates
70
+ useSignal(INCIDENT_UPDATED, ({ systemIds }) => {
71
+ if (systemId && systemIds.includes(systemId)) {
72
+ loadData();
73
+ }
74
+ });
75
+
76
+ const getStatusBadge = (status: IncidentStatus) => {
77
+ switch (status) {
78
+ case "investigating": {
79
+ return <Badge variant="destructive">Investigating</Badge>;
80
+ }
81
+ case "identified": {
82
+ return <Badge variant="warning">Identified</Badge>;
83
+ }
84
+ case "fixing": {
85
+ return <Badge variant="warning">Fixing</Badge>;
86
+ }
87
+ case "monitoring": {
88
+ return <Badge variant="info">Monitoring</Badge>;
89
+ }
90
+ case "resolved": {
91
+ return <Badge variant="success">Resolved</Badge>;
92
+ }
93
+ default: {
94
+ return <Badge>{status}</Badge>;
95
+ }
96
+ }
97
+ };
98
+
99
+ const getSeverityBadge = (severity: string) => {
100
+ switch (severity) {
101
+ case "critical": {
102
+ return <Badge variant="destructive">Critical</Badge>;
103
+ }
104
+ case "major": {
105
+ return <Badge variant="warning">Major</Badge>;
106
+ }
107
+ default: {
108
+ return <Badge variant="secondary">Minor</Badge>;
109
+ }
110
+ }
111
+ };
112
+
113
+ if (loading) {
114
+ return (
115
+ <div className="p-12 flex justify-center">
116
+ <LoadingSpinner />
117
+ </div>
118
+ );
119
+ }
120
+
121
+ return (
122
+ <div className="space-y-6 p-6">
123
+ <Card>
124
+ <CardHeader className="border-b border-border">
125
+ <div className="flex items-center justify-between">
126
+ <div className="flex items-center gap-2">
127
+ <AlertTriangle className="h-5 w-5 text-muted-foreground" />
128
+ <CardTitle>
129
+ Incident History{system ? ` - ${system.name}` : ""}
130
+ </CardTitle>
131
+ </div>
132
+ {system && (
133
+ <BackLink
134
+ to={resolveRoute(catalogRoutes.routes.systemDetail, {
135
+ systemId: system.id,
136
+ })}
137
+ >
138
+ Back to {system.name}
139
+ </BackLink>
140
+ )}
141
+ </div>
142
+ </CardHeader>
143
+ <CardContent className="p-0">
144
+ {incidents.length === 0 ? (
145
+ <EmptyState
146
+ title="No incidents"
147
+ description="This system has no recorded incidents."
148
+ />
149
+ ) : (
150
+ <div className="divide-y divide-border">
151
+ {incidents.map((incident) => (
152
+ <Link
153
+ key={incident.id}
154
+ to={resolveRoute(incidentRoutes.routes.detail, {
155
+ incidentId: incident.id,
156
+ })}
157
+ className="block p-4 hover:bg-muted/50 transition-colors"
158
+ >
159
+ <div className="flex items-start justify-between">
160
+ <div className="flex-1">
161
+ <div className="flex items-center gap-2 mb-1">
162
+ <h4 className="font-medium text-foreground">
163
+ {incident.title}
164
+ </h4>
165
+ <ChevronRight className="h-4 w-4 text-muted-foreground" />
166
+ </div>
167
+ {incident.description && (
168
+ <p className="text-sm text-muted-foreground line-clamp-2 mb-2">
169
+ {incident.description}
170
+ </p>
171
+ )}
172
+ <div className="flex items-center gap-4 text-xs text-muted-foreground">
173
+ <div className="flex items-center gap-1">
174
+ <Clock className="h-3 w-3" />
175
+ <span>
176
+ {formatDistanceToNow(new Date(incident.createdAt), {
177
+ addSuffix: true,
178
+ })}
179
+ </span>
180
+ </div>
181
+ </div>
182
+ </div>
183
+ <div className="flex items-center gap-2 shrink-0">
184
+ {getSeverityBadge(incident.severity)}
185
+ {getStatusBadge(incident.status)}
186
+ </div>
187
+ </div>
188
+ </Link>
189
+ ))}
190
+ </div>
191
+ )}
192
+ </CardContent>
193
+ </Card>
194
+ </div>
195
+ );
196
+ };
197
+
198
+ export const SystemIncidentHistoryPage = wrapInSuspense(
199
+ SystemIncidentHistoryPageContent
200
+ );
@@ -0,0 +1,58 @@
1
+ import React from "react";
2
+ import { Badge } from "@checkmate-monitor/ui";
3
+ import type {
4
+ IncidentStatus,
5
+ IncidentSeverity,
6
+ } from "@checkmate-monitor/incident-common";
7
+
8
+ /**
9
+ * Returns a styled badge for the given incident status.
10
+ * Use this utility to ensure consistent status badge styling across the plugin.
11
+ */
12
+ export function getIncidentStatusBadge(
13
+ status: IncidentStatus
14
+ ): React.ReactNode {
15
+ switch (status) {
16
+ case "investigating": {
17
+ return <Badge variant="destructive">Investigating</Badge>;
18
+ }
19
+ case "identified": {
20
+ return <Badge variant="warning">Identified</Badge>;
21
+ }
22
+ case "fixing": {
23
+ return <Badge variant="warning">Fixing</Badge>;
24
+ }
25
+ case "monitoring": {
26
+ return <Badge variant="info">Monitoring</Badge>;
27
+ }
28
+ case "resolved": {
29
+ return <Badge variant="success">Resolved</Badge>;
30
+ }
31
+ default: {
32
+ return <Badge>{status}</Badge>;
33
+ }
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Returns a styled badge for the given incident severity.
39
+ * Use this utility to ensure consistent severity badge styling across the plugin.
40
+ */
41
+ export function getIncidentSeverityBadge(
42
+ severity: IncidentSeverity
43
+ ): React.ReactNode {
44
+ switch (severity) {
45
+ case "critical": {
46
+ return <Badge variant="destructive">Critical</Badge>;
47
+ }
48
+ case "major": {
49
+ return <Badge variant="warning">Major</Badge>;
50
+ }
51
+ case "minor": {
52
+ return <Badge variant="secondary">Minor</Badge>;
53
+ }
54
+ default: {
55
+ return <Badge>{severity}</Badge>;
56
+ }
57
+ }
58
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "extends": "@checkmate-monitor/tsconfig/frontend.json",
3
+ "include": [
4
+ "src"
5
+ ]
6
+ }