@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,284 @@
1
+ import React, { useEffect, useState, useCallback, useMemo } from "react";
2
+ import { useParams, useNavigate, useSearchParams } from "react-router-dom";
3
+ import {
4
+ useApi,
5
+ rpcApiRef,
6
+ permissionApiRef,
7
+ wrapInSuspense,
8
+ } from "@checkstack/frontend-api";
9
+ import { useSignal } from "@checkstack/signal-frontend";
10
+ import { resolveRoute } from "@checkstack/common";
11
+ import { incidentApiRef } from "../api";
12
+ import {
13
+ incidentRoutes,
14
+ INCIDENT_UPDATED,
15
+ type IncidentDetail,
16
+ } from "@checkstack/incident-common";
17
+ import { CatalogApi, type System } from "@checkstack/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
+ PageLayout,
30
+ } from "@checkstack/ui";
31
+ import {
32
+ AlertTriangle,
33
+ Clock,
34
+ Calendar,
35
+ MessageSquare,
36
+ CheckCircle2,
37
+ Server,
38
+ Plus,
39
+ } from "lucide-react";
40
+ import { format, formatDistanceToNow } from "date-fns";
41
+ import { IncidentUpdateForm } from "../components/IncidentUpdateForm";
42
+ import {
43
+ getIncidentStatusBadge,
44
+ getIncidentSeverityBadge,
45
+ } from "../utils/badges";
46
+
47
+ const IncidentDetailPageContent: React.FC = () => {
48
+ const { incidentId } = useParams<{ incidentId: string }>();
49
+ const navigate = useNavigate();
50
+ const [searchParams] = useSearchParams();
51
+ const api = useApi(incidentApiRef);
52
+ const rpcApi = useApi(rpcApiRef);
53
+ const permissionApi = useApi(permissionApiRef);
54
+ const toast = useToast();
55
+
56
+ const catalogApi = useMemo(() => rpcApi.forPlugin(CatalogApi), [rpcApi]);
57
+
58
+ const { allowed: canManage } = permissionApi.useResourcePermission(
59
+ "incident",
60
+ "manage"
61
+ );
62
+
63
+ const [incident, setIncident] = useState<IncidentDetail | undefined>();
64
+ const [systems, setSystems] = useState<System[]>([]);
65
+ const [loading, setLoading] = useState(true);
66
+ const [showUpdateForm, setShowUpdateForm] = useState(false);
67
+
68
+ const loadData = useCallback(async () => {
69
+ if (!incidentId) return;
70
+
71
+ try {
72
+ const [incidentData, systemList] = await Promise.all([
73
+ api.getIncident({ id: incidentId }),
74
+ catalogApi.getSystems(),
75
+ ]);
76
+ setIncident(incidentData ?? undefined);
77
+ setSystems(systemList);
78
+ } catch (error) {
79
+ console.error("Failed to load incident:", error);
80
+ } finally {
81
+ setLoading(false);
82
+ }
83
+ }, [incidentId, api, catalogApi]);
84
+
85
+ useEffect(() => {
86
+ loadData();
87
+ }, [loadData]);
88
+
89
+ // Listen for realtime updates
90
+ useSignal(INCIDENT_UPDATED, ({ incidentId: updatedId }) => {
91
+ if (incidentId === updatedId) {
92
+ loadData();
93
+ }
94
+ });
95
+
96
+ const handleUpdateSuccess = () => {
97
+ setShowUpdateForm(false);
98
+ loadData();
99
+ };
100
+
101
+ const handleResolve = async () => {
102
+ if (!incidentId) return;
103
+
104
+ try {
105
+ await api.resolveIncident({ id: incidentId });
106
+ toast.success("Incident resolved");
107
+ await loadData();
108
+ } catch (error) {
109
+ const message =
110
+ error instanceof Error ? error.message : "Failed to resolve";
111
+ toast.error(message);
112
+ }
113
+ };
114
+
115
+ const getSystemName = (systemId: string): string => {
116
+ return systems.find((s) => s.id === systemId)?.name ?? systemId;
117
+ };
118
+
119
+ if (loading) {
120
+ return (
121
+ <div className="p-12 flex justify-center">
122
+ <LoadingSpinner />
123
+ </div>
124
+ );
125
+ }
126
+
127
+ if (!incident) {
128
+ return (
129
+ <div className="p-12 text-center">
130
+ <p className="text-muted-foreground">Incident not found</p>
131
+ <BackLink
132
+ to={resolveRoute(incidentRoutes.routes.config, {})}
133
+ className="mt-4"
134
+ >
135
+ Back to Incidents
136
+ </BackLink>
137
+ </div>
138
+ );
139
+ }
140
+
141
+ const canResolve = canManage && incident.status !== "resolved";
142
+ // Use 'from' query param for back navigation, fallback to first affected system
143
+ const sourceSystemId = searchParams.get("from") ?? incident.systemIds[0];
144
+
145
+ return (
146
+ <PageLayout
147
+ title={incident.title}
148
+ subtitle="Incident details and status history"
149
+ loading={false}
150
+ allowed={true}
151
+ actions={
152
+ sourceSystemId ? (
153
+ <BackLink
154
+ onClick={() =>
155
+ navigate(
156
+ resolveRoute(incidentRoutes.routes.systemHistory, {
157
+ systemId: sourceSystemId,
158
+ })
159
+ )
160
+ }
161
+ >
162
+ Back to History
163
+ </BackLink>
164
+ ) : undefined
165
+ }
166
+ >
167
+ <div className="space-y-6">
168
+ {/* Incident Info Card */}
169
+ <Card>
170
+ <CardHeader className="border-b border-border">
171
+ <div className="flex items-center justify-between">
172
+ <div className="flex items-center gap-2">
173
+ <AlertTriangle className="h-5 w-5 text-muted-foreground" />
174
+ <CardTitle>Incident Details</CardTitle>
175
+ </div>
176
+ <div className="flex items-center gap-2">
177
+ {getIncidentSeverityBadge(incident.severity)}
178
+ {getIncidentStatusBadge(incident.status)}
179
+ {canResolve && (
180
+ <Button variant="outline" size="sm" onClick={handleResolve}>
181
+ <CheckCircle2 className="h-4 w-4 mr-1" />
182
+ Resolve
183
+ </Button>
184
+ )}
185
+ </div>
186
+ </div>
187
+ </CardHeader>
188
+ <CardContent className="p-6 space-y-4">
189
+ {incident.description && (
190
+ <div>
191
+ <h4 className="text-sm font-medium text-muted-foreground mb-1">
192
+ Description
193
+ </h4>
194
+ <p className="text-foreground">{incident.description}</p>
195
+ </div>
196
+ )}
197
+
198
+ <div className="grid grid-cols-2 gap-4">
199
+ <div>
200
+ <h4 className="text-sm font-medium text-muted-foreground mb-1">
201
+ Started
202
+ </h4>
203
+ <div className="flex items-center gap-2 text-foreground">
204
+ <Calendar className="h-4 w-4" />
205
+ <span>{format(new Date(incident.createdAt), "PPpp")}</span>
206
+ </div>
207
+ </div>
208
+ <div>
209
+ <h4 className="text-sm font-medium text-muted-foreground mb-1">
210
+ Duration
211
+ </h4>
212
+ <div className="flex items-center gap-2 text-foreground">
213
+ <Clock className="h-4 w-4" />
214
+ <span>
215
+ {formatDistanceToNow(new Date(incident.createdAt), {
216
+ addSuffix: false,
217
+ })}
218
+ </span>
219
+ </div>
220
+ </div>
221
+ </div>
222
+
223
+ <div>
224
+ <h4 className="text-sm font-medium text-muted-foreground mb-2">
225
+ Affected Systems
226
+ </h4>
227
+ <div className="flex flex-wrap gap-2">
228
+ {incident.systemIds.map((systemId) => (
229
+ <Badge key={systemId} variant="outline">
230
+ <Server className="h-3 w-3 mr-1" />
231
+ {getSystemName(systemId)}
232
+ </Badge>
233
+ ))}
234
+ </div>
235
+ </div>
236
+ </CardContent>
237
+ </Card>
238
+
239
+ {/* Status Updates Timeline */}
240
+ <Card>
241
+ <CardHeader className="border-b border-border">
242
+ <div className="flex items-center justify-between">
243
+ <div className="flex items-center gap-2">
244
+ <MessageSquare className="h-5 w-5 text-muted-foreground" />
245
+ <CardTitle>Status Updates</CardTitle>
246
+ </div>
247
+ {canManage && !showUpdateForm && (
248
+ <Button
249
+ variant="outline"
250
+ size="sm"
251
+ onClick={() => setShowUpdateForm(true)}
252
+ >
253
+ <Plus className="h-4 w-4 mr-1" />
254
+ Add Update
255
+ </Button>
256
+ )}
257
+ </div>
258
+ </CardHeader>
259
+ <CardContent className="p-6">
260
+ {/* Add Update Form */}
261
+ {showUpdateForm && incidentId && (
262
+ <div className="mb-6">
263
+ <IncidentUpdateForm
264
+ incidentId={incidentId}
265
+ onSuccess={handleUpdateSuccess}
266
+ onCancel={() => setShowUpdateForm(false)}
267
+ />
268
+ </div>
269
+ )}
270
+
271
+ <StatusUpdateTimeline
272
+ updates={incident.updates}
273
+ renderStatusBadge={getIncidentStatusBadge}
274
+ emptyTitle="No status updates"
275
+ emptyDescription="No status updates have been posted for this incident."
276
+ />
277
+ </CardContent>
278
+ </Card>
279
+ </div>
280
+ </PageLayout>
281
+ );
282
+ };
283
+
284
+ 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 "@checkstack/frontend-api";
8
+ import { useSignal } from "@checkstack/signal-frontend";
9
+ import { resolveRoute } from "@checkstack/common";
10
+ import { incidentApiRef } from "../api";
11
+ import {
12
+ incidentRoutes,
13
+ INCIDENT_UPDATED,
14
+ type IncidentWithSystems,
15
+ type IncidentStatus,
16
+ } from "@checkstack/incident-common";
17
+ import {
18
+ CatalogApi,
19
+ type System,
20
+ catalogRoutes,
21
+ } from "@checkstack/catalog-common";
22
+ import {
23
+ Card,
24
+ CardHeader,
25
+ CardTitle,
26
+ CardContent,
27
+ Badge,
28
+ LoadingSpinner,
29
+ EmptyState,
30
+ BackLink,
31
+ } from "@checkstack/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
+ })}?from=${systemId}`}
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 "@checkstack/ui";
3
+ import type {
4
+ IncidentStatus,
5
+ IncidentSeverity,
6
+ } from "@checkstack/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": "@checkstack/tsconfig/frontend.json",
3
+ "include": [
4
+ "src"
5
+ ]
6
+ }