Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions apps/gittensory-miner-ui/src/lib/run-history.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Read-only client for the local run-state API (#4305). The dashboard is a browser app and the miner's stores
// are `node:sqlite` files on disk, so the view never touches SQL — it fetches the dev server's local read-only
// endpoint (see `vite-run-state-api.ts`), which itself calls into `packages/gittensory-miner/lib/run-state.js`'s
// existing exports.

export const RUN_STATE_API_PATH = "/api/run-state";

/** One `miner_run_state` row as served by the local API — mirrors `run-state.js`'s row shape. */
export type RunStateRow = {
repoFullName: string;
state: "idle" | "discovering" | "planning" | "preparing";
updatedAt: string;
};

export type RunHistoryResult = { ok: true; rows: RunStateRow[] } | { ok: false; error: string };

function isRunStateRow(value: unknown): value is RunStateRow {
if (typeof value !== "object" || value === null) return false;
const row = value as Record<string, unknown>;
return (
typeof row.repoFullName === "string" &&
typeof row.updatedAt === "string" &&
(row.state === "idle" || row.state === "discovering" || row.state === "planning" || row.state === "preparing")
);
}

/** Fetch the local run-state rows. Failures (server down, malformed payload) surface as a typed error result —
* the view renders them as a message, never a crash. `fetchImpl` is injectable for tests. */
export async function fetchRunStates(fetchImpl: typeof fetch = fetch): Promise<RunHistoryResult> {
try {
const response = await fetchImpl(RUN_STATE_API_PATH);
if (!response.ok) return { ok: false, error: `local run-state API responded ${response.status}` };
const payload: unknown = await response.json();
const rows = (payload as { rows?: unknown }).rows;
if (!Array.isArray(rows) || !rows.every(isRunStateRow)) {
return { ok: false, error: "local run-state API returned an unexpected payload shape" };
}
return { ok: true, rows };
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : "failed to reach the local run-state API" };
}
}
72 changes: 46 additions & 26 deletions apps/gittensory-miner-ui/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,50 +8,70 @@
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.

import { Route as rootRouteImport } from "./routes/__root";
import { Route as IndexRouteImport } from "./routes/index";
import { Route as rootRouteImport } from './routes/__root'
import { Route as RunHistoryRouteImport } from './routes/run-history'
import { Route as IndexRouteImport } from './routes/index'

const RunHistoryRoute = RunHistoryRouteImport.update({
id: '/run-history',
path: '/run-history',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
id: "/",
path: "/",
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any);
} as any)

export interface FileRoutesByFullPath {
"/": typeof IndexRoute;
'/': typeof IndexRoute
'/run-history': typeof RunHistoryRoute
}
export interface FileRoutesByTo {
"/": typeof IndexRoute;
'/': typeof IndexRoute
'/run-history': typeof RunHistoryRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport;
"/": typeof IndexRoute;
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/run-history': typeof RunHistoryRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath;
fullPaths: "/";
fileRoutesByTo: FileRoutesByTo;
to: "/";
id: "__root__" | "/";
fileRoutesById: FileRoutesById;
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/run-history'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/run-history'
id: '__root__' | '/' | '/run-history'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute;
IndexRoute: typeof IndexRoute
RunHistoryRoute: typeof RunHistoryRoute
}

declare module "@tanstack/react-router" {
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
"/": {
id: "/";
path: "/";
fullPath: "/";
preLoaderRoute: typeof IndexRouteImport;
parentRoute: typeof rootRouteImport;
};
'/run-history': {
id: '/run-history'
path: '/run-history'
fullPath: '/run-history'
preLoaderRoute: typeof RunHistoryRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
}
}

const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
};
export const routeTree = rootRouteImport._addFileChildren(rootRouteChildren)._addFileTypes<FileRouteTypes>();
RunHistoryRoute: RunHistoryRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
5 changes: 4 additions & 1 deletion apps/gittensory-miner-ui/src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,13 @@ function RootLayout() {
<p className="text-sm uppercase tracking-[0.2em] text-emerald-300/80">Gittensory Miner</p>
<h1 className="text-lg font-semibold">Local dashboard shell</h1>
</div>
<nav className="text-sm text-white/70">
<nav className="flex gap-4 text-sm text-white/70">
<Link to="/" className="hover:text-white">
Overview
</Link>
<Link to="/run-history" className="hover:text-white">
Run history
</Link>
</nav>
</div>
</header>
Expand Down
97 changes: 97 additions & 0 deletions apps/gittensory-miner-ui/src/routes/run-history.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { createFileRoute } from "@tanstack/react-router";
import { useEffect, useState } from "react";

import { fetchRunStates, type RunHistoryResult, type RunStateRow } from "../lib/run-history";

export const Route = createFileRoute("/run-history")({
component: RunHistoryPage,
});

// Read-only run-history table (#4305): one row per repo from the local `miner_run_state` store (repo, state,
// last-updated), served by the dev server's local API. No writes, no new state — a fresh install renders the
// empty state, an unreachable API renders an error message.

const STATE_BADGE_CLASSES: Record<RunStateRow["state"], string> = {
idle: "bg-white/10 text-white/70",
discovering: "bg-sky-500/20 text-sky-200",
planning: "bg-amber-500/20 text-amber-200",
preparing: "bg-emerald-500/20 text-emerald-200",
};

export function RunHistoryView({ result }: { result: RunHistoryResult | null }) {
if (result === null) {
return <p className="text-sm text-white/60">Loading local run state…</p>;
}
if (!result.ok) {
return (
<p role="alert" className="text-sm text-rose-300">
Could not read local run state: {result.error}
</p>
);
}
if (result.rows.length === 0) {
return (
<p className="text-sm text-white/60">
No local run state yet — the table fills in once the miner records its first repo run.
</p>
);
}
return (
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-white/10 text-xs uppercase tracking-wider text-white/50">
<th scope="col" className="py-2 pr-4">
Repository
</th>
<th scope="col" className="py-2 pr-4">
State
</th>
<th scope="col" className="py-2">
Last updated
</th>
</tr>
</thead>
<tbody>
{result.rows.map((row) => (
<tr key={row.repoFullName} className="border-b border-white/5">
<td className="py-2 pr-4 font-mono text-white/90">{row.repoFullName}</td>
<td className="py-2 pr-4">
<span className={`rounded-full px-2 py-0.5 text-xs ${STATE_BADGE_CLASSES[row.state]}`}>{row.state}</span>
</td>
<td className="py-2 text-white/70">{row.updatedAt}</td>
</tr>
))}
</tbody>
</table>
);
}

export function RunHistoryPage({
loadRunStates = fetchRunStates,
}: {
loadRunStates?: () => Promise<RunHistoryResult>;
}) {
const [result, setResult] = useState<RunHistoryResult | null>(null);

useEffect(() => {
let cancelled = false;
void loadRunStates().then((loaded) => {
if (!cancelled) setResult(loaded);
});
return () => {
cancelled = true;
};
}, [loadRunStates]);

return (
<section className="rounded-xl border border-white/10 bg-white/5 p-6">
<h2 className="text-xl font-semibold">Run history</h2>
<p className="mt-1 text-sm text-white/60">
Local, read-only view over the miner&apos;s per-repo run state (`miner_run_state`).
</p>
<div className="mt-4">
<RunHistoryView result={result} />
</div>
</section>
);
}
88 changes: 88 additions & 0 deletions apps/gittensory-miner-ui/src/run-history.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it } from "vitest";

