Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions app/api/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>/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.
Expand Down
6 changes: 6 additions & 0 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
62 changes: 62 additions & 0 deletions static/css/daw.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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; }
Expand Down Expand Up @@ -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;
Expand Down
25 changes: 25 additions & 0 deletions static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@
<div class="daw-notif-panel" role="menu" aria-label="Notifications">
<div class="daw-notif-header">
<span class="uplabel">Notifications</span>
<button class="daw-notif-clear" id="notifClearAll" type="button">Clear all</button>
<button class="daw-notif-close" type="button" aria-label="Close notifications">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6 6 18M6 6l12 12"/></svg>
</button>
Expand Down Expand Up @@ -869,6 +870,30 @@ <h2 id="releaseTitle">New release available</h2>
</div>
</div>

<!-- Failure detail dialog (opened from a notification card). The report
link is a plain external anchor: the global handler in main.js routes
it through Tauri's open_url on desktop and a new tab in a browser. -->
<div class="about-backdrop hidden" id="failureDialog" role="dialog" aria-modal="true" aria-labelledby="failureTitle">
<div class="about-card failure-card">
<button class="about-close" id="failureClose" type="button" aria-label="Close failure dialog">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M18 6 6 18M6 6l12 12"/></svg>
</button>
<div class="about-logo failure-logo" aria-hidden="true">
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M12 9v4 M12 17h.01 M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"/>
</svg>
</div>
<h2 id="failureTitle">Something failed</h2>
<span class="about-version-badge failure-when" id="failureWhen"></span>
<p class="failure-message" id="failureMessage"></p>
<p class="failure-hint">These details go in the report. Your track title and source link are not included — add them yourself if they help.</p>
<pre class="failure-tech"><code id="failureTech"></code></pre>
<div class="about-primary-links">
<a class="about-link about-link-primary" id="failureReport" target="_blank" rel="noopener noreferrer">Report on GitHub</a>
</div>
</div>
</div>

<script type="module" src="/js/main.js"></script>
<script type="module" src="/js/ui-chrome.js"></script>
</body>
Expand Down
86 changes: 77 additions & 9 deletions static/js/catalog.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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, "&amp;")
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) => {
Expand All @@ -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() {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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"; }
}
Expand Down Expand Up @@ -3175,6 +3232,7 @@ function openLibraryEditor() {
<div class="settings-subtabs" role="tablist">
<button class="settings-subtab active" type="button" data-sub="location" role="tab">Location</button>
<button class="settings-subtab" type="button" data-sub="application" role="tab">Application log</button>
<button class="settings-subtab" type="button" data-sub="backend" role="tab">Backend log</button>
<button class="settings-subtab" type="button" data-sub="setup" role="tab">Setup log</button>
</div>
<div class="settings-subpane" data-subpane="location">
Expand All @@ -3198,6 +3256,16 @@ function openLibraryEditor() {
</div>
<textarea class="settings-registry-view settings-logtail-view" data-view="application" readonly spellcheck="false" aria-label="Application log (read only)">Loading…</textarea>
</div>
<div class="settings-subpane hidden" data-subpane="backend">
<div class="settings-row">
<div class="settings-row-text">
<div class="settings-row-title">Backend log</div>
<div class="settings-row-desc">The last hour from <code>backend.log</code> — raw output of the bundled Python process, including anything that crashed it before the application log could record it. Desktop app only. Read-only.</div>
</div>
<button class="settings-registry-refresh settings-logtail-refresh" type="button" data-view="backend">Refresh</button>
</div>
<textarea class="settings-registry-view settings-logtail-view" data-view="backend" readonly spellcheck="false" aria-label="Backend log (read only)">Loading…</textarea>
</div>
<div class="settings-subpane hidden" data-subpane="setup">
<div class="settings-row">
<div class="settings-row-text">
Expand Down
Loading
Loading