all the changes
This commit is contained in:
1 parent
94f30165f9
commit
f30adf05af
35 files changed
+2979
-1607
No files matched your search
+4
-4
@@ -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 />;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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}`)
|
||||
|
||||
Reference in new issue
Block a user