@hugpy/video-intelligence-ui 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +41 -0
- package/assets/sampels/images/bycycle/bd8603bad35d4f179f4d1134d8faf2ee.mp4 +0 -0
- package/assets/sampels/images/bycycle/frame_00000.png +0 -0
- package/assets/sampels/images/bycycle/frame_00001.png +0 -0
- package/assets/sampels/images/bycycle/frame_00002.png +0 -0
- package/assets/sampels/images/bycycle/frame_00003.png +0 -0
- package/assets/sampels/images/bycycle/frame_00004.png +0 -0
- package/assets/sampels/images/bycycle/frame_00005.png +0 -0
- package/assets/sampels/images/forrest/ff23090611b844a6b85c224f388bd542.mp4 +0 -0
- package/assets/sampels/images/forrest/frame_00000.png +0 -0
- package/assets/sampels/images/forrest/frame_00001.png +0 -0
- package/assets/sampels/images/forrest/frame_00002.png +0 -0
- package/assets/sampels/images/forrest/frame_00003.png +0 -0
- package/assets/sampels/images/forrest/frame_00004.png +0 -0
- package/assets/sampels/images/forrest/frame_00005.png +0 -0
- package/assets/sampels/images/shenzo/e1a4fcd623084ab4a986d11c8cd00db8.mp4 +0 -0
- package/assets/sampels/images/shenzo/frame_00000.png +0 -0
- package/assets/sampels/images/shenzo/frame_00001.png +0 -0
- package/assets/sampels/images/shenzo/frame_00002.png +0 -0
- package/assets/sampels/images/shenzo/frame_00003.png +0 -0
- package/assets/sampels/images/shenzo/frame_00004.png +0 -0
- package/assets/sampels/images/shenzo/frame_00005.png +0 -0
- package/assets/sampels/scenes/man.mp4 +0 -0
- package/assets/sampels/scenes/woman.mp4 +0 -0
- package/assets/sampels/studio_movies/caverns/_assembly/contrib_00.mp4 +0 -0
- package/assets/sampels/studio_movies/caverns/movie.json +67 -0
- package/assets/sampels/studio_movies/caverns/movie.mp4 +0 -0
- package/assets/sampels/studio_movies/caverns/segment_00/5eea7770a554fef0c3bdc38f8f38b925dcf75f13c015d423e8eadec47d41bf6b/clip.mp4 +0 -0
- package/assets/sampels/studio_movies/caverns/segment_00/5eea7770a554fef0c3bdc38f8f38b925dcf75f13c015d423e8eadec47d41bf6b/manifest.json +87 -0
- package/assets/sampels/studio_movies/caverns/segment_00/5eea7770a554fef0c3bdc38f8f38b925dcf75f13c015d423e8eadec47d41bf6b/provenance.json +7 -0
- package/assets/sampels/studio_movies/caverns/segment_01/branch.png +0 -0
- package/assets/sampels/studio_movies/keeper-matrix/_assembly/concat_list.txt +2 -0
- package/assets/sampels/studio_movies/keeper-matrix/_assembly/contrib_00.mp4 +0 -0
- package/assets/sampels/studio_movies/keeper-matrix/_assembly/contrib_01.mp4 +0 -0
- package/assets/sampels/studio_movies/keeper-matrix/movie.json +81 -0
- package/assets/sampels/studio_movies/keeper-matrix/movie.mp4 +0 -0
- package/assets/sampels/studio_movies/keeper-matrix/segment_00/64854c6a21d47ce10b23e884e12de448b25bfb0a375d33238dd0bec8056c076d/clip.mp4 +0 -0
- package/assets/sampels/studio_movies/keeper-matrix/segment_00/64854c6a21d47ce10b23e884e12de448b25bfb0a375d33238dd0bec8056c076d/manifest.json +87 -0
- package/assets/sampels/studio_movies/keeper-matrix/segment_00/64854c6a21d47ce10b23e884e12de448b25bfb0a375d33238dd0bec8056c076d/provenance.json +7 -0
- package/assets/sampels/studio_movies/keeper-matrix/segment_01/branch.png +0 -0
- package/assets/sampels/studio_movies/keeper-matrix/segment_01/cc926b44a42b90d7d291a4e50e3859458306c207a17c718086a3466e4049d6c6/clip.mp4 +0 -0
- package/assets/sampels/studio_movies/keeper-matrix/segment_01/cc926b44a42b90d7d291a4e50e3859458306c207a17c718086a3466e4049d6c6/manifest.json +87 -0
- package/assets/sampels/studio_movies/keeper-matrix/segment_01/cc926b44a42b90d7d291a4e50e3859458306c207a17c718086a3466e4049d6c6/provenance.json +7 -0
- package/assets/sampels/videos/a_red_paper_boat_drifting_on_a_calm_pond__gentle_ripples__mo_a62d6c28.mp4 +0 -0
- package/assets/sampels/videos/aurora.mp4 +0 -0
- package/assets/sampels/videos/dark_night.mp4 +0 -0
- package/assets/sampels/videos/fire_and_soul.mp4 +0 -0
- package/assets/sampels/videos/harsh_seas.mp4 +0 -0
- package/entry.tsx +74 -0
- package/index.html +53 -0
- package/package.json +42 -0
- package/src/assets/hugpy-mark.png +0 -0
- package/src/config.ts +676 -0
- package/src/demo/DemoBanner.tsx +54 -0
- package/src/demo/assets/demo-audio.mp3 +0 -0
- package/src/demo/assets/demo-clip.mp4 +0 -0
- package/src/demo/assets/demo-crop.mp4 +0 -0
- package/src/demo/assets/demo-poster.jpg +0 -0
- package/src/demo/assets/frame-1.jpg +0 -0
- package/src/demo/assets/frame-2.jpg +0 -0
- package/src/demo/assets/frame-3.jpg +0 -0
- package/src/demo/assets/frame-4.jpg +0 -0
- package/src/demo/assets/frame-5.jpg +0 -0
- package/src/demo/assets/frame-6.jpg +0 -0
- package/src/demo/assets/gen-1.jpg +0 -0
- package/src/demo/assets/gen-2.jpg +0 -0
- package/src/demo/assets.d.ts +14 -0
- package/src/demo/demo.css +64 -0
- package/src/demo/demoFetch.ts +305 -0
- package/src/demo/fixtures.ts +814 -0
- package/src/demo/index.ts +43 -0
- package/src/demo/mode.ts +81 -0
- package/src/demo/promptAssist.ts +181 -0
- package/src/demo/seed.ts +97 -0
- package/src/nav/BrandMark.css +66 -0
- package/src/nav/BrandMark.tsx +40 -0
- package/src/nav/Navbar.css +13 -0
- package/src/nav/Navbar.tsx +68 -0
- package/src/regions/SpatialRegionEditor.tsx +375 -0
- package/src/regions/TemporalRegionEditor.tsx +485 -0
- package/src/regions/types.ts +85 -0
- package/src/session.ts +85 -0
- package/src/share.ts +77 -0
- package/src/stations/ActivePanel.tsx +55 -0
- package/src/stations/ActiveProcessesStation.tsx +272 -0
- package/src/stations/ActiveProcessesStation.tsx.bak-sessions-20260812 +228 -0
- package/src/stations/AddFileBar.tsx +59 -0
- package/src/stations/AudioCropStation.tsx +157 -0
- package/src/stations/ComfyPanel.tsx +210 -0
- package/src/stations/ComfyStation.tsx +196 -0
- package/src/stations/CondensedKnobStrip.tsx +245 -0
- package/src/stations/ConsolePanel.tsx +82 -0
- package/src/stations/FrameExtractStation.tsx +148 -0
- package/src/stations/GenIdentityBar.tsx +412 -0
- package/src/stations/GenerateStation.tsx +5603 -0
- package/src/stations/GenerateStation.tsx.bak-tester-removal +5170 -0
- package/src/stations/GoalComposer.tsx +139 -0
- package/src/stations/ImageCropStation.tsx +153 -0
- package/src/stations/KnobInfo.tsx +64 -0
- package/src/stations/PlaceholderStation.tsx +26 -0
- package/src/stations/SectionTabs.tsx +276 -0
- package/src/stations/SharePanel.tsx +298 -0
- package/src/stations/SidebarRegistry.tsx +105 -0
- package/src/stations/StationShell.tsx +96 -0
- package/src/stations/StudioClipsStation.tsx +28 -0
- package/src/stations/WorkbenchDrawer.tsx +66 -0
- package/src/stations/WorkbenchSidebar.tsx +1224 -0
- package/src/stations/WorkbenchStation.tsx +91 -0
- package/src/stations/comfyEmbed.ts +79 -0
- package/src/stations/drawerShell.tsx +419 -0
- package/src/stations/ops/AudioCropCore.tsx +218 -0
- package/src/stations/ops/CharacterGroupsPanel.tsx +716 -0
- package/src/stations/ops/FrameExtractCore.tsx +695 -0
- package/src/stations/ops/ImageCropCore.tsx +164 -0
- package/src/stations/ops/VideoCropCore.tsx +193 -0
- package/src/stations/ops/useVideoCropJobs.ts +224 -0
- package/src/stations/pollCaps.ts +87 -0
- package/src/stations/registry.ts +191 -0
- package/src/stations/studio/AngleRingEditor.tsx +159 -0
- package/src/stations/studio/IdentitiesStation.tsx +2455 -0
- package/src/stations/studio/IdentityProfileControls.tsx +262 -0
- package/src/stations/studio/StudioActiveProcesses.tsx +224 -0
- package/src/stations/studio/StudioAssistLogPanel.tsx +220 -0
- package/src/stations/studio/StudioGenerateMode.tsx +29 -0
- package/src/stations/studio/StudioGenerateTab.tsx +2688 -0
- package/src/stations/studio/StudioItemCard.tsx +334 -0
- package/src/stations/studio/StudioLibraryTab.tsx +506 -0
- package/src/stations/studio/StudioMovieComposer.tsx +2875 -0
- package/src/stations/studio/StudioMovieComposer.tsx.bak-sessions-20260812 +2729 -0
- package/src/stations/studio/StudioMovieSessions.tsx +553 -0
- package/src/stations/studio/StudioMovieSessions.tsx.bak-sessions-20260812 +417 -0
- package/src/stations/studio/StudioPlane.tsx +302 -0
- package/src/stations/studio/StudioSplicedRow.tsx +476 -0
- package/src/stations/studio/StudioViewer.tsx +339 -0
- package/src/stations/studio/TurntableViewer.tsx +220 -0
- package/src/stations/studio/movieTimeline.ts +198 -0
- package/src/stations/studio/promptCard.tsx +498 -0
- package/src/stations/studio/studioItem.ts +87 -0
- package/src/stations/studio/studioShared.tsx +821 -0
- package/src/stations/studio/useIdentityProfiles.ts +575 -0
- package/src/stations/studio/useReconstruction.ts +283 -0
- package/src/stations/types.ts +43 -0
- package/src/stations/useAudioCropJobs.ts +202 -0
- package/src/stations/useAudioExtractJob.ts +144 -0
- package/src/stations/useCropJobs.ts +206 -0
- package/src/stations/useFrameJobs.ts +140 -0
- package/src/stations/useGenerateJob.ts +151 -0
- package/src/stations/useGenerateMovieJob.ts +177 -0
- package/src/stations/useGenerateRowJobs.ts +184 -0
- package/src/stations/useGenerateSceneJob.ts +156 -0
- package/src/stations/useGenerateSceneRowJobs.ts +282 -0
- package/src/style/app.css +4974 -0
- package/src/transport/client.ts +262 -0
- package/src/transport/telemetry.ts +97 -0
- package/src/video/DropReceptacle.tsx +122 -0
- package/src/video/PromptAssistButtons.tsx +118 -0
- package/src/video/composerBridge.ts +66 -0
- package/src/video/contract.ts +599 -0
- package/src/video/jobTracker.ts +702 -0
- package/src/video/mediaLibrary.ts +328 -0
- package/src/video/renderCompat.ts +512 -0
- package/src/video/sessionForm.ts +189 -0
- package/src/video/spreadAssist.ts +392 -0
- package/src/video/studioBridge.ts +82 -0
- package/src/video/useMediaJobs.ts +279 -0
- package/src/video/useModels.ts +413 -0
- package/src/video/useMoviePresets.ts +97 -0
- package/src/video/useMovieSessions.ts +292 -0
- package/src/video/usePresets.ts +167 -0
- package/src/video/useProjects.ts +87 -0
- package/src/video/usePromptAssist.ts +394 -0
- package/src/video/useRenderPresets.ts +221 -0
- package/src/video/useStudioAssistLog.ts +266 -0
- package/src/video/useStudioPresets.ts +128 -0
- package/src/video/useWorkerCalls.ts +165 -0
- package/tsconfig.json +17 -0
- package/vite.config.ts +33 -0
package/src/share.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// k9 — video SHARE-LINK credential (client half).
|
|
2
|
+
//
|
|
3
|
+
// An operator mints a share link (`https://dev.hugpy.ai/video/?share=<key>`) and
|
|
4
|
+
// sends it to an outside party. On first load this module lifts the `?share=` key
|
|
5
|
+
// out of the URL, stashes it in localStorage (so it survives reloads and SPA
|
|
6
|
+
// navigations — on dev the shell + assets are served ungated by the webpack
|
|
7
|
+
// watcher, so a bare reload restores the key from storage), and STRIPS it from the
|
|
8
|
+
// address bar (nothing reloads — `history.replaceState`) so the credential isn't
|
|
9
|
+
// left sitting in the URL.
|
|
10
|
+
//
|
|
11
|
+
// The stored key then rides EVERY hugpy API call:
|
|
12
|
+
// • XHR through the transport (`transport/client.ts`) carries it as the
|
|
13
|
+
// `X-Video-Share` header — the SPA's single fetch choke point stamps it once;
|
|
14
|
+
// • element-src media loads (`<img>`/`<video>` to /video/media | /video/studio/
|
|
15
|
+
// clip, via `config.ts`) can't carry a header, so those URLs carry `?share=`
|
|
16
|
+
// in the query (`withShareParam`).
|
|
17
|
+
// Server side, `video_auth._video_share_principal` accepts any of the three.
|
|
18
|
+
//
|
|
19
|
+
// A console-authenticated operator never has a stored share key (they never opened
|
|
20
|
+
// a share link), so nothing here affects them — their same-origin session cookie
|
|
21
|
+
// authenticates their calls exactly as before.
|
|
22
|
+
|
|
23
|
+
const STORAGE_KEY = "hugpy.video.share";
|
|
24
|
+
const PARAM = "share";
|
|
25
|
+
|
|
26
|
+
let _key: string | null = null;
|
|
27
|
+
let _initialized = false;
|
|
28
|
+
|
|
29
|
+
/** Lift `?share=` out of the URL (stash + strip), else restore from storage.
|
|
30
|
+
* Idempotent; safe to call before any network activity. Must run at boot. */
|
|
31
|
+
export function initShare(): void {
|
|
32
|
+
if (_initialized) return;
|
|
33
|
+
_initialized = true;
|
|
34
|
+
if (typeof window === "undefined") return;
|
|
35
|
+
try {
|
|
36
|
+
const url = new URL(window.location.href);
|
|
37
|
+
const fromUrl = url.searchParams.get(PARAM);
|
|
38
|
+
if (fromUrl) {
|
|
39
|
+
_key = fromUrl;
|
|
40
|
+
try {
|
|
41
|
+
localStorage.setItem(STORAGE_KEY, fromUrl);
|
|
42
|
+
} catch {
|
|
43
|
+
/* private mode — the in-memory key still drives this load */
|
|
44
|
+
}
|
|
45
|
+
// Strip the credential from the address bar without reloading.
|
|
46
|
+
url.searchParams.delete(PARAM);
|
|
47
|
+
const rest = url.search ? url.search : "";
|
|
48
|
+
window.history.replaceState({}, "", `${url.pathname}${rest}${url.hash}`);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
_key = localStorage.getItem(STORAGE_KEY);
|
|
52
|
+
} catch {
|
|
53
|
+
// storage/URL unavailable — leave whatever we have (likely null).
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The active share key, or null. Lazily initialises so an early caller is safe. */
|
|
58
|
+
export function videoShareKey(): string | null {
|
|
59
|
+
if (!_initialized) initShare();
|
|
60
|
+
return _key;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Append the share credential to a URL used as an element `src` (img/video),
|
|
64
|
+
* which cannot carry the `X-Video-Share` header. No-op when there's no key. */
|
|
65
|
+
export function withShareParam(url: string): string {
|
|
66
|
+
const key = videoShareKey();
|
|
67
|
+
if (!key) return url;
|
|
68
|
+
const sep = url.includes("?") ? "&" : "?";
|
|
69
|
+
return `${url}${sep}${PARAM}=${encodeURIComponent(key)}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** True when this session is running off a share link (no console login). Used
|
|
73
|
+
* only to shape UI copy — the Share button visibility is decided by a live
|
|
74
|
+
* operator-auth probe, never by this. */
|
|
75
|
+
export function isSharedSession(): boolean {
|
|
76
|
+
return videoShareKey() != null;
|
|
77
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// ActivePanel — the console-wide Active Processes view in the shared
|
|
2
|
+
// expand-into-the-screen drawer (operator ask 2026-08-06: "'Active' as an
|
|
3
|
+
// overlay", the same treatment ComfyUI got on 2026-08-04). Watching what the box
|
|
4
|
+
// is doing is something you want to do FROM the station you are working in, not
|
|
5
|
+
// by navigating away from it.
|
|
6
|
+
//
|
|
7
|
+
// Since the tab round (2026-08-06) this file contributes ONE TAB to the single
|
|
8
|
+
// unified drawer (drawerShell.tsx → WorkbenchDrawer.tsx) — same window as Console
|
|
9
|
+
// and ComfyUI, same `.vi-console-panel*` chrome. Its body is the EXISTING
|
|
10
|
+
// ActiveProcessesStation, mounted verbatim. Not a second, divergent renderer of
|
|
11
|
+
// the same feed: the `/active` tab and this overlay are literally the same
|
|
12
|
+
// component, so the placement line, the awaiting_capacity hold, the progress
|
|
13
|
+
// bars, the timeline expander and Cancel all behave identically in both places.
|
|
14
|
+
//
|
|
15
|
+
// ── POLLING: `keepMounted: false` IS THE PAUSE (read before changing it) ────
|
|
16
|
+
// The cadence lives in useMediaJobs, which polls ONLY while a consumer is
|
|
17
|
+
// mounted (mount → immediate fetch + 2s interval; unmount → interval cleared,
|
|
18
|
+
// mounted-ref flipped so a late response never setStates). There is no separate
|
|
19
|
+
// pause seam — mount IS the switch. So this tab declares `keepMounted: false`,
|
|
20
|
+
// and the poll runs exactly when the operator is watching it:
|
|
21
|
+
// * drawer closed → the shell renders no portal at all → no poll;
|
|
22
|
+
// * drawer open, other tab → this body is REMOVED (not merely hidden) → no poll;
|
|
23
|
+
// * drawer open, this tab → mounted → 2s poll.
|
|
24
|
+
// Console and ComfyUI take the opposite choice (`keepMounted: true`) because
|
|
25
|
+
// their bodies are iframes, where staying mounted costs nothing and unmounting
|
|
26
|
+
// would throw away a live session. Do NOT flip this one to true to "keep the
|
|
27
|
+
// list warm": a hidden-but-mounted body would poll every 2s behind the ComfyUI
|
|
28
|
+
// frame on every station, which is precisely what the drawer was built to avoid.
|
|
29
|
+
import { ActiveProcessesStation } from "./ActiveProcessesStation";
|
|
30
|
+
import type { DrawerTab } from "./drawerShell";
|
|
31
|
+
|
|
32
|
+
/** Active Processes' tab in the unified drawer. */
|
|
33
|
+
export function activeDrawerTab(): DrawerTab<"active"> {
|
|
34
|
+
return {
|
|
35
|
+
id: "active",
|
|
36
|
+
// The short chrome word, the way the Console tab says "Console" — the
|
|
37
|
+
// station card below carries its own "Active Processes" heading, so a longer
|
|
38
|
+
// label here would only stack the same words twice.
|
|
39
|
+
label: "Active",
|
|
40
|
+
hint: "Open Active Processes alongside this station — no tab switch",
|
|
41
|
+
toggleClassName: "vi-active-toggle",
|
|
42
|
+
toggleLabel: "▤ Active",
|
|
43
|
+
ariaLabel: "Active processes",
|
|
44
|
+
panelClassName: "vi-active-panel",
|
|
45
|
+
keepMounted: false, // see the polling note above — this is the pause
|
|
46
|
+
body: (
|
|
47
|
+
/* The station body is a `.station-card` built for a page column, so it
|
|
48
|
+
gets its own scroll region here (the other tabs' bodies are iframes,
|
|
49
|
+
which scroll themselves). */
|
|
50
|
+
<div className="vi-active-panel-body">
|
|
51
|
+
<ActiveProcessesStation />
|
|
52
|
+
</div>
|
|
53
|
+
),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// ACTIVE PROCESSES — the console-wide, placement-aware view of every in-flight
|
|
2
|
+
// media-bus job (studio / movie / identity / generate / crop / extract), fed by
|
|
3
|
+
// GET /video/jobs. Promotes the studio-only StudioActiveProcesses pattern to the
|
|
4
|
+
// whole console and adds the two things that were invisible before:
|
|
5
|
+
//
|
|
6
|
+
// • WHERE a job physically executes — the `placement` line ("ae · cuda:0 ·
|
|
7
|
+
// P-studio"), with an "external" cue when it runs OFF the central box (an ae
|
|
8
|
+
// worker, ComfyUI, or the identity-render service on :9750), plus reserved
|
|
9
|
+
// VRAM when a GPU reservation is held.
|
|
10
|
+
// • The reservation HOLD phase — a job that can't fit the card yet sits as
|
|
11
|
+
// `awaiting_capacity` (with the shortfall reason on hover + the overtaken
|
|
12
|
+
// count), not a fake "running".
|
|
13
|
+
// • WHAT it is doing right now — a moving progress bar (segments done plus the
|
|
14
|
+
// within-segment denoise fraction the runner reports) and the TAIL of the job's
|
|
15
|
+
// stage timeline, so a running render shows its last few events instead of an
|
|
16
|
+
// opaque "running" chip.
|
|
17
|
+
// • WHY it broke — a terminal-failed job renders its failure envelope VERBATIM
|
|
18
|
+
// (code + message + the stage it died in), never a generic "failed". Terminal
|
|
19
|
+
// rows arrive only with the "Show finished" filter on.
|
|
20
|
+
//
|
|
21
|
+
// HONEST rendering only: no fabricated progress bars — a numeric progress/stage
|
|
22
|
+
// shows only when the server sends one. Cancel hits the same /video/jobs/<id>/
|
|
23
|
+
// cancel seam every station uses; a held job cancels exactly like any queued job.
|
|
24
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
25
|
+
import type { StationSpec } from "./types";
|
|
26
|
+
import { request } from "../transport/client";
|
|
27
|
+
import { jobCancelUrl } from "../config";
|
|
28
|
+
import { ProcessTimeline, shortId } from "./studio/studioShared";
|
|
29
|
+
import { SessionsPanel } from "./studio/StudioMovieSessions";
|
|
30
|
+
import {
|
|
31
|
+
useMediaJobs,
|
|
32
|
+
placementLine,
|
|
33
|
+
isExternal,
|
|
34
|
+
reservedGib,
|
|
35
|
+
elapsedSince,
|
|
36
|
+
progressLabel,
|
|
37
|
+
latestEvent,
|
|
38
|
+
type MediaJob,
|
|
39
|
+
} from "../video/useMediaJobs";
|
|
40
|
+
|
|
41
|
+
function PhaseChip({ job }: { job: MediaJob }) {
|
|
42
|
+
if (job.phase === "awaiting_capacity") {
|
|
43
|
+
const reason = job.hold?.reason ? JSON.stringify(job.hold.reason) : "";
|
|
44
|
+
const overtaken = job.hold?.overtaken ?? 0;
|
|
45
|
+
return (
|
|
46
|
+
<span
|
|
47
|
+
className="vi-status vi-status-awaiting_capacity"
|
|
48
|
+
title={
|
|
49
|
+
reason
|
|
50
|
+
? `Held — waiting for GPU capacity. Shortfall: ${reason}`
|
|
51
|
+
: "Held — waiting for GPU capacity"
|
|
52
|
+
}
|
|
53
|
+
>
|
|
54
|
+
awaiting capacity{overtaken > 0 ? ` · jumped ${overtaken}×` : ""}
|
|
55
|
+
</span>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
return <span className={`vi-status vi-status-${job.phase}`}>{job.phase}</span>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The bar + its honest label. Renders nothing when the server sent no number. */
|
|
62
|
+
function ProgressBar({ job }: { job: MediaJob }) {
|
|
63
|
+
if (job.progress == null) return null;
|
|
64
|
+
const pct = Math.max(0, Math.min(100, job.progress * 100));
|
|
65
|
+
return (
|
|
66
|
+
<div className="vi-job-progress">
|
|
67
|
+
<div className="vi-job-progress-track">
|
|
68
|
+
<div className="vi-job-progress-fill" style={{ width: `${pct}%` }} />
|
|
69
|
+
</div>
|
|
70
|
+
<span className="vi-job-progress-label">{progressLabel(job)}</span>
|
|
71
|
+
</div>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** ONE job row's content (head, placement/meta, progress, timeline, Cancel) —
|
|
76
|
+
* extracted so the Sessions panel's per-surface sections (Scene/Movie/Clip)
|
|
77
|
+
* render feed jobs through the SAME renderer as the main list instead of a
|
|
78
|
+
* parallel copy. The `<li className="vi-studio-active-row">` wrapper (and its
|
|
79
|
+
* `key`) stays at the call site — this project's scoped tsconfig rejects a
|
|
80
|
+
* `key` prop on a locally-declared component (the SessionRowBody idiom). */
|
|
81
|
+
export function JobRow({
|
|
82
|
+
job: j,
|
|
83
|
+
expanded: isOpen,
|
|
84
|
+
onToggle,
|
|
85
|
+
nowMs,
|
|
86
|
+
cancelBusy,
|
|
87
|
+
onCancel,
|
|
88
|
+
}: {
|
|
89
|
+
job: MediaJob;
|
|
90
|
+
expanded: boolean;
|
|
91
|
+
onToggle: () => void;
|
|
92
|
+
nowMs: number;
|
|
93
|
+
cancelBusy: boolean;
|
|
94
|
+
onCancel: () => void;
|
|
95
|
+
}) {
|
|
96
|
+
const line = placementLine(j.placement);
|
|
97
|
+
const gib = reservedGib(j.placement);
|
|
98
|
+
const external = isExternal(j.placement);
|
|
99
|
+
const elided = j.stageLogTotal - j.stageLog.length;
|
|
100
|
+
const event = latestEvent(j);
|
|
101
|
+
return (
|
|
102
|
+
<>
|
|
103
|
+
<div className="vi-studio-active-head">
|
|
104
|
+
<button
|
|
105
|
+
type="button"
|
|
106
|
+
className="vi-timeline-toggle"
|
|
107
|
+
onClick={onToggle}
|
|
108
|
+
aria-expanded={isOpen}
|
|
109
|
+
title={isOpen ? "Hide detail" : "Show what it's doing"}
|
|
110
|
+
>
|
|
111
|
+
<span className="vi-timeline-caret" aria-hidden>
|
|
112
|
+
{isOpen ? "▾" : "▸"}
|
|
113
|
+
</span>
|
|
114
|
+
</button>
|
|
115
|
+
<PhaseChip job={j} />
|
|
116
|
+
<span className="vi-studio-active-id">
|
|
117
|
+
{j.name || "job"} · {shortId(j.id)}
|
|
118
|
+
</span>
|
|
119
|
+
</div>
|
|
120
|
+
<div className="vi-studio-active-meta">
|
|
121
|
+
{line ? (
|
|
122
|
+
<span
|
|
123
|
+
className={`vi-placement${external ? " vi-placement-external" : ""}`}
|
|
124
|
+
title={external ? "Runs off the central box" : undefined}
|
|
125
|
+
>
|
|
126
|
+
{external ? "⇄ " : ""}
|
|
127
|
+
{line}
|
|
128
|
+
{external ? " · external" : ""}
|
|
129
|
+
</span>
|
|
130
|
+
) : null}
|
|
131
|
+
{gib ? <span>· {gib}</span> : null}
|
|
132
|
+
{j.stage ? <span>· {j.stage}</span> : null}
|
|
133
|
+
<span>· {elapsedSince(j.createdAt)}</span>
|
|
134
|
+
</div>
|
|
135
|
+
<ProgressBar job={j} />
|
|
136
|
+
{event ? <p className="vi-job-latest">{event}</p> : null}
|
|
137
|
+
{isOpen ? (
|
|
138
|
+
<div className="vi-timeline-panel">
|
|
139
|
+
{/* The SAME renderer the studio tab uses: the failure envelope
|
|
140
|
+
verbatim (code · stage · message), the live "now" line, and
|
|
141
|
+
every stage the render moved through. A live row carries a
|
|
142
|
+
tail; a terminal one carries the whole sequence. */}
|
|
143
|
+
<ProcessTimeline
|
|
144
|
+
stageLog={j.stageLog}
|
|
145
|
+
failure={j.failure}
|
|
146
|
+
currentStage={j.stage}
|
|
147
|
+
lastMovementTs={j.progressedAt}
|
|
148
|
+
terminal={j.terminal}
|
|
149
|
+
nowMs={nowMs}
|
|
150
|
+
/>
|
|
151
|
+
{elided > 0 ? (
|
|
152
|
+
<p className="vi-knob-hint">
|
|
153
|
+
…{elided} earlier event{elided === 1 ? "" : "s"} — open the job for
|
|
154
|
+
its full history.
|
|
155
|
+
</p>
|
|
156
|
+
) : null}
|
|
157
|
+
</div>
|
|
158
|
+
) : null}
|
|
159
|
+
{j.terminal ? null : (
|
|
160
|
+
<button
|
|
161
|
+
type="button"
|
|
162
|
+
className="vi-btn vi-btn-sm vi-btn-ghost"
|
|
163
|
+
onClick={onCancel}
|
|
164
|
+
disabled={cancelBusy}
|
|
165
|
+
>
|
|
166
|
+
{cancelBusy ? "Cancelling…" : "✕ Cancel"}
|
|
167
|
+
</button>
|
|
168
|
+
)}
|
|
169
|
+
</>
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// `spec` is optional because this body has TWO mounts: the registry's /active
|
|
174
|
+
// station (which always passes one) and the sidebar's ActivePanel overlay
|
|
175
|
+
// (2026-08-06), which mounts it inside the shared drawer shell and has no spec
|
|
176
|
+
// to hand it. The prop was already unused here, so optional costs nothing and
|
|
177
|
+
// keeps ONE renderer for the feed instead of an overlay-only copy.
|
|
178
|
+
export function ActiveProcessesStation({ spec: _spec }: { spec?: StationSpec } = {}) {
|
|
179
|
+
const [showFinished, setShowFinished] = useState(false);
|
|
180
|
+
const { jobs, refetch } = useMediaJobs(showFinished);
|
|
181
|
+
// Expanded rows + a 1s clock, exactly as the studio tab's sibling panel does —
|
|
182
|
+
// ProcessTimeline needs `nowMs` to age its "…since last movement" line.
|
|
183
|
+
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
|
184
|
+
const [nowMs, setNowMs] = useState(() => Date.now());
|
|
185
|
+
useEffect(() => {
|
|
186
|
+
const id = window.setInterval(() => setNowMs(Date.now()), 1000);
|
|
187
|
+
return () => window.clearInterval(id);
|
|
188
|
+
}, []);
|
|
189
|
+
const toggle = useCallback((id: string) => {
|
|
190
|
+
setExpanded((prev) => {
|
|
191
|
+
const n = new Set(prev);
|
|
192
|
+
if (n.has(id)) n.delete(id);
|
|
193
|
+
else n.add(id);
|
|
194
|
+
return n;
|
|
195
|
+
});
|
|
196
|
+
}, []);
|
|
197
|
+
const [cancelBusy, setCancelBusy] = useState<Set<string>>(new Set());
|
|
198
|
+
const mounted = useRef(true);
|
|
199
|
+
|
|
200
|
+
const onCancel = useCallback(
|
|
201
|
+
async (jobId: string) => {
|
|
202
|
+
setCancelBusy((prev) => new Set(prev).add(jobId));
|
|
203
|
+
try {
|
|
204
|
+
await request<unknown>(jobCancelUrl(jobId), {
|
|
205
|
+
method: "POST",
|
|
206
|
+
meta: { specKey: "media", operation: "media.job.cancel" },
|
|
207
|
+
});
|
|
208
|
+
if (!mounted.current) return;
|
|
209
|
+
refetch();
|
|
210
|
+
} finally {
|
|
211
|
+
if (mounted.current)
|
|
212
|
+
setCancelBusy((prev) => {
|
|
213
|
+
const n = new Set(prev);
|
|
214
|
+
n.delete(jobId);
|
|
215
|
+
return n;
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
},
|
|
219
|
+
[refetch],
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
return (
|
|
223
|
+
<section className="station-card station-wide vi-studio-active">
|
|
224
|
+
<h2>Active Processes</h2>
|
|
225
|
+
<p className="vi-knob-hint">
|
|
226
|
+
Every in-flight media build across the console, and WHERE it physically runs.
|
|
227
|
+
A build marked <strong>external</strong> is executing off this box — on the{" "}
|
|
228
|
+
ae worker, ComfyUI, or the identity-render service.
|
|
229
|
+
</p>
|
|
230
|
+
<label className="vi-knob-hint vi-job-filter">
|
|
231
|
+
<input
|
|
232
|
+
type="checkbox"
|
|
233
|
+
checked={showFinished}
|
|
234
|
+
onChange={(e) => setShowFinished(e.target.checked)}
|
|
235
|
+
/>{" "}
|
|
236
|
+
Show finished — recent done/failed/cancelled builds, with each failure's
|
|
237
|
+
full stage timeline and its exact error.
|
|
238
|
+
</label>
|
|
239
|
+
{jobs.length === 0 ? (
|
|
240
|
+
<p className="vi-knob-hint">
|
|
241
|
+
No active builds — enqueue a render and it appears here with its placement.
|
|
242
|
+
</p>
|
|
243
|
+
) : (
|
|
244
|
+
<ul className="vi-studio-active-list">
|
|
245
|
+
{jobs.map((j) => (
|
|
246
|
+
<li key={j.id} className="vi-studio-active-row">
|
|
247
|
+
<JobRow
|
|
248
|
+
job={j}
|
|
249
|
+
expanded={expanded.has(j.id)}
|
|
250
|
+
onToggle={() => toggle(j.id)}
|
|
251
|
+
nowMs={nowMs}
|
|
252
|
+
cancelBusy={cancelBusy.has(j.id)}
|
|
253
|
+
onCancel={() => void onCancel(j.id)}
|
|
254
|
+
/>
|
|
255
|
+
</li>
|
|
256
|
+
))}
|
|
257
|
+
</ul>
|
|
258
|
+
)}
|
|
259
|
+
{/* SESSIONS (operator ask 2026-08-12): the recovery surface lives HERE now —
|
|
260
|
+
relocated from the Cinema composer so "what can I pick back up" is visible
|
|
261
|
+
from every station, split per generate surface. It reuses THIS station's
|
|
262
|
+
feed + row renderer; only the Cinema section owns an extra poll (the
|
|
263
|
+
durable sessions walk), and only while it is open. */}
|
|
264
|
+
<SessionsPanel
|
|
265
|
+
jobs={jobs}
|
|
266
|
+
nowMs={nowMs}
|
|
267
|
+
cancelBusy={cancelBusy}
|
|
268
|
+
onCancel={(id) => void onCancel(id)}
|
|
269
|
+
/>
|
|
270
|
+
</section>
|
|
271
|
+
);
|
|
272
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// ACTIVE PROCESSES — the console-wide, placement-aware view of every in-flight
|
|
2
|
+
// media-bus job (studio / movie / identity / generate / crop / extract), fed by
|
|
3
|
+
// GET /video/jobs. Promotes the studio-only StudioActiveProcesses pattern to the
|
|
4
|
+
// whole console and adds the two things that were invisible before:
|
|
5
|
+
//
|
|
6
|
+
// • WHERE a job physically executes — the `placement` line ("ae · cuda:0 ·
|
|
7
|
+
// P-studio"), with an "external" cue when it runs OFF the central box (an ae
|
|
8
|
+
// worker, ComfyUI, or the identity-render service on :9750), plus reserved
|
|
9
|
+
// VRAM when a GPU reservation is held.
|
|
10
|
+
// • The reservation HOLD phase — a job that can't fit the card yet sits as
|
|
11
|
+
// `awaiting_capacity` (with the shortfall reason on hover + the overtaken
|
|
12
|
+
// count), not a fake "running".
|
|
13
|
+
// • WHAT it is doing right now — a moving progress bar (segments done plus the
|
|
14
|
+
// within-segment denoise fraction the runner reports) and the TAIL of the job's
|
|
15
|
+
// stage timeline, so a running render shows its last few events instead of an
|
|
16
|
+
// opaque "running" chip.
|
|
17
|
+
// • WHY it broke — a terminal-failed job renders its failure envelope VERBATIM
|
|
18
|
+
// (code + message + the stage it died in), never a generic "failed". Terminal
|
|
19
|
+
// rows arrive only with the "Show finished" filter on.
|
|
20
|
+
//
|
|
21
|
+
// HONEST rendering only: no fabricated progress bars — a numeric progress/stage
|
|
22
|
+
// shows only when the server sends one. Cancel hits the same /video/jobs/<id>/
|
|
23
|
+
// cancel seam every station uses; a held job cancels exactly like any queued job.
|
|
24
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
25
|
+
import type { StationSpec } from "./types";
|
|
26
|
+
import { request } from "../transport/client";
|
|
27
|
+
import { jobCancelUrl } from "../config";
|
|
28
|
+
import { ProcessTimeline, shortId } from "./studio/studioShared";
|
|
29
|
+
import {
|
|
30
|
+
useMediaJobs,
|
|
31
|
+
placementLine,
|
|
32
|
+
isExternal,
|
|
33
|
+
reservedGib,
|
|
34
|
+
elapsedSince,
|
|
35
|
+
progressLabel,
|
|
36
|
+
latestEvent,
|
|
37
|
+
type MediaJob,
|
|
38
|
+
} from "../video/useMediaJobs";
|
|
39
|
+
|
|
40
|
+
function PhaseChip({ job }: { job: MediaJob }) {
|
|
41
|
+
if (job.phase === "awaiting_capacity") {
|
|
42
|
+
const reason = job.hold?.reason ? JSON.stringify(job.hold.reason) : "";
|
|
43
|
+
const overtaken = job.hold?.overtaken ?? 0;
|
|
44
|
+
return (
|
|
45
|
+
<span
|
|
46
|
+
className="vi-status vi-status-awaiting_capacity"
|
|
47
|
+
title={
|
|
48
|
+
reason
|
|
49
|
+
? `Held — waiting for GPU capacity. Shortfall: ${reason}`
|
|
50
|
+
: "Held — waiting for GPU capacity"
|
|
51
|
+
}
|
|
52
|
+
>
|
|
53
|
+
awaiting capacity{overtaken > 0 ? ` · jumped ${overtaken}×` : ""}
|
|
54
|
+
</span>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
return <span className={`vi-status vi-status-${job.phase}`}>{job.phase}</span>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The bar + its honest label. Renders nothing when the server sent no number. */
|
|
61
|
+
function ProgressBar({ job }: { job: MediaJob }) {
|
|
62
|
+
if (job.progress == null) return null;
|
|
63
|
+
const pct = Math.max(0, Math.min(100, job.progress * 100));
|
|
64
|
+
return (
|
|
65
|
+
<div className="vi-job-progress">
|
|
66
|
+
<div className="vi-job-progress-track">
|
|
67
|
+
<div className="vi-job-progress-fill" style={{ width: `${pct}%` }} />
|
|
68
|
+
</div>
|
|
69
|
+
<span className="vi-job-progress-label">{progressLabel(job)}</span>
|
|
70
|
+
</div>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// `spec` is optional because this body has TWO mounts: the registry's /active
|
|
75
|
+
// station (which always passes one) and the sidebar's ActivePanel overlay
|
|
76
|
+
// (2026-08-06), which mounts it inside the shared drawer shell and has no spec
|
|
77
|
+
// to hand it. The prop was already unused here, so optional costs nothing and
|
|
78
|
+
// keeps ONE renderer for the feed instead of an overlay-only copy.
|
|
79
|
+
export function ActiveProcessesStation({ spec: _spec }: { spec?: StationSpec } = {}) {
|
|
80
|
+
const [showFinished, setShowFinished] = useState(false);
|
|
81
|
+
const { jobs, refetch } = useMediaJobs(showFinished);
|
|
82
|
+
// Expanded rows + a 1s clock, exactly as the studio tab's sibling panel does —
|
|
83
|
+
// ProcessTimeline needs `nowMs` to age its "…since last movement" line.
|
|
84
|
+
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
|
85
|
+
const [nowMs, setNowMs] = useState(() => Date.now());
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
const id = window.setInterval(() => setNowMs(Date.now()), 1000);
|
|
88
|
+
return () => window.clearInterval(id);
|
|
89
|
+
}, []);
|
|
90
|
+
const toggle = useCallback((id: string) => {
|
|
91
|
+
setExpanded((prev) => {
|
|
92
|
+
const n = new Set(prev);
|
|
93
|
+
if (n.has(id)) n.delete(id);
|
|
94
|
+
else n.add(id);
|
|
95
|
+
return n;
|
|
96
|
+
});
|
|
97
|
+
}, []);
|
|
98
|
+
const [cancelBusy, setCancelBusy] = useState<Set<string>>(new Set());
|
|
99
|
+
const mounted = useRef(true);
|
|
100
|
+
|
|
101
|
+
const onCancel = useCallback(
|
|
102
|
+
async (jobId: string) => {
|
|
103
|
+
setCancelBusy((prev) => new Set(prev).add(jobId));
|
|
104
|
+
try {
|
|
105
|
+
await request<unknown>(jobCancelUrl(jobId), {
|
|
106
|
+
method: "POST",
|
|
107
|
+
meta: { specKey: "media", operation: "media.job.cancel" },
|
|
108
|
+
});
|
|
109
|
+
if (!mounted.current) return;
|
|
110
|
+
refetch();
|
|
111
|
+
} finally {
|
|
112
|
+
if (mounted.current)
|
|
113
|
+
setCancelBusy((prev) => {
|
|
114
|
+
const n = new Set(prev);
|
|
115
|
+
n.delete(jobId);
|
|
116
|
+
return n;
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
[refetch],
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<section className="station-card station-wide vi-studio-active">
|
|
125
|
+
<h2>Active Processes</h2>
|
|
126
|
+
<p className="vi-knob-hint">
|
|
127
|
+
Every in-flight media build across the console, and WHERE it physically runs.
|
|
128
|
+
A build marked <strong>external</strong> is executing off this box — on the{" "}
|
|
129
|
+
ae worker, ComfyUI, or the identity-render service.
|
|
130
|
+
</p>
|
|
131
|
+
<label className="vi-knob-hint vi-job-filter">
|
|
132
|
+
<input
|
|
133
|
+
type="checkbox"
|
|
134
|
+
checked={showFinished}
|
|
135
|
+
onChange={(e) => setShowFinished(e.target.checked)}
|
|
136
|
+
/>{" "}
|
|
137
|
+
Show finished — recent done/failed/cancelled builds, with each failure's
|
|
138
|
+
full stage timeline and its exact error.
|
|
139
|
+
</label>
|
|
140
|
+
{jobs.length === 0 ? (
|
|
141
|
+
<p className="vi-knob-hint">
|
|
142
|
+
No active builds — enqueue a render and it appears here with its placement.
|
|
143
|
+
</p>
|
|
144
|
+
) : (
|
|
145
|
+
<ul className="vi-studio-active-list">
|
|
146
|
+
{jobs.map((j) => {
|
|
147
|
+
const line = placementLine(j.placement);
|
|
148
|
+
const gib = reservedGib(j.placement);
|
|
149
|
+
const external = isExternal(j.placement);
|
|
150
|
+
const isOpen = expanded.has(j.id);
|
|
151
|
+
const elided = j.stageLogTotal - j.stageLog.length;
|
|
152
|
+
const event = latestEvent(j);
|
|
153
|
+
return (
|
|
154
|
+
<li key={j.id} className="vi-studio-active-row">
|
|
155
|
+
<div className="vi-studio-active-head">
|
|
156
|
+
<button
|
|
157
|
+
type="button"
|
|
158
|
+
className="vi-timeline-toggle"
|
|
159
|
+
onClick={() => toggle(j.id)}
|
|
160
|
+
aria-expanded={isOpen}
|
|
161
|
+
title={isOpen ? "Hide detail" : "Show what it's doing"}
|
|
162
|
+
>
|
|
163
|
+
<span className="vi-timeline-caret" aria-hidden>
|
|
164
|
+
{isOpen ? "▾" : "▸"}
|
|
165
|
+
</span>
|
|
166
|
+
</button>
|
|
167
|
+
<PhaseChip job={j} />
|
|
168
|
+
<span className="vi-studio-active-id">
|
|
169
|
+
{j.name || "job"} · {shortId(j.id)}
|
|
170
|
+
</span>
|
|
171
|
+
</div>
|
|
172
|
+
<div className="vi-studio-active-meta">
|
|
173
|
+
{line ? (
|
|
174
|
+
<span
|
|
175
|
+
className={`vi-placement${external ? " vi-placement-external" : ""}`}
|
|
176
|
+
title={external ? "Runs off the central box" : undefined}
|
|
177
|
+
>
|
|
178
|
+
{external ? "⇄ " : ""}
|
|
179
|
+
{line}
|
|
180
|
+
{external ? " · external" : ""}
|
|
181
|
+
</span>
|
|
182
|
+
) : null}
|
|
183
|
+
{gib ? <span>· {gib}</span> : null}
|
|
184
|
+
{j.stage ? <span>· {j.stage}</span> : null}
|
|
185
|
+
<span>· {elapsedSince(j.createdAt)}</span>
|
|
186
|
+
</div>
|
|
187
|
+
<ProgressBar job={j} />
|
|
188
|
+
{event ? <p className="vi-job-latest">{event}</p> : null}
|
|
189
|
+
{isOpen ? (
|
|
190
|
+
<div className="vi-timeline-panel">
|
|
191
|
+
{/* The SAME renderer the studio tab uses: the failure envelope
|
|
192
|
+
verbatim (code · stage · message), the live "now" line, and
|
|
193
|
+
every stage the render moved through. A live row carries a
|
|
194
|
+
tail; a terminal one carries the whole sequence. */}
|
|
195
|
+
<ProcessTimeline
|
|
196
|
+
stageLog={j.stageLog}
|
|
197
|
+
failure={j.failure}
|
|
198
|
+
currentStage={j.stage}
|
|
199
|
+
lastMovementTs={j.progressedAt}
|
|
200
|
+
terminal={j.terminal}
|
|
201
|
+
nowMs={nowMs}
|
|
202
|
+
/>
|
|
203
|
+
{elided > 0 ? (
|
|
204
|
+
<p className="vi-knob-hint">
|
|
205
|
+
…{elided} earlier event{elided === 1 ? "" : "s"} — open the
|
|
206
|
+
job for its full history.
|
|
207
|
+
</p>
|
|
208
|
+
) : null}
|
|
209
|
+
</div>
|
|
210
|
+
) : null}
|
|
211
|
+
{j.terminal ? null : (
|
|
212
|
+
<button
|
|
213
|
+
type="button"
|
|
214
|
+
className="vi-btn vi-btn-sm vi-btn-ghost"
|
|
215
|
+
onClick={() => void onCancel(j.id)}
|
|
216
|
+
disabled={cancelBusy.has(j.id)}
|
|
217
|
+
>
|
|
218
|
+
{cancelBusy.has(j.id) ? "Cancelling…" : "✕ Cancel"}
|
|
219
|
+
</button>
|
|
220
|
+
)}
|
|
221
|
+
</li>
|
|
222
|
+
);
|
|
223
|
+
})}
|
|
224
|
+
</ul>
|
|
225
|
+
)}
|
|
226
|
+
</section>
|
|
227
|
+
);
|
|
228
|
+
}
|