diff --git a/cueweb/.env.example b/cueweb/.env.example index b4ff813e0..08e4168e9 100644 --- a/cueweb/.env.example +++ b/cueweb/.env.example @@ -1,5 +1,18 @@ NEXT_PUBLIC_OPENCUE_ENDPOINT=http://your-rest-gateway-url.com +# Optional allow-list for the frame preview route (/api/frame/preview), as a +# colon-separated list of absolute path prefixes. When set, the server only +# serves preview images located under one of these roots. When unset, preview +# reads are not restricted to a root (set this to harden the deployment). +# CUEWEB_PREVIEW_ROOTS=/mnt/render:/shows + +# Optional allow-list for the Stuck Frames "Last Line" reader +# (/api/stuck-frames/lastline), as a colon-separated list of absolute path +# prefixes. When set, only .rqlog files under one of these roots are read. +# When unset, reads are not restricted to a root (job log paths are +# site-specific). Set this to harden the deployment. +# CUEWEB_LOG_ROOTS=/mnt/logs:/shows + # Sentry values SENTRY_ENVIRONMENT='development' SENTRY_DSN = sentrydsn diff --git a/cueweb/Dockerfile b/cueweb/Dockerfile index d04746570..2ed9013a7 100644 --- a/cueweb/Dockerfile +++ b/cueweb/Dockerfile @@ -94,6 +94,29 @@ ENV NEXT_PUBLIC_OPENCUE_ENDPOINT=${NEXT_PUBLIC_OPENCUE_ENDPOINT} ARG NEXT_PUBLIC_LOG_EDITOR_URL= ENV NEXT_PUBLIC_LOG_EDITOR_URL=${NEXT_PUBLIC_LOG_EDITOR_URL} +# CueProgBar launcher (Monitor Jobs "Show Progress Bar"). CueProgBar is a +# desktop tool (python -m cuegui.cueguiplugin.cueprogbar ); a browser +# can only reach it via a registered URL scheme. {job} is replaced with the +# job name. Leave empty to fall back to the in-table Progress column. +ARG NEXT_PUBLIC_CUEPROGBAR_URL= +ENV NEXT_PUBLIC_CUEPROGBAR_URL=${NEXT_PUBLIC_CUEPROGBAR_URL} + +# The CueProgBar command shown in the "Show Progress Bar" dialog. {job} is +# replaced with the job name. Sites override this with their own launcher, +# e.g. "spawn launch cueprogbar {job}". +ARG NEXT_PUBLIC_CUEPROGBAR_COMMAND=python -m cuegui.cueguiplugin.cueprogbar {job} +ENV NEXT_PUBLIC_CUEPROGBAR_COMMAND=${NEXT_PUBLIC_CUEPROGBAR_COMMAND} + +# "Preview All" (Frames menu) opens rendered output in an external image +# viewer. PREVIEW_COMMAND is the command shown/copied; PREVIEW_URL is an +# optional registered URL scheme the Launch button hands off to a local +# handler. Placeholders {paths} (output paths), {job}, {layer}, {frame} are +# substituted. Override per site, e.g. a viewer scheme "openrv://{paths}". +ARG NEXT_PUBLIC_PREVIEW_COMMAND=rv {paths} +ENV NEXT_PUBLIC_PREVIEW_COMMAND=${NEXT_PUBLIC_PREVIEW_COMMAND} +ARG NEXT_PUBLIC_PREVIEW_URL= +ENV NEXT_PUBLIC_PREVIEW_URL=${NEXT_PUBLIC_PREVIEW_URL} + # Authentication providers - use ARG to allow override at build time # Set to empty string to disable authentication (sandbox mode) # Set to comma-separated list for production (e.g., "google,okta,github,ldap") diff --git a/cueweb/README.md b/cueweb/README.md index 1fa74e596..49dc24db0 100644 --- a/cueweb/README.md +++ b/cueweb/README.md @@ -104,6 +104,7 @@ CueWeb replicates the core functionality of CueGUI (Cuetopia and Cuecommander) i - Frame navigation with hyperlinks to logs and data pages. - Stacked job progress bar with a hover tooltip showing per-state frame counts and percentages (succeeded / running / waiting / depend / dead). The Layers table reuses the same `` renderer with `getLayerProgressSegments` so per-layer progress matches the per-job style. - Frame state filter chips above the frames table (`WAITING`, `RUNNING`, `SUCCEEDED`, `DEAD`, `EATEN`, `DEPEND`) with per-state counts, OR-combined selection, and selection mirrored to the `frameStates` URL query parameter for bookmarkable/shareable filtered views. + - Visual **frame range selector** above the frames table (CueGUI `FrameRangeSelection.py` parity): a horizontal strip with one state-colored cell per frame in ascending frame order. Click-drag selects a contiguous range, shift-click extends the selection from the anchor, and the selected subset feeds straight into the same **Retry** / **Eat** / **Kill** frame actions as the right-click menu (with a confirm step; Kill is destructive). The strip reflects the active frame-state filter and survives the 5s auto-refresh. - CueGUI-parity right-click menus on every row, following the CueGUI Monitor Jobs and Monitor Job Details menu structure. Menus scroll instead of overflowing on small viewports; items not yet implemented surface a friendly placeholder toast. - Mobile-friendly equivalent of right-click: every Jobs / Layers / Frames row has a small `⋮` button as its leftmost cell. Tapping it opens the same context menu the desktop right-click opens, so touch users get the full action set without a right-click event. - Wired copy actions: **Copy Job Name** (Job menu); **Copy Layer Name** (Layer menu); **Copy Frame Name** + **Copy Log Path** (Frame menu). Each pushes the value to the clipboard with a confirmation toast. Works whether CueWeb is served from `localhost` or from a LAN IP over plain HTTP. diff --git a/cueweb/app/__tests__/components/frame-range-selector.test.tsx b/cueweb/app/__tests__/components/frame-range-selector.test.tsx new file mode 100644 index 000000000..9113d950d --- /dev/null +++ b/cueweb/app/__tests__/components/frame-range-selector.test.tsx @@ -0,0 +1,146 @@ +/** + * @jest-environment jsdom + */ + +/* + * Copyright Contributors to the OpenCue Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import "@testing-library/jest-dom"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import type { Frame } from "@/app/frames/frame-columns"; +import { eatFrames, killFrames, retryFrames } from "@/app/utils/action_utils"; +import { FrameRangeSelector } from "@/components/ui/frame-range-selector"; + +// Mock the action layer so the test asserts the selected subset that gets +// handed off, not the network behavior (which has its own coverage). +jest.mock("@/app/utils/action_utils", () => ({ + retryFrames: jest.fn(), + eatFrames: jest.fn(), + killFrames: jest.fn(), +})); + +// The safety flag hook reads localStorage + window events; stub it "enabled". +jest.mock("@/app/utils/use_disable_job_interaction", () => ({ + useDisableJobInteraction: () => ({ disabled: false, setDisabled: jest.fn(), toggle: jest.fn() }), +})); + +function makeFrame(number: number, state = "DEAD"): Frame { + return { + id: `frame-${number}`, + name: `${number}-layer`, + layerName: "layer", + number, + state, + retryCount: 0, + exitStatus: 0, + dispatchOrder: number, + startTime: 0, + stopTime: 0, + maxRss: "0", + usedMemory: "0", + reservedMemory: "0", + reservedGpuMemory: "0", + lastResource: "/", + checkpointState: "", + checkpointCount: 0, + totalCoreTime: 0, + lluTime: 0, + totalGpuTime: 0, + maxGpuMemory: "0", + usedGpuMemory: "0", + frameStateDisplayOverride: "", + }; +} + +const FRAMES = [1, 2, 3, 4, 5].map((n) => makeFrame(n)); + +function cell(number: number): HTMLElement { + const el = document.querySelector(`[data-frame-number="${number}"]`); + if (!el) throw new Error(`cell #${number} not found`); + return el as HTMLElement; +} + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe("FrameRangeSelector", () => { + it("drag selects a contiguous range and feeds it into Retry", async () => { + render(); + + // Drag from frame #2 to frame #4 -> selects {2,3,4}. + fireEvent.mouseDown(cell(2)); + fireEvent.mouseEnter(cell(3)); + fireEvent.mouseEnter(cell(4)); + fireEvent.mouseUp(window); + + expect(screen.getByText(/Selected 3 frames \(#2–#4\)/)).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + + // Confirm in the dialog (there are two "Retry" buttons now; pick the + // one inside the dialog). + const dialog = await screen.findByRole("dialog"); + fireEvent.click(within(dialog).getByRole("button", { name: "Retry" })); + + await waitFor(() => expect(retryFrames).toHaveBeenCalledTimes(1)); + const handed = (retryFrames as jest.Mock).mock.calls[0][0] as Frame[]; + expect(handed.map((f) => f.number).sort((a, b) => a - b)).toEqual([2, 3, 4]); + expect(eatFrames).not.toHaveBeenCalled(); + expect(killFrames).not.toHaveBeenCalled(); + }); + + it("shift-click extends the selection from the anchor", async () => { + render(); + + // Anchor at #2 (single click), then shift-click #5 -> {2,3,4,5}. + fireEvent.mouseDown(cell(2)); + fireEvent.mouseUp(window); + fireEvent.mouseDown(cell(5), { shiftKey: true }); + + expect(screen.getByText(/Selected 4 frames \(#2–#5\)/)).toBeInTheDocument(); + }); + + it("routes Kill through a destructive confirm with the selected subset", async () => { + render(); + + fireEvent.mouseDown(cell(1)); + fireEvent.mouseEnter(cell(2)); + fireEvent.mouseUp(window); + + fireEvent.click(screen.getByRole("button", { name: "Kill" })); + const dialog = await screen.findByRole("dialog"); + fireEvent.click(within(dialog).getByRole("button", { name: "Kill" })); + + await waitFor(() => expect(killFrames).toHaveBeenCalledTimes(1)); + const [handed, username, reason] = (killFrames as jest.Mock).mock.calls[0]; + expect((handed as Frame[]).map((f) => f.number)).toEqual([1, 2]); + expect(username).toBe("tester"); + expect(reason).toMatch(/frame range selector/i); + }); + + it("Clear removes the current selection", () => { + render(); + + fireEvent.mouseDown(cell(1)); + fireEvent.mouseEnter(cell(3)); + fireEvent.mouseUp(window); + expect(screen.getByText(/Selected 3 frames/)).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Clear" })); + expect(screen.getByText(/Drag to select a range of 5 frames/)).toBeInTheDocument(); + }); +}); diff --git a/cueweb/app/api/department/getdepartmentnames/route.ts b/cueweb/app/api/department/getdepartmentnames/route.ts new file mode 100644 index 000000000..b0ce24599 --- /dev/null +++ b/cueweb/app/api/department/getdepartmentnames/route.ts @@ -0,0 +1,36 @@ +/* + * Copyright Contributors to the OpenCue Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { handleRoute } from '@/app/utils/api_utils'; +import { NextRequest, NextResponse } from "next/server"; + +// List the known department names (for the Group dialog's Department dropdown). +// Request: {} (empty). RPC: /department.DepartmentInterface/GetDepartmentNames. +export async function POST(request: NextRequest) { + const endpoint = "/department.DepartmentInterface/GetDepartmentNames"; + const method = request.method; + if (method !== 'POST') { + return NextResponse.json({ error: 'Invalid method. Only POST is allowed.' }, { status: 405 }); + } + + const response = await handleRoute(method, endpoint, JSON.stringify({})); + const responseData = await response.json(); + + if (!response.ok) { + return NextResponse.json({ error: responseData?.error ?? "Failed to fetch department names" }, { status: response.status }); + } + return NextResponse.json({ data: responseData.data?.names ?? [] }, { status: response.status }); +} diff --git a/cueweb/app/api/department/gettasks/route.ts b/cueweb/app/api/department/gettasks/route.ts new file mode 100644 index 000000000..96c15ac42 --- /dev/null +++ b/cueweb/app/api/department/gettasks/route.ts @@ -0,0 +1,44 @@ +/* + * Copyright Contributors to the OpenCue Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { handleRoute } from '@/app/utils/api_utils'; +import { NextRequest, NextResponse } from "next/server"; + +// List a department's tasks. Request: { department }. +// RPC: /department.DepartmentInterface/GetTasks (nested under tasks.tasks). +export async function POST(request: NextRequest) { + const endpoint = "/department.DepartmentInterface/GetTasks"; + const method = request.method; + if (method !== 'POST') { + return NextResponse.json({ error: 'Invalid method. Only POST is allowed.' }, { status: 405 }); + } + + let jsonBody: any; + try { + jsonBody = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 }); + } + const body = JSON.stringify(jsonBody); + if (!jsonBody || typeof jsonBody !== 'object' || !jsonBody.department?.id) { + return NextResponse.json({ error: 'Invalid request body: department is required' }, { status: 400 }); + } + + const response = await handleRoute(method, endpoint, body); + const responseData = await response.json(); + if (!response.ok) return NextResponse.json({ error: responseData.error }, { status: response.status }); + return NextResponse.json({ data: responseData.data?.tasks?.tasks ?? [] }, { status: response.status }); +} diff --git a/cueweb/app/api/filter/getactions/route.ts b/cueweb/app/api/filter/getactions/route.ts new file mode 100644 index 000000000..78689ee7d --- /dev/null +++ b/cueweb/app/api/filter/getactions/route.ts @@ -0,0 +1,44 @@ +/* + * Copyright Contributors to the OpenCue Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { handleRoute } from '@/app/utils/api_utils'; +import { NextRequest, NextResponse } from "next/server"; + +// List a filter's actions. Request: { filter }. +// RPC: /filter.FilterInterface/GetActions (nested under actions.actions). +export async function POST(request: NextRequest) { + const endpoint = "/filter.FilterInterface/GetActions"; + const method = request.method; + if (method !== 'POST') { + return NextResponse.json({ error: 'Invalid method. Only POST is allowed.' }, { status: 405 }); + } + + let jsonBody: any; + try { + jsonBody = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 }); + } + const body = JSON.stringify(jsonBody); + if (!jsonBody || typeof jsonBody !== 'object' || !jsonBody.filter?.id) { + return NextResponse.json({ error: 'Invalid request body: filter is required' }, { status: 400 }); + } + + const response = await handleRoute(method, endpoint, body); + const responseData = await response.json(); + if (!response.ok) return NextResponse.json({ error: responseData.error }, { status: response.status }); + return NextResponse.json({ data: responseData.data?.actions?.actions ?? [] }, { status: response.status }); +} diff --git a/cueweb/app/api/filter/getmatchers/route.ts b/cueweb/app/api/filter/getmatchers/route.ts new file mode 100644 index 000000000..86df62bd5 --- /dev/null +++ b/cueweb/app/api/filter/getmatchers/route.ts @@ -0,0 +1,44 @@ +/* + * Copyright Contributors to the OpenCue Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { handleRoute } from '@/app/utils/api_utils'; +import { NextRequest, NextResponse } from "next/server"; + +// List a filter's matchers. Request: { filter }. +// RPC: /filter.FilterInterface/GetMatchers (nested under matchers.matchers). +export async function POST(request: NextRequest) { + const endpoint = "/filter.FilterInterface/GetMatchers"; + const method = request.method; + if (method !== 'POST') { + return NextResponse.json({ error: 'Invalid method. Only POST is allowed.' }, { status: 405 }); + } + + let jsonBody: any; + try { + jsonBody = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 }); + } + const body = JSON.stringify(jsonBody); + if (!jsonBody || typeof jsonBody !== 'object' || !jsonBody.filter?.id) { + return NextResponse.json({ error: 'Invalid request body: filter is required' }, { status: 400 }); + } + + const response = await handleRoute(method, endpoint, body); + const responseData = await response.json(); + if (!response.ok) return NextResponse.json({ error: responseData.error }, { status: response.status }); + return NextResponse.json({ data: responseData.data?.matchers?.matchers ?? [] }, { status: response.status }); +} diff --git a/cueweb/app/api/filter/mutate/route.ts b/cueweb/app/api/filter/mutate/route.ts new file mode 100644 index 000000000..8f66dec57 --- /dev/null +++ b/cueweb/app/api/filter/mutate/route.ts @@ -0,0 +1,71 @@ +/* + * Copyright Contributors to the OpenCue Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { handleRoute } from '@/app/utils/api_utils'; +import { NextRequest, NextResponse } from "next/server"; + +// Consolidated proxy for the View Filters dialog mutations. The body is +// { op, ...payload }; `op` selects one of the allowlisted Filter / Matcher / +// Action RPCs and the rest of the body is forwarded verbatim. Returns the +// gateway response data (the created object for create ops). +const ENDPOINTS: Record = { + // ShowInterface + "show.createfilter": "/show.ShowInterface/CreateFilter", + // FilterInterface + "filter.setname": "/filter.FilterInterface/SetName", + "filter.settype": "/filter.FilterInterface/SetType", + "filter.setenabled": "/filter.FilterInterface/SetEnabled", + "filter.setorder": "/filter.FilterInterface/SetOrder", + "filter.raiseorder": "/filter.FilterInterface/RaiseOrder", + "filter.lowerorder": "/filter.FilterInterface/LowerOrder", + "filter.orderfirst": "/filter.FilterInterface/OrderFirst", + "filter.orderlast": "/filter.FilterInterface/OrderLast", + "filter.delete": "/filter.FilterInterface/Delete", + "filter.creatematcher": "/filter.FilterInterface/CreateMatcher", + "filter.createaction": "/filter.FilterInterface/CreateAction", + // MatcherInterface + "matcher.commit": "/filter.MatcherInterface/Commit", + "matcher.delete": "/filter.MatcherInterface/Delete", + // ActionInterface + "action.commit": "/filter.ActionInterface/Commit", + "action.delete": "/filter.ActionInterface/Delete", +}; + +export async function POST(request: NextRequest) { + const method = request.method; + if (method !== 'POST') { + return NextResponse.json({ error: 'Invalid method. Only POST is allowed.' }, { status: 405 }); + } + + let jsonBody: any; + try { + jsonBody = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 }); + } + const { op, ...payload } = jsonBody ?? {}; + const endpoint = typeof op === 'string' ? ENDPOINTS[op] : undefined; + if (!endpoint) { + return NextResponse.json({ error: `Unknown filter op: ${op}` }, { status: 400 }); + } + + const response = await handleRoute(method, endpoint, JSON.stringify(payload), true); + const responseData = await response.json(); + if (!response.ok) { + return NextResponse.json({ error: responseData?.error ?? `Failed: ${op}` }, { status: response.status }); + } + return NextResponse.json({ data: responseData.data ?? { success: true } }, { status: response.status }); +} diff --git a/cueweb/app/api/frame/action/createdependonframe/route.ts b/cueweb/app/api/frame/action/createdependonframe/route.ts index 36a4e22f2..21c94ff57 100644 --- a/cueweb/app/api/frame/action/createdependonframe/route.ts +++ b/cueweb/app/api/frame/action/createdependonframe/route.ts @@ -22,7 +22,7 @@ import { NextRequest, NextResponse } from "next/server"; // and Layer on Simulation Frame dependency wizard flows. The latter // loops this call once per source frame in the source layer. export async function POST(request: NextRequest) { - const endpoint = "/frame.FrameInterface/CreateDependencyOnFrame"; + const endpoint = "/job.FrameInterface/CreateDependencyOnFrame"; const method = request.method; if (method !== 'POST') { return NextResponse.json({ error: 'Invalid method. Only POST is allowed.' }, { status: 405 }); diff --git a/cueweb/app/api/frame/action/createdependonjob/route.ts b/cueweb/app/api/frame/action/createdependonjob/route.ts index f000cafd0..f03e14a0f 100644 --- a/cueweb/app/api/frame/action/createdependonjob/route.ts +++ b/cueweb/app/api/frame/action/createdependonjob/route.ts @@ -21,7 +21,7 @@ import { NextRequest, NextResponse } from "next/server"; // FrameInterface.CreateDependencyOnJob. Used by the Frame On Job // dependency wizard flow. export async function POST(request: NextRequest) { - const endpoint = "/frame.FrameInterface/CreateDependencyOnJob"; + const endpoint = "/job.FrameInterface/CreateDependencyOnJob"; const method = request.method; if (method !== 'POST') { return NextResponse.json({ error: 'Invalid method. Only POST is allowed.' }, { status: 405 }); diff --git a/cueweb/app/api/frame/action/createdependonlayer/route.ts b/cueweb/app/api/frame/action/createdependonlayer/route.ts index a5e9a9360..e0d48b109 100644 --- a/cueweb/app/api/frame/action/createdependonlayer/route.ts +++ b/cueweb/app/api/frame/action/createdependonlayer/route.ts @@ -21,7 +21,7 @@ import { NextRequest, NextResponse } from "next/server"; // FrameInterface.CreateDependencyOnLayer. Used by the Frame On Layer // dependency wizard flow. export async function POST(request: NextRequest) { - const endpoint = "/frame.FrameInterface/CreateDependencyOnLayer"; + const endpoint = "/job.FrameInterface/CreateDependencyOnLayer"; const method = request.method; if (method !== 'POST') { return NextResponse.json({ error: 'Invalid method. Only POST is allowed.' }, { status: 405 }); diff --git a/cueweb/app/api/frame/action/dropdepends/route.ts b/cueweb/app/api/frame/action/dropdepends/route.ts new file mode 100644 index 000000000..eb6f3a210 --- /dev/null +++ b/cueweb/app/api/frame/action/dropdepends/route.ts @@ -0,0 +1,42 @@ +/* + * Copyright Contributors to the OpenCue Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { handleRoute } from '@/app/utils/api_utils'; +import { NextRequest, NextResponse } from "next/server"; + +// Drop all dependencies on a frame (CueGUI FrameActions.dropDepends). +// RPC: /job.FrameInterface/DropDepends. Request: { frame, target } where +// target is a depend.DependTarget name (default "ANY_TARGET" = drop all). +export async function POST(request: NextRequest) { + const endpoint = "/job.FrameInterface/DropDepends"; + if (request.method !== 'POST') { + return NextResponse.json({ error: 'Invalid method. Only POST is allowed.' }, { status: 405 }); + } + let jsonBody: any; + try { + jsonBody = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); + } + if (!jsonBody?.frame) { + return NextResponse.json({ error: 'Invalid request body (need {frame})' }, { status: 400 }); + } + if (!jsonBody.target) jsonBody.target = "ANY_TARGET"; + const response = await handleRoute(request.method, endpoint, JSON.stringify(jsonBody), true); + const responseData = await response.json(); + if (!response.ok) return NextResponse.json({ error: responseData.error }, { status: response.status }); + return NextResponse.json({ data: responseData.data }, { status: response.status }); +} diff --git a/cueweb/app/api/frame/action/getdepends/route.ts b/cueweb/app/api/frame/action/getdepends/route.ts new file mode 100644 index 000000000..305b4ce3c --- /dev/null +++ b/cueweb/app/api/frame/action/getdepends/route.ts @@ -0,0 +1,46 @@ +/* + * Copyright Contributors to the OpenCue Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { handleRoute } from '@/app/utils/api_utils'; +import { NextRequest, NextResponse } from "next/server"; + +// Return the dependencies a frame depends on (CueGUI DependDialog -> +// getWhatThisDependsOn). RPC: /job.FrameInterface/GetWhatThisDependsOn. +// Request: { frame }. Response wraps a depend.DependSeq. +export async function POST(request: NextRequest) { + const endpoint = "/job.FrameInterface/GetWhatThisDependsOn"; + const method = request.method; + if (method !== 'POST') { + return NextResponse.json({ error: 'Invalid method. Only POST is allowed.' }, { status: 405 }); + } + + let jsonBody: any; + try { + jsonBody = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); + } + if (!jsonBody || typeof jsonBody !== 'object' || !jsonBody.frame) { + return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }); + } + const body = JSON.stringify(jsonBody); + + const response = await handleRoute(method, endpoint, body, true); + const responseData = await response.json(); + + if (!response.ok) return NextResponse.json({ error: responseData.error }, { status: response.status }); + return NextResponse.json({ data: responseData.data }, { status: response.status }); +} diff --git a/cueweb/app/api/frame/action/markaswaiting/route.ts b/cueweb/app/api/frame/action/markaswaiting/route.ts new file mode 100644 index 000000000..aae428e7d --- /dev/null +++ b/cueweb/app/api/frame/action/markaswaiting/route.ts @@ -0,0 +1,46 @@ +/* + * Copyright Contributors to the OpenCue Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { handleRoute } from '@/app/utils/api_utils'; +import { NextRequest, NextResponse } from "next/server"; + +// Mark a frame as waiting, ignoring its dependencies once (CueGUI +// FrameActions.markAsWaiting). RPC: /job.FrameInterface/MarkAsWaiting. +// Request: { frame }. +export async function POST(request: NextRequest) { + const endpoint = "/job.FrameInterface/MarkAsWaiting"; + const method = request.method; + if (method !== 'POST') { + return NextResponse.json({ error: 'Invalid method. Only POST is allowed.' }, { status: 405 }); + } + + let jsonBody: any; + try { + jsonBody = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); + } + if (!jsonBody || typeof jsonBody !== 'object' || !jsonBody.frame) { + return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }); + } + const body = JSON.stringify(jsonBody); + + const response = await handleRoute(method, endpoint, body, true); + const responseData = await response.json(); + + if (!response.ok) return NextResponse.json({ error: responseData.error }, { status: response.status }); + return NextResponse.json({ data: responseData.data }, { status: response.status }); +} diff --git a/cueweb/app/api/frame/preview/route.ts b/cueweb/app/api/frame/preview/route.ts new file mode 100644 index 000000000..d029bcfc0 --- /dev/null +++ b/cueweb/app/api/frame/preview/route.ts @@ -0,0 +1,135 @@ +/* + * Copyright Contributors to the OpenCue Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { promises as fs } from "fs"; +import { NextRequest, NextResponse } from "next/server"; +import path from "path"; +import { fileExtension, isWebRenderableImage } from "@/app/utils/preview_utils"; + +// Serves a rendered frame image from the server filesystem for the frame +// preview side panel. GET /api/frame/preview?path=. +// +// A browser can't read filesystem paths, so this route streams the bytes (the +// CueWeb container must have the render output mounted/readable). EXR-like +// formats return 415 so the panel shows its "not supported in browser" +// fallback. Path traversal is blocked and, when CUEWEB_PREVIEW_ROOTS is set +// (colon-separated absolute prefixes), reads are restricted to those roots. + +const MIME: Record = { + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + webp: "image/webp", + bmp: "image/bmp", + avif: "image/avif", + // SVG is intentionally omitted: serving it same-origin would allow script + // execution, so the route treats .svg as an unsupported format (415). +}; + +function allowedRoots(): string[] { + return (process.env.CUEWEB_PREVIEW_ROOTS ?? "") + .split(":") + .map((r) => r.trim()) + .filter(Boolean); +} + +export async function GET(request: NextRequest) { + const target = request.nextUrl.searchParams.get("path") ?? ""; + + if (!target || target.includes("\0")) { + return NextResponse.json({ error: "Missing or invalid path" }, { status: 400 }); + } + if (!path.isAbsolute(target)) { + return NextResponse.json({ error: "Path must be absolute" }, { status: 400 }); + } + const normalized = path.resolve(target); + + // Canonicalize via realpath before the boundary check: path.resolve is purely + // lexical, but fs.readFile follows symlinks, so a symlink inside an allowed + // root could otherwise resolve to a file outside it and still pass. realpath + // also requires the file to exist, surfacing missing/permission errors here. + let realTarget: string; + try { + realTarget = await fs.realpath(normalized); + } catch (error: any) { + console.error(`Frame preview realpath failed for ${normalized}:`, error?.code ?? error); + if (error?.code === "ENOENT") { + return NextResponse.json({ error: "File not found" }, { status: 404 }); + } + if (error?.code === "EACCES") { + return NextResponse.json({ error: "Permission denied" }, { status: 403 }); + } + return NextResponse.json({ error: "Could not read image" }, { status: 500 }); + } + + // When preview roots are configured, the canonical target must sit inside one + // of them (also canonicalized). Roots are an optional per-site allow-list; + // when unset, reads are not restricted to a root (render output paths are + // site-specific). A root that can't be resolved is treated as non-matching. + const rawRoots = allowedRoots(); + if (rawRoots.length > 0) { + const roots = ( + await Promise.all( + rawRoots.map(async (r) => { + try { + return await fs.realpath(path.resolve(r)); + } catch { + return null; + } + }), + ) + ).filter((r): r is string => r !== null); + const inAllowedRoot = roots.some((root) => { + const rel = path.relative(root, realTarget); + return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)); + }); + if (!inAllowedRoot) { + return NextResponse.json({ error: "Path is outside the allowed preview roots" }, { status: 403 }); + } + } + + const ext = fileExtension(realTarget); + if (!isWebRenderableImage(realTarget)) { + // EXR / TIFF / DPX / SVG etc. - the browser can't (safely) render inline. + return NextResponse.json( + { error: "unsupported", ext, message: "Preview not supported in browser for this format" }, + { status: 415 }, + ); + } + + try { + const data = await fs.readFile(realTarget); + return new NextResponse(new Uint8Array(data), { + status: 200, + headers: { + "Content-Type": MIME[ext] ?? "application/octet-stream", + "Cache-Control": "private, max-age=60", + }, + }); + } catch (error: any) { + // Log the resolved path server-side for diagnostics, but don't echo the + // server filesystem layout back to the client. + console.error(`Frame preview read failed for ${realTarget}:`, error?.code ?? error); + if (error?.code === "ENOENT") { + return NextResponse.json({ error: "File not found" }, { status: 404 }); + } + if (error?.code === "EACCES") { + return NextResponse.json({ error: "Permission denied" }, { status: 403 }); + } + return NextResponse.json({ error: "Could not read image" }, { status: 500 }); + } +} diff --git a/cueweb/app/api/getlog/route.ts b/cueweb/app/api/getlog/route.ts new file mode 100644 index 000000000..0cf88f3b8 --- /dev/null +++ b/cueweb/app/api/getlog/route.ts @@ -0,0 +1,77 @@ +/* + * Copyright Contributors to the OpenCue Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { promises as fs } from "fs"; +import path from "path"; +import { getServerSession } from "next-auth"; +import { NextRequest, NextResponse } from "next/server"; + +import { authOptions } from "@/lib/auth"; + +/** + * Download the raw frame log as a plain-text attachment. Backs the "Download" + * button on the frame log viewer; `path` is the currently-selected log version + * (the same path the viewer streams via /api/getlines). + * + * Auth: when an authentication provider is configured + * (`NEXT_PUBLIC_AUTH_PROVIDER`), a signed-in session is required; the sandbox + * (no provider) stays open, matching the rest of CueWeb. + */ + +// Download filename: the log basename with the `.rqlog` suffix swapped for +// `.log`, sanitized so it is safe to embed in the Content-Disposition header +// (the value is derived server-side, never taken from the client). +function downloadName(filePath: string): string { + const base = path.basename(filePath); + const stem = base.endsWith(".rqlog") ? base.slice(0, -".rqlog".length) : base; + const safe = stem.replace(/[^A-Za-z0-9._-]/g, "_"); + return `${safe || "log"}.log`; +} + +export async function GET(request: NextRequest) { + // Respect auth: require a session when authentication is configured. + if ((process.env.NEXT_PUBLIC_AUTH_PROVIDER ?? "").trim().length > 0) { + const session = await getServerSession(authOptions).catch(() => null); + if (!session?.user) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + } + } + + const filePath = request.nextUrl.searchParams.get("path"); + if (!filePath) { + return NextResponse.json({ error: "Query parameter 'path' is required" }, { status: 400 }); + } + + try { + const stat = await fs.stat(filePath); + if (!stat.isFile()) { + return NextResponse.json({ error: "Not a file" }, { status: 400 }); + } + // Read with fs (no shell) so the path can't be used for command injection. + const data = await fs.readFile(filePath); + return new NextResponse(data, { + status: 200, + headers: { + "Content-Type": "text/plain; charset=utf-8", + "Content-Disposition": `attachment; filename="${downloadName(filePath)}"`, + "Content-Length": String(stat.size), + "Cache-Control": "no-store", + }, + }); + } catch (error) { + return NextResponse.json({ error: "Log file not found" }, { status: 404 }); + } +} diff --git a/cueweb/app/api/getlogversions/route.ts b/cueweb/app/api/getlogversions/route.ts index e5180bcce..d1ca23ac4 100644 --- a/cueweb/app/api/getlogversions/route.ts +++ b/cueweb/app/api/getlogversions/route.ts @@ -14,41 +14,59 @@ * limitations under the License. */ -import { exec as execCallback } from "child_process"; import { promises as fs } from "fs"; import { NextResponse } from "next/server"; import path from "path"; -import { promisify } from "util"; -// Helper function to get all matching files in the folder -const exec = promisify(execCallback); +// One log version (the active log + its rotated retries), with the metadata the +// version dropdown shows: byte size and last-modified time (epoch ms). +interface LogVersion { + name: string; + size: number; + mtime: number; +} -async function getLogVersions(filename: string) { +async function getLogVersions(filename: string): Promise { const logDir = path.dirname(filename); const basename = path.basename(filename); - // Try to check the file, if there's an error finding it, return [] + // Bail out if the base log file isn't readable. try { - await exec(`wc -l ${path.join(logDir, basename)}`); + await fs.stat(path.join(logDir, basename)); } catch (error) { return []; } try { - // Read the directory and find matching files that start with the same basename + // Find matching files: the base log and its rotated versions (basename.N). const files = await fs.readdir(logDir); - const matchingFiles = files.filter((file) => - file === basename || file.startsWith(`${basename}.`) + const matchingFiles = files.filter( + (file) => file === basename || file.startsWith(`${basename}.`), + ); + + // stat each to get size + mtime (tolerate a file vanishing between readdir + // and stat, e.g. mid-rotation). + const versions = await Promise.all( + matchingFiles.map(async (name): Promise => { + try { + const stat = await fs.stat(path.join(logDir, name)); + return { name, size: stat.size, mtime: stat.mtimeMs }; + } catch { + return { name, size: 0, mtime: 0 }; + } + }), ); - // Sort the files: base file first, then by decreasing version number - matchingFiles.sort((a, b) => { - const versionA = a === basename ? Number.MAX_SAFE_INTEGER : parseInt(a.split('.').pop() || "0", 10); - const versionB = b === basename ? Number.MAX_SAFE_INTEGER : parseInt(b.split('.').pop() || "0", 10); - return versionB - versionA; + // Newest first by modified time; tie-break by version number (base log, + // treated as the highest, then decreasing .N) for stable ordering. + versions.sort((a, b) => { + if (b.mtime !== a.mtime) return b.mtime - a.mtime; + const va = a.name === basename ? Number.MAX_SAFE_INTEGER : parseInt(a.name.split(".").pop() || "0", 10); + const vb = b.name === basename ? Number.MAX_SAFE_INTEGER : parseInt(b.name.split(".").pop() || "0", 10); + return vb - va; }); - return matchingFiles; + return versions; } catch (error) { console.error("Error reading directory:", error); return []; diff --git a/cueweb/app/api/group/action/createsubgroup/route.ts b/cueweb/app/api/group/action/createsubgroup/route.ts new file mode 100644 index 000000000..fc0bda646 --- /dev/null +++ b/cueweb/app/api/group/action/createsubgroup/route.ts @@ -0,0 +1,53 @@ +/* + * Copyright Contributors to the OpenCue Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { handleRoute } from '@/app/utils/api_utils'; +import { NextRequest, NextResponse } from "next/server"; + +// Create a sub-group under a parent group. Request: { group, name }. +// RPC: /group.GroupInterface/CreateSubGroup. +export async function POST(request: NextRequest) { + const endpoint = "/job.GroupInterface/CreateSubGroup"; + const method = request.method; + if (method !== 'POST') { + return NextResponse.json({ error: 'Invalid method. Only POST is allowed.' }, { status: 405 }); + } + + let jsonBody: any; + try { + jsonBody = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 }); + } + if ( + !jsonBody || + typeof jsonBody !== 'object' || + !jsonBody.group?.id || + typeof jsonBody.name !== 'string' || + jsonBody.name.trim().length === 0 + ) { + return NextResponse.json({ error: 'Invalid request body: group and a non-empty name are required' }, { status: 400 }); + } + + const body = JSON.stringify(jsonBody); + const response = await handleRoute(method, endpoint, body, true); + const responseData = await response.json(); + + if (!response.ok) { + return NextResponse.json({ error: responseData?.error ?? "Failed to create group" }, { status: response.status }); + } + return NextResponse.json({ data: responseData.data }, { status: response.status }); +} diff --git a/cueweb/app/api/group/action/delete/route.ts b/cueweb/app/api/group/action/delete/route.ts new file mode 100644 index 000000000..4b254d7fc --- /dev/null +++ b/cueweb/app/api/group/action/delete/route.ts @@ -0,0 +1,44 @@ +/* + * Copyright Contributors to the OpenCue Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { handleRoute } from '@/app/utils/api_utils'; +import { NextRequest, NextResponse } from "next/server"; + +// Delete a group. Request: { group }. RPC: /job.GroupInterface/Delete. +export async function POST(request: NextRequest) { + const endpoint = "/job.GroupInterface/Delete"; + const method = request.method; + if (method !== 'POST') { + return NextResponse.json({ error: 'Invalid method. Only POST is allowed.' }, { status: 405 }); + } + + let jsonBody: any; + try { + jsonBody = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 }); + } + if (!jsonBody?.group?.id) { + return NextResponse.json({ error: 'Invalid request body: group is required' }, { status: 400 }); + } + + const response = await handleRoute(method, endpoint, JSON.stringify(jsonBody), true); + const responseData = await response.json(); + if (!response.ok) { + return NextResponse.json({ error: responseData?.error ?? "Failed to delete group" }, { status: response.status }); + } + return NextResponse.json({ data: responseData.data ?? { success: true } }, { status: response.status }); +} diff --git a/cueweb/app/api/group/action/update/route.ts b/cueweb/app/api/group/action/update/route.ts new file mode 100644 index 000000000..61eaa14c7 --- /dev/null +++ b/cueweb/app/api/group/action/update/route.ts @@ -0,0 +1,110 @@ +/* + * Copyright Contributors to the OpenCue Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { handleRoute } from '@/app/utils/api_utils'; +import { NextRequest, NextResponse } from "next/server"; + +// Apply a Group Properties edit. Request: { group, changes: {...} }. Each +// present field maps to one GroupInterface setter RPC (matching CueGUI's +// GroupDialog, which only calls the setters whose value changed). Cores are +// decimal cores; priority and gpus are integers. +// +// changes keys -> RPC + request field: +// name SetName { group, name } +// department SetDepartment { group, dept } +// defaultJobPriority SetDefaultJobPriority { group, priority } +// defaultJobMinCores SetDefaultJobMinCores { group, min_cores } +// defaultJobMaxCores SetDefaultJobMaxCores { group, max_cores } +// minCores SetMinCores { group, min_cores } +// maxCores SetMaxCores { group, max_cores } +// defaultJobMinGpus SetDefaultJobMinGpus { group, min_gpus } +// defaultJobMaxGpus SetDefaultJobMaxGpus { group, max_gpus } +// minGpus SetMinGpus { group, min_gpus } +// maxGpus SetMaxGpus { group, max_gpus } + +type Setter = { rpc: string; field: string }; + +const SETTERS: Record = { + name: { rpc: "SetName", field: "name" }, + department: { rpc: "SetDepartment", field: "dept" }, + defaultJobPriority: { rpc: "SetDefaultJobPriority", field: "priority" }, + defaultJobMinCores: { rpc: "SetDefaultJobMinCores", field: "min_cores" }, + defaultJobMaxCores: { rpc: "SetDefaultJobMaxCores", field: "max_cores" }, + minCores: { rpc: "SetMinCores", field: "min_cores" }, + maxCores: { rpc: "SetMaxCores", field: "max_cores" }, + defaultJobMinGpus: { rpc: "SetDefaultJobMinGpus", field: "min_gpus" }, + defaultJobMaxGpus: { rpc: "SetDefaultJobMaxGpus", field: "max_gpus" }, + minGpus: { rpc: "SetMinGpus", field: "min_gpus" }, + maxGpus: { rpc: "SetMaxGpus", field: "max_gpus" }, +}; + +export async function POST(request: NextRequest) { + const method = request.method; + if (method !== 'POST') { + return NextResponse.json({ error: 'Invalid method. Only POST is allowed.' }, { status: 405 }); + } + + let jsonBody: any; + try { + jsonBody = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 }); + } + const group = jsonBody?.group; + const changes = jsonBody?.changes; + if (!group?.id || !changes || typeof changes !== 'object') { + return NextResponse.json({ error: 'Invalid request body: group and changes are required' }, { status: 400 }); + } + + // Reject unknown keys rather than silently dropping them: a no-op that still + // reports success would hide a contract regression (e.g. a renamed setter key) + // and the user's edit would appear to apply when it did not. + const unknownKeys = Object.keys(changes).filter((key) => !(key in SETTERS)); + if (unknownKeys.length > 0) { + return NextResponse.json( + { error: `Unknown change keys: ${unknownKeys.join(", ")}` }, + { status: 400 }, + ); + } + + // Apply each changed field in turn; stop and report the first failure so the + // dialog can keep the modal open for retry. + for (const key of Object.keys(changes)) { + const setter = SETTERS[key]; + const value = changes[key]; + const isName = setter.field === "name" || setter.field === "dept"; + if (isName) { + if (typeof value !== 'string') { + return NextResponse.json({ error: `Invalid value for ${key}` }, { status: 400 }); + } + } else if (typeof value !== 'number' || !Number.isFinite(value)) { + return NextResponse.json({ error: `Invalid value for ${key}` }, { status: 400 }); + } + + const endpoint = `/job.GroupInterface/${setter.rpc}`; + const body = JSON.stringify({ group, [setter.field]: value }); + const response = await handleRoute(method, endpoint, body, true); + if (!response.ok) { + const data = await response.json().catch(() => ({})); + return NextResponse.json( + { error: data?.error ?? `Failed to apply ${key}` }, + { status: response.status }, + ); + } + } + + return NextResponse.json({ data: { success: true } }, { status: 200 }); +} diff --git a/cueweb/app/api/host/action/addcomment/route.ts b/cueweb/app/api/host/action/addcomment/route.ts new file mode 100644 index 000000000..b5da736b9 --- /dev/null +++ b/cueweb/app/api/host/action/addcomment/route.ts @@ -0,0 +1,39 @@ +/* + * Copyright Contributors to the OpenCue Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { handleRoute } from '@/app/utils/api_utils'; +import { NextRequest, NextResponse } from "next/server"; + +// Add a comment to a host. Request: { host, new_comment: { user, subject, +// message } }. RPC: /host.HostInterface/AddComment. +export async function POST(request: NextRequest) { + const endpoint = "/host.HostInterface/AddComment"; + if (request.method !== 'POST') { + return NextResponse.json({ error: 'Invalid method. Only POST is allowed.' }, { status: 405 }); + } + let jsonBody: any; + try { + jsonBody = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 }); + } + if (!jsonBody?.host || !jsonBody?.new_comment) { + return NextResponse.json({ error: 'Invalid request body: host and new_comment required' }, { status: 400 }); + } + // handleRoute already returns the final {data}/{error} NextResponse; return it + // directly so error propagation and status codes are preserved. + return handleRoute(request.method, endpoint, JSON.stringify(jsonBody), true); +} diff --git a/cueweb/app/api/host/action/delete/route.ts b/cueweb/app/api/host/action/delete/route.ts new file mode 100644 index 000000000..9571859ae --- /dev/null +++ b/cueweb/app/api/host/action/delete/route.ts @@ -0,0 +1,39 @@ +/* + * Copyright Contributors to the OpenCue Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { handleRoute } from '@/app/utils/api_utils'; +import { NextRequest, NextResponse } from "next/server"; + +// Delete a host (CueGUI "Delete Host", admin-only). Request: { host }. +// RPC: /host.HostInterface/Delete. +export async function POST(request: NextRequest) { + const endpoint = "/host.HostInterface/Delete"; + if (request.method !== 'POST') { + return NextResponse.json({ error: 'Invalid method. Only POST is allowed.' }, { status: 405 }); + } + let jsonBody: any; + try { + jsonBody = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 }); + } + if (!jsonBody?.host) { + return NextResponse.json({ error: 'Invalid request body: host required' }, { status: 400 }); + } + // handleRoute already returns the final {data}/{error} NextResponse; return it + // directly so error propagation and status codes are preserved. + return handleRoute(request.method, endpoint, JSON.stringify(jsonBody), true); +} diff --git a/cueweb/app/api/host/action/renametag/route.ts b/cueweb/app/api/host/action/renametag/route.ts new file mode 100644 index 000000000..e0fa91093 --- /dev/null +++ b/cueweb/app/api/host/action/renametag/route.ts @@ -0,0 +1,39 @@ +/* + * Copyright Contributors to the OpenCue Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { handleRoute } from '@/app/utils/api_utils'; +import { NextRequest, NextResponse } from "next/server"; + +// Rename a host tag. Request: { host, old_tag, new_tag }. +// RPC: /host.HostInterface/RenameTag. +export async function POST(request: NextRequest) { + const endpoint = "/host.HostInterface/RenameTag"; + if (request.method !== 'POST') { + return NextResponse.json({ error: 'Invalid method. Only POST is allowed.' }, { status: 405 }); + } + let jsonBody: any; + try { + jsonBody = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 }); + } + if (!jsonBody?.host || typeof jsonBody.old_tag !== 'string' || typeof jsonBody.new_tag !== 'string') { + return NextResponse.json({ error: 'Invalid request body: host, old_tag, new_tag required' }, { status: 400 }); + } + // handleRoute already returns the final {data}/{error} NextResponse; return it + // directly so error propagation and status codes are preserved. + return handleRoute(request.method, endpoint, JSON.stringify(jsonBody), true); +} diff --git a/cueweb/app/api/host/action/setallocation/route.ts b/cueweb/app/api/host/action/setallocation/route.ts new file mode 100644 index 000000000..3e21d4fe7 --- /dev/null +++ b/cueweb/app/api/host/action/setallocation/route.ts @@ -0,0 +1,39 @@ +/* + * Copyright Contributors to the OpenCue Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { handleRoute } from '@/app/utils/api_utils'; +import { NextRequest, NextResponse } from "next/server"; + +// Move a host to another allocation (CueGUI "Change Allocation"). Request: +// { host, allocation_id }. RPC: /host.HostInterface/SetAllocation. +export async function POST(request: NextRequest) { + const endpoint = "/host.HostInterface/SetAllocation"; + if (request.method !== 'POST') { + return NextResponse.json({ error: 'Invalid method. Only POST is allowed.' }, { status: 405 }); + } + let jsonBody: any; + try { + jsonBody = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 }); + } + if (!jsonBody?.host || typeof jsonBody.allocation_id !== 'string' || !jsonBody.allocation_id) { + return NextResponse.json({ error: 'Invalid request body: host and allocation_id required' }, { status: 400 }); + } + // handleRoute already returns the final {data}/{error} NextResponse; return it + // directly so error propagation and status codes are preserved. + return handleRoute(request.method, endpoint, JSON.stringify(jsonBody), true); +} diff --git a/cueweb/app/api/host/action/sethardwarestate/route.ts b/cueweb/app/api/host/action/sethardwarestate/route.ts new file mode 100644 index 000000000..b8312e435 --- /dev/null +++ b/cueweb/app/api/host/action/sethardwarestate/route.ts @@ -0,0 +1,48 @@ +/* + * Copyright Contributors to the OpenCue Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { handleRoute } from '@/app/utils/api_utils'; +import { NextRequest, NextResponse } from "next/server"; + +// Set a host's hardware state (CueGUI "Set/Clear Repair State"). Request: +// { host, state } where state is a HardwareState enum name (e.g. "REPAIR", +// "DOWN", "UP"). RPC: /host.HostInterface/SetHardwareState. +export async function POST(request: NextRequest) { + const endpoint = "/host.HostInterface/SetHardwareState"; + if (request.method !== 'POST') { + return NextResponse.json({ error: 'Invalid method. Only POST is allowed.' }, { status: 405 }); + } + let jsonBody: any; + try { + jsonBody = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 }); + } + if (!jsonBody?.host || typeof jsonBody.state !== 'string') { + return NextResponse.json({ error: 'Invalid request body: host and state required' }, { status: 400 }); + } + // state must be a host.HardwareState enum name (proto/src/host.proto). + const VALID_STATES = ["UP", "DOWN", "REBOOTING", "REBOOT_WHEN_IDLE", "REPAIR"]; + if (!VALID_STATES.includes(jsonBody.state)) { + return NextResponse.json( + { error: `Invalid state: must be one of ${VALID_STATES.join(", ")}` }, + { status: 400 }, + ); + } + // handleRoute already returns the final {data}/{error} NextResponse; return it + // directly so error propagation and status codes are preserved. + return handleRoute(request.method, endpoint, JSON.stringify(jsonBody), true); +} diff --git a/cueweb/app/api/host/action/takeownership/route.ts b/cueweb/app/api/host/action/takeownership/route.ts new file mode 100644 index 000000000..a097f668f --- /dev/null +++ b/cueweb/app/api/host/action/takeownership/route.ts @@ -0,0 +1,41 @@ +/* + * Copyright Contributors to the OpenCue Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { handleRoute } from '@/app/utils/api_utils'; +import { NextRequest, NextResponse } from "next/server"; + +// Claim ownership of a host for a user (CueGUI HostActions.takeOwnership). +// RPC: /host.OwnerInterface/TakeOwnership. Request: { owner: { name }, host } +// where `host` is the host name string (OwnerTakeOwnershipRequest in +// proto/src/host.proto). +export async function POST(request: NextRequest) { + const endpoint = "/host.OwnerInterface/TakeOwnership"; + if (request.method !== 'POST') { + return NextResponse.json({ error: 'Invalid method. Only POST is allowed.' }, { status: 405 }); + } + let jsonBody: any; + try { + jsonBody = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 }); + } + if (!jsonBody?.owner?.name || typeof jsonBody.host !== 'string' || !jsonBody.host) { + return NextResponse.json({ error: 'Invalid request body: owner.name and host (name) required' }, { status: 400 }); + } + // handleRoute already returns the final {data}/{error} NextResponse; return it + // directly so error propagation and status codes are preserved. + return handleRoute(request.method, endpoint, JSON.stringify(jsonBody), true); +} diff --git a/cueweb/app/api/job/action/addrenderpart/route.ts b/cueweb/app/api/job/action/addrenderpart/route.ts new file mode 100644 index 000000000..e770db86d --- /dev/null +++ b/cueweb/app/api/job/action/addrenderpart/route.ts @@ -0,0 +1,52 @@ +/* + * Copyright Contributors to the OpenCue Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { handleRoute } from '@/app/utils/api_utils'; +import { NextRequest, NextResponse } from "next/server"; + +// Add a render partition to a job (CueGUI "Use Local Cores" - book a host's +// resources to the job). Request: { job, host, threads, max_cores, +// max_memory, max_gpu_memory, max_gpus, username }. +// RPC: /job.JobInterface/AddRenderPartition. +export async function POST(request: NextRequest) { + const endpoint = "/job.JobInterface/AddRenderPartition"; + if (request.method !== 'POST') { + return NextResponse.json({ error: 'Invalid method. Only POST is allowed.' }, { status: 405 }); + } + let jsonBody: any; + try { + jsonBody = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 }); + } + if (!jsonBody?.job || typeof jsonBody.host !== 'string' || !jsonBody.host) { + return NextResponse.json({ error: 'Invalid request body: job and host required' }, { status: 400 }); + } + const response = await handleRoute(request.method, endpoint, JSON.stringify(jsonBody), true); + const responseData = await response.json(); + if (!response.ok) { + // Cuebot only allows a local render partition on a NIMBY-locked host (the + // workstation must be reserved for local use first). Surface that as a + // clear message instead of the raw SpcueRuntimeException stack trace. + const raw = String(responseData?.error ?? ""); + const friendly = /not NIMBY locked/i.test(raw) + ? `Use Local Cores requires host "${jsonBody.host}" to be NIMBY-locked first ` + + `(this reserves the workstation for local use). NIMBY-lock the host, then try again.` + : responseData.error; + return NextResponse.json({ error: friendly }, { status: response.status }); + } + return NextResponse.json({ data: responseData.data }, { status: response.status }); +} diff --git a/cueweb/app/api/job/action/markdoneframes/route.ts b/cueweb/app/api/job/action/markdoneframes/route.ts new file mode 100644 index 000000000..d4bdc805c --- /dev/null +++ b/cueweb/app/api/job/action/markdoneframes/route.ts @@ -0,0 +1,42 @@ +/* + * Copyright Contributors to the OpenCue Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { handleRoute } from '@/app/utils/api_utils'; +import { NextRequest, NextResponse } from "next/server"; + +// Mark a job's frames done, selected by a FrameSearchCriteria (CueGUI +// FrameActions.markdone / eatandmarkdone -> job.markdoneFrames(name=...)). +// RPC: /job.JobInterface/MarkDoneFrames. Request: { job, req } where req is a +// FrameSearchCriteria (e.g. { frames: [""] }). +export async function POST(request: NextRequest) { + const endpoint = "/job.JobInterface/MarkDoneFrames"; + if (request.method !== 'POST') { + return NextResponse.json({ error: 'Invalid method. Only POST is allowed.' }, { status: 405 }); + } + let jsonBody: any; + try { + jsonBody = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); + } + if (!jsonBody?.job || !jsonBody.req || typeof jsonBody.req !== 'object') { + return NextResponse.json({ error: 'Invalid request body (need {job, req})' }, { status: 400 }); + } + const response = await handleRoute(request.method, endpoint, JSON.stringify(jsonBody), true); + const responseData = await response.json(); + if (!response.ok) return NextResponse.json({ error: responseData.error }, { status: response.status }); + return NextResponse.json({ data: responseData.data }, { status: response.status }); +} diff --git a/cueweb/app/api/job/action/reorderframes/route.ts b/cueweb/app/api/job/action/reorderframes/route.ts new file mode 100644 index 000000000..1ef9e3672 --- /dev/null +++ b/cueweb/app/api/job/action/reorderframes/route.ts @@ -0,0 +1,41 @@ +/* + * Copyright Contributors to the OpenCue Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { handleRoute } from '@/app/utils/api_utils'; +import { NextRequest, NextResponse } from "next/server"; + +// Reorder a job's frames. Request: { job, range, order } where order is an +// Order enum name: "FIRST" | "LAST" | "REVERSE". +// RPC: /job.JobInterface/ReorderFrames. +export async function POST(request: NextRequest) { + const endpoint = "/job.JobInterface/ReorderFrames"; + if (request.method !== 'POST') { + return NextResponse.json({ error: 'Invalid method. Only POST is allowed.' }, { status: 405 }); + } + let jsonBody: any; + try { + jsonBody = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 }); + } + if (!jsonBody?.job || typeof jsonBody.range !== 'string' || typeof jsonBody.order !== 'string') { + return NextResponse.json({ error: 'Invalid request body: job, range, order required' }, { status: 400 }); + } + const response = await handleRoute(request.method, endpoint, JSON.stringify(jsonBody), true); + const responseData = await response.json(); + if (!response.ok) return NextResponse.json({ error: responseData.error }, { status: response.status }); + return NextResponse.json({ data: responseData.data }, { status: response.status }); +} diff --git a/cueweb/app/api/job/action/setmaxgpus/route.ts b/cueweb/app/api/job/action/setmaxgpus/route.ts new file mode 100644 index 000000000..33d8fd960 --- /dev/null +++ b/cueweb/app/api/job/action/setmaxgpus/route.ts @@ -0,0 +1,40 @@ +/* + * Copyright Contributors to the OpenCue Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { handleRoute } from '@/app/utils/api_utils'; +import { NextRequest, NextResponse } from "next/server"; + +// Set a job's maximum GPUs. Request: { job, val }. +// RPC: /job.JobInterface/SetMaxGpus. +export async function POST(request: NextRequest) { + const endpoint = "/job.JobInterface/SetMaxGpus"; + if (request.method !== 'POST') { + return NextResponse.json({ error: 'Invalid method. Only POST is allowed.' }, { status: 405 }); + } + let jsonBody: any; + try { + jsonBody = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 }); + } + if (!jsonBody?.job || typeof jsonBody.val !== 'number') { + return NextResponse.json({ error: 'Invalid request body: job and numeric val required' }, { status: 400 }); + } + const response = await handleRoute(request.method, endpoint, JSON.stringify(jsonBody), true); + const responseData = await response.json(); + if (!response.ok) return NextResponse.json({ error: responseData.error }, { status: response.status }); + return NextResponse.json({ data: responseData.data }, { status: response.status }); +} diff --git a/cueweb/app/api/job/action/setmingpus/route.ts b/cueweb/app/api/job/action/setmingpus/route.ts new file mode 100644 index 000000000..46076de03 --- /dev/null +++ b/cueweb/app/api/job/action/setmingpus/route.ts @@ -0,0 +1,40 @@ +/* + * Copyright Contributors to the OpenCue Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { handleRoute } from '@/app/utils/api_utils'; +import { NextRequest, NextResponse } from "next/server"; + +// Set a job's minimum GPUs. Request: { job, val }. +// RPC: /job.JobInterface/SetMinGpus. +export async function POST(request: NextRequest) { + const endpoint = "/job.JobInterface/SetMinGpus"; + if (request.method !== 'POST') { + return NextResponse.json({ error: 'Invalid method. Only POST is allowed.' }, { status: 405 }); + } + let jsonBody: any; + try { + jsonBody = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 }); + } + if (!jsonBody?.job || typeof jsonBody.val !== 'number') { + return NextResponse.json({ error: 'Invalid request body: job and numeric val required' }, { status: 400 }); + } + const response = await handleRoute(request.method, endpoint, JSON.stringify(jsonBody), true); + const responseData = await response.json(); + if (!response.ok) return NextResponse.json({ error: responseData.error }, { status: response.status }); + return NextResponse.json({ data: responseData.data }, { status: response.status }); +} diff --git a/cueweb/app/api/job/action/staggerframes/route.ts b/cueweb/app/api/job/action/staggerframes/route.ts new file mode 100644 index 000000000..c6028a530 --- /dev/null +++ b/cueweb/app/api/job/action/staggerframes/route.ts @@ -0,0 +1,40 @@ +/* + * Copyright Contributors to the OpenCue Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { handleRoute } from '@/app/utils/api_utils'; +import { NextRequest, NextResponse } from "next/server"; + +// Stagger a job's frames. Request: { job, range, stagger }. +// RPC: /job.JobInterface/StaggerFrames. +export async function POST(request: NextRequest) { + const endpoint = "/job.JobInterface/StaggerFrames"; + if (request.method !== 'POST') { + return NextResponse.json({ error: 'Invalid method. Only POST is allowed.' }, { status: 405 }); + } + let jsonBody: any; + try { + jsonBody = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 }); + } + if (!jsonBody?.job || typeof jsonBody.range !== 'string' || typeof jsonBody.stagger !== 'number') { + return NextResponse.json({ error: 'Invalid request body: job, range, numeric stagger required' }, { status: 400 }); + } + const response = await handleRoute(request.method, endpoint, JSON.stringify(jsonBody), true); + const responseData = await response.json(); + if (!response.ok) return NextResponse.json({ error: responseData.error }, { status: response.status }); + return NextResponse.json({ data: responseData.data }, { status: response.status }); +} diff --git a/cueweb/app/api/layer/action/createdependonframe/route.ts b/cueweb/app/api/layer/action/createdependonframe/route.ts index ccdc6c510..66e61fbfd 100644 --- a/cueweb/app/api/layer/action/createdependonframe/route.ts +++ b/cueweb/app/api/layer/action/createdependonframe/route.ts @@ -21,7 +21,7 @@ import { NextRequest, NextResponse } from "next/server"; // LayerInterface.CreateDependencyOnFrame. Used by the Layer On Frame // dependency wizard flow. export async function POST(request: NextRequest) { - const endpoint = "/layer.LayerInterface/CreateDependencyOnFrame"; + const endpoint = "/job.LayerInterface/CreateDependencyOnFrame"; const method = request.method; if (method !== 'POST') { return NextResponse.json({ error: 'Invalid method. Only POST is allowed.' }, { status: 405 }); diff --git a/cueweb/app/api/layer/action/createdependonjob/route.ts b/cueweb/app/api/layer/action/createdependonjob/route.ts index 265e0961f..4f4b9dae3 100644 --- a/cueweb/app/api/layer/action/createdependonjob/route.ts +++ b/cueweb/app/api/layer/action/createdependonjob/route.ts @@ -21,7 +21,7 @@ import { NextRequest, NextResponse } from "next/server"; // LayerInterface.CreateDependencyOnJob. Used by the Layer On Job // dependency wizard flow. export async function POST(request: NextRequest) { - const endpoint = "/layer.LayerInterface/CreateDependencyOnJob"; + const endpoint = "/job.LayerInterface/CreateDependencyOnJob"; const method = request.method; if (method !== 'POST') { return NextResponse.json({ error: 'Invalid method. Only POST is allowed.' }, { status: 405 }); diff --git a/cueweb/app/api/layer/action/createdependonlayer/route.ts b/cueweb/app/api/layer/action/createdependonlayer/route.ts index a65d203bf..4b11e303b 100644 --- a/cueweb/app/api/layer/action/createdependonlayer/route.ts +++ b/cueweb/app/api/layer/action/createdependonlayer/route.ts @@ -21,7 +21,7 @@ import { NextRequest, NextResponse } from "next/server"; // LayerInterface.CreateDependencyOnLayer. Used by the Layer On Layer // dependency wizard flow. export async function POST(request: NextRequest) { - const endpoint = "/layer.LayerInterface/CreateDependencyOnLayer"; + const endpoint = "/job.LayerInterface/CreateDependencyOnLayer"; const method = request.method; if (method !== 'POST') { return NextResponse.json({ error: 'Invalid method. Only POST is allowed.' }, { status: 405 }); diff --git a/cueweb/app/api/layer/action/createframebyframedepend/route.ts b/cueweb/app/api/layer/action/createframebyframedepend/route.ts index 782edb3b4..ffb85fc13 100644 --- a/cueweb/app/api/layer/action/createframebyframedepend/route.ts +++ b/cueweb/app/api/layer/action/createframebyframedepend/route.ts @@ -22,7 +22,7 @@ import { NextRequest, NextResponse } from "next/server"; // Frame depend type and by the "Frame By Frame for all layers (Hard // Depend)" wizard option, which calls this once per matched layer pair. export async function POST(request: NextRequest) { - const endpoint = "/layer.LayerInterface/CreateFrameByFrameDependency"; + const endpoint = "/job.LayerInterface/CreateFrameByFrameDependency"; const method = request.method; if (method !== 'POST') { return NextResponse.json({ error: 'Invalid method. Only POST is allowed.' }, { status: 405 }); diff --git a/cueweb/app/api/layer/action/getdepends/route.ts b/cueweb/app/api/layer/action/getdepends/route.ts new file mode 100644 index 000000000..e685d2c69 --- /dev/null +++ b/cueweb/app/api/layer/action/getdepends/route.ts @@ -0,0 +1,46 @@ +/* + * Copyright Contributors to the OpenCue Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { handleRoute } from '@/app/utils/api_utils'; +import { NextRequest, NextResponse } from "next/server"; + +// Return the dependencies this layer depends on (CueGUI DependDialog -> +// getWhatThisDependsOn). RPC: /job.LayerInterface/GetWhatThisDependsOn. +// Request: { layer }. Response wraps a depend.DependSeq. +export async function POST(request: NextRequest) { + const endpoint = "/job.LayerInterface/GetWhatThisDependsOn"; + const method = request.method; + if (method !== 'POST') { + return NextResponse.json({ error: 'Invalid method. Only POST is allowed.' }, { status: 405 }); + } + + let jsonBody: any; + try { + jsonBody = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 }); + } + if (!jsonBody || typeof jsonBody !== 'object' || !jsonBody.layer) { + return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }); + } + const body = JSON.stringify(jsonBody); + + const response = await handleRoute(method, endpoint, body, true); + const responseData = await response.json(); + + if (!response.ok) return NextResponse.json({ error: responseData.error }, { status: response.status }); + return NextResponse.json({ data: responseData.data }, { status: response.status }); +} diff --git a/cueweb/app/api/layer/action/getoutputpaths/route.ts b/cueweb/app/api/layer/action/getoutputpaths/route.ts new file mode 100644 index 000000000..ed8f4c057 --- /dev/null +++ b/cueweb/app/api/layer/action/getoutputpaths/route.ts @@ -0,0 +1,47 @@ +/* + * Copyright Contributors to the OpenCue Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { handleRoute } from '@/app/utils/api_utils'; +import { NextRequest, NextResponse } from "next/server"; + +// Return a layer's registered output paths (CueGUI Preview reads rendered +// outputs from these). A browser can't render the preview images, so CueWeb +// surfaces the paths so the user can locate them. +// RPC: /job.LayerInterface/GetOutputPaths. Request: { layer }. +export async function POST(request: NextRequest) { + const endpoint = "/job.LayerInterface/GetOutputPaths"; + const method = request.method; + if (method !== 'POST') { + return NextResponse.json({ error: 'Invalid method. Only POST is allowed.' }, { status: 405 }); + } + + let jsonBody: any; + try { + jsonBody = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 }); + } + if (!jsonBody || typeof jsonBody !== 'object' || !jsonBody.layer) { + return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }); + } + const body = JSON.stringify(jsonBody); + + const response = await handleRoute(method, endpoint, body, true); + const responseData = await response.json(); + + if (!response.ok) return NextResponse.json({ error: responseData.error }, { status: response.status }); + return NextResponse.json({ data: responseData.data }, { status: response.status }); +} diff --git a/cueweb/app/api/layer/action/markdone/route.ts b/cueweb/app/api/layer/action/markdone/route.ts new file mode 100644 index 000000000..68a287114 --- /dev/null +++ b/cueweb/app/api/layer/action/markdone/route.ts @@ -0,0 +1,45 @@ +/* + * Copyright Contributors to the OpenCue Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { handleRoute } from '@/app/utils/api_utils'; +import { NextRequest, NextResponse } from "next/server"; + +// Mark all frames in a layer done (CueGUI LayerActions.markdone). +// RPC: /job.LayerInterface/MarkdoneFrames. Request: { layer }. +export async function POST(request: NextRequest) { + const endpoint = "/job.LayerInterface/MarkdoneFrames"; + const method = request.method; + if (method !== 'POST') { + return NextResponse.json({ error: 'Invalid method. Only POST is allowed.' }, { status: 405 }); + } + + let jsonBody: any; + try { + jsonBody = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 }); + } + if (!jsonBody || typeof jsonBody !== 'object' || !jsonBody.layer) { + return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }); + } + const body = JSON.stringify(jsonBody); + + const response = await handleRoute(method, endpoint, body, true); + const responseData = await response.json(); + + if (!response.ok) return NextResponse.json({ error: responseData.error }, { status: response.status }); + return NextResponse.json({ data: responseData.data }, { status: response.status }); +} diff --git a/cueweb/app/api/layer/action/reorderframes/route.ts b/cueweb/app/api/layer/action/reorderframes/route.ts new file mode 100644 index 000000000..9bdb14f03 --- /dev/null +++ b/cueweb/app/api/layer/action/reorderframes/route.ts @@ -0,0 +1,41 @@ +/* + * Copyright Contributors to the OpenCue Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { handleRoute } from '@/app/utils/api_utils'; +import { NextRequest, NextResponse } from "next/server"; + +// Reorder a layer's frames (CueGUI LayerActions.reorder). order is an Order +// enum name: "FIRST" | "LAST" | "REVERSE". +// RPC: /job.LayerInterface/ReorderFrames. Request: { layer, range, order }. +export async function POST(request: NextRequest) { + const endpoint = "/job.LayerInterface/ReorderFrames"; + if (request.method !== 'POST') { + return NextResponse.json({ error: 'Invalid method. Only POST is allowed.' }, { status: 405 }); + } + let jsonBody: any; + try { + jsonBody = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 }); + } + if (!jsonBody?.layer || typeof jsonBody.range !== 'string' || typeof jsonBody.order !== 'string') { + return NextResponse.json({ error: 'Invalid request body: layer, range, order required' }, { status: 400 }); + } + const response = await handleRoute(request.method, endpoint, JSON.stringify(jsonBody), true); + const responseData = await response.json(); + if (!response.ok) return NextResponse.json({ error: responseData.error }, { status: response.status }); + return NextResponse.json({ data: responseData.data }, { status: response.status }); +} diff --git a/cueweb/app/api/layer/action/setmincores/route.ts b/cueweb/app/api/layer/action/setmincores/route.ts new file mode 100644 index 000000000..5f7694d5b --- /dev/null +++ b/cueweb/app/api/layer/action/setmincores/route.ts @@ -0,0 +1,57 @@ +/* + * Copyright Contributors to the OpenCue Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { handleRoute } from '@/app/utils/api_utils'; +import { NextRequest, NextResponse } from "next/server"; + +// Set a layer's minimum cores (CueGUI Stuck Frame "Core Up"). Request: +// { layer, cores }. RPC: /job.LayerInterface/SetMinCores. +export async function POST(request: NextRequest) { + const endpoint = "/job.LayerInterface/SetMinCores"; + const method = request.method; + if (method !== 'POST') { + return NextResponse.json({ error: 'Invalid method. Only POST is allowed.' }, { status: 405 }); + } + + let jsonBody: any; + try { + jsonBody = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 }); + } + // cores is a float proto field (fractional core counts are valid), so reject + // only non-finite (typeof NaN is "number") and negative values, not fractions. + if ( + !jsonBody || + typeof jsonBody !== 'object' || + typeof jsonBody.layer !== 'object' || + jsonBody.layer === null || + typeof jsonBody.layer.id !== 'string' || + jsonBody.layer.id.trim() === '' || + typeof jsonBody.cores !== 'number' || + !Number.isFinite(jsonBody.cores) || + jsonBody.cores < 0 + ) { + return NextResponse.json({ error: 'Invalid request body: layer.id and non-negative numeric cores are required' }, { status: 400 }); + } + + const body = JSON.stringify(jsonBody); + const response = await handleRoute(method, endpoint, body, true); + const responseData = await response.json(); + + if (!response.ok) return NextResponse.json({ error: responseData.error }, { status: response.status }); + return NextResponse.json({ data: responseData.data }, { status: response.status }); +} diff --git a/cueweb/app/api/layer/action/setmingpumemory/route.ts b/cueweb/app/api/layer/action/setmingpumemory/route.ts new file mode 100644 index 000000000..169387a7d --- /dev/null +++ b/cueweb/app/api/layer/action/setmingpumemory/route.ts @@ -0,0 +1,40 @@ +/* + * Copyright Contributors to the OpenCue Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { handleRoute } from '@/app/utils/api_utils'; +import { NextRequest, NextResponse } from "next/server"; + +// Set a layer's minimum GPU memory in KB (CueGUI LayerPropertiesDialog). +// RPC: /job.LayerInterface/SetMinGpuMemory. Request: { layer, gpu_memory:number }. +export async function POST(request: NextRequest) { + const endpoint = "/job.LayerInterface/SetMinGpuMemory"; + if (request.method !== 'POST') { + return NextResponse.json({ error: 'Invalid method. Only POST is allowed.' }, { status: 405 }); + } + let jsonBody: any; + try { + jsonBody = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); + } + if (!jsonBody?.layer || typeof jsonBody.gpu_memory !== 'number' || !Number.isFinite(jsonBody.gpu_memory)) { + return NextResponse.json({ error: 'Invalid request body (need {layer, gpu_memory:number})' }, { status: 400 }); + } + const response = await handleRoute(request.method, endpoint, JSON.stringify(jsonBody), true); + const responseData = await response.json(); + if (!response.ok) return NextResponse.json({ error: responseData.error }, { status: response.status }); + return NextResponse.json({ data: responseData.data }, { status: response.status }); +} diff --git a/cueweb/app/api/layer/action/setminmemory/route.ts b/cueweb/app/api/layer/action/setminmemory/route.ts new file mode 100644 index 000000000..827bccf2e --- /dev/null +++ b/cueweb/app/api/layer/action/setminmemory/route.ts @@ -0,0 +1,41 @@ +/* + * Copyright Contributors to the OpenCue Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { handleRoute } from '@/app/utils/api_utils'; +import { NextRequest, NextResponse } from "next/server"; + +// Set a layer's minimum memory in KB (CueGUI LayerPropertiesDialog). +// RPC: /job.LayerInterface/SetMinMemory. Request: { layer, memory:number }. +export async function POST(request: NextRequest) { + const endpoint = "/job.LayerInterface/SetMinMemory"; + if (request.method !== 'POST') { + return NextResponse.json({ error: 'Invalid method. Only POST is allowed.' }, { status: 405 }); + } + let jsonBody: any; + try { + jsonBody = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); + } + // memory is KB backed by an int64 proto field: reject negative/fractional. + if (!jsonBody?.layer || typeof jsonBody.memory !== 'number' || !Number.isInteger(jsonBody.memory) || jsonBody.memory < 0) { + return NextResponse.json({ error: 'Invalid request body (need {layer, memory: non-negative integer KB})' }, { status: 400 }); + } + const response = await handleRoute(request.method, endpoint, JSON.stringify(jsonBody), true); + const responseData = await response.json(); + if (!response.ok) return NextResponse.json({ error: responseData.error }, { status: response.status }); + return NextResponse.json({ data: responseData.data }, { status: response.status }); +} diff --git a/cueweb/app/api/layer/action/settags/route.ts b/cueweb/app/api/layer/action/settags/route.ts new file mode 100644 index 000000000..9e773ffee --- /dev/null +++ b/cueweb/app/api/layer/action/settags/route.ts @@ -0,0 +1,40 @@ +/* + * Copyright Contributors to the OpenCue Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { handleRoute } from '@/app/utils/api_utils'; +import { NextRequest, NextResponse } from "next/server"; + +// Set a layer's tags (CueGUI LayerTagsDialog). +// RPC: /job.LayerInterface/SetTags. Request: { layer, tags: string[] }. +export async function POST(request: NextRequest) { + const endpoint = "/job.LayerInterface/SetTags"; + if (request.method !== 'POST') { + return NextResponse.json({ error: 'Invalid method. Only POST is allowed.' }, { status: 405 }); + } + let jsonBody: any; + try { + jsonBody = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); + } + if (!jsonBody?.layer || !Array.isArray(jsonBody.tags) || !jsonBody.tags.every((t: unknown) => typeof t === 'string')) { + return NextResponse.json({ error: 'Invalid request body (need {layer, tags:string[]})' }, { status: 400 }); + } + const response = await handleRoute(request.method, endpoint, JSON.stringify(jsonBody), true); + const responseData = await response.json(); + if (!response.ok) return NextResponse.json({ error: responseData.error }, { status: response.status }); + return NextResponse.json({ data: responseData.data }, { status: response.status }); +} diff --git a/cueweb/app/api/layer/action/setthreadable/route.ts b/cueweb/app/api/layer/action/setthreadable/route.ts new file mode 100644 index 000000000..eab9b07bd --- /dev/null +++ b/cueweb/app/api/layer/action/setthreadable/route.ts @@ -0,0 +1,40 @@ +/* + * Copyright Contributors to the OpenCue Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { handleRoute } from '@/app/utils/api_utils'; +import { NextRequest, NextResponse } from "next/server"; + +// Set whether a layer's frames are threadable (CueGUI LayerPropertiesDialog). +// RPC: /job.LayerInterface/SetThreadable. Request: { layer, threadable:boolean }. +export async function POST(request: NextRequest) { + const endpoint = "/job.LayerInterface/SetThreadable"; + if (request.method !== 'POST') { + return NextResponse.json({ error: 'Invalid method. Only POST is allowed.' }, { status: 405 }); + } + let jsonBody: any; + try { + jsonBody = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); + } + if (!jsonBody?.layer || typeof jsonBody.threadable !== 'boolean') { + return NextResponse.json({ error: 'Invalid request body (need {layer, threadable:boolean})' }, { status: 400 }); + } + const response = await handleRoute(request.method, endpoint, JSON.stringify(jsonBody), true); + const responseData = await response.json(); + if (!response.ok) return NextResponse.json({ error: responseData.error }, { status: response.status }); + return NextResponse.json({ data: responseData.data }, { status: response.status }); +} diff --git a/cueweb/app/api/layer/action/staggerframes/route.ts b/cueweb/app/api/layer/action/staggerframes/route.ts new file mode 100644 index 000000000..b6b3b9762 --- /dev/null +++ b/cueweb/app/api/layer/action/staggerframes/route.ts @@ -0,0 +1,40 @@ +/* + * Copyright Contributors to the OpenCue Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { handleRoute } from '@/app/utils/api_utils'; +import { NextRequest, NextResponse } from "next/server"; + +// Stagger a layer's frames (CueGUI LayerActions.stagger). +// RPC: /job.LayerInterface/StaggerFrames. Request: { layer, range, stagger }. +export async function POST(request: NextRequest) { + const endpoint = "/job.LayerInterface/StaggerFrames"; + if (request.method !== 'POST') { + return NextResponse.json({ error: 'Invalid method. Only POST is allowed.' }, { status: 405 }); + } + let jsonBody: any; + try { + jsonBody = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 }); + } + if (!jsonBody?.layer || typeof jsonBody.range !== 'string' || typeof jsonBody.stagger !== 'number') { + return NextResponse.json({ error: 'Invalid request body: layer, range, numeric stagger required' }, { status: 400 }); + } + const response = await handleRoute(request.method, endpoint, JSON.stringify(jsonBody), true); + const responseData = await response.json(); + if (!response.ok) return NextResponse.json({ error: responseData.error }, { status: response.status }); + return NextResponse.json({ data: responseData.data }, { status: response.status }); +} diff --git a/cueweb/app/api/proc/action/kill/route.ts b/cueweb/app/api/proc/action/kill/route.ts new file mode 100644 index 000000000..6e9870d40 --- /dev/null +++ b/cueweb/app/api/proc/action/kill/route.ts @@ -0,0 +1,40 @@ +/* + * Copyright Contributors to the OpenCue Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { handleRoute } from '@/app/utils/api_utils'; +import { NextRequest, NextResponse } from "next/server"; + +// Kill a single proc (Proc monitor "Kill"). Request: { proc }. +// RPC: /host.ProcInterface/Kill. +export async function POST(request: NextRequest) { + const endpoint = "/host.ProcInterface/Kill"; + if (request.method !== 'POST') { + return NextResponse.json({ error: 'Invalid method. Only POST is allowed.' }, { status: 405 }); + } + let jsonBody: any; + try { + jsonBody = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 }); + } + if (!jsonBody?.proc) { + return NextResponse.json({ error: 'Invalid request body: proc required' }, { status: 400 }); + } + const response = await handleRoute(request.method, endpoint, JSON.stringify(jsonBody), true); + const responseData = await response.json(); + if (!response.ok) return NextResponse.json({ error: responseData.error }, { status: response.status }); + return NextResponse.json({ data: responseData.data }, { status: response.status }); +} diff --git a/cueweb/app/api/proc/action/unbookone/route.ts b/cueweb/app/api/proc/action/unbookone/route.ts new file mode 100644 index 000000000..f81342fc3 --- /dev/null +++ b/cueweb/app/api/proc/action/unbookone/route.ts @@ -0,0 +1,40 @@ +/* + * Copyright Contributors to the OpenCue Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { handleRoute } from '@/app/utils/api_utils'; +import { NextRequest, NextResponse } from "next/server"; + +// Unbook a single proc, optionally killing it (Proc monitor "Unbook" / +// "Unbook and Kill"). Request: { proc, kill }. RPC: /host.ProcInterface/Unbook. +export async function POST(request: NextRequest) { + const endpoint = "/host.ProcInterface/Unbook"; + if (request.method !== 'POST') { + return NextResponse.json({ error: 'Invalid method. Only POST is allowed.' }, { status: 405 }); + } + let jsonBody: any; + try { + jsonBody = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 }); + } + if (!jsonBody?.proc || typeof jsonBody.kill !== 'boolean') { + return NextResponse.json({ error: 'Invalid request body: proc and kill required' }, { status: 400 }); + } + const response = await handleRoute(request.method, endpoint, JSON.stringify(jsonBody), true); + const responseData = await response.json(); + if (!response.ok) return NextResponse.json({ error: responseData.error }, { status: response.status }); + return NextResponse.json({ data: responseData.data }, { status: response.status }); +} diff --git a/cueweb/app/api/proc/getprocs/route.ts b/cueweb/app/api/proc/getprocs/route.ts new file mode 100644 index 000000000..db71c8907 --- /dev/null +++ b/cueweb/app/api/proc/getprocs/route.ts @@ -0,0 +1,45 @@ +/* + * Copyright Contributors to the OpenCue Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { handleRoute } from '@/app/utils/api_utils'; +import { NextRequest, NextResponse } from "next/server"; + +// List procs matching a ProcSearchCriteria (the Monitor Hosts proc panel, +// filtered by host names). Request: { r: { hosts: [...] } }. The gateway +// double-nests as { procs: { procs: [...] } }; we unwrap to a flat array. +// RPC: /host.ProcInterface/GetProcs. +export async function POST(request: NextRequest) { + const endpoint = "/host.ProcInterface/GetProcs"; + if (request.method !== 'POST') { + return NextResponse.json({ error: 'Invalid method. Only POST is allowed.' }, { status: 405 }); + } + let jsonBody: any; + try { + jsonBody = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 }); + } + if (!jsonBody?.r || typeof jsonBody.r !== 'object') { + return NextResponse.json({ error: 'Invalid request body: r (ProcSearchCriteria) required' }, { status: 400 }); + } + const response = await handleRoute(request.method, endpoint, JSON.stringify(jsonBody)); + const responseData = await response.json(); + if (!response.ok) { + return NextResponse.json({ error: responseData?.error ?? "Failed to fetch procs" }, { status: response.status }); + } + const procs = responseData?.data?.procs?.procs ?? []; + return NextResponse.json({ data: procs }, { status: response.status }); +} diff --git a/cueweb/app/api/service/create/route.ts b/cueweb/app/api/service/create/route.ts new file mode 100644 index 000000000..ad6dcdb24 --- /dev/null +++ b/cueweb/app/api/service/create/route.ts @@ -0,0 +1,45 @@ +/* + * Copyright Contributors to the OpenCue Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { handleRoute } from '@/app/utils/api_utils'; +import { NextRequest, NextResponse } from "next/server"; + +// Create a new facility-wide default service. Request: { data: Service }. +// RPC: /service.ServiceInterface/CreateService. +export async function POST(request: NextRequest) { + const endpoint = "/service.ServiceInterface/CreateService"; + const method = request.method; + if (method !== 'POST') { + return NextResponse.json({ error: 'Invalid method. Only POST is allowed.' }, { status: 405 }); + } + + let jsonBody: any; + try { + jsonBody = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 }); + } + if (!jsonBody || typeof jsonBody !== 'object' || !jsonBody.data || typeof jsonBody.data.name !== 'string') { + return NextResponse.json({ error: 'Invalid request body: data with a name is required' }, { status: 400 }); + } + + const body = JSON.stringify(jsonBody); + const response = await handleRoute(method, endpoint, body, true); + const responseData = await response.json(); + + if (!response.ok) return NextResponse.json({ error: responseData.error }, { status: response.status }); + return NextResponse.json({ data: responseData.data }, { status: response.status }); +} diff --git a/cueweb/app/api/service/delete/route.ts b/cueweb/app/api/service/delete/route.ts new file mode 100644 index 000000000..312ff5226 --- /dev/null +++ b/cueweb/app/api/service/delete/route.ts @@ -0,0 +1,45 @@ +/* + * Copyright Contributors to the OpenCue Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { handleRoute } from '@/app/utils/api_utils'; +import { NextRequest, NextResponse } from "next/server"; + +// Delete a facility-wide default service. Request: { service: Service }. +// RPC: /service.ServiceInterface/Delete. +export async function POST(request: NextRequest) { + const endpoint = "/service.ServiceInterface/Delete"; + const method = request.method; + if (method !== 'POST') { + return NextResponse.json({ error: 'Invalid method. Only POST is allowed.' }, { status: 405 }); + } + + let jsonBody: any; + try { + jsonBody = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 }); + } + if (!jsonBody || typeof jsonBody !== 'object' || !jsonBody.service) { + return NextResponse.json({ error: 'Invalid request body: service is required' }, { status: 400 }); + } + + const body = JSON.stringify(jsonBody); + const response = await handleRoute(method, endpoint, body, true); + const responseData = await response.json(); + + if (!response.ok) return NextResponse.json({ error: responseData.error }, { status: response.status }); + return NextResponse.json({ data: responseData.data }, { status: response.status }); +} diff --git a/cueweb/app/api/service/getdefaultservices/route.ts b/cueweb/app/api/service/getdefaultservices/route.ts new file mode 100644 index 000000000..bc11009c4 --- /dev/null +++ b/cueweb/app/api/service/getdefaultservices/route.ts @@ -0,0 +1,50 @@ +/* + * Copyright Contributors to the OpenCue Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { handleRoute } from '@/app/utils/api_utils'; +import { NextRequest, NextResponse } from "next/server"; + +// Lists the facility-wide default services (the left pane of the Facility +// Service Defaults page, mirroring CueGUI's opencue.api.getDefaultServices()). +// The gateway double-nests the result as { services: { services: [...] } }; +// we unwrap to a flat array. RPC: /service.ServiceInterface/GetDefaultServices. +export async function POST(request: NextRequest) { + const endpoint = "/service.ServiceInterface/GetDefaultServices"; + const method = request.method; + if (method !== 'POST') { + return NextResponse.json({ error: 'Invalid method. Only POST is allowed.' }, { status: 405 }); + } + + let parsed: unknown = {}; + try { + parsed = await request.json(); + } catch { + // Empty body is acceptable - GetDefaultServices takes no parameters. + } + const body = JSON.stringify(parsed ?? {}); + + const response = await handleRoute(method, endpoint, body); + const responseData = await response.json(); + + if (!response.ok) { + return NextResponse.json( + { error: responseData?.error ?? "Failed to fetch services" }, + { status: response.status }, + ); + } + const services = responseData?.data?.services?.services ?? []; + return NextResponse.json({ data: services }, { status: response.status }); +} diff --git a/cueweb/app/api/service/update/route.ts b/cueweb/app/api/service/update/route.ts new file mode 100644 index 000000000..1784d3df9 --- /dev/null +++ b/cueweb/app/api/service/update/route.ts @@ -0,0 +1,45 @@ +/* + * Copyright Contributors to the OpenCue Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { handleRoute } from '@/app/utils/api_utils'; +import { NextRequest, NextResponse } from "next/server"; + +// Update an existing facility-wide default service. Request: { service: Service }. +// RPC: /service.ServiceInterface/Update. +export async function POST(request: NextRequest) { + const endpoint = "/service.ServiceInterface/Update"; + const method = request.method; + if (method !== 'POST') { + return NextResponse.json({ error: 'Invalid method. Only POST is allowed.' }, { status: 405 }); + } + + let jsonBody: any; + try { + jsonBody = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 }); + } + if (!jsonBody || typeof jsonBody !== 'object' || !jsonBody.service || typeof jsonBody.service.name !== 'string') { + return NextResponse.json({ error: 'Invalid request body: service with a name is required' }, { status: 400 }); + } + + const body = JSON.stringify(jsonBody); + const response = await handleRoute(method, endpoint, body, true); + const responseData = await response.json(); + + if (!response.ok) return NextResponse.json({ error: responseData.error }, { status: response.status }); + return NextResponse.json({ data: responseData.data }, { status: response.status }); +} diff --git a/cueweb/app/api/serviceoverride/mutate/route.ts b/cueweb/app/api/serviceoverride/mutate/route.ts new file mode 100644 index 000000000..6b56a17dc --- /dev/null +++ b/cueweb/app/api/serviceoverride/mutate/route.ts @@ -0,0 +1,56 @@ +/* + * Copyright Contributors to the OpenCue Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { handleRoute } from '@/app/utils/api_utils'; +import { NextRequest, NextResponse } from "next/server"; + +// Consolidated proxy for the show-scoped Service Properties dialog (CueGUI +// ServiceDialog, show mode). Body is { op, ...payload }; `op` selects one of +// the allowlisted RPCs and the rest is forwarded verbatim. +// show.createserviceoverride { show, service } (service is a Service) +// override.update { service } (Service; cuebot keys on data.id) +// override.delete { service } +const ENDPOINTS: Record = { + "show.createserviceoverride": "/show.ShowInterface/CreateServiceOverride", + "override.update": "/service.ServiceOverrideInterface/Update", + "override.delete": "/service.ServiceOverrideInterface/Delete", +}; + +export async function POST(request: NextRequest) { + const method = request.method; + if (method !== 'POST') { + return NextResponse.json({ error: 'Invalid method. Only POST is allowed.' }, { status: 405 }); + } + + let jsonBody: any; + try { + jsonBody = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 }); + } + const { op, ...payload } = jsonBody ?? {}; + const endpoint = typeof op === 'string' ? ENDPOINTS[op] : undefined; + if (!endpoint) { + return NextResponse.json({ error: `Unknown service override op: ${op}` }, { status: 400 }); + } + + const response = await handleRoute(method, endpoint, JSON.stringify(payload), true); + const responseData = await response.json(); + if (!response.ok) { + return NextResponse.json({ error: responseData?.error ?? `Failed: ${op}` }, { status: response.status }); + } + return NextResponse.json({ data: responseData.data ?? { success: true } }, { status: response.status }); +} diff --git a/cueweb/app/api/show/getdepartments/route.ts b/cueweb/app/api/show/getdepartments/route.ts new file mode 100644 index 000000000..2c4917b6b --- /dev/null +++ b/cueweb/app/api/show/getdepartments/route.ts @@ -0,0 +1,44 @@ +/* + * Copyright Contributors to the OpenCue Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { handleRoute } from '@/app/utils/api_utils'; +import { NextRequest, NextResponse } from "next/server"; + +// List a show's departments (Task Properties dialog). Request: { show }. +// RPC: /show.ShowInterface/GetDepartments (nested under departments.departments). +export async function POST(request: NextRequest) { + const endpoint = "/show.ShowInterface/GetDepartments"; + const method = request.method; + if (method !== 'POST') { + return NextResponse.json({ error: 'Invalid method. Only POST is allowed.' }, { status: 405 }); + } + + let jsonBody: any; + try { + jsonBody = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 }); + } + const body = JSON.stringify(jsonBody); + if (!jsonBody || typeof jsonBody !== 'object' || !jsonBody.show?.id) { + return NextResponse.json({ error: 'Invalid request body: show is required' }, { status: 400 }); + } + + const response = await handleRoute(method, endpoint, body); + const responseData = await response.json(); + if (!response.ok) return NextResponse.json({ error: responseData.error }, { status: response.status }); + return NextResponse.json({ data: responseData.data?.departments?.departments ?? [] }, { status: response.status }); +} diff --git a/cueweb/app/api/show/getfilters/route.ts b/cueweb/app/api/show/getfilters/route.ts new file mode 100644 index 000000000..c3dcc1f77 --- /dev/null +++ b/cueweb/app/api/show/getfilters/route.ts @@ -0,0 +1,45 @@ +/* + * Copyright Contributors to the OpenCue Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { handleRoute } from '@/app/utils/api_utils'; +import { NextRequest, NextResponse } from "next/server"; + +// List a show's dispatcher filters (View Filters dialog). Request: { show }. +// RPC: /show.ShowInterface/GetFilters. The gateway nests the seq under +// filters.filters. +export async function POST(request: NextRequest) { + const endpoint = "/show.ShowInterface/GetFilters"; + const method = request.method; + if (method !== 'POST') { + return NextResponse.json({ error: 'Invalid method. Only POST is allowed.' }, { status: 405 }); + } + + let jsonBody: any; + try { + jsonBody = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 }); + } + const body = JSON.stringify(jsonBody); + if (!jsonBody || typeof jsonBody !== 'object' || !jsonBody.show?.id) { + return NextResponse.json({ error: 'Invalid request body: show is required' }, { status: 400 }); + } + + const response = await handleRoute(method, endpoint, body); + const responseData = await response.json(); + if (!response.ok) return NextResponse.json({ error: responseData.error }, { status: response.status }); + return NextResponse.json({ data: responseData.data?.filters?.filters ?? [] }, { status: response.status }); +} diff --git a/cueweb/app/api/show/getserviceoverrides/route.ts b/cueweb/app/api/show/getserviceoverrides/route.ts new file mode 100644 index 000000000..df7114340 --- /dev/null +++ b/cueweb/app/api/show/getserviceoverrides/route.ts @@ -0,0 +1,48 @@ +/* + * Copyright Contributors to the OpenCue Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { handleRoute } from '@/app/utils/api_utils'; +import { NextRequest, NextResponse } from "next/server"; + +// List a show's service overrides (Service Properties dialog). Request: +// { show }. RPC: /show.ShowInterface/GetServiceOverrides. The gateway nests the +// seq under serviceOverrides.serviceOverrides. +export async function POST(request: NextRequest) { + const endpoint = "/show.ShowInterface/GetServiceOverrides"; + const method = request.method; + if (method !== 'POST') { + return NextResponse.json({ error: 'Invalid method. Only POST is allowed.' }, { status: 405 }); + } + + let jsonBody: any; + try { + jsonBody = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 }); + } + const body = JSON.stringify(jsonBody); + if (!jsonBody || typeof jsonBody !== 'object' || !jsonBody.show?.id) { + return NextResponse.json({ error: 'Invalid request body: show is required' }, { status: 400 }); + } + + const response = await handleRoute(method, endpoint, body); + const responseData = await response.json(); + if (!response.ok) return NextResponse.json({ error: responseData.error }, { status: response.status }); + return NextResponse.json( + { data: responseData.data?.serviceOverrides?.serviceOverrides ?? [] }, + { status: response.status }, + ); +} diff --git a/cueweb/app/api/stuck-frames/lastline/route.ts b/cueweb/app/api/stuck-frames/lastline/route.ts new file mode 100644 index 000000000..bd31c1d45 --- /dev/null +++ b/cueweb/app/api/stuck-frames/lastline/route.ts @@ -0,0 +1,93 @@ +/* + * Copyright Contributors to the OpenCue Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { NextRequest, NextResponse } from "next/server"; +import { execFile as execFileCallback } from "child_process"; +import { promisify } from "util"; +import { promises as fs } from "fs"; +import path from "path"; + +const execFile = promisify(execFileCallback); + +// Optional per-site allow-list (colon-separated absolute prefixes). When set, +// only .rqlog files under one of these roots are read; when unset, reads aren't +// restricted to a root (job log paths are site-specific). +function allowedLogRoots(): string[] { + return (process.env.CUEWEB_LOG_ROOTS ?? "") + .split(":") + .map((r) => r.trim()) + .filter(Boolean); +} + +// Returns the last non-empty line of a frame's .rqlog (the Stuck Frames +// "Last Line" column, mirroring CueGUI's getLastLine). Best-effort: if the log +// filesystem isn't mounted in this deployment, or the file is missing, it +// returns an empty line rather than erroring. execFile (no shell) + canonical +// path validation (realpath, .rqlog extension, optional root allow-list) keep +// the caller-supplied path from being abused. +export async function GET(request: NextRequest) { + const rawPath = request.nextUrl.searchParams.get("path"); + if (!rawPath || !rawPath.endsWith(".rqlog")) { + return NextResponse.json({ lastLine: "" }, { status: 200 }); + } + + // Canonicalize (follows symlinks) so the extension / root checks apply to the + // real file rather than a lexical path. A missing/unreadable file resolves to + // the best-effort empty response. + let target: string; + try { + target = await fs.realpath(path.resolve(rawPath)); + } catch { + return NextResponse.json({ lastLine: "" }, { status: 200 }); + } + if (!target.endsWith(".rqlog")) { + return NextResponse.json({ lastLine: "" }, { status: 200 }); + } + + const rawRoots = allowedLogRoots(); + if (rawRoots.length > 0) { + const roots = ( + await Promise.all( + rawRoots.map(async (r) => { + try { + return await fs.realpath(path.resolve(r)); + } catch { + return null; + } + }), + ) + ).filter((r): r is string => r !== null); + const inAllowedRoot = roots.some((root) => { + const rel = path.relative(root, target); + return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)); + }); + if (!inAllowedRoot) { + return NextResponse.json({ lastLine: "" }, { status: 200 }); + } + } + + try { + // tail the file, then keep the last non-blank line. + const { stdout } = await execFile("tail", ["-n", "20", "--", target], { + timeout: 5000, + maxBuffer: 1024 * 1024, + }); + const lines = stdout.split("\n").map((l) => l.trimEnd()).filter((l) => l.trim() !== ""); + return NextResponse.json({ lastLine: lines.length ? lines[lines.length - 1] : "" }, { status: 200 }); + } catch { + return NextResponse.json({ lastLine: "" }, { status: 200 }); + } +} diff --git a/cueweb/app/api/stuck-frames/route.ts b/cueweb/app/api/stuck-frames/route.ts new file mode 100644 index 000000000..c6dd3fd80 --- /dev/null +++ b/cueweb/app/api/stuck-frames/route.ts @@ -0,0 +1,163 @@ +/* + * Copyright Contributors to the OpenCue Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { fetchObjectFromRestGateway } from '@/app/utils/api_utils'; +import { NextRequest, NextResponse } from "next/server"; + +// Server-side data gathering for the Stuck Frames page. CueGUI's +// StuckFramePlugin walks every show's procs; we approximate by listing the +// unfinished jobs (GetJobs) and, per job, fetching its RUNNING frames +// (GetFrames, FrameState 2) and its layers (GetLayers, for the per-service +// average frame time). Each frame is stamped with its job, service and the +// layer's average frame time so the client can apply the full CueGUI +// stuck-detection predicate (LLU / % stuck / avg-completion / runtime) live +// against the user's per-service filter thresholds. +// +// RPCs: /job.JobInterface/GetJobs, /job.JobInterface/GetFrames, +// /job.JobInterface/GetLayers. + +const RUNNING_STATE = 2; // FrameState.RUNNING (proto/src/job.proto) +const MAX_FRAMES_PER_JOB = 1000; +// Cap the per-job GetFrames/GetLayers fan-out. Without a bound, a farm with +// thousands of unfinished jobs would fire that many concurrent gateway calls +// on every 30s poll; this keeps at most N jobs in flight at a time. +const MAX_CONCURRENT_JOBS = 16; + +async function gatewayJson(endpoint: string, body: string): Promise { + try { + const resp = await fetchObjectFromRestGateway(endpoint, "POST", body); + const json = await resp.json(); + if (json?.error) return null; + return json?.data ?? null; + } catch { + return null; + } +} + +// Map over items with a fixed-size worker pool, preserving input order. Plain +// promises (no extra dependency) so the route stays self-contained. +async function mapWithConcurrency( + items: T[], + limit: number, + fn: (item: T, index: number) => Promise, +): Promise { + const results: R[] = new Array(items.length); + let cursor = 0; + async function worker() { + while (true) { + const index = cursor++; + if (index >= items.length) return; + results[index] = await fn(items[index], index); + } + } + const workerCount = Math.min(limit, items.length); + await Promise.all(Array.from({ length: workerCount }, () => worker())); + return results; +} + +// Safety bound on pagination so a malformed/never-shrinking response can't spin +// forever: MAX_FRAME_PAGES * MAX_FRAMES_PER_JOB frames per job. +const MAX_FRAME_PAGES = 50; + +// Fetch every RUNNING frame for a job, paging through GetFrames until a short +// page arrives. A single page caps at MAX_FRAMES_PER_JOB, so a job with more +// running frames than that would otherwise be silently truncated. +async function getRunningFrames(job: any): Promise { + const all: any[] = []; + for (let page = 1; page <= MAX_FRAME_PAGES; page++) { + const framesData = await gatewayJson( + "/job.JobInterface/GetFrames", + JSON.stringify({ + job: { id: job.id, name: job.name }, + req: { + include_finished: false, + page, + limit: MAX_FRAMES_PER_JOB, + states: { frame_states: [RUNNING_STATE] }, + }, + }), + ); + const batch: any[] = Array.isArray(framesData?.frames?.frames) ? framesData.frames.frames : []; + all.push(...batch); + // A short (or empty/failed) page means we've reached the end. + if (batch.length < MAX_FRAMES_PER_JOB) break; + } + return all; +} + +export async function POST(_request: NextRequest) { + try { + const jobsData = await gatewayJson( + "/job.JobInterface/GetJobs", + JSON.stringify({ r: { include_finished: false } }), + ); + if (jobsData === null) { + return NextResponse.json({ error: "Failed to list jobs" }, { status: 500 }); + } + const jobs: any[] = Array.isArray(jobsData?.jobs?.jobs) ? jobsData.jobs.jobs : []; + + const perJob = await mapWithConcurrency( + jobs, + MAX_CONCURRENT_JOBS, + async (job) => { + const [frames, layersData] = await Promise.all([ + getRunningFrames(job), + gatewayJson( + "/job.JobInterface/GetLayers", + JSON.stringify({ job: { id: job.id, name: job.name } }), + ), + ]); + + const layers: any[] = Array.isArray(layersData?.layers?.layers) ? layersData.layers.layers : []; + // layerName -> details for attaching to each frame (service + average + // frame time for detection; id + minCores for the Core Up action). + const layerInfo = new Map< + string, + { id: string; service: string; avgFrameSec: number; minCores: number } + >(); + for (const layer of layers) { + layerInfo.set(layer.name, { + id: layer.id ?? "", + service: Array.isArray(layer.services) && layer.services.length ? layer.services[0] : "", + avgFrameSec: Number(layer.layerStats?.avgFrameSec ?? 0), + minCores: Number(layer.minCores ?? 0), + }); + } + + return frames + .filter((f) => f.state === "RUNNING") + .map((f) => { + const info = layerInfo.get(f.layerName); + return { + ...f, + jobId: job.id, + jobName: job.name, + jobLogDir: job.logDir ?? "", + jobHasComment: !!job.hasComment, + service: info?.service ?? "", + avgFrameSec: info?.avgFrameSec ?? 0, + layerId: info?.id ?? "", + layerMinCores: info?.minCores ?? 0, + }; + }); + }, + ); + + return NextResponse.json({ data: perJob.flat() }, { status: 200 }); + } catch (error) { + return NextResponse.json({ error: (error as Error).message }, { status: 500 }); + } +} diff --git a/cueweb/app/api/task/mutate/route.ts b/cueweb/app/api/task/mutate/route.ts new file mode 100644 index 000000000..6f4de236a --- /dev/null +++ b/cueweb/app/api/task/mutate/route.ts @@ -0,0 +1,59 @@ +/* + * Copyright Contributors to the OpenCue Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { handleRoute } from '@/app/utils/api_utils'; +import { NextRequest, NextResponse } from "next/server"; + +// Consolidated proxy for the Task Properties dialog mutations (CueGUI +// TasksDialog). Body is { op, ...payload }; `op` selects one of the allowlisted +// Department / Task RPCs and the rest is forwarded verbatim. +const ENDPOINTS: Record = { + // DepartmentInterface + "dept.addtask": "/department.DepartmentInterface/AddTask", + "dept.setmanagedcores": "/department.DepartmentInterface/SetManagedCores", + "dept.enabletimanaged": "/department.DepartmentInterface/EnableTiManaged", + "dept.disabletimanaged": "/department.DepartmentInterface/DisableTiManaged", + // TaskInterface + "task.setmincores": "/task.TaskInterface/SetMinCores", + "task.clearadjustments": "/task.TaskInterface/ClearAdjustments", + "task.delete": "/task.TaskInterface/Delete", +}; + +export async function POST(request: NextRequest) { + const method = request.method; + if (method !== 'POST') { + return NextResponse.json({ error: 'Invalid method. Only POST is allowed.' }, { status: 405 }); + } + + let jsonBody: any; + try { + jsonBody = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 }); + } + const { op, ...payload } = jsonBody ?? {}; + const endpoint = typeof op === 'string' ? ENDPOINTS[op] : undefined; + if (!endpoint) { + return NextResponse.json({ error: `Unknown task op: ${op}` }, { status: 400 }); + } + + const response = await handleRoute(method, endpoint, JSON.stringify(payload), true); + const responseData = await response.json(); + if (!response.ok) { + return NextResponse.json({ error: responseData?.error ?? `Failed: ${op}` }, { status: response.status }); + } + return NextResponse.json({ data: responseData.data ?? { success: true } }, { status: response.status }); +} diff --git a/cueweb/app/frames/[frame-name]/page.tsx b/cueweb/app/frames/[frame-name]/page.tsx index 468db180e..182f7fc35 100644 --- a/cueweb/app/frames/[frame-name]/page.tsx +++ b/cueweb/app/frames/[frame-name]/page.tsx @@ -17,14 +17,15 @@ */ -import { getFrame } from "@/app/utils/get_utils"; -import { handleError } from "@/app/utils/notify_utils"; +import { getFrame, getJobsForRegex } from "@/app/utils/get_utils"; +import type { Job } from "@/app/jobs/columns"; +import { handleError, toastSuccess } from "@/app/utils/notify_utils"; import { Breadcrumbs } from "@/components/ui/breadcrumbs"; import { Button } from "@/components/ui/button"; import { EmptyState } from "@/components/ui/empty-state"; import { Skeleton } from "@/components/ui/skeleton"; import Editor, { Monaco } from "@monaco-editor/react"; -import { FileX } from "lucide-react"; +import { ChevronDown, Download, FileX } from "lucide-react"; import FormControl from "@mui/material/FormControl"; import MenuItem from "@mui/material/MenuItem"; import Select from "@mui/material/Select"; @@ -33,6 +34,9 @@ import { useParams, useSearchParams } from "next/navigation"; import * as path from "path"; import React, { useEffect, useMemo, useRef, useState } from "react"; import { SimpleDataTable } from "../../../components/ui/simple-data-table"; +import { FrameExtraDialogs } from "../../../components/ui/frame-extra-dialogs"; +import { FramePreviewPanel } from "../../../components/ui/frame-preview-panel"; +import { FrameLogSearch } from "../../../components/ui/frame-log-search"; import { Frame, frameColumns } from "../frame-columns"; import { SelectChangeEvent } from "@mui/material/Select"; @@ -52,25 +56,84 @@ function jobNameFromLogPath(logPath: string): string { // number of log lines for paginated infinite logs const LOG_CHUNK_SIZE = process.env.NEXT_PUBLIC_LOG_CHUNK_SIZE ? parseInt(process.env.NEXT_PUBLIC_LOG_CHUNK_SIZE) : 100; +// One entry in the log-version dropdown: the rqlog file name plus the size and +// last-modified time returned by /api/getlogversions. +interface LogVersion { + name: string; + size: number; + mtime: number; +} + +/** Format a byte count as MB (the dropdown's size column). */ +function formatLogSize(bytes: number): string { + const mb = bytes / (1024 * 1024); + if (mb > 0 && mb < 0.01) return "< 0.01 MB"; + return `${mb.toFixed(2)} MB`; +} + +/** Format an epoch-ms mtime for the dropdown's timestamp column. */ +function formatLogMtime(mtime: number): string { + if (!mtime) return "—"; + return new Date(mtime).toLocaleString(); +} + export default function FramePage() { const searchParams = useSearchParams(); const routeParams = useParams<{ "frame-name": string }>(); const [frameObject, setFrame] = React.useState(null); + // Parent job, resolved from the log path's job-name prefix so the frame + // preview panel and the job-scoped frame actions work on this page too. + const [job, setJob] = React.useState(null); const [totalNumLogLines, setTotalNumLogLines] = useState(-1); const editorRef = useRef(null); const frameId = searchParams.get("frameId") || ""; const logDirPath = searchParams.get("frameLogDir") || ""; const username = searchParams.get("username") || ""; + // Live-tail mode (opened via the frame menu's "Tail Log"): load only the + // most recent lines, follow by default, and poll faster. + const tailMode = searchParams.get("mode") === "tail"; + const TAIL_INITIAL_LINES = 200; const [curLogVersion, setCurLogVersion] = useState(path.basename(logDirPath)); const [curLogPath, setCurLogPath] = useState(logDirPath) - const [logVersions, setLogVersions] = useState([]); + const [logVersions, setLogVersions] = useState([]); const [initialDataLoaded, setInitialDataLoaded] = useState(false); const [numberOfLinesLoaded, setNumberOfLinesLoaded] = useState(LOG_CHUNK_SIZE); const [fetchingLogs, setFetchingLogs] = useState(false); const [scrollTrigger, setScrollTrigger] = useState(false); const [editorMounted, setEditorMounted] = useState(false); + // Bumped on every editor content change (initial fill, infinite-scroll + // loads, version switch) so the log search can re-run as lines stream in. + const [logContentVersion, setLogContentVersion] = useState(0); + // "Follow" tail mode: auto-scroll to the bottom as new lines arrive. Pauses + // when the user scrolls up; the Jump-to-bottom button re-enables it. + const [followMode, setFollowMode] = useState(false); + const [atBottom, setAtBottom] = useState(true); + const followRef = useRef(false); + // Timestamp of the last programmatic scroll / content replace, so the scroll + // listener can tell a setValue-induced scroll reset from a real user scroll. + const programmaticScrollRef = useRef(0); + useEffect(() => { followRef.current = followMode; }, [followMode]); + // Monaco namespace (for Range/MouseTargetType), the absolute line-number + // offset, and the per-line copy-glyph decoration ids. + const monacoRef = useRef(null); + const logDisplayStartRef = useRef(1); + const copyGlyphDecorationsRef = useRef([]); + // Shared guard so the follow-mode poll and the scroll-triggered loader can't + // both append newer logs at once (which would duplicate a range / drift the + // line counters). + const appendInFlightRef = useRef(false); + + // Copy a single log line to the clipboard with a confirmation toast. + const copyLineText = async (text: string) => { + try { + await navigator.clipboard.writeText(text); + toastSuccess("Line copied"); + } catch (error) { + handleError(error, "Could not copy line"); + } + }; // Status of the log file currently selected in the version dropdown. // `loading` (initial), `ready` (we have lines), `empty` (the log file // exists but is empty), or `missing` (the log file could not be found). @@ -80,6 +143,8 @@ export default function FramePage() { // To track log line display const [logDisplayStart, setLogDisplayStart] = useState(-1); const [logDisplayEnd, setLogDisplayEnd] = useState(-1); + // Keep the absolute line-number offset in a ref for Monaco's lineNumbers fn. + useEffect(() => { logDisplayStartRef.current = logDisplayStart > 0 ? logDisplayStart : 1; }, [logDisplayStart]); const defaultMessage = "Please wait while the logs are loading. \ Important: Loading files with more than 1 million lines may take additional time."; @@ -114,7 +179,9 @@ export default function FramePage() { setLogStatus("ready"); setTotalNumLogLines(totalLines); - let startline = totalLines < LOG_CHUNK_SIZE ? 1 : totalLines - LOG_CHUNK_SIZE + 1; + // Tail mode loads the last ~200 lines; normal view loads one chunk. + const initialLines = tailMode ? TAIL_INITIAL_LINES : LOG_CHUNK_SIZE; + let startline = totalLines < initialLines ? 1 : totalLines - initialLines + 1; let endline = totalLines; let newLogs = await fetchPaginatedLogs(startline, endline); setNumberOfLinesLoaded(endline - startline + 1); @@ -147,12 +214,55 @@ export default function FramePage() { const handleEditorDidMount = (editor: editor.IStandaloneCodeEditor, monaco: Monaco) => { editor.updateOptions({ theme: "vs-dark", - lineNumbers: "off", + // Absolute file line numbers: Monaco line N maps to file line + // (logDisplayStart + N - 1) since only a window of the log is loaded. + lineNumbers: (n: number) => String(logDisplayStartRef.current + n - 1), + glyphMargin: true, readOnly: true, }); + monacoRef.current = monaco; + + // Right-click / keyboard "Copy Line" (copies the line under the cursor). + editor.addAction({ + id: "cueweb-copy-frame-log-line", + label: "Copy Line", + contextMenuGroupId: "9_cutcopypaste", + contextMenuOrder: 1.5, + run: (ed) => { + const pos = ed.getPosition(); + const model = ed.getModel(); + if (pos && model) copyLineText(model.getLineContent(pos.lineNumber)); + }, + }); + + // Click the hover copy glyph (or a line number) to copy that line. + editor.onMouseDown((e) => { + const t = e.target; + if ( + (t.type === monaco.editor.MouseTargetType.GUTTER_GLYPH_MARGIN || + t.type === monaco.editor.MouseTargetType.GUTTER_LINE_NUMBERS) && + t.position + ) { + const model = editor.getModel(); + if (model) copyLineText(model.getLineContent(t.position.lineNumber)); + } + }); editorRef.current = editor; - editorRef.current.onDidScrollChange(() => setScrollTrigger((prev) => !prev)); + editorRef.current.onDidScrollChange(() => { + const ed = editorRef.current; + if (ed) { + // Distance (px) from the very bottom of the scrollable log. + const dist = ed.getScrollHeight() - ed.getScrollTop() - ed.getLayoutInfo().height; + const near = dist <= 50; + setAtBottom(near); + // Pause follow only on a genuine user scroll-up - not the scroll reset + // setValue causes, nor our own auto-scroll-to-bottom. + const programmatic = Date.now() - programmaticScrollRef.current < 300; + if (!near && !programmatic && followRef.current) setFollowMode(false); + } + setScrollTrigger((prev) => !prev); + }); setEditorMounted(true); }; @@ -163,9 +273,21 @@ export default function FramePage() { } }; + // Hard scroll to the very bottom (used by follow mode + Jump to bottom). + const scrollToVeryBottom = () => { + const ed = editorRef.current; + if (!ed) return; + programmaticScrollRef.current = Date.now(); + ed.setScrollTop(ed.getScrollHeight()); + }; + function updateTextInEditor(text: string) { if (editorRef.current !== null) { + // setValue resets the scroll position; mark it programmatic so the + // scroll listener doesn't mistake it for a user scroll-up. + programmaticScrollRef.current = Date.now(); editorRef.current?.setValue(text); + setLogContentVersion((v) => v + 1); } } @@ -237,28 +359,36 @@ export default function FramePage() { }; const loadNewerLogMessages = async () => { - // check if total number of lines has grown aka there are new logs - const newLogLineCount = await getLogLineCount(); - // return early if the num of lines in logfile has not changed - // and we are displaying the end of the log already - if (newLogLineCount == totalNumLogLines && logDisplayEnd == totalNumLogLines) return; - - // calculate new end line - // get the number of new lines that have been added to the log and - // get whichever is smaller - the difference or LOG_CHUNK_SIZE - const newLinesCount = Math.min(newLogLineCount - logDisplayEnd, LOG_CHUNK_SIZE); - let endLine = logDisplayEnd + newLinesCount; - // update text in editor - let newLogLines = await fetchPaginatedLogs(logDisplayEnd + 1, endLine); - let prevLogs = editorRef.current?.getValue(); - updateTextInEditor(prevLogs + newLogLines); - - // update the number of log lines loaded in window - setNumberOfLinesLoaded(numberOfLinesLoaded + newLinesCount); - // update the number of lines in log file - setTotalNumLogLines(newLogLineCount); - // update the new end line - setLogDisplayEnd(endLine); + // Serialize against the scroll-trigger loader and other poll ticks: an + // overlapping append would re-fetch the same range and drift the counters. + if (appendInFlightRef.current) return; + appendInFlightRef.current = true; + try { + // check if total number of lines has grown aka there are new logs + const newLogLineCount = await getLogLineCount(); + // return early if the num of lines in logfile has not changed + // and we are displaying the end of the log already + if (newLogLineCount == totalNumLogLines && logDisplayEnd == totalNumLogLines) return; + + // calculate new end line + // get the number of new lines that have been added to the log and + // get whichever is smaller - the difference or LOG_CHUNK_SIZE + const newLinesCount = Math.min(newLogLineCount - logDisplayEnd, LOG_CHUNK_SIZE); + let endLine = logDisplayEnd + newLinesCount; + // update text in editor + let newLogLines = await fetchPaginatedLogs(logDisplayEnd + 1, endLine); + let prevLogs = editorRef.current?.getValue(); + updateTextInEditor(prevLogs + newLogLines); + + // update the number of log lines loaded in window + setNumberOfLinesLoaded(numberOfLinesLoaded + newLinesCount); + // update the number of lines in log file + setTotalNumLogLines(newLogLineCount); + // update the new end line + setLogDisplayEnd(endLine); + } finally { + appendInFlightRef.current = false; + } }; // helper function to access next js endpoint for retrieving log lines @@ -282,6 +412,80 @@ export default function FramePage() { return totLines; }; + // Follow tail mode: while on, poll for newer lines, append them, and stick to + // the bottom. Re-subscribes when the relevant log state changes so the + // appended-from closure stays fresh. + useEffect(() => { + if (!followMode || logStatus !== "ready") return; + let cancelled = false; + // Serialize ticks: a slow fetch must not overlap with the next interval + // and append the same chunk twice from stale logDisplayEnd state. + let inFlight = false; + const tick = async () => { + if (cancelled || !editorRef.current || inFlight) return; + inFlight = true; + try { + await loadNewerLogMessages(); + if (!cancelled) scrollToVeryBottom(); + } finally { + inFlight = false; + } + }; + tick(); + // Tail mode polls every 1s; otherwise a gentler 1.5s. + const id = setInterval(tick, tailMode ? 1000 : 1500); + return () => { + cancelled = true; + clearInterval(id); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [followMode, logStatus, logDisplayEnd, totalNumLogLines, numberOfLinesLoaded, curLogPath, tailMode]); + + // Start following automatically in tail mode or for a running frame + // (still pausable by scrolling up). + useEffect(() => { + if (tailMode || frameObject?.state === "RUNNING") { + setFollowMode(true); + setAtBottom(true); + } + }, [tailMode, frameObject?.state]); + + // Maintain a hover copy-glyph on each line. Decorate only the visible lines + // (re-applied on scroll) rather than every loaded line, so the cost is + // bounded by the viewport instead of O(total loaded lines) on each content + // update - important for large / live-tailing logs. + useEffect(() => { + const ed = editorRef.current; + const monaco = monacoRef.current; + if (!ed || !monaco) return; + + const applyVisibleGlyphs = () => { + const model = ed.getModel(); + if (!model) return; + const lineCount = model.getLineCount(); + const decos = []; + for (const range of ed.getVisibleRanges()) { + const start = Math.max(1, range.startLineNumber); + const end = Math.min(lineCount, range.endLineNumber); + for (let i = start; i <= end; i++) { + decos.push({ + range: new monaco.Range(i, 1, i, 1), + options: { + glyphMarginClassName: "cueweb-copy-line-glyph", + glyphMarginHoverMessage: { value: "Copy line" }, + }, + }); + } + } + copyGlyphDecorationsRef.current = ed.deltaDecorations(copyGlyphDecorationsRef.current, decos); + }; + + applyVisibleGlyphs(); + // Re-decorate as new lines scroll into view. + const scrollDisposable = ed.onDidScrollChange(applyVisibleGlyphs); + return () => scrollDisposable.dispose(); + }, [logContentVersion, editorMounted]); + // Handles updates when a different log version is selected const handleVersionChange = (e: SelectChangeEvent) => { setCurLogVersion(e.target.value); @@ -303,6 +507,29 @@ export default function FramePage() { fetchLogVersions(); }, [logDirPath]); + // Resolve the parent job from the log path's job-name prefix so the frame + // preview panel (and job-scoped frame actions) have job context here. + useEffect(() => { + const name = jobNameFromLogPath(logDirPath); + if (!name) { + setJob(null); + return; + } + let cancelled = false; + (async () => { + try { + const escaped = name.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&"); + const matches = await getJobsForRegex(`^${escaped}$`, true); + if (!cancelled) setJob(matches.length ? matches[0] : null); + } catch { + if (!cancelled) setJob(null); + } + })(); + return () => { + cancelled = true; + }; + }, [logDirPath]); + const frameNameFromRoute = decodeURIComponent(routeParams?.["frame-name"] ?? ""); const derivedJobName = jobNameFromLogPath(logDirPath); const breadcrumbItems = useMemo(() => { @@ -332,6 +559,11 @@ export default function FramePage() { <> {frameObject.name} + {/* Frame right-click dialogs + preview panel. The parent job is + resolved from the log path's job-name prefix, so job-scoped + actions and the frame preview work here too. */} + + ) : (
@@ -340,10 +572,11 @@ export default function FramePage() { {/* Some white space between table and logs div */}
- {/* Dropdown to select different log versions */} + {/* Dropdown to select different log versions + raw-log download */}

Log versions

- + theme.palette.background.default, @@ -355,19 +588,54 @@ export default function FramePage() { value={curLogVersion} label="log version" onChange={handleVersionChange} + // Keep the closed control compact (just the file name); the rich + // name/timestamp/size rows only appear in the open dropdown. + renderValue={(val) => String(val)} + sx={{ minWidth: 340 }} + MenuProps={{ PaperProps: { sx: { minWidth: 420 } } }} > - {logVersions.map((version) => ( - - {version} - - ))} + {logVersions.map((version) => { + // logVersions is newest-first (sorted by mtime in the API), so the + // first entry is the most recent -> gets the "Latest" badge. + const isLatest = version.name === logVersions[0]?.name; + return ( + + + {version.name} + + {formatLogMtime(version.mtime)} + + + {formatLogSize(version.size)} + + {isLatest && ( + + Latest + + )} + + + ); + })} + {/* Download the currently-selected log version as a .log attachment. */} + +
{/* Logs for Frame */} -
-
+
+
+ {totalNumLogLines && totalNumLogLines != -1 ? totalNumLogLines.toLocaleString() + " lines of logs" : ""}
+ {logStatus !== "missing" && logStatus !== "empty" ? ( + + ) : null}
{logStatus === "missing" || logStatus === "empty" ? (
)}
+ + {/* Floating jump-to-bottom: shown when scrolled up off the tail. + Clicking it re-enables follow. */} + {logStatus === "ready" && !atBottom ? ( + + ) : null}
); diff --git a/cueweb/app/frames/frame-columns.tsx b/cueweb/app/frames/frame-columns.tsx index e6589e5c2..753cf2aeb 100644 --- a/cueweb/app/frames/frame-columns.tsx +++ b/cueweb/app/frames/frame-columns.tsx @@ -19,7 +19,7 @@ import { ColumnDef } from "@tanstack/react-table"; import { Button } from "@/components/ui/button"; -import { ArrowUpDown } from "lucide-react"; +import { ArrowUpDown, Image as ImageIcon } from "lucide-react"; import { convertUnixToHumanReadableDate, convertMemoryToString, secondsToHHMMSS } from "@/app/utils/layers_frames_utils"; import { RowActionsCell } from "@/components/ui/row-actions-cell"; @@ -125,6 +125,32 @@ export const frameColumns: ColumnDef[] = [ enableSorting: false, enableHiding: false, }, + { + // Frame preview thumbnail: opens the rendered image in a right-side + // slide-over (FramePreviewPanel). The panel resolves the output path from + // the frame's layer, so the per-row trigger only needs the frame. + id: "preview", + header: () => Preview, + cell: ({ row }) => ( + + ), + enableSorting: false, + enableHiding: true, + }, { accessorKey: "dispatchOrder", header: ({ column }) => , diff --git a/cueweb/app/globals.css b/cueweb/app/globals.css index fb2ada1ee..d438e3903 100644 --- a/cueweb/app/globals.css +++ b/cueweb/app/globals.css @@ -121,3 +121,33 @@ --toastify-text-color-dark: hsl(var(--foreground)); --toastify-toast-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); } + +/* Frame log viewer search highlight (Monaco inline decorations). Monaco + renders into the document, so these global classes apply to match ranges. */ +.cueweb-log-match { + background-color: rgba(250, 204, 21, 0.35); /* amber-400 @ 35% */ + border-radius: 2px; +} +.cueweb-log-match-current { + background-color: rgba(249, 115, 22, 0.75); /* orange-500 @ 75% */ + border-radius: 2px; +} + +/* Per-line copy affordance in the frame log viewer's glyph margin. The icon + is hidden until its margin row is hovered, then click-to-copy that line. */ +.cueweb-copy-line-glyph { + cursor: pointer; + opacity: 0; + transition: opacity 0.1s ease-in-out; +} +.cueweb-copy-line-glyph::before { + content: "\29C9"; /* ⧉ copy-ish glyph */ + font-size: 12px; + color: #9aa0a6; +} +.monaco-editor .margin-view-overlays > div:hover .cueweb-copy-line-glyph { + opacity: 1; +} +.cueweb-copy-line-glyph:hover::before { + color: #ffffff; +} diff --git a/cueweb/app/hosts/[host-name]/page.tsx b/cueweb/app/hosts/[host-name]/page.tsx index 8aa9fe00b..a8fdc03f7 100644 --- a/cueweb/app/hosts/[host-name]/page.tsx +++ b/cueweb/app/hosts/[host-name]/page.tsx @@ -28,6 +28,7 @@ import { getHostProcs, } from "@/app/utils/get_utils"; import { handleError } from "@/app/utils/notify_utils"; +import { setAttributeSelection } from "@/app/utils/use_attribute_selection"; import { Breadcrumbs } from "@/components/ui/breadcrumbs"; import { Button } from "@/components/ui/button"; import { EditHostTagsDialog } from "@/components/ui/edit-host-tags-dialog"; @@ -141,6 +142,19 @@ export default function HostDetailPage() { loadHost(); }, [loadHost]); + // Load the host into the Attributes panel (CueGUI parity: selecting a host + // shows its attributes). Refreshes as the host data is re-fetched. + React.useEffect(() => { + if (host) { + setAttributeSelection({ + type: "host", + id: host.id, + name: host.name, + data: host as unknown as Record, + }); + } + }, [host]); + // Tag edits (and other host actions) fire cueweb:hosts-changed. Patch the // affected fields immediately so the Tags tab updates without a flash, then // silently reconcile with Cuebot in case it normalized the tag set. diff --git a/cueweb/app/hosts/columns.tsx b/cueweb/app/hosts/columns.tsx index 540494682..6b82b33fa 100644 --- a/cueweb/app/hosts/columns.tsx +++ b/cueweb/app/hosts/columns.tsx @@ -17,12 +17,12 @@ */ import { ColumnDef } from "@tanstack/react-table"; -import { ArrowUpDown } from "lucide-react"; +import { ArrowUpDown, StickyNote } from "lucide-react"; import Link from "next/link"; import { Button } from "@/components/ui/button"; -import { Status } from "@/components/ui/status"; import { Host } from "@/app/utils/get_utils"; -import { idleRatio, kbStringToHuman, kbStringToNumber } from "@/app/hosts/host_format_utils"; +import { kbStringToHuman, kbStringToNumber } from "@/app/hosts/host_format_utils"; +import { OPEN_HOST_COMMENTS_EVENT } from "@/components/ui/host-action-events"; function sortableHeader(label: string) { // eslint-disable-next-line react/display-name @@ -39,12 +39,40 @@ function sortableHeader(label: string) { ); } +// Red (used) + green (free) horizontal bar, mirroring CueGUI's Host*BarDelegate. +function MemBar({ usedKb, totalKb }: { usedKb: number; totalKb: number }) { + const pct = totalKb > 0 ? Math.min(100, Math.max(0, (usedKb / totalKb) * 100)) : 0; + return ( +
+
+
+
+ ); +} + +const kb = (v?: string) => kbStringToNumber(v ?? ""); + +function formatBootTime(epoch: number): string { + if (!epoch) return ""; + const d = new Date(epoch * 1000); + const mm = String(d.getMonth() + 1).padStart(2, "0"); + const dd = String(d.getDate()).padStart(2, "0"); + const hh = String(d.getHours()).padStart(2, "0"); + const mi = String(d.getMinutes()).padStart(2, "0"); + return `${mm}/${dd} ${hh}:${mi}`; +} + +function openComments(host: Host) { + if (typeof window === "undefined") return; + window.dispatchEvent(new CustomEvent(OPEN_HOST_COMMENTS_EVENT, { detail: { hosts: [host] } })); +} + +// Full CueGUI Monitor Hosts column set. Header labels mirror CueGUI; numeric / +// memory columns sort by their underlying value, the bar columns by free space. export const hostColumns: ColumnDef[] = [ { accessorKey: "name", header: sortableHeader("Name"), - // Link into the host detail page (procs / comments / tags). stopPropagation - // so the click doesn't also trigger any row-level handler. cell: ({ row }) => ( [] = [ ), }, + { + // Dedicated comments column (CueGUI / Monitor Jobs parity): an amber + // sticky-note when the host has comments; click to view. Sortable so hosts + // with comments can be pulled to the top. + id: "comments", + accessorFn: (row) => (row.hasComment ? 1 : 0), + header: ({ column }: { column: any }) => ( + + ), + cell: ({ row }) => + row.original.hasComment ? ( + + ) : null, + }, + { + id: "load", + header: sortableHeader("Load %"), + accessorFn: (h) => (h.cores ? (h.load ?? 0) / h.cores : 0), + cell: ({ row }) => { + const h = row.original; + const pct = h.cores ? (h.load ?? 0) / h.cores : 0; + return {Math.round(pct)}%; + }, + }, + { + id: "swap", + header: sortableHeader("Swap"), + accessorFn: (h) => kb(h.freeSwap), + cell: ({ row }) => , + }, + { + id: "physical", + header: sortableHeader("Physical"), + accessorFn: (h) => kb(h.freeMemory), + cell: ({ row }) => , + }, + { + id: "gpuMemoryBar", + header: sortableHeader("GPU Memory"), + accessorFn: (h) => kb(h.freeGpuMemory), + cell: ({ row }) => , + }, + { + id: "totalMemory", + header: sortableHeader("Total Memory"), + accessorFn: (h) => kb(h.memory), + cell: ({ row }) => {kbStringToHuman(row.original.memory)}, + }, + { + id: "idleMemory", + header: sortableHeader("Idle Memory"), + accessorFn: (h) => kb(h.idleMemory), + cell: ({ row }) => {kbStringToHuman(row.original.idleMemory)}, + }, + { + id: "temp", + header: sortableHeader("Temp"), + accessorFn: (h) => kb(h.freeMcp), + cell: ({ row }) => , + }, + { + id: "tempFree", + header: sortableHeader("Temp Free"), + accessorFn: (h) => kb(h.freeMcp), + cell: ({ row }) => {kbStringToHuman(row.original.freeMcp)}, + }, + { + id: "tempFreePct", + header: sortableHeader("Temp Free %"), + accessorFn: (h) => (kb(h.totalMcp) ? kb(h.freeMcp) / kb(h.totalMcp) : 0), + cell: ({ row }) => { + const total = kb(row.original.totalMcp); + if (!total) return ; + return {Math.round((100 * kb(row.original.freeMcp)) / total)}%; + }, + }, + { + id: "cores", + header: sortableHeader("Cores"), + accessorFn: (h) => h.cores, + cell: ({ row }) => {row.original.cores.toFixed(2)}, + }, + { + id: "idleCores", + header: sortableHeader("Idle Cores"), + accessorFn: (h) => h.idleCores, + cell: ({ row }) => {row.original.idleCores.toFixed(2)}, + }, + { + id: "gpus", + header: sortableHeader("GPUs"), + accessorFn: (h) => h.gpus ?? 0, + cell: ({ row }) => {row.original.gpus ?? 0}, + }, + { + id: "idleGpus", + header: sortableHeader("Idle GPUs"), + accessorFn: (h) => h.idleGpus ?? 0, + cell: ({ row }) => {row.original.idleGpus ?? 0}, + }, + { + id: "gpuMem", + header: sortableHeader("GPU Mem"), + accessorFn: (h) => kb(h.gpuMemory), + cell: ({ row }) => {kbStringToHuman(row.original.gpuMemory ?? "")}, + }, + { + id: "gpuMemIdle", + header: sortableHeader("GPU Mem Idle"), + accessorFn: (h) => kb(h.idleGpuMemory), + cell: ({ row }) => {kbStringToHuman(row.original.idleGpuMemory ?? "")}, + }, + { + id: "ping", + header: sortableHeader("Ping"), + accessorFn: (h) => h.pingTime ?? 0, + cell: ({ row }) => { + const ping = row.original.pingTime ? Math.max(0, Math.round(Date.now() / 1000 - row.original.pingTime)) : 0; + return {ping}; + }, + }, + { + id: "bootTime", + header: sortableHeader("Boot Time"), + accessorFn: (h) => h.bootTime ?? 0, + cell: ({ row }) => {formatBootTime(row.original.bootTime)}, + }, { accessorKey: "state", - header: sortableHeader("State"), - cell: ({ row }) => , + id: "hardware", + header: sortableHeader("Hardware"), + cell: ({ row }) => {row.original.state}, }, { accessorKey: "lockState", id: "locked", header: sortableHeader("Locked"), - cell: ({ row }) => , + cell: ({ row }) => {row.original.lockState}, }, { - accessorKey: "nimbyEnabled", - id: "nimby", - header: sortableHeader("NIMBY"), - cell: ({ row }) => {row.original.nimbyEnabled ? "Yes" : "No"}, - }, - { - id: "cores", - header: sortableHeader("Cores (Idle/Total)"), - // Sort by idle ratio so "most free" sorts together regardless of host size. - accessorFn: (h) => idleRatio(h.idleCores, h.cores), - cell: ({ row }) => ( - - {row.original.idleCores.toFixed(2)} / {row.original.cores.toFixed(2)} - - ), + id: "threadMode", + header: sortableHeader("ThreadMode"), + accessorFn: (h) => h.threadMode ?? "", + cell: ({ row }) => {row.original.threadMode ?? ""}, }, { - id: "memory", - header: sortableHeader("Memory (Idle/Total)"), - // Sort by idle ratio (matching Cores), not the formatted string. - accessorFn: (h) => idleRatio(kbStringToNumber(h.idleMemory), kbStringToNumber(h.totalMemory)), - cell: ({ row }) => ( - - {kbStringToHuman(row.original.idleMemory)} / {kbStringToHuman(row.original.totalMemory)} - - ), + id: "os", + header: sortableHeader("OS"), + accessorFn: (h) => h.os ?? "", + cell: ({ row }) => {row.original.os ?? ""}, }, { - id: "freeMcp", - header: sortableHeader("Free /mcp"), - accessorFn: (h) => kbStringToNumber(h.freeMcp), - cell: ({ row }) => {kbStringToHuman(row.original.freeMcp)}, + id: "tags", + header: sortableHeader("Tags"), + accessorFn: (h) => (h.tags ?? []).join(","), + cell: ({ row }) => {(row.original.tags ?? []).join(", ")}, }, ]; + +// Row tint by state/lock (CueGUI HostWidgetItem BackgroundRole). Returns a +// Tailwind class for SimpleDataTable's getRowClassName hook. +export function hostRowClassName(host: Host): string | undefined { + if (host.state === "REBOOT_WHEN_IDLE") return "bg-amber-950/40"; + if (host.state !== "UP") return "bg-red-950/40"; + if (host.lockState === "LOCKED") return "bg-yellow-950/40"; + return undefined; +} diff --git a/cueweb/app/hosts/page.tsx b/cueweb/app/hosts/page.tsx index 319648f72..5a5d5d782 100644 --- a/cueweb/app/hosts/page.tsx +++ b/cueweb/app/hosts/page.tsx @@ -17,27 +17,126 @@ */ import * as React from "react"; +import { ChevronDown } from "lucide-react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; + import { Host, getHosts } from "@/app/utils/get_utils"; -import { hostColumns } from "@/app/hosts/columns"; +import { setAttributeSelection } from "@/app/utils/use_attribute_selection"; +import { hostColumns, hostRowClassName } from "@/app/hosts/columns"; import { SimpleDataTable } from "@/components/ui/simple-data-table"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; import { Skeleton } from "@/components/ui/skeleton"; import { HostLockDialog } from "@/components/ui/host-lock-dialog"; import { HostRebootDialog } from "@/components/ui/host-reboot-dialog"; import { EditHostTagsDialog } from "@/components/ui/edit-host-tags-dialog"; -import { - HOSTS_CHANGED_EVENT, - type HostsChangedDetail, -} from "@/components/ui/host-action-events"; +import { HostMonitorDialogs } from "@/components/ui/host-monitor-dialogs"; +import { ProcMonitorPanel } from "@/components/ui/proc-monitor-panel"; +import { HOSTS_CHANGED_EVENT, type HostsChangedDetail } from "@/components/ui/host-action-events"; const REFRESH_MS = 30000; +const HARDWARE_STATES = ["UP", "DOWN", "REBOOTING", "REBOOT_WHEN_IDLE", "REPAIR"]; +const LOCK_STATES = ["OPEN", "LOCKED", "NIMBY_LOCKED"]; + +function FilterMenu({ + label, + options, + selected, + onChange, +}: { + label: string; + options: string[]; + selected: Set; + onChange: (next: Set) => void; +}) { + function toggle(value: string, checked: boolean) { + const next = new Set(selected); + if (checked) next.add(value); + else next.delete(value); + onChange(next); + } + return ( + + + + + + onChange(new Set())}>Clear + + {options.length === 0 ? ( +
None
+ ) : ( + options.map((o) => ( + toggle(o, !!c)} + onSelect={(e) => e.preventDefault()} + > + {o} + + )) + )} +
+
+ ); +} + +// Filter state is mirrored in the URL query string (CueGUI keeps it in the +// plugin session; the web equivalent is a shareable/bookmarkable URL): +// ?q=&alloc=a,b&hw=UP,DOWN&lock=OPEN&os=rhel9 +function parseSetParam(params: URLSearchParams, key: string): Set { + return new Set( + (params.get(key) ?? "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean), + ); +} + +function HostsPageInner() { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); -export default function HostsPage() { const [hosts, setHosts] = React.useState(null); + // Host row clicked into the Attributes panel (CueGUI parity). + const [selectedHostId, setSelectedHostId] = React.useState(null); const [error, setError] = React.useState(null); + const [autoRefresh, setAutoRefresh] = React.useState(true); + + // Seed filters from the URL once on mount (initializers run a single time). + const [search, setSearch] = React.useState(() => searchParams.get("q") ?? ""); + const [allocFilter, setAllocFilter] = React.useState>(() => parseSetParam(searchParams, "alloc")); + const [hwFilter, setHwFilter] = React.useState>(() => parseSetParam(searchParams, "hw")); + const [lockFilter, setLockFilter] = React.useState>(() => parseSetParam(searchParams, "lock")); + const [osFilter, setOsFilter] = React.useState>(() => parseSetParam(searchParams, "os")); + + // Keep the URL in sync as filters change (replace, so we don't spam history). + React.useEffect(() => { + const params = new URLSearchParams(); + if (search.trim()) params.set("q", search.trim()); + if (allocFilter.size) params.set("alloc", Array.from(allocFilter).join(",")); + if (hwFilter.size) params.set("hw", Array.from(hwFilter).join(",")); + if (lockFilter.size) params.set("lock", Array.from(lockFilter).join(",")); + if (osFilter.size) params.set("os", Array.from(osFilter).join(",")); + const qs = params.toString(); + router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false }); + }, [search, allocFilter, hwFilter, lockFilter, osFilter, pathname, router]); - // isCancelled lets the polling effect drop a late response after unmount; - // the Retry button omits it. const load = React.useCallback(async (isCancelled?: () => boolean) => { try { const data = await getHosts(); @@ -46,8 +145,6 @@ export default function HostsPage() { setError(null); } catch (err) { if (isCancelled?.()) return; - // Keep previously loaded rows on a failed poll; only blank to [] if we - // never loaded anything. getHosts already toasts via handleError. setError(err instanceof Error ? err.message : String(err)); setHosts((prev) => prev ?? []); } @@ -57,39 +154,96 @@ export default function HostsPage() { let cancelled = false; const isCancelled = () => cancelled; load(isCancelled); + if (!autoRefresh) return () => { cancelled = true; }; const interval = setInterval(() => load(isCancelled), REFRESH_MS); return () => { cancelled = true; clearInterval(interval); }; - }, [load]); + }, [load, autoRefresh]); - // After a lock/unlock/reboot the dialogs fire cueweb:hosts-changed. - // Optimistically apply the patch (lockState and/or state) to the affected - // rows so the table reflects the change immediately, then kick off a fetch - // to reconcile with Cuebot (the gateway may take a beat to settle, and a - // request it rejects will be corrected on the next poll). React.useEffect(() => { function handler(e: Event) { const detail = (e as CustomEvent).detail; if (!detail?.hostIds?.length || !detail.patch) return; const ids = new Set(detail.hostIds); - setHosts((prev) => - prev - ? prev.map((h) => (ids.has(h.id) ? { ...h, ...detail.patch } : h)) - : prev, - ); + setHosts((prev) => (prev ? prev.map((h) => (ids.has(h.id) ? { ...h, ...detail.patch } : h)) : prev)); load(); } window.addEventListener(HOSTS_CHANGED_EVENT, handler); return () => window.removeEventListener(HOSTS_CHANGED_EVENT, handler); }, [load]); + const allocOptions = React.useMemo( + () => Array.from(new Set((hosts ?? []).map((h) => h.allocName).filter(Boolean) as string[])).sort(), + [hosts], + ); + const osOptions = React.useMemo( + () => Array.from(new Set((hosts ?? []).map((h) => h.os).filter(Boolean) as string[])).sort(), + [hosts], + ); + + const filtered = React.useMemo(() => { + if (!hosts) return null; + let nameRe: RegExp | null = null; + if (search.trim()) { + try { + nameRe = new RegExp(search.trim(), "i"); + } catch { + nameRe = null; + } + } + const term = search.trim().toLowerCase(); + return hosts.filter((h) => { + if (search.trim()) { + const ok = nameRe ? nameRe.test(h.name) : h.name.toLowerCase().includes(term); + if (!ok) return false; + } + if (allocFilter.size && !(h.allocName && allocFilter.has(h.allocName))) return false; + if (hwFilter.size && !hwFilter.has(h.state)) return false; + if (lockFilter.size && !lockFilter.has(h.lockState)) return false; + if (osFilter.size && !(h.os && osFilter.has(h.os))) return false; + return true; + }); + }, [hosts, search, allocFilter, hwFilter, lockFilter, osFilter]); + + function clearFilters() { + setSearch(""); + setAllocFilter(new Set()); + setHwFilter(new Set()); + setLockFilter(new Set()); + setOsFilter(new Set()); + } + return (

