From 73a5a0a27aa1089082b773b1bd5f6e0eeace209c Mon Sep 17 00:00:00 2001
From: Kingston
- hflow {config.hflow_version} · ui {config.hflow_ui_version} + hflow {config.hflow_version} · server {config.hflow_server_version}
); diff --git a/ui/src/api.ts b/ui/src/api.ts index 9a7647ad..0ff0dc83 100644 --- a/ui/src/api.ts +++ b/ui/src/api.ts @@ -1,6 +1,21 @@ -// Typed client for the hflow-server JSON API (/api/v1). -// Interfaces mirror the served contract shapes exactly; this module is -// the only place that talks to the network. +// Typed client for the hflow-server JSON API (/api/v1). The only module here +// that talks to the network. +// +// Payload types are ALIASES of `apiSchema.ts`, which `pnpm gen:api` generates +// from the server's own /api/openapi.json. They were hand-written once and +// drifted: the server declared `kind: StepKind` while the copy said +// `kind: string`, it grew a `curation` capability the copy never learned +// about, and a renamed config field went unnoticed for a release. Nothing +// caught any of it, because nothing compared them. Now there is one owner -- +// the server -- and regenerating is a diff rather than an audit. +// +// What stays hand-written below is the part the server does not declare: +// request shapes this client composes, and view-model types (`EpisodesQuery`, +// `FacetName`) that exist only in the browser. + +import type { components } from "./apiSchema"; + +type Served = components["schemas"]; export class ApiError extends Error { readonly status: number; @@ -14,111 +29,36 @@ export class ApiError extends Error { } } -export interface WorkspaceCapabilities { - catalog: boolean; - media: boolean; - runtime: boolean; - /** True when the server imported a --pipeline app at startup. */ - pipeline: boolean; -} +export type WorkspaceCapabilities = Served["WorkspaceCapabilities"]; -export interface WorkspaceConfig { - mode: string; - read_only: boolean; - hflow_version: string; - hflow_ui_version: string; - data_root: string; - workspace_id: string | null; - capabilities: WorkspaceCapabilities; - // These two carry the live vocabularies from hflow.steps so the frontend - // never hardcodes them. Required, not optional: this bundle ships inside the - // same wheel as the server that serves it, so the two cannot be different - // versions and "an older server omits the field" is not a reachable state. - run_profiles: string[]; - ingest_modes: string[]; -} - -export interface EpisodeColumn { - name: string; - type: string; -} +export type WorkspaceConfig = Served["WorkspaceConfigResponse"]; + +export type EpisodeColumn = Served["ColumnDescriptor"]; /** One row of the wide `episodes` view; columns are described by EpisodeColumn. */ export type EpisodeRow = Record- The episode spans {secondsLabel(spanSeconds)}, but no check recorded an interval inside it. + The episode spans {secondsLabel(spanSeconds)}, but{" "} + {unplaceableCount === 0 + ? "no check recorded an interval inside it." + : `none of its ${unplaceableCount} recorded interval${ + unplaceableCount === 1 ? "" : "s" + } could be placed on that axis: the catalog has no start time to measure them from.`}
); } @@ -160,6 +198,12 @@ export function TimelineStrip({ timeline }: { timeline: EpisodeTimeline }) { axis spans the intervals themselves — the catalog records no episode duration ) : null} + {unplaceableCount > 0 ? ( + + {unplaceableCount} interval{unplaceableCount === 1 ? "" : "s"} not drawn — the catalog + has no start time to measure {unplaceableCount === 1 ? "it" : "them"} from + + ) : null} ); diff --git a/ui/src/components/StatusChip.tsx b/ui/src/components/StatusChip.tsx index 2451ba24..72d65f33 100644 --- a/ui/src/components/StatusChip.tsx +++ b/ui/src/components/StatusChip.tsx @@ -21,6 +21,9 @@ function toneForStatus(status: string): StatusTone { } } -export function StatusChip({ status }: { status: string }) { +export function StatusChip({ status }: { status: string | null }) { + // A null state is Airflow's "not scheduled yet", not an error: it reads as + // the muted em dash the tables use for every other absent value. + if (status === null) return —; return {status}; } diff --git a/ui/src/components/VersionChip.tsx b/ui/src/components/VersionChip.tsx index fc168598..f386276e 100644 --- a/ui/src/components/VersionChip.tsx +++ b/ui/src/components/VersionChip.tsx @@ -7,8 +7,11 @@ import { useCopyToClipboard } from "../useCopyToClipboard"; * shortFingerprint rule the episode dossier uses), full value on hover, one * click copies the whole hash (diffing pipelines needs the exact string). */ -export function VersionChip({ version }: { version: string }) { +export function VersionChip({ version }: { version: string | null }) { const { copyState, copyText } = useCopyToClipboard(); + // Some served versions are nullable (a check_runs LEFT JOIN that matched + // nothing). There is no hash to show or copy, so it reads as absent. + if (version === null) return —; const title = copyState === "copied" diff --git a/ui/src/format.ts b/ui/src/format.ts index 9f90419d..747bf541 100644 --- a/ui/src/format.ts +++ b/ui/src/format.ts @@ -9,7 +9,11 @@ export function looksLikeIsoTimestamp(text: string): boolean { } /** "2026-08-21T14:03:22.123456+00:00" -> "2026-08-21 14:03:22" (full value goes in title). */ -export function formatTimestamp(isoText: string): string { +export function formatTimestamp(isoText: string | null): string { + // Several served timestamps are nullable -- a run Airflow never stamped, a + // check_runs join that found no row. Absent renders as the same em dash as + // every other missing value, rather than throwing on .replace. + if (isoText === null) return "—"; return isoText.replace("T", " ").replace(/(\.\d+)?(Z|[+-]\d{2}:?\d{2})?$/, ""); } @@ -24,7 +28,17 @@ export function formatDurationSeconds(seconds: number | null): string { return `${seconds.toFixed(3)} s`; } -export function nanosecondsToRelativeSeconds(valueNs: number, originNs: number): string { +export function nanosecondsToRelativeSeconds( + valueNs: number | null, + originNs: number | null, +): string { + // The served interval rows type their bounds nullable, so a row missing one + // has no position to report. Em dash, like every other absent value. + if (valueNs === null || originNs === null) return "—"; + return relativeSeconds(valueNs, originNs); +} + +function relativeSeconds(valueNs: number, originNs: number): string { return ((valueNs - originNs) / 1e9).toFixed(3); } @@ -59,7 +73,8 @@ export function summarizeValueText(value: unknown): string { return JSON.stringify(value); } -export function shortFingerprint(fingerprint: string): string { +export function shortFingerprint(fingerprint: string | null): string { + if (fingerprint === null) return "—"; return fingerprint.length > 10 ? fingerprint.slice(0, 10) : fingerprint; } diff --git a/ui/src/pages/EpisodeDetailPage.tsx b/ui/src/pages/EpisodeDetailPage.tsx index 7c2a18b3..f26de77d 100644 --- a/ui/src/pages/EpisodeDetailPage.tsx +++ b/ui/src/pages/EpisodeDetailPage.tsx @@ -197,10 +197,10 @@ function CheckRunsSection({ checkRuns }: { checkRuns: EpisodeCheckRun[] }) {