import { fetchRunStates, RUN_STATE_API_PATH, type RunHistoryResult, type RunStateRow } from "./lib/run-history";
import { RunHistoryPage, RunHistoryView } from "./routes/run-history";

const fixtureRows: RunStateRow[] = [
{ repoFullName: "acme/widgets", state: "preparing", updatedAt: "2026-07-10T06:00:00.000Z" },
{ repoFullName: "acme/gadgets", state: "idle", updatedAt: "2026-07-10T05:00:00.000Z" },
];

describe("RunHistoryView (#4305)", () => {
it("renders one table row per run-state fixture row with repo, state badge, and last-updated", () => {
render(<RunHistoryView result={{ ok: true, rows: fixtureRows }} />);
expect(screen.getByRole("columnheader", { name: "Repository" })).toBeTruthy();
expect(screen.getByText("acme/widgets")).toBeTruthy();
expect(screen.getByText("preparing")).toBeTruthy();
expect(screen.getByText("acme/gadgets")).toBeTruthy();
expect(screen.getByText("2026-07-10T05:00:00.000Z")).toBeTruthy();
expect(screen.getAllByRole("row")).toHaveLength(3); // header + 2 fixture rows
});

it("renders the fresh-install empty state without erroring", () => {
render(<RunHistoryView result={{ ok: true, rows: [] }} />);
expect(screen.getByText(/No local run state yet/i)).toBeTruthy();
expect(screen.queryByRole("table")).toBeNull();
});

it("renders an error message when the local API is unreachable", () => {
render(<RunHistoryView result={{ ok: false, error: "connection refused" }} />);
expect(screen.getByRole("alert").textContent).toContain("connection refused");
});

it("renders the loading state before the first result arrives", () => {
render(<RunHistoryView result={null} />);
expect(screen.getByText(/Loading local run state/i)).toBeTruthy();
});
});

describe("RunHistoryPage (#4305)", () => {
it("loads rows through the injected loader and renders them", async () => {
const loadRunStates = async (): Promise<RunHistoryResult> => ({ ok: true, rows: fixtureRows });
render(<RunHistoryPage loadRunStates={loadRunStates} />);
expect(screen.getByRole("heading", { name: "Run history" })).toBeTruthy();
await waitFor(() => expect(screen.getByText("acme/widgets")).toBeTruthy());
});
});

describe("fetchRunStates (#4305)", () => {
const jsonResponse = (status: number, payload: unknown) =>
({ ok: status >= 200 && status < 300, status, json: async () => payload }) as unknown as Response;

it("returns typed rows from a well-formed payload, requesting the local API path", async () => {
let requested: string | undefined;
const result = await fetchRunStates(async (input) => {
requested = String(input);
return jsonResponse(200, { rows: fixtureRows });
});
expect(requested).toBe(RUN_STATE_API_PATH);
expect(result).toEqual({ ok: true, rows: fixtureRows });
});

it("surfaces a non-2xx response as a typed error", async () => {
const result = await fetchRunStates(async () => jsonResponse(500, { error: "boom" }));
expect(result).toEqual({ ok: false, error: "local run-state API responded 500" });
});

it("rejects a malformed payload shape (missing rows / bad row fields)", async () => {
expect(await fetchRunStates(async () => jsonResponse(200, { rows: "nope" }))).toMatchObject({ ok: false });
expect(
await fetchRunStates(async () =>
jsonResponse(200, { rows: [{ repoFullName: 1, state: "idle", updatedAt: "t" }] }),
),
).toMatchObject({ ok: false });
expect(
await fetchRunStates(async () =>
jsonResponse(200, { rows: [{ repoFullName: "a/b", state: "warp", updatedAt: "t" }] }),
),
).toMatchObject({ ok: false });
});

it("surfaces a thrown fetch (server not running) as a typed error, never a crash", async () => {
const result = await fetchRunStates(async () => {
throw new Error("connection refused");
});
expect(result).toEqual({ ok: false, error: "connection refused" });
});
});
Loading
Loading