morz-infoboard/server/backend/internal/httpapi/manage/templates.go
Jesko Anschütz 12c10f0337 Admin-UI: Bildschirm einrichten mit Ansible-Anleitung (Variante A)
- POST /admin/screens/provision: legt Screen in DB an (Upsert) und zeigt
  eine 5-Schritt-Seite mit kopierbaren Code-Blöcken:
  1. inventory.yml Eintrag
  2. host_vars/{slug}/vars.yml Inhalt
  3. ssh-copy-id Befehl
  4. ansible-playbook Befehl (mit Vault-Passwort-Hinweis)
  5. Link zur Playlist-Verwaltung
- Admin-Formular: IP-Adresse + SSH-User Felder ergänzt
- Altes "nur anlegen"-Formular als aufklappbaren Details-Block versteckt
- Clipboard-Copy-Buttons für jeden Code-Block

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 06:07:14 +01:00

611 lines
24 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package manage
const provisionTmpl = `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Einrichten {{.Screen.Name}}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.2/css/bulma.min.css">
<style>
body { background: #f5f5f5; }
pre { background: #1e1e1e; color: #d4d4d4; padding: 1rem; border-radius: 4px;
overflow-x: auto; font-size: 0.9em; line-height: 1.5; }
.step-number { background: #3273dc; color: #fff; border-radius: 50%;
width: 2rem; height: 2rem; display: inline-flex;
align-items: center; justify-content: center;
font-weight: bold; margin-right: 0.5rem; flex-shrink: 0; }
.step { display: flex; align-items: flex-start; gap: 1rem; margin-bottom: 2rem; }
.step-body { flex: 1; }
.copy-btn { cursor: pointer; font-size: 0.75em; }
</style>
</head>
<body>
<nav class="navbar is-dark">
<div class="navbar-brand">
<a class="navbar-item" href="/admin">← Admin</a>
<span class="navbar-item"><strong>Bildschirm einrichten: {{.Screen.Name}}</strong></span>
</div>
</nav>
<section class="section">
<div class="container" style="max-width:860px">
<div class="notification is-success is-light">
<strong>✓ Screen «{{.Screen.Name}}» ({{.Screen.Slug}}) wurde im Backend angelegt.</strong><br>
Führe die folgenden Schritte auf deinem Ansible-Host aus, um den Bildschirm zu provisionieren.
</div>
<!-- Schritt 1 -->
<div class="box">
<div class="step">
<span class="step-number">1</span>
<div class="step-body">
<p class="title is-6">Host zur Ansible-Inventardatei hinzufügen</p>
<p class="mb-3">Öffne <code>ansible/inventory.yml</code> und füge den Host unter <code>signage_players → hosts</code> ein:</p>
<pre id="inv"> {{.Screen.Slug}}:</pre>
<button class="button is-small is-light copy-btn mt-2" onclick="copy('inv')">📋 Kopieren</button>
</div>
</div>
</div>
<!-- Schritt 2 -->
<div class="box">
<div class="step">
<span class="step-number">2</span>
<div class="step-body">
<p class="title is-6">Host-Variablen anlegen</p>
<p class="mb-3">Erstelle die Datei <code>ansible/host_vars/{{.Screen.Slug}}/vars.yml</code> mit folgendem Inhalt:</p>
<pre id="hostvars">---
ansible_host: {{.IP}}
ansible_user: {{.SSHUser}}
screen_id: {{.Screen.Slug}}
screen_name: "{{.Screen.Name}}"
screen_orientation: {{.Orientation}}</pre>
<button class="button is-small is-light copy-btn mt-2" onclick="copy('hostvars')">📋 Kopieren</button>
<p class="help mt-2">Tipp: <code>mkdir -p ansible/host_vars/{{.Screen.Slug}}</code></p>
</div>
</div>
</div>
<!-- Schritt 3 -->
<div class="box">
<div class="step">
<span class="step-number">3</span>
<div class="step-body">
<p class="title is-6">SSH-Zugang sicherstellen</p>
<p>Stelle sicher, dass dein SSH-Key auf dem Zielgerät hinterlegt ist:</p>
<pre id="sshcopy">ssh-copy-id {{.SSHUser}}@{{.IP}}</pre>
<button class="button is-small is-light copy-btn mt-2" onclick="copy('sshcopy')">📋 Kopieren</button>
</div>
</div>
</div>
<!-- Schritt 4 -->
<div class="box">
<div class="step">
<span class="step-number">4</span>
<div class="step-body">
<p class="title is-6">Ansible-Playbook ausführen</p>
<p class="mb-3">Führe das Playbook vom Projektverzeichnis aus aus. Das installiert den Agent, konfiguriert Chromium und startet den Kiosk-Modus:</p>
<pre id="playbookcmd">cd /path/to/morz-infoboard
ansible-playbook -i ansible/inventory.yml ansible/site.yml --limit {{.Screen.Slug}}</pre>
<button class="button is-small is-light copy-btn mt-2" onclick="copy('playbookcmd')">📋 Kopieren</button>
<p class="help mt-2">
Falls du einen Vault-Pass verwendest:
<code>--vault-password-file ansible/.vault_pass</code>
</p>
</div>
</div>
</div>
<!-- Schritt 5 -->
<div class="box">
<div class="step">
<span class="step-number">5</span>
<div class="step-body">
<p class="title is-6">Fertig — Playlist befüllen</p>
<p>Nach erfolgreichem Ansible-Lauf meldet sich der Bildschirm automatisch im Backend an und lädt seine Playlist. Jetzt kannst du Inhalte zuweisen:</p>
<a class="button is-primary mt-3" href="/manage/{{.Screen.Slug}}">Playlist für «{{.Screen.Name}}» verwalten →</a>
</div>
</div>
</div>
</div>
</section>
<script>
function copy(id) {
var el = document.getElementById(id);
navigator.clipboard.writeText(el.innerText).then(function() {
var btn = el.nextElementSibling;
var orig = btn.textContent;
btn.textContent = '✓ Kopiert!';
setTimeout(function() { btn.textContent = orig; }, 1500);
});
}
</script>
</body>
</html>`
const adminTmpl = `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MORZ Infoboard Admin</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.2/css/bulma.min.css">
<style>
body { background: #f5f5f5; }
.navbar { margin-bottom: 1.5rem; }
</style>
</head>
<body>
<nav class="navbar is-dark" role="navigation">
<div class="navbar-brand">
<span class="navbar-item"><strong>📺 MORZ Infoboard</strong></span>
</div>
</nav>
<section class="section pt-0">
<div class="container">
<div class="box">
<h2 class="title is-5">Bildschirme</h2>
{{if .Screens}}
<table class="table is-fullwidth is-hoverable is-striped">
<thead>
<tr>
<th>Name</th>
<th>Slug</th>
<th>Format</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{{range .Screens}}
<tr>
<td><strong>{{.Name}}</strong></td>
<td><code>{{.Slug}}</code></td>
<td>{{orientationLabel .Orientation}}</td>
<td>
<a class="button is-small is-link" href="/manage/{{.Slug}}">Playlist verwalten</a>
&nbsp;
<form method="POST" action="/admin/screens/{{.ID}}/delete" style="display:inline"
onsubmit="return confirm('Bildschirm löschen?\n\nAlle Playlist-Einträge werden ebenfalls gelöscht.')">
<button class="button is-small is-danger is-outlined" type="submit">Löschen</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="has-text-grey">Noch keine Bildschirme angelegt.</p>
{{end}}
</div>
<div class="box">
<h2 class="title is-5">Neuen Bildschirm einrichten</h2>
<p class="mb-4 has-text-grey">
Fülle die Angaben aus. Der Bildschirm wird im Backend angelegt und du erhältst
eine <strong>Schritt-für-Schritt-Anleitung</strong> mit allen nötigen Befehlen
für das Ansible-Deployment.
</p>
<form method="POST" action="/admin/screens/provision">
<div class="columns is-multiline">
<div class="column is-3">
<div class="field">
<label class="label">Slug / Hostname</label>
<div class="control">
<input class="input" type="text" name="slug" placeholder="z.B. info12" required
pattern="[a-z0-9-]+" title="Nur Kleinbuchstaben, Zahlen und Bindestriche">
</div>
<p class="help">Eindeutig, URL-sicher — wird als <code>screen_id</code> verwendet</p>
</div>
</div>
<div class="column is-3">
<div class="field">
<label class="label">Anzeigename</label>
<div class="control">
<input class="input" type="text" name="name" placeholder="z.B. Kantine EG" required>
</div>
</div>
</div>
<div class="column is-2">
<div class="field">
<label class="label">IP-Adresse</label>
<div class="control">
<input class="input" type="text" name="ip" placeholder="10.0.0.X" required>
</div>
</div>
</div>
<div class="column is-2">
<div class="field">
<label class="label">SSH-User</label>
<div class="control">
<input class="input" type="text" name="ssh_user" placeholder="morz" value="morz">
</div>
</div>
</div>
<div class="column is-2">
<div class="field">
<label class="label">Format</label>
<div class="control">
<div class="select is-fullwidth">
<select name="orientation">
<option value="landscape">Querformat</option>
<option value="portrait">Hochformat</option>
</select>
</div>
</div>
</div>
</div>
</div>
<button class="button is-primary" type="submit">Anlegen &amp; Anleitung generieren →</button>
</form>
</div>
<div class="box">
<h2 class="title is-5">Bestehenden Screen manuell anlegen</h2>
<details>
<summary class="has-text-grey" style="cursor:pointer">Nur DB-Eintrag, kein Deployment (aufklappen)</summary>
<form method="POST" action="/admin/screens" class="mt-4">
<div class="columns is-vcentered">
<div class="column is-3">
<div class="field">
<label class="label">Slug</label>
<div class="control">
<input class="input" type="text" name="slug" placeholder="z.B. flur-eg" required
pattern="[a-z0-9-]+" title="Nur Kleinbuchstaben, Zahlen und Bindestriche">
</div>
</div>
</div>
<div class="column is-4">
<div class="field">
<label class="label">Name</label>
<div class="control">
<input class="input" type="text" name="name" placeholder="z.B. Flur Erdgeschoss" required>
</div>
</div>
</div>
<div class="column is-2">
<div class="field">
<label class="label">Format</label>
<div class="control">
<div class="select is-fullwidth">
<select name="orientation">
<option value="landscape">Querformat</option>
<option value="portrait">Hochformat</option>
</select>
</div>
</div>
</div>
</div>
<div class="column is-3">
<div class="field">
<label class="label">&nbsp;</label>
<button class="button is-outlined is-fullwidth" type="submit">Nur anlegen</button>
</div>
</div>
</div>
</form>
</details>
</div>
</div>
</section>
</body>
</html>`
const manageTmpl = `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Playlist {{.Screen.Name}}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.2/css/bulma.min.css">
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.3/Sortable.min.js"></script>
<style>
body { background: #f5f5f5; }
.drag-handle { cursor: grab; color: #aaa; font-size: 1.2em; user-select: none; }
.drag-handle:hover { color: #333; }
.item-disabled td { opacity: 0.5; }
.edit-row td { background: #fffbf0; padding: 0.75rem 1rem; }
.tag-type { font-size: 0.7em; text-transform: uppercase; letter-spacing: 0.05em; }
.sortable-ghost { background: #e8f4fd !important; }
.tab-panel { display: none; }
.tab-panel.is-active { display: block; }
</style>
</head>
<body>
<nav class="navbar is-dark" role="navigation">
<div class="navbar-brand">
<a class="navbar-item" href="/admin">← Admin</a>
<span class="navbar-item">
<strong>{{.Screen.Name}}</strong>
&nbsp;
<span class="tag is-info is-light">{{orientationLabel .Screen.Orientation}}</span>
</span>
</div>
</nav>
<section class="section pt-4">
<div class="container">
<!-- ── Playlist ── -->
<div class="box">
<h2 class="title is-5 mb-3">Aktuelle Playlist</h2>
{{if .Items}}
<table class="table is-fullwidth" id="playlist-table">
<thead>
<tr>
<th style="width:2rem"></th>
<th style="width:5rem">Typ</th>
<th>Titel / Quelle</th>
<th style="width:6rem">Dauer</th>
<th style="width:7rem">Status</th>
<th style="width:12rem">Aktionen</th>
</tr>
</thead>
<tbody id="sortable-items">
{{range .Items}}
<tr id="item-{{.ID}}" class="{{if not .Enabled}}item-disabled{{end}}">
<td class="drag-handle" title="Ziehen zum Sortieren">⠿</td>
<td>
<span class="tag is-light tag-type">{{typeIcon .Type}}&nbsp;{{.Type}}</span>
</td>
<td>
<div>{{if .Title}}<strong>{{.Title}}</strong>{{else}}<em class="has-text-grey">{{shortSrc .Src}}</em>{{end}}</div>
{{if .Title}}<small class="has-text-grey">{{shortSrc .Src}}</small>{{end}}
</td>
<td>{{.DurationSeconds}}&thinsp;s</td>
<td>
{{if .Enabled}}
<span class="tag is-success is-light">Aktiv</span>
{{else}}
<span class="tag is-warning is-light">Deaktiviert</span>
{{end}}
</td>
<td>
<button class="button is-small is-info is-outlined" onclick="toggleEdit('{{.ID}}')">Bearbeiten</button>
<form method="POST" action="/manage/{{$.Screen.Slug}}/items/{{.ID}}/delete"
style="display:inline"
onsubmit="return confirm('Eintrag wirklich aus der Playlist entfernen?')">
<button class="button is-small is-danger is-outlined" type="submit" title="Entfernen">✕</button>
</form>
</td>
</tr>
<tr id="edit-{{.ID}}" class="edit-row" style="display:none">
<td colspan="6">
<form method="POST" action="/manage/{{$.Screen.Slug}}/items/{{.ID}}">
<div class="columns is-vcentered is-multiline">
<div class="column is-4">
<label class="label is-small">Titel</label>
<input class="input is-small" type="text" name="title" value="{{.Title}}"
placeholder="Anzeigename (optional)">
</div>
<div class="column is-narrow">
<label class="label is-small">Dauer (Sek.)</label>
<input class="input is-small" type="number" name="duration_seconds"
value="{{.DurationSeconds}}" min="1" max="3600" style="width:6rem">
</div>
<div class="column is-narrow">
<label class="label is-small">Gültig ab</label>
<input class="input is-small" type="datetime-local" name="valid_from"
value="{{formatDT .ValidFrom}}">
</div>
<div class="column is-narrow">
<label class="label is-small">Gültig bis</label>
<input class="input is-small" type="datetime-local" name="valid_until"
value="{{formatDT .ValidUntil}}">
</div>
<div class="column is-narrow">
<label class="label is-small">Aktiv</label>
<div class="select is-small">
<select name="enabled">
<option value="true"{{if .Enabled}} selected{{end}}>Ja</option>
<option value="false"{{if not .Enabled}} selected{{end}}>Nein</option>
</select>
</div>
</div>
<div class="column is-narrow">
<label class="label is-small">&nbsp;</label>
<div class="buttons">
<button class="button is-small is-success" type="submit">Speichern</button>
<button class="button is-small" type="button" onclick="toggleEdit('{{.ID}}')">Abbrechen</button>
</div>
</div>
</div>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
<p class="help has-text-grey mt-2">Einträge per Drag &amp; Drop in der Reihenfolge verschieben.</p>
{{else}}
<div class="notification is-light">
Die Playlist ist noch leer. Füge unten Medien aus der Bibliothek hinzu oder lade neue Dateien hoch.
</div>
{{end}}
</div>
<!-- ── Medienbibliothek ── -->
<div class="box">
<h2 class="title is-5 mb-3">Medienbibliothek</h2>
{{if .Assets}}
<table class="table is-fullwidth is-hoverable is-striped">
<thead>
<tr>
<th style="width:5rem">Typ</th>
<th>Titel</th>
<th>Quelle</th>
<th style="width:14rem">Aktionen</th>
</tr>
</thead>
<tbody>
{{range .Assets}}
<tr>
<td><span class="tag is-light tag-type">{{typeIcon .Type}}&nbsp;{{.Type}}</span></td>
<td>{{.Title}}</td>
<td>
<small class="has-text-grey">
{{if .StoragePath}}{{shortSrc .StoragePath}}{{else}}{{shortSrc .OriginalURL}}{{end}}
</small>
</td>
<td>
{{if index $.AddedAssets .ID}}
<span class="tag is-success is-light mr-2">✓ In Playlist</span>
{{else}}
<form method="POST" action="/manage/{{$.Screen.Slug}}/items" style="display:inline">
<input type="hidden" name="media_asset_id" value="{{.ID}}">
<button class="button is-small is-primary" type="submit">+ Hinzufügen</button>
</form>
&nbsp;
{{end}}
<form method="POST" action="/manage/{{$.Screen.Slug}}/media/{{.ID}}/delete"
style="display:inline"
onsubmit="return confirm('Medium wirklich aus der Bibliothek löschen?\n(Playlist-Einträge bleiben bestehen, zeigen dann aber nichts an.)')">
<button class="button is-small is-danger is-outlined" type="submit" title="Aus Bibliothek löschen">🗑</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="has-text-grey">Noch keine Medien hochgeladen. Lade unten eine Datei hoch oder füge eine Webseite hinzu.</p>
{{end}}
</div>
<!-- ── Neues Medium hinzufügen ── -->
<div class="box">
<h2 class="title is-5 mb-3">Neues Medium hinzufügen</h2>
<div class="tabs" id="upload-tabs">
<ul>
<li id="tab-file" class="is-active"><a onclick="switchTab('file')">📁 Datei hochladen</a></li>
<li id="tab-web"><a onclick="switchTab('web')">🌐 Webseite / URL</a></li>
</ul>
</div>
<div id="panel-file" class="tab-panel is-active">
<form method="POST" action="/manage/{{.Screen.Slug}}/upload" enctype="multipart/form-data">
<div class="columns is-vcentered">
<div class="column is-2">
<div class="field">
<label class="label">Typ</label>
<div class="select is-fullwidth">
<select name="type">
<option value="image">🖼 Bild</option>
<option value="video">🎬 Video</option>
<option value="pdf">📄 PDF</option>
</select>
</div>
</div>
</div>
<div class="column">
<div class="field">
<label class="label">Titel <span class="has-text-grey is-size-7">(optional)</span></label>
<input class="input" type="text" name="title"
placeholder="Wird aus Dateinamen abgeleitet, wenn leer">
</div>
</div>
<div class="column">
<div class="field">
<label class="label">Datei</label>
<div class="control">
<input class="input" type="file" name="file" required
accept="image/*,video/*,application/pdf">
</div>
</div>
</div>
<div class="column is-narrow">
<div class="field">
<label class="label">&nbsp;</label>
<button class="button is-primary" type="submit">Hochladen</button>
</div>
</div>
</div>
</form>
</div>
<div id="panel-web" class="tab-panel">
<form method="POST" action="/manage/{{.Screen.Slug}}/upload" enctype="multipart/form-data">
<input type="hidden" name="type" value="web">
<div class="columns is-vcentered">
<div class="column">
<div class="field">
<label class="label">URL</label>
<input class="input" type="url" name="url"
placeholder="https://example.com" required>
</div>
</div>
<div class="column">
<div class="field">
<label class="label">Titel <span class="has-text-grey is-size-7">(optional)</span></label>
<input class="input" type="text" name="title" placeholder="Anzeigename">
</div>
</div>
<div class="column is-narrow">
<div class="field">
<label class="label">&nbsp;</label>
<button class="button is-primary" type="submit">Hinzufügen</button>
</div>
</div>
</div>
</form>
</div>
</div>
</div>
</section>
<script>
function toggleEdit(id) {
var row = document.getElementById('edit-' + id);
if (row) {
row.style.display = (row.style.display === 'none' || row.style.display === '') ? 'table-row' : 'none';
}
}
function switchTab(tab) {
var panels = ['file', 'web'];
panels.forEach(function(p) {
var panel = document.getElementById('panel-' + p);
var tabEl = document.getElementById('tab-' + p);
if (p === tab) {
panel.classList.add('is-active');
tabEl.classList.add('is-active');
} else {
panel.classList.remove('is-active');
tabEl.classList.remove('is-active');
}
});
}
// Drag-and-drop reordering
var sortableEl = document.getElementById('sortable-items');
if (sortableEl) {
Sortable.create(sortableEl, {
handle: '.drag-handle',
animation: 150,
ghostClass: 'sortable-ghost',
onEnd: function() {
var ids = [];
sortableEl.querySelectorAll('tr[id^="item-"]').forEach(function(tr) {
ids.push(tr.id.replace('item-', ''));
});
fetch('/manage/{{.Screen.Slug}}/reorder', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(ids)
});
}
});
}
</script>
</body>
</html>`