Vollstaendiger Neubau der Anwendung. Der vorherige Stand bleibt unveraendert im Zweig SONNET5 erhalten. Aufbau: apps/tesm-license (Anwendung), packages/tesm-core (gemeinsamer Kern), packages/tesm-licensing (Lizenzprotokoll), deploy (Installation, systemd, privilegierter Helfer), docs, tests. Die verwaltete Anwendung liegt in ihrem eigenen Repository; beide Repositorien bringen die gemeinsamen Pakete mit, damit sich jedes allein installieren laesst. Die wichtigsten Unterschiede zum Vorgaenger: * Keine doppelte licensing.py -- ein Paket, das beide Anwendungen installieren, statt zweier Dateien, die byte-identisch bleiben sollen. * Der Webprozess laeuft unprivilegiert; alles, was Root braucht, geht ueber einen einzigen Helfer mit Positivlisten fuer jedes Argument. * CSRF-Schutz ueberhaupt -- der Vorgaenger hatte keinen. * Rechte werden serverseitig geprueft, nicht nur im Template ausgeblendet. * Keine Lizenz ohne master_endpoint: eine Ausstellung ohne Endpunkt wird abgelehnt statt eine Lizenz zu erzeugen, die sich nie aktivieren kann. * Offline-Aktivierung in beide Richtungen; die Lizenz bleibt als "Aktivierung offen" markiert, bis sie zurueckkommt. * Getrennte Signaturkontexte je Nachrichtenart, Nonce gegen Wiedereinspielung, seq gegen das Zurueckrollen auf eine aeltere Lizenz. * Kein Hostname im Maschinen-Fingerabdruck. * Verschachtelte Datenbankverbindungen sind ein Fehler, kein Deadlock. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
78 lines
3.6 KiB
HTML
78 lines
3.6 KiB
HTML
{% extends "tesm_core/base.html" %}
|
|
{% from "tesm_core/macros.html" import pill, search_box, empty_state %}
|
|
|
|
{% block page_title %}Lizenzen{% endblock %}
|
|
{% block page_sub %}{{ licenses | length }} Lizenzen{% if active_filter %} · Filter: {{ active_filter }}{% endif %}{% endblock %}
|
|
|
|
{% block topbar_extra %}
|
|
{{ search_box('licenses-table', 'Kunde, Lizenz-ID, Host ...') }}
|
|
{% if current_user.has('licenses.execute') %}
|
|
<a class="btn btn--sm" href="{{ url_for('licenses.manual_code') }}">
|
|
{{ icon('transfer', size=15) }} Offline-Code
|
|
</a>
|
|
{% endif %}
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<section class="card">
|
|
<div class="table-toolbar">
|
|
<div class="btn-group">
|
|
<a class="btn btn--sm {{ 'btn--subtle' if not active_filter }}" href="{{ url_for('licenses.overview') }}">alle</a>
|
|
{% for state in states %}
|
|
<a class="btn btn--sm {{ 'btn--subtle' if active_filter == state }}"
|
|
href="{{ url_for('licenses.overview', status=state) }}">{{ state }}</a>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
<div class="table-wrap">
|
|
<table class="table" id="licenses-table" data-behavior="sortable">
|
|
<thead>
|
|
<tr>
|
|
<th class="sortable">Kunde</th>
|
|
<th class="sortable">Lizenz</th>
|
|
<th class="sortable">Typ</th>
|
|
<th class="sortable">Zustand</th>
|
|
<th class="sortable">Restlaufzeit</th>
|
|
<th class="sortable">Host</th>
|
|
<th class="sortable">Heartbeat</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for entry in licenses %}
|
|
<tr data-filter-text="{{ entry.customer_name }} {{ entry.license_id }} {{ entry.hostname }} {{ entry.type }}">
|
|
<td class="fw-600">
|
|
<a href="{{ url_for('customers.detail', customer_id=entry.customer_id) }}">{{ entry.customer_name }}</a>
|
|
{% if entry.role != 'customer' %}{{ pill(role_labels.get(entry.role, entry.role), 'accent') }}{% endif %}
|
|
</td>
|
|
<td class="mono fs-sm">
|
|
<a href="{{ url_for('licenses.detail', license_id=entry.license_id) }}">{{ entry.license_id[:12] }}…</a>
|
|
</td>
|
|
<td class="fs-sm">{{ type_labels.get(entry.type, entry.type) }}</td>
|
|
<td>
|
|
{% if entry.status == 'active' %}{{ pill('aktiv', 'online') }}
|
|
{% elif entry.status == 'issued' %}{{ pill('ausgestellt', 'info') }}
|
|
{% elif entry.status == 'revoked' %}{{ pill('widerrufen', 'danger') }}
|
|
{% else %}{{ pill('deaktiviert', '') }}{% endif %}
|
|
</td>
|
|
<td data-sort-value="{{ entry.days_left if entry.days_left is not none else 99999 }}">
|
|
{% if entry.lifetime %}{{ pill('unbefristet', 'online') }}
|
|
{% elif entry.is_expired %}{{ pill('abgelaufen', 'danger') }}
|
|
{% elif entry.color == 'red' %}{{ pill(entry.days_left ~ ' Tage', 'danger') }}
|
|
{% elif entry.color == 'orange' %}{{ pill(entry.days_left ~ ' Tage', 'warning') }}
|
|
{% else %}{{ pill(entry.days_left ~ ' Tage', 'online') }}{% endif %}
|
|
</td>
|
|
<td class="fs-sm">{{ entry.hostname or '–' | safe }}</td>
|
|
<td class="fs-sm" data-sort-value="{{ entry.last_heartbeat_at or '' }}">
|
|
{% if entry.last_heartbeat_at %}
|
|
<span data-relative-time="{{ entry.last_heartbeat_at }}">{{ entry.last_heartbeat_at | dt }}</span>
|
|
{% else %}<span class="faint">nie</span>{% endif %}
|
|
</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{% if not licenses %}{{ empty_state('Keine Lizenzen', 'Zu diesem Filter gibt es nichts.', 'shield') }}{% endif %}
|
|
</section>
|
|
{% endblock %}
|