Make queue UI optional and fix idle summary

This commit is contained in:
Meta Vens 2026-04-26 04:23:37 +01:00
commit 7206b454db
7 changed files with 165 additions and 64 deletions

View file

@ -56,6 +56,7 @@ type RuntimeConfig = {
ui: {
showDeviceControl: boolean;
showStripBackgroundControl: boolean;
showQueuePanel: boolean;
};
};
@ -213,6 +214,7 @@ const phaseYInput = byId<HTMLInputElement>("phaseYInput");
const stepsInput = byId<HTMLInputElement>("stepsInput");
const seedInput = byId<HTMLInputElement>("seedInput");
const runControlsCopy = byId<HTMLParagraphElement>("runControlsCopy");
const queuePanel = byId<HTMLElement>("queuePanel");
const queueSummaryPanel = byId<HTMLDivElement>("queueSummary");
const cancelJobButton = byId<HTMLButtonElement>("cancelJobButton");
const deviceField = byId<HTMLElement>("deviceField");
@ -361,6 +363,11 @@ function renderEventLog(): void {
}
function renderQueueSummary(): void {
if (!state.runtimeConfig?.ui.showQueuePanel) {
queueSummaryPanel.innerHTML = "";
cancelJobButton.hidden = true;
return;
}
queueSummaryPanel.innerHTML = "";
if (hasActiveJob()) {
const cards: Array<[string, string]> = [
@ -436,6 +443,16 @@ function hasActiveJob(): boolean {
return state.jobId !== null && ["queued", "running", "waiting"].includes(state.status);
}
function queueSummaryFromState(activeStatus: string | null = state.status): QueueSummary {
return {
queueDepth: state.queueDepth,
waitingCount: state.waitingCount,
queueCapacity: state.queueCapacity || (state.runtimeConfig?.limits.queueCapacity ?? 0),
hasActiveJob: state.queueDepth > 0,
activeStatus,
};
}
function formatByteLimit(bytes: number): string {
if (bytes >= 1024 * 1024) {
return `${formatNumber(bytes / (1024 * 1024), 1)} MiB`;
@ -473,6 +490,7 @@ function applyRuntimeConfig(config: RuntimeConfig): void {
state.runtimeConfig = config;
deviceField.hidden = !config.ui.showDeviceControl;
stripBackgroundField.hidden = !config.ui.showStripBackgroundControl;
queuePanel.hidden = !config.ui.showQueuePanel;
stepsInput.max = String(config.limits.maxSteps);
if (stepsInput.value.trim() === "" || Number(stepsInput.value) > config.limits.maxSteps || Number(stepsInput.value) === 48) {
stepsInput.value = String(config.limits.defaultSteps);
@ -747,15 +765,20 @@ function renderStatusMetrics(): void {
const items: StatusMetricItem[] = [];
const frame = getSelectedFrame();
if (state.stageKey === "queued" || state.status === "queued" || state.status === "waiting") {
items.push({
label: "Line",
value:
state.queuePosition === null || state.queueDepth <= 0
? "pending"
: `${state.queuePosition} / ${Math.max(state.queueDepth, state.queuePosition)}`,
});
items.push({ label: "Waiting", value: `${state.waitingCount} / ${state.queueCapacity || (state.runtimeConfig?.limits.queueCapacity ?? 0)}` });
items.push({ label: "Heartbeat", value: `${state.heartbeatIntervalSeconds}s` });
if (state.runtimeConfig?.ui.showQueuePanel) {
items.push({
label: "Line",
value:
state.queuePosition === null || state.queueDepth <= 0
? "pending"
: `${state.queuePosition} / ${Math.max(state.queueDepth, state.queuePosition)}`,
});
items.push({
label: "Waiting",
value: `${state.waitingCount} / ${state.queueCapacity || (state.runtimeConfig?.limits.queueCapacity ?? 0)}`,
});
items.push({ label: "Heartbeat", value: `${state.heartbeatIntervalSeconds}s` });
}
} else if (state.stageKey === "inference") {
if (state.latticeSearch) {
items.push({ label: "Sizes", value: `${state.latticeSearch.completedCandidates} / ${state.latticeSearch.candidateCount}` });
@ -1385,6 +1408,11 @@ async function loadRuntimeConfig(): Promise<void> {
}
async function refreshQueueSummary(): Promise<void> {
if (!state.runtimeConfig?.ui.showQueuePanel) {
state.queueSummary = null;
renderQueueSummary();
return;
}
if (hasActiveJob()) {
return;
}
@ -1404,6 +1432,10 @@ function stopQueuePolling(): void {
}
function syncQueuePolling(): void {
if (!state.runtimeConfig?.ui.showQueuePanel) {
stopQueuePolling();
return;
}
if (hasActiveJob()) {
stopQueuePolling();
return;
@ -1555,13 +1587,7 @@ async function handleEvent(eventName: string, payload: JsonRecord): Promise<void
break;
case "queue_state":
applyQueueState(payload as Partial<JobStateSnapshot>);
state.queueSummary = {
queueDepth: state.queueDepth,
waitingCount: state.waitingCount,
queueCapacity: state.queueCapacity,
hasActiveJob: state.queueDepth > 0,
activeStatus: state.status,
};
state.queueSummary = queueSummaryFromState();
break;
case "job_canceled":
stopHeartbeat();
@ -1570,8 +1596,8 @@ async function handleEvent(eventName: string, payload: JsonRecord): Promise<void
addLog("Canceled", String(payload.message ?? "Canceled the job."));
runButton.disabled = false;
currentEventSource?.close();
await refreshQueueSummary().catch(() => undefined);
syncQueuePolling();
void refreshQueueSummary().catch(() => undefined);
break;
case "job_failed":
stopHeartbeat();
@ -1580,8 +1606,8 @@ async function handleEvent(eventName: string, payload: JsonRecord): Promise<void
addLog("Failure", String(payload.message ?? "The run failed."));
runButton.disabled = false;
currentEventSource?.close();
await refreshQueueSummary().catch(() => undefined);
syncQueuePolling();
void refreshQueueSummary().catch(() => undefined);
break;
case "stage_started":
setJobState("running");
@ -1795,8 +1821,8 @@ async function handleEvent(eventName: string, payload: JsonRecord): Promise<void
runButton.disabled = false;
currentEventSource?.close();
await loadEditorAsset(state.finalOutputImage);
await refreshQueueSummary().catch(() => undefined);
syncQueuePolling();
void refreshQueueSummary().catch(() => undefined);
break;
default:
break;
@ -1852,13 +1878,7 @@ async function startRun(): Promise<void> {
const payload = (await response.json()) as JobStateSnapshot;
state.jobId = payload.jobId;
applyJobSnapshot(payload);
state.queueSummary = {
queueDepth: payload.queueDepth,
waitingCount: payload.waitingCount,
queueCapacity: payload.queueCapacity,
hasActiveJob: payload.queueDepth > 0,
activeStatus: payload.status,
};
state.queueSummary = queueSummaryFromState(payload.status);
connectEventStream(payload.jobId);
syncHeartbeat();
syncQueuePolling();
@ -2133,10 +2153,12 @@ async function initializeApp(): Promise<void> {
} catch (error) {
addLog("Config", error instanceof Error ? error.message : "Failed to load runtime config.");
}
try {
await refreshQueueSummary();
} catch (error) {
addLog("Queue", error instanceof Error ? error.message : "Failed to load queue summary.");
if (state.runtimeConfig?.ui.showQueuePanel) {
try {
await refreshQueueSummary();
} catch (error) {
addLog("Queue", error instanceof Error ? error.message : "Failed to load queue summary.");
}
}
syncQueuePolling();
await renderEverything();

View file

@ -1,7 +1,9 @@
param(
[string]$BindHost = "127.0.0.1",
[int]$Port = 8000,
[switch]$Reload
[switch]$Reload,
[ValidateSet("auto", "show", "hide")]
[string]$QueueUI = "auto"
)
$ErrorActionPreference = "Stop"
@ -15,7 +17,8 @@ if (-not (Test-Path $python)) {
$args = @(
(Join-Path $repoRoot "scripts\run_gui.py"),
"--host", $BindHost,
"--port", $Port
"--port", $Port,
"--queue-ui", $QueueUI
)
if ($Reload) {

View file

@ -164,15 +164,28 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument("--host", default="127.0.0.1", help="Host interface to bind")
parser.add_argument("--port", type=int, default=8000, help="Port to bind")
parser.add_argument("--reload", action="store_true", help="Enable autoreload while developing the GUI server")
parser.add_argument(
"--queue-ui",
choices=("auto", "show", "hide"),
default="auto",
help="Whether to show the queue panel in the frontend. auto follows hosted-demo mode.",
)
return parser
def main(argv: list[str] | None = None) -> int:
repo_root = _repo_root()
_ensure_src_on_path()
from repixelizer.gui import main as gui_main
args = build_parser().parse_args(argv)
if args.queue_ui == "show":
os.environ["REPIXELIZER_SHOW_QUEUE_PANEL"] = "1"
elif args.queue_ui == "hide":
os.environ["REPIXELIZER_SHOW_QUEUE_PANEL"] = "0"
else:
os.environ.pop("REPIXELIZER_SHOW_QUEUE_PANEL", None)
from repixelizer.gui import main as gui_main
try:
message = _reclaim_stale_gui_port(args.host, args.port, repo_root)
except RuntimeError as exc:

View file

@ -148,6 +148,7 @@ def _env_int(name: str, default: int) -> int:
@dataclass(slots=True)
class HostedDemoConfig:
hosted_demo: bool
show_queue_panel: bool
max_upload_bytes: int
max_input_dimension: int
max_output_dimension: int
@ -180,6 +181,7 @@ class HostedDemoConfig:
spool_dir = Path(tempfile.gettempdir()) / "repixelizer-gui-spool"
return cls(
hosted_demo=hosted_demo,
show_queue_panel=_env_flag("REPIXELIZER_SHOW_QUEUE_PANEL", hosted_demo),
max_upload_bytes=max(1, _env_int("REPIXELIZER_MAX_UPLOAD_BYTES", defaults["max_upload_bytes"])),
max_input_dimension=max(1, _env_int("REPIXELIZER_MAX_INPUT_DIMENSION", defaults["max_input_dimension"])),
max_output_dimension=max(1, _env_int("REPIXELIZER_MAX_OUTPUT_DIMENSION", defaults["max_output_dimension"])),
@ -200,6 +202,7 @@ class HostedDemoConfig:
return {
"showDeviceControl": not self.hosted_demo,
"showStripBackgroundControl": not self.hosted_demo,
"showQueuePanel": self.show_queue_panel,
}
def public_payload(self) -> dict[str, Any]:
@ -962,6 +965,7 @@ def create_app():
"stateUrl": f"/api/jobs/{job.job_id}",
"queuePosition": state_payload["queuePosition"],
"queueDepth": state_payload["queueDepth"],
"waitingCount": state_payload["waitingCount"],
"queueCapacity": state_payload["queueCapacity"],
"heartbeatIntervalSeconds": state_payload["heartbeatIntervalSeconds"],
"staleAfterSeconds": state_payload["staleAfterSeconds"],

View file

@ -45,6 +45,7 @@ const phaseYInput = byId("phaseYInput");
const stepsInput = byId("stepsInput");
const seedInput = byId("seedInput");
const runControlsCopy = byId("runControlsCopy");
const queuePanel = byId("queuePanel");
const queueSummaryPanel = byId("queueSummary");
const cancelJobButton = byId("cancelJobButton");
const deviceField = byId("deviceField");
@ -182,6 +183,11 @@ function renderEventLog() {
}
}
function renderQueueSummary() {
if (!state.runtimeConfig?.ui.showQueuePanel) {
queueSummaryPanel.innerHTML = "";
cancelJobButton.hidden = true;
return;
}
queueSummaryPanel.innerHTML = "";
if (hasActiveJob()) {
const cards = [
@ -251,6 +257,15 @@ function setStage(stageKey, label, detail) {
function hasActiveJob() {
return state.jobId !== null && ["queued", "running", "waiting"].includes(state.status);
}
function queueSummaryFromState(activeStatus = state.status) {
return {
queueDepth: state.queueDepth,
waitingCount: state.waitingCount,
queueCapacity: state.queueCapacity || (state.runtimeConfig?.limits.queueCapacity ?? 0),
hasActiveJob: state.queueDepth > 0,
activeStatus,
};
}
function formatByteLimit(bytes) {
if (bytes >= 1024 * 1024) {
return `${formatNumber(bytes / (1024 * 1024), 1)} MiB`;
@ -287,6 +302,7 @@ function applyRuntimeConfig(config) {
state.runtimeConfig = config;
deviceField.hidden = !config.ui.showDeviceControl;
stripBackgroundField.hidden = !config.ui.showStripBackgroundControl;
queuePanel.hidden = !config.ui.showQueuePanel;
stepsInput.max = String(config.limits.maxSteps);
if (stepsInput.value.trim() === "" || Number(stepsInput.value) > config.limits.maxSteps || Number(stepsInput.value) === 48) {
stepsInput.value = String(config.limits.defaultSteps);
@ -526,14 +542,19 @@ function renderStatusMetrics() {
const items = [];
const frame = getSelectedFrame();
if (state.stageKey === "queued" || state.status === "queued" || state.status === "waiting") {
items.push({
label: "Line",
value: state.queuePosition === null || state.queueDepth <= 0
? "pending"
: `${state.queuePosition} / ${Math.max(state.queueDepth, state.queuePosition)}`,
});
items.push({ label: "Waiting", value: `${state.waitingCount} / ${state.queueCapacity || (state.runtimeConfig?.limits.queueCapacity ?? 0)}` });
items.push({ label: "Heartbeat", value: `${state.heartbeatIntervalSeconds}s` });
if (state.runtimeConfig?.ui.showQueuePanel) {
items.push({
label: "Line",
value: state.queuePosition === null || state.queueDepth <= 0
? "pending"
: `${state.queuePosition} / ${Math.max(state.queueDepth, state.queuePosition)}`,
});
items.push({
label: "Waiting",
value: `${state.waitingCount} / ${state.queueCapacity || (state.runtimeConfig?.limits.queueCapacity ?? 0)}`,
});
items.push({ label: "Heartbeat", value: `${state.heartbeatIntervalSeconds}s` });
}
}
else if (state.stageKey === "inference") {
if (state.latticeSearch) {
@ -1120,6 +1141,11 @@ async function loadRuntimeConfig() {
applyRuntimeConfig((await response.json()));
}
async function refreshQueueSummary() {
if (!state.runtimeConfig?.ui.showQueuePanel) {
state.queueSummary = null;
renderQueueSummary();
return;
}
if (hasActiveJob()) {
return;
}
@ -1137,6 +1163,10 @@ function stopQueuePolling() {
}
}
function syncQueuePolling() {
if (!state.runtimeConfig?.ui.showQueuePanel) {
stopQueuePolling();
return;
}
if (hasActiveJob()) {
stopQueuePolling();
return;
@ -1280,13 +1310,7 @@ async function handleEvent(eventName, payload) {
break;
case "queue_state":
applyQueueState(payload);
state.queueSummary = {
queueDepth: state.queueDepth,
waitingCount: state.waitingCount,
queueCapacity: state.queueCapacity,
hasActiveJob: state.queueDepth > 0,
activeStatus: state.status,
};
state.queueSummary = queueSummaryFromState();
break;
case "job_canceled":
stopHeartbeat();
@ -1295,8 +1319,8 @@ async function handleEvent(eventName, payload) {
addLog("Canceled", String(payload.message ?? "Canceled the job."));
runButton.disabled = false;
currentEventSource?.close();
await refreshQueueSummary().catch(() => undefined);
syncQueuePolling();
void refreshQueueSummary().catch(() => undefined);
break;
case "job_failed":
stopHeartbeat();
@ -1305,8 +1329,8 @@ async function handleEvent(eventName, payload) {
addLog("Failure", String(payload.message ?? "The run failed."));
runButton.disabled = false;
currentEventSource?.close();
await refreshQueueSummary().catch(() => undefined);
syncQueuePolling();
void refreshQueueSummary().catch(() => undefined);
break;
case "stage_started":
setJobState("running");
@ -1482,8 +1506,8 @@ async function handleEvent(eventName, payload) {
runButton.disabled = false;
currentEventSource?.close();
await loadEditorAsset(state.finalOutputImage);
await refreshQueueSummary().catch(() => undefined);
syncQueuePolling();
void refreshQueueSummary().catch(() => undefined);
break;
default:
break;
@ -1537,13 +1561,7 @@ async function startRun() {
const payload = (await response.json());
state.jobId = payload.jobId;
applyJobSnapshot(payload);
state.queueSummary = {
queueDepth: payload.queueDepth,
waitingCount: payload.waitingCount,
queueCapacity: payload.queueCapacity,
hasActiveJob: payload.queueDepth > 0,
activeStatus: payload.status,
};
state.queueSummary = queueSummaryFromState(payload.status);
connectEventStream(payload.jobId);
syncHeartbeat();
syncQueuePolling();
@ -1784,11 +1802,13 @@ async function initializeApp() {
catch (error) {
addLog("Config", error instanceof Error ? error.message : "Failed to load runtime config.");
}
try {
await refreshQueueSummary();
}
catch (error) {
addLog("Queue", error instanceof Error ? error.message : "Failed to load queue summary.");
if (state.runtimeConfig?.ui.showQueuePanel) {
try {
await refreshQueueSummary();
}
catch (error) {
addLog("Queue", error instanceof Error ? error.message : "Failed to load queue summary.");
}
}
syncQueuePolling();
await renderEverything();

View file

@ -322,7 +322,7 @@
<button id="runButton" class="button" type="button">Run The Machine</button>
</section>
<section class="card sidebar-section ui-surface ui-frame">
<section id="queuePanel" class="card sidebar-section ui-surface ui-frame" hidden>
<div class="card-head">
<div class="copy-stack">
<h2>Queue</h2>

View file

@ -3,6 +3,7 @@ from __future__ import annotations
import importlib.util
import io
import asyncio
import os
import threading
import time
from pathlib import Path
@ -172,6 +173,25 @@ def test_repo_gui_runner_dispatches_to_gui_main(monkeypatch, capsys) -> None:
assert "Reload: on" in output
def test_repo_gui_runner_queue_ui_override_sets_env(monkeypatch) -> None:
repo_root = Path(__file__).resolve().parents[1]
script_path = repo_root / "scripts" / "run_gui.py"
spec = importlib.util.spec_from_file_location("repixelizer_run_gui_script", script_path)
assert spec is not None
assert spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
monkeypatch.delenv("REPIXELIZER_SHOW_QUEUE_PANEL", raising=False)
monkeypatch.setattr(module, "_reclaim_stale_gui_port", lambda host, port, repo_root: None)
monkeypatch.setattr("repixelizer.gui.main", lambda **_: 0)
exit_code = module.main(["--queue-ui", "show"])
assert exit_code == 0
assert os.environ["REPIXELIZER_SHOW_QUEUE_PANEL"] == "1"
def test_repo_gui_runner_reclaims_stale_gui_port_before_launch(monkeypatch, capsys) -> None:
repo_root = Path(__file__).resolve().parents[1]
script_path = repo_root / "scripts" / "run_gui.py"
@ -441,6 +461,25 @@ def test_gui_hosted_config_endpoint_exposes_demo_limits(monkeypatch, tmp_path: P
assert payload["limits"]["defaultSteps"] == 32
assert payload["ui"]["showDeviceControl"] is False
assert payload["ui"]["showStripBackgroundControl"] is False
assert payload["ui"]["showQueuePanel"] is True
def test_gui_queue_panel_defaults_off_for_local_runs_and_can_be_forced(monkeypatch, tmp_path: Path) -> None:
from repixelizer.gui import create_app
monkeypatch.delenv("REPIXELIZER_HOSTED_DEMO", raising=False)
monkeypatch.delenv("REPIXELIZER_SHOW_QUEUE_PANEL", raising=False)
monkeypatch.setenv("REPIXELIZER_SPOOL_DIR", str(tmp_path / "spool"))
app = create_app()
payload = _response_json(_route_endpoint(app, "/api/config", "GET")())
assert payload["hostedDemo"] is False
assert payload["ui"]["showQueuePanel"] is False
monkeypatch.setenv("REPIXELIZER_SHOW_QUEUE_PANEL", "1")
app = create_app()
payload = _response_json(_route_endpoint(app, "/api/config", "GET")())
assert payload["ui"]["showQueuePanel"] is True
def test_gui_queue_rejects_eleventh_waiting_job(monkeypatch, tmp_path: Path) -> None: