474 lines
20 KiB
TypeScript
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
|