diff --git a/frontend/packages/swarm-ui/src/App.tsx b/frontend/packages/swarm-ui/src/App.tsx
index 891678b3..4afa9d16 100644
--- a/frontend/packages/swarm-ui/src/App.tsx
+++ b/frontend/packages/swarm-ui/src/App.tsx
@@ -1,7 +1,7 @@
// Root shell component — routing only. Each route's content is its own
// page component under `./pages/`; this file just maps paths to them.
import { Redirect, Route, Router, Switch, useSearch } from "wouter-preact";
-import { jobsHref } from "./pages/builds/builds-types.js";
+import { MOVED_ROUTES, movedHref } from "./shell/routes.js";
import { Shell } from "./shell/Shell.js";
import { useGuardedLocation } from "./shell/useGuardedLocation.js";
import { AgentsPage } from "./pages/agents/AgentsPage.js";
@@ -16,11 +16,10 @@ import { ReportsPage } from "./pages/reports/ReportsPage.js";
import { StartPage } from "./pages/StartPage.js";
import { Panel } from "./ui/panel/Panel.js";
-// The builds page moved onto the jobs tab. Links made before that, such as the
-// Details link on a swarm-builder commit status, land here and go on to the
-// same build's card, query kept.
-function BuildsMoved() {
- return ;
+// An address from before the tabs were folded together: replace it with the
+// tab that holds the page now, query kept.
+function MovedTo({ from }: { from: string }) {
+ return ;
}
function NotFound() {
@@ -38,9 +37,11 @@ export function App() {
-
-
-
+ {Object.keys(MOVED_ROUTES).map((from) => (
+
+
+
+ ))}
-
-
-
-
-
-
-
-
-
-
diff --git a/frontend/packages/swarm-ui/src/pages/builds/builds-types.ts b/frontend/packages/swarm-ui/src/pages/builds/builds-types.ts
index 6e0f8a79..322ba6a5 100644
--- a/frontend/packages/swarm-ui/src/pages/builds/builds-types.ts
+++ b/frontend/packages/swarm-ui/src/pages/builds/builds-types.ts
@@ -37,11 +37,6 @@ export const OUTCOME_TONE: Record = {
error: "warning",
};
-/** Where an old `/builds` link goes: the jobs page, keeping its query. */
-export function jobsHref(search: string): string {
- return search === "" ? "/jobs" : `/jobs?${search}`;
-}
-
/** The run a `?run=` query names, or `null` when it names none. */
export function wantedRun(search: string): string | null {
const id = new URLSearchParams(search).get("run");
diff --git a/frontend/packages/swarm-ui/src/shell/routes.ts b/frontend/packages/swarm-ui/src/shell/routes.ts
index 57d17296..07238feb 100644
--- a/frontend/packages/swarm-ui/src/shell/routes.ts
+++ b/frontend/packages/swarm-ui/src/shell/routes.ts
@@ -27,3 +27,22 @@ export function visibleNavItems(
if (!hideDeveloper) return items;
return items.filter((item) => !DEVELOPER_ROUTES.includes(item.href));
}
+
+// Tabs that were folded into another one. Each old address redirects to its
+// new tab, so a bookmark or a link made before the move still lands. The
+// builds page's `?run=` query rides along, which a swarm-builder commit
+// status link relies on.
+export const MOVED_ROUTES: Readonly> = {
+ "/alerts": "/reports",
+ "/issues": "/reports",
+ "/builds": "/jobs",
+ "/review": "/jobs",
+ "/schedules": "/messages",
+};
+
+/** Where an old address goes, keeping its query; `null` for any other path. */
+export function movedHref(path: string, search: string): string | null {
+ const to = MOVED_ROUTES[path];
+ if (to === undefined) return null;
+ return search === "" ? to : `${to}?${search}`;
+}
diff --git a/frontend/packages/swarm-ui/test/builds-types.test.mjs b/frontend/packages/swarm-ui/test/builds-types.test.mjs
index d4210fa2..2cba6d47 100644
--- a/frontend/packages/swarm-ui/test/builds-types.test.mjs
+++ b/frontend/packages/swarm-ui/test/builds-types.test.mjs
@@ -3,11 +3,7 @@
// stripping, so the `.ts` module is imported as it is.
import assert from "node:assert/strict";
import { test } from "node:test";
-import {
- bodyText,
- jobsHref,
- wantedRun,
-} from "../src/pages/builds/builds-types.ts";
+import { bodyText, wantedRun } from "../src/pages/builds/builds-types.ts";
const base = {
id: "atlas-abc-1",
@@ -53,8 +49,3 @@ test("a run with no text for its outcome shows no body", () => {
assert.equal(bodyText({ ...base, outcome: "failed" }), null);
assert.equal(bodyText({ ...base, outcome: "built", error_tail: "x" }), null);
});
-
-test("an old builds link goes to the jobs page and keeps its query", () => {
- assert.equal(jobsHref("run=atlas-abc-1"), "/jobs?run=atlas-abc-1");
- assert.equal(jobsHref(""), "/jobs");
-});
diff --git a/frontend/packages/swarm-ui/test/routes.test.mjs b/frontend/packages/swarm-ui/test/routes.test.mjs
index a77b80b5..f33fa1a4 100644
--- a/frontend/packages/swarm-ui/test/routes.test.mjs
+++ b/frontend/packages/swarm-ui/test/routes.test.mjs
@@ -1,7 +1,12 @@
// The shell's per-route layout: which paths are full-bleed. Run by `npm test`.
import assert from "node:assert/strict";
import { test } from "node:test";
-import { isFullBleedRoute, visibleNavItems } from "../src/shell/routes.ts";
+import {
+ MOVED_ROUTES,
+ isFullBleedRoute,
+ movedHref,
+ visibleNavItems,
+} from "../src/shell/routes.ts";
test("the start page is full-bleed", () => {
assert.equal(isFullBleedRoute("/"), true);
@@ -42,3 +47,25 @@ test("hide developer details takes the components tab out of the nav", () => {
test("with it off the nav is whole again", () => {
assert.deepEqual(visibleNavItems(NAV, false), NAV);
});
+
+test("an old address goes to the tab that holds its page, query kept", () => {
+ assert.equal(movedHref("/alerts", ""), "/reports");
+ assert.equal(movedHref("/issues", ""), "/reports");
+ assert.equal(movedHref("/review", ""), "/jobs");
+ assert.equal(movedHref("/schedules", ""), "/messages");
+ assert.equal(
+ movedHref("/builds", "run=atlas-abc-1"),
+ "/jobs?run=atlas-abc-1",
+ );
+});
+
+test("a current tab is not moved", () => {
+ assert.equal(movedHref("/jobs", ""), null);
+ assert.equal(movedHref("/agents", "x=1"), null);
+});
+
+test("no old address leads to another old address", () => {
+ for (const to of Object.values(MOVED_ROUTES)) {
+ assert.equal(to in MOVED_ROUTES, false, to);
+ }
+});