7577 lines
447 KiB
PHP
7577 lines
447 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\Bugtracker\Roadmap;
|
||
use Deploymentcenter\Modules\License\Audit;
|
||
use Deploymentcenter\Modules\License\KeyGen;
|
||
use Deploymentcenter\Modules\UpdateService\ReleaseGuard;
|
||
use Deploymentcenter\Modules\UpdateService\ReleaseSigner;
|
||
use Deploymentcenter\Modules\UpdateService\UpdateManager;
|
||
use Deploymentcenter\Modules\Watchdog\AgentScript;
|
||
use Deploymentcenter\Modules\Watchdog\Evaluator;
|
||
use Deploymentcenter\Modules\Watchdog\EventLog;
|
||
use Deploymentcenter\Modules\Watchdog\MonitorRepo;
|
||
use Deploymentcenter\Modules\Watchdog\TokenManager;
|
||
|
||
Auth::requireLogin();
|
||
|
||
$pdo = Db::init();
|
||
|
||
// ======================================================================
|
||
// Migrations-Schranke
|
||
// ======================================================================
|
||
//
|
||
// Das Dashboard greift auf Spalten zu, die erst durch die Migration entstehen
|
||
// (claimed_by, lease_until, dedup_key ...). Ohne diese Pruefung liefe die Seite
|
||
// vor der Migration in einen Fehler - und ausgerechnet die Schaltflaeche zum
|
||
// Ausfuehren der Migration liegt in dieser Seite. Deshalb hier eine eigene,
|
||
// minimale Ansicht, solange etwas aussteht.
|
||
|
||
$pendingMigrations = [];
|
||
try {
|
||
$pendingMigrations = \Deploymentcenter\Core\Migrator::status($pdo)['pending'];
|
||
} catch (Throwable $e) {
|
||
Logger::warning('Migrationsstatus nicht ermittelbar', ['error' => $e->getMessage()]);
|
||
}
|
||
|
||
if ($pendingMigrations !== []) {
|
||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'run_migration') {
|
||
Csrf::requireValid();
|
||
|
||
$migrationResult = \Deploymentcenter\Core\Migrator::migrate($pdo);
|
||
|
||
if ($migrationResult['failed'] !== null) {
|
||
$migrationError = Config::isDebug()
|
||
? $migrationResult['failed']['error']
|
||
: 'Details stehen im Log unter var/log/.';
|
||
$migrationFailedVersion = $migrationResult['failed']['version'];
|
||
} else {
|
||
header('Location: /index.php', true, 303);
|
||
exit;
|
||
}
|
||
}
|
||
|
||
$csrfField = Csrf::field();
|
||
?>
|
||
<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Datenbank-Migration erforderlich</title>
|
||
<style>
|
||
body { font-family: system-ui, sans-serif; background:#0a0d14; color:#EDEFF5;
|
||
display:flex; align-items:center; justify-content:center; min-height:100vh; margin:0; padding:2rem; }
|
||
.box { background:rgba(255,255,255,0.045); border:1px solid rgba(255,255,255,0.09);
|
||
border-radius:18px; padding:2rem; max-width:640px; }
|
||
h1 { font-size:1.3rem; margin:0 0 1rem 0; }
|
||
p { color:#8B93A7; line-height:1.6; }
|
||
code { background:rgba(0,0,0,0.4); padding:0.15rem 0.4rem; border-radius:4px;
|
||
font-family:ui-monospace, monospace; font-size:0.85rem; color:#5b9dff; }
|
||
ul { color:#8B93A7; }
|
||
.btn { background:linear-gradient(135deg,#1652F0,#4c8dff); color:#fff; border:none;
|
||
border-radius:10px; padding:0.7rem 1.3rem; font-size:0.9rem; font-weight:700;
|
||
cursor:pointer; margin-top:1.25rem; }
|
||
.err { background:rgba(246,70,93,0.15); border:1px solid rgba(246,70,93,0.3);
|
||
color:#fca5a5; padding:1rem; border-radius:10px; margin:1rem 0; font-size:0.875rem; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="box">
|
||
<h1>🗄️ Datenbank-Migration erforderlich</h1>
|
||
|
||
<?php if (isset($migrationError)): ?>
|
||
<div class="err">
|
||
<strong>Migration <?= e((string)$migrationFailedVersion) ?> fehlgeschlagen.</strong><br>
|
||
<?= e((string)$migrationError) ?>
|
||
</div>
|
||
<?php endif; ?>
|
||
|
||
<p>
|
||
Es stehen <?= count($pendingMigrations) ?> Migration(en) aus. Solange sie nicht
|
||
angewendet sind, fehlen Tabellen und Spalten, die das Dashboard benötigt.
|
||
</p>
|
||
|
||
<ul>
|
||
<?php foreach ($pendingMigrations as $version): ?>
|
||
<li><code><?= e((string)$version) ?></code></li>
|
||
<?php endforeach; ?>
|
||
</ul>
|
||
|
||
<p>
|
||
Die Migrationen sind additiv: bestehende Daten bleiben erhalten, bereits
|
||
vorhandene Tabellen und Spalten werden übersprungen.
|
||
</p>
|
||
|
||
<form method="POST" action="index.php">
|
||
<?= $csrfField ?>
|
||
<input type="hidden" name="action" value="run_migration">
|
||
<button type="submit" class="btn">Migration jetzt ausführen</button>
|
||
</form>
|
||
</div>
|
||
</body>
|
||
</html>
|
||
<?php
|
||
exit;
|
||
}
|
||
|
||
// ======================================================================
|
||
// Hilfsfunktionen fuer die Ausgabe
|
||
// ======================================================================
|
||
|
||
/**
|
||
* Kurzform fuer htmlspecialchars mit sicheren Voreinstellungen.
|
||
*
|
||
* Nimmt bewusst jeden skalaren Typ entgegen, nicht nur ?string. Zwei Quellen
|
||
* liefern hier regelmaessig Integer:
|
||
* - PDO gibt mit ATTR_STRINGIFY_FETCHES = false numerische Spalten als int
|
||
* zurueck, und genau diese Werte werden ausgegeben.
|
||
* - PHP wandelt numerische Array-Schluessel automatisch in int um, sodass
|
||
* foreach ($optionen as $wert => $label) bei Schluesseln wie '24' einen
|
||
* int liefert.
|
||
*
|
||
* Mit declare(strict_types=1) warf eine ?string-Signatur dabei einen
|
||
* TypeError - und da diese Funktion mitten im Seitenaufbau steht, brach das
|
||
* Rendering ab, bevor der Script-Block ausgegeben wurde. Im Browser
|
||
* aeusserte sich das als "switchMainTab is not defined".
|
||
*
|
||
* @param mixed $value
|
||
*/
|
||
function e($value): string
|
||
{
|
||
if ($value === null) {
|
||
return '';
|
||
}
|
||
if (is_bool($value)) {
|
||
return $value ? '1' : '';
|
||
}
|
||
if (is_array($value) || is_object($value)) {
|
||
return '';
|
||
}
|
||
|
||
return htmlspecialchars((string)$value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||
}
|
||
|
||
/**
|
||
* Formatiert einen UTC-Zeitstempel aus der Datenbank in der App-Zeitzone.
|
||
* Die Datenbanksitzung laeuft auf UTC, die Anzeige auf Europe/Berlin.
|
||
*/
|
||
function dc_dt(?string $utc, string $format = 'd.m.Y H:i'): string
|
||
{
|
||
if ($utc === null || $utc === '' || str_starts_with($utc, '0000')) {
|
||
return 'Nie';
|
||
}
|
||
|
||
try {
|
||
$dt = new DateTime($utc, new DateTimeZone('UTC'));
|
||
$dt->setTimezone(new DateTimeZone((string)Config::get('app.timezone', 'UTC')));
|
||
return $dt->format($format);
|
||
} catch (Throwable $e) {
|
||
return $utc;
|
||
}
|
||
}
|
||
|
||
/** Relative Zeitangabe, z. B. "vor 4 min". */
|
||
function dc_ago(?string $utc): string
|
||
{
|
||
if ($utc === null || $utc === '') {
|
||
return 'nie';
|
||
}
|
||
|
||
$ts = strtotime($utc . ' UTC');
|
||
if ($ts === false) {
|
||
return 'unbekannt';
|
||
}
|
||
|
||
$diff = time() - $ts;
|
||
if ($diff < 60) { return 'gerade eben'; }
|
||
if ($diff < 3600) { return 'vor ' . intdiv($diff, 60) . ' min'; }
|
||
if ($diff < 86400) { return 'vor ' . intdiv($diff, 3600) . ' h'; }
|
||
|
||
return 'vor ' . intdiv($diff, 86400) . ' Tagen';
|
||
}
|
||
|
||
/** Hinterlegt eine Meldung, die nach dem Redirect angezeigt wird. */
|
||
function dc_flash(string $message, string $type = 'success'): void
|
||
{
|
||
Auth::startSession();
|
||
$_SESSION['dc_flash'] = ['message' => $message, 'type' => $type];
|
||
}
|
||
|
||
/**
|
||
* Wie viele handlungsfaehige Administratorkonten gibt es noch?
|
||
*
|
||
* Grundlage fuer die Sperren beim Deaktivieren und Loeschen: Faellt das
|
||
* letzte Konto weg, das sich am WebUI anmelden kann, kommt niemand mehr an
|
||
* die Oberflaeche - auch nicht, um den Schritt rueckgaengig zu machen.
|
||
* Installationskonten zaehlen bewusst nicht mit, sie duerfen sich hier gar
|
||
* nicht anmelden.
|
||
*/
|
||
/**
|
||
* Erzeugt den Zugangsschutz der Release-Verzeichnisse neu.
|
||
*
|
||
* Wird nach jeder Aenderung aufgerufen, die beeinflusst, wer herunterladen
|
||
* darf: Lizenzen und Installationskonten. Bewusst immer fuer alle Produkte -
|
||
* bei einer Handvoll Projekten kostet das kaum etwas und erspart die
|
||
* Fehlerquelle, den richtigen Slug zu ermitteln.
|
||
*
|
||
* Ein Fehlschlag darf die ausloesende Aktion nicht scheitern lassen: Eine
|
||
* angelegte Lizenz ist angelegt, auch wenn die Datei gerade nicht schreibbar
|
||
* war. Der naechtliche Abgleich holt es nach, und die Oberflaeche zeigt den
|
||
* Zustand unter UpdateService an.
|
||
*/
|
||
function dc_guard_releases(PDO $pdo): void
|
||
{
|
||
try {
|
||
$result = ReleaseGuard::regenerateAll($pdo);
|
||
|
||
if ($result['errors'] !== []) {
|
||
Logger::warning('Release-Zugangsschutz teilweise fehlgeschlagen', [
|
||
'errors' => $result['errors'],
|
||
]);
|
||
}
|
||
} catch (Throwable $e) {
|
||
Logger::warning('Release-Zugangsschutz nicht erzeugt', ['error' => $e->getMessage()]);
|
||
}
|
||
}
|
||
|
||
function dc_active_admin_count(PDO $pdo): int
|
||
{
|
||
try {
|
||
$stmt = $pdo->query("SELECT COUNT(*) FROM dc_users WHERE role = 'admin' AND disabled = 0");
|
||
return (int)$stmt->fetchColumn();
|
||
} catch (Throwable $e) {
|
||
// Vor Migration 012 gibt es weder role noch disabled; dann sind alle
|
||
// Konten Administratoren.
|
||
try {
|
||
return (int)$pdo->query('SELECT COUNT(*) FROM dc_users')->fetchColumn();
|
||
} catch (Throwable $inner) {
|
||
return 0;
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Post/Redirect/Get: leitet nach einer Aktion auf dieselbe Seite um, damit
|
||
* ein Neuladen das Formular nicht erneut abschickt.
|
||
*
|
||
* Das Formular teilt beim Absenden im Feld _return mit, welche Unteransicht
|
||
* gerade geoeffnet war; dorthin wird zurueckgeleitet. Der als $fragment
|
||
* uebergebene Wert dient nur noch als Rueckfallebene, etwa wenn JavaScript
|
||
* abgeschaltet ist.
|
||
*
|
||
* Zuvor wurde ausschliesslich der Modul-Anker gesetzt. Man legte einen Monitor
|
||
* an und landete anschliessend auf dem Watchdog-Dashboard statt im Formular.
|
||
*/
|
||
function dc_redirect(string $fragment = ''): void
|
||
{
|
||
$return = $_POST['_return'] ?? null;
|
||
|
||
// Nur eigene Ansichtskennungen zulassen - der Wert landet in einem
|
||
// Location-Header.
|
||
if (is_string($return) && preg_match('/^(?:tab|sub)-[a-z0-9-]{1,60}$/', $return) === 1) {
|
||
$fragment = '#' . $return;
|
||
}
|
||
|
||
$query = $_SERVER['QUERY_STRING'] ?? '';
|
||
$target = '/index.php' . ($query !== '' ? '?' . $query : '') . $fragment;
|
||
|
||
if (!headers_sent()) {
|
||
header('Location: ' . $target, true, 303);
|
||
}
|
||
exit;
|
||
}
|
||
|
||
// ======================================================================
|
||
// Download-Handler (GET)
|
||
// ======================================================================
|
||
|
||
if (($_GET['action'] ?? '') === 'download_lic') {
|
||
$licenseId = (int)($_GET['id'] ?? 0);
|
||
|
||
$stmt = $pdo->prepare('
|
||
SELECT l.*, p.slug AS product_slug
|
||
FROM license_licenses l
|
||
JOIN dc_projects p ON l.product_id = p.id
|
||
WHERE l.id = :id
|
||
');
|
||
$stmt->execute([':id' => $licenseId]);
|
||
$license = $stmt->fetch();
|
||
|
||
if (!is_array($license)) {
|
||
http_response_code(404);
|
||
exit('Lizenz nicht gefunden.');
|
||
}
|
||
|
||
$payload = [
|
||
'type' => 'offline_license_file',
|
||
'issued_at' => time(),
|
||
'product' => $license['product_slug'],
|
||
'license_key' => $license['license_key'],
|
||
'customer' => $license['customer_name'] ?? 'Universal',
|
||
'valid_until' => $license['expires_at'] ? strtotime((string)$license['expires_at']) : strtotime('+1 year'),
|
||
];
|
||
|
||
// Die Erstfassung setzte ein hartkodiertes Secret ein und beschriftete das
|
||
// Ergebnis irrefuehrend als "ED25519_SIG_". Jetzt eine ehrlich benannte
|
||
// HMAC-SHA256-Signatur ueber den kanonisierten Inhalt, mit dem Schluessel
|
||
// aus der Konfiguration. Der Client kann sie mit demselben Schluessel pruefen.
|
||
$signingKey = (string)Config::get('security.license_key', '');
|
||
$canonical = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||
$payload['signature_alg'] = 'HMAC-SHA256';
|
||
$payload['signature'] = $signingKey !== ''
|
||
? hash_hmac('sha256', (string)$canonical, $signingKey)
|
||
: null;
|
||
|
||
$json = json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||
$filename = sprintf('%s_%s_offline.lic', $license['product_slug'], substr((string)$license['license_key'], 0, 5));
|
||
|
||
header('Content-Type: application/json; charset=utf-8');
|
||
header('Content-Disposition: attachment; filename="' . preg_replace('/[^A-Za-z0-9._-]/', '', $filename) . '"');
|
||
header('Content-Length: ' . strlen((string)$json));
|
||
echo $json;
|
||
exit;
|
||
}
|
||
|
||
if (($_GET['action'] ?? '') === 'download_agent') {
|
||
$source = trim((string)($_GET['source'] ?? 'server-node'));
|
||
$os = trim((string)($_GET['os'] ?? 'windows'));
|
||
$token = trim((string)($_GET['token'] ?? ''));
|
||
|
||
// Erzeugt wird in AgentScript - dieselbe Fassung, die der Installer ueber
|
||
// den Setup-Weg bekommt. Das Saeubern der Eingaben passiert dort mit.
|
||
$agent = AgentScript::build(
|
||
$os,
|
||
$source,
|
||
$token,
|
||
Http::baseUrl() . '/api/watchdog/v1/ping'
|
||
);
|
||
|
||
header('Content-Type: text/plain; charset=utf-8');
|
||
header('Content-Disposition: attachment; filename="' . $agent['filename'] . '"');
|
||
echo $agent['script'];
|
||
exit;
|
||
}
|
||
|
||
// ======================================================================
|
||
// POST-Verarbeitung
|
||
// ======================================================================
|
||
|
||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||
|
||
// Ein einziger Aufruf schuetzt alle Aktionen dieser Seite.
|
||
Csrf::requireValid();
|
||
|
||
$action = is_string($_POST['action'] ?? null) ? $_POST['action'] : '';
|
||
$actor = Auth::username();
|
||
|
||
try {
|
||
switch ($action) {
|
||
|
||
// ---------------------------------------------------- Projekte
|
||
case 'save_project': {
|
||
$id = (int)($_POST['project_id'] ?? 0);
|
||
$slug = trim((string)($_POST['slug'] ?? ''));
|
||
$name = trim((string)($_POST['name'] ?? ''));
|
||
$ttl = max(1, (int)($_POST['ttl'] ?? 168));
|
||
$notes = trim((string)($_POST['notes'] ?? ''));
|
||
$repo = trim((string)($_POST['repo_url'] ?? ''));
|
||
$agent = trim((string)($_POST['default_agent'] ?? ''));
|
||
|
||
if ($name === '') {
|
||
dc_flash('Der Projektname darf nicht leer sein.', 'danger');
|
||
dc_redirect('#tab-projects');
|
||
}
|
||
|
||
if ($id > 0) {
|
||
$stmt = $pdo->prepare('
|
||
UPDATE dc_projects
|
||
SET name = :name, default_cache_ttl_hours = :ttl, notes = :notes,
|
||
repo_url = :repo, default_agent = :agent
|
||
WHERE id = :id
|
||
');
|
||
$stmt->execute([
|
||
':name' => $name,
|
||
':ttl' => $ttl,
|
||
':notes' => $notes !== '' ? $notes : null,
|
||
':repo' => $repo !== '' ? $repo : null,
|
||
':agent' => $agent !== '' ? $agent : null,
|
||
':id' => $id,
|
||
]);
|
||
dc_flash(sprintf('Projekt "%s" wurde aktualisiert.', $name));
|
||
} else {
|
||
if ($slug === '' || preg_match('/^[a-z0-9][a-z0-9_-]{1,63}$/', $slug) !== 1) {
|
||
dc_flash('Der Slug muss aus Kleinbuchstaben, Ziffern, Bindestrich oder Unterstrich bestehen.', 'danger');
|
||
dc_redirect('#tab-projects');
|
||
}
|
||
|
||
$stmt = $pdo->prepare('
|
||
INSERT INTO dc_projects (slug, name, default_cache_ttl_hours, notes, repo_url, default_agent)
|
||
VALUES (:slug, :name, :ttl, :notes, :repo, :agent)
|
||
');
|
||
$stmt->execute([
|
||
':slug' => $slug,
|
||
':name' => $name,
|
||
':ttl' => $ttl,
|
||
':notes' => $notes !== '' ? $notes : null,
|
||
':repo' => $repo !== '' ? $repo : null,
|
||
':agent' => $agent !== '' ? $agent : null,
|
||
]);
|
||
dc_flash(sprintf('Projekt "%s" wurde angelegt.', $name));
|
||
}
|
||
|
||
dc_redirect('#tab-projects');
|
||
}
|
||
|
||
case 'delete_project': {
|
||
$id = (int)($_POST['project_id'] ?? 0);
|
||
$confirmSlug = trim((string)($_POST['confirm_slug'] ?? ''));
|
||
|
||
$stmt = $pdo->prepare('SELECT slug, name FROM dc_projects WHERE id = :id');
|
||
$stmt->execute([':id' => $id]);
|
||
$project = $stmt->fetch();
|
||
|
||
if (!is_array($project) || !hash_equals((string)$project['slug'], $confirmSlug)) {
|
||
dc_flash('Sicherheitsbestaetigung fehlgeschlagen - der eingegebene Slug stimmt nicht ueberein.', 'danger');
|
||
dc_redirect('#tab-projects');
|
||
}
|
||
|
||
Db::transaction(static function (PDO $db) use ($id, $project): void {
|
||
$db->prepare('
|
||
DELETE a FROM license_activations a
|
||
JOIN license_licenses l ON a.license_id = l.id
|
||
WHERE l.product_id = :id
|
||
')->execute([':id' => $id]);
|
||
|
||
$db->prepare('DELETE FROM license_licenses WHERE product_id = :id')->execute([':id' => $id]);
|
||
$db->prepare('DELETE FROM updateservice_releases WHERE product_slug = :slug')
|
||
->execute([':slug' => $project['slug']]);
|
||
$db->prepare('DELETE FROM bugtracker_items WHERE project_slug = :slug')
|
||
->execute([':slug' => $project['slug']]);
|
||
$db->prepare('DELETE FROM dc_projects WHERE id = :id')->execute([':id' => $id]);
|
||
});
|
||
|
||
// Mit dem Projekt sind auch seine Lizenzen weg.
|
||
dc_guard_releases($pdo);
|
||
|
||
dc_flash(sprintf('Projekt "%s" und alle zugehoerigen Daten wurden geloescht.', $project['name']));
|
||
dc_redirect('#tab-projects');
|
||
}
|
||
|
||
// ---------------------------------------------------- Lizenzen
|
||
case 'create_license': {
|
||
$productId = (int)($_POST['product_id'] ?? 0);
|
||
if ($productId <= 0) {
|
||
dc_flash('Bitte ein Projekt auswaehlen.', 'danger');
|
||
dc_redirect('#tab-license');
|
||
}
|
||
|
||
$licenseKey = KeyGen::generateKey();
|
||
$expiresAt = !empty($_POST['expires_at']) ? $_POST['expires_at'] . ' 23:59:59' : null;
|
||
|
||
$pdo->prepare('
|
||
INSERT INTO license_licenses
|
||
(product_id, license_key, customer_name, customer_email, max_activations, expires_at, notes)
|
||
VALUES (:pid, :key, :cname, :cemail, :max, :expires, :notes)
|
||
')->execute([
|
||
':pid' => $productId,
|
||
':key' => $licenseKey,
|
||
':cname' => trim((string)($_POST['customer_name'] ?? '')),
|
||
':cemail' => trim((string)($_POST['customer_email'] ?? '')),
|
||
':max' => max(1, (int)($_POST['max_activations'] ?? 2)),
|
||
':expires' => $expiresAt,
|
||
':notes' => trim((string)($_POST['notes'] ?? '')),
|
||
]);
|
||
|
||
Audit::log($pdo, $actor, 'license.create', ['key' => $licenseKey]);
|
||
|
||
// Der neue Schluessel muss sofort Updates ziehen koennen.
|
||
dc_guard_releases($pdo);
|
||
|
||
dc_flash('Lizenzschluessel erzeugt: <strong style="font-family:monospace;">' . e($licenseKey) . '</strong>');
|
||
dc_redirect('#tab-license');
|
||
}
|
||
|
||
case 'edit_license': {
|
||
$id = (int)($_POST['license_id'] ?? 0);
|
||
if ($id <= 0) {
|
||
dc_redirect('#tab-license');
|
||
}
|
||
|
||
$status = in_array($_POST['status'] ?? '', ['active', 'revoked', 'suspended'], true)
|
||
? $_POST['status']
|
||
: 'active';
|
||
|
||
$stmt = $pdo->prepare('
|
||
UPDATE license_licenses
|
||
SET customer_name = :cname, customer_email = :cemail, status = :status,
|
||
max_activations = :max, expires_at = :expires, notes = :notes
|
||
WHERE id = :id
|
||
');
|
||
$stmt->execute([
|
||
':cname' => trim((string)($_POST['customer_name'] ?? '')),
|
||
':cemail' => trim((string)($_POST['customer_email'] ?? '')),
|
||
':status' => $status,
|
||
':max' => max(1, (int)($_POST['max_activations'] ?? 2)),
|
||
':expires' => !empty($_POST['expires_at']) ? $_POST['expires_at'] . ' 23:59:59' : null,
|
||
':notes' => trim((string)($_POST['notes'] ?? '')),
|
||
':id' => $id,
|
||
]);
|
||
|
||
Audit::log($pdo, $actor, 'license.update', ['license_id' => $id, 'status' => $status]);
|
||
|
||
// Status oder Ablaufdatum koennen sich geaendert haben.
|
||
dc_guard_releases($pdo);
|
||
|
||
dc_flash($stmt->rowCount() > 0 ? 'Lizenzdaten aktualisiert.' : 'Keine Aenderung vorgenommen.');
|
||
dc_redirect('#tab-license');
|
||
}
|
||
|
||
case 'toggle_license_status': {
|
||
$id = (int)($_POST['license_id'] ?? 0);
|
||
$newStatus = in_array($_POST['new_status'] ?? '', ['active', 'revoked', 'suspended'], true)
|
||
? $_POST['new_status']
|
||
: 'revoked';
|
||
|
||
if ($id > 0) {
|
||
$pdo->prepare('UPDATE license_licenses SET status = :status WHERE id = :id')
|
||
->execute([':status' => $newStatus, ':id' => $id]);
|
||
Audit::log($pdo, $actor, 'license.status', ['license_id' => $id, 'status' => $newStatus]);
|
||
|
||
// Eine widerrufene Lizenz muss den Zugang sofort verlieren -
|
||
// sonst zieht sie weiter Updates, bis der naechtliche
|
||
// Abgleich laeuft.
|
||
dc_guard_releases($pdo);
|
||
|
||
dc_flash('Lizenz-Status geaendert zu: ' . strtoupper($newStatus));
|
||
}
|
||
dc_redirect('#tab-license');
|
||
}
|
||
|
||
case 'toggle_block_activation': {
|
||
$id = (int)($_POST['activation_id'] ?? 0);
|
||
$block = (int)($_POST['block_state'] ?? 0) === 1 ? 1 : 0;
|
||
|
||
if ($id > 0) {
|
||
$pdo->prepare('UPDATE license_activations SET is_blocked = :blocked WHERE id = :id')
|
||
->execute([':blocked' => $block, ':id' => $id]);
|
||
dc_flash($block === 1 ? 'Hardware-Aktivierung gesperrt.' : 'Hardware-Aktivierung entsperrt.');
|
||
}
|
||
dc_redirect('#tab-license');
|
||
}
|
||
|
||
case 'release_activation': {
|
||
$id = (int)($_POST['activation_id'] ?? 0);
|
||
|
||
$stmt = $pdo->prepare('
|
||
SELECT a.*, l.license_key
|
||
FROM license_activations a
|
||
JOIN license_licenses l ON a.license_id = l.id
|
||
WHERE a.id = :id
|
||
');
|
||
$stmt->execute([':id' => $id]);
|
||
$activation = $stmt->fetch();
|
||
|
||
if (is_array($activation)) {
|
||
$pdo->prepare('DELETE FROM license_activations WHERE id = :id')->execute([':id' => $id]);
|
||
|
||
// Frueher scheiterte genau diese Zeile: Audit war weder
|
||
// eingebunden noch importiert - garantierter Fatal Error.
|
||
Audit::log($pdo, $actor, 'activation_released', [
|
||
'license_id' => $activation['license_id'],
|
||
'license_key' => $activation['license_key'],
|
||
'hardware_id' => $activation['hardware_id'],
|
||
'hostname' => $activation['hostname'],
|
||
]);
|
||
|
||
dc_flash('Hardware-Aktivierung wurde freigegeben.');
|
||
} else {
|
||
dc_flash('Die Aktivierung existiert nicht mehr.', 'danger');
|
||
}
|
||
dc_redirect('#tab-license');
|
||
}
|
||
|
||
// ---------------------------------------------------- Watchdog
|
||
case 'add_watchdog_monitor': {
|
||
$source = trim((string)($_POST['source'] ?? ''));
|
||
if ($source === '') {
|
||
dc_flash('Der Monitor-Name darf nicht leer sein.', 'danger');
|
||
dc_redirect('#tab-watchdog');
|
||
}
|
||
|
||
$monitorRepo = new MonitorRepo($pdo);
|
||
$monitorRepo->createMonitor($source, [
|
||
'type' => $_POST['type'] ?? 'heartbeat',
|
||
'expected_interval_sec' => (int)($_POST['interval'] ?? 60),
|
||
'group_key' => trim((string)($_POST['group'] ?? 'Default')),
|
||
'parent_source' => trim((string)($_POST['parent_source'] ?? '')),
|
||
'os' => trim((string)($_POST['os'] ?? '')),
|
||
]);
|
||
|
||
$tokenManager = new TokenManager($pdo);
|
||
$token = $tokenManager->createToken($source, 'Token fuer ' . $source);
|
||
|
||
dc_flash(sprintf(
|
||
'Monitor "%s" angelegt. Agent-Token (bitte jetzt kopieren): <strong style="font-family:monospace;">%s</strong>',
|
||
e($source),
|
||
e($token['raw_token'])
|
||
));
|
||
dc_redirect('#tab-watchdog');
|
||
}
|
||
|
||
case 'edit_watchdog_monitor': {
|
||
$oldSource = trim((string)($_POST['old_source'] ?? ''));
|
||
if ($oldSource === '') {
|
||
dc_redirect('#tab-watchdog');
|
||
}
|
||
|
||
// Nur tatsaechlich uebermittelte Felder werden geschrieben.
|
||
// Die Erstfassung setzte alle Spalten fest - fehlte ein Feld im
|
||
// Formular (etwa "os"), wurde die Spalte bei jedem Speichern
|
||
// auf NULL zurueckgesetzt.
|
||
$fields = [];
|
||
$mapping = [
|
||
'new_source' => 'source',
|
||
'type' => 'type',
|
||
'group_key' => 'group_key',
|
||
'parent_source' => 'parent_source',
|
||
'os' => 'os',
|
||
'notes' => 'notes',
|
||
'url' => 'url',
|
||
'icon' => 'icon',
|
||
'expected_interval_sec' => 'expected_interval_sec',
|
||
];
|
||
|
||
foreach ($mapping as $postKey => $field) {
|
||
if (array_key_exists($postKey, $_POST)) {
|
||
$fields[$field] = $_POST[$postKey];
|
||
}
|
||
}
|
||
$fields['is_muted'] = isset($_POST['is_muted']);
|
||
|
||
// Optionaler Icon-Upload
|
||
if (!empty($_FILES['custom_icon_file']['name']) && is_uploaded_file($_FILES['custom_icon_file']['tmp_name'])) {
|
||
$uploaded = dc_storeIconUpload($_FILES['custom_icon_file']);
|
||
if ($uploaded !== null) {
|
||
$fields['icon'] = $uploaded;
|
||
}
|
||
}
|
||
|
||
$monitorRepo = new MonitorRepo($pdo);
|
||
$monitorRepo->updateMonitor($oldSource, 'default', $fields);
|
||
|
||
dc_flash(sprintf('Monitor "%s" wurde aktualisiert.', e((string)($fields['source'] ?? $oldSource))));
|
||
dc_redirect('#tab-watchdog');
|
||
}
|
||
|
||
case 'delete_watchdog_monitor': {
|
||
$source = trim((string)($_POST['source'] ?? ''));
|
||
if ($source !== '') {
|
||
(new MonitorRepo($pdo))->deleteMonitor($source, 'default');
|
||
dc_flash(sprintf('Monitor "%s" wurde geloescht.', e($source)));
|
||
}
|
||
dc_redirect('#tab-watchdog');
|
||
}
|
||
|
||
case 'link_entity': {
|
||
$source = trim((string)($_POST['source'] ?? ''));
|
||
$parent = trim((string)($_POST['parent_source'] ?? ''));
|
||
|
||
if ($source !== '') {
|
||
(new MonitorRepo($pdo))->setParentSource($source, $parent !== '' ? $parent : null);
|
||
dc_flash(sprintf('Hierarchie gespeichert: %s -> %s', e($source), e($parent !== '' ? $parent : 'Top Level')));
|
||
}
|
||
dc_redirect('#tab-watchdog');
|
||
}
|
||
|
||
case 'run_evaluator': {
|
||
$result = Evaluator::run($pdo);
|
||
dc_flash(sprintf(
|
||
'Evaluator ausgefuehrt: %d Monitor(e) geprueft, %d Zustandswechsel, %d abgelaufene Leases freigegeben.',
|
||
$result['checked'],
|
||
$result['changed'],
|
||
$result['released_leases']
|
||
));
|
||
dc_redirect('#tab-watchdog');
|
||
}
|
||
|
||
case 'create_agent_token': {
|
||
$source = trim((string)($_POST['token_source'] ?? ''));
|
||
$name = trim((string)($_POST['token_name'] ?? ''));
|
||
|
||
$tokenManager = new TokenManager($pdo);
|
||
$token = $tokenManager->createToken($source, $name !== '' ? $name : null);
|
||
|
||
dc_flash('Agent-Token erzeugt: <strong style="font-family:monospace;">' . e($token['raw_token']) . '</strong>');
|
||
dc_redirect('#tab-watchdog');
|
||
}
|
||
|
||
case 'revoke_agent_token': {
|
||
$tokenId = trim((string)($_POST['token_id'] ?? ''));
|
||
if ($tokenId !== '') {
|
||
$revoked = (new TokenManager($pdo))->revokeToken($tokenId);
|
||
dc_flash($revoked ? 'Agent-Token wurde widerrufen.' : 'Token nicht gefunden.', $revoked ? 'success' : 'danger');
|
||
}
|
||
dc_redirect('#tab-watchdog');
|
||
}
|
||
|
||
// ----------------------------------------------- UpdateService
|
||
case 'add_release': {
|
||
$productSlug = trim((string)($_POST['product_slug'] ?? ''));
|
||
$version = trim((string)($_POST['version'] ?? ''));
|
||
$url = trim((string)($_POST['download_url'] ?? ''));
|
||
|
||
if ($productSlug === '' || $version === '' || $url === '') {
|
||
dc_flash('Projekt, Version und Download-URL sind Pflichtfelder.', 'danger');
|
||
dc_redirect('#tab-updateservice');
|
||
}
|
||
|
||
$hash = trim((string)($_POST['sha256_hash'] ?? ''));
|
||
if ($hash !== '' && preg_match('/^[0-9a-f]{64}$/i', $hash) !== 1) {
|
||
dc_flash('Der SHA256-Hash muss aus genau 64 Hexadezimalzeichen bestehen.', 'danger');
|
||
dc_redirect('#tab-updateservice');
|
||
}
|
||
|
||
$updateManager = new UpdateManager($pdo);
|
||
|
||
$channel = trim((string)($_POST['channel'] ?? 'prod'));
|
||
$platform = UpdateManager::normalizePlatform((string)($_POST['platform'] ?? ''));
|
||
$size = (int)($_POST['size_bytes'] ?? 0);
|
||
|
||
// Auch der Weg ueber die Oberflaeche wird signiert, sonst
|
||
// haetten von Hand eingetragene Releases keine Vertrauenskette.
|
||
$signature = ReleaseSigner::sign(ReleaseSigner::canonical(
|
||
$productSlug,
|
||
$version,
|
||
$channel,
|
||
$platform,
|
||
$hash !== '' ? $hash : null,
|
||
$url,
|
||
$size
|
||
));
|
||
|
||
$result = $updateManager->addRelease(
|
||
$productSlug,
|
||
$version,
|
||
$channel,
|
||
trim((string)($_POST['release_notes'] ?? '')),
|
||
$url,
|
||
$hash !== '' ? $hash : null,
|
||
trim((string)($_POST['git_commit'] ?? '')),
|
||
$size,
|
||
null,
|
||
isset($_POST['is_critical']),
|
||
$actor,
|
||
$platform,
|
||
$signature
|
||
);
|
||
|
||
dc_flash(sprintf(
|
||
'Release %s (%s, %s) fuer "%s" %s.%s%s',
|
||
e($version),
|
||
e($channel),
|
||
e($platform),
|
||
e($productSlug),
|
||
$result['created'] ? 'veroeffentlicht' : 'aktualisiert',
|
||
$signature === null
|
||
? ' Hinweis: unsigniert, kein Signierschluessel hinterlegt.'
|
||
: '',
|
||
$result['auto_resolved'] > 0
|
||
? sprintf(' %d Bugtracker-Item(s) automatisch geschlossen.', $result['auto_resolved'])
|
||
: ''
|
||
));
|
||
dc_redirect('#tab-updateservice');
|
||
}
|
||
|
||
case 'edit_release': {
|
||
$releaseId = (int)($_POST['release_id'] ?? 0);
|
||
if ($releaseId <= 0) {
|
||
dc_flash('Ungültige Release-ID.', 'danger');
|
||
dc_redirect('#tab-updateservice');
|
||
}
|
||
|
||
$updateManager = new UpdateManager($pdo);
|
||
$existing = $updateManager->getReleaseById($releaseId);
|
||
if ($existing === null) {
|
||
dc_flash('Das zu bearbeitende Release wurde nicht gefunden.', 'danger');
|
||
dc_redirect('#tab-updateservice');
|
||
}
|
||
|
||
$productSlug = trim((string)($_POST['product_slug'] ?? ''));
|
||
$version = trim((string)($_POST['version'] ?? ''));
|
||
$url = trim((string)($_POST['download_url'] ?? ''));
|
||
|
||
if ($productSlug === '' || $version === '' || $url === '') {
|
||
dc_flash('Projekt, Version und Download-URL sind Pflichtfelder.', 'danger');
|
||
dc_redirect('#tab-updateservice');
|
||
}
|
||
|
||
$hash = trim((string)($_POST['sha256_hash'] ?? ''));
|
||
if ($hash !== '' && preg_match('/^[0-9a-f]{64}$/i', $hash) !== 1) {
|
||
dc_flash('Der SHA256-Hash muss aus genau 64 Hexadezimalzeichen bestehen.', 'danger');
|
||
dc_redirect('#tab-updateservice');
|
||
}
|
||
|
||
$channel = trim((string)($_POST['channel'] ?? 'prod'));
|
||
$platform = UpdateManager::normalizePlatform((string)($_POST['platform'] ?? ''));
|
||
$size = (int)($_POST['size_bytes'] ?? 0);
|
||
|
||
$signature = ReleaseSigner::sign(ReleaseSigner::canonical(
|
||
$productSlug,
|
||
$version,
|
||
$channel,
|
||
$platform,
|
||
$hash !== '' ? $hash : null,
|
||
$url,
|
||
$size
|
||
));
|
||
|
||
try {
|
||
$ok = $updateManager->updateRelease(
|
||
$releaseId,
|
||
$productSlug,
|
||
$version,
|
||
$channel,
|
||
trim((string)($_POST['release_notes'] ?? '')),
|
||
$url,
|
||
$hash !== '' ? $hash : null,
|
||
trim((string)($_POST['git_commit'] ?? '')),
|
||
$size,
|
||
$existing['manifest_json'] ?? null,
|
||
isset($_POST['is_critical']),
|
||
$actor,
|
||
$platform,
|
||
$signature
|
||
);
|
||
|
||
if ($ok) {
|
||
dc_flash(sprintf(
|
||
'Release %s (%s, %s) für "%s" erfolgreich aktualisiert.%s',
|
||
e($version),
|
||
e($channel),
|
||
e($platform),
|
||
e($productSlug),
|
||
$signature === null
|
||
? ' Hinweis: unsigniert, kein Signierschlüssel hinterlegt.'
|
||
: ''
|
||
));
|
||
} else {
|
||
dc_flash('Das Release konnte nicht aktualisiert werden.', 'danger');
|
||
}
|
||
} catch (\PDOException $e) {
|
||
if (str_contains($e->getMessage(), 'uq_prod_ver_chan_plat') || $e->getCode() === '23000') {
|
||
dc_flash('Ein Release mit dieser Kombination aus Projekt, Version, Kanal und Plattform existiert bereits.', 'danger');
|
||
} else {
|
||
dc_flash('Fehler beim Aktualisieren des Releases: ' . $e->getMessage(), 'danger');
|
||
}
|
||
}
|
||
|
||
dc_redirect('#tab-updateservice');
|
||
}
|
||
|
||
case 'delete_release': {
|
||
$releaseId = (int)($_POST['release_id'] ?? 0);
|
||
if ($releaseId <= 0) {
|
||
dc_flash('Ungültige Release-ID.', 'danger');
|
||
dc_redirect('#tab-updateservice');
|
||
}
|
||
|
||
$updateManager = new UpdateManager($pdo);
|
||
$existing = $updateManager->getReleaseById($releaseId);
|
||
if ($existing === null) {
|
||
dc_flash('Das zu löschende Release wurde nicht gefunden.', 'danger');
|
||
dc_redirect('#tab-updateservice');
|
||
}
|
||
|
||
$deleted = $updateManager->deleteRelease($releaseId);
|
||
if ($deleted) {
|
||
dc_flash(sprintf(
|
||
'Release v%s (%s, %s) für "%s" erfolgreich gelöscht.',
|
||
e($existing['version']),
|
||
e($existing['channel']),
|
||
e($existing['platform'] ?? 'any'),
|
||
e($existing['product_slug'])
|
||
));
|
||
} else {
|
||
dc_flash('Das Release konnte nicht gelöscht werden.', 'danger');
|
||
}
|
||
|
||
dc_redirect('#tab-updateservice');
|
||
}
|
||
|
||
// ------------------------------------------------------ Tokens
|
||
case 'create_master_token': {
|
||
$name = trim((string)($_POST['name'] ?? ''));
|
||
if ($name === '') {
|
||
dc_flash('Die Token-Bezeichnung darf nicht leer sein.', 'danger');
|
||
dc_redirect('#tab-tokens');
|
||
}
|
||
|
||
$scopes = isset($_POST['scopes']) && is_array($_POST['scopes']) ? $_POST['scopes'] : ['*'];
|
||
$expiresAt = !empty($_POST['expires_at']) ? $_POST['expires_at'] . ' 23:59:59' : null;
|
||
|
||
$tokenManager = new CoreTokenManager($pdo);
|
||
$token = $tokenManager->createMasterToken(
|
||
$name,
|
||
trim((string)($_POST['project_slug'] ?? '')),
|
||
trim((string)($_POST['license_key'] ?? '')),
|
||
(string)($_POST['owner_type'] ?? 'custom'),
|
||
trim((string)($_POST['owner_identity'] ?? '')),
|
||
$scopes,
|
||
(string)($_POST['environment'] ?? 'all'),
|
||
$expiresAt
|
||
);
|
||
|
||
dc_flash(
|
||
'Master-Token erstellt. Wert jetzt kopieren: '
|
||
. '<strong style="font-family:monospace; color:var(--success); font-size:1.05em;">'
|
||
. e($token['raw_token']) . '</strong>'
|
||
);
|
||
dc_redirect('#tab-tokens');
|
||
}
|
||
|
||
case 'edit_master_token': {
|
||
$tokenId = trim((string)($_POST['token_id'] ?? ''));
|
||
$scopes = $_POST['scopes'] ?? [];
|
||
|
||
try {
|
||
$ok = (new CoreTokenManager($pdo))->updateMasterToken($tokenId, [
|
||
'name' => $_POST['name'] ?? '',
|
||
'project_slug' => $_POST['project_slug'] ?? '',
|
||
'license_key' => $_POST['license_key'] ?? '',
|
||
'owner_type' => $_POST['owner_type'] ?? '',
|
||
'owner_identity' => $_POST['owner_identity'] ?? '',
|
||
'environment' => $_POST['environment'] ?? '',
|
||
// Das Datumsfeld liefert nur den Tag; das Token soll
|
||
// an diesem Tag noch gelten und erst danach ablaufen.
|
||
'expires_at' => !empty($_POST['expires_at'])
|
||
? $_POST['expires_at'] . ' 23:59:59' : '',
|
||
'scopes' => is_array($scopes) ? $scopes : [],
|
||
]);
|
||
} catch (\InvalidArgumentException $e) {
|
||
dc_flash($e->getMessage(), 'danger');
|
||
dc_redirect('#tab-tokens');
|
||
}
|
||
|
||
dc_flash(
|
||
$ok
|
||
? 'Token-Angaben gespeichert. Der Tokenwert selbst ist unveraendert — '
|
||
. 'auf den Maschinen muss nichts ausgetauscht werden.'
|
||
: 'Master-Token nicht gefunden.',
|
||
$ok ? 'success' : 'danger'
|
||
);
|
||
dc_redirect('#tab-tokens');
|
||
}
|
||
|
||
case 'revoke_core_token': {
|
||
$tokenId = trim((string)($_POST['token_id'] ?? ''));
|
||
if ($tokenId !== '') {
|
||
$affected = (new CoreTokenManager($pdo))->revokeToken($tokenId);
|
||
dc_flash(sprintf('%d Token (inkl. Sub-Tokens) widerrufen.', $affected));
|
||
}
|
||
dc_redirect('#tab-tokens');
|
||
}
|
||
|
||
case 'delete_core_token': {
|
||
$tokenId = trim((string)($_POST['token_id'] ?? ''));
|
||
if ($tokenId !== '') {
|
||
$affected = (new CoreTokenManager($pdo))->deleteToken($tokenId);
|
||
dc_flash(sprintf('%d Token (inkl. Sub-Tokens) dauerhaft geloescht.', $affected));
|
||
}
|
||
dc_redirect('#tab-tokens');
|
||
}
|
||
|
||
// -------------------------------------------------- Bugtracker
|
||
case 'bt_create_item': {
|
||
$title = trim((string)($_POST['title'] ?? ''));
|
||
if ($title === '') {
|
||
dc_flash('Der Titel darf nicht leer sein.', 'danger');
|
||
dc_redirect('#tab-bugtracker');
|
||
}
|
||
|
||
// Das Formular hat eine Pflichtauswahl ueber die echten Projekte;
|
||
// leer kommt hier nur an, wenn jemand am Formular vorbei postet.
|
||
// Frueher wurde daraus stillschweigend 'default' - ein Projekt,
|
||
// das es nicht gibt, mit project_id NULL und einem Item, das in
|
||
// keiner Ansicht mehr auftauchte.
|
||
$btZiel = trim((string)($_POST['project_slug'] ?? ''));
|
||
|
||
if ($btZiel === '') {
|
||
dc_flash('Bitte ein Projekt auswaehlen.', 'danger');
|
||
dc_redirect('#tab-bugtracker');
|
||
}
|
||
|
||
$bugRepo = new BugRepo($pdo);
|
||
|
||
if (!$bugRepo->projectExists($btZiel)) {
|
||
dc_flash(sprintf('Das Projekt "%s" gibt es nicht.', $btZiel), 'danger');
|
||
dc_redirect('#tab-bugtracker');
|
||
}
|
||
|
||
$result = $bugRepo->reportItem([
|
||
'project_slug' => $btZiel,
|
||
'type' => (string)($_POST['type'] ?? 'bug'),
|
||
'title' => $title,
|
||
'description' => trim((string)($_POST['description'] ?? '')),
|
||
'error_message' => trim((string)($_POST['error_message'] ?? '')),
|
||
'stack_trace' => trim((string)($_POST['stack_trace'] ?? '')),
|
||
'build_version' => trim((string)($_POST['build_version'] ?? '')),
|
||
'environment' => (string)($_POST['environment'] ?? 'production'),
|
||
'severity' => (string)($_POST['severity'] ?? 'medium'),
|
||
'push_id' => trim((string)($_POST['push_id'] ?? '')),
|
||
'target_agent' => trim((string)($_POST['target_agent'] ?? '')),
|
||
'tags' => trim((string)($_POST['tags'] ?? '')),
|
||
'repo_url' => trim((string)($_POST['repo_url'] ?? '')),
|
||
'file_path' => trim((string)($_POST['file_path'] ?? '')),
|
||
'created_by' => 'user:' . $actor,
|
||
]);
|
||
|
||
dc_flash($result['is_new']
|
||
? sprintf('Item #%d angelegt.', $result['id'])
|
||
: sprintf('Bereits bekannt - Item #%d, Zaehler jetzt bei %d.', $result['id'], $result['occurrence_count']));
|
||
dc_redirect('#tab-bugtracker');
|
||
}
|
||
|
||
case 'bt_add_comment': {
|
||
$itemId = (int)($_POST['item_id'] ?? 0);
|
||
$comment = trim((string)($_POST['comment'] ?? ''));
|
||
|
||
if ($itemId > 0 && $comment !== '') {
|
||
(new BugRepo($pdo))->addComment($itemId, 'user:' . $actor, $comment, 'commented');
|
||
dc_flash(sprintf('Kommentar zu Item #%d gespeichert.', $itemId));
|
||
} else {
|
||
dc_flash('Kommentar konnte nicht gespeichert werden.', 'danger');
|
||
}
|
||
dc_redirect('#tab-bugtracker');
|
||
}
|
||
|
||
case 'bt_change_status': {
|
||
$itemId = (int)($_POST['item_id'] ?? 0);
|
||
$status = (string)($_POST['status'] ?? 'open');
|
||
|
||
$ok = $itemId > 0 && (new BugRepo($pdo))->updateStatus(
|
||
$itemId,
|
||
$status,
|
||
trim((string)($_POST['notes'] ?? '')),
|
||
'user:' . $actor
|
||
);
|
||
|
||
dc_flash(
|
||
$ok ? sprintf('Status von Item #%d auf "%s" gesetzt.', $itemId, $status)
|
||
: 'Statuswechsel fehlgeschlagen - Item oder Status unbekannt.',
|
||
$ok ? 'success' : 'danger'
|
||
);
|
||
dc_redirect('#tab-bugtracker');
|
||
}
|
||
|
||
case 'bt_update_item': {
|
||
$itemId = (int)($_POST['item_id'] ?? 0);
|
||
|
||
$updates = [];
|
||
foreach (['status', 'severity', 'push_id', 'target_agent', 'tags', 'resolution_notes', 'resolved_in_build', 'repo_url', 'file_path'] as $field) {
|
||
if (array_key_exists($field, $_POST)) {
|
||
$updates[$field] = $_POST[$field];
|
||
}
|
||
}
|
||
|
||
try {
|
||
$ok = $itemId > 0 && (new BugRepo($pdo))->updateItemDetails($itemId, $updates, 'user:' . $actor);
|
||
} catch (\InvalidArgumentException $e) {
|
||
dc_flash($e->getMessage(), 'danger');
|
||
dc_redirect('#tab-bugtracker');
|
||
}
|
||
|
||
dc_flash(
|
||
$ok ? sprintf('Item #%d aktualisiert.', $itemId) : 'Item nicht gefunden.',
|
||
$ok ? 'success' : 'danger'
|
||
);
|
||
dc_redirect('#tab-bugtracker');
|
||
}
|
||
|
||
case 'bt_resolve': {
|
||
$itemId = (int)($_POST['item_id'] ?? 0);
|
||
$build = trim((string)($_POST['resolved_in_build'] ?? ''));
|
||
|
||
if ($build === '') {
|
||
dc_flash('Bitte den Build angeben, in dem das Item geloest wurde.', 'danger');
|
||
dc_redirect('#tab-bugtracker');
|
||
}
|
||
|
||
$ok = $itemId > 0 && (new BugRepo($pdo))->resolveItem(
|
||
$itemId,
|
||
$build,
|
||
trim((string)($_POST['resolution_notes'] ?? '')),
|
||
'user:' . $actor
|
||
);
|
||
|
||
dc_flash(
|
||
$ok ? sprintf('Item #%d in Build "%s" geloest.', $itemId, e($build)) : 'Item nicht gefunden.',
|
||
$ok ? 'success' : 'danger'
|
||
);
|
||
dc_redirect('#tab-bugtracker');
|
||
}
|
||
|
||
// ------------------------------------------------------ Roadmap
|
||
//
|
||
// Alle Roadmap-Aktionen schreiben in dieselben Item-Spalten wie
|
||
// der Bugtracker. Ein eigener Datenbestand existiert nicht - was
|
||
// hier gesetzt wird, ist Themenbereich, Reihenfolge, Zielfassung
|
||
// und der Verweis auf die Begruendung in der Datei.
|
||
|
||
case 'rm_set': {
|
||
$itemId = (int)($_POST['item_id'] ?? 0);
|
||
|
||
$updates = [];
|
||
foreach (['epic', 'target_build', 'defer_trigger', 'doc_anchor', 'status'] as $field) {
|
||
if (array_key_exists($field, $_POST)) {
|
||
$updates[$field] = $_POST[$field];
|
||
}
|
||
}
|
||
|
||
// Leeres Rangfeld heisst "keine Meinung", nicht "Rang 0" -
|
||
// sonst spraenge der Eintrag an die Spitze seines Bereichs.
|
||
if (array_key_exists('roadmap_rank', $_POST)) {
|
||
$raw = trim((string)$_POST['roadmap_rank']);
|
||
$updates['roadmap_rank'] = is_numeric($raw) ? (int)$raw : null;
|
||
}
|
||
|
||
try {
|
||
$ok = $itemId > 0
|
||
&& (new BugRepo($pdo))->updateItemDetails($itemId, $updates, 'user:' . $actor);
|
||
} catch (\InvalidArgumentException $e) {
|
||
dc_flash($e->getMessage(), 'danger');
|
||
dc_redirect('#tab-roadmap');
|
||
}
|
||
|
||
dc_flash(
|
||
$ok ? sprintf('Roadmap-Angaben zu #%d gespeichert.', $itemId) : 'Item nicht gefunden.',
|
||
$ok ? 'success' : 'danger'
|
||
);
|
||
dc_redirect('#tab-roadmap');
|
||
}
|
||
|
||
case 'rm_move': {
|
||
$itemId = (int)($_POST['item_id'] ?? 0);
|
||
$dir = ($_POST['direction'] ?? 'up') === 'down' ? 'down' : 'up';
|
||
|
||
$ok = $itemId > 0 && (new Roadmap($pdo))->move($itemId, $dir);
|
||
|
||
if (!$ok) {
|
||
dc_flash('Der Eintrag steht bereits am Rand seines Bereichs.', 'danger');
|
||
}
|
||
dc_redirect('#tab-roadmap');
|
||
}
|
||
|
||
case 'rm_save_epic': {
|
||
$slug = trim((string)($_POST['project_slug'] ?? ''));
|
||
$epic = trim((string)($_POST['epic'] ?? ''));
|
||
$title = trim((string)($_POST['title'] ?? ''));
|
||
|
||
if ($slug === '' || $epic === '' || $title === '') {
|
||
dc_flash('Projekt, Kuerzel und Titel werden alle drei gebraucht.', 'danger');
|
||
dc_redirect('#sub-roadmap-epics');
|
||
}
|
||
|
||
(new Roadmap($pdo))->saveEpic(
|
||
$slug,
|
||
$epic,
|
||
$title,
|
||
trim((string)($_POST['summary'] ?? '')) ?: null,
|
||
(int)($_POST['sort_order'] ?? 100)
|
||
);
|
||
|
||
dc_flash(sprintf('Themenbereich "%s" gespeichert.', e($epic)));
|
||
dc_redirect('#sub-roadmap-epics');
|
||
}
|
||
|
||
case 'rm_delete_epic': {
|
||
$slug = trim((string)($_POST['project_slug'] ?? ''));
|
||
$epic = trim((string)($_POST['epic'] ?? ''));
|
||
|
||
// Loeschen nur, solange nichts daran haengt. Sonst blieben
|
||
// Items mit einem Bereich zurueck, den es nicht mehr gibt -
|
||
// genau die haengende Referenz, die hier vermieden werden soll.
|
||
$ok = (new Roadmap($pdo))->deleteEpic($slug, $epic);
|
||
|
||
dc_flash(
|
||
$ok
|
||
? sprintf('Themenbereich "%s" geloescht.', e($epic))
|
||
: 'Der Bereich hat noch Eintraege - erst umhaengen, dann loeschen.',
|
||
$ok ? 'success' : 'danger'
|
||
);
|
||
dc_redirect('#sub-roadmap-epics');
|
||
}
|
||
|
||
// ------------------------------------------------ Ignore-Regeln
|
||
case 'ir_create': {
|
||
$rules = new IgnoreRules($pdo);
|
||
|
||
try {
|
||
$ruleId = $rules->create(
|
||
trim((string)($_POST['project_slug'] ?? '')),
|
||
(string)($_POST['match_type'] ?? 'contains'),
|
||
(string)($_POST['pattern'] ?? ''),
|
||
(string)($_POST['reason'] ?? ''),
|
||
(int)($_POST['alert_on_rate'] ?? 0),
|
||
'user:' . $actor
|
||
);
|
||
} catch (InvalidArgumentException $e) {
|
||
dc_flash('Regel nicht angelegt: ' . e($e->getMessage()), 'danger');
|
||
dc_redirect('#sub-bugtracker-ignore');
|
||
}
|
||
|
||
// Bereits erfasste, passende Fehler gleich mit stummschalten -
|
||
// sonst muesste man sie einzeln nachpflegen.
|
||
$affected = 0;
|
||
if (!empty($_POST['apply_retroactively'])) {
|
||
$affected = $rules->applyRetroactively($ruleId);
|
||
}
|
||
|
||
dc_flash(sprintf(
|
||
'Regel #%d angelegt.%s',
|
||
$ruleId,
|
||
$affected > 0 ? sprintf(' %d bestehende(s) Item(s) wurden stummgeschaltet.', $affected) : ''
|
||
));
|
||
dc_redirect('#sub-bugtracker-ignore');
|
||
}
|
||
|
||
case 'ir_toggle': {
|
||
$ruleId = (int)($_POST['rule_id'] ?? 0);
|
||
$enable = (int)($_POST['enable'] ?? 0) === 1;
|
||
|
||
if ($ruleId > 0 && (new IgnoreRules($pdo))->setEnabled($ruleId, $enable)) {
|
||
dc_flash(sprintf('Regel #%d %s.', $ruleId, $enable ? 'aktiviert' : 'deaktiviert'));
|
||
} else {
|
||
dc_flash('Regel nicht gefunden.', 'danger');
|
||
}
|
||
dc_redirect('#sub-bugtracker-ignore');
|
||
}
|
||
|
||
case 'ir_delete': {
|
||
$ruleId = (int)($_POST['rule_id'] ?? 0);
|
||
|
||
if ($ruleId > 0 && (new IgnoreRules($pdo))->delete($ruleId)) {
|
||
// Items behalten ihren Status, verlieren aber die Bindung.
|
||
$pdo->prepare('UPDATE bugtracker_items SET ignore_rule_id = NULL WHERE ignore_rule_id = :id')
|
||
->execute([':id' => $ruleId]);
|
||
dc_flash(sprintf('Regel #%d geloescht.', $ruleId));
|
||
} else {
|
||
dc_flash('Regel nicht gefunden.', 'danger');
|
||
}
|
||
dc_redirect('#sub-bugtracker-ignore');
|
||
}
|
||
|
||
case 'ir_apply': {
|
||
$ruleId = (int)($_POST['rule_id'] ?? 0);
|
||
$affected = $ruleId > 0 ? (new IgnoreRules($pdo))->applyRetroactively($ruleId) : 0;
|
||
|
||
dc_flash(sprintf('%d bestehende(s) Item(s) durch Regel #%d stummgeschaltet.', $affected, $ruleId));
|
||
dc_redirect('#sub-bugtracker-ignore');
|
||
}
|
||
|
||
// Einzelnes Item stummschalten oder wieder aufnehmen
|
||
case 'bt_set_ignored': {
|
||
$itemId = (int)($_POST['item_id'] ?? 0);
|
||
$ignore = (int)($_POST['ignore'] ?? 1) === 1;
|
||
|
||
if ($itemId > 0) {
|
||
$ok = (new BugRepo($pdo))->updateItemDetails(
|
||
$itemId,
|
||
['status' => $ignore ? 'ignored' : 'open'],
|
||
'user:' . $actor
|
||
);
|
||
dc_flash(
|
||
$ok
|
||
? sprintf('Item #%d %s.', $itemId, $ignore ? 'stummgeschaltet' : 'wieder aufgenommen')
|
||
: 'Item nicht gefunden.',
|
||
$ok ? 'success' : 'danger'
|
||
);
|
||
}
|
||
dc_redirect('#sub-bugtracker-errors');
|
||
}
|
||
|
||
case 'bt_release_claim': {
|
||
$itemId = (int)($_POST['item_id'] ?? 0);
|
||
if ($itemId > 0) {
|
||
$pdo->prepare('
|
||
UPDATE bugtracker_items
|
||
SET claimed_by = NULL, claimed_at = NULL, lease_until = NULL
|
||
WHERE id = :id
|
||
')->execute([':id' => $itemId]);
|
||
(new BugRepo($pdo))->addComment($itemId, 'user:' . $actor, 'Zuweisung durch Administrator aufgehoben.', 'released');
|
||
dc_flash(sprintf('Zuweisung von Item #%d aufgehoben.', $itemId));
|
||
}
|
||
dc_redirect('#tab-bugtracker');
|
||
}
|
||
|
||
case 'save_rocketchat_settings': {
|
||
$enabled = isset($_POST['rocketchat_enabled']) ? '1' : '0';
|
||
$url = trim((string)($_POST['rocketchat_url'] ?? ''));
|
||
$username = trim((string)($_POST['rocketchat_username'] ?? ''));
|
||
$password = (string)($_POST['rocketchat_password'] ?? '');
|
||
$statusChannel = trim((string)($_POST['rocketchat_status_channel'] ?? ''));
|
||
$alertChannel = trim((string)($_POST['rocketchat_alert_channel'] ?? ''));
|
||
$verifySsl = isset($_POST['rocketchat_verify_ssl']) ? '1' : '0';
|
||
$reportInterval = max(300, (int)($_POST['rocketchat_report_interval'] ?? 43200));
|
||
|
||
$settings = [
|
||
'rocketchat_enabled' => $enabled,
|
||
'rocketchat_url' => $url,
|
||
'rocketchat_username' => $username,
|
||
'rocketchat_password' => $password,
|
||
'rocketchat_status_channel' => $statusChannel,
|
||
'rocketchat_alert_channel' => $alertChannel,
|
||
'rocketchat_verify_ssl' => $verifySsl,
|
||
'rocketchat_report_interval' => (string)$reportInterval,
|
||
];
|
||
|
||
$stmt = $pdo->prepare("
|
||
INSERT INTO dc_settings (skey, svalue) VALUES (:k, :v)
|
||
ON DUPLICATE KEY UPDATE svalue = VALUES(svalue)
|
||
");
|
||
foreach ($settings as $k => $v) {
|
||
$stmt->execute([':k' => $k, ':v' => $v]);
|
||
}
|
||
|
||
$stmtCron = $pdo->prepare("
|
||
INSERT INTO watchdog_cron_jobs (name, interval_sec, enabled)
|
||
VALUES ('rocketchat_status_report', :interval, :enabled)
|
||
ON DUPLICATE KEY UPDATE interval_sec = VALUES(interval_sec), enabled = VALUES(enabled)
|
||
");
|
||
$stmtCron->execute([':interval' => $reportInterval, ':enabled' => (int)$enabled]);
|
||
|
||
dc_flash('Rocket.Chat Einstellungen erfolgreich gespeichert.', 'success');
|
||
dc_redirect('#sub-system-rocketchat');
|
||
}
|
||
|
||
case 'test_rocketchat_connection': {
|
||
$config = \Deploymentcenter\Modules\Notify\RocketChatNotifier::getConfig($pdo);
|
||
$res = \Deploymentcenter\Modules\Notify\RocketChatNotifier::testConnection($config);
|
||
dc_flash($res['message'], $res['success'] ? 'success' : 'danger');
|
||
dc_redirect('#sub-system-rocketchat');
|
||
}
|
||
|
||
case 'send_rocketchat_report_now': {
|
||
$res = \Deploymentcenter\Modules\Notify\RocketChatNotifier::sendStatusReport($pdo, true);
|
||
dc_flash($res['reason'], $res['sent'] ? 'success' : 'warning');
|
||
dc_redirect('#sub-system-rocketchat');
|
||
}
|
||
|
||
case 'toggle_release_guard_exempt': {
|
||
$slug = trim((string)($_POST['slug'] ?? ''));
|
||
$makeExempt = ($_POST['exempt'] ?? '') === '1';
|
||
|
||
if ($slug === '') {
|
||
dc_flash('Kein Produkt angegeben.', 'danger');
|
||
dc_redirect('#sub-update-guard');
|
||
}
|
||
|
||
ReleaseGuard::setExempt($pdo, $slug, $makeExempt);
|
||
|
||
if ($makeExempt) {
|
||
dc_flash(sprintf(
|
||
'Achtung: "%s" ist jetzt OHNE Zugangsschutz - jeder im Internet kann die Pakete '
|
||
. 'herunterladen. Nur so lange lassen, bis die ausgelieferten Installationen ihren '
|
||
. 'Lizenzschluessel mitschicken.',
|
||
e($slug)
|
||
), 'danger');
|
||
} else {
|
||
dc_flash(sprintf(
|
||
'Zugangsschutz fuer "%s" ist wieder aktiv. Installationen ohne Lizenzschluessel '
|
||
. 'bekommen ab jetzt 401.',
|
||
e($slug)
|
||
));
|
||
}
|
||
|
||
dc_redirect('#sub-update-guard');
|
||
}
|
||
|
||
case 'regenerate_release_guard': {
|
||
$guard = ReleaseGuard::regenerateAll($pdo);
|
||
|
||
// Direkt gegenpruefen, ob der Schutz tatsaechlich greift.
|
||
// Vorhandene Dateien beweisen nichts - unter Nginx werden sie
|
||
// ignoriert, und die Oberflaeche meldete trotzdem Erfolg.
|
||
$test = ReleaseGuard::selfTest($pdo);
|
||
|
||
if ($test['failed'] !== []) {
|
||
$reasons = array_map(
|
||
static fn(array $f): string => ($f['slug'] ?? '?') . ': ' . ($f['reason'] ?? ''),
|
||
$test['failed']
|
||
);
|
||
dc_flash(
|
||
'Zugangsschutz erzeugt, aber der Selbsttest schlaegt fehl! ' . e(implode(' | ', $reasons)),
|
||
'danger'
|
||
);
|
||
dc_redirect('#sub-update-guard');
|
||
}
|
||
|
||
if ($guard['errors'] !== []) {
|
||
dc_flash(
|
||
'Zugangsschutz teilweise erneuert. Fehler: ' . e(implode(' | ', $guard['errors'])),
|
||
'danger'
|
||
);
|
||
} else {
|
||
dc_flash(sprintf(
|
||
'Zugangsschutz erneuert und geprueft: %d Produkt(e), %d Zugang/Zugaenge, '
|
||
. '%d Adresse(n) antworten ohne Zugangsdaten korrekt mit 401.%s',
|
||
$guard['projects'],
|
||
$guard['entries'],
|
||
$test['ok'],
|
||
$guard['skipped'] !== []
|
||
? ' Ohne Zugaenge und damit gesperrt: ' . e(implode(', ', $guard['skipped'])) . '.'
|
||
: ''
|
||
));
|
||
}
|
||
|
||
dc_redirect('#sub-update-guard');
|
||
}
|
||
|
||
// ------------------------------------------------- Benutzerkonten
|
||
case 'add_user': {
|
||
$newUser = trim((string)($_POST['username'] ?? ''));
|
||
$newPass = (string)($_POST['password'] ?? '');
|
||
$newRole = ($_POST['role'] ?? 'installer') === 'admin' ? 'admin' : 'installer';
|
||
|
||
if (preg_match('/^[A-Za-z0-9._-]{3,64}$/', $newUser) !== 1) {
|
||
dc_flash('Der Benutzername muss 3 bis 64 Zeichen lang sein und darf Buchstaben, Ziffern, Punkt, Bindestrich und Unterstrich enthalten.', 'danger');
|
||
dc_redirect('#sub-system-users');
|
||
}
|
||
|
||
// Ein Installationskonto wird auf fremden Rechnern eingegeben.
|
||
// Ein kurzes Passwort waere dort schnell mitgelesen und liesse
|
||
// sich ebenso schnell durchprobieren.
|
||
if (strlen($newPass) < 12) {
|
||
dc_flash('Das Passwort muss mindestens 12 Zeichen lang sein.', 'danger');
|
||
dc_redirect('#sub-system-users');
|
||
}
|
||
|
||
$exists = $pdo->prepare('SELECT id FROM dc_users WHERE username = :u LIMIT 1');
|
||
$exists->execute([':u' => $newUser]);
|
||
if ($exists->fetch() !== false) {
|
||
dc_flash(sprintf('Der Benutzername "%s" ist bereits vergeben.', e($newUser)), 'danger');
|
||
dc_redirect('#sub-system-users');
|
||
}
|
||
|
||
$ins = $pdo->prepare('
|
||
INSERT INTO dc_users (username, password_hash, role, created_at)
|
||
VALUES (:u, :h, :r, UTC_TIMESTAMP())
|
||
');
|
||
$ins->execute([
|
||
':u' => $newUser,
|
||
':h' => password_hash($newPass, PASSWORD_DEFAULT),
|
||
':r' => $newRole,
|
||
]);
|
||
|
||
Logger::info('Benutzerkonto angelegt', [
|
||
'username' => $newUser,
|
||
'role' => $newRole,
|
||
'actor' => $actor,
|
||
]);
|
||
|
||
// Installationskonten stehen in jeder .htpasswd - ohne sie
|
||
// waere eine Erstinstallation nicht moeglich, weil es dabei
|
||
// noch keinen Lizenzschluessel gibt. Administratoren zaehlen
|
||
// dazu; die Bedingung stand hier noch aus der Zeit, als nur
|
||
// die Rolle 'installer' Zugang hatte, und liess ein frisches
|
||
// Administratorkonto bis zum naechsten turnusmaessigen Lauf
|
||
// vor verschlossener Ablage stehen.
|
||
dc_guard_releases($pdo);
|
||
|
||
dc_flash(sprintf(
|
||
'Konto "%s" mit der Rolle %s angelegt.%s',
|
||
e($newUser),
|
||
e($newRole),
|
||
$newRole === 'installer'
|
||
? ' Es kann sich nicht am WebUI anmelden, sondern nur Anwendungen einrichten.'
|
||
: ''
|
||
));
|
||
dc_redirect('#sub-system-users');
|
||
}
|
||
|
||
case 'set_user_password': {
|
||
$userId = (int)($_POST['user_id'] ?? 0);
|
||
$newPass = (string)($_POST['password'] ?? '');
|
||
|
||
if (strlen($newPass) < 12) {
|
||
dc_flash('Das Passwort muss mindestens 12 Zeichen lang sein.', 'danger');
|
||
dc_redirect('#sub-system-users');
|
||
}
|
||
|
||
$upd = $pdo->prepare('UPDATE dc_users SET password_hash = :h WHERE id = :id');
|
||
$upd->execute([':h' => password_hash($newPass, PASSWORD_DEFAULT), ':id' => $userId]);
|
||
|
||
Logger::info('Passwort geaendert', ['user_id' => $userId, 'actor' => $actor]);
|
||
|
||
// Der Hash steht auch in den .htpasswd-Dateien.
|
||
dc_guard_releases($pdo);
|
||
|
||
dc_flash($upd->rowCount() > 0 ? 'Passwort geaendert.' : 'Konto nicht gefunden.',
|
||
$upd->rowCount() > 0 ? 'success' : 'danger');
|
||
dc_redirect('#sub-system-users');
|
||
}
|
||
|
||
case 'toggle_user_disabled': {
|
||
$userId = (int)($_POST['user_id'] ?? 0);
|
||
|
||
$row = $pdo->prepare('SELECT id, username, role, disabled FROM dc_users WHERE id = :id LIMIT 1');
|
||
$row->execute([':id' => $userId]);
|
||
$target = $row->fetch();
|
||
|
||
if (!is_array($target)) {
|
||
dc_flash('Konto nicht gefunden.', 'danger');
|
||
dc_redirect('#sub-system-users');
|
||
}
|
||
|
||
$disable = (int)$target['disabled'] === 0;
|
||
|
||
// Ohne diese Sperre liesse sich das letzte handlungsfaehige
|
||
// Administratorkonto abschalten - danach kaeme niemand mehr an
|
||
// die Oberflaeche, auch nicht, um es rueckgaengig zu machen.
|
||
if ($disable && $target['role'] === 'admin' && dc_active_admin_count($pdo) <= 1) {
|
||
dc_flash('Das letzte aktive Administratorkonto kann nicht deaktiviert werden.', 'danger');
|
||
dc_redirect('#sub-system-users');
|
||
}
|
||
|
||
$upd = $pdo->prepare('UPDATE dc_users SET disabled = :d WHERE id = :id');
|
||
$upd->execute([':d' => $disable ? 1 : 0, ':id' => $userId]);
|
||
|
||
Logger::info('Konto ' . ($disable ? 'deaktiviert' : 'aktiviert'), [
|
||
'username' => $target['username'],
|
||
'actor' => $actor,
|
||
]);
|
||
|
||
dc_guard_releases($pdo);
|
||
|
||
dc_flash(sprintf('Konto "%s" wurde %s.', e((string)$target['username']),
|
||
$disable ? 'deaktiviert' : 'wieder aktiviert'));
|
||
dc_redirect('#sub-system-users');
|
||
}
|
||
|
||
case 'delete_user': {
|
||
$userId = (int)($_POST['user_id'] ?? 0);
|
||
|
||
$row = $pdo->prepare('SELECT id, username, role FROM dc_users WHERE id = :id LIMIT 1');
|
||
$row->execute([':id' => $userId]);
|
||
$target = $row->fetch();
|
||
|
||
if (!is_array($target)) {
|
||
dc_flash('Konto nicht gefunden.', 'danger');
|
||
dc_redirect('#sub-system-users');
|
||
}
|
||
|
||
if ($userId === Auth::userId()) {
|
||
dc_flash('Das eigene Konto kann nicht geloescht werden.', 'danger');
|
||
dc_redirect('#sub-system-users');
|
||
}
|
||
|
||
if ($target['role'] === 'admin' && dc_active_admin_count($pdo) <= 1) {
|
||
dc_flash('Das letzte Administratorkonto kann nicht geloescht werden.', 'danger');
|
||
dc_redirect('#sub-system-users');
|
||
}
|
||
|
||
$del = $pdo->prepare('DELETE FROM dc_users WHERE id = :id');
|
||
$del->execute([':id' => $userId]);
|
||
|
||
Logger::info('Benutzerkonto geloescht', [
|
||
'username' => $target['username'],
|
||
'actor' => $actor,
|
||
]);
|
||
|
||
dc_guard_releases($pdo);
|
||
|
||
dc_flash(sprintf('Konto "%s" wurde geloescht.', e((string)$target['username'])));
|
||
dc_redirect('#sub-system-users');
|
||
}
|
||
|
||
case 'unlock_user': {
|
||
$userId = (int)($_POST['user_id'] ?? 0);
|
||
if (Auth::unlockAccount($pdo, $userId)) {
|
||
dc_flash('Benutzerkonto wurde erfolgreich entsperrt.');
|
||
} else {
|
||
dc_flash('Konto konnte nicht entsperrt werden oder existiert nicht.', 'danger');
|
||
}
|
||
dc_redirect('#sub-system-users');
|
||
}
|
||
|
||
default:
|
||
dc_flash('Unbekannte Aktion.', 'danger');
|
||
dc_redirect();
|
||
}
|
||
} catch (Throwable $e) {
|
||
Logger::exception($e);
|
||
dc_flash(
|
||
'Die Aktion ist fehlgeschlagen: ' . e($e->getMessage()),
|
||
'danger'
|
||
);
|
||
dc_redirect();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Nimmt ein hochgeladenes Icon entgegen und liefert den relativen Pfad.
|
||
* Prueft den tatsaechlichen Dateityp, nicht nur die Endung.
|
||
*/
|
||
function dc_storeIconUpload(array $file): ?string
|
||
{
|
||
$maxBytes = 512 * 1024;
|
||
if (($file['size'] ?? 0) <= 0 || $file['size'] > $maxBytes) {
|
||
return null;
|
||
}
|
||
|
||
$extension = strtolower(pathinfo((string)$file['name'], PATHINFO_EXTENSION));
|
||
$allowed = ['svg' => 'image/svg+xml', 'png' => 'image/png', 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'webp' => 'image/webp'];
|
||
|
||
if (!isset($allowed[$extension])) {
|
||
return null;
|
||
}
|
||
|
||
// SVG kann Skripte enthalten und wird deshalb nur akzeptiert, wenn keine
|
||
// ausfuehrbaren Bestandteile enthalten sind.
|
||
if ($extension === 'svg') {
|
||
$content = (string)file_get_contents((string)$file['tmp_name']);
|
||
if (preg_match('/<\s*script|javascript:|on\w+\s*=/i', $content) === 1) {
|
||
Logger::warning('SVG-Upload mit Skriptanteil abgelehnt');
|
||
return null;
|
||
}
|
||
} else {
|
||
$info = @getimagesize((string)$file['tmp_name']);
|
||
if ($info === false) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
$directory = __DIR__ . '/assets/icons/custom';
|
||
if (!is_dir($directory) && !@mkdir($directory, 0755, true) && !is_dir($directory)) {
|
||
return null;
|
||
}
|
||
|
||
$safeName = preg_replace('/[^a-z0-9]/', '', strtolower(pathinfo((string)$file['name'], PATHINFO_FILENAME))) ?? 'icon';
|
||
$filename = 'icon_' . time() . '_' . substr($safeName, 0, 32) . '.' . $extension;
|
||
|
||
if (!move_uploaded_file((string)$file['tmp_name'], $directory . '/' . $filename)) {
|
||
return null;
|
||
}
|
||
|
||
return 'custom/' . $filename;
|
||
}
|
||
|
||
// ======================================================================
|
||
// Daten fuer die Darstellung
|
||
// ======================================================================
|
||
|
||
Auth::startSession();
|
||
$flash = $_SESSION['dc_flash'] ?? null;
|
||
unset($_SESSION['dc_flash']);
|
||
|
||
$msg = is_array($flash) ? (string)$flash['message'] : null;
|
||
$msgType = is_array($flash) && ($flash['type'] ?? '') === 'danger' ? 'danger' : 'success';
|
||
|
||
$projects = $pdo->query('SELECT * FROM dc_projects ORDER BY name ASC')->fetchAll() ?: [];
|
||
|
||
$licenses = $pdo->query('
|
||
SELECT l.*, p.name AS product_name, p.slug AS product_slug,
|
||
(SELECT COUNT(*) FROM license_activations a WHERE a.license_id = l.id) AS active_count
|
||
FROM license_licenses l
|
||
JOIN dc_projects p ON l.product_id = p.id
|
||
ORDER BY l.created_at DESC
|
||
LIMIT 500
|
||
')->fetchAll() ?: [];
|
||
|
||
$activations = $pdo->query('
|
||
SELECT a.*, l.license_key, p.slug AS product_slug, p.name AS product_name
|
||
FROM license_activations a
|
||
JOIN license_licenses l ON a.license_id = l.id
|
||
JOIN dc_projects p ON l.product_id = p.id
|
||
ORDER BY a.last_seen DESC
|
||
LIMIT 500
|
||
')->fetchAll() ?: [];
|
||
|
||
$auditLogs = $pdo->query('SELECT * FROM license_audit_log ORDER BY created_at DESC LIMIT 100')->fetchAll() ?: [];
|
||
|
||
$monitorRepo = new MonitorRepo($pdo);
|
||
$monitors = $monitorRepo->getAllMonitors();
|
||
|
||
$tokenManager = new TokenManager($pdo);
|
||
$agentTokens = $tokenManager->getAllTokens();
|
||
|
||
$tokensBySource = [];
|
||
foreach ($agentTokens as $token) {
|
||
if (empty($token['revoked']) && !empty($token['monitor_source'])) {
|
||
$tokensBySource[$token['monitor_source']] = $token;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Bringt die Monitore in Baumreihenfolge.
|
||
* Erkennt Zyklen, damit ein fehlerhaft gesetzter Parent keine Endlosschleife
|
||
* und keine doppelten Zeilen erzeugt.
|
||
*
|
||
* @param list<array<string,mixed>> $monitors
|
||
* @return list<array<string,mixed>>
|
||
*/
|
||
function buildMonitorTree(array $monitors): array
|
||
{
|
||
$childrenOf = [];
|
||
$bySource = [];
|
||
|
||
foreach ($monitors as $monitor) {
|
||
$source = (string)$monitor['source'];
|
||
$bySource[$source] = $monitor;
|
||
|
||
$parent = !empty($monitor['parent_source']) ? (string)$monitor['parent_source'] : '__ROOT__';
|
||
if ($parent === $source) {
|
||
$parent = '__ROOT__'; // Selbstreferenz aufloesen
|
||
}
|
||
$childrenOf[$parent][] = $source;
|
||
}
|
||
|
||
$flat = [];
|
||
$visited = [];
|
||
|
||
$walk = static function (string $parentSource, int $depth) use (&$walk, &$flat, &$visited, $childrenOf, $bySource): void {
|
||
if ($depth > 5 || empty($childrenOf[$parentSource])) {
|
||
return;
|
||
}
|
||
|
||
$nodes = $childrenOf[$parentSource];
|
||
$count = count($nodes);
|
||
|
||
foreach ($nodes as $index => $source) {
|
||
if (!isset($bySource[$source]) || isset($visited[$source])) {
|
||
continue;
|
||
}
|
||
$visited[$source] = true;
|
||
|
||
$node = $bySource[$source];
|
||
$node['depth'] = $depth;
|
||
$node['is_last'] = ($index === $count - 1);
|
||
$flat[] = $node;
|
||
|
||
$walk($source, $depth + 1);
|
||
}
|
||
};
|
||
|
||
$walk('__ROOT__', 0);
|
||
|
||
// Monitore, deren Parent nicht existiert oder Teil eines Zyklus ist,
|
||
// erscheinen auf oberster Ebene statt zu verschwinden.
|
||
foreach ($monitors as $monitor) {
|
||
if (!isset($visited[(string)$monitor['source']])) {
|
||
$monitor['depth'] = 0;
|
||
$monitor['is_last'] = true;
|
||
$flat[] = $monitor;
|
||
}
|
||
}
|
||
|
||
return $flat;
|
||
}
|
||
|
||
$hierarchicalMonitors = buildMonitorTree($monitors);
|
||
|
||
$groupStats = [];
|
||
$monitorsUp = 0;
|
||
$monitorsWarning = 0;
|
||
$monitorsDown = 0;
|
||
|
||
foreach ($monitors as $monitor) {
|
||
$state = (string)$monitor['state'];
|
||
|
||
if ($state === 'up') {
|
||
$monitorsUp++;
|
||
} elseif ($state === 'warning' || $state === 'unknown') {
|
||
$monitorsWarning++;
|
||
} elseif ($state !== 'maintenance' && $state !== 'stopped') {
|
||
$monitorsDown++;
|
||
}
|
||
|
||
$groupKey = !empty($monitor['group_key']) ? (string)$monitor['group_key'] : 'Default';
|
||
if (!isset($groupStats[$groupKey])) {
|
||
$groupStats[$groupKey] = ['total' => 0, 'ok' => 0];
|
||
}
|
||
$groupStats[$groupKey]['total']++;
|
||
if ($state === 'up') {
|
||
$groupStats[$groupKey]['ok']++;
|
||
}
|
||
}
|
||
|
||
/** Ermittelt das Icon eines Monitors. */
|
||
function getMonitorIconUrl(?string $icon, ?string $source, ?string $os, ?string $type): string
|
||
{
|
||
if (!empty($icon) && $icon !== 'auto') {
|
||
if (str_starts_with($icon, 'custom/')) {
|
||
return 'assets/icons/' . $icon;
|
||
}
|
||
if (str_starts_with($icon, 'assets/icons/')) {
|
||
return $icon;
|
||
}
|
||
// Fruehere Fassungen legten die Symbole flach unter assets/ ab und
|
||
// schrieben diesen Pfad in die Datenbank. Die Dateien liegen jetzt nur
|
||
// noch unter assets/icons/ - Altwerte werden hier umgesetzt.
|
||
if (str_starts_with($icon, 'assets/')) {
|
||
return 'assets/icons/' . substr($icon, strlen('assets/'));
|
||
}
|
||
return 'assets/icons/' . $icon;
|
||
}
|
||
|
||
$haystack = strtolower(($source ?? '') . ' ' . ($os ?? '') . ' ' . ($type ?? ''));
|
||
|
||
$rules = [
|
||
'proxmox' => ['proxmox', 'pve'],
|
||
'windows' => ['win'],
|
||
'linux' => ['linux', 'ubuntu', 'debian'],
|
||
'mysql' => ['mysql', 'mariadb'],
|
||
'docker' => ['docker'],
|
||
'nginx' => ['nginx'],
|
||
'redis' => ['redis'],
|
||
'python' => ['python'],
|
||
'php' => ['php'],
|
||
'node' => ['node'],
|
||
];
|
||
|
||
foreach ($rules as $iconName => $needles) {
|
||
foreach ($needles as $needle) {
|
||
if (str_contains($haystack, $needle)) {
|
||
return 'assets/icons/' . $iconName . '.svg';
|
||
}
|
||
}
|
||
}
|
||
|
||
return 'assets/icons/server.svg';
|
||
}
|
||
|
||
$eventLog = new EventLog($pdo);
|
||
$recentEvents = $eventLog->getRecentEvents(100);
|
||
|
||
$updateManager = new UpdateManager($pdo);
|
||
$releases = $updateManager->getReleases();
|
||
|
||
// Benutzerkonten fuer die Verwaltung. Der Passwort-Hash wird bewusst nicht
|
||
// mitgelesen - er hat in der Ansicht nichts verloren.
|
||
try {
|
||
$users = $pdo->query('
|
||
SELECT id, username, role, disabled, failed_login_count, locked_until, last_login_at, created_at
|
||
FROM dc_users ORDER BY role ASC, username ASC
|
||
')->fetchAll() ?: [];
|
||
} catch (Throwable $e) {
|
||
try {
|
||
$users = $pdo->query('
|
||
SELECT id, username, role, disabled, last_login_at, created_at
|
||
FROM dc_users ORDER BY role ASC, username ASC
|
||
')->fetchAll() ?: [];
|
||
} catch (Throwable $e2) {
|
||
// Vor Migration 012 fehlen role, disabled und last_login_at.
|
||
$users = $pdo->query('SELECT id, username, created_at FROM dc_users ORDER BY username ASC')->fetchAll() ?: [];
|
||
}
|
||
}
|
||
$usersSchemaReady = $users === [] || array_key_exists('role', $users[0]);
|
||
|
||
// Zustand des Zugangsschutzes der Release-Verzeichnisse.
|
||
$releaseGuard = ReleaseGuard::status($pdo);
|
||
|
||
// Ergebnis des letzten Selbsttests. Bewusst nur gelesen: ein HTTP-Abruf je
|
||
// Produkt bei jedem Seitenaufbau waere Verschwendung.
|
||
$releaseGuardTest = ReleaseGuard::lastSelfTest($pdo);
|
||
|
||
// Installer-Downloads. Die Binaries liegen unter /installer/ im Webroot und
|
||
// werden nicht von deploy.py uebertragen, sondern von
|
||
// scripts/upload_installer.py - der Quelltext des Agenten gehoert nicht auf
|
||
// den Webserver, die uebersetzten Dateien schon.
|
||
$installerManifest = null;
|
||
$installerPath = dirname(__DIR__) . '/installer/installer.json';
|
||
|
||
if (is_file($installerPath)) {
|
||
$raw = (string)file_get_contents($installerPath);
|
||
|
||
// Ein vorangestelltes BOM laesst json_decode() scheitern. Windows-Werkzeuge
|
||
// schreiben es gern mit; die Datei ist deswegen nicht kaputt.
|
||
$raw = preg_replace('/^\xEF\xBB\xBF/', '', $raw) ?? $raw;
|
||
|
||
$decoded = json_decode($raw, true);
|
||
if (is_array($decoded) && isset($decoded['binaries']) && is_array($decoded['binaries'])) {
|
||
$installerManifest = $decoded;
|
||
}
|
||
}
|
||
|
||
$coreTokenManager = new CoreTokenManager($pdo);
|
||
$coreMasterTokens = $coreTokenManager->getAllMasterTokens();
|
||
$coreAllTokens = $coreTokenManager->getAllTokens();
|
||
|
||
// ----------------------------------------------------------------------
|
||
// Bugtracker: serverseitige Filterung und Pagination
|
||
// ----------------------------------------------------------------------
|
||
$btPerPage = 50;
|
||
$btPage = max(1, (int)($_GET['bt_page'] ?? 1));
|
||
|
||
/**
|
||
* Status, die beim ersten Aufruf angehakt sind.
|
||
*
|
||
* Alles, was gerade laeuft oder Aufmerksamkeit verlangt. Erledigtes,
|
||
* Geschlossenes und Abgelehntes bleibt draussen - es ist der weitaus groesste
|
||
* Teil des Bestandes und verdeckt sonst das, was noch zu tun ist.
|
||
*
|
||
* "deferred" fehlt mit Absicht: Der ganze Sinn dieses Status ist "nicht jetzt".
|
||
* Ein zurueckgestelltes Vorhaben in der Standardliste waere ein Widerspruch in
|
||
* sich - es steht unter "Zurueckgestellt" bereit, wenn jemand danach sucht.
|
||
*/
|
||
const BT_STATUS_VORGABE = [
|
||
'open', 'planned', 'in_progress', 'triage_external',
|
||
'awaiting_verification', 'in_verification', 'fix_insufficient',
|
||
];
|
||
|
||
/**
|
||
* Liest die angehakten Status aus der Anfrage.
|
||
*
|
||
* Drei Faelle, und der mittlere ist der Grund fuer das versteckte Feld im
|
||
* Formular:
|
||
*
|
||
* Parameter fehlt ganz -> erster Aufruf, es gilt die Vorgabe
|
||
* Parameter ist leer -> alle Haken entfernt, also KEINE Einschraenkung
|
||
* Parameter hat Werte -> genau diese Status
|
||
*
|
||
* Ohne den zweiten Fall waere "alle Haken weg" von "noch nie etwas gewaehlt"
|
||
* nicht zu unterscheiden - HTML sendet fuer leere Ankreuzfelder nichts. Die
|
||
* Vorgabe kaeme dann bei jedem Versuch zurueck, sie loszuwerden.
|
||
*
|
||
* Zeichenketten werden weiterhin angenommen: Aeltere Lesezeichen und die
|
||
* Verweise aus den Kennzahlen kommen als bt_status=open,in_progress herein.
|
||
*
|
||
* @return list<string> Leer bedeutet "keine Einschraenkung".
|
||
*/
|
||
function btStatusAuswahl(): array
|
||
{
|
||
if (!array_key_exists('bt_status', $_GET)) {
|
||
return BT_STATUS_VORGABE;
|
||
}
|
||
|
||
$roh = $_GET['bt_status'];
|
||
$werte = is_array($roh) ? $roh : explode(',', (string)$roh);
|
||
|
||
$werte = array_map(static fn($v): string => trim((string)$v), $werte);
|
||
$werte = array_values(array_filter(
|
||
$werte,
|
||
static fn(string $v): bool => in_array($v, BugRepo::STATUSES, true)
|
||
));
|
||
|
||
// "all" als einzelner Wert ist die alte Schreibweise fuer "ohne Filter".
|
||
if (!is_array($roh) && trim((string)$roh) === 'all') {
|
||
return [];
|
||
}
|
||
|
||
return array_values(array_unique($werte));
|
||
}
|
||
|
||
$btStatusAuswahl = btStatusAuswahl();
|
||
|
||
/**
|
||
* Roadmap-Eintraege gehoeren in die Roadmap, nicht in die Bugliste.
|
||
*
|
||
* Seit 2.7.0 IST ein Roadmap-Eintrag ein Bugtracker-Item - es gibt keine
|
||
* zweite Ablage, und ein Item steht auf der Roadmap, sobald es einen
|
||
* Themenbereich hat. Richtig so; nur landet damit die gesamte Planung aller
|
||
* Projekte in derselben Liste wie die Meldungen. Nach der Uebernahme der
|
||
* Projekt-Roadmaps waren das 217 von 229 Feature Requests - die zwoelf
|
||
* Vorschlaege, um die es in dieser Liste geht, lagen darunter begraben.
|
||
*
|
||
* Die Bugliste zeigt deshalb ab Werk nur, was NICHT auf der Roadmap steht.
|
||
* Sichtbar bleibt beides, nur an getrennten Orten - und der Filter holt die
|
||
* Planung jederzeit wieder herein.
|
||
*/
|
||
$btRoadmap = (string)($_GET['bt_roadmap'] ?? 'ohne');
|
||
if (!in_array($btRoadmap, ['ohne', 'nur', 'alle'], true)) {
|
||
$btRoadmap = 'ohne';
|
||
}
|
||
|
||
$btFilters = [
|
||
'project_slug' => (string)($_GET['bt_project'] ?? 'all'),
|
||
'environment' => (string)($_GET['bt_env'] ?? 'all'),
|
||
'type' => (string)($_GET['bt_type'] ?? 'all'),
|
||
'severity' => (string)($_GET['bt_severity'] ?? 'all'),
|
||
'status' => $btStatusAuswahl === [] ? 'all' : $btStatusAuswahl,
|
||
'search' => (string)($_GET['bt_q'] ?? ''),
|
||
'order' => (string)($_GET['bt_order'] ?? 'newest'),
|
||
'limit' => $btPerPage,
|
||
'offset' => ($btPage - 1) * $btPerPage,
|
||
];
|
||
|
||
if ($btRoadmap !== 'alle') {
|
||
$btFilters['on_roadmap'] = ($btRoadmap === 'nur');
|
||
}
|
||
|
||
$bugRepo = new BugRepo($pdo);
|
||
$bugtrackerResult = $bugRepo->getItems($btFilters);
|
||
$bugtrackerItems = $bugtrackerResult['items'];
|
||
$bugtrackerTotal = $bugtrackerResult['total'];
|
||
$bugtrackerPages = max(1, (int)ceil($bugtrackerTotal / $btPerPage));
|
||
$bugtrackerStats = $bugRepo->getStats(
|
||
$btFilters['project_slug'] !== 'all' ? $btFilters['project_slug'] : null
|
||
);
|
||
|
||
/**
|
||
* Wie viele Treffer der Statusfilter gerade verschluckt.
|
||
*
|
||
* Der Fall, um den es geht: Jemand sucht nach "288", bekommt nichts und
|
||
* schliesst daraus, dass es den Eintrag nicht gibt - dabei ist er nur
|
||
* erledigt und deshalb ausgeblendet. Eine Suche, die schweigt, obwohl sie
|
||
* etwas gefunden hat, ist schlimmer als gar keine Suche.
|
||
*
|
||
* Die zusaetzliche Abfrage laeuft nur bei aktiver Suche UND aktivem
|
||
* Statusfilter - also genau dann, wenn die Verwechslung moeglich ist. Sie
|
||
* holt eine einzige Zeile; gebraucht wird nur die Gesamtzahl.
|
||
*/
|
||
$btVerdeckt = 0;
|
||
if ($btFilters['search'] !== '' && ($btStatusAuswahl !== [] || $btRoadmap !== 'alle')) {
|
||
$ohneEinschraenkung = $btFilters;
|
||
unset($ohneEinschraenkung['on_roadmap']);
|
||
$ohneEinschraenkung['status'] = 'all';
|
||
$ohneEinschraenkung['limit'] = 1;
|
||
$ohneEinschraenkung['offset'] = 0;
|
||
|
||
$btVerdeckt = max(0, $bugRepo->getItems($ohneEinschraenkung)['total'] - $bugtrackerTotal);
|
||
}
|
||
|
||
/** Baut eine URL mit den aktuellen Bugtracker-Filtern und einzelnen Ueberschreibungen. */
|
||
function btUrl(array $overrides = []): string
|
||
{
|
||
$params = [
|
||
'bt_project' => $_GET['bt_project'] ?? null,
|
||
'bt_env' => $_GET['bt_env'] ?? null,
|
||
'bt_type' => $_GET['bt_type'] ?? null,
|
||
'bt_severity' => $_GET['bt_severity'] ?? null,
|
||
'bt_status' => $_GET['bt_status'] ?? null,
|
||
'bt_roadmap' => $_GET['bt_roadmap'] ?? null,
|
||
'bt_q' => $_GET['bt_q'] ?? null,
|
||
'bt_order' => $_GET['bt_order'] ?? null,
|
||
'bt_page' => $_GET['bt_page'] ?? null,
|
||
];
|
||
|
||
$params = array_merge($params, $overrides);
|
||
$params = array_filter($params, static fn($v): bool => $v !== null && $v !== '' && $v !== 'all');
|
||
|
||
return 'index.php' . ($params !== [] ? '?' . http_build_query($params) : '') . '#tab-bugtracker';
|
||
}
|
||
|
||
// ----------------------------------------------------------------------
|
||
// Fehler-Stream
|
||
// ----------------------------------------------------------------------
|
||
$errPerPage = 40;
|
||
$errPage = max(1, (int)($_GET['err_page'] ?? 1));
|
||
$errShow = (string)($_GET['err_show'] ?? 'active'); // active | ignored | all
|
||
|
||
$errFilters = [
|
||
'project_slug' => (string)($_GET['err_project'] ?? 'all'),
|
||
'environment' => (string)($_GET['err_env'] ?? 'all'),
|
||
'error_level' => (string)($_GET['err_level'] ?? 'all'),
|
||
'search' => (string)($_GET['err_q'] ?? ''),
|
||
'since_hours' => (int)($_GET['err_hours'] ?? 0),
|
||
'errors_only' => true,
|
||
'order' => 'newest',
|
||
'limit' => $errPerPage,
|
||
'offset' => ($errPage - 1) * $errPerPage,
|
||
];
|
||
|
||
if ($errShow === 'ignored') {
|
||
$errFilters['only_ignored'] = true;
|
||
} elseif ($errShow === 'all') {
|
||
$errFilters['include_ignored'] = true;
|
||
}
|
||
|
||
$errorResult = $bugRepo->getItems($errFilters);
|
||
$errorItems = $errorResult['items'];
|
||
$errorTotal = $errorResult['total'];
|
||
$errorPages = max(1, (int)ceil($errorTotal / $errPerPage));
|
||
|
||
$ignoreRuleRepo = new IgnoreRules($pdo);
|
||
$ignoreRules = $ignoreRuleRepo->all();
|
||
|
||
/** Baut eine URL mit den aktuellen Fehler-Filtern. */
|
||
function errUrl(array $overrides = []): string
|
||
{
|
||
$params = [
|
||
'err_project' => $_GET['err_project'] ?? null,
|
||
'err_env' => $_GET['err_env'] ?? null,
|
||
'err_level' => $_GET['err_level'] ?? null,
|
||
'err_show' => $_GET['err_show'] ?? null,
|
||
'err_hours' => $_GET['err_hours'] ?? null,
|
||
'err_q' => $_GET['err_q'] ?? null,
|
||
'err_page' => $_GET['err_page'] ?? null,
|
||
];
|
||
|
||
$params = array_merge($params, $overrides);
|
||
$params = array_filter($params, static fn($v): bool => $v !== null && $v !== '' && $v !== 'all' && $v !== '0');
|
||
|
||
return 'index.php' . ($params !== [] ? '?' . http_build_query($params) : '') . '#sub-bugtracker-errors';
|
||
}
|
||
|
||
// ----------------------------------------------------------------------
|
||
// Roadmap
|
||
// ----------------------------------------------------------------------
|
||
// Kein eigener Datenbestand: Ein Roadmap-Eintrag ist ein bugtracker_item mit
|
||
// gesetztem Themenbereich. Diese Ansicht ist eine Sicht darauf, kein zweites
|
||
// System - deshalb liest sie dieselbe getItems()-Abfrage wie der Bugtracker,
|
||
// nur mit anderer Sortierung und einem zusaetzlichen Filter.
|
||
$roadmapRepo = new Roadmap($pdo);
|
||
|
||
$rmProject = (string)($_GET['rm_project'] ?? 'all');
|
||
$rmEpic = (string)($_GET['rm_epic'] ?? 'all');
|
||
$rmLage = (string)($_GET['rm_lage'] ?? 'aktiv');
|
||
$rmAgent = (string)($_GET['rm_agent'] ?? '');
|
||
$rmBuild = (string)($_GET['rm_build'] ?? 'all');
|
||
$rmSearch = (string)($_GET['rm_q'] ?? '');
|
||
|
||
/**
|
||
* Uebersetzt die gewaehlte Lage in Status.
|
||
*
|
||
* "aktiv" ist die Voreinstellung und nicht "alle": Eine Roadmap, die beim
|
||
* Aufschlagen mit sechzig erledigten Eintraegen beginnt, beantwortet die
|
||
* Frage nicht, fuer die man sie aufschlaegt.
|
||
*/
|
||
$rmLageStatuses = [
|
||
'aktiv' => Roadmap::PENDING_STATUSES,
|
||
'offen' => Roadmap::STATUS_GROUPS['offen'],
|
||
'arbeit' => Roadmap::STATUS_GROUPS['arbeit'],
|
||
'erledigt' => Roadmap::STATUS_GROUPS['erledigt'],
|
||
'zurueckgestellt' => Roadmap::STATUS_GROUPS['zurueckgestellt'],
|
||
'verworfen' => Roadmap::STATUS_GROUPS['verworfen'],
|
||
];
|
||
|
||
$rmFilters = [
|
||
'project_slug' => $rmProject,
|
||
'on_roadmap' => true,
|
||
'epic' => $rmEpic,
|
||
'target_build' => $rmBuild,
|
||
'claimed_by' => $rmAgent,
|
||
'search' => $rmSearch,
|
||
'order' => 'roadmap',
|
||
'limit' => 400,
|
||
];
|
||
|
||
if ($rmLage !== 'all' && isset($rmLageStatuses[$rmLage])) {
|
||
$rmFilters['status'] = implode(',', $rmLageStatuses[$rmLage]);
|
||
}
|
||
|
||
$roadmapResult = $bugRepo->getItems($rmFilters);
|
||
$roadmapItems = $roadmapResult['items'];
|
||
$roadmapTotal = $roadmapResult['total'];
|
||
|
||
$roadmapStats = $roadmapRepo->stats($rmProject);
|
||
$roadmapEpics = $roadmapRepo->epics($rmProject);
|
||
$roadmapOrphans = $roadmapRepo->orphanEpics($rmProject);
|
||
$roadmapNext = $roadmapRepo->nextUp($rmProject, 6);
|
||
$roadmapActive = $roadmapRepo->activeWork($rmProject);
|
||
|
||
// Bereichstitel als Nachschlagewerk, damit die Liste nicht je Zeile eine
|
||
// weitere Abfrage braucht.
|
||
$roadmapEpicTitles = [];
|
||
foreach ($roadmapEpics as $rmE) {
|
||
$roadmapEpicTitles[$rmE['project_slug'] . '/' . $rmE['epic']] = (string)$rmE['title'];
|
||
}
|
||
|
||
// Zielfassungen, die tatsaechlich vergeben sind - eine Auswahlliste aus
|
||
// vorhandenen Werten statt eines Freitextfelds, in das sich Tippfehler
|
||
// schleichen.
|
||
$roadmapBuilds = $pdo->query('
|
||
SELECT DISTINCT target_build FROM bugtracker_items
|
||
WHERE target_build IS NOT NULL AND target_build <> ""
|
||
AND epic IS NOT NULL AND epic <> ""
|
||
ORDER BY target_build DESC LIMIT 50
|
||
')->fetchAll(PDO::FETCH_COLUMN) ?: [];
|
||
|
||
// Eintraege, die noch keinen Bereich haben und deshalb nicht auf der Roadmap
|
||
// stehen. Die Aufnahmeliste: Der Weg vom Backlog auf die Planung ist genau
|
||
// eine Angabe, und dies ist die Stelle, an der man ihn sieht.
|
||
$roadmapCandidates = $bugRepo->getItems([
|
||
'project_slug' => $rmProject,
|
||
'on_roadmap' => false,
|
||
'type' => 'feature_request',
|
||
'status' => 'open,planned',
|
||
'order' => 'severity',
|
||
'limit' => 60,
|
||
])['items'];
|
||
|
||
/**
|
||
* Baut eine URL mit den aktuellen Roadmap-Filtern.
|
||
*
|
||
* Das Sprungziel ist einstellbar, weil ein gesetzter Filter nur dort etwas
|
||
* bewirkt, wo die gefilterte Liste auch steht. Ein Verweis auf einen
|
||
* Themenbereich, der auf der Uebersicht landet, setzt den Filter zwar - man
|
||
* sieht davon aber nichts und haelt den Link fuer kaputt.
|
||
*/
|
||
function rmUrl(array $overrides = [], string $anker = '#tab-roadmap'): string
|
||
{
|
||
$params = [
|
||
'rm_project' => $_GET['rm_project'] ?? null,
|
||
'rm_epic' => $_GET['rm_epic'] ?? null,
|
||
'rm_lage' => $_GET['rm_lage'] ?? null,
|
||
'rm_agent' => $_GET['rm_agent'] ?? null,
|
||
'rm_build' => $_GET['rm_build'] ?? null,
|
||
'rm_q' => $_GET['rm_q'] ?? null,
|
||
];
|
||
|
||
$params = array_merge($params, $overrides);
|
||
$params = array_filter($params, static fn($v): bool => $v !== null && $v !== '' && $v !== 'all');
|
||
|
||
return 'index.php' . ($params !== [] ? '?' . http_build_query($params) : '') . $anker;
|
||
}
|
||
|
||
/** Zeichen und Klartext zu einem Status, fuer Abzeichen in der Liste. */
|
||
function rmBadge(string $status): string
|
||
{
|
||
$marker = Roadmap::MARKERS[$status] ?? '·';
|
||
$label = Roadmap::STATUS_LABELS[$status] ?? $status;
|
||
|
||
$class = match (true) {
|
||
in_array($status, ['resolved', 'closed'], true) => 'badge-up',
|
||
$status === 'in_progress' => 'badge-warning',
|
||
in_array($status, ['awaiting_verification', 'in_verification'], true) => 'badge-warning',
|
||
$status === 'fix_insufficient' => 'badge-down',
|
||
default => 'badge-stopped',
|
||
};
|
||
|
||
return '<span class="badge ' . $class . '">' . $marker . ' ' . e($label) . '</span>';
|
||
}
|
||
|
||
// Die auswaehlbaren Rechte - EINE Liste fuer Anlegen und Bearbeiten.
|
||
//
|
||
// Vorher stand sie im Anlegeformular. Genau deshalb fehlten die
|
||
// Supervisor-Rechte monatelang: Wer eine Liste an zwei Stellen fuehrt,
|
||
// pflegt eine davon nicht. Ein zweites Formular waere die zweite Stelle
|
||
// gewesen.
|
||
$scopeChoices = [
|
||
'*' => ['⚠️ Alle Rechte', 'Uneingeschränkt — auch Releases veröffentlichen und Tokens ausstellen. Nicht an einen Agenten geben.'],
|
||
'bugtracker:manage' => ['⚙️ Bugtracker verwalten', 'Übernehmen, kommentieren, schließen — schließt Lesen und Melden ein'],
|
||
'bugtracker:report' => ['🐛 Bugtracker melden', 'Nur Einträge anlegen — schließt Lesen ein'],
|
||
'bugtracker:read' => ['👁️ Bugtracker lesen', 'Nur Abfragen'],
|
||
'watchdog:ping' => ['🛡️ Watchdog Heartbeat', 'Statusmeldungen senden'],
|
||
'watchdog:read' => ['📊 Watchdog lesen', 'Monitore und Ereignisse abfragen'],
|
||
// Die Supervisor-Rechte fehlten hier, obwohl TokenManager
|
||
// sie seit 2.6.0 kennt. Folge: Ein Werkbank-Token liess
|
||
// sich ueber die Oberflaeche gar nicht vollstaendig
|
||
// ausstellen - dc_findings und dc_metrics im MCP-Adapter
|
||
// liefen ins unauthorized, und der Grund war nirgends zu
|
||
// sehen.
|
||
'supervisor:read' => ['🔎 Befunde lesen', 'Supervisor-Befunde abfragen — braucht ein Werkbank-Token für dc_findings'],
|
||
'supervisor:manage' => ['🔬 Befunde einstufen', 'Befunde abhaken und bewerten — schließt Lesen ein'],
|
||
'supervisor:report' => ['📡 Befunde melden', 'Nur einliefern, etwa aus einem Prüfwerkzeug — schließt Lesen ein'],
|
||
'updateservice:read' => ['📦 Updates prüfen', 'Auf neue Releases prüfen'],
|
||
'updateservice:publish' => ['🚀 Releases veröffentlichen', 'Neue Releases einstellen — schließt Prüfen ein'],
|
||
'license:dev' => ['🧪 DEV-Lizenz abholen', 'Befristeten Testschlüssel selbst ausstellen — nur für Werkbank-Token auf Testmaschinen'],
|
||
];
|
||
|
||
// Agenten-Uebersicht: wer bearbeitet gerade was
|
||
$agentWorkload = $pdo->query('
|
||
SELECT claimed_by,
|
||
COUNT(*) AS items,
|
||
MIN(lease_until) AS next_expiry
|
||
FROM bugtracker_items
|
||
WHERE claimed_by IS NOT NULL AND lease_until > UTC_TIMESTAMP()
|
||
GROUP BY claimed_by
|
||
ORDER BY items DESC
|
||
')->fetchAll() ?: [];
|
||
|
||
// Zustand des Evaluators - ohne ihn sind alle Monitor-Zustaende wertlos
|
||
$evaluatorJob = $pdo->query("SELECT * FROM watchdog_cron_jobs WHERE name = 'evaluator'")->fetch();
|
||
$evaluatorLastRun = is_array($evaluatorJob) ? ($evaluatorJob['last_run_utc'] ?? null) : null;
|
||
$evaluatorStale = $evaluatorLastRun === null
|
||
|| (time() - (int)strtotime((string)$evaluatorLastRun . ' UTC')) > 900;
|
||
|
||
// Rocket.Chat Konfiguration und Cron-Status
|
||
$rcConfig = \Deploymentcenter\Modules\Notify\RocketChatNotifier::getConfig($pdo);
|
||
$rcCronJob = $pdo->query("SELECT * FROM watchdog_cron_jobs WHERE name = 'rocketchat_status_report'")->fetch() ?: [];
|
||
|
||
$baseUrl = Http::baseUrl();
|
||
$csrfField = Csrf::field();
|
||
?>
|
||
<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Deploymentcenter - Operations Hub</title>
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Roboto+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||
<style>
|
||
:root {
|
||
--bg-dark: #0a0d14;
|
||
--bg-sidebar: rgba(255,255,255,0.015);
|
||
--bg-card: rgba(255, 255, 255, 0.045);
|
||
--bg-card-elev: rgba(255, 255, 255, 0.08);
|
||
--border-glass: rgba(255, 255, 255, 0.09);
|
||
--border-soft: rgba(255, 255, 255, 0.05);
|
||
--primary: #5b9dff;
|
||
--primary-glow: rgba(91, 157, 255, 0.4);
|
||
--success: #12D48A;
|
||
--warning: #F5A623;
|
||
--danger: #F6465D;
|
||
--text-main: #EDEFF5;
|
||
--text-muted: #8B93A7;
|
||
--sidebar-width: 240px;
|
||
--sidebar-collapsed-width: 72px;
|
||
}
|
||
|
||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||
body { font-family: 'Manrope', system-ui, sans-serif; background: radial-gradient(1100px 700px at 15% -10%, rgba(22,82,240,0.28), transparent 60%), radial-gradient(900px 600px at 110% 10%, rgba(18,212,138,0.10), transparent 55%), var(--bg-dark); color: var(--text-main); min-height: 100vh; display: flex; overflow-x: hidden; }
|
||
|
||
/* Decorative Ambient Glow */
|
||
.glow-overlay { position: fixed; inset: 0; pointer-events: none; background: radial-gradient(600px 400px at 80% 85%, rgba(76,141,255,0.08), transparent 60%); z-index: 0; }
|
||
|
||
/* Left Sidebar (Collapsible) */
|
||
.sidebar { width: var(--sidebar-width); background: rgba(10, 13, 20, 0.7); border-right: 1px solid var(--border-glass); padding: 1.25rem 0.75rem; display: flex; flex-direction: column; justify-content: space-between; position: fixed; height: 100vh; transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1); z-index: 100; backdrop-filter: blur(24px); }
|
||
.sidebar.collapsed { width: var(--sidebar-collapsed-width); }
|
||
|
||
.brand { display: flex; align-items: center; gap: 0.75rem; padding: 0.5rem 0.5rem 1.5rem 0.5rem; border-bottom: 1px solid var(--border-soft); margin-bottom: 1rem; overflow: hidden; white-space: nowrap; }
|
||
.brand-logo { min-width: 34px; height: 34px; background: linear-gradient(135deg, #1652F0, #4c8dff); border-radius: 10px; display: flex; align-items: center; justify-content: center; box-shadow: 0 0 20px rgba(22,82,240,0.6); }
|
||
.brand-logo svg { width: 20px; height: 20px; stroke: #fff; fill: none; stroke-width: 2; }
|
||
.brand-title { font-size: 1.1rem; font-weight: 800; color: #fff; letter-spacing: -0.02em; }
|
||
.sidebar.collapsed .brand-title { display: none; }
|
||
|
||
.sidebar-toggle-btn { background: var(--bg-card-elev); border: 1px solid var(--border-glass); color: var(--text-muted); width: 26px; height: 26px; border-radius: 50%; display: flex; align-items: center; justify-content: center; cursor: pointer; position: absolute; right: -13px; top: 22px; transition: transform 0.3s; z-index: 101; }
|
||
.sidebar.collapsed .sidebar-toggle-btn { transform: rotate(180deg); }
|
||
|
||
.nav-section { margin-bottom: 1.5rem; }
|
||
.nav-section-title { font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.08em; color: #5B6377; padding: 0 0.75rem 0.5rem 0.75rem; font-weight: 700; }
|
||
.sidebar.collapsed .nav-section-title { display: none; }
|
||
|
||
.nav-menu { display: flex; flex-direction: column; gap: 0.35rem; list-style: none; }
|
||
.nav-link { display: flex; align-items: center; gap: 0.85rem; padding: 0.7rem 0.85rem; color: var(--text-muted); text-decoration: none; border-radius: 10px; font-size: 0.875rem; font-weight: 600; transition: all 0.2s; cursor: pointer; white-space: nowrap; }
|
||
.nav-link:hover, .nav-link.active { background: rgba(91, 157, 255, 0.12); color: #fff; }
|
||
.nav-link.active { color: #fff; background: linear-gradient(135deg, #1652F0, #4c8dff); box-shadow: 0 4px 20px rgba(22,82,240,0.4); }
|
||
.nav-link svg { min-width: 20px; height: 20px; stroke: currentColor; fill: none; stroke-width: 2; }
|
||
.sidebar.collapsed .nav-text { display: none; }
|
||
|
||
.user-panel { border-top: 1px solid var(--border-soft); padding-top: 1rem; display: flex; align-items: center; justify-content: space-between; font-size: 0.85rem; color: var(--text-muted); overflow: hidden; white-space: nowrap; }
|
||
.sidebar.collapsed .user-name { display: none; }
|
||
|
||
/* Main Wrapper */
|
||
.main-wrapper { margin-left: var(--sidebar-width); flex: 1; transition: margin-left 0.3s cubic-bezier(0.4, 0, 0.2, 1); display: flex; flex-direction: column; min-width: 0; position: relative; z-index: 1; }
|
||
.main-wrapper.expanded { margin-left: var(--sidebar-collapsed-width); }
|
||
|
||
/* Top Bar Header with Embedded Module Sub-Navigation Top-Bar */
|
||
.top-bar { background: rgba(10, 13, 20, 0.75); border-bottom: 1px solid var(--border-glass); padding: 0.85rem 2rem; display: flex; align-items: center; justify-content: space-between; backdrop-filter: blur(24px); position: sticky; top: 0; z-index: 90; gap: 1.5rem; }
|
||
.top-left { display: flex; align-items: center; gap: 1.5rem; flex: 1; min-width: 0; }
|
||
.top-title { font-size: 1.25rem; font-weight: 800; color: #fff; letter-spacing: -0.02em; white-space: nowrap; }
|
||
|
||
/* Embedded Top-Bar Subnav */
|
||
.topbar-subnav { display: flex; align-items: center; gap: 0.4rem; overflow-x: auto; scrollbar-width: none; }
|
||
.subnav-link { background: rgba(255,255,255,0.03); border: 1px solid var(--border-glass); color: var(--text-muted); padding: 0.45rem 0.85rem; font-size: 0.8rem; font-weight: 600; border-radius: 8px; cursor: pointer; transition: all 0.2s; white-space: nowrap; display: inline-flex; align-items: center; gap: 0.4rem; }
|
||
.subnav-link:hover { color: #fff; background: rgba(255,255,255,0.08); }
|
||
.subnav-link.active { background: #5b9dff; color: #0a0d14; border-color: #5b9dff; font-weight: 700; box-shadow: 0 0 12px var(--primary-glow); }
|
||
|
||
.header-actions { display: flex; align-items: center; gap: 1rem; }
|
||
.btn-autorefresh { background: rgba(255,255,255,0.04); border: 1px solid var(--border-glass); color: var(--text-main); font-size: 0.75rem; font-weight: 700; padding: 0.5rem 0.85rem; border-radius: 8px; cursor: pointer; display: flex; align-items: center; gap: 0.5rem; transition: all 0.2s; font-family: 'Roboto Mono', monospace; }
|
||
.btn-autorefresh.active { background: rgba(18,212,138,0.15); border-color: var(--success); color: var(--success); }
|
||
|
||
/*
|
||
Die Obergrenze lag bei 1600px. Auf einem breiten Schirm blieb rechts
|
||
ein leerer Streifen, waehrend die Tabellen links zusammengedrueckt
|
||
standen - besonders im Bugtracker, wo eine Zeile acht Spalten hat.
|
||
|
||
Ganz ohne Grenze wird es allerdings unlesbar: Bei 3400px wandert das
|
||
Auge von der Item-Nummer bis zur Statusspalte quer ueber den Tisch.
|
||
2400px ist der Kompromiss - fuellt jeden ueblichen Schirm und
|
||
bremst erst darueber.
|
||
*/
|
||
/*
|
||
min-width:0 ist der unscheinbare Teil und der wichtigere.
|
||
|
||
body ist ein Flex-Container (Seitenleiste + Inhalt), .main-content
|
||
also ein Flex-Element - und die haben von Haus aus min-width:auto,
|
||
weigern sich also, unter ihre Inhaltsbreite zu schrumpfen. Folge:
|
||
Eine breite Tabelle schob den ganzen Inhaltsbereich auf, statt in
|
||
ihrem eigenen Behaelter zu scrollen, und die SEITE lief quer. Damit
|
||
wandert beim Scrollen auch die Seitenleiste aus dem Bild.
|
||
|
||
Mit min-width:0 gibt .main-content nach, und .tabelle-scroll tut,
|
||
wofuer es da ist.
|
||
*/
|
||
.main-content { padding: 1.75rem 2rem; max-width: 2400px; min-width: 0; }
|
||
|
||
/* Stats & KPI Grid */
|
||
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1.25rem; margin-bottom: 1.75rem; }
|
||
.stat-card { background: var(--bg-card); border: 1px solid var(--border-glass); border-radius: 16px; padding: 1.25rem; backdrop-filter: blur(24px); box-shadow: 0 8px 30px rgba(0,0,0,0.25); }
|
||
.stat-header { display: flex; align-items: center; justify-content: space-between; color: var(--text-muted); font-size: 0.75rem; font-weight: 600; }
|
||
.stat-value { font-size: 2rem; font-weight: 800; margin-top: 0.5rem; color: #fff; font-family: 'Roboto Mono', monospace; letter-spacing: -0.02em; }
|
||
|
||
/* Group Overview Cards Panel (Screenshot 2) */
|
||
.group-cards-grid { display: flex; flex-wrap: wrap; gap: 1rem; margin-top: 0.75rem; }
|
||
.group-card { background: rgba(255,255,255,0.03); border: 1px solid var(--border-glass); border-radius: 12px; padding: 0.85rem 1.1rem; display: flex; align-items: center; justify-content: space-between; min-width: 180px; gap: 1rem; }
|
||
.group-card-name { font-size: 0.875rem; font-weight: 700; color: #fff; }
|
||
|
||
/* Icon Grid Selector in Edit Modal (Screenshot 1) */
|
||
.icon-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.5rem; max-height: 180px; overflow-y: auto; padding-right: 4px; }
|
||
.icon-option-btn { background: rgba(255,255,255,0.03); border: 1px solid var(--border-glass); border-radius: 10px; padding: 0.55rem 0.75rem; color: var(--text-main); font-size: 0.8rem; font-weight: 600; cursor: pointer; display: flex; align-items: center; gap: 0.5rem; transition: all 0.2s; }
|
||
.icon-option-btn:hover { background: rgba(255,255,255,0.08); }
|
||
.icon-option-btn.selected { border-color: #FF7A45; background: rgba(242,98,46,0.15); color: #fff; box-shadow: 0 0 10px rgba(242,98,46,0.3); }
|
||
|
||
/* Cards & Tables */
|
||
.card { background: var(--bg-card); border: 1px solid var(--border-glass); border-radius: 18px; padding: 1.5rem; margin-bottom: 1.75rem; backdrop-filter: blur(24px); box-shadow: 0 8px 30px rgba(0,0,0,0.25); }
|
||
.card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.25rem; }
|
||
.card-title { font-size: 1.05rem; font-weight: 700; color: #fff; display: flex; align-items: center; gap: 0.5rem; }
|
||
|
||
table { width: 100%; border-collapse: collapse; text-align: left; font-size: 0.85rem; }
|
||
th { padding: 0.8rem 1rem; color: #5B6377; font-weight: 700; border-bottom: 1px solid var(--border-glass); font-size: 0.725rem; text-transform: uppercase; letter-spacing: 0.05em; }
|
||
td { padding: 0.85rem 1rem; border-bottom: 1px solid var(--border-soft); vertical-align: middle; }
|
||
tr:hover td { background: rgba(255, 255, 255, 0.02); }
|
||
|
||
/* Badges */
|
||
/*
|
||
Ein Abzeichen ist eine Beschriftung, kein Fliesstext - es bricht nie um.
|
||
white-space:nowrap fehlte, und in einer schmalen Spalte wurde aus
|
||
"FEATURE" ein "FEATUR" mit einem "E" darunter. Zusammen mit
|
||
border-radius:20px, das bei zwei Zeilen keine Pille mehr ergibt,
|
||
sondern eine Ellipse, sah das Ergebnis nach Ballon aus.
|
||
|
||
line-height fest, weil Emoji hoeher bauen als Ziffern: Ohne die
|
||
Angabe war ein Abzeichen mit Zeichen einige Pixel groesser als eines
|
||
ohne, und die Zeilen standen unruhig untereinander.
|
||
*/
|
||
.badge { padding: 0.2rem 0.6rem; border-radius: 20px; font-size: 0.725rem; font-weight: 700; display: inline-flex; align-items: center; gap: 0.35rem; font-family: 'Roboto Mono', monospace; white-space: nowrap; line-height: 1.45; min-height: 25px; box-sizing: border-box; }
|
||
.badge-up { background: rgba(18,212,138,0.12); color: var(--success); border: 1px solid rgba(18,212,138,0.3); }
|
||
.badge-warning { background: rgba(245,166,35,0.12); color: var(--warning); border: 1px solid rgba(245,166,35,0.3); }
|
||
.badge-down { background: rgba(246,70,93,0.12); color: var(--danger); border: 1px solid rgba(246,70,93,0.3); }
|
||
.badge-stopped { background: rgba(139,147,167,0.12); color: var(--text-muted); border: 1px solid rgba(139,147,167,0.3); }
|
||
|
||
/* Buttons & Form Inputs */
|
||
.btn { background: linear-gradient(135deg,#1652F0,#4c8dff); color: #fff; border: none; border-radius: 10px; padding: 0.6rem 1.1rem; font-size: 0.85rem; font-weight: 700; cursor: pointer; transition: all 0.2s; text-decoration: none; display: inline-flex; align-items: center; gap: 0.5rem; box-shadow: 0 0 15px rgba(22,82,240,0.4); }
|
||
.btn:hover { transform: translateY(-1px); box-shadow: 0 0 22px rgba(22,82,240,0.6); }
|
||
.btn-secondary { background: rgba(255,255,255,0.06); border: 1px solid var(--border-glass); color: var(--text-main); box-shadow: none; }
|
||
.btn-secondary:hover { background: rgba(255,255,255,0.12); }
|
||
.btn-sm { padding: 0.35rem 0.65rem; font-size: 0.75rem; border-radius: 6px; }
|
||
.btn-danger { background: var(--danger); box-shadow: 0 0 15px rgba(246,70,93,0.4); }
|
||
.btn-danger:hover { background: #dc2626; }
|
||
|
||
.form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1rem; margin-bottom: 1.25rem; }
|
||
.form-group { display: flex; flex-direction: column; gap: 0.35rem; }
|
||
.form-label { font-size: 0.775rem; color: var(--text-muted); font-weight: 600; }
|
||
.form-input { background: rgba(255,255,255,0.04); border: 1px solid var(--border-glass); border-radius: 10px; padding: 0.65rem 0.85rem; color: #fff; font-size: 0.875rem; outline: none; font-family: 'Manrope', system-ui; transition: border-color 0.2s; }
|
||
.form-input:focus { border-color: var(--primary); }
|
||
|
||
/* Dropdown Option List Styling Fix */
|
||
select.form-input option {
|
||
background-color: #0e121c;
|
||
color: #EDEFF5;
|
||
}
|
||
|
||
.prompt-box { background: rgba(0,0,0,0.5); border: 1px solid var(--border-glass); border-radius: 10px; padding: 1.1rem; font-family: 'Roboto Mono', monospace; font-size: 0.825rem; color: #cbd5e1; white-space: pre-wrap; word-break: break-all; margin-bottom: 0.75rem; position: relative; max-height: 380px; overflow-y: auto; }
|
||
|
||
.alert { padding: 1rem 1.25rem; border-radius: 12px; margin-bottom: 1.5rem; font-size: 0.875rem; font-weight: 600; }
|
||
.alert-success { background: rgba(18,212,138,0.15); border: 1px solid rgba(18,212,138,0.3); color: #a7f3d0; }
|
||
.alert-danger { background: rgba(246,70,93,0.15); border: 1px solid rgba(246,70,93,0.3); color: #fca5a5; }
|
||
|
||
/* Modals */
|
||
.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.75); backdrop-filter: blur(10px); display: none; align-items: center; justify-content: center; z-index: 200; }
|
||
.modal-backdrop.active { display: flex; }
|
||
.modal-card { background: #14171d; border: 1px solid rgba(255,255,255,0.12); border-radius: 18px; padding: 1.75rem; max-width: 600px; width: 95%; max-height: 90vh; overflow-y: auto; box-shadow: 0 20px 50px rgba(0,0,0,0.8); }
|
||
|
||
/*
|
||
Verweise im Inhalt.
|
||
|
||
Es gab dafuer bisher keine Regel - Links in Karten und Tabellen
|
||
erschienen also im Browservorgabeblau (#0000EE) auf beinahe
|
||
schwarzem Grund und waren schlicht nicht lesbar. Aufgefallen ist es
|
||
an den Bereichsnamen der Roadmap, betrifft aber jeden Verweis im
|
||
Inhaltsbereich.
|
||
|
||
Bewusst eng gefasst: .btn und .nav-link sind ebenfalls <a> und
|
||
bringen ihre eigene Gestaltung mit. Eine Regel auf blankes "a"
|
||
haette sie ueberschrieben.
|
||
*/
|
||
.card a:not(.btn), table a:not(.btn) {
|
||
color: var(--primary);
|
||
text-decoration: none;
|
||
border-bottom: 1px solid rgba(91,157,255,0.35);
|
||
transition: color .15s, border-color .15s;
|
||
}
|
||
.card a:not(.btn):hover, table a:not(.btn):hover {
|
||
color: #9cc4ff;
|
||
border-bottom-color: #9cc4ff;
|
||
}
|
||
|
||
/*
|
||
Spaltenkopf mit Erklärung.
|
||
|
||
Ein title= allein findet niemand - es gibt keinen Hinweis darauf,
|
||
dass da etwas zu holen wäre. Der Fragezeichen-Zeiger und die
|
||
gepunktete Linie sind die übliche Anzeige dafür und kosten nichts.
|
||
*/
|
||
.hilfe { cursor: help; border-bottom: 1px dotted rgba(255,255,255,0.28); }
|
||
|
||
/* ---------------------------------------------------------------
|
||
Breite Datentabellen (Bugtracker, Fehlerstrom)
|
||
|
||
Ohne table-layout:fixed verteilt der Browser die Breite nach
|
||
Inhalt. Eine einzige lange Exception-Meldung oder ein Dateipfad
|
||
wie "public/api/bugtracker/v1/manage/index.php:407" zog damit die
|
||
Titelspalte auf und quetschte alle anderen zusammen - je Zeile
|
||
anders, weshalb die Spalten von oben nach unten wanderten.
|
||
|
||
Mit fester Verteilung stehen die schmalen Spalten still, und die
|
||
Titelspalte bekommt, was uebrig ist. Sie muss dafuer kuerzen
|
||
koennen - siehe .zelle-titel.
|
||
--------------------------------------------------------------- */
|
||
.tabelle-scroll { overflow-x: auto; }
|
||
table.tabelle-fest { table-layout: fixed; min-width: 1280px; }
|
||
table.tabelle-fest td { overflow-wrap: anywhere; }
|
||
|
||
/*
|
||
Der Titel wird nach zwei Zeilen abgeschnitten, nicht nach einer:
|
||
Eine Exception-Meldung ist in der ersten Zeile oft nur Namensraum
|
||
und sagt noch nichts. Der vollstaendige Text haengt im title=.
|
||
*/
|
||
.zelle-titel .titel {
|
||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
|
||
overflow: hidden; color: #fff; font-weight: 700;
|
||
}
|
||
.zelle-titel .meta {
|
||
font-size: 0.75rem; color: var(--text-muted); margin-top: 0.25rem;
|
||
display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center;
|
||
overflow: hidden;
|
||
}
|
||
/* Ein langer Dateipfad darf die Zeile nicht in die Hoehe treiben. */
|
||
.zelle-titel .meta .badge { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
|
||
/*
|
||
Die Knoepfe brachen um, weil die Zelle enger wurde als ihr Inhalt -
|
||
daher das Verschobene im Screenshot: Stift oben, Lupe und Haken in
|
||
der Zeile darunter. Sie bleiben jetzt in einer Reihe, und die Spalte
|
||
ist breit genug dafuer.
|
||
*/
|
||
.zelle-aktionen { white-space: nowrap; }
|
||
.zelle-aktionen > div { display: flex; gap: 0.3rem; flex-wrap: nowrap; }
|
||
|
||
.tab-content, .subtab-content { display: none; }
|
||
|
||
/* Mehrfachauswahl im Filterbalken.
|
||
<details> statt eigenem Klappmechanismus: Auf- und Zuklappen kann der
|
||
Browser, und ohne JavaScript bleibt es bedienbar. */
|
||
.filter-mehrfach { position: relative; display: inline-block; }
|
||
.filter-mehrfach > summary {
|
||
list-style: none; cursor: pointer; white-space: nowrap;
|
||
padding: 0.3rem 0.6rem; font-size: 0.8rem;
|
||
border: 1px solid var(--border-glass, rgba(255,255,255,0.12));
|
||
border-radius: 6px; background: rgba(255,255,255,0.04);
|
||
}
|
||
.filter-mehrfach > summary::-webkit-details-marker { display: none; }
|
||
.filter-mehrfach[open] > summary { border-color: #7aa2ff; }
|
||
.filter-mehrfach-inhalt {
|
||
position: absolute; z-index: 50; top: calc(100% + 4px); left: 0;
|
||
min-width: 240px; padding: 0.6rem 0.75rem;
|
||
background: #161c28;
|
||
border: 1px solid var(--border-glass, rgba(255,255,255,0.12));
|
||
border-radius: 8px; box-shadow: 0 12px 32px rgba(0,0,0,0.5);
|
||
}
|
||
.filter-mehrfach-inhalt label {
|
||
display: flex; align-items: center; gap: 0.45rem;
|
||
padding: 0.15rem 0; font-size: 0.82rem; cursor: pointer; font-weight: 400;
|
||
}
|
||
.filter-gruppe {
|
||
font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.06em;
|
||
opacity: 0.55; margin: 0.55rem 0 0.2rem;
|
||
}
|
||
.filter-gruppe:first-child { margin-top: 0; }
|
||
.filter-mehrfach-fuss {
|
||
display: flex; gap: 0.35rem; margin-top: 0.7rem;
|
||
padding-top: 0.6rem;
|
||
border-top: 1px solid var(--border-glass, rgba(255,255,255,0.12));
|
||
}
|
||
.filter-hinweis { font-size: 0.7rem; opacity: 0.5; margin-top: 0.4rem; }
|
||
.tab-content.active, .subtab-content.active { display: block; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<!-- Glow Overlay -->
|
||
<div class="glow-overlay"></div>
|
||
|
||
<!-- Left Collapsible Sidebar -->
|
||
<aside class="sidebar" id="sidebar">
|
||
<button class="sidebar-toggle-btn" id="sidebarToggle" onclick="toggleSidebar()" title="Menü einklappen / ausklappen">
|
||
<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" fill="none" stroke-width="2"><polyline points="15 18 9 12 15 6"></polyline></svg>
|
||
</button>
|
||
|
||
<div>
|
||
<div class="brand">
|
||
<div class="brand-logo">
|
||
<svg viewBox="0 0 24 24"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"></path></svg>
|
||
</div>
|
||
<div class="brand-title">Deploymentcenter</div>
|
||
</div>
|
||
|
||
<div class="nav-section">
|
||
<div class="nav-section-title">Navigation</div>
|
||
<ul class="nav-menu">
|
||
<li class="nav-item">
|
||
<a class="nav-link active" onclick="switchMainTab('overview', this)" title="Übersicht">
|
||
<svg viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7"></rect><rect x="14" y="3" width="7" height="7"></rect><rect x="14" y="14" width="7" height="7"></rect><rect x="3" y="14" width="7" height="7"></rect></svg>
|
||
<span class="nav-text">Übersicht</span>
|
||
</a>
|
||
</li>
|
||
<li class="nav-item">
|
||
<a class="nav-link" onclick="switchMainTab('projects', this)" title="Projekte">
|
||
<svg viewBox="0 0 24 24"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>
|
||
<span class="nav-text">Projekte</span>
|
||
</a>
|
||
</li>
|
||
<li class="nav-item">
|
||
<a class="nav-link" onclick="switchMainTab('license', this)" title="Lizenzen">
|
||
<svg viewBox="0 0 24 24"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"></path></svg>
|
||
<span class="nav-text">Lizenzen</span>
|
||
</a>
|
||
</li>
|
||
<li class="nav-item">
|
||
<a class="nav-link" onclick="switchMainTab('watchdog', this)" title="WatchDog">
|
||
<svg viewBox="0 0 24 24"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path></svg>
|
||
<span class="nav-text">WatchDog</span>
|
||
</a>
|
||
</li>
|
||
<li class="nav-item">
|
||
<a class="nav-link" onclick="switchMainTab('updateservice', this)" title="UpdateService">
|
||
<svg viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>
|
||
<span class="nav-text">UpdateService</span>
|
||
</a>
|
||
</li>
|
||
<li class="nav-item">
|
||
<a class="nav-link" onclick="switchMainTab('roadmap', this)" title="Roadmap & Projektstatus">
|
||
<svg viewBox="0 0 24 24"><line x1="3" y1="6" x2="21" y2="6"></line><line x1="3" y1="12" x2="15" y2="12"></line><line x1="3" y1="18" x2="9" y2="18"></line><circle cx="18" cy="12" r="2"></circle><circle cx="12" cy="18" r="2"></circle></svg>
|
||
<span class="nav-text">Roadmap</span>
|
||
</a>
|
||
</li>
|
||
<li class="nav-item">
|
||
<a class="nav-link" onclick="switchMainTab('bugtracker', this)" title="Bug- & Feature-Tracker">
|
||
<svg viewBox="0 0 24 24"><path d="M12 2a2 2 0 0 1 2 2v1h3a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2h-1v2h2a1 1 0 0 1 0 2h-2v2h1a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2v-2a2 2 0 0 1 2-2h1v-2H6a1 1 0 0 1 0-2h2v-2H7a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h3V4a2 2 0 0 1 2-2z"></path></svg>
|
||
<span class="nav-text">Bugtracker</span>
|
||
</a>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<div class="nav-section" style="margin-bottom:1rem;">
|
||
<div class="nav-section-title">Administration</div>
|
||
<ul class="nav-menu">
|
||
<li class="nav-item">
|
||
<a class="nav-link" onclick="switchMainTab('tokens', this)" title="Master- & Sub-Tokens">
|
||
<svg viewBox="0 0 24 24"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"></path></svg>
|
||
<span class="nav-text">Token-Verwaltung</span>
|
||
</a>
|
||
</li>
|
||
<li class="nav-item">
|
||
<a class="nav-link" onclick="switchMainTab('system', this)" title="System & DB">
|
||
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
|
||
<span class="nav-text">System & DB</span>
|
||
</a>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div class="user-panel">
|
||
<span class="user-name">👤 <?= htmlspecialchars($_SESSION['dc_username'] ?? 'Admin') ?></span>
|
||
<a href="logout.php" style="color:var(--danger); text-decoration:none; font-weight:700;">Abmelden</a>
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
|
||
<!-- Main Content Wrapper -->
|
||
<div class="main-wrapper" id="mainWrapper">
|
||
|
||
<!-- Top Header Bar with Embedded Module Subnav Bar -->
|
||
<header class="top-bar">
|
||
<div class="top-left">
|
||
<div class="top-title" id="topPageTitle">Globales Dashboard</div>
|
||
|
||
<!-- Subnav Bar Embedded in Top-Bar -->
|
||
<div class="topbar-subnav" id="topbarSubnav">
|
||
<!-- Dynamic Subnav items rendered via JavaScript -->
|
||
</div>
|
||
</div>
|
||
|
||
<div class="header-actions">
|
||
<button class="btn-autorefresh" id="btnAutoRefresh" onclick="toggleAutoRefresh()">
|
||
<span id="autoRefreshIcon">⏱️</span>
|
||
<span id="autoRefreshText">30s REFRESH: AUS</span>
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
<main class="main-content">
|
||
|
||
<?php if ($msg): ?>
|
||
<?php /* Der Inhalt wird beim Erzeugen der Meldung mit e() maskiert. */ ?>
|
||
<div class="alert alert-<?= $msgType === 'danger' ? 'danger' : 'success' ?>"><?= $msg ?></div>
|
||
<?php endif; ?>
|
||
|
||
<?php if ($evaluatorStale): ?>
|
||
<!--
|
||
Ohne laufenden Evaluator aendert sich der Zustand eines Monitors
|
||
nur beim Eintreffen eines Heartbeats - ein ausgefallener Server
|
||
bliebe dauerhaft gruen. Deshalb ein deutlicher Hinweis.
|
||
-->
|
||
<div class="alert alert-danger" style="display:flex; flex-wrap:wrap; align-items:center; justify-content:space-between; gap:1rem;">
|
||
<div>
|
||
<strong>⚠️ Watchdog-Evaluator läuft nicht.</strong>
|
||
Die Monitor-Zustände sind derzeit nicht verlässlich – ein ausgefallenes System bleibt „up“, bis es sich wieder meldet.
|
||
<?php if ($evaluatorLastRun !== null): ?>
|
||
Letzter Lauf: <?= e(dc_dt((string)$evaluatorLastRun)) ?> (<?= e(dc_ago((string)$evaluatorLastRun)) ?>).
|
||
<?php else: ?>
|
||
Der Evaluator lief noch nie.
|
||
<?php endif; ?>
|
||
<div style="font-size:0.78rem; margin-top:0.5rem; font-family:'Roboto Mono',monospace; opacity:0.85;">
|
||
<!--
|
||
Der interne Aufruf steht zuerst: er braucht keinen Schluessel
|
||
im Crontab - der taucht sonst in der Prozessliste jedes
|
||
Nutzers auf - und laeuft auch dann, wenn der Webserver
|
||
gerade nicht erreichbar ist.
|
||
-->
|
||
Empfohlen (intern):<br>
|
||
* * * * * /usr/bin/php <?= e(DC_ROOT) ?>/cli/tick.php --quiet
|
||
<div style="margin-top:0.45rem; opacity:0.75;">
|
||
Alternativ über HTTP:<br>
|
||
* * * * * curl -fsS -H "Authorization: Bearer <SHARED_KEY>" <?= e($baseUrl) ?>/api/watchdog/v1/evaluate > /dev/null
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<form method="POST" action="index.php#tab-watchdog" style="flex-shrink:0;">
|
||
<?= $csrfField ?>
|
||
<input type="hidden" name="action" value="run_evaluator">
|
||
<button type="submit" class="btn btn-sm">Jetzt einmalig ausführen</button>
|
||
</form>
|
||
</div>
|
||
<?php endif; ?>
|
||
|
||
<!-- ================= MODULE 0: OVERVIEW ================= -->
|
||
<div id="tab-overview" class="tab-content active">
|
||
<div class="stats-grid">
|
||
<div class="stat-card">
|
||
<div class="stat-header">Aktive Projekte</div>
|
||
<div class="stat-value"><?= count($projects) ?></div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-header">Aktive Lizenzen</div>
|
||
<div class="stat-value"><?= count($licenses) ?></div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-header">Hardware Aktivierungen</div>
|
||
<div class="stat-value"><?= count($activations) ?></div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-header">Watchdog Monitore</div>
|
||
<div class="stat-value">
|
||
<span style="color:var(--success)"><?= $monitorsUp ?></span> /
|
||
<span style="color:var(--warning)"><?= $monitorsWarning ?></span> /
|
||
<span style="color:var(--danger)"><?= $monitorsDown ?></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-header"><h2 class="card-title">🛡️ Watchdog System-Hierarchie & Monitore</h2></div>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Hierarchie / Entity Source</th>
|
||
<th>Typ</th>
|
||
<th>Status</th>
|
||
<th>Version</th>
|
||
<th>Letzte Meldung</th>
|
||
<th>Zuletzt Gesehen</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<?php foreach ($hierarchicalMonitors as $m): ?>
|
||
<?php
|
||
$depth = (int)$m['depth'];
|
||
$indentPx = $depth * 24;
|
||
$isChild = ($depth > 0);
|
||
$iconUrl = getMonitorIconUrl($m['icon']??null, $m['source']??null, $m['os']??null, $m['type']??null);
|
||
?>
|
||
<tr>
|
||
<td>
|
||
<div style="display:flex; align-items:center; gap:8px; margin-left:<?= $indentPx ?>px; <?= $isChild ? 'border-left:2px solid var(--primary); padding-left:8px;' : '' ?>">
|
||
<?php if ($isChild): ?>
|
||
<span style="color:var(--primary); font-family:'Roboto Mono'; font-size:13px; font-weight:bold"><?= $m['is_last'] ? '└─' : '├─' ?></span>
|
||
<?php endif; ?>
|
||
<img src="<?= htmlspecialchars($iconUrl) ?>" width="18" height="18" alt="icon">
|
||
<strong><?= htmlspecialchars($m['source']) ?></strong>
|
||
<span style="color:var(--text-muted); font-size:0.75rem;">(<?= htmlspecialchars($m['instance']) ?>)</span>
|
||
</div>
|
||
</td>
|
||
<td><code><?= htmlspecialchars($m['type']) ?></code></td>
|
||
<td>
|
||
<span class="badge badge-<?= $m['state'] === 'up' ? 'up' : ($m['state'] === 'warning' ? 'warning' : 'down') ?>">
|
||
● <?= strtoupper($m['state']) ?>
|
||
</span>
|
||
</td>
|
||
<td>
|
||
<?php if (!empty($m['app_version'])): ?>
|
||
<code><?= htmlspecialchars((string)$m['app_version']) ?></code>
|
||
<?php else: ?>
|
||
<span style="color:var(--text-muted);">–</span>
|
||
<?php endif; ?>
|
||
</td>
|
||
<td><?= htmlspecialchars($m['last_message'] ?? '-') ?></td>
|
||
<td><?= htmlspecialchars($m['last_seen_utc'] ?? 'Nie') ?></td>
|
||
</tr>
|
||
<?php endforeach; ?>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ================= MODULE 1: PROJEKTE ================= -->
|
||
<div id="tab-projects" class="tab-content">
|
||
<!-- Einheitlich wie alle anderen Module: der Inhalt liegt in einer
|
||
benannten Unteransicht. Zuvor stand er direkt im Modul, wodurch
|
||
der Navigationseintrag "Projekte Uebersicht" ins Leere zeigte. -->
|
||
<div id="sub-projects-list" class="subtab-content active">
|
||
<div class="card">
|
||
<div class="card-header"><h2 class="card-title">📁 Neues Projekt anlegen / Bearbeiten</h2></div>
|
||
<form method="POST" action="index.php#tab-projects">
|
||
<?= $csrfField ?>
|
||
<input type="hidden" name="action" value="save_project">
|
||
<input type="hidden" name="project_id" id="projEditId" value="0">
|
||
<div class="form-grid">
|
||
<div class="form-group">
|
||
<label class="form-label">Projekt-Slug (z. B. polytrader - unveränderlich)</label>
|
||
<input type="text" name="slug" id="projEditSlug" class="form-input" required placeholder="polytrader">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Projekt Name</label>
|
||
<input type="text" name="name" id="projEditName" class="form-input" required placeholder="PolyTrader Suite Pro">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Offline Grace Cache TTL (Stunden)</label>
|
||
<input type="number" name="ttl" id="projEditTtl" class="form-input" value="168">
|
||
</div>
|
||
</div>
|
||
<div class="form-group" style="margin-bottom:1rem;">
|
||
<label class="form-label">Projekt Beschreibung / Notizen</label>
|
||
<input type="text" name="notes" id="projEditNotes" class="form-input" placeholder="Trading & Handelssystem Platform">
|
||
</div>
|
||
<div style="display:flex; gap:0.5rem;">
|
||
<button type="submit" class="btn" id="projSubmitBtn">Projekt Anlegen</button>
|
||
<button type="button" class="btn btn-secondary" onclick="resetProjectForm()">Formular Zurücksetzen</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-header"><h2 class="card-title">Projekte Übersicht & Verwaltung</h2></div>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>ID</th>
|
||
<th>Slug</th>
|
||
<th>Projekt Name</th>
|
||
<th>Cache TTL</th>
|
||
<th>Verknüpfte Lizenzen</th>
|
||
<th>Verknüpfte Releases</th>
|
||
<th>Aktionen</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<?php foreach ($projects as $p): ?>
|
||
<?php
|
||
$licCount = count(array_filter($licenses, fn($l) => $l['product_slug'] === $p['slug']));
|
||
$relCount = count(array_filter($releases, fn($r) => $r['product_slug'] === $p['slug']));
|
||
?>
|
||
<tr>
|
||
<td><?= $p['id'] ?></td>
|
||
<td><code style="font-weight:700; color:var(--primary);"><?= htmlspecialchars($p['slug']) ?></code></td>
|
||
<td><strong><?= htmlspecialchars($p['name']) ?></strong></td>
|
||
<td><?= $p['default_cache_ttl_hours'] ?> h (<?= round($p['default_cache_ttl_hours']/24, 1) ?> Tage)</td>
|
||
<td><span class="badge badge-up"><?= $licCount ?> Lizenzen</span></td>
|
||
<td><span class="badge badge-up"><?= $relCount ?> Releases</span></td>
|
||
<td style="display:flex; gap:0.35rem;">
|
||
<button class="btn btn-sm btn-secondary" onclick="editProject(<?= htmlspecialchars(json_encode($p)) ?>)">✏️ Bearbeiten</button>
|
||
<button class="btn btn-sm btn-danger" onclick="openDeleteProjectModal(<?= $p['id'] ?>, '<?= htmlspecialchars($p['slug']) ?>', '<?= htmlspecialchars($p['name']) ?>')">🗑️ Löschen</button>
|
||
</td>
|
||
</tr>
|
||
<?php endforeach; ?>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Delete Project Safety Modal -->
|
||
<div class="modal-backdrop" id="deleteProjectModal">
|
||
<div class="modal-card">
|
||
<h2 style="color:var(--danger); margin-bottom:0.75rem;">⚠️ Projekt unwiderruflich löschen</h2>
|
||
<p style="color:var(--text-muted); font-size:0.875rem; margin-bottom:1rem;">
|
||
Sind Sie sicher, dass Sie das Projekt <strong id="deleteProjNameDisplay" style="color:#fff;"></strong> löschen möchten?
|
||
</p>
|
||
<p style="color:var(--danger); font-size:0.8rem; font-weight:700; margin-bottom:1rem;">
|
||
Bitte geben Sie zur Sicherheitsbestätigung den Projekt-Slug ein: <code id="deleteProjSlugDisplay"></code>
|
||
</p>
|
||
<form method="POST" action="index.php#tab-projects">
|
||
<?= $csrfField ?>
|
||
<input type="hidden" name="action" value="delete_project">
|
||
<input type="hidden" name="project_id" id="deleteProjId">
|
||
<input type="text" name="confirm_slug" id="deleteProjConfirmInput" class="form-input" style="margin-bottom:1.25rem;" placeholder="Slug hier eintippen" required>
|
||
<div style="display:flex; justify-content:flex-end; gap:0.75rem;">
|
||
<button type="button" class="btn btn-secondary" onclick="closeDeleteProjectModal()">Abbrechen</button>
|
||
<button type="submit" class="btn btn-danger">Endgültig Löschen</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Rich Monitor Edit Modal (Matching Screenshot 1: Monitor & Icon bearbeiten) -->
|
||
<div class="modal-backdrop" id="editMonitorModal">
|
||
<div class="modal-card">
|
||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:1.25rem;">
|
||
<h2 style="font-size:1.15rem; font-weight:800; color:#fff; display:flex; align-items:center; gap:0.5rem;">✏️ Monitor & Icon bearbeiten</h2>
|
||
<button type="button" onclick="closeEditMonitorModal()" style="background:none; border:none; color:var(--text-muted); font-size:1.25rem; cursor:pointer;">×</button>
|
||
</div>
|
||
|
||
<form method="POST" action="index.php#sub-watchdog-hierarchy" enctype="multipart/form-data">
|
||
<?= $csrfField ?>
|
||
<input type="hidden" name="action" value="edit_watchdog_monitor">
|
||
<input type="hidden" name="old_source" id="monEditOldSource">
|
||
<input type="hidden" name="icon" id="monEditIconSelected" value="">
|
||
|
||
<div class="form-group" style="margin-bottom:1rem;">
|
||
<label class="form-label">Monitor Name (source)</label>
|
||
<input type="text" name="new_source" id="monEditNewSource" class="form-input" required placeholder="ClawdDotNet Test">
|
||
</div>
|
||
|
||
<!-- Icon Grid Selector (Visual Preview) -->
|
||
<div class="form-group" style="margin-bottom:1rem;">
|
||
<label class="form-label">Icon wählen (Visuelle Vorschau):</label>
|
||
<div class="icon-grid">
|
||
<button type="button" class="icon-option-btn selected" data-icon="" onclick="selectIcon(this, '')">🪄 Auto-Detect</button>
|
||
<button type="button" class="icon-option-btn" data-icon="assets/icons/proxmox.svg" onclick="selectIcon(this, 'assets/icons/proxmox.svg')"><img src="assets/icons/proxmox.svg" width="16" height="16"> Proxmox</button>
|
||
<button type="button" class="icon-option-btn" data-icon="assets/icons/windows.svg" onclick="selectIcon(this, 'assets/icons/windows.svg')"><img src="assets/icons/windows.svg" width="16" height="16"> Windows</button>
|
||
<button type="button" class="icon-option-btn" data-icon="assets/icons/linux.svg" onclick="selectIcon(this, 'assets/icons/linux.svg')"><img src="assets/icons/linux.svg" width="16" height="16"> Linux</button>
|
||
<button type="button" class="icon-option-btn" data-icon="assets/icons/mysql.svg" onclick="selectIcon(this, 'assets/icons/mysql.svg')"><img src="assets/icons/mysql.svg" width="16" height="16"> MySQL</button>
|
||
<button type="button" class="icon-option-btn" data-icon="assets/icons/docker.svg" onclick="selectIcon(this, 'assets/icons/docker.svg')"><img src="assets/icons/docker.svg" width="16" height="16"> Docker</button>
|
||
<button type="button" class="icon-option-btn" data-icon="assets/icons/nginx.svg" onclick="selectIcon(this, 'assets/icons/nginx.svg')"><img src="assets/icons/nginx.svg" width="16" height="16"> Nginx</button>
|
||
<button type="button" class="icon-option-btn" data-icon="assets/icons/redis.svg" onclick="selectIcon(this, 'assets/icons/redis.svg')"><img src="assets/icons/redis.svg" width="16" height="16"> Redis</button>
|
||
<button type="button" class="icon-option-btn" data-icon="assets/icons/python.svg" onclick="selectIcon(this, 'assets/icons/python.svg')"><img src="assets/icons/python.svg" width="16" height="16"> Python</button>
|
||
<button type="button" class="icon-option-btn" data-icon="assets/icons/php.svg" onclick="selectIcon(this, 'assets/icons/php.svg')"><img src="assets/icons/php.svg" width="16" height="16"> PHP</button>
|
||
<button type="button" class="icon-option-btn" data-icon="assets/icons/node.svg" onclick="selectIcon(this, 'assets/icons/node.svg')"><img src="assets/icons/node.svg" width="16" height="16"> Node.js</button>
|
||
<button type="button" class="icon-option-btn" data-icon="assets/icons/server.svg" onclick="selectIcon(this, 'assets/icons/server.svg')"><img src="assets/icons/server.svg" width="16" height="16"> Generischer</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group" style="margin-bottom:1rem;">
|
||
<label class="form-label">Oder eigenes Icon hochladen (SVG, PNG, JPG, WEBP)</label>
|
||
<input type="file" name="custom_icon_file" class="form-input" accept=".svg,.png,.jpg,.jpeg,.webp">
|
||
</div>
|
||
|
||
<div class="form-group" style="margin-bottom:1rem;">
|
||
<label class="form-label">Web-Backend URL (z. B. https://pve.example.com:8006)</label>
|
||
<input type="url" name="url" id="monEditUrl" class="form-input" placeholder="https://...">
|
||
</div>
|
||
|
||
<div class="form-group" style="margin-bottom:1rem;">
|
||
<label class="form-label">Typ</label>
|
||
<select name="type" id="monEditType" class="form-input">
|
||
<option value="host">Host / Server (Windows / Linux)</option>
|
||
<option value="heartbeat">Dienst / Worker (Heartbeat)</option>
|
||
<option value="hypervisor_node">Hypervisor Node (Proxmox)</option>
|
||
<option value="guest">Guest VM / Container</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="form-group" style="margin-bottom:1rem;">
|
||
<label class="form-label">Übergeordneter Host / Hypervisor (Parent)</label>
|
||
<select name="parent_source" id="monEditParent" class="form-input">
|
||
<option value="">-- Keine (Top-Level) --</option>
|
||
<?php foreach ($monitors as $pm): ?>
|
||
<?php if ($pm['type'] !== 'heartbeat'): ?>
|
||
<option value="<?= e($pm['source']) ?>"><?= e($pm['source']) ?> (<?= e($pm['type']) ?>)</option>
|
||
<?php endif; ?>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
</div>
|
||
|
||
<!--
|
||
Dieses Feld fehlte im Bearbeiten-Dialog vollstaendig.
|
||
Da der Handler trotzdem $_POST['os'] auslas und den
|
||
leeren Wert schrieb, loeschte jedes Speichern das
|
||
Betriebssystem des Monitors - und damit auch die
|
||
automatische Icon-Erkennung.
|
||
-->
|
||
<div class="form-group" style="margin-bottom:1rem;">
|
||
<label class="form-label">Betriebssystem / Plattform</label>
|
||
<input type="text" name="os" id="monEditOs" class="form-input"
|
||
list="osSuggestions" placeholder="z. B. Ubuntu 24.04 LTS">
|
||
<datalist id="osSuggestions">
|
||
<option value="Windows Server 2022"></option>
|
||
<option value="Windows 11"></option>
|
||
<option value="Ubuntu 24.04 LTS"></option>
|
||
<option value="Debian 12"></option>
|
||
<option value="Proxmox VE 8"></option>
|
||
<option value=".NET 8 Service"></option>
|
||
<option value="PHP 8.3"></option>
|
||
<option value="Python Service"></option>
|
||
<option value="Docker Container"></option>
|
||
</datalist>
|
||
</div>
|
||
|
||
<div class="form-grid">
|
||
<div class="form-group">
|
||
<label class="form-label">Gruppe</label>
|
||
<input type="text" name="group_key" id="monEditGroup" class="form-input" placeholder="Applications">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Erwartetes Intervall (Sekunden)</label>
|
||
<input type="number" name="expected_interval_sec" id="monEditInterval" class="form-input" value="60">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group" style="margin-bottom:1rem;">
|
||
<label class="form-label">Notizen / Dokumentation</label>
|
||
<textarea name="notes" id="monEditNotes" class="form-input" rows="3" placeholder="Zusätzliche Notizen, IPs, Ports oder Ansprechpartner..."></textarea>
|
||
</div>
|
||
|
||
<div style="background:rgba(255,255,255,0.03); border:1px solid var(--border-glass); border-radius:12px; padding:0.85rem 1rem; margin-bottom:1.25rem; display:flex; gap:0.75rem; align-items:flex-start;">
|
||
<input type="checkbox" name="is_muted" id="monEditMuted" style="margin-top:3px;">
|
||
<div>
|
||
<label for="monEditMuted" style="font-weight:700; font-size:0.85rem; color:#fff; cursor:pointer;">🔕 Warnungen & Alarme stummschalten (Dev-Instanz / Wartung)</label>
|
||
<div style="font-size:0.75rem; color:var(--text-muted); margin-top:2px;">Aktivieren für Dev- oder Test-Systeme. Der Monitor bleibt sichtbar, sendet aber keine Telegram-/Matrix-Alarme bei Ausfällen.</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div style="display:flex; justify-content:space-between; align-items:center;">
|
||
<button type="button" class="btn btn-danger btn-sm" onclick="triggerDeleteMonitorFromModal()">🗑️ Monitor Löschen</button>
|
||
<div style="display:flex; gap:0.5rem;">
|
||
<button type="button" class="btn btn-secondary" onclick="closeEditMonitorModal()">Abbrechen</button>
|
||
<button type="submit" class="btn" style="background:#F2622E;">Speichern</button>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Hidden delete form for modal trigger -->
|
||
<form id="hiddenDeleteMonitorForm" method="POST" action="index.php#sub-watchdog-hierarchy" style="display:none">
|
||
<?= $csrfField ?>
|
||
<input type="hidden" name="action" value="delete_watchdog_monitor">
|
||
<input type="hidden" name="source" id="hiddenDeleteSource">
|
||
</form>
|
||
|
||
<!-- ================= MODULE 2: LIZENZEN ================= -->
|
||
<!--
|
||
Zusammengelegt: "Dashboard", "Lizenzverwaltung", "Lizenz Bearbeiten & HW"
|
||
und "Settings" waren vier Unteransichten fuer eine Aufgabe. Das
|
||
Dashboard zeigte drei Zahlen, Settings zwei fest verdrahtete URLs,
|
||
und zum Bearbeiten sprang man in eine eigene Ansicht mit einer
|
||
Auswahlliste, in der man die gerade angeklickte Lizenz erneut
|
||
suchen musste. Jetzt eine Ansicht mit einem Bearbeiten-Dialog.
|
||
-->
|
||
<div id="tab-license" class="tab-content">
|
||
|
||
<div id="sub-license-licenses" class="subtab-content active">
|
||
<?php
|
||
$activeLicenses = count(array_filter($licenses, static fn($l): bool => $l['status'] === 'active'));
|
||
$blockedActivations = count(array_filter($activations, static fn($a): bool => !empty($a['is_blocked'])));
|
||
?>
|
||
<div class="stats-grid" style="grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));">
|
||
<div class="stat-card">
|
||
<div class="stat-header">Lizenzen gesamt</div>
|
||
<div class="stat-value"><?= count($licenses) ?></div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-header">davon aktiv</div>
|
||
<div class="stat-value" style="color:var(--success);"><?= $activeLicenses ?></div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-header">Aktivierte Systeme</div>
|
||
<div class="stat-value"><?= count($activations) ?></div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-header">Gesperrte Hardware</div>
|
||
<div class="stat-value" style="color:<?= $blockedActivations > 0 ? 'var(--danger)' : 'var(--text-muted)' ?>;"><?= $blockedActivations ?></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-header"><h2 class="card-title">🔑 Neuen Lizenzschlüssel generieren</h2></div>
|
||
<form method="POST" action="index.php#sub-license-licenses">
|
||
<?= $csrfField ?>
|
||
<input type="hidden" name="action" value="create_license">
|
||
<div class="form-grid">
|
||
<div class="form-group">
|
||
<label class="form-label">Projekt</label>
|
||
<select name="product_id" class="form-input" required>
|
||
<?php foreach ($projects as $p): ?>
|
||
<option value="<?= (int)$p['id'] ?>"><?= e($p['name']) ?> (<?= e($p['slug']) ?>)</option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Kunden-Name</label>
|
||
<input type="text" name="customer_name" class="form-input" placeholder="Musterfirma GmbH">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Kunden-E-Mail</label>
|
||
<input type="email" name="customer_email" class="form-input" placeholder="kunde@example.com">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Max. Aktivierungen</label>
|
||
<input type="number" name="max_activations" class="form-input" value="2" min="1">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Ablaufdatum (leer = unbegrenzt)</label>
|
||
<input type="date" name="expires_at" class="form-input">
|
||
</div>
|
||
</div>
|
||
<button type="submit" class="btn">Lizenz generieren</button>
|
||
</form>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-header"><h2 class="card-title">Erstellte Lizenzschlüssel</h2></div>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Projekt</th>
|
||
<th>Lizenzschlüssel</th>
|
||
<th>Kunde</th>
|
||
<th>Aktivierungen</th>
|
||
<th>Status</th>
|
||
<th>Ablaufdatum</th>
|
||
<th>Aktionen</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<?php if ($licenses === []): ?>
|
||
<tr>
|
||
<td colspan="7" style="text-align:center; padding:2rem; color:var(--text-muted);">
|
||
Noch keine Lizenzen angelegt.
|
||
</td>
|
||
</tr>
|
||
<?php endif; ?>
|
||
|
||
<?php foreach ($licenses as $l): ?>
|
||
<?php
|
||
$used = (int)$l['active_count'];
|
||
$max = (int)$l['max_activations'];
|
||
$full = $max > 0 && $used >= $max;
|
||
$licJson = e((string)json_encode($l, JSON_INVALID_UTF8_SUBSTITUTE));
|
||
?>
|
||
<tr>
|
||
<td><?= e($l['product_name']) ?></td>
|
||
<td><code style="font-size:0.85rem; font-weight:bold; color:var(--primary);"><?= e($l['license_key']) ?></code></td>
|
||
<td>
|
||
<?= e($l['customer_name'] ?? '—') ?>
|
||
<?php if (!empty($l['customer_email'])): ?>
|
||
<div style="font-size:0.72rem; color:var(--text-muted);"><?= e($l['customer_email']) ?></div>
|
||
<?php endif; ?>
|
||
</td>
|
||
<td>
|
||
<span class="badge <?= $full ? 'badge-warning' : 'badge-stopped' ?>">
|
||
<?= $used ?> / <?= $max ?>
|
||
</span>
|
||
</td>
|
||
<td>
|
||
<span class="badge badge-<?= $l['status'] === 'active' ? 'up' : 'down' ?>">
|
||
<?= e(strtoupper((string)$l['status'])) ?>
|
||
</span>
|
||
</td>
|
||
<td style="font-size:0.8rem;">
|
||
<?= !empty($l['expires_at']) ? e(dc_dt((string)$l['expires_at'], 'd.m.Y')) : 'Unbefristet' ?>
|
||
</td>
|
||
<td>
|
||
<div style="display:flex; gap:0.35rem; flex-wrap:wrap;">
|
||
<button type="button" class="btn btn-sm btn-secondary"
|
||
onclick="openEditLicense(<?= $licJson ?>)">✏️ Bearbeiten</button>
|
||
<a href="index.php?action=download_lic&id=<?= (int)$l['id'] ?>"
|
||
class="btn btn-sm btn-secondary" title="Signierte Offline-Lizenzdatei herunterladen">📥 .lic</a>
|
||
<form method="POST" action="index.php#sub-license-licenses" style="display:inline">
|
||
<?= $csrfField ?>
|
||
<input type="hidden" name="action" value="toggle_license_status">
|
||
<input type="hidden" name="license_id" value="<?= (int)$l['id'] ?>">
|
||
<input type="hidden" name="new_status" value="<?= $l['status'] === 'active' ? 'revoked' : 'active' ?>">
|
||
<button type="submit" class="btn btn-sm <?= $l['status'] === 'active' ? 'btn-danger' : 'btn' ?>">
|
||
<?= $l['status'] === 'active' ? 'Widerrufen' : 'Aktivieren' ?>
|
||
</button>
|
||
</form>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
<?php endforeach; ?>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h2 class="card-title">🖥️ Aktivierte Hardware</h2>
|
||
</div>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Projekt</th>
|
||
<th>Lizenzschlüssel</th>
|
||
<th>Hardware-ID / Quelle</th>
|
||
<th>Plattform</th>
|
||
<th>Hostname</th>
|
||
<th>Zuletzt gesehen</th>
|
||
<th>Status</th>
|
||
<th>Aktionen</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<?php if ($activations === []): ?>
|
||
<tr>
|
||
<td colspan="8" style="text-align:center; padding:2rem; color:var(--text-muted);">
|
||
Noch keine Aktivierungen.
|
||
</td>
|
||
</tr>
|
||
<?php endif; ?>
|
||
|
||
<?php foreach ($activations as $a): ?>
|
||
<tr>
|
||
<td><?= e($a['product_name']) ?></td>
|
||
<td><code style="font-size:0.78rem;"><?= e($a['license_key']) ?></code></td>
|
||
<td>
|
||
<code style="font-size:0.72rem; word-break:break-all;"><?= e($a['hardware_id']) ?></code>
|
||
<?php if (!empty($a['hwid_source'])): ?>
|
||
<div style="font-size:0.7rem; color:var(--text-muted);">Quelle: <?= e($a['hwid_source']) ?></div>
|
||
<?php endif; ?>
|
||
</td>
|
||
<td>
|
||
<span class="badge badge-stopped">
|
||
<?= e(strtoupper((string)($a['platform'] ?? 'WIN'))) ?> · v<?= (int)($a['hwid_version'] ?? 1) ?>
|
||
</span>
|
||
</td>
|
||
<td style="font-size:0.8rem;"><?= e($a['hostname'] ?? '—') ?></td>
|
||
<td style="font-size:0.78rem; color:var(--text-muted);" title="<?= e(dc_dt($a['last_seen'] ?? null, 'd.m.Y H:i:s')) ?>">
|
||
<?= e(dc_ago($a['last_seen'] ?? null)) ?>
|
||
</td>
|
||
<td>
|
||
<span class="badge badge-<?= !empty($a['is_blocked']) ? 'down' : 'up' ?>">
|
||
<?= !empty($a['is_blocked']) ? 'GESPERRT' : 'AKTIV' ?>
|
||
</span>
|
||
</td>
|
||
<td>
|
||
<div style="display:flex; gap:0.35rem; flex-wrap:wrap;">
|
||
<form method="POST" action="index.php#sub-license-licenses" style="display:inline">
|
||
<?= $csrfField ?>
|
||
<input type="hidden" name="action" value="toggle_block_activation">
|
||
<input type="hidden" name="activation_id" value="<?= (int)$a['id'] ?>">
|
||
<input type="hidden" name="block_state" value="<?= !empty($a['is_blocked']) ? '0' : '1' ?>">
|
||
<button type="submit" class="btn btn-sm <?= !empty($a['is_blocked']) ? 'btn' : 'btn-danger' ?>">
|
||
<?= !empty($a['is_blocked']) ? 'Entsperren' : 'Sperren' ?>
|
||
</button>
|
||
</form>
|
||
<form method="POST" action="index.php#sub-license-licenses" style="display:inline"
|
||
onsubmit="return confirm('Aktivierung wirklich freigeben? Der Platz wird für ein anderes System frei.');">
|
||
<?= $csrfField ?>
|
||
<input type="hidden" name="action" value="release_activation">
|
||
<input type="hidden" name="activation_id" value="<?= (int)$a['id'] ?>">
|
||
<button type="submit" class="btn btn-sm btn-secondary">Freigeben</button>
|
||
</form>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
<?php endforeach; ?>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Subtab: Offline Licenses -->
|
||
<div id="sub-license-offline" class="subtab-content">
|
||
<div class="card">
|
||
<div class="card-header"><h2 class="card-title">💾 Offline-Lizenzdatei (.lic)</h2></div>
|
||
<p style="color:var(--text-muted); font-size:0.875rem; margin-bottom:1rem; max-width:70ch;">
|
||
Für Systeme ohne Internetzugang. Die Datei wird serverseitig erzeugt und mit
|
||
HMAC-SHA256 über den Schlüssel aus <code>security.license_key</code> signiert.
|
||
Am schnellsten geht es über den 📥 .lic-Knopf direkt in der Lizenzliste.
|
||
</p>
|
||
<div class="form-grid">
|
||
<div class="form-group">
|
||
<label class="form-label">Lizenz auswählen</label>
|
||
<select id="offlineLicSelect" class="form-input">
|
||
<?php foreach ($licenses as $l): ?>
|
||
<option value="<?= e($l['license_key']) ?>"><?= e($l['license_key']) ?> (<?= e($l['customer_name'] ?? 'ohne Kundenangabe') ?>)</option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Ziel-Hardware-ID (optional)</label>
|
||
<input type="text" id="offlineHwId" class="form-input" placeholder="2:win:a765bd47...">
|
||
</div>
|
||
</div>
|
||
<button class="btn" onclick="generateOfflinePayload()">Vorschau erzeugen</button>
|
||
|
||
<div style="margin-top:1.5rem;">
|
||
<label class="form-label">Vorschau (unsigniert)</label>
|
||
<div class="prompt-box" id="offlineResultBox">Lizenz auswählen und auf „Vorschau erzeugen“ klicken.</div>
|
||
<p style="color:var(--text-muted); font-size:0.78rem; margin-top:0.5rem;">
|
||
Diese Vorschau dient nur der Ansicht. Die gültige, signierte Datei liefert
|
||
der 📥 .lic-Knopf in der Lizenzliste.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Subtab: Audit Log -->
|
||
<div id="sub-license-audit" class="subtab-content">
|
||
<div class="card">
|
||
<div class="card-header"><h2 class="card-title">📜 Audit-Protokoll</h2></div>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>ID</th>
|
||
<th>Zeitpunkt</th>
|
||
<th>Akteur</th>
|
||
<th>Aktion</th>
|
||
<th>Details</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<?php if ($auditLogs === []): ?>
|
||
<tr><td colspan="5" style="text-align:center; padding:2rem; color:var(--text-muted);">Noch keine Einträge.</td></tr>
|
||
<?php endif; ?>
|
||
<?php foreach ($auditLogs as $log): ?>
|
||
<tr>
|
||
<td><?= (int)$log['id'] ?></td>
|
||
<td style="font-size:0.8rem;" title="<?= e((string)$log['created_at']) ?> UTC"><?= e(dc_dt((string)$log['created_at'])) ?></td>
|
||
<td><code style="font-size:0.78rem;"><?= e($log['actor']) ?></code></td>
|
||
<td><strong><?= e($log['action']) ?></strong></td>
|
||
<td><code style="font-size:0.75rem; word-break:break-all;"><?= e($log['details'] ?? '—') ?></code></td>
|
||
</tr>
|
||
<?php endforeach; ?>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Subtab: Integration -->
|
||
<div id="sub-license-integration" class="subtab-content">
|
||
<div class="card">
|
||
<div class="card-header"><h2 class="card-title">💻 Endpunkte & Anbindung</h2></div>
|
||
<div class="form-grid">
|
||
<div class="form-group">
|
||
<label class="form-label">Prüfung (öffentlich, ohne Token)</label>
|
||
<input type="text" readonly class="form-input" value="<?= e($baseUrl) ?>/api/license/v1/validate">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Deaktivierung (authentifiziert)</label>
|
||
<input type="text" readonly class="form-input" value="<?= e($baseUrl) ?>/api/license/v1/deactivate">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="alert" style="background:rgba(91,157,255,0.10); border:1px solid rgba(91,157,255,0.25); color:#bfdbfe; font-weight:500; font-size:0.82rem; margin-top:1.25rem;">
|
||
Die Antwort von <code>/validate</code> trägt den Lizenzzustand im Feld
|
||
<code>status</code> auf oberster Ebene (<code>valid</code>, <code>revoked</code>,
|
||
<code>expired</code>, <code>not_found</code>, <code>activation_limit</code>) —
|
||
bewusst ohne den Umschlag der übrigen API, weil bereits ausgelieferte Clients
|
||
dieses Format erwarten.
|
||
</div>
|
||
|
||
<div class="prompt-box" style="margin-top:1rem;">POST <?= e($baseUrl) ?>/api/license/v1/validate
|
||
Content-Type: application/json
|
||
|
||
{
|
||
"product": "myapp",
|
||
"license_key": "XXXXX-XXXXX-XXXXX-XXXXX-XXXXX",
|
||
"hardware_id": "2:win:a765bd47...",
|
||
"hostname": "DESKTOP-01",
|
||
"app_version": "v1.4.2",
|
||
"nonce": "zufallswert"
|
||
}
|
||
|
||
Antwort:
|
||
{ "type": "validation_result", "status": "valid",
|
||
"expires_at": 1786435199, "cache_ttl_hours": 168,
|
||
"message": "License is valid" }</div>
|
||
|
||
<p style="color:var(--text-muted); font-size:0.82rem; margin-top:1rem;">
|
||
Fertige Anbindung für .NET: <code>Deploymentcenter.Client</code>
|
||
(<code>LicenseClient</code>, <code>HardwareId</code>, verschlüsselter Offline-Cache).
|
||
Ausführlich in <code>docs/LICENSE_INTEGRATION_GUIDE.md</code>.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ================= MODULE 3: WATCHDOG ================= -->
|
||
<div id="tab-watchdog" class="tab-content">
|
||
|
||
<!-- Subtab: Watchdog Dashboard -->
|
||
<div id="sub-watchdog-dashboard" class="subtab-content active">
|
||
<div class="stats-grid">
|
||
<div class="stat-card">
|
||
<div class="stat-header">Gesund / Up</div>
|
||
<div class="stat-value" style="color:var(--success);"><?= $monitorsUp ?></div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-header">Warnungen</div>
|
||
<div class="stat-value" style="color:var(--warning);"><?= $monitorsWarning ?></div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-header">Down / Kritisch</div>
|
||
<div class="stat-value" style="color:var(--danger);"><?= $monitorsDown ?></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Gruppen-Übersicht Panel (Matching Screenshot 2) -->
|
||
<div class="card">
|
||
<div class="card-header"><h2 class="card-title">📁 Gruppen-Übersicht</h2></div>
|
||
<div class="group-cards-grid">
|
||
<?php foreach ($groupStats as $gName => $gData): ?>
|
||
<?php
|
||
$isAllOk = ($gData['ok'] === $gData['total']);
|
||
$pillClass = $isAllOk ? 'badge-up' : 'badge-down';
|
||
?>
|
||
<div class="group-card">
|
||
<span class="group-card-name"><?= htmlspecialchars($gName) ?></span>
|
||
<span class="badge <?= $pillClass ?>"><?= $gData['ok'] ?>/<?= $gData['total'] ?> OK</span>
|
||
</div>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Subtab: Watchdog Hierarchie & Tree View (Exact requested columns & Edit modal button) -->
|
||
<div id="sub-watchdog-hierarchy" class="subtab-content">
|
||
<div class="card">
|
||
<div class="card-header"><h2 class="card-title">🌳 Watchdog System-Hierarchie</h2></div>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Name</th>
|
||
<th>Typ</th>
|
||
<th>Status</th>
|
||
<th>Version</th>
|
||
<th>Zuletzt gesehen</th>
|
||
<th>Kurz-Info / Message</th>
|
||
<th>Aktionen</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<?php foreach ($hierarchicalMonitors as $m): ?>
|
||
<?php
|
||
$depth = (int)$m['depth'];
|
||
$indentPx = $depth * 24;
|
||
$isChild = ($depth > 0);
|
||
$iconUrl = getMonitorIconUrl($m['icon']??null, $m['source']??null, $m['os']??null, $m['type']??null);
|
||
?>
|
||
<tr>
|
||
<td>
|
||
<div style="display:flex; align-items:center; gap:8px; margin-left:<?= $indentPx ?>px; <?= $isChild ? 'border-left:2px solid var(--primary); padding-left:8px;' : '' ?>">
|
||
<?php if ($isChild): ?>
|
||
<span style="color:var(--primary); font-family:'Roboto Mono'; font-size:13px; font-weight:bold"><?= $m['is_last'] ? '└─' : '├─' ?></span>
|
||
<?php endif; ?>
|
||
<img src="<?= htmlspecialchars($iconUrl) ?>" width="18" height="18" alt="icon">
|
||
<strong><?= htmlspecialchars($m['source']) ?></strong>
|
||
</div>
|
||
</td>
|
||
<td><code><?= htmlspecialchars($m['type']) ?></code></td>
|
||
<td><span class="badge badge-<?= $m['state'] === 'up' ? 'up' : 'down' ?>"><?= strtoupper($m['state']) ?></span></td>
|
||
<td>
|
||
<?php if (!empty($m['app_version'])): ?>
|
||
<code><?= htmlspecialchars((string)$m['app_version']) ?></code>
|
||
<?php else: ?>
|
||
<span style="color:var(--text-muted);">–</span>
|
||
<?php endif; ?>
|
||
</td>
|
||
<td><?= htmlspecialchars($m['last_seen_utc'] ?? 'Nie') ?></td>
|
||
<td><span style="color:var(--text-muted); font-size:0.8rem;"><?= htmlspecialchars($m['last_message'] ?? '-') ?></span></td>
|
||
<td style="display:flex; gap:0.35rem;">
|
||
<button class="btn btn-sm btn-secondary" onclick="openEditMonitorModal(<?= htmlspecialchars(json_encode($m)) ?>)">✏️ Bearbeiten</button>
|
||
</td>
|
||
</tr>
|
||
<?php endforeach; ?>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Subtab: Add Monitor Wizard -->
|
||
<div id="sub-watchdog-add" class="subtab-content">
|
||
<div class="card">
|
||
<div class="card-header"><h2 class="card-title">🖥️ Monitor Anlegen</h2></div>
|
||
<form method="POST" action="index.php#sub-watchdog-add">
|
||
<?= $csrfField ?>
|
||
<input type="hidden" name="action" value="add_watchdog_monitor">
|
||
<div class="form-grid">
|
||
<div class="form-group">
|
||
<label class="form-label">Monitor Name (source)</label>
|
||
<input type="text" name="source" class="form-input" required placeholder="z. B. srv-app-02">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Typ</label>
|
||
<select name="type" class="form-input">
|
||
<option value="host">Host / Server (Windows / Linux)</option>
|
||
<option value="heartbeat">Dienst / Worker (Heartbeat)</option>
|
||
<option value="hypervisor_node">Hypervisor Node (Proxmox)</option>
|
||
<option value="guest">Guest VM / Container</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Übergeordnete Entität (Parent Source)</label>
|
||
<select name="parent_source" class="form-input">
|
||
<option value="">-- Keine (Top Level) --</option>
|
||
<?php foreach ($monitors as $pm): ?>
|
||
<?php if ($pm['type'] !== 'heartbeat'): ?>
|
||
<option value="<?= htmlspecialchars($pm['source']) ?>"><?= htmlspecialchars($pm['source']) ?> (<?= htmlspecialchars($pm['type']) ?>)</option>
|
||
<?php endif; ?>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Betriebssystem / Framework</label>
|
||
<select name="os" class="form-input">
|
||
<option value="Windows">Windows (PowerShell Task)</option>
|
||
<option value="Linux">Linux (Bash Cron)</option>
|
||
<option value=".NET 8 Service">.NET 8 Service</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Erwartetes Intervall (Sekunden)</label>
|
||
<input type="number" name="interval" class="form-input" value="60">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Gruppe</label>
|
||
<input type="text" name="group" class="form-input" value="Infrastructure">
|
||
</div>
|
||
</div>
|
||
<button type="submit" class="btn">Monitor Anlegen & Token Generieren</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Subtab: Watchdog Event Log -->
|
||
<div id="sub-watchdog-eventlog" class="subtab-content">
|
||
<div class="card">
|
||
<div class="card-header"><h2 class="card-title">📜 Chronologisches Event-Log</h2></div>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Zeitpunkt (UTC)</th>
|
||
<th>Source</th>
|
||
<th>Kind</th>
|
||
<th>Severity</th>
|
||
<th>Nachricht</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<?php foreach ($recentEvents as $e): ?>
|
||
<tr>
|
||
<td><?= htmlspecialchars($e['at_utc']) ?></td>
|
||
<td><strong><?= htmlspecialchars($e['source']) ?></strong></td>
|
||
<td><code><?= htmlspecialchars($e['kind']) ?></code></td>
|
||
<td><span class="badge badge-<?= $e['severity']==='info'?'up':($e['severity']==='warning'?'warning':'down') ?>"><?= strtoupper($e['severity']) ?></span></td>
|
||
<td><?= htmlspecialchars($e['message'] ?? '-') ?></td>
|
||
</tr>
|
||
<?php endforeach; ?>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Subtab: Agent Tokens -->
|
||
<div id="sub-watchdog-tokens" class="subtab-content">
|
||
<div class="card">
|
||
<div class="card-header"><h2 class="card-title">Neuen Agent-Token erstellen</h2></div>
|
||
<form method="POST" action="index.php#sub-watchdog-tokens">
|
||
<?= $csrfField ?>
|
||
<input type="hidden" name="action" value="create_agent_token">
|
||
<div class="form-grid">
|
||
<div class="form-group">
|
||
<label class="form-label">Bezeichnung / Name</label>
|
||
<input type="text" name="token_name" class="form-input" required placeholder="z. B. Infrastructure Server Agent">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Gebunden an Source (optional)</label>
|
||
<select name="token_source" class="form-input">
|
||
<option value="">-- Alle Sources (Universal) --</option>
|
||
<?php foreach ($monitors as $m): ?>
|
||
<option value="<?= htmlspecialchars($m['source']) ?>"><?= htmlspecialchars($m['source']) ?></option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<button type="submit" class="btn">Token Generieren</button>
|
||
</form>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-header"><h2 class="card-title">Agent Tokens</h2></div>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Token ID</th>
|
||
<th>Bezeichnung</th>
|
||
<th>Gebundene Source</th>
|
||
<th>Token Value</th>
|
||
<th>Status</th>
|
||
<th>Aktion</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<?php foreach ($agentTokens as $tok): ?>
|
||
<?php
|
||
$rawVal = !empty($tok['raw_token']) ? $tok['raw_token'] : 'wd_live_token_default';
|
||
$masked = substr($rawVal, 0, 8) . '••••••••••••••••';
|
||
?>
|
||
<tr>
|
||
<td><code><?= htmlspecialchars($tok['token_id']) ?></code></td>
|
||
<td><strong><?= htmlspecialchars($tok['name']) ?></strong></td>
|
||
<td><?= htmlspecialchars($tok['monitor_source'] ?? 'Alle Sources') ?></td>
|
||
<td>
|
||
<code id="tok-text-<?= $tok['token_id'] ?>" data-full="<?= htmlspecialchars($rawVal) ?>" data-masked="<?= htmlspecialchars($masked) ?>">
|
||
<?= htmlspecialchars($masked) ?>
|
||
</code>
|
||
<button type="button" class="btn btn-sm btn-secondary" onclick="toggleTokenMask('<?= $tok['token_id'] ?>')">👁️</button>
|
||
<button type="button" class="btn btn-sm btn-secondary" onclick="copyTokenValue('<?= $tok['token_id'] ?>')">📋</button>
|
||
</td>
|
||
<td><span class="badge badge-<?= $tok['revoked'] ? 'down' : 'up' ?>"><?= $tok['revoked'] ? 'WIDERUFEN' : 'AKTIV' ?></span></td>
|
||
<td>
|
||
<?php if (!$tok['revoked']): ?>
|
||
<form method="POST" action="index.php#sub-watchdog-tokens" style="display:inline">
|
||
<?= $csrfField ?>
|
||
<input type="hidden" name="action" value="revoke_agent_token">
|
||
<input type="hidden" name="token_id" value="<?= $tok['token_id'] ?>">
|
||
<button type="submit" class="btn btn-sm btn-danger">Widerrufen</button>
|
||
</form>
|
||
<?php endif; ?>
|
||
</td>
|
||
</tr>
|
||
<?php endforeach; ?>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ================= MODULE 4: UPDATESERVICE ================= -->
|
||
<div id="tab-updateservice" class="tab-content">
|
||
|
||
<div id="sub-update-releases" class="subtab-content active">
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h2 class="card-title">📦 Veröffentlichte Software Releases</h2>
|
||
</div>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Projekt</th>
|
||
<th>Kanal</th>
|
||
<th>Plattform</th>
|
||
<th>Version</th>
|
||
<th>Git Commit</th>
|
||
<th>Größe</th>
|
||
<th>Release Notes</th>
|
||
<th>Download & SHA256</th>
|
||
<th>Datum</th>
|
||
<th>Aktionen</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<?php foreach ($releases as $r): ?>
|
||
<?php
|
||
$channelClass = match($r['channel'] ?? 'prod') {
|
||
'prod' => 'badge-up',
|
||
'beta' => 'badge-warning',
|
||
'dev' => 'badge-stopped',
|
||
default => 'badge-up'
|
||
};
|
||
$sizeFormatted = !empty($r['size_bytes']) ? round($r['size_bytes'] / (1024 * 1024), 2) . ' MB' : '-';
|
||
?>
|
||
<tr>
|
||
<td><strong><?= htmlspecialchars($r['product_slug']) ?></strong></td>
|
||
<td><span class="badge <?= $channelClass ?>"><?= strtoupper(htmlspecialchars($r['channel'] ?? 'prod')) ?></span></td>
|
||
<td>
|
||
<?php $platformLabel = (string)($r['platform'] ?? 'any'); ?>
|
||
<code style="font-size:0.75rem;<?= $platformLabel === 'any' ? 'color:var(--text-muted);' : '' ?>"><?= htmlspecialchars($platformLabel) ?></code>
|
||
</td>
|
||
<td>
|
||
<code>v<?= htmlspecialchars($r['version']) ?></code>
|
||
<?php if (!empty($r['is_critical'])): ?><span class="badge badge-down">KRITISCH</span><?php endif; ?>
|
||
<?php if (empty($r['manifest_signature'])): ?><span class="badge badge-warning" title="Ohne Signatur - der Agent kann die Herkunft nicht prüfen.">UNSIGNIERT</span><?php endif; ?>
|
||
</td>
|
||
<td><code style="color:var(--text-muted);"><?= htmlspecialchars($r['git_commit'] ?? 'n/a') ?></code></td>
|
||
<td><span style="font-family:'Roboto Mono', monospace; font-size:0.8rem;"><?= $sizeFormatted ?></span></td>
|
||
<td><?= htmlspecialchars($r['release_notes'] ?? '-') ?></td>
|
||
<td>
|
||
<a href="<?= htmlspecialchars($r['download_url']) ?>" target="_blank" class="btn btn-sm btn-secondary">
|
||
⬇️ Download Package
|
||
</a>
|
||
<?php if (!empty($r['sha256_hash'])): ?>
|
||
<div style="font-family:'Roboto Mono', monospace; font-size:0.65rem; color:var(--text-muted); margin-top:2px;" title="<?= htmlspecialchars($r['sha256_hash']) ?>">
|
||
SHA: <?= htmlspecialchars(substr($r['sha256_hash'], 0, 12)) ?>...
|
||
</div>
|
||
<?php endif; ?>
|
||
</td>
|
||
<td><?= htmlspecialchars($r['created_at']) ?></td>
|
||
<td style="white-space:nowrap;">
|
||
<button type="button" class="btn btn-sm btn-secondary" onclick="openEditReleaseModal(<?= htmlspecialchars(json_encode($r, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP)) ?>)" title="Release bearbeiten">✏️ Bearbeiten</button>
|
||
<button type="button" class="btn btn-sm btn-danger" onclick="openDeleteReleaseModal(<?= (int)$r['id'] ?>, '<?= htmlspecialchars(addslashes($r['version'])) ?>', '<?= htmlspecialchars(addslashes($r['product_slug'])) ?>')" title="Release löschen">🗑️ Löschen</button>
|
||
</td>
|
||
</tr>
|
||
<?php endforeach; ?>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="sub-update-guard" class="subtab-content">
|
||
<div class="card">
|
||
<div class="card-header"><h2 class="card-title">🔒 Zugangsschutz der Releases</h2></div>
|
||
|
||
<p style="color:var(--text-muted); font-size:0.875rem; margin-bottom:1.25rem;">
|
||
Die Release-Verzeichnisse liegen hinter HTTP-Basic-Auth. Zugang hat, wer
|
||
einen <strong>gültigen Lizenzschlüssel</strong> für das jeweilige Produkt
|
||
besitzt — Benutzername und Passwort sind beide der Schlüssel. Die Anwendung
|
||
kennt ihn ohnehin und versorgt sich damit selbst. Zusätzlich stehen die
|
||
<strong>Installationskonten</strong> in jeder Datei: bei einer
|
||
Erstinstallation gibt es noch keinen Schlüssel.
|
||
</p>
|
||
|
||
<div class="alert" style="background:rgba(91,157,255,0.12); border:1px solid rgba(91,157,255,0.3); color:#bfdbfe; margin-bottom:1.25rem;">
|
||
<strong>Je Produkt eine eigene Datei.</strong> Eine gemeinsame Liste würde
|
||
bedeuten, dass eine Lizenz für Produkt A auch Produkt B öffnet. Die Zuordnung
|
||
ergibt sich aus <code>license_licenses.product_id</code>.
|
||
</div>
|
||
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Produkt</th>
|
||
<th>Status</th>
|
||
<th>Zugänge</th>
|
||
<th>Zuletzt erzeugt</th>
|
||
<th>Aktion</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<?php if ($releaseGuard === []): ?>
|
||
<tr><td colspan="5" style="color:var(--text-muted);">Es liegen noch keine Release-Verzeichnisse vor.</td></tr>
|
||
<?php endif; ?>
|
||
<?php foreach ($releaseGuard as $g): ?>
|
||
<?php $isExempt = !empty($g['exempt']); ?>
|
||
<tr>
|
||
<td><strong><?= e((string)$g['slug']) ?></strong></td>
|
||
<td>
|
||
<?php if ($isExempt): ?>
|
||
<span class="badge badge-down">AUSGENOMMEN</span>
|
||
<?php elseif (!$g['protected']): ?>
|
||
<span class="badge badge-down">OFFEN</span>
|
||
<?php elseif ((int)$g['entries'] === 0): ?>
|
||
<span class="badge badge-warning">GESPERRT</span>
|
||
<?php else: ?>
|
||
<span class="badge badge-up">GESCHÜTZT</span>
|
||
<?php endif; ?>
|
||
<?php if (!$g['writable']): ?>
|
||
<span class="badge badge-down" title="PHP kann hier nicht schreiben">NICHT SCHREIBBAR</span>
|
||
<?php endif; ?>
|
||
</td>
|
||
<td><?= (int)$g['entries'] ?></td>
|
||
<td style="font-size:0.8rem; color:var(--text-muted);">
|
||
<?= $g['updated_at'] !== null ? e((string)$g['updated_at']) : '—' ?>
|
||
</td>
|
||
<td>
|
||
<form method="POST" action="index.php" style="display:inline;"
|
||
<?= $isExempt ? '' : 'onsubmit="return confirm(\'Damit kann jeder im Internet die Pakete von ' . e((string)$g['slug']) . ' herunterladen. Wirklich?\');"' ?>>
|
||
<?= $csrfField ?>
|
||
<input type="hidden" name="action" value="toggle_release_guard_exempt">
|
||
<input type="hidden" name="_return" value="sub-update-guard">
|
||
<input type="hidden" name="slug" value="<?= e((string)$g['slug']) ?>">
|
||
<input type="hidden" name="exempt" value="<?= $isExempt ? '0' : '1' ?>">
|
||
<button type="submit" class="btn btn-sm <?= $isExempt ? '' : 'btn-secondary' ?>">
|
||
<?= $isExempt ? 'Schutz aktivieren' : 'Ausnehmen' ?>
|
||
</button>
|
||
</form>
|
||
</td>
|
||
</tr>
|
||
<?php endforeach; ?>
|
||
</tbody>
|
||
</table>
|
||
|
||
<?php if ($releaseGuardTest !== null): ?>
|
||
<?php $testFailed = !empty($releaseGuardTest['failed']); ?>
|
||
<div class="alert" style="margin-top:1rem; <?= $testFailed
|
||
? 'background:rgba(255,70,70,0.12); border:1px solid rgba(255,70,70,0.35); color:#fecaca;'
|
||
: 'background:rgba(60,200,120,0.12); border:1px solid rgba(60,200,120,0.3); color:#bbf7d0;' ?>">
|
||
<strong>Selbsttest vom <?= e((string)($releaseGuardTest['ran_at'] ?? '?')) ?> UTC:</strong>
|
||
<?= (int)($releaseGuardTest['ok'] ?? 0) ?> von
|
||
<?= (int)($releaseGuardTest['checked'] ?? 0) ?> Adresse(n) antworten ohne
|
||
Zugangsdaten korrekt mit 401.
|
||
<?php if ($testFailed): ?>
|
||
<ul style="margin:0.5rem 0 0 1rem;">
|
||
<?php foreach ($releaseGuardTest['failed'] as $f): ?>
|
||
<li><strong><?= e((string)($f['slug'] ?? '?')) ?>:</strong> <?= e((string)($f['reason'] ?? '')) ?></li>
|
||
<?php endforeach; ?>
|
||
</ul>
|
||
<?php endif; ?>
|
||
</div>
|
||
<?php endif; ?>
|
||
|
||
<p style="color:var(--text-muted); font-size:0.8rem; margin-top:1rem;">
|
||
<strong>Der Selbsttest ist die einzige belastbare Aussage.</strong> Dass die
|
||
Dateien vorhanden sind, beweist nichts: Unter Nginx werden
|
||
<code>.htaccess</code>-Dateien vollständig ignoriert — die Verzeichnisse wären
|
||
dann offen und die <code>.htpasswd</code> öffentlich abrufbar, während hier
|
||
„GESCHÜTZT“ stünde. Der Test ruft die eigene Paket-Adresse ohne Zugangsdaten
|
||
ab und erwartet 401.
|
||
</p>
|
||
|
||
<p style="color:var(--text-muted); font-size:0.8rem; margin-top:1rem;">
|
||
<strong>AUSGENOMMEN</strong> heißt: bewusst offen gelassen — für Produkte,
|
||
deren ausgelieferte Installationen noch mit einem SDK vor 2.4 gebaut sind und
|
||
deshalb keinen Lizenzschlüssel mitschicken. Sie bekämen sonst 401 statt
|
||
Updates. <strong>Sobald ein Build mit Schlüssel draußen ist, wieder
|
||
einschalten.</strong><br>
|
||
<strong>OFFEN</strong> heißt: jeder im Internet kann die Pakete laden.
|
||
<strong>GESPERRT</strong> heißt: niemand kommt heran, weil es weder gültige
|
||
Lizenzen noch Installationskonten gibt — bei internen Anwendungen ohne
|
||
Lizenzen ist das der Normalfall, solange kein Installationskonto existiert.
|
||
</p>
|
||
|
||
<form method="POST" action="index.php" style="margin-top:1.25rem;">
|
||
<?= $csrfField ?>
|
||
<input type="hidden" name="action" value="regenerate_release_guard">
|
||
<input type="hidden" name="_return" value="sub-update-guard">
|
||
<button type="submit" class="btn">Zugangsschutz jetzt neu erzeugen</button>
|
||
</form>
|
||
|
||
<p style="color:var(--text-muted); font-size:0.8rem; margin-top:1rem;">
|
||
Geschieht automatisch bei jeder Lizenz- und Kontoänderung. Abgelaufene
|
||
Lizenzen lösen von sich aus nichts aus — dafür gleicht
|
||
<code>cli/tick.php</code> nach, spätestens alle sechs Stunden.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="sub-update-installer" class="subtab-content">
|
||
<div class="card">
|
||
<div class="card-header"><h2 class="card-title">⬇️ Installer herunterladen</h2></div>
|
||
|
||
<p style="color:var(--text-muted); font-size:0.875rem; margin-bottom:1.25rem;">
|
||
Ein Werkzeug für alles: Dasselbe Programm richtet eine Anwendung erstmalig
|
||
ein und aktualisiert sie später. Es bringt die .NET-Laufzeit mit — auf dem
|
||
Zielsystem muss nichts vorinstalliert sein.
|
||
</p>
|
||
|
||
<?php if ($installerManifest === null): ?>
|
||
<div class="alert" style="background:rgba(255,180,0,0.12); border:1px solid rgba(255,180,0,0.3); color:#fde68a; font-weight:500;">
|
||
<strong>Noch nichts hinterlegt.</strong> Die Binaries werden mit
|
||
<code>scripts/build_installer.ps1</code> gebaut und mit
|
||
<code>scripts/upload_installer.py</code> nach <code>/installer/</code>
|
||
hochgeladen.
|
||
</div>
|
||
<?php else: ?>
|
||
<div style="font-size:0.8rem; color:var(--text-muted); margin-bottom:1rem;">
|
||
Version <strong><?= e((string)($installerManifest['version'] ?? '?')) ?></strong>
|
||
· Build <code><?= e((string)($installerManifest['gitCommit'] ?? '?')) ?></code>
|
||
· <?= e(substr((string)($installerManifest['buildDateUtc'] ?? ''), 0, 10)) ?>
|
||
</div>
|
||
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Plattform</th>
|
||
<th>Größe</th>
|
||
<th>SHA256</th>
|
||
<th>Download</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<?php foreach ($installerManifest['binaries'] as $bin): ?>
|
||
<?php
|
||
$binFile = (string)($bin['file'] ?? '');
|
||
$binHash = (string)($bin['sha256'] ?? '');
|
||
$binUrl = $baseUrl . '/installer/' . rawurlencode($binFile);
|
||
?>
|
||
<tr>
|
||
<td><code><?= e((string)($bin['platform'] ?? '?')) ?></code></td>
|
||
<td style="font-family:'Roboto Mono',monospace; font-size:0.8rem;">
|
||
<?= number_format(((int)($bin['sizeBytes'] ?? 0)) / (1024 * 1024), 1, ',', '.') ?> MB
|
||
</td>
|
||
<td>
|
||
<code style="font-size:0.65rem; color:var(--text-muted); word-break:break-all;" title="<?= e($binHash) ?>">
|
||
<?= e(substr($binHash, 0, 24)) ?>…
|
||
</code>
|
||
</td>
|
||
<td>
|
||
<a href="<?= e($binUrl) ?>" class="btn btn-sm">⬇️ Herunterladen</a>
|
||
</td>
|
||
</tr>
|
||
<?php endforeach; ?>
|
||
</tbody>
|
||
</table>
|
||
|
||
<div class="alert" style="background:rgba(91,157,255,0.12); border:1px solid rgba(91,157,255,0.3); color:#bfdbfe; margin-top:1rem;">
|
||
<strong>Prüfsumme vergleichen.</strong> „Programm herunterladen und
|
||
Zugangsdaten eingeben“ ist genau das Muster, das Phishing nachahmt.
|
||
Die vollständigen Hashes stehen im Tooltip und unter
|
||
<a href="<?= e($baseUrl) ?>/installer/installer.json" target="_blank" rel="noopener" style="color:#bfdbfe;">installer.json</a>.
|
||
</div>
|
||
<?php endif; ?>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-header"><h2 class="card-title">Direkt auf dem Zielsystem</h2></div>
|
||
|
||
<p style="color:var(--text-muted); font-size:0.875rem; margin-bottom:0.75rem;">
|
||
<strong>Linux</strong> — lädt das passende Binary, prüft die Prüfsumme und legt es ab:
|
||
</p>
|
||
<div class="prompt-box">wget -qO- <?= e($baseUrl) ?>/installer/install.sh | sh</div>
|
||
|
||
<p style="color:var(--text-muted); font-size:0.875rem; margin:1rem 0 0.75rem;">
|
||
Oder ohne Skript, direkt das Binary:
|
||
</p>
|
||
<div class="prompt-box">wget <?= e($baseUrl) ?>/installer/update-agent-linux-x64 -O update-agent
|
||
chmod +x update-agent
|
||
./update-agent --action install</div>
|
||
|
||
<p style="color:var(--text-muted); font-size:0.875rem; margin:1rem 0 0.75rem;">
|
||
<strong>Windows</strong> (PowerShell):
|
||
</p>
|
||
<div class="prompt-box">irm <?= e($baseUrl) ?>/installer/install.ps1 | iex</div>
|
||
|
||
<p style="color:var(--text-muted); font-size:0.8rem; margin-top:1rem;">
|
||
Die Skripte richten <strong>nichts</strong> von selbst ein — sie laden, prüfen
|
||
und sagen, wie es weitergeht. Ein Skript aus dem Netz, das ungefragt eine
|
||
Anwendung aufsetzt und dabei nach Zugangsdaten fragt, wäre genau das, wovor
|
||
man Nutzer sonst warnt.
|
||
</p>
|
||
|
||
<p style="color:var(--text-muted); font-size:0.875rem; margin:1rem 0 0.75rem;">
|
||
Nur die <strong>Maschine überwachen</strong>, ohne etwas zu installieren —
|
||
legt Monitor und Token an, schreibt das Agent-Skript und plant es ein:
|
||
</p>
|
||
<div class="prompt-box">./update-agent --action monitor</div>
|
||
|
||
<p style="color:var(--text-muted); font-size:0.8rem; margin-top:0.75rem;">
|
||
Für die Installation wird ein Konto der Rolle <strong>installer</strong>
|
||
gebraucht — anzulegen unter <em>System → 👤 Benutzer</em>. Ein
|
||
Administratorkonto funktioniert auch, gehört aber nicht auf ein Zielsystem:
|
||
seine Zugangsdaten würden damit auf jedem Rechner landen, auf dem je etwas
|
||
installiert wurde.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="sub-update-publish" class="subtab-content">
|
||
<div class="card">
|
||
<div class="card-header"><h2 class="card-title">Neues Software-Release veröffentlichen</h2></div>
|
||
<form method="POST" action="index.php#sub-update-publish">
|
||
<?= $csrfField ?>
|
||
<input type="hidden" name="action" value="add_release">
|
||
<div class="form-grid">
|
||
<div class="form-group">
|
||
<label class="form-label">Projekt-Slug</label>
|
||
<select name="product_slug" class="form-input" required>
|
||
<?php foreach ($projects as $p): ?>
|
||
<option value="<?= htmlspecialchars($p['slug']) ?>"><?= htmlspecialchars($p['name']) ?> (<?= htmlspecialchars($p['slug']) ?>)</option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Release Kanal</label>
|
||
<select name="channel" class="form-input" required>
|
||
<option value="prod" selected>prod (Produktiv)</option>
|
||
<option value="beta">beta (Vorab-Test)</option>
|
||
<option value="dev">dev (Entwicklung)</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Plattform</label>
|
||
<select name="platform" class="form-input">
|
||
<option value="any" selected>any (plattformunabhängig)</option>
|
||
<option value="win-x64">win-x64</option>
|
||
<option value="win-arm64">win-arm64</option>
|
||
<option value="linux-x64">linux-x64</option>
|
||
<option value="linux-arm64">linux-arm64</option>
|
||
<option value="linux-musl-x64">linux-musl-x64</option>
|
||
<option value="osx-x64">osx-x64</option>
|
||
<option value="osx-arm64">osx-arm64</option>
|
||
</select>
|
||
<div style="font-size:0.7rem; color:var(--text-muted); margin-top:0.25rem;">
|
||
Clients ohne Plattformangabe sehen ausschließlich <code>any</code>.
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Version (z. B. 1.2.0)</label>
|
||
<input type="text" name="version" class="form-input" required placeholder="1.2.0">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Git Commit (Kurz-Hash)</label>
|
||
<input type="text" name="git_commit" class="form-input" placeholder="a1b2c3d">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Download URL</label>
|
||
<input type="url" name="download_url" class="form-input" required placeholder="https://dc.mhdf.de/releases/myapp/prod/1.2.0/package.tar.gz">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">SHA256 Hash (Optional)</label>
|
||
<input type="text" name="sha256_hash" class="form-input" placeholder="e3b0c44298fc1c149afbf4c8996fb924...">
|
||
</div>
|
||
</div>
|
||
<div class="form-group" style="margin-bottom:1rem;">
|
||
<label class="form-label">Release Notes / Changelog</label>
|
||
<textarea name="release_notes" class="form-input" rows="3" placeholder="Changelog und Verbesserungen..."></textarea>
|
||
</div>
|
||
<div style="margin-bottom:1.25rem;">
|
||
<label style="display:inline-flex; align-items:center; gap:0.5rem; font-size:0.875rem; cursor:pointer;">
|
||
<input type="checkbox" name="is_critical" value="1"> Kritisches Sicherheits-Update (Rollout priorisieren)
|
||
</label>
|
||
</div>
|
||
<button type="submit" class="btn">Release Speichern & Freigeben</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Edit Release Modal -->
|
||
<div class="modal-backdrop" id="editReleaseModal">
|
||
<div class="modal-card">
|
||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:1.5rem;">
|
||
<h3 style="margin:0;">✏️ Release bearbeiten</h3>
|
||
<button type="button" onclick="closeEditReleaseModal()" style="background:none; border:none; color:var(--text-muted); font-size:1.25rem; cursor:pointer;">×</button>
|
||
</div>
|
||
<form method="POST" action="index.php#sub-update-releases">
|
||
<?= $csrfField ?>
|
||
<input type="hidden" name="action" value="edit_release">
|
||
<input type="hidden" name="release_id" id="edit_release_id" value="">
|
||
|
||
<div class="form-grid">
|
||
<div class="form-group">
|
||
<label class="form-label">Projekt-Slug</label>
|
||
<select name="product_slug" id="edit_release_product_slug" class="form-input" required>
|
||
<?php foreach ($projects as $p): ?>
|
||
<option value="<?= htmlspecialchars($p['slug']) ?>"><?= htmlspecialchars($p['name']) ?> (<?= htmlspecialchars($p['slug']) ?>)</option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Release Kanal</label>
|
||
<select name="channel" id="edit_release_channel" class="form-input" required>
|
||
<option value="prod">prod (Produktiv)</option>
|
||
<option value="beta">beta (Vorab-Test)</option>
|
||
<option value="dev">dev (Entwicklung)</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Plattform</label>
|
||
<select name="platform" id="edit_release_platform" class="form-input">
|
||
<option value="any">any (plattformunabhängig)</option>
|
||
<option value="win-x64">win-x64</option>
|
||
<option value="win-arm64">win-arm64</option>
|
||
<option value="linux-x64">linux-x64</option>
|
||
<option value="linux-arm64">linux-arm64</option>
|
||
<option value="linux-musl-x64">linux-musl-x64</option>
|
||
<option value="osx-x64">osx-x64</option>
|
||
<option value="osx-arm64">osx-arm64</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Version (z. B. 1.2.0)</label>
|
||
<input type="text" name="version" id="edit_release_version" class="form-input" required placeholder="1.2.0">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Git Commit (Kurz-Hash)</label>
|
||
<input type="text" name="git_commit" id="edit_release_git_commit" class="form-input" placeholder="a1b2c3d">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Download URL</label>
|
||
<input type="url" name="download_url" id="edit_release_download_url" class="form-input" required placeholder="https://...">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">SHA256 Hash (Optional)</label>
|
||
<input type="text" name="sha256_hash" id="edit_release_sha256_hash" class="form-input" placeholder="e3b0c44298fc1c149afbf4c8996fb924...">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Größe (Bytes)</label>
|
||
<input type="number" name="size_bytes" id="edit_release_size_bytes" class="form-input" min="0" placeholder="0">
|
||
</div>
|
||
</div>
|
||
<div class="form-group" style="margin-bottom:1rem;">
|
||
<label class="form-label">Release Notes / Changelog</label>
|
||
<textarea name="release_notes" id="edit_release_release_notes" class="form-input" rows="3" placeholder="Changelog..."></textarea>
|
||
</div>
|
||
<div style="margin-bottom:1.25rem;">
|
||
<label style="display:inline-flex; align-items:center; gap:0.5rem; font-size:0.875rem; cursor:pointer;">
|
||
<input type="checkbox" name="is_critical" id="edit_release_is_critical" value="1"> Kritisches Sicherheits-Update (Rollout priorisieren)
|
||
</label>
|
||
</div>
|
||
<div style="display:flex; justify-content:flex-end; gap:0.75rem;">
|
||
<button type="button" class="btn btn-secondary" onclick="closeEditReleaseModal()">Abbrechen</button>
|
||
<button type="submit" class="btn">Änderungen Speichern</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Delete Release Modal -->
|
||
<div class="modal-backdrop" id="deleteReleaseModal">
|
||
<div class="modal-card" style="max-width:480px;">
|
||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:1.5rem;">
|
||
<h3 style="margin:0; color:#ef4444;">🗑️ Release löschen</h3>
|
||
<button type="button" onclick="closeDeleteReleaseModal()" style="background:none; border:none; color:var(--text-muted); font-size:1.25rem; cursor:pointer;">×</button>
|
||
</div>
|
||
<p>Möchten Sie das Release <strong id="delete_release_version_display"></strong> für <strong id="delete_release_product_display"></strong> wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.</p>
|
||
<form method="POST" action="index.php#sub-update-releases" style="margin-top:1.5rem;">
|
||
<?= $csrfField ?>
|
||
<input type="hidden" name="action" value="delete_release">
|
||
<input type="hidden" name="release_id" id="delete_release_id" value="">
|
||
<div style="display:flex; justify-content:flex-end; gap:0.75rem;">
|
||
<button type="button" class="btn btn-secondary" onclick="closeDeleteReleaseModal()">Abbrechen</button>
|
||
<button type="submit" class="btn btn-danger">Release unwiderruflich löschen</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ================= MODULE: ROADMAP ================= -->
|
||
<!--
|
||
Kein eigener Datenbestand. Ein Roadmap-Eintrag ist ein
|
||
bugtracker_item mit gesetztem Themenbereich; diese Ansicht ist
|
||
die Sicht darauf. Wer hier etwas aendert, aendert dasselbe
|
||
Item, das der Bugtracker zeigt - eine zweite Wahrheit ueber
|
||
denselben Vorgang soll gar nicht erst entstehen koennen.
|
||
-->
|
||
<div id="tab-roadmap" class="tab-content">
|
||
|
||
<!-- =============== Subtab: Übersicht =============== -->
|
||
<div id="sub-roadmap-board" class="subtab-content active">
|
||
|
||
<div class="stats-grid" style="grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));">
|
||
<div class="stat-card">
|
||
<div class="stat-header">📋 Offen</div>
|
||
<div class="stat-value" style="color:#5b9dff;"><?= $roadmapStats['pending'] ?></div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-header">🔨 In Arbeit</div>
|
||
<div class="stat-value" style="color:var(--warning);"><?= $roadmapStats['working'] ?></div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-header">✅ Erledigt</div>
|
||
<div class="stat-value" style="color:var(--success);"><?= $roadmapStats['done'] ?></div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-header">🧊 Zurückgestellt</div>
|
||
<div class="stat-value" style="color:var(--text-muted);"><?= $roadmapStats['deferred'] ?></div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-header">❌ Verworfen</div>
|
||
<div class="stat-value" style="color:var(--text-muted);"><?= $roadmapStats['rejected'] ?></div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-header">🤖 Belegt</div>
|
||
<div class="stat-value" style="color:#66bb6a;"><?= $roadmapStats['claimed'] ?></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card" style="padding:0.85rem 1.1rem;">
|
||
<form method="GET" action="index.php" style="display:flex; flex-wrap:wrap; gap:0.45rem; align-items:center;">
|
||
<!-- Die offene Ansicht faehrt als verstecktes Feld mit. Ein GET-Formular
|
||
verwirft beim Absenden den Anker seiner action, und der Anker war
|
||
bisher die einzige Quelle dafuer, was angezeigt wird - nach jeder
|
||
Filteraenderung stand man wieder im Dashboard. Fest im Markup statt
|
||
aus dem DOM gelesen: hier ist ohne Zweifel bekannt, wo das Formular
|
||
steht. -->
|
||
<input type="hidden" name="view" value="sub-roadmap-board">
|
||
<select name="rm_project" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="dcSubmit(this.form)">
|
||
<option value="all">🌐 Alle Projekte</option>
|
||
<?php foreach ($projects as $p): ?>
|
||
<option value="<?= e($p['slug']) ?>" <?= $rmProject === $p['slug'] ? 'selected' : '' ?>>
|
||
<?= e($p['name']) ?>
|
||
</option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
|
||
<select name="rm_lage" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="dcSubmit(this.form)">
|
||
<?php
|
||
// Voreinstellung ist "aktiv" und nicht "alle": Eine Roadmap,
|
||
// die mit sechzig erledigten Eintraegen beginnt, beantwortet
|
||
// die Frage nicht, fuer die man sie aufschlaegt.
|
||
$rmLageOptions = [
|
||
'aktiv' => '🎯 Aktiv (offen + in Arbeit)',
|
||
'offen' => '📋 Nur offen',
|
||
'arbeit' => '🔨 Nur in Arbeit',
|
||
'erledigt' => '✅ Erledigt',
|
||
'zurueckgestellt' => '🧊 Zurückgestellt',
|
||
'verworfen' => '❌ Verworfen',
|
||
'all' => '📌 Alle Lagen',
|
||
];
|
||
foreach ($rmLageOptions as $value => $label): ?>
|
||
<option value="<?= e($value) ?>" <?= $rmLage === $value ? 'selected' : '' ?>><?= e($label) ?></option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
|
||
<select name="rm_epic" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="dcSubmit(this.form)">
|
||
<option value="all">🗂️ Alle Bereiche</option>
|
||
<?php foreach ($roadmapEpics as $rmE): ?>
|
||
<option value="<?= e((string)$rmE['epic']) ?>" <?= $rmEpic === (string)$rmE['epic'] ? 'selected' : '' ?>>
|
||
<?= e((string)$rmE['epic']) ?> — <?= e((string)$rmE['title']) ?>
|
||
</option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
|
||
<select name="rm_build" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="dcSubmit(this.form)">
|
||
<option value="all">🎯 Alle Zielfassungen</option>
|
||
<?php foreach ($roadmapBuilds as $rmB): ?>
|
||
<option value="<?= e((string)$rmB) ?>" <?= $rmBuild === (string)$rmB ? 'selected' : '' ?>><?= e((string)$rmB) ?></option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
|
||
<input type="text" name="rm_agent" value="<?= e($rmAgent) ?>" placeholder="🤖 Agent"
|
||
class="form-input" style="width:130px; padding:0.3rem 0.6rem; font-size:0.8rem;">
|
||
|
||
<input type="text" name="rm_q" value="<?= e($rmSearch) ?>" placeholder="🔍 Suche"
|
||
class="form-input" style="width:170px; padding:0.3rem 0.6rem; font-size:0.8rem;">
|
||
|
||
<button type="submit" class="btn btn-sm">Filtern</button>
|
||
<a href="index.php#tab-roadmap" class="btn btn-sm btn-secondary">Zurücksetzen</a>
|
||
</form>
|
||
</div>
|
||
|
||
<?php if ($roadmapOrphans !== []): ?>
|
||
<!--
|
||
Ein Bereich, den ein Item nennt und den es in
|
||
roadmap_epics nicht gibt. Meist ein Tippfehler - und
|
||
der erzeugt sonst lautlos einen weiteren Bereich, der
|
||
in keiner Uebersicht auftaucht.
|
||
-->
|
||
<div class="card" style="padding:0.9rem 1.15rem; border-left:3px solid var(--danger);">
|
||
<strong style="font-size:0.9rem;">⚠️ Unbekannte Themenbereiche</strong>
|
||
<div style="margin-top:0.5rem; display:flex; flex-wrap:wrap; gap:0.5rem;">
|
||
<?php foreach ($roadmapOrphans as $orphan): ?>
|
||
<span class="badge badge-down">
|
||
<?= e((string)$orphan['project_slug']) ?> / <?= e((string)$orphan['epic']) ?>
|
||
· <?= (int)$orphan['items'] ?> Eintrag/Einträge
|
||
</span>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
<div style="margin-top:0.5rem; font-size:0.8rem; color:var(--text-muted);">
|
||
Diese Kürzel stehen an Items, sind aber nicht angelegt. Unter
|
||
„Themenbereiche“ anlegen oder die Items umhängen.
|
||
</div>
|
||
</div>
|
||
<?php endif; ?>
|
||
|
||
<?php if ($roadmapNext !== []): ?>
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h2 class="card-title">🎯 Als Nächstes</h2>
|
||
</div>
|
||
<!--
|
||
Diese Liste stand frueher von Hand oben in
|
||
ROADMAP.md und veraltete zuverlaessig. Sie ergibt
|
||
sich jetzt aus Reihenfolge und Status.
|
||
-->
|
||
<table>
|
||
<thead>
|
||
<tr><th style="width:40px;">#</th><th>Vorhaben</th><th>Bereich</th><th>Stand</th><th>Ziel</th><th>Wer</th></tr>
|
||
</thead>
|
||
<tbody>
|
||
<?php foreach ($roadmapNext as $i => $item): ?>
|
||
<tr>
|
||
<td style="color:var(--text-muted);"><?= $i + 1 ?>.</td>
|
||
<td>
|
||
<span style="font-family:'Roboto Mono'; color:var(--text-muted); font-size:0.8rem;">#<?= (int)$item['id'] ?></span>
|
||
<strong style="margin-left:0.35rem;"><?= e((string)$item['title']) ?></strong>
|
||
<?php if ($rmProject === 'all'): ?>
|
||
<span class="badge badge-stopped" style="margin-left:0.4rem;"><?= e((string)$item['project_slug']) ?></span>
|
||
<?php endif; ?>
|
||
</td>
|
||
<td><?= e((string)$item['epic']) ?></td>
|
||
<td><?= rmBadge((string)$item['status']) ?></td>
|
||
<td><?= $item['target_build'] ? '<code>' . e((string)$item['target_build']) . '</code>' : '—' ?></td>
|
||
<td><?= $item['claimed_by'] ? e((string)$item['claimed_by']) : '<span style="color:var(--text-muted);">frei</span>' ?></td>
|
||
</tr>
|
||
<?php endforeach; ?>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<?php endif; ?>
|
||
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h2 class="card-title">🤖 Wer arbeitet gerade woran</h2>
|
||
</div>
|
||
<?php if ($roadmapActive === []): ?>
|
||
<p style="color:var(--text-muted); padding:0.75rem 0;">
|
||
Zurzeit hält kein Agent ein Item. Ohne aktive Lease ist jedes
|
||
Vorhaben frei — das ist der Normalzustand zwischen zwei Läufen.
|
||
</p>
|
||
<?php else: ?>
|
||
<table>
|
||
<thead>
|
||
<tr><th>Item</th><th>Projekt</th><th>Bereich</th><th>Stand</th><th>Agent</th><th>Seit</th><th>Lease läuft</th></tr>
|
||
</thead>
|
||
<tbody>
|
||
<?php foreach ($roadmapActive as $work): ?>
|
||
<tr>
|
||
<td>
|
||
<span style="font-family:'Roboto Mono'; color:var(--text-muted); font-size:0.8rem;">#<?= (int)$work['id'] ?></span>
|
||
<span style="margin-left:0.35rem;"><?= e((string)$work['title']) ?></span>
|
||
</td>
|
||
<td><span class="badge badge-stopped"><?= e((string)$work['project_slug']) ?></span></td>
|
||
<td><?= $work['epic'] ? e((string)$work['epic']) : '<span style="color:var(--text-muted);">—</span>' ?></td>
|
||
<td><?= rmBadge((string)$work['status']) ?></td>
|
||
<td><strong><?= e((string)$work['claimed_by']) ?></strong></td>
|
||
<td style="color:var(--text-muted);"><?= e(dc_ago((string)$work['claimed_at'])) ?></td>
|
||
<td>
|
||
<?php $left = (int)$work['lease_minutes_left']; ?>
|
||
<span class="badge <?= $left < 15 ? 'badge-down' : 'badge-warning' ?>">
|
||
noch <?= $left ?> min
|
||
</span>
|
||
</td>
|
||
</tr>
|
||
<?php endforeach; ?>
|
||
</tbody>
|
||
</table>
|
||
<?php endif; ?>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h2 class="card-title">🗂️ Themenbereiche im Überblick</h2>
|
||
</div>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th style="width:60px;">Kürzel</th><th>Bereich</th>
|
||
<?php if ($rmProject === 'all'): ?><th>Projekt</th><?php endif; ?>
|
||
<!--
|
||
Spaltenköpfe aus Zeichen brauchen eine Erklärung.
|
||
Wer die Legende oben nicht gelesen hat - und das ist
|
||
der Normalfall - rät sonst, was 🧊 bedeutet.
|
||
title= genügt: ein eigenes Bedienelement für vier
|
||
Wörter wäre mehr Aufwand als Nutzen.
|
||
-->
|
||
<th class="hilfe" style="width:220px;"
|
||
title="Erledigt im Verhältnis zum tatsächlichen Umfang. Zurückgestelltes und Verworfenes zählt nicht mit — ein Bereich, in dem die Hälfte verworfen wurde, ist nicht zur Hälfte fertig, er ist kleiner geworden.">Fortschritt</th>
|
||
<th class="hilfe" style="width:90px;" title="Offen — geplant, noch niemand dran">📋</th>
|
||
<th class="hilfe" style="width:90px;" title="In Arbeit — ein Agent oder Mensch hält das Item gerade">🔨</th>
|
||
<th class="hilfe" style="width:90px;" title="Erledigt und ausgeliefert">✅</th>
|
||
<th class="hilfe" style="width:90px;" title="Zurückgestellt (mit benanntem Auslöser) / Verworfen (mit Begründung, nicht erneut vorschlagen)">🧊 / ❌</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<?php foreach ($roadmapEpics as $rmE): ?>
|
||
<?php
|
||
$total = (int)$rmE['total'];
|
||
$done = (int)$rmE['done'];
|
||
// Zurueckgestelltes und Verworfenes zaehlt nicht in den
|
||
// Nenner: Ein Bereich, in dem die Haelfte verworfen wurde,
|
||
// ist nicht zur Haelfte fertig - er ist kleiner geworden.
|
||
$scope = $total - (int)$rmE['deferred'] - (int)$rmE['rejected'];
|
||
$percent = $scope > 0 ? (int)round($done / $scope * 100) : 0;
|
||
?>
|
||
<tr>
|
||
<td><strong style="font-family:'Roboto Mono';"><?= e((string)$rmE['epic']) ?></strong></td>
|
||
<td>
|
||
<a href="<?= e(rmUrl(['rm_epic' => (string)$rmE['epic'], 'rm_project' => (string)$rmE['project_slug']], '#sub-roadmap-list')) ?>"
|
||
title="Vorhaben dieses Bereichs anzeigen">
|
||
<?= e((string)$rmE['title']) ?>
|
||
</a>
|
||
<?php if (!empty($rmE['summary'])): ?>
|
||
<div style="font-size:0.78rem; color:var(--text-muted); margin-top:0.15rem;">
|
||
<?= e((string)$rmE['summary']) ?>
|
||
</div>
|
||
<?php endif; ?>
|
||
</td>
|
||
<?php if ($rmProject === 'all'): ?>
|
||
<td><span class="badge badge-stopped"><?= e((string)$rmE['project_slug']) ?></span></td>
|
||
<?php endif; ?>
|
||
<td>
|
||
<div style="background:rgba(255,255,255,0.06); border-radius:4px; height:8px; overflow:hidden;">
|
||
<div style="width:<?= $percent ?>%; height:100%; background:var(--success);"></div>
|
||
</div>
|
||
<span style="font-size:0.75rem; color:var(--text-muted);">
|
||
<?= $done ?> / <?= $scope ?> <?= $scope > 0 ? '(' . $percent . '%)' : '' ?>
|
||
</span>
|
||
</td>
|
||
<td><?= (int)$rmE['pending'] ?></td>
|
||
<td><?= (int)$rmE['working'] ?></td>
|
||
<td><?= $done ?></td>
|
||
<td style="color:var(--text-muted);"><?= (int)$rmE['deferred'] ?> / <?= (int)$rmE['rejected'] ?></td>
|
||
</tr>
|
||
<?php endforeach; ?>
|
||
<?php if ($roadmapEpics === []): ?>
|
||
<tr><td colspan="8" style="color:var(--text-muted); padding:1rem;">
|
||
Noch keine Themenbereiche angelegt. Unter „Themenbereiche“ anfangen.
|
||
</td></tr>
|
||
<?php endif; ?>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- =============== Subtab: Alle Vorhaben =============== -->
|
||
<div id="sub-roadmap-list" class="subtab-content">
|
||
<div class="card">
|
||
<div class="card-header" style="display:flex; flex-wrap:wrap; justify-content:space-between; align-items:center; gap:0.75rem;">
|
||
<h2 class="card-title" style="margin:0;">
|
||
🗺️ Vorhaben
|
||
<span class="badge badge-stopped" style="margin-left:0.5rem;"><?= (int)$roadmapTotal ?> Treffer</span>
|
||
</h2>
|
||
<span style="font-size:0.8rem; color:var(--text-muted);">
|
||
Filter oben unter „Übersicht“ — sie gelten für beide Ansichten.
|
||
</span>
|
||
</div>
|
||
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th class="hilfe" style="width:60px;"
|
||
title="Reihenfolge in Zehnerschritten. Die Lücken sind Absicht: So lässt sich etwas dazwischenschieben, ohne alles neu zu nummerieren. Ein Strich heißt "keine Meinung" — solche Einträge stehen hinten.">Rang</th>
|
||
<th class="hilfe" style="width:70px;"
|
||
title="Die Item-Nummer im Bugtracker. Sie ist zugleich die Kennung, unter der die Begründung in der Projektdatei steht.">Item</th>
|
||
<th>Vorhaben</th>
|
||
<th style="width:90px;">Bereich</th>
|
||
<th style="width:150px;">Stand</th>
|
||
<th class="hilfe" style="width:100px;"
|
||
title="Geplante Zielfassung. Bei erledigten Einträgen die Fassung, in der es tatsächlich gelandet ist.">Ziel</th>
|
||
<th class="hilfe" style="width:120px;"
|
||
title="Wer das Item gerade exklusiv hält. Frei heißt: übernehmbar über ?action=next bzw. dc_next_task.">Wer</th>
|
||
<th class="hilfe" style="width:60px;"
|
||
title="Steht die Begründung in der Projektdatei? Blatt = doc_anchor gesetzt, Strich = es gibt nur den Zustand. Bei Zurückgestelltem und Verworfenem sollte hier nie ein Strich stehen.">Doku</th>
|
||
<th style="width:120px;"></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<?php $rmLastEpic = null; ?>
|
||
<?php foreach ($roadmapItems as $item): ?>
|
||
<?php
|
||
$itemEpic = (string)($item['epic'] ?? '');
|
||
// Der Schluessel traegt das Projekt mit: Ueber alle
|
||
// Projekte hinweg gibt es "A" mehrfach, und zwei
|
||
// gleichnamige Bereiche duerfen nicht unter einer
|
||
// Ueberschrift landen.
|
||
$epicKey = $item['project_slug'] . '/' . $itemEpic;
|
||
?>
|
||
<?php if ($epicKey !== $rmLastEpic): ?>
|
||
<tr style="background:rgba(255,255,255,0.02);">
|
||
<td colspan="9" style="padding:0.55rem 0.75rem; font-weight:700; font-size:0.82rem;">
|
||
<?= e($itemEpic) ?> — <?= e($roadmapEpicTitles[$epicKey] ?? 'Unbekannter Bereich') ?>
|
||
<?php if ($rmProject === 'all'): ?>
|
||
<span class="badge badge-stopped" style="margin-left:0.4rem;"><?= e((string)$item['project_slug']) ?></span>
|
||
<?php endif; ?>
|
||
</td>
|
||
</tr>
|
||
<?php $rmLastEpic = $epicKey; ?>
|
||
<?php endif; ?>
|
||
<tr>
|
||
<td style="font-family:'Roboto Mono'; color:var(--text-muted);">
|
||
<?= $item['roadmap_rank'] !== null ? (int)$item['roadmap_rank'] : '—' ?>
|
||
</td>
|
||
<td style="font-family:'Roboto Mono'; font-size:0.8rem;">#<?= (int)$item['id'] ?></td>
|
||
<td>
|
||
<strong><?= e((string)$item['title']) ?></strong>
|
||
<?php if ($rmProject === 'all'): ?>
|
||
<span class="badge badge-stopped" style="margin-left:0.4rem;"><?= e((string)$item['project_slug']) ?></span>
|
||
<?php endif; ?>
|
||
<?php if ((string)$item['status'] === 'deferred' && !empty($item['defer_trigger'])): ?>
|
||
<div style="font-size:0.78rem; color:var(--text-muted); margin-top:0.2rem;">
|
||
🧊 Auslöser: <?= e((string)$item['defer_trigger']) ?>
|
||
</div>
|
||
<?php endif; ?>
|
||
</td>
|
||
<td><?= e($itemEpic) ?></td>
|
||
<td><?= rmBadge((string)$item['status']) ?></td>
|
||
<td><?= $item['target_build'] ? '<code>' . e((string)$item['target_build']) . '</code>' : '—' ?></td>
|
||
<td><?= $item['claimed_by'] ? e((string)$item['claimed_by']) : '<span style="color:var(--text-muted);">frei</span>' ?></td>
|
||
<td style="text-align:center;">
|
||
<?php if (!empty($item['doc_anchor'])): ?>
|
||
<span title="<?= e((string)$item['doc_anchor']) ?>">📄</span>
|
||
<?php else: ?>
|
||
<span style="color:var(--text-muted);" title="Keine Begründung hinterlegt">—</span>
|
||
<?php endif; ?>
|
||
</td>
|
||
<td style="white-space:nowrap;">
|
||
<form method="POST" action="index.php#tab-roadmap" style="display:inline">
|
||
<?= $csrfField ?>
|
||
<input type="hidden" name="action" value="rm_move">
|
||
<input type="hidden" name="item_id" value="<?= (int)$item['id'] ?>">
|
||
<input type="hidden" name="direction" value="up">
|
||
<button type="submit" class="btn btn-sm btn-secondary" title="Nach oben">▲</button>
|
||
</form>
|
||
<form method="POST" action="index.php#tab-roadmap" style="display:inline">
|
||
<?= $csrfField ?>
|
||
<input type="hidden" name="action" value="rm_move">
|
||
<input type="hidden" name="item_id" value="<?= (int)$item['id'] ?>">
|
||
<input type="hidden" name="direction" value="down">
|
||
<button type="submit" class="btn btn-sm btn-secondary" title="Nach unten">▼</button>
|
||
</form>
|
||
<button type="button" class="btn btn-sm btn-secondary"
|
||
onclick='openRoadmapEdit(<?= htmlspecialchars(json_encode([
|
||
"id" => (int)$item["id"],
|
||
"title" => (string)$item["title"],
|
||
"epic" => $itemEpic,
|
||
"roadmap_rank" => $item["roadmap_rank"],
|
||
"target_build" => (string)($item["target_build"] ?? ""),
|
||
"defer_trigger" => (string)($item["defer_trigger"] ?? ""),
|
||
"doc_anchor" => (string)($item["doc_anchor"] ?? ""),
|
||
"status" => (string)$item["status"],
|
||
], JSON_UNESCAPED_UNICODE), ENT_QUOTES) ?>)'>✏️</button>
|
||
</td>
|
||
</tr>
|
||
<?php endforeach; ?>
|
||
<?php if ($roadmapItems === []): ?>
|
||
<tr><td colspan="9" style="color:var(--text-muted); padding:1rem;">
|
||
Keine Vorhaben mit diesen Filtern. Unter „Aufnahmeliste“ stehen die
|
||
Einträge, die noch keinen Bereich haben.
|
||
</td></tr>
|
||
<?php endif; ?>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- =============== Subtab: Aufnahmeliste =============== -->
|
||
<div id="sub-roadmap-intake" class="subtab-content">
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h2 class="card-title">➕ Noch nicht auf der Roadmap</h2>
|
||
</div>
|
||
<p style="color:var(--text-muted); font-size:0.85rem; margin-bottom:0.9rem;">
|
||
Offene Feature-Requests ohne Themenbereich. Ein Vorhaben steht auf der
|
||
Roadmap, sobald es einen Bereich hat — ein zweites Kennzeichen daneben
|
||
gibt es bewusst nicht, sonst könnten beide auseinanderlaufen.
|
||
</p>
|
||
<table>
|
||
<thead>
|
||
<tr><th style="width:70px;">Item</th><th>Titel</th><th style="width:110px;">Projekt</th>
|
||
<th style="width:110px;">Schweregrad</th><th style="width:110px;">Gemeldet</th><th style="width:90px;"></th></tr>
|
||
</thead>
|
||
<tbody>
|
||
<?php foreach ($roadmapCandidates as $cand): ?>
|
||
<tr>
|
||
<td style="font-family:'Roboto Mono'; font-size:0.8rem;">#<?= (int)$cand['id'] ?></td>
|
||
<td><?= e((string)$cand['title']) ?></td>
|
||
<td><span class="badge badge-stopped"><?= e((string)$cand['project_slug']) ?></span></td>
|
||
<td><?= e((string)$cand['severity']) ?></td>
|
||
<td style="color:var(--text-muted);"><?= e(dc_ago((string)$cand['first_seen_at'])) ?></td>
|
||
<td>
|
||
<button type="button" class="btn btn-sm"
|
||
onclick='openRoadmapEdit(<?= htmlspecialchars(json_encode([
|
||
"id" => (int)$cand["id"],
|
||
"title" => (string)$cand["title"],
|
||
"epic" => "",
|
||
"roadmap_rank" => null,
|
||
"target_build" => "",
|
||
"defer_trigger" => "",
|
||
"doc_anchor" => "",
|
||
"status" => (string)$cand["status"],
|
||
], JSON_UNESCAPED_UNICODE), ENT_QUOTES) ?>)'>Aufnehmen</button>
|
||
</td>
|
||
</tr>
|
||
<?php endforeach; ?>
|
||
<?php if ($roadmapCandidates === []): ?>
|
||
<tr><td colspan="6" style="color:var(--text-muted); padding:1rem;">
|
||
Nichts offen ohne Bereich — die Roadmap ist vollständig.
|
||
</td></tr>
|
||
<?php endif; ?>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- =============== Subtab: Themenbereiche =============== -->
|
||
<div id="sub-roadmap-epics" class="subtab-content">
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h2 class="card-title">🗂️ Themenbereich anlegen oder ändern</h2>
|
||
</div>
|
||
<!--
|
||
Der Bereichstitel steht hier und nicht an jedem Item:
|
||
sonst waere er in jeder Zeile anders schreibbar, und
|
||
drei Tippfehler ergaeben drei Bereiche.
|
||
-->
|
||
<form method="POST" action="index.php#sub-roadmap-epics">
|
||
<?= $csrfField ?>
|
||
<input type="hidden" name="action" value="rm_save_epic">
|
||
<div class="form-grid">
|
||
<div class="form-group">
|
||
<label class="form-label">Projekt</label>
|
||
<select name="project_slug" class="form-input" required>
|
||
<?php foreach ($projects as $p): ?>
|
||
<option value="<?= e($p['slug']) ?>" <?= $rmProject === $p['slug'] ? 'selected' : '' ?>>
|
||
<?= e($p['name']) ?>
|
||
</option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Kürzel (z. B. A, B, INFRA)</label>
|
||
<input type="text" name="epic" class="form-input" maxlength="24" required>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Titel</label>
|
||
<input type="text" name="title" class="form-input" maxlength="160" required>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Reihenfolge</label>
|
||
<input type="number" name="sort_order" class="form-input" value="100">
|
||
</div>
|
||
</div>
|
||
<div class="form-group" style="margin-top:0.9rem;">
|
||
<label class="form-label">Kurzbeschreibung (optional)</label>
|
||
<input type="text" name="summary" class="form-input"
|
||
placeholder="Wofür steht dieser Bereich? Ein Satz genügt.">
|
||
</div>
|
||
<button type="submit" class="btn" style="margin-top:1rem;">Bereich speichern</button>
|
||
</form>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-header"><h2 class="card-title">Vorhandene Bereiche</h2></div>
|
||
<table>
|
||
<thead>
|
||
<tr><th style="width:80px;">Kürzel</th><th>Titel</th><th style="width:120px;">Projekt</th>
|
||
<th style="width:90px;">Einträge</th><th style="width:90px;">Sortierung</th><th style="width:110px;"></th></tr>
|
||
</thead>
|
||
<tbody>
|
||
<?php foreach ($roadmapEpics as $rmE): ?>
|
||
<tr>
|
||
<td><strong style="font-family:'Roboto Mono';"><?= e((string)$rmE['epic']) ?></strong></td>
|
||
<td>
|
||
<?= e((string)$rmE['title']) ?>
|
||
<?php if (!empty($rmE['summary'])): ?>
|
||
<div style="font-size:0.78rem; color:var(--text-muted);"><?= e((string)$rmE['summary']) ?></div>
|
||
<?php endif; ?>
|
||
</td>
|
||
<td><span class="badge badge-stopped"><?= e((string)$rmE['project_slug']) ?></span></td>
|
||
<td><?= (int)$rmE['total'] ?></td>
|
||
<td style="color:var(--text-muted);"><?= (int)$rmE['sort_order'] ?></td>
|
||
<td>
|
||
<?php if ((int)$rmE['total'] === 0): ?>
|
||
<form method="POST" action="index.php#sub-roadmap-epics" style="display:inline"
|
||
onsubmit="return confirm('Bereich <?= e((string)$rmE['epic']) ?> löschen?');">
|
||
<?= $csrfField ?>
|
||
<input type="hidden" name="action" value="rm_delete_epic">
|
||
<input type="hidden" name="project_slug" value="<?= e((string)$rmE['project_slug']) ?>">
|
||
<input type="hidden" name="epic" value="<?= e((string)$rmE['epic']) ?>">
|
||
<button type="submit" class="btn btn-sm btn-danger">Löschen</button>
|
||
</form>
|
||
<?php else: ?>
|
||
<span style="font-size:0.78rem; color:var(--text-muted);">hat Einträge</span>
|
||
<?php endif; ?>
|
||
</td>
|
||
</tr>
|
||
<?php endforeach; ?>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- =============== Dialog: Roadmap-Angaben =============== -->
|
||
<div id="roadmapEditModal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.75); backdrop-filter:blur(8px); z-index:9999; align-items:center; justify-content:center; padding:1rem;">
|
||
<div class="card" style="max-width:720px; width:100%; max-height:90vh; overflow-y:auto; margin:0;">
|
||
<div class="card-header">
|
||
<h2 class="card-title">Roadmap-Angaben zu <span id="rmEditLabel"></span></h2>
|
||
</div>
|
||
<form method="POST" action="index.php#tab-roadmap">
|
||
<?= $csrfField ?>
|
||
<input type="hidden" name="action" value="rm_set">
|
||
<input type="hidden" name="item_id" id="rmEditId">
|
||
|
||
<div class="form-grid">
|
||
<div class="form-group">
|
||
<label class="form-label">Themenbereich</label>
|
||
<select name="epic" id="rmEditEpic" class="form-input">
|
||
<option value="">— kein Bereich (nicht auf der Roadmap) —</option>
|
||
<?php foreach ($roadmapEpics as $rmE): ?>
|
||
<option value="<?= e((string)$rmE['epic']) ?>">
|
||
<?= e((string)$rmE['epic']) ?> — <?= e((string)$rmE['title']) ?>
|
||
(<?= e((string)$rmE['project_slug']) ?>)
|
||
</option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Rang (leer = ans Ende)</label>
|
||
<input type="number" name="roadmap_rank" id="rmEditRank" class="form-input">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Zielfassung</label>
|
||
<input type="text" name="target_build" id="rmEditBuild" class="form-input" placeholder="z. B. 2.7.0">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Status</label>
|
||
<select name="status" id="rmEditStatus" class="form-input" onchange="rmToggleTrigger()">
|
||
<?php foreach (BugRepo::STATUSES as $st): ?>
|
||
<option value="<?= e($st) ?>">
|
||
<?= e((Roadmap::MARKERS[$st] ?? '·') . ' ' . (Roadmap::STATUS_LABELS[$st] ?? $st)) ?>
|
||
</option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group" style="margin-top:0.9rem;">
|
||
<label class="form-label">
|
||
Wo steht die Begründung? (Pfad und Anker in der Projektdatei)
|
||
</label>
|
||
<input type="text" name="doc_anchor" id="rmEditAnchor" class="form-input"
|
||
placeholder="docs/ROADMAP.md#142-symbol-upload">
|
||
</div>
|
||
|
||
<div class="form-group" id="rmTriggerGroup" style="margin-top:0.9rem;">
|
||
<label class="form-label">
|
||
🧊 Auslöser — woran erkennt man, dass „später“ jetzt ist?
|
||
</label>
|
||
<textarea name="defer_trigger" id="rmEditTrigger" class="form-input" rows="2"
|
||
placeholder="z. B.: sobald Logauszüge strukturiert hereinkommen"></textarea>
|
||
<div style="font-size:0.78rem; color:var(--text-muted); margin-top:0.3rem;">
|
||
Pflicht beim Zurückstellen. Ohne Auslöser ist ein Vorhaben nicht
|
||
zurückgestellt, sondern vergessen.
|
||
</div>
|
||
</div>
|
||
|
||
<div style="display:flex; justify-content:flex-end; gap:0.75rem; margin-top:1.2rem;">
|
||
<button type="button" class="btn btn-secondary" onclick="closeRoadmapEdit()">Abbrechen</button>
|
||
<button type="submit" class="btn">Speichern</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ================= MODULE: BUGTRACKER & FEATURE-TRACKER ================= -->
|
||
<div id="tab-bugtracker" class="tab-content">
|
||
|
||
<div id="sub-bugtracker-items" class="subtab-content active">
|
||
<div class="stats-grid" style="grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));">
|
||
<div class="stat-card">
|
||
<div class="stat-header">Dev Bugs (Entwicklung)</div>
|
||
<div class="stat-value" style="color:#5b9dff;"><?= $bugtrackerStats['open_bugs_dev'] ?></div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-header">Prod Bugs (Produktion)</div>
|
||
<div class="stat-value" style="color:var(--danger);"><?= $bugtrackerStats['open_bugs_prod'] ?></div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-header">Offene Feature Requests</div>
|
||
<div class="stat-value" style="color:var(--warning);"><?= $bugtrackerStats['open_features'] ?></div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-header">💡 Ideen & 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;">
|
||
<!-- Die offene Ansicht faehrt als verstecktes Feld mit. Ein GET-Formular
|
||
verwirft beim Absenden den Anker seiner action, und der Anker war
|
||
bisher die einzige Quelle dafuer, was angezeigt wird - nach jeder
|
||
Filteraenderung stand man wieder im Dashboard. Fest im Markup statt
|
||
aus dem DOM gelesen: hier ist ohne Zweifel bekannt, wo das Formular
|
||
steht. -->
|
||
<input type="hidden" name="view" value="sub-bugtracker-items">
|
||
<select name="bt_project" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="dcSubmit(this.form)">
|
||
<option value="all">🌐 Alle Projekte</option>
|
||
<?php foreach ($projects as $p): ?>
|
||
<option value="<?= e($p['slug']) ?>" <?= $btFilters['project_slug'] === $p['slug'] ? 'selected' : '' ?>>
|
||
<?= e($p['name']) ?>
|
||
</option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
|
||
<select name="bt_env" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="dcSubmit(this.form)">
|
||
<?php
|
||
$envOptions = [
|
||
'all' => '🌍 Alle Umgebungen',
|
||
'production' => '🔴 Produktion',
|
||
'development' => '🔵 Entwicklung',
|
||
'staging' => '🟡 Staging',
|
||
'testing' => '🧪 Testing',
|
||
];
|
||
foreach ($envOptions as $value => $label): ?>
|
||
<option value="<?= e($value) ?>" <?= $btFilters['environment'] === $value ? 'selected' : '' ?>><?= e($label) ?></option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
|
||
<select name="bt_type" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="dcSubmit(this.form)">
|
||
<?php
|
||
$typeOptions = [
|
||
'all' => '📂 Alle Typen',
|
||
'bug' => '🐛 Nur Bugs',
|
||
'feature_request' => '💡 Nur Features',
|
||
];
|
||
foreach ($typeOptions as $value => $label): ?>
|
||
<option value="<?= e($value) ?>" <?= $btFilters['type'] === $value ? 'selected' : '' ?>><?= e($label) ?></option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
|
||
<select name="bt_severity" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="dcSubmit(this.form)">
|
||
<?php
|
||
$sevOptions = [
|
||
'all' => '🔥 Alle Schweregrade',
|
||
'critical' => '🔥 Kritisch',
|
||
'high' => 'Hoch',
|
||
'medium' => 'Mittel',
|
||
'low' => 'Niedrig',
|
||
'wishlist' => '⭐ Wunschliste',
|
||
'idea' => '💡 Ideen',
|
||
];
|
||
foreach ($sevOptions as $value => $label): ?>
|
||
<option value="<?= e($value) ?>" <?= $btFilters['severity'] === $value ? 'selected' : '' ?>><?= e($label) ?></option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
|
||
<!--
|
||
Status als Mehrfachauswahl statt als Klappliste.
|
||
Vorher liess sich genau eine Lage waehlen, und wer
|
||
"offen und in Nachpruefung" sehen wollte, brauchte
|
||
eine vorgefertigte Kombination - stand sie nicht in
|
||
der Liste, ging es nicht.
|
||
|
||
Das versteckte Feld davor ist kein Schoenheitsfehler:
|
||
HTML sendet fuer nicht angehakte Kaestchen NICHTS.
|
||
Ohne es waere "alle Haken entfernt" von "erster
|
||
Aufruf" nicht zu unterscheiden, und die Vorgabe kaeme
|
||
bei jedem Versuch zurueck, sie loszuwerden.
|
||
|
||
Kein Absenden bei jedem Klick: Wer drei Status waehlt,
|
||
will einmal laden und nicht dreimal. Deshalb der
|
||
eigene Knopf im Klappfeld.
|
||
-->
|
||
<select name="bt_roadmap" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="dcSubmit(this.form)"
|
||
title="Ein Vorhaben steht auf der Roadmap, sobald es einen Themenbereich hat. Es bleibt ein Item wie jedes andere - es wird hier nur nicht doppelt gezeigt.">
|
||
<?php foreach ([
|
||
'ohne' => '🗺️ Ohne Roadmap',
|
||
'nur' => '🗺️ Nur Roadmap',
|
||
'alle' => '🗺️ Mit Roadmap',
|
||
] as $wert => $beschriftung): ?>
|
||
<option value="<?= e($wert) ?>" <?= $btRoadmap === $wert ? 'selected' : '' ?>><?= e($beschriftung) ?></option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
|
||
<details class="filter-mehrfach">
|
||
<summary title="Status auswaehlen">
|
||
<?php
|
||
$statusGruppen = [
|
||
'Laufend' => [
|
||
'open' => 'Offen',
|
||
'planned' => 'Geplant',
|
||
'in_progress' => 'In Bearbeitung',
|
||
],
|
||
'Verlangt Aufmerksamkeit' => [
|
||
'triage_external' => '📥 Fremdmeldung',
|
||
'awaiting_verification' => '🔎 Wartet auf Nachpruefung',
|
||
'in_verification' => '🔎 In Nachpruefung',
|
||
'fix_insufficient' => '❗ Fix traegt nicht',
|
||
],
|
||
'Abgeschlossen' => [
|
||
'resolved' => 'Gelöst',
|
||
'closed' => 'Geschlossen',
|
||
'rejected' => 'Abgelehnt',
|
||
'ignored' => 'Ignoriert',
|
||
'deferred' => 'Zurückgestellt',
|
||
],
|
||
];
|
||
|
||
$btStatusLabels = [];
|
||
foreach ($statusGruppen as $eintraege) {
|
||
$btStatusLabels += $eintraege;
|
||
}
|
||
|
||
$anzahl = count($btStatusAuswahl);
|
||
if ($anzahl === 0) {
|
||
$btStatusText = '📌 Alle Status';
|
||
} elseif ($anzahl === 1) {
|
||
$btStatusText = '📌 ' . ($btStatusLabels[$btStatusAuswahl[0]] ?? $btStatusAuswahl[0]);
|
||
} else {
|
||
$btStatusText = sprintf('📌 %d Status', $anzahl);
|
||
}
|
||
?>
|
||
<?= e($btStatusText) ?> ▾
|
||
</summary>
|
||
|
||
<div class="filter-mehrfach-inhalt">
|
||
<input type="hidden" name="bt_status[]" value="">
|
||
|
||
<?php foreach ($statusGruppen as $gruppe => $eintraege): ?>
|
||
<div class="filter-gruppe"><?= e($gruppe) ?></div>
|
||
<?php foreach ($eintraege as $wert => $beschriftung): ?>
|
||
<label>
|
||
<input type="checkbox" name="bt_status[]" value="<?= e($wert) ?>"
|
||
<?= in_array($wert, $btStatusAuswahl, true) ? 'checked' : '' ?>>
|
||
<?= e($beschriftung) ?>
|
||
</label>
|
||
<?php endforeach; ?>
|
||
<?php endforeach; ?>
|
||
|
||
<div class="filter-mehrfach-fuss">
|
||
<button type="button" class="btn btn-sm btn-secondary"
|
||
onclick="dcAlleHaken(this, false)">Keine</button>
|
||
<button type="button" class="btn btn-sm btn-secondary"
|
||
onclick="dcAlleHaken(this, true)">Alle</button>
|
||
<button type="submit" class="btn btn-sm">Übernehmen</button>
|
||
</div>
|
||
<div class="filter-hinweis">Kein Haken = keine Einschränkung.</div>
|
||
</div>
|
||
</details>
|
||
|
||
<select name="bt_order" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="dcSubmit(this.form)">
|
||
<?php
|
||
$orderOptions = [
|
||
'newest' => '🕒 Neueste zuerst',
|
||
'severity' => '🔥 Nach Schweregrad',
|
||
'occurrences' => '📈 Nach Häufigkeit',
|
||
'updated' => '✏️ Zuletzt geändert',
|
||
'oldest' => '⏳ Älteste zuerst',
|
||
];
|
||
foreach ($orderOptions as $value => $label): ?>
|
||
<option value="<?= e($value) ?>" <?= $btFilters['order'] === $value ? 'selected' : '' ?>><?= e($label) ?></option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
|
||
<input type="text" name="bt_q" value="<?= e($btFilters['search']) ?>" class="form-input"
|
||
style="width:190px; padding:0.3rem 0.6rem; font-size:0.8rem;" placeholder="🔍 Suchen oder #288"
|
||
title="Volltext ueber Titel, Beschreibung, Fehlermeldung, Tags und Dateipfad. Eine Zahl findet zusaetzlich das Item mit dieser Nummer; mit Raute (#288) nur dieses eine.">
|
||
|
||
<button type="submit" class="btn btn-sm">Filtern</button>
|
||
<?php
|
||
// Der Statusfilter zaehlt hier nicht mehr als "gesetzt":
|
||
// Er ist ab Werk eingeschraenkt, sonst stuende der
|
||
// Zuruecksetzen-Knopf immer da und bedeutete nichts.
|
||
$btAbweichend = $btStatusAuswahl !== BT_STATUS_VORGABE || $btRoadmap !== 'ohne';
|
||
if ($btFilters['search'] !== '' || $btFilters['project_slug'] !== 'all'
|
||
|| $btAbweichend || $btFilters['severity'] !== 'all'
|
||
|| $btFilters['type'] !== 'all' || $btFilters['environment'] !== 'all'): ?>
|
||
<a href="index.php#tab-bugtracker" class="btn btn-sm btn-secondary">Zurücksetzen</a>
|
||
<?php endif; ?>
|
||
</form>
|
||
|
||
<?php if ($btVerdeckt > 0): ?>
|
||
<!--
|
||
Der Fall: Jemand sucht "288", bekommt nichts und
|
||
schliesst daraus, es gebe den Eintrag nicht - dabei ist
|
||
er nur erledigt und deshalb ausgeblendet. Eine Suche,
|
||
die schweigt, obwohl sie etwas gefunden hat, ist
|
||
schlimmer als gar keine.
|
||
-->
|
||
<div class="alert" style="margin-top:0.75rem; display:flex; flex-wrap:wrap; align-items:center; gap:0.6rem; background:rgba(122,162,255,0.12); border:1px solid #7aa2ff; color:#cfe0ff;">
|
||
<span>
|
||
🔎 <strong><?= (int)$btVerdeckt ?></strong>
|
||
<?= $btVerdeckt === 1 ? 'weiterer Treffer' : 'weitere Treffer' ?>
|
||
für „<?= e($btFilters['search']) ?>"
|
||
<?= $btVerdeckt === 1 ? 'wird' : 'werden' ?>
|
||
ausgeblendet - vom Statusfilter oder weil sie auf der Roadmap stehen.
|
||
</span>
|
||
<a class="btn btn-sm btn-secondary"
|
||
<?php
|
||
// Ein leerer Eintrag, keine Zeichenkette "all":
|
||
// btUrl() wirft 'all' als "nicht gesetzt" heraus,
|
||
// und dann griffe wieder die Vorgabe. Als Liste mit
|
||
// einem leeren Wert bleibt der Parameter in der URL
|
||
// stehen und bedeutet ausdruecklich "ohne Filter".
|
||
?>
|
||
href="<?= e(btUrl(['bt_status' => [''], 'bt_roadmap' => 'alle', 'bt_page' => null])) ?>">Überall suchen</a>
|
||
</div>
|
||
<?php endif; ?>
|
||
</div>
|
||
|
||
<!--
|
||
Die Breiten stehen hier und nicht im Stylesheet, weil
|
||
sie zu dieser Tabelle gehoeren und zu keiner anderen.
|
||
"Projekt & Titel" bekommt bewusst keine: Bei
|
||
table-layout:fixed nimmt die Spalte ohne Angabe den
|
||
Rest - sie waechst also mit dem Bildschirm, waehrend
|
||
die uebrigen stehen bleiben.
|
||
-->
|
||
<div class="tabelle-scroll">
|
||
<table class="tabelle-fest">
|
||
<thead>
|
||
<tr>
|
||
<?php // Die Breiten sind am breitesten Abzeichen der Spalte
|
||
// gemessen, zuzueglich 32px Zellenpolsterung - nicht
|
||
// geschaetzt. Bei "Status" gibt
|
||
// "WARTET AUF PRUEFUNG" mit 158px den Ausschlag,
|
||
// bei "ID / Typ" das FEATURE-Abzeichen mit 88px. ?>
|
||
<th style="width:124px;">ID / Typ</th>
|
||
<th style="width:104px;">Umgebung</th>
|
||
<th>Projekt & Titel</th>
|
||
<th style="width:104px;">Build</th>
|
||
<th style="width:132px;">Schweregrad</th>
|
||
<th style="width:82px;">Anzahl</th>
|
||
<th style="width:194px;">Status</th>
|
||
<th style="width:150px;">Aktionen</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="btTableBody">
|
||
<?php if ($bugtrackerItems === []): ?>
|
||
<tr>
|
||
<td colspan="8" style="text-align:center; padding:2.5rem; color:var(--text-muted);">
|
||
Keine Einträge für diese Filterkombination.
|
||
</td>
|
||
</tr>
|
||
<?php endif; ?>
|
||
|
||
<?php foreach ($bugtrackerItems as $item): ?>
|
||
<?php
|
||
$typeBadge = $item['type'] === 'feature_request'
|
||
? '<span class="badge" style="background:rgba(180,100,255,0.2); color:#c87dff; border:1px solid #c87dff;">💡 FEATURE</span>'
|
||
: '<span class="badge badge-warning">🐛 BUG</span>';
|
||
|
||
$envBadge = match ((string)$item['environment']) {
|
||
'development' => '<span class="badge" style="background:rgba(91,157,255,0.2); color:#5b9dff; border:1px solid #5b9dff;">🔵 DEV</span>',
|
||
'production' => '<span class="badge badge-down">🔴 PROD</span>',
|
||
default => '<span class="badge badge-stopped">' . e(strtoupper((string)$item['environment'])) . '</span>',
|
||
};
|
||
|
||
$statusBadge = match ((string)$item['status']) {
|
||
'open' => '<span class="badge badge-down">OFFEN</span>',
|
||
'planned' => '<span class="badge" style="background:rgba(255,165,0,0.2); color:orange; border:1px solid orange;">GEPLANT</span>',
|
||
'in_progress' => '<span class="badge badge-warning">IN BEARBEITUNG</span>',
|
||
'resolved' => '<span class="badge badge-up">GELÖST</span>',
|
||
'rejected' => '<span class="badge badge-stopped">ABGELEHNT</span>',
|
||
'triage_external' => '<span class="badge" style="background:rgba(120,160,255,0.2); color:#7aa2ff; border:1px solid #7aa2ff;">📥 FREMDMELDUNG</span>',
|
||
'awaiting_verification' => '<span class="badge" style="background:rgba(0,210,255,0.18); color:#5ce1e6; border:1px solid #5ce1e6;">🔎 WARTET AUF PRÜFUNG</span>',
|
||
'in_verification' => '<span class="badge" style="background:rgba(0,210,255,0.3); color:#5ce1e6; border:1px solid #5ce1e6;">🔎 WIRD GEPRÜFT</span>',
|
||
'fix_insufficient' => '<span class="badge badge-down" style="font-weight:bold;">❗ FIX TRÄGT NICHT</span>',
|
||
default => '<span class="badge badge-stopped">' . e(strtoupper((string)$item['status'])) . '</span>',
|
||
};
|
||
|
||
$sevBadge = match ((string)$item['severity']) {
|
||
'idea' => '<span class="badge" style="background:rgba(215,100,255,0.25); color:#e082ff; border:1px solid #e082ff;">💡 IDEE</span>',
|
||
'wishlist' => '<span class="badge" style="background:rgba(0,210,255,0.25); color:#5ce1e6; border:1px solid #5ce1e6;">⭐ WUNSCH</span>',
|
||
'critical' => '<span class="badge badge-down" style="font-weight:bold;">🔥 KRITISCH</span>',
|
||
'high' => '<span class="badge badge-warning">HOCH</span>',
|
||
'medium' => '<span class="badge badge-stopped">MITTEL</span>',
|
||
default => '<span class="badge badge-stopped">NIEDRIG</span>',
|
||
};
|
||
|
||
// json_encode liefert eine gueltige JS-Literaldarstellung; das
|
||
// vorherige addslashes() konnte an Zeilenumbruechen zerbrechen.
|
||
$jsonItem = e((string)json_encode($item, JSON_INVALID_UTF8_SUBSTITUTE));
|
||
$jsTitle = e((string)json_encode((string)$item['title'], JSON_INVALID_UTF8_SUBSTITUTE));
|
||
|
||
$isClaimed = !empty($item['claimed_by'])
|
||
&& !empty($item['lease_until'])
|
||
&& strtotime((string)$item['lease_until'] . ' UTC') > time();
|
||
?>
|
||
<tr class="bt-row">
|
||
<td>#<?= (int)$item['id'] ?><br><?= $typeBadge ?></td>
|
||
<td><?= $envBadge ?></td>
|
||
<td class="zelle-titel">
|
||
<div class="titel" title="<?= e((string)$item['title']) ?>"><?= e((string)$item['title']) ?></div>
|
||
<div class="meta">
|
||
<span>Projekt: <code><?= e((string)$item['project_slug']) ?></code></span>
|
||
<span>Von: <?= e((string)$item['created_by']) ?></span>
|
||
<span title="Zuletzt gesehen"><?= e(dc_ago($item['last_seen_at'] ?? null)) ?></span>
|
||
<?php if ($isClaimed): ?>
|
||
<span class="badge" style="background:rgba(245,166,35,0.2); color:#fbbf24; font-size:0.7rem;">
|
||
🔒 <?= e((string)$item['claimed_by']) ?>
|
||
</span>
|
||
<?php endif; ?>
|
||
<?php if (!empty($item['push_id'])): ?>
|
||
<span class="badge" style="background:rgba(255,255,255,0.06); font-family:monospace; font-size:0.7rem;">📲 <?= e((string)$item['push_id']) ?></span>
|
||
<?php endif; ?>
|
||
<?php if (!empty($item['target_agent'])): ?>
|
||
<span class="badge" style="background:rgba(0,200,100,0.15); color:#66bb6a; font-size:0.7rem;">🤖 <?= e((string)$item['target_agent']) ?></span>
|
||
<?php endif; ?>
|
||
<?php if (!empty($item['file_path'])): ?>
|
||
<span class="badge" style="background:rgba(255,255,255,0.06); font-family:monospace; font-size:0.7rem;">
|
||
📄 <?= e((string)$item['file_path']) ?><?= !empty($item['line_no']) ? ':' . (int)$item['line_no'] : '' ?>
|
||
</span>
|
||
<?php endif; ?>
|
||
<?php if (!empty($item['tags'])): ?>
|
||
<span class="badge" style="background:rgba(100,180,255,0.15); color:#90caf9; font-size:0.7rem;">🏷️ <?= e((string)$item['tags']) ?></span>
|
||
<?php endif; ?>
|
||
</div>
|
||
</td>
|
||
<td><code><?= e((string)($item['build_version'] ?? 'unbekannt')) ?></code></td>
|
||
<td><?= $sevBadge ?></td>
|
||
<td>
|
||
<span class="badge" style="background:rgba(255,255,255,0.08); font-family:monospace;">
|
||
<?= (int)$item['occurrence_count'] ?>x
|
||
</span>
|
||
</td>
|
||
<td><?= $statusBadge ?></td>
|
||
<td class="zelle-aktionen">
|
||
<div>
|
||
<button type="button" class="btn btn-sm btn-secondary" title="Bearbeiten" onclick="openEditBugtrackerModal(<?= $jsonItem ?>)">✏️</button>
|
||
<button type="button" class="btn btn-sm btn-secondary" onclick="openBugtrackerModal(<?= (int)$item['id'] ?>)">🔍</button>
|
||
<?php if (!in_array((string)$item['status'], ['resolved', 'closed'], true)): ?>
|
||
<button type="button" class="btn btn-sm" onclick="openResolveModal(<?= (int)$item['id'] ?>, <?= $jsTitle ?>)">✔</button>
|
||
<?php endif; ?>
|
||
<?php if ($isClaimed): ?>
|
||
<form method="POST" action="index.php#tab-bugtracker" style="display:inline"
|
||
onsubmit="return confirm('Zuweisung an <?= e((string)$item['claimed_by']) ?> aufheben?');">
|
||
<?= $csrfField ?>
|
||
<input type="hidden" name="action" value="bt_release_claim">
|
||
<input type="hidden" name="item_id" value="<?= (int)$item['id'] ?>">
|
||
<button type="submit" class="btn btn-sm btn-secondary" title="Zuweisung aufheben">🔓</button>
|
||
</form>
|
||
<?php endif; ?>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
<?php endforeach; ?>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<?php if ($bugtrackerPages > 1): ?>
|
||
<div style="display:flex; justify-content:space-between; align-items:center; margin-top:1rem; padding-top:1rem; border-top:1px solid var(--border-soft);">
|
||
<span style="font-size:0.8rem; color:var(--text-muted);">
|
||
Seite <?= (int)$btPage ?> von <?= (int)$bugtrackerPages ?>
|
||
(<?= (int)$bugtrackerTotal ?> Einträge)
|
||
</span>
|
||
<div style="display:flex; gap:0.4rem;">
|
||
<?php if ($btPage > 1): ?>
|
||
<a href="<?= e(btUrl(['bt_page' => $btPage - 1])) ?>" class="btn btn-sm btn-secondary">← Zurück</a>
|
||
<?php endif; ?>
|
||
<?php if ($btPage < $bugtrackerPages): ?>
|
||
<a href="<?= e(btUrl(['bt_page' => $btPage + 1])) ?>" class="btn btn-sm btn-secondary">Weiter →</a>
|
||
<?php endif; ?>
|
||
</div>
|
||
</div>
|
||
<?php endif; ?>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Subtab: Item manuell anlegen -->
|
||
<div id="sub-bugtracker-new" class="subtab-content">
|
||
<div class="card">
|
||
<div class="card-header"><h2 class="card-title">➕ Bug, Feature Request oder Idee Erfassen</h2></div>
|
||
<form method="POST" action="index.php#tab-bugtracker">
|
||
<?= $csrfField ?>
|
||
<input type="hidden" name="action" value="bt_create_item">
|
||
<div class="form-grid">
|
||
<div class="form-group">
|
||
<label class="form-label">Projekt</label>
|
||
<select name="project_slug" class="form-input" required>
|
||
<?php foreach ($projects as $p): ?>
|
||
<option value="<?= htmlspecialchars($p['slug']) ?>"><?= htmlspecialchars($p['name']) ?> (<?= htmlspecialchars($p['slug']) ?>)</option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Typ</label>
|
||
<select name="type" class="form-input" required>
|
||
<option value="bug" selected>🐛 Bug / Fehlerbericht</option>
|
||
<option value="feature_request">💡 Feature Request / Vorschlag / Idee</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Umgebung (Environment)</label>
|
||
<select name="environment" class="form-input" required>
|
||
<option value="development">🔵 Entwicklung (Development)</option>
|
||
<option value="production" selected>🔴 Produktion (Production)</option>
|
||
<option value="staging">🟡 Staging</option>
|
||
<option value="testing">🧪 Testing</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Schweregrad / Priorität</label>
|
||
<select name="severity" class="form-input" required>
|
||
<option value="idea">💡 Idee / Gedanke für Später</option>
|
||
<option value="wishlist">⭐ Wunschliste / Backlog</option>
|
||
<option value="low">Niedrig</option>
|
||
<option value="medium" selected>Mittel</option>
|
||
<option value="high">Hoch</option>
|
||
<option value="critical">🔥 Kritisch</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="form-grid" style="margin-top:1rem;">
|
||
<div class="form-group">
|
||
<label class="form-label">Push-ID (Optional, z. B. Workflow / Notification Token)</label>
|
||
<input type="text" name="push_id" class="form-input" placeholder="z. B. push_notify_88192">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Ziel-Agent / Zuweisung (Optional)</label>
|
||
<input type="text" name="target_agent" class="form-input" placeholder="z. B. agent:code-fixer-01">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Tags / Kategorien (Kommagetrennt)</label>
|
||
<input type="text" name="tags" class="form-input" placeholder="z. B. ui, security, database">
|
||
</div>
|
||
</div>
|
||
<div class="form-group" style="margin-top:1rem;">
|
||
<label class="form-label">Titel / Zusammenfassung</label>
|
||
<input type="text" name="title" class="form-input" required placeholder="z. B. NullReferenceException bei Order-Submit oder Idee für TOTP 2FA">
|
||
</div>
|
||
<div class="form-group" style="margin-top:1rem;">
|
||
<label class="form-label">Build / Version</label>
|
||
<input type="text" name="build_version" class="form-input" value="v1.4.2" placeholder="v1.4.2">
|
||
</div>
|
||
<div class="form-group" style="margin-top:1rem;">
|
||
<label class="form-label">Detaillierte Beschreibung</label>
|
||
<textarea name="description" class="form-input" rows="3" placeholder="Was ist passiert? Unter welchen Bedingungen? Oder was soll umgesetzt werden?"></textarea>
|
||
</div>
|
||
<div class="form-group" style="margin-top:1rem;">
|
||
<label class="form-label">Fehlermeldung / Exception Message (Nur bei Bugs)</label>
|
||
<textarea name="error_message" class="form-input" rows="2" placeholder="Exakte Fehlermeldung aus dem Log..."></textarea>
|
||
</div>
|
||
<div class="form-group" style="margin-top:1rem;">
|
||
<label class="form-label">Stacktrace / Log Ausschnitt (Nur bei Bugs)</label>
|
||
<textarea name="stack_trace" class="form-input" rows="4" style="font-family:monospace;" placeholder="at MyApp.Core.Service.DoWork()..."></textarea>
|
||
</div>
|
||
<button type="submit" class="btn" style="margin-top:1rem;">Item Speichern & Anlegen</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- =============== Subtab: Fehler-Stream =============== -->
|
||
<div id="sub-bugtracker-errors" class="subtab-content">
|
||
<div class="stats-grid" style="grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));">
|
||
<div class="stat-card">
|
||
<div class="stat-header">Fehler (24 h)</div>
|
||
<div class="stat-value" style="color:var(--warning);"><?= $bugtrackerStats['errors_24h'] ?></div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-header">💀 Offene Abstürze</div>
|
||
<div class="stat-value" style="color:var(--danger);"><?= $bugtrackerStats['fatal_open'] ?></div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-header">🔇 Stummgeschaltete Gruppen</div>
|
||
<div class="stat-value" style="color:var(--text-muted);"><?= $bugtrackerStats['ignored_groups'] ?></div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-header">davon Vorkommnisse</div>
|
||
<div class="stat-value" style="color:var(--text-muted); font-size:1.5rem;">
|
||
<?= number_format($bugtrackerStats['ignored_occurrences'], 0, ',', '.') ?>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<?php if ($bugtrackerStats['ignored_occurrences'] > 0): ?>
|
||
<div class="alert" style="background:rgba(91,157,255,0.10); border:1px solid rgba(91,157,255,0.25); color:#bfdbfe; font-weight:500; font-size:0.85rem;">
|
||
<?= number_format($bugtrackerStats['ignored_occurrences'], 0, ',', '.') ?> Vorkommnisse
|
||
wurden als bekannt eingestuft und aus dieser Ansicht herausgehalten — ohne sie zu verwerfen.
|
||
Der Zähler bleibt erhalten, damit eine auffällige Häufung trotzdem auffällt.
|
||
</div>
|
||
<?php endif; ?>
|
||
|
||
<div class="card">
|
||
<div class="card-header" style="display:flex; flex-wrap:wrap; justify-content:space-between; align-items:center; gap:0.75rem;">
|
||
<h2 class="card-title" style="margin:0;">
|
||
⚡ Fehler-Stream
|
||
<span class="badge badge-stopped" style="margin-left:0.5rem;"><?= (int)$errorTotal ?> Gruppen</span>
|
||
</h2>
|
||
|
||
<form method="GET" action="index.php" style="display:flex; flex-wrap:wrap; gap:0.4rem; align-items:center;">
|
||
<!-- Die offene Ansicht faehrt als verstecktes Feld mit. Ein GET-Formular
|
||
verwirft beim Absenden den Anker seiner action, und der Anker war
|
||
bisher die einzige Quelle dafuer, was angezeigt wird - nach jeder
|
||
Filteraenderung stand man wieder im Dashboard. Fest im Markup statt
|
||
aus dem DOM gelesen: hier ist ohne Zweifel bekannt, wo das Formular
|
||
steht. -->
|
||
<input type="hidden" name="view" value="sub-bugtracker-errors">
|
||
<select name="err_project" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="dcSubmit(this.form)">
|
||
<option value="all">🌐 Alle Projekte</option>
|
||
<?php foreach ($projects as $p): ?>
|
||
<option value="<?= e($p['slug']) ?>" <?= $errFilters['project_slug'] === $p['slug'] ? 'selected' : '' ?>>
|
||
<?= e($p['name']) ?>
|
||
</option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
|
||
<select name="err_level" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="dcSubmit(this.form)">
|
||
<?php
|
||
$levelOptions = [
|
||
'all' => '🎚️ Alle Klassen',
|
||
'fatal' => '💀 Absturz (fatal)',
|
||
'error' => '⚠️ Fehler (error)',
|
||
'warning' => 'ℹ️ Warnung',
|
||
];
|
||
foreach ($levelOptions as $value => $label): ?>
|
||
<option value="<?= e($value) ?>" <?= $errFilters['error_level'] === $value ? 'selected' : '' ?>><?= e($label) ?></option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
|
||
<select name="err_env" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="dcSubmit(this.form)">
|
||
<?php
|
||
$errEnvOptions = [
|
||
'all' => '🌍 Alle Umgebungen',
|
||
'production' => '🔴 Produktion',
|
||
'development' => '🔵 Entwicklung',
|
||
'staging' => '🟡 Staging',
|
||
'testing' => '🧪 Testing',
|
||
];
|
||
foreach ($errEnvOptions as $value => $label): ?>
|
||
<option value="<?= e($value) ?>" <?= $errFilters['environment'] === $value ? 'selected' : '' ?>><?= e($label) ?></option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
|
||
<select name="err_show" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="dcSubmit(this.form)">
|
||
<?php
|
||
$showOptions = [
|
||
'active' => '🔔 Nur relevante',
|
||
'ignored' => '🔇 Nur stummgeschaltete',
|
||
'all' => '📋 Alle',
|
||
];
|
||
foreach ($showOptions as $value => $label): ?>
|
||
<option value="<?= e($value) ?>" <?= $errShow === $value ? 'selected' : '' ?>><?= e($label) ?></option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
|
||
<select name="err_hours" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="dcSubmit(this.form)">
|
||
<?php
|
||
$hourOptions = [
|
||
'0' => '🕒 Gesamter Zeitraum',
|
||
'1' => 'Letzte Stunde',
|
||
'24' => 'Letzte 24 Stunden',
|
||
'168' => 'Letzte 7 Tage',
|
||
'720' => 'Letzte 30 Tage',
|
||
];
|
||
foreach ($hourOptions as $value => $label): ?>
|
||
<option value="<?= e($value) ?>" <?= (string)$errFilters['since_hours'] === $value ? 'selected' : '' ?>><?= e($label) ?></option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
|
||
<input type="text" name="err_q" value="<?= e($errFilters['search']) ?>" class="form-input"
|
||
style="width:190px; padding:0.3rem 0.6rem; font-size:0.8rem;" placeholder="🔍 Suchen oder #288"
|
||
title="Volltext ueber Titel, Beschreibung, Fehlermeldung, Tags und Dateipfad. Eine Zahl findet zusaetzlich das Item mit dieser Nummer; mit Raute (#288) nur dieses eine.">
|
||
<button type="submit" class="btn btn-sm">Filtern</button>
|
||
<a href="index.php#sub-bugtracker-errors" class="btn btn-sm btn-secondary">Zurücksetzen</a>
|
||
</form>
|
||
</div>
|
||
|
||
<!-- Dieselbe feste Verteilung wie im Bugtracker: Ohne sie zieht
|
||
eine lange Exception-Meldung die Fehlerspalte auf und quetscht
|
||
den Rest zusammen, je Zeile unterschiedlich weit. -->
|
||
<div class="tabelle-scroll">
|
||
<table class="tabelle-fest">
|
||
<thead>
|
||
<tr>
|
||
<th style="width:110px;">Klasse</th>
|
||
<th>Fehler</th>
|
||
<th style="width:180px;">Projekt / Umgebung</th>
|
||
<th style="width:104px;">Build</th>
|
||
<th style="width:82px;">Anzahl</th>
|
||
<th style="width:120px;">Zuletzt</th>
|
||
<th style="width:150px;">Aktionen</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<?php if ($errorItems === []): ?>
|
||
<tr>
|
||
<td colspan="7" style="text-align:center; padding:2.5rem; color:var(--text-muted);">
|
||
Keine Fehler für diese Filterkombination.
|
||
<?php if ($errShow === 'active'): ?>
|
||
<br><small>Stummgeschaltete Einträge sind ausgeblendet — über „Nur stummgeschaltete“ sichtbar.</small>
|
||
<?php endif; ?>
|
||
</td>
|
||
</tr>
|
||
<?php endif; ?>
|
||
|
||
<?php foreach ($errorItems as $item): ?>
|
||
<?php
|
||
$isIgnored = (string)$item['status'] === 'ignored';
|
||
$level = (string)($item['error_level'] ?? 'error');
|
||
|
||
$levelBadge = match ($level) {
|
||
'fatal' => '<span class="badge badge-down" style="font-weight:bold;">💀 FATAL</span>',
|
||
'warning' => '<span class="badge" style="background:rgba(91,157,255,0.2); color:#5b9dff; border:1px solid #5b9dff;">ℹ️ WARN</span>',
|
||
default => '<span class="badge badge-warning">⚠️ ERROR</span>',
|
||
};
|
||
|
||
// Rate im laufenden Fenster – auffällige Häufung sichtbar machen
|
||
$windowCount = (int)($item['rate_window_count'] ?? 0);
|
||
$windowFresh = !empty($item['rate_window_start'])
|
||
&& (time() - (int)strtotime((string)$item['rate_window_start'] . ' UTC')) < 3600;
|
||
?>
|
||
<tr style="<?= $isIgnored ? 'opacity:0.55;' : '' ?>">
|
||
<td><?= $levelBadge ?></td>
|
||
<td class="zelle-titel">
|
||
<?php // Kein mb_strimwidth mehr - die Kuerzung macht jetzt CSS,
|
||
// und zwar passend zur tatsaechlichen Spaltenbreite. 90 Zeichen
|
||
// waren auf einem breiten Schirm zu wenig und auf einem
|
||
// schmalen zu viel. ?>
|
||
<div class="titel" title="<?= e((string)$item['title']) ?>"><?= e((string)$item['title']) ?></div>
|
||
<div class="meta">
|
||
<?php if ($isIgnored): ?>
|
||
<span class="badge" style="background:rgba(255,255,255,0.06); font-size:0.68rem;">
|
||
🔇 stummgeschaltet<?= !empty($item['ignore_rule_id']) ? ' (Regel #' . (int)$item['ignore_rule_id'] . ')' : '' ?>
|
||
</span>
|
||
<?php endif; ?>
|
||
<?php if ($windowFresh && $windowCount > 1): ?>
|
||
<span class="badge" style="background:rgba(245,166,35,0.18); color:#fbbf24; font-size:0.68rem;">
|
||
📈 <?= $windowCount ?>× in dieser Stunde
|
||
</span>
|
||
<?php endif; ?>
|
||
<?php if (!empty($item['file_path'])): ?>
|
||
<span style="font-family:monospace;">📄 <?= e((string)$item['file_path']) ?><?= !empty($item['line_no']) ? ':' . (int)$item['line_no'] : '' ?></span>
|
||
<?php endif; ?>
|
||
</div>
|
||
</td>
|
||
<td>
|
||
<code style="font-size:0.75rem;"><?= e((string)$item['project_slug']) ?></code>
|
||
<div style="font-size:0.7rem; color:var(--text-muted);"><?= e(strtoupper((string)$item['environment'])) ?></div>
|
||
</td>
|
||
<td><code style="font-size:0.75rem;"><?= e((string)($item['build_version'] ?? '—')) ?></code></td>
|
||
<td>
|
||
<span class="badge" style="background:rgba(255,255,255,0.08); font-family:monospace;">
|
||
<?= number_format((int)$item['occurrence_count'], 0, ',', '.') ?>×
|
||
</span>
|
||
</td>
|
||
<td style="font-size:0.75rem; color:var(--text-muted);"><?= e(dc_ago($item['last_seen_at'] ?? null)) ?></td>
|
||
<td class="zelle-aktionen">
|
||
<div>
|
||
<button type="button" class="btn btn-sm btn-secondary"
|
||
onclick="openBugtrackerModal(<?= (int)$item['id'] ?>)" title="Details">🔍</button>
|
||
|
||
<form method="POST" action="index.php#sub-bugtracker-errors" style="display:inline">
|
||
<?= $csrfField ?>
|
||
<input type="hidden" name="action" value="bt_set_ignored">
|
||
<input type="hidden" name="item_id" value="<?= (int)$item['id'] ?>">
|
||
<input type="hidden" name="ignore" value="<?= $isIgnored ? '0' : '1' ?>">
|
||
<button type="submit" class="btn btn-sm btn-secondary"
|
||
title="<?= $isIgnored ? 'Wieder aufnehmen' : 'Stummschalten' ?>">
|
||
<?= $isIgnored ? '🔔' : '🔇' ?>
|
||
</button>
|
||
</form>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
<?php endforeach; ?>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<?php if ($errorPages > 1): ?>
|
||
<div style="display:flex; justify-content:space-between; align-items:center; margin-top:1rem; padding-top:1rem; border-top:1px solid var(--border-soft);">
|
||
<span style="font-size:0.8rem; color:var(--text-muted);">
|
||
Seite <?= (int)$errPage ?> von <?= (int)$errorPages ?> (<?= (int)$errorTotal ?> Gruppen)
|
||
</span>
|
||
<div style="display:flex; gap:0.4rem;">
|
||
<?php if ($errPage > 1): ?>
|
||
<a href="<?= e(errUrl(['err_page' => $errPage - 1])) ?>" class="btn btn-sm btn-secondary">← Zurück</a>
|
||
<?php endif; ?>
|
||
<?php if ($errPage < $errorPages): ?>
|
||
<a href="<?= e(errUrl(['err_page' => $errPage + 1])) ?>" class="btn btn-sm btn-secondary">Weiter →</a>
|
||
<?php endif; ?>
|
||
</div>
|
||
</div>
|
||
<?php endif; ?>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- =============== Subtab: Ignore-Regeln =============== -->
|
||
<div id="sub-bugtracker-ignore" class="subtab-content">
|
||
<div class="card">
|
||
<div class="card-header"><h2 class="card-title">🔇 Bekannte, harmlose Fehler</h2></div>
|
||
<p style="color:var(--text-muted); font-size:0.875rem; margin-bottom:1.25rem; max-width:70ch;">
|
||
Ein Treffer bedeutet nicht „wegwerfen“: Der Fehler wird weiterhin erfasst und
|
||
hochgezählt, bleibt aber aus der Übersicht heraus und löst keine Benachrichtigung
|
||
aus. Der Zähler ist dabei der eigentliche Gewinn — dass ein bekannter Fehler
|
||
auftritt, ist normal; dass er plötzlich hundertmal so oft auftritt, ist ein Signal.
|
||
Dafür ist die Alarmschwelle da.
|
||
</p>
|
||
|
||
<form method="POST" action="index.php#sub-bugtracker-ignore">
|
||
<?= $csrfField ?>
|
||
<input type="hidden" name="action" value="ir_create">
|
||
|
||
<div class="form-grid">
|
||
<div class="form-group">
|
||
<label class="form-label">Projekt</label>
|
||
<select name="project_slug" class="form-input">
|
||
<option value="">🌐 Alle Projekte</option>
|
||
<?php foreach ($projects as $p): ?>
|
||
<option value="<?= e($p['slug']) ?>"><?= e($p['name']) ?></option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Vergleichsart</label>
|
||
<select name="match_type" class="form-input">
|
||
<option value="contains">Enthält (empfohlen)</option>
|
||
<option value="exception_class">Ausnahmeklasse exakt</option>
|
||
<option value="regex">Regulärer Ausdruck</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Alarm ab … pro Stunde</label>
|
||
<input type="number" name="alert_on_rate" class="form-input" min="0" placeholder="z. B. 500">
|
||
<small style="font-size:0.7rem; color:var(--text-muted);">0 oder leer = nie alarmieren</small>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group" style="margin-top:1rem;">
|
||
<label class="form-label">Suchmuster</label>
|
||
<input type="text" name="pattern" class="form-input" required
|
||
placeholder="z. B. Duplicate entry">
|
||
</div>
|
||
|
||
<div class="form-group" style="margin-top:1rem;">
|
||
<label class="form-label">Begründung — warum ist das harmlos?</label>
|
||
<textarea name="reason" class="form-input" rows="2" required
|
||
placeholder="z. B. Marktdaten kommen doppelt aus dem Feed. Der Eintrag wird ohnehin nur einmal benötigt, die Verarbeitung läuft normal weiter."></textarea>
|
||
<small style="font-size:0.7rem; color:var(--text-muted);">
|
||
Pflichtfeld: In sechs Monaten weiß sonst niemand mehr, warum hier weggeschaut wird.
|
||
</small>
|
||
</div>
|
||
|
||
<label style="display:flex; align-items:center; gap:0.5rem; margin-top:1rem; font-size:0.85rem; cursor:pointer;">
|
||
<input type="checkbox" name="apply_retroactively" value="1" checked>
|
||
Bereits erfasste, passende Einträge gleich mit stummschalten
|
||
</label>
|
||
|
||
<button type="submit" class="btn" style="margin-top:1.25rem;">Regel anlegen</button>
|
||
</form>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-header"><h2 class="card-title">Bestehende Regeln</h2></div>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>#</th>
|
||
<th>Projekt</th>
|
||
<th>Muster</th>
|
||
<th>Begründung</th>
|
||
<th>Alarmschwelle</th>
|
||
<th>Treffer</th>
|
||
<th>Status</th>
|
||
<th>Aktionen</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<?php if ($ignoreRules === []): ?>
|
||
<tr>
|
||
<td colspan="8" style="text-align:center; padding:2rem; color:var(--text-muted);">
|
||
Noch keine Regeln angelegt.
|
||
</td>
|
||
</tr>
|
||
<?php endif; ?>
|
||
|
||
<?php foreach ($ignoreRules as $rule): ?>
|
||
<?php $enabled = !empty($rule['enabled']); ?>
|
||
<tr style="<?= $enabled ? '' : 'opacity:0.5;' ?>">
|
||
<td><?= (int)$rule['id'] ?></td>
|
||
<td>
|
||
<?php if (empty($rule['project_slug'])): ?>
|
||
<span class="badge badge-stopped">ALLE</span>
|
||
<?php else: ?>
|
||
<code style="font-size:0.75rem;"><?= e((string)$rule['project_slug']) ?></code>
|
||
<?php endif; ?>
|
||
</td>
|
||
<td>
|
||
<code style="font-size:0.78rem;"><?= e((string)$rule['pattern']) ?></code>
|
||
<div style="font-size:0.68rem; color:var(--text-muted);"><?= e((string)$rule['match_type']) ?></div>
|
||
</td>
|
||
<td style="font-size:0.78rem; color:var(--text-muted); max-width:320px;">
|
||
<?= e(mb_strimwidth((string)$rule['reason'], 0, 160, '…')) ?>
|
||
</td>
|
||
<td>
|
||
<?php if ($rule['alert_on_rate'] !== null): ?>
|
||
<span class="badge badge-warning"><?= (int)$rule['alert_on_rate'] ?>/h</span>
|
||
<?php else: ?>
|
||
<span style="color:var(--text-muted); font-size:0.8rem;">—</span>
|
||
<?php endif; ?>
|
||
</td>
|
||
<td>
|
||
<span class="badge" style="background:rgba(255,255,255,0.08); font-family:monospace;">
|
||
<?= number_format((int)$rule['match_count'], 0, ',', '.') ?>
|
||
</span>
|
||
<div style="font-size:0.68rem; color:var(--text-muted);">
|
||
<?= (int)($rule['item_count'] ?? 0) ?> Item(s)
|
||
<?php if (!empty($rule['last_match_at'])): ?>
|
||
· <?= e(dc_ago((string)$rule['last_match_at'])) ?>
|
||
<?php endif; ?>
|
||
</div>
|
||
</td>
|
||
<td>
|
||
<span class="badge badge-<?= $enabled ? 'up' : 'stopped' ?>">
|
||
<?= $enabled ? 'AKTIV' : 'INAKTIV' ?>
|
||
</span>
|
||
</td>
|
||
<td>
|
||
<div style="display:flex; gap:0.3rem; flex-wrap:wrap;">
|
||
<form method="POST" action="index.php#sub-bugtracker-ignore" style="display:inline">
|
||
<?= $csrfField ?>
|
||
<input type="hidden" name="action" value="ir_toggle">
|
||
<input type="hidden" name="rule_id" value="<?= (int)$rule['id'] ?>">
|
||
<input type="hidden" name="enable" value="<?= $enabled ? '0' : '1' ?>">
|
||
<button type="submit" class="btn btn-sm btn-secondary">
|
||
<?= $enabled ? 'Deaktivieren' : 'Aktivieren' ?>
|
||
</button>
|
||
</form>
|
||
|
||
<form method="POST" action="index.php#sub-bugtracker-ignore" style="display:inline">
|
||
<?= $csrfField ?>
|
||
<input type="hidden" name="action" value="ir_apply">
|
||
<input type="hidden" name="rule_id" value="<?= (int)$rule['id'] ?>">
|
||
<button type="submit" class="btn btn-sm btn-secondary" title="Rückwirkend auf bestehende Einträge anwenden">↺</button>
|
||
</form>
|
||
|
||
<form method="POST" action="index.php#sub-bugtracker-ignore" style="display:inline"
|
||
onsubmit="return confirm('Regel wirklich löschen? Bereits stummgeschaltete Einträge bleiben stumm, verlieren aber die Zuordnung.');">
|
||
<?= $csrfField ?>
|
||
<input type="hidden" name="action" value="ir_delete">
|
||
<input type="hidden" name="rule_id" value="<?= (int)$rule['id'] ?>">
|
||
<button type="submit" class="btn btn-sm btn-danger">🗑️</button>
|
||
</form>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
<?php endforeach; ?>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-header"><h2 class="card-title">💻 Anbindung</h2></div>
|
||
<p style="color:var(--text-muted); font-size:0.85rem; margin-bottom:1rem;">
|
||
Ein Aufruf, den man in den globalen Exception-Handler hängt. Titel und
|
||
Dringlichkeit leitet der Server ab, die Regeln oben greifen automatisch.
|
||
</p>
|
||
<div class="prompt-box">POST <?= e($baseUrl) ?>/api/errors/v1/report
|
||
Authorization: Bearer <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;">
|
||
<button type="button" class="btn btn-sm btn-secondary" title="Angaben ändern"
|
||
onclick='openTokenEdit(<?= htmlspecialchars(json_encode([
|
||
"token_id" => (string)$mTok["token_id"],
|
||
"name" => (string)$mTok["name"],
|
||
"owner_type" => (string)$mTok["owner_type"],
|
||
"owner_identity" => (string)($mTok["owner_identity"] ?? ""),
|
||
"project_slug" => (string)($mTok["project_slug"] ?? ""),
|
||
"license_key" => (string)($mTok["license_key"] ?? ""),
|
||
"environment" => (string)$mTok["environment"],
|
||
"expires_at" => substr((string)($mTok["expires_at"] ?? ""), 0, 10),
|
||
"scopes" => $scopesArr,
|
||
"subs" => (int)($mTok["sub_token_count"] ?? 0),
|
||
], JSON_UNESCAPED_UNICODE), ENT_QUOTES) ?>)'>✏️</button>
|
||
<?php if (!$mTok['revoked']): ?>
|
||
<form method="POST" action="index.php#tab-tokens" style="display:inline" onsubmit="return confirm('Master-Token widerrufen? Alle zugehörigen Sub-Tokens werden sofort kaskadierend mit gesperrt!');">
|
||
<?= $csrfField ?>
|
||
<input type="hidden" name="action" value="revoke_core_token">
|
||
<input type="hidden" name="token_id" value="<?= $mTok['token_id'] ?>">
|
||
<button type="submit" class="btn btn-sm btn-warning">Widerrufen</button>
|
||
</form>
|
||
<?php else: ?>
|
||
<span class="badge badge-down">WIDERUFEN</span>
|
||
<?php endif; ?>
|
||
<form method="POST" action="index.php#tab-tokens" style="display:inline" onsubmit="return confirm('Master-Token und alle Sub-Tokens DAUERHAFT LÖSCHEN?');">
|
||
<?= $csrfField ?>
|
||
<input type="hidden" name="action" value="delete_core_token">
|
||
<input type="hidden" name="token_id" value="<?= $mTok['token_id'] ?>">
|
||
<button type="submit" class="btn btn-sm btn-danger">🗑️ Löschen</button>
|
||
</form>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
<?php endforeach; ?>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="sub-tokens-subs" class="subtab-content">
|
||
<div class="card">
|
||
<div class="card-header"><h2 class="card-title">🔑 Aktive Sub-Tokens (Per Provisioning API Erstellt)</h2></div>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Token ID</th>
|
||
<th>Parent Master ID</th>
|
||
<th>Bezeichnung / Client</th>
|
||
<th>Umgebung</th>
|
||
<th>Scopes</th>
|
||
<th>Token Key</th>
|
||
<th>Zuletzt Genutzt</th>
|
||
<th>Status</th>
|
||
<th>Aktionen</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<?php
|
||
$subTokens = array_filter($coreAllTokens, fn($t) => $t['type'] === 'sub');
|
||
foreach ($subTokens as $sTok):
|
||
$scopesArr = json_decode($sTok['scopes'], true) ?: [];
|
||
$sRaw = !empty($sTok['raw_token']) ? $sTok['raw_token'] : $sTok['token_id'];
|
||
$sMasked = (strlen($sRaw) > 12) ? substr($sRaw, 0, 12) . '••••••••••••••••' : $sRaw;
|
||
?>
|
||
<tr>
|
||
<td><code><?= htmlspecialchars($sTok['token_id']) ?></code></td>
|
||
<td><code><?= htmlspecialchars($sTok['parent_token_id'] ?? '-') ?></code></td>
|
||
<td><strong><?= htmlspecialchars($sTok['name']) ?></strong></td>
|
||
<td>
|
||
<span class="badge badge-stopped"><?= strtoupper($sTok['environment']) ?></span>
|
||
</td>
|
||
<td>
|
||
<?php foreach ($scopesArr as $sc): ?>
|
||
<span class="badge" style="background:rgba(255,255,255,0.06); font-size:0.75rem;"><?= htmlspecialchars($sc) ?></span>
|
||
<?php endforeach; ?>
|
||
</td>
|
||
<td>
|
||
<code id="tok-sub-<?= $sTok['token_id'] ?>" data-full="<?= htmlspecialchars($sRaw) ?>" data-masked="<?= htmlspecialchars($sMasked) ?>">
|
||
<?= htmlspecialchars($sMasked) ?>
|
||
</code>
|
||
<button type="button" class="btn btn-sm btn-secondary" onclick="toggleTokenMask('tok-sub-<?= $sTok['token_id'] ?>')">👁️</button>
|
||
<button type="button" class="btn btn-sm btn-secondary" onclick="copyTokenValue('tok-sub-<?= $sTok['token_id'] ?>')">📋</button>
|
||
</td>
|
||
<td><?= $sTok['last_used_at'] ? htmlspecialchars($sTok['last_used_at']) : 'Noch nie' ?></td>
|
||
<td><span class="badge badge-<?= $sTok['revoked'] ? 'down' : 'up' ?>"><?= $sTok['revoked'] ? 'WIDERUFEN' : 'AKTIV' ?></span></td>
|
||
<td>
|
||
<div style="display:flex; gap:0.3rem;">
|
||
<?php if (!$sTok['revoked']): ?>
|
||
<form method="POST" action="index.php#tab-tokens" style="display:inline" onsubmit="return confirm('Sub-Token widerrufen?');">
|
||
<?= $csrfField ?>
|
||
<input type="hidden" name="action" value="revoke_core_token">
|
||
<input type="hidden" name="token_id" value="<?= $sTok['token_id'] ?>">
|
||
<button type="submit" class="btn btn-sm btn-warning">Widerrufen</button>
|
||
</form>
|
||
<?php endif; ?>
|
||
<form method="POST" action="index.php#tab-tokens" style="display:inline" onsubmit="return confirm('Sub-Token DAUERHAFT LÖSCHEN?');">
|
||
<?= $csrfField ?>
|
||
<input type="hidden" name="action" value="delete_core_token">
|
||
<input type="hidden" name="token_id" value="<?= $sTok['token_id'] ?>">
|
||
<button type="submit" class="btn btn-sm btn-danger">🗑️ Löschen</button>
|
||
</form>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
<?php endforeach; ?>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- =============== Dialog: Master-Token bearbeiten =============== -->
|
||
<!--
|
||
Alles ausser dem Tokenwert selbst und der Nummer. Zuvor
|
||
erzwang ein Schreibfehler im Namen oder ein nachtraeglich
|
||
gebrauchtes Recht ein komplett neues Token - mit der Folge,
|
||
dass der neue Wert auf alle Maschinen verteilt werden muss
|
||
und am Ende das alte mitlaeuft, weil eine vergessen wurde.
|
||
-->
|
||
<div id="tokenEditModal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.75); backdrop-filter:blur(8px); z-index:9999; align-items:center; justify-content:center; padding:1rem;">
|
||
<div class="card" style="max-width:820px; width:100%; max-height:90vh; overflow-y:auto; margin:0;">
|
||
<div class="card-header">
|
||
<h2 class="card-title">✏️ Token bearbeiten — <span id="tokEditName"></span></h2>
|
||
</div>
|
||
|
||
<div style="font-size:0.82rem; color:var(--text-muted); margin-bottom:1rem;">
|
||
Der <strong>Tokenwert</strong> und die <strong>Nummer</strong>
|
||
(<code id="tokEditIdText"></code>) bleiben unverändert — auf den
|
||
Maschinen muss nichts ausgetauscht werden.
|
||
</div>
|
||
|
||
<div id="tokEditSubHinweis" style="display:none; background:rgba(245,166,35,0.10); border-left:3px solid var(--warning); border-radius:8px; padding:0.75rem 1rem; margin-bottom:1rem; font-size:0.83rem;">
|
||
An diesem Token hängen <strong><span id="tokEditSubCount"></span> Sub-Tokens</strong>.
|
||
Sie haben ihre Rechte beim Ausstellen bekommen und behalten sie —
|
||
eine Einschränkung hier wirkt <em>nicht</em> rückwirkend auf sie.
|
||
Wer auch die Kinder beschneiden will, widerruft sie und stellt sie neu aus.
|
||
</div>
|
||
|
||
<form method="POST" action="index.php#tab-tokens">
|
||
<?= $csrfField ?>
|
||
<input type="hidden" name="action" value="edit_master_token">
|
||
<input type="hidden" name="token_id" id="tokEditId">
|
||
|
||
<div class="form-grid">
|
||
<div class="form-group">
|
||
<label class="form-label">Bezeichnung / Name</label>
|
||
<input type="text" name="name" id="tokEditNameInput" class="form-input" required>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Identitätstyp (Owner Type)</label>
|
||
<select name="owner_type" id="tokEditOwnerType" class="form-input" required>
|
||
<option value="custom">Custom / Allgemein</option>
|
||
<option value="project">Projekt-Gebunden</option>
|
||
<option value="license">Lizenz-Gebunden</option>
|
||
<option value="host">Host / Infrastruktur Server</option>
|
||
<option value="dev_agent">Entwickler KI-Agent</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Freie Identität / Host / HWID</label>
|
||
<input type="text" name="owner_identity" id="tokEditIdentity" class="form-input">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Projekt</label>
|
||
<select name="project_slug" id="tokEditProject" class="form-input">
|
||
<option value="">-- Keins (Universal) --</option>
|
||
<?php foreach ($projects as $p): ?>
|
||
<option value="<?= e($p['slug']) ?>"><?= e($p['name']) ?></option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Umgebung</label>
|
||
<select name="environment" id="tokEditEnv" class="form-input" required>
|
||
<option value="all">🌐 Alle Umgebungen</option>
|
||
<option value="production">🔴 Nur Produktiv</option>
|
||
<option value="development">🔵 Nur Entwicklung</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Gültig bis (leer = unbefristet)</label>
|
||
<input type="date" name="expires_at" id="tokEditExpires" class="form-input">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group" style="margin-top:0.5rem;">
|
||
<label class="form-label">Lizenzschlüssel (nur bei Lizenz-Bindung)</label>
|
||
<input type="text" name="license_key" id="tokEditLicense" class="form-input"
|
||
placeholder="XXXXX-XXXXX-XXXXX-XXXXX-XXXXX">
|
||
</div>
|
||
|
||
<div class="form-group" style="margin-top:1rem;">
|
||
<label class="form-label">Erlaubte Rechte (Scopes)</label>
|
||
<p style="font-size:0.75rem; color:var(--text-muted); margin:0.25rem 0 0.6rem 0;">
|
||
Mindestens eines. Stärkere Rechte schließen die schwächeren ein.
|
||
</p>
|
||
<div style="display:grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap:0.5rem;">
|
||
<?php foreach ($scopeChoices as $value => $labels): ?>
|
||
<label style="display:flex; align-items:flex-start; gap:0.5rem; font-size:0.85rem; cursor:pointer; background:rgba(255,255,255,0.03); border:1px solid var(--border-glass); border-radius:10px; padding:0.6rem 0.75rem;">
|
||
<input type="checkbox" name="scopes[]" value="<?= e($value) ?>"
|
||
class="tok-edit-scope" style="margin-top:3px;">
|
||
<span>
|
||
<strong><?= e($labels[0]) ?></strong>
|
||
<div style="font-size:0.75rem; color:var(--text-muted);"><?= e($labels[1]) ?></div>
|
||
</span>
|
||
</label>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
</div>
|
||
|
||
<div style="display:flex; justify-content:flex-end; gap:0.75rem; margin-top:1.25rem;">
|
||
<button type="button" class="btn btn-secondary" onclick="closeTokenEdit()">Abbrechen</button>
|
||
<button type="submit" class="btn">Speichern</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="sub-tokens-create" class="subtab-content">
|
||
<div class="card">
|
||
<div class="card-header"><h2 class="card-title">➕ Neuen Token erstellen</h2></div>
|
||
<!--
|
||
Frueher hiess das hier "Master-Token erstellen" - und genau
|
||
deshalb fand niemand das Werkbank-Token: Es entsteht an
|
||
derselben Stelle, nur mit anderem Identitaetstyp. Der Name
|
||
der Ansicht versprach etwas anderes als sie kann.
|
||
-->
|
||
<div style="background:rgba(91,157,255,0.08); border-left:3px solid var(--primary); border-radius:8px; padding:0.85rem 1.1rem; margin-bottom:1.25rem; font-size:0.86rem;">
|
||
<strong>Werkbank-Token für einen Coding-Agenten?</strong> Hier richtig.
|
||
Identitätstyp auf <em>Entwickler KI-Agent</em>, ein Projekt auswählen
|
||
(nicht „Keins“) und als Rechte <code>Bugtracker verwalten</code> plus
|
||
<code>Befunde lesen</code> ankreuzen — <code>Alle Rechte</code> nicht.
|
||
<div style="color:var(--text-muted); margin-top:0.5rem;">
|
||
Die Projektbindung ist keine Formsache: Aus ihr leitet der Server
|
||
ab, wer eine Meldung abgesetzt hat. Ein Token für alle Projekte,
|
||
und das Signal „drei Projekte melden dasselbe“ ist wertlos.
|
||
Ausführlich in <code>docs/AGENTEN_ANBINDEN.md</code>.
|
||
</div>
|
||
</div>
|
||
<form method="POST" action="index.php#tab-tokens">
|
||
<?= $csrfField ?>
|
||
<input type="hidden" name="action" value="create_master_token">
|
||
<div class="form-grid">
|
||
<div class="form-group">
|
||
<label class="form-label">Bezeichnung / Name</label>
|
||
<input type="text" name="name" class="form-input" required placeholder="z. B. PolyTrader Prod Server Master Key">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Identitätstyp (Owner Type)</label>
|
||
<select name="owner_type" class="form-input" required>
|
||
<option value="custom" selected>Custom / Allgemein</option>
|
||
<option value="project">Projekt-Gebunden</option>
|
||
<option value="license">Lizenz-Gebunden</option>
|
||
<option value="host">Host / Infrastruktur Server</option>
|
||
<option value="dev_agent">Entwickler KI-Agent</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Freie Identität / Host / HWID (Optional)</label>
|
||
<input type="text" name="owner_identity" class="form-input" placeholder="z. B. srv-db-01 oder HWID-88A9...">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Projekt (Optional)</label>
|
||
<select name="project_slug" class="form-input">
|
||
<option value="">-- Keins (Universal) --</option>
|
||
<?php foreach ($projects as $p): ?>
|
||
<option value="<?= htmlspecialchars($p['slug']) ?>"><?= htmlspecialchars($p['name']) ?></option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Umgebung (Environment Limit)</label>
|
||
<select name="environment" class="form-input" required>
|
||
<option value="all" selected>🌐 Alle Umgebungen (All)</option>
|
||
<option value="production">🔴 Nur Produktiv (Production)</option>
|
||
<option value="development">🔵 Nur Entwicklung (Development)</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group" style="margin-top:1rem;">
|
||
<label class="form-label">Erlaubte Rechte (Scopes)</label>
|
||
<p style="font-size:0.75rem; color:var(--text-muted); margin:0.25rem 0 0.6rem 0;">
|
||
Stärkere Rechte schließen die schwächeren ein: <code>bugtracker:manage</code>
|
||
umfasst Lesen und Melden, <code>updateservice:publish</code> auch Lesen.
|
||
Für einen Coding-Agenten genügt daher <em>Bugtracker verwalten</em>.
|
||
</p>
|
||
<div style="display:grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap:0.5rem;">
|
||
<?php
|
||
foreach ($scopeChoices as $value => $labels): ?>
|
||
<label style="display:flex; align-items:flex-start; gap:0.5rem; font-size:0.85rem; cursor:pointer; background:rgba(255,255,255,0.03); border:1px solid var(--border-glass); border-radius:10px; padding:0.6rem 0.75rem;">
|
||
<?php
|
||
// Vorangekreuzt ist "Bugtracker verwalten", nicht "Alle
|
||
// Rechte". Zuvor war der Vollzugriff die Vorgabe - wer
|
||
// ein Agenten-Token anlegte und den Haken uebersah, gab
|
||
// damit alles heraus, inklusive Releases und
|
||
// Tokenausstellung. Die gefaehrlichste Auswahl gehoert
|
||
// nicht in die Vorgabe.
|
||
?>
|
||
<input type="checkbox" name="scopes[]" value="<?= e($value) ?>" <?= $value === 'bugtracker:manage' ? 'checked' : '' ?> style="margin-top:3px;">
|
||
<span>
|
||
<strong><?= e($labels[0]) ?></strong>
|
||
<span style="display:block; font-size:0.72rem; color:var(--text-muted); margin-top:2px;"><?= e($labels[1]) ?></span>
|
||
</span>
|
||
</label>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group" style="margin-top:1rem;">
|
||
<label class="form-label">Gültig bis (optional)</label>
|
||
<input type="date" name="expires_at" class="form-input">
|
||
<small style="font-size:0.72rem; color:var(--text-muted);">
|
||
Leer lassen für unbegrenzte Gültigkeit. Abgelaufene Tokens werden ab sofort
|
||
tatsächlich abgewiesen — die Spalte existierte zuvor, wurde aber nie geprüft.
|
||
</small>
|
||
</div>
|
||
|
||
<button type="submit" class="btn" style="margin-top:1.25rem;">Master-Token Erstellen</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<!-- ================= MODULE 5: SYSTEM & DB ================= -->
|
||
<div id="tab-system" class="tab-content">
|
||
|
||
<div id="sub-system-status" class="subtab-content active">
|
||
<div class="card">
|
||
<div class="card-header"><h2 class="card-title">⚙️ Server & Umgebung</h2></div>
|
||
<div class="form-grid">
|
||
<div class="form-group">
|
||
<label class="form-label">PHP-Version</label>
|
||
<input type="text" readonly class="form-input" value="<?= e(PHP_VERSION) ?>">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Server-Software</label>
|
||
<input type="text" readonly class="form-input" value="<?= e((string)($_SERVER['SERVER_SOFTWARE'] ?? 'unbekannt')) ?>">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Anwendungsversion</label>
|
||
<input type="text" readonly class="form-input" value="<?= e((string)Config::get('app.version', 'unbekannt')) ?>">
|
||
</div>
|
||
<div class="form-group">
|
||
<!--
|
||
Frueher standen Datenbankname und Hostname hier fest im
|
||
Quelltext. Jetzt kommt der Wert aus der Konfiguration,
|
||
und der Hostname wird nicht mehr ausgegeben.
|
||
-->
|
||
<label class="form-label">Datenbank</label>
|
||
<input type="text" readonly class="form-input" value="<?= e((string)Config::get('db.dbname', 'unbekannt')) ?>">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Zeitzone (Anzeige)</label>
|
||
<input type="text" readonly class="form-input" value="<?= e((string)Config::get('app.timezone', 'UTC')) ?> — Speicherung in UTC">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Debug-Modus</label>
|
||
<input type="text" readonly class="form-input" value="<?= Config::isDebug() ? 'AKTIV — auf Produktivsystemen abschalten' : 'aus' ?>">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-header"><h2 class="card-title">🩺 Systemzustand</h2></div>
|
||
<table>
|
||
<thead>
|
||
<tr><th>Prüfung</th><th>Zustand</th><th>Hinweis</th></tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr>
|
||
<td>Watchdog-Evaluator</td>
|
||
<td>
|
||
<span class="badge badge-<?= $evaluatorStale ? 'down' : 'up' ?>">
|
||
<?= $evaluatorStale ? 'INAKTIV' : 'AKTIV' ?>
|
||
</span>
|
||
</td>
|
||
<td style="font-size:0.8rem; color:var(--text-muted);">
|
||
<?php if ($evaluatorLastRun !== null): ?>
|
||
Letzter Lauf: <?= e(dc_dt((string)$evaluatorLastRun)) ?> (<?= e(dc_ago((string)$evaluatorLastRun)) ?>)
|
||
<?php else: ?>
|
||
Noch nie gelaufen — ohne ihn bleiben ausgefallene Monitore „up“.
|
||
<?php endif; ?>
|
||
</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Log-Verzeichnis beschreibbar</td>
|
||
<?php $logWritable = is_dir(DC_VAR . '/log') ? is_writable(DC_VAR . '/log') : is_writable(DC_ROOT); ?>
|
||
<td><span class="badge badge-<?= $logWritable ? 'up' : 'warning' ?>"><?= $logWritable ? 'OK' : 'PRÜFEN' ?></span></td>
|
||
<td style="font-size:0.8rem; color:var(--text-muted);">var/log/ — Fehlerprotokolle</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Konfiguration ausserhalb Git</td>
|
||
<td><span class="badge badge-up">OK</span></td>
|
||
<td style="font-size:0.8rem; color:var(--text-muted);">config/config.php ist per .gitignore ausgeschlossen</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="sub-system-rocketchat" class="subtab-content">
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h2 class="card-title">💬 Rocket.Chat Benachrichtigungen & Statusberichte</h2>
|
||
</div>
|
||
<p style="color:var(--text-muted); font-size:0.875rem; margin-bottom:1.5rem;">
|
||
Hier konfigurieren Sie die automatischen Rocket.Chat-Benachrichtigungen. Alle 12 Stunden wird ein detaillierter Systemstatusbericht an den Status-Kanal gesendet. Kritische Systemausfälle und Bugs landen sofort im Alarm-Kanal.
|
||
</p>
|
||
|
||
<form method="POST">
|
||
<?= $csrfField ?>
|
||
<input type="hidden" name="action" value="save_rocketchat_settings">
|
||
|
||
<div class="form-grid">
|
||
<div class="form-group" style="grid-column: span 2;">
|
||
<label class="form-label" style="display:flex; align-items:center; gap:0.5rem; cursor:pointer;">
|
||
<input type="checkbox" name="rocketchat_enabled" value="1" <?= !empty($rcConfig['enabled']) ? 'checked' : '' ?>>
|
||
<strong>Rocket.Chat Benachrichtigungen aktivieren</strong>
|
||
</label>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label class="form-label">Rocket.Chat Server-URL</label>
|
||
<input type="url" name="rocketchat_url" class="form-input" required value="<?= e($rcConfig['url']) ?>" placeholder="https://chat.wh1.mhdf.de">
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label class="form-label">Benutzername</label>
|
||
<input type="text" name="rocketchat_username" class="form-input" required value="<?= e($rcConfig['username']) ?>" placeholder="deploymentcenter">
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label class="form-label">Passwort</label>
|
||
<input type="password" name="rocketchat_password" class="form-input" required value="<?= e($rcConfig['password']) ?>">
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label class="form-label">SSL-Zertifikat verifizieren</label>
|
||
<label class="form-label" style="display:flex; align-items:center; gap:0.5rem; cursor:pointer; font-weight:normal; margin-top:0.3rem;">
|
||
<input type="checkbox" name="rocketchat_verify_ssl" value="1" <?= !empty($rcConfig['verify_ssl']) ? 'checked' : '' ?>>
|
||
SSL-Zertifikat prüfen (bei selbstsignierten/unsicheren Zertifikaten deaktivieren)
|
||
</label>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label class="form-label">Status-Kanal (12h Bericht)</label>
|
||
<input type="text" name="rocketchat_status_channel" class="form-input" required value="<?= e($rcConfig['status_channel']) ?>" placeholder="#DC-Systemstatus">
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label class="form-label">Alarm-Kanal (Kritische Ausfälle & Bugs)</label>
|
||
<input type="text" name="rocketchat_alert_channel" class="form-input" required value="<?= e($rcConfig['alert_channel']) ?>" placeholder="#DC-Alerts">
|
||
</div>
|
||
|
||
<div class="form-group" style="grid-column: span 2;">
|
||
<label class="form-label">Statusbericht Intervall (Sekunden)</label>
|
||
<input type="number" name="rocketchat_report_interval" class="form-input" min="300" step="60" value="<?= e((string)$rcConfig['report_interval']) ?>">
|
||
<span style="font-size:0.75rem; color:var(--text-muted);">Standard: 43200 Sekunden (12 Stunden)</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div style="margin-top:1.5rem; display:flex; gap:1rem;">
|
||
<button type="submit" class="btn btn-primary">💾 Einstellungen Speichern</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
|
||
<div class="card" style="margin-top:1.5rem;">
|
||
<div class="card-header">
|
||
<h2 class="card-title">🧪 Aktionen & Testlauf</h2>
|
||
</div>
|
||
<p style="color:var(--text-muted); font-size:0.875rem; margin-bottom:1.5rem;">
|
||
Hier können Sie die Erreichbarkeit Ihres Rocket.Chat-Servers testen oder sofort einen 12-Stunden-Statusbericht auslösen.
|
||
</p>
|
||
|
||
<div style="display:flex; flex-wrap:wrap; gap:1rem;">
|
||
<form method="POST">
|
||
<?= $csrfField ?>
|
||
<input type="hidden" name="action" value="test_rocketchat_connection">
|
||
<button type="submit" class="btn btn-secondary">🧪 Verbindung & Kanäle Testen</button>
|
||
</form>
|
||
|
||
<form method="POST">
|
||
<?= $csrfField ?>
|
||
<input type="hidden" name="action" value="send_rocketchat_report_now">
|
||
<button type="submit" class="btn btn-secondary">📊 12h Statusbericht Jetzt Senden</button>
|
||
</form>
|
||
</div>
|
||
|
||
<div style="margin-top:1.5rem; background:rgba(0,0,0,0.2); padding:1rem; border-radius:8px; font-size:0.85rem; color:var(--text-muted);">
|
||
<strong>📌 Status des automatischen 12h Cron-Jobs:</strong><br>
|
||
• Letzter Bericht: <?= !empty($rcCronJob['last_run_utc']) ? e(dc_dt((string)$rcCronJob['last_run_utc'])) . ' (' . e(dc_ago((string)$rcCronJob['last_run_utc'])) . ')' : 'Noch nie gelaufen' ?><br>
|
||
• Letzter Status: <span class="badge badge-<?= ($rcCronJob['last_status'] ?? '') === 'ok' ? 'up' : 'warning' ?>"><?= e((string)($rcCronJob['last_status'] ?? 'bereit')) ?></span><br>
|
||
• Nächster geplanter Lauf: In ca. <?= !empty($rcCronJob['last_run_utc']) ? max(0, (int)round(($rcConfig['report_interval'] - (time() - strtotime((string)$rcCronJob['last_run_utc'] . ' UTC'))) / 3600)) . ' Std.' : 'sofort beim nächsten Tick' ?>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="sub-system-users" class="subtab-content">
|
||
<div class="card">
|
||
<div class="card-header"><h2 class="card-title">👤 Benutzerkonten</h2></div>
|
||
|
||
<?php if (!$usersSchemaReady): ?>
|
||
<div class="alert" style="background:rgba(255,180,0,0.12); border:1px solid rgba(255,180,0,0.3); color:#fde68a; font-weight:500;">
|
||
<strong>Migration 012 fehlt.</strong> Rollen, Deaktivierung und der Zeitpunkt
|
||
der letzten Anmeldung stehen erst danach zur Verfügung. Bis dahin gelten alle
|
||
Konten als Administratoren.
|
||
</div>
|
||
<?php endif; ?>
|
||
|
||
<p style="color:var(--text-muted); font-size:0.875rem; margin-bottom:1.25rem;">
|
||
Für die Erstinstallation von Anwendungen gibt es die Rolle
|
||
<strong>installer</strong>. Solche Konten melden sich ausschließlich über
|
||
<code>/api/setup/v1/login</code> an und bekommen dafür ein Token, das nach
|
||
30 Minuten verfällt. <strong>Am WebUI können sie sich nicht anmelden.</strong>
|
||
Der Grund: Die Zugangsdaten werden auf jedem Zielsystem eingegeben — mit einem
|
||
Administratorkonto verteilte man damit den Zugang zu Tokens, Lizenzen und
|
||
Monitoren auf jeden Rechner, auf dem je etwas installiert wurde.
|
||
</p>
|
||
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Benutzer</th>
|
||
<th>Rolle</th>
|
||
<th>Status</th>
|
||
<th>Letzte Anmeldung</th>
|
||
<th>Angelegt</th>
|
||
<th>Aktionen</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<?php foreach ($users as $u): ?>
|
||
<?php
|
||
$uRole = (string)($u['role'] ?? 'admin');
|
||
$uDisabled = (int)($u['disabled'] ?? 0) === 1;
|
||
$isSelf = (int)$u['id'] === Auth::userId();
|
||
$isLocked = !empty($u['locked_until']) && strtotime((string)$u['locked_until'] . ' UTC') > time();
|
||
$lockMins = $isLocked ? (int)ceil((strtotime((string)$u['locked_until'] . ' UTC') - time()) / 60) : 0;
|
||
$failCount = (int)($u['failed_login_count'] ?? 0);
|
||
?>
|
||
<tr<?= $uDisabled ? ' style="opacity:0.55;"' : ($isLocked ? ' style="background:rgba(239,68,68,0.08);"' : '') ?>>
|
||
<td>
|
||
<strong><?= e((string)$u['username']) ?></strong>
|
||
<?php if ($isSelf): ?><span class="badge badge-up">Sie</span><?php endif; ?>
|
||
</td>
|
||
<td>
|
||
<span class="badge <?= $uRole === 'admin' ? 'badge-up' : 'badge-warning' ?>">
|
||
<?= e(strtoupper($uRole)) ?>
|
||
</span>
|
||
</td>
|
||
<td>
|
||
<?php if ($isLocked): ?>
|
||
<span class="badge badge-down">GESPERRT (noch <?= $lockMins ?>m)</span>
|
||
<?php elseif ($uDisabled): ?>
|
||
<span class="badge badge-down">DEAKTIVIERT</span>
|
||
<?php else: ?>
|
||
<span style="color:var(--text-main);">aktiv</span>
|
||
<?php endif; ?>
|
||
<?php if ($failCount > 0 && !$isLocked): ?>
|
||
<div style="font-size:0.75rem; color:var(--text-muted); margin-top:2px;">
|
||
<?= $failCount ?> Fehlversuch(e)
|
||
</div>
|
||
<?php endif; ?>
|
||
</td>
|
||
<td style="font-size:0.8rem; color:var(--text-muted);">
|
||
<?= !empty($u['last_login_at']) ? e((string)$u['last_login_at']) : 'nie' ?>
|
||
</td>
|
||
<td style="font-size:0.8rem; color:var(--text-muted);"><?= e((string)($u['created_at'] ?? '-')) ?></td>
|
||
<td>
|
||
<div style="display:flex; flex-wrap:wrap; gap:0.35rem;">
|
||
<?php if ($isLocked): ?>
|
||
<form method="POST" action="index.php" style="display:inline;">
|
||
<?= $csrfField ?>
|
||
<input type="hidden" name="action" value="unlock_user">
|
||
<input type="hidden" name="_return" value="sub-system-users">
|
||
<input type="hidden" name="user_id" value="<?= (int)$u['id'] ?>">
|
||
<button type="submit" class="btn btn-sm btn-primary">
|
||
🔓 Entsperren
|
||
</button>
|
||
</form>
|
||
<?php endif; ?>
|
||
|
||
<form method="POST" action="index.php" style="display:inline-flex; gap:0.25rem;"
|
||
onsubmit="return confirm('Passwort für <?= e((string)$u['username']) ?> wirklich ändern?');">
|
||
<?= $csrfField ?>
|
||
<input type="hidden" name="action" value="set_user_password">
|
||
<input type="hidden" name="_return" value="sub-system-users">
|
||
<input type="hidden" name="user_id" value="<?= (int)$u['id'] ?>">
|
||
<input type="password" name="password" class="form-input" required minlength="12"
|
||
placeholder="Neues Passwort" style="width:170px; padding:0.3rem 0.5rem; font-size:0.8rem;">
|
||
<button type="submit" class="btn btn-sm btn-secondary">Setzen</button>
|
||
</form>
|
||
|
||
<?php if (!$isSelf): ?>
|
||
<form method="POST" action="index.php" style="display:inline;">
|
||
<?= $csrfField ?>
|
||
<input type="hidden" name="action" value="toggle_user_disabled">
|
||
<input type="hidden" name="_return" value="sub-system-users">
|
||
<input type="hidden" name="user_id" value="<?= (int)$u['id'] ?>">
|
||
<button type="submit" class="btn btn-sm btn-secondary">
|
||
<?= $uDisabled ? 'Aktivieren' : 'Deaktivieren' ?>
|
||
</button>
|
||
</form>
|
||
|
||
<form method="POST" action="index.php" style="display:inline;"
|
||
onsubmit="return confirm('Konto <?= e((string)$u['username']) ?> endgültig löschen?');">
|
||
<?= $csrfField ?>
|
||
<input type="hidden" name="action" value="delete_user">
|
||
<input type="hidden" name="_return" value="sub-system-users">
|
||
<input type="hidden" name="user_id" value="<?= (int)$u['id'] ?>">
|
||
<button type="submit" class="btn btn-sm btn-danger">Löschen</button>
|
||
</form>
|
||
<?php endif; ?>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
<?php endforeach; ?>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-header"><h2 class="card-title">Neues Konto anlegen</h2></div>
|
||
<form method="POST" action="index.php">
|
||
<?= $csrfField ?>
|
||
<input type="hidden" name="action" value="add_user">
|
||
<input type="hidden" name="_return" value="sub-system-users">
|
||
<div class="form-grid">
|
||
<div class="form-group">
|
||
<label class="form-label">Benutzername</label>
|
||
<input type="text" name="username" class="form-input" required
|
||
pattern="[A-Za-z0-9._\-]{3,64}" placeholder="installer">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Rolle</label>
|
||
<select name="role" class="form-input">
|
||
<option value="installer" selected>installer (nur Erstinstallation)</option>
|
||
<option value="admin">admin (volle Verwaltung)</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Passwort (mindestens 12 Zeichen)</label>
|
||
<input type="password" name="password" class="form-input" required minlength="12">
|
||
</div>
|
||
</div>
|
||
<button type="submit" class="btn">Konto anlegen</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="sub-system-swagger" class="subtab-content">
|
||
<div class="card">
|
||
<div class="card-header"><h2 class="card-title">📖 API-Dokumentation</h2></div>
|
||
<p style="color:var(--text-muted); font-size:0.875rem; margin-bottom:1.25rem;">
|
||
Die Schnittstelle beschreibt sich selbst. Der frühere fest im Quelltext
|
||
hinterlegte Textblock ist durch eine maschinenlesbare OpenAPI-Beschreibung
|
||
ersetzt — ein Agent kann sie abrufen und sich daran orientieren, ohne dass
|
||
eine Prompt-Vorlage gepflegt werden muss.
|
||
</p>
|
||
<div style="display:flex; flex-wrap:wrap; gap:0.5rem; margin-bottom:1.25rem;">
|
||
<a href="<?= e($baseUrl) ?>/api/openapi.json" target="_blank" rel="noopener" class="btn">OpenAPI 3.0 (JSON)</a>
|
||
<a href="<?= e($baseUrl) ?>/api/health" target="_blank" rel="noopener" class="btn btn-secondary">Health-Endpunkt</a>
|
||
<a href="<?= e($baseUrl) ?>/docs/" target="_blank" rel="noopener" class="btn btn-secondary">Agenten-Handbuch</a>
|
||
</div>
|
||
<div class="prompt-box">Kernendpunkte im Überblick
|
||
|
||
BUGTRACKER (Token mit bugtracker:report / :read / :manage)
|
||
POST /api/bugtracker/v1/report Bug, Feature oder Idee melden
|
||
GET /api/bugtracker/v1/projects Projekt-Discovery
|
||
GET /api/bugtracker/v1/manage?action=list Filtern, Pagination, updated_since
|
||
POST /api/bugtracker/v1/manage?action=next Nächstes Item holen UND übernehmen
|
||
POST /api/bugtracker/v1/manage?action=comment&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': [
|
||
// Dashboard, "Lizenz Bearbeiten & HW" und Settings sind entfallen:
|
||
// vier Unteransichten fuer eine Aufgabe. Bearbeitet wird jetzt
|
||
// ueber einen Dialog direkt in der Liste.
|
||
{ id: 'sub-license-licenses', label: '🔑 Lizenzen & Hardware', active: true },
|
||
{ id: 'sub-license-offline', label: '💾 Offline-Lizenzen (.lic)' },
|
||
{ id: 'sub-license-audit', label: '📜 Audit-Log' },
|
||
{ id: 'sub-license-integration', label: '💻 Integration' }
|
||
],
|
||
'watchdog': [
|
||
{ id: 'sub-watchdog-dashboard', label: '📊 Dashboard', active: true },
|
||
{ id: 'sub-watchdog-hierarchy', label: '🌳 System-Hierarchie' },
|
||
{ id: 'sub-watchdog-add', label: '➕ Monitor Hinzufügen' },
|
||
{ id: 'sub-watchdog-eventlog', label: '📜 Event-Log' },
|
||
{ id: 'sub-watchdog-tokens', label: '🔑 Agent-Tokens' }
|
||
],
|
||
'updateservice': [
|
||
{ id: 'sub-update-releases', label: '📊 Releases Overview', active: true },
|
||
{ id: 'sub-update-installer', label: '⬇️ Installer' },
|
||
{ id: 'sub-update-guard', label: '🔒 Zugangsschutz' },
|
||
{ id: 'sub-update-publish', label: '➕ Release Veröffentlichen' }
|
||
],
|
||
'roadmap': [
|
||
{ id: 'sub-roadmap-board', label: '🗺️ Übersicht', active: true },
|
||
{ id: 'sub-roadmap-list', label: '📋 Alle Vorhaben' },
|
||
{ id: 'sub-roadmap-intake', label: '➕ Aufnahmeliste' },
|
||
{ id: 'sub-roadmap-epics', label: '🗂️ Themenbereiche' }
|
||
],
|
||
'bugtracker': [
|
||
{ id: 'sub-bugtracker-items', label: '🐛 Bugs & Features', active: true },
|
||
{ id: 'sub-bugtracker-errors', label: '⚡ Fehler-Stream' },
|
||
{ id: 'sub-bugtracker-ignore', label: '🔇 Ignore-Regeln' },
|
||
{ id: 'sub-bugtracker-new', label: '➕ Item Anlegen' }
|
||
],
|
||
'tokens': [
|
||
{ id: 'sub-tokens-masters', label: '👑 Master-Tokens', active: true },
|
||
{ id: 'sub-tokens-subs', label: '🔑 Sub-Tokens' },
|
||
{ id: 'sub-tokens-create', label: '➕ Token erstellen' }
|
||
],
|
||
'system': [
|
||
{ id: 'sub-system-status', label: '⚙️ System-Status', active: true },
|
||
{ id: 'sub-system-users', label: '👤 Benutzer' },
|
||
{ id: 'sub-system-rocketchat', label: '💬 Rocket.Chat' },
|
||
{ id: 'sub-system-swagger', label: '📖 API Swagger Docs' },
|
||
{ id: 'sub-system-migration', label: '🗄️ DB Migration' }
|
||
]
|
||
};
|
||
|
||
/**
|
||
* Ermittelt, zu welchem Modul eine Unteransicht gehoert.
|
||
*
|
||
* Zuvor wurde das aus dem Namen geraten (sub-<modul>-<rest>), was bei
|
||
* "sub-update-releases" fehlschlug - das Modul heisst updateservice.
|
||
* Die Zuordnung kommt jetzt aus derselben Liste, die auch die
|
||
* Navigation aufbaut, und ist damit immer korrekt.
|
||
*/
|
||
function moduleOfSubtab(subtabId) {
|
||
for (const moduleName of Object.keys(subnavItems)) {
|
||
if (subnavItems[moduleName].some(item => item.id === subtabId)) {
|
||
return moduleName;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// Render Subnav links in Top-Bar
|
||
function renderTopBarSubnav(moduleName, activeSubtabId) {
|
||
const container = document.getElementById('topbarSubnav');
|
||
container.innerHTML = '';
|
||
|
||
const items = subnavItems[moduleName] || [];
|
||
items.forEach(item => {
|
||
const isActive = activeSubtabId ? (item.id === activeSubtabId) : !!item.active;
|
||
|
||
const btn = document.createElement('button');
|
||
btn.className = `subnav-link ${isActive ? 'active' : ''}`;
|
||
btn.innerText = item.label;
|
||
// Eindeutige Kennung statt Textvergleich - der Beschriftungstext
|
||
// taugt nicht zum Wiederfinden.
|
||
btn.dataset.subtab = item.id;
|
||
btn.onclick = () => switchSubTab(moduleName, item.id, btn);
|
||
container.appendChild(btn);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Ansichtsrettung: Merkt sich beim Absenden, welche Unteransicht offen
|
||
* war, und stellt sie nach dem Neuladen wieder her.
|
||
*
|
||
* Ohne das landete man stets auf dem Dashboard - man legte einen
|
||
* Monitor an und wurde aus dem Formular geworfen, und jede
|
||
* Filteraenderung im Bugtracker warf einen dorthin zurueck.
|
||
*
|
||
* Als zentraler Zuhoerer umgesetzt, damit auch kuenftige Formulare das
|
||
* Verhalten automatisch erben. Die drei Funktionen darunter gehoeren
|
||
* zusammen: dcCurrentView() liest die Ansicht ab, dcRememberView()
|
||
* haengt sie an das Formular, dcSubmit() sorgt dafuer, dass beim
|
||
* Absenden aus einem onchange ueberhaupt ein submit-Ereignis entsteht.
|
||
*/
|
||
function dcCurrentView() {
|
||
// Erst das Modul, dann die Unteransicht DARIN. Die Reihenfolge ist
|
||
// der Kern der Sache: Im Markup tragen ACHT Unteransichten die
|
||
// Klasse "active" - jedes Modul hat seine eigene Startansicht, und
|
||
// switchMainTab() raeumt nur innerhalb des Moduls auf, das es
|
||
// oeffnet. Ein dokumentweites querySelector('.subtab-content.active')
|
||
// liefert deshalb immer die erste in Dokumentreihenfolge
|
||
// (sub-projects-list), voellig unabhaengig davon, was gerade
|
||
// sichtbar ist. Genau daran ist die Ansichtsrettung fuer POST seit
|
||
// jeher gescheitert - und der Anschein, sie funktioniere, kam von
|
||
// den wenigen Formularen, die ihren Anker fest in der action
|
||
// stehen haben.
|
||
const activeTab = document.querySelector('.main-content > .tab-content.active');
|
||
if (!activeTab) {
|
||
return '';
|
||
}
|
||
|
||
const activeSub = activeTab.querySelector('.subtab-content.active');
|
||
return activeSub ? activeSub.id : activeTab.id;
|
||
}
|
||
|
||
/**
|
||
* Haengt die gerade offene Ansicht an ein Formular.
|
||
*
|
||
* POST: als _return - dc_redirect() macht daraus den Anker der
|
||
* Weiterleitung.
|
||
* GET: als view - ein Anker ueberlebt die Serialisierung eines
|
||
* GET-Formulars nicht, der Query-String schon. Genau daran
|
||
* scheiterten die Filter: nach jeder Aenderung stand die Seite
|
||
* wieder auf dem Dashboard, weil die neue URL keinen Anker
|
||
* mehr trug.
|
||
*/
|
||
function dcRememberView(form) {
|
||
const isPost = (form.method || 'get').toLowerCase() === 'post';
|
||
const name = isPost ? '_return' : 'view';
|
||
|
||
if (form.querySelector('input[name="' + name + '"]')) {
|
||
return;
|
||
}
|
||
|
||
const target = dcCurrentView();
|
||
if (!target) {
|
||
return;
|
||
}
|
||
|
||
const field = document.createElement('input');
|
||
field.type = 'hidden';
|
||
field.name = name;
|
||
field.value = target;
|
||
form.appendChild(field);
|
||
}
|
||
|
||
document.addEventListener('submit', (event) => {
|
||
const form = event.target;
|
||
if (!(form instanceof HTMLFormElement)) {
|
||
return;
|
||
}
|
||
dcRememberView(form);
|
||
}, true);
|
||
|
||
/**
|
||
* Absenden aus einem onchange heraus.
|
||
*
|
||
* form.submit() loest KEIN submit-Ereignis aus - der Zuhoerer oben
|
||
* lief deshalb bei den Filtern nie, obwohl es ihn gab.
|
||
* requestSubmit() tut es. Wo es fehlt, wird die Ansicht vorher von
|
||
* Hand angehaengt.
|
||
*/
|
||
/**
|
||
* Setzt oder entfernt alle Haken einer Mehrfachauswahl.
|
||
*
|
||
* Ohne das waere "ich will jetzt wirklich alles sehen" ein Dutzend
|
||
* Klicks - und genau dann braucht man es, wenn man etwas sucht und
|
||
* nicht findet.
|
||
*/
|
||
function dcAlleHaken(knopf, gesetzt) {
|
||
knopf.closest('.filter-mehrfach-inhalt')
|
||
.querySelectorAll('input[type=checkbox]')
|
||
.forEach(k => { k.checked = gesetzt; });
|
||
}
|
||
|
||
function dcSubmit(form) {
|
||
if (typeof form.requestSubmit === 'function') {
|
||
form.requestSubmit();
|
||
return;
|
||
}
|
||
dcRememberView(form);
|
||
form.submit();
|
||
}
|
||
|
||
// Sidebar Toggle Handler
|
||
function toggleSidebar() {
|
||
const sidebar = document.getElementById('sidebar');
|
||
const mainWrapper = document.getElementById('mainWrapper');
|
||
sidebar.classList.toggle('collapsed');
|
||
mainWrapper.classList.toggle('expanded');
|
||
localStorage.setItem('sidebar_collapsed', sidebar.classList.contains('collapsed'));
|
||
}
|
||
|
||
if (localStorage.getItem('sidebar_collapsed') === 'true') {
|
||
document.getElementById('sidebar').classList.add('collapsed');
|
||
document.getElementById('mainWrapper').classList.add('expanded');
|
||
}
|
||
|
||
// 30s Auto Refresh Toggle
|
||
function toggleAutoRefresh() {
|
||
const btn = document.getElementById('btnAutoRefresh');
|
||
const txt = document.getElementById('autoRefreshText');
|
||
|
||
isAutoRefreshActive = !isAutoRefreshActive;
|
||
if (isAutoRefreshActive) {
|
||
btn.classList.add('active');
|
||
txt.innerText = '30s REFRESH: AN';
|
||
autoRefreshTimer = setInterval(() => location.reload(), 30000);
|
||
} else {
|
||
btn.classList.remove('active');
|
||
txt.innerText = '30s REFRESH: AUS';
|
||
if (autoRefreshTimer) clearInterval(autoRefreshTimer);
|
||
}
|
||
}
|
||
|
||
// Main Module Navigation Switcher
|
||
//
|
||
// activeSubtabId und updateHash werden beim Wiederherstellen aus der URL
|
||
// gesetzt: dort soll eine bestimmte Unteransicht geoeffnet und der
|
||
// Anker nicht ueberschrieben werden.
|
||
function switchMainTab(moduleName, el, activeSubtabId, updateHash = true) {
|
||
document.querySelectorAll('.main-content > .tab-content').forEach(t => t.classList.remove('active'));
|
||
document.querySelectorAll('.nav-link').forEach(n => n.classList.remove('active'));
|
||
|
||
const targetTab = document.getElementById('tab-' + moduleName);
|
||
if (targetTab) {
|
||
targetTab.classList.add('active');
|
||
}
|
||
|
||
const navLink = el || document.querySelector(`.nav-link[onclick*="'${moduleName}'"]`);
|
||
if (navLink) {
|
||
navLink.classList.add('active');
|
||
}
|
||
|
||
const pageTitles = {
|
||
'overview': 'Globales Dashboard',
|
||
'projects': 'Projektverwaltung',
|
||
'license': 'Lizenzverwaltung',
|
||
'watchdog': 'WatchDog Monitoring',
|
||
'updateservice': 'UpdateService Releases',
|
||
'bugtracker': 'Bug- & Feature-Tracker',
|
||
'tokens': 'Token-Verwaltung & Provisionierung',
|
||
'system': 'System & Datenbank Status'
|
||
};
|
||
document.getElementById('topPageTitle').innerText = pageTitles[moduleName] || 'Deploymentcenter';
|
||
|
||
renderTopBarSubnav(moduleName, activeSubtabId);
|
||
|
||
// Unteransicht passend zur Navigation einblenden
|
||
const items = subnavItems[moduleName] || [];
|
||
const wanted = activeSubtabId
|
||
|| (items.find(item => item.active) || items[0] || {}).id;
|
||
|
||
if (targetTab && wanted) {
|
||
targetTab.querySelectorAll('.subtab-content').forEach(s => s.classList.remove('active'));
|
||
const sub = document.getElementById(wanted);
|
||
if (sub) {
|
||
sub.classList.add('active');
|
||
}
|
||
}
|
||
|
||
if (updateHash) {
|
||
location.hash = wanted || ('tab-' + moduleName);
|
||
}
|
||
}
|
||
|
||
// Die frueher hier stehende Funktion filterBugtrackerTable() ist entfallen.
|
||
// Sie blendete Zeilen im Browser aus, waehrend der Server ohnehin alle
|
||
// Datensaetze gerendert hatte. Gefiltert wird jetzt serverseitig ueber
|
||
// GET-Parameter - das skaliert, ueberlebt den Auto-Refresh und laesst
|
||
// sich als Link teilen.
|
||
|
||
// ---------------------------------------------------------------
|
||
// Token bearbeiten
|
||
// ---------------------------------------------------------------
|
||
|
||
/**
|
||
* Fuellt den Dialog aus der Zeile, die geklickt wurde.
|
||
*
|
||
* Die Angaben kommen als JSON aus dem PHP-Teil mit, statt sie
|
||
* nachzuladen: Sie stehen ohnehin schon in der Tabelle, und ein
|
||
* zusaetzlicher Endpunkt waere eine weitere Stelle, an der die
|
||
* Rechtepruefung sitzen muesste.
|
||
*/
|
||
function openTokenEdit(tok) {
|
||
document.getElementById('tokEditId').value = tok.token_id;
|
||
document.getElementById('tokEditIdText').innerText = tok.token_id;
|
||
document.getElementById('tokEditName').innerText = tok.name;
|
||
document.getElementById('tokEditNameInput').value = tok.name;
|
||
document.getElementById('tokEditOwnerType').value = tok.owner_type;
|
||
document.getElementById('tokEditIdentity').value = tok.owner_identity || '';
|
||
document.getElementById('tokEditProject').value = tok.project_slug || '';
|
||
document.getElementById('tokEditLicense').value = tok.license_key || '';
|
||
document.getElementById('tokEditEnv').value = tok.environment;
|
||
document.getElementById('tokEditExpires').value = tok.expires_at || '';
|
||
|
||
const gesetzt = new Set(tok.scopes || []);
|
||
document.querySelectorAll('.tok-edit-scope').forEach(feld => {
|
||
feld.checked = gesetzt.has(feld.value);
|
||
});
|
||
|
||
// Der Hinweis zu den Sub-Tokens nur dort, wo es welche gibt -
|
||
// sonst stumpft er ab und wird ueberlesen.
|
||
const hinweis = document.getElementById('tokEditSubHinweis');
|
||
if (tok.subs > 0) {
|
||
document.getElementById('tokEditSubCount').innerText = tok.subs;
|
||
hinweis.style.display = 'block';
|
||
} else {
|
||
hinweis.style.display = 'none';
|
||
}
|
||
|
||
document.getElementById('tokenEditModal').style.display = 'flex';
|
||
}
|
||
|
||
function closeTokenEdit() {
|
||
document.getElementById('tokenEditModal').style.display = 'none';
|
||
}
|
||
|
||
// ---------------------------------------------------------------
|
||
// Roadmap
|
||
// ---------------------------------------------------------------
|
||
|
||
/**
|
||
* Oeffnet den Dialog fuer die Roadmap-Angaben eines Items.
|
||
*
|
||
* Derselbe Dialog dient dem Aufnehmen (Bereich setzen) und dem
|
||
* Aendern. Ein eigener Aufnahmedialog waere ein zweites Formular fuer
|
||
* dieselben vier Felder gewesen.
|
||
*/
|
||
function openRoadmapEdit(item) {
|
||
document.getElementById('rmEditId').value = item.id;
|
||
document.getElementById('rmEditLabel').innerText = '#' + item.id + ' — ' + item.title;
|
||
document.getElementById('rmEditEpic').value = item.epic || '';
|
||
document.getElementById('rmEditRank').value = item.roadmap_rank === null ? '' : item.roadmap_rank;
|
||
document.getElementById('rmEditBuild').value = item.target_build || '';
|
||
document.getElementById('rmEditAnchor').value = item.doc_anchor || '';
|
||
document.getElementById('rmEditTrigger').value = item.defer_trigger || '';
|
||
document.getElementById('rmEditStatus').value = item.status;
|
||
|
||
rmToggleTrigger();
|
||
document.getElementById('roadmapEditModal').style.display = 'flex';
|
||
}
|
||
|
||
function closeRoadmapEdit() {
|
||
document.getElementById('roadmapEditModal').style.display = 'none';
|
||
}
|
||
|
||
/**
|
||
* Der Ausloeser ist nur beim Zurueckstellen von Belang.
|
||
*
|
||
* Das Feld bleibt sichtbar, wird aber hervorgehoben und als Pflicht
|
||
* markiert - ausblenden waere schlechter, weil ein bereits
|
||
* hinterlegter Ausloeser dann unsichtbar mitliefe.
|
||
*/
|
||
function rmToggleTrigger() {
|
||
const deferred = document.getElementById('rmEditStatus').value === 'deferred';
|
||
const group = document.getElementById('rmTriggerGroup');
|
||
const field = document.getElementById('rmEditTrigger');
|
||
|
||
group.style.opacity = deferred ? '1' : '0.55';
|
||
field.required = deferred;
|
||
}
|
||
|
||
// Open Bugtracker Edit Modal
|
||
function openEditBugtrackerModal(item) {
|
||
document.getElementById('btEditItemId').value = item.id;
|
||
document.getElementById('btEditTitleDisplay').innerText = '#' + item.id + ': ' + item.title;
|
||
document.getElementById('btEditStatus').value = item.status;
|
||
document.getElementById('btEditSeverity').value = item.severity;
|
||
document.getElementById('btEditPushId').value = item.push_id || '';
|
||
document.getElementById('btEditTargetAgent').value = item.target_agent || '';
|
||
document.getElementById('btEditTags').value = item.tags || '';
|
||
document.getElementById('btEditResolvedInBuild').value = item.resolved_in_build || '';
|
||
document.getElementById('btEditResolutionNotes').value = item.resolution_notes || '';
|
||
document.getElementById('btEditModal').style.display = 'flex';
|
||
}
|
||
|
||
function closeEditBugtrackerModal() {
|
||
document.getElementById('btEditModal').style.display = 'none';
|
||
}
|
||
|
||
/**
|
||
* Maskiert Text fuer die Einbettung in HTML.
|
||
*
|
||
* Ohne diese Funktion wurden Titel, Beschreibung, Fehlermeldung,
|
||
* Stacktrace und Kommentare ungefiltert per innerHTML eingesetzt.
|
||
* Da der Report-Endpunkt zudem ohne Token erreichbar war, konnte
|
||
* jeder ein Item mit eingebettetem Skript einliefern, das beim
|
||
* Oeffnen der Detailansicht in der Admin-Sitzung ausgefuehrt wurde.
|
||
*/
|
||
function esc(value) {
|
||
if (value === null || value === undefined) {
|
||
return '';
|
||
}
|
||
return String(value)
|
||
.replace(/&/g, '&')
|
||
.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');
|
||
}
|
||
|
||
// Ohne uebergebenen Knopf ueber die Kennung finden, damit die
|
||
// Markierung auch beim Wiederherstellen aus der URL stimmt.
|
||
const button = el
|
||
|| document.querySelector(`#topbarSubnav .subnav-link[data-subtab="${subtabId}"]`);
|
||
if (button) {
|
||
button.classList.add('active');
|
||
}
|
||
|
||
location.hash = subtabId;
|
||
}
|
||
|
||
// Project Editing Form Functions
|
||
function editProject(p) {
|
||
document.getElementById('projEditId').value = p.id;
|
||
document.getElementById('projEditSlug').value = p.slug;
|
||
document.getElementById('projEditSlug').readOnly = true;
|
||
document.getElementById('projEditName').value = p.name;
|
||
document.getElementById('projEditTtl').value = p.default_cache_ttl_hours;
|
||
document.getElementById('projEditNotes').value = p.notes || '';
|
||
document.getElementById('projSubmitBtn').innerText = 'Änderungen Speichern';
|
||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||
}
|
||
|
||
function resetProjectForm() {
|
||
document.getElementById('projEditId').value = 0;
|
||
document.getElementById('projEditSlug').value = '';
|
||
document.getElementById('projEditSlug').readOnly = false;
|
||
document.getElementById('projEditName').value = '';
|
||
document.getElementById('projEditTtl').value = 168;
|
||
document.getElementById('projEditNotes').value = '';
|
||
document.getElementById('projSubmitBtn').innerText = 'Projekt Anlegen';
|
||
}
|
||
|
||
// UpdateService Release Modal Functions
|
||
function openEditReleaseModal(r) {
|
||
const setVal = (id, val) => {
|
||
const el = document.getElementById(id);
|
||
if (el) el.value = val;
|
||
};
|
||
|
||
setVal('edit_release_id', r.id || 0);
|
||
setVal('edit_release_product_slug', r.product_slug || '');
|
||
setVal('edit_release_channel', r.channel || 'prod');
|
||
setVal('edit_release_platform', r.platform || 'any');
|
||
setVal('edit_release_version', r.version || '');
|
||
setVal('edit_release_git_commit', r.git_commit || '');
|
||
setVal('edit_release_download_url', r.download_url || '');
|
||
setVal('edit_release_sha256_hash', r.sha256_hash || '');
|
||
setVal('edit_release_size_bytes', r.size_bytes || 0);
|
||
setVal('edit_release_release_notes', r.release_notes || '');
|
||
|
||
const critEl = document.getElementById('edit_release_is_critical');
|
||
if (critEl) critEl.checked = Boolean(Number(r.is_critical));
|
||
|
||
const modal = document.getElementById('editReleaseModal');
|
||
if (modal) modal.classList.add('active');
|
||
}
|
||
|
||
function closeEditReleaseModal() {
|
||
const modal = document.getElementById('editReleaseModal');
|
||
if (modal) modal.classList.remove('active');
|
||
}
|
||
|
||
function openDeleteReleaseModal(id, version, product) {
|
||
const idEl = document.getElementById('delete_release_id');
|
||
if (idEl) idEl.value = id;
|
||
|
||
const verEl = document.getElementById('delete_release_version_display');
|
||
if (verEl) verEl.textContent = 'v' + version;
|
||
|
||
const prodEl = document.getElementById('delete_release_product_display');
|
||
if (prodEl) prodEl.textContent = product;
|
||
|
||
const modal = document.getElementById('deleteReleaseModal');
|
||
if (modal) modal.classList.add('active');
|
||
}
|
||
|
||
function closeDeleteReleaseModal() {
|
||
const modal = document.getElementById('deleteReleaseModal');
|
||
if (modal) modal.classList.remove('active');
|
||
}
|
||
|
||
// Project Safety Deletion Modal
|
||
function openDeleteProjectModal(id, slug, name) {
|
||
document.getElementById('deleteProjId').value = id;
|
||
document.getElementById('deleteProjSlugDisplay').innerText = slug;
|
||
document.getElementById('deleteProjNameDisplay').innerText = name + ' (' + slug + ')';
|
||
document.getElementById('deleteProjConfirmInput').value = '';
|
||
document.getElementById('deleteProjectModal').classList.add('active');
|
||
}
|
||
|
||
function closeDeleteProjectModal() {
|
||
document.getElementById('deleteProjectModal').classList.remove('active');
|
||
}
|
||
|
||
// Monitor Icon Selector (Matching Screenshot 1)
|
||
function selectIcon(btnEl, iconVal) {
|
||
document.querySelectorAll('.icon-option-btn').forEach(b => b.classList.remove('selected'));
|
||
btnEl.classList.add('selected');
|
||
document.getElementById('monEditIconSelected').value = iconVal;
|
||
}
|
||
|
||
// Rich Monitor Editing Modal Functions (Screenshot 1 - Null-safe)
|
||
function openEditMonitorModal(m) {
|
||
const setVal = (id, val) => {
|
||
const el = document.getElementById(id);
|
||
if (el) el.value = val;
|
||
};
|
||
|
||
setVal('monEditOldSource', m.source || '');
|
||
setVal('monEditNewSource', m.source || '');
|
||
setVal('monEditType', m.type || 'host');
|
||
setVal('monEditParent', m.parent_source || '');
|
||
setVal('monEditOs', m.os || '');
|
||
setVal('monEditGroup', m.group_key || 'Applications');
|
||
setVal('monEditInterval', m.expected_interval_sec || 60);
|
||
setVal('monEditNotes', m.notes || '');
|
||
setVal('monEditUrl', m.url || '');
|
||
|
||
const mutedEl = document.getElementById('monEditMuted');
|
||
if (mutedEl) mutedEl.checked = parseInt(m.is_muted) === 1;
|
||
|
||
// Highlight selected icon preset or auto
|
||
const iconVal = m.icon || '';
|
||
setVal('monEditIconSelected', iconVal);
|
||
document.querySelectorAll('.icon-option-btn').forEach(b => {
|
||
b.classList.remove('selected');
|
||
if (b.getAttribute('data-icon') === iconVal) {
|
||
b.classList.add('selected');
|
||
}
|
||
});
|
||
|
||
const modal = document.getElementById('editMonitorModal');
|
||
if (modal) modal.classList.add('active');
|
||
}
|
||
|
||
function closeEditMonitorModal() {
|
||
const modal = document.getElementById('editMonitorModal');
|
||
if (modal) modal.classList.remove('active');
|
||
}
|
||
|
||
function triggerDeleteMonitorFromModal() {
|
||
const source = document.getElementById('monEditOldSource').value;
|
||
if (confirm(`Möchtest du den Monitor '${source}' wirklich unwiderruflich löschen?`)) {
|
||
document.getElementById('hiddenDeleteSource').value = source;
|
||
document.getElementById('hiddenDeleteMonitorForm').submit();
|
||
}
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// Lizenz bearbeiten
|
||
//
|
||
// Frueher sprang der Edit-Knopf in eine eigene Unteransicht mit einer
|
||
// Auswahlliste, in der man die gerade angeklickte Lizenz erneut suchen
|
||
// musste. Jetzt oeffnet sich ein Dialog direkt ueber der Tabelle.
|
||
// ------------------------------------------------------------------
|
||
function openEditLicense(license) {
|
||
const setVal = (id, value) => {
|
||
const el = document.getElementById(id);
|
||
if (el) el.value = value ?? '';
|
||
};
|
||
|
||
setVal('licEditId', license.id);
|
||
setVal('licEditCustomerName', license.customer_name);
|
||
setVal('licEditCustomerEmail', license.customer_email);
|
||
setVal('licEditStatus', license.status || 'active');
|
||
setVal('licEditMaxAct', license.max_activations || 2);
|
||
setVal('licEditNotes', license.notes);
|
||
|
||
// Das Datumsfeld erwartet YYYY-MM-DD, die Datenbank liefert einen
|
||
// vollstaendigen Zeitstempel.
|
||
const expires = license.expires_at ? String(license.expires_at).substring(0, 10) : '';
|
||
setVal('licEditExpiresAt', expires);
|
||
|
||
const title = document.getElementById('licEditTitle');
|
||
if (title) {
|
||
title.textContent = `${license.license_key} — ${license.customer_name || 'ohne Kundenangabe'}`;
|
||
}
|
||
|
||
const activationInfo = document.getElementById('licEditActivations');
|
||
if (activationInfo) {
|
||
activationInfo.textContent =
|
||
`${license.active_count ?? 0} von ${license.max_activations ?? 0} Aktivierungen belegt`;
|
||
}
|
||
|
||
document.getElementById('licEditModal').style.display = 'flex';
|
||
}
|
||
|
||
function closeEditLicense() {
|
||
document.getElementById('licEditModal').style.display = 'none';
|
||
}
|
||
|
||
// Token Masking Toggle & Copy
|
||
function toggleTokenMask(tokId) {
|
||
const el = document.getElementById(tokId) || document.getElementById('tok-text-' + tokId) || document.getElementById('tok-core-' + tokId) || document.getElementById('tok-sub-' + tokId);
|
||
if (!el) return;
|
||
if (el.innerText.includes('••••')) {
|
||
el.innerText = el.getAttribute('data-full');
|
||
} else {
|
||
el.innerText = el.getAttribute('data-masked');
|
||
}
|
||
}
|
||
|
||
function copyTokenValue(tokId) {
|
||
const el = document.getElementById(tokId) || document.getElementById('tok-text-' + tokId) || document.getElementById('tok-core-' + tokId) || document.getElementById('tok-sub-' + tokId);
|
||
if (!el) return;
|
||
const fullVal = el.getAttribute('data-full');
|
||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||
navigator.clipboard.writeText(fullVal).then(() => {
|
||
alert('Token in Zwischenablage kopiert!');
|
||
}).catch(() => {
|
||
prompt('Token kopieren:', fullVal);
|
||
});
|
||
} else {
|
||
prompt('Token kopieren:', fullVal);
|
||
}
|
||
}
|
||
|
||
// Reine Ansichtsvorschau. Die vorherige Fassung erzeugte hier eine
|
||
// Zeichenkette, die sie "ED25519_SIG_" nannte - tatsaechlich war es ein
|
||
// base64-kodierter Schluessel ohne jede Signaturwirkung. Die gueltige,
|
||
// serverseitig signierte Datei liefert der .lic-Knopf in der Liste.
|
||
function generateOfflinePayload() {
|
||
const key = document.getElementById('offlineLicSelect').value;
|
||
const hwId = document.getElementById('offlineHwId').value || '(beliebig)';
|
||
|
||
const preview = {
|
||
type: 'offline_license_file',
|
||
issued_at: Math.floor(Date.now() / 1000),
|
||
license_key: key,
|
||
hardware_id: hwId,
|
||
signature_alg: 'HMAC-SHA256',
|
||
signature: '(wird beim Herunterladen serverseitig erzeugt)'
|
||
};
|
||
|
||
document.getElementById('offlineResultBox').innerText = JSON.stringify(preview, null, 2);
|
||
}
|
||
|
||
// Initialize default subnav for Overview
|
||
renderTopBarSubnav('overview');
|
||
|
||
/**
|
||
* Stellt Modul und Unteransicht aus dem Anker der URL wieder her.
|
||
*
|
||
* Die vorherige Fassung leitete den Modulnamen aus dem Anker ab
|
||
* (sub-<modul>-<rest>) und suchte den Navigationsknopf anhand seines
|
||
* Beschriftungstextes. Beides ging schief: "sub-update-releases" gehoert
|
||
* zum Modul updateservice, und keine Beschriftung enthaelt den
|
||
* Modulnamen. Der Rueckweg landete deshalb immer auf dem Dashboard.
|
||
*/
|
||
function restoreViewFromHash() {
|
||
let hash = location.hash.replace('#', '');
|
||
|
||
// Ohne Anker: der Wert aus dem Query-String, den dcRememberView()
|
||
// an GET-Formulare haengt. Nur eigene Ansichtskennungen zulassen -
|
||
// der Wert steuert, was angezeigt wird.
|
||
if (!hash) {
|
||
const fromQuery = new URLSearchParams(location.search).get('view');
|
||
if (fromQuery && /^(?:tab|sub)-[a-z0-9-]{1,60}$/.test(fromQuery)) {
|
||
hash = fromQuery;
|
||
}
|
||
}
|
||
|
||
if (!hash) {
|
||
return;
|
||
}
|
||
|
||
if (hash.startsWith('tab-')) {
|
||
const moduleName = hash.substring(4);
|
||
if (subnavItems[moduleName]) {
|
||
switchMainTab(moduleName, null, null, false);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (!hash.startsWith('sub-')) {
|
||
return;
|
||
}
|
||
|
||
const moduleName = moduleOfSubtab(hash);
|
||
if (!moduleName) {
|
||
// Unbekannte Unteransicht - dann wenigstens das Modul, wenn es
|
||
// sich aus dem Namen ergibt. Wortlos im Dashboard zu landen ist
|
||
// die schlechteste aller Antworten: Der Nutzer sieht nicht,
|
||
// dass etwas schiefging, sondern nur, dass er woanders ist.
|
||
const rest = hash.substring(4);
|
||
const guess = Object.keys(subnavItems).find(m => rest.startsWith(m + '-'));
|
||
if (guess) {
|
||
switchMainTab(guess, null, null, false);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Die Unteransicht wird direkt mit uebergeben - die Navigation wird
|
||
// synchron aufgebaut, ein Timeout ist dafuer nicht noetig.
|
||
switchMainTab(moduleName, null, hash, false);
|
||
}
|
||
|
||
window.addEventListener('load', restoreViewFromHash);
|
||
|
||
// Vor- und Zurueck-Navigation im Browser ebenfalls beruecksichtigen
|
||
window.addEventListener('hashchange', restoreViewFromHash);
|
||
</script>
|
||
|
||
<!-- Bugtracker Details & Timeline Modal -->
|
||
<div id="btDetailModal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.75); backdrop-filter:blur(8px); z-index:9999; align-items:center; justify-content:center; padding:1rem;">
|
||
<div class="card" style="width:100%; max-width:750px; max-height:90vh; overflow-y:auto; position:relative; background:#161c28; border:1px solid var(--border-glass);">
|
||
<div id="btModalContent">
|
||
<!-- Loaded via JS -->
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!--
|
||
Bugtracker Resolve Modal
|
||
|
||
Dieser Dialog fehlte bisher vollstaendig: openResolveModal() griff auf
|
||
die Elemente resolveItemId, resolveItemTitle und btResolveModal zu, die
|
||
es im HTML nie gab. Der "Gelöst"-Button war damit funktionslos und
|
||
brach in der Konsole mit einem TypeError ab.
|
||
-->
|
||
<div id="btResolveModal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.75); backdrop-filter:blur(8px); z-index:9999; align-items:center; justify-content:center; padding:1rem;">
|
||
<div class="card" style="width:100%; max-width:560px; background:#161c28; border:1px solid var(--border-glass);">
|
||
<div class="card-header" style="display:flex; justify-content:space-between; align-items:center;">
|
||
<h3 class="card-title" style="margin:0; color:#fff;">✔ Item als gelöst markieren</h3>
|
||
<button type="button" class="btn btn-sm btn-secondary" onclick="closeResolveModal()">✕</button>
|
||
</div>
|
||
|
||
<p style="font-size:0.875rem; color:var(--text-muted); margin-bottom:1rem;">
|
||
<span id="resolveItemTitle" style="color:#fff;"></span>
|
||
</p>
|
||
|
||
<form method="POST" action="index.php#tab-bugtracker">
|
||
<?= $csrfField ?>
|
||
<input type="hidden" name="action" value="bt_resolve">
|
||
<input type="hidden" name="item_id" id="resolveItemId" value="0">
|
||
|
||
<div class="form-group" style="margin-bottom:1rem;">
|
||
<label class="form-label">Gelöst in Build / Version *</label>
|
||
<input type="text" name="resolved_in_build" class="form-input" required
|
||
placeholder="z. B. v1.4.3">
|
||
<small style="font-size:0.72rem; color:var(--text-muted); margin-top:0.3rem;">
|
||
Wird dieser Build später über <code>/api/updateservice/v1/publish</code>
|
||
veröffentlicht, schließen sich Items mit derselben Angabe automatisch.
|
||
</small>
|
||
</div>
|
||
|
||
<div class="form-group" style="margin-bottom:1.25rem;">
|
||
<label class="form-label">Lösungs-Notiz (optional)</label>
|
||
<textarea name="resolution_notes" class="form-input" rows="3"
|
||
placeholder="Was wurde geändert? Hinweise für andere Agenten..."></textarea>
|
||
</div>
|
||
|
||
<div style="display:flex; justify-content:flex-end; gap:0.5rem;">
|
||
<button type="button" class="btn btn-secondary" onclick="closeResolveModal()">Abbrechen</button>
|
||
<button type="submit" class="btn">Als gelöst markieren</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<!--
|
||
Lizenz bearbeiten
|
||
|
||
Ersetzt die frühere Unteransicht "Lizenz Bearbeiten & HW". Dort musste
|
||
man die gerade angeklickte Lizenz in einer Auswahlliste erneut suchen.
|
||
-->
|
||
<div id="licEditModal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.75); backdrop-filter:blur(8px); z-index:9999; align-items:center; justify-content:center; padding:1rem;">
|
||
<div class="card" style="width:100%; max-width:620px; background:#161c28; border:1px solid var(--border-glass); max-height:90vh; overflow-y:auto;">
|
||
<div class="card-header" style="display:flex; justify-content:space-between; align-items:center;">
|
||
<h3 class="card-title" style="margin:0; color:#fff;">✏️ Lizenz bearbeiten</h3>
|
||
<button type="button" class="btn btn-sm btn-secondary" onclick="closeEditLicense()">✕</button>
|
||
</div>
|
||
|
||
<p style="font-size:0.85rem; color:var(--text-muted); margin-bottom:0.35rem;">
|
||
<code id="licEditTitle" style="color:var(--primary);"></code>
|
||
</p>
|
||
<p style="font-size:0.75rem; color:var(--text-muted); margin-bottom:1.25rem;" id="licEditActivations"></p>
|
||
|
||
<form method="POST" action="index.php#sub-license-licenses">
|
||
<?= $csrfField ?>
|
||
<input type="hidden" name="action" value="edit_license">
|
||
<input type="hidden" name="license_id" id="licEditId" value="0">
|
||
|
||
<div class="form-grid">
|
||
<div class="form-group">
|
||
<label class="form-label">Kunden-Name</label>
|
||
<input type="text" name="customer_name" id="licEditCustomerName" class="form-input">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Kunden-E-Mail</label>
|
||
<input type="email" name="customer_email" id="licEditCustomerEmail" class="form-input">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Status</label>
|
||
<select name="status" id="licEditStatus" class="form-input">
|
||
<option value="active">Aktiv</option>
|
||
<option value="suspended">Pausiert</option>
|
||
<option value="revoked">Widerrufen</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Max. Aktivierungen</label>
|
||
<input type="number" name="max_activations" id="licEditMaxAct" class="form-input" min="1">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Ablaufdatum (leer = unbegrenzt)</label>
|
||
<input type="date" name="expires_at" id="licEditExpiresAt" class="form-input">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group" style="margin-top:1rem;">
|
||
<label class="form-label">Notizen</label>
|
||
<textarea name="notes" id="licEditNotes" class="form-input" rows="2"
|
||
placeholder="Vertragsnummer, Ansprechpartner, Besonderheiten..."></textarea>
|
||
</div>
|
||
|
||
<div style="display:flex; justify-content:flex-end; gap:0.5rem; margin-top:1.25rem;">
|
||
<button type="button" class="btn btn-secondary" onclick="closeEditLicense()">Abbrechen</button>
|
||
<button type="submit" class="btn">Änderungen speichern</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Bugtracker Edit Modal -->
|
||
<div id="btEditModal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.75); backdrop-filter:blur(8px); z-index:9999; align-items:center; justify-content:center; padding:1rem;">
|
||
<div class="card" style="width:100%; max-width:650px; background:#161c28; border:1px solid var(--border-glass);">
|
||
<div class="card-header" style="display:flex; justify-content:space-between; align-items:center;">
|
||
<h3 class="card-title" style="margin:0; color:#fff;">✏️ Item Bearbeiten</h3>
|
||
<button type="button" class="btn btn-sm btn-secondary" onclick="closeEditBugtrackerModal()">✕</button>
|
||
</div>
|
||
<p style="font-size:0.875rem; color:var(--text-muted); margin-bottom:1rem;" id="btEditTitleDisplay"></p>
|
||
<form method="POST" action="index.php#tab-bugtracker">
|
||
<?= $csrfField ?>
|
||
<input type="hidden" name="action" value="bt_update_item">
|
||
<input type="hidden" name="item_id" id="btEditItemId" value="0">
|
||
|
||
<div class="form-grid">
|
||
<div class="form-group">
|
||
<label class="form-label">Status Bearbeiten</label>
|
||
<select name="status" id="btEditStatus" class="form-input" required>
|
||
<option value="open">Offen</option>
|
||
<option value="planned">Geplant</option>
|
||
<option value="in_progress">In Bearbeitung</option>
|
||
<option value="resolved">Gelöst / Umgesetzt</option>
|
||
<option value="closed">Geschlossen</option>
|
||
<option value="rejected">Abgelehnt</option>
|
||
<option value="triage_external">📥 Fremdmeldung (ungesichtet)</option>
|
||
<option value="awaiting_verification">🔎 Wartet auf Nachprüfung</option>
|
||
<option value="in_verification">🔎 Wird gerade geprüft</option>
|
||
<option value="fix_insufficient">❗ Fix trägt nicht</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Schweregrad Bearbeiten</label>
|
||
<select name="severity" id="btEditSeverity" class="form-input" required>
|
||
<option value="idea">💡 Idee / Gedanke für Später</option>
|
||
<option value="wishlist">⭐ Wunschliste / Backlog</option>
|
||
<option value="low">Niedrig</option>
|
||
<option value="medium">Mittel</option>
|
||
<option value="high">Hoch</option>
|
||
<option value="critical">🔥 Kritisch</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-grid" style="margin-top:1rem;">
|
||
<div class="form-group">
|
||
<label class="form-label">Push-ID (Workflow / Push Notification Token)</label>
|
||
<input type="text" name="push_id" id="btEditPushId" class="form-input" placeholder="z. B. push_notify_99182">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Ziel-Agent / Zuweisung</label>
|
||
<input type="text" name="target_agent" id="btEditTargetAgent" class="form-input" placeholder="z. B. agent:dev-monitor-01">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group" style="margin-top:1rem;">
|
||
<label class="form-label">Tags (Kommagetrennt)</label>
|
||
<input type="text" name="tags" id="btEditTags" class="form-input" placeholder="z. B. ui, security, database">
|
||
</div>
|
||
|
||
<div class="form-grid" style="margin-top:1rem;">
|
||
<div class="form-group">
|
||
<label class="form-label">Lösungs-Build (Falls gelöst)</label>
|
||
<input type="text" name="resolved_in_build" id="btEditResolvedInBuild" class="form-input" placeholder="v1.4.3">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Lösungs-Notizen / Dokumentation</label>
|
||
<input type="text" name="resolution_notes" id="btEditResolutionNotes" class="form-input" placeholder="Note zur Behebung...">
|
||
</div>
|
||
</div>
|
||
|
||
<div style="display:flex; justify-content:flex-end; gap:0.5rem; margin-top:1.25rem;">
|
||
<button type="button" class="btn btn-secondary" onclick="closeEditBugtrackerModal()">Abbrechen</button>
|
||
<button type="submit" class="btn">Änderungen Speichern</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</body>
|
||
</html>
|