refactor(api): reuse board column contract across clients

Make BoardColumnDefinition.id reference the existing BoardColumn schema and carry that typed contract through generated TypeScript, server responses, demo data, and the runs board UI.
This commit is contained in:
Bryan Helmkamp 2026-05-04 15:52:24 -04:00
parent 63940fdddc
commit b5b08e78d3
No known key found for this signature in database
8 changed files with 74 additions and 51 deletions

View file

@ -1,8 +1,10 @@
import { formatElapsedSecs, formatDurationSecs } from "../lib/format";
import type {
RunListItem,
RunStatus as ApiRunStatus,
RunSummary,
import {
BoardColumn,
type BoardColumn as ApiBoardColumn,
type RunListItem,
type RunStatus as ApiRunStatus,
type RunSummary,
} from "@qltysh/fabro-api-client";
export type CiStatus = "passing" | "failing" | "pending";
@ -37,7 +39,16 @@ export interface RunItem {
sourceDirectory?: string;
}
export type ColumnStatus = "queued" | "initializing" | "running" | "blocked" | "succeeded" | "failed";
export type ColumnStatus = ApiBoardColumn;
export const columnStatuses = [
BoardColumn.QUEUED,
BoardColumn.INITIALIZING,
BoardColumn.RUNNING,
BoardColumn.BLOCKED,
BoardColumn.SUCCEEDED,
BoardColumn.FAILED,
] as const satisfies readonly ColumnStatus[];
export const columnStatusDisplay: Record<ColumnStatus, { label: string; dot: string; text: string }> = {
queued: { label: "Queued", dot: "bg-fg-muted", text: "text-fg-muted" },

View file

@ -34,6 +34,9 @@ const immutableOptions: SWRConfiguration = {
revalidateOnReconnect: false,
};
type BoardRunsEnvelope = PaginatedEnvelope<PaginatedBoardRunList["data"][number]> &
Pick<PaginatedBoardRunList, "columns">;
export function useAuthConfig() {
return useSWR<{ methods: string[] }>(queryKeys.auth.config(), apiFetcher, immutableOptions);
}
@ -61,11 +64,7 @@ export function useSystemInfo() {
}
export function useBoardsRuns() {
return useSWR<
PaginatedEnvelope<PaginatedBoardRunList["data"][number]> & {
columns: { id: string; name: string }[];
}
>(queryKeys.boards.runs(), apiPaginatedFetcher);
return useSWR<BoardRunsEnvelope>(queryKeys.boards.runs(), apiPaginatedFetcher);
}
export function useRun(id: string | undefined) {

View file

@ -18,7 +18,7 @@ import {
arrayMove,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { ciConfig, columnStatusDisplay, deriveCiStatus, mapRunListItem } from "../data/runs";
import { ciConfig, columnStatusDisplay, columnStatuses, deriveCiStatus, mapRunListItem } from "../data/runs";
import type { CiStatus, CheckRun, CheckStatus, RunItem, RunWithStatus, ColumnStatus } from "../data/runs";
import { EmptyState } from "../components/state";
import { shouldRefreshBoardForEvent, useBoardEvents } from "../lib/board-events";
@ -50,7 +50,7 @@ const defaultColumnStyle: ColumnStyle = { iconType: "branch", actions: [] };
const defaultColumnColors = { dot: "bg-fg-muted", text: "text-fg-muted" };
interface BoardRunsResponse {
columns: { id: string; name: string }[];
columns: PaginatedBoardRunList["columns"];
data: PaginatedBoardRunList["data"];
meta: PaginatedBoardRunList["meta"];
}
@ -65,17 +65,8 @@ type Column = {
items: RunItem[];
};
const SKELETON_STATUSES: ColumnStatus[] = [
"queued",
"initializing",
"running",
"blocked",
"succeeded",
"failed",
];
function buildSkeletonColumns(): Column[] {
return SKELETON_STATUSES.map((id) => {
return columnStatuses.map((id) => {
const colors = columnStatusDisplay[id];
return {
id,
@ -100,7 +91,7 @@ export function buildBoardColumns(response: BoardRunsResponse): Column[] {
}
return response.columns.map((col) => {
const id = col.id as ColumnStatus;
const id = col.id;
const colors = columnStatusDisplay[id] ?? defaultColumnColors;
return {
id,

View file

@ -5670,7 +5670,7 @@ components:
- name
properties:
id:
type: string
$ref: "#/components/schemas/BoardColumn"
name:
type: string

View file

@ -975,27 +975,27 @@ mod runs {
pub(super) fn columns() -> Vec<BoardColumnDefinition> {
vec![
BoardColumnDefinition {
id: "queued".into(),
id: BoardColumn::Queued,
name: "Queued".into(),
},
BoardColumnDefinition {
id: "initializing".into(),
id: BoardColumn::Initializing,
name: "Initializing".into(),
},
BoardColumnDefinition {
id: "running".into(),
id: BoardColumn::Running,
name: "Running".into(),
},
BoardColumnDefinition {
id: "blocked".into(),
id: BoardColumn::Blocked,
name: "Blocked".into(),
},
BoardColumnDefinition {
id: "succeeded".into(),
id: BoardColumn::Succeeded,
name: "Succeeded".into(),
},
BoardColumnDefinition {
id: "failed".into(),
id: BoardColumn::Failed,
name: "Failed".into(),
},
]

View file

@ -10,7 +10,9 @@ use axum::{Json, Router};
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use bytes::Bytes;
use fabro_api::types::{RunManifest, RunStatusResponse, SubmitAnswerRequest};
use fabro_api::types::{
BoardColumn, BoardColumnDefinition, RunManifest, RunStatusResponse, SubmitAnswerRequest,
};
use fabro_config::Storage;
use fabro_interview::AnswerSubmission;
use fabro_types::{
@ -82,27 +84,45 @@ impl ListRunsParams {
}
}
fn board_column(status: RunStatus) -> Option<&'static str> {
fn board_column(status: RunStatus) -> Option<BoardColumn> {
match status {
RunStatus::Submitted | RunStatus::Queued => Some("queued"),
RunStatus::Starting => Some("initializing"),
RunStatus::Running | RunStatus::Paused { .. } => Some("running"),
RunStatus::Blocked { .. } => Some("blocked"),
RunStatus::Succeeded { .. } => Some("succeeded"),
RunStatus::Failed { .. } | RunStatus::Dead => Some("failed"),
RunStatus::Submitted | RunStatus::Queued => Some(BoardColumn::Queued),
RunStatus::Starting => Some(BoardColumn::Initializing),
RunStatus::Running | RunStatus::Paused { .. } => Some(BoardColumn::Running),
RunStatus::Blocked { .. } => Some(BoardColumn::Blocked),
RunStatus::Succeeded { .. } => Some(BoardColumn::Succeeded),
RunStatus::Failed { .. } | RunStatus::Dead => Some(BoardColumn::Failed),
RunStatus::Removing | RunStatus::Archived { .. } => None,
}
}
pub(crate) fn board_columns() -> serde_json::Value {
serde_json::json!([
{"id": "queued", "name": "Queued"},
{"id": "initializing", "name": "Initializing"},
{"id": "running", "name": "Running"},
{"id": "blocked", "name": "Blocked"},
{"id": "succeeded", "name": "Succeeded"},
{"id": "failed", "name": "Failed"},
])
pub(crate) fn board_columns() -> Vec<BoardColumnDefinition> {
vec![
BoardColumnDefinition {
id: BoardColumn::Queued,
name: "Queued".into(),
},
BoardColumnDefinition {
id: BoardColumn::Initializing,
name: "Initializing".into(),
},
BoardColumnDefinition {
id: BoardColumn::Running,
name: "Running".into(),
},
BoardColumnDefinition {
id: BoardColumn::Blocked,
name: "Blocked".into(),
},
BoardColumnDefinition {
id: BoardColumn::Succeeded,
name: "Succeeded".into(),
},
BoardColumnDefinition {
id: BoardColumn::Failed,
name: "Failed".into(),
},
]
}
async fn board_run_metadata(

View file

@ -6615,8 +6615,8 @@ async fn queue_position_reported_for_queued_runs() {
let first_run_id = create_and_start_run(&app, MINIMAL_DOT).await;
let second_run_id = create_and_start_run(&app, MINIMAL_DOT).await;
// Queued runs are excluded from the board, so verify queue positions
// via the in-memory state directly.
// Queue position is tracked in memory even when queued runs are also
// visible on the board.
let runs = state.runs.lock().expect("runs lock poisoned");
let positions = compute_queue_positions(&runs);
let first_id = first_run_id.parse::<RunId>().unwrap();

View file

@ -13,9 +13,11 @@
*/
// May contain unused imports in some cases
// @ts-ignore
import type { BoardColumn } from './board-column';
export interface BoardColumnDefinition {
'id': string;
'id': BoardColumn;
'name': string;
}