@checkstack/slo-frontend 0.2.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 +37 -0
- package/package.json +35 -0
- package/src/api.ts +13 -0
- package/src/components/AchievementBadge.tsx +105 -0
- package/src/components/AttributionChart.tsx +112 -0
- package/src/components/BurnRateIndicator.tsx +50 -0
- package/src/components/DependencyExclusionConfig.tsx +144 -0
- package/src/components/DowntimeTimeline.tsx +104 -0
- package/src/components/ErrorBudgetBar.tsx +48 -0
- package/src/components/MilestoneFeed.tsx +107 -0
- package/src/components/SloEditor.tsx +332 -0
- package/src/components/SloMenuItems.tsx +30 -0
- package/src/components/SloTrendChart.tsx +183 -0
- package/src/components/StreakCounter.tsx +45 -0
- package/src/components/SystemSloBadge.tsx +51 -0
- package/src/components/SystemSloPanel.tsx +90 -0
- package/src/index.tsx +57 -0
- package/src/pages/SloConfigPage.tsx +242 -0
- package/src/pages/SloDetailPage.tsx +283 -0
- package/src/pages/SloOverviewPage.tsx +140 -0
- package/tsconfig.json +6 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# @checkstack/slo-frontend
|
|
2
|
+
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 3c34b07: Complete SLO Reliability Engine frontend and backend
|
|
8
|
+
|
|
9
|
+
**Frontend** — 7 new visualization components:
|
|
10
|
+
|
|
11
|
+
- `StreakCounter`: Fire-themed compliance streak counter with color-coded flame and best-streak trophy
|
|
12
|
+
- `AchievementBadge`: Emoji-labeled badges for 9 achievement types with hover tooltip
|
|
13
|
+
- `AttributionChart`: Horizontal stacked bar showing error budget split (self/upstream/remaining)
|
|
14
|
+
- `DowntimeTimeline`: Dot-and-line timeline with attribution badges and timestamps
|
|
15
|
+
- `SloTrendChart`: Pure SVG availability trend line chart from daily snapshots
|
|
16
|
+
- `MilestoneFeed`: Organization-wide milestone feed on the SLO overview sidebar
|
|
17
|
+
- `DependencyExclusionConfig`: Interactive upstream dependency picker for SLO editor
|
|
18
|
+
|
|
19
|
+
**Backend** — Weekly digest scheduled integration event:
|
|
20
|
+
|
|
21
|
+
- `weekly-digest.ts`: Cron job (Monday 09:00 UTC) emitting SLO performance summary
|
|
22
|
+
- Top/worst performers, breach counts, and streak data delivered via configured notification channels
|
|
23
|
+
- New `sloWeeklyDigest` hook registered as integration event
|
|
24
|
+
|
|
25
|
+
### Patch Changes
|
|
26
|
+
|
|
27
|
+
- Updated dependencies [d1a2796]
|
|
28
|
+
- Updated dependencies [3c34b07]
|
|
29
|
+
- @checkstack/common@0.6.5
|
|
30
|
+
- @checkstack/ui@1.2.1
|
|
31
|
+
- @checkstack/dashboard-frontend@0.3.26
|
|
32
|
+
- @checkstack/frontend-api@0.3.9
|
|
33
|
+
- @checkstack/slo-common@0.2.0
|
|
34
|
+
- @checkstack/catalog-common@1.3.1
|
|
35
|
+
- @checkstack/healthcheck-common@0.10.1
|
|
36
|
+
- @checkstack/dependency-common@0.2.1
|
|
37
|
+
- @checkstack/signal-frontend@0.0.15
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@checkstack/slo-frontend",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "src/index.tsx",
|
|
6
|
+
"checkstack": {
|
|
7
|
+
"type": "frontend"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"typecheck": "tsc --noEmit",
|
|
11
|
+
"lint": "bun run lint:code",
|
|
12
|
+
"lint:code": "eslint . --max-warnings 0"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@checkstack/catalog-common": "1.3.0",
|
|
16
|
+
"@checkstack/common": "0.6.4",
|
|
17
|
+
"@checkstack/dashboard-frontend": "0.3.25",
|
|
18
|
+
"@checkstack/frontend-api": "0.3.8",
|
|
19
|
+
"@checkstack/signal-frontend": "0.0.14",
|
|
20
|
+
"@checkstack/dependency-common": "0.2.0",
|
|
21
|
+
"@checkstack/healthcheck-common": "0.10.0",
|
|
22
|
+
"@checkstack/slo-common": "0.1.0",
|
|
23
|
+
"@checkstack/ui": "1.2.0",
|
|
24
|
+
"date-fns": "^4.1.0",
|
|
25
|
+
"lucide-react": "^0.344.0",
|
|
26
|
+
"react": "^18.2.0",
|
|
27
|
+
"react-router-dom": "^6.20.0"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"typescript": "^5.0.0",
|
|
31
|
+
"@types/react": "^18.2.0",
|
|
32
|
+
"@checkstack/tsconfig": "0.0.5",
|
|
33
|
+
"@checkstack/scripts": "0.1.2"
|
|
34
|
+
}
|
|
35
|
+
}
|
package/src/api.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Re-export types for convenience
|
|
2
|
+
export type {
|
|
3
|
+
SloObjective,
|
|
4
|
+
SloStatus,
|
|
5
|
+
SloDowntimeEvent,
|
|
6
|
+
SloStreak,
|
|
7
|
+
SloAchievement,
|
|
8
|
+
SloDailySnapshot,
|
|
9
|
+
DependencyExclusionMode,
|
|
10
|
+
AttributionType,
|
|
11
|
+
} from "@checkstack/slo-common";
|
|
12
|
+
// Client definition - use with usePluginClient
|
|
13
|
+
export { SloApi } from "@checkstack/slo-common";
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Award } from "lucide-react";
|
|
3
|
+
import { Badge } from "@checkstack/ui";
|
|
4
|
+
|
|
5
|
+
interface AchievementBadgeProps {
|
|
6
|
+
achievement: string;
|
|
7
|
+
unlockedAt: Date;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const ACHIEVEMENT_META: Record<
|
|
11
|
+
string,
|
|
12
|
+
{ label: string; emoji: string; description: string }
|
|
13
|
+
> = {
|
|
14
|
+
first_steps: {
|
|
15
|
+
label: "First Steps",
|
|
16
|
+
emoji: "🎯",
|
|
17
|
+
description: "First SLO defined",
|
|
18
|
+
},
|
|
19
|
+
iron_uptime: {
|
|
20
|
+
label: "Iron Uptime",
|
|
21
|
+
emoji: "🛡️",
|
|
22
|
+
description: "30-day compliance streak",
|
|
23
|
+
},
|
|
24
|
+
diamond_uptime: {
|
|
25
|
+
label: "Diamond Uptime",
|
|
26
|
+
emoji: "💎",
|
|
27
|
+
description: "90-day compliance streak",
|
|
28
|
+
},
|
|
29
|
+
budget_miser: {
|
|
30
|
+
label: "Budget Miser",
|
|
31
|
+
emoji: "💰",
|
|
32
|
+
description: ">90% budget remaining at window end",
|
|
33
|
+
},
|
|
34
|
+
clean_sheet: {
|
|
35
|
+
label: "Clean Sheet",
|
|
36
|
+
emoji: "✨",
|
|
37
|
+
description: "Zero breaches in a quarter",
|
|
38
|
+
},
|
|
39
|
+
nines_club: {
|
|
40
|
+
label: "Nines Club",
|
|
41
|
+
emoji: "🏆",
|
|
42
|
+
description: "99.99% over 365 days",
|
|
43
|
+
},
|
|
44
|
+
cascade_breaker: {
|
|
45
|
+
label: "Cascade Breaker",
|
|
46
|
+
emoji: "🛑",
|
|
47
|
+
description: "Upstream resolved before SLO impact",
|
|
48
|
+
},
|
|
49
|
+
full_coverage: {
|
|
50
|
+
label: "Full Coverage",
|
|
51
|
+
emoji: "🔒",
|
|
52
|
+
description: "All systems in group have SLOs",
|
|
53
|
+
},
|
|
54
|
+
rapid_recovery: {
|
|
55
|
+
label: "Rapid Recovery",
|
|
56
|
+
emoji: "⚡",
|
|
57
|
+
description: "SLO compliance restored within 5 min",
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Renders an achievement badge with emoji, label, and tooltip description.
|
|
63
|
+
*/
|
|
64
|
+
export const AchievementBadge: React.FC<AchievementBadgeProps> = ({
|
|
65
|
+
achievement,
|
|
66
|
+
unlockedAt,
|
|
67
|
+
}) => {
|
|
68
|
+
const meta = ACHIEVEMENT_META[achievement];
|
|
69
|
+
const label = meta?.label ?? achievement;
|
|
70
|
+
const emoji = meta?.emoji ?? "🏅";
|
|
71
|
+
const description = meta?.description ?? "";
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<div className="group relative inline-flex" title={description}>
|
|
75
|
+
<Badge variant="outline" className="gap-1.5 cursor-default">
|
|
76
|
+
<span>{emoji}</span>
|
|
77
|
+
<span>{label}</span>
|
|
78
|
+
</Badge>
|
|
79
|
+
{/* Tooltip on hover */}
|
|
80
|
+
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 hidden group-hover:block z-10">
|
|
81
|
+
<div className="bg-popover border border-border rounded-md shadow-md px-3 py-2 text-xs whitespace-nowrap">
|
|
82
|
+
<div className="font-medium">{label}</div>
|
|
83
|
+
<div className="text-muted-foreground">{description}</div>
|
|
84
|
+
<div className="text-muted-foreground mt-1">
|
|
85
|
+
{new Date(unlockedAt).toLocaleDateString("en-US", {
|
|
86
|
+
month: "short",
|
|
87
|
+
day: "numeric",
|
|
88
|
+
year: "numeric",
|
|
89
|
+
})}
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
);
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Renders a placeholder for systems with no achievements yet.
|
|
99
|
+
*/
|
|
100
|
+
export const NoAchievements: React.FC = () => (
|
|
101
|
+
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
102
|
+
<Award className="w-4 h-4" />
|
|
103
|
+
<span>No achievements unlocked yet</span>
|
|
104
|
+
</div>
|
|
105
|
+
);
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
interface AttributionChartProps {
|
|
4
|
+
attribution: Array<{
|
|
5
|
+
sourceType: "self" | "upstream";
|
|
6
|
+
systemId?: string;
|
|
7
|
+
systemName?: string;
|
|
8
|
+
minutes: number;
|
|
9
|
+
}>;
|
|
10
|
+
totalBudgetMinutes: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Horizontal stacked bar showing error budget consumption split by attribution.
|
|
15
|
+
* Self-caused downtime in red, upstream-attributed in amber, remaining in green.
|
|
16
|
+
*/
|
|
17
|
+
export const AttributionChart: React.FC<AttributionChartProps> = ({
|
|
18
|
+
attribution,
|
|
19
|
+
totalBudgetMinutes,
|
|
20
|
+
}) => {
|
|
21
|
+
if (totalBudgetMinutes <= 0) return;
|
|
22
|
+
|
|
23
|
+
const selfMinutes = attribution
|
|
24
|
+
.filter((a) => a.sourceType === "self")
|
|
25
|
+
.reduce((sum, a) => sum + a.minutes, 0);
|
|
26
|
+
|
|
27
|
+
const upstreamMinutes = attribution
|
|
28
|
+
.filter((a) => a.sourceType === "upstream")
|
|
29
|
+
.reduce((sum, a) => sum + a.minutes, 0);
|
|
30
|
+
|
|
31
|
+
const selfPercent = Math.min(
|
|
32
|
+
(selfMinutes / totalBudgetMinutes) * 100,
|
|
33
|
+
100,
|
|
34
|
+
);
|
|
35
|
+
const upstreamPercent = Math.min(
|
|
36
|
+
(upstreamMinutes / totalBudgetMinutes) * 100,
|
|
37
|
+
100 - selfPercent,
|
|
38
|
+
);
|
|
39
|
+
const remainingPercent = Math.max(
|
|
40
|
+
100 - selfPercent - upstreamPercent,
|
|
41
|
+
0,
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<div className="space-y-2">
|
|
46
|
+
{/* Stacked bar */}
|
|
47
|
+
<div className="h-6 rounded-full overflow-hidden flex bg-muted/30 border border-border">
|
|
48
|
+
{selfPercent > 0 && (
|
|
49
|
+
<div
|
|
50
|
+
className="bg-destructive/80 transition-all duration-500"
|
|
51
|
+
style={{ width: `${selfPercent}%` }}
|
|
52
|
+
title={`Self: ${selfMinutes.toFixed(1)} min`}
|
|
53
|
+
/>
|
|
54
|
+
)}
|
|
55
|
+
{upstreamPercent > 0 && (
|
|
56
|
+
<div
|
|
57
|
+
className="bg-amber-500/80 transition-all duration-500"
|
|
58
|
+
style={{ width: `${upstreamPercent}%` }}
|
|
59
|
+
title={`Upstream: ${upstreamMinutes.toFixed(1)} min`}
|
|
60
|
+
/>
|
|
61
|
+
)}
|
|
62
|
+
{remainingPercent > 0 && (
|
|
63
|
+
<div
|
|
64
|
+
className="bg-emerald-500/30 transition-all duration-500"
|
|
65
|
+
style={{ width: `${remainingPercent}%` }}
|
|
66
|
+
title={`Remaining: ${(totalBudgetMinutes - selfMinutes - upstreamMinutes).toFixed(1)} min`}
|
|
67
|
+
/>
|
|
68
|
+
)}
|
|
69
|
+
</div>
|
|
70
|
+
|
|
71
|
+
{/* Legend */}
|
|
72
|
+
<div className="flex flex-wrap gap-4 text-xs">
|
|
73
|
+
<div className="flex items-center gap-1.5">
|
|
74
|
+
<div className="w-2.5 h-2.5 rounded-full bg-destructive/80" />
|
|
75
|
+
<span className="text-muted-foreground">
|
|
76
|
+
Self: {selfMinutes.toFixed(1)} min
|
|
77
|
+
</span>
|
|
78
|
+
</div>
|
|
79
|
+
{upstreamMinutes > 0 && (
|
|
80
|
+
<div className="flex items-center gap-1.5">
|
|
81
|
+
<div className="w-2.5 h-2.5 rounded-full bg-amber-500/80" />
|
|
82
|
+
<span className="text-muted-foreground">
|
|
83
|
+
Upstream: {upstreamMinutes.toFixed(1)} min
|
|
84
|
+
</span>
|
|
85
|
+
</div>
|
|
86
|
+
)}
|
|
87
|
+
<div className="flex items-center gap-1.5">
|
|
88
|
+
<div className="w-2.5 h-2.5 rounded-full bg-emerald-500/30" />
|
|
89
|
+
<span className="text-muted-foreground">
|
|
90
|
+
Remaining:{" "}
|
|
91
|
+
{(totalBudgetMinutes - selfMinutes - upstreamMinutes).toFixed(1)}{" "}
|
|
92
|
+
min
|
|
93
|
+
</span>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
{/* Per-upstream breakdown */}
|
|
98
|
+
{attribution.some((a) => a.sourceType === "upstream") && (
|
|
99
|
+
<div className="text-xs text-muted-foreground space-y-0.5 pt-1">
|
|
100
|
+
{attribution
|
|
101
|
+
.filter((a) => a.sourceType === "upstream")
|
|
102
|
+
.map((a) => (
|
|
103
|
+
<div key={a.systemId ?? "unknown"} className="flex justify-between">
|
|
104
|
+
<span>↳ {a.systemName ?? a.systemId ?? "Unknown"}</span>
|
|
105
|
+
<span>{a.minutes.toFixed(1)} min</span>
|
|
106
|
+
</div>
|
|
107
|
+
))}
|
|
108
|
+
</div>
|
|
109
|
+
)}
|
|
110
|
+
</div>
|
|
111
|
+
);
|
|
112
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { TrendingUp, TrendingDown, Minus } from "lucide-react";
|
|
3
|
+
|
|
4
|
+
interface BurnRateIndicatorProps {
|
|
5
|
+
burnRate: number | null;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Visual burn rate indicator.
|
|
10
|
+
* Shows how fast the error budget is being consumed relative to the window.
|
|
11
|
+
* - < 1.0: consuming slower than expected (good)
|
|
12
|
+
* - 1.0: on pace
|
|
13
|
+
* - > 1.0: consuming faster than expected (bad)
|
|
14
|
+
*/
|
|
15
|
+
export const BurnRateIndicator: React.FC<BurnRateIndicatorProps> = ({
|
|
16
|
+
burnRate,
|
|
17
|
+
}) => {
|
|
18
|
+
if (burnRate === null || burnRate === undefined) {
|
|
19
|
+
return (
|
|
20
|
+
<span className="inline-flex items-center gap-1 text-sm text-muted-foreground">
|
|
21
|
+
<Minus className="w-3.5 h-3.5" />
|
|
22
|
+
<span>N/A</span>
|
|
23
|
+
</span>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const isGood = burnRate < 1;
|
|
28
|
+
const isBad = burnRate > 1.5;
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<span
|
|
32
|
+
className={`inline-flex items-center gap-1 text-sm font-medium ${
|
|
33
|
+
isGood
|
|
34
|
+
? "text-success"
|
|
35
|
+
: isBad
|
|
36
|
+
? "text-destructive"
|
|
37
|
+
: "text-muted-foreground"
|
|
38
|
+
}`}
|
|
39
|
+
>
|
|
40
|
+
{isGood ? (
|
|
41
|
+
<TrendingDown className="w-3.5 h-3.5" />
|
|
42
|
+
) : isBad ? (
|
|
43
|
+
<TrendingUp className="w-3.5 h-3.5" />
|
|
44
|
+
) : (
|
|
45
|
+
<Minus className="w-3.5 h-3.5" />
|
|
46
|
+
)}
|
|
47
|
+
<span>{burnRate.toFixed(2)}x</span>
|
|
48
|
+
</span>
|
|
49
|
+
);
|
|
50
|
+
};
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { usePluginClient } from "@checkstack/frontend-api";
|
|
3
|
+
import { DependencyApi } from "@checkstack/dependency-common";
|
|
4
|
+
import { CatalogApi } from "@checkstack/catalog-common";
|
|
5
|
+
import type { DependencyExclusionMode } from "@checkstack/slo-common";
|
|
6
|
+
import {
|
|
7
|
+
Label,
|
|
8
|
+
Select,
|
|
9
|
+
SelectTrigger,
|
|
10
|
+
SelectValue,
|
|
11
|
+
SelectContent,
|
|
12
|
+
SelectItem,
|
|
13
|
+
} from "@checkstack/ui";
|
|
14
|
+
import { ShieldCheck, ShieldAlert, ShieldOff } from "lucide-react";
|
|
15
|
+
|
|
16
|
+
interface DependencyExclusionConfigProps {
|
|
17
|
+
systemId: string;
|
|
18
|
+
mode: DependencyExclusionMode;
|
|
19
|
+
onModeChange: (mode: DependencyExclusionMode) => void;
|
|
20
|
+
excludedIds: string[];
|
|
21
|
+
onExcludedIdsChange: (ids: string[]) => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Configuration component for SLO dependency exclusion.
|
|
26
|
+
* Shows mode selector and auto-discovered upstream dependency checklist.
|
|
27
|
+
*/
|
|
28
|
+
export const DependencyExclusionConfig: React.FC<
|
|
29
|
+
DependencyExclusionConfigProps
|
|
30
|
+
> = ({ systemId, mode, onModeChange, excludedIds, onExcludedIdsChange }) => {
|
|
31
|
+
const depClient = usePluginClient(DependencyApi);
|
|
32
|
+
const catalogClient = usePluginClient(CatalogApi);
|
|
33
|
+
|
|
34
|
+
// Fetch upstream dependencies for this system
|
|
35
|
+
const { data: depData } = depClient.getDependencies.useQuery(
|
|
36
|
+
{ systemId, direction: "upstream" },
|
|
37
|
+
{ enabled: !!systemId },
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
// Fetch system names for display
|
|
41
|
+
const { data: systemsData } = catalogClient.getSystems.useQuery({});
|
|
42
|
+
|
|
43
|
+
const upstreamDeps = depData?.dependencies ?? [];
|
|
44
|
+
const systemNameMap = new Map(
|
|
45
|
+
systemsData?.systems.map((s) => [s.id, s.name]),
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const handleToggle = (depSystemId: string) => {
|
|
49
|
+
if (excludedIds.includes(depSystemId)) {
|
|
50
|
+
onExcludedIdsChange(excludedIds.filter((id) => id !== depSystemId));
|
|
51
|
+
} else {
|
|
52
|
+
onExcludedIdsChange([...excludedIds, depSystemId]);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<div className="space-y-4">
|
|
58
|
+
{/* Mode selector */}
|
|
59
|
+
<div className="grid gap-2">
|
|
60
|
+
<Label>Dependency Exclusion Mode</Label>
|
|
61
|
+
<Select
|
|
62
|
+
value={mode}
|
|
63
|
+
onValueChange={(v) => onModeChange(v as DependencyExclusionMode)}
|
|
64
|
+
>
|
|
65
|
+
<SelectTrigger>
|
|
66
|
+
<SelectValue />
|
|
67
|
+
</SelectTrigger>
|
|
68
|
+
<SelectContent>
|
|
69
|
+
<SelectItem value="strict">
|
|
70
|
+
<div className="flex items-center gap-2">
|
|
71
|
+
<ShieldAlert className="w-4 h-4 text-destructive" />
|
|
72
|
+
Strict — All downtime counts
|
|
73
|
+
</div>
|
|
74
|
+
</SelectItem>
|
|
75
|
+
<SelectItem value="self-only">
|
|
76
|
+
<div className="flex items-center gap-2">
|
|
77
|
+
<ShieldCheck className="w-4 h-4 text-emerald-500" />
|
|
78
|
+
Self-Only — Exclude upstream-attributed downtime
|
|
79
|
+
</div>
|
|
80
|
+
</SelectItem>
|
|
81
|
+
</SelectContent>
|
|
82
|
+
</Select>
|
|
83
|
+
<p className="text-xs text-muted-foreground">
|
|
84
|
+
{mode === "strict"
|
|
85
|
+
? "All downtime counts against the error budget, regardless of root cause."
|
|
86
|
+
: "Only self-caused downtime counts. Upstream failures are attributed but excluded from budget consumption."}
|
|
87
|
+
</p>
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
{/* Upstream dependency exclusion list */}
|
|
91
|
+
{mode === "self-only" && upstreamDeps.length > 0 && (
|
|
92
|
+
<div className="grid gap-2">
|
|
93
|
+
<Label className="flex items-center gap-2">
|
|
94
|
+
<ShieldOff className="w-4 h-4" />
|
|
95
|
+
Excluded Dependencies
|
|
96
|
+
</Label>
|
|
97
|
+
<p className="text-xs text-muted-foreground">
|
|
98
|
+
Check dependencies to exclude from upstream attribution. Downtime
|
|
99
|
+
from excluded upstreams will count as self-caused.
|
|
100
|
+
</p>
|
|
101
|
+
<div className="space-y-2 rounded-md border border-border p-3">
|
|
102
|
+
{upstreamDeps.map((dep) => {
|
|
103
|
+
const upstreamId =
|
|
104
|
+
dep.sourceSystemId === systemId
|
|
105
|
+
? dep.targetSystemId
|
|
106
|
+
: dep.sourceSystemId;
|
|
107
|
+
const isExcluded = excludedIds.includes(upstreamId);
|
|
108
|
+
const name =
|
|
109
|
+
systemNameMap.get(upstreamId) ?? upstreamId;
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<label
|
|
113
|
+
key={dep.id}
|
|
114
|
+
className="flex items-center gap-2 cursor-pointer text-sm hover:bg-muted/50 rounded px-2 py-1 transition-colors"
|
|
115
|
+
>
|
|
116
|
+
<input
|
|
117
|
+
type="checkbox"
|
|
118
|
+
checked={isExcluded}
|
|
119
|
+
onChange={() => handleToggle(upstreamId)}
|
|
120
|
+
className="rounded border-border"
|
|
121
|
+
/>
|
|
122
|
+
<span className={isExcluded ? "line-through text-muted-foreground" : ""}>
|
|
123
|
+
{name}
|
|
124
|
+
</span>
|
|
125
|
+
{isExcluded && (
|
|
126
|
+
<span className="text-xs text-muted-foreground">
|
|
127
|
+
(treated as self)
|
|
128
|
+
</span>
|
|
129
|
+
)}
|
|
130
|
+
</label>
|
|
131
|
+
);
|
|
132
|
+
})}
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
)}
|
|
136
|
+
|
|
137
|
+
{mode === "self-only" && upstreamDeps.length === 0 && (
|
|
138
|
+
<p className="text-xs text-muted-foreground">
|
|
139
|
+
No upstream dependencies configured for this system.
|
|
140
|
+
</p>
|
|
141
|
+
)}
|
|
142
|
+
</div>
|
|
143
|
+
);
|
|
144
|
+
};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Badge } from "@checkstack/ui";
|
|
3
|
+
import { Clock } from "lucide-react";
|
|
4
|
+
import { formatDistanceToNow, format } from "date-fns";
|
|
5
|
+
|
|
6
|
+
interface DowntimeEvent {
|
|
7
|
+
id: string;
|
|
8
|
+
startTime: Date;
|
|
9
|
+
endTime: Date | null;
|
|
10
|
+
durationSeconds: number | null;
|
|
11
|
+
attributionType: "self" | "upstream";
|
|
12
|
+
upstreamSystemId: string | null;
|
|
13
|
+
upstreamSystemName: string | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface DowntimeTimelineProps {
|
|
17
|
+
events: DowntimeEvent[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Visual timeline of downtime events with attribution coloring.
|
|
22
|
+
* Shows a chronological list with duration, attribution badge, and timestamps.
|
|
23
|
+
*/
|
|
24
|
+
export const DowntimeTimeline: React.FC<DowntimeTimelineProps> = ({
|
|
25
|
+
events,
|
|
26
|
+
}) => {
|
|
27
|
+
if (events.length === 0) {
|
|
28
|
+
return (
|
|
29
|
+
<div className="text-sm text-muted-foreground text-center py-4">
|
|
30
|
+
No downtime events in the current window
|
|
31
|
+
</div>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<div className="relative">
|
|
37
|
+
{/* Timeline line */}
|
|
38
|
+
<div className="absolute left-[11px] top-2 bottom-2 w-px bg-border" />
|
|
39
|
+
|
|
40
|
+
<div className="space-y-3">
|
|
41
|
+
{events.map((event) => {
|
|
42
|
+
const isOngoing = event.endTime === null;
|
|
43
|
+
const isSelf = event.attributionType === "self";
|
|
44
|
+
const durationMinutes = event.durationSeconds
|
|
45
|
+
? Math.round(event.durationSeconds / 60)
|
|
46
|
+
: undefined;
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<div key={event.id} className="flex gap-3 relative">
|
|
50
|
+
{/* Timeline dot */}
|
|
51
|
+
<div
|
|
52
|
+
className={`mt-1.5 w-[9px] h-[9px] rounded-full border-2 z-10 shrink-0 ${
|
|
53
|
+
isOngoing
|
|
54
|
+
? "bg-destructive border-destructive animate-pulse"
|
|
55
|
+
: isSelf
|
|
56
|
+
? "bg-destructive/60 border-destructive/60"
|
|
57
|
+
: "bg-amber-500/60 border-amber-500/60"
|
|
58
|
+
}`}
|
|
59
|
+
/>
|
|
60
|
+
|
|
61
|
+
{/* Event details */}
|
|
62
|
+
<div className="flex-1 min-w-0">
|
|
63
|
+
<div className="flex items-center gap-2 flex-wrap">
|
|
64
|
+
<Badge
|
|
65
|
+
variant={isSelf ? "destructive" : "warning"}
|
|
66
|
+
>
|
|
67
|
+
{isSelf
|
|
68
|
+
? "Self"
|
|
69
|
+
: `Upstream: ${event.upstreamSystemName ?? event.upstreamSystemId}`}
|
|
70
|
+
</Badge>
|
|
71
|
+
{isOngoing && (
|
|
72
|
+
<Badge variant="destructive">Ongoing</Badge>
|
|
73
|
+
)}
|
|
74
|
+
{durationMinutes !== undefined && (
|
|
75
|
+
<span className="text-xs text-muted-foreground tabular-nums">
|
|
76
|
+
{durationMinutes < 60
|
|
77
|
+
? `${durationMinutes} min`
|
|
78
|
+
: `${Math.floor(durationMinutes / 60)}h ${durationMinutes % 60}m`}
|
|
79
|
+
</span>
|
|
80
|
+
)}
|
|
81
|
+
</div>
|
|
82
|
+
<div className="flex items-center gap-1.5 mt-1 text-xs text-muted-foreground">
|
|
83
|
+
<Clock className="w-3 h-3" />
|
|
84
|
+
<span>
|
|
85
|
+
{format(new Date(event.startTime), "MMM d, HH:mm")}
|
|
86
|
+
{event.endTime
|
|
87
|
+
? ` → ${format(new Date(event.endTime), "HH:mm")}`
|
|
88
|
+
: ""}
|
|
89
|
+
</span>
|
|
90
|
+
<span className="text-muted-foreground/50">·</span>
|
|
91
|
+
<span>
|
|
92
|
+
{formatDistanceToNow(new Date(event.startTime), {
|
|
93
|
+
addSuffix: true,
|
|
94
|
+
})}
|
|
95
|
+
</span>
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
);
|
|
100
|
+
})}
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
);
|
|
104
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
interface ErrorBudgetBarProps {
|
|
4
|
+
consumedPercent: number;
|
|
5
|
+
warningThreshold: number;
|
|
6
|
+
criticalThreshold: number;
|
|
7
|
+
label?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Visual error budget consumption bar.
|
|
12
|
+
* Shows green → amber → red progression as budget is consumed.
|
|
13
|
+
*/
|
|
14
|
+
export const ErrorBudgetBar: React.FC<ErrorBudgetBarProps> = ({
|
|
15
|
+
consumedPercent,
|
|
16
|
+
warningThreshold,
|
|
17
|
+
criticalThreshold,
|
|
18
|
+
label,
|
|
19
|
+
}) => {
|
|
20
|
+
const remainingPercent = Math.max(0, 100 - consumedPercent);
|
|
21
|
+
const getBarColor = () => {
|
|
22
|
+
if (consumedPercent >= criticalThreshold) return "var(--destructive)";
|
|
23
|
+
if (consumedPercent >= warningThreshold) return "var(--warning)";
|
|
24
|
+
return "var(--success)";
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<div className="space-y-1.5">
|
|
29
|
+
{label && (
|
|
30
|
+
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
|
31
|
+
<span>{label}</span>
|
|
32
|
+
<span className="font-medium tabular-nums">
|
|
33
|
+
{remainingPercent.toFixed(1)}% remaining
|
|
34
|
+
</span>
|
|
35
|
+
</div>
|
|
36
|
+
)}
|
|
37
|
+
<div className="h-2 w-full rounded-full bg-muted overflow-hidden">
|
|
38
|
+
<div
|
|
39
|
+
className="h-full rounded-full transition-all duration-300"
|
|
40
|
+
style={{
|
|
41
|
+
width: `${Math.min(consumedPercent, 100)}%`,
|
|
42
|
+
backgroundColor: getBarColor(),
|
|
43
|
+
}}
|
|
44
|
+
/>
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
);
|
|
48
|
+
};
|