Compare commits

..

2 Commits

Author SHA1 Message Date
ske087 b4c3f65636 Dashboard: move Recent Activity under Playlist Overview and split out heartbeats
Moves the Recent Activity card into the main column, directly beneath
Playlist Overview, so the two cards that describe "what the server and
its players are doing" sit together. System Status stays full-width below
the grid.

While moving it, the card was showing almost nothing useful: player
feedback ("Feedback received from ...") is a ~15s heartbeat that makes up
84% of all log rows (423/506) and 18 of the newest 20, so the list was a
wall of repeated one-line status reports that pushed real events off the
screen.

The card is now tabbed:
  - "Activity" (default) - uploads, logins, HTTPS changes, deployments...
  - "Player heartbeats"  - the raw status stream, on demand

get_recent_logs() gains exclude_prefix / include_prefix so the split
happens in SQL. Filtering a single wide window in Python instead gave only
7 useful entries, because the number returned depends on heartbeat volume
and would fall further as more players are added. Both queries now return
25. Prefixes are passed with autoescape=True so a literal '%' or '_' in a
prefix matches itself rather than acting as a LIKE wildcard.

UI: the list is capped at 320px and scrolls, so a long log cannot make
the card dominate the page. Long messages wrap instead of pushing the
timestamp off-screen (previously float:right), and the debug level now
has its own colour instead of rendering as "info" green.

Verified: 25/25 entries per tab, no cross-contamination, no horizontal
overflow at 500-1400px, tabs wrap below the title under ~600px, and dark
mode uses the theme variables.
2026-09-11 15:42:45 +03:00
ske087 84b5cd3d48 Dashboard: add Playlist Overview card and compact the sidebar
Restructure the dashboard into a two-column layout: a wide main column
holding the new Playlist Overview card, and a narrow (220px) right
sidebar with the compacted Quick Actions and Workflow Guide cards.

The Overview card answers "which playlist should I add media to, and
who will see it?" without opening each playlist. Per playlist it shows:
  - name, linked to that playlist's content manager
  - item count, total duration and current version
  - "N/M online" coverage, or a "no players" warning
  - one chip per assigned player, with an online/offline dot, linking
    to that player's manage page
  - an explicit empty state when no player is assigned

Players with no playlist are surfaced in a separate warning, since they
silently display nothing.

main.py builds this in a fixed number of queries: players are fetched
once and grouped by playlist_id, rather than calling
Playlist.players per row (which would be one query per playlist).

Layout notes:
- minmax(0, 1fr) on the main column so long names cannot widen the grid
- sidebar is sticky on tall screens and collapses below the main column
  under 900px, where the two small cards sit side by side
- sidebar cards used the global .card hover lift; suppressed for these

Also fixes a corrupted emoji (U+FFFD) in the System Status card.

