feat: add weblink playlists, SSH player deployment, Caddy HTTPS, build player page

- Content model: add url column + is_weblink() for web page content items
- Player model: add deployment tracking fields (status, timestamps, message)
- Content blueprint: add add_weblink and add_weblink_to_playlist routes;
  weblinks auto-deleted when removed from playlist
- Players blueprint: add SSH deploy mode in add_player; weblink-aware playlist
- API blueprint: weblink URL served directly in playlist response;
  add /api/deploy/test-ssh and /api/deploy/player endpoints
- Admin blueprint: add build-player page (clone from Gitea, write base config);
  replace nginx status card with Caddy status on HTTPS config page
- caddy_manager: rewritten to generate proper HTTPS/internal-CA Caddyfiles
  and reload Caddy via admin API (/load) for live config updates
- ssh_deploy, background_tasks, player_build: new utils for SSH deployment
- background_tasks: push Flask app context into background thread so DB
  updates after deployment complete correctly
- ssh_deploy: robust install script detection with passwordless sudo injection
  (uses SSH credentials, cleaned up after install)
- Dockerfile: add git, sshpass, openssh-client, rsync
- docker-compose: switch nginx to Caddy on ports 80/443; add port 5000 for dev
- Templates: add_player deploy mode UI, weblink form in playlist/upload pages,
  build_player admin page, Caddy status on HTTPS config page
