@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.
- package/CHANGELOG.md +86 -0
- package/package.json +29 -0
- package/src/api.ts +10 -0
- package/src/components/IncidentEditor.tsx +311 -0
- package/src/components/IncidentMenuItems.tsx +33 -0
- package/src/components/IncidentUpdateForm.tsx +124 -0
- package/src/components/SystemIncidentBadge.tsx +68 -0
- package/src/components/SystemIncidentPanel.tsx +240 -0
- package/src/index.tsx +69 -0
- package/src/pages/IncidentConfigPage.tsx +387 -0
- package/src/pages/IncidentDetailPage.tsx +284 -0
- package/src/pages/SystemIncidentHistoryPage.tsx +200 -0
- package/src/utils/badges.tsx +58 -0
- package/tsconfig.json +6 -0
|
@@ -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);
|