Skip to content

Commit 00e9925

Browse files
[cueweb/docs] Add Cuebot Facility switching (server-side gateway routing) (#2433)
## Related Issues Main issue: - #2016 Issues related to this PR: - #2437 ## Summarize your change. [cueweb] Add Cuebot Facility switching (server-side gateway routing) Wire the existing "Cuebot Facility" header selector (CueGUI parity) to actually re-route REST gateway calls per facility. Until now the selector only persisted an informational value in localStorage; all gateway calls went to a single NEXT_PUBLIC_OPENCUE_ENDPOINT. - [x] lib/facility.ts: resolve a facility name to its REST gateway URL + JWT secret from CUEBOT_<NAME>_REST_GATEWAY_URL / CUEBOT_<NAME>_JWT_SECRET, falling back to NEXT_PUBLIC_OPENCUE_ENDPOINT / NEXT_JWT_SECRET so the default deployment needs no new config. getRequestFacilityTarget() reads the selection from the request cookie and re-validates it against the configured list. next/headers is imported dynamically so the module stays out of the client bundle (api_utils is client-reachable). - [x] api_utils.ts: fetchObjectFromRestGateway resolves the per-request facility (single choke point for all proxy routes); createJwtToken takes an optional per-facility signing secret. - [x] api/health: probe the selected facility's gateway so the status bar reflects the active facility. - [x] use_cuebot_facility.ts: mirror the selection into the cueweb.facility cookie (so server routes see it), sync it on mount for pre-existing users, and reload on switch to re-fetch all data against the new gateway (mirroring CueGUI, which clears and refetches on a facility change). - [x] status-bar.tsx: show the active Cuebot facility. - [x] Document NEXT_PUBLIC_CUEBOT_FACILITIES and the paired CUEBOT_<NAME>_* vars in .env.example and docker-compose.yml. [cueweb/docs] Document Cuebot Facility switching (server-side gateway routing) Update the CueWeb docs to reflect that the Cuebot Facility menu now actually re-routes REST gateway calls per facility, rather than persisting an informational-only selection. - [x] getting-started/deploying-cueweb.md: remove the stale "per-facility routing is implemented in a separate page-level change" note; document that switching re-routes server-side via the cueweb.facility cookie and add the per-facility CUEBOT_<NAME>_REST_GATEWAY_URL / CUEBOT_<NAME>_JWT_SECRET vars (with fallback to NEXT_PUBLIC_OPENCUE_ENDPOINT / NEXT_JWT_SECRET). - [x] other-guides/cueweb.md, user-guides/cueweb-user-guide.md: expand the Cuebot Facility menu description (selecting a facility re-fetches all data from that facility's gateway, the choice persists, the active facility shows in the status bar) - CueGUI parity, user-facing. - [x] reference/cueweb.md: add the per-facility env-var rows; expand the dropdown entry with the cookie -> lib/facility.ts (getRequestFacilityTarget) -> per-request gateway resolution and the status-bar probe. - [x] developer-guide/cueweb-development.md: expand the useCuebotFacility hook entry (cookie mirror, reload-on-switch, the lib/facility.ts resolver, env precedence, dynamic next/headers note) and add lib/facility.ts to the file tree. ## LLM usage disclosure Parts of this solution's implementation were developed with assistance from Claude Opus.
1 parent 03dbe48 commit 00e9925

16 files changed

Lines changed: 337 additions & 20 deletions

File tree

cueweb/.env.example

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,19 @@
11
NEXT_PUBLIC_OPENCUE_ENDPOINT=http://your-rest-gateway-url.com
22

3+
# Cuebot Facility switching (CueGUI "Cuebot Facility" menu parity).
4+
# NEXT_PUBLIC_CUEBOT_FACILITIES lists the facilities shown in the header menu
5+
# (comma-separated). Defaults to "local,dev,cloud,external" when unset.
6+
# NEXT_PUBLIC_CUEBOT_FACILITIES=local,dev,cloud,external
7+
#
8+
# Each facility may point at its own REST gateway + JWT secret via paired,
9+
# server-only vars named CUEBOT_<NAME>_REST_GATEWAY_URL and
10+
# CUEBOT_<NAME>_JWT_SECRET (NAME uppercased). A facility with no override falls
11+
# back to NEXT_PUBLIC_OPENCUE_ENDPOINT / NEXT_JWT_SECRET, so the default
12+
# deployment works with only the "local" facility wired up.
13+
# CUEBOT_DEV_REST_GATEWAY_URL=http://dev-rest-gateway:8448
14+
# CUEBOT_DEV_JWT_SECRET=dev-gateway-jwt-secret
15+
# CUEBOT_CLOUD_REST_GATEWAY_URL=https://cloud-rest-gateway.example.com
16+
# CUEBOT_CLOUD_JWT_SECRET=cloud-gateway-jwt-secret
317
# Optional allow-list for the Stuck Frames "Last Line" reader
418
# (/api/stuck-frames/lastline), as a colon-separated list of absolute path
519
# prefixes. When set, only .rqlog files under one of these roots are read.

cueweb/Dockerfile

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,14 @@ COPY package*.json ./
1818
ENV SENTRYCLI_SKIP_DOWNLOAD=1
1919
# run npm install if npm ci is not working properly because of the package-lock.json
2020
# RUN npm install
21-
RUN npm ci
21+
# Retry on transient registry network errors (ECONNRESET / aborted) instead of
22+
# failing the build on the first blip. CI runners hit these intermittently.
23+
RUN npm ci \
24+
--fetch-retries=5 \
25+
--fetch-retry-factor=2 \
26+
--fetch-retry-mintimeout=20000 \
27+
--fetch-retry-maxtimeout=120000 \
28+
--fetch-timeout=600000
2229

2330
COPY *.json /opt/cueweb/
2431
COPY next.config.js /opt/cueweb/

cueweb/app/api/health/route.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import { NextResponse } from "next/server";
1818

1919
import { createJwtToken } from "@/app/utils/api_utils";
20+
import { getRequestFacilityTarget } from "@/lib/facility";
2021

2122
interface JwtParams {
2223
sub: string;
@@ -49,7 +50,9 @@ interface HealthBody {
4950
}
5051

5152
export async function GET(): Promise<NextResponse<HealthBody>> {
52-
const gateway = process.env.NEXT_PUBLIC_OPENCUE_ENDPOINT;
53+
// Probe the gateway for the facility selected in the request cookie, so the
54+
// status bar reflects the facility the rest of the app is talking to.
55+
const { gatewayUrl: gateway, jwtSecret } = await getRequestFacilityTarget();
5356
const checkedAt = new Date().toISOString();
5457

5558
if (!gateway) {
@@ -59,7 +62,7 @@ export async function GET(): Promise<NextResponse<HealthBody>> {
5962
status: 0,
6063
latencyMs: 0,
6164
checkedAt,
62-
error: "NEXT_PUBLIC_OPENCUE_ENDPOINT is not configured",
65+
error: "No REST gateway configured for the selected facility",
6366
},
6467
{ status: 200 },
6568
);
@@ -74,7 +77,7 @@ export async function GET(): Promise<NextResponse<HealthBody>> {
7477

7578
let token: string;
7679
try {
77-
token = createJwtToken(jwtParams);
80+
token = createJwtToken(jwtParams, jwtSecret);
7881
} catch (err) {
7982
console.error("Health JWT signing failed", err);
8083
return NextResponse.json(

cueweb/app/utils/api_utils.ts

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import jwt from "jsonwebtoken";
1818
import { NextResponse } from "next/server";
1919
import { handleError } from "./notify_utils";
20+
import { getRequestFacilityTarget } from "@/lib/facility";
2021

2122
/************************************************************/
2223
// Utility functions for accessing the Api including:
@@ -39,16 +40,27 @@ export async function fetchObjectFromRestGateway(
3940
method: string,
4041
body: string
4142
): Promise<NextResponse> {
42-
const NEXT_PUBLIC_OPENCUE_ENDPOINT = process.env.NEXT_PUBLIC_OPENCUE_ENDPOINT;
43-
const url = `${NEXT_PUBLIC_OPENCUE_ENDPOINT}${endpoint}`;
44-
43+
// Route to the gateway for the facility selected in the request cookie
44+
// (Cuebot Facility menu). Falls back to the default/legacy gateway when
45+
// no per-facility config is present.
46+
const { gatewayUrl, jwtSecret } = await getRequestFacilityTarget();
47+
if (!gatewayUrl) {
48+
// Misconfigured facility (no gateway URL and no default): fail with a
49+
// clear, diagnosable error instead of a generic fetch failure.
50+
return NextResponse.json(
51+
{ error: "No REST gateway configured for the selected facility" },
52+
{ status: 503 },
53+
);
54+
}
55+
const url = `${gatewayUrl}${endpoint}`;
56+
4557
const jwtParams: JwtParams = {
4658
sub: "user-id", // Replace with a user id
4759
role: "user-role", // Replace with the user's role
4860
iat: Math.floor(Date.now() / 1000),
4961
exp: Math.floor(Date.now() / 1000) + 3600, // Expires in 1 hour
5062
};
51-
const jwtToken = createJwtToken(jwtParams);
63+
const jwtToken = createJwtToken(jwtParams, jwtSecret);
5264

5365
try {
5466
const response = await fetch(url, {
@@ -73,11 +85,19 @@ export async function fetchObjectFromRestGateway(
7385
}
7486
}
7587

76-
// Create the JWT token given the payload parameters
77-
export function createJwtToken({ sub, role, iat, exp }: JwtParams): string {
78-
const NEXT_JWT_SECRET = process.env.NEXT_JWT_SECRET;
88+
// Create the JWT token given the payload parameters. The signing secret
89+
// defaults to NEXT_JWT_SECRET but can be overridden per facility (the target
90+
// gateway trusts its own secret).
91+
export function createJwtToken({ sub, role, iat, exp }: JwtParams, secret?: string): string {
92+
const signingSecret = secret ?? process.env.NEXT_JWT_SECRET;
93+
// Fail fast on a missing/blank secret rather than signing with an empty key.
94+
// Validate via trim() but sign with the original value (a gateway reading the
95+
// same env verbatim would not trim it).
96+
if (!signingSecret || signingSecret.trim() === "") {
97+
throw new Error("Missing JWT signing secret");
98+
}
7999
const payload = { sub, role, iat, exp };
80-
return jwt.sign(payload, NEXT_JWT_SECRET as string);
100+
return jwt.sign(payload, signingSecret);
81101
}
82102

83103

cueweb/app/utils/use_cuebot_facility.ts

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,46 @@ import * as React from "react";
2929
* across components via a CustomEvent (same tab) + the browser `storage`
3030
* event (cross-tab).
3131
*
32-
* NOTE: this hook persists and broadcasts the selection. Actually routing
33-
* REST-gateway calls per-facility is a separate task. Until that lands, the value is informational.
32+
* The selection is ALSO written to a cookie (`cueweb.facility`) so server-side
33+
* API routes can resolve the per-facility REST gateway for each request (see
34+
* `lib/facility.ts`). Selecting a facility reloads the page so every view
35+
* re-fetches against the newly selected gateway — mirroring CueGUI, which
36+
* clears and re-fetches all data on a facility switch.
3437
*/
3538

3639
export const STORAGE_KEY = "cueweb.facility.selected";
3740
const CHANGE_EVENT = "cueweb:facility-changed";
41+
// Cookie read server-side by lib/facility.ts (FACILITY_COOKIE). Keep in sync.
42+
const COOKIE_KEY = "cueweb.facility";
3843

3944
const DEFAULT_FACILITIES = ["local", "dev", "cloud", "external"] as const;
4045

46+
/** Mirror the selection into a cookie the server reads on every request. */
47+
function writeCookie(value: string): void {
48+
if (typeof document === "undefined") return;
49+
// Not HttpOnly: the client sets it for instant routing, and the server
50+
// re-validates the value against the configured facility list, so a tampered
51+
// cookie can only ever select another already-configured facility.
52+
const oneYear = 60 * 60 * 24 * 365;
53+
document.cookie = `${COOKIE_KEY}=${encodeURIComponent(value)}; path=/; max-age=${oneYear}; samesite=lax`;
54+
}
55+
56+
/** Read the facility cookie (returns null when absent). */
57+
function readCookie(): string | null {
58+
if (typeof document === "undefined") return null;
59+
const row = document.cookie
60+
.split("; ")
61+
.find((r) => r.startsWith(`${COOKIE_KEY}=`));
62+
if (!row) return null;
63+
try {
64+
// A malformed value (bad %-escape) would otherwise throw in the mount
65+
// effect and stop the hook from syncing/recovering.
66+
return decodeURIComponent(row.slice(COOKIE_KEY.length + 1));
67+
} catch {
68+
return null;
69+
}
70+
}
71+
4172
/** Parse the build-time env var; falls back to the CueGUI defaults. */
4273
function readFacilitiesFromEnv(): string[] {
4374
const raw = process.env.NEXT_PUBLIC_CUEBOT_FACILITIES ?? "";
@@ -84,7 +115,11 @@ export function useCuebotFacility(): {
84115
);
85116

86117
React.useEffect(() => {
87-
setFacilityState(readSelected(facilities));
118+
const current = readSelected(facilities);
119+
setFacilityState(current);
120+
// Propagate a pre-existing localStorage selection (set before the cookie
121+
// existed) to the cookie so server routes pick it up without a reselect.
122+
if (readCookie() !== current) writeCookie(current);
88123

89124
const customHandler = () => setFacilityState(readSelected(facilities));
90125
const storageHandler = (e: StorageEvent) => {
@@ -102,10 +137,16 @@ export function useCuebotFacility(): {
102137
const setFacility = React.useCallback(
103138
(next: string) => {
104139
if (!facilities.includes(next)) return;
140+
const previous = readSelected(facilities);
105141
writeSelected(next);
142+
writeCookie(next);
106143
setFacilityState(next);
107144
if (typeof window !== "undefined") {
108145
window.dispatchEvent(new CustomEvent(CHANGE_EVENT));
146+
// Re-fetch everything against the newly selected gateway. CueGUI clears
147+
// and reloads all data on a facility switch; a full reload is the
148+
// simplest equivalent and guarantees no stale cross-facility data.
149+
if (next !== previous) window.location.reload();
109150
}
110151
},
111152
[facilities],

cueweb/components/ui/status-bar.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,10 @@
1818

1919
import { usePathname } from "next/navigation";
2020
import * as React from "react";
21-
import { Activity, Clock, Tag } from "lucide-react";
21+
import { Activity, Clock, Server, Tag } from "lucide-react";
2222

2323
import { cn } from "@/lib/utils";
24+
import { useCuebotFacility } from "@/app/utils/use_cuebot_facility";
2425

2526
/**
2627
* IDE-style fixed status bar mounted at the bottom of every authenticated
@@ -92,6 +93,7 @@ function StatusItem({
9293

9394
export function StatusBar() {
9495
const pathname = usePathname();
96+
const { facility } = useCuebotFacility();
9597
const [health, setHealth] = React.useState<HealthBody | null>(null);
9698
const [lastRefresh, setLastRefresh] = React.useState<string | null>(null);
9799
// Tick once per second so relative timestamps stay fresh without waiting
@@ -221,6 +223,18 @@ export function StatusBar() {
221223

222224
<span className="mx-1 h-3 w-px bg-border dark:bg-zinc-700" aria-hidden="true" />
223225

226+
<StatusItem
227+
icon={Server}
228+
title={`Active Cuebot facility: ${facility}`}
229+
label={
230+
<>
231+
Facility: <span className="font-medium uppercase">{facility}</span>
232+
</>
233+
}
234+
/>
235+
236+
<span className="mx-1 h-3 w-px bg-border dark:bg-zinc-700" aria-hidden="true" />
237+
224238
<StatusItem
225239
icon={Clock}
226240
title={refreshTitle}

cueweb/lib/facility.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
/*
2+
* Copyright Contributors to the OpenCue Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
/**
18+
* Server-side Cuebot facility resolver (CueGUI "Cuebot Facility" parity).
19+
*
20+
* CueGUI maps a facility name to a list of Cuebot host:port pairs
21+
* (`cuebot.facility` in opencue's config) and rewires the gRPC connection
22+
* when the user switches (`Cuebot.setHostWithFacility`). CueWeb talks to the
23+
* REST gateway rather than gRPC directly, so the CueWeb analog maps a facility
24+
* name to a REST-gateway base URL + the JWT secret that gateway trusts.
25+
*
26+
* The selected facility travels from the browser to the server in a cookie
27+
* (see `FACILITY_COOKIE`); every gateway-bound API route resolves the target
28+
* for the current request via `getRequestFacilityTarget()`.
29+
*
30+
* Configuration (all server-side, optional):
31+
* NEXT_PUBLIC_CUEBOT_FACILITIES comma-separated facility names (the menu).
32+
* CUEBOT_<NAME>_REST_GATEWAY_URL gateway base URL for that facility.
33+
* CUEBOT_<NAME>_JWT_SECRET JWT secret for that facility's gateway.
34+
*
35+
* When a facility has no explicit *_REST_GATEWAY_URL / *_JWT_SECRET, it falls
36+
* back to the legacy single-gateway vars (NEXT_PUBLIC_OPENCUE_ENDPOINT /
37+
* NEXT_JWT_SECRET), so the default deployment keeps working with zero new
38+
* config and only the `local` facility wired up.
39+
*
40+
* NOTE: `getRequestFacilityTarget()` reads `next/headers` and only runs
41+
* server-side. It uses a *dynamic* import so this module stays free of a
42+
* static `next/headers` dependency — `api_utils.ts` (which imports this
43+
* module) is also part of the client bundle, and Next.js forbids a static
44+
* `next/headers` import anywhere in the client graph. The pure helpers below
45+
* are safe to import from anywhere. Client components that need the cookie
46+
* name use the literal in `use_cuebot_facility.ts`, kept in sync with
47+
* `FACILITY_COOKIE`.
48+
*/
49+
50+
/** Cookie carrying the selected facility name. Mirrors the localStorage value
51+
* written by `useCuebotFacility`; must match the literal there. */
52+
export const FACILITY_COOKIE = "cueweb.facility";
53+
54+
/** Default facility list — matches CueGUI's example `cuebot.facility` keys. */
55+
const DEFAULT_FACILITIES = ["local", "dev", "cloud", "external"] as const;
56+
57+
export interface FacilityTarget {
58+
/** The resolved (validated) facility name. */
59+
name: string;
60+
/** REST gateway base URL to send this request to. */
61+
gatewayUrl: string;
62+
/** JWT secret the target gateway trusts. */
63+
jwtSecret: string;
64+
}
65+
66+
/** The configured facility names (the menu). Falls back to the CueGUI defaults. */
67+
export function getConfiguredFacilities(): string[] {
68+
const raw = process.env.NEXT_PUBLIC_CUEBOT_FACILITIES ?? "";
69+
const parsed = raw
70+
.split(",")
71+
.map((s) => s.trim())
72+
.filter((s) => s.length > 0);
73+
return parsed.length > 0 ? parsed : [...DEFAULT_FACILITIES];
74+
}
75+
76+
/** "dev" -> "CUEBOT_DEV_REST_GATEWAY_URL". Non-alphanumerics become "_". */
77+
function envKey(facility: string, suffix: string): string {
78+
const norm = facility.toUpperCase().replace(/[^A-Z0-9]+/g, "_");
79+
return `CUEBOT_${norm}_${suffix}`;
80+
}
81+
82+
/**
83+
* Resolve a facility name to its gateway target. Unknown / unconfigured names
84+
* fall back to the first configured facility (the default), mirroring CueGUI's
85+
* `setHostWithFacility` which falls back to `cuebot.facility_default`.
86+
*/
87+
export function resolveFacilityTarget(name: string | undefined): FacilityTarget {
88+
const facilities = getConfiguredFacilities();
89+
const fallbackName = facilities[0] ?? "local";
90+
const facility = name && facilities.includes(name) ? name : fallbackName;
91+
92+
const gatewayUrl =
93+
process.env[envKey(facility, "REST_GATEWAY_URL")] ??
94+
process.env.NEXT_PUBLIC_OPENCUE_ENDPOINT ??
95+
"";
96+
const jwtSecret =
97+
process.env[envKey(facility, "JWT_SECRET")] ??
98+
process.env.NEXT_JWT_SECRET ??
99+
"";
100+
101+
return { name: facility, gatewayUrl, jwtSecret };
102+
}
103+
104+
/**
105+
* Resolve the facility target for the current request, reading the selection
106+
* from the request cookie and validating it against the configured list.
107+
* Safe to call outside a request scope (returns the default target).
108+
*/
109+
export async function getRequestFacilityTarget(): Promise<FacilityTarget> {
110+
let selected: string | undefined;
111+
try {
112+
// Dynamic import keeps next/headers out of the static client graph.
113+
const { cookies } = await import("next/headers");
114+
const store = await cookies();
115+
selected = store.get(FACILITY_COOKIE)?.value;
116+
} catch {
117+
// Not in a request scope (e.g. build-time): use the default.
118+
}
119+
return resolveFacilityTarget(selected);
120+
}

docker-compose.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,15 @@ services:
293293
environment:
294294
# Runtime environment variables
295295
- NEXT_PUBLIC_OPENCUE_ENDPOINT=http://rest-gateway:8448
296+
# Cuebot Facility switching (CueGUI "Cuebot Facility" menu parity).
297+
# NEXT_PUBLIC_CUEBOT_FACILITIES lists the facilities in the header menu;
298+
# defaults to "local,dev,cloud,external". Each facility can target its own
299+
# gateway via CUEBOT_<NAME>_REST_GATEWAY_URL + CUEBOT_<NAME>_JWT_SECRET
300+
# (server-only); a facility without overrides falls back to
301+
# NEXT_PUBLIC_OPENCUE_ENDPOINT / NEXT_JWT_SECRET below. Examples:
302+
# - NEXT_PUBLIC_CUEBOT_FACILITIES=local,dev,cloud,external
303+
# - CUEBOT_DEV_REST_GATEWAY_URL=http://dev-rest-gateway:8448
304+
# - CUEBOT_DEV_JWT_SECRET=${JWT_SECRET:-opencue-dev-jwt-secret-change-in-production}
296305
# See the build args block above - empty means same-origin relative.
297306
- NEXT_PUBLIC_URL=
298307
# See the build args block above. Runtime value is only useful if

0 commit comments

Comments
 (0)