1302 lines
76 KiB
PHP
1302 lines
76 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
require_once __DIR__ . '/../src/Core/Db.php';
|
||
require_once __DIR__ . '/../src/Core/Auth.php';
|
||
require_once __DIR__ . '/../src/Modules/License/KeyGen.php';
|
||
require_once __DIR__ . '/../src/Modules/License/LicenseService.php';
|
||
require_once __DIR__ . '/../src/Modules/Watchdog/MonitorRepo.php';
|
||
require_once __DIR__ . '/../src/Modules/Watchdog/EventLog.php';
|
||
require_once __DIR__ . '/../src/Modules/Watchdog/TokenManager.php';
|
||
require_once __DIR__ . '/../src/Modules/UpdateService/UpdateManager.php';
|
||
|
||
use Deploymentcenter\Core\Db;
|
||
use Deploymentcenter\Core\Auth;
|
||
use Deploymentcenter\Modules\License\KeyGen;
|
||
use Deploymentcenter\Modules\Watchdog\MonitorRepo;
|
||
use Deploymentcenter\Modules\Watchdog\EventLog;
|
||
use Deploymentcenter\Modules\Watchdog\TokenManager;
|
||
use Deploymentcenter\Modules\UpdateService\UpdateManager;
|
||
|
||
Auth::requireLogin();
|
||
|
||
$config = require __DIR__ . '/../config/config.php';
|
||
$pdo = Db::init($config);
|
||
|
||
$msg = null;
|
||
$msgType = 'success';
|
||
|
||
// Handle POST actions
|
||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||
$action = $_POST['action'] ?? '';
|
||
|
||
// Create Product
|
||
if ($action === 'create_product') {
|
||
$slug = trim($_POST['slug'] ?? '');
|
||
$name = trim($_POST['name'] ?? '');
|
||
$ttl = (int)($_POST['ttl'] ?? 168);
|
||
$notes = trim($_POST['notes'] ?? '');
|
||
|
||
if ($slug && $name) {
|
||
try {
|
||
$stmt = $pdo->prepare('INSERT INTO license_products (slug, name, default_cache_ttl_hours, notes) VALUES (:s, :n, :t, :notes)');
|
||
$stmt->execute([':s' => $slug, ':n' => $name, ':t' => $ttl, ':notes' => $notes]);
|
||
$msg = "Produkt '{$name}' (Slug: {$slug}) wurde erfolgreich angelegt.";
|
||
} catch (Throwable $e) {
|
||
$msg = "Fehler beim Erstellen des Produkts: " . $e->getMessage();
|
||
$msgType = 'danger';
|
||
}
|
||
}
|
||
}
|
||
|
||
// Create License Key
|
||
if ($action === 'create_license') {
|
||
$productId = (int)($_POST['product_id'] ?? 0);
|
||
$customerName = trim($_POST['customer_name'] ?? '');
|
||
$customerEmail = trim($_POST['customer_email'] ?? '');
|
||
$maxActivations = (int)($_POST['max_activations'] ?? 2);
|
||
$expiresAt = !empty($_POST['expires_at']) ? $_POST['expires_at'] . ' 23:59:59' : null;
|
||
$notes = trim($_POST['notes'] ?? '');
|
||
|
||
if ($productId > 0) {
|
||
$licenseKey = KeyGen::generateKey();
|
||
try {
|
||
$stmt = $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, :exp, :notes)
|
||
');
|
||
$stmt->execute([
|
||
':pid' => $productId,
|
||
':key' => $licenseKey,
|
||
':cname' => $customerName,
|
||
':cemail' => $customerEmail,
|
||
':max' => $maxActivations,
|
||
':exp' => $expiresAt,
|
||
':notes' => $notes
|
||
]);
|
||
|
||
// Audit log
|
||
$pdo->prepare('INSERT INTO license_audit_log (actor, action, details) VALUES ("admin", "license.create", :d)')
|
||
->execute([':d' => json_encode(['key' => $licenseKey, 'customer' => $customerName])]);
|
||
|
||
$msg = "Lizenzschlüssel erfolgreich generiert: <strong style='font-family:monospace; font-size:1.1rem;'>{$licenseKey}</strong>";
|
||
} catch (Throwable $e) {
|
||
$msg = "Fehler bei Generierung: " . $e->getMessage();
|
||
$msgType = 'danger';
|
||
}
|
||
}
|
||
}
|
||
|
||
// Revoke License
|
||
if ($action === 'revoke_license') {
|
||
$licId = (int)($_POST['license_id'] ?? 0);
|
||
if ($licId > 0) {
|
||
$stmt = $pdo->prepare('UPDATE license_licenses SET status = "revoked" WHERE id = :id');
|
||
$stmt->execute([':id' => $licId]);
|
||
|
||
$pdo->prepare('INSERT INTO license_audit_log (actor, action, details) VALUES ("admin", "license.revoke", :d)')
|
||
->execute([':d' => json_encode(['license_id' => $licId])]);
|
||
|
||
$msg = "Lizenz wurde widerrufen.";
|
||
}
|
||
}
|
||
|
||
// Block / Unblock Hardware Activation
|
||
if ($action === 'toggle_block_activation') {
|
||
$actId = (int)($_POST['activation_id'] ?? 0);
|
||
$block = (int)($_POST['block_state'] ?? 0);
|
||
if ($actId > 0) {
|
||
$stmt = $pdo->prepare('UPDATE license_activations SET is_blocked = :b WHERE id = :id');
|
||
$stmt->execute([':b' => $block, ':id' => $actId]);
|
||
$msg = $block ? "Hardware-Aktivierung wurde gesperrt." : "Hardware-Aktivierung wurde entsperrt.";
|
||
}
|
||
}
|
||
|
||
// Extend License Expiration
|
||
if ($action === 'extend_license') {
|
||
$licId = (int)($_POST['license_id'] ?? 0);
|
||
$newExp = !empty($_POST['new_expires_at']) ? $_POST['new_expires_at'] . ' 23:59:59' : null;
|
||
if ($licId > 0) {
|
||
$stmt = $pdo->prepare('UPDATE license_licenses SET expires_at = :exp, status = "active" WHERE id = :id');
|
||
$stmt->execute([':exp' => $newExp, ':id' => $licId]);
|
||
$msg = "Ablaufdatum der Lizenz wurde aktualisiert.";
|
||
}
|
||
}
|
||
|
||
// Create Agent Token (Watchdog)
|
||
if ($action === 'create_agent_token') {
|
||
$source = trim($_POST['token_source'] ?? '');
|
||
$name = trim($_POST['token_name'] ?? '');
|
||
if ($name) {
|
||
$tokMgr = new TokenManager($pdo);
|
||
$res = $tokMgr->createToken($source, $name);
|
||
$msg = "Agent-Token für '{$name}' generiert: <strong style='font-family:monospace;'>{$res['raw_token']}</strong> (Bitte sicher aufbewahren!)";
|
||
}
|
||
}
|
||
|
||
// Revoke Agent Token
|
||
if ($action === 'revoke_agent_token') {
|
||
$tokId = trim($_POST['token_id'] ?? '');
|
||
if ($tokId) {
|
||
$stmt = $pdo->prepare('UPDATE watchdog_agent_tokens SET revoked = 1 WHERE token_id = :id');
|
||
$stmt->execute([':id' => $tokId]);
|
||
$msg = "Agent-Token wurde widerrufen.";
|
||
}
|
||
}
|
||
|
||
// Add Update Release
|
||
if ($action === 'add_release') {
|
||
$productSlug = trim($_POST['product_slug'] ?? '');
|
||
$version = trim($_POST['version'] ?? '');
|
||
$url = trim($_POST['download_url'] ?? '');
|
||
$hash = trim($_POST['sha256_hash'] ?? '');
|
||
$notes = trim($_POST['release_notes'] ?? '');
|
||
$critical = isset($_POST['is_critical']);
|
||
|
||
if ($productSlug && $version && $url) {
|
||
$updMgr = new UpdateManager($pdo);
|
||
if ($updMgr->addRelease($productSlug, $version, $notes, $url, $hash, $critical)) {
|
||
$msg = "Release v{$version} für '{$productSlug}' gespeichert.";
|
||
} else {
|
||
$msg = "Fehler beim Speichern des Releases.";
|
||
$msgType = 'danger';
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Fetch all Data
|
||
$products = $pdo->query('SELECT * FROM license_products 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 license_products p ON l.product_id = p.id
|
||
ORDER BY l.created_at DESC
|
||
')->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 license_products p ON l.product_id = p.id
|
||
ORDER BY a.last_seen DESC
|
||
')->fetchAll();
|
||
|
||
$auditLogs = $pdo->query('SELECT * FROM license_audit_log ORDER BY created_at DESC LIMIT 100')->fetchAll();
|
||
|
||
$monitorRepo = new MonitorRepo($pdo);
|
||
$monitors = $monitorRepo->getAllMonitors();
|
||
|
||
$monitorsUp = 0; $monitorsWarning = 0; $monitorsDown = 0;
|
||
foreach ($monitors as $m) {
|
||
if ($m['state'] === 'up') $monitorsUp++;
|
||
elseif ($m['state'] === 'warning') $monitorsWarning++;
|
||
else $monitorsDown++;
|
||
}
|
||
|
||
$eventLog = new EventLog($pdo);
|
||
$recentEvents = $eventLog->getRecentEvents(100);
|
||
|
||
$agentTokens = $pdo->query('SELECT * FROM watchdog_agent_tokens ORDER BY created_at_utc DESC')->fetchAll();
|
||
|
||
$updateMgr = new UpdateManager($pdo);
|
||
$releases = $updateMgr->getReleases();
|
||
|
||
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
|
||
$host = $_SERVER['HTTP_HOST'] ?? 'dc.mhdf.de';
|
||
$baseUrl = $protocol . '://' . $host;
|
||
?>
|
||
<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Deploymentcenter - Central Platform</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=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||
<style>
|
||
:root {
|
||
--bg-main: #0e1013;
|
||
--bg-sidebar: #12151b;
|
||
--bg-card: rgba(22, 25, 31, 0.85);
|
||
--bg-card-elev: #1c2027;
|
||
--border-card: #262b33;
|
||
--border-soft: rgba(255, 255, 255, 0.08);
|
||
--primary: #f2622e; /* Brand Orange */
|
||
--primary-hover: #d95323;
|
||
--accent-weak: rgba(242, 98, 46, 0.14);
|
||
--success: #35c46a;
|
||
--warning: #f5a623;
|
||
--danger: #ef4e4e;
|
||
--text-main: #e6e8eb;
|
||
--text-muted: #8a9099;
|
||
--sidebar-width: 260px;
|
||
--sidebar-collapsed-width: 72px;
|
||
}
|
||
|
||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||
body { font-family: 'Inter', system-ui, sans-serif; background: var(--bg-main); color: var(--text-main); min-height: 100vh; display: flex; overflow-x: hidden; }
|
||
|
||
/* Sidebar Collapsible */
|
||
.sidebar { width: var(--sidebar-width); background: var(--bg-sidebar); border-right: 1px solid var(--border-card); 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; }
|
||
.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: 38px; height: 38px; background: linear-gradient(135deg, var(--primary), #a855f7); border-radius: 10px; display: flex; align-items: center; justify-content: center; box-shadow: 0 4px 15px rgba(242,98,46,0.3); }
|
||
.brand-logo svg { width: 22px; height: 22px; stroke: #fff; fill: none; stroke-width: 2; }
|
||
.brand-title { font-size: 1.1rem; font-weight: 700; 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-card); color: var(--text-muted); width: 28px; height: 28px; border-radius: 50%; display: flex; align-items: center; justify-content: center; cursor: pointer; position: absolute; right: -14px; top: 22px; transition: transform 0.3s; z-index: 101; }
|
||
.sidebar.collapsed .sidebar-toggle-btn { transform: rotate(180deg); }
|
||
|
||
.nav-menu { display: flex; flex-direction: column; gap: 0.35rem; list-style: none; }
|
||
.nav-item { position: relative; }
|
||
.nav-link { display: flex; align-items: center; gap: 0.85rem; padding: 0.75rem 0.85rem; color: var(--text-muted); text-decoration: none; border-radius: 10px; font-size: 0.9rem; font-weight: 500; transition: all 0.2s; cursor: pointer; white-space: nowrap; }
|
||
.nav-link:hover, .nav-link.active { background: var(--accent-weak); color: #fff; }
|
||
.nav-link.active { color: var(--primary); border-left: 3px solid var(--primary); }
|
||
.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 Content */
|
||
.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; }
|
||
.main-wrapper.expanded { margin-left: var(--sidebar-collapsed-width); }
|
||
|
||
.main-content { padding: 1.75rem 2rem; max-width: 1500px; }
|
||
|
||
/* Horizontal Submenu Top Bar */
|
||
.module-subnav { display: flex; align-items: center; gap: 0.5rem; background: var(--bg-card); border: 1px solid var(--border-card); padding: 0.5rem 0.75rem; border-radius: 14px; margin-bottom: 1.75rem; overflow-x: auto; scrollbar-width: none; }
|
||
.subnav-link { background: transparent; border: none; color: var(--text-muted); padding: 0.6rem 1rem; font-size: 0.85rem; font-weight: 600; border-radius: 8px; cursor: pointer; transition: all 0.2s; white-space: nowrap; display: flex; align-items: center; gap: 0.5rem; }
|
||
.subnav-link:hover { color: #fff; background: rgba(255,255,255,0.04); }
|
||
.subnav-link.active { background: var(--primary); color: #fff; box-shadow: 0 4px 12px rgba(242,98,46,0.3); }
|
||
|
||
/* Stats 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-card); border-radius: 14px; padding: 1.25rem; }
|
||
.stat-header { display: flex; align-items: center; justify-content: space-between; color: var(--text-muted); font-size: 0.8rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; }
|
||
.stat-value { font-size: 1.9rem; font-weight: 700; margin-top: 0.5rem; color: #fff; display: flex; align-items: center; gap: 0.5rem; }
|
||
|
||
/* Segment Bars (Resource Usage) */
|
||
.segment-bar-container { margin-top: 0.5rem; }
|
||
.segment-bar-label { display: flex; justify-content: space-between; font-size: 0.75rem; color: var(--text-muted); margin-bottom: 0.25rem; }
|
||
.segment-bar { display: flex; gap: 2px; height: 10px; background: rgba(0,0,0,0.3); padding: 2px; border-radius: 4px; }
|
||
.segment { flex: 1; border-radius: 1px; background: rgba(255,255,255,0.06); }
|
||
.segment.filled-ok { background: var(--success); }
|
||
.segment.filled-warn { background: var(--warning); }
|
||
.segment.filled-crit { background: var(--danger); }
|
||
|
||
/* Cards & Tables */
|
||
.card { background: var(--bg-card); border: 1px solid var(--border-card); border-radius: 14px; padding: 1.5rem; margin-bottom: 1.75rem; }
|
||
.card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.25rem; }
|
||
.card-title { font-size: 1.1rem; font-weight: 600; color: #fff; display: flex; align-items: center; gap: 0.5rem; }
|
||
|
||
table { width: 100%; border-collapse: collapse; text-align: left; font-size: 0.875rem; }
|
||
th { padding: 0.75rem 1rem; color: var(--text-muted); font-weight: 600; border-bottom: 1px solid var(--border-card); font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.03em; }
|
||
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.25rem 0.65rem; border-radius: 20px; font-size: 0.75rem; font-weight: 600; display: inline-flex; align-items: center; gap: 0.35rem; }
|
||
.badge-up { background: rgba(53, 196, 106, 0.15); color: var(--success); border: 1px solid rgba(53, 196, 106, 0.3); }
|
||
.badge-warning { background: rgba(245, 166, 35, 0.15); color: var(--warning); border: 1px solid rgba(245, 166, 35, 0.3); }
|
||
.badge-down { background: rgba(239, 78, 78, 0.15); color: var(--danger); border: 1px solid rgba(239, 78, 78, 0.3); }
|
||
.badge-stopped { background: rgba(138, 144, 153, 0.15); color: var(--text-muted); border: 1px solid rgba(138, 144, 153, 0.3); }
|
||
|
||
/* Buttons & Controls */
|
||
.btn { background: var(--primary); color: #fff; border: none; border-radius: 8px; padding: 0.6rem 1.1rem; font-size: 0.85rem; font-weight: 600; cursor: pointer; transition: all 0.2s; text-decoration: none; display: inline-flex; align-items: center; gap: 0.5rem; }
|
||
.btn:hover { background: var(--primary-hover); transform: translateY(-1px); }
|
||
.btn-secondary { background: var(--bg-card-elev); border: 1px solid var(--border-card); color: var(--text-main); }
|
||
.btn-secondary:hover { background: #262b33; }
|
||
.btn-sm { padding: 0.35rem 0.7rem; font-size: 0.75rem; }
|
||
.btn-danger { background: var(--danger); }
|
||
.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.8rem; color: var(--text-muted); font-weight: 500; }
|
||
.form-input { background: #0e1013; border: 1px solid var(--border-card); border-radius: 8px; padding: 0.65rem 0.85rem; color: #fff; font-size: 0.875rem; outline: none; transition: border-color 0.2s; }
|
||
.form-input:focus { border-color: var(--primary); }
|
||
|
||
.prompt-box { background: #0b0d10; border: 1px solid var(--border-card); border-radius: 10px; padding: 1.1rem; font-family: 'Consolas', 'Courier New', monospace; font-size: 0.85rem; 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: 10px; margin-bottom: 1.5rem; font-size: 0.9rem; }
|
||
.alert-success { background: rgba(53, 196, 106, 0.15); border: 1px solid rgba(53, 196, 106, 0.3); color: #a7f3d0; }
|
||
.alert-danger { background: rgba(239, 78, 78, 0.15); border: 1px solid rgba(239, 78, 78, 0.3); color: #fca5a5; }
|
||
|
||
.tab-content, .subtab-content { display: none; }
|
||
.tab-content.active, .subtab-content.active { display: block; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<!-- 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>
|
||
|
||
<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('license', this)" title="LicenseLabrador">
|
||
<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">LicenseLabrador</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('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:600;">Abmelden</a>
|
||
</div>
|
||
</aside>
|
||
|
||
<!-- Main Content Area -->
|
||
<div class="main-wrapper" id="mainWrapper">
|
||
<main class="main-content">
|
||
|
||
<?php if ($msg): ?>
|
||
<div class="alert alert-<?= $msgType ?>"><?= $msg ?></div>
|
||
<?php endif; ?>
|
||
|
||
<!-- ================= MODULE 0: OVERVIEW ================= -->
|
||
<div id="tab-overview" class="tab-content active">
|
||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:1.5rem;">
|
||
<h1 style="font-size:1.6rem; font-weight:700;">Globales Plattform Dashboard</h1>
|
||
<span style="font-size:0.85rem; color:var(--text-muted);">Letzte Aktualisierung: <?= date('H:i:s') ?> Uhr</span>
|
||
</div>
|
||
|
||
<div class="stats-grid">
|
||
<div class="stat-card">
|
||
<div class="stat-header">Produkte</div>
|
||
<div class="stat-value"><?= count($products) ?></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">Monitore Status</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 Monitore Status</h2></div>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Source / Instanz</th>
|
||
<th>Typ</th>
|
||
<th>Gruppe</th>
|
||
<th>Status</th>
|
||
<th>Letzte Meldung</th>
|
||
<th>Zuletzt Gesehen</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<?php foreach ($monitors as $m): ?>
|
||
<tr>
|
||
<td><strong><?= htmlspecialchars($m['source']) ?></strong> (<?= htmlspecialchars($m['instance']) ?>)</td>
|
||
<td><code><?= htmlspecialchars($m['type']) ?></code></td>
|
||
<td><?= htmlspecialchars($m['group_key'] ?? 'Default') ?></td>
|
||
<td>
|
||
<span class="badge badge-<?= $m['state'] === 'up' ? 'up' : ($m['state'] === 'warning' ? 'warning' : 'down') ?>">
|
||
● <?= strtoupper($m['state']) ?>
|
||
</span>
|
||
</td>
|
||
<td><?= htmlspecialchars($m['last_message'] ?? '-') ?></td>
|
||
<td><?= htmlspecialchars($m['last_seen_utc'] ?? 'Nie') ?></td>
|
||
</tr>
|
||
<?php endforeach; ?>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ================= MODULE 1: LICENSELABRADOR ================= -->
|
||
<div id="tab-license" class="tab-content">
|
||
<!-- Module Sub-Navigation Topbar -->
|
||
<div class="module-subnav">
|
||
<button class="subnav-link active" onclick="switchSubTab('license', 'sub-license-dashboard', this)">📊 Dashboard</button>
|
||
<button class="subnav-link" onclick="switchSubTab('license', 'sub-license-products', this)">📦 Produkte</button>
|
||
<button class="subnav-link" onclick="switchSubTab('license', 'sub-license-licenses', this)">🔑 Lizenzen</button>
|
||
<button class="subnav-link" onclick="switchSubTab('license', 'sub-license-details', this)">🔍 Lizenz-Details</button>
|
||
<button class="subnav-link" onclick="switchSubTab('license', 'sub-license-offline', this)">💾 Offline-Lizenzen</button>
|
||
<button class="subnav-link" onclick="switchSubTab('license', 'sub-license-audit', this)">📜 Audit-Log</button>
|
||
<button class="subnav-link" onclick="switchSubTab('license', 'sub-license-integration', this)">💻 Integration & Prompts</button>
|
||
<button class="subnav-link" onclick="switchSubTab('license', 'sub-license-settings', this)">⚙️ Einstellungen</button>
|
||
</div>
|
||
|
||
<!-- Subtab: License Dashboard -->
|
||
<div id="sub-license-dashboard" class="subtab-content active">
|
||
<div class="stats-grid">
|
||
<div class="stat-card">
|
||
<div class="stat-header">Registrierte Produkte</div>
|
||
<div class="stat-value"><?= count($products) ?></div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-header">Gültige Lizenzen</div>
|
||
<div class="stat-value"><?= count(array_filter($licenses, fn($l) => $l['status'] === 'active')) ?></div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-header">Aktivierte Systeme</div>
|
||
<div class="stat-value"><?= count($activations) ?></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Subtab: Products -->
|
||
<div id="sub-license-products" class="subtab-content">
|
||
<div class="card">
|
||
<div class="card-header"><h2 class="card-title">Neues Produkt anlegen</h2></div>
|
||
<form method="POST">
|
||
<input type="hidden" name="action" value="create_product">
|
||
<div class="form-grid">
|
||
<div class="form-group">
|
||
<label class="form-label">Produkt-Slug (z. B. myapp)</label>
|
||
<input type="text" name="slug" class="form-input" required placeholder="myapp">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Produkt Name</label>
|
||
<input type="text" name="name" class="form-input" required placeholder="My Application Pro">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Offline Grace Cache TTL (Stunden)</label>
|
||
<input type="number" name="ttl" class="form-input" value="168">
|
||
</div>
|
||
</div>
|
||
<button type="submit" class="btn">Produkt Speichern</button>
|
||
</form>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-header"><h2 class="card-title">Produktübersicht</h2></div>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>ID</th>
|
||
<th>Slug</th>
|
||
<th>Produkt Name</th>
|
||
<th>Cache TTL</th>
|
||
<th>Erstellt am</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<?php foreach ($products as $p): ?>
|
||
<tr>
|
||
<td><?= $p['id'] ?></td>
|
||
<td><code><?= 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><?= $p['created_at'] ?></td>
|
||
</tr>
|
||
<?php endforeach; ?>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Subtab: Licenses -->
|
||
<div id="sub-license-licenses" class="subtab-content">
|
||
<div class="card">
|
||
<div class="card-header"><h2 class="card-title">Neuen Lizenzschlüssel generieren</h2></div>
|
||
<form method="POST">
|
||
<input type="hidden" name="action" value="create_license">
|
||
<div class="form-grid">
|
||
<div class="form-group">
|
||
<label class="form-label">Produkt</label>
|
||
<select name="product_id" class="form-input" required>
|
||
<?php foreach ($products as $p): ?>
|
||
<option value="<?= $p['id'] ?>"><?= htmlspecialchars($p['name']) ?> (<?= htmlspecialchars($p['slug']) ?>)</option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Kunden Name</label>
|
||
<input type="text" name="customer_name" class="form-input" placeholder="Musterfirma GmbH">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Kunden E-Mail</label>
|
||
<input type="email" name="customer_email" class="form-input" placeholder="kunde@example.com">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Max. Aktivierungen</label>
|
||
<input type="number" name="max_activations" class="form-input" value="2" min="1">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Ablaufdatum (leer = unbegrenzt)</label>
|
||
<input type="date" name="expires_at" class="form-input">
|
||
</div>
|
||
</div>
|
||
<button type="submit" class="btn">Lizenz Generieren</button>
|
||
</form>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-header"><h2 class="card-title">Lizenzschlüssel Verwaltung</h2></div>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Produkt</th>
|
||
<th>Lizenzschlüssel</th>
|
||
<th>Kunde</th>
|
||
<th>Aktivierungen</th>
|
||
<th>Status</th>
|
||
<th>Ablaufdatum</th>
|
||
<th>Aktionen</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<?php foreach ($licenses as $l): ?>
|
||
<tr>
|
||
<td><?= htmlspecialchars($l['product_name']) ?></td>
|
||
<td><code style="font-size:0.95rem; font-weight:bold; color:var(--primary);"><?= htmlspecialchars($l['license_key']) ?></code></td>
|
||
<td><?= htmlspecialchars($l['customer_name'] ?? '-') ?></td>
|
||
<td><?= $l['active_count'] ?> / <?= $l['max_activations'] ?></td>
|
||
<td><span class="badge badge-<?= $l['status'] === 'active' ? 'up' : 'down' ?>"><?= strtoupper($l['status']) ?></span></td>
|
||
<td><?= $l['expires_at'] ? htmlspecialchars($l['expires_at']) : 'Unbefristet' ?></td>
|
||
<td>
|
||
<?php if ($l['status'] === 'active'): ?>
|
||
<form method="POST" style="display:inline">
|
||
<input type="hidden" name="action" value="revoke_license">
|
||
<input type="hidden" name="license_id" value="<?= $l['id'] ?>">
|
||
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Lizenz wirklich widerrufen?')">Widerrufen</button>
|
||
</form>
|
||
<?php endif; ?>
|
||
</td>
|
||
</tr>
|
||
<?php endforeach; ?>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Subtab: License Details -->
|
||
<div id="sub-license-details" class="subtab-content">
|
||
<div class="card">
|
||
<div class="card-header"><h2 class="card-title">Hardware-Aktivierungen verwalten</h2></div>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Produkt</th>
|
||
<th>Lizenzschlüssel</th>
|
||
<th>Hardware-ID</th>
|
||
<th>Hostname</th>
|
||
<th>App-Version</th>
|
||
<th>Zuletzt gesehen</th>
|
||
<th>Sperr-Status</th>
|
||
<th>Aktion</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<?php foreach ($activations as $a): ?>
|
||
<tr>
|
||
<td><?= htmlspecialchars($a['product_name']) ?></td>
|
||
<td><code><?= htmlspecialchars($a['license_key']) ?></code></td>
|
||
<td><code><?= htmlspecialchars($a['hardware_id']) ?></code></td>
|
||
<td><?= htmlspecialchars($a['hostname'] ?? '-') ?></td>
|
||
<td><?= htmlspecialchars($a['app_version'] ?? '-') ?></td>
|
||
<td><?= htmlspecialchars($a['last_seen']) ?></td>
|
||
<td>
|
||
<?php if ($a['is_blocked']): ?>
|
||
<span class="badge badge-down">GESPERRT</span>
|
||
<?php else: ?>
|
||
<span class="badge badge-up">AKTIV</span>
|
||
<?php endif; ?>
|
||
</td>
|
||
<td>
|
||
<form method="POST" style="display:inline">
|
||
<input type="hidden" name="action" value="toggle_block_activation">
|
||
<input type="hidden" name="activation_id" value="<?= $a['id'] ?>">
|
||
<input type="hidden" name="block_state" value="<?= $a['is_blocked'] ? '0' : '1' ?>">
|
||
<button type="submit" class="btn btn-sm <?= $a['is_blocked'] ? 'btn' : 'btn-danger' ?>">
|
||
<?= $a['is_blocked'] ? 'Entsperren' : 'Sperren' ?>
|
||
</button>
|
||
</form>
|
||
</td>
|
||
</tr>
|
||
<?php endforeach; ?>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Subtab: Offline Licenses -->
|
||
<div id="sub-license-offline" class="subtab-content">
|
||
<div class="card">
|
||
<div class="card-header"><h2 class="card-title">💾 Signierte Offline-Lizenzdatei erzeugen</h2></div>
|
||
<p style="color:var(--text-muted); font-size:0.9rem; margin-bottom:1rem;">
|
||
Generiert eine verschlüsselte/signierte <code>.lic</code> Offline-Lizenzdatei für Air-Gapped Kundensysteme ohne Internetverbindung.
|
||
</p>
|
||
<div class="form-grid">
|
||
<div class="form-group">
|
||
<label class="form-label">Lizenz auswählen</label>
|
||
<select id="offlineLicSelect" class="form-input">
|
||
<?php foreach ($licenses as $l): ?>
|
||
<option value="<?= htmlspecialchars($l['license_key']) ?>"><?= htmlspecialchars($l['license_key']) ?> (<?= htmlspecialchars($l['customer_name'] ?? 'Unbekannt') ?>)</option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Ziel-Hardware-ID</label>
|
||
<input type="text" id="offlineHwId" class="form-input" placeholder="HWID-88A9-99B1-CC02">
|
||
</div>
|
||
</div>
|
||
<button class="btn" onclick="generateOfflinePayload()">Offline Payload Generieren</button>
|
||
|
||
<div style="margin-top:1.5rem;">
|
||
<label class="form-label">Generierte Offline .lic Payload (JSON)</label>
|
||
<div class="prompt-box" id="offlineResultBox">Wählen Sie oben eine Lizenz aus und klicken Sie auf 'Offline Payload Generieren'.</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Subtab: Audit Log -->
|
||
<div id="sub-license-audit" class="subtab-content">
|
||
<div class="card">
|
||
<div class="card-header"><h2 class="card-title">📜 System Audit-Protokoll</h2></div>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>ID</th>
|
||
<th>Zeitpunkt</th>
|
||
<th>Akteur</th>
|
||
<th>Aktion</th>
|
||
<th>Details</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<?php foreach ($auditLogs as $log): ?>
|
||
<tr>
|
||
<td><?= $log['id'] ?></td>
|
||
<td><?= $log['created_at'] ?></td>
|
||
<td><code><?= htmlspecialchars($log['actor']) ?></code></td>
|
||
<td><strong><?= htmlspecialchars($log['action']) ?></strong></td>
|
||
<td><code style="font-size:0.8rem;"><?= htmlspecialchars($log['details'] ?? '-') ?></code></td>
|
||
</tr>
|
||
<?php endforeach; ?>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Subtab: Integration & Prompts -->
|
||
<div id="sub-license-integration" class="subtab-content">
|
||
<div class="card">
|
||
<div class="card-header"><h2 class="card-title">🤖 KI-Prompts zur Modul-Integration in Ihre Softwareprojekte</h2></div>
|
||
<p style="color:var(--text-muted); font-size:0.875rem; margin-bottom:1rem;">
|
||
Kopieren Sie diese vorgefertigten Prompts und übergeben Sie sie Ihrem KI-Coding-Assistenten (GitHub Copilot, Antigravity, ChatGPT), um die Lizenzprüfung in Ihre Anwendungen einzubauen:
|
||
</p>
|
||
|
||
<div style="margin-bottom:1.5rem;">
|
||
<label class="form-label">C# / .NET Project Prompt</label>
|
||
<div class="prompt-box" id="prompt-cs">Ich möchte mein C# / .NET Projekt mit dem LicenseLabrador Modul des Deploymentcenters verbinden.
|
||
|
||
Server-Konfiguration:
|
||
- Base API URL: <?= $baseUrl ?>/api/license/v1
|
||
- Endpunkte: /validate (POST), /deactivate (POST)
|
||
- Produkt-Slug: myapp
|
||
|
||
Anforderungen:
|
||
1. Erstelle eine C# Klasse `LicenseValidator.cs`.
|
||
2. Sende beim Anwendungsstart einen POST-Request an `<?= $baseUrl ?>/api/license/v1/validate` mit JSON:
|
||
{ "product": "myapp", "license_key": "XXXXX-XXXXX-XXXXX-XXXXX-XXXXX", "hardware_id": getHardwareId(), "nonce": Guid.NewGuid().ToString() }
|
||
3. Werte den Status aus ("valid", "revoked", "expired", "activation_limit").
|
||
4. Bei Server-Unerreichbarkeit: Erlaube Offline-Nutzung innerhalb des Cache-TTL Zeitraums.</div>
|
||
<button class="btn btn-secondary btn-sm" onclick="copyText('prompt-cs')">📋 Prompt Kopieren (C#)</button>
|
||
</div>
|
||
|
||
<div style="margin-bottom:1.5rem;">
|
||
<label class="form-label">Python Project Prompt</label>
|
||
<div class="prompt-box" id="prompt-py">Ich möchte meine Python-Anwendung mit dem LicenseLabrador Modul des Deploymentcenters verbinden.
|
||
|
||
Server URL: <?= $baseUrl ?>/api/license/v1/validate
|
||
|
||
Anforderungen:
|
||
- Erstelle ein Python-Skript `license_checker.py`.
|
||
- Generiere die Machine-GUID / Hardware-ID.
|
||
- Sende einen HTTP POST im JSON-Format mit `product`, `license_key`, `hardware_id`, `nonce`.
|
||
- Binde eine lokale Fallback-Logik bei Offline-Zeiten ein.</div>
|
||
<button class="btn btn-secondary btn-sm" onclick="copyText('prompt-py')">📋 Prompt Kopieren (Python)</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Subtab: Settings -->
|
||
<div id="sub-license-settings" class="subtab-content">
|
||
<div class="card">
|
||
<div class="card-header"><h2 class="card-title">⚙️ Moduleinstellungen & API Konfiguration</h2></div>
|
||
<div class="form-grid">
|
||
<div class="form-group">
|
||
<label class="form-label">REST API Validation Endpoint</label>
|
||
<input type="text" readonly class="form-input" value="<?= $baseUrl ?>/api/license/v1/validate">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">REST API Deactivation Endpoint</label>
|
||
<input type="text" readonly class="form-input" value="<?= $baseUrl ?>/api/license/v1/deactivate">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ================= MODULE 2: WATCHDOG ================= -->
|
||
<div id="tab-watchdog" class="tab-content">
|
||
<!-- Module Sub-Navigation Topbar -->
|
||
<div class="module-subnav">
|
||
<button class="subnav-link active" onclick="switchSubTab('watchdog', 'sub-watchdog-dashboard', this)">📊 Dashboard</button>
|
||
<button class="subnav-link" onclick="switchSubTab('watchdog', 'sub-watchdog-uptime', this)">⏱️ Uptime Monitor</button>
|
||
<button class="subnav-link" onclick="switchSubTab('watchdog', 'sub-watchdog-services', this)">⚙️ Services</button>
|
||
<button class="subnav-link" onclick="switchSubTab('watchdog', 'sub-watchdog-applications', this)">💻 Applications</button>
|
||
<button class="subnav-link" onclick="switchSubTab('watchdog', 'sub-watchdog-machines', this)">🖥️ Machines</button>
|
||
<button class="subnav-link" onclick="switchSubTab('watchdog', 'sub-watchdog-hypervisors', this)">☁️ Hypervisors</button>
|
||
<button class="subnav-link" onclick="switchSubTab('watchdog', 'sub-watchdog-eventlog', this)">📜 Event-Log</button>
|
||
<button class="subnav-link" onclick="switchSubTab('watchdog', 'sub-watchdog-tokens', this)">🔑 Agent-Tokens</button>
|
||
<button class="subnav-link" onclick="switchSubTab('watchdog', 'sub-watchdog-integration', this)">💻 Integration & Prompts</button>
|
||
</div>
|
||
|
||
<!-- 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>
|
||
|
||
<!-- Resource Usage Segment Bars -->
|
||
<div class="card">
|
||
<div class="card-header"><h2 class="card-title">🖥️ Server-Ressourcen Momentanwerte (Snapshot)</h2></div>
|
||
<?php foreach ($monitors as $m): ?>
|
||
<?php if ($m['type'] === 'host' && !empty($m['metrics_json'])): ?>
|
||
<?php $metrics = json_decode($m['metrics_json'], true); ?>
|
||
<div style="margin-bottom:1.25rem;">
|
||
<div style="display:flex; justify-content:space-between; font-weight:600; margin-bottom:0.35rem;">
|
||
<span><?= htmlspecialchars($m['source']) ?> (<?= htmlspecialchars($m['os'] ?? 'Host') ?>)</span>
|
||
<span class="badge badge-<?= $m['state'] === 'up' ? 'up' : 'down' ?>"><?= strtoupper($m['state']) ?></span>
|
||
</div>
|
||
<div class="segment-bar-container">
|
||
<div class="segment-bar-label"><span>CPU Auslastung</span><span><?= $metrics['cpu'] ?? 0 ?>%</span></div>
|
||
<div class="segment-bar">
|
||
<?php for ($i=1; $i<=40; $i++): ?>
|
||
<?php $pct = ($i/40)*100; $fill = ($metrics['cpu'] ?? 0) >= $pct ? (($metrics['cpu']??0)>85?'filled-crit':(($metrics['cpu']??0)>70?'filled-warn':'filled-ok')) : ''; ?>
|
||
<div class="segment <?= $fill ?>"></div>
|
||
<?php endfor; ?>
|
||
</div>
|
||
</div>
|
||
<div class="segment-bar-container" style="margin-top:0.5rem;">
|
||
<div class="segment-bar-label"><span>RAM Auslastung</span><span><?= $metrics['ram'] ?? 0 ?>%</span></div>
|
||
<div class="segment-bar">
|
||
<?php for ($i=1; $i<=40; $i++): ?>
|
||
<?php $pct = ($i/40)*100; $fill = ($metrics['ram'] ?? 0) >= $pct ? (($metrics['ram']??0)>85?'filled-crit':(($metrics['ram']??0)>70?'filled-warn':'filled-ok')) : ''; ?>
|
||
<div class="segment <?= $fill ?>"></div>
|
||
<?php endfor; ?>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<?php endif; ?>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Subtab: Watchdog Uptime -->
|
||
<div id="sub-watchdog-uptime" class="subtab-content">
|
||
<div class="card">
|
||
<div class="card-header"><h2 class="card-title">⏱️ Uptime & Liveness Matrix</h2></div>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Monitor</th>
|
||
<th>Gruppe</th>
|
||
<th>Erwartetes Intervall</th>
|
||
<th>Status</th>
|
||
<th>Zuletzt Gesehen</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<?php foreach ($monitors as $m): ?>
|
||
<tr>
|
||
<td><strong><?= htmlspecialchars($m['source']) ?></strong></td>
|
||
<td><?= htmlspecialchars($m['group_key'] ?? 'Default') ?></td>
|
||
<td>Alle <?= $m['expected_interval_sec'] ?>s</td>
|
||
<td><span class="badge badge-<?= $m['state'] === 'up' ? 'up' : ($m['state']==='warning'?'warning':'down') ?>"><?= strtoupper($m['state']) ?></span></td>
|
||
<td><?= htmlspecialchars($m['last_seen_utc'] ?? 'Nie') ?></td>
|
||
</tr>
|
||
<?php endforeach; ?>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Subtab: Services -->
|
||
<div id="sub-watchdog-services" class="subtab-content">
|
||
<div class="card">
|
||
<div class="card-header"><h2 class="card-title">⚙️ Überwachte Backend-Dienste (type=heartbeat / Services)</h2></div>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Dienst-Name</th>
|
||
<th>Gruppe</th>
|
||
<th>Status</th>
|
||
<th>Letzte Meldung</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<?php foreach (array_filter($monitors, fn($m) => $m['group_key'] === 'Services') as $m): ?>
|
||
<tr>
|
||
<td><strong><?= htmlspecialchars($m['source']) ?></strong></td>
|
||
<td><?= htmlspecialchars($m['group_key']) ?></td>
|
||
<td><span class="badge badge-<?= $m['state'] === 'up' ? 'up' : 'warning' ?>"><?= strtoupper($m['state']) ?></span></td>
|
||
<td><?= htmlspecialchars($m['last_message'] ?? '-') ?></td>
|
||
</tr>
|
||
<?php endforeach; ?>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Subtab: Applications -->
|
||
<div id="sub-watchdog-applications" class="subtab-content">
|
||
<div class="card">
|
||
<div class="card-header"><h2 class="card-title">💻 Überwachte Software-Anwendungen</h2></div>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Anwendung</th>
|
||
<th>Umgebung / OS</th>
|
||
<th>Status</th>
|
||
<th>Letztes Signal</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<?php foreach (array_filter($monitors, fn($m) => $m['group_key'] === 'Applications') as $m): ?>
|
||
<tr>
|
||
<td><strong><?= htmlspecialchars($m['source']) ?></strong></td>
|
||
<td><?= htmlspecialchars($m['os'] ?? '.NET') ?></td>
|
||
<td><span class="badge badge-up">ONLINE</span></td>
|
||
<td><?= htmlspecialchars($m['last_seen_utc']) ?></td>
|
||
</tr>
|
||
<?php endforeach; ?>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Subtab: Machines -->
|
||
<div id="sub-watchdog-machines" class="subtab-content">
|
||
<div class="card">
|
||
<div class="card-header"><h2 class="card-title">🖥️ Server & Physische / Virtuelle Maschinen (type=host)</h2></div>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Host Name</th>
|
||
<th>Betriebssystem</th>
|
||
<th>Status</th>
|
||
<th>CPU / RAM Snapshot</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<?php foreach (array_filter($monitors, fn($m) => $m['type'] === 'host') as $m): ?>
|
||
<?php $metrics = json_decode($m['metrics_json'] ?? '{}', true); ?>
|
||
<tr>
|
||
<td><strong><?= htmlspecialchars($m['source']) ?></strong></td>
|
||
<td><?= htmlspecialchars($m['os'] ?? 'Linux/Windows') ?></td>
|
||
<td><span class="badge badge-up">ONLINE</span></td>
|
||
<td>CPU: <strong><?= $metrics['cpu'] ?? 0 ?>%</strong> | RAM: <strong><?= $metrics['ram'] ?? 0 ?>%</strong></td>
|
||
</tr>
|
||
<?php endforeach; ?>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Subtab: Hypervisors -->
|
||
<div id="sub-watchdog-hypervisors" class="subtab-content">
|
||
<div class="card">
|
||
<div class="card-header"><h2 class="card-title">☁️ Proxmox Hypervisor & VM Guests</h2></div>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Node / Guest</th>
|
||
<th>Typ</th>
|
||
<th>Zugehöriger Node</th>
|
||
<th>Status</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<?php foreach (array_filter($monitors, fn($m) => in_array($m['type'], ['hypervisor_node', 'guest'])) as $m): ?>
|
||
<tr>
|
||
<td><strong><?= htmlspecialchars($m['source']) ?></strong></td>
|
||
<td><code><?= htmlspecialchars($m['type']) ?></code></td>
|
||
<td><?= htmlspecialchars($m['group_key'] ?? '-') ?></td>
|
||
<td><span class="badge badge-<?= $m['state']==='up'?'up':'stopped' ?>"><?= strtoupper($m['state']) ?></span></td>
|
||
</tr>
|
||
<?php endforeach; ?>
|
||
</tbody>
|
||
</table>
|
||
</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">
|
||
<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>
|
||
<input type="text" name="token_source" class="form-input" placeholder="srv-db-01">
|
||
</div>
|
||
</div>
|
||
<button type="submit" class="btn">Token Generieren</button>
|
||
</form>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-header"><h2 class="card-title">Aktive Agent Tokens</h2></div>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Token ID</th>
|
||
<th>Bezeichnung</th>
|
||
<th>Gebundene Source</th>
|
||
<th>Erstellt am</th>
|
||
<th>Status</th>
|
||
<th>Aktion</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<?php foreach ($agentTokens as $tok): ?>
|
||
<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><?= htmlspecialchars($tok['created_at_utc']) ?></td>
|
||
<td><span class="badge badge-<?= $tok['revoked'] ? 'down' : 'up' ?>"><?= $tok['revoked'] ? 'WIDERUFEN' : 'AKTIV' ?></span></td>
|
||
<td>
|
||
<?php if (!$tok['revoked']): ?>
|
||
<form method="POST" style="display:inline">
|
||
<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>
|
||
|
||
<!-- Subtab: Watchdog Integration & Prompts -->
|
||
<div id="sub-watchdog-integration" class="subtab-content">
|
||
<div class="card">
|
||
<div class="card-header"><h2 class="card-title">🤖 Watchdog KI-Prompts & Code-Snippets</h2></div>
|
||
<div style="margin-bottom:1.5rem;">
|
||
<label class="form-label">C# Background Service Heartbeat Prompt</label>
|
||
<div class="prompt-box" id="prompt-wd-cs">Ich möchte in meinem C# .NET Worker Service einen Watchdog-Heartbeat einbauen.
|
||
|
||
Ping-URL: <?= $baseUrl ?>/api/watchdog/v1/ping
|
||
HTTP Method: POST
|
||
JSON-Payload:
|
||
{
|
||
"source": "MyWorkerService",
|
||
"status": "ok",
|
||
"message": "Processing queue",
|
||
"expected_interval_sec": 60
|
||
}
|
||
|
||
Bitte schreibe einen IHostedService in C#, der alle 30 Sekunden automatisch diesen Heartbeat-Ping an das Deploymentcenter sendet.</div>
|
||
<button class="btn btn-secondary btn-sm" onclick="copyText('prompt-wd-cs')">📋 Prompt Kopieren (C# Worker)</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ================= MODULE 3: UPDATESERVICE ================= -->
|
||
<div id="tab-updateservice" class="tab-content">
|
||
<div class="module-subnav">
|
||
<button class="subnav-link active" onclick="switchSubTab('updateservice', 'sub-update-releases', this)">📊 Releases Overview</button>
|
||
<button class="subnav-link" onclick="switchSubTab('updateservice', 'sub-update-publish', this)">➕ Release Veröffentlichen</button>
|
||
<button class="subnav-link" onclick="switchSubTab('updateservice', 'sub-update-integration', this)">💻 Integration & Prompts</button>
|
||
</div>
|
||
|
||
<div id="sub-update-releases" class="subtab-content active">
|
||
<div class="card">
|
||
<div class="card-header"><h2 class="card-title">📦 Veröffentlichte Releases</h2></div>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Produkt</th>
|
||
<th>Version</th>
|
||
<th>Release Notes</th>
|
||
<th>Download URL</th>
|
||
<th>Datum</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<?php foreach ($releases as $r): ?>
|
||
<tr>
|
||
<td><strong><?= htmlspecialchars($r['product_slug']) ?></strong></td>
|
||
<td><code>v<?= htmlspecialchars($r['version']) ?></code></td>
|
||
<td><?= htmlspecialchars($r['release_notes'] ?? '-') ?></td>
|
||
<td><a href="<?= htmlspecialchars($r['download_url']) ?>" target="_blank" style="color:var(--primary);"><?= htmlspecialchars($r['download_url']) ?></a></td>
|
||
<td><?= htmlspecialchars($r['created_at']) ?></td>
|
||
</tr>
|
||
<?php endforeach; ?>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="sub-update-publish" class="subtab-content">
|
||
<div class="card">
|
||
<div class="card-header"><h2 class="card-title">Neues Software-Release veröffentlichen</h2></div>
|
||
<form method="POST">
|
||
<input type="hidden" name="action" value="add_release">
|
||
<div class="form-grid">
|
||
<div class="form-group">
|
||
<label class="form-label">Produkt-Slug</label>
|
||
<select name="product_slug" class="form-input" required>
|
||
<?php foreach ($products 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">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">Download URL</label>
|
||
<input type="url" name="download_url" class="form-input" required placeholder="https://dc.mhdf.de/downloads/myapp-1.2.0.zip">
|
||
</div>
|
||
</div>
|
||
<div class="form-group" style="margin-bottom:1rem;">
|
||
<label class="form-label">Release Notes</label>
|
||
<textarea name="release_notes" class="form-input" rows="3" placeholder="Changelog und Verbesserungen..."></textarea>
|
||
</div>
|
||
<button type="submit" class="btn">Release Speichern & Freigeben</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="sub-update-integration" class="subtab-content">
|
||
<div class="card">
|
||
<div class="card-header"><h2 class="card-title">🤖 UpdateService KI-Prompts</h2></div>
|
||
<div class="prompt-box" id="prompt-upd">Ich möchte in meiner Client-Anwendung eine automatische Update-Prüfung einbauen.
|
||
|
||
Update-Check URL: <?= $baseUrl ?>/api/updateservice/v1/check?product=myapp&version=1.0.0
|
||
|
||
Bitte erstelle ein Modul, das beim Start prüft, ob `update_available` true ist, und dem Nutzer den Download-Link sowie die Release Notes anzeigt.</div>
|
||
<button class="btn btn-secondary btn-sm" onclick="copyText('prompt-upd')">📋 Prompt Kopieren</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ================= MODULE 4: SYSTEM & DB ================= -->
|
||
<div id="tab-system" class="tab-content">
|
||
<div class="module-subnav">
|
||
<button class="subnav-link active" onclick="switchSubTab('system', 'sub-system-status', this)">⚙️ System-Status</button>
|
||
<button class="subnav-link" onclick="switchSubTab('system', 'sub-system-migration', this)">🗄️ Daten-Migration & Repair</button>
|
||
</div>
|
||
|
||
<div id="sub-system-status" class="subtab-content active">
|
||
<div class="card">
|
||
<div class="card-header"><h2 class="card-title">⚙️ Server & Umgebung Information</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="<?= PHP_VERSION ?>">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Server Software</label>
|
||
<input type="text" readonly class="form-input" value="<?= $_SERVER['SERVER_SOFTWARE'] ?? 'Apache/MySQL' ?>">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">MySQL Datenbank</label>
|
||
<input type="text" readonly class="form-input" value="bergisnu_db0 @ lznk.your-database.de">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="sub-system-migration" class="subtab-content">
|
||
<div class="card">
|
||
<div class="card-header"><h2 class="card-title">🗄️ Datenbank-Migration & Testdaten-Re-Seed</h2></div>
|
||
<p style="color:var(--text-muted); font-size:0.9rem; margin-bottom:1.25rem;">
|
||
Führt das vollständige DB-Schema aus und stellt sicher, dass alle Tabellen (<code>license_*</code>, <code>watchdog_*</code>, <code>updateservice_*</code>, <code>dc_*</code>) angelegt sind und Testdaten für alle Module migriert werden.
|
||
</p>
|
||
<a href="install_db.php" target="_blank" class="btn">Datenbank-Migration jetzt ausführen (install_db.php)</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</main>
|
||
</div>
|
||
|
||
<script>
|
||
// Sidebar Toggle Handler with localStorage persistence
|
||
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'));
|
||
}
|
||
|
||
// Restore sidebar state
|
||
if (localStorage.getItem('sidebar_collapsed') === 'true') {
|
||
document.getElementById('sidebar').classList.add('collapsed');
|
||
document.getElementById('mainWrapper').classList.add('expanded');
|
||
}
|
||
|
||
// Main Module Navigation Switcher
|
||
function switchMainTab(moduleName, el) {
|
||
document.querySelectorAll('.main-content > .tab-content').forEach(t => t.classList.remove('active'));
|
||
document.querySelectorAll('.nav-link').forEach(n => n.classList.remove('active'));
|
||
|
||
const targetTab = document.getElementById('tab-' + moduleName);
|
||
if (targetTab) {
|
||
targetTab.classList.add('active');
|
||
}
|
||
if (el) {
|
||
el.classList.add('active');
|
||
}
|
||
}
|
||
|
||
// Horizontal Submenu Switcher
|
||
function switchSubTab(moduleName, subtabId, el) {
|
||
const parentModule = document.getElementById('tab-' + moduleName);
|
||
if (!parentModule) return;
|
||
|
||
parentModule.querySelectorAll('.subtab-content').forEach(s => s.classList.remove('active'));
|
||
parentModule.querySelectorAll('.subnav-link').forEach(l => l.classList.remove('active'));
|
||
|
||
const targetSub = document.getElementById(subtabId);
|
||
if (targetSub) {
|
||
targetSub.classList.add('active');
|
||
}
|
||
if (el) {
|
||
el.classList.add('active');
|
||
}
|
||
}
|
||
|
||
// Helper to copy text to clipboard
|
||
function copyText(id) {
|
||
const text = document.getElementById(id).innerText;
|
||
navigator.clipboard.writeText(text);
|
||
alert('Prompt / Code in Zwischenablage kopiert!');
|
||
}
|
||
|
||
// Generate Offline Payload
|
||
function generateOfflinePayload() {
|
||
const key = document.getElementById('offlineLicSelect').value;
|
||
const hwId = document.getElementById('offlineHwId').value || 'HWID-GENERAL';
|
||
|
||
const payload = {
|
||
"type": "offline_license_file",
|
||
"issued_at": Math.floor(Date.now() / 1000),
|
||
"license_key": key,
|
||
"hardware_id": hwId,
|
||
"status": "valid",
|
||
"valid_until": "2027-12-31 23:59:59",
|
||
"signature": "ED25519_SIG_" + btoa(key + hwId).substring(0, 32)
|
||
};
|
||
|
||
document.getElementById('offlineResultBox').innerText = JSON.stringify(payload, null, 2);
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|