@checkstack/incident-frontend 0.2.0 → 0.3.0
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 +67 -0
- package/package.json +2 -1
- package/src/api.ts +9 -10
- package/src/components/IncidentEditor.tsx +58 -52
- package/src/components/IncidentUpdateForm.tsx +25 -25
- package/src/components/SystemIncidentBadge.tsx +46 -31
- package/src/components/SystemIncidentPanel.tsx +14 -21
- package/src/index.tsx +2 -13
- package/src/pages/IncidentConfigPage.tsx +57 -73
- package/src/pages/IncidentDetailPage.tsx +42 -45
- package/src/pages/SystemIncidentHistoryPage.tsx +23 -39
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,72 @@
|
|
|
1
1
|
# @checkstack/incident-frontend
|
|
2
2
|
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 7a23261: ## TanStack Query Integration
|
|
8
|
+
|
|
9
|
+
Migrated all frontend components to use `usePluginClient` hook with TanStack Query integration, replacing the legacy `forPlugin()` pattern.
|
|
10
|
+
|
|
11
|
+
### New Features
|
|
12
|
+
|
|
13
|
+
- **`usePluginClient` hook**: Provides type-safe access to plugin APIs with `.useQuery()` and `.useMutation()` methods
|
|
14
|
+
- **Automatic request deduplication**: Multiple components requesting the same data share a single network request
|
|
15
|
+
- **Built-in caching**: Configurable stale time and cache duration per query
|
|
16
|
+
- **Loading/error states**: TanStack Query provides `isLoading`, `error`, `isRefetching` states automatically
|
|
17
|
+
- **Background refetching**: Stale data is automatically refreshed when components mount
|
|
18
|
+
|
|
19
|
+
### Contract Changes
|
|
20
|
+
|
|
21
|
+
All RPC contracts now require `operationType: "query"` or `operationType: "mutation"` metadata:
|
|
22
|
+
|
|
23
|
+
```typescript
|
|
24
|
+
const getItems = proc()
|
|
25
|
+
.meta({ operationType: "query", access: [access.read] })
|
|
26
|
+
.output(z.array(itemSchema))
|
|
27
|
+
.query();
|
|
28
|
+
|
|
29
|
+
const createItem = proc()
|
|
30
|
+
.meta({ operationType: "mutation", access: [access.manage] })
|
|
31
|
+
.input(createItemSchema)
|
|
32
|
+
.output(itemSchema)
|
|
33
|
+
.mutation();
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Migration
|
|
37
|
+
|
|
38
|
+
```typescript
|
|
39
|
+
// Before (forPlugin pattern)
|
|
40
|
+
const api = useApi(myPluginApiRef);
|
|
41
|
+
const [items, setItems] = useState<Item[]>([]);
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
api.getItems().then(setItems);
|
|
44
|
+
}, [api]);
|
|
45
|
+
|
|
46
|
+
// After (usePluginClient pattern)
|
|
47
|
+
const client = usePluginClient(MyPluginApi);
|
|
48
|
+
const { data: items, isLoading } = client.getItems.useQuery({});
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Bug Fixes
|
|
52
|
+
|
|
53
|
+
- Fixed `rpc.test.ts` test setup for middleware type inference
|
|
54
|
+
- Fixed `SearchDialog` to use `setQuery` instead of deprecated `search` method
|
|
55
|
+
- Fixed null→undefined warnings in notification and queue frontends
|
|
56
|
+
|
|
57
|
+
### Patch Changes
|
|
58
|
+
|
|
59
|
+
- Updated dependencies [180be38]
|
|
60
|
+
- Updated dependencies [7a23261]
|
|
61
|
+
- @checkstack/dashboard-frontend@0.2.0
|
|
62
|
+
- @checkstack/frontend-api@0.2.0
|
|
63
|
+
- @checkstack/common@0.3.0
|
|
64
|
+
- @checkstack/auth-frontend@0.3.0
|
|
65
|
+
- @checkstack/catalog-common@1.2.0
|
|
66
|
+
- @checkstack/incident-common@0.3.0
|
|
67
|
+
- @checkstack/ui@0.2.1
|
|
68
|
+
- @checkstack/signal-frontend@0.0.7
|
|
69
|
+
|
|
3
70
|
## 0.2.0
|
|
4
71
|
|
|
5
72
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/incident-frontend",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "src/index.tsx",
|
|
6
6
|
"scripts": {
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
"@checkstack/auth-frontend": "workspace:*",
|
|
13
13
|
"@checkstack/catalog-common": "workspace:*",
|
|
14
14
|
"@checkstack/common": "workspace:*",
|
|
15
|
+
"@checkstack/dashboard-frontend": "workspace:*",
|
|
15
16
|
"@checkstack/frontend-api": "workspace:*",
|
|
16
17
|
"@checkstack/incident-common": "workspace:*",
|
|
17
18
|
"@checkstack/signal-frontend": "workspace:*",
|
package/src/api.ts
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
);
|
|
1
|
+
// Re-export types for convenience
|
|
2
|
+
export type {
|
|
3
|
+
IncidentWithSystems,
|
|
4
|
+
IncidentDetail,
|
|
5
|
+
IncidentUpdate,
|
|
6
|
+
IncidentStatus,
|
|
7
|
+
} from "@checkstack/incident-common";
|
|
8
|
+
// Client definition is in @checkstack/incident-common - use with usePluginClient
|
|
9
|
+
export { IncidentApi } from "@checkstack/incident-common";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import React, { useState, useEffect
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import React, { useState, useEffect } from "react";
|
|
2
|
+
import { usePluginClient } from "@checkstack/frontend-api";
|
|
3
|
+
import { IncidentApi } from "../api";
|
|
4
4
|
import type {
|
|
5
5
|
IncidentWithSystems,
|
|
6
6
|
IncidentSeverity,
|
|
@@ -47,7 +47,7 @@ export const IncidentEditor: React.FC<Props> = ({
|
|
|
47
47
|
systems,
|
|
48
48
|
onSave,
|
|
49
49
|
}) => {
|
|
50
|
-
const
|
|
50
|
+
const incidentClient = usePluginClient(IncidentApi);
|
|
51
51
|
const toast = useToast();
|
|
52
52
|
|
|
53
53
|
// Incident fields
|
|
@@ -57,29 +57,46 @@ export const IncidentEditor: React.FC<Props> = ({
|
|
|
57
57
|
const [selectedSystemIds, setSelectedSystemIds] = useState<Set<string>>(
|
|
58
58
|
new Set()
|
|
59
59
|
);
|
|
60
|
-
const [saving, setSaving] = useState(false);
|
|
61
60
|
|
|
62
61
|
// Status update fields
|
|
63
62
|
const [updates, setUpdates] = useState<IncidentUpdate[]>([]);
|
|
64
|
-
const [loadingUpdates,
|
|
63
|
+
const [loadingUpdates, _setLoadingUpdates] = useState(false);
|
|
65
64
|
const [showUpdateForm, setShowUpdateForm] = useState(false);
|
|
66
65
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
if (detail) {
|
|
73
|
-
setUpdates(detail.updates);
|
|
74
|
-
}
|
|
75
|
-
} catch (error) {
|
|
76
|
-
console.error("Failed to load incident details:", error);
|
|
77
|
-
} finally {
|
|
78
|
-
setLoadingUpdates(false);
|
|
79
|
-
}
|
|
66
|
+
// Mutations
|
|
67
|
+
const createMutation = incidentClient.createIncident.useMutation({
|
|
68
|
+
onSuccess: () => {
|
|
69
|
+
toast.success("Incident created");
|
|
70
|
+
onSave();
|
|
80
71
|
},
|
|
81
|
-
|
|
82
|
-
|
|
72
|
+
onError: (error) => {
|
|
73
|
+
toast.error(error instanceof Error ? error.message : "Failed to save");
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const updateMutation = incidentClient.updateIncident.useMutation({
|
|
78
|
+
onSuccess: () => {
|
|
79
|
+
toast.success("Incident updated");
|
|
80
|
+
onSave();
|
|
81
|
+
},
|
|
82
|
+
onError: (error) => {
|
|
83
|
+
toast.error(error instanceof Error ? error.message : "Failed to save");
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// Query for incident details (only when editing)
|
|
88
|
+
const { data: incidentDetail, refetch: refetchDetail } =
|
|
89
|
+
incidentClient.getIncident.useQuery(
|
|
90
|
+
{ id: incident?.id ?? "" },
|
|
91
|
+
{ enabled: !!incident?.id && open }
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
// Sync updates from query
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
if (incidentDetail) {
|
|
97
|
+
setUpdates(incidentDetail.updates);
|
|
98
|
+
}
|
|
99
|
+
}, [incidentDetail]);
|
|
83
100
|
|
|
84
101
|
// Reset form when incident changes
|
|
85
102
|
useEffect(() => {
|
|
@@ -88,8 +105,6 @@ export const IncidentEditor: React.FC<Props> = ({
|
|
|
88
105
|
setDescription(incident.description ?? "");
|
|
89
106
|
setSeverity(incident.severity);
|
|
90
107
|
setSelectedSystemIds(new Set(incident.systemIds));
|
|
91
|
-
// Load full incident with updates
|
|
92
|
-
loadIncidentDetails(incident.id);
|
|
93
108
|
} else {
|
|
94
109
|
setTitle("");
|
|
95
110
|
setDescription("");
|
|
@@ -98,7 +113,7 @@ export const IncidentEditor: React.FC<Props> = ({
|
|
|
98
113
|
setUpdates([]);
|
|
99
114
|
setShowUpdateForm(false);
|
|
100
115
|
}
|
|
101
|
-
}, [incident, open
|
|
116
|
+
}, [incident, open]);
|
|
102
117
|
|
|
103
118
|
const handleSystemToggle = (systemId: string) => {
|
|
104
119
|
setSelectedSystemIds((prev) => {
|
|
@@ -112,7 +127,7 @@ export const IncidentEditor: React.FC<Props> = ({
|
|
|
112
127
|
});
|
|
113
128
|
};
|
|
114
129
|
|
|
115
|
-
const handleSubmit =
|
|
130
|
+
const handleSubmit = () => {
|
|
116
131
|
if (!title.trim()) {
|
|
117
132
|
toast.error("Title is required");
|
|
118
133
|
return;
|
|
@@ -122,44 +137,35 @@ export const IncidentEditor: React.FC<Props> = ({
|
|
|
122
137
|
return;
|
|
123
138
|
}
|
|
124
139
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
severity,
|
|
141
|
-
systemIds: [...selectedSystemIds],
|
|
142
|
-
});
|
|
143
|
-
toast.success("Incident created");
|
|
144
|
-
}
|
|
145
|
-
onSave();
|
|
146
|
-
} catch (error) {
|
|
147
|
-
const message = error instanceof Error ? error.message : "Failed to save";
|
|
148
|
-
toast.error(message);
|
|
149
|
-
} finally {
|
|
150
|
-
setSaving(false);
|
|
140
|
+
if (incident) {
|
|
141
|
+
updateMutation.mutate({
|
|
142
|
+
id: incident.id,
|
|
143
|
+
title,
|
|
144
|
+
description: description || undefined,
|
|
145
|
+
severity,
|
|
146
|
+
systemIds: [...selectedSystemIds],
|
|
147
|
+
});
|
|
148
|
+
} else {
|
|
149
|
+
createMutation.mutate({
|
|
150
|
+
title,
|
|
151
|
+
description,
|
|
152
|
+
severity,
|
|
153
|
+
systemIds: [...selectedSystemIds],
|
|
154
|
+
});
|
|
151
155
|
}
|
|
152
156
|
};
|
|
153
157
|
|
|
154
158
|
const handleUpdateSuccess = () => {
|
|
155
159
|
if (incident) {
|
|
156
|
-
|
|
160
|
+
void refetchDetail();
|
|
157
161
|
}
|
|
158
162
|
setShowUpdateForm(false);
|
|
159
163
|
// Notify parent to refresh list (status may have changed)
|
|
160
164
|
onSave();
|
|
161
165
|
};
|
|
162
166
|
|
|
167
|
+
const saving = createMutation.isPending || updateMutation.isPending;
|
|
168
|
+
|
|
163
169
|
return (
|
|
164
170
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
165
171
|
<DialogContent size="xl">
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React, { useState } from "react";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { usePluginClient } from "@checkstack/frontend-api";
|
|
3
|
+
import { IncidentApi } from "../api";
|
|
4
4
|
import type { IncidentStatus } from "@checkstack/incident-common";
|
|
5
5
|
import {
|
|
6
6
|
Button,
|
|
@@ -30,37 +30,37 @@ export const IncidentUpdateForm: React.FC<IncidentUpdateFormProps> = ({
|
|
|
30
30
|
onSuccess,
|
|
31
31
|
onCancel,
|
|
32
32
|
}) => {
|
|
33
|
-
const
|
|
33
|
+
const incidentClient = usePluginClient(IncidentApi);
|
|
34
34
|
const toast = useToast();
|
|
35
35
|
|
|
36
36
|
const [message, setMessage] = useState("");
|
|
37
37
|
const [statusChange, setStatusChange] = useState<IncidentStatus | "">("");
|
|
38
|
-
const [isPosting, setIsPosting] = useState(false);
|
|
39
38
|
|
|
40
|
-
const
|
|
41
|
-
|
|
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
|
-
});
|
|
39
|
+
const addUpdateMutation = incidentClient.addUpdate.useMutation({
|
|
40
|
+
onSuccess: () => {
|
|
53
41
|
toast.success("Update posted");
|
|
54
42
|
setMessage("");
|
|
55
43
|
setStatusChange("");
|
|
56
44
|
onSuccess();
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
45
|
+
},
|
|
46
|
+
onError: (error) => {
|
|
47
|
+
toast.error(
|
|
48
|
+
error instanceof Error ? error.message : "Failed to post update"
|
|
49
|
+
);
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const handleSubmit = () => {
|
|
54
|
+
if (!message.trim()) {
|
|
55
|
+
toast.error("Update message is required");
|
|
56
|
+
return;
|
|
63
57
|
}
|
|
58
|
+
|
|
59
|
+
addUpdateMutation.mutate({
|
|
60
|
+
incidentId,
|
|
61
|
+
message,
|
|
62
|
+
statusChange: statusChange || undefined,
|
|
63
|
+
});
|
|
64
64
|
};
|
|
65
65
|
|
|
66
66
|
return (
|
|
@@ -107,9 +107,9 @@ export const IncidentUpdateForm: React.FC<IncidentUpdateFormProps> = ({
|
|
|
107
107
|
<Button
|
|
108
108
|
size="sm"
|
|
109
109
|
onClick={handleSubmit}
|
|
110
|
-
disabled={
|
|
110
|
+
disabled={addUpdateMutation.isPending || !message.trim()}
|
|
111
111
|
>
|
|
112
|
-
{
|
|
112
|
+
{addUpdateMutation.isPending ? (
|
|
113
113
|
<>
|
|
114
114
|
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
|
115
115
|
Posting...
|
|
@@ -1,60 +1,75 @@
|
|
|
1
|
-
import React
|
|
2
|
-
import {
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { usePluginClient, type SlotContext } from "@checkstack/frontend-api";
|
|
3
3
|
import { useSignal } from "@checkstack/signal-frontend";
|
|
4
4
|
import { SystemStateBadgesSlot } from "@checkstack/catalog-common";
|
|
5
|
-
import {
|
|
5
|
+
import { IncidentApi } from "../api";
|
|
6
6
|
import {
|
|
7
7
|
INCIDENT_UPDATED,
|
|
8
8
|
type IncidentWithSystems,
|
|
9
9
|
} from "@checkstack/incident-common";
|
|
10
10
|
import { Badge } from "@checkstack/ui";
|
|
11
|
+
import { useSystemBadgeDataOptional } from "@checkstack/dashboard-frontend";
|
|
11
12
|
|
|
12
13
|
type Props = SlotContext<typeof SystemStateBadgesSlot>;
|
|
13
14
|
|
|
14
15
|
const SEVERITY_WEIGHTS = { critical: 3, major: 2, minor: 1 } as const;
|
|
15
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Finds the most severe incident from a list.
|
|
19
|
+
*/
|
|
20
|
+
function getMostSevereIncident(
|
|
21
|
+
incidents: IncidentWithSystems[]
|
|
22
|
+
): IncidentWithSystems | undefined {
|
|
23
|
+
if (incidents.length === 0) return undefined;
|
|
24
|
+
const sorted = [...incidents].toSorted((a, b) => {
|
|
25
|
+
return (
|
|
26
|
+
(SEVERITY_WEIGHTS[b.severity as keyof typeof SEVERITY_WEIGHTS] || 0) -
|
|
27
|
+
(SEVERITY_WEIGHTS[a.severity as keyof typeof SEVERITY_WEIGHTS] || 0)
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
return sorted[0];
|
|
31
|
+
}
|
|
32
|
+
|
|
16
33
|
/**
|
|
17
34
|
* Displays an incident badge for a system when it has an active incident.
|
|
18
35
|
* Shows nothing if no active incidents.
|
|
36
|
+
*
|
|
37
|
+
* When rendered within SystemBadgeDataProvider, uses bulk-fetched data.
|
|
38
|
+
* Otherwise, falls back to individual fetch.
|
|
39
|
+
*
|
|
19
40
|
* Listens for realtime updates via signals.
|
|
20
41
|
*/
|
|
21
42
|
export const SystemIncidentBadge: React.FC<Props> = ({ system }) => {
|
|
22
|
-
const
|
|
23
|
-
const
|
|
24
|
-
IncidentWithSystems | undefined
|
|
25
|
-
>();
|
|
43
|
+
const incidentClient = usePluginClient(IncidentApi);
|
|
44
|
+
const badgeData = useSystemBadgeDataOptional();
|
|
26
45
|
|
|
27
|
-
|
|
28
|
-
|
|
46
|
+
// Try to get data from provider first
|
|
47
|
+
const providerData = badgeData?.getSystemBadgeData(system?.id ?? "");
|
|
48
|
+
const providerIncident = providerData
|
|
49
|
+
? getMostSevereIncident(providerData.incidents)
|
|
50
|
+
: undefined;
|
|
29
51
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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]);
|
|
52
|
+
// Query for incidents if not using provider
|
|
53
|
+
const { data: incidents, refetch } =
|
|
54
|
+
incidentClient.getIncidentsForSystem.useQuery(
|
|
55
|
+
{ systemId: system?.id ?? "" },
|
|
56
|
+
{ enabled: !badgeData && !!system?.id }
|
|
57
|
+
);
|
|
45
58
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}, [refetch]);
|
|
59
|
+
const localIncident = incidents
|
|
60
|
+
? getMostSevereIncident(incidents)
|
|
61
|
+
: undefined;
|
|
50
62
|
|
|
51
|
-
// Listen for realtime incident updates
|
|
63
|
+
// Listen for realtime incident updates (only in fallback mode)
|
|
52
64
|
useSignal(INCIDENT_UPDATED, ({ systemIds }) => {
|
|
53
|
-
if (system?.id && systemIds.includes(system.id)) {
|
|
54
|
-
refetch();
|
|
65
|
+
if (!badgeData && system?.id && systemIds.includes(system.id)) {
|
|
66
|
+
void refetch();
|
|
55
67
|
}
|
|
56
68
|
});
|
|
57
69
|
|
|
70
|
+
// Use provider data if available, otherwise use local state
|
|
71
|
+
const activeIncident = badgeData ? providerIncident : localIncident;
|
|
72
|
+
|
|
58
73
|
if (!activeIncident) return;
|
|
59
74
|
|
|
60
75
|
const variant =
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import React
|
|
1
|
+
import React from "react";
|
|
2
2
|
import { Link } from "react-router-dom";
|
|
3
|
-
import {
|
|
3
|
+
import { usePluginClient, type SlotContext } from "@checkstack/frontend-api";
|
|
4
4
|
import { useSignal } from "@checkstack/signal-frontend";
|
|
5
5
|
import { resolveRoute } from "@checkstack/common";
|
|
6
6
|
import { SystemDetailsTopSlot } from "@checkstack/catalog-common";
|
|
7
|
-
import {
|
|
7
|
+
import { IncidentApi } from "../api";
|
|
8
8
|
import {
|
|
9
9
|
incidentRoutes,
|
|
10
10
|
INCIDENT_UPDATED,
|
|
@@ -76,29 +76,22 @@ function findMostSevereIncident(
|
|
|
76
76
|
* Listens for realtime updates via signals.
|
|
77
77
|
*/
|
|
78
78
|
export const SystemIncidentPanel: React.FC<Props> = ({ system }) => {
|
|
79
|
-
const
|
|
80
|
-
const [incidents, setIncidents] = useState<IncidentWithSystems[]>([]);
|
|
81
|
-
const [loading, setLoading] = useState(true);
|
|
79
|
+
const incidentClient = usePluginClient(IncidentApi);
|
|
82
80
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
// Initial fetch
|
|
94
|
-
useEffect(() => {
|
|
95
|
-
refetch();
|
|
96
|
-
}, [refetch]);
|
|
81
|
+
// Fetch incidents with useQuery
|
|
82
|
+
const {
|
|
83
|
+
data: incidents = [],
|
|
84
|
+
isLoading: loading,
|
|
85
|
+
refetch,
|
|
86
|
+
} = incidentClient.getIncidentsForSystem.useQuery(
|
|
87
|
+
{ systemId: system?.id ?? "" },
|
|
88
|
+
{ enabled: !!system?.id }
|
|
89
|
+
);
|
|
97
90
|
|
|
98
91
|
// Listen for realtime incident updates
|
|
99
92
|
useSignal(INCIDENT_UPDATED, ({ systemIds }) => {
|
|
100
93
|
if (system?.id && systemIds.includes(system.id)) {
|
|
101
|
-
refetch();
|
|
94
|
+
void refetch();
|
|
102
95
|
}
|
|
103
96
|
});
|
|
104
97
|
|
package/src/index.tsx
CHANGED
|
@@ -1,14 +1,10 @@
|
|
|
1
1
|
import {
|
|
2
2
|
createFrontendPlugin,
|
|
3
3
|
createSlotExtension,
|
|
4
|
-
rpcApiRef,
|
|
5
|
-
type ApiRef,
|
|
6
4
|
UserMenuItemsSlot,
|
|
7
5
|
} from "@checkstack/frontend-api";
|
|
8
|
-
import { incidentApiRef, type IncidentApiClient } from "./api";
|
|
9
6
|
import {
|
|
10
7
|
incidentRoutes,
|
|
11
|
-
IncidentApi,
|
|
12
8
|
pluginMetadata,
|
|
13
9
|
incidentAccess,
|
|
14
10
|
} from "@checkstack/incident-common";
|
|
@@ -43,15 +39,8 @@ export default createFrontendPlugin({
|
|
|
43
39
|
title: "System Incident History",
|
|
44
40
|
},
|
|
45
41
|
],
|
|
46
|
-
|
|
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
|
-
],
|
|
42
|
+
// No APIs needed - components use usePluginClient() directly
|
|
43
|
+
apis: [],
|
|
55
44
|
extensions: [
|
|
56
45
|
createSlotExtension(UserMenuItemsSlot, {
|
|
57
46
|
id: "incident.user-menu.items",
|
|
@@ -1,18 +1,18 @@
|
|
|
1
|
-
import React, { useEffect, useState
|
|
1
|
+
import React, { useEffect, useState } from "react";
|
|
2
2
|
import { useSearchParams } from "react-router-dom";
|
|
3
3
|
import {
|
|
4
|
-
|
|
5
|
-
rpcApiRef,
|
|
4
|
+
usePluginClient,
|
|
6
5
|
accessApiRef,
|
|
6
|
+
useApi,
|
|
7
7
|
wrapInSuspense,
|
|
8
8
|
} from "@checkstack/frontend-api";
|
|
9
|
-
import {
|
|
9
|
+
import { IncidentApi } from "../api";
|
|
10
10
|
import type {
|
|
11
11
|
IncidentWithSystems,
|
|
12
12
|
IncidentStatus,
|
|
13
13
|
} from "@checkstack/incident-common";
|
|
14
14
|
import { incidentAccess } from "@checkstack/incident-common";
|
|
15
|
-
import { CatalogApi
|
|
15
|
+
import { CatalogApi } from "@checkstack/catalog-common";
|
|
16
16
|
import {
|
|
17
17
|
Card,
|
|
18
18
|
CardHeader,
|
|
@@ -49,20 +49,16 @@ import { formatDistanceToNow } from "date-fns";
|
|
|
49
49
|
import { IncidentEditor } from "../components/IncidentEditor";
|
|
50
50
|
|
|
51
51
|
const IncidentConfigPageContent: React.FC = () => {
|
|
52
|
-
const
|
|
53
|
-
const
|
|
52
|
+
const incidentClient = usePluginClient(IncidentApi);
|
|
53
|
+
const catalogClient = usePluginClient(CatalogApi);
|
|
54
54
|
const accessApi = useApi(accessApiRef);
|
|
55
55
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
56
|
-
|
|
57
|
-
const catalogApi = useMemo(() => rpcApi.forPlugin(CatalogApi), [rpcApi]);
|
|
58
56
|
const toast = useToast();
|
|
59
57
|
|
|
60
|
-
const { allowed: canManage, loading: accessLoading } =
|
|
61
|
-
|
|
58
|
+
const { allowed: canManage, loading: accessLoading } = accessApi.useAccess(
|
|
59
|
+
incidentAccess.incident.manage
|
|
60
|
+
);
|
|
62
61
|
|
|
63
|
-
const [incidents, setIncidents] = useState<IncidentWithSystems[]>([]);
|
|
64
|
-
const [systems, setSystems] = useState<System[]>([]);
|
|
65
|
-
const [loading, setLoading] = useState(true);
|
|
66
62
|
const [statusFilter, setStatusFilter] = useState<IncidentStatus | "all">(
|
|
67
63
|
"all"
|
|
68
64
|
);
|
|
@@ -76,37 +72,28 @@ const IncidentConfigPageContent: React.FC = () => {
|
|
|
76
72
|
|
|
77
73
|
// Delete confirmation state
|
|
78
74
|
const [deleteId, setDeleteId] = useState<string | undefined>();
|
|
79
|
-
const [isDeleting, setIsDeleting] = useState(false);
|
|
80
75
|
|
|
81
76
|
// Resolve confirmation state
|
|
82
77
|
const [resolveId, setResolveId] = useState<string | undefined>();
|
|
83
|
-
const [isResolving, setIsResolving] = useState(false);
|
|
84
78
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
catalogApi.getSystems(),
|
|
96
|
-
]);
|
|
97
|
-
setIncidents(incidentList);
|
|
98
|
-
setSystems(systemList);
|
|
99
|
-
} catch (error) {
|
|
100
|
-
const message = error instanceof Error ? error.message : "Failed to load";
|
|
101
|
-
toast.error(message);
|
|
102
|
-
} finally {
|
|
103
|
-
setLoading(false);
|
|
104
|
-
}
|
|
105
|
-
};
|
|
79
|
+
// Fetch incidents with useQuery
|
|
80
|
+
const {
|
|
81
|
+
data: incidentsData,
|
|
82
|
+
isLoading: incidentsLoading,
|
|
83
|
+
refetch: refetchIncidents,
|
|
84
|
+
} = incidentClient.listIncidents.useQuery(
|
|
85
|
+
statusFilter === "all"
|
|
86
|
+
? { includeResolved: showResolved }
|
|
87
|
+
: { status: statusFilter, includeResolved: showResolved }
|
|
88
|
+
);
|
|
106
89
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
90
|
+
// Fetch systems with useQuery
|
|
91
|
+
const { data: systemsData, isLoading: systemsLoading } =
|
|
92
|
+
catalogClient.getSystems.useQuery({});
|
|
93
|
+
|
|
94
|
+
const incidents = incidentsData?.incidents ?? [];
|
|
95
|
+
const systems = systemsData?.systems ?? [];
|
|
96
|
+
const loading = incidentsLoading || systemsLoading;
|
|
110
97
|
|
|
111
98
|
// Handle ?action=create URL parameter (from command palette)
|
|
112
99
|
useEffect(() => {
|
|
@@ -119,6 +106,29 @@ const IncidentConfigPageContent: React.FC = () => {
|
|
|
119
106
|
}
|
|
120
107
|
}, [searchParams, canManage, setSearchParams]);
|
|
121
108
|
|
|
109
|
+
// Mutations
|
|
110
|
+
const deleteMutation = incidentClient.deleteIncident.useMutation({
|
|
111
|
+
onSuccess: () => {
|
|
112
|
+
toast.success("Incident deleted");
|
|
113
|
+
void refetchIncidents();
|
|
114
|
+
setDeleteId(undefined);
|
|
115
|
+
},
|
|
116
|
+
onError: (error) => {
|
|
117
|
+
toast.error(error instanceof Error ? error.message : "Failed to delete");
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const resolveMutation = incidentClient.resolveIncident.useMutation({
|
|
122
|
+
onSuccess: () => {
|
|
123
|
+
toast.success("Incident resolved");
|
|
124
|
+
void refetchIncidents();
|
|
125
|
+
setResolveId(undefined);
|
|
126
|
+
},
|
|
127
|
+
onError: (error) => {
|
|
128
|
+
toast.error(error instanceof Error ? error.message : "Failed to resolve");
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
|
|
122
132
|
const handleCreate = () => {
|
|
123
133
|
setEditingIncident(undefined);
|
|
124
134
|
setEditorOpen(true);
|
|
@@ -129,45 +139,19 @@ const IncidentConfigPageContent: React.FC = () => {
|
|
|
129
139
|
setEditorOpen(true);
|
|
130
140
|
};
|
|
131
141
|
|
|
132
|
-
const handleDelete =
|
|
142
|
+
const handleDelete = () => {
|
|
133
143
|
if (!deleteId) return;
|
|
134
|
-
|
|
135
|
-
setIsDeleting(true);
|
|
136
|
-
try {
|
|
137
|
-
await api.deleteIncident({ id: deleteId });
|
|
138
|
-
toast.success("Incident deleted");
|
|
139
|
-
loadData();
|
|
140
|
-
} catch (error) {
|
|
141
|
-
const message =
|
|
142
|
-
error instanceof Error ? error.message : "Failed to delete";
|
|
143
|
-
toast.error(message);
|
|
144
|
-
} finally {
|
|
145
|
-
setIsDeleting(false);
|
|
146
|
-
setDeleteId(undefined);
|
|
147
|
-
}
|
|
144
|
+
deleteMutation.mutate({ id: deleteId });
|
|
148
145
|
};
|
|
149
146
|
|
|
150
|
-
const handleResolve =
|
|
147
|
+
const handleResolve = () => {
|
|
151
148
|
if (!resolveId) return;
|
|
152
|
-
|
|
153
|
-
setIsResolving(true);
|
|
154
|
-
try {
|
|
155
|
-
await api.resolveIncident({ id: resolveId });
|
|
156
|
-
toast.success("Incident resolved");
|
|
157
|
-
loadData();
|
|
158
|
-
} catch (error) {
|
|
159
|
-
const message =
|
|
160
|
-
error instanceof Error ? error.message : "Failed to resolve";
|
|
161
|
-
toast.error(message);
|
|
162
|
-
} finally {
|
|
163
|
-
setIsResolving(false);
|
|
164
|
-
setResolveId(undefined);
|
|
165
|
-
}
|
|
149
|
+
resolveMutation.mutate({ id: resolveId });
|
|
166
150
|
};
|
|
167
151
|
|
|
168
152
|
const handleSave = () => {
|
|
169
153
|
setEditorOpen(false);
|
|
170
|
-
|
|
154
|
+
void refetchIncidents();
|
|
171
155
|
};
|
|
172
156
|
|
|
173
157
|
const getStatusBadge = (status: IncidentStatus) => {
|
|
@@ -369,7 +353,7 @@ const IncidentConfigPageContent: React.FC = () => {
|
|
|
369
353
|
confirmText="Delete"
|
|
370
354
|
variant="danger"
|
|
371
355
|
onConfirm={handleDelete}
|
|
372
|
-
isLoading={
|
|
356
|
+
isLoading={deleteMutation.isPending}
|
|
373
357
|
/>
|
|
374
358
|
|
|
375
359
|
<ConfirmationModal
|
|
@@ -380,7 +364,7 @@ const IncidentConfigPageContent: React.FC = () => {
|
|
|
380
364
|
confirmText="Resolve"
|
|
381
365
|
variant="info"
|
|
382
366
|
onConfirm={handleResolve}
|
|
383
|
-
isLoading={
|
|
367
|
+
isLoading={resolveMutation.isPending}
|
|
384
368
|
/>
|
|
385
369
|
</PageLayout>
|
|
386
370
|
);
|
|
@@ -1,21 +1,20 @@
|
|
|
1
|
-
import React, {
|
|
1
|
+
import React, { useState } from "react";
|
|
2
2
|
import { useParams, useNavigate, useSearchParams } from "react-router-dom";
|
|
3
3
|
import {
|
|
4
|
-
|
|
5
|
-
rpcApiRef,
|
|
4
|
+
usePluginClient,
|
|
6
5
|
accessApiRef,
|
|
6
|
+
useApi,
|
|
7
7
|
wrapInSuspense,
|
|
8
8
|
} from "@checkstack/frontend-api";
|
|
9
9
|
import { useSignal } from "@checkstack/signal-frontend";
|
|
10
10
|
import { resolveRoute } from "@checkstack/common";
|
|
11
|
-
import {
|
|
11
|
+
import { IncidentApi } from "../api";
|
|
12
12
|
import {
|
|
13
13
|
incidentRoutes,
|
|
14
14
|
INCIDENT_UPDATED,
|
|
15
|
-
type IncidentDetail,
|
|
16
15
|
incidentAccess,
|
|
17
16
|
} from "@checkstack/incident-common";
|
|
18
|
-
import { CatalogApi
|
|
17
|
+
import { CatalogApi } from "@checkstack/catalog-common";
|
|
19
18
|
import {
|
|
20
19
|
Card,
|
|
21
20
|
CardHeader,
|
|
@@ -49,67 +48,60 @@ const IncidentDetailPageContent: React.FC = () => {
|
|
|
49
48
|
const { incidentId } = useParams<{ incidentId: string }>();
|
|
50
49
|
const navigate = useNavigate();
|
|
51
50
|
const [searchParams] = useSearchParams();
|
|
52
|
-
const
|
|
53
|
-
const
|
|
51
|
+
const incidentClient = usePluginClient(IncidentApi);
|
|
52
|
+
const catalogClient = usePluginClient(CatalogApi);
|
|
54
53
|
const accessApi = useApi(accessApiRef);
|
|
55
54
|
const toast = useToast();
|
|
56
55
|
|
|
57
|
-
const catalogApi = useMemo(() => rpcApi.forPlugin(CatalogApi), [rpcApi]);
|
|
58
|
-
|
|
59
56
|
const { allowed: canManage } = accessApi.useAccess(
|
|
60
57
|
incidentAccess.incident.manage
|
|
61
58
|
);
|
|
62
59
|
|
|
63
|
-
const [incident, setIncident] = useState<IncidentDetail | undefined>();
|
|
64
|
-
const [systems, setSystems] = useState<System[]>([]);
|
|
65
|
-
const [loading, setLoading] = useState(true);
|
|
66
60
|
const [showUpdateForm, setShowUpdateForm] = useState(false);
|
|
67
61
|
|
|
68
|
-
|
|
69
|
-
|
|
62
|
+
// Fetch incident with useQuery
|
|
63
|
+
const {
|
|
64
|
+
data: incident,
|
|
65
|
+
isLoading: incidentLoading,
|
|
66
|
+
refetch: refetchIncident,
|
|
67
|
+
} = incidentClient.getIncident.useQuery(
|
|
68
|
+
{ id: incidentId ?? "" },
|
|
69
|
+
{ enabled: !!incidentId }
|
|
70
|
+
);
|
|
70
71
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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]);
|
|
72
|
+
// Fetch systems with useQuery
|
|
73
|
+
const { data: systemsData, isLoading: systemsLoading } =
|
|
74
|
+
catalogClient.getSystems.useQuery({});
|
|
84
75
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
}, [loadData]);
|
|
76
|
+
const systems = systemsData?.systems ?? [];
|
|
77
|
+
const loading = incidentLoading || systemsLoading;
|
|
88
78
|
|
|
89
79
|
// Listen for realtime updates
|
|
90
80
|
useSignal(INCIDENT_UPDATED, ({ incidentId: updatedId }) => {
|
|
91
81
|
if (incidentId === updatedId) {
|
|
92
|
-
|
|
82
|
+
void refetchIncident();
|
|
93
83
|
}
|
|
94
84
|
});
|
|
95
85
|
|
|
86
|
+
// Resolve mutation
|
|
87
|
+
const resolveMutation = incidentClient.resolveIncident.useMutation({
|
|
88
|
+
onSuccess: () => {
|
|
89
|
+
toast.success("Incident resolved");
|
|
90
|
+
void refetchIncident();
|
|
91
|
+
},
|
|
92
|
+
onError: (error) => {
|
|
93
|
+
toast.error(error instanceof Error ? error.message : "Failed to resolve");
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
|
|
96
97
|
const handleUpdateSuccess = () => {
|
|
97
98
|
setShowUpdateForm(false);
|
|
98
|
-
|
|
99
|
+
void refetchIncident();
|
|
99
100
|
};
|
|
100
101
|
|
|
101
|
-
const handleResolve =
|
|
102
|
+
const handleResolve = () => {
|
|
102
103
|
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
|
-
}
|
|
104
|
+
resolveMutation.mutate({ id: incidentId });
|
|
113
105
|
};
|
|
114
106
|
|
|
115
107
|
const getSystemName = (systemId: string): string => {
|
|
@@ -177,7 +169,12 @@ const IncidentDetailPageContent: React.FC = () => {
|
|
|
177
169
|
{getIncidentSeverityBadge(incident.severity)}
|
|
178
170
|
{getIncidentStatusBadge(incident.status)}
|
|
179
171
|
{canResolve && (
|
|
180
|
-
<Button
|
|
172
|
+
<Button
|
|
173
|
+
variant="outline"
|
|
174
|
+
size="sm"
|
|
175
|
+
onClick={handleResolve}
|
|
176
|
+
disabled={resolveMutation.isPending}
|
|
177
|
+
>
|
|
181
178
|
<CheckCircle2 className="h-4 w-4 mr-1" />
|
|
182
179
|
Resolve
|
|
183
180
|
</Button>
|
|
@@ -1,20 +1,15 @@
|
|
|
1
|
-
import React
|
|
1
|
+
import React from "react";
|
|
2
2
|
import { useParams, Link } from "react-router-dom";
|
|
3
|
-
import {
|
|
3
|
+
import { usePluginClient, wrapInSuspense } from "@checkstack/frontend-api";
|
|
4
4
|
import { useSignal } from "@checkstack/signal-frontend";
|
|
5
5
|
import { resolveRoute } from "@checkstack/common";
|
|
6
|
-
import {
|
|
6
|
+
import { IncidentApi } from "../api";
|
|
7
7
|
import {
|
|
8
8
|
incidentRoutes,
|
|
9
9
|
INCIDENT_UPDATED,
|
|
10
|
-
type IncidentWithSystems,
|
|
11
10
|
type IncidentStatus,
|
|
12
11
|
} from "@checkstack/incident-common";
|
|
13
|
-
import {
|
|
14
|
-
CatalogApi,
|
|
15
|
-
type System,
|
|
16
|
-
catalogRoutes,
|
|
17
|
-
} from "@checkstack/catalog-common";
|
|
12
|
+
import { CatalogApi, catalogRoutes } from "@checkstack/catalog-common";
|
|
18
13
|
import {
|
|
19
14
|
Card,
|
|
20
15
|
CardHeader,
|
|
@@ -30,43 +25,32 @@ import { formatDistanceToNow } from "date-fns";
|
|
|
30
25
|
|
|
31
26
|
const SystemIncidentHistoryPageContent: React.FC = () => {
|
|
32
27
|
const { systemId } = useParams<{ systemId: string }>();
|
|
33
|
-
const
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
const catalogApi = useMemo(() => rpcApi.forPlugin(CatalogApi), [rpcApi]);
|
|
37
|
-
|
|
38
|
-
const [incidents, setIncidents] = useState<IncidentWithSystems[]>([]);
|
|
39
|
-
const [system, setSystem] = useState<System | undefined>();
|
|
40
|
-
const [loading, setLoading] = useState(true);
|
|
28
|
+
const incidentClient = usePluginClient(IncidentApi);
|
|
29
|
+
const catalogClient = usePluginClient(CatalogApi);
|
|
41
30
|
|
|
42
|
-
|
|
43
|
-
|
|
31
|
+
// Fetch incidents with useQuery
|
|
32
|
+
const {
|
|
33
|
+
data: incidentsData,
|
|
34
|
+
isLoading: incidentsLoading,
|
|
35
|
+
refetch: refetchIncidents,
|
|
36
|
+
} = incidentClient.listIncidents.useQuery(
|
|
37
|
+
{ systemId, includeResolved: true },
|
|
38
|
+
{ enabled: !!systemId }
|
|
39
|
+
);
|
|
44
40
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
await Promise.all([
|
|
49
|
-
api.listIncidents({ systemId, includeResolved: true }),
|
|
50
|
-
catalogApi.getSystems(),
|
|
51
|
-
]);
|
|
52
|
-
const systemData = systemList.find((s) => s.id === systemId);
|
|
53
|
-
setIncidents(incidentList);
|
|
54
|
-
setSystem(systemData);
|
|
55
|
-
} catch (error) {
|
|
56
|
-
console.error("Failed to load incidents:", error);
|
|
57
|
-
} finally {
|
|
58
|
-
setLoading(false);
|
|
59
|
-
}
|
|
60
|
-
};
|
|
41
|
+
// Fetch systems with useQuery
|
|
42
|
+
const { data: systemsData, isLoading: systemsLoading } =
|
|
43
|
+
catalogClient.getSystems.useQuery({});
|
|
61
44
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
45
|
+
const incidents = incidentsData?.incidents ?? [];
|
|
46
|
+
const systems = systemsData?.systems ?? [];
|
|
47
|
+
const system = systems.find((s) => s.id === systemId);
|
|
48
|
+
const loading = incidentsLoading || systemsLoading;
|
|
65
49
|
|
|
66
50
|
// Listen for realtime updates
|
|
67
51
|
useSignal(INCIDENT_UPDATED, ({ systemIds }) => {
|
|
68
52
|
if (systemId && systemIds.includes(systemId)) {
|
|
69
|
-
|
|
53
|
+
void refetchIncidents();
|
|
70
54
|
}
|
|
71
55
|
});
|
|
72
56
|
|