diff --git a/cueweb/app/__tests__/api/utils/limit_action_utils.test.ts b/cueweb/app/__tests__/api/utils/limit_action_utils.test.ts new file mode 100644 index 000000000..5a1bbf90a --- /dev/null +++ b/cueweb/app/__tests__/api/utils/limit_action_utils.test.ts @@ -0,0 +1,84 @@ +/* + * 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 { + createLimit, + deleteLimit, + renameLimit, + setLimitMaxValue, +} from '@/app/utils/action_utils'; +import { accessActionApi } from '@/app/utils/api_utils'; + +jest.mock('@/app/utils/api_utils', () => ({ + accessActionApi: jest.fn(), + accessGetApi: jest.fn(), +})); + +jest.mock('@/app/utils/notify_utils', () => ({ + toastSuccess: jest.fn(), + toastWarning: jest.fn(), + handleError: jest.fn(), +})); + +jest.mock('@/app/utils/get_utils', () => ({ + getJobForLayer: jest.fn(), + getFrameLogDir: jest.fn(), +})); + +describe('limit action_utils helpers', () => { + beforeEach(() => jest.clearAllMocks()); + + it('createLimit posts { name, max_value }', async () => { + (accessActionApi as jest.Mock).mockResolvedValue({ success: true }); + await expect(createLimit('test1', 0)).resolves.toBe(true); + expect(accessActionApi).toHaveBeenCalledWith( + '/api/limit/action/create', + [JSON.stringify({ name: 'test1', max_value: 0 })], + ); + }); + + it('deleteLimit posts { name }', async () => { + (accessActionApi as jest.Mock).mockResolvedValue({ success: true }); + await deleteLimit('test1'); + expect(accessActionApi).toHaveBeenCalledWith( + '/api/limit/action/delete', + [JSON.stringify({ name: 'test1' })], + ); + }); + + it('renameLimit posts { old_name, new_name }', async () => { + (accessActionApi as jest.Mock).mockResolvedValue({ success: true }); + await renameLimit('test1', 'test2'); + expect(accessActionApi).toHaveBeenCalledWith( + '/api/limit/action/rename', + [JSON.stringify({ old_name: 'test1', new_name: 'test2' })], + ); + }); + + it('setLimitMaxValue posts { name, max_value }', async () => { + (accessActionApi as jest.Mock).mockResolvedValue({ success: true }); + await setLimitMaxValue('test1', 50); + expect(accessActionApi).toHaveBeenCalledWith( + '/api/limit/action/setmaxvalue', + [JSON.stringify({ name: 'test1', max_value: 50 })], + ); + }); + + it('returns false when the action reports failure', async () => { + (accessActionApi as jest.Mock).mockResolvedValue({ error: 'boom' }); + await expect(createLimit('test1', 0)).resolves.toBe(false); + }); +}); diff --git a/cueweb/app/__tests__/utils/limit_get_utils.test.ts b/cueweb/app/__tests__/utils/limit_get_utils.test.ts new file mode 100644 index 000000000..c1532c1f1 --- /dev/null +++ b/cueweb/app/__tests__/utils/limit_get_utils.test.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 { getLimits } from '@/app/utils/get_utils'; +import { accessGetApi } from '@/app/utils/api_utils'; + +jest.mock('@/app/utils/api_utils', () => ({ + accessGetApi: jest.fn(), +})); + +describe('getLimits', () => { + beforeEach(() => jest.clearAllMocks()); + + it('posts to /api/limit/getall and returns the array', async () => { + const limits = [{ id: 'l1', name: 'asdf', maxValue: 0, currentRunning: 0 }]; + (accessGetApi as jest.Mock).mockResolvedValue(limits); + + await expect(getLimits()).resolves.toEqual(limits); + expect(accessGetApi).toHaveBeenCalledWith('/api/limit/getall', JSON.stringify({})); + }); + + it('returns [] when the gateway returns a non-array', async () => { + (accessGetApi as jest.Mock).mockResolvedValue(null); + await expect(getLimits()).resolves.toEqual([]); + }); +}); diff --git a/cueweb/app/api/limit/action/create/route.ts b/cueweb/app/api/limit/action/create/route.ts new file mode 100644 index 000000000..6e1a892d7 --- /dev/null +++ b/cueweb/app/api/limit/action/create/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"; + +// Create a limit. Request: { name, max_value }. RPC: /limit.LimitInterface/Create. +export async function POST(request: NextRequest) { + const endpoint = "/limit.LimitInterface/Create"; + 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' || + typeof jsonBody.name !== 'string' || + jsonBody.name.trim().length === 0 || + typeof jsonBody.max_value !== 'number' + ) { + return NextResponse.json({ error: 'Invalid request body: name and max_value are required' }, { status: 400 }); + } + + const body = JSON.stringify({ name: jsonBody.name.trim(), max_value: jsonBody.max_value }); + 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/limit/action/delete/route.ts b/cueweb/app/api/limit/action/delete/route.ts new file mode 100644 index 000000000..3dc954a5f --- /dev/null +++ b/cueweb/app/api/limit/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 limit by name. Request: { name }. RPC: /limit.LimitInterface/Delete. +export async function POST(request: NextRequest) { + const endpoint = "/limit.LimitInterface/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' || typeof jsonBody.name !== 'string' || jsonBody.name.length === 0) { + return NextResponse.json({ error: 'Invalid request body: name is required' }, { status: 400 }); + } + + const body = JSON.stringify({ name: jsonBody.name }); + 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/limit/action/rename/route.ts b/cueweb/app/api/limit/action/rename/route.ts new file mode 100644 index 000000000..bc94f2b25 --- /dev/null +++ b/cueweb/app/api/limit/action/rename/route.ts @@ -0,0 +1,51 @@ +/* + * 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 limit. Request: { old_name, new_name }. RPC: /limit.LimitInterface/Rename. +export async function POST(request: NextRequest) { + const endpoint = "/limit.LimitInterface/Rename"; + 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' || + typeof jsonBody.old_name !== 'string' || + jsonBody.old_name.length === 0 || + typeof jsonBody.new_name !== 'string' || + jsonBody.new_name.trim().length === 0 + ) { + return NextResponse.json({ error: 'Invalid request body: old_name and new_name are required' }, { status: 400 }); + } + + const body = JSON.stringify({ old_name: jsonBody.old_name, new_name: jsonBody.new_name.trim() }); + 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/limit/action/setmaxvalue/route.ts b/cueweb/app/api/limit/action/setmaxvalue/route.ts new file mode 100644 index 000000000..72baa5039 --- /dev/null +++ b/cueweb/app/api/limit/action/setmaxvalue/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"; + +// Set a limit's max value. Request: { name, max_value }. The value must be a +// non-negative integer. RPC: /limit.LimitInterface/SetMaxValue. +export async function POST(request: NextRequest) { + const endpoint = "/limit.LimitInterface/SetMaxValue"; + 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' || + typeof jsonBody.name !== 'string' || + jsonBody.name.length === 0 || + typeof jsonBody.max_value !== 'number' || + jsonBody.max_value < 0 + ) { + return NextResponse.json({ error: 'Invalid request body: name and a non-negative max_value are required' }, { status: 400 }); + } + + const body = JSON.stringify({ name: jsonBody.name, max_value: jsonBody.max_value }); + 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/limit/getall/route.ts b/cueweb/app/api/limit/getall/route.ts new file mode 100644 index 000000000..960a4696c --- /dev/null +++ b/cueweb/app/api/limit/getall/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 every limit for the Limits page. Unlike host/show GetAll, the gateway +// nests the result a single level as { limits: [...] } (LimitGetAllResponse has +// a `repeated Limit limits`, no inner *Seq), so we unwrap one level. +// RPC: /limit.LimitInterface/GetAll. +export async function POST(request: NextRequest) { + const endpoint = "/limit.LimitInterface/GetAll"; + 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 - GetAll 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 limits" }, + { status: response.status }, + ); + } + const limits = responseData?.data?.limits ?? []; + return NextResponse.json({ data: limits }, { status: response.status }); +} diff --git a/cueweb/app/limits/limit-columns.tsx b/cueweb/app/limits/limit-columns.tsx new file mode 100644 index 000000000..a8060163b --- /dev/null +++ b/cueweb/app/limits/limit-columns.tsx @@ -0,0 +1,57 @@ +"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 { ColumnDef } from "@tanstack/react-table"; +import { ArrowUpDown } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Limit } from "@/app/utils/get_utils"; + +function sortableHeader(label: string) { + // eslint-disable-next-line react/display-name + return ({ column }: { column: any }) => ( + + ); +} + +// Columns mirror CueGUI's Limits window: Limit Name, Max Value, Current +// Running. Numeric columns sort by their underlying value. +export const limitColumns: ColumnDef[] = [ + { + accessorKey: "name", + header: sortableHeader("Limit Name"), + cell: ({ row }) => {row.original.name}, + }, + { + accessorKey: "maxValue", + header: sortableHeader("Max Value"), + cell: ({ row }) => {row.original.maxValue}, + }, + { + accessorKey: "currentRunning", + header: sortableHeader("Current Running"), + cell: ({ row }) => {row.original.currentRunning}, + }, +]; diff --git a/cueweb/app/limits/page.tsx b/cueweb/app/limits/page.tsx new file mode 100644 index 000000000..c3bea79d5 --- /dev/null +++ b/cueweb/app/limits/page.tsx @@ -0,0 +1,119 @@ +"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 { Limit, getLimits } from "@/app/utils/get_utils"; +import { limitColumns } from "@/app/limits/limit-columns"; +import { handleError } from "@/app/utils/notify_utils"; +import { SimpleDataTable } from "@/components/ui/simple-data-table"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { LimitAddDialog } from "@/components/ui/limit-add-dialog"; +import { LimitEditMaxValueDialog } from "@/components/ui/limit-edit-max-value-dialog"; +import { LimitRenameDialog } from "@/components/ui/limit-rename-dialog"; +import { LimitDeleteDialog } from "@/components/ui/limit-delete-dialog"; +import { LIMITS_CHANGED_EVENT } from "@/components/ui/limit-action-events"; + +const REFRESH_MS = 30000; + +export default function LimitsPage() { + const [limits, setLimits] = React.useState(null); + const [error, setError] = React.useState(null); + const [addOpen, setAddOpen] = React.useState(false); + + const load = React.useCallback(async (isCancelled?: () => boolean) => { + try { + const data = await getLimits(); + if (isCancelled?.()) return; + setLimits(data); + setError(null); + } catch (err) { + if (isCancelled?.()) return; + handleError(err, "Could not load limits"); + setError(err instanceof Error ? err.message : String(err)); + setLimits((prev) => prev ?? []); + } + }, []); + + React.useEffect(() => { + let cancelled = false; + const isCancelled = () => cancelled; + load(isCancelled); + const interval = setInterval(() => load(isCancelled), REFRESH_MS); + return () => { + cancelled = true; + clearInterval(interval); + }; + }, [load]); + + // Re-fetch after a limit is created / renamed / deleted / max value set. + React.useEffect(() => { + const handler = () => load(); + window.addEventListener(LIMITS_CHANGED_EVENT, handler); + return () => window.removeEventListener(LIMITS_CHANGED_EVENT, handler); + }, [load]); + + return ( +
+
+

Limits

+
+ + +
+
+ + {limits === null ? ( +
+ + + +
+ ) : ( + <> + {error && limits.length === 0 ? ( +
+ Could not load limits from Cuebot. + +
+ ) : null} + + + )} + + {/* Add Limit (header button) + the row context-menu dialogs (Edit Max + Value / Rename / Delete), opened via CustomEvents. */} + + + + +
+ ); +} diff --git a/cueweb/app/utils/action_utils.ts b/cueweb/app/utils/action_utils.ts index 182b437d0..6cbcd6bd4 100644 --- a/cueweb/app/utils/action_utils.ts +++ b/cueweb/app/utils/action_utils.ts @@ -20,7 +20,7 @@ import * as React from "react"; import { Frame } from "../frames/frame-columns"; import { Layer } from "../layers/layer-columns"; import { accessActionApi, accessGetApi } from "./api_utils"; -import { getFrameLogDir, getJobForLayer, Host, JobComment, Show } from "./get_utils"; +import { getFrameLogDir, getJobForLayer, Host, JobComment, Limit, Show } from "./get_utils"; import { handleError, toastSuccess, toastWarning } from "./notify_utils"; /**************************************/ @@ -1159,3 +1159,60 @@ export function createSubscriptionGivenRow(row: Row) { }), ); } + +/**************************************/ +// Limit actions (CueCommander Limits window parity) +/**************************************/ + +// Limit mutations key on the limit name (the proto requests take name / +// old_name, not an id or object). They call accessActionApi directly so the +// calling dialog can show a single toast; errors are surfaced by accessActionApi. +async function limitAction(endpoint: string, body: object): Promise { + const result = await accessActionApi(endpoint, [JSON.stringify(body)]); + return !!result?.success; +} + +export async function createLimit(name: string, maxValue: number): Promise { + return limitAction("/api/limit/action/create", { name, max_value: maxValue }); +} + +export async function deleteLimit(name: string): Promise { + return limitAction("/api/limit/action/delete", { name }); +} + +export async function renameLimit(oldName: string, newName: string): Promise { + return limitAction("/api/limit/action/rename", { old_name: oldName, new_name: newName }); +} + +export async function setLimitMaxValue(name: string, maxValue: number): Promise { + return limitAction("/api/limit/action/setmaxvalue", { name, max_value: maxValue }); +} + +// Context-menu dispatchers: open the page-level dialogs via CustomEvent so the +// free-function handlers stay free of component state (same pattern as hosts/shows). +export function editLimitMaxValueGivenRow(row: Row) { + if (typeof window === "undefined") return; + window.dispatchEvent( + new CustomEvent("cueweb:open-limit-edit-max-value", { + detail: { limit: row.original as Limit }, + }), + ); +} + +export function renameLimitGivenRow(row: Row) { + if (typeof window === "undefined") return; + window.dispatchEvent( + new CustomEvent("cueweb:open-limit-rename", { + detail: { limit: row.original as Limit }, + }), + ); +} + +export function deleteLimitGivenRow(row: Row) { + if (typeof window === "undefined") return; + window.dispatchEvent( + new CustomEvent("cueweb:open-limit-delete", { + detail: { limit: row.original as Limit }, + }), + ); +} diff --git a/cueweb/app/utils/get_utils.ts b/cueweb/app/utils/get_utils.ts index 12e5f92ff..6179ee391 100644 --- a/cueweb/app/utils/get_utils.ts +++ b/cueweb/app/utils/get_utils.ts @@ -173,6 +173,15 @@ export type Allocation = { }; }; +// Limit shape - mirrors limit.Limit. maxValue / currentRunning arrive from the +// gateway in camelCase. +export type Limit = { + id: string; + name: string; + maxValue: number; + currentRunning: number; +}; + // Fetch a single frame based on the request body export async function getFrame(body: string): Promise { const ENDPOINT = "/api/frame/getframe"; @@ -343,6 +352,13 @@ export async function getAllocations(): Promise { return Array.isArray(response) ? response : []; } +// Fetch every limit known to Cuebot (for the Limits page). +export async function getLimits(): Promise { + const ENDPOINT = "/api/limit/getall"; + const response = await accessGetApi(ENDPOINT, JSON.stringify({})); + return Array.isArray(response) ? response : []; +} + // Fetch all comments for a given job export async function getJobComments(job: Job): Promise { const ENDPOINT = "/api/job/getcomments"; diff --git a/cueweb/components/ui/context_menus/action-context-menu.tsx b/cueweb/components/ui/context_menus/action-context-menu.tsx index 748147cce..54b44dc39 100644 --- a/cueweb/components/ui/context_menus/action-context-menu.tsx +++ b/cueweb/components/ui/context_menus/action-context-menu.tsx @@ -26,6 +26,7 @@ import { copyJobNameGivenRow, copyLayerNameGivenRow, createSubscriptionGivenRow, + deleteLimitGivenRow, dependencyWizardGivenRow, dropExternalDependsGivenRow, dropInternalDependsGivenRow, @@ -33,6 +34,7 @@ import { eatJobsDeadFramesGivenRow, eatLayerFramesGivenRow, editHostTagsGivenRow, + editLimitMaxValueGivenRow, emailArtistGivenRow, killFrameGivenRow, killJobGivenRow, @@ -45,6 +47,7 @@ import { retryFrameGivenRow, retryJobsDeadFramesGivenRow, retryLayerDeadFramesGivenRow, + renameLimitGivenRow, retryLayerFramesGivenRow, setCoresGivenRow, setMaxRetriesGivenRow, @@ -77,6 +80,7 @@ import { TbLockOpen, TbMessage, TbPacman, + TbPencil, TbPlayerPause, TbPlayerPlay, TbPlugConnectedX, @@ -86,6 +90,7 @@ import { TbReload, TbSettings, TbStar, + TbTrash, TbTag, } from "react-icons/tb"; import { BaseContextMenu } from "./base-context-menu"; @@ -659,6 +664,54 @@ export const ShowContextMenu: React.FC = ({ ); }; +interface LimitContextMenuProps { + contextMenuState: ContextMenuState; + contextMenuHandleClose: () => void; + contextMenuRef: React.RefObject; + contextMenuTargetAreaRef: React.RefObject; +} + +// Context menu for the Limits table (CueGUI LimitsWidget parity): Edit Max +// Value, Delete Limit, Rename. +export const LimitContextMenu: React.FC = ({ + contextMenuState, + contextMenuHandleClose, + contextMenuRef, + contextMenuTargetAreaRef, +}) => { + const items: MenuItem[] = [ + { + label: "Edit Max Value", + onClick: editLimitMaxValueGivenRow, + isActive: true, + component: , + }, + sep("group-limit-modify"), + { + label: "Delete Limit", + onClick: deleteLimitGivenRow, + isActive: true, + component: , + }, + { + label: "Rename", + onClick: renameLimitGivenRow, + isActive: true, + component: , + }, + ]; + + return ( + + ); +}; + // Context menu for tables that contain Frames export const FrameContextMenu: React.FC = ({ username, diff --git a/cueweb/components/ui/limit-action-events.ts b/cueweb/components/ui/limit-action-events.ts new file mode 100644 index 000000000..72dfc0a0f --- /dev/null +++ b/cueweb/components/ui/limit-action-events.ts @@ -0,0 +1,33 @@ +/* + * 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 type { Limit } from "@/app/utils/get_utils"; + +// Shared CustomEvent names + payload types for the Limits window dialogs, +// kept in one module so the row context menu, the dialogs, and the page agree +// on the contract (same pattern as host-/show-action-events). + +export const OPEN_LIMIT_EDIT_MAX_VALUE_EVENT = "cueweb:open-limit-edit-max-value"; +export const OPEN_LIMIT_RENAME_EVENT = "cueweb:open-limit-rename"; +export const OPEN_LIMIT_DELETE_EVENT = "cueweb:open-limit-delete"; + +export type OpenLimitDetail = { + limit: Limit; +}; + +// Fired after a limit changes (created, renamed, deleted, max value set) so +// the Limits page re-fetches. +export const LIMITS_CHANGED_EVENT = "cueweb:limits-changed"; diff --git a/cueweb/components/ui/limit-add-dialog.tsx b/cueweb/components/ui/limit-add-dialog.tsx new file mode 100644 index 000000000..31f18f4ae --- /dev/null +++ b/cueweb/components/ui/limit-add-dialog.tsx @@ -0,0 +1,101 @@ +"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 { createLimit } from "@/app/utils/action_utils"; +import { toastSuccess, toastWarning } from "@/app/utils/notify_utils"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { LIMITS_CHANGED_EVENT } from "@/components/ui/limit-action-events"; + +interface LimitAddDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +/** + * "Add Limit" dialog (CueGUI LimitsWidget parity). Opened from the Limits page + * header button. Asks for a name; the new limit is created with max value 0 + * (matching CueGUI, which then lets you Edit Max Value). Fires + * `cueweb:limits-changed` on success. + */ +export function LimitAddDialog({ open, onOpenChange }: LimitAddDialogProps) { + const [name, setName] = React.useState(""); + const [submitting, setSubmitting] = React.useState(false); + + const handleOpenChange = (next: boolean) => { + if (!next) setName(""); + onOpenChange(next); + }; + + async function handleCreate() { + const trimmed = name.trim(); + if (trimmed.length === 0) { + toastWarning("Enter a name for the new limit."); + return; + } + setSubmitting(true); + try { + const ok = await createLimit(trimmed, 0); + if (ok) { + toastSuccess(`Created limit ${trimmed}`); + window.dispatchEvent(new CustomEvent(LIMITS_CHANGED_EVENT)); + handleOpenChange(false); + } + } finally { + setSubmitting(false); + } + } + + return ( + {} : handleOpenChange}> + + + Add Limit + Enter a name for the new limit. + + + setName(e.target.value)} + disabled={submitting} + autoFocus + aria-label="Limit name" + /> + + + + + + + + ); +} diff --git a/cueweb/components/ui/limit-delete-dialog.tsx b/cueweb/components/ui/limit-delete-dialog.tsx new file mode 100644 index 000000000..9c5b408b0 --- /dev/null +++ b/cueweb/components/ui/limit-delete-dialog.tsx @@ -0,0 +1,99 @@ +"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 type { Limit } from "@/app/utils/get_utils"; +import { deleteLimit } from "@/app/utils/action_utils"; +import { toastSuccess } from "@/app/utils/notify_utils"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + LIMITS_CHANGED_EVENT, + OPEN_LIMIT_DELETE_EVENT, + type OpenLimitDetail, +} from "@/components/ui/limit-action-events"; + +/** + * "Delete selected limits?" confirmation (CueGUI LimitsWidget parity). Mounted + * once on the Limits page and opened by a `cueweb:open-limit-delete` event from + * the row context menu. On confirm it calls Delete (by name) and fires + * `cueweb:limits-changed`. + */ +export function LimitDeleteDialog() { + const [open, setOpen] = React.useState(false); + const [limit, setLimit] = React.useState(null); + const [submitting, setSubmitting] = React.useState(false); + + React.useEffect(() => { + function handler(e: Event) { + const detail = (e as CustomEvent).detail; + if (!detail?.limit) return; + setLimit(detail.limit); + setOpen(true); + } + window.addEventListener(OPEN_LIMIT_DELETE_EVENT, handler); + return () => window.removeEventListener(OPEN_LIMIT_DELETE_EVENT, handler); + }, []); + + async function handleConfirm() { + if (!limit) return; + setSubmitting(true); + try { + const ok = await deleteLimit(limit.name); + if (ok) { + toastSuccess(`Deleted limit ${limit.name}`); + window.dispatchEvent(new CustomEvent(LIMITS_CHANGED_EVENT)); + setOpen(false); + } + } finally { + setSubmitting(false); + } + } + + return ( + {} : setOpen}> + + + Delete selected limit? + This cannot be undone. + + +
+ {limit?.name} +
+ + + + + +
+
+ ); +} diff --git a/cueweb/components/ui/limit-edit-max-value-dialog.tsx b/cueweb/components/ui/limit-edit-max-value-dialog.tsx new file mode 100644 index 000000000..a238768fa --- /dev/null +++ b/cueweb/components/ui/limit-edit-max-value-dialog.tsx @@ -0,0 +1,124 @@ +"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 type { Limit } from "@/app/utils/get_utils"; +import { setLimitMaxValue } from "@/app/utils/action_utils"; +import { toastSuccess, toastWarning } from "@/app/utils/notify_utils"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { + LIMITS_CHANGED_EVENT, + OPEN_LIMIT_EDIT_MAX_VALUE_EVENT, + type OpenLimitDetail, +} from "@/components/ui/limit-action-events"; + +/** + * "Edit Max Value" dialog (CueGUI LimitsWidget parity). Mounted once on the + * Limits page and opened by a `cueweb:open-limit-edit-max-value` event from the + * row context menu. Validates a non-negative integer before calling + * SetMaxValue, then fires `cueweb:limits-changed` so the table refreshes. + */ +export function LimitEditMaxValueDialog() { + const [open, setOpen] = React.useState(false); + const [limit, setLimit] = React.useState(null); + const [value, setValue] = React.useState("0"); + const [submitting, setSubmitting] = React.useState(false); + + React.useEffect(() => { + function handler(e: Event) { + const detail = (e as CustomEvent).detail; + if (!detail?.limit) return; + setLimit(detail.limit); + setValue(String(detail.limit.maxValue ?? 0)); + setOpen(true); + } + window.addEventListener(OPEN_LIMIT_EDIT_MAX_VALUE_EVENT, handler); + return () => window.removeEventListener(OPEN_LIMIT_EDIT_MAX_VALUE_EVENT, handler); + }, []); + + async function handleSave() { + if (!limit) return; + if (value.trim() === "") { + toastWarning("Max value must be a non-negative integer."); + return; + } + const n = Number(value); + if (!Number.isInteger(n) || n < 0) { + toastWarning("Max value must be a non-negative integer."); + return; + } + setSubmitting(true); + try { + const ok = await setLimitMaxValue(limit.name, n); + if (ok) { + toastSuccess(`Set max value ${n} on ${limit.name}`); + window.dispatchEvent(new CustomEvent(LIMITS_CHANGED_EVENT)); + setOpen(false); + } + } finally { + setSubmitting(false); + } + } + + return ( + {} : setOpen}> + + + Edit Max Value + + {limit ? ( + <>Please enter the new max value for {limit.name}: + ) : ( + "Please enter the new limit max value:" + )} + + + + setValue(e.target.value)} + disabled={submitting} + autoFocus + aria-label="Max value" + /> + + + + + + + + ); +} diff --git a/cueweb/components/ui/limit-rename-dialog.tsx b/cueweb/components/ui/limit-rename-dialog.tsx new file mode 100644 index 000000000..a2c85a39a --- /dev/null +++ b/cueweb/components/ui/limit-rename-dialog.tsx @@ -0,0 +1,111 @@ +"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 type { Limit } from "@/app/utils/get_utils"; +import { renameLimit } from "@/app/utils/action_utils"; +import { toastSuccess, toastWarning } from "@/app/utils/notify_utils"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { + LIMITS_CHANGED_EVENT, + OPEN_LIMIT_RENAME_EVENT, + type OpenLimitDetail, +} from "@/components/ui/limit-action-events"; + +/** + * "Rename a Limit" dialog (CueGUI LimitsWidget parity). Mounted once on the + * Limits page and opened by a `cueweb:open-limit-rename` event from the row + * context menu. On OK it calls Rename (old_name -> new_name) and fires + * `cueweb:limits-changed`. + */ +export function LimitRenameDialog() { + const [open, setOpen] = React.useState(false); + const [limit, setLimit] = React.useState(null); + const [name, setName] = React.useState(""); + const [submitting, setSubmitting] = React.useState(false); + + React.useEffect(() => { + function handler(e: Event) { + const detail = (e as CustomEvent).detail; + if (!detail?.limit) return; + setLimit(detail.limit); + setName(""); + setOpen(true); + } + window.addEventListener(OPEN_LIMIT_RENAME_EVENT, handler); + return () => window.removeEventListener(OPEN_LIMIT_RENAME_EVENT, handler); + }, []); + + async function handleSave() { + if (!limit) return; + const trimmed = name.trim(); + if (trimmed.length === 0) { + toastWarning("Please enter a new limit name."); + return; + } + setSubmitting(true); + try { + const ok = await renameLimit(limit.name, trimmed); + if (ok) { + toastSuccess(`Renamed ${limit.name} to ${trimmed}`); + window.dispatchEvent(new CustomEvent(LIMITS_CHANGED_EVENT)); + setOpen(false); + } + } finally { + setSubmitting(false); + } + } + + return ( + {} : setOpen}> + + + Rename a Limit + Please enter the new Limit name: + + + setName(e.target.value)} + disabled={submitting} + autoFocus + aria-label="New limit name" + /> + + + + + + + + ); +} diff --git a/cueweb/components/ui/simple-data-table.tsx b/cueweb/components/ui/simple-data-table.tsx index fa00e176d..842aa2db5 100644 --- a/cueweb/components/ui/simple-data-table.tsx +++ b/cueweb/components/ui/simple-data-table.tsx @@ -29,7 +29,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { EmptyState } from "@/components/ui/empty-state"; -import { FrameContextMenu, HostContextMenu, LayerContextMenu, ShowContextMenu } from "@/components/ui/context_menus/action-context-menu"; +import { FrameContextMenu, HostContextMenu, LayerContextMenu, LimitContextMenu, ShowContextMenu } from "@/components/ui/context_menus/action-context-menu"; import { useContextMenu } from "@/components/ui/context_menus/useContextMenu"; import { Input } from "@/components/ui/input"; import { DataTablePagination } from "@/components/ui/pagination"; @@ -46,7 +46,7 @@ import { useReactTable, VisibilityState, } from "@tanstack/react-table"; -import { ChevronDown, ChevronLeft, ChevronRight, Cpu, Film, Layers, PieChart, Search, Server, X } from "lucide-react"; +import { ChevronDown, ChevronLeft, ChevronRight, Cpu, Film, Gauge, Layers, PieChart, Search, Server, X } from "lucide-react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import * as React from "react"; import { Job } from "../../app/jobs/columns"; @@ -79,6 +79,9 @@ interface SimpleDataTableProps { // Allocations variant (read-only, Allocations page): allocation-specific // filter/empty copy and no row context menu. isAllocationsTable?: boolean; + // Limits variant (Limits page): limit-specific filter/empty copy and the + // LimitContextMenu (Edit Max Value, Delete, Rename). + isLimitsTable?: boolean; username: string; // When set, column visibility for this table persists to localStorage // under the given key. Use stable keys like "cueweb.layers.columnVisibility" @@ -114,6 +117,7 @@ export function SimpleDataTable({ isProcsTable = false, isShowsTable = false, isAllocationsTable = false, + isLimitsTable = false, username, columnVisibilityStorageKey, defaultColumnVisibility, @@ -515,8 +519,8 @@ export function SimpleDataTable({ type="search" value={globalFilter} onChange={(e) => setGlobalFilter(e.target.value)} - placeholder={isHostsTable ? "Filter hosts..." : isProcsTable ? "Filter procs..." : isShowsTable ? "Filter shows..." : isAllocationsTable ? "Filter allocations..." : (isFramesTable || isFramesLogTable) ? "Filter frames..." : "Filter layers..."} - aria-label={isHostsTable ? "Filter hosts" : isProcsTable ? "Filter procs" : isShowsTable ? "Filter shows" : isAllocationsTable ? "Filter allocations" : (isFramesTable || isFramesLogTable) ? "Filter frames" : "Filter layers"} + placeholder={isHostsTable ? "Filter hosts..." : isProcsTable ? "Filter procs..." : isShowsTable ? "Filter shows..." : isAllocationsTable ? "Filter allocations..." : isLimitsTable ? "Filter limits..." : (isFramesTable || isFramesLogTable) ? "Filter frames..." : "Filter layers..."} + aria-label={isHostsTable ? "Filter hosts" : isProcsTable ? "Filter procs" : isShowsTable ? "Filter shows" : isAllocationsTable ? "Filter allocations" : isLimitsTable ? "Filter limits" : (isFramesTable || isFramesLogTable) ? "Filter frames" : "Filter layers"} className="h-8 w-44 pl-7 pr-7 text-xs" /> {globalFilter ? ( @@ -629,6 +633,8 @@ export function SimpleDataTable({