From ef61e275b24fc259ebd2582195aff35b19aae869 Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Sun, 22 Feb 2026 19:52:41 -0600 Subject: [PATCH] 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 --- src/haunt_fm/api/status_page.py | 127 ++++++++++- src/haunt_fm/templates/status.html | 329 ++++++++++++++++++++--------- 2 files changed, 354 insertions(+), 102 deletions(-) diff --git a/src/haunt_fm/api/status_page.py b/src/haunt_fm/api/status_page.py index 8b2e164..f18809d 100644 --- a/src/haunt_fm/api/status_page.py +++ b/src/haunt_fm/api/status_page.py @@ -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) diff --git a/src/haunt_fm/templates/status.html b/src/haunt_fm/templates/status.html index 2e06c45..5df00bb 100644 --- a/src/haunt_fm/templates/status.html +++ b/src/haunt_fm/templates/status.html @@ -3,135 +3,262 @@ - haunt-fm status + haunt-fm dashboard -

haunt-fm

- - {{ 'Healthy' if data.healthy else 'Degraded' }} - +
+

haunt-fm

+ + {{ 'Healthy' if data.healthy else 'Degraded' }} + +
+
-

Pipeline

-
- Database - {{ 'Connected' if data.db_connected else 'Down' }} +

Recent Listens

+ {% if recent_listens %} + {% for listen in recent_listens %} +
+ {{ listen.title }} + — {{ listen.artist }} +
{{ listen.speaker }} · {{ listen.listened_at | timeago }}
+
+ {% endfor %} + {% else %} +
No listens recorded yet
+ {% endif %} +
+ + +
+

Profiles

+ {% if profiles %} + {% for profile in profiles %} +
+
{{ profile.name }}
+
+ {{ profile.event_count }} events · {{ profile.track_count }} tracks + · last: {{ profile.last_listen | timeago }} +
+ {% if profile.speakers %} +
speakers: {{ profile.speakers | join(', ') }}
+ {% endif %} +
+ {% endfor %} + {% else %} +
No profiles created yet
+ {% endif %} +
+ + +
+
+

Pipeline

+
+ Database + {{ 'Connected' if data.db_connected else 'Down' }} +
+
+ CLAP Model + {{ 'Loaded' if data.clap_model_loaded else 'Not loaded' }} +
+
+ Embed Worker + {{ 'Running' if data.pipeline.embeddings.worker_running else 'Stopped' }} +
-
- CLAP Model - {{ 'Loaded' if data.clap_model_loaded else 'Not loaded' }} + +
+

Tracks

+
+ Total + {{ data.pipeline.tracks.total }} +
+
+ From history + {{ data.pipeline.tracks.from_history }} +
+
+ From discovery + {{ data.pipeline.tracks.from_discovery }} +
-
-

Listening History

-
- Total events - {{ data.pipeline.listen_events.total }} + +
+
+

Embeddings

+ {% 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 %} +
+
+
+
+ Progress + {{ emb_pct }}% ({{ emb.done }}/{{ emb_total }}) +
+
+ Pending + {{ emb.pending }} +
+
+ Failed + {{ emb.failed }} +
+
+ No preview + {{ emb.no_preview }} +
-
- Last 24h - {{ data.pipeline.listen_events.last_24h }} -
-
- Latest - {{ data.pipeline.listen_events.latest or 'Never' }} + +
+

Playlists

+
+ Generated + {{ data.pipeline.playlists.total_generated }} +
+
+ Last generated + {{ data.pipeline.playlists.last_generated | timeago }} +
+ {% if recent_playlists %} +

Recent

+ {% for pl in recent_playlists %} +
+
{{ pl.name }} ({{ pl.tracks }} tracks, {{ pl.known_pct }}% known)
+
{{ pl.created_at | timeago }}
+
+ {% endfor %} + {% endif %}
+
-

Tracks

-
- Total - {{ data.pipeline.tracks.total }} -
-
- From history - {{ data.pipeline.tracks.from_history }} -
-
- From discovery - {{ data.pipeline.tracks.from_discovery }} -
-
- -
-

Embeddings

-
- Worker - {{ 'Running' if data.pipeline.embeddings.worker_running else 'Stopped' }} -
-
- Done - {{ data.pipeline.embeddings.done }} -
-
- Pending - {{ data.pipeline.embeddings.pending }} -
-
- Failed - {{ data.pipeline.embeddings.failed }} -
-
- No preview - {{ data.pipeline.embeddings.no_preview }} -
-
- -
-

Taste Profile

-
- Profile - {{ 'Built (' ~ data.pipeline.taste_profile.track_count ~ ' tracks)' if data.pipeline.taste_profile.exists else 'Not built' }} -
-
- -
-

Playlists

-
- Generated - {{ data.pipeline.playlists.total_generated }} -
-
- Last generated - {{ data.pipeline.playlists.last_generated or 'Never' }} -
+

Taste Profiles

+ {% if profiles %} + {% for profile in profiles %} + {% set taste = taste_profiles.get(profile.id) %} +
+ {{ profile.name }} + + {% if taste %} + Built ({{ taste.track_count }} tracks) · {{ taste.updated_at | timeago }} + {% else %} + Not built + {% endif %} + +
+ {% endfor %} + {% else %} +
+ Default + + {{ 'Built (' ~ data.pipeline.taste_profile.track_count ~ ' tracks)' if data.pipeline.taste_profile.exists else 'Not built' }} + +
+ {% endif %}
+

Dependencies

-
- Last.fm API - {{ data.dependencies.lastfm_api }} -
-
- iTunes API - {{ data.dependencies.itunes_api }} -
-
+
+ Last.fm + iTunes Home Assistant - {{ 'Reachable' if data.dependencies.ha_reachable else 'Unknown' }} + {% if data.dependencies.music_assistant_reachable is defined %} + Music Assistant + {% endif %}