@checkstack/dashboard-frontend 0.7.8 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
- component: Dashboard as React.ComponentType<unknown>,
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
- };