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>
This commit is contained in:
Jesko Anschütz 2026-03-23 06:07:14 +01:00
parent e03948f25d
commit 12c10f0337
3 changed files with 256 additions and 16 deletions

View file

@ -1,5 +1,133 @@
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>
@ -57,8 +185,71 @@ const adminTmpl = `<!DOCTYPE html>
</div>
<div class="box">
<h2 class="title is-5">Neuer Bildschirm</h2>
<form method="POST" action="/admin/screens">
<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">
@ -67,7 +258,6 @@ const adminTmpl = `<!DOCTYPE html>
<input class="input" type="text" name="slug" placeholder="z.B. flur-eg" required
pattern="[a-z0-9-]+" title="Nur Kleinbuchstaben, Zahlen und Bindestriche">
</div>
<p class="help">URL-sichere Kennung (eindeutig)</p>
</div>
</div>
<div class="column is-4">
@ -94,13 +284,12 @@ const adminTmpl = `<!DOCTYPE html>
<div class="column is-3">
<div class="field">
<label class="label">&nbsp;</label>
<div class="control">
<button class="button is-primary is-fullwidth" type="submit">Erstellen</button>
</div>
<button class="button is-outlined is-fullwidth" type="submit">Nur anlegen</button>
</div>
</div>
</div>
</form>
</details>
</div>
</div>

View file

@ -165,6 +165,56 @@ func HandleCreateScreenUI(tenants *store.TenantStore, screens *store.ScreenStore
}
}
// HandleProvisionUI creates a screen in DB and shows the Ansible setup instructions.
func HandleProvisionUI(tenants *store.TenantStore, screens *store.ScreenStore) http.HandlerFunc {
t := template.Must(template.New("provision").Funcs(tmplFuncs).Parse(provisionTmpl))
return func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
slug := strings.TrimSpace(r.FormValue("slug"))
name := strings.TrimSpace(r.FormValue("name"))
ip := strings.TrimSpace(r.FormValue("ip"))
sshUser := strings.TrimSpace(r.FormValue("ssh_user"))
orientation := r.FormValue("orientation")
if slug == "" || ip == "" {
http.Error(w, "slug und IP-Adresse erforderlich", http.StatusBadRequest)
return
}
if name == "" {
name = slug
}
if sshUser == "" {
sshUser = "morz"
}
if orientation == "" {
orientation = "landscape"
}
tenant, err := tenants.Get(r.Context(), "morz")
if err != nil {
http.Error(w, "standard-tenant nicht gefunden", http.StatusInternalServerError)
return
}
screen, err := screens.Upsert(r.Context(), tenant.ID, slug, name, orientation)
if err != nil {
http.Error(w, "DB-Fehler: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
t.Execute(w, map[string]any{ //nolint:errcheck
"Screen": screen,
"IP": ip,
"SSHUser": sshUser,
"Orientation": orientation,
})
}
}
// HandleDeleteScreenUI handles DELETE for a screen, then redirects.
func HandleDeleteScreenUI(screens *store.ScreenStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {

View file

@ -78,6 +78,7 @@ func registerManageRoutes(mux *http.ServeMux, d RouterDeps) {
// ── Admin UI ──────────────────────────────────────────────────────────
mux.HandleFunc("GET /admin", manage.HandleAdminUI(d.TenantStore, d.ScreenStore))
mux.HandleFunc("POST /admin/screens/provision", manage.HandleProvisionUI(d.TenantStore, d.ScreenStore))
mux.HandleFunc("POST /admin/screens", manage.HandleCreateScreenUI(d.TenantStore, d.ScreenStore))
mux.HandleFunc("POST /admin/screens/{screenId}/delete", manage.HandleDeleteScreenUI(d.ScreenStore))