Files
Deploymentcenter/public/index.php
T
Deploymentcenter BotandClaude Opus 5 0c20698fd5 fix(2.9.2): Fehlermeldung lesbar machen, Modellliste ins Formularraster
Zwei Befunde aus der Abnahme auf der Testinstanz.

1. Die Ablehnung eines Softwarekeys erschien als "[object Object]".
   Die API verpackt Fehler als { error: { code, message } }; das
   JavaScript las "data.message || data.error" und griff damit auf das
   OBJEKT zu - ausgerechnet an der Stelle, an der die Begruendung stehen
   sollte, warum ein Schluessel abgelehnt wurde. Eine Regel, die ihren
   Grund nicht nennt, ist in der Bedienung dasselbe wie ein Defekt.

   Derselbe Fehler steckte in login.php. Dort waere er schwerer wiegend:
   An der zweiten Stufe ist diese Meldung der einzige Hinweis, warum es
   nicht weitergeht. Beide lesen jetzt beide Formen - die verschachtelte
   und die flache, die weiterhin vorkommt.

2. Die Textbox fuer die Modellliste stand neben statt unter ihrer
   Beschriftung und behielt ihre Eigengroesse. Der umschliessende
   form-group fehlte; genau der setzt im Stylesheet display:flex mit
   column und laesst das Feld die Breite fuellen. Alle anderen
   Textboxen der Oberflaeche haben ihn.

Zusaetzlich docs/HARDWAREKEY.md: Konzept, Beispielcode und Prueflíste
zur gleichwertigen Uebernahme in ein anderes Projekt. Der Schwerpunkt
liegt auf den drei Dingen, die wie eine Pruefung aussehen und keine
sind - cross-platform, response.authenticatorAttachment und ein blosses
x5c -, samt dem Gegentest, ohne den nichts belegt ist.

Kein Eingriff in src/, sql/ oder public/api/ - der Kern ist unveraendert
gegenueber dem Lauf mit 184 von 184 gruenen Testfaellen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 11:37:49 +02:00

9302 lines
527 KiB
PHP
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.
<?php
/**
* Deploymentcenter WebUI - Operations Hub
*
* Wesentliche Aenderungen gegenueber der Erstfassung:
* - Bootstrap statt handgepflegter require-Kette. Die fehlende Einbindung von
* Audit.php liess "Hardware-Aktivierung freigeben" zuverlaessig in einen
* Fatal Error laufen.
* - CSRF-Schutz fuer saemtliche zustandsaendernden Formulare.
* - Post/Redirect/Get: ein F5 nach dem Speichern legt keinen zweiten
* Kommentar, kein zweites Item und kein zweites Release mehr an.
* - Bugtracker-Liste wird serverseitig gefiltert und seitenweise geladen,
* statt saemtliche Datensaetze ins HTML zu rendern.
* - Zeitstempel liegen in der Datenbank in UTC und werden hier in die
* App-Zeitzone umgerechnet.
*/
declare(strict_types=1);
require_once __DIR__ . '/../src/bootstrap.php';
use Deploymentcenter\Core\ApiAuth;
use Deploymentcenter\Core\Auth;
use Deploymentcenter\Core\Config;
use Deploymentcenter\Core\Csrf;
use Deploymentcenter\Core\Db;
use Deploymentcenter\Core\Http;
use Deploymentcenter\Core\Logger;
use Deploymentcenter\Core\SecurityLogger;
use Deploymentcenter\Core\TokenManager as CoreTokenManager;
use Deploymentcenter\Core\Webauthn\CredentialRepo;
use Deploymentcenter\Core\Webauthn\HardwarePolicy;
use Deploymentcenter\Modules\Bugtracker\BugRepo;
use Deploymentcenter\Modules\Bugtracker\IgnoreRules;
use Deploymentcenter\Modules\Bugtracker\Roadmap;
use Deploymentcenter\Modules\License\Audit;
use Deploymentcenter\Modules\License\KeyGen;
use Deploymentcenter\Modules\UpdateService\ReleaseGuard;
use Deploymentcenter\Modules\UpdateService\ReleaseSigner;
use Deploymentcenter\Modules\UpdateService\UpdateManager;
use Deploymentcenter\Modules\Watchdog\AgentScript;
use Deploymentcenter\Modules\Watchdog\Evaluator;
use Deploymentcenter\Modules\Watchdog\EventLog;
use Deploymentcenter\Modules\Watchdog\MonitorRepo;
use Deploymentcenter\Modules\Watchdog\TokenManager;
Auth::requireLogin();
$pdo = Db::init();
// ======================================================================
// Migrations-Schranke
// ======================================================================
//
// Das Dashboard greift auf Spalten zu, die erst durch die Migration entstehen
// (claimed_by, lease_until, dedup_key ...). Ohne diese Pruefung liefe die Seite
// vor der Migration in einen Fehler - und ausgerechnet die Schaltflaeche zum
// Ausfuehren der Migration liegt in dieser Seite. Deshalb hier eine eigene,
// minimale Ansicht, solange etwas aussteht.
$pendingMigrations = [];
try {
$pendingMigrations = \Deploymentcenter\Core\Migrator::status($pdo)['pending'];
} catch (Throwable $e) {
Logger::warning('Migrationsstatus nicht ermittelbar', ['error' => $e->getMessage()]);
}
if ($pendingMigrations !== []) {
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'run_migration') {
Csrf::requireValid();
$migrationResult = \Deploymentcenter\Core\Migrator::migrate($pdo);
if ($migrationResult['failed'] !== null) {
$migrationError = Config::isDebug()
? $migrationResult['failed']['error']
: 'Details stehen im Log unter var/log/.';
$migrationFailedVersion = $migrationResult['failed']['version'];
} else {
header('Location: /index.php', true, 303);
exit;
}
}
$csrfField = Csrf::field();
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Datenbank-Migration erforderlich</title>
<style>
body { font-family: system-ui, sans-serif; background:#0a0d14; color:#EDEFF5;
display:flex; align-items:center; justify-content:center; min-height:100vh; margin:0; padding:2rem; }
.box { background:rgba(255,255,255,0.045); border:1px solid rgba(255,255,255,0.09);
border-radius:18px; padding:2rem; max-width:640px; }
h1 { font-size:1.3rem; margin:0 0 1rem 0; }
p { color:#8B93A7; line-height:1.6; }
code { background:rgba(0,0,0,0.4); padding:0.15rem 0.4rem; border-radius:4px;
font-family:ui-monospace, monospace; font-size:0.85rem; color:#5b9dff; }
ul { color:#8B93A7; }
.btn { background:linear-gradient(135deg,#1652F0,#4c8dff); color:#fff; border:none;
border-radius:10px; padding:0.7rem 1.3rem; font-size:0.9rem; font-weight:700;
cursor:pointer; margin-top:1.25rem; }
.err { background:rgba(246,70,93,0.15); border:1px solid rgba(246,70,93,0.3);
color:#fca5a5; padding:1rem; border-radius:10px; margin:1rem 0; font-size:0.875rem; }
</style>
</head>
<body>
<div class="box">
<h1>🗄️ Datenbank-Migration erforderlich</h1>
<?php if (isset($migrationError)): ?>
<div class="err">
<strong>Migration <?= e((string)$migrationFailedVersion) ?> fehlgeschlagen.</strong><br>
<?= e((string)$migrationError) ?>
</div>
<?php endif; ?>
<p>
Es stehen <?= count($pendingMigrations) ?> Migration(en) aus. Solange sie nicht
angewendet sind, fehlen Tabellen und Spalten, die das Dashboard benötigt.
</p>
<ul>
<?php foreach ($pendingMigrations as $version): ?>
<li><code><?= e((string)$version) ?></code></li>
<?php endforeach; ?>
</ul>
<p>
Die Migrationen sind additiv: bestehende Daten bleiben erhalten, bereits
vorhandene Tabellen und Spalten werden übersprungen.
</p>
<form method="POST" action="index.php">
<?= $csrfField ?>
<input type="hidden" name="action" value="run_migration">
<button type="submit" class="btn">Migration jetzt ausführen</button>
</form>
</div>
</body>
</html>
<?php
exit;
}
// ======================================================================
// Hilfsfunktionen fuer die Ausgabe
// ======================================================================
/**
* Kurzform fuer htmlspecialchars mit sicheren Voreinstellungen.
*
* Nimmt bewusst jeden skalaren Typ entgegen, nicht nur ?string. Zwei Quellen
* liefern hier regelmaessig Integer:
* - PDO gibt mit ATTR_STRINGIFY_FETCHES = false numerische Spalten als int
* zurueck, und genau diese Werte werden ausgegeben.
* - PHP wandelt numerische Array-Schluessel automatisch in int um, sodass
* foreach ($optionen as $wert => $label) bei Schluesseln wie '24' einen
* int liefert.
*
* Mit declare(strict_types=1) warf eine ?string-Signatur dabei einen
* TypeError - und da diese Funktion mitten im Seitenaufbau steht, brach das
* Rendering ab, bevor der Script-Block ausgegeben wurde. Im Browser
* aeusserte sich das als "switchMainTab is not defined".
*
* @param mixed $value
*/
function e($value): string
{
if ($value === null) {
return '';
}
if (is_bool($value)) {
return $value ? '1' : '';
}
if (is_array($value) || is_object($value)) {
return '';
}
return htmlspecialchars((string)$value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
/**
* Formatiert einen UTC-Zeitstempel aus der Datenbank in der App-Zeitzone.
* Die Datenbanksitzung laeuft auf UTC, die Anzeige auf Europe/Berlin.
*/
function dc_dt(?string $utc, string $format = 'd.m.Y H:i'): string
{
if ($utc === null || $utc === '' || str_starts_with($utc, '0000')) {
return 'Nie';
}
try {
$dt = new DateTime($utc, new DateTimeZone('UTC'));
$dt->setTimezone(new DateTimeZone((string)Config::get('app.timezone', 'UTC')));
return $dt->format($format);
} catch (Throwable $e) {
return $utc;
}
}
/** Relative Zeitangabe, z. B. "vor 4 min". */
function dc_ago(?string $utc): string
{
if ($utc === null || $utc === '') {
return 'nie';
}
$ts = strtotime($utc . ' UTC');
if ($ts === false) {
return 'unbekannt';
}
$diff = time() - $ts;
if ($diff < 60) { return 'gerade eben'; }
if ($diff < 3600) { return 'vor ' . intdiv($diff, 60) . ' min'; }
if ($diff < 86400) { return 'vor ' . intdiv($diff, 3600) . ' h'; }
return 'vor ' . intdiv($diff, 86400) . ' Tagen';
}
/** Hinterlegt eine Meldung, die nach dem Redirect angezeigt wird. */
function dc_flash(string $message, string $type = 'success'): void
{
Auth::startSession();
$_SESSION['dc_flash'] = ['message' => $message, 'type' => $type];
}
/**
* Wie viele handlungsfaehige Administratorkonten gibt es noch?
*
* Grundlage fuer die Sperren beim Deaktivieren und Loeschen: Faellt das
* letzte Konto weg, das sich am WebUI anmelden kann, kommt niemand mehr an
* die Oberflaeche - auch nicht, um den Schritt rueckgaengig zu machen.
* Installationskonten zaehlen bewusst nicht mit, sie duerfen sich hier gar
* nicht anmelden.
*/
/**
* Erzeugt den Zugangsschutz der Release-Verzeichnisse neu.
*
* Wird nach jeder Aenderung aufgerufen, die beeinflusst, wer herunterladen
* darf: Lizenzen und Installationskonten. Bewusst immer fuer alle Produkte -
* bei einer Handvoll Projekten kostet das kaum etwas und erspart die
* Fehlerquelle, den richtigen Slug zu ermitteln.
*
* Ein Fehlschlag darf die ausloesende Aktion nicht scheitern lassen: Eine
* angelegte Lizenz ist angelegt, auch wenn die Datei gerade nicht schreibbar
* war. Der naechtliche Abgleich holt es nach, und die Oberflaeche zeigt den
* Zustand unter UpdateService an.
*/
function dc_guard_releases(PDO $pdo): void
{
try {
$result = ReleaseGuard::regenerateAll($pdo);
if ($result['errors'] !== []) {
Logger::warning('Release-Zugangsschutz teilweise fehlgeschlagen', [
'errors' => $result['errors'],
]);
}
} catch (Throwable $e) {
Logger::warning('Release-Zugangsschutz nicht erzeugt', ['error' => $e->getMessage()]);
}
}
function dc_active_admin_count(PDO $pdo): int
{
try {
$stmt = $pdo->query("SELECT COUNT(*) FROM dc_users WHERE role = 'admin' AND disabled = 0");
return (int)$stmt->fetchColumn();
} catch (Throwable $e) {
// Vor Migration 012 gibt es weder role noch disabled; dann sind alle
// Konten Administratoren.
try {
return (int)$pdo->query('SELECT COUNT(*) FROM dc_users')->fetchColumn();
} catch (Throwable $inner) {
return 0;
}
}
}
/**
* Post/Redirect/Get: leitet nach einer Aktion auf dieselbe Seite um, damit
* ein Neuladen das Formular nicht erneut abschickt.
*
* Das Formular teilt beim Absenden im Feld _return mit, welche Unteransicht
* gerade geoeffnet war; dorthin wird zurueckgeleitet. Der als $fragment
* uebergebene Wert dient nur noch als Rueckfallebene, etwa wenn JavaScript
* abgeschaltet ist.
*
* Zuvor wurde ausschliesslich der Modul-Anker gesetzt. Man legte einen Monitor
* an und landete anschliessend auf dem Watchdog-Dashboard statt im Formular.
*/
function dc_redirect(string $fragment = ''): void
{
$return = $_POST['_return'] ?? null;
// Nur eigene Ansichtskennungen zulassen - der Wert landet in einem
// Location-Header.
if (is_string($return) && preg_match('/^(?:tab|sub)-[a-z0-9-]{1,60}$/', $return) === 1) {
$fragment = '#' . $return;
}
$query = $_SERVER['QUERY_STRING'] ?? '';
$target = '/index.php' . ($query !== '' ? '?' . $query : '') . $fragment;
if (!headers_sent()) {
header('Location: ' . $target, true, 303);
}
exit;
}
// ======================================================================
// Download-Handler (GET)
// ======================================================================
if (($_GET['action'] ?? '') === 'download_lic') {
$licenseId = (int)($_GET['id'] ?? 0);
$stmt = $pdo->prepare('
SELECT l.*, p.slug AS product_slug
FROM license_licenses l
JOIN dc_projects p ON l.product_id = p.id
WHERE l.id = :id
');
$stmt->execute([':id' => $licenseId]);
$license = $stmt->fetch();
if (!is_array($license)) {
http_response_code(404);
exit('Lizenz nicht gefunden.');
}
$payload = [
'type' => 'offline_license_file',
'issued_at' => time(),
'product' => $license['product_slug'],
'license_key' => $license['license_key'],
'customer' => $license['customer_name'] ?? 'Universal',
'valid_until' => $license['expires_at'] ? strtotime((string)$license['expires_at']) : strtotime('+1 year'),
];
// Die Erstfassung setzte ein hartkodiertes Secret ein und beschriftete das
// Ergebnis irrefuehrend als "ED25519_SIG_". Jetzt eine ehrlich benannte
// HMAC-SHA256-Signatur ueber den kanonisierten Inhalt, mit dem Schluessel
// aus der Konfiguration. Der Client kann sie mit demselben Schluessel pruefen.
$signingKey = (string)Config::get('security.license_key', '');
$canonical = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$payload['signature_alg'] = 'HMAC-SHA256';
$payload['signature'] = $signingKey !== ''
? hash_hmac('sha256', (string)$canonical, $signingKey)
: null;
$json = json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$filename = sprintf('%s_%s_offline.lic', $license['product_slug'], substr((string)$license['license_key'], 0, 5));
header('Content-Type: application/json; charset=utf-8');
header('Content-Disposition: attachment; filename="' . preg_replace('/[^A-Za-z0-9._-]/', '', $filename) . '"');
header('Content-Length: ' . strlen((string)$json));
echo $json;
exit;
}
if (($_GET['action'] ?? '') === 'download_agent') {
$source = trim((string)($_GET['source'] ?? 'server-node'));
$os = trim((string)($_GET['os'] ?? 'windows'));
$token = trim((string)($_GET['token'] ?? ''));
// Erzeugt wird in AgentScript - dieselbe Fassung, die der Installer ueber
// den Setup-Weg bekommt. Das Saeubern der Eingaben passiert dort mit.
$agent = AgentScript::build(
$os,
$source,
$token,
Http::baseUrl() . '/api/watchdog/v1/ping'
);
header('Content-Type: text/plain; charset=utf-8');
header('Content-Disposition: attachment; filename="' . $agent['filename'] . '"');
echo $agent['script'];
exit;
}
// ======================================================================
// POST-Verarbeitung
// ======================================================================
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Ein einziger Aufruf schuetzt alle Aktionen dieser Seite.
Csrf::requireValid();
$action = is_string($_POST['action'] ?? null) ? $_POST['action'] : '';
$actor = Auth::username();
try {
switch ($action) {
// ---------------------------------------------------- Projekte
case 'save_project': {
$id = (int)($_POST['project_id'] ?? 0);
$slug = trim((string)($_POST['slug'] ?? ''));
$name = trim((string)($_POST['name'] ?? ''));
$ttl = max(1, (int)($_POST['ttl'] ?? 168));
$notes = trim((string)($_POST['notes'] ?? ''));
$repo = trim((string)($_POST['repo_url'] ?? ''));
$agent = trim((string)($_POST['default_agent'] ?? ''));
if ($name === '') {
dc_flash('Der Projektname darf nicht leer sein.', 'danger');
dc_redirect('#tab-projects');
}
if ($id > 0) {
$stmt = $pdo->prepare('
UPDATE dc_projects
SET name = :name, default_cache_ttl_hours = :ttl, notes = :notes,
repo_url = :repo, default_agent = :agent
WHERE id = :id
');
$stmt->execute([
':name' => $name,
':ttl' => $ttl,
':notes' => $notes !== '' ? $notes : null,
':repo' => $repo !== '' ? $repo : null,
':agent' => $agent !== '' ? $agent : null,
':id' => $id,
]);
dc_flash(sprintf('Projekt "%s" wurde aktualisiert.', $name));
} else {
if ($slug === '' || preg_match('/^[a-z0-9][a-z0-9_-]{1,63}$/', $slug) !== 1) {
dc_flash('Der Slug muss aus Kleinbuchstaben, Ziffern, Bindestrich oder Unterstrich bestehen.', 'danger');
dc_redirect('#tab-projects');
}
$stmt = $pdo->prepare('
INSERT INTO dc_projects (slug, name, default_cache_ttl_hours, notes, repo_url, default_agent)
VALUES (:slug, :name, :ttl, :notes, :repo, :agent)
');
$stmt->execute([
':slug' => $slug,
':name' => $name,
':ttl' => $ttl,
':notes' => $notes !== '' ? $notes : null,
':repo' => $repo !== '' ? $repo : null,
':agent' => $agent !== '' ? $agent : null,
]);
dc_flash(sprintf('Projekt "%s" wurde angelegt.', $name));
}
dc_redirect('#tab-projects');
}
case 'delete_project': {
$id = (int)($_POST['project_id'] ?? 0);
$confirmSlug = trim((string)($_POST['confirm_slug'] ?? ''));
$stmt = $pdo->prepare('SELECT slug, name FROM dc_projects WHERE id = :id');
$stmt->execute([':id' => $id]);
$project = $stmt->fetch();
if (!is_array($project) || !hash_equals((string)$project['slug'], $confirmSlug)) {
dc_flash('Sicherheitsbestaetigung fehlgeschlagen - der eingegebene Slug stimmt nicht ueberein.', 'danger');
dc_redirect('#tab-projects');
}
Db::transaction(static function (PDO $db) use ($id, $project): void {
$db->prepare('
DELETE a FROM license_activations a
JOIN license_licenses l ON a.license_id = l.id
WHERE l.product_id = :id
')->execute([':id' => $id]);
$db->prepare('DELETE FROM license_licenses WHERE product_id = :id')->execute([':id' => $id]);
$db->prepare('DELETE FROM updateservice_releases WHERE product_slug = :slug')
->execute([':slug' => $project['slug']]);
$db->prepare('DELETE FROM bugtracker_items WHERE project_slug = :slug')
->execute([':slug' => $project['slug']]);
$db->prepare('DELETE FROM dc_projects WHERE id = :id')->execute([':id' => $id]);
});
// Mit dem Projekt sind auch seine Lizenzen weg.
dc_guard_releases($pdo);
dc_flash(sprintf('Projekt "%s" und alle zugehoerigen Daten wurden geloescht.', $project['name']));
dc_redirect('#tab-projects');
}
// ---------------------------------------------------- Lizenzen
case 'create_license': {
$productId = (int)($_POST['product_id'] ?? 0);
if ($productId <= 0) {
dc_flash('Bitte ein Projekt auswaehlen.', 'danger');
dc_redirect('#tab-license');
}
$licenseKey = KeyGen::generateKey();
$expiresAt = !empty($_POST['expires_at']) ? $_POST['expires_at'] . ' 23:59:59' : null;
$pdo->prepare('
INSERT INTO license_licenses
(product_id, license_key, customer_name, customer_email, max_activations, expires_at, notes)
VALUES (:pid, :key, :cname, :cemail, :max, :expires, :notes)
')->execute([
':pid' => $productId,
':key' => $licenseKey,
':cname' => trim((string)($_POST['customer_name'] ?? '')),
':cemail' => trim((string)($_POST['customer_email'] ?? '')),
':max' => max(1, (int)($_POST['max_activations'] ?? 2)),
':expires' => $expiresAt,
':notes' => trim((string)($_POST['notes'] ?? '')),
]);
Audit::log($pdo, $actor, 'license.create', ['key' => $licenseKey]);
// Der neue Schluessel muss sofort Updates ziehen koennen.
dc_guard_releases($pdo);
dc_flash('Lizenzschluessel erzeugt: <strong style="font-family:monospace;">' . e($licenseKey) . '</strong>');
dc_redirect('#tab-license');
}
case 'edit_license': {
$id = (int)($_POST['license_id'] ?? 0);
if ($id <= 0) {
dc_redirect('#tab-license');
}
$status = in_array($_POST['status'] ?? '', ['active', 'revoked', 'suspended'], true)
? $_POST['status']
: 'active';
$stmt = $pdo->prepare('
UPDATE license_licenses
SET customer_name = :cname, customer_email = :cemail, status = :status,
max_activations = :max, expires_at = :expires, notes = :notes
WHERE id = :id
');
$stmt->execute([
':cname' => trim((string)($_POST['customer_name'] ?? '')),
':cemail' => trim((string)($_POST['customer_email'] ?? '')),
':status' => $status,
':max' => max(1, (int)($_POST['max_activations'] ?? 2)),
':expires' => !empty($_POST['expires_at']) ? $_POST['expires_at'] . ' 23:59:59' : null,
':notes' => trim((string)($_POST['notes'] ?? '')),
':id' => $id,
]);
Audit::log($pdo, $actor, 'license.update', ['license_id' => $id, 'status' => $status]);
// Status oder Ablaufdatum koennen sich geaendert haben.
dc_guard_releases($pdo);
dc_flash($stmt->rowCount() > 0 ? 'Lizenzdaten aktualisiert.' : 'Keine Aenderung vorgenommen.');
dc_redirect('#tab-license');
}
case 'toggle_license_status': {
$id = (int)($_POST['license_id'] ?? 0);
$newStatus = in_array($_POST['new_status'] ?? '', ['active', 'revoked', 'suspended'], true)
? $_POST['new_status']
: 'revoked';
if ($id > 0) {
$pdo->prepare('UPDATE license_licenses SET status = :status WHERE id = :id')
->execute([':status' => $newStatus, ':id' => $id]);
Audit::log($pdo, $actor, 'license.status', ['license_id' => $id, 'status' => $newStatus]);
// Eine widerrufene Lizenz muss den Zugang sofort verlieren -
// sonst zieht sie weiter Updates, bis der naechtliche
// Abgleich laeuft.
dc_guard_releases($pdo);
dc_flash('Lizenz-Status geaendert zu: ' . strtoupper($newStatus));
}
dc_redirect('#tab-license');
}
case 'toggle_block_activation': {
$id = (int)($_POST['activation_id'] ?? 0);
$block = (int)($_POST['block_state'] ?? 0) === 1 ? 1 : 0;
if ($id > 0) {
$pdo->prepare('UPDATE license_activations SET is_blocked = :blocked WHERE id = :id')
->execute([':blocked' => $block, ':id' => $id]);
dc_flash($block === 1 ? 'Hardware-Aktivierung gesperrt.' : 'Hardware-Aktivierung entsperrt.');
}
dc_redirect('#tab-license');
}
case 'release_activation': {
$id = (int)($_POST['activation_id'] ?? 0);
$stmt = $pdo->prepare('
SELECT a.*, l.license_key
FROM license_activations a
JOIN license_licenses l ON a.license_id = l.id
WHERE a.id = :id
');
$stmt->execute([':id' => $id]);
$activation = $stmt->fetch();
if (is_array($activation)) {
$pdo->prepare('DELETE FROM license_activations WHERE id = :id')->execute([':id' => $id]);
// Frueher scheiterte genau diese Zeile: Audit war weder
// eingebunden noch importiert - garantierter Fatal Error.
Audit::log($pdo, $actor, 'activation_released', [
'license_id' => $activation['license_id'],
'license_key' => $activation['license_key'],
'hardware_id' => $activation['hardware_id'],
'hostname' => $activation['hostname'],
]);
dc_flash('Hardware-Aktivierung wurde freigegeben.');
} else {
dc_flash('Die Aktivierung existiert nicht mehr.', 'danger');
}
dc_redirect('#tab-license');
}
// ---------------------------------------------------- Watchdog
case 'add_watchdog_monitor': {
$source = trim((string)($_POST['source'] ?? ''));
if ($source === '') {
dc_flash('Der Monitor-Name darf nicht leer sein.', 'danger');
dc_redirect('#tab-watchdog');
}
$monitorRepo = new MonitorRepo($pdo);
$monitorRepo->createMonitor($source, [
'type' => $_POST['type'] ?? 'heartbeat',
'expected_interval_sec' => (int)($_POST['interval'] ?? 60),
'group_key' => trim((string)($_POST['group'] ?? 'Default')),
'parent_source' => trim((string)($_POST['parent_source'] ?? '')),
'os' => trim((string)($_POST['os'] ?? '')),
]);
$tokenManager = new TokenManager($pdo);
$token = $tokenManager->createToken($source, 'Token fuer ' . $source);
dc_flash(sprintf(
'Monitor "%s" angelegt. Agent-Token (bitte jetzt kopieren): <strong style="font-family:monospace;">%s</strong>',
e($source),
e($token['raw_token'])
));
dc_redirect('#tab-watchdog');
}
case 'edit_watchdog_monitor': {
$oldSource = trim((string)($_POST['old_source'] ?? ''));
if ($oldSource === '') {
dc_redirect('#tab-watchdog');
}
// Nur tatsaechlich uebermittelte Felder werden geschrieben.
// Die Erstfassung setzte alle Spalten fest - fehlte ein Feld im
// Formular (etwa "os"), wurde die Spalte bei jedem Speichern
// auf NULL zurueckgesetzt.
$fields = [];
$mapping = [
'new_source' => 'source',
'type' => 'type',
'group_key' => 'group_key',
'parent_source' => 'parent_source',
'os' => 'os',
'notes' => 'notes',
'url' => 'url',
'icon' => 'icon',
'expected_interval_sec' => 'expected_interval_sec',
];
foreach ($mapping as $postKey => $field) {
if (array_key_exists($postKey, $_POST)) {
$fields[$field] = $_POST[$postKey];
}
}
$fields['is_muted'] = isset($_POST['is_muted']);
// Optionaler Icon-Upload
if (!empty($_FILES['custom_icon_file']['name']) && is_uploaded_file($_FILES['custom_icon_file']['tmp_name'])) {
$uploaded = dc_storeIconUpload($_FILES['custom_icon_file']);
if ($uploaded !== null) {
$fields['icon'] = $uploaded;
}
}
$monitorRepo = new MonitorRepo($pdo);
$monitorRepo->updateMonitor($oldSource, 'default', $fields);
dc_flash(sprintf('Monitor "%s" wurde aktualisiert.', e((string)($fields['source'] ?? $oldSource))));
dc_redirect('#tab-watchdog');
}
case 'delete_watchdog_monitor': {
$source = trim((string)($_POST['source'] ?? ''));
if ($source !== '') {
(new MonitorRepo($pdo))->deleteMonitor($source, 'default');
dc_flash(sprintf('Monitor "%s" wurde geloescht.', e($source)));
}
dc_redirect('#tab-watchdog');
}
case 'link_entity': {
$source = trim((string)($_POST['source'] ?? ''));
$parent = trim((string)($_POST['parent_source'] ?? ''));
if ($source !== '') {
(new MonitorRepo($pdo))->setParentSource($source, $parent !== '' ? $parent : null);
dc_flash(sprintf('Hierarchie gespeichert: %s -> %s', e($source), e($parent !== '' ? $parent : 'Top Level')));
}
dc_redirect('#tab-watchdog');
}
case 'run_evaluator': {
$result = Evaluator::run($pdo);
dc_flash(sprintf(
'Evaluator ausgefuehrt: %d Monitor(e) geprueft, %d Zustandswechsel, %d abgelaufene Leases freigegeben.',
$result['checked'],
$result['changed'],
$result['released_leases']
));
dc_redirect('#tab-watchdog');
}
case 'create_agent_token': {
$source = trim((string)($_POST['token_source'] ?? ''));
$name = trim((string)($_POST['token_name'] ?? ''));
$tokenManager = new TokenManager($pdo);
$token = $tokenManager->createToken($source, $name !== '' ? $name : null);
dc_flash('Agent-Token erzeugt: <strong style="font-family:monospace;">' . e($token['raw_token']) . '</strong>');
dc_redirect('#tab-watchdog');
}
case 'revoke_agent_token': {
$tokenId = trim((string)($_POST['token_id'] ?? ''));
if ($tokenId !== '') {
$revoked = (new TokenManager($pdo))->revokeToken($tokenId);
dc_flash($revoked ? 'Agent-Token wurde widerrufen.' : 'Token nicht gefunden.', $revoked ? 'success' : 'danger');
}
dc_redirect('#tab-watchdog');
}
// ----------------------------------------------- UpdateService
case 'add_release': {
$productSlug = trim((string)($_POST['product_slug'] ?? ''));
$version = trim((string)($_POST['version'] ?? ''));
$url = trim((string)($_POST['download_url'] ?? ''));
if ($productSlug === '' || $version === '' || $url === '') {
dc_flash('Projekt, Version und Download-URL sind Pflichtfelder.', 'danger');
dc_redirect('#tab-updateservice');
}
$hash = trim((string)($_POST['sha256_hash'] ?? ''));
if ($hash !== '' && preg_match('/^[0-9a-f]{64}$/i', $hash) !== 1) {
dc_flash('Der SHA256-Hash muss aus genau 64 Hexadezimalzeichen bestehen.', 'danger');
dc_redirect('#tab-updateservice');
}
$updateManager = new UpdateManager($pdo);
$channel = trim((string)($_POST['channel'] ?? 'prod'));
$platform = UpdateManager::normalizePlatform((string)($_POST['platform'] ?? ''));
$size = (int)($_POST['size_bytes'] ?? 0);
// Auch der Weg ueber die Oberflaeche wird signiert, sonst
// haetten von Hand eingetragene Releases keine Vertrauenskette.
$signature = ReleaseSigner::sign(ReleaseSigner::canonical(
$productSlug,
$version,
$channel,
$platform,
$hash !== '' ? $hash : null,
$url,
$size
));
$result = $updateManager->addRelease(
$productSlug,
$version,
$channel,
trim((string)($_POST['release_notes'] ?? '')),
$url,
$hash !== '' ? $hash : null,
trim((string)($_POST['git_commit'] ?? '')),
$size,
null,
isset($_POST['is_critical']),
$actor,
$platform,
$signature
);
dc_flash(sprintf(
'Release %s (%s, %s) fuer "%s" %s.%s%s',
e($version),
e($channel),
e($platform),
e($productSlug),
$result['created'] ? 'veroeffentlicht' : 'aktualisiert',
$signature === null
? ' Hinweis: unsigniert, kein Signierschluessel hinterlegt.'
: '',
$result['auto_resolved'] > 0
? sprintf(' %d Bugtracker-Item(s) automatisch geschlossen.', $result['auto_resolved'])
: ''
));
dc_redirect('#tab-updateservice');
}
case 'edit_release': {
$releaseId = (int)($_POST['release_id'] ?? 0);
if ($releaseId <= 0) {
dc_flash('Ungültige Release-ID.', 'danger');
dc_redirect('#tab-updateservice');
}
$updateManager = new UpdateManager($pdo);
$existing = $updateManager->getReleaseById($releaseId);
if ($existing === null) {
dc_flash('Das zu bearbeitende Release wurde nicht gefunden.', 'danger');
dc_redirect('#tab-updateservice');
}
$productSlug = trim((string)($_POST['product_slug'] ?? ''));
$version = trim((string)($_POST['version'] ?? ''));
$url = trim((string)($_POST['download_url'] ?? ''));
if ($productSlug === '' || $version === '' || $url === '') {
dc_flash('Projekt, Version und Download-URL sind Pflichtfelder.', 'danger');
dc_redirect('#tab-updateservice');
}
$hash = trim((string)($_POST['sha256_hash'] ?? ''));
if ($hash !== '' && preg_match('/^[0-9a-f]{64}$/i', $hash) !== 1) {
dc_flash('Der SHA256-Hash muss aus genau 64 Hexadezimalzeichen bestehen.', 'danger');
dc_redirect('#tab-updateservice');
}
$channel = trim((string)($_POST['channel'] ?? 'prod'));
$platform = UpdateManager::normalizePlatform((string)($_POST['platform'] ?? ''));
$size = (int)($_POST['size_bytes'] ?? 0);
$signature = ReleaseSigner::sign(ReleaseSigner::canonical(
$productSlug,
$version,
$channel,
$platform,
$hash !== '' ? $hash : null,
$url,
$size
));
try {
$ok = $updateManager->updateRelease(
$releaseId,
$productSlug,
$version,
$channel,
trim((string)($_POST['release_notes'] ?? '')),
$url,
$hash !== '' ? $hash : null,
trim((string)($_POST['git_commit'] ?? '')),
$size,
$existing['manifest_json'] ?? null,
isset($_POST['is_critical']),
$actor,
$platform,
$signature
);
if ($ok) {
dc_flash(sprintf(
'Release %s (%s, %s) für "%s" erfolgreich aktualisiert.%s',
e($version),
e($channel),
e($platform),
e($productSlug),
$signature === null
? ' Hinweis: unsigniert, kein Signierschlüssel hinterlegt.'
: ''
));
} else {
dc_flash('Das Release konnte nicht aktualisiert werden.', 'danger');
}
} catch (\PDOException $e) {
if (str_contains($e->getMessage(), 'uq_prod_ver_chan_plat') || $e->getCode() === '23000') {
dc_flash('Ein Release mit dieser Kombination aus Projekt, Version, Kanal und Plattform existiert bereits.', 'danger');
} else {
dc_flash('Fehler beim Aktualisieren des Releases: ' . $e->getMessage(), 'danger');
}
}
dc_redirect('#tab-updateservice');
}
case 'delete_release': {
$releaseId = (int)($_POST['release_id'] ?? 0);
if ($releaseId <= 0) {
dc_flash('Ungültige Release-ID.', 'danger');
dc_redirect('#tab-updateservice');
}
$updateManager = new UpdateManager($pdo);
$existing = $updateManager->getReleaseById($releaseId);
if ($existing === null) {
dc_flash('Das zu löschende Release wurde nicht gefunden.', 'danger');
dc_redirect('#tab-updateservice');
}
$deleted = $updateManager->deleteRelease($releaseId);
if ($deleted) {
dc_flash(sprintf(
'Release v%s (%s, %s) für "%s" erfolgreich gelöscht.',
e($existing['version']),
e($existing['channel']),
e($existing['platform'] ?? 'any'),
e($existing['product_slug'])
));
} else {
dc_flash('Das Release konnte nicht gelöscht werden.', 'danger');
}
dc_redirect('#tab-updateservice');
}
// ------------------------------------------------------ Tokens
case 'create_master_token': {
$name = trim((string)($_POST['name'] ?? ''));
if ($name === '') {
dc_flash('Die Token-Bezeichnung darf nicht leer sein.', 'danger');
dc_redirect('#tab-tokens');
}
$scopes = isset($_POST['scopes']) && is_array($_POST['scopes']) ? $_POST['scopes'] : ['*'];
$expiresAt = !empty($_POST['expires_at']) ? $_POST['expires_at'] . ' 23:59:59' : null;
$tokenManager = new CoreTokenManager($pdo);
$token = $tokenManager->createMasterToken(
$name,
trim((string)($_POST['project_slug'] ?? '')),
trim((string)($_POST['license_key'] ?? '')),
(string)($_POST['owner_type'] ?? 'custom'),
trim((string)($_POST['owner_identity'] ?? '')),
$scopes,
(string)($_POST['environment'] ?? 'all'),
$expiresAt
);
dc_flash(
'Master-Token erstellt. Wert jetzt kopieren: '
. '<strong style="font-family:monospace; color:var(--success); font-size:1.05em;">'
. e($token['raw_token']) . '</strong>'
);
dc_redirect('#tab-tokens');
}
case 'edit_master_token': {
$tokenId = trim((string)($_POST['token_id'] ?? ''));
$scopes = $_POST['scopes'] ?? [];
try {
$ok = (new CoreTokenManager($pdo))->updateMasterToken($tokenId, [
'name' => $_POST['name'] ?? '',
'project_slug' => $_POST['project_slug'] ?? '',
'license_key' => $_POST['license_key'] ?? '',
'owner_type' => $_POST['owner_type'] ?? '',
'owner_identity' => $_POST['owner_identity'] ?? '',
'environment' => $_POST['environment'] ?? '',
// Das Datumsfeld liefert nur den Tag; das Token soll
// an diesem Tag noch gelten und erst danach ablaufen.
'expires_at' => !empty($_POST['expires_at'])
? $_POST['expires_at'] . ' 23:59:59' : '',
'scopes' => is_array($scopes) ? $scopes : [],
]);
} catch (\InvalidArgumentException $e) {
dc_flash($e->getMessage(), 'danger');
dc_redirect('#tab-tokens');
}
dc_flash(
$ok
? 'Token-Angaben gespeichert. Der Tokenwert selbst ist unveraendert — '
. 'auf den Maschinen muss nichts ausgetauscht werden.'
: 'Master-Token nicht gefunden.',
$ok ? 'success' : 'danger'
);
dc_redirect('#tab-tokens');
}
case 'revoke_core_token': {
$tokenId = trim((string)($_POST['token_id'] ?? ''));
if ($tokenId !== '') {
$affected = (new CoreTokenManager($pdo))->revokeToken($tokenId);
dc_flash(sprintf('%d Token (inkl. Sub-Tokens) widerrufen.', $affected));
}
dc_redirect('#tab-tokens');
}
case 'delete_core_token': {
$tokenId = trim((string)($_POST['token_id'] ?? ''));
if ($tokenId !== '') {
$affected = (new CoreTokenManager($pdo))->deleteToken($tokenId);
dc_flash(sprintf('%d Token (inkl. Sub-Tokens) dauerhaft geloescht.', $affected));
}
dc_redirect('#tab-tokens');
}
// -------------------------------------------------- Bugtracker
case 'bt_create_item': {
$title = trim((string)($_POST['title'] ?? ''));
if ($title === '') {
dc_flash('Der Titel darf nicht leer sein.', 'danger');
dc_redirect('#tab-bugtracker');
}
// Das Formular hat eine Pflichtauswahl ueber die echten Projekte;
// leer kommt hier nur an, wenn jemand am Formular vorbei postet.
// Frueher wurde daraus stillschweigend 'default' - ein Projekt,
// das es nicht gibt, mit project_id NULL und einem Item, das in
// keiner Ansicht mehr auftauchte.
$btZiel = trim((string)($_POST['project_slug'] ?? ''));
if ($btZiel === '') {
dc_flash('Bitte ein Projekt auswaehlen.', 'danger');
dc_redirect('#tab-bugtracker');
}
$bugRepo = new BugRepo($pdo);
if (!$bugRepo->projectExists($btZiel)) {
dc_flash(sprintf('Das Projekt "%s" gibt es nicht.', $btZiel), 'danger');
dc_redirect('#tab-bugtracker');
}
$result = $bugRepo->reportItem([
'project_slug' => $btZiel,
'type' => (string)($_POST['type'] ?? 'bug'),
'title' => $title,
'description' => trim((string)($_POST['description'] ?? '')),
'error_message' => trim((string)($_POST['error_message'] ?? '')),
'stack_trace' => trim((string)($_POST['stack_trace'] ?? '')),
'build_version' => trim((string)($_POST['build_version'] ?? '')),
'environment' => (string)($_POST['environment'] ?? 'production'),
'severity' => (string)($_POST['severity'] ?? 'medium'),
'push_id' => trim((string)($_POST['push_id'] ?? '')),
'target_agent' => trim((string)($_POST['target_agent'] ?? '')),
'tags' => trim((string)($_POST['tags'] ?? '')),
'repo_url' => trim((string)($_POST['repo_url'] ?? '')),
'file_path' => trim((string)($_POST['file_path'] ?? '')),
'created_by' => 'user:' . $actor,
]);
dc_flash($result['is_new']
? sprintf('Item #%d angelegt.', $result['id'])
: sprintf('Bereits bekannt - Item #%d, Zaehler jetzt bei %d.', $result['id'], $result['occurrence_count']));
dc_redirect('#tab-bugtracker');
}
case 'bt_add_comment': {
$itemId = (int)($_POST['item_id'] ?? 0);
$comment = trim((string)($_POST['comment'] ?? ''));
if ($itemId > 0 && $comment !== '') {
(new BugRepo($pdo))->addComment($itemId, 'user:' . $actor, $comment, 'commented');
dc_flash(sprintf('Kommentar zu Item #%d gespeichert.', $itemId));
} else {
dc_flash('Kommentar konnte nicht gespeichert werden.', 'danger');
}
dc_redirect('#tab-bugtracker');
}
case 'bt_change_status': {
$itemId = (int)($_POST['item_id'] ?? 0);
$status = (string)($_POST['status'] ?? 'open');
$ok = $itemId > 0 && (new BugRepo($pdo))->updateStatus(
$itemId,
$status,
trim((string)($_POST['notes'] ?? '')),
'user:' . $actor
);
dc_flash(
$ok ? sprintf('Status von Item #%d auf "%s" gesetzt.', $itemId, $status)
: 'Statuswechsel fehlgeschlagen - Item oder Status unbekannt.',
$ok ? 'success' : 'danger'
);
dc_redirect('#tab-bugtracker');
}
case 'bt_update_item': {
$itemId = (int)($_POST['item_id'] ?? 0);
$updates = [];
foreach (['status', 'severity', 'push_id', 'target_agent', 'tags', 'resolution_notes', 'resolved_in_build', 'repo_url', 'file_path'] as $field) {
if (array_key_exists($field, $_POST)) {
$updates[$field] = $_POST[$field];
}
}
try {
$ok = $itemId > 0 && (new BugRepo($pdo))->updateItemDetails($itemId, $updates, 'user:' . $actor);
} catch (\InvalidArgumentException $e) {
dc_flash($e->getMessage(), 'danger');
dc_redirect('#tab-bugtracker');
}
dc_flash(
$ok ? sprintf('Item #%d aktualisiert.', $itemId) : 'Item nicht gefunden.',
$ok ? 'success' : 'danger'
);
dc_redirect('#tab-bugtracker');
}
case 'bt_resolve': {
$itemId = (int)($_POST['item_id'] ?? 0);
$build = trim((string)($_POST['resolved_in_build'] ?? ''));
if ($build === '') {
dc_flash('Bitte den Build angeben, in dem das Item geloest wurde.', 'danger');
dc_redirect('#tab-bugtracker');
}
$ok = $itemId > 0 && (new BugRepo($pdo))->resolveItem(
$itemId,
$build,
trim((string)($_POST['resolution_notes'] ?? '')),
'user:' . $actor
);
dc_flash(
$ok ? sprintf('Item #%d in Build "%s" geloest.', $itemId, e($build)) : 'Item nicht gefunden.',
$ok ? 'success' : 'danger'
);
dc_redirect('#tab-bugtracker');
}
// ------------------------------------------------------ Roadmap
//
// Alle Roadmap-Aktionen schreiben in dieselben Item-Spalten wie
// der Bugtracker. Ein eigener Datenbestand existiert nicht - was
// hier gesetzt wird, ist Themenbereich, Reihenfolge, Zielfassung
// und der Verweis auf die Begruendung in der Datei.
case 'rm_set': {
$itemId = (int)($_POST['item_id'] ?? 0);
$updates = [];
foreach (['epic', 'target_build', 'defer_trigger', 'doc_anchor', 'status'] as $field) {
if (array_key_exists($field, $_POST)) {
$updates[$field] = $_POST[$field];
}
}
// Leeres Rangfeld heisst "keine Meinung", nicht "Rang 0" -
// sonst spraenge der Eintrag an die Spitze seines Bereichs.
if (array_key_exists('roadmap_rank', $_POST)) {
$raw = trim((string)$_POST['roadmap_rank']);
$updates['roadmap_rank'] = is_numeric($raw) ? (int)$raw : null;
}
try {
$ok = $itemId > 0
&& (new BugRepo($pdo))->updateItemDetails($itemId, $updates, 'user:' . $actor);
} catch (\InvalidArgumentException $e) {
dc_flash($e->getMessage(), 'danger');
dc_redirect('#tab-roadmap');
}
dc_flash(
$ok ? sprintf('Roadmap-Angaben zu #%d gespeichert.', $itemId) : 'Item nicht gefunden.',
$ok ? 'success' : 'danger'
);
dc_redirect('#tab-roadmap');
}
case 'rm_move': {
$itemId = (int)($_POST['item_id'] ?? 0);
$dir = ($_POST['direction'] ?? 'up') === 'down' ? 'down' : 'up';
$ok = $itemId > 0 && (new Roadmap($pdo))->move($itemId, $dir);
if (!$ok) {
dc_flash('Der Eintrag steht bereits am Rand seines Bereichs.', 'danger');
}
dc_redirect('#tab-roadmap');
}
case 'rm_save_epic': {
$slug = trim((string)($_POST['project_slug'] ?? ''));
$epic = trim((string)($_POST['epic'] ?? ''));
$title = trim((string)($_POST['title'] ?? ''));
if ($slug === '' || $epic === '' || $title === '') {
dc_flash('Projekt, Kuerzel und Titel werden alle drei gebraucht.', 'danger');
dc_redirect('#sub-roadmap-epics');
}
(new Roadmap($pdo))->saveEpic(
$slug,
$epic,
$title,
trim((string)($_POST['summary'] ?? '')) ?: null,
(int)($_POST['sort_order'] ?? 100)
);
dc_flash(sprintf('Themenbereich "%s" gespeichert.', e($epic)));
dc_redirect('#sub-roadmap-epics');
}
case 'rm_delete_epic': {
$slug = trim((string)($_POST['project_slug'] ?? ''));
$epic = trim((string)($_POST['epic'] ?? ''));
// Loeschen nur, solange nichts daran haengt. Sonst blieben
// Items mit einem Bereich zurueck, den es nicht mehr gibt -
// genau die haengende Referenz, die hier vermieden werden soll.
$ok = (new Roadmap($pdo))->deleteEpic($slug, $epic);
dc_flash(
$ok
? sprintf('Themenbereich "%s" geloescht.', e($epic))
: 'Der Bereich hat noch Eintraege - erst umhaengen, dann loeschen.',
$ok ? 'success' : 'danger'
);
dc_redirect('#sub-roadmap-epics');
}
// ------------------------------------------------ Ignore-Regeln
case 'ir_create': {
$rules = new IgnoreRules($pdo);
try {
$ruleId = $rules->create(
trim((string)($_POST['project_slug'] ?? '')),
(string)($_POST['match_type'] ?? 'contains'),
(string)($_POST['pattern'] ?? ''),
(string)($_POST['reason'] ?? ''),
(int)($_POST['alert_on_rate'] ?? 0),
'user:' . $actor
);
} catch (InvalidArgumentException $e) {
dc_flash('Regel nicht angelegt: ' . e($e->getMessage()), 'danger');
dc_redirect('#sub-bugtracker-ignore');
}
// Bereits erfasste, passende Fehler gleich mit stummschalten -
// sonst muesste man sie einzeln nachpflegen.
$affected = 0;
if (!empty($_POST['apply_retroactively'])) {
$affected = $rules->applyRetroactively($ruleId);
}
dc_flash(sprintf(
'Regel #%d angelegt.%s',
$ruleId,
$affected > 0 ? sprintf(' %d bestehende(s) Item(s) wurden stummgeschaltet.', $affected) : ''
));
dc_redirect('#sub-bugtracker-ignore');
}
case 'ir_toggle': {
$ruleId = (int)($_POST['rule_id'] ?? 0);
$enable = (int)($_POST['enable'] ?? 0) === 1;
if ($ruleId > 0 && (new IgnoreRules($pdo))->setEnabled($ruleId, $enable)) {
dc_flash(sprintf('Regel #%d %s.', $ruleId, $enable ? 'aktiviert' : 'deaktiviert'));
} else {
dc_flash('Regel nicht gefunden.', 'danger');
}
dc_redirect('#sub-bugtracker-ignore');
}
case 'ir_delete': {
$ruleId = (int)($_POST['rule_id'] ?? 0);
if ($ruleId > 0 && (new IgnoreRules($pdo))->delete($ruleId)) {
// Items behalten ihren Status, verlieren aber die Bindung.
$pdo->prepare('UPDATE bugtracker_items SET ignore_rule_id = NULL WHERE ignore_rule_id = :id')
->execute([':id' => $ruleId]);
dc_flash(sprintf('Regel #%d geloescht.', $ruleId));
} else {
dc_flash('Regel nicht gefunden.', 'danger');
}
dc_redirect('#sub-bugtracker-ignore');
}
case 'ir_apply': {
$ruleId = (int)($_POST['rule_id'] ?? 0);
$affected = $ruleId > 0 ? (new IgnoreRules($pdo))->applyRetroactively($ruleId) : 0;
dc_flash(sprintf('%d bestehende(s) Item(s) durch Regel #%d stummgeschaltet.', $affected, $ruleId));
dc_redirect('#sub-bugtracker-ignore');
}
// Einzelnes Item stummschalten oder wieder aufnehmen
case 'bt_set_ignored': {
$itemId = (int)($_POST['item_id'] ?? 0);
$ignore = (int)($_POST['ignore'] ?? 1) === 1;
if ($itemId > 0) {
$ok = (new BugRepo($pdo))->updateItemDetails(
$itemId,
['status' => $ignore ? 'ignored' : 'open'],
'user:' . $actor
);
dc_flash(
$ok
? sprintf('Item #%d %s.', $itemId, $ignore ? 'stummgeschaltet' : 'wieder aufgenommen')
: 'Item nicht gefunden.',
$ok ? 'success' : 'danger'
);
}
dc_redirect('#sub-bugtracker-errors');
}
case 'bt_release_claim': {
$itemId = (int)($_POST['item_id'] ?? 0);
if ($itemId > 0) {
$pdo->prepare('
UPDATE bugtracker_items
SET claimed_by = NULL, claimed_at = NULL, lease_until = NULL
WHERE id = :id
')->execute([':id' => $itemId]);
(new BugRepo($pdo))->addComment($itemId, 'user:' . $actor, 'Zuweisung durch Administrator aufgehoben.', 'released');
dc_flash(sprintf('Zuweisung von Item #%d aufgehoben.', $itemId));
}
dc_redirect('#tab-bugtracker');
}
case 'save_rocketchat_settings': {
$enabled = isset($_POST['rocketchat_enabled']) ? '1' : '0';
$url = trim((string)($_POST['rocketchat_url'] ?? ''));
$username = trim((string)($_POST['rocketchat_username'] ?? ''));
$password = (string)($_POST['rocketchat_password'] ?? '');
$statusChannel = trim((string)($_POST['rocketchat_status_channel'] ?? ''));
$alertChannel = trim((string)($_POST['rocketchat_alert_channel'] ?? ''));
$verifySsl = isset($_POST['rocketchat_verify_ssl']) ? '1' : '0';
$reportInterval = max(300, (int)($_POST['rocketchat_report_interval'] ?? 43200));
$settings = [
'rocketchat_enabled' => $enabled,
'rocketchat_url' => $url,
'rocketchat_username' => $username,
'rocketchat_password' => $password,
'rocketchat_status_channel' => $statusChannel,
'rocketchat_alert_channel' => $alertChannel,
'rocketchat_verify_ssl' => $verifySsl,
'rocketchat_report_interval' => (string)$reportInterval,
];
$stmt = $pdo->prepare("
INSERT INTO dc_settings (skey, svalue) VALUES (:k, :v)
ON DUPLICATE KEY UPDATE svalue = VALUES(svalue)
");
foreach ($settings as $k => $v) {
$stmt->execute([':k' => $k, ':v' => $v]);
}
$stmtCron = $pdo->prepare("
INSERT INTO watchdog_cron_jobs (name, interval_sec, enabled)
VALUES ('rocketchat_status_report', :interval, :enabled)
ON DUPLICATE KEY UPDATE interval_sec = VALUES(interval_sec), enabled = VALUES(enabled)
");
$stmtCron->execute([':interval' => $reportInterval, ':enabled' => (int)$enabled]);
dc_flash('Rocket.Chat Einstellungen erfolgreich gespeichert.', 'success');
dc_redirect('#sub-system-rocketchat');
}
case 'test_rocketchat_connection': {
$config = \Deploymentcenter\Modules\Notify\RocketChatNotifier::getConfig($pdo);
$res = \Deploymentcenter\Modules\Notify\RocketChatNotifier::testConnection($config);
dc_flash($res['message'], $res['success'] ? 'success' : 'danger');
dc_redirect('#sub-system-rocketchat');
}
case 'send_rocketchat_report_now': {
$res = \Deploymentcenter\Modules\Notify\RocketChatNotifier::sendStatusReport($pdo, true);
dc_flash($res['reason'], $res['sent'] ? 'success' : 'warning');
dc_redirect('#sub-system-rocketchat');
}
case 'toggle_release_guard_exempt': {
$slug = trim((string)($_POST['slug'] ?? ''));
$makeExempt = ($_POST['exempt'] ?? '') === '1';
if ($slug === '') {
dc_flash('Kein Produkt angegeben.', 'danger');
dc_redirect('#sub-update-guard');
}
ReleaseGuard::setExempt($pdo, $slug, $makeExempt);
if ($makeExempt) {
dc_flash(sprintf(
'Achtung: "%s" ist jetzt OHNE Zugangsschutz - jeder im Internet kann die Pakete '
. 'herunterladen. Nur so lange lassen, bis die ausgelieferten Installationen ihren '
. 'Lizenzschluessel mitschicken.',
e($slug)
), 'danger');
} else {
dc_flash(sprintf(
'Zugangsschutz fuer "%s" ist wieder aktiv. Installationen ohne Lizenzschluessel '
. 'bekommen ab jetzt 401.',
e($slug)
));
}
dc_redirect('#sub-update-guard');
}
case 'regenerate_release_guard': {
$guard = ReleaseGuard::regenerateAll($pdo);
// Direkt gegenpruefen, ob der Schutz tatsaechlich greift.
// Vorhandene Dateien beweisen nichts - unter Nginx werden sie
// ignoriert, und die Oberflaeche meldete trotzdem Erfolg.
$test = ReleaseGuard::selfTest($pdo);
if ($test['failed'] !== []) {
$reasons = array_map(
static fn(array $f): string => ($f['slug'] ?? '?') . ': ' . ($f['reason'] ?? ''),
$test['failed']
);
dc_flash(
'Zugangsschutz erzeugt, aber der Selbsttest schlaegt fehl! ' . e(implode(' | ', $reasons)),
'danger'
);
dc_redirect('#sub-update-guard');
}
if ($guard['errors'] !== []) {
dc_flash(
'Zugangsschutz teilweise erneuert. Fehler: ' . e(implode(' | ', $guard['errors'])),
'danger'
);
} else {
dc_flash(sprintf(
'Zugangsschutz erneuert und geprueft: %d Produkt(e), %d Zugang/Zugaenge, '
. '%d Adresse(n) antworten ohne Zugangsdaten korrekt mit 401.%s',
$guard['projects'],
$guard['entries'],
$test['ok'],
$guard['skipped'] !== []
? ' Ohne Zugaenge und damit gesperrt: ' . e(implode(', ', $guard['skipped'])) . '.'
: ''
));
}
dc_redirect('#sub-update-guard');
}
// ------------------------------------------------- Benutzerkonten
case 'add_user': {
$newUser = trim((string)($_POST['username'] ?? ''));
$newPass = (string)($_POST['password'] ?? '');
$newRole = ($_POST['role'] ?? 'installer') === 'admin' ? 'admin' : 'installer';
if (preg_match('/^[A-Za-z0-9._-]{3,64}$/', $newUser) !== 1) {
dc_flash('Der Benutzername muss 3 bis 64 Zeichen lang sein und darf Buchstaben, Ziffern, Punkt, Bindestrich und Unterstrich enthalten.', 'danger');
dc_redirect('#sub-system-users');
}
// Ein Installationskonto wird auf fremden Rechnern eingegeben.
// Ein kurzes Passwort waere dort schnell mitgelesen und liesse
// sich ebenso schnell durchprobieren.
if (strlen($newPass) < 12) {
dc_flash('Das Passwort muss mindestens 12 Zeichen lang sein.', 'danger');
dc_redirect('#sub-system-users');
}
$exists = $pdo->prepare('SELECT id FROM dc_users WHERE username = :u LIMIT 1');
$exists->execute([':u' => $newUser]);
if ($exists->fetch() !== false) {
dc_flash(sprintf('Der Benutzername "%s" ist bereits vergeben.', e($newUser)), 'danger');
dc_redirect('#sub-system-users');
}
$ins = $pdo->prepare('
INSERT INTO dc_users (username, password_hash, role, created_at)
VALUES (:u, :h, :r, UTC_TIMESTAMP())
');
$ins->execute([
':u' => $newUser,
':h' => password_hash($newPass, PASSWORD_DEFAULT),
':r' => $newRole,
]);
Logger::info('Benutzerkonto angelegt', [
'username' => $newUser,
'role' => $newRole,
'actor' => $actor,
]);
// Installationskonten stehen in jeder .htpasswd - ohne sie
// waere eine Erstinstallation nicht moeglich, weil es dabei
// noch keinen Lizenzschluessel gibt. Administratoren zaehlen
// dazu; die Bedingung stand hier noch aus der Zeit, als nur
// die Rolle 'installer' Zugang hatte, und liess ein frisches
// Administratorkonto bis zum naechsten turnusmaessigen Lauf
// vor verschlossener Ablage stehen.
dc_guard_releases($pdo);
dc_flash(sprintf(
'Konto "%s" mit der Rolle %s angelegt.%s',
e($newUser),
e($newRole),
$newRole === 'installer'
? ' Es kann sich nicht am WebUI anmelden, sondern nur Anwendungen einrichten.'
: ''
));
dc_redirect('#sub-system-users');
}
case 'set_user_password': {
$userId = (int)($_POST['user_id'] ?? 0);
$newPass = (string)($_POST['password'] ?? '');
if (strlen($newPass) < 12) {
dc_flash('Das Passwort muss mindestens 12 Zeichen lang sein.', 'danger');
dc_redirect('#sub-system-users');
}
$upd = $pdo->prepare('UPDATE dc_users SET password_hash = :h WHERE id = :id');
$upd->execute([':h' => password_hash($newPass, PASSWORD_DEFAULT), ':id' => $userId]);
Logger::info('Passwort geaendert', ['user_id' => $userId, 'actor' => $actor]);
// Der Hash steht auch in den .htpasswd-Dateien.
dc_guard_releases($pdo);
dc_flash($upd->rowCount() > 0 ? 'Passwort geaendert.' : 'Konto nicht gefunden.',
$upd->rowCount() > 0 ? 'success' : 'danger');
dc_redirect('#sub-system-users');
}
case 'rename_user': {
$userId = (int)($_POST['user_id'] ?? 0);
$newName = trim((string)($_POST['username'] ?? ''));
if (preg_match('/^[A-Za-z0-9._-]{3,64}$/', $newName) !== 1) {
dc_flash('Der Benutzername muss 3 bis 64 Zeichen lang sein und darf Buchstaben, Ziffern, Punkt, Bindestrich und Unterstrich enthalten.', 'danger');
dc_redirect('#sub-system-users');
}
$row = $pdo->prepare('SELECT id, username, role FROM dc_users WHERE id = :id LIMIT 1');
$row->execute([':id' => $userId]);
$target = $row->fetch();
if (!is_array($target)) {
dc_flash('Konto nicht gefunden.', 'danger');
dc_redirect('#sub-system-users');
}
if ($target['username'] === $newName) {
dc_flash('Der Name ist unveraendert.', 'danger');
dc_redirect('#sub-system-users');
}
$exists = $pdo->prepare('SELECT id FROM dc_users WHERE username = :u AND id <> :id LIMIT 1');
$exists->execute([':u' => $newName, ':id' => $userId]);
if ($exists->fetch() !== false) {
dc_flash(sprintf('Der Benutzername "%s" ist bereits vergeben.', e($newName)), 'danger');
dc_redirect('#sub-system-users');
}
$oldName = (string)$target['username'];
$upd = $pdo->prepare('UPDATE dc_users SET username = :u WHERE id = :id');
$upd->execute([':u' => $newName, ':id' => $userId]);
Logger::info('Benutzerkonto umbenannt', [
'user_id' => $userId,
'from' => $oldName,
'to' => $newName,
'actor' => $actor,
]);
// Registrierte Hardwarekeys haengen an der Konto-ID, nicht am
// Namen - sie ueberstehen die Umbenennung. Die Sperrzaehlung
// in dc_login_attempts haengt dagegen am Namen; ein unter dem
// alten Namen laufendes Sperrfenster wuerde also weiterlaufen
// und einen gleichnamigen kuenftigen Versuch treffen. Deshalb
// hier aufraeumen.
try {
$pdo->prepare('DELETE FROM dc_login_attempts WHERE username = :u AND success = 0')
->execute([':u' => $oldName]);
} catch (\Throwable $e) {
Logger::warning('Fehlversuche des alten Namens nicht entfernt', ['error' => $e->getMessage()]);
}
// Der Name steht auch in den .htpasswd-Dateien des Zugangsschutzes.
dc_guard_releases($pdo);
// Wer sich selbst umbenennt, traegt den alten Namen noch in
// der Sitzung. Ohne diese Zeile stuende oben rechts weiter
// der alte Name, und der naechste Protokolleintrag traege ihn
// als Urheber.
if ($userId === Auth::userId()) {
$_SESSION['dc_username'] = $newName;
}
dc_flash(sprintf('Konto "%s" heisst jetzt "%s".', e($oldName), e($newName)));
dc_redirect('#sub-system-users');
}
case 'issue_recovery_token': {
$userId = (int)($_POST['user_id'] ?? 0);
$row = $pdo->prepare('SELECT id, username FROM dc_users WHERE id = :id LIMIT 1');
$row->execute([':id' => $userId]);
$target = $row->fetch();
if (!is_array($target)) {
dc_flash('Konto nicht gefunden.', 'danger');
dc_redirect('#sub-system-users');
}
$token = (new CredentialRepo($pdo))->issueRecoveryToken($userId);
Logger::info('Notfalltoken erzeugt', [
'user_id' => $userId,
'actor' => $actor,
]);
// Ueber die Sitzung und nicht ueber die Meldung: Der Klartext
// soll einmal gross und lesbar dastehen, nicht in einer Zeile
// verschwinden, die nach drei Sekunden weggeklickt ist.
$_SESSION['dc_fresh_recovery_token'] = [
'username' => (string)$target['username'],
'token' => $token,
];
dc_redirect('#sub-system-users');
}
case 'save_webauthn_policy': {
$policy = new HardwarePolicy($pdo);
$wanted = (string)($_POST['policy_level'] ?? HardwarePolicy::OFF);
$before = $policy->level();
if (!in_array($wanted, HardwarePolicy::LEVELS, true)) {
dc_flash('Unbekannte Stufe.', 'danger');
dc_redirect('#sub-system-users');
}
// Die Modellliste zuerst: Sie kann unbrauchbar sein, und dann
// soll die Stufe nicht schon umgestellt sein. Andersherum
// haette man eine halb angewandte Regel - die Stufe neu, die
// Liste alt -, und niemand wuesste, welcher Teil gilt.
try {
$policy->setAllowlist((string)($_POST['policy_aaguids'] ?? ''));
} catch (InvalidArgumentException $e) {
dc_flash($e->getMessage(), 'danger');
dc_redirect('#sub-system-users');
}
$blocked = $policy->blockingReason($wanted, Auth::userId());
if ($blocked !== null) {
dc_flash($blocked, 'danger');
dc_redirect('#sub-system-users');
}
$policy->setLevel($wanted);
// Eine geaenderte Anmelderegel gehoert ins
// Sicherheitsprotokoll, nicht nur ins Anwendungslog: Sie
// beantwortet spaeter die Frage, seit wann ein Konto mit
// welchem Anspruch geschuetzt war.
SecurityLogger::log('webauthn_policy_changed', Http::clientIp(), [
'username' => $actor,
'from' => $before,
'to' => $wanted,
]);
dc_flash(sprintf('Hardwarekey-Regel steht jetzt auf "%s".', $wanted));
dc_redirect('#sub-system-users');
}
case 'delete_credential': {
$userId = (int)($_POST['user_id'] ?? 0);
$credentialId = (int)($_POST['credential_id'] ?? 0);
$repo = new CredentialRepo($pdo);
// Den letzten Schluessel eines Kontos zu entfernen ist
// erlaubt - es ist der Weg zurueck, wenn der Key kaputt ist.
// Aber es schaltet die zweite Stufe fuer dieses Konto ab, und
// das soll niemand versehentlich tun, ohne es zu lesen.
$remaining = max(0, $repo->countForUser($userId) - 1);
if ($repo->delete($userId, $credentialId)) {
Logger::info('Hardwarekey entfernt', [
'user_id' => $userId,
'actor' => $actor,
]);
dc_flash($remaining === 0
? 'Hardwarekey entfernt. Fuer dieses Konto ist damit kein zweiter Faktor mehr hinterlegt - die Anmeldung laeuft wieder nur ueber das Passwort.'
: sprintf('Hardwarekey entfernt. Es sind noch %d hinterlegt.', $remaining));
} else {
dc_flash('Hardwarekey nicht gefunden.', 'danger');
}
dc_redirect('#sub-system-users');
}
case 'toggle_user_disabled': {
$userId = (int)($_POST['user_id'] ?? 0);
$row = $pdo->prepare('SELECT id, username, role, disabled FROM dc_users WHERE id = :id LIMIT 1');
$row->execute([':id' => $userId]);
$target = $row->fetch();
if (!is_array($target)) {
dc_flash('Konto nicht gefunden.', 'danger');
dc_redirect('#sub-system-users');
}
$disable = (int)$target['disabled'] === 0;
// Ohne diese Sperre liesse sich das letzte handlungsfaehige
// Administratorkonto abschalten - danach kaeme niemand mehr an
// die Oberflaeche, auch nicht, um es rueckgaengig zu machen.
if ($disable && $target['role'] === 'admin' && dc_active_admin_count($pdo) <= 1) {
dc_flash('Das letzte aktive Administratorkonto kann nicht deaktiviert werden.', 'danger');
dc_redirect('#sub-system-users');
}
$upd = $pdo->prepare('UPDATE dc_users SET disabled = :d WHERE id = :id');
$upd->execute([':d' => $disable ? 1 : 0, ':id' => $userId]);
Logger::info('Konto ' . ($disable ? 'deaktiviert' : 'aktiviert'), [
'username' => $target['username'],
'actor' => $actor,
]);
dc_guard_releases($pdo);
dc_flash(sprintf('Konto "%s" wurde %s.', e((string)$target['username']),
$disable ? 'deaktiviert' : 'wieder aktiviert'));
dc_redirect('#sub-system-users');
}
case 'delete_user': {
$userId = (int)($_POST['user_id'] ?? 0);
$row = $pdo->prepare('SELECT id, username, role FROM dc_users WHERE id = :id LIMIT 1');
$row->execute([':id' => $userId]);
$target = $row->fetch();
if (!is_array($target)) {
dc_flash('Konto nicht gefunden.', 'danger');
dc_redirect('#sub-system-users');
}
if ($userId === Auth::userId()) {
dc_flash('Das eigene Konto kann nicht geloescht werden.', 'danger');
dc_redirect('#sub-system-users');
}
if ($target['role'] === 'admin' && dc_active_admin_count($pdo) <= 1) {
dc_flash('Das letzte Administratorkonto kann nicht geloescht werden.', 'danger');
dc_redirect('#sub-system-users');
}
// Erst die Schluessel, dann das Konto. Bliebe eine Zeile in
// dc_user_credentials stehen, waere der betroffene Hardwarekey
// danach unbrauchbar: credential_id ist geraeteweit eindeutig,
// und die verwaiste Zeile haelt sie besetzt.
try {
$pdo->prepare('DELETE FROM dc_user_credentials WHERE user_id = :id')
->execute([':id' => $userId]);
} catch (\Throwable $e) {
Logger::warning('Hardwarekeys des geloeschten Kontos nicht entfernt', ['error' => $e->getMessage()]);
}
$del = $pdo->prepare('DELETE FROM dc_users WHERE id = :id');
$del->execute([':id' => $userId]);
Logger::info('Benutzerkonto geloescht', [
'username' => $target['username'],
'actor' => $actor,
]);
dc_guard_releases($pdo);
dc_flash(sprintf('Konto "%s" wurde geloescht.', e((string)$target['username'])));
dc_redirect('#sub-system-users');
}
case 'unlock_user': {
$userId = (int)($_POST['user_id'] ?? 0);
if (Auth::unlockAccount($pdo, $userId)) {
dc_flash('Benutzerkonto wurde erfolgreich entsperrt.');
} else {
dc_flash('Konto konnte nicht entsperrt werden oder existiert nicht.', 'danger');
}
dc_redirect('#sub-system-users');
}
default:
dc_flash('Unbekannte Aktion.', 'danger');
dc_redirect();
}
} catch (Throwable $e) {
Logger::exception($e);
dc_flash(
'Die Aktion ist fehlgeschlagen: ' . e($e->getMessage()),
'danger'
);
dc_redirect();
}
}
/**
* Nimmt ein hochgeladenes Icon entgegen und liefert den relativen Pfad.
* Prueft den tatsaechlichen Dateityp, nicht nur die Endung.
*/
function dc_storeIconUpload(array $file): ?string
{
$maxBytes = 512 * 1024;
if (($file['size'] ?? 0) <= 0 || $file['size'] > $maxBytes) {
return null;
}
$extension = strtolower(pathinfo((string)$file['name'], PATHINFO_EXTENSION));
$allowed = ['svg' => 'image/svg+xml', 'png' => 'image/png', 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'webp' => 'image/webp'];
if (!isset($allowed[$extension])) {
return null;
}
// SVG kann Skripte enthalten und wird deshalb nur akzeptiert, wenn keine
// ausfuehrbaren Bestandteile enthalten sind.
if ($extension === 'svg') {
$content = (string)file_get_contents((string)$file['tmp_name']);
if (preg_match('/<\s*script|javascript:|on\w+\s*=/i', $content) === 1) {
Logger::warning('SVG-Upload mit Skriptanteil abgelehnt');
return null;
}
} else {
$info = @getimagesize((string)$file['tmp_name']);
if ($info === false) {
return null;
}
}
$directory = __DIR__ . '/assets/icons/custom';
if (!is_dir($directory) && !@mkdir($directory, 0755, true) && !is_dir($directory)) {
return null;
}
$safeName = preg_replace('/[^a-z0-9]/', '', strtolower(pathinfo((string)$file['name'], PATHINFO_FILENAME))) ?? 'icon';
$filename = 'icon_' . time() . '_' . substr($safeName, 0, 32) . '.' . $extension;
if (!move_uploaded_file((string)$file['tmp_name'], $directory . '/' . $filename)) {
return null;
}
return 'custom/' . $filename;
}
// ======================================================================
// Daten fuer die Darstellung
// ======================================================================
Auth::startSession();
$flash = $_SESSION['dc_flash'] ?? null;
unset($_SESSION['dc_flash']);
$msg = is_array($flash) ? (string)$flash['message'] : null;
$msgType = is_array($flash) && ($flash['type'] ?? '') === 'danger' ? 'danger' : 'success';
$projects = $pdo->query('SELECT * FROM dc_projects ORDER BY name ASC')->fetchAll() ?: [];
$licenses = $pdo->query('
SELECT l.*, p.name AS product_name, p.slug AS product_slug,
(SELECT COUNT(*) FROM license_activations a WHERE a.license_id = l.id) AS active_count
FROM license_licenses l
JOIN dc_projects p ON l.product_id = p.id
ORDER BY l.created_at DESC
LIMIT 500
')->fetchAll() ?: [];
$activations = $pdo->query('
SELECT a.*, l.license_key, p.slug AS product_slug, p.name AS product_name
FROM license_activations a
JOIN license_licenses l ON a.license_id = l.id
JOIN dc_projects p ON l.product_id = p.id
ORDER BY a.last_seen DESC
LIMIT 500
')->fetchAll() ?: [];
$auditLogs = $pdo->query('SELECT * FROM license_audit_log ORDER BY created_at DESC LIMIT 100')->fetchAll() ?: [];
$monitorRepo = new MonitorRepo($pdo);
$monitors = $monitorRepo->getAllMonitors();
$tokenManager = new TokenManager($pdo);
$agentTokens = $tokenManager->getAllTokens();
$tokensBySource = [];
foreach ($agentTokens as $token) {
if (empty($token['revoked']) && !empty($token['monitor_source'])) {
$tokensBySource[$token['monitor_source']] = $token;
}
}
/**
* Bringt die Monitore in Baumreihenfolge.
* Erkennt Zyklen, damit ein fehlerhaft gesetzter Parent keine Endlosschleife
* und keine doppelten Zeilen erzeugt.
*
* @param list<array<string,mixed>> $monitors
* @return list<array<string,mixed>>
*/
function buildMonitorTree(array $monitors): array
{
$childrenOf = [];
$bySource = [];
foreach ($monitors as $monitor) {
$source = (string)$monitor['source'];
$bySource[$source] = $monitor;
$parent = !empty($monitor['parent_source']) ? (string)$monitor['parent_source'] : '__ROOT__';
if ($parent === $source) {
$parent = '__ROOT__'; // Selbstreferenz aufloesen
}
$childrenOf[$parent][] = $source;
}
$flat = [];
$visited = [];
$walk = static function (string $parentSource, int $depth) use (&$walk, &$flat, &$visited, $childrenOf, $bySource): void {
if ($depth > 5 || empty($childrenOf[$parentSource])) {
return;
}
$nodes = $childrenOf[$parentSource];
$count = count($nodes);
foreach ($nodes as $index => $source) {
if (!isset($bySource[$source]) || isset($visited[$source])) {
continue;
}
$visited[$source] = true;
$node = $bySource[$source];
$node['depth'] = $depth;
$node['is_last'] = ($index === $count - 1);
$flat[] = $node;
$walk($source, $depth + 1);
}
};
$walk('__ROOT__', 0);
// Monitore, deren Parent nicht existiert oder Teil eines Zyklus ist,
// erscheinen auf oberster Ebene statt zu verschwinden.
foreach ($monitors as $monitor) {
if (!isset($visited[(string)$monitor['source']])) {
$monitor['depth'] = 0;
$monitor['is_last'] = true;
$flat[] = $monitor;
}
}
return $flat;
}
$hierarchicalMonitors = buildMonitorTree($monitors);
$groupStats = [];
$monitorsUp = 0;
$monitorsWarning = 0;
$monitorsDown = 0;
foreach ($monitors as $monitor) {
$state = (string)$monitor['state'];
if ($state === 'up') {
$monitorsUp++;
} elseif ($state === 'warning' || $state === 'unknown') {
$monitorsWarning++;
} elseif ($state !== 'maintenance' && $state !== 'stopped') {
$monitorsDown++;
}
$groupKey = !empty($monitor['group_key']) ? (string)$monitor['group_key'] : 'Default';
if (!isset($groupStats[$groupKey])) {
$groupStats[$groupKey] = ['total' => 0, 'ok' => 0];
}
$groupStats[$groupKey]['total']++;
if ($state === 'up') {
$groupStats[$groupKey]['ok']++;
}
}
/** Ermittelt das Icon eines Monitors. */
function getMonitorIconUrl(?string $icon, ?string $source, ?string $os, ?string $type): string
{
if (!empty($icon) && $icon !== 'auto') {
if (str_starts_with($icon, 'custom/')) {
return 'assets/icons/' . $icon;
}
if (str_starts_with($icon, 'assets/icons/')) {
return $icon;
}
// Fruehere Fassungen legten die Symbole flach unter assets/ ab und
// schrieben diesen Pfad in die Datenbank. Die Dateien liegen jetzt nur
// noch unter assets/icons/ - Altwerte werden hier umgesetzt.
if (str_starts_with($icon, 'assets/')) {
return 'assets/icons/' . substr($icon, strlen('assets/'));
}
return 'assets/icons/' . $icon;
}
$haystack = strtolower(($source ?? '') . ' ' . ($os ?? '') . ' ' . ($type ?? ''));
$rules = [
'proxmox' => ['proxmox', 'pve'],
'windows' => ['win'],
'linux' => ['linux', 'ubuntu', 'debian'],
'mysql' => ['mysql', 'mariadb'],
'docker' => ['docker'],
'nginx' => ['nginx'],
'redis' => ['redis'],
'python' => ['python'],
'php' => ['php'],
'node' => ['node'],
];
foreach ($rules as $iconName => $needles) {
foreach ($needles as $needle) {
if (str_contains($haystack, $needle)) {
return 'assets/icons/' . $iconName . '.svg';
}
}
}
return 'assets/icons/server.svg';
}
$eventLog = new EventLog($pdo);
$recentEvents = $eventLog->getRecentEvents(100);
$updateManager = new UpdateManager($pdo);
$releases = $updateManager->getReleases();
// Benutzerkonten fuer die Verwaltung. Der Passwort-Hash wird bewusst nicht
// mitgelesen - er hat in der Ansicht nichts verloren.
try {
$users = $pdo->query('
SELECT id, username, role, disabled, failed_login_count, locked_until, last_login_at, created_at
FROM dc_users ORDER BY role ASC, username ASC
')->fetchAll() ?: [];
} catch (Throwable $e) {
try {
$users = $pdo->query('
SELECT id, username, role, disabled, last_login_at, created_at
FROM dc_users ORDER BY role ASC, username ASC
')->fetchAll() ?: [];
} catch (Throwable $e2) {
// Vor Migration 012 fehlen role, disabled und last_login_at.
$users = $pdo->query('SELECT id, username, created_at FROM dc_users ORDER BY username ASC')->fetchAll() ?: [];
}
}
$usersSchemaReady = $users === [] || array_key_exists('role', $users[0]);
// Zweiter Faktor je Konto: wie viele Hardwarekeys hinterlegt sind und wie es
// um den Notfalltoken steht. In einer Abfrage und nicht je Zeile - es sind
// zwar nie viele Konten, aber eine Schleife mit zwei Abfragen pro Durchgang
// gewoehnt man sich besser gar nicht erst an.
$credentialRepo = new CredentialRepo($pdo);
$credentialsByUser = [];
$recoveryByUser = [];
$twoFactorReady = true;
try {
$rows = $pdo->query('
SELECT user_id, COUNT(*) AS keys_count
FROM dc_user_credentials GROUP BY user_id
')->fetchAll() ?: [];
foreach ($rows as $row) {
$credentialsByUser[(int)$row['user_id']] = (int)$row['keys_count'];
}
$rows = $pdo->query('
SELECT id, recovery_hash IS NOT NULL AS has_token, recovery_created_at, recovery_used_at
FROM dc_users
')->fetchAll() ?: [];
foreach ($rows as $row) {
$recoveryByUser[(int)$row['id']] = [
'present' => (int)$row['has_token'] === 1,
'created_at' => $row['recovery_created_at'] ?? null,
'used_at' => $row['recovery_used_at'] ?? null,
];
}
} catch (Throwable $e) {
// Vor Migration 019 gibt es weder Tabelle noch Spalten. Die Oberflaeche
// zeigt dann einen Hinweis statt leerer Spalten - "keine Schluessel" und
// "Migration fehlt" sehen sonst gleich aus.
$twoFactorReady = false;
}
// Die Schluessel des eigenen Kontos - nur die lassen sich hier verwalten.
$myCredentials = [];
$myRecovery = ['present' => false, 'created_at' => null, 'used_at' => null];
if ($twoFactorReady) {
try {
$myCredentials = $credentialRepo->forUser(Auth::userId());
$myRecovery = $credentialRepo->recoveryState(Auth::userId());
} catch (Throwable $e) {
$twoFactorReady = false;
}
}
// Die Hardwarekey-Regel. Steht die Migration 021 noch aus, bleibt alles auf
// den Vorgaben - "off" ist dasselbe Verhalten wie vor dieser Funktion, und
// eine Oberflaeche, die eine Regel zum Einschalten anbietet, die die
// Datenbank nicht tragen kann, waere ein Versprechen ohne Deckung.
$hardwarePolicy = new HardwarePolicy($pdo);
$policyLevel = HardwarePolicy::OFF;
$policyAllowlist = '';
$policyRootCount = 0;
$myAttestedCount = 0;
$attestationReady = false;
if ($twoFactorReady) {
try {
$policyLevel = $hardwarePolicy->level();
$policyAllowlist = $hardwarePolicy->allowlistText();
$policyRootCount = count($hardwarePolicy->roots());
$myAttestedCount = $credentialRepo->countAttestedForUser(Auth::userId());
$attestationReady = true;
} catch (Throwable $e) {
$attestationReady = false;
}
}
// Einmalige Anzeigen, die die Sitzung von der vorigen Anfrage mitbringt.
$freshRecoveryToken = $_SESSION['dc_fresh_recovery_token'] ?? null;
unset($_SESSION['dc_fresh_recovery_token']);
// Zustand des Zugangsschutzes der Release-Verzeichnisse.
$releaseGuard = ReleaseGuard::status($pdo);
// Ergebnis des letzten Selbsttests. Bewusst nur gelesen: ein HTTP-Abruf je
// Produkt bei jedem Seitenaufbau waere Verschwendung.
$releaseGuardTest = ReleaseGuard::lastSelfTest($pdo);
// Installer-Downloads. Die Binaries liegen unter /installer/ im Webroot und
// werden nicht von deploy.py uebertragen, sondern von
// scripts/upload_installer.py - der Quelltext des Agenten gehoert nicht auf
// den Webserver, die uebersetzten Dateien schon.
$installerManifest = null;
$installerPath = dirname(__DIR__) . '/installer/installer.json';
if (is_file($installerPath)) {
$raw = (string)file_get_contents($installerPath);
// Ein vorangestelltes BOM laesst json_decode() scheitern. Windows-Werkzeuge
// schreiben es gern mit; die Datei ist deswegen nicht kaputt.
$raw = preg_replace('/^\xEF\xBB\xBF/', '', $raw) ?? $raw;
$decoded = json_decode($raw, true);
if (is_array($decoded) && isset($decoded['binaries']) && is_array($decoded['binaries'])) {
$installerManifest = $decoded;
}
}
$coreTokenManager = new CoreTokenManager($pdo);
$coreMasterTokens = $coreTokenManager->getAllMasterTokens();
$coreAllTokens = $coreTokenManager->getAllTokens();
// ----------------------------------------------------------------------
// Bugtracker: serverseitige Filterung und Pagination
// ----------------------------------------------------------------------
$btPerPage = 50;
$btPage = max(1, (int)($_GET['bt_page'] ?? 1));
/**
* Status, die beim ersten Aufruf angehakt sind.
*
* Alles, was gerade laeuft oder Aufmerksamkeit verlangt. Erledigtes,
* Geschlossenes und Abgelehntes bleibt draussen - es ist der weitaus groesste
* Teil des Bestandes und verdeckt sonst das, was noch zu tun ist.
*
* "deferred" fehlt mit Absicht: Der ganze Sinn dieses Status ist "nicht jetzt".
* Ein zurueckgestelltes Vorhaben in der Standardliste waere ein Widerspruch in
* sich - es steht unter "Zurueckgestellt" bereit, wenn jemand danach sucht.
*/
const BT_STATUS_VORGABE = [
'open', 'planned', 'in_progress', 'triage_external',
'awaiting_verification', 'in_verification', 'fix_insufficient',
];
/**
* Liest die angehakten Status aus der Anfrage.
*
* Drei Faelle, und der mittlere ist der Grund fuer das versteckte Feld im
* Formular:
*
* Parameter fehlt ganz -> erster Aufruf, es gilt die Vorgabe
* Parameter ist leer -> alle Haken entfernt, also KEINE Einschraenkung
* Parameter hat Werte -> genau diese Status
*
* Ohne den zweiten Fall waere "alle Haken weg" von "noch nie etwas gewaehlt"
* nicht zu unterscheiden - HTML sendet fuer leere Ankreuzfelder nichts. Die
* Vorgabe kaeme dann bei jedem Versuch zurueck, sie loszuwerden.
*
* Zeichenketten werden weiterhin angenommen: Aeltere Lesezeichen und die
* Verweise aus den Kennzahlen kommen als bt_status=open,in_progress herein.
*
* @return list<string> Leer bedeutet "keine Einschraenkung".
*/
function btStatusAuswahl(): array
{
if (!array_key_exists('bt_status', $_GET)) {
return BT_STATUS_VORGABE;
}
$roh = $_GET['bt_status'];
$werte = is_array($roh) ? $roh : explode(',', (string)$roh);
$werte = array_map(static fn($v): string => trim((string)$v), $werte);
$werte = array_values(array_filter(
$werte,
static fn(string $v): bool => in_array($v, BugRepo::STATUSES, true)
));
// "all" als einzelner Wert ist die alte Schreibweise fuer "ohne Filter".
if (!is_array($roh) && trim((string)$roh) === 'all') {
return [];
}
return array_values(array_unique($werte));
}
$btStatusAuswahl = btStatusAuswahl();
/**
* Roadmap-Eintraege gehoeren in die Roadmap, nicht in die Bugliste.
*
* Seit 2.7.0 IST ein Roadmap-Eintrag ein Bugtracker-Item - es gibt keine
* zweite Ablage, und ein Item steht auf der Roadmap, sobald es einen
* Themenbereich hat. Richtig so; nur landet damit die gesamte Planung aller
* Projekte in derselben Liste wie die Meldungen. Nach der Uebernahme der
* Projekt-Roadmaps waren das 217 von 229 Feature Requests - die zwoelf
* Vorschlaege, um die es in dieser Liste geht, lagen darunter begraben.
*
* Die Bugliste zeigt deshalb ab Werk nur, was NICHT auf der Roadmap steht.
* Sichtbar bleibt beides, nur an getrennten Orten - und der Filter holt die
* Planung jederzeit wieder herein.
*/
$btRoadmap = (string)($_GET['bt_roadmap'] ?? 'ohne');
if (!in_array($btRoadmap, ['ohne', 'nur', 'alle'], true)) {
$btRoadmap = 'ohne';
}
$btFilters = [
'project_slug' => (string)($_GET['bt_project'] ?? 'all'),
'environment' => (string)($_GET['bt_env'] ?? 'all'),
'type' => (string)($_GET['bt_type'] ?? 'all'),
'severity' => (string)($_GET['bt_severity'] ?? 'all'),
'status' => $btStatusAuswahl === [] ? 'all' : $btStatusAuswahl,
'search' => (string)($_GET['bt_q'] ?? ''),
'order' => (string)($_GET['bt_order'] ?? 'newest'),
'limit' => $btPerPage,
'offset' => ($btPage - 1) * $btPerPage,
];
if ($btRoadmap !== 'alle') {
$btFilters['on_roadmap'] = ($btRoadmap === 'nur');
}
$bugRepo = new BugRepo($pdo);
$bugtrackerResult = $bugRepo->getItems($btFilters);
$bugtrackerItems = $bugtrackerResult['items'];
$bugtrackerTotal = $bugtrackerResult['total'];
$bugtrackerPages = max(1, (int)ceil($bugtrackerTotal / $btPerPage));
$bugtrackerStats = $bugRepo->getStats(
$btFilters['project_slug'] !== 'all' ? $btFilters['project_slug'] : null
);
/**
* Wie viele Treffer der Statusfilter gerade verschluckt.
*
* Der Fall, um den es geht: Jemand sucht nach "288", bekommt nichts und
* schliesst daraus, dass es den Eintrag nicht gibt - dabei ist er nur
* erledigt und deshalb ausgeblendet. Eine Suche, die schweigt, obwohl sie
* etwas gefunden hat, ist schlimmer als gar keine Suche.
*
* Die zusaetzliche Abfrage laeuft nur bei aktiver Suche UND aktivem
* Statusfilter - also genau dann, wenn die Verwechslung moeglich ist. Sie
* holt eine einzige Zeile; gebraucht wird nur die Gesamtzahl.
*/
$btVerdeckt = 0;
if ($btFilters['search'] !== '' && ($btStatusAuswahl !== [] || $btRoadmap !== 'alle')) {
$ohneEinschraenkung = $btFilters;
unset($ohneEinschraenkung['on_roadmap']);
$ohneEinschraenkung['status'] = 'all';
$ohneEinschraenkung['limit'] = 1;
$ohneEinschraenkung['offset'] = 0;
$btVerdeckt = max(0, $bugRepo->getItems($ohneEinschraenkung)['total'] - $bugtrackerTotal);
}
/** Baut eine URL mit den aktuellen Bugtracker-Filtern und einzelnen Ueberschreibungen. */
function btUrl(array $overrides = []): string
{
$params = [
'bt_project' => $_GET['bt_project'] ?? null,
'bt_env' => $_GET['bt_env'] ?? null,
'bt_type' => $_GET['bt_type'] ?? null,
'bt_severity' => $_GET['bt_severity'] ?? null,
'bt_status' => $_GET['bt_status'] ?? null,
'bt_roadmap' => $_GET['bt_roadmap'] ?? null,
'bt_q' => $_GET['bt_q'] ?? null,
'bt_order' => $_GET['bt_order'] ?? null,
'bt_page' => $_GET['bt_page'] ?? null,
];
$params = array_merge($params, $overrides);
$params = array_filter($params, static fn($v): bool => $v !== null && $v !== '' && $v !== 'all');
return 'index.php' . ($params !== [] ? '?' . http_build_query($params) : '') . '#tab-bugtracker';
}
// ----------------------------------------------------------------------
// Fehler-Stream
// ----------------------------------------------------------------------
$errPerPage = 40;
$errPage = max(1, (int)($_GET['err_page'] ?? 1));
$errShow = (string)($_GET['err_show'] ?? 'active'); // active | ignored | all
$errFilters = [
'project_slug' => (string)($_GET['err_project'] ?? 'all'),
'environment' => (string)($_GET['err_env'] ?? 'all'),
'error_level' => (string)($_GET['err_level'] ?? 'all'),
'search' => (string)($_GET['err_q'] ?? ''),
'since_hours' => (int)($_GET['err_hours'] ?? 0),
'errors_only' => true,
'order' => 'newest',
'limit' => $errPerPage,
'offset' => ($errPage - 1) * $errPerPage,
];
if ($errShow === 'ignored') {
$errFilters['only_ignored'] = true;
} elseif ($errShow === 'all') {
$errFilters['include_ignored'] = true;
}
$errorResult = $bugRepo->getItems($errFilters);
$errorItems = $errorResult['items'];
$errorTotal = $errorResult['total'];
$errorPages = max(1, (int)ceil($errorTotal / $errPerPage));
$ignoreRuleRepo = new IgnoreRules($pdo);
$ignoreRules = $ignoreRuleRepo->all();
/** Baut eine URL mit den aktuellen Fehler-Filtern. */
function errUrl(array $overrides = []): string
{
$params = [
'err_project' => $_GET['err_project'] ?? null,
'err_env' => $_GET['err_env'] ?? null,
'err_level' => $_GET['err_level'] ?? null,
'err_show' => $_GET['err_show'] ?? null,
'err_hours' => $_GET['err_hours'] ?? null,
'err_q' => $_GET['err_q'] ?? null,
'err_page' => $_GET['err_page'] ?? null,
];
$params = array_merge($params, $overrides);
$params = array_filter($params, static fn($v): bool => $v !== null && $v !== '' && $v !== 'all' && $v !== '0');
return 'index.php' . ($params !== [] ? '?' . http_build_query($params) : '') . '#sub-bugtracker-errors';
}
// ----------------------------------------------------------------------
// Roadmap
// ----------------------------------------------------------------------
// Kein eigener Datenbestand: Ein Roadmap-Eintrag ist ein bugtracker_item mit
// gesetztem Themenbereich. Diese Ansicht ist eine Sicht darauf, kein zweites
// System - deshalb liest sie dieselbe getItems()-Abfrage wie der Bugtracker,
// nur mit anderer Sortierung und einem zusaetzlichen Filter.
$roadmapRepo = new Roadmap($pdo);
$rmProject = (string)($_GET['rm_project'] ?? 'all');
$rmEpic = (string)($_GET['rm_epic'] ?? 'all');
$rmLage = (string)($_GET['rm_lage'] ?? 'aktiv');
$rmAgent = (string)($_GET['rm_agent'] ?? '');
$rmBuild = (string)($_GET['rm_build'] ?? 'all');
$rmSearch = (string)($_GET['rm_q'] ?? '');
/**
* Uebersetzt die gewaehlte Lage in Status.
*
* "aktiv" ist die Voreinstellung und nicht "alle": Eine Roadmap, die beim
* Aufschlagen mit sechzig erledigten Eintraegen beginnt, beantwortet die
* Frage nicht, fuer die man sie aufschlaegt.
*/
$rmLageStatuses = [
'aktiv' => Roadmap::PENDING_STATUSES,
'offen' => Roadmap::STATUS_GROUPS['offen'],
'arbeit' => Roadmap::STATUS_GROUPS['arbeit'],
'erledigt' => Roadmap::STATUS_GROUPS['erledigt'],
'zurueckgestellt' => Roadmap::STATUS_GROUPS['zurueckgestellt'],
'verworfen' => Roadmap::STATUS_GROUPS['verworfen'],
];
$rmFilters = [
'project_slug' => $rmProject,
'on_roadmap' => true,
'epic' => $rmEpic,
'target_build' => $rmBuild,
'claimed_by' => $rmAgent,
'search' => $rmSearch,
'order' => 'roadmap',
'limit' => 400,
];
if ($rmLage !== 'all' && isset($rmLageStatuses[$rmLage])) {
$rmFilters['status'] = implode(',', $rmLageStatuses[$rmLage]);
}
$roadmapResult = $bugRepo->getItems($rmFilters);
$roadmapItems = $roadmapResult['items'];
$roadmapTotal = $roadmapResult['total'];
$roadmapStats = $roadmapRepo->stats($rmProject);
$roadmapEpics = $roadmapRepo->epics($rmProject);
$roadmapOrphans = $roadmapRepo->orphanEpics($rmProject);
$roadmapNext = $roadmapRepo->nextUp($rmProject, 6);
$roadmapActive = $roadmapRepo->activeWork($rmProject);
// Bereichstitel als Nachschlagewerk, damit die Liste nicht je Zeile eine
// weitere Abfrage braucht.
$roadmapEpicTitles = [];
foreach ($roadmapEpics as $rmE) {
$roadmapEpicTitles[$rmE['project_slug'] . '/' . $rmE['epic']] = (string)$rmE['title'];
}
// Zielfassungen, die tatsaechlich vergeben sind - eine Auswahlliste aus
// vorhandenen Werten statt eines Freitextfelds, in das sich Tippfehler
// schleichen.
$roadmapBuilds = $pdo->query('
SELECT DISTINCT target_build FROM bugtracker_items
WHERE target_build IS NOT NULL AND target_build <> ""
AND epic IS NOT NULL AND epic <> ""
ORDER BY target_build DESC LIMIT 50
')->fetchAll(PDO::FETCH_COLUMN) ?: [];
// Eintraege, die noch keinen Bereich haben und deshalb nicht auf der Roadmap
// stehen. Die Aufnahmeliste: Der Weg vom Backlog auf die Planung ist genau
// eine Angabe, und dies ist die Stelle, an der man ihn sieht.
$roadmapCandidates = $bugRepo->getItems([
'project_slug' => $rmProject,
'on_roadmap' => false,
'type' => 'feature_request',
'status' => 'open,planned',
'order' => 'severity',
'limit' => 60,
])['items'];
/**
* Baut eine URL mit den aktuellen Roadmap-Filtern.
*
* Das Sprungziel ist einstellbar, weil ein gesetzter Filter nur dort etwas
* bewirkt, wo die gefilterte Liste auch steht. Ein Verweis auf einen
* Themenbereich, der auf der Uebersicht landet, setzt den Filter zwar - man
* sieht davon aber nichts und haelt den Link fuer kaputt.
*/
function rmUrl(array $overrides = [], string $anker = '#tab-roadmap'): string
{
$params = [
'rm_project' => $_GET['rm_project'] ?? null,
'rm_epic' => $_GET['rm_epic'] ?? null,
'rm_lage' => $_GET['rm_lage'] ?? null,
'rm_agent' => $_GET['rm_agent'] ?? null,
'rm_build' => $_GET['rm_build'] ?? null,
'rm_q' => $_GET['rm_q'] ?? null,
];
$params = array_merge($params, $overrides);
$params = array_filter($params, static fn($v): bool => $v !== null && $v !== '' && $v !== 'all');
return 'index.php' . ($params !== [] ? '?' . http_build_query($params) : '') . $anker;
}
/** Zeichen und Klartext zu einem Status, fuer Abzeichen in der Liste. */
function rmBadge(string $status): string
{
$marker = Roadmap::MARKERS[$status] ?? '·';
$label = Roadmap::STATUS_LABELS[$status] ?? $status;
$class = match (true) {
in_array($status, ['resolved', 'closed'], true) => 'badge-up',
$status === 'in_progress' => 'badge-warning',
in_array($status, ['awaiting_verification', 'in_verification'], true) => 'badge-warning',
$status === 'fix_insufficient' => 'badge-down',
default => 'badge-stopped',
};
return '<span class="badge ' . $class . '">' . $marker . ' ' . e($label) . '</span>';
}
// Die auswaehlbaren Rechte - EINE Liste fuer Anlegen und Bearbeiten.
//
// Vorher stand sie im Anlegeformular. Genau deshalb fehlten die
// Supervisor-Rechte monatelang: Wer eine Liste an zwei Stellen fuehrt,
// pflegt eine davon nicht. Ein zweites Formular waere die zweite Stelle
// gewesen.
$scopeChoices = [
'*' => ['⚠️ Alle Rechte', 'Uneingeschränkt — auch Releases veröffentlichen und Tokens ausstellen. Nicht an einen Agenten geben.'],
'bugtracker:manage' => ['⚙️ Bugtracker verwalten', 'Übernehmen, kommentieren, schließen — schließt Lesen und Melden ein'],
'bugtracker:report' => ['🐛 Bugtracker melden', 'Nur Einträge anlegen — schließt Lesen ein'],
'bugtracker:read' => ['👁️ Bugtracker lesen', 'Nur Abfragen'],
'watchdog:ping' => ['🛡️ Watchdog Heartbeat', 'Statusmeldungen senden'],
'watchdog:read' => ['📊 Watchdog lesen', 'Monitore und Ereignisse abfragen'],
// Die Supervisor-Rechte fehlten hier, obwohl TokenManager
// sie seit 2.6.0 kennt. Folge: Ein Werkbank-Token liess
// sich ueber die Oberflaeche gar nicht vollstaendig
// ausstellen - dc_findings und dc_metrics im MCP-Adapter
// liefen ins unauthorized, und der Grund war nirgends zu
// sehen.
'supervisor:read' => ['🔎 Befunde lesen', 'Supervisor-Befunde abfragen — braucht ein Werkbank-Token für dc_findings'],
'supervisor:manage' => ['🔬 Befunde einstufen', 'Befunde abhaken und bewerten — schließt Lesen ein'],
'supervisor:report' => ['📡 Befunde melden', 'Nur einliefern, etwa aus einem Prüfwerkzeug — schließt Lesen ein'],
'updateservice:read' => ['📦 Updates prüfen', 'Auf neue Releases prüfen'],
'updateservice:publish' => ['🚀 Releases veröffentlichen', 'Neue Releases einstellen — schließt Prüfen ein'],
'license:dev' => ['🧪 DEV-Lizenz abholen', 'Befristeten Testschlüssel selbst ausstellen — nur für Werkbank-Token auf Testmaschinen'],
];
// Agenten-Uebersicht: wer bearbeitet gerade was
$agentWorkload = $pdo->query('
SELECT claimed_by,
COUNT(*) AS items,
MIN(lease_until) AS next_expiry
FROM bugtracker_items
WHERE claimed_by IS NOT NULL AND lease_until > UTC_TIMESTAMP()
GROUP BY claimed_by
ORDER BY items DESC
')->fetchAll() ?: [];
// Zustand des Evaluators - ohne ihn sind alle Monitor-Zustaende wertlos
$evaluatorJob = $pdo->query("SELECT * FROM watchdog_cron_jobs WHERE name = 'evaluator'")->fetch();
$evaluatorLastRun = is_array($evaluatorJob) ? ($evaluatorJob['last_run_utc'] ?? null) : null;
$evaluatorStale = $evaluatorLastRun === null
|| (time() - (int)strtotime((string)$evaluatorLastRun . ' UTC')) > 900;
// Rocket.Chat Konfiguration und Cron-Status
$rcConfig = \Deploymentcenter\Modules\Notify\RocketChatNotifier::getConfig($pdo);
$rcCronJob = $pdo->query("SELECT * FROM watchdog_cron_jobs WHERE name = 'rocketchat_status_report'")->fetch() ?: [];
$baseUrl = Http::baseUrl();
$csrfField = Csrf::field();
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Deploymentcenter - Operations Hub</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Roboto+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--bg-dark: #0a0d14;
--bg-sidebar: rgba(255,255,255,0.015);
--bg-card: rgba(255, 255, 255, 0.045);
--bg-card-elev: rgba(255, 255, 255, 0.08);
--border-glass: rgba(255, 255, 255, 0.09);
--border-soft: rgba(255, 255, 255, 0.05);
--primary: #5b9dff;
--primary-glow: rgba(91, 157, 255, 0.4);
--success: #12D48A;
--warning: #F5A623;
--danger: #F6465D;
--text-main: #EDEFF5;
--text-muted: #8B93A7;
--sidebar-width: 240px;
--sidebar-collapsed-width: 72px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Manrope', system-ui, sans-serif; background: radial-gradient(1100px 700px at 15% -10%, rgba(22,82,240,0.28), transparent 60%), radial-gradient(900px 600px at 110% 10%, rgba(18,212,138,0.10), transparent 55%), var(--bg-dark); color: var(--text-main); min-height: 100vh; display: flex; overflow-x: hidden; }
/* Decorative Ambient Glow */
.glow-overlay { position: fixed; inset: 0; pointer-events: none; background: radial-gradient(600px 400px at 80% 85%, rgba(76,141,255,0.08), transparent 60%); z-index: 0; }
/* Left Sidebar (Collapsible) */
.sidebar { width: var(--sidebar-width); background: rgba(10, 13, 20, 0.7); border-right: 1px solid var(--border-glass); padding: 1.25rem 0.75rem; display: flex; flex-direction: column; justify-content: space-between; position: fixed; height: 100vh; transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1); z-index: 100; backdrop-filter: blur(24px); }
.sidebar.collapsed { width: var(--sidebar-collapsed-width); }
.brand { display: flex; align-items: center; gap: 0.75rem; padding: 0.5rem 0.5rem 1.5rem 0.5rem; border-bottom: 1px solid var(--border-soft); margin-bottom: 1rem; overflow: hidden; white-space: nowrap; }
.brand-logo { min-width: 34px; height: 34px; background: linear-gradient(135deg, #1652F0, #4c8dff); border-radius: 10px; display: flex; align-items: center; justify-content: center; box-shadow: 0 0 20px rgba(22,82,240,0.6); }
.brand-logo svg { width: 20px; height: 20px; stroke: #fff; fill: none; stroke-width: 2; }
.brand-title { font-size: 1.1rem; font-weight: 800; color: #fff; letter-spacing: -0.02em; }
.sidebar.collapsed .brand-title { display: none; }
.sidebar-toggle-btn { background: var(--bg-card-elev); border: 1px solid var(--border-glass); color: var(--text-muted); width: 26px; height: 26px; border-radius: 50%; display: flex; align-items: center; justify-content: center; cursor: pointer; position: absolute; right: -13px; top: 22px; transition: transform 0.3s; z-index: 101; }
.sidebar.collapsed .sidebar-toggle-btn { transform: rotate(180deg); }
.nav-section { margin-bottom: 1.5rem; }
.nav-section-title { font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.08em; color: #5B6377; padding: 0 0.75rem 0.5rem 0.75rem; font-weight: 700; }
.sidebar.collapsed .nav-section-title { display: none; }
.nav-menu { display: flex; flex-direction: column; gap: 0.35rem; list-style: none; }
.nav-link { display: flex; align-items: center; gap: 0.85rem; padding: 0.7rem 0.85rem; color: var(--text-muted); text-decoration: none; border-radius: 10px; font-size: 0.875rem; font-weight: 600; transition: all 0.2s; cursor: pointer; white-space: nowrap; }
.nav-link:hover, .nav-link.active { background: rgba(91, 157, 255, 0.12); color: #fff; }
.nav-link.active { color: #fff; background: linear-gradient(135deg, #1652F0, #4c8dff); box-shadow: 0 4px 20px rgba(22,82,240,0.4); }
.nav-link svg { min-width: 20px; height: 20px; stroke: currentColor; fill: none; stroke-width: 2; }
.sidebar.collapsed .nav-text { display: none; }
.user-panel { border-top: 1px solid var(--border-soft); padding-top: 1rem; display: flex; align-items: center; justify-content: space-between; font-size: 0.85rem; color: var(--text-muted); overflow: hidden; white-space: nowrap; }
.sidebar.collapsed .user-name { display: none; }
/* Main Wrapper */
.main-wrapper { margin-left: var(--sidebar-width); flex: 1; transition: margin-left 0.3s cubic-bezier(0.4, 0, 0.2, 1); display: flex; flex-direction: column; min-width: 0; position: relative; z-index: 1; }
.main-wrapper.expanded { margin-left: var(--sidebar-collapsed-width); }
/* Top Bar Header with Embedded Module Sub-Navigation Top-Bar */
.top-bar { background: rgba(10, 13, 20, 0.75); border-bottom: 1px solid var(--border-glass); padding: 0.85rem 2rem; display: flex; align-items: center; justify-content: space-between; backdrop-filter: blur(24px); position: sticky; top: 0; z-index: 90; gap: 1.5rem; }
.top-left { display: flex; align-items: center; gap: 1.5rem; flex: 1; min-width: 0; }
.top-title { font-size: 1.25rem; font-weight: 800; color: #fff; letter-spacing: -0.02em; white-space: nowrap; }
/* Embedded Top-Bar Subnav */
.topbar-subnav { display: flex; align-items: center; gap: 0.4rem; overflow-x: auto; scrollbar-width: none; }
.subnav-link { background: rgba(255,255,255,0.03); border: 1px solid var(--border-glass); color: var(--text-muted); padding: 0.45rem 0.85rem; font-size: 0.8rem; font-weight: 600; border-radius: 8px; cursor: pointer; transition: all 0.2s; white-space: nowrap; display: inline-flex; align-items: center; gap: 0.4rem; }
.subnav-link:hover { color: #fff; background: rgba(255,255,255,0.08); }
.subnav-link.active { background: #5b9dff; color: #0a0d14; border-color: #5b9dff; font-weight: 700; box-shadow: 0 0 12px var(--primary-glow); }
.header-actions { display: flex; align-items: center; gap: 1rem; }
.btn-autorefresh { background: rgba(255,255,255,0.04); border: 1px solid var(--border-glass); color: var(--text-main); font-size: 0.75rem; font-weight: 700; padding: 0.5rem 0.85rem; border-radius: 8px; cursor: pointer; display: flex; align-items: center; gap: 0.5rem; transition: all 0.2s; font-family: 'Roboto Mono', monospace; }
.btn-autorefresh.active { background: rgba(18,212,138,0.15); border-color: var(--success); color: var(--success); }
/*
Die Obergrenze lag bei 1600px. Auf einem breiten Schirm blieb rechts
ein leerer Streifen, waehrend die Tabellen links zusammengedrueckt
standen - besonders im Bugtracker, wo eine Zeile acht Spalten hat.
Ganz ohne Grenze wird es allerdings unlesbar: Bei 3400px wandert das
Auge von der Item-Nummer bis zur Statusspalte quer ueber den Tisch.
2400px ist der Kompromiss - fuellt jeden ueblichen Schirm und
bremst erst darueber.
*/
/*
min-width:0 ist der unscheinbare Teil und der wichtigere.
body ist ein Flex-Container (Seitenleiste + Inhalt), .main-content
also ein Flex-Element - und die haben von Haus aus min-width:auto,
weigern sich also, unter ihre Inhaltsbreite zu schrumpfen. Folge:
Eine breite Tabelle schob den ganzen Inhaltsbereich auf, statt in
ihrem eigenen Behaelter zu scrollen, und die SEITE lief quer. Damit
wandert beim Scrollen auch die Seitenleiste aus dem Bild.
Mit min-width:0 gibt .main-content nach, und .tabelle-scroll tut,
wofuer es da ist.
*/
.main-content { padding: 1.75rem 2rem; max-width: 2400px; min-width: 0; }
/* Stats & KPI Grid */
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1.25rem; margin-bottom: 1.75rem; }
.stat-card { background: var(--bg-card); border: 1px solid var(--border-glass); border-radius: 16px; padding: 1.25rem; backdrop-filter: blur(24px); box-shadow: 0 8px 30px rgba(0,0,0,0.25); }
.stat-header { display: flex; align-items: center; justify-content: space-between; color: var(--text-muted); font-size: 0.75rem; font-weight: 600; }
.stat-value { font-size: 2rem; font-weight: 800; margin-top: 0.5rem; color: #fff; font-family: 'Roboto Mono', monospace; letter-spacing: -0.02em; }
/* Group Overview Cards Panel (Screenshot 2) */
.group-cards-grid { display: flex; flex-wrap: wrap; gap: 1rem; margin-top: 0.75rem; }
.group-card { background: rgba(255,255,255,0.03); border: 1px solid var(--border-glass); border-radius: 12px; padding: 0.85rem 1.1rem; display: flex; align-items: center; justify-content: space-between; min-width: 180px; gap: 1rem; }
.group-card-name { font-size: 0.875rem; font-weight: 700; color: #fff; }
/* Icon Grid Selector in Edit Modal (Screenshot 1) */
.icon-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.5rem; max-height: 180px; overflow-y: auto; padding-right: 4px; }
.icon-option-btn { background: rgba(255,255,255,0.03); border: 1px solid var(--border-glass); border-radius: 10px; padding: 0.55rem 0.75rem; color: var(--text-main); font-size: 0.8rem; font-weight: 600; cursor: pointer; display: flex; align-items: center; gap: 0.5rem; transition: all 0.2s; }
.icon-option-btn:hover { background: rgba(255,255,255,0.08); }
.icon-option-btn.selected { border-color: #FF7A45; background: rgba(242,98,46,0.15); color: #fff; box-shadow: 0 0 10px rgba(242,98,46,0.3); }
/* Cards & Tables */
.card { background: var(--bg-card); border: 1px solid var(--border-glass); border-radius: 18px; padding: 1.5rem; margin-bottom: 1.75rem; backdrop-filter: blur(24px); box-shadow: 0 8px 30px rgba(0,0,0,0.25); }
.card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.25rem; }
.card-title { font-size: 1.05rem; font-weight: 700; color: #fff; display: flex; align-items: center; gap: 0.5rem; }
table { width: 100%; border-collapse: collapse; text-align: left; font-size: 0.85rem; }
th { padding: 0.8rem 1rem; color: #5B6377; font-weight: 700; border-bottom: 1px solid var(--border-glass); font-size: 0.725rem; text-transform: uppercase; letter-spacing: 0.05em; }
td { padding: 0.85rem 1rem; border-bottom: 1px solid var(--border-soft); vertical-align: middle; }
tr:hover td { background: rgba(255, 255, 255, 0.02); }
/* Badges */
/*
Ein Abzeichen ist eine Beschriftung, kein Fliesstext - es bricht nie um.
white-space:nowrap fehlte, und in einer schmalen Spalte wurde aus
"FEATURE" ein "FEATUR" mit einem "E" darunter. Zusammen mit
border-radius:20px, das bei zwei Zeilen keine Pille mehr ergibt,
sondern eine Ellipse, sah das Ergebnis nach Ballon aus.
line-height fest, weil Emoji hoeher bauen als Ziffern: Ohne die
Angabe war ein Abzeichen mit Zeichen einige Pixel groesser als eines
ohne, und die Zeilen standen unruhig untereinander.
*/
.badge { padding: 0.2rem 0.6rem; border-radius: 20px; font-size: 0.725rem; font-weight: 700; display: inline-flex; align-items: center; gap: 0.35rem; font-family: 'Roboto Mono', monospace; white-space: nowrap; line-height: 1.45; min-height: 25px; box-sizing: border-box; }
.badge-up { background: rgba(18,212,138,0.12); color: var(--success); border: 1px solid rgba(18,212,138,0.3); }
.badge-warning { background: rgba(245,166,35,0.12); color: var(--warning); border: 1px solid rgba(245,166,35,0.3); }
.badge-down { background: rgba(246,70,93,0.12); color: var(--danger); border: 1px solid rgba(246,70,93,0.3); }
.badge-stopped { background: rgba(139,147,167,0.12); color: var(--text-muted); border: 1px solid rgba(139,147,167,0.3); }
/* Buttons & Form Inputs */
.btn { background: linear-gradient(135deg,#1652F0,#4c8dff); color: #fff; border: none; border-radius: 10px; padding: 0.6rem 1.1rem; font-size: 0.85rem; font-weight: 700; cursor: pointer; transition: all 0.2s; text-decoration: none; display: inline-flex; align-items: center; gap: 0.5rem; box-shadow: 0 0 15px rgba(22,82,240,0.4); }
.btn:hover { transform: translateY(-1px); box-shadow: 0 0 22px rgba(22,82,240,0.6); }
.btn-secondary { background: rgba(255,255,255,0.06); border: 1px solid var(--border-glass); color: var(--text-main); box-shadow: none; }
.btn-secondary:hover { background: rgba(255,255,255,0.12); }
.btn-sm { padding: 0.35rem 0.65rem; font-size: 0.75rem; border-radius: 6px; }
.btn-danger { background: var(--danger); box-shadow: 0 0 15px rgba(246,70,93,0.4); }
.btn-danger:hover { background: #dc2626; }
.form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1rem; margin-bottom: 1.25rem; }
.form-group { display: flex; flex-direction: column; gap: 0.35rem; }
.form-label { font-size: 0.775rem; color: var(--text-muted); font-weight: 600; }
.form-input { background: rgba(255,255,255,0.04); border: 1px solid var(--border-glass); border-radius: 10px; padding: 0.65rem 0.85rem; color: #fff; font-size: 0.875rem; outline: none; font-family: 'Manrope', system-ui; transition: border-color 0.2s; }
.form-input:focus { border-color: var(--primary); }
/* Dropdown Option List Styling Fix */
select.form-input option {
background-color: #0e121c;
color: #EDEFF5;
}
.prompt-box { background: rgba(0,0,0,0.5); border: 1px solid var(--border-glass); border-radius: 10px; padding: 1.1rem; font-family: 'Roboto Mono', monospace; font-size: 0.825rem; color: #cbd5e1; white-space: pre-wrap; word-break: break-all; margin-bottom: 0.75rem; position: relative; max-height: 380px; overflow-y: auto; }
.alert { padding: 1rem 1.25rem; border-radius: 12px; margin-bottom: 1.5rem; font-size: 0.875rem; font-weight: 600; }
.alert-success { background: rgba(18,212,138,0.15); border: 1px solid rgba(18,212,138,0.3); color: #a7f3d0; }
.alert-danger { background: rgba(246,70,93,0.15); border: 1px solid rgba(246,70,93,0.3); color: #fca5a5; }
/* Modals */
.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.75); backdrop-filter: blur(10px); display: none; align-items: center; justify-content: center; z-index: 200; }
.modal-backdrop.active { display: flex; }
.modal-card { background: #14171d; border: 1px solid rgba(255,255,255,0.12); border-radius: 18px; padding: 1.75rem; max-width: 600px; width: 95%; max-height: 90vh; overflow-y: auto; box-shadow: 0 20px 50px rgba(0,0,0,0.8); }
/*
Verweise im Inhalt.
Es gab dafuer bisher keine Regel - Links in Karten und Tabellen
erschienen also im Browservorgabeblau (#0000EE) auf beinahe
schwarzem Grund und waren schlicht nicht lesbar. Aufgefallen ist es
an den Bereichsnamen der Roadmap, betrifft aber jeden Verweis im
Inhaltsbereich.
Bewusst eng gefasst: .btn und .nav-link sind ebenfalls <a> und
bringen ihre eigene Gestaltung mit. Eine Regel auf blankes "a"
haette sie ueberschrieben.
*/
.card a:not(.btn), table a:not(.btn) {
color: var(--primary);
text-decoration: none;
border-bottom: 1px solid rgba(91,157,255,0.35);
transition: color .15s, border-color .15s;
}
.card a:not(.btn):hover, table a:not(.btn):hover {
color: #9cc4ff;
border-bottom-color: #9cc4ff;
}
/*
Spaltenkopf mit Erklärung.
Ein title= allein findet niemand - es gibt keinen Hinweis darauf,
dass da etwas zu holen wäre. Der Fragezeichen-Zeiger und die
gepunktete Linie sind die übliche Anzeige dafür und kosten nichts.
*/
.hilfe { cursor: help; border-bottom: 1px dotted rgba(255,255,255,0.28); }
/* ---------------------------------------------------------------
Breite Datentabellen (Bugtracker, Fehlerstrom)
Ohne table-layout:fixed verteilt der Browser die Breite nach
Inhalt. Eine einzige lange Exception-Meldung oder ein Dateipfad
wie "public/api/bugtracker/v1/manage/index.php:407" zog damit die
Titelspalte auf und quetschte alle anderen zusammen - je Zeile
anders, weshalb die Spalten von oben nach unten wanderten.
Mit fester Verteilung stehen die schmalen Spalten still, und die
Titelspalte bekommt, was uebrig ist. Sie muss dafuer kuerzen
koennen - siehe .zelle-titel.
--------------------------------------------------------------- */
.tabelle-scroll { overflow-x: auto; }
table.tabelle-fest { table-layout: fixed; min-width: 1280px; }
table.tabelle-fest td { overflow-wrap: anywhere; }
/*
Der Titel wird nach zwei Zeilen abgeschnitten, nicht nach einer:
Eine Exception-Meldung ist in der ersten Zeile oft nur Namensraum
und sagt noch nichts. Der vollstaendige Text haengt im title=.
*/
.zelle-titel .titel {
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
overflow: hidden; color: #fff; font-weight: 700;
}
.zelle-titel .meta {
font-size: 0.75rem; color: var(--text-muted); margin-top: 0.25rem;
display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center;
overflow: hidden;
}
/* Ein langer Dateipfad darf die Zeile nicht in die Hoehe treiben. */
.zelle-titel .meta .badge { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/*
Die Knoepfe brachen um, weil die Zelle enger wurde als ihr Inhalt -
daher das Verschobene im Screenshot: Stift oben, Lupe und Haken in
der Zeile darunter. Sie bleiben jetzt in einer Reihe, und die Spalte
ist breit genug dafuer.
*/
.zelle-aktionen { white-space: nowrap; }
.zelle-aktionen > div { display: flex; gap: 0.3rem; flex-wrap: nowrap; }
.tab-content, .subtab-content { display: none; }
/* Mehrfachauswahl im Filterbalken.
<details> statt eigenem Klappmechanismus: Auf- und Zuklappen kann der
Browser, und ohne JavaScript bleibt es bedienbar. */
.filter-mehrfach { position: relative; display: inline-block; }
.filter-mehrfach > summary {
list-style: none; cursor: pointer; white-space: nowrap;
padding: 0.3rem 0.6rem; font-size: 0.8rem;
border: 1px solid var(--border-glass, rgba(255,255,255,0.12));
border-radius: 6px; background: rgba(255,255,255,0.04);
}
.filter-mehrfach > summary::-webkit-details-marker { display: none; }
.filter-mehrfach[open] > summary { border-color: #7aa2ff; }
.filter-mehrfach-inhalt {
position: absolute; z-index: 50; top: calc(100% + 4px); left: 0;
min-width: 240px; padding: 0.6rem 0.75rem;
background: #161c28;
border: 1px solid var(--border-glass, rgba(255,255,255,0.12));
border-radius: 8px; box-shadow: 0 12px 32px rgba(0,0,0,0.5);
}
.filter-mehrfach-inhalt label {
display: flex; align-items: center; gap: 0.45rem;
padding: 0.15rem 0; font-size: 0.82rem; cursor: pointer; font-weight: 400;
}
.filter-gruppe {
font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.06em;
opacity: 0.55; margin: 0.55rem 0 0.2rem;
}
.filter-gruppe:first-child { margin-top: 0; }
.filter-mehrfach-fuss {
display: flex; gap: 0.35rem; margin-top: 0.7rem;
padding-top: 0.6rem;
border-top: 1px solid var(--border-glass, rgba(255,255,255,0.12));
}
.filter-hinweis { font-size: 0.7rem; opacity: 0.5; margin-top: 0.4rem; }
.tab-content.active, .subtab-content.active { display: block; }
/* ================= Roadmap-Tafel ================= */
.tafel-scroller { overflow-x: auto; padding-bottom: 0.75rem; -webkit-overflow-scrolling: touch; }
.tafel-track {
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(250px, 1fr);
gap: 0.9rem;
align-items: start;
min-width: min-content;
}
.tafel-spalte {
background: var(--bg-card);
border: 1px solid var(--border-glass);
border-radius: 12px;
padding: 0.85rem 0.7rem 1rem;
display: flex;
flex-direction: column;
gap: 0.85rem;
}
.tafel-spalte.kind-done { background: transparent; }
.tafel-spalte.kind-think { background: transparent; border-style: dashed; }
.tafel-spalte.drop-ziel { border-color: var(--primary); box-shadow: 0 0 0 2px var(--primary-glow); }
.tafel-kopf { border-bottom: 1px solid var(--border-soft); padding-bottom: 0.6rem; }
.tafel-kopf h3 { margin: 0; font-size: 1rem; font-weight: 700; letter-spacing: -0.01em; }
.tafel-kopf p { margin: 0.2rem 0 0; font-size: 0.76rem; color: var(--text-muted); }
.tafel-kopf .rang {
display: block; margin-top: 0.3rem;
font-family: 'Roboto Mono', monospace; font-size: 0.68rem; color: var(--text-muted); opacity: 0.75;
}
.tafel-spalte.kind-now .tafel-kopf h3 { color: var(--primary); }
.tafel-spur { display: flex; flex-direction: column; gap: 0.4rem; }
.tafel-spur > h4 {
margin: 0 0 0.1rem;
font-family: 'Roboto Mono', monospace;
font-size: 0.66rem; letter-spacing: 0.07em; text-transform: uppercase;
color: var(--text-muted);
display: flex; align-items: center; gap: 0.35rem;
}
.tafel-karte {
position: relative;
background: var(--bg-card-elev);
border: 1px solid var(--border-glass);
border-left: 3px solid var(--border-glass);
border-radius: 8px;
padding: 0.5rem 0.6rem 0.55rem;
cursor: pointer;
display: flex; flex-direction: column; gap: 0.2rem;
transition: transform 0.12s ease, border-color 0.12s ease;
}
.tafel-karte:hover { transform: translateY(-1px); border-color: var(--primary); }
.tafel-karte:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
.tafel-karte.dragging { opacity: 0.4; }
.tafel-karte .kopfzeile { display: flex; align-items: center; gap: 0.35rem; flex-wrap: wrap; }
.tafel-karte .nummer { font-family: 'Roboto Mono', monospace; font-size: 0.68rem; color: var(--text-muted); }
.tafel-karte .titel { font-size: 0.82rem; line-height: 1.3; font-weight: 600; }
.tafel-karte .zusatz { font-size: 0.72rem; line-height: 1.35; color: var(--text-muted); }
.tafel-karte.state-done {
background: rgba(18, 212, 138, 0.07);
border-color: transparent; border-left-color: var(--success);
}
.tafel-karte.state-done .titel { font-weight: 400; color: var(--text-muted); }
.tafel-karte.state-working { border-left-color: var(--warning); }
.tafel-karte.state-verification { border-left-color: #c084fc; }
.tafel-karte.state-deferred { background: transparent; border-style: dashed; }
.tafel-karte.state-idea { background: transparent; border-style: dotted; }
.tafel-karte.hervorgehoben { background: rgba(246, 70, 93, 0.09); }
.tafel-marke {
font-family: 'Roboto Mono', monospace;
font-size: 0.6rem; font-weight: 700; letter-spacing: 0.03em;
padding: 0.12rem 0.3rem; border-radius: 3px;
background: rgba(255,255,255,0.1); color: var(--text-muted);
white-space: nowrap;
}
.tafel-marke.warn { background: rgba(245, 166, 35, 0.18); color: var(--warning); }
.tafel-marke.pruef { background: rgba(192, 132, 252, 0.18); color: #d8b4fe; }
.tafel-marke.hoch { background: rgba(246, 70, 93, 0.18); color: var(--danger); }
.tafel-marke.neu { background: rgba(91, 157, 255, 0.2); color: var(--primary); }
.tafel-marke.leer { background: transparent; color: var(--text-muted);
border: 1px dashed var(--border-glass); opacity: 0.75; }
.tafel-luecke { height: 0.35rem; border-radius: 3px; transition: background 0.1s ease; }
.tafel-luecke.aktiv { background: var(--primary); }
/* --- Seitenpanel --- */
.panel-schatten {
position: fixed; inset: 0; background: rgba(4, 7, 12, 0.6);
backdrop-filter: blur(2px); z-index: 900; display: none;
}
.panel-schatten.offen { display: block; }
.tafel-panel {
position: fixed; top: 0; right: 0; bottom: 0;
width: min(560px, 100vw);
background: #10141d;
border-left: 1px solid var(--border-glass);
z-index: 901;
transform: translateX(100%);
transition: transform 0.18s ease;
display: flex; flex-direction: column;
}
.tafel-panel.offen { transform: translateX(0); }
.tafel-panel header {
padding: 1rem 1.1rem 0.85rem;
border-bottom: 1px solid var(--border-glass);
display: flex; align-items: flex-start; gap: 0.75rem;
}
.tafel-panel header h2 { margin: 0; font-size: 1rem; line-height: 1.35; flex: 1; }
.tafel-panel .panel-inhalt { overflow-y: auto; padding: 1rem 1.1rem 2rem; flex: 1; }
.tafel-panel .panel-block { margin-bottom: 1.35rem; }
.tafel-panel .panel-block > h3 {
margin: 0 0 0.5rem; font-size: 0.72rem; letter-spacing: 0.07em;
text-transform: uppercase; color: var(--text-muted);
font-family: 'Roboto Mono', monospace;
}
.tafel-panel .beschreibung {
white-space: pre-wrap; font-size: 0.82rem; line-height: 1.5;
color: var(--text-muted); max-height: 22rem; overflow-y: auto;
}
.tafel-panel .kommentar {
border-left: 2px solid var(--border-glass);
padding: 0.35rem 0 0.35rem 0.65rem; margin-bottom: 0.7rem;
}
.tafel-panel .kommentar .meta {
font-family: 'Roboto Mono', monospace; font-size: 0.68rem; color: var(--text-muted);
display: flex; gap: 0.4rem; flex-wrap: wrap; margin-bottom: 0.2rem;
}
.tafel-panel .kommentar .text { font-size: 0.8rem; line-height: 1.45; white-space: pre-wrap; }
.tafel-panel .felder { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.6rem; }
.panel-schliessen {
background: none; border: none; color: var(--text-muted);
font-size: 1.4rem; line-height: 1; cursor: pointer; padding: 0;
}
.panel-schliessen:hover { color: var(--text-main); }
@media (max-width: 760px) {
.tafel-scroller { overflow: visible; }
.tafel-track { grid-auto-flow: row; grid-auto-columns: auto; min-width: 0; }
}
@media print {
.sidebar, .topbar, .tafel-panel, .panel-schatten, #tafelNeuLaden { display: none !important; }
.tafel-scroller { overflow: visible; }
.tafel-track { grid-auto-flow: column; }
.tafel-karte { break-inside: avoid; }
}
</style>
</head>
<body>
<!-- Glow Overlay -->
<div class="glow-overlay"></div>
<!-- Left Collapsible Sidebar -->
<aside class="sidebar" id="sidebar">
<button class="sidebar-toggle-btn" id="sidebarToggle" onclick="toggleSidebar()" title="Menü einklappen / ausklappen">
<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" fill="none" stroke-width="2"><polyline points="15 18 9 12 15 6"></polyline></svg>
</button>
<div>
<div class="brand">
<div class="brand-logo">
<svg viewBox="0 0 24 24"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"></path></svg>
</div>
<div class="brand-title">Deploymentcenter</div>
</div>
<div class="nav-section">
<div class="nav-section-title">Navigation</div>
<ul class="nav-menu">
<li class="nav-item">
<a class="nav-link active" onclick="switchMainTab('overview', this)" title="Übersicht">
<svg viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7"></rect><rect x="14" y="3" width="7" height="7"></rect><rect x="14" y="14" width="7" height="7"></rect><rect x="3" y="14" width="7" height="7"></rect></svg>
<span class="nav-text">Übersicht</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link" onclick="switchMainTab('projects', this)" title="Projekte">
<svg viewBox="0 0 24 24"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>
<span class="nav-text">Projekte</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link" onclick="switchMainTab('license', this)" title="Lizenzen">
<svg viewBox="0 0 24 24"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"></path></svg>
<span class="nav-text">Lizenzen</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link" onclick="switchMainTab('watchdog', this)" title="WatchDog">
<svg viewBox="0 0 24 24"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path></svg>
<span class="nav-text">WatchDog</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link" onclick="switchMainTab('updateservice', this)" title="UpdateService">
<svg viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>
<span class="nav-text">UpdateService</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link" onclick="switchMainTab('roadmap', this)" title="Roadmap & Projektstatus">
<svg viewBox="0 0 24 24"><line x1="3" y1="6" x2="21" y2="6"></line><line x1="3" y1="12" x2="15" y2="12"></line><line x1="3" y1="18" x2="9" y2="18"></line><circle cx="18" cy="12" r="2"></circle><circle cx="12" cy="18" r="2"></circle></svg>
<span class="nav-text">Roadmap</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link" onclick="switchMainTab('bugtracker', this)" title="Bug- & Feature-Tracker">
<svg viewBox="0 0 24 24"><path d="M12 2a2 2 0 0 1 2 2v1h3a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2h-1v2h2a1 1 0 0 1 0 2h-2v2h1a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2v-2a2 2 0 0 1 2-2h1v-2H6a1 1 0 0 1 0-2h2v-2H7a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h3V4a2 2 0 0 1 2-2z"></path></svg>
<span class="nav-text">Bugtracker</span>
</a>
</li>
</ul>
</div>
</div>
<div>
<div class="nav-section" style="margin-bottom:1rem;">
<div class="nav-section-title">Administration</div>
<ul class="nav-menu">
<li class="nav-item">
<a class="nav-link" onclick="switchMainTab('tokens', this)" title="Master- & Sub-Tokens">
<svg viewBox="0 0 24 24"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"></path></svg>
<span class="nav-text">Token-Verwaltung</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link" onclick="switchMainTab('system', this)" title="System & DB">
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
<span class="nav-text">System & DB</span>
</a>
</li>
</ul>
</div>
<div class="user-panel">
<span class="user-name">👤 <?= htmlspecialchars($_SESSION['dc_username'] ?? 'Admin') ?></span>
<a href="logout.php" style="color:var(--danger); text-decoration:none; font-weight:700;">Abmelden</a>
</div>
</div>
</aside>
<!-- Main Content Wrapper -->
<div class="main-wrapper" id="mainWrapper">
<!-- Top Header Bar with Embedded Module Subnav Bar -->
<header class="top-bar">
<div class="top-left">
<div class="top-title" id="topPageTitle">Globales Dashboard</div>
<!-- Subnav Bar Embedded in Top-Bar -->
<div class="topbar-subnav" id="topbarSubnav">
<!-- Dynamic Subnav items rendered via JavaScript -->
</div>
</div>
<div class="header-actions">
<button class="btn-autorefresh" id="btnAutoRefresh" onclick="toggleAutoRefresh()">
<span id="autoRefreshIcon">⏱️</span>
<span id="autoRefreshText">30s REFRESH: AUS</span>
</button>
</div>
</header>
<main class="main-content">
<?php if ($msg): ?>
<?php /* Der Inhalt wird beim Erzeugen der Meldung mit e() maskiert. */ ?>
<div class="alert alert-<?= $msgType === 'danger' ? 'danger' : 'success' ?>"><?= $msg ?></div>
<?php endif; ?>
<?php if ($evaluatorStale): ?>
<!--
Ohne laufenden Evaluator aendert sich der Zustand eines Monitors
nur beim Eintreffen eines Heartbeats - ein ausgefallener Server
bliebe dauerhaft gruen. Deshalb ein deutlicher Hinweis.
-->
<div class="alert alert-danger" style="display:flex; flex-wrap:wrap; align-items:center; justify-content:space-between; gap:1rem;">
<div>
<strong>⚠️ Watchdog-Evaluator läuft nicht.</strong>
Die Monitor-Zustände sind derzeit nicht verlässlich – ein ausgefallenes System bleibt „up“, bis es sich wieder meldet.
<?php if ($evaluatorLastRun !== null): ?>
Letzter Lauf: <?= e(dc_dt((string)$evaluatorLastRun)) ?> (<?= e(dc_ago((string)$evaluatorLastRun)) ?>).
<?php else: ?>
Der Evaluator lief noch nie.
<?php endif; ?>
<div style="font-size:0.78rem; margin-top:0.5rem; font-family:'Roboto Mono',monospace; opacity:0.85;">
<!--
Der interne Aufruf steht zuerst: er braucht keinen Schluessel
im Crontab - der taucht sonst in der Prozessliste jedes
Nutzers auf - und laeuft auch dann, wenn der Webserver
gerade nicht erreichbar ist.
-->
Empfohlen (intern):<br>
* * * * * /usr/bin/php <?= e(DC_ROOT) ?>/cli/tick.php --quiet
<div style="margin-top:0.45rem; opacity:0.75;">
Alternativ über HTTP:<br>
* * * * * curl -fsS -H "Authorization: Bearer &lt;SHARED_KEY&gt;" <?= e($baseUrl) ?>/api/watchdog/v1/evaluate &gt; /dev/null
</div>
</div>
</div>
<form method="POST" action="index.php#tab-watchdog" style="flex-shrink:0;">
<?= $csrfField ?>
<input type="hidden" name="action" value="run_evaluator">
<button type="submit" class="btn btn-sm">Jetzt einmalig ausführen</button>
</form>
</div>
<?php endif; ?>
<!-- ================= MODULE 0: OVERVIEW ================= -->
<div id="tab-overview" class="tab-content active">
<div class="stats-grid">
<div class="stat-card">
<div class="stat-header">Aktive Projekte</div>
<div class="stat-value"><?= count($projects) ?></div>
</div>
<div class="stat-card">
<div class="stat-header">Aktive Lizenzen</div>
<div class="stat-value"><?= count($licenses) ?></div>
</div>
<div class="stat-card">
<div class="stat-header">Hardware Aktivierungen</div>
<div class="stat-value"><?= count($activations) ?></div>
</div>
<div class="stat-card">
<div class="stat-header">Watchdog Monitore</div>
<div class="stat-value">
<span style="color:var(--success)"><?= $monitorsUp ?></span> /
<span style="color:var(--warning)"><?= $monitorsWarning ?></span> /
<span style="color:var(--danger)"><?= $monitorsDown ?></span>
</div>
</div>
</div>
<div class="card">
<div class="card-header"><h2 class="card-title">🛡️ Watchdog System-Hierarchie & Monitore</h2></div>
<table>
<thead>
<tr>
<th>Hierarchie / Entity Source</th>
<th>Typ</th>
<th>Status</th>
<th>Version</th>
<th>Letzte Meldung</th>
<th>Zuletzt Gesehen</th>
</tr>
</thead>
<tbody>
<?php foreach ($hierarchicalMonitors as $m): ?>
<?php
$depth = (int)$m['depth'];
$indentPx = $depth * 24;
$isChild = ($depth > 0);
$iconUrl = getMonitorIconUrl($m['icon']??null, $m['source']??null, $m['os']??null, $m['type']??null);
?>
<tr>
<td>
<div style="display:flex; align-items:center; gap:8px; margin-left:<?= $indentPx ?>px; <?= $isChild ? 'border-left:2px solid var(--primary); padding-left:8px;' : '' ?>">
<?php if ($isChild): ?>
<span style="color:var(--primary); font-family:'Roboto Mono'; font-size:13px; font-weight:bold"><?= $m['is_last'] ? '└─' : '├─' ?></span>
<?php endif; ?>
<img src="<?= htmlspecialchars($iconUrl) ?>" width="18" height="18" alt="icon">
<strong><?= htmlspecialchars($m['source']) ?></strong>
<span style="color:var(--text-muted); font-size:0.75rem;">(<?= htmlspecialchars($m['instance']) ?>)</span>
</div>
</td>
<td><code><?= htmlspecialchars($m['type']) ?></code></td>
<td>
<span class="badge badge-<?= $m['state'] === 'up' ? 'up' : ($m['state'] === 'warning' ? 'warning' : 'down') ?>">
● <?= strtoupper($m['state']) ?>
</span>
</td>
<td>
<?php if (!empty($m['app_version'])): ?>
<code><?= htmlspecialchars((string)$m['app_version']) ?></code>
<?php else: ?>
<span style="color:var(--text-muted);">–</span>
<?php endif; ?>
</td>
<td><?= htmlspecialchars($m['last_message'] ?? '-') ?></td>
<td><?= htmlspecialchars($m['last_seen_utc'] ?? 'Nie') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<!-- ================= MODULE 1: PROJEKTE ================= -->
<div id="tab-projects" class="tab-content">
<!-- Einheitlich wie alle anderen Module: der Inhalt liegt in einer
benannten Unteransicht. Zuvor stand er direkt im Modul, wodurch
der Navigationseintrag "Projekte Uebersicht" ins Leere zeigte. -->
<div id="sub-projects-list" class="subtab-content active">
<div class="card">
<div class="card-header"><h2 class="card-title">📁 Neues Projekt anlegen / Bearbeiten</h2></div>
<form method="POST" action="index.php#tab-projects">
<?= $csrfField ?>
<input type="hidden" name="action" value="save_project">
<input type="hidden" name="project_id" id="projEditId" value="0">
<div class="form-grid">
<div class="form-group">
<label class="form-label">Projekt-Slug (z. B. polytrader - unveränderlich)</label>
<input type="text" name="slug" id="projEditSlug" class="form-input" required placeholder="polytrader">
</div>
<div class="form-group">
<label class="form-label">Projekt Name</label>
<input type="text" name="name" id="projEditName" class="form-input" required placeholder="PolyTrader Suite Pro">
</div>
<div class="form-group">
<label class="form-label">Offline Grace Cache TTL (Stunden)</label>
<input type="number" name="ttl" id="projEditTtl" class="form-input" value="168">
</div>
</div>
<div class="form-group" style="margin-bottom:1rem;">
<label class="form-label">Projekt Beschreibung / Notizen</label>
<input type="text" name="notes" id="projEditNotes" class="form-input" placeholder="Trading & Handelssystem Platform">
</div>
<div style="display:flex; gap:0.5rem;">
<button type="submit" class="btn" id="projSubmitBtn">Projekt Anlegen</button>
<button type="button" class="btn btn-secondary" onclick="resetProjectForm()">Formular Zurücksetzen</button>
</div>
</form>
</div>
<div class="card">
<div class="card-header"><h2 class="card-title">Projekte Übersicht & Verwaltung</h2></div>
<table>
<thead>
<tr>
<th>ID</th>
<th>Slug</th>
<th>Projekt Name</th>
<th>Cache TTL</th>
<th>Verknüpfte Lizenzen</th>
<th>Verknüpfte Releases</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
<?php foreach ($projects as $p): ?>
<?php
$licCount = count(array_filter($licenses, fn($l) => $l['product_slug'] === $p['slug']));
$relCount = count(array_filter($releases, fn($r) => $r['product_slug'] === $p['slug']));
?>
<tr>
<td><?= $p['id'] ?></td>
<td><code style="font-weight:700; color:var(--primary);"><?= htmlspecialchars($p['slug']) ?></code></td>
<td><strong><?= htmlspecialchars($p['name']) ?></strong></td>
<td><?= $p['default_cache_ttl_hours'] ?> h (<?= round($p['default_cache_ttl_hours']/24, 1) ?> Tage)</td>
<td><span class="badge badge-up"><?= $licCount ?> Lizenzen</span></td>
<td><span class="badge badge-up"><?= $relCount ?> Releases</span></td>
<td style="display:flex; gap:0.35rem;">
<button class="btn btn-sm btn-secondary" onclick="editProject(<?= htmlspecialchars(json_encode($p)) ?>)">✏️ Bearbeiten</button>
<button class="btn btn-sm btn-danger" onclick="openDeleteProjectModal(<?= $p['id'] ?>, '<?= htmlspecialchars($p['slug']) ?>', '<?= htmlspecialchars($p['name']) ?>')">🗑️ Löschen</button>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
</div>
<!-- Delete Project Safety Modal -->
<div class="modal-backdrop" id="deleteProjectModal">
<div class="modal-card">
<h2 style="color:var(--danger); margin-bottom:0.75rem;">⚠️ Projekt unwiderruflich löschen</h2>
<p style="color:var(--text-muted); font-size:0.875rem; margin-bottom:1rem;">
Sind Sie sicher, dass Sie das Projekt <strong id="deleteProjNameDisplay" style="color:#fff;"></strong> löschen möchten?
</p>
<p style="color:var(--danger); font-size:0.8rem; font-weight:700; margin-bottom:1rem;">
Bitte geben Sie zur Sicherheitsbestätigung den Projekt-Slug ein: <code id="deleteProjSlugDisplay"></code>
</p>
<form method="POST" action="index.php#tab-projects">
<?= $csrfField ?>
<input type="hidden" name="action" value="delete_project">
<input type="hidden" name="project_id" id="deleteProjId">
<input type="text" name="confirm_slug" id="deleteProjConfirmInput" class="form-input" style="margin-bottom:1.25rem;" placeholder="Slug hier eintippen" required>
<div style="display:flex; justify-content:flex-end; gap:0.75rem;">
<button type="button" class="btn btn-secondary" onclick="closeDeleteProjectModal()">Abbrechen</button>
<button type="submit" class="btn btn-danger">Endgültig Löschen</button>
</div>
</form>
</div>
</div>
<!-- Rich Monitor Edit Modal (Matching Screenshot 1: Monitor & Icon bearbeiten) -->
<div class="modal-backdrop" id="editMonitorModal">
<div class="modal-card">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:1.25rem;">
<h2 style="font-size:1.15rem; font-weight:800; color:#fff; display:flex; align-items:center; gap:0.5rem;">✏️ Monitor & Icon bearbeiten</h2>
<button type="button" onclick="closeEditMonitorModal()" style="background:none; border:none; color:var(--text-muted); font-size:1.25rem; cursor:pointer;">&times;</button>
</div>
<form method="POST" action="index.php#sub-watchdog-hierarchy" enctype="multipart/form-data">
<?= $csrfField ?>
<input type="hidden" name="action" value="edit_watchdog_monitor">
<input type="hidden" name="old_source" id="monEditOldSource">
<input type="hidden" name="icon" id="monEditIconSelected" value="">
<div class="form-group" style="margin-bottom:1rem;">
<label class="form-label">Monitor Name (source)</label>
<input type="text" name="new_source" id="monEditNewSource" class="form-input" required placeholder="ClawdDotNet Test">
</div>
<!-- Icon Grid Selector (Visual Preview) -->
<div class="form-group" style="margin-bottom:1rem;">
<label class="form-label">Icon wählen (Visuelle Vorschau):</label>
<div class="icon-grid">
<button type="button" class="icon-option-btn selected" data-icon="" onclick="selectIcon(this, '')">🪄 Auto-Detect</button>
<button type="button" class="icon-option-btn" data-icon="assets/icons/proxmox.svg" onclick="selectIcon(this, 'assets/icons/proxmox.svg')"><img src="assets/icons/proxmox.svg" width="16" height="16"> Proxmox</button>
<button type="button" class="icon-option-btn" data-icon="assets/icons/windows.svg" onclick="selectIcon(this, 'assets/icons/windows.svg')"><img src="assets/icons/windows.svg" width="16" height="16"> Windows</button>
<button type="button" class="icon-option-btn" data-icon="assets/icons/linux.svg" onclick="selectIcon(this, 'assets/icons/linux.svg')"><img src="assets/icons/linux.svg" width="16" height="16"> Linux</button>
<button type="button" class="icon-option-btn" data-icon="assets/icons/mysql.svg" onclick="selectIcon(this, 'assets/icons/mysql.svg')"><img src="assets/icons/mysql.svg" width="16" height="16"> MySQL</button>
<button type="button" class="icon-option-btn" data-icon="assets/icons/docker.svg" onclick="selectIcon(this, 'assets/icons/docker.svg')"><img src="assets/icons/docker.svg" width="16" height="16"> Docker</button>
<button type="button" class="icon-option-btn" data-icon="assets/icons/nginx.svg" onclick="selectIcon(this, 'assets/icons/nginx.svg')"><img src="assets/icons/nginx.svg" width="16" height="16"> Nginx</button>
<button type="button" class="icon-option-btn" data-icon="assets/icons/redis.svg" onclick="selectIcon(this, 'assets/icons/redis.svg')"><img src="assets/icons/redis.svg" width="16" height="16"> Redis</button>
<button type="button" class="icon-option-btn" data-icon="assets/icons/python.svg" onclick="selectIcon(this, 'assets/icons/python.svg')"><img src="assets/icons/python.svg" width="16" height="16"> Python</button>
<button type="button" class="icon-option-btn" data-icon="assets/icons/php.svg" onclick="selectIcon(this, 'assets/icons/php.svg')"><img src="assets/icons/php.svg" width="16" height="16"> PHP</button>
<button type="button" class="icon-option-btn" data-icon="assets/icons/node.svg" onclick="selectIcon(this, 'assets/icons/node.svg')"><img src="assets/icons/node.svg" width="16" height="16"> Node.js</button>
<button type="button" class="icon-option-btn" data-icon="assets/icons/server.svg" onclick="selectIcon(this, 'assets/icons/server.svg')"><img src="assets/icons/server.svg" width="16" height="16"> Generischer</button>
</div>
</div>
<div class="form-group" style="margin-bottom:1rem;">
<label class="form-label">Oder eigenes Icon hochladen (SVG, PNG, JPG, WEBP)</label>
<input type="file" name="custom_icon_file" class="form-input" accept=".svg,.png,.jpg,.jpeg,.webp">
</div>
<div class="form-group" style="margin-bottom:1rem;">
<label class="form-label">Web-Backend URL (z. B. https://pve.example.com:8006)</label>
<input type="url" name="url" id="monEditUrl" class="form-input" placeholder="https://...">
</div>
<div class="form-group" style="margin-bottom:1rem;">
<label class="form-label">Typ</label>
<select name="type" id="monEditType" class="form-input">
<option value="host">Host / Server (Windows / Linux)</option>
<option value="heartbeat">Dienst / Worker (Heartbeat)</option>
<option value="hypervisor_node">Hypervisor Node (Proxmox)</option>
<option value="guest">Guest VM / Container</option>
</select>
</div>
<div class="form-group" style="margin-bottom:1rem;">
<label class="form-label">Übergeordneter Host / Hypervisor (Parent)</label>
<select name="parent_source" id="monEditParent" class="form-input">
<option value="">-- Keine (Top-Level) --</option>
<?php foreach ($monitors as $pm): ?>
<?php if ($pm['type'] !== 'heartbeat'): ?>
<option value="<?= e($pm['source']) ?>"><?= e($pm['source']) ?> (<?= e($pm['type']) ?>)</option>
<?php endif; ?>
<?php endforeach; ?>
</select>
</div>
<!--
Dieses Feld fehlte im Bearbeiten-Dialog vollstaendig.
Da der Handler trotzdem $_POST['os'] auslas und den
leeren Wert schrieb, loeschte jedes Speichern das
Betriebssystem des Monitors - und damit auch die
automatische Icon-Erkennung.
-->
<div class="form-group" style="margin-bottom:1rem;">
<label class="form-label">Betriebssystem / Plattform</label>
<input type="text" name="os" id="monEditOs" class="form-input"
list="osSuggestions" placeholder="z. B. Ubuntu 24.04 LTS">
<datalist id="osSuggestions">
<option value="Windows Server 2022"></option>
<option value="Windows 11"></option>
<option value="Ubuntu 24.04 LTS"></option>
<option value="Debian 12"></option>
<option value="Proxmox VE 8"></option>
<option value=".NET 8 Service"></option>
<option value="PHP 8.3"></option>
<option value="Python Service"></option>
<option value="Docker Container"></option>
</datalist>
</div>
<div class="form-grid">
<div class="form-group">
<label class="form-label">Gruppe</label>
<input type="text" name="group_key" id="monEditGroup" class="form-input" placeholder="Applications">
</div>
<div class="form-group">
<label class="form-label">Erwartetes Intervall (Sekunden)</label>
<input type="number" name="expected_interval_sec" id="monEditInterval" class="form-input" value="60">
</div>
</div>
<div class="form-group" style="margin-bottom:1rem;">
<label class="form-label">Notizen / Dokumentation</label>
<textarea name="notes" id="monEditNotes" class="form-input" rows="3" placeholder="Zusätzliche Notizen, IPs, Ports oder Ansprechpartner..."></textarea>
</div>
<div style="background:rgba(255,255,255,0.03); border:1px solid var(--border-glass); border-radius:12px; padding:0.85rem 1rem; margin-bottom:1.25rem; display:flex; gap:0.75rem; align-items:flex-start;">
<input type="checkbox" name="is_muted" id="monEditMuted" style="margin-top:3px;">
<div>
<label for="monEditMuted" style="font-weight:700; font-size:0.85rem; color:#fff; cursor:pointer;">🔕 Warnungen & Alarme stummschalten (Dev-Instanz / Wartung)</label>
<div style="font-size:0.75rem; color:var(--text-muted); margin-top:2px;">Aktivieren für Dev- oder Test-Systeme. Der Monitor bleibt sichtbar, sendet aber keine Telegram-/Matrix-Alarme bei Ausfällen.</div>
</div>
</div>
<div style="display:flex; justify-content:space-between; align-items:center;">
<button type="button" class="btn btn-danger btn-sm" onclick="triggerDeleteMonitorFromModal()">🗑️ Monitor Löschen</button>
<div style="display:flex; gap:0.5rem;">
<button type="button" class="btn btn-secondary" onclick="closeEditMonitorModal()">Abbrechen</button>
<button type="submit" class="btn" style="background:#F2622E;">Speichern</button>
</div>
</div>
</form>
</div>
</div>
<!-- Hidden delete form for modal trigger -->
<form id="hiddenDeleteMonitorForm" method="POST" action="index.php#sub-watchdog-hierarchy" style="display:none">
<?= $csrfField ?>
<input type="hidden" name="action" value="delete_watchdog_monitor">
<input type="hidden" name="source" id="hiddenDeleteSource">
</form>
<!-- ================= MODULE 2: LIZENZEN ================= -->
<!--
Zusammengelegt: "Dashboard", "Lizenzverwaltung", "Lizenz Bearbeiten & HW"
und "Settings" waren vier Unteransichten fuer eine Aufgabe. Das
Dashboard zeigte drei Zahlen, Settings zwei fest verdrahtete URLs,
und zum Bearbeiten sprang man in eine eigene Ansicht mit einer
Auswahlliste, in der man die gerade angeklickte Lizenz erneut
suchen musste. Jetzt eine Ansicht mit einem Bearbeiten-Dialog.
-->
<div id="tab-license" class="tab-content">
<div id="sub-license-licenses" class="subtab-content active">
<?php
$activeLicenses = count(array_filter($licenses, static fn($l): bool => $l['status'] === 'active'));
$blockedActivations = count(array_filter($activations, static fn($a): bool => !empty($a['is_blocked'])));
?>
<div class="stats-grid" style="grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));">
<div class="stat-card">
<div class="stat-header">Lizenzen gesamt</div>
<div class="stat-value"><?= count($licenses) ?></div>
</div>
<div class="stat-card">
<div class="stat-header">davon aktiv</div>
<div class="stat-value" style="color:var(--success);"><?= $activeLicenses ?></div>
</div>
<div class="stat-card">
<div class="stat-header">Aktivierte Systeme</div>
<div class="stat-value"><?= count($activations) ?></div>
</div>
<div class="stat-card">
<div class="stat-header">Gesperrte Hardware</div>
<div class="stat-value" style="color:<?= $blockedActivations > 0 ? 'var(--danger)' : 'var(--text-muted)' ?>;"><?= $blockedActivations ?></div>
</div>
</div>
<div class="card">
<div class="card-header"><h2 class="card-title">🔑 Neuen Lizenzschlüssel generieren</h2></div>
<form method="POST" action="index.php#sub-license-licenses">
<?= $csrfField ?>
<input type="hidden" name="action" value="create_license">
<div class="form-grid">
<div class="form-group">
<label class="form-label">Projekt</label>
<select name="product_id" class="form-input" required>
<?php foreach ($projects as $p): ?>
<option value="<?= (int)$p['id'] ?>"><?= e($p['name']) ?> (<?= e($p['slug']) ?>)</option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label class="form-label">Kunden-Name</label>
<input type="text" name="customer_name" class="form-input" placeholder="Musterfirma GmbH">
</div>
<div class="form-group">
<label class="form-label">Kunden-E-Mail</label>
<input type="email" name="customer_email" class="form-input" placeholder="kunde@example.com">
</div>
<div class="form-group">
<label class="form-label">Max. Aktivierungen</label>
<input type="number" name="max_activations" class="form-input" value="2" min="1">
</div>
<div class="form-group">
<label class="form-label">Ablaufdatum (leer = unbegrenzt)</label>
<input type="date" name="expires_at" class="form-input">
</div>
</div>
<button type="submit" class="btn">Lizenz generieren</button>
</form>
</div>
<div class="card">
<div class="card-header"><h2 class="card-title">Erstellte Lizenzschlüssel</h2></div>
<table>
<thead>
<tr>
<th>Projekt</th>
<th>Lizenzschlüssel</th>
<th>Kunde</th>
<th>Aktivierungen</th>
<th>Status</th>
<th>Ablaufdatum</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
<?php if ($licenses === []): ?>
<tr>
<td colspan="7" style="text-align:center; padding:2rem; color:var(--text-muted);">
Noch keine Lizenzen angelegt.
</td>
</tr>
<?php endif; ?>
<?php foreach ($licenses as $l): ?>
<?php
$used = (int)$l['active_count'];
$max = (int)$l['max_activations'];
$full = $max > 0 && $used >= $max;
$licJson = e((string)json_encode($l, JSON_INVALID_UTF8_SUBSTITUTE));
?>
<tr>
<td><?= e($l['product_name']) ?></td>
<td><code style="font-size:0.85rem; font-weight:bold; color:var(--primary);"><?= e($l['license_key']) ?></code></td>
<td>
<?= e($l['customer_name'] ?? '—') ?>
<?php if (!empty($l['customer_email'])): ?>
<div style="font-size:0.72rem; color:var(--text-muted);"><?= e($l['customer_email']) ?></div>
<?php endif; ?>
</td>
<td>
<span class="badge <?= $full ? 'badge-warning' : 'badge-stopped' ?>">
<?= $used ?> / <?= $max ?>
</span>
</td>
<td>
<span class="badge badge-<?= $l['status'] === 'active' ? 'up' : 'down' ?>">
<?= e(strtoupper((string)$l['status'])) ?>
</span>
</td>
<td style="font-size:0.8rem;">
<?= !empty($l['expires_at']) ? e(dc_dt((string)$l['expires_at'], 'd.m.Y')) : 'Unbefristet' ?>
</td>
<td>
<div style="display:flex; gap:0.35rem; flex-wrap:wrap;">
<button type="button" class="btn btn-sm btn-secondary"
onclick="openEditLicense(<?= $licJson ?>)">✏️ Bearbeiten</button>
<a href="index.php?action=download_lic&amp;id=<?= (int)$l['id'] ?>"
class="btn btn-sm btn-secondary" title="Signierte Offline-Lizenzdatei herunterladen">📥 .lic</a>
<form method="POST" action="index.php#sub-license-licenses" style="display:inline">
<?= $csrfField ?>
<input type="hidden" name="action" value="toggle_license_status">
<input type="hidden" name="license_id" value="<?= (int)$l['id'] ?>">
<input type="hidden" name="new_status" value="<?= $l['status'] === 'active' ? 'revoked' : 'active' ?>">
<button type="submit" class="btn btn-sm <?= $l['status'] === 'active' ? 'btn-danger' : 'btn' ?>">
<?= $l['status'] === 'active' ? 'Widerrufen' : 'Aktivieren' ?>
</button>
</form>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<div class="card">
<div class="card-header">
<h2 class="card-title">🖥️ Aktivierte Hardware</h2>
</div>
<table>
<thead>
<tr>
<th>Projekt</th>
<th>Lizenzschlüssel</th>
<th>Hardware-ID / Quelle</th>
<th>Plattform</th>
<th>Hostname</th>
<th>Zuletzt gesehen</th>
<th>Status</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
<?php if ($activations === []): ?>
<tr>
<td colspan="8" style="text-align:center; padding:2rem; color:var(--text-muted);">
Noch keine Aktivierungen.
</td>
</tr>
<?php endif; ?>
<?php foreach ($activations as $a): ?>
<tr>
<td><?= e($a['product_name']) ?></td>
<td><code style="font-size:0.78rem;"><?= e($a['license_key']) ?></code></td>
<td>
<code style="font-size:0.72rem; word-break:break-all;"><?= e($a['hardware_id']) ?></code>
<?php if (!empty($a['hwid_source'])): ?>
<div style="font-size:0.7rem; color:var(--text-muted);">Quelle: <?= e($a['hwid_source']) ?></div>
<?php endif; ?>
</td>
<td>
<span class="badge badge-stopped">
<?= e(strtoupper((string)($a['platform'] ?? 'WIN'))) ?> · v<?= (int)($a['hwid_version'] ?? 1) ?>
</span>
</td>
<td style="font-size:0.8rem;"><?= e($a['hostname'] ?? '—') ?></td>
<td style="font-size:0.78rem; color:var(--text-muted);" title="<?= e(dc_dt($a['last_seen'] ?? null, 'd.m.Y H:i:s')) ?>">
<?= e(dc_ago($a['last_seen'] ?? null)) ?>
</td>
<td>
<span class="badge badge-<?= !empty($a['is_blocked']) ? 'down' : 'up' ?>">
<?= !empty($a['is_blocked']) ? 'GESPERRT' : 'AKTIV' ?>
</span>
</td>
<td>
<div style="display:flex; gap:0.35rem; flex-wrap:wrap;">
<form method="POST" action="index.php#sub-license-licenses" style="display:inline">
<?= $csrfField ?>
<input type="hidden" name="action" value="toggle_block_activation">
<input type="hidden" name="activation_id" value="<?= (int)$a['id'] ?>">
<input type="hidden" name="block_state" value="<?= !empty($a['is_blocked']) ? '0' : '1' ?>">
<button type="submit" class="btn btn-sm <?= !empty($a['is_blocked']) ? 'btn' : 'btn-danger' ?>">
<?= !empty($a['is_blocked']) ? 'Entsperren' : 'Sperren' ?>
</button>
</form>
<form method="POST" action="index.php#sub-license-licenses" style="display:inline"
onsubmit="return confirm('Aktivierung wirklich freigeben? Der Platz wird für ein anderes System frei.');">
<?= $csrfField ?>
<input type="hidden" name="action" value="release_activation">
<input type="hidden" name="activation_id" value="<?= (int)$a['id'] ?>">
<button type="submit" class="btn btn-sm btn-secondary">Freigeben</button>
</form>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<!-- Subtab: Offline Licenses -->
<div id="sub-license-offline" class="subtab-content">
<div class="card">
<div class="card-header"><h2 class="card-title">💾 Offline-Lizenzdatei (.lic)</h2></div>
<p style="color:var(--text-muted); font-size:0.875rem; margin-bottom:1rem; max-width:70ch;">
Für Systeme ohne Internetzugang. Die Datei wird serverseitig erzeugt und mit
HMAC-SHA256 über den Schlüssel aus <code>security.license_key</code> signiert.
Am schnellsten geht es über den 📥&nbsp;.lic-Knopf direkt in der Lizenzliste.
</p>
<div class="form-grid">
<div class="form-group">
<label class="form-label">Lizenz auswählen</label>
<select id="offlineLicSelect" class="form-input">
<?php foreach ($licenses as $l): ?>
<option value="<?= e($l['license_key']) ?>"><?= e($l['license_key']) ?> (<?= e($l['customer_name'] ?? 'ohne Kundenangabe') ?>)</option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label class="form-label">Ziel-Hardware-ID (optional)</label>
<input type="text" id="offlineHwId" class="form-input" placeholder="2:win:a765bd47...">
</div>
</div>
<button class="btn" onclick="generateOfflinePayload()">Vorschau erzeugen</button>
<div style="margin-top:1.5rem;">
<label class="form-label">Vorschau (unsigniert)</label>
<div class="prompt-box" id="offlineResultBox">Lizenz auswählen und auf „Vorschau erzeugen“ klicken.</div>
<p style="color:var(--text-muted); font-size:0.78rem; margin-top:0.5rem;">
Diese Vorschau dient nur der Ansicht. Die gültige, signierte Datei liefert
der 📥&nbsp;.lic-Knopf in der Lizenzliste.
</p>
</div>
</div>
</div>
<!-- Subtab: Audit Log -->
<div id="sub-license-audit" class="subtab-content">
<div class="card">
<div class="card-header"><h2 class="card-title">📜 Audit-Protokoll</h2></div>
<table>
<thead>
<tr>
<th>ID</th>
<th>Zeitpunkt</th>
<th>Akteur</th>
<th>Aktion</th>
<th>Details</th>
</tr>
</thead>
<tbody>
<?php if ($auditLogs === []): ?>
<tr><td colspan="5" style="text-align:center; padding:2rem; color:var(--text-muted);">Noch keine Einträge.</td></tr>
<?php endif; ?>
<?php foreach ($auditLogs as $log): ?>
<tr>
<td><?= (int)$log['id'] ?></td>
<td style="font-size:0.8rem;" title="<?= e((string)$log['created_at']) ?> UTC"><?= e(dc_dt((string)$log['created_at'])) ?></td>
<td><code style="font-size:0.78rem;"><?= e($log['actor']) ?></code></td>
<td><strong><?= e($log['action']) ?></strong></td>
<td><code style="font-size:0.75rem; word-break:break-all;"><?= e($log['details'] ?? '—') ?></code></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<!-- Subtab: Integration -->
<div id="sub-license-integration" class="subtab-content">
<div class="card">
<div class="card-header"><h2 class="card-title">💻 Endpunkte & Anbindung</h2></div>
<div class="form-grid">
<div class="form-group">
<label class="form-label">Prüfung (öffentlich, ohne Token)</label>
<input type="text" readonly class="form-input" value="<?= e($baseUrl) ?>/api/license/v1/validate">
</div>
<div class="form-group">
<label class="form-label">Deaktivierung (authentifiziert)</label>
<input type="text" readonly class="form-input" value="<?= e($baseUrl) ?>/api/license/v1/deactivate">
</div>
</div>
<div class="alert" style="background:rgba(91,157,255,0.10); border:1px solid rgba(91,157,255,0.25); color:#bfdbfe; font-weight:500; font-size:0.82rem; margin-top:1.25rem;">
Die Antwort von <code>/validate</code> trägt den Lizenzzustand im Feld
<code>status</code> auf oberster Ebene (<code>valid</code>, <code>revoked</code>,
<code>expired</code>, <code>not_found</code>, <code>activation_limit</code>) —
bewusst ohne den Umschlag der übrigen API, weil bereits ausgelieferte Clients
dieses Format erwarten.
</div>
<div class="prompt-box" style="margin-top:1rem;">POST <?= e($baseUrl) ?>/api/license/v1/validate
Content-Type: application/json
{
"product": "myapp",
"license_key": "XXXXX-XXXXX-XXXXX-XXXXX-XXXXX",
"hardware_id": "2:win:a765bd47...",
"hostname": "DESKTOP-01",
"app_version": "v1.4.2",
"nonce": "zufallswert"
}
Antwort:
{ "type": "validation_result", "status": "valid",
"expires_at": 1786435199, "cache_ttl_hours": 168,
"message": "License is valid" }</div>
<p style="color:var(--text-muted); font-size:0.82rem; margin-top:1rem;">
Fertige Anbindung für .NET: <code>Deploymentcenter.Client</code>
(<code>LicenseClient</code>, <code>HardwareId</code>, verschlüsselter Offline-Cache).
Ausführlich in <code>docs/LICENSE_INTEGRATION_GUIDE.md</code>.
</p>
</div>
</div>
</div>
<!-- ================= MODULE 3: WATCHDOG ================= -->
<div id="tab-watchdog" class="tab-content">
<!-- Subtab: Watchdog Dashboard -->
<div id="sub-watchdog-dashboard" class="subtab-content active">
<div class="stats-grid">
<div class="stat-card">
<div class="stat-header">Gesund / Up</div>
<div class="stat-value" style="color:var(--success);"><?= $monitorsUp ?></div>
</div>
<div class="stat-card">
<div class="stat-header">Warnungen</div>
<div class="stat-value" style="color:var(--warning);"><?= $monitorsWarning ?></div>
</div>
<div class="stat-card">
<div class="stat-header">Down / Kritisch</div>
<div class="stat-value" style="color:var(--danger);"><?= $monitorsDown ?></div>
</div>
</div>
<!-- Gruppen-Übersicht Panel (Matching Screenshot 2) -->
<div class="card">
<div class="card-header"><h2 class="card-title">📁 Gruppen-Übersicht</h2></div>
<div class="group-cards-grid">
<?php foreach ($groupStats as $gName => $gData): ?>
<?php
$isAllOk = ($gData['ok'] === $gData['total']);
$pillClass = $isAllOk ? 'badge-up' : 'badge-down';
?>
<div class="group-card">
<span class="group-card-name"><?= htmlspecialchars($gName) ?></span>
<span class="badge <?= $pillClass ?>"><?= $gData['ok'] ?>/<?= $gData['total'] ?> OK</span>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
<!-- Subtab: Watchdog Hierarchie & Tree View (Exact requested columns & Edit modal button) -->
<div id="sub-watchdog-hierarchy" class="subtab-content">
<div class="card">
<div class="card-header"><h2 class="card-title">🌳 Watchdog System-Hierarchie</h2></div>
<table>
<thead>
<tr>
<th>Name</th>
<th>Typ</th>
<th>Status</th>
<th>Version</th>
<th>Zuletzt gesehen</th>
<th>Kurz-Info / Message</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
<?php foreach ($hierarchicalMonitors as $m): ?>
<?php
$depth = (int)$m['depth'];
$indentPx = $depth * 24;
$isChild = ($depth > 0);
$iconUrl = getMonitorIconUrl($m['icon']??null, $m['source']??null, $m['os']??null, $m['type']??null);
?>
<tr>
<td>
<div style="display:flex; align-items:center; gap:8px; margin-left:<?= $indentPx ?>px; <?= $isChild ? 'border-left:2px solid var(--primary); padding-left:8px;' : '' ?>">
<?php if ($isChild): ?>
<span style="color:var(--primary); font-family:'Roboto Mono'; font-size:13px; font-weight:bold"><?= $m['is_last'] ? '└─' : '├─' ?></span>
<?php endif; ?>
<img src="<?= htmlspecialchars($iconUrl) ?>" width="18" height="18" alt="icon">
<strong><?= htmlspecialchars($m['source']) ?></strong>
</div>
</td>
<td><code><?= htmlspecialchars($m['type']) ?></code></td>
<td><span class="badge badge-<?= $m['state'] === 'up' ? 'up' : 'down' ?>"><?= strtoupper($m['state']) ?></span></td>
<td>
<?php if (!empty($m['app_version'])): ?>
<code><?= htmlspecialchars((string)$m['app_version']) ?></code>
<?php else: ?>
<span style="color:var(--text-muted);">–</span>
<?php endif; ?>
</td>
<td><?= htmlspecialchars($m['last_seen_utc'] ?? 'Nie') ?></td>
<td><span style="color:var(--text-muted); font-size:0.8rem;"><?= htmlspecialchars($m['last_message'] ?? '-') ?></span></td>
<td style="display:flex; gap:0.35rem;">
<button class="btn btn-sm btn-secondary" onclick="openEditMonitorModal(<?= htmlspecialchars(json_encode($m)) ?>)">✏️ Bearbeiten</button>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<!-- Subtab: Add Monitor Wizard -->
<div id="sub-watchdog-add" class="subtab-content">
<div class="card">
<div class="card-header"><h2 class="card-title">🖥️ Monitor Anlegen</h2></div>
<form method="POST" action="index.php#sub-watchdog-add">
<?= $csrfField ?>
<input type="hidden" name="action" value="add_watchdog_monitor">
<div class="form-grid">
<div class="form-group">
<label class="form-label">Monitor Name (source)</label>
<input type="text" name="source" class="form-input" required placeholder="z. B. srv-app-02">
</div>
<div class="form-group">
<label class="form-label">Typ</label>
<select name="type" class="form-input">
<option value="host">Host / Server (Windows / Linux)</option>
<option value="heartbeat">Dienst / Worker (Heartbeat)</option>
<option value="hypervisor_node">Hypervisor Node (Proxmox)</option>
<option value="guest">Guest VM / Container</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Übergeordnete Entität (Parent Source)</label>
<select name="parent_source" class="form-input">
<option value="">-- Keine (Top Level) --</option>
<?php foreach ($monitors as $pm): ?>
<?php if ($pm['type'] !== 'heartbeat'): ?>
<option value="<?= htmlspecialchars($pm['source']) ?>"><?= htmlspecialchars($pm['source']) ?> (<?= htmlspecialchars($pm['type']) ?>)</option>
<?php endif; ?>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label class="form-label">Betriebssystem / Framework</label>
<select name="os" class="form-input">
<option value="Windows">Windows (PowerShell Task)</option>
<option value="Linux">Linux (Bash Cron)</option>
<option value=".NET 8 Service">.NET 8 Service</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Erwartetes Intervall (Sekunden)</label>
<input type="number" name="interval" class="form-input" value="60">
</div>
<div class="form-group">
<label class="form-label">Gruppe</label>
<input type="text" name="group" class="form-input" value="Infrastructure">
</div>
</div>
<button type="submit" class="btn">Monitor Anlegen & Token Generieren</button>
</form>
</div>
</div>
<!-- Subtab: Watchdog Event Log -->
<div id="sub-watchdog-eventlog" class="subtab-content">
<div class="card">
<div class="card-header"><h2 class="card-title">📜 Chronologisches Event-Log</h2></div>
<table>
<thead>
<tr>
<th>Zeitpunkt (UTC)</th>
<th>Source</th>
<th>Kind</th>
<th>Severity</th>
<th>Nachricht</th>
</tr>
</thead>
<tbody>
<?php foreach ($recentEvents as $e): ?>
<tr>
<td><?= htmlspecialchars($e['at_utc']) ?></td>
<td><strong><?= htmlspecialchars($e['source']) ?></strong></td>
<td><code><?= htmlspecialchars($e['kind']) ?></code></td>
<td><span class="badge badge-<?= $e['severity']==='info'?'up':($e['severity']==='warning'?'warning':'down') ?>"><?= strtoupper($e['severity']) ?></span></td>
<td><?= htmlspecialchars($e['message'] ?? '-') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<!-- Subtab: Agent Tokens -->
<div id="sub-watchdog-tokens" class="subtab-content">
<div class="card">
<div class="card-header"><h2 class="card-title">Neuen Agent-Token erstellen</h2></div>
<form method="POST" action="index.php#sub-watchdog-tokens">
<?= $csrfField ?>
<input type="hidden" name="action" value="create_agent_token">
<div class="form-grid">
<div class="form-group">
<label class="form-label">Bezeichnung / Name</label>
<input type="text" name="token_name" class="form-input" required placeholder="z. B. Infrastructure Server Agent">
</div>
<div class="form-group">
<label class="form-label">Gebunden an Source (optional)</label>
<select name="token_source" class="form-input">
<option value="">-- Alle Sources (Universal) --</option>
<?php foreach ($monitors as $m): ?>
<option value="<?= htmlspecialchars($m['source']) ?>"><?= htmlspecialchars($m['source']) ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
<button type="submit" class="btn">Token Generieren</button>
</form>
</div>
<div class="card">
<div class="card-header"><h2 class="card-title">Agent Tokens</h2></div>
<table>
<thead>
<tr>
<th>Token ID</th>
<th>Bezeichnung</th>
<th>Gebundene Source</th>
<th>Token Value</th>
<th>Status</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
<?php foreach ($agentTokens as $tok): ?>
<?php
$rawVal = !empty($tok['raw_token']) ? $tok['raw_token'] : 'wd_live_token_default';
$masked = substr($rawVal, 0, 8) . '••••••••••••••••';
?>
<tr>
<td><code><?= htmlspecialchars($tok['token_id']) ?></code></td>
<td><strong><?= htmlspecialchars($tok['name']) ?></strong></td>
<td><?= htmlspecialchars($tok['monitor_source'] ?? 'Alle Sources') ?></td>
<td>
<code id="tok-text-<?= $tok['token_id'] ?>" data-full="<?= htmlspecialchars($rawVal) ?>" data-masked="<?= htmlspecialchars($masked) ?>">
<?= htmlspecialchars($masked) ?>
</code>
<button type="button" class="btn btn-sm btn-secondary" onclick="toggleTokenMask('<?= $tok['token_id'] ?>')">👁️</button>
<button type="button" class="btn btn-sm btn-secondary" onclick="copyTokenValue('<?= $tok['token_id'] ?>')">📋</button>
</td>
<td><span class="badge badge-<?= $tok['revoked'] ? 'down' : 'up' ?>"><?= $tok['revoked'] ? 'WIDERUFEN' : 'AKTIV' ?></span></td>
<td>
<?php if (!$tok['revoked']): ?>
<form method="POST" action="index.php#sub-watchdog-tokens" style="display:inline">
<?= $csrfField ?>
<input type="hidden" name="action" value="revoke_agent_token">
<input type="hidden" name="token_id" value="<?= $tok['token_id'] ?>">
<button type="submit" class="btn btn-sm btn-danger">Widerrufen</button>
</form>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
</div>
<!-- ================= MODULE 4: UPDATESERVICE ================= -->
<div id="tab-updateservice" class="tab-content">
<div id="sub-update-releases" class="subtab-content active">
<div class="card">
<div class="card-header">
<h2 class="card-title">📦 Veröffentlichte Software Releases</h2>
</div>
<table>
<thead>
<tr>
<th>Projekt</th>
<th>Kanal</th>
<th>Plattform</th>
<th>Version</th>
<th>Git Commit</th>
<th>Größe</th>
<th>Release Notes</th>
<th>Download & SHA256</th>
<th>Datum</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
<?php foreach ($releases as $r): ?>
<?php
$channelClass = match($r['channel'] ?? 'prod') {
'prod' => 'badge-up',
'beta' => 'badge-warning',
'dev' => 'badge-stopped',
default => 'badge-up'
};
$sizeFormatted = !empty($r['size_bytes']) ? round($r['size_bytes'] / (1024 * 1024), 2) . ' MB' : '-';
?>
<tr>
<td><strong><?= htmlspecialchars($r['product_slug']) ?></strong></td>
<td><span class="badge <?= $channelClass ?>"><?= strtoupper(htmlspecialchars($r['channel'] ?? 'prod')) ?></span></td>
<td>
<?php $platformLabel = (string)($r['platform'] ?? 'any'); ?>
<code style="font-size:0.75rem;<?= $platformLabel === 'any' ? 'color:var(--text-muted);' : '' ?>"><?= htmlspecialchars($platformLabel) ?></code>
</td>
<td>
<code>v<?= htmlspecialchars($r['version']) ?></code>
<?php if (!empty($r['is_critical'])): ?><span class="badge badge-down">KRITISCH</span><?php endif; ?>
<?php if (empty($r['manifest_signature'])): ?><span class="badge badge-warning" title="Ohne Signatur - der Agent kann die Herkunft nicht prüfen.">UNSIGNIERT</span><?php endif; ?>
</td>
<td><code style="color:var(--text-muted);"><?= htmlspecialchars($r['git_commit'] ?? 'n/a') ?></code></td>
<td><span style="font-family:'Roboto Mono', monospace; font-size:0.8rem;"><?= $sizeFormatted ?></span></td>
<td><?= htmlspecialchars($r['release_notes'] ?? '-') ?></td>
<td>
<a href="<?= htmlspecialchars($r['download_url']) ?>" target="_blank" class="btn btn-sm btn-secondary">
⬇️ Download Package
</a>
<?php if (!empty($r['sha256_hash'])): ?>
<div style="font-family:'Roboto Mono', monospace; font-size:0.65rem; color:var(--text-muted); margin-top:2px;" title="<?= htmlspecialchars($r['sha256_hash']) ?>">
SHA: <?= htmlspecialchars(substr($r['sha256_hash'], 0, 12)) ?>...
</div>
<?php endif; ?>
</td>
<td><?= htmlspecialchars($r['created_at']) ?></td>
<td style="white-space:nowrap;">
<button type="button" class="btn btn-sm btn-secondary" onclick="openEditReleaseModal(<?= htmlspecialchars(json_encode($r, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP)) ?>)" title="Release bearbeiten">✏️ Bearbeiten</button>
<button type="button" class="btn btn-sm btn-danger" onclick="openDeleteReleaseModal(<?= (int)$r['id'] ?>, '<?= htmlspecialchars(addslashes($r['version'])) ?>', '<?= htmlspecialchars(addslashes($r['product_slug'])) ?>')" title="Release löschen">🗑️ Löschen</button>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<div id="sub-update-guard" class="subtab-content">
<div class="card">
<div class="card-header"><h2 class="card-title">🔒 Zugangsschutz der Releases</h2></div>
<p style="color:var(--text-muted); font-size:0.875rem; margin-bottom:1.25rem;">
Die Release-Verzeichnisse liegen hinter HTTP-Basic-Auth. Zugang hat, wer
einen <strong>gültigen Lizenzschlüssel</strong> für das jeweilige Produkt
besitzt — Benutzername und Passwort sind beide der Schlüssel. Die Anwendung
kennt ihn ohnehin und versorgt sich damit selbst. Zusätzlich stehen die
<strong>Installationskonten</strong> in jeder Datei: bei einer
Erstinstallation gibt es noch keinen Schlüssel.
</p>
<div class="alert" style="background:rgba(91,157,255,0.12); border:1px solid rgba(91,157,255,0.3); color:#bfdbfe; margin-bottom:1.25rem;">
<strong>Je Produkt eine eigene Datei.</strong> Eine gemeinsame Liste würde
bedeuten, dass eine Lizenz für Produkt A auch Produkt B öffnet. Die Zuordnung
ergibt sich aus <code>license_licenses.product_id</code>.
</div>
<table>
<thead>
<tr>
<th>Produkt</th>
<th>Status</th>
<th>Zugänge</th>
<th>Zuletzt erzeugt</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
<?php if ($releaseGuard === []): ?>
<tr><td colspan="5" style="color:var(--text-muted);">Es liegen noch keine Release-Verzeichnisse vor.</td></tr>
<?php endif; ?>
<?php foreach ($releaseGuard as $g): ?>
<?php $isExempt = !empty($g['exempt']); ?>
<tr>
<td><strong><?= e((string)$g['slug']) ?></strong></td>
<td>
<?php if ($isExempt): ?>
<span class="badge badge-down">AUSGENOMMEN</span>
<?php elseif (!$g['protected']): ?>
<span class="badge badge-down">OFFEN</span>
<?php elseif ((int)$g['entries'] === 0): ?>
<span class="badge badge-warning">GESPERRT</span>
<?php else: ?>
<span class="badge badge-up">GESCHÜTZT</span>
<?php endif; ?>
<?php if (!$g['writable']): ?>
<span class="badge badge-down" title="PHP kann hier nicht schreiben">NICHT SCHREIBBAR</span>
<?php endif; ?>
</td>
<td><?= (int)$g['entries'] ?></td>
<td style="font-size:0.8rem; color:var(--text-muted);">
<?= $g['updated_at'] !== null ? e((string)$g['updated_at']) : '—' ?>
</td>
<td>
<form method="POST" action="index.php" style="display:inline;"
<?= $isExempt ? '' : 'onsubmit="return confirm(\'Damit kann jeder im Internet die Pakete von ' . e((string)$g['slug']) . ' herunterladen. Wirklich?\');"' ?>>
<?= $csrfField ?>
<input type="hidden" name="action" value="toggle_release_guard_exempt">
<input type="hidden" name="_return" value="sub-update-guard">
<input type="hidden" name="slug" value="<?= e((string)$g['slug']) ?>">
<input type="hidden" name="exempt" value="<?= $isExempt ? '0' : '1' ?>">
<button type="submit" class="btn btn-sm <?= $isExempt ? '' : 'btn-secondary' ?>">
<?= $isExempt ? 'Schutz aktivieren' : 'Ausnehmen' ?>
</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php if ($releaseGuardTest !== null): ?>
<?php $testFailed = !empty($releaseGuardTest['failed']); ?>
<div class="alert" style="margin-top:1rem; <?= $testFailed
? 'background:rgba(255,70,70,0.12); border:1px solid rgba(255,70,70,0.35); color:#fecaca;'
: 'background:rgba(60,200,120,0.12); border:1px solid rgba(60,200,120,0.3); color:#bbf7d0;' ?>">
<strong>Selbsttest vom <?= e((string)($releaseGuardTest['ran_at'] ?? '?')) ?> UTC:</strong>
<?= (int)($releaseGuardTest['ok'] ?? 0) ?> von
<?= (int)($releaseGuardTest['checked'] ?? 0) ?> Adresse(n) antworten ohne
Zugangsdaten korrekt mit 401.
<?php if ($testFailed): ?>
<ul style="margin:0.5rem 0 0 1rem;">
<?php foreach ($releaseGuardTest['failed'] as $f): ?>
<li><strong><?= e((string)($f['slug'] ?? '?')) ?>:</strong> <?= e((string)($f['reason'] ?? '')) ?></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</div>
<?php endif; ?>
<p style="color:var(--text-muted); font-size:0.8rem; margin-top:1rem;">
<strong>Der Selbsttest ist die einzige belastbare Aussage.</strong> Dass die
Dateien vorhanden sind, beweist nichts: Unter Nginx werden
<code>.htaccess</code>-Dateien vollständig ignoriert — die Verzeichnisse wären
dann offen und die <code>.htpasswd</code> öffentlich abrufbar, während hier
„GESCHÜTZT“ stünde. Der Test ruft die eigene Paket-Adresse ohne Zugangsdaten
ab und erwartet 401.
</p>
<p style="color:var(--text-muted); font-size:0.8rem; margin-top:1rem;">
<strong>AUSGENOMMEN</strong> heißt: bewusst offen gelassen — für Produkte,
deren ausgelieferte Installationen noch mit einem SDK vor 2.4 gebaut sind und
deshalb keinen Lizenzschlüssel mitschicken. Sie bekämen sonst 401 statt
Updates. <strong>Sobald ein Build mit Schlüssel draußen ist, wieder
einschalten.</strong><br>
<strong>OFFEN</strong> heißt: jeder im Internet kann die Pakete laden.
<strong>GESPERRT</strong> heißt: niemand kommt heran, weil es weder gültige
Lizenzen noch Installationskonten gibt — bei internen Anwendungen ohne
Lizenzen ist das der Normalfall, solange kein Installationskonto existiert.
</p>
<form method="POST" action="index.php" style="margin-top:1.25rem;">
<?= $csrfField ?>
<input type="hidden" name="action" value="regenerate_release_guard">
<input type="hidden" name="_return" value="sub-update-guard">
<button type="submit" class="btn">Zugangsschutz jetzt neu erzeugen</button>
</form>
<p style="color:var(--text-muted); font-size:0.8rem; margin-top:1rem;">
Geschieht automatisch bei jeder Lizenz- und Kontoänderung. Abgelaufene
Lizenzen lösen von sich aus nichts aus — dafür gleicht
<code>cli/tick.php</code> nach, spätestens alle sechs Stunden.
</p>
</div>
</div>
<div id="sub-update-installer" class="subtab-content">
<div class="card">
<div class="card-header"><h2 class="card-title">⬇️ Installer herunterladen</h2></div>
<p style="color:var(--text-muted); font-size:0.875rem; margin-bottom:1.25rem;">
Ein Werkzeug für alles: Dasselbe Programm richtet eine Anwendung erstmalig
ein und aktualisiert sie später. Es bringt die .NET-Laufzeit mit — auf dem
Zielsystem muss nichts vorinstalliert sein.
</p>
<?php if ($installerManifest === null): ?>
<div class="alert" style="background:rgba(255,180,0,0.12); border:1px solid rgba(255,180,0,0.3); color:#fde68a; font-weight:500;">
<strong>Noch nichts hinterlegt.</strong> Die Binaries werden mit
<code>scripts/build_installer.ps1</code> gebaut und mit
<code>scripts/upload_installer.py</code> nach <code>/installer/</code>
hochgeladen.
</div>
<?php else: ?>
<div style="font-size:0.8rem; color:var(--text-muted); margin-bottom:1rem;">
Version <strong><?= e((string)($installerManifest['version'] ?? '?')) ?></strong>
· Build <code><?= e((string)($installerManifest['gitCommit'] ?? '?')) ?></code>
· <?= e(substr((string)($installerManifest['buildDateUtc'] ?? ''), 0, 10)) ?>
</div>
<table>
<thead>
<tr>
<th>Plattform</th>
<th>Größe</th>
<th>SHA256</th>
<th>Download</th>
</tr>
</thead>
<tbody>
<?php foreach ($installerManifest['binaries'] as $bin): ?>
<?php
$binFile = (string)($bin['file'] ?? '');
$binHash = (string)($bin['sha256'] ?? '');
$binUrl = $baseUrl . '/installer/' . rawurlencode($binFile);
?>
<tr>
<td><code><?= e((string)($bin['platform'] ?? '?')) ?></code></td>
<td style="font-family:'Roboto Mono',monospace; font-size:0.8rem;">
<?= number_format(((int)($bin['sizeBytes'] ?? 0)) / (1024 * 1024), 1, ',', '.') ?> MB
</td>
<td>
<code style="font-size:0.65rem; color:var(--text-muted); word-break:break-all;" title="<?= e($binHash) ?>">
<?= e(substr($binHash, 0, 24)) ?>…
</code>
</td>
<td>
<a href="<?= e($binUrl) ?>" class="btn btn-sm">⬇️ Herunterladen</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<div class="alert" style="background:rgba(91,157,255,0.12); border:1px solid rgba(91,157,255,0.3); color:#bfdbfe; margin-top:1rem;">
<strong>Prüfsumme vergleichen.</strong> „Programm herunterladen und
Zugangsdaten eingeben“ ist genau das Muster, das Phishing nachahmt.
Die vollständigen Hashes stehen im Tooltip und unter
<a href="<?= e($baseUrl) ?>/installer/installer.json" target="_blank" rel="noopener" style="color:#bfdbfe;">installer.json</a>.
</div>
<?php endif; ?>
</div>
<div class="card">
<div class="card-header"><h2 class="card-title">Direkt auf dem Zielsystem</h2></div>
<p style="color:var(--text-muted); font-size:0.875rem; margin-bottom:0.75rem;">
<strong>Linux</strong> — lädt das passende Binary, prüft die Prüfsumme und legt es ab:
</p>
<div class="prompt-box">wget -qO- <?= e($baseUrl) ?>/installer/install.sh | sh</div>
<p style="color:var(--text-muted); font-size:0.875rem; margin:1rem 0 0.75rem;">
Oder ohne Skript, direkt das Binary:
</p>
<div class="prompt-box">wget <?= e($baseUrl) ?>/installer/update-agent-linux-x64 -O update-agent
chmod +x update-agent
./update-agent --action install</div>
<p style="color:var(--text-muted); font-size:0.875rem; margin:1rem 0 0.75rem;">
<strong>Windows</strong> (PowerShell):
</p>
<div class="prompt-box">irm <?= e($baseUrl) ?>/installer/install.ps1 | iex</div>
<p style="color:var(--text-muted); font-size:0.8rem; margin-top:1rem;">
Die Skripte richten <strong>nichts</strong> von selbst ein — sie laden, prüfen
und sagen, wie es weitergeht. Ein Skript aus dem Netz, das ungefragt eine
Anwendung aufsetzt und dabei nach Zugangsdaten fragt, wäre genau das, wovor
man Nutzer sonst warnt.
</p>
<p style="color:var(--text-muted); font-size:0.875rem; margin:1rem 0 0.75rem;">
Nur die <strong>Maschine überwachen</strong>, ohne etwas zu installieren —
legt Monitor und Token an, schreibt das Agent-Skript und plant es ein:
</p>
<div class="prompt-box">./update-agent --action monitor</div>
<p style="color:var(--text-muted); font-size:0.8rem; margin-top:0.75rem;">
Für die Installation wird ein Konto der Rolle <strong>installer</strong>
gebraucht — anzulegen unter <em>System → 👤 Benutzer</em>. Ein
Administratorkonto funktioniert auch, gehört aber nicht auf ein Zielsystem:
seine Zugangsdaten würden damit auf jedem Rechner landen, auf dem je etwas
installiert wurde.
</p>
</div>
</div>
<div id="sub-update-publish" class="subtab-content">
<div class="card">
<div class="card-header"><h2 class="card-title">Neues Software-Release veröffentlichen</h2></div>
<form method="POST" action="index.php#sub-update-publish">
<?= $csrfField ?>
<input type="hidden" name="action" value="add_release">
<div class="form-grid">
<div class="form-group">
<label class="form-label">Projekt-Slug</label>
<select name="product_slug" class="form-input" required>
<?php foreach ($projects as $p): ?>
<option value="<?= htmlspecialchars($p['slug']) ?>"><?= htmlspecialchars($p['name']) ?> (<?= htmlspecialchars($p['slug']) ?>)</option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label class="form-label">Release Kanal</label>
<select name="channel" class="form-input" required>
<option value="prod" selected>prod (Produktiv)</option>
<option value="beta">beta (Vorab-Test)</option>
<option value="dev">dev (Entwicklung)</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Plattform</label>
<select name="platform" class="form-input">
<option value="any" selected>any (plattformunabhängig)</option>
<option value="win-x64">win-x64</option>
<option value="win-arm64">win-arm64</option>
<option value="linux-x64">linux-x64</option>
<option value="linux-arm64">linux-arm64</option>
<option value="linux-musl-x64">linux-musl-x64</option>
<option value="osx-x64">osx-x64</option>
<option value="osx-arm64">osx-arm64</option>
</select>
<div style="font-size:0.7rem; color:var(--text-muted); margin-top:0.25rem;">
Clients ohne Plattformangabe sehen ausschließlich <code>any</code>.
</div>
</div>
<div class="form-group">
<label class="form-label">Version (z. B. 1.2.0)</label>
<input type="text" name="version" class="form-input" required placeholder="1.2.0">
</div>
<div class="form-group">
<label class="form-label">Git Commit (Kurz-Hash)</label>
<input type="text" name="git_commit" class="form-input" placeholder="a1b2c3d">
</div>
<div class="form-group">
<label class="form-label">Download URL</label>
<input type="url" name="download_url" class="form-input" required placeholder="https://dc.mhdf.de/releases/myapp/prod/1.2.0/package.tar.gz">
</div>
<div class="form-group">
<label class="form-label">SHA256 Hash (Optional)</label>
<input type="text" name="sha256_hash" class="form-input" placeholder="e3b0c44298fc1c149afbf4c8996fb924...">
</div>
</div>
<div class="form-group" style="margin-bottom:1rem;">
<label class="form-label">Release Notes / Changelog</label>
<textarea name="release_notes" class="form-input" rows="3" placeholder="Changelog und Verbesserungen..."></textarea>
</div>
<div style="margin-bottom:1.25rem;">
<label style="display:inline-flex; align-items:center; gap:0.5rem; font-size:0.875rem; cursor:pointer;">
<input type="checkbox" name="is_critical" value="1"> Kritisches Sicherheits-Update (Rollout priorisieren)
</label>
</div>
<button type="submit" class="btn">Release Speichern & Freigeben</button>
</form>
</div>
</div>
<!-- Edit Release Modal -->
<div class="modal-backdrop" id="editReleaseModal">
<div class="modal-card">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:1.5rem;">
<h3 style="margin:0;">✏️ Release bearbeiten</h3>
<button type="button" onclick="closeEditReleaseModal()" style="background:none; border:none; color:var(--text-muted); font-size:1.25rem; cursor:pointer;">&times;</button>
</div>
<form method="POST" action="index.php#sub-update-releases">
<?= $csrfField ?>
<input type="hidden" name="action" value="edit_release">
<input type="hidden" name="release_id" id="edit_release_id" value="">
<div class="form-grid">
<div class="form-group">
<label class="form-label">Projekt-Slug</label>
<select name="product_slug" id="edit_release_product_slug" class="form-input" required>
<?php foreach ($projects as $p): ?>
<option value="<?= htmlspecialchars($p['slug']) ?>"><?= htmlspecialchars($p['name']) ?> (<?= htmlspecialchars($p['slug']) ?>)</option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label class="form-label">Release Kanal</label>
<select name="channel" id="edit_release_channel" class="form-input" required>
<option value="prod">prod (Produktiv)</option>
<option value="beta">beta (Vorab-Test)</option>
<option value="dev">dev (Entwicklung)</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Plattform</label>
<select name="platform" id="edit_release_platform" class="form-input">
<option value="any">any (plattformunabhängig)</option>
<option value="win-x64">win-x64</option>
<option value="win-arm64">win-arm64</option>
<option value="linux-x64">linux-x64</option>
<option value="linux-arm64">linux-arm64</option>
<option value="linux-musl-x64">linux-musl-x64</option>
<option value="osx-x64">osx-x64</option>
<option value="osx-arm64">osx-arm64</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Version (z. B. 1.2.0)</label>
<input type="text" name="version" id="edit_release_version" class="form-input" required placeholder="1.2.0">
</div>
<div class="form-group">
<label class="form-label">Git Commit (Kurz-Hash)</label>
<input type="text" name="git_commit" id="edit_release_git_commit" class="form-input" placeholder="a1b2c3d">
</div>
<div class="form-group">
<label class="form-label">Download URL</label>
<input type="url" name="download_url" id="edit_release_download_url" class="form-input" required placeholder="https://...">
</div>
<div class="form-group">
<label class="form-label">SHA256 Hash (Optional)</label>
<input type="text" name="sha256_hash" id="edit_release_sha256_hash" class="form-input" placeholder="e3b0c44298fc1c149afbf4c8996fb924...">
</div>
<div class="form-group">
<label class="form-label">Größe (Bytes)</label>
<input type="number" name="size_bytes" id="edit_release_size_bytes" class="form-input" min="0" placeholder="0">
</div>
</div>
<div class="form-group" style="margin-bottom:1rem;">
<label class="form-label">Release Notes / Changelog</label>
<textarea name="release_notes" id="edit_release_release_notes" class="form-input" rows="3" placeholder="Changelog..."></textarea>
</div>
<div style="margin-bottom:1.25rem;">
<label style="display:inline-flex; align-items:center; gap:0.5rem; font-size:0.875rem; cursor:pointer;">
<input type="checkbox" name="is_critical" id="edit_release_is_critical" value="1"> Kritisches Sicherheits-Update (Rollout priorisieren)
</label>
</div>
<div style="display:flex; justify-content:flex-end; gap:0.75rem;">
<button type="button" class="btn btn-secondary" onclick="closeEditReleaseModal()">Abbrechen</button>
<button type="submit" class="btn">Änderungen Speichern</button>
</div>
</form>
</div>
</div>
<!-- Delete Release Modal -->
<div class="modal-backdrop" id="deleteReleaseModal">
<div class="modal-card" style="max-width:480px;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:1.5rem;">
<h3 style="margin:0; color:#ef4444;">🗑️ Release löschen</h3>
<button type="button" onclick="closeDeleteReleaseModal()" style="background:none; border:none; color:var(--text-muted); font-size:1.25rem; cursor:pointer;">&times;</button>
</div>
<p>Möchten Sie das Release <strong id="delete_release_version_display"></strong> für <strong id="delete_release_product_display"></strong> wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.</p>
<form method="POST" action="index.php#sub-update-releases" style="margin-top:1.5rem;">
<?= $csrfField ?>
<input type="hidden" name="action" value="delete_release">
<input type="hidden" name="release_id" id="delete_release_id" value="">
<div style="display:flex; justify-content:flex-end; gap:0.75rem;">
<button type="button" class="btn btn-secondary" onclick="closeDeleteReleaseModal()">Abbrechen</button>
<button type="submit" class="btn btn-danger">Release unwiderruflich löschen</button>
</div>
</form>
</div>
</div>
</div>
<!-- ================= MODULE: ROADMAP ================= -->
<!--
Kein eigener Datenbestand. Ein Roadmap-Eintrag ist ein
bugtracker_item mit gesetztem Themenbereich; diese Ansicht ist
die Sicht darauf. Wer hier etwas aendert, aendert dasselbe
Item, das der Bugtracker zeigt - eine zweite Wahrheit ueber
denselben Vorgang soll gar nicht erst entstehen koennen.
-->
<div id="tab-roadmap" class="tab-content">
<!-- =============== Subtab: Tafel =============== -->
<!--
Die Tafel wird nicht serverseitig gerendert, sondern aus
/api/bugtracker/v1/roadmap?action=board geholt. Grund: Die
fertige Gliederung - Spalten, Spuren, Zustaende - soll aus
genau einer Quelle kommen. Sonst schneidet die Seite
anders als die API, und ein spaeterer Export ein drittes
Mal anders.
-->
<div id="sub-roadmap-tafel" class="subtab-content active">
<div class="card" style="padding:0.85rem 1.1rem;">
<div style="display:flex; flex-wrap:wrap; gap:0.6rem; align-items:center;">
<select id="tafelProjekt" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;">
<?php foreach ($projects as $p): ?>
<option value="<?= e($p['slug']) ?>" <?= $rmProject === $p['slug'] ? 'selected' : '' ?>>
<?= e($p['name']) ?>
</option>
<?php endforeach; ?>
</select>
<label style="font-size:0.8rem; color:var(--text-muted); display:flex; align-items:center; gap:0.35rem;">
Erledigte zeigen:
<select id="tafelDoneLimit" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;">
<option value="12" selected>die letzten 12</option>
<option value="30">die letzten 30</option>
<option value="0">alle</option>
</select>
</label>
<button type="button" class="btn btn-sm btn-secondary" id="tafelNeuLaden">Neu laden</button>
<span id="tafelStand" style="font-size:0.78rem; color:var(--text-muted); margin-left:auto;"></span>
</div>
</div>
<div id="tafelHinweis"></div>
<div class="stats-grid" id="tafelStats"
style="grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));"></div>
<div class="card" id="tafelLegende" style="padding:0.75rem 1.1rem; font-size:0.8rem;"></div>
<div class="tafel-scroller">
<div class="tafel-track" id="tafelTrack">
<div style="padding:2rem; color:var(--text-muted);">Tafel wird geladen …</div>
</div>
</div>
</div>
<!-- =============== Subtab: Übersicht =============== -->
<div id="sub-roadmap-board" class="subtab-content">
<div class="stats-grid" style="grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));">
<div class="stat-card">
<div class="stat-header">📋 Offen</div>
<div class="stat-value" style="color:#5b9dff;"><?= $roadmapStats['pending'] ?></div>
</div>
<div class="stat-card">
<div class="stat-header">🔨 In Arbeit</div>
<div class="stat-value" style="color:var(--warning);"><?= $roadmapStats['working'] ?></div>
</div>
<div class="stat-card">
<div class="stat-header">✅ Erledigt</div>
<div class="stat-value" style="color:var(--success);"><?= $roadmapStats['done'] ?></div>
</div>
<div class="stat-card">
<div class="stat-header">🧊 Zurückgestellt</div>
<div class="stat-value" style="color:var(--text-muted);"><?= $roadmapStats['deferred'] ?></div>
</div>
<div class="stat-card">
<div class="stat-header">❌ Verworfen</div>
<div class="stat-value" style="color:var(--text-muted);"><?= $roadmapStats['rejected'] ?></div>
</div>
<div class="stat-card">
<div class="stat-header">🤖 Belegt</div>
<div class="stat-value" style="color:#66bb6a;"><?= $roadmapStats['claimed'] ?></div>
</div>
</div>
<div class="card" style="padding:0.85rem 1.1rem;">
<form method="GET" action="index.php" style="display:flex; flex-wrap:wrap; gap:0.45rem; align-items:center;">
<!-- Die offene Ansicht faehrt als verstecktes Feld mit. Ein GET-Formular
verwirft beim Absenden den Anker seiner action, und der Anker war
bisher die einzige Quelle dafuer, was angezeigt wird - nach jeder
Filteraenderung stand man wieder im Dashboard. Fest im Markup statt
aus dem DOM gelesen: hier ist ohne Zweifel bekannt, wo das Formular
steht. -->
<input type="hidden" name="view" value="sub-roadmap-board">
<select name="rm_project" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="dcSubmit(this.form)">
<option value="all">🌐 Alle Projekte</option>
<?php foreach ($projects as $p): ?>
<option value="<?= e($p['slug']) ?>" <?= $rmProject === $p['slug'] ? 'selected' : '' ?>>
<?= e($p['name']) ?>
</option>
<?php endforeach; ?>
</select>
<select name="rm_lage" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="dcSubmit(this.form)">
<?php
// Voreinstellung ist "aktiv" und nicht "alle": Eine Roadmap,
// die mit sechzig erledigten Eintraegen beginnt, beantwortet
// die Frage nicht, fuer die man sie aufschlaegt.
$rmLageOptions = [
'aktiv' => '🎯 Aktiv (offen + in Arbeit)',
'offen' => '📋 Nur offen',
'arbeit' => '🔨 Nur in Arbeit',
'erledigt' => '✅ Erledigt',
'zurueckgestellt' => '🧊 Zurückgestellt',
'verworfen' => '❌ Verworfen',
'all' => '📌 Alle Lagen',
];
foreach ($rmLageOptions as $value => $label): ?>
<option value="<?= e($value) ?>" <?= $rmLage === $value ? 'selected' : '' ?>><?= e($label) ?></option>
<?php endforeach; ?>
</select>
<select name="rm_epic" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="dcSubmit(this.form)">
<option value="all">🗂️ Alle Bereiche</option>
<?php foreach ($roadmapEpics as $rmE): ?>
<option value="<?= e((string)$rmE['epic']) ?>" <?= $rmEpic === (string)$rmE['epic'] ? 'selected' : '' ?>>
<?= e((string)$rmE['epic']) ?> — <?= e((string)$rmE['title']) ?>
</option>
<?php endforeach; ?>
</select>
<select name="rm_build" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="dcSubmit(this.form)">
<option value="all">🎯 Alle Zielfassungen</option>
<?php foreach ($roadmapBuilds as $rmB): ?>
<option value="<?= e((string)$rmB) ?>" <?= $rmBuild === (string)$rmB ? 'selected' : '' ?>><?= e((string)$rmB) ?></option>
<?php endforeach; ?>
</select>
<input type="text" name="rm_agent" value="<?= e($rmAgent) ?>" placeholder="🤖 Agent"
class="form-input" style="width:130px; padding:0.3rem 0.6rem; font-size:0.8rem;">
<input type="text" name="rm_q" value="<?= e($rmSearch) ?>" placeholder="🔍 Suche"
class="form-input" style="width:170px; padding:0.3rem 0.6rem; font-size:0.8rem;">
<button type="submit" class="btn btn-sm">Filtern</button>
<a href="index.php#tab-roadmap" class="btn btn-sm btn-secondary">Zurücksetzen</a>
</form>
</div>
<?php if ($roadmapOrphans !== []): ?>
<!--
Ein Bereich, den ein Item nennt und den es in
roadmap_epics nicht gibt. Meist ein Tippfehler - und
der erzeugt sonst lautlos einen weiteren Bereich, der
in keiner Uebersicht auftaucht.
-->
<div class="card" style="padding:0.9rem 1.15rem; border-left:3px solid var(--danger);">
<strong style="font-size:0.9rem;">⚠️ Unbekannte Themenbereiche</strong>
<div style="margin-top:0.5rem; display:flex; flex-wrap:wrap; gap:0.5rem;">
<?php foreach ($roadmapOrphans as $orphan): ?>
<span class="badge badge-down">
<?= e((string)$orphan['project_slug']) ?> / <?= e((string)$orphan['epic']) ?>
&middot; <?= (int)$orphan['items'] ?> Eintrag/Einträge
</span>
<?php endforeach; ?>
</div>
<div style="margin-top:0.5rem; font-size:0.8rem; color:var(--text-muted);">
Diese Kürzel stehen an Items, sind aber nicht angelegt. Unter
„Themenbereiche“ anlegen oder die Items umhängen.
</div>
</div>
<?php endif; ?>
<?php if ($roadmapNext !== []): ?>
<div class="card">
<div class="card-header">
<h2 class="card-title">🎯 Als Nächstes</h2>
</div>
<!--
Diese Liste stand frueher von Hand oben in
ROADMAP.md und veraltete zuverlaessig. Sie ergibt
sich jetzt aus Reihenfolge und Status.
-->
<table>
<thead>
<tr><th style="width:40px;">#</th><th>Vorhaben</th><th>Bereich</th><th>Stand</th><th>Ziel</th><th>Wer</th></tr>
</thead>
<tbody>
<?php foreach ($roadmapNext as $i => $item): ?>
<tr>
<td style="color:var(--text-muted);"><?= $i + 1 ?>.</td>
<td>
<span style="font-family:'Roboto Mono'; color:var(--text-muted); font-size:0.8rem;">#<?= (int)$item['id'] ?></span>
<strong style="margin-left:0.35rem;"><?= e((string)$item['title']) ?></strong>
<?php if ($rmProject === 'all'): ?>
<span class="badge badge-stopped" style="margin-left:0.4rem;"><?= e((string)$item['project_slug']) ?></span>
<?php endif; ?>
</td>
<td><?= e((string)$item['epic']) ?></td>
<td><?= rmBadge((string)$item['status']) ?></td>
<td><?= $item['target_build'] ? '<code>' . e((string)$item['target_build']) . '</code>' : '—' ?></td>
<td><?= $item['claimed_by'] ? e((string)$item['claimed_by']) : '<span style="color:var(--text-muted);">frei</span>' ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
<div class="card">
<div class="card-header">
<h2 class="card-title">🤖 Wer arbeitet gerade woran</h2>
</div>
<?php if ($roadmapActive === []): ?>
<p style="color:var(--text-muted); padding:0.75rem 0;">
Zurzeit hält kein Agent ein Item. Ohne aktive Lease ist jedes
Vorhaben frei — das ist der Normalzustand zwischen zwei Läufen.
</p>
<?php else: ?>
<table>
<thead>
<tr><th>Item</th><th>Projekt</th><th>Bereich</th><th>Stand</th><th>Agent</th><th>Seit</th><th>Lease läuft</th></tr>
</thead>
<tbody>
<?php foreach ($roadmapActive as $work): ?>
<tr>
<td>
<span style="font-family:'Roboto Mono'; color:var(--text-muted); font-size:0.8rem;">#<?= (int)$work['id'] ?></span>
<span style="margin-left:0.35rem;"><?= e((string)$work['title']) ?></span>
</td>
<td><span class="badge badge-stopped"><?= e((string)$work['project_slug']) ?></span></td>
<td><?= $work['epic'] ? e((string)$work['epic']) : '<span style="color:var(--text-muted);">—</span>' ?></td>
<td><?= rmBadge((string)$work['status']) ?></td>
<td><strong><?= e((string)$work['claimed_by']) ?></strong></td>
<td style="color:var(--text-muted);"><?= e(dc_ago((string)$work['claimed_at'])) ?></td>
<td>
<?php $left = (int)$work['lease_minutes_left']; ?>
<span class="badge <?= $left < 15 ? 'badge-down' : 'badge-warning' ?>">
noch <?= $left ?> min
</span>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<div class="card">
<div class="card-header">
<h2 class="card-title">🗂️ Themenbereiche im Überblick</h2>
</div>
<table>
<thead>
<tr>
<th style="width:60px;">Kürzel</th><th>Bereich</th>
<?php if ($rmProject === 'all'): ?><th>Projekt</th><?php endif; ?>
<!--
Spaltenköpfe aus Zeichen brauchen eine Erklärung.
Wer die Legende oben nicht gelesen hat - und das ist
der Normalfall - rät sonst, was 🧊 bedeutet.
title= genügt: ein eigenes Bedienelement für vier
Wörter wäre mehr Aufwand als Nutzen.
-->
<th class="hilfe" style="width:220px;"
title="Erledigt im Verhältnis zum tatsächlichen Umfang. Zurückgestelltes und Verworfenes zählt nicht mit — ein Bereich, in dem die Hälfte verworfen wurde, ist nicht zur Hälfte fertig, er ist kleiner geworden.">Fortschritt</th>
<th class="hilfe" style="width:90px;" title="Offen — geplant, noch niemand dran">📋</th>
<th class="hilfe" style="width:90px;" title="In Arbeit — ein Agent oder Mensch hält das Item gerade">🔨</th>
<th class="hilfe" style="width:90px;" title="Erledigt und ausgeliefert">✅</th>
<th class="hilfe" style="width:90px;" title="Zurückgestellt (mit benanntem Auslöser) / Verworfen (mit Begründung, nicht erneut vorschlagen)">🧊 / ❌</th>
</tr>
</thead>
<tbody>
<?php foreach ($roadmapEpics as $rmE): ?>
<?php
$total = (int)$rmE['total'];
$done = (int)$rmE['done'];
// Zurueckgestelltes und Verworfenes zaehlt nicht in den
// Nenner: Ein Bereich, in dem die Haelfte verworfen wurde,
// ist nicht zur Haelfte fertig - er ist kleiner geworden.
$scope = $total - (int)$rmE['deferred'] - (int)$rmE['rejected'];
$percent = $scope > 0 ? (int)round($done / $scope * 100) : 0;
?>
<tr>
<td><strong style="font-family:'Roboto Mono';"><?= e((string)$rmE['epic']) ?></strong></td>
<td>
<a href="<?= e(rmUrl(['rm_epic' => (string)$rmE['epic'], 'rm_project' => (string)$rmE['project_slug']], '#sub-roadmap-list')) ?>"
title="Vorhaben dieses Bereichs anzeigen">
<?= e((string)$rmE['title']) ?>
</a>
<?php if (!empty($rmE['summary'])): ?>
<div style="font-size:0.78rem; color:var(--text-muted); margin-top:0.15rem;">
<?= e((string)$rmE['summary']) ?>
</div>
<?php endif; ?>
</td>
<?php if ($rmProject === 'all'): ?>
<td><span class="badge badge-stopped"><?= e((string)$rmE['project_slug']) ?></span></td>
<?php endif; ?>
<td>
<div style="background:rgba(255,255,255,0.06); border-radius:4px; height:8px; overflow:hidden;">
<div style="width:<?= $percent ?>%; height:100%; background:var(--success);"></div>
</div>
<span style="font-size:0.75rem; color:var(--text-muted);">
<?= $done ?> / <?= $scope ?> <?= $scope > 0 ? '(' . $percent . '%)' : '' ?>
</span>
</td>
<td><?= (int)$rmE['pending'] ?></td>
<td><?= (int)$rmE['working'] ?></td>
<td><?= $done ?></td>
<td style="color:var(--text-muted);"><?= (int)$rmE['deferred'] ?> / <?= (int)$rmE['rejected'] ?></td>
</tr>
<?php endforeach; ?>
<?php if ($roadmapEpics === []): ?>
<tr><td colspan="8" style="color:var(--text-muted); padding:1rem;">
Noch keine Themenbereiche angelegt. Unter „Themenbereiche“ anfangen.
</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<!-- =============== Subtab: Alle Vorhaben =============== -->
<div id="sub-roadmap-list" class="subtab-content">
<div class="card">
<div class="card-header" style="display:flex; flex-wrap:wrap; justify-content:space-between; align-items:center; gap:0.75rem;">
<h2 class="card-title" style="margin:0;">
🗺️ Vorhaben
<span class="badge badge-stopped" style="margin-left:0.5rem;"><?= (int)$roadmapTotal ?> Treffer</span>
</h2>
<span style="font-size:0.8rem; color:var(--text-muted);">
Filter oben unter „Übersicht“ — sie gelten für beide Ansichten.
</span>
</div>
<table>
<thead>
<tr>
<th class="hilfe" style="width:60px;"
title="Reihenfolge in Zehnerschritten. Die Lücken sind Absicht: So lässt sich etwas dazwischenschieben, ohne alles neu zu nummerieren. Ein Strich heißt &quot;keine Meinung&quot; — solche Einträge stehen hinten.">Rang</th>
<th class="hilfe" style="width:70px;"
title="Die Item-Nummer im Bugtracker. Sie ist zugleich die Kennung, unter der die Begründung in der Projektdatei steht.">Item</th>
<th>Vorhaben</th>
<th style="width:90px;">Bereich</th>
<th style="width:150px;">Stand</th>
<th class="hilfe" style="width:100px;"
title="Geplante Zielfassung. Bei erledigten Einträgen die Fassung, in der es tatsächlich gelandet ist.">Ziel</th>
<th class="hilfe" style="width:120px;"
title="Wer das Item gerade exklusiv hält. Frei heißt: übernehmbar über ?action=next bzw. dc_next_task.">Wer</th>
<th class="hilfe" style="width:60px;"
title="Steht die Begründung in der Projektdatei? Blatt = doc_anchor gesetzt, Strich = es gibt nur den Zustand. Bei Zurückgestelltem und Verworfenem sollte hier nie ein Strich stehen.">Doku</th>
<th style="width:120px;"></th>
</tr>
</thead>
<tbody>
<?php $rmLastEpic = null; ?>
<?php foreach ($roadmapItems as $item): ?>
<?php
$itemEpic = (string)($item['epic'] ?? '');
// Der Schluessel traegt das Projekt mit: Ueber alle
// Projekte hinweg gibt es "A" mehrfach, und zwei
// gleichnamige Bereiche duerfen nicht unter einer
// Ueberschrift landen.
$epicKey = $item['project_slug'] . '/' . $itemEpic;
?>
<?php if ($epicKey !== $rmLastEpic): ?>
<tr style="background:rgba(255,255,255,0.02);">
<td colspan="9" style="padding:0.55rem 0.75rem; font-weight:700; font-size:0.82rem;">
<?= e($itemEpic) ?> — <?= e($roadmapEpicTitles[$epicKey] ?? 'Unbekannter Bereich') ?>
<?php if ($rmProject === 'all'): ?>
<span class="badge badge-stopped" style="margin-left:0.4rem;"><?= e((string)$item['project_slug']) ?></span>
<?php endif; ?>
</td>
</tr>
<?php $rmLastEpic = $epicKey; ?>
<?php endif; ?>
<tr>
<td style="font-family:'Roboto Mono'; color:var(--text-muted);">
<?= $item['roadmap_rank'] !== null ? (int)$item['roadmap_rank'] : '—' ?>
</td>
<td style="font-family:'Roboto Mono'; font-size:0.8rem;">#<?= (int)$item['id'] ?></td>
<td>
<strong><?= e((string)$item['title']) ?></strong>
<?php if ($rmProject === 'all'): ?>
<span class="badge badge-stopped" style="margin-left:0.4rem;"><?= e((string)$item['project_slug']) ?></span>
<?php endif; ?>
<?php if ((string)$item['status'] === 'deferred' && !empty($item['defer_trigger'])): ?>
<div style="font-size:0.78rem; color:var(--text-muted); margin-top:0.2rem;">
🧊 Auslöser: <?= e((string)$item['defer_trigger']) ?>
</div>
<?php endif; ?>
</td>
<td><?= e($itemEpic) ?></td>
<td><?= rmBadge((string)$item['status']) ?></td>
<td><?= $item['target_build'] ? '<code>' . e((string)$item['target_build']) . '</code>' : '—' ?></td>
<td><?= $item['claimed_by'] ? e((string)$item['claimed_by']) : '<span style="color:var(--text-muted);">frei</span>' ?></td>
<td style="text-align:center;">
<?php if (!empty($item['doc_anchor'])): ?>
<span title="<?= e((string)$item['doc_anchor']) ?>">📄</span>
<?php else: ?>
<span style="color:var(--text-muted);" title="Keine Begründung hinterlegt">—</span>
<?php endif; ?>
</td>
<td style="white-space:nowrap;">
<form method="POST" action="index.php#tab-roadmap" style="display:inline">
<?= $csrfField ?>
<input type="hidden" name="action" value="rm_move">
<input type="hidden" name="item_id" value="<?= (int)$item['id'] ?>">
<input type="hidden" name="direction" value="up">
<button type="submit" class="btn btn-sm btn-secondary" title="Nach oben">▲</button>
</form>
<form method="POST" action="index.php#tab-roadmap" style="display:inline">
<?= $csrfField ?>
<input type="hidden" name="action" value="rm_move">
<input type="hidden" name="item_id" value="<?= (int)$item['id'] ?>">
<input type="hidden" name="direction" value="down">
<button type="submit" class="btn btn-sm btn-secondary" title="Nach unten">▼</button>
</form>
<button type="button" class="btn btn-sm btn-secondary"
onclick='openRoadmapEdit(<?= htmlspecialchars(json_encode([
"id" => (int)$item["id"],
"title" => (string)$item["title"],
"epic" => $itemEpic,
"roadmap_rank" => $item["roadmap_rank"],
"target_build" => (string)($item["target_build"] ?? ""),
"defer_trigger" => (string)($item["defer_trigger"] ?? ""),
"doc_anchor" => (string)($item["doc_anchor"] ?? ""),
"status" => (string)$item["status"],
], JSON_UNESCAPED_UNICODE), ENT_QUOTES) ?>)'>✏️</button>
</td>
</tr>
<?php endforeach; ?>
<?php if ($roadmapItems === []): ?>
<tr><td colspan="9" style="color:var(--text-muted); padding:1rem;">
Keine Vorhaben mit diesen Filtern. Unter „Aufnahmeliste“ stehen die
Einträge, die noch keinen Bereich haben.
</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<!-- =============== Subtab: Aufnahmeliste =============== -->
<div id="sub-roadmap-intake" class="subtab-content">
<div class="card">
<div class="card-header">
<h2 class="card-title">➕ Noch nicht auf der Roadmap</h2>
</div>
<p style="color:var(--text-muted); font-size:0.85rem; margin-bottom:0.9rem;">
Offene Feature-Requests ohne Themenbereich. Ein Vorhaben steht auf der
Roadmap, sobald es einen Bereich hat — ein zweites Kennzeichen daneben
gibt es bewusst nicht, sonst könnten beide auseinanderlaufen.
</p>
<table>
<thead>
<tr><th style="width:70px;">Item</th><th>Titel</th><th style="width:110px;">Projekt</th>
<th style="width:110px;">Schweregrad</th><th style="width:110px;">Gemeldet</th><th style="width:90px;"></th></tr>
</thead>
<tbody>
<?php foreach ($roadmapCandidates as $cand): ?>
<tr>
<td style="font-family:'Roboto Mono'; font-size:0.8rem;">#<?= (int)$cand['id'] ?></td>
<td><?= e((string)$cand['title']) ?></td>
<td><span class="badge badge-stopped"><?= e((string)$cand['project_slug']) ?></span></td>
<td><?= e((string)$cand['severity']) ?></td>
<td style="color:var(--text-muted);"><?= e(dc_ago((string)$cand['first_seen_at'])) ?></td>
<td>
<button type="button" class="btn btn-sm"
onclick='openRoadmapEdit(<?= htmlspecialchars(json_encode([
"id" => (int)$cand["id"],
"title" => (string)$cand["title"],
"epic" => "",
"roadmap_rank" => null,
"target_build" => "",
"defer_trigger" => "",
"doc_anchor" => "",
"status" => (string)$cand["status"],
], JSON_UNESCAPED_UNICODE), ENT_QUOTES) ?>)'>Aufnehmen</button>
</td>
</tr>
<?php endforeach; ?>
<?php if ($roadmapCandidates === []): ?>
<tr><td colspan="6" style="color:var(--text-muted); padding:1rem;">
Nichts offen ohne Bereich — die Roadmap ist vollständig.
</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<!-- =============== Subtab: Themenbereiche =============== -->
<div id="sub-roadmap-epics" class="subtab-content">
<div class="card">
<div class="card-header">
<h2 class="card-title">🗂️ Themenbereich anlegen oder ändern</h2>
</div>
<!--
Der Bereichstitel steht hier und nicht an jedem Item:
sonst waere er in jeder Zeile anders schreibbar, und
drei Tippfehler ergaeben drei Bereiche.
-->
<form method="POST" action="index.php#sub-roadmap-epics">
<?= $csrfField ?>
<input type="hidden" name="action" value="rm_save_epic">
<div class="form-grid">
<div class="form-group">
<label class="form-label">Projekt</label>
<select name="project_slug" class="form-input" required>
<?php foreach ($projects as $p): ?>
<option value="<?= e($p['slug']) ?>" <?= $rmProject === $p['slug'] ? 'selected' : '' ?>>
<?= e($p['name']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label class="form-label">Kürzel (z. B. A, B, INFRA)</label>
<input type="text" name="epic" class="form-input" maxlength="24" required>
</div>
<div class="form-group">
<label class="form-label">Titel</label>
<input type="text" name="title" class="form-input" maxlength="160" required>
</div>
<div class="form-group">
<label class="form-label">Reihenfolge</label>
<input type="number" name="sort_order" class="form-input" value="100">
</div>
</div>
<div class="form-group" style="margin-top:0.9rem;">
<label class="form-label">Kurzbeschreibung (optional)</label>
<input type="text" name="summary" class="form-input"
placeholder="Wofür steht dieser Bereich? Ein Satz genügt.">
</div>
<button type="submit" class="btn" style="margin-top:1rem;">Bereich speichern</button>
</form>
</div>
<div class="card">
<div class="card-header"><h2 class="card-title">Vorhandene Bereiche</h2></div>
<table>
<thead>
<tr><th style="width:80px;">Kürzel</th><th>Titel</th><th style="width:120px;">Projekt</th>
<th style="width:90px;">Einträge</th><th style="width:90px;">Sortierung</th><th style="width:110px;"></th></tr>
</thead>
<tbody>
<?php foreach ($roadmapEpics as $rmE): ?>
<tr>
<td><strong style="font-family:'Roboto Mono';"><?= e((string)$rmE['epic']) ?></strong></td>
<td>
<?= e((string)$rmE['title']) ?>
<?php if (!empty($rmE['summary'])): ?>
<div style="font-size:0.78rem; color:var(--text-muted);"><?= e((string)$rmE['summary']) ?></div>
<?php endif; ?>
</td>
<td><span class="badge badge-stopped"><?= e((string)$rmE['project_slug']) ?></span></td>
<td><?= (int)$rmE['total'] ?></td>
<td style="color:var(--text-muted);"><?= (int)$rmE['sort_order'] ?></td>
<td>
<?php if ((int)$rmE['total'] === 0): ?>
<form method="POST" action="index.php#sub-roadmap-epics" style="display:inline"
onsubmit="return confirm('Bereich <?= e((string)$rmE['epic']) ?> löschen?');">
<?= $csrfField ?>
<input type="hidden" name="action" value="rm_delete_epic">
<input type="hidden" name="project_slug" value="<?= e((string)$rmE['project_slug']) ?>">
<input type="hidden" name="epic" value="<?= e((string)$rmE['epic']) ?>">
<button type="submit" class="btn btn-sm btn-danger">Löschen</button>
</form>
<?php else: ?>
<span style="font-size:0.78rem; color:var(--text-muted);">hat Einträge</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<!-- =============== Dialog: Roadmap-Angaben =============== -->
<div id="roadmapEditModal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.75); backdrop-filter:blur(8px); z-index:9999; align-items:center; justify-content:center; padding:1rem;">
<div class="card" style="max-width:720px; width:100%; max-height:90vh; overflow-y:auto; margin:0;">
<div class="card-header">
<h2 class="card-title">Roadmap-Angaben zu <span id="rmEditLabel"></span></h2>
</div>
<form method="POST" action="index.php#tab-roadmap">
<?= $csrfField ?>
<input type="hidden" name="action" value="rm_set">
<input type="hidden" name="item_id" id="rmEditId">
<div class="form-grid">
<div class="form-group">
<label class="form-label">Themenbereich</label>
<select name="epic" id="rmEditEpic" class="form-input">
<option value="">— kein Bereich (nicht auf der Roadmap) —</option>
<?php foreach ($roadmapEpics as $rmE): ?>
<option value="<?= e((string)$rmE['epic']) ?>">
<?= e((string)$rmE['epic']) ?> — <?= e((string)$rmE['title']) ?>
(<?= e((string)$rmE['project_slug']) ?>)
</option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label class="form-label">Rang (leer = ans Ende)</label>
<input type="number" name="roadmap_rank" id="rmEditRank" class="form-input">
</div>
<div class="form-group">
<label class="form-label">Zielfassung</label>
<input type="text" name="target_build" id="rmEditBuild" class="form-input" placeholder="z. B. 2.7.0">
</div>
<div class="form-group">
<label class="form-label">Status</label>
<select name="status" id="rmEditStatus" class="form-input" onchange="rmToggleTrigger()">
<?php foreach (BugRepo::STATUSES as $st): ?>
<option value="<?= e($st) ?>">
<?= e((Roadmap::MARKERS[$st] ?? '·') . ' ' . (Roadmap::STATUS_LABELS[$st] ?? $st)) ?>
</option>
<?php endforeach; ?>
</select>
</div>
</div>
<div class="form-group" style="margin-top:0.9rem;">
<label class="form-label">
Wo steht die Begründung? (Pfad und Anker in der Projektdatei)
</label>
<input type="text" name="doc_anchor" id="rmEditAnchor" class="form-input"
placeholder="docs/ROADMAP.md#142-symbol-upload">
</div>
<div class="form-group" id="rmTriggerGroup" style="margin-top:0.9rem;">
<label class="form-label">
🧊 Auslöser — woran erkennt man, dass „später“ jetzt ist?
</label>
<textarea name="defer_trigger" id="rmEditTrigger" class="form-input" rows="2"
placeholder="z. B.: sobald Logauszüge strukturiert hereinkommen"></textarea>
<div style="font-size:0.78rem; color:var(--text-muted); margin-top:0.3rem;">
Pflicht beim Zurückstellen. Ohne Auslöser ist ein Vorhaben nicht
zurückgestellt, sondern vergessen.
</div>
</div>
<div style="display:flex; justify-content:flex-end; gap:0.75rem; margin-top:1.2rem;">
<button type="button" class="btn btn-secondary" onclick="closeRoadmapEdit()">Abbrechen</button>
<button type="submit" class="btn">Speichern</button>
</div>
</form>
</div>
</div>
</div>
<!-- ================= MODULE: BUGTRACKER & FEATURE-TRACKER ================= -->
<div id="tab-bugtracker" class="tab-content">
<div id="sub-bugtracker-items" class="subtab-content active">
<div class="stats-grid" style="grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));">
<div class="stat-card">
<div class="stat-header">Dev Bugs (Entwicklung)</div>
<div class="stat-value" style="color:#5b9dff;"><?= $bugtrackerStats['open_bugs_dev'] ?></div>
</div>
<div class="stat-card">
<div class="stat-header">Prod Bugs (Produktion)</div>
<div class="stat-value" style="color:var(--danger);"><?= $bugtrackerStats['open_bugs_prod'] ?></div>
</div>
<div class="stat-card">
<div class="stat-header">Offene Feature Requests</div>
<div class="stat-value" style="color:var(--warning);"><?= $bugtrackerStats['open_features'] ?></div>
</div>
<div class="stat-card">
<div class="stat-header">💡 Ideen &amp; Gedanken</div>
<div class="stat-value" style="color:#e082ff;"><?= $bugtrackerStats['ideas_count'] ?></div>
</div>
<div class="stat-card">
<div class="stat-header">🤖 Von Agenten belegt</div>
<div class="stat-value" style="color:#66bb6a;"><?= $bugtrackerStats['in_progress_by_agents'] ?></div>
</div>
<div class="stat-card">
<div class="stat-header">Gelöst / Umgesetzt</div>
<div class="stat-value" style="color:var(--success);"><?= $bugtrackerStats['resolved_total'] ?></div>
</div>
</div>
<?php if ($agentWorkload !== []): ?>
<div class="card" style="padding:1rem 1.25rem;">
<div class="card-header" style="margin-bottom:0.75rem;">
<h2 class="card-title" style="font-size:0.95rem;">🤖 Aktive Agenten</h2>
</div>
<div class="group-cards-grid">
<?php foreach ($agentWorkload as $agent): ?>
<div class="group-card">
<span class="group-card-name"><?= e((string)$agent['claimed_by']) ?></span>
<span class="badge badge-warning">
<?= (int)$agent['items'] ?> Item(s) &middot; Lease bis <?= e(dc_dt((string)$agent['next_expiry'], 'H:i')) ?>
</span>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<div class="card">
<div class="card-header" style="display:flex; flex-wrap:wrap; justify-content:space-between; align-items:center; gap:0.75rem;">
<h2 class="card-title" style="margin:0;">
🐛 Bugs, Features &amp; Ideen
<span class="badge badge-stopped" style="margin-left:0.5rem;"><?= (int)$bugtrackerTotal ?> Treffer</span>
</h2>
<!--
Filterung erfolgt jetzt serverseitig ueber GET-Parameter.
Zuvor wurden saemtliche Datensaetze ins HTML gerendert und
nur per JavaScript aus- und eingeblendet - das skalierte nicht
und ging bei jedem Auto-Refresh verloren.
-->
<form method="GET" action="index.php" style="display:flex; flex-wrap:wrap; gap:0.4rem; align-items:center;">
<!-- Die offene Ansicht faehrt als verstecktes Feld mit. Ein GET-Formular
verwirft beim Absenden den Anker seiner action, und der Anker war
bisher die einzige Quelle dafuer, was angezeigt wird - nach jeder
Filteraenderung stand man wieder im Dashboard. Fest im Markup statt
aus dem DOM gelesen: hier ist ohne Zweifel bekannt, wo das Formular
steht. -->
<input type="hidden" name="view" value="sub-bugtracker-items">
<select name="bt_project" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="dcSubmit(this.form)">
<option value="all">🌐 Alle Projekte</option>
<?php foreach ($projects as $p): ?>
<option value="<?= e($p['slug']) ?>" <?= $btFilters['project_slug'] === $p['slug'] ? 'selected' : '' ?>>
<?= e($p['name']) ?>
</option>
<?php endforeach; ?>
</select>
<select name="bt_env" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="dcSubmit(this.form)">
<?php
$envOptions = [
'all' => '🌍 Alle Umgebungen',
'production' => '🔴 Produktion',
'development' => '🔵 Entwicklung',
'staging' => '🟡 Staging',
'testing' => '🧪 Testing',
];
foreach ($envOptions as $value => $label): ?>
<option value="<?= e($value) ?>" <?= $btFilters['environment'] === $value ? 'selected' : '' ?>><?= e($label) ?></option>
<?php endforeach; ?>
</select>
<select name="bt_type" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="dcSubmit(this.form)">
<?php
$typeOptions = [
'all' => '📂 Alle Typen',
'bug' => '🐛 Nur Bugs',
'feature_request' => '💡 Nur Features',
];
foreach ($typeOptions as $value => $label): ?>
<option value="<?= e($value) ?>" <?= $btFilters['type'] === $value ? 'selected' : '' ?>><?= e($label) ?></option>
<?php endforeach; ?>
</select>
<select name="bt_severity" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="dcSubmit(this.form)">
<?php
$sevOptions = [
'all' => '🔥 Alle Schweregrade',
'critical' => '🔥 Kritisch',
'high' => 'Hoch',
'medium' => 'Mittel',
'low' => 'Niedrig',
'wishlist' => '⭐ Wunschliste',
'idea' => '💡 Ideen',
];
foreach ($sevOptions as $value => $label): ?>
<option value="<?= e($value) ?>" <?= $btFilters['severity'] === $value ? 'selected' : '' ?>><?= e($label) ?></option>
<?php endforeach; ?>
</select>
<!--
Status als Mehrfachauswahl statt als Klappliste.
Vorher liess sich genau eine Lage waehlen, und wer
"offen und in Nachpruefung" sehen wollte, brauchte
eine vorgefertigte Kombination - stand sie nicht in
der Liste, ging es nicht.
Das versteckte Feld davor ist kein Schoenheitsfehler:
HTML sendet fuer nicht angehakte Kaestchen NICHTS.
Ohne es waere "alle Haken entfernt" von "erster
Aufruf" nicht zu unterscheiden, und die Vorgabe kaeme
bei jedem Versuch zurueck, sie loszuwerden.
Kein Absenden bei jedem Klick: Wer drei Status waehlt,
will einmal laden und nicht dreimal. Deshalb der
eigene Knopf im Klappfeld.
-->
<select name="bt_roadmap" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="dcSubmit(this.form)"
title="Ein Vorhaben steht auf der Roadmap, sobald es einen Themenbereich hat. Es bleibt ein Item wie jedes andere - es wird hier nur nicht doppelt gezeigt.">
<?php foreach ([
'ohne' => '🗺️ Ohne Roadmap',
'nur' => '🗺️ Nur Roadmap',
'alle' => '🗺️ Mit Roadmap',
] as $wert => $beschriftung): ?>
<option value="<?= e($wert) ?>" <?= $btRoadmap === $wert ? 'selected' : '' ?>><?= e($beschriftung) ?></option>
<?php endforeach; ?>
</select>
<details class="filter-mehrfach">
<summary title="Status auswaehlen">
<?php
$statusGruppen = [
'Laufend' => [
'open' => 'Offen',
'planned' => 'Geplant',
'in_progress' => 'In Bearbeitung',
],
'Verlangt Aufmerksamkeit' => [
'triage_external' => '📥 Fremdmeldung',
'awaiting_verification' => '🔎 Wartet auf Nachpruefung',
'in_verification' => '🔎 In Nachpruefung',
'fix_insufficient' => '❗ Fix traegt nicht',
],
'Abgeschlossen' => [
'resolved' => 'Gelöst',
'closed' => 'Geschlossen',
'rejected' => 'Abgelehnt',
'ignored' => 'Ignoriert',
'deferred' => 'Zurückgestellt',
],
];
$btStatusLabels = [];
foreach ($statusGruppen as $eintraege) {
$btStatusLabels += $eintraege;
}
$anzahl = count($btStatusAuswahl);
if ($anzahl === 0) {
$btStatusText = '📌 Alle Status';
} elseif ($anzahl === 1) {
$btStatusText = '📌 ' . ($btStatusLabels[$btStatusAuswahl[0]] ?? $btStatusAuswahl[0]);
} else {
$btStatusText = sprintf('📌 %d Status', $anzahl);
}
?>
<?= e($btStatusText) ?> ▾
</summary>
<div class="filter-mehrfach-inhalt">
<input type="hidden" name="bt_status[]" value="">
<?php foreach ($statusGruppen as $gruppe => $eintraege): ?>
<div class="filter-gruppe"><?= e($gruppe) ?></div>
<?php foreach ($eintraege as $wert => $beschriftung): ?>
<label>
<input type="checkbox" name="bt_status[]" value="<?= e($wert) ?>"
<?= in_array($wert, $btStatusAuswahl, true) ? 'checked' : '' ?>>
<?= e($beschriftung) ?>
</label>
<?php endforeach; ?>
<?php endforeach; ?>
<div class="filter-mehrfach-fuss">
<button type="button" class="btn btn-sm btn-secondary"
onclick="dcAlleHaken(this, false)">Keine</button>
<button type="button" class="btn btn-sm btn-secondary"
onclick="dcAlleHaken(this, true)">Alle</button>
<button type="submit" class="btn btn-sm">Übernehmen</button>
</div>
<div class="filter-hinweis">Kein Haken = keine Einschränkung.</div>
</div>
</details>
<select name="bt_order" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="dcSubmit(this.form)">
<?php
$orderOptions = [
'newest' => '🕒 Neueste zuerst',
'severity' => '🔥 Nach Schweregrad',
'occurrences' => '📈 Nach Häufigkeit',
'updated' => '✏️ Zuletzt geändert',
'oldest' => '⏳ Älteste zuerst',
];
foreach ($orderOptions as $value => $label): ?>
<option value="<?= e($value) ?>" <?= $btFilters['order'] === $value ? 'selected' : '' ?>><?= e($label) ?></option>
<?php endforeach; ?>
</select>
<input type="text" name="bt_q" value="<?= e($btFilters['search']) ?>" class="form-input"
style="width:190px; padding:0.3rem 0.6rem; font-size:0.8rem;" placeholder="🔍 Suchen oder #288"
title="Volltext ueber Titel, Beschreibung, Fehlermeldung, Tags und Dateipfad. Eine Zahl findet zusaetzlich das Item mit dieser Nummer; mit Raute (#288) nur dieses eine.">
<button type="submit" class="btn btn-sm">Filtern</button>
<?php
// Der Statusfilter zaehlt hier nicht mehr als "gesetzt":
// Er ist ab Werk eingeschraenkt, sonst stuende der
// Zuruecksetzen-Knopf immer da und bedeutete nichts.
$btAbweichend = $btStatusAuswahl !== BT_STATUS_VORGABE || $btRoadmap !== 'ohne';
if ($btFilters['search'] !== '' || $btFilters['project_slug'] !== 'all'
|| $btAbweichend || $btFilters['severity'] !== 'all'
|| $btFilters['type'] !== 'all' || $btFilters['environment'] !== 'all'): ?>
<a href="index.php#tab-bugtracker" class="btn btn-sm btn-secondary">Zurücksetzen</a>
<?php endif; ?>
</form>
<?php if ($btVerdeckt > 0): ?>
<!--
Der Fall: Jemand sucht "288", bekommt nichts und
schliesst daraus, es gebe den Eintrag nicht - dabei ist
er nur erledigt und deshalb ausgeblendet. Eine Suche,
die schweigt, obwohl sie etwas gefunden hat, ist
schlimmer als gar keine.
-->
<div class="alert" style="margin-top:0.75rem; display:flex; flex-wrap:wrap; align-items:center; gap:0.6rem; background:rgba(122,162,255,0.12); border:1px solid #7aa2ff; color:#cfe0ff;">
<span>
🔎 <strong><?= (int)$btVerdeckt ?></strong>
<?= $btVerdeckt === 1 ? 'weiterer Treffer' : 'weitere Treffer' ?>
für „<?= e($btFilters['search']) ?>"
<?= $btVerdeckt === 1 ? 'wird' : 'werden' ?>
ausgeblendet - vom Statusfilter oder weil sie auf der Roadmap stehen.
</span>
<a class="btn btn-sm btn-secondary"
<?php
// Ein leerer Eintrag, keine Zeichenkette "all":
// btUrl() wirft 'all' als "nicht gesetzt" heraus,
// und dann griffe wieder die Vorgabe. Als Liste mit
// einem leeren Wert bleibt der Parameter in der URL
// stehen und bedeutet ausdruecklich "ohne Filter".
?>
href="<?= e(btUrl(['bt_status' => [''], 'bt_roadmap' => 'alle', 'bt_page' => null])) ?>">Überall suchen</a>
</div>
<?php endif; ?>
</div>
<!--
Die Breiten stehen hier und nicht im Stylesheet, weil
sie zu dieser Tabelle gehoeren und zu keiner anderen.
"Projekt & Titel" bekommt bewusst keine: Bei
table-layout:fixed nimmt die Spalte ohne Angabe den
Rest - sie waechst also mit dem Bildschirm, waehrend
die uebrigen stehen bleiben.
-->
<div class="tabelle-scroll">
<table class="tabelle-fest">
<thead>
<tr>
<?php // Die Breiten sind am breitesten Abzeichen der Spalte
// gemessen, zuzueglich 32px Zellenpolsterung - nicht
// geschaetzt. Bei "Status" gibt
// "WARTET AUF PRUEFUNG" mit 158px den Ausschlag,
// bei "ID / Typ" das FEATURE-Abzeichen mit 88px. ?>
<th style="width:124px;">ID / Typ</th>
<th style="width:104px;">Umgebung</th>
<th>Projekt &amp; Titel</th>
<th style="width:104px;">Build</th>
<th style="width:132px;">Schweregrad</th>
<th style="width:82px;">Anzahl</th>
<th style="width:194px;">Status</th>
<th style="width:150px;">Aktionen</th>
</tr>
</thead>
<tbody id="btTableBody">
<?php if ($bugtrackerItems === []): ?>
<tr>
<td colspan="8" style="text-align:center; padding:2.5rem; color:var(--text-muted);">
Keine Einträge für diese Filterkombination.
</td>
</tr>
<?php endif; ?>
<?php foreach ($bugtrackerItems as $item): ?>
<?php
$typeBadge = $item['type'] === 'feature_request'
? '<span class="badge" style="background:rgba(180,100,255,0.2); color:#c87dff; border:1px solid #c87dff;">💡 FEATURE</span>'
: '<span class="badge badge-warning">🐛 BUG</span>';
$envBadge = match ((string)$item['environment']) {
'development' => '<span class="badge" style="background:rgba(91,157,255,0.2); color:#5b9dff; border:1px solid #5b9dff;">🔵 DEV</span>',
'production' => '<span class="badge badge-down">🔴 PROD</span>',
default => '<span class="badge badge-stopped">' . e(strtoupper((string)$item['environment'])) . '</span>',
};
$statusBadge = match ((string)$item['status']) {
'open' => '<span class="badge badge-down">OFFEN</span>',
'planned' => '<span class="badge" style="background:rgba(255,165,0,0.2); color:orange; border:1px solid orange;">GEPLANT</span>',
'in_progress' => '<span class="badge badge-warning">IN BEARBEITUNG</span>',
'resolved' => '<span class="badge badge-up">GELÖST</span>',
'rejected' => '<span class="badge badge-stopped">ABGELEHNT</span>',
'triage_external' => '<span class="badge" style="background:rgba(120,160,255,0.2); color:#7aa2ff; border:1px solid #7aa2ff;">📥 FREMDMELDUNG</span>',
'awaiting_verification' => '<span class="badge" style="background:rgba(0,210,255,0.18); color:#5ce1e6; border:1px solid #5ce1e6;">🔎 WARTET AUF PRÜFUNG</span>',
'in_verification' => '<span class="badge" style="background:rgba(0,210,255,0.3); color:#5ce1e6; border:1px solid #5ce1e6;">🔎 WIRD GEPRÜFT</span>',
'fix_insufficient' => '<span class="badge badge-down" style="font-weight:bold;">❗ FIX TRÄGT NICHT</span>',
default => '<span class="badge badge-stopped">' . e(strtoupper((string)$item['status'])) . '</span>',
};
$sevBadge = match ((string)$item['severity']) {
'idea' => '<span class="badge" style="background:rgba(215,100,255,0.25); color:#e082ff; border:1px solid #e082ff;">💡 IDEE</span>',
'wishlist' => '<span class="badge" style="background:rgba(0,210,255,0.25); color:#5ce1e6; border:1px solid #5ce1e6;">⭐ WUNSCH</span>',
'critical' => '<span class="badge badge-down" style="font-weight:bold;">🔥 KRITISCH</span>',
'high' => '<span class="badge badge-warning">HOCH</span>',
'medium' => '<span class="badge badge-stopped">MITTEL</span>',
default => '<span class="badge badge-stopped">NIEDRIG</span>',
};
// json_encode liefert eine gueltige JS-Literaldarstellung; das
// vorherige addslashes() konnte an Zeilenumbruechen zerbrechen.
$jsonItem = e((string)json_encode($item, JSON_INVALID_UTF8_SUBSTITUTE));
$jsTitle = e((string)json_encode((string)$item['title'], JSON_INVALID_UTF8_SUBSTITUTE));
$isClaimed = !empty($item['claimed_by'])
&& !empty($item['lease_until'])
&& strtotime((string)$item['lease_until'] . ' UTC') > time();
?>
<tr class="bt-row">
<td>#<?= (int)$item['id'] ?><br><?= $typeBadge ?></td>
<td><?= $envBadge ?></td>
<td class="zelle-titel">
<div class="titel" title="<?= e((string)$item['title']) ?>"><?= e((string)$item['title']) ?></div>
<div class="meta">
<span>Projekt: <code><?= e((string)$item['project_slug']) ?></code></span>
<span>Von: <?= e((string)$item['created_by']) ?></span>
<span title="Zuletzt gesehen"><?= e(dc_ago($item['last_seen_at'] ?? null)) ?></span>
<?php if ($isClaimed): ?>
<span class="badge" style="background:rgba(245,166,35,0.2); color:#fbbf24; font-size:0.7rem;">
🔒 <?= e((string)$item['claimed_by']) ?>
</span>
<?php endif; ?>
<?php if (!empty($item['push_id'])): ?>
<span class="badge" style="background:rgba(255,255,255,0.06); font-family:monospace; font-size:0.7rem;">📲 <?= e((string)$item['push_id']) ?></span>
<?php endif; ?>
<?php if (!empty($item['target_agent'])): ?>
<span class="badge" style="background:rgba(0,200,100,0.15); color:#66bb6a; font-size:0.7rem;">🤖 <?= e((string)$item['target_agent']) ?></span>
<?php endif; ?>
<?php if (!empty($item['file_path'])): ?>
<span class="badge" style="background:rgba(255,255,255,0.06); font-family:monospace; font-size:0.7rem;">
📄 <?= e((string)$item['file_path']) ?><?= !empty($item['line_no']) ? ':' . (int)$item['line_no'] : '' ?>
</span>
<?php endif; ?>
<?php if (!empty($item['tags'])): ?>
<span class="badge" style="background:rgba(100,180,255,0.15); color:#90caf9; font-size:0.7rem;">🏷️ <?= e((string)$item['tags']) ?></span>
<?php endif; ?>
</div>
</td>
<td><code><?= e((string)($item['build_version'] ?? 'unbekannt')) ?></code></td>
<td><?= $sevBadge ?></td>
<td>
<span class="badge" style="background:rgba(255,255,255,0.08); font-family:monospace;">
<?= (int)$item['occurrence_count'] ?>x
</span>
</td>
<td><?= $statusBadge ?></td>
<td class="zelle-aktionen">
<div>
<button type="button" class="btn btn-sm btn-secondary" title="Bearbeiten" onclick="openEditBugtrackerModal(<?= $jsonItem ?>)">✏️</button>
<button type="button" class="btn btn-sm btn-secondary" onclick="openBugtrackerModal(<?= (int)$item['id'] ?>)">🔍</button>
<?php if (!in_array((string)$item['status'], ['resolved', 'closed'], true)): ?>
<button type="button" class="btn btn-sm" onclick="openResolveModal(<?= (int)$item['id'] ?>, <?= $jsTitle ?>)">✔</button>
<?php endif; ?>
<?php if ($isClaimed): ?>
<form method="POST" action="index.php#tab-bugtracker" style="display:inline"
onsubmit="return confirm('Zuweisung an <?= e((string)$item['claimed_by']) ?> aufheben?');">
<?= $csrfField ?>
<input type="hidden" name="action" value="bt_release_claim">
<input type="hidden" name="item_id" value="<?= (int)$item['id'] ?>">
<button type="submit" class="btn btn-sm btn-secondary" title="Zuweisung aufheben">🔓</button>
</form>
<?php endif; ?>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php if ($bugtrackerPages > 1): ?>
<div style="display:flex; justify-content:space-between; align-items:center; margin-top:1rem; padding-top:1rem; border-top:1px solid var(--border-soft);">
<span style="font-size:0.8rem; color:var(--text-muted);">
Seite <?= (int)$btPage ?> von <?= (int)$bugtrackerPages ?>
(<?= (int)$bugtrackerTotal ?> Einträge)
</span>
<div style="display:flex; gap:0.4rem;">
<?php if ($btPage > 1): ?>
<a href="<?= e(btUrl(['bt_page' => $btPage - 1])) ?>" class="btn btn-sm btn-secondary">← Zurück</a>
<?php endif; ?>
<?php if ($btPage < $bugtrackerPages): ?>
<a href="<?= e(btUrl(['bt_page' => $btPage + 1])) ?>" class="btn btn-sm btn-secondary">Weiter →</a>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
</div>
</div>
<!-- Subtab: Item manuell anlegen -->
<div id="sub-bugtracker-new" class="subtab-content">
<div class="card">
<div class="card-header"><h2 class="card-title">➕ Bug, Feature Request oder Idee Erfassen</h2></div>
<form method="POST" action="index.php#tab-bugtracker">
<?= $csrfField ?>
<input type="hidden" name="action" value="bt_create_item">
<div class="form-grid">
<div class="form-group">
<label class="form-label">Projekt</label>
<select name="project_slug" class="form-input" required>
<?php foreach ($projects as $p): ?>
<option value="<?= htmlspecialchars($p['slug']) ?>"><?= htmlspecialchars($p['name']) ?> (<?= htmlspecialchars($p['slug']) ?>)</option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label class="form-label">Typ</label>
<select name="type" class="form-input" required>
<option value="bug" selected>🐛 Bug / Fehlerbericht</option>
<option value="feature_request">💡 Feature Request / Vorschlag / Idee</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Umgebung (Environment)</label>
<select name="environment" class="form-input" required>
<option value="development">🔵 Entwicklung (Development)</option>
<option value="production" selected>🔴 Produktion (Production)</option>
<option value="staging">🟡 Staging</option>
<option value="testing">🧪 Testing</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Schweregrad / Priorität</label>
<select name="severity" class="form-input" required>
<option value="idea">💡 Idee / Gedanke für Später</option>
<option value="wishlist">⭐ Wunschliste / Backlog</option>
<option value="low">Niedrig</option>
<option value="medium" selected>Mittel</option>
<option value="high">Hoch</option>
<option value="critical">🔥 Kritisch</option>
</select>
</div>
</div>
<div class="form-grid" style="margin-top:1rem;">
<div class="form-group">
<label class="form-label">Push-ID (Optional, z. B. Workflow / Notification Token)</label>
<input type="text" name="push_id" class="form-input" placeholder="z. B. push_notify_88192">
</div>
<div class="form-group">
<label class="form-label">Ziel-Agent / Zuweisung (Optional)</label>
<input type="text" name="target_agent" class="form-input" placeholder="z. B. agent:code-fixer-01">
</div>
<div class="form-group">
<label class="form-label">Tags / Kategorien (Kommagetrennt)</label>
<input type="text" name="tags" class="form-input" placeholder="z. B. ui, security, database">
</div>
</div>
<div class="form-group" style="margin-top:1rem;">
<label class="form-label">Titel / Zusammenfassung</label>
<input type="text" name="title" class="form-input" required placeholder="z. B. NullReferenceException bei Order-Submit oder Idee für TOTP 2FA">
</div>
<div class="form-group" style="margin-top:1rem;">
<label class="form-label">Build / Version</label>
<input type="text" name="build_version" class="form-input" value="v1.4.2" placeholder="v1.4.2">
</div>
<div class="form-group" style="margin-top:1rem;">
<label class="form-label">Detaillierte Beschreibung</label>
<textarea name="description" class="form-input" rows="3" placeholder="Was ist passiert? Unter welchen Bedingungen? Oder was soll umgesetzt werden?"></textarea>
</div>
<div class="form-group" style="margin-top:1rem;">
<label class="form-label">Fehlermeldung / Exception Message (Nur bei Bugs)</label>
<textarea name="error_message" class="form-input" rows="2" placeholder="Exakte Fehlermeldung aus dem Log..."></textarea>
</div>
<div class="form-group" style="margin-top:1rem;">
<label class="form-label">Stacktrace / Log Ausschnitt (Nur bei Bugs)</label>
<textarea name="stack_trace" class="form-input" rows="4" style="font-family:monospace;" placeholder="at MyApp.Core.Service.DoWork()..."></textarea>
</div>
<button type="submit" class="btn" style="margin-top:1rem;">Item Speichern & Anlegen</button>
</form>
</div>
</div>
<!-- =============== Subtab: Fehler-Stream =============== -->
<div id="sub-bugtracker-errors" class="subtab-content">
<div class="stats-grid" style="grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));">
<div class="stat-card">
<div class="stat-header">Fehler (24 h)</div>
<div class="stat-value" style="color:var(--warning);"><?= $bugtrackerStats['errors_24h'] ?></div>
</div>
<div class="stat-card">
<div class="stat-header">💀 Offene Abstürze</div>
<div class="stat-value" style="color:var(--danger);"><?= $bugtrackerStats['fatal_open'] ?></div>
</div>
<div class="stat-card">
<div class="stat-header">🔇 Stummgeschaltete Gruppen</div>
<div class="stat-value" style="color:var(--text-muted);"><?= $bugtrackerStats['ignored_groups'] ?></div>
</div>
<div class="stat-card">
<div class="stat-header">davon Vorkommnisse</div>
<div class="stat-value" style="color:var(--text-muted); font-size:1.5rem;">
<?= number_format($bugtrackerStats['ignored_occurrences'], 0, ',', '.') ?>
</div>
</div>
</div>
<?php if ($bugtrackerStats['ignored_occurrences'] > 0): ?>
<div class="alert" style="background:rgba(91,157,255,0.10); border:1px solid rgba(91,157,255,0.25); color:#bfdbfe; font-weight:500; font-size:0.85rem;">
<?= number_format($bugtrackerStats['ignored_occurrences'], 0, ',', '.') ?> Vorkommnisse
wurden als bekannt eingestuft und aus dieser Ansicht herausgehalten — ohne sie zu verwerfen.
Der Zähler bleibt erhalten, damit eine auffällige Häufung trotzdem auffällt.
</div>
<?php endif; ?>
<div class="card">
<div class="card-header" style="display:flex; flex-wrap:wrap; justify-content:space-between; align-items:center; gap:0.75rem;">
<h2 class="card-title" style="margin:0;">
⚡ Fehler-Stream
<span class="badge badge-stopped" style="margin-left:0.5rem;"><?= (int)$errorTotal ?> Gruppen</span>
</h2>
<form method="GET" action="index.php" style="display:flex; flex-wrap:wrap; gap:0.4rem; align-items:center;">
<!-- Die offene Ansicht faehrt als verstecktes Feld mit. Ein GET-Formular
verwirft beim Absenden den Anker seiner action, und der Anker war
bisher die einzige Quelle dafuer, was angezeigt wird - nach jeder
Filteraenderung stand man wieder im Dashboard. Fest im Markup statt
aus dem DOM gelesen: hier ist ohne Zweifel bekannt, wo das Formular
steht. -->
<input type="hidden" name="view" value="sub-bugtracker-errors">
<select name="err_project" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="dcSubmit(this.form)">
<option value="all">🌐 Alle Projekte</option>
<?php foreach ($projects as $p): ?>
<option value="<?= e($p['slug']) ?>" <?= $errFilters['project_slug'] === $p['slug'] ? 'selected' : '' ?>>
<?= e($p['name']) ?>
</option>
<?php endforeach; ?>
</select>
<select name="err_level" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="dcSubmit(this.form)">
<?php
$levelOptions = [
'all' => '🎚️ Alle Klassen',
'fatal' => '💀 Absturz (fatal)',
'error' => '⚠️ Fehler (error)',
'warning' => 'ℹ️ Warnung',
];
foreach ($levelOptions as $value => $label): ?>
<option value="<?= e($value) ?>" <?= $errFilters['error_level'] === $value ? 'selected' : '' ?>><?= e($label) ?></option>
<?php endforeach; ?>
</select>
<select name="err_env" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="dcSubmit(this.form)">
<?php
$errEnvOptions = [
'all' => '🌍 Alle Umgebungen',
'production' => '🔴 Produktion',
'development' => '🔵 Entwicklung',
'staging' => '🟡 Staging',
'testing' => '🧪 Testing',
];
foreach ($errEnvOptions as $value => $label): ?>
<option value="<?= e($value) ?>" <?= $errFilters['environment'] === $value ? 'selected' : '' ?>><?= e($label) ?></option>
<?php endforeach; ?>
</select>
<select name="err_show" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="dcSubmit(this.form)">
<?php
$showOptions = [
'active' => '🔔 Nur relevante',
'ignored' => '🔇 Nur stummgeschaltete',
'all' => '📋 Alle',
];
foreach ($showOptions as $value => $label): ?>
<option value="<?= e($value) ?>" <?= $errShow === $value ? 'selected' : '' ?>><?= e($label) ?></option>
<?php endforeach; ?>
</select>
<select name="err_hours" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="dcSubmit(this.form)">
<?php
$hourOptions = [
'0' => '🕒 Gesamter Zeitraum',
'1' => 'Letzte Stunde',
'24' => 'Letzte 24 Stunden',
'168' => 'Letzte 7 Tage',
'720' => 'Letzte 30 Tage',
];
foreach ($hourOptions as $value => $label): ?>
<option value="<?= e($value) ?>" <?= (string)$errFilters['since_hours'] === $value ? 'selected' : '' ?>><?= e($label) ?></option>
<?php endforeach; ?>
</select>
<input type="text" name="err_q" value="<?= e($errFilters['search']) ?>" class="form-input"
style="width:190px; padding:0.3rem 0.6rem; font-size:0.8rem;" placeholder="🔍 Suchen oder #288"
title="Volltext ueber Titel, Beschreibung, Fehlermeldung, Tags und Dateipfad. Eine Zahl findet zusaetzlich das Item mit dieser Nummer; mit Raute (#288) nur dieses eine.">
<button type="submit" class="btn btn-sm">Filtern</button>
<a href="index.php#sub-bugtracker-errors" class="btn btn-sm btn-secondary">Zurücksetzen</a>
</form>
</div>
<!-- Dieselbe feste Verteilung wie im Bugtracker: Ohne sie zieht
eine lange Exception-Meldung die Fehlerspalte auf und quetscht
den Rest zusammen, je Zeile unterschiedlich weit. -->
<div class="tabelle-scroll">
<table class="tabelle-fest">
<thead>
<tr>
<th style="width:110px;">Klasse</th>
<th>Fehler</th>
<th style="width:180px;">Projekt / Umgebung</th>
<th style="width:104px;">Build</th>
<th style="width:82px;">Anzahl</th>
<th style="width:120px;">Zuletzt</th>
<th style="width:150px;">Aktionen</th>
</tr>
</thead>
<tbody>
<?php if ($errorItems === []): ?>
<tr>
<td colspan="7" style="text-align:center; padding:2.5rem; color:var(--text-muted);">
Keine Fehler für diese Filterkombination.
<?php if ($errShow === 'active'): ?>
<br><small>Stummgeschaltete Einträge sind ausgeblendet — über „Nur stummgeschaltete“ sichtbar.</small>
<?php endif; ?>
</td>
</tr>
<?php endif; ?>
<?php foreach ($errorItems as $item): ?>
<?php
$isIgnored = (string)$item['status'] === 'ignored';
$level = (string)($item['error_level'] ?? 'error');
$levelBadge = match ($level) {
'fatal' => '<span class="badge badge-down" style="font-weight:bold;">💀 FATAL</span>',
'warning' => '<span class="badge" style="background:rgba(91,157,255,0.2); color:#5b9dff; border:1px solid #5b9dff;">ℹ️ WARN</span>',
default => '<span class="badge badge-warning">⚠️ ERROR</span>',
};
// Rate im laufenden Fenster – auffällige Häufung sichtbar machen
$windowCount = (int)($item['rate_window_count'] ?? 0);
$windowFresh = !empty($item['rate_window_start'])
&& (time() - (int)strtotime((string)$item['rate_window_start'] . ' UTC')) < 3600;
?>
<tr style="<?= $isIgnored ? 'opacity:0.55;' : '' ?>">
<td><?= $levelBadge ?></td>
<td class="zelle-titel">
<?php // Kein mb_strimwidth mehr - die Kuerzung macht jetzt CSS,
// und zwar passend zur tatsaechlichen Spaltenbreite. 90 Zeichen
// waren auf einem breiten Schirm zu wenig und auf einem
// schmalen zu viel. ?>
<div class="titel" title="<?= e((string)$item['title']) ?>"><?= e((string)$item['title']) ?></div>
<div class="meta">
<?php if ($isIgnored): ?>
<span class="badge" style="background:rgba(255,255,255,0.06); font-size:0.68rem;">
🔇 stummgeschaltet<?= !empty($item['ignore_rule_id']) ? ' (Regel #' . (int)$item['ignore_rule_id'] . ')' : '' ?>
</span>
<?php endif; ?>
<?php if ($windowFresh && $windowCount > 1): ?>
<span class="badge" style="background:rgba(245,166,35,0.18); color:#fbbf24; font-size:0.68rem;">
📈 <?= $windowCount ?>× in dieser Stunde
</span>
<?php endif; ?>
<?php if (!empty($item['file_path'])): ?>
<span style="font-family:monospace;">📄 <?= e((string)$item['file_path']) ?><?= !empty($item['line_no']) ? ':' . (int)$item['line_no'] : '' ?></span>
<?php endif; ?>
</div>
</td>
<td>
<code style="font-size:0.75rem;"><?= e((string)$item['project_slug']) ?></code>
<div style="font-size:0.7rem; color:var(--text-muted);"><?= e(strtoupper((string)$item['environment'])) ?></div>
</td>
<td><code style="font-size:0.75rem;"><?= e((string)($item['build_version'] ?? '—')) ?></code></td>
<td>
<span class="badge" style="background:rgba(255,255,255,0.08); font-family:monospace;">
<?= number_format((int)$item['occurrence_count'], 0, ',', '.') ?>×
</span>
</td>
<td style="font-size:0.75rem; color:var(--text-muted);"><?= e(dc_ago($item['last_seen_at'] ?? null)) ?></td>
<td class="zelle-aktionen">
<div>
<button type="button" class="btn btn-sm btn-secondary"
onclick="openBugtrackerModal(<?= (int)$item['id'] ?>)" title="Details">🔍</button>
<form method="POST" action="index.php#sub-bugtracker-errors" style="display:inline">
<?= $csrfField ?>
<input type="hidden" name="action" value="bt_set_ignored">
<input type="hidden" name="item_id" value="<?= (int)$item['id'] ?>">
<input type="hidden" name="ignore" value="<?= $isIgnored ? '0' : '1' ?>">
<button type="submit" class="btn btn-sm btn-secondary"
title="<?= $isIgnored ? 'Wieder aufnehmen' : 'Stummschalten' ?>">
<?= $isIgnored ? '🔔' : '🔇' ?>
</button>
</form>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php if ($errorPages > 1): ?>
<div style="display:flex; justify-content:space-between; align-items:center; margin-top:1rem; padding-top:1rem; border-top:1px solid var(--border-soft);">
<span style="font-size:0.8rem; color:var(--text-muted);">
Seite <?= (int)$errPage ?> von <?= (int)$errorPages ?> (<?= (int)$errorTotal ?> Gruppen)
</span>
<div style="display:flex; gap:0.4rem;">
<?php if ($errPage > 1): ?>
<a href="<?= e(errUrl(['err_page' => $errPage - 1])) ?>" class="btn btn-sm btn-secondary">← Zurück</a>
<?php endif; ?>
<?php if ($errPage < $errorPages): ?>
<a href="<?= e(errUrl(['err_page' => $errPage + 1])) ?>" class="btn btn-sm btn-secondary">Weiter →</a>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
</div>
</div>
<!-- =============== Subtab: Ignore-Regeln =============== -->
<div id="sub-bugtracker-ignore" class="subtab-content">
<div class="card">
<div class="card-header"><h2 class="card-title">🔇 Bekannte, harmlose Fehler</h2></div>
<p style="color:var(--text-muted); font-size:0.875rem; margin-bottom:1.25rem; max-width:70ch;">
Ein Treffer bedeutet nicht „wegwerfen“: Der Fehler wird weiterhin erfasst und
hochgezählt, bleibt aber aus der Übersicht heraus und löst keine Benachrichtigung
aus. Der Zähler ist dabei der eigentliche Gewinn — dass ein bekannter Fehler
auftritt, ist normal; dass er plötzlich hundertmal so oft auftritt, ist ein Signal.
Dafür ist die Alarmschwelle da.
</p>
<form method="POST" action="index.php#sub-bugtracker-ignore">
<?= $csrfField ?>
<input type="hidden" name="action" value="ir_create">
<div class="form-grid">
<div class="form-group">
<label class="form-label">Projekt</label>
<select name="project_slug" class="form-input">
<option value="">🌐 Alle Projekte</option>
<?php foreach ($projects as $p): ?>
<option value="<?= e($p['slug']) ?>"><?= e($p['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label class="form-label">Vergleichsart</label>
<select name="match_type" class="form-input">
<option value="contains">Enthält (empfohlen)</option>
<option value="exception_class">Ausnahmeklasse exakt</option>
<option value="regex">Regulärer Ausdruck</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Alarm ab … pro Stunde</label>
<input type="number" name="alert_on_rate" class="form-input" min="0" placeholder="z. B. 500">
<small style="font-size:0.7rem; color:var(--text-muted);">0 oder leer = nie alarmieren</small>
</div>
</div>
<div class="form-group" style="margin-top:1rem;">
<label class="form-label">Suchmuster</label>
<input type="text" name="pattern" class="form-input" required
placeholder="z. B. Duplicate entry">
</div>
<div class="form-group" style="margin-top:1rem;">
<label class="form-label">Begründung — warum ist das harmlos?</label>
<textarea name="reason" class="form-input" rows="2" required
placeholder="z. B. Marktdaten kommen doppelt aus dem Feed. Der Eintrag wird ohnehin nur einmal benötigt, die Verarbeitung läuft normal weiter."></textarea>
<small style="font-size:0.7rem; color:var(--text-muted);">
Pflichtfeld: In sechs Monaten weiß sonst niemand mehr, warum hier weggeschaut wird.
</small>
</div>
<label style="display:flex; align-items:center; gap:0.5rem; margin-top:1rem; font-size:0.85rem; cursor:pointer;">
<input type="checkbox" name="apply_retroactively" value="1" checked>
Bereits erfasste, passende Einträge gleich mit stummschalten
</label>
<button type="submit" class="btn" style="margin-top:1.25rem;">Regel anlegen</button>
</form>
</div>
<div class="card">
<div class="card-header"><h2 class="card-title">Bestehende Regeln</h2></div>
<table>
<thead>
<tr>
<th>#</th>
<th>Projekt</th>
<th>Muster</th>
<th>Begründung</th>
<th>Alarmschwelle</th>
<th>Treffer</th>
<th>Status</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
<?php if ($ignoreRules === []): ?>
<tr>
<td colspan="8" style="text-align:center; padding:2rem; color:var(--text-muted);">
Noch keine Regeln angelegt.
</td>
</tr>
<?php endif; ?>
<?php foreach ($ignoreRules as $rule): ?>
<?php $enabled = !empty($rule['enabled']); ?>
<tr style="<?= $enabled ? '' : 'opacity:0.5;' ?>">
<td><?= (int)$rule['id'] ?></td>
<td>
<?php if (empty($rule['project_slug'])): ?>
<span class="badge badge-stopped">ALLE</span>
<?php else: ?>
<code style="font-size:0.75rem;"><?= e((string)$rule['project_slug']) ?></code>
<?php endif; ?>
</td>
<td>
<code style="font-size:0.78rem;"><?= e((string)$rule['pattern']) ?></code>
<div style="font-size:0.68rem; color:var(--text-muted);"><?= e((string)$rule['match_type']) ?></div>
</td>
<td style="font-size:0.78rem; color:var(--text-muted); max-width:320px;">
<?= e(mb_strimwidth((string)$rule['reason'], 0, 160, '…')) ?>
</td>
<td>
<?php if ($rule['alert_on_rate'] !== null): ?>
<span class="badge badge-warning"><?= (int)$rule['alert_on_rate'] ?>/h</span>
<?php else: ?>
<span style="color:var(--text-muted); font-size:0.8rem;">—</span>
<?php endif; ?>
</td>
<td>
<span class="badge" style="background:rgba(255,255,255,0.08); font-family:monospace;">
<?= number_format((int)$rule['match_count'], 0, ',', '.') ?>
</span>
<div style="font-size:0.68rem; color:var(--text-muted);">
<?= (int)($rule['item_count'] ?? 0) ?> Item(s)
<?php if (!empty($rule['last_match_at'])): ?>
· <?= e(dc_ago((string)$rule['last_match_at'])) ?>
<?php endif; ?>
</div>
</td>
<td>
<span class="badge badge-<?= $enabled ? 'up' : 'stopped' ?>">
<?= $enabled ? 'AKTIV' : 'INAKTIV' ?>
</span>
</td>
<td>
<div style="display:flex; gap:0.3rem; flex-wrap:wrap;">
<form method="POST" action="index.php#sub-bugtracker-ignore" style="display:inline">
<?= $csrfField ?>
<input type="hidden" name="action" value="ir_toggle">
<input type="hidden" name="rule_id" value="<?= (int)$rule['id'] ?>">
<input type="hidden" name="enable" value="<?= $enabled ? '0' : '1' ?>">
<button type="submit" class="btn btn-sm btn-secondary">
<?= $enabled ? 'Deaktivieren' : 'Aktivieren' ?>
</button>
</form>
<form method="POST" action="index.php#sub-bugtracker-ignore" style="display:inline">
<?= $csrfField ?>
<input type="hidden" name="action" value="ir_apply">
<input type="hidden" name="rule_id" value="<?= (int)$rule['id'] ?>">
<button type="submit" class="btn btn-sm btn-secondary" title="Rückwirkend auf bestehende Einträge anwenden">↺</button>
</form>
<form method="POST" action="index.php#sub-bugtracker-ignore" style="display:inline"
onsubmit="return confirm('Regel wirklich löschen? Bereits stummgeschaltete Einträge bleiben stumm, verlieren aber die Zuordnung.');">
<?= $csrfField ?>
<input type="hidden" name="action" value="ir_delete">
<input type="hidden" name="rule_id" value="<?= (int)$rule['id'] ?>">
<button type="submit" class="btn btn-sm btn-danger">🗑️</button>
</form>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<div class="card">
<div class="card-header"><h2 class="card-title">💻 Anbindung</h2></div>
<p style="color:var(--text-muted); font-size:0.85rem; margin-bottom:1rem;">
Ein Aufruf, den man in den globalen Exception-Handler hängt. Titel und
Dringlichkeit leitet der Server ab, die Regeln oben greifen automatisch.
</p>
<div class="prompt-box">POST <?= e($baseUrl) ?>/api/errors/v1/report
Authorization: Bearer &lt;DC_TOKEN&gt;
Content-Type: application/json
{
"project_slug": "polytrader",
"exception": "PDOException",
"message": "SQLSTATE[23000]: Duplicate entry '...' for key 'uq_market'",
"stack_trace": "...",
"level": "error", // fatal = Prozess beendet
"build": "v2.0.1",
"environment": "production",
"file": "src/Market/Importer.php",
"line": 142
}
Antwort:
{ "status": "success", "item_id": 42, "ignored": true,
"occurrence_count": 3841, "rate_alerted": false,
"message": "Als bekannt eingestuft, gezaehlt, nicht gemeldet." }</div>
</div>
</div>
</div>
<!-- ================= MODULE: TOKEN-VERWALTUNG ================= -->
<div id="tab-tokens" class="tab-content">
<div id="sub-tokens-masters" class="subtab-content active">
<div class="card">
<div class="card-header"><h2 class="card-title">👑 Master-Tokens (Selbst-Provisionierung für Client-Apps & Host-Skripte)</h2></div>
<table>
<thead>
<tr>
<th>Token ID</th>
<th>Bezeichnung</th>
<th>Typ / Identität</th>
<th>Projekt / Lizenz</th>
<th>Rechte (Scopes)</th>
<th>Sub-Tokens</th>
<th>Token Key</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
<?php foreach ($coreMasterTokens as $mTok): ?>
<?php
$rawVal = !empty($mTok['raw_token']) ? $mTok['raw_token'] : 'dc_master_...';
$masked = substr($rawVal, 0, 12) . '••••••••••••••••';
$scopesArr = json_decode($mTok['scopes'], true) ?: ['*'];
?>
<tr>
<td><code><?= htmlspecialchars($mTok['token_id']) ?></code></td>
<td><strong><?= htmlspecialchars($mTok['name']) ?></strong></td>
<td>
<span class="badge badge-warning"><?= strtoupper($mTok['owner_type']) ?></span><br>
<small><?= htmlspecialchars($mTok['owner_identity'] ?? '-') ?></small>
</td>
<td>
<?= htmlspecialchars($mTok['project_slug'] ?? '-') ?>
<?= !empty($mTok['license_key']) ? '<br><small>Lic: ' . htmlspecialchars($mTok['license_key']) . '</small>' : '' ?>
</td>
<td>
<?php foreach ($scopesArr as $sc): ?>
<span class="badge" style="background:rgba(255,255,255,0.06); font-size:0.75rem;"><?= htmlspecialchars($sc) ?></span>
<?php endforeach; ?>
</td>
<td>
<span class="badge badge-up"><?= (int)($mTok['sub_token_count'] ?? 0) ?> Sub-Tokens</span>
</td>
<td>
<code id="tok-core-<?= $mTok['token_id'] ?>" data-full="<?= htmlspecialchars($rawVal) ?>" data-masked="<?= htmlspecialchars($masked) ?>">
<?= htmlspecialchars($masked) ?>
</code>
<button type="button" class="btn btn-sm btn-secondary" onclick="toggleTokenMask('tok-core-<?= $mTok['token_id'] ?>')">👁️</button>
<button type="button" class="btn btn-sm btn-secondary" onclick="copyTokenValue('tok-core-<?= $mTok['token_id'] ?>')">📋</button>
</td>
<td>
<div style="display:flex; gap:0.3rem;">
<button type="button" class="btn btn-sm btn-secondary" title="Angaben ändern"
onclick='openTokenEdit(<?= htmlspecialchars(json_encode([
"token_id" => (string)$mTok["token_id"],
"name" => (string)$mTok["name"],
"owner_type" => (string)$mTok["owner_type"],
"owner_identity" => (string)($mTok["owner_identity"] ?? ""),
"project_slug" => (string)($mTok["project_slug"] ?? ""),
"license_key" => (string)($mTok["license_key"] ?? ""),
"environment" => (string)$mTok["environment"],
"expires_at" => substr((string)($mTok["expires_at"] ?? ""), 0, 10),
"scopes" => $scopesArr,
"subs" => (int)($mTok["sub_token_count"] ?? 0),
], JSON_UNESCAPED_UNICODE), ENT_QUOTES) ?>)'>✏️</button>
<?php if (!$mTok['revoked']): ?>
<form method="POST" action="index.php#tab-tokens" style="display:inline" onsubmit="return confirm('Master-Token widerrufen? Alle zugehörigen Sub-Tokens werden sofort kaskadierend mit gesperrt!');">
<?= $csrfField ?>
<input type="hidden" name="action" value="revoke_core_token">
<input type="hidden" name="token_id" value="<?= $mTok['token_id'] ?>">
<button type="submit" class="btn btn-sm btn-warning">Widerrufen</button>
</form>
<?php else: ?>
<span class="badge badge-down">WIDERUFEN</span>
<?php endif; ?>
<form method="POST" action="index.php#tab-tokens" style="display:inline" onsubmit="return confirm('Master-Token und alle Sub-Tokens DAUERHAFT LÖSCHEN?');">
<?= $csrfField ?>
<input type="hidden" name="action" value="delete_core_token">
<input type="hidden" name="token_id" value="<?= $mTok['token_id'] ?>">
<button type="submit" class="btn btn-sm btn-danger">🗑️ Löschen</button>
</form>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<div id="sub-tokens-subs" class="subtab-content">
<div class="card">
<div class="card-header"><h2 class="card-title">🔑 Aktive Sub-Tokens (Per Provisioning API Erstellt)</h2></div>
<table>
<thead>
<tr>
<th>Token ID</th>
<th>Parent Master ID</th>
<th>Bezeichnung / Client</th>
<th>Umgebung</th>
<th>Scopes</th>
<th>Token Key</th>
<th>Zuletzt Genutzt</th>
<th>Status</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
<?php
$subTokens = array_filter($coreAllTokens, fn($t) => $t['type'] === 'sub');
foreach ($subTokens as $sTok):
$scopesArr = json_decode($sTok['scopes'], true) ?: [];
$sRaw = !empty($sTok['raw_token']) ? $sTok['raw_token'] : $sTok['token_id'];
$sMasked = (strlen($sRaw) > 12) ? substr($sRaw, 0, 12) . '••••••••••••••••' : $sRaw;
?>
<tr>
<td><code><?= htmlspecialchars($sTok['token_id']) ?></code></td>
<td><code><?= htmlspecialchars($sTok['parent_token_id'] ?? '-') ?></code></td>
<td><strong><?= htmlspecialchars($sTok['name']) ?></strong></td>
<td>
<span class="badge badge-stopped"><?= strtoupper($sTok['environment']) ?></span>
</td>
<td>
<?php foreach ($scopesArr as $sc): ?>
<span class="badge" style="background:rgba(255,255,255,0.06); font-size:0.75rem;"><?= htmlspecialchars($sc) ?></span>
<?php endforeach; ?>
</td>
<td>
<code id="tok-sub-<?= $sTok['token_id'] ?>" data-full="<?= htmlspecialchars($sRaw) ?>" data-masked="<?= htmlspecialchars($sMasked) ?>">
<?= htmlspecialchars($sMasked) ?>
</code>
<button type="button" class="btn btn-sm btn-secondary" onclick="toggleTokenMask('tok-sub-<?= $sTok['token_id'] ?>')">👁️</button>
<button type="button" class="btn btn-sm btn-secondary" onclick="copyTokenValue('tok-sub-<?= $sTok['token_id'] ?>')">📋</button>
</td>
<td><?= $sTok['last_used_at'] ? htmlspecialchars($sTok['last_used_at']) : 'Noch nie' ?></td>
<td><span class="badge badge-<?= $sTok['revoked'] ? 'down' : 'up' ?>"><?= $sTok['revoked'] ? 'WIDERUFEN' : 'AKTIV' ?></span></td>
<td>
<div style="display:flex; gap:0.3rem;">
<?php if (!$sTok['revoked']): ?>
<form method="POST" action="index.php#tab-tokens" style="display:inline" onsubmit="return confirm('Sub-Token widerrufen?');">
<?= $csrfField ?>
<input type="hidden" name="action" value="revoke_core_token">
<input type="hidden" name="token_id" value="<?= $sTok['token_id'] ?>">
<button type="submit" class="btn btn-sm btn-warning">Widerrufen</button>
</form>
<?php endif; ?>
<form method="POST" action="index.php#tab-tokens" style="display:inline" onsubmit="return confirm('Sub-Token DAUERHAFT LÖSCHEN?');">
<?= $csrfField ?>
<input type="hidden" name="action" value="delete_core_token">
<input type="hidden" name="token_id" value="<?= $sTok['token_id'] ?>">
<button type="submit" class="btn btn-sm btn-danger">🗑️ Löschen</button>
</form>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<!-- =============== Dialog: Master-Token bearbeiten =============== -->
<!--
Alles ausser dem Tokenwert selbst und der Nummer. Zuvor
erzwang ein Schreibfehler im Namen oder ein nachtraeglich
gebrauchtes Recht ein komplett neues Token - mit der Folge,
dass der neue Wert auf alle Maschinen verteilt werden muss
und am Ende das alte mitlaeuft, weil eine vergessen wurde.
-->
<div id="tokenEditModal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.75); backdrop-filter:blur(8px); z-index:9999; align-items:center; justify-content:center; padding:1rem;">
<div class="card" style="max-width:820px; width:100%; max-height:90vh; overflow-y:auto; margin:0;">
<div class="card-header">
<h2 class="card-title">✏️ Token bearbeiten — <span id="tokEditName"></span></h2>
</div>
<div style="font-size:0.82rem; color:var(--text-muted); margin-bottom:1rem;">
Der <strong>Tokenwert</strong> und die <strong>Nummer</strong>
(<code id="tokEditIdText"></code>) bleiben unverändert — auf den
Maschinen muss nichts ausgetauscht werden.
</div>
<div id="tokEditSubHinweis" style="display:none; background:rgba(245,166,35,0.10); border-left:3px solid var(--warning); border-radius:8px; padding:0.75rem 1rem; margin-bottom:1rem; font-size:0.83rem;">
An diesem Token hängen <strong><span id="tokEditSubCount"></span> Sub-Tokens</strong>.
Sie haben ihre Rechte beim Ausstellen bekommen und behalten sie —
eine Einschränkung hier wirkt <em>nicht</em> rückwirkend auf sie.
Wer auch die Kinder beschneiden will, widerruft sie und stellt sie neu aus.
</div>
<form method="POST" action="index.php#tab-tokens">
<?= $csrfField ?>
<input type="hidden" name="action" value="edit_master_token">
<input type="hidden" name="token_id" id="tokEditId">
<div class="form-grid">
<div class="form-group">
<label class="form-label">Bezeichnung / Name</label>
<input type="text" name="name" id="tokEditNameInput" class="form-input" required>
</div>
<div class="form-group">
<label class="form-label">Identitätstyp (Owner Type)</label>
<select name="owner_type" id="tokEditOwnerType" class="form-input" required>
<option value="custom">Custom / Allgemein</option>
<option value="project">Projekt-Gebunden</option>
<option value="license">Lizenz-Gebunden</option>
<option value="host">Host / Infrastruktur Server</option>
<option value="dev_agent">Entwickler KI-Agent</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Freie Identität / Host / HWID</label>
<input type="text" name="owner_identity" id="tokEditIdentity" class="form-input">
</div>
<div class="form-group">
<label class="form-label">Projekt</label>
<select name="project_slug" id="tokEditProject" class="form-input">
<option value="">-- Keins (Universal) --</option>
<?php foreach ($projects as $p): ?>
<option value="<?= e($p['slug']) ?>"><?= e($p['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label class="form-label">Umgebung</label>
<select name="environment" id="tokEditEnv" class="form-input" required>
<option value="all">🌐 Alle Umgebungen</option>
<option value="production">🔴 Nur Produktiv</option>
<option value="development">🔵 Nur Entwicklung</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Gültig bis (leer = unbefristet)</label>
<input type="date" name="expires_at" id="tokEditExpires" class="form-input">
</div>
</div>
<div class="form-group" style="margin-top:0.5rem;">
<label class="form-label">Lizenzschlüssel (nur bei Lizenz-Bindung)</label>
<input type="text" name="license_key" id="tokEditLicense" class="form-input"
placeholder="XXXXX-XXXXX-XXXXX-XXXXX-XXXXX">
</div>
<div class="form-group" style="margin-top:1rem;">
<label class="form-label">Erlaubte Rechte (Scopes)</label>
<p style="font-size:0.75rem; color:var(--text-muted); margin:0.25rem 0 0.6rem 0;">
Mindestens eines. Stärkere Rechte schließen die schwächeren ein.
</p>
<div style="display:grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap:0.5rem;">
<?php foreach ($scopeChoices as $value => $labels): ?>
<label style="display:flex; align-items:flex-start; gap:0.5rem; font-size:0.85rem; cursor:pointer; background:rgba(255,255,255,0.03); border:1px solid var(--border-glass); border-radius:10px; padding:0.6rem 0.75rem;">
<input type="checkbox" name="scopes[]" value="<?= e($value) ?>"
class="tok-edit-scope" style="margin-top:3px;">
<span>
<strong><?= e($labels[0]) ?></strong>
<div style="font-size:0.75rem; color:var(--text-muted);"><?= e($labels[1]) ?></div>
</span>
</label>
<?php endforeach; ?>
</div>
</div>
<div style="display:flex; justify-content:flex-end; gap:0.75rem; margin-top:1.25rem;">
<button type="button" class="btn btn-secondary" onclick="closeTokenEdit()">Abbrechen</button>
<button type="submit" class="btn">Speichern</button>
</div>
</form>
</div>
</div>
<div id="sub-tokens-create" class="subtab-content">
<div class="card">
<div class="card-header"><h2 class="card-title">➕ Neuen Token erstellen</h2></div>
<!--
Frueher hiess das hier "Master-Token erstellen" - und genau
deshalb fand niemand das Werkbank-Token: Es entsteht an
derselben Stelle, nur mit anderem Identitaetstyp. Der Name
der Ansicht versprach etwas anderes als sie kann.
-->
<div style="background:rgba(91,157,255,0.08); border-left:3px solid var(--primary); border-radius:8px; padding:0.85rem 1.1rem; margin-bottom:1.25rem; font-size:0.86rem;">
<strong>Werkbank-Token für einen Coding-Agenten?</strong> Hier richtig.
Identitätstyp auf <em>Entwickler KI-Agent</em>, ein Projekt auswählen
(nicht „Keins“) und als Rechte <code>Bugtracker verwalten</code> plus
<code>Befunde lesen</code> ankreuzen — <code>Alle Rechte</code> nicht.
<div style="color:var(--text-muted); margin-top:0.5rem;">
Die Projektbindung ist keine Formsache: Aus ihr leitet der Server
ab, wer eine Meldung abgesetzt hat. Ein Token für alle Projekte,
und das Signal „drei Projekte melden dasselbe“ ist wertlos.
Ausführlich in <code>docs/AGENTEN_ANBINDEN.md</code>.
</div>
</div>
<form method="POST" action="index.php#tab-tokens">
<?= $csrfField ?>
<input type="hidden" name="action" value="create_master_token">
<div class="form-grid">
<div class="form-group">
<label class="form-label">Bezeichnung / Name</label>
<input type="text" name="name" class="form-input" required placeholder="z. B. PolyTrader Prod Server Master Key">
</div>
<div class="form-group">
<label class="form-label">Identitätstyp (Owner Type)</label>
<select name="owner_type" class="form-input" required>
<option value="custom" selected>Custom / Allgemein</option>
<option value="project">Projekt-Gebunden</option>
<option value="license">Lizenz-Gebunden</option>
<option value="host">Host / Infrastruktur Server</option>
<option value="dev_agent">Entwickler KI-Agent</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Freie Identität / Host / HWID (Optional)</label>
<input type="text" name="owner_identity" class="form-input" placeholder="z. B. srv-db-01 oder HWID-88A9...">
</div>
<div class="form-group">
<label class="form-label">Projekt (Optional)</label>
<select name="project_slug" class="form-input">
<option value="">-- Keins (Universal) --</option>
<?php foreach ($projects as $p): ?>
<option value="<?= htmlspecialchars($p['slug']) ?>"><?= htmlspecialchars($p['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label class="form-label">Umgebung (Environment Limit)</label>
<select name="environment" class="form-input" required>
<option value="all" selected>🌐 Alle Umgebungen (All)</option>
<option value="production">🔴 Nur Produktiv (Production)</option>
<option value="development">🔵 Nur Entwicklung (Development)</option>
</select>
</div>
</div>
<div class="form-group" style="margin-top:1rem;">
<label class="form-label">Erlaubte Rechte (Scopes)</label>
<p style="font-size:0.75rem; color:var(--text-muted); margin:0.25rem 0 0.6rem 0;">
Stärkere Rechte schließen die schwächeren ein: <code>bugtracker:manage</code>
umfasst Lesen und Melden, <code>updateservice:publish</code> auch Lesen.
Für einen Coding-Agenten genügt daher <em>Bugtracker verwalten</em>.
</p>
<div style="display:grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap:0.5rem;">
<?php
foreach ($scopeChoices as $value => $labels): ?>
<label style="display:flex; align-items:flex-start; gap:0.5rem; font-size:0.85rem; cursor:pointer; background:rgba(255,255,255,0.03); border:1px solid var(--border-glass); border-radius:10px; padding:0.6rem 0.75rem;">
<?php
// Vorangekreuzt ist "Bugtracker verwalten", nicht "Alle
// Rechte". Zuvor war der Vollzugriff die Vorgabe - wer
// ein Agenten-Token anlegte und den Haken uebersah, gab
// damit alles heraus, inklusive Releases und
// Tokenausstellung. Die gefaehrlichste Auswahl gehoert
// nicht in die Vorgabe.
?>
<input type="checkbox" name="scopes[]" value="<?= e($value) ?>" <?= $value === 'bugtracker:manage' ? 'checked' : '' ?> style="margin-top:3px;">
<span>
<strong><?= e($labels[0]) ?></strong>
<span style="display:block; font-size:0.72rem; color:var(--text-muted); margin-top:2px;"><?= e($labels[1]) ?></span>
</span>
</label>
<?php endforeach; ?>
</div>
</div>
<div class="form-group" style="margin-top:1rem;">
<label class="form-label">Gültig bis (optional)</label>
<input type="date" name="expires_at" class="form-input">
<small style="font-size:0.72rem; color:var(--text-muted);">
Leer lassen für unbegrenzte Gültigkeit. Abgelaufene Tokens werden ab sofort
tatsächlich abgewiesen — die Spalte existierte zuvor, wurde aber nie geprüft.
</small>
</div>
<button type="submit" class="btn" style="margin-top:1.25rem;">Master-Token Erstellen</button>
</form>
</div>
</div>
</div>
<!-- ================= MODULE 5: SYSTEM & DB ================= -->
<div id="tab-system" class="tab-content">
<div id="sub-system-status" class="subtab-content active">
<div class="card">
<div class="card-header"><h2 class="card-title">⚙️ Server &amp; Umgebung</h2></div>
<div class="form-grid">
<div class="form-group">
<label class="form-label">PHP-Version</label>
<input type="text" readonly class="form-input" value="<?= e(PHP_VERSION) ?>">
</div>
<div class="form-group">
<label class="form-label">Server-Software</label>
<input type="text" readonly class="form-input" value="<?= e((string)($_SERVER['SERVER_SOFTWARE'] ?? 'unbekannt')) ?>">
</div>
<div class="form-group">
<label class="form-label">Anwendungsversion</label>
<input type="text" readonly class="form-input" value="<?= e((string)Config::get('app.version', 'unbekannt')) ?>">
</div>
<div class="form-group">
<!--
Frueher standen Datenbankname und Hostname hier fest im
Quelltext. Jetzt kommt der Wert aus der Konfiguration,
und der Hostname wird nicht mehr ausgegeben.
-->
<label class="form-label">Datenbank</label>
<input type="text" readonly class="form-input" value="<?= e((string)Config::get('db.dbname', 'unbekannt')) ?>">
</div>
<div class="form-group">
<label class="form-label">Zeitzone (Anzeige)</label>
<input type="text" readonly class="form-input" value="<?= e((string)Config::get('app.timezone', 'UTC')) ?> — Speicherung in UTC">
</div>
<div class="form-group">
<label class="form-label">Debug-Modus</label>
<input type="text" readonly class="form-input" value="<?= Config::isDebug() ? 'AKTIV — auf Produktivsystemen abschalten' : 'aus' ?>">
</div>
</div>
</div>
<div class="card">
<div class="card-header"><h2 class="card-title">🩺 Systemzustand</h2></div>
<table>
<thead>
<tr><th>Prüfung</th><th>Zustand</th><th>Hinweis</th></tr>
</thead>
<tbody>
<tr>
<td>Watchdog-Evaluator</td>
<td>
<span class="badge badge-<?= $evaluatorStale ? 'down' : 'up' ?>">
<?= $evaluatorStale ? 'INAKTIV' : 'AKTIV' ?>
</span>
</td>
<td style="font-size:0.8rem; color:var(--text-muted);">
<?php if ($evaluatorLastRun !== null): ?>
Letzter Lauf: <?= e(dc_dt((string)$evaluatorLastRun)) ?> (<?= e(dc_ago((string)$evaluatorLastRun)) ?>)
<?php else: ?>
Noch nie gelaufen — ohne ihn bleiben ausgefallene Monitore „up“.
<?php endif; ?>
</td>
</tr>
<tr>
<td>Log-Verzeichnis beschreibbar</td>
<?php $logWritable = is_dir(DC_VAR . '/log') ? is_writable(DC_VAR . '/log') : is_writable(DC_ROOT); ?>
<td><span class="badge badge-<?= $logWritable ? 'up' : 'warning' ?>"><?= $logWritable ? 'OK' : 'PRÜFEN' ?></span></td>
<td style="font-size:0.8rem; color:var(--text-muted);">var/log/ — Fehlerprotokolle</td>
</tr>
<tr>
<td>Konfiguration ausserhalb Git</td>
<td><span class="badge badge-up">OK</span></td>
<td style="font-size:0.8rem; color:var(--text-muted);">config/config.php ist per .gitignore ausgeschlossen</td>
</tr>
</tbody>
</table>
</div>
</div>
<div id="sub-system-rocketchat" class="subtab-content">
<div class="card">
<div class="card-header">
<h2 class="card-title">💬 Rocket.Chat Benachrichtigungen &amp; Statusberichte</h2>
</div>
<p style="color:var(--text-muted); font-size:0.875rem; margin-bottom:1.5rem;">
Hier konfigurieren Sie die automatischen Rocket.Chat-Benachrichtigungen. Alle 12 Stunden wird ein detaillierter Systemstatusbericht an den Status-Kanal gesendet. Kritische Systemausfälle und Bugs landen sofort im Alarm-Kanal.
</p>
<form method="POST">
<?= $csrfField ?>
<input type="hidden" name="action" value="save_rocketchat_settings">
<div class="form-grid">
<div class="form-group" style="grid-column: span 2;">
<label class="form-label" style="display:flex; align-items:center; gap:0.5rem; cursor:pointer;">
<input type="checkbox" name="rocketchat_enabled" value="1" <?= !empty($rcConfig['enabled']) ? 'checked' : '' ?>>
<strong>Rocket.Chat Benachrichtigungen aktivieren</strong>
</label>
</div>
<div class="form-group">
<label class="form-label">Rocket.Chat Server-URL</label>
<input type="url" name="rocketchat_url" class="form-input" required value="<?= e($rcConfig['url']) ?>" placeholder="https://chat.wh1.mhdf.de">
</div>
<div class="form-group">
<label class="form-label">Benutzername</label>
<input type="text" name="rocketchat_username" class="form-input" required value="<?= e($rcConfig['username']) ?>" placeholder="deploymentcenter">
</div>
<div class="form-group">
<label class="form-label">Passwort</label>
<input type="password" name="rocketchat_password" class="form-input" required value="<?= e($rcConfig['password']) ?>">
</div>
<div class="form-group">
<label class="form-label">SSL-Zertifikat verifizieren</label>
<label class="form-label" style="display:flex; align-items:center; gap:0.5rem; cursor:pointer; font-weight:normal; margin-top:0.3rem;">
<input type="checkbox" name="rocketchat_verify_ssl" value="1" <?= !empty($rcConfig['verify_ssl']) ? 'checked' : '' ?>>
SSL-Zertifikat prüfen (bei selbstsignierten/unsicheren Zertifikaten deaktivieren)
</label>
</div>
<div class="form-group">
<label class="form-label">Status-Kanal (12h Bericht)</label>
<input type="text" name="rocketchat_status_channel" class="form-input" required value="<?= e($rcConfig['status_channel']) ?>" placeholder="#DC-Systemstatus">
</div>
<div class="form-group">
<label class="form-label">Alarm-Kanal (Kritische Ausfälle &amp; Bugs)</label>
<input type="text" name="rocketchat_alert_channel" class="form-input" required value="<?= e($rcConfig['alert_channel']) ?>" placeholder="#DC-Alerts">
</div>
<div class="form-group" style="grid-column: span 2;">
<label class="form-label">Statusbericht Intervall (Sekunden)</label>
<input type="number" name="rocketchat_report_interval" class="form-input" min="300" step="60" value="<?= e((string)$rcConfig['report_interval']) ?>">
<span style="font-size:0.75rem; color:var(--text-muted);">Standard: 43200 Sekunden (12 Stunden)</span>
</div>
</div>
<div style="margin-top:1.5rem; display:flex; gap:1rem;">
<button type="submit" class="btn btn-primary">💾 Einstellungen Speichern</button>
</div>
</form>
</div>
<div class="card" style="margin-top:1.5rem;">
<div class="card-header">
<h2 class="card-title">🧪 Aktionen &amp; Testlauf</h2>
</div>
<p style="color:var(--text-muted); font-size:0.875rem; margin-bottom:1.5rem;">
Hier können Sie die Erreichbarkeit Ihres Rocket.Chat-Servers testen oder sofort einen 12-Stunden-Statusbericht auslösen.
</p>
<div style="display:flex; flex-wrap:wrap; gap:1rem;">
<form method="POST">
<?= $csrfField ?>
<input type="hidden" name="action" value="test_rocketchat_connection">
<button type="submit" class="btn btn-secondary">🧪 Verbindung &amp; Kanäle Testen</button>
</form>
<form method="POST">
<?= $csrfField ?>
<input type="hidden" name="action" value="send_rocketchat_report_now">
<button type="submit" class="btn btn-secondary">📊 12h Statusbericht Jetzt Senden</button>
</form>
</div>
<div style="margin-top:1.5rem; background:rgba(0,0,0,0.2); padding:1rem; border-radius:8px; font-size:0.85rem; color:var(--text-muted);">
<strong>📌 Status des automatischen 12h Cron-Jobs:</strong><br>
• Letzter Bericht: <?= !empty($rcCronJob['last_run_utc']) ? e(dc_dt((string)$rcCronJob['last_run_utc'])) . ' (' . e(dc_ago((string)$rcCronJob['last_run_utc'])) . ')' : 'Noch nie gelaufen' ?><br>
• Letzter Status: <span class="badge badge-<?= ($rcCronJob['last_status'] ?? '') === 'ok' ? 'up' : 'warning' ?>"><?= e((string)($rcCronJob['last_status'] ?? 'bereit')) ?></span><br>
• Nächster geplanter Lauf: In ca. <?= !empty($rcCronJob['last_run_utc']) ? max(0, (int)round(($rcConfig['report_interval'] - (time() - strtotime((string)$rcCronJob['last_run_utc'] . ' UTC'))) / 3600)) . ' Std.' : 'sofort beim nächsten Tick' ?>
</div>
</div>
</div>
<div id="sub-system-users" class="subtab-content">
<?php if (is_array($freshRecoveryToken)): ?>
<div class="card" style="border-color:rgba(99,102,241,0.5);">
<div class="card-header"><h2 class="card-title">🔐 Neuer Notfalltoken für <?= e((string)$freshRecoveryToken['username']) ?></h2></div>
<p style="color:var(--text-muted); font-size:0.875rem; margin-bottom:0.75rem;">
<strong>Jetzt notieren.</strong> In der Datenbank liegt nur ein Hash —
dieser Klartext wird nicht wieder angezeigt, auch nicht beim Neuladen
dieser Seite. Ausdrucken und dorthin legen, wo der Ersatzschlüssel liegt.
</p>
<div style="font-family:'Roboto Mono', monospace; font-size:1.5rem; letter-spacing:0.08em;
background:rgba(15,23,42,0.7); border:1px solid var(--card-border, rgba(255,255,255,0.12));
border-radius:10px; padding:1rem 1.25rem; user-select:all; word-break:break-all;">
<?= e((string)$freshRecoveryToken['token']) ?>
</div>
<p style="color:var(--text-muted); font-size:0.8rem; margin-top:0.75rem;">
Beim Eintippen zählen nur Buchstaben und Ziffern — Bindestriche und
Groß-/Kleinschreibung sind gleichgültig. Der Token gilt für genau eine
Anmeldung.
</p>
</div>
<?php endif; ?>
<div class="card">
<div class="card-header"><h2 class="card-title">🔑 Meine Hardwarekeys</h2></div>
<?php if (!$twoFactorReady): ?>
<div class="alert" style="background:rgba(255,180,0,0.12); border:1px solid rgba(255,180,0,0.3); color:#fde68a; font-weight:500;">
<strong>Migration 019 fehlt.</strong> Hardwarekeys und Notfalltoken stehen
erst danach zur Verfügung. Bis dahin genügt zur Anmeldung das Passwort.
Die Migration läuft unter <em>System → 🗄️ DB Migration</em>.
</div>
<?php else: ?>
<p style="color:var(--text-muted); font-size:0.875rem; margin-bottom:1.25rem;">
Sobald hier ein Schlüssel steht, verlangt die Anmeldung ihn — das Passwort
allein genügt dann nicht mehr. Ein Schlüssel gilt nur für
<code><?= e(\Deploymentcenter\Core\Webauthn\WebauthnServer::rpId()) ?></code>;
auf der Testinstanz muss er eigens registriert werden.
<strong>Registrieren Sie zwei</strong> — oder legen Sie sich einen
Notfalltoken an, bevor Sie sich auf einen einzigen verlassen.
</p>
<?php if ($myCredentials === []): ?>
<p style="color:var(--warning); font-size:0.875rem; margin-bottom:1rem;">
Noch kein Schlüssel registriert. Ihre Anmeldung läuft derzeit nur über das Passwort.
</p>
<?php else: ?>
<table style="margin-bottom:1.25rem;">
<thead>
<tr>
<th>Bezeichnung</th>
<th>Herkunft</th>
<th>Registriert</th>
<th>Zuletzt benutzt</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
<?php foreach ($myCredentials as $cred): ?>
<tr>
<td><strong><?= e((string)$cred['label']) ?></strong></td>
<td style="font-size:0.8rem;">
<?php if (!empty($cred['attested'])): ?>
<span style="color:var(--success); font-weight:600;">🔒 Hardware</span>
<?php if (!empty($cred['attestation_root'])): ?>
<div style="color:var(--text-muted); font-size:0.75rem;">
<?= e((string)$cred['attestation_root']) ?>
</div>
<?php endif; ?>
<?php if (!empty($cred['aaguid_text'])): ?>
<div style="color:var(--text-muted); font-size:0.7rem; font-family:monospace;"
title="Gerätekennung — lässt sich in die Modellliste übernehmen.">
<?= e((string)$cred['aaguid_text']) ?>
</div>
<?php endif; ?>
<?php else: ?>
<span style="color:var(--text-muted);">nicht nachgewiesen</span>
<div style="color:var(--text-muted); font-size:0.75rem;">
<?php if (!empty($cred['attestation_fmt'])): ?>
Format <?= e((string)$cred['attestation_fmt']) ?>
<?php else: ?>
vor der Prüfung aufgenommen
<?php endif; ?>
</div>
<?php endif; ?>
</td>
<td style="font-size:0.8rem; color:var(--text-muted);"><?= e((string)$cred['created_at']) ?></td>
<td style="font-size:0.8rem; color:var(--text-muted);">
<?= !empty($cred['last_used_at']) ? e((string)$cred['last_used_at']) : 'nie' ?>
</td>
<td>
<form method="POST" action="index.php" style="display:inline;"
onsubmit="return confirm('Schlüssel „<?= e((string)$cred['label']) ?>“ entfernen? Ist es der letzte, genügt danach wieder das Passwort allein.');">
<?= $csrfField ?>
<input type="hidden" name="action" value="delete_credential">
<input type="hidden" name="_return" value="sub-system-users">
<input type="hidden" name="user_id" value="<?= Auth::userId() ?>">
<input type="hidden" name="credential_id" value="<?= (int)$cred['id'] ?>">
<button type="submit" class="btn btn-sm btn-danger">Entfernen</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<div style="display:flex; flex-wrap:wrap; gap:0.5rem; align-items:center;">
<input type="text" id="keyLabel" class="form-input" maxlength="64"
placeholder="Bezeichnung, z. B. „Yubikey blau“" style="width:260px;">
<button type="button" class="btn" id="btnRegisterKey">Hardwarekey registrieren</button>
</div>
<div id="keyRegisterStatus" style="margin-top:0.75rem; font-size:0.85rem; color:var(--text-muted);"></div>
<p style="color:var(--text-muted); font-size:0.8rem; margin-top:1rem;">
Notfalltoken für Ihr eigenes Konto:
<?php if ($myRecovery['present']): ?>
hinterlegt seit <?= e((string)$myRecovery['created_at']) ?>.
<?php elseif (!empty($myRecovery['used_at'])): ?>
<span style="color:var(--warning);">verbraucht am <?= e((string)$myRecovery['used_at']) ?>.</span>
<?php else: ?>
keiner hinterlegt.
<?php endif; ?>
Erzeugen lässt er sich in der Tabelle darunter.
</p>
<?php endif; ?>
</div>
<?php if ($twoFactorReady): ?>
<div class="card">
<div class="card-header"><h2 class="card-title">🛡️ Welche Schlüssel zugelassen sind</h2></div>
<?php if (!$attestationReady): ?>
<div class="alert" style="background:rgba(255,180,0,0.12); border:1px solid rgba(255,180,0,0.3); color:#fde68a; font-weight:500;">
<strong>Migration 021 fehlt.</strong> Die Herkunft eines Schlüssels lässt sich
erst danach festhalten. Bis dahin gilt „jeder Schlüssel“ — also das Verhalten
vor dieser Funktion. Die Migration läuft unter <em>System → 🗄️ DB Migration</em>.
</div>
<?php else: ?>
<p style="color:var(--text-muted); font-size:0.875rem; margin-bottom:1.25rem;">
WebAuthn allein unterscheidet nicht zwischen einem Yubikey und einem
Passwortverwalter — beide sind für den Browser gültige Authenticatoren, und
<code>cross-platform</code> in den Vorgaben ist nur ein Hinweis an die
Oberfläche, keine Prüfung. Wer <strong>ausschließlich Hardware</strong>
zulassen will, braucht die Attestation: ein Zertifikat, das der Schlüssel ab
Werk trägt und das auf eine Herstellerwurzel führt. Software kann sie nicht
herleiten, weil der zugehörige private Schlüssel das Sicherheitselement nie
verlässt.
</p>
<form method="POST" action="index.php">
<?= $csrfField ?>
<input type="hidden" name="action" value="save_webauthn_policy">
<input type="hidden" name="_return" value="sub-system-users">
<?php
$stufen = [
HardwarePolicy::OFF => [
'Jeder Authenticator',
'Es wird keine Attestation angefordert und nichts geprüft. '
. 'Passwortverwalter und eingebaute Passkeys sind zugelassen. '
. 'Das Verhalten bis Fassung 2.9.1.',
],
HardwarePolicy::REGISTER => [
'Neue Schlüssel nur als Hardware',
'Eine neue Registrierung muss eine geprüfte Herstellerattestation '
. 'vorlegen. Bereits hinterlegte Schlüssel melden sich weiter an — '
. 'das ist die Stufe, auf der Sie Ihren Yubikey neu aufnehmen, '
. 'ohne sich auszusperren.',
],
HardwarePolicy::STRICT => [
'Auch die Anmeldung nur mit Hardware',
'Zusätzlich werden bei der Anmeldung nur noch Schlüssel angeboten und '
. 'akzeptiert, deren Herkunft geprüft ist. Erst diese Stufe erfüllt '
. 'ein Protokoll, das „nur Hardware“ verlangt.',
],
];
?>
<?php foreach ($stufen as $wert => $beschreibung): ?>
<label style="display:block; margin-bottom:0.9rem; cursor:pointer;">
<input type="radio" name="policy_level" value="<?= e($wert) ?>"
<?= $policyLevel === $wert ? 'checked' : '' ?>>
<strong style="margin-left:0.35rem;"><?= e($beschreibung[0]) ?></strong>
<span style="display:block; margin-left:1.6rem; color:var(--text-muted); font-size:0.82rem;">
<?= e($beschreibung[1]) ?>
</span>
</label>
<?php endforeach; ?>
<?php if ($myAttestedCount === 0): ?>
<div class="alert" style="background:rgba(255,180,0,0.12); border:1px solid rgba(255,180,0,0.3); color:#fde68a; font-size:0.85rem; margin:1rem 0;">
Für Ihr Konto ist noch kein geprüfter Hardwarekey hinterlegt. Die Stufe
<em>Auch die Anmeldung nur mit Hardware</em> lässt sich deshalb nicht
einschalten — sie würde Ihren eigenen Zugang sperren. Stellen Sie erst auf
<em>Neue Schlüssel nur als Hardware</em> und nehmen Sie den Schlüssel neu auf.
</div>
<?php endif; ?>
<div class="form-group" style="margin-top:1rem;">
<label class="form-label">Zugelassene Modelle (optional)</label>
<textarea name="policy_aaguids" class="form-input" rows="3"
style="font-family:monospace; font-size:0.8rem;"
placeholder="leer = jedes Gerät des Herstellers&#10;d7781e5d-e353-46aa-afe2-3ca49f13332a"><?= e($policyAllowlist) ?></textarea>
</div>
<p style="color:var(--text-muted); font-size:0.78rem; margin:0.4rem 0 1rem;">
Eine Gerätekennung je Zeile. <strong>Leer lassen ist der Normalfall:</strong>
Die Wurzelprüfung beantwortet bereits „stammt von diesem Hersteller“. Die Liste
verengt auf einzelne Modelle und hat einen Unterhaltspreis — neue
Firmwarestände bekommen neue Kennungen, und eine ungepflegte Liste weist
irgendwann einen echten Schlüssel ab. Die Kennung Ihrer Schlüssel steht in
der Tabelle oben.
</p>
<p style="color:var(--text-muted); font-size:0.78rem; margin-bottom:1rem;">
Hinterlegte Herstellerwurzeln: <strong><?= (int)$policyRootCount ?></strong>
(mitgeliefert: Yubico). Weitere Hersteller lassen sich über
<code>security.webauthn_root_files</code> in der <code>config.php</code>
ergänzen — bewusst dort und nicht hier, damit sich die Prüfung nicht über
dieselbe Oberfläche aushebeln lässt, die sie schützt.
</p>
<button type="submit" class="btn">Regel speichern</button>
</form>
<?php endif; ?>
</div>
<?php endif; ?>
<div class="card">
<div class="card-header"><h2 class="card-title">👤 Benutzerkonten</h2></div>
<?php if (!$usersSchemaReady): ?>
<div class="alert" style="background:rgba(255,180,0,0.12); border:1px solid rgba(255,180,0,0.3); color:#fde68a; font-weight:500;">
<strong>Migration 012 fehlt.</strong> Rollen, Deaktivierung und der Zeitpunkt
der letzten Anmeldung stehen erst danach zur Verfügung. Bis dahin gelten alle
Konten als Administratoren.
</div>
<?php endif; ?>
<p style="color:var(--text-muted); font-size:0.875rem; margin-bottom:1.25rem;">
Für die Erstinstallation von Anwendungen gibt es die Rolle
<strong>installer</strong>. Solche Konten melden sich ausschließlich über
<code>/api/setup/v1/login</code> an und bekommen dafür ein Token, das nach
30 Minuten verfällt. <strong>Am WebUI können sie sich nicht anmelden.</strong>
Der Grund: Die Zugangsdaten werden auf jedem Zielsystem eingegeben — mit einem
Administratorkonto verteilte man damit den Zugang zu Tokens, Lizenzen und
Monitoren auf jeden Rechner, auf dem je etwas installiert wurde.
</p>
<table>
<thead>
<tr>
<th>Benutzer</th>
<th>Rolle</th>
<th class="hilfe" title="Ein Konto verlangt den Hardwarekey, sobald einer registriert ist. Der Notfalltoken ist der Ersatzweg, wenn der Key fehlt.">Zweiter Faktor</th>
<th>Status</th>
<th>Letzte Anmeldung</th>
<th>Angelegt</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
<?php foreach ($users as $u): ?>
<?php
$uRole = (string)($u['role'] ?? 'admin');
$uDisabled = (int)($u['disabled'] ?? 0) === 1;
$isSelf = (int)$u['id'] === Auth::userId();
$isLocked = !empty($u['locked_until']) && strtotime((string)$u['locked_until'] . ' UTC') > time();
$lockMins = $isLocked ? (int)ceil((strtotime((string)$u['locked_until'] . ' UTC') - time()) / 60) : 0;
$failCount = (int)($u['failed_login_count'] ?? 0);
?>
<tr<?= $uDisabled ? ' style="opacity:0.55;"' : ($isLocked ? ' style="background:rgba(239,68,68,0.08);"' : '') ?>>
<td>
<strong><?= e((string)$u['username']) ?></strong>
<?php if ($isSelf): ?><span class="badge badge-up">Sie</span><?php endif; ?>
</td>
<td>
<span class="badge <?= $uRole === 'admin' ? 'badge-up' : 'badge-warning' ?>">
<?= e(strtoupper($uRole)) ?>
</span>
</td>
<td style="font-size:0.8rem;">
<?php
$uKeys = $credentialsByUser[(int)$u['id']] ?? 0;
$uRecovery = $recoveryByUser[(int)$u['id']] ?? ['present' => false, 'used_at' => null];
?>
<?php if (!$twoFactorReady): ?>
<span style="color:var(--text-muted);">—</span>
<?php elseif ($uRole === 'installer'): ?>
<span class="hilfe" style="color:var(--text-muted);"
title="Installationskonten melden sich nicht am WebUI an. Der zweite Faktor betrifft sie nicht.">
entfällt
</span>
<?php elseif ($uKeys > 0): ?>
<span class="badge badge-up">🔑 <?= $uKeys ?></span>
<?php else: ?>
<span class="hilfe" style="color:var(--warning);"
title="Ohne registrierten Hardwarekey genügt das Passwort allein.">
nur Passwort
</span>
<?php endif; ?>
<?php if ($twoFactorReady && $uRole !== 'installer'): ?>
<div style="margin-top:3px; color:var(--text-muted);">
<?php if (!empty($uRecovery['present'])): ?>
Notfalltoken hinterlegt
<?php elseif (!empty($uRecovery['used_at'])): ?>
<span style="color:var(--warning);">Notfalltoken verbraucht</span>
<?php else: ?>
kein Notfalltoken
<?php endif; ?>
</div>
<?php endif; ?>
</td>
<td>
<?php if ($isLocked): ?>
<span class="badge badge-down">GESPERRT (noch <?= $lockMins ?>m)</span>
<?php elseif ($uDisabled): ?>
<span class="badge badge-down">DEAKTIVIERT</span>
<?php else: ?>
<span style="color:var(--text-main);">aktiv</span>
<?php endif; ?>
<?php if ($failCount > 0 && !$isLocked): ?>
<div style="font-size:0.75rem; color:var(--text-muted); margin-top:2px;">
<?= $failCount ?> Fehlversuch(e)
</div>
<?php endif; ?>
</td>
<td style="font-size:0.8rem; color:var(--text-muted);">
<?= !empty($u['last_login_at']) ? e((string)$u['last_login_at']) : 'nie' ?>
</td>
<td style="font-size:0.8rem; color:var(--text-muted);"><?= e((string)($u['created_at'] ?? '-')) ?></td>
<td>
<div style="display:flex; flex-wrap:wrap; gap:0.35rem;">
<?php if ($isLocked): ?>
<form method="POST" action="index.php" style="display:inline;">
<?= $csrfField ?>
<input type="hidden" name="action" value="unlock_user">
<input type="hidden" name="_return" value="sub-system-users">
<input type="hidden" name="user_id" value="<?= (int)$u['id'] ?>">
<button type="submit" class="btn btn-sm btn-primary">
🔓 Entsperren
</button>
</form>
<?php endif; ?>
<form method="POST" action="index.php" style="display:inline-flex; gap:0.25rem;"
onsubmit="return confirm('Konto <?= e((string)$u['username']) ?> wirklich umbenennen? Registrierte Hardwarekeys bleiben gültig.');">
<?= $csrfField ?>
<input type="hidden" name="action" value="rename_user">
<input type="hidden" name="_return" value="sub-system-users">
<input type="hidden" name="user_id" value="<?= (int)$u['id'] ?>">
<input type="text" name="username" class="form-input" required
pattern="[A-Za-z0-9._\-]{3,64}"
value="<?= e((string)$u['username']) ?>"
style="width:150px; padding:0.3rem 0.5rem; font-size:0.8rem;">
<button type="submit" class="btn btn-sm btn-secondary">Umbenennen</button>
</form>
<?php if ($twoFactorReady && $uRole !== 'installer'): ?>
<form method="POST" action="index.php" style="display:inline;"
onsubmit="return confirm('Neuen Notfalltoken für <?= e((string)$u['username']) ?> erzeugen? Ein vorhandener wird dabei ungültig.');">
<?= $csrfField ?>
<input type="hidden" name="action" value="issue_recovery_token">
<input type="hidden" name="_return" value="sub-system-users">
<input type="hidden" name="user_id" value="<?= (int)$u['id'] ?>">
<button type="submit" class="btn btn-sm btn-secondary">
<?= !empty($uRecovery['present']) ? 'Notfalltoken ersetzen' : 'Notfalltoken erzeugen' ?>
</button>
</form>
<?php endif; ?>
<form method="POST" action="index.php" style="display:inline-flex; gap:0.25rem;"
onsubmit="return confirm('Passwort für <?= e((string)$u['username']) ?> wirklich ändern?');">
<?= $csrfField ?>
<input type="hidden" name="action" value="set_user_password">
<input type="hidden" name="_return" value="sub-system-users">
<input type="hidden" name="user_id" value="<?= (int)$u['id'] ?>">
<input type="password" name="password" class="form-input" required minlength="12"
placeholder="Neues Passwort" style="width:170px; padding:0.3rem 0.5rem; font-size:0.8rem;">
<button type="submit" class="btn btn-sm btn-secondary">Setzen</button>
</form>
<?php if (!$isSelf): ?>
<form method="POST" action="index.php" style="display:inline;">
<?= $csrfField ?>
<input type="hidden" name="action" value="toggle_user_disabled">
<input type="hidden" name="_return" value="sub-system-users">
<input type="hidden" name="user_id" value="<?= (int)$u['id'] ?>">
<button type="submit" class="btn btn-sm btn-secondary">
<?= $uDisabled ? 'Aktivieren' : 'Deaktivieren' ?>
</button>
</form>
<form method="POST" action="index.php" style="display:inline;"
onsubmit="return confirm('Konto <?= e((string)$u['username']) ?> endgültig löschen?');">
<?= $csrfField ?>
<input type="hidden" name="action" value="delete_user">
<input type="hidden" name="_return" value="sub-system-users">
<input type="hidden" name="user_id" value="<?= (int)$u['id'] ?>">
<button type="submit" class="btn btn-sm btn-danger">Löschen</button>
</form>
<?php endif; ?>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<div class="card">
<div class="card-header"><h2 class="card-title">Neues Konto anlegen</h2></div>
<form method="POST" action="index.php">
<?= $csrfField ?>
<input type="hidden" name="action" value="add_user">
<input type="hidden" name="_return" value="sub-system-users">
<div class="form-grid">
<div class="form-group">
<label class="form-label">Benutzername</label>
<input type="text" name="username" class="form-input" required
pattern="[A-Za-z0-9._\-]{3,64}" placeholder="installer">
</div>
<div class="form-group">
<label class="form-label">Rolle</label>
<select name="role" class="form-input">
<option value="installer" selected>installer (nur Erstinstallation)</option>
<option value="admin">admin (volle Verwaltung)</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Passwort (mindestens 12 Zeichen)</label>
<input type="password" name="password" class="form-input" required minlength="12">
</div>
</div>
<button type="submit" class="btn">Konto anlegen</button>
</form>
</div>
</div>
<div id="sub-system-swagger" class="subtab-content">
<div class="card">
<div class="card-header"><h2 class="card-title">📖 API-Dokumentation</h2></div>
<p style="color:var(--text-muted); font-size:0.875rem; margin-bottom:1.25rem;">
Die Schnittstelle beschreibt sich selbst. Der frühere fest im Quelltext
hinterlegte Textblock ist durch eine maschinenlesbare OpenAPI-Beschreibung
ersetzt — ein Agent kann sie abrufen und sich daran orientieren, ohne dass
eine Prompt-Vorlage gepflegt werden muss.
</p>
<div style="display:flex; flex-wrap:wrap; gap:0.5rem; margin-bottom:1.25rem;">
<a href="<?= e($baseUrl) ?>/api/openapi.json" target="_blank" rel="noopener" class="btn">OpenAPI 3.0 (JSON)</a>
<a href="<?= e($baseUrl) ?>/api/health" target="_blank" rel="noopener" class="btn btn-secondary">Health-Endpunkt</a>
<a href="<?= e($baseUrl) ?>/docs/" target="_blank" rel="noopener" class="btn btn-secondary">Agenten-Handbuch</a>
</div>
<div class="prompt-box">Kernendpunkte im Überblick
BUGTRACKER (Token mit bugtracker:report / :read / :manage)
POST /api/bugtracker/v1/report Bug, Feature oder Idee melden
GET /api/bugtracker/v1/projects Projekt-Discovery
GET /api/bugtracker/v1/manage?action=list Filtern, Pagination, updated_since
POST /api/bugtracker/v1/manage?action=next Nächstes Item holen UND übernehmen
POST /api/bugtracker/v1/manage?action=comment&amp;id=42
POST /api/bugtracker/v1/manage?action=resolve&amp;id=42
UPDATESERVICE
GET /api/updateservice/v1/check?product=myapp&amp;version=1.0.0
POST /api/updateservice/v1/publish (Token mit updateservice:publish)
schliesst automatisch alle Items mit passendem resolved_in_build
WATCHDOG
POST /api/watchdog/v1/ping Heartbeat (watchdog:ping)
GET /api/watchdog/v1/evaluate Zustandsbewertung (Cron, Shared Key)
TOKENS
POST /api/tokens/v1/provision Sub-Token aus Master-Token
SYSTEM
GET /api/health Verfügbarkeit &amp; Schema-Status
GET /api/openapi.json Vollständige Spezifikation</div>
</div>
</div>
<div id="sub-system-migration" class="subtab-content">
<div class="card">
<div class="card-header"><h2 class="card-title">🗄️ Datenbank-Migration</h2></div>
<p style="color:var(--text-muted); font-size:0.875rem; margin-bottom:1rem;">
Führt ausstehende Migrationen aus. Bereits angewendete Versionen werden in
<code>dc_migrations</code> vermerkt und übersprungen.
</p>
<div class="alert" style="background:rgba(91,157,255,0.12); border:1px solid rgba(91,157,255,0.3); color:#bfdbfe; font-weight:500;">
<strong>Hinweis zur Sicherheit:</strong> Dieser Endpunkt war zuvor ohne
Anmeldung erreichbar und setzte bei jedem Aufruf das Administrator-Passwort
auf einen fest im Quelltext stehenden Wert zurück. Er verlangt jetzt eine
angemeldete Sitzung oder den Shared Key und legt ein Konto nur an, wenn noch
keines existiert.
</div>
<a href="install_db.php" target="_blank" rel="noopener" class="btn">Migration jetzt ausführen</a>
</div>
</div>
</div>
</main>
</div>
<script>
let autoRefreshTimer = null;
let isAutoRefreshActive = false;
const subnavItems = {
'overview': [],
'projects': [
{ id: 'sub-projects-list', label: '📊 Projekte Übersicht', active: true }
],
'license': [
// Dashboard, "Lizenz Bearbeiten & HW" und Settings sind entfallen:
// vier Unteransichten fuer eine Aufgabe. Bearbeitet wird jetzt
// ueber einen Dialog direkt in der Liste.
{ id: 'sub-license-licenses', label: '🔑 Lizenzen & Hardware', active: true },
{ id: 'sub-license-offline', label: '💾 Offline-Lizenzen (.lic)' },
{ id: 'sub-license-audit', label: '📜 Audit-Log' },
{ id: 'sub-license-integration', label: '💻 Integration' }
],
'watchdog': [
{ id: 'sub-watchdog-dashboard', label: '📊 Dashboard', active: true },
{ id: 'sub-watchdog-hierarchy', label: '🌳 System-Hierarchie' },
{ id: 'sub-watchdog-add', label: '➕ Monitor Hinzufügen' },
{ id: 'sub-watchdog-eventlog', label: '📜 Event-Log' },
{ id: 'sub-watchdog-tokens', label: '🔑 Agent-Tokens' }
],
'updateservice': [
{ id: 'sub-update-releases', label: '📊 Releases Overview', active: true },
{ id: 'sub-update-installer', label: '⬇️ Installer' },
{ id: 'sub-update-guard', label: '🔒 Zugangsschutz' },
{ id: 'sub-update-publish', label: '➕ Release Veröffentlichen' }
],
'roadmap': [
{ id: 'sub-roadmap-tafel', label: '🧭 Tafel', active: true },
{ id: 'sub-roadmap-board', label: '📊 Übersicht' },
{ id: 'sub-roadmap-list', label: '📋 Alle Vorhaben' },
{ id: 'sub-roadmap-intake', label: '➕ Aufnahmeliste' },
{ id: 'sub-roadmap-epics', label: '🗂️ Themenbereiche' }
],
'bugtracker': [
{ id: 'sub-bugtracker-items', label: '🐛 Bugs & Features', active: true },
{ id: 'sub-bugtracker-errors', label: '⚡ Fehler-Stream' },
{ id: 'sub-bugtracker-ignore', label: '🔇 Ignore-Regeln' },
{ id: 'sub-bugtracker-new', label: '➕ Item Anlegen' }
],
'tokens': [
{ id: 'sub-tokens-masters', label: '👑 Master-Tokens', active: true },
{ id: 'sub-tokens-subs', label: '🔑 Sub-Tokens' },
{ id: 'sub-tokens-create', label: '➕ Token erstellen' }
],
'system': [
{ id: 'sub-system-status', label: '⚙️ System-Status', active: true },
{ id: 'sub-system-users', label: '👤 Benutzer' },
{ id: 'sub-system-rocketchat', label: '💬 Rocket.Chat' },
{ id: 'sub-system-swagger', label: '📖 API Swagger Docs' },
{ id: 'sub-system-migration', label: '🗄️ DB Migration' }
]
};
/**
* Ermittelt, zu welchem Modul eine Unteransicht gehoert.
*
* Zuvor wurde das aus dem Namen geraten (sub-<modul>-<rest>), was bei
* "sub-update-releases" fehlschlug - das Modul heisst updateservice.
* Die Zuordnung kommt jetzt aus derselben Liste, die auch die
* Navigation aufbaut, und ist damit immer korrekt.
*/
function moduleOfSubtab(subtabId) {
for (const moduleName of Object.keys(subnavItems)) {
if (subnavItems[moduleName].some(item => item.id === subtabId)) {
return moduleName;
}
}
return null;
}
// Render Subnav links in Top-Bar
function renderTopBarSubnav(moduleName, activeSubtabId) {
const container = document.getElementById('topbarSubnav');
container.innerHTML = '';
const items = subnavItems[moduleName] || [];
items.forEach(item => {
const isActive = activeSubtabId ? (item.id === activeSubtabId) : !!item.active;
const btn = document.createElement('button');
btn.className = `subnav-link ${isActive ? 'active' : ''}`;
btn.innerText = item.label;
// Eindeutige Kennung statt Textvergleich - der Beschriftungstext
// taugt nicht zum Wiederfinden.
btn.dataset.subtab = item.id;
btn.onclick = () => switchSubTab(moduleName, item.id, btn);
container.appendChild(btn);
});
}
/**
* Ansichtsrettung: Merkt sich beim Absenden, welche Unteransicht offen
* war, und stellt sie nach dem Neuladen wieder her.
*
* Ohne das landete man stets auf dem Dashboard - man legte einen
* Monitor an und wurde aus dem Formular geworfen, und jede
* Filteraenderung im Bugtracker warf einen dorthin zurueck.
*
* Als zentraler Zuhoerer umgesetzt, damit auch kuenftige Formulare das
* Verhalten automatisch erben. Die drei Funktionen darunter gehoeren
* zusammen: dcCurrentView() liest die Ansicht ab, dcRememberView()
* haengt sie an das Formular, dcSubmit() sorgt dafuer, dass beim
* Absenden aus einem onchange ueberhaupt ein submit-Ereignis entsteht.
*/
function dcCurrentView() {
// Erst das Modul, dann die Unteransicht DARIN. Die Reihenfolge ist
// der Kern der Sache: Im Markup tragen ACHT Unteransichten die
// Klasse "active" - jedes Modul hat seine eigene Startansicht, und
// switchMainTab() raeumt nur innerhalb des Moduls auf, das es
// oeffnet. Ein dokumentweites querySelector('.subtab-content.active')
// liefert deshalb immer die erste in Dokumentreihenfolge
// (sub-projects-list), voellig unabhaengig davon, was gerade
// sichtbar ist. Genau daran ist die Ansichtsrettung fuer POST seit
// jeher gescheitert - und der Anschein, sie funktioniere, kam von
// den wenigen Formularen, die ihren Anker fest in der action
// stehen haben.
const activeTab = document.querySelector('.main-content > .tab-content.active');
if (!activeTab) {
return '';
}
const activeSub = activeTab.querySelector('.subtab-content.active');
return activeSub ? activeSub.id : activeTab.id;
}
/**
* Haengt die gerade offene Ansicht an ein Formular.
*
* POST: als _return - dc_redirect() macht daraus den Anker der
* Weiterleitung.
* GET: als view - ein Anker ueberlebt die Serialisierung eines
* GET-Formulars nicht, der Query-String schon. Genau daran
* scheiterten die Filter: nach jeder Aenderung stand die Seite
* wieder auf dem Dashboard, weil die neue URL keinen Anker
* mehr trug.
*/
function dcRememberView(form) {
const isPost = (form.method || 'get').toLowerCase() === 'post';
const name = isPost ? '_return' : 'view';
if (form.querySelector('input[name="' + name + '"]')) {
return;
}
const target = dcCurrentView();
if (!target) {
return;
}
const field = document.createElement('input');
field.type = 'hidden';
field.name = name;
field.value = target;
form.appendChild(field);
}
document.addEventListener('submit', (event) => {
const form = event.target;
if (!(form instanceof HTMLFormElement)) {
return;
}
dcRememberView(form);
}, true);
/**
* Absenden aus einem onchange heraus.
*
* form.submit() loest KEIN submit-Ereignis aus - der Zuhoerer oben
* lief deshalb bei den Filtern nie, obwohl es ihn gab.
* requestSubmit() tut es. Wo es fehlt, wird die Ansicht vorher von
* Hand angehaengt.
*/
/**
* Setzt oder entfernt alle Haken einer Mehrfachauswahl.
*
* Ohne das waere "ich will jetzt wirklich alles sehen" ein Dutzend
* Klicks - und genau dann braucht man es, wenn man etwas sucht und
* nicht findet.
*/
function dcAlleHaken(knopf, gesetzt) {
knopf.closest('.filter-mehrfach-inhalt')
.querySelectorAll('input[type=checkbox]')
.forEach(k => { k.checked = gesetzt; });
}
function dcSubmit(form) {
if (typeof form.requestSubmit === 'function') {
form.requestSubmit();
return;
}
dcRememberView(form);
form.submit();
}
// Sidebar Toggle Handler
function toggleSidebar() {
const sidebar = document.getElementById('sidebar');
const mainWrapper = document.getElementById('mainWrapper');
sidebar.classList.toggle('collapsed');
mainWrapper.classList.toggle('expanded');
localStorage.setItem('sidebar_collapsed', sidebar.classList.contains('collapsed'));
}
if (localStorage.getItem('sidebar_collapsed') === 'true') {
document.getElementById('sidebar').classList.add('collapsed');
document.getElementById('mainWrapper').classList.add('expanded');
}
// 30s Auto Refresh Toggle
function toggleAutoRefresh() {
const btn = document.getElementById('btnAutoRefresh');
const txt = document.getElementById('autoRefreshText');
isAutoRefreshActive = !isAutoRefreshActive;
if (isAutoRefreshActive) {
btn.classList.add('active');
txt.innerText = '30s REFRESH: AN';
autoRefreshTimer = setInterval(() => location.reload(), 30000);
} else {
btn.classList.remove('active');
txt.innerText = '30s REFRESH: AUS';
if (autoRefreshTimer) clearInterval(autoRefreshTimer);
}
}
// Main Module Navigation Switcher
//
// activeSubtabId und updateHash werden beim Wiederherstellen aus der URL
// gesetzt: dort soll eine bestimmte Unteransicht geoeffnet und der
// Anker nicht ueberschrieben werden.
function switchMainTab(moduleName, el, activeSubtabId, updateHash = true) {
document.querySelectorAll('.main-content > .tab-content').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.nav-link').forEach(n => n.classList.remove('active'));
const targetTab = document.getElementById('tab-' + moduleName);
if (targetTab) {
targetTab.classList.add('active');
}
const navLink = el || document.querySelector(`.nav-link[onclick*="'${moduleName}'"]`);
if (navLink) {
navLink.classList.add('active');
}
const pageTitles = {
'overview': 'Globales Dashboard',
'projects': 'Projektverwaltung',
'license': 'Lizenzverwaltung',
'watchdog': 'WatchDog Monitoring',
'updateservice': 'UpdateService Releases',
'bugtracker': 'Bug- & Feature-Tracker',
'tokens': 'Token-Verwaltung & Provisionierung',
'system': 'System & Datenbank Status'
};
document.getElementById('topPageTitle').innerText = pageTitles[moduleName] || 'Deploymentcenter';
renderTopBarSubnav(moduleName, activeSubtabId);
// Unteransicht passend zur Navigation einblenden
const items = subnavItems[moduleName] || [];
const wanted = activeSubtabId
|| (items.find(item => item.active) || items[0] || {}).id;
if (targetTab && wanted) {
targetTab.querySelectorAll('.subtab-content').forEach(s => s.classList.remove('active'));
const sub = document.getElementById(wanted);
if (sub) {
sub.classList.add('active');
}
}
if (updateHash) {
location.hash = wanted || ('tab-' + moduleName);
}
}
// Die frueher hier stehende Funktion filterBugtrackerTable() ist entfallen.
// Sie blendete Zeilen im Browser aus, waehrend der Server ohnehin alle
// Datensaetze gerendert hatte. Gefiltert wird jetzt serverseitig ueber
// GET-Parameter - das skaliert, ueberlebt den Auto-Refresh und laesst
// sich als Link teilen.
// ---------------------------------------------------------------
// Token bearbeiten
// ---------------------------------------------------------------
/**
* Fuellt den Dialog aus der Zeile, die geklickt wurde.
*
* Die Angaben kommen als JSON aus dem PHP-Teil mit, statt sie
* nachzuladen: Sie stehen ohnehin schon in der Tabelle, und ein
* zusaetzlicher Endpunkt waere eine weitere Stelle, an der die
* Rechtepruefung sitzen muesste.
*/
function openTokenEdit(tok) {
document.getElementById('tokEditId').value = tok.token_id;
document.getElementById('tokEditIdText').innerText = tok.token_id;
document.getElementById('tokEditName').innerText = tok.name;
document.getElementById('tokEditNameInput').value = tok.name;
document.getElementById('tokEditOwnerType').value = tok.owner_type;
document.getElementById('tokEditIdentity').value = tok.owner_identity || '';
document.getElementById('tokEditProject').value = tok.project_slug || '';
document.getElementById('tokEditLicense').value = tok.license_key || '';
document.getElementById('tokEditEnv').value = tok.environment;
document.getElementById('tokEditExpires').value = tok.expires_at || '';
const gesetzt = new Set(tok.scopes || []);
document.querySelectorAll('.tok-edit-scope').forEach(feld => {
feld.checked = gesetzt.has(feld.value);
});
// Der Hinweis zu den Sub-Tokens nur dort, wo es welche gibt -
// sonst stumpft er ab und wird ueberlesen.
const hinweis = document.getElementById('tokEditSubHinweis');
if (tok.subs > 0) {
document.getElementById('tokEditSubCount').innerText = tok.subs;
hinweis.style.display = 'block';
} else {
hinweis.style.display = 'none';
}
document.getElementById('tokenEditModal').style.display = 'flex';
}
function closeTokenEdit() {
document.getElementById('tokenEditModal').style.display = 'none';
}
// ---------------------------------------------------------------
// Roadmap
// ---------------------------------------------------------------
/**
* Oeffnet den Dialog fuer die Roadmap-Angaben eines Items.
*
* Derselbe Dialog dient dem Aufnehmen (Bereich setzen) und dem
* Aendern. Ein eigener Aufnahmedialog waere ein zweites Formular fuer
* dieselben vier Felder gewesen.
*/
function openRoadmapEdit(item) {
document.getElementById('rmEditId').value = item.id;
document.getElementById('rmEditLabel').innerText = '#' + item.id + ' — ' + item.title;
document.getElementById('rmEditEpic').value = item.epic || '';
document.getElementById('rmEditRank').value = item.roadmap_rank === null ? '' : item.roadmap_rank;
document.getElementById('rmEditBuild').value = item.target_build || '';
document.getElementById('rmEditAnchor').value = item.doc_anchor || '';
document.getElementById('rmEditTrigger').value = item.defer_trigger || '';
document.getElementById('rmEditStatus').value = item.status;
rmToggleTrigger();
document.getElementById('roadmapEditModal').style.display = 'flex';
}
function closeRoadmapEdit() {
document.getElementById('roadmapEditModal').style.display = 'none';
}
/**
* Der Ausloeser ist nur beim Zurueckstellen von Belang.
*
* Das Feld bleibt sichtbar, wird aber hervorgehoben und als Pflicht
* markiert - ausblenden waere schlechter, weil ein bereits
* hinterlegter Ausloeser dann unsichtbar mitliefe.
*/
function rmToggleTrigger() {
const deferred = document.getElementById('rmEditStatus').value === 'deferred';
const group = document.getElementById('rmTriggerGroup');
const field = document.getElementById('rmEditTrigger');
group.style.opacity = deferred ? '1' : '0.55';
field.required = deferred;
}
// Open Bugtracker Edit Modal
function openEditBugtrackerModal(item) {
document.getElementById('btEditItemId').value = item.id;
document.getElementById('btEditTitleDisplay').innerText = '#' + item.id + ': ' + item.title;
document.getElementById('btEditStatus').value = item.status;
document.getElementById('btEditSeverity').value = item.severity;
document.getElementById('btEditPushId').value = item.push_id || '';
document.getElementById('btEditTargetAgent').value = item.target_agent || '';
document.getElementById('btEditTags').value = item.tags || '';
document.getElementById('btEditResolvedInBuild').value = item.resolved_in_build || '';
document.getElementById('btEditResolutionNotes').value = item.resolution_notes || '';
document.getElementById('btEditModal').style.display = 'flex';
}
function closeEditBugtrackerModal() {
document.getElementById('btEditModal').style.display = 'none';
}
/**
* Maskiert Text fuer die Einbettung in HTML.
*
* Ohne diese Funktion wurden Titel, Beschreibung, Fehlermeldung,
* Stacktrace und Kommentare ungefiltert per innerHTML eingesetzt.
* Da der Report-Endpunkt zudem ohne Token erreichbar war, konnte
* jeder ein Item mit eingebettetem Skript einliefern, das beim
* Oeffnen der Detailansicht in der Admin-Sitzung ausgefuehrt wurde.
*/
function esc(value) {
if (value === null || value === undefined) {
return '';
}
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
// Open Bugtracker Item Details & Timeline Modal
function openBugtrackerModal(itemId) {
const modal = document.getElementById('btDetailModal');
const content = document.getElementById('btModalContent');
modal.style.display = 'flex';
content.innerHTML = '<div style="text-align:center; padding:2rem; color:var(--text-muted);">⏳ Lade Details & Timeline...</div>';
fetch(`api/bugtracker/v1/manage?action=get&id=${encodeURIComponent(itemId)}`, {
headers: { 'Accept': 'application/json' },
credentials: 'same-origin'
})
.then(r => r.json())
.then(res => {
if (!res || res.status !== 'success' || !res.item) {
const reason = res && res.error ? res.error.message : 'Unbekannter Fehler';
content.innerHTML = '<div class="alert alert-danger">Item konnte nicht geladen werden: ' + esc(reason) + '</div>';
return;
}
const item = res.item;
const comments = item.comments || [];
let commentsHtml = '';
if (comments.length === 0) {
commentsHtml = '<p style="color:var(--text-muted); font-size:0.85rem;">Noch keine Kommentare oder Ermittlungsschritte hinterlegt.</p>';
} else {
comments.forEach(c => {
commentsHtml += `
<div style="background:rgba(255,255,255,0.03); border:1px solid var(--border-glass); padding:0.75rem; border-radius:8px; margin-bottom:0.6rem;">
<div style="display:flex; justify-content:space-between; font-size:0.8rem; margin-bottom:0.3rem;">
<strong style="color:var(--primary);">${esc(c.author)}</strong>
<span style="color:var(--text-muted);">${esc(c.created_at)} UTC</span>
</div>
${c.action_taken ? `<div style="font-size:0.7rem;color:#8B93A7;margin-bottom:0.25rem;">[${esc(c.action_taken)}]</div>` : ''}
<div style="font-size:0.875rem; white-space:pre-wrap;">${esc(c.comment)}</div>
</div>
`;
});
}
const levelBadge = item.error_level
? `<span class="badge badge-${item.error_level === 'fatal' ? 'down' : 'warning'}">${esc(item.error_level).toUpperCase()}</span>`
: '';
// Stummgeschaltet: Grund der Regel sichtbar machen, damit
// nicht geraten werden muss, warum hier weggeschaut wird.
const ignoreBanner = (item.status === 'ignored')
? `<div class="alert" style="background:rgba(139,147,167,0.15);border:1px solid rgba(139,147,167,0.3);color:#cbd5e1;padding:0.6rem 0.9rem;font-size:0.8rem;margin-bottom:1rem;">
🔇 Als bekannt eingestuft${item.ignore_rule_id ? ` durch Regel #${esc(item.ignore_rule_id)}` : ''}.
Wird weiter gezählt (${esc(item.occurrence_count)}×), löst aber keine Benachrichtigung aus.
</div>`
: '';
const rateInfo = (item.rate_window_count && Number(item.rate_window_count) > 1)
? `<div style="font-size:0.78rem;color:var(--text-muted);margin-bottom:1rem;">
📈 ${esc(item.rate_window_count)} Vorkommnisse im laufenden Stundenfenster
${item.rate_alerted_at ? `· zuletzt alarmiert ${esc(item.rate_alerted_at)} UTC` : ''}
</div>`
: '';
const claimBanner = (item.claimed_by && item.lease_until)
? `<div class="alert" style="background:rgba(245,166,35,0.15);border:1px solid rgba(245,166,35,0.3);color:#fbbf24;padding:0.6rem 0.9rem;font-size:0.8rem;margin-bottom:1rem;">
🤖 In Bearbeitung durch <strong>${esc(item.claimed_by)}</strong> bis ${esc(item.lease_until)} UTC
</div>`
: '';
const contextRows = [
['Repository', item.repo_url],
['Branch', item.git_branch],
['Commit', item.commit_sha],
['Datei', item.file_path ? item.file_path + (item.line_no ? ':' + item.line_no : '') : null]
].filter(row => row[1]);
const contextHtml = contextRows.length === 0 ? '' : `
<div style="margin-bottom:1rem;">
<strong>Code-Kontext:</strong>
<div style="background:rgba(0,0,0,0.2);padding:0.6rem;border-radius:6px;margin-top:0.3rem;font-size:0.82rem;font-family:monospace;">
${contextRows.map(row => `<div>${esc(row[0])}: ${esc(row[1])}</div>`).join('')}
</div>
</div>`;
content.innerHTML = `
<div style="display:flex; justify-content:space-between; align-items:start; margin-bottom:1rem;">
<div>
<span class="badge badge-${item.type === 'feature_request' ? 'warning' : 'down'}">${esc(item.type).toUpperCase()}</span>
<span class="badge badge-stopped">${esc(item.environment).toUpperCase()}</span>
<span class="badge badge-stopped">${esc(item.status).toUpperCase()}</span>
${levelBadge}
<h3 style="margin:0.5rem 0 0.2rem 0; color:#fff;">#${esc(item.id)}: ${esc(item.title)}</h3>
<div style="font-size:0.8rem; color:var(--text-muted);">
Projekt: <code>${esc(item.project_slug)}</code>
| Build: <code>${esc(item.build_version || 'unbekannt')}</code>
| Gemeldet von: ${esc(item.created_by)}
| ${esc(item.occurrence_count)}x aufgetreten
</div>
</div>
<button type="button" class="btn btn-sm btn-secondary" onclick="closeBugtrackerModal()">✕</button>
</div>
${ignoreBanner}
${claimBanner}
${rateInfo}
${item.description ? `
<div style="margin-bottom:1rem;">
<strong>Beschreibung:</strong>
<div style="background:rgba(0,0,0,0.2); padding:0.6rem; border-radius:6px; font-size:0.875rem; margin-top:0.3rem; white-space:pre-wrap;">${esc(item.description)}</div>
</div>
` : ''}
${contextHtml}
${item.error_message ? `
<div style="margin-bottom:1rem;">
<strong style="color:var(--danger);">Fehlermeldung:</strong>
<pre style="background:#0d1117; color:#ff7b72; padding:0.75rem; border-radius:6px; font-family:monospace; font-size:0.82rem; overflow-x:auto; margin-top:0.3rem;">${esc(item.error_message)}</pre>
</div>
` : ''}
${item.stack_trace ? `
<div style="margin-bottom:1rem;">
<strong>Stacktrace:</strong>
<pre style="background:#0d1117; color:#c9d1d9; padding:0.75rem; border-radius:6px; font-family:monospace; font-size:0.78rem; max-height:200px; overflow-y:auto; margin-top:0.3rem;">${esc(item.stack_trace)}</pre>
</div>
` : ''}
<hr style="border-color:var(--border-glass); margin:1.25rem 0;">
<h4 style="margin-bottom:0.75rem; color:#fff;">📜 Agenten-Historie & Kommentar-Timeline</h4>
<div style="max-height:250px; overflow-y:auto; margin-bottom:1rem;">${commentsHtml}</div>
<form method="POST" action="index.php#tab-bugtracker">
<?= $csrfField ?>
<input type="hidden" name="action" value="bt_add_comment">
<input type="hidden" name="item_id" value="${esc(item.id)}">
<div class="form-group">
<label class="form-label">Ermittlungsschritt / Kommentar Hinzufügen</label>
<textarea name="comment" class="form-input" rows="2" required placeholder="Notiere hier Diagnoseergebnisse oder Hinweise für andere Agenten..."></textarea>
</div>
<button type="submit" class="btn btn-sm" style="margin-top:0.5rem;">Kommentar Speichern</button>
</form>
`;
})
.catch(err => {
content.innerHTML = '<div class="alert alert-danger">Fehler beim Laden: ' + esc(err.message) + '</div>';
});
}
function closeBugtrackerModal() {
document.getElementById('btDetailModal').style.display = 'none';
}
function openResolveModal(itemId, title) {
document.getElementById('resolveItemId').value = itemId;
document.getElementById('resolveItemTitle').innerText = title;
document.getElementById('btResolveModal').style.display = 'flex';
}
function closeResolveModal() {
document.getElementById('btResolveModal').style.display = 'none';
}
// Horizontal Submenu Switcher in Top Bar
function switchSubTab(moduleName, subtabId, el) {
const parentModule = document.getElementById('tab-' + moduleName);
if (!parentModule) return;
parentModule.querySelectorAll('.subtab-content').forEach(s => s.classList.remove('active'));
document.querySelectorAll('#topbarSubnav .subnav-link').forEach(l => l.classList.remove('active'));
const targetSub = document.getElementById(subtabId);
if (targetSub) {
targetSub.classList.add('active');
}
// Ohne uebergebenen Knopf ueber die Kennung finden, damit die
// Markierung auch beim Wiederherstellen aus der URL stimmt.
const button = el
|| document.querySelector(`#topbarSubnav .subnav-link[data-subtab="${subtabId}"]`);
if (button) {
button.classList.add('active');
}
location.hash = subtabId;
}
// Project Editing Form Functions
function editProject(p) {
document.getElementById('projEditId').value = p.id;
document.getElementById('projEditSlug').value = p.slug;
document.getElementById('projEditSlug').readOnly = true;
document.getElementById('projEditName').value = p.name;
document.getElementById('projEditTtl').value = p.default_cache_ttl_hours;
document.getElementById('projEditNotes').value = p.notes || '';
document.getElementById('projSubmitBtn').innerText = 'Änderungen Speichern';
window.scrollTo({ top: 0, behavior: 'smooth' });
}
function resetProjectForm() {
document.getElementById('projEditId').value = 0;
document.getElementById('projEditSlug').value = '';
document.getElementById('projEditSlug').readOnly = false;
document.getElementById('projEditName').value = '';
document.getElementById('projEditTtl').value = 168;
document.getElementById('projEditNotes').value = '';
document.getElementById('projSubmitBtn').innerText = 'Projekt Anlegen';
}
// UpdateService Release Modal Functions
function openEditReleaseModal(r) {
const setVal = (id, val) => {
const el = document.getElementById(id);
if (el) el.value = val;
};
setVal('edit_release_id', r.id || 0);
setVal('edit_release_product_slug', r.product_slug || '');
setVal('edit_release_channel', r.channel || 'prod');
setVal('edit_release_platform', r.platform || 'any');
setVal('edit_release_version', r.version || '');
setVal('edit_release_git_commit', r.git_commit || '');
setVal('edit_release_download_url', r.download_url || '');
setVal('edit_release_sha256_hash', r.sha256_hash || '');
setVal('edit_release_size_bytes', r.size_bytes || 0);
setVal('edit_release_release_notes', r.release_notes || '');
const critEl = document.getElementById('edit_release_is_critical');
if (critEl) critEl.checked = Boolean(Number(r.is_critical));
const modal = document.getElementById('editReleaseModal');
if (modal) modal.classList.add('active');
}
function closeEditReleaseModal() {
const modal = document.getElementById('editReleaseModal');
if (modal) modal.classList.remove('active');
}
function openDeleteReleaseModal(id, version, product) {
const idEl = document.getElementById('delete_release_id');
if (idEl) idEl.value = id;
const verEl = document.getElementById('delete_release_version_display');
if (verEl) verEl.textContent = 'v' + version;
const prodEl = document.getElementById('delete_release_product_display');
if (prodEl) prodEl.textContent = product;
const modal = document.getElementById('deleteReleaseModal');
if (modal) modal.classList.add('active');
}
function closeDeleteReleaseModal() {
const modal = document.getElementById('deleteReleaseModal');
if (modal) modal.classList.remove('active');
}
// Project Safety Deletion Modal
function openDeleteProjectModal(id, slug, name) {
document.getElementById('deleteProjId').value = id;
document.getElementById('deleteProjSlugDisplay').innerText = slug;
document.getElementById('deleteProjNameDisplay').innerText = name + ' (' + slug + ')';
document.getElementById('deleteProjConfirmInput').value = '';
document.getElementById('deleteProjectModal').classList.add('active');
}
function closeDeleteProjectModal() {
document.getElementById('deleteProjectModal').classList.remove('active');
}
// Monitor Icon Selector (Matching Screenshot 1)
function selectIcon(btnEl, iconVal) {
document.querySelectorAll('.icon-option-btn').forEach(b => b.classList.remove('selected'));
btnEl.classList.add('selected');
document.getElementById('monEditIconSelected').value = iconVal;
}
// Rich Monitor Editing Modal Functions (Screenshot 1 - Null-safe)
function openEditMonitorModal(m) {
const setVal = (id, val) => {
const el = document.getElementById(id);
if (el) el.value = val;
};
setVal('monEditOldSource', m.source || '');
setVal('monEditNewSource', m.source || '');
setVal('monEditType', m.type || 'host');
setVal('monEditParent', m.parent_source || '');
setVal('monEditOs', m.os || '');
setVal('monEditGroup', m.group_key || 'Applications');
setVal('monEditInterval', m.expected_interval_sec || 60);
setVal('monEditNotes', m.notes || '');
setVal('monEditUrl', m.url || '');
const mutedEl = document.getElementById('monEditMuted');
if (mutedEl) mutedEl.checked = parseInt(m.is_muted) === 1;
// Highlight selected icon preset or auto
const iconVal = m.icon || '';
setVal('monEditIconSelected', iconVal);
document.querySelectorAll('.icon-option-btn').forEach(b => {
b.classList.remove('selected');
if (b.getAttribute('data-icon') === iconVal) {
b.classList.add('selected');
}
});
const modal = document.getElementById('editMonitorModal');
if (modal) modal.classList.add('active');
}
function closeEditMonitorModal() {
const modal = document.getElementById('editMonitorModal');
if (modal) modal.classList.remove('active');
}
function triggerDeleteMonitorFromModal() {
const source = document.getElementById('monEditOldSource').value;
if (confirm(`Möchtest du den Monitor '${source}' wirklich unwiderruflich löschen?`)) {
document.getElementById('hiddenDeleteSource').value = source;
document.getElementById('hiddenDeleteMonitorForm').submit();
}
}
// ------------------------------------------------------------------
// Lizenz bearbeiten
//
// Frueher sprang der Edit-Knopf in eine eigene Unteransicht mit einer
// Auswahlliste, in der man die gerade angeklickte Lizenz erneut suchen
// musste. Jetzt oeffnet sich ein Dialog direkt ueber der Tabelle.
// ------------------------------------------------------------------
function openEditLicense(license) {
const setVal = (id, value) => {
const el = document.getElementById(id);
if (el) el.value = value ?? '';
};
setVal('licEditId', license.id);
setVal('licEditCustomerName', license.customer_name);
setVal('licEditCustomerEmail', license.customer_email);
setVal('licEditStatus', license.status || 'active');
setVal('licEditMaxAct', license.max_activations || 2);
setVal('licEditNotes', license.notes);
// Das Datumsfeld erwartet YYYY-MM-DD, die Datenbank liefert einen
// vollstaendigen Zeitstempel.
const expires = license.expires_at ? String(license.expires_at).substring(0, 10) : '';
setVal('licEditExpiresAt', expires);
const title = document.getElementById('licEditTitle');
if (title) {
title.textContent = `${license.license_key} — ${license.customer_name || 'ohne Kundenangabe'}`;
}
const activationInfo = document.getElementById('licEditActivations');
if (activationInfo) {
activationInfo.textContent =
`${license.active_count ?? 0} von ${license.max_activations ?? 0} Aktivierungen belegt`;
}
document.getElementById('licEditModal').style.display = 'flex';
}
function closeEditLicense() {
document.getElementById('licEditModal').style.display = 'none';
}
// Token Masking Toggle & Copy
function toggleTokenMask(tokId) {
const el = document.getElementById(tokId) || document.getElementById('tok-text-' + tokId) || document.getElementById('tok-core-' + tokId) || document.getElementById('tok-sub-' + tokId);
if (!el) return;
if (el.innerText.includes('••••')) {
el.innerText = el.getAttribute('data-full');
} else {
el.innerText = el.getAttribute('data-masked');
}
}
function copyTokenValue(tokId) {
const el = document.getElementById(tokId) || document.getElementById('tok-text-' + tokId) || document.getElementById('tok-core-' + tokId) || document.getElementById('tok-sub-' + tokId);
if (!el) return;
const fullVal = el.getAttribute('data-full');
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(fullVal).then(() => {
alert('Token in Zwischenablage kopiert!');
}).catch(() => {
prompt('Token kopieren:', fullVal);
});
} else {
prompt('Token kopieren:', fullVal);
}
}
// Reine Ansichtsvorschau. Die vorherige Fassung erzeugte hier eine
// Zeichenkette, die sie "ED25519_SIG_" nannte - tatsaechlich war es ein
// base64-kodierter Schluessel ohne jede Signaturwirkung. Die gueltige,
// serverseitig signierte Datei liefert der .lic-Knopf in der Liste.
function generateOfflinePayload() {
const key = document.getElementById('offlineLicSelect').value;
const hwId = document.getElementById('offlineHwId').value || '(beliebig)';
const preview = {
type: 'offline_license_file',
issued_at: Math.floor(Date.now() / 1000),
license_key: key,
hardware_id: hwId,
signature_alg: 'HMAC-SHA256',
signature: '(wird beim Herunterladen serverseitig erzeugt)'
};
document.getElementById('offlineResultBox').innerText = JSON.stringify(preview, null, 2);
}
// Initialize default subnav for Overview
renderTopBarSubnav('overview');
/**
* Stellt Modul und Unteransicht aus dem Anker der URL wieder her.
*
* Die vorherige Fassung leitete den Modulnamen aus dem Anker ab
* (sub-<modul>-<rest>) und suchte den Navigationsknopf anhand seines
* Beschriftungstextes. Beides ging schief: "sub-update-releases" gehoert
* zum Modul updateservice, und keine Beschriftung enthaelt den
* Modulnamen. Der Rueckweg landete deshalb immer auf dem Dashboard.
*/
function restoreViewFromHash() {
let hash = location.hash.replace('#', '');
// Tiefer Verweis auf ein Kaertchen der Tafel:
// #roadmap/<projekt>/item/<nummer>
// Damit kann ein Agent in einem Kommentar auf die Stelle in der
// Tafel zeigen, statt "steht in der Roadmap, such mal" zu sagen.
const tiefer = hash.match(/^roadmap\/([a-z0-9._-]{1,64})\/item\/(\d{1,9})$/i);
if (tiefer) {
switchMainTab('roadmap', null, 'sub-roadmap-tafel', false);
tafelOeffneItem(tiefer[1], parseInt(tiefer[2], 10));
return;
}
// Ohne Anker: der Wert aus dem Query-String, den dcRememberView()
// an GET-Formulare haengt. Nur eigene Ansichtskennungen zulassen -
// der Wert steuert, was angezeigt wird.
if (!hash) {
const fromQuery = new URLSearchParams(location.search).get('view');
if (fromQuery && /^(?:tab|sub)-[a-z0-9-]{1,60}$/.test(fromQuery)) {
hash = fromQuery;
}
}
if (!hash) {
return;
}
if (hash.startsWith('tab-')) {
const moduleName = hash.substring(4);
if (subnavItems[moduleName]) {
switchMainTab(moduleName, null, null, false);
}
return;
}
if (!hash.startsWith('sub-')) {
return;
}
const moduleName = moduleOfSubtab(hash);
if (!moduleName) {
// Unbekannte Unteransicht - dann wenigstens das Modul, wenn es
// sich aus dem Namen ergibt. Wortlos im Dashboard zu landen ist
// die schlechteste aller Antworten: Der Nutzer sieht nicht,
// dass etwas schiefging, sondern nur, dass er woanders ist.
const rest = hash.substring(4);
const guess = Object.keys(subnavItems).find(m => rest.startsWith(m + '-'));
if (guess) {
switchMainTab(guess, null, null, false);
}
return;
}
// Die Unteransicht wird direkt mit uebergeben - die Navigation wird
// synchron aufgebaut, ein Timeout ist dafuer nicht noetig.
switchMainTab(moduleName, null, hash, false);
}
window.addEventListener('load', restoreViewFromHash);
// Vor- und Zurueck-Navigation im Browser ebenfalls beruecksichtigen
window.addEventListener('hashchange', restoreViewFromHash);
</script>
<!-- Bugtracker Details & Timeline Modal -->
<div id="btDetailModal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.75); backdrop-filter:blur(8px); z-index:9999; align-items:center; justify-content:center; padding:1rem;">
<div class="card" style="width:100%; max-width:750px; max-height:90vh; overflow-y:auto; position:relative; background:#161c28; border:1px solid var(--border-glass);">
<div id="btModalContent">
<!-- Loaded via JS -->
</div>
</div>
</div>
<!--
Bugtracker Resolve Modal
Dieser Dialog fehlte bisher vollstaendig: openResolveModal() griff auf
die Elemente resolveItemId, resolveItemTitle und btResolveModal zu, die
es im HTML nie gab. Der "Gelöst"-Button war damit funktionslos und
brach in der Konsole mit einem TypeError ab.
-->
<div id="btResolveModal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.75); backdrop-filter:blur(8px); z-index:9999; align-items:center; justify-content:center; padding:1rem;">
<div class="card" style="width:100%; max-width:560px; background:#161c28; border:1px solid var(--border-glass);">
<div class="card-header" style="display:flex; justify-content:space-between; align-items:center;">
<h3 class="card-title" style="margin:0; color:#fff;">✔ Item als gelöst markieren</h3>
<button type="button" class="btn btn-sm btn-secondary" onclick="closeResolveModal()">✕</button>
</div>
<p style="font-size:0.875rem; color:var(--text-muted); margin-bottom:1rem;">
<span id="resolveItemTitle" style="color:#fff;"></span>
</p>
<form method="POST" action="index.php#tab-bugtracker">
<?= $csrfField ?>
<input type="hidden" name="action" value="bt_resolve">
<input type="hidden" name="item_id" id="resolveItemId" value="0">
<div class="form-group" style="margin-bottom:1rem;">
<label class="form-label">Gelöst in Build / Version *</label>
<input type="text" name="resolved_in_build" class="form-input" required
placeholder="z. B. v1.4.3">
<small style="font-size:0.72rem; color:var(--text-muted); margin-top:0.3rem;">
Wird dieser Build später über <code>/api/updateservice/v1/publish</code>
veröffentlicht, schließen sich Items mit derselben Angabe automatisch.
</small>
</div>
<div class="form-group" style="margin-bottom:1.25rem;">
<label class="form-label">Lösungs-Notiz (optional)</label>
<textarea name="resolution_notes" class="form-input" rows="3"
placeholder="Was wurde geändert? Hinweise für andere Agenten..."></textarea>
</div>
<div style="display:flex; justify-content:flex-end; gap:0.5rem;">
<button type="button" class="btn btn-secondary" onclick="closeResolveModal()">Abbrechen</button>
<button type="submit" class="btn">Als gelöst markieren</button>
</div>
</form>
</div>
</div>
<!--
Lizenz bearbeiten
Ersetzt die frühere Unteransicht "Lizenz Bearbeiten & HW". Dort musste
man die gerade angeklickte Lizenz in einer Auswahlliste erneut suchen.
-->
<div id="licEditModal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.75); backdrop-filter:blur(8px); z-index:9999; align-items:center; justify-content:center; padding:1rem;">
<div class="card" style="width:100%; max-width:620px; background:#161c28; border:1px solid var(--border-glass); max-height:90vh; overflow-y:auto;">
<div class="card-header" style="display:flex; justify-content:space-between; align-items:center;">
<h3 class="card-title" style="margin:0; color:#fff;">✏️ Lizenz bearbeiten</h3>
<button type="button" class="btn btn-sm btn-secondary" onclick="closeEditLicense()">✕</button>
</div>
<p style="font-size:0.85rem; color:var(--text-muted); margin-bottom:0.35rem;">
<code id="licEditTitle" style="color:var(--primary);"></code>
</p>
<p style="font-size:0.75rem; color:var(--text-muted); margin-bottom:1.25rem;" id="licEditActivations"></p>
<form method="POST" action="index.php#sub-license-licenses">
<?= $csrfField ?>
<input type="hidden" name="action" value="edit_license">
<input type="hidden" name="license_id" id="licEditId" value="0">
<div class="form-grid">
<div class="form-group">
<label class="form-label">Kunden-Name</label>
<input type="text" name="customer_name" id="licEditCustomerName" class="form-input">
</div>
<div class="form-group">
<label class="form-label">Kunden-E-Mail</label>
<input type="email" name="customer_email" id="licEditCustomerEmail" class="form-input">
</div>
<div class="form-group">
<label class="form-label">Status</label>
<select name="status" id="licEditStatus" class="form-input">
<option value="active">Aktiv</option>
<option value="suspended">Pausiert</option>
<option value="revoked">Widerrufen</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Max. Aktivierungen</label>
<input type="number" name="max_activations" id="licEditMaxAct" class="form-input" min="1">
</div>
<div class="form-group">
<label class="form-label">Ablaufdatum (leer = unbegrenzt)</label>
<input type="date" name="expires_at" id="licEditExpiresAt" class="form-input">
</div>
</div>
<div class="form-group" style="margin-top:1rem;">
<label class="form-label">Notizen</label>
<textarea name="notes" id="licEditNotes" class="form-input" rows="2"
placeholder="Vertragsnummer, Ansprechpartner, Besonderheiten..."></textarea>
</div>
<div style="display:flex; justify-content:flex-end; gap:0.5rem; margin-top:1.25rem;">
<button type="button" class="btn btn-secondary" onclick="closeEditLicense()">Abbrechen</button>
<button type="submit" class="btn">Änderungen speichern</button>
</div>
</form>
</div>
</div>
<!-- Bugtracker Edit Modal -->
<div id="btEditModal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.75); backdrop-filter:blur(8px); z-index:9999; align-items:center; justify-content:center; padding:1rem;">
<div class="card" style="width:100%; max-width:650px; background:#161c28; border:1px solid var(--border-glass);">
<div class="card-header" style="display:flex; justify-content:space-between; align-items:center;">
<h3 class="card-title" style="margin:0; color:#fff;">✏️ Item Bearbeiten</h3>
<button type="button" class="btn btn-sm btn-secondary" onclick="closeEditBugtrackerModal()">✕</button>
</div>
<p style="font-size:0.875rem; color:var(--text-muted); margin-bottom:1rem;" id="btEditTitleDisplay"></p>
<form method="POST" action="index.php#tab-bugtracker">
<?= $csrfField ?>
<input type="hidden" name="action" value="bt_update_item">
<input type="hidden" name="item_id" id="btEditItemId" value="0">
<div class="form-grid">
<div class="form-group">
<label class="form-label">Status Bearbeiten</label>
<select name="status" id="btEditStatus" class="form-input" required>
<option value="open">Offen</option>
<option value="planned">Geplant</option>
<option value="in_progress">In Bearbeitung</option>
<option value="resolved">Gelöst / Umgesetzt</option>
<option value="closed">Geschlossen</option>
<option value="rejected">Abgelehnt</option>
<option value="triage_external">📥 Fremdmeldung (ungesichtet)</option>
<option value="awaiting_verification">🔎 Wartet auf Nachprüfung</option>
<option value="in_verification">🔎 Wird gerade geprüft</option>
<option value="fix_insufficient">❗ Fix trägt nicht</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Schweregrad Bearbeiten</label>
<select name="severity" id="btEditSeverity" class="form-input" required>
<option value="idea">💡 Idee / Gedanke für Später</option>
<option value="wishlist">⭐ Wunschliste / Backlog</option>
<option value="low">Niedrig</option>
<option value="medium">Mittel</option>
<option value="high">Hoch</option>
<option value="critical">🔥 Kritisch</option>
</select>
</div>
</div>
<div class="form-grid" style="margin-top:1rem;">
<div class="form-group">
<label class="form-label">Push-ID (Workflow / Push Notification Token)</label>
<input type="text" name="push_id" id="btEditPushId" class="form-input" placeholder="z. B. push_notify_99182">
</div>
<div class="form-group">
<label class="form-label">Ziel-Agent / Zuweisung</label>
<input type="text" name="target_agent" id="btEditTargetAgent" class="form-input" placeholder="z. B. agent:dev-monitor-01">
</div>
</div>
<div class="form-group" style="margin-top:1rem;">
<label class="form-label">Tags (Kommagetrennt)</label>
<input type="text" name="tags" id="btEditTags" class="form-input" placeholder="z. B. ui, security, database">
</div>
<div class="form-grid" style="margin-top:1rem;">
<div class="form-group">
<label class="form-label">Lösungs-Build (Falls gelöst)</label>
<input type="text" name="resolved_in_build" id="btEditResolvedInBuild" class="form-input" placeholder="v1.4.3">
</div>
<div class="form-group">
<label class="form-label">Lösungs-Notizen / Dokumentation</label>
<input type="text" name="resolution_notes" id="btEditResolutionNotes" class="form-input" placeholder="Note zur Behebung...">
</div>
</div>
<div style="display:flex; justify-content:flex-end; gap:0.5rem; margin-top:1.25rem;">
<button type="button" class="btn btn-secondary" onclick="closeEditBugtrackerModal()">Abbrechen</button>
<button type="submit" class="btn">Änderungen Speichern</button>
</div>
</form>
</div>
</div>
<!-- =============== Seitenpanel der Roadmap-Tafel =============== -->
<div class="panel-schatten" id="panelSchatten"></div>
<aside class="tafel-panel" id="tafelPanel" aria-hidden="true" aria-label="Vorhaben">
<header>
<h2 id="panelTitel">—</h2>
<button type="button" class="panel-schliessen" id="panelSchliessen" title="Schließen">&times;</button>
</header>
<div class="panel-inhalt" id="panelInhalt"></div>
</aside>
<script>
/* ---------------------------------------------------------------------
* Die Roadmap-Tafel
*
* Gerendert wird nur, was der Server unter ?action=board liefert - die
* Einteilung in Spalten und Spuren entsteht dort, nicht hier. Sonst
* schnitte die Seite anders als die API, und der erzeugte Markdown-Block
* in docs/ROADMAP.md ein drittes Mal anders.
*
* Jeder Text aus einem Item ist Fremdtext von einer fremden Maschine.
* Er wird ausschliesslich ueber textContent gesetzt, nie ueber innerHTML.
* ------------------------------------------------------------------- */
let tafelDaten = null;
let tafelGezogen = null;
const tafelCsrf = <?= json_encode(Csrf::token(), JSON_UNESCAPED_SLASHES) ?>;
function tafelProjektSlug() {
const feld = document.getElementById('tafelProjekt');
return feld ? feld.value : '';
}
async function tafelApi(pfad, payload) {
const optionen = {
credentials: 'same-origin',
headers: { 'Accept': 'application/json' }
};
if (payload) {
optionen.method = 'POST';
optionen.headers['Content-Type'] = 'application/json';
optionen.headers['X-CSRF-Token'] = tafelCsrf;
optionen.body = JSON.stringify(payload);
}
const antwort = await fetch(pfad, optionen);
const daten = await antwort.json().catch(() => ({}));
if (!antwort.ok) {
const fehler = daten.error || {};
throw new Error(fehler.message || 'Unerwartete Antwort des Servers.');
}
return daten;
}
function tafelHinweis(text, art) {
const ziel = document.getElementById('tafelHinweis');
ziel.innerHTML = '';
if (!text) { return; }
const kasten = document.createElement('div');
kasten.className = 'alert';
kasten.style.cssText = art === 'fehler'
? 'background:rgba(246,70,93,0.12); border:1px solid rgba(246,70,93,0.3); color:#fca5a5;'
: 'background:rgba(245,166,35,0.12); border:1px solid rgba(245,166,35,0.3); color:#fde68a;';
kasten.textContent = text;
ziel.appendChild(kasten);
}
async function tafelLaden() {
const slug = tafelProjektSlug();
if (!slug) { return; }
const grenze = document.getElementById('tafelDoneLimit').value;
const track = document.getElementById('tafelTrack');
track.innerHTML = '';
const laedt = document.createElement('div');
laedt.style.cssText = 'padding:2rem; color:var(--text-muted);';
laedt.textContent = 'Tafel wird geladen …';
track.appendChild(laedt);
try {
tafelDaten = await tafelApi('/api/bugtracker/v1/roadmap?action=board'
+ '&project=' + encodeURIComponent(slug)
+ '&done_limit=' + encodeURIComponent(grenze));
tafelRendern(tafelDaten);
} catch (err) {
track.innerHTML = '';
tafelHinweis(err.message, 'fehler');
}
}
function tafelRendern(daten) {
const track = document.getElementById('tafelTrack');
track.innerHTML = '';
document.getElementById('tafelStand').textContent =
'Stand ' + daten.meta.generated_at + ' UTC · ' + daten.items.length + ' Karten';
tafelHinweis(daten.meta.phases_guessed
? 'Die Spaltengrenzen sind geraten — geschnitten an den größten Lücken der Rangfolge. '
+ 'Wer sie festlegen will, pflegt sie in roadmap_phases; dann steht hier nichts mehr.'
: null, 'warnung');
tafelStatsRendern(daten.stats);
tafelLegendeRendern(daten.lanes);
const spurNachEpic = {};
daten.lanes.forEach(l => { spurNachEpic[l.epic] = l; });
daten.phases.forEach(phase => {
const spalte = document.createElement('section');
spalte.className = 'tafel-spalte kind-' + (phase.kind || 'plan');
spalte.dataset.phase = phase.key;
const kopf = document.createElement('div');
kopf.className = 'tafel-kopf';
const h3 = document.createElement('h3');
h3.textContent = phase.label;
kopf.appendChild(h3);
if (phase.subtitle) {
const p = document.createElement('p');
p.textContent = phase.subtitle;
kopf.appendChild(p);
}
if (phase.hint) {
const span = document.createElement('span');
span.className = 'rang';
span.textContent = phase.hint;
kopf.appendChild(span);
}
spalte.appendChild(kopf);
// Nur Rangspalten nehmen gezogene Karten auf. "Fundament" und
// "Wird ueberlegt" haengen an Status und nicht am Rang - eine
// Karte dorthin zu ziehen waere eine Statusaenderung, und die
// gehoert ins Panel, wo man sie begruenden kann.
const istRangSpalte = phase.rank_from !== null && phase.rank_to !== null;
daten.lanes.forEach(spur => {
const karten = daten.items.filter(
i => i.phase === phase.key && i.lane === spur.epic
);
// Leere Spuren werden weggelassen - bei acht Bereichen und
// sechs Spalten waeren das sonst vierzig leere Ueberschriften.
// Wer eine Karte in einen Bereich ziehen will, in dem hier noch
// nichts steht, benutzt die Ablageflaeche am Fuss der Spalte.
if (!karten.length) { return; }
karten.sort((a, b) => (a.rank ?? 1e9) - (b.rank ?? 1e9) || a.id - b.id);
const spurEl = document.createElement('div');
spurEl.className = 'tafel-spur';
spurEl.dataset.lane = spur.epic;
const h4 = document.createElement('h4');
h4.textContent = spur.title;
if (spur.highlight) {
h4.style.color = spur.color || 'var(--danger)';
} else if (spur.color) {
h4.style.color = spur.color;
}
spurEl.appendChild(h4);
if (istRangSpalte) {
spurEl.appendChild(tafelLuecke(phase, spur.epic, null, karten[0] || null));
}
karten.forEach((karte, i) => {
spurEl.appendChild(tafelKarte(karte, spur));
if (istRangSpalte) {
spurEl.appendChild(
tafelLuecke(phase, spur.epic, karte, karten[i + 1] || null)
);
}
});
spalte.appendChild(spurEl);
});
// Ablageflaeche am Fuss jeder Rangspalte. Sie beantwortet den Fall,
// den die Fugen zwischen den Karten nicht abdecken: eine Karte in
// eine Spalte ziehen, in der ihr Bereich noch gar nicht vorkommt.
if (istRangSpalte) {
spalte.appendChild(tafelAblage(phase));
}
track.appendChild(spalte);
});
}
/**
* Ablageflaeche einer ganzen Spalte: "irgendwo hier hinein".
*
* Der Rang wird dann aus den Spaltengrenzen bestimmt, nicht aus Nachbarn.
*/
function tafelAblage(phase) {
const el = document.createElement('div');
el.style.cssText = 'border:1.5px dashed var(--border-glass); border-radius:8px;'
+ ' padding:0.55rem; text-align:center; font-size:0.72rem; color:var(--text-muted);'
+ ' opacity:0.6;';
el.textContent = 'hierher ziehen';
el.addEventListener('dragover', ev => {
if (!tafelGezogen) { return; }
ev.preventDefault();
ev.dataTransfer.dropEffect = 'move';
el.style.borderColor = 'var(--primary)';
el.style.opacity = '1';
});
el.addEventListener('dragleave', () => {
el.style.borderColor = 'var(--border-glass)';
el.style.opacity = '0.6';
});
el.addEventListener('drop', async ev => {
ev.preventDefault();
const karte = tafelGezogen;
tafelGezogen = null;
if (!karte) { return; }
try {
const ergebnis = await tafelApi('/api/bugtracker/v1/roadmap?action=rank_between', {
id: karte.id,
prev_id: null,
next_id: null,
min_rank: phase.rank_from,
max_rank: phase.rank_to
});
await tafelLaden();
if (ergebnis.renumbered) {
tafelHinweis('#' + karte.id + ' steht jetzt auf Rang ' + ergebnis.rank
+ '. Die Ränge dieses Projekts wurden dabei einmal neu vergeben.', 'warnung');
}
} catch (err) {
tafelHinweis(err.message, 'fehler');
}
});
return el;
}
function tafelStatsRendern(stats) {
const ziel = document.getElementById('tafelStats');
ziel.innerHTML = '';
[
['✅ Erledigt', stats.done, 'var(--success)'],
['📋 Geplant', stats.planned, '#5b9dff'],
['🔴 Hervorgehoben', stats.highlight, 'var(--danger)'],
['🧊 Wird überlegt', stats.thinking, 'var(--text-muted)']
].forEach(([beschriftung, wert, farbe]) => {
const karte = document.createElement('div');
karte.className = 'stat-card';
const kopf = document.createElement('div');
kopf.className = 'stat-header';
kopf.textContent = beschriftung;
const zahl = document.createElement('div');
zahl.className = 'stat-value';
zahl.style.color = farbe;
zahl.textContent = String(wert);
karte.appendChild(kopf);
karte.appendChild(zahl);
ziel.appendChild(karte);
});
}
function tafelLegendeRendern(lanes) {
const ziel = document.getElementById('tafelLegende');
ziel.innerHTML = '';
const zeile = document.createElement('div');
zeile.style.cssText = 'display:flex; flex-wrap:wrap; gap:0.35rem 1.1rem; align-items:center; color:var(--text-muted);';
[
['Erledigt', 'state-done'],
['In Arbeit', 'state-working'],
['Nachprüfung', 'state-verification'],
['Geplant', 'state-open'],
['Zurückgestellt', 'state-deferred'],
['Idee', 'state-idea']
].forEach(([text, klasse]) => {
const eintrag = document.createElement('span');
eintrag.style.cssText = 'display:inline-flex; align-items:center; gap:0.35rem;';
const probe = document.createElement('i');
probe.className = 'tafel-karte ' + klasse;
probe.style.cssText = 'width:22px; height:14px; padding:0; cursor:default; display:inline-block;';
eintrag.appendChild(probe);
eintrag.appendChild(document.createTextNode(text));
zeile.appendChild(eintrag);
});
const hervorgehoben = lanes.filter(l => l.highlight).map(l => l.title);
if (hervorgehoben.length) {
const hinweis = document.createElement('span');
hinweis.style.cssText = 'display:inline-flex; align-items:center; gap:0.35rem;';
const probe = document.createElement('i');
probe.className = 'tafel-karte hervorgehoben';
probe.style.cssText = 'width:22px; height:14px; padding:0; cursor:default; display:inline-block;';
hinweis.appendChild(probe);
hinweis.appendChild(document.createTextNode('Hervorgehoben: ' + hervorgehoben.join(', ')));
zeile.appendChild(hinweis);
}
const zieh = document.createElement('span');
zieh.style.cssText = 'margin-left:auto; opacity:0.75;';
zieh.textContent = 'Karte anklicken öffnet das Panel · Ziehen setzt die Reihenfolge';
zeile.appendChild(zieh);
ziel.appendChild(zeile);
}
function tafelKarte(karte, spur) {
const el = document.createElement('div');
el.className = 'tafel-karte state-' + karte.state + (spur.highlight ? ' hervorgehoben' : '');
el.dataset.id = String(karte.id);
el.dataset.rank = karte.rank === null ? '' : String(karte.rank);
el.tabIndex = 0;
el.setAttribute('role', 'button');
// Erledigtes wird nicht umsortiert - sein Rang sagt nichts mehr.
el.draggable = karte.state !== 'done';
if (spur.color && !spur.highlight) {
el.style.borderLeftColor = spur.color;
}
const kopfzeile = document.createElement('div');
kopfzeile.className = 'kopfzeile';
const nummer = document.createElement('span');
nummer.className = 'nummer';
nummer.textContent = (karte.state === 'done' ? '✓ #' : '#') + karte.id;
kopfzeile.appendChild(nummer);
const marken = [];
if (karte.state === 'verification') { marken.push(['Nachprüfung', 'pruef']); }
if (karte.state === 'working') {
marken.push([karte.claimed_by ? karte.claimed_by : 'in Arbeit', 'warn']);
}
if (karte.lease_expired) { marken.push(['Lease abgelaufen', 'warn']); }
if (karte.is_new) { marken.push(['neu', 'neu']); }
// Erledigtes braucht keine Begruendung mehr - dort waere die Marke nur
// ein Vorwurf ohne Adressaten.
if (!karte.has_rationale && karte.state !== 'done') {
marken.push(['ohne Begründung', 'leer']);
}
if (karte.severity === 'critical' || karte.severity === 'high') {
marken.push([karte.severity === 'critical' ? 'kritisch' : 'hoch', 'hoch']);
}
marken.forEach(([text, klasse]) => {
const marke = document.createElement('span');
marke.className = 'tafel-marke ' + klasse;
marke.textContent = text;
kopfzeile.appendChild(marke);
});
el.appendChild(kopfzeile);
const titel = document.createElement('div');
titel.className = 'titel';
titel.textContent = karte.title;
el.appendChild(titel);
// Ein zurueckgestelltes Vorhaben ohne Ausloeser ist ein vergessenes -
// deshalb steht der Ausloeser am Kaertchen und nicht nur im Panel.
const zusatz = karte.defer_trigger
? 'Wieder dran: ' + karte.defer_trigger
: (karte.target_build ? 'Ziel: ' + karte.target_build : null);
if (zusatz) {
const z = document.createElement('div');
z.className = 'zusatz';
z.textContent = zusatz;
el.appendChild(z);
}
el.addEventListener('click', () => tafelPanelOeffnen(karte.id));
el.addEventListener('keydown', ev => {
if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault();
tafelPanelOeffnen(karte.id);
}
});
el.addEventListener('dragstart', ev => {
tafelGezogen = karte;
el.classList.add('dragging');
ev.dataTransfer.effectAllowed = 'move';
ev.dataTransfer.setData('text/plain', String(karte.id));
});
el.addEventListener('dragend', () => {
tafelGezogen = null;
el.classList.remove('dragging');
document.querySelectorAll('.tafel-luecke.aktiv')
.forEach(l => l.classList.remove('aktiv'));
});
return el;
}
/**
* Eine Ablagefuge zwischen zwei Karten.
*
* Die Nachbarn sind die Karten dieser Spur in dieser Spalte - ihre Raenge
* gelten aber projektweit. Genau deshalb schickt der Client keine fertige
* Zahl, sondern nur die beiden Nachbarn: Den Rang dazwischen rechnet der
* Server, der als Einziger weiss, was sonst noch zwischen ihnen liegt.
*/
function tafelLuecke(phase, lane, davor, dahinter) {
const el = document.createElement('div');
el.className = 'tafel-luecke';
el.addEventListener('dragover', ev => {
if (!tafelGezogen) { return; }
if (davor && davor.id === tafelGezogen.id) { return; }
if (dahinter && dahinter.id === tafelGezogen.id) { return; }
ev.preventDefault();
ev.dataTransfer.dropEffect = 'move';
el.classList.add('aktiv');
});
el.addEventListener('dragleave', () => el.classList.remove('aktiv'));
el.addEventListener('drop', async ev => {
ev.preventDefault();
el.classList.remove('aktiv');
const karte = tafelGezogen;
tafelGezogen = null;
if (!karte) { return; }
try {
const ergebnis = await tafelApi(
'/api/bugtracker/v1/roadmap?action=rank_between',
{
id: karte.id,
prev_id: davor ? davor.id : null,
next_id: dahinter ? dahinter.id : null,
min_rank: phase.rank_from,
max_rank: phase.rank_to
}
);
await tafelLaden();
if (ergebnis.renumbered) {
tafelHinweis('#' + karte.id + ' steht jetzt auf Rang ' + ergebnis.rank
+ '. Zwischen den Nachbarn war kein Platz mehr, deshalb wurden die Ränge '
+ 'dieses Projekts einmal neu vergeben — die Reihenfolge blieb dabei erhalten.',
'warnung');
}
} catch (err) {
tafelHinweis(err.message, 'fehler');
}
});
return el;
}
/* --------------------------- Seitenpanel --------------------------- */
function tafelPanelSchliessen() {
document.getElementById('tafelPanel').classList.remove('offen');
document.getElementById('tafelPanel').setAttribute('aria-hidden', 'true');
document.getElementById('panelSchatten').classList.remove('offen');
if (location.hash.startsWith('#roadmap/')) {
history.replaceState(null, '', location.pathname + '#sub-roadmap-tafel');
}
}
function tafelOeffneItem(slug, id) {
const feld = document.getElementById('tafelProjekt');
if (feld && slug && feld.value !== slug) {
feld.value = slug;
tafelLaden().then(() => tafelPanelOeffnen(id));
return;
}
if (!tafelDaten) {
tafelLaden().then(() => tafelPanelOeffnen(id));
return;
}
tafelPanelOeffnen(id);
}
async function tafelPanelOeffnen(id) {
const panel = document.getElementById('tafelPanel');
const inhalt = document.getElementById('panelInhalt');
panel.classList.add('offen');
panel.setAttribute('aria-hidden', 'false');
document.getElementById('panelSchatten').classList.add('offen');
document.getElementById('panelTitel').textContent = 'Vorhaben #' + id;
inhalt.innerHTML = '';
const laedt = document.createElement('p');
laedt.style.color = 'var(--text-muted)';
laedt.textContent = 'wird geladen …';
inhalt.appendChild(laedt);
// Der Anker macht die Stelle zitierbar: Ein Agent kann in einem
// Kommentar auf genau dieses Kaertchen zeigen.
history.replaceState(null, '', location.pathname
+ '#roadmap/' + tafelProjektSlug() + '/item/' + id);
try {
const daten = await tafelApi('/api/bugtracker/v1/manage/?action=get&id=' + encodeURIComponent(id));
tafelPanelFuellen(daten.item);
} catch (err) {
inhalt.innerHTML = '';
const fehler = document.createElement('p');
fehler.style.color = 'var(--danger)';
fehler.textContent = err.message;
inhalt.appendChild(fehler);
}
}
function panelBlock(titel) {
const block = document.createElement('div');
block.className = 'panel-block';
const h3 = document.createElement('h3');
h3.textContent = titel;
block.appendChild(h3);
return block;
}
function tafelPanelFuellen(item) {
const inhalt = document.getElementById('panelInhalt');
inhalt.innerHTML = '';
document.getElementById('panelTitel').textContent = '#' + item.id + ' · ' + item.title;
/* --- Kenndaten --- */
const kopf = document.createElement('div');
kopf.className = 'panel-block';
kopf.style.cssText = 'display:flex; flex-wrap:wrap; gap:0.35rem;';
[
['Status', item.status],
['Schweregrad', item.severity],
['Art', item.type],
['Bereich', item.epic || '—'],
['Rang', item.roadmap_rank === null ? '—' : item.roadmap_rank],
['Ziel', item.target_build || '—']
].forEach(([beschriftung, wert]) => {
const marke = document.createElement('span');
marke.className = 'tafel-marke';
marke.textContent = beschriftung + ': ' + wert;
kopf.appendChild(marke);
});
if (item.claimed_by) {
const marke = document.createElement('span');
marke.className = 'tafel-marke warn';
marke.textContent = 'gehalten von ' + item.claimed_by
+ (item.lease_until ? ' bis ' + item.lease_until : '');
kopf.appendChild(marke);
}
inhalt.appendChild(kopf);
/* --- Begründung: warum wir uns so entschieden haben --------------
Bis 2.9.0 stand hier nur der Verweis doc_anchor auf einen Absatz in
einer Datei, die das WebUI nie zu sehen bekommt. Jetzt steht der
Absatz selbst hier - und lässt sich an derselben Stelle schreiben. */
const grundBlock = panelBlock('Begründung');
const grundFeld = document.createElement('textarea');
grundFeld.className = 'form-input';
grundFeld.rows = 8;
grundFeld.style.cssText = 'width:100%; font-family:inherit; line-height:1.5; resize:vertical;';
grundFeld.value = item.rationale || '';
grundFeld.placeholder = 'Warum ist das so entschieden? Was spricht dafür, was dagegen, '
+ 'und woran erkennt man später, dass es richtig war? — Markdown ist erlaubt.';
grundBlock.appendChild(grundFeld);
const grundZeile = document.createElement('div');
grundZeile.style.cssText = 'display:flex; gap:0.5rem; margin-top:0.5rem; align-items:center; flex-wrap:wrap;';
const grundSpeichern = document.createElement('button');
grundSpeichern.className = 'btn btn-sm';
grundSpeichern.textContent = 'Begründung speichern';
grundSpeichern.addEventListener('click', async () => {
grundSpeichern.disabled = true;
try {
await tafelApi('/api/bugtracker/v1/roadmap?action=set', {
id: item.id,
rationale: grundFeld.value.trim()
});
await tafelLaden();
await tafelPanelOeffnen(item.id);
} catch (err) {
alert(err.message);
} finally {
grundSpeichern.disabled = false;
}
});
grundZeile.appendChild(grundSpeichern);
const grundStand = document.createElement('span');
grundStand.style.cssText = 'font-size:0.72rem; color:var(--text-muted);';
if (item.rationale_updated_at) {
grundStand.textContent = 'zuletzt geändert ' + item.rationale_updated_at + ' UTC';
} else if (!item.rationale) {
grundStand.textContent = 'Noch keine hinterlegt.';
}
grundZeile.appendChild(grundStand);
grundBlock.appendChild(grundZeile);
if (item.doc_anchor) {
const alt = document.createElement('div');
alt.style.cssText = 'font-size:0.72rem; color:var(--text-muted); margin-top:0.5rem;';
alt.textContent = 'Früherer Verweis: ' + item.doc_anchor
+ ' — die Datei wird inzwischen aus diesem Feld erzeugt.';
grundBlock.appendChild(alt);
}
inhalt.appendChild(grundBlock);
if (item.defer_trigger) {
const block = panelBlock('Wieder dran, wenn');
const p = document.createElement('div');
p.className = 'beschreibung';
p.textContent = item.defer_trigger;
block.appendChild(p);
inhalt.appendChild(block);
}
/* --- Beschreibung --- */
if (item.description) {
const block = panelBlock('Beschreibung');
const p = document.createElement('div');
p.className = 'beschreibung';
p.textContent = item.description;
block.appendChild(p);
inhalt.appendChild(block);
}
/* --- Betroffene Projekte --- */
if (Array.isArray(item.affects) && item.affects.length) {
const block = panelBlock('Gemeldet aus');
item.affects.forEach(a => {
const marke = document.createElement('span');
marke.className = 'tafel-marke';
marke.style.marginRight = '0.3rem';
marke.textContent = a.project_slug + ' (' + a.report_count + '×)';
block.appendChild(marke);
});
inhalt.appendChild(block);
}
/* --- Roadmap-Felder aendern --- */
const felderBlock = panelBlock('Einordnung ändern');
const felder = document.createElement('div');
felder.className = 'felder';
const epicFeld = document.createElement('input');
epicFeld.className = 'form-input';
epicFeld.value = item.epic || '';
epicFeld.placeholder = 'Bereich, z. B. A';
const rangFeld = document.createElement('input');
rangFeld.className = 'form-input';
rangFeld.type = 'number';
rangFeld.value = item.roadmap_rank === null ? '' : item.roadmap_rank;
rangFeld.placeholder = 'Rang';
const zielFeld = document.createElement('input');
zielFeld.className = 'form-input';
zielFeld.value = item.target_build || '';
zielFeld.placeholder = 'Zielfassung, z. B. 2.9.0';
const statusFeld = document.createElement('select');
statusFeld.className = 'form-input';
['open', 'planned', 'in_progress', 'deferred', 'resolved', 'closed'].forEach(s => {
const opt = document.createElement('option');
opt.value = s;
opt.textContent = s;
opt.selected = (s === item.status);
statusFeld.appendChild(opt);
});
felder.appendChild(epicFeld);
felder.appendChild(rangFeld);
felder.appendChild(zielFeld);
felder.appendChild(statusFeld);
felderBlock.appendChild(felder);
const ausloeser = document.createElement('input');
ausloeser.className = 'form-input';
ausloeser.style.cssText = 'margin-top:0.6rem; width:100%; display:block;';
ausloeser.value = item.defer_trigger || '';
ausloeser.placeholder = 'Auslöser — Pflicht beim Zurückstellen: woran erkennt man, dass „später“ jetzt ist?';
felderBlock.appendChild(ausloeser);
const speichern = document.createElement('button');
speichern.className = 'btn btn-sm';
speichern.style.cssText = 'margin-top:0.6rem; display:block;';
speichern.textContent = 'Einordnung speichern';
speichern.addEventListener('click', async () => {
speichern.disabled = true;
try {
await tafelApi('/api/bugtracker/v1/roadmap?action=set', {
id: item.id,
epic: epicFeld.value.trim(),
// Leere Zeichenkette und nicht null: Ein null im JSON kommt
// serverseitig nicht von "Feld war gar nicht dabei" zu
// unterscheiden - dann bliebe der Rang stehen, statt
// geloescht zu werden.
roadmap_rank: rangFeld.value === '' ? '' : parseInt(rangFeld.value, 10),
target_build: zielFeld.value.trim(),
status: statusFeld.value,
defer_trigger: ausloeser.value.trim()
});
await tafelLaden();
await tafelPanelOeffnen(item.id);
} catch (err) {
alert(err.message);
} finally {
speichern.disabled = false;
}
});
felderBlock.appendChild(speichern);
inhalt.appendChild(felderBlock);
/* --- Kommentieren --- */
const kommentarBlock = panelBlock('Anmerken');
const eingabe = document.createElement('textarea');
eingabe.className = 'form-input';
eingabe.rows = 3;
eingabe.placeholder = 'Was ist Ihnen aufgefallen?';
kommentarBlock.appendChild(eingabe);
const zeile = document.createElement('div');
zeile.style.cssText = 'display:flex; gap:0.5rem; margin-top:0.5rem; align-items:center;';
const artFeld = document.createElement('select');
artFeld.className = 'form-input';
artFeld.style.width = 'auto';
['commented', 'investigated', 'fix_proposed', 'needs_human', 'blocked'].forEach(a => {
const opt = document.createElement('option');
opt.value = a;
opt.textContent = a;
artFeld.appendChild(opt);
});
const senden = document.createElement('button');
senden.className = 'btn btn-sm';
senden.textContent = 'Kommentar anfügen';
senden.addEventListener('click', async () => {
if (!eingabe.value.trim()) { return; }
senden.disabled = true;
try {
await tafelApi('/api/bugtracker/v1/manage/?action=comment', {
id: item.id,
comment: eingabe.value.trim(),
action_taken: artFeld.value
});
eingabe.value = '';
await tafelPanelOeffnen(item.id);
} catch (err) {
alert(err.message);
} finally {
senden.disabled = false;
}
});
zeile.appendChild(artFeld);
zeile.appendChild(senden);
kommentarBlock.appendChild(zeile);
inhalt.appendChild(kommentarBlock);
/* --- Verlauf --- */
const verlauf = panelBlock('Verlauf');
const kommentare = Array.isArray(item.comments) ? item.comments.slice().reverse() : [];
if (!kommentare.length) {
const leer = document.createElement('p');
leer.style.cssText = 'color:var(--text-muted); font-size:0.8rem;';
leer.textContent = 'Noch nichts vermerkt.';
verlauf.appendChild(leer);
}
kommentare.forEach(k => {
const kasten = document.createElement('div');
kasten.className = 'kommentar';
const meta = document.createElement('div');
meta.className = 'meta';
[k.author, k.created_at, k.action_taken].filter(Boolean).forEach(t => {
const span = document.createElement('span');
span.textContent = t;
meta.appendChild(span);
});
const text = document.createElement('div');
text.className = 'text';
text.textContent = k.comment;
kasten.appendChild(meta);
kasten.appendChild(text);
verlauf.appendChild(kasten);
});
inhalt.appendChild(verlauf);
}
document.addEventListener('DOMContentLoaded', () => {
const projektFeld = document.getElementById('tafelProjekt');
if (!projektFeld) { return; }
projektFeld.addEventListener('change', tafelLaden);
document.getElementById('tafelDoneLimit').addEventListener('change', tafelLaden);
document.getElementById('tafelNeuLaden').addEventListener('click', tafelLaden);
document.getElementById('panelSchliessen').addEventListener('click', tafelPanelSchliessen);
document.getElementById('panelSchatten').addEventListener('click', tafelPanelSchliessen);
document.addEventListener('keydown', ev => {
if (ev.key === 'Escape') { tafelPanelSchliessen(); }
});
// Ein tiefer Verweis laedt die Tafel ueber restoreViewFromHash selbst.
if (!location.hash.startsWith('#roadmap/')) {
tafelLaden();
}
});
</script>
<script>
/* ---------------------------------------------------------------------
* Hardwarekey registrieren
*
* navigator.credentials laesst sich nicht aus einem Formular heraus
* bedienen - deshalb dieser Umweg ueber /api/auth/v1/. Das CSRF-Token
* reist im Kopf mit, weil der Rumpf JSON ist und kein Formularfeld hat.
* ------------------------------------------------------------------- */
(function () {
const button = document.getElementById('btnRegisterKey');
if (!button) { return; }
const status = document.getElementById('keyRegisterStatus');
const labelField = document.getElementById('keyLabel');
const csrfToken = <?= json_encode(Csrf::token(), JSON_UNESCAPED_SLASHES) ?>;
const fromB64u = value => {
const padded = value.replace(/-/g, '+').replace(/_/g, '/');
const binary = atob(padded + '='.repeat((4 - padded.length % 4) % 4));
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i); }
return bytes;
};
const toB64u = buffer => btoa(String.fromCharCode(...new Uint8Array(buffer)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
const setStatus = (text, tone) => {
status.textContent = text;
status.style.color = tone === 'error' ? 'var(--danger)'
: (tone === 'ok' ? 'var(--success)' : 'var(--text-muted)');
};
/**
* Holt den lesbaren Text aus einer Fehlerantwort.
*
* Die API verpackt Fehler als { error: { code, message } }. Ein
* "data.message || data.error" griff deshalb auf das OBJEKT zu, und der
* Browser machte daraus "[object Object]" - ausgerechnet an der Stelle,
* an der die Begruendung stehen sollte, warum ein Schluessel abgelehnt
* wurde. Beide Formen zu lesen ist kein Zierrat: Die aeltere, flache
* Form kommt weiterhin vor.
*/
const fehlertext = data => {
const huelle = data && data.error;
if (huelle && typeof huelle === 'object' && huelle.message) { return huelle.message; }
if (data && typeof data.message === 'string') { return data.message; }
if (typeof huelle === 'string') { return huelle; }
return 'Unerwartete Antwort des Servers.';
};
const post = async (action, payload) => {
const response = await fetch('/api/auth/v1/?action=' + action, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken },
credentials: 'same-origin',
body: JSON.stringify(payload || {})
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(fehlertext(data));
}
return data;
};
button.addEventListener('click', async () => {
if (!window.PublicKeyCredential) {
setStatus('Dieser Browser kennt keine Hardwarekeys.', 'error');
return;
}
button.disabled = true;
try {
setStatus('Anfrage wird vorbereitet …');
const vorgabe = await post('register_options');
const options = vorgabe.publicKey;
// Die Regel steht vor der Berührung, nicht danach. Wer erst nach
// dem Antippen erfährt, dass nur Hardware zählt, sucht den Fehler
// beim Schlüssel statt bei der Einstellung.
const nurHardware = vorgabe.policy && vorgabe.policy !== 'off';
options.challenge = fromB64u(options.challenge);
options.user.id = fromB64u(options.user.id);
options.excludeCredentials = (options.excludeCredentials || []).map(c => ({
...c, id: fromB64u(c.id)
}));
setStatus(nurHardware
? 'Bitte den Hardwarekey jetzt anstecken und berühren. Zugelassen sind nur Geräte '
+ 'mit Herstellernachweis — ein Passwortverwalter wird abgelehnt.'
: 'Bitte den Hardwarekey jetzt anstecken und berühren.');
const credential = await navigator.credentials.create({ publicKey: options });
setStatus('Schlüssel wird hinterlegt …');
const transports = typeof credential.response.getTransports === 'function'
? credential.response.getTransports()
: null;
const ergebnis = await post('register_verify', {
clientDataJSON: toB64u(credential.response.clientDataJSON),
attestationObject: toB64u(credential.response.attestationObject),
label: (labelField.value || '').trim(),
transports: transports
});
setStatus(ergebnis.attested
? 'Registriert und als Hardware nachgewiesen (' + (ergebnis.root || 'Herstellerwurzel')
+ '). Die Seite wird neu geladen …'
: 'Registriert. Ein Hardwarenachweis liegt nicht vor. Die Seite wird neu geladen …', 'ok');
window.location.reload();
} catch (err) {
const message = err && err.name === 'NotAllowedError'
? 'Abgebrochen oder zu lange gewartet. Bitte erneut versuchen.'
: (err && err.message ? err.message : 'Der Hardwarekey konnte nicht gelesen werden.');
setStatus(message, 'error');
button.disabled = false;
}
});
})();
</script>
</body>
</html>