Skip to content

Commit 5b117ef

Browse files
[cueweb/docs] Add view presets, immersive mode, and split-view workspaces (#2449)
## Related Issues - #2455 ## Summarize your change. [cueweb] Add view presets, immersive mode, and split-view workspaces Three web-native replacements for CueGUI window/layout affordances, all sharing the existing localStorage + cross-tab `storage`-event conventions. Saveable view presets (CueGUI "Save Window Settings"): - [x] New components/ui/views-menu.tsx: per-table "Views" dropdown to Save as…, Apply, Rename, Delete named presets capturing column order/visibility, sort, filters, and page size. Persists under cueweb.views.<page> with the active preset under cueweb.views.<page>.active; syncs across tabs. Built-in "Default" entry restores documented defaults and can't be renamed/deleted. - [x] Operates on the TanStack table instance, so it's table-agnostic. Wired into SimpleDataTable (hosts, allocations, shows, layers, frames) and the Jobs data-table toolbar. Immersive (full-screen) mode (CueGUI Toggle Full-Screen): - [x] New app/utils/use_immersive_mode.ts + components/ui/app-shell.tsx: hides the header, sidebar, and status bar so the active table gets the full viewport. - [x] Toggled via `F` / Cmd-Ctrl+Shift+F, the Other menu, and a floating Exit-immersive button. Persists under cueweb.layout.immersive; syncs tabs. Multi-pane split workspaces (CueGUI Window > "Add new window"): - [x] New /split route + components/ui/split-view.tsx: open two pages side-by-side in resizable iframe panes, each with its own URL (/split?left=/jobs&right=/hosts/server-01). Drag/keyboard divider resize (ratio persisted under cueweb.split.ratio); in-pane navigation syncs back to the URL so reload restores both panes. AppShell hides chrome inside panes. - [x] Register Immersive + Split view in the menu registry (Help search), add Suspense around useSearchParams on /split, and document all three in README + docs/_docs/reference/cueweb.md. - [x] Split-view: the per-pane page picker now offers Monitor Jobs, every CueCommander page (Allocations, Limits, Monitor Cue, Monitor Hosts, Redirect, Services, Shows, Stuck Frame, Subscription Graphs, Subscriptions), CueSubmit, the plugins index, and the Cue Progress Bar plugin, in sidebar order. - [x] Tests: views-menu, immersive_mode, and split_view_utils unit suites (jest green; tsc + lint clean; production build passes). [cueweb/docs] Document workspace layout (view presets, immersive mode, split view) across all guides: - [x] quick-starts: a "Customize your workspace" step. - [x] concepts: "Workspace layout (web-native windows)". - [x] getting-started: a "Workspace layout" note (client-side state; allow same-origin framing for split view behind a proxy). - [x] user-guide: a full "Workspace layout" section with view-preset, immersive, and split-view screenshots, plus the picker list. - [x] other-guides: a feature-list entry - [x] reference: corrected the split-view picker list. - [x] tutorials: a "Customizing your workspace" walkthrough with screenshots. - [x] developer-guide: a "Workspace layout" implementation section. ## LLM usage disclosure Parts of this solution's implementation were developed with assistance from Claude Opus.
1 parent 3af9662 commit 5b117ef

53 files changed

Lines changed: 2048 additions & 26 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

cueweb/README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,8 @@ CueWeb replicates the core functionality of CueGUI (Cuetopia and Cuecommander) i
9595
- The header and login page share the same OpenCue + CueWeb branding via the `CueWebIcon` component.
9696
- **Job management dashboard:**
9797
- Customizable table views: hide/show columns AND reorder them left/right inside each table's **Columns** dropdown, with a pinned **Reset to Default** button that restores both visibility and order. Both states persist in `localStorage` per table (Jobs: `columnVisibility` / `columnOrder`; Layers: `cueweb.layers.columnVisibility` / `cueweb.layers.columnOrder`; Frames: `cueweb.frames.columnVisibility` / `cueweb.frames.columnOrder`).
98+
- Saveable view presets (web-native replacement for CueGUI's *Save Window Settings*): each major table has a **Views** dropdown to **Save as…**, **Apply**, **Rename**, and **Delete** named presets capturing column order/visibility, sort, filters, and page size. Presets persist per page under `localStorage["cueweb.views.<page>"]` (e.g. `cueweb.views.jobs`, `cueweb.views.hosts`, `cueweb.views.frames`) with the active preset name under `cueweb.views.<page>.active`, and broadcast across tabs via the `storage` event. A built-in **Default** entry can't be renamed or deleted; selecting it restores the table to its documented defaults.
99+
- Multi-pane split workspace (web-native replacement for CueGUI's Window ▸ *Add new window*): **Other ▸ Split view** opens two pages side-by-side at `/split?left=/jobs&right=/hosts/server-01`. Drag the divider to resize (position persists under `localStorage["cueweb.split.ratio"]`); each pane is an independent same-origin view with its own URL (encoded in the page URL, so a reload restores both panes); a per-pane page picker, **Swap**, and **open-in-new-tab** round it out. Navigating inside a pane (e.g. clicking a host) updates that pane's URL in place.
98100
- CueGUI-parity Jobs columns: Name, **Comments** (sortable sticky-note column - sort to pull jobs with comments to the top), State, Done / Total, Running, Dead, Eaten, Wait, MaxRss, Age, Readable Age, **Launched**, **Eligible**, **Finished**, **User Color** (per-job color swatch persisted to `localStorage["cueweb.userColors"]` with cross-tab sync), Progress, Notify.
99101
- CueGUI-parity Layers columns: Dispatch Order, Name, Services, Limits, Range, Cores, Memory, Gpus, Gpu Memory, MaxRss, Total, Done, Run, Depend, Wait, Eaten, Dead, Avg, Tags, Progress (stacked animated bar with the same per-state palette as the Jobs progress bar), Timeout, Timeout LLU, **Eligible**.
100102
- CueGUI-parity Frames columns: Order, Frame, Layer, Status, Cores, GPUs, Host, Retries, CheckP, Runtime, **LLU** (only populated for `RUNNING` frames, matching CueGUI), **Memory (RSS)**, **Memory (PSS)**, GPU Memory, **Remain** (placeholder until the ETA predictor is wired in), Start Time, Stop Time, **Eligible Time**, **Submission Time**, **Last Line** (placeholder until the per-frame log-tail fetch is wired in).
@@ -439,7 +441,7 @@ Go back to [Contents](#contents).
439441

440442
## Keyboard shortcuts
441443

442-
CueWeb registers a small set of global keyboard shortcuts (mounted from `cueweb/app/layout.tsx` via `KeyboardShortcuts` in `cueweb/components/ui/shortcuts-overlay.tsx`). Single-letter shortcuts are ignored while typing into a text field, and modifier-key combos (Ctrl / Cmd / Alt) are passed through to the browser, so they will not collide with native shortcuts such as Ctrl+R (full page reload).
444+
CueWeb registers a small set of global keyboard shortcuts (mounted from `cueweb/app/layout.tsx` via `KeyboardShortcuts` in `cueweb/components/ui/shortcuts-overlay.tsx`). Single-letter shortcuts are ignored while typing into a text field, and modifier-key combos (Ctrl / Cmd / Alt) are passed through to the browser, so they will not collide with native shortcuts such as Ctrl+R (full page reload) — the one exception is the explicit immersive chord `Cmd/Ctrl+Shift+F`, which is captured so it works even from inside a search field.
443445

444446
| Key | Action | Where it works |
445447
|-----|--------|----------------|
@@ -448,6 +450,7 @@ CueWeb registers a small set of global keyboard shortcuts (mounted from `cueweb/
448450
| `/` | Focus the jobs search box | On the jobs page (`/`) |
449451
| `r` | Refresh the jobs table | On the jobs page (`/`) |
450452
| `t` | Toggle the light / dark theme | Anywhere |
453+
| `F` (or `Cmd/Ctrl+Shift+F`) | Toggle immersive (full-screen) mode — hides the header, sidebar and status bar; persists across reloads under `localStorage["cueweb.layout.immersive"]` and syncs across tabs | Anywhere |
451454

452455
The same overlay is also reachable from the menu, for users who prefer mouse navigation:
453456

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
/**
2+
* @jest-environment jsdom
3+
*/
4+
5+
/*
6+
* Copyright Contributors to the OpenCue Project
7+
*
8+
* Licensed under the Apache License, Version 2.0 (the "License");
9+
* you may not use this file except in compliance with the License.
10+
* You may obtain a copy of the License at
11+
*
12+
* http://www.apache.org/licenses/LICENSE-2.0
13+
*
14+
* Unless required by applicable law or agreed to in writing, software
15+
* distributed under the License is distributed on an "AS IS" BASIS,
16+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
17+
* See the License for the specific language governing permissions and
18+
* limitations under the License.
19+
*/
20+
import {
21+
applyView,
22+
captureView,
23+
loadViews,
24+
saveViews,
25+
View,
26+
} from "@/components/ui/views-menu";
27+
28+
// Minimal stand-in for the slice of the TanStack table API that captureView /
29+
// applyView touch. Lets us exercise the pure capture/apply logic without
30+
// mounting a real React table.
31+
function makeMockTable(opts: {
32+
columnIds: string[];
33+
visibility?: Record<string, boolean>;
34+
columnOrder?: string[];
35+
sorting?: { id: string; desc: boolean }[];
36+
columnFilters?: { id: string; value: unknown }[];
37+
pageSize?: number;
38+
}) {
39+
const state = {
40+
columnOrder: opts.columnOrder ?? [],
41+
sorting: opts.sorting ?? [],
42+
columnFilters: opts.columnFilters ?? [],
43+
pagination: { pageIndex: 0, pageSize: opts.pageSize ?? 10 },
44+
};
45+
const visibility: Record<string, boolean> = { ...(opts.visibility ?? {}) };
46+
47+
return {
48+
state,
49+
visibility,
50+
getAllLeafColumns: () => opts.columnIds.map((id) => ({ id })),
51+
getColumn: (id: string) => ({
52+
id,
53+
getIsVisible: () => visibility[id] !== false,
54+
}),
55+
getState: () => state,
56+
setColumnOrder: (order: string[]) => {
57+
state.columnOrder = order;
58+
},
59+
setColumnVisibility: (v: Record<string, boolean>) => {
60+
Object.keys(visibility).forEach((k) => delete visibility[k]);
61+
Object.assign(visibility, v);
62+
},
63+
setSorting: (s: { id: string; desc: boolean }[]) => {
64+
state.sorting = s;
65+
},
66+
setColumnFilters: (f: { id: string; value: unknown }[]) => {
67+
state.columnFilters = f;
68+
},
69+
setPageSize: (n: number) => {
70+
state.pagination.pageSize = n;
71+
},
72+
};
73+
}
74+
75+
describe("views-menu storage helpers", () => {
76+
beforeEach(() => {
77+
localStorage.clear();
78+
});
79+
80+
it("loadViews returns [] when nothing is stored", () => {
81+
expect(loadViews("jobs")).toEqual([]);
82+
});
83+
84+
it("loadViews returns [] for malformed JSON", () => {
85+
localStorage.setItem("cueweb.views.jobs", "{not json");
86+
expect(loadViews("jobs")).toEqual([]);
87+
});
88+
89+
it("loadViews returns [] when the stored value is not an array", () => {
90+
localStorage.setItem("cueweb.views.jobs", JSON.stringify({ a: 1 }));
91+
expect(loadViews("jobs")).toEqual([]);
92+
});
93+
94+
it("saveViews + loadViews round-trips a preset list", () => {
95+
const views: View[] = [
96+
{
97+
name: "Mine",
98+
columns: [{ id: "name", visible: true, order: 0 }],
99+
sort: [{ id: "name", dir: "asc" }],
100+
filters: [],
101+
pageSize: 25,
102+
},
103+
];
104+
saveViews("jobs", views);
105+
expect(localStorage.getItem("cueweb.views.jobs")).toBe(
106+
JSON.stringify(views),
107+
);
108+
expect(loadViews("jobs")).toEqual(views);
109+
});
110+
111+
it("namespaces presets per page", () => {
112+
saveViews("hosts", [
113+
{ name: "H", columns: [], sort: [], filters: [], pageSize: 10 },
114+
]);
115+
expect(loadViews("hosts")).toHaveLength(1);
116+
expect(loadViews("jobs")).toEqual([]);
117+
});
118+
});
119+
120+
describe("captureView", () => {
121+
it("captures column order, visibility, sort, filters and page size", () => {
122+
const table = makeMockTable({
123+
columnIds: ["select", "name", "state", "cores"],
124+
visibility: { cores: false },
125+
columnOrder: ["select", "state", "name", "cores"],
126+
sorting: [{ id: "name", desc: true }],
127+
columnFilters: [{ id: "state", value: "RUNNING" }],
128+
pageSize: 50,
129+
});
130+
131+
const view = captureView(table as any, "snap");
132+
133+
expect(view.name).toBe("snap");
134+
expect(view.columns.map((c) => c.id)).toEqual([
135+
"select",
136+
"state",
137+
"name",
138+
"cores",
139+
]);
140+
expect(view.columns.map((c) => c.order)).toEqual([0, 1, 2, 3]);
141+
expect(view.columns.find((c) => c.id === "cores")?.visible).toBe(false);
142+
expect(view.columns.find((c) => c.id === "name")?.visible).toBe(true);
143+
expect(view.sort).toEqual([{ id: "name", dir: "desc" }]);
144+
expect(view.filters).toEqual([{ id: "state", value: "RUNNING" }]);
145+
expect(view.pageSize).toBe(50);
146+
});
147+
148+
it("falls back to natural order and appends columns missing from columnOrder", () => {
149+
const table = makeMockTable({
150+
columnIds: ["a", "b", "c"],
151+
// Partial / stale order: only "c" listed, plus a bogus id to ignore.
152+
columnOrder: ["c", "ghost"],
153+
});
154+
155+
const view = captureView(table as any, "v");
156+
expect(view.columns.map((c) => c.id)).toEqual(["c", "a", "b"]);
157+
});
158+
});
159+
160+
describe("applyView", () => {
161+
it("writes order, visibility, sort, filters and page size back to the table", () => {
162+
const table = makeMockTable({ columnIds: ["a", "b", "c"] });
163+
const view: View = {
164+
name: "v",
165+
columns: [
166+
{ id: "b", visible: true, order: 0 },
167+
{ id: "a", visible: false, order: 1 },
168+
{ id: "c", visible: true, order: 2 },
169+
],
170+
sort: [{ id: "b", dir: "desc" }],
171+
filters: [{ id: "a", value: "x" }],
172+
pageSize: 100,
173+
};
174+
175+
applyView(table as any, view);
176+
177+
expect(table.state.columnOrder).toEqual(["b", "a", "c"]);
178+
expect(table.visibility).toEqual({ b: true, a: false, c: true });
179+
expect(table.state.sorting).toEqual([{ id: "b", desc: true }]);
180+
expect(table.state.columnFilters).toEqual([{ id: "a", value: "x" }]);
181+
expect(table.state.pagination.pageSize).toBe(100);
182+
});
183+
184+
it("sorts columns by their saved order before applying", () => {
185+
const table = makeMockTable({ columnIds: ["a", "b", "c"] });
186+
const view: View = {
187+
name: "v",
188+
columns: [
189+
{ id: "c", visible: true, order: 2 },
190+
{ id: "a", visible: true, order: 0 },
191+
{ id: "b", visible: true, order: 1 },
192+
],
193+
sort: [],
194+
filters: [],
195+
pageSize: 10,
196+
};
197+
198+
applyView(table as any, view);
199+
expect(table.state.columnOrder).toEqual(["a", "b", "c"]);
200+
});
201+
202+
it("ignores a non-positive page size", () => {
203+
const table = makeMockTable({ columnIds: ["a"], pageSize: 20 });
204+
applyView(table as any, {
205+
name: "v",
206+
columns: [{ id: "a", visible: true, order: 0 }],
207+
sort: [],
208+
filters: [],
209+
pageSize: 0,
210+
});
211+
expect(table.state.pagination.pageSize).toBe(20);
212+
});
213+
214+
it("round-trips through capture then apply", () => {
215+
const source = makeMockTable({
216+
columnIds: ["select", "name", "state"],
217+
visibility: { state: false },
218+
columnOrder: ["name", "select", "state"],
219+
sorting: [{ id: "name", desc: false }],
220+
pageSize: 15,
221+
});
222+
const view = captureView(source as any, "roundtrip");
223+
224+
const target = makeMockTable({ columnIds: ["select", "name", "state"] });
225+
applyView(target as any, view);
226+
227+
expect(target.state.columnOrder).toEqual(["name", "select", "state"]);
228+
expect(target.visibility).toEqual({
229+
name: true,
230+
select: true,
231+
state: false,
232+
});
233+
expect(target.state.sorting).toEqual([{ id: "name", desc: false }]);
234+
expect(target.state.pagination.pageSize).toBe(15);
235+
});
236+
});

0 commit comments

Comments
 (0)