v2.1-enterprise

This commit is contained in:
2026-03-23 19:32:57 +01:00
parent b1500e5d87
commit 988a8b143f
35 changed files with 1304 additions and 0 deletions
@@ -0,0 +1,56 @@
{% extends 'base.html' %}
{% block title %}Admin Dashboard{% endblock %}
{% block content %}
<div class="page-header">
<div>
<h1>Admin Dashboard</h1>
<p>Streams og brugere</p>
</div>
<a class="button" href="{{ url_for('main.create_streamer') }}">+ Opret streamer</a>
</div>
<section class="card">
<h2>Streams</h2>
<table class="stream-table">
<thead><tr><th>Navn</th><th>Slug</th><th>Ejer</th><th>Status</th><th>Aktiv</th><th>Handlinger</th></tr></thead>
<tbody>
{% for s in streams %}
{% set p = path_map.get(s.slug) %}
{% set live = p and (p.ready or p.online) %}
<tr>
<td>{{ s.name }}</td>
<td>/{{ s.slug }}</td>
<td>{{ s.user.display_name }}</td>
<td><span class="badge {{ 'live' if live else 'offline' }}">{{ 'LIVE' if live else 'OFFLINE' }}</span></td>
<td>{{ 'Ja' if s.active else 'Nej' }}</td>
<td><a class="button secondary" href="{{ url_for('main.stream_detail_admin', stream_id=s.id) }}">Detaljer</a></td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
<section class="card space-top">
<h2>Brugere</h2>
<table class="stream-table">
<thead><tr><th>Navn</th><th>Brugernavn</th><th>E-mail</th><th>Rolle</th><th>Status</th><th>Handlinger</th></tr></thead>
<tbody>
{% for u in users %}
<tr>
<td>{{ u.display_name }}</td>
<td>{{ u.username }}</td>
<td>{{ u.email }}</td>
<td>{{ u.role }}</td>
<td>{{ 'Aktiv' if u.active else 'Deaktiv' }}</td>
<td>
{% if u.role == 'streamer' %}
<form method="post" action="{{ url_for('main.admin_send_reset', user_id=u.id) }}">
<button type="submit" class="secondary">Send reset</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
{% endblock %}
@@ -0,0 +1,38 @@
<!doctype html>
<html lang="da">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}TuxiNet StreamHub{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
</head>
<body class="{% block body_class %}{% endblock %}">
<header class="topbar">
<a class="brand" href="{{ url_for('main.index') }}">
<img src="{{ url_for('static', filename='logo.png') }}" alt="TuxiNet StreamHub">
<span>TuxiNet StreamHub</span>
</a>
{% if current_user %}
<nav>
{% if current_user.role == 'admin' %}
<a href="{{ url_for('main.admin_dashboard') }}">Dashboard</a>
<a href="{{ url_for('main.create_streamer') }}">Ny streamer</a>
{% endif %}
<a href="{{ url_for('main.logout') }}">Log ud</a>
</nav>
{% endif %}
</header>
<main class="container">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="flash {{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</main>
</body>
</html>
@@ -0,0 +1,29 @@
{% extends 'base.html' %}
{% block title %}Ny streamer{% endblock %}
{% block content %}
<div class="page-header"><h1>Opret streamer + stream</h1></div>
<div class="detail-layout">
<section class="card main-panel">
<h2>Streamer</h2>
<form method="post" class="form-grid">
<label>Visningsnavn<input type="text" name="display_name" required></label>
<label>Brugernavn<input type="text" name="username" required></label>
<label>E-mail<input type="email" name="email" required></label>
<h2>Stream</h2>
<label>Stream navn<input type="text" name="stream_name" required></label>
<label>Slug<input type="text" name="slug" required></label>
<label>Chat type
<select name="chat_type">
<option value="none">Ingen</option>
<option value="twitch">Twitch</option>
<option value="youtube">YouTube</option>
</select>
</label>
<label>Chat URL<input type="text" name="chat_url"></label>
<label>Noter<textarea name="notes"></textarea></label>
<button type="submit">Opret streamer</button>
</form>
</section>
</div>
{% endblock %}
@@ -0,0 +1,14 @@
{% extends 'base.html' %}
{% block title %}Skift kode{% endblock %}
{% block content %}
<div class="card narrow">
<h1>Skift din kode</h1>
<p class="muted">Du skal ændre din midlertidige kode, før du kan fortsætte.</p>
<form method="post" class="form-grid">
<label>Nyt kodeord<input type="password" name="password" required></label>
<label>Gentag kodeord<input type="password" name="password_confirm" required></label>
<button type="submit">Gem ny kode</button>
</form>
</div>
{% endblock %}
@@ -0,0 +1,12 @@
{% extends 'base.html' %}
{% block title %}Glemt kodeord{% endblock %}
{% block content %}
<div class="card narrow">
<h1>Glemt kodeord</h1>
<form method="post" class="form-grid">
<label>Brugernavn eller e-mail<input type="text" name="identity" required></label>
<button type="submit">Send reset-link</button>
</form>
</div>
{% endblock %}
@@ -0,0 +1,19 @@
{% extends 'base.html' %}
{% block title %}Login{% endblock %}
{% block body_class %}login-page{% endblock %}
{% block content %}
<div class="login-wrap">
<div class="login-card">
<img class="login-logo" src="{{ url_for('static', filename='logo.png') }}" alt="logo">
<h1>Log ind</h1>
<p class="muted">Enterprise RTMP control platform</p>
<form method="post" class="form-grid">
<label>Brugernavn<input type="text" name="username" required></label>
<label>Kodeord<input type="password" name="password" required></label>
<button type="submit">Log ind</button>
</form>
<div class="login-links"><a href="{{ url_for('main.forgot_password') }}">Glemt kodeord?</a></div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,13 @@
{% extends 'base.html' %}
{% block title %}Reset kodeord{% endblock %}
{% block content %}
<div class="card narrow">
<h1>Nulstil kodeord</h1>
<form method="post" class="form-grid">
<label>Nyt kodeord<input type="password" name="password" required></label>
<label>Gentag kodeord<input type="password" name="password_confirm" required></label>
<button type="submit">Gem kodeord</button>
</form>
</div>
{% endblock %}
@@ -0,0 +1,71 @@
{% extends 'base.html' %}
{% block title %}{{ stream.name }}{% endblock %}
{% block content %}
<div class="page-header">
<div>
<h1>{{ stream.name }}</h1>
<p>{{ stream.user.display_name }} · /{{ stream.slug }}</p>
</div>
<div class="badge {{ 'live' if stream.is_live else 'offline' }}">{{ 'LIVE' if stream.is_live else 'OFFLINE' }}</div>
</div>
<div class="detail-layout">
<section class="card main-panel">
<h2>Preview</h2>
<div class="video-frame large"><video id="previewVideo" controls playsinline muted poster="{{ url_for('static', filename='videooffline.png') }}"></video></div>
<div class="muted">{{ public_hls }}</div>
<div class="stats-grid compact-top">
<div class="mini-stat"><span>Bitrate</span><strong>{{ stats.get('bitrate', '-') }}</strong></div>
<div class="mini-stat"><span>FPS</span><strong>{{ stats.get('fps', '-') }}</strong></div>
<div class="mini-stat"><span>Video</span><strong>{{ stats.get('video_codec', '-') }}</strong></div>
<div class="mini-stat"><span>Lyd</span><strong>{{ stats.get('audio_codec', '-') }}</strong></div>
<div class="mini-stat"><span>Kanaler</span><strong>{{ stats.get('audio_channels', '-') }}</strong></div>
<div class="mini-stat"><span>Sample rate</span><strong>{{ stats.get('audio_sample_rate', '-') }}</strong></div>
</div>
</section>
<aside class="card side-panel">
<h2>Ingest</h2>
<label>OBS Server</label><input readonly value="{{ ingest_server }}">
<label>OBS Key</label><input readonly value="{{ ingest_key }}">
<label>Larix / Streamlabs URL</label><input readonly value="{{ ingest_alt_server }}">
<label>Larix / Streamlabs Key</label><input readonly value="{{ ingest_alt_key }}">
<label>Fuldt link</label><input readonly value="{{ ingest_full }}">
<div class="stack-actions">
<form method="post" action="{{ url_for('main.toggle_stream', stream_id=stream.id) }}"><button type="submit">{{ 'Deaktiver' if stream.active else 'Aktiver' }}</button></form>
<form method="post" action="{{ url_for('main.rotate_stream_token', stream_id=stream.id) }}"><button type="submit" class="secondary">Rotér token</button></form>
</div>
</aside>
</div>
<div class="detail-layout lower-gap">
<section class="card main-panel">
<h2>Forbindelser / sessioner</h2>
<table class="stream-table">
<thead><tr><th>Start</th><th>Status</th><th>IP</th><th>Kilde</th></tr></thead>
<tbody>
{% for row in sessions %}
<tr><td>{{ row.started_at }}</td><td>{{ row.status }}</td><td>{{ row.publisher_ip or '-' }}</td><td>{{ row.source_type or '-' }}</td></tr>
{% endfor %}
</tbody>
</table>
</section>
<aside class="card side-panel">
<h2>Chat</h2>
{% if stream.chat_url %}
<iframe src="{{ stream.chat_url }}" class="chat-frame"></iframe>
{% else %}
<div class="muted">Ingen chat sat op endnu.</div>
{% endif %}
</aside>
</div>
<script>
const previewUrl = {{ public_hls|tojson }};
const video = document.getElementById('previewVideo');
if (window.Hls && Hls.isSupported()) {
const hls = new Hls({lowLatencyMode: true});
hls.loadSource(previewUrl);
hls.attachMedia(video);
} else {
video.src = previewUrl;
}
</script>
{% endblock %}
@@ -0,0 +1,85 @@
{% extends 'base.html' %}
{% block title %}Streamer Panel{% endblock %}
{% block content %}
<div class="page-header">
<div>
<h1>Streamer Panel</h1>
<p>{{ stream.name }} · /{{ stream.slug }}</p>
</div>
<div id="liveBadge" class="badge {{ 'live' if stream.is_live else 'offline' }}">{{ 'LIVE' if stream.is_live else 'OFFLINE' }}</div>
</div>
<div class="streamer-layout-fixed">
<section class="card preview-column">
<div class="panel-head"><h2>Preview</h2><div class="muted">{{ public_hls }}</div></div>
<div class="video-frame large"><video id="streamerPreview" controls playsinline muted poster="{{ url_for('static', filename='videooffline.png') }}"></video></div>
<div class="stats-grid compact-top">
<div class="mini-stat"><span>Bitrate</span><strong id="spBitrate">{{ stats.get('bitrate', '-') }}</strong></div>
<div class="mini-stat"><span>FPS</span><strong id="spFps">{{ stats.get('fps', '-') }}</strong></div>
<div class="mini-stat"><span>Video</span><strong id="spVideo">{{ stats.get('video_codec', '-') }}</strong></div>
<div class="mini-stat"><span>Lyd</span><strong id="spAudio">{{ stats.get('audio_codec', '-') }}</strong></div>
<div class="mini-stat"><span>Kanaler</span><strong id="spChannels">{{ stats.get('audio_channels', '-') }}</strong></div>
<div class="mini-stat"><span>Sample rate</span><strong id="spSample">{{ stats.get('audio_sample_rate', '-') }}</strong></div>
<div class="mini-stat"><span>Ready</span><strong id="spReady">{{ 'true' if path_info and path_info.ready else 'false' }}</strong></div>
<div class="mini-stat"><span>Online</span><strong id="spOnline">{{ 'true' if path_info and path_info.online else 'false' }}</strong></div>
</div>
<div class="ingest-box">
<h2>Dine forbindelser</h2>
<table class="stream-table">
<thead><tr><th>Start</th><th>Status</th><th>IP</th></tr></thead>
<tbody>
{% for row in sessions %}
<tr><td>{{ row.started_at }}</td><td>{{ row.status }}</td><td>{{ row.publisher_ip or '-' }}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
<aside class="card chat-column">
<h2>Chat</h2>
{% if stream.chat_url %}
<iframe src="{{ stream.chat_url }}" class="chat-frame"></iframe>
{% else %}
<div class="muted">Ingen chat er sat op endnu for denne kanal.</div>
{% endif %}
</aside>
</div>
<script>
const previewUrl = {{ public_hls|tojson }};
const statusUrl = {{ url_for('main.stream_status', stream_id=stream.id)|tojson }};
const player = document.getElementById('streamerPreview');
let hlsInstance = null;
function attachPlayer(url) {
if (hlsInstance) { hlsInstance.destroy(); hlsInstance = null; }
if (window.Hls && Hls.isSupported()) {
hlsInstance = new Hls({lowLatencyMode: true});
hlsInstance.loadSource(url);
hlsInstance.attachMedia(player);
} else { player.src = url; }
}
function fillStats(stats) {
document.getElementById('spBitrate').textContent = stats.bitrate || '-';
document.getElementById('spFps').textContent = stats.fps || '-';
document.getElementById('spVideo').textContent = stats.video_codec || '-';
document.getElementById('spAudio').textContent = stats.audio_codec || '-';
document.getElementById('spChannels').textContent = stats.audio_channels || '-';
document.getElementById('spSample').textContent = stats.audio_sample_rate || '-';
}
attachPlayer(previewUrl);
fillStats({{ stats|tojson }});
async function refreshStatus() {
const res = await fetch(statusUrl);
const data = await res.json();
const live = !!data.is_live;
const badge = document.getElementById('liveBadge');
badge.textContent = live ? 'LIVE' : 'OFFLINE';
badge.classList.toggle('live', live);
badge.classList.toggle('offline', !live);
document.getElementById('spReady').textContent = data.path_info?.ready ? 'true' : 'false';
document.getElementById('spOnline').textContent = data.path_info?.online ? 'true' : 'false';
fillStats(data.stats || {});
if (!live) { player.poster = "{{ url_for('static', filename='videooffline.png') }}"; }
}
setInterval(refreshStatus, 3000);
</script>
{% endblock %}