Files
Deploymentcenter/public/index.php
T
Deploymentcenter BotandClaude Opus 5 92bb400f02 fix(webui): e() nimmt jeden skalaren Wert entgegen
Die Seite brach beim Aufbau ab und lieferte im Browser
"switchMainTab is not defined": das Rendering endete vor dem Script-Block,
sodass keine einzige Funktion definiert wurde.

Ursache: e() war als ?string deklariert. PHP wandelt numerische Array-Schlüssel
automatisch in int um, sodass die Zeitraum-Auswahl im Fehler-Stream mit den
Schlüsseln 0/1/24/168/720 einen int übergab. Unter declare(strict_types=1) warf
das einen TypeError mitten in der Ausgabe.

Die enge Signatur war generell unpassend: mit ATTR_STRINGIFY_FETCHES = false
liefert PDO numerische Spalten als int, und genau diese Werte werden über e()
ausgegeben. Die Funktion nimmt jetzt jeden skalaren Typ entgegen.

Gefunden hat den Fehler die Selbstmeldung: die unbehandelte Exception landete
als Item im eigenen Bugtracker, inklusive Datei und Zeilennummer.

Die übrigen typisierten Helfer (dc_dt, dc_ago, getMonitorIconUrl, btUrl,
errUrl) wurden gegengeprüft; sie erhalten ausschließlich Strings bzw. Arrays.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 22:06:39 +02:00

