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
82 changes: 82 additions & 0 deletions tests/test_idle_pill_run_button.py
Original file line number Diff line number Diff line change
@@ -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'<button[^>]*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'<div class="di-pill[^>]*onclick="diPillClick\(\)"[^>]*>', 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('<header class="page-header">'):source.index("</header>")]
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
2 changes: 0 additions & 2 deletions web/routers/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -36,7 +35,6 @@ def dashboard(request: Request):
"dashboard.html",
{
"page_title": "Dashboard",
"stats": stats,
"op_status": op_status,
"retention_label": retention_label
}
Expand Down
19 changes: 18 additions & 1 deletion web/static/css/custom.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
30 changes: 30 additions & 0 deletions web/static/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 */ }
37 changes: 25 additions & 12 deletions web/templates/components/global_operation_banner.html
Original file line number Diff line number Diff line change
Expand Up @@ -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'); }
Expand Down Expand Up @@ -452,7 +453,7 @@
{% set poll_interval = '8s' if (blocked_message is defined and blocked_message) else '2s' %}
<div class="di-pill di-pill--{{ color_mod }} di-glow" data-state="maint-running"
hx-get="/api/operation-banner" hx-trigger="every {{ poll_interval }}" hx-swap="innerHTML" hx-target="#global-operation-banner"
onmouseenter="diHoverIn()" onmouseleave="diHoverOut()" onclick="diPillClick()">
onmouseenter="diHoverIn()" onmouseleave="diHoverOut()" onclick="diPillClick()" tabindex="0">

