Files
tesm-license/srv/tesm-license/templates/index.html
T
alientimandClaude Sonnet 5 cee5311b1d Ticketverwaltung, Kundenstammdaten, Kundenportal, Lifetime-Lizenzen, Dashboard-Kacheln (v1.2.0)
Reaktion auf mehrere Bug-Reports und Feature-Wuensche zum bestehenden
Ticket-System (v1.1.0): 'Logs gehen nicht', 'Ticketverwaltung fehlt',
'Lizenzen koennen nicht vom Dashboard geloescht werden', plus eine Reihe
konkreter neuer Anforderungen.

## Bugfixes

- /logs: behob einen Absturz (jinja2.exceptions.UndefinedError:
  'global_check_interval' is undefined) UND die eigentliche Ursache
  dahinter -- logs.html war unveraendert aus dem TESM-Fork uebernommen
  (PoE-Geraete-Polling-Text/-Farblogik, ein Template-Feld, das app.py nie
  befuellt hat) und ausserdem schrieb auf diesem Server ueberhaupt nichts
  in live.log (kein poe.sh-Aequivalent vorhanden). Live-Log zeigt jetzt
  echte Ticket-/Lizenzaktionen, mit passendem Hinweistext und Faerbung.
- Lizenz-Loeschung war bereits vorhanden, aber nur ueber Ticket- ->
  Lizenz-Detailseite erreichbar -- jetzt zusaetzlich inline in der
  Lizenzhistorie jedes Tickets.

## Ticketverwaltung (neu: eigene Seite /tickets)

- Volle CRUD: Liste mit Suche, Bearbeiten (Typ/Module/Laufzeit/Lifetime,
  wirkt nur auf die naechste Ausstellung), Loeschen (nur wenn keine
  offene Lizenz mehr besteht, dann inkl. Historie).
- Dashboard (/) zeigt seitdem NICHT mehr die Ticket-Tabelle, sondern eine
  rein informative Kachel-Uebersicht der aktuell AKTIVEN Lizenzen, exakt
  im Kachel-Stil von TESMs eigenem Geraete-Dashboard: Ampelfarben (gruen
  >90 Tage, orange 30-90, rot <30/abgelaufen), gruppiert, Firma + Hostname
  der Kundeninstanz. Hostname kommt neu vom TESM-Client per Activate/
  Heartbeat (srv/tesm/licensing.py, abwaerts-kompatibel: alte Clients ohne
  das Feld ueberschreiben nie einen bereits bekannten Hostnamen).

## Kundenstammdaten

- Neue Felder: Strasse/Hausnummer/PLZ/Ort/Ansprechpartner. E-Mail ist beim
  Anlegen jetzt Pflicht; beim Bearbeiten bewusst nur validiert, wenn
  ausgefuellt (sonst waeren Bestandskunden aus der Zeit vor dieser
  Umstellung fuer JEDE Aenderung blockiert gewesen).

## Kundenportal (neu, per Magic-Link, kein Login)

- /portal/<token>: zeigt einem Kunden ALLE seine Tickets/Lizenzen auf
  einen Blick (ergaenzt den bestehenden Pro-Ticket-Link
  /self-service/<ticket_id>) und erlaubt ihm, seine eigenen Kontakt-/
  Anschriftdaten selbst zu pflegen -- Firmenname und kommerzielle
  Ticket-Eckdaten bleiben bewusst admin-verwaltet. token = eigene Spalte
  license_customers.portal_token, rueckwirkend fuer Bestandskunden erzeugt.

## Lifetime-Lizenzen

- Neues Ticket-Flag 'Lifetime': ignoriert die Laufzeit fuer jede REGULAERE
  Ausstellung (Sentinel-Ablaufdatum 2099-12-31, licensing.
  LIFETIME_EXPIRES_AT) -- eine zusaetzlich erzeugte Trial-Lizenz aus
  demselben Ticket bleibt immer die normale 30-Tage-Variante. Trial ALS
  TICKET-GRUNDTYP + Lifetime wird serverseitig verhindert (haette eine
  dauerhafte, nie ablaufende Lizenz ganz ohne Module ergeben).

## Absicherung nach Review

Ein Adversarial-Review-Durchlauf ueber den kompletten Diff deckte vor dem
Deploy zusaetzlich auf und wurde behoben:
- TOCTOU-Race: zwei nahezu gleichzeitige 'Lizenz erstellen'-Anfragen fuer
  dasselbe Ticket (z.B. Doppelklick im Self-Service) konnten beide
  durchkommen. Jetzt durch einen partiellen Unique-Index
  (idx_one_open_license_per_ticket) auf DB-Ebene ausgeschlossen; die
  zweite Anfrage bekommt sauber die bereits erstellte Lizenz zurueck statt
  eine zweite zu erzeugen.
- Analoger Unique-Index auf license_customers.portal_token als Backstop
  fuer den neuen Magic-Link.
- tickets.html nutzte noch die alte, feste '<=30 Tage'-Schwelle statt der
  gemeinsamen Ampelfarbe -- Lizenzen im 30-90-Tage-Bereich blieben dort
  unmarkiert, obwohl das Dashboard sie schon als orange zeigte.

SCHEMA_VERSION 2 -> 3 (neue Spalten/Tabellen, ueber install.sh mit
Backup+Health-Check abgesichert).

