Add playlists, credentials, and jobs pages; wire up dashboard
Vendored htmx.min.js (1.9.12) and alpine.min.js (3.14.1) into static/ -- self-hosted per the no-CDN/CSP requirement, included in base.html. routers/playlists.py: list/add/remove playlists, a detail page showing live per-playlist status via status_service (Spotify vs. beets vs. sldl index vs. quarantine), edit form for active/cron_expr/no_m3u/notes, and a "Run now" button wired to the jobs run-now endpoint. routers/credentials.py: write-only credential forms per scope (a saved secret is never sent back to the browser -- fields just show "(set)" and leaving a field blank keeps its current value, matching credential_service.set_credentials' partial-update semantics). routers/jobs.py: maintenance job list with run-now/enable-disable, and a recent-runs table that auto-refreshes via HTMX polling (hx-trigger="every 5s") against a partial-only endpoint -- the one place in the UI where avoiding a full-page reload actually matters, since job status changes while the page is sitting open. Per-run log viewing. Dashboard now shows real data (beets stats, playlist counts, recent job runs) instead of the placeholder from Task 2. Verified all 10 authenticated pages render successfully (200, no template errors) via TestClient with require_auth overridden, including the playlist detail page's graceful-degradation path when Spotify credentials aren't configured yet (renders a "couldn't fetch live status" message instead of a 500).
This commit is contained in:
@@ -5,6 +5,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}alembic{% endblock %}</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
<script src="/static/alpine.min.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Credentials — alembic{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Credentials</h1>
|
||||
<p class="muted">
|
||||
Write-only: saved values are encrypted at rest and never sent back to the
|
||||
browser. Leave a field blank to keep its current value unchanged.
|
||||
</p>
|
||||
|
||||
{% for scope, fields in scope_fields.items() %}
|
||||
<h2>{{ scope }}</h2>
|
||||
<form method="post" action="/settings/credentials/{{ scope }}">
|
||||
{% for field in fields %}
|
||||
<div>
|
||||
<label for="{{ scope }}_{{ field }}">
|
||||
{{ field }}
|
||||
{% if field in configured.get(scope, []) %}<span class="muted">(set)</span>{% endif %}
|
||||
</label>
|
||||
{% if field in ("cookies_txt",) %}
|
||||
<textarea id="{{ scope }}_{{ field }}" name="{{ field }}" placeholder="leave blank to keep current"></textarea>
|
||||
{% else %}
|
||||
<input type="password" id="{{ scope }}_{{ field }}" name="{{ field }}" placeholder="leave blank to keep current" autocomplete="off">
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
<button type="submit">Save {{ scope }}</button>
|
||||
</form>
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
@@ -2,9 +2,29 @@
|
||||
{% block title %}Dashboard — alembic{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Dashboard</h1>
|
||||
<p class="muted">
|
||||
Logged in as {{ user.name or user.email }}. Playlist status, job history,
|
||||
and pipeline health will render here once the corresponding services are
|
||||
built (status_service, scheduler_service).
|
||||
</p>
|
||||
<p class="muted">Logged in as {{ user.name or user.email }}.</p>
|
||||
|
||||
<h2>At a glance</h2>
|
||||
<ul>
|
||||
<li>{{ stats.total_tracks }} tracks in the beets library{% if not stats.db_exists %} <span class="muted">(beets DB not found)</span>{% endif %}</li>
|
||||
<li>{{ active_playlist_count }} / {{ playlist_count }} playlists active — <a href="/playlists">manage</a></li>
|
||||
</ul>
|
||||
|
||||
<h2>Recent job runs</h2>
|
||||
<table>
|
||||
<thead><tr><th>Job</th><th>Status</th><th>Triggered by</th><th>Summary</th></tr></thead>
|
||||
<tbody>
|
||||
{% for run in recent_runs %}
|
||||
<tr>
|
||||
<td>{{ run.job_key }}</td>
|
||||
<td>{{ run.status }}</td>
|
||||
<td class="muted">{{ run.triggered_by }}</td>
|
||||
<td>{{ run.summary or "" }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="4" class="muted">No job runs yet.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<p><a href="/jobs">See all jobs →</a></p>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{% for run in recent_runs %}
|
||||
<tr>
|
||||
<td>{{ run.job_key }}</td>
|
||||
<td>{{ run.status }}</td>
|
||||
<td class="muted">{{ run.triggered_by }}</td>
|
||||
<td>{{ run.summary or "" }}</td>
|
||||
<td>{% if run.log_path %}<a href="/jobs/runs/{{ run.id }}/log" target="_blank">log</a>{% endif %}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5" class="muted">No job runs yet.</td></tr>
|
||||
{% endfor %}
|
||||
@@ -0,0 +1,36 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Jobs — alembic{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Jobs</h1>
|
||||
|
||||
<h2>Maintenance jobs</h2>
|
||||
<table>
|
||||
<thead><tr><th>Job</th><th>Status</th><th>Next run</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for job in maintenance_jobs %}
|
||||
<tr>
|
||||
<td>{{ job.job_key }}</td>
|
||||
<td>{{ "enabled" if job.enabled else "disabled" }}</td>
|
||||
<td class="muted">{{ job.next_run or "-" }}</td>
|
||||
<td>
|
||||
<form method="post" action="/jobs/{{ job.job_key }}/run" style="display:inline">
|
||||
<button type="submit">Run now</button>
|
||||
</form>
|
||||
<form method="post" action="/jobs/{{ job.job_key }}/toggle" style="display:inline">
|
||||
<button type="submit">{{ "disable" if job.enabled else "enable" }}</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="muted">Playlist syncs are triggered from each playlist's own page.</p>
|
||||
|
||||
<h2>Recent runs</h2>
|
||||
<table>
|
||||
<thead><tr><th>Job</th><th>Status</th><th>Triggered by</th><th>Summary</th><th></th></tr></thead>
|
||||
<tbody id="runs-tbody" hx-get="/jobs/_runs_table" hx-trigger="every 5s" hx-swap="innerHTML">
|
||||
{% include "jobs/_runs_table.html" %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,44 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ playlist.name }} — alembic{% endblock %}
|
||||
{% block content %}
|
||||
<p><a href="/playlists">← back to playlists</a></p>
|
||||
<h1>{{ playlist.name }}</h1>
|
||||
<p class="muted"><a href="{{ playlist.spotify_url }}">{{ playlist.spotify_url }}</a></p>
|
||||
|
||||
<form method="post" action="/playlists/{{ playlist.id }}">
|
||||
<label><input type="checkbox" name="active" value="true" {{ "checked" if playlist.active }}> active</label>
|
||||
<label for="cron_expr">Cron schedule</label>
|
||||
<input type="text" id="cron_expr" name="cron_expr" value="{{ playlist.cron_expr or '' }}" placeholder="0 1 * * *">
|
||||
<label><input type="checkbox" name="no_m3u" value="true" {{ "checked" if playlist.no_m3u }}> skip M3U</label>
|
||||
<label for="notes">Notes</label>
|
||||
<textarea id="notes" name="notes">{{ playlist.notes or '' }}</textarea>
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
|
||||
<form method="post" action="/jobs/playlist:{{ playlist.name }}/run" style="display:inline">
|
||||
<button type="submit">Run now</button>
|
||||
</form>
|
||||
|
||||
<h2>Status</h2>
|
||||
{% if error %}
|
||||
<p class="muted">Couldn't fetch live status: {{ error }}</p>
|
||||
{% elif status %}
|
||||
<p>
|
||||
{% for key, count in status.counts.items() %}
|
||||
<strong>{{ count }}</strong> {{ key }}
|
||||
{% endfor %}
|
||||
</p>
|
||||
<table>
|
||||
<thead><tr><th>Artist</th><th>Title</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
{% for t in status.tracks %}
|
||||
<tr>
|
||||
<td>{{ t.artist }}</td>
|
||||
<td>{{ t.title }}</td>
|
||||
<td>{{ t.status }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,44 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Playlists — alembic{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Playlists</h1>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Name</th><th>Active</th><th>Cron</th><th>No M3U</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for p in playlists %}
|
||||
<tr>
|
||||
<td><a href="/playlists/{{ p.id }}">{{ p.name }}</a></td>
|
||||
<td>{{ "yes" if p.active else "no" }}</td>
|
||||
<td class="muted">{{ p.cron_expr or "(unscheduled)" }}</td>
|
||||
<td>{{ "yes" if p.no_m3u else "no" }}</td>
|
||||
<td>
|
||||
<form method="post" action="/playlists/{{ p.id }}/delete" style="display:inline"
|
||||
onsubmit="return confirm('Remove {{ p.name }}? This deletes its rendered config but not any downloaded music.')">
|
||||
<button type="submit">remove</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5" class="muted">No playlists yet.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>Add a playlist</h2>
|
||||
<form method="post" action="/playlists">
|
||||
<label for="name">Name (used as the beets grouping tag)</label>
|
||||
<input type="text" id="name" name="name" required pattern="[a-z0-9_-]+">
|
||||
|
||||
<label for="spotify_url">Spotify playlist URL</label>
|
||||
<input type="text" id="spotify_url" name="spotify_url" required>
|
||||
|
||||
<label for="cron_expr">Cron schedule (5-field, blank = unscheduled/manual-only)</label>
|
||||
<input type="text" id="cron_expr" name="cron_expr" placeholder="0 1 * * *">
|
||||
|
||||
<label><input type="checkbox" name="no_m3u" value="true"> skip M3U generation (e.g. "liked songs")</label>
|
||||
|
||||
<button type="submit">Add playlist</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user