Skip to content

Commit ccf4a8f

Browse files
committed
fix(gui): expose delete/clear/diagnostics APIs and repair i18n + empty states
- Expose delete_job, clear_jobs and get_job_diagnostics in EXPOSED_API_METHODS so the GUI delete-single and clear-all history actions actually take effect - Fix invalid en.json (missing trailing comma) that broke the whole English catalog and surfaced raw i18n keys across the app - Add the missing icon placeholder (02) to the Jobs empty state so the box is no longer blank when there is no task history - Add job-history diagnostics export, diagnostics module helpers, CSS and locale strings for en/ru/zh-CN
1 parent d13dab5 commit ccf4a8f

10 files changed

Lines changed: 294 additions & 21 deletions

File tree

plexmuxy/diagnostics.py

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,8 @@
1717
def export_diagnostics(config: AppConfig, output: Path, job_context: dict | None = None) -> Path:
1818
output = output.expanduser().resolve()
1919
output.parent.mkdir(parents=True, exist_ok=True)
20-
config_data = redact_config(config_to_dict(config))
21-
payload = {
22-
"plexmuxy_version": __version__,
23-
"python_version": sys.version,
24-
"platform": platform.platform(),
25-
"dependencies": dependency_versions(),
26-
"mkvmerge": mkvmerge_info(config),
27-
}
20+
payload = collect_diagnostic_payload(config, job_context)
21+
config_data = payload["config"]
2822
with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as archive:
2923
archive.writestr("system.json", json.dumps(payload, indent=2, ensure_ascii=False))
3024
archive.writestr("config.redacted.json", json.dumps(config_data, indent=2, ensure_ascii=False))
@@ -42,6 +36,47 @@ def export_diagnostics(config: AppConfig, output: Path, job_context: dict | None
4236
return output
4337

4438

39+
def collect_diagnostic_payload(config: AppConfig, job_context: dict | None = None) -> dict:
40+
config_data = redact_config(config_to_dict(config))
41+
payload: dict = {
42+
"plexmuxy_version": __version__,
43+
"python_version": sys.version,
44+
"platform": platform.platform(),
45+
"dependencies": dependency_versions(),
46+
"mkvmerge": mkvmerge_info(config),
47+
"config": config_data,
48+
}
49+
if job_context is not None:
50+
payload["job"] = redact_paths(job_context)
51+
return payload
52+
53+
54+
def format_diagnostic_payload(payload: dict) -> str:
55+
lines = []
56+
lines.append("PlexMuxy Diagnostics")
57+
lines.append("=" * 32)
58+
lines.append(f"PlexMuxy version: {payload.get('plexmuxy_version', 'unknown')}")
59+
lines.append(f"Python version: {payload.get('python_version', 'unknown')}")
60+
lines.append(f"Platform: {payload.get('platform', 'unknown')}")
61+
dependencies = payload.get("dependencies") or {}
62+
if dependencies:
63+
lines.append("Dependencies: " + ", ".join(f"{name}={version}" for name, version in dependencies.items()))
64+
mkvmerge = payload.get("mkvmerge") or {}
65+
if mkvmerge.get("version"):
66+
lines.append(f"mkvmerge: {mkvmerge.get('version')}")
67+
lines.append("")
68+
config = payload.get("config")
69+
if config is not None:
70+
lines.append("Configuration:")
71+
lines.append(json.dumps(config, indent=2, ensure_ascii=False))
72+
job = payload.get("job")
73+
if job is not None:
74+
lines.append("")
75+
lines.append("Job context:")
76+
lines.append(json.dumps(job, indent=2, ensure_ascii=False))
77+
return "\n".join(lines)
78+
79+
4580
def redact_config(data: dict) -> dict:
4681
redacted = json.loads(json.dumps(data))
4782
if redacted.get("task", {}).get("output_dir"):

plexmuxy/job_store.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,16 @@ def reorder(self, ordered_job_ids: list[str]) -> None:
288288
(position, utc_now(), job_id),
289289
)
290290

291+
def delete_job(self, job_id: str) -> None:
292+
with self._lock, self._connection:
293+
self._connection.execute("DELETE FROM jobs WHERE id = ?", (str(job_id),))
294+
295+
def clear_jobs(self) -> int:
296+
with self._lock, self._connection:
297+
count = self._connection.execute("SELECT COUNT(*) AS total FROM jobs").fetchone()["total"]
298+
self._connection.execute("DELETE FROM jobs")
299+
return int(count)
300+
291301

