Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions apps/api/src/routes/internal/query-engine.http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1387,7 +1387,9 @@ export const HttpQueryEngineLive = HttpApiBuilder.group(MapleInternalApi, "query
saturation: Number(row.saturation) || 0,
})),
// The denominator has to match the predicate the list ran, or a scoped
// view reads "Top 17 of 541". The scope counts are already computed.
// view reads "Top 17 of 541". The scope counts are already computed
// within the requested lifecycle, so only the unscoped case has to
// pick which lifecycle total it wants.
totalCount:
Number(
payload.scope === "saturated"
Expand All @@ -1396,9 +1398,12 @@ export const HttpQueryEngineLive = HttpApiBuilder.group(MapleInternalApi, "query
? countRow?.elevatedPods
: payload.scope === "unbounded"
? countRow?.unboundedPods
: payload.scope === "stale"
? countRow?.stalePods
: countRow?.totalPods,
: payload.lifecycle === "ended"
? countRow?.endedPods
: payload.lifecycle === "all"
? Number(countRow?.livePods ?? 0) +
Number(countRow?.endedPods ?? 0)
: countRow?.livePods,
) ||
// A failed count must not render as "0 of 0" under a list with rows.
rows.length,
Expand All @@ -1410,11 +1415,11 @@ export const HttpQueryEngineLive = HttpApiBuilder.group(MapleInternalApi, "query
const tenant = yield* CurrentTenant.Context
const row = yield* runQueryFirst(Queries.podsSummary, tenant, payload)
return new PodsSummaryResponse({
totalPods: Number(row?.totalPods) || 0,
livePods: Number(row?.livePods) || 0,
endedPods: Number(row?.endedPods) || 0,
saturatedPods: Number(row?.saturatedPods) || 0,
elevatedPods: Number(row?.elevatedPods) || 0,
unboundedPods: Number(row?.unboundedPods) || 0,
stalePods: Number(row?.stalePods) || 0,
})
}),
)
Expand Down
8 changes: 7 additions & 1 deletion apps/web/src/api/warehouse/infra.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,10 @@ export type PodSortKey = "saturation" | "cpuUsage" | "cpuLimitPct" | "memoryLimi
export type SortDirection = "asc" | "desc"

/** One-click fleet scopes from the summary band. */
export type PodScope = "saturated" | "elevated" | "unbounded" | "stale"
export type PodScope = "saturated" | "elevated" | "unbounded"

/** Which slice of the window's pods to list — see the domain contract. */
export type PodLifecycle = "live" | "ended" | "all"

