Skip to content

Commit 02a8668

Browse files
[cueweb] Implement Subscribe to Job (email subscription via Cuebot)
- Right-click "Subscribe to Job" now opens a themed CueGUI-parity dialog (job name + read-only From + editable To + Save/Cancel). - Save calls a new /api/job/action/addsubscriber proxy that forwards to /job.JobInterface/AddSubscriber on the REST gateway; Cuebot emails the subscriber when the job finishes. - Independent of the Notify-bell (browser-side toast + optional desktop popup) - users can use either or both. - Two configurable env vars: NEXT_PUBLIC_EMAIL_DOMAIN (reused) and NEXT_PUBLIC_SUBSCRIBE_FROM_EMAIL (new, default opencue-noreply@ <domain>) for the informational From label. - Docs refresh: reference + developer-guide carry the API route, CustomEvent flow, and bell-vs-email comparison; user-guide, tutorial, quick-start, other-guides, deploying-cueweb, and the cueweb README describe the user-facing behavior with new light-mode screenshots (menu, dialog, confirmation toast).
1 parent cadd6dd commit 02a8668

19 files changed

Lines changed: 478 additions & 15 deletions

cueweb/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -375,7 +375,7 @@ The current CueWeb system offers a robust set of features designed to enhance us
375375
- **Mobile-friendly UI:** every authenticated route works on phone viewports. Hamburger-triggered nav drawer on phones, per-row `` Actions button so touch users get the right-click menu via a tap, horizontally swipeable wide data tables, and tappable key badges in the shortcuts overlay so `/` / `r` / `t` are reachable without a physical keyboard.
376376
- **LAN access:** the client builds same-origin relative URLs for every API call by default, so the app loads correctly from any host (`http://<lan-ip>:3000` from a phone, `http://localhost:3000` on the dev Mac). Clipboard has an `execCommand("copy")` fallback for plain-HTTP LAN deployments where the modern Clipboard API is unavailable.
377377
- **Auto-reloading:** Real-time updates for tables.
378-
- **Job-finished notifications:** Per-job bell to subscribe to completion. A background poller fires a toast (and an optional desktop popup when notification permission is granted) when a subscribed job reaches `FINISHED`. Subscriptions persist in `localStorage`, stay in sync across browser tabs, and the notify decision is serialized cross-tab via the Web Locks API so only one tab toasts when several poll the same job.
378+
- **Job-finished notifications (two channels):** A per-row **Notify bell** subscribes the browser - a background poller fires a toast (and an optional desktop popup when notification permission is granted) when the job reaches `FINISHED`; subscriptions persist in `localStorage`, sync across tabs, and the notify decision is serialized cross-tab via the Web Locks API so only one tab toasts when several poll the same job. The right-click **Subscribe to Job** menu entry opens a CueGUI-parity dialog that registers an *email* subscriber on Cuebot via the `AddSubscriber` RPC, so Cuebot mails the saved address when the job finishes. The two channels are independent.
379379
- **Logs:** View current and previous logs via dropdown.
380380
- **Security:** Use JWT-based authorization and secure headers.
381381
- **Keyboard shortcuts:** Press `?` anywhere in the app to open a cheat-sheet overlay; the same overlay is also reachable from **Other ▸ Show Shortcuts** in the header or the sidebar. An optional **Notify on Shortcut** toggle (also under Other) fires a toast naming the shortcut that just triggered. See [Keyboard shortcuts](#keyboard-shortcuts) below for the full list.
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/*
2+
* Copyright Contributors to the OpenCue Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { handleRoute } from '@/app/utils/api_utils';
18+
import { NextRequest, NextResponse } from "next/server";
19+
20+
export async function POST(request: NextRequest) {
21+
const endpoint = "/job.JobInterface/AddSubscriber";
22+
const method = request.method;
23+
if (method !== 'POST') {
24+
return NextResponse.json({ error: 'Invalid method. Only POST is allowed.' }, { status: 405 });
25+
}
26+
27+
const body = JSON.stringify(await request.json());
28+
const jsonBody = JSON.parse(body);
29+
if (
30+
!jsonBody ||
31+
typeof jsonBody !== 'object' ||
32+
!jsonBody.job ||
33+
typeof jsonBody.subscriber !== 'string' ||
34+
!jsonBody.subscriber.trim()
35+
) {
36+
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
37+
}
38+
39+
const response = await handleRoute(method, endpoint, body, true);
40+
const responseData = await response.json();
41+
42+
if (!response.ok) return NextResponse.json({ error: responseData.error, status: response.status });
43+
return NextResponse.json({ data: responseData.data, status: responseData.status });
44+
}

cueweb/app/jobs/data-table.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import { EmailArtistDialog } from "@/components/ui/email-artist-dialog";
4848
import { JobDetailsInline } from "@/components/ui/job-details-inline";
4949
import { RequestCoresDialog } from "@/components/ui/request-cores-dialog";
5050
import { SetPriorityDialog } from "@/components/ui/set-priority-dialog";
51+
import { SubscribeToJobDialog } from "@/components/ui/subscribe-to-job-dialog";
5152
import { JobProgressBar } from "@/components/ui/job-progress-bar";
5253
import JobSearchbox from "@/components/ui/jobs-searchbox";
5354
import { DataTablePagination } from "@/components/ui/pagination";
@@ -1651,6 +1652,13 @@ export function DataTable({ columns, username }: DataTableProps) {
16511652
active layers; user fills Date/Time + notes; Send hands the
16521653
result to the user's default mail client via a mailto: URL. */}
16531654
<RequestCoresDialog />
1655+
1656+
{/* Subscribe to Job dialog. Mounted once here; opens in response
1657+
to a `cueweb:open-subscribe-to-job` CustomEvent fired from the
1658+
row context menu's "Subscribe to Job" entry. On Save it calls
1659+
the Cuebot AddSubscriber RPC so Cuebot emails the subscriber
1660+
when the job finishes. */}
1661+
<SubscribeToJobDialog />
16541662
</>
16551663
);
16561664
}

