mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
feat: wire web UI to real server with demo mode toggle
Server changes:
- Add /boards/runs to demo routes (delegates to list_runs)
- Fix demo get_run_status to return StoreRunSummary shape matching OpenAPI spec
- Enrich real /boards/runs to return RunListItem shape with board column mapping
(Running->working, Paused->pending, Completed->merge; others excluded)
- Update existing tests that asserted old RunStatusResponse fields from /boards/runs
Web UI changes:
- Add DemoModeProvider context and useDemoMode hook
- Hide Workflows/Insights nav items in production mode via getVisibleNavigation
- Change run-detail loader to use /runs/{id} directly instead of searching /boards/runs
- Add mapRunSummaryToRunItem for mapping server response to UI shape
- Add Graph tab, hide Stages tab in production mode, always hide Files tab
- Make run-overview and run-graph loaders resilient to 501 via apiJsonOrNull
- Add isNotImplemented and apiJsonOrNull helpers to api.ts
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2f8c379644
commit
da88a47595
21 changed files with 2498 additions and 2103 deletions
16
apps/fabro-web/app/api.test.ts
Normal file
16
apps/fabro-web/app/api.test.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
import { isNotImplemented } from "./api";
|
||||
|
||||
describe("isNotImplemented", () => {
|
||||
test("returns true for 501 status", () => {
|
||||
expect(isNotImplemented(501)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for 200 status", () => {
|
||||
expect(isNotImplemented(200)).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for 404 status", () => {
|
||||
expect(isNotImplemented(404)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -27,6 +27,27 @@ export async function apiJson<T>(path: string, options?: ApiOptions): Promise<T>
|
|||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export function isNotImplemented(status: number): boolean {
|
||||
return status === 501;
|
||||
}
|
||||
|
||||
export async function apiJsonOrNull<T>(
|
||||
path: string,
|
||||
options?: ApiOptions,
|
||||
): Promise<T | null> {
|
||||
const response = await apiFetch(path, options);
|
||||
if (isNotImplemented(response.status)) {
|
||||
return null;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Response(null, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
});
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export async function getSetupStatus(): Promise<{ configured: boolean }> {
|
||||
const response = await fetch("/api/v1/setup/status", { credentials: "include" });
|
||||
if (!response.ok) {
|
||||
|
|
|
|||
49
apps/fabro-web/app/data/runs.test.ts
Normal file
49
apps/fabro-web/app/data/runs.test.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
import { mapRunSummaryToRunItem } from "./runs";
|
||||
|
||||
describe("mapRunSummaryToRunItem", () => {
|
||||
test("maps store run summary to RunItem", () => {
|
||||
const summary = {
|
||||
run_id: "01ABC",
|
||||
goal: "Fix the build",
|
||||
workflow_slug: "fix_build",
|
||||
workflow_name: "Fix Build",
|
||||
host_repo_path: "/home/user/myrepo",
|
||||
status: "running",
|
||||
duration_ms: 65000,
|
||||
total_usd_micros: 500000,
|
||||
labels: {},
|
||||
start_time: "2026-04-08T12:00:00Z",
|
||||
status_reason: null,
|
||||
pending_control: null,
|
||||
};
|
||||
const item = mapRunSummaryToRunItem(summary);
|
||||
expect(item.id).toBe("01ABC");
|
||||
expect(item.title).toBe("Fix the build");
|
||||
expect(item.workflow).toBe("fix_build");
|
||||
expect(item.repo).toBe("myrepo");
|
||||
expect(item.elapsed).toBeDefined();
|
||||
});
|
||||
|
||||
test("handles missing optional fields", () => {
|
||||
const summary = {
|
||||
run_id: "01DEF",
|
||||
goal: null,
|
||||
workflow_slug: null,
|
||||
workflow_name: null,
|
||||
host_repo_path: null,
|
||||
status: "submitted",
|
||||
duration_ms: null,
|
||||
total_usd_micros: null,
|
||||
labels: {},
|
||||
start_time: null,
|
||||
status_reason: null,
|
||||
pending_control: null,
|
||||
};
|
||||
const item = mapRunSummaryToRunItem(summary);
|
||||
expect(item.id).toBe("01DEF");
|
||||
expect(item.title).toBe("Untitled run");
|
||||
expect(item.workflow).toBe("unknown");
|
||||
expect(item.repo).toBe("unknown");
|
||||
});
|
||||
});
|
||||
|
|
@ -66,6 +66,36 @@ export function mapRunListItem(item: RunListItem): RunItem {
|
|||
};
|
||||
}
|
||||
|
||||
export interface RunSummaryResponse {
|
||||
run_id: string;
|
||||
goal: string | null;
|
||||
workflow_slug: string | null;
|
||||
workflow_name: string | null;
|
||||
host_repo_path: string | null;
|
||||
status: string | null;
|
||||
status_reason: string | null;
|
||||
pending_control: string | null;
|
||||
duration_ms: number | null;
|
||||
total_usd_micros: number | null;
|
||||
labels: Record<string, string>;
|
||||
start_time: string | null;
|
||||
}
|
||||
|
||||
export function mapRunSummaryToRunItem(summary: RunSummaryResponse): RunItem {
|
||||
const repoPath = summary.host_repo_path ?? "";
|
||||
const repoName = repoPath.split("/").pop() || "unknown";
|
||||
return {
|
||||
id: summary.run_id,
|
||||
repo: repoName,
|
||||
title: summary.goal ?? "Untitled run",
|
||||
workflow: summary.workflow_slug ?? "unknown",
|
||||
elapsed:
|
||||
summary.duration_ms != null
|
||||
? formatElapsedSecs(summary.duration_ms / 1000)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function deriveCiStatus(checks: CheckRun[]): CiStatus {
|
||||
if (checks.some((c) => c.status === "failure")) return "failing";
|
||||
if (checks.some((c) => c.status === "pending" || c.status === "queued")) return "pending";
|
||||
|
|
|
|||
20
apps/fabro-web/app/layouts/app-shell.test.tsx
Normal file
20
apps/fabro-web/app/layouts/app-shell.test.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
import { getVisibleNavigation } from "./app-shell";
|
||||
|
||||
describe("getVisibleNavigation", () => {
|
||||
test("shows all nav items in demo mode", () => {
|
||||
const items = getVisibleNavigation(true);
|
||||
const names = items.map((i) => i.name);
|
||||
expect(names).toContain("Workflows");
|
||||
expect(names).toContain("Runs");
|
||||
expect(names).toContain("Insights");
|
||||
});
|
||||
|
||||
test("hides Workflows and Insights in production mode", () => {
|
||||
const items = getVisibleNavigation(false);
|
||||
const names = items.map((i) => i.name);
|
||||
expect(names).not.toContain("Workflows");
|
||||
expect(names).not.toContain("Insights");
|
||||
expect(names).toContain("Runs");
|
||||
});
|
||||
});
|
||||
|
|
@ -19,18 +19,23 @@ import {
|
|||
} from "@heroicons/react/24/outline";
|
||||
import { Form, Link, Outlet, redirect, useLocation, useMatches, useRevalidator } from "react-router";
|
||||
import { getAuthMe } from "../api";
|
||||
import { DemoModeProvider } from "../lib/demo-mode";
|
||||
import { useTheme } from "../lib/theme";
|
||||
|
||||
export async function loader() {
|
||||
return getAuthMe();
|
||||
}
|
||||
|
||||
const navigation = [
|
||||
{ name: "Workflows", href: "/workflows", icon: RectangleStackIcon },
|
||||
{ name: "Runs", href: "/runs", icon: PlayIcon },
|
||||
{ name: "Insights", href: "/insights", icon: ChartBarIcon },
|
||||
const allNavigation = [
|
||||
{ name: "Workflows", href: "/workflows", icon: RectangleStackIcon, demoOnly: true },
|
||||
{ name: "Runs", href: "/runs", icon: PlayIcon, demoOnly: false },
|
||||
{ name: "Insights", href: "/insights", icon: ChartBarIcon, demoOnly: true },
|
||||
];
|
||||
|
||||
export function getVisibleNavigation(demoMode: boolean) {
|
||||
return allNavigation.filter((item) => !item.demoOnly || demoMode);
|
||||
}
|
||||
|
||||
function classNames(...classes: Array<string | false | null | undefined>) {
|
||||
return classes.filter(Boolean).join(" ");
|
||||
}
|
||||
|
|
@ -41,6 +46,7 @@ export default function AppShell({ loaderData }: any) {
|
|||
const matches = useMatches();
|
||||
const revalidator = useRevalidator();
|
||||
const { theme, toggle } = useTheme();
|
||||
const navigation = getVisibleNavigation(demoMode);
|
||||
const currentNav = navigation.find((item) => pathname.startsWith(item.href));
|
||||
const title = currentNav?.name ?? "";
|
||||
const lastMatch = matches[matches.length - 1];
|
||||
|
|
@ -63,6 +69,7 @@ export default function AppShell({ loaderData }: any) {
|
|||
}
|
||||
|
||||
return (
|
||||
<DemoModeProvider value={demoMode}>
|
||||
<div className="min-h-full">
|
||||
<Disclosure as="nav" className="bg-panel/50">
|
||||
<div className="px-4 sm:px-6 lg:px-8">
|
||||
|
|
@ -263,5 +270,6 @@ export default function AppShell({ loaderData }: any) {
|
|||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</DemoModeProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
29
apps/fabro-web/app/lib/demo-mode.test.tsx
Normal file
29
apps/fabro-web/app/lib/demo-mode.test.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
import { renderToString } from "react-dom/server";
|
||||
import { DemoModeProvider, useDemoMode } from "./demo-mode";
|
||||
|
||||
function TestConsumer() {
|
||||
const demoMode = useDemoMode();
|
||||
return <span data-demo={demoMode}>{demoMode ? "demo" : "prod"}</span>;
|
||||
}
|
||||
|
||||
describe("DemoModeProvider", () => {
|
||||
test("provides demo mode value to children", () => {
|
||||
const html = renderToString(
|
||||
<DemoModeProvider value={true}>
|
||||
<TestConsumer />
|
||||
</DemoModeProvider>,
|
||||
);
|
||||
expect(html).toContain("demo");
|
||||
expect(html).toContain('data-demo="true"');
|
||||
});
|
||||
|
||||
test("defaults to false", () => {
|
||||
const html = renderToString(
|
||||
<DemoModeProvider value={false}>
|
||||
<TestConsumer />
|
||||
</DemoModeProvider>,
|
||||
);
|
||||
expect(html).toContain("prod");
|
||||
});
|
||||
});
|
||||
21
apps/fabro-web/app/lib/demo-mode.tsx
Normal file
21
apps/fabro-web/app/lib/demo-mode.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { createContext, useContext } from "react";
|
||||
|
||||
const DemoModeContext = createContext(false);
|
||||
|
||||
export function DemoModeProvider({
|
||||
value,
|
||||
children,
|
||||
}: {
|
||||
value: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<DemoModeContext.Provider value={value}>
|
||||
{children}
|
||||
</DemoModeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useDemoMode(): boolean {
|
||||
return useContext(DemoModeContext);
|
||||
}
|
||||
|
|
@ -2,29 +2,40 @@ import { useEffect } from "react";
|
|||
import { ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/20/solid";
|
||||
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
|
||||
import { Link, Outlet, useFetcher, useLocation } from "react-router";
|
||||
import { columnNames, mapRunListItem, statusColors } from "../data/runs";
|
||||
import type { ColumnStatus } from "../data/runs";
|
||||
import { columnNames, mapRunSummaryToRunItem, statusColors } from "../data/runs";
|
||||
import type { ColumnStatus, RunSummaryResponse } from "../data/runs";
|
||||
import { apiJson } from "../api";
|
||||
import type { PaginatedRunList, PreviewUrlResponse } from "@qltysh/fabro-api-client";
|
||||
import { useDemoMode } from "../lib/demo-mode";
|
||||
import type { PreviewUrlResponse } from "@qltysh/fabro-api-client";
|
||||
|
||||
const tabs = [
|
||||
{ name: "Overview", path: "", count: null },
|
||||
{ name: "Stages", path: "/stages/detect-drift", count: null },
|
||||
{ name: "Files Changed", path: "/files", count: null },
|
||||
{ name: "Billing", path: "/billing", count: null },
|
||||
const allTabs = [
|
||||
{ name: "Overview", path: "", count: null, demoOnly: false, broken: false },
|
||||
{ name: "Stages", path: "/stages/detect-drift", count: null, demoOnly: true, broken: false },
|
||||
{ name: "Files Changed", path: "/files", count: null, demoOnly: false, broken: true },
|
||||
{ name: "Graph", path: "/graph", count: null, demoOnly: false, broken: false },
|
||||
{ name: "Billing", path: "/billing", count: null, demoOnly: false, broken: false },
|
||||
];
|
||||
|
||||
export const handle = { hideHeader: true };
|
||||
|
||||
export async function loader({ request, params }: any) {
|
||||
const response = await apiJson<PaginatedRunList>("/boards/runs", { request });
|
||||
const apiRun = response.data.find((r) => r.id === params.id);
|
||||
if (!apiRun) return { run: null };
|
||||
const response = await fetch(`/api/v1/runs/${params.id}`, {
|
||||
credentials: "include",
|
||||
});
|
||||
if (!response.ok) return { run: null };
|
||||
const summary: RunSummaryResponse = await response.json();
|
||||
const item = mapRunSummaryToRunItem(summary);
|
||||
const statusMap: Record<string, ColumnStatus> = {
|
||||
running: "working",
|
||||
paused: "pending",
|
||||
completed: "merge",
|
||||
};
|
||||
const status = statusMap[summary.status ?? ""] ?? "working";
|
||||
return {
|
||||
run: {
|
||||
...mapRunListItem(apiRun),
|
||||
status: apiRun.status as ColumnStatus,
|
||||
statusLabel: columnNames[apiRun.status as ColumnStatus] ?? apiRun.status,
|
||||
...item,
|
||||
status,
|
||||
statusLabel: columnNames[status] ?? summary.status ?? "Unknown",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -54,6 +65,8 @@ export default function RunDetail({ loaderData, params }: any) {
|
|||
const { pathname } = useLocation();
|
||||
const basePath = `/runs/${params.id}`;
|
||||
const previewFetcher = useFetcher<PreviewUrlResponse>();
|
||||
const demoMode = useDemoMode();
|
||||
const tabs = allTabs.filter((t) => !t.broken && (!t.demoOnly || demoMode));
|
||||
|
||||
useEffect(() => {
|
||||
if (previewFetcher.data?.url) {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { CheckCircleIcon, ArrowPathIcon, PauseCircleIcon, XCircleIcon } from "@h
|
|||
import { DocumentTextIcon, MapIcon } from "@heroicons/react/24/outline";
|
||||
import { useTheme } from "../lib/theme";
|
||||
import { getGraphTheme } from "../lib/graph-theme";
|
||||
import { apiFetch, apiJson } from "../api";
|
||||
import { apiFetch, apiJsonOrNull } from "../api";
|
||||
import { formatDurationSecs } from "../lib/format";
|
||||
import type { PaginatedRunStageList } from "@qltysh/fabro-api-client";
|
||||
|
||||
|
|
@ -22,11 +22,11 @@ interface Stage {
|
|||
}
|
||||
|
||||
export async function loader({ request, params }: any) {
|
||||
const [{ data: apiStages }, graphRes] = await Promise.all([
|
||||
apiJson<PaginatedRunStageList>(`/runs/${params.id}/stages`, { request }),
|
||||
const [stagesResult, graphRes] = await Promise.all([
|
||||
apiJsonOrNull<PaginatedRunStageList>(`/runs/${params.id}/stages`, { request }),
|
||||
apiFetch(`/runs/${params.id}/graph`, { request }),
|
||||
]);
|
||||
const stages: Stage[] = apiStages.map((s) => ({
|
||||
const stages: Stage[] = (stagesResult?.data ?? []).map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
dotId: s.dot_id ?? s.id,
|
||||
|
|
|
|||
|
|
@ -5,10 +5,9 @@ import { CheckCircleIcon, ArrowPathIcon, PauseCircleIcon, XCircleIcon } from "@h
|
|||
import { DocumentTextIcon, MapIcon } from "@heroicons/react/24/outline";
|
||||
import { useTheme } from "../lib/theme";
|
||||
import { getGraphTheme } from "../lib/graph-theme";
|
||||
import { apiJson } from "../api";
|
||||
import { apiJsonOrNull } from "../api";
|
||||
import { formatDurationSecs } from "../lib/format";
|
||||
import type { PaginatedRunStageList, PaginatedRunList } from "@qltysh/fabro-api-client";
|
||||
import type { WorkflowDetailResponse } from "../lib/workflow-api";
|
||||
import type { PaginatedRunStageList } from "@qltysh/fabro-api-client";
|
||||
|
||||
export const handle = { wide: true };
|
||||
|
||||
|
|
@ -22,27 +21,17 @@ interface Stage {
|
|||
}
|
||||
|
||||
export async function loader({ request, params }: any) {
|
||||
const [{ data: apiStages }, response] = await Promise.all([
|
||||
apiJson<PaginatedRunStageList>(`/runs/${params.id}/stages`, { request }),
|
||||
apiJson<PaginatedRunList>("/boards/runs", { request }),
|
||||
]);
|
||||
const stages: Stage[] = apiStages.map((s) => ({
|
||||
const stagesResult = await apiJsonOrNull<PaginatedRunStageList>(
|
||||
`/runs/${params.id}/stages`,
|
||||
{ request },
|
||||
);
|
||||
const stages: Stage[] = (stagesResult?.data ?? []).map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
status: s.status as StageStatus,
|
||||
duration: s.duration_secs != null ? formatDurationSecs(s.duration_secs) : "--",
|
||||
}));
|
||||
const run = response.data.find((r) => r.id === params.id);
|
||||
let graphDot: string | null = null;
|
||||
if (run) {
|
||||
try {
|
||||
const workflow = await apiJson<WorkflowDetailResponse>(`/workflows/${run.workflow}`, { request });
|
||||
graphDot = workflow.graph;
|
||||
} catch {
|
||||
// workflow not found — leave graphDot null
|
||||
}
|
||||
}
|
||||
return { stages, graphDot };
|
||||
return { stages, graphDot: null };
|
||||
}
|
||||
|
||||
const statusConfig: Record<StageStatus, { icon: typeof CheckCircleIcon; color: string }> = {
|
||||
|
|
|
|||
13
apps/fabro-web/dist/assets/chunk-90qzx8bn.js
vendored
13
apps/fabro-web/dist/assets/chunk-90qzx8bn.js
vendored
File diff suppressed because one or more lines are too long
10
apps/fabro-web/dist/assets/chunk-90qzx8bn.js.map
vendored
10
apps/fabro-web/dist/assets/chunk-90qzx8bn.js.map
vendored
File diff suppressed because one or more lines are too long
13
apps/fabro-web/dist/assets/chunk-a51kkeyv.js
vendored
Normal file
13
apps/fabro-web/dist/assets/chunk-a51kkeyv.js
vendored
Normal file
File diff suppressed because one or more lines are too long
10
apps/fabro-web/dist/assets/chunk-a51kkeyv.js.map
vendored
Normal file
10
apps/fabro-web/dist/assets/chunk-a51kkeyv.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
1944
apps/fabro-web/dist/assets/entry-3m7f90fs.js
vendored
Normal file
1944
apps/fabro-web/dist/assets/entry-3m7f90fs.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1944
apps/fabro-web/dist/assets/entry-r31bcs2m.js
vendored
1944
apps/fabro-web/dist/assets/entry-r31bcs2m.js
vendored
File diff suppressed because one or more lines are too long
4
apps/fabro-web/dist/index.html
vendored
4
apps/fabro-web/dist/index.html
vendored
|
|
@ -61,14 +61,14 @@
|
|||
<script type="module" src="/assets/chunk-sadshphz.js"></script>
|
||||
<script type="module" src="/assets/chunk-pmthkscp.js"></script>
|
||||
<script type="module" src="/assets/chunk-v61ks9f7.js"></script>
|
||||
<script type="module" src="/assets/entry-r31bcs2m.js"></script>
|
||||
<script type="module" src="/assets/entry-3m7f90fs.js"></script>
|
||||
<script type="module" src="/assets/chunk-n1k68xa8.js"></script>
|
||||
<script type="module" src="/assets/chunk-rsph5pvm.js"></script>
|
||||
<script type="module" src="/assets/chunk-9t57pdty.js"></script>
|
||||
<script type="module" src="/assets/chunk-pectm3zk.js"></script>
|
||||
<script type="module" src="/assets/chunk-kgq5332v.js"></script>
|
||||
<script type="module" src="/assets/chunk-q07bg6gn.js"></script>
|
||||
<script type="module" src="/assets/chunk-90qzx8bn.js"></script>
|
||||
<script type="module" src="/assets/chunk-a51kkeyv.js"></script>
|
||||
<script type="module" src="/assets/chunk-ysp46zk7.js"></script>
|
||||
<script type="module" src="/assets/chunk-m2sbqbtm.js"></script>
|
||||
<script type="module" src="/assets/chunk-d0p7g53p.js"></script>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ use axum::extract::{Path, Query, State};
|
|||
use axum::http::StatusCode;
|
||||
use axum::response::sse::{Event, Sse};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use fabro_api::types::{RunArtifactListResponse, RunStatus, RunStatusResponse};
|
||||
use fabro_api::types::RunArtifactListResponse;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::error::ApiError;
|
||||
|
|
@ -42,6 +42,14 @@ pub(crate) async fn list_runs(
|
|||
paginated_response(runs::list_items(), &pagination)
|
||||
}
|
||||
|
||||
pub(crate) async fn list_board_runs(
|
||||
auth: AuthenticatedService,
|
||||
state: State<Arc<AppState>>,
|
||||
pagination: Query<PaginationParams>,
|
||||
) -> Response {
|
||||
list_runs(auth, state, pagination).await
|
||||
}
|
||||
|
||||
pub(crate) async fn create_run_stub(
|
||||
_auth: AuthenticatedService,
|
||||
State(_state): State<Arc<AppState>>,
|
||||
|
|
@ -176,19 +184,30 @@ pub(crate) async fn get_run_status(
|
|||
Path(id): Path<String>,
|
||||
) -> Response {
|
||||
match runs::list_items().into_iter().find(|r| r.id == id) {
|
||||
Some(item) => (
|
||||
StatusCode::OK,
|
||||
Json(RunStatusResponse {
|
||||
id: id.clone(),
|
||||
status: RunStatus::Running,
|
||||
error: None,
|
||||
queue_position: None,
|
||||
status_reason: None,
|
||||
pending_control: None,
|
||||
created_at: item.created_at,
|
||||
}),
|
||||
)
|
||||
.into_response(),
|
||||
Some(item) => {
|
||||
let elapsed_ms = item
|
||||
.timings
|
||||
.as_ref()
|
||||
.map(|t| (t.elapsed_secs * 1000.0) as u64);
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(json!({
|
||||
"run_id": item.id,
|
||||
"goal": item.title,
|
||||
"workflow_slug": item.workflow.slug,
|
||||
"workflow_name": item.workflow.slug,
|
||||
"host_repo_path": format!("/demo/{}", item.repository.name),
|
||||
"labels": {},
|
||||
"start_time": item.created_at.to_rfc3339(),
|
||||
"status": "running",
|
||||
"status_reason": null,
|
||||
"pending_control": null,
|
||||
"duration_ms": elapsed_ms,
|
||||
"total_usd_micros": null,
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
None => ApiError::not_found("Run not found.").into_response(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -847,6 +847,7 @@ pub fn build_router(state: Arc<AppState>, auth_mode: AuthMode) -> Router {
|
|||
fn demo_routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
.route("/runs", get(demo::list_runs).post(demo::create_run_stub))
|
||||
.route("/boards/runs", get(demo::list_board_runs))
|
||||
.route("/preflight", post(run_preflight))
|
||||
.route("/graph/render", post(render_graph_from_manifest))
|
||||
.route("/attach", get(demo::attach_events_stub))
|
||||
|
|
@ -2014,25 +2015,25 @@ fn test_config_path() -> PathBuf {
|
|||
std::env::temp_dir().join(format!("fabro-test-settings-{}.toml", Ulid::new()))
|
||||
}
|
||||
|
||||
fn board_column(status: RunStatus) -> Option<&'static str> {
|
||||
match status {
|
||||
RunStatus::Running => Some("working"),
|
||||
RunStatus::Paused => Some("pending"),
|
||||
RunStatus::Completed => Some("merge"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_board_runs(
|
||||
_auth: AuthenticatedService,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(pagination): Query<PaginationParams>,
|
||||
) -> Response {
|
||||
let live_runs = {
|
||||
let live_runs: Vec<(RunId, RunStatus, chrono::DateTime<chrono::Utc>)> = {
|
||||
let runs = state.runs.lock().expect("runs lock poisoned");
|
||||
let queue_positions = compute_queue_positions(&runs);
|
||||
runs.iter()
|
||||
.map(|(id, managed_run)| {
|
||||
(
|
||||
*id,
|
||||
managed_run.status,
|
||||
managed_run.error.clone(),
|
||||
queue_positions.get(id).copied(),
|
||||
managed_run.created_at,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.map(|(id, managed_run)| (*id, managed_run.status, managed_run.created_at))
|
||||
.collect()
|
||||
};
|
||||
let summaries = match state
|
||||
.store
|
||||
|
|
@ -2048,27 +2049,40 @@ async fn list_board_runs(
|
|||
.into_response();
|
||||
}
|
||||
};
|
||||
let limit = pagination.limit.clamp(1, 100) as usize;
|
||||
let offset = pagination.offset as usize;
|
||||
let all_items: Vec<RunStatusResponse> = live_runs
|
||||
let all_items: Vec<serde_json::Value> = live_runs
|
||||
.iter()
|
||||
.map(|(id, status, error, queue_position, created_at)| {
|
||||
.filter_map(|(id, status, created_at)| {
|
||||
let column = board_column(*status)?;
|
||||
let summary = summaries.get(id);
|
||||
RunStatusResponse {
|
||||
id: id.to_string(),
|
||||
status: *status,
|
||||
error: error.as_ref().map(|msg| RunError {
|
||||
message: msg.clone(),
|
||||
}),
|
||||
queue_position: *queue_position,
|
||||
status_reason: summary
|
||||
.and_then(|summary| summary.status_reason.map(api_status_reason)),
|
||||
pending_control: summary
|
||||
.and_then(|summary| summary.pending_control.map(api_pending_control)),
|
||||
created_at: *created_at,
|
||||
}
|
||||
let title = summary
|
||||
.and_then(|s| s.goal.as_deref())
|
||||
.unwrap_or("Untitled run");
|
||||
let workflow_slug = summary
|
||||
.and_then(|s| s.workflow_slug.as_deref())
|
||||
.unwrap_or("unknown");
|
||||
let workflow_name = summary
|
||||
.and_then(|s| s.workflow_name.as_deref())
|
||||
.unwrap_or(workflow_slug);
|
||||
let repo_name = summary
|
||||
.and_then(|s| s.host_repo_path.as_deref())
|
||||
.and_then(|p| p.rsplit('/').next())
|
||||
.unwrap_or("unknown");
|
||||
let elapsed_secs = summary
|
||||
.and_then(|s| s.duration_ms)
|
||||
.map(|ms| ms as f64 / 1000.0);
|
||||
Some(serde_json::json!({
|
||||
"id": id.to_string(),
|
||||
"title": title,
|
||||
"repository": { "name": repo_name },
|
||||
"workflow": { "slug": workflow_slug, "name": workflow_name },
|
||||
"status": column,
|
||||
"created_at": created_at.to_rfc3339(),
|
||||
"timings": elapsed_secs.map(|s| serde_json::json!({ "elapsed_secs": s })),
|
||||
}))
|
||||
})
|
||||
.collect();
|
||||
let limit = pagination.limit.clamp(1, 100) as usize;
|
||||
let offset = pagination.offset as usize;
|
||||
let page: Vec<_> = all_items.into_iter().skip(offset).take(limit + 1).collect();
|
||||
let has_more = page.len() > limit;
|
||||
let data: Vec<_> = page.into_iter().take(limit).collect();
|
||||
|
|
@ -2228,6 +2242,7 @@ fn remove_run_dir(run_dir: &std::path::Path) -> std::io::Result<()> {
|
|||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn compute_queue_positions(runs: &HashMap<RunId, ManagedRun>) -> HashMap<RunId, i64> {
|
||||
let mut queued: Vec<(&RunId, &ManagedRun)> = runs
|
||||
.iter()
|
||||
|
|
@ -7458,6 +7473,7 @@ mod tests {
|
|||
assert_eq!(body["status"].as_str().unwrap(), "failed");
|
||||
assert_eq!(body["status_reason"].as_str().unwrap(), "cancelled");
|
||||
|
||||
// Cancelled (failed) runs are excluded from the board
|
||||
let req = Request::builder()
|
||||
.method("GET")
|
||||
.uri(api("/boards/runs"))
|
||||
|
|
@ -7466,14 +7482,12 @@ mod tests {
|
|||
let response = app.clone().oneshot(req).await.unwrap();
|
||||
let body = body_json(response.into_body()).await;
|
||||
let run_id_str = run_id.to_string();
|
||||
let item = body["data"]
|
||||
let found = body["data"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|item| item["id"].as_str() == Some(run_id_str.as_str()))
|
||||
.expect("board item should exist");
|
||||
assert_eq!(item["status_reason"].as_str(), Some("cancelled"));
|
||||
assert!(item["pending_control"].is_null());
|
||||
.any(|item| item["id"].as_str() == Some(run_id_str.as_str()));
|
||||
assert!(!found, "cancelled run should not appear on the board");
|
||||
|
||||
let run_store = state.store.open_run_reader(&run_id).await.unwrap();
|
||||
let status = run_store.state().await.unwrap().status.unwrap();
|
||||
|
|
@ -7566,6 +7580,17 @@ mod tests {
|
|||
assert_eq!(body["status"].as_str(), Some("running"));
|
||||
assert_eq!(body["pending_control"].as_str(), Some("pause"));
|
||||
|
||||
// Verify pending_control via /runs/{id} (board no longer includes this field)
|
||||
let req = Request::builder()
|
||||
.method("GET")
|
||||
.uri(api(&format!("/runs/{run_id}")))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let response = app.clone().oneshot(req).await.unwrap();
|
||||
let body = body_json(response.into_body()).await;
|
||||
assert_eq!(body["pending_control"].as_str(), Some("pause"));
|
||||
|
||||
// Verify the run appears on the board with "working" status
|
||||
let req = Request::builder()
|
||||
.method("GET")
|
||||
.uri(api("/boards/runs"))
|
||||
|
|
@ -7579,7 +7604,7 @@ mod tests {
|
|||
.iter()
|
||||
.find(|item| item["id"].as_str() == Some(run_id_str.as_str()))
|
||||
.expect("board item should exist");
|
||||
assert_eq!(item["pending_control"].as_str(), Some("pause"));
|
||||
assert_eq!(item["status"].as_str(), Some("working"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -7860,39 +7885,26 @@ mod tests {
|
|||
#[tokio::test]
|
||||
async fn queue_position_reported_for_queued_runs() {
|
||||
let state = create_app_state();
|
||||
let app = build_router(state, AuthMode::Disabled);
|
||||
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
|
||||
|
||||
// Create and start two runs (no scheduler, both stay queued)
|
||||
let first_run_id = create_and_start_run(&app, MINIMAL_DOT).await;
|
||||
let second_run_id = create_and_start_run(&app, MINIMAL_DOT).await;
|
||||
|
||||
// Check queue positions via the live board endpoint.
|
||||
let req = Request::builder()
|
||||
.method("GET")
|
||||
.uri(api("/boards/runs"))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let response = app.clone().oneshot(req).await.unwrap();
|
||||
let body = body_json(response.into_body()).await;
|
||||
let items = body["data"].as_array().unwrap();
|
||||
|
||||
let first = items
|
||||
.iter()
|
||||
.find(|item| item["id"].as_str() == Some(first_run_id.as_str()))
|
||||
.unwrap();
|
||||
assert_eq!(first["queue_position"].as_i64().unwrap(), 1);
|
||||
|
||||
let second = items
|
||||
.iter()
|
||||
.find(|item| item["id"].as_str() == Some(second_run_id.as_str()))
|
||||
.unwrap();
|
||||
assert_eq!(second["queue_position"].as_i64().unwrap(), 2);
|
||||
// Queued runs are excluded from the board, so verify queue positions
|
||||
// via the in-memory state directly.
|
||||
let runs = state.runs.lock().expect("runs lock poisoned");
|
||||
let positions = compute_queue_positions(&runs);
|
||||
let first_id = first_run_id.parse::<RunId>().unwrap();
|
||||
let second_id = second_run_id.parse::<RunId>().unwrap();
|
||||
assert_eq!(positions.get(&first_id).copied(), Some(1));
|
||||
assert_eq!(positions.get(&second_id).copied(), Some(2));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn concurrency_limit_respected() {
|
||||
let state = create_app_state_with_options(Settings::default(), 1);
|
||||
let app = test_app_with_scheduler(state);
|
||||
let app = test_app_with_scheduler(Arc::clone(&state));
|
||||
|
||||
// Create and start two runs with max_concurrent_runs=1
|
||||
create_and_start_run(&app, MINIMAL_DOT).await;
|
||||
|
|
@ -7901,7 +7913,9 @@ mod tests {
|
|||
// Give scheduler time to pick up the first run
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
|
||||
// Check statuses: at most 1 should be starting/running, the other queued
|
||||
// Board only shows runs with board-column statuses (Running -> "working",
|
||||
// Paused -> "pending", Completed -> "merge"). Queued/Starting/Failed are excluded.
|
||||
// With max_concurrent_runs=1, at most 1 should be active on the board.
|
||||
let req = Request::builder()
|
||||
.method("GET")
|
||||
.uri(api("/boards/runs"))
|
||||
|
|
@ -7912,16 +7926,11 @@ mod tests {
|
|||
let items = body["data"].as_array().unwrap();
|
||||
let active_count = items
|
||||
.iter()
|
||||
.filter(|item| {
|
||||
let s = item["status"].as_str().unwrap();
|
||||
s == "starting" || s == "running"
|
||||
})
|
||||
.filter(|item| item["status"].as_str() == Some("working"))
|
||||
.count();
|
||||
// With max_concurrent_runs=1, at most 1 should be active
|
||||
// (the first one might have completed already, so active could be 0 or 1)
|
||||
assert!(
|
||||
active_count <= 1,
|
||||
"expected at most 1 active run, got {active_count}"
|
||||
"expected at most 1 active run on the board, got {active_count}"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -8030,4 +8039,174 @@ mod tests {
|
|||
let response = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn demo_boards_runs_returns_run_list_items() {
|
||||
let state = create_app_state();
|
||||
let app = build_router(state, AuthMode::Disabled);
|
||||
let req = Request::builder()
|
||||
.method("GET")
|
||||
.uri(api("/boards/runs"))
|
||||
.header("X-Fabro-Demo", "1")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let response = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = body_json(response.into_body()).await;
|
||||
let data = body["data"].as_array().expect("data should be array");
|
||||
assert!(!data.is_empty(), "demo should return runs");
|
||||
let first = &data[0];
|
||||
assert!(first["id"].is_string());
|
||||
assert!(first["repository"].is_object());
|
||||
assert!(first["title"].is_string());
|
||||
assert!(first["workflow"].is_object());
|
||||
assert!(first["status"].is_string());
|
||||
assert!(first["created_at"].is_string());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn demo_get_run_returns_store_run_summary_shape() {
|
||||
let state = create_app_state();
|
||||
let app = build_router(state, AuthMode::Disabled);
|
||||
let req = Request::builder()
|
||||
.method("GET")
|
||||
.uri(api("/runs/run-1"))
|
||||
.header("X-Fabro-Demo", "1")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let response = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = body_json(response.into_body()).await;
|
||||
// Should have StoreRunSummary fields, not RunStatusResponse fields
|
||||
assert!(body["run_id"].is_string(), "should have run_id field");
|
||||
assert!(body["goal"].is_string(), "should have goal field");
|
||||
assert!(
|
||||
body["workflow_slug"].is_string(),
|
||||
"should have workflow_slug field"
|
||||
);
|
||||
// Should NOT have RunStatusResponse-only fields
|
||||
assert!(
|
||||
body["queue_position"].is_null(),
|
||||
"should not have queue_position"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn demo_get_run_returns_404_for_unknown_run() {
|
||||
let state = create_app_state();
|
||||
let app = build_router(state, AuthMode::Disabled);
|
||||
let req = Request::builder()
|
||||
.method("GET")
|
||||
.uri(api("/runs/nonexistent-run-id"))
|
||||
.header("X-Fabro-Demo", "1")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let response = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn boards_runs_returns_run_list_items_with_board_columns() {
|
||||
let state = create_app_state();
|
||||
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
|
||||
let run_id = create_and_start_run(&app, MINIMAL_DOT).await;
|
||||
|
||||
// Set run to running so it appears on the board
|
||||
{
|
||||
let id = run_id.parse::<RunId>().unwrap();
|
||||
let mut runs = state.runs.lock().expect("runs lock poisoned");
|
||||
let managed_run = runs.get_mut(&id).expect("run should exist");
|
||||
managed_run.status = RunStatus::Running;
|
||||
}
|
||||
|
||||
let req = Request::builder()
|
||||
.method("GET")
|
||||
.uri(api("/boards/runs"))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let response = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = body_json(response.into_body()).await;
|
||||
let data = body["data"].as_array().expect("data should be array");
|
||||
let item = data
|
||||
.iter()
|
||||
.find(|i| i["id"].as_str() == Some(&run_id))
|
||||
.expect("run should be in board");
|
||||
// Should have RunListItem fields
|
||||
assert!(item["title"].is_string());
|
||||
assert!(item["repository"].is_object());
|
||||
assert!(item["workflow"].is_object());
|
||||
// Status should be a board column, not a lifecycle status
|
||||
let status = item["status"].as_str().unwrap();
|
||||
assert!(
|
||||
["working", "pending", "review", "merge"].contains(&status),
|
||||
"status should be a board column, got: {status}"
|
||||
);
|
||||
assert!(item["created_at"].is_string());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn boards_runs_excludes_non_board_statuses() {
|
||||
let state = create_app_state();
|
||||
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
|
||||
let run_id_str = create_and_start_run(&app, MINIMAL_DOT).await;
|
||||
let run_id = run_id_str.parse::<RunId>().unwrap();
|
||||
|
||||
// Set run to Failed — should not appear on the board
|
||||
{
|
||||
let mut runs = state.runs.lock().expect("runs lock poisoned");
|
||||
let managed_run = runs.get_mut(&run_id).expect("run should exist");
|
||||
managed_run.status = RunStatus::Failed;
|
||||
}
|
||||
|
||||
let req = Request::builder()
|
||||
.method("GET")
|
||||
.uri(api("/boards/runs"))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let response = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = body_json(response.into_body()).await;
|
||||
let data = body["data"].as_array().expect("data should be array");
|
||||
let found = data.iter().any(|i| i["id"].as_str() == Some(&run_id_str));
|
||||
assert!(!found, "failed run should not appear on the board");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn boards_runs_maps_paused_to_pending_and_completed_to_merge() {
|
||||
let state = create_app_state();
|
||||
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
|
||||
|
||||
let paused_id_str = create_and_start_run(&app, MINIMAL_DOT).await;
|
||||
let paused_id = paused_id_str.parse::<RunId>().unwrap();
|
||||
let completed_id_str = create_and_start_run(&app, MINIMAL_DOT).await;
|
||||
let completed_id = completed_id_str.parse::<RunId>().unwrap();
|
||||
|
||||
{
|
||||
let mut runs = state.runs.lock().expect("runs lock poisoned");
|
||||
runs.get_mut(&paused_id).unwrap().status = RunStatus::Paused;
|
||||
runs.get_mut(&completed_id).unwrap().status = RunStatus::Completed;
|
||||
}
|
||||
|
||||
let req = Request::builder()
|
||||
.method("GET")
|
||||
.uri(api("/boards/runs"))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let response = app.oneshot(req).await.unwrap();
|
||||
let body = body_json(response.into_body()).await;
|
||||
let data = body["data"].as_array().expect("data should be array");
|
||||
|
||||
let paused_item = data
|
||||
.iter()
|
||||
.find(|i| i["id"].as_str() == Some(&paused_id_str))
|
||||
.expect("paused run should be on board");
|
||||
assert_eq!(paused_item["status"].as_str().unwrap(), "pending");
|
||||
|
||||
let completed_item = data
|
||||
.iter()
|
||||
.find(|i| i["id"].as_str() == Some(&completed_id_str))
|
||||
.expect("completed run should be on board");
|
||||
assert_eq!(completed_item["status"].as_str().unwrap(), "merge");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue