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:
@@ -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>
|
||||
|
||||
@@ -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 & 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 %}
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 & 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 & 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 %}
|
||||
|
||||
Reference in New Issue
Block a user