diff --git a/app/blueprints/main.py b/app/blueprints/main.py index 322c377..7d9fd45 100644 --- a/app/blueprints/main.py +++ b/app/blueprints/main.py @@ -33,7 +33,20 @@ def dashboard(): storage_mb += os.path.getsize(filepath) storage_mb = round(storage_mb / (1024 * 1024), 2) # Convert to MB - server_logs = get_recent_logs(20) + # Recent Activity. + # + # Player feedback ("Feedback received from ...") is a ~15s heartbeat: it is + # ~84% of all log rows, and 18 of the newest 20. Showing it unfiltered + # buries the events an operator actually cares about (uploads, logins, + # HTTPS changes, deployments), so the two kinds are queried separately and + # the card defaults to real activity, with heartbeats available on demand. + # + # The split is done in SQL rather than by filtering one wide window in + # Python: with a fleet of players the heartbeat rate is high enough that a + # fixed window would return mostly noise and only a handful of real events. + HEARTBEAT_PREFIX = 'Feedback received from' + activity_logs = get_recent_logs(25, exclude_prefix=HEARTBEAT_PREFIX) + heartbeat_logs = get_recent_logs(25, include_prefix=HEARTBEAT_PREFIX) # Per-playlist overview for the dashboard: which playlist holds what, and # which players consume it. This answers "where should I add media / which @@ -69,7 +82,8 @@ def dashboard(): total_playlists=total_playlists, total_content=total_content, storage_mb=storage_mb, - recent_logs=server_logs, + recent_logs=activity_logs, + heartbeat_logs=heartbeat_logs, playlist_overview=playlist_overview, unassigned_players=unassigned_players ) diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index e4840e7..4b78ff4 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -121,6 +121,57 @@ {% endif %} + + + {% if recent_logs or heartbeat_logs %} +
+

+ Recent Activity + + + + +

+ +
+ {% if recent_logs %} + {% for log in recent_logs %} +
+ + [{{ log.level.upper() }}] + + {{ log.message }} + {{ log.timestamp | localtime('%Y-%m-%d %H:%M:%S') }} +
+ {% endfor %} + {% else %} +

No activity recorded yet.

+ {% endif %} +
+ + +
+ {% endif %} @@ -347,29 +398,84 @@ body.dark-mode .player-chip.is-offline .status-dot { background: #718096; } color: var(--text-secondary); } +/* ── Recent Activity: tabbed list, scrollable so it cannot dominate ────── */ +.log-tabs { + display: flex; + gap: 0.25rem; + flex-shrink: 0; +} +.log-tab { + background: var(--bg-color); + color: var(--text-secondary); + border: 1px solid var(--border-color); + border-radius: 999px; + padding: 0.25rem 0.7rem; + font-size: 0.78rem; + font-weight: 500; + cursor: pointer; + font-family: inherit; + display: inline-flex; + align-items: center; + gap: 0.35rem; + transition: all 0.2s; +} +.log-tab:hover { + border-color: var(--primary-color); + color: var(--text-color); +} +.log-tab.is-active { + background: var(--primary-gradient); + border-color: transparent; + color: #fff; +} +.log-count { + background: rgba(0, 0, 0, 0.12); + border-radius: 999px; + padding: 0 0.4rem; + font-size: 0.72rem; + line-height: 1.5; +} +.log-tab.is-active .log-count { + background: rgba(255, 255, 255, 0.25); +} + +/* Cap the height and scroll: a fixed number of rows stay readable. */ +.log-pane { + max-height: 320px; + overflow-y: auto; + margin-top: 0.5rem; +} .log-item { - padding: 0.5rem; + padding: 0.5rem 0.25rem; border-bottom: 1px solid var(--border-color); + font-size: 0.86rem; + line-height: 1.5; + display: flex; + align-items: baseline; + gap: 0.5rem; + flex-wrap: wrap; +} +.log-item:last-child { + border-bottom: none; +} +.log-level { + font-weight: 700; + flex-shrink: 0; +} +.log-message { + /* Long messages wrap instead of pushing the timestamp off-screen. */ + flex: 1 1 auto; + min-width: 0; + word-break: break-word; +} +.log-time { + margin-left: auto; + flex-shrink: 0; + font-variant-numeric: tabular-nums; + white-space: nowrap; } -{% if recent_logs %} -
-

Recent Activity

-
- {% for log in recent_logs %} -
- - [{{ log.level.upper() }}] - - {{ log.message }} - {{ log.timestamp | localtime('%Y-%m-%d %H:%M:%S') }} -
- {% endfor %} -
-
-{% endif %} -

System Status

✅ All systems operational

@@ -377,4 +483,27 @@ body.dark-mode .player-chip.is-offline .status-dot { background: #718096; }

🔄 Groups removed - Streamlined workflow

⚡ DigiServer v2.0

+ + {% endblock %} diff --git a/app/utils/logger.py b/app/utils/logger.py index 4f8cc73..f9383b2 100644 --- a/app/utils/logger.py +++ b/app/utils/logger.py @@ -23,21 +23,42 @@ def log_action(level: str, message: str) -> None: db.session.rollback() -def get_recent_logs(limit: int = 20, level: Optional[str] = None) -> list: +def get_recent_logs(limit: int = 20, level: Optional[str] = None, + exclude_prefix: Optional[str] = None, + include_prefix: Optional[str] = None) -> list: """Get the most recent log entries. - + Args: limit: Maximum number of logs to return level: Optional filter by log level - + exclude_prefix: Drop entries whose message starts with this text. + Useful for high-frequency noise: player feedback alone is ~84% of + all rows, so over-fetching a window and filtering in Python makes + the number of *useful* entries depend on heartbeat volume. Filtering + in SQL keeps a busy fleet from starving the result. + include_prefix: Keep only entries whose message starts with this text. + Returns: - List of ServerLog instances + List of ServerLog instances, newest first + + Note: + Prefixes are escaped before reaching SQL LIKE (via ``autoescape``), so a + literal '%' or '_' inside the prefix matches itself rather than acting + as a wildcard. """ query = ServerLog.query if level: query = query.filter_by(level=level) + if exclude_prefix: + query = query.filter( + ~ServerLog.message.startswith(exclude_prefix, autoescape=True)) + + if include_prefix: + query = query.filter( + ServerLog.message.startswith(include_prefix, autoescape=True)) + return query.order_by(ServerLog.timestamp.desc()).limit(limit).all()