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
25 changes: 23 additions & 2 deletions web/src/components/connection-banner.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,18 @@ function setOnline(value: boolean) {
// hooks are re-read) — RouterProvider re-rendered with the same static route element would bail out.
let rerenderBanner: () => void = () => {};

function renderBanner(props: { bridge?: "connected" | "disconnected"; error?: boolean } = {}) {
function renderBanner(
props: { bridge?: "connected" | "disconnected"; error?: boolean; authError?: boolean } = {},
) {
function Harness() {
const [, setN] = useState(0);
rerenderBanner = () => setN((n) => n + 1);
return (
<ConnectionBanner bridge={props.bridge ?? "disconnected"} error={props.error ?? false} />
<ConnectionBanner
bridge={props.bridge ?? "disconnected"}
error={props.error ?? false}
authError={props.authError ?? false}
/>
);
}
const router = createMemoryRouter([{ path: "/", element: <Harness /> }]);
Expand All @@ -59,6 +65,21 @@ afterEach(() => {
});

describe("ConnectionBanner — the single connection surface", () => {
it("shows the auth refusal with Reload and no connection treatment", () => {
h.trouble = true;
h.lost = true;
renderBanner({ authError: true });

expect(screen.getByRole("alert")).toHaveTextContent(
"Access refused. This is not a connection problem.",
);
expect(screen.getByRole("button", { name: "Reload" })).toBeInTheDocument();
expect(screen.queryByText("Reconnecting…")).toBeNull();
expect(screen.queryByRole("button", { name: "Retry" })).toBeNull();
expect(document.querySelector(".animate-spin")).toBeNull();
expect(cfg.calls).toBe(0);
});

it("renders nothing while healthy — no bar at all", () => {
renderBanner({ bridge: "connected" });
expect(screen.queryByRole("status")).toBeNull();
Expand Down
46 changes: 45 additions & 1 deletion web/src/components/connection-banner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ interface ConnectionBannerProps {
bridge: BridgeStatus | undefined;
/** The last snapshot fetch failed (stale data on screen). */
error: boolean;
/** The failed snapshot request was rejected with HTTP 401 or 403. */
authError: boolean;
}

// The result of the /api/config probe (which never touches Herdr): "unknown" until it resolves,
Expand All @@ -40,7 +42,49 @@ export const EXIT_MS = 200;
// disagree; `connecting` is poll-truth (isConnecting) — navigator.onLine is COPY-only (it picks the
// red cause), never a gate. Threshold lockstep with the shared clock is proven in use-connection-lost;
// here we own the amber→red→green state machine and the smooth mount/unmount.
export function ConnectionBanner({ bridge, error }: ConnectionBannerProps) {
export function ConnectionBanner({ bridge, error, authError }: ConnectionBannerProps) {
if (authError) return <AuthErrorBanner />;
return <ConnectionStateBanner bridge={bridge} error={error} />;
}

// A refusal is not an outage, so it gets its own surface ahead of the connection state machine: no
// probe, no reconnect spinner, no escalation clock. The copy stays deliberately non-specific about
// the cause. The flag covers 401 and 403 alike, and a 403 can equally mean "this device is not
// allowlisted", "host not allowed" or "cross-origin rejected", so naming any one of them would be
// wrong more often than right. What the operator needs here is the one fact the old behaviour hid:
// this is not the network. Reload is the useful action either way, because it is what lets a
// fronting proxy serve its sign-in page.
function AuthErrorBanner() {
return (
<div className="grid shrink-0 grid-rows-[1fr] overflow-hidden opacity-100">
<div className="min-h-0 overflow-hidden">
<div
role="alert"
aria-live="polite"
className={cn(
"flex items-center gap-2 border-b px-4 py-1 text-xs [padding-top:calc(env(safe-area-inset-top)_+_0.25rem)]",
TINT.blocked.row,
)}
>
<TriangleAlert className={cn("size-3.5 shrink-0", TINT.blocked.icon)} />
<span className="min-w-0 flex-1 truncate font-medium text-foreground">
Access refused. This is not a connection problem.
</span>
<Button
size="sm"
className="h-6 gap-1 px-2 text-xs"
onClick={() => window.location.reload()}
>
<RefreshCw className="size-3.5" />
Reload
</Button>
</div>
</div>
</div>
);
}

function ConnectionStateBanner({ bridge, error }: Omit<ConnectionBannerProps, "authError">) {
const stalled = useLoadingStalled();
const connecting = isConnecting({ bridge, error, stalled });
const trouble = useConnectionTrouble(connecting);
Expand Down
1 change: 1 addition & 0 deletions web/src/components/update-banner.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ function homeData(update: UpdateInfo | undefined): HomeData {
snoozedUntil: null,
update,
error: false,
authError: false,
};
}

Expand Down
1 change: 1 addition & 0 deletions web/src/components/update-check-control.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ function homeData(update: UpdateInfo | undefined): HomeData {
snoozedUntil: null,
update,
error: false,
authError: false,
};
}

Expand Down
1 change: 1 addition & 0 deletions web/src/hooks/use-polling.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ function makeData(agents: AgentView[], shellPanes: AgentView[] = []): HomeData {
snoozedUntil: null,
update: undefined,
error: false,
authError: false,
};
}

Expand Down
5 changes: 5 additions & 0 deletions web/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ class ApiError extends Error {
}
}

/** True when an API request failed with the given HTTP status. */
export function isApiErrorStatus(error: unknown, status: number): boolean {
return error instanceof ApiError && error.status === status;
}

// Every request gets a deadline so a black-holed connection (phone sleep/wake, a Tailscale route
// that goes dark) can't leave a fetch pending forever — which would zombify the app: the poller
// gates on `revalidator.state === "idle"` and never fires again, and route navigations wait on a
Expand Down
53 changes: 53 additions & 0 deletions web/src/lib/loaders.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,33 @@ afterEach(() => {
const failSnapshot = () =>
server.use(http.get("/api/snapshot", () => new HttpResponse(null, { status: 500 })));

const rejectSnapshot = (status: 401 | 403) =>
server.use(http.get("/api/snapshot", () => new HttpResponse(null, { status })));

const failPane = () =>
server.use(http.get(/\/api\/pane\/[^/]+$/, () => new HttpResponse(null, { status: 500 })));

const rejectPane = (status: 401 | 403) =>
server.use(http.get(/\/api\/pane\/[^/]+$/, () => new HttpResponse(null, { status })));

describe("rootLoader", () => {
it("returns the live snapshot on success", async () => {
const { rootLoader } = await import("./loaders");
const data = await rootLoader();
expect(data.error).toBe(false);
expect(data.authError).toBe(false);
expect(data.bridge).toBe("connected");
expect(data.agents).toHaveLength(2);
});

it.each([401, 403] as const)("marks a %i response as an auth error", async (status) => {
rejectSnapshot(status);
const { rootLoader } = await import("./loaders");
const data = await rootLoader();
expect(data.error).toBe(true);
expect(data.authError).toBe(true);
});

it("keeps the last-good herd (flagged error) when a refresh fails", async () => {
const { rootLoader } = await import("./loaders");
await rootLoader(); // prime the cache with a good snapshot
Expand All @@ -36,11 +51,20 @@ describe("rootLoader", () => {
const stale = await rootLoader();

expect(stale.error).toBe(true);
expect(stale.authError).toBe(false);
expect(stale.bridge).toBe("connected"); // from the cached snapshot
expect(stale.agents).toHaveLength(2);
expect(stale.agents[0]!.paneId).toBe(fixtureAgents[0]!.paneId);
});

it("does not mark a network error as an auth error", async () => {
const { rootLoader } = await import("./loaders");
vi.spyOn(globalThis, "fetch").mockRejectedValue(new TypeError("network failed"));
const data = await rootLoader();
expect(data.error).toBe(true);
expect(data.authError).toBe(false);
});

it("returns empty + error when there is no last-good snapshot", async () => {
failSnapshot();
const { rootLoader } = await import("./loaders");
Expand All @@ -59,6 +83,7 @@ describe("rootLoader", () => {
vi.spyOn(globalThis, "fetch").mockRejectedValue(new DOMException("timed out", "TimeoutError"));
const data = await rootLoader();
expect(data.error).toBe(true);
expect(data.authError).toBe(false);
expect(data.bridge).toBeUndefined();
expect(data.agents).toEqual([]);
});
Expand Down Expand Up @@ -91,10 +116,19 @@ describe("paneLoader", () => {
const { paneLoader } = await import("./loaders");
const data = await paneLoader({ params: { paneId: "w1:p1" } });
expect(data.error).toBe(false);
expect(data.authError).toBe(false);
expect(data.paneId).toBe("w1:p1");
expect(data.text).toBe("hello from the pane");
});

it.each([401, 403] as const)("marks a %i response as an auth error", async (status) => {
rejectPane(status);
const { paneLoader } = await import("./loaders");
const data = await paneLoader({ params: { paneId: "w1:p1" } });
expect(data.error).toBe(true);
expect(data.authError).toBe(true);
});

it("keeps the last-good pane text (flagged error) when a refresh fails", async () => {
const { paneLoader } = await import("./loaders");
await paneLoader({ params: { paneId: "w1:p1" } }); // prime per-pane cache
Expand All @@ -103,6 +137,7 @@ describe("paneLoader", () => {
const stale = await paneLoader({ params: { paneId: "w1:p1" } });

expect(stale.error).toBe(true);
expect(stale.authError).toBe(false);
expect(stale.text).toBe("hello from the pane");
expect(stale.paneId).toBe("w1:p1");
});
Expand All @@ -128,6 +163,7 @@ describe("paneLoader", () => {
const stale = await paneLoader({ params: { paneId: "w1:p1" } });

expect(stale.error).toBe(true);
expect(stale.authError).toBe(false);
expect(stale.text).toBe("hello from the pane");
expect(stale.paneId).toBe("w1:p1");
});
Expand Down Expand Up @@ -275,6 +311,23 @@ describe("loaders — offline navigation fast path", () => {
expect(data.agents).toHaveLength(2);
});

it("keeps the last auth classification on the navigation fast path", async () => {
rejectSnapshot(401);
const { rootLoader } = await import("./loaders");
const { latchLost } = await import("./connection-health");

const rejected = await rootLoader({ request: new Request("http://localhost/") });
expect(rejected.authError).toBe(true);
latchLost();

const fetchSpy = vi.spyOn(globalThis, "fetch");
const data = await rootLoader({ request: new Request("http://localhost/space/w1") });

expect(fetchSpy).not.toHaveBeenCalled();
expect(data.error).toBe(true);
expect(data.authError).toBe(true);
});

it("a revalidation (same url) still really fetches while latched — polls keep probing", async () => {
const { rootLoader } = await import("./loaders");
const { latchLost } = await import("./connection-health");
Expand Down
42 changes: 40 additions & 2 deletions web/src/lib/loaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
// no flag, no race — and because a navigation aborts any in-flight revalidation, the nav is instant
// even while a poll's doomed fetch is still hanging.

import { fetchPane, fetchSnapshot } from "@/lib/api";
import { fetchPane, fetchSnapshot, isApiErrorStatus } from "@/lib/api";
import { isLostLatched } from "@/lib/connection-health";
import { SESSION_PARAM, normalizeSession } from "@/lib/session";
import type {
Expand Down Expand Up @@ -75,6 +75,8 @@ export interface HomeData {
update: UpdateInfo | undefined;
/** True when this render is the last-good snapshot after a failed refresh. */
error: boolean;
/** True when the failed refresh was rejected with HTTP 401 or 403. */
authError: boolean;
}

export interface PaneData {
Expand All @@ -91,12 +93,32 @@ export interface PaneData {
* the degraded (stale-text) path, where the guard's fresh fetch will reject a mismatch anyway. */
revision: number;
error: boolean;
/** True when the failed refresh was rejected with HTTP 401 or 403. */
authError: boolean;
}

// Keep-previous-data cache is now PER-SESSION: switching sessions must not show the other session's
// herd flagged as stale. Keyed by session name ("" = primary).
const lastSnapshot = new Map<string, SnapshotResponse>();

// A latched navigation skips the network, so retain whether the last real outcome for each session
// was an auth rejection. Store only rejected sessions; every other real outcome removes the marker.
const authErrorSessions = new Set<string>();

function rememberAuthError(session: string | undefined, authError: boolean): void {
const key = session ?? "";
if (authError) authErrorSessions.add(key);
else authErrorSessions.delete(key);
}

function hasAuthError(session: string | undefined): boolean {
return authErrorSessions.has(session ?? "");
}

function isAuthError(error: unknown): boolean {
return isApiErrorStatus(error, 401) || isApiErrorStatus(error, 403);
}

// The URL each loader last RAN for — the nav-vs-revalidate discriminator for the offline fast path (see
// the header comment). Module-scoped so it survives revalidations (the loader re-runs every poll) and
// resets on a full reload — same lifetime as the caches. `lastRootUrl` is enough for the root loader
Expand Down Expand Up @@ -128,6 +150,7 @@ function toHomeData(snap: SnapshotResponse, session: string | undefined, error:
snoozedUntil: snap.notifications?.snoozedUntil ?? null,
update: snap.update,
error,
authError: error && hasAuthError(session),
};
}

Expand All @@ -151,6 +174,7 @@ function staleHome(session: string | undefined): HomeData {
snoozedUntil: null,
update: undefined,
error: true,
authError: hasAuthError(session),
};
}

Expand All @@ -173,9 +197,11 @@ export async function rootLoader({ request }: { request?: Request } = {}): Promi
try {
const snap = await fetchSnapshot(session, request?.signal);
lastSnapshot.set(session ?? "", snap);
rememberAuthError(session, false);
return toHomeData(snap, session, false);
} catch (e) {
if (isAbortError(e)) throw e; // superseded revalidation — let React Router drop it
rememberAuthError(session, isAuthError(e));
// Keep the last good herd on screen, flagged so the ConnectionBanner can say "reconnecting…".
return staleHome(session);
}
Expand Down Expand Up @@ -254,6 +280,7 @@ function stalePane(paneId: string, session: string | undefined, lines: number):
requestedLines: lines,
revision: 0,
error: true,
authError: hasAuthError(session),
};
}

Expand Down Expand Up @@ -289,9 +316,20 @@ export async function paneLoader({
const read: PaneReadResponse = await fetchPane(paneId, lines, session, request?.signal);
const text = read.text || lastPaneText.get(key) || "";
rememberPaneText(key, text);
return { paneId, session, text, truncated: read.truncated, requestedLines: lines, revision: read.revision, error: false };
rememberAuthError(session, false);
return {
paneId,
session,
text,
truncated: read.truncated,
requestedLines: lines,
revision: read.revision,
error: false,
authError: false,
};
} catch (e) {
if (isAbortError(e)) throw e; // superseded revalidation — let React Router drop it
rememberAuthError(session, isAuthError(e));
// Genuine network / server failure: show stale text flagged as degraded.
return stalePane(paneId, session, lines);
}
Expand Down
2 changes: 2 additions & 0 deletions web/src/routes/detail.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ const connected = (agents: AgentView[], shellPanes: AgentView[] = []): HomeData
snoozedUntil: null,
update: undefined,
error: false,
authError: false,
});

function makeRouter(initialPath: string, homeLoader: () => HomeData) {
Expand All @@ -68,6 +69,7 @@ function makeRouter(initialPath: string, homeLoader: () => HomeData) {
requestedLines: 600,
revision: 0,
error: false,
authError: false,
}),
element: <DetailRoute />,
},
Expand Down
2 changes: 1 addition & 1 deletion web/src/routes/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export function RootLayout() {
in amber "reconnecting…" only after ≥4s of sustained trouble (the flicker fix), escalates to a
red "not connected" cause + Retry/Reload at ≥15s, and flashes green on recovery. Reads the
same shared-clock signals as the header dog, so the two always agree. */}
<ConnectionBanner bridge={data.bridge} error={data.error} />
<ConnectionBanner bridge={data.bridge} error={data.error} authError={data.authError} />
<Outlet />
</div>
);
Expand Down