Fruehjahrsputz. Alles hier ist unabhaengig von der laufenden Supervisor- Arbeit im Arbeitsverzeichnis - die bleibt unangetastet. Entfernt: - Die zwoelf Symbole unter public/assets/ waren bitgleiche Dubletten der Dateien in public/assets/icons/. Referenziert wurde ausschliesslich der Pfad mit icons/. getMonitorIconUrl() setzt flach gespeicherte Altwerte aus der Datenbank jetzt auf icons/ um, damit bestehende Monitore ihr Symbol behalten. - System.CommandLine (2.0.0-beta4) aus dem Packager. Kein using, kein Symbol, die Argumente werden von Hand geparst. Build bleibt fehlerfrei. Geschlossen: - client-php/ und mcp/ wurden weder von der .htaccess gesperrt noch vom Deployment ausgeschlossen. Die .mjs-Datei haette der Webserver im Klartext ausgeliefert. Beide gehoeren nicht ins Webroot: die PHP-Datei wird ins Zielprojekt kopiert, der MCP-Server laeuft lokal ueber stdio. Jetzt in beiden Ausschlusslisten - wie client-dotnet. - Fuer /api/supervisor/v1/ fehlte die Rewrite-Regel. Der Datei-Fallback haette /api/supervisor/v1/findings auf eine nicht vorhandene Datei geleitet, obwohl Code und OpenAPI diese Schreibweise vorsehen. Ergaenzt: - __pycache__/ und *.pyc in .gitignore. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
5888 lines
344 KiB
PHP
5888 lines
344 KiB
PHP
<?php
|
||
|
||
/**
|
||
* Deploymentcenter WebUI - Operations Hub
|
||
*
|
||
* Wesentliche Aenderungen gegenueber der Erstfassung:
|
||
* - Bootstrap statt handgepflegter require-Kette. Die fehlende Einbindung von
|
||
* Audit.php liess "Hardware-Aktivierung freigeben" zuverlaessig in einen
|
||
* Fatal Error laufen.
|
||
* - CSRF-Schutz fuer saemtliche zustandsaendernden Formulare.
|
||
* - Post/Redirect/Get: ein F5 nach dem Speichern legt keinen zweiten
|
||
* Kommentar, kein zweites Item und kein zweites Release mehr an.
|
||
* - Bugtracker-Liste wird serverseitig gefiltert und seitenweise geladen,
|
||
* statt saemtliche Datensaetze ins HTML zu rendern.
|
||
* - Zeitstempel liegen in der Datenbank in UTC und werden hier in die
|
||
* App-Zeitzone umgerechnet.
|
||
*/
|
||
|
||
declare(strict_types=1);
|
||
|
||
require_once __DIR__ . '/../src/bootstrap.php';
|
||
|
||
use Deploymentcenter\Core\ApiAuth;
|
||
use Deploymentcenter\Core\Auth;
|
||
use Deploymentcenter\Core\Config;
|
||
use Deploymentcenter\Core\Csrf;
|
||
use Deploymentcenter\Core\Db;
|
||
use Deploymentcenter\Core\Http;
|
||
use Deploymentcenter\Core\Logger;
|
||
use Deploymentcenter\Core\TokenManager as CoreTokenManager;
|
||
use Deploymentcenter\Modules\Bugtracker\BugRepo;
|
||
use Deploymentcenter\Modules\Bugtracker\IgnoreRules;
|
||
use Deploymentcenter\Modules\License\Audit;
|
||
use Deploymentcenter\Modules\License\KeyGen;
|
||
use Deploymentcenter\Modules\UpdateService\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 'revoke_core_token': {
|
||
$tokenId = trim((string)($_POST['token_id'] ?? ''));
|
||
if ($tokenId !== '') {
|
||
$affected = (new CoreTokenManager($pdo))->revokeToken($tokenId);
|
||
dc_flash(sprintf('%d Token (inkl. Sub-Tokens) widerrufen.', $affected));
|
||
}
|
||
dc_redirect('#tab-tokens');
|
||
}
|
||
|
||
case 'delete_core_token': {
|
||
$tokenId = trim((string)($_POST['token_id'] ?? ''));
|
||
if ($tokenId !== '') {
|
||
$affected = (new CoreTokenManager($pdo))->deleteToken($tokenId);
|
||
dc_flash(sprintf('%d Token (inkl. Sub-Tokens) dauerhaft geloescht.', $affected));
|
||
}
|
||
dc_redirect('#tab-tokens');
|
||
}
|
||
|
||
// -------------------------------------------------- Bugtracker
|
||
case 'bt_create_item': {
|
||
$title = trim((string)($_POST['title'] ?? ''));
|
||
if ($title === '') {
|
||
dc_flash('Der Titel darf nicht leer sein.', 'danger');
|
||
dc_redirect('#tab-bugtracker');
|
||
}
|
||
|
||
$bugRepo = new BugRepo($pdo);
|
||
$result = $bugRepo->reportItem([
|
||
'project_slug' => trim((string)($_POST['project_slug'] ?? 'default')),
|
||
'type' => (string)($_POST['type'] ?? 'bug'),
|
||
'title' => $title,
|
||
'description' => trim((string)($_POST['description'] ?? '')),
|
||
'error_message' => trim((string)($_POST['error_message'] ?? '')),
|
||
'stack_trace' => trim((string)($_POST['stack_trace'] ?? '')),
|
||
'build_version' => trim((string)($_POST['build_version'] ?? '')),
|
||
'environment' => (string)($_POST['environment'] ?? 'production'),
|
||
'severity' => (string)($_POST['severity'] ?? 'medium'),
|
||
'push_id' => trim((string)($_POST['push_id'] ?? '')),
|
||
'target_agent' => trim((string)($_POST['target_agent'] ?? '')),
|
||
'tags' => trim((string)($_POST['tags'] ?? '')),
|
||
'repo_url' => trim((string)($_POST['repo_url'] ?? '')),
|
||
'file_path' => trim((string)($_POST['file_path'] ?? '')),
|
||
'created_by' => 'user:' . $actor,
|
||
]);
|
||
|
||
dc_flash($result['is_new']
|
||
? sprintf('Item #%d angelegt.', $result['id'])
|
||
: sprintf('Bereits bekannt - Item #%d, Zaehler jetzt bei %d.', $result['id'], $result['occurrence_count']));
|
||
dc_redirect('#tab-bugtracker');
|
||
}
|
||
|
||
case 'bt_add_comment': {
|
||
$itemId = (int)($_POST['item_id'] ?? 0);
|
||
$comment = trim((string)($_POST['comment'] ?? ''));
|
||
|
||
if ($itemId > 0 && $comment !== '') {
|
||
(new BugRepo($pdo))->addComment($itemId, 'user:' . $actor, $comment, 'commented');
|
||
dc_flash(sprintf('Kommentar zu Item #%d gespeichert.', $itemId));
|
||
} else {
|
||
dc_flash('Kommentar konnte nicht gespeichert werden.', 'danger');
|
||
}
|
||
dc_redirect('#tab-bugtracker');
|
||
}
|
||
|
||
case 'bt_change_status': {
|
||
$itemId = (int)($_POST['item_id'] ?? 0);
|
||
$status = (string)($_POST['status'] ?? 'open');
|
||
|
||
$ok = $itemId > 0 && (new BugRepo($pdo))->updateStatus(
|
||
$itemId,
|
||
$status,
|
||
trim((string)($_POST['notes'] ?? '')),
|
||
'user:' . $actor
|
||
);
|
||
|
||
dc_flash(
|
||
$ok ? sprintf('Status von Item #%d auf "%s" gesetzt.', $itemId, $status)
|
||
: 'Statuswechsel fehlgeschlagen - Item oder Status unbekannt.',
|
||
$ok ? 'success' : 'danger'
|
||
);
|
||
dc_redirect('#tab-bugtracker');
|
||
}
|
||
|
||
case 'bt_update_item': {
|
||
$itemId = (int)($_POST['item_id'] ?? 0);
|
||
|
||
$updates = [];
|
||
foreach (['status', 'severity', 'push_id', 'target_agent', 'tags', 'resolution_notes', 'resolved_in_build', 'repo_url', 'file_path'] as $field) {
|
||
if (array_key_exists($field, $_POST)) {
|
||
$updates[$field] = $_POST[$field];
|
||
}
|
||
}
|
||
|
||
$ok = $itemId > 0 && (new BugRepo($pdo))->updateItemDetails($itemId, $updates, 'user:' . $actor);
|
||
|
||
dc_flash(
|
||
$ok ? sprintf('Item #%d aktualisiert.', $itemId) : 'Item nicht gefunden.',
|
||
$ok ? 'success' : 'danger'
|
||
);
|
||
dc_redirect('#tab-bugtracker');
|
||
}
|
||
|
||
case 'bt_resolve': {
|
||
$itemId = (int)($_POST['item_id'] ?? 0);
|
||
$build = trim((string)($_POST['resolved_in_build'] ?? ''));
|
||
|
||
if ($build === '') {
|
||
dc_flash('Bitte den Build angeben, in dem das Item geloest wurde.', 'danger');
|
||
dc_redirect('#tab-bugtracker');
|
||
}
|
||
|
||
$ok = $itemId > 0 && (new BugRepo($pdo))->resolveItem(
|
||
$itemId,
|
||
$build,
|
||
trim((string)($_POST['resolution_notes'] ?? '')),
|
||
'user:' . $actor
|
||
);
|
||
|
||
dc_flash(
|
||
$ok ? sprintf('Item #%d in Build "%s" geloest.', $itemId, e($build)) : 'Item nicht gefunden.',
|
||
$ok ? 'success' : 'danger'
|
||
);
|
||
dc_redirect('#tab-bugtracker');
|
||
}
|
||
|
||
// ------------------------------------------------ Ignore-Regeln
|
||
case 'ir_create': {
|
||
$rules = new IgnoreRules($pdo);
|
||
|
||
try {
|
||
$ruleId = $rules->create(
|
||
trim((string)($_POST['project_slug'] ?? '')),
|
||
(string)($_POST['match_type'] ?? 'contains'),
|
||
(string)($_POST['pattern'] ?? ''),
|
||
(string)($_POST['reason'] ?? ''),
|
||
(int)($_POST['alert_on_rate'] ?? 0),
|
||
'user:' . $actor
|
||
);
|
||
} catch (InvalidArgumentException $e) {
|
||
dc_flash('Regel nicht angelegt: ' . e($e->getMessage()), 'danger');
|
||
dc_redirect('#sub-bugtracker-ignore');
|
||
}
|
||
|
||
// Bereits erfasste, passende Fehler gleich mit stummschalten -
|
||
// sonst muesste man sie einzeln nachpflegen.
|
||
$affected = 0;
|
||
if (!empty($_POST['apply_retroactively'])) {
|
||
$affected = $rules->applyRetroactively($ruleId);
|
||
}
|
||
|
||
dc_flash(sprintf(
|
||
'Regel #%d angelegt.%s',
|
||
$ruleId,
|
||
$affected > 0 ? sprintf(' %d bestehende(s) Item(s) wurden stummgeschaltet.', $affected) : ''
|
||
));
|
||
dc_redirect('#sub-bugtracker-ignore');
|
||
}
|
||
|
||
case 'ir_toggle': {
|
||
$ruleId = (int)($_POST['rule_id'] ?? 0);
|
||
$enable = (int)($_POST['enable'] ?? 0) === 1;
|
||
|
||
if ($ruleId > 0 && (new IgnoreRules($pdo))->setEnabled($ruleId, $enable)) {
|
||
dc_flash(sprintf('Regel #%d %s.', $ruleId, $enable ? 'aktiviert' : 'deaktiviert'));
|
||
} else {
|
||
dc_flash('Regel nicht gefunden.', 'danger');
|
||
}
|
||
dc_redirect('#sub-bugtracker-ignore');
|
||
}
|
||
|
||
case 'ir_delete': {
|
||
$ruleId = (int)($_POST['rule_id'] ?? 0);
|
||
|
||
if ($ruleId > 0 && (new IgnoreRules($pdo))->delete($ruleId)) {
|
||
// Items behalten ihren Status, verlieren aber die Bindung.
|
||
$pdo->prepare('UPDATE bugtracker_items SET ignore_rule_id = NULL WHERE ignore_rule_id = :id')
|
||
->execute([':id' => $ruleId]);
|
||
dc_flash(sprintf('Regel #%d geloescht.', $ruleId));
|
||
} else {
|
||
dc_flash('Regel nicht gefunden.', 'danger');
|
||
}
|
||
dc_redirect('#sub-bugtracker-ignore');
|
||
}
|
||
|
||
case 'ir_apply': {
|
||
$ruleId = (int)($_POST['rule_id'] ?? 0);
|
||
$affected = $ruleId > 0 ? (new IgnoreRules($pdo))->applyRetroactively($ruleId) : 0;
|
||
|
||
dc_flash(sprintf('%d bestehende(s) Item(s) durch Regel #%d stummgeschaltet.', $affected, $ruleId));
|
||
dc_redirect('#sub-bugtracker-ignore');
|
||
}
|
||
|
||
// Einzelnes Item stummschalten oder wieder aufnehmen
|
||
case 'bt_set_ignored': {
|
||
$itemId = (int)($_POST['item_id'] ?? 0);
|
||
$ignore = (int)($_POST['ignore'] ?? 1) === 1;
|
||
|
||
if ($itemId > 0) {
|
||
$ok = (new BugRepo($pdo))->updateItemDetails(
|
||
$itemId,
|
||
['status' => $ignore ? 'ignored' : 'open'],
|
||
'user:' . $actor
|
||
);
|
||
dc_flash(
|
||
$ok
|
||
? sprintf('Item #%d %s.', $itemId, $ignore ? 'stummgeschaltet' : 'wieder aufgenommen')
|
||
: 'Item nicht gefunden.',
|
||
$ok ? 'success' : 'danger'
|
||
);
|
||
}
|
||
dc_redirect('#sub-bugtracker-errors');
|
||
}
|
||
|
||
case 'bt_release_claim': {
|
||
$itemId = (int)($_POST['item_id'] ?? 0);
|
||
if ($itemId > 0) {
|
||
$pdo->prepare('
|
||
UPDATE bugtracker_items
|
||
SET claimed_by = NULL, claimed_at = NULL, lease_until = NULL
|
||
WHERE id = :id
|
||
')->execute([':id' => $itemId]);
|
||
(new BugRepo($pdo))->addComment($itemId, 'user:' . $actor, 'Zuweisung durch Administrator aufgehoben.', 'released');
|
||
dc_flash(sprintf('Zuweisung von Item #%d aufgehoben.', $itemId));
|
||
}
|
||
dc_redirect('#tab-bugtracker');
|
||
}
|
||
|
||
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');
|
||
}
|
||
|
||
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, last_login_at, created_at
|
||
FROM dc_users ORDER BY role ASC, username ASC
|
||
')->fetchAll() ?: [];
|
||
} catch (Throwable $e) {
|
||
// 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));
|
||
|
||
$btFilters = [
|
||
'project_slug' => (string)($_GET['bt_project'] ?? 'all'),
|
||
'environment' => (string)($_GET['bt_env'] ?? 'all'),
|
||
'type' => (string)($_GET['bt_type'] ?? 'all'),
|
||
'severity' => (string)($_GET['bt_severity'] ?? 'all'),
|
||
'status' => (string)($_GET['bt_status'] ?? 'all'),
|
||
'search' => (string)($_GET['bt_q'] ?? ''),
|
||
'order' => (string)($_GET['bt_order'] ?? 'newest'),
|
||
'limit' => $btPerPage,
|
||
'offset' => ($btPage - 1) * $btPerPage,
|
||
];
|
||
|
||
$bugRepo = new BugRepo($pdo);
|
||
$bugtrackerResult = $bugRepo->getItems($btFilters);
|
||
$bugtrackerItems = $bugtrackerResult['items'];
|
||
$bugtrackerTotal = $bugtrackerResult['total'];
|
||
$bugtrackerPages = max(1, (int)ceil($bugtrackerTotal / $btPerPage));
|
||
$bugtrackerStats = $bugRepo->getStats(
|
||
$btFilters['project_slug'] !== 'all' ? $btFilters['project_slug'] : null
|
||
);
|
||
|
||
/** Baut eine URL mit den aktuellen Bugtracker-Filtern und einzelnen Ueberschreibungen. */
|
||
function btUrl(array $overrides = []): string
|
||
{
|
||
$params = [
|
||
'bt_project' => $_GET['bt_project'] ?? null,
|
||
'bt_env' => $_GET['bt_env'] ?? null,
|
||
'bt_type' => $_GET['bt_type'] ?? null,
|
||
'bt_severity' => $_GET['bt_severity'] ?? null,
|
||
'bt_status' => $_GET['bt_status'] ?? null,
|
||
'bt_q' => $_GET['bt_q'] ?? null,
|
||
'bt_order' => $_GET['bt_order'] ?? null,
|
||
'bt_page' => $_GET['bt_page'] ?? null,
|
||
];
|
||
|
||
$params = array_merge($params, $overrides);
|
||
$params = array_filter($params, static fn($v): bool => $v !== null && $v !== '' && $v !== 'all');
|
||
|
||
return 'index.php' . ($params !== [] ? '?' . http_build_query($params) : '') . '#tab-bugtracker';
|
||
}
|
||
|
||
// ----------------------------------------------------------------------
|
||
// Fehler-Stream
|
||
// ----------------------------------------------------------------------
|
||
$errPerPage = 40;
|
||
$errPage = max(1, (int)($_GET['err_page'] ?? 1));
|
||
$errShow = (string)($_GET['err_show'] ?? 'active'); // active | ignored | all
|
||
|
||
$errFilters = [
|
||
'project_slug' => (string)($_GET['err_project'] ?? 'all'),
|
||
'environment' => (string)($_GET['err_env'] ?? 'all'),
|
||
'error_level' => (string)($_GET['err_level'] ?? 'all'),
|
||
'search' => (string)($_GET['err_q'] ?? ''),
|
||
'since_hours' => (int)($_GET['err_hours'] ?? 0),
|
||
'errors_only' => true,
|
||
'order' => 'newest',
|
||
'limit' => $errPerPage,
|
||
'offset' => ($errPage - 1) * $errPerPage,
|
||
];
|
||
|
||
if ($errShow === 'ignored') {
|
||
$errFilters['only_ignored'] = true;
|
||
} elseif ($errShow === 'all') {
|
||
$errFilters['include_ignored'] = true;
|
||
}
|
||
|
||
$errorResult = $bugRepo->getItems($errFilters);
|
||
$errorItems = $errorResult['items'];
|
||
$errorTotal = $errorResult['total'];
|
||
$errorPages = max(1, (int)ceil($errorTotal / $errPerPage));
|
||
|
||
$ignoreRuleRepo = new IgnoreRules($pdo);
|
||
$ignoreRules = $ignoreRuleRepo->all();
|
||
|
||
/** Baut eine URL mit den aktuellen Fehler-Filtern. */
|
||
function errUrl(array $overrides = []): string
|
||
{
|
||
$params = [
|
||
'err_project' => $_GET['err_project'] ?? null,
|
||
'err_env' => $_GET['err_env'] ?? null,
|
||
'err_level' => $_GET['err_level'] ?? null,
|
||
'err_show' => $_GET['err_show'] ?? null,
|
||
'err_hours' => $_GET['err_hours'] ?? null,
|
||
'err_q' => $_GET['err_q'] ?? null,
|
||
'err_page' => $_GET['err_page'] ?? null,
|
||
];
|
||
|
||
$params = array_merge($params, $overrides);
|
||
$params = array_filter($params, static fn($v): bool => $v !== null && $v !== '' && $v !== 'all' && $v !== '0');
|
||
|
||
return 'index.php' . ($params !== [] ? '?' . http_build_query($params) : '') . '#sub-bugtracker-errors';
|
||
}
|
||
|
||
// Agenten-Uebersicht: wer bearbeitet gerade was
|
||
$agentWorkload = $pdo->query('
|
||
SELECT claimed_by,
|
||
COUNT(*) AS items,
|
||
MIN(lease_until) AS next_expiry
|
||
FROM bugtracker_items
|
||
WHERE claimed_by IS NOT NULL AND lease_until > UTC_TIMESTAMP()
|
||
GROUP BY claimed_by
|
||
ORDER BY items DESC
|
||
')->fetchAll() ?: [];
|
||
|
||
// Zustand des Evaluators - ohne ihn sind alle Monitor-Zustaende wertlos
|
||
$evaluatorJob = $pdo->query("SELECT * FROM watchdog_cron_jobs WHERE name = 'evaluator'")->fetch();
|
||
$evaluatorLastRun = is_array($evaluatorJob) ? ($evaluatorJob['last_run_utc'] ?? null) : null;
|
||
$evaluatorStale = $evaluatorLastRun === null
|
||
|| (time() - (int)strtotime((string)$evaluatorLastRun . ' UTC')) > 900;
|
||
|
||
// 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); }
|
||
|
||
.main-content { padding: 1.75rem 2rem; max-width: 1600px; }
|
||
|
||
/* Stats & KPI Grid */
|
||
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1.25rem; margin-bottom: 1.75rem; }
|
||
.stat-card { background: var(--bg-card); border: 1px solid var(--border-glass); border-radius: 16px; padding: 1.25rem; backdrop-filter: blur(24px); box-shadow: 0 8px 30px rgba(0,0,0,0.25); }
|
||
.stat-header { display: flex; align-items: center; justify-content: space-between; color: var(--text-muted); font-size: 0.75rem; font-weight: 600; }
|
||
.stat-value { font-size: 2rem; font-weight: 800; margin-top: 0.5rem; color: #fff; font-family: 'Roboto Mono', monospace; letter-spacing: -0.02em; }
|
||
|
||
/* Group Overview Cards Panel (Screenshot 2) */
|
||
.group-cards-grid { display: flex; flex-wrap: wrap; gap: 1rem; margin-top: 0.75rem; }
|
||
.group-card { background: rgba(255,255,255,0.03); border: 1px solid var(--border-glass); border-radius: 12px; padding: 0.85rem 1.1rem; display: flex; align-items: center; justify-content: space-between; min-width: 180px; gap: 1rem; }
|
||
.group-card-name { font-size: 0.875rem; font-weight: 700; color: #fff; }
|
||
|
||
/* Icon Grid Selector in Edit Modal (Screenshot 1) */
|
||
.icon-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.5rem; max-height: 180px; overflow-y: auto; padding-right: 4px; }
|
||
.icon-option-btn { background: rgba(255,255,255,0.03); border: 1px solid var(--border-glass); border-radius: 10px; padding: 0.55rem 0.75rem; color: var(--text-main); font-size: 0.8rem; font-weight: 600; cursor: pointer; display: flex; align-items: center; gap: 0.5rem; transition: all 0.2s; }
|
||
.icon-option-btn:hover { background: rgba(255,255,255,0.08); }
|
||
.icon-option-btn.selected { border-color: #FF7A45; background: rgba(242,98,46,0.15); color: #fff; box-shadow: 0 0 10px rgba(242,98,46,0.3); }
|
||
|
||
/* Cards & Tables */
|
||
.card { background: var(--bg-card); border: 1px solid var(--border-glass); border-radius: 18px; padding: 1.5rem; margin-bottom: 1.75rem; backdrop-filter: blur(24px); box-shadow: 0 8px 30px rgba(0,0,0,0.25); }
|
||
.card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.25rem; }
|
||
.card-title { font-size: 1.05rem; font-weight: 700; color: #fff; display: flex; align-items: center; gap: 0.5rem; }
|
||
|
||
table { width: 100%; border-collapse: collapse; text-align: left; font-size: 0.85rem; }
|
||
th { padding: 0.8rem 1rem; color: #5B6377; font-weight: 700; border-bottom: 1px solid var(--border-glass); font-size: 0.725rem; text-transform: uppercase; letter-spacing: 0.05em; }
|
||
td { padding: 0.85rem 1rem; border-bottom: 1px solid var(--border-soft); vertical-align: middle; }
|
||
tr:hover td { background: rgba(255, 255, 255, 0.02); }
|
||
|
||
/* Badges */
|
||
.badge { padding: 0.2rem 0.6rem; border-radius: 20px; font-size: 0.725rem; font-weight: 700; display: inline-flex; align-items: center; gap: 0.35rem; font-family: 'Roboto Mono', monospace; }
|
||
.badge-up { background: rgba(18,212,138,0.12); color: var(--success); border: 1px solid rgba(18,212,138,0.3); }
|
||
.badge-warning { background: rgba(245,166,35,0.12); color: var(--warning); border: 1px solid rgba(245,166,35,0.3); }
|
||
.badge-down { background: rgba(246,70,93,0.12); color: var(--danger); border: 1px solid rgba(246,70,93,0.3); }
|
||
.badge-stopped { background: rgba(139,147,167,0.12); color: var(--text-muted); border: 1px solid rgba(139,147,167,0.3); }
|
||
|
||
/* Buttons & Form Inputs */
|
||
.btn { background: linear-gradient(135deg,#1652F0,#4c8dff); color: #fff; border: none; border-radius: 10px; padding: 0.6rem 1.1rem; font-size: 0.85rem; font-weight: 700; cursor: pointer; transition: all 0.2s; text-decoration: none; display: inline-flex; align-items: center; gap: 0.5rem; box-shadow: 0 0 15px rgba(22,82,240,0.4); }
|
||
.btn:hover { transform: translateY(-1px); box-shadow: 0 0 22px rgba(22,82,240,0.6); }
|
||
.btn-secondary { background: rgba(255,255,255,0.06); border: 1px solid var(--border-glass); color: var(--text-main); box-shadow: none; }
|
||
.btn-secondary:hover { background: rgba(255,255,255,0.12); }
|
||
.btn-sm { padding: 0.35rem 0.65rem; font-size: 0.75rem; border-radius: 6px; }
|
||
.btn-danger { background: var(--danger); box-shadow: 0 0 15px rgba(246,70,93,0.4); }
|
||
.btn-danger:hover { background: #dc2626; }
|
||
|
||
.form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1rem; margin-bottom: 1.25rem; }
|
||
.form-group { display: flex; flex-direction: column; gap: 0.35rem; }
|
||
.form-label { font-size: 0.775rem; color: var(--text-muted); font-weight: 600; }
|
||
.form-input { background: rgba(255,255,255,0.04); border: 1px solid var(--border-glass); border-radius: 10px; padding: 0.65rem 0.85rem; color: #fff; font-size: 0.875rem; outline: none; font-family: 'Manrope', system-ui; transition: border-color 0.2s; }
|
||
.form-input:focus { border-color: var(--primary); }
|
||
|
||
/* Dropdown Option List Styling Fix */
|
||
select.form-input option {
|
||
background-color: #0e121c;
|
||
color: #EDEFF5;
|
||
}
|
||
|
||
.prompt-box { background: rgba(0,0,0,0.5); border: 1px solid var(--border-glass); border-radius: 10px; padding: 1.1rem; font-family: 'Roboto Mono', monospace; font-size: 0.825rem; color: #cbd5e1; white-space: pre-wrap; word-break: break-all; margin-bottom: 0.75rem; position: relative; max-height: 380px; overflow-y: auto; }
|
||
|
||
.alert { padding: 1rem 1.25rem; border-radius: 12px; margin-bottom: 1.5rem; font-size: 0.875rem; font-weight: 600; }
|
||
.alert-success { background: rgba(18,212,138,0.15); border: 1px solid rgba(18,212,138,0.3); color: #a7f3d0; }
|
||
.alert-danger { background: rgba(246,70,93,0.15); border: 1px solid rgba(246,70,93,0.3); color: #fca5a5; }
|
||
|
||
/* Modals */
|
||
.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.75); backdrop-filter: blur(10px); display: none; align-items: center; justify-content: center; z-index: 200; }
|
||
.modal-backdrop.active { display: flex; }
|
||
.modal-card { background: #14171d; border: 1px solid rgba(255,255,255,0.12); border-radius: 18px; padding: 1.75rem; max-width: 600px; width: 95%; max-height: 90vh; overflow-y: auto; box-shadow: 0 20px 50px rgba(0,0,0,0.8); }
|
||
|
||
.tab-content, .subtab-content { display: none; }
|
||
.tab-content.active, .subtab-content.active { display: block; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<!-- Glow Overlay -->
|
||
<div class="glow-overlay"></div>
|
||
|
||
<!-- Left Collapsible Sidebar -->
|
||
<aside class="sidebar" id="sidebar">
|
||
<button class="sidebar-toggle-btn" id="sidebarToggle" onclick="toggleSidebar()" title="Menü einklappen / ausklappen">
|
||
<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" fill="none" stroke-width="2"><polyline points="15 18 9 12 15 6"></polyline></svg>
|
||
</button>
|
||
|
||
<div>
|
||
<div class="brand">
|
||
<div class="brand-logo">
|
||
<svg viewBox="0 0 24 24"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"></path></svg>
|
||
</div>
|
||
<div class="brand-title">Deploymentcenter</div>
|
||
</div>
|
||
|
||
<div class="nav-section">
|
||
<div class="nav-section-title">Navigation</div>
|
||
<ul class="nav-menu">
|
||
<li class="nav-item">
|
||
<a class="nav-link active" onclick="switchMainTab('overview', this)" title="Übersicht">
|
||
<svg viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7"></rect><rect x="14" y="3" width="7" height="7"></rect><rect x="14" y="14" width="7" height="7"></rect><rect x="3" y="14" width="7" height="7"></rect></svg>
|
||
<span class="nav-text">Übersicht</span>
|
||
</a>
|
||
</li>
|
||
<li class="nav-item">
|
||
<a class="nav-link" onclick="switchMainTab('projects', this)" title="Projekte">
|
||
<svg viewBox="0 0 24 24"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>
|
||
<span class="nav-text">Projekte</span>
|
||
</a>
|
||
</li>
|
||
<li class="nav-item">
|
||
<a class="nav-link" onclick="switchMainTab('license', this)" title="Lizenzen">
|
||
<svg viewBox="0 0 24 24"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"></path></svg>
|
||
<span class="nav-text">Lizenzen</span>
|
||
</a>
|
||
</li>
|
||
<li class="nav-item">
|
||
<a class="nav-link" onclick="switchMainTab('watchdog', this)" title="WatchDog">
|
||
<svg viewBox="0 0 24 24"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path></svg>
|
||
<span class="nav-text">WatchDog</span>
|
||
</a>
|
||
</li>
|
||
<li class="nav-item">
|
||
<a class="nav-link" onclick="switchMainTab('updateservice', this)" title="UpdateService">
|
||
<svg viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>
|
||
<span class="nav-text">UpdateService</span>
|
||
</a>
|
||
</li>
|
||
<li class="nav-item">
|
||
<a class="nav-link" onclick="switchMainTab('bugtracker', this)" title="Bug- & Feature-Tracker">
|
||
<svg viewBox="0 0 24 24"><path d="M12 2a2 2 0 0 1 2 2v1h3a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2h-1v2h2a1 1 0 0 1 0 2h-2v2h1a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2v-2a2 2 0 0 1 2-2h1v-2H6a1 1 0 0 1 0-2h2v-2H7a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h3V4a2 2 0 0 1 2-2z"></path></svg>
|
||
<span class="nav-text">Bugtracker</span>
|
||
</a>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<div class="nav-section" style="margin-bottom:1rem;">
|
||
<div class="nav-section-title">Administration</div>
|
||
<ul class="nav-menu">
|
||
<li class="nav-item">
|
||
<a class="nav-link" onclick="switchMainTab('tokens', this)" title="Master- & Sub-Tokens">
|
||
<svg viewBox="0 0 24 24"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"></path></svg>
|
||
<span class="nav-text">Token-Verwaltung</span>
|
||
</a>
|
||
</li>
|
||
<li class="nav-item">
|
||
<a class="nav-link" onclick="switchMainTab('system', this)" title="System & DB">
|
||
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
|
||
<span class="nav-text">System & DB</span>
|
||
</a>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div class="user-panel">
|
||
<span class="user-name">👤 <?= htmlspecialchars($_SESSION['dc_username'] ?? 'Admin') ?></span>
|
||
<a href="logout.php" style="color:var(--danger); text-decoration:none; font-weight:700;">Abmelden</a>
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
|
||
<!-- Main Content Wrapper -->
|
||
<div class="main-wrapper" id="mainWrapper">
|
||
|
||
<!-- Top Header Bar with Embedded Module Subnav Bar -->
|
||
<header class="top-bar">
|
||
<div class="top-left">
|
||
<div class="top-title" id="topPageTitle">Globales Dashboard</div>
|
||
|
||
<!-- Subnav Bar Embedded in Top-Bar -->
|
||
<div class="topbar-subnav" id="topbarSubnav">
|
||
<!-- Dynamic Subnav items rendered via JavaScript -->
|
||
</div>
|
||
</div>
|
||
|
||
<div class="header-actions">
|
||
<button class="btn-autorefresh" id="btnAutoRefresh" onclick="toggleAutoRefresh()">
|
||
<span id="autoRefreshIcon">⏱️</span>
|
||
<span id="autoRefreshText">30s REFRESH: AUS</span>
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
<main class="main-content">
|
||
|
||
<?php if ($msg): ?>
|
||
<?php /* Der Inhalt wird beim Erzeugen der Meldung mit e() maskiert. */ ?>
|
||
<div class="alert alert-<?= $msgType === 'danger' ? 'danger' : 'success' ?>"><?= $msg ?></div>
|
||
<?php endif; ?>
|
||
|
||
<?php if ($evaluatorStale): ?>
|
||
<!--
|
||
Ohne laufenden Evaluator aendert sich der Zustand eines Monitors
|
||
nur beim Eintreffen eines Heartbeats - ein ausgefallener Server
|
||
bliebe dauerhaft gruen. Deshalb ein deutlicher Hinweis.
|
||
-->
|
||
<div class="alert alert-danger" style="display:flex; flex-wrap:wrap; align-items:center; justify-content:space-between; gap:1rem;">
|
||
<div>
|
||
<strong>⚠️ Watchdog-Evaluator läuft nicht.</strong>
|
||
Die Monitor-Zustände sind derzeit nicht verlässlich – ein ausgefallenes System bleibt „up“, bis es sich wieder meldet.
|
||
<?php if ($evaluatorLastRun !== null): ?>
|
||
Letzter Lauf: <?= e(dc_dt((string)$evaluatorLastRun)) ?> (<?= e(dc_ago((string)$evaluatorLastRun)) ?>).
|
||
<?php else: ?>
|
||
Der Evaluator lief noch nie.
|
||
<?php endif; ?>
|
||
<div style="font-size:0.78rem; margin-top:0.5rem; font-family:'Roboto Mono',monospace; opacity:0.85;">
|
||
<!--
|
||
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: BUGTRACKER & FEATURE-TRACKER ================= -->
|
||
<div id="tab-bugtracker" class="tab-content">
|
||
|
||
<div id="sub-bugtracker-items" class="subtab-content active">
|
||
<div class="stats-grid" style="grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));">
|
||
<div class="stat-card">
|
||
<div class="stat-header">Dev Bugs (Entwicklung)</div>
|
||
<div class="stat-value" style="color:#5b9dff;"><?= $bugtrackerStats['open_bugs_dev'] ?></div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-header">Prod Bugs (Produktion)</div>
|
||
<div class="stat-value" style="color:var(--danger);"><?= $bugtrackerStats['open_bugs_prod'] ?></div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-header">Offene Feature Requests</div>
|
||
<div class="stat-value" style="color:var(--warning);"><?= $bugtrackerStats['open_features'] ?></div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-header">💡 Ideen & Gedanken</div>
|
||
<div class="stat-value" style="color:#e082ff;"><?= $bugtrackerStats['ideas_count'] ?></div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-header">🤖 Von Agenten belegt</div>
|
||
<div class="stat-value" style="color:#66bb6a;"><?= $bugtrackerStats['in_progress_by_agents'] ?></div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-header">Gelöst / Umgesetzt</div>
|
||
<div class="stat-value" style="color:var(--success);"><?= $bugtrackerStats['resolved_total'] ?></div>
|
||
</div>
|
||
</div>
|
||
|
||
<?php if ($agentWorkload !== []): ?>
|
||
<div class="card" style="padding:1rem 1.25rem;">
|
||
<div class="card-header" style="margin-bottom:0.75rem;">
|
||
<h2 class="card-title" style="font-size:0.95rem;">🤖 Aktive Agenten</h2>
|
||
</div>
|
||
<div class="group-cards-grid">
|
||
<?php foreach ($agentWorkload as $agent): ?>
|
||
<div class="group-card">
|
||
<span class="group-card-name"><?= e((string)$agent['claimed_by']) ?></span>
|
||
<span class="badge badge-warning">
|
||
<?= (int)$agent['items'] ?> Item(s) · Lease bis <?= e(dc_dt((string)$agent['next_expiry'], 'H:i')) ?>
|
||
</span>
|
||
</div>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
</div>
|
||
<?php endif; ?>
|
||
|
||
<div class="card">
|
||
<div class="card-header" style="display:flex; flex-wrap:wrap; justify-content:space-between; align-items:center; gap:0.75rem;">
|
||
<h2 class="card-title" style="margin:0;">
|
||
🐛 Bugs, Features & Ideen
|
||
<span class="badge badge-stopped" style="margin-left:0.5rem;"><?= (int)$bugtrackerTotal ?> Treffer</span>
|
||
</h2>
|
||
|
||
<!--
|
||
Filterung erfolgt jetzt serverseitig ueber GET-Parameter.
|
||
Zuvor wurden saemtliche Datensaetze ins HTML gerendert und
|
||
nur per JavaScript aus- und eingeblendet - das skalierte nicht
|
||
und ging bei jedem Auto-Refresh verloren.
|
||
-->
|
||
<form method="GET" action="index.php" style="display:flex; flex-wrap:wrap; gap:0.4rem; align-items:center;">
|
||
<select name="bt_project" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="this.form.submit()">
|
||
<option value="all">🌐 Alle Projekte</option>
|
||
<?php foreach ($projects as $p): ?>
|
||
<option value="<?= e($p['slug']) ?>" <?= $btFilters['project_slug'] === $p['slug'] ? 'selected' : '' ?>>
|
||
<?= e($p['name']) ?>
|
||
</option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
|
||
<select name="bt_env" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="this.form.submit()">
|
||
<?php
|
||
$envOptions = [
|
||
'all' => '🌍 Alle Umgebungen',
|
||
'production' => '🔴 Produktion',
|
||
'development' => '🔵 Entwicklung',
|
||
'staging' => '🟡 Staging',
|
||
'testing' => '🧪 Testing',
|
||
];
|
||
foreach ($envOptions as $value => $label): ?>
|
||
<option value="<?= e($value) ?>" <?= $btFilters['environment'] === $value ? 'selected' : '' ?>><?= e($label) ?></option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
|
||
<select name="bt_type" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="this.form.submit()">
|
||
<?php
|
||
$typeOptions = [
|
||
'all' => '📂 Alle Typen',
|
||
'bug' => '🐛 Nur Bugs',
|
||
'feature_request' => '💡 Nur Features',
|
||
];
|
||
foreach ($typeOptions as $value => $label): ?>
|
||
<option value="<?= e($value) ?>" <?= $btFilters['type'] === $value ? 'selected' : '' ?>><?= e($label) ?></option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
|
||
<select name="bt_severity" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="this.form.submit()">
|
||
<?php
|
||
$sevOptions = [
|
||
'all' => '🔥 Alle Schweregrade',
|
||
'critical' => '🔥 Kritisch',
|
||
'high' => 'Hoch',
|
||
'medium' => 'Mittel',
|
||
'low' => 'Niedrig',
|
||
'wishlist' => '⭐ Wunschliste',
|
||
'idea' => '💡 Ideen',
|
||
];
|
||
foreach ($sevOptions as $value => $label): ?>
|
||
<option value="<?= e($value) ?>" <?= $btFilters['severity'] === $value ? 'selected' : '' ?>><?= e($label) ?></option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
|
||
<select name="bt_status" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="this.form.submit()">
|
||
<?php
|
||
$statusOptions = [
|
||
'all' => '📌 Alle Status',
|
||
'open,planned,in_progress' => '📋 Nur offene',
|
||
'open' => 'Offen',
|
||
'planned' => 'Geplant',
|
||
'in_progress' => 'In Bearbeitung',
|
||
'resolved' => 'Gelöst',
|
||
'closed' => 'Geschlossen',
|
||
'rejected' => 'Abgelehnt',
|
||
// Meldungen aus einem anderen Projekt. Ohne eigenen
|
||
// Filtereintrag wären sie nur unter "Alle Status" zu
|
||
// finden - ein Eingang, den niemand sieht, ist keiner.
|
||
'triage_external' => '📥 Fremdmeldungen',
|
||
];
|
||
foreach ($statusOptions as $value => $label): ?>
|
||
<option value="<?= e($value) ?>" <?= $btFilters['status'] === $value ? 'selected' : '' ?>><?= e($label) ?></option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
|
||
<select name="bt_order" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="this.form.submit()">
|
||
<?php
|
||
$orderOptions = [
|
||
'newest' => '🕒 Neueste zuerst',
|
||
'severity' => '🔥 Nach Schweregrad',
|
||
'occurrences' => '📈 Nach Häufigkeit',
|
||
'updated' => '✏️ Zuletzt geändert',
|
||
'oldest' => '⏳ Älteste zuerst',
|
||
];
|
||
foreach ($orderOptions as $value => $label): ?>
|
||
<option value="<?= e($value) ?>" <?= $btFilters['order'] === $value ? 'selected' : '' ?>><?= e($label) ?></option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
|
||
<input type="text" name="bt_q" value="<?= e($btFilters['search']) ?>" class="form-input"
|
||
style="width:170px; padding:0.3rem 0.6rem; font-size:0.8rem;" placeholder="🔍 Suchen...">
|
||
|
||
<button type="submit" class="btn btn-sm">Filtern</button>
|
||
<?php if ($btFilters['search'] !== '' || $btFilters['project_slug'] !== 'all' || $btFilters['status'] !== 'all' || $btFilters['severity'] !== 'all' || $btFilters['type'] !== 'all' || $btFilters['environment'] !== 'all'): ?>
|
||
<a href="index.php#tab-bugtracker" class="btn btn-sm btn-secondary">Zurücksetzen</a>
|
||
<?php endif; ?>
|
||
</form>
|
||
</div>
|
||
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>ID / Typ</th>
|
||
<th>Umgebung</th>
|
||
<th>Projekt & Titel</th>
|
||
<th>Build</th>
|
||
<th>Schweregrad</th>
|
||
<th>Anzahl</th>
|
||
<th>Status</th>
|
||
<th>Aktionen</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="btTableBody">
|
||
<?php if ($bugtrackerItems === []): ?>
|
||
<tr>
|
||
<td colspan="8" style="text-align:center; padding:2.5rem; color:var(--text-muted);">
|
||
Keine Einträge für diese Filterkombination.
|
||
</td>
|
||
</tr>
|
||
<?php endif; ?>
|
||
|
||
<?php foreach ($bugtrackerItems as $item): ?>
|
||
<?php
|
||
$typeBadge = $item['type'] === 'feature_request'
|
||
? '<span class="badge" style="background:rgba(180,100,255,0.2); color:#c87dff; border:1px solid #c87dff;">💡 FEATURE</span>'
|
||
: '<span class="badge badge-warning">🐛 BUG</span>';
|
||
|
||
$envBadge = match ((string)$item['environment']) {
|
||
'development' => '<span class="badge" style="background:rgba(91,157,255,0.2); color:#5b9dff; border:1px solid #5b9dff;">🔵 DEV</span>',
|
||
'production' => '<span class="badge badge-down">🔴 PROD</span>',
|
||
default => '<span class="badge badge-stopped">' . e(strtoupper((string)$item['environment'])) . '</span>',
|
||
};
|
||
|
||
$statusBadge = match ((string)$item['status']) {
|
||
'open' => '<span class="badge badge-down">OFFEN</span>',
|
||
'planned' => '<span class="badge" style="background:rgba(255,165,0,0.2); color:orange; border:1px solid orange;">GEPLANT</span>',
|
||
'in_progress' => '<span class="badge badge-warning">IN BEARBEITUNG</span>',
|
||
'resolved' => '<span class="badge badge-up">GELÖST</span>',
|
||
'rejected' => '<span class="badge badge-stopped">ABGELEHNT</span>',
|
||
'triage_external' => '<span class="badge" style="background:rgba(120,160,255,0.2); color:#7aa2ff; border:1px solid #7aa2ff;">📥 FREMDMELDUNG</span>',
|
||
default => '<span class="badge badge-stopped">' . e(strtoupper((string)$item['status'])) . '</span>',
|
||
};
|
||
|
||
$sevBadge = match ((string)$item['severity']) {
|
||
'idea' => '<span class="badge" style="background:rgba(215,100,255,0.25); color:#e082ff; border:1px solid #e082ff;">💡 IDEE</span>',
|
||
'wishlist' => '<span class="badge" style="background:rgba(0,210,255,0.25); color:#5ce1e6; border:1px solid #5ce1e6;">⭐ WUNSCH</span>',
|
||
'critical' => '<span class="badge badge-down" style="font-weight:bold;">🔥 KRITISCH</span>',
|
||
'high' => '<span class="badge badge-warning">HOCH</span>',
|
||
'medium' => '<span class="badge badge-stopped">MITTEL</span>',
|
||
default => '<span class="badge badge-stopped">NIEDRIG</span>',
|
||
};
|
||
|
||
// json_encode liefert eine gueltige JS-Literaldarstellung; das
|
||
// vorherige addslashes() konnte an Zeilenumbruechen zerbrechen.
|
||
$jsonItem = e((string)json_encode($item, JSON_INVALID_UTF8_SUBSTITUTE));
|
||
$jsTitle = e((string)json_encode((string)$item['title'], JSON_INVALID_UTF8_SUBSTITUTE));
|
||
|
||
$isClaimed = !empty($item['claimed_by'])
|
||
&& !empty($item['lease_until'])
|
||
&& strtotime((string)$item['lease_until'] . ' UTC') > time();
|
||
?>
|
||
<tr class="bt-row">
|
||
<td>#<?= (int)$item['id'] ?><br><?= $typeBadge ?></td>
|
||
<td><?= $envBadge ?></td>
|
||
<td>
|
||
<strong style="color:#fff;"><?= e((string)$item['title']) ?></strong>
|
||
<div style="font-size:0.75rem; color:var(--text-muted); margin-top:0.2rem; display:flex; flex-wrap:wrap; gap:0.4rem; align-items:center;">
|
||
<span>Projekt: <code><?= e((string)$item['project_slug']) ?></code></span>
|
||
<span>Von: <?= e((string)$item['created_by']) ?></span>
|
||
<span title="Zuletzt gesehen"><?= e(dc_ago($item['last_seen_at'] ?? null)) ?></span>
|
||
<?php if ($isClaimed): ?>
|
||
<span class="badge" style="background:rgba(245,166,35,0.2); color:#fbbf24; font-size:0.7rem;">
|
||
🔒 <?= e((string)$item['claimed_by']) ?>
|
||
</span>
|
||
<?php endif; ?>
|
||
<?php if (!empty($item['push_id'])): ?>
|
||
<span class="badge" style="background:rgba(255,255,255,0.06); font-family:monospace; font-size:0.7rem;">📲 <?= e((string)$item['push_id']) ?></span>
|
||
<?php endif; ?>
|
||
<?php if (!empty($item['target_agent'])): ?>
|
||
<span class="badge" style="background:rgba(0,200,100,0.15); color:#66bb6a; font-size:0.7rem;">🤖 <?= e((string)$item['target_agent']) ?></span>
|
||
<?php endif; ?>
|
||
<?php if (!empty($item['file_path'])): ?>
|
||
<span class="badge" style="background:rgba(255,255,255,0.06); font-family:monospace; font-size:0.7rem;">
|
||
📄 <?= e((string)$item['file_path']) ?><?= !empty($item['line_no']) ? ':' . (int)$item['line_no'] : '' ?>
|
||
</span>
|
||
<?php endif; ?>
|
||
<?php if (!empty($item['tags'])): ?>
|
||
<span class="badge" style="background:rgba(100,180,255,0.15); color:#90caf9; font-size:0.7rem;">🏷️ <?= e((string)$item['tags']) ?></span>
|
||
<?php endif; ?>
|
||
</div>
|
||
</td>
|
||
<td><code><?= e((string)($item['build_version'] ?? 'unbekannt')) ?></code></td>
|
||
<td><?= $sevBadge ?></td>
|
||
<td>
|
||
<span class="badge" style="background:rgba(255,255,255,0.08); font-family:monospace;">
|
||
<?= (int)$item['occurrence_count'] ?>x
|
||
</span>
|
||
</td>
|
||
<td><?= $statusBadge ?></td>
|
||
<td>
|
||
<div style="display:flex; gap:0.3rem; flex-wrap:wrap;">
|
||
<button type="button" class="btn btn-sm btn-secondary" onclick="openEditBugtrackerModal(<?= $jsonItem ?>)">✏️</button>
|
||
<button type="button" class="btn btn-sm btn-secondary" onclick="openBugtrackerModal(<?= (int)$item['id'] ?>)">🔍</button>
|
||
<?php if (!in_array((string)$item['status'], ['resolved', 'closed'], true)): ?>
|
||
<button type="button" class="btn btn-sm" onclick="openResolveModal(<?= (int)$item['id'] ?>, <?= $jsTitle ?>)">✔</button>
|
||
<?php endif; ?>
|
||
<?php if ($isClaimed): ?>
|
||
<form method="POST" action="index.php#tab-bugtracker" style="display:inline"
|
||
onsubmit="return confirm('Zuweisung an <?= e((string)$item['claimed_by']) ?> aufheben?');">
|
||
<?= $csrfField ?>
|
||
<input type="hidden" name="action" value="bt_release_claim">
|
||
<input type="hidden" name="item_id" value="<?= (int)$item['id'] ?>">
|
||
<button type="submit" class="btn btn-sm btn-secondary" title="Zuweisung aufheben">🔓</button>
|
||
</form>
|
||
<?php endif; ?>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
<?php endforeach; ?>
|
||
</tbody>
|
||
</table>
|
||
|
||
<?php if ($bugtrackerPages > 1): ?>
|
||
<div style="display:flex; justify-content:space-between; align-items:center; margin-top:1rem; padding-top:1rem; border-top:1px solid var(--border-soft);">
|
||
<span style="font-size:0.8rem; color:var(--text-muted);">
|
||
Seite <?= (int)$btPage ?> von <?= (int)$bugtrackerPages ?>
|
||
(<?= (int)$bugtrackerTotal ?> Einträge)
|
||
</span>
|
||
<div style="display:flex; gap:0.4rem;">
|
||
<?php if ($btPage > 1): ?>
|
||
<a href="<?= e(btUrl(['bt_page' => $btPage - 1])) ?>" class="btn btn-sm btn-secondary">← Zurück</a>
|
||
<?php endif; ?>
|
||
<?php if ($btPage < $bugtrackerPages): ?>
|
||
<a href="<?= e(btUrl(['bt_page' => $btPage + 1])) ?>" class="btn btn-sm btn-secondary">Weiter →</a>
|
||
<?php endif; ?>
|
||
</div>
|
||
</div>
|
||
<?php endif; ?>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Subtab: Item manuell anlegen -->
|
||
<div id="sub-bugtracker-new" class="subtab-content">
|
||
<div class="card">
|
||
<div class="card-header"><h2 class="card-title">➕ Bug, Feature Request oder Idee Erfassen</h2></div>
|
||
<form method="POST" action="index.php#tab-bugtracker">
|
||
<?= $csrfField ?>
|
||
<input type="hidden" name="action" value="bt_create_item">
|
||
<div class="form-grid">
|
||
<div class="form-group">
|
||
<label class="form-label">Projekt</label>
|
||
<select name="project_slug" class="form-input" required>
|
||
<?php foreach ($projects as $p): ?>
|
||
<option value="<?= htmlspecialchars($p['slug']) ?>"><?= htmlspecialchars($p['name']) ?> (<?= htmlspecialchars($p['slug']) ?>)</option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Typ</label>
|
||
<select name="type" class="form-input" required>
|
||
<option value="bug" selected>🐛 Bug / Fehlerbericht</option>
|
||
<option value="feature_request">💡 Feature Request / Vorschlag / Idee</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Umgebung (Environment)</label>
|
||
<select name="environment" class="form-input" required>
|
||
<option value="development">🔵 Entwicklung (Development)</option>
|
||
<option value="production" selected>🔴 Produktion (Production)</option>
|
||
<option value="staging">🟡 Staging</option>
|
||
<option value="testing">🧪 Testing</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Schweregrad / Priorität</label>
|
||
<select name="severity" class="form-input" required>
|
||
<option value="idea">💡 Idee / Gedanke für Später</option>
|
||
<option value="wishlist">⭐ Wunschliste / Backlog</option>
|
||
<option value="low">Niedrig</option>
|
||
<option value="medium" selected>Mittel</option>
|
||
<option value="high">Hoch</option>
|
||
<option value="critical">🔥 Kritisch</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="form-grid" style="margin-top:1rem;">
|
||
<div class="form-group">
|
||
<label class="form-label">Push-ID (Optional, z. B. Workflow / Notification Token)</label>
|
||
<input type="text" name="push_id" class="form-input" placeholder="z. B. push_notify_88192">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Ziel-Agent / Zuweisung (Optional)</label>
|
||
<input type="text" name="target_agent" class="form-input" placeholder="z. B. agent:code-fixer-01">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Tags / Kategorien (Kommagetrennt)</label>
|
||
<input type="text" name="tags" class="form-input" placeholder="z. B. ui, security, database">
|
||
</div>
|
||
</div>
|
||
<div class="form-group" style="margin-top:1rem;">
|
||
<label class="form-label">Titel / Zusammenfassung</label>
|
||
<input type="text" name="title" class="form-input" required placeholder="z. B. NullReferenceException bei Order-Submit oder Idee für TOTP 2FA">
|
||
</div>
|
||
<div class="form-group" style="margin-top:1rem;">
|
||
<label class="form-label">Build / Version</label>
|
||
<input type="text" name="build_version" class="form-input" value="v1.4.2" placeholder="v1.4.2">
|
||
</div>
|
||
<div class="form-group" style="margin-top:1rem;">
|
||
<label class="form-label">Detaillierte Beschreibung</label>
|
||
<textarea name="description" class="form-input" rows="3" placeholder="Was ist passiert? Unter welchen Bedingungen? Oder was soll umgesetzt werden?"></textarea>
|
||
</div>
|
||
<div class="form-group" style="margin-top:1rem;">
|
||
<label class="form-label">Fehlermeldung / Exception Message (Nur bei Bugs)</label>
|
||
<textarea name="error_message" class="form-input" rows="2" placeholder="Exakte Fehlermeldung aus dem Log..."></textarea>
|
||
</div>
|
||
<div class="form-group" style="margin-top:1rem;">
|
||
<label class="form-label">Stacktrace / Log Ausschnitt (Nur bei Bugs)</label>
|
||
<textarea name="stack_trace" class="form-input" rows="4" style="font-family:monospace;" placeholder="at MyApp.Core.Service.DoWork()..."></textarea>
|
||
</div>
|
||
<button type="submit" class="btn" style="margin-top:1rem;">Item Speichern & Anlegen</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- =============== Subtab: Fehler-Stream =============== -->
|
||
<div id="sub-bugtracker-errors" class="subtab-content">
|
||
<div class="stats-grid" style="grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));">
|
||
<div class="stat-card">
|
||
<div class="stat-header">Fehler (24 h)</div>
|
||
<div class="stat-value" style="color:var(--warning);"><?= $bugtrackerStats['errors_24h'] ?></div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-header">💀 Offene Abstürze</div>
|
||
<div class="stat-value" style="color:var(--danger);"><?= $bugtrackerStats['fatal_open'] ?></div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-header">🔇 Stummgeschaltete Gruppen</div>
|
||
<div class="stat-value" style="color:var(--text-muted);"><?= $bugtrackerStats['ignored_groups'] ?></div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-header">davon Vorkommnisse</div>
|
||
<div class="stat-value" style="color:var(--text-muted); font-size:1.5rem;">
|
||
<?= number_format($bugtrackerStats['ignored_occurrences'], 0, ',', '.') ?>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<?php if ($bugtrackerStats['ignored_occurrences'] > 0): ?>
|
||
<div class="alert" style="background:rgba(91,157,255,0.10); border:1px solid rgba(91,157,255,0.25); color:#bfdbfe; font-weight:500; font-size:0.85rem;">
|
||
<?= number_format($bugtrackerStats['ignored_occurrences'], 0, ',', '.') ?> Vorkommnisse
|
||
wurden als bekannt eingestuft und aus dieser Ansicht herausgehalten — ohne sie zu verwerfen.
|
||
Der Zähler bleibt erhalten, damit eine auffällige Häufung trotzdem auffällt.
|
||
</div>
|
||
<?php endif; ?>
|
||
|
||
<div class="card">
|
||
<div class="card-header" style="display:flex; flex-wrap:wrap; justify-content:space-between; align-items:center; gap:0.75rem;">
|
||
<h2 class="card-title" style="margin:0;">
|
||
⚡ Fehler-Stream
|
||
<span class="badge badge-stopped" style="margin-left:0.5rem;"><?= (int)$errorTotal ?> Gruppen</span>
|
||
</h2>
|
||
|
||
<form method="GET" action="index.php" style="display:flex; flex-wrap:wrap; gap:0.4rem; align-items:center;">
|
||
<select name="err_project" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="this.form.submit()">
|
||
<option value="all">🌐 Alle Projekte</option>
|
||
<?php foreach ($projects as $p): ?>
|
||
<option value="<?= e($p['slug']) ?>" <?= $errFilters['project_slug'] === $p['slug'] ? 'selected' : '' ?>>
|
||
<?= e($p['name']) ?>
|
||
</option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
|
||
<select name="err_level" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="this.form.submit()">
|
||
<?php
|
||
$levelOptions = [
|
||
'all' => '🎚️ Alle Klassen',
|
||
'fatal' => '💀 Absturz (fatal)',
|
||
'error' => '⚠️ Fehler (error)',
|
||
'warning' => 'ℹ️ Warnung',
|
||
];
|
||
foreach ($levelOptions as $value => $label): ?>
|
||
<option value="<?= e($value) ?>" <?= $errFilters['error_level'] === $value ? 'selected' : '' ?>><?= e($label) ?></option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
|
||
<select name="err_env" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="this.form.submit()">
|
||
<?php
|
||
$errEnvOptions = [
|
||
'all' => '🌍 Alle Umgebungen',
|
||
'production' => '🔴 Produktion',
|
||
'development' => '🔵 Entwicklung',
|
||
'staging' => '🟡 Staging',
|
||
'testing' => '🧪 Testing',
|
||
];
|
||
foreach ($errEnvOptions as $value => $label): ?>
|
||
<option value="<?= e($value) ?>" <?= $errFilters['environment'] === $value ? 'selected' : '' ?>><?= e($label) ?></option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
|
||
<select name="err_show" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="this.form.submit()">
|
||
<?php
|
||
$showOptions = [
|
||
'active' => '🔔 Nur relevante',
|
||
'ignored' => '🔇 Nur stummgeschaltete',
|
||
'all' => '📋 Alle',
|
||
];
|
||
foreach ($showOptions as $value => $label): ?>
|
||
<option value="<?= e($value) ?>" <?= $errShow === $value ? 'selected' : '' ?>><?= e($label) ?></option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
|
||
<select name="err_hours" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="this.form.submit()">
|
||
<?php
|
||
$hourOptions = [
|
||
'0' => '🕒 Gesamter Zeitraum',
|
||
'1' => 'Letzte Stunde',
|
||
'24' => 'Letzte 24 Stunden',
|
||
'168' => 'Letzte 7 Tage',
|
||
'720' => 'Letzte 30 Tage',
|
||
];
|
||
foreach ($hourOptions as $value => $label): ?>
|
||
<option value="<?= e($value) ?>" <?= (string)$errFilters['since_hours'] === $value ? 'selected' : '' ?>><?= e($label) ?></option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
|
||
<input type="text" name="err_q" value="<?= e($errFilters['search']) ?>" class="form-input"
|
||
style="width:170px; padding:0.3rem 0.6rem; font-size:0.8rem;" placeholder="🔍 Suchen...">
|
||
<button type="submit" class="btn btn-sm">Filtern</button>
|
||
<a href="index.php#sub-bugtracker-errors" class="btn btn-sm btn-secondary">Zurücksetzen</a>
|
||
</form>
|
||
</div>
|
||
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Klasse</th>
|
||
<th>Fehler</th>
|
||
<th>Projekt / Umgebung</th>
|
||
<th>Build</th>
|
||
<th>Anzahl</th>
|
||
<th>Zuletzt</th>
|
||
<th>Aktionen</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<?php if ($errorItems === []): ?>
|
||
<tr>
|
||
<td colspan="7" style="text-align:center; padding:2.5rem; color:var(--text-muted);">
|
||
Keine Fehler für diese Filterkombination.
|
||
<?php if ($errShow === 'active'): ?>
|
||
<br><small>Stummgeschaltete Einträge sind ausgeblendet — über „Nur stummgeschaltete“ sichtbar.</small>
|
||
<?php endif; ?>
|
||
</td>
|
||
</tr>
|
||
<?php endif; ?>
|
||
|
||
<?php foreach ($errorItems as $item): ?>
|
||
<?php
|
||
$isIgnored = (string)$item['status'] === 'ignored';
|
||
$level = (string)($item['error_level'] ?? 'error');
|
||
|
||
$levelBadge = match ($level) {
|
||
'fatal' => '<span class="badge badge-down" style="font-weight:bold;">💀 FATAL</span>',
|
||
'warning' => '<span class="badge" style="background:rgba(91,157,255,0.2); color:#5b9dff; border:1px solid #5b9dff;">ℹ️ WARN</span>',
|
||
default => '<span class="badge badge-warning">⚠️ ERROR</span>',
|
||
};
|
||
|
||
// Rate im laufenden Fenster – auffällige Häufung sichtbar machen
|
||
$windowCount = (int)($item['rate_window_count'] ?? 0);
|
||
$windowFresh = !empty($item['rate_window_start'])
|
||
&& (time() - (int)strtotime((string)$item['rate_window_start'] . ' UTC')) < 3600;
|
||
?>
|
||
<tr style="<?= $isIgnored ? 'opacity:0.55;' : '' ?>">
|
||
<td><?= $levelBadge ?></td>
|
||
<td>
|
||
<strong style="color:#fff;"><?= e(mb_strimwidth((string)$item['title'], 0, 90, '…')) ?></strong>
|
||
<div style="font-size:0.72rem; color:var(--text-muted); margin-top:0.2rem; display:flex; flex-wrap:wrap; gap:0.4rem; align-items:center;">
|
||
<?php if ($isIgnored): ?>
|
||
<span class="badge" style="background:rgba(255,255,255,0.06); font-size:0.68rem;">
|
||
🔇 stummgeschaltet<?= !empty($item['ignore_rule_id']) ? ' (Regel #' . (int)$item['ignore_rule_id'] . ')' : '' ?>
|
||
</span>
|
||
<?php endif; ?>
|
||
<?php if ($windowFresh && $windowCount > 1): ?>
|
||
<span class="badge" style="background:rgba(245,166,35,0.18); color:#fbbf24; font-size:0.68rem;">
|
||
📈 <?= $windowCount ?>× in dieser Stunde
|
||
</span>
|
||
<?php endif; ?>
|
||
<?php if (!empty($item['file_path'])): ?>
|
||
<span style="font-family:monospace;">📄 <?= e((string)$item['file_path']) ?><?= !empty($item['line_no']) ? ':' . (int)$item['line_no'] : '' ?></span>
|
||
<?php endif; ?>
|
||
</div>
|
||
</td>
|
||
<td>
|
||
<code style="font-size:0.75rem;"><?= e((string)$item['project_slug']) ?></code>
|
||
<div style="font-size:0.7rem; color:var(--text-muted);"><?= e(strtoupper((string)$item['environment'])) ?></div>
|
||
</td>
|
||
<td><code style="font-size:0.75rem;"><?= e((string)($item['build_version'] ?? '—')) ?></code></td>
|
||
<td>
|
||
<span class="badge" style="background:rgba(255,255,255,0.08); font-family:monospace;">
|
||
<?= number_format((int)$item['occurrence_count'], 0, ',', '.') ?>×
|
||
</span>
|
||
</td>
|
||
<td style="font-size:0.75rem; color:var(--text-muted);"><?= e(dc_ago($item['last_seen_at'] ?? null)) ?></td>
|
||
<td>
|
||
<div style="display:flex; gap:0.3rem; flex-wrap:wrap;">
|
||
<button type="button" class="btn btn-sm btn-secondary"
|
||
onclick="openBugtrackerModal(<?= (int)$item['id'] ?>)" title="Details">🔍</button>
|
||
|
||
<form method="POST" action="index.php#sub-bugtracker-errors" style="display:inline">
|
||
<?= $csrfField ?>
|
||
<input type="hidden" name="action" value="bt_set_ignored">
|
||
<input type="hidden" name="item_id" value="<?= (int)$item['id'] ?>">
|
||
<input type="hidden" name="ignore" value="<?= $isIgnored ? '0' : '1' ?>">
|
||
<button type="submit" class="btn btn-sm btn-secondary"
|
||
title="<?= $isIgnored ? 'Wieder aufnehmen' : 'Stummschalten' ?>">
|
||
<?= $isIgnored ? '🔔' : '🔇' ?>
|
||
</button>
|
||
</form>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
<?php endforeach; ?>
|
||
</tbody>
|
||
</table>
|
||
|
||
<?php if ($errorPages > 1): ?>
|
||
<div style="display:flex; justify-content:space-between; align-items:center; margin-top:1rem; padding-top:1rem; border-top:1px solid var(--border-soft);">
|
||
<span style="font-size:0.8rem; color:var(--text-muted);">
|
||
Seite <?= (int)$errPage ?> von <?= (int)$errorPages ?> (<?= (int)$errorTotal ?> Gruppen)
|
||
</span>
|
||
<div style="display:flex; gap:0.4rem;">
|
||
<?php if ($errPage > 1): ?>
|
||
<a href="<?= e(errUrl(['err_page' => $errPage - 1])) ?>" class="btn btn-sm btn-secondary">← Zurück</a>
|
||
<?php endif; ?>
|
||
<?php if ($errPage < $errorPages): ?>
|
||
<a href="<?= e(errUrl(['err_page' => $errPage + 1])) ?>" class="btn btn-sm btn-secondary">Weiter →</a>
|
||
<?php endif; ?>
|
||
</div>
|
||
</div>
|
||
<?php endif; ?>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- =============== Subtab: Ignore-Regeln =============== -->
|
||
<div id="sub-bugtracker-ignore" class="subtab-content">
|
||
<div class="card">
|
||
<div class="card-header"><h2 class="card-title">🔇 Bekannte, harmlose Fehler</h2></div>
|
||
<p style="color:var(--text-muted); font-size:0.875rem; margin-bottom:1.25rem; max-width:70ch;">
|
||
Ein Treffer bedeutet nicht „wegwerfen“: Der Fehler wird weiterhin erfasst und
|
||
hochgezählt, bleibt aber aus der Übersicht heraus und löst keine Benachrichtigung
|
||
aus. Der Zähler ist dabei der eigentliche Gewinn — dass ein bekannter Fehler
|
||
auftritt, ist normal; dass er plötzlich hundertmal so oft auftritt, ist ein Signal.
|
||
Dafür ist die Alarmschwelle da.
|
||
</p>
|
||
|
||
<form method="POST" action="index.php#sub-bugtracker-ignore">
|
||
<?= $csrfField ?>
|
||
<input type="hidden" name="action" value="ir_create">
|
||
|
||
<div class="form-grid">
|
||
<div class="form-group">
|
||
<label class="form-label">Projekt</label>
|
||
<select name="project_slug" class="form-input">
|
||
<option value="">🌐 Alle Projekte</option>
|
||
<?php foreach ($projects as $p): ?>
|
||
<option value="<?= e($p['slug']) ?>"><?= e($p['name']) ?></option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Vergleichsart</label>
|
||
<select name="match_type" class="form-input">
|
||
<option value="contains">Enthält (empfohlen)</option>
|
||
<option value="exception_class">Ausnahmeklasse exakt</option>
|
||
<option value="regex">Regulärer Ausdruck</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Alarm ab … pro Stunde</label>
|
||
<input type="number" name="alert_on_rate" class="form-input" min="0" placeholder="z. B. 500">
|
||
<small style="font-size:0.7rem; color:var(--text-muted);">0 oder leer = nie alarmieren</small>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group" style="margin-top:1rem;">
|
||
<label class="form-label">Suchmuster</label>
|
||
<input type="text" name="pattern" class="form-input" required
|
||
placeholder="z. B. Duplicate entry">
|
||
</div>
|
||
|
||
<div class="form-group" style="margin-top:1rem;">
|
||
<label class="form-label">Begründung — warum ist das harmlos?</label>
|
||
<textarea name="reason" class="form-input" rows="2" required
|
||
placeholder="z. B. Marktdaten kommen doppelt aus dem Feed. Der Eintrag wird ohnehin nur einmal benötigt, die Verarbeitung läuft normal weiter."></textarea>
|
||
<small style="font-size:0.7rem; color:var(--text-muted);">
|
||
Pflichtfeld: In sechs Monaten weiß sonst niemand mehr, warum hier weggeschaut wird.
|
||
</small>
|
||
</div>
|
||
|
||
<label style="display:flex; align-items:center; gap:0.5rem; margin-top:1rem; font-size:0.85rem; cursor:pointer;">
|
||
<input type="checkbox" name="apply_retroactively" value="1" checked>
|
||
Bereits erfasste, passende Einträge gleich mit stummschalten
|
||
</label>
|
||
|
||
<button type="submit" class="btn" style="margin-top:1.25rem;">Regel anlegen</button>
|
||
</form>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-header"><h2 class="card-title">Bestehende Regeln</h2></div>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>#</th>
|
||
<th>Projekt</th>
|
||
<th>Muster</th>
|
||
<th>Begründung</th>
|
||
<th>Alarmschwelle</th>
|
||
<th>Treffer</th>
|
||
<th>Status</th>
|
||
<th>Aktionen</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<?php if ($ignoreRules === []): ?>
|
||
<tr>
|
||
<td colspan="8" style="text-align:center; padding:2rem; color:var(--text-muted);">
|
||
Noch keine Regeln angelegt.
|
||
</td>
|
||
</tr>
|
||
<?php endif; ?>
|
||
|
||
<?php foreach ($ignoreRules as $rule): ?>
|
||
<?php $enabled = !empty($rule['enabled']); ?>
|
||
<tr style="<?= $enabled ? '' : 'opacity:0.5;' ?>">
|
||
<td><?= (int)$rule['id'] ?></td>
|
||
<td>
|
||
<?php if (empty($rule['project_slug'])): ?>
|
||
<span class="badge badge-stopped">ALLE</span>
|
||
<?php else: ?>
|
||
<code style="font-size:0.75rem;"><?= e((string)$rule['project_slug']) ?></code>
|
||
<?php endif; ?>
|
||
</td>
|
||
<td>
|
||
<code style="font-size:0.78rem;"><?= e((string)$rule['pattern']) ?></code>
|
||
<div style="font-size:0.68rem; color:var(--text-muted);"><?= e((string)$rule['match_type']) ?></div>
|
||
</td>
|
||
<td style="font-size:0.78rem; color:var(--text-muted); max-width:320px;">
|
||
<?= e(mb_strimwidth((string)$rule['reason'], 0, 160, '…')) ?>
|
||
</td>
|
||
<td>
|
||
<?php if ($rule['alert_on_rate'] !== null): ?>
|
||
<span class="badge badge-warning"><?= (int)$rule['alert_on_rate'] ?>/h</span>
|
||
<?php else: ?>
|
||
<span style="color:var(--text-muted); font-size:0.8rem;">—</span>
|
||
<?php endif; ?>
|
||
</td>
|
||
<td>
|
||
<span class="badge" style="background:rgba(255,255,255,0.08); font-family:monospace;">
|
||
<?= number_format((int)$rule['match_count'], 0, ',', '.') ?>
|
||
</span>
|
||
<div style="font-size:0.68rem; color:var(--text-muted);">
|
||
<?= (int)($rule['item_count'] ?? 0) ?> Item(s)
|
||
<?php if (!empty($rule['last_match_at'])): ?>
|
||
· <?= e(dc_ago((string)$rule['last_match_at'])) ?>
|
||
<?php endif; ?>
|
||
</div>
|
||
</td>
|
||
<td>
|
||
<span class="badge badge-<?= $enabled ? 'up' : 'stopped' ?>">
|
||
<?= $enabled ? 'AKTIV' : 'INAKTIV' ?>
|
||
</span>
|
||
</td>
|
||
<td>
|
||
<div style="display:flex; gap:0.3rem; flex-wrap:wrap;">
|
||
<form method="POST" action="index.php#sub-bugtracker-ignore" style="display:inline">
|
||
<?= $csrfField ?>
|
||
<input type="hidden" name="action" value="ir_toggle">
|
||
<input type="hidden" name="rule_id" value="<?= (int)$rule['id'] ?>">
|
||
<input type="hidden" name="enable" value="<?= $enabled ? '0' : '1' ?>">
|
||
<button type="submit" class="btn btn-sm btn-secondary">
|
||
<?= $enabled ? 'Deaktivieren' : 'Aktivieren' ?>
|
||
</button>
|
||
</form>
|
||
|
||
<form method="POST" action="index.php#sub-bugtracker-ignore" style="display:inline">
|
||
<?= $csrfField ?>
|
||
<input type="hidden" name="action" value="ir_apply">
|
||
<input type="hidden" name="rule_id" value="<?= (int)$rule['id'] ?>">
|
||
<button type="submit" class="btn btn-sm btn-secondary" title="Rückwirkend auf bestehende Einträge anwenden">↺</button>
|
||
</form>
|
||
|
||
<form method="POST" action="index.php#sub-bugtracker-ignore" style="display:inline"
|
||
onsubmit="return confirm('Regel wirklich löschen? Bereits stummgeschaltete Einträge bleiben stumm, verlieren aber die Zuordnung.');">
|
||
<?= $csrfField ?>
|
||
<input type="hidden" name="action" value="ir_delete">
|
||
<input type="hidden" name="rule_id" value="<?= (int)$rule['id'] ?>">
|
||
<button type="submit" class="btn btn-sm btn-danger">🗑️</button>
|
||
</form>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
<?php endforeach; ?>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-header"><h2 class="card-title">💻 Anbindung</h2></div>
|
||
<p style="color:var(--text-muted); font-size:0.85rem; margin-bottom:1rem;">
|
||
Ein Aufruf, den man in den globalen Exception-Handler hängt. Titel und
|
||
Dringlichkeit leitet der Server ab, die Regeln oben greifen automatisch.
|
||
</p>
|
||
<div class="prompt-box">POST <?= e($baseUrl) ?>/api/errors/v1/report
|
||
Authorization: Bearer <DC_TOKEN>
|
||
Content-Type: application/json
|
||
|
||
{
|
||
"project_slug": "polytrader",
|
||
"exception": "PDOException",
|
||
"message": "SQLSTATE[23000]: Duplicate entry '...' for key 'uq_market'",
|
||
"stack_trace": "...",
|
||
"level": "error", // fatal = Prozess beendet
|
||
"build": "v2.0.1",
|
||
"environment": "production",
|
||
"file": "src/Market/Importer.php",
|
||
"line": 142
|
||
}
|
||
|
||
Antwort:
|
||
{ "status": "success", "item_id": 42, "ignored": true,
|
||
"occurrence_count": 3841, "rate_alerted": false,
|
||
"message": "Als bekannt eingestuft, gezaehlt, nicht gemeldet." }</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<!-- ================= MODULE: TOKEN-VERWALTUNG ================= -->
|
||
<div id="tab-tokens" class="tab-content">
|
||
|
||
<div id="sub-tokens-masters" class="subtab-content active">
|
||
<div class="card">
|
||
<div class="card-header"><h2 class="card-title">👑 Master-Tokens (Selbst-Provisionierung für Client-Apps & Host-Skripte)</h2></div>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Token ID</th>
|
||
<th>Bezeichnung</th>
|
||
<th>Typ / Identität</th>
|
||
<th>Projekt / Lizenz</th>
|
||
<th>Rechte (Scopes)</th>
|
||
<th>Sub-Tokens</th>
|
||
<th>Token Key</th>
|
||
<th>Aktionen</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<?php foreach ($coreMasterTokens as $mTok): ?>
|
||
<?php
|
||
$rawVal = !empty($mTok['raw_token']) ? $mTok['raw_token'] : 'dc_master_...';
|
||
$masked = substr($rawVal, 0, 12) . '••••••••••••••••';
|
||
$scopesArr = json_decode($mTok['scopes'], true) ?: ['*'];
|
||
?>
|
||
<tr>
|
||
<td><code><?= htmlspecialchars($mTok['token_id']) ?></code></td>
|
||
<td><strong><?= htmlspecialchars($mTok['name']) ?></strong></td>
|
||
<td>
|
||
<span class="badge badge-warning"><?= strtoupper($mTok['owner_type']) ?></span><br>
|
||
<small><?= htmlspecialchars($mTok['owner_identity'] ?? '-') ?></small>
|
||
</td>
|
||
<td>
|
||
<?= htmlspecialchars($mTok['project_slug'] ?? '-') ?>
|
||
<?= !empty($mTok['license_key']) ? '<br><small>Lic: ' . htmlspecialchars($mTok['license_key']) . '</small>' : '' ?>
|
||
</td>
|
||
<td>
|
||
<?php foreach ($scopesArr as $sc): ?>
|
||
<span class="badge" style="background:rgba(255,255,255,0.06); font-size:0.75rem;"><?= htmlspecialchars($sc) ?></span>
|
||
<?php endforeach; ?>
|
||
</td>
|
||
<td>
|
||
<span class="badge badge-up"><?= (int)($mTok['sub_token_count'] ?? 0) ?> Sub-Tokens</span>
|
||
</td>
|
||
<td>
|
||
<code id="tok-core-<?= $mTok['token_id'] ?>" data-full="<?= htmlspecialchars($rawVal) ?>" data-masked="<?= htmlspecialchars($masked) ?>">
|
||
<?= htmlspecialchars($masked) ?>
|
||
</code>
|
||
<button type="button" class="btn btn-sm btn-secondary" onclick="toggleTokenMask('tok-core-<?= $mTok['token_id'] ?>')">👁️</button>
|
||
<button type="button" class="btn btn-sm btn-secondary" onclick="copyTokenValue('tok-core-<?= $mTok['token_id'] ?>')">📋</button>
|
||
</td>
|
||
<td>
|
||
<div style="display:flex; gap:0.3rem;">
|
||
<?php if (!$mTok['revoked']): ?>
|
||
<form method="POST" action="index.php#tab-tokens" style="display:inline" onsubmit="return confirm('Master-Token widerrufen? Alle zugehörigen Sub-Tokens werden sofort kaskadierend mit gesperrt!');">
|
||
<?= $csrfField ?>
|
||
<input type="hidden" name="action" value="revoke_core_token">
|
||
<input type="hidden" name="token_id" value="<?= $mTok['token_id'] ?>">
|
||
<button type="submit" class="btn btn-sm btn-warning">Widerrufen</button>
|
||
</form>
|
||
<?php else: ?>
|
||
<span class="badge badge-down">WIDERUFEN</span>
|
||
<?php endif; ?>
|
||
<form method="POST" action="index.php#tab-tokens" style="display:inline" onsubmit="return confirm('Master-Token und alle Sub-Tokens DAUERHAFT LÖSCHEN?');">
|
||
<?= $csrfField ?>
|
||
<input type="hidden" name="action" value="delete_core_token">
|
||
<input type="hidden" name="token_id" value="<?= $mTok['token_id'] ?>">
|
||
<button type="submit" class="btn btn-sm btn-danger">🗑️ Löschen</button>
|
||
</form>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
<?php endforeach; ?>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="sub-tokens-subs" class="subtab-content">
|
||
<div class="card">
|
||
<div class="card-header"><h2 class="card-title">🔑 Aktive Sub-Tokens (Per Provisioning API Erstellt)</h2></div>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Token ID</th>
|
||
<th>Parent Master ID</th>
|
||
<th>Bezeichnung / Client</th>
|
||
<th>Umgebung</th>
|
||
<th>Scopes</th>
|
||
<th>Token Key</th>
|
||
<th>Zuletzt Genutzt</th>
|
||
<th>Status</th>
|
||
<th>Aktionen</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<?php
|
||
$subTokens = array_filter($coreAllTokens, fn($t) => $t['type'] === 'sub');
|
||
foreach ($subTokens as $sTok):
|
||
$scopesArr = json_decode($sTok['scopes'], true) ?: [];
|
||
$sRaw = !empty($sTok['raw_token']) ? $sTok['raw_token'] : $sTok['token_id'];
|
||
$sMasked = (strlen($sRaw) > 12) ? substr($sRaw, 0, 12) . '••••••••••••••••' : $sRaw;
|
||
?>
|
||
<tr>
|
||
<td><code><?= htmlspecialchars($sTok['token_id']) ?></code></td>
|
||
<td><code><?= htmlspecialchars($sTok['parent_token_id'] ?? '-') ?></code></td>
|
||
<td><strong><?= htmlspecialchars($sTok['name']) ?></strong></td>
|
||
<td>
|
||
<span class="badge badge-stopped"><?= strtoupper($sTok['environment']) ?></span>
|
||
</td>
|
||
<td>
|
||
<?php foreach ($scopesArr as $sc): ?>
|
||
<span class="badge" style="background:rgba(255,255,255,0.06); font-size:0.75rem;"><?= htmlspecialchars($sc) ?></span>
|
||
<?php endforeach; ?>
|
||
</td>
|
||
<td>
|
||
<code id="tok-sub-<?= $sTok['token_id'] ?>" data-full="<?= htmlspecialchars($sRaw) ?>" data-masked="<?= htmlspecialchars($sMasked) ?>">
|
||
<?= htmlspecialchars($sMasked) ?>
|
||
</code>
|
||
<button type="button" class="btn btn-sm btn-secondary" onclick="toggleTokenMask('tok-sub-<?= $sTok['token_id'] ?>')">👁️</button>
|
||
<button type="button" class="btn btn-sm btn-secondary" onclick="copyTokenValue('tok-sub-<?= $sTok['token_id'] ?>')">📋</button>
|
||
</td>
|
||
<td><?= $sTok['last_used_at'] ? htmlspecialchars($sTok['last_used_at']) : 'Noch nie' ?></td>
|
||
<td><span class="badge badge-<?= $sTok['revoked'] ? 'down' : 'up' ?>"><?= $sTok['revoked'] ? 'WIDERUFEN' : 'AKTIV' ?></span></td>
|
||
<td>
|
||
<div style="display:flex; gap:0.3rem;">
|
||
<?php if (!$sTok['revoked']): ?>
|
||
<form method="POST" action="index.php#tab-tokens" style="display:inline" onsubmit="return confirm('Sub-Token widerrufen?');">
|
||
<?= $csrfField ?>
|
||
<input type="hidden" name="action" value="revoke_core_token">
|
||
<input type="hidden" name="token_id" value="<?= $sTok['token_id'] ?>">
|
||
<button type="submit" class="btn btn-sm btn-warning">Widerrufen</button>
|
||
</form>
|
||
<?php endif; ?>
|
||
<form method="POST" action="index.php#tab-tokens" style="display:inline" onsubmit="return confirm('Sub-Token DAUERHAFT LÖSCHEN?');">
|
||
<?= $csrfField ?>
|
||
<input type="hidden" name="action" value="delete_core_token">
|
||
<input type="hidden" name="token_id" value="<?= $sTok['token_id'] ?>">
|
||
<button type="submit" class="btn btn-sm btn-danger">🗑️ Löschen</button>
|
||
</form>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
<?php endforeach; ?>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="sub-tokens-create" class="subtab-content">
|
||
<div class="card">
|
||
<div class="card-header"><h2 class="card-title">➕ Neuen Master-Token Erstellen</h2></div>
|
||
<form method="POST" action="index.php#tab-tokens">
|
||
<?= $csrfField ?>
|
||
<input type="hidden" name="action" value="create_master_token">
|
||
<div class="form-grid">
|
||
<div class="form-group">
|
||
<label class="form-label">Bezeichnung / Name</label>
|
||
<input type="text" name="name" class="form-input" required placeholder="z. B. PolyTrader Prod Server Master Key">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Identitätstyp (Owner Type)</label>
|
||
<select name="owner_type" class="form-input" required>
|
||
<option value="custom" selected>Custom / Allgemein</option>
|
||
<option value="project">Projekt-Gebunden</option>
|
||
<option value="license">Lizenz-Gebunden</option>
|
||
<option value="host">Host / Infrastruktur Server</option>
|
||
<option value="dev_agent">Entwickler KI-Agent</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Freie Identität / Host / HWID (Optional)</label>
|
||
<input type="text" name="owner_identity" class="form-input" placeholder="z. B. srv-db-01 oder HWID-88A9...">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Projekt (Optional)</label>
|
||
<select name="project_slug" class="form-input">
|
||
<option value="">-- Keins (Universal) --</option>
|
||
<?php foreach ($projects as $p): ?>
|
||
<option value="<?= htmlspecialchars($p['slug']) ?>"><?= htmlspecialchars($p['name']) ?></option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Umgebung (Environment Limit)</label>
|
||
<select name="environment" class="form-input" required>
|
||
<option value="all" selected>🌐 Alle Umgebungen (All)</option>
|
||
<option value="production">🔴 Nur Produktiv (Production)</option>
|
||
<option value="development">🔵 Nur Entwicklung (Development)</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group" style="margin-top:1rem;">
|
||
<label class="form-label">Erlaubte Rechte (Scopes)</label>
|
||
<p style="font-size:0.75rem; color:var(--text-muted); margin:0.25rem 0 0.6rem 0;">
|
||
Stärkere Rechte schließen die schwächeren ein: <code>bugtracker:manage</code>
|
||
umfasst Lesen und Melden, <code>updateservice:publish</code> auch Lesen.
|
||
Für einen Coding-Agenten genügt daher <em>Bugtracker verwalten</em>.
|
||
</p>
|
||
<div style="display:grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap:0.5rem;">
|
||
<?php
|
||
$scopeChoices = [
|
||
'*' => ['🌟 Alle Rechte', 'Uneingeschränkter Zugriff'],
|
||
'bugtracker:manage' => ['⚙️ Bugtracker verwalten', 'Übernehmen, kommentieren, schließen — schließt Lesen und Melden ein'],
|
||
'bugtracker:report' => ['🐛 Bugtracker melden', 'Nur Einträge anlegen — schließt Lesen ein'],
|
||
'bugtracker:read' => ['👁️ Bugtracker lesen', 'Nur Abfragen'],
|
||
'watchdog:ping' => ['🛡️ Watchdog Heartbeat', 'Statusmeldungen senden'],
|
||
'watchdog:read' => ['📊 Watchdog lesen', 'Monitore und Ereignisse abfragen'],
|
||
'updateservice:read' => ['📦 Updates prüfen', 'Auf neue Releases prüfen'],
|
||
'updateservice:publish' => ['🚀 Releases veröffentlichen', 'Neue Releases einstellen — schließt Prüfen ein'],
|
||
];
|
||
foreach ($scopeChoices as $value => $labels): ?>
|
||
<label style="display:flex; align-items:flex-start; gap:0.5rem; font-size:0.85rem; cursor:pointer; background:rgba(255,255,255,0.03); border:1px solid var(--border-glass); border-radius:10px; padding:0.6rem 0.75rem;">
|
||
<input type="checkbox" name="scopes[]" value="<?= e($value) ?>" <?= $value === '*' ? 'checked' : '' ?> style="margin-top:3px;">
|
||
<span>
|
||
<strong><?= e($labels[0]) ?></strong>
|
||
<span style="display:block; font-size:0.72rem; color:var(--text-muted); margin-top:2px;"><?= e($labels[1]) ?></span>
|
||
</span>
|
||
</label>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group" style="margin-top:1rem;">
|
||
<label class="form-label">Gültig bis (optional)</label>
|
||
<input type="date" name="expires_at" class="form-input">
|
||
<small style="font-size:0.72rem; color:var(--text-muted);">
|
||
Leer lassen für unbegrenzte Gültigkeit. Abgelaufene Tokens werden ab sofort
|
||
tatsächlich abgewiesen — die Spalte existierte zuvor, wurde aber nie geprüft.
|
||
</small>
|
||
</div>
|
||
|
||
<button type="submit" class="btn" style="margin-top:1.25rem;">Master-Token Erstellen</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<!-- ================= MODULE 5: SYSTEM & DB ================= -->
|
||
<div id="tab-system" class="tab-content">
|
||
|
||
<div id="sub-system-status" class="subtab-content active">
|
||
<div class="card">
|
||
<div class="card-header"><h2 class="card-title">⚙️ Server & Umgebung</h2></div>
|
||
<div class="form-grid">
|
||
<div class="form-group">
|
||
<label class="form-label">PHP-Version</label>
|
||
<input type="text" readonly class="form-input" value="<?= e(PHP_VERSION) ?>">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Server-Software</label>
|
||
<input type="text" readonly class="form-input" value="<?= e((string)($_SERVER['SERVER_SOFTWARE'] ?? 'unbekannt')) ?>">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Anwendungsversion</label>
|
||
<input type="text" readonly class="form-input" value="<?= e((string)Config::get('app.version', 'unbekannt')) ?>">
|
||
</div>
|
||
<div class="form-group">
|
||
<!--
|
||
Frueher standen Datenbankname und Hostname hier fest im
|
||
Quelltext. Jetzt kommt der Wert aus der Konfiguration,
|
||
und der Hostname wird nicht mehr ausgegeben.
|
||
-->
|
||
<label class="form-label">Datenbank</label>
|
||
<input type="text" readonly class="form-input" value="<?= e((string)Config::get('db.dbname', 'unbekannt')) ?>">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Zeitzone (Anzeige)</label>
|
||
<input type="text" readonly class="form-input" value="<?= e((string)Config::get('app.timezone', 'UTC')) ?> — Speicherung in UTC">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Debug-Modus</label>
|
||
<input type="text" readonly class="form-input" value="<?= Config::isDebug() ? 'AKTIV — auf Produktivsystemen abschalten' : 'aus' ?>">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-header"><h2 class="card-title">🩺 Systemzustand</h2></div>
|
||
<table>
|
||
<thead>
|
||
<tr><th>Prüfung</th><th>Zustand</th><th>Hinweis</th></tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr>
|
||
<td>Watchdog-Evaluator</td>
|
||
<td>
|
||
<span class="badge badge-<?= $evaluatorStale ? 'down' : 'up' ?>">
|
||
<?= $evaluatorStale ? 'INAKTIV' : 'AKTIV' ?>
|
||
</span>
|
||
</td>
|
||
<td style="font-size:0.8rem; color:var(--text-muted);">
|
||
<?php if ($evaluatorLastRun !== null): ?>
|
||
Letzter Lauf: <?= e(dc_dt((string)$evaluatorLastRun)) ?> (<?= e(dc_ago((string)$evaluatorLastRun)) ?>)
|
||
<?php else: ?>
|
||
Noch nie gelaufen — ohne ihn bleiben ausgefallene Monitore „up“.
|
||
<?php endif; ?>
|
||
</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Log-Verzeichnis beschreibbar</td>
|
||
<?php $logWritable = is_dir(DC_VAR . '/log') ? is_writable(DC_VAR . '/log') : is_writable(DC_ROOT); ?>
|
||
<td><span class="badge badge-<?= $logWritable ? 'up' : 'warning' ?>"><?= $logWritable ? 'OK' : 'PRÜFEN' ?></span></td>
|
||
<td style="font-size:0.8rem; color:var(--text-muted);">var/log/ — Fehlerprotokolle</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Konfiguration ausserhalb Git</td>
|
||
<td><span class="badge badge-up">OK</span></td>
|
||
<td style="font-size:0.8rem; color:var(--text-muted);">config/config.php ist per .gitignore ausgeschlossen</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="sub-system-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();
|
||
?>
|
||
<tr<?= $uDisabled ? ' style="opacity:0.55;"' : '' ?>>
|
||
<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><?= $uDisabled ? '<span class="badge badge-down">DEAKTIVIERT</span>' : 'aktiv' ?></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;">
|
||
<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' }
|
||
],
|
||
'bugtracker': [
|
||
{ id: 'sub-bugtracker-items', label: '🐛 Bugs & Features', active: true },
|
||
{ id: 'sub-bugtracker-errors', label: '⚡ Fehler-Stream' },
|
||
{ id: 'sub-bugtracker-ignore', label: '🔇 Ignore-Regeln' },
|
||
{ id: 'sub-bugtracker-new', label: '➕ Item Anlegen' }
|
||
],
|
||
'tokens': [
|
||
{ id: 'sub-tokens-masters', label: '👑 Master-Tokens', active: true },
|
||
{ id: 'sub-tokens-subs', label: '🔑 Sub-Tokens' },
|
||
{ id: 'sub-tokens-create', label: '➕ Master-Token Erstellen' }
|
||
],
|
||
'system': [
|
||
{ id: 'sub-system-status', label: '⚙️ System-Status', active: true },
|
||
{ id: 'sub-system-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);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Merkt sich beim Absenden, welche Unteransicht offen war.
|
||
*
|
||
* Der Server haengt diesen Wert nach dem Speichern wieder an die URL.
|
||
* Ohne das landete man stets auf dem Dashboard des Moduls - man legte
|
||
* einen Monitor an und wurde aus dem Formular geworfen.
|
||
*
|
||
* Als zentraler Zuhoerer umgesetzt, damit auch kuenftige Formulare das
|
||
* Verhalten automatisch erben.
|
||
*/
|
||
document.addEventListener('submit', (event) => {
|
||
const form = event.target;
|
||
if (!(form instanceof HTMLFormElement)) {
|
||
return;
|
||
}
|
||
if ((form.method || '').toLowerCase() !== 'post') {
|
||
return;
|
||
}
|
||
if (form.querySelector('input[name="_return"]')) {
|
||
return;
|
||
}
|
||
|
||
const activeSub = document.querySelector('.subtab-content.active');
|
||
const activeTab = document.querySelector('.main-content > .tab-content.active');
|
||
const target = activeSub ? activeSub.id : (activeTab ? activeTab.id : '');
|
||
|
||
if (!target) {
|
||
return;
|
||
}
|
||
|
||
const field = document.createElement('input');
|
||
field.type = 'hidden';
|
||
field.name = '_return';
|
||
field.value = target;
|
||
form.appendChild(field);
|
||
}, true);
|
||
|
||
// 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.
|
||
|
||
// 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() {
|
||
const hash = location.hash.replace('#', '');
|
||
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) {
|
||
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>
|
||
</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>
|