diff --git a/app/api/jobs.py b/app/api/jobs.py index 1e78141..cdc9440 100644 --- a/app/api/jobs.py +++ b/app/api/jobs.py @@ -424,6 +424,61 @@ def _beats_paths(job_id: str) -> tuple[Path, Path]: return stems / "beats.json", stems / "beats.user.json" +# Keys of error.txt that may be handed to the client. `title` and `source` are +# deliberately absent: this feeds a "report it on GitHub" flow whose issues are +# public, and the user adds what they were working on if they want to. The +# server is the right place to enforce that -- not the client that builds the +# report body. +_FAILURE_PUBLIC_KEYS = frozenset( + ("time", "stage", "device", "model", "cause", "timings", "exception") +) + + +@router.get("/{job_id}/failure") +def get_failure(job_id: str) -> dict: + """Return the quarantined failure evidence for a job that errored. + + _quarantine_failed_job writes jobs/failed//error.txt on every pipeline + failure (#277) and until now nothing ever read it back: the UI had only the + one-line `error_detail`, so a bug report could not carry the stderr tail + that says *why* demucs died. Read-only, and never serves the whole file -- + only the technical keys above, plus the tail. + """ + if not JOB_ID_RE.match(job_id): + raise HTTPException(status_code=404, detail="job not found") + + # JOB_ID_RE rejects "failed", so the quarantine dir can never be addressed + # as a job id; join it explicitly and re-verify the result stays inside. + failed_dir = (JOBS_DIR / "failed" / job_id).resolve() + if not failed_dir.is_relative_to((JOBS_DIR / "failed").resolve()): + raise HTTPException(status_code=404, detail="job not found") + path = failed_dir / "error.txt" + if not path.is_file(): + raise HTTPException(status_code=404, detail="no failure evidence for this job") + + try: + text = path.read_text(encoding="utf-8") + except OSError as exc: + logger.exception("unreadable failure evidence for %s", job_id) + raise HTTPException(status_code=404, detail="no failure evidence for this job") from exc + + fields: dict[str, str] = {} + tail: list[str] = [] + in_tail = False + for line in text.splitlines(): + if line.strip() == "--- stderr tail ---": + in_tail = True + continue + if in_tail: + tail.append(line) + continue + key, sep, value = line.partition(":") + if sep and key in _FAILURE_PUBLIC_KEYS: + fields[key] = value.strip() + + return {"job_id": job_id, **fields, "tail": tail} + + @router.get("/{job_id}/beats") def get_beats(job_id: str) -> Response: """Return the beat grid, preferring the user's edits over the detected one. diff --git a/app/main.py b/app/main.py index 38f4d5d..7e35b49 100644 --- a/app/main.py +++ b/app/main.py @@ -571,6 +571,12 @@ def get_logs_info() -> dict[str, object]: # otherwise make a busy log look empty. _LOG_VIEWS: dict[str, tuple[str, ...]] = { "application": ("stemdeck.log", "stemdeck.log.1"), + # The backend's raw stdout/stderr. Worth a view of its own because it holds + # what the application log cannot: anything the process printed before + # logging was configured, and anything that killed it before a handler ran. + # A backend that dies at startup leaves stemdeck.log empty and the answer + # here. + "backend": ("backend.log", "backend.log.1", "backend.log.2"), "setup": ("setup.log",), } # Bounds on what a single view returns. The application log rotates at 5 MB, so diff --git a/static/css/daw.css b/static/css/daw.css index 491e78b..ceddb87 100644 --- a/static/css/daw.css +++ b/static/css/daw.css @@ -331,6 +331,15 @@ input, textarea { font-family: inherit; } border-radius: 5px; } .daw-notif-close:hover { background: var(--panel-3); color: var(--fg); } +/* Sits between the label and the close button; the header is space-between, so + push it right and leave a gap before the X. */ +.daw-notif-clear { + margin-left: auto; margin-right: 8px; + background: none; border: none; padding: 0; + color: var(--muted); cursor: pointer; + font-family: inherit; font-size: 11px; +} +.daw-notif-clear:hover { color: var(--fg); text-decoration: underline; } .daw-notif-list { padding: 8px; display: flex; flex-direction: column; gap: 6px; } .daw-notif-card { display: flex; @@ -349,6 +358,15 @@ input, textarea { font-family: inherit; } border-radius: 6px; color: var(--accent); } +/* A failure, not an announcement. The amber tint lives in the base icon rule + rather than in .daw-notif-release, so both properties need overriding. */ +.daw-notif-error { border-color: rgba(214,90,74,0.35); cursor: pointer; } +.daw-notif-error:hover { background: var(--panel-2); } +.daw-notif-error .daw-notif-card-icon { + background: rgba(214,90,74,0.14); + color: var(--danger); +} +.daw-notif-error:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } .daw-notif-card-body { flex: 1; min-width: 0; } .daw-notif-card-title { font-size: 12px; font-weight: 600; color: var(--fg); } .daw-notif-card-desc { font-size: 11px; color: var(--muted); margin-top: 2px; } @@ -2740,6 +2758,50 @@ input, textarea { font-family: inherit; } /* The "New release available" card opens the dialog on click. */ .daw-notif-release { cursor: pointer; } +/* ── Failure dialog ── */ +/* Same shell as the release dialog, in the danger colour, with the technical + block the report will carry shown verbatim -- nothing is sent that the user + has not been able to read first. */ +.failure-card { + width: min(560px, calc(100vw - 32px)); + max-height: calc(100vh - 64px); + display: flex; + flex-direction: column; + align-items: center; +} +.failure-logo { color: var(--danger); } +.failure-when { color: var(--muted); } +.failure-message { + margin: 12px 0 0; + font-size: 13px; line-height: 1.5; color: var(--fg-2); + white-space: pre-line; text-align: center; +} +.failure-hint { + margin: 10px 0 0; + font-size: 11px; line-height: 1.5; color: var(--muted); + text-align: center; +} +.failure-tech { + width: 100%; flex: 1 1 auto; min-height: 0; + margin: 12px 0 0; padding: 10px 12px; + overflow: auto; text-align: left; + background: var(--bg); border: 1px solid var(--border); border-radius: 8px; +} +.failure-tech code { + font-family: var(--font-mono); font-size: 11px; line-height: 1.55; + color: var(--fg-2); white-space: pre-wrap; overflow-wrap: anywhere; +} +/* Reporting is the reason this dialog exists, so it gets the app's primary + button treatment (as Export Mix does) rather than the muted link style the + About/release dialogs use for their several equal-weight links. */ +.failure-card .about-link-primary { + background: var(--accent); border-color: transparent; color: #1a1206; +} +.failure-card .about-link-primary:hover { + background: color-mix(in srgb, var(--accent) 85%, white); + color: #1a1206; +} + /* ── Library sections (Recent · Stem Collections · Tags) ── */ .lib-section { display: flex; diff --git a/static/index.html b/static/index.html index b43b3bb..bf64997 100644 --- a/static/index.html +++ b/static/index.html @@ -109,6 +109,7 @@ + + + diff --git a/static/js/catalog.js b/static/js/catalog.js index 8f3828f..26f64ba 100644 --- a/static/js/catalog.js +++ b/static/js/catalog.js @@ -10,6 +10,7 @@ import { startQueueStream, } from "./queue.js"; import { fmtTime, storeGet, storeSet } from "./utils.js"; +import { notifyFailure, setReleasePending } from "./notifications.js"; // Escape user-supplied strings before inserting into innerHTML. function esc(s) { @@ -1548,6 +1549,26 @@ async function completeSettledJob(jobId) { render(); return; } + // A background job that failed used to say nothing at all: no banner (that + // belongs to the foreground import), no queue UI, just a console warning + // and a library row indistinguishable from a healthy one. Queue three + // tracks, lose one, never find out. It gets a notification like any other + // failure now. + if (state.status === "error") { + notifyFailure({ + kind: "import", + message: state.error || "Audio processing failed.", + detail: state.error_detail || null, + context: { + jobId, + stage: state.stage, + device: state.compute_device, + gpuFallback: state.gpu_fallback, + timings: state.stage_timings ? JSON.stringify(state.stage_timings) : null, + }, + }); + } + const track = stateMetadataToTrack(state, { ...existing, id: jobId }); track.id = jobId; track.channel = state.status === "done" ? "Extracted" : existing.channel; @@ -1993,15 +2014,31 @@ function setDisplayedVersion(version) { if (about) about.textContent = `v${currentVersion}`; } +// Kept from the health check so a bug report can state the running version, +// model and ffmpeg status without a second round trip. +let healthInfo = {}; + async function loadCurrentVersion() { try { const res = await fetch("/api/health", { cache: "no-store" }); if (!res.ok) return; const data = await res.json(); + healthInfo = data; setDisplayedVersion(data.version); } catch (e) { console.warn("[catalog] version fetch failed:", e); } } +/** Everything a bug report needs about this install. */ +export async function collectDiagnostics() { + return { + version: currentVersion, + model: healthInfo.demucs_model, + ffmpegConfigured: healthInfo.ffmpeg_configured, + buildTarget: await getBuildTarget(), + isDesktop: Boolean(window.__TAURI__?.core?.invoke), + }; +} + function escapeHtml(value) { return String(value == null ? "" : value) .replace(/&/g, "&") @@ -2111,7 +2148,7 @@ function renderReleaseNotes(markdown) { // Resolve the running build's OS/arch/GPU variant. On desktop this is exact // (Rust build_target); on web/server there is no reliable signal, so guess the // OS from the user agent and leave the variant as CPU. -async function getBuildTarget() { +export async function getBuildTarget() { if (cachedBuildTarget) return cachedBuildTarget; const invoke = window.__TAURI__?.core?.invoke; if (invoke) { @@ -2230,14 +2267,13 @@ async function checkForUpdate() { const card = document.getElementById("notifReleaseCard"); const desc = document.getElementById("notifReleaseDesc"); - const badge = document.getElementById("notifBadge"); - const empty = document.getElementById("notifEmpty"); const dismissBtn = document.getElementById("notifReleaseDismiss"); if (desc) desc.textContent = `v${latest}`; card?.classList.remove("hidden"); - badge?.classList.remove("hidden"); - empty?.classList.add("hidden"); + // The badge and empty state are shared with failure cards now, so they are + // decided in one place from the full set rather than toggled from here. + setReleasePending(true); // Clicking the card (anywhere but the dismiss button) opens the release dialog. card?.addEventListener("click", (e) => { @@ -2249,10 +2285,20 @@ async function checkForUpdate() { e.stopPropagation(); try { localStorage.setItem(DISMISSED_UPDATE_KEY, latest); } catch (e) { console.warn(e); } card?.classList.add("hidden"); - badge?.classList.add("hidden"); - empty?.classList.remove("hidden"); + setReleasePending(false); }, { once: true }); - } catch (e) { console.warn("[catalog] update check failed:", e); } + } catch (e) { + console.warn("[catalog] update check failed:", e); + // Only report a genuine failure, not "we are offline": an update check that + // cannot reach GitHub is not a StemDeck bug and must not file one. + if (!(e instanceof TypeError)) { + notifyFailure({ + kind: "update", + message: "Could not check for updates.", + detail: String(e?.message || e), + }); + } + } } function wireAboutDialog() { @@ -2844,7 +2890,12 @@ async function wireNetworkSetting(overlay) { } async function loadRegistryView(overlay) { - const view = overlay.querySelector(".settings-registry-view"); + // Scoped to the registry pane: the log viewers reuse .settings-registry-view + // for its read-only-textarea styling and sit earlier in the markup, so a bare + // class lookup returned the *application log* box. The registry JSON was + // being written into a hidden textarea while the registry pane sat on its + // literal "Loading…" placeholder for ever. + const view = overlay.querySelector('[data-pane="registry"] .settings-registry-view'); if (!view) return; view.value = "Loading…"; try { @@ -2940,6 +2991,12 @@ async function exportLogs(btn) { } catch (e) { console.warn("[settings] log export failed:", e); showError("Could not export the logs.", null, { retry: false }); + notifyFailure({ + kind: "export", + message: "Could not export the logs.", + detail: String(e?.message || e), + context: { stage: "Exporting logs" }, + }); } finally { if (btn) { btn.disabled = false; btn.textContent = "Export logs"; } } @@ -3175,6 +3232,7 @@ function openLibraryEditor() {
+
@@ -3198,6 +3256,16 @@ function openLibraryEditor() {
+