4434 lines
262 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\TokenManager as CoreTokenManager;
use Deploymentcenter\Modules\Bugtracker\BugRepo;
use Deploymentcenter\Modules\Bugtracker\IgnoreRules;
use Deploymentcenter\Modules\License\Audit;
use Deploymentcenter\Modules\License\KeyGen;
use Deploymentcenter\Modules\UpdateService\UpdateManager;
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];
}
/**
* Post/Redirect/Get: leitet nach einer Aktion auf dieselbe Seite um, damit
* ein Neuladen das Formular nicht erneut abschickt.
*/
function dc_redirect(string $fragment = ''): void
{
$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'] ?? ''));
// Nur unkritische Zeichen, damit nichts in das erzeugte Skript
// hineingeschmuggelt werden kann.
$source = preg_replace('/[^A-Za-z0-9._ -]/', '', $source) ?? 'server-node';
$token = preg_replace('/[^A-Za-z0-9._-]/', '', $token) ?? '';
$endpoint = Http::baseUrl() . '/api/watchdog/v1/ping';
if ($os === 'windows') {
$script = "# Deploymentcenter Watchdog Agent (Windows PowerShell)\n"
. "# Als geplante Aufgabe minuetlich ausfuehren.\n\n"
. '$WatchdogUrl = "' . $endpoint . "\"\n"
. '$Token = "' . $token . "\"\n"
. '$Source = "' . $source . "\"\n\n"
. '$body = @{ source = $Source; status = "ok"; message = "Heartbeat via PowerShell Task"; interval = 60 } | ConvertTo-Json' . "\n"
. 'try {' . "\n"
. ' Invoke-RestMethod -Uri $WatchdogUrl -Method Post -Body $body -ContentType "application/json" -Headers @{ "X-Agent-Token" = $Token } | Out-Null' . "\n"
. ' Write-Host "[OK] Heartbeat gesendet."' . "\n"
. '} catch {' . "\n"
. ' Write-Error "Heartbeat fehlgeschlagen: $_"' . "\n"
. ' exit 1' . "\n"
. '}' . "\n";
$filename = 'watchdog-agent-' . $source . '.ps1';
} else {
$script = "#!/usr/bin/env bash\n"
. "# Deploymentcenter Watchdog Agent (Linux)\n"
. "# Per Cron minuetlich ausfuehren.\n\n"
. 'set -euo pipefail' . "\n\n"
. 'WATCHDOG_URL="' . $endpoint . "\"\n"
. 'TOKEN="' . $token . "\"\n"
. 'SOURCE="' . $source . "\"\n\n"
. 'curl -fsS -X POST "$WATCHDOG_URL" \\' . "\n"
. ' -H "Content-Type: application/json" \\' . "\n"
. ' -H "X-Agent-Token: $TOKEN" \\' . "\n"
. ' -d "{\"source\": \"$SOURCE\", \"status\": \"ok\", \"message\": \"Heartbeat via Cron\", \"interval\": 60}" \\' . "\n"
. ' > /dev/null && echo "[OK] Heartbeat gesendet."' . "\n";
$filename = 'watchdog-agent-' . $source . '.sh';
}
header('Content-Type: text/plain; charset=utf-8');
header('Content-Disposition: attachment; filename="' . preg_replace('/[^A-Za-z0-9._-]/', '', $filename) . '"');
echo $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]);
});
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]);
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]);
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]);
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);
$result = $updateManager->addRelease(
$productSlug,
$version,
trim((string)($_POST['channel'] ?? 'prod')),
trim((string)($_POST['release_notes'] ?? '')),
$url,
$hash !== '' ? $hash : null,
trim((string)($_POST['git_commit'] ?? '')),
(int)($_POST['size_bytes'] ?? 0),
null,
isset($_POST['is_critical']),
$actor
);
dc_flash(sprintf(
'Release %s fuer "%s" %s.%s',
e($version),
e($productSlug),
$result['created'] ? 'veroeffentlicht' : 'aktualisiert',
$result['auto_resolved'] > 0
? sprintf(' %d Bugtracker-Item(s) automatisch geschlossen.', $result['auto_resolved'])
: ''
));
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 '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');
}
$bugRepo = new BugRepo($pdo);
$result = $bugRepo->reportItem([
'project_slug' => trim((string)($_POST['project_slug'] ?? 'default')),
'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];
}
}
$ok = $itemId > 0 && (new BugRepo($pdo))->updateItemDetails($itemId, $updates, 'user:' . $actor);
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');
}
// ------------------------------------------------ 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');
}
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/')) {
return $icon;
}
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();
$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));
$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' => (string)($_GET['bt_status'] ?? 'all'),
'search' => (string)($_GET['bt_q'] ?? ''),
'order' => (string)($_GET['bt_order'] ?? 'newest'),
'limit' => $btPerPage,
'offset' => ($btPage - 1) * $btPerPage,
];
$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
);
/** 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_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';
}
// 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;
$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); }
.main-content { padding: 1.75rem 2rem; max-width: 1600px; }
/* 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 */
.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; }
.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); }
.tab-content, .subtab-content { display: none; }
.tab-content.active, .subtab-content.active { display: block; }
</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('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;">
Cron einrichten: * * * * * curl -fsS -H "Authorization: Bearer &lt;SHARED_KEY&gt;" <?= e($baseUrl) ?>/api/watchdog/v1/evaluate &gt; /dev/null
</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>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><?= 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">
<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>
<!-- 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 ================= -->
<div id="tab-license" class="tab-content">
<!-- Subtab: License Dashboard -->
<div id="sub-license-dashboard" class="subtab-content active">
<div class="stats-grid">
<div class="stat-card">
<div class="stat-header">Registrierte Projekte</div>
<div class="stat-value"><?= count($projects) ?></div>
</div>
<div class="stat-card">
<div class="stat-header">Gültige Lizenzen</div>
<div class="stat-value"><?= count(array_filter($licenses, fn($l) => $l['status'] === 'active')) ?></div>
</div>
<div class="stat-card">
<div class="stat-header">Aktivierte Systeme</div>
<div class="stat-value"><?= count($activations) ?></div>
</div>
</div>
</div>
<!-- Subtab: Licenses -->
<div id="sub-license-licenses" class="subtab-content">
<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="<?= $p['id'] ?>"><?= htmlspecialchars($p['name']) ?> (<?= htmlspecialchars($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 foreach ($licenses as $l): ?>
<tr>
<td><?= htmlspecialchars($l['product_name']) ?></td>
<td><code style="font-size:0.9rem; font-weight:bold; color:var(--primary);"><?= htmlspecialchars($l['license_key']) ?></code></td>
<td><?= htmlspecialchars($l['customer_name'] ?? '-') ?></td>
<td><?= $l['active_count'] ?> / <?= $l['max_activations'] ?></td>
<td><span class="badge badge-<?= $l['status'] === 'active' ? 'up' : 'down' ?>"><?= strtoupper($l['status']) ?></span></td>
<td><?= $l['expires_at'] ? htmlspecialchars($l['expires_at']) : 'Unbefristet' ?></td>
<td style="display:flex; gap:0.35rem;">
<a href="index.php?action=download_lic&id=<?= $l['id'] ?>" class="btn btn-sm btn-secondary" title=".lic Datei herunterladen">📥 .lic</a>
<button class="btn btn-sm btn-secondary" onclick="openEditLicense(<?= htmlspecialchars(json_encode($l)) ?>)">✏️ Edit</button>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<!-- Subtab: License Details & Full Editing -->
<div id="sub-license-details" class="subtab-content">
<div class="card">
<div class="card-header"><h2 class="card-title">✏️ Lizenz Vollständig Bearbeiten</h2></div>
<form method="POST" action="index.php#sub-license-details">
<?= $csrfField ?>
<input type="hidden" name="action" value="edit_license">
<div class="form-grid">
<div class="form-group">
<label class="form-label">Lizenz auswählen</label>
<select name="license_id" id="licEditSelect" class="form-input" required onchange="loadLicenseData(this)">
<?php foreach ($licenses as $l): ?>
<option value="<?= $l['id'] ?>" data-cname="<?= htmlspecialchars($l['customer_name'] ?? '') ?>" data-cemail="<?= htmlspecialchars($l['customer_email'] ?? '') ?>" data-status="<?= $l['status'] ?>" data-max="<?= $l['max_activations'] ?>" data-exp="<?= $l['expires_at'] ? date('Y-m-d', strtotime($l['expires_at'])) : '' ?>" data-notes="<?= htmlspecialchars($l['notes'] ?? '') ?>">
<?= htmlspecialchars($l['license_key']) ?> (<?= htmlspecialchars($l['customer_name'] ?? 'Unbekannt') ?>)
</option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label class="form-label">Kunden Name</label>
<input type="text" id="editCustomerName" name="customer_name" class="form-input">
</div>
<div class="form-group">
<label class="form-label">Kunden E-Mail</label>
<input type="email" id="editCustomerEmail" name="customer_email" class="form-input">
</div>
<div class="form-group">
<label class="form-label">Status</label>
<select id="editStatus" name="status" class="form-input">
<option value="active">Aktiv</option>
<option value="revoked">Widerrufen</option>
<option value="suspended">Pausiert</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Max. Aktivierungen</label>
<input type="number" id="editMaxAct" name="max_activations" class="form-input" min="1">
</div>
<div class="form-group">
<label class="form-label">Ablaufdatum</label>
<input type="date" id="editExpiresAt" name="expires_at" class="form-input">
</div>
</div>
<button type="submit" class="btn">Lizenzdaten Speichern</button>
</form>
</div>
<div class="card">
<div class="card-header"><h2 class="card-title">Aktivierte Hardware-IDs verwalten</h2></div>
<table>
<thead>
<tr>
<th>Projekt</th>
<th>Lizenzschlüssel</th>
<th>Hardware-ID / Quelle</th>
<th>Plattform / Ver.</th>
<th>Hostname</th>
<th>Zuletzt gesehen</th>
<th>Status</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
<?php foreach ($activations as $a): ?>
<tr>
<td><?= htmlspecialchars($a['product_name']) ?></td>
<td><code><?= htmlspecialchars($a['license_key']) ?></code></td>
<td>
<code><?= htmlspecialchars($a['hardware_id']) ?></code>
<?php if (!empty($a['hwid_source'])): ?>
<br><small style="color:var(--text-muted);">Quelle: <?= htmlspecialchars($a['hwid_source']) ?></small>
<?php endif; ?>
</td>
<td>
<span class="badge" style="background:var(--bg-card); border:1px solid var(--border-color); color:var(--text-main);">
<?= htmlspecialchars(strtoupper($a['platform'] ?? 'WIN')) ?> (v<?= (int)($a['hwid_version'] ?? 1) ?>)
</span>
</td>
<td><?= htmlspecialchars($a['hostname'] ?? '-') ?></td>
<td><?= htmlspecialchars($a['last_seen']) ?></td>
<td>
<span class="badge badge-<?= $a['is_blocked'] ? 'down' : 'up' ?>"><?= $a['is_blocked'] ? 'GESPERRT' : 'AKTIV' ?></span>
</td>
<td>
<form method="POST" action="index.php#sub-license-details" style="display:inline">
<?= $csrfField ?>
<input type="hidden" name="action" value="toggle_block_activation">
<input type="hidden" name="activation_id" value="<?= $a['id'] ?>">
<input type="hidden" name="block_state" value="<?= $a['is_blocked'] ? '0' : '1' ?>">
<button type="submit" class="btn btn-sm <?= $a['is_blocked'] ? 'btn' : 'btn-danger' ?>">
<?= $a['is_blocked'] ? 'Entsperren' : 'Sperren' ?>
</button>
</form>
<form method="POST" action="index.php#sub-license-details" style="display:inline" onsubmit="return confirm('Möchtest du diese Aktivierung wirklich freigeben (löschen)?');">
<?= $csrfField ?>
<input type="hidden" name="action" value="release_activation">
<input type="hidden" name="activation_id" value="<?= $a['id'] ?>">
<button type="submit" class="btn btn-sm btn-danger" style="margin-left:4px;">
Freigeben
</button>
</form>
</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">💾 Signierte Offline-Lizenzdatei erzeugen (.lic)</h2></div>
<p style="color:var(--text-muted); font-size:0.875rem; margin-bottom:1rem;">
Erzeugt eine signierte <code>.lic</code> Offline-Lizenzdatei für Air-Gapped Kundensysteme.
</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="<?= htmlspecialchars($l['license_key']) ?>"><?= htmlspecialchars($l['license_key']) ?> (<?= htmlspecialchars($l['customer_name'] ?? 'Unbekannt') ?>)</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="HWID-88A9-99B1-CC02">
</div>
</div>
<button class="btn" onclick="generateOfflinePayload()">Offline Payload Generieren</button>
<div style="margin-top:1.5rem;">
<label class="form-label">Generierte Offline .lic Payload (JSON)</label>
<div class="prompt-box" id="offlineResultBox">Wählen Sie oben eine Lizenz aus und klicken Sie auf 'Offline Payload Generieren'.</div>
</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 foreach ($auditLogs as $log): ?>
<tr>
<td><?= $log['id'] ?></td>
<td><?= $log['created_at'] ?></td>
<td><code><?= htmlspecialchars($log['actor']) ?></code></td>
<td><strong><?= htmlspecialchars($log['action']) ?></strong></td>
<td><code style="font-size:0.8rem;"><?= htmlspecialchars($log['details'] ?? '-') ?></code></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<!-- Subtab: Integration & Prompts -->
<div id="sub-license-integration" class="subtab-content">
<div class="card">
<div class="card-header"><h2 class="card-title">🔑 Server Ed25519 Public Key & Endpunkte</h2></div>
<div class="form-grid">
<div class="form-group">
<label class="form-label">Server Public Key (Ed25519 Base64)</label>
<input type="text" id="pubKeyInput" readonly class="form-input" value="MCowBQYDK2VwAyEA9f8J7K2mX4vQ8n1L6s5t4r3q2p1o0n9m8l7k6j5h4g3f">
</div>
<div class="form-group">
<label class="form-label">API Validate Endpoint</label>
<input type="text" readonly class="form-input" value="<?= $baseUrl ?>/api/license/v1/validate">
</div>
</div>
</div>
</div>
<!-- Subtab: Settings -->
<div id="sub-license-settings" class="subtab-content">
<div class="card">
<div class="card-header"><h2 class="card-title">⚙️ Moduleinstellungen & API Konfiguration</h2></div>
<div class="form-grid">
<div class="form-group">
<label class="form-label">REST API Validation Endpoint (Public)</label>
<input type="text" readonly class="form-input" value="<?= $baseUrl ?>/api/license/v1/validate">
</div>
<div class="form-group">
<label class="form-label">REST API Deactivation Endpoint (Geschützt - Auth erforderlich)</label>
<input type="text" readonly class="form-input" value="<?= $baseUrl ?>/api/license/v1/deactivate">
</div>
</div>
</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>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><?= 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>Version</th>
<th>Git Commit</th>
<th>Größe</th>
<th>Release Notes</th>
<th>Download & SHA256</th>
<th>Datum</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><code>v<?= htmlspecialchars($r['version']) ?></code> <?php if (!empty($r['is_critical'])): ?><span class="badge badge-down">KRITISCH</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>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</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">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>
</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;">
<select name="bt_project" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="this.form.submit()">
<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="this.form.submit()">
<?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="this.form.submit()">
<?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="this.form.submit()">
<?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>
<select name="bt_status" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="this.form.submit()">
<?php
$statusOptions = [
'all' => '📌 Alle Status',
'open,planned,in_progress' => '📋 Nur offene',
'open' => 'Offen',
'planned' => 'Geplant',
'in_progress' => 'In Bearbeitung',
'resolved' => 'Gelöst',
'closed' => 'Geschlossen',
'rejected' => 'Abgelehnt',
];
foreach ($statusOptions as $value => $label): ?>
<option value="<?= e($value) ?>" <?= $btFilters['status'] === $value ? 'selected' : '' ?>><?= e($label) ?></option>
<?php endforeach; ?>
</select>
<select name="bt_order" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="this.form.submit()">
<?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:170px; padding:0.3rem 0.6rem; font-size:0.8rem;" placeholder="🔍 Suchen...">
<button type="submit" class="btn btn-sm">Filtern</button>
<?php if ($btFilters['search'] !== '' || $btFilters['project_slug'] !== 'all' || $btFilters['status'] !== 'all' || $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>
</div>
<table>
<thead>
<tr>
<th>ID / Typ</th>
<th>Umgebung</th>
<th>Projekt &amp; Titel</th>
<th>Build</th>
<th>Schweregrad</th>
<th>Anzahl</th>
<th>Status</th>
<th>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>',
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>
<strong style="color:#fff;"><?= e((string)$item['title']) ?></strong>
<div style="font-size:0.75rem; color:var(--text-muted); margin-top:0.2rem; display:flex; flex-wrap:wrap; gap:0.4rem; align-items:center;">
<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>
<div style="display:flex; gap:0.3rem; flex-wrap:wrap;">
<button type="button" class="btn btn-sm btn-secondary" 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>
<?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;">
<select name="err_project" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="this.form.submit()">
<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="this.form.submit()">
<?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="this.form.submit()">
<?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="this.form.submit()">
<?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="this.form.submit()">
<?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:170px; padding:0.3rem 0.6rem; font-size:0.8rem;" placeholder="🔍 Suchen...">
<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>
<table>
<thead>
<tr>
<th>Klasse</th>
<th>Fehler</th>
<th>Projekt / Umgebung</th>
<th>Build</th>
<th>Anzahl</th>
<th>Zuletzt</th>
<th>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>
<strong style="color:#fff;"><?= e(mb_strimwidth((string)$item['title'], 0, 90, '…')) ?></strong>
<div style="font-size:0.72rem; color:var(--text-muted); margin-top:0.2rem; display:flex; flex-wrap:wrap; gap:0.4rem; align-items:center;">
<?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>
<div style="display:flex; gap:0.3rem; flex-wrap:wrap;">
<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>
<?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;">
<?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>
<div id="sub-tokens-create" class="subtab-content">
<div class="card">
<div class="card-header"><h2 class="card-title"> Neuen Master-Token Erstellen</h2></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
$scopeChoices = [
'*' => ['🌟 Alle Rechte', 'Uneingeschränkter Zugriff'],
'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'],
'updateservice:read' => ['📦 Updates prüfen', 'Auf neue Releases prüfen'],
'updateservice:publish' => ['🚀 Releases veröffentlichen', 'Neue Releases einstellen — schließt Prüfen ein'],
];
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) ?>" <?= $value === '*' ? '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-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': [
{ id: 'sub-license-dashboard', label: '📊 Dashboard', active: true },
{ id: 'sub-license-licenses', label: '🔑 Lizenzverwaltung' },
{ id: 'sub-license-details', label: '🔍 Lizenz Bearbeiten & HW' },
{ id: 'sub-license-offline', label: '💾 Offline-Lizenzen (.lic)' },
{ id: 'sub-license-audit', label: '📜 Audit-Log' },
{ id: 'sub-license-integration', label: '💻 Integration' },
{ id: 'sub-license-settings', label: '⚙️ Settings' }
],
'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-publish', label: ' Release Veröffentlichen' }
],
'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: ' Master-Token Erstellen' }
],
'system': [
{ id: 'sub-system-status', label: '⚙️ System-Status', active: true },
{ id: 'sub-system-swagger', label: '📖 API Swagger Docs' },
{ id: 'sub-system-migration', label: '🗄️ DB Migration' }
]
};
// Render Subnav links in Top-Bar
function renderTopBarSubnav(moduleName) {
const container = document.getElementById('topbarSubnav');
container.innerHTML = '';
const items = subnavItems[moduleName] || [];
items.forEach(item => {
const btn = document.createElement('button');
btn.className = `subnav-link ${item.active ? 'active' : ''}`;
btn.innerText = item.label;
btn.onclick = () => switchSubTab(moduleName, item.id, btn);
container.appendChild(btn);
});
}
// 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
function switchMainTab(moduleName, el) {
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');
}
if (el) {
el.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);
location.hash = '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.
// 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');
}
if (el) {
el.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';
}
// 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();
}
}
// License Editing Functions
function openEditLicense(l) {
switchMainTab('license', document.querySelector('.nav-link[onclick*="license"]'));
const subLink = document.querySelector('#topbarSubnav .subnav-link:nth-child(3)');
switchSubTab('license', 'sub-license-details', subLink);
const select = document.getElementById('licEditSelect');
select.value = l.id;
loadLicenseData(select);
window.scrollTo({ top: 0, behavior: 'smooth' });
}
function loadLicenseData(selectEl) {
const opt = selectEl.options[selectEl.selectedIndex];
document.getElementById('editCustomerName').value = opt.getAttribute('data-cname') || '';
document.getElementById('editCustomerEmail').value = opt.getAttribute('data-cemail') || '';
document.getElementById('editStatus').value = opt.getAttribute('data-status') || 'active';
document.getElementById('editMaxAct').value = opt.getAttribute('data-max') || '2';
document.getElementById('editExpiresAt').value = opt.getAttribute('data-exp') || '';
}
// 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);
}
}
function generateOfflinePayload() {
const key = document.getElementById('offlineLicSelect').value;
const hwId = document.getElementById('offlineHwId').value || 'HWID-GENERAL';
const payload = {
"type": "offline_license_file",
"issued_at": Math.floor(Date.now() / 1000),
"license_key": key,
"hardware_id": hwId,
"status": "valid",
"valid_until": "2027-12-31 23:59:59",
"signature": "ED25519_SIG_" + btoa(key + hwId).substring(0, 32)
};
document.getElementById('offlineResultBox').innerText = JSON.stringify(payload, null, 2);
}
// Initialize default subnav for Overview
renderTopBarSubnav('overview');
// Restore tab based on URL hash
window.addEventListener('load', () => {
const hash = location.hash.replace('#', '');
if (hash) {
if (hash.startsWith('tab-')) {
const module = hash.replace('tab-', '');
const link = document.querySelector(`.nav-link[onclick*="${module}"]`);
if (link) switchMainTab(module, link);
} else if (hash.startsWith('sub-')) {
const moduleParts = hash.split('-');
const module = moduleParts[1];
const mainLink = document.querySelector(`.nav-link[onclick*="${module}"]`);
if (mainLink) switchMainTab(module, mainLink);
setTimeout(() => {
const subLink = Array.from(document.querySelectorAll('#topbarSubnav .subnav-link')).find(l => l.innerText.toLowerCase().includes(module));
if (subLink) switchSubTab(module, hash, subLink);
}, 50);
}
}
});
</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>
<!-- 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>
</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>
</body>
</html>