diff --git a/tests/test_idle_pill_run_button.py b/tests/test_idle_pill_run_button.py new file mode 100644 index 00000000..f5bf7922 --- /dev/null +++ b/tests/test_idle_pill_run_button.py @@ -0,0 +1,82 @@ +""" +The operation pill is the single place to start a run. + +The Dashboard header used to carry its own Verbose / Run Now / Dry Run +controls, duplicating the pill with a separate Verbose setting and a disabled +state evaluated only at page load. Both idle pills now carry a labelled Run +button, and every clickable pill is keyboard focusable. +""" + +import re +from pathlib import Path + +import pytest +from jinja2 import Environment, FileSystemLoader + +# Load templates straight from disk rather than through web.config: several test +# modules replace web.config in sys.modules with a MagicMock at import time, so +# importing it here would depend on collection order. +TEMPLATES_DIR = Path(__file__).resolve().parents[1] / "web" / "templates" +BANNER = "components/global_operation_banner.html" + + +def _render(**ctx): + env = Environment(loader=FileSystemLoader(str(TEMPLATES_DIR))) + # Filters the app registers on the shared instance; not exercised by the idle pills. + for name in ("truncate_filename", "format_bytes", "format_time", "format_datetime"): + env.filters[name] = lambda value, *args, **kwargs: value + ctx.setdefault("status", {"state": "idle"}) + return env.get_template(BANNER).render(**ctx) + + +@pytest.mark.parametrize("scheduler_status, pill_id", [ + ({"next_run_relative": "32m"}, "idle-countdown-pill"), + (None, "idle-ready-pill"), +], ids=["countdown", "ready"]) +def test_idle_pill_has_run_button(scheduler_status, pill_id): + html = _render(scheduler_status=scheduler_status) + pill = html[html.index(f'id="{pill_id}"'):] + compact = pill[:pill.index('class="di-pill__expanded"')] + + assert 'class="di-btn di-btn--primary di-btn--run"' in compact + assert "diTriggerRun(false)" in compact + assert ">Run" in compact + # Hovering the button must not start the pill's expand timer + assert "clearTimeout(_diHoverTimer)" in compact + + +def test_ready_pill_label_uses_app_icon(): + """With a Run button on the pill, a play icon beside "PlexCache" would read as a second action.""" + html = _render(scheduler_status=None) + pill = html[html.index('id="idle-ready-pill"'):] + left = pill[pill.index('class="di-pill__left"'):pill.index('class="di-pill__right"')] + assert 'data-lucide="hard-drive"' in left + assert 'data-lucide="play"' not in left + + +def test_run_button_stops_click_reaching_the_pill(): + html = _render(scheduler_status={"next_run_relative": "32m"}) + button = re.search(r']*di-btn--run[^>]*>', html, re.S).group(0) + assert "event.stopPropagation(); diTriggerRun(false);" in button + + +def test_expanded_idle_pill_keeps_verbose_and_dry_run(): + html = _render(scheduler_status={"next_run_relative": "32m"}) + expanded = html[html.index('class="di-pill__expanded"'):] + assert 'id="di-verbose-cb"' in expanded + assert "diTriggerRun(true)" in expanded + + +def test_clickable_pills_are_focusable(): + source = (TEMPLATES_DIR / BANNER).read_text(encoding="utf-8") + clickable = re.findall(r'
]*>', source, re.S) + assert clickable + assert all('tabindex="0"' in tag for tag in clickable) + + +def test_dashboard_header_has_no_run_controls(): + source = (TEMPLATES_DIR / "dashboard.html").read_text(encoding="utf-8") + header = source[source.index('")] + for marker in ('id="run-form"', 'id="verbose-toggle"', 'id="dry-run-input"', "Run Now", "Dry Run"): + assert marker not in header, marker + assert "plexcache_dashboard_verbose" not in source diff --git a/web/routers/dashboard.py b/web/routers/dashboard.py index e3c4c8cf..b45d9711 100644 --- a/web/routers/dashboard.py +++ b/web/routers/dashboard.py @@ -25,7 +25,6 @@ def dashboard(request: Request): operation_runner = get_operation_runner() # Only get operation status - stats will be lazy loaded - stats = {"is_running": operation_runner.is_running} op_status = operation_runner.get_status_dict() retention_hours = _get_activity_retention_hours() @@ -36,7 +35,6 @@ def dashboard(request: Request): "dashboard.html", { "page_title": "Dashboard", - "stats": stats, "op_status": op_status, "retention_label": retention_label } diff --git a/web/static/css/custom.css b/web/static/css/custom.css index 5e203751..61c16b42 100644 --- a/web/static/css/custom.css +++ b/web/static/css/custom.css @@ -652,6 +652,23 @@ footer { color: #000; } .di-btn--primary:hover { background: #f0b020; } +/* Compact "Run" button on the idle pill (replaces the Dashboard header controls) */ +.di-btn--run { + padding: 0.25rem 0.65rem 0.25rem 0.55rem; + gap: 0.3rem; + border-radius: var(--plex-radius-lg); + font-size: 0.75rem; + font-weight: 700; +} +.di-btn--run svg { width: 11px; height: 11px; fill: currentColor; } +@media (pointer: coarse) { + .di-btn--run { padding: 0.45rem 0.9rem 0.45rem 0.75rem; } +} +.di-pill:focus-visible, +.di-btn:focus-visible { + outline: 2px solid var(--plex-orange); + outline-offset: 2px; +} .di-btn--secondary { background: rgba(var(--di-on),0.08); color: var(--di-t70); @@ -1879,7 +1896,7 @@ footer { border: 1px solid var(--plex-border); color: var(--plex-text); padding: 0.35rem 0.7rem; - border-radius: 999px; + border-radius: var(--plex-radius-lg); font-size: 0.82rem; cursor: pointer; transition: background 120ms ease, border-color 120ms ease; diff --git a/web/static/js/app.js b/web/static/js/app.js index bb0dcf62..8f0d6071 100644 --- a/web/static/js/app.js +++ b/web/static/js/app.js @@ -107,3 +107,33 @@ if (typeof window.formatBytes !== 'function') { return i === 0 ? Math.round(size) + ' B' : size.toFixed(2) + ' ' + units[i]; }; } + +// Operation pill keyboard access. Pills are focusable (tabindex="0"); Enter or +// Space acts like a click (expand / detail / collapse), Escape collapses and +// returns focus to the pill. Delegated once here because the pill markup is +// swapped by HTMX every few seconds. +document.addEventListener('keydown', function(e) { + var banner = document.getElementById('global-operation-banner'); + if (!banner || !banner.contains(e.target)) return; + var pill = e.target.classList && e.target.classList.contains('di-pill') ? e.target : null; + if (pill && (e.key === 'Enter' || e.key === ' ')) { + e.preventDefault(); + pill.click(); + } else if (e.key === 'Escape' && typeof diSmoothSetState === 'function') { + diSmoothSetState('false'); + var focusPill = banner.querySelector('.di-pill'); + if (focusPill) focusPill.focus(); + } +}); + +// The Dashboard header used to keep its own Verbose toggle under a separate key. +// Carry a saved preference over to the pill's setting once, then drop the old key. +try { + var legacyVerbose = localStorage.getItem('plexcache_dashboard_verbose'); + if (legacyVerbose !== null) { + if (localStorage.getItem('verbose_mode') === null) { + localStorage.setItem('verbose_mode', legacyVerbose === 'true' ? 'true' : 'false'); + } + localStorage.removeItem('plexcache_dashboard_verbose'); + } +} catch (e) { /* storage unavailable: nothing to migrate */ } diff --git a/web/templates/components/global_operation_banner.html b/web/templates/components/global_operation_banner.html index c5975f44..fc85fd59 100644 --- a/web/templates/components/global_operation_banner.html +++ b/web/templates/components/global_operation_banner.html @@ -47,6 +47,7 @@ // Also apply classes for JS-generated pills (completion banners) var pill = document.querySelector('#global-operation-banner .di-pill'); if (pill) { + pill.setAttribute('aria-expanded', state === 'tier2' || state === 'tier3' ? 'true' : 'false'); pill.classList.remove('di-expanded', 'di-detail'); if (state === 'tier2') pill.classList.add('di-expanded'); if (state === 'tier3') { pill.classList.add('di-expanded'); pill.classList.add('di-detail'); } @@ -452,7 +453,7 @@ {% set poll_interval = '8s' if (blocked_message is defined and blocked_message) else '2s' %}
+ onmouseenter="diHoverIn()" onmouseleave="diHoverOut()" onclick="diPillClick()" tabindex="0">
@@ -618,7 +619,7 @@ document.getElementById('global-operation-banner').innerHTML = `
+ onmouseenter="diHoverIn()" onmouseleave="diHoverOut()" onclick="diPillClick()" tabindex="0">
@@ -722,7 +723,7 @@ MAINTENANCE FAILED — Red pill ═══════════════════════════════════════════════════════════ #}
+ onmouseenter="diHoverIn()" onmouseleave="diHoverOut()" onclick="diPillClick()" tabindex="0">
@@ -778,7 +779,7 @@
+ onmouseenter="diHoverIn()" onmouseleave="diHoverOut()" onclick="diPillClick()" tabindex="0">
@@ -1121,7 +1122,7 @@ document.getElementById('global-operation-banner').innerHTML = `
+ onmouseenter="diHoverIn()" onmouseleave="diHoverOut()" onclick="diPillClick()" tabindex="0">
@@ -1216,7 +1217,7 @@ OPERATION FAILED — Red pill ═══════════════════════════════════════════════════════════ #}
+ onmouseenter="diHoverIn()" onmouseleave="diHoverOut()" onclick="diPillClick()" tabindex="0">
@@ -1254,7 +1255,7 @@ ═══════════════════════════════════════════════════════════ #}
+ onmouseenter="diHoverIn()" onmouseleave="diHoverOut()" onclick="diPillClick()" tabindex="0">
@@ -1344,7 +1345,7 @@ ═══════════════════════════════════════════════════════════ #}
+ onmouseenter="diHoverIn()" onmouseleave="diHoverOut()" onclick="diPillClick()" tabindex="0">
@@ -1406,7 +1407,7 @@ {% if scheduler_status is defined and scheduler_status and scheduler_status.next_run_relative %}
+ onmouseenter="diHoverIn()" onmouseleave="diHoverOut()" onclick="diPillClick()" tabindex="0">
@@ -1416,6 +1417,11 @@
{{ scheduler_status.next_run_relative }} +
@@ -1474,21 +1480,28 @@ {# ── No schedule configured — persistent "Ready" pill with quick actions ── #}
+ onmouseenter="diHoverIn()" onmouseleave="diHoverOut()" onclick="diPillClick()" tabindex="0">
- + PlexCache
+
+ +
-
+
PlexCache Ready
diff --git a/web/templates/dashboard.html b/web/templates/dashboard.html index 3200775a..7bed3273 100644 --- a/web/templates/dashboard.html +++ b/web/templates/dashboard.html @@ -12,31 +12,6 @@

Dashboard

PlexCache-D Status Overview

-
-
- - - - -
-
@@ -125,27 +100,6 @@

Recent Activity