<!-- Compact view -->
<div class="di-pill__compact">
Expand Down Expand Up @@ -618,7 +619,7 @@
document.getElementById('global-operation-banner').innerHTML = `
<div class="di-pill di-pill--${colorMod}" data-state="maint-completed"
hx-get="/api/operation-banner" hx-trigger="every 10s" hx-swap="innerHTML" hx-target="#global-operation-banner"
onmouseenter="diHoverIn()" onmouseleave="diHoverOut()" onclick="diPillClick()">
onmouseenter="diHoverIn()" onmouseleave="diHoverOut()" onclick="diPillClick()" tabindex="0">
<div class="di-pill__compact">
<div class="di-pill__left">
<div class="di-icon"><i data-lucide="${iconName}" style="width: 14px; height: 14px;"></i></div>
Expand Down Expand Up @@ -722,7 +723,7 @@
MAINTENANCE FAILED — Red pill
═══════════════════════════════════════════════════════════ #}
<div class="di-pill di-pill--error" data-state="maint-failed"
onmouseenter="diHoverIn()" onmouseleave="diHoverOut()" onclick="diPillClick()">
onmouseenter="diHoverIn()" onmouseleave="diHoverOut()" onclick="diPillClick()" tabindex="0">
<div class="di-pill__compact">
<div class="di-pill__left">
<div class="di-icon"><i data-lucide="x-circle" style="width: 14px; height: 14px;"></i></div>
Expand Down Expand Up @@ -778,7 +779,7 @@

<div class="di-pill di-pill--{{ color_mod }} di-glow" data-state="op-running-{{ color_mod }}"
hx-get="/api/operation-banner" hx-trigger="every {{ poll_interval }}" hx-swap="innerHTML" hx-target="#global-operation-banner"
onmouseenter="diHoverIn()" onmouseleave="diHoverOut()" onclick="diPillClick()">
onmouseenter="diHoverIn()" onmouseleave="diHoverOut()" onclick="diPillClick()" tabindex="0">

<!-- Compact view -->
<div class="di-pill__compact">
Expand Down Expand Up @@ -1121,7 +1122,7 @@
document.getElementById('global-operation-banner').innerHTML = `
<div class="di-pill di-pill--${colorMod}" data-state="op-completed"
hx-get="/api/operation-banner" hx-trigger="every 10s" hx-swap="innerHTML" hx-target="#global-operation-banner"
onmouseenter="diHoverIn()" onmouseleave="diHoverOut()" onclick="diPillClick()">
onmouseenter="diHoverIn()" onmouseleave="diHoverOut()" onclick="diPillClick()" tabindex="0">
<div class="di-pill__compact">
<div class="di-pill__left">
<div class="di-icon"><i data-lucide="${iconName}" style="width: 14px; height: 14px;"></i></div>
Expand Down Expand Up @@ -1216,7 +1217,7 @@
OPERATION FAILED — Red pill
═══════════════════════════════════════════════════════════ #}
<div class="di-pill di-pill--error" data-state="op-failed"
onmouseenter="diHoverIn()" onmouseleave="diHoverOut()" onclick="diPillClick()">
onmouseenter="diHoverIn()" onmouseleave="diHoverOut()" onclick="diPillClick()" tabindex="0">
<div class="di-pill__compact">
<div class="di-pill__left">
<div class="di-icon"><i data-lucide="x-circle" style="width: 14px; height: 14px;"></i></div>
Expand Down Expand Up @@ -1254,7 +1255,7 @@
═══════════════════════════════════════════════════════════ #}
<div class="di-pill di-pill--maintenance" data-state="queue-countdown"
hx-get="/api/operation-banner" hx-trigger="every 2s" hx-swap="innerHTML" hx-target="#global-operation-banner"
onmouseenter="diHoverIn()" onmouseleave="diHoverOut()" onclick="diPillClick()">
onmouseenter="diHoverIn()" onmouseleave="diHoverOut()" onclick="diPillClick()" tabindex="0">
<div class="di-pill__compact">
<div class="di-pill__left">
<div class="di-icon"><i data-lucide="list-ordered" style="width: 14px; height: 14px;"></i></div>
Expand Down Expand Up @@ -1344,7 +1345,7 @@
═══════════════════════════════════════════════════════════ #}
<div class="di-pill di-pill--idle" data-state="queue-paused"
hx-get="/api/operation-banner" hx-trigger="every 10s" hx-swap="innerHTML" hx-target="#global-operation-banner"
onmouseenter="diHoverIn()" onmouseleave="diHoverOut()" onclick="diPillClick()">
onmouseenter="diHoverIn()" onmouseleave="diHoverOut()" onclick="diPillClick()" tabindex="0">
<div class="di-pill__compact">
<div class="di-pill__left">
<div class="di-icon" style="background: color-mix(in srgb, var(--plex-warning) 15%, transparent); color: var(--plex-warning);"><i data-lucide="pause" style="width: 14px; height: 14px;"></i></div>
Expand Down Expand Up @@ -1406,7 +1407,7 @@
{% if scheduler_status is defined and scheduler_status and scheduler_status.next_run_relative %}
<div class="di-pill di-pill--idle" id="idle-countdown-pill" data-state="idle"
hx-get="/api/operation-banner" hx-trigger="every 10s" hx-swap="innerHTML" hx-target="#global-operation-banner"
onmouseenter="diHoverIn()" onmouseleave="diHoverOut()" onclick="diPillClick()">
onmouseenter="diHoverIn()" onmouseleave="diHoverOut()" onclick="diPillClick()" tabindex="0">

<!-- Compact view -->
<div class="di-pill__compact">
Expand All @@ -1416,6 +1417,11 @@
</div>
<div class="di-pill__right">
<span class="di-metric" style="color: var(--di-idle-value); font-weight: 400; font-size: 0.8rem;">{{ scheduler_status.next_run_relative }}</span>
<button type="button" class="di-btn di-btn--primary di-btn--run" title="Run now" aria-label="Run now"
onmouseenter="clearTimeout(_diHoverTimer);" onmouseleave="diHoverIn();"
onclick="event.stopPropagation(); diTriggerRun(false);">
<i data-lucide="play"></i>Run
</button>
</div>
</div>