Verified at 1600/1200/950/850/600px: no overflow, no button label
wrapping, and correct dark-mode colours.
2026-09-11 14:28:49 +03:00
3 changed files with 507 additions and 73 deletions
+46 -2
View File
@@ -33,7 +33,48 @@ 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
# playlist needs editing?" without opening each playlist.
#
# Players are fetched in a single query and grouped in Python rather than
# relying on Playlist.players per row, to avoid one query per playlist.
playlists = Playlist.query.order_by(Playlist.name).all()
players_by_playlist = {}
for player in Player.query.order_by(Player.name).all():
if player.playlist_id is not None:
players_by_playlist.setdefault(player.playlist_id, []).append(player)
playlist_overview = []
for playlist in playlists:
assigned = players_by_playlist.get(playlist.id, [])
playlist_overview.append({
'playlist': playlist,
'content_count': playlist.contents.count(),
'total_duration': playlist.total_duration,
'players': assigned,
'player_count': len(assigned),
'online_count': sum(1 for p in assigned if p.is_online),
})
# Players with no playlist produce no content on screen, so surface them.
unassigned_players = Player.query.filter(Player.playlist_id.is_(None))\
.order_by(Player.name).all()
return render_template(
'dashboard.html',
@@ -41,7 +82,10 @@ 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
)
+422 -53
View File
@@ -43,98 +43,467 @@
</div>
</div>
<div class="dash-grid">
<!-- ── Main column: where content actually lives ─────────────────────── -->
<div class="dash-main">
<div class="card">
<h2>Quick Actions</h2>
<div style="display: flex; gap: 1rem; flex-wrap: wrap; margin-top: 1rem;">
<a href="{{ url_for('players.add_player') }}" class="btn btn-success" style="display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/monitor.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
<h2 style="display: flex; align-items: center; justify-content: space-between; gap: 0.5rem;">
<span style="display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 24px; height: 24px;">
Playlist Overview
</span>
<a href="{{ url_for('content.content_list') }}" class="btn btn-sm btn-primary">Manage</a>
</h2>
<p class="secondary-text" style="font-size: 0.9rem; margin-top: -0.5rem; margin-bottom: 1rem;">
Pick a playlist to add media to or edit. Players listed here are the devices
that will show it.
</p>
{% if playlist_overview %}
{% for item in playlist_overview %}
<div class="playlist-row">
<div class="playlist-row-head">
<div style="min-width: 0;">
<a href="{{ url_for('content.manage_playlist_content', playlist_id=item.playlist.id) }}"
class="playlist-name">{{ item.playlist.name }}</a>
<div class="secondary-text" style="font-size: 0.82rem; margin-top: 2px;">
{{ item.content_count }} item{{ '' if item.content_count == 1 else 's' }}
&middot; {{ item.total_duration }}s
&middot; v{{ item.playlist.version }}
&middot;
{% if item.player_count %}
<span style="color: #27ae60; font-weight: 600;">
{{ item.online_count }}/{{ item.player_count }} online
</span>
{% else %}
<span style="color: #e67e22; font-weight: 600;">no players</span>
{% endif %}
</div>
</div>
<a href="{{ url_for('content.manage_playlist_content', playlist_id=item.playlist.id) }}"
class="btn btn-sm">Edit</a>
</div>
{% if item.players %}
<div class="player-chips">
{% for player in item.players %}
<a href="{{ url_for('players.manage_player', player_id=player.id) }}"
class="player-chip {{ 'is-online' if player.is_online else 'is-offline' }}"
title="{{ player.name }}{% if player.location %} — {{ player.location }}{% endif %} ({{ 'online' if player.is_online else 'offline' }})">
<span class="status-dot"></span>
<span class="player-chip-name">{{ player.name }}</span>
</a>
{% endfor %}
</div>
{% else %}
<p class="empty-hint">
No players assigned — content here is not shown on any screen yet.
<a href="{{ url_for('players.list') }}">Assign a player &rarr;</a>
</p>
{% endif %}
</div>
{% endfor %}
{% else %}
<p class="empty-hint">
No playlists yet.
<a href="{{ url_for('content.content_list') }}">Create your first playlist &rarr;</a>
</p>
{% endif %}
{% if unassigned_players %}
<div class="unassigned-note">
<strong>{{ unassigned_players | length }} player{{ '' if unassigned_players | length == 1 else 's' }}
not assigned to any playlist:</strong>
{% for player in unassigned_players %}
<a href="{{ url_for('players.manage_player', player_id=player.id) }}">{{ player.name }}</a>{{ ', ' if not loop.last else '' }}
{% endfor %}
— these will not display content until a playlist is assigned.
</div>
{% endif %}
</div>
<!-- Recent Activity sits under Playlist Overview: both describe what
the server and its players are actually doing. -->
{% if recent_logs or heartbeat_logs %}
<div class="card">
<h2 style="display: flex; align-items: center; justify-content: space-between; gap: 0.5rem;">
<span>Recent Activity</span>
<span class="log-tabs">
<button type="button" class="log-tab is-active" data-log-tab="activity">
Activity <span class="log-count">{{ recent_logs | length }}</span>
</button>
<button type="button" class="log-tab" data-log-tab="heartbeat">
Player heartbeats <span class="log-count">{{ heartbeat_logs | length }}</span>
</button>
</span>
</h2>
<div class="log-pane" data-log-pane="activity">
{% if recent_logs %}
{% for log in recent_logs %}
<div class="log-item">
<span class="log-level" style="color: {% if log.level == 'error' %}#e74c3c{% elif log.level == 'warning' %}#f39c12{% elif log.level == 'debug' %}#718096{% else %}#27ae60{% endif %};">
[{{ log.level.upper() }}]
</span>
<span class="log-message">{{ log.message }}</span>
<small class="secondary-text log-time">{{ log.timestamp | localtime('%Y-%m-%d %H:%M:%S') }}</small>
</div>
{% endfor %}
{% else %}
<p class="empty-hint">No activity recorded yet.</p>
{% endif %}
</div>
<div class="log-pane" data-log-pane="heartbeat" hidden>
{% if heartbeat_logs %}
<p class="empty-hint" style="margin-top: 0;">
Routine player status reports — newest first.
</p>
{% for log in heartbeat_logs %}
<div class="log-item">
<span class="log-level" style="color: #27ae60;">[{{ log.level.upper() }}]</span>
<span class="log-message">{{ log.message }}</span>
<small class="secondary-text log-time">{{ log.timestamp | localtime('%Y-%m-%d %H:%M:%S') }}</small>
</div>
{% endfor %}
{% else %}
<p class="empty-hint">No player heartbeats recorded yet.</p>
{% endif %}
</div>
</div>
{% endif %}
</div>
<!-- ── Right sidebar: small, fixed-width helper cards ─────────────────── -->
<aside class="dash-side">
<div class="card card-compact">
<h3 class="compact-title">Quick Actions</h3>
<div class="compact-actions">
<a href="{{ url_for('players.add_player') }}" class="btn btn-sm btn-success compact-btn">
<img src="{{ url_for('static', filename='icons/monitor.svg') }}" alt="" class="compact-btn-icon">
Add Player
</a>
<a href="{{ url_for('content.content_list') }}" class="btn btn-success" style="display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
<a href="{{ url_for('content.content_list') }}" class="btn btn-sm btn-success compact-btn">
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" class="compact-btn-icon">
Create Playlist
</a>
<a href="{{ url_for('content.content_list') }}" class="btn btn-success" style="display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
<a href="{{ url_for('content.upload_media_page') }}" class="btn btn-sm btn-success compact-btn">
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" class="compact-btn-icon">
Upload Media
</a>
{% if current_user.is_admin %}
<a href="{{ url_for('admin.admin_panel') }}" class="btn">Admin Panel</a>
<a href="{{ url_for('admin.admin_panel') }}" class="btn btn-sm compact-btn">
<img src="{{ url_for('static', filename='icons/info.svg') }}" alt="" class="compact-btn-icon">
Admin Panel
</a>
{% endif %}
</div>
</div>
<div class="card">
<h2 style="display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/info.svg') }}" alt="" style="width: 24px; height: 24px;">
<div class="card card-compact">
<h3 class="compact-title">
<img src="{{ url_for('static', filename='icons/info.svg') }}" alt="" style="width: 18px; height: 18px;">
Workflow Guide
</h2>
</h3>
<div class="workflow-guide">
<ol style="line-height: 2; margin: 0; padding-left: 1.5rem;">
<li><strong>Create a Playlist</strong> - Group your content into themed collections</li>
<li><strong>Upload Media</strong> - Add images, videos, or PDFs to your media library</li>
<li><strong>Add Content to Playlist</strong> - Build your playlist with drag-and-drop ordering</li>
<li><strong>Add Player</strong> - Register physical display devices</li>
<li><strong>Assign Playlist</strong> - Connect players to their playlists</li>
<li><strong>Players Auto-Download</strong> - Devices fetch and display content automatically</li>
<ol>
<li><strong>Create a Playlist</strong></li>
<li><strong>Upload Media</strong></li>
<li><strong>Add Content</strong></li>
<li><strong>Add Player</strong></li>
<li><strong>Assign Playlist</strong></li>
<li><strong>Auto-Download</strong></li>
</ol>
</div>
</div>
</aside>
</div>
<style>
/* Dashboard two-column layout: main content + narrow right sidebar.
`minmax(0, 1fr)` keeps long playlist/player names from widening the grid. */
.dash-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 220px;
gap: 1.5rem;
align-items: start;
}
.dash-side {
display: flex;
flex-direction: column;
gap: 1rem;
/* Stick the sidebar while the playlist card scrolls, on tall screens. */
position: sticky;
top: 1rem;
}
/* Compact card used by the sidebar widgets. */
.card-compact {
padding: 0.85rem;
margin-bottom: 0;
}
.card-compact:hover {
/* The global .card lift is distracting on small utility cards. */
transform: none;
}
.compact-title {
margin: 0 0 0.6rem 0;
font-size: 0.95rem;
display: flex;
align-items: center;
gap: 0.4rem;
}
.compact-actions {
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.compact-btn {
display: flex;
align-items: center;
gap: 0.4rem;
width: 100%;
padding: 0.45rem 0.6rem;
font-size: 0.82rem;
text-align: left;
justify-content: flex-start;
}
.compact-btn-icon {
width: 15px;
height: 15px;
flex: 0 0 auto;
filter: brightness(0) invert(1);
}
/* The Admin Panel button keeps the default (non-success) background, so its
icon must stay in the button's text colour instead of being forced white. */
.compact-btn:not(.btn-success) .compact-btn-icon {
filter: none;
}
body.dark-mode .compact-btn:not(.btn-success) .compact-btn-icon {
filter: brightness(0) invert(1);
}
.workflow-guide {
margin-top: 0.4rem;
padding: 0.6rem 0.7rem;
background: var(--bg-color);
border-radius: 6px;
border: 1px solid var(--border-color);
}
.workflow-guide ol {
margin: 0;
padding-left: 1.2rem;
line-height: 1.65;
font-size: 0.82rem;
}
/* ── Playlist overview rows ───────────────────────────────────────────── */
.playlist-row {
padding: 0.75rem 0;
border-bottom: 1px solid var(--border-color);
}
.playlist-row:last-of-type {
border-bottom: none;
}
.playlist-row-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.playlist-name {
font-weight: 600;
font-size: 1rem;
color: var(--primary-color);
text-decoration: none;
word-break: break-word;
}
.playlist-name:hover {
text-decoration: underline;
}
.player-chips {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-top: 0.5rem;
}
.player-chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.15rem 0.55rem;
border-radius: 999px;
font-size: 0.78rem;
text-decoration: none;
border: 1px solid var(--border-color);
background: var(--bg-color);
color: var(--text-color);
max-width: 100%;
}
.player-chip:hover {
border-color: var(--primary-color);
}
.player-chip-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex: 0 0 auto;
}
.player-chip.is-online .status-dot { background: #27ae60; }
.player-chip.is-offline .status-dot { background: #cbd5e0; }
body.dark-mode .player-chip.is-offline .status-dot { background: #718096; }
.empty-hint {
margin-top: 0.5rem;
font-size: 0.85rem;
color: var(--text-secondary);
}
.unassigned-note {
margin-top: 1rem;
padding: 1rem;
background: #f8f9fa;
border-radius: 4px;
border: 1px solid #e2e8f0;
padding: 0.7rem 0.85rem;
border-radius: 8px;
border-left: 4px solid #e67e22;
background: rgba(230, 126, 34, 0.1);
font-size: 0.85rem;
}
.unassigned-note a {
color: var(--primary-color);
}
body.dark-mode .workflow-guide {
background: #1a202c;
border: 1px solid #4a5568;
/* Sidebar collapses below the main column when space runs out. */
@media (max-width: 900px) {
.dash-grid {
grid-template-columns: 1fr;
}
.dash-side {
position: static;
/* Narrow screens: let the small cards sit side by side. */
flex-direction: row;
flex-wrap: wrap;
}
.dash-side .card-compact {
flex: 1 1 200px;
}
}
/* Shared helpers used by the cards below. */
.secondary-text {
color: #7f8c8d;
color: var(--text-secondary);
}
body.dark-mode .secondary-text {
color: #a0aec0;
/* ── 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;
border-bottom: 1px solid #e2e8f0;
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;
}
body.dark-mode .log-item {
border-bottom: 1px solid #4a5568;
.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;
}
</style>
{% if recent_logs %}
<div class="card">
<h2>Recent Activity</h2>
<div style="margin-top: 1rem;">
{% for log in recent_logs %}
<div class="log-item">
<span style="color: {% if log.level == 'error' %}#e74c3c{% elif log.level == 'warning' %}#f39c12{% else %}#27ae60{% endif %}; font-weight: bold;">
[{{ log.level.upper() }}]
</span>
{{ log.message }}
<small class="secondary-text" style="float: right;">{{ log.timestamp | localtime('%Y-%m-%d %H:%M:%S') }}</small>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<div class="card">
<h2>System Status</h2>
<p>✅ All systems operational</p>
<p> Playlist-centric architecture active</p>
<p>📋 Playlist-centric architecture active</p>
<p>🔄 Groups removed - Streamlined workflow</p>
<p>⚡ DigiServer v2.0</p>
</div>
<script>
/* Recent Activity tabs. Toggles the `hidden` attribute so the panes work even
if this script is blocked — the activity pane is simply always shown. */
(function () {
var tabs = document.querySelectorAll('.log-tab');
var panes = document.querySelectorAll('.log-pane');
if (!tabs.length) return;
tabs.forEach(function (tab) {
tab.addEventListener('click', function () {
var target = tab.getAttribute('data-log-tab');
tabs.forEach(function (t) {
t.classList.toggle('is-active', t === tab);
});
panes.forEach(function (p) {
p.hidden = p.getAttribute('data-log-pane') !== target;
});
});
});
})();
</script>
{% endblock %}
+23 -2
View File
@@ -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()