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:
2026-02-22 19:52:41 -06:00
parent 094621a9a8
commit ef61e275b2
2 changed files with 354 additions and 102 deletions

View File

@@ -4,10 +4,19 @@ from pathlib import Path
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from jinja2 import Environment, FileSystemLoader from jinja2 import Environment, FileSystemLoader
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from haunt_fm.api.status import status as get_status_data from haunt_fm.api.status import status as get_status_data
from haunt_fm.db import get_session from haunt_fm.db import get_session
from haunt_fm.models.track import (
ListenEvent,
Playlist,
Profile,
SpeakerProfileMapping,
TasteProfile,
Track,
)
router = APIRouter() router = APIRouter()
@@ -15,9 +24,125 @@ _template_dir = Path(__file__).parent.parent / "templates"
_jinja_env = Environment(loader=FileSystemLoader(str(_template_dir)), autoescape=True) _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) @router.get("/", response_class=HTMLResponse)
async def status_page(request: Request, session: AsyncSession = Depends(get_session)): async def status_page(request: Request, session: AsyncSession = Depends(get_session)):
data = await get_status_data(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") 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) return HTMLResponse(html)

View File

@@ -3,34 +3,142 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>haunt-fm status</title> <title>haunt-fm dashboard</title>
<style> <style>
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, monospace; background: #0d1117; color: #c9d1d9; padding: 2rem; } body {
h1 { color: #58a6ff; margin-bottom: 1.5rem; font-size: 1.5rem; } font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, monospace;
.status-badge { display: inline-block; padding: 0.25rem 0.75rem; border-radius: 1rem; font-size: 0.85rem; font-weight: 600; margin-bottom: 1.5rem; } 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.healthy { background: #238636; color: #fff; }
.status-badge.degraded { background: #da3633; 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; } /* Sections */
.row { display: flex; justify-content: space-between; padding: 0.35rem 0; border-bottom: 1px solid #21262d; } .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; } .row:last-child { border-bottom: none; }
.label { color: #8b949e; } .label { color: #8b949e; font-size: 0.9rem; }
.value { color: #c9d1d9; font-weight: 500; } .value { color: #c9d1d9; font-weight: 500; font-size: 0.9rem; }
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 0.4rem; vertical-align: middle; }
/* 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.green { background: #3fb950; }
.dot.red { background: #f85149; } .dot.red { background: #f85149; }
.dot.yellow { background: #d29922; } .dot.yellow { background: #d29922; }
.dot.gray { background: #484f58; } .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> </style>
</head> </head>
<body> <body>
<div class="header">
<h1>haunt-fm</h1> <h1>haunt-fm</h1>
<span class="status-badge {{ 'healthy' if data.healthy else 'degraded' }}"> <span class="status-badge {{ 'healthy' if data.healthy else 'degraded' }}">
{{ 'Healthy' if data.healthy else 'Degraded' }} {{ 'Healthy' if data.healthy else 'Degraded' }}
</span> </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"> &mdash; {{ listen.artist }}</span>
<div class="listen-meta">{{ listen.speaker }} &middot; {{ 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 &middot; {{ profile.track_count }} tracks
&middot; 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"> <div class="section">
<h2>Pipeline</h2> <h2>Pipeline</h2>
<div class="row"> <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="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> <span class="value">{{ 'Loaded' if data.clap_model_loaded else 'Not loaded' }}</span>
</div> </div>
</div>
<div class="section">
<h2>Listening History</h2>
<div class="row"> <div class="row">
<span class="label">Total events</span> <span class="label"><span class="dot {{ 'green' if data.pipeline.embeddings.worker_running else 'gray' }}"></span>Embed Worker</span>
<span class="value">{{ data.pipeline.listen_events.total }}</span> <span class="value">{{ 'Running' if data.pipeline.embeddings.worker_running else 'Stopped' }}</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>
</div> </div>
</div> </div>
@@ -74,36 +170,33 @@
<span class="value">{{ data.pipeline.tracks.from_discovery }}</span> <span class="value">{{ data.pipeline.tracks.from_discovery }}</span>
</div> </div>
</div> </div>
</div>
<!-- Embeddings + Playlists grid -->
<div class="grid">
<div class="section"> <div class="section">
<h2>Embeddings</h2> <h2>Embeddings</h2>
<div class="row"> {% set emb = data.pipeline.embeddings %}
<span class="label"><span class="dot {{ 'green' if data.pipeline.embeddings.worker_running else 'gray' }}"></span>Worker</span> {% set emb_total = emb.done + emb.pending + emb.failed + emb.no_preview %}
<span class="value">{{ 'Running' if data.pipeline.embeddings.worker_running else 'Stopped' }}</span> {% 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>
<div class="row"> <div class="row">
<span class="label">Done</span> <span class="label">Progress</span>
<span class="value">{{ data.pipeline.embeddings.done }}</span> <span class="value">{{ emb_pct }}% ({{ emb.done }}/{{ emb_total }})</span>
</div> </div>
<div class="row"> <div class="row">
<span class="label">Pending</span> <span class="label">Pending</span>
<span class="value">{{ data.pipeline.embeddings.pending }}</span> <span class="value">{{ emb.pending }}</span>
</div> </div>
<div class="row"> <div class="row">
<span class="label">Failed</span> <span class="label">Failed</span>
<span class="value">{{ data.pipeline.embeddings.failed }}</span> <span class="value">{{ emb.failed }}</span>
</div> </div>
<div class="row"> <div class="row">
<span class="label">No preview</span> <span class="label">No preview</span>
<span class="value">{{ data.pipeline.embeddings.no_preview }}</span> <span class="value">{{ emb.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>
</div> </div>
</div> </div>
@@ -115,23 +208,57 @@
</div> </div>
<div class="row"> <div class="row">
<span class="label">Last generated</span> <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>
</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) &middot; {{ 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"> <div class="section">
<h2>Dependencies</h2> <h2>Dependencies</h2>
<div class="row"> <div class="deps-row">
<span class="label"><span class="dot {{ 'green' if data.dependencies.lastfm_api == 'ok' else 'gray' }}"></span>Last.fm API</span> <span class="label"><span class="dot {{ 'green' if data.dependencies.lastfm_api == 'configured' else 'gray' }}"></span>Last.fm</span>
<span class="value">{{ data.dependencies.lastfm_api }}</span> <span class="label"><span class="dot {{ 'green' if data.dependencies.itunes_api == 'ok' else 'gray' }}"></span>iTunes</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">
<span class="label"><span class="dot {{ 'green' if data.dependencies.ha_reachable else 'gray' }}"></span>Home Assistant</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>
</div> </div>