Skip to content

Commit d386ac4

Browse files
committed
fix(webapp): keep the queue metrics dashboard on an environment switch
The built-in queues dashboard is gated per organization, like Logs and Query, so it belongs in ORGANIZATION_SPECIFIC_PAGES rather than being left out of the portable pages entirely: an environment or project switch stays inside the organization whose flag let you open it. The manifest assertion that derives the gated pages from the route sources now reads both ways a loader turns you away — a redirect home and a 404 on the same shape of organization gate — so a future gated page still cannot slip in unnoticed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nj3iCRvSP9sP7y7hxXVJbx
1 parent 5a292da commit d386ac4

2 files changed

Lines changed: 75 additions & 17 deletions

File tree

apps/webapp/app/utils/pageSwitching.test.ts

Lines changed: 72 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -44,17 +44,44 @@ function rendersAPage(file: string): boolean {
4444
return /^export default/m.test(readFileSync(join(APP_DIR, file), "utf8"));
4545
}
4646

47-
function sendsYouHome(file: string): boolean {
48-
return /redirect\("\/"\)/.test(readFileSync(join(APP_DIR, file), "utf8"));
47+
const ORGANIZATION_GATE = String.raw`(?:can|has)[A-Z]\w*\([^)]*\borganizationSlug\b[^)]*\)`;
48+
49+
const GATE_REJECTS = new RegExp(
50+
String.raw`if \(\s*(?:(\w+) === "([^"]+)" &&\s*)?!\(await ${ORGANIZATION_GATE}\)\s*\)\s*\{\s*throw`
51+
);
52+
53+
const GATE_REJECTS_VIA_FLAG = new RegExp(
54+
String.raw`const canAccess = await ${ORGANIZATION_GATE};\s*if \(!canAccess\) \{\s*throw`
55+
);
56+
57+
/**
58+
* The page a loader turns you away from when an organization-scoped check says no, whether it
59+
* sends you home or 404s. A gate that only covers one value of a route param names that page; an
60+
* unconditional gate on a route that takes a resource id names nothing, since a page with an id in
61+
* it is never portable anyway.
62+
*/
63+
function organizationGatedPage(suffix: string, file: string): string | undefined {
64+
const source = readFileSync(join(APP_DIR, file), "utf8");
65+
const guarded = GATE_REJECTS.exec(source);
66+
67+
if (guarded === null) return GATE_REJECTS_VIA_FLAG.test(source) ? suffix : undefined;
68+
69+
const [, param, key] = guarded;
70+
if (param === undefined) return suffix.includes(":") ? undefined : suffix;
71+
72+
return suffix.includes(`:${param}`) ? suffix.replace(`:${param}`, key) : undefined;
4973
}
5074

5175
const belowEnvironment = Object.values(compiledRoutes)
5276
.filter((route) => compiledUrl(route.id).startsWith(ENVIRONMENT_URL))
53-
.map((route) => ({
54-
suffix: compiledUrl(route.id).slice(ENVIRONMENT_URL.length).replace(/^\//, ""),
55-
rendersAPage: rendersAPage(route.file),
56-
sendsYouHome: sendsYouHome(route.file),
57-
}));
77+
.map((route) => {
78+
const suffix = compiledUrl(route.id).slice(ENVIRONMENT_URL.length).replace(/^\//, "");
79+
return {
80+
suffix,
81+
rendersAPage: rendersAPage(route.file),
82+
organizationGatedPage: organizationGatedPage(suffix, route.file),
83+
};
84+
});
5885

5986
const environmentRoutes = [...new Set(belowEnvironment.map((route) => route.suffix))];
6087

@@ -197,14 +224,26 @@ describe("pages a project switch cannot carry", () => {
197224
});
198225

199226
describe("pages an organization switch cannot carry", () => {
200-
it("are the ones whose loaders send you home when the organization is not allowed in", () => {
201-
const sendHome = [
227+
it("are the ones whose loaders turn you away when the organization is not allowed in", () => {
228+
const gated = [
202229
...new Set(
203-
belowEnvironment.filter((route) => route.sendsYouHome).map((route) => route.suffix)
230+
belowEnvironment
231+
.map((route) => route.organizationGatedPage)
232+
.filter((page): page is string => page !== undefined)
204233
),
205234
].sort();
206235

207-
expect(sendHome).toEqual([...ORGANIZATION_SPECIFIC_PAGES].sort());
236+
expect(gated).toEqual([...ORGANIZATION_SPECIFIC_PAGES].sort());
237+
});
238+
239+
it("are read from both ways a loader turns you away, so neither stops being noticed", () => {
240+
const gatedPage = (suffix: string) =>
241+
belowEnvironment.find((route) => route.suffix === suffix)?.organizationGatedPage;
242+
243+
expect(gatedPage("logs")).toBe("logs");
244+
expect(gatedPage("dashboards/:dashboardKey")).toBe("dashboards/queues");
245+
expect(gatedPage("queues/:queueParam")).toBeUndefined();
246+
expect(gatedPage("apikeys")).toBeUndefined();
208247
});
209248

210249
it("still travel with an environment or project switch, which stay in the same organization", () => {
@@ -214,10 +253,27 @@ describe("pages an organization switch cannot carry", () => {
214253
expect(ORGANIZATION_PORTABLE_PAGES.has(page)).toBe(false);
215254
expect(environmentPortablePage(page)).toBe(page);
216255
expect(projectPortablePage(page)).toBe(page);
217-
expect(organizationPortablePage(page)).toBe("");
218256
}
219257
});
220258

259+
it("stay put when only the environment changes", () => {
260+
expect(
261+
pathForEnvironmentSwitch({
262+
location: locationOn("dashboards/queues", "?period=1d"),
263+
environmentPathname: environmentLocation.pathname,
264+
environmentSlug: "preview",
265+
})
266+
).toBe("/orgs/acme/projects/api/env/preview/dashboards/queues?period=1d");
267+
268+
expect(
269+
pathForEnvironmentSwitch({
270+
location: locationOn("logs"),
271+
environmentPathname: environmentLocation.pathname,
272+
environmentSlug: "prod",
273+
})
274+
).toBe("/orgs/acme/projects/api/env/prod/logs");
275+
});
276+
221277
it("are otherwise the same list, so nothing else is quietly dropped", () => {
222278
const dropped = [...PROJECT_PORTABLE_PAGES]
223279
.filter((page) => !ORGANIZATION_PORTABLE_PAGES.has(page))
@@ -226,13 +282,14 @@ describe("pages an organization switch cannot carry", () => {
226282
expect(dropped).toEqual([...ORGANIZATION_SPECIFIC_PAGES].sort());
227283
});
228284

229-
it("fall back to the tasks page when the organization changes", () => {
285+
it("fall back to the nearest page the organization switched into can open", () => {
230286
const read = (search: string) =>
231287
requestedOrganizationPortablePage(new Request(`http://localhost/orgs/acme${search}`));
232288

233289
expect(portablePageSearch(organizationPortablePage("logs"))).toBe("");
234290
expect(read("?page=logs")).toBe("");
235291
expect(read("?page=query")).toBe("");
292+
expect(read("?page=dashboards/queues")).toBe("dashboards");
236293
expect(read("?page=apikeys")).toBe("apikeys");
237294
});
238295
});
@@ -278,10 +335,10 @@ describe("pages named after a resource", () => {
278335
expect(projectPortablePage("tasks/scheduled/my-task")).toBe("");
279336
});
280337

281-
it("keep the built-in metric dashboards but not the one gated per organization", () => {
338+
it("keep the built-in metric dashboards, which are pages rather than saved dashboards", () => {
282339
expect(projectPortablePage("dashboards/overview")).toBe("dashboards/overview");
283340
expect(projectPortablePage("dashboards/llm")).toBe("dashboards/llm");
284-
expect(projectPortablePage("dashboards/queues")).toBe("dashboards");
341+
expect(projectPortablePage("dashboards/queues")).toBe("dashboards/queues");
285342
});
286343
});
287344

apps/webapp/app/utils/pageSwitching.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const NESTED_PORTABLE_PAGES = [
1111
"alerts/new",
1212
"dashboards/llm",
1313
"dashboards/overview",
14+
"dashboards/queues",
1415
"environment-variables/new",
1516
"models/compare",
1617
"schedules/new",
@@ -22,8 +23,8 @@ const NESTED_PORTABLE_PAGES = [
2223
/** The branch lists render under any environment of their project, but not in every project. */
2324
export const PROJECT_SPECIFIC_PAGES = ["branches", "dev-branches"];
2425

25-
/** Gated by an organization feature flag, so their loaders send you home from an organization without it. */
26-
export const ORGANIZATION_SPECIFIC_PAGES = ["logs", "query"];
26+
/** Gated by an organization feature flag, so their loaders turn you away in an organization without it. */
27+
export const ORGANIZATION_SPECIFIC_PAGES = ["logs", "query", "dashboards/queues"];
2728

2829
/** Every page below an environment that names no resource, so any environment can render it. */
2930
export const ENVIRONMENT_PORTABLE_PAGES: ReadonlySet<string> = new Set(

0 commit comments

Comments
 (0)