export interface InfraPresenceInput {
startTime: string
Expand Down Expand Up @@ -207,6 +210,8 @@ export interface ListPodsInput {
workloadKind?: WorkloadKind
workloadName?: string
scope?: PodScope
/** Server-side default is `live`. */
lifecycle?: PodLifecycle
sortBy?: PodSortKey
sortDir?: SortDirection
limit?: number
Expand Down Expand Up @@ -245,6 +250,7 @@ export function listPods({ data }: { data: ListPodsInput }) {
workloadKind: data.workloadKind,
workloadName: data.workloadName,
scope: data.scope,
lifecycle: data.lifecycle,
sortBy: data.sortBy,
sortDir: data.sortDir,
limit: data.limit,
Expand Down
8 changes: 4 additions & 4 deletions apps/web/src/components/infra/format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,12 @@ describe("deriveHostStatus", () => {
expect(deriveHostStatus("2026-04-24T11:58:30Z", now)).toBe("idle")
})

it("returns down when last-seen is older than 10x scrape interval", () => {
expect(deriveHostStatus("2026-04-24T11:55:00Z", now)).toBe("down")
it("returns ended when last-seen is older than 10x scrape interval", () => {
expect(deriveHostStatus("2026-04-24T11:55:00Z", now)).toBe("ended")
})

it("returns down for a malformed ISO string", () => {
expect(deriveHostStatus("not-a-date", now)).toBe("down")
it("returns ended for a malformed ISO string", () => {
expect(deriveHostStatus("not-a-date", now)).toBe("ended")
})
})

Expand Down
16 changes: 13 additions & 3 deletions apps/web/src/components/infra/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,17 @@ import { toEpochMs } from "@maple/ui/lib/time-format"
// Generic number/byte/percent formatting lives in `@maple/ui/lib/format`; only
// infra-specific status policy stays here.

export type HostStatus = "active" | "idle" | "down"
/**
* Collector freshness, which is all a metrics window can honestly report.
*
* `ended` is deliberately not "down": a series that stops mid-window means the
* resource stopped reporting, and for a pod on an autoscaled fleet that is
* almost always a normal termination — scale-in, a rollout, a replaced Fargate
* task. Calling that an error painted the expected case red. A real down state
* needs an expectation signal (`k8s.pod.phase`, or a workload's available vs
* desired replicas), and belongs beside these rather than instead of them.
*/
export type HostStatus = "active" | "idle" | "ended"
export type SeverityLevel = "ok" | "warn" | "crit"

export function severityLevel(fraction: number): SeverityLevel {
Expand All @@ -17,11 +27,11 @@ const SCRAPE_INTERVAL_MS = 30_000

export function deriveHostStatus(lastSeenIso: string, reference: number | string = Date.now()): HostStatus {
const lastSeen = toEpochMs(lastSeenIso)
if (!Number.isFinite(lastSeen)) return "down"
if (!Number.isFinite(lastSeen)) return "ended"
const referenceMs = typeof reference === "number" ? reference : toEpochMs(reference)
const ref = Number.isFinite(referenceMs) ? referenceMs : Date.now()
const age = ref - lastSeen
if (age < SCRAPE_INTERVAL_MS * 2) return "active"
if (age < SCRAPE_INTERVAL_MS * 10) return "idle"
return "down"
return "ended"
}
6 changes: 3 additions & 3 deletions apps/web/src/components/infra/severity-tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,20 +40,20 @@ export const BAR_VALUE_TONE: Record<SeverityLevel, string> = {
export const STATUS_DOT: Record<HostStatus, string> = {
active: "bg-[var(--severity-info)]",
idle: "bg-muted-foreground/60",
down: "bg-[var(--severity-error)]",
ended: "bg-muted-foreground/40",
} satisfies Record<HostStatus, string>

/** Status dot ring. */
export const STATUS_RING: Record<HostStatus, string> = {
active: "ring-[color-mix(in_oklab,var(--severity-info)_45%,transparent)]",
idle: "ring-border",
down: "ring-[color-mix(in_oklab,var(--severity-error)_45%,transparent)]",
ended: "ring-border",
} satisfies Record<HostStatus, string>

const STATUS_LABEL: Record<HostStatus, string> = {
active: "Active",
idle: "Idle",
down: "Down",
ended: "Ended",
} satisfies Record<HostStatus, string>

/** Human-readable status word, paired with color so it is never the sole signal. */
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/infra/status-badge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { statusLabel } from "./severity-tokens"
const STATUS_TEXT: Record<HostStatus, string> = {
active: "text-[var(--severity-info)]",
idle: "text-muted-foreground",
down: "text-[var(--severity-error)]",
ended: "text-muted-foreground",
} satisfies Record<HostStatus, string>

interface HostStatusBadgeProps {
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/routes/infra/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ const STATUS_FILTERS: ReadonlyArray<{ value: StatusFilter; label: string }> = [
{ value: "all", label: "All" },
{ value: "active", label: "Active" },
{ value: "idle", label: "Idle" },
{ value: "down", label: "Down" },
{ value: "ended", label: "Ended" },
]

function InfraPage() {
Expand Down Expand Up @@ -162,7 +162,7 @@ function FleetView({
)

const counts = useMemo(() => {
const c: Record<HostStatus, number> = { active: 0, idle: 0, down: 0 } satisfies Record<
const c: Record<HostStatus, number> = { active: 0, idle: 0, ended: 0 } satisfies Record<
HostStatus,
number
>
Expand Down
15 changes: 8 additions & 7 deletions apps/web/src/routes/infra/kubernetes/nodes/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {

const DEFAULT_PRESET = "12h"

const NodeStatusParam = Schema.optional(Schema.Literals(["active", "idle", "down"]))
const NodeStatusParam = Schema.optional(Schema.Literals(["active", "idle", "ended"]))

const nodesSearchSchema = Schema.Struct({
q: Schema.optional(Schema.String),
Expand All @@ -44,9 +44,10 @@ export const Route = createFileRoute("/infra/kubernetes/nodes/")({

/**
* The states are collector freshness, not Kubernetes conditions: a node is
* "Down" here when no kubelet metric has arrived recently, which is a fact about
* the collector as much as about the node. `k8s.node.condition_ready` is
* collected but unqueried — when it lands, it belongs beside these, not instead.
* "Ended" here when no kubelet metric has arrived recently, which on an ASG or
* a Karpenter-managed fleet usually means the node was scaled in, not that it
* failed — so it reads neutral. `k8s.node.condition_ready` is collected but
* unqueried; when it lands it belongs beside these, not instead.
*/
const STATUS_CELLS: ReadonlyArray<{
status: HostStatus
Expand All @@ -55,13 +56,13 @@ const STATUS_CELLS: ReadonlyArray<{
}> = [
{ status: "active", hint: "reporting", tone: "info" },
{ status: "idle", hint: "quiet >1m", tone: "warn" },
{ status: "down", hint: "silent >5m", tone: "crit" },
{ status: "ended", hint: "silent >5m", tone: "neutral" },
]

const STATUS_SEGMENT: Record<HostStatus, string> = {
active: "bg-[var(--severity-info)]",
idle: "bg-[var(--severity-warn)]",
down: "bg-[var(--severity-error)]",
ended: "bg-muted-foreground/40",
} satisfies Record<HostStatus, string>

function NodesPage() {
Expand Down Expand Up @@ -148,7 +149,7 @@ function NodesPage() {

// The band counts the whole scope as of the window's end, so it keeps
// saying what the search and the status cell just hid.
const counts = { active: 0, idle: 0, down: 0 } satisfies Record<HostStatus, number>
const counts = { active: 0, idle: 0, ended: 0 } satisfies Record<HostStatus, number>
for (const node of nodes) counts[deriveHostStatus(node.lastSeen, endTime)]++

const q = searchText.trim().toLowerCase()
Expand Down
61 changes: 45 additions & 16 deletions apps/web/src/routes/infra/kubernetes/pods/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,19 @@ import {
const PAGE_SIZE = 50
const DEFAULT_PRESET = "12h"

/** A one-click scope from the band. `undefined` means the whole fleet. */
type PodScope = "saturated" | "elevated" | "unbounded" | "stale"
/**
* A one-click scope from the band. `undefined` means the live fleet, which is
* what the list shows by default — see `PodLifecycle` in the domain contract.
* `ended` is a lifecycle rather than a saturation bucket, but it rides in the
* same URL param so the band stays one uniform row of cells.
*/
type PodScope = "saturated" | "elevated" | "unbounded" | "ended"

const PodSortKeyParam = Schema.optional(
Schema.Literals(["saturation", "cpuUsage", "cpuLimitPct", "memoryLimitPct", "podName", "lastSeen"]),
)
const SortDirParam = Schema.optional(Schema.Literals(["asc", "desc"]))
const ScopeParam = Schema.optional(Schema.Literals(["saturated", "elevated", "unbounded", "stale"]))
const ScopeParam = Schema.optional(Schema.Literals(["saturated", "elevated", "unbounded", "ended"]))

const podsSearchSchema = Schema.Struct({
q: Schema.optional(Schema.String),
Expand Down Expand Up @@ -83,7 +88,7 @@ const SCOPE_LABEL: Record<PodScope, string> = {
saturated: "at or above 90% of a limit",
elevated: "at or above 60% of a limit",
unbounded: "running with no limits set",
stale: "whose collector has gone quiet",
ended: "no longer running",
} satisfies Record<PodScope, string>

function PodsPage() {
Expand Down Expand Up @@ -127,14 +132,17 @@ function PodsPage() {
const searchText = search.q ?? ""
const debouncedSearch = useDebouncedValue(searchText, 300)

// "Ended" is the lifecycle dial, not a saturation bucket: the other three
// scopes narrow the live fleet, this one swaps which fleet is on screen.
const podsResult = useAtomValue(
listPodsResultAtom({
data: {
startTime,
endTime,
...filters,
search: debouncedSearch.trim() || undefined,
scope,
scope: scope === "ended" ? undefined : scope,
lifecycle: scope === "ended" ? "ended" : "live",
sortBy,
sortDir,
limit: PAGE_SIZE,
Expand Down Expand Up @@ -182,6 +190,13 @@ function PodsPage() {
patchSearch({ sortBy: key, sortDir: key === "podName" ? "asc" : "desc" })
}

// An unfiltered page can come back empty because the fleet is entirely gone
// rather than never instrumented — a job that finished, an environment scaled
// to zero. The band already knows, so the empty state can stop guessing.
const endedPods = Result.builder(summaryResult)
.onSuccess((counts) => counts.endedPods)
.orElse(() => 0)

const hasStructuredFilter = Object.values(filters).some((v) => (v?.length ?? 0) > 0)
const hasAnyNarrowing = hasStructuredFilter || Boolean(searchText.trim()) || Boolean(scope)

Expand Down Expand Up @@ -253,22 +268,22 @@ function PodsPage() {
tone: "warn",
},
{
scope: "stale",
label: "Stale collector",
hint: ">5m",
value: counts.stalePods,
scope: "ended",
label: "Ended",
hint: "not running",
value: counts.endedPods,
tone: "neutral",
},
]
const healthy = Math.max(
counts.totalPods - counts.saturatedPods - counts.elevatedPods,
counts.livePods - counts.saturatedPods - counts.elevatedPods,
0,
)
return (
<FleetBand
total={counts.totalPods}
noun="pod"
caption="share of the fleet by peak utilization"
total={counts.livePods}
noun="live pod"
caption="share of the live fleet by peak utilization"
segments={[
{ key: "healthy", count: healthy, className: "bg-muted-foreground/35" },
{
Expand Down Expand Up @@ -305,12 +320,26 @@ function PodsPage() {
<EmptyMedia variant="icon">
<FolderIcon size={16} />
</EmptyMedia>
<EmptyTitle>No pods reporting yet</EmptyTitle>
<EmptyTitle>
{endedPods > 0
? "Nothing running right now"
: "No pods reporting yet"}
</EmptyTitle>
<EmptyDescription>
Install the Maple Kubernetes Helm chart so the kubelet stats
receiver can start collecting per-pod CPU and memory metrics.
{endedPods > 0
? `Every pod that reported in this window has since ended. Open the Ended scope to see the ${endedPods.toLocaleString()} that ran.`
: "Install the Maple Kubernetes Helm chart so the kubelet stats receiver can start collecting per-pod CPU and memory metrics."}
</EmptyDescription>
</EmptyHeader>
{endedPods > 0 ? (
<Button
variant="outline"
size="sm"
onClick={() => patchSearch({ scope: "ended" })}
>
Show ended pods
</Button>
) : null}
</Empty>
)
}
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/routes/infra/kubernetes/workloads/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ const KIND_OPTIONS = [
* pods view is where a spike shows.
*/
function scopeOf(workload: WorkloadRow, referenceTime: string): WorkloadScope | null {
if (deriveHostStatus(workload.lastSeen, referenceTime) === "down") return "stale"
if (deriveHostStatus(workload.lastSeen, referenceTime) === "ended") return "stale"
const level = severityLevel(Math.max(workload.avgCpuLimitPct, workload.avgMemoryLimitPct))
if (level === "crit") return "saturated"
if (level === "warn") return "elevated"
Expand Down
16 changes: 13 additions & 3 deletions packages/domain/src/http/query-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1207,7 +1207,14 @@ const PodSortKeyLiteral = Schema.Literals([
const SortDirectionLiteral = Schema.Literals(["asc", "desc"])

/** One-click fleet scopes from the browse summary band. */
const PodScopeLiteral = Schema.Literals(["saturated", "elevated", "unbounded", "stale"])
const PodScopeLiteral = Schema.Literals(["saturated", "elevated", "unbounded"])

/**
* Which slice of the window's pods to return. A windowed list is the union of
* everything that reported at any point in it, so on an autoscaled fleet most
* of those pods no longer exist. Defaults to `live` server-side.
*/
const PodLifecycleLiteral = Schema.Literals(["live", "ended", "all"])

export class ListPodsRequest extends Schema.Class<ListPodsRequest>("ListPodsRequest")({
startTime: TinybirdDateTime,
Expand Down Expand Up @@ -1236,6 +1243,7 @@ export class ListPodsRequest extends Schema.Class<ListPodsRequest>("ListPodsRequ
workloadKind: Schema.optional(WorkloadKindLiteral),
workloadName: Schema.optional(Schema.String),
scope: Schema.optional(PodScopeLiteral),
lifecycle: Schema.optional(PodLifecycleLiteral),
sortBy: Schema.optional(PodSortKeyLiteral),
sortDir: Schema.optional(SortDirectionLiteral),
limit: Schema.optional(Schema.Number),
Expand Down Expand Up @@ -1286,11 +1294,13 @@ export class PodsSummaryRequest extends Schema.Class<PodsSummaryRequest>("PodsSu
}) {}

export class PodsSummaryResponse extends Schema.Class<PodsSummaryResponse>("PodsSummaryResponse")({
totalPods: Schema.Number,
/** Still reporting at the window's end — the fleet as it stands. */
livePods: Schema.Number,
/** Reported earlier in the window and stopped: scale-in, a rollout, a cycled task. */
endedPods: Schema.Number,
saturatedPods: Schema.Number,
elevatedPods: Schema.Number,
unboundedPods: Schema.Number,
stalePods: Schema.Number,
}) {}

// Containers (Docker) — docker_stats receiver rows, identity (container.name,
Expand Down
Loading
Loading