feat(errors, watchdog): Fehler-Stream mit Ignore-Regeln, Metrik-Verlauf, Abhängigkeits-Alarme

Fehler-Schnittstelle
- Neuer schlanker Eingang POST /api/errors/v1/report für den globalen
  Exception-Handler einer Anwendung. Titel und Dringlichkeit leitet der Server
  ab; gespeichert wird in derselben Tabelle wie der Bugtracker. Ein zweiter
  Speicher wäre nur ein zweiter Ort, an dem man suchen müsste.
- error_level (fatal/error/warning) trennt die technische Art des Ereignisses
  von der geschäftlichen Dringlichkeit. Ein Duplicate-Entry ist technisch ein
  error, geschäftlich belanglos — beides zu vermischen war der Grund, warum
  solche Meldungen als Bug im Dashboard landeten.

Ignore-Regeln gegen bekanntes Rauschen
- bugtracker_ignore_rules mit contains/regex/exception_class, Pflichtfeld für
  die Begründung und optionaler Alarmschwelle.
- Ein Treffer bedeutet nicht "wegwerfen": Der Fehler wird weiterhin erfasst und
  hochgezählt, bleibt aber aus der Übersicht heraus und löst keine
  Benachrichtigung aus. Der Zähler ist der eigentliche Zweck — dass ein
  bekannter Fehler auftritt, ist normal; dass er plötzlich hundertmal so oft
  auftritt, ist ein Signal. Dafür das rollende Stundenfenster und
  error.rate_exceeded.
- Neue Regeln lassen sich rückwirkend auf bestehende Einträge anwenden.

Gruppierung überarbeitet
- Der Schlüssel nahm bisher 300 Zeichen Stacktrace auf. Derselbe Fehler
  zersplitterte dadurch, sobald ein Aufrufer den Stack einmal mitschickte und
  einmal nicht. Jetzt zählt der Ursprungsort: bevorzugt die Dateiangabe, sonst
  der erste Rahmen des Stacktrace.
- Die Normalisierung ersetzte nur Zahlen ab vier Stellen, wodurch
  'AA-1' und 'BB-2' getrennt blieben. Werte in Anführungszeichen, die Ziffern
  enthalten, gelten jetzt als veränderlich — der Schlüsselname bleibt erhalten,
  sodass verschiedene Unique-Keys unterscheidbar sind. Mit 9 Testfällen belegt.

Metrik-Verlauf
- watchdog_metrics speichert numerische Heartbeat-Werte mit Zeitstempel.
  Zuvor wurde metrics_json bei jedem Heartbeat überschrieben; damit ließ sich
  "die Platte läuft seit drei Tagen voll" nicht erkennen, nur "sie ist voll".
- GET /api/watchdog/v1/metrics liefert den verdichteten Verlauf und die
  Abweichung vom eigenen Sieben-Tage-Durchschnitt. Dieser relative Ansatz
  braucht keine projektspezifischen Schwellwerte.
- Aufbewahrung 14 Tage, Bereinigung stündlich durch den Evaluator.

Health-Checks per Push statt Abruf
- Der Heartbeat nimmt ein checks-Objekt entgegen, das die Anwendung selbst
  ermittelt. Das Deploymentcenter interpretiert die Namen nicht, es liest nur
  ok und message — was "gesund" bedeutet, entscheidet jede Anwendung selbst.
  Schlägt eine Prüfung fehl, wird ein als ok gemeldeter Heartbeat auf warning
  herabgestuft.
- Bewusst ausgehend: auf den Zielmaschinen müssen keine Ports geöffnet werden.

Abhängigkeitsbewusste Alarmierung
- Fällt ein Monitor aus, dessen Parent selbst unten ist, wird der Alarm
  unterdrückt. Der Zustand bleibt sichtbar. Vorher erzeugte ein ausgefallener
  Hypervisor mit zwölf VMs dreizehn Meldungen für ein Problem.
- Mehrere Ebenen und fehlerhafte Hierarchien (Zyklen, gelöschte Parents) sind
  abgesichert; mit 10 Testfällen belegt.

