Redesign status page as full dashboard
Add recent listens, profiles, taste profiles, and recent playlists to the status page. Two-column responsive grid layout with progress bar for embeddings and relative timestamps throughout. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,10 +4,19 @@ from pathlib import Path
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from haunt_fm.api.status import status as get_status_data
|
||||
from haunt_fm.db import get_session
|
||||
from haunt_fm.models.track import (
|
||||
ListenEvent,
|
||||
Playlist,
|
||||
Profile,
|
||||
SpeakerProfileMapping,
|
||||
TasteProfile,
|
||||
Track,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -15,9 +24,125 @@ _template_dir = Path(__file__).parent.parent / "templates"
|
||||
_jinja_env = Environment(loader=FileSystemLoader(str(_template_dir)), autoescape=True)
|
||||
|
||||
|
||||
def _timeago(dt: datetime | str | None) -> str:
|
||||
"""Return a human-readable relative time string like '2 min ago'."""
|
||||
if dt is None:
|
||||
return "never"
|
||||
if isinstance(dt, str):
|
||||
try:
|
||||
dt = datetime.fromisoformat(dt)
|
||||
except ValueError:
|
||||
return dt
|
||||
now = datetime.now(timezone.utc)
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
diff = now - dt
|
||||
seconds = int(diff.total_seconds())
|
||||
if seconds < 60:
|
||||
return "just now"
|
||||
minutes = seconds // 60
|
||||
if minutes < 60:
|
||||
return f"{minutes} min ago"
|
||||
hours = minutes // 60
|
||||
if hours < 24:
|
||||
return f"{hours} hr ago"
|
||||
days = hours // 24
|
||||
if days < 30:
|
||||
return f"{days}d ago"
|
||||
return dt.strftime("%b %d")
|
||||
|
||||
|
||||
_jinja_env.filters["timeago"] = _timeago
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def status_page(request: Request, session: AsyncSession = Depends(get_session)):
|
||||
data = await get_status_data(session)
|
||||
|
||||
# Recent listens (last 10) with track info
|
||||
recent_rows = (
|
||||
await session.execute(
|
||||
select(ListenEvent, Track)
|
||||
.join(Track, ListenEvent.track_id == Track.id)
|
||||
.order_by(ListenEvent.listened_at.desc())
|
||||
.limit(10)
|
||||
)
|
||||
).all()
|
||||
recent_listens = [
|
||||
{
|
||||
"title": track.title,
|
||||
"artist": track.artist,
|
||||
"speaker": event.speaker_name or "Unknown",
|
||||
"listened_at": event.listened_at,
|
||||
}
|
||||
for event, track in recent_rows
|
||||
]
|
||||
|
||||
# Profiles with event/track counts and last listen
|
||||
profile_rows = (
|
||||
await session.execute(
|
||||
select(
|
||||
Profile,
|
||||
func.count(ListenEvent.id).label("event_count"),
|
||||
func.count(func.distinct(ListenEvent.track_id)).label("track_count"),
|
||||
func.max(ListenEvent.listened_at).label("last_listen"),
|
||||
)
|
||||
.outerjoin(ListenEvent, ListenEvent.profile_id == Profile.id)
|
||||
.group_by(Profile.id)
|
||||
.order_by(Profile.created_at)
|
||||
)
|
||||
).all()
|
||||
|
||||
# Speaker mappings keyed by profile_id
|
||||
mapping_rows = (await session.execute(select(SpeakerProfileMapping))).scalars().all()
|
||||
speakers_by_profile: dict[int, list[str]] = {}
|
||||
for m in mapping_rows:
|
||||
speakers_by_profile.setdefault(m.profile_id, []).append(m.speaker_name)
|
||||
|
||||
profiles = [
|
||||
{
|
||||
"id": profile.id,
|
||||
"name": profile.display_name or profile.name,
|
||||
"event_count": event_count,
|
||||
"track_count": track_count,
|
||||
"last_listen": last_listen,
|
||||
"speakers": speakers_by_profile.get(profile.id, []),
|
||||
}
|
||||
for profile, event_count, track_count, last_listen in profile_rows
|
||||
]
|
||||
|
||||
# Taste profiles keyed by profile_id
|
||||
taste_rows = (await session.execute(select(TasteProfile))).scalars().all()
|
||||
taste_by_profile_id: dict[int | None, dict] = {}
|
||||
for tp in taste_rows:
|
||||
taste_by_profile_id[tp.profile_id] = {
|
||||
"track_count": tp.track_count,
|
||||
"updated_at": tp.updated_at,
|
||||
}
|
||||
|
||||
# Recent playlists (last 5)
|
||||
playlist_rows = (
|
||||
await session.execute(
|
||||
select(Playlist).order_by(Playlist.created_at.desc()).limit(5)
|
||||
)
|
||||
).scalars().all()
|
||||
recent_playlists = [
|
||||
{
|
||||
"name": p.name or f"Playlist #{p.id}",
|
||||
"tracks": p.total_tracks,
|
||||
"known_pct": p.known_pct,
|
||||
"created_at": p.created_at,
|
||||
}
|
||||
for p in playlist_rows
|
||||
]
|
||||
|
||||
template = _jinja_env.get_template("status.html")
|
||||
html = template.render(data=data, now=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"))
|
||||
html = template.render(
|
||||
data=data,
|
||||
recent_listens=recent_listens,
|
||||
profiles=profiles,
|
||||
taste_profiles=taste_by_profile_id,
|
||||
recent_playlists=recent_playlists,
|
||||
now=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"),
|
||||
)
|
||||
return HTMLResponse(html)
|
||||
|
||||
@@ -3,34 +3,142 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>haunt-fm status</title>
|
||||
<title>haunt-fm dashboard</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, monospace; background: #0d1117; color: #c9d1d9; padding: 2rem; }
|
||||
h1 { color: #58a6ff; margin-bottom: 1.5rem; font-size: 1.5rem; }
|
||||
.status-badge { display: inline-block; padding: 0.25rem 0.75rem; border-radius: 1rem; font-size: 0.85rem; font-weight: 600; margin-bottom: 1.5rem; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, monospace;
|
||||
background: #0d1117; color: #c9d1d9; padding: 1.5rem;
|
||||
max-width: 800px; margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem; }
|
||||
.header h1 { color: #58a6ff; font-size: 1.5rem; }
|
||||
.status-badge {
|
||||
display: inline-block; padding: 0.25rem 0.75rem; border-radius: 1rem;
|
||||
font-size: 0.8rem; font-weight: 600;
|
||||
}
|
||||
.status-badge.healthy { background: #238636; color: #fff; }
|
||||
.status-badge.degraded { background: #da3633; color: #fff; }
|
||||
.section { background: #161b22; border: 1px solid #30363d; border-radius: 0.5rem; padding: 1.25rem; margin-bottom: 1rem; }
|
||||
.section h2 { color: #8b949e; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.75rem; }
|
||||
.row { display: flex; justify-content: space-between; padding: 0.35rem 0; border-bottom: 1px solid #21262d; }
|
||||
|
||||
/* Sections */
|
||||
.section {
|
||||
background: #161b22; border: 1px solid #30363d; border-radius: 0.5rem;
|
||||
padding: 1.25rem; margin-bottom: 1rem;
|
||||
}
|
||||
.section h2 {
|
||||
color: #8b949e; font-size: 0.75rem; text-transform: uppercase;
|
||||
letter-spacing: 0.08em; margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
/* Rows */
|
||||
.row {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 0.3rem 0; border-bottom: 1px solid #21262d;
|
||||
}
|
||||
.row:last-child { border-bottom: none; }
|
||||
.label { color: #8b949e; }
|
||||
.value { color: #c9d1d9; font-weight: 500; }
|
||||
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 0.4rem; vertical-align: middle; }
|
||||
.label { color: #8b949e; font-size: 0.9rem; }
|
||||
.value { color: #c9d1d9; font-weight: 500; font-size: 0.9rem; }
|
||||
|
||||
/* Status dots */
|
||||
.dot {
|
||||
display: inline-block; width: 8px; height: 8px; border-radius: 50%;
|
||||
margin-right: 0.4rem; vertical-align: middle;
|
||||
}
|
||||
.dot.green { background: #3fb950; }
|
||||
.dot.red { background: #f85149; }
|
||||
.dot.yellow { background: #d29922; }
|
||||
.dot.gray { background: #484f58; }
|
||||
.timestamp { color: #484f58; font-size: 0.8rem; margin-top: 1.5rem; text-align: center; }
|
||||
|
||||
/* Listen items */
|
||||
.listen-item { padding: 0.5rem 0; border-bottom: 1px solid #21262d; }
|
||||
.listen-item:last-child { border-bottom: none; }
|
||||
.listen-title { color: #c9d1d9; font-weight: 500; font-size: 0.9rem; }
|
||||
.listen-artist { color: #8b949e; font-size: 0.9rem; }
|
||||
.listen-meta { color: #484f58; font-size: 0.8rem; margin-top: 0.15rem; }
|
||||
|
||||
/* Grid */
|
||||
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
|
||||
@media (max-width: 600px) { .grid { grid-template-columns: 1fr; } }
|
||||
|
||||
/* Progress bar */
|
||||
.progress-bar {
|
||||
width: 100%; height: 6px; background: #21262d; border-radius: 3px;
|
||||
margin: 0.5rem 0; overflow: hidden;
|
||||
}
|
||||
.progress-fill { height: 100%; background: #3fb950; border-radius: 3px; transition: width 0.3s; }
|
||||
|
||||
/* Profile cards */
|
||||
.profile-card { padding: 0.6rem 0; border-bottom: 1px solid #21262d; }
|
||||
.profile-card:last-child { border-bottom: none; }
|
||||
.profile-name { color: #58a6ff; font-weight: 600; font-size: 0.95rem; }
|
||||
.profile-stats { color: #8b949e; font-size: 0.85rem; margin-top: 0.15rem; }
|
||||
.profile-speakers { color: #484f58; font-size: 0.8rem; margin-top: 0.1rem; }
|
||||
|
||||
/* Playlist items */
|
||||
.playlist-item { padding: 0.4rem 0; border-bottom: 1px solid #21262d; }
|
||||
.playlist-item:last-child { border-bottom: none; }
|
||||
.playlist-name { color: #c9d1d9; font-size: 0.9rem; }
|
||||
.playlist-meta { color: #484f58; font-size: 0.8rem; }
|
||||
|
||||
/* Dependencies row */
|
||||
.deps-row { display: flex; flex-wrap: wrap; gap: 1rem; padding: 0.3rem 0; }
|
||||
|
||||
/* Footer */
|
||||
.timestamp { color: #484f58; font-size: 0.8rem; margin-top: 1rem; text-align: center; }
|
||||
|
||||
/* Empty state */
|
||||
.empty { color: #484f58; font-style: italic; font-size: 0.85rem; padding: 0.5rem 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>haunt-fm</h1>
|
||||
<span class="status-badge {{ 'healthy' if data.healthy else 'degraded' }}">
|
||||
{{ 'Healthy' if data.healthy else 'Degraded' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Recent Listens -->
|
||||
<div class="section">
|
||||
<h2>Recent Listens</h2>
|
||||
{% if recent_listens %}
|
||||
{% for listen in recent_listens %}
|
||||
<div class="listen-item">
|
||||
<span class="listen-title">{{ listen.title }}</span>
|
||||
<span class="listen-artist"> — {{ listen.artist }}</span>
|
||||
<div class="listen-meta">{{ listen.speaker }} · {{ listen.listened_at | timeago }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="empty">No listens recorded yet</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Profiles -->
|
||||
<div class="section">
|
||||
<h2>Profiles</h2>
|
||||
{% if profiles %}
|
||||
{% for profile in profiles %}
|
||||
<div class="profile-card">
|
||||
<div class="profile-name">{{ profile.name }}</div>
|
||||
<div class="profile-stats">
|
||||
{{ profile.event_count }} events · {{ profile.track_count }} tracks
|
||||
· last: {{ profile.last_listen | timeago }}
|
||||
</div>
|
||||
{% if profile.speakers %}
|
||||
<div class="profile-speakers">speakers: {{ profile.speakers | join(', ') }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="empty">No profiles created yet</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Pipeline + Tracks grid -->
|
||||
<div class="grid">
|
||||
<div class="section">
|
||||
<h2>Pipeline</h2>
|
||||
<div class="row">
|
||||
@@ -41,21 +149,9 @@
|
||||
<span class="label"><span class="dot {{ 'green' if data.clap_model_loaded else 'gray' }}"></span>CLAP Model</span>
|
||||
<span class="value">{{ 'Loaded' if data.clap_model_loaded else 'Not loaded' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Listening History</h2>
|
||||
<div class="row">
|
||||
<span class="label">Total events</span>
|
||||
<span class="value">{{ data.pipeline.listen_events.total }}</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="label">Last 24h</span>
|
||||
<span class="value">{{ data.pipeline.listen_events.last_24h }}</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="label">Latest</span>
|
||||
<span class="value">{{ data.pipeline.listen_events.latest or 'Never' }}</span>
|
||||
<span class="label"><span class="dot {{ 'green' if data.pipeline.embeddings.worker_running else 'gray' }}"></span>Embed Worker</span>
|
||||
<span class="value">{{ 'Running' if data.pipeline.embeddings.worker_running else 'Stopped' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -74,36 +170,33 @@
|
||||
<span class="value">{{ data.pipeline.tracks.from_discovery }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Embeddings + Playlists grid -->
|
||||
<div class="grid">
|
||||
<div class="section">
|
||||
<h2>Embeddings</h2>
|
||||
<div class="row">
|
||||
<span class="label"><span class="dot {{ 'green' if data.pipeline.embeddings.worker_running else 'gray' }}"></span>Worker</span>
|
||||
<span class="value">{{ 'Running' if data.pipeline.embeddings.worker_running else 'Stopped' }}</span>
|
||||
{% set emb = data.pipeline.embeddings %}
|
||||
{% set emb_total = emb.done + emb.pending + emb.failed + emb.no_preview %}
|
||||
{% set emb_pct = ((emb.done / emb_total) * 100) | int if emb_total > 0 else 0 %}
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width: {{ emb_pct }}%"></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="label">Done</span>
|
||||
<span class="value">{{ data.pipeline.embeddings.done }}</span>
|
||||
<span class="label">Progress</span>
|
||||
<span class="value">{{ emb_pct }}% ({{ emb.done }}/{{ emb_total }})</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="label">Pending</span>
|
||||
<span class="value">{{ data.pipeline.embeddings.pending }}</span>
|
||||
<span class="value">{{ emb.pending }}</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="label">Failed</span>
|
||||
<span class="value">{{ data.pipeline.embeddings.failed }}</span>
|
||||
<span class="value">{{ emb.failed }}</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="label">No preview</span>
|
||||
<span class="value">{{ data.pipeline.embeddings.no_preview }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Taste Profile</h2>
|
||||
<div class="row">
|
||||
<span class="label"><span class="dot {{ 'green' if data.pipeline.taste_profile.exists else 'gray' }}"></span>Profile</span>
|
||||
<span class="value">{{ 'Built (' ~ data.pipeline.taste_profile.track_count ~ ' tracks)' if data.pipeline.taste_profile.exists else 'Not built' }}</span>
|
||||
<span class="value">{{ emb.no_preview }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -115,23 +208,57 @@
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="label">Last generated</span>
|
||||
<span class="value">{{ data.pipeline.playlists.last_generated or 'Never' }}</span>
|
||||
<span class="value">{{ data.pipeline.playlists.last_generated | timeago }}</span>
|
||||
</div>
|
||||
{% if recent_playlists %}
|
||||
<h2 style="margin-top: 0.75rem;">Recent</h2>
|
||||
{% for pl in recent_playlists %}
|
||||
<div class="playlist-item">
|
||||
<div class="playlist-name">{{ pl.name }} ({{ pl.tracks }} tracks, {{ pl.known_pct }}% known)</div>
|
||||
<div class="playlist-meta">{{ pl.created_at | timeago }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Taste Profiles -->
|
||||
<div class="section">
|
||||
<h2>Taste Profiles</h2>
|
||||
{% if profiles %}
|
||||
{% for profile in profiles %}
|
||||
{% set taste = taste_profiles.get(profile.id) %}
|
||||
<div class="row">
|
||||
<span class="label"><span class="dot {{ 'green' if taste else 'gray' }}"></span>{{ profile.name }}</span>
|
||||
<span class="value">
|
||||
{% if taste %}
|
||||
Built ({{ taste.track_count }} tracks) · {{ taste.updated_at | timeago }}
|
||||
{% else %}
|
||||
Not built
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="row">
|
||||
<span class="label"><span class="dot {{ 'green' if data.pipeline.taste_profile.exists else 'gray' }}"></span>Default</span>
|
||||
<span class="value">
|
||||
{{ 'Built (' ~ data.pipeline.taste_profile.track_count ~ ' tracks)' if data.pipeline.taste_profile.exists else 'Not built' }}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Dependencies -->
|
||||
<div class="section">
|
||||
<h2>Dependencies</h2>
|
||||
<div class="row">
|
||||
<span class="label"><span class="dot {{ 'green' if data.dependencies.lastfm_api == 'ok' else 'gray' }}"></span>Last.fm API</span>
|
||||
<span class="value">{{ data.dependencies.lastfm_api }}</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="label"><span class="dot {{ 'green' if data.dependencies.itunes_api == 'ok' else 'gray' }}"></span>iTunes API</span>
|
||||
<span class="value">{{ data.dependencies.itunes_api }}</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="deps-row">
|
||||
<span class="label"><span class="dot {{ 'green' if data.dependencies.lastfm_api == 'configured' else 'gray' }}"></span>Last.fm</span>
|
||||
<span class="label"><span class="dot {{ 'green' if data.dependencies.itunes_api == 'ok' else 'gray' }}"></span>iTunes</span>
|
||||
<span class="label"><span class="dot {{ 'green' if data.dependencies.ha_reachable else 'gray' }}"></span>Home Assistant</span>
|
||||
<span class="value">{{ 'Reachable' if data.dependencies.ha_reachable else 'Unknown' }}</span>
|
||||
{% if data.dependencies.music_assistant_reachable is defined %}
|
||||
<span class="label"><span class="dot {{ 'green' if data.dependencies.music_assistant_reachable else 'gray' }}"></span>Music Assistant</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user