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:
@@ -15,6 +15,10 @@ RUN apt-get update && \
|
|||||||
libreoffice-core \
|
libreoffice-core \
|
||||||
libreoffice-impress \
|
libreoffice-impress \
|
||||||
libreoffice-writer \
|
libreoffice-writer \
|
||||||
|
sshpass \
|
||||||
|
openssh-client \
|
||||||
|
rsync \
|
||||||
|
git \
|
||||||
&& apt-get clean && \
|
&& apt-get clean && \
|
||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
|||||||
+14
-1
@@ -41,6 +41,11 @@ def create_app(config_name=None):
|
|||||||
# Apply ProxyFix middleware for reverse proxy (Nginx/Caddy)
|
# Apply ProxyFix middleware for reverse proxy (Nginx/Caddy)
|
||||||
# This ensures proper handling of X-Forwarded-* headers
|
# This ensures proper handling of X-Forwarded-* headers
|
||||||
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1)
|
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1)
|
||||||
|
|
||||||
|
# ScriptNameFix: reads X-Script-Name header set by the umbrella nginx
|
||||||
|
# (e.g. /digiserver) so that url_for() generates correct full paths.
|
||||||
|
from app.utils.script_name_fix import ScriptNameFix
|
||||||
|
app.wsgi_app = ScriptNameFix(app.wsgi_app)
|
||||||
|
|
||||||
# Initialize extensions
|
# Initialize extensions
|
||||||
db.init_app(app)
|
db.init_app(app)
|
||||||
@@ -70,7 +75,15 @@ def create_app(config_name=None):
|
|||||||
register_commands(app)
|
register_commands(app)
|
||||||
register_context_processors(app)
|
register_context_processors(app)
|
||||||
register_template_filters(app)
|
register_template_filters(app)
|
||||||
|
|
||||||
|
# Portal SSO: auto-login users arriving via the umbrella nginx gateway
|
||||||
|
from app.utils.portal_sso import init_portal_sso
|
||||||
|
init_portal_sso(app)
|
||||||
|
|
||||||
|
# Ensure DB schema exists (idempotent; safe to call even with migrate)
|
||||||
|
with app.app_context():
|
||||||
|
db.create_all()
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+147
-6
@@ -11,7 +11,6 @@ from app.extensions import db, bcrypt
|
|||||||
from app.models import User, Player, Content, ServerLog, Playlist, HTTPSConfig
|
from app.models import User, Player, Content, ServerLog, Playlist, HTTPSConfig
|
||||||
from app.utils.logger import log_action
|
from app.utils.logger import log_action
|
||||||
from app.utils.caddy_manager import CaddyConfigGenerator
|
from app.utils.caddy_manager import CaddyConfigGenerator
|
||||||
from app.utils.nginx_config_reader import get_nginx_status
|
|
||||||
|
|
||||||
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
|
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
|
||||||
|
|
||||||
@@ -871,14 +870,10 @@ def https_config():
|
|||||||
db.session.commit()
|
db.session.commit()
|
||||||
log_action('info', f'HTTPS status auto-corrected to enabled (detected from request)')
|
log_action('info', f'HTTPS status auto-corrected to enabled (detected from request)')
|
||||||
|
|
||||||
# Get Nginx configuration status
|
|
||||||
nginx_status = get_nginx_status()
|
|
||||||
|
|
||||||
return render_template('admin/https_config.html',
|
return render_template('admin/https_config.html',
|
||||||
config=config,
|
config=config,
|
||||||
is_https_active=is_https_active,
|
is_https_active=is_https_active,
|
||||||
current_host=current_host,
|
current_host=current_host)
|
||||||
nginx_status=nginx_status)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log_action('error', f'Error loading HTTPS config page: {str(e)}')
|
log_action('error', f'Error loading HTTPS config page: {str(e)}')
|
||||||
flash('Error loading HTTPS configuration page.', 'danger')
|
flash('Error loading HTTPS configuration page.', 'danger')
|
||||||
@@ -1015,3 +1010,149 @@ def https_config_status():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
log_action('error', f'Error getting HTTPS status: {str(e)}')
|
log_action('error', f'Error getting HTTPS status: {str(e)}')
|
||||||
return jsonify({'error': str(e)}), 500
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Player Build & Deployment Routes
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
def _player_build_meta_path() -> str:
|
||||||
|
"""Path to the persisted player-build settings (in the instance folder)."""
|
||||||
|
from app.utils.player_build import BUILD_META_FILENAME
|
||||||
|
return os.path.join(current_app.instance_path, BUILD_META_FILENAME)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route('/build-player', methods=['GET'])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def build_player():
|
||||||
|
"""Display the 'Build player files for deployment' admin page."""
|
||||||
|
from app.utils.ssh_deploy import get_local_player_code_status
|
||||||
|
from app.utils.player_build import load_build_settings
|
||||||
|
|
||||||
|
player_code_dir = current_app.config['PLAYER_CODE_DIR']
|
||||||
|
settings = load_build_settings(_player_build_meta_path()) or {}
|
||||||
|
|
||||||
|
# Prefill server address from saved settings, else from HTTPS config.
|
||||||
|
if not settings.get('server_ip'):
|
||||||
|
https_cfg = HTTPSConfig.get_config()
|
||||||
|
if https_cfg and (https_cfg.domain or https_cfg.ip_address):
|
||||||
|
settings.setdefault('server_ip', https_cfg.domain or https_cfg.ip_address)
|
||||||
|
settings.setdefault('port', str(https_cfg.port or 443))
|
||||||
|
settings.setdefault('use_https', bool(https_cfg.https_enabled))
|
||||||
|
|
||||||
|
# Sensible defaults.
|
||||||
|
settings.setdefault('repo_url', current_app.config.get('PLAYER_REPO_URL', ''))
|
||||||
|
settings.setdefault('branch', 'main')
|
||||||
|
default_host = request.host.split(':')[0]
|
||||||
|
if default_host in ('localhost', '127.0.0.1', '') or default_host.startswith('127.'):
|
||||||
|
from app.utils.ssh_deploy import detect_server_ip
|
||||||
|
default_host = detect_server_ip() or default_host
|
||||||
|
settings.setdefault('server_ip', default_host)
|
||||||
|
settings.setdefault('port', '443')
|
||||||
|
settings.setdefault('use_https', True)
|
||||||
|
settings.setdefault('verify_ssl', False)
|
||||||
|
settings.setdefault('orientation', 'Landscape')
|
||||||
|
settings.setdefault('max_resolution', '1920x1080')
|
||||||
|
|
||||||
|
code_status = get_local_player_code_status(player_code_dir)
|
||||||
|
if code_status.get('updated'):
|
||||||
|
code_status['updated_str'] = datetime.fromtimestamp(
|
||||||
|
code_status['updated']).strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'admin/build_player.html',
|
||||||
|
settings=settings,
|
||||||
|
code_status=code_status,
|
||||||
|
player_code_dir=player_code_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route('/build-player', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def build_player_action():
|
||||||
|
"""Build/refresh the staged player code and/or write its base config."""
|
||||||
|
from app.utils.player_build import (
|
||||||
|
build_player_files, write_base_config, get_short_head,
|
||||||
|
save_build_settings, make_build_record,
|
||||||
|
)
|
||||||
|
|
||||||
|
player_code_dir = current_app.config['PLAYER_CODE_DIR']
|
||||||
|
action = request.form.get('action', 'build_and_config')
|
||||||
|
|
||||||
|
repo_url = request.form.get('repo_url', '').strip()
|
||||||
|
branch = request.form.get('branch', 'main').strip() or 'main'
|
||||||
|
server_ip = request.form.get('server_ip', '').strip()
|
||||||
|
port = request.form.get('port', '443').strip()
|
||||||
|
use_https = request.form.get('use_https') == 'on'
|
||||||
|
verify_ssl = request.form.get('verify_ssl') == 'on'
|
||||||
|
orientation = request.form.get('orientation', 'Landscape').strip() or 'Landscape'
|
||||||
|
max_resolution = request.form.get('max_resolution', '1920x1080').strip() or '1920x1080'
|
||||||
|
|
||||||
|
# Validation
|
||||||
|
errors = []
|
||||||
|
if action in ('build_files', 'build_and_config') and not repo_url:
|
||||||
|
errors.append('Repository URL is required to build player files.')
|
||||||
|
if action in ('save_config', 'build_and_config') and not server_ip:
|
||||||
|
errors.append('Server IP / domain is required for the player configuration.')
|
||||||
|
try:
|
||||||
|
port_num = int(port)
|
||||||
|
if port_num < 1 or port_num > 65535:
|
||||||
|
errors.append('Port must be between 1 and 65535.')
|
||||||
|
except ValueError:
|
||||||
|
errors.append('Port must be a valid number.')
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
for err in errors:
|
||||||
|
flash(err, 'warning')
|
||||||
|
return redirect(url_for('admin.build_player'))
|
||||||
|
|
||||||
|
messages = []
|
||||||
|
success = True
|
||||||
|
version = None
|
||||||
|
|
||||||
|
# Step 1: build/refresh files from the repository.
|
||||||
|
if action in ('build_files', 'build_and_config'):
|
||||||
|
result = build_player_files(player_code_dir, repo_url, branch)
|
||||||
|
version = result.get('version')
|
||||||
|
messages.append(result['message'])
|
||||||
|
if not result['success']:
|
||||||
|
success = False
|
||||||
|
log_action('error', f'Player build failed by {current_user.username}: {result["message"]}')
|
||||||
|
|
||||||
|
# Step 2: write the base config (only if the previous step didn't fail).
|
||||||
|
if success and action in ('save_config', 'build_and_config'):
|
||||||
|
cfg_result = write_base_config(
|
||||||
|
player_code_dir=player_code_dir,
|
||||||
|
server_ip=server_ip,
|
||||||
|
port=port,
|
||||||
|
use_https=use_https,
|
||||||
|
verify_ssl=verify_ssl,
|
||||||
|
orientation=orientation,
|
||||||
|
max_resolution=max_resolution,
|
||||||
|
)
|
||||||
|
messages.append(cfg_result['message'])
|
||||||
|
if not cfg_result['success']:
|
||||||
|
success = False
|
||||||
|
|
||||||
|
# Persist settings so deployment uses the same server address.
|
||||||
|
if version is None:
|
||||||
|
version = get_short_head(player_code_dir)
|
||||||
|
save_build_settings(
|
||||||
|
_player_build_meta_path(),
|
||||||
|
make_build_record(
|
||||||
|
repo_url=repo_url, branch=branch, server_ip=server_ip, port=port,
|
||||||
|
use_https=use_https, verify_ssl=verify_ssl, orientation=orientation,
|
||||||
|
max_resolution=max_resolution, version=version, built_by=current_user.username,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
summary = ' '.join(messages) if messages else 'No action performed.'
|
||||||
|
if success:
|
||||||
|
log_action('info', f'Player files built by {current_user.username} (version {version})')
|
||||||
|
flash(f'✅ {summary}', 'success')
|
||||||
|
else:
|
||||||
|
flash(f'⚠️ {summary}', 'danger')
|
||||||
|
|
||||||
|
return redirect(url_for('admin.build_player'))
|
||||||
|
|||||||
+126
-3
@@ -3,6 +3,7 @@ from flask import Blueprint, request, jsonify, current_app
|
|||||||
from functools import wraps
|
from functools import wraps
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
import secrets
|
import secrets
|
||||||
|
import hashlib
|
||||||
import bcrypt
|
import bcrypt
|
||||||
from typing import Optional, Dict, List
|
from typing import Optional, Dict, List
|
||||||
|
|
||||||
@@ -396,9 +397,13 @@ def get_cached_playlist(player_id: int) -> List[Dict]:
|
|||||||
for idx, content in enumerate(content_list, start=1):
|
for idx, content in enumerate(content_list, start=1):
|
||||||
# Generate full URL for content
|
# Generate full URL for content
|
||||||
from flask import request as current_request
|
from flask import request as current_request
|
||||||
# Get server base URL
|
|
||||||
server_base = current_request.host_url.rstrip('/')
|
server_base = current_request.host_url.rstrip('/')
|
||||||
content_url = f"{server_base}/static/uploads/{content.filename}"
|
script_root = current_request.script_root.rstrip('/')
|
||||||
|
content_url = f"{server_base}{script_root}/static/uploads/{content.filename}"
|
||||||
|
|
||||||
|
# Web links carry the page URL directly instead of a file download URL.
|
||||||
|
is_weblink = content.content_type == 'weblink'
|
||||||
|
item_url = content.url if is_weblink else content_url
|
||||||
|
|
||||||
playlist_data.append({
|
playlist_data.append({
|
||||||
'id': content.id,
|
'id': content.id,
|
||||||
@@ -406,7 +411,7 @@ def get_cached_playlist(player_id: int) -> List[Dict]:
|
|||||||
'type': content.content_type,
|
'type': content.content_type,
|
||||||
'duration': content._playlist_duration or content.duration or 10,
|
'duration': content._playlist_duration or content.duration or 10,
|
||||||
'position': content._playlist_position or idx,
|
'position': content._playlist_position or idx,
|
||||||
'url': content_url, # Full URL for downloads
|
'url': item_url, # Web page URL for weblinks, file download URL otherwise
|
||||||
'description': content.description,
|
'description': content.description,
|
||||||
'edit_on_player': getattr(content, '_playlist_edit_on_player_enabled', False)
|
'edit_on_player': getattr(content, '_playlist_edit_on_player_enabled', False)
|
||||||
})
|
})
|
||||||
@@ -857,6 +862,124 @@ def receive_edited_media():
|
|||||||
return jsonify({'error': 'Internal server error'}), 500
|
return jsonify({'error': 'Internal server error'}), 500
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# SSH/Deployment Endpoints - For player provisioning and code deployment
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@api_bp.route('/deploy/test-ssh', methods=['POST'])
|
||||||
|
@rate_limit(max_requests=30, window=60)
|
||||||
|
def test_ssh_connection():
|
||||||
|
"""Test SSH connection to a remote host.
|
||||||
|
|
||||||
|
Request JSON:
|
||||||
|
hostname: Target hostname or IP (required)
|
||||||
|
username: SSH username (required)
|
||||||
|
password: SSH password (required)
|
||||||
|
port: SSH port (default: 22)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with connection test result
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from app.utils.ssh_deploy import test_ssh_connection as test_ssh
|
||||||
|
|
||||||
|
data = request.get_json()
|
||||||
|
if not data:
|
||||||
|
return jsonify({'error': 'No data provided'}), 400
|
||||||
|
|
||||||
|
hostname = data.get('hostname', '').strip()
|
||||||
|
username = data.get('username', '').strip()
|
||||||
|
password = data.get('password', '').strip()
|
||||||
|
port = data.get('port', 22)
|
||||||
|
|
||||||
|
if not hostname or not username or not password:
|
||||||
|
return jsonify({'error': 'hostname, username, and password are required'}), 400
|
||||||
|
|
||||||
|
result = test_ssh(hostname, username, password, port)
|
||||||
|
|
||||||
|
log_action('info', f'SSH test for {username}@{hostname}: {result["message"]}')
|
||||||
|
|
||||||
|
return jsonify(result), 200 if result['success'] else 400
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log_action('error', f'Error testing SSH connection: {str(e)}')
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'error': str(e),
|
||||||
|
'message': f'SSH test error: {str(e)}'
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.route('/deploy/player', methods=['POST'])
|
||||||
|
@rate_limit(max_requests=20, window=60)
|
||||||
|
def deploy_player():
|
||||||
|
"""Deploy player code to a remote host via SSH.
|
||||||
|
|
||||||
|
Request JSON:
|
||||||
|
hostname: Target hostname or IP (required)
|
||||||
|
username: SSH username (required)
|
||||||
|
password: SSH password (required)
|
||||||
|
player_name: Name for the player instance (required)
|
||||||
|
port: SSH port (default: 22)
|
||||||
|
deploy_path: Deployment path on remote host
|
||||||
|
repo_url: Git repository URL
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with deployment status and step details
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from app.utils.ssh_deploy import deploy_player_to_host
|
||||||
|
|
||||||
|
data = request.get_json()
|
||||||
|
if not data:
|
||||||
|
return jsonify({'error': 'No data provided'}), 400
|
||||||
|
|
||||||
|
hostname = data.get('hostname', '').strip()
|
||||||
|
username = data.get('username', '').strip()
|
||||||
|
password = data.get('password', '').strip()
|
||||||
|
player_name = data.get('player_name', '').strip()
|
||||||
|
port = data.get('port', 22)
|
||||||
|
deploy_path = data.get('deploy_path', None)
|
||||||
|
repo_url = data.get('repo_url', 'https://gitea.moto-adv.com/ske087/Kiwy-Signage.git').strip()
|
||||||
|
|
||||||
|
if not hostname or not username or not password:
|
||||||
|
return jsonify({'error': 'hostname, username, and password are required'}), 400
|
||||||
|
|
||||||
|
if not player_name:
|
||||||
|
return jsonify({'error': 'player_name is required'}), 400
|
||||||
|
|
||||||
|
scheme = request.headers.get('X-Forwarded-Proto', request.scheme)
|
||||||
|
host = request.headers.get('X-Forwarded-Host', request.host)
|
||||||
|
server_url = f"{scheme}://{host}"
|
||||||
|
|
||||||
|
api_key = hashlib.sha256(f'{player_name}:{hostname}'.encode()).hexdigest()[:32]
|
||||||
|
|
||||||
|
result = deploy_player_to_host(
|
||||||
|
hostname=hostname,
|
||||||
|
username=username,
|
||||||
|
password=password,
|
||||||
|
player_name=player_name,
|
||||||
|
repo_url=repo_url,
|
||||||
|
deploy_path=deploy_path,
|
||||||
|
port=port,
|
||||||
|
server_url=server_url,
|
||||||
|
server_api_key=api_key
|
||||||
|
)
|
||||||
|
|
||||||
|
log_action('info', f'Player deployment for {player_name} on {hostname}: success={result["success"]}')
|
||||||
|
|
||||||
|
return jsonify(result), 200 if result['success'] else 400
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log_action('error', f'Error deploying player: {str(e)}')
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'error': str(e),
|
||||||
|
'message': f'Deployment error: {str(e)}',
|
||||||
|
'steps': []
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
@api_bp.errorhandler(404)
|
@api_bp.errorhandler(404)
|
||||||
def api_not_found(error):
|
def api_not_found(error):
|
||||||
"""Handle 404 errors in API."""
|
"""Handle 404 errors in API."""
|
||||||
|
|||||||
+159
-2
@@ -6,6 +6,9 @@ from werkzeug.utils import secure_filename
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
import os
|
import os
|
||||||
import threading
|
import threading
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from app.extensions import db, cache
|
from app.extensions import db, cache
|
||||||
from app.models import Content, Playlist, Player
|
from app.models import Content, Playlist, Player
|
||||||
@@ -201,8 +204,10 @@ def manage_playlist_content(playlist_id: int):
|
|||||||
# Get content in playlist (ordered)
|
# Get content in playlist (ordered)
|
||||||
playlist_content = playlist.get_content_ordered()
|
playlist_content = playlist.get_content_ordered()
|
||||||
|
|
||||||
# Get all available content not in this playlist
|
# Get all available content not in this playlist.
|
||||||
all_content = Content.query.all()
|
# Web links are created on demand per playlist, so they are not offered
|
||||||
|
# as reusable library items here.
|
||||||
|
all_content = Content.query.filter(Content.content_type != 'weblink').all()
|
||||||
playlist_content_ids = {c.id for c in playlist_content}
|
playlist_content_ids = {c.id for c in playlist_content}
|
||||||
available_content = [c for c in all_content if c.id not in playlist_content_ids]
|
available_content = [c for c in all_content if c.id not in playlist_content_ids]
|
||||||
|
|
||||||
@@ -262,6 +267,152 @@ def add_content_to_playlist(playlist_id: int):
|
|||||||
return redirect(url_for('content.manage_playlist_content', playlist_id=playlist_id))
|
return redirect(url_for('content.manage_playlist_content', playlist_id=playlist_id))
|
||||||
|
|
||||||
|
|
||||||
|
@content_bp.route('/add-weblink', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def add_weblink():
|
||||||
|
"""Create a web link content item from the Upload Media page.
|
||||||
|
|
||||||
|
Optionally adds it directly to a playlist if playlist_id is supplied.
|
||||||
|
Returns JSON when the request carries Accept: application/json, otherwise
|
||||||
|
redirects back to the upload page.
|
||||||
|
"""
|
||||||
|
use_json = 'application/json' in request.accept_mimetypes.best or \
|
||||||
|
request.headers.get('X-Requested-With') == 'XMLHttpRequest'
|
||||||
|
|
||||||
|
try:
|
||||||
|
web_url = (request.form.get('url') or '').strip()
|
||||||
|
duration = request.form.get('duration', type=int, default=30)
|
||||||
|
description = (request.form.get('description') or '').strip() or None
|
||||||
|
playlist_id = request.form.get('playlist_id', type=int)
|
||||||
|
|
||||||
|
parsed = urlparse(web_url)
|
||||||
|
if parsed.scheme.lower() not in ('http', 'https') or not parsed.netloc:
|
||||||
|
if use_json:
|
||||||
|
return jsonify({'success': False, 'error': 'Please enter a valid http:// or https:// web address.'}), 400
|
||||||
|
flash('Please enter a valid http:// or https:// web address.', 'warning')
|
||||||
|
return redirect(url_for('content.upload_media_page'))
|
||||||
|
|
||||||
|
if not duration or duration < 1:
|
||||||
|
duration = 30
|
||||||
|
|
||||||
|
content = Content(
|
||||||
|
filename=f'weblink-{uuid.uuid4().hex[:12]}',
|
||||||
|
content_type='weblink',
|
||||||
|
url=web_url,
|
||||||
|
duration=duration,
|
||||||
|
description=description or web_url,
|
||||||
|
uploaded_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.session.add(content)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
if playlist_id:
|
||||||
|
playlist = Playlist.query.get(playlist_id)
|
||||||
|
if playlist:
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
max_pos = db.session.execute(
|
||||||
|
select(func.max(playlist_content.c.position)).where(
|
||||||
|
playlist_content.c.playlist_id == playlist_id
|
||||||
|
)
|
||||||
|
).scalar() or 0
|
||||||
|
db.session.execute(
|
||||||
|
playlist_content.insert().values(
|
||||||
|
playlist_id=playlist_id,
|
||||||
|
content_id=content.id,
|
||||||
|
position=max_pos + 1,
|
||||||
|
duration=duration,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
playlist.increment_version()
|
||||||
|
log_action('info', f'Web link "{web_url}" added to playlist "{playlist.name}"')
|
||||||
|
else:
|
||||||
|
log_action('warning', f'Web link "{web_url}" created; playlist {playlist_id} not found')
|
||||||
|
else:
|
||||||
|
log_action('info', f'Web link "{web_url}" added to media library')
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
cache.clear()
|
||||||
|
|
||||||
|
if use_json:
|
||||||
|
return jsonify({'success': True, 'content_id': content.id, 'message': 'Web link added successfully.'})
|
||||||
|
flash('Web link added successfully.', 'success')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
log_action('error', f'Error adding web link: {str(e)}')
|
||||||
|
if use_json:
|
||||||
|
return jsonify({'success': False, 'error': 'Failed to add web link.'}), 500
|
||||||
|
flash('Error adding web link.', 'danger')
|
||||||
|
|
||||||
|
return redirect(url_for('content.upload_media_page'))
|
||||||
|
|
||||||
|
|
||||||
|
@content_bp.route('/playlist/<int:playlist_id>/add-weblink', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def add_weblink_to_playlist(playlist_id: int):
|
||||||
|
"""Create a web link content item and add it to the playlist."""
|
||||||
|
playlist = Playlist.query.get_or_404(playlist_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
web_url = (request.form.get('url') or '').strip()
|
||||||
|
duration = request.form.get('duration', type=int, default=30)
|
||||||
|
description = (request.form.get('description') or '').strip() or None
|
||||||
|
|
||||||
|
# Validate the URL: only http/https schemes are allowed (avoid file://, etc.)
|
||||||
|
parsed = urlparse(web_url)
|
||||||
|
if parsed.scheme.lower() not in ('http', 'https') or not parsed.netloc:
|
||||||
|
flash('Please enter a valid http:// or https:// web address.', 'warning')
|
||||||
|
return redirect(url_for('content.manage_playlist_content', playlist_id=playlist_id))
|
||||||
|
|
||||||
|
if duration is None or duration < 1:
|
||||||
|
duration = 30
|
||||||
|
|
||||||
|
# Create a weblink Content row. filename is a synthetic unique label
|
||||||
|
# (no file on disk); the real target lives in the url column.
|
||||||
|
content = Content(
|
||||||
|
filename=f'weblink-{uuid.uuid4().hex[:12]}',
|
||||||
|
content_type='weblink',
|
||||||
|
url=web_url,
|
||||||
|
duration=duration,
|
||||||
|
description=description or web_url,
|
||||||
|
uploaded_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.session.add(content)
|
||||||
|
db.session.flush() # assign content.id
|
||||||
|
|
||||||
|
# Append to the end of the playlist
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
|
||||||
|
max_pos = db.session.execute(
|
||||||
|
select(func.max(playlist_content.c.position)).where(
|
||||||
|
playlist_content.c.playlist_id == playlist_id
|
||||||
|
)
|
||||||
|
).scalar() or 0
|
||||||
|
|
||||||
|
db.session.execute(
|
||||||
|
playlist_content.insert().values(
|
||||||
|
playlist_id=playlist_id,
|
||||||
|
content_id=content.id,
|
||||||
|
position=max_pos + 1,
|
||||||
|
duration=duration,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
playlist.increment_version()
|
||||||
|
db.session.commit()
|
||||||
|
cache.clear()
|
||||||
|
|
||||||
|
log_action('info', f'Added web link "{web_url}" to playlist "{playlist.name}"')
|
||||||
|
flash('Web link added to playlist.', 'success')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
log_action('error', f'Error adding web link to playlist: {str(e)}')
|
||||||
|
flash('Error adding web link to playlist.', 'danger')
|
||||||
|
|
||||||
|
return redirect(url_for('content.manage_playlist_content', playlist_id=playlist_id))
|
||||||
|
|
||||||
|
|
||||||
@content_bp.route('/playlist/<int:playlist_id>/remove-content/<int:content_id>', methods=['POST'])
|
@content_bp.route('/playlist/<int:playlist_id>/remove-content/<int:content_id>', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def remove_content_from_playlist(playlist_id: int, content_id: int):
|
def remove_content_from_playlist(playlist_id: int, content_id: int):
|
||||||
@@ -277,6 +428,12 @@ def remove_content_from_playlist(playlist_id: int, content_id: int):
|
|||||||
(playlist_content.c.content_id == content_id)
|
(playlist_content.c.content_id == content_id)
|
||||||
)
|
)
|
||||||
db.session.execute(stmt)
|
db.session.execute(stmt)
|
||||||
|
|
||||||
|
# Web link items are playlist-specific and have no media-library
|
||||||
|
# presence, so delete the orphan Content row when it is removed.
|
||||||
|
content = db.session.get(Content, content_id)
|
||||||
|
if content is not None and content.content_type == 'weblink':
|
||||||
|
db.session.delete(content)
|
||||||
|
|
||||||
playlist.increment_version()
|
playlist.increment_version()
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""Players blueprint for player management and display."""
|
"""Players blueprint for player management and display."""
|
||||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
|
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, current_app
|
||||||
from flask_login import login_required
|
from flask_login import login_required
|
||||||
from werkzeug.security import generate_password_hash
|
from werkzeug.security import generate_password_hash
|
||||||
import secrets
|
import secrets
|
||||||
@@ -41,7 +41,7 @@ def list():
|
|||||||
@players_bp.route('/add', methods=['GET', 'POST'])
|
@players_bp.route('/add', methods=['GET', 'POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def add_player():
|
def add_player():
|
||||||
"""Add a new player."""
|
"""Add a new player with optional SSH deployment."""
|
||||||
if request.method == 'GET':
|
if request.method == 'GET':
|
||||||
playlists = Playlist.query.filter_by(is_active=True).order_by(Playlist.name).all()
|
playlists = Playlist.query.filter_by(is_active=True).order_by(Playlist.name).all()
|
||||||
return render_template('players/add_player.html', playlists=playlists)
|
return render_template('players/add_player.html', playlists=playlists)
|
||||||
@@ -55,6 +55,13 @@ def add_player():
|
|||||||
orientation = request.form.get('orientation', 'Landscape')
|
orientation = request.form.get('orientation', 'Landscape')
|
||||||
playlist_id = request.form.get('playlist_id', '').strip()
|
playlist_id = request.form.get('playlist_id', '').strip()
|
||||||
|
|
||||||
|
# Get SSH deployment info if provided
|
||||||
|
ssh_hostname = request.form.get('ssh_hostname', '').strip()
|
||||||
|
ssh_username = request.form.get('ssh_username', '').strip()
|
||||||
|
ssh_password = request.form.get('ssh_password', '').strip()
|
||||||
|
ssh_port = int(request.form.get('ssh_port', '22')) if request.form.get('ssh_port') else 22
|
||||||
|
deploy_player = request.form.get('deploy_player', '').strip()
|
||||||
|
|
||||||
# Validation
|
# Validation
|
||||||
if not name or len(name) < 3:
|
if not name or len(name) < 3:
|
||||||
flash('Player name must be at least 3 characters long.', 'warning')
|
flash('Player name must be at least 3 characters long.', 'warning')
|
||||||
@@ -102,14 +109,82 @@ def add_player():
|
|||||||
|
|
||||||
log_action('info', f'Player "{name}" (hostname: {hostname}) created')
|
log_action('info', f'Player "{name}" (hostname: {hostname}) created')
|
||||||
|
|
||||||
|
# If deployment requested and SSH credentials provided, trigger background deployment
|
||||||
|
deployment_initiated = False
|
||||||
|
if deploy_player and ssh_hostname and ssh_username and ssh_password:
|
||||||
|
try:
|
||||||
|
from app.utils.background_tasks import background_player_deployment, run_background_task
|
||||||
|
|
||||||
|
# Determine the server address the player should contact.
|
||||||
|
from flask import request as flask_request
|
||||||
|
from app.models.https_config import HTTPSConfig
|
||||||
|
|
||||||
|
server_url = None
|
||||||
|
try:
|
||||||
|
import os
|
||||||
|
from app.utils.player_build import get_player_server_settings, BUILD_META_FILENAME
|
||||||
|
meta_path = os.path.join(current_app.instance_path, BUILD_META_FILENAME)
|
||||||
|
build_srv = get_player_server_settings(meta_path)
|
||||||
|
if build_srv:
|
||||||
|
scheme = 'https' if build_srv['use_https'] else 'http'
|
||||||
|
server_url = f"{scheme}://{build_srv['server_ip']}:{build_srv['port']}"
|
||||||
|
except Exception:
|
||||||
|
server_url = None
|
||||||
|
|
||||||
|
if not server_url:
|
||||||
|
https_cfg = HTTPSConfig.get_config()
|
||||||
|
if https_cfg and https_cfg.https_enabled and (https_cfg.domain or https_cfg.ip_address):
|
||||||
|
host = https_cfg.domain or https_cfg.ip_address
|
||||||
|
cfg_port = https_cfg.port or 443
|
||||||
|
server_url = f"https://{host}:{cfg_port}"
|
||||||
|
else:
|
||||||
|
host = flask_request.host
|
||||||
|
hostname_only = host.split(':')[0]
|
||||||
|
if hostname_only in ('localhost', '127.0.0.1', '') or hostname_only.startswith('127.'):
|
||||||
|
from app.utils.ssh_deploy import detect_server_ip
|
||||||
|
detected_ip = detect_server_ip()
|
||||||
|
if detected_ip:
|
||||||
|
port_part = host.split(':', 1)[1] if ':' in host else ''
|
||||||
|
host = f"{detected_ip}:{port_part}" if port_part else detected_ip
|
||||||
|
server_url = f"{flask_request.scheme}://{host}"
|
||||||
|
|
||||||
|
# Generate API key for player authentication
|
||||||
|
import hashlib
|
||||||
|
api_key = hashlib.sha256(f'{name}:{hostname}'.encode()).hexdigest()[:32]
|
||||||
|
|
||||||
|
# Start deployment in background thread
|
||||||
|
run_background_task(
|
||||||
|
background_player_deployment,
|
||||||
|
hostname=ssh_hostname,
|
||||||
|
username=ssh_username,
|
||||||
|
password=ssh_password,
|
||||||
|
player_name=name,
|
||||||
|
player_id=new_player.id,
|
||||||
|
port=ssh_port,
|
||||||
|
server_url=server_url,
|
||||||
|
server_api_key=api_key,
|
||||||
|
player_hostname=hostname,
|
||||||
|
quickconnect_code=quickconnect_code,
|
||||||
|
orientation=orientation
|
||||||
|
)
|
||||||
|
deployment_initiated = True
|
||||||
|
log_action('info', f'Background deployment initiated for player "{name}" on {ssh_hostname}')
|
||||||
|
except Exception as deploy_err:
|
||||||
|
log_action('error', f'Failed to initiate background deployment for player "{name}": {str(deploy_err)}')
|
||||||
|
|
||||||
# Flash detailed success message
|
# Flash detailed success message
|
||||||
success_msg = f'''
|
success_msg = f'''
|
||||||
Player "{name}" created successfully!<br>
|
Player "{name}" created successfully!<br>
|
||||||
<strong>Auth Code:</strong> {auth_code}<br>
|
<strong>Auth Code:</strong> <code style="background: #f0f0f0; padding: 2px 6px; border-radius: 3px;">{auth_code}</code><br>
|
||||||
<strong>Hostname:</strong> {hostname}<br>
|
<strong>Hostname:</strong> {hostname}<br>
|
||||||
<strong>Quick Connect:</strong> {quickconnect_code}<br>
|
<strong>Quick Connect:</strong> {quickconnect_code}<br>
|
||||||
<small>Configure the player with these credentials in app_config.json</small>
|
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
if deployment_initiated:
|
||||||
|
success_msg += f'<strong style="color: #0275d8;">⌛ Deployment in Progress</strong> Deploying to {ssh_hostname} in background...<br>'
|
||||||
|
success_msg += '<small>Check player status to see deployment completion</small><br>'
|
||||||
|
|
||||||
|
success_msg += '<small>Configure the player with these credentials in app_config.json</small>'
|
||||||
flash(success_msg, 'success')
|
flash(success_msg, 'success')
|
||||||
|
|
||||||
return redirect(url_for('players.list'))
|
return redirect(url_for('players.list'))
|
||||||
@@ -426,9 +501,14 @@ def get_player_playlist(player_id: int) -> List[dict]:
|
|||||||
# Build playlist
|
# Build playlist
|
||||||
playlist = []
|
playlist = []
|
||||||
for content in ordered_content:
|
for content in ordered_content:
|
||||||
|
# For weblinks, serve the actual URL directly; for files, serve static path
|
||||||
|
if content.content_type == 'weblink' and content.url:
|
||||||
|
item_url = content.url
|
||||||
|
else:
|
||||||
|
item_url = url_for('static', filename=f'uploads/{content.filename}')
|
||||||
playlist.append({
|
playlist.append({
|
||||||
'id': content.id,
|
'id': content.id,
|
||||||
'url': url_for('static', filename=f'uploads/{content.filename}'),
|
'url': item_url,
|
||||||
'type': content.content_type,
|
'type': content.content_type,
|
||||||
'duration': getattr(content, '_playlist_duration', content.duration or 10),
|
'duration': getattr(content, '_playlist_duration', content.duration or 10),
|
||||||
'position': getattr(content, '_playlist_position', 0),
|
'position': getattr(content, '_playlist_position', 0),
|
||||||
|
|||||||
@@ -48,6 +48,10 @@ class Config:
|
|||||||
DEFAULT_ADMIN_USER = os.getenv('ADMIN_USER', 'admin')
|
DEFAULT_ADMIN_USER = os.getenv('ADMIN_USER', 'admin')
|
||||||
DEFAULT_ADMIN_PASSWORD = os.getenv('ADMIN_PASSWORD', 'Initial01!')
|
DEFAULT_ADMIN_PASSWORD = os.getenv('ADMIN_PASSWORD', 'Initial01!')
|
||||||
|
|
||||||
|
# Player deployment — staged code directory and default repo
|
||||||
|
PLAYER_CODE_DIR = os.getenv('PLAYER_CODE_DIR', '/app/data/player')
|
||||||
|
PLAYER_REPO_URL = os.getenv('PLAYER_REPO_URL', '')
|
||||||
|
|
||||||
|
|
||||||
class DevelopmentConfig(Config):
|
class DevelopmentConfig(Config):
|
||||||
"""Development configuration"""
|
"""Development configuration"""
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ class Content(db.Model):
|
|||||||
content_type = db.Column(db.String(50), nullable=False, index=True)
|
content_type = db.Column(db.String(50), nullable=False, index=True)
|
||||||
duration = db.Column(db.Integer, default=10, nullable=True)
|
duration = db.Column(db.Integer, default=10, nullable=True)
|
||||||
file_size = db.Column(db.BigInteger, nullable=True)
|
file_size = db.Column(db.BigInteger, nullable=True)
|
||||||
|
# For 'weblink' content this holds the web page URL to display on the player.
|
||||||
|
# NULL for file-based content (image/video/pdf).
|
||||||
|
url = db.Column(db.String(2048), nullable=True)
|
||||||
description = db.Column(db.Text, nullable=True)
|
description = db.Column(db.Text, nullable=True)
|
||||||
uploaded_at = db.Column(db.DateTime, default=datetime.utcnow,
|
uploaded_at = db.Column(db.DateTime, default=datetime.utcnow,
|
||||||
nullable=False, index=True)
|
nullable=False, index=True)
|
||||||
@@ -61,3 +64,7 @@ class Content(db.Model):
|
|||||||
def is_pdf(self) -> bool:
|
def is_pdf(self) -> bool:
|
||||||
"""Check if content is a PDF."""
|
"""Check if content is a PDF."""
|
||||||
return self.content_type == 'pdf'
|
return self.content_type == 'pdf'
|
||||||
|
|
||||||
|
def is_weblink(self) -> bool:
|
||||||
|
"""Check if content is a web link (URL) rather than an uploaded file."""
|
||||||
|
return self.content_type == 'weblink'
|
||||||
|
|||||||
@@ -41,6 +41,12 @@ class Player(db.Model):
|
|||||||
playlist_id = db.Column(db.Integer, db.ForeignKey('playlist.id', ondelete='SET NULL'),
|
playlist_id = db.Column(db.Integer, db.ForeignKey('playlist.id', ondelete='SET NULL'),
|
||||||
nullable=True, index=True)
|
nullable=True, index=True)
|
||||||
|
|
||||||
|
# Deployment tracking
|
||||||
|
deployment_status = db.Column(db.String(50), default='pending', nullable=True) # pending, deployed, failed
|
||||||
|
last_deployment_at = db.Column(db.DateTime, nullable=True)
|
||||||
|
last_deployment_status = db.Column(db.String(50), nullable=True) # success, failed
|
||||||
|
last_deployment_message = db.Column(db.Text, nullable=True)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
playlist = db.relationship('Playlist', back_populates='players')
|
playlist = db.relationship('Playlist', back_populates='players')
|
||||||
feedback = db.relationship('PlayerFeedback', back_populates='player',
|
feedback = db.relationship('PlayerFeedback', back_populates='player',
|
||||||
|
|||||||
@@ -95,6 +95,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</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) -->
|
<!-- Logo Customization Card (Admin Only) -->
|
||||||
<div class="card management-card" style="background: linear-gradient(135deg, #fa709a 0%, #fee140 100%);">
|
<div class="card management-card" style="background: linear-gradient(135deg, #fa709a 0%, #fee140 100%);">
|
||||||
<h2>🎨 Logo Customization</h2>
|
<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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Nginx Status Card -->
|
<!-- Caddy Reverse Proxy Status Card -->
|
||||||
<div class="card nginx-status-card">
|
<div class="card nginx-status-card">
|
||||||
<h2>🔧 Nginx Reverse Proxy Status</h2>
|
<h2>🔧 Caddy Reverse Proxy Status</h2>
|
||||||
{% if nginx_status.available %}
|
<div class="nginx-status-content">
|
||||||
<div class="nginx-status-content">
|
<div class="status-item">
|
||||||
<div class="status-item">
|
<strong>Reverse Proxy:</strong>
|
||||||
<strong>Status:</strong>
|
<span class="badge badge-success">✅ Caddy</span>
|
||||||
<span class="badge badge-success">✅ Nginx Configured</span>
|
</div>
|
||||||
</div>
|
<div class="status-item">
|
||||||
|
<strong>SSL/TLS:</strong>
|
||||||
<div class="status-item">
|
{% if config and config.https_enabled %}
|
||||||
<strong>Configuration Path:</strong>
|
<span class="badge badge-success">🔒 Enabled — auto-managed by Caddy</span>
|
||||||
<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>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="status-item">
|
<span class="badge badge-warning">⚠️ HTTP only — enable HTTPS above</span>
|
||||||
<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>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
<div class="status-item">
|
||||||
<div class="status-disabled">
|
<strong>Certificate Provider:</strong>
|
||||||
<p>⚠️ <strong>Nginx configuration not accessible</strong></p>
|
<code>Let's Encrypt (automatic)</code>
|
||||||
<p>Error: {{ nginx_status.error|default('Unknown error') }}</p>
|
|
||||||
<p style="font-size: 12px; color: #666;">Path checked: {{ nginx_status.path }}</p>
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Information Section -->
|
<!-- Information Section -->
|
||||||
|
|||||||
@@ -314,11 +314,18 @@
|
|||||||
</td>
|
</td>
|
||||||
<td><span class="drag-handle">⋮⋮</span></td>
|
<td><span class="drag-handle">⋮⋮</span></td>
|
||||||
<td>{{ loop.index }}</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>
|
<td>
|
||||||
{% if content.content_type == 'image' %}📷 Image
|
{% if content.content_type == 'image' %}📷 Image
|
||||||
{% elif content.content_type == 'video' %}🎥 Video
|
{% elif content.content_type == 'video' %}🎥 Video
|
||||||
{% elif content.content_type == 'pdf' %}📄 PDF
|
{% elif content.content_type == 'pdf' %}📄 PDF
|
||||||
|
{% elif content.content_type == 'weblink' %}🔗 Web Link
|
||||||
{% else %}📁 Other{% endif %}
|
{% else %}📁 Other{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -401,6 +408,22 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<h2 style="margin-bottom: 20px;">➕ Add Content</h2>
|
<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 %}
|
{% if available_content %}
|
||||||
<div class="available-content">
|
<div class="available-content">
|
||||||
{% for content in available_content %}
|
{% for content in available_content %}
|
||||||
|
|||||||
@@ -317,6 +317,66 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</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>
|
<script>
|
||||||
const uploadZone = document.getElementById('upload-zone');
|
const uploadZone = document.getElementById('upload-zone');
|
||||||
const fileInput = document.getElementById('file-input');
|
const fileInput = document.getElementById('file-input');
|
||||||
@@ -509,6 +569,44 @@
|
|||||||
uploadBtn.disabled = true;
|
uploadBtn.disabled = true;
|
||||||
uploadBtn.innerHTML = '⏳ Uploading...';
|
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>
|
</script>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -4,135 +4,156 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<style>
|
<style>
|
||||||
.form-group {
|
.form-group { margin-bottom: 1rem; }
|
||||||
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 label {
|
|
||||||
font-weight: bold;
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.dark-mode .form-group label {
|
|
||||||
color: #e2e8f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-control {
|
.form-control {
|
||||||
width: 100%;
|
width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;
|
||||||
padding: 0.5rem;
|
box-sizing: border-box;
|
||||||
border: 1px solid #ddd;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body.dark-mode .form-control {
|
body.dark-mode .form-control {
|
||||||
background: #1a202c;
|
background: #1a202c; border-color: #4a5568; color: #e2e8f0;
|
||||||
border-color: #4a5568;
|
|
||||||
color: #e2e8f0;
|
|
||||||
}
|
}
|
||||||
|
body.dark-mode .form-control:focus { border-color: #7c3aed; outline: none; }
|
||||||
body.dark-mode .form-control:focus {
|
|
||||||
border-color: #7c3aed;
|
.form-help { color: #6c757d; font-size: 0.875rem; }
|
||||||
outline: none;
|
body.dark-mode .form-help { color: #718096; }
|
||||||
}
|
|
||||||
|
|
||||||
.form-help {
|
|
||||||
color: #6c757d;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.dark-mode .form-help {
|
|
||||||
color: #718096;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-header {
|
.section-header {
|
||||||
margin-top: 2rem;
|
margin-top: 2rem; padding-bottom: 0.5rem; border-bottom: 2px solid;
|
||||||
padding-bottom: 0.5rem;
|
|
||||||
border-bottom: 2px solid;
|
|
||||||
}
|
}
|
||||||
|
.section-header.blue { border-color: #007bff; }
|
||||||
.section-header.blue {
|
body.dark-mode .section-header.blue { border-color: #667eea; }
|
||||||
border-color: #007bff;
|
.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.blue {
|
body.dark-mode .section-header.yellow { border-color: #ecc94b; }
|
||||||
border-color: #667eea;
|
.section-header.purple { border-color: #9b59b6; }
|
||||||
}
|
body.dark-mode .section-header.purple { border-color: #b794f6; }
|
||||||
|
|
||||||
.section-header.green {
|
body.dark-mode h1, body.dark-mode h3, body.dark-mode h4 { color: #e2e8f0; }
|
||||||
border-color: #28a745;
|
body.dark-mode p { color: #a0aec0; }
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-box {
|
.info-box {
|
||||||
background-color: #e7f3ff;
|
background-color: #e7f3ff; border-left: 4px solid #007bff; padding: 1rem; margin: 2rem 0;
|
||||||
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 {
|
.info-box h4 { margin-top: 0; color: #007bff; }
|
||||||
background-color: #1a365d;
|
body.dark-mode .info-box h4 { color: #667eea; }
|
||||||
border-left-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;
|
||||||
}
|
}
|
||||||
|
@media (max-width: 700px) { .mode-chooser { grid-template-columns: 1fr; } }
|
||||||
.info-box h4 {
|
|
||||||
margin-top: 0;
|
.mode-card {
|
||||||
color: #007bff;
|
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 .mode-card { background: #2d3748; border-color: #4a5568; }
|
||||||
body.dark-mode .info-box h4 {
|
|
||||||
color: #667eea;
|
.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;
|
||||||
}
|
}
|
||||||
|
body.dark-mode .ssh-section { background-color: #1e2a38; }
|
||||||
.info-box code {
|
|
||||||
background: #f4f4f4;
|
.connection-status {
|
||||||
padding: 2px 6px;
|
padding: 1rem; border-radius: 4px; margin-top: 1rem; display: none;
|
||||||
border-radius: 3px;
|
|
||||||
}
|
}
|
||||||
|
.connection-status.success {
|
||||||
body.dark-mode .info-box code {
|
background-color: #d4edda; border: 1px solid #c3e6cb; color: #155724; display: block;
|
||||||
background: #2d3748;
|
|
||||||
color: #e2e8f0;
|
|
||||||
}
|
}
|
||||||
|
.connection-status.error {
|
||||||
body.dark-mode small {
|
background-color: #f8d7da; border: 1px solid #f5c6cb; color: #721c24; display: block;
|
||||||
color: #718096;
|
|
||||||
}
|
}
|
||||||
|
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>
|
</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>
|
<h1>Add New Player</h1>
|
||||||
<p style="color: #6c757d; margin-bottom: 2rem;">
|
<p style="color: #6c757d; margin-bottom: 1.5rem;">
|
||||||
Create a new digital signage player with authentication credentials
|
Choose how you want to add the player to the system.
|
||||||
</p>
|
</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">
|
<form method="POST">
|
||||||
<h3 class="section-header blue" style="margin-top: 0;">
|
<h3 class="section-header blue" style="margin-top: 0;">Basic Information</h3>
|
||||||
Basic Information
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Display Name *</label>
|
<label>Display Name *</label>
|
||||||
<input type="text" name="name" required class="form-control"
|
<input type="text" name="name" required class="form-control"
|
||||||
@@ -145,31 +166,28 @@
|
|||||||
<input type="text" name="hostname" required class="form-control"
|
<input type="text" name="hostname" required class="form-control"
|
||||||
placeholder="e.g., office-player-001">
|
placeholder="e.g., office-player-001">
|
||||||
<small class="form-help">
|
<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>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Location</label>
|
<label>Location</label>
|
||||||
<input type="text" name="location" class="form-control"
|
<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>
|
<small class="form-help">Physical location of the player (optional)</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 class="section-header green">
|
<h3 class="section-header green">Authentication</h3>
|
||||||
Authentication
|
|
||||||
</h3>
|
|
||||||
<p class="form-help" style="margin-bottom: 1rem;">
|
<p class="form-help" style="margin-bottom: 1rem;">
|
||||||
Choose one authentication method (Quick Connect recommended for easy setup)
|
Choose one authentication method (Quick Connect recommended for easy setup)
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Password</label>
|
<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">
|
placeholder="Leave empty to use Quick Connect only">
|
||||||
<small class="form-help">
|
<small class="form-help">Secure password for player authentication (optional if using Quick Connect)</small>
|
||||||
Secure password for player authentication (optional if using Quick Connect)
|
|
||||||
</small>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -177,13 +195,11 @@
|
|||||||
<input type="text" name="quickconnect_code" required class="form-control"
|
<input type="text" name="quickconnect_code" required class="form-control"
|
||||||
placeholder="e.g., OFFICE123">
|
placeholder="e.g., OFFICE123">
|
||||||
<small class="form-help">
|
<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>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 class="section-header yellow">
|
<h3 class="section-header yellow">Display Settings</h3>
|
||||||
Display Settings
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Orientation</label>
|
<label>Orientation</label>
|
||||||
@@ -199,7 +215,9 @@
|
|||||||
<select name="playlist_id" class="form-control">
|
<select name="playlist_id" class="form-control">
|
||||||
<option value="">No Playlist (Unassigned)</option>
|
<option value="">No Playlist (Unassigned)</option>
|
||||||
{% for playlist in playlists %}
|
{% 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 %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<small class="form-help">Assign player to a playlist (optional)</small>
|
<small class="form-help">Assign player to a playlist (optional)</small>
|
||||||
@@ -208,16 +226,16 @@
|
|||||||
<div class="info-box">
|
<div class="info-box">
|
||||||
<h4>📋 Setup Instructions</h4>
|
<h4>📋 Setup Instructions</h4>
|
||||||
<ol style="margin: 0.5rem 0; padding-left: 1.5rem;">
|
<ol style="margin: 0.5rem 0; padding-left: 1.5rem;">
|
||||||
<li>Create the player with the form above</li>
|
<li>Create the player with the form above.</li>
|
||||||
<li>Note the generated <strong>Auth Code</strong> (shown after creation)</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>Configure the player's <code>app_config.json</code>:
|
||||||
<ul style="margin-top: 0.5rem;">
|
<ul style="margin-top: 0.5rem;">
|
||||||
<li><code>server_ip</code>: Your server address</li>
|
<li><code>server_ip</code> — your server address</li>
|
||||||
<li><code>screen_name</code>: Same as <strong>Hostname</strong> above</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>quickconnect_key</code> — same as <strong>Quick Connect Code</strong> above</li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li>Start the player - it will authenticate automatically</li>
|
<li>Start the player — it will authenticate automatically.</li>
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -225,11 +243,257 @@
|
|||||||
<button type="submit" class="btn btn-success" style="padding: 0.75rem 2rem;">
|
<button type="submit" class="btn btn-success" style="padding: 0.75rem 2rem;">
|
||||||
✓ Create Player
|
✓ Create Player
|
||||||
</button>
|
</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
|
Cancel
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</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>
|
</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 %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"""Background task execution for long-running operations."""
|
||||||
|
import threading
|
||||||
|
import logging
|
||||||
|
from typing import Callable, Any, Dict
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def run_background_task(task_func: Callable, *args, **kwargs) -> threading.Thread:
|
||||||
|
"""Run a function in a background thread, with a Flask app context pushed."""
|
||||||
|
from flask import current_app
|
||||||
|
# Capture the app instance now (in the request context) so the thread can use it
|
||||||
|
app = current_app._get_current_object()
|
||||||
|
|
||||||
|
def wrapper():
|
||||||
|
with app.app_context():
|
||||||
|
try:
|
||||||
|
logger.info(f"Starting background task: {task_func.__name__}")
|
||||||
|
task_func(*args, **kwargs)
|
||||||
|
logger.info(f"Completed background task: {task_func.__name__}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Background task failed ({task_func.__name__}): {str(e)}", exc_info=True)
|
||||||
|
|
||||||
|
thread = threading.Thread(target=wrapper, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
return thread
|
||||||
|
|
||||||
|
|
||||||
|
def background_player_deployment(
|
||||||
|
hostname: str,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
player_name: str,
|
||||||
|
player_id: int,
|
||||||
|
port: int = 22,
|
||||||
|
server_url: str = None,
|
||||||
|
server_api_key: str = None,
|
||||||
|
player_hostname: str = None,
|
||||||
|
quickconnect_code: str = None,
|
||||||
|
orientation: str = 'Landscape',
|
||||||
|
verify_ssl: bool = False
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Deploy player code to host in background.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
hostname: SSH hostname/IP
|
||||||
|
username: SSH username
|
||||||
|
password: SSH password
|
||||||
|
player_name: Player name
|
||||||
|
player_id: Player database ID
|
||||||
|
port: SSH port
|
||||||
|
server_url: DigiServer URL for player
|
||||||
|
server_api_key: API key for player
|
||||||
|
player_hostname: Player screen identity used for auth (Player.hostname)
|
||||||
|
quickconnect_code: Quick connect code used for auth
|
||||||
|
orientation: Player orientation (Landscape/Portrait)
|
||||||
|
verify_ssl: Whether the player should verify the server TLS certificate
|
||||||
|
"""
|
||||||
|
from app.utils.ssh_deploy import deploy_player_to_host
|
||||||
|
from app.models import Player
|
||||||
|
from app.extensions import db
|
||||||
|
from app.utils.logger import log_action
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Execute deployment
|
||||||
|
result = deploy_player_to_host(
|
||||||
|
hostname=hostname,
|
||||||
|
username=username,
|
||||||
|
password=password,
|
||||||
|
player_name=player_name,
|
||||||
|
port=port,
|
||||||
|
server_url=server_url,
|
||||||
|
server_api_key=server_api_key,
|
||||||
|
player_hostname=player_hostname,
|
||||||
|
quickconnect_code=quickconnect_code,
|
||||||
|
orientation=orientation,
|
||||||
|
verify_ssl=verify_ssl
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update player with deployment status
|
||||||
|
from datetime import datetime
|
||||||
|
player = Player.query.get(player_id)
|
||||||
|
if player:
|
||||||
|
player.last_deployment_at = datetime.utcnow()
|
||||||
|
if result.get('success'):
|
||||||
|
player.deployment_status = 'deployed'
|
||||||
|
player.last_deployment_status = 'success'
|
||||||
|
player.last_deployment_message = result.get('message', 'Deployment successful')
|
||||||
|
log_action('info', f'Background deployment completed for player "{player_name}": {result["message"]}')
|
||||||
|
else:
|
||||||
|
player.deployment_status = 'failed'
|
||||||
|
player.last_deployment_status = 'failed'
|
||||||
|
player.last_deployment_message = result.get('error', result.get('message', 'Deployment failed'))
|
||||||
|
log_action('error', f'Background deployment failed for player "{player_name}": {result.get("error", result.get("message"))}')
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Background deployment error for player '{player_name}': {str(e)}", exc_info=True)
|
||||||
|
log_action('error', f'Background deployment error for player "{player_name}": {str(e)}')
|
||||||
+116
-2
@@ -3,12 +3,126 @@ import os
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
from app.models.https_config import HTTPSConfig
|
from app.models.https_config import HTTPSConfig
|
||||||
|
|
||||||
|
# Shared reverse-proxy snippet used in every Caddy site block
|
||||||
|
_PROXY_SNIPPET = """\
|
||||||
|
reverse_proxy digiserver-app:5000 {
|
||||||
|
header_up Host {host}
|
||||||
|
header_up X-Real-IP {remote_host}
|
||||||
|
header_up X-Forwarded-Proto {scheme}
|
||||||
|
transport http {
|
||||||
|
read_timeout 300s
|
||||||
|
write_timeout 300s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
request_body {
|
||||||
|
max_size 2GB
|
||||||
|
}
|
||||||
|
|
||||||
|
encode gzip
|
||||||
|
|
||||||
|
header {
|
||||||
|
X-Frame-Options "SAMEORIGIN"
|
||||||
|
X-Content-Type-Options "nosniff"
|
||||||
|
X-XSS-Protection "1; mode=block"
|
||||||
|
}
|
||||||
|
|
||||||
|
log {
|
||||||
|
output file /var/log/caddy/access.log
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
class CaddyConfigGenerator:
|
class CaddyConfigGenerator:
|
||||||
"""Generate Caddyfile configuration based on HTTPSConfig."""
|
"""Generate Caddyfile configuration based on HTTPSConfig."""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def generate_caddyfile(config: Optional[HTTPSConfig] = None) -> str:
|
def generate_caddyfile(config: Optional['HTTPSConfig'] = None) -> str:
|
||||||
|
"""Generate a complete Caddyfile.
|
||||||
|
|
||||||
|
Behaviour:
|
||||||
|
- HTTPS disabled / no domain → HTTP-only on port 80 (initial deploy mode).
|
||||||
|
- HTTPS enabled + real domain → Caddy auto-provisions a Let's Encrypt cert
|
||||||
|
for that domain; HTTP redirects to HTTPS automatically.
|
||||||
|
- HTTPS enabled + IP only (no domain) → TLS with Caddy's internal CA
|
||||||
|
(self-signed, trusted within the Docker network).
|
||||||
|
"""
|
||||||
|
if config is None:
|
||||||
|
config = HTTPSConfig.get_config()
|
||||||
|
|
||||||
|
email = (config.email or "admin@localhost") if config else "admin@localhost"
|
||||||
|
https_enabled = config.https_enabled if config else False
|
||||||
|
domain = (config.domain or "").strip() if config else ""
|
||||||
|
ip_address = (config.ip_address or "").strip() if config else ""
|
||||||
|
|
||||||
|
global_block = f"""{{\n admin 0.0.0.0:2019\n email {email}\n}}\n\n"""
|
||||||
|
|
||||||
|
if https_enabled and domain:
|
||||||
|
# Caddy handles Let's Encrypt + HTTP→HTTPS redirect automatically
|
||||||
|
# when a plain hostname (no scheme) is used.
|
||||||
|
caddyfile = global_block
|
||||||
|
caddyfile += f"{domain} {{\n{_PROXY_SNIPPET}}}\n"
|
||||||
|
# Also accept requests on the raw IP (HTTP only, no cert needed)
|
||||||
|
if ip_address:
|
||||||
|
caddyfile += f"\nhttp://{ip_address} {{\n{_PROXY_SNIPPET}}}\n"
|
||||||
|
elif https_enabled and ip_address:
|
||||||
|
# No public domain — use Caddy's internal CA (self-signed)
|
||||||
|
caddyfile = global_block
|
||||||
|
caddyfile += f"https://{ip_address} {{\n tls internal\n{_PROXY_SNIPPET}}}\n"
|
||||||
|
caddyfile += f"\nhttp://{ip_address} {{\n redir https://{ip_address}{{uri}} 301\n}}\n"
|
||||||
|
else:
|
||||||
|
# HTTP-only fallback (first deploy, before HTTPS is configured)
|
||||||
|
caddyfile = "{\n admin 0.0.0.0:2019\n}\n\n"
|
||||||
|
caddyfile += f":80 {{\n{_PROXY_SNIPPET}}}\n"
|
||||||
|
|
||||||
|
return caddyfile
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def write_caddyfile(caddyfile_content: str,
|
||||||
|
path: str = '/etc/caddy/Caddyfile') -> bool:
|
||||||
|
"""Write Caddyfile to disk.
|
||||||
|
|
||||||
|
The default path is /etc/caddy/Caddyfile — the standard location inside
|
||||||
|
the caddy:2-alpine container when a volume is mounted there.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||||
|
with open(path, 'w') as f:
|
||||||
|
f.write(caddyfile_content)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error writing Caddyfile: {str(e)}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def reload_caddy() -> bool:
|
||||||
|
"""Push the current Caddyfile to Caddy via its admin API (/load).
|
||||||
|
|
||||||
|
Caddy applies the new config live without dropping connections.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
caddyfile_path = '/etc/caddy/Caddyfile'
|
||||||
|
if not os.path.exists(caddyfile_path):
|
||||||
|
print(f"Caddyfile not found at {caddyfile_path}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
with open(caddyfile_path, 'rb') as f:
|
||||||
|
caddyfile_bytes = f.read()
|
||||||
|
|
||||||
|
req = urllib.request.Request(
|
||||||
|
'http://caddy:2019/load',
|
||||||
|
data=caddyfile_bytes,
|
||||||
|
headers={'Content-Type': 'text/caddyfile'},
|
||||||
|
method='POST',
|
||||||
|
)
|
||||||
|
response = urllib.request.urlopen(req, timeout=10)
|
||||||
|
return response.status == 200
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Caddy reload error: {str(e)}")
|
||||||
|
return False
|
||||||
|
|
||||||
"""Generate complete Caddyfile content.
|
"""Generate complete Caddyfile content.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
"""Utilities for building/staging the player files on the server.
|
||||||
|
|
||||||
|
Admins use the "Build player files" admin page to:
|
||||||
|
* clone/refresh the player source code from a git repository into a local
|
||||||
|
staged directory (``PLAYER_CODE_DIR``), and
|
||||||
|
* write a base ``config/app_config.json`` so the staged code already knows how
|
||||||
|
to reach this server.
|
||||||
|
|
||||||
|
The SSH deployment flow then ships this staged directory to player devices, so
|
||||||
|
the version admins build here is exactly what gets deployed.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from app.utils.ssh_deploy import generate_app_config
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Metadata file name stored in the Flask instance folder.
|
||||||
|
BUILD_META_FILENAME = 'player_build.json'
|
||||||
|
|
||||||
|
|
||||||
|
def _run_git(args, cwd=None, timeout=300) -> subprocess.CompletedProcess:
|
||||||
|
return subprocess.run(
|
||||||
|
['git'] + args,
|
||||||
|
cwd=cwd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_short_head(player_code_dir: str) -> str:
|
||||||
|
"""Return the short git commit of the staged code, or 'unknown'."""
|
||||||
|
try:
|
||||||
|
result = _run_git(['-C', player_code_dir, 'rev-parse', '--short', 'HEAD'], timeout=10)
|
||||||
|
if result.returncode == 0:
|
||||||
|
return result.stdout.strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return 'unknown'
|
||||||
|
|
||||||
|
|
||||||
|
def build_player_files(player_code_dir: str, repo_url: str, branch: str = 'main') -> Dict[str, Any]:
|
||||||
|
"""Clone or refresh the player source into ``player_code_dir``.
|
||||||
|
|
||||||
|
If the directory is already a git checkout of ``repo_url`` it is updated in
|
||||||
|
place (fetch + hard reset to the chosen branch). Otherwise it is cloned
|
||||||
|
fresh (an existing non-git directory is replaced).
|
||||||
|
|
||||||
|
Returns a dict: ``success`` (bool), ``message`` (str), ``version`` (str),
|
||||||
|
``branch`` (str).
|
||||||
|
"""
|
||||||
|
branch = (branch or 'main').strip()
|
||||||
|
repo_url = (repo_url or '').strip()
|
||||||
|
if not repo_url:
|
||||||
|
return {'success': False, 'message': 'Repository URL is required.', 'version': None, 'branch': branch}
|
||||||
|
|
||||||
|
try:
|
||||||
|
git_dir = os.path.join(player_code_dir, '.git')
|
||||||
|
is_git_repo = os.path.isdir(git_dir)
|
||||||
|
|
||||||
|
if is_git_repo:
|
||||||
|
# Update existing checkout in place.
|
||||||
|
fetch = _run_git(['-C', player_code_dir, 'fetch', '--prune', 'origin'])
|
||||||
|
if fetch.returncode != 0:
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'message': f'git fetch failed: {fetch.stderr.strip() or fetch.stdout.strip()}',
|
||||||
|
'version': get_short_head(player_code_dir),
|
||||||
|
'branch': branch,
|
||||||
|
}
|
||||||
|
# Point origin at the requested URL in case it changed.
|
||||||
|
_run_git(['-C', player_code_dir, 'remote', 'set-url', 'origin', repo_url])
|
||||||
|
checkout = _run_git(['-C', player_code_dir, 'checkout', branch])
|
||||||
|
if checkout.returncode != 0:
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'message': f'git checkout {branch} failed: {checkout.stderr.strip()}',
|
||||||
|
'version': get_short_head(player_code_dir),
|
||||||
|
'branch': branch,
|
||||||
|
}
|
||||||
|
reset = _run_git(['-C', player_code_dir, 'reset', '--hard', f'origin/{branch}'])
|
||||||
|
if reset.returncode != 0:
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'message': f'git reset failed: {reset.stderr.strip()}',
|
||||||
|
'version': get_short_head(player_code_dir),
|
||||||
|
'branch': branch,
|
||||||
|
}
|
||||||
|
action = 'Updated'
|
||||||
|
else:
|
||||||
|
# Fresh clone. Replace any existing (non-git) directory.
|
||||||
|
parent = os.path.dirname(player_code_dir.rstrip('/'))
|
||||||
|
os.makedirs(parent, exist_ok=True)
|
||||||
|
if os.path.exists(player_code_dir):
|
||||||
|
shutil.rmtree(player_code_dir)
|
||||||
|
clone = _run_git(['clone', '--branch', branch, repo_url, player_code_dir])
|
||||||
|
if clone.returncode != 0:
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'message': f'git clone failed: {clone.stderr.strip() or clone.stdout.strip()}',
|
||||||
|
'version': None,
|
||||||
|
'branch': branch,
|
||||||
|
}
|
||||||
|
action = 'Cloned'
|
||||||
|
|
||||||
|
version = get_short_head(player_code_dir)
|
||||||
|
logger.info('%s player code from %s (%s) -> %s', action, repo_url, branch, version)
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'message': f'{action} player code from {branch} (version {version}).',
|
||||||
|
'version': version,
|
||||||
|
'branch': branch,
|
||||||
|
}
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return {'success': False, 'message': 'Git operation timed out.', 'version': None, 'branch': branch}
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception('build_player_files failed')
|
||||||
|
return {'success': False, 'message': f'Build failed: {str(e)}', 'version': None, 'branch': branch}
|
||||||
|
|
||||||
|
|
||||||
|
def write_base_config(
|
||||||
|
player_code_dir: str,
|
||||||
|
server_ip: str,
|
||||||
|
port: str,
|
||||||
|
use_https: bool = True,
|
||||||
|
verify_ssl: bool = False,
|
||||||
|
orientation: str = 'Landscape',
|
||||||
|
max_resolution: str = '1920x1080',
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Write a base ``config/app_config.json`` into the staged player code.
|
||||||
|
|
||||||
|
``screen_name`` and ``quickconnect_key`` are left blank on purpose: they are
|
||||||
|
per-player and get filled in by the SSH deploy step for each device.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
config_dir = os.path.join(player_code_dir, 'config')
|
||||||
|
os.makedirs(config_dir, exist_ok=True)
|
||||||
|
content = generate_app_config(
|
||||||
|
server_ip=server_ip,
|
||||||
|
port=str(port),
|
||||||
|
screen_name='',
|
||||||
|
quickconnect_code='',
|
||||||
|
orientation=orientation,
|
||||||
|
use_https=use_https,
|
||||||
|
verify_ssl=verify_ssl,
|
||||||
|
max_resolution=max_resolution,
|
||||||
|
)
|
||||||
|
config_path = os.path.join(config_dir, 'app_config.json')
|
||||||
|
with open(config_path, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(content)
|
||||||
|
logger.info('Wrote base player config -> %s', config_path)
|
||||||
|
return {'success': True, 'message': 'Base player config written.', 'path': config_path}
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception('write_base_config failed')
|
||||||
|
return {'success': False, 'message': f'Failed to write config: {str(e)}'}
|
||||||
|
|
||||||
|
|
||||||
|
def load_build_settings(meta_path: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Load saved build settings from ``meta_path`` (or None if absent/invalid)."""
|
||||||
|
try:
|
||||||
|
if os.path.isfile(meta_path):
|
||||||
|
with open(meta_path, 'r', encoding='utf-8') as f:
|
||||||
|
return json.load(f)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning('Could not read build settings: %s', e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def save_build_settings(meta_path: str, data: Dict[str, Any]) -> bool:
|
||||||
|
"""Persist build settings to ``meta_path``."""
|
||||||
|
try:
|
||||||
|
os.makedirs(os.path.dirname(meta_path), exist_ok=True)
|
||||||
|
with open(meta_path, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(data, f, indent=2)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning('Could not save build settings: %s', e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_player_server_settings(meta_path: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Return the saved server address settings for deployment, if available.
|
||||||
|
|
||||||
|
Returns a dict with ``server_ip``, ``port`` (str), ``use_https`` (bool) and
|
||||||
|
``verify_ssl`` (bool), or None when no usable build settings are saved.
|
||||||
|
"""
|
||||||
|
settings = load_build_settings(meta_path)
|
||||||
|
if not settings:
|
||||||
|
return None
|
||||||
|
server_ip = (settings.get('server_ip') or '').strip()
|
||||||
|
if not server_ip:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
'server_ip': server_ip,
|
||||||
|
'port': str(settings.get('port') or ('443' if settings.get('use_https', True) else '80')),
|
||||||
|
'use_https': bool(settings.get('use_https', True)),
|
||||||
|
'verify_ssl': bool(settings.get('verify_ssl', False)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def make_build_record(repo_url, branch, server_ip, port, use_https, verify_ssl,
|
||||||
|
orientation, max_resolution, version, built_by) -> Dict[str, Any]:
|
||||||
|
"""Assemble the metadata record to persist after a build."""
|
||||||
|
return {
|
||||||
|
'repo_url': repo_url,
|
||||||
|
'branch': branch,
|
||||||
|
'server_ip': server_ip,
|
||||||
|
'port': str(port),
|
||||||
|
'use_https': bool(use_https),
|
||||||
|
'verify_ssl': bool(verify_ssl),
|
||||||
|
'orientation': orientation,
|
||||||
|
'max_resolution': max_resolution,
|
||||||
|
'built_version': version,
|
||||||
|
'built_at': datetime.utcnow().isoformat(timespec='seconds') + 'Z',
|
||||||
|
'built_by': built_by,
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""
|
||||||
|
Portal SSO middleware for DigiServer v2.
|
||||||
|
|
||||||
|
When the umbrella nginx verifies the portal JWT it sets two headers:
|
||||||
|
X-Auth-Username — the portal username
|
||||||
|
X-Auth-Role — 'admin' or 'user'
|
||||||
|
|
||||||
|
This before_request handler reads those headers and auto-logs in the
|
||||||
|
corresponding local DigiServer user, creating them on first access if
|
||||||
|
needed. The local session is then maintained normally by Flask-Login.
|
||||||
|
"""
|
||||||
|
import secrets
|
||||||
|
from flask import request
|
||||||
|
from flask_login import login_user, current_user
|
||||||
|
|
||||||
|
|
||||||
|
def init_portal_sso(app):
|
||||||
|
"""Register the SSO before_request handler on the given Flask app."""
|
||||||
|
|
||||||
|
@app.before_request
|
||||||
|
def _portal_sso():
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
return
|
||||||
|
|
||||||
|
username = request.headers.get('X-Auth-Username', '').strip()
|
||||||
|
if not username:
|
||||||
|
return
|
||||||
|
|
||||||
|
role = request.headers.get('X-Auth-Role', 'user').strip()
|
||||||
|
user = _get_or_create_user(username, role)
|
||||||
|
if user:
|
||||||
|
login_user(user, remember=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_or_create_user(username, role):
|
||||||
|
from app.models.user import User
|
||||||
|
from app.extensions import db, bcrypt
|
||||||
|
|
||||||
|
try:
|
||||||
|
user = User.query.filter_by(username=username).first()
|
||||||
|
if not user:
|
||||||
|
hashed_pw = bcrypt.generate_password_hash(secrets.token_hex(32)).decode('utf-8')
|
||||||
|
user = User(
|
||||||
|
username=username,
|
||||||
|
password=hashed_pw,
|
||||||
|
role='admin' if role == 'admin' else 'user',
|
||||||
|
)
|
||||||
|
db.session.add(user)
|
||||||
|
db.session.commit()
|
||||||
|
return user
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""
|
||||||
|
ScriptNameFix WSGI middleware.
|
||||||
|
|
||||||
|
When nginx strips the path prefix before forwarding to a Flask app it also
|
||||||
|
sets the X-Script-Name header (e.g. /digiserver). This middleware reads
|
||||||
|
that header and sets SCRIPT_NAME in the WSGI environ so that Flask's
|
||||||
|
url_for() generates absolute URLs with the correct prefix.
|
||||||
|
|
||||||
|
Usage in the app factory:
|
||||||
|
from app.utils.script_name_fix import ScriptNameFix
|
||||||
|
app.wsgi_app = ScriptNameFix(app.wsgi_app)
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class ScriptNameFix:
|
||||||
|
def __init__(self, app):
|
||||||
|
self.app = app
|
||||||
|
|
||||||
|
def __call__(self, environ, start_response):
|
||||||
|
script_name = environ.get('HTTP_X_SCRIPT_NAME', '').rstrip('/')
|
||||||
|
if script_name:
|
||||||
|
environ['SCRIPT_NAME'] = script_name
|
||||||
|
path_info = environ.get('PATH_INFO', '/')
|
||||||
|
if path_info.startswith(script_name):
|
||||||
|
environ['PATH_INFO'] = path_info[len(script_name):] or '/'
|
||||||
|
return self.app(environ, start_response)
|
||||||
@@ -0,0 +1,753 @@
|
|||||||
|
"""SSH deployment utilities for player provisioning."""
|
||||||
|
import subprocess
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
from typing import Tuple, Dict, Any, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Pre-staged player code location in container
|
||||||
|
LOCAL_PLAYER_CODE_DIR = '/app/data/player'
|
||||||
|
|
||||||
|
|
||||||
|
def get_local_player_code_status(player_code_dir: Optional[str] = None) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Check status of pre-staged player code.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
player_code_dir: Optional override for the staged code path. Defaults to
|
||||||
|
``LOCAL_PLAYER_CODE_DIR`` (the container location).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with availability, version, and path info
|
||||||
|
"""
|
||||||
|
code_dir = player_code_dir or LOCAL_PLAYER_CODE_DIR
|
||||||
|
try:
|
||||||
|
if not os.path.isdir(code_dir):
|
||||||
|
return {
|
||||||
|
'available': False,
|
||||||
|
'reason': 'Directory not found',
|
||||||
|
'path': code_dir
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if git repository
|
||||||
|
git_dir = os.path.join(code_dir, '.git')
|
||||||
|
if not os.path.isdir(git_dir):
|
||||||
|
return {
|
||||||
|
'available': False,
|
||||||
|
'reason': 'Not a git repository',
|
||||||
|
'path': code_dir
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get current git version
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
['git', '-C', code_dir, 'rev-parse', '--short', 'HEAD'],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5
|
||||||
|
)
|
||||||
|
version = result.stdout.strip() if result.returncode == 0 else 'unknown'
|
||||||
|
except:
|
||||||
|
version = 'unknown'
|
||||||
|
|
||||||
|
# Get directory size
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
['du', '-sh', code_dir],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5
|
||||||
|
)
|
||||||
|
size = result.stdout.split()[0] if result.returncode == 0 else 'unknown'
|
||||||
|
except:
|
||||||
|
size = 'unknown'
|
||||||
|
|
||||||
|
return {
|
||||||
|
'available': True,
|
||||||
|
'path': code_dir,
|
||||||
|
'version': version,
|
||||||
|
'size': size,
|
||||||
|
'updated': os.path.getmtime(git_dir),
|
||||||
|
'reason': 'Pre-staged code ready for deployment'
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f'Error checking player code status: {str(e)}')
|
||||||
|
return {
|
||||||
|
'available': False,
|
||||||
|
'reason': f'Status check failed: {str(e)}',
|
||||||
|
'path': code_dir
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_ssh_connection(hostname: str, username: str, password: str, port: int = 22) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Test SSH connection to a remote host.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
hostname: Target hostname or IP
|
||||||
|
username: SSH username
|
||||||
|
password: SSH password
|
||||||
|
port: SSH port (default 22)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with status, message, and timestamp
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Use sshpass to test connection without interactive prompt
|
||||||
|
cmd = [
|
||||||
|
'sshpass', '-p', password,
|
||||||
|
'ssh', '-o', 'StrictHostKeyChecking=no',
|
||||||
|
'-o', 'ConnectTimeout=10',
|
||||||
|
'-p', str(port),
|
||||||
|
f'{username}@{hostname}',
|
||||||
|
'echo "SSH connection successful"'
|
||||||
|
]
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=15
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'message': f'SSH connection successful to {hostname}',
|
||||||
|
'timestamp': datetime.now().isoformat(),
|
||||||
|
'output': result.stdout.strip()
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
error_msg = result.stderr.strip() or result.stdout.strip()
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'message': f'SSH connection failed: {error_msg}',
|
||||||
|
'timestamp': datetime.now().isoformat(),
|
||||||
|
'error': error_msg
|
||||||
|
}
|
||||||
|
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'message': f'SSH connection timeout to {hostname}',
|
||||||
|
'timestamp': datetime.now().isoformat(),
|
||||||
|
'error': 'Connection timeout (10s)'
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'SSH test error: {str(e)}')
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'message': f'SSH connection error: {str(e)}',
|
||||||
|
'timestamp': datetime.now().isoformat(),
|
||||||
|
'error': str(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def generate_player_config(
|
||||||
|
player_name: str,
|
||||||
|
server_url: str,
|
||||||
|
api_key: str,
|
||||||
|
player_id: str = None,
|
||||||
|
location: str = None
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Generate player configuration JSON for connecting to DigiServer.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
player_name: Name of the player
|
||||||
|
server_url: DigiServer base URL (e.g., http://localhost/digiserver)
|
||||||
|
api_key: API authentication key
|
||||||
|
player_id: Optional player ID (defaults to player_name)
|
||||||
|
location: Optional player location/description
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON configuration string
|
||||||
|
"""
|
||||||
|
config = {
|
||||||
|
"player": {
|
||||||
|
"name": player_name,
|
||||||
|
"id": player_id or player_name,
|
||||||
|
"location": location or "",
|
||||||
|
"version": "2.0"
|
||||||
|
},
|
||||||
|
"server": {
|
||||||
|
"url": server_url,
|
||||||
|
"api_endpoint": f"{server_url}/api",
|
||||||
|
"authentication": {
|
||||||
|
"type": "api_key",
|
||||||
|
"key": api_key
|
||||||
|
},
|
||||||
|
"endpoints": {
|
||||||
|
"playlists": f"{server_url}/api/playlists",
|
||||||
|
"content": f"{server_url}/api/content",
|
||||||
|
"schedule": f"{server_url}/api/schedule",
|
||||||
|
"heartbeat": f"{server_url}/api/player/heartbeat",
|
||||||
|
"logs": f"{server_url}/api/player/logs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"playback": {
|
||||||
|
"audio_enabled": True,
|
||||||
|
"video_enabled": True,
|
||||||
|
"max_resolution": "4K",
|
||||||
|
"refresh_interval": 60,
|
||||||
|
"rotation": "0"
|
||||||
|
},
|
||||||
|
"networking": {
|
||||||
|
"timeout": 30,
|
||||||
|
"retry_count": 3,
|
||||||
|
"retry_delay": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.dumps(config, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
def detect_server_ip() -> Optional[str]:
|
||||||
|
"""Best-effort detection of this server's primary LAN IP address.
|
||||||
|
|
||||||
|
Opens a UDP socket toward a public address (no packets are actually sent)
|
||||||
|
and reads the local socket address, which resolves to the IP of the
|
||||||
|
interface used for outbound traffic. Returns None on failure.
|
||||||
|
"""
|
||||||
|
import socket
|
||||||
|
s = None
|
||||||
|
try:
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
s.connect(('8.8.8.8', 80))
|
||||||
|
ip = s.getsockname()[0]
|
||||||
|
if ip and not ip.startswith('127.'):
|
||||||
|
return ip
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
if s is not None:
|
||||||
|
try:
|
||||||
|
s.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Fallback via hostname resolution.
|
||||||
|
try:
|
||||||
|
ip = socket.gethostbyname(socket.gethostname())
|
||||||
|
if ip and not ip.startswith('127.'):
|
||||||
|
return ip
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_server_address(server_url: str) -> Dict[str, Any]:
|
||||||
|
"""Derive the values the player needs from a DigiServer URL.
|
||||||
|
|
||||||
|
The player's config/app_config.json stores server_ip + port + use_https and
|
||||||
|
builds requests as ``{scheme}://{server_ip}:{port}/api/...`` (it does NOT use
|
||||||
|
any URL path prefix such as ``/digiserver``). This helper extracts the host,
|
||||||
|
port and scheme from a server URL and drops any path component.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
server_url: e.g. ``https://signage.example.com/digiserver`` or
|
||||||
|
``http://192.168.0.50:8080``
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with ``server_ip`` (str), ``port`` (str) and ``use_https`` (bool).
|
||||||
|
"""
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
parsed = urlparse(server_url if '://' in (server_url or '') else f'//{server_url}')
|
||||||
|
use_https = (parsed.scheme or 'https') == 'https'
|
||||||
|
host = parsed.hostname or ''
|
||||||
|
port = parsed.port
|
||||||
|
if port is None:
|
||||||
|
port = 443 if use_https else 80
|
||||||
|
return {'server_ip': host, 'port': str(port), 'use_https': use_https}
|
||||||
|
|
||||||
|
|
||||||
|
def generate_app_config(
|
||||||
|
server_ip: str,
|
||||||
|
port: str,
|
||||||
|
screen_name: str,
|
||||||
|
quickconnect_code: str,
|
||||||
|
orientation: str = 'Landscape',
|
||||||
|
use_https: bool = True,
|
||||||
|
verify_ssl: bool = False,
|
||||||
|
max_resolution: str = '1920x1080',
|
||||||
|
) -> str:
|
||||||
|
"""Generate the config/app_config.json the player actually reads.
|
||||||
|
|
||||||
|
This is what binds a deployed player to the real server and its assigned
|
||||||
|
playlist: the player authenticates with ``screen_name`` + ``quickconnect_code``
|
||||||
|
and the server returns the playlist assigned to that player.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
server_ip: Server IP or domain the player should contact.
|
||||||
|
port: Server port as a string.
|
||||||
|
screen_name: Player hostname / screen identity (matches Player.hostname).
|
||||||
|
quickconnect_code: Quick connect code (matches Player.quickconnect_code).
|
||||||
|
orientation: Landscape or Portrait.
|
||||||
|
use_https: Whether the player should use HTTPS.
|
||||||
|
verify_ssl: Whether the player should verify the TLS certificate.
|
||||||
|
max_resolution: Maximum playback resolution.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON configuration string.
|
||||||
|
"""
|
||||||
|
config = {
|
||||||
|
'server_ip': server_ip,
|
||||||
|
'port': str(port),
|
||||||
|
'screen_name': screen_name,
|
||||||
|
'quickconnect_key': quickconnect_code,
|
||||||
|
'orientation': orientation or 'Landscape',
|
||||||
|
'touch': 'True',
|
||||||
|
'max_resolution': max_resolution,
|
||||||
|
'edit_feature_enabled': True,
|
||||||
|
'use_https': bool(use_https),
|
||||||
|
'verify_ssl': bool(verify_ssl),
|
||||||
|
}
|
||||||
|
return json.dumps(config, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
def deploy_player_to_host(
|
||||||
|
hostname: str,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
player_name: str,
|
||||||
|
repo_url: str = 'https://gitea.moto-adv.com/ske087/Kiwy-Signage.git',
|
||||||
|
deploy_path: str = None, # Default: /home/[user]/kiwy-signage
|
||||||
|
port: int = 22,
|
||||||
|
server_url: str = None, # DigiServer URL for player to connect to
|
||||||
|
server_api_key: str = None, # API key for player authentication
|
||||||
|
player_hostname: str = None, # Player screen identity (Player.hostname)
|
||||||
|
quickconnect_code: str = None, # Player quick connect code
|
||||||
|
orientation: str = 'Landscape', # Player orientation
|
||||||
|
verify_ssl: bool = False, # Whether the player should verify TLS
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Deploy player code to remote host.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
hostname: Target hostname or IP
|
||||||
|
username: SSH username
|
||||||
|
password: SSH password
|
||||||
|
player_name: Name for the player instance
|
||||||
|
repo_url: Git repository URL
|
||||||
|
deploy_path: Path where to deploy on remote host (default: /home/[user]/kiwy-signage)
|
||||||
|
port: SSH port (default 22)
|
||||||
|
server_url: DigiServer URL for player connection
|
||||||
|
server_api_key: API key for player authentication
|
||||||
|
player_hostname: Player screen identity used for auth (Player.hostname)
|
||||||
|
quickconnect_code: Quick connect code used for auth (Player.quickconnect_code)
|
||||||
|
orientation: Player orientation (Landscape/Portrait)
|
||||||
|
verify_ssl: Whether the player should verify the server TLS certificate
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with deployment status and output
|
||||||
|
"""
|
||||||
|
# Set default deployment path to user's home directory
|
||||||
|
if deploy_path is None:
|
||||||
|
deploy_path = f'/home/{username}/kiwy-signage'
|
||||||
|
try:
|
||||||
|
# Step 1: Verify host accessibility
|
||||||
|
test_result = test_ssh_connection(hostname, username, password, port)
|
||||||
|
if not test_result['success']:
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'message': 'Cannot deploy: SSH connection failed',
|
||||||
|
'timestamp': datetime.now().isoformat(),
|
||||||
|
'error': test_result['message'],
|
||||||
|
'steps': []
|
||||||
|
}
|
||||||
|
|
||||||
|
steps = [
|
||||||
|
{
|
||||||
|
'step': 'SSH Connection Test',
|
||||||
|
'status': 'completed',
|
||||||
|
'message': 'SSH connection successful',
|
||||||
|
'timestamp': datetime.now().isoformat()
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
# Step 2: Create deployment directory
|
||||||
|
try:
|
||||||
|
mkdir_cmd = [
|
||||||
|
'sshpass', '-p', password,
|
||||||
|
'ssh', '-o', 'StrictHostKeyChecking=no',
|
||||||
|
'-p', str(port),
|
||||||
|
f'{username}@{hostname}',
|
||||||
|
f'mkdir -p {deploy_path}'
|
||||||
|
]
|
||||||
|
result = subprocess.run(mkdir_cmd, capture_output=True, text=True, timeout=30)
|
||||||
|
steps.append({
|
||||||
|
'step': 'Create Deploy Directory',
|
||||||
|
'status': 'completed' if result.returncode == 0 else 'failed',
|
||||||
|
'message': f'Directory {deploy_path} created',
|
||||||
|
'timestamp': datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
steps.append({
|
||||||
|
'step': 'Create Deploy Directory',
|
||||||
|
'status': 'failed',
|
||||||
|
'message': f'Failed: {str(e)}',
|
||||||
|
'timestamp': datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'message': f'Deployment failed at step: Create Deploy Directory',
|
||||||
|
'timestamp': datetime.now().isoformat(),
|
||||||
|
'error': str(e),
|
||||||
|
'steps': steps
|
||||||
|
}
|
||||||
|
|
||||||
|
# Step 3: Deploy code (use local if available, otherwise clone from git)
|
||||||
|
try:
|
||||||
|
code_status = get_local_player_code_status()
|
||||||
|
|
||||||
|
if code_status['available']:
|
||||||
|
# Use pre-staged player code via rsync
|
||||||
|
logger.info(f'Using pre-staged player code (version: {code_status.get("version", "unknown")})')
|
||||||
|
|
||||||
|
rsync_cmd = [
|
||||||
|
'sshpass', '-p', password,
|
||||||
|
'rsync', '-avz',
|
||||||
|
'--delete',
|
||||||
|
'-e', f'ssh -o StrictHostKeyChecking=no -p {port}',
|
||||||
|
f'{LOCAL_PLAYER_CODE_DIR}/',
|
||||||
|
f'{username}@{hostname}:{deploy_path}/'
|
||||||
|
]
|
||||||
|
result = subprocess.run(rsync_cmd, capture_output=True, text=True, timeout=300)
|
||||||
|
|
||||||
|
steps.append({
|
||||||
|
'step': 'Deploy Code',
|
||||||
|
'status': 'completed' if result.returncode == 0 else 'failed',
|
||||||
|
'message': f'Code deployed via rsync (version: {code_status.get("version", "local")})',
|
||||||
|
'timestamp': datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
logger.warning(f'Rsync failed, falling back to git clone: {result.stderr}')
|
||||||
|
# Fall back to git clone
|
||||||
|
raise Exception('Rsync failed, retrying with git')
|
||||||
|
else:
|
||||||
|
# No local code, clone from repository
|
||||||
|
logger.info(f'No pre-staged code available ({code_status.get("reason", "unknown")}), cloning from repository')
|
||||||
|
raise Exception('Local code not available')
|
||||||
|
|
||||||
|
except Exception as rsync_error:
|
||||||
|
# Fallback: Clone or pull repository
|
||||||
|
try:
|
||||||
|
logger.info(f'Deploying via git: {rsync_error}')
|
||||||
|
|
||||||
|
# Check if repo already exists
|
||||||
|
check_cmd = [
|
||||||
|
'sshpass', '-p', password,
|
||||||
|
'ssh', '-o', 'StrictHostKeyChecking=no',
|
||||||
|
'-p', str(port),
|
||||||
|
f'{username}@{hostname}',
|
||||||
|
f'[ -d {deploy_path}/.git ]'
|
||||||
|
]
|
||||||
|
result = subprocess.run(check_cmd, capture_output=True, text=True, timeout=10)
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
# Repo exists, pull latest
|
||||||
|
git_cmd = [
|
||||||
|
'sshpass', '-p', password,
|
||||||
|
'ssh', '-o', 'StrictHostKeyChecking=no',
|
||||||
|
'-p', str(port),
|
||||||
|
f'{username}@{hostname}',
|
||||||
|
f'cd {deploy_path} && git pull origin main 2>&1'
|
||||||
|
]
|
||||||
|
git_msg = 'Pull latest code'
|
||||||
|
else:
|
||||||
|
# Clone repository
|
||||||
|
git_cmd = [
|
||||||
|
'sshpass', '-p', password,
|
||||||
|
'ssh', '-o', 'StrictHostKeyChecking=no',
|
||||||
|
'-p', str(port),
|
||||||
|
f'{username}@{hostname}',
|
||||||
|
f'git clone {repo_url} {deploy_path} 2>&1'
|
||||||
|
]
|
||||||
|
git_msg = 'Clone repository'
|
||||||
|
|
||||||
|
result = subprocess.run(git_cmd, capture_output=True, text=True, timeout=120)
|
||||||
|
steps.append({
|
||||||
|
'step': 'Deploy Code',
|
||||||
|
'status': 'completed' if result.returncode == 0 else 'failed',
|
||||||
|
'message': f'{git_msg}: {result.stdout.split(chr(10))[0][:100]}',
|
||||||
|
'timestamp': datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'message': f'Deployment failed at step: Deploy Code',
|
||||||
|
'timestamp': datetime.now().isoformat(),
|
||||||
|
'error': result.stderr or result.stdout,
|
||||||
|
'steps': steps
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
steps.append({
|
||||||
|
'step': 'Deploy Code',
|
||||||
|
'status': 'failed',
|
||||||
|
'message': f'Failed: {str(e)}',
|
||||||
|
'timestamp': datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'message': f'Deployment failed at step: Deploy Code',
|
||||||
|
'timestamp': datetime.now().isoformat(),
|
||||||
|
'error': str(e),
|
||||||
|
'steps': steps
|
||||||
|
}
|
||||||
|
|
||||||
|
# Step 3.5: Generate player configuration (config/app_config.json)
|
||||||
|
# This is the file the player actually reads to learn the server address
|
||||||
|
# and its screen identity. Authenticating with that identity is what binds
|
||||||
|
# the player to its assigned playlist on the real server.
|
||||||
|
install_env_prefix = ''
|
||||||
|
try:
|
||||||
|
screen_name = player_hostname or player_name
|
||||||
|
if server_url and screen_name and quickconnect_code:
|
||||||
|
addr = parse_server_address(server_url)
|
||||||
|
app_config_content = generate_app_config(
|
||||||
|
server_ip=addr['server_ip'],
|
||||||
|
port=addr['port'],
|
||||||
|
screen_name=screen_name,
|
||||||
|
quickconnect_code=quickconnect_code,
|
||||||
|
orientation=orientation or 'Landscape',
|
||||||
|
use_https=addr['use_https'],
|
||||||
|
verify_ssl=verify_ssl,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build an env prefix so install.sh's configure_player() also runs
|
||||||
|
# (single, consistent configuration path on the player side).
|
||||||
|
import shlex
|
||||||
|
env_pairs = {
|
||||||
|
'KIWY_SERVER_IP': addr['server_ip'],
|
||||||
|
'KIWY_PORT': addr['port'],
|
||||||
|
'KIWY_SCREEN_NAME': screen_name,
|
||||||
|
'KIWY_QUICKCONNECT': quickconnect_code,
|
||||||
|
'KIWY_ORIENTATION': orientation or 'Landscape',
|
||||||
|
'KIWY_USE_HTTPS': 'true' if addr['use_https'] else 'false',
|
||||||
|
'KIWY_VERIFY_SSL': 'true' if verify_ssl else 'false',
|
||||||
|
}
|
||||||
|
install_env_prefix = ' '.join(
|
||||||
|
f'{k}={shlex.quote(str(v))}' for k, v in env_pairs.items()
|
||||||
|
) + ' '
|
||||||
|
|
||||||
|
# Write config/app_config.json directly (robust even if install.sh
|
||||||
|
# is missing or fails), and clear any stale baked-in auth.
|
||||||
|
remote_cmd = (
|
||||||
|
f'mkdir -p {deploy_path}/config && '
|
||||||
|
f"cat > {deploy_path}/config/app_config.json << 'EOF'\n"
|
||||||
|
f'{app_config_content}\n'
|
||||||
|
f'EOF\n'
|
||||||
|
f'rm -f {deploy_path}/player_auth.json {deploy_path}/src/player_auth.json 2>/dev/null || true'
|
||||||
|
)
|
||||||
|
write_config_cmd = [
|
||||||
|
'sshpass', '-p', password,
|
||||||
|
'ssh', '-o', 'StrictHostKeyChecking=no',
|
||||||
|
'-p', str(port),
|
||||||
|
f'{username}@{hostname}',
|
||||||
|
remote_cmd
|
||||||
|
]
|
||||||
|
result = subprocess.run(write_config_cmd, capture_output=True, text=True, timeout=30)
|
||||||
|
steps.append({
|
||||||
|
'step': 'Configure Player',
|
||||||
|
'status': 'completed' if result.returncode == 0 else 'warning',
|
||||||
|
'message': (
|
||||||
|
f'Wrote config/app_config.json '
|
||||||
|
f'(server {addr["server_ip"]}:{addr["port"]}, screen {screen_name})'
|
||||||
|
),
|
||||||
|
'timestamp': datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
steps.append({
|
||||||
|
'step': 'Configure Player',
|
||||||
|
'status': 'skipped',
|
||||||
|
'message': 'Missing server_url / player hostname / quickconnect; player not auto-configured',
|
||||||
|
'timestamp': datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f'Failed to create player config: {str(e)}')
|
||||||
|
steps.append({
|
||||||
|
'step': 'Configure Player',
|
||||||
|
'status': 'warning',
|
||||||
|
'message': f'Failed to write player config: {str(e)}',
|
||||||
|
'timestamp': datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
|
||||||
|
# Step 4: Run installation script
|
||||||
|
# Before running the install script, grant the SSH user temporary
|
||||||
|
# passwordless sudo so that any 'sudo apt-get / pip install' calls inside
|
||||||
|
# install.sh don't hang waiting for an interactive password prompt.
|
||||||
|
# The sudoers entry is removed automatically after the script finishes.
|
||||||
|
try:
|
||||||
|
sudoers_file = f'/etc/sudoers.d/kiwy_deploy_{username}'
|
||||||
|
setup_sudo_cmd = [
|
||||||
|
'sshpass', '-p', password,
|
||||||
|
'ssh', '-o', 'StrictHostKeyChecking=no',
|
||||||
|
'-p', str(port),
|
||||||
|
f'{username}@{hostname}',
|
||||||
|
(
|
||||||
|
f'echo {password!r} | sudo -S bash -c '
|
||||||
|
f'"echo \\"{username} ALL=(ALL) NOPASSWD:ALL\\" '
|
||||||
|
f'> {sudoers_file} && chmod 440 {sudoers_file}" 2>&1'
|
||||||
|
)
|
||||||
|
]
|
||||||
|
sudo_result = subprocess.run(setup_sudo_cmd, capture_output=True, text=True, timeout=15)
|
||||||
|
sudo_configured = sudo_result.returncode == 0
|
||||||
|
if not sudo_configured:
|
||||||
|
logger.warning(f'Could not configure passwordless sudo: {sudo_result.stderr[:200]}')
|
||||||
|
except Exception as e:
|
||||||
|
sudo_configured = False
|
||||||
|
logger.warning(f'Passwordless sudo setup failed: {str(e)}')
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Build a shell one-liner: run the first install script found.
|
||||||
|
# Priority: install.sh > setup.sh > install_player.sh > any *.sh except start.sh
|
||||||
|
run_install_cmd = (
|
||||||
|
f'cd {deploy_path} && '
|
||||||
|
f'INSTALL_SCRIPT="" && '
|
||||||
|
f'for s in install.sh setup.sh install_player.sh; do '
|
||||||
|
f' if [ -f "$s" ]; then INSTALL_SCRIPT="$s"; break; fi; '
|
||||||
|
f'done && '
|
||||||
|
f'if [ -z "$INSTALL_SCRIPT" ]; then '
|
||||||
|
f' INSTALL_SCRIPT=$(ls *.sh 2>/dev/null | grep -v "^start.sh$" | head -1); '
|
||||||
|
f'fi && '
|
||||||
|
f'if [ -n "$INSTALL_SCRIPT" ]; then '
|
||||||
|
f' chmod +x "$INSTALL_SCRIPT" && '
|
||||||
|
f' echo "Running $INSTALL_SCRIPT" && '
|
||||||
|
f' {install_env_prefix}bash "$INSTALL_SCRIPT" 2>&1; '
|
||||||
|
f' echo "Exit code: $?"; '
|
||||||
|
f'else '
|
||||||
|
f' echo "No install script found"; '
|
||||||
|
f'fi'
|
||||||
|
)
|
||||||
|
install_cmd = [
|
||||||
|
'sshpass', '-p', password,
|
||||||
|
'ssh', '-o', 'StrictHostKeyChecking=no',
|
||||||
|
'-p', str(port),
|
||||||
|
f'{username}@{hostname}',
|
||||||
|
run_install_cmd
|
||||||
|
]
|
||||||
|
result = subprocess.run(install_cmd, capture_output=True, text=True, timeout=600)
|
||||||
|
output = (result.stdout or '').strip()
|
||||||
|
|
||||||
|
if 'No install script found' in output:
|
||||||
|
steps.append({
|
||||||
|
'step': 'Run Installation Script',
|
||||||
|
'status': 'skipped',
|
||||||
|
'message': 'No install script found in deploy directory',
|
||||||
|
'timestamp': datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
script_line = next((l for l in output.splitlines() if l.startswith('Running ')), '')
|
||||||
|
script_name = script_line.replace('Running ', '').strip() or 'install script'
|
||||||
|
steps.append({
|
||||||
|
'step': 'Run Installation Script',
|
||||||
|
'status': 'completed' if result.returncode == 0 else 'completed_with_warnings',
|
||||||
|
'message': f'Executed {script_name} (exit {result.returncode})',
|
||||||
|
'timestamp': datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
if result.returncode != 0:
|
||||||
|
logger.warning(f'Install script exited {result.returncode}: {result.stderr[:200]}')
|
||||||
|
else:
|
||||||
|
logger.info(f'Install script completed successfully on {hostname}')
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
steps.append({
|
||||||
|
'step': 'Run Installation Script',
|
||||||
|
'status': 'completed_with_warnings',
|
||||||
|
'message': 'Install script timed out after 600s — it may still be running on the device',
|
||||||
|
'timestamp': datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
steps.append({
|
||||||
|
'step': 'Run Installation Script',
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Error running installation: {str(e)}',
|
||||||
|
'timestamp': datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
logger.error(f'Installation script error: {str(e)}')
|
||||||
|
finally:
|
||||||
|
# Always clean up the temporary sudoers entry
|
||||||
|
if sudo_configured:
|
||||||
|
try:
|
||||||
|
cleanup_cmd = [
|
||||||
|
'sshpass', '-p', password,
|
||||||
|
'ssh', '-o', 'StrictHostKeyChecking=no',
|
||||||
|
'-p', str(port),
|
||||||
|
f'{username}@{hostname}',
|
||||||
|
f'sudo rm -f {sudoers_file} 2>/dev/null || true'
|
||||||
|
]
|
||||||
|
subprocess.run(cleanup_cmd, capture_output=True, text=True, timeout=10)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Step 5: Start player service (execute start.sh)
|
||||||
|
try:
|
||||||
|
# Check if start.sh exists
|
||||||
|
check_start = [
|
||||||
|
'sshpass', '-p', password,
|
||||||
|
'ssh', '-o', 'StrictHostKeyChecking=no',
|
||||||
|
'-p', str(port),
|
||||||
|
f'{username}@{hostname}',
|
||||||
|
f'[ -f {deploy_path}/start.sh ]'
|
||||||
|
]
|
||||||
|
start_check = subprocess.run(check_start, capture_output=True, text=True, timeout=10)
|
||||||
|
|
||||||
|
if start_check.returncode == 0:
|
||||||
|
# Make sure start.sh is executable and run it
|
||||||
|
start_cmd = [
|
||||||
|
'sshpass', '-p', password,
|
||||||
|
'ssh', '-o', 'StrictHostKeyChecking=no',
|
||||||
|
'-p', str(port),
|
||||||
|
f'{username}@{hostname}',
|
||||||
|
f'cd {deploy_path} && chmod +x start.sh && bash start.sh 2>&1'
|
||||||
|
]
|
||||||
|
result = subprocess.run(start_cmd, capture_output=True, text=True, timeout=300)
|
||||||
|
|
||||||
|
# Capture first line of output for feedback
|
||||||
|
output_msg = result.stdout.split('\n')[0][:100] if result.stdout else 'Started'
|
||||||
|
|
||||||
|
steps.append({
|
||||||
|
'step': 'Start Player Service',
|
||||||
|
'status': 'completed' if result.returncode == 0 else 'completed_with_warnings',
|
||||||
|
'message': f'Player service started: {output_msg}',
|
||||||
|
'timestamp': datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
logger.info(f'Player service started on {hostname} at {deploy_path}')
|
||||||
|
else:
|
||||||
|
steps.append({
|
||||||
|
'step': 'Start Player Service',
|
||||||
|
'status': 'warning',
|
||||||
|
'message': 'start.sh not found - player may require manual startup',
|
||||||
|
'timestamp': datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
logger.warning(f'start.sh not found at {deploy_path}/start.sh on {hostname}')
|
||||||
|
except Exception as e:
|
||||||
|
steps.append({
|
||||||
|
'step': 'Start Player Service',
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Error starting player service: {str(e)}',
|
||||||
|
'timestamp': datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
logger.error(f'Failed to start player service: {str(e)}')
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'message': f'Player "{player_name}" deployed successfully to {hostname}',
|
||||||
|
'timestamp': datetime.now().isoformat(),
|
||||||
|
'deploy_path': deploy_path,
|
||||||
|
'steps': steps
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Deployment error: {str(e)}')
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'message': f'Unexpected deployment error: {str(e)}',
|
||||||
|
'timestamp': datetime.now().isoformat(),
|
||||||
|
'error': str(e),
|
||||||
|
'steps': []
|
||||||
|
}
|
||||||
+13
-14
@@ -5,8 +5,11 @@ services:
|
|||||||
build: .
|
build: .
|
||||||
container_name: digiserver-v2
|
container_name: digiserver-v2
|
||||||
# Don't expose directly; use Caddy reverse proxy instead
|
# Don't expose directly; use Caddy reverse proxy instead
|
||||||
|
# Port 5000 is also mapped directly for dev/testing access when Caddy isn't running
|
||||||
expose:
|
expose:
|
||||||
- "5000"
|
- "5000"
|
||||||
|
ports:
|
||||||
|
- "5000:5000"
|
||||||
volumes:
|
volumes:
|
||||||
# Code is in the Docker image - no volume mount needed
|
# Code is in the Docker image - no volume mount needed
|
||||||
# Only mount persistent data folders:
|
# Only mount persistent data folders:
|
||||||
@@ -27,22 +30,18 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- digiserver-network
|
- digiserver-network
|
||||||
|
|
||||||
# Nginx reverse proxy with HTTPS support
|
# Caddy reverse proxy — auto-provisions Let's Encrypt certs when a real domain is configured
|
||||||
nginx:
|
caddy:
|
||||||
image: nginx:alpine
|
image: caddy:2-alpine
|
||||||
container_name: digiserver-nginx
|
container_name: digiserver-caddy
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- "8080:80"
|
||||||
- "443:443"
|
- "8443:443"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data/nginx.conf:/etc/nginx/nginx.conf:ro
|
- ./data/Caddyfile:/etc/caddy/Caddyfile:rw
|
||||||
- ./data/nginx-custom-domains.conf:/etc/nginx/conf.d/custom-domains.conf:rw
|
- ./data/caddy-data:/data
|
||||||
- ./data/nginx-ssl:/etc/nginx/ssl:ro
|
- ./data/caddy-config:/config
|
||||||
- ./data/nginx-logs:/var/log/nginx
|
- ./data/caddy-logs:/var/log/caddy
|
||||||
- ./data/certbot:/var/www/certbot:ro # For Let's Encrypt ACME challenges
|
|
||||||
environment:
|
|
||||||
- DOMAIN=${DOMAIN:-localhost}
|
|
||||||
- EMAIL=${EMAIL:-admin@localhost}
|
|
||||||
depends_on:
|
depends_on:
|
||||||
digiserver-app:
|
digiserver-app:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""Add deployment tracking columns to player table."""
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, '/app')
|
||||||
|
|
||||||
|
from app.app import create_app
|
||||||
|
from app.extensions import db
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
print("Adding deployment tracking columns to player table...")
|
||||||
|
columns = [
|
||||||
|
("deployment_status", "VARCHAR(50) DEFAULT 'pending'"),
|
||||||
|
("last_deployment_at", "DATETIME"),
|
||||||
|
("last_deployment_status", "VARCHAR(50)"),
|
||||||
|
("last_deployment_message", "TEXT"),
|
||||||
|
]
|
||||||
|
for col_name, col_type in columns:
|
||||||
|
try:
|
||||||
|
db.session.execute(text(f"ALTER TABLE player ADD COLUMN {col_name} {col_type}"))
|
||||||
|
db.session.commit()
|
||||||
|
print(f"✓ '{col_name}' column added to player table.")
|
||||||
|
except Exception as e:
|
||||||
|
if 'duplicate column' in str(e).lower() or 'already exists' in str(e).lower():
|
||||||
|
print(f"✓ '{col_name}' column already exists, skipping.")
|
||||||
|
else:
|
||||||
|
print(f"✗ Error adding '{col_name}': {e}")
|
||||||
|
raise
|
||||||
|
print("Done.")
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"""Add url column to content table for weblink support."""
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, '/app')
|
||||||
|
|
||||||
|
from app.app import create_app
|
||||||
|
from app.extensions import db
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
print("Adding 'url' column to content table...")
|
||||||
|
try:
|
||||||
|
db.session.execute(text("ALTER TABLE content ADD COLUMN url VARCHAR(2048)"))
|
||||||
|
db.session.commit()
|
||||||
|
print("✓ 'url' column added to content table.")
|
||||||
|
except Exception as e:
|
||||||
|
if 'duplicate column' in str(e).lower() or 'already exists' in str(e).lower():
|
||||||
|
print("✓ 'url' column already exists, skipping.")
|
||||||
|
else:
|
||||||
|
print(f"✗ Error: {e}")
|
||||||
|
raise
|
||||||
Reference in New Issue
Block a user