Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
073bd38
Merge remote-tracking branch 'origin/task/914' into task/916
V3RON Oct 23, 2026
b38b936
test: a re-read of an unchanged route sends the first tag, and a 304 …
V3RON Oct 23, 2026
a28db46
feat(ui): the console sends each route's last tag back and keeps its …
V3RON Oct 23, 2026
8d976dd
test: a read with a tag sends If-None-Match and answers a 304 as not …
V3RON Oct 23, 2026
3ff9e2f
feat(ui): ApiClient.get sends If-None-Match and answers a 304 as not …
V3RON Oct 23, 2026
ea8ce49
test: a re-read of an unchanged route keeps data on a 304, replaces i…
V3RON Oct 23, 2026
ded5109
feat(ui): a 304 after the data was dropped is read again without a ta…
V3RON Oct 23, 2026
001f3fd
test: a lease renewal moves the lease's deadline in place with no rea…
V3RON Oct 23, 2026
5d41810
feat(ui): a lease.renewed event moves the lease's deadline in the lea…
V3RON Oct 23, 2026
eb9a989
test: a lease renewal is matched by worker in a gateway and its deadl…
V3RON Oct 23, 2026
c68d6a2
feat(ui): renewals match a gateway's leases and the worker page's, an…
V3RON Oct 23, 2026
5190e52
docs(ui): the manual describes the ETag round trip and in-place renew…
V3RON Oct 23, 2026
264b1e0
Merge origin/task/915 into task/916
V3RON Oct 23, 2026
25a4516
refactor(ui): keep the conditional read within the complexity bound (…
V3RON Oct 23, 2026
14239a7
chore: renewal helpers stay private and the console's react-query is …
V3RON Oct 23, 2026
4b6552f
test: a renewal marks only what it leaves stale (#916)
V3RON Oct 23, 2026
bfc4376
feat(ui): a renewal marks its inbox ending item and its open lease pa…
V3RON Oct 23, 2026
97a821d
refactor(ui): keep the event handler within the complexity bound (#916)
V3RON Oct 23, 2026
b53ad53
refactor(ui): the renewal patch is its own step (#916)
V3RON Oct 23, 2026
f49b192
test: the renewal test straddles the kept window, tells a worker's le…
V3RON Oct 23, 2026
03ec8e2
fix(ui): a renewal that moves no lease leaves the poll alone, and a r…
V3RON Oct 23, 2026
0e82b04
fix(ui): a patched renewal leaves the poll's timer running, only a re…
V3RON Oct 23, 2026
1638a8d
test: the toast test's table is shown before time freezes, and an act…
V3RON Oct 23, 2026
0b5aac5
Merge remote-tracking branch 'origin/task/915' into HEAD
V3RON Oct 23, 2026
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
1 change: 1 addition & 0 deletions .fallowrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
"@fontsource-variable/geist-mono",
"recharts",
"@base-ui/react",
"@tanstack/react-query",
"@tanstack/react-table",
"@tanstack/react-virtual",
// Recharts' peer dependency: installed for it, never imported by the console itself.
Expand Down
14 changes: 11 additions & 3 deletions e2e/console/actions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,8 @@ test.describe("the console acts", () => {
try {
await page.clock.install();
await openPage(page, fleet.gateway, "/fleet", "Fleet");
// why: the page tells its views of an answer through a timer, which a frozen clock never fires, so a table still loading when time freezes never shows.
await expect(rowOf(page, "toast-d")).toBeVisible();
// why: with time frozen, only the fast-forwards below age a toast, however slow the machine.
await page.clock.pauseAt(new Date(Date.now() + 1_000));
const toasts = page.getByRole("region", { name: "Notifications" });
Expand All @@ -555,10 +557,16 @@ test.describe("the console acts", () => {
await rowOf(page, worker)
.getByRole("button", { name: `Actions for ${worker}` })
.press("Enter");
await page
const entry = page
.getByRole("menu", { name: `Actions for ${worker}` })
.getByRole("menuitem", { name: item, exact: true })
.click();
.getByRole("menuitem", { name: item, exact: true });
// why: the answer to the read an earlier action set off lands in real time, and the page shows it only once the frozen clock moves again, so until the menu offers the item the clock moves a little at a time.
let tries = 0;
await expect(async () => {
if (tries++ > 0) await page.clock.runFor(500);
await expect(entry).toBeVisible({ timeout: 1_000 });
}).toPass({ timeout: 20_000 });
await entry.click();
// why: a pointer left on the clicked item can sit where the toast appears, and a hovered
// toast never times out.
await page.mouse.move(0, 0);
Expand Down
3 changes: 2 additions & 1 deletion e2e/console/activity.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
settled,
test,
} from "./fixtures.js";
import { fetchFull } from "./full-fetch.js";

const IOS = { availableOsVersions: ["18.4"], knownModels: ["iPhone 16"] };

Expand Down Expand Up @@ -555,7 +556,7 @@ test.describe("the Activity page", () => {
return;
}
// why: the page holds only the newest two events, so the rest must come from the daemon's older page.
const body = (await (await route.fetch()).json()) as {
const body = (await (await fetchFull(route)).json()) as {
events: Array<Envelope & { readonly seq: number }>;
};
all = body.events.toSorted((a, b) => b.seq - a.seq);
Expand Down
8 changes: 8 additions & 0 deletions e2e/console/full-fetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { Route } from "@playwright/test";

// why: the console sends back the ETag of its last answer, and `route.fetch()` forwards the console's
// headers, so a stub that edits the daemon's answer would be handed a 304 with no body to edit.
export function fetchFull(route: Route): ReturnType<Route["fetch"]> {
const { "if-none-match": _tag, ...headers } = route.request().headers();
return route.fetch({ headers });
}
4 changes: 3 additions & 1 deletion e2e/console/inbox-stub.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { Page, Route } from "@playwright/test";

import { fetchFull } from "./full-fetch.js";

/** why: an Inbox item the daemon cannot make quickly (a request that waited minutes, a lease about to end), in the shape `GET /v1/inbox` sends. */
export type StubbedItem = (now: number) => Record<string, unknown>;

Expand Down Expand Up @@ -71,7 +73,7 @@ export async function addInboxItems(
await page.route(
(url) => url.pathname === "/v1/inbox",
async (route: Route) => {
const response = await route.fetch();
const response = await fetchFull(route);
const now = new Date(response.headers().date ?? "").getTime();
const body = (await response.json()) as { items: unknown[] };
await route.fulfill({
Expand Down
3 changes: 2 additions & 1 deletion e2e/console/long-lists.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
startDaemon,
test as base,
} from "./fixtures.js";
import { fetchFull } from "./full-fetch.js";
import { returnToTab } from "./tab.js";

const IOS = { availableOsVersions: ["18.4"], knownModels: ["iPhone 16"] };
Expand Down Expand Up @@ -121,7 +122,7 @@ async function answer(page: Page, pathname: string, body: () => unknown): Promis
await page.route(
(url) => url.pathname === pathname,
async (route: Route) => {
const response = await route.fetch();
const response = await fetchFull(route);
await route.fulfill({ json: body(), response });
},
);
Expand Down
5 changes: 3 additions & 2 deletions e2e/console/overview.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
startFleet,
test,
} from "./fixtures.js";
import { fetchFull } from "./full-fetch.js";

const IOS = { availableOsVersions: ["18.4"], knownModels: ["iPhone 16"] };
const ONE_DEVICE = { limits: { ios: { maxDevices: 1, maxRunning: 1 }, maxRunning: 1 } };
Expand Down Expand Up @@ -445,7 +446,7 @@ test.describe("the Overview's five numbers", () => {
await page.route(
(url) => url.pathname === "/v1/lease-requests",
async (route: Route) => {
const response = await route.fetch();
const response = await fetchFull(route);
const now = new Date(response.headers().date ?? "").getTime();
await route.fulfill({
json: {
Expand All @@ -467,7 +468,7 @@ test.describe("the Overview's five numbers", () => {
await page.route(
(url) => url.pathname === "/v1/leases",
async (route: Route) => {
const response = await route.fetch();
const response = await fetchFull(route);
const now = new Date(response.headers().date ?? "").getTime();
const { leases } = (await response.json()) as {
leases: { id: string; ttlDeadline: number }[];
Expand Down
5 changes: 3 additions & 2 deletions e2e/console/update.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Page } from "@playwright/test";

import { disposeFleet, expect, type RunningDaemon, signIn, startFleet, test } from "./fixtures.js";
import { fetchFull } from "./full-fetch.js";

const IOS = { availableOsVersions: ["18.4"], knownModels: ["iPhone 16"] };

Expand All @@ -12,7 +13,7 @@ async function showWorkerFields(page: Page, fields: () => WorkerFields): Promise
await page.route(
(url) => url.pathname === "/v1/workers",
async (route) => {
const response = await route.fetch();
const response = await fetchFull(route);
const body = (await response.json()) as { workers: Record<string, unknown>[] };
await route.fulfill({
json: { ...body, workers: body.workers.map((view) => ({ ...view, ...fields() })) },
Expand Down Expand Up @@ -41,7 +42,7 @@ test.describe("the console updates a worker", () => {
await page.route(
(url) => url.pathname === "/v1/status",
async (route) => {
const response = await route.fetch();
const response = await fetchFull(route);
const body = (await response.json()) as { daemon: Record<string, unknown> };
await route.fulfill({
json: { ...body, daemon: { ...body.daemon, latestVersion: "0.4.0", version: "0.3.0" } },
Expand Down
3 changes: 2 additions & 1 deletion e2e/console/workers-answers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Page, Route } from "@playwright/test";
import { fetchFull } from "./full-fetch.js";

interface WorkerPayload {
connection?: string;
Expand All @@ -17,7 +18,7 @@ const CONNECT_ERROR = /ECONNREFUSED|ECONNRESET|socket hang up/i;

async function fetchOrDrop(route: Route): Promise<Awaited<ReturnType<Route["fetch"]>> | undefined> {
try {
return await route.fetch();
return await fetchFull(route);
} catch (error) {
if (!(error instanceof Error) || !CONNECT_ERROR.test(error.message)) throw error;
await route.abort().catch(() => undefined);
Expand Down
2 changes: 2 additions & 0 deletions e2e/console/workers-gateway-stops-mid-fetch.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ test.describe("what the workers page response helper drops and what it lets thro
abort: async () => {
aborted = true;
},
request: () => ({ headers: () => ({}) }),
fetch: async () => {
throw new Error("getaddrinfo ENOTFOUND gateway.invalid");
},
Expand Down Expand Up @@ -161,6 +162,7 @@ test.describe("what the workers page response helper drops and what it lets thro
abort: async () => {
aborted = true;
},
request: () => ({ headers: () => ({}) }),
fetch: async () => {
throw new Error(message);
},
Expand Down
7 changes: 7 additions & 0 deletions ui/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,13 @@ phase, the health checks and the clock offset, and does one classified GET for a
again, any other failure is the connection lost (`ConnectionLost`), which a page never shows as an
error. A refused stream is asked for again a second later.

A read sends back the ETag of the route's last answer (`ApiClient.get(path, { tag })`, kept per key in
`state/queries.ts`); a `304` keeps the cached data object, so nothing re-renders, and one that finds
its data dropped is read again without a tag. A `lease.renewed` event moves the lease's `ttlDeadline`,
`lastRenewedAt` and `ttlMs` in `['leases']` and `['workers']` in place (`model/renewal.ts`) with no read,
and the deadline is kept for 60 s against an older answer. Console browser stubs fetch through
`e2e/console/full-fetch.ts`, which drops `If-None-Match`.

## Actions

An action is described once per kind of item in `model/actions.ts` (`leaseActions`, `requestActions`,
Expand Down
30 changes: 30 additions & 0 deletions ui/src/data/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,36 @@ describe("createApiClient", () => {
expect(await api.get("/v1/workers")).toEqual({ body: { workers: [] }, date });
});

it("a read with a tag sends it as If-None-Match and answers a 304 as not modified, a 200 with its body and tag", async () => {
const date = "Fri, 02 Oct 2026 12:00:00 GMT";
const sent: (string | undefined)[] = [];
let answer: () => Response = () => new Response(null, { headers: { Date: date }, status: 304 });
const fetch: Fetch = async (_input, init) => {
sent.push(new Headers(init.headers).get("If-None-Match") ?? undefined);
return answer();
};
const api = createApiClient({ fetch, signOut: () => {}, token: () => "slk_op" });

expect(await api.get("/v1/workers", { tag: '"a"' })).toEqual({ date, notModified: true });
answer = () =>
new Response(JSON.stringify({ workers: [] }), {
headers: { Date: date, ETag: '"b"' },
status: 200,
});
expect(await api.get("/v1/workers", { tag: '"a"' })).toEqual({
body: { workers: [] },
date,
tag: '"b"',
});
answer = () => new Response(JSON.stringify({ workers: [] }), { status: 200 });
expect(await api.get("/v1/workers", { tag: undefined })).toEqual({
body: { workers: [] },
date: null,
tag: null,
});
expect(sent).toEqual(['"a"', '"a"', undefined]);
});

it("/v1/healthz is asked with no token, and only a success status counts as up", async () => {
const date = "Fri, 02 Oct 2026 12:00:00 GMT";
const up = answering(200, { ok: true });
Expand Down
42 changes: 36 additions & 6 deletions ui/src/data/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ interface SendOptions {
readonly signal?: AbortSignal;
readonly timeoutMs?: number;
readonly body?: string;
readonly ifNoneMatch?: string;
}

export async function send<T>(
Expand All @@ -57,6 +58,7 @@ export async function send<T>(
credentials: "same-origin",
headers: {
...(options.token === undefined ? {} : { Authorization: `Bearer ${options.token}` }),
...(options.ifNoneMatch === undefined ? {} : { "If-None-Match": options.ifNoneMatch }),
...(options.body === undefined ? {} : { "Content-Type": "application/json" }),
},
...(options.body === undefined ? {} : { body: options.body }),
Expand All @@ -79,9 +81,19 @@ export interface ApiResponse<T> {
readonly date: string | null;
}

export interface ReadOptions {
// why: the ETag of the last answer to this route; `undefined` asks for the whole answer.
readonly tag: string | undefined;
}

export type ConditionalResponse<T> =
| { readonly notModified: true; readonly date: string | null }
| (ApiResponse<T> & { readonly notModified?: false; readonly tag: string | null });

export interface ApiClient {
getJson<T>(path: ApiPath): Promise<T>;
get<T>(path: ApiPath): Promise<ApiResponse<T>>;
get<T>(path: ApiPath, options: ReadOptions): Promise<ConditionalResponse<T>>;
// why: a stream's bound, 10 seconds unless `headTimeoutMs` says otherwise, covers its headers
// only; the body stays open until it ends or `signal` aborts it.
stream(
Expand Down Expand Up @@ -131,7 +143,10 @@ export function createApiClient(options: ApiClientOptions): ApiClient {
): Promise<T> => {
try {
return await send(path, sendOptions, async (response) => {
await refuseError(response, refuse);
// why: a 304 answers the tag this read sent, so it is not a refusal.
if (response.status !== 304 || sendOptions.ifNoneMatch === undefined) {
await refuseError(response, refuse);
}
return read(response);
});
} catch (error: unknown) {
Expand All @@ -144,11 +159,26 @@ export function createApiClient(options: ApiClientOptions): ApiClient {
throw error;
}
};
const get = <T>(path: ApiPath) =>
watched(path, authorized(), async (response) => ({
body: (await response.json()) as T,
date: response.headers.get("Date"),
}));
function get<T>(path: ApiPath): Promise<ApiResponse<T>>;
function get<T>(path: ApiPath, readOptions: ReadOptions): Promise<ConditionalResponse<T>>;
function get<T>(
path: ApiPath,
readOptions?: ReadOptions,
): Promise<ApiResponse<T> | ConditionalResponse<T>> {
const tag = readOptions?.tag;
return watched(
path,
{ ...authorized(), ...(tag === undefined ? {} : { ifNoneMatch: tag }) },
async (response) => {
const date = response.headers.get("Date");
if (response.status === 304) return { date, notModified: true as const };
const body = (await response.json()) as T;
return readOptions === undefined
? { body, date }
: { body, date, tag: response.headers.get("ETag") };
},
);
}
return {
get,
getJson: async <T>(path: ApiPath) => (await get<T>(path)).body,
Expand Down
25 changes: 21 additions & 4 deletions ui/src/data/live/connection.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import { ApiError, type ApiClient, type ApiPath, RequestTimeoutError } from "../api";
import {
ApiError,
type ApiClient,
type ApiPath,
type ConditionalResponse,
type ReadOptions,
RequestTimeoutError,
} from "../api";
import { createSseParser, type SseMessage } from "./sse";
import { clockOffset } from "./clock";

Expand Down Expand Up @@ -72,6 +79,16 @@ export function isRefusal(error: unknown): boolean {
return error instanceof ApiError || error instanceof SyntaxError;
}

export type ConditionalRead<T> =
| { readonly notModified: true }
| { readonly notModified?: false; readonly body: T; readonly tag: string | null };

function conditional<T>(response: ConditionalResponse<T>): ConditionalRead<T> {
return response.notModified === true
? { notModified: true }
: { body: response.body, tag: response.tag };
}

export class LiveConnection {
readonly #api: LiveApi;
readonly #clock: Clock;
Expand Down Expand Up @@ -108,11 +125,11 @@ export class LiveConnection {
// why: one GET with the classification every read gets: a refusal is the route's own answer and
// the daemon is there; a timeout while the daemon starts is asked again; anything else is the
// connection lost.
async read<T>(path: ApiPath): Promise<T> {
async read<T>(path: ApiPath, options: ReadOptions): Promise<ConditionalRead<T>> {
for (;;) {
let response;
try {
response = await this.#api.get<T>(path);
response = await this.#api.get<T>(path, options);
} catch (error: unknown) {
if (this.#disposed) throw error;
switch (this.#classify(error)) {
Expand All @@ -133,7 +150,7 @@ export class LiveConnection {
this.#answeredAt = this.#clock.now();
this.#daemonAnswered();
}
return response.body;
return conditional(response);
}
}

Expand Down
Loading
Loading