Files
go-otel/otel-ui/src/components/RolloutsVisualizer.tsx
T
mats f30adf05af
Build and Publish / Metadata (push) Successful in 2s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m23s
all the changes
2026-09-19 23:25:59 +02:00

474 lines
20 KiB
TypeScript

import { useEffect, useMemo, useState } from "react"
import { Activity, CheckCircle2, CloudCog, RefreshCw, Rocket, XCircle } from "lucide-react"
import { useRollouts } from "../hooks/useRollouts"
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"
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 { 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 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))
}
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>
)
}
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