292302
def _json(value: Any) -> str:
293303
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))

plexmuxy_gui/api.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,11 @@
3636
resolve_mkvmerge,
3737
resolve_unrar,
3838
)
39-
from plexmuxy.diagnostics import export_diagnostics as write_diagnostics
39+
from plexmuxy.diagnostics import (
40+
collect_diagnostic_payload,
41+
export_diagnostics as write_diagnostics,
42+
format_diagnostic_payload,
43+
)
4044
from plexmuxy.font_cache import FontSubsetCache
4145
from plexmuxy.integrations.plex import refresh_paths
4246
from plexmuxy.job_store import JobStore, platform_state_path
@@ -648,6 +652,33 @@ def run() -> dict[str, Any]:
648652
return self.ok({"path": str(path), "directory": str(path.parent)})
649653
return self.guarded(run)
650654

655+
def get_job_diagnostics(self, job_id: str) -> dict[str, Any]:
656+
def run() -> dict[str, Any]:
657+
store, _queue = self._ensure_jobs()
658+
job = store.get_job(str(job_id))
659+
context = {
660+
"job": asdict(job),
661+
"events": [asdict(event) for event in store.list_events(job.id)],
662+
"report": store.load_report(job.id),
663+
}
664+
payload = collect_diagnostic_payload(load_or_default_config(), context)
665+
return self.ok({"text": format_diagnostic_payload(payload)})
666+
return self.guarded(run)
667+
668+
def delete_job(self, job_id: str) -> dict[str, Any]:
669+
def run() -> dict[str, Any]:
670+
store, _queue = self._ensure_jobs()
671+
store.delete_job(str(job_id))
672+
return self.ok({"job_id": str(job_id)})
673+
return self.guarded(run)
674+
675+
def clear_jobs(self) -> dict[str, Any]:
676+
def run() -> dict[str, Any]:
677+
store, _queue = self._ensure_jobs()
678+
removed = store.clear_jobs()
679+
return self.ok({"removed": removed})
680+
return self.guarded(run)
681+
651682
def retry_plex_refresh(self, job_id: str) -> dict[str, Any]:
652683
def run() -> dict[str, Any]:
653684
store, _queue = self._ensure_jobs()

plexmuxy_gui/app.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,12 @@
4545
"cancel_job",
4646
"choose_dependency",
4747
"choose_directory",
48+
"clear_jobs",
4849
"close_window",
50+
"delete_job",
4951
"export_diagnostics",
5052
"export_job_diagnostics",
53+
"get_job_diagnostics",
5154
"check_updates",
5255
"clear_font_cache",
5356
"create_audio_preview",

plexmuxy_gui/static/app.css

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1416,6 +1416,66 @@ p {
14161416
gap: 18px;
14171417
}
14181418