Verifiziert: vollstaendige Migration gegen eine echte Kopie der
Live-Datenbank, anschliessend 22 Funktionstests direkt gegen die echten
Routen (Validierung, Lifetime-Ausstellung, Sperr-/Kaskadenregeln bei
Loeschungen, Kundenportal inkl. ungueltiger Eingaben) sowie ein Playwright-
Seitenladetest ohne Konsolenfehler -- beides nach den Review-Fixes erneut
komplett durchlaufen, inkl. direktem Nachweis, dass der neue Unique-Index
eine doppelte offene Lizenz pro Ticket tatsaechlich auf DB-Ebene ablehnt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 15:51:20 +02:00

120 lines
4.7 KiB
HTML

{% extends "base.html" %}
{% set active_page = "index" %}
{% block page_title %}Dashboard{% endblock %}
{% block page_sub %}<div class="topbar-sub">Übersicht aktiver Kundeninstanzen</div>{% endblock %}
{% block content %}
{% if buckets is none %}
<div class="card card-pad" style="max-width:560px; margin:40px auto; text-align:center;">
<p class="text-faint" style="font-size:13px;">
{% if not current_user.is_authenticated %}Bitte anmelden.{% else %}Keine Berechtigung, Lizenzen anzusehen.{% endif %}
</p>
</div>
{% else %}
<div class="section-head">
<div>
<h2 style="font-size:16px;">Aktive Lizenzen</h2>
<div class="hint">
Rein informative Übersicht nach Restlaufzeit der Ablauffristen (grün &gt; 90 Tage, orange 30-90 Tage, rot &lt; 30 Tage). Tickets anlegen, bearbeiten oder Lizenzen ausstellen/widerrufen geschieht unter <a href="{{ url_for('tickets_list') }}" style="color:var(--accent-strong); font-weight:600;">Tickets</a>.
</div>
</div>
</div>
<div class="stat-row">
<div class="stat-card">
<div class="stat-label">Rot (&lt; 30 Tage)</div>
<div class="stat-value offline">{{ stats.red }}</div>
</div>
<div class="stat-card">
<div class="stat-label">Orange (30-90 Tage)</div>
<div class="stat-value" style="color:var(--warning);">{{ stats.orange }}</div>
</div>
<div class="stat-card">
<div class="stat-label">Grün (&gt; 90 Tage / Lifetime)</div>
<div class="stat-value online">{{ stats.green }}</div>
</div>
<div class="stat-card">
<div class="stat-label">Aktiv gesamt</div>
<div class="stat-value">{{ stats.total }}</div>
</div>
</div>
{% macro license_tile(l) %}
{% set ds = {'red': 'offline', 'orange': 'unbekannt', 'green': 'online'}[l.color] %}
<div class="device-card is-readonly" data-status="{{ ds }}">
<div class="dc-top">
<div class="dc-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="16" r="1.5"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>
</div>
<span class="pill {{ {'red': 'offline', 'orange': 'unknown', 'green': 'online'}[l.color] }}">
{% if l.is_lifetime %}Lifetime{% elif l.is_expired %}Abgelaufen{% else %}{{ l.days_left }} Tage{% endif %}
</span>
</div>
<div class="dc-name">{{ l.customer_name }}</div>
<div class="dc-ip">{{ l.hostname or 'Hostname unbekannt' }}</div>
<div class="text-faint" style="font-size:11px; margin-top:2px;">{{ type_labels.get(l.type, l.type) }}</div>
</div>
{% endmacro %}
{% macro section_chevron() %}<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>{% endmacro %}
{% if stats.total %}
{% if buckets.red %}
<div class="dash-section" data-status="red">
<div class="dash-section-title">{{ section_chevron() }}Rot <span class="pill offline">{{ buckets.red|length }}</span></div>
<div class="device-grid">
{% for l in buckets.red %}{{ license_tile(l) }}{% endfor %}
</div>
</div>
{% endif %}
{% if buckets.orange %}
<div class="dash-section" data-status="orange">
<div class="dash-section-title">{{ section_chevron() }}Orange <span class="pill unknown">{{ buckets.orange|length }}</span></div>
<div class="device-grid">
{% for l in buckets.orange %}{{ license_tile(l) }}{% endfor %}
</div>
</div>
{% endif %}
{% if buckets.green %}
<div class="dash-section" data-status="green">
<div class="dash-section-title">{{ section_chevron() }}Grün <span class="pill online">{{ buckets.green|length }}</span></div>
<div class="device-grid">
{% for l in buckets.green %}{{ license_tile(l) }}{% endfor %}
</div>
</div>
{% endif %}
{% else %}
<div class="card card-pad" style="text-align:center; color:var(--text-faint);">
Aktuell keine aktivierte Lizenz. Neue Tickets/Lizenzen unter <a href="{{ url_for('tickets_list') }}" style="color:var(--accent-strong); font-weight:600;">Tickets</a> anlegen.
</div>
{% endif %}
{% endif %}
{% endblock %}
{% block scripts %}
<script>
document.addEventListener("DOMContentLoaded", () => {
const collapsed = new Set();
function apply() {
document.querySelectorAll(".dash-section").forEach(section => {
section.classList.toggle("collapsed", collapsed.has(section.dataset.status));
});
}
document.querySelectorAll(".dash-section-title").forEach(title => {
title.addEventListener("click", function () {
const key = this.closest(".dash-section").dataset.status;
if (collapsed.has(key)) collapsed.delete(key); else collapsed.add(key);
apply();
});
});
});
</script>
{% endblock %}