From b3a0214e5d9b7c99fed990ed0d4183034d1c0f3e Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 30 Oct 2026 19:14:58 +0100 Subject: [PATCH 1/2] refactor(web): migrate hand-rolled toolbars, dots, empty lines and helpers to shared primitives New page/kit/list-toolbar (listToolbar, toolbarCount, toolbarStat) replaces the copied list toolbars and count readouts; list pages that showed a StatRail (uptime, Supabase, PlanetScale) now show inline toolbar stats. Status dots, eyebrows, in-panel empty lines, not-found states, dividers, inline code, link buttons and pending buttons use their primitives, with data status on the tone palette instead of success/destructive. Duplicate helpers are gone: severity badges, clipboard writers, formatCores, the PlanetScale metrics copy, local plural and relative-time helpers. Integration reconnect cards share integrationNotice. --- .../components/infra/cloudflare/accounts.ts | 7 +- .../components/infra/cloudflare/list-model.ts | 14 + .../infra/cloudflare/worker-signals.ts | 3 +- apps/web/src/components/infra/format.test.ts | 4 +- apps/web/src/components/infra/format.ts | 12 +- .../components/infra/planetscale/metrics.ts | 37 +- .../src/components/infra/railway/format.ts | 3 - .../integrations/planetscale-setup-steps.ts | 7 +- .../page/agent-sessions/list/view-toolbar.ts | 18 +- .../page/agent-sessions/session/command.ts | 6 +- .../page/agent-sessions/session/view-rail.ts | 4 +- .../agent-sessions/session/view-toolbar.ts | 21 +- .../tool/error-modal-command.ts | 33 +- .../tool/view-error-modal-payload.ts | 17 +- .../tool/view-error-modal-rail.ts | 146 +++++-- .../tool/view-error-modal-samples.ts | 74 +++- .../agent-sessions/tool/view-error-modal.ts | 67 ++- .../page/agent-sessions/tool/view-errors.ts | 5 +- .../page/agent-sessions/tool/view-sessions.ts | 30 +- .../agent-sessions/tools-kit/error-parts.ts | 18 +- .../page/agent-sessions/tools-kit/table.ts | 2 +- .../page/agent-sessions/tools-kit/toolbar.ts | 2 +- apps/web/src/page/alerts/destination/view.ts | 4 +- .../src/page/alerts/editor/view-action-bar.ts | 21 +- .../src/page/alerts/editor/view-condition.ts | 74 +++- .../src/page/alerts/editor/view-details.ts | 18 +- .../web/src/page/alerts/editor/view-impact.ts | 79 ++-- .../page/alerts/editor/view-notifications.ts | 64 ++- .../page/alerts/hub/hub-uptime.scene.test.ts | 4 +- apps/web/src/page/alerts/hub/view-inbox.ts | 13 +- apps/web/src/page/alerts/hub/view-rules.ts | 59 +-- apps/web/src/page/alerts/hub/view-uptime.ts | 64 +-- .../web/src/page/alerts/incident/view-rail.ts | 53 ++- .../src/page/alerts/incident/view-timeline.ts | 33 +- apps/web/src/page/alerts/shared/rule-chart.ts | 7 +- apps/web/src/page/alerts/uptime-form/view.ts | 12 +- .../page/alerts/uptime-monitor/view-panels.ts | 17 +- apps/web/src/page/analytics/view-ai-parts.ts | 391 ++++++++++++------ apps/web/src/page/analytics/view-ai.ts | 49 ++- apps/web/src/page/anomalies/list/view.ts | 48 +-- .../page/code-review/kit/review-sheet-more.ts | 5 +- .../code-review/kit/review-sheet-sections.ts | 3 +- .../web/src/page/code-review/settings/view.ts | 117 ++---- .../web/src/page/dashboards/kit/tile-shell.ts | 4 +- .../page/dashboards/kit/time-range-label.ts | 23 +- .../web/src/page/dashboards/list/view-list.ts | 48 +-- apps/web/src/page/dashboards/list/view.ts | 4 +- .../src/page/dashboards/templates/derive.ts | 8 - .../page/dashboards/templates/view-panel.ts | 29 +- .../src/page/dashboards/view/view-history.ts | 101 ++++- apps/web/src/page/dashboards/view/view.ts | 27 +- .../src/page/dashboards/widget/view-rail.ts | 3 +- .../src/page/dashboards/widget/view-sql.ts | 49 ++- apps/web/src/page/dashboards/widget/view.ts | 32 +- apps/web/src/page/errors/hub/view-toolbar.ts | 87 ++-- apps/web/src/page/errors/hub/view.ts | 3 +- apps/web/src/page/errors/issue/view-panels.ts | 2 +- .../src/page/errors/issue/view-skeleton.ts | 23 +- .../src/page/infra-integrations/aws/derive.ts | 8 + .../infra-integrations/aws/list/view-table.ts | 18 +- .../page/infra-integrations/aws/list/view.ts | 16 +- .../cloudflare/list/view-accounts.ts | 16 +- .../cloudflare/list/view-tables.ts | 11 +- .../cloudflare/worker/view.ts | 21 +- .../cloudflare/zone/derive.ts | 16 +- .../cloudflare/zone/view.ts | 9 +- .../infra-integrations/gcp/list/view-band.ts | 14 +- .../gcp/list/view-project-menu.ts | 12 +- .../gcp/list/view-resources.ts | 27 +- .../page/infra-integrations/gcp/list/view.ts | 13 +- .../gcp/workload/view-rail.ts | 10 +- .../infra-integrations/gcp/workload/view.ts | 25 +- .../planetscale/db/view-breakdown.ts | 4 +- .../planetscale/list/view-table.ts | 4 +- .../planetscale/list/view.ts | 111 +++-- .../infra-integrations/planetscale/metrics.ts | 44 -- .../railway/service/service.scene.test.ts | 12 +- .../railway/service/view.ts | 18 +- .../supabase/list/supabase.scene.test.ts | 2 +- .../supabase/list/view-bottom.ts | 16 +- .../infra-integrations/supabase/list/view.ts | 89 ++-- .../supabase/project/view-attention.ts | 11 +- .../supabase/project/view-callers.ts | 3 +- .../supabase/project/view-traffic.ts | 5 +- apps/web/src/page/infra/kit/page-parts.ts | 47 +-- .../src/page/infra/kubernetes/pod-table.ts | 96 +++-- .../src/page/infra/overview/view-findings.ts | 12 +- .../src/page/integrations/kit/count-chip.ts | 28 ++ apps/web/src/page/integrations/kit/notice.ts | 12 + .../integrations/view-cloudflare-board.ts | 31 +- .../src/page/integrations/view-cloudflare.ts | 24 +- .../page/integrations/view-flow-supabase.ts | 6 +- .../integrations/view-gcp-setup-methods.ts | 51 ++- .../page/integrations/view-gcp-workloads.ts | 82 ++-- apps/web/src/page/integrations/view-gcp.ts | 16 +- apps/web/src/page/integrations/view-github.ts | 24 +- .../src/page/integrations/view-planetscale.ts | 24 +- .../web/src/page/integrations/view-railway.ts | 24 +- .../src/page/integrations/view-supabase.ts | 39 +- .../investigations/conversation/command.ts | 9 +- .../conversation/kit/view-tool.ts | 2 +- apps/web/src/page/investigations/list/view.ts | 204 ++++----- apps/web/src/page/kit/clipboard.ts | 10 +- apps/web/src/page/kit/list-toolbar.ts | 70 ++++ apps/web/src/page/logs/detail/view-hero.ts | 29 +- apps/web/src/page/logs/detail/view-panels.ts | 5 +- .../src/page/onboarding/create-org-view.ts | 10 +- .../page/onboarding/quick-start-team-view.ts | 9 +- .../src/page/onboarding/quick-start-view.ts | 82 ++-- .../src/page/releases/detail/view-sections.ts | 23 +- apps/web/src/page/replays/detail/view-rail.ts | 48 +-- .../src/page/replays/detail/view-session.ts | 304 ++++++++++---- apps/web/src/page/replays/detail/view.ts | 35 +- apps/web/src/page/replays/kit/replay-meta.ts | 64 ++- .../src/page/replays/kit/replays-toolbar.ts | 28 +- apps/web/src/page/replays/kit/session-row.ts | 24 +- apps/web/src/page/replays/list/view.ts | 146 ++++--- apps/web/src/page/security/finding/view.ts | 3 +- .../src/page/security/kit/add-target-view.ts | 5 +- .../src/page/security/kit/findings-view.ts | 2 +- .../page/security/kit/spend-settings-view.ts | 4 +- .../src/page/security/run/view-findings.ts | 18 +- apps/web/src/page/security/run/view-header.ts | 12 +- apps/web/src/page/security/run/view-panels.ts | 20 +- apps/web/src/page/security/run/view.ts | 4 +- .../src/page/security/tabs/view-findings.ts | 62 ++- .../src/page/security/tabs/view-overview.ts | 19 +- apps/web/src/page/security/tabs/view-runs.ts | 70 ++-- .../src/page/service-map/view-db-sections.ts | 26 +- apps/web/src/page/service-map/view-scene3d.ts | 12 +- .../services/detail/endpoint-peek/view.ts | 135 +++--- .../src/page/services/detail/view-api-tab.ts | 388 ++++++++--------- apps/web/src/page/services/detail/view-api.ts | 24 +- .../src/page/services/detail/view-deploys.ts | 45 +- .../page/services/detail/view-footprint.ts | 12 +- apps/web/src/page/services/list/view-table.ts | 2 +- apps/web/src/page/settings/ai/view.ts | 14 +- apps/web/src/page/settings/audit-log/view.ts | 4 +- apps/web/src/page/settings/automation/view.ts | 15 +- .../src/page/settings/billing/view-cycle.ts | 3 +- .../src/page/settings/billing/view-plan.ts | 282 +++++++++---- .../src/page/settings/data-platform/view.ts | 10 +- .../src/page/settings/ingestion/view-rules.ts | 14 +- apps/web/src/page/settings/ingestion/view.ts | 25 +- .../src/page/settings/keys/view-reference.ts | 19 +- apps/web/src/page/settings/kit/save-bar.ts | 6 +- apps/web/src/page/settings/kit/section.ts | 4 +- apps/web/src/page/settings/mcp/view-tools.ts | 42 +- .../src/page/settings/setup-health/view.ts | 35 +- .../src/page/sources/detail/view-overview.ts | 13 +- apps/web/src/page/sources/list/view.ts | 76 +--- .../page/traces/detail/span-panel/command.ts | 8 +- .../traces/detail/span-panel/view-tabs.ts | 21 +- .../web/src/page/traces/detail/view-states.ts | 42 +- apps/web/src/service-map/map/loading.ts | 31 +- apps/web/src/service-map/trace-flow/view.ts | 3 +- 156 files changed, 3289 insertions(+), 2502 deletions(-) delete mode 100644 apps/web/src/page/infra-integrations/planetscale/metrics.ts create mode 100644 apps/web/src/page/integrations/kit/count-chip.ts create mode 100644 apps/web/src/page/integrations/kit/notice.ts create mode 100644 apps/web/src/page/kit/list-toolbar.ts diff --git a/apps/web/src/components/infra/cloudflare/accounts.ts b/apps/web/src/components/infra/cloudflare/accounts.ts index 47600ae350..a85f891ff1 100644 --- a/apps/web/src/components/infra/cloudflare/accounts.ts +++ b/apps/web/src/components/infra/cloudflare/accounts.ts @@ -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 @@ -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. @@ -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)}`, } } diff --git a/apps/web/src/components/infra/cloudflare/list-model.ts b/apps/web/src/components/infra/cloudflare/list-model.ts index 538ac6eb4f..c9828b7c77 100644 --- a/apps/web/src/components/infra/cloudflare/list-model.ts +++ b/apps/web/src/components/infra/cloudflare/list-model.ts @@ -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, @@ -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 + export function classifyZone(requests: number, serverErrorRate: number, clientErrorRate: number): ZoneHealth { if (requests < MIN_RATED_REQUESTS) return "lowVolume" if (serverErrorRate >= ERROR_RATE_WARN) return "serving5xx" @@ -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 + 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" diff --git a/apps/web/src/components/infra/cloudflare/worker-signals.ts b/apps/web/src/components/infra/cloudflare/worker-signals.ts index 460101750e..e57a5bed01 100644 --- a/apps/web/src/components/infra/cloudflare/worker-signals.ts +++ b/apps/web/src/components/infra/cloudflare/worker-signals.ts @@ -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 } @@ -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", })) } diff --git a/apps/web/src/components/infra/format.test.ts b/apps/web/src/components/infra/format.test.ts index cad71ed1cc..092d294ab3 100644 --- a/apps/web/src/components/infra/format.test.ts +++ b/apps/web/src/components/infra/format.test.ts @@ -59,8 +59,8 @@ 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") }) it("formats memory against allocatable", () => { diff --git a/apps/web/src/components/infra/format.ts b/apps/web/src/components/infra/format.ts index ef2cf881ad..b142978542 100644 --- a/apps/web/src/components/infra/format.ts +++ b/apps/web/src/components/infra/format.ts @@ -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. @@ -50,8 +50,14 @@ 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, comparable across a fleet's magnitudes: 0.004, 0.42, 3.92, 12. */ +export const formatCores = (cores: number): string => { + if (!Number.isFinite(cores)) return EMPTY_VALUE + if (cores === 0) return "0" + if (cores >= 10) return cores.toFixed(0) + if (cores >= 1) return cores.toFixed(2) + return cores.toFixed(3) +} /** * A node's usage / allocatable fraction, or NaN when allocatable was not collected diff --git a/apps/web/src/components/infra/planetscale/metrics.ts b/apps/web/src/components/infra/planetscale/metrics.ts index 184bdad6ac..cf66ed5c1a 100644 --- a/apps/web/src/components/infra/planetscale/metrics.ts +++ b/apps/web/src/components/infra/planetscale/metrics.ts @@ -1,21 +1,23 @@ // 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 { 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" @@ -23,10 +25,27 @@ export function lagTone(seconds: number): Tone { 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` -/** 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 +} diff --git a/apps/web/src/components/infra/railway/format.ts b/apps/web/src/components/infra/railway/format.ts index 90858d04e6..b1c486bd28 100644 --- a/apps/web/src/components/infra/railway/format.ts +++ b/apps/web/src/components/infra/railway/format.ts @@ -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) diff --git a/apps/web/src/components/integrations/planetscale-setup-steps.ts b/apps/web/src/components/integrations/planetscale-setup-steps.ts index 18e44718e8..4649fc5fcd 100644 --- a/apps/web/src/components/integrations/planetscale-setup-steps.ts +++ b/apps/web/src/components/integrations/planetscale-setup-steps.ts @@ -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" @@ -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. @@ -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" @@ -219,7 +218,7 @@ export function derivePlanetScaleSetup( "webhooks", "done", "Webhooks registered", - plural(webhooks.databases, "database", "databases"), + countLabel(webhooks.databases, "database"), ) : webhooks.state === "pending" ? check( diff --git a/apps/web/src/page/agent-sessions/list/view-toolbar.ts b/apps/web/src/page/agent-sessions/list/view-toolbar.ts index 3dd2df251a..0595d6f342 100644 --- a/apps/web/src/page/agent-sessions/list/view-toolbar.ts +++ b/apps/web/src/page/agent-sessions/list/view-toolbar.ts @@ -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" @@ -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 = (h: HtmlBuilder, id: string) => [ h.DataAttribute("base-ui-tooltip-trigger", ""), diff --git a/apps/web/src/page/agent-sessions/session/command.ts b/apps/web/src/page/agent-sessions/session/command.ts index 384cdc5e15..b5b6cd14cf 100644 --- a/apps/web/src/page/agent-sessions/session/command.ts +++ b/apps/web/src/page/agent-sessions/session/command.ts @@ -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" @@ -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") diff --git a/apps/web/src/page/agent-sessions/session/view-rail.ts b/apps/web/src/page/agent-sessions/session/view-rail.ts index 0b69d199a6..81c7ea3c65 100644 --- a/apps/web/src/page/agent-sessions/session/view-rail.ts +++ b/apps/web/src/page/agent-sessions/session/view-rail.ts @@ -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" @@ -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"), ], ), ], diff --git a/apps/web/src/page/agent-sessions/session/view-toolbar.ts b/apps/web/src/page/agent-sessions/session/view-toolbar.ts index fe7832cd06..7338106111 100644 --- a/apps/web/src/page/agent-sessions/session/view-toolbar.ts +++ b/apps/web/src/page/agent-sessions/session/view-toolbar.ts @@ -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" @@ -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", diff --git a/apps/web/src/page/agent-sessions/tool/error-modal-command.ts b/apps/web/src/page/agent-sessions/tool/error-modal-command.ts index 8a03f4ae1f..b18e65dc75 100644 --- a/apps/web/src/page/agent-sessions/tool/error-modal-command.ts +++ b/apps/web/src/page/agent-sessions/tool/error-modal-command.ts @@ -3,6 +3,7 @@ import { getAiToolErrorDetail, getAiToolErrorSamples } from "@maple/web-data/war import { Effect, Option, Schema, Stream } from "effect" import { Command, Dom } from "foldkit" import { toLoadFailure } from "../../../data/load-failure" +import { writeClipboard } from "../../kit/clipboard" import { Selection, toInput } from "../tools-kit/reads" import { Message, type ModalKey } from "./error-modal-model" @@ -11,7 +12,9 @@ export const FetchErrorDetail = Command.define("FetchAgentToolErrorDetail", { args: { key: Schema.String, selection: Selection, fingerprint: Schema.String }, messages: [Message.CompletedFetchDetail], execute: ({ key, selection, fingerprint }) => - getAiToolErrorDetail({ data: { ...toInput(selection), tool: selection.tool ?? "", fingerprint } }).pipe( + getAiToolErrorDetail({ + data: { ...toInput(selection), tool: selection.tool ?? "", fingerprint }, + }).pipe( Effect.mapError(toLoadFailure), Effect.result, Effect.map((result) => Message.CompletedFetchDetail({ key, result })), @@ -31,7 +34,14 @@ export const FetchErrorSamples = Command.define("FetchAgentToolErrorSamples", { messages: [Message.CompletedFetchSamples], execute: ({ key, selection, fingerprint, session, variant, before }) => getAiToolErrorSamples({ - data: { ...toInput(selection), tool: selection.tool ?? "", fingerprint, session, variant, before }, + data: { + ...toInput(selection), + tool: selection.tool ?? "", + fingerprint, + session, + variant, + before, + }, }).pipe( Effect.map((page) => ({ occurrences: page.occurrences, nextCursor: page.nextCursor })), Effect.mapError(toLoadFailure), @@ -43,20 +53,16 @@ export const FetchErrorSamples = Command.define("FetchAgentToolErrorSamples", { /** "Copy link": the page's own URL, which holds the group and its narrowing. */ export const CopyErrorLink = Command.define("CopyAgentToolErrorLink", { messages: [Message.CompletedCopyLink], - execute: Effect.tryPromise(() => navigator.clipboard.writeText(window.location.href)).pipe( - Effect.as(Message.CompletedCopyLink({ isCopied: true })), - Effect.orElseSucceed(() => Message.CompletedCopyLink({ isCopied: false })), - ), + execute: Effect.suspend(() => writeClipboard(window.location.href)).pipe( + Effect.map((isCopied) => Message.CompletedCopyLink({ isCopied })), + ), }) export const CopyErrorArguments = Command.define("CopyAgentToolErrorArguments", { args: { text: Schema.String }, messages: [Message.CompletedCopyArguments], execute: ({ text }) => - Effect.tryPromise(() => navigator.clipboard.writeText(text)).pipe( - Effect.as(Message.CompletedCopyArguments({ isCopied: true })), - Effect.orElseSucceed(() => Message.CompletedCopyArguments({ isCopied: false })), - ), + writeClipboard(text).pipe(Effect.map((isCopied) => Message.CompletedCopyArguments({ isCopied }))), }) /** `rowRefs.current.get(index)?.scrollIntoView({ block: "nearest" })`. */ @@ -78,7 +84,8 @@ const isTyping = (event: KeyboardEvent): boolean => event.metaKey || event.ctrlKey || event.altKey || - (event.target instanceof HTMLElement && event.target.closest("input, textarea, [contenteditable='true']") !== null) + (event.target instanceof HTMLElement && + event.target.closest("input, textarea, [contenteditable='true']") !== null) /** Where the open group sits in the Errors read: ↑/↓ step (and claim the key) only inside it. */ export type GroupPosition = Readonly<{ index: number; total: number }> @@ -112,7 +119,9 @@ export const modalKeys = (position: GroupPosition): Stream.Stream => type: "keydown", filterMapEvent: (event) => { const key = SAMPLE_KEYS.get(event.key) - return key === undefined || isTyping(event) ? Option.none() : Option.some(Message.PressedModalKey({ key })) + return key === undefined || isTyping(event) + ? Option.none() + : Option.some(Message.PressedModalKey({ key })) }, }), ) diff --git a/apps/web/src/page/agent-sessions/tool/view-error-modal-payload.ts b/apps/web/src/page/agent-sessions/tool/view-error-modal-payload.ts index cb4f98dbe7..ac07c0c128 100644 --- a/apps/web/src/page/agent-sessions/tool/view-error-modal-payload.ts +++ b/apps/web/src/page/agent-sessions/tool/view-error-modal-payload.ts @@ -9,6 +9,7 @@ import { } from "../../../lib/agent-sessions/tool-error-display" import { formatToolCount } from "@maple/web-data/lib/tool-analytics" import type { Html, HtmlBuilder } from "foldkit/html" +import { button } from "../../../ui/button" import { eyebrow } from "../../../ui/eyebrow" import { statusDot } from "../../../ui/status-dot" import { Message, type Model, type ToolErrorOccurrenceRow } from "./error-modal-model" @@ -192,14 +193,14 @@ const argumentsBlock = ( ...(lines === undefined ? [] : [ - h.button( - [ - h.Class( - "flex h-6 shrink-0 items-center rounded-md border border-border bg-card px-2 text-foreground transition-colors hover:bg-muted/50", - ), - h.Type("button"), - h.OnClick(Message.ToggledFullArguments()), - ], + button( + h, + { + variant: "outline", + size: "xs", + className: "text-2xs sm:text-2xs", + attributes: [h.OnClick(Message.ToggledFullArguments())], + }, [isFull ? "Shorten" : "Show full payload"], ), ]), diff --git a/apps/web/src/page/agent-sessions/tool/view-error-modal-rail.ts b/apps/web/src/page/agent-sessions/tool/view-error-modal-rail.ts index 4566b825b0..d6ed0c1f6d 100644 --- a/apps/web/src/page/agent-sessions/tool/view-error-modal-rail.ts +++ b/apps/web/src/page/agent-sessions/tool/view-error-modal-rail.ts @@ -23,13 +23,23 @@ type BreakdownRow = ErrorDetail["breakdown"][number] const RAIL_TITLE = "font-mono text-xs font-medium text-foreground" -const variantText = (h: H, message: string, difference: VariantDifference | undefined): ReadonlyArray => +const variantText = ( + h: H, + message: string, + difference: VariantDifference | undefined, +): ReadonlyArray => difference === undefined || difference.middle === "" ? [h.span([h.Class("truncate text-muted-foreground")], [unwrapToolErrorText(message).text])] : [ - h.span([h.Class("min-w-0 truncate whitespace-pre text-muted-foreground")], [difference.before]), + h.span( + [h.Class("min-w-0 truncate whitespace-pre text-muted-foreground")], + [difference.before], + ), h.span([h.Class("shrink-0 whitespace-pre font-medium text-foreground")], [difference.middle]), - h.span([h.Class("min-w-0 truncate whitespace-pre text-muted-foreground")], [difference.after]), + h.span( + [h.Class("min-w-0 truncate whitespace-pre text-muted-foreground")], + [difference.after], + ), ] /** The raw texts a group folded, where there is more than one; one narrows the samples. */ @@ -63,7 +73,7 @@ const variantsSection = ( h.AriaPressed(String(isSelected)), h.Class( cn( - "-mx-2 flex h-[30px] shrink-0 items-center gap-2.5 rounded-md px-2 text-left font-mono text-xs transition-colors", + "-mx-2 flex h-8 shrink-0 items-center gap-2.5 rounded-md px-2 text-left font-mono text-xs transition-colors", isSelected ? "bg-primary/10" : "hover:bg-muted/40", ), ), @@ -73,11 +83,24 @@ const variantsSection = ( ], [ h.span( - [h.Class("flex w-0 min-w-0 flex-1 items-baseline overflow-hidden whitespace-pre")], + [ + h.Class( + "flex w-0 min-w-0 flex-1 items-baseline overflow-hidden whitespace-pre", + ), + ], variantText(h, row.message, differences.get(row.message)), ), - meter(h, { value: row.calls, max, minVisible: 4, tone: "crit", className: "w-12 shrink-0 rounded-xs bg-muted" }), - h.span([h.Class("w-7 shrink-0 text-right tabular-nums text-foreground")], [formatToolCount(row.calls)]), + meter(h, { + value: row.calls, + max, + minVisible: 4, + tone: "crit", + className: "w-12 shrink-0 rounded-xs bg-muted", + }), + h.span( + [h.Class("w-7 shrink-0 text-right tabular-nums text-foreground")], + [formatToolCount(row.calls)], + ), ], ) }), @@ -101,10 +124,25 @@ const whereItHappens = (h: H, rows: ReadonlyArray, isLoading: bool [ h.span([h.Class(RAIL_TITLE)], ["Where it happens"]), ...(isLoading && rows.length === 0 - ? [h.div([h.Class("flex flex-col gap-2")], [skeleton(h, "h-3 w-3/4"), skeleton(h, "h-3 w-1/2")])] + ? [ + h.div( + [h.Class("flex flex-col gap-2")], + [skeleton(h, "h-3 w-3/4"), skeleton(h, "h-3 w-1/2")], + ), + ] : [ - breakdown(h, "Model", fold(rows, (row) => row.model), "unattributed"), - breakdown(h, "Service", fold(rows, (row) => row.service), "unknown"), + breakdown( + h, + "Model", + fold(rows, (row) => row.model), + "unattributed", + ), + breakdown( + h, + "Service", + fold(rows, (row) => row.service), + "unknown", + ), ]), ], ) @@ -121,11 +159,13 @@ export const sessionLink = ( [ // `{ tool, view, span }` in that order. h.Href( - `${hrefOf(AppRoute.AgentSession({ sessionId: options.sessionId, search: {} }))}${orderedSearch([ - ["tool", options.tool], - ["view", "trace"], - ["span", options.spanId], - ])}`, + `${hrefOf(AppRoute.AgentSession({ sessionId: options.sessionId, search: {} }))}${orderedSearch( + [ + ["tool", options.tool], + ["view", "trace"], + ["span", options.spanId], + ], + )}`, ), h.Title(options.sessionId), h.Class( @@ -173,7 +213,11 @@ const sessionsSection = (h: H, model: Model, inputs: ModalInputs, detail: ErrorD ), eyebrow( h, - { variant: "mono", as: "div", className: "flex h-[30px] shrink-0 items-center gap-3 border-y border-border px-4" }, + { + variant: "mono", + as: "div", + className: "flex h-8 shrink-0 items-center gap-3 border-y border-border px-4", + }, [ h.span([h.Class("grow")], ["Session"]), h.span([h.Class("w-10 shrink-0 text-right")], ["Hits"]), @@ -193,11 +237,23 @@ const sessionsSection = (h: H, model: Model, inputs: ModalInputs, detail: ErrorD [h.Class("w-10 shrink-0 text-right font-mono text-xs tabular-nums text-foreground")], [formatToolCount(group.calls)], ), - relativeTime(h, { value: group.lastSeen, nowMs, timeZone, variant: "orDate", tooltip: "title", className: LAST_CLASS }), + relativeTime(h, { + value: group.lastSeen, + nowMs, + timeZone, + variant: "orDate", + tooltip: "title", + className: LAST_CLASS, + }), ], ), ...(isLoading && detail.sessions.length === 0 - ? [h.div([h.Class("flex flex-col gap-1.5 px-4 py-3")], [skeleton(h, "h-10"), skeleton(h, "h-10")])] + ? [ + h.div( + [h.Class("flex flex-col gap-1.5 px-4 py-3")], + [skeleton(h, "h-10"), skeleton(h, "h-10")], + ), + ] : detail.sessions.map((row) => { const isSelected = row.sessionId === selected return h.div( @@ -211,7 +267,11 @@ const sessionsSection = (h: H, model: Model, inputs: ModalInputs, detail: ErrorD "absolute inset-0 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring", ), h.Type("button"), - h.OnClick(Message.ClickedSession({ session: isSelected ? undefined : row.sessionId })), + h.OnClick( + Message.ClickedSession({ + session: isSelected ? undefined : row.sessionId, + }), + ), ], [], ), @@ -224,16 +284,37 @@ const sessionsSection = (h: H, model: Model, inputs: ModalInputs, detail: ErrorD className: "max-w-full font-mono text-xs", }), h.span( - [h.Class("w-full truncate font-mono text-2xs leading-3.5 text-muted-foreground/70")], - [row.service === "" ? (row.agentName === "" ? row.vendorId : row.agentName) : row.service], + [ + h.Class( + "w-full truncate font-mono text-2xs leading-3.5 text-muted-foreground/70", + ), + ], + [ + row.service === "" + ? row.agentName === "" + ? row.vendorId + : row.agentName + : row.service, + ], ), ], ), h.span( - [h.Class("w-10 shrink-0 text-right font-mono text-xs tabular-nums text-foreground")], + [ + h.Class( + "w-10 shrink-0 text-right font-mono text-xs tabular-nums text-foreground", + ), + ], [formatToolCount(row.hits)], ), - relativeTime(h, { value: row.lastSeen, nowMs, timeZone, variant: "orDate", tooltip: "title", className: LAST_CLASS }), + relativeTime(h, { + value: row.lastSeen, + nowMs, + timeZone, + variant: "orDate", + tooltip: "title", + className: LAST_CLASS, + }), ], ) })), @@ -242,7 +323,12 @@ const sessionsSection = (h: H, model: Model, inputs: ModalInputs, detail: ErrorD [ h.span( [h.Class("text-muted-foreground")], - ["Showing ", formatToolCount(detail.sessions.length), " of ", formatToolCount(group.sessions)], + [ + "Showing ", + formatToolCount(detail.sessions.length), + " of ", + formatToolCount(group.sessions), + ], ), h.span([h.Class("text-muted-foreground/60")], ["by hits"]), ], @@ -283,13 +369,21 @@ export const railView = ( const fold = (rows: ReadonlyArray, keyOf: (row: BreakdownRow) => string) => [ ...rows - .reduce((totals, row) => totals.set(keyOf(row), (totals.get(keyOf(row)) ?? 0) + row.calls), new Map()) + .reduce( + (totals, row) => totals.set(keyOf(row), (totals.get(keyOf(row)) ?? 0) + row.calls), + new Map(), + ) .entries(), ] .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) .slice(0, 3) -const breakdown = (h: H, label: string, rows: ReadonlyArray, blank: string): Html => { +const breakdown = ( + h: H, + label: string, + rows: ReadonlyArray, + blank: string, +): Html => { const max = rows[0]?.[1] ?? 1 return h.div( [h.Class("flex flex-col gap-1.5")], diff --git a/apps/web/src/page/agent-sessions/tool/view-error-modal-samples.ts b/apps/web/src/page/agent-sessions/tool/view-error-modal-samples.ts index a27468d59c..2a95dfaced 100644 --- a/apps/web/src/page/agent-sessions/tool/view-error-modal-samples.ts +++ b/apps/web/src/page/agent-sessions/tool/view-error-modal-samples.ts @@ -8,6 +8,7 @@ import { AsyncData } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { AppRoute, hrefOf } from "../../../route" import { orderedSearch } from "../../../route/tanstack-search" +import { emptyMessage } from "../../../ui/empty" import { errorState } from "../../../ui/error-state" import { icon } from "../../../ui/icon" import { iconButton } from "../../../ui/icon-button" @@ -30,7 +31,10 @@ const footer = (h: H, model: Model, shown: number, total: number): Html => { return h.div( [h.Class("flex h-9 items-center gap-[9px] px-6 font-mono text-xs")], [ - h.span([h.Class("text-muted-foreground")], ["Showing ", formatToolCount(shown), " of ", formatToolCount(total)]), + h.span( + [h.Class("text-muted-foreground")], + ["Showing ", formatToolCount(shown), " of ", formatToolCount(total)], + ), ...(paging === "more" ? [ h.button( @@ -48,7 +52,9 @@ const footer = (h: H, model: Model, shown: number, total: number): Html => { ? [ h.button( [ - h.Class("text-severity-error transition-colors hover:text-foreground"), + h.Class( + "text-severity-error transition-colors hover:text-foreground", + ), h.Type("button"), h.OnClick(Message.ClickedLoadMore()), ], @@ -83,12 +89,7 @@ export const samplesPane = ( ), ] : rows.length === 0 - ? [ - h.div( - [h.Class("px-6 py-12 text-center font-mono text-xs text-muted-foreground")], - ["No samples in the selected window."], - ), - ] + ? [emptyMessage(h, {}, ["No samples in the selected window."])] : rows.map((row, index) => sampleRow(h, model, inputs, row, { index, @@ -100,7 +101,11 @@ export const samplesPane = ( [h.Class("flex min-w-0 grow flex-col overflow-hidden")], [ h.div( - [h.Class("flex h-12 shrink-0 items-center justify-between gap-4 border-b border-border px-6 font-mono")], + [ + h.Class( + "flex h-12 shrink-0 items-center justify-between gap-4 border-b border-border px-6 font-mono", + ), + ], [ h.div( [h.Class("flex items-baseline gap-2")], @@ -108,14 +113,22 @@ export const samplesPane = ( h.span([h.Class("text-xs font-medium text-foreground")], ["Samples"]), h.span( [h.Class("text-2xs leading-3.5 tabular-nums text-muted-foreground/70")], - [formatToolCount(options.total), " failed ", pluralize(options.total, "call"), " · newest first"], + [ + formatToolCount(options.total), + " failed ", + pluralize(options.total, "call"), + " · newest first", + ], ), ], ), h.div( [h.Class("flex items-center gap-2")], [ - h.span([h.Class("pr-1 text-2xs text-muted-foreground/60 max-md:hidden")], ["j / k"]), + h.span( + [h.Class("pr-1 text-2xs text-muted-foreground/60 max-md:hidden")], + ["j / k"], + ), iconButton( h, { @@ -130,7 +143,11 @@ export const samplesPane = ( ), h.span( [h.Class("w-[72px] text-center text-xs tabular-nums text-foreground")], - [rows.length === 0 ? "0" : String(current + 1), " of ", formatToolCount(options.total)], + [ + rows.length === 0 ? "0" : String(current + 1), + " of ", + formatToolCount(options.total), + ], ), iconButton( h, @@ -165,7 +182,11 @@ const sampleRow = ( ): Html => { const time = formatTimestampInTimezone(row.timestamp, { timeZone: inputs.timeZone }) return h.div( - [h.Key(row.spanId), h.DataAttribute("slot", "tool-error-sample"), h.Class("border-b border-border/50")], + [ + h.Key(row.spanId), + h.DataAttribute("slot", "tool-error-sample"), + h.Class("border-b border-border/50"), + ], [ h.div( [ @@ -215,7 +236,11 @@ const sampleRow = ( ? [] : [ h.span( - [h.Class("hidden min-w-0 shrink truncate text-xs text-muted-foreground/70 xl:block")], + [ + h.Class( + "hidden min-w-0 shrink truncate text-xs text-muted-foreground/70 xl:block", + ), + ], [row.service], ), ]), @@ -236,7 +261,11 @@ const sampleRow = ( ? [] : [ h.span( - [h.Class("hidden shrink-0 text-2xs tabular-nums text-muted-foreground/70 md:block")], + [ + h.Class( + "hidden shrink-0 text-2xs tabular-nums text-muted-foreground/70 md:block", + ), + ], ["args ", formatToolCount(row.argumentsBytes), " B"], ), ]), @@ -248,16 +277,21 @@ const sampleRow = ( [ // The call's own timestamp narrows the trace read to one partition. h.Href( - `${hrefOf(AppRoute.Trace({ traceId: row.traceId, search: {} }))}${orderedSearch([ - ["spanId", row.spanId], - ["t", formatWarehouseDateTime(row.timestamp)], - ])}`, + `${hrefOf(AppRoute.Trace({ traceId: row.traceId, search: {} }))}${orderedSearch( + [ + ["spanId", row.spanId], + ["t", formatWarehouseDateTime(row.timestamp)], + ], + )}`, ), h.Class( "relative flex shrink-0 items-center gap-[5px] text-2xs text-muted-foreground transition-colors hover:text-foreground", ), ], - ["Open trace", icon(h, "ExternalLinkIcon", { size: 11, attributes: [h.AriaHidden(true)] })], + [ + "Open trace", + icon(h, "ExternalLinkIcon", { size: 11, attributes: [h.AriaHidden(true)] }), + ], ), ], ), diff --git a/apps/web/src/page/agent-sessions/tool/view-error-modal.ts b/apps/web/src/page/agent-sessions/tool/view-error-modal.ts index 639e8c419d..8a1d5de893 100644 --- a/apps/web/src/page/agent-sessions/tool/view-error-modal.ts +++ b/apps/web/src/page/agent-sessions/tool/view-error-modal.ts @@ -1,4 +1,4 @@ -import { formatPercent } from "@maple/ui/lib/format" +import { formatPercent, pluralize } from "@maple/ui/lib/format" import { cn } from "@maple/ui/lib/utils" import { errorTextTokens, @@ -16,6 +16,7 @@ import type { Html, HtmlBuilder } from "foldkit/html" import { AppRoute, hrefOf } from "../../../route" import type * as Dialog from "../../../ui/dialog" import * as DialogView from "../../../ui/dialog-view" +import { linkButton } from "../../../ui/button" import { eyebrow } from "../../../ui/eyebrow" import { icon } from "../../../ui/icon" import { iconButton } from "../../../ui/icon-button" @@ -52,7 +53,13 @@ export const detailOf = (model: Model): ErrorDetail => export const differencesOf = (detail: ErrorDetail): ReadonlyMap => { const found = variantDifferences(detail.variants.map((row) => unwrapToolErrorText(row.message).text)) - return new Map(detail.variants.flatMap((row, index) => Option.toArray(Option.fromUndefinedOr(found[index])).map((difference) => [row.message, difference] as const))) + return new Map( + detail.variants.flatMap((row, index) => + Option.toArray(Option.fromUndefinedOr(found[index])).map( + (difference) => [row.message, difference] as const, + ), + ), + ) } const facts = (h: H, items: ReadonlyArray): ReadonlyArray => @@ -60,7 +67,9 @@ const facts = (h: H, items: ReadonlyArray): ReadonlyArray = h.span( [h.Class("contents")], [ - ...(index > 0 ? [h.span([h.AriaHidden(true), h.Class("text-muted-foreground/40")], ["·"])] : []), + ...(index > 0 + ? [h.span([h.AriaHidden(true), h.Class("text-muted-foreground/40")], ["·"])] + : []), typeof item === "string" ? h.span([h.Class("text-muted-foreground")], [item]) : item, ], ), @@ -88,14 +97,16 @@ const headingOf = (h: H, group: Prepared, text: string): Html => { const headerFacts = (h: H, inputs: ModalInputs): ReadonlyArray => { const { group, timeZone } = inputs - const share = formatPercent(inputs.toolFailures > 0 ? group.calls / inputs.toolFailures : 0, { floor: 0.01 }) + const share = formatPercent(inputs.toolFailures > 0 ? group.calls / inputs.toolFailures : 0, { + floor: 0.01, + }) return facts(h, [ h.span( [h.Class("text-foreground")], - [formatToolCount(group.calls), " failed call", group.calls === 1 ? "" : "s"], + [formatToolCount(group.calls), ` failed ${pluralize(group.calls, "call")}`], ), `${share} of failures`, - `${formatToolCount(group.sessions)} session${group.sessions === 1 ? "" : "s"}`, + `${formatToolCount(group.sessions)} ${pluralize(group.sessions, "session")}`, `first seen ${formatTimestampInTimezone(group.firstSeen, { timeZone })}`, `last seen ${formatTimestampInTimezone(group.lastSeen, { timeZone })}`, ]) @@ -107,7 +118,11 @@ const headerLead = (h: H, inputs: ModalInputs): Html => { [h.Class("flex min-w-0 flex-1 flex-col gap-2.5")], [ h.div( - [h.Class("flex flex-wrap items-center gap-2.5 font-mono text-2xs leading-3.5 text-muted-foreground/80")], + [ + h.Class( + "flex flex-wrap items-center gap-2.5 font-mono text-2xs leading-3.5 text-muted-foreground/80", + ), + ], [ h.span([], [inputs.tool]), h.span([h.Class("text-muted-foreground/40")], ["/"]), @@ -118,7 +133,11 @@ const headerLead = (h: H, inputs: ModalInputs): Html => { ? [] : [ h.span( - [h.Class("ml-1 rounded-sm border border-border px-[5px] text-2xs text-muted-foreground/70")], + [ + h.Class( + "ml-1 rounded-sm border border-border px-[5px] text-2xs text-muted-foreground/70", + ), + ], ["error.type ", group.errorType], ), ]), @@ -126,7 +145,11 @@ const headerLead = (h: H, inputs: ModalInputs): Html => { ), headingOf(h, group, unwrapToolErrorText(group.message).text), h.div( - [h.Class("flex flex-wrap items-center gap-x-3.5 gap-y-1 pt-1 font-mono text-2xs leading-3.5")], + [ + h.Class( + "flex flex-wrap items-center gap-x-3.5 gap-y-1 pt-1 font-mono text-2xs leading-3.5", + ), + ], headerFacts(h, inputs), ), ], @@ -150,7 +173,10 @@ const headerTrend = (h: H, inputs: ModalInputs): Html => { h.div( [h.Class("flex items-baseline justify-between font-mono")], [ - eyebrow(h, { variant: "mono", className: "leading-3.5" }, ["Failed calls / ", bucket.unit]), + eyebrow(h, { variant: "mono", className: "leading-3.5" }, [ + "Failed calls / ", + bucket.unit, + ]), h.span( [h.Class("text-2xs leading-3.5 tabular-nums text-muted-foreground/70")], ["max ", formatToolCount(Math.max(0, ...group.spark))], @@ -304,15 +330,22 @@ const headerActions = (h: H, model: Model, inputs: ModalInputs): Html => { className: NAV_BUTTON, attributes: [h.OnClick(Message.ClickedCopyLink())], }, - [icon(h, model.linkCopy === "copied" ? "CheckIcon" : "LinkIcon", { attributes: [h.AriaHidden(true)] })], - ), - h.a( [ - h.Href(hrefOf(AppRoute.AgentSessions({ search: { tools: [inputs.tool], hasErrors: true } }))), - h.Class( - "inline-flex h-[30px] items-center gap-2 rounded-md border border-border bg-card px-2.5 font-mono text-2xs text-foreground transition-colors hover:bg-muted/50", - ), + icon(h, model.linkCopy === "copied" ? "CheckIcon" : "LinkIcon", { + attributes: [h.AriaHidden(true)], + }), ], + ), + linkButton( + h, + { + variant: "outline", + size: "sm", + className: "font-mono text-2xs sm:text-2xs", + href: hrefOf( + AppRoute.AgentSessions({ search: { tools: [inputs.tool], hasErrors: true } }), + ), + }, [ "Open in Sessions", icon(h, "ExternalLinkIcon", { diff --git a/apps/web/src/page/agent-sessions/tool/view-errors.ts b/apps/web/src/page/agent-sessions/tool/view-errors.ts index 38b9c63f20..d50d94a5d0 100644 --- a/apps/web/src/page/agent-sessions/tool/view-errors.ts +++ b/apps/web/src/page/agent-sessions/tool/view-errors.ts @@ -323,7 +323,7 @@ const showMore = (h: H, hidden: ReadonlyArray, isExpanded: boolean): H [ isExpanded ? "Show fewer errors" - : `Show ${formatToolCount(hidden.length)} more error${hidden.length === 1 ? "" : "s"}`, + : `Show ${formatToolCount(hidden.length)} more ${pluralize(hidden.length, "error")}`, ], ), ...(isExpanded @@ -333,8 +333,7 @@ const showMore = (h: H, hidden: ReadonlyArray, isExpanded: boolean): H [h.Class("text-muted-foreground/70")], [ formatToolCount(hiddenCalls), - " failed call", - hiddenCalls === 1 ? "" : "s", + ` failed ${pluralize(hiddenCalls, "call")}`, ...(hiddenCalls === hidden.length && hidden.length > 1 ? [", 1 each"] : []), ], ), diff --git a/apps/web/src/page/agent-sessions/tool/view-sessions.ts b/apps/web/src/page/agent-sessions/tool/view-sessions.ts index d552a5aa6d..b25572daae 100644 --- a/apps/web/src/page/agent-sessions/tool/view-sessions.ts +++ b/apps/web/src/page/agent-sessions/tool/view-sessions.ts @@ -13,6 +13,7 @@ import { orderedSearch } from "../../../route/tanstack-search" import { badge } from "../../../ui/display" import { emptyMessage } from "../../../ui/empty" import { errorState } from "../../../ui/error-state" +import { linkButton } from "../../../ui/button" import { icon } from "../../../ui/icon" import { skeletonList } from "../../../ui/skeleton" import { statusDot } from "../../../ui/status-dot" @@ -42,12 +43,14 @@ const sessionRow = (h: H, session: SessionRow, tool: string, nowMs: number, time ), // `{ ...sessionLinkWindow(session), tool, view }` in that order. h.Href( - `${hrefOf(AppRoute.AgentSession({ sessionId: session.sessionId, search: {} }))}${orderedSearch([ - ["t", sessionLinkWindow(session).t], - ["end", sessionLinkWindow(session).end], - ["tool", tool], - ["view", "trace"], - ])}`, + `${hrefOf(AppRoute.AgentSession({ sessionId: session.sessionId, search: {} }))}${orderedSearch( + [ + ["t", sessionLinkWindow(session).t], + ["end", sessionLinkWindow(session).end], + ["tool", tool], + ["view", "trace"], + ], + )}`, ), ], [ @@ -154,13 +157,14 @@ export const sessionsPanel = (h: H, inputs: SessionsPanelInputs): Html => ), ], ), - h.a( - [ - h.Class( - "inline-flex h-7 shrink-0 items-center gap-2 rounded-md border border-border bg-card px-2.5 font-mono text-2xs text-foreground transition-colors hover:bg-muted/50", - ), - h.Href(hrefOf(AppRoute.AgentSessions({ search: { tools: [inputs.tool] } }))), - ], + linkButton( + h, + { + variant: "outline", + size: "xs", + className: "font-mono text-2xs sm:text-2xs", + href: hrefOf(AppRoute.AgentSessions({ search: { tools: [inputs.tool] } })), + }, [ "Open in Sessions", icon(h, "ExternalLinkIcon", { diff --git a/apps/web/src/page/agent-sessions/tools-kit/error-parts.ts b/apps/web/src/page/agent-sessions/tools-kit/error-parts.ts index b42df13039..32f3bb0ac7 100644 --- a/apps/web/src/page/agent-sessions/tools-kit/error-parts.ts +++ b/apps/web/src/page/agent-sessions/tools-kit/error-parts.ts @@ -1,4 +1,4 @@ -import { formatErrorRate } from "@maple/ui/lib/format" +import { formatErrorRate, pluralize } from "@maple/ui/lib/format" import { formatRelativeTimeOrDate } from "@maple/ui/lib/time-format" import { TONE_TEXT } from "@maple/ui/lib/tone" import { cn } from "@maple/ui/lib/utils" @@ -72,14 +72,18 @@ const HEADING = "font-mono text-lg leading-6.5 font-semibold tracking-tight text /** `ErrorTextHeading`: the message whole, its path on a line of its own in the error colour. */ export const errorTextHeading = (h: HtmlBuilder, tokens: ReadonlyArray): Html => { const pathIndex = tokens.findIndex((token) => token.kind === "path") - if (pathIndex < 0) return h.h2([h.Class(cn(HEADING, "line-clamp-3 break-words"))], headingTokens(h, tokens)) + if (pathIndex < 0) + return h.h2([h.Class(cn(HEADING, "line-clamp-3 break-words"))], headingTokens(h, tokens)) const path = tokens[pathIndex] const atIndex = tokens[pathIndex - 1]?.kind === "at" ? pathIndex - 1 : pathIndex const tail = tokens.slice(pathIndex + 1) return h.h2( [h.Class("flex min-w-0 flex-col gap-0.5")], [ - h.span([h.Class(cn(HEADING, "line-clamp-2 break-words"))], headingTokens(h, tokens.slice(0, atIndex))), + h.span( + [h.Class(cn(HEADING, "line-clamp-2 break-words"))], + headingTokens(h, tokens.slice(0, atIndex)), + ), h.span( [h.Class("flex min-w-0 flex-wrap items-baseline gap-3 pl-6 font-mono text-lg leading-6.5")], [ @@ -93,7 +97,11 @@ export const errorTextHeading = (h: HtmlBuilder, tokens: ReadonlyArray 0 ? [ h.span( - [h.Class("line-clamp-2 break-words pl-6 font-mono text-ui leading-4.5 text-foreground/80")], + [ + h.Class( + "line-clamp-2 break-words pl-6 font-mono text-ui leading-4.5 text-foreground/80", + ), + ], headingTokens(h, tail), ), ] @@ -182,7 +190,7 @@ export const failureStatusLine = ( scope === "tool" ? `No failures since ${dateTime(status.since)}` : `None since ${shortDate(status.since)}`, - `· ${formatToolCount(status.callsSince)} call${status.callsSince === 1 ? "" : "s"} since`, + `· ${formatToolCount(status.callsSince)} ${pluralize(status.callsSince, "call")} since`, ] : status.kind === "quiet" ? [ diff --git a/apps/web/src/page/agent-sessions/tools-kit/table.ts b/apps/web/src/page/agent-sessions/tools-kit/table.ts index ec5bb516fd..c3d13796b9 100644 --- a/apps/web/src/page/agent-sessions/tools-kit/table.ts +++ b/apps/web/src/page/agent-sessions/tools-kit/table.ts @@ -55,7 +55,7 @@ export const toolTableHead = ( className?: string, ): Html => h.div( - [h.Class(cn("flex h-[30px] shrink-0 items-center gap-3 border-y border-border px-2.5", className))], + [h.Class(cn("flex h-8 shrink-0 items-center gap-3 border-y border-border px-2.5", className))], children, ) diff --git a/apps/web/src/page/agent-sessions/tools-kit/toolbar.ts b/apps/web/src/page/agent-sessions/tools-kit/toolbar.ts index 84c5ffe637..7bf1cbfcc9 100644 --- a/apps/web/src/page/agent-sessions/tools-kit/toolbar.ts +++ b/apps/web/src/page/agent-sessions/tools-kit/toolbar.ts @@ -48,7 +48,7 @@ export const facetSelect = (h: HtmlBuilder, config: FacetSelectConfig): size: "sm", isDisabled: config.options.length === 0, className: cn( - "h-[30px] gap-2 rounded-md px-2.5 font-mono text-xs", + "gap-2 font-mono text-xs", isSet ? "border-primary/40 bg-primary/10 text-primary [&_svg]:text-primary" : "bg-card text-foreground", diff --git a/apps/web/src/page/alerts/destination/view.ts b/apps/web/src/page/alerts/destination/view.ts index 56623f5e3d..2b79287d37 100644 --- a/apps/web/src/page/alerts/destination/view.ts +++ b/apps/web/src/page/alerts/destination/view.ts @@ -423,8 +423,8 @@ const telegramFields = (h: H, model: Model, isEditing: boolean): ReadonlyArray): Htm toParentMessage: (message: Tooltip.Message) => Message.GotIssuesTooltipMessage({ message }), viewInputs: { toContent: () => [ - h.ul([h.Class("space-y-0.5 text-xs")], issues.map((issue) => h.li([h.Key(issue)], [`• ${issue}`]))), + h.ul( + [h.Class("space-y-0.5 text-xs")], + issues.map((issue) => h.li([h.Key(issue)], [`• ${issue}`])), + ), ], toView: ({ trigger, portal }) => h.span( @@ -45,7 +49,8 @@ const issuesDetails = (h: H, model: BarView, issues: ReadonlyArray): Htm { variant: "link", size: "xs", - className: "text-muted-foreground underline decoration-dotted hover:text-foreground", + className: + "text-muted-foreground underline decoration-dotted hover:text-foreground", attributes: trigger(), }, ["details"], @@ -70,7 +75,10 @@ const validationSummary = (h: H, model: BarView, issues: ReadonlyArray): [h.Class(cn("flex min-w-0 items-center gap-1.5 truncate text-xs text-muted-foreground"))], [ icon(h, "CircleWarningIcon", { size: 14, className: "shrink-0 text-severity-warn" }), - h.span([h.Class("truncate")], ["Fix before saving: ", h.span([h.Class("text-foreground")], [summary])]), + h.span( + [h.Class("truncate")], + ["Fix before saving: ", h.span([h.Class("text-foreground")], [summary])], + ), ...(hiddenCount > 0 ? [issuesDetails(h, model, issues)] : []), ], ) @@ -114,7 +122,10 @@ const bar = (h: H, model: BarView, form: RuleForm, timing: AlertQueryTiming | nu h.div( [h.Class("flex min-w-0 flex-col gap-0.5")], [ - h.p([h.Class("truncate text-sm text-foreground"), h.Title(summary)], [summary]), + h.p( + [h.Class("truncate text-sm text-foreground"), h.Title(summary)], + [summary], + ), validationSummary(h, model, issues), ], ), @@ -131,7 +142,7 @@ const bar = (h: H, model: BarView, form: RuleForm, timing: AlertQueryTiming | nu [ changeCount === 0 ? "No changes" - : `${changeCount} unsaved ${changeCount === 1 ? "change" : "changes"}`, + : `${changeCount} unsaved ${pluralize(changeCount, "change")}`, ], ), ]), diff --git a/apps/web/src/page/alerts/editor/view-condition.ts b/apps/web/src/page/alerts/editor/view-condition.ts index 3a59462fcb..1b8e94c47b 100644 --- a/apps/web/src/page/alerts/editor/view-condition.ts +++ b/apps/web/src/page/alerts/editor/view-condition.ts @@ -1,6 +1,10 @@ import type { AlertQueryTiming, AlertSeverity } from "@maple/domain/http" import { alertIntervalsFitting } from "@maple/query-engine/datetime" -import { comparatorSupportsTiers, isRangeComparator, thresholdUnitLabel } from "@maple/web-data/alerts/form-utils" +import { + comparatorSupportsTiers, + isRangeComparator, + thresholdUnitLabel, +} from "@maple/web-data/alerts/form-utils" import { cn } from "@maple/ui/lib/utils" import { createLazy, type Html, type HtmlBuilder } from "foldkit/html" import { button } from "../../../ui/button" @@ -44,7 +48,12 @@ type ConditionView = Pick & /** `COMPARATOR_ITEMS`, in `comparatorLabels` order. */ const COMPARATOR_ITEMS = [...COMPARATOR_WORDS].map(([value, label]) => ({ value, label })) -const SEVERITY_OPTIONS: ReadonlyArray<{ value: AlertSeverity; label: string; selected: string; dot: string }> = [ +const SEVERITY_OPTIONS: ReadonlyArray<{ + value: AlertSeverity + label: string + selected: string + dot: string +}> = [ { value: "warning", label: "Warning", @@ -91,11 +100,16 @@ const severityPicker = (h: H, value: AlertSeverity): Html => { cn( "inline-flex h-8 cursor-pointer select-none items-center justify-center gap-1.5 rounded-sm border border-transparent px-2.5 text-xs font-medium", "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", - isSelected ? option.selected : "text-muted-foreground hover:bg-muted/40 hover:text-foreground", + isSelected + ? option.selected + : "text-muted-foreground hover:bg-muted/40 hover:text-foreground", ), { children: [ - statusDot(h, { tone: "custom", className: isSelected ? option.dot : "bg-muted-foreground/40" }), + statusDot(h, { + tone: "custom", + className: isSelected ? option.dot : "bg-muted-foreground/40", + }), option.label, ], }, @@ -114,7 +128,10 @@ const tierLabel = (h: H, severity: AlertSeverity): Html => { h.span( [ h.Class( - cn("inline-flex h-9 items-center gap-1.5 rounded-md border px-2.5 text-xs font-medium", option?.selected), + cn( + "inline-flex h-9 items-center gap-1.5 rounded-md border px-2.5 text-xs font-medium", + option?.selected, + ), ), ], [statusDot(h, { tone: "custom", className: option?.dot ?? "" }), option?.label ?? ""], @@ -189,7 +206,7 @@ const criticalRow = ( { variant: "ghost", size: "sm", - className: "mt-6 h-7 px-2 text-xs text-muted-foreground", + className: "mt-6 px-2 text-xs text-muted-foreground", attributes: [h.OnClick(Message.ClickedRemoveCriticalTier())], }, ["Remove"], @@ -226,7 +243,12 @@ const windowDescription = (form: RuleForm): string => { } /** "Check every": the interval, and for raw SQL the query time each one allows. */ -const intervalPicker = (h: H, model: ConditionView, form: RuleForm, timing: AlertQueryTiming | null): Html => { +const intervalPicker = ( + h: H, + model: ConditionView, + form: RuleForm, + timing: AlertQueryTiming | null, +): Html => { const isRaw = form.signalType === "raw_query" const options = intervalOptions(form, isRaw ? timing : null) const selected = intervalRadioValue(form.evaluationIntervalMinutes) @@ -246,7 +268,9 @@ const intervalPicker = (h: H, model: ConditionView, form: RuleForm, timing: Aler h.div( [ h.AriaLabelledBy("rule-interval-label"), - h.Class("grid grid-cols-[repeat(auto-fill,minmax(5.5rem,1fr))] gap-1 rounded-md bg-muted/30 p-0.5"), + h.Class( + "grid grid-cols-[repeat(auto-fill,minmax(5.5rem,1fr))] gap-1 rounded-md bg-muted/30 p-0.5", + ), h.DataAttribute("filled", ""), h.Role("radiogroup"), ], @@ -296,7 +320,10 @@ const intervalPicker = (h: H, model: ConditionView, form: RuleForm, timing: Aler } const slowdownOf = (model: Model): Pick => { - const row = model.editingRuleId === null ? undefined : model.rules?.find((rule) => rule.id === model.editingRuleId) + const row = + model.editingRuleId === null + ? undefined + : model.rules?.find((rule) => rule.id === model.editingRuleId) return { slowedMinutes: row?.slowed_interval_minutes ?? null } } @@ -306,7 +333,11 @@ const slowdownNotice = (h: H, model: ConditionView, form: RuleForm): ReadonlyArr ? [] : [ h.div( - [h.Class("flex flex-wrap items-center justify-between gap-2 rounded-md border border-severity-warn/40 bg-severity-warn/10 px-3 py-2 text-xs")], + [ + h.Class( + "flex flex-wrap items-center justify-between gap-2 rounded-md border border-severity-warn/40 bg-severity-warn/10 px-3 py-2 text-xs", + ), + ], [ h.span( [], @@ -319,7 +350,11 @@ const slowdownNotice = (h: H, model: ConditionView, form: RuleForm): ReadonlyArr { variant: "outline", size: "xs", - attributes: [h.OnClick(Message.ClickedUseInterval({ minutes: String(model.slowedMinutes) }))], + attributes: [ + h.OnClick( + Message.ClickedUseInterval({ minutes: String(model.slowedMinutes) }), + ), + ], }, [`Keep ${model.slowedMinutes} min`], ), @@ -494,7 +529,7 @@ const thresholdsSection = (h: H, model: ConditionView, form: RuleForm, saved: Ru { variant: "ghost", size: "sm", - className: "-ml-2 h-7 px-2 text-xs text-muted-foreground", + className: "-ml-2 px-2 text-xs text-muted-foreground", attributes: [h.OnClick(Message.ClickedAddCriticalTier())], }, [icon(h, "PlusIcon", { size: 12 }), "Add critical tier"], @@ -505,16 +540,13 @@ const thresholdsSection = (h: H, model: ConditionView, form: RuleForm, saved: Ru ) } - const slot = createLazy() -const viewOf = stableSlice( - (model: Model): ConditionView => ({ - mountedForm: model.mountedForm, - savedForm: model.savedForm, - comparatorSelect: model.selects[COMPARATOR_SELECT_ID], - ...slowdownOf(model), - }), -) +const viewOf = stableSlice((model: Model): ConditionView => ({ + mountedForm: model.mountedForm, + savedForm: model.savedForm, + comparatorSelect: model.selects[COMPARATOR_SELECT_ID], + ...slowdownOf(model), +})) /** The condition card under the chart; `form` is the evaluated draft (`evaluatedFormOf`). */ export const conditionFields = (h: H, model: Model, form: RuleForm): Html => diff --git a/apps/web/src/page/alerts/editor/view-details.ts b/apps/web/src/page/alerts/editor/view-details.ts index 34cfc1b2f0..4088b49e80 100644 --- a/apps/web/src/page/alerts/editor/view-details.ts +++ b/apps/web/src/page/alerts/editor/view-details.ts @@ -66,10 +66,17 @@ const tagInput = (h: H, model: DetailsView, form: RuleForm): Html => { ), h.OnBlur(Message.BlurredTagInput()), ...(form.tags.length === 0 ? [h.Placeholder("prod, payments, team-checkout…")] : []), - h.Class("min-w-[80px] flex-1 bg-transparent leading-6 outline-none placeholder:text-muted-foreground/70"), + h.Class( + "min-w-[80px] flex-1 bg-transparent leading-6 outline-none placeholder:text-muted-foreground/70", + ), ]), ...(available.length > 0 - ? [h.datalist([h.Id(TAG_LIST_ID)], available.map((tag) => h.option([h.Value(tag)], [])))] + ? [ + h.datalist( + [h.Id(TAG_LIST_ID)], + available.map((tag) => h.option([h.Value(tag)], [])), + ), + ] : []), ], ) @@ -91,8 +98,8 @@ const nameField = (h: H, model: DetailsView, form: RuleForm): Html => { h, { variant: "ghost", - size: "sm", - className: "h-6 px-1.5 text-2xs", + size: "xs", + className: "px-1.5 text-2xs", attributes: [h.OnClick(Message.ClickedSuggestName())], }, ["Suggest: ", suggestion ?? ""], @@ -177,4 +184,5 @@ const viewOf = stableSlice((model: Model) => ) /** `DetailsSection`: the name with a one-click suggestion, tags, and notes behind a button. */ -export const detailsSection = (h: H, model: Model, form: RuleForm): Html => slot(details, [h, viewOf(model), form]) +export const detailsSection = (h: H, model: Model, form: RuleForm): Html => + slot(details, [h, viewOf(model), form]) diff --git a/apps/web/src/page/alerts/editor/view-impact.ts b/apps/web/src/page/alerts/editor/view-impact.ts index 9af1357fc9..a096f5ec4f 100644 --- a/apps/web/src/page/alerts/editor/view-impact.ts +++ b/apps/web/src/page/alerts/editor/view-impact.ts @@ -1,3 +1,4 @@ +import { countLabel, pluralize } from "@maple/ui/lib/format" import { type AlertRulePreviewResponse, UNGROUPED_GROUP_KEY } from "@maple/domain/http" import { breachStatsFromPreview, formatBreachDuration } from "@maple/web-data/alerts/breach-stats" import { displaySignalType, formatSignalValue } from "@maple/web-data/alerts/form-utils" @@ -19,16 +20,29 @@ import { isEvaluationChanged } from "./plan" type H = HtmlBuilder -const figure = (h: H, before: string, after: string, caption: string, tone: "good" | "warn" | "neutral"): Html => +const figure = ( + h: H, + before: string, + after: string, + caption: string, + tone: "good" | "warn" | "neutral", +): Html => h.div( [], [ h.p( - [h.Class("flex items-baseline gap-2 text-2xl font-medium tabular-nums")], + [h.Class("flex items-baseline gap-2 text-2xl font-semibold tabular-nums")], [ h.span([h.Class("text-muted-foreground line-through decoration-1")], [before]), h.span( - [h.Class(cn(tone === "good" && "text-severity-info", tone === "warn" && "text-severity-warn"))], + [ + h.Class( + cn( + tone === "good" && "text-severity-info", + tone === "warn" && "text-severity-warn", + ), + ), + ], [after], ), ], @@ -67,7 +81,7 @@ export const editImpact = (h: H, model: ImpactView, form: RuleForm): Html => { "Before and after · ", String(changes.length), " unsaved ", - changes.length === 1 ? "change" : "changes", + pluralize(changes.length, "change"), ]), replay.isLoading || draft.isLoading ? skeleton(h, "h-14 w-full") @@ -77,7 +91,7 @@ export const editImpact = (h: H, model: ImpactView, form: RuleForm): Html => { figure( h, String(before.breachCount), - `${after.breachCount} ${after.breachCount === 1 ? "alert" : "alerts"}`, + countLabel(after.breachCount, "alert"), delta === 0 ? "Same number of alerts in this window" : `${Math.abs(delta)} ${delta < 0 ? "fewer" : "more"} than the saved rule`, @@ -94,7 +108,10 @@ export const editImpact = (h: H, model: ImpactView, form: RuleForm): Html => { ), h.div( [h.Class("space-y-1")], - [fireRail(h, "before", replay.preview, domain, "muted"), fireRail(h, "after", draft.preview, domain, "crit")], + [ + fireRail(h, "before", replay.preview, domain, "muted"), + fireRail(h, "after", draft.preview, domain, "crit"), + ], ), h.ul( [h.Class("flex flex-wrap gap-1.5 text-xs")], @@ -103,7 +120,10 @@ export const editImpact = (h: H, model: ImpactView, form: RuleForm): Html => { [h.Key(change.field), h.Class("rounded-sm border bg-muted/30 px-2 py-1")], [ h.span([h.Class("text-muted-foreground")], [change.field, " "]), - h.span([h.Class("font-mono text-muted-foreground line-through")], [change.before]), + h.span( + [h.Class("font-mono text-muted-foreground line-through")], + [change.before], + ), h.span([h.Class("text-muted-foreground")], [" → "]), h.span([h.Class("font-mono")], [change.after]), ], @@ -132,30 +152,40 @@ const clearedBlock = ( String(cleared.length), " open", " ", - cleared.length === 1 ? "incident" : "incidents", + pluralize(cleared.length, "incident"), " would clear under this edit", ], ), h.ul( [h.Class("mt-0.5 text-muted-foreground")], [ - ...cleared.slice(0, 3).map((incident) => - h.li( - [h.Key(incident.id)], - [ - incident.groupKey === null || incident.groupKey === UNGROUPED_GROUP_KEY - ? incident.ruleName - : incident.groupKey, - ...(incident.lastObservedValue === null - ? [] - : [` at ${formatSignalValue(display, incident.lastObservedValue)}`]), - ], + ...cleared + .slice(0, 3) + .map((incident) => + h.li( + [h.Key(incident.id)], + [ + incident.groupKey === null || + incident.groupKey === UNGROUPED_GROUP_KEY + ? incident.ruleName + : incident.groupKey, + ...(incident.lastObservedValue === null + ? [] + : [ + ` at ${formatSignalValue(display, incident.lastObservedValue)}`, + ]), + ], + ), ), - ), - ...(cleared.length > 3 ? [h.li([], ["and ", String(cleared.length - 3), " more"])] : []), + ...(cleared.length > 3 + ? [h.li([], ["and ", String(cleared.length - 3), " more"])] + : []), ], ), - h.p([h.Class("mt-0.5 text-muted-foreground")], ["Takes effect on the next check after saving."]), + h.p( + [h.Class("mt-0.5 text-muted-foreground")], + ["Takes effect on the next check after saving."], + ), ], ), ] @@ -182,7 +212,10 @@ const fireRail = ( [ h.span([h.Class("text-right text-2xs text-muted-foreground")], [label]), h.div( - [h.Class("relative h-2 rounded-sm bg-muted/50"), h.AriaLabel(`${label}: ${marks.length} alerts`)], + [ + h.Class("relative h-2 rounded-sm bg-muted/50"), + h.AriaLabel(`${label}: ${marks.length} alerts`), + ], marks.map((mark) => h.span([ h.Key(mark.key), diff --git a/apps/web/src/page/alerts/editor/view-notifications.ts b/apps/web/src/page/alerts/editor/view-notifications.ts index b894f29cda..679d981636 100644 --- a/apps/web/src/page/alerts/editor/view-notifications.ts +++ b/apps/web/src/page/alerts/editor/view-notifications.ts @@ -40,7 +40,13 @@ const BODY_PLACEHOLDER = [ const tierHeading = (h: H, severity: "warning" | "critical", text: string): Html => h.p( [h.Class("flex items-center gap-1.5 text-xs font-medium text-muted-foreground")], - [statusDot(h, { tone: "custom", className: severity === "critical" ? "bg-severity-error" : "bg-severity-warn" }), text], + [ + statusDot(h, { + tone: "custom", + className: severity === "critical" ? "bg-severity-error" : "bg-severity-warn", + }), + text, + ], ) /** `MessageTemplate`: collapsed until opened, or open when the rule has one. */ @@ -66,10 +72,19 @@ const messageTemplate = (h: H, model: NotificationsView, form: NotificationsForm [ h.span( [h.Class("flex items-center gap-1.5")], - [icon(h, isOpen ? "ChevronDownIcon" : "ChevronRightIcon", { size: 12 }), "Message template"], + [ + icon(h, isOpen ? "ChevronDownIcon" : "ChevronRightIcon", { size: 12 }), + "Message template", + ], ), ...(hasTemplate && !isOpen - ? [badge(h, { pill: true, size: "xs", className: "bg-primary/10 text-primary" }, ["Customized"])] + ? [ + badge( + h, + { pill: true, size: "xs", className: "bg-primary/10 text-primary" }, + ["Customized"], + ), + ] : []), ], ), @@ -94,7 +109,9 @@ const messageTemplate = (h: H, model: NotificationsView, form: NotificationsForm attributes: [ h.Placeholder(TITLE_PLACEHOLDER), h.Value(form.notificationTitle), - h.OnInput((value) => Message.ChangedField({ field: "notificationTitle", value })), + h.OnInput((value) => + Message.ChangedField({ field: "notificationTitle", value }), + ), ], }), ]), @@ -108,7 +125,9 @@ const messageTemplate = (h: H, model: NotificationsView, form: NotificationsForm h.AriaLabelledBy(bodyIds.label), h.Placeholder(BODY_PLACEHOLDER), h.Rows(4), - h.OnInput((value) => Message.ChangedField({ field: "notificationBody", value })), + h.OnInput((value) => + Message.ChangedField({ field: "notificationBody", value }), + ), ], }), ]), @@ -142,7 +161,7 @@ const notifications = (h: H, model: NotificationsView, form: NotificationsForm): variant: "ghost", size: "sm", isLoading: model.isSendingTest, - className: "-my-1 h-7 px-2 text-xs", + className: "-my-1 px-2 text-xs", attributes: [h.OnClick(Message.ClickedSendTest())], }, [icon(h, "PaperPlaneIcon", { size: 12 }), "Send test"], @@ -161,14 +180,30 @@ const notifications = (h: H, model: NotificationsView, form: NotificationsForm): [h.Class("space-y-1.5")], [ tierHeading(h, "warning", "Warning"), - destinationList(h, model, "warning", form.destinationIds, destinations), + destinationList( + h, + model, + "warning", + form.destinationIds, + destinations, + ), ], ), h.div( [h.Class("space-y-1.5")], [ - tierHeading(h, "critical", "Critical, on top of the warning destinations"), - destinationList(h, model, "critical", escalation.destinationIds, destinations), + tierHeading( + h, + "critical", + "Critical, on top of the warning destinations", + ), + destinationList( + h, + model, + "critical", + escalation.destinationIds, + destinations, + ), ], ), ], @@ -194,7 +229,8 @@ const variables = (h: H): Html => { variant: "outline", size: "xs", - className: "font-mono text-3xs text-muted-foreground hover:text-foreground sm:text-3xs", + className: + "font-mono text-3xs text-muted-foreground hover:text-foreground sm:text-3xs", attributes: [h.OnClick(Message.ClickedTemplateVariable({ key: variable.key }))], }, [variable.key], @@ -221,7 +257,13 @@ const viewOf = stableSlice((model: Model) => ]), ) const formOf = stableSlice((form: RuleForm) => - Struct.pick(form, ["comparator", "escalation", "destinationIds", "notificationTitle", "notificationBody"]), + Struct.pick(form, [ + "comparator", + "escalation", + "destinationIds", + "notificationTitle", + "notificationBody", + ]), ) /** "Who gets notified": one list per tier, Send test while anything is selected, and the template. */ diff --git a/apps/web/src/page/alerts/hub/hub-uptime.scene.test.ts b/apps/web/src/page/alerts/hub/hub-uptime.scene.test.ts index fad09d10bd..a1cdc29106 100644 --- a/apps/web/src/page/alerts/hub/hub-uptime.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub-uptime.scene.test.ts @@ -165,8 +165,8 @@ describe("alerts hub, Uptime tab", () => { scene( { update: pageUpdate, view }, given(withMonitors(THREE)), - expectView(text("1 up · 1 down · 1 paused")).toExist(), - expectView(text("payments-api for 6m")).toExist(), + expectView(text(/^3\s*monitors$/)).toExist(), + expectView(text(/^1\s*down$/)).toExist(), expectView(text("Down 6m")).toExist(), expectView(text("Paused")).toExist(), expectView(role("link", { name: "payments-api" })).toHaveAttr( diff --git a/apps/web/src/page/alerts/hub/view-inbox.ts b/apps/web/src/page/alerts/hub/view-inbox.ts index 150b59d815..b9ec236a8d 100644 --- a/apps/web/src/page/alerts/hub/view-inbox.ts +++ b/apps/web/src/page/alerts/hub/view-inbox.ts @@ -22,6 +22,7 @@ import { } from "@maple/web-data/alerts/overview-derive" import type { Html, HtmlBuilder } from "foldkit/html" import { badge } from "../../../ui/display" +import { emptyMessage } from "../../../ui/empty" import { eyebrow } from "../../../ui/eyebrow" import { icon } from "../../../ui/icon" import { rowLink } from "../../../ui/table" @@ -272,10 +273,7 @@ const fireStrip = ( ], ), shown.length === 0 - ? h.p( - [h.Class("border-t py-4 text-sm text-muted-foreground")], - ["No rule fired in the last 24 hours."], - ) + ? emptyMessage(h, { className: "border-t" }, ["No rule fired in the last 24 hours."]) : h.ul( [h.Class("divide-y border-y")], shown.map((lane) => fireLaneRow(h, lane)), @@ -444,10 +442,9 @@ const headroomList = (h: HtmlBuilder, rows: ReadonlyArray, n ], ), shown.length === 0 - ? h.p( - [h.Class("border-t py-4 text-sm text-muted-foreground")], - ["No rule has reported a value yet. The first checks land within a minute."], - ) + ? emptyMessage(h, { className: "border-t" }, [ + "No rule has reported a value yet. The first checks land within a minute.", + ]) : h.ul( [h.Class("divide-y border-y")], shown.map((row) => headroomRow(h, row, now)), diff --git a/apps/web/src/page/alerts/hub/view-rules.ts b/apps/web/src/page/alerts/hub/view-rules.ts index 3543dad59a..e502f922d3 100644 --- a/apps/web/src/page/alerts/hub/view-rules.ts +++ b/apps/web/src/page/alerts/hub/view-rules.ts @@ -23,6 +23,7 @@ import type { MenuEntry } from "../../../ui/menu-view" import { rowActionsMenu } from "../../../ui/row-actions-menu" import { statusDot } from "../../../ui/status-dot" import { ROW_LINK_LIFT, rowLink } from "../../../ui/table" +import { listToolbar, toolbarCount } from "../../kit/list-toolbar" import { filteredEmpty } from "../../kit/signal-empty-state" import { ruleHref } from "../shared/links" import { isoMs } from "../shared/time" @@ -127,36 +128,38 @@ export const rulesTab = (h: H, model: Model, inputs: RulesTabInputs): Html => { return h.div( [h.Class("space-y-4")], [ - h.div( - [h.Class("flex flex-wrap items-center gap-2")], - [ - rulesSearch(h, model.rulesQuery), - ...BUCKETS.map((bucket) => - filterChip( - h, - { - isPressed: status === bucket.value, - onClick: Message.ClickedStatusFilter({ bucket: bucket.value }), - tone: bucket.tone, - hasDot: true, - count: String(bucketCounts.get(bucket.value) ?? 0), - }, - [bucket.label], + listToolbar(h, { + className: "pb-0", + leading: h.div( + [h.Class("flex flex-wrap items-center gap-2")], + [ + rulesSearch(h, model.rulesQuery), + ...BUCKETS.map((bucket) => + filterChip( + h, + { + isPressed: status === bucket.value, + onClick: Message.ClickedStatusFilter({ bucket: bucket.value }), + tone: bucket.tone, + hasDot: true, + count: String(bucketCounts.get(bucket.value) ?? 0), + }, + [bucket.label], + ), ), - ), - h.span( - [h.Class("ml-auto font-mono text-xs text-muted-foreground")], - [ - countLabel(rules.length, "rule"), - " · ", - String(totalFired), - " fired in ", - String(FIRED_WINDOW_DAYS), - "d", - ], - ), + ], + ), + trailing: [ + toolbarCount(h, [ + countLabel(rules.length, "rule"), + " · ", + String(totalFired), + " fired in ", + String(FIRED_WINDOW_DAYS), + "d", + ]), ], - ), + }), ...(model.hasToggleFailed ? [ alert(h, { variant: "crit", size: "sm" }, [ diff --git a/apps/web/src/page/alerts/hub/view-uptime.ts b/apps/web/src/page/alerts/hub/view-uptime.ts index 9d3db205ee..63f105bb4b 100644 --- a/apps/web/src/page/alerts/hub/view-uptime.ts +++ b/apps/web/src/page/alerts/hub/view-uptime.ts @@ -1,4 +1,4 @@ -import { countLabel } from "@maple/ui/lib/format" +import { countLabel, pluralize } from "@maple/ui/lib/format" import { cn } from "@maple/ui/lib/utils" import { Option } from "effect" import { AsyncData } from "foldkit" @@ -19,14 +19,12 @@ import { segmentedSelect } from "../../../ui/segmented-select" import { skeleton } from "../../../ui/skeleton" import { ROW_LINK_LIFT, rowLink } from "../../../ui/table" import { emptyActions, filteredEmpty } from "../../kit/signal-empty-state" -import { statRail, statRailItem, statRailLoading } from "../../kit/stat-rail" -import { isoMs } from "../shared/time" +import { listToolbar, toolbarStat } from "../../kit/list-toolbar" import { monitorHref, newMonitorHref } from "../shared/uptime-commands" import { displayStatus, formatMs, formatShare, - formatSpan, KIND_LABEL, matchesQuery, latencyPercentiles, @@ -49,7 +47,7 @@ import { type ServiceNamespaces, } from "./uptime-groups" -// The hub's Uptime tab: the KPI rail, the filters and the monitor table. +// The hub's Uptime tab: the filters with the readout, and the monitor table. type H = HtmlBuilder @@ -95,39 +93,22 @@ const uptimeEmptyState = (h: H, serviceName: string | undefined, isAdmin: boolea "py-12", ) -const kpis = (h: H, monitors: ReadonlyArray, nowMs: number): Html => { +/** The window's figures as the toolbar readout: the total, what is down now and the recent uptime. */ +const readout = (h: H, monitors: ReadonlyArray): ReadonlyArray => { const summary = summarize(monitors) - const down = monitors.filter((monitor) => displayStatus(monitor) === "down") - const firstDown = down[0] - return statRail(h, { columns: 3 }, [ - statRailItem(h, { - eyebrow: "Monitors", - value: String(summary.total), - compact: true, - subline: `${summary.up} up · ${summary.down} down · ${summary.paused} paused`, - }), - statRailItem(h, { - eyebrow: "Down now", + return [ + toolbarStat(h, { value: String(summary.total), label: pluralize(summary.total, "monitor") }), + toolbarStat(h, { value: String(summary.down), - tone: summary.down > 0 ? "crit" : "neutral", - compact: true, - subline: - firstDown === undefined - ? "Every active check passes" - : down.length > 1 - ? `${firstDown.name} and ${down.length - 1} more` - : firstDown.state.status_changed_at === null - ? firstDown.name - : `${firstDown.name} for ${formatSpan(nowMs - isoMs(firstDown.state.status_changed_at))}`, + label: "down", + dot: summary.down > 0 ? "crit" : undefined, }), - statRailItem(h, { - eyebrow: "Uptime, recent checks", + toolbarStat(h, { value: formatShare(summary.share), - tone: summary.share !== null && summary.share < 1 ? "warn" : "neutral", - compact: true, - subline: "Across the last checks of every active monitor", + label: "uptime, recent checks", + dot: summary.share !== null && summary.share < 1 ? "warn" : undefined, }), - ]) + ] } const statusSegments = (h: H, model: Model, monitors: ReadonlyArray): Html => { @@ -153,9 +134,10 @@ const statusSegments = (h: H, model: Model, monitors: ReadonlyArray): Html => - h.div( - [h.Class("flex flex-wrap items-center gap-2")], - [ + listToolbar(h, { + leading: statusSegments(h, model, monitors), + className: "pb-0", + trailing: [ searchInput(h, { id: "uptime-filter", value: model.uptimeQuery, @@ -165,7 +147,6 @@ const toolbar = (h: H, model: Model, monitors: ReadonlyArray): Ht onClear: Message.ChangedUptimeQuery({ value: "" }), className: "w-64", }), - statusSegments(h, model, monitors), nativeSelect( h, { @@ -183,8 +164,9 @@ const toolbar = (h: H, model: Model, monitors: ReadonlyArray): Ht }, KIND_FILTERS.map((kind) => nativeOption(kind.value, kind.label)), ), + h.div([h.Class("flex items-center gap-3")], readout(h, monitors)), ], - ) + }) const rowMenu = (h: H, model: Model, monitor: UptimeMonitor): Html => { const menuId = monitorMenuId(monitor.id) @@ -390,7 +372,6 @@ const loaded = ( return h.div( [h.Class("flex flex-col gap-4")], [ - kpis(h, monitors, nowMs), toolbar(h, model, monitors), rows.length === 0 ? filteredEmpty(h, { @@ -431,10 +412,7 @@ export const uptimeTab = (h: H, model: Model, nowMs: number, pinnedNamespace: st } const loadingState = (h: H): Html => - h.div( - [h.Class("flex flex-col gap-4")], - [statRailLoading(h, 3, 3), skeleton(h, "h-9 w-2/3"), skeleton(h, "h-64 w-full")], - ) + h.div([h.Class("flex flex-col gap-4")], [skeleton(h, "h-9 w-2/3"), skeleton(h, "h-64 w-full")]) export const deleteMonitorDialog = (h: H, model: Model): Html => { const monitors = AsyncData.getOrElse(model.monitors.data, () => []) diff --git a/apps/web/src/page/alerts/incident/view-rail.ts b/apps/web/src/page/alerts/incident/view-rail.ts index 59961f47c6..1f6f6a2497 100644 --- a/apps/web/src/page/alerts/incident/view-rail.ts +++ b/apps/web/src/page/alerts/incident/view-rail.ts @@ -1,3 +1,4 @@ +import { pluralize } from "@maple/ui/lib/format" import type { AlertIncidentDocument, AlertRuleDocument } from "@maple/domain/http" import { formatWarehouseDateTime } from "@maple/query-engine" import { cn } from "@maple/ui/lib/utils" @@ -56,17 +57,28 @@ const groupsSection = ( return h.li( [h.Key(row.group_key), h.Class("flex items-center gap-2 text-sm")], [ - statusDot(h, { tone: breached ? "crit" : row.last_status == null ? "neutral" : "ok" }), + statusDot(h, { + tone: breached ? "crit" : row.last_status == null ? "neutral" : "ok", + }), open != null && !current ? h.a( - [h.Href(incidentHref(open.id)), h.Class("min-w-0 flex-1 truncate hover:underline")], + [ + h.Href(incidentHref(open.id)), + h.Class("min-w-0 flex-1 truncate hover:underline"), + ], [row.group_key], ) - : h.span([h.Class(cn("min-w-0 flex-1 truncate", current && "font-medium"))], [row.group_key]), + : h.span( + [h.Class(cn("min-w-0 flex-1 truncate", current && "font-medium"))], + [row.group_key], + ), h.span( [ h.Class( - cn("font-mono text-xs tabular-nums", breached ? "text-severity-error" : "text-muted-foreground"), + cn( + "font-mono text-xs tabular-nums", + breached ? "text-severity-error" : "text-muted-foreground", + ), ), ], [formatSignalValue(rule.signalType, row.last_value)], @@ -76,7 +88,12 @@ const groupsSection = ( }), ), ...(rows.length > GROUP_ROW_LIMIT - ? [h.p([h.Class("text-muted-foreground text-xs")], [String(rows.length - GROUP_ROW_LIMIT), " more groups"])] + ? [ + h.p( + [h.Class("text-muted-foreground text-xs")], + [String(rows.length - GROUP_ROW_LIMIT), " more groups"], + ), + ] : []), ]) } @@ -84,7 +101,10 @@ const groupsSection = ( const evidenceLink = (h: HtmlBuilder, detail: string, link: Html): Html => h.li([h.Class("space-y-0.5")], [link, h.p([h.Class("text-muted-foreground text-xs")], [detail])]) -const incidentServices = (rule: AlertRuleDocument | null, incident: AlertIncidentDocument): ReadonlyArray => { +const incidentServices = ( + rule: AlertRuleDocument | null, + incident: AlertIncidentDocument, +): ReadonlyArray => { const group = incidentGroup(incident) if (group != null && rule?.groupBy?.length === 1 && rule.groupBy[0] === "service.name") return [group] return rule?.serviceNames ?? [] @@ -97,7 +117,10 @@ const evidenceSection = ( window: Readonly<{ start: number; end: number }>, ): Html => { const services = incidentServices(rule, incident) - const range = { startTime: formatWarehouseDateTime(window.start), endTime: formatWarehouseDateTime(window.end) } + const range = { + startTime: formatWarehouseDateTime(window.start), + endTime: formatWarehouseDateTime(window.end), + } const scope = services.length > 0 ? services.join(", ") : "all services" const servicesSearch = services.length > 0 ? [...services] : undefined return railSection(h, ["Evidence"], null, [ @@ -111,7 +134,14 @@ const evidenceSection = ( "The error issue behind this alert", h.a( [ - h.Href(hrefOf(AppRoute.ErrorIssue({ issueId: incident.errorIssueId, search: {} }))), + h.Href( + hrefOf( + AppRoute.ErrorIssue({ + issueId: incident.errorIssueId, + search: {}, + }), + ), + ), h.Class("hover:underline"), ], ["Linked error issue"], @@ -176,7 +206,10 @@ const ruleSection = ( return railSection( h, ["Rule"], - h.a([h.Href(createHref({ ruleId: rule.id })), h.Class("text-primary text-xs hover:underline")], ["Edit rule"]), + h.a( + [h.Href(createHref({ ruleId: rule.id })), h.Class("text-primary text-xs hover:underline")], + ["Edit rule"], + ), [ h.div( [h.Class("space-y-2")], @@ -189,7 +222,7 @@ const ruleSection = ( "Fired ", String(recent.length), " ", - recent.length === 1 ? "time" : "times", + pluralize(recent.length, "time"), " in 30d", median != null ? ` · median ${Math.max(1, Math.round(median / 60_000))}m` : "", ], diff --git a/apps/web/src/page/alerts/incident/view-timeline.ts b/apps/web/src/page/alerts/incident/view-timeline.ts index 0a4afc6e58..63ef56a523 100644 --- a/apps/web/src/page/alerts/incident/view-timeline.ts +++ b/apps/web/src/page/alerts/incident/view-timeline.ts @@ -48,7 +48,12 @@ export const incidentTimeline = ( ) } -const entryRow = (h: HtmlBuilder, entry: IncidentTimelineEntry, firstAt: number, timeZone: string): Html => +const entryRow = ( + h: HtmlBuilder, + entry: IncidentTimelineEntry, + firstAt: number, + timeZone: string, +): Html => h.li( [h.Key(entry.key), h.Class("grid grid-cols-[4.5rem_1rem_minmax(0,1fr)] gap-x-3 py-3")], [ @@ -67,7 +72,11 @@ const entryRow = (h: HtmlBuilder, entry: IncidentTimelineEntry, firstAt: n [h.Class("flex justify-center pt-1.5")], [ entry.pending - ? h.span([h.AriaHidden(true), h.Class(cn("size-2 rounded-full border", TONE_BORDER[entry.tone]))]) + ? statusDot(h, { + tone: "custom", + size: "lg", + className: cn("border", TONE_BORDER[entry.tone]), + }) : statusDot(h, { tone: entry.tone, size: "lg" }), ], ), @@ -75,18 +84,32 @@ const entryRow = (h: HtmlBuilder, entry: IncidentTimelineEntry, firstAt: n [h.Class("min-w-0 space-y-0.5")], [ h.p([h.Class(cn("text-sm", entry.pending && "text-muted-foreground"))], [entry.title]), - ...(entry.detail ? [h.p([h.Class("text-muted-foreground text-xs")], [entry.detail])] : []), + ...(entry.detail + ? [h.p([h.Class("text-muted-foreground text-xs")], [entry.detail])] + : []), ...(entry.investigation ? [ h.div( [h.Class("mt-2 space-y-2 rounded-md border bg-muted/30 px-3 py-2.5")], [ ...(entry.investigation.headline - ? [h.p([h.Class("text-sm leading-relaxed")], [entry.investigation.headline])] + ? [ + h.p( + [h.Class("text-sm leading-relaxed")], + [entry.investigation.headline], + ), + ] : []), h.a( [ - h.Href(hrefOf(AppRoute.Investigation({ id: String(entry.investigation.id), search: {} }))), + h.Href( + hrefOf( + AppRoute.Investigation({ + id: String(entry.investigation.id), + search: {}, + }), + ), + ), h.Class("text-primary text-xs hover:underline"), ], ["Open investigation"], diff --git a/apps/web/src/page/alerts/shared/rule-chart.ts b/apps/web/src/page/alerts/shared/rule-chart.ts index 3a7bf23e8b..732a2b796e 100644 --- a/apps/web/src/page/alerts/shared/rule-chart.ts +++ b/apps/web/src/page/alerts/shared/rule-chart.ts @@ -44,7 +44,6 @@ import { isoMs } from "./time" /** The tokens the chart and its chrome read off the theme (`PAINT_COLORS` plus the frame). */ export const RULE_CHART_TOKENS: ReadonlyArray = [ "--chart-1", - "--destructive", "--severity-warn", "--severity-info", "--severity-error", @@ -201,7 +200,7 @@ export const ruleChart = (h: HtmlBuilder, inputs: RuleChartInputs): Htm hasGhost, colors: { signal: resolved(SIGNAL_TOKEN), - destructive: resolved("--destructive"), + destructive: resolved("--severity-error"), warn: resolved("--severity-warn"), muted: resolved("--muted-foreground"), border: resolved("--border"), @@ -294,7 +293,7 @@ export const ruleChart = (h: HtmlBuilder, inputs: RuleChartInputs): Htm h.Class( cn( "inline-block h-0 w-4 shrink-0 border-t-[1.5px] border-dashed", - criticalThreshold != null ? "border-severity-warn" : "border-destructive", + criticalThreshold != null ? "border-severity-warn" : "border-severity-error", ), ), ]), @@ -332,7 +331,7 @@ export const ruleChart = (h: HtmlBuilder, inputs: RuleChartInputs): Htm h.span([ h.AriaHidden(true), h.Class( - "inline-block h-0 w-4 shrink-0 border-t-[1.5px] border-dashed border-destructive", + "inline-block h-0 w-4 shrink-0 border-t-[1.5px] border-dashed border-severity-error", ), ]), h.span( diff --git a/apps/web/src/page/alerts/uptime-form/view.ts b/apps/web/src/page/alerts/uptime-form/view.ts index c2a9be616b..f88015a958 100644 --- a/apps/web/src/page/alerts/uptime-form/view.ts +++ b/apps/web/src/page/alerts/uptime-form/view.ts @@ -1,3 +1,4 @@ +import { TONE_TEXT } from "@maple/ui/lib/tone" import { cn } from "@maple/ui/lib/utils" import { Option } from "effect" import { AsyncData, Submodel } from "foldkit" @@ -142,25 +143,28 @@ const testResultBody = (h: H, model: Model): ReadonlyArray => { [h.Class("flex items-baseline gap-2")], [ h.span( - [h.Class(cn("font-mono text-2xl", result.ok ? "text-success" : "text-destructive"))], + [h.Class(cn("font-mono text-2xl font-semibold", TONE_TEXT[result.ok ? "ok" : "crit"]))], [result.status_code === null ? "No response" : String(result.status_code)], ), h.span([h.Class("font-mono text-xs text-muted-foreground")], [formatMs(result.duration_ms)]), ], ), - ...(result.error === null ? [] : [h.p([h.Class("text-sm text-destructive")], [result.error])]), + ...(result.error === null ? [] : [h.p([h.Class(cn("text-sm", TONE_TEXT.crit))], [result.error])]), h.ul( [h.Class("flex flex-col gap-1.5 text-sm")], result.ok ? model.form.assertions.map((row) => h.li( [h.Key(String(row.id)), h.Class("flex items-center gap-2")], - [icon(h, "CheckIcon", { className: "text-success" }), describeAssertion(row)], + [ + icon(h, "CheckIcon", { className: "text-severity-info" }), + describeAssertion(row), + ], ), ) : [ h.li( - [h.Class("flex items-center gap-2 text-destructive")], + [h.Class(cn("flex items-center gap-2", TONE_TEXT.crit))], [icon(h, "XmarkIcon"), result.failed_assertion ?? "The check did not pass."], ), ], diff --git a/apps/web/src/page/alerts/uptime-monitor/view-panels.ts b/apps/web/src/page/alerts/uptime-monitor/view-panels.ts index fe8b934c66..0ae17280c0 100644 --- a/apps/web/src/page/alerts/uptime-monitor/view-panels.ts +++ b/apps/web/src/page/alerts/uptime-monitor/view-panels.ts @@ -1,6 +1,6 @@ import { MAPLE_SERVICES_NAMESPACE } from "@maple/domain/uptime" import { bucketTimeScale } from "@maple/ui/components/plot/plot-scales" -import { formatBucketLabel } from "@maple/ui/lib/format" +import { countLabel, formatBucketLabel } from "@maple/ui/lib/format" import { TONE_COLOR, TONE_TEXT } from "@maple/ui/lib/tone" import { cn } from "@maple/ui/lib/utils" import { formatAlertDateTime, formatAlertTime } from "@maple/web-data/alerts/form-utils" @@ -18,8 +18,10 @@ import { plotFrame } from "../../../charts/plot-frame" import { stackedBreakdown } from "../../../charts/stacked-breakdown-chart" import { resolveColor, type ThemeColors } from "../../../charts/theme-colors" import { chartSkeleton } from "../../../ui/chart-skeleton" +import { emptyMessage } from "../../../ui/empty" import { errorState } from "../../../ui/error-state" import { segmentedSelect } from "../../../ui/segmented-select" +import { relativeTime } from "../../kit/relative-time" import { statRail, statRailItem } from "../../kit/stat-rail" import { isoMs } from "../shared/time" import * as CopyButton from "../../../ui/copy-button" @@ -103,8 +105,9 @@ const isStatsRefreshing = (model: Model): boolean => AsyncData.isRefreshing(mode const timeLabel = (window: UptimeWindow, iso: string, timeZone: string): string => window === "1h" ? formatAlertTime(iso, timeZone) : formatAlertDateTime(iso, timeZone) -/** `3m ago`. */ -const ago = (nowMs: number, atMs: number): string => `${formatSpan(nowMs - atMs)} ago` +/** `3m ago`, the absolute time on hover. */ +const ago = (h: H, nowMs: number, timeZone: string, iso: string): Html => + relativeTime(h, { value: isoMs(iso), nowMs, timeZone, tooltip: "title" }) /** The figures over the page: status, the last check, uptime and the window's percentiles. */ export const kpiRail = ( @@ -134,7 +137,7 @@ export const kpiRail = ( value: monitor.state.last_ping_at === null ? "None yet" - : ago(nowMs, isoMs(monitor.state.last_ping_at)), + : ago(h, nowMs, timeZone, monitor.state.last_ping_at), compact: true, subline: monitor.state.last_ping_at === null @@ -143,7 +146,7 @@ export const kpiRail = ( }) : statRailItem(h, { eyebrow: "Last checked", - value: last === null ? "Not yet" : ago(nowMs, isoMs(last.at)), + value: last === null ? "Not yet" : ago(h, nowMs, timeZone, last.at), tone: last !== null && !last.ok ? "crit" : "neutral", compact: true, subline: @@ -1016,7 +1019,7 @@ export const checksPanel = (h: H, monitor: UptimeMonitor, timeZone: string): Htm ], ), rows.length === 0 - ? h.p([h.Class("px-4 py-6 text-sm text-muted-foreground")], ["No checks yet."]) + ? emptyMessage(h, {}, ["No checks yet."]) : h.ul( [h.Class("divide-y")], rows.map((row, index) => @@ -1116,7 +1119,7 @@ export const setupPanel = (h: H, model: Model, monitor: UptimeMonitor, serviceHr count === 0 ? "Nobody yet" : destinations === null - ? `${count} ${count === 1 ? "destination" : "destinations"}` + ? countLabel(count, "destination") : monitor.destination_ids .map( (id) => diff --git a/apps/web/src/page/analytics/view-ai-parts.ts b/apps/web/src/page/analytics/view-ai-parts.ts index 35236de344..505011e15a 100644 --- a/apps/web/src/page/analytics/view-ai-parts.ts +++ b/apps/web/src/page/analytics/view-ai-parts.ts @@ -25,7 +25,8 @@ import type { AiCrawlers } from "./model" type H = HtmlBuilder -export const plural = (count: number, one: string, many: string) => `${formatNumber(count)} ${pluralize(count, one, many)}` +export const plural = (count: number, one: string, many: string) => + `${formatNumber(count)} ${pluralize(count, one, many)}` const PRODUCT_ICONS = new Map([ ["chatgpt", "OpenAiIcon"], @@ -95,14 +96,19 @@ export const aiPanel = ( divided: false, className: "items-start pt-3 pb-2.5", action: - options.aside === undefined ? undefined : h.div([h.Class("shrink-0 text-2xs text-muted-foreground")], [options.aside]), + options.aside === undefined + ? undefined + : h.div([h.Class("shrink-0 text-2xs text-muted-foreground")], [options.aside]), children: [ - h.div([h.Class("min-w-0")], [ - panelTitle(h, [options.title]), - ...(options.hint === undefined - ? [] - : [h.div([h.Class("mt-0.5 text-2xs text-muted-foreground/70")], [options.hint])]), - ]), + h.div( + [h.Class("min-w-0")], + [ + panelTitle(h, [options.title]), + ...(options.hint === undefined + ? [] + : [h.div([h.Class("mt-0.5 text-2xs text-muted-foreground/70")], [options.hint])]), + ], + ), ], }), ...children, @@ -119,7 +125,10 @@ export const aiPanel = ( ), ], [ - h.span([h.Class("flex items-center gap-1.5")], [icon(h, "MaximizeIcon", { size: 12 }), "Expand table"]), + h.span( + [h.Class("flex items-center gap-1.5")], + [icon(h, "MaximizeIcon", { size: 12 }), "Expand table"], + ), h.span([h.Class("font-mono tabular-nums")], [options.expand.count]), ], ), @@ -129,12 +138,18 @@ export const aiPanel = ( export const aiEmpty = (h: H, children: ReadonlyArray): Html => emptyMessage(h, { className: "px-6 leading-relaxed" }, children) -const crawlLine = (product: AiProduct, crawl: AiCrawlSummary | undefined, nowMs: number): { text: string; title?: string } => { +const crawlLine = ( + product: AiProduct, + crawl: AiCrawlSummary | undefined, + nowMs: number, +): { text: string; title?: string } => { const top = crawl?.purposes[0] if (crawl !== undefined && top !== undefined) return { text: `Read ${plural(top.pages, "page", "pages")} for ${top.purpose}, ${formatRelativeTime(toEpochMs(top.lastSeen), nowMs)}`, - title: crawl.purposes.map((activity) => `${plural(activity.pages, "page", "pages")} for ${activity.purpose}`).join(" · "), + title: crawl.purposes + .map((activity) => `${plural(activity.pages, "page", "pages")} for ${activity.purpose}`) + .join(" · "), } if (crawl !== undefined && crawl.requests > 0) return { @@ -170,24 +185,39 @@ export const aiProductCard = ( attributes: [h.Style({ "animation-delay": `${delay}ms`, "animation-fill-mode": "backwards" })], }, [ - h.div([h.Class("flex items-center justify-between gap-3")], [ - h.div([h.Class("flex min-w-0 items-center gap-2")], [ - aiProductIcon(h, product.id), - h.span([h.Class("truncate text-sm font-medium")], [product.label]), - ]), - ...(visits > 0 && served > 0 - ? [ - h.span( - [ - h.Class("shrink-0 font-mono text-3xs tabular-nums text-muted-foreground/80"), - h.Title("Crawler fetches the site served, per visit this product sent"), - ], - [formatRatio(served / visits), " fetches / visit"], - ), - ] - : []), - ]), - statFigure(h, { value: formatNumber(visits), unit: visits === 1 ? "visit sent" : "visits sent", size: "sm", className: "mt-3" }), + h.div( + [h.Class("flex items-center justify-between gap-3")], + [ + h.div( + [h.Class("flex min-w-0 items-center gap-2")], + [ + aiProductIcon(h, product.id), + h.span([h.Class("truncate text-sm font-medium")], [product.label]), + ], + ), + ...(visits > 0 && served > 0 + ? [ + h.span( + [ + h.Class( + "shrink-0 font-mono text-3xs tabular-nums text-muted-foreground/80", + ), + h.Title( + "Crawler fetches the site served, per visit this product sent", + ), + ], + [formatRatio(served / visits), " fetches / visit"], + ), + ] + : []), + ], + ), + statFigure(h, { + value: formatNumber(visits), + unit: pluralize(visits, "visit sent", "visits sent"), + size: "sm", + className: "mt-3", + }), miniBars(h, { values: referrals?.spark ?? [], color: SPARK_COLOR.neutral, @@ -195,7 +225,10 @@ export const aiProductCard = ( className: cn("mt-3 h-8 w-full", visits === 0 && "opacity-40"), }), h.div( - [h.Class("mt-3 truncate text-2xs text-muted-foreground"), ...(line.title === undefined ? [] : [h.Title(line.title)])], + [ + h.Class("mt-3 truncate text-2xs text-muted-foreground"), + ...(line.title === undefined ? [] : [h.Title(line.title)]), + ], [line.text], ), ], @@ -211,17 +244,24 @@ const shareBarStyle = (share: number): Readonly> => { const pct = Math.min(100, share * 100) if (!Number.isFinite(pct) || pct < 0.5) return {} const fill = "color-mix(in oklab, var(--primary) 14%, transparent)" - return { "background-image": `linear-gradient(to right, ${fill} 0%, ${fill} ${pct}%, transparent ${pct}%)` } + return { + "background-image": `linear-gradient(to right, ${fill} 0%, ${fill} ${pct}%, transparent ${pct}%)`, + } } const shareChange = (h: H, share: number, points: number | null): Html => { if (points === null) - return h.span([h.Class("text-muted-foreground/70"), h.Title("No visits from it in the previous period")], ["New"]) + return h.span( + [h.Class("text-muted-foreground/70"), h.Title("No visits from it in the previous period")], + ["New"], + ) const before = share - points / 100 if (Math.abs(points) < 0.05) return h.span([h.Class("text-muted-foreground/70")], ["No change"]) return h.span( [ - h.Title(`${formatPercent(before)} of AI visits in the previous period, ${formatPercent(share)} now`), + h.Title( + `${formatPercent(before)} of AI visits in the previous period, ${formatPercent(share)} now`, + ), h.Class(points > 0 ? "text-severity-info" : "text-severity-error"), ], [h.span([h.AriaHidden(true)], [points > 0 ? "↑" : "↓"]), " from ", formatPercent(before)], @@ -238,7 +278,12 @@ export const rankingTable = (h: H, ranks: ReadonlyArray, surface columnHead(h, { label: "Assistant", width: "w-0 flex-1 min-w-0" }), columnHead(h, { label: "Visits", width: "w-14", align: "right" }), columnHead(h, { label: "Share", width: "w-14", align: "right" }), - columnHead(h, { label: "Change", width: "w-20", align: "right", hidden: "hidden @min-[360px]/panel:flex" }), + columnHead(h, { + label: "Change", + width: "w-20", + align: "right", + hidden: "hidden @min-[360px]/panel:flex", + }), ], ranks.map((rank, index) => h.div( @@ -248,16 +293,33 @@ export const rankingTable = (h: H, ranks: ReadonlyArray, surface h.Class("flex items-center gap-4 border-b border-border/40 px-4 py-2 last:border-0"), ], [ - h.span([h.Class("w-5 text-right font-mono text-2xs tabular-nums text-muted-foreground/60")], [String(index + 1)]), - h.span([h.Class("flex w-0 min-w-0 flex-1 items-center gap-2")], [ - aiProductIcon(h, rank.product.id), - h.span([h.Class("truncate text-xs text-foreground/90")], [rank.product.label]), - ]), - h.span([h.Class("w-14 text-right font-mono text-2xs tabular-nums text-muted-foreground")], [formatNumber(rank.visits)]), - h.span([h.Class("w-14 text-right font-mono text-2xs tabular-nums")], [formatPercent(rank.share)]), - h.span([h.Class("hidden w-20 justify-end font-mono text-3xs tabular-nums @min-[360px]/panel:flex")], [ - shareChange(h, rank.share, rank.shareDeltaPoints), - ]), + h.span( + [h.Class("w-5 text-right font-mono text-2xs tabular-nums text-muted-foreground/60")], + [String(index + 1)], + ), + h.span( + [h.Class("flex w-0 min-w-0 flex-1 items-center gap-2")], + [ + aiProductIcon(h, rank.product.id), + h.span([h.Class("truncate text-xs text-foreground/90")], [rank.product.label]), + ], + ), + h.span( + [h.Class("w-14 text-right font-mono text-2xs tabular-nums text-muted-foreground")], + [formatNumber(rank.visits)], + ), + h.span( + [h.Class("w-14 text-right font-mono text-2xs tabular-nums")], + [formatPercent(rank.share)], + ), + h.span( + [ + h.Class( + "hidden w-20 justify-end font-mono text-3xs tabular-nums @min-[360px]/panel:flex", + ), + ], + [shareChange(h, rank.share, rank.shareDeltaPoints)], + ), ], ), ), @@ -295,44 +357,91 @@ export const crawlerTable = ( columnHead(h, { label: "Crawler", width: "w-0 flex-1 min-w-0" }), columnHead(h, { label: "Purpose", width: "w-[72px]" }), columnHead(h, { label: "Pages", width: "w-12", align: "right" }), - columnHead(h, { label: "Fetches", width: "w-14", align: "right", hidden: "hidden @min-[380px]/panel:flex" }), - columnHead(h, { label: "Last seen", width: "w-20", align: "right", hidden: "hidden @min-[460px]/panel:flex" }), + columnHead(h, { + label: "Fetches", + width: "w-14", + align: "right", + hidden: "hidden @min-[380px]/panel:flex", + }), + columnHead(h, { + label: "Last seen", + width: "w-20", + align: "right", + hidden: "hidden @min-[460px]/panel:flex", + }), ], crawlers.map((row) => { const product = productForCrawler(row.crawler) const purpose = purposeForCrawler(row.crawler) - return h.div([h.Key(row.crawler), h.Class("flex items-center gap-4 border-b border-border/40 px-4 py-2 last:border-0")], [ - h.span([h.Class("flex w-0 min-w-0 flex-1 items-center gap-2")], [ - aiProductIcon(h, product?.id ?? ""), - h.span([h.Class("truncate text-xs text-foreground/90")], [row.crawler]), - ]), - h.span([h.Class("flex w-[72px] shrink-0")], purpose === undefined ? [] : [purposeBadge(h, purpose)]), - h.span([h.Class(cn("w-12 text-right font-mono text-2xs tabular-nums", row.pages === 0 && "text-muted-foreground/60"))], [ - formatNumber(row.pages), - ]), - h.span( - [ - h.Class("hidden w-14 text-right font-mono text-2xs tabular-nums text-muted-foreground @min-[380px]/panel:inline-block"), - ...(row.failedRequests > 0 - ? [h.Title(`${plural(row.failedRequests, "fetch", "fetches")} got an error response`)] - : []), - ], - [formatNumber(row.requests)], - ), - relativeTime(h, { - value: row.lastSeen, - nowMs: clock.nowMs, - timeZone: clock.timeZone, - tooltip: "title", - className: "hidden w-20 text-right text-2xs text-muted-foreground @min-[460px]/panel:inline-block", - }), - ]) + return h.div( + [ + h.Key(row.crawler), + h.Class("flex items-center gap-4 border-b border-border/40 px-4 py-2 last:border-0"), + ], + [ + h.span( + [h.Class("flex w-0 min-w-0 flex-1 items-center gap-2")], + [ + aiProductIcon(h, product?.id ?? ""), + h.span([h.Class("truncate text-xs text-foreground/90")], [row.crawler]), + ], + ), + h.span( + [h.Class("flex w-[72px] shrink-0")], + purpose === undefined ? [] : [purposeBadge(h, purpose)], + ), + h.span( + [ + h.Class( + cn( + "w-12 text-right font-mono text-2xs tabular-nums", + row.pages === 0 && "text-muted-foreground/60", + ), + ), + ], + [formatNumber(row.pages)], + ), + h.span( + [ + h.Class( + "hidden w-14 text-right font-mono text-2xs tabular-nums text-muted-foreground @min-[380px]/panel:inline-block", + ), + ...(row.failedRequests > 0 + ? [ + h.Title( + `${plural(row.failedRequests, "fetch", "fetches")} got an error response`, + ), + ] + : []), + ], + [formatNumber(row.requests)], + ), + relativeTime(h, { + value: row.lastSeen, + nowMs: clock.nowMs, + timeZone: clock.timeZone, + tooltip: "title", + className: + "hidden w-20 text-right text-2xs text-muted-foreground @min-[460px]/panel:inline-block", + }), + ], + ) }), ) -const FORMATS: ReadonlyArray<{ format: "markdown" | "llms" | "html"; label: string; description: string; icon: IconName }> = [ +const FORMATS: ReadonlyArray<{ + format: "markdown" | "llms" | "html" + label: string + description: string + icon: IconName +}> = [ { format: "markdown", label: "Markdown", description: ".md pages", icon: "MarkdownIcon" }, - { format: "llms", label: "llms.txt", description: "llms.txt and llms-full.txt", icon: "UnorderedListIcon" }, + { + format: "llms", + label: "llms.txt", + description: "llms.txt and llms-full.txt", + icon: "UnorderedListIcon", + }, { format: "html", label: "HTML", description: "Regular web pages", icon: "FileCodeIcon" }, ] @@ -345,53 +454,101 @@ const crawlerMarks = (h: H, crawlers: ReadonlyArray): Html => { }), ).values(), ] - return h.span([h.Class("flex items-center gap-1")], [ - ...products.slice(0, 6).map((product) => h.span([h.Key(product.id), h.Title(product.label)], [aiProductIcon(h, product.id, 12)])), - ...(products.length > 6 ? [h.span([], ["+", String(products.length - 6)])] : []), - ]) + return h.span( + [h.Class("flex items-center gap-1")], + [ + ...products + .slice(0, 6) + .map((product) => + h.span([h.Key(product.id), h.Title(product.label)], [aiProductIcon(h, product.id, 12)]), + ), + ...(products.length > 6 ? [h.span([], ["+", String(products.length - 6)])] : []), + ], + ) } /** `AiContentFormats`: one card per format, then the other files. */ export const contentFormats = (h: H, formats: AiCrawlers["formats"]): Html => { const byFormat = new Map(formats.map((row) => [row.format, row])) const other = byFormat.get("other") - return h.div([h.Class("space-y-2")], [ - h.div( - [h.Class("grid gap-3 @min-[720px]/page:grid-cols-3")], - FORMATS.map((entry) => { - const row = byFormat.get(entry.format) - const requests = row?.requests ?? 0 - const pages = row?.pages ?? 0 - return panel(h, { className: "px-4 py-3.5" }, [ - h.div([h.Class("flex items-center gap-2.5")], [ - h.span([h.Class("flex size-7 shrink-0 items-center justify-center rounded-sm border bg-muted/40 text-muted-foreground")], [ - icon(h, entry.icon, { size: 14 }), - ]), - h.div([h.Class("min-w-0")], [ - h.div([h.Class("text-sm font-medium leading-tight")], [entry.label]), - h.div([h.Class("truncate text-2xs text-muted-foreground")], [entry.description]), - ]), - ]), - statFigure(h, { value: formatNumber(requests), unit: requests === 1 ? "fetch" : "fetches", size: "sm", className: "mt-3" }), - h.div( - [h.Class("mt-3 flex min-h-4 items-center gap-1.5 text-2xs text-muted-foreground")], - requests === 0 - ? ["Not fetched yet"] - : pages === 0 - ? [h.span([h.Title("Every fetch got an error response. Check that the file exists.")], ["Requested, none served"])] - : [h.span([], [plural(pages, "page", "pages"), ", read by"]), crawlerMarks(h, row?.crawlers ?? [])], - ), - ]) - }), - ), - ...(other !== undefined && other.requests > 0 - ? [ - h.div([h.Class("flex items-center gap-1.5 px-1 text-2xs text-muted-foreground/70")], [ - icon(h, "FileIcon", { size: 12 }), - plural(other.requests, "other fetch", "other fetches"), - " (images, scripts, feeds and other files)", - ]), - ] - : []), - ]) + return h.div( + [h.Class("space-y-2")], + [ + h.div( + [h.Class("grid gap-3 @min-[720px]/page:grid-cols-3")], + FORMATS.map((entry) => { + const row = byFormat.get(entry.format) + const requests = row?.requests ?? 0 + const pages = row?.pages ?? 0 + return panel(h, { className: "px-4 py-3.5" }, [ + h.div( + [h.Class("flex items-center gap-2.5")], + [ + h.span( + [ + h.Class( + "flex size-7 shrink-0 items-center justify-center rounded-sm border bg-muted/40 text-muted-foreground", + ), + ], + [icon(h, entry.icon, { size: 14 })], + ), + h.div( + [h.Class("min-w-0")], + [ + h.div([h.Class("text-sm font-medium leading-tight")], [entry.label]), + h.div( + [h.Class("truncate text-2xs text-muted-foreground")], + [entry.description], + ), + ], + ), + ], + ), + statFigure(h, { + value: formatNumber(requests), + unit: pluralize(requests, "fetch", "fetches"), + size: "sm", + className: "mt-3", + }), + h.div( + [ + h.Class( + "mt-3 flex min-h-4 items-center gap-1.5 text-2xs text-muted-foreground", + ), + ], + requests === 0 + ? ["Not fetched yet"] + : pages === 0 + ? [ + h.span( + [ + h.Title( + "Every fetch got an error response. Check that the file exists.", + ), + ], + ["Requested, none served"], + ), + ] + : [ + h.span([], [plural(pages, "page", "pages"), ", read by"]), + crawlerMarks(h, row?.crawlers ?? []), + ], + ), + ]) + }), + ), + ...(other !== undefined && other.requests > 0 + ? [ + h.div( + [h.Class("flex items-center gap-1.5 px-1 text-2xs text-muted-foreground/70")], + [ + icon(h, "FileIcon", { size: 12 }), + plural(other.requests, "other fetch", "other fetches"), + " (images, scripts, feeds and other files)", + ], + ), + ] + : []), + ], + ) } diff --git a/apps/web/src/page/analytics/view-ai.ts b/apps/web/src/page/analytics/view-ai.ts index a002de5490..b8bbfa3fbb 100644 --- a/apps/web/src/page/analytics/view-ai.ts +++ b/apps/web/src/page/analytics/view-ai.ts @@ -7,7 +7,7 @@ import { totalReferrals, } from "../../components/analytics/ai/ai-traffic-model" import { DEFAULT_TRAFFIC, hasActiveFilters } from "../../components/analytics/filters" -import { EMPTY_VALUE, formatNumber, formatPercent } from "@maple/ui/lib/format" +import { EMPTY_VALUE, formatNumber, formatPercent, pluralize } from "@maple/ui/lib/format" import { Option } from "effect" import { AsyncData } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" @@ -66,7 +66,11 @@ export const crawledPagesCard = (model: Model, crawlers: AiCrawlers): BreakdownC dimensions: [ { tab: "Pages read by AI crawlers", - rows: crawlers.pages.map((page) => ({ name: page.path, count: page.requests, secondary: page.host })), + rows: crawlers.pages.map((page) => ({ + name: page.path, + count: page.requests, + secondary: page.host, + })), filterKey: "pagePath", noun: "page", nounPlural: "pages", @@ -88,7 +92,9 @@ const noAiTraffic = (h: H): Html => "See visits sent by ChatGPT, Claude and other assistants, and which pages AI crawlers read. Visits come from the Maple browser SDK, crawler fetches from your server’s traces.", ]), ]), - emptyContent(h, [emptyActions(h, [docsLink(h, "browserSdk", "Setup guide"), docsLink(h, "webAnalytics")])]), + emptyContent(h, [ + emptyActions(h, [docsLink(h, "browserSdk", "Setup guide"), docsLink(h, "webAnalytics")]), + ]), ]) const NO_CRAWLS = (h: H): ReadonlyArray => [ @@ -105,7 +111,9 @@ export const aiTab = (h: H, model: Model, timeZone: string): Html => { const bucketSeconds = sparkBucketSeconds(window.startTime, window.endTime) const referralPoints = dataOf(model.aiReferrals) const referrals = - referralPoints === undefined ? undefined : summarizeReferrals(referralPoints, { ...window, bucketSeconds }) + referralPoints === undefined + ? undefined + : summarizeReferrals(referralPoints, { ...window, bucketSeconds }) const previousPoints = dataOf(model.previousAiReferrals) const previousTotals = previousPoints === undefined ? undefined : totalReferrals(previousPoints) const crawlers = dataOf(model.aiCrawlers) @@ -200,11 +208,19 @@ const rankingPanel = (h: H, ranks: ReturnType): Html => { ]) : aiPanel(h, { ...options, - expand: { id: "ai-ranking", count: plural(ranks.length, "assistant", "assistants"), table: rankingTable(h, ranks, CARD_SURFACE) }, + expand: { + id: "ai-ranking", + count: plural(ranks.length, "assistant", "assistants"), + table: rankingTable(h, ranks, CARD_SURFACE), + }, }) } -const crawlersPanel = (h: H, data: AiCrawlers, clock: Readonly<{ nowMs: number; timeZone: string }>): Html => { +const crawlersPanel = ( + h: H, + data: AiCrawlers, + clock: Readonly<{ nowMs: number; timeZone: string }>, +): Html => { const requests = data.crawlers.reduce((sum, row) => sum + row.requests, 0) const failed = data.crawlers.reduce((sum, row) => sum + row.failedRequests, 0) const options = { @@ -217,7 +233,11 @@ const crawlersPanel = (h: H, data: AiCrawlers, clock: Readonly<{ nowMs: number; aside: failed > 0 ? h.span( - [h.Title("User agents are easy to spoof: scanners borrow crawler names and mostly get 404s.")], + [ + h.Title( + "User agents are easy to spoof: scanners borrow crawler names and mostly get 404s.", + ), + ], [formatPercent(failed / Math.max(requests, 1)), " of fetches failed"], ) : undefined, @@ -275,8 +295,10 @@ const aiKpis = ( (total, summary) => summary.spark.map((value, i) => value + (total[i] ?? 0)), [], ) - const previousVisits = previousTotals === undefined ? undefined : [...previousTotals.values()].reduce((a, b) => a + b, 0) - const change = previousVisits !== undefined && previousVisits > 0 ? (visits - previousVisits) / previousVisits : null + const previousVisits = + previousTotals === undefined ? undefined : [...previousTotals.values()].reduce((a, b) => a + b, 0) + const change = + previousVisits !== undefined && previousVisits > 0 ? (visits - previousVisits) / previousVisits : null const sessions = dataOf(model.summary)?.sessions const fetches = crawlers?.crawlers.reduce((sum, row) => sum + row.requests, 0) ?? 0 const failed = crawlers?.crawlers.reduce((sum, row) => sum + row.failedRequests, 0) ?? 0 @@ -291,7 +313,7 @@ const aiKpis = ( referrals === undefined ? undefined : products > 0 - ? `from ${products} ${products === 1 ? "assistant" : "assistants"}` + ? `from ${products} ${pluralize(products, "assistant")}` : "No AI referrals yet", }), statRailItem(h, { @@ -310,7 +332,12 @@ const aiKpis = ( eyebrow: "Crawler fetches", value: crawlers === undefined ? EMPTY_VALUE : formatNumber(fetches), tone: crawlers !== undefined && fetches > 0 && failed / fetches > 0.5 ? "warn" : "neutral", - subline: crawlers === undefined ? undefined : failed > 0 ? `${formatNumber(failed)} got an error` : "All served", + subline: + crawlers === undefined + ? undefined + : failed > 0 + ? `${formatNumber(failed)} got an error` + : "All served", delay: 180, }), ]) diff --git a/apps/web/src/page/anomalies/list/view.ts b/apps/web/src/page/anomalies/list/view.ts index d4f13345dd..8ec564429a 100644 --- a/apps/web/src/page/anomalies/list/view.ts +++ b/apps/web/src/page/anomalies/list/view.ts @@ -1,3 +1,4 @@ +import { pluralize } from "@maple/ui/lib/format" import { AsyncData, Submodel } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { errorState } from "../../../ui/error-state" @@ -7,6 +8,7 @@ import { skeletonList } from "../../../ui/skeleton" import * as TabsView from "../../../ui/tabs-view" import type { PageViewInputs } from "../../contract" import { dashboardPage } from "../../kit/dashboard-page" +import { listToolbar, toolbarCount } from "../../kit/list-toolbar" import { allIncidentsOf, anomalyFilterChips, @@ -45,34 +47,24 @@ const liveChip = (h: H, isLive: boolean): Html => /** `ListToolbar`: the status tabs and the count readout. */ const toolbar = (h: H, model: Model, count: number | null): Html => - h.div( - [h.Class("flex flex-wrap items-center gap-2 gap-y-1.5 pb-3")], - [ - h.submodel({ - slotId: "anomalies-status-tabs", - model: model.tabs, - view: TabsView.view, - viewInputs: { - labels: TAB_LABELS, - ariaLabel: "Filter anomalies", - tabClassName: "group/tab h-7 grow-0 px-2.5 text-xs sm:h-7 sm:text-xs", - panel: () => undefined, - }, - toParentMessage: (message) => Message.GotTabsMessage({ message }), - }), - h.div( - [h.Class("ml-auto flex min-w-0 flex-wrap items-center justify-end gap-2")], - count === null - ? [] - : [ - h.span( - [h.Class("text-xs text-muted-foreground tabular-nums")], - [String(count), " ", count === 1 ? "anomaly" : "anomalies"], - ), - ], - ), - ], - ) + listToolbar(h, { + leading: h.submodel({ + slotId: "anomalies-status-tabs", + model: model.tabs, + view: TabsView.view, + viewInputs: { + labels: TAB_LABELS, + ariaLabel: "Filter anomalies", + tabClassName: "group/tab h-7 grow-0 px-2.5 text-xs sm:h-7 sm:text-xs", + panel: () => undefined, + }, + toParentMessage: (message) => Message.GotTabsMessage({ message }), + }), + trailing: + count === null + ? [] + : [toolbarCount(h, [String(count), " ", pluralize(count, "anomaly", "anomalies")])], + }) export const view = Submodel.defineView((model, inputs, h) => { const all = allIncidentsOf(model) diff --git a/apps/web/src/page/code-review/kit/review-sheet-more.ts b/apps/web/src/page/code-review/kit/review-sheet-more.ts index d0871be498..86821d9160 100644 --- a/apps/web/src/page/code-review/kit/review-sheet-more.ts +++ b/apps/web/src/page/code-review/kit/review-sheet-more.ts @@ -13,6 +13,7 @@ import { relativeTime } from "../../kit/relative-time" import { bulletList, ciResultBadge, section, traceLink } from "./parts" import { type ReviewSheet, SheetMessage } from "./review-sheet" import { CI_RUNS_SHOWN } from "./review-sheet-update" +import { emptyMessage } from "../../../ui/empty" // `ProductionSection`, `PostMergeSection`, `PullRequestCiRuns` and the review history. @@ -124,9 +125,7 @@ export const ciRunsSection = (h: H, sheet: ReviewSheet): Html => ciSection(h, [h.p([h.Class("text-sm text-muted-foreground")], ["Couldn't load cI runs"])]), onData: (response) => response.runs.length === 0 - ? ciSection(h, [ - h.p([h.Class("text-sm text-muted-foreground")], ["No CI runs in the last 90 days"]), - ]) + ? ciSection(h, [emptyMessage(h, {}, ["No CI runs in the last 90 days"])]) : ciSection(h, [ h.ul( [h.Class("-mx-2 flex flex-col")], diff --git a/apps/web/src/page/code-review/kit/review-sheet-sections.ts b/apps/web/src/page/code-review/kit/review-sheet-sections.ts index 5c78ba6e4d..4ae9ccc146 100644 --- a/apps/web/src/page/code-review/kit/review-sheet-sections.ts +++ b/apps/web/src/page/code-review/kit/review-sheet-sections.ts @@ -29,6 +29,7 @@ import { messageResponse } from "./markdown" import { bulletList, section } from "./parts" import type { ReviewSheet, SheetMessage } from "./review-sheet" import { ciRunsSection, historySection, postMergeSection, productionSection } from "./review-sheet-more" +import { emptyMessage } from "../../../ui/empty" // `ReviewDetailContent`'s body. @@ -163,7 +164,7 @@ const reportSections = (h: H, detail: CodeReviewDetail, nowMs: number): Readonly : []), section(h, `Issues (${report.findings.length})`, [ report.findings.length === 0 - ? h.p([h.Class("text-sm text-muted-foreground")], ["No issues filed on this head."]) + ? emptyMessage(h, {}, ["No issues filed on this head."]) : h.ul( [h.Class("flex flex-col gap-3")], report.findings.map((finding) => diff --git a/apps/web/src/page/code-review/settings/view.ts b/apps/web/src/page/code-review/settings/view.ts index 3272a678ab..e546f731ee 100644 --- a/apps/web/src/page/code-review/settings/view.ts +++ b/apps/web/src/page/code-review/settings/view.ts @@ -8,7 +8,6 @@ import * as DialogView from "../../../ui/dialog-view" import { emptyMessage } from "../../../ui/empty" import { errorState } from "../../../ui/error-state" import { icon } from "../../../ui/icon" -import { panel } from "../../../ui/panel" import type * as Select from "../../../ui/select" import { view as selectView } from "../../../ui/select-view" import { settingRow } from "../../../ui/setting-row" @@ -26,39 +25,12 @@ import { DEFAULT_MODEL_LABEL, MODEL_OPTIONS, } from "./update" +import { settingsSection, settingsSections } from "../../settings/kit/section" // `CodeReviewSettingsView`: the code review settings page. type H = HtmlBuilder -/** `SettingsSection`: heading and description, then the content, framed in a padded Panel or not. */ -const settingsSection = ( - h: H, - options: Readonly<{ id: string; title: string; description: string; isFramed: boolean }>, - children: ReadonlyArray, -): Html => - h.section( - [h.AriaLabelledBy(options.id), h.Class("flex scroll-mt-6 flex-col gap-3 w-full max-w-3xl")], - [ - h.div( - [h.Class("space-y-1")], - [ - h.div( - [h.Class("flex items-baseline gap-2.5")], - [ - h.h2( - [h.Class("text-sm font-medium text-foreground"), h.Id(options.id)], - [options.title], - ), - ], - ), - h.p([h.Class("text-sm text-muted-foreground")], [options.description]), - ], - ), - ...(options.isFramed ? [panel(h, { padded: true }, children)] : children), - ], - ) - const MODEL_ENTRIES = [ { _tag: "Item" as const, item: { value: DEFAULT_MODEL, label: DEFAULT_MODEL_LABEL } }, ...MODEL_OPTIONS.map((model) => ({ @@ -238,50 +210,49 @@ const content = (h: H, model: Model): Html => onRetry: Message.ClickedRetrySettings(), }), onData: () => - h.div( - [h.Class("flex flex-col gap-10 w-full max-w-3xl")], - [ - settingsSection( - h, - { - id: "code-review-settings-reviewer", - title: "Reviewer", - description: "The model every review and reply in this organization runs on.", - isFramed: false, - }, - [modelSetting(h, model)], - ), - settingsSection( - h, - { - id: "code-review-settings-defaults", - title: "Default review rules", - description: - "Every repository starts from these. A repository can override any of them.", - isFramed: true, - }, - Option.match(Option.fromNullishOr(model.defaultsForm), { - onNone: () => [], - onSome: (form) => [ - formView(h, form, model.isAdmin, (message) => - Message.GotDefaultsFormMessage({ message }), - ), - ], - }), - ), - settingsSection( - h, - { - id: "code-review-settings-repositories", - title: "Repositories", - description: - "Pick which repositories get reviews, and tune any of them on its own.", - isFramed: false, - }, - [repositoriesSection(h, model)], - ), - ], - ), + settingsSections(h, [ + settingsSection( + h, + { + id: "code-review-settings-reviewer", + title: "Reviewer", + description: ["The model every review and reply in this organization runs on."], + framed: false, + }, + [modelSetting(h, model)], + ), + settingsSection( + h, + { + id: "code-review-settings-defaults", + title: "Default review rules", + description: [ + "Every repository starts from these. A repository can override any of them.", + ], + framed: true, + }, + Option.match(Option.fromNullishOr(model.defaultsForm), { + onNone: () => [], + onSome: (form) => [ + formView(h, form, model.isAdmin, (message) => + Message.GotDefaultsFormMessage({ message }), + ), + ], + }), + ), + settingsSection( + h, + { + id: "code-review-settings-repositories", + title: "Repositories", + description: [ + "Pick which repositories get reviews, and tune any of them on its own.", + ], + framed: false, + }, + [repositoriesSection(h, model)], + ), + ]), }) export const view = Submodel.defineView((model, _inputs, h) => diff --git a/apps/web/src/page/dashboards/kit/tile-shell.ts b/apps/web/src/page/dashboards/kit/tile-shell.ts index 6a96397da0..1f33fcd98a 100644 --- a/apps/web/src/page/dashboards/kit/tile-shell.ts +++ b/apps/web/src/page/dashboards/kit/tile-shell.ts @@ -219,7 +219,7 @@ const mutedError = ( { variant: "outline", size: "xs", - className: "mt-1 h-6 gap-1 text-3xs", + className: "mt-1 gap-1 text-3xs", attributes: [h.OnClick(narrowRange)], }, [icon(h, "ClockIcon"), NARROW_RANGE_LABEL], @@ -233,7 +233,7 @@ const mutedError = ( { variant: "outline", size: "xs", - className: "mt-1 h-6 gap-1 text-3xs", + className: "mt-1 gap-1 text-3xs", attributes: [h.OnClick(configure)], }, [icon(h, "PencilIcon"), "Edit"], diff --git a/apps/web/src/page/dashboards/kit/time-range-label.ts b/apps/web/src/page/dashboards/kit/time-range-label.ts index f0c5f5bc70..bc9bc22141 100644 --- a/apps/web/src/page/dashboards/kit/time-range-label.ts +++ b/apps/web/src/page/dashboards/kit/time-range-label.ts @@ -1,18 +1,24 @@ import type { TimeRange } from "@maple/widgets/dashboard" import { formatTimestampInTimezone, normalizeTimestampInput } from "@maple/web-data/lib/timezone-format" +import { pluralize } from "@maple/ui/lib/format" import { DateTime, Option } from "effect" import { presetLabel } from "../../kit/time-controls" // `formatTimeRangeDisplay` and `widgetTimeRangeLabel`, with the clock passed in instead of read. const MINUTE = 60_000 -const plural = (count: number, unit: string) => `Last ${count} ${unit}${count !== 1 ? "s" : ""}` +const lastSpan = (count: number, unit: string) => `Last ${count} ${pluralize(count, unit)}` const epochOf = (value: string): number | null => Option.getOrNull(Option.map(DateTime.make(normalizeTimestampInput(value)), DateTime.toEpochMillis)) /** An absolute window: "Last N units" when it ends now, else both ends in the zone. */ -export const timeRangeDisplay = (startTime: string, endTime: string, timeZone: string, nowMs: number): string => { +export const timeRangeDisplay = ( + startTime: string, + endTime: string, + timeZone: string, + nowMs: number, +): string => { const start = epochOf(startTime) const end = epochOf(endTime) if (start === null || end === null) return `${startTime} - ${endTime}` @@ -22,13 +28,14 @@ export const timeRangeDisplay = (startTime: string, endTime: string, timeZone: s const hours = Math.round(diffMs / (60 * MINUTE)) const days = Math.round(diffMs / (24 * 60 * MINUTE)) const weeks = Math.round(diffMs / (7 * 24 * 60 * MINUTE)) - if (minutes < 60) return plural(minutes, "minute") - if (hours < 24) return plural(hours, "hour") - if (days < 7) return plural(days, "day") - if (weeks < 5) return plural(weeks, "week") - return plural(Math.round(days / 30), "month") + if (minutes < 60) return lastSpan(minutes, "minute") + if (hours < 24) return lastSpan(hours, "hour") + if (days < 7) return lastSpan(days, "day") + if (weeks < 5) return lastSpan(weeks, "week") + return lastSpan(Math.round(days / 30), "month") } - const format = (epochMs: number) => formatTimestampInTimezone(new Date(epochMs), { timeZone, style: "range" }) + const format = (epochMs: number) => + formatTimestampInTimezone(new Date(epochMs), { timeZone, style: "range" }) return `${format(start)} - ${format(end)}` } diff --git a/apps/web/src/page/dashboards/list/view-list.ts b/apps/web/src/page/dashboards/list/view-list.ts index 81da083915..9c0a8bf80b 100644 --- a/apps/web/src/page/dashboards/list/view-list.ts +++ b/apps/web/src/page/dashboards/list/view-list.ts @@ -1,3 +1,4 @@ +import { pluralize } from "@maple/ui/lib/format" import { cn } from "@maple/ui/lib/utils" import { rowActionsMenuContentStyles, rowActionsMenuIconStyles } from "@maple/ui/styles/ui/row-actions-menu" import type { Html, HtmlBuilder } from "foldkit/html" @@ -11,6 +12,7 @@ import * as Menu from "../../../ui/menu" import * as MenuView from "../../../ui/menu-view" import { segmentedSelect } from "../../../ui/segmented-select" import { skeleton, skeletonSlowNote } from "../../../ui/skeleton" +import { listToolbar, toolbarCount } from "../../kit/list-toolbar" import { docsLink } from "../../kit/signal-empty-state" import { DASHBOARD_SORT_OPTIONS, type DashboardSortOption } from "../kit/summary" import { relativeTime } from "../../kit/relative-time" @@ -102,43 +104,31 @@ const sortMenu = (h: H, model: Model): Html => { const toolbar = (h: H, model: Model, dashboards: ReadonlyArray, scoped: number): Html => { const isFiltering = queryOf(model.search).trim().length > 0 || tagsOf(model.search).length > 0 - return h.div( - [ - h.Class( - cn( - "flex flex-wrap items-center justify-between gap-3 border-b px-4 py-3", - "justify-start gap-2 border-b-0 px-0 pt-0", - ), - ), - ], - [ + return listToolbar(h, { + leading: segmentedSelect(h, { + options: SCOPE_OPTIONS, + value: scopeOf(model.search), + onChange: (scope) => Message.ChangedScope({ scope }), + ariaLabel: "Filter dashboards by favorite", + className: "shrink-0", + }), + trailing: [ toolbarSearch(h, { id: "dashboards-search", draft: model.searchDraft, placeholder: "Search dashboards", - className: "min-w-0 grow sm:max-w-70", + className: "w-full min-w-0 sm:w-70", onInput: (value) => Message.ChangedSearchDraft({ value }), }), - segmentedSelect(h, { - options: SCOPE_OPTIONS, - value: scopeOf(model.search), - onChange: (scope) => Message.ChangedScope({ scope }), - ariaLabel: "Filter dashboards by favorite", - className: "shrink-0", - }), tagFilter(h, model, collectTags(dashboards)), sortMenu(h, model), - h.span([h.Class("grow")], []), - h.span( - [h.Class("text-muted-foreground shrink-0 font-mono text-2xs")], - [ - isFiltering || scopeOf(model.search) === "favorites" - ? `${scoped} of ${dashboards.length}` - : `${dashboards.length} of ${dashboards.length}`, - ], - ), + toolbarCount(h, [ + isFiltering || scopeOf(model.search) === "favorites" + ? `${scoped} of ${dashboards.length}` + : `${dashboards.length} of ${dashboards.length}`, + ]), ], - ) + }) } /** The reason in the menu itself: a tooltip would break the menu's roving focus. */ @@ -213,7 +203,7 @@ type RowContext = Readonly<{ model: Model; nowMs: number; timeZone: string }> /** `DashboardRow`: one link for the whole row, then the star and the kebab above it. */ const dashboardRow = (h: H, context: RowContext, dashboard: ListDashboard, isFavorite: boolean): Html => { const isEmpty = dashboard.widgetCount === 0 - const widgets = `${dashboard.widgetCount} ${dashboard.widgetCount === 1 ? "widget" : "widgets"}` + const widgets = `${dashboard.widgetCount} ${pluralize(dashboard.widgetCount, "widget")}` const name = displayName(dashboard.name) return h.keyed("div")( dashboard.id, diff --git a/apps/web/src/page/dashboards/list/view.ts b/apps/web/src/page/dashboards/list/view.ts index 39771413f5..f8690428f2 100644 --- a/apps/web/src/page/dashboards/list/view.ts +++ b/apps/web/src/page/dashboards/list/view.ts @@ -154,9 +154,9 @@ const degradedBanner = (h: H): Html => button( h, { - size: "sm", + size: "xs", variant: "ghost", - className: "-my-1 h-6 shrink-0 px-2 text-xs hover:text-foreground", + className: "-my-1 shrink-0 hover:text-foreground", attributes: [h.OnClick(Message.ClickedRetrySync())], }, ["Retry sync"], diff --git a/apps/web/src/page/dashboards/templates/derive.ts b/apps/web/src/page/dashboards/templates/derive.ts index c2dd7c03d0..ac0d1162b3 100644 --- a/apps/web/src/page/dashboards/templates/derive.ts +++ b/apps/web/src/page/dashboards/templates/derive.ts @@ -1,7 +1,6 @@ import type { V2DashboardTemplate, V2DashboardTemplatePreview } from "@maple/domain/http/v2" import { ALL_VALUE, type ResolvedVariable, type VariableValues } from "@maple/query-engine" import { countLabel } from "@maple/ui/lib/format" -import { formatRelativeFrom, toEpochMs } from "@maple/ui/lib/time-format" import type { IconName } from "../../../ui/icon" import type { MetricSeen } from "./model" @@ -47,13 +46,6 @@ export const templateMatches = (template: V2DashboardTemplate, query: string): b ) } -/** "12s ago", or `null` when the timestamp is unusable. */ -export const relativeAge = (iso: string | null, now: number): string | null => { - if (iso === null) return null - const epochMs = toEpochMs(iso) - return Number.isFinite(epochMs) ? formatRelativeFrom(epochMs, now) : null -} - /** Where a gated template sends you to fix the gap (`setupDestination`). */ export const setupHref = (template: V2DashboardTemplate): string => template.requirement?.kind === "integration" ? "/sources/new" : "/settings?tab=ingestion" diff --git a/apps/web/src/page/dashboards/templates/view-panel.ts b/apps/web/src/page/dashboards/templates/view-panel.ts index 7ee3897ce3..cfda3ed54a 100644 --- a/apps/web/src/page/dashboards/templates/view-panel.ts +++ b/apps/web/src/page/dashboards/templates/view-panel.ts @@ -1,6 +1,8 @@ import type { DashboardDocument } from "@maple/domain/http" import type { V2DashboardTemplate } from "@maple/domain/http/v2" import type { VariableValues } from "@maple/query-engine" +import { pluralize } from "@maple/ui/lib/format" +import { toEpochMs } from "@maple/ui/lib/time-format" import { cn } from "@maple/ui/lib/utils" import { fieldDescriptionStyles, fieldLabelStyles, fieldStyles } from "@maple/ui/styles/ui/field" import { skeletonStyles } from "@maple/ui/styles/ui/skeleton" @@ -13,6 +15,7 @@ import { empty, emptyDescription, emptyHeader, emptyMedia, emptyTitle } from ".. import { eyebrow } from "../../../ui/eyebrow" import { icon } from "../../../ui/icon" import { input } from "../../../ui/input" +import { relativeTime } from "../../kit/relative-time" import { dataStateOf } from "../kit/data-state" import { widgetTile } from "../kit/tiles" import { MeasurePreview } from "./command" @@ -21,7 +24,6 @@ import { CATEGORY_LABELS, compositionLabel, READINESS_WINDOW_LABEL, - relativeAge, setupHref, type TemplateReadiness, templateIcon, @@ -177,7 +179,7 @@ const previewBody = (h: H, model: Model, timeZone: string, variables: VariableVa ? [ h.p( [h.Class("text-muted-foreground text-xs")], - [`${hidden} more ${hidden === 1 ? "widget" : "widgets"} below the fold.`], + [`${hidden} more ${pluralize(hidden, "widget")} below the fold.`], ), ] : []), @@ -191,11 +193,12 @@ const requirementBlock = ( template: V2DashboardTemplate, readiness: TemplateReadiness | undefined, nowMs: number, + timeZone: string, ): ReadonlyArray => { const requirement = template.requirement if (requirement === null) return [] const isReady = readiness?.ready !== false - const age = relativeAge(readiness?.lastSeen ?? null, nowMs) + const lastSeen = readiness?.lastSeen ?? null return [ h.div( [h.Class("flex flex-col gap-2.5")], @@ -269,10 +272,20 @@ const requirementBlock = ( [ String(readiness.metricCount), " ", - readiness.metricCount === 1 - ? "metric" - : "metrics", - ...(age === null ? [] : [` · last seen ${age}`]), + pluralize(readiness.metricCount, "metric"), + ...(lastSeen === null || + !Number.isFinite(toEpochMs(lastSeen)) + ? [] + : [ + " · ", + relativeTime(h, { + value: lastSeen, + nowMs, + timeZone, + prefix: "last seen", + tooltip: "title", + }), + ]), ], ), ] @@ -511,7 +524,7 @@ export const detailPanel = ( ), ], ), - ...requirementBlock(h, template, readiness, model.nowMs ?? 0), + ...requirementBlock(h, template, readiness, model.nowMs ?? 0, timeZone), ...parameterFields(h, model, template), createRow(h, model, template, isReady), ], diff --git a/apps/web/src/page/dashboards/view/view-history.ts b/apps/web/src/page/dashboards/view/view-history.ts index 3f191a4ac4..c6956364b8 100644 --- a/apps/web/src/page/dashboards/view/view-history.ts +++ b/apps/web/src/page/dashboards/view/view-history.ts @@ -11,6 +11,7 @@ import { eyebrow } from "../../../ui/eyebrow" import { icon } from "../../../ui/icon" import { iconButton } from "../../../ui/icon-button" import { skeleton } from "../../../ui/skeleton" +import { statusDot } from "../../../ui/status-dot" import { relativeTime } from "../../kit/relative-time" import { sectionHeading } from "../../kit/section-heading" import { actorInitials, KIND_DOT, KIND_LABEL, type VersionRow } from "./history" @@ -24,7 +25,13 @@ type Clock = Readonly<{ nowMs: number; timeZone: string }> const dot = (h: H): Html => h.span([h.AriaHidden(true), h.Class("opacity-50")], ["·"]) -const versionItem = (h: H, version: VersionRow, isPreviewing: boolean, isCurrent: boolean, clock: Clock): Html => +const versionItem = ( + h: H, + version: VersionRow, + isPreviewing: boolean, + isCurrent: boolean, + clock: Clock, +): Html => h.li( [h.Class("relative")], [ @@ -41,15 +48,14 @@ const versionItem = (h: H, version: VersionRow, isPreviewing: boolean, isCurrent ), ], [ - h.span([ - h.AriaHidden(true), - h.Class( - cn( - "absolute left-[15px] top-[14px] block size-2 rounded-full ring-2 ring-background", - KIND_DOT[version.changeKind], - ), + statusDot(h, { + tone: "custom", + size: "lg", + className: cn( + "absolute left-[15px] top-[14px] ring-2 ring-background", + KIND_DOT[version.changeKind], ), - ]), + }), h.div( [h.Class("flex items-start gap-2")], [ @@ -70,7 +76,16 @@ const versionItem = (h: H, version: VersionRow, isPreviewing: boolean, isCurrent [KIND_LABEL[version.changeKind]], ), ...(isCurrent - ? [badge(h, { variant: "tag", className: "bg-primary/10 text-primary" }, ["Current"])] + ? [ + badge( + h, + { + variant: "tag", + className: "bg-primary/10 text-primary", + }, + ["Current"], + ), + ] : []), ...(version.isRestore ? [ @@ -88,7 +103,11 @@ const versionItem = (h: H, version: VersionRow, isPreviewing: boolean, isCurrent [version.changeSummary ?? KIND_LABEL[version.changeKind]], ), h.div( - [h.Class("mt-1 flex items-center gap-1.5 font-mono text-3xs text-muted-foreground")], + [ + h.Class( + "mt-1 flex items-center gap-1.5 font-mono text-3xs text-muted-foreground", + ), + ], [ h.span( [ @@ -101,7 +120,11 @@ const versionItem = (h: H, version: VersionRow, isPreviewing: boolean, isCurrent ), h.span([], ["v", String(version.versionNumber)]), dot(h), - relativeTime(h, { value: version.createdAt, ...clock, tooltip: "title" }), + relativeTime(h, { + value: version.createdAt, + ...clock, + tooltip: "title", + }), ], ), ], @@ -117,7 +140,10 @@ const loading = (h: H): Html => h.div( [h.Role("status"), h.AriaLabel("Loading history"), h.Class("flex flex-col gap-4 px-4 py-4")], [0, 1, 2, 3].map(() => - h.div([h.Class("flex flex-col gap-1.5")], [skeleton(h, "h-3 w-32"), skeleton(h, "h-2.5 w-48 opacity-60")]), + h.div( + [h.Class("flex flex-col gap-1.5")], + [skeleton(h, "h-3 w-32"), skeleton(h, "h-2.5 w-48 opacity-60")], + ), ), ) @@ -130,7 +156,11 @@ const noHistory = (h: H): Html => icon(h, "HistoryIcon", { size: 16, className: "text-muted-foreground" }), ]), emptyTitle(h, ["No history yet"], "text-xs font-medium"), - emptyDescription(h, ["Each save is captured here so you can revisit or restore."], "text-2xs"), + emptyDescription( + h, + ["Each save is captured here so you can revisit or restore."], + "text-2xs", + ), ]), ], "gap-0 px-4 py-12 md:py-12", @@ -146,7 +176,13 @@ const list = (h: H, model: Model, versions: ReadonlyArray, clock: Cl [ h.span([h.AriaHidden(true), h.Class("absolute left-4 top-3 bottom-3 w-px bg-border")]), ...versions.map((version) => - versionItem(h, version, previewed?.id === version.id, previewed === null && version.id === latest, clock), + versionItem( + h, + version, + previewed?.id === version.id, + previewed === null && version.id === latest, + clock, + ), ), ], ) @@ -182,7 +218,12 @@ export const historyPanel = (h: H, model: Model, clock: Clock): Html => { onIdle: () => loading(h), onLoading: () => loading(h), onFailure: (failure) => - errorState(h, { failure, variant: "inline", title: "Couldn't load history", className: "px-4" }), + errorState(h, { + failure, + variant: "inline", + title: "Couldn't load history", + className: "px-4", + }), onSuccess: (rows) => list(h, model, rows, clock), onRefreshing: (rows) => list(h, model, rows, clock), onStale: ({ data }) => list(h, model, data, clock), @@ -212,11 +253,29 @@ export const previewBanner = (h: H, model: Model, version: VersionRow, clock: Cl h.div( [h.Class("flex items-center gap-1.5")], [ - button(h, { variant: "ghost", size: "sm", attributes: [h.OnClick(Message.ClickedCancelPreview())] }, ["Cancel"]), - button(h, { variant: "default", size: "sm", attributes: [h.OnClick(Message.ClickedRestoreVersion())] }, [ - icon(h, "ArrowPathIcon", { attributes: [h.DataAttribute("icon", "inline-start")] }), - "Restore this version", - ]), + button( + h, + { + variant: "ghost", + size: "sm", + attributes: [h.OnClick(Message.ClickedCancelPreview())], + }, + ["Cancel"], + ), + button( + h, + { + variant: "default", + size: "sm", + attributes: [h.OnClick(Message.ClickedRestoreVersion())], + }, + [ + icon(h, "ArrowPathIcon", { + attributes: [h.DataAttribute("icon", "inline-start")], + }), + "Restore this version", + ], + ), ], ), ], diff --git a/apps/web/src/page/dashboards/view/view.ts b/apps/web/src/page/dashboards/view/view.ts index 91195fe126..bbf52b74a5 100644 --- a/apps/web/src/page/dashboards/view/view.ts +++ b/apps/web/src/page/dashboards/view/view.ts @@ -19,6 +19,7 @@ import { skeleton } from "../../../ui/skeleton" import type { PageContext, PageViewInputs } from "../../contract" import { variablesOf } from "./plan" import { dashboardPage } from "../../kit/dashboard-page" +import { resourceNotFound } from "../../kit/resource-not-found" import { docsLink } from "../../kit/signal-empty-state" import { Message } from "./message" import type { Model } from "./model" @@ -124,25 +125,15 @@ const viewSkeleton = (h: H): Html => { /** `ResourceNotFound`: the id resolved to nothing. */ const notFound = (h: H, dashboardId: string): Html => - empty( - h, - [ - emptyHeader(h, [ - emptyMedia(h, { variant: "icon" }, [icon(h, "CircleWarningIcon", { size: 18 })]), - emptyTitle(h, ["Dashboard not found"]), - emptyDescription(h, [ - "No dashboard with id ", - inlineCode(h, { className: "break-all px-1.5 py-0.5" }, [dashboardId]), - ]), - ]), - linkButton( - h, - { variant: "outline", size: "sm", href: hrefOf(AppRoute.Dashboards({ search: {} })) }, - ["Back to all dashboards"], - ), + resourceNotFound(h, { + title: "Dashboard not found", + description: [ + "No dashboard with id ", + inlineCode(h, { className: "break-all px-1.5 py-0.5" }, [dashboardId]), ], - cn("md:py-12", "py-24"), - ) + back: { href: hrefOf(AppRoute.Dashboards({ search: {} })), label: "Back to all dashboards" }, + className: "py-24", + }) const GRID_PREVIEW = [ { glyph: "ChartBarTrendUpIcon", className: "h-16 grow" }, diff --git a/apps/web/src/page/dashboards/widget/view-rail.ts b/apps/web/src/page/dashboards/widget/view-rail.ts index ba3838653d..b643afd00f 100644 --- a/apps/web/src/page/dashboards/widget/view-rail.ts +++ b/apps/web/src/page/dashboards/widget/view-rail.ts @@ -4,6 +4,7 @@ import { eyebrowVariants } from "@maple/ui/styles/ui/eyebrow" import { fieldLabelStyles, fieldStyles } from "@maple/ui/styles/ui/field" import type { Html, HtmlBuilder } from "foldkit/html" import * as Checkbox from "../../../ui/checkbox" +import { separator } from "../../../ui/display" import { field, fieldLabel, type FieldState, input } from "../../../ui/field" import { segmentedSelect } from "../../../ui/segmented-select" import { textarea } from "../../../ui/textarea" @@ -51,7 +52,7 @@ export const railSetting = ( ...children, ]) -export const divider = (h: H): Html => h.div([h.Class("h-px bg-border")], []) +export const divider = (h: H): Html => separator(h, "horizontal", undefined) /** `Segments`: the rail's full-width segmented control. */ export const segments = ( diff --git a/apps/web/src/page/dashboards/widget/view-sql.ts b/apps/web/src/page/dashboards/widget/view-sql.ts index 96478805b8..139eb3dae1 100644 --- a/apps/web/src/page/dashboards/widget/view-sql.ts +++ b/apps/web/src/page/dashboards/widget/view-sql.ts @@ -94,7 +94,9 @@ const editor = (h: H, sql: string): Html => [h.Class("font-mono")], [ ...tokenizeSql(sql).map((token) => - h.span(token.className === undefined ? [] : [h.Class(token.className)], [token.text]), + h.span(token.className === undefined ? [] : [h.Class(token.className)], [ + token.text, + ]), ), "\n", ], @@ -130,8 +132,18 @@ const chip = (h: H, token: string, className: string, trigger: ReadonlyArray, triggerId: string): string => chips.find((_, index) => chipTriggerId(index) === triggerId)?.[1] ?? "" -const IDLE: FieldState = { isInvalid: false, isDirty: false, isFilled: false, isFocused: false, isTouched: false } -const BUCKET_IDS = { control: "widget-editor-sql-bucket", label: "widget-editor-sql-bucket-label", error: "widget-editor-sql-bucket-error" } +const IDLE: FieldState = { + isInvalid: false, + isDirty: false, + isFilled: false, + isFocused: false, + isTouched: false, +} +const BUCKET_IDS = { + control: "widget-editor-sql-bucket", + label: "widget-editor-sql-bucket-label", + error: "widget-editor-sql-bucket-error", +} /** The chips, each a trigger of the one description tooltip. */ const chipRow = ( @@ -177,7 +189,10 @@ const footer = (h: H, model: Model, rawSql: RawSqlState): Html => { className: "max-w-xs", toContent: (triggerId) => [chipDescription(chips, triggerId)], toView: ({ trigger, portal }) => - h.div([], [footerRow(h, rawSql, chipRow(h, chips, trigger)), ...(portal === null ? [] : [portal])]), + h.div( + [], + [footerRow(h, rawSql, chipRow(h, chips, trigger)), ...(portal === null ? [] : [portal])], + ), }, }) } @@ -201,7 +216,8 @@ const footerRow = (h: H, rawSql: RawSqlState, chips: Html): Html => { input(h, { id: BUCKET_IDS.control, type: "number", - className: "h-7 w-20 text-xs", + size: "sm", + className: "w-20 text-xs", attributes: [ h.Min("1"), h.Placeholder("auto"), @@ -222,7 +238,15 @@ export const rawSqlEditor = (h: H, model: Model, rawSql: RawSqlState): ReadonlyA return [ ...(error === null ? [] - : [h.div([h.Class(cn(fieldErrorStyles, "font-medium")), h.DataAttribute("slot", "field-error")], [error])]), + : [ + h.div( + [ + h.Class(cn(fieldErrorStyles, "font-medium")), + h.DataAttribute("slot", "field-error"), + ], + [error], + ), + ]), h.div( [h.Class("space-y-3")], [ @@ -232,7 +256,12 @@ export const rawSqlEditor = (h: H, model: Model, rawSql: RawSqlState): ReadonlyA header(h, rawSql), ...(rawSql.isCollapsed ? [] - : [h.div([h.Class("p-3 space-y-3")], [editor(h, rawSql.draft.sql), footer(h, model, rawSql)])]), + : [ + h.div( + [h.Class("p-3 space-y-3")], + [editor(h, rawSql.draft.sql), footer(h, model, rawSql)], + ), + ]), ], ), h.div( @@ -240,7 +269,11 @@ export const rawSqlEditor = (h: H, model: Model, rawSql: RawSqlState): ReadonlyA [ button( h, - { size: "sm", isDisabled: issue !== null, attributes: [h.OnClick(Message.ClickedRunPreview())] }, + { + size: "sm", + isDisabled: issue !== null, + attributes: [h.OnClick(Message.ClickedRunPreview())], + }, ["Run preview"], ), h.span( diff --git a/apps/web/src/page/dashboards/widget/view.ts b/apps/web/src/page/dashboards/widget/view.ts index 428b199c98..124b6e8ba5 100644 --- a/apps/web/src/page/dashboards/widget/view.ts +++ b/apps/web/src/page/dashboards/widget/view.ts @@ -1,16 +1,15 @@ -import { cn } from "@maple/ui/lib/utils" import { Submodel } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { WIDGET_CHART_TOKENS } from "../kit/chart-tokens" import { ResolveThemeColors } from "../../../charts/theme-colors" -import { button, linkButton } from "../../../ui/button" +import { button } from "../../../ui/button" import { chartSkeleton } from "../../../ui/chart-skeleton" -import { empty, emptyDescription, emptyHeader, emptyMedia, emptyTitle } from "../../../ui/empty" -import { icon } from "../../../ui/icon" + import { inlineCode } from "../../../ui/inline-code" import { skeleton } from "../../../ui/skeleton" import type { PageViewInputs } from "../../contract" import { dashboardPage } from "../../kit/dashboard-page" +import { resourceNotFound } from "../../kit/resource-not-found" import { presetLabel } from "../../kit/time-controls" import * as TimeRangePicker from "../../kit/time-range-picker" import { dataStateOf } from "../kit/data-state" @@ -68,21 +67,15 @@ const editorSkeleton = (h: H): Html => /** `ResourceNotFound`: no such widget on this board. */ const notFound = (h: H, widgetId: string, dashboardHref: string): Html => - empty( - h, - [ - emptyHeader(h, [ - emptyMedia(h, { variant: "icon" }, [icon(h, "CircleWarningIcon", { size: 18 })]), - emptyTitle(h, ["Widget not found"]), - emptyDescription(h, [ - "No widget with id ", - inlineCode(h, { className: "break-all px-1.5 py-0.5" }, [widgetId]), - ]), - ]), - linkButton(h, { variant: "outline", size: "sm", href: dashboardHref }, ["Back to dashboard"]), + resourceNotFound(h, { + title: "Widget not found", + description: [ + "No widget with id ", + inlineCode(h, { className: "break-all px-1.5 py-0.5" }, [widgetId]), ], - cn("md:py-12", "py-24"), - ) + back: { href: dashboardHref, label: "Back to dashboard" }, + className: "py-24", + }) const timeLabel = (model: Model): string => model.timeRange === null @@ -149,7 +142,8 @@ const preview = (h: H, model: Model, timeZone: string): Html => { onSort: (field, isText) => Message.ClickedSortColumn({ field, isText }), sparkline: sparklineState(model, widget), sparklineChart: model.sparklineChart, - onSparklineChart: (message) => Message.GotSparklineChartMessage({ message }), + onSparklineChart: (message) => + Message.GotSparklineChartMessage({ message }), }, widget, ), diff --git a/apps/web/src/page/errors/hub/view-toolbar.ts b/apps/web/src/page/errors/hub/view-toolbar.ts index a56f927f31..68fe3438d5 100644 --- a/apps/web/src/page/errors/hub/view-toolbar.ts +++ b/apps/web/src/page/errors/hub/view-toolbar.ts @@ -12,6 +12,7 @@ import { SEVERITY_FILL, SEVERITY_LABEL, SEVERITY_ORDER, severityDot } from "../k import { Message } from "./message" import type { Model } from "./model" import { searchInput } from "../../../ui/search-input" +import { listToolbar, toolbarCount } from "../../kit/list-toolbar" // The errors stat strip, list toolbar, search and the sort and severity selects. @@ -131,55 +132,47 @@ const toolbarSearch = (h: H, draft: string): Html => /** `ListToolbar`: view tabs, then search, sort, severity and the count readout. */ export const toolbar = (h: H, model: Model, count: ReadonlyArray | null): Html => - h.div( - [h.Class("flex flex-wrap items-center gap-2 gap-y-1.5 pb-3")], - [ + listToolbar(h, { + leading: h.submodel({ + slotId: "errors-view-tabs", + model: model.tabs, + view: TabsView.view, + viewInputs: { + labels: VIEW_LABELS, + ariaLabel: "Filter errors", + tabClassName: "group/tab h-7 grow-0 px-2.5 text-xs sm:h-7 sm:text-xs", + panel: () => undefined, + }, + toParentMessage: (message) => Message.GotTabsMessage({ message }), + }), + trailing: [ + toolbarSearch(h, model.searchDraft), h.submodel({ - slotId: "errors-view-tabs", - model: model.tabs, - view: TabsView.view, + slotId: model.sortSelect.id, + model: model.sortSelect, + view: SelectView.view, viewInputs: { - labels: VIEW_LABELS, - ariaLabel: "Filter errors", - tabClassName: "group/tab h-7 grow-0 px-2.5 text-xs sm:h-7 sm:text-xs", - panel: () => undefined, + entries: SORT_ENTRIES, + size: "sm", + className: "h-7 w-[126px] text-xs", + toView: (parts) => h.div([h.Class("contents")], parts), }, - toParentMessage: (message) => Message.GotTabsMessage({ message }), + toParentMessage: (message) => Message.GotSortSelectMessage({ message }), }), - h.div( - [h.Class("ml-auto flex min-w-0 flex-wrap items-center justify-end gap-2")], - [ - toolbarSearch(h, model.searchDraft), - h.submodel({ - slotId: model.sortSelect.id, - model: model.sortSelect, - view: SelectView.view, - viewInputs: { - entries: SORT_ENTRIES, - size: "sm", - className: "h-7 w-[126px] text-xs", - toView: (parts) => h.div([h.Class("contents")], parts), - }, - toParentMessage: (message) => Message.GotSortSelectMessage({ message }), - }), - h.submodel({ - slotId: model.severitySelect.id, - model: model.severitySelect, - view: SelectView.view, - viewInputs: { - entries: SEVERITY_ENTRIES, - size: "sm", - className: "h-7 w-[122px] text-xs", - renderValue: (value) => severityContent(h, value, SEVERITY_TRIGGER_LABEL), - renderItem: (option) => severityContent(h, option.value, SEVERITY_FILTER_LABEL), - toView: (parts) => h.div([h.Class("contents")], parts), - }, - toParentMessage: (message) => Message.GotSeveritySelectMessage({ message }), - }), - ...(count === null - ? [] - : [h.span([h.Class("text-xs text-muted-foreground tabular-nums")], count)]), - ], - ), + h.submodel({ + slotId: model.severitySelect.id, + model: model.severitySelect, + view: SelectView.view, + viewInputs: { + entries: SEVERITY_ENTRIES, + size: "sm", + className: "h-7 w-[122px] text-xs", + renderValue: (value) => severityContent(h, value, SEVERITY_TRIGGER_LABEL), + renderItem: (option) => severityContent(h, option.value, SEVERITY_FILTER_LABEL), + toView: (parts) => h.div([h.Class("contents")], parts), + }, + toParentMessage: (message) => Message.GotSeveritySelectMessage({ message }), + }), + ...(count === null ? [] : [toolbarCount(h, count)]), ], - ) + }) diff --git a/apps/web/src/page/errors/hub/view.ts b/apps/web/src/page/errors/hub/view.ts index f676495e02..8fd1579f25 100644 --- a/apps/web/src/page/errors/hub/view.ts +++ b/apps/web/src/page/errors/hub/view.ts @@ -1,3 +1,4 @@ +import { pluralize } from "@maple/ui/lib/format" import { Submodel } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { activeFilterChips } from "../../../ui/filters/active-filter-chips" @@ -23,7 +24,7 @@ const countReadout = (model: Model, shown: number): ReadonlyArray | null if (listStatusOf(model) !== "ready") return null if (pagingOf(model) !== "end") return [`${shown}+ issues`] // Split where the JSX interpolates, so the text runs match. - return [String(shown), " ", shown === 1 ? "issue" : "issues"] + return [String(shown), " ", pluralize(shown, "issue")] } /** Kept by reference across renders, so the memoized rows that read it stay cached. */ diff --git a/apps/web/src/page/errors/issue/view-panels.ts b/apps/web/src/page/errors/issue/view-panels.ts index fd262f1245..bb592d9f13 100644 --- a/apps/web/src/page/errors/issue/view-panels.ts +++ b/apps/web/src/page/errors/issue/view-panels.ts @@ -95,7 +95,7 @@ export const pullRequestsPanel = ( { size: "sm", variant: "ghost", - className: "h-7 gap-1 px-2 text-xs", + className: "gap-1 px-2 text-xs", isDisabled: isBusy, attributes: [h.OnClick(Message.ClickedAttach())], }, diff --git a/apps/web/src/page/errors/issue/view-skeleton.ts b/apps/web/src/page/errors/issue/view-skeleton.ts index 620cc5ed67..c2b459b4ac 100644 --- a/apps/web/src/page/errors/issue/view-skeleton.ts +++ b/apps/web/src/page/errors/issue/view-skeleton.ts @@ -1,12 +1,10 @@ import type { Html, HtmlBuilder } from "foldkit/html" import { AppRoute, hrefOf } from "../../../route" -import { linkButton } from "../../../ui/button" import { detailRailGroup, detailRailRow } from "../../../ui/detail-rail" -import { empty, emptyHeader, emptyMedia, emptyTitle } from "../../../ui/empty" -import { icon } from "../../../ui/icon" import { panel } from "../../../ui/panel" import { skeleton, skeletonList } from "../../../ui/skeleton" import { dashboardPage } from "../../kit/dashboard-page" +import { resourceNotFound } from "../../kit/resource-not-found" import { tabOf, windowLabel } from "./derive" import type { Message } from "./message" import type { Model } from "./model" @@ -118,19 +116,8 @@ export const issueSkeleton = (h: H, model: Model): Html => { /** `ResourceNotFound` with its way back to the list. */ export const issueNotFound = (h: H): Html => dashboardPage(h, {}, [ - empty( - h, - [ - emptyHeader(h, [ - emptyMedia(h, { variant: "icon" }, [icon(h, "CircleWarningIcon", { size: 18 })]), - emptyTitle(h, ["Issue not found"]), - ]), - linkButton( - h, - { variant: "outline", size: "sm", href: hrefOf(AppRoute.Errors({ search: {} })) }, - ["Back to errors"], - ), - ], - "md:py-12", - ), + resourceNotFound(h, { + title: "Issue not found", + back: { href: hrefOf(AppRoute.Errors({ search: {} })), label: "Back to errors" }, + }), ]) diff --git a/apps/web/src/page/infra-integrations/aws/derive.ts b/apps/web/src/page/infra-integrations/aws/derive.ts index 99fc299c0d..a2b05e9a47 100644 --- a/apps/web/src/page/infra-integrations/aws/derive.ts +++ b/apps/web/src/page/infra-integrations/aws/derive.ts @@ -1,4 +1,5 @@ import { AWS_INFRA_KINDS, type AwsInfraKindId } from "@maple/domain/aws-infra" +import type { Tone } from "@maple/ui/lib/tone" import { toEpochMs } from "@maple/ui/lib/time-format" import { Array as Arr } from "effect" import type { TracedService } from "../kit/traced" @@ -11,6 +12,13 @@ export type Reading = Readonly<{ value: number; peak: number }> export type Health = "crit" | "warn" | "idle" | "ok" +export const HEALTH_TONE = { + crit: "crit", + warn: "warn", + idle: "neutral", + ok: "ok", +} as const satisfies Record + export type ResourceView = Readonly<{ kind: AwsInfraKindId keys: ReadonlyArray diff --git a/apps/web/src/page/infra-integrations/aws/list/view-table.ts b/apps/web/src/page/infra-integrations/aws/list/view-table.ts index dbc0bf8832..818f754de1 100644 --- a/apps/web/src/page/infra-integrations/aws/list/view-table.ts +++ b/apps/web/src/page/infra-integrations/aws/list/view-table.ts @@ -14,13 +14,15 @@ import { cn } from "@maple/ui/lib/utils" import type { Html, HtmlBuilder } from "foldkit/html" import { AppRoute, hrefOf } from "../../../../route" import { meter } from "../../../../ui/meter" +import { inlineCode } from "../../../../ui/inline-code" import { skeleton } from "../../../../ui/skeleton" +import { statusDot } from "../../../../ui/status-dot" import { columnHead, dataTable, ROW_LINK_CLASS, skeletonRows } from "../../../infra/kit/data-table" import { type SparkWindow, windowSparkline } from "../../kit/window-sparkline" import type { TracedService } from "../../kit/traced" import { cpuShare, - type Health, + HEALTH_TONE, HOT, lambdaErrorRate, lb5xxRate, @@ -58,13 +60,6 @@ type Column = Readonly<{ const MUTED = "font-mono text-2xs text-muted-foreground" const NUMBER = "font-mono text-xs tabular-nums text-foreground/80" -const DOT = { - crit: "bg-severity-error", - warn: "bg-severity-warn", - idle: "border border-muted-foreground bg-transparent", - ok: "bg-success", -} satisfies Record - const reading = (view: ResourceView, key: string) => view.readings[key]?.value ?? null const numberCell = (h: H, text: string | null, tone?: "crit" | "warn"): Html => @@ -135,10 +130,7 @@ const tracedCell = (h: H, view: ResourceView, context: TableContext): Html => { const service = tracedAs(context.traced, view) return service === null ? h.span([h.Class(MUTED)], ["not traced"]) - : h.span( - [h.Class("truncate rounded-sm bg-muted px-1.5 py-0.5 font-mono text-2xs text-foreground/80")], - [service.serviceName], - ) + : inlineCode(h, { className: "truncate text-2xs" }, [service.serviceName]) } const perMinuteText = (view: ResourceView, key: string, context: TableContext) => { @@ -403,7 +395,7 @@ const row = (h: H, view: ResourceView, context: TableContext, showTraced: boolea h.div( [h.Class(cn("flex items-start gap-2.5", NAME_COLUMN.width))], [ - h.span([h.Class(cn("mt-1.5 size-1.5 shrink-0 rounded-full", DOT[view.health]))]), + statusDot(h, { tone: HEALTH_TONE[view.health], className: "mt-1.5" }), h.div( [h.Class("flex min-w-0 flex-col gap-0.5")], [ diff --git a/apps/web/src/page/infra-integrations/aws/list/view.ts b/apps/web/src/page/infra-integrations/aws/list/view.ts index ed612c271c..a3bc22ba92 100644 --- a/apps/web/src/page/infra-integrations/aws/list/view.ts +++ b/apps/web/src/page/infra-integrations/aws/list/view.ts @@ -1,3 +1,4 @@ +import { TONE_FILL } from "@maple/ui/lib/tone" import { AWS_INFRA_KINDS, type AwsInfraKindId } from "@maple/domain/aws-infra" import { countLabel, pluralize } from "@maple/ui/lib/format" import { toEpochMs } from "@maple/ui/lib/time-format" @@ -24,7 +25,7 @@ import type { AwsStatus, FleetRead } from "../data" import { BAND_CELLS, filterViews, - type Health, + HEALTH_TONE, regionsOf, type ResourceView, resourceViews, @@ -42,15 +43,6 @@ type H = HtmlBuilder const MANAGE_HREF = hrefOf(AppRoute.Integrations({ search: { integration: "aws" } })) -const SEGMENT = { - ok: "bg-success", - warn: "bg-severity-warn", - crit: "bg-severity-error", - idle: "bg-muted-foreground/50", -} satisfies Record - -const TONE = { crit: "crit", warn: "warn", idle: "neutral", ok: "ok" } as const - const loading = (h: H): Html => h.div( [h.Class("space-y-4")], @@ -85,14 +77,14 @@ const band = (h: H, model: Model, kind: AwsInfraKindId, views: ReadonlyArray ({ key: health, count: summary.counts[health], - className: SEGMENT[health], + className: TONE_FILL[HEALTH_TONE[health]], })), cells: BAND_CELLS[kind].map((cell) => ({ scope: cell.scope, label: cell.label, hint: cell.hint, value: summary.byScope[cell.scope] ?? 0, - tone: TONE[cell.tone], + tone: HEALTH_TONE[cell.tone], })), activeScope: model.search.scope, onScopeChange: (scope) => Message.ClickedScope({ scope }), diff --git a/apps/web/src/page/infra-integrations/cloudflare/list/view-accounts.ts b/apps/web/src/page/infra-integrations/cloudflare/list/view-accounts.ts index e31bcd3f85..b9d5dc3ce2 100644 --- a/apps/web/src/page/infra-integrations/cloudflare/list/view-accounts.ts +++ b/apps/web/src/page/infra-integrations/cloudflare/list/view-accounts.ts @@ -5,6 +5,7 @@ import type { Html, HtmlBuilder } from "foldkit/html" import { heroFieldChipClass, heroFieldChipParts } from "../../../infra/kit/hero-chip" import type * as Menu from "../../../../ui/menu" import * as MenuView from "../../../../ui/menu-view" +import { statusDot } from "../../../../ui/status-dot" import { Message } from "./model" import { ALL_ACCOUNTS, MANAGE_ACCOUNTS } from "./update" @@ -13,11 +14,6 @@ import { ALL_ACCOUNTS, MANAGE_ACCOUNTS } from "./update" type H = HtmlBuilder -const TONE_DOT = { - ok: "bg-severity-info", - warn: "bg-severity-warn", - crit: "bg-severity-error", -} as const satisfies Record const TONE_TEXT = { ok: "text-muted-foreground", warn: "text-severity-warn", @@ -108,10 +104,7 @@ export const accountsStrip = ( h.span( [h.Class("flex items-center gap-2")], [ - h.span([ - h.AriaHidden(true), - h.Class(cn("size-1.5 shrink-0 rounded-full", TONE_DOT[account.health.tone])), - ]), + statusDot(h, { tone: account.health.tone }), h.span([h.Class("truncate text-xs font-medium text-foreground")], [account.name]), h.span( [h.Class("ml-auto font-mono text-2xs tabular-nums text-foreground")], @@ -155,10 +148,7 @@ export const accountHealthLine = (h: H, account: CloudflareAccount): Html | null ), ], [ - h.span([ - h.AriaHidden(true), - h.Class(cn("size-1.5 shrink-0 rounded-full", TONE_DOT[account.health.tone])), - ]), + statusDot(h, { tone: account.health.tone }), h.span( [h.Class("text-foreground")], [ diff --git a/apps/web/src/page/infra-integrations/cloudflare/list/view-tables.ts b/apps/web/src/page/infra-integrations/cloudflare/list/view-tables.ts index 493be0997e..0fdcb00234 100644 --- a/apps/web/src/page/infra-integrations/cloudflare/list/view-tables.ts +++ b/apps/web/src/page/infra-integrations/cloudflare/list/view-tables.ts @@ -3,10 +3,10 @@ import { CPU_HEAVY_P99_MS, MIN_RATED_REQUESTS, SLOW_P99_MS, - type WorkerHealth, workerHealth, type WorkerListRow, workerRowKey, + WORKER_HEALTH_TONE, } from "../../../../components/infra/cloudflare/list-model" import { ERROR_RATE_WARN, errorRateClass } from "@maple/ui/lib/error-rate" import { @@ -25,6 +25,7 @@ import { resolveColor, type ThemeColors } from "../../../../charts/theme-colors" import { AppRoute, hrefOf } from "../../../../route" import { segmentedBar } from "../../../../ui/meter" import { skeleton } from "../../../../ui/skeleton" +import { statusDot } from "../../../../ui/status-dot" import { columnHead, dataTable, @@ -342,12 +343,6 @@ export const zoneTable = ( // WORKERS -const HEALTH_DOT = { - erroring: "bg-severity-error", - slow: "bg-severity-warn", - ok: "bg-severity-info", -} as const satisfies Record - const workerMeta = (h: H, row: WorkerListRow) => [ row.nameInOtherAccounts ? "name shared with another account" : "", row.errors > 0 ? `${formatNumber(row.errors)} of ${formatNumber(row.requests)} failed` : "", @@ -387,7 +382,7 @@ const workerRow = (h: H, row: WorkerListRow, accounts: ReadonlyMap /** The failure and Durable Object strips name `--severity-fatal` and `--chart-2` too. */ const THEME_TOKENS = [...INFRA_CHART_TOKENS, "--severity-fatal", "--chart-2"] -const HEALTH_DOT = { - erroring: "bg-severity-error", - slow: "bg-severity-warn", - ok: "bg-severity-info", -} as const satisfies Record - /** `CloudflareWorkerHeader`. */ const header = (h: H, model: Model, data: PageData) => { const { row, accountName, traced, currentVersion } = data @@ -66,12 +62,7 @@ const header = (h: H, model: Model, data: PageData) => { [ ...(row === null ? [] - : [ - h.span([ - h.AriaHidden(true), - h.Class(cn("size-2 shrink-0 rounded-full", HEALTH_DOT[workerHealth(row)])), - ]), - ]), + : [statusDot(h, { tone: WORKER_HEALTH_TONE[workerHealth(row)], size: "lg" })]), model.scriptName, ], ), @@ -84,7 +75,7 @@ const header = (h: H, model: Model, data: PageData) => { [h.Href(hrefOf(AppRoute.Service({ serviceName: tracedService, search: {} })))], [heroChip(h, ["traced as ", tracedService])], ), - ...(currentVersion === null ? [] : [heroChip(h, ["version ", currentVersion.slice(0, 7)])]), + ...(currentVersion === null ? [] : [heroChip(h, ["version ", shortId(currentVersion, "sha")])]), ], }) } @@ -406,7 +397,7 @@ const rail = (h: H, data: PageData, timeZone: string): Html => { deploys.map((deploy) => detailRailMetaRow(h, { icon: "CodeIcon", - label: deploy.commitSha.slice(0, 7), + label: shortId(deploy.commitSha, "sha"), value: formatTimeInTimezone(deploy.firstSeen, { timeZone }), }), ), diff --git a/apps/web/src/page/infra-integrations/cloudflare/zone/derive.ts b/apps/web/src/page/infra-integrations/cloudflare/zone/derive.ts index ac7b1b127c..c25df84a0d 100644 --- a/apps/web/src/page/infra-integrations/cloudflare/zone/derive.ts +++ b/apps/web/src/page/infra-integrations/cloudflare/zone/derive.ts @@ -1,10 +1,11 @@ -import { classifyZone, type ZoneHealth } from "../../../../components/infra/cloudflare/list-model" +import { classifyZone, ZONE_HEALTH_TONE } from "../../../../components/infra/cloudflare/list-model" import { type CloudflareFilterKey, type CloudflareFilters, filtersFromSearch, } from "../../../../components/infra/cloudflare/filters" import { zoneSignalTotals } from "../../../../components/infra/cloudflare/zone-signals" +import type { Tone } from "@maple/ui/lib/tone" import { AsyncData } from "foldkit" import { chartBucketSeconds } from "../../../../charts/infra-metric-series" import type { TimeWindow } from "../../../../data/time-range" @@ -169,17 +170,10 @@ export const zoneAccountOf = (model: Model): ZoneAccount | null => { } } -const ZONE_HEALTH_DOT = { - serving5xx: "bg-severity-error", - mostly4xx: "bg-severity-warn", - ok: "bg-severity-info", - lowVolume: "bg-muted-foreground", -} as const satisfies Record - -/** `zoneSeverityClass`: the header dot. */ -export const severityOf = (model: Model): string | null => { +/** The header dot's tone. */ +export const severityOf = (model: Model): Tone | null => { const detail = dataOf(model.detail.data) if (detail === null) return null const totals = zoneSignalTotals(detail.statusBuckets, detail.cacheBuckets) - return ZONE_HEALTH_DOT[classifyZone(totals.requests, totals.serverErrorRate, totals.clientErrorRate)] + return ZONE_HEALTH_TONE[classifyZone(totals.requests, totals.serverErrorRate, totals.clientErrorRate)] } diff --git a/apps/web/src/page/infra-integrations/cloudflare/zone/view.ts b/apps/web/src/page/infra-integrations/cloudflare/zone/view.ts index 6d5cc5c2b4..36819e5a9a 100644 --- a/apps/web/src/page/infra-integrations/cloudflare/zone/view.ts +++ b/apps/web/src/page/infra-integrations/cloudflare/zone/view.ts @@ -1,4 +1,3 @@ -import { cn } from "@maple/ui/lib/utils" import { AsyncData, Submodel } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { INFRA_CHART_TOKENS } from "../../../../charts/infra-metric-series" @@ -10,6 +9,7 @@ import { errorState } from "../../../../ui/error-state" import { icon } from "../../../../ui/icon" import { segmentedSelect } from "../../../../ui/segmented-select" import { skeleton } from "../../../../ui/skeleton" +import { statusDot } from "../../../../ui/status-dot" import * as Tooltip from "../../../../ui/tooltip" import type { PageViewInputs } from "../../../contract" import { dashboardPage } from "../../../kit/dashboard-page" @@ -82,12 +82,7 @@ const header = (h: H, model: Model, hasFilters: boolean): HeaderParts => { const hero = pageHeroParts(h, { title: h.span( [h.Class("flex items-center gap-3")], - [ - ...(severity === null - ? [] - : [h.span([h.AriaHidden(true), h.Class(cn("size-2 shrink-0 rounded-full", severity))])]), - model.zoneName, - ], + [...(severity === null ? [] : [statusDot(h, { tone: severity, size: "lg" })]), model.zoneName], ), meta: [ heroChip(h, "zone"), diff --git a/apps/web/src/page/infra-integrations/gcp/list/view-band.ts b/apps/web/src/page/infra-integrations/gcp/list/view-band.ts index d6170a2ff7..a2143a83c2 100644 --- a/apps/web/src/page/infra-integrations/gcp/list/view-band.ts +++ b/apps/web/src/page/infra-integrations/gcp/list/view-band.ts @@ -7,9 +7,9 @@ import { type RowRules, } from "../../../../components/infra/gcp/list-model" import { TONE_FILL, type Tone } from "@maple/ui/lib/tone" -import { cn } from "@maple/ui/lib/utils" import type { Html, HtmlBuilder } from "foldkit/html" import { FLEET_BAND_BOXED, fleetBand, fleetBandLoading } from "../../../infra/kit/fleet-band" +import { statusDot } from "../../../../ui/status-dot" // The band specs and `GcpHealthDot`. @@ -130,15 +130,9 @@ export const healthDot = ( health: S | "ok", ): Html => { const tone: Tone = health === "ok" ? "ok" : spec.cells[health].tone - return h.span([ - h.AriaHidden(true), - h.Class( - cn( - "size-2 shrink-0 rounded-full", - tone === "neutral" ? "border border-muted-foreground/70" : TONE_FILL[tone], - ), - ), - ]) + return tone === "neutral" + ? statusDot(h, { tone: "custom", size: "lg", className: "border border-muted-foreground/70" }) + : statusDot(h, { tone, size: "lg" }) } export const gcpBand = ( diff --git a/apps/web/src/page/infra-integrations/gcp/list/view-project-menu.ts b/apps/web/src/page/infra-integrations/gcp/list/view-project-menu.ts index aed17c0907..d8e2e7f6af 100644 --- a/apps/web/src/page/infra-integrations/gcp/list/view-project-menu.ts +++ b/apps/web/src/page/infra-integrations/gcp/list/view-project-menu.ts @@ -1,10 +1,11 @@ import type { GcpProjectHealth } from "../../../../components/infra/gcp/list-model" -import { TONE_FILL, TONE_TEXT } from "@maple/ui/lib/tone" +import { TONE_TEXT } from "@maple/ui/lib/tone" import { cn } from "@maple/ui/lib/utils" import type { Html } from "foldkit/html" import { heroFieldChipClass, heroFieldChipParts } from "../../../infra/kit/hero-chip" import type * as Menu from "../../../../ui/menu" import * as MenuView from "../../../../ui/menu-view" +import { statusDot } from "../../../../ui/status-dot" import { Message } from "./model" import type { H } from "./view-rows" @@ -29,14 +30,7 @@ const projectItem = ( [ h.span( [h.Class("mt-1.5 flex w-1.5 shrink-0")], - health === undefined - ? [] - : [ - h.span([ - h.AriaHidden(true), - h.Class(cn("size-1.5 shrink-0 rounded-full", TONE_FILL[health.tone])), - ]), - ], + health === undefined ? [] : [statusDot(h, { tone: health.tone })], ), h.span( [h.Class("flex flex-col")], diff --git a/apps/web/src/page/infra-integrations/gcp/list/view-resources.ts b/apps/web/src/page/infra-integrations/gcp/list/view-resources.ts index 83894b6bb9..02950f1267 100644 --- a/apps/web/src/page/infra-integrations/gcp/list/view-resources.ts +++ b/apps/web/src/page/infra-integrations/gcp/list/view-resources.ts @@ -17,6 +17,7 @@ import { skeletonRows, ROW_LINK_CLASS, } from "../../../infra/kit/data-table" +import { listToolbar, toolbarCount } from "../../../kit/list-toolbar" import { filteredEmpty } from "../../../kit/signal-empty-state" import type { Resources } from "../data" import { gcpMessage } from "../gcp-message" @@ -184,19 +185,21 @@ const resourcesList = ( toMessage: (message) => Message.GotResourceProjectSelectMessage({ message }), }, (projectParts) => - h.div( - [h.Class("flex flex-wrap items-center justify-between gap-3")], - [ - h.div( - [h.Class("flex flex-wrap items-center gap-2")], - [...typeParts, ...projectParts], - ), - h.span( - [h.Class("text-xs text-muted-foreground tabular-nums")], - [summary, " · ", countLabel(projectCount, "project"), " discovered"], - ), + listToolbar(h, { + className: "pb-0", + leading: h.div( + [h.Class("flex flex-wrap items-center gap-2")], + [...typeParts, ...projectParts], + ), + trailing: [ + toolbarCount(h, [ + summary, + " · ", + countLabel(projectCount, "project"), + " discovered", + ]), ], - ), + }), ), ) return [ diff --git a/apps/web/src/page/infra-integrations/gcp/list/view.ts b/apps/web/src/page/infra-integrations/gcp/list/view.ts index e64fb56aba..ffc5f5585e 100644 --- a/apps/web/src/page/infra-integrations/gcp/list/view.ts +++ b/apps/web/src/page/infra-integrations/gcp/list/view.ts @@ -25,7 +25,7 @@ import { } from "../../../../components/infra/gcp/tabs" import { formatPercentColumn, pluralize } from "@maple/ui/lib/format" import { refreshingClass } from "@maple/ui/lib/refreshing" -import { TONE_FILL, TONE_TEXT } from "@maple/ui/lib/tone" +import { TONE_TEXT } from "@maple/ui/lib/tone" import { cn } from "@maple/ui/lib/utils" import { Option } from "effect" import { AsyncData, Submodel } from "foldkit" @@ -36,6 +36,7 @@ import { errorState } from "../../../../ui/error-state" import { icon } from "../../../../ui/icon" import type * as Menu from "../../../../ui/menu" import { skeleton } from "../../../../ui/skeleton" +import { statusDot } from "../../../../ui/status-dot" import type { PageViewInputs } from "../../../contract" import { dashboardPage } from "../../../kit/dashboard-page" import { integrationNotConnected } from "../../kit/empty-states" @@ -249,15 +250,7 @@ const healthText = (health: GcpProjectHealth | undefined) => // A project with no connector health still gets a dot, neutral, so every name lines up. const healthDotOf = (h: H, health: GcpProjectHealth | undefined): ReadonlyArray => [ - h.span([ - h.AriaHidden(true), - h.Class( - cn( - "size-1.5 shrink-0 rounded-full", - health === undefined ? "bg-muted-foreground/50" : TONE_FILL[health.tone], - ), - ), - ]), + statusDot(h, { tone: health?.tone ?? "neutral" }), ] /** What a project's share counts, matching `gcpProjectShares`. */ diff --git a/apps/web/src/page/infra-integrations/gcp/workload/view-rail.ts b/apps/web/src/page/infra-integrations/gcp/workload/view-rail.ts index 5402ea8cf1..dfac43e2c2 100644 --- a/apps/web/src/page/infra-integrations/gcp/workload/view-rail.ts +++ b/apps/web/src/page/infra-integrations/gcp/workload/view-rail.ts @@ -7,6 +7,7 @@ import type { Html, HtmlBuilder } from "foldkit/html" import { AppRoute, hrefOf } from "../../../../route" import { detailRailField, detailRailGroup, detailRailMetaRow } from "../../../../ui/detail-rail" import { icon, type IconName } from "../../../../ui/icon" +import { inlineCode } from "../../../../ui/inline-code" import { serviceDot } from "../../../../ui/values" import type { Message } from "./model" @@ -127,13 +128,12 @@ export const workloadRail = (h: H, options: RailOptions): Html => { [h.Class("flex flex-wrap gap-1")], labels.map((text) => h.span( + [h.Class("flex min-w-0 max-w-full"), h.Title(text)], [ - h.Class( - "max-w-full truncate rounded-sm bg-muted px-1.5 font-mono text-2xs/[18px]", - ), - h.Title(text), + inlineCode(h, { className: "truncate text-2xs/[18px]" }, [ + text, + ]), ], - [text], ), ), ), diff --git a/apps/web/src/page/infra-integrations/gcp/workload/view.ts b/apps/web/src/page/infra-integrations/gcp/workload/view.ts index bc0683d7eb..86f19b63d5 100644 --- a/apps/web/src/page/infra-integrations/gcp/workload/view.ts +++ b/apps/web/src/page/infra-integrations/gcp/workload/view.ts @@ -13,6 +13,7 @@ import type { GcpTraceGap } from "../../../../components/infra/gcp/workload-sign import type { ErrorRateLevel } from "@maple/ui/lib/error-rate" import { formatNumber } from "@maple/ui/lib/format" import { refreshingClass } from "@maple/ui/lib/refreshing" +import type { Tone } from "@maple/ui/lib/tone" import { cn } from "@maple/ui/lib/utils" import { AsyncData, Submodel } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" @@ -24,6 +25,7 @@ import { chartLoading } from "../../../../ui/chart-state" import { emptyMessage } from "../../../../ui/empty" import { errorState } from "../../../../ui/error-state" import { icon } from "../../../../ui/icon" +import { statusDot } from "../../../../ui/status-dot" import type { PageViewInputs } from "../../../contract" import { dashboardPage } from "../../../kit/dashboard-page" import { CHART_HEIGHT } from "../../kit/chart-card" @@ -57,11 +59,10 @@ const SERVICE_KIND = { loadBalancing: "Load balancer", } as const satisfies Record -const HEALTH_DOT = { - crit: "bg-severity-error", - warn: "bg-severity-warn", - neutral: "bg-severity-info", -} as const satisfies Record +const HEALTH_TONE = { crit: "crit", warn: "warn", neutral: "ok" } as const satisfies Record< + ErrorRateLevel, + Tone +> const BARE_IDENTITY = ["Region", "Zone", "Location"] const RAIL_IDENTITY = ["Project"] @@ -109,15 +110,11 @@ const header = (h: H, model: Model, data: PageData) => { ...(data.health === null ? [] : [ - h.span([ - h.AriaHidden(true), - h.Class( - cn( - "absolute -right-0.5 -bottom-0.5 size-2.5 rounded-full ring-2 ring-background", - HEALTH_DOT[data.health], - ), - ), - ]), + statusDot(h, { + tone: HEALTH_TONE[data.health], + className: + "absolute -right-0.5 -bottom-0.5 size-2.5 ring-2 ring-background", + }), ]), ], ), diff --git a/apps/web/src/page/infra-integrations/planetscale/db/view-breakdown.ts b/apps/web/src/page/infra-integrations/planetscale/db/view-breakdown.ts index ebe77b5885..cbb5499bb7 100644 --- a/apps/web/src/page/infra-integrations/planetscale/db/view-breakdown.ts +++ b/apps/web/src/page/infra-integrations/planetscale/db/view-breakdown.ts @@ -11,11 +11,11 @@ import { BRANCH_STATE_LABEL, branchStateOf, type BranchState } from "../filters" import { formatLag, formatStoragePercent, - formatWholePercent, lagClass, MISSING, utilizationClass, -} from "../metrics" +} from "../../../../components/infra/planetscale/metrics" +import { formatWholePercent } from "../../../../components/infra/format" import { type BreakdownSortKey, type Measure, Message } from "./model" // `PlanetScaleBranchBreakdownPanel`. diff --git a/apps/web/src/page/infra-integrations/planetscale/list/view-table.ts b/apps/web/src/page/infra-integrations/planetscale/list/view-table.ts index 70cd0e0b83..f272c2dfd7 100644 --- a/apps/web/src/page/infra-integrations/planetscale/list/view-table.ts +++ b/apps/web/src/page/infra-integrations/planetscale/list/view-table.ts @@ -18,11 +18,11 @@ import { abnormalState, formatLag, formatStoragePercent, - formatWholePercent, lagClass, MISSING, utilizationClass, -} from "../metrics" +} from "../../../../components/infra/planetscale/metrics" +import { formatWholePercent } from "../../../../components/infra/format" import { Message, type SortKey } from "./model" // `PlanetScaleDatabaseTable` and its loading state. diff --git a/apps/web/src/page/infra-integrations/planetscale/list/view.ts b/apps/web/src/page/infra-integrations/planetscale/list/view.ts index 53fb5d0696..e5b006b3ac 100644 --- a/apps/web/src/page/infra-integrations/planetscale/list/view.ts +++ b/apps/web/src/page/infra-integrations/planetscale/list/view.ts @@ -2,7 +2,8 @@ import { derivePlanetScaleSetup, metricsCollectionFailing, } from "../../../../components/integrations/planetscale-setup-steps" -import { EMPTY_VALUE, formatNumber } from "@maple/ui/lib/format" +import { EMPTY_VALUE, formatNumber, pluralize } from "@maple/ui/lib/format" +import type { Tone } from "@maple/ui/lib/tone" import { Option } from "effect" import { AsyncData, Submodel } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" @@ -12,14 +13,20 @@ import { linkButton } from "../../../../ui/button" import { empty, emptyDescription, emptyHeader, emptyMedia, emptyTitle } from "../../../../ui/empty" import { errorState } from "../../../../ui/error-state" import { icon } from "../../../../ui/icon" +import { skeleton } from "../../../../ui/skeleton" import type { PageViewInputs } from "../../../contract" import { dashboardPage } from "../../../kit/dashboard-page" import { docsLink, emptyActions } from "../../../kit/signal-empty-state" import { pageHeroParts } from "../../../infra/kit/page-parts" import { pageTime } from "../../kit/page-time" -import { statRail, statRailItem, statRailLoading } from "../../../kit/stat-rail" +import { listToolbar, toolbarStat } from "../../../kit/list-toolbar" import type { DatabaseList, DatabaseStat, PlanetscaleStatus } from "../data" -import { formatLag, formatStoragePercent, lagTone, utilizationTone } from "../metrics" +import { + formatLag, + formatStoragePercent, + lagTone, + utilizationTone, +} from "../../../../components/infra/planetscale/metrics" import { inventoryIsStale, inventoryNotice, @@ -39,7 +46,11 @@ type Clock = Readonly<{ nowMs: number; timeZone: string }> const NO_STATS: ReadonlyArray = [] -const loading = (h: H): Html => h.div([h.Class("space-y-4")], [statRailLoading(h), databaseTableLoading(h)]) +const loading = (h: H): Html => + h.div( + [h.Class("space-y-4")], + [listToolbar(h, { trailing: [skeleton(h, "h-4 w-72")] }), databaseTableLoading(h)], + ) interface Totals { readonly connections: number @@ -93,6 +104,62 @@ const noDatabases = (h: H): Html => "py-16", ) +/** The fleet's figures as toolbar readouts; the worst storage and lag name their database. */ +const summaryToolbar = ( + h: H, + options: Readonly<{ databases: number; branchTotal: number; never: boolean; totals: Totals }>, +): Html => { + const { totals } = options + // A neutral reading needs no dot. + const dotOf = (tone: Tone): Tone | undefined => (tone === "neutral" ? undefined : tone) + const lagHasOwner = totals.lagOwner !== null && totals.lagMax > 0 + return listToolbar(h, { + trailing: [ + toolbarStat(h, { + value: String(options.databases), + label: pluralize(options.databases, "database"), + }), + toolbarStat(h, { + value: String(options.branchTotal), + label: pluralize(options.branchTotal, "branch", "branches"), + }), + ...(options.never + ? [] + : [ + toolbarStat(h, { + value: + totals.storageMax === null + ? EMPTY_VALUE + : formatStoragePercent(totals.storageMax), + label: + totals.storageOwner === null + ? "worst storage" + : `worst storage (${totals.storageOwner})`, + dot: + totals.storageMax === null + ? undefined + : dotOf(utilizationTone(totals.storageMax)), + }), + toolbarStat(h, { + value: formatLag(totals.lagMax), + label: lagHasOwner + ? `worst replica lag (${totals.lagOwner})` + : "worst replica lag", + dot: dotOf(lagTone(totals.lagMax)), + }), + ...(lagHasOwner + ? [] + : [ + toolbarStat(h, { + value: formatNumber(totals.connections), + label: "connections", + }), + ]), + ]), + ], + }) +} + const content = ( h: H, model: Model, @@ -145,41 +212,7 @@ const content = ( ), ] : []), - statRail(h, {}, [ - statRailItem(h, { - compact: true, - eyebrow: "Databases", - value: String(inventory.databases.length), - }), - statRailItem(h, { compact: true, eyebrow: "Branches", value: String(branchTotal) }), - ...(never - ? [] - : [ - statRailItem(h, { - compact: true, - eyebrow: "Worst storage", - value: - totals.storageMax === null - ? EMPTY_VALUE - : formatStoragePercent(totals.storageMax), - tone: - totals.storageMax === null - ? "neutral" - : utilizationTone(totals.storageMax), - subline: totals.storageOwner ?? undefined, - }), - statRailItem(h, { - compact: true, - eyebrow: "Worst replica lag", - value: formatLag(totals.lagMax), - tone: lagTone(totals.lagMax), - subline: - totals.lagOwner !== null && totals.lagMax > 0 - ? totals.lagOwner - : `${formatNumber(totals.connections)} connections`, - }), - ]), - ]), + summaryToolbar(h, { databases: inventory.databases.length, branchTotal, never, totals }), inventory.databases.length === 0 ? noDatabases(h) : databaseTable(h, { diff --git a/apps/web/src/page/infra-integrations/planetscale/metrics.ts b/apps/web/src/page/infra-integrations/planetscale/metrics.ts deleted file mode 100644 index 5d2cd83143..0000000000 --- a/apps/web/src/page/infra-integrations/planetscale/metrics.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { formatDuration } from "@maple/ui/lib/format" -import { VALUE_TONE } from "../../../components/infra/severity-tokens" -import type { Tone } from "@maple/ui/lib/tone" -import { utilizationLevel } from "@maple/ui/lib/utilization" - -// A copy of apps/web/src/components/infra/planetscale/metrics.ts; keep the two in step. - -/** Metrics missing for the window sort below every real value. */ -export const MISSING = Number.NEGATIVE_INFINITY - -export const utilizationTone = (percent: number): Tone => { - const level = utilizationLevel(percent / 100) - return level === "ok" ? "neutral" : level -} - -export const 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] -} - -export const formatLag = (seconds: number): string => formatDuration(seconds * 1000) - -export const formatStoragePercent = (percent: number): string => - `${percent >= 99.5 || percent < 10 ? percent.toFixed(1) : percent.toFixed(0)}%` - -export const abnormalState = (state: string | null): string | null => { - if (state === null) return null - const normalized = state.toLowerCase() - return normalized === "ready" || normalized === "active" ? null : normalized -} - -export { formatWholePercent } from "../../../components/infra/format" diff --git a/apps/web/src/page/infra-integrations/railway/service/service.scene.test.ts b/apps/web/src/page/infra-integrations/railway/service/service.scene.test.ts index 57e67d9bfb..dd1e2e034d 100644 --- a/apps/web/src/page/infra-integrations/railway/service/service.scene.test.ts +++ b/apps/web/src/page/infra-integrations/railway/service/service.scene.test.ts @@ -51,8 +51,14 @@ describe("railway service", () => { const scoped = routeChanged(reloaded, { ...route, search: { environmentId: "env_staging" } }, context) expect(names(scoped.commands)).toContain("FetchRailwayServiceTimeseries") expect(names(scoped.commands)).not.toContain("ResolveRailwayServiceWindow") - const moved = routeChanged(scoped.model, { ...route, search: { environmentId: "env_staging", timePreset: "1h" } }, context) - expect(moved.commands?.map(({ name, args }) => [name, args?.["mode"]])).toEqual([["ResolveRailwayServiceWindow", "Refresh"]]) + const moved = routeChanged( + scoped.model, + { ...route, search: { environmentId: "env_staging", timePreset: "1h" } }, + context, + ) + expect(moved.commands?.map(({ name, args }) => [name, args?.["mode"]])).toEqual([ + ["ResolveRailwayServiceWindow", "Refresh"], + ]) }) test("the window brings the fleet read and the service's series, bucketed for ~100 points", () => { @@ -88,7 +94,7 @@ describe("railway service", () => { { update: pageUpdate, view }, given(loaded), themeResolved(), - expectView(text("1.5 vCPU")).toExist(), + expectView(text("1.50 vCPU")).toExist(), ) }) diff --git a/apps/web/src/page/infra-integrations/railway/service/view.ts b/apps/web/src/page/infra-integrations/railway/service/view.ts index 483bc235bd..b8c8a79633 100644 --- a/apps/web/src/page/infra-integrations/railway/service/view.ts +++ b/apps/web/src/page/infra-integrations/railway/service/view.ts @@ -1,4 +1,5 @@ -import { formatCores, shareOfLimit } from "../../../../components/infra/railway/format" +import { formatCores } from "../../../../components/infra/format" +import { shareOfLimit } from "../../../../components/infra/railway/format" import { formatBytes, formatPercent } from "@maple/ui/lib/format" import { Option } from "effect" import { AsyncData, Submodel } from "foldkit" @@ -88,11 +89,11 @@ const stats = (h: H, service: RailwayServiceRow): Html => { statRailItem(h, { compact: true, eyebrow: "Avg CPU", - value: formatCores(service.cpuAvg), + value: `${formatCores(service.cpuAvg)} vCPU`, subline: cpuShare === null ? undefined - : `${formatPercent(cpuShare / 100)} of ${formatCores(service.cpuLimit)}`, + : `${formatPercent(cpuShare / 100)} of ${formatCores(service.cpuLimit)} vCPU`, }), statRailItem(h, { compact: true, @@ -147,7 +148,16 @@ const tracedChip = (h: H, model: Model, service: RailwayServiceRow): Html => { if (traced === null) return null return traced.some((row) => row.serviceName === service.serviceName) ? h.a( - [h.Href(hrefOf(AppRoute.Service({ serviceName: service.serviceName, search: pickTimeSearch(model.search) })))], + [ + h.Href( + hrefOf( + AppRoute.Service({ + serviceName: service.serviceName, + search: pickTimeSearch(model.search), + }), + ), + ), + ], [heroChip(h, ["traced as ", service.serviceName])], ) : heroChip(h, "no traces matched") diff --git a/apps/web/src/page/infra-integrations/supabase/list/supabase.scene.test.ts b/apps/web/src/page/infra-integrations/supabase/list/supabase.scene.test.ts index 8319ad6c88..417dd2a0b2 100644 --- a/apps/web/src/page/infra-integrations/supabase/list/supabase.scene.test.ts +++ b/apps/web/src/page/infra-integrations/supabase/list/supabase.scene.test.ts @@ -202,7 +202,7 @@ describe("supabase list", () => { scene( { update: pageUpdate, view }, given(loaded), - expectView(text("1 of 3 healthy")).toExist(), + expectView(text("1 of 3")).toExist(), expectView(text("new-project near its connection limit and 1 more")).toExist(), expectView(text("58 / 60")).toExist(), expectView(text("Healthy")).toExist(), diff --git a/apps/web/src/page/infra-integrations/supabase/list/view-bottom.ts b/apps/web/src/page/infra-integrations/supabase/list/view-bottom.ts index 7832bfaf0e..d1aacad130 100644 --- a/apps/web/src/page/infra-integrations/supabase/list/view-bottom.ts +++ b/apps/web/src/page/infra-integrations/supabase/list/view-bottom.ts @@ -4,6 +4,7 @@ import { TONE_TEXT } from "@maple/ui/lib/tone" import { cn } from "@maple/ui/lib/utils" import type { Html, HtmlBuilder } from "foldkit/html" import { AppRoute, hrefOf } from "../../../../route" +import { eyebrow } from "../../../../ui/eyebrow" import { FLEET_INSIGHTS_LIMIT } from "../data" import { logSourceLabel, severityOf } from "../health" import { supabaseLogsHref } from "../links" @@ -105,15 +106,14 @@ const findingRow = (h: H, finding: AdvisorFinding): Html => `${finding.projectRef}:${finding.lint.cacheKey}`, hrefOf(AppRoute.InfraSupabaseProject({ projectRef: finding.projectRef, search: {} })), [ - h.span( - [ - h.Class( - cn( - "w-24 shrink-0 text-3xs font-medium uppercase tracking-wide", - TONE_TEXT[finding.lint.level === "ERROR" ? "crit" : "warn"], - ), + eyebrow( + h, + { + className: cn( + "w-24 shrink-0", + TONE_TEXT[finding.lint.level === "ERROR" ? "crit" : "warn"], ), - ], + }, [finding.lint.advisor === "security" ? "Security" : "Performance"], ), h.span( diff --git a/apps/web/src/page/infra-integrations/supabase/list/view.ts b/apps/web/src/page/infra-integrations/supabase/list/view.ts index 4d65dcc772..6dd3c35118 100644 --- a/apps/web/src/page/infra-integrations/supabase/list/view.ts +++ b/apps/web/src/page/infra-integrations/supabase/list/view.ts @@ -1,11 +1,4 @@ -import { - countLabel, - EMPTY_VALUE, - formatBytes, - formatNumber, - formatPercent, - formatRate, -} from "@maple/ui/lib/format" +import { countLabel, formatBytes, formatNumber, formatPercent, formatRate } from "@maple/ui/lib/format" import { AsyncData, Submodel } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import type { LoadFailure } from "../../../../data/load-failure" @@ -13,9 +6,10 @@ import { alert, alertAction, alertDescription, alertTitle } from "../../../../ui import { linkButton } from "../../../../ui/button" import { errorState } from "../../../../ui/error-state" import { icon } from "../../../../ui/icon" +import { skeleton } from "../../../../ui/skeleton" import type { PageViewInputs } from "../../../contract" import { dashboardPage } from "../../../kit/dashboard-page" -import { statRail, statRailItem, statRailLoading } from "../../../kit/stat-rail" +import { listToolbar, toolbarCount, toolbarStat } from "../../../kit/list-toolbar" import { pageHeroParts } from "../../../infra/kit/page-parts" import { integrationNotConnected } from "../../kit/empty-states" import { pageTime } from "../../kit/page-time" @@ -35,48 +29,49 @@ import { supabaseTable, supabaseTableLoading } from "./view-table" type H = HtmlBuilder const loading = (h: H): Html => - h.div([h.Class("space-y-4")], [statRailLoading(h, 4, 4), supabaseTableLoading(h)]) + h.div( + [h.Class("space-y-4")], + [listToolbar(h, { trailing: [skeleton(h, "h-4 w-96")] }), supabaseTableLoading(h)], + ) const LOGS_OFF = "Turn on logs for a project to count it" -const tiles = (h: H, views: ReadonlyArray): Html => { +/** The fleet's figures as toolbar readouts, the first unhealthy reason beside the health count. */ +const summaryToolbar = (h: H, views: ReadonlyArray): Html => { const summary = fleetSummary(views) const more = summary.unhealthyCount > 1 ? ` and ${summary.unhealthyCount - 1} more` : "" - return statRail(h, { columns: 4 }, [ - statRailItem(h, { - compact: true, - eyebrow: "Projects", - value: `${summary.healthy} of ${summary.total} healthy`, - tone: summary.unhealthyCount > 0 ? "warn" : undefined, - subline: - summary.firstReason !== null - ? `${summary.firstReason}${more}` - : summary.unknown > 0 - ? `${countLabel(summary.unknown, "project")} without metrics` - : "Every project is healthy", - }), - statRailItem(h, { - compact: true, - eyebrow: "API requests", - value: summary.hasLogs ? formatNumber(summary.requests) : EMPTY_VALUE, - subline: summary.hasLogs - ? `5xx ${formatPercent(summary.errorRate5xx)} · ${formatRate(summary.requestsPerMinute)} req/min` - : LOGS_OFF, - tone: summary.errorRate5xx >= ERROR_RATE_LIMIT ? "crit" : undefined, - }), - statRailItem(h, { - compact: true, - eyebrow: "Errors and warnings", - value: summary.hasLogs ? formatNumber(summary.issues) : EMPTY_VALUE, - subline: summary.hasLogs ? `across ${countLabel(summary.issueProjects, "project")}` : LOGS_OFF, - }), - statRailItem(h, { - compact: true, - eyebrow: "Storage", - value: formatBytes(summary.dbSize), - subline: `across ${countLabel(summary.databases, "database")}`, - }), - ]) + return listToolbar(h, { + trailing: [ + toolbarStat(h, { + value: `${summary.healthy} of ${summary.total}`, + label: "healthy", + dot: summary.unhealthyCount > 0 ? "warn" : "ok", + }), + ...(summary.firstReason === null ? [] : [toolbarCount(h, [`${summary.firstReason}${more}`])]), + ...(summary.firstReason === null && summary.unknown > 0 + ? [toolbarStat(h, { value: String(summary.unknown), label: "without metrics" })] + : []), + ...(summary.hasLogs + ? [ + toolbarStat(h, { value: formatNumber(summary.requests), label: "API requests" }), + toolbarStat(h, { + value: formatPercent(summary.errorRate5xx), + label: "5xx", + dot: summary.errorRate5xx >= ERROR_RATE_LIMIT ? "crit" : undefined, + }), + toolbarStat(h, { value: formatRate(summary.requestsPerMinute), label: "req/min" }), + toolbarStat(h, { + value: formatNumber(summary.issues), + label: `errors and warnings across ${countLabel(summary.issueProjects, "project")}`, + }), + ] + : [toolbarCount(h, [LOGS_OFF])]), + toolbarStat(h, { + value: formatBytes(summary.dbSize), + label: `stored across ${countLabel(summary.databases, "database")}`, + }), + ], + }) } const revokedNotice = (h: H): Html => @@ -112,7 +107,7 @@ const overview = ( [h.Class("space-y-4")], [ ...(status.revoked ? [revokedNotice(h)] : []), - tiles(h, views), + summaryToolbar(h, views), supabaseTable(h, views, isWaiting), h.div( [h.Class("grid grid-cols-1 gap-4 lg:grid-cols-2")], diff --git a/apps/web/src/page/infra-integrations/supabase/project/view-attention.ts b/apps/web/src/page/infra-integrations/supabase/project/view-attention.ts index 18822b8f65..35838249e3 100644 --- a/apps/web/src/page/infra-integrations/supabase/project/view-attention.ts +++ b/apps/web/src/page/infra-integrations/supabase/project/view-attention.ts @@ -1,9 +1,9 @@ import { TONE_TEXT } from "@maple/ui/lib/tone" -import { cn } from "@maple/ui/lib/utils" import { formatClockInTimezone } from "@maple/web-data/lib/timezone-format" import { Option } from "effect" import { AsyncData } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" +import { eyebrow } from "../../../../ui/eyebrow" import { type AttentionItem, MAX_ATTENTION_ITEMS, needsAttention } from "./attention" import type { HealthStrip } from "./derive" import { Message } from "./message" @@ -21,10 +21,7 @@ const itemRow = (h: H, item: AttentionItem, timeZone: string): Html => h.Class("flex flex-col gap-1 border-t border-border/40 px-4 py-3 first:border-t-0"), ], [ - h.span( - [h.Class(cn("text-3xs font-medium uppercase tracking-wide", TONE_TEXT[item.tone]))], - [item.label], - ), + eyebrow(h, { className: TONE_TEXT[item.tone] }, [item.label]), h.span([h.Class("line-clamp-2 text-xs text-foreground [overflow-wrap:anywhere]")], [item.title]), h.span( [h.Class("line-clamp-1 text-2xs text-muted-foreground"), h.Title(item.detail)], @@ -92,7 +89,9 @@ export const attentionPanel = (h: H, model: Model, strip: HealthStrip | null, ti ? [ h.ul( [h.AriaLabel("Needs attention")], - items.slice(0, MAX_ATTENTION_ITEMS).map((item) => itemRow(h, item, timeZone)), + items + .slice(0, MAX_ATTENTION_ITEMS) + .map((item) => itemRow(h, item, timeZone)), ), ] : failures.length > 0 diff --git a/apps/web/src/page/infra-integrations/supabase/project/view-callers.ts b/apps/web/src/page/infra-integrations/supabase/project/view-callers.ts index 9d993d6b5b..4d82aa8eb3 100644 --- a/apps/web/src/page/infra-integrations/supabase/project/view-callers.ts +++ b/apps/web/src/page/infra-integrations/supabase/project/view-callers.ts @@ -4,6 +4,7 @@ import { cn } from "@maple/ui/lib/utils" import type { Html, HtmlBuilder } from "foldkit/html" import { docsUrl } from "../../../../lib/docs" import { AppRoute, hrefOf } from "../../../../route" +import { statusDot } from "../../../../ui/status-dot" import { columnHead, dataTable } from "../../../infra/kit/data-table" import { type SparkWindow, windowSparkline } from "../../kit/window-sparkline" import { ERROR_RATE_LIMIT } from "../health" @@ -62,7 +63,7 @@ const callerRow = (h: H, model: Model, caller: SupabaseProjectCaller, spark: Spa ), ], [ - h.span([h.Class("size-1.5 shrink-0 rounded-full bg-chart-1")]), + statusDot(h, { tone: "custom", className: "bg-chart-1" }), h.span( [h.Class("truncate font-mono"), h.Title(caller.serviceName)], [caller.serviceName], diff --git a/apps/web/src/page/infra-integrations/supabase/project/view-traffic.ts b/apps/web/src/page/infra-integrations/supabase/project/view-traffic.ts index 47084aa298..2ed2231b1d 100644 --- a/apps/web/src/page/infra-integrations/supabase/project/view-traffic.ts +++ b/apps/web/src/page/infra-integrations/supabase/project/view-traffic.ts @@ -24,7 +24,10 @@ const total = (h: H, value: string, label: string, className?: string): Html => h.div( [h.Class("flex flex-col items-end gap-0.5")], [ - h.span([h.Class(cn("font-mono text-lg tabular-nums text-foreground", className))], [value]), + h.span( + [h.Class(cn("font-mono text-lg font-semibold tabular-nums text-foreground", className))], + [value], + ), h.span([h.Class("text-2xs text-muted-foreground")], [label]), ], ) diff --git a/apps/web/src/page/infra/kit/page-parts.ts b/apps/web/src/page/infra/kit/page-parts.ts index bec552a060..8445dd9714 100644 --- a/apps/web/src/page/infra/kit/page-parts.ts +++ b/apps/web/src/page/infra/kit/page-parts.ts @@ -7,6 +7,7 @@ import { empty, emptyDescription, emptyHeader, emptyMedia, emptyMessage, emptyTi import { icon } from "../../../ui/icon" import { pageTitle } from "../../../ui/page-layout" import { searchInput } from "../../../ui/search-input" +import { listToolbar, toolbarCount } from "../../kit/list-toolbar" import { docsLink, emptyActions } from "../../kit/signal-empty-state" // `PageHero`, `SearchToolbar` and the infra empty states. @@ -105,31 +106,31 @@ export type SearchToolbarOptions = Readonly<{ className?: string }> -/** `SearchToolbar` over `SearchInput size="default"` (w-64). */ +/** `SearchToolbar` over `SearchInput size="default"` (w-64), on the shared list toolbar. */ export const searchToolbar = (h: HtmlBuilder, options: SearchToolbarOptions): Html => - h.div( - [h.Class(cn("flex flex-wrap items-center justify-between gap-3", options.className))], - [ - h.div( - [h.Class("flex flex-wrap items-center gap-2")], - [ - searchInput(h, { - id: options.id, - size: "default", - className: "w-64", - placeholder: options.placeholder, - value: options.value, - onInput: options.onInput, - onClear: options.onClear, - }), - ...(options.children ?? []), - ], - ), - ...(options.trailing === undefined || options.trailing === "" + listToolbar(h, { + // Parents space their sections; the list toolbar's own bottom padding would double it. + className: cn("pb-0", options.className), + leading: h.div( + [h.Class("flex flex-wrap items-center gap-2")], + [ + searchInput(h, { + id: options.id, + size: "default", + className: "w-64", + placeholder: options.placeholder, + value: options.value, + onInput: options.onInput, + onClear: options.onClear, + }), + ...(options.children ?? []), + ], + ), + trailing: + options.trailing === undefined || options.trailing === "" ? [] - : [h.span([h.Class("text-xs text-muted-foreground tabular-nums")], [options.trailing])]), - ], - ) + : [toolbarCount(h, [options.trailing])], + }) /** `InfraSetupEmpty`: nothing reporting yet; the button opens the page's install modal. */ export const setupEmpty = ( diff --git a/apps/web/src/page/infra/kubernetes/pod-table.ts b/apps/web/src/page/infra/kubernetes/pod-table.ts index 56c60c75a1..cd7fa8c674 100644 --- a/apps/web/src/page/infra/kubernetes/pod-table.ts +++ b/apps/web/src/page/infra/kubernetes/pod-table.ts @@ -1,5 +1,10 @@ -import { formatWholePercent } from "../../../components/infra/format" -import { formatRestarts, podKey, type PodRestartSummary, restartTone } from "../../../components/infra/pod-restarts" +import { formatCores, formatWholePercent } from "../../../components/infra/format" +import { + formatRestarts, + podKey, + type PodRestartSummary, + restartTone, +} from "../../../components/infra/pod-restarts" import { TONE_TEXT } from "@maple/ui/lib/tone" import { cn } from "@maple/ui/lib/utils" import { rowSelectedClass } from "@maple/ui/styles/ui/list-row" @@ -9,7 +14,14 @@ import { AppRoute, hrefOf } from "../../../route" import { middleTruncate } from "../../../ui/middle-truncate" import { skeleton } from "../../../ui/skeleton" import { avgPeak } from "../kit/avg-peak" -import { columnHead, dataTable, dataTableEmpty, headSort, ROW_LINK_CLASS, skeletonRows } from "../kit/data-table" +import { + columnHead, + dataTable, + dataTableEmpty, + headSort, + ROW_LINK_CLASS, + skeletonRows, +} from "../kit/data-table" import type { SortDir } from "../../kit/table-sort" import { meterRows } from "../kit/meter-rows" import { metaLine } from "../kit/meta-line" @@ -42,23 +54,30 @@ const metaTitle = (pod: PodRow, workload: Readonly<{ kind: string; name: string .filter(Boolean) .join(" · ") -/** `formatCores`: comparable cores across a fleet's magnitudes. */ -const formatCores = (cores: number): string => { - if (!Number.isFinite(cores) || cores === 0) return "0" - if (cores >= 10) return cores.toFixed(0) - if (cores >= 1) return cores.toFixed(2) - return cores.toFixed(3) -} - -const columns = (h: HtmlBuilder, sort: Sort, onSort: ((key: PodSortKey) => M) | undefined): ReadonlyArray => { +const columns = ( + h: HtmlBuilder, + sort: Sort, + onSort: ((key: PodSortKey) => M) | undefined, +): ReadonlyArray => { const head = (key: PodSortKey) => onSort === undefined || sort === null ? { isActive: sort !== null && sort.key === key, dir: sort?.dir ?? "desc" } : headSort(sort, key, onSort) return [ columnHead(h, { label: "Pod", sort: head("podName"), width: "w-0 flex-1 min-w-[260px]" }), - columnHead(h, { label: "Peak saturation", sort: head("saturation"), width: "w-[176px]", hidden: "hidden md:flex" }), - columnHead(h, { label: "CPU cores", sort: head("cpuUsage"), align: "right", width: "w-[132px]", hidden: "hidden lg:flex" }), + columnHead(h, { + label: "Peak saturation", + sort: head("saturation"), + width: "w-[176px]", + hidden: "hidden md:flex", + }), + columnHead(h, { + label: "CPU cores", + sort: head("cpuUsage"), + align: "right", + width: "w-[132px]", + hidden: "hidden lg:flex", + }), columnHead(h, { label: "Mem of limit", sort: head("memoryLimitPct"), @@ -76,7 +95,10 @@ export const podTableLoading = (h: HtmlBuilder): Html => { ariaLabel: "Pods" }, columns(h, null, undefined), skeletonRows(h, 6, () => [ - h.div([h.Class("w-0 min-w-[260px] flex-1")], [skeleton(h, "h-4 w-48"), skeleton(h, "mt-1.5 h-3 w-40")]), + h.div( + [h.Class("w-0 min-w-[260px] flex-1")], + [skeleton(h, "h-4 w-48"), skeleton(h, "mt-1.5 h-3 w-40")], + ), h.div( [h.Class("hidden w-[176px] space-y-1.5 md:block")], [skeleton(h, "h-2.5 w-[176px]"), skeleton(h, "h-2.5 w-[176px]")], @@ -93,7 +115,12 @@ const restartMarker = (h: HtmlBuilder, summary: PodRestartSummary | undefi const label = [formatRestarts(summary.restarts), summary.lastTerminatedReason].filter(Boolean).join(" · ") return h.span( [ - h.Class(cn("shrink-0 text-2xs font-medium", TONE_TEXT[restartTone(summary.restarts, summary.lastTerminatedReason)])), + h.Class( + cn( + "shrink-0 text-2xs font-medium", + TONE_TEXT[restartTone(summary.restarts, summary.lastTerminatedReason)], + ), + ), h.Title(`${label} in this window, ${summary.totalRestarts} in total`), ], [label], @@ -147,10 +174,18 @@ const podRow = (h: HtmlBuilder, pod: PodRow, inputs: PodTableInputs): H text: pod.podName, tail: 12, mono: true, - className: "text-xs font-medium text-foreground transition-colors group-hover:text-primary", + className: + "text-xs font-medium text-foreground transition-colors group-hover:text-primary", }), - hostStatusBadge(h, { quiet: true, lastSeen: pod.lastSeen, referenceMs: inputs.referenceTime }), - restartMarker(h, Option.getOrUndefined(Option.map(inputs.restarts, (map) => map.get(key)))), + hostStatusBadge(h, { + quiet: true, + lastSeen: pod.lastSeen, + referenceMs: inputs.referenceTime, + }), + restartMarker( + h, + Option.getOrUndefined(Option.map(inputs.restarts, (map) => map.get(key))), + ), ], ), metaLine( @@ -175,10 +210,19 @@ const podRow = (h: HtmlBuilder, pod: PodRow, inputs: PodTableInputs): H ]), ], ), - h.div([h.Class("hidden w-[132px] text-right lg:block")], [avgPeak(h, { avg: pod.cpuUsage, peak: pod.cpuUsagePeak, format: formatCores })]), + h.div( + [h.Class("hidden w-[132px] text-right lg:block")], + [avgPeak(h, { avg: pod.cpuUsage, peak: pod.cpuUsagePeak, format: formatCores })], + ), h.div( [h.Class("hidden w-[120px] text-right lg:block")], - [avgPeak(h, { avg: pod.memoryLimitPct, peak: pod.memoryLimitPctPeak, format: formatWholePercent })], + [ + avgPeak(h, { + avg: pod.memoryLimitPct, + peak: pod.memoryLimitPctPeak, + format: formatWholePercent, + }), + ], ), h.div( [h.Class("w-[100px] text-right")], @@ -196,10 +240,16 @@ const podRow = (h: HtmlBuilder, pod: PodRow, inputs: PodTableInputs): H ) } -export const podTable = (h: HtmlBuilder, pods: ReadonlyArray, inputs: PodTableInputs): Html => +export const podTable = ( + h: HtmlBuilder, + pods: ReadonlyArray, + inputs: PodTableInputs, +): Html => dataTable( h, { ariaLabel: "Pods", isWaiting: inputs.isWaiting }, columns(h, { key: inputs.sortBy, dir: inputs.sortDir }, inputs.onSort), - pods.length === 0 ? [dataTableEmpty(h, ["No pods match your filter."])] : pods.map((pod) => podRow(h, pod, inputs)), + pods.length === 0 + ? [dataTableEmpty(h, ["No pods match your filter."])] + : pods.map((pod) => podRow(h, pod, inputs)), ) diff --git a/apps/web/src/page/infra/overview/view-findings.ts b/apps/web/src/page/infra/overview/view-findings.ts index 121bac7dad..76d443fb7f 100644 --- a/apps/web/src/page/infra/overview/view-findings.ts +++ b/apps/web/src/page/infra/overview/view-findings.ts @@ -3,6 +3,7 @@ import type { Html, HtmlBuilder } from "foldkit/html" import { isSameFailure, type LoadFailure } from "../../../data/load-failure" import { errorState } from "../../../ui/error-state" import { icon } from "../../../ui/icon" +import { emptyMessage } from "../../../ui/empty" import { skeleton } from "../../../ui/skeleton" import { statusDot } from "../../../ui/status-dot" import { sectionHeading } from "../../kit/section-heading" @@ -179,14 +180,9 @@ export const needsAttention = ( }) : rows.length > 0 ? findingsList(h, rows) - : h.p( - [ - h.Class( - "rounded-md border px-4 py-6 text-center text-sm text-muted-foreground", - ), - ], - ["Nothing is over its thresholds in this window."], - ), + : emptyMessage(h, { dashed: true }, [ + "Nothing is over its thresholds in this window.", + ]), ], ) } diff --git a/apps/web/src/page/integrations/kit/count-chip.ts b/apps/web/src/page/integrations/kit/count-chip.ts new file mode 100644 index 0000000000..60aedf9433 --- /dev/null +++ b/apps/web/src/page/integrations/kit/count-chip.ts @@ -0,0 +1,28 @@ +import type { Tone } from "@maple/ui/lib/tone" +import { cn } from "@maple/ui/lib/utils" +import { + filterChipCountStyles, + filterChipPressedBorderStyles, + filterChipVariants, +} from "@maple/ui/styles/ui/filter-chip" +import type { HtmlBuilder } from "foldkit/html" +import { statusDot } from "../../../ui/status-dot" +import type * as ToggleGroup from "../../../ui/toggle-group" + +/** A ToggleGroup item drawn as a `filterChip`: label, optional tone dot, trailing count. */ +export const countChipItem = ( + h: HtmlBuilder, + options: Readonly<{ value: string; label: string; count: number; tone?: Tone | undefined }>, +): ToggleGroup.ToggleGroupItem => ({ + value: options.value, + label: options.label, + className: cn( + filterChipVariants({ size: "xs" }), + filterChipPressedBorderStyles[options.tone ?? "neutral"], + ), + children: [ + ...(options.tone === undefined ? [] : [statusDot(h, { tone: options.tone })]), + options.label, + h.span([h.Class(filterChipCountStyles)], [String(options.count)]), + ], +}) diff --git a/apps/web/src/page/integrations/kit/notice.ts b/apps/web/src/page/integrations/kit/notice.ts new file mode 100644 index 0000000000..8926122299 --- /dev/null +++ b/apps/web/src/page/integrations/kit/notice.ts @@ -0,0 +1,12 @@ +import type { Html, HtmlBuilder } from "foldkit/html" +import { panel } from "../../../ui/panel" + +/** One line of integration state ("Railway is not connected.") with an optional trailing action. */ +export const integrationNotice = ( + h: HtmlBuilder, + options: Readonly<{ message: Html | string; action?: Html | undefined }>, +): Html => + panel(h, { padded: true, className: "flex-row items-center justify-between gap-4 text-sm" }, [ + h.span([h.Class("text-muted-foreground")], [options.message]), + ...(options.action === undefined ? [] : [options.action]), + ]) diff --git a/apps/web/src/page/integrations/view-cloudflare-board.ts b/apps/web/src/page/integrations/view-cloudflare-board.ts index 555f133c3e..6905ecb904 100644 --- a/apps/web/src/page/integrations/view-cloudflare-board.ts +++ b/apps/web/src/page/integrations/view-cloudflare-board.ts @@ -10,6 +10,7 @@ import { emptyMessage } from "../../ui/empty" import { panel, panelHeader } from "../../ui/panel" import { searchInput } from "../../ui/search-input" import { statusDot } from "../../ui/status-dot" +import { countChipItem } from "./kit/count-chip" import * as ToggleGroup from "../../ui/toggle-group" import { truncatedText } from "../../ui/truncated-text" import { columnHead } from "../infra/kit/data-table" @@ -221,9 +222,14 @@ export const zoneBoard = ( size: "xs", className: "ml-auto gap-1.5", items: [ - countChip(h, "all", "All", decorated.length), + countChipItem(h, { value: "all", label: "All", count: decorated.length }), ...CHIP_ORDER.filter((kind) => counts(kind) > 0).map((kind) => - countChip(h, kind, STATUS_META[kind].label, counts(kind), STATUS_META[kind].tone), + countChipItem(h, { + value: kind, + label: STATUS_META[kind].label, + count: counts(kind), + tone: STATUS_META[kind].tone, + }), ), ], }), @@ -393,24 +399,3 @@ export const workersCard = ( ), ]) } - -const countChip = ( - h: H, - value: string, - label: string, - count: number, - tone?: ZoneStatusInfo["tone"], -): ToggleGroup.ToggleGroupItem => ({ - value, - label, - className: - "group h-6 gap-1.5 rounded-full border-border/60 px-2.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground data-pressed:border-transparent data-pressed:bg-muted data-pressed:text-foreground sm:h-6 sm:text-xs", - children: [ - ...(tone === undefined ? [] : [statusDot(h, { tone })]), - label, - h.span( - [h.Class("tabular-nums text-muted-foreground/70 group-data-pressed:text-muted-foreground")], - [String(count)], - ), - ], -}) diff --git a/apps/web/src/page/integrations/view-cloudflare.ts b/apps/web/src/page/integrations/view-cloudflare.ts index 58dc5675c5..203ba8fc26 100644 --- a/apps/web/src/page/integrations/view-cloudflare.ts +++ b/apps/web/src/page/integrations/view-cloudflare.ts @@ -6,6 +6,7 @@ import { button, linkButton } from "../../ui/button" import { confirmDialog } from "../../ui/confirm-dialog" import { errorState } from "../../ui/error-state" import { skeleton } from "../../ui/skeleton" +import { integrationNotice } from "./kit/notice" import { Message } from "./message" import type { Model } from "./model" import { cloudflareConnected } from "./view-cloudflare-connected" @@ -16,21 +17,14 @@ type H = HtmlBuilder /** A source page whose grant was removed: the way back is the connect flow. */ const cloudflareReconnect = (h: H): Html => - h.div( - [ - h.Class( - "flex items-center justify-between gap-4 rounded-lg border border-border bg-card p-4 text-sm", - ), - ], - [ - h.span([h.Class("text-muted-foreground")], ["Cloudflare is not connected."]), - linkButton( - h, - { size: "sm", href: hrefOf(AppRoute.SourcesNew({ search: { integration: "cloudflare" } })) }, - ["Reconnect"], - ), - ], - ) + integrationNotice(h, { + message: "Cloudflare is not connected.", + action: linkButton( + h, + { size: "sm", href: hrefOf(AppRoute.SourcesNew({ search: { integration: "cloudflare" } })) }, + ["Reconnect"], + ), + }) export const cloudflareCard = (h: H, model: Model, timeZone: string): Html => AsyncData.match(model.statuses.cloudflare.data, { diff --git a/apps/web/src/page/integrations/view-flow-supabase.ts b/apps/web/src/page/integrations/view-flow-supabase.ts index 2c9f594d08..adc999017b 100644 --- a/apps/web/src/page/integrations/view-flow-supabase.ts +++ b/apps/web/src/page/integrations/view-flow-supabase.ts @@ -6,6 +6,7 @@ import { AsyncData } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { AppRoute, hrefOf } from "../../route" import { button } from "../../ui/button" +import { eyebrow } from "../../ui/eyebrow" import { icon } from "../../ui/icon" import { columnHead, dataTable, dataTableEmpty } from "../infra/kit/data-table" import { SUPABASE_NOT_CONFIGURED } from "../infra-integrations/supabase/connect" @@ -75,10 +76,7 @@ const windowSteps = (h: H): Html => h.div( [h.Class("flex flex-col gap-2 rounded-md bg-muted/40 p-4")], [ - h.span( - [h.Class("font-mono text-[10px] uppercase tracking-wide text-muted-foreground")], - ["In the Supabase window"], - ), + eyebrow(h, { variant: "mono" }, ["In the Supabase window"]), h.ul( [h.Class("flex flex-col gap-1.5 text-sm")], [ diff --git a/apps/web/src/page/integrations/view-gcp-setup-methods.ts b/apps/web/src/page/integrations/view-gcp-setup-methods.ts index 42977e219a..24b2441b40 100644 --- a/apps/web/src/page/integrations/view-gcp-setup-methods.ts +++ b/apps/web/src/page/integrations/view-gcp-setup-methods.ts @@ -2,6 +2,7 @@ import { cn } from "@maple/ui/lib/utils" import type { GcpManualStep, GcpStepField } from "../../components/integrations/gcp-setup-steps" import type { Html, HtmlBuilder } from "foldkit/html" import { icon } from "../../ui/icon" +import { inlineCode } from "../../ui/inline-code" import type { ConfigureModel } from "./gcp-configure-schema" import { Message } from "./message" import type { Model } from "./model" @@ -23,11 +24,12 @@ export const gcpValue = (h: H, model: Model, value: GcpStepField["value"], copy return h.span( [h.Class("inline-flex max-w-full items-start gap-0.5")], [ - h.span( - [h.Class("min-w-0 rounded-md bg-muted px-1.5 py-0.5 font-mono text-xs/5 break-all text-foreground")], - [value], - ), - copyOf(h, model, `value-${value}`, value, { label: "value", iconSize: 12, className: "size-6 shrink-0" }), + inlineCode(h, { className: "min-w-0 break-all text-xs/5" }, [value]), + copyOf(h, model, `value-${value}`, value, { + label: "value", + iconSize: 12, + className: "size-6 shrink-0", + }), ], ) } @@ -63,7 +65,9 @@ const manualStepBody = (h: H, model: Model, step: GcpManualStep): Html => h.Href(step.consoleUrl), h.Target("_blank"), h.Rel("noreferrer"), - h.Class("inline-flex items-center gap-1 font-medium text-foreground hover:underline"), + h.Class( + "inline-flex items-center gap-1 font-medium text-foreground hover:underline", + ), ], ["Open in console", icon(h, "ExternalLinkIcon", { size: 12 })], ), @@ -89,7 +93,11 @@ const manualStepBody = (h: H, model: Model, step: GcpManualStep): Html => ? [] : [ h.ul( - [h.Class("flex list-disc flex-col gap-1 pl-4 text-xs/5 text-pretty text-muted-foreground")], + [ + h.Class( + "flex list-disc flex-col gap-1 pl-4 text-xs/5 text-pretty text-muted-foreground", + ), + ], step.notes.map((note) => h.li([h.Key(note)], [note])), ), ]), @@ -101,7 +109,12 @@ export const manualSteps = ( h: H, model: Model, configure: ConfigureModel, - options: Readonly<{ heading: string; detail: string; steps: ReadonlyArray; docsHref: string }>, + options: Readonly<{ + heading: string + detail: string + steps: ReadonlyArray + docsHref: string + }>, ): Html => { const firstTitle = options.steps[0]?.title ?? null const open = configure.isManualTouched ? configure.manualOpen : firstTitle @@ -113,9 +126,15 @@ export const manualSteps = ( [ h.div( [h.Class("flex flex-col gap-0.5")], - [h.h3([h.Class(GCP_TITLE)], [options.heading]), h.p([h.Class(SMALL)], [options.detail])], + [ + h.h3([h.Class(GCP_TITLE)], [options.heading]), + h.p([h.Class(SMALL)], [options.detail]), + ], + ), + h.span( + [h.Class("text-xs/5")], + [externalLink(h, options.docsHref, "Full guide in the docs")], ), - h.span([h.Class("text-xs/5")], [externalLink(h, options.docsHref, "Full guide in the docs")]), ], ), h.ol( @@ -141,7 +160,12 @@ export const manualSteps = ( [ h.Type("button"), h.AriaExpanded(expanded), - h.OnClick(Message.ClickedGcpManualStep({ title: step.title, firstTitle })), + h.OnClick( + Message.ClickedGcpManualStep({ + title: step.title, + firstTitle, + }), + ), h.Class( "flex min-w-0 flex-1 items-center gap-3 text-left text-sm font-medium outline-none focus-visible:underline", ), @@ -162,7 +186,10 @@ export const manualSteps = ( ]), icon(h, "ChevronDownIcon", { size: 14, - className: cn("shrink-0 text-muted-foreground transition-transform", expanded && "rotate-180"), + className: cn( + "shrink-0 text-muted-foreground transition-transform", + expanded && "rotate-180", + ), }), ], ), diff --git a/apps/web/src/page/integrations/view-gcp-workloads.ts b/apps/web/src/page/integrations/view-gcp-workloads.ts index 74c542bf2b..0c095f070a 100644 --- a/apps/web/src/page/integrations/view-gcp-workloads.ts +++ b/apps/web/src/page/integrations/view-gcp-workloads.ts @@ -20,6 +20,7 @@ import { icon } from "../../ui/icon" import { panel, panelHeader } from "../../ui/panel" import { skeleton } from "../../ui/skeleton" import { statusDot } from "../../ui/status-dot" +import { countChipItem } from "./kit/count-chip" import * as ToggleGroup from "../../ui/toggle-group" import { truncatedText } from "../../ui/truncated-text" import { statRail } from "../kit/stat-rail" @@ -37,20 +38,6 @@ const VALUES_SHOWN = 3 const VALUE_VISIBLE = ["flex", "hidden @sm:flex", "hidden @lg:flex"] const WINDOW = "24h" -const chip = (h: H, value: string, label: string, count: number): ToggleGroup.ToggleGroupItem => ({ - value, - label, - className: - "group h-6 gap-1.5 rounded-full border-border/60 px-2.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground data-pressed:border-transparent data-pressed:bg-muted data-pressed:text-foreground sm:h-6 sm:text-xs", - children: [ - label, - h.span( - [h.Class("tabular-nums text-muted-foreground/70 group-data-pressed:text-muted-foreground")], - [String(count)], - ), - ], -}) - const workloads = (h: H, model: Model, className: string): Html => { const fleet = fleetOf(model) if (fleet === null) return skeleton(h, cn("h-40 rounded-md", className)) @@ -93,14 +80,13 @@ const workloads = (h: H, model: Model, className: string): Html => { size: "xs", className: "gap-1.5", items: [ - chip(h, "all", "All", all.length), + countChipItem(h, { value: "all", label: "All", count: all.length }), ...services.map((entry) => - chip( - h, - entry.service, - GCP_INFRA_SERVICES[entry.service].title, - entry.workloads.length, - ), + countChipItem(h, { + value: entry.service, + label: GCP_INFRA_SERVICES[entry.service].title, + count: entry.workloads.length, + }), ), ], }), @@ -231,36 +217,34 @@ const resources = (h: H, model: Model, className: string): Html => { ) : h.ul( [h.Class("flex flex-col px-2 py-1.5")], - types - .slice(0, TYPES_SHOWN) - .map(({ assetType, count }) => - h.li( - [], - [ - h.a( - [ - h.Href( - hrefOf( - AppRoute.InfraGcp({ - search: { tab: GCP_RESOURCES_TAB, type: assetType }, - }), - ), - ), - h.Class( - "flex items-center justify-between gap-3 rounded-sm px-2 py-1.5 text-xs transition-colors hover:bg-muted/40 focus-visible:bg-muted/40 focus-visible:outline-none", - ), - ], - [ - truncatedText(h, { text: gcpAssetTypeLabel(assetType) }), - h.span( - [h.Class("shrink-0 tabular-nums text-muted-foreground")], - [formatNumber(count)], + types.slice(0, TYPES_SHOWN).map(({ assetType, count }) => + h.li( + [], + [ + h.a( + [ + h.Href( + hrefOf( + AppRoute.InfraGcp({ + search: { tab: GCP_RESOURCES_TAB, type: assetType }, + }), ), - ], - ), - ], - ), + ), + h.Class( + "flex items-center justify-between gap-3 rounded-sm px-2 py-1.5 text-xs transition-colors hover:bg-muted/40 focus-visible:bg-muted/40 focus-visible:outline-none", + ), + ], + [ + truncatedText(h, { text: gcpAssetTypeLabel(assetType) }), + h.span( + [h.Class("shrink-0 tabular-nums text-muted-foreground")], + [formatNumber(count)], + ), + ], + ), + ], ), + ), ), ...(inventory === "failed" || inventory.total === 0 ? [] diff --git a/apps/web/src/page/integrations/view-gcp.ts b/apps/web/src/page/integrations/view-gcp.ts index 513d6ba20a..310acf8eb8 100644 --- a/apps/web/src/page/integrations/view-gcp.ts +++ b/apps/web/src/page/integrations/view-gcp.ts @@ -16,6 +16,7 @@ import { skeleton } from "../../ui/skeleton" import { statusDot } from "../../ui/status-dot" import { sectionHeading } from "../kit/section-heading" import { HEALTH_TONE } from "./derive" +import { integrationNotice } from "./kit/notice" import { Message } from "./message" import type { Model } from "./model" import { gcpConnectors } from "./update-gcp" @@ -77,17 +78,10 @@ const FLOW_HREF = hrefOf(AppRoute.SourcesNew({ search: { integration: "gcp" } }) /** A source page whose connection was removed: the way back is the connect flow. */ const gcpReconnect = (h: H, text: string): Html => - h.div( - [ - h.Class( - "flex items-center justify-between gap-4 rounded-lg border border-border bg-card p-4 text-sm", - ), - ], - [ - h.span([h.Class("text-muted-foreground")], [text]), - linkButton(h, { size: "sm", href: FLOW_HREF }, ["Reconnect"]), - ], - ) + integrationNotice(h, { + message: text, + action: linkButton(h, { size: "sm", href: FLOW_HREF }, ["Reconnect"]), + }) const byStatus = ( h: H, diff --git a/apps/web/src/page/integrations/view-github.ts b/apps/web/src/page/integrations/view-github.ts index a80e2c4d1e..610569b09e 100644 --- a/apps/web/src/page/integrations/view-github.ts +++ b/apps/web/src/page/integrations/view-github.ts @@ -15,6 +15,7 @@ import { statusDot } from "../../ui/status-dot" import { switchControl } from "../../ui/switch" import { integrationIconPlate } from "../infra-integrations/kit/integration-empty" import { GITHUB_ACCENT } from "./catalog" +import { integrationNotice } from "./kit/notice" import { Message } from "./message" import type { Model } from "./model" import { ciTracesShown } from "./update-github" @@ -212,21 +213,14 @@ const loadingState = (h: H): Html => /** A source page whose installation was removed: the way back is the connect flow. */ const githubReconnect = (h: H): Html => - h.div( - [ - h.Class( - "flex items-center justify-between gap-4 rounded-lg border border-border bg-card p-4 text-sm", - ), - ], - [ - h.span([h.Class("text-muted-foreground")], ["GitHub is not connected."]), - linkButton( - h, - { size: "sm", href: hrefOf(AppRoute.SourcesNew({ search: { integration: "github" } })) }, - ["Reconnect"], - ), - ], - ) + integrationNotice(h, { + message: "GitHub is not connected.", + action: linkButton( + h, + { size: "sm", href: hrefOf(AppRoute.SourcesNew({ search: { integration: "github" } })) }, + ["Reconnect"], + ), + }) export const githubDeactivated = (h: H, model: Model, status: GithubIntegrationStatus): Html => { const suspended = status.state === "suspended" diff --git a/apps/web/src/page/integrations/view-planetscale.ts b/apps/web/src/page/integrations/view-planetscale.ts index 247d0156c7..0a35f583f5 100644 --- a/apps/web/src/page/integrations/view-planetscale.ts +++ b/apps/web/src/page/integrations/view-planetscale.ts @@ -11,6 +11,7 @@ import { skeleton } from "../../ui/skeleton" import { integrationIconPlate } from "../infra-integrations/kit/integration-empty" import { liveChecklist } from "../kit/connect-flow" import { catalogEntry } from "./catalog" +import { integrationNotice } from "./kit/notice" import { Message } from "./message" import type { Model } from "./model" import { isWatchExpired } from "./planetscale" @@ -234,21 +235,14 @@ const byStatus = (h: H, model: Model, status: V2PlanetScaleIntegration, timeZone /** A source page whose connection was removed: the way back is the connect flow. */ const planetscaleReconnect = (h: H, text: string): Html => - h.div( - [ - h.Class( - "flex items-center justify-between gap-4 rounded-lg border border-border bg-card p-4 text-sm", - ), - ], - [ - h.span([h.Class("text-muted-foreground")], [text]), - linkButton( - h, - { size: "sm", href: hrefOf(AppRoute.SourcesNew({ search: { integration: "planetscale" } })) }, - ["Reconnect"], - ), - ], - ) + integrationNotice(h, { + message: text, + action: linkButton( + h, + { size: "sm", href: hrefOf(AppRoute.SourcesNew({ search: { integration: "planetscale" } })) }, + ["Reconnect"], + ), + }) /** A failed status read is not "not connected": no connect offer over a maybe-authorized org. */ const statusFailed = (h: H): Html => diff --git a/apps/web/src/page/integrations/view-railway.ts b/apps/web/src/page/integrations/view-railway.ts index fd62f3baff..1e97fd11d1 100644 --- a/apps/web/src/page/integrations/view-railway.ts +++ b/apps/web/src/page/integrations/view-railway.ts @@ -10,6 +10,7 @@ import { columnHead, dataTable, dataTableEmpty } from "../infra/kit/data-table" import { relativeTime } from "../kit/relative-time" import { RAILWAY_ACCENT } from "../infra-integrations/kit/integration-empty" import { railwayTokenForm } from "./view-railway-token" +import { integrationNotice } from "./kit/notice" import { Message } from "./message" import type { Model } from "./model" import { unsyncedEnvironments } from "./railway" @@ -180,18 +181,11 @@ const byStatus = (h: H, model: Model, status: RailwayIntegrationStatus, timeZone /** A source page whose token was removed: the way back is the connect flow. */ const railwayReconnect = (h: H): Html => - h.div( - [ - h.Class( - "flex items-center justify-between gap-4 rounded-lg border border-border bg-card p-4 text-sm", - ), - ], - [ - h.span([h.Class("text-muted-foreground")], ["Railway is not connected."]), - linkButton( - h, - { size: "sm", href: hrefOf(AppRoute.SourcesNew({ search: { integration: "railway" } })) }, - ["Reconnect"], - ), - ], - ) + integrationNotice(h, { + message: "Railway is not connected.", + action: linkButton( + h, + { size: "sm", href: hrefOf(AppRoute.SourcesNew({ search: { integration: "railway" } })) }, + ["Reconnect"], + ), + }) diff --git a/apps/web/src/page/integrations/view-supabase.ts b/apps/web/src/page/integrations/view-supabase.ts index b5a49fdbe2..6b25a8c958 100644 --- a/apps/web/src/page/integrations/view-supabase.ts +++ b/apps/web/src/page/integrations/view-supabase.ts @@ -15,6 +15,7 @@ import { relativeTime } from "../kit/relative-time" import { healthPill, isReadPending, lifecycleOf, projectHealth } from "../infra-integrations/supabase/health" import type { SupabaseProjectRow } from "../infra-integrations/supabase/model" import { SUPABASE_BRAND, SUPABASE_NOT_CONFIGURED } from "../infra-integrations/supabase/connect" +import { integrationNotice } from "./kit/notice" import { Message } from "./message" import type { Model } from "./model" import { @@ -357,28 +358,16 @@ export const supabaseCard = (h: H, model: Model, timeZone: string): Html => /** A source page whose organization was disconnected: the way back is the connect flow. */ const supabaseReconnect = (h: H, isConfigured: boolean): Html => - h.div( - [ - h.Class( - "flex items-center justify-between gap-4 rounded-lg border border-border bg-card p-4 text-sm", - ), - ], - [ - h.span( - [h.Class("text-muted-foreground")], - [isConfigured ? "Supabase is not connected." : SUPABASE_NOT_CONFIGURED], - ), - ...(isConfigured - ? [ - linkButton( - h, - { - size: "sm", - href: hrefOf(AppRoute.SourcesNew({ search: { integration: "supabase" } })), - }, - ["Reconnect"], - ), - ] - : []), - ], - ) + integrationNotice(h, { + message: isConfigured ? "Supabase is not connected." : SUPABASE_NOT_CONFIGURED, + action: isConfigured + ? linkButton( + h, + { + size: "sm", + href: hrefOf(AppRoute.SourcesNew({ search: { integration: "supabase" } })), + }, + ["Reconnect"], + ) + : undefined, + }) diff --git a/apps/web/src/page/investigations/conversation/command.ts b/apps/web/src/page/investigations/conversation/command.ts index e9656a2754..b33b4f60ec 100644 --- a/apps/web/src/page/investigations/conversation/command.ts +++ b/apps/web/src/page/investigations/conversation/command.ts @@ -2,7 +2,7 @@ import { ChatApplyRequest } from "@maple/domain/http" import { Effect, Schema } from "effect" import { Command } from "foldkit" import { MapleAiClient } from "../../../platform/api-clients" -import { writeClipboardFallback } from "@maple/ui/lib/clipboard" +import { writeClipboard } from "../../kit/clipboard" import { parseMarkdownTexts } from "../../kit/markdown" import { abortTurn, loadHistory, postMessage } from "./kit/chat-api" import { historyMessageToUIMessage } from "./kit/fold" @@ -91,12 +91,7 @@ const saveFile = (name: string, contents: string, type: string) => export const CopyCode = Command.define("CopyChatCode", { args: { code: Schema.String }, messages: [Message.CompletedCodeAction], - execute: ({ code }) => - Effect.tryPromise(() => navigator.clipboard.writeText(code)).pipe( - Effect.catch(() => Effect.try(() => writeClipboardFallback(code))), - Effect.ignore, - Effect.as(Message.CompletedCodeAction()), - ), + execute: ({ code }) => Effect.as(writeClipboard(code), Message.CompletedCodeAction()), }) export const DownloadCode = Command.define("DownloadChatCode", { diff --git a/apps/web/src/page/investigations/conversation/kit/view-tool.ts b/apps/web/src/page/investigations/conversation/kit/view-tool.ts index 1aa8d693ed..153c93ee4d 100644 --- a/apps/web/src/page/investigations/conversation/kit/view-tool.ts +++ b/apps/web/src/page/investigations/conversation/kit/view-tool.ts @@ -80,7 +80,7 @@ export const runningClock = (h: HtmlBuilder, mount: PanelHost["clockMou const statusGlyph = (h: HtmlBuilder, status: ToolStatus, live: boolean, toolName: string): Html => { if (status === "running") return live ? dotLoader(h) : spinner(h, "size-3.5 text-muted-foreground motion-reduce:animate-none") - if (status === "error") return icon(h, "CircleXmarkIcon", { className: "size-3.5 text-destructive" }) + if (status === "error") return icon(h, "CircleXmarkIcon", { className: "size-3.5 text-severity-error" }) if (status === "stopped") return icon(h, "CircleWarningIcon", { className: "size-3.5 text-muted-foreground" }) return icon(h, toolIcon(toolName), { className: "size-3.5" }) diff --git a/apps/web/src/page/investigations/list/view.ts b/apps/web/src/page/investigations/list/view.ts index 2090e1647b..4315fe1eee 100644 --- a/apps/web/src/page/investigations/list/view.ts +++ b/apps/web/src/page/investigations/list/view.ts @@ -1,6 +1,5 @@ import type { V2Investigation } from "@maple/domain/http/v2" -import { EMPTY_VALUE, formatNumber } from "@maple/ui/lib/format" -import { TONE_TEXT } from "@maple/ui/lib/tone" +import { EMPTY_VALUE, formatNumber, pluralize } from "@maple/ui/lib/format" import { toEpochMs } from "@maple/ui/lib/time-format" import { cn } from "@maple/ui/lib/utils" import { @@ -30,6 +29,7 @@ import * as TabsView from "../../../ui/tabs-view" import { truncatedText } from "../../../ui/truncated-text" import type { PageViewInputs } from "../../contract" import { dashboardPage } from "../../kit/dashboard-page" +import { listToolbar, toolbarCount, toolbarStat } from "../../kit/list-toolbar" import { relativeTime } from "../../kit/relative-time" import { docsLink, filteredEmpty } from "../../kit/signal-empty-state" import { presenceOf } from "../../kit/signal-presence" @@ -67,15 +67,6 @@ const bar = (h: H, model: Model, isAccent = false, placeholder?: string): Html = placeholder, }) -const stat = (h: H, value: number, label: string, tone?: "info" | "warn"): Html => - h.span( - [h.Class("flex items-center gap-1.5 whitespace-nowrap text-sm")], - [ - h.span([h.Class(cn("font-medium tabular-nums", tone && TONE_TEXT[tone]))], [formatNumber(value)]), - h.span([h.Class("text-muted-foreground")], [label]), - ], - ) - const KIND_ENTRIES: ReadonlyArray = KIND_FILTER_VALUES.map((value) => ({ _tag: "Item", item: { value, label: KIND_FILTER_LABEL[value] }, @@ -105,119 +96,106 @@ const toolbar = ( ["history", all.length - activeCount], ]) const stats = triageStatsOf(all, model.nowMs) - // Two rows: tabs beside the triage counters, then the filters with room for their labels. - return h.div( - [h.Class(cn("flex flex-col gap-1.5", isFramed ? "border-b border-border/60 px-2 py-1.5" : "pb-3"))], - [ - h.div( - [h.Class("flex min-w-0 items-center gap-2")], - [ - h.submodel({ - slotId: "investigations-view-tabs", - model: model.tabs, - view: TabsView.view, - viewInputs: { - labels: ["Active", "History"], - ariaLabel: "Filter investigations", - tabClassName: "group/tab h-7 grow-0 px-2.5 text-xs sm:h-7 sm:text-xs", - // Unknown counts (loading or failed) are left out, never a false 0. - tabContent: (value, label) => [ - label, - ...(isKnown - ? [ - h.span( - [ - h.Class( - "text-muted-foreground/70 tabular-nums group-data-active/tab:text-muted-foreground", - ), - ], - [String(counts.get(value) ?? 0)], - ), - ] - : []), - ], - panel: () => undefined, - }, - toParentMessage: (message) => Message.GotTabsMessage({ message }), - }), + return listToolbar(h, { + framed: isFramed, + leading: h.submodel({ + slotId: "investigations-view-tabs", + model: model.tabs, + view: TabsView.view, + viewInputs: { + labels: ["Active", "History"], + ariaLabel: "Filter investigations", + tabClassName: "group/tab h-7 grow-0 px-2.5 text-xs sm:h-7 sm:text-xs", + // Unknown counts (loading or failed) are left out, never a false 0. + tabContent: (value, label) => [ + label, ...(isKnown ? [ - h.div( - [h.Class("ml-auto hidden items-center gap-4 sm:flex")], + h.span( [ - stat( - h, - stats.running, - "investigating", - stats.running > 0 ? "info" : undefined, - ), - stat( - h, - stats.review, - stats.critical > 0 - ? `to review (${stats.critical} critical)` - : "to review", - stats.review > 0 ? "warn" : undefined, + h.Class( + "text-muted-foreground/70 tabular-nums group-data-active/tab:text-muted-foreground", ), - stat(h, stats.resolved, "resolved in 24h"), ], + [String(counts.get(value) ?? 0)], ), ] : []), ], - ), - h.div( - [h.Class("flex min-w-0 flex-wrap items-center gap-2")], - [ - h.submodel({ - slotId: model.kindSelect.id, - model: model.kindSelect, - view: SelectView.view, - viewInputs: { - entries: KIND_ENTRIES, - className: "w-[140px]", - renderValue: (value) => [kindLabel(value)], - toView: (parts) => h.div([h.Class("contents")], parts), - }, - toParentMessage: (message) => Message.GotKindSelectMessage({ message }), - }), - h.submodel({ - slotId: model.sortSelect.id, - model: model.sortSelect, - view: SelectView.view, - viewInputs: { - entries: SORT_ENTRIES, - className: "w-[168px]", - renderValue: (value) => [sortLabel(value)], - toView: (parts) => h.div([h.Class("contents")], parts), - }, - toParentMessage: (message) => Message.GotSortSelectMessage({ message }), - }), - searchInput(h, { - id: "investigations-search", - size: "default", - className: "w-auto min-w-56 max-w-sm flex-1", - placeholder: "Search subjects and findings", - value: model.searchDraft, - onInput: (value) => Message.ChangedSearchDraft({ value }), - onClear: Message.ChangedSearchDraft({ value: "" }), - }), - h.span( - [h.Class("ml-auto text-xs text-muted-foreground tabular-nums")], - !isKnown - ? [] - : hasMore - ? [`Showing ${visible.length} of the ${all.length} most recent`] - : [ - String(visible.length), - " ", - visible.length === 1 ? "investigation" : "investigations", - ], - ), - ], + panel: () => undefined, + }, + toParentMessage: (message) => Message.GotTabsMessage({ message }), + }), + trailing: [ + ...(isKnown + ? [ + h.div( + [h.Class("hidden items-center gap-3 sm:flex")], + [ + toolbarStat(h, { + value: formatNumber(stats.running), + label: "investigating", + dot: stats.running > 0 ? "info" : undefined, + }), + toolbarStat(h, { + value: formatNumber(stats.review), + label: + stats.critical > 0 + ? `to review (${stats.critical} critical)` + : "to review", + dot: stats.review > 0 ? "warn" : undefined, + }), + toolbarStat(h, { + value: formatNumber(stats.resolved), + label: "resolved in 24h", + }), + ], + ), + ] + : []), + h.submodel({ + slotId: model.kindSelect.id, + model: model.kindSelect, + view: SelectView.view, + viewInputs: { + entries: KIND_ENTRIES, + className: "w-[140px]", + renderValue: (value) => [kindLabel(value)], + toView: (parts) => h.div([h.Class("contents")], parts), + }, + toParentMessage: (message) => Message.GotKindSelectMessage({ message }), + }), + h.submodel({ + slotId: model.sortSelect.id, + model: model.sortSelect, + view: SelectView.view, + viewInputs: { + entries: SORT_ENTRIES, + className: "w-[168px]", + renderValue: (value) => [sortLabel(value)], + toView: (parts) => h.div([h.Class("contents")], parts), + }, + toParentMessage: (message) => Message.GotSortSelectMessage({ message }), + }), + searchInput(h, { + id: "investigations-search", + size: "default", + className: "w-auto min-w-56 max-w-sm flex-1", + placeholder: "Search subjects and findings", + value: model.searchDraft, + onInput: (value) => Message.ChangedSearchDraft({ value }), + onClear: Message.ChangedSearchDraft({ value: "" }), + }), + toolbarCount( + h, + !isKnown + ? [] + : hasMore + ? [`Showing ${visible.length} of the ${all.length} most recent`] + : [String(visible.length), " ", pluralize(visible.length, "investigation")], ), ], - ) + }) } const rowFinding = (h: H, investigation: V2Investigation): Html => { diff --git a/apps/web/src/page/kit/clipboard.ts b/apps/web/src/page/kit/clipboard.ts index 742cd0faab..1f7d1fd07a 100644 --- a/apps/web/src/page/kit/clipboard.ts +++ b/apps/web/src/page/kit/clipboard.ts @@ -1,14 +1,8 @@ -import { writeClipboardFallback } from "@maple/ui/lib/clipboard" -import { Effect } from "effect" +import * as CopyButton from "../../ui/copy-button" import { PageOutMessage } from "../contract" /** `useCopy`'s write: the clipboard API, then the `execCommand` fallback; `true` when it landed. */ -export const writeClipboard = (text: string): Effect.Effect => - Effect.tryPromise(() => navigator.clipboard.writeText(text)).pipe( - Effect.as(true), - Effect.catch(() => Effect.try(() => writeClipboardFallback(text))), - Effect.orElseSucceed(() => false), - ) +export const writeClipboard = CopyButton.writeClipboard /** `useCopy({ label, successMessage })`'s toast for a finished copy (no label: the generic titles). */ export const copyToast = (label: string | undefined, isOk: boolean, successMessage?: string) => diff --git a/apps/web/src/page/kit/list-toolbar.ts b/apps/web/src/page/kit/list-toolbar.ts new file mode 100644 index 0000000000..1ede2dc7ae --- /dev/null +++ b/apps/web/src/page/kit/list-toolbar.ts @@ -0,0 +1,70 @@ +import type { Tone } from "@maple/ui/lib/tone" +import { cn } from "@maple/ui/lib/utils" +import type { Attribute, Html, HtmlBuilder } from "foldkit/html" +import { statusDot } from "../../ui/status-dot" + +// `ListToolbar`: a list page's row of view tabs, then search, filters and the count readout. + +export type ListToolbarOptions = Readonly<{ + /** View tabs or a scope picker, pinned left. */ + leading?: Html | null + /** Search, filters and the count readout, right-aligned and wrapping. */ + trailing?: ReadonlyArray + /** Inside a panel: a divided header row instead of a bare row above the list. */ + framed?: boolean + className?: string +}> + +export const listToolbar = (h: HtmlBuilder, options: ListToolbarOptions): Html => + h.div( + [ + h.DataAttribute("slot", "list-toolbar"), + h.Class( + cn( + "flex flex-wrap items-center gap-2 gap-y-1.5", + options.framed === true ? "border-b border-border/60 px-2 py-1.5" : "pb-3", + options.className, + ), + ), + ], + [ + ...(options.leading === undefined || options.leading === null ? [] : [options.leading]), + h.div( + [h.Class("ml-auto flex min-w-0 flex-wrap items-center justify-end gap-2")], + options.trailing ?? [], + ), + ], + ) + +/** The quiet count readout: "12 issues", "50+ issues", "Top 20 of 312 pods". */ +export const toolbarCount = (h: HtmlBuilder, children: ReadonlyArray): Html => + h.span([h.Class("whitespace-nowrap text-xs text-muted-foreground tabular-nums")], children) + +/** A figure in the readout: the value in the foreground, its label muted, an optional status dot. */ +export const toolbarStat = ( + h: HtmlBuilder, + options: Readonly<{ + value: string + label: string + dot?: Tone | undefined + attributes?: ReadonlyArray> + }>, +): Html => + h.span( + [ + h.Class("flex items-center gap-1.5 whitespace-nowrap text-xs tabular-nums"), + ...(options.attributes ?? []), + ], + [ + ...(options.dot === undefined ? [] : [statusDot(h, { tone: options.dot })]), + // One inline run, so the value and label keep their space for screen readers. + h.span( + [], + [ + h.span([h.Class("font-medium text-foreground")], [options.value]), + " ", + h.span([h.Class("text-muted-foreground")], [options.label]), + ], + ), + ], + ) diff --git a/apps/web/src/page/logs/detail/view-hero.ts b/apps/web/src/page/logs/detail/view-hero.ts index e7cbefc482..07bbcbd213 100644 --- a/apps/web/src/page/logs/detail/view-hero.ts +++ b/apps/web/src/page/logs/detail/view-hero.ts @@ -1,9 +1,10 @@ -import { getSeverityColor, SEVERITY_COLORS } from "@maple/ui/lib/severity" +import { SEVERITY_COLORS } from "@maple/ui/lib/severity" import { cn } from "@maple/ui/lib/utils" import type { ChildAttribute, Html, HtmlBuilder } from "foldkit/html" import { button } from "../../../ui/button" import { copyButton } from "../../../ui/copy-button" import { badge } from "../../../ui/display" +import { severityBadge } from "../../../ui/logs/severity-badge" import { icon } from "../../../ui/icon" import { logMessageOf, logMessageView } from "../../../ui/logs/log-message" import type { LogRecord } from "../../../ui/logs/log-row-view" @@ -74,34 +75,16 @@ export const logTextButton = ( children, ) -const severityBadge = (h: H, log: LogRecord): Html => { - const label = severityLabel(log.severityText, log.severityNumber) - const color = getSeverityColor(label) - return h.span( +const severityTrigger = (h: H, log: LogRecord): Html => + h.span( [ h.Class("cursor-help inline-flex"), h.DataAttribute("base-ui-tooltip-trigger", ""), h.DataAttribute("slot", "tooltip-trigger"), h.Id("log-detail-severity-trigger"), ], - [ - badge( - h, - { - variant: "secondary", - className: "font-mono text-3xs uppercase", - attributes: [ - h.Style({ - color, - "background-color": `color-mix(in oklch, ${color} 10%, transparent)`, - }), - ], - }, - [label], - ), - ], + [severityBadge(h, { severity: log.severityText, severityNumber: log.severityNumber })], ) -} export const heroHeader = ( h: H, @@ -118,7 +101,7 @@ export const heroHeader = ( h.div( [h.Class("flex items-center gap-2")], [ - severityBadge(h, log), + severityTrigger(h, log), badge(h, { variant: "outline", size: "xs", mono: true }, [ serviceDot(h, { serviceName: log.serviceName, size: "sm" }), copyable(h, model, "hero-service", log.serviceName, [log.serviceName]), diff --git a/apps/web/src/page/logs/detail/view-panels.ts b/apps/web/src/page/logs/detail/view-panels.ts index 64c6229967..2a40140dca 100644 --- a/apps/web/src/page/logs/detail/view-panels.ts +++ b/apps/web/src/page/logs/detail/view-panels.ts @@ -9,6 +9,7 @@ import { Effect } from "effect" import { AsyncData, Mount } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { ROW_LINE_CHARS } from "../../../lib/display-text" +import { separator } from "../../../ui/display" import { emptyMessage } from "../../../ui/empty" import { errorSectionView } from "../../../ui/error-section" import { errorState } from "../../../ui/error-state" @@ -285,7 +286,7 @@ const timelineRows = (h: H, model: Model, current: LogRecord, data: ReadonlyArra h.div( [h.Class("flex items-center gap-2 px-2 py-0.5 bg-muted/30")], [ - h.div([h.Class("h-px flex-1 bg-border")], []), + separator(h, "horizontal", "flex-1"), h.span( [ h.Class( @@ -294,7 +295,7 @@ const timelineRows = (h: H, model: Model, current: LogRecord, data: ReadonlyArra ], [spanNames.get(spanChanged) ?? shortId(spanChanged, "span")], ), - h.div([h.Class("h-px flex-1 bg-border")], []), + separator(h, "horizontal", "flex-1"), ], ), ]), diff --git a/apps/web/src/page/onboarding/create-org-view.ts b/apps/web/src/page/onboarding/create-org-view.ts index b56e0be76f..1f6f4e927a 100644 --- a/apps/web/src/page/onboarding/create-org-view.ts +++ b/apps/web/src/page/onboarding/create-org-view.ts @@ -230,7 +230,7 @@ const form = (h: H, model: Model, memberships: ReadonlyArray) { type: "submit", size: "lg", - className: "h-12 w-full text-[15px] font-semibold", + className: "h-12 w-full text-title font-semibold", isLoading: model.isCreating, isDisabled: model.name.trim() === "" || @@ -305,7 +305,7 @@ const workspacePreview = (h: H, model: Model): Html => [initialOf(model.name)], ), h.span( - [h.Class("min-w-0 flex-1 truncate text-[13px] font-medium")], + [h.Class("min-w-0 flex-1 truncate text-ui font-medium")], [nameOf(model.name)], ), ...(hasMultipleRegions @@ -337,7 +337,7 @@ const workspacePreview = (h: H, model: Model): Html => [h.Class("flex flex-1 flex-col gap-4 p-5")], [ h.span( - [h.Class("font-display text-[15px] font-semibold")], + [h.Class("font-display text-title font-semibold")], [`Welcome to ${nameOf(model.name)}`], ), h.div( @@ -352,7 +352,7 @@ const workspacePreview = (h: H, model: Model): Html => [icon(h, "FrameworkOpenTelemetryIcon", { size: 28 })], ), h.span( - [h.Class("text-[13px] font-medium")], + [h.Class("text-ui font-medium")], ["Waiting for your first span"], ), h.span( @@ -409,7 +409,7 @@ export const view = ( h.p( [ h.Class( - "max-w-md text-[15px] leading-relaxed text-muted-foreground", + "max-w-md text-title leading-relaxed text-muted-foreground", ), ], [ diff --git a/apps/web/src/page/onboarding/quick-start-team-view.ts b/apps/web/src/page/onboarding/quick-start-team-view.ts index dc2962d9e0..74a27a8125 100644 --- a/apps/web/src/page/onboarding/quick-start-team-view.ts +++ b/apps/web/src/page/onboarding/quick-start-team-view.ts @@ -115,7 +115,7 @@ export const teamStep = (h: H, team: Team): Html => { ["Bring in your team and tools"], ), h.p( - [h.Class("text-[15px] leading-relaxed text-muted-foreground")], + [h.Class("text-title leading-relaxed text-muted-foreground")], ["Maple shows up where you already work. Every one of these is optional."], ), ], @@ -220,10 +220,7 @@ export const teamPreview = (h: H): Html => h.div( [h.Class("mb-1 flex flex-col gap-1")], [ - h.span( - [h.Class("text-2xs uppercase tracking-widest text-muted-foreground")], - ["Where Maple shows up"], - ), + eyebrow(h, { variant: "label" }, ["Where Maple shows up"]), h.span([h.Class("font-display text-xl font-semibold")], ["Right where the work happens"]), ], ), @@ -235,7 +232,7 @@ export const teamPreview = (h: H): Html => [h.Class("flex items-center gap-2 text-xs text-muted-foreground")], [icon(h, surface.icon, { size: 14 }), surface.where], ), - h.span([h.Class("text-[13px] font-medium")], [surface.title]), + h.span([h.Class("text-ui font-medium")], [surface.title]), h.span([h.Class("text-xs leading-snug text-muted-foreground")], [surface.body]), ], ), diff --git a/apps/web/src/page/onboarding/quick-start-view.ts b/apps/web/src/page/onboarding/quick-start-view.ts index 4cd6fbd0e1..b4c342eb5a 100644 --- a/apps/web/src/page/onboarding/quick-start-view.ts +++ b/apps/web/src/page/onboarding/quick-start-view.ts @@ -30,6 +30,8 @@ import * as QuickStartTeamView from "./quick-start-team-view" import { STEP_BODY_CLASS, STEP_FOOTER_CLASS } from "./step-layout" import { Message, type Model, MountDitherField } from "./quick-start" import { type QuickStartState, resolveQuickStartStep, type StepId } from "./quick-start-state" +import { eyebrow, eyebrowVariants } from "../../ui/eyebrow" +import { statusDot } from "../../ui/status-dot" // `/quick-start`: each step's form on the left and, where it has one, a preview of what it sets up on // the right, over the dithered backdrop that spreads a little further every step. @@ -293,14 +295,13 @@ const stackCard = (h: H, item: StackItem, isChecked: boolean, onChange: Message) ], ) -const STACK_LEGEND_CLASS = "text-2xs uppercase tracking-widest text-muted-foreground" const STACK_GRID_CLASS = "grid gap-2 sm:grid-cols-2 xl:grid-cols-3" const stackGroup = (h: H, legend: string, cards: ReadonlyArray): Html => h.fieldset( [h.Class("flex min-w-0 flex-col gap-3")], [ - h.legend([h.Class(cn("mb-3", STACK_LEGEND_CLASS))], [legend]), + h.legend([h.Class(cn("mb-3", eyebrowVariants({ variant: "label" })))], [legend]), h.div([h.Class(STACK_GRID_CLASS)], cards), ], ) @@ -329,7 +330,7 @@ const languageGroup = (h: H, state: QuickStartState, query: string): Html => { h.div( [h.Class("flex items-center justify-between gap-4")], [ - h.span([h.AriaHidden(true), h.Class(STACK_LEGEND_CLASS)], ["Your code"]), + eyebrow(h, { variant: "label", attributes: [h.AriaHidden(true)] }, ["Your code"]), searchInput(h, { id: "stack-language-search", value: query, @@ -407,7 +408,7 @@ const stackStep = (h: H, state: QuickStartState, query: string, hasPlan: boolean ["What are you running?"], ), h.p( - [h.Class("text-[15px] leading-relaxed text-muted-foreground")], + [h.Class("text-title leading-relaxed text-muted-foreground")], ["Pick everything that applies. We set up just those, and you can add more later."], ), ], @@ -472,10 +473,7 @@ const stackPreview = (h: H, orgName: string, state: QuickStartState): Html => { h.div( [h.Class("flex flex-col gap-1")], [ - h.span( - [h.Class("text-2xs uppercase tracking-widest text-muted-foreground")], - [`What ${orgName} gets`], - ), + eyebrow(h, { variant: "label" }, [`What ${orgName} gets`]), h.span( [h.Class("font-display text-xl font-semibold")], [ @@ -584,7 +582,7 @@ const providerRow = (h: H, model: Model, platform: StackPlatform): Html => { [h.Class("flex items-center gap-3")], [ h.span( - [h.Class("flex items-center gap-1.5 text-sm text-success")], + [h.Class("flex items-center gap-1.5 text-sm text-severity-info")], [icon(h, "CircleCheckIcon", { size: 14 }), "Connected"], ), // A push source made after its sheet was dismissed: its one-time token is still there. @@ -636,7 +634,7 @@ export const connectStep = (h: H, model: Model, state: QuickStartState): Html => ["Connect your stack"], ), h.p( - [h.Class("text-[15px] leading-relaxed text-muted-foreground")], + [h.Class("text-title leading-relaxed text-muted-foreground")], [ "Connect each provider without leaving setup. Your code sends its traces, logs and metrics with OpenTelemetry: one prompt to your coding agent, or a few lines by hand.", ], @@ -649,10 +647,7 @@ export const connectStep = (h: H, model: Model, state: QuickStartState): Html => h.section( [h.Class("flex flex-col gap-3")], [ - h.h2( - [h.Class("text-2xs uppercase tracking-widest text-muted-foreground")], - ["Providers"], - ), + eyebrow(h, { variant: "label", as: "h2" }, ["Providers"]), h.ul( [ h.Class( @@ -670,14 +665,9 @@ export const connectStep = (h: H, model: Model, state: QuickStartState): Html => h.div( [h.Class("flex items-center justify-between gap-3")], [ - h.h2( - [ - h.Class( - "flex items-center gap-2 text-2xs uppercase tracking-widest text-muted-foreground", - ), - ], - ["Your code, with OpenTelemetry"], - ), + eyebrow(h, { variant: "label", as: "h2", className: "flex items-center gap-2" }, [ + "Your code, with OpenTelemetry", + ]), // The languages picked, or every SDK there are snippets for when none was. h.span( [h.AriaHidden(true), h.Class("flex items-center gap-1.5")], @@ -782,10 +772,7 @@ const connectPreview = (h: H, orgName: string, model: Model, state: QuickStartSt h.div( [h.Class("flex flex-col gap-1")], [ - h.span( - [h.Class("text-2xs uppercase tracking-widest text-muted-foreground")], - [`Arriving in ${orgName}`], - ), + eyebrow(h, { variant: "label" }, [`Arriving in ${orgName}`]), h.span( [h.Class("font-display text-xl font-semibold")], [ @@ -802,10 +789,7 @@ const connectPreview = (h: H, orgName: string, model: Model, state: QuickStartSt "flex items-center gap-1.5 rounded-sm border border-border px-2 py-0.5 text-2xs text-muted-foreground", ), ], - [ - h.span([h.AriaHidden(true), h.Class("size-1.5 rounded-full bg-success")], []), - "Listening", - ], + [statusDot(h, { tone: "ok" }), "Listening"], ), ], ), @@ -827,7 +811,7 @@ const connectPreview = (h: H, orgName: string, model: Model, state: QuickStartSt cn( "text-xs", row.presence.status === "present" - ? "text-success" + ? "text-severity-info" : "text-muted-foreground", ), ), @@ -858,7 +842,7 @@ const connectPreview = (h: H, orgName: string, model: Model, state: QuickStartSt cn( "text-xs", model.connect.connected[platform] === true - ? "text-success" + ? "text-severity-info" : "text-muted-foreground", ), ), @@ -957,7 +941,14 @@ export const liveStep = (h: H, model: Model, state: QuickStartState, orgName: st [h.Class("flex flex-col items-center gap-3 text-center")], [ h.p( - [h.Class(cn("text-sm", isLive ? "text-success" : "text-muted-foreground"))], + [ + h.Class( + cn( + "text-sm", + isLive ? "text-severity-info" : "text-muted-foreground", + ), + ), + ], [isLive ? "Data is flowing" : "All set up"], ), h.h1( @@ -974,7 +965,7 @@ export const liveStep = (h: H, model: Model, state: QuickStartState, orgName: st chip(h, [ icon(h, "CircleCheckIcon", { size: 12, - className: "text-success", + className: "text-severity-info", }), row.label, ]), @@ -999,10 +990,7 @@ export const liveStep = (h: H, model: Model, state: QuickStartState, orgName: st h.section( [h.Class("flex flex-col gap-3")], [ - h.h2( - [h.Class("text-2xs uppercase tracking-widest text-muted-foreground")], - ["Start with your own data"], - ), + eyebrow(h, { variant: "label", as: "h2" }, ["Start with your own data"]), h.ul( [ h.Class( @@ -1017,14 +1005,11 @@ export const liveStep = (h: H, model: Model, state: QuickStartState, orgName: st h.div( [h.Class("flex flex-col gap-1")], [ - h.span( - [h.Class("text-[15px] font-medium")], - [card.title], - ), + h.span([h.Class("text-title font-medium")], [card.title]), h.span( [ h.Class( - "text-[13px] leading-relaxed text-muted-foreground", + "text-ui leading-relaxed text-muted-foreground", ), ], [card.finding], @@ -1035,7 +1020,7 @@ export const liveStep = (h: H, model: Model, state: QuickStartState, orgName: st [ h.Href(card.href), h.Class( - "mt-auto flex items-center gap-1.5 text-[13px] text-primary hover:underline", + "mt-auto flex items-center gap-1.5 text-ui text-primary hover:underline", ), ], [card.action, icon(h, "ArrowRightIcon", { size: 12 })], @@ -1052,14 +1037,7 @@ export const liveStep = (h: H, model: Model, state: QuickStartState, orgName: st h.section( [h.Class("flex flex-col gap-3")], [ - h.h2( - [ - h.Class( - "text-2xs uppercase tracking-widest text-muted-foreground", - ), - ], - ["Still waiting on"], - ), + eyebrow(h, { variant: "label", as: "h2" }, ["Still waiting on"]), h.ul( [ h.Class( diff --git a/apps/web/src/page/releases/detail/view-sections.ts b/apps/web/src/page/releases/detail/view-sections.ts index 2e2ba5066c..d131044605 100644 --- a/apps/web/src/page/releases/detail/view-sections.ts +++ b/apps/web/src/page/releases/detail/view-sections.ts @@ -19,7 +19,7 @@ import { } from "../../../components/releases/release-model" import type { Html, HtmlBuilder } from "foldkit/html" import { AppRoute, hrefOf } from "../../../route" -import { badge } from "../../../ui/display" +import { severityBadge } from "../../errors/kit/severity" import { emptyMessage } from "../../../ui/empty" import { listRow } from "../../../ui/list-row" import { panel, panelBody, panelHeader } from "../../../ui/panel" @@ -386,23 +386,6 @@ export const releaseChangeset = (h: HtmlBuilder, state: ChangesetState, cl ) } -// Issue severity tones and labels (as in page/errors/kit/severity.ts). -const SEVERITY = new Map([ - ["critical", { tone: "bg-severity-fatal/12 text-severity-fatal", label: "Critical" }], - ["high", { tone: "bg-severity-error/12 text-severity-error", label: "High" }], - ["medium", { tone: "bg-severity-warn/12 text-severity-warn", label: "Medium" }], - ["low", { tone: "bg-severity-debug/12 text-severity-debug", label: "Low" }], -]) - -const severityBadge = (h: HtmlBuilder, severity: string | null): Html => { - const known = severity === null ? undefined : SEVERITY.get(severity) - return known === undefined - ? h.span([h.Class(cn("inline-flex h-5", "w-[60px] justify-center")), h.Title("Severity not set")]) - : badge(h, { variant: "outline", className: cn(known.tone, "w-[60px] justify-center") }, [ - known.label, - ]) -} - const issueLine = ( h: HtmlBuilder, issue: ErrorIssueDocument, @@ -419,14 +402,14 @@ const issueLine = ( ? h.span( [h.Class("contents")], [ - severityBadge(h, issue.severity), + severityBadge(h, issue.severity, "w-[60px] justify-center"), h.span( [h.Class("ml-2.5 inline-flex"), h.Title(issue.serviceName)], [serviceDot(h, { serviceName: issue.serviceName })], ), ], ) - : severityBadge(h, issue.severity), + : severityBadge(h, issue.severity, "w-[60px] justify-center"), title: truncatedText(h, { text: title, className: "font-normal" }), trailing: h.span( [h.Class("contents")], diff --git a/apps/web/src/page/replays/detail/view-rail.ts b/apps/web/src/page/replays/detail/view-rail.ts index b689e124fd..26a1813ea2 100644 --- a/apps/web/src/page/replays/detail/view-rail.ts +++ b/apps/web/src/page/replays/detail/view-rail.ts @@ -19,10 +19,12 @@ import { emptyMessage } from "../../../ui/empty" import { errorState } from "../../../ui/error-state" import { httpSpanLabel } from "../../../ui/http-span-label" import { icon, type IconName } from "../../../ui/icon" +import { iconButton } from "../../../ui/icon-button" import { inlineCode } from "../../../ui/inline-code" import { meter } from "../../../ui/meter" import { skeleton } from "../../../ui/skeleton" import { togglePrimitive } from "../../../ui/toggle" +import { MARKER_SOFT, MARKER_TEXT } from "../kit/replay-meta" import { realOffsetFrom, timelineFrom } from "./derive" import { Message } from "./message" import type { EventFilter, EventRow, Model, RailTab, ReplayDetail, TraceSummary } from "./model" @@ -45,22 +47,11 @@ const FILTER_LABELS = { error: "Errors", } satisfies Record +// Event kinds are categories, so they take the timeline markers' chart hues; network has no marker. const EVENT_KIND_VISUALS = { - navigation: { - icon: "PixelWindowIcon", - tone: "text-success-foreground", - selected: "bg-success/15 text-success-foreground", - }, - click: { - icon: "PixelCrosshairsIcon", - tone: "text-warning-foreground", - selected: "bg-warning/15 text-warning-foreground", - }, - network: { - icon: "PixelNodesIcon", - tone: "text-info-foreground", - selected: "bg-info/15 text-info-foreground", - }, + navigation: { icon: "PixelWindowIcon", tone: MARKER_TEXT.nav, selected: MARKER_SOFT.nav }, + click: { icon: "PixelCrosshairsIcon", tone: MARKER_TEXT.click, selected: MARKER_SOFT.click }, + network: { icon: "PixelNodesIcon", tone: "text-chart-5", selected: "bg-chart-5/15 text-chart-5" }, custom: { icon: "PixelSparkleIcon", tone: "text-primary", selected: "bg-primary/15 text-primary" }, console: { icon: "PixelBracketsCurlyIcon", @@ -111,7 +102,7 @@ const eventVisual = (ev: EventRow): { icon: IconName; tone: string; lead: string ev.level === "error" ? EVENT_KIND_VISUALS.error.tone : ev.level === "warn" - ? EVENT_KIND_VISUALS.click.tone + ? TONE_TEXT.warn : EVENT_KIND_VISUALS.console.tone return { ...EVENT_KIND_VISUALS.console, tone, lead: ev.message, trail: "" } } @@ -221,17 +212,20 @@ const filterBar = (h: H, model: RailView, events: ReadonlyArray): Html ], ), ), - h.button( - [ - h.Type("button"), - h.Class( - "inline-flex size-5 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground data-[popup-open]:bg-muted data-[popup-open]:text-foreground ml-auto", - ), - h.AriaLabel("What the event icons mean"), - h.AriaHasPopup("dialog"), - h.AriaExpanded(false), - h.DataAttribute("base-ui-popover-trigger", ""), - ], + iconButton( + h, + { + label: "What the event icons mean", + size: "icon-xs", + hasTooltip: false, + className: + "ml-auto size-5 text-muted-foreground sm:size-5 data-[popup-open]:bg-muted data-[popup-open]:text-foreground", + attributes: [ + h.AriaHasPopup("dialog"), + h.AriaExpanded(false), + h.DataAttribute("base-ui-popover-trigger", ""), + ], + }, [icon(h, "CircleInfoIcon", { size: 13 })], ), ], diff --git a/apps/web/src/page/replays/detail/view-session.ts b/apps/web/src/page/replays/detail/view-session.ts index c1bed5f0ee..b37effc691 100644 --- a/apps/web/src/page/replays/detail/view-session.ts +++ b/apps/web/src/page/replays/detail/view-session.ts @@ -1,4 +1,4 @@ -import { EMPTY_VALUE } from "@maple/ui/lib/format" +import { EMPTY_VALUE, pluralize } from "@maple/ui/lib/format" import { shortId } from "@maple/ui/lib/ids" import { formatSessionDuration } from "@maple/ui/lib/replay-format" import { parseAttributes } from "@maple/ui/lib/span-tree" @@ -33,10 +33,28 @@ const row = ( ): Html => detailRailRow(h, { ...options, labelWidth: "102px" }, children) const value = (h: H, text: string, options: Readonly<{ isMono?: boolean; className?: string }> = {}): Html => - h.span([h.Class(cn("text-xs text-foreground", options.isMono === true && "font-mono tabular-nums", options.className))], [text]) + h.span( + [ + h.Class( + cn( + "text-xs text-foreground", + options.isMono === true && "font-mono tabular-nums", + options.className, + ), + ), + ], + [text], + ) /** `CopyButton iconSize={12} className="ml-1 size-5 shrink-0" toast={false}`. */ -export const copy = (h: H, model: Pick, key: string, text: string, label: string, className = "ml-1 size-5 shrink-0"): Html => +export const copy = ( + h: H, + model: Pick, + key: string, + text: string, + label: string, + className = "ml-1 size-5 shrink-0", +): Html => CopyButton.view(h, { model: model.copies[key] ?? CopyButton.init(), toParentMessage: (message) => Message.GotCopyMessage({ key, message }), @@ -60,23 +78,39 @@ const stat = (h: H, iconName: IconName, label: string, count: number, isDanger = ), ], [ - h.span([h.Class("flex items-center gap-1 text-3xs text-muted-foreground")], [ - icon(h, iconName, { className: cn("size-3 shrink-0", isDanger && "text-severity-error"), attributes: [h.AriaHidden(true)] }), - h.span([h.Class("truncate")], [label]), - ]), h.span( - [h.Class(cn("font-mono text-sm font-semibold tabular-nums", isDanger ? "text-severity-error" : "text-foreground"))], + [h.Class("flex items-center gap-1 text-3xs text-muted-foreground")], + [ + icon(h, iconName, { + className: cn("size-3 shrink-0", isDanger && "text-severity-error"), + attributes: [h.AriaHidden(true)], + }), + h.span([h.Class("truncate")], [label]), + ], + ), + h.span( + [ + h.Class( + cn( + "font-mono text-sm font-semibold tabular-nums", + isDanger ? "text-severity-error" : "text-foreground", + ), + ), + ], [String(count)], ), ], ) const legend = (h: H, swatch: string, label: string, text: string): Html => - h.span([h.Class("flex items-center gap-1.5")], [ - statusDot(h, { tone: "custom", className: swatch }), - label, - h.span([h.Class("font-mono tabular-nums text-foreground")], [text]), - ]) + h.span( + [h.Class("flex items-center gap-1.5")], + [ + statusDot(h, { tone: "custom", className: swatch }), + label, + h.span([h.Class("font-mono tabular-nums text-foreground")], [text]), + ], + ) const summary = (h: H, session: ReplayDetail): Html => { const total = session.durationMs @@ -84,33 +118,62 @@ const summary = (h: H, session: ReplayDetail): Html => { const idle = session.idleTimeMs const share = total !== null && total > 0 && (active !== null || idle !== null) - ? { active: Math.min(100, ((active ?? 0) / total) * 100), idle: Math.min(100, ((idle ?? 0) / total) * 100) } + ? { + active: Math.min(100, ((active ?? 0) / total) * 100), + idle: Math.min(100, ((idle ?? 0) / total) * 100), + } : null const isErrored = session.errorCount > 0 - return h.section([h.Class("border-b border-border/40 px-4 py-3.5")], [ - statFigure(h, { value: formatSessionDuration(total), unit: "on the page" }), - ...(share === null - ? [] - : [ - segmentedBar(h, { - segments: [ - { key: "active", value: share.active, className: "bg-primary" }, - { key: "idle", value: share.idle, className: "bg-muted-foreground/40" }, - ], - total: 100, - className: "mt-3 h-1 gap-px bg-muted", - }), - h.div([h.Class("mt-2 flex items-center gap-4 text-2xs text-muted-foreground")], [ - ...(active === null ? [] : [legend(h, "bg-primary", "Active", formatSessionDuration(active))]), - ...(idle === null ? [] : [legend(h, "bg-muted-foreground/40", "Idle", formatSessionDuration(idle))]), + return h.section( + [h.Class("border-b border-border/40 px-4 py-3.5")], + [ + statFigure(h, { value: formatSessionDuration(total), unit: "on the page" }), + ...(share === null + ? [] + : [ + segmentedBar(h, { + segments: [ + { key: "active", value: share.active, className: "bg-primary" }, + { key: "idle", value: share.idle, className: "bg-muted-foreground/40" }, + ], + total: 100, + className: "mt-3 h-1 gap-px bg-muted", + }), + h.div( + [h.Class("mt-2 flex items-center gap-4 text-2xs text-muted-foreground")], + [ + ...(active === null + ? [] + : [legend(h, "bg-primary", "Active", formatSessionDuration(active))]), + ...(idle === null + ? [] + : [ + legend( + h, + "bg-muted-foreground/40", + "Idle", + formatSessionDuration(idle), + ), + ]), + ], + ), ]), - ]), - h.div([h.Class("mt-3 grid grid-cols-3 gap-1.5")], [ - stat(h, "FileIcon", "Pages", session.pageViews || 1), - stat(h, "CursorPointerIcon", "Clicks", session.clickCount), - stat(h, "AlertWarningIcon", session.errorCount === 1 ? "Error" : "Errors", session.errorCount, isErrored), - ]), - ]) + h.div( + [h.Class("mt-3 grid grid-cols-3 gap-1.5")], + [ + stat(h, "FileIcon", "Pages", session.pageViews || 1), + stat(h, "CursorPointerIcon", "Clicks", session.clickCount), + stat( + h, + "AlertWarningIcon", + pluralize(session.errorCount, "Error"), + session.errorCount, + isErrored, + ), + ], + ), + ], + ) } const identity = (h: H, model: SessionView, session: ReplayDetail): Html | null => { @@ -119,7 +182,13 @@ const identity = (h: H, model: SessionView, session: ReplayDetail): Html | null const { userName, userEmail, groupName, groupId } = session if (!userName && !userEmail && !userId && !groupName && !groupId && traits.length === 0) return null return detailRailGroup(h, "Identity", [ - ...(userName ? [row(h, { icon: "UserIcon", label: "Name", title: userName }, [value(h, userName, { className: "truncate" })])] : []), + ...(userName + ? [ + row(h, { icon: "UserIcon", label: "Name", title: userName }, [ + value(h, userName, { className: "truncate" }), + ]), + ] + : []), ...(userEmail ? [ row(h, { icon: "EnvelopeIcon", label: "Email", title: userEmail }, [ @@ -131,7 +200,12 @@ const identity = (h: H, model: SessionView, session: ReplayDetail): Html | null ...(userId ? [ row(h, { icon: "IdBadgeIcon", label: "User ID", title: userId }, [ - link(h, hrefOf(AppRoute.Replays({ search: { userId } })), userId, "truncate font-mono text-xs text-primary underline-offset-2 hover:underline"), + link( + h, + hrefOf(AppRoute.Replays({ search: { userId } })), + userId, + "truncate font-mono text-xs text-primary underline-offset-2 hover:underline", + ), copy(h, model, "userId", userId, "User ID"), ]), ] @@ -148,7 +222,12 @@ const identity = (h: H, model: SessionView, session: ReplayDetail): Html | null }, [ groupName - ? link(h, hrefOf(AppRoute.Replays({ search: { group: groupName } })), groupName, "truncate text-xs text-primary underline-offset-2 hover:underline") + ? link( + h, + hrefOf(AppRoute.Replays({ search: { group: groupName } })), + groupName, + "truncate text-xs text-primary underline-offset-2 hover:underline", + ) : value(h, groupId, { isMono: true, className: "truncate" }), ], ), @@ -160,7 +239,11 @@ const identity = (h: H, model: SessionView, session: ReplayDetail): Html | null h.div( [h.Class("flex flex-wrap gap-x-2 gap-y-0.5 text-2xs text-muted-foreground")], traits.map(([key, text]) => - truncatedText(h, { text: `${key}: ${text}`, className: "max-w-full" }, [key, "=", h.span([h.Class("text-foreground/80")], [text])]), + truncatedText(h, { text: `${key}: ${text}`, className: "max-w-full" }, [ + key, + "=", + h.span([h.Class("text-foreground/80")], [text]), + ]), ), ), ]), @@ -171,25 +254,66 @@ const identity = (h: H, model: SessionView, session: ReplayDetail): Html | null const envFact = ( h: H, - options: Readonly<{ icon: IconName; glyph?: string | undefined; label: string; value: string; className?: string }>, + options: Readonly<{ + icon: IconName + glyph?: string | undefined + label: string + value: string + className?: string + }>, ): Html => - h.span([h.Class("flex min-w-0 items-center gap-2"), h.Title(`${options.label}: ${options.value || "unknown"}`)], [ - options.glyph === undefined || options.glyph === "" - ? icon(h, options.icon, { className: "size-3.5 shrink-0 text-muted-foreground", attributes: [h.AriaHidden(true)] }) - : h.span([h.AriaHidden(true), h.Class("w-3.5 shrink-0 text-center text-sm leading-none")], [options.glyph]), - h.span( - [h.Class(cn("min-w-0 truncate text-xs", options.value ? "text-foreground" : "text-muted-foreground", options.className))], - [options.value || EMPTY_VALUE], - ), - ]) + h.span( + [ + h.Class("flex min-w-0 items-center gap-2"), + h.Title(`${options.label}: ${options.value || "unknown"}`), + ], + [ + options.glyph === undefined || options.glyph === "" + ? icon(h, options.icon, { + className: "size-3.5 shrink-0 text-muted-foreground", + attributes: [h.AriaHidden(true)], + }) + : h.span( + [h.AriaHidden(true), h.Class("w-3.5 shrink-0 text-center text-sm leading-none")], + [options.glyph], + ), + h.span( + [ + h.Class( + cn( + "min-w-0 truncate text-xs", + options.value ? "text-foreground" : "text-muted-foreground", + options.className, + ), + ), + ], + [options.value || EMPTY_VALUE], + ), + ], + ) -const path = (h: H, text: string): Html => h.span([h.Class("min-w-0 truncate font-mono text-xs text-foreground")], [text]) +const path = (h: H, text: string): Html => + h.span([h.Class("min-w-0 truncate font-mono text-xs text-foreground")], [text]) const acquisition = (h: H, session: ReplayDetail): Html | null => session.entryPath || session.referrerHost || session.utmSource ? detailRailGroup(h, "Acquisition", [ - ...(session.entryPath ? [row(h, { icon: "ArrowRightFromLineIcon", label: "Entry", title: session.entryPath }, [path(h, session.entryPath)])] : []), - ...(session.exitPath ? [row(h, { icon: "LogoutIcon", label: "Exit", title: session.exitPath }, [path(h, session.exitPath)])] : []), + ...(session.entryPath + ? [ + row( + h, + { icon: "ArrowRightFromLineIcon", label: "Entry", title: session.entryPath }, + [path(h, session.entryPath)], + ), + ] + : []), + ...(session.exitPath + ? [ + row(h, { icon: "LogoutIcon", label: "Exit", title: session.exitPath }, [ + path(h, session.exitPath), + ]), + ] + : []), row(h, { icon: "LinkIcon", label: "Referrer" }, [ session.referrerHost ? value(h, session.referrerHost, { isMono: true, className: "truncate" }) @@ -198,12 +322,18 @@ const acquisition = (h: H, session: ReplayDetail): Html | null => ...(session.utmSource ? [ row(h, { icon: "PaperPlaneIcon", label: "Source" }, [ - value(h, [session.utmSource, session.utmMedium].filter(Boolean).join(" / "), { className: "truncate" }), + value(h, [session.utmSource, session.utmMedium].filter(Boolean).join(" / "), { + className: "truncate", + }), ]), ] : []), ...(session.utmCampaign - ? [row(h, { icon: "RocketIcon", label: "Campaign", title: session.utmCampaign }, [value(h, session.utmCampaign, { className: "truncate" })])] + ? [ + row(h, { icon: "RocketIcon", label: "Campaign", title: session.utmCampaign }, [ + value(h, session.utmCampaign, { className: "truncate" }), + ]), + ] : []), ]) : null @@ -220,23 +350,35 @@ const context = (h: H, model: SessionView, session: ReplayDetail): Html => { ] : []), row(h, { icon: "IdBadgeIcon", label: "Session ID", title: model.sessionId }, [ - value(h, shortId(model.sessionId, "session", { ellipsis: true }), { isMono: true, className: "truncate" }), + value(h, shortId(model.sessionId, "session", { ellipsis: true }), { + isMono: true, + className: "truncate", + }), copy(h, model, "session", model.sessionId, "Session ID"), ]), ...(recorded === undefined ? [] : [ row(h, { icon: "EyeIcon", label: "Recording" }, [ - h.span([h.Class("flex items-center gap-1.5 text-xs")], [ - statusDot(h, { tone: recorded ? "ok" : "neutral" }), - h.span([h.Class(recorded ? "text-foreground" : "text-muted-foreground")], [recorded ? "Complete" : "Not recorded"]), - ]), + h.span( + [h.Class("flex items-center gap-1.5 text-xs")], + [ + statusDot(h, { tone: recorded ? "ok" : "neutral" }), + h.span( + [h.Class(recorded ? "text-foreground" : "text-muted-foreground")], + [recorded ? "Complete" : "Not recorded"], + ), + ], + ), ]), ]), ...(session.userAgent ? [ detailRailField(h, { label: "User agent" }, [ - h.span([h.Class("break-words font-mono text-3xs leading-3.75 text-muted-foreground")], [session.userAgent]), + h.span( + [h.Class("break-words font-mono text-3xs leading-3.75 text-muted-foreground")], + [session.userAgent], + ), ]), ] : []), @@ -260,22 +402,36 @@ export const sessionTab = (h: H, model: SessionView, session: ReplayDetail): Htm "truncate font-mono text-xs text-primary underline-offset-2 hover:underline", ), ]), - row(h, { icon: "UserIcon", label: "Visitor" }, [value(h, session.visitorIsNew ? "New" : "Returning")]), + row(h, { icon: "UserIcon", label: "Visitor" }, [ + value(h, session.visitorIsNew ? "New" : "Returning"), + ]), ]) : null, acquisition(h, session), detailRailGroup(h, "Environment", [ - h.div([h.Class("grid grid-cols-2 gap-x-3 gap-y-2 pt-0.5")], [ - envFact(h, { icon: browserIconFor(session.browserName), label: "Browser", value: session.browserName }), - envFact(h, { icon: "CubeIcon", label: "Operating system", value: session.osName }), - envFact(h, { icon: deviceIconFor(session.deviceType), label: "Device", value: session.deviceType, className: "capitalize" }), - envFact(h, { - icon: "GlobeIcon", - glyph: session.country ? countryFlag(session.country) : undefined, - label: "Country", - value: session.country ? countryName(session.country) : "", - }), - ]), + h.div( + [h.Class("grid grid-cols-2 gap-x-3 gap-y-2 pt-0.5")], + [ + envFact(h, { + icon: browserIconFor(session.browserName), + label: "Browser", + value: session.browserName, + }), + envFact(h, { icon: "CubeIcon", label: "Operating system", value: session.osName }), + envFact(h, { + icon: deviceIconFor(session.deviceType), + label: "Device", + value: session.deviceType, + className: "capitalize", + }), + envFact(h, { + icon: "GlobeIcon", + glyph: session.country ? countryFlag(session.country) : undefined, + label: "Country", + value: session.country ? countryName(session.country) : "", + }), + ], + ), ]), context(h, model, session), ].filter((node): node is Html => node !== null), diff --git a/apps/web/src/page/replays/detail/view.ts b/apps/web/src/page/replays/detail/view.ts index 96a8c7b970..9f3acad1f3 100644 --- a/apps/web/src/page/replays/detail/view.ts +++ b/apps/web/src/page/replays/detail/view.ts @@ -2,23 +2,21 @@ import { shortId } from "@maple/ui/lib/ids" import { initialsFrom } from "@maple/ui/lib/initials" import { gradientFor, hostFromUrl } from "@maple/ui/lib/replay" import { formatSessionDuration } from "@maple/ui/lib/replay-format" -import { cn } from "@maple/ui/lib/utils" import { parseChTimestampMs } from "../../../components/replays/replay-timeline" import { Struct } from "effect" import { AsyncData, Submodel } from "foldkit" import { createLazy, type Html, type HtmlBuilder } from "foldkit/html" import type { BreadcrumbEntry } from "../../../shell" -import { linkButton } from "../../../ui/button" import { badge } from "../../../ui/display" -import { empty, emptyDescription, emptyHeader, emptyMedia, emptyTitle } from "../../../ui/empty" import { errorState } from "../../../ui/error-state" import { eyebrow } from "../../../ui/eyebrow" import { icon } from "../../../ui/icon" -import { inlineCode } from "../../../ui/inline-code" +import { inlineCode, inlineCodeClassName } from "../../../ui/inline-code" import { skeleton } from "../../../ui/skeleton" import { statusDot } from "../../../ui/status-dot" import type { PageViewInputs } from "../../contract" import { dashboardPage } from "../../kit/dashboard-page" +import { resourceNotFound } from "../../kit/resource-not-found" import { relativeTime } from "../../kit/relative-time" import { stableSlice } from "../../kit/stable-slice" import { errorCountPill, livePill } from "../kit/sessions-parts" @@ -132,8 +130,12 @@ const identityBar = (h: H, model: IdentityView, session: ReplayDetail, timeZone: : []), h.span( [ + // The id chip holds its copy button, so it borrows the chip's look on a span. h.Class( - "hidden shrink-0 items-center gap-1 rounded-md bg-muted px-1.5 py-0.5 font-mono text-2xs text-muted-foreground xl:inline-flex", + inlineCodeClassName( + "chip", + "hidden shrink-0 items-center gap-1 text-2xs text-muted-foreground xl:inline-flex", + ), ), ], [ @@ -220,22 +222,15 @@ const detailSkeleton = (h: H): Html => ) const notFound = (h: H, sessionId: string): Html => - empty( - h, - [ - emptyHeader(h, [ - emptyMedia(h, { variant: "icon" }, [icon(h, "CircleWarningIcon", { size: 18 })]), - emptyTitle(h, ["Session not found"]), - emptyDescription(h, [ - "No metadata for session ", - inlineCode(h, {}, [sessionId]), - ". It may have expired or not been ingested yet.", - ]), - ]), - linkButton(h, { variant: "outline", size: "sm", href: "/replays" }, ["Back to replays"]), + resourceNotFound(h, { + title: "Session not found", + description: [ + "No metadata for session ", + inlineCode(h, {}, [sessionId]), + ". It may have expired or not been ingested yet.", ], - cn("md:py-12"), - ) + back: { href: "/replays", label: "Back to replays" }, + }) export const view = Submodel.defineView((model, inputs, h) => { const session = detailOf(model) diff --git a/apps/web/src/page/replays/kit/replay-meta.ts b/apps/web/src/page/replays/kit/replay-meta.ts index a2aa029fc3..f42ab34792 100644 --- a/apps/web/src/page/replays/kit/replay-meta.ts +++ b/apps/web/src/page/replays/kit/replay-meta.ts @@ -29,6 +29,21 @@ export const MARKER_STYLES = { nav: "bg-chart-1", } satisfies Record +/** The same hues as text, and as the soft fill of a selected filter. */ +export const MARKER_TEXT = { + click: "text-chart-3", + input: "text-chart-2", + scroll: "text-chart-4", + nav: "text-chart-1", +} satisfies Record + +export const MARKER_SOFT = { + click: "bg-chart-3/15 text-chart-3", + input: "bg-chart-2/15 text-chart-2", + scroll: "bg-chart-4/15 text-chart-4", + nav: "bg-chart-1/15 text-chart-1", +} satisfies Record + export const MARKER_LABELS = { click: "Click", input: "Input", @@ -38,12 +53,23 @@ export const MARKER_LABELS = { const EVENT_INCREMENTAL = 3 const EVENT_META = 4 -const SOURCE = { mouseMove: 1, mouseInteraction: 2, scroll: 3, viewportResize: 4, input: 5, touchMove: 6, drag: 12 } +const SOURCE = { + mouseMove: 1, + mouseInteraction: 2, + scroll: 3, + viewportResize: 4, + input: 5, + touchMove: 6, + drag: 12, +} const INTERACTION_CLICK = 2 const INTERACTION_TOUCH_START = 7 const IDLE_THRESHOLD_MS = 2000 -const MARKER_COALESCE_MS = { click: 150, input: 800, scroll: 400, nav: 0 } satisfies Record +const MARKER_COALESCE_MS = { click: 150, input: 800, scroll: 400, nav: 0 } satisfies Record< + ActionKind, + number +> const isMovementNoise = (source: number | undefined): boolean => source === SOURCE.mouseMove || @@ -128,8 +154,14 @@ const step = (state: MetaState, raw: unknown, ev: RawEvent): MetaState => { const href = ev.data.href if (href === undefined || timestamp === undefined) return sized const navigated = - sized.lastHref !== undefined && href !== sized.lastHref ? pushMarker(sized, "nav", timestamp - startTime) : sized - return { ...navigated, lastHref: href, firstHref: navigated.firstHref ?? { href, ms: timestamp - startTime } } + sized.lastHref !== undefined && href !== sized.lastHref + ? pushMarker(sized, "nav", timestamp - startTime) + : sized + return { + ...navigated, + lastHref: href, + firstHref: navigated.firstHref ?? { href, ms: timestamp - startTime }, + } } if (!isIncremental || timestamp === undefined) return idled const ms = timestamp - startTime @@ -148,14 +180,24 @@ const timestampOf = (event: unknown): number | undefined => { return decoded !== undefined && decoded._tag === "Some" ? decoded.value.timestamp : undefined } -const scan = (events: ReadonlyArray, startTime: number, prevMeaningfulTs: number | undefined): MetaState => +const scan = ( + events: ReadonlyArray, + startTime: number, + prevMeaningfulTs: number | undefined, +): MetaState => events.reduce( (state, raw) => { const decoded = asRawEvent(raw) return decoded._tag === "Some" ? step(state, raw, decoded.value) : state }, { - meta: { recordedWidth: 1280, recordedHeight: 720, startTime, actionMarkers: [], inactiveIntervals: [] }, + meta: { + recordedWidth: 1280, + recordedHeight: 720, + startTime, + actionMarkers: [], + inactiveIntervals: [], + }, prevMeaningfulTs, firstMeaningfulTs: undefined, lastMsByKind: {}, @@ -193,7 +235,11 @@ export const deriveRangeMeta = ( events: ReadonlyArray, originMs: number, ): RangeMeta => { - const { meta, prevMeaningfulTs, firstMeaningfulTs, firstHref, lastHref } = scan(events, originMs, undefined) + const { meta, prevMeaningfulTs, firstMeaningfulTs, firstHref, lastHref } = scan( + events, + originMs, + undefined, + ) return { fromChunkSeq: range.fromChunkSeq, toChunkSeq: range.toChunkSeq, @@ -230,7 +276,9 @@ export const combineRangeMetas = (originMs: number, ranges: ReadonlyArray range.actionMarkers), ...navs].toSorted((a, b) => a.ms - b.ms), + actionMarkers: [...sorted.flatMap((range) => range.actionMarkers), ...navs].toSorted( + (a, b) => a.ms - b.ms, + ), inactiveIntervals: [...sorted.flatMap((range) => range.inactiveIntervals), ...gaps].toSorted( (a, b) => a.start - b.start, ), diff --git a/apps/web/src/page/replays/kit/replays-toolbar.ts b/apps/web/src/page/replays/kit/replays-toolbar.ts index 7b5208e521..b43702f852 100644 --- a/apps/web/src/page/replays/kit/replays-toolbar.ts +++ b/apps/web/src/page/replays/kit/replays-toolbar.ts @@ -5,6 +5,7 @@ import { cn } from "@maple/ui/lib/utils" import type { Html, HtmlBuilder } from "foldkit/html" import { filterChip } from "../../../ui/filter-chip" import { searchInput } from "../../../ui/search-input" +import { listToolbar } from "../../kit/list-toolbar" // The replays toolbar: the URL search and the triage chips. // TODO(tooltip): the chips' hover tooltips. @@ -50,18 +51,19 @@ const triageChip = ( export const replaysToolbar = (h: HtmlBuilder, options: ReplaysToolbarOptions): Html => { const isWaiting = options.isWaiting ?? false - return h.div( - [h.Class("flex flex-wrap items-center justify-between gap-3")], - [ - searchInput(h, { - id: "replays-search", - value: options.query, - placeholder: "Search by URL…", - onInput: options.onInput, - onClear: options.onClear, - size: "default", - className: cn("max-w-sm", "w-full sm:max-w-sm"), - }), + // The sticky area pads the page edge, so the toolbar adds no gap under itself. + return listToolbar(h, { + className: "pb-0", + leading: searchInput(h, { + id: "replays-search", + value: options.query, + placeholder: "Search by URL…", + onInput: options.onInput, + onClear: options.onClear, + size: "default", + className: cn("max-w-sm", "w-full sm:max-w-sm"), + }), + trailing: [ h.div( [ ...(isWaiting ? [h.AriaBusy(true)] : []), @@ -87,5 +89,5 @@ export const replaysToolbar = (h: HtmlBuilder, options: ReplaysToolbarOpti ], ), ], - ) + }) } diff --git a/apps/web/src/page/replays/kit/session-row.ts b/apps/web/src/page/replays/kit/session-row.ts index 403179cf81..eb5359f40d 100644 --- a/apps/web/src/page/replays/kit/session-row.ts +++ b/apps/web/src/page/replays/kit/session-row.ts @@ -1,3 +1,4 @@ +import { countLabel } from "@maple/ui/lib/format" import { SessionTag } from "@maple/domain/query-engine" import { isSessionLive } from "@maple/ui/lib/replay-format" import { noiseTierOf, SESSION_TAG_LABELS } from "../../../components/replays/session-tags" @@ -55,9 +56,7 @@ const tiersOf = (sessions: ReadonlyArray) => { } const summarize = (tiers: ReadonlyArray<{ readonly tag: SessionTag; readonly count: number }>) => - tiers - .map(({ tag, count }) => `${count} ${SESSION_TAG_LABELS[tag].toLowerCase()}${count === 1 ? "" : "s"}`) - .join(" · ") + tiers.map(({ tag, count }) => countLabel(count, SESSION_TAG_LABELS[tag].toLowerCase())).join(" · ") const runItems = ( run: ReadonlyArray, @@ -69,8 +68,17 @@ const runItems = ( const isExpanded = expanded.includes(first.sessionId) const tiers = tiersOf(run) return [ - { kind: "quiet", key: first.sessionId, count: run.length, summary: summarize(tiers), tiers, expanded: isExpanded }, - ...(isExpanded ? run.map((session): SessionListItem => ({ kind: "session", session, lowSignal: true })) : []), + { + kind: "quiet", + key: first.sessionId, + count: run.length, + summary: summarize(tiers), + tiers, + expanded: isExpanded, + }, + ...(isExpanded + ? run.map((session): SessionListItem => ({ kind: "session", session, lowSignal: true })) + : []), ] } @@ -84,7 +92,11 @@ export const sessionListItems = ( const lowSignal = isLowSignal(session, options.nowMs) if (options.collapse && lowSignal) return { items: state.items, run: [...state.run, session] } return { - items: [...state.items, ...runItems(state.run, options.expanded), { kind: "session", session, lowSignal }], + items: [ + ...state.items, + ...runItems(state.run, options.expanded), + { kind: "session", session, lowSignal }, + ], run: [], } }, diff --git a/apps/web/src/page/replays/list/view.ts b/apps/web/src/page/replays/list/view.ts index 98d3b67e9b..fd162f4194 100644 --- a/apps/web/src/page/replays/list/view.ts +++ b/apps/web/src/page/replays/list/view.ts @@ -10,11 +10,11 @@ import { icon } from "../../../ui/icon" import { iconButton } from "../../../ui/icon-button" import { listFooter, loadingMoreRow } from "../../../ui/list-footer" import { button } from "../../../ui/button" -import { statusDot } from "../../../ui/status-dot" import { truncatedText } from "../../../ui/truncated-text" import { AppRoute, hrefOf } from "../../../route" import type { PageViewInputs } from "../../contract" import { dashboardPage } from "../../kit/dashboard-page" +import { toolbarStat } from "../../kit/list-toolbar" import { presenceOf } from "../../kit/signal-presence" import { signalEmptyState } from "../../kit/signal-empty-state" import { timeControls } from "../../kit/time-controls" @@ -43,14 +43,6 @@ type H = HtmlBuilder const AUTO_LOAD_MIN_ROWS = 25 -/** `ToolbarStat`. */ -const toolbarStat = (h: H, value: number, label: string, hasDot = false): Html => - h.span([h.Class("flex items-center gap-1.5 whitespace-nowrap text-sm")], [ - ...(hasDot ? [statusDot(h, { tone: "ok" })] : []), - h.span([h.Class("font-medium tabular-nums")], [formatNumber(value)]), - h.span([h.Class("text-muted-foreground")], [label]), - ]) - const headerActions = (h: H, model: Model): Html => { const facets = facetsOf(model) const { search } = model @@ -61,11 +53,20 @@ const headerActions = (h: H, model: Model): Html => { ? [] : [ h.div( - [h.Class("hidden items-center gap-4 sm:flex")], + [h.Class("hidden items-center gap-3 sm:flex")], [ - toolbarStat(h, facets.totalSessions, "sessions"), + toolbarStat(h, { + value: formatNumber(facets.totalSessions), + label: "sessions", + }), ...(facets.liveSessions > 0 - ? [toolbarStat(h, facets.liveSessions, "live", true)] + ? [ + toolbarStat(h, { + value: formatNumber(facets.liveSessions), + label: "live", + dot: "ok", + }), + ] : []), ], ), @@ -97,53 +98,90 @@ const activeUserFilter = ( h: H, config: Readonly<{ userId: string; count: number; label: string; clearLabel: string; onClear: Message }>, ): Html => - h.div([h.Class("mb-3 flex items-center gap-3 rounded-md border border-primary/30 bg-primary/5 px-3 py-2")], [ - h.div( - [ - h.Class( - `grid size-8 shrink-0 place-items-center rounded-full bg-gradient-to-br ${gradientFor(config.userId)} text-xs font-semibold text-white shadow-sm`, - ), - ], - [initialsFrom(config.userId)], - ), - h.div([h.Class("min-w-0 flex-1")], [ - h.div([h.Class("flex items-baseline gap-2")], [ - h.span([h.Class("text-xs text-muted-foreground")], [config.label]), - truncatedText(h, { text: config.userId, mono: true, tooltip: "none", className: "text-sm font-medium" }), - ]), - h.span([h.Class("text-xs text-muted-foreground tabular-nums")], [countLabel(config.count, "session"), " loaded"]), - ]), - iconButton( - h, - { - label: config.clearLabel, - className: "rounded-full text-muted-foreground before:rounded-full", - attributes: [h.OnClick(config.onClear)], - }, - [icon(h, "XmarkIcon")], - ), - ]) + h.div( + [h.Class("mb-3 flex items-center gap-3 rounded-md border border-primary/30 bg-primary/5 px-3 py-2")], + [ + h.div( + [ + h.Class( + `grid size-8 shrink-0 place-items-center rounded-full bg-gradient-to-br ${gradientFor(config.userId)} text-xs font-semibold text-white shadow-sm`, + ), + ], + [initialsFrom(config.userId)], + ), + h.div( + [h.Class("min-w-0 flex-1")], + [ + h.div( + [h.Class("flex items-baseline gap-2")], + [ + h.span([h.Class("text-xs text-muted-foreground")], [config.label]), + truncatedText(h, { + text: config.userId, + mono: true, + tooltip: "none", + className: "text-sm font-medium", + }), + ], + ), + h.span( + [h.Class("text-xs text-muted-foreground tabular-nums")], + [countLabel(config.count, "session"), " loaded"], + ), + ], + ), + iconButton( + h, + { + label: config.clearLabel, + className: "rounded-full text-muted-foreground before:rounded-full", + attributes: [h.OnClick(config.onClear)], + }, + [icon(h, "XmarkIcon")], + ), + ], + ) /** The sentinel or the Load more button, then the cap footer and the loading row. */ -const listFooterParts = (h: H, model: Model, items: ReturnType): ReadonlyArray => { +const listFooterParts = ( + h: H, + model: Model, + items: ReturnType, +): ReadonlyArray => { const hasMore = nextCursor(model) !== null const { isFetching } = model.pagination const hidesSessions = items.some((item) => item.kind === "quiet" && !item.expanded) const last = items.at(-1) const endsInFoldedRun = last?.kind === "quiet" && !last.expanded const isManual = hidesSessions && (items.length < AUTO_LOAD_MIN_ROWS || endsInFoldedRun) - const capped = isCapped(model) ? listFooter(h, { shown: allRows(model).length, noun: "sessions", isCapped: true, align: "start", className: "px-0" }) : null + const capped = isCapped(model) + ? listFooter(h, { + shown: allRows(model).length, + noun: "sessions", + isCapped: true, + align: "start", + className: "px-0", + }) + : null return [ ...(hasMore ? [ isManual - ? h.div([h.Class("flex justify-center py-3")], [ - button( - h, - { variant: "outline", size: "sm", isLoading: isFetching, attributes: [h.OnClick(Message.ClickedLoadMore())] }, - ["Load more sessions"], - ), - ]) + ? h.div( + [h.Class("flex justify-center py-3")], + [ + button( + h, + { + variant: "outline", + size: "sm", + isLoading: isFetching, + attributes: [h.OnClick(Message.ClickedLoadMore())], + }, + ["Load more sessions"], + ), + ], + ) : h.div( [ h.Key(listEndKey(model)), @@ -197,7 +235,11 @@ const listBody = (h: H, model: Model, timeZone: string): Html => nowMs: model.nowMs, timeZone, durationP95: facetsOf(model)?.durationP95, - onOpen: (session) => Message.ClickedSession({ sessionId: session.sessionId, startTime: session.startTime }), + onOpen: (session) => + Message.ClickedSession({ + sessionId: session.sessionId, + startTime: session.startTime, + }), onToggleRun: (key) => Message.ToggledRun({ key }), onFilterTag: (tag) => Message.ClickedRowTag({ tag }), onFilterGroup: (group) => Message.ClickedRowGroup({ group }), @@ -228,7 +270,8 @@ export const view = Submodel.defineView((model, toParentMessage: toTimePicker, viewInputs: { label, - selectedPreset: search.timePreset ?? (search.startTime === undefined ? DEFAULT_PRESET : undefined), + selectedPreset: + search.timePreset ?? (search.startTime === undefined ? DEFAULT_PRESET : undefined), presets: TimeRangePicker.PRESET_VALUES, }, }), @@ -238,7 +281,10 @@ export const view = Submodel.defineView((model, { title: "Replays", filters: filtersRail(h, model), - actions: h.div([h.Class("flex flex-wrap items-center gap-2")], [headerActions(h, model), timeCluster]), + actions: h.div( + [h.Class("flex flex-wrap items-center gap-2")], + [headerActions(h, model), timeCluster], + ), sticky: replaysToolbar(h, { query: model.query, onInput: (value) => Message.ChangedQuery({ value }), diff --git a/apps/web/src/page/security/finding/view.ts b/apps/web/src/page/security/finding/view.ts index cb4f02186d..299af17be0 100644 --- a/apps/web/src/page/security/finding/view.ts +++ b/apps/web/src/page/security/finding/view.ts @@ -203,10 +203,11 @@ const triagePanel = (h: H, model: Model, finding: SecurityAuditFinding): Html => { type: "submit", variant: "outline", + isLoading: model.isSaving, isDisabled: !isChange || model.isSaving, className: "w-full", }, - [model.isSaving ? "Updating…" : "Update status"], + ["Update status"], ), ], ), diff --git a/apps/web/src/page/security/kit/add-target-view.ts b/apps/web/src/page/security/kit/add-target-view.ts index 8b0821fcc2..45bc45f75d 100644 --- a/apps/web/src/page/security/kit/add-target-view.ts +++ b/apps/web/src/page/security/kit/add-target-view.ts @@ -398,8 +398,9 @@ const addTargetDialog = ( title: "Add audit target", description: "Pick a repository branch to audit. You can start a run as soon as it is added.", onSubmit: Message.Submitted(), - submitLabel: model.isPending ? "Adding…" : "Add target", - isSubmitDisabled: model.isPending || !hasRepositories, + submitLabel: "Add target", + isPending: model.isPending, + isSubmitDisabled: !hasRepositories, footerStart: hasRepositories ? startNowToggle(h, model) : undefined, panelClassName: "flex flex-col gap-5", fields: hasRepositories diff --git a/apps/web/src/page/security/kit/findings-view.ts b/apps/web/src/page/security/kit/findings-view.ts index 7323de100b..a6bf7b1e03 100644 --- a/apps/web/src/page/security/kit/findings-view.ts +++ b/apps/web/src/page/security/kit/findings-view.ts @@ -83,7 +83,7 @@ export const unverifiedChip = (h: HtmlBuilder): Html => [ h.Title("Severity estimated from the source; not proven by running the code"), h.Class( - "rounded-sm border border-dashed border-muted-foreground/50 px-1.5 py-px text-[11px] text-muted-foreground", + "rounded-sm border border-dashed border-muted-foreground/50 px-1.5 py-px text-2xs text-muted-foreground", ), ], ["Unverified"], diff --git a/apps/web/src/page/security/kit/spend-settings-view.ts b/apps/web/src/page/security/kit/spend-settings-view.ts index 0abdcd9a63..b90599605b 100644 --- a/apps/web/src/page/security/kit/spend-settings-view.ts +++ b/apps/web/src/page/security/kit/spend-settings-view.ts @@ -145,8 +145,8 @@ const spendSettingsDialog = (h: H, model: Model): Html => { title: "Spend & models", description: "Applies to every security audit in this organization.", onSubmit: Message.Submitted(), - submitLabel: model.isPending ? "Saving…" : "Save changes", - isSubmitDisabled: model.isPending, + submitLabel: "Save changes", + isPending: model.isPending, panelClassName: "flex flex-col gap-5", fields: [ ...failureAlert(h, model), diff --git a/apps/web/src/page/security/run/view-findings.ts b/apps/web/src/page/security/run/view-findings.ts index bbfcc21c76..d19a612644 100644 --- a/apps/web/src/page/security/run/view-findings.ts +++ b/apps/web/src/page/security/run/view-findings.ts @@ -15,6 +15,7 @@ import { FINDING_STATUS_LABEL, gateOf, isLive, locationOf } from "../kit/format" import { gateState, placeholderRows } from "../kit/parts" import { countOf, openFindingOf } from "./derive" import { Message, type Model } from "./model" +import { emptyMessage } from "../../../ui/empty" // What the run reported, first on the page: severity tabs over severity groups, each finding // opening in a side sheet. @@ -68,16 +69,13 @@ const reportLinks = (h: H, run: SecurityAuditRunDetail): ReadonlyArray => const emptyLine = (h: H, run: SecurityAuditRunDetail): Html => { const waiting = countOf(run, "candidate") - return h.p( - [h.Class("px-4 py-6 text-sm text-muted-foreground")], - [ - isLive(run) - ? waiting > 0 - ? `Nothing verified yet. ${waiting} ${waiting === 1 ? "candidate is" : "candidates are"} waiting for a second model.` - : "Nothing reported yet. Findings appear here as they are verified." - : "This run reported no findings.", - ], - ) + return emptyMessage(h, {}, [ + isLive(run) + ? waiting > 0 + ? `Nothing verified yet. ${waiting} ${waiting === 1 ? "candidate is" : "candidates are"} waiting for a second model.` + : "Nothing reported yet. Findings appear here as they are verified." + : "This run reported no findings.", + ]) } const findingsList = (h: H, model: Model, findings: ReadonlyArray): Html => { diff --git a/apps/web/src/page/security/run/view-header.ts b/apps/web/src/page/security/run/view-header.ts index 64589d0ba9..c75176c651 100644 --- a/apps/web/src/page/security/run/view-header.ts +++ b/apps/web/src/page/security/run/view-header.ts @@ -29,6 +29,7 @@ import { iconTile, metaItem, placeholder, runStatusBadge } from "../kit/parts" import { meter } from "../../../ui/meter" import { countOf, gapCount, type Phase, type PhaseState, type Verdicts } from "./derive" import { Message, type Model } from "./model" +import { statusDot } from "../../../ui/status-dot" // The run's title block, its stop banner, the phase strip and the four totals. @@ -131,13 +132,11 @@ export const runActions = (h: H, model: Model, run: SecurityAuditRunDetail): Htm h, { size: "sm", + isLoading: model.busy === "start", isDisabled: model.busy !== null, attributes: [h.OnClick(Message.ClickedStartRun())], }, - [ - icon(h, "MediaPlayIcon"), - model.busy === "start" ? "Starting…" : "Start run", - ], + [icon(h, "MediaPlayIcon"), "Start run"], ), ]), ], @@ -149,10 +148,11 @@ const startRunButton = (h: H, model: Model): Html => { size: "sm", variant: "outline", + isLoading: model.busy === "start", isDisabled: model.busy !== null, attributes: [h.OnClick(Message.ClickedStartRun())], }, - [model.busy === "start" ? "Starting…" : "Start run"], + ["Start run"], ) const stopAction = (h: H, model: Model, action: StopAction): ReadonlyArray => { @@ -221,7 +221,7 @@ const phaseMark = (h: H, state: PhaseState): Html => { "flex size-4 items-center justify-center rounded-full border-2 border-severity-debug", ), ], - [h.span([h.Class("size-1.5 rounded-full bg-severity-debug")])], + [statusDot(h, { tone: "done" })], ) case "pending": case "skipped": diff --git a/apps/web/src/page/security/run/view-panels.ts b/apps/web/src/page/security/run/view-panels.ts index ab578bbe73..25c7c20a4d 100644 --- a/apps/web/src/page/security/run/view-panels.ts +++ b/apps/web/src/page/security/run/view-panels.ts @@ -22,6 +22,7 @@ import { import { type EventFilter, eventLineOf, unitContext, unitName } from "../kit/outcome" import { EVENTS_PAGE } from "./command" import { Message, type Model, type UnitFilter } from "./model" +import { emptyMessage } from "../../../ui/empty" // The run's coverage ledger, its gaps and the event log. @@ -108,7 +109,7 @@ const unitsBody = ( onData: (response) => { const shown = rows(response.units) return shown.length === 0 - ? h.p([h.Class("px-4 py-6 text-sm text-muted-foreground")], ["No units in this status."]) + ? emptyMessage(h, {}, ["No units in this status."]) : table(h, { variant: "bare", ariaLabel: "Coverage units" }, [ tableHeader(h, [ tableRow(h, [ @@ -302,16 +303,13 @@ export const eventsPanel = ( [ ...(events.length === 0 ? [] : [eventChips(h, model, events)]), shown.length === 0 - ? h.p( - [h.Class("px-4 py-6 text-sm text-muted-foreground")], - [ - events.length === 0 - ? "No events yet." - : model.eventFilter === "problems" - ? "No problems in the events read so far." - : "No events about leads read so far.", - ], - ) + ? emptyMessage(h, {}, [ + events.length === 0 + ? "No events yet." + : model.eventFilter === "problems" + ? "No problems in the events read so far." + : "No events about leads read so far.", + ]) : h.ol( [h.Class("divide-y")], shown.map((event) => eventRow(h, event, timeZone)), diff --git a/apps/web/src/page/security/run/view.ts b/apps/web/src/page/security/run/view.ts index 3f6166fda1..d1e6b4a3c9 100644 --- a/apps/web/src/page/security/run/view.ts +++ b/apps/web/src/page/security/run/view.ts @@ -86,9 +86,9 @@ const cancelDialog = (h: H, model: Model): Html => title: "Cancel this run?", description: "The agents stop after the ones in flight. Units it closed stay closed; the rest are left as gaps the next run starts with. Spend so far is kept.", - confirmLabel: model.busy === "cancel" ? "Cancelling…" : "Cancel run", + confirmLabel: "Cancel run", cancelLabel: "Keep running", - isConfirmDisabled: model.busy === "cancel", + isPending: model.busy === "cancel", onConfirm: Message.ClickedConfirmCancel(), }) diff --git a/apps/web/src/page/security/tabs/view-findings.ts b/apps/web/src/page/security/tabs/view-findings.ts index ccbff147e4..ed38a6d6a0 100644 --- a/apps/web/src/page/security/tabs/view-findings.ts +++ b/apps/web/src/page/security/tabs/view-findings.ts @@ -1,4 +1,5 @@ import type { SecurityAuditFinding, SecurityAuditTarget } from "@maple/domain/http" +import { countLabel } from "@maple/ui/lib/format" import type { Html, HtmlBuilder } from "foldkit/html" import { AppRoute, hrefOf } from "../../../route" import { badge } from "../../../ui/display" @@ -10,7 +11,7 @@ import { switchControl } from "../../../ui/switch" import { nothingInWindow } from "../../kit/nothing-in-window" import { relativeTime } from "../../kit/relative-time" import { countByBucket, sectionsOf, type SeverityBucket } from "../kit/findings" -import { findingGroups, severityTabs, trailingText } from "../kit/findings-view" +import { findingGroups, severityTabs } from "../kit/findings-view" import { FINDING_STATUS_LABEL, STATUS_TAB_LABEL } from "../kit/format" import { gateState, placeholderRows, repositoryName } from "../kit/parts" import { @@ -24,6 +25,7 @@ import { toolbarFindings, } from "./derive" import { Message, type Model } from "./model" +import { listToolbar, toolbarCount } from "../../kit/list-toolbar" // The Findings tab, as Linear lists issues: severity tabs, a filter toolbar, then one dense line // per finding under collapsible severity groups. @@ -73,18 +75,18 @@ const toolbar = ( ? "All targets" : (targets.find((target) => target.id === value)?.repository ?? "Removed target") const statusLabel = (value: string) => STATUS_TABS.find((tab) => tab === value) ?? "open" - return h.div( - [h.Class("flex flex-wrap items-center gap-2 border-b px-3 py-2")], - [ - searchInput(h, { - id: "security-findings-search", - size: "sm", - className: "w-64 max-w-sm", - placeholder: "Search title, file or fingerprint", - value: model.searchDraft, - onInput: (value) => Message.ChangedSearchDraft({ value }), - onClear: Message.ChangedSearchDraft({ value: "" }), - }), + return listToolbar(h, { + framed: true, + leading: searchInput(h, { + id: "security-findings-search", + size: "sm", + className: "w-64 max-w-sm", + placeholder: "Search title, file or fingerprint", + value: model.searchDraft, + onInput: (value) => Message.ChangedSearchDraft({ value }), + onClear: Message.ChangedSearchDraft({ value: "" }), + }), + trailing: [ selectSubmodel( h, model.statusSelect, @@ -114,7 +116,7 @@ const toolbar = ( h.label( [ h.Id("security-show-rejected-label"), - h.Class("ml-auto flex items-center gap-2 text-xs text-muted-foreground"), + h.Class("flex items-center gap-2 text-xs text-muted-foreground"), ], [ ...switchControl(h, { @@ -128,7 +130,7 @@ const toolbar = ( ], ), ], - ) + }) } /** Reopened and new lead; a triaged finding says how; an open one says nothing. */ @@ -254,25 +256,17 @@ export const findingsBody = (h: H, model: Model, timeZone: string): ReadonlyArra ...(targets.length === 0 || state._tag !== "Ready" ? [] : [ - h.div( - [h.Class("flex items-center gap-3 border-b px-3 py-2")], - [ - severityTabs(h, { - counts: countByBucket( - toolbarFindings(all, model.findingsSearch, query), - ), - active: model.findingsSearch.severity ?? null, - onPick: (bucket: SeverityBucket | null) => - Message.PickedSeverity({ bucket }), - label: "Severity", - }), - trailingText( - h, - `${shown} ${shown === 1 ? "finding" : "findings"}`, - "ml-auto shrink-0 tabular-nums", - ), - ], - ), + listToolbar(h, { + framed: true, + leading: severityTabs(h, { + counts: countByBucket(toolbarFindings(all, model.findingsSearch, query)), + active: model.findingsSearch.severity ?? null, + onPick: (bucket: SeverityBucket | null) => + Message.PickedSeverity({ bucket }), + label: "Severity", + }), + trailing: [toolbarCount(h, [countLabel(shown, "finding")])], + }), toolbar(h, model, all, targets), ]), content, diff --git a/apps/web/src/page/security/tabs/view-overview.ts b/apps/web/src/page/security/tabs/view-overview.ts index f9ed1fbcbe..31911ea5dd 100644 --- a/apps/web/src/page/security/tabs/view-overview.ts +++ b/apps/web/src/page/security/tabs/view-overview.ts @@ -62,6 +62,7 @@ import { bucketOf, countByBucket, isUnverified } from "../kit/findings" import { findingBadge } from "../kit/findings-view" import { auditSteps, emptyOnRamp } from "./view-empty" import { leadsLine, stopSummaryOf } from "../kit/outcome" +import { emptyMessage } from "../../../ui/empty" // The Overview tab: totals, the targets table, open leads and recent runs. @@ -518,14 +519,11 @@ const topFindingsList = ( const rows = openLeads(findings).slice(0, 5) const coverage = coverageAcross(model) return rows.length === 0 - ? h.p( - [h.Class("px-4 py-6 text-sm text-muted-foreground")], - [ - coverage === null - ? "No open leads yet." - : `No open leads. The latest runs closed ${coverage.closed} of ${coverage.total} units.`, - ], - ) + ? emptyMessage(h, {}, [ + coverage === null + ? "No open leads yet." + : `No open leads. The latest runs closed ${coverage.closed} of ${coverage.total} units.`, + ]) : h.ul( [h.Class("divide-y")], rows.map((finding) => @@ -577,10 +575,7 @@ const recentRunsPanel = (h: H, model: Model, targets: ReadonlyArray { diff --git a/apps/web/src/page/security/tabs/view-runs.ts b/apps/web/src/page/security/tabs/view-runs.ts index 6493d88a8b..c69f1f2c44 100644 --- a/apps/web/src/page/security/tabs/view-runs.ts +++ b/apps/web/src/page/security/tabs/view-runs.ts @@ -1,5 +1,6 @@ import type { SecurityAuditRun, SecurityAuditRunDetail, SecurityAuditTarget } from "@maple/domain/http" import { AsyncData } from "foldkit" +import { countLabel } from "@maple/ui/lib/format" import type { Html, HtmlBuilder } from "foldkit/html" import { AppRoute, hrefOf } from "../../../route" import { button, linkButton } from "../../../ui/button" @@ -39,6 +40,7 @@ import { BUCKET_LABEL, type BucketCounts, SCORED_BUCKETS } from "../kit/findings import { bucketDot } from "../kit/findings-view" import { meter } from "../../../ui/meter" import { Message, type Model } from "./model" +import { listToolbar, toolbarCount } from "../../kit/list-toolbar" // The Runs tab: every run, newest first, narrowed to one target from the select. @@ -203,44 +205,37 @@ const toolbar = ( count: number, isCapped: boolean, ): Html => - h.div( - [h.Class("flex flex-wrap items-center gap-2 border-b border-border/60 px-2 py-1.5")], - [ - h.submodel({ - slotId: model.runsTargetSelect.id, - model: model.runsTargetSelect, - view: SelectView.view, - viewInputs: { - entries: [ - { _tag: "Item", item: { value: "all", label: "All targets" } }, - ...targets.map((target): SelectView.SelectEntry => ({ - _tag: "Item", - item: { value: target.id, label: target.repository }, - })), - ], - size: "sm", - ariaLabel: "Target", - className: "h-7 w-auto min-w-40 text-xs", - renderValue: (value) => [ - h.span([h.Class("text-muted-foreground")], ["Target "]), - value === "all" - ? "All targets" - : (targets.find((target) => target.id === value)?.repository ?? "Removed target"), - ], - toView: (parts) => h.div([h.Class("contents")], parts), - }, - toParentMessage: (message) => Message.GotRunsTargetSelectMessage({ message }), - }), - h.span( - [h.Class("ml-auto text-xs tabular-nums text-muted-foreground")], - [ - isCapped - ? `showing the ${RUN_LIST_CAP} newest` - : `${count} ${count === 1 ? "run" : "runs"}`, + listToolbar(h, { + framed: true, + leading: h.submodel({ + slotId: model.runsTargetSelect.id, + model: model.runsTargetSelect, + view: SelectView.view, + viewInputs: { + entries: [ + { _tag: "Item", item: { value: "all", label: "All targets" } }, + ...targets.map((target): SelectView.SelectEntry => ({ + _tag: "Item", + item: { value: target.id, label: target.repository }, + })), ], - ), + size: "sm", + ariaLabel: "Target", + className: "h-7 w-auto min-w-40 text-xs", + renderValue: (value) => [ + h.span([h.Class("text-muted-foreground")], ["Target "]), + value === "all" + ? "All targets" + : (targets.find((target) => target.id === value)?.repository ?? "Removed target"), + ], + toView: (parts) => h.div([h.Class("contents")], parts), + }, + toParentMessage: (message) => Message.GotRunsTargetSelectMessage({ message }), + }), + trailing: [ + toolbarCount(h, [isCapped ? `showing the ${RUN_LIST_CAP} newest` : countLabel(count, "run")]), ], - ) + }) /** "Start run" for the target the list is narrowed to. */ export const startRunAction = (h: H, model: Model): Html | undefined => { @@ -252,13 +247,14 @@ export const startRunAction = (h: H, model: Model): Html | undefined => { h, { size: "sm", + isLoading: model.startingTargetId === target.id, isDisabled: isRunning || model.startingTargetId !== null, attributes: [ h.OnClick(Message.ClickedStartRun({ targetId: target.id })), ...(isRunning ? [h.Title("One audit runs per target at a time")] : []), ], }, - [icon(h, "MediaPlayIcon"), model.startingTargetId === target.id ? "Starting…" : "Start run"], + [icon(h, "MediaPlayIcon"), "Start run"], ) } diff --git a/apps/web/src/page/service-map/view-db-sections.ts b/apps/web/src/page/service-map/view-db-sections.ts index 9c517e0b2b..9e3fde2032 100644 --- a/apps/web/src/page/service-map/view-db-sections.ts +++ b/apps/web/src/page/service-map/view-db-sections.ts @@ -3,7 +3,6 @@ import type { PlanetScaleNodeMetrics } from "@maple/ui/components/service-map/se import { countLabel, EMPTY_VALUE, - formatDuration, formatLatency, formatNumber, formatPercent, @@ -11,12 +10,16 @@ import { pluralize, } from "@maple/ui/lib/format" import { latencyToneClass } from "@maple/ui/lib/latency-tone" -import { TONE_TEXT } from "@maple/ui/lib/tone" -import { utilizationLevel } from "@maple/ui/lib/utilization" import { cn } from "@maple/ui/lib/utils" import { Option } from "effect" import { AsyncData } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" +import { + formatLag, + formatStoragePercent, + lagClass, + utilizationClass, +} from "../../components/infra/planetscale/metrics" import type { LoadFailure } from "../../data/load-failure" import { AppRoute, hrefOf } from "../../route" import { alert, alertDescription, alertTitle } from "../../ui/alert" @@ -39,23 +42,6 @@ import { queryActivityCharts } from "./view-db-charts" type H = HtmlBuilder -// PlanetScale tone classes and formats (see components/infra/planetscale/metrics.ts). -const utilizationClass = (percent: number): string | undefined => { - const level = utilizationLevel(percent / 100) - return level === "ok" ? undefined : TONE_TEXT[level] -} -const lagClass = (seconds: number): string | undefined => - !Number.isFinite(seconds) - ? undefined - : seconds > 10 - ? TONE_TEXT.crit - : seconds > 1 - ? TONE_TEXT.warn - : undefined -const formatLag = (seconds: number) => formatDuration(seconds * 1000) -const formatStoragePercent = (percent: number) => - `${percent >= 99.5 || percent < 10 ? percent.toFixed(1) : percent.toFixed(0)}%` - /** A failed read as the panels show it: a small critical Alert with `displayError`'s copy. */ export const failureAlert = (h: H, data: AsyncData.AsyncData): Option.Option => Option.map(AsyncData.getError(data), (failure) => diff --git a/apps/web/src/page/service-map/view-scene3d.ts b/apps/web/src/page/service-map/view-scene3d.ts index 795bb8554b..8cb2c669d2 100644 --- a/apps/web/src/page/service-map/view-scene3d.ts +++ b/apps/web/src/page/service-map/view-scene3d.ts @@ -17,6 +17,7 @@ import { button } from "../../ui/button" import { icon } from "../../ui/icon" import { iconButton } from "../../ui/icon-button" import { middleTruncate } from "../../ui/middle-truncate" +import { statusDot } from "../../ui/status-dot" import * as ToggleGroup from "../../ui/toggle-group" import { machineBadge } from "./badge" import { @@ -335,13 +336,10 @@ const overlays = ( h.span( [h.Class("flex items-center gap-1.5")], [ - h.span( - [ - h.Class("size-1.5 rounded-full"), - h.Style({ "background-color": HEALTH_COLOR[status] }), - ], - [], - ), + statusDot(h, { + tone: "custom", + attributes: [h.Style({ "background-color": HEALTH_COLOR[status] })], + }), HEALTH_LABEL[status], ], ), diff --git a/apps/web/src/page/services/detail/endpoint-peek/view.ts b/apps/web/src/page/services/detail/endpoint-peek/view.ts index e3bf206d94..46287738f7 100644 --- a/apps/web/src/page/services/detail/endpoint-peek/view.ts +++ b/apps/web/src/page/services/detail/endpoint-peek/view.ts @@ -8,6 +8,7 @@ import { formatRate, } from "@maple/ui/lib/format" import { refreshingClass } from "@maple/ui/lib/refreshing" +import { TONE_FILL, TONE_TEXT, type Tone } from "@maple/ui/lib/tone" import { cn } from "@maple/ui/lib/utils" import { toEpochMs } from "@maple/ui/lib/time-format" import { AsyncData, Submodel } from "foldkit" @@ -18,10 +19,14 @@ import { linkButton } from "../../../../ui/button" import * as Dialog from "../../../../ui/dialog" import * as DialogView from "../../../../ui/dialog-view" import { kbd } from "../../../../ui/display" +import { emptyMessage } from "../../../../ui/empty" import { errorState } from "../../../../ui/error-state" +import { eyebrow } from "../../../../ui/eyebrow" import { icon } from "../../../../ui/icon" import { iconButton } from "../../../../ui/icon-button" import { skeleton } from "../../../../ui/skeleton" +import { truncatedId } from "../../../../ui/truncated-id" +import { statRail, statRailItem } from "../../../kit/stat-rail" import { callsPerSecond } from "../derive" import { endpointStatusCodes, @@ -65,7 +70,7 @@ export type ViewInputs = Readonly<{ const toChart = (chartId: string) => (message: Chart.Message) => Message.GotChartMessage({ chartId, message }) -const eyebrowClass = "text-2xs uppercase tracking-wider text-muted-foreground" +const sectionLabel = (h: H, label: string): Html => eyebrow(h, { variant: "label", as: "h3" }, [label]) const card = ( h: H, @@ -79,7 +84,7 @@ const card = ( [ h.div( [h.Class("flex items-baseline justify-between gap-2")], - [h.h3([h.Class(eyebrowClass)], [label]), ...(aside === null ? [] : [aside])], + [sectionLabel(h, label), ...(aside === null ? [] : [aside])], ), ...children, ], @@ -87,66 +92,46 @@ const card = ( const hint = (h: H, text: string) => h.span([h.Class("text-3xs text-muted-foreground")], [text]) -const stat = (h: H, label: string, value: string, sub: string, valueClassName?: string): Html => - h.div( - [ - h.Key(label), - h.Class( - "flex min-w-0 flex-col gap-1 border-b p-3 odd:border-r sm:border-r sm:border-b-0 sm:last:border-r-0", - ), - ], - [ - h.span([h.Class("text-3xs uppercase tracking-wider text-muted-foreground")], [label]), - h.span([h.Class(cn("text-lg font-medium leading-6 tabular-nums", valueClassName))], [value]), - h.span([h.Class("truncate text-3xs text-muted-foreground")], [sub]), - ], - ) +const stat = (h: H, label: string, value: string, sub: string, tone?: Tone): Html => + statRailItem(h, { eyebrow: label, value, subline: sub, tone, size: "sm", className: "min-w-0" }) const stats = (h: H, peek: Peek, codes: ReturnType, seconds: number): Html => { const { endpoint } = peek const mix = knownStatusMix(endpoint) const topClient = codes.find((code) => code.name.startsWith("4")) - return h.div( - [h.Class("grid grid-cols-2 overflow-hidden rounded-lg border bg-card sm:grid-cols-4")], - [ - stat( - h, - "Req/s", - formatRate(callsPerSecond(endpoint.estimatedSpanCount, seconds)), - `${formatNumber(Math.round(endpoint.estimatedSpanCount))} total`, - ), - stat( - h, - "4xx", - mix === null ? "—" : formatErrorRate(mix.client), - // The leading code comes from the status rows; until they land it stays blank. - mix === null || mix.client === 0 - ? "none in window" - : topClient === undefined - ? "" - : `mostly ${topClient.name}`, - mix !== null && mix.client > 0 ? "text-warning-foreground" : undefined, - ), - stat( - h, - "5xx", - mix === null ? formatErrorRate(endpoint.errorRate) : formatErrorRate(mix.server), - (mix?.server ?? endpoint.errorRate) === 0 - ? "none in window" - : countLabel( - Math.round(endpoint.estimatedSpanCount * (mix?.server ?? endpoint.errorRate)), - "response", - ), - (mix?.server ?? endpoint.errorRate) > 0 ? "text-destructive-foreground" : undefined, - ), - stat( - h, - "p95", - formatLatency(endpoint.p95DurationMs), - `p99 ${formatLatency(endpoint.p99DurationMs)}`, - ), - ], - ) + return statRail(h, { columns: 4, className: "overflow-hidden" }, [ + stat( + h, + "Req/s", + formatRate(callsPerSecond(endpoint.estimatedSpanCount, seconds)), + `${formatNumber(Math.round(endpoint.estimatedSpanCount))} total`, + ), + stat( + h, + "4xx", + mix === null ? "—" : formatErrorRate(mix.client), + // The leading code comes from the status rows; until they land it stays blank. + mix === null || mix.client === 0 + ? "none in window" + : topClient === undefined + ? "" + : `mostly ${topClient.name}`, + mix !== null && mix.client > 0 ? "warn" : undefined, + ), + stat( + h, + "5xx", + mix === null ? formatErrorRate(endpoint.errorRate) : formatErrorRate(mix.server), + (mix?.server ?? endpoint.errorRate) === 0 + ? "none in window" + : countLabel( + Math.round(endpoint.estimatedSpanCount * (mix?.server ?? endpoint.errorRate)), + "response", + ), + (mix?.server ?? endpoint.errorRate) > 0 ? "crit" : undefined, + ), + stat(h, "p95", formatLatency(endpoint.p95DurationMs), `p99 ${formatLatency(endpoint.p99DurationMs)}`), + ]) } const legendSwatch = (h: H, label: string, color: string): Html => @@ -162,7 +147,7 @@ const chartSection = (h: H, title: string, legend: ReadonlyArray, body: Ht h.div( [h.Class("flex items-center justify-between")], [ - h.h3([h.Class(eyebrowClass)], [title]), + sectionLabel(h, title), h.div([h.Class("flex items-center gap-3 text-3xs text-muted-foreground")], legend), ], ), @@ -241,10 +226,10 @@ const statusCard = (h: H, rows: ReadonlyArray, spanName: string): Htm ? [h.p([h.Class("text-2xs text-muted-foreground")], ["No status codes recorded in this window."])] : codes.slice(0, 4).map((code) => { const tone = code.name.startsWith("2") - ? { text: "text-success-foreground", bar: "bg-chart-3" } + ? { text: TONE_TEXT.info, bar: "bg-chart-3" } : isServerError(code.name) - ? { text: "text-destructive-foreground", bar: "bg-severity-error" } - : { text: "text-warning-foreground", bar: "bg-warning" } + ? { text: TONE_TEXT.crit, bar: TONE_FILL.crit } + : { text: TONE_TEXT.warn, bar: TONE_FILL.warn } return h.div( [h.Key(code.name), h.Class("flex flex-col gap-1")], [ @@ -426,7 +411,7 @@ const slowestCard = (h: H, detail: EndpointDetail, inputs: ViewInputs): Html => h.div( [h.Class("flex items-baseline gap-2")], [ - h.h3([h.Class(eyebrowClass)], ["Slowest requests"]), + sectionLabel(h, "Slowest requests"), h.span( [h.Class("hidden text-3xs text-muted-foreground sm:inline")], ["slowest per hour and status"], @@ -445,12 +430,7 @@ const slowestCard = (h: H, detail: EndpointDetail, inputs: ViewInputs): Html => ], ), ...(detail.slowest.length === 0 - ? [ - h.p( - [h.Class("px-3 py-4 text-2xs text-muted-foreground")], - ["No requests in this window."], - ), - ] + ? [emptyMessage(h, {}, ["No requests in this window."])] : detail.slowest.map((request) => h.a( [ @@ -461,10 +441,11 @@ const slowestCard = (h: H, detail: EndpointDetail, inputs: ViewInputs): Html => ), ], [ - h.span( - [h.Class("w-16 shrink-0 text-info-foreground sm:w-20")], - [request.traceId.slice(0, 8)], - ), + truncatedId(h, { + value: request.traceId, + kind: "trace", + className: "w-16 shrink-0 text-info-foreground sm:w-20", + }), h.span( [h.Class("w-14 shrink-0 text-right")], [formatLatency(request.durationMs)], @@ -475,10 +456,12 @@ const slowestCard = (h: H, detail: EndpointDetail, inputs: ViewInputs): Html => cn( "w-10 shrink-0", request.httpStatusCode.startsWith("2") - ? "text-success-foreground" - : isServerError(request.httpStatusCode) - ? "text-destructive-foreground" - : "text-warning-foreground", + ? TONE_TEXT.info + : TONE_TEXT[ + isServerError(request.httpStatusCode) + ? "crit" + : "warn" + ], ), ), ], diff --git a/apps/web/src/page/services/detail/view-api-tab.ts b/apps/web/src/page/services/detail/view-api-tab.ts index 855fb0aca1..a29ed7662d 100644 --- a/apps/web/src/page/services/detail/view-api-tab.ts +++ b/apps/web/src/page/services/detail/view-api-tab.ts @@ -8,19 +8,23 @@ import { formatRate, } from "@maple/ui/lib/format" import { refreshingClass } from "@maple/ui/lib/refreshing" +import { TONE_FILL, TONE_TEXT, type Tone } from "@maple/ui/lib/tone" import { cn } from "@maple/ui/lib/utils" import { Option } from "effect" import { AsyncData } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import type * as Chart from "../../../charts/chart" import type { ThemeColors } from "../../../charts/theme-colors" +import { emptyMessage } from "../../../ui/empty" import { errorState } from "../../../ui/error-state" import { panel } from "../../../ui/panel" import { searchInput } from "../../../ui/search-input" import { skeleton } from "../../../ui/skeleton" import { statFigure } from "../../../ui/stat-figure" +import { statusDot } from "../../../ui/status-dot" import { table, tableBody, tableHeader, tableRow } from "../../../ui/table" import { docsLink } from "../../kit/signal-empty-state" +import { statRail, statRailLoading } from "../../kit/stat-rail" import { windowSeconds } from "./derive" import { apiKpis, @@ -69,7 +73,7 @@ const MARKER_GUTTER = 32 type Tile = Readonly<{ label: string - dot?: string + dot?: Tone value: string unit?: string valueClassName?: string @@ -82,19 +86,12 @@ type Tile = Readonly<{ // Same anatomy as the shared `statRailItem`: sentence-case label, figure with its unit, caption. const kpiTile = (h: H, tile: Tile, theme: ThemeColors | null): Html => h.div( - [ - h.Key(tile.label), - h.Class( - "flex min-w-0 flex-col border-b px-5 py-4 odd:border-r last:col-span-2 last:border-b-0 last:border-r-0 md:border-r md:border-b-0 md:last:col-span-1 md:last:border-r-0", - ), - ], + [h.Key(tile.label), h.Class("flex min-w-0 flex-col px-5 py-4 last:col-span-2 md:last:col-span-1")], [ h.div( [h.Class("flex items-center gap-1.5")], [ - ...(tile.dot === undefined - ? [] - : [h.span([h.Class(cn("size-1.5 rounded-full", tile.dot))])]), + ...(tile.dot === undefined ? [] : [statusDot(h, { tone: tile.dot })]), h.span([h.Class("truncate text-2xs font-medium text-muted-foreground")], [tile.label]), ], ), @@ -170,21 +167,21 @@ const kpiStrip = ( }, { label: "4xx responses", - dot: "bg-warning", + dot: "warn", value: client === null ? "—" : formatErrorRate(client), - valueClassName: client !== null && client > 0 ? "text-warning-foreground" : undefined, + valueClassName: client !== null && client > 0 ? TONE_TEXT.warn : undefined, aside: client === null ? "no status codes" : leadingCode(summaries, false), - asideClassName: client !== null && client > 0 ? "text-warning-foreground" : undefined, + asideClassName: client !== null && client > 0 ? TONE_TEXT.warn : undefined, values: sparks.client, token: client !== null && client > 0 ? SPARK_TOKENS.client : SPARK_TOKENS.quiet, }, { label: "5xx responses", - dot: "bg-severity-error", + dot: "crit", value: server === null ? "—" : formatErrorRate(server), - valueClassName: server !== null && server > 0.01 ? "text-destructive-foreground" : undefined, + valueClassName: server !== null && server > 0.01 ? TONE_TEXT.crit : undefined, aside: server === null ? "no status codes" : leadingCode(summaries, true), - asideClassName: server !== null && server > 0 ? "text-destructive-foreground" : undefined, + asideClassName: server !== null && server > 0 ? TONE_TEXT.crit : undefined, values: sparks.server, token: server !== null && server > 0 ? SPARK_TOKENS.server : SPARK_TOKENS.quiet, }, @@ -196,8 +193,8 @@ const kpiStrip = ( kpis.p95Change === null || Math.abs(kpis.p95Change) < 0.05 ? undefined : kpis.p95Change < 0 - ? "text-success-foreground" - : "text-warning-foreground", + ? TONE_TEXT.info + : TONE_TEXT.warn, values: sparks.p95, token: SPARK_TOKENS.latency, }, @@ -210,30 +207,14 @@ const kpiStrip = ( token: SPARK_TOKENS.bytes, }, ] - return h.div( - [h.Class("grid w-full grid-cols-2 overflow-hidden rounded-lg border bg-card md:grid-cols-5")], + return statRail( + h, + { columns: 5, className: "w-full overflow-hidden" }, tiles.map((tile) => kpiTile(h, tile, theme)), ) } -const kpiSkeleton = (h: H): Html => - h.div( - [ - h.Class("grid w-full grid-cols-2 overflow-hidden rounded-lg border bg-card md:grid-cols-5"), - h.AriaBusy(true), - ], - Array.from({ length: 5 }, (_, index) => - h.div( - [ - h.Key(String(index)), - h.Class( - "flex flex-col gap-2.5 border-b px-5 py-4 md:border-r md:border-b-0 md:last:border-r-0", - ), - ], - [skeleton(h, "h-2.5 w-20"), skeleton(h, "h-6 w-24"), skeleton(h, "h-7 w-full")], - ), - ), - ) +const kpiSkeleton = (h: H): Html => statRailLoading(h, 5, 5, "w-full overflow-hidden") // RESPONSES CHART @@ -270,74 +251,71 @@ const responsesChart = ( }) const context: ChartContext | null = theme === null ? null : { theme, timeZone: inputs.timeZone, nowMs: model.nowMs ?? 0 } - return h.div( - [h.Class("flex min-w-0 flex-1 flex-col gap-3 rounded-lg border bg-card p-4")], - [ - h.div( - [h.Class("flex flex-wrap items-center justify-between gap-x-4 gap-y-2")], - [ - h.div( - [h.Class("flex items-baseline gap-2")], - [ - h.span([h.Class("text-ui font-medium")], ["Responses by status"]), - h.span([h.Class("text-2xs text-muted-foreground")], ["req/s, all endpoints"]), - ], - ), - h.div( - [h.Class("flex items-center gap-4 text-2xs text-muted-foreground")], - [ - legendItem( - h, - swatch(h, STATUS_COLORS["2xx"]), - `2xx ${mix === null ? "—" : formatErrorRate(mix.ok)}`, - ), - legendItem( - h, - swatch(h, STATUS_COLORS["4xx"]), - `4xx ${mix === null ? "—" : formatErrorRate(mix.client)}`, - ), - legendItem( - h, - swatch(h, STATUS_COLORS["5xx"]), - `5xx ${mix === null ? "—" : formatErrorRate(mix.server)}`, - ), - ...(hasUnknownBuckets(overview.buckets) - ? [legendItem(h, swatch(h, STATUS_COLORS.unknown), "no status code")] - : []), - ...(markers.length === 0 - ? [] - : [ - legendItem( - h, - h.span([ - h.Class( - "h-2.5 w-0 border-l border-dashed border-muted-foreground", - ), - ]), - "deploy", - ), - ]), - ], - ), - ], - ), - context === null - ? skeleton(h, "h-[200px] w-full") - : statusChart(h, { - chartId: "api-responses", - chart: chartModel, - buckets: overview.buckets, - bucketSeconds: inputs.bucketSeconds, - window: inputs.window, - context, - className: "h-[200px] w-full", - marginTop: markers.length === 0 ? undefined : MARKER_GUTTER, - overlay, - reportPlot: markers.length > 0, - toMessage: toChart("api-responses"), - }), - ], - ) + return panel(h, { padded: true, className: "flex-1 gap-3" }, [ + h.div( + [h.Class("flex flex-wrap items-center justify-between gap-x-4 gap-y-2")], + [ + h.div( + [h.Class("flex items-baseline gap-2")], + [ + h.span([h.Class("text-ui font-medium")], ["Responses by status"]), + h.span([h.Class("text-2xs text-muted-foreground")], ["req/s, all endpoints"]), + ], + ), + h.div( + [h.Class("flex items-center gap-4 text-2xs text-muted-foreground")], + [ + legendItem( + h, + swatch(h, STATUS_COLORS["2xx"]), + `2xx ${mix === null ? "—" : formatErrorRate(mix.ok)}`, + ), + legendItem( + h, + swatch(h, STATUS_COLORS["4xx"]), + `4xx ${mix === null ? "—" : formatErrorRate(mix.client)}`, + ), + legendItem( + h, + swatch(h, STATUS_COLORS["5xx"]), + `5xx ${mix === null ? "—" : formatErrorRate(mix.server)}`, + ), + ...(hasUnknownBuckets(overview.buckets) + ? [legendItem(h, swatch(h, STATUS_COLORS.unknown), "no status code")] + : []), + ...(markers.length === 0 + ? [] + : [ + legendItem( + h, + h.span([ + h.Class( + "h-2.5 w-0 border-l border-dashed border-muted-foreground", + ), + ]), + "deploy", + ), + ]), + ], + ), + ], + ), + context === null + ? skeleton(h, "h-[200px] w-full") + : statusChart(h, { + chartId: "api-responses", + chart: chartModel, + buckets: overview.buckets, + bucketSeconds: inputs.bucketSeconds, + window: inputs.window, + context, + className: "h-[200px] w-full", + marginTop: markers.length === 0 ? undefined : MARKER_GUTTER, + overlay, + reportPlot: markers.length > 0, + toMessage: toChart("api-responses"), + }), + ]) } // NON-2XX BREAKDOWN @@ -356,7 +334,7 @@ const codeRow = (h: H, summary: StatusCodeSummary): Html => { h.Class( cn( "rounded-sm bg-muted px-1 font-medium", - isServer ? "text-destructive-foreground" : "text-warning-foreground", + TONE_TEXT[isServer ? "crit" : "warn"], ), ), ], @@ -374,7 +352,7 @@ const codeRow = (h: H, summary: StatusCodeSummary): Html => { [h.Class("h-1 w-full rounded-full bg-muted")], [ h.div([ - h.Class(cn("h-1 rounded-full", isServer ? "bg-severity-error" : "bg-warning")), + h.Class(cn("h-1 rounded-full", TONE_FILL[isServer ? "crit" : "warn"])), h.Style({ width: `${Math.max(summary.share * 100, 1)}%` }), ]), ], @@ -394,50 +372,38 @@ const codeRow = (h: H, summary: StatusCodeSummary): Html => { const breakdownPanel = (h: H, summaries: ReadonlyArray | null): Html => { const total = summaries?.reduce((sum, summary) => sum + summary.count, 0) ?? 0 - return h.div( - [ - h.Class( - "flex w-full shrink-0 flex-col gap-3 overflow-hidden rounded-lg border bg-card p-4 lg:w-[400px]", - ), - ], - [ - h.div( - [h.Class("flex items-baseline justify-between gap-2")], - [ - h.span([h.Class("text-ui font-medium")], ["Non-2xx responses"]), - ...(summaries === null - ? [] - : [ - h.span( - [ - h.Class("text-2xs text-muted-foreground tabular-nums"), - h.Title( - "Counted over complete hours, so the total can reach past the window's edges", - ), - ], - [`${formatNumber(Math.round(total))} in complete hours`], - ), - ]), - ], - ), - summaries === null - ? h.div( - [h.Class("flex flex-col gap-3"), h.AriaBusy(true)], - [0, 1, 2, 3].map((index) => - h.div([h.Key(String(index))], [skeleton(h, "h-9 w-full")]), - ), - ) - : summaries.length === 0 - ? h.p( - [h.Class("py-6 text-center text-xs text-muted-foreground")], - ["Every response in this window was below 400."], - ) - : h.div( - [h.Class("flex flex-col gap-3")], - summaries.slice(0, 4).map((summary) => codeRow(h, summary)), - ), - ], - ) + return panel(h, { padded: true, className: "w-full shrink-0 gap-3 lg:w-[400px]" }, [ + h.div( + [h.Class("flex items-baseline justify-between gap-2")], + [ + h.span([h.Class("text-ui font-medium")], ["Non-2xx responses"]), + ...(summaries === null + ? [] + : [ + h.span( + [ + h.Class("text-2xs text-muted-foreground tabular-nums"), + h.Title( + "Counted over complete hours, so the total can reach past the window's edges", + ), + ], + [`${formatNumber(Math.round(total))} in complete hours`], + ), + ]), + ], + ), + summaries === null + ? h.div( + [h.Class("flex flex-col gap-3"), h.AriaBusy(true)], + [0, 1, 2, 3].map((index) => h.div([h.Key(String(index))], [skeleton(h, "h-9 w-full")])), + ) + : summaries.length === 0 + ? emptyMessage(h, {}, ["Every response in this window was below 400."]) + : h.div( + [h.Class("flex flex-col gap-3")], + summaries.slice(0, 4).map((summary) => codeRow(h, summary)), + ), + ]) } // TABLE @@ -549,73 +515,65 @@ const endpointsTable = ( }) // A sampled endpoint's rate is scaled up from its traced spans; the header says so. const isEstimated = visible.some((endpoint) => endpoint.estimatedSpanCount > endpoint.spanCount) - return h.div( - [h.Class("flex w-full flex-col overflow-hidden rounded-lg border bg-card")], - [ - toolbar(h, model, visible.length), - table(h, {}, [ - tableHeader(h, [ - tableRow( - h, - [ - column("path", "Endpoint", "pl-4", "left"), - isEstimated - ? column( - "traffic", - "Req/s est.", - COLUMN.rate, - undefined, - "Estimated from sampled traces", - ) - : column("traffic", "Req/s", COLUMN.rate), - headLabel( + return panel(h, { className: "w-full" }, [ + toolbar(h, model, visible.length), + table(h, {}, [ + tableHeader(h, [ + tableRow( + h, + [ + column("path", "Endpoint", "pl-4", "left"), + isEstimated + ? column( + "traffic", + "Req/s est.", + COLUMN.rate, + undefined, + "Estimated from sampled traces", + ) + : column("traffic", "Req/s", COLUMN.rate), + headLabel( + h, + [ + h.span([h.Class("md:hidden")], ["4xx"]), + h.span([h.Class("hidden md:inline")], ["2xx / 4xx / 5xx"]), + ], + cn("pr-0 text-right", COLUMN.mix), + ), + column("errorRate", "5xx", COLUMN.err), + column("p50", "p50", COLUMN.p50), + column("p95", "p95", COLUMN.p95), + column("p99", "p99", COLUMN.p99), + headLabel(h, ["Body avg"], cn("pr-0 text-right", COLUMN.body)), + headLabel(h, [], COLUMN.chevron), + ], + "border-b hover:bg-transparent", + ), + ]), + tableBody( + h, + groups.length === 0 + ? [ + tableRow( h, [ - h.span([h.Class("md:hidden")], ["4xx"]), - h.span([h.Class("hidden md:inline")], ["2xx / 4xx / 5xx"]), + h.td( + [ + h.Attribute("colspan", String(COLUMN_COUNT)), + h.Class("py-8 text-center text-xs text-muted-foreground"), + ], + ["No endpoint matches this filter."], + ), ], - cn("pr-0 text-right", COLUMN.mix), + "hover:bg-transparent", ), - column("errorRate", "5xx", COLUMN.err), - column("p50", "p50", COLUMN.p50), - column("p95", "p95", COLUMN.p95), - column("p99", "p99", COLUMN.p99), - headLabel(h, ["Body avg"], cn("pr-0 text-right", COLUMN.body)), - headLabel(h, [], COLUMN.chevron), - ], - "border-b hover:bg-transparent", - ), - ]), - tableBody( - h, - groups.length === 0 - ? [ - tableRow( - h, - [ - h.td( - [ - h.Attribute("colspan", String(COLUMN_COUNT)), - h.Class("py-8 text-center text-xs text-muted-foreground"), - ], - ["No endpoint matches this filter."], - ), - ], - "hover:bg-transparent", - ), - ] - : groups.flatMap((group) => - groupRows( - h, - group, - context, - model.expandedEndpointGroups.includes(group.kind), - ), - ), - ), - ]), - ], - ) + ] + : groups.flatMap((group) => + groupRows(h, group, context, model.expandedEndpointGroups.includes(group.kind)), + ), + ), + ]), + ]) } // STATES diff --git a/apps/web/src/page/services/detail/view-api.ts b/apps/web/src/page/services/detail/view-api.ts index 332379bc84..aaa879913d 100644 --- a/apps/web/src/page/services/detail/view-api.ts +++ b/apps/web/src/page/services/detail/view-api.ts @@ -12,6 +12,7 @@ import { formatNumber, formatRate, } from "@maple/ui/lib/format" +import { TONE_FILL, TONE_TEXT } from "@maple/ui/lib/tone" import { cn } from "@maple/ui/lib/utils" import type { Html, HtmlBuilder } from "foldkit/html" import type { ThemeColors } from "../../../charts/theme-colors" @@ -94,8 +95,14 @@ export const statusMixBar = (h: HtmlBuilder, mix: StatusMix | null, classN ? [] : [ h.div([h.Class("h-full bg-chart-3"), h.Style({ width: `${mix.ok * 100}%` })]), - h.div([h.Class("h-full bg-warning"), h.Style({ width: `${mix.client * 100}%` })]), - h.div([h.Class("h-full bg-severity-error"), h.Style({ width: `${mix.server * 100}%` })]), + h.div([ + h.Class(cn("h-full", TONE_FILL.warn)), + h.Style({ width: `${mix.client * 100}%` }), + ]), + h.div([ + h.Class(cn("h-full", TONE_FILL.crit)), + h.Style({ width: `${mix.server * 100}%` }), + ]), ], ) @@ -187,7 +194,10 @@ const endpointRow = ( h.span( [ h.Class( - "hidden shrink-0 rounded-sm bg-muted px-1.5 text-3xs text-warning-foreground md:inline", + cn( + "hidden shrink-0 rounded-sm bg-muted px-1.5 text-3xs md:inline", + TONE_TEXT.warn, + ), ), ], ["slowest"], @@ -254,7 +264,7 @@ const endpointRow = ( statusMixBar(h, mix, "hidden w-24 md:flex"), h.span( [h.Class("w-12 text-right")], - [shareText(h, mix?.client ?? null, "text-warning-foreground")], + [shareText(h, mix?.client ?? null, TONE_TEXT.warn)], ), ], ), @@ -263,7 +273,7 @@ const endpointRow = ( ), tableCell( h, - [shareText(h, serverShare(endpoint), "text-destructive-foreground")], + [shareText(h, serverShare(endpoint), TONE_TEXT.crit)], cn(numeric, "py-0 pr-0", COLUMN.err), ), latencyCell(h, endpoint.p50DurationMs, "p50", COLUMN.p50), @@ -358,9 +368,7 @@ const groupHeaderRow = ( cell( formatErrorRate(mix === null ? group.totals.errorRate : mix.server), COLUMN.err, - (mix === null ? group.totals.errorRate : mix.server) > 0 - ? "text-destructive-foreground" - : undefined, + (mix === null ? group.totals.errorRate : mix.server) > 0 ? TONE_TEXT.crit : undefined, ), cell("—", COLUMN.p50), cell(formatLatencyText(group.totals.p95DurationMs), COLUMN.p95), diff --git a/apps/web/src/page/services/detail/view-deploys.ts b/apps/web/src/page/services/detail/view-deploys.ts index ccd1b4d5ed..082dc5e039 100644 --- a/apps/web/src/page/services/detail/view-deploys.ts +++ b/apps/web/src/page/services/detail/view-deploys.ts @@ -4,7 +4,9 @@ import { shortId } from "@maple/ui/lib/ids" import { TONE_SOFT } from "@maple/ui/lib/tone" import { skeletonListGapStyles, skeletonListStyles } from "@maple/ui/styles/ui/skeleton" import { cn } from "@maple/ui/lib/utils" +import { IssueSeverity } from "@maple/domain/http" import { AsyncData } from "foldkit" +import { Schema } from "effect" import type { Html, HtmlBuilder } from "foldkit/html" import { button } from "../../../ui/button" import { badge } from "../../../ui/display" @@ -20,6 +22,7 @@ import type { Issue, Model } from "./model" import { DEPLOYS_EXPANDED, DEPLOYS_RAIL } from "./update" import { sectionCard } from "./view-panels" import { relativeTime } from "../../kit/relative-time" +import { severityBadge } from "../../errors/kit/severity" // `ServiceErrorsPanel` + `IssueLine`, and `ServiceRecentDeploys` (components/services/). @@ -27,29 +30,21 @@ type H = HtmlBuilder export type Clock = Readonly<{ nowMs: number; timeZone: string }> -// Issue severity tones and labels (as in page/errors/kit/severity.ts). -const SEVERITY = new Map([ - ["critical", { tone: "bg-severity-fatal/12 text-severity-fatal", label: "Critical" }], - ["high", { tone: "bg-severity-error/12 text-severity-error", label: "High" }], - ["medium", { tone: "bg-severity-warn/12 text-severity-warn", label: "Medium" }], - ["low", { tone: "bg-severity-debug/12 text-severity-debug", label: "Low" }], -]) +const isIssueSeverity = Schema.is(IssueSeverity) -const severityBadge = (h: H, severity: string | null): Html => { - const known = severity === null ? undefined : SEVERITY.get(severity) - return known === undefined - ? h.span([h.Class(cn("inline-flex h-5", "w-[60px] justify-center")), h.Title("Severity not set")]) - : badge(h, { variant: "outline", className: cn(known.tone, "w-[60px] justify-center") }, [ - known.label, - ]) -} +const issueSeverityBadge = (h: H, severity: string | null): Html => + severityBadge( + h, + severity !== null && isIssueSeverity(severity) ? severity : null, + "w-[60px] justify-center", + ) const issueLine = (h: H, issue: Issue, clock: Clock): Html => listRow(h, { density: "compact", className: "gap-2.5 rounded-sm px-2 text-sm", href: `/errors/issues/${issue.id}`, - leading: severityBadge(h, issue.severity), + leading: issueSeverityBadge(h, issue.severity), title: truncatedText(h, { text: issue.title, className: "font-normal" }), trailing: h.span( [h.Class("contents")], @@ -58,7 +53,14 @@ const issueLine = (h: H, issue: Issue, clock: Clock): Html => [h.Class("font-mono text-xs tabular-nums text-muted-foreground")], [formatNumber(issue.occurrenceCount), "×"], ), - relativeTime(h, { value: issue.lastSeenAt, ...clock, variant: "orDate", mono: true, tooltip: "title", className: "w-14 text-right text-xs text-muted-foreground/70" }), + relativeTime(h, { + value: issue.lastSeenAt, + ...clock, + variant: "orDate", + mono: true, + tooltip: "title", + className: "w-14 text-right text-xs text-muted-foreground/70", + }), ], ), }) @@ -153,7 +155,14 @@ const rowMeta = ( ): ReadonlyArray => [ ...prefix, h.span([h.Class("shrink-0 tabular-nums")], [formatNumber(deploy.spanCount), " spans"]), - relativeTime(h, { value: deploy.firstSeen, ...clock, variant: "orDate", mono: true, tooltip: "title", className: "ml-auto shrink-0 cursor-default text-muted-foreground/70" }), + relativeTime(h, { + value: deploy.firstSeen, + ...clock, + variant: "orDate", + mono: true, + tooltip: "title", + className: "ml-auto shrink-0 cursor-default text-muted-foreground/70", + }), ] const blankAvatar = (h: H): Html => h.span([h.Class("block size-5 rounded-full bg-muted")]) diff --git a/apps/web/src/page/services/detail/view-footprint.ts b/apps/web/src/page/services/detail/view-footprint.ts index 3e48a74fc1..dbecd19d14 100644 --- a/apps/web/src/page/services/detail/view-footprint.ts +++ b/apps/web/src/page/services/detail/view-footprint.ts @@ -66,7 +66,11 @@ export const usagePanel = (h: H, model: Model, envFilterActive: boolean): Html = [ eyebrow(h, {}, [stat.label]), h.span( - [h.Class("font-mono text-lg leading-tight tabular-nums text-foreground")], + [ + h.Class( + "font-mono text-lg font-semibold leading-tight tabular-nums text-foreground", + ), + ], [stat.format(totals[stat.key])], ), delta(h, { @@ -93,11 +97,7 @@ const meterRow = (h: H, label: string, fraction: number): Html => { [h.Key(label), h.Class("flex items-center gap-2 leading-none")], [ h.span( - [ - h.Class( - cn("w-8", "shrink-0 font-mono text-3xs tracking-wider text-muted-foreground/70"), - ), - ], + [h.Class(cn("w-8", "shrink-0 font-mono text-3xs tracking-wider text-muted-foreground/70"))], [label], ), meter(h, { diff --git a/apps/web/src/page/services/list/view-table.ts b/apps/web/src/page/services/list/view-table.ts index ed92a30918..4c83762e88 100644 --- a/apps/web/src/page/services/list/view-table.ts +++ b/apps/web/src/page/services/list/view-table.ts @@ -453,7 +453,7 @@ const desktopTable = ( environmentBadge(h, environment), h.span( [h.Class("text-xs text-muted-foreground")], - [String(rows.length), " ", rows.length === 1 ? "service" : "services"], + [String(rows.length), " ", pluralize(rows.length, "service")], ), ], mode === "namespace" ? "pl-4" : undefined, diff --git a/apps/web/src/page/settings/ai/view.ts b/apps/web/src/page/settings/ai/view.ts index a9d8bdc61a..88adb7a881 100644 --- a/apps/web/src/page/settings/ai/view.ts +++ b/apps/web/src/page/settings/ai/view.ts @@ -1,7 +1,6 @@ import type { AiTriageSettingsDocument, OpenRouterKeyDocument } from "@maple/domain/http" import { formatNumber } from "@maple/ui/lib/format" import { cn } from "@maple/ui/lib/utils" -import { buttonVariants } from "@maple/ui/styles/ui/button" import { collapsiblePanelStyles } from "@maple/ui/styles/ui/collapsible" import { PASSES_PER_DAY_LIMITS, @@ -11,7 +10,7 @@ import { import { limitsUpdate } from "../../../components/settings/ai-triage-limits" import { AsyncData, Submodel } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" -import { button } from "../../../ui/button" +import { button, linkButton } from "../../../ui/button" import { confirmDialog } from "../../../ui/confirm-dialog" import { badge } from "../../../ui/display" import { errorState } from "../../../ui/error-state" @@ -164,7 +163,7 @@ const keyForm = (h: H, model: Model, isReplacing: boolean): Html => { isLoading: model.isSavingKey, isDisabled: model.keyDraft.value.trim() === "", }, - [model.isSavingKey ? "Checking…" : "Save key"], + ["Save key"], ), ...(isReplacing ? [ @@ -656,12 +655,9 @@ const escalationCard = (h: H): Html => ), ], ), - h.a( - [ - h.Href("/settings?tab=automation"), - h.Class(cn(buttonVariants({ variant: "outline", size: "sm", className: "shrink-0" }))), - h.DataAttribute("slot", "button"), - ], + linkButton( + h, + { variant: "outline", size: "sm", className: "shrink-0", href: "/settings?tab=automation" }, ["Open automation"], ), ], diff --git a/apps/web/src/page/settings/audit-log/view.ts b/apps/web/src/page/settings/audit-log/view.ts index 184d872668..23c8edb817 100644 --- a/apps/web/src/page/settings/audit-log/view.ts +++ b/apps/web/src/page/settings/audit-log/view.ts @@ -17,7 +17,7 @@ import { Submodel } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import type { SettingsSearch as SettingsSearchSchema } from "../../../route/search" import { button } from "../../../ui/button" -import { avatarFallback, badge } from "../../../ui/display" +import { avatarFallback, badge, separator } from "../../../ui/display" import { empty, emptyDescription, emptyHeader, emptyMedia, emptyTitle } from "../../../ui/empty" import { errorState } from "../../../ui/error-state" import { eyebrow } from "../../../ui/eyebrow" @@ -164,7 +164,7 @@ const dateRange = (h: H, model: Model, range: AuditDateRange, timeZone: string): [option.label], ), ), - h.div([h.Class("mx-3 my-1 h-px bg-border/60")], []), + separator(h, "horizontal", "mx-3 my-1 data-[orientation=horizontal]:w-auto"), h.button( [ h.Type("button"), diff --git a/apps/web/src/page/settings/automation/view.ts b/apps/web/src/page/settings/automation/view.ts index fb10861c46..8a5da35abd 100644 --- a/apps/web/src/page/settings/automation/view.ts +++ b/apps/web/src/page/settings/automation/view.ts @@ -1,10 +1,8 @@ import type { AlertDestinationDocument, IssueSeverity } from "@maple/domain/http" -import { cn } from "@maple/ui/lib/utils" -import { buttonVariants } from "@maple/ui/styles/ui/button" import { AsyncData, Submodel } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { badge } from "../../../ui/display" -import { button } from "../../../ui/button" +import { button, linkButton } from "../../../ui/button" import { emptyMessage } from "../../../ui/empty" import { errorState } from "../../../ui/error-state" import { field, fieldLabel, type FieldState } from "../../../ui/field" @@ -136,14 +134,9 @@ const destinationsBlock = (h: H, model: Model, draft: PolicyDraft): Html => { h.div( [h.Class("flex flex-wrap items-center gap-x-4 gap-y-2")], [ - h.a( - [ - h.Href("/alerts?tab=routing"), - h.Class(cn(buttonVariants({ size: "sm", variant: "outline" }))), - h.DataAttribute("slot", "button"), - ], - ["Add a destination"], - ), + linkButton(h, { size: "sm", variant: "outline", href: "/alerts?tab=routing" }, [ + "Add a destination", + ]), docsLink(h, "destinations"), ], ), diff --git a/apps/web/src/page/settings/billing/view-cycle.ts b/apps/web/src/page/settings/billing/view-cycle.ts index 97d5e171e9..bfa46a07ba 100644 --- a/apps/web/src/page/settings/billing/view-cycle.ts +++ b/apps/web/src/page/settings/billing/view-cycle.ts @@ -1,5 +1,6 @@ import { formatCurrency } from "@maple/domain/format" import type { DailySpendResponse } from "@maple/domain/http" +import { TONE_TEXT } from "@maple/ui/lib/tone" import { cn } from "@maple/ui/lib/utils" import { collapsiblePanelStyles } from "@maple/ui/styles/ui/collapsible" import type { TrialStatus } from "../../../lib/billing/plan-gating" @@ -381,7 +382,7 @@ export const cyclePanel = (h: H, model: Model, inputs: CyclePanelInputs): Html = valueClassName: (spend.baseCents > 0 && spend.projectedCents > spend.baseCents) || (maximum !== null && spend.projectedCents > maximum) - ? "text-warning" + ? TONE_TEXT.warn : undefined, note: ["At this cycle's daily rate"], }), diff --git a/apps/web/src/page/settings/billing/view-plan.ts b/apps/web/src/page/settings/billing/view-plan.ts index 8a216bc22a..2b047e0333 100644 --- a/apps/web/src/page/settings/billing/view-plan.ts +++ b/apps/web/src/page/settings/billing/view-plan.ts @@ -43,15 +43,32 @@ const perInterval = (dollars: number | null, interval: string | null | undefined /** One line of the plan list: a name and caption on the left, a price and an optional action on the right. */ const planLine = ( h: H, - options: Readonly<{ key: string; name: Html | string; caption: string; price: Html | string; muted?: boolean; action?: Html }>, + options: Readonly<{ + key: string + name: Html | string + caption: string + price: Html | string + muted?: boolean + action?: Html + }>, ): Html => h.div( - [h.Key(options.key), h.Class("flex flex-wrap items-center justify-between gap-x-6 gap-y-1 px-4 py-3")], + [ + h.Key(options.key), + h.Class("flex flex-wrap items-center justify-between gap-x-6 gap-y-1 px-4 py-3"), + ], [ h.div( [h.Class("flex min-w-0 flex-col gap-0.5")], [ - h.span([h.Class(cn("text-sm font-medium", options.muted === true && "text-muted-foreground"))], [options.name]), + h.span( + [ + h.Class( + cn("text-sm font-medium", options.muted === true && "text-muted-foreground"), + ), + ], + [options.name], + ), h.span([h.Class("text-2xs text-muted-foreground")], [options.caption]), ], ), @@ -59,7 +76,14 @@ const planLine = ( [h.Class("flex items-center gap-4")], [ h.span( - [h.Class(cn("font-mono text-sm tabular-nums", options.muted === true && "text-muted-foreground"))], + [ + h.Class( + cn( + "font-mono text-sm tabular-nums", + options.muted === true && "text-muted-foreground", + ), + ), + ], [options.price], ), ...(options.action === undefined ? [] : [options.action]), @@ -121,15 +145,34 @@ const planRow = (h: H, spend: SpendModel, plans: ReadonlyArray, can const includedRun = (plan: CatalogPlan): string => plan.items .flatMap((item) => { - if (!isPricingCardFeature(item.featureId) || item.included === undefined || item.included === null) return [] + if ( + !isPricingCardFeature(item.featureId) || + item.included === undefined || + item.included === null + ) + return [] if (item.featureId === "ai_credits") return [`$${formatCount(item.included)} AI usage`] const amount = formatCount(item.included) - return [featureUnit(item.featureId) === "GB" ? `${amount} GB ${item.featureId}` : `${amount} ${featureUnit(item.featureId)}`] + return [ + featureUnit(item.featureId) === "GB" + ? `${amount} GB ${item.featureId}` + : `${amount} ${featureUnit(item.featureId)}`, + ] }) .join(" · ") const currentBadge = (h: H, text: string): Html => - badge(h, { variant: "muted", pill: true, size: "xs", mono: true, className: "px-2 bg-primary/15 text-primary" }, [text]) + badge( + h, + { + variant: "muted", + pill: true, + size: "xs", + mono: true, + className: "px-2 bg-primary/15 text-primary", + }, + [text], + ) /** A custom-priced customer's own plan, never a catalog offer they do not pay. */ const customPlanPlate = (h: H, spend: SpendModel): Html => { @@ -148,46 +191,69 @@ const customPlanPlate = (h: H, spend: SpendModel): Html => { ) .join(" · ") const isPriced = price !== undefined && price !== null - return panel(h, { padded: true, className: "gap-6 border-primary/40 bg-primary/[0.03] lg:flex-row lg:items-start lg:justify-between" }, [ - h.div( - [h.Class("max-w-sm")], - [ - h.div( - [h.Class("flex items-center gap-2")], - [h.span([h.Class("text-sm")], [spend.planName ?? "Your plan"]), currentBadge(h, "Current plan")], - ), - h.div( - [h.Class("mt-3 flex items-baseline gap-2")], - [ - h.span([h.Class("font-mono text-3xl tabular-nums")], [isPriced ? `$${price}` : "Custom"]), - h.span( - [h.Class("text-2xs text-muted-foreground")], - [!isPriced ? "negotiated pricing" : hasUsage ? `/${interval} + usage` : `/${interval}`], - ), - ], - ), - h.p( - [h.Class("mt-3 text-2xs leading-relaxed text-muted-foreground")], - [ - hasUsage - ? "A custom plan: your allotments and rates are your own, not the published ones." - : "A custom plan with hard caps: usage past your allotments is rejected, never billed.", - ], - ), - ], - ), - h.div( - [h.Class("flex-1 lg:px-6")], - [ - eyebrow(h, {}, ["Included every cycle"]), - h.p( - [h.Class("mt-2 font-mono text-2xs leading-relaxed text-foreground/85")], - [included.length > 0 ? included : "Allotments are set on your contract."], - ), - ], - ), - h.p([h.Class("w-full shrink-0 text-2xs text-muted-foreground lg:w-44 lg:text-center")], [CURRENT_PLAN_CAPTION]), - ]) + return panel( + h, + { + padded: true, + className: + "gap-6 border-primary/40 bg-primary/[0.03] lg:flex-row lg:items-start lg:justify-between", + }, + [ + h.div( + [h.Class("max-w-sm")], + [ + h.div( + [h.Class("flex items-center gap-2")], + [ + h.span([h.Class("text-sm")], [spend.planName ?? "Your plan"]), + currentBadge(h, "Current plan"), + ], + ), + h.div( + [h.Class("mt-3 flex items-baseline gap-2")], + [ + h.span( + [h.Class("font-mono text-3xl font-semibold tabular-nums")], + [isPriced ? `$${price}` : "Custom"], + ), + h.span( + [h.Class("text-2xs text-muted-foreground")], + [ + !isPriced + ? "negotiated pricing" + : hasUsage + ? `/${interval} + usage` + : `/${interval}`, + ], + ), + ], + ), + h.p( + [h.Class("mt-3 text-2xs leading-relaxed text-muted-foreground")], + [ + hasUsage + ? "A custom plan: your allotments and rates are your own, not the published ones." + : "A custom plan with hard caps: usage past your allotments is rejected, never billed.", + ], + ), + ], + ), + h.div( + [h.Class("flex-1 lg:px-6")], + [ + eyebrow(h, {}, ["Included every cycle"]), + h.p( + [h.Class("mt-2 font-mono text-2xs leading-relaxed text-foreground/85")], + [included.length > 0 ? included : "Allotments are set on your contract."], + ), + ], + ), + h.p( + [h.Class("w-full shrink-0 text-2xs text-muted-foreground lg:w-44 lg:text-center")], + [CURRENT_PLAN_CAPTION], + ), + ], + ) } const offerPanel = ( @@ -199,10 +265,18 @@ const offerPanel = ( ): Html => { const isActive = !options.isCustom && plan.customerEligibility?.status === "active" const isTrialAvailable = plan.customerEligibility?.trialAvailable === true - const retention = getPlanFeatures(plan.id.toLowerCase()).find((feature) => feature.label === "Data retention") + const retention = getPlanFeatures(plan.id.toLowerCase()).find( + (feature) => feature.label === "Data retention", + ) return panel( h, - { padded: true, className: cn("gap-6 lg:flex-row lg:items-start lg:justify-between", isActive && "border-primary/40 bg-primary/[0.03]") }, + { + padded: true, + className: cn( + "gap-6 lg:flex-row lg:items-start lg:justify-between", + isActive && "border-primary/40 bg-primary/[0.03]", + ), + }, [ h.div( [h.Class("max-w-sm")], @@ -217,8 +291,14 @@ const offerPanel = ( h.div( [h.Class("mt-3 flex items-baseline gap-2")], [ - h.span([h.Class("font-mono text-3xl tabular-nums")], ["$", String(plan.price?.amount ?? 0)]), - h.span([h.Class("text-2xs text-muted-foreground")], ["/", plan.price?.interval ?? "month", " + usage"]), + h.span( + [h.Class("font-mono text-3xl font-semibold tabular-nums")], + ["$", String(plan.price?.amount ?? 0)], + ), + h.span( + [h.Class("text-2xs text-muted-foreground")], + ["/", plan.price?.interval ?? "month", " + usage"], + ), ], ), h.p( @@ -233,7 +313,12 @@ const offerPanel = ( eyebrow(h, {}, ["Included every cycle"]), h.p( [h.Class("mt-2 font-mono text-2xs leading-relaxed text-foreground/85")], - [includedRun(plan), ...(retention === undefined ? [] : [` · ${retention.value.toLowerCase()} retention`])], + [ + includedRun(plan), + ...(retention === undefined + ? [] + : [` · ${retention.value.toLowerCase()} retention`]), + ], ), ...(addOns.length > 0 ? [ @@ -243,11 +328,16 @@ const offerPanel = ( h.p( [h.Key(addOn.id), h.Class("text-2xs text-muted-foreground")], [ - addOn.customerEligibility?.status === "active" ? "Add-on active · " : "Add-on available · ", + addOn.customerEligibility?.status === "active" + ? "Add-on active · " + : "Add-on available · ", addOn.name, - ...(addOn.price?.amount === undefined || addOn.price.amount === null + ...(addOn.price?.amount === undefined || + addOn.price.amount === null ? [] - : [` +$${addOn.price.amount}/${addOn.price.interval ?? "month"}`]), + : [ + ` +$${addOn.price.amount}/${addOn.price.interval ?? "month"}`, + ]), ], ), ), @@ -259,7 +349,12 @@ const offerPanel = ( h.div( [h.Class("flex w-full shrink-0 flex-col gap-2 lg:w-44")], isActive - ? [h.p([h.Class("text-2xs text-muted-foreground lg:text-center")], [CURRENT_PLAN_CAPTION])] + ? [ + h.p( + [h.Class("text-2xs text-muted-foreground lg:text-center")], + [CURRENT_PLAN_CAPTION], + ), + ] : [ button( h, @@ -274,7 +369,10 @@ const offerPanel = ( : "Subscribe", ], ), - h.p([h.Class("text-center text-2xs text-muted-foreground")], ["$0 due today · cancel anytime"]), + h.p( + [h.Class("text-center text-2xs text-muted-foreground")], + ["$0 due today · cancel anytime"], + ), ], ), ], @@ -282,13 +380,30 @@ const offerPanel = ( } /** The plan picker behind "Compare plans": one offer, not a tier ladder. */ -const planOffer = (h: H, model: Model, spend: SpendModel | null, customer: BillingCustomer | null): ReadonlyArray => { +const planOffer = ( + h: H, + model: Model, + spend: SpendModel | null, + customer: BillingCustomer | null, +): ReadonlyArray => { const { isTrialing, daysRemaining } = getTrialStatus(customer ?? undefined) const trialText = isTrialing && daysRemaining !== null ? `Trial · ${daysRemaining}d left` : null return AsyncData.matchDataSplitEmpty(model.plans, { - onIdle: () => [h.div([h.Class("space-y-3")], [skeleton(h, "h-32 w-full rounded-md"), skeleton(h, "h-16 w-full rounded-md")])], - onLoading: () => [h.div([h.Class("space-y-3")], [skeleton(h, "h-32 w-full rounded-md"), skeleton(h, "h-16 w-full rounded-md")])], - onFailure: (failure) => [errorState(h, { failure, title: "Unable to load pricing plans", variant: "row" })], + onIdle: () => [ + h.div( + [h.Class("space-y-3")], + [skeleton(h, "h-32 w-full rounded-md"), skeleton(h, "h-16 w-full rounded-md")], + ), + ], + onLoading: () => [ + h.div( + [h.Class("space-y-3")], + [skeleton(h, "h-32 w-full rounded-md"), skeleton(h, "h-16 w-full rounded-md")], + ), + ], + onFailure: (failure) => [ + errorState(h, { failure, title: "Unable to load pricing plans", variant: "row" }), + ], onData: (plans) => { const offers = plans.filter((plan) => plan.addOn !== true && plan.autoEnable !== true) const addOns = plans.filter((plan) => plan.addOn === true) @@ -306,7 +421,12 @@ const planOffer = (h: H, model: Model, spend: SpendModel | null, customer: Billi }) } -const compareDialog = (h: H, model: Model, spend: SpendModel | null, customer: BillingCustomer | null): Html => +const compareDialog = ( + h: H, + model: Model, + spend: SpendModel | null, + customer: BillingCustomer | null, +): Html => h.submodel({ slotId: "billing-compare", model: model.compareDialog, @@ -319,9 +439,16 @@ const compareDialog = (h: H, model: Model, spend: SpendModel | null, customer: B toContent: () => [ DialogView.dialogHeader(h, "dialog", [ DialogView.dialogTitle(h, "dialog", model.compareDialog.id, "Compare plans"), - DialogView.dialogDescription(h, "dialog", model.compareDialog.id, "Plans and add-ons you can switch to."), + DialogView.dialogDescription( + h, + "dialog", + model.compareDialog.id, + "Plans and add-ons you can switch to.", + ), + ]), + DialogView.dialogPanel(h, "dialog", model.compareDialog.id, [ + ...planOffer(h, model, spend, customer), ]), - DialogView.dialogPanel(h, "dialog", model.compareDialog.id, [...planOffer(h, model, spend, customer)]), ], }, }) @@ -330,7 +457,12 @@ const compareDialog = (h: H, model: Model, spend: SpendModel | null, customer: B export const planSection = ( h: H, model: Model, - options: Readonly<{ spend: SpendModel | null; customer: BillingCustomer | null; isLegacy: boolean; canManage: boolean }>, + options: Readonly<{ + spend: SpendModel | null + customer: BillingCustomer | null + isLegacy: boolean + canManage: boolean + }>, ): Html => { const plans = AsyncData.getOrElse(model.plans, () => undefined) const visiblePlans = AsyncData.isFailure(model.plans) ? [] : plans @@ -344,8 +476,16 @@ export const planSection = ( ...(options.canManage && { actions: button( h, - { variant: "link", size: "sm", className: "text-primary", attributes: [h.OnClick(Message.ClickedCompare())] }, - ["Compare plans", icon(h, "ArrowRightIcon", { className: "size-3", attributes: [h.AriaHidden(true)] })], + { + variant: "link", + size: "sm", + className: "text-primary", + attributes: [h.OnClick(Message.ClickedCompare())], + }, + [ + "Compare plans", + icon(h, "ArrowRightIcon", { className: "size-3", attributes: [h.AriaHidden(true)] }), + ], ), }), }, @@ -357,7 +497,9 @@ export const planSection = ( ? [ h.p( [h.Class("px-1 text-xs text-muted-foreground")], - ["You're on a legacy plan that's no longer offered. Compare plans for current pricing and features."], + [ + "You're on a legacy plan that's no longer offered. Compare plans for current pricing and features.", + ], ), ] : []), @@ -368,7 +510,9 @@ export const planSection = ( " ", h.a( [ - h.Class("font-medium whitespace-nowrap text-foreground underline underline-offset-4"), + h.Class( + "font-medium whitespace-nowrap text-foreground underline underline-offset-4", + ), h.Href(ENTERPRISE_CALL_URL), h.Rel("noopener noreferrer"), h.Target("_blank"), diff --git a/apps/web/src/page/settings/data-platform/view.ts b/apps/web/src/page/settings/data-platform/view.ts index 8bffa54539..23c4d52d53 100644 --- a/apps/web/src/page/settings/data-platform/view.ts +++ b/apps/web/src/page/settings/data-platform/view.ts @@ -5,7 +5,7 @@ import type { OrgClickHouseSettingsResponse, } from "@maple/domain/http" import { EMPTY_VALUE, formatNumber, formatStorageBytes } from "@maple/ui/lib/format" -import { TONE_FILL, TONE_TEXT } from "@maple/ui/lib/tone" +import { TONE_TEXT } from "@maple/ui/lib/tone" import { cn } from "@maple/ui/lib/utils" import { columnDriftResolvedByApply, @@ -30,6 +30,7 @@ import { statRail, statRailItem } from "../../kit/stat-rail" import { settingsSection, settingsSections } from "../kit/section" import { type ConnectionField, Message, type Model, type Totals } from "./model" import { draftOf, isRunActive } from "./update" +import { statusDot } from "../../../ui/status-dot" type H = HtmlBuilder type Inputs = Readonly<{ nowMs: number; timeZone: string }> @@ -55,7 +56,7 @@ const connectionStatus = (h: H, settings: OrgClickHouseSettingsResponse, inputs: return h.div( [h.Class("flex items-center gap-2 border-b px-4 py-3 text-xs")], [ - h.span([h.Class(cn("size-1.5 rounded-full", TONE_FILL.neutral))], []), + statusDot(h, { tone: "neutral" }), h.span([], ["Not connected"]), h.span([h.Class("text-muted-foreground")], ["· Queries use Maple's warehouse"]), ], @@ -67,10 +68,7 @@ const connectionStatus = (h: H, settings: OrgClickHouseSettingsResponse, inputs: h.div( [h.Class("flex items-center gap-2")], [ - h.span( - [h.Class(cn("size-1.5 rounded-full", isFailing ? TONE_FILL.crit : TONE_FILL.ok))], - [], - ), + statusDot(h, { tone: isFailing ? "crit" : "ok" }), h.span([], [isFailing ? "Connection error" : "Connected"]), ...(settings.lastSyncAt === null ? [] diff --git a/apps/web/src/page/settings/ingestion/view-rules.ts b/apps/web/src/page/settings/ingestion/view-rules.ts index 17509d3e6c..9b00c38d51 100644 --- a/apps/web/src/page/settings/ingestion/view-rules.ts +++ b/apps/web/src/page/settings/ingestion/view-rules.ts @@ -2,10 +2,9 @@ import type { V2AttributeMapping, V2Recommendation } from "@maple/domain/http/v2 import { formatNumber } from "@maple/ui/lib/format" import { TONE_TEXT } from "@maple/ui/lib/tone" import { cn } from "@maple/ui/lib/utils" -import { buttonVariants } from "@maple/ui/styles/ui/button" import { AsyncData } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" -import { button } from "../../../ui/button" +import { button, linkButton } from "../../../ui/button" import { confirmDialog } from "../../../ui/confirm-dialog" import { badge } from "../../../ui/display" import { empty, emptyDescription, emptyHeader, emptyMedia, emptyMessage, emptyTitle } from "../../../ui/empty" @@ -100,16 +99,7 @@ const recommendationActions = (h: H, model: Model, issue: V2Recommendation): Rea ), ] : [] - : [ - h.a( - [ - h.Href(href), - h.Class(cn(buttonVariants({ variant: "outline", size: "sm" }))), - h.DataAttribute("slot", "button"), - ], - [icon(h, "CodeIcon"), "How to fix"], - ), - ] + : [linkButton(h, { variant: "outline", size: "sm", href }, [icon(h, "CodeIcon"), "How to fix"])] return [ ...primary, button( diff --git a/apps/web/src/page/settings/ingestion/view.ts b/apps/web/src/page/settings/ingestion/view.ts index 0f13b268fc..6cd885ea29 100644 --- a/apps/web/src/page/settings/ingestion/view.ts +++ b/apps/web/src/page/settings/ingestion/view.ts @@ -1,11 +1,10 @@ import { countLabel, formatNumber } from "@maple/ui/lib/format" import { cn } from "@maple/ui/lib/utils" -import { buttonVariants } from "@maple/ui/styles/ui/button" import { docsUrl } from "../../../lib/docs" import { ingestUrl } from "../../../lib/services/common/ingest-url" import { AsyncData, Submodel } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" -import { button } from "../../../ui/button" +import { button, linkButton } from "../../../ui/button" import * as CopyButton from "../../../ui/copy-button" import { confirmDialog } from "../../../ui/confirm-dialog" import { icon } from "../../../ui/icon" @@ -78,20 +77,14 @@ const statusBanner = (h: H, model: Model): Html => { h.div([h.Class("grow")], []), ...(connection.isConnected ? [ - h.a( - [ - h.Href("/traces"), - h.Class( - cn( - buttonVariants({ - variant: "ghost", - size: "sm", - className: "text-muted-foreground hover:text-foreground gap-1.5", - }), - ), - ), - h.DataAttribute("slot", "button"), - ], + linkButton( + h, + { + variant: "ghost", + size: "sm", + className: "text-muted-foreground hover:text-foreground gap-1.5", + href: "/traces", + }, ["Explore traces", icon(h, "ArrowRightIcon")], ), ] diff --git a/apps/web/src/page/settings/keys/view-reference.ts b/apps/web/src/page/settings/keys/view-reference.ts index 745e36ef60..a735eb8cc7 100644 --- a/apps/web/src/page/settings/keys/view-reference.ts +++ b/apps/web/src/page/settings/keys/view-reference.ts @@ -1,6 +1,4 @@ -import { cn } from "@maple/ui/lib/utils" import { collapsiblePanelStyles } from "@maple/ui/styles/ui/collapsible" -import { buttonVariants } from "@maple/ui/styles/ui/button" import { apiPublicUrl } from "../../../lib/services/common/api-base-url" import type { Html, HtmlBuilder } from "foldkit/html" import { copyableField } from "../../../ui/copyable-field" @@ -10,6 +8,7 @@ import { panel } from "../../../ui/panel" import { INLINE_LINK_CLASS, settingsSection } from "../kit/section" import { Message } from "./message" import type { Model } from "./model" +import { linkButton } from "../../../ui/button" // `ApiReference` and `OAuthPointer`. @@ -170,14 +169,14 @@ export const apiReference = (h: H, model: Model): Html => "A REST interface with snake_case JSON, cursor-paginated lists and scoped API keys.", ], framed: false, - actions: h.a( - [ - h.Href(`${apiPublicUrl}/v2/docs`), - h.Target("_blank"), - h.Rel("noopener noreferrer"), - h.Class(cn(buttonVariants({ size: "sm", variant: "outline" }))), - h.DataAttribute("slot", "button"), - ], + actions: linkButton( + h, + { + size: "sm", + variant: "outline", + href: `${apiPublicUrl}/v2/docs`, + attributes: [h.Target("_blank"), h.Rel("noopener noreferrer")], + }, [ icon(h, "CodeIcon", { attributes: [h.DataAttribute("icon", "inline-start")] }), "Open API reference", diff --git a/apps/web/src/page/settings/kit/save-bar.ts b/apps/web/src/page/settings/kit/save-bar.ts index e12f280d05..b33987df16 100644 --- a/apps/web/src/page/settings/kit/save-bar.ts +++ b/apps/web/src/page/settings/kit/save-bar.ts @@ -1,6 +1,7 @@ import { cn } from "@maple/ui/lib/utils" import type { Html, HtmlBuilder } from "foldkit/html" import { button } from "../../../ui/button" +import { statusDot } from "../../../ui/status-dot" // `SettingsSaveBar`: pinned while a tab has unsaved edits. TODO(settings): its `UnsavedChangesGuard` // (confirm before navigating away, and the browser's beforeunload prompt) needs a root-level navigation @@ -35,10 +36,7 @@ export const saveBar = (h: HtmlBuilder, options: SaveBarOptions): Reado h.div( [h.Class("flex min-w-0 items-center gap-2 text-sm")], [ - h.span( - [h.AriaHidden(true), h.Class("size-1.5 shrink-0 rounded-full bg-primary")], - [], - ), + statusDot(h, { tone: "live" }), h.span([h.Class("shrink-0 text-foreground")], ["Unsaved changes"]), ...(options.context === undefined ? [] diff --git a/apps/web/src/page/settings/kit/section.ts b/apps/web/src/page/settings/kit/section.ts index 6248f3078d..469b717932 100644 --- a/apps/web/src/page/settings/kit/section.ts +++ b/apps/web/src/page/settings/kit/section.ts @@ -2,6 +2,7 @@ import { cn } from "@maple/ui/lib/utils" import type { Html, HtmlBuilder } from "foldkit/html" import { panel } from "../../../ui/panel" import { sectionHeading } from "../../kit/section-heading" +import { emptyMessage } from "../../../ui/empty" // `SettingsSection`, `SettingsSections`, `AdminOnlyNote`. @@ -76,5 +77,4 @@ export const adminOnlyNote = (h: HtmlBuilder, children: Children, classNam export const INLINE_LINK_CLASS = "text-primary underline-offset-4 hover:underline" /** The centered one-line note a list section shows for empty or failed loads. */ -export const sectionNote = (h: HtmlBuilder, text: string): Html => - h.p([h.Class("px-4 py-6 text-center font-prose text-sm text-muted-foreground")], [text]) +export const sectionNote = (h: HtmlBuilder, text: string): Html => emptyMessage(h, {}, [text]) diff --git a/apps/web/src/page/settings/mcp/view-tools.ts b/apps/web/src/page/settings/mcp/view-tools.ts index d82432a21c..5a016f15cf 100644 --- a/apps/web/src/page/settings/mcp/view-tools.ts +++ b/apps/web/src/page/settings/mcp/view-tools.ts @@ -12,6 +12,7 @@ import { skeletonList } from "../../../ui/skeleton" import { sectionNote, settingsSection } from "../kit/section" import { Message } from "./message" import type { Model } from "./model" +import { emptyMessage } from "../../../ui/empty" // `McpToolsList`. @@ -30,7 +31,10 @@ const toolRow = (h: H, tool: McpToolCatalogEntry): Html => ], [ h.code([h.Class("truncate font-mono text-xs font-medium text-foreground")], [tool.name]), - h.p([h.Class("truncate text-xs text-muted-foreground max-sm:hidden"), h.Title(tool.description)], [tool.description]), + h.p( + [h.Class("truncate text-xs text-muted-foreground max-sm:hidden"), h.Title(tool.description)], + [tool.description], + ), tool.readOnly ? badge(h, { variant: "muted", size: "sm" }, ["Read only"]) : badge(h, { variant: "warn", size: "sm" }, ["Writes"]), @@ -42,7 +46,11 @@ const toolGroup = (h: H, group: McpToolGroup, isOpen: boolean, isShowingAll: boo const rows = hidden === 0 ? group.tools : group.tools.slice(0, PREVIEW_ROWS) const panelId = `mcp-tools-${group.id}-panel` return h.div( - [h.Key(group.id), h.DataAttribute(isOpen ? "open" : "closed", ""), h.DataAttribute("slot", "collapsible")], + [ + h.Key(group.id), + h.DataAttribute(isOpen ? "open" : "closed", ""), + h.DataAttribute("slot", "collapsible"), + ], [ h.button( [ @@ -59,10 +67,16 @@ const toolGroup = (h: H, group: McpToolGroup, isOpen: boolean, isShowingAll: boo [ icon(h, "ChevronRightIcon", { size: 14, - className: cn("shrink-0 text-muted-foreground transition-transform", isOpen && "rotate-90"), + className: cn( + "shrink-0 text-muted-foreground transition-transform", + isOpen && "rotate-90", + ), }), h.span([h.Class("font-medium")], [group.label]), - h.span([h.Class("text-xs text-muted-foreground tabular-nums")], [String(group.tools.length)]), + h.span( + [h.Class("text-xs text-muted-foreground tabular-nums")], + [String(group.tools.length)], + ), ], ), ...(isOpen @@ -83,7 +97,10 @@ const toolGroup = (h: H, group: McpToolGroup, isOpen: boolean, isShowingAll: boo h.div( [h.Class("px-4 pb-3 pl-10")], [ - h.ul([], rows.map((tool) => toolRow(h, tool))), + h.ul( + [], + rows.map((tool) => toolRow(h, tool)), + ), ...(hidden > 0 ? [ button( @@ -92,7 +109,13 @@ const toolGroup = (h: H, group: McpToolGroup, isOpen: boolean, isShowingAll: boo variant: "ghost", size: "xs", className: "-ml-2 mt-1 text-muted-foreground", - attributes: [h.OnClick(Message.ClickedShowAllTools({ groupId: group.id }))], + attributes: [ + h.OnClick( + Message.ClickedShowAllTools({ + groupId: group.id, + }), + ), + ], }, ["Show ", String(hidden), " more"], ), @@ -126,7 +149,7 @@ const catalog = (h: H, model: Model, tools: ReadonlyArray): ], ), groups.length === 0 - ? h.p([h.Class("px-4 py-6 text-center text-sm text-muted-foreground")], ["No tools match “", model.toolSearch, "”."]) + ? emptyMessage(h, {}, ["No tools match “", model.toolSearch, "”."]) : h.div( [h.Class("divide-y")], groups.map((group, index) => @@ -142,7 +165,10 @@ const catalog = (h: H, model: Model, tools: ReadonlyArray): } export const toolsSection = (h: H, model: Model): Html => { - const count = AsyncData.getOrElse(AsyncData.map(model.tools, (tools) => tools.length), () => null) + const count = AsyncData.getOrElse( + AsyncData.map(model.tools, (tools) => tools.length), + () => null, + ) return settingsSection( h, { diff --git a/apps/web/src/page/settings/setup-health/view.ts b/apps/web/src/page/settings/setup-health/view.ts index 31fab762eb..3e42d5c018 100644 --- a/apps/web/src/page/settings/setup-health/view.ts +++ b/apps/web/src/page/settings/setup-health/view.ts @@ -2,7 +2,6 @@ import type { V2SetupAudit, V2SetupAuditCheck } from "@maple/domain/http/v2" import { countLabel } from "@maple/ui/lib/format" import { TONE_FILL, TONE_TEXT } from "@maple/ui/lib/tone" import { cn } from "@maple/ui/lib/utils" -import { buttonVariants } from "@maple/ui/styles/ui/button" import { AsyncData, Submodel } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { alert, alertDescription, alertTitle } from "../../../ui/alert" @@ -26,6 +25,7 @@ import { setupHealthAction, } from "./derive" import { Message, type Model } from "./model" +import { linkButton } from "../../../ui/button" type H = HtmlBuilder type Inputs = Readonly<{ nowMs: number; timeZone: string }> @@ -58,21 +58,22 @@ const CATEGORY_LABEL = { data_platform: "Data platform", } as const satisfies { readonly [Category in V2SetupAuditCheck["category"]]: string } -const actionClassName = cn(buttonVariants({ variant: "outline", size: "sm" }), "shrink-0 gap-1.5") - const actionButton = (h: H, action: SetupHealthAction): Html => action.kind === "docs" - ? h.a( - [ - h.Href(action.href), - h.Target("_blank"), - h.Rel("noopener noreferrer"), - h.Class(actionClassName), - ], + ? linkButton( + h, + { + variant: "outline", + size: "sm", + className: "shrink-0 gap-1.5", + href: action.href, + attributes: [h.Target("_blank"), h.Rel("noopener noreferrer")], + }, [action.label, icon(h, "ExternalLinkIcon", { size: 14 })], ) - : h.a( - [h.Href(action.href), h.Class(actionClassName)], + : linkButton( + h, + { variant: "outline", size: "sm", className: "shrink-0 gap-1.5", href: action.href }, [action.label, icon(h, "ArrowRightIcon", { size: 14 })], ) @@ -263,13 +264,9 @@ const noTelemetry = (h: H): Html => ]), ]), emptyActions(h, [ - h.a( - [ - h.Href("/settings?tab=ingestion"), - h.Class(buttonVariants({ variant: "outline", size: "sm" })), - ], - ["Go to Ingestion"], - ), + linkButton(h, { variant: "outline", size: "sm", href: "/settings?tab=ingestion" }, [ + "Go to Ingestion", + ]), docsLink(h, "quickstart"), ]), ], diff --git a/apps/web/src/page/sources/detail/view-overview.ts b/apps/web/src/page/sources/detail/view-overview.ts index 47b31b59d7..a3d493a78e 100644 --- a/apps/web/src/page/sources/detail/view-overview.ts +++ b/apps/web/src/page/sources/detail/view-overview.ts @@ -7,6 +7,7 @@ import * as BarChart from "../../../charts/bar-chart" import type * as Chart from "../../../charts/chart" import { alert, alertDescription, alertTitle } from "../../../ui/alert" import { panel } from "../../../ui/panel" +import { emptyMessage } from "../../../ui/empty" import { skeleton } from "../../../ui/skeleton" import { integrationPlatformOf, @@ -126,18 +127,10 @@ const volumePanel = (h: H, model: Model, timeZone: string, nowMs: number): Html const isFailed = AsyncData.isFailure(model.usage.data) const body = Option.match(usage, { onNone: () => - isFailed - ? h.p( - [h.Class("text-muted-foreground py-6 text-center text-xs")], - ["Volume is unavailable right now."], - ) - : skeleton(h, "h-48 w-full"), + isFailed ? emptyMessage(h, {}, ["Volume is unavailable right now."]) : skeleton(h, "h-48 w-full"), onSome: (value) => value.buckets.length === 0 - ? h.p( - [h.Class("text-muted-foreground py-8 text-center text-xs")], - ["Nothing stored from this source in the last 24 hours."], - ) + ? emptyMessage(h, {}, ["Nothing stored from this source in the last 24 hours."]) : usageChart(h, model, usageRows(value), timeZone, nowMs), }) return panel(h, { padded: true, className: "gap-3" }, [ diff --git a/apps/web/src/page/sources/list/view.ts b/apps/web/src/page/sources/list/view.ts index c5ea0424c4..157492052a 100644 --- a/apps/web/src/page/sources/list/view.ts +++ b/apps/web/src/page/sources/list/view.ts @@ -4,13 +4,14 @@ import { Option } from "effect" import { AsyncData, Submodel } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { linkButton } from "../../../ui/button" -import { empty, emptyDescription, emptyHeader, emptyMedia, emptyTitle } from "../../../ui/empty" +import { empty, emptyDescription, emptyHeader, emptyMedia, emptyMessage, emptyTitle } from "../../../ui/empty" import { errorState } from "../../../ui/error-state" import { icon } from "../../../ui/icon" import { skeleton } from "../../../ui/skeleton" import { table, tableBody, tableCell, tableHead, tableHeader, tableRow } from "../../../ui/table" import type { PageViewInputs } from "../../contract" import { dashboardPage } from "../../kit/dashboard-page" +import { listToolbar, toolbarStat } from "../../kit/list-toolbar" import { platformMeta, platformTile } from "../kit/platforms" import { PULL_SOURCE_PLATFORMS, type PullSourcePlatform, type SourceId } from "@maple/domain/primitives" import { AppRoute, hrefOf } from "../../../route" @@ -18,7 +19,7 @@ import * as ToggleGroup from "../../../ui/toggle-group" import { integrationPlatformOf, orderSources, sourceHref, viaLabel } from "../kit/shared" import { needsAttention, sourceStatusBadge } from "../kit/status" import { signalCounts, type VolumeTotals, volumeSize } from "../kit/volume" -import { statusDot } from "../../../ui/status-dot" +import { countChipItem } from "../../integrations/kit/count-chip" import { Message } from "./message" import type { Model, SourceKind } from "./model" @@ -37,7 +38,8 @@ const connectButton = (h: H, variant?: "outline"): Html => "Connect source", ]) -const hasNoAddedSources = (rows: ReadonlyArray): boolean => rows.every((source) => source.is_default) +const hasNoAddedSources = (rows: ReadonlyArray): boolean => + rows.every((source) => source.is_default) /** Mirrors `body`: the empty state renders once the list loaded with no added sources. */ const showsEmptyState = (model: Model): boolean => @@ -166,22 +168,8 @@ const kindOf = (source: V2Source): Exclude => const KIND_LABELS = { all: "All", push: "OpenTelemetry", integration: "Integrations" } as const -// styles: the Cloudflare zone board's count chips (view-cloudflare-board.ts `countChip`). -const CHIP_CLASS = - "group h-6 gap-1.5 rounded-full border-border/60 px-2.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground data-pressed:border-transparent data-pressed:bg-muted data-pressed:text-foreground sm:h-6 sm:text-xs" - -const kindChip = (h: H, kind: SourceKind, count: number): ToggleGroup.ToggleGroupItem => ({ - value: kind, - label: KIND_LABELS[kind], - className: CHIP_CLASS, - children: [ - KIND_LABELS[kind], - h.span( - [h.Class("tabular-nums text-muted-foreground/70 group-data-pressed:text-muted-foreground")], - [String(count)], - ), - ], -}) +const kindChip = (h: H, kind: SourceKind, count: number): ToggleGroup.ToggleGroupItem => + countChipItem(h, { value: kind, label: KIND_LABELS[kind], count }) const kindFilter = (h: H, model: Model, sources: ReadonlyArray): Html => ToggleGroup.view(h, { @@ -207,23 +195,7 @@ const attentionFilter = (h: H, model: Model, sources: ReadonlyArray): isConnected: false, size: "xs", items: [ - { - value: "attention", - label: "Needs attention", - className: CHIP_CLASS, - children: [ - statusDot(h, { tone: "warn" }), - "Needs attention", - h.span( - [ - h.Class( - "tabular-nums text-muted-foreground/70 group-data-pressed:text-muted-foreground", - ), - ], - [String(count)], - ), - ], - }, + countChipItem(h, { value: "attention", label: "Needs attention", count, tone: "warn" }), ], }) } @@ -269,10 +241,11 @@ const orgTotal = ( bytes: sum.bytes + entry.bytes, } }, NO_VOLUME) - return h.span( - [h.Class("text-muted-foreground ml-auto text-xs tabular-nums"), h.Title(signalCounts(totals))], - [h.span([h.Class("text-foreground font-medium")], [volumeSize(totals)]), " stored in the last 24h"], - ) + return toolbarStat(h, { + value: volumeSize(totals), + label: "stored in the last 24h", + attributes: [h.Title(signalCounts(totals))], + }) } const sourcesTable = (h: H, model: Model, rows: ReadonlyArray, nowMs: number): Html => { @@ -289,14 +262,14 @@ const sourcesTable = (h: H, model: Model, rows: ReadonlyArray, nowMs: return h.div( [h.Class("flex flex-col gap-4")], [ - h.div( - [h.Class("flex flex-wrap items-center gap-3")], - [ - kindFilter(h, model, sources), - ...(attention === null ? [] : [attention]), - ...(total === null ? [] : [total]), - ], - ), + listToolbar(h, { + className: "pb-0", + leading: h.div( + [h.Class("flex flex-wrap items-center gap-3")], + [kindFilter(h, model, sources), ...(attention === null ? [] : [attention])], + ), + trailing: total === null ? [] : [total], + }), table(h, { variant: "card", className: "table-fixed" }, [ tableHeader(h, [ tableRow(h, [ @@ -316,12 +289,7 @@ const sourcesTable = (h: H, model: Model, rows: ReadonlyArray, nowMs: ...(added.length === 0 ? [emptyState(h)] : shown.length === 0 - ? [ - h.p( - [h.Class("text-muted-foreground px-1 text-sm")], - ["No sources match these filters."], - ), - ] + ? [emptyMessage(h, {}, ["No sources match these filters."])] : []), ...(strip === null ? [] : [strip]), ], diff --git a/apps/web/src/page/traces/detail/span-panel/command.ts b/apps/web/src/page/traces/detail/span-panel/command.ts index 1918e28a46..373c71ec71 100644 --- a/apps/web/src/page/traces/detail/span-panel/command.ts +++ b/apps/web/src/page/traces/detail/span-panel/command.ts @@ -4,6 +4,7 @@ import { getSpanDetail } from "@maple/web-data/warehouse/traces" import { Duration, Effect, Schema } from "effect" import { Command } from "foldkit" import { toLoadFailure } from "../../../../data/load-failure" +import { writeClipboard } from "../../../kit/clipboard" import { Message } from "./message" /** `SpanLogs`' page size; a full page reads "100+". */ @@ -50,9 +51,10 @@ export const WriteSpanClipboard = Command.define("WriteSpanClipboard", { args: { id: Schema.String, text: Schema.String, generation: Schema.Number }, messages: [Message.SucceededCopy, Message.FailedCopy], execute: ({ id, text, generation }) => - Effect.tryPromise(() => navigator.clipboard.writeText(text)).pipe( - Effect.as(Message.SucceededCopy({ id, generation })), - Effect.orElseSucceed(() => Message.FailedCopy({ id, generation })), + writeClipboard(text).pipe( + Effect.map((isOk) => + isOk ? Message.SucceededCopy({ id, generation }) : Message.FailedCopy({ id, generation }), + ), ), }) diff --git a/apps/web/src/page/traces/detail/span-panel/view-tabs.ts b/apps/web/src/page/traces/detail/span-panel/view-tabs.ts index e58271f22e..9182f4f040 100644 --- a/apps/web/src/page/traces/detail/span-panel/view-tabs.ts +++ b/apps/web/src/page/traces/detail/span-panel/view-tabs.ts @@ -1,4 +1,3 @@ -import { getSeverityColor } from "@maple/ui/lib/severity" import { cn } from "@maple/ui/lib/utils" import { skeletonListGapStyles, skeletonListStyles } from "@maple/ui/styles/ui/skeleton" import { @@ -18,6 +17,7 @@ import { badge } from "../../../../ui/display" import { emptyMessage } from "../../../../ui/empty" import { errorState } from "../../../../ui/error-state" import { icon, type IconName } from "../../../../ui/icon" +import { severityBadge } from "../../../../ui/logs/severity-badge" import { skeleton } from "../../../../ui/skeleton" import * as Tabs from "../../../../ui/tabs" import { docsLink } from "../../../kit/signal-empty-state" @@ -131,23 +131,6 @@ export const tabList = (h: H, model: Model, values: ReadonlyArray): Ht ], ) -/** `SeverityBadge` (packages/ui/src/components/logs/severity-badge.tsx), from the text only. */ -const severityBadge = (h: H, severityText: string): Html => { - const label = severityText.trim() === "" ? "UNSET" : severityText - const color = getSeverityColor(label) - return badge( - h, - { - variant: "secondary", - className: cn("font-mono text-3xs uppercase", "h-4 px-1"), - attributes: [ - h.Style({ color, "background-color": `color-mix(in oklch, ${color} 10%, transparent)` }), - ], - }, - [label], - ) -} - const logEntry = (h: H, log: LogEntry, timeZone: string): Html => h.button( [h.Class("block w-full p-2 text-left hover:bg-muted/30 cursor-pointer"), h.Type("button")], @@ -156,7 +139,7 @@ const logEntry = (h: H, log: LogEntry, timeZone: string): Html => [h.Class("flex items-center gap-2 text-3xs text-muted-foreground mb-1")], [ h.span([], [formatTimestamp(log.timestamp, timeZone, false)]), - severityBadge(h, log.severityText), + severityBadge(h, { severity: log.severityText, className: "h-4 px-1" }), ], ), h.p([h.Class("font-mono text-xs whitespace-pre-wrap break-all line-clamp-3")], [log.body]), diff --git a/apps/web/src/page/traces/detail/view-states.ts b/apps/web/src/page/traces/detail/view-states.ts index f6f99f8e26..57d9654b90 100644 --- a/apps/web/src/page/traces/detail/view-states.ts +++ b/apps/web/src/page/traces/detail/view-states.ts @@ -1,11 +1,10 @@ import { cn } from "@maple/ui/lib/utils" import { skeletonListGapStyles, skeletonListStyles } from "@maple/ui/styles/ui/skeleton" import type { Html, HtmlBuilder } from "foldkit/html" -import { linkButton } from "../../../ui/button" -import { empty, emptyDescription, emptyHeader, emptyMedia, emptyTitle } from "../../../ui/empty" -import { icon } from "../../../ui/icon" + import { traceIdBadge } from "../../../ui/trace-badges" import { skeleton } from "../../../ui/skeleton" +import { resourceNotFound } from "../../kit/resource-not-found" import { docsLink } from "../../kit/signal-empty-state" import type { Message } from "./message" @@ -56,26 +55,19 @@ export const traceNotFound = ( backHref: string, copy: Readonly<{ title: string; description: string; hasRetentionLink?: boolean }>, ): Html => - empty( - h, - [ - emptyHeader(h, [ - emptyMedia(h, { variant: "icon" }, [icon(h, "CircleWarningIcon", { size: 18 })]), - emptyTitle(h, [copy.title]), - emptyDescription(h, [ - h.div( - [h.Class("flex flex-col items-center gap-3")], - [ - traceIdBadge(h, traceId), - h.p([h.Class("max-w-md")], [copy.description]), - copy.hasRetentionLink === true - ? docsLink(h, "retention", "How long traces are kept") - : null, - ], - ), - ]), - ]), - linkButton(h, { variant: "outline", size: "sm", href: backHref }, ["Back to Traces"]), + resourceNotFound(h, { + title: copy.title, + description: [ + h.div( + [h.Class("flex flex-col items-center gap-3")], + [ + traceIdBadge(h, traceId), + h.p([h.Class("max-w-md")], [copy.description]), + copy.hasRetentionLink === true + ? docsLink(h, "retention", "How long traces are kept") + : null, + ], + ), ], - "md:py-12", - ) + back: { href: backHref, label: "Back to Traces" }, + }) diff --git a/apps/web/src/service-map/map/loading.ts b/apps/web/src/service-map/map/loading.ts index 9f78197b75..d034594234 100644 --- a/apps/web/src/service-map/map/loading.ts +++ b/apps/web/src/service-map/map/loading.ts @@ -1,5 +1,6 @@ import "@maple/ui/components/service-map/service-map.css" import type { Html, HtmlBuilder } from "foldkit/html" +import { eyebrow } from "../../ui/eyebrow" // `ServiceMapLoading` (packages/ui `service-map/service-map-loading.tsx`): a fixed ghost topology, // generated once since it carries no data. @@ -981,24 +982,16 @@ export const serviceMapLoading = (h: HtmlBuilder): Html => ), ], ), - h.div( - [ - h.Class( - "font-medium uppercase font-mono text-3xs tracking-widest text-muted-foreground/70 flex items-center gap-2", - ), - h.Attribute("data-slot", "eyebrow"), - ], - [ - h.span([], ["Mapping service connections"]), - h.span( - [h.Attribute("aria-hidden", "true"), h.Class("inline-flex")], - [ - h.span([h.Class("pulse-dot")], ["."]), - h.span([h.Class("pulse-dot")], ["."]), - h.span([h.Class("pulse-dot")], ["."]), - ], - ), - ], - ), + eyebrow(h, { variant: "mono", as: "div", className: "flex items-center gap-2" }, [ + h.span([], ["Mapping service connections"]), + h.span( + [h.Attribute("aria-hidden", "true"), h.Class("inline-flex")], + [ + h.span([h.Class("pulse-dot")], ["."]), + h.span([h.Class("pulse-dot")], ["."]), + h.span([h.Class("pulse-dot")], ["."]), + ], + ), + ]), ], ) diff --git a/apps/web/src/service-map/trace-flow/view.ts b/apps/web/src/service-map/trace-flow/view.ts index 63be06be25..d225acaf89 100644 --- a/apps/web/src/service-map/trace-flow/view.ts +++ b/apps/web/src/service-map/trace-flow/view.ts @@ -132,8 +132,7 @@ const header = (h: HtmlBuilder, onFit: M): Html => h, { variant: "ghost", - size: "sm", - className: "h-6 text-xs", + size: "xs", attributes: [h.OnClick(onFit)], }, [icon(h, "EyeIcon"), "Fit view"], From c52615a04deb21469eb81e95f7cb89dc3d7896f0 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 30 Oct 2026 19:23:51 +0100 Subject: [PATCH 2/2] fix(web): review fixes for the primitives sweep One formatCores rule without trailing zeros, PlanetScale lag back on formatDuration, the uptime down figure names the down monitors, AWS idle is a hollow ring again, code-review settings keep the settings width, endpoint peek stats go four-wide from sm. --- apps/web/src/components/infra/format.test.ts | 1 + apps/web/src/components/infra/format.ts | 11 +-- .../infra/overview/summaries.test.ts | 2 +- .../components/infra/planetscale/metrics.ts | 4 +- apps/web/src/page/alerts/hub/view-uptime.ts | 5 + .../web/src/page/code-review/settings/view.ts | 94 ++++++++++--------- .../infra-integrations/aws/list/view-table.ts | 8 +- .../railway/service/service.scene.test.ts | 2 +- .../services/detail/endpoint-peek/view.ts | 2 +- 9 files changed, 72 insertions(+), 57 deletions(-) diff --git a/apps/web/src/components/infra/format.test.ts b/apps/web/src/components/infra/format.test.ts index 092d294ab3..9080750c3f 100644 --- a/apps/web/src/components/infra/format.test.ts +++ b/apps/web/src/components/infra/format.test.ts @@ -61,6 +61,7 @@ describe("node capacity", () => { it("formats cores against allocatable", () => { 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", () => { diff --git a/apps/web/src/components/infra/format.ts b/apps/web/src/components/infra/format.ts index b142978542..11e5959021 100644 --- a/apps/web/src/components/infra/format.ts +++ b/apps/web/src/components/infra/format.ts @@ -50,14 +50,9 @@ export const formatUtilizationPercent = (fraction: number): string => { return rounded >= 10 ? `${Math.round(pct)}%` : `${rounded}%` } -/** CPU in cores, comparable across a fleet's magnitudes: 0.004, 0.42, 3.92, 12. */ -export const formatCores = (cores: number): string => { - if (!Number.isFinite(cores)) return EMPTY_VALUE - if (cores === 0) return "0" - if (cores >= 10) return cores.toFixed(0) - if (cores >= 1) return cores.toFixed(2) - return cores.toFixed(3) -} +/** 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 diff --git a/apps/web/src/components/infra/overview/summaries.test.ts b/apps/web/src/components/infra/overview/summaries.test.ts index e4a58727d1..e78ae647bd 100644 --- a/apps/web/src/components/infra/overview/summaries.test.ts +++ b/apps/web/src/components/infra/overview/summaries.test.ts @@ -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"], ]) }) }) diff --git a/apps/web/src/components/infra/planetscale/metrics.ts b/apps/web/src/components/infra/planetscale/metrics.ts index cf66ed5c1a..c79688c446 100644 --- a/apps/web/src/components/infra/planetscale/metrics.ts +++ b/apps/web/src/components/infra/planetscale/metrics.ts @@ -5,6 +5,7 @@ // 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 { formatDuration } from "@maple/ui/lib/format" import { type Tone, VALUE_TONE } from "../severity-tokens" import { utilizationLevel } from "@maple/ui/lib/utilization" @@ -36,8 +37,7 @@ export const lagClass = (seconds: number): string | undefined => { } /** 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. */ export const formatStoragePercent = (percent: number) => diff --git a/apps/web/src/page/alerts/hub/view-uptime.ts b/apps/web/src/page/alerts/hub/view-uptime.ts index 63f105bb4b..58c70b07d1 100644 --- a/apps/web/src/page/alerts/hub/view-uptime.ts +++ b/apps/web/src/page/alerts/hub/view-uptime.ts @@ -96,12 +96,17 @@ const uptimeEmptyState = (h: H, serviceName: string | undefined, isAdmin: boolea /** The window's figures as the toolbar readout: the total, what is down now and the recent uptime. */ const readout = (h: H, monitors: ReadonlyArray): ReadonlyArray => { const summary = summarize(monitors) + const downNames = monitors + .filter((monitor) => displayStatus(monitor) === "down") + .map((monitor) => monitor.name) return [ toolbarStat(h, { value: String(summary.total), label: pluralize(summary.total, "monitor") }), toolbarStat(h, { value: String(summary.down), label: "down", dot: summary.down > 0 ? "crit" : undefined, + // The rows sort down first; the tooltip names them without a scroll. + attributes: downNames.length === 0 ? [] : [h.Title(downNames.join(", "))], }), toolbarStat(h, { value: formatShare(summary.share), diff --git a/apps/web/src/page/code-review/settings/view.ts b/apps/web/src/page/code-review/settings/view.ts index e546f731ee..37691d8df2 100644 --- a/apps/web/src/page/code-review/settings/view.ts +++ b/apps/web/src/page/code-review/settings/view.ts @@ -210,49 +210,57 @@ const content = (h: H, model: Model): Html => onRetry: Message.ClickedRetrySettings(), }), onData: () => - settingsSections(h, [ - settingsSection( - h, - { - id: "code-review-settings-reviewer", - title: "Reviewer", - description: ["The model every review and reply in this organization runs on."], - framed: false, - }, - [modelSetting(h, model)], - ), - settingsSection( - h, - { - id: "code-review-settings-defaults", - title: "Default review rules", - description: [ - "Every repository starts from these. A repository can override any of them.", - ], - framed: true, - }, - Option.match(Option.fromNullishOr(model.defaultsForm), { - onNone: () => [], - onSome: (form) => [ - formView(h, form, model.isAdmin, (message) => - Message.GotDefaultsFormMessage({ message }), - ), - ], - }), - ), - settingsSection( - h, - { - id: "code-review-settings-repositories", - title: "Repositories", - description: [ - "Pick which repositories get reviews, and tune any of them on its own.", - ], - framed: false, - }, - [repositoriesSection(h, model)], - ), - ]), + // The settings column width, like every settings page. + h.div( + [h.Class("max-w-4xl")], + [ + settingsSections(h, [ + settingsSection( + h, + { + id: "code-review-settings-reviewer", + title: "Reviewer", + description: [ + "The model every review and reply in this organization runs on.", + ], + framed: false, + }, + [modelSetting(h, model)], + ), + settingsSection( + h, + { + id: "code-review-settings-defaults", + title: "Default review rules", + description: [ + "Every repository starts from these. A repository can override any of them.", + ], + framed: true, + }, + Option.match(Option.fromNullishOr(model.defaultsForm), { + onNone: () => [], + onSome: (form) => [ + formView(h, form, model.isAdmin, (message) => + Message.GotDefaultsFormMessage({ message }), + ), + ], + }), + ), + settingsSection( + h, + { + id: "code-review-settings-repositories", + title: "Repositories", + description: [ + "Pick which repositories get reviews, and tune any of them on its own.", + ], + framed: false, + }, + [repositoriesSection(h, model)], + ), + ]), + ], + ), }) export const view = Submodel.defineView((model, _inputs, h) => diff --git a/apps/web/src/page/infra-integrations/aws/list/view-table.ts b/apps/web/src/page/infra-integrations/aws/list/view-table.ts index 818f754de1..df7077a753 100644 --- a/apps/web/src/page/infra-integrations/aws/list/view-table.ts +++ b/apps/web/src/page/infra-integrations/aws/list/view-table.ts @@ -395,7 +395,13 @@ const row = (h: H, view: ResourceView, context: TableContext, showTraced: boolea h.div( [h.Class(cn("flex items-start gap-2.5", NAME_COLUMN.width))], [ - statusDot(h, { tone: HEALTH_TONE[view.health], className: "mt-1.5" }), + // Idle is a hollow ring, as on GCP: nothing wrong, nothing running. + view.health === "idle" + ? statusDot(h, { + tone: "custom", + className: "mt-1.5 border border-muted-foreground/70", + }) + : statusDot(h, { tone: HEALTH_TONE[view.health], className: "mt-1.5" }), h.div( [h.Class("flex min-w-0 flex-col gap-0.5")], [ diff --git a/apps/web/src/page/infra-integrations/railway/service/service.scene.test.ts b/apps/web/src/page/infra-integrations/railway/service/service.scene.test.ts index dd1e2e034d..7f7af8727f 100644 --- a/apps/web/src/page/infra-integrations/railway/service/service.scene.test.ts +++ b/apps/web/src/page/infra-integrations/railway/service/service.scene.test.ts @@ -94,7 +94,7 @@ describe("railway service", () => { { update: pageUpdate, view }, given(loaded), themeResolved(), - expectView(text("1.50 vCPU")).toExist(), + expectView(text("1.5 vCPU")).toExist(), ) }) diff --git a/apps/web/src/page/services/detail/endpoint-peek/view.ts b/apps/web/src/page/services/detail/endpoint-peek/view.ts index 46287738f7..e37e1274a0 100644 --- a/apps/web/src/page/services/detail/endpoint-peek/view.ts +++ b/apps/web/src/page/services/detail/endpoint-peek/view.ts @@ -99,7 +99,7 @@ const stats = (h: H, peek: Peek, codes: ReturnType, const { endpoint } = peek const mix = knownStatusMix(endpoint) const topClient = codes.find((code) => code.name.startsWith("4")) - return statRail(h, { columns: 4, className: "overflow-hidden" }, [ + return statRail(h, { columns: 4, className: "overflow-hidden sm:grid-cols-4" }, [ stat( h, "Req/s",