1419+
.card-title .badge {
1420+
flex: 0 0 auto;
1421+
white-space: nowrap;
1422+
}
1423+
1424+
.confirm-dialog.danger .dialog-mark {
1425+
background: var(--color-danger-surface);
1426+
color: var(--color-danger-text);
1427+
}
1428+
1429+
.confirm-dialog.danger #confirm-confirm-btn,
1430+
.confirm-dialog.danger #confirm-confirm-btn:active:not(:disabled) {
1431+
border-color: color-mix(in srgb, var(--color-danger) 50%, transparent);
1432+
background: var(--color-danger-surface);
1433+
color: var(--color-danger-text);
1434+
}
1435+
1436+
.confirm-dialog.danger #confirm-confirm-btn:hover:not(:disabled) {
1437+
border-color: var(--color-danger);
1438+
background: color-mix(in srgb, var(--color-danger-surface) 82%, var(--color-danger) 18%);
1439+
color: var(--color-danger-text);
1440+
}
1441+
1442+
.diagnostics-dialog {
1443+
width: min(720px, calc(100vw - 32px));
1444+
border: 1px solid var(--color-border);
1445+
border-radius: var(--radius-md);
1446+
padding: 0;
1447+
background: var(--color-surface-raised);
1448+
box-shadow: var(--shadow-popover);
1449+
color: var(--color-text-body);
1450+
}
1451+
1452+
.diagnostics-dialog::backdrop {
1453+
background: var(--color-scrim);
1454+
backdrop-filter: blur(3px);
1455+
}
1456+
1457+
.diagnostics-dialog form {
1458+
display: grid;
1459+
gap: 0;
1460+
padding: 22px;
1461+
}
1462+
1463+
.diagnostics-content {
1464+
margin: 18px 0 0;
1465+
max-height: min(60vh, 520px);
1466+
overflow: auto;
1467+
border: 1px solid var(--color-border);
1468+
border-radius: var(--radius-sm);
1469+
padding: 14px;
1470+
background: var(--color-canvas);
1471+
color: var(--color-text-body);
1472+
font-family: "Cascadia Mono", "SFMono-Regular", Consolas, monospace;
1473+
font-size: 12px;
1474+
line-height: 1.6;
1475+
white-space: pre-wrap;
1476+
overflow-wrap: anywhere;
1477+
}
1478+
14191479
.card-title h4 {
14201480
margin: 0;
14211481
font-size: 15px;

plexmuxy_gui/static/app.js

Lines changed: 69 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ const state = {
77
lastNonSubsetFontMode: "all",
88
planEdits: new Map(), jobs: [], queuePaused: false, activePreviewId: null,
99
dependencyDrafts: { mkvmerge: null, ffmpeg: null, unrar: null }, dependencyBusy: {},
10+
currentDiagnosticsJobId: null,
1011
};
1112

1213
const THEME_STORAGE_KEY = "plexmuxy-theme";
@@ -47,6 +48,9 @@ function bindEvents() {
4748
["plan-btn", "click", generatePlan], ["run-btn", "click", runMux], ["cancel-btn", "click", cancelJob],
4849
["apply-plan-edits-btn", "click", applyPlanEdits], ["refresh-jobs-btn", "click", loadJobs],
4950
["queue-toggle-btn", "click", toggleQueue], ["clear-font-cache-btn", "click", clearFontCache],
51+
["delete-all-jobs-btn", "click", deleteAllJobs],
52+
["diagnostics-export-btn", "click", exportDiagnosticsFromDialog],
53+
["diagnostics-copy-btn", "click", copyDiagnosticsFromDialog],
5054
["open-font-cache-btn", "click", () => callApi("open_font_cache_location")],
5155
["check-updates-btn", "click", checkUpdates],
5256
["input-dir", "input", (event) => { state.inputDir = event.target.value; clearReports(); }],
@@ -789,16 +793,67 @@ function requiresDeleteConfirmation(payload) {
789793
return Boolean(payload.overrides.cleanup === "delete" || task.delete_original_video || task.delete_original_audio || task.delete_subtitle || font.delete_fonts_after_mux);
790794
}
791795

792-
function confirmAction(message) {
796+
function confirmAction(message, options = {}) {
793797
const dialog = $("confirm-dialog");
794-
setText("confirm-dialog-body", message + " " + t("confirm.review"));
798+
setText("confirm-dialog-title", options.title || t("confirm.title"));
799+
setText("confirm-dialog-body", options.review === false ? message : `${message} ${t("confirm.review")}`);
800+
setText("confirm-confirm-btn", options.confirmLabel || t("confirm.run"));
801+
dialog.classList.toggle("danger", Boolean(options.danger));
795802
dialog.returnValue = "";
796803
dialog.showModal();
797804
return new Promise((resolve) => {
798805
dialog.addEventListener("close", () => resolve(dialog.returnValue === "confirm"), { once: true });
799806
});
800807
}
801808

809+
async function deleteAllJobs() {
810+
if (!state.jobs.length) return;
811+
if (await confirmAction(t("jobs.deleteAllConfirm"), { title: t("jobs.deleteAllTitle"), confirmLabel: t("jobs.deleteAll"), danger: true, review: false })) {
812+
try { await callApi("clear_jobs"); await loadJobs(); showToast(t("toast.jobsCleared.body", { count: state.jobs.length }), "success", t("toast.jobsCleared.title")); }
813+
catch (error) { showError(error.message); }
814+
}
815+
}
816+
817+
async function previewJobDiagnostics(job) {
818+
state.currentDiagnosticsJobId = job.id;
819+
const dialog = $("diagnostics-dialog");
820+
const content = $("diagnostics-content");
821+
content.textContent = t("jobs.diagnostics.loading");
822+
dialog.returnValue = "";
823+
dialog.showModal();
824+
try {
825+
const result = await callApi("get_job_diagnostics", job.id);
826+
content.textContent = result.text;
827+
} catch (error) {
828+
content.textContent = error.message;
829+
}
830+
}
831+
832+
async function exportDiagnosticsFromDialog() {
833+
const jobId = state.currentDiagnosticsJobId;
834+
const dialog = $("diagnostics-dialog");
835+
if (!jobId) return;
836+
dialog.querySelector("#diagnostics-export-btn").disabled = true;
837+
try {
838+
const result = await callApi("export_job_diagnostics", jobId);
839+
showToast(t("toast.diagnostics.body", { path: result.path }), "success", t("toast.diagnostics.title"), {
840+
label: t("toast.diagnostics.open"),
841+
callback: async () => { try { await callApi("open_diagnostics_location"); } catch (error) { showToast(error.message, "error", t("toast.environmentError.title")); } },
842+
}, 12000);
843+
} catch (error) { showError(error.message); }
844+
finally { dialog.querySelector("#diagnostics-export-btn").disabled = false; }
845+
}
846+
847+
async function copyDiagnosticsFromDialog() {
848+
const text = $("diagnostics-content").textContent;
849+
try {
850+
await navigator.clipboard.writeText(text);
851+
showToast(t("jobs.diagnostics.copied"), "success", t("toast.diagnostics.title"));
852+
} catch (error) {
853+
showToast(error.message, "error", t("toast.environmentError.title"));
854+
}
855+
}
856+
802857
async function callApi(method, payload) {
803858
if (!window.pywebview?.api?.[method]) throw new Error(t("error.bridgeUnavailable"));
804859
const response = payload === undefined ? await window.pywebview.api[method]() : await window.pywebview.api[method](payload);
@@ -976,7 +1031,9 @@ async function loadJobs() {
9761031
function renderJobs() {
9771032
const container = $("jobs-list"); if (!container) return; clear(container);
9781033
setText("queue-toggle-btn", state.queuePaused ? t("jobs.resume") : t("jobs.pause"));
979-
if (!state.jobs.length) return empty(container, "", t("jobs.empty.title"), t("jobs.empty.detail"));
1034+
const deleteAllButton = $("delete-all-jobs-btn");
1035+
if (deleteAllButton) deleteAllButton.disabled = !state.jobs.length;
1036+
if (!state.jobs.length) return empty(container, "02", t("jobs.empty.title"), t("jobs.empty.detail"));
9801037
state.jobs.forEach((job) => {
9811038
const card = element("article", "job-history-card"); const heading = element("div", "card-title");
9821039
const copy = element("div"); copy.append(element("h4", "", job.input_dir), element("div", "file-path", job.updated_at));
@@ -1001,8 +1058,6 @@ function renderJobs() {
10011058
view.addEventListener("click", () => openSavedJob(job.id)); actions.append(view);
10021059
const output = element("button", "ghost compact", t("jobs.openOutput")); output.type = "button";
10031060
output.addEventListener("click", async () => { try { await callApi("open_job_output", job.id); } catch (error) { showError(error.message); } }); actions.append(output);
1004-
const diagnostics = element("button", "ghost compact", t("jobs.diagnostics")); diagnostics.type = "button";
1005-
diagnostics.addEventListener("click", async () => { try { const result = await callApi("export_job_diagnostics", job.id); showToast(t("toast.diagnostics.body", { path: result.path }), "success", t("toast.diagnostics.title")); } catch (error) { showError(error.message); } }); actions.append(diagnostics);
10061061
if (job.state === "completed" && state.config?.plex?.enabled) {
10071062
const plex = element("button", "ghost compact", t("jobs.retryPlex")); plex.type = "button";
10081063
plex.addEventListener("click", async () => {
@@ -1017,6 +1072,15 @@ function renderJobs() {
10171072
const replan = element("button", "ghost compact", t("jobs.replan")); replan.type = "button";
10181073
replan.addEventListener("click", async () => { await callApi("replan_job", job.id); await loadJobs(); }); actions.append(replan);
10191074
}
1075+
const diagnostics = element("button", "ghost compact", t("jobs.diagnostics")); diagnostics.type = "button";
1076+
diagnostics.addEventListener("click", () => previewJobDiagnostics(job)); actions.append(diagnostics);
1077+
const remove = element("button", "destructive compact", t("jobs.delete")); remove.type = "button";
1078+
remove.addEventListener("click", async () => {
1079+
if (await confirmAction(t("jobs.deleteConfirm"), { title: t("jobs.deleteTitle"), confirmLabel: t("jobs.delete"), danger: true, review: false })) {
1080+
try { await callApi("delete_job", job.id); await loadJobs(); showToast(t("toast.jobDeleted.body"), "success", t("toast.jobDeleted.title")); }
1081+
catch (error) { showError(error.message); }
1082+
}
1083+
}); actions.append(remove);
10201084
card.append(actions); container.append(card);
10211085
});
10221086
container.className = "stack";

plexmuxy_gui/static/index.html

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -448,11 +448,11 @@ <h4 data-i18n="run.empty.title">No run yet</h4>
448448
<div id="jobs-view" class="view hidden" data-view="jobs">
449449
<div class="page-heading">
450450
<div><p class="breadcrumb" data-i18n="jobs.breadcrumb">Task history</p><h2 data-i18n="jobs.title">Queue and history</h2><p data-i18n="jobs.description">Directory tasks run one at a time and remain visible after restart.</p></div>
451-
<div class="button-row"><button id="queue-toggle-btn" class="secondary" type="button" data-i18n="jobs.pause">Pause queue</button><button id="refresh-jobs-btn" class="ghost" type="button" data-i18n="jobs.refresh">Refresh</button></div>
451+
<div class="button-row"><button id="queue-toggle-btn" class="secondary" type="button" data-i18n="jobs.pause">Pause queue</button><button id="delete-all-jobs-btn" class="ghost" type="button" data-i18n="jobs.deleteAll">Clear history</button><button id="refresh-jobs-btn" class="ghost" type="button" data-i18n="jobs.refresh">Refresh</button></div>
452452
</div>
453453
<section class="panel" aria-labelledby="jobs-list-title">
454454
<div class="panel-header"><div><h3 id="jobs-list-title" data-i18n="jobs.listTitle">Tasks</h3><p class="section-description" data-i18n="jobs.listDescription">Review status, retry interrupted work, or reopen the output location.</p></div></div>
455-
<div id="jobs-list" class="stack empty-state"><h4 data-i18n="jobs.empty.title">No task history</h4><p data-i18n="jobs.empty.detail">Generate a plan to create the first persistent task.</p></div>
455+
<div id="jobs-list" class="stack empty-state"><span class="empty-icon" aria-hidden="true">02</span><h4 data-i18n="jobs.empty.title">No task history</h4><p data-i18n="jobs.empty.detail">Generate a plan to create the first persistent task.</p></div>
456456
</section>
457457
</div>
458458

@@ -690,8 +690,8 @@ <h2 id="confirm-dialog-title" data-i18n="confirm.title">Start this mux job?</h2>
690690
<p id="confirm-dialog-body"></p>
691691
</div>
692692
<div class="dialog-actions">
693-
<button class="secondary" type="submit" value="cancel" data-i18n="confirm.back">Go back</button>
694-
<button class="primary" type="submit" value="confirm" data-i18n="confirm.run">Run mux</button>
693+
<button id="confirm-cancel-btn" class="secondary" type="submit" value="cancel" data-i18n="confirm.back">Go back</button>
694+
<button id="confirm-confirm-btn" class="primary" type="submit" value="confirm" data-i18n="confirm.run">Run mux</button>
695695
</div>
696696
</form>
697697
</dialog>
@@ -711,6 +711,22 @@ <h2 id="close-dialog-title" data-i18n="close.title">Close this window?</h2>
711711
</form>
712712
</dialog>
713713

714+
<dialog id="diagnostics-dialog" class="diagnostics-dialog" aria-labelledby="diagnostics-dialog-title" aria-describedby="diagnostics-dialog-body">
715+
<form method="dialog">
716+
<div class="dialog-copy">
717+
<p class="eyebrow" data-i18n="jobs.diagnostics.eyebrow">Diagnostics</p>
718+
<h2 id="diagnostics-dialog-title" data-i18n="jobs.diagnostics.title">Job diagnostics</h2>
719+
<p id="diagnostics-dialog-body" data-i18n="jobs.diagnostics.description">Preview the information included in the exported archive, then export it if needed.</p>
720+
</div>
721+
<pre id="diagnostics-content" class="diagnostics-content" tabindex="0"></pre>
722+
<div class="dialog-actions">
723+
<button id="diagnostics-copy-btn" class="ghost" type="button" data-i18n="jobs.diagnostics.copy">Copy</button>
724+
<button class="secondary" type="submit" value="close" data-i18n="jobs.diagnostics.close">Close</button>
725+
<button id="diagnostics-export-btn" class="primary" type="button" data-i18n="jobs.diagnostics.export">Export archive</button>
726+
</div>
727+
</form>
728+
</dialog>
729+
714730
<script src="./i18n.js"></script>
715731
<script src="./app.js"></script>
716732
</body>

0 commit comments

Comments
 (0)