From dc118538825f1bd6ac110fdbd335aee274458f61 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 31 Oct 2026 10:53:16 +0100 Subject: [PATCH 1/4] style(web): align the service page tabs The Overview, API, Operations and Dependencies tabs drifted apart: three section-title styles, gap-2/3/4 spacing, pl-3 vs pl-4 table edges, a different sparkline colour per tab (Operations' --chart-1 turns orange in dark mode), and Operations and Dependencies rendered nothing below md. - One header: every panel and table uses panelHeader; tables open with a shared tableTitle (name + count), the API toolbar included. - gap-3 between sections, h-11 rows with 16px edges on all three tables. - Calls and latency bars are neutral, only error bars carry colour. - Calls/requests sparklines use --chart-throughput on every tab and the services list (the list's var(--color-primary, ...) never resolved on canvas). - Operations and Dependencies tables show on mobile with minor columns hidden; Dependencies gains loading and error states; skeletons carry the header rows so nothing shifts on load. - Panel links use textLink; one 30-day range note. - /lab/service-runtimes takes ?tab= and follows tab switches. --- apps/web/src/lab/entries/service-runtimes.ts | 36 ++- apps/web/src/lab/registry.ts | 2 +- .../page/services/detail/view-api-charts.ts | 2 +- .../src/page/services/detail/view-api-tab.ts | 299 +++++++++--------- apps/web/src/page/services/detail/view-api.ts | 17 +- .../page/services/detail/view-dependencies.ts | 112 ++++--- .../src/page/services/detail/view-deploys.ts | 16 +- .../page/services/detail/view-operations.ts | 66 ++-- .../src/page/services/detail/view-panels.ts | 5 +- .../page/services/detail/view-table-cells.ts | 45 ++- .../src/page/services/detail/view-uptime.ts | 33 +- apps/web/src/page/services/list/view-table.ts | 3 +- 12 files changed, 354 insertions(+), 282 deletions(-) diff --git a/apps/web/src/lab/entries/service-runtimes.ts b/apps/web/src/lab/entries/service-runtimes.ts index 1cac0f6e11..4f2e1073d2 100644 --- a/apps/web/src/lab/entries/service-runtimes.ts +++ b/apps/web/src/lab/entries/service-runtimes.ts @@ -1,14 +1,16 @@ import { OrgId } from "@maple/domain" -import { Effect, Result, Schema } from "effect" +import { Effect, Option, Result, Schema } from "effect" import { AsyncData, Command, type Update } from "foldkit" import type { HtmlBuilder } from "foldkit/html" import { defineMessageUnion } from "foldkit/message" +import { fromString as urlFromString } from "foldkit/url" import type { PageContext, PageReturn } from "../../page/contract" import { Message as PageMessage } from "../../page/services/detail/message" import { Model as PageModel } from "../../page/services/detail/model" import * as DetailUpdate from "../../page/services/detail/update" import { view as pageView } from "../../page/services/detail/view" import type { Resources } from "../../platform/resources" +import { urlToAppRoute } from "../../route" import { RUNTIMES_COMMITS, RUNTIMES_DEPENDENCIES, @@ -24,8 +26,8 @@ import { } from "../service-runtimes-fixture" import { defineShellLab } from "../shell-lab" -// `/lab/service-runtimes`: the service page's Overview tab for `fleet-api` (Google Cloud Run, no -// Kubernetes) over one hour, in the real shell. Every read is answered from the fixture. +// `/lab/service-runtimes[?tab=operations|dependencies]`: the service page for `fleet-api` (Google +// Cloud Run, no Kubernetes) over one hour, in the real shell. Every read is answered from the fixture. const CONTEXT: PageContext = { orgId: Schema.decodeSync(OrgId)("org_lab_service_runtimes"), @@ -33,7 +35,7 @@ const CONTEXT: PageContext = { timeZone: "UTC", } -const Flags = Schema.Struct({}) +const Flags = Schema.Struct({ tab: Schema.NullOr(Schema.String) }) type Flags = typeof Flags.Type const Model = Schema.Struct({ page: PageModel }) @@ -144,9 +146,19 @@ const step = (result: PageReturn): Return => { } } -const init = (_flags: Flags): Return => { +/** The page's URL writes (a tab switch), applied to its own search the way a route change would. */ +const follow = (model: PageModel, url: string): PageReturn => + Option.match(urlFromString(new URL(url, "http://localhost").href), { + onNone: () => ({ model }), + onSome: (parsed) => { + const route = urlToAppRoute(parsed) + return route._tag === "Service" ? DetailUpdate.routeChanged(model, route, CONTEXT) : { model } + }, + }) + +const init = (flags: Flags): Return => { const started = DetailUpdate.init( - { serviceName: RUNTIMES_SERVICE, search: { timePreset: "1h" } }, + { serviceName: RUNTIMES_SERVICE, search: { timePreset: "1h", tab: flags.tab ?? undefined } }, CONTEXT, ) const clocked = DetailUpdate.update( @@ -157,10 +169,16 @@ const init = (_flags: Flags): Return => { return step({ ...clocked, commands: [...(started.commands ?? []), ...(clocked.commands ?? [])] }) } -/** Navigation out of the page (a tab, a row) is dropped: the lab stays on its one view. */ +/** A tab switch is followed in place; navigation out of the page (a row) is dropped. */ const update = (model: Model, message: Message): Return => Message.match(message, { - GotPageMessage: ({ message: inner }) => step(DetailUpdate.update(model.page, inner, CONTEXT)), + GotPageMessage: ({ message: inner }) => { + const next = DetailUpdate.update(model.page, inner, CONTEXT) + const out = next.outMessage + if (out === undefined || out._tag !== "RequestedNavigation") return step(next) + const followed = follow(next.model, out.url) + return step({ ...followed, commands: [...(next.commands ?? []), ...(followed.commands ?? [])] }) + }, }) const view = (model: Model, h: HtmlBuilder) => @@ -176,7 +194,7 @@ export const lab = defineShellLab("Service runtimes", { Model, Message, Flags, - flags: Effect.succeed({}), + flags: Effect.sync(() => ({ tab: new URLSearchParams(window.location.search).get("tab") })), init, update, view, diff --git a/apps/web/src/lab/registry.ts b/apps/web/src/lab/registry.ts index 581abf464a..7642b10240 100644 --- a/apps/web/src/lab/registry.ts +++ b/apps/web/src/lab/registry.ts @@ -238,7 +238,7 @@ export const LAB_ENTRIES: ReadonlyArray = [ path: "/lab/service-runtimes", title: "Service runtimes", description: - "The service page's Overview tab for fleet-api, a Google Cloud Run service with no Kubernetes workloads, over one hour.", + "The service page for fleet-api, a Google Cloud Run service with no Kubernetes workloads, over one hour; ?tab=operations or ?tab=dependencies opens that tab.", kind: "lab", session: "none", }, diff --git a/apps/web/src/page/services/detail/view-api-charts.ts b/apps/web/src/page/services/detail/view-api-charts.ts index c03a1c7e56..ec5d2edc3f 100644 --- a/apps/web/src/page/services/detail/view-api-charts.ts +++ b/apps/web/src/page/services/detail/view-api-charts.ts @@ -27,7 +27,7 @@ export const STATUS_COLORS = { const STACK_ORDER = ["5xx", "4xx", "2xx", "unknown"] as const export const SPARK_TOKENS = { - requests: "--chart-p50", + requests: "--chart-throughput", client: "--warning", server: "--severity-error", latency: "--chart-p95", 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 73d8cc7ab3..dc361928cd 100644 --- a/apps/web/src/page/services/detail/view-api-tab.ts +++ b/apps/web/src/page/services/detail/view-api-tab.ts @@ -17,7 +17,7 @@ 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 { panel, panelBody, panelHeader } from "../../../ui/panel" import { searchInput } from "../../../ui/search-input" import { skeleton } from "../../../ui/skeleton" import { statFigure } from "../../../ui/stat-figure" @@ -53,7 +53,7 @@ import type { ApiBucket, ApiOverview, Endpoint, StatusRow } from "./model-tabs" import { COLUMN, COLUMN_COUNT, groupRows, groupsOf, type RowContext } from "./view-api" import { type ChartContext, SPARK_TOKENS, sparkline, STATUS_COLORS, statusChart } from "./view-api-charts" import { markersOverlay } from "./view-markers" -import { headLabel, sortColumnHead } from "./view-table-cells" +import { headLabel, rangeNote, sortColumnHead, tableSkeletonHead, tableTitle } from "./view-table-cells" // `ServiceApiTab`: the KPI strip, the responses chart beside the non-2xx breakdown, and the // endpoint table with its filters. The endpoint drawer is the page's (`endpoint-peek`). @@ -221,8 +221,7 @@ const kpiSkeleton = (h: H): Html => statRailLoading(h, 5, 5, "w-full overflow-hi const legendItem = (h: H, swatch: Html, label: string): Html => h.div([h.Key(label), h.Class("flex items-center gap-1.5")], [swatch, h.span([], [label])]) -const swatch = (h: H, color: string) => - h.span([h.Class("size-2 rounded-xs"), h.Style({ background: color })]) +const swatch = (h: H, color: string) => h.span([h.Class("size-2 rounded-xs"), h.Style({ background: color })]) const responsesChart = ( h: H, @@ -251,70 +250,67 @@ const responsesChart = ( }) const context: ChartContext | null = theme === null ? null : { theme, timeZone: inputs.timeZone, nowMs: model.nowMs ?? 0 } - 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")], + return panel(h, { className: "min-w-0 flex-1" }, [ + panelHeader(h, { + title: "Responses by status", + scope: h.span([h.Class("text-2xs text-foreground-subtle")], ["req/s, all endpoints"]), + divided: false, + action: 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", + ), + ]), + ], + ), + }), + panelBody( + h, [ - 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"), + }), ], + "px-4 pb-4 pt-2", ), - 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"), - }), ]) } @@ -372,37 +368,41 @@ 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 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")], + return panel(h, { className: "w-full shrink-0 lg:w-[400px]" }, [ + panelHeader(h, { + title: "Non-2xx responses", + action: + summaries === null + ? undefined + : 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`], + ), + }), + panelBody( + h, [ - 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)), ), - ]), ], + "p-4", ), - 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)), - ), ]) } @@ -428,41 +428,40 @@ const filterChip = (h: H, filter: EndpointFilter, active: EndpointFilter): Html ) const toolbar = (h: H, model: Model, count: number): Html => - h.div( - [h.Class("flex flex-wrap items-center gap-3 border-b px-4 py-2")], - [ - h.span([h.Class("text-ui font-medium")], ["Endpoints"]), - ...(AsyncData.isFailure(model.endpointSeries.data) - ? [ - h.span( - [h.Class("text-2xs text-severity-error")], - ["Trends failed to load; Reload to retry."], - ), - ] - : []), - h.span( - [h.Class("rounded-sm bg-muted px-1.5 text-2xs text-muted-foreground tabular-nums")], - [formatNumber(count)], - ), - h.div( - [ - h.Class("ml-3 flex items-center gap-1 text-2xs"), - h.Role("group"), - h.AriaLabel("Filter endpoints"), - ], - ENDPOINT_FILTERS.map((filter) => filterChip(h, filter, model.endpointFilter)), - ), - searchInput(h, { - id: "service-endpoint-search", - value: model.endpointQuery, - placeholder: "Filter routes…", - ariaLabel: "Filter routes", - onInput: (value) => Message.ChangedEndpointQuery({ value }), - onClear: Message.ChangedEndpointQuery({ value: "" }), - className: "ml-auto w-60", - }), - ], - ) + tableTitle(h, { + title: "Endpoints", + count, + action: h.div( + [h.Class("flex flex-wrap items-center gap-3")], + [ + ...(AsyncData.isFailure(model.endpointSeries.data) + ? [ + h.span( + [h.Class("text-2xs text-severity-error")], + ["Trends failed to load; Reload to retry."], + ), + ] + : []), + h.div( + [ + h.Class("flex items-center gap-1 text-2xs"), + h.Role("group"), + h.AriaLabel("Filter endpoints"), + ], + ENDPOINT_FILTERS.map((filter) => filterChip(h, filter, model.endpointFilter)), + ), + searchInput(h, { + id: "service-endpoint-search", + value: model.endpointQuery, + placeholder: "Filter routes…", + ariaLabel: "Filter routes", + onInput: (value) => Message.ChangedEndpointQuery({ value }), + onClear: Message.ChangedEndpointQuery({ value: "" }), + className: "w-60", + }), + ], + ), + }) /** Request rate per bucket by span name on one axis; a bucket an endpoint was quiet in reads 0. */ const sparksBySpanName = (buckets: ReadonlyArray, bucketSeconds: number, window: WindowMs) => { @@ -581,24 +580,25 @@ const endpointsTable = ( const loadingState = (h: H): Html => h.div( - [h.Class("flex flex-col gap-4")], + [h.Class("flex flex-col gap-3")], [ kpiSkeleton(h), h.div( - [h.Class("flex flex-col gap-4 lg:flex-row")], + [h.Class("flex flex-col gap-3 lg:flex-row")], [ - skeleton(h, "h-[262px] flex-1 rounded-md"), - skeleton(h, "h-[262px] w-full rounded-md lg:w-[400px]"), + skeleton(h, "h-[282px] flex-1 rounded-md"), + skeleton(h, "h-[282px] w-full rounded-md lg:w-[400px]"), ], ), panel(h, {}, [ + ...tableSkeletonHead(h, "Endpoints"), h.div( [h.AriaBusy(true), h.Class("flex flex-col")], Array.from({ length: 8 }, (_, index) => h.div( [ h.Key(String(index)), - h.Class("flex h-12 items-center gap-3 border-b px-4 last:border-b-0"), + h.Class("flex h-11 items-center gap-3 border-b px-4 last:border-b-0"), ], [ skeleton(h, "h-2.5 w-9"), @@ -619,7 +619,10 @@ const loadingState = (h: H): Html => const emptyState = (h: H, serviceName: string): Html => { const mono = (text: string) => h.span([h.Class("text-foreground-soft")], [text]) return panel(h, { className: "items-center gap-3.5 px-4 py-11 text-center" }, [ - h.span([h.Class("text-title font-medium text-foreground-soft")], ["No HTTP endpoints in this window"]), + h.span( + [h.Class("text-title font-medium text-foreground-soft")], + ["No HTTP endpoints in this window"], + ), h.span( [h.Class("max-w-[620px] text-ui leading-[21px] text-muted-foreground")], [ @@ -637,17 +640,19 @@ const emptyState = (h: H, serviceName: string): Html => { ]) } -const notes = (h: H, count: number, seconds: number): ReadonlyArray => - [ - ...(isTruncated(count) - ? [ - `Showing the ${ENDPOINTS_LIMIT} busiest endpoints. This service has more. Narrow the time range or environment to see the rest.`, - ] - : []), - ...(seconds > TRACE_DETAIL_LIMIT_SECONDS - ? ["Endpoint summaries cover the selected range; trace drill-downs show the latest 30 days."] - : []), - ].map((note) => h.p([h.Key(note), h.Class("px-0.5 text-xs text-muted-foreground")], [note])) +const notes = (h: H, count: number, seconds: number): ReadonlyArray => [ + ...(isTruncated(count) + ? [ + h.p( + [h.Class("text-xs text-muted-foreground")], + [ + `Showing the ${ENDPOINTS_LIMIT} busiest endpoints. This service has more. Narrow the time range or environment to see the rest.`, + ], + ), + ] + : []), + ...(seconds > TRACE_DETAIL_LIMIT_SECONDS ? [rangeNote(h, "Endpoint")] : []), +] const loaded = ( h: H, @@ -671,7 +676,7 @@ const loaded = ( : null return h.div( [ - h.Class(cn("flex flex-col gap-4", refreshingClass(isWaiting))), + h.Class(cn("flex flex-col gap-3", refreshingClass(isWaiting))), ...(isWaiting ? [h.AriaBusy(true)] : []), ], [ @@ -683,7 +688,7 @@ const loaded = ( onSome: (data) => kpiStrip(h, data, summaries, seconds, inputs, theme), }), h.div( - [h.Class("flex flex-col gap-4 lg:flex-row")], + [h.Class("flex flex-col gap-3 lg:flex-row")], [ overviewFailure !== null ? h.div( @@ -696,7 +701,7 @@ const loaded = ( ], ) : Option.match(overview, { - onNone: () => skeleton(h, "h-[262px] flex-1 rounded-md"), + onNone: () => skeleton(h, "h-[282px] flex-1 rounded-md"), onSome: (data) => responsesChart(h, model, data, inputs, theme), }), breakdownFailure !== null diff --git a/apps/web/src/page/services/detail/view-api.ts b/apps/web/src/page/services/detail/view-api.ts index b9ad3be582..f39b09bb55 100644 --- a/apps/web/src/page/services/detail/view-api.ts +++ b/apps/web/src/page/services/detail/view-api.ts @@ -66,7 +66,12 @@ const methodTone = (method: string): string => ? "text-severity-error" : "text-muted-foreground" -export const methodLabel = (h: HtmlBuilder, method: string, className = "w-10", isTinted = true): Html => +export const methodLabel = ( + h: HtmlBuilder, + method: string, + className = "w-10", + isTinted = true, +): Html => h.span( [ h.Class( @@ -318,8 +323,10 @@ const endpointRow = ( ), ], cn( - "group/row h-12 cursor-pointer border-b text-ui last:border-b-0 has-[button:focus-visible]:ring-1 has-[button:focus-visible]:ring-ring has-[button:focus-visible]:ring-inset", - isSelected ? "bg-accent hover:bg-accent active:bg-accent" : "hover:bg-surface-sunken active:bg-surface-sunken", + "group/row h-11 cursor-pointer border-b text-ui last:border-b-0 has-[button:focus-visible]:ring-1 has-[button:focus-visible]:ring-ring has-[button:focus-visible]:ring-inset", + isSelected + ? "bg-accent hover:bg-accent active:bg-accent" + : "hover:bg-surface-sunken active:bg-surface-sunken", ), [h.Key(endpoint.spanName), h.OnClick(Message.ClickedEndpoint({ spanName: endpoint.spanName }))], ) @@ -349,7 +356,9 @@ const groupHeaderRow = ( tableCell( h, // Muted totals drop the unit to subtle; a toned total keeps it in the tone. - text === EMPTY_VALUE ? [emptyValue(h)] : measure(h, text, tone === undefined ? "text-foreground-subtle" : null), + text === EMPTY_VALUE + ? [emptyValue(h)] + : measure(h, text, tone === undefined ? "text-foreground-subtle" : null), cn(numeric, "py-0 pr-0 text-2xs text-muted-foreground", tone, width), ) return tableRow( diff --git a/apps/web/src/page/services/detail/view-dependencies.ts b/apps/web/src/page/services/detail/view-dependencies.ts index c991f87786..e24b23b2ee 100644 --- a/apps/web/src/page/services/detail/view-dependencies.ts +++ b/apps/web/src/page/services/detail/view-dependencies.ts @@ -8,9 +8,11 @@ import { AsyncData } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { badge } from "../../../ui/display" import { emptyMessage } from "../../../ui/empty" +import { errorState } from "../../../ui/error-state" import { eyebrow } from "../../../ui/eyebrow" import { icon, type IconName } from "../../../ui/icon" import { panel } from "../../../ui/panel" +import { skeleton } from "../../../ui/skeleton" import { table, tableBody, tableCell, tableHeader, tableRow } from "../../../ui/table" import { errorRateValue, latencyValue, serviceDot } from "../../../ui/values" import { @@ -25,10 +27,17 @@ import { sortRows } from "../../kit/table-sort" import { TRACE_DETAIL_LIMIT_SECONDS } from "./derive-tables" import { Message } from "./message" import type { Model } from "./model" -import { barCell, headLabel, sortColumnHead } from "./view-table-cells" +import type { DependencyBundle } from "./model-tabs" +import { + barCell, + headLabel, + rangeNote, + sortColumnHead, + tableSkeletonHead, + tableTitle, +} from "./view-table-cells" // `ServiceDependenciesTab` + `DependencyTable` + `DependencyTypeBadge` (components/services/). -// TODO(mobile-list): the `md:hidden` tap-to-trace list and its sort pills are not ported. // TODO(sampled-tooltip): an estimated rate's "Estimated ×N" tooltip is not ported (trigger only). type H = HtmlBuilder @@ -36,7 +45,7 @@ type H = HtmlBuilder const BADGE = new Map>([ ["service", { label: "Service", tone: "bg-severity-info-a2 text-severity-info", icon: "ServerIcon" }], ["database", { label: "Database", tone: "bg-chart-3/10 text-chart-3", icon: "DatabaseIcon" }], - ["http", { label: "HTTP", tone: "bg-foreground/5 text-muted-foreground", icon: "GlobeIcon" }], + ["http", { label: "HTTP", tone: "bg-surface-sunken text-muted-foreground", icon: "GlobeIcon" }], ["messaging", { label: "Queue", tone: "bg-chart-4/10 text-chart-4", icon: "PaperPlaneIcon" }], ["rpc", { label: "RPC", tone: "bg-chart-5/10 text-chart-5", icon: "NetworkNodesIcon" }], ]) @@ -96,7 +105,7 @@ const dependencyRow = (h: H, row: DependencyRow, maxima: Readonly<{ calls: numbe ], ), ], - "max-w-0 py-2 pl-3 align-middle", + "max-w-0 py-0 pl-4 align-middle", ), barCell(h, { value: row.callsPerSec, max: maxima.calls, tone: "calls" }, sampledRate(h, row)), barCell( @@ -107,15 +116,15 @@ const dependencyRow = (h: H, row: DependencyRow, maxima: Readonly<{ calls: numbe tableCell( h, [latencyValue(h, { ms: row.avgDurationMs, scale: "avg", className: "text-xs" })], - "py-2 text-right align-middle", + "hidden py-0 text-right align-middle md:table-cell", ), barCell( h, - { value: row.p95DurationMs, max: maxima.p95, tone: "latency" }, + { value: row.p95DurationMs, max: maxima.p95, tone: "latency", className: "pr-4" }, latencyValue(h, { ms: row.p95DurationMs, scale: "p95", className: "text-xs" }), ), ], - "cursor-pointer group/row border-b last:border-b-0 hover:bg-surface-sunken active:bg-surface-sunken focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-inset", + "group/row h-11 cursor-pointer border-b last:border-b-0 hover:bg-surface-sunken active:bg-surface-sunken focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-inset", [ h.Key(row.id), h.Attribute("tabindex", "0"), @@ -135,19 +144,20 @@ const dependencyTable = (h: H, model: Model, rows: ReadonlyArray) const sorted = sortRows(rows, sort, (row, key) => key === "errorRate" ? row.errorRate : key === "p95DurationMs" ? row.p95DurationMs : row.callsPerSec, ) - const column = (sortKey: string, label: string) => - sortColumnHead(h, { table: "dependencies", sortKey, label, sort }) - return panel(h, { className: "hidden md:block" }, [ + const column = (sortKey: string, label: string, className?: string) => + sortColumnHead(h, { table: "dependencies", sortKey, label, sort, className }) + return panel(h, {}, [ + tableTitle(h, { title: "Dependencies", count: rows.length }), table(h, {}, [ tableHeader(h, [ tableRow( h, [ - headLabel(h, ["Target"], "pl-3"), + headLabel(h, ["Target"], "pl-4"), column("callsPerSec", "Calls /s"), column("errorRate", "Errors"), - headLabel(h, ["Avg"], "text-right"), - column("p95DurationMs", "p95"), + headLabel(h, ["Avg"], "hidden text-right md:table-cell"), + column("p95DurationMs", "p95", "pr-4"), ], "border-b", ), @@ -209,7 +219,7 @@ const headlineFact = ( ], ) -const summaryStrip = (h: H, count: number, summary: DependencySummary): Html => +const summaryStrip = (h: H, summary: DependencySummary): Html => h.div( [ h.Class( @@ -217,20 +227,8 @@ const summaryStrip = (h: H, count: number, summary: DependencySummary): Html => ), ], [ - h.div( - [h.Class("flex items-baseline gap-x-3")], - [ - h.span( - [h.Class("text-foreground")], - [ - h.span([h.Class("tabular-nums font-mono font-medium")], [String(count)]), - " ", - h.span([h.Class("text-muted-foreground")], ["downstream"]), - ], - ), - h.span([h.Class("text-foreground-subtle")], [summary.breakdown]), - ], - ), + // The table title carries the count; the strip says what kind of targets they are. + h.span([h.Class("text-foreground-subtle")], [summary.breakdown]), h.span([h.Class("hidden grow sm:block")]), h.div( [h.Class("flex flex-col gap-1 sm:contents")], @@ -262,37 +260,55 @@ const summaryStrip = (h: H, count: number, summary: DependencySummary): Html => ], ) -/** The Dependencies tab: summary strip, the 30-day note, and the sortable table. */ -export const dependenciesTab = (h: H, model: Model): Html => { +const loadingState = (h: H): Html => + panel(h, {}, [ + ...tableSkeletonHead(h, "Dependencies"), + h.div( + [h.AriaBusy(true), h.Class("flex flex-col")], + Array.from({ length: 6 }, (_, index) => + h.div( + [ + h.Key(String(index)), + h.Class("flex h-11 items-center gap-3 border-b px-4 last:border-b-0"), + ], + [ + skeleton(h, "h-4 w-16"), + skeleton(h, "h-3 flex-1"), + skeleton(h, "h-3 w-12"), + skeleton(h, "h-3 w-10"), + skeleton(h, "h-3 w-12"), + ], + ), + ), + ), + ]) + +/** The Dependencies tab: skeleton, error, or the summary strip, the 30-day note and the table. */ +export const dependenciesTab = (h: H, model: Model): Html => + AsyncData.match(model.dependencies.data, { + onIdle: () => loadingState(h), + onLoading: () => loadingState(h), + onFailure: (failure) => errorState(h, { failure, title: "Couldn't load dependencies" }), + onStale: ({ error }) => errorState(h, { failure: error, title: "Couldn't load dependencies" }), + onRefreshing: (bundle) => dependenciesBody(h, model, bundle, true), + onSuccess: (bundle) => dependenciesBody(h, model, bundle, false), + }) + +const dependenciesBody = (h: H, model: Model, bundle: DependencyBundle, isWaiting: boolean): Html => { const window = model.window const startMs = window === null ? 0 : parseWarehouseDateTime(window.startTime) const endMs = window === null ? 0 : parseWarehouseDateTime(window.endTime) const seconds = startMs > 0 && endMs > 0 ? Math.max((endMs - startMs) / 1000, 1) : 3600 - const rows = foldHostRows( - AsyncData.getOrElse( - AsyncData.map(model.dependencies.data, (bundle) => dependencyRows(bundle, seconds)), - () => [], - ), - ) + const rows = foldHostRows(dependencyRows(bundle, seconds)) const summary = dependencySummary(rows) - const isWaiting = AsyncData.isRefreshing(model.dependencies.data) return h.div( [ h.Class(cn("flex flex-col gap-3", refreshingClass(isWaiting))), ...(isWaiting ? [h.AriaBusy(true)] : []), ], [ - ...(summary === undefined ? [] : [summaryStrip(h, rows.length, summary)]), - ...(seconds > TRACE_DETAIL_LIMIT_SECONDS - ? [ - h.p( - [h.Class("text-xs text-muted-foreground")], - [ - "Dependency summaries cover the selected range; individual trace drill-downs show the latest 30 days.", - ], - ), - ] - : []), + ...(summary === undefined ? [] : [summaryStrip(h, summary)]), + ...(seconds > TRACE_DETAIL_LIMIT_SECONDS ? [rangeNote(h, "Dependency")] : []), dependencyTable(h, model, rows), ], ) diff --git a/apps/web/src/page/services/detail/view-deploys.ts b/apps/web/src/page/services/detail/view-deploys.ts index b6fa8fa9cc..e00b5cd840 100644 --- a/apps/web/src/page/services/detail/view-deploys.ts +++ b/apps/web/src/page/services/detail/view-deploys.ts @@ -15,6 +15,7 @@ import { docsLink } from "../../kit/signal-empty-state" import { emptyMessage } from "../../../ui/empty" import { listRow } from "../../../ui/list-row" import { skeleton, skeletonList } from "../../../ui/skeleton" +import { textLink } from "../../../ui/text-link" import { truncatedText } from "../../../ui/truncated-text" import { type DeployEntry, deriveDeploys, firstLine, isResolvableSha } from "./derive-panels" import { Message } from "./message" @@ -76,7 +77,7 @@ const issuesFrame = (h: H, isLimited: boolean, children: ReadonlyArray): H ...(isLimited ? [h.span([h.Class("text-2xs text-muted-foreground")], ["Latest 90 days"])] : []), - h.a([h.Class("text-xs text-primary hover:underline active:underline"), h.Href("/errors")], ["View all →"]), + textLink(h, { href: "/errors", className: "text-xs" }, ["View all →"]), ], ), }, @@ -195,15 +196,10 @@ const connectRepoFooter = (h: H, model: Model, deploys: ReadonlyArray