3 Commits

Author SHA1 Message Date
ske087 7177cdb9ab Add in-app help page with user manual (Romanian)
Adds a /help page served from the dashboard navigation, containing the full
Romanian user manual for operators: dashboard, playlist management, adding
media, removing files from playlists, player management, and the advanced
edited-media view.

Implementation notes
- Manual is pre-rendered to HTML with pandoc and shipped as a static asset
  (app/static/help/_manual_body.html) rather than parsed at runtime. This
  keeps the container free of a Markdown dependency and makes the page render
  identically regardless of what is installed.
- /help is login-protected; the sidebar table of contents is derived from the
  level-2 headings at request time.
- Screenshots are served from app/static/help/screenshots/ (32 images).
- Regenerate after editing the manual:
    documentatie/convert_help_page.sh
    docker compose up -d --build

Also adds
- backup_players_playlists.sh / restore_database.sh for DB backup and restore.
- .gitignore rules so local database backups (real production data), generated
  .docx files and duplicate screenshot copies are never committed.
2026-09-15 16:46:16 +03:00
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
47 changed files with 3589 additions and 73 deletions
+21
View File
@@ -76,6 +76,27 @@ build/
#data
data/
# Local database backups / restore points.
# These contain REAL production data (users, password hashes, player feedback).
# They must never leave the host — keep them on disk only.
backups/
restore-points/
# Generated documents (regenerate with documentatie/convert_manual.sh)
documentatie/*.docx
# Screenshots are shipped with the app under app/static/help/screenshots/.
# The copy in documentatie/ is the editing source; ignore it to avoid committing
# ~4 MB twice.
documentatie/screenshots/
# Local notes / scratch files
inportant info .txt
important info.txt
# Legacy pre-sanitization archive (also covered as docs/old_code_documentation/)
old_code_documentation/
# Local archive / restore-point snapshots.
# Kept on disk for reference (see docs/SANITIZATION-REVIEW.md) but deliberately
# NOT tracked: docs/legacy code/ is a full pre-sanitization repo snapshot and
+90 -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,10 +82,57 @@ 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
)
@main_bp.route('/help')
@login_required
def help_page():
"""User manual / help page.
The manual lives at ``app/static/help/_manual_body.html`` — a pre-rendered
HTML fragment generated from ``documentatie/Manual-Utilizare-DigiServer.md``
with ``pandoc`` (see ``documentatie/convert_help_page.sh``).
It is shipped as a static asset rather than parsed at runtime on purpose:
it keeps the runtime free of a Markdown dependency and means the help page
renders identically regardless of what is installed in the container.
"""
import os
import re
base_dir = os.path.dirname(os.path.dirname(__file__)) # -> app/
manual_path = os.path.join(base_dir, 'static', 'help', '_manual_body.html')
manual_html = ''
toc = []
try:
with open(manual_path, 'r', encoding='utf-8') as f:
manual_html = f.read()
# Build the sidebar from the level-2 headings (`<h2 id="...">`), which
# is exactly the printed table of contents of the manual. Nested <h3>
# entries would make the sidebar unwieldy, so they are left out.
for anchor, title in re.findall(
r'<h2 id="([^"]+)"[^>]*>(.*?)</h2>', manual_html, re.DOTALL
):
clean = re.sub(r'<[^>]+>', '', title).strip()
if clean:
toc.append({'anchor': anchor, 'title': clean})
except FileNotFoundError:
manual_html = (
'<h1>Manual indisponibil</h1>'
'<p>Fișierul manualului nu a fost găsit. '
'Rulați <code>documentatie/convert_help_page.sh</code> pentru a-l genera.</p>'
)
return render_template('help.html', manual_html=manual_html, toc=toc)
@main_bp.route('/health')
def health():
"""Health check endpoint"""
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path>
<line x1="12" y1="17" x2="12.01" y2="17"></line>
</svg>

After

Width:  |  Height:  |  Size: 340 B

+4
View File
@@ -393,6 +393,10 @@
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="">
Playlists
</a>
<a href="{{ url_for('main.help_page') }}">
<img src="{{ url_for('static', filename='icons/help.svg') }}" alt="" onerror="this.style.display='none';">
Ajutor
</a>
<a href="{{ url_for('admin.admin_panel') }}">Admin</a>
<a href="{{ url_for('auth.logout') }}">Logout ({{ current_user.username }})</a>
<button class="dark-mode-toggle" onclick="toggleDarkMode()" title="Toggle Dark Mode">
+424 -55
View File
@@ -43,98 +43,467 @@
</div>
</div>
<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);">
<div class="dash-grid">
<!-- ── Main column: where content actually lives ─────────────────────── -->
<div class="dash-main">
<div class="card">
<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>
<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 %}
+276
View File
@@ -0,0 +1,276 @@
{% extends "base.html" %}
{% block title %}Ajutor Manual de Utilizare{% endblock %}
{% block content %}
<style>
/* ---- Help page: local layout & typography ---- */
.help-wrapper {
display: grid;
grid-template-columns: 280px 1fr;
gap: 2rem;
align-items: start;
}
/* Sidebar cu cuprins */
.help-toc {
position: sticky;
top: 20px;
max-height: calc(100vh - 40px);
overflow-y: auto;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 1.25rem;
box-shadow: var(--shadow);
}
.help-toc h3 {
font-size: 0.95rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary);
margin-bottom: 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border-color);
}
.help-toc ul {
list-style: none;
padding: 0;
margin: 0;
}
.help-toc li { margin-bottom: 0.15rem; }
.help-toc a {
display: block;
padding: 0.4rem 0.6rem;
border-radius: 6px;
color: var(--text-color);
text-decoration: none;
font-size: 0.875rem;
line-height: 1.35;
transition: all 0.15s;
}
.help-toc a:hover {
background: var(--bg-color);
color: var(--primary-color);
}
.help-toc a.active {
background: var(--primary-color);
color: #fff;
font-weight: 500;
}
/* Continutul manualului */
.help-content {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 2rem 2.5rem;
box-shadow: var(--shadow);
line-height: 1.7;
overflow-wrap: break-word;
}
.help-content h1 {
font-size: 1.85rem;
margin: 0 0 1rem;
padding-bottom: 0.75rem;
border-bottom: 3px solid var(--primary-color);
}
.help-content h2 {
font-size: 1.4rem;
margin: 2.25rem 0 0.85rem;
padding-top: 1rem;
color: var(--primary-color);
scroll-margin-top: 20px;
}
.help-content h3 {
font-size: 1.1rem;
margin: 1.5rem 0 0.6rem;
scroll-margin-top: 20px;
}
.help-content h4 {
font-size: 1rem;
margin: 1.25rem 0 0.5rem;
}
.help-content p { margin: 0.7rem 0; }
.help-content ul, .help-content ol { margin: 0.7rem 0 0.7rem 1.5rem; }
.help-content li { margin-bottom: 0.35rem; }
.help-content hr {
border: none;
border-top: 1px solid var(--border-color);
margin: 2rem 0;
}
.help-content a { color: var(--primary-color); }
.help-content code {
background: var(--bg-color);
padding: 0.15rem 0.4rem;
border-radius: 4px;
font-size: 0.875em;
font-family: 'SFMono-Regular', Consolas, monospace;
border: 1px solid var(--border-color);
}
.help-content pre {
background: var(--bg-color);
padding: 1rem;
border-radius: 8px;
overflow-x: auto;
border: 1px solid var(--border-color);
margin: 1rem 0;
}
.help-content pre code {
background: none;
border: none;
padding: 0;
}
.help-content img {
max-width: 100%;
height: auto;
border-radius: 8px;
border: 1px solid var(--border-color);
box-shadow: var(--shadow);
margin: 0.75rem 0;
display: block;
}
.help-content table {
width: 100%;
border-collapse: collapse;
margin: 1rem 0;
font-size: 0.9rem;
}
.help-content th, .help-content td {
border: 1px solid var(--border-color);
padding: 0.6rem 0.75rem;
text-align: left;
vertical-align: top;
}
.help-content th {
background: var(--bg-color);
font-weight: 600;
}
.help-content tr:nth-child(even) td {
background: color-mix(in srgb, var(--bg-color) 50%, transparent);
}
/* Citat / atentionari */
.help-content blockquote {
border-left: 4px solid var(--primary-color);
background: var(--bg-color);
margin: 1rem 0;
padding: 0.75rem 1rem;
border-radius: 0 8px 8px 0;
}
.help-content blockquote p { margin: 0.3rem 0; }
/* Textul italics al figurilor */
.help-content em { color: var(--text-secondary); font-size: 0.9em; }
/* Back-to-top */
.help-top-btn {
position: fixed;
right: 24px;
bottom: 24px;
width: 44px;
height: 44px;
border-radius: 50%;
background: var(--primary-color);
color: #fff;
border: none;
cursor: pointer;
font-size: 1.25rem;
box-shadow: var(--shadow-lg);
display: none;
align-items: center;
justify-content: center;
transition: transform 0.2s;
z-index: 500;
}
.help-top-btn:hover { transform: translateY(-3px); }
.help-top-btn.visible { display: flex; }
/* Cauta */
.help-search {
width: 100%;
padding: 0.5rem 0.75rem;
margin-bottom: 0.75rem;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--bg-color);
color: var(--text-color);
font-size: 0.875rem;
}
.help-search:focus {
outline: none;
border-color: var(--primary-color);
}
@media (max-width: 900px) {
.help-wrapper { grid-template-columns: 1fr; }
.help-toc { position: static; max-height: none; }
.help-content { padding: 1.25rem; }
.help-content h1 { font-size: 1.5rem; }
.help-content h2 { font-size: 1.2rem; }
}
</style>
<div class="container">
<div class="help-wrapper">
<!-- Cuprins / navigare laterala -->
<aside class="help-toc">
<h3>Cuprins</h3>
<input type="text" id="tocSearch" class="help-search"
placeholder="Caută în cuprins..." autocomplete="off">
<ul id="tocList">
{% for item in toc %}
<li>
<a href="#{{ item.anchor }}" data-anchor="{{ item.anchor }}">
{{ item.title }}
</a>
</li>
{% endfor %}
</ul>
</aside>
<!-- Continutul manualului -->
<main class="help-content" id="helpContent">
{{ manual_html | safe }}
</main>
</div>
</div>
<button class="help-top-btn" id="topBtn" title="Sus" onclick="window.scrollTo({top:0,behavior:'smooth'})"></button>
<script>
(function () {
// Cuprins: evidentiaza sectiunea curenta la scroll
var links = Array.prototype.slice.call(document.querySelectorAll('#tocList a'));
var sections = links.map(function (a) {
return document.getElementById(a.dataset.anchor);
}).filter(Boolean);
function onScroll() {
var btn = document.getElementById('topBtn');
if (window.scrollY > 400) { btn.classList.add('visible'); }
else { btn.classList.remove('visible'); }
var pos = window.scrollY + 120;
var current = null;
for (var i = 0; i < sections.length; i++) {
if (sections[i].offsetTop <= pos) { current = sections[i].id; }
}
links.forEach(function (a) {
a.classList.toggle('active', a.dataset.anchor === current);
});
}
window.addEventListener('scroll', onScroll, { passive: true });
onScroll();
// Filtrare in cuprins
var search = document.getElementById('tocSearch');
search.addEventListener('input', function () {
var q = this.value.toLowerCase().trim();
links.forEach(function (a) {
var li = a.parentElement;
li.style.display = (!q || a.textContent.toLowerCase().indexOf(q) !== -1) ? '' : 'none';
});
});
})();
</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()
+240
View File
@@ -0,0 +1,240 @@
#!/bin/bash
# ============================================================================
# DigiServer - Database Backup (restore-ready for Docker image / host upgrades)
# ----------------------------------------------------------------------------
# Produces a timestamped, self-contained bundle under ./backups/<timestamp>/:
#
# dashboard.db.gz - CONSISTENT full snapshot of the SQLite database
# (player, playlist, playlist_content,
# player_feedback and all other tables)
# players_playlists.sql.gz - logical dump of player/playlist tables
# (secondary safety net; human-readable)
# SHA256SUMS - integrity check
# MANIFEST.txt - row counts, sizes, checksums, app version
# RESTORE.md - copy-paste restore instructions for this bundle
#
# The bundle is designed so that after a `docker compose build` + host upgrade
# you can run ./restore_database.sh <timestamp> and be back exactly as before.
#
# Usage:
# ./backup_players_playlists.sh # full restore-ready backup
# ./backup_players_playlists.sh --logical-only # SQL dump only (no 2.2GB copy)
# ============================================================================
set -euo pipefail
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
COMPOSE="docker compose -f $PROJECT_DIR/docker-compose.yml"
CONTAINER="digiserver-v2"
DB_HOST="$PROJECT_DIR/data/instance/dashboard.db"
DB_CTR="/app/instance/dashboard.db"
SNAP_CTR="/app/instance/__snapshot.db"
BACKUP_ROOT="$PROJECT_DIR/backups"
TS="$(date +%Y%m%d_%H%M%S)"
DEST="$BACKUP_ROOT/$TS"
ALL_DUMP_TABLES=(player playlist playlist_content player_user player_edit content group group_content)
LOGICAL_ONLY=0
[ "${1:-}" = "--logical-only" ] && LOGICAL_ONLY=1
mkdir -p "$DEST"
echo "============================================================"
echo " DigiServer restore-ready backup"
echo " Target: $DEST"
echo "============================================================"
if [ ! -f "$DB_HOST" ]; then
echo "ERROR: database not found at $DB_HOST"
exit 1
fi
# ---------------------------------------------------------------------------
# 1. Consistent snapshot via SQLite online backup API (safe while app runs)
# ---------------------------------------------------------------------------
if [ "$LOGICAL_ONLY" -eq 0 ]; then
echo
echo "[1/5] Creating consistent DB snapshot (online backup API)..."
$COMPOSE exec -T digiserver-app python - <<'PYEOF'
import sqlite3
src = sqlite3.connect('/app/instance/dashboard.db')
dst = sqlite3.connect('/app/instance/__snapshot.db')
with dst:
src.backup(dst)
dst.close(); src.close()
print(" snapshot created")
PYEOF
docker cp "$CONTAINER:$SNAP_CTR" "$DEST/dashboard.db"
$COMPOSE exec -T digiserver-app rm -f "$SNAP_CTR"
echo " -> size: $(du -h "$DEST/dashboard.db" | cut -f1)"
echo
echo "[2/5] Compressing snapshot..."
gzip -f "$DEST/dashboard.db"
echo " -> $(ls -lh "$DEST/dashboard.db.gz" | awk '{print $5}')"
else
echo
echo "[1/5] Skipping full snapshot (--logical-only)"
echo "[2/5] Skipping compression"
fi
# ---------------------------------------------------------------------------
# 3. Logical SQL dump (secondary safety net)
# ---------------------------------------------------------------------------
echo
echo "[3/5] Creating logical SQL dump of player/playlist tables..."
TABLE_LIST=$(printf '"%s",' "${ALL_DUMP_TABLES[@]}")
TABLE_LIST="[${TABLE_LIST%,}]"
$COMPOSE exec -T digiserver-app python - "$TABLE_LIST" > "$DEST/players_playlists.sql" <<'PYEOF'
import sqlite3, sys
tables = eval(sys.argv[1])
con = sqlite3.connect('/app/instance/dashboard.db')
o = sys.stdout
o.write("-- DigiServer logical backup: players & playlists\n")
o.write("-- Tables: %s\n\n" % ", ".join(tables))
o.write("PRAGMA foreign_keys=OFF;\nBEGIN TRANSACTION;\n\n")
for t in tables:
row = con.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name=?", (t,)).fetchone()
if not row or not row[0]:
continue
o.write("DROP TABLE IF EXISTS \"%s\";\n" % t)
o.write(row[0].rstrip() + ";\n")
cur = con.execute('SELECT * FROM "%s"' % t)
cols = [d[0] for d in cur.description]
collist = ",".join('"%s"' % c for c in cols)
batch = []
def flush(b):
if b:
o.write("INSERT INTO \"%s\" (%s) VALUES\n%s;\n" % (t, collist, ",\n".join(b)))
for rec in cur:
vals = []
for v in rec:
if v is None: vals.append("NULL")
elif isinstance(v, (int, float)): vals.append(str(v))
elif isinstance(v, bytes): vals.append("X'%s'" % v.hex())
else: vals.append("'" + str(v).replace("'", "''") + "'")
batch.append("(%s)" % ",".join(vals))
if len(batch) >= 500:
flush(batch); batch = []
flush(batch)
o.write("\n")
o.write("COMMIT;\n")
PYEOF
gzip -f "$DEST/players_playlists.sql"
echo " -> $(ls -lh "$DEST/players_playlists.sql.gz" | awk '{print $5}')"
# ---------------------------------------------------------------------------
# 4. Checksums
# ---------------------------------------------------------------------------
echo
echo "[4/5] Writing checksums..."
( cd "$DEST" && sha256sum *.gz > SHA256SUMS )
sed 's/^/ /' "$DEST/SHA256SUMS"
# ---------------------------------------------------------------------------
# 5. Manifest + restore instructions
# ---------------------------------------------------------------------------
echo
echo "[5/5] Writing MANIFEST.txt and RESTORE.md..."
{
echo "DigiServer backup manifest"
echo "=========================="
echo "Created: $(date -Iseconds)"
echo "Host: $(hostname)"
echo "Backup ID: $TS"
echo "Source DB: $DB_HOST"
echo "Source DB size: $(du -h "$DB_HOST" | cut -f1)"
echo ""
echo "Row counts at backup time:"
$COMPOSE exec -T digiserver-app python - <<'PYEOF' 2>/dev/null || echo " (unavailable)"
import sqlite3
con = sqlite3.connect('/app/instance/dashboard.db')
for t in ['player','playlist','playlist_content','player_user','player_edit',
'player_feedback','content','group','group_content','user']:
try:
n = con.execute('SELECT COUNT(*) FROM "%s"' % t).fetchone()[0]
print(' %-20s %9d' % (t, n))
except Exception as e:
print(' %-20s ERROR %s' % (t, e))
PYEOF
echo ""
echo "Files:"
( cd "$DEST" && ls -lh | tail -n +2 | sed 's/^/ /' )
} > "$DEST/MANIFEST.txt"
cat > "$DEST/RESTORE.md" <<EOF
# Restore this DigiServer backup
Backup ID: **$TS**
Created: $(date -Iseconds)
## What's in this bundle
| File | Purpose |
|------|---------|
| \`dashboard.db.gz\` | Full consistent snapshot (all tables) - primary restore source |
| \`players_playlists.sql.gz\` | Logical dump of player/playlist tables - secondary source |
| \`SHA256SUMS\` | Integrity check |
| \`MANIFEST.txt\` | Row counts + metadata |
## Restore procedure (after Docker image / host upgrade)
Run from the project root (\`$PROJECT_DIR\`):
\`\`\`bash
./restore_database.sh $TS
\`\`\`
Or do it manually:
\`\`\`bash
# 1. Stop the app so nothing writes to the DB
docker compose stop digiserver-app
# 2. Verify the backup is intact
( cd backups/$TS && sha256sum -c SHA256SUMS )
# 3. Decompress and swap in the database
gunzip -c backups/$TS/dashboard.db.gz > data/instance/dashboard.db
# 4. Fix ownership (container runs as uid 1000)
sudo chown 1000:1000 data/instance/dashboard.db
chmod 644 data/instance/dashboard.db
# 5. Remove any stale journal file
rm -f data/instance/dashboard.db-journal
# 6. Rebuild/start the upgraded image
docker compose up -d --build
# 7. Re-run migrations (new schema may have been added)
docker compose exec -T digiserver-app python /app/migrations/add_https_config_table.py
docker compose exec -T digiserver-app python /app/migrations/add_player_user_table.py
docker compose exec -T digiserver-app python /app/migrations/add_email_to_https_config.py
docker compose exec -T digiserver-app python /app/migrations/migrate_player_user_global.py
# 8. Verify
docker compose exec -T digiserver-app python -c "
import sqlite3
c=sqlite3.connect('/app/instance/dashboard.db')
print('players: ', c.execute('SELECT COUNT(*) FROM player').fetchone()[0])
print('playlists:', c.execute('SELECT COUNT(*) FROM playlist').fetchone()[0])
print('items: ', c.execute('SELECT COUNT(*) FROM playlist_content').fetchone()[0])
"
\`\`\`
## Important notes
- The container's **entrypoint only creates a DB if one is missing** - dropping in this
file bypasses re-initialisation, which is what we want.
- Ownership **must be uid/gid 1000** (host \`pi:pi\` == container \`appuser\`).
- If the new app version changed the schema, the migration step (7) is mandatory.
EOF
echo
echo "============================================================"
echo " Backup complete and restore-ready: $DEST"
echo " To restore later: ./restore_database.sh $TS"
echo "============================================================"
+225
View File
@@ -0,0 +1,225 @@
# Cum convertesc acest manual în format .DOCX
Acest fișier explică pașii pentru a transforma `Manual-Utilizare-DigiServer.md` într-un document Word (`.docx`) gata de printat sau distribuit.
---
## Metoda 1 Pandoc (recomandată)
Pandoc este cea mai bună soluție: convertește Markdown în DOCX păstrând titluri, tabele, liste și imagini.
### 1.1. Instalare Pandoc
Pe Debian/Ubuntu (inclusiv pe acest server):
```bash
sudo apt-get update
sudo apt-get install -y pandoc
```
Verificare:
```bash
pandoc --version
```
### 1.2. Conversie simplă
Din folderul `documentatie/`:
```bash
cd /srv/digiserver-v2/documentatie
pandoc Manual-Utilizare-DigiServer.md -o Manual-Utilizare-DigiServer.docx
```
### 1.3. Conversie cu cuprins automat (Table of Contents)
```bash
pandoc Manual-Utilizare-DigiServer.md \
--toc \
--toc-depth=2 \
--number-sections \
-o Manual-Utilizare-DigiServer.docx
```
| Opțiune | Efect |
|---------|-------|
| `--toc` | Adaugă cuprins automat |
| `--toc-depth=2` | Cuprinsul include titluri de nivel 1 și 2 |
| `--number-sections` | Numerotează secțiunile (1, 1.1, 1.2...) |
### 1.4. Conversie cu șablon personalizat (logo, fonturi corporate)
Dacă doriți un aspect personalizat:
1. Generați un șablon de referință:
```bash
pandoc -o template-referinta.docx --print-default-data-file reference.docx
```
2. Deschideți `template-referinta.docx` în Word
3. Modificați stilurile (fonturi, culori, antet, logo)
4. Salvați și folosiți-l la conversie:
```bash
pandoc Manual-Utilizare-DigiServer.md \
--toc --number-sections \
--reference-doc=template-referinta.docx \
-o Manual-Utilizare-DigiServer.docx
```
### 1.5. Conversie cu imagini redimensionate (pentru capturile de ecran)
```bash
pandoc Manual-Utilizare-DigiServer.md \
--toc --number-sections \
--resource-path=screenshots \
-o Manual-Utilizare-DigiServer.docx
```
> **Notă:** Pentru ca imaginile să apară în document, ele trebuie să existe în folderul
> `screenshots/` cu numele exacte din Anexa B și trebuie referite în Markdown.
---
## Metoda 2 VS Code (fără instalări suplimentare)
Dacă editați în VS Code:
1. Instalați extensia **„Markdown PDF"** sau **„Markdown Preview Enhanced"**
2. Deschideți `Manual-Utilizare-DigiServer.md`
3. Click dreapta → **„Markdown: Export to DOCX"** (sau PDF)
4. Salvați documentul rezultat
Alternativ, extensia **„Markdown All in One"** + **„Pandoc"** permite export direct DOCX din Command Palette.
---
## Metoda 3 Online (fără instalare)
Pentru conversie rapidă fără a instala nimic:
1. Accesați un convertor online (ex: `https://www.markdowntodocx.com`)
2. Încărcați sau lipiți conținutul din `Manual-Utilizare-DigiServer.md`
3. Descărcați fișierul `.docx` rezultat
> **Atenție:** Nu folosiți convertizoare online pentru documente cu informații sensibile (adrese IP interne, credențiale). Preferați Metoda 1 sau 2.
---
## Pașii compleți pentru un document final (cu capturi de ecran)
### Pasul 1 Faceți capturile de ecran
Deschideți aplicația în browser și faceți capturi pentru fiecare punct din **Anexa B** a manualului.
**Sfaturi pentru capturi bune:**
- Folosiți rezoluția completă a ferestrei (nu tăiați meniurile)
- Evitați să apară date sensibile (parole, IP-uri reale dacă nu e necesar)
- Salvați în format PNG pentru claritate
- Denumiți exact ca în Anexa B (ex: `01-login.png`)
### Pasul 2 Salvați capturile
Puneți toate fișierele în:
```
/srv/digiserver-v2/documentatie/screenshots/
```
### Pasul 3 Referiți imaginile în Markdown
Înlocuiți liniile de tip:
```markdown
**[SCREENSHOT: 03-dashboard.png Dashboard după autentificare]**
```
cu:
```markdown
![Dashboard după autentificare](screenshots/03-dashboard.png)
*Figura 1 Dashboard-ul principal al aplicației*
```
### Pasul 4 Convertiți în DOCX
```bash
cd /srv/digiserver-v2/documentatie
pandoc Manual-Utilizare-DigiServer.md \
--toc \
--toc-depth=2 \
--number-sections \
--resource-path=screenshots \
-o Manual-Utilizare-DigiServer.docx
```
### Pasul 5 Verificați documentul
Deschideți `Manual-Utilizare-DigiServer.docx` în Word / LibreOffice și verificați:
- [ ] Cuprinsul este corect și complet
- [ ] Toate capturile de ecran apar
- [ ] Tabelele sunt formatate corect
- [ ] Numerotarea secțiunilor este consecventă
- [ ] Textul diacriticilor românești (ă, â, î, ș, ț) se afișează corect
---
## Script automat de conversie
Un script care face totul automat:
```bash
#!/bin/bash
# convert_manual.sh - Converteste manualul in DOCX cu cuprins si imagini
cd "$(dirname "$0")"
echo "Verificare Pandoc..."
if ! command -v pandoc &> /dev/null; then
echo "Pandoc nu este instalat. Rulati: sudo apt-get install -y pandoc"
exit 1
fi
echo "Numar capturi de ecran disponibile:"
ls -1 screenshots/*.png 2>/dev/null | wc -l
echo "Conversie in DOCX..."
pandoc Manual-Utilizare-DigiServer.md \
--toc \
--toc-depth=2 \
--number-sections \
--resource-path=screenshots \
-o Manual-Utilizare-DigiServer.docx
if [ -f Manual-Utilizare-DigiServer.docx ]; then
echo "✓ Document creat: Manual-Utilizare-DigiServer.docx"
ls -lh Manual-Utilizare-DigiServer.docx
else
echo "✗ Eroare la conversie"
exit 1
fi
```
Salvați ca `convert_manual.sh`, apoi:
```bash
chmod +x convert_manual.sh
./convert_manual.sh
```
---
## Rezumat rapid
| Metodă | Necesită | Calitate | Recomandat pentru |
|--------|----------|----------|-------------------|
| **Pandoc** | instalare | ★★★★★ | Document final profesional |
| **VS Code** | extensie | ★★★★ | Utilizatori VS Code |
| **Online** | browser | ★★★ | Conversie rapidă, documente nesensibile |
**Recomandare:** Folosiți **Pandoc cu `--toc` și `--number-sections`** și un șablon de referință personalizat pentru un document final profesionist.
---
*Pentru întrebări despre conversie, consultați documentația Pandoc: https://pandoc.org/MANUAL.html*
+711
View File
@@ -0,0 +1,711 @@
# Manual de Utilizare DigiServer v2
**Versiune document:** 1.0
**Data:** Septembrie 2026
**Aplicație:** DigiServer v2 Sistem de management al conținutului pentru ecrane digitale (Digital Signage)
---
## Cuprins
1. [Introducere](#1-introducere)
2. [Autentificare în aplicație](#2-autentificare-în-aplicație)
3. [Dashboard (Panoul principal)](#3-dashboard-panoul-principal)
4. [Biblioteca de Media](#4-biblioteca-de-media)
5. [Adăugarea de Media în sistem](#5-adăugarea-de-media-în-sistem)
6. [Managementul Playlist-urilor](#6-managementul-playlist-urilor)
7. [Adăugarea de conținut într-un Playlist](#7-adăugarea-de-conținut-într-un-playlist)
8. [Ștergerea unui fișier din Playlist](#8-ștergerea-unui-fișier-din-playlist)
9. [Reordonarea conținutului în Playlist](#9-reordonarea-conținutului-în-playlist)
10. [Managementul Playerelelor](#10-managementul-playerelelor)
11. [Vizualizarea fișierelor editate pe Player (funcție avansată)](#11-vizualizarea-fișierelor-editate-pe-player-funcție-avansată)
12. [Administrare](#12-administrare)
13. [Întrebări frecvente și depanare](#13-întrebări-frecvente-și-depanare)
---
## 1. Introducere
**DigiServer v2** este o aplicație web folosită pentru a gestiona conținutul afișat pe ecranele digitale (playere). Cu ajutorul ei puteți:
- Încărca fișiere media (imagini, videoclipuri, documente PDF, prezentări PowerPoint)
- Crea **playlist-uri** liste ordonate de conținut care se afișează pe ecrane
- Atribui playlist-uri către playere specifice
- Urmări automatizarea și starea playerelor
- Vizualiza și gestiona versiunile editate ale fișierelor de pe playere
### Structura generală a aplicației
```
┌─────────────────────────────────────────────────────────┐
│ UTILIZATOR (browser) │
│ │ │
│ ▼ │
│ DigiServer (interfață web) │
│ │ │
│ ├──► Biblioteca de Media (fișiere) │
│ ├──► Playlist-uri (liste de redare) │
│ └──► Playere (ecrane fizice) │
└─────────────────────────────────────────────────────────┘
```
### Roluri de utilizator
| Rol | Drepturi |
|-----|----------|
| **Admin** | Acces complet: utilizatori, setări sistem, HTTPS, playere, conținut |
| **Editor** | Poate încărca conținut, crea playlist-uri și edita playere |
| **Utilizator** | Poate vizualiza conținut și playlists |
---
## 2. Autentificare în aplicație
### 2.1. Deschiderea aplicației
Deschideți un browser (Chrome, Firefox sau Edge) și accesați una dintre adresele:
- **Prin adresă IP:** `https://10.76.152.164`
- **Prin nume gazdă:** `https://digiserver`
- **Prin domeniu:** `https://digiserver.sibiusb.harting.intra`
> **Notă:** Dacă browserul afișează un avertisment de certificat („Conexiunea nu este privată"), acest lucru este normal pentru rețeaua internă. Apăsați **„Avansat" → „Continuă către..."**. Administratorul de rețea poate instala certificatul intern pentru a elimina acest avertisment.
### 2.2. Ecranul de autentificare
![Ecranul de autentificare](screenshots/01-login.png)
*Figura 1 Ecranul de login*
Introduceți:
1. **Nume utilizator** (username)
2. **Parolă**
3. Apăsați butonul **„Autentificare"** / **„Login"**
> **Important:** Dacă ați uitat parola, contactați administratorul sistemului. Parola poate fi schimbată ulterior din meniul de cont.
### 2.3. Schimbarea parolei
După prima autentificare, se recomandă schimbarea parolei:
1. Accesați **meniul de utilizator** (colț dreapta-sus)
2. Selectați **„Schimbă parola"** / **„Change Password"**
3. Introduceți parola veche, apoi parola nouă (de două ori)
4. Confirmați
![Formularul de schimbare a parolei](screenshots/02-change-password.png)
*Figura 2 Schimbarea parolei*
---
## 3. Dashboard (Panoul principal)
După autentificare, sunteți redirecționat către **Dashboard** pagina principală a aplicației.
![Dashboard după autentificare](screenshots/03-dashboard.png)
*Figura 3 Dashboard-ul principal*
### 3.1. Elementele Dashboard-ului
Dashboard-ul oferă o imagine de ansamblu asupra sistemului:
| Element | Descriere |
|---------|-----------|
| **Statistici** | Număr de playere, playlist-uri, fișiere media |
| **Stare playere** | Care playere sunt online / offline |
| **Acțiuni rapide** | Butoane pentru adăugare rapidă de conținut |
| **Meniu de navigare** | Acces către toate secțiunile aplicației |
### 3.2. Meniul principal de navigare
Meniul din partea stângă (sau de sus) conține:
| Secțiune | Funcție |
|----------|---------|
| **Dashboard** | Pagina principală cu statistici |
| **Media Library** (Biblioteca de Media) | Toate fișierele încărcate |
| **Playlists** | Gestionarea listelor de redare |
| **Players** (Playere) | Gestionarea ecranelor |
| **Admin** | Setări de sistem (doar pentru administratori) |
### 3.3. Cum citiți statisticile
1. **Playere (Players):** Numărul total de ecrane configurate
2. **Playlist-uri:** Numărul de liste de redare existente
3. **Media:** Numărul total de fișiere încărcate
Apăsați pe orice statistică pentru a accesa secțiunea corespunzătoare.
---
## 4. Biblioteca de Media
Biblioteca de Media conține **toate fișierele** încărcate în sistem: imagini, videoclipuri, documente PDF și prezentări.
![Biblioteca de Media](screenshots/04-media-library.png)
*Figura 4 Bibliotecac de Media*
### 4.1. Accesarea bibliotecii
Din meniul principal, selectați **„Media Library"** sau accesați direct `/media-library`.
### 4.2. Informații afișate pentru fiecare fișier
| Coloană / Element | Semnificație |
|-------------------|--------------|
| **Nume fișier** | Numele original al fișierului încărcat |
| **Tip** | Imagine / Video / PDF / Prezentare |
| **Dimensiune** | Mărimea fișierului |
| **Data încărcării** | Când a fost adăugat în sistem |
| **Previzualizare** | Miniatură pentru imagini și videoclipuri |
### 4.3. Filtrarea și căutarea
- Folosiți **bara de căutare** pentru a găsi un fișier după nume
- Folosiți **filtrele** pentru a afișa doar un anumit tip (imagini, videoclipuri etc.)
### 4.4. Ștergerea unui fișier din bibliotecă
> **Atenție:** Ștergerea din bibliotecă este permanentă. Dacă fișierul este folosit într-un playlist, acesta va apărea ca lipsă.
1. Găsiți fișierul în listă
2. Apăsați butonul **Ștergere** (iconiță coș de gunoi / X)
3. Confirmați ștergerea
![Accesarea opțiunii de ștergere](screenshots/05-1-delete-file.png)
*Figura 5 Accesarea butonului de ștergere*
![Confirmarea ștergerii unui fișier](screenshots/05-2-delete-file-confirm.png)
*Figura 6 Confirmarea ștergerii*
---
## 5. Adăugarea de Media în sistem
### 5.1. Deschiderea formularului de încărcare
Din meniul principal selectați **„Upload Media"** sau accesați pagina de încărcare.
![Formularul de încărcare media](screenshots/06-1-upload-media.png)
*Figura 7 Pagina de încărcare media*
### 5.2. Tipuri de fișiere acceptate
| Categorie | Formate acceptate |
|-----------|-------------------|
| **Imagini** | PNG, JPG, JPEG, GIF, BMP |
| **Video** | MP4, AVI, MKV, MOV, WEBM |
| **Documente** | PDF |
| **Prezentări** | PPT, PPTX |
### 5.3. Pașii pentru încărcare
1. **Apăsați zona de selecție** sau butonul **„Alege fișier"**
2. Selectați fișierul (sau mai multe fișiere) din calculator
3. Așteptați finalizarea încărcării
4. Veți primi confirmarea că fișierul a fost adăugat
![Selectarea fișierului de încărcat](screenshots/06-2-upload-media-select.png)
*Figura 8 Selectarea fișierului*
### 5.4. Confirmarea încărcării
După finalizarea încărcării, aplicația afișează un banner verde de confirmare cu mesajul **„Successfully uploaded"**, care indică playlist-ul în care a fost adăugat fișierul.
![Banner de confirmare a încărcării](screenshots/07-upload-success.png)
*Figura 9 Bannerul „Successfully uploaded"*
### 5.5. Adăugarea unui link web (Web Link)
Pe lângă fișiere, puteți adăuga și **adrese web** care să fie afișate pe ecrane.
1. Selectați opțiunea **„Add Web Link"** / **„Adaugă link web"**
2. Introduceți **URL-ul complet** (ex: `https://www.exemplu.ro`)
3. Dați un **titlu** pentru link
4. Salvați
![Adăugarea unui link web](screenshots/08-add-weblink.png)
*Figura 10 Adăugarea unui link web*
> **Sfat:** Limita maximă pentru un fișier este de 2 GB. Pentru fișiere foarte mari, împărțiți-le sau contactați administratorul.
---
## 6. Managementul Playlist-urilor
Un **playlist** este o listă ordonată de conținut care se redă pe un player. Puteți avea mai multe playlist-uri pentru diferite ecrane sau situații.
### 6.1. Vizualizarea playlist-urilor
Din meniul principal selectați **„Playlists"**.
![Lista playlist-urilor](screenshots/09-playlists-list.png)
*Figura 9 Lista playlist-urilor*
Aici vedeți toate playlist-urile existente, cu:
| Element | Descriere |
|---------|-----------|
| **Nume** | Denumirea playlist-ului |
| **Nr. elemente** | Câte fișiere conține |
| **Player atribuit** | Pe ce ecran se redă |
| **Acțiuni** | Editare, gestionare conținut, ștergere |
### 6.2. Crearea unui playlist nou
1. Apăsați butonul **„Creează Playlist"** / **„Create Playlist"**
2. Introduceți **numele playlist-ului** (ex: „Ecran Recepție")
3. Opțional: adăugați o **descriere**
4. Apăsați **„Salvează"**
![Crearea unui playlist nou](screenshots/10-create-playlist.png)
*Figura 10 Crearea unui playlist*
### 6.3. Gestionarea conținutului unui playlist
Pentru a vedea și edita conținutul unui playlist:
1. Găsiți playlist-ul în listă
2. Apăsați butonul **„Gestionare"** / **„Manage"** (sau numele playlist-ului)
3. Se deschide pagina de management a conținutului
![Pagina de management a playlist-ului](screenshots/11-1-manage-playlist.png)
*Figura 12 Accesarea managementului*
![Conținutul playlist-ului](screenshots/11-2-manage-playlist-content.png)
*Figura 13 Conținutul playlist-ului*
### 6.4. Ștergerea unui playlist
1. Găsiți playlist-ul în listă
2. Apăsați butonul **Ștergere**
3. Confirmați
> **Atenție:** Ștergerea unui playlist nu șterge fișierele media. Doar lista de redare este eliminată.
---
## 7. Adăugarea de conținut într-un Playlist
### 7.1. Pași pentru adăugare
1. Deschideți playlist-ul (secțiunea **Management**)
2. Apăsați **„Adaugă conținut"** / **„Add Content"**
3. Se afișează lista cu toate fișierele disponibile
4. **Bifați** fișierele dorite (puteți selecta mai multe)
5. Apăsați **„Adaugă"**
![Selectarea conținutului de adăugat](screenshots/12-add-content-to-playlist.png)
*Figura 12 Adăugarea conținutului*
### 7.2. Opțiuni per element
După adăugare, pentru fiecare element puteți configura:
| Opțiune | Descriere |
|---------|-----------|
| **Durată afișare** | Cât timp se afișează elementul (secunde) |
| **Fără sunet (Muted)** | Dezactivează sunetul pentru acel element |
| **Editare permisă** | Permite player-ului să editeze fișierul local |
![Opțiunile fiecărui element](screenshots/13-content-options.png)
*Figura 13 Opțiunile elementelor*
### 7.3. Adăugarea unui link web direct în playlist
1. În pagina de management a playlist-ului, apăsați **„Adaugă link web"**
2. Introduceți URL-ul și titlul
3. Salvați link-ul apare în listă ca orice alt element
---
## 8. Ștergerea unui fișier din Playlist
### 8.1. Ștergerea unui singur element
1. Deschideți playlist-ul în modul **Management**
2. Găsiți elementul dorit în listă
3. Apăsați iconița **Ștergere** (X / coș de gunoi) de pe rândul respectiv
4. Confirmați dacă vi se cere
![Ștergerea unui element din playlist](screenshots/14-remove-from-playlist.png)
*Figura 14 Ștergerea unui element*
> **Important:** Ștergerea din playlist **nu șterge fișierul** din Biblioteca de Media. Fișierul rămâne disponibil pentru alte playlist-uri.
### 8.2. Ștergerea mai multor elemente simultan (Bulk Remove)
Pentru a șterge mai multe fișiere dintr-un playlist:
1. În pagina de management, **bifați** elementele dorite
2. Apăsați butonul **„Șterge selectate"** / **„Bulk Remove"**
3. Confirmați operațiunea
![Ștergerea în masă](screenshots/15-bulk-remove.png)
*Figura 15 Ștergerea în masă*
### 8.3. Diferența: ștergere playlist vs. ștergere fișier
| Acțiune | Efect |
|---------|-------|
| **Ștergere element din playlist** | Elementul dispare din listă; fișierul rămâne în bibliotecă |
| **Ștergere fișier din bibliotecă** | Fișierul dispare complet; apare ca lipsă în toate playlist-urile |
| **Ștergere playlist** | Lista dispare; toate fișierele rămân în bibliotecă |
---
## 9. Reordonarea conținutului în Playlist
Ordinea elementelor determină ordinea de afișare pe ecran.
### 9.1. Reordonare prin tragere (drag & drop)
1. Deschideți playlist-ul în modul **Management**
2. Apăsați și țineți apăsat pe iconița de **mâner** (⋮⋮) din dreptul unui element
3. Trageți elementul în poziția dorită
4. Ordinea se salvează automat
![Reordonarea elementelor](screenshots/16-reorder-playlist.png)
*Figura 16 Reordonarea elementelor*
### 9.2. Verificarea ordinii
După reordonare, verificați că numerele de ordine (1, 2, 3...) corespund intenției dvs.
---
## 10. Managementul Playerelelor
**Playerele** sunt ecranele fizice care afișează conținutul.
### 10.1. Lista playerelor
Din meniul principal selectați **„Players"**.
![Lista playerelor](screenshots/17-players-list.png)
*Figura 17 Lista playerelor*
| Coloană | Semnificație |
|---------|--------------|
| **Nume** | Denumirea player-ului |
| **Locație** | Unde este amplasat ecranul |
| **Adresă IP** | Adresa de rețea a player-ului |
| **Playlist atribuit** | Ce conținut redă |
| **Stare** | Online / Offline / Stare deploy |
### 10.2. Adăugarea unui player nou
1. Apăsați **„Adaugă Player"** / **„Add Player"**
2. Completați:
- **Nume** (ex: „Ecran Hol Principal")
- **Adresă IP**
- **Locație** (opțional)
3. Salvați
![Adăugarea unui player](screenshots/18-1-add-player.png)
*Figura 20 Formularul de adăugare player*
![Player completat](screenshots/18-2-add-player-filled.png)
*Figura 21 Formularul completat*
### 10.3. Editarea unui player
1. Apăsați **„Editare"** / **„Edit"** pe rândul player-ului
2. Modificați câmpurile dorite
3. Salvați
### 10.4. Atribuirea unui playlist unui player
1. În lista de playere, găsiți player-ul dorit
2. Apăsați **„Atribuie Playlist"** / **„Assign Playlist"**
3. Selectați playlist-ul din listă
4. Confirmați
![Atribuirea unui playlist](screenshots/19-1-assign-playlist.png)
*Figura 22 Selectarea playlist-ului*
![Atribuire confirmată](screenshots/19-2-assign-playlist-done.png)
*Figura 23 Confirmarea atribuirii*
> **Notă:** După atribuire, player-ul va prelua noul conținut la următoarea sincronizare.
### 10.5. Starea de deployment
Fiecare player are o **stare de deployment** care arată dacă conținutul a fost trimis cu succes:
| Stare | Semnificație |
|-------|--------------|
| ✓ **Succes** | Conținutul a fost trimis cu succes |
| ⏳ **În așteptare** | Se așteaptă sincronizarea |
| ✗ **Eroare** | Trimiterea a eșuat verificați mesajul |
### 10.6. Regenerarea cheii de autentificare
Dacă un player nu se mai poate conecta:
1. Găsiți player-ul în listă
2. Apăsați **„Regenerează autentificare"** / **„Regenerate Auth"**
3. Confirmați se va genera o cheie nouă
> **Atenție:** Va trebui să actualizați cheia și pe player-ul fizic.
---
## 11. Vizualizarea fișierelor editate pe Player (funcție avansată)
Această funcție vă permite să vedeți **ce versiuni editate** ale fișierelor există pe fiecare player. Este utilă când un player modifică local fișierele (de exemplu, suprapune informații, ajustează rezoluția etc.).
### 11.1. Accesarea raportului de fișiere editate
1. Accesați secțiunea **Players**
2. Găsiți player-ul dorit
3. Apăsați **„Fișiere editate"** / **„Edited Media"**
![Lista fișierelor editate](screenshots/20-edited-media.png)
*Figura 20 Fișierele editate*
### 11.2. Ce afișează această pagină
| Element | Descriere |
|---------|-----------|
| **Fișier original** | Fișierul din biblioteca centrală |
| **Versiune editată** | Fișierul modificat pe player |
| **Data editării** | Când a fost modificat |
| **Status** | Sincronizat / Diferit / Lipsă |
### 11.3. Raportul detaliat
Pentru o vedere completă:
1. Apăsați **„Raport fișiere editate"** / **„Edited Media Report"**
2. Se generează un raport cu toate diferențele
![Raportul fișierelor editate](screenshots/21-1-edited-media-report.png)
*Figura 26 Raportul fișierelor editate*
![Detaliu raport](screenshots/21-2-edited-media-report-detail.png)
*Figura 27 Detaliu din raport*
### 11.4. Interpretarea statusurilor
| Status | Ce înseamnă | Ce faceți |
|--------|-------------|-----------|
| **Sincronizat** | Fișierul de pe player corespunde cu cel central | Nimic este în regulă |
| **Diferit** | Fișierul a fost editat pe player | Decideți dacă păstrați sau resincronizați |
| **Lipsă** | Fișierul lipsește de pe player | Resincronizați player-ul |
### 11.5. Vizualizare în mod ecran complet (Fullscreen)
Pentru a vedea cum arată conținutul pe player:
1. În lista de playere, apăsați **„Ecran complet"** / **„Fullscreen"**
2. Se deschide o previzualizare la scară
![Previzualizarea fullscreen](screenshots/22-player-fullscreen.png)
*Figura 22 Previzualizarea fullscreen*
---
## 12. Administrare
> Această secțiune este disponibilă **doar pentru administratori**.
### 12.1. Managementul utilizatorilor
**Admin → Users**
- Creați utilizatori noi
- Modificați roluri (Admin / Editor / Utilizator)
- Resetați parole
- Ștergeți utilizatori
![Managementul utilizatorilor](screenshots/23-1-user-management.png)
*Figura 28 Lista utilizatorilor*
![Editarea unui utilizator](screenshots/23-2-user-management-edit.png)
*Figura 29 Editarea unui utilizator*
### 12.2. Setări HTTPS
**Admin → HTTPS Configuration**
Aici configurați certificatul SSL/TLS pentru acces securizat:
1. **Hostname** numele serverului
2. **Adresă IP** IP-ul serverului
3. **Domeniu** domeniul (lăsați gol pentru rețea internă)
4. **Email** pentru notificări certificat
![Configurarea HTTPS](screenshots/24-https-config.png)
*Figura 24 Configurarea HTTPS*
> **Important:** Când mutați serverul în rețeaua de producție, setați IP-ul corect (`10.76.152.164`).
### 12.3. Personalizare logo
**Admin → Customize Logos**
- Încărcați logo pentru antet (header)
- Încărcați logo pentru ecranul de login
### 12.4. Dependențe și unelte
**Admin → Dependencies**
Permite instalarea de unelte opționale:
| Unealtă | Rol |
|---------|-----|
| **LibreOffice** | Conversie automată PPT/PPTX în imagini |
| **Emoji Fonts** | Afișarea corectă a emoji-urilor |
![Instalarea dependențelor](screenshots/25-dependencies.png)
*Figura 25 Instalarea dependențelor*
### 12.5. Media rămasă fără utilizare
**Admin → Leftover Media**
Afișează fișierele care nu mai sunt folosite în niciun playlist. Le puteți șterge pentru a elibera spațiu.
### 12.6. Jurnale de sistem (Logs)
**Admin → System Info / Logs**
Vedeți activitatea sistemului și eventualele erori.
---
## 13. Întrebări frecvente și depanare
### 13.1. Nu mă pot autentifica
**Simptom:** Parola este respinsă.
**Soluție:**
1. Verificați că scrieți corect numele de utilizator și parola (atenție la majuscule)
2. Dacă ați uitat parola, contactați administratorul
3. Verificați că accesați adresa corectă (cu `https://`)
### 13.2. Browserul afișează avertisment de certificat
**Simptom:** „Conexiunea nu este privată" / „Nu este sigur".
**Soluție:** Acest lucru este normal pentru rețeaua internă. Apăsați **„Avansat" → „Continuă către..."**. Administratorul de rețea poate furniza un certificat intern pentru a elimina avertismentul.
### 13.3. Fișierul nu se încarcă
**Simptom:** Încărcarea eșuează sau se blochează.
**Soluție:**
1. Verificați că fișierul este sub limita de **2 GB**
2. Verificați că formatul este acceptat (PNG, JPG, MP4, PDF, PPT etc.)
3. Verificați conexiunea la rețea
4. Încercați din nou; dacă persistă, contactați administratorul
### 13.4. Player-ul nu afișează conținutul nou
**Simptom:** Ați schimbat playlist-ul, dar ecranul afișează vechiul conținut.
**Soluție:**
1. Verificați că playlist-ul este **atribuit** player-ului
2. Verificați **starea de deployment** a player-ului
3. Așteptați următoarea sincronizare (poate dura câteva minute)
4. Dacă persistă, verificați că player-ul este **online** și accesibil în rețea
### 13.5. Am șters un fișier din bibliotecă din greșeală
**Simptom:** Fișierul apare ca „lipsă" în playlist-uri.
**Soluție:** Reîncărcați fișierul în bibliotecă și adăugați-l din nou în playlist. Dacă aveți un backup, contactați administratorul.
### 13.6. Playlist-ul se redă în ordine greșită
**Simptom:** Elementele apar în altă ordine decât ați stabilit.
**Soluție:** Verificați ordinea în pagina de **Management** a playlist-ului și reordonați folosind drag & drop. Confirmați că ordinea s-a salvat.
### 13.7. Fișierele editate nu corespund
**Simptom:** Raportul de fișiere editate arată diferențe neașteptate.
**Soluție:** Consultați secțiunea [11.4](#114-interpretarea-statusurilor) pentru interpretarea statusurilor. Decideți dacă resincronizați player-ul.
---
## Anexă A Glosar de termeni
| Termen | Explicație |
|--------|------------|
| **Media** | Fișier (imagine, video, document) încărcat în sistem |
| **Playlist** | Listă ordonată de conținut care se redă pe un ecran |
| **Player** | Dispozitivul/ecranul fizic care afișează conținutul |
| **Deployment** | Procesul de trimitere a conținutului către player |
| **Dashboard** | Pagina principală cu statistici |
| **Bibliotecă de Media** | Colecția tuturor fișierelor încărcate |
| **Bulk Remove** | Ștergerea mai multor elemente simultan |
| **Edited Media** | Versiunile modificate local ale fișierelor, de pe player |
| **Web Link** | Adresă web afișată ca element în playlist |
---
## Anexă B Lista capturilor de ecran necesare
Salvați capturile în folderul `documentatie/screenshots/` cu numele exacte de mai jos:
| Nr. | Nume fișier | Secțiune | Ce trebuie să conțină |
|-----|-------------|----------|------------------------|
| 1 | `01-login.png` | 2.2 | Ecranul de autentificare |
| 2 | `02-change-password.png` | 2.3 | Formularul de schimbare parolă |
| 3 | `03-dashboard.png` | 3 | Dashboard-ul complet |
| 4 | `04-media-library.png` | 4 | Lista fișierelor media |
| 5 | `05-delete-file.png` | 4.4 | Confirmarea ștergerii |
| 6 | `06-upload-media.png` | 5.1 | Formularul de încărcare |
| 7 | `07-upload-progress.png` | 5.3 | Progresul încărcării |
| 8 | `08-add-weblink.png` | 5.4 | Formularul de link web |
| 9 | `09-playlists-list.png` | 6.1 | Lista playlist-urilor |
| 10 | `10-create-playlist.png` | 6.2 | Crearea unui playlist |
| 11 | `11-manage-playlist.png` | 6.3 | Managementul conținutului |
| 12 | `12-add-content-to-playlist.png` | 7.1 | Selectarea conținutului |
| 13 | `13-content-options.png` | 7.2 | Opțiunile unui element |
| 14 | `14-remove-from-playlist.png` | 8.1 | Ștergerea unui element |
| 15 | `15-bulk-remove.png` | 8.2 | Ștergerea în masă |
| 16 | `16-reorder-playlist.png` | 9.1 | Reordonarea elementelor |
| 17 | `17-players-list.png` | 10.1 | Lista playerelor |
| 18 | `18-add-player.png` | 10.2 | Adăugarea unui player |
| 19 | `19-assign-playlist.png` | 10.4 | Atribuirea unui playlist |
| 20 | `20-edited-media.png` | 11.1 | Fișierele editate |
| 21 | `21-edited-media-report.png` | 11.3 | Raportul detaliat |
| 22 | `22-player-fullscreen.png` | 11.5 | Previzualizarea fullscreen |
| 23 | `23-user-management.png` | 12.1 | Managementul utilizatorilor |
| 24 | `24-https-config.png` | 12.2 | Configurarea HTTPS |
| 25 | `25-dependencies.png` | 12.4 | Instalarea dependențelor |
---
*Sfârșitul documentului*
+167
View File
@@ -0,0 +1,167 @@
#!/bin/bash
# ============================================================================
# capture_screenshots.sh - Helper pentru capturile de ecran ale manualului
# ----------------------------------------------------------------------------
# Ghideaza pas-cu-pas captura celor 25 de ecrane necesare pentru manual.
# Foloseste Flameshot (recomandat) sau scrot.
#
# Utilizare:
# ./capture_screenshots.sh # mod ghidat, pas cu pas
# ./capture_screenshots.sh --list # doar listeaza ce trebuie capturat
# ./capture_screenshots.sh --check # verifica ce capturi exista deja
# ============================================================================
set -u
SCREENSHOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/screenshots"
mkdir -p "$SCREENSHOT_DIR"
# Cele 25 de capturi necesare: nume|descriere|secțiune manual
CAPTURES=(
"01-login|Ecranul de autentificare (login)|2.2"
"02-change-password|Formularul de schimbare parola|2.3"
"03-dashboard|Dashboard-ul complet cu statistici|3"
"04-media-library|Lista fisierelor din Biblioteca de Media|4"
"05-delete-file|Confirmarea stergerii unui fisier|4.4"
"06-upload-media|Formularul de incarcare media|5.1"
"07-upload-progress|Progresul incarcarii unui fisier|5.3"
"08-add-weblink|Formularul de adaugare link web|5.4"
"09-playlists-list|Lista tuturor playlist-urilor|6.1"
"10-create-playlist|Crearea unui playlist nou|6.2"
"11-manage-playlist|Pagina de management a continutului|6.3"
"12-add-content-to-playlist|Selectarea continutului de adaugat|7.1"
"13-content-options|Optiunile per element (durata, muted)|7.2"
"14-remove-from-playlist|Stergerea unui element din playlist|8.1"
"15-bulk-remove|Stergerea in masa a elementelor|8.2"
"16-reorder-playlist|Reordonarea elementelor (drag & drop)|9.1"
"17-players-list|Lista playerelor|10.1"
"18-add-player|Adaugarea unui player nou|10.2"
"19-assign-playlist|Atribuirea unui playlist unui player|10.4"
"20-edited-media|Lista fisierelor editate pe player|11.1"
"21-edited-media-report|Raportul detaliat al fisierelor editate|11.3"
"22-player-fullscreen|Previzualizarea in mod ecran complet|11.5"
"23-user-management|Managementul utilizatorilor|12.1"
"24-https-config|Configurarea HTTPS|12.2"
"25-dependencies|Instalarea dependentelor|12.4"
)
# ---------------------------------------------------------------------------
# Mod --list
# ---------------------------------------------------------------------------
if [ "${1:-}" = "--list" ]; then
echo "============================================================"
echo " Capturi de ecran necesare pentru manual (25 total)"
echo "============================================================"
echo
printf "%-6s %-34s %-36s %s\n" "NR" "FISIER" "CE SA CONTINA" "SECTIUNE"
echo "--------------------------------------------------------------------------"
i=1
for entry in "${CAPTURES[@]}"; do
IFS='|' read -r name desc section <<< "$entry"
printf "%-6s %-34s %-36s %s\n" "$i." "${name}.png" "$desc" "$section"
i=$((i+1))
done
echo
echo "Salveaza toate capturile in:"
echo " $SCREENSHOT_DIR"
exit 0
fi
# ---------------------------------------------------------------------------
# Mod --check
# ---------------------------------------------------------------------------
if [ "${1:-}" = "--check" ]; then
echo "============================================================"
echo " Verificare capturi de ecran existente"
echo "============================================================"
echo
found=0; missing=0
for entry in "${CAPTURES[@]}"; do
IFS='|' read -r name desc section <<< "$entry"
if [ -f "$SCREENSHOT_DIR/${name}.png" ]; then
size=$(du -h "$SCREENSHOT_DIR/${name}.png" | cut -f1)
printf " \033[0;32m✓\033[0m %-32s %s\n" "${name}.png" "$size"
found=$((found+1))
else
printf " \033[0;31m✗\033[0m %-32s LIPSA\n" "${name}.png"
missing=$((missing+1))
fi
done
echo
echo "-------------------------------------------------------------"
echo " Gasite: $found / 25"
echo " Lipsa: $missing / 25"
echo "-------------------------------------------------------------"
if [ "$missing" -eq 0 ]; then
echo
echo " Toate capturile sunt prezente! Puteti rula:"
echo " ./convert_manual.sh"
fi
exit 0
fi
# ---------------------------------------------------------------------------
# Mod ghidat
# ---------------------------------------------------------------------------
echo "============================================================"
echo " Ghid capturi de ecran - Manual DigiServer"
echo "============================================================"
echo
if ! command -v scrot &> /dev/null && ! command -v flameshot &> /dev/null; then
echo "✗ Nu exista tool de capturi. Instalati cu:"
echo " sudo apt-get install -y flameshot scrot"
exit 1
fi
echo "Deschideti aplicatia in browser: https://10.76.152.164"
echo "Autentificati-va ca admin, apoi urmati pasii."
echo
echo "Pentru fiecare ecran vi se va cere sa apasati Enter,"
echo "apoi se face captura. Daca ati facut-o deja, scrieti 's' (skip)."
echo
completed=0
for entry in "${CAPTURES[@]}"; do
IFS='|' read -r name desc section <<< "$entry"
target="$SCREENSHOT_DIR/${name}.png"
echo "-------------------------------------------------------------"
echo " ${name}.png"
echo " Ce: $desc"
echo " Sectiune manual: $section"
if [ -f "$target" ]; then
echo " (exista deja - scrieti 'r' pentru a reface, Enter pentru skip)"
fi
read -r -p " Enter = captura | s = skip | q = iesire: " action
case "$action" in
q|Q) echo; echo "Iesire. Capturi realizate: $completed"; exit 0 ;;
r|R) : ;;
s|S) echo " skipped"; continue ;;
esac
echo " Captura in 3 secunde... (treceti la fereastra dorita)"
sleep 3
if command -v scrot &> /dev/null; then
scrot "$target"
else
flameshot full -p "$target"
fi
if [ -f "$target" ]; then
echo " ✓ Salvat: $target"
completed=$((completed+1))
else
echo " ✗ Captura a esuat"
fi
done
echo
echo "============================================================"
echo " Ghid terminat. Capturi realizate: $completed"
echo "============================================================"
echo
echo "Verificati cu: ./capture_screenshots.sh --check"
echo "Apoi convertiti: ./convert_manual.sh"
+93
View File
@@ -0,0 +1,93 @@
#!/bin/bash
# ============================================================================
# convert_help_page.sh - Regenerates the in-app help page content
# ----------------------------------------------------------------------------
# Converts documentatie/Manual-Utilizare-DigiServer.md into the HTML fragment
# that the Flask app serves at /help, and refreshes the screenshot copies.
#
# Run this whenever the manual is edited, then rebuild the image:
# ./documentatie/convert_help_page.sh
# cd .. && docker compose up -d --build
# ============================================================================
set -euo pipefail
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SRC_MD="$PROJECT_DIR/documentatie/Manual-Utilizare-DigiServer.md"
SRC_IMG_DIR="$PROJECT_DIR/documentatie/screenshots"
DEST_DIR="$PROJECT_DIR/app/static/help"
DEST_IMG_DIR="$DEST_DIR/screenshots"
DEST_HTML="$DEST_DIR/_manual_body.html"
echo "============================================================"
echo " Regenerare pagina Ajutor (/help)"
echo "============================================================"
echo
# ---------------------------------------------------------------------------
# Checks
# ---------------------------------------------------------------------------
if ! command -v pandoc &> /dev/null; then
echo "✗ Pandoc nu este instalat."
echo " Instalati cu: sudo apt-get install -y pandoc"
exit 1
fi
if [ ! -f "$SRC_MD" ]; then
echo "✗ Nu am gasit manualul: $SRC_MD"
exit 1
fi
mkdir -p "$DEST_IMG_DIR"
# ---------------------------------------------------------------------------
# 1. Convert Markdown -> HTML fragment
# ---------------------------------------------------------------------------
echo "[1/3] Conversie Markdown -> HTML..."
pandoc "$SRC_MD" \
-t html5 \
--no-highlight \
--wrap=none \
-o "$DEST_HTML"
echo "$(basename "$DEST_HTML")"
# ---------------------------------------------------------------------------
# 2. Copy screenshots and rewrite their paths for Flask's static route
# ---------------------------------------------------------------------------
echo
echo "[2/3] Copiere capturi de ecran si rescriere cai..."
cp "$SRC_IMG_DIR"/*.png "$DEST_IMG_DIR"/ 2>/dev/null || {
echo " ⚠ Nicio captura gasita in $SRC_IMG_DIR"
}
COUNT=$(ls -1 "$DEST_IMG_DIR"/*.png 2>/dev/null | wc -l)
echo "$COUNT capturi copiate"
# Markdown references images as "screenshots/x.png"; the app serves them from
# /static/help/screenshots/, so the prefix has to be made explicit.
sed -i 's|src="screenshots/|src="/static/help/screenshots/|g' "$DEST_HTML"
REFS=$(grep -c '/static/help/screenshots/' "$DEST_HTML" || true)
echo "$REFS referinte de imagini rescrise"
# ---------------------------------------------------------------------------
# 3. Sanity check: every referenced image must exist
# ---------------------------------------------------------------------------
echo
echo "[3/3] Verificare consistenta..."
MISSING=0
while IFS= read -r ref; do
[ -z "$ref" ] && continue
if [ ! -f "$DEST_IMG_DIR/$(basename "$ref")" ]; then
echo " ✗ lipsa: $ref"
MISSING=$((MISSING + 1))
fi
done < <(grep -oE '/static/help/screenshots/[^"]+' "$DEST_HTML" | sort -u || true)
if [ "$MISSING" -eq 0 ]; then
echo " ✓ toate imaginile referite exista"
else
echo "$MISSING imagini lipsa"
fi
echo
echo "============================================================"
echo " Gata. Rebuild imaginea pentru a aplica modificarile:"
echo " cd $PROJECT_DIR && docker compose up -d --build"
echo "============================================================"
+84
View File
@@ -0,0 +1,84 @@
#!/bin/bash
# ============================================================================
# convert_manual.sh - Converteste manualul DigiServer in format DOCX
# ----------------------------------------------------------------------------
# Genereaza un document Word cu:
# - Cuprins automat
# - Secțiuni numerotate
# - Capturi de ecran incluse (din folderul screenshots/)
#
# Utilizare:
# ./convert_manual.sh
# ============================================================================
set -e
cd "$(dirname "$0")"
echo "============================================================"
echo " Conversie Manual DigiServer -> DOCX"
echo "============================================================"
echo
# ---------------------------------------------------------------------------
# Verificare Pandoc
# ---------------------------------------------------------------------------
if ! command -v pandoc &> /dev/null; then
echo "✗ Pandoc nu este instalat."
echo
echo " Instalati cu:"
echo " sudo apt-get update && sudo apt-get install -y pandoc"
echo
exit 1
fi
echo "✓ Pandoc detectat: $(pandoc --version | head -1)"
echo
# ---------------------------------------------------------------------------
# Verificare fisiere sursa
# ---------------------------------------------------------------------------
if [ ! -f "Manual-Utilizare-DigiServer.md" ]; then
echo "✗ Fisierul Manual-Utilizare-DigiServer.md nu a fost gasit."
exit 1
fi
echo "✓ Manual gasit"
mkdir -p screenshots
SCREENSHOT_COUNT=$(ls -1 screenshots/*.png screenshots/*.jpg 2>/dev/null | wc -l)
echo "✓ Capturi de ecran disponibile: $SCREENSHOT_COUNT"
if [ "$SCREENSHOT_COUNT" -eq 0 ]; then
echo " (Nu exista capturi - documentul va fi generat doar cu text)"
fi
echo
# ---------------------------------------------------------------------------
# Conversie
# ---------------------------------------------------------------------------
echo "Conversie in curs..."
echo
pandoc Manual-Utilizare-DigiServer.md \
--toc \
--toc-depth=2 \
--number-sections \
--resource-path=screenshots \
--metadata title="Manual de Utilizare DigiServer v2" \
--metadata lang="ro-RO" \
-o Manual-Utilizare-DigiServer.docx
# ---------------------------------------------------------------------------
# Verificare rezultat
# ---------------------------------------------------------------------------
echo
if [ -f "Manual-Utilizare-DigiServer.docx" ]; then
echo "============================================================"
echo " ✓ Document creat cu succes!"
echo "============================================================"
ls -lh Manual-Utilizare-DigiServer.docx
echo
echo "Deschideti cu: libreoffice Manual-Utilizare-DigiServer.docx"
echo " sau deschideti in Microsoft Word."
else
echo "✗ Eroare: documentul nu a fost creat."
exit 1
fi
+156
View File
@@ -0,0 +1,156 @@
#!/bin/bash
# ============================================================================
# DigiServer - Database Restore
# ----------------------------------------------------------------------------
# Restores a database snapshot created by ./backup_players_playlists.sh.
#
# Usage:
# ./restore_database.sh # restore the latest backup
# ./restore_database.sh 20260915_143000 # restore a specific backup ID
# ./restore_database.sh --list # list available backups
# ./restore_database.sh <id> --skip-migrations
#
# Typical use: run AFTER upgrading the Docker image / docker host.
# ============================================================================
set -euo pipefail
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
COMPOSE="docker compose -f $PROJECT_DIR/docker-compose.yml"
BACKUP_ROOT="$PROJECT_DIR/backups"
DB_HOST="$PROJECT_DIR/data/instance/dashboard.db"
SKIP_MIGRATIONS=0
BACKUP_ID=""
for arg in "$@"; do
case "$arg" in
--list) ls -1 "$BACKUP_ROOT" 2>/dev/null | sort || echo "(no backups found)"; exit 0 ;;
--skip-migrations) SKIP_MIGRATIONS=1 ;;
-*) echo "Unknown option: $arg"; exit 1 ;;
*) BACKUP_ID="$arg" ;;
esac
done
if [ -z "$BACKUP_ID" ]; then
BACKUP_ID="$(ls -1 "$BACKUP_ROOT" 2>/dev/null | sort | tail -1 || true)"
fi
if [ -z "$BACKUP_ID" ]; then
echo "ERROR: no backups found in $BACKUP_ROOT"
exit 1
fi
DEST="$BACKUP_ROOT/$BACKUP_ID"
if [ ! -d "$DEST" ]; then
echo "ERROR: backup '$BACKUP_ID' not found in $BACKUP_ROOT"
echo "Available:"
ls -1 "$BACKUP_ROOT" 2>/dev/null | sed 's/^/ /'
exit 1
fi
echo "============================================================"
echo " DigiServer database restore"
echo " Backup: $BACKUP_ID"
echo "============================================================"
# ---------------------------------------------------------------------------
# 1. Verify integrity
# ---------------------------------------------------------------------------
if [ -f "$DEST/SHA256SUMS" ]; then
echo
echo "[1/6] Verifying checksums..."
( cd "$DEST" && sha256sum -c SHA256SUMS )
else
echo
echo "[1/6] No SHA256SUMS found, skipping verification"
fi
# ---------------------------------------------------------------------------
# 2. Stop the app (so nothing writes during restore)
# ---------------------------------------------------------------------------
echo
echo "[2/6] Stopping application container..."
$COMPOSE stop digiserver-app
# ---------------------------------------------------------------------------
# 3. Back up the CURRENT database before overwriting it
# ---------------------------------------------------------------------------
if [ -f "$DB_HOST" ]; then
SAFETY="$BACKUP_ROOT/pre_restore_$(date +%Y%m%d_%H%M%S)_dashboard.db"
echo
echo "[3/6] Saving current DB as a safety copy -> $(basename "$SAFETY")"
cp "$DB_HOST" "$SAFETY"
else
echo
echo "[3/6] No existing DB to preserve"
fi
# ---------------------------------------------------------------------------
# 4. Restore the snapshot
# ---------------------------------------------------------------------------
echo
echo "[4/6] Restoring database snapshot..."
if [ -f "$DEST/dashboard.db.gz" ]; then
gunzip -c "$DEST/dashboard.db.gz" > "$DB_HOST"
elif [ -f "$DEST/dashboard.db" ]; then
cp "$DEST/dashboard.db" "$DB_HOST"
else
echo "ERROR: no dashboard.db(.gz) in backup $BACKUP_ID"
exit 1
fi
# Remove any stale journal left over from the old DB
rm -f "$DB_HOST-journal" "$DB_HOST-wal" "$DB_HOST-shm"
# Ownership: container runs as uid 1000; host files are pi:pi (1000)
if [ "$(id -u)" -eq 0 ]; then
chown 1000:1000 "$DB_HOST"
else
sudo chown 1000:1000 "$DB_HOST"
fi
chmod 644 "$DB_HOST"
echo " -> restored $(du -h "$DB_HOST" | cut -f1)"
# ---------------------------------------------------------------------------
# 5. Start the (upgraded) app
# ---------------------------------------------------------------------------
echo
echo "[5/6] Starting application..."
$COMPOSE up -d --build
sleep 8
# ---------------------------------------------------------------------------
# 6. Re-run migrations so any new schema is applied to the restored data
# ---------------------------------------------------------------------------
if [ "$SKIP_MIGRATIONS" -eq 0 ]; then
echo
echo "[6/6] Re-running migrations..."
for m in add_https_config_table add_player_user_table add_email_to_https_config migrate_player_user_global; do
if $COMPOSE exec -T digiserver-app test -f "/app/migrations/$m.py"; then
echo " - $m"
$COMPOSE exec -T digiserver-app python "/app/migrations/$m.py" || \
echo " (warning: $m reported an issue - often means it was already applied)"
fi
done
else
echo
echo "[6/6] Migrations skipped (--skip-migrations)"
fi
# ---------------------------------------------------------------------------
# Verify
# ---------------------------------------------------------------------------
echo
echo "Verification:"
$COMPOSE exec -T digiserver-app python -c "
import sqlite3
c = sqlite3.connect('/app/instance/dashboard.db')
for t in ['player','playlist','playlist_content']:
try:
print(' %-18s %d' % (t, c.execute('SELECT COUNT(*) FROM \"%s\"' % t).fetchone()[0]))
except Exception as e:
print(' %-18s ERROR %s' % (t, e))
"
echo
echo "============================================================"
echo " Restore complete: $BACKUP_ID"
echo "============================================================"