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