Fehler-Schnittstelle - Neuer schlanker Eingang POST /api/errors/v1/report für den globalen Exception-Handler einer Anwendung. Titel und Dringlichkeit leitet der Server ab; gespeichert wird in derselben Tabelle wie der Bugtracker. Ein zweiter Speicher wäre nur ein zweiter Ort, an dem man suchen müsste. - error_level (fatal/error/warning) trennt die technische Art des Ereignisses von der geschäftlichen Dringlichkeit. Ein Duplicate-Entry ist technisch ein error, geschäftlich belanglos — beides zu vermischen war der Grund, warum solche Meldungen als Bug im Dashboard landeten. Ignore-Regeln gegen bekanntes Rauschen - bugtracker_ignore_rules mit contains/regex/exception_class, Pflichtfeld für die Begründung und optionaler Alarmschwelle. - 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 der eigentliche Zweck — dass ein bekannter Fehler auftritt, ist normal; dass er plötzlich hundertmal so oft auftritt, ist ein Signal. Dafür das rollende Stundenfenster und error.rate_exceeded. - Neue Regeln lassen sich rückwirkend auf bestehende Einträge anwenden. Gruppierung überarbeitet - Der Schlüssel nahm bisher 300 Zeichen Stacktrace auf. Derselbe Fehler zersplitterte dadurch, sobald ein Aufrufer den Stack einmal mitschickte und einmal nicht. Jetzt zählt der Ursprungsort: bevorzugt die Dateiangabe, sonst der erste Rahmen des Stacktrace. - Die Normalisierung ersetzte nur Zahlen ab vier Stellen, wodurch 'AA-1' und 'BB-2' getrennt blieben. Werte in Anführungszeichen, die Ziffern enthalten, gelten jetzt als veränderlich — der Schlüsselname bleibt erhalten, sodass verschiedene Unique-Keys unterscheidbar sind. Mit 9 Testfällen belegt. Metrik-Verlauf - watchdog_metrics speichert numerische Heartbeat-Werte mit Zeitstempel. Zuvor wurde metrics_json bei jedem Heartbeat überschrieben; damit ließ sich "die Platte läuft seit drei Tagen voll" nicht erkennen, nur "sie ist voll". - GET /api/watchdog/v1/metrics liefert den verdichteten Verlauf und die Abweichung vom eigenen Sieben-Tage-Durchschnitt. Dieser relative Ansatz braucht keine projektspezifischen Schwellwerte. - Aufbewahrung 14 Tage, Bereinigung stündlich durch den Evaluator. Health-Checks per Push statt Abruf - Der Heartbeat nimmt ein checks-Objekt entgegen, das die Anwendung selbst ermittelt. Das Deploymentcenter interpretiert die Namen nicht, es liest nur ok und message — was "gesund" bedeutet, entscheidet jede Anwendung selbst. Schlägt eine Prüfung fehl, wird ein als ok gemeldeter Heartbeat auf warning herabgestuft. - Bewusst ausgehend: auf den Zielmaschinen müssen keine Ports geöffnet werden. Abhängigkeitsbewusste Alarmierung - Fällt ein Monitor aus, dessen Parent selbst unten ist, wird der Alarm unterdrückt. Der Zustand bleibt sichtbar. Vorher erzeugte ein ausgefallener Hypervisor mit zwölf VMs dreizehn Meldungen für ein Problem. - Mehrere Ebenen und fehlerhafte Hierarchien (Zyklen, gelöschte Parents) sind abgesichert; mit 10 Testfällen belegt. WebUI - Neue Ansicht "Fehler-Stream" mit Filtern nach Projekt, Fehlerklasse, Umgebung, Zeitraum und Sichtbarkeit sowie Volltextsuche und Pagination. Stummgeschaltete Einträge sind standardmäßig ausgeblendet. - Verwaltung der Ignore-Regeln inklusive Trefferzähler. - Die Detailansicht zeigt Fehlerklasse, Stummschaltungsgrund und die Häufung im laufenden Stundenfenster. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4407 lines
261 KiB
PHP
4407 lines
261 KiB
PHP
<?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. */
|
||
function e(?string $value): string
|
||
{
|
||
return htmlspecialchars($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 <SHARED_KEY>" <?= e($baseUrl) ?>/api/watchdog/v1/evaluate > /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;">×</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 & 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) · 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 & 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 & 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 <DC_TOKEN>
|
||
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 & 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&id=42
|
||
POST /api/bugtracker/v1/manage?action=resolve&id=42
|
||
|
||
UPDATESERVICE
|
||
GET /api/updateservice/v1/check?product=myapp&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 & 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, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
// 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>
|