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
7 changes: 3 additions & 4 deletions apps/web/src/components/infra/cloudflare/accounts.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { CloudflareConnectedAccountStatus } from "@maple/domain/http"
import { formatRelativeFrom } from "@maple/ui/lib/time-format"

/** A sync older than this is "delayed": the poller runs every few minutes. */
export const STALE_SYNC_MS = 30 * 60_000
Expand All @@ -19,8 +20,6 @@ export interface CloudflareAccount {
health: AccountHealth
}

const minutesAgo = (now: number, at: number) => Math.max(1, Math.round((now - at) / 60_000))

/**
* Health from the poller's own bookkeeping, worst first: zones with errors, then a stale sync, then
* a failing Workers dataset. A revoked grant is grant-wide, so the page states it once, not per account.
Expand All @@ -44,12 +43,12 @@ export function accountHealth(account: CloudflareConnectedAccountStatus, now: nu
].filter((at): at is number => at !== null)
const oldest = synced.length === 0 ? null : Math.min(...synced)
if (oldest !== null && now - oldest > STALE_SYNC_MS) {
return { tone: "warn", message: `Delayed · last sync ${minutesAgo(now, oldest)}m ago` }
return { tone: "warn", message: `Delayed · last sync ${formatRelativeFrom(oldest, now)}` }
}
if (account.workers?.lastError) return { tone: "warn", message: "Workers analytics failing" }
return {
tone: "ok",
message: oldest === null ? "waiting for first sync" : `synced ${minutesAgo(now, oldest)}m ago`,
message: oldest === null ? "waiting for first sync" : `synced ${formatRelativeFrom(oldest, now)}`,
}
}

Expand Down
14 changes: 14 additions & 0 deletions apps/web/src/components/infra/cloudflare/list-model.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ERROR_RATE_WARN } from "@maple/ui/lib/error-rate"
import type { Tone } from "@maple/ui/lib/tone"

import type {
CloudflareDurableObjectRow,
Expand Down Expand Up @@ -26,6 +27,13 @@ export type WorkerScope = (typeof WORKER_SCOPES)[number]
export type ZoneHealth = "serving5xx" | "mostly4xx" | "ok" | "lowVolume"

/** One rule for the list row and the detail header, so a zone never reads differently on each. */
export const ZONE_HEALTH_TONE = {
serving5xx: "crit",
mostly4xx: "warn",
ok: "ok",
lowVolume: "neutral",
} as const satisfies Record<ZoneHealth, Tone>

