@checkstack/dashboard-frontend 0.7.7 → 0.8.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 +102 -0
- package/package.json +19 -23
- package/src/Dashboard.tsx +182 -388
- package/src/components/DashboardAllClear.tsx +40 -0
- package/src/components/FleetHealthHeader.tsx +100 -0
- package/src/components/ProblemSystemCard.tsx +164 -0
- package/src/index.tsx +6 -2
- package/src/logic/systemSignals.test.ts +110 -0
- package/src/logic/systemSignals.ts +106 -0
- package/tsconfig.json +0 -12
- package/src/components/AnomalyOverviewSheet.tsx +0 -98
- package/src/components/IncidentOverviewSheet.tsx +0 -107
- package/src/components/MaintenanceOverviewSheet.tsx +0 -128
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import {
|
|
3
|
+
Card,
|
|
4
|
+
CardContent,
|
|
5
|
+
DynamicIcon,
|
|
6
|
+
AnimatedCounter,
|
|
7
|
+
cn,
|
|
8
|
+
} from "@checkstack/ui";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The dashboard's resting state: a calm, reassuring "all clear" hero shown when
|
|
12
|
+
* every monitored system is healthy. A soft success glow is used only when the
|
|
13
|
+
* device can afford it; low-power devices get a solid tint instead.
|
|
14
|
+
*/
|
|
15
|
+
export const DashboardAllClear: React.FC<{
|
|
16
|
+
systemsCount: number;
|
|
17
|
+
isLowPower: boolean;
|
|
18
|
+
}> = ({ systemsCount, isLowPower }) => {
|
|
19
|
+
return (
|
|
20
|
+
<Card
|
|
21
|
+
className={cn(
|
|
22
|
+
"overflow-hidden border-success/30",
|
|
23
|
+
isLowPower
|
|
24
|
+
? "bg-success/5"
|
|
25
|
+
: "bg-gradient-to-br from-success/10 via-transparent to-transparent",
|
|
26
|
+
)}
|
|
27
|
+
>
|
|
28
|
+
<CardContent className="flex flex-col items-center py-14 text-center">
|
|
29
|
+
<div className="mb-4 flex size-14 items-center justify-center rounded-full bg-success/10 text-success">
|
|
30
|
+
<DynamicIcon name="ShieldCheck" className="h-7 w-7" />
|
|
31
|
+
</div>
|
|
32
|
+
<p className="text-lg font-semibold text-foreground">All clear</p>
|
|
33
|
+
<p className="mt-1 max-w-prose text-sm text-muted-foreground">
|
|
34
|
+
All <AnimatedCounter value={systemsCount} /> systems are healthy.
|
|
35
|
+
Nothing needs your attention right now.
|
|
36
|
+
</p>
|
|
37
|
+
</CardContent>
|
|
38
|
+
</Card>
|
|
39
|
+
);
|
|
40
|
+
};
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { SystemSignalTone } from "@checkstack/catalog-common";
|
|
3
|
+
import { DynamicIcon, cn, type IconName } from "@checkstack/ui";
|
|
4
|
+
import type { SeverityCounts } from "../logic/systemSignals";
|
|
5
|
+
|
|
6
|
+
interface ToneChip {
|
|
7
|
+
tone: SystemSignalTone;
|
|
8
|
+
label: string;
|
|
9
|
+
icon: IconName;
|
|
10
|
+
count: number;
|
|
11
|
+
activeClass: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const idleClass =
|
|
15
|
+
"border-border bg-card text-muted-foreground hover:bg-muted/50";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Compact fleet-health strip: a one-line summary plus severity chips that
|
|
19
|
+
* double as filters for the problem list. Counts come straight from the
|
|
20
|
+
* aggregated signals so the strip is agnostic to which plugins produced them.
|
|
21
|
+
*/
|
|
22
|
+
export const FleetHealthHeader: React.FC<{
|
|
23
|
+
systemsCount: number;
|
|
24
|
+
problemsCount: number;
|
|
25
|
+
counts: SeverityCounts;
|
|
26
|
+
healthyCount: number;
|
|
27
|
+
activeTone: SystemSignalTone | null;
|
|
28
|
+
onToneSelect: (tone: SystemSignalTone | null) => void;
|
|
29
|
+
isLowPower: boolean;
|
|
30
|
+
}> = ({
|
|
31
|
+
systemsCount,
|
|
32
|
+
problemsCount,
|
|
33
|
+
counts,
|
|
34
|
+
healthyCount,
|
|
35
|
+
activeTone,
|
|
36
|
+
onToneSelect,
|
|
37
|
+
isLowPower,
|
|
38
|
+
}) => {
|
|
39
|
+
const chips: ToneChip[] = [
|
|
40
|
+
{
|
|
41
|
+
tone: "error",
|
|
42
|
+
label: "Critical",
|
|
43
|
+
icon: "OctagonAlert",
|
|
44
|
+
count: counts.error,
|
|
45
|
+
activeClass: "border-destructive bg-destructive/10 text-destructive",
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
tone: "warn",
|
|
49
|
+
label: "Degraded",
|
|
50
|
+
icon: "TriangleAlert",
|
|
51
|
+
count: counts.warn,
|
|
52
|
+
activeClass: "border-warning bg-warning/10 text-warning",
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
tone: "info",
|
|
56
|
+
label: "Watch",
|
|
57
|
+
icon: "Info",
|
|
58
|
+
count: counts.info,
|
|
59
|
+
activeClass: "border-info bg-info/10 text-info",
|
|
60
|
+
},
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
const summary =
|
|
64
|
+
problemsCount === 0
|
|
65
|
+
? "All systems healthy"
|
|
66
|
+
: `${problemsCount} of ${systemsCount} ${problemsCount === 1 ? "system needs" : "systems need"} attention`;
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
70
|
+
<p className="text-sm font-medium text-foreground">{summary}</p>
|
|
71
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
72
|
+
{chips.map((chip) => {
|
|
73
|
+
const active = activeTone === chip.tone;
|
|
74
|
+
return (
|
|
75
|
+
<button
|
|
76
|
+
key={chip.tone}
|
|
77
|
+
type="button"
|
|
78
|
+
aria-pressed={active}
|
|
79
|
+
onClick={() => onToneSelect(active ? null : chip.tone)}
|
|
80
|
+
className={cn(
|
|
81
|
+
"inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
82
|
+
!isLowPower && "transition-colors",
|
|
83
|
+
active ? chip.activeClass : idleClass,
|
|
84
|
+
)}
|
|
85
|
+
>
|
|
86
|
+
<DynamicIcon name={chip.icon} className="h-3.5 w-3.5" />
|
|
87
|
+
<span className="tabular-nums">{chip.count}</span>
|
|
88
|
+
{chip.label}
|
|
89
|
+
</button>
|
|
90
|
+
);
|
|
91
|
+
})}
|
|
92
|
+
<span className="inline-flex items-center gap-1.5 rounded-full border border-border bg-card px-3 py-1 text-xs font-medium text-muted-foreground">
|
|
93
|
+
<DynamicIcon name="ShieldCheck" className="h-3.5 w-3.5 text-success" />
|
|
94
|
+
<span className="tabular-nums">{healthyCount}</span>
|
|
95
|
+
Healthy
|
|
96
|
+
</span>
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
);
|
|
100
|
+
};
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Link } from "react-router-dom";
|
|
3
|
+
import { formatDistanceToNow } from "date-fns";
|
|
4
|
+
import { resolveRoute } from "@checkstack/common";
|
|
5
|
+
import {
|
|
6
|
+
catalogRoutes,
|
|
7
|
+
type System,
|
|
8
|
+
type SystemSignal,
|
|
9
|
+
type SystemSignalTone,
|
|
10
|
+
} from "@checkstack/catalog-common";
|
|
11
|
+
import { Card, CardContent, DynamicIcon, cn } from "@checkstack/ui";
|
|
12
|
+
import { ChevronRight } from "lucide-react";
|
|
13
|
+
import type { ProblemSystem } from "../logic/systemSignals";
|
|
14
|
+
|
|
15
|
+
const chipBg: Record<SystemSignalTone, string> = {
|
|
16
|
+
error: "bg-destructive/10 text-destructive",
|
|
17
|
+
warn: "bg-warning/10 text-warning",
|
|
18
|
+
info: "bg-info/10 text-info",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const dotBg: Record<SystemSignalTone, string> = {
|
|
22
|
+
error: "bg-destructive",
|
|
23
|
+
warn: "bg-warning",
|
|
24
|
+
info: "bg-info",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const dotRing: Record<SystemSignalTone, string> = {
|
|
28
|
+
error: "ring-destructive/20",
|
|
29
|
+
warn: "ring-warning/20",
|
|
30
|
+
info: "ring-info/20",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const glow: Record<SystemSignalTone, string> = {
|
|
34
|
+
error: "from-destructive/[0.08]",
|
|
35
|
+
warn: "from-warning/[0.08]",
|
|
36
|
+
info: "from-info/[0.07]",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const SignalRow: React.FC<{ signal: SystemSignal; isLowPower: boolean }> = ({
|
|
40
|
+
signal,
|
|
41
|
+
isLowPower,
|
|
42
|
+
}) => {
|
|
43
|
+
const body = (
|
|
44
|
+
<>
|
|
45
|
+
<span
|
|
46
|
+
className={cn(
|
|
47
|
+
"flex size-6 shrink-0 items-center justify-center rounded-md",
|
|
48
|
+
chipBg[signal.tone],
|
|
49
|
+
)}
|
|
50
|
+
>
|
|
51
|
+
<DynamicIcon name={signal.iconName} className="h-3.5 w-3.5" />
|
|
52
|
+
</span>
|
|
53
|
+
<span className="shrink-0 text-sm font-medium text-foreground">
|
|
54
|
+
{signal.label}
|
|
55
|
+
</span>
|
|
56
|
+
{signal.detail && (
|
|
57
|
+
<span className="min-w-0 truncate text-xs text-muted-foreground">
|
|
58
|
+
{signal.detail}
|
|
59
|
+
</span>
|
|
60
|
+
)}
|
|
61
|
+
{signal.href && (
|
|
62
|
+
<ChevronRight
|
|
63
|
+
className={cn(
|
|
64
|
+
"ml-auto h-4 w-4 shrink-0 text-muted-foreground/70 opacity-0 group-hover/row:opacity-100",
|
|
65
|
+
!isLowPower && "transition-opacity",
|
|
66
|
+
)}
|
|
67
|
+
aria-hidden="true"
|
|
68
|
+
/>
|
|
69
|
+
)}
|
|
70
|
+
</>
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
if (!signal.href) {
|
|
74
|
+
return (
|
|
75
|
+
<li className="flex items-center gap-2.5 px-2 py-1.5">{body}</li>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<li>
|
|
81
|
+
<Link
|
|
82
|
+
to={signal.href}
|
|
83
|
+
className={cn(
|
|
84
|
+
"group/row -mx-2 flex items-center gap-2.5 rounded-lg px-2 py-1.5 hover:bg-muted/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
85
|
+
!isLowPower && "transition-colors",
|
|
86
|
+
)}
|
|
87
|
+
>
|
|
88
|
+
{body}
|
|
89
|
+
</Link>
|
|
90
|
+
</li>
|
|
91
|
+
);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* One "needs attention" system in the dashboard overview: an elevated card whose
|
|
96
|
+
* name links to the system, with a tone-coded status dot, a "since" pill, and one
|
|
97
|
+
* row per contributed {@link SystemSignal} deep-linking to the page the issue
|
|
98
|
+
* originates from. Signals are pre-sorted worst-first by the aggregator.
|
|
99
|
+
*/
|
|
100
|
+
export const ProblemSystemCard: React.FC<{
|
|
101
|
+
system: System;
|
|
102
|
+
problem: ProblemSystem;
|
|
103
|
+
isLowPower: boolean;
|
|
104
|
+
}> = ({ system, problem, isLowPower }) => {
|
|
105
|
+
return (
|
|
106
|
+
<Card
|
|
107
|
+
className={cn(
|
|
108
|
+
"group/card relative overflow-hidden border-border/70",
|
|
109
|
+
!isLowPower &&
|
|
110
|
+
"transition-all duration-200 hover:border-border hover:shadow-lg hover:shadow-black/5",
|
|
111
|
+
)}
|
|
112
|
+
>
|
|
113
|
+
{!isLowPower && (
|
|
114
|
+
<div
|
|
115
|
+
className={cn(
|
|
116
|
+
"pointer-events-none absolute inset-x-0 top-0 h-20 bg-gradient-to-b to-transparent",
|
|
117
|
+
glow[problem.worstTone],
|
|
118
|
+
)}
|
|
119
|
+
aria-hidden="true"
|
|
120
|
+
/>
|
|
121
|
+
)}
|
|
122
|
+
<CardContent className="relative p-4">
|
|
123
|
+
<div className="flex items-center gap-2.5">
|
|
124
|
+
<span
|
|
125
|
+
className={cn(
|
|
126
|
+
"size-2.5 shrink-0 rounded-full",
|
|
127
|
+
dotBg[problem.worstTone],
|
|
128
|
+
!isLowPower && cn("ring-4", dotRing[problem.worstTone]),
|
|
129
|
+
)}
|
|
130
|
+
aria-hidden="true"
|
|
131
|
+
/>
|
|
132
|
+
<Link
|
|
133
|
+
to={resolveRoute(catalogRoutes.routes.systemDetail, {
|
|
134
|
+
systemId: system.id,
|
|
135
|
+
})}
|
|
136
|
+
className={cn(
|
|
137
|
+
"min-w-0 flex-1 truncate text-[15px] font-semibold text-foreground",
|
|
138
|
+
!isLowPower && "transition-colors",
|
|
139
|
+
"hover:text-primary",
|
|
140
|
+
)}
|
|
141
|
+
>
|
|
142
|
+
{system.name}
|
|
143
|
+
</Link>
|
|
144
|
+
{problem.oldestSince && (
|
|
145
|
+
<span className="shrink-0 rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
|
|
146
|
+
{formatDistanceToNow(new Date(problem.oldestSince), {
|
|
147
|
+
addSuffix: true,
|
|
148
|
+
})}
|
|
149
|
+
</span>
|
|
150
|
+
)}
|
|
151
|
+
</div>
|
|
152
|
+
<ul className="mt-3 space-y-0.5">
|
|
153
|
+
{problem.signals.map((signal, index) => (
|
|
154
|
+
<SignalRow
|
|
155
|
+
key={`${signal.source}-${index}`}
|
|
156
|
+
signal={signal}
|
|
157
|
+
isLowPower={isLowPower}
|
|
158
|
+
/>
|
|
159
|
+
))}
|
|
160
|
+
</ul>
|
|
161
|
+
</CardContent>
|
|
162
|
+
</Card>
|
|
163
|
+
);
|
|
164
|
+
};
|
package/src/index.tsx
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { FrontendPlugin, DashboardSlot } from "@checkstack/frontend-api";
|
|
2
|
-
import { Dashboard } from "./Dashboard";
|
|
3
2
|
import { pluginMetadata } from "./pluginMetadata";
|
|
4
3
|
|
|
5
4
|
export const dashboardPlugin: FrontendPlugin = {
|
|
@@ -8,7 +7,12 @@ export const dashboardPlugin: FrontendPlugin = {
|
|
|
8
7
|
{
|
|
9
8
|
id: "dashboard-main",
|
|
10
9
|
slot: DashboardSlot,
|
|
11
|
-
|
|
10
|
+
// Heavy dashboard (widgets/charts) — lazy so it stays out of the initial
|
|
11
|
+
// bundle; it renders only on the "/" dashboard route.
|
|
12
|
+
load: () =>
|
|
13
|
+
import("./Dashboard").then((m) => ({
|
|
14
|
+
default: m.Dashboard as React.ComponentType<unknown>,
|
|
15
|
+
})),
|
|
12
16
|
},
|
|
13
17
|
],
|
|
14
18
|
};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import type { SystemSignal } from "@checkstack/catalog-common";
|
|
3
|
+
import {
|
|
4
|
+
buildProblemSystems,
|
|
5
|
+
countByTone,
|
|
6
|
+
mergeSignalsBySystem,
|
|
7
|
+
sortSignals,
|
|
8
|
+
type SignalsBySource,
|
|
9
|
+
} from "./systemSignals";
|
|
10
|
+
|
|
11
|
+
const signal = (
|
|
12
|
+
source: string,
|
|
13
|
+
tone: SystemSignal["tone"],
|
|
14
|
+
label: string,
|
|
15
|
+
since?: string,
|
|
16
|
+
): SystemSignal => ({ source, tone, label, since });
|
|
17
|
+
|
|
18
|
+
describe("mergeSignalsBySystem", () => {
|
|
19
|
+
it("merges signals from multiple sources per system", () => {
|
|
20
|
+
const bySource: SignalsBySource = {
|
|
21
|
+
incident: { sysA: [signal("incident", "error", "Critical incident")] },
|
|
22
|
+
slo: {
|
|
23
|
+
sysA: [signal("slo", "warn", "SLO at risk")],
|
|
24
|
+
sysB: [signal("slo", "error", "SLO breaching")],
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
const merged = mergeSignalsBySystem(bySource);
|
|
28
|
+
expect(merged.sysA).toHaveLength(2);
|
|
29
|
+
expect(merged.sysB).toHaveLength(1);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("drops empty signal arrays so a cleared source contributes nothing", () => {
|
|
33
|
+
const bySource: SignalsBySource = {
|
|
34
|
+
incident: { sysA: [] },
|
|
35
|
+
slo: { sysA: [signal("slo", "warn", "SLO at risk")] },
|
|
36
|
+
};
|
|
37
|
+
const merged = mergeSignalsBySystem(bySource);
|
|
38
|
+
expect(merged.sysA).toHaveLength(1);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("sortSignals", () => {
|
|
43
|
+
it("orders error before warn before info", () => {
|
|
44
|
+
const sorted = sortSignals([
|
|
45
|
+
signal("a", "info", "i"),
|
|
46
|
+
signal("b", "error", "e"),
|
|
47
|
+
signal("c", "warn", "w"),
|
|
48
|
+
]);
|
|
49
|
+
expect(sorted.map((s) => s.tone)).toEqual(["error", "warn", "info"]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("orders oldest first within the same tone", () => {
|
|
53
|
+
const sorted = sortSignals([
|
|
54
|
+
signal("a", "warn", "newer", "2026-06-04T12:00:00.000Z"),
|
|
55
|
+
signal("b", "warn", "older", "2026-06-04T10:00:00.000Z"),
|
|
56
|
+
]);
|
|
57
|
+
expect(sorted.map((s) => s.label)).toEqual(["older", "newer"]);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("buildProblemSystems", () => {
|
|
62
|
+
it("excludes systems with no signals and derives worst tone", () => {
|
|
63
|
+
const problems = buildProblemSystems({
|
|
64
|
+
sysA: [signal("slo", "warn", "SLO at risk"), signal("incident", "error", "Critical")],
|
|
65
|
+
sysB: [],
|
|
66
|
+
});
|
|
67
|
+
expect(problems).toHaveLength(1);
|
|
68
|
+
expect(problems[0].systemId).toBe("sysA");
|
|
69
|
+
expect(problems[0].worstTone).toBe("error");
|
|
70
|
+
expect(problems[0].signalCount).toBe(2);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("sorts by worst tone, then signal count, then oldest", () => {
|
|
74
|
+
const problems = buildProblemSystems({
|
|
75
|
+
warnOne: [signal("slo", "warn", "w")],
|
|
76
|
+
errorOne: [signal("incident", "error", "e")],
|
|
77
|
+
errorTwo: [
|
|
78
|
+
signal("incident", "error", "e1"),
|
|
79
|
+
signal("slo", "error", "e2"),
|
|
80
|
+
],
|
|
81
|
+
});
|
|
82
|
+
// errorTwo (2 signals) before errorOne (1 signal), both before warnOne.
|
|
83
|
+
expect(problems.map((p) => p.systemId)).toEqual([
|
|
84
|
+
"errorTwo",
|
|
85
|
+
"errorOne",
|
|
86
|
+
"warnOne",
|
|
87
|
+
]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("computes the oldest since across a system's signals", () => {
|
|
91
|
+
const problems = buildProblemSystems({
|
|
92
|
+
sysA: [
|
|
93
|
+
signal("incident", "error", "newer", "2026-06-04T12:00:00.000Z"),
|
|
94
|
+
signal("maintenance", "info", "older", "2026-06-04T08:00:00.000Z"),
|
|
95
|
+
],
|
|
96
|
+
});
|
|
97
|
+
expect(problems[0].oldestSince).toBe("2026-06-04T08:00:00.000Z");
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("countByTone", () => {
|
|
102
|
+
it("counts each system once by its worst tone", () => {
|
|
103
|
+
const problems = buildProblemSystems({
|
|
104
|
+
a: [signal("incident", "error", "e"), signal("slo", "warn", "w")],
|
|
105
|
+
b: [signal("slo", "warn", "w")],
|
|
106
|
+
c: [signal("maintenance", "info", "i")],
|
|
107
|
+
});
|
|
108
|
+
expect(countByTone(problems)).toEqual({ error: 1, warn: 1, info: 1 });
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { SystemSignal, SystemSignalTone } from "@checkstack/catalog-common";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Severity sort weight. Lower sorts first, so a system's worst signal (and the
|
|
5
|
+
* problem cards themselves) read error -> warn -> info, matching the icon-only
|
|
6
|
+
* `StatusBadge` ordering used everywhere else.
|
|
7
|
+
*/
|
|
8
|
+
export const TONE_WEIGHT: Record<SystemSignalTone, number> = {
|
|
9
|
+
error: 0,
|
|
10
|
+
warn: 1,
|
|
11
|
+
info: 2,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** A system that has at least one signal, with derived sort/grouping metadata. */
|
|
15
|
+
export interface ProblemSystem {
|
|
16
|
+
systemId: string;
|
|
17
|
+
/** All signals for the system, sorted worst-first. */
|
|
18
|
+
signals: SystemSignal[];
|
|
19
|
+
/** The most severe tone present (drives the card accent + the header counts). */
|
|
20
|
+
worstTone: SystemSignalTone;
|
|
21
|
+
/** Number of distinct signals (tie-break: more problems sort higher). */
|
|
22
|
+
signalCount: number;
|
|
23
|
+
/** Earliest `since` across the system's signals (tie-break: longest-suffering first). */
|
|
24
|
+
oldestSince?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Per-source signal maps, keyed by the source id reported through `onSignals`. */
|
|
28
|
+
export type SignalsBySource = Record<
|
|
29
|
+
string,
|
|
30
|
+
Record<string, SystemSignal[]>
|
|
31
|
+
>;
|
|
32
|
+
|
|
33
|
+
/** Merge every source's per-system map into one per-system-id signal list. */
|
|
34
|
+
export function mergeSignalsBySystem(
|
|
35
|
+
bySource: SignalsBySource,
|
|
36
|
+
): Record<string, SystemSignal[]> {
|
|
37
|
+
const merged: Record<string, SystemSignal[]> = {};
|
|
38
|
+
for (const map of Object.values(bySource)) {
|
|
39
|
+
for (const [systemId, signals] of Object.entries(map)) {
|
|
40
|
+
if (!signals || signals.length === 0) continue;
|
|
41
|
+
(merged[systemId] ??= []).push(...signals);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return merged;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function sinceMs(since: string | undefined): number {
|
|
48
|
+
if (!since) return Number.POSITIVE_INFINITY;
|
|
49
|
+
const ms = Date.parse(since);
|
|
50
|
+
return Number.isNaN(ms) ? Number.POSITIVE_INFINITY : ms;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Sort a single system's signals worst-tone-first, then oldest-first. */
|
|
54
|
+
export function sortSignals(signals: SystemSignal[]): SystemSignal[] {
|
|
55
|
+
return signals.toSorted((a, b) => {
|
|
56
|
+
const tone = TONE_WEIGHT[a.tone] - TONE_WEIGHT[b.tone];
|
|
57
|
+
if (tone !== 0) return tone;
|
|
58
|
+
return sinceMs(a.since) - sinceMs(b.since);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Turn the merged per-system signal map into a sorted list of problem systems.
|
|
64
|
+
* Sort order: worst tone first, then more signals first, then oldest first.
|
|
65
|
+
* Systems with no signals never appear (healthy systems are hidden).
|
|
66
|
+
*/
|
|
67
|
+
export function buildProblemSystems(
|
|
68
|
+
merged: Record<string, SystemSignal[]>,
|
|
69
|
+
): ProblemSystem[] {
|
|
70
|
+
const problems: ProblemSystem[] = [];
|
|
71
|
+
|
|
72
|
+
for (const [systemId, rawSignals] of Object.entries(merged)) {
|
|
73
|
+
if (rawSignals.length === 0) continue;
|
|
74
|
+
const signals = sortSignals(rawSignals);
|
|
75
|
+
const oldest = Math.min(...signals.map((s) => sinceMs(s.since)));
|
|
76
|
+
problems.push({
|
|
77
|
+
systemId,
|
|
78
|
+
signals,
|
|
79
|
+
worstTone: signals[0].tone,
|
|
80
|
+
signalCount: signals.length,
|
|
81
|
+
oldestSince: Number.isFinite(oldest)
|
|
82
|
+
? new Date(oldest).toISOString()
|
|
83
|
+
: undefined,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return problems.toSorted((a, b) => {
|
|
88
|
+
const tone = TONE_WEIGHT[a.worstTone] - TONE_WEIGHT[b.worstTone];
|
|
89
|
+
if (tone !== 0) return tone;
|
|
90
|
+
if (b.signalCount !== a.signalCount) return b.signalCount - a.signalCount;
|
|
91
|
+
return sinceMs(a.oldestSince) - sinceMs(b.oldestSince);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface SeverityCounts {
|
|
96
|
+
error: number;
|
|
97
|
+
warn: number;
|
|
98
|
+
info: number;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Count problem systems by their worst tone (each system counted once). */
|
|
102
|
+
export function countByTone(problems: ProblemSystem[]): SeverityCounts {
|
|
103
|
+
const counts: SeverityCounts = { error: 0, warn: 0, info: 0 };
|
|
104
|
+
for (const problem of problems) counts[problem.worstTone] += 1;
|
|
105
|
+
return counts;
|
|
106
|
+
}
|
package/tsconfig.json
CHANGED
|
@@ -4,12 +4,6 @@
|
|
|
4
4
|
"src"
|
|
5
5
|
],
|
|
6
6
|
"references": [
|
|
7
|
-
{
|
|
8
|
-
"path": "../anomaly-common"
|
|
9
|
-
},
|
|
10
|
-
{
|
|
11
|
-
"path": "../auth-frontend"
|
|
12
|
-
},
|
|
13
7
|
{
|
|
14
8
|
"path": "../catalog-common"
|
|
15
9
|
},
|
|
@@ -37,12 +31,6 @@
|
|
|
37
31
|
{
|
|
38
32
|
"path": "../maintenance-common"
|
|
39
33
|
},
|
|
40
|
-
{
|
|
41
|
-
"path": "../notification-common"
|
|
42
|
-
},
|
|
43
|
-
{
|
|
44
|
-
"path": "../notification-frontend"
|
|
45
|
-
},
|
|
46
34
|
{
|
|
47
35
|
"path": "../queue-frontend"
|
|
48
36
|
},
|
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
import React from "react";
|
|
2
|
-
import {
|
|
3
|
-
Sheet,
|
|
4
|
-
SheetContent,
|
|
5
|
-
SheetHeader,
|
|
6
|
-
SheetTitle,
|
|
7
|
-
SheetBody,
|
|
8
|
-
Badge,
|
|
9
|
-
} from "@checkstack/ui";
|
|
10
|
-
import { Link } from "react-router-dom";
|
|
11
|
-
import {
|
|
12
|
-
catalogRoutes,
|
|
13
|
-
type System,
|
|
14
|
-
} from "@checkstack/catalog-common";
|
|
15
|
-
import { resolveRoute } from "@checkstack/common";
|
|
16
|
-
import { type AnomalyDto } from "@checkstack/anomaly-common";
|
|
17
|
-
import { formatDistanceToNow } from "date-fns";
|
|
18
|
-
|
|
19
|
-
interface Props {
|
|
20
|
-
open: boolean;
|
|
21
|
-
onOpenChange: (open: boolean) => void;
|
|
22
|
-
anomalies: AnomalyDto[];
|
|
23
|
-
systems: System[];
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export const AnomalyOverviewSheet: React.FC<Props> = ({
|
|
27
|
-
open,
|
|
28
|
-
onOpenChange,
|
|
29
|
-
anomalies,
|
|
30
|
-
systems,
|
|
31
|
-
}) => {
|
|
32
|
-
// Map of systemId -> systemName
|
|
33
|
-
const systemMap = new Map(systems.map((s) => [s.id, s.name]));
|
|
34
|
-
|
|
35
|
-
return (
|
|
36
|
-
<Sheet open={open} onOpenChange={onOpenChange}>
|
|
37
|
-
<SheetContent>
|
|
38
|
-
<SheetHeader className="flex flex-row items-start justify-between gap-4 pt-6">
|
|
39
|
-
<div className="flex flex-col gap-1 text-left">
|
|
40
|
-
<SheetTitle>Active Anomalies</SheetTitle>
|
|
41
|
-
<p className="text-sm text-muted-foreground">
|
|
42
|
-
Overview of unusual system behavior
|
|
43
|
-
</p>
|
|
44
|
-
</div>
|
|
45
|
-
</SheetHeader>
|
|
46
|
-
|
|
47
|
-
<SheetBody className="flex flex-col gap-3 pb-8">
|
|
48
|
-
{anomalies.length === 0 ? (
|
|
49
|
-
<p className="text-sm text-muted-foreground text-center py-8">
|
|
50
|
-
No active anomalies
|
|
51
|
-
</p>
|
|
52
|
-
) : (
|
|
53
|
-
anomalies.map((anomaly) => {
|
|
54
|
-
const systemName = systemMap.get(anomaly.systemId) || anomaly.systemId;
|
|
55
|
-
const deviationText = anomaly.deviation
|
|
56
|
-
? `${anomaly.deviation.toFixed(1)}σ`
|
|
57
|
-
: "unusual";
|
|
58
|
-
|
|
59
|
-
return (
|
|
60
|
-
<Link
|
|
61
|
-
key={anomaly.id}
|
|
62
|
-
to={resolveRoute(catalogRoutes.routes.systemDetail, { systemId: anomaly.systemId })}
|
|
63
|
-
onClick={() => onOpenChange(false)}
|
|
64
|
-
className="flex flex-col gap-2 rounded-lg border border-border bg-card p-4 hover:border-primary/50 hover:shadow-sm transition-all text-left"
|
|
65
|
-
>
|
|
66
|
-
<div className="flex items-start justify-between gap-4">
|
|
67
|
-
<h4 className="font-medium text-foreground">
|
|
68
|
-
{systemName}
|
|
69
|
-
</h4>
|
|
70
|
-
<Badge variant="warning" className="flex-shrink-0 font-mono">
|
|
71
|
-
{deviationText}
|
|
72
|
-
</Badge>
|
|
73
|
-
</div>
|
|
74
|
-
<div className="flex flex-col gap-1 mt-2">
|
|
75
|
-
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">
|
|
76
|
-
Metric
|
|
77
|
-
</span>
|
|
78
|
-
<span className="text-sm font-mono text-foreground break-all">
|
|
79
|
-
{anomaly.fieldPath}
|
|
80
|
-
</span>
|
|
81
|
-
</div>
|
|
82
|
-
<div className="flex items-center justify-between mt-2 pt-2 border-t border-border/50 text-xs text-muted-foreground">
|
|
83
|
-
<span className="font-mono">
|
|
84
|
-
Observed: {anomaly.observedValue} <span className="opacity-70">(~{anomaly.baselineValue})</span>
|
|
85
|
-
</span>
|
|
86
|
-
<span>
|
|
87
|
-
{formatDistanceToNow(new Date(anomaly.startedAt), { addSuffix: true })}
|
|
88
|
-
</span>
|
|
89
|
-
</div>
|
|
90
|
-
</Link>
|
|
91
|
-
);
|
|
92
|
-
})
|
|
93
|
-
)}
|
|
94
|
-
</SheetBody>
|
|
95
|
-
</SheetContent>
|
|
96
|
-
</Sheet>
|
|
97
|
-
);
|
|
98
|
-
};
|