- Migrations: add_url_to_content, add_deployment_fields_to_player
- app.py: call db.create_all() on startup for schema bootstrap
- config.py: add PLAYER_CODE_DIR and PLAYER_REPO_URL settings
This commit is contained in:
2026-07-13 16:46:19 +03:00
parent ae3b82862d
commit 2af04e1db3
24 changed files with 2571 additions and 248 deletions
+11
View File
@@ -95,6 +95,17 @@
</div>
</div>
<!-- Build Player Files Card (Admin Only) -->
<div class="card management-card" style="background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);">
<h2>🚀 Build Player Files</h2>
<p>Clone/refresh the player code from Gitea and configure the server address for deployment</p>
<div class="card-actions">
<a href="{{ url_for('admin.build_player') }}" class="btn btn-primary">
Build Player Files
</a>
</div>
</div>
<!-- Logo Customization Card (Admin Only) -->
<div class="card management-card" style="background: linear-gradient(135deg, #fa709a 0%, #fee140 100%);">
<h2>🎨 Logo Customization</h2>
+130
View File
@@ -0,0 +1,130 @@
{% extends "base.html" %}
{% block title %}Build Player Files - DigiServer v2{% endblock %}
{% block content %}
<div class="container">
<div class="page-header">
<a href="{{ url_for('admin.admin_panel') }}" class="back-link">← Back to Admin Panel</a>
<h1>🧰 Build Player Files for Deployment</h1>
<p style="color: #6c757d; margin-top: 6px;">
Pull the latest player source from a repository onto this server and bake the
configuration it needs to talk to this server. SSH deployments ship exactly
this staged copy, so the version you build here is what players run.
</p>
</div>
<!-- Current staged code status -->
<div class="card status-card">
<h2>Current Staged Player Code</h2>
{% if code_status.available %}
<p><span class="badge badge-success">✅ Ready</span></p>
<ul style="line-height: 1.8;">
<li><strong>Version (git):</strong> <code>{{ code_status.version }}</code></li>
<li><strong>Size:</strong> {{ code_status.size }}</li>
{% if code_status.updated_str %}
<li><strong>Last updated:</strong> {{ code_status.updated_str }}</li>
{% endif %}
<li><strong>Path:</strong> <code>{{ code_status.path }}</code></li>
</ul>
{% else %}
<p>
<span class="badge badge-warning">⚠️ Not staged</span>
{{ code_status.reason }}
</p>
<p style="color: #6c757d;">Path: <code>{{ code_status.path }}</code></p>
{% endif %}
{% if settings.built_at %}
<p style="color: #6c757d; margin-top: 8px;">
Last build saved: {{ settings.built_at }}
{% if settings.built_by %}by {{ settings.built_by }}{% endif %}
</p>
{% endif %}
</div>
<form method="POST" action="{{ url_for('admin.build_player_action') }}">
<!-- Repository -->
<div class="card">
<h2>1. Player Files Repository</h2>
<div class="form-group">
<label for="repo_url">Git Repository URL</label>
<input type="text" id="repo_url" name="repo_url" class="form-control"
value="{{ settings.repo_url }}"
placeholder="https://gitea.example.com/org/Kiwy-Signage.git">
<small style="color: #6c757d;">Cloned/refreshed into the staged directory on this server.</small>
</div>
<div class="form-group">
<label for="branch">Branch</label>
<input type="text" id="branch" name="branch" class="form-control"
value="{{ settings.branch }}" placeholder="main">
</div>
</div>
<!-- Server configuration -->
<div class="card">
<h2>2. Player → Server Configuration</h2>
<p style="color: #6c757d;">
The player contacts the DigiServer API directly at
<code>{scheme}://{server}:{port}/api/...</code> (no <code>/digiserver</code> path).
Enter the address players can reach this server on.
</p>
<div class="form-group">
<label for="server_ip">Server IP / Domain</label>
<input type="text" id="server_ip" name="server_ip" class="form-control"
value="{{ settings.server_ip }}" placeholder="signage.example.com or 192.168.0.50">
</div>
<div class="form-group">
<label for="port">Port</label>
<input type="number" id="port" name="port" class="form-control"
value="{{ settings.port }}" min="1" max="65535">
<small style="color: #6c757d;">Use 443 for HTTPS, 80 for plain HTTP, or your custom port (e.g. 8080).</small>
</div>
<div class="form-group">
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
<input type="checkbox" name="use_https" {% if settings.use_https %}checked{% endif %}>
Use HTTPS
</label>
</div>
<div class="form-group">
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
<input type="checkbox" name="verify_ssl" {% if settings.verify_ssl %}checked{% endif %}>
Verify SSL certificate
</label>
<small style="color: #6c757d;">Leave off for self-signed certificates.</small>
</div>
<div class="form-group">
<label for="orientation">Orientation</label>
<select id="orientation" name="orientation" class="form-control">
<option value="Landscape" {% if settings.orientation == 'Landscape' %}selected{% endif %}>Landscape</option>
<option value="Portrait" {% if settings.orientation == 'Portrait' %}selected{% endif %}>Portrait</option>
</select>
</div>
<div class="form-group">
<label for="max_resolution">Max Resolution</label>
<input type="text" id="max_resolution" name="max_resolution" class="form-control"
value="{{ settings.max_resolution }}" placeholder="1920x1080">
</div>
<p style="color: #6c757d; font-size: 13px;">
️ The per-player <strong>screen name</strong> and <strong>quick-connect code</strong>
are filled in automatically for each device during SSH deployment.
</p>
</div>
<!-- Actions -->
<div class="card">
<h2>3. Build</h2>
<div class="card-actions" style="display: flex; gap: 10px; flex-wrap: wrap;">
<button type="submit" name="action" value="build_and_config" class="btn btn-primary">
⬇️ Build files &amp; write config
</button>
<button type="submit" name="action" value="build_files" class="btn btn-secondary">
Build files only
</button>
<button type="submit" name="action" value="save_config" class="btn btn-secondary">
Write config only
</button>
</div>
</div>
</form>
</div>
{% endblock %}
+24 -82
View File
@@ -160,93 +160,35 @@
</form>
</div>
<!-- Nginx Status Card -->
<!-- Caddy Reverse Proxy Status Card -->
<div class="card nginx-status-card">
<h2>🔧 Nginx Reverse Proxy Status</h2>
{% if nginx_status.available %}
<div class="nginx-status-content">
<div class="status-item">
<strong>Status:</strong>
<span class="badge badge-success">✅ Nginx Configured</span>
</div>
<div class="status-item">
<strong>Configuration Path:</strong>
<code>{{ nginx_status.path }}</code>
</div>
{% if nginx_status.ssl_enabled %}
<div class="status-item">
<strong>SSL/TLS:</strong>
<span class="badge badge-success">🔒 Enabled</span>
</div>
<h2>🔧 Caddy Reverse Proxy Status</h2>
<div class="nginx-status-content">
<div class="status-item">
<strong>Reverse Proxy:</strong>
<span class="badge badge-success">✅ Caddy</span>
</div>
<div class="status-item">
<strong>SSL/TLS:</strong>
{% if config and config.https_enabled %}
<span class="badge badge-success">🔒 Enabled — auto-managed by Caddy</span>
{% else %}
<div class="status-item">
<strong>SSL/TLS:</strong>
<span class="badge badge-warning">⚠️ Not Configured</span>
</div>
{% endif %}
{% if nginx_status.http_ports %}
<div class="status-item">
<strong>HTTP Ports:</strong>
<code>{{ nginx_status.http_ports|join(', ') }}</code>
</div>
{% endif %}
{% if nginx_status.https_ports %}
<div class="status-item">
<strong>HTTPS Ports:</strong>
<code>{{ nginx_status.https_ports|join(', ') }}</code>
</div>
{% endif %}
{% if nginx_status.server_names %}
<div class="status-item">
<strong>Server Names:</strong>
{% for name in nginx_status.server_names %}
<code>{{ name }}</code>{% if not loop.last %}<br>{% endif %}
{% endfor %}
</div>
{% endif %}
{% if nginx_status.upstream_servers %}
<div class="status-item">
<strong>Upstream Servers:</strong>
{% for server in nginx_status.upstream_servers %}
<code>{{ server }}</code>{% if not loop.last %}<br>{% endif %}
{% endfor %}
</div>
{% endif %}
{% if nginx_status.ssl_protocols %}
<div class="status-item">
<strong>SSL Protocols:</strong>
<code>{{ nginx_status.ssl_protocols|join(', ') }}</code>
</div>
{% endif %}
{% if nginx_status.client_max_body_size %}
<div class="status-item">
<strong>Max Body Size:</strong>
<code>{{ nginx_status.client_max_body_size }}</code>
</div>
{% endif %}
{% if nginx_status.gzip_enabled %}
<div class="status-item">
<strong>Gzip Compression:</strong>
<span class="badge badge-success">✅ Enabled</span>
</div>
<span class="badge badge-warning">⚠️ HTTP only — enable HTTPS above</span>
{% endif %}
</div>
{% else %}
<div class="status-disabled">
<p>⚠️ <strong>Nginx configuration not accessible</strong></p>
<p>Error: {{ nginx_status.error|default('Unknown error') }}</p>
<p style="font-size: 12px; color: #666;">Path checked: {{ nginx_status.path }}</p>
<div class="status-item">
<strong>Certificate Provider:</strong>
<code>Let's Encrypt (automatic)</code>
</div>
{% endif %}
<div class="status-item">
<strong>Admin API:</strong>
<code>caddy:2019</code>
</div>
<div class="status-item">
<strong>Upstream:</strong>
<code>digiserver-app:5000</code>
</div>
</div>
</div>
<!-- Information Section -->
@@ -314,11 +314,18 @@
</td>
<td><span class="drag-handle">⋮⋮</span></td>
<td>{{ loop.index }}</td>
<td>{{ content.filename }}</td>
<td>
{% if content.content_type == 'weblink' %}
<a href="{{ content.url }}" target="_blank" rel="noopener noreferrer">{{ content.url }}</a>
{% else %}
{{ content.filename }}
{% endif %}
</td>
<td>
{% if content.content_type == 'image' %}📷 Image
{% elif content.content_type == 'video' %}🎥 Video
{% elif content.content_type == 'pdf' %}📄 PDF
{% elif content.content_type == 'weblink' %}🔗 Web Link
{% else %}📁 Other{% endif %}
</td>
<td>
@@ -401,6 +408,22 @@
<div class="card">
<h2 style="margin-bottom: 20px;"> Add Content</h2>
<div style="margin-bottom: 24px; padding-bottom: 24px; border-bottom: 1px solid #e0e0e0;">
<h3 style="margin-bottom: 12px; font-size: 16px;">🔗 Add Web Link</h3>
<form method="POST"
action="{{ url_for('content.add_weblink_to_playlist', playlist_id=playlist.id) }}">
<input type="url" name="url" required
placeholder="https://example.com/dashboard"
style="width: 100%; padding: 8px; margin-bottom: 8px; box-sizing: border-box;">
<div style="display: flex; gap: 8px; align-items: center;">
<label style="font-size: 13px; color: #666;">Duration (s):</label>
<input type="number" name="duration" value="30" min="1"
style="width: 80px; padding: 6px;">
<button type="submit" class="btn btn-primary btn-sm">+ Add Link</button>
</div>
</form>
</div>
{% if available_content %}
<div class="available-content">
{% for content in available_content %}
+98
View File
@@ -317,6 +317,66 @@
</form>
</div>
<!-- ── Web Link Card ─────────────────────────────────────────────────────── -->
<div class="card" style="margin-top: 20px;">
<h2 style="margin-bottom: 15px; font-size: 18px; display: flex; align-items: center; gap: 0.5rem;">
🌐 Add Web Page Link
</h2>
<p style="color: #6c757d; font-size: 13px; margin-bottom: 16px;">
Add a website URL to display on the player (e.g. a dashboard, live feed, or any public web page).
</p>
<form id="weblink-form" method="POST" action="{{ request.script_root }}/content/add-weblink">
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px;">
<!-- URL -->
<div class="form-group" style="grid-column: 1 / -1;">
<label for="wl-url">Web Page URL <span style="color:#e53e3e;">*</span></label>
<input type="url" id="wl-url" name="url" class="form-control"
placeholder="https://example.com" required>
<small style="color:#6c757d; font-size:11px;">Must start with http:// or https://</small>
</div>
<!-- Description -->
<div class="form-group">
<label for="wl-description">Label / Description</label>
<input type="text" id="wl-description" name="description" class="form-control"
placeholder="e.g. Live Dashboard">
<small style="color:#6c757d; font-size:11px;">Optional — shown in the media library</small>
</div>
<!-- Duration -->
<div class="form-group">
<label for="wl-duration">Display Duration (seconds)</label>
<input type="number" id="wl-duration" name="duration" class="form-control"
value="30" min="5" max="3600">
<small style="color:#6c757d; font-size:11px;">How long to show the page per loop</small>
</div>
<!-- Playlist -->
<div class="form-group" style="grid-column: 1 / -1;">
<label for="wl-playlist">Add to Playlist (Optional)</label>
<select id="wl-playlist" name="playlist_id" class="form-control">
<option value="">-- Media Library Only --</option>
{% for playlist in playlists %}
<option value="{{ playlist.id }}">
{{ playlist.name }} ({{ playlist.orientation }}) — {{ playlist.content_count }} items
</option>
{% endfor %}
</select>
</div>
</div>
<div style="display:flex; align-items:center; gap:12px; margin-top:8px;">
<button type="submit" class="btn-upload" id="wl-submit-btn"
style="display:inline-flex; align-items:center; gap:0.5rem; padding:10px 24px;">
🌐 Add Web Link
</button>
<span id="wl-status" style="font-size:13px; display:none;"></span>
</div>
</form>
</div>
<script>
const uploadZone = document.getElementById('upload-zone');
const fileInput = document.getElementById('file-input');
@@ -509,6 +569,44 @@
uploadBtn.disabled = true;
uploadBtn.innerHTML = '⏳ Uploading...';
});
// ── Web Link form — AJAX submit ────────────────────────────────────────
const wlForm = document.getElementById('weblink-form');
const wlBtn = document.getElementById('wl-submit-btn');
const wlStatus = document.getElementById('wl-status');
wlForm.addEventListener('submit', async (e) => {
e.preventDefault();
wlBtn.disabled = true;
wlBtn.textContent = '⏳ Adding…';
wlStatus.style.display = 'none';
try {
const resp = await fetch(wlForm.action, {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
body: new FormData(wlForm),
});
const data = await resp.json();
if (data.success) {
wlStatus.style.color = '#38a169';
wlStatus.textContent = '✓ ' + data.message;
wlForm.reset();
document.getElementById('wl-duration').value = 30;
} else {
wlStatus.style.color = '#e53e3e';
wlStatus.textContent = '✗ ' + (data.error || 'Failed to add web link.');
}
} catch (err) {
wlStatus.style.color = '#e53e3e';
wlStatus.textContent = '✗ Network error — please try again.';
}
wlStatus.style.display = 'inline';
wlBtn.disabled = false;
wlBtn.innerHTML = '🌐 Add Web Link';
});
</script>
{% endblock %}
+396 -132
View File
@@ -4,135 +4,156 @@
{% block content %}
<style>
.form-group {
margin-bottom: 1rem;
}
.form-group label {
font-weight: bold;
display: block;
margin-bottom: 0.5rem;
}
body.dark-mode .form-group label {
color: #e2e8f0;
}
.form-group { margin-bottom: 1rem; }
.form-group label { font-weight: bold; display: block; margin-bottom: 0.5rem; }
body.dark-mode .form-group label { color: #e2e8f0; }
.form-control {
width: 100%;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;
box-sizing: border-box;
}
body.dark-mode .form-control {
background: #1a202c;
border-color: #4a5568;
color: #e2e8f0;
background: #1a202c; border-color: #4a5568; color: #e2e8f0;
}
body.dark-mode .form-control:focus {
border-color: #7c3aed;
outline: none;
}
.form-help {
color: #6c757d;
font-size: 0.875rem;
}
body.dark-mode .form-help {
color: #718096;
}
body.dark-mode .form-control:focus { border-color: #7c3aed; outline: none; }
.form-help { color: #6c757d; font-size: 0.875rem; }
body.dark-mode .form-help { color: #718096; }
.section-header {
margin-top: 2rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid;
margin-top: 2rem; padding-bottom: 0.5rem; border-bottom: 2px solid;
}
.section-header.blue {
border-color: #007bff;
}
body.dark-mode .section-header.blue {
border-color: #667eea;
}
.section-header.green {
border-color: #28a745;
}
body.dark-mode .section-header.green {
border-color: #48bb78;
}
.section-header.yellow {
border-color: #ffc107;
}
body.dark-mode .section-header.yellow {
border-color: #ecc94b;
}
body.dark-mode h1,
body.dark-mode h3,
body.dark-mode h4 {
color: #e2e8f0;
}
body.dark-mode p {
color: #a0aec0;
}
.section-header.blue { border-color: #007bff; }
body.dark-mode .section-header.blue { border-color: #667eea; }
.section-header.green { border-color: #28a745; }
body.dark-mode .section-header.green { border-color: #48bb78; }
.section-header.yellow { border-color: #ffc107; }
body.dark-mode .section-header.yellow { border-color: #ecc94b; }
.section-header.purple { border-color: #9b59b6; }
body.dark-mode .section-header.purple { border-color: #b794f6; }
body.dark-mode h1, body.dark-mode h3, body.dark-mode h4 { color: #e2e8f0; }
body.dark-mode p { color: #a0aec0; }
.info-box {
background-color: #e7f3ff;
border-left: 4px solid #007bff;
padding: 1rem;
margin: 2rem 0;
background-color: #e7f3ff; border-left: 4px solid #007bff; padding: 1rem; margin: 2rem 0;
}
body.dark-mode .info-box {
background-color: #1a365d;
border-left-color: #667eea;
body.dark-mode .info-box { background-color: #1a365d; border-left-color: #667eea; }
.info-box h4 { margin-top: 0; color: #007bff; }
body.dark-mode .info-box h4 { color: #667eea; }
.info-box code { background: #f4f4f4; padding: 2px 6px; border-radius: 3px; }
body.dark-mode .info-box code { background: #2d3748; color: #e2e8f0; }
body.dark-mode small { color: #718096; }
/* Mode chooser cards */
.mode-chooser {
display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; margin-bottom: 2rem;
}
.info-box h4 {
margin-top: 0;
color: #007bff;
@media (max-width: 700px) { .mode-chooser { grid-template-columns: 1fr; } }
.mode-card {
border: 2px solid #ddd; border-radius: 8px; padding: 1.5rem;
cursor: pointer; transition: border-color 0.2s, box-shadow 0.2s;
background: #fff; text-align: center;
}
body.dark-mode .info-box h4 {
color: #667eea;
body.dark-mode .mode-card { background: #2d3748; border-color: #4a5568; }
.mode-card:hover { border-color: #007bff; box-shadow: 0 4px 12px rgba(0,123,255,0.15); }
body.dark-mode .mode-card:hover { border-color: #667eea; }
.mode-card.active { border-color: #007bff; box-shadow: 0 4px 12px rgba(0,123,255,0.2); }
body.dark-mode .mode-card.active { border-color: #667eea; box-shadow: 0 4px 12px rgba(102,126,234,0.25); }
.mode-card.active-deploy { border-color: #28a745; box-shadow: 0 4px 12px rgba(40,167,69,0.2); }
body.dark-mode .mode-card.active-deploy { border-color: #48bb78; }
.mode-icon { font-size: 2.5rem; margin-bottom: 0.5rem; }
.mode-title { font-size: 1.15rem; font-weight: bold; margin-bottom: 0.4rem; }
body.dark-mode .mode-title { color: #e2e8f0; }
.mode-desc { font-size: 0.875rem; color: #6c757d; }
body.dark-mode .mode-desc { color: #a0aec0; }
/* Panel visibility */
.mode-panel { display: none; }
.mode-panel.active { display: block; }
/* SSH section */
.ssh-section {
background-color: #f8f9fa; padding: 1.5rem; border-radius: 6px; margin-bottom: 2rem;
}
.info-box code {
background: #f4f4f4;
padding: 2px 6px;
border-radius: 3px;
body.dark-mode .ssh-section { background-color: #1e2a38; }
.connection-status {
padding: 1rem; border-radius: 4px; margin-top: 1rem; display: none;
}
body.dark-mode .info-box code {
background: #2d3748;
color: #e2e8f0;
.connection-status.success {
background-color: #d4edda; border: 1px solid #c3e6cb; color: #155724; display: block;
}
body.dark-mode small {
color: #718096;
.connection-status.error {
background-color: #f8d7da; border: 1px solid #f5c6cb; color: #721c24; display: block;
}
body.dark-mode .connection-status.success { background-color: #22543d; border-color: #2f855a; color: #9ae6b4; }
body.dark-mode .connection-status.error { background-color: #742a2a; border-color: #c53030; color: #fc8181; }
/* Deploy-form hidden until SSH verified */
.deploy-player-form { display: none; }
.deploy-player-form.active { display: block; }
.btn {
padding: 0.5rem 1rem; margin-right: 0.5rem; margin-bottom: 0.5rem;
border: none; border-radius: 4px; cursor: pointer; font-size: 0.9rem;
text-decoration: none; display: inline-block;
}
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary { background-color: #007bff; color: white; }
.btn-primary:hover:not(:disabled) { background-color: #0056b3; }
.btn-success { background-color: #28a745; color: white; }
.btn-success:hover:not(:disabled) { background-color: #218838; }
.btn-secondary { background-color: #6c757d; color: white; }
.btn-secondary:hover:not(:disabled) { background-color: #5a6268; }
.loading-spinner {
display: inline-block; width: 1rem; height: 1rem;
border: 2px solid rgba(255,255,255,0.3); border-radius: 50%;
border-top-color: white; animation: spin 1s linear infinite; margin-right: 0.5rem;
}
@keyframes spin { to { transform: rotate(360deg); } }
.row2col { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
@media (max-width: 768px) { .row2col { grid-template-columns: 1fr; } }
</style>
<div class="container" style="max-width: 800px; margin-top: 2rem;">
<div class="container" style="max-width: 960px; margin-top: 2rem;">
<h1>Add New Player</h1>
<p style="color: #6c757d; margin-bottom: 2rem;">
Create a new digital signage player with authentication credentials
<p style="color: #6c757d; margin-bottom: 1.5rem;">
Choose how you want to add the player to the system.
</p>
<div class="card">
<!-- ───────────────────────────── Mode selector ───────────────────────────── -->
<div class="mode-chooser">
<div class="mode-card active" id="card_manual" onclick="selectMode('manual')">
<div class="mode-icon">📋</div>
<div class="mode-title">Manual Registration</div>
<div class="mode-desc">
Register the player in the database and wait for the client to connect.
You configure <code>app_config.json</code> on the device yourself.
</div>
</div>
<div class="mode-card" id="card_deploy" onclick="selectMode('deploy')">
<div class="mode-icon">🚀</div>
<div class="mode-title">Create &amp; Deploy</div>
<div class="mode-desc">
Register the player <em>and</em> automatically push the Linux player
code to the target host via SSH.
</div>
</div>
</div>
<!-- ─────────────────────── Mode 1 — Manual Registration ─────────────────── -->
<div class="card mode-panel active" id="panel_manual">
<form method="POST">
<h3 class="section-header blue" style="margin-top: 0;">
Basic Information
</h3>
<h3 class="section-header blue" style="margin-top: 0;">Basic Information</h3>
<div class="form-group">
<label>Display Name *</label>
<input type="text" name="name" required class="form-control"
@@ -145,31 +166,28 @@
<input type="text" name="hostname" required class="form-control"
placeholder="e.g., office-player-001">
<small class="form-help">
Unique identifier for this player (must match screen_name in player config)
Unique identifier must match <code>screen_name</code> in the player's
<code>app_config.json</code>
</small>
</div>
<div class="form-group">
<label>Location</label>
<input type="text" name="location" class="form-control"
placeholder="e.g., Main Office - Reception Area">
placeholder="e.g., Main Office Reception Area">
<small class="form-help">Physical location of the player (optional)</small>
</div>
<h3 class="section-header green">
Authentication
</h3>
<h3 class="section-header green">Authentication</h3>
<p class="form-help" style="margin-bottom: 1rem;">
Choose one authentication method (Quick Connect recommended for easy setup)
</p>
<div class="form-group">
<label>Password</label>
<input type="password" name="password" id="password" class="form-control"
<input type="password" name="password" class="form-control"
placeholder="Leave empty to use Quick Connect only">
<small class="form-help">
Secure password for player authentication (optional if using Quick Connect)
</small>
<small class="form-help">Secure password for player authentication (optional if using Quick Connect)</small>
</div>
<div class="form-group">
@@ -177,13 +195,11 @@
<input type="text" name="quickconnect_code" required class="form-control"
placeholder="e.g., OFFICE123">
<small class="form-help">
Easy pairing code for quick setup (must match quickconnect_key in player config)
Easy pairing code must match <code>quickconnect_key</code> in player config
</small>
</div>
<h3 class="section-header yellow">
Display Settings
</h3>
<h3 class="section-header yellow">Display Settings</h3>
<div class="form-group">
<label>Orientation</label>
@@ -199,7 +215,9 @@
<select name="playlist_id" class="form-control">
<option value="">No Playlist (Unassigned)</option>
{% for playlist in playlists %}
<option value="{{ playlist.id }}">{{ playlist.name }} ({{ playlist.orientation }}) - {{ playlist.content_count }} items</option>
<option value="{{ playlist.id }}">
{{ playlist.name }} ({{ playlist.orientation }}) {{ playlist.content_count }} items
</option>
{% endfor %}
</select>
<small class="form-help">Assign player to a playlist (optional)</small>
@@ -208,16 +226,16 @@
<div class="info-box">
<h4>📋 Setup Instructions</h4>
<ol style="margin: 0.5rem 0; padding-left: 1.5rem;">
<li>Create the player with the form above</li>
<li>Note the generated <strong>Auth Code</strong> (shown after creation)</li>
<li>Configure the player's <code>app_config.json</code> with:
<li>Create the player with the form above.</li>
<li>Note the generated <strong>Auth Code</strong> shown after creation.</li>
<li>Configure the player's <code>app_config.json</code>:
<ul style="margin-top: 0.5rem;">
<li><code>server_ip</code>: Your server address</li>
<li><code>screen_name</code>: Same as <strong>Hostname</strong> above</li>
<li><code>quickconnect_key</code>: Same as <strong>Quick Connect Code</strong> above</li>
<li><code>server_ip</code> — your server address</li>
<li><code>screen_name</code> — same as <strong>Hostname</strong> above</li>
<li><code>quickconnect_key</code> — same as <strong>Quick Connect Code</strong> above</li>
</ul>
</li>
<li>Start the player - it will authenticate automatically</li>
<li>Start the player it will authenticate automatically.</li>
</ol>
</div>
@@ -225,11 +243,257 @@
<button type="submit" class="btn btn-success" style="padding: 0.75rem 2rem;">
✓ Create Player
</button>
<a href="{{ url_for('players.list') }}" class="btn" style="padding: 0.75rem 2rem; margin-left: 1rem;">
<a href="{{ url_for('players.list') }}" class="btn btn-secondary"
style="padding: 0.75rem 2rem; margin-left: 0.5rem;">
Cancel
</a>
</div>
</form>
</div>
<!-- ──────────────────────── Mode 2 — Create & Deploy ────────────────────── -->
<div class="card mode-panel" id="panel_deploy">
<!-- Step 1 — SSH connection test -->
<div class="ssh-section">
<h3 class="section-header purple" style="margin-top: 0;">
🔌 Step 1 — SSH Connection
</h3>
<p class="form-help">
Test SSH connectivity to the target Linux host before proceeding.
</p>
<div class="row2col">
<div class="form-group">
<label>Target Hostname / IP *</label>
<input type="text" id="ssh_hostname" class="form-control"
placeholder="e.g., 192.168.1.100">
<small class="form-help">IP address or hostname of the target machine</small>
</div>
<div class="form-group">
<label>SSH Port</label>
<input type="number" id="ssh_port" class="form-control"
value="22" min="1" max="65535">
<small class="form-help">SSH port (default: 22)</small>
</div>
</div>
<div class="row2col">
<div class="form-group">
<label>SSH Username *</label>
<input type="text" id="ssh_username" class="form-control"
placeholder="e.g., pi or ubuntu">
<small class="form-help">SSH login username</small>
</div>
<div class="form-group">
<label>SSH Password *</label>
<input type="password" id="ssh_password" class="form-control"
placeholder="SSH password">
<small class="form-help">SSH login password</small>
</div>
</div>
<button type="button" id="test_ssh_btn" class="btn btn-primary"
onclick="testSSHConnection()">
✓ Test SSH Connection
</button>
<button type="button" id="clear_ssh_btn" class="btn btn-secondary"
onclick="clearSSHForm()" style="display: none;">
🔄 Clear
</button>
<div id="connection_status" class="connection-status"></div>
</div>
<!-- Step 2 — Player info (shown after successful SSH test) -->
<div id="deploy_player_form_section" class="deploy-player-form">
<form method="POST" id="add_player_form">
<!-- Hidden SSH fields -->
<input type="hidden" id="form_ssh_hostname" name="ssh_hostname" value="">
<input type="hidden" id="form_ssh_username" name="ssh_username" value="">
<input type="hidden" id="form_ssh_password" name="ssh_password" value="">
<input type="hidden" id="form_ssh_port" name="ssh_port" value="">
<input type="hidden" name="deploy_player" value="1">
<h3 class="section-header blue">Step 2 — Basic Information</h3>
<div class="form-group">
<label>Display Name *</label>
<input type="text" name="name" required class="form-control"
placeholder="e.g., Office Reception Player">
<small class="form-help">Friendly name for the player</small>
</div>
<div class="form-group">
<label>Hostname *</label>
<input type="text" name="hostname" required class="form-control"
placeholder="e.g., office-player-001">
<small class="form-help">
Unique identifier — will be written to <code>app_config.json</code>
on the remote host automatically
</small>
</div>
<div class="form-group">
<label>Location</label>
<input type="text" name="location" class="form-control"
placeholder="e.g., Main Office Reception Area">
<small class="form-help">Physical location (optional)</small>
</div>
<h3 class="section-header green">Authentication</h3>
<div class="form-group">
<label>Password</label>
<input type="password" name="password" class="form-control"
placeholder="Leave empty to use Quick Connect only">
<small class="form-help">Secure password (optional if using Quick Connect)</small>
</div>
<div class="form-group">
<label>Quick Connect Code *</label>
<input type="text" name="quickconnect_code" required class="form-control"
placeholder="e.g., OFFICE123">
<small class="form-help">Easy pairing code — deployed automatically to remote config</small>
</div>
<h3 class="section-header yellow">Display Settings</h3>
<div class="form-group">
<label>Orientation</label>
<select name="orientation" class="form-control">
<option value="Landscape" selected>Landscape</option>
<option value="Portrait">Portrait</option>
</select>
<small class="form-help">Display orientation for the player</small>
</div>
<div class="form-group">
<label>Assign Playlist</label>
<select name="playlist_id" class="form-control">
<option value="">No Playlist (Unassigned)</option>
{% for playlist in playlists %}
<option value="{{ playlist.id }}">
{{ playlist.name }} ({{ playlist.orientation }}) {{ playlist.content_count }} items
</option>
{% endfor %}
</select>
<small class="form-help">Assign player to a playlist (optional)</small>
</div>
<div class="info-box">
<h4>🚀 What Happens Next</h4>
<ol style="margin: 0.5rem 0; padding-left: 1.5rem;">
<li><strong>Player Record</strong> is created in the database with an Auth Code.</li>
<li><strong>Player Code</strong> from the Kiwy-Signage repository is pushed to
<strong id="deploy_host_info">the target host</strong> via SSH.</li>
<li><strong>Installation Scripts</strong> run on the remote host in the background.</li>
<li><strong>Config</strong> (<code>app_config.json</code>) is written automatically
with the server address, hostname and Quick Connect Code.</li>
</ol>
</div>
<div style="margin-top: 2rem; padding-top: 1rem; border-top: 1px solid #ddd;">
<button type="submit" class="btn btn-success" style="padding: 0.75rem 2rem;">
⚙️ Create &amp; Deploy Player
</button>
<a href="{{ url_for('players.list') }}" class="btn btn-secondary"
style="padding: 0.75rem 2rem; margin-left: 0.5rem;">
Cancel
</a>
</div>
</form>
</div>
</div><!-- /panel_deploy -->
</div>
<script>
// ── Mode switching ─────────────────────────────────────────────────────────
function selectMode(mode) {
// cards
document.getElementById('card_manual').classList.remove('active', 'active-deploy');
document.getElementById('card_deploy').classList.remove('active', 'active-deploy');
if (mode === 'manual') {
document.getElementById('card_manual').classList.add('active');
} else {
document.getElementById('card_deploy').classList.add('active-deploy');
}
// panels
document.getElementById('panel_manual').classList.toggle('active', mode === 'manual');
document.getElementById('panel_deploy').classList.toggle('active', mode === 'deploy');
}
// ── SSH flow ───────────────────────────────────────────────────────────────
function testSSHConnection() {
const hostname = document.getElementById('ssh_hostname').value.trim();
const username = document.getElementById('ssh_username').value.trim();
const password = document.getElementById('ssh_password').value.trim();
const port = parseInt(document.getElementById('ssh_port').value) || 22;
if (!hostname || !username || !password) {
alert('Please fill in all SSH connection fields.');
return;
}
const btn = document.getElementById('test_ssh_btn');
const statusDiv = document.getElementById('connection_status');
btn.disabled = true;
btn.innerHTML = '<span class="loading-spinner"></span>Testing connection…';
fetch('{{ url_for("api.test_ssh_connection") }}', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({hostname, username, password, port})
})
.then(r => r.json())
.then(data => {
statusDiv.className = 'connection-status ' + (data.success ? 'success' : 'error');
statusDiv.innerHTML = '<strong>' + (data.success ? '✓ Connected!' : '✗ Connection Failed')
+ '</strong><br>' + data.message;
if (data.success) {
// Lock SSH fields
['ssh_hostname','ssh_username','ssh_password','ssh_port'].forEach(id => {
document.getElementById(id).disabled = true;
});
document.getElementById('test_ssh_btn').style.display = 'none';
document.getElementById('clear_ssh_btn').style.display = 'inline-block';
// Copy credentials to hidden form fields
document.getElementById('form_ssh_hostname').value = hostname;
document.getElementById('form_ssh_username').value = username;
document.getElementById('form_ssh_password').value = password;
document.getElementById('form_ssh_port').value = port;
// Reveal player form and update host label
document.getElementById('deploy_player_form_section').classList.add('active');
document.getElementById('deploy_host_info').textContent = hostname;
}
btn.disabled = false;
btn.innerHTML = '✓ Test SSH Connection';
})
.catch(err => {
statusDiv.className = 'connection-status error';
statusDiv.innerHTML = '<strong>✗ Error:</strong> ' + err.message;
btn.disabled = false;
btn.innerHTML = '✓ Test SSH Connection';
});
}
function clearSSHForm() {
['ssh_hostname','ssh_username','ssh_password','ssh_port'].forEach(id => {
const el = document.getElementById(id);
el.value = id === 'ssh_port' ? '22' : '';
el.disabled = false;
});
document.getElementById('test_ssh_btn').style.display = 'inline-block';
document.getElementById('clear_ssh_btn').style.display = 'none';
document.getElementById('connection_status').className = 'connection-status';
document.getElementById('deploy_player_form_section').classList.remove('active');
document.getElementById('add_player_form').reset();
}
</script>
{% endblock %}