cueweb/app/utils/action_utils.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -462,6 +462,30 @@ export function requestCoresGivenRow(row: Row<any>) {
462462
);
463463
}
464464

465+
// Right-click "Subscribe to Job" handler. Dispatches a CustomEvent that
466+
// the SubscribeToJobDialog listens for. The dialog mirrors CueGUI's
467+
// SubscribeToJobDialog: a small form with the job name, a (read-only)
468+
// From address and an editable To address. Save calls
469+
// addJobSubscriber() which forwards to the AddSubscriber RPC on Cuebot;
470+
// when the job finishes Cuebot sends an email to the subscriber.
471+
export function subscribeToJobGivenRow(row: Row<any>) {
472+
const job = row.original as Job;
473+
if (typeof window === "undefined") return;
474+
window.dispatchEvent(
475+
new CustomEvent("cueweb:open-subscribe-to-job", {
476+
detail: { job },
477+
}),
478+
);
479+
}
480+
481+
// Calls the Cuebot AddSubscriber RPC to register an email subscriber for
482+
// the job. Cuebot sends notification email to subscriber on job completion.
483+
export async function addJobSubscriber(job: Job, subscriber: string) {
484+
const endpoint = "/api/job/action/addsubscriber";
485+
const body = JSON.stringify({ job, subscriber });
486+
await performAction(endpoint, [body], `Subscribed ${subscriber} to ${job.name}`);
487+
}
488+
465489
export function setMaxRetriesGivenRow(row: Row<any>) {
466490
const job = row.original as Job;
467491
const raw = window.prompt(`Set max retries for ${job.name}`, "3");

cueweb/components/ui/context_menus/action-context-menu.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import {
4242
retryLayerFramesGivenRow,
4343
setMaxRetriesGivenRow,
4444
setPriorityGivenRow,
45+
subscribeToJobGivenRow,
4546
unmonitorJobGivenRow,
4647
unpauseJobGivenRow,
4748
} from "@/app/utils/action_utils";
@@ -285,8 +286,11 @@ export const JobContextMenu: React.FC<JobContextMenuProps> = ({
285286
component: <TbSettings className="mr-1" size={14} color={grayIfDisabled(editable)} />,
286287
},
287288
{
289+
// Opens a small dialog mirroring CueGUI's SubscribeToJobDialog. On
290+
// Save the address is registered with Cuebot via the AddSubscriber
291+
// RPC; Cuebot emails the subscriber when the job finishes.
288292
label: "Subscribe to Job",
289-
onClick: notYetImplemented("Subscribe to Job"),
293+
onClick: subscribeToJobGivenRow,
290294
isActive: true,
291295
component: <TbStar className="mr-1" size={14} />,
292296
},
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
"use client";
2+
3+
/*
4+
* Copyright Contributors to the OpenCue Project
5+
*
6+
* Licensed under the Apache License, Version 2.0 (the "License");
7+
* you may not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
19+
import * as React from "react";
20+
import { useSession } from "next-auth/react";
21+
22+
import {
23+
Dialog,
24+
DialogContent,
25+
DialogDescription,
26+
DialogFooter,
27+
DialogHeader,
28+
DialogTitle,
29+
} from "@/components/ui/dialog";
30+
import { Button } from "@/components/ui/button";
31+
import type { Job } from "@/app/jobs/columns";
32+
import { addJobSubscriber } from "@/app/utils/action_utils";
33+
import { toastWarning } from "@/app/utils/notify_utils";
34+
35+
/**
36+
* "Subscribe to Job" dialog. Mounted once at the page level and opened
37+
* in response to a `cueweb:open-subscribe-to-job` CustomEvent dispatched
38+
* from the row context menu. Decoupled this way so the menu's free-
39+
* function handlers can stay free of component refs.
40+
*
41+
* UI mirrors CueGUI's SubscribeToJobDialog:
42+
*
43+
* Subscribe to jobs <jobName>
44+
*
45+
* Job name
46+
* <jobName>
47+
*
48+
* From: <noreply address>
49+
* To: <user>@<domain>
50+
*
51+
* [ Save ] [ Cancel ]
52+
*
53+
* Save calls the Cuebot AddSubscriber RPC via /api/job/action/addsubscriber;
54+
* Cuebot emails the subscriber when the job finishes. The From address is
55+
* informational - the actual sender is whatever Cuebot is configured with.
56+
*
57+
* Defaults are configurable at build time:
58+
*
59+
* NEXT_PUBLIC_EMAIL_DOMAIN - domain part of the default "To"
60+
* address. Defaults to
61+
* "your.domain.com".
62+
* NEXT_PUBLIC_SUBSCRIBE_FROM_EMAIL - the informational From label.
63+
* Defaults to
64+
* "opencue-noreply@<EMAIL_DOMAIN>".
65+
*/
66+
67+
export const OPEN_SUBSCRIBE_TO_JOB_EVENT = "cueweb:open-subscribe-to-job";
68+
69+
export type OpenSubscribeToJobDetail = {
70+
job: Job;
71+
};
72+
73+
const EMAIL_DOMAIN = (
74+
process.env.NEXT_PUBLIC_EMAIL_DOMAIN ?? "your.domain.com"
75+
).trim();
76+
const FROM_EMAIL = (
77+
process.env.NEXT_PUBLIC_SUBSCRIBE_FROM_EMAIL ??
78+
`opencue-noreply@${EMAIL_DOMAIN}`
79+
).trim();
80+
81+
function defaultToFor(user: string): string {
82+
const safeUser = user?.trim() || "you";
83+
return `${safeUser}@${EMAIL_DOMAIN}`;
84+
}
85+
86+
// RFC 5322-ish: just enough to reject obvious typos before hitting the
87+
// backend. Cuebot does its own validation server-side.
88+
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
89+
90+
export function SubscribeToJobDialog() {
91+
const { data: session } = useSession();
92+
const [open, setOpen] = React.useState(false);
93+
const [job, setJob] = React.useState<Job | null>(null);
94+
const [to, setTo] = React.useState("");
95+
const [busy, setBusy] = React.useState(false);
96+
97+
React.useEffect(() => {
98+
function handler(e: Event) {
99+
const detail = (e as CustomEvent<OpenSubscribeToJobDetail>).detail;
100+
if (!detail?.job) return;
101+
const j = detail.job;
102+
// Prefer the signed-in user's email when available; otherwise fall
103+
// back to <sessionName-or-jobUser>@<EMAIL_DOMAIN>, matching the
104+
// CueGUI default of getpass.getuser()+EMAIL_DOMAIN.
105+
const sessionEmail = session?.user?.email?.trim();
106+
const userFallback = session?.user?.name?.trim() || j.user;
107+
setJob(j);
108+
setTo(sessionEmail || defaultToFor(userFallback));
109+
setOpen(true);
110+
}
111+
window.addEventListener(OPEN_SUBSCRIBE_TO_JOB_EVENT, handler);
112+
return () =>
113+
window.removeEventListener(OPEN_SUBSCRIBE_TO_JOB_EVENT, handler);
114+
}, [session?.user?.email, session?.user?.name]);
115+
116+
async function handleSave() {
117+
if (!job) return;
118+
const subscriber = to.trim();
119+
if (!EMAIL_RE.test(subscriber)) {
120+
toastWarning("Enter a valid email address before saving.");
121+
return;
122+
}
123+
setBusy(true);
124+
try {
125+
await addJobSubscriber(job, subscriber);
126+
setOpen(false);
127+
} finally {
128+
setBusy(false);
129+
}
130+
}
131+
132+
return (
133+
<Dialog open={open} onOpenChange={(o) => !busy && setOpen(o)}>
134+
<DialogContent className="sm:max-w-xl">
135+
<DialogHeader>
136+
<DialogTitle>
137+
<span className="font-mono break-all">
138+
Subscribe to jobs {job?.name ?? "Job"}
139+
</span>
140+
</DialogTitle>
141+
<DialogDescription>
142+
On Save the address is registered as a subscriber on Cuebot;
143+
you&apos;ll receive an email from Cuebot when the job finishes. The
144+
<span className="font-mono"> From:</span> address shown is
145+
informational - the real sender is whatever Cuebot is
146+
configured with.
147+
</DialogDescription>
148+
</DialogHeader>
149+
150+
<div className="grid gap-3 py-2 text-sm">
151+
<div>
152+
<div className="text-foreground/70">Job name</div>
153+
<div className="mt-1 rounded-md border border-input bg-foreground/[0.04] px-3 py-2 font-mono text-xs break-all">
154+
{job?.name ?? ""}
155+
</div>
156+
</div>
157+
158+
<div className="flex items-center gap-3">
159+
<label
160+
htmlFor="subscribe-to-job-from"
161+
className="w-16 shrink-0 text-right text-foreground/70"
162+
>
163+
From:
164+
</label>
165+
<span
166+
id="subscribe-to-job-from"
167+
className="flex-1 rounded-md border border-input bg-foreground/[0.04] px-3 py-1.5 font-mono text-xs"
168+
>
169+
{FROM_EMAIL}
170+
</span>
171+
</div>
172+
173+
<div className="flex items-center gap-3">
174+
<label
175+
htmlFor="subscribe-to-job-to"
176+
className="w-16 shrink-0 text-right text-foreground/70"
177+
>
178+
To:
179+
</label>
180+
<input
181+
id="subscribe-to-job-to"
182+
type="email"
183+
value={to}
184+
onChange={(e) => setTo(e.target.value)}
185+
autoFocus
186+
className="flex-1 rounded-md border border-input bg-background px-3 py-1.5 text-sm"
187+
/>
188+
</div>
189+
</div>
190+
191+
<DialogFooter>
192+
<Button
193+
type="button"
194+
variant="outline"
195+
onClick={() => setOpen(false)}
196+
disabled={busy}
197+
>
198+
Cancel
199+
</Button>
200+
<Button
201+
type="button"
202+
onClick={handleSave}
203+
disabled={busy || !to.trim()}
204+
>
205+
{busy ? "Saving..." : "Save"}
206+
</Button>
207+
</DialogFooter>
208+
</DialogContent>
209+
</Dialog>
210+
);
211+
}

0 commit comments

Comments
 (0)