@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.
- package/CHANGELOG.md +17 -0
- package/package.json +29 -0
- package/src/api.ts +10 -0
- package/src/components/IncidentEditor.tsx +311 -0
- package/src/components/IncidentMenuItems.tsx +27 -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 +70 -0
- package/src/pages/IncidentConfigPage.tsx +387 -0
- package/src/pages/IncidentDetailPage.tsx +269 -0
- package/src/pages/SystemIncidentHistoryPage.tsx +200 -0
- package/src/utils/badges.tsx +58 -0
- package/tsconfig.json +6 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# @checkmate-monitor/incident-frontend
|
|
2
|
+
|
|
3
|
+
## 0.0.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies [eff5b4e]
|
|
8
|
+
- Updated dependencies [ffc28f6]
|
|
9
|
+
- Updated dependencies [4dd644d]
|
|
10
|
+
- Updated dependencies [b55fae6]
|
|
11
|
+
- Updated dependencies [b354ab3]
|
|
12
|
+
- @checkmate-monitor/ui@0.1.0
|
|
13
|
+
- @checkmate-monitor/common@0.1.0
|
|
14
|
+
- @checkmate-monitor/catalog-common@0.1.0
|
|
15
|
+
- @checkmate-monitor/incident-common@0.1.0
|
|
16
|
+
- @checkmate-monitor/signal-frontend@0.1.0
|
|
17
|
+
- @checkmate-monitor/frontend-api@0.0.2
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@checkmate-monitor/incident-frontend",
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "src/index.tsx",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"typecheck": "tsc --noEmit",
|
|
8
|
+
"lint": "bun run lint:code",
|
|
9
|
+
"lint:code": "eslint . --max-warnings 0"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@checkmate-monitor/catalog-common": "workspace:*",
|
|
13
|
+
"@checkmate-monitor/common": "workspace:*",
|
|
14
|
+
"@checkmate-monitor/frontend-api": "workspace:*",
|
|
15
|
+
"@checkmate-monitor/incident-common": "workspace:*",
|
|
16
|
+
"@checkmate-monitor/signal-frontend": "workspace:*",
|
|
17
|
+
"@checkmate-monitor/ui": "workspace:*",
|
|
18
|
+
"date-fns": "^4.1.0",
|
|
19
|
+
"lucide-react": "^0.344.0",
|
|
20
|
+
"react": "^18.2.0",
|
|
21
|
+
"react-router-dom": "^6.20.0"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"typescript": "^5.0.0",
|
|
25
|
+
"@types/react": "^18.2.0",
|
|
26
|
+
"@checkmate-monitor/tsconfig": "workspace:*",
|
|
27
|
+
"@checkmate-monitor/scripts": "workspace:*"
|
|
28
|
+
}
|
|
29
|
+
}
|
package/src/api.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { createApiRef } from "@checkmate-monitor/frontend-api";
|
|
2
|
+
import { IncidentApi } from "@checkmate-monitor/incident-common";
|
|
3
|
+
import type { InferClient } from "@checkmate-monitor/common";
|
|
4
|
+
|
|
5
|
+
// IncidentApiClient type inferred from the client definition
|
|
6
|
+
export type IncidentApiClient = InferClient<typeof IncidentApi>;
|
|
7
|
+
|
|
8
|
+
export const incidentApiRef = createApiRef<IncidentApiClient>(
|
|
9
|
+
"plugin.incident.api"
|
|
10
|
+
);
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import React, { useState, useEffect, useCallback } from "react";
|
|
2
|
+
import { useApi } from "@checkmate-monitor/frontend-api";
|
|
3
|
+
import { incidentApiRef } from "../api";
|
|
4
|
+
import type {
|
|
5
|
+
IncidentWithSystems,
|
|
6
|
+
IncidentSeverity,
|
|
7
|
+
IncidentUpdate,
|
|
8
|
+
} from "@checkmate-monitor/incident-common";
|
|
9
|
+
import type { System } from "@checkmate-monitor/catalog-common";
|
|
10
|
+
import {
|
|
11
|
+
Dialog,
|
|
12
|
+
DialogContent,
|
|
13
|
+
DialogHeader,
|
|
14
|
+
DialogTitle,
|
|
15
|
+
DialogFooter,
|
|
16
|
+
Button,
|
|
17
|
+
Input,
|
|
18
|
+
Label,
|
|
19
|
+
Textarea,
|
|
20
|
+
Checkbox,
|
|
21
|
+
useToast,
|
|
22
|
+
Select,
|
|
23
|
+
SelectTrigger,
|
|
24
|
+
SelectValue,
|
|
25
|
+
SelectContent,
|
|
26
|
+
SelectItem,
|
|
27
|
+
StatusUpdateTimeline,
|
|
28
|
+
} from "@checkmate-monitor/ui";
|
|
29
|
+
import { Plus, MessageSquare, Loader2, AlertCircle } from "lucide-react";
|
|
30
|
+
import { IncidentUpdateForm } from "./IncidentUpdateForm";
|
|
31
|
+
import { getIncidentStatusBadge } from "../utils/badges";
|
|
32
|
+
|
|
33
|
+
interface Props {
|
|
34
|
+
open: boolean;
|
|
35
|
+
onOpenChange: (open: boolean) => void;
|
|
36
|
+
incident?: IncidentWithSystems;
|
|
37
|
+
systems: System[];
|
|
38
|
+
onSave: () => void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const IncidentEditor: React.FC<Props> = ({
|
|
42
|
+
open,
|
|
43
|
+
onOpenChange,
|
|
44
|
+
incident,
|
|
45
|
+
systems,
|
|
46
|
+
onSave,
|
|
47
|
+
}) => {
|
|
48
|
+
const api = useApi(incidentApiRef);
|
|
49
|
+
const toast = useToast();
|
|
50
|
+
|
|
51
|
+
// Incident fields
|
|
52
|
+
const [title, setTitle] = useState("");
|
|
53
|
+
const [description, setDescription] = useState("");
|
|
54
|
+
const [severity, setSeverity] = useState<IncidentSeverity>("major");
|
|
55
|
+
const [selectedSystemIds, setSelectedSystemIds] = useState<Set<string>>(
|
|
56
|
+
new Set()
|
|
57
|
+
);
|
|
58
|
+
const [saving, setSaving] = useState(false);
|
|
59
|
+
|
|
60
|
+
// Status update fields
|
|
61
|
+
const [updates, setUpdates] = useState<IncidentUpdate[]>([]);
|
|
62
|
+
const [loadingUpdates, setLoadingUpdates] = useState(false);
|
|
63
|
+
const [showUpdateForm, setShowUpdateForm] = useState(false);
|
|
64
|
+
|
|
65
|
+
const loadIncidentDetails = useCallback(
|
|
66
|
+
async (id: string) => {
|
|
67
|
+
setLoadingUpdates(true);
|
|
68
|
+
try {
|
|
69
|
+
const detail = await api.getIncident({ id });
|
|
70
|
+
if (detail) {
|
|
71
|
+
setUpdates(detail.updates);
|
|
72
|
+
}
|
|
73
|
+
} catch (error) {
|
|
74
|
+
console.error("Failed to load incident details:", error);
|
|
75
|
+
} finally {
|
|
76
|
+
setLoadingUpdates(false);
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
[api]
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
// Reset form when incident changes
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
if (incident) {
|
|
85
|
+
setTitle(incident.title);
|
|
86
|
+
setDescription(incident.description ?? "");
|
|
87
|
+
setSeverity(incident.severity);
|
|
88
|
+
setSelectedSystemIds(new Set(incident.systemIds));
|
|
89
|
+
// Load full incident with updates
|
|
90
|
+
loadIncidentDetails(incident.id);
|
|
91
|
+
} else {
|
|
92
|
+
setTitle("");
|
|
93
|
+
setDescription("");
|
|
94
|
+
setSeverity("major");
|
|
95
|
+
setSelectedSystemIds(new Set());
|
|
96
|
+
setUpdates([]);
|
|
97
|
+
setShowUpdateForm(false);
|
|
98
|
+
}
|
|
99
|
+
}, [incident, open, loadIncidentDetails]);
|
|
100
|
+
|
|
101
|
+
const handleSystemToggle = (systemId: string) => {
|
|
102
|
+
setSelectedSystemIds((prev) => {
|
|
103
|
+
const next = new Set(prev);
|
|
104
|
+
if (next.has(systemId)) {
|
|
105
|
+
next.delete(systemId);
|
|
106
|
+
} else {
|
|
107
|
+
next.add(systemId);
|
|
108
|
+
}
|
|
109
|
+
return next;
|
|
110
|
+
});
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const handleSubmit = async () => {
|
|
114
|
+
if (!title.trim()) {
|
|
115
|
+
toast.error("Title is required");
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (selectedSystemIds.size === 0) {
|
|
119
|
+
toast.error("At least one system must be selected");
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
setSaving(true);
|
|
124
|
+
try {
|
|
125
|
+
if (incident) {
|
|
126
|
+
await api.updateIncident({
|
|
127
|
+
id: incident.id,
|
|
128
|
+
title,
|
|
129
|
+
description: description || undefined,
|
|
130
|
+
severity,
|
|
131
|
+
systemIds: [...selectedSystemIds],
|
|
132
|
+
});
|
|
133
|
+
toast.success("Incident updated");
|
|
134
|
+
} else {
|
|
135
|
+
await api.createIncident({
|
|
136
|
+
title,
|
|
137
|
+
description,
|
|
138
|
+
severity,
|
|
139
|
+
systemIds: [...selectedSystemIds],
|
|
140
|
+
});
|
|
141
|
+
toast.success("Incident created");
|
|
142
|
+
}
|
|
143
|
+
onSave();
|
|
144
|
+
} catch (error) {
|
|
145
|
+
const message = error instanceof Error ? error.message : "Failed to save";
|
|
146
|
+
toast.error(message);
|
|
147
|
+
} finally {
|
|
148
|
+
setSaving(false);
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const handleUpdateSuccess = () => {
|
|
153
|
+
if (incident) {
|
|
154
|
+
loadIncidentDetails(incident.id);
|
|
155
|
+
}
|
|
156
|
+
setShowUpdateForm(false);
|
|
157
|
+
// Notify parent to refresh list (status may have changed)
|
|
158
|
+
onSave();
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
return (
|
|
162
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
163
|
+
<DialogContent size="xl">
|
|
164
|
+
<DialogHeader>
|
|
165
|
+
<DialogTitle>
|
|
166
|
+
{incident ? "Edit Incident" : "Create Incident"}
|
|
167
|
+
</DialogTitle>
|
|
168
|
+
</DialogHeader>
|
|
169
|
+
|
|
170
|
+
<div className="grid gap-6 py-4 max-h-[70vh] overflow-y-auto">
|
|
171
|
+
{/* Basic Info Section */}
|
|
172
|
+
<div className="grid gap-4">
|
|
173
|
+
<div className="grid gap-2">
|
|
174
|
+
<Label htmlFor="title">Title</Label>
|
|
175
|
+
<Input
|
|
176
|
+
id="title"
|
|
177
|
+
value={title}
|
|
178
|
+
onChange={(e) => setTitle(e.target.value)}
|
|
179
|
+
placeholder="API degradation affecting users"
|
|
180
|
+
/>
|
|
181
|
+
</div>
|
|
182
|
+
|
|
183
|
+
<div className="grid gap-2">
|
|
184
|
+
<Label htmlFor="description">Description</Label>
|
|
185
|
+
<Textarea
|
|
186
|
+
id="description"
|
|
187
|
+
value={description}
|
|
188
|
+
onChange={(e) => setDescription(e.target.value)}
|
|
189
|
+
placeholder="Details about the incident..."
|
|
190
|
+
rows={3}
|
|
191
|
+
/>
|
|
192
|
+
</div>
|
|
193
|
+
|
|
194
|
+
<div className="grid gap-2">
|
|
195
|
+
<Label>Severity</Label>
|
|
196
|
+
<Select
|
|
197
|
+
value={severity}
|
|
198
|
+
onValueChange={(v) => setSeverity(v as IncidentSeverity)}
|
|
199
|
+
>
|
|
200
|
+
<SelectTrigger>
|
|
201
|
+
<SelectValue placeholder="Select severity" />
|
|
202
|
+
</SelectTrigger>
|
|
203
|
+
<SelectContent>
|
|
204
|
+
<SelectItem value="minor">Minor</SelectItem>
|
|
205
|
+
<SelectItem value="major">Major</SelectItem>
|
|
206
|
+
<SelectItem value="critical">Critical</SelectItem>
|
|
207
|
+
</SelectContent>
|
|
208
|
+
</Select>
|
|
209
|
+
</div>
|
|
210
|
+
|
|
211
|
+
<div className="grid gap-2">
|
|
212
|
+
<Label>Affected Systems</Label>
|
|
213
|
+
<div className="max-h-36 overflow-y-auto border rounded-md p-3 space-y-2">
|
|
214
|
+
{systems.length === 0 ? (
|
|
215
|
+
<p className="text-sm text-muted-foreground">
|
|
216
|
+
No systems available
|
|
217
|
+
</p>
|
|
218
|
+
) : (
|
|
219
|
+
systems.map((system) => (
|
|
220
|
+
<div
|
|
221
|
+
key={system.id}
|
|
222
|
+
className="flex items-center space-x-2 p-2 rounded hover:bg-accent cursor-pointer"
|
|
223
|
+
onClick={() => handleSystemToggle(system.id)}
|
|
224
|
+
>
|
|
225
|
+
<Checkbox
|
|
226
|
+
id={`system-${system.id}`}
|
|
227
|
+
checked={selectedSystemIds.has(system.id)}
|
|
228
|
+
/>
|
|
229
|
+
<Label
|
|
230
|
+
htmlFor={`system-${system.id}`}
|
|
231
|
+
className="cursor-pointer flex-1"
|
|
232
|
+
>
|
|
233
|
+
{system.name}
|
|
234
|
+
</Label>
|
|
235
|
+
</div>
|
|
236
|
+
))
|
|
237
|
+
)}
|
|
238
|
+
</div>
|
|
239
|
+
<p className="text-xs text-muted-foreground">
|
|
240
|
+
{selectedSystemIds.size} system(s) selected
|
|
241
|
+
</p>
|
|
242
|
+
</div>
|
|
243
|
+
</div>
|
|
244
|
+
|
|
245
|
+
{/* Status Updates Section - Only show when editing */}
|
|
246
|
+
{incident && (
|
|
247
|
+
<div className="border-t pt-4">
|
|
248
|
+
<div className="flex items-center justify-between mb-4">
|
|
249
|
+
<div className="flex items-center gap-2">
|
|
250
|
+
<MessageSquare className="h-4 w-4 text-muted-foreground" />
|
|
251
|
+
<Label className="text-base font-medium">
|
|
252
|
+
Status Updates
|
|
253
|
+
</Label>
|
|
254
|
+
</div>
|
|
255
|
+
{!showUpdateForm && (
|
|
256
|
+
<Button
|
|
257
|
+
variant="outline"
|
|
258
|
+
size="sm"
|
|
259
|
+
onClick={() => setShowUpdateForm(true)}
|
|
260
|
+
>
|
|
261
|
+
<Plus className="h-4 w-4 mr-1" />
|
|
262
|
+
Add Update
|
|
263
|
+
</Button>
|
|
264
|
+
)}
|
|
265
|
+
</div>
|
|
266
|
+
|
|
267
|
+
{/* Add Update Form */}
|
|
268
|
+
{showUpdateForm && (
|
|
269
|
+
<div className="mb-4">
|
|
270
|
+
<IncidentUpdateForm
|
|
271
|
+
incidentId={incident.id}
|
|
272
|
+
onSuccess={handleUpdateSuccess}
|
|
273
|
+
onCancel={() => setShowUpdateForm(false)}
|
|
274
|
+
/>
|
|
275
|
+
</div>
|
|
276
|
+
)}
|
|
277
|
+
|
|
278
|
+
{/* Updates List */}
|
|
279
|
+
{loadingUpdates ? (
|
|
280
|
+
<div className="flex justify-center py-4">
|
|
281
|
+
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
282
|
+
</div>
|
|
283
|
+
) : updates.length === 0 ? (
|
|
284
|
+
<div className="flex flex-col items-center py-6 text-muted-foreground">
|
|
285
|
+
<AlertCircle className="h-8 w-8 mb-2" />
|
|
286
|
+
<p className="text-sm">No status updates yet</p>
|
|
287
|
+
</div>
|
|
288
|
+
) : (
|
|
289
|
+
<StatusUpdateTimeline
|
|
290
|
+
updates={updates}
|
|
291
|
+
renderStatusBadge={getIncidentStatusBadge}
|
|
292
|
+
showTimeline={false}
|
|
293
|
+
maxHeight="max-h-48"
|
|
294
|
+
/>
|
|
295
|
+
)}
|
|
296
|
+
</div>
|
|
297
|
+
)}
|
|
298
|
+
</div>
|
|
299
|
+
|
|
300
|
+
<DialogFooter>
|
|
301
|
+
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
|
302
|
+
Cancel
|
|
303
|
+
</Button>
|
|
304
|
+
<Button onClick={handleSubmit} disabled={saving}>
|
|
305
|
+
{saving ? "Saving..." : incident ? "Update" : "Create"}
|
|
306
|
+
</Button>
|
|
307
|
+
</DialogFooter>
|
|
308
|
+
</DialogContent>
|
|
309
|
+
</Dialog>
|
|
310
|
+
);
|
|
311
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Link } from "react-router-dom";
|
|
3
|
+
import { AlertTriangle } from "lucide-react";
|
|
4
|
+
import { useApi, permissionApiRef } from "@checkmate-monitor/frontend-api";
|
|
5
|
+
import { DropdownMenuItem } from "@checkmate-monitor/ui";
|
|
6
|
+
import { resolveRoute } from "@checkmate-monitor/common";
|
|
7
|
+
import { incidentRoutes } from "@checkmate-monitor/incident-common";
|
|
8
|
+
|
|
9
|
+
export const IncidentMenuItems = () => {
|
|
10
|
+
const permissionApi = useApi(permissionApiRef);
|
|
11
|
+
const { allowed: canManage, loading } = permissionApi.useResourcePermission(
|
|
12
|
+
"incident",
|
|
13
|
+
"manage"
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
if (loading || !canManage) {
|
|
17
|
+
return <React.Fragment />;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<Link to={resolveRoute(incidentRoutes.routes.config)}>
|
|
22
|
+
<DropdownMenuItem icon={<AlertTriangle className="w-4 h-4" />}>
|
|
23
|
+
Incidents
|
|
24
|
+
</DropdownMenuItem>
|
|
25
|
+
</Link>
|
|
26
|
+
);
|
|
27
|
+
};
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import { useApi } from "@checkmate-monitor/frontend-api";
|
|
3
|
+
import { incidentApiRef } from "../api";
|
|
4
|
+
import type { IncidentStatus } from "@checkmate-monitor/incident-common";
|
|
5
|
+
import {
|
|
6
|
+
Button,
|
|
7
|
+
Textarea,
|
|
8
|
+
Label,
|
|
9
|
+
Select,
|
|
10
|
+
SelectTrigger,
|
|
11
|
+
SelectValue,
|
|
12
|
+
SelectContent,
|
|
13
|
+
SelectItem,
|
|
14
|
+
useToast,
|
|
15
|
+
} from "@checkmate-monitor/ui";
|
|
16
|
+
import { Loader2 } from "lucide-react";
|
|
17
|
+
|
|
18
|
+
interface IncidentUpdateFormProps {
|
|
19
|
+
incidentId: string;
|
|
20
|
+
onSuccess: () => void;
|
|
21
|
+
onCancel?: () => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Reusable form for adding status updates to an incident.
|
|
26
|
+
* Used in both IncidentDetailPage and IncidentEditor.
|
|
27
|
+
*/
|
|
28
|
+
export const IncidentUpdateForm: React.FC<IncidentUpdateFormProps> = ({
|
|
29
|
+
incidentId,
|
|
30
|
+
onSuccess,
|
|
31
|
+
onCancel,
|
|
32
|
+
}) => {
|
|
33
|
+
const api = useApi(incidentApiRef);
|
|
34
|
+
const toast = useToast();
|
|
35
|
+
|
|
36
|
+
const [message, setMessage] = useState("");
|
|
37
|
+
const [statusChange, setStatusChange] = useState<IncidentStatus | "">("");
|
|
38
|
+
const [isPosting, setIsPosting] = useState(false);
|
|
39
|
+
|
|
40
|
+
const handleSubmit = async () => {
|
|
41
|
+
if (!message.trim()) {
|
|
42
|
+
toast.error("Update message is required");
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
setIsPosting(true);
|
|
47
|
+
try {
|
|
48
|
+
await api.addUpdate({
|
|
49
|
+
incidentId,
|
|
50
|
+
message,
|
|
51
|
+
statusChange: statusChange || undefined,
|
|
52
|
+
});
|
|
53
|
+
toast.success("Update posted");
|
|
54
|
+
setMessage("");
|
|
55
|
+
setStatusChange("");
|
|
56
|
+
onSuccess();
|
|
57
|
+
} catch (error) {
|
|
58
|
+
const errorMessage =
|
|
59
|
+
error instanceof Error ? error.message : "Failed to post update";
|
|
60
|
+
toast.error(errorMessage);
|
|
61
|
+
} finally {
|
|
62
|
+
setIsPosting(false);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<div className="p-4 bg-muted/30 rounded-lg border space-y-3">
|
|
68
|
+
<div className="grid gap-2">
|
|
69
|
+
<Label htmlFor="updateMessage">Update Message</Label>
|
|
70
|
+
<Textarea
|
|
71
|
+
id="updateMessage"
|
|
72
|
+
value={message}
|
|
73
|
+
onChange={(e) => setMessage(e.target.value)}
|
|
74
|
+
placeholder="Describe the status update..."
|
|
75
|
+
rows={2}
|
|
76
|
+
/>
|
|
77
|
+
</div>
|
|
78
|
+
<div className="grid gap-2">
|
|
79
|
+
<Label>Change Status (Optional)</Label>
|
|
80
|
+
<Select
|
|
81
|
+
value={statusChange || "__keep_current__"}
|
|
82
|
+
onValueChange={(v) =>
|
|
83
|
+
setStatusChange(
|
|
84
|
+
v === "__keep_current__" ? "" : (v as IncidentStatus)
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
>
|
|
88
|
+
<SelectTrigger>
|
|
89
|
+
<SelectValue placeholder="Keep current status" />
|
|
90
|
+
</SelectTrigger>
|
|
91
|
+
<SelectContent>
|
|
92
|
+
<SelectItem value="__keep_current__">Keep Current</SelectItem>
|
|
93
|
+
<SelectItem value="investigating">Investigating</SelectItem>
|
|
94
|
+
<SelectItem value="identified">Identified</SelectItem>
|
|
95
|
+
<SelectItem value="fixing">Fixing</SelectItem>
|
|
96
|
+
<SelectItem value="monitoring">Monitoring</SelectItem>
|
|
97
|
+
<SelectItem value="resolved">Resolved</SelectItem>
|
|
98
|
+
</SelectContent>
|
|
99
|
+
</Select>
|
|
100
|
+
</div>
|
|
101
|
+
<div className="flex gap-2 justify-end">
|
|
102
|
+
{onCancel && (
|
|
103
|
+
<Button variant="outline" size="sm" onClick={onCancel}>
|
|
104
|
+
Cancel
|
|
105
|
+
</Button>
|
|
106
|
+
)}
|
|
107
|
+
<Button
|
|
108
|
+
size="sm"
|
|
109
|
+
onClick={handleSubmit}
|
|
110
|
+
disabled={isPosting || !message.trim()}
|
|
111
|
+
>
|
|
112
|
+
{isPosting ? (
|
|
113
|
+
<>
|
|
114
|
+
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
|
115
|
+
Posting...
|
|
116
|
+
</>
|
|
117
|
+
) : (
|
|
118
|
+
"Post Update"
|
|
119
|
+
)}
|
|
120
|
+
</Button>
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
);
|
|
124
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import React, { useEffect, useState, useCallback } from "react";
|
|
2
|
+
import { useApi, type SlotContext } from "@checkmate-monitor/frontend-api";
|
|
3
|
+
import { useSignal } from "@checkmate-monitor/signal-frontend";
|
|
4
|
+
import { SystemStateBadgesSlot } from "@checkmate-monitor/catalog-common";
|
|
5
|
+
import { incidentApiRef } from "../api";
|
|
6
|
+
import {
|
|
7
|
+
INCIDENT_UPDATED,
|
|
8
|
+
type IncidentWithSystems,
|
|
9
|
+
} from "@checkmate-monitor/incident-common";
|
|
10
|
+
import { Badge } from "@checkmate-monitor/ui";
|
|
11
|
+
|
|
12
|
+
type Props = SlotContext<typeof SystemStateBadgesSlot>;
|
|
13
|
+
|
|
14
|
+
const SEVERITY_WEIGHTS = { critical: 3, major: 2, minor: 1 } as const;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Displays an incident badge for a system when it has an active incident.
|
|
18
|
+
* Shows nothing if no active incidents.
|
|
19
|
+
* Listens for realtime updates via signals.
|
|
20
|
+
*/
|
|
21
|
+
export const SystemIncidentBadge: React.FC<Props> = ({ system }) => {
|
|
22
|
+
const api = useApi(incidentApiRef);
|
|
23
|
+
const [activeIncident, setActiveIncident] = useState<
|
|
24
|
+
IncidentWithSystems | undefined
|
|
25
|
+
>();
|
|
26
|
+
|
|
27
|
+
const refetch = useCallback(() => {
|
|
28
|
+
if (!system?.id) return;
|
|
29
|
+
|
|
30
|
+
api
|
|
31
|
+
.getIncidentsForSystem({ systemId: system.id })
|
|
32
|
+
.then((incidents: IncidentWithSystems[]) => {
|
|
33
|
+
// Get the most severe active incident
|
|
34
|
+
const sorted = [...incidents].toSorted((a, b) => {
|
|
35
|
+
return (
|
|
36
|
+
(SEVERITY_WEIGHTS[b.severity as keyof typeof SEVERITY_WEIGHTS] ||
|
|
37
|
+
0) -
|
|
38
|
+
(SEVERITY_WEIGHTS[a.severity as keyof typeof SEVERITY_WEIGHTS] || 0)
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
setActiveIncident(sorted[0]);
|
|
42
|
+
})
|
|
43
|
+
.catch(console.error);
|
|
44
|
+
}, [system?.id, api]);
|
|
45
|
+
|
|
46
|
+
// Initial fetch
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
refetch();
|
|
49
|
+
}, [refetch]);
|
|
50
|
+
|
|
51
|
+
// Listen for realtime incident updates
|
|
52
|
+
useSignal(INCIDENT_UPDATED, ({ systemIds }) => {
|
|
53
|
+
if (system?.id && systemIds.includes(system.id)) {
|
|
54
|
+
refetch();
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
if (!activeIncident) return;
|
|
59
|
+
|
|
60
|
+
const variant =
|
|
61
|
+
activeIncident.severity === "critical"
|
|
62
|
+
? "destructive"
|
|
63
|
+
: activeIncident.severity === "major"
|
|
64
|
+
? "warning"
|
|
65
|
+
: "info";
|
|
66
|
+
|
|
67
|
+
return <Badge variant={variant}>Incident</Badge>;
|
|
68
|
+
};
|