WebUI
- Neue Ansicht "Fehler-Stream" mit Filtern nach Projekt, Fehlerklasse,
  Umgebung, Zeitraum und Sichtbarkeit sowie Volltextsuche und Pagination.
  Stummgeschaltete Einträge sind standardmäßig ausgeblendet.
- Verwaltung der Ignore-Regeln inklusive Trefferzähler.
- Die Detailansicht zeigt Fehlerklasse, Stummschaltungsgrund und die Häufung
  im laufenden Stundenfenster.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Deploymentcenter Bot
2026-08-07 21:55:23 +02:00
co-authored by Claude Opus 5
parent a74c6fd990
commit 60e34b29f6
13 changed files with 2228 additions and 45 deletions
+578
View File
@@ -29,6 +29,7 @@ use Deploymentcenter\Core\Http;
use Deploymentcenter\Core\Logger;
use Deploymentcenter\Core\TokenManager as CoreTokenManager;
use Deploymentcenter\Modules\Bugtracker\BugRepo;
use Deploymentcenter\Modules\Bugtracker\IgnoreRules;
use Deploymentcenter\Modules\License\Audit;
use Deploymentcenter\Modules\License\KeyGen;
use Deploymentcenter\Modules\UpdateService\UpdateManager;
@@ -854,6 +855,94 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
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) {
@@ -1165,6 +1254,58 @@ function btUrl(array $overrides = []): string
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,
@@ -2811,6 +2952,418 @@ $csrfField = Csrf::field();
</div>
</div>
<!-- =============== Subtab: Fehler-Stream =============== -->
<div id="sub-bugtracker-errors" class="subtab-content">
<div class="stats-grid" style="grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));">
<div class="stat-card">
<div class="stat-header">Fehler (24 h)</div>
<div class="stat-value" style="color:var(--warning);"><?= $bugtrackerStats['errors_24h'] ?></div>
</div>
<div class="stat-card">
<div class="stat-header">💀 Offene Abstürze</div>
<div class="stat-value" style="color:var(--danger);"><?= $bugtrackerStats['fatal_open'] ?></div>
</div>
<div class="stat-card">
<div class="stat-header">🔇 Stummgeschaltete Gruppen</div>
<div class="stat-value" style="color:var(--text-muted);"><?= $bugtrackerStats['ignored_groups'] ?></div>
</div>
<div class="stat-card">
<div class="stat-header">davon Vorkommnisse</div>
<div class="stat-value" style="color:var(--text-muted); font-size:1.5rem;">
<?= number_format($bugtrackerStats['ignored_occurrences'], 0, ',', '.') ?>
</div>
</div>
</div>
<?php if ($bugtrackerStats['ignored_occurrences'] > 0): ?>
<div class="alert" style="background:rgba(91,157,255,0.10); border:1px solid rgba(91,157,255,0.25); color:#bfdbfe; font-weight:500; font-size:0.85rem;">
<?= number_format($bugtrackerStats['ignored_occurrences'], 0, ',', '.') ?> Vorkommnisse
wurden als bekannt eingestuft und aus dieser Ansicht herausgehalten ohne sie zu verwerfen.
Der Zähler bleibt erhalten, damit eine auffällige Häufung trotzdem auffällt.
</div>
<?php endif; ?>
<div class="card">
<div class="card-header" style="display:flex; flex-wrap:wrap; justify-content:space-between; align-items:center; gap:0.75rem;">
<h2 class="card-title" style="margin:0;">
Fehler-Stream
<span class="badge badge-stopped" style="margin-left:0.5rem;"><?= (int)$errorTotal ?> Gruppen</span>
</h2>
<form method="GET" action="index.php" style="display:flex; flex-wrap:wrap; gap:0.4rem; align-items:center;">
<select name="err_project" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="this.form.submit()">
<option value="all">🌐 Alle Projekte</option>
<?php foreach ($projects as $p): ?>
<option value="<?= e($p['slug']) ?>" <?= $errFilters['project_slug'] === $p['slug'] ? 'selected' : '' ?>>
<?= e($p['name']) ?>
</option>
<?php endforeach; ?>
</select>
<select name="err_level" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="this.form.submit()">
<?php
$levelOptions = [
'all' => '🎚️ Alle Klassen',
'fatal' => '💀 Absturz (fatal)',
'error' => '⚠️ Fehler (error)',
'warning' => '️ Warnung',
];
foreach ($levelOptions as $value => $label): ?>
<option value="<?= e($value) ?>" <?= $errFilters['error_level'] === $value ? 'selected' : '' ?>><?= e($label) ?></option>
<?php endforeach; ?>
</select>
<select name="err_env" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="this.form.submit()">
<?php
$errEnvOptions = [
'all' => '🌍 Alle Umgebungen',
'production' => '🔴 Produktion',
'development' => '🔵 Entwicklung',
'staging' => '🟡 Staging',
'testing' => '🧪 Testing',
];
foreach ($errEnvOptions as $value => $label): ?>
<option value="<?= e($value) ?>" <?= $errFilters['environment'] === $value ? 'selected' : '' ?>><?= e($label) ?></option>
<?php endforeach; ?>
</select>
<select name="err_show" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="this.form.submit()">
<?php
$showOptions = [
'active' => '🔔 Nur relevante',
'ignored' => '🔇 Nur stummgeschaltete',
'all' => '📋 Alle',
];
foreach ($showOptions as $value => $label): ?>
<option value="<?= e($value) ?>" <?= $errShow === $value ? 'selected' : '' ?>><?= e($label) ?></option>
<?php endforeach; ?>
</select>
<select name="err_hours" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="this.form.submit()">
<?php
$hourOptions = [
'0' => '🕒 Gesamter Zeitraum',
'1' => 'Letzte Stunde',
'24' => 'Letzte 24 Stunden',
'168' => 'Letzte 7 Tage',
'720' => 'Letzte 30 Tage',
];
foreach ($hourOptions as $value => $label): ?>
<option value="<?= e($value) ?>" <?= (string)$errFilters['since_hours'] === $value ? 'selected' : '' ?>><?= e($label) ?></option>
<?php endforeach; ?>
</select>
<input type="text" name="err_q" value="<?= e($errFilters['search']) ?>" class="form-input"
style="width:170px; padding:0.3rem 0.6rem; font-size:0.8rem;" placeholder="🔍 Suchen...">
<button type="submit" class="btn btn-sm">Filtern</button>
<a href="index.php#sub-bugtracker-errors" class="btn btn-sm btn-secondary">Zurücksetzen</a>
</form>
</div>
<table>
<thead>
<tr>
<th>Klasse</th>
<th>Fehler</th>
<th>Projekt / Umgebung</th>
<th>Build</th>
<th>Anzahl</th>
<th>Zuletzt</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
<?php if ($errorItems === []): ?>
<tr>
<td colspan="7" style="text-align:center; padding:2.5rem; color:var(--text-muted);">
Keine Fehler für diese Filterkombination.
<?php if ($errShow === 'active'): ?>
<br><small>Stummgeschaltete Einträge sind ausgeblendet über „Nur stummgeschaltete“ sichtbar.</small>
<?php endif; ?>
</td>
</tr>
<?php endif; ?>
<?php foreach ($errorItems as $item): ?>
<?php
$isIgnored = (string)$item['status'] === 'ignored';
$level = (string)($item['error_level'] ?? 'error');
$levelBadge = match ($level) {
'fatal' => '<span class="badge badge-down" style="font-weight:bold;">💀 FATAL</span>',
'warning' => '<span class="badge" style="background:rgba(91,157,255,0.2); color:#5b9dff; border:1px solid #5b9dff;">️ WARN</span>',
default => '<span class="badge badge-warning">⚠️ ERROR</span>',
};
// Rate im laufenden Fenster auffällige Häufung sichtbar machen
$windowCount = (int)($item['rate_window_count'] ?? 0);
$windowFresh = !empty($item['rate_window_start'])
&& (time() - (int)strtotime((string)$item['rate_window_start'] . ' UTC')) < 3600;
?>
<tr style="<?= $isIgnored ? 'opacity:0.55;' : '' ?>">
<td><?= $levelBadge ?></td>
<td>
<strong style="color:#fff;"><?= e(mb_strimwidth((string)$item['title'], 0, 90, '…')) ?></strong>
<div style="font-size:0.72rem; color:var(--text-muted); margin-top:0.2rem; display:flex; flex-wrap:wrap; gap:0.4rem; align-items:center;">
<?php if ($isIgnored): ?>
<span class="badge" style="background:rgba(255,255,255,0.06); font-size:0.68rem;">
🔇 stummgeschaltet<?= !empty($item['ignore_rule_id']) ? ' (Regel #' . (int)$item['ignore_rule_id'] . ')' : '' ?>
</span>
<?php endif; ?>
<?php if ($windowFresh && $windowCount > 1): ?>
<span class="badge" style="background:rgba(245,166,35,0.18); color:#fbbf24; font-size:0.68rem;">
📈 <?= $windowCount ?>× in dieser Stunde
</span>
<?php endif; ?>
<?php if (!empty($item['file_path'])): ?>
<span style="font-family:monospace;">📄 <?= e((string)$item['file_path']) ?><?= !empty($item['line_no']) ? ':' . (int)$item['line_no'] : '' ?></span>
<?php endif; ?>
</div>
</td>
<td>
<code style="font-size:0.75rem;"><?= e((string)$item['project_slug']) ?></code>
<div style="font-size:0.7rem; color:var(--text-muted);"><?= e(strtoupper((string)$item['environment'])) ?></div>
</td>
<td><code style="font-size:0.75rem;"><?= e((string)($item['build_version'] ?? '—')) ?></code></td>
<td>
<span class="badge" style="background:rgba(255,255,255,0.08); font-family:monospace;">
<?= number_format((int)$item['occurrence_count'], 0, ',', '.') ?>×
</span>
</td>
<td style="font-size:0.75rem; color:var(--text-muted);"><?= e(dc_ago($item['last_seen_at'] ?? null)) ?></td>
<td>
<div style="display:flex; gap:0.3rem; flex-wrap:wrap;">
<button type="button" class="btn btn-sm btn-secondary"
onclick="openBugtrackerModal(<?= (int)$item['id'] ?>)" title="Details">🔍</button>
<form method="POST" action="index.php#sub-bugtracker-errors" style="display:inline">
<?= $csrfField ?>
<input type="hidden" name="action" value="bt_set_ignored">
<input type="hidden" name="item_id" value="<?= (int)$item['id'] ?>">
<input type="hidden" name="ignore" value="<?= $isIgnored ? '0' : '1' ?>">
<button type="submit" class="btn btn-sm btn-secondary"
title="<?= $isIgnored ? 'Wieder aufnehmen' : 'Stummschalten' ?>">
<?= $isIgnored ? '🔔' : '🔇' ?>
</button>
</form>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php if ($errorPages > 1): ?>
<div style="display:flex; justify-content:space-between; align-items:center; margin-top:1rem; padding-top:1rem; border-top:1px solid var(--border-soft);">
<span style="font-size:0.8rem; color:var(--text-muted);">
Seite <?= (int)$errPage ?> von <?= (int)$errorPages ?> (<?= (int)$errorTotal ?> Gruppen)
</span>
<div style="display:flex; gap:0.4rem;">
<?php if ($errPage > 1): ?>
<a href="<?= e(errUrl(['err_page' => $errPage - 1])) ?>" class="btn btn-sm btn-secondary"> Zurück</a>
<?php endif; ?>
<?php if ($errPage < $errorPages): ?>
<a href="<?= e(errUrl(['err_page' => $errPage + 1])) ?>" class="btn btn-sm btn-secondary">Weiter </a>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
</div>
</div>
<!-- =============== Subtab: Ignore-Regeln =============== -->
<div id="sub-bugtracker-ignore" class="subtab-content">
<div class="card">
<div class="card-header"><h2 class="card-title">🔇 Bekannte, harmlose Fehler</h2></div>
<p style="color:var(--text-muted); font-size:0.875rem; margin-bottom:1.25rem; max-width:70ch;">
Ein Treffer bedeutet nicht „wegwerfen“: Der Fehler wird weiterhin erfasst und
hochgezählt, bleibt aber aus der Übersicht heraus und löst keine Benachrichtigung
aus. Der Zähler ist dabei der eigentliche Gewinn dass ein bekannter Fehler
auftritt, ist normal; dass er plötzlich hundertmal so oft auftritt, ist ein Signal.
Dafür ist die Alarmschwelle da.
</p>
<form method="POST" action="index.php#sub-bugtracker-ignore">
<?= $csrfField ?>
<input type="hidden" name="action" value="ir_create">
<div class="form-grid">
<div class="form-group">
<label class="form-label">Projekt</label>
<select name="project_slug" class="form-input">
<option value="">🌐 Alle Projekte</option>
<?php foreach ($projects as $p): ?>
<option value="<?= e($p['slug']) ?>"><?= e($p['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label class="form-label">Vergleichsart</label>
<select name="match_type" class="form-input">
<option value="contains">Enthält (empfohlen)</option>
<option value="exception_class">Ausnahmeklasse exakt</option>
<option value="regex">Regulärer Ausdruck</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Alarm ab pro Stunde</label>
<input type="number" name="alert_on_rate" class="form-input" min="0" placeholder="z. B. 500">
<small style="font-size:0.7rem; color:var(--text-muted);">0 oder leer = nie alarmieren</small>
</div>
</div>
<div class="form-group" style="margin-top:1rem;">
<label class="form-label">Suchmuster</label>
<input type="text" name="pattern" class="form-input" required
placeholder="z. B. Duplicate entry">
</div>
<div class="form-group" style="margin-top:1rem;">
<label class="form-label">Begründung warum ist das harmlos?</label>
<textarea name="reason" class="form-input" rows="2" required
placeholder="z. B. Marktdaten kommen doppelt aus dem Feed. Der Eintrag wird ohnehin nur einmal benötigt, die Verarbeitung läuft normal weiter."></textarea>
<small style="font-size:0.7rem; color:var(--text-muted);">
Pflichtfeld: In sechs Monaten weiß sonst niemand mehr, warum hier weggeschaut wird.
</small>
</div>
<label style="display:flex; align-items:center; gap:0.5rem; margin-top:1rem; font-size:0.85rem; cursor:pointer;">
<input type="checkbox" name="apply_retroactively" value="1" checked>
Bereits erfasste, passende Einträge gleich mit stummschalten
</label>
<button type="submit" class="btn" style="margin-top:1.25rem;">Regel anlegen</button>
</form>
</div>
<div class="card">
<div class="card-header"><h2 class="card-title">Bestehende Regeln</h2></div>
<table>
<thead>
<tr>
<th>#</th>
<th>Projekt</th>
<th>Muster</th>
<th>Begründung</th>
<th>Alarmschwelle</th>
<th>Treffer</th>
<th>Status</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
<?php if ($ignoreRules === []): ?>
<tr>
<td colspan="8" style="text-align:center; padding:2rem; color:var(--text-muted);">
Noch keine Regeln angelegt.
</td>
</tr>
<?php endif; ?>
<?php foreach ($ignoreRules as $rule): ?>
<?php $enabled = !empty($rule['enabled']); ?>
<tr style="<?= $enabled ? '' : 'opacity:0.5;' ?>">
<td><?= (int)$rule['id'] ?></td>
<td>
<?php if (empty($rule['project_slug'])): ?>
<span class="badge badge-stopped">ALLE</span>
<?php else: ?>
<code style="font-size:0.75rem;"><?= e((string)$rule['project_slug']) ?></code>
<?php endif; ?>
</td>
<td>
<code style="font-size:0.78rem;"><?= e((string)$rule['pattern']) ?></code>
<div style="font-size:0.68rem; color:var(--text-muted);"><?= e((string)$rule['match_type']) ?></div>
</td>
<td style="font-size:0.78rem; color:var(--text-muted); max-width:320px;">
<?= e(mb_strimwidth((string)$rule['reason'], 0, 160, '…')) ?>
</td>
<td>
<?php if ($rule['alert_on_rate'] !== null): ?>
<span class="badge badge-warning"><?= (int)$rule['alert_on_rate'] ?>/h</span>
<?php else: ?>
<span style="color:var(--text-muted); font-size:0.8rem;"></span>
<?php endif; ?>
</td>
<td>
<span class="badge" style="background:rgba(255,255,255,0.08); font-family:monospace;">
<?= number_format((int)$rule['match_count'], 0, ',', '.') ?>
</span>
<div style="font-size:0.68rem; color:var(--text-muted);">
<?= (int)($rule['item_count'] ?? 0) ?> Item(s)
<?php if (!empty($rule['last_match_at'])): ?>
· <?= e(dc_ago((string)$rule['last_match_at'])) ?>
<?php endif; ?>
</div>
</td>
<td>
<span class="badge badge-<?= $enabled ? 'up' : 'stopped' ?>">
<?= $enabled ? 'AKTIV' : 'INAKTIV' ?>
</span>
</td>
<td>
<div style="display:flex; gap:0.3rem; flex-wrap:wrap;">
<form method="POST" action="index.php#sub-bugtracker-ignore" style="display:inline">
<?= $csrfField ?>
<input type="hidden" name="action" value="ir_toggle">
<input type="hidden" name="rule_id" value="<?= (int)$rule['id'] ?>">
<input type="hidden" name="enable" value="<?= $enabled ? '0' : '1' ?>">
<button type="submit" class="btn btn-sm btn-secondary">
<?= $enabled ? 'Deaktivieren' : 'Aktivieren' ?>
</button>
</form>
<form method="POST" action="index.php#sub-bugtracker-ignore" style="display:inline">
<?= $csrfField ?>
<input type="hidden" name="action" value="ir_apply">
<input type="hidden" name="rule_id" value="<?= (int)$rule['id'] ?>">
<button type="submit" class="btn btn-sm btn-secondary" title="Rückwirkend auf bestehende Einträge anwenden"></button>
</form>
<form method="POST" action="index.php#sub-bugtracker-ignore" style="display:inline"
onsubmit="return confirm('Regel wirklich löschen? Bereits stummgeschaltete Einträge bleiben stumm, verlieren aber die Zuordnung.');">
<?= $csrfField ?>
<input type="hidden" name="action" value="ir_delete">
<input type="hidden" name="rule_id" value="<?= (int)$rule['id'] ?>">
<button type="submit" class="btn btn-sm btn-danger">🗑️</button>
</form>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<div class="card">
<div class="card-header"><h2 class="card-title">💻 Anbindung</h2></div>
<p style="color:var(--text-muted); font-size:0.85rem; margin-bottom:1rem;">
Ein Aufruf, den man in den globalen Exception-Handler hängt. Titel und
Dringlichkeit leitet der Server ab, die Regeln oben greifen automatisch.
</p>
<div class="prompt-box">POST <?= e($baseUrl) ?>/api/errors/v1/report
Authorization: Bearer &lt;DC_TOKEN&gt;
Content-Type: application/json
{
"project_slug": "polytrader",
"exception": "PDOException",
"message": "SQLSTATE[23000]: Duplicate entry '...' for key 'uq_market'",
"stack_trace": "...",
"level": "error", // fatal = Prozess beendet
"build": "v2.0.1",
"environment": "production",
"file": "src/Market/Importer.php",
"line": 142
}
Antwort:
{ "status": "success", "item_id": 42, "ignored": true,
"occurrence_count": 3841, "rate_alerted": false,
"message": "Als bekannt eingestuft, gezaehlt, nicht gemeldet." }</div>
</div>
</div>
</div>
<!-- ================= MODULE: TOKEN-VERWALTUNG ================= -->
@@ -3226,6 +3779,8 @@ SYSTEM
],
'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': [
@@ -3399,6 +3954,26 @@ SYSTEM
});
}
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
@@ -3426,6 +4001,7 @@ SYSTEM
<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>
@@ -3437,7 +4013,9 @@ SYSTEM
<button type="button" class="btn btn-sm btn-secondary" onclick="closeBugtrackerModal()"></button>
</div>
${ignoreBanner}
${claimBanner}
${rateInfo}
${item.description ? `
<div style="margin-bottom:1rem;">