Expand Down Expand Up @@ -1474,21 +1480,28 @@
{# ── No schedule configured — persistent "Ready" pill with quick actions ── #}
<div class="di-pill di-pill--idle" id="idle-ready-pill" data-state="idle-ready"
hx-get="/api/operation-banner" hx-trigger="every 10s" hx-swap="innerHTML" hx-target="#global-operation-banner"
onmouseenter="diHoverIn()" onmouseleave="diHoverOut()" onclick="diPillClick()">
onmouseenter="diHoverIn()" onmouseleave="diHoverOut()" onclick="diPillClick()" tabindex="0">

<!-- Compact view -->
<div class="di-pill__compact">
<div class="di-pill__left">
<i data-lucide="play" style="width: 13px; height: 13px; color: var(--plex-orange); opacity: 0.6;"></i>
<i data-lucide="hard-drive" style="width: 13px; height: 13px; color: var(--plex-orange); opacity: 0.6;"></i>
<span style="font-size: 0.78rem; color:var(--di-t50);">PlexCache</span>
</div>
<div class="di-pill__right">
<button type="button" class="di-btn di-btn--primary di-btn--run" title="Run now" aria-label="Run now"
onmouseenter="clearTimeout(_diHoverTimer);" onmouseleave="diHoverIn();"
onclick="event.stopPropagation(); diTriggerRun(false);">
<i data-lucide="play"></i>Run
</button>
</div>
</div>

<!-- Expanded view — quick actions -->
<div class="di-pill__expanded">
<div class="di-expanded__header">
<div class="di-expanded__header-left">
<div class="di-icon" style="background: color-mix(in srgb, var(--plex-orange) 15%, transparent); color: var(--plex-orange);"><i data-lucide="play" style="width: 14px; height: 14px;"></i></div>
<div class="di-icon" style="background: color-mix(in srgb, var(--plex-orange) 15%, transparent); color: var(--plex-orange);"><i data-lucide="hard-drive" style="width: 14px; height: 14px;"></i></div>
<span class="di-label">PlexCache Ready</span>
</div>
</div>
Expand Down
46 changes: 0 additions & 46 deletions web/templates/dashboard.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,31 +12,6 @@ <h1>Dashboard</h1>
<p>PlexCache-D Status Overview</p>
</div>
</div>
<div class="action-buttons">
<form id="run-form" style="display: inline-flex; align-items: center; gap: 0.75rem;"
hx-post="/operations/run"
hx-target="#global-operation-banner"
hx-swap="innerHTML">
<label class="switch" style="margin: 0;">
<input type="checkbox" id="verbose-toggle" name="verbose" value="true">
<span style="font-size: 0.85rem;">Verbose</span>
</label>
<input type="hidden" id="dry-run-input" name="dry_run" value="false">
<button type="submit" class="btn btn-primary"
hx-indicator="#run-spinner"
{% if stats.is_running %}disabled{% endif %}>
<span id="run-spinner" class="htmx-indicator spinner"></span>
<i data-lucide="play" class="htmx-hide-on-request"></i>
<span class="htmx-hide-on-request">Run Now</span>
</button>
<button type="button" class="btn btn-secondary"
onclick="document.getElementById('dry-run-input').value='true'; document.getElementById('run-form').requestSubmit(); document.getElementById('dry-run-input').value='false';"
{% if stats.is_running %}disabled{% endif %}>
<i data-lucide="eye"></i>
Dry Run
</button>
</form>
</div>
</header>

<!-- Config health warnings (issue #136) - empty unless a path_mapping issue is detected -->
Expand Down Expand Up @@ -125,27 +100,6 @@ <h2>Recent Activity</h2>
</div>

<script>
// Persist verbose toggle preference
const VERBOSE_PREF_KEY = 'plexcache_dashboard_verbose';

function loadVerbosePreference() {
const saved = localStorage.getItem(VERBOSE_PREF_KEY);
if (saved === 'true') {
document.getElementById('verbose-toggle').checked = true;
}
}

function saveVerbosePreference() {
const checked = document.getElementById('verbose-toggle').checked;
localStorage.setItem(VERBOSE_PREF_KEY, checked ? 'true' : 'false');
}

// Load preference on page load
document.addEventListener('DOMContentLoaded', loadVerbosePreference);

// Save preference when toggle changes
document.getElementById('verbose-toggle').addEventListener('change', saveVerbosePreference);

// ── Activity Runs (Option D run-grouped view) ──────────────────────────
// Use `var` so re-execution after HTMX swap of recent_activity.html doesn't
// throw on `const` redeclaration (the partial includes its own <script>).
Expand Down
Loading