export function classifyZone(requests: number, serverErrorRate: number, clientErrorRate: number): ZoneHealth {
if (requests < MIN_RATED_REQUESTS) return "lowVolume"
if (serverErrorRate >= ERROR_RATE_WARN) return "serving5xx"
Expand Down Expand Up @@ -133,6 +141,12 @@ export function mergeWorkerRows(

export type WorkerHealth = "erroring" | "slow" | "ok"

export const WORKER_HEALTH_TONE = {
erroring: "crit",
slow: "warn",
ok: "ok",
} as const satisfies Record<WorkerHealth, Tone>

export function workerHealth(row: WorkerListRow): WorkerHealth {
if (row.errorRate >= ERROR_RATE_WARN || row.doErrorRate >= ERROR_RATE_WARN) return "erroring"
if (row.durationP99Ms >= SLOW_P99_MS) return "slow"
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/components/infra/cloudflare/worker-signals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {
CloudflareWorkerLatencyBucket,
CloudflareWorkerOutcomeBucket,
} from "@maple/web-data/warehouse/cloudflare-infra"
import { shortId } from "@maple/ui/lib/ids"
import type { ChartEventMarker } from "../primitives/chart-event-marker"

type StripRow = { bucket: string; attributeValue: string; value: number }
Expand Down Expand Up @@ -128,7 +129,7 @@ export function deployMarkers(
.map((release) => ({
id: release.commitSha,
at: release.firstSeen,
label: release.commitSha.slice(0, 7),
label: shortId(release.commitSha, "sha"),
tone: "neutral",
}))
}
Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/components/infra/format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,9 @@ describe("node capacity", () => {
})

it("formats cores against allocatable", () => {
expect(formatCoresOfAllocatable(0.4231, 3.92)).toBe("0.42 / 3.92 cores")
expect(formatCoresOfAllocatable(0.4231, 0)).toBe("0.42 cores")
expect(formatCoresOfAllocatable(0.4231, 3.92)).toBe("0.423 / 3.92 cores")
expect(formatCoresOfAllocatable(0.4231, 0)).toBe("0.423 cores")
expect(formatCoresOfAllocatable(10.4, 16)).toBe("10.4 / 16 cores")
})

it("formats memory against allocatable", () => {
Expand Down
7 changes: 4 additions & 3 deletions apps/web/src/components/infra/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { type UtilizationLevel, utilizationLevel } from "@maple/ui/lib/utilizati
*
* `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
* 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.
Expand Down Expand Up @@ -50,8 +50,9 @@ export const formatUtilizationPercent = (fraction: number): string => {
return rounded >= 10 ? `${Math.round(pct)}%` : `${rounded}%`
}

/** CPU usage in cores, two decimals. */
export const formatCores = (cores: number) => (Number.isFinite(cores) ? cores.toFixed(2) : EMPTY_VALUE)
/** CPU in cores without trailing zeros: 0.004, 0.42, 1.5, 12.35. */
export const formatCores = (cores: number): string =>
Number.isFinite(cores) ? String(Number(cores.toFixed(cores < 1 ? 3 : 2))) : EMPTY_VALUE

/**
* A node's usage / allocatable fraction, or NaN when allocatable was not collected
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/infra/overview/summaries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ describe("summarizePlanetScale", () => {
const { findings } = summarizePlanetScale([db("ledger", 2, 92), db("users", 0.1, null)])
expect(findings.map((f) => [f.tone, f.title])).toEqual([
["crit", "ledger storage at 92%"],
["warn", "ledger replica 2.0s behind primary"],
["warn", "ledger replica 2.00s behind primary"],
])
})
})
Expand Down
41 changes: 30 additions & 11 deletions apps/web/src/components/infra/planetscale/metrics.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,51 @@
// Thresholds, tones, and formatting for the PlanetScale metrics.
//
// Every PlanetScale surface — the fleet table, the branch table, the detail
// charts, the stat rails, the service-map panel — reads its severity from here.
// These gauges arrive as 0–100 percentages; utilization divides them onto the
// shared 0–1 thresholds (`@maple/ui/lib/utilization`) so an 85% reads the same
// colour here as on every other infra gauge.
// Every PlanetScale surface (the fleet table, the branch table, the detail charts, the toolbar
// stats, the service-map panel) reads its severity from here. These gauges arrive as 0-100
// percentages; utilization divides them onto the shared 0-1 thresholds (`@maple/ui/lib/utilization`)
// so an 85% reads the same colour here as on every other infra gauge.

import type { Tone } from "../severity-tokens"
import { formatDuration } from "@maple/ui/lib/format"
import { type Tone, VALUE_TONE } from "../severity-tokens"
import { utilizationLevel } from "@maple/ui/lib/utilization"

/** Utilization percentage (0–100) → tone, on the shared fraction thresholds. Shared by CPU, memory, and storage. */
/** Metrics missing for the window sort below every real value. */
export const MISSING = Number.NEGATIVE_INFINITY

/** Utilization percentage (0-100) to tone, on the shared fraction thresholds. Shared by CPU, memory, and storage. */
export function utilizationTone(percent: number): Tone {
const level = utilizationLevel(percent / 100)
return level === "ok" ? "neutral" : level
}

/** Replica lag in seconds → tone. A second of lag is already worth noticing. */
/** Replica lag in seconds to tone. A second of lag is already worth noticing. */
export function lagTone(seconds: number): Tone {
if (!Number.isFinite(seconds)) return "neutral"
if (seconds > 10) return "crit"
if (seconds > 1) return "warn"
return "neutral"
}

export const utilizationClass = (percent: number): string | undefined => {
const tone = utilizationTone(percent)
return tone === "neutral" ? undefined : VALUE_TONE[tone]
}

export const lagClass = (seconds: number): string | undefined => {
const tone = lagTone(seconds)
return tone === "neutral" ? undefined : VALUE_TONE[tone]
}

/** Replica lag reads in seconds once it is a real problem, milliseconds while it isn't. */
export const formatLag = (seconds: number) =>
seconds >= 1 ? `${seconds.toFixed(1)}s` : `${Math.round(seconds * 1000)}ms`
export const formatLag = (seconds: number): string => formatDuration(seconds * 1000)

/** Storage used as a whole percentage — the number people quote about a full disk. */
/** Storage used as a whole percentage: the number people quote about a full disk. */
export const formatStoragePercent = (percent: number) =>
`${percent >= 99.5 || percent < 10 ? percent.toFixed(1) : percent.toFixed(0)}%`

/** A branch state worth showing: anything but ready/active, lowercased. */
export const abnormalState = (state: string | null): string | null => {
if (state === null) return null
const normalized = state.toLowerCase()
return normalized === "ready" || normalized === "active" ? null : normalized
}
3 changes: 0 additions & 3 deletions apps/web/src/components/infra/railway/format.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,2 @@
export const formatCores = (value: number) =>
`${value.toLocaleString(undefined, { maximumFractionDigits: value < 1 ? 3 : 2 })} vCPU`

/** Share of the limit as a percentage, or null when Railway reported no limit. */
export const shareOfLimit = (value: number, limit: number) => (limit > 0 ? (value / limit) * 100 : null)
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
// (same shape as branch-selection.ts).

import type { V2PlanetScaleIntegration } from "@maple/domain/http/v2"
import { countLabel } from "@maple/ui/lib/format"

/** Where branch metrics come from. Legacy wire values ("oauth", "missing") mean the API poller. */
export type MetricsAuth = "api" | "service_token"
Expand Down Expand Up @@ -107,8 +108,6 @@ export interface PlanetScaleSetup {
/** What the inventory found, when it has loaded. */
export type InventoryCounts = Readonly<{ databases: number; branches: number }>

const plural = (count: number, noun: string, nouns: string) => `${count} ${count === 1 ? noun : nouns}`

/**
* Derive Go live's checks from the status the page already fetches.
* Everything here is a function of `status` and the inventory counts: no extra requests, no local state.
Expand Down Expand Up @@ -169,7 +168,7 @@ export function derivePlanetScaleSetup(
"Resources found",
inventory === null
? "Databases and branches listed"
: `${plural(inventory.databases, "database", "databases")}, ${plural(inventory.branches, "branch", "branches")}`,
: `${countLabel(inventory.databases, "database")}, ${countLabel(inventory.branches, "branch", "branches")}`,
)

const metricsTitle = viaApi ? "First poll, branch metrics" : "First scrape, branch metrics"
Expand Down Expand Up @@ -219,7 +218,7 @@ export function derivePlanetScaleSetup(
"webhooks",
"done",
"Webhooks registered",
plural(webhooks.databases, "database", "databases"),
countLabel(webhooks.databases, "database"),
)
: webhooks.state === "pending"
? check(
Expand Down
18 changes: 5 additions & 13 deletions apps/web/src/page/agent-sessions/list/view-toolbar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { cn } from "@maple/ui/lib/utils"
import type { Html, HtmlBuilder } from "foldkit/html"
import { refreshButton } from "../../../ui/refresh-button"
import { switchControl } from "../../../ui/switch"
import { toolbarStat } from "../../kit/list-toolbar"
import { Message } from "./message"
import type { Model } from "./model"
import { searchInput } from "../../../ui/search-input"
Expand All @@ -28,19 +29,10 @@ const toolbarSearch = (h: H, model: Model): Html =>

/** `ToolbarStat`: "6 of 1,666" when the total is known and larger, else "6 shown". */
const stat = (h: H, value: number, total: number | undefined): Html =>
h.span(
[h.Class("flex items-center gap-1.5 whitespace-nowrap text-sm")],
total !== undefined && total > value
? [
h.span([h.Class("font-medium tabular-nums")], [value.toLocaleString()]),
h.span([h.Class("text-muted-foreground")], ["of"]),
h.span([h.Class("tabular-nums text-muted-foreground")], [total.toLocaleString()]),
]
: [
h.span([h.Class("font-medium tabular-nums")], [value.toLocaleString()]),
h.span([h.Class("text-muted-foreground")], ["shown"]),
],
)
toolbarStat(h, {
value: value.toLocaleString(),
label: total !== undefined && total > value ? `of ${total.toLocaleString()}` : "shown",
})

const tooltipTrigger = <M>(h: HtmlBuilder<M>, id: string) => [
h.DataAttribute("base-ui-tooltip-trigger", ""),
Expand Down
6 changes: 2 additions & 4 deletions apps/web/src/page/agent-sessions/session/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Command } from "foldkit"
import { toLoadFailure } from "../../../data/load-failure"
import { TimeWindow } from "../../../data/time-range"
import { detectModels } from "../kit/detected-models"
import { writeClipboard } from "../../kit/clipboard"
import { readLocale } from "../../kit/locale"
import { Message, SpanScope } from "./message"
import { parseMarkdownTexts } from "../../kit/message-response"
Expand Down Expand Up @@ -116,10 +117,7 @@ export const RunCodeAction = Command.define("RunAgentSessionCodeAction", {
messages: [Message.CompletedCodeAction],
execute: ({ action, code, language }) =>
(action === "copy"
? Effect.tryPromise(() => navigator.clipboard.writeText(code)).pipe(
Effect.as(true),
Effect.orElseSucceed(() => false),
)
? writeClipboard(code)
: Effect.sync(() => {
const url = URL.createObjectURL(new Blob([code], { type: "text/plain" }))
const anchor = document.createElement("a")
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/page/agent-sessions/session/view-rail.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { formatCost, type SessionSummary } from "@maple/agent-sessions"
import { formatNumber, formatPercent } from "@maple/ui/lib/format"
import { formatNumber, formatPercent, pluralize } from "@maple/ui/lib/format"
import { formatSessionDuration } from "@maple/ui/lib/replay-format"
import { cn } from "@maple/ui/lib/utils"
import type { Html, HtmlBuilder } from "foldkit/html"
Expand Down Expand Up @@ -124,7 +124,7 @@ const costSection = (h: H, summary: SessionSummary, detect: (model: string) => D
" ",
String(model.llmCalls),
" ",
model.llmCalls === 1 ? "call" : "calls",
pluralize(model.llmCalls, "call"),
],
),
],
Expand Down
21 changes: 12 additions & 9 deletions apps/web/src/page/agent-sessions/session/view-toolbar.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Effect, Stream } from "effect"
import { Mount } from "foldkit"
import type { Html, HtmlBuilder } from "foldkit/html"
import { button } from "../../../ui/button"
import { icon } from "../../../ui/icon"
import { label } from "../../../ui/label"
import * as Popover from "../../../ui/popover"
Expand Down Expand Up @@ -113,15 +114,17 @@ const displayOptions = (h: H, model: Model, view: SessionView): Html => {
h.div(
[h.Class("contents")],
withTrigger(
h.button(
[
h.Type("button"),
h.AriaLabel(`Display options, ${on} of ${rows.length} shown`),
h.Class(
"inline-flex h-7 cursor-pointer items-center gap-1.5 rounded-md border border-input bg-background px-2 text-foreground text-xs transition-colors hover:bg-muted/64 data-[popup-open]:bg-muted/64",
),
...trigger,
],
button(
h,
{
variant: "outline",
size: "xs",
className: "data-[popup-open]:bg-accent/50",
attributes: [
h.AriaLabel(`Display options, ${on} of ${rows.length} shown`),
...trigger,
],
},
[
icon(h, "SlidersIcon", { size: 13, className: "text-muted-foreground" }),
"Display",
Expand Down
Loading
Loading