all the changes
Build and Publish / Metadata (push) Successful in 2s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m23s

This commit is contained in:
mats committed 2026-09-19 23:25:59 +02:00
1 parent 94f30165f9
commit f30adf05af
35 files changed
+2979 -1607

No files matched your search

+4 -4
View File
@@ -71,10 +71,10 @@ function App() {
typeof window !== "undefined" &&
(window.location.pathname === "/auth_callback" ||
window.location.search.includes("code="));
// The Issues tab is the redesigned triage overview: it owns its own filter
// bar and cards, so the legacy anomaly banner and volume waveform above it
// would just duplicate that UI in a different visual language.
const isTriageOverview = activeTab === "tab0" || activeTab === "tab3" || activeTab === "tab7";
// The redesigned screens (issues, logs, RUM sessions, rollouts, metrics) own
// their own filter bar and cards, so the legacy anomaly banner and volume
// waveform above them would just duplicate that UI in a different visual language.
const isTriageOverview = activeTab === "tab0" || activeTab === "tab3" || activeTab === "tab7" || activeTab === "tab9" || activeTab === "tab2";
if (isCallback) {
return <AuthCallbackView />;
+1 -1
View File
@@ -81,7 +81,7 @@ export const AnomalyRadarBar: React.FC = () => {
<button
type="button"
onClick={() => setActiveTab("tab0")}
className="inline-flex h-7 items-center gap-1 rounded-[8px] bg-ds-critical px-2.5 text-[12px] font-semibold text-white hover:opacity-90"
className="inline-flex h-7 items-center gap-1 rounded-[8px] bg-ds-critical px-2.5 text-[12px] font-semibold text-ds-on-accent hover:opacity-90"
>
Triage issues <ArrowRight className="h-3 w-3" aria-hidden="true" />
</button>
-372
View File
@@ -1,372 +0,0 @@
import { useMemo } from "react";
import { ArrowDownRight, ArrowUpRight, Minus } from "lucide-react";
import { getServicePalette } from "../lib/otel-utils";
export interface GaugeMeterProps {
name: string;
service?: string;
value: number;
min?: number;
max?: number;
avg?: number;
unit?: string;
size?: "sm" | "md" | "lg";
thresholds?: {
warn: number;
danger: number;
};
subtitle?: string;
history?: number[];
showTrend?: boolean;
isSum?: boolean;
aggLabel?: string;
soloPod?: string;
podCount?: number;
podSummaries?: Array<{ pod: string; value: number }>;
}
const formatNumber = (val: number, maxDecimals = 2) => {
if (Math.abs(val) >= 1_000_000) {
return `${(val / 1_000_000).toFixed(1)}M`;
}
if (Math.abs(val) >= 1_000) {
return `${(val / 1_000).toFixed(1)}k`;
}
if (Number.isInteger(val)) {
return val.toString();
}
return val.toFixed(maxDecimals);
};
// SVG arc calculations
const polarToCartesian = (centerX: number, centerY: number, radius: number, angleInDegrees: number) => {
const angleInRadians = ((angleInDegrees - 90) * Math.PI) / 180.0;
return {
x: centerX + radius * Math.cos(angleInRadians),
y: centerY + radius * Math.sin(angleInRadians),
};
};
const describeArc = (x: number, y: number, radius: number, startAngle: number, endAngle: number) => {
const start = polarToCartesian(x, y, radius, endAngle);
const end = polarToCartesian(x, y, radius, startAngle);
const largeArcFlag = endAngle - startAngle <= 180 ? "0" : "1";
return ["M", start.x, start.y, "A", radius, radius, 0, largeArcFlag, 0, end.x, end.y].join(" ");
};
export const GaugeMeter = ({
name,
service,
value,
min: customMin,
max: customMax,
avg,
unit = "",
size = "md",
thresholds,
subtitle,
history = [],
showTrend = true,
isSum = false,
aggLabel,
soloPod,
podCount,
podSummaries,
}: GaugeMeterProps) => {
const servicePalette = service ? getServicePalette(service) : null;
// Determine min/max range
const { min, max } = useMemo(() => {
let computedMin = customMin !== undefined ? customMin : 0;
let computedMax = customMax !== undefined ? customMax : 100;
if (history.length > 0 && customMin === undefined && customMax === undefined) {
const histMin = Math.min(...history);
const histMax = Math.max(...history);
if (histMax <= 1 && histMin >= 0) {
// Percentage ratio 0..1
computedMin = 0;
computedMax = 1;
} else if (histMax <= 100 && histMin >= 0) {
// 0..100%
computedMin = 0;
computedMax = 100;
} else {
computedMin = Math.floor(Math.min(0, histMin));
computedMax = Math.ceil(histMax * 1.15) || 100;
}
}
if (computedMax <= computedMin) {
computedMax = computedMin + 1;
}
return { min: computedMin, max: computedMax };
}, [customMin, customMax, history]);
// Normalized percent 0..100
const percent = useMemo(() => {
const clamped = Math.max(min, Math.min(value, max));
return ((clamped - min) / (max - min)) * 100;
}, [value, min, max]);
// Color determination based on thresholds
const warnThreshold = thresholds?.warn ?? 70;
const dangerThreshold = thresholds?.danger ?? 90;
const statusColor = useMemo(() => {
if (percent >= dangerThreshold) {
return {
text: "text-ds-critical",
stroke: "var(--ds-critical)",
gradientStart: "var(--ds-critical)",
gradientEnd: "var(--ds-critical)",
bg: "bg-ds-critical-bg",
border: "border-ds-critical/30",
glow: "color-mix(in srgb, var(--ds-critical) 35%, transparent)",
label: "Critical",
};
}
if (percent >= warnThreshold) {
return {
text: "text-ds-warn",
stroke: "var(--ds-warn)",
gradientStart: "var(--ds-warn)",
gradientEnd: "var(--ds-warn)",
bg: "bg-ds-warn-bg",
border: "border-ds-warn/30",
glow: "color-mix(in srgb, var(--ds-warn) 35%, transparent)",
label: "Warning",
};
}
return {
text: "text-ds-accent",
stroke: "var(--ds-accent)",
gradientStart: "var(--ds-accent)",
gradientEnd: "var(--ds-accent)",
bg: "bg-ds-accent-bg",
border: "border-ds-accent/30",
glow: "color-mix(in srgb, var(--ds-accent) 35%, transparent)",
label: "Nominal",
};
}, [percent, warnThreshold, dangerThreshold]);
// Trend calculation
const trend = useMemo(() => {
if (!showTrend || history.length < 2) return null;
const prev = history[history.length - 2];
const diff = value - prev;
if (Math.abs(diff) < 0.0001) return { direction: "flat", text: "steady", color: "text-ds-text-3" };
if (diff > 0) return { direction: "up", text: `+${formatNumber(diff)}`, color: "text-ds-critical" };
return { direction: "down", text: `${formatNumber(diff)}`, color: "text-ds-ok" };
}, [value, history, showTrend]);
// Dimensions
const dim = size === "sm" ? 120 : size === "lg" ? 220 : 160;
const strokeWidth = size === "sm" ? 8 : size === "lg" ? 14 : 10;
const radius = (dim - strokeWidth * 2) / 2;
const centerX = dim / 2;
const centerY = dim / 2 + (size === "sm" ? 4 : 8);
// 240 degree arc from -120 to +120 degrees
const startAngle = -120;
const endAngle = 120;
const totalAngle = endAngle - startAngle;
const valueAngle = startAngle + (percent / 100) * totalAngle;
const bgArc = describeArc(centerX, centerY, radius, startAngle, endAngle);
const valueArc = describeArc(centerX, centerY, radius, startAngle, Math.min(valueAngle, endAngle));
// Ticks at 0, 50, 100
const tickAngles = [startAngle, startAngle + totalAngle * 0.5, endAngle];
const gaugeId = useMemo(
() => `${service ? service.replace(/[^a-zA-Z0-9]/g, "_") + "_" : ""}${name.replace(/[^a-zA-Z0-9]/g, "_")}`,
[service, name]
);
return (
<div className="flex flex-col items-center justify-between rounded-[14px] border border-ds-line bg-ds-surface-2 p-4 transition-all hover:border-ds-line-strong">
<div className="flex w-full items-start justify-between gap-2 border-b border-ds-line pb-2">
<div className="flex flex-col min-w-0 pr-1">
<div className="flex items-center gap-1 flex-wrap mb-1">
{service && (
<span
className={`inline-flex items-center gap-1 self-start rounded px-1.5 py-0.5 text-[9px] font-mono font-bold uppercase tracking-wider border ${servicePalette?.bg} ${servicePalette?.text} ${servicePalette?.border}`}
title={`Owning Service: ${service}`}
>
<span className="w-1.5 h-1.5 rounded-full bg-current opacity-80" />
<span className="truncate max-w-[120px]">{service}</span>
</span>
)}
{podCount !== undefined && podCount > 1 && (
<span
className="inline-flex items-center gap-1 rounded bg-ds-accent-bg px-1.5 py-0.5 text-[9px] font-mono font-bold text-ds-accent border border-ds-accent/30"
title={`Sum of values across ${podCount} pods`}
>
<span className="text-[11px] font-bold leading-none text-ds-accent">∑</span>
<span>{podCount} pods</span>
</span>
)}
</div>
<span className="truncate text-xs font-bold uppercase tracking-wider text-ds-text-2" title={name}>
{name}
</span>
</div>
<span className={`shrink-0 rounded-full px-2 py-0.5 text-[9px] font-black uppercase tracking-wider ${statusColor.bg} ${statusColor.text} ${statusColor.border} border`}>
{statusColor.label}
</span>
</div>
<div className="relative my-2 flex items-center justify-center">
<svg width={dim} height={dim * 0.85} viewBox={`0 0 ${dim} ${dim * 0.85}`} className="overflow-visible">
<defs>
<linearGradient id={`gauge-grad-${gaugeId}`} x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor={statusColor.gradientStart} />
<stop offset="100%" stopColor={statusColor.gradientEnd} />
</linearGradient>
<filter id={`gauge-glow-${gaugeId}`} x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="0" stdDeviation="3" floodColor={statusColor.glow} />
</filter>
</defs>
{/* Background Track */}
<path
d={bgArc}
fill="none"
stroke="var(--ds-line-strong)"
strokeWidth={strokeWidth}
strokeLinecap="round"
/>
{/* Ticks */}
{tickAngles.map((angle, idx) => {
const inner = polarToCartesian(centerX, centerY, radius - strokeWidth * 0.8, angle);
const outer = polarToCartesian(centerX, centerY, radius + strokeWidth * 0.8, angle);
return (
<line
key={idx}
x1={inner.x}
y1={inner.y}
x2={outer.x}
y2={outer.y}
stroke="var(--ds-line-strong)"
strokeWidth={1.5}
/>
);
})}
{/* Active Value Arc */}
{percent > 0 && (
<path
d={valueArc}
fill="none"
stroke={`url(#gauge-grad-${gaugeId})`}
strokeWidth={strokeWidth}
strokeLinecap="round"
filter={`url(#gauge-glow-${gaugeId})`}
/>
)}
{/* Needle / Value dot indicator */}
{percent > 0 && (() => {
const tip = polarToCartesian(centerX, centerY, radius, valueAngle);
return (
<circle
cx={tip.x}
cy={tip.y}
r={strokeWidth * 0.6}
fill="var(--ds-text)"
stroke={statusColor.stroke}
strokeWidth={2}
className="transition-all duration-300"
/>
);
})()}
</svg>
{/* Center Readout */}
<div className="absolute inset-0 flex flex-col items-center justify-center pt-3 text-center pointer-events-none">
{soloPod ? (
<span className="text-[8px] font-mono uppercase font-bold text-ds-warn tracking-wider truncate max-w-[100px]" title={`Solo: ${soloPod}`}>
{soloPod}
</span>
) : aggLabel ? (
<span className="text-[8.5px] font-mono uppercase font-bold text-ds-accent tracking-wider">
{aggLabel}
</span>
) : podCount !== undefined && podCount > 1 ? (
<span className="text-[8.5px] font-mono uppercase font-bold text-ds-accent tracking-wider">
{isSum ? "Sum (∑)" : "Avg (μ)"}
</span>
) : null}
<div className="flex items-baseline gap-1">
<span className={`font-mono font-black tracking-tight ${size === "sm" ? "text-lg" : size === "lg" ? "text-3xl" : "text-2xl"} ${statusColor.text}`}>
{formatNumber(value)}
</span>
{unit && (
<span className="text-[10px] font-bold uppercase text-ds-text-2">
{unit}
</span>
)}
</div>
{podCount !== undefined && podCount > 1 ? (
<span
className="text-[9px] font-mono text-ds-text-2 truncate max-w-[100px]"
title={isSum ? `Average: ${formatNumber(value / podCount)} per pod` : `Fleet average across ${podCount} pods`}
>
{isSum ? `~${formatNumber(value / podCount)}/pod` : `${podCount} pods avg`}
</span>
) : subtitle ? (
<span className="text-[10px] text-ds-text-3 truncate max-w-[90px]">
{subtitle}
</span>
) : null}
</div>
</div>
{/* Footer range indicators & trend */}
<div className="flex w-full items-center justify-between border-t border-ds-line pt-2 text-[10px] text-ds-text-2">
<span className="font-mono">
<span className="text-ds-text-3">min</span> {formatNumber(min)}
</span>
{trend && (
<span className={`inline-flex items-center gap-0.5 font-mono text-[9px] font-bold ${trend.color}`}>
{trend.direction === "up" && <ArrowUpRight className="h-2.5 w-2.5" />}
{trend.direction === "down" && <ArrowDownRight className="h-2.5 w-2.5" />}
{trend.direction === "flat" && <Minus className="h-2.5 w-2.5" />}
{trend.text}
</span>
)}
{avg !== undefined && (
<span className="font-mono text-ds-text-2">
<span className="text-ds-text-3">avg</span> {formatNumber(avg)}
</span>
)}
<span className="font-mono">
<span className="text-ds-text-3">max</span> {formatNumber(max)}
</span>
</div>
{/* Pod Breakdown pills if available */}
{podSummaries && podSummaries.length > 1 && (
<div className="mt-2 flex w-full flex-wrap items-center gap-1 border-t border-ds-line pt-1.5">
{podSummaries.slice(0, 3).map((ps) => (
<div
key={ps.pod}
className="flex items-center gap-1 rounded bg-ds-surface-2 px-1.5 py-0.5 text-[8.5px] font-mono text-ds-text-2"
title={`${ps.pod}: ${formatNumber(ps.value)} ${unit || ""}`}
>
<span className="truncate max-w-[65px] text-ds-text-2">{ps.pod}</span>
<span className="font-bold text-ds-accent">{formatNumber(ps.value)}</span>
</div>
))}
{podSummaries.length > 3 && (
<span className="text-[8px] font-mono text-ds-text-3 self-center">
+{podSummaries.length - 3}
</span>
)}
</div>
)}
</div>
);
};
File diff suppressed because it is too large. Load diff
+461 -36
View File
@@ -1,48 +1,473 @@
import { useState } from "react"
import { Activity, CheckCircle2, CloudCog, RefreshCw, XCircle } from "lucide-react"
import { useEffect, useMemo, useState } from "react"
import { Activity, CheckCircle2, CloudCog, RefreshCw, Rocket, XCircle } from "lucide-react"
import { useRollouts } from "../hooks/useRollouts"
import { formatRelativeTime } from "../lib/timeUtils"
import type { RolloutImpact } from "../lib/types"
import { useObservabilityData } from "../hooks/useObservabilityData"
import { formatDuration, formatRelativeTime } from "../lib/timeUtils"
import type { Rollout, RolloutImpact } from "../lib/types"
import { FilterBar, type FilterChip } from "./FilterBar"
import { MasterDetail } from "./MasterDetail"
import { Segmented } from "./common/Segmented"
import { DetailField, SectionLabel } from "./common/DetailField"
import { useSubRoute } from "../lib/routes"
const phaseStyle: Record<string, { className: string; Icon: typeof Activity }> = {
completed: { className: "text-ds-ok bg-ds-ok/15 border-ds-ok/20", Icon: CheckCircle2 },
failed: { className: "text-ds-critical bg-ds-critical-bg border-ds-critical/20", Icon: XCircle },
progressing: { className: "text-ds-warn bg-ds-warn-bg border-ds-warn/20", Icon: Activity },
type PhaseFilter = "progressing" | "completed" | "failed"
type SortMode = "recent" | "active"
const phaseMeta: Record<string, { Icon: typeof Activity; pill: string; dot: string; bar: string }> = {
completed: { Icon: CheckCircle2, pill: "text-ds-ok bg-ds-ok/15 border-ds-ok/20", dot: "bg-ds-ok", bar: "bg-ds-ok" },
failed: { Icon: XCircle, pill: "text-ds-critical bg-ds-critical-bg border-ds-critical/20", dot: "bg-ds-critical", bar: "bg-ds-critical" },
progressing: { Icon: Activity, pill: "text-ds-warn bg-ds-warn-bg border-ds-warn/20", dot: "bg-ds-warn", bar: "bg-ds-warn" },
}
const phaseOf = (rollout: Rollout) => phaseMeta[rollout.phase] ?? phaseMeta.progressing
const PhasePill = ({ rollout }: { rollout: Rollout }) => {
const { Icon, pill } = phaseOf(rollout)
return (
<span
className={`inline-flex shrink-0 items-center gap-1 whitespace-nowrap rounded border px-2 py-1 text-[10px] font-bold uppercase ${pill}`}
>
<Icon className="h-3 w-3" aria-hidden="true" /> {rollout.phase}
</span>
)
}
/** Real replica progress: available of desired, tinted by the rollout's actual phase. */
const ReplicaBar = ({ rollout }: { rollout: Rollout }) => {
const { bar } = phaseOf(rollout)
const ratio = rollout.desiredReplicas > 0 ? Math.min(rollout.availableReplicas / rollout.desiredReplicas, 1) : 0
return (
<span className="flex flex-1 items-center gap-2">
<span className="h-1 flex-1 overflow-hidden rounded-full bg-ds-fill">
<span className={`block h-full rounded-full ${bar}`} style={{ width: `${Math.round(ratio * 100)}%` }} />
</span>
<span className="shrink-0 font-[family-name:var(--font-data)] text-[11px] text-ds-text-3">
{rollout.availableReplicas}/{rollout.desiredReplicas}
</span>
</span>
)
}
const RolloutListItem = ({ rollout }: { rollout: Rollout }) => {
const { dot } = phaseOf(rollout)
return (
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<span className={`h-2 w-2 shrink-0 rounded-full ${dot}`} aria-hidden="true" />
<span className="truncate font-[family-name:var(--font-data)] text-[12.5px] font-medium text-ds-text">
{rollout.workload}
</span>
<span className="shrink-0 font-[family-name:var(--font-data)] text-[11px] text-ds-text-3">
{rollout.namespace}
</span>
<span className="flex-grow" />
<span className="shrink-0 font-[family-name:var(--font-data)] text-[11px] text-ds-text-3">
{formatRelativeTime(rollout.startedAt / 1e6)}
</span>
</div>
<div className="flex items-center gap-2">
<span className="shrink-0 rounded-[6px] border border-ds-line-strong px-1.5 py-0.5 font-[family-name:var(--font-data)] text-[10.5px] text-ds-text-2">
rev {rollout.revision || "?"}
</span>
<ReplicaBar rollout={rollout} />
</div>
</div>
)
}
const formatDateTime = (ms: number) =>
new Date(ms).toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" })
/**
* Error rate around the rollout, from Prometheus. A nonzero 5xx rate is drawn
* in the critical tone; the hairline marks when this revision started rolling.
*/
const ImpactChart = ({ impact, rollout }: { impact: RolloutImpact; rollout: Rollout }) => {
const samples = useMemo(
() =>
impact.points
.map((point) => ({ t: point.timestamp, v: Number.parseFloat(point.value) }))
.filter((sample) => Number.isFinite(sample.v)),
[impact.points],
)
if (samples.length === 0) {
return <p className="text-[12px] text-ds-text-3">No matching Prometheus series for this workload.</p>
}
const max = Math.max(...samples.map((s) => s.v))
const first = samples[0].t
const span = Math.max(samples[samples.length - 1].t - first, 1)
const startPct = ((rollout.startedAt / 1e9 - first) / span) * 100
const formatRate = (v: number) => (v >= 100 ? String(Math.round(v)) : String(Number(v.toFixed(3))))
return (
<div className="flex flex-col gap-1.5">
<div className="flex items-baseline justify-between gap-2">
<span className="font-[family-name:var(--font-data)] text-[10.5px] text-ds-text-3">
5xx/s · peak {formatRate(max)}
</span>
<span className="font-[family-name:var(--font-data)] text-[10.5px] text-ds-text-3">
{formatDateTime(first * 1000)} → {formatDateTime(samples[samples.length - 1].t * 1000)}
</span>
</div>
<div className="relative flex h-16 items-end gap-px rounded-[8px] bg-ds-fill/50 px-1 py-1">
{startPct > 0 && startPct < 100 && (
<span
className="absolute inset-y-0 w-px bg-ds-text-3"
style={{ left: `${startPct}%` }}
title={`Rollout started ${formatDateTime(rollout.startedAt / 1e6)}`}
/>
)}
{samples.map((sample) => (
<span
key={sample.t}
className={`min-w-[1px] flex-1 rounded-t-[1px] ${sample.v > 0 ? "bg-ds-critical/70" : "bg-ds-line-strong/60"}`}
style={{ height: max > 0 ? `${Math.max((sample.v / max) * 100, 2)}%` : "2%" }}
title={`${formatDateTime(sample.t * 1000)} — ${formatRate(sample.v)} 5xx/s`}
/>
))}
</div>
</div>
)
}
const RolloutDetail = ({
rollout,
impactState,
impactLoading,
}: {
rollout: Rollout
impactState?: RolloutImpact
impactLoading: boolean
}) => {
const short = rollout.generation > 0 ? ` · generation ${rollout.generation}` : ""
const replicas = [
{ label: "Desired", value: rollout.desiredReplicas },
{ label: "Updated", value: rollout.updatedReplicas },
{ label: "Available", value: rollout.availableReplicas, warn: rollout.availableReplicas < rollout.desiredReplicas },
{ label: "Ready", value: rollout.readyReplicas, warn: rollout.readyReplicas < rollout.desiredReplicas },
]
return (
<div className="flex flex-col gap-4 rounded-[14px] border border-ds-line bg-ds-surface p-4">
<div className="flex items-center gap-3">
<PhasePill rollout={rollout} />
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate font-[family-name:var(--font-data)] text-[13px] font-semibold text-ds-text">
{rollout.namespace}/{rollout.workload}
</span>
<span className="font-[family-name:var(--font-data)] text-[11px] text-ds-text-3">
revision {rollout.revision || "unknown"}
{short}
</span>
</div>
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-3 border-y border-ds-fill py-3 sm:grid-cols-3">
<DetailField label="Service" value={rollout.service} />
<DetailField label="Release" value={rollout.release} />
<DetailField label="Last state change" value={formatRelativeTime(rollout.lastSeen / 1e6)} />
<DetailField label="Image" value={rollout.image} />
<DetailField label="Reason" value={rollout.reason} />
<DetailField label="Message" value={rollout.message} />
</div>
<div className="grid grid-cols-4 gap-2">
{replicas.map((stat) => (
<div key={stat.label} className="flex flex-col gap-1">
<span className="text-[10.5px] font-bold uppercase tracking-[0.09em] text-ds-text-3">{stat.label}</span>
<span
className={`font-[family-name:var(--font-display)] text-[17px] font-bold ${stat.warn ? "text-ds-warn" : "text-ds-text"}`}
>
{stat.value}
</span>
</div>
))}
</div>
<div className="flex flex-col gap-2">
<SectionLabel>Timeline</SectionLabel>
<div className="flex items-baseline justify-between gap-2">
<span className="text-[12.5px] text-ds-text">Started</span>
<span className="text-right font-[family-name:var(--font-data)] text-[11.5px] text-ds-text-2">
{formatDateTime(rollout.startedAt / 1e6)} · {formatRelativeTime(rollout.startedAt / 1e6)}
</span>
</div>
<div className="flex items-baseline justify-between gap-2">
<span className="text-[12.5px] text-ds-text">Completed</span>
{rollout.completedAt ? (
<span className="text-right font-[family-name:var(--font-data)] text-[11.5px] text-ds-text-2">
{formatDateTime(rollout.completedAt / 1e6)} · took{" "}
{formatDuration(Math.max(rollout.completedAt - rollout.startedAt, 0))}
</span>
) : (
<span className="font-[family-name:var(--font-data)] text-[11.5px] text-ds-text-3">not yet</span>
)}
</div>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<SectionLabel>Prometheus error rate</SectionLabel>
{impactLoading && <RefreshCw className="h-3 w-3 animate-spin text-ds-text-3" aria-hidden="true" />}
</div>
{!impactState ? (
<p className="text-[12px] text-ds-text-3">Loading impact data…</p>
) : !impactState.configured ? (
<p className="text-[12px] text-ds-text-3">Prometheus is not configured.</p>
) : (
<ImpactChart impact={impactState} rollout={rollout} />
)}
</div>
</div>
)
}
const Tile = ({
label,
value,
detail,
tone,
}: {
label: string
value: string
detail: string
tone?: "critical" | "warn" | "ok"
}) => (
<div className="flex flex-col gap-1 rounded-[12px] border border-ds-line bg-ds-surface px-4 py-3">
<span className="text-[10.5px] font-bold uppercase tracking-[0.09em] text-ds-text-3">{label}</span>
<span
className={`font-[family-name:var(--font-display)] text-[22px] font-bold ${
tone === "critical" ? "text-ds-critical" : tone === "warn" ? "text-ds-warn" : tone === "ok" ? "text-ds-ok" : "text-ds-text"
}`}
>
{value}
</span>
<span className="text-[12px] text-ds-text-3">{detail}</span>
</div>
)
const phaseRank: Record<string, number> = { progressing: 0, failed: 1, completed: 2 }
export function RolloutsVisualizer() {
const { rollouts, loading, error, refresh, impact } = useRollouts()
const [selected, setSelected] = useState<string | null>(null)
const [impactData, setImpactData] = useState<Record<string, RolloutImpact>>({})
const { searchQuery, setSearchQuery } = useObservabilityData()
const { segments, go } = useSubRoute()
const [phaseFilter, setPhaseFilter] = useState<PhaseFilter | null>(null)
const [namespaceFilter, setNamespaceFilter] = useState<string | null>(null)
const [sortMode, setSortMode] = useState<SortMode>("recent")
const [impactById, setImpactById] = useState<Record<string, RolloutImpact>>({})
const [impactPending, setImpactPending] = useState<Record<string, boolean>>({})
const [impactFailed, setImpactFailed] = useState<Record<string, boolean>>({})
const [impactError, setImpactError] = useState<string | null>(null)
const select = async (id: string) => {
setSelected(selected === id ? null : id)
if (impactData[id]) return
try {
setImpactError(null)
const result = await impact(id)
setImpactData((previous) => ({ ...previous, [id]: result }))
}
catch (cause) { setImpactError(cause instanceof Error ? cause.message : String(cause)) }
const namespaces = useMemo(() => [...new Set(rollouts.map((r) => r.namespace))].sort(), [rollouts])
const filtered = useMemo(() => {
return rollouts.filter((r) => {
if (phaseFilter && r.phase !== phaseFilter) return false
if (namespaceFilter && r.namespace !== namespaceFilter) return false
if (searchQuery) {
const haystack = [r.workload, r.namespace, r.service, r.release, r.image, r.revision]
.filter(Boolean)
.join(" ")
.toLowerCase()
if (!haystack.includes(searchQuery.toLowerCase())) return false
}
return true
})
}, [rollouts, phaseFilter, namespaceFilter, searchQuery])
const sorted = useMemo(() => {
const byStart = (a: Rollout, b: Rollout) => b.startedAt - a.startedAt
if (sortMode === "active") return [...filtered].sort((a, b) => (phaseRank[a.phase] ?? 3) - (phaseRank[b.phase] ?? 3) || byStart(a, b))
return [...filtered].sort(byStart)
}, [filtered, sortMode])
const selected = sorted.find((r) => r.id === segments[0]) ?? sorted[0] ?? null
// Fetch Prometheus impact for whatever the user selects; cached per rollout,
// failures recorded so a dead Prometheus isn't re-polled on every render.
const selectedId = selected?.id ?? null
useEffect(() => {
if (!selectedId || impactById[selectedId] || impactFailed[selectedId] || impactPending[selectedId]) return
setImpactPending((prev) => ({ ...prev, [selectedId]: true }))
impact(selectedId)
.then((result) => {
setImpactById((prev) => ({ ...prev, [selectedId]: result }))
setImpactError(null)
})
.catch((cause) => {
setImpactFailed((prev) => ({ ...prev, [selectedId]: true }))
setImpactError(cause instanceof Error ? cause.message : String(cause))
})
.finally(() => setImpactPending((prev) => ({ ...prev, [selectedId]: false })))
}, [selectedId, impactById, impactFailed, impactPending, impact])
const stats = useMemo(() => {
const dayAgoMs = Date.now() - 24 * 3600 * 1000
const progressing = rollouts.filter((r) => r.phase === "progressing").length
const failed = rollouts.filter((r) => r.phase === "failed").length
const recent = rollouts.filter((r) => r.startedAt / 1e6 >= dayAgoMs).length
const workloads = new Set(rollouts.map((r) => `${r.namespace}/${r.workload}`)).size
return { progressing, failed, recent, workloads }
}, [rollouts])
const activeFilterCount = (phaseFilter ? 1 : 0) + (namespaceFilter ? 1 : 0) + (searchQuery ? 1 : 0)
const chips: FilterChip[] = [
...(phaseFilter
? [
{
id: "phase",
filterKey: "phase",
value: phaseFilter,
tone: phaseFilter === "failed" ? ("critical" as const) : phaseFilter === "progressing" ? ("warn" as const) : ("ok" as const),
onRemove: () => setPhaseFilter(null),
},
]
: []),
...(namespaceFilter
? [{ id: "namespace", filterKey: "namespace", value: namespaceFilter, tone: "neutral" as const, onRemove: () => setNamespaceFilter(null) }]
: []),
...(searchQuery ? [{ id: "query", filterKey: "query", value: searchQuery, tone: "neutral" as const, onRemove: () => setSearchQuery("") }] : []),
]
const addFilterOptions = [
...(["progressing", "failed", "completed"] as PhaseFilter[])
.filter((phase) => phase !== phaseFilter)
.map((phase) => ({ value: `phase:${phase}`, label: `phase=${phase}` })),
...namespaces.filter((ns) => ns !== namespaceFilter).map((ns) => ({ value: `ns:${ns}`, label: `namespace=${ns}` })),
]
const onAddFilter = (value: string) => {
if (value.startsWith("phase:")) setPhaseFilter(value.slice(6) as PhaseFilter)
else if (value.startsWith("ns:")) setNamespaceFilter(value.slice(3))
}
if (loading && rollouts.length === 0) return <div className="py-16 text-center text-sm text-ds-text-3">Loading cluster rollouts…</div>
return <div className="space-y-4">
<div className="flex flex-wrap items-center gap-3 border-b border-ds-line pb-3">
<div><div className="flex items-center gap-2 text-[10px] font-bold uppercase tracking-[0.2em] text-ds-text-3"><CloudCog className="h-3 w-3 text-ds-accent" /> Cluster Rollouts</div><div className="mt-1 text-xs text-ds-text-3">Read-only Kubernetes deployment state with optional Prometheus impact data.</div></div>
<button onClick={() => void refresh()} className="ml-auto inline-flex items-center gap-2 rounded-lg border border-ds-line px-3 py-2 text-[10px] font-bold uppercase tracking-wider text-ds-text-2 hover:text-ds-text"><RefreshCw className="h-3 w-3" /> Refresh</button>
</div>
{error && <div className="rounded-lg border border-ds-warn/20 bg-ds-warn-bg p-3 text-xs text-ds-warn">Rollout watcher unavailable: {String(error)}</div>}
{impactError && <div className="rounded-lg border border-ds-critical/20 bg-ds-critical-bg p-3 text-xs text-ds-critical">Impact query failed: {impactError}</div>}
{rollouts.length === 0 ? <div className="py-16 text-center text-sm text-ds-text-3">No rollout data. Enable the Kubernetes watcher and grant its read-only RBAC permissions.</div> : <div className="space-y-2">{rollouts.map((rollout) => {
const status = phaseStyle[rollout.phase] ?? phaseStyle.progressing
const Icon = status.Icon
const data = impactData[rollout.id]
return <div key={rollout.id} className="rounded-xl border border-ds-line bg-ds-surface-2 p-4">
<button onClick={() => void select(rollout.id)} className="w-full text-left"><div className="flex flex-wrap items-center gap-3"><span className={`inline-flex items-center gap-1 rounded border px-2 py-1 text-[10px] font-bold uppercase ${status.className}`}><Icon className="h-3 w-3" /> {rollout.phase}</span><span className="font-mono text-xs text-ds-text">{rollout.namespace}/{rollout.workload}</span><span className="text-xs text-ds-text-2">revision {rollout.revision || "unknown"}</span><span className="ml-auto text-[10px] text-ds-text-3">{formatRelativeTime(rollout.lastSeen / 1e6)}</span></div><div className="mt-3 grid gap-2 text-[11px] text-ds-text-2 sm:grid-cols-2 lg:grid-cols-4"><span>release: <b className="text-ds-text">{rollout.release || "unlabeled"}</b></span><span>image: <b className="font-mono text-ds-text">{rollout.image || "unknown"}</b></span><span>replicas: <b className="text-ds-text">{rollout.availableReplicas}/{rollout.desiredReplicas} ready</b></span><span>reason: <b className="text-ds-text">{rollout.reason || "unknown"}</b></span></div></button>
{selected === rollout.id && <div className="mt-4 border-t border-ds-line pt-3 text-xs text-ds-text-2"><div>Started {formatRelativeTime(rollout.startedAt / 1e6)}{rollout.completedAt ? ` · completed ${formatRelativeTime(rollout.completedAt / 1e6)}` : ""}</div><div className="mt-2 text-ds-text-3">{rollout.message || "No rollout message reported."}</div>{data && <div className="mt-3 rounded-lg bg-ds-surface-2 p-3"><div className="flex items-center gap-2 text-[10px] font-bold uppercase tracking-widest text-ds-text-3"><Activity className="h-3 w-3" /> Prometheus error-rate points</div>{!data.configured ? <div className="mt-2 text-ds-text-3">Prometheus is not configured.</div> : data.points.length === 0 ? <div className="mt-2 text-ds-text-3">No matching Prometheus series.</div> : <div className="mt-2 flex flex-wrap gap-2">{data.points.slice(-12).map((point) => <span key={`${point.timestamp}-${point.value}`} className="rounded bg-ds-accent-bg px-2 py-1 font-mono text-[10px] text-ds-accent">{new Date(point.timestamp * 1000).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}: {point.value}</span>)}</div>}</div>}</div>}
const clearAll = () => {
setPhaseFilter(null)
setNamespaceFilter(null)
setSearchQuery("")
}
const matchText = `Matching ${filtered.length} of ${rollouts.length} rollouts · ${activeFilterCount} filter${activeFilterCount === 1 ? "" : "s"}`
if (error) {
return (
<div className="flex flex-col gap-5">
<FilterBar chips={[]} matchText={matchText} />
<div className="flex flex-col items-center justify-center gap-2 rounded-[14px] border border-dashed border-ds-line py-20 text-center">
<CloudCog className="h-8 w-8 text-ds-text-3" aria-hidden="true" />
<p className="text-[14px] font-medium text-ds-text">Rollout watcher unavailable</p>
<p className="max-w-sm text-[12.5px] text-ds-text-3">{error.message}</p>
</div>
</div>
})}</div>}
</div>
)
}
if (loading && rollouts.length === 0) {
return <div className="py-16 text-center text-sm text-ds-text-3">Loading cluster rollouts…</div>
}
if (rollouts.length === 0) {
return (
<div className="flex flex-col gap-5">
<FilterBar chips={[]} matchText={matchText} />
<div className="flex flex-col items-center justify-center gap-2 rounded-[14px] border border-dashed border-ds-line py-20 text-center">
<CloudCog className="h-8 w-8 text-ds-text-3" aria-hidden="true" />
<p className="text-[14px] font-medium text-ds-text">No rollout data</p>
<p className="max-w-sm text-[12.5px] text-ds-text-3">
Enable the Kubernetes watcher and grant its read-only RBAC permissions to see deployment rollouts here.
</p>
</div>
</div>
)
}
return (
<div className="flex flex-col gap-5">
<FilterBar
chips={chips}
addFilterOptions={addFilterOptions.length > 0 ? addFilterOptions : undefined}
onAddFilter={addFilterOptions.length > 0 ? onAddFilter : undefined}
matchText={matchText}
onClearAll={activeFilterCount > 0 ? clearAll : undefined}
/>
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<Tile label="Tracked rollouts" value={String(rollouts.length)} detail={`across ${stats.workloads} workloads`} />
<Tile
label="In progress"
value={String(stats.progressing)}
detail={stats.progressing > 0 ? "rolling out right now" : "cluster is settled"}
tone={stats.progressing > 0 ? "warn" : "ok"}
/>
<Tile
label="Failed"
value={String(stats.failed)}
detail={stats.failed > 0 ? "deadline exceeded or unavailable" : "no failing rollouts"}
tone={stats.failed > 0 ? "critical" : "ok"}
/>
<Tile label="Last 24 hours" value={String(stats.recent)} detail="rollouts started in the last day" />
</div>
{sorted.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 rounded-[14px] border border-ds-line bg-ds-surface py-16 text-center">
<Rocket className="h-6 w-6 text-ds-text-3" aria-hidden="true" />
<p className="text-[13px] text-ds-text-3">No rollouts match your filters.</p>
</div>
) : (
<MasterDetail
items={sorted}
getKey={(r) => r.id}
selectedKey={selected?.id ?? null}
onSelect={(id) => go([id])}
label="Cluster rollouts"
storageKey="rollouts"
listSize={{ default: 400, min: 300, max: 620 }}
listHeader={
<div className="flex items-center justify-between gap-2">
<h2 className="text-[11px] font-bold uppercase tracking-[0.1em] text-ds-text-3">
Rollouts · {sortMode === "active" ? "active first" : "newest first"}
</h2>
<div className="flex items-center gap-2">
<Segmented
label="Sort rollouts"
value={sortMode}
onChange={setSortMode}
options={[
{ value: "recent", label: "Recent" },
{ value: "active", label: "Active" },
]}
/>
<button
type="button"
onClick={() => void refresh()}
aria-label="Refresh rollouts"
title="Refresh rollouts"
className="inline-flex h-7 w-7 items-center justify-center rounded-[7px] text-ds-text-3 hover:bg-ds-fill hover:text-ds-text-2"
>
<RefreshCw className="h-3.5 w-3.5" aria-hidden="true" />
</button>
</div>
</div>
}
renderItem={(rollout) => <RolloutListItem rollout={rollout} />}
renderDetail={(rollout) => (
<RolloutDetail
rollout={rollout}
impactState={impactById[rollout.id]}
impactLoading={Boolean(impactPending[rollout.id])}
/>
)}
/>
)}
{impactError && (
<p className="rounded-lg border border-ds-warn/20 bg-ds-warn-bg p-3 text-xs text-ds-warn">
Impact query failed: {impactError}
</p>
)}
</div>
)
}
export default RolloutsVisualizer
+21 -2
View File
@@ -1,10 +1,29 @@
import useSWR from "swr"
import { authSWRFetcher, authFetch, tokens } from "../lib/auth"
import { authFetch, tokens } from "../lib/auth"
import type { Rollout, RolloutImpact } from "../lib/types"
/**
* Like authSWRFetcher but keeps the API's JSON error body ("kubernetes
* integration is not configured") instead of reducing it to "HTTP 501".
*/
const rolloutFetcher = async (key: string): Promise<Rollout[]> => {
const response = await authFetch(key)
if (!response.ok) {
let message = `HTTP ${response.status}`
try {
const body = (await response.json()) as { error?: string }
if (body.error) message = body.error
} catch {
// not JSON - keep the status text
}
throw new Error(message)
}
return (await response.json()) as Rollout[]
}
export function useRollouts() {
const key = tokens() ? "/api/rollouts" : null
const state = useSWR<Rollout[]>(key, authSWRFetcher, { refreshInterval: 30_000, revalidateOnFocus: false, shouldRetryOnError: false })
const state = useSWR<Rollout[]>(key, rolloutFetcher, { refreshInterval: 30_000, revalidateOnFocus: false, shouldRetryOnError: false })
const impact = async (id: string) => {
const response = await authFetch(`/api/rollouts/${encodeURIComponent(id)}/impact`)
if (!response.ok) throw new Error(`HTTP ${response.status}`)