Monitor Hosts

- {hosts === null ? ( + {/* Filter bar (CueGUI parity). */} +
+ setSearch(e.target.value)} + placeholder="Filter hosts (name / regex)" + className="h-8 w-64" + aria-label="Filter hosts" + /> + + + + + +
+ + + +
+
+ + {filtered === null ? (
@@ -97,30 +251,60 @@ export default function HostsPage() {
) : ( <> - {error && hosts.length === 0 ? ( + {error && hosts && hosts.length === 0 ? (
Could not load hosts from Cuebot. - +
) : null} { + const h = host as Host; + setSelectedHostId(h.id); + setAttributeSelection({ + type: "host", + id: h.id, + name: h.name, + data: h as unknown as Record, + }); + }} + selectedRowId={selectedHostId} /> )} - {/* Dialogs opened by the host row context menu: Lock / Unlock - (cueweb:open-host-lock), immediate Reboot (cueweb:open-host-reboot), - and Edit Tags (cueweb:open-host-tags). */} + {/* Bottom proc panel (View Procs). */} + + + {/* Dialogs opened by the host row context menu. */} +
); } + +// useSearchParams() requires a Suspense boundary for this client route. +export default function HostsPage() { + return ( + + + + +
+ } + > + + + ); +} diff --git a/cueweb/app/jobs/[job-name]/page.tsx b/cueweb/app/jobs/[job-name]/page.tsx index 9894752e2..1636a0e4c 100644 --- a/cueweb/app/jobs/[job-name]/page.tsx +++ b/cueweb/app/jobs/[job-name]/page.tsx @@ -34,9 +34,15 @@ import { secondsToHumanAge, } from "@/app/utils/layers_frames_utils"; import { handleError } from "@/app/utils/notify_utils"; +import { setAttributeSelection } from "@/app/utils/use_attribute_selection"; import { Breadcrumbs } from "@/components/ui/breadcrumbs"; import { Button } from "@/components/ui/button"; import { EmptyState } from "@/components/ui/empty-state"; +import { LayerExtraDialogs } from "@/components/ui/layer-extra-dialogs"; +import { FrameExtraDialogs } from "@/components/ui/frame-extra-dialogs"; +import { FramePreviewPanel } from "@/components/ui/frame-preview-panel"; +import { FrameRangeSelector } from "@/components/ui/frame-range-selector"; +import { DependencyWizardDialog } from "@/components/ui/dependency-wizard-dialog"; import { SimpleDataTable } from "@/components/ui/simple-data-table"; import { Skeleton } from "@/components/ui/skeleton"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; @@ -80,6 +86,14 @@ export default function JobDetailPage() { const [currentUser, setCurrentUser] = React.useState(UNKNOWN_USER); + // CueGUI "View Layer" filters the frame view to a single layer. The layer + // context menu dispatches `cueweb:view-layer`; we switch to the Frames tab + // and narrow the frame list to that layer's name until the user clears it. + const [layerFilter, setLayerFilter] = React.useState(null); + // Rows clicked into the Attributes panel (CueGUI parity). + const [selectedLayerId, setSelectedLayerId] = React.useState(null); + const [selectedFrameId, setSelectedFrameId] = React.useState(null); + React.useEffect(() => { (async () => { try { @@ -140,6 +154,19 @@ export default function JobDetailPage() { }; }, [jobIdParam, jobName]); + // Load the job into the Attributes panel (CueGUI parity). A layer/frame row + // click below overrides it with the more specific entity. + React.useEffect(() => { + if (job) { + setAttributeSelection({ + type: "job", + id: job.id, + name: job.name, + data: job as unknown as Record, + }); + } + }, [job]); + // Lazy-load each tab's data: only fetch when its tab is first opened, then // refresh on a 5s interval while it stays the active tab. const loadedTabs = React.useRef(new Set()); @@ -218,6 +245,27 @@ export default function JobDetailPage() { [pathname, router, searchParams, tab], ); + React.useEffect(() => { + function handler(e: Event) { + const layer = (e as CustomEvent<{ layer?: { name?: string } }>).detail?.layer; + if (!layer?.name) return; + setLayerFilter(layer.name); + setTab("frames"); + } + window.addEventListener("cueweb:view-layer", handler); + return () => window.removeEventListener("cueweb:view-layer", handler); + }, [setTab]); + + // Clear any active layer filter when the user navigates to a different job. + React.useEffect(() => { + setLayerFilter(null); + }, [job?.id]); + + const displayedFrames = React.useMemo( + () => (layerFilter ? frames.filter((f) => f.layerName === layerFilter) : frames), + [frames, layerFilter], + ); + return (
) : ( - + <> + { + const layer = row as Layer; + setSelectedLayerId(layer.id); + setAttributeSelection({ + type: "layer", + id: layer.id, + name: layer.name, + data: layer as unknown as Record, + }); + }} + selectedRowId={selectedLayerId} + /> + + {/* One wizard instance for this page; the layer menu's + "Dependency Wizard..." bridges its event to this. */} + + )} + {layerFilter ? ( +
+ Showing layer + {layerFilter} + +
+ ) : null} {framesLoading ? ( - ) : frames.length === 0 ? ( + ) : displayedFrames.length === 0 ? (
diff --git a/cueweb/app/jobs/columns.tsx b/cueweb/app/jobs/columns.tsx index 838550e1f..0e640d02c 100644 --- a/cueweb/app/jobs/columns.tsx +++ b/cueweb/app/jobs/columns.tsx @@ -21,6 +21,7 @@ import * as React from "react"; import { ColumnDef } from "@tanstack/react-table"; import { Button } from "@/components/ui/button"; import { ArrowUpDown, ChevronDown, ChevronRight, MoreHorizontal, StickyNote, X } from "lucide-react"; +import { TbPacman } from "react-icons/tb"; import { Checkbox } from "@/components/ui/checkbox"; import { convertMemoryToString, convertUnixToHumanReadableDate, secondsToHHHMM, secondsToHumanAge } from "@/app/utils/layers_frames_utils"; import { RowActionsCell } from "@/components/ui/row-actions-cell"; @@ -226,9 +227,8 @@ function UserColorSwatch({ jobId }: { jobId: string }) { function JobCommentIndicator({ job }: { job: Job }) { const handleClick = (e: React.MouseEvent) => { e.stopPropagation(); - const params = new URLSearchParams({ jobId: job.id }); - const url = `/jobs/${encodeURIComponent(job.name)}/comments?${params.toString()}`; - window.open(url, "_blank", "noopener,noreferrer"); + // Open the Comments modal (CueGUI parity) instead of a new tab. + window.dispatchEvent(new CustomEvent("cueweb:open-job-comments", { detail: { job } })); }; return ( @@ -387,6 +387,44 @@ export const columns: ColumnDef[] = [ return a - b; }, }, + { + // Auto-eat indicator (CueGUI JobMonitorTree "_Autoeat" column): a pac-man + // icon right of the comment column, shown when the job has auto-eating + // enabled. Job-only - auto_eat is a job property, so (matching CueGUI) + // the layer and frame tables have no equivalent column. + id: "autoeat", + accessorFn: (row) => (row.autoEat ? 1 : 0), + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const job = row.original as Job; + if (!job.autoEat) return null; + return ( + + + ); + }, + sortingFn: (rowA, rowB) => { + const a = (rowA.original as Job).autoEat ? 1 : 0; + const b = (rowB.original as Job).autoEat ? 1 : 0; + return a - b; + }, + }, { accessorKey: "state", header: ({ column }) => { diff --git a/cueweb/app/jobs/data-table.tsx b/cueweb/app/jobs/data-table.tsx index bad3ec4c9..21f5868c5 100644 --- a/cueweb/app/jobs/data-table.tsx +++ b/cueweb/app/jobs/data-table.tsx @@ -52,6 +52,8 @@ import { EmailArtistDialog } from "@/components/ui/email-artist-dialog"; import { JobDetailsInline } from "@/components/ui/job-details-inline"; import { RequestCoresDialog } from "@/components/ui/request-cores-dialog"; import { SetCoresDialog } from "@/components/ui/set-cores-dialog"; +import { JobExtraDialogs } from "@/components/ui/job-extra-dialogs"; +import { JobCommentsDialog } from "@/components/ui/job-comments-dialog"; import { SetPriorityDialog } from "@/components/ui/set-priority-dialog"; import { SubscribeToJobDialog } from "@/components/ui/subscribe-to-job-dialog"; import { UnbookDialog } from "@/components/ui/unbook-dialog"; @@ -63,6 +65,7 @@ import SearchDropdown from "@/components/ui/search-dropdown"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { readableTextColor, useUserColors } from "@/app/utils/user_colors"; import Alert from "@mui/material/Alert"; import Box from "@mui/material/Box"; import LinearProgress from "@mui/material/LinearProgress"; @@ -1425,6 +1428,11 @@ export function DataTable({ columns, username }: DataTableProps) { | { kind: "header"; key: string; count: number }; const tableRows = table.getRowModel().rows; + // CueGUI parity: "Set user color" paints the whole job row. The live + // jobId -> hex map is read from localStorage and applied as the row's + // background (with a legible text color) below. + const userColors = useUserColors(); + const displayItems = React.useMemo(() => { if (state.groupBy === "Clear") { return tableRows.map((row) => ({ kind: "row" as const, row, groupKey: "" })); @@ -1933,10 +1941,16 @@ export function DataTable({ columns, username }: DataTableProps) { return null; } const row = item.row; + const userColor = userColors[(row.original as Job).id]; return ( contextMenuHandleOpen(e, row)} onClick={() => { const job = row.original as Job; @@ -2040,6 +2054,15 @@ export function DataTable({ columns, username }: DataTableProps) { context menu's "Set Priority..." entry. */} + {/* Additional CueGUI job-menu dialogs: Set Minimum/Maximum Cores & GPUs + (cueweb:open-set-job-scalar), Reorder Frames, Stagger Frames, Use + Local Cores, and Set User Color. */} + + + {/* Comments modal (CueGUI parity), opened by the "Comments..." menu + item / the comment icon via cueweb:open-job-comments. */} + + {/* Set Min/Max Cores dialog. Mounted once here; opens in response to a `cueweb:open-set-cores` CustomEvent fired from the row context menu's "Set Min/Max Cores..." entry. */} diff --git a/cueweb/app/monitor-cue/page.tsx b/cueweb/app/monitor-cue/page.tsx new file mode 100644 index 000000000..32a0e6ba9 --- /dev/null +++ b/cueweb/app/monitor-cue/page.tsx @@ -0,0 +1,1072 @@ +"use client"; + +/* + * Copyright Contributors to the OpenCue Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as React from "react"; +import Link from "next/link"; +import { useSession } from "next-auth/react"; +import type { Row } from "@tanstack/react-table"; +import { ArrowUpDown, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Folder, RefreshCw, Search, StickyNote } from "lucide-react"; +import { TbPacman, TbReload, TbPlayerPause, TbPlayerPlay } from "react-icons/tb"; +import { MdOutlineCancel } from "react-icons/md"; + +import type { Job } from "@/app/jobs/columns"; +import { UNKNOWN_USER } from "@/app/utils/constants"; +import { Group, GroupStats, Show, getActiveShows, getGroupJobs, getShowGroups } from "@/app/utils/get_utils"; +import { setAttributeSelection } from "@/app/utils/use_attribute_selection"; +import { buildTreeFromGroups, type TreeNode } from "@/components/group-tree/build-tree"; +import { + deleteGroup, + eatJobsDeadFrames, + killJobs, + pauseJobs, + retryJobsDeadFrames, + unpauseJobs, +} from "@/app/utils/action_utils"; +import { handleError, toastWarning } from "@/app/utils/notify_utils"; +import { convertMemoryToString, secondsToHHHMM, secondsToHumanAge } from "@/app/utils/layers_frames_utils"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { ConfirmDialog } from "@/components/ui/confirm-dialog"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { JobProgressBar } from "@/components/ui/job-progress-bar"; +import { JobBookingBar } from "@/components/ui/job-booking-bar"; +import { JobContextMenu } from "@/components/ui/context_menus/action-context-menu"; +import { useContextMenu } from "@/components/ui/context_menus/useContextMenu"; +// Job action dialogs the JobContextMenu opens via CustomEvents. +import { DependencyWizardDialog } from "@/components/ui/dependency-wizard-dialog"; +import { EmailArtistDialog } from "@/components/ui/email-artist-dialog"; +import { RequestCoresDialog } from "@/components/ui/request-cores-dialog"; +import { SetCoresDialog } from "@/components/ui/set-cores-dialog"; +import { SetPriorityDialog } from "@/components/ui/set-priority-dialog"; +import { SubscribeToJobDialog } from "@/components/ui/subscribe-to-job-dialog"; +import { UnbookDialog } from "@/components/ui/unbook-dialog"; +import { ViewDependenciesDialog } from "@/components/ui/view-dependencies-dialog"; +import { JobExtraDialogs } from "@/components/ui/job-extra-dialogs"; +import { JobCommentsDialog } from "@/components/ui/job-comments-dialog"; +import { SendToGroupDialog } from "@/components/ui/send-to-group-dialog"; +import { ShowPropertiesDialog } from "@/components/ui/show-properties-dialog"; +import { GroupPropertiesDialog, OPEN_GROUP_PROPERTIES_EVENT } from "@/components/ui/group-properties-dialog"; +import { CreateGroupDialog, GROUPS_CHANGED_EVENT, OPEN_CREATE_GROUP_EVENT } from "@/components/ui/create-group-dialog"; +import { ViewFiltersDialog } from "@/components/ui/view-filters-dialog"; +import { TaskPropertiesDialog } from "@/components/ui/task-properties-dialog"; +import { ServicePropertiesDialog } from "@/components/ui/service-properties-dialog"; +import { MonitorCueShowMenu, type ShowMenuState } from "@/components/ui/monitor-cue-show-menu"; + +const REFRESH_MS = 5000; +const SELECTED_SHOWS_KEY = "cueweb.monitor-cue.shows"; + +const mem = (kb?: string) => { + const n = Number(kb ?? 0); + return Number.isFinite(n) && n > 0 ? convertMemoryToString(n, "job") : "0K"; +}; + +// memory_warning_level (Kb) from cuegui.yaml: jobs whose peak frame memory +// exceeds this get the yellow row tint, matching CueGUI. +const MEMORY_WARNING_LEVEL = 5242880; + +// CueGUI JobWidgetTree row tint: blue=paused, red=dead frames, yellow=maxRss +// over the warning level, green=no running frames but frames waiting, +// purple=all remaining frames depend on something. +function jobRowClass(j: Job): string { + const s = j.jobStats; + if (j.isPaused) return "bg-blue-950/50"; + if ((s?.deadFrames ?? 0) > 0) return "bg-red-950/50"; + if (Number.parseInt(s?.maxRss ?? "0") > MEMORY_WARNING_LEVEL) return "bg-yellow-900/40"; + if ((s?.runningFrames ?? 0) === 0) { + if ((s?.waitingFrames ?? 0) === 0 && (s?.dependFrames ?? 0) > 0) return "bg-purple-950/50"; + if ((s?.waitingFrames ?? 0) > 0) return "bg-green-950/40"; + } + return ""; +} + +// A flattened Monitor Cue tree row: either a group folder or a job, with the +// indentation depth. +type TreeRow = + | { kind: "group"; group: Group; depth: number; stats: GroupStats } + | { kind: "job"; job: Job; depth: number }; + +// Flatten a group node's *contents* (its direct jobs, then each child folder and +// that folder's contents) into render rows, honoring the per-folder collapse, +// the job sort, and the substring filter. The node's own folder row is rendered +// by the caller (the show header for the root, the folder row for subgroups). +function flattenGroup( + node: TreeNode, + depth: number, + jobsByGroup: Record, + collapsedGroups: Set, + cmp: (a: Job, b: Job) => number, + needle: string, +): TreeRow[] { + const rows: TreeRow[] = []; + const jobs = (jobsByGroup[node.group.id] ?? []) + .filter((j) => !needle || j.name.toLowerCase().includes(needle)) + .sort(cmp); + for (const j of jobs) rows.push({ kind: "job", job: j, depth }); + const children = [...node.children].sort((a, b) => a.group.name.localeCompare(b.group.name)); + for (const child of children) { + rows.push({ kind: "group", group: child.group, depth, stats: child.rolledUpStats }); + if (!collapsedGroups.has(child.group.id)) { + rows.push(...flattenGroup(child, depth + 1, jobsByGroup, collapsedGroups, cmp, needle)); + } + } + return rows; +} + +// Which Monitor Cue columns a group folder row populates (from its rolled-up +// GroupStats); other columns are blank on group rows. +const GROUP_STAT_COL: Record React.ReactNode> = { + run: (s) => s.runningFrames, + cores: (s) => s.reservedCores.toFixed(2), + gpus: (s) => s.reservedGpus, + wait: (s) => s.waitingFrames, + depend: (s) => s.dependFrames, +}; + +// Unified, ordered, hideable, sortable column model for the Monitor Cue table. +// `cell` renders the body content; `sort` (when present) makes the header +// sortable; `header` overrides the header text with an icon. The checkbox +// column is fixed (not in this list). +type CueCol = { + key: string; + label: string; + title?: string; + align?: "left" | "right" | "center"; + minW?: string; + sort?: (j: Job) => number | string; + header?: React.ReactNode; + cell: (j: Job, now: number) => React.ReactNode; +}; + +const ALL_COLUMNS: CueCol[] = [ + { + key: "job", label: "Job", align: "left", minW: "min-w-[18rem]", sort: (j) => j.name.toLowerCase(), + cell: (j) => ( + e.stopPropagation()} + > + {j.name} + + ), + }, + { + key: "comment", label: "", title: "Has comments", align: "center", + header: