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.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)

View File

@@ -3,135 +3,262 @@
<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>
<h1>haunt-fm</h1>
<span class="status-badge {{ 'healthy' if data.healthy else 'degraded' }}">
{{ 'Healthy' if data.healthy else 'Degraded' }}
</span>
<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>Pipeline</h2>
<div class="row">
<span class="label"><span class="dot {{ 'green' if data.db_connected else 'red' }}"></span>Database</span>
<span class="value">{{ 'Connected' if data.db_connected else 'Down' }}</span>
<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">
<h2>Pipeline</h2>
<div class="row">
<span class="label"><span class="dot {{ 'green' if data.db_connected else 'red' }}"></span>Database</span>
<span class="value">{{ 'Connected' if data.db_connected else 'Down' }}</span>
</div>
<div class="row">
<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 class="row">
<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>
<div class="row">
<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 class="section">
<h2>Tracks</h2>
<div class="row">
<span class="label">Total</span>
<span class="value">{{ data.pipeline.tracks.total }}</span>
</div>
<div class="row">
<span class="label">From history</span>
<span class="value">{{ data.pipeline.tracks.from_history }}</span>
</div>
<div class="row">
<span class="label">From discovery</span>
<span class="value">{{ data.pipeline.tracks.from_discovery }}</span>
</div>
</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>
<!-- Embeddings + Playlists grid -->
<div class="grid">
<div class="section">
<h2>Embeddings</h2>
{% 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">Progress</span>
<span class="value">{{ emb_pct }}% ({{ emb.done }}/{{ emb_total }})</span>
</div>
<div class="row">
<span class="label">Pending</span>
<span class="value">{{ emb.pending }}</span>
</div>
<div class="row">
<span class="label">Failed</span>
<span class="value">{{ emb.failed }}</span>
</div>
<div class="row">
<span class="label">No preview</span>
<span class="value">{{ emb.no_preview }}</span>
</div>
</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 class="section">
<h2>Playlists</h2>
<div class="row">
<span class="label">Generated</span>
<span class="value">{{ data.pipeline.playlists.total_generated }}</span>
</div>
<div class="row">
<span class="label">Last generated</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>Tracks</h2>
<div class="row">
<span class="label">Total</span>
<span class="value">{{ data.pipeline.tracks.total }}</span>
</div>
<div class="row">
<span class="label">From history</span>
<span class="value">{{ data.pipeline.tracks.from_history }}</span>
</div>
<div class="row">
<span class="label">From discovery</span>
<span class="value">{{ data.pipeline.tracks.from_discovery }}</span>
</div>
</div>
<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>
</div>
<div class="row">
<span class="label">Done</span>
<span class="value">{{ data.pipeline.embeddings.done }}</span>
</div>
<div class="row">
<span class="label">Pending</span>
<span class="value">{{ data.pipeline.embeddings.pending }}</span>
</div>
<div class="row">
<span class="label">Failed</span>
<span class="value">{{ data.pipeline.embeddings.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>
</div>
</div>
<div class="section">
<h2>Playlists</h2>
<div class="row">
<span class="label">Generated</span>
<span class="value">{{ data.pipeline.playlists.total_generated }}</span>
</div>
<div class="row">
<span class="label">Last generated</span>
<span class="value">{{ data.pipeline.playlists.last_generated or 'Never' }}</span>
</div>
<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">
<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>