Master-Lizenzserver Phase 2: neue Schwester-Anwendung srv/tesm-license (v1.0.0)
Fork von TESM (srv/tesm), auf Kunden-/Lizenzverwaltung reduziert statt
Geraete-/PoE-Management. Wiederverwendet unveraendert: Login/Session/
Benutzer- und Gruppenverwaltung, LDAP/AD, NGINX- und Systemeinstellungen,
Live-Log/Verlauf/Auditlog, Im-/Export-Grundgeruest, das komplette
PERMISSIONS/NAV_ITEMS/inject_nav()-Rechtesystem sowie das in Phase 1
gebaute Lizenzsystem selbst sowohl fuer die MASTER-eigene Bootstrap-Lizenz
als auch fuer das exakt gleiche licensing.py (byte-identisch zu TESM --
Signieren/Verifizieren muss zwischen beiden Seiten kompatibel bleiben).
Entfernt: Clients/Switche/Zugangsdaten/DHCP/Fileshare/Wartung/Papierkorb/
manueller PoE-Neustart/SSH-Terminal inkl. aller zugehoerigen Tabellen,
Routen, Permissions, Nav-Eintraege und Abhaengigkeiten (paramiko/Flask-Sock/
simple-websocket/PyNaCl/pyasn1 aus requirements.txt). Benutzer-/Gruppen-
Loeschung von Soft- auf Hard-Delete umgestellt (kein Papierkorb mehr).
Eigener Pfad-/Env-Var-Namespace (TESM_LICENSE_* statt TESM_*, /var/log/
tesm-license statt /var/log/tesm usw.), damit Master und TESM testweise
sogar auf demselben Host nebeneinander laufen koennen, ohne sich Log-/
Config-Pfade streitig zu machen.
Neu -- der eigentliche Lizenzserver:
- license_customers/licenses-Tabellen, Master-Signaturschluessel
(master_signing_key.json, einmalig erzeugt, NIE automatisch rotiert --
jede Kundenlizenz traegt den zum Ausstellungszeitpunkt aktuellen
master_pubkey fest eingebettet).
- Dashboard ('/') als Kunden-/Lizenzuebersicht (Typ, Module, Ablauf,
Status, letzter Heartbeat), eigene Kunden-Verwaltungsseite.
- Lizenz-Ausstellung (Typ/Module/Laufzeit -> signierte Datei via
licensing.issue_license), Detailseite, Download, Widerruf.
- /api/activate, /api/deactivate, /api/heartbeat (unauthentifiziert per
Design -- die Signatur der Anfrage IST der Berechtigungsnachweis) sowie
eine manuelle Offline-Code-Seite, die dieselben drei Verarbeitungs-
funktionen (_process_activate/_process_deactivate/_process_heartbeat)
nutzt wie die Online-API -- ein Protokoll, zwei Transportwege.
- E-Mail-Versand ausgestellter Lizenzen per Microsoft Graph
(Client-Credentials-Flow, reine Standardbibliothek/urllib, keine neue
Abhaengigkeit) inkl. Einrichtungsanleitung und Verbindungstest.
- create_master_license.py: lokales Bootstrap-/Erneuerungs-Skript fuer
die eigene Lizenz des Masters (kein externer Super-Master noetig).
Verifiziert auf dem Testsystem (192.168.82.51): App laeuft parallel zu der
dort laufenden echten TESM-Instanz (Port 5001 vs. 80/5000, eigene
Log-/DB-Pfade, TESM unangetastet). Kompletter ECHTER End-to-End-Rundlauf
per Playwright durchgespielt -- kein simulierter Gegenpart: Kunde anlegen
-> Custom-Lizenz (dhcp+fileshare) ausstellen -> herunterladen -> auf der
echten TESM-Instanz hochladen -> ECHTE Online-Aktivierung (Master
verzeichnet Fingerprint, TESM zeigt 'Aktiviert') -> ECHTE Online-
Deaktivierung (TESM zeigt 'lizenzlos, Export bleibt moeglich', Master
zeigt Status 'Deaktiviert'). Lokale Syntax-/Templatepruefung (py_compile,
pyflakes, jinja2-Parse aller Templates) sauber.
Bewusst NICHT nach main gemergt/getaggt/ausgerollt -- folgt zusammen mit
Phase 3 (Infrastruktur-Umzug + POETEST-Enterprise-Lizenz), siehe Plan
toasty-twirling-hickey.md (Phase 2 von 3).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "logs" %}
|
||||
{% block page_title %}Auditlog{% endblock %}
|
||||
{% block page_sub %}
|
||||
<div class="topbar-sub" id="auditEntryCount">
|
||||
{% if has_more %}{{ entries|length }} von {{ total_count }} Einträgen geladen{% else %}{{ entries|length }} Eintrag{{ 'e' if entries|length != 1 else '' }}{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% import "_audit_log_macros.html" as m %}
|
||||
|
||||
{% set ns = namespace(create=0, delete=0, edit=0) %}
|
||||
{% for e in entries %}
|
||||
{% set cat = m.category_of(e['action'].split('.')[-1])|trim %}
|
||||
{% if cat == "create" %}{% set ns.create = ns.create + 1 %}
|
||||
{% elif cat == "delete" %}{% set ns.delete = ns.delete + 1 %}
|
||||
{% else %}{% set ns.edit = ns.edit + 1 %}{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% macro fmt_count(loaded, total) %}{% if loaded < total %}{{ loaded }} / {{ total }}{% else %}{{ loaded }}{% endif %}{% endmacro %}
|
||||
|
||||
<div class="stat-row" style="margin-bottom:18px;">
|
||||
<div class="stat-card" data-category-filter="">
|
||||
<div class="stat-label">Alle</div>
|
||||
<div class="stat-value" id="statAll">{{ fmt_count(entries|length, total_count) }}</div>
|
||||
</div>
|
||||
<div class="stat-card" data-category-filter="create">
|
||||
<div class="stat-label">Hinzufügen</div>
|
||||
<div class="stat-value" id="statCreate" style="color:var(--success);">{{ fmt_count(ns.create, total_create) }}</div>
|
||||
</div>
|
||||
<div class="stat-card" data-category-filter="edit">
|
||||
<div class="stat-label">Änderungen</div>
|
||||
<div class="stat-value" id="statEdit" style="color:var(--accent-strong);">{{ fmt_count(ns.edit, total_edit) }}</div>
|
||||
</div>
|
||||
<div class="stat-card" data-category-filter="delete">
|
||||
<div class="stat-label">Löschungen</div>
|
||||
<div class="stat-value" id="statDelete" style="color:var(--danger);">{{ fmt_count(ns.delete, total_delete) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<div class="table-toolbar">
|
||||
<div class="search-input">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
|
||||
<input type="text" id="auditSearch" placeholder="Benutzer, Aktion, Ziel oder Details durchsuchen...">
|
||||
</div>
|
||||
</div>
|
||||
<div style="overflow-x:auto;">
|
||||
<table class="data-table" id="auditTable" data-sortable>
|
||||
<thead><tr>
|
||||
<th data-sort-key="ts" style="width:1%; white-space:nowrap;">Zeitpunkt</th>
|
||||
<th data-sort-key="user" style="width:1%; white-space:nowrap;">Benutzer</th>
|
||||
<th data-sort-key="action" style="width:1%; white-space:nowrap;">Aktion</th>
|
||||
<th data-sort-key="target" style="width:1%; white-space:nowrap;">Ziel</th>
|
||||
<th>Details</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for e in entries %}{{ m.audit_row(e) }}{% else %}
|
||||
<tr class="empty-row"><td colspan="5">Noch keine Änderungen protokolliert.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p id="auditNoResults" class="text-faint hidden" style="padding:16px; text-align:center; font-size:12.5px;">Keine Einträge für diese Auswahl.</p>
|
||||
<div id="auditLoadMoreWrap" class="{{ 'hidden' if not has_more }}" style="padding:16px; text-align:center; display:flex; gap:8px; justify-content:center;">
|
||||
<button type="button" id="auditLoadMoreBtn" class="btn btn-primary" data-oldest-id="{{ oldest_loaded_id or '' }}">
|
||||
Mehr laden (300)
|
||||
</button>
|
||||
<button type="button" id="auditLoadAllBtn" class="btn btn-secondary">
|
||||
Alle laden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const searchInput = document.getElementById("auditSearch");
|
||||
const noResults = document.getElementById("auditNoResults");
|
||||
let activeCategory = null;
|
||||
|
||||
function applyFilters() {
|
||||
const q = (searchInput ? searchInput.value : "").trim().toLowerCase();
|
||||
const rows = Array.from(document.querySelectorAll("#auditTable tbody tr"));
|
||||
let anyVisible = false;
|
||||
rows.forEach(function (row) {
|
||||
if (row.classList.contains("empty-row")) return;
|
||||
const categoryMatches = !activeCategory || row.dataset.category === activeCategory;
|
||||
const textMatches = !q || row.innerText.toLowerCase().includes(q);
|
||||
const match = categoryMatches && textMatches;
|
||||
row.style.display = match ? "" : "none";
|
||||
if (match) anyVisible = true;
|
||||
});
|
||||
if (noResults) noResults.classList.toggle("hidden", anyVisible);
|
||||
document.querySelectorAll(".stat-card[data-category-filter]").forEach(function (card) {
|
||||
const isTotal = card.dataset.categoryFilter === "";
|
||||
const active = activeCategory === null ? isTotal : card.dataset.categoryFilter === activeCategory;
|
||||
card.classList.toggle("active", active);
|
||||
});
|
||||
}
|
||||
|
||||
if (searchInput) searchInput.addEventListener("input", applyFilters);
|
||||
|
||||
document.querySelectorAll(".stat-card[data-category-filter]").forEach(function (card) {
|
||||
card.addEventListener("click", function () {
|
||||
const key = this.dataset.categoryFilter;
|
||||
activeCategory = (!key || activeCategory === key) ? null : key;
|
||||
applyFilters();
|
||||
});
|
||||
});
|
||||
|
||||
/* ---- Nachladen älterer Einträge (Mehr laden / Alle laden) ---- */
|
||||
const totalCount = {{ total_count }};
|
||||
const totalCreate = {{ total_create }};
|
||||
const totalEdit = {{ total_edit }};
|
||||
const totalDelete = {{ total_delete }};
|
||||
const entryCountLabel = document.getElementById("auditEntryCount");
|
||||
|
||||
function fmtCount(loaded, total) {
|
||||
return loaded < total ? loaded + " / " + total : String(loaded);
|
||||
}
|
||||
|
||||
function recomputeStats() {
|
||||
const rows = Array.from(document.querySelectorAll("#auditTable tbody tr[data-category]"));
|
||||
const counts = { create: 0, edit: 0, delete: 0 };
|
||||
rows.forEach(function (r) { counts[r.dataset.category] = (counts[r.dataset.category] || 0) + 1; });
|
||||
document.getElementById("statAll").textContent = fmtCount(rows.length, totalCount);
|
||||
document.getElementById("statCreate").textContent = fmtCount(counts.create, totalCreate);
|
||||
document.getElementById("statEdit").textContent = fmtCount(counts.edit, totalEdit);
|
||||
document.getElementById("statDelete").textContent = fmtCount(counts.delete, totalDelete);
|
||||
if (entryCountLabel) {
|
||||
entryCountLabel.textContent = rows.length < totalCount
|
||||
? rows.length + " von " + totalCount + " Einträgen geladen"
|
||||
: rows.length + " Eintrag" + (rows.length === 1 ? "" : "e");
|
||||
}
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
const loadMoreBtn = document.getElementById("auditLoadMoreBtn");
|
||||
const loadAllBtn = document.getElementById("auditLoadAllBtn");
|
||||
const loadMoreWrap = document.getElementById("auditLoadMoreWrap");
|
||||
const tbody = document.querySelector("#auditTable tbody");
|
||||
const MORE_URL = "{{ url_for('activity_log_more') }}";
|
||||
|
||||
// Laedt genau einen weiteren 300er-Block nach und haengt ihn an -- von
|
||||
// beiden Buttons genutzt: "Mehr laden" ruft das einmal auf, "Alle laden"
|
||||
// ruft es wiederholt auf, bis der Server "keine weiteren mehr" meldet.
|
||||
function loadMoreOnce() {
|
||||
const oldestId = loadMoreBtn.dataset.oldestId;
|
||||
if (!oldestId) return Promise.resolve({ hasMore: false });
|
||||
return fetch(MORE_URL + "?before_id=" + encodeURIComponent(oldestId))
|
||||
.then(function (r) {
|
||||
const hasMore = r.headers.get("X-Has-More") === "1";
|
||||
const newOldestId = r.headers.get("X-Oldest-Id");
|
||||
return r.text().then(function (html) {
|
||||
return { html: html, hasMore: hasMore, newOldestId: newOldestId };
|
||||
});
|
||||
})
|
||||
.then(function (result) {
|
||||
const emptyRow = tbody.querySelector(".empty-row");
|
||||
if (emptyRow) emptyRow.remove();
|
||||
tbody.insertAdjacentHTML("beforeend", result.html);
|
||||
if (result.newOldestId) loadMoreBtn.dataset.oldestId = result.newOldestId;
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
if (loadMoreBtn) {
|
||||
loadMoreBtn.addEventListener("click", function () {
|
||||
loadMoreBtn.disabled = true;
|
||||
if (loadAllBtn) loadAllBtn.disabled = true;
|
||||
const originalText = loadMoreBtn.textContent;
|
||||
loadMoreBtn.textContent = "Lädt …";
|
||||
loadMoreOnce()
|
||||
.then(function (result) {
|
||||
if (!result.hasMore) loadMoreWrap.classList.add("hidden");
|
||||
recomputeStats();
|
||||
applyFilters();
|
||||
})
|
||||
.catch(function () { /* Button unten wird trotzdem wieder aktiviert */ })
|
||||
.then(function () {
|
||||
loadMoreBtn.disabled = false;
|
||||
if (loadAllBtn) loadAllBtn.disabled = false;
|
||||
loadMoreBtn.textContent = originalText;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (loadAllBtn) {
|
||||
loadAllBtn.addEventListener("click", function () {
|
||||
loadMoreBtn.disabled = true;
|
||||
loadAllBtn.disabled = true;
|
||||
const originalText = loadAllBtn.textContent;
|
||||
|
||||
function step() {
|
||||
loadAllBtn.textContent = "Lädt … (" + recomputeStats() + " von " + totalCount + ")";
|
||||
return loadMoreOnce().then(function (result) {
|
||||
return result.hasMore ? step() : null;
|
||||
});
|
||||
}
|
||||
|
||||
step()
|
||||
.then(function () {
|
||||
loadMoreWrap.classList.add("hidden");
|
||||
})
|
||||
.catch(function () { /* teilweise geladene Eintraege bleiben stehen */ })
|
||||
.then(function () {
|
||||
recomputeStats();
|
||||
applyFilters();
|
||||
loadMoreBtn.disabled = false;
|
||||
loadAllBtn.disabled = false;
|
||||
loadAllBtn.textContent = originalText;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
applyFilters();
|
||||
recomputeStats();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user