feat(2.7.0): Roadmap im DC, Testinstanz mit Testtor, Supervisor und MCP

Sammelcommit fuer den ueber mehrere Sitzungen gewachsenen, bisher nicht
eingecheckten Stand. Getrennte Commits waeren ehrlicher gewesen, sind es
nachtraeglich aber nicht mehr: BugRepo, changelog.json und die Doku tragen
Aenderungen aus allen Straengen gleichzeitig, und keine Zwischenstufe waere
lauffaehig.

ROADMAP IM DEPLOYMENTCENTER (2.7.0)

Planungsstand, Reihenfolge und Zustaendigkeit lagen in handgepflegten
Markdown-Dateien neben einem Bugtracker, der genau diese drei Dinge bereits
fuehrt. Zwei Pfleger fuer dasselbe Feld - Drift war eine Frage der Zeit.

Ab jetzt: Zustand im DC, Begruendung in der Datei. Kein Feld hat zwei Pfleger.

  - Migration 016: epic, roadmap_rank, target_build, defer_trigger,
    doc_anchor, Status "deferred", Tabelle roadmap_epics.
    KEINE Roadmap-Tabelle - ein Eintrag ist ein bugtracker_item.
  - Zugehoerigkeit ist ein Feld: Ein Item steht auf der Roadmap, wenn es ein
    epic hat. Ein zweites Kennzeichen daneben koennte auseinanderlaufen.
  - Kennung ist die Item-ID. Handvergebene Kuerzel ("A12") erzeugen die
    schlimmste Fehlerart: Beide Seiten stimmig, Verschiedenes gemeint, weil
    die Kennung nach einer Loeschung neu vergeben wurde.
  - Zurueckstellen verlangt defer_trigger. Ohne benannten Anlass ist ein
    Vorhaben nicht zurueckgestellt, sondern vergessen. Die Regel sitzt im
    Repository, damit sie auf allen drei Schreibwegen gleich greift.
  - Modules/Bugtracker/Roadmap.php, /api/bugtracker/v1/roadmap.php,
    WebUI-Modul mit Filtern, cli/roadmap.php --write/--check,
    dc_roadmap und dc_roadmap_set im MCP-Adapter.
  - cli/roadmap.php --write ERZEUGT den Zustandsteil der Markdown-Datei;
    --check prueft nur die Prosa. Was erzeugt wird, kann nicht driften.
  - docs/ROADMAP_MIGRATION.md: Uebernahme fuer die anderen Projekte.

TESTINSTANZ UND TESTTOR

  - dc-dev.mhdf.de: zweite Installation, eigene DB, eigene Schluessel,
    Basic-Auth davor, Rocket.Chat aus.
  - 124 Testfaelle in tests/e2e gegen API, Rechte, Migrationen, Sperre.
  - scripts/deploy.py bringt vor jedem Upload erst den anstehenden Stand auf
    die Testinstanz und laesst dann den Testlauf laufen. Erst bei
    Rueckgabewert 0 geht etwas auf die Produktion (--ohne-test als Notausgang).
    Andersherum prueft der Lauf aelteren Code als den, der gleich hochgeht.
  - tests/ ist aus dem Produktiv-Deploy ausgeschlossen; testctl.php kann
    nicht versehentlich auf dc.mhdf.de landen.
  - Ein Lauf mit mehr uebersprungenen als geprueften Faellen endet mit
    Rueckgabewert 2 statt 0. Nichts ist kaputt, geprueft wurde es aber auch
    nicht - der Unterschied darf nicht am Rueckgabewert verschwinden.

SUPERVISOR, PROJEKTLANDSCHAFT, NACHPRUEFUNG (2.6.0/2.6.1)

  - Migration 013: projektuebergreifend melden (dev_agent, evidence,
    triage_external, affects).
  - Migration 014: supervisor_findings, Katalog, fuenf serverseitige
    Detektoren im Tick.
  - Migration 015: Nachpruefung als Arbeitsschlange statt Benachrichtigung.
  - DEV-Lizenzschluessel zur Selbstbedienung, dc-testkit fuer die Test-VM.
  - MCP-Adapter: 15 Werkzeuge, ohne Fremdabhaengigkeiten, mit Abgrenzung
    von Fremddaten gegen Prompt-Injektion.

BEHOBEN

  - observe und status im Supervisor legten den Zustand des Befundes als
    "status" in die Antwort. Http::ok() merged ['status' => 'success']
    darunter, also gewann der Befundzustand - eine angenommene Meldung kam
    als status="open" an. Heisst jetzt finding_status. War Item #59.

Migrationen 013-016 sind auf dem Produktivserver noch NICHT ausgefuehrt.
Ohne sie bricht das WebUI, weil die Roadmap-Ansicht bei jedem Seitenaufruf
gegen bugtracker_items.epic abfragt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Deploymentcenter Bot
2026-08-29 21:59:20 +02:00
co-authored by Claude Opus 5
parent 9d0261306c
commit 1d5ac92051
52 changed files with 15261 additions and 103 deletions
+876 -1
View File
@@ -30,6 +30,7 @@ use Deploymentcenter\Core\Logger;
use Deploymentcenter\Core\TokenManager as CoreTokenManager;
use Deploymentcenter\Modules\Bugtracker\BugRepo;
use Deploymentcenter\Modules\Bugtracker\IgnoreRules;
use Deploymentcenter\Modules\Bugtracker\Roadmap;
use Deploymentcenter\Modules\License\Audit;
use Deploymentcenter\Modules\License\KeyGen;
use Deploymentcenter\Modules\UpdateService\ReleaseGuard;
@@ -1051,7 +1052,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
}
}
$ok = $itemId > 0 && (new BugRepo($pdo))->updateItemDetails($itemId, $updates, 'user:' . $actor);
try {
$ok = $itemId > 0 && (new BugRepo($pdo))->updateItemDetails($itemId, $updates, 'user:' . $actor);
} catch (\InvalidArgumentException $e) {
dc_flash($e->getMessage(), 'danger');
dc_redirect('#tab-bugtracker');
}
dc_flash(
$ok ? sprintf('Item #%d aktualisiert.', $itemId) : 'Item nicht gefunden.',
@@ -1083,6 +1089,97 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
dc_redirect('#tab-bugtracker');
}
// ------------------------------------------------------ Roadmap
//
// Alle Roadmap-Aktionen schreiben in dieselben Item-Spalten wie
// der Bugtracker. Ein eigener Datenbestand existiert nicht - was
// hier gesetzt wird, ist Themenbereich, Reihenfolge, Zielfassung
// und der Verweis auf die Begruendung in der Datei.
case 'rm_set': {
$itemId = (int)($_POST['item_id'] ?? 0);
$updates = [];
foreach (['epic', 'target_build', 'defer_trigger', 'doc_anchor', 'status'] as $field) {
if (array_key_exists($field, $_POST)) {
$updates[$field] = $_POST[$field];
}
}
// Leeres Rangfeld heisst "keine Meinung", nicht "Rang 0" -
// sonst spraenge der Eintrag an die Spitze seines Bereichs.
if (array_key_exists('roadmap_rank', $_POST)) {
$raw = trim((string)$_POST['roadmap_rank']);
$updates['roadmap_rank'] = is_numeric($raw) ? (int)$raw : null;
}
try {
$ok = $itemId > 0
&& (new BugRepo($pdo))->updateItemDetails($itemId, $updates, 'user:' . $actor);
} catch (\InvalidArgumentException $e) {
dc_flash($e->getMessage(), 'danger');
dc_redirect('#tab-roadmap');
}
dc_flash(
$ok ? sprintf('Roadmap-Angaben zu #%d gespeichert.', $itemId) : 'Item nicht gefunden.',
$ok ? 'success' : 'danger'
);
dc_redirect('#tab-roadmap');
}
case 'rm_move': {
$itemId = (int)($_POST['item_id'] ?? 0);
$dir = ($_POST['direction'] ?? 'up') === 'down' ? 'down' : 'up';
$ok = $itemId > 0 && (new Roadmap($pdo))->move($itemId, $dir);
if (!$ok) {
dc_flash('Der Eintrag steht bereits am Rand seines Bereichs.', 'danger');
}
dc_redirect('#tab-roadmap');
}
case 'rm_save_epic': {
$slug = trim((string)($_POST['project_slug'] ?? ''));
$epic = trim((string)($_POST['epic'] ?? ''));
$title = trim((string)($_POST['title'] ?? ''));
if ($slug === '' || $epic === '' || $title === '') {
dc_flash('Projekt, Kuerzel und Titel werden alle drei gebraucht.', 'danger');
dc_redirect('#sub-roadmap-epics');
}
(new Roadmap($pdo))->saveEpic(
$slug,
$epic,
$title,
trim((string)($_POST['summary'] ?? '')) ?: null,
(int)($_POST['sort_order'] ?? 100)
);
dc_flash(sprintf('Themenbereich "%s" gespeichert.', e($epic)));
dc_redirect('#sub-roadmap-epics');
}
case 'rm_delete_epic': {
$slug = trim((string)($_POST['project_slug'] ?? ''));
$epic = trim((string)($_POST['epic'] ?? ''));
// Loeschen nur, solange nichts daran haengt. Sonst blieben
// Items mit einem Bereich zurueck, den es nicht mehr gibt -
// genau die haengende Referenz, die hier vermieden werden soll.
$ok = (new Roadmap($pdo))->deleteEpic($slug, $epic);
dc_flash(
$ok
? sprintf('Themenbereich "%s" geloescht.', e($epic))
: 'Der Bereich hat noch Eintraege - erst umhaengen, dann loeschen.',
$ok ? 'success' : 'danger'
);
dc_redirect('#sub-roadmap-epics');
}
// ------------------------------------------------ Ignore-Regeln
case 'ir_create': {
$rules = new IgnoreRules($pdo);
@@ -1860,6 +1957,127 @@ function errUrl(array $overrides = []): string
return 'index.php' . ($params !== [] ? '?' . http_build_query($params) : '') . '#sub-bugtracker-errors';
}
// ----------------------------------------------------------------------
// Roadmap
// ----------------------------------------------------------------------
// Kein eigener Datenbestand: Ein Roadmap-Eintrag ist ein bugtracker_item mit
// gesetztem Themenbereich. Diese Ansicht ist eine Sicht darauf, kein zweites
// System - deshalb liest sie dieselbe getItems()-Abfrage wie der Bugtracker,
// nur mit anderer Sortierung und einem zusaetzlichen Filter.
$roadmapRepo = new Roadmap($pdo);
$rmProject = (string)($_GET['rm_project'] ?? 'all');
$rmEpic = (string)($_GET['rm_epic'] ?? 'all');
$rmLage = (string)($_GET['rm_lage'] ?? 'aktiv');
$rmAgent = (string)($_GET['rm_agent'] ?? '');
$rmBuild = (string)($_GET['rm_build'] ?? 'all');
$rmSearch = (string)($_GET['rm_q'] ?? '');
/**
* Uebersetzt die gewaehlte Lage in Status.
*
* "aktiv" ist die Voreinstellung und nicht "alle": Eine Roadmap, die beim
* Aufschlagen mit sechzig erledigten Eintraegen beginnt, beantwortet die
* Frage nicht, fuer die man sie aufschlaegt.
*/
$rmLageStatuses = [
'aktiv' => Roadmap::PENDING_STATUSES,
'offen' => Roadmap::STATUS_GROUPS['offen'],
'arbeit' => Roadmap::STATUS_GROUPS['arbeit'],
'erledigt' => Roadmap::STATUS_GROUPS['erledigt'],
'zurueckgestellt' => Roadmap::STATUS_GROUPS['zurueckgestellt'],
'verworfen' => Roadmap::STATUS_GROUPS['verworfen'],
];
$rmFilters = [
'project_slug' => $rmProject,
'on_roadmap' => true,
'epic' => $rmEpic,
'target_build' => $rmBuild,
'claimed_by' => $rmAgent,
'search' => $rmSearch,
'order' => 'roadmap',
'limit' => 400,
];
if ($rmLage !== 'all' && isset($rmLageStatuses[$rmLage])) {
$rmFilters['status'] = implode(',', $rmLageStatuses[$rmLage]);
}
$roadmapResult = $bugRepo->getItems($rmFilters);
$roadmapItems = $roadmapResult['items'];
$roadmapTotal = $roadmapResult['total'];
$roadmapStats = $roadmapRepo->stats($rmProject);
$roadmapEpics = $roadmapRepo->epics($rmProject);
$roadmapOrphans = $roadmapRepo->orphanEpics($rmProject);
$roadmapNext = $roadmapRepo->nextUp($rmProject, 6);
$roadmapActive = $roadmapRepo->activeWork($rmProject);
// Bereichstitel als Nachschlagewerk, damit die Liste nicht je Zeile eine
// weitere Abfrage braucht.
$roadmapEpicTitles = [];
foreach ($roadmapEpics as $rmE) {
$roadmapEpicTitles[$rmE['project_slug'] . '/' . $rmE['epic']] = (string)$rmE['title'];
}
// Zielfassungen, die tatsaechlich vergeben sind - eine Auswahlliste aus
// vorhandenen Werten statt eines Freitextfelds, in das sich Tippfehler
// schleichen.
$roadmapBuilds = $pdo->query('
SELECT DISTINCT target_build FROM bugtracker_items
WHERE target_build IS NOT NULL AND target_build <> ""
AND epic IS NOT NULL AND epic <> ""
ORDER BY target_build DESC LIMIT 50
')->fetchAll(PDO::FETCH_COLUMN) ?: [];
// Eintraege, die noch keinen Bereich haben und deshalb nicht auf der Roadmap
// stehen. Die Aufnahmeliste: Der Weg vom Backlog auf die Planung ist genau
// eine Angabe, und dies ist die Stelle, an der man ihn sieht.
$roadmapCandidates = $bugRepo->getItems([
'project_slug' => $rmProject,
'on_roadmap' => false,
'type' => 'feature_request',
'status' => 'open,planned',
'order' => 'severity',
'limit' => 60,
])['items'];
/** Baut eine URL mit den aktuellen Roadmap-Filtern. */
function rmUrl(array $overrides = []): string
{
$params = [
'rm_project' => $_GET['rm_project'] ?? null,
'rm_epic' => $_GET['rm_epic'] ?? null,
'rm_lage' => $_GET['rm_lage'] ?? null,
'rm_agent' => $_GET['rm_agent'] ?? null,
'rm_build' => $_GET['rm_build'] ?? null,
'rm_q' => $_GET['rm_q'] ?? null,
];
$params = array_merge($params, $overrides);
$params = array_filter($params, static fn($v): bool => $v !== null && $v !== '' && $v !== 'all');
return 'index.php' . ($params !== [] ? '?' . http_build_query($params) : '') . '#tab-roadmap';
}
/** Zeichen und Klartext zu einem Status, fuer Abzeichen in der Liste. */
function rmBadge(string $status): string
{
$marker = Roadmap::MARKERS[$status] ?? '·';
$label = Roadmap::STATUS_LABELS[$status] ?? $status;
$class = match (true) {
in_array($status, ['resolved', 'closed'], true) => 'badge-up',
$status === 'in_progress' => 'badge-warning',
in_array($status, ['awaiting_verification', 'in_verification'], true) => 'badge-warning',
$status === 'fix_insufficient' => 'badge-down',
default => 'badge-stopped',
};
return '<span class="badge ' . $class . '">' . $marker . ' ' . e($label) . '</span>';
}
// Agenten-Uebersicht: wer bearbeitet gerade was
$agentWorkload = $pdo->query('
SELECT claimed_by,
@@ -2088,6 +2306,12 @@ $csrfField = Csrf::field();
<span class="nav-text">UpdateService</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link" onclick="switchMainTab('roadmap', this)" title="Roadmap & Projektstatus">
<svg viewBox="0 0 24 24"><line x1="3" y1="6" x2="21" y2="6"></line><line x1="3" y1="12" x2="15" y2="12"></line><line x1="3" y1="18" x2="9" y2="18"></line><circle cx="18" cy="12" r="2"></circle><circle cx="12" cy="18" r="2"></circle></svg>
<span class="nav-text">Roadmap</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link" onclick="switchMainTab('bugtracker', this)" title="Bug- & Feature-Tracker">
<svg viewBox="0 0 24 24"><path d="M12 2a2 2 0 0 1 2 2v1h3a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2h-1v2h2a1 1 0 0 1 0 2h-2v2h1a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2v-2a2 2 0 0 1 2-2h1v-2H6a1 1 0 0 1 0-2h2v-2H7a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h3V4a2 2 0 0 1 2-2z"></path></svg>
@@ -3575,6 +3799,592 @@ chmod +x update-agent
</div>
</div>
<!-- ================= MODULE: ROADMAP ================= -->
<!--
Kein eigener Datenbestand. Ein Roadmap-Eintrag ist ein
bugtracker_item mit gesetztem Themenbereich; diese Ansicht ist
die Sicht darauf. Wer hier etwas aendert, aendert dasselbe
Item, das der Bugtracker zeigt - eine zweite Wahrheit ueber
denselben Vorgang soll gar nicht erst entstehen koennen.
-->
<div id="tab-roadmap" class="tab-content">
<!-- =============== Subtab: Übersicht =============== -->
<div id="sub-roadmap-board" class="subtab-content active">
<div class="stats-grid" style="grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));">
<div class="stat-card">
<div class="stat-header">📋 Offen</div>
<div class="stat-value" style="color:#5b9dff;"><?= $roadmapStats['pending'] ?></div>
</div>
<div class="stat-card">
<div class="stat-header">🔨 In Arbeit</div>
<div class="stat-value" style="color:var(--warning);"><?= $roadmapStats['working'] ?></div>
</div>
<div class="stat-card">
<div class="stat-header">✅ Erledigt</div>
<div class="stat-value" style="color:var(--success);"><?= $roadmapStats['done'] ?></div>
</div>
<div class="stat-card">
<div class="stat-header">🧊 Zurückgestellt</div>
<div class="stat-value" style="color:var(--text-muted);"><?= $roadmapStats['deferred'] ?></div>
</div>
<div class="stat-card">
<div class="stat-header">❌ Verworfen</div>
<div class="stat-value" style="color:var(--text-muted);"><?= $roadmapStats['rejected'] ?></div>
</div>
<div class="stat-card">
<div class="stat-header">🤖 Belegt</div>
<div class="stat-value" style="color:#66bb6a;"><?= $roadmapStats['claimed'] ?></div>
</div>
</div>
<div class="card" style="padding:0.85rem 1.1rem;">
<form method="GET" action="index.php" style="display:flex; flex-wrap:wrap; gap:0.45rem; align-items:center;">
<select name="rm_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']) ?>" <?= $rmProject === $p['slug'] ? 'selected' : '' ?>>
<?= e($p['name']) ?>
</option>
<?php endforeach; ?>
</select>
<select name="rm_lage" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="this.form.submit()">
<?php
// Voreinstellung ist "aktiv" und nicht "alle": Eine Roadmap,
// die mit sechzig erledigten Eintraegen beginnt, beantwortet
// die Frage nicht, fuer die man sie aufschlaegt.
$rmLageOptions = [
'aktiv' => '🎯 Aktiv (offen + in Arbeit)',
'offen' => '📋 Nur offen',
'arbeit' => '🔨 Nur in Arbeit',
'erledigt' => '✅ Erledigt',
'zurueckgestellt' => '🧊 Zurückgestellt',
'verworfen' => '❌ Verworfen',
'all' => '📌 Alle Lagen',
];
foreach ($rmLageOptions as $value => $label): ?>
<option value="<?= e($value) ?>" <?= $rmLage === $value ? 'selected' : '' ?>><?= e($label) ?></option>
<?php endforeach; ?>
</select>
<select name="rm_epic" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="this.form.submit()">
<option value="all">🗂️ Alle Bereiche</option>
<?php foreach ($roadmapEpics as $rmE): ?>
<option value="<?= e((string)$rmE['epic']) ?>" <?= $rmEpic === (string)$rmE['epic'] ? 'selected' : '' ?>>
<?= e((string)$rmE['epic']) ?> — <?= e((string)$rmE['title']) ?>
</option>
<?php endforeach; ?>
</select>
<select name="rm_build" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="this.form.submit()">
<option value="all">🎯 Alle Zielfassungen</option>
<?php foreach ($roadmapBuilds as $rmB): ?>
<option value="<?= e((string)$rmB) ?>" <?= $rmBuild === (string)$rmB ? 'selected' : '' ?>><?= e((string)$rmB) ?></option>
<?php endforeach; ?>
</select>
<input type="text" name="rm_agent" value="<?= e($rmAgent) ?>" placeholder="🤖 Agent"
class="form-input" style="width:130px; padding:0.3rem 0.6rem; font-size:0.8rem;">
<input type="text" name="rm_q" value="<?= e($rmSearch) ?>" placeholder="🔍 Suche"
class="form-input" style="width:170px; padding:0.3rem 0.6rem; font-size:0.8rem;">
<button type="submit" class="btn btn-sm">Filtern</button>
<a href="index.php#tab-roadmap" class="btn btn-sm btn-secondary">Zurücksetzen</a>
</form>
</div>
<?php if ($roadmapOrphans !== []): ?>
<!--
Ein Bereich, den ein Item nennt und den es in
roadmap_epics nicht gibt. Meist ein Tippfehler - und
der erzeugt sonst lautlos einen weiteren Bereich, der
in keiner Uebersicht auftaucht.
-->
<div class="card" style="padding:0.9rem 1.15rem; border-left:3px solid var(--danger);">
<strong style="font-size:0.9rem;">⚠️ Unbekannte Themenbereiche</strong>
<div style="margin-top:0.5rem; display:flex; flex-wrap:wrap; gap:0.5rem;">
<?php foreach ($roadmapOrphans as $orphan): ?>
<span class="badge badge-down">
<?= e((string)$orphan['project_slug']) ?> / <?= e((string)$orphan['epic']) ?>
&middot; <?= (int)$orphan['items'] ?> Eintrag/Einträge
</span>
<?php endforeach; ?>
</div>
<div style="margin-top:0.5rem; font-size:0.8rem; color:var(--text-muted);">
Diese Kürzel stehen an Items, sind aber nicht angelegt. Unter
„Themenbereiche“ anlegen oder die Items umhängen.
</div>
</div>
<?php endif; ?>
<?php if ($roadmapNext !== []): ?>
<div class="card">
<div class="card-header">
<h2 class="card-title">🎯 Als Nächstes</h2>
</div>
<!--
Diese Liste stand frueher von Hand oben in
ROADMAP.md und veraltete zuverlaessig. Sie ergibt
sich jetzt aus Reihenfolge und Status.
-->
<table>
<thead>
<tr><th style="width:40px;">#</th><th>Vorhaben</th><th>Bereich</th><th>Stand</th><th>Ziel</th><th>Wer</th></tr>
</thead>
<tbody>
<?php foreach ($roadmapNext as $i => $item): ?>
<tr>
<td style="color:var(--text-muted);"><?= $i + 1 ?>.</td>
<td>
<span style="font-family:'Roboto Mono'; color:var(--text-muted); font-size:0.8rem;">#<?= (int)$item['id'] ?></span>
<strong style="margin-left:0.35rem;"><?= e((string)$item['title']) ?></strong>
<?php if ($rmProject === 'all'): ?>
<span class="badge badge-stopped" style="margin-left:0.4rem;"><?= e((string)$item['project_slug']) ?></span>
<?php endif; ?>
</td>
<td><?= e((string)$item['epic']) ?></td>
<td><?= rmBadge((string)$item['status']) ?></td>
<td><?= $item['target_build'] ? '<code>' . e((string)$item['target_build']) . '</code>' : '—' ?></td>
<td><?= $item['claimed_by'] ? e((string)$item['claimed_by']) : '<span style="color:var(--text-muted);">frei</span>' ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
<div class="card">
<div class="card-header">
<h2 class="card-title">🤖 Wer arbeitet gerade woran</h2>
</div>
<?php if ($roadmapActive === []): ?>
<p style="color:var(--text-muted); padding:0.75rem 0;">
Zurzeit hält kein Agent ein Item. Ohne aktive Lease ist jedes
Vorhaben frei — das ist der Normalzustand zwischen zwei Läufen.
</p>
<?php else: ?>
<table>
<thead>
<tr><th>Item</th><th>Projekt</th><th>Bereich</th><th>Stand</th><th>Agent</th><th>Seit</th><th>Lease läuft</th></tr>
</thead>
<tbody>
<?php foreach ($roadmapActive as $work): ?>
<tr>
<td>
<span style="font-family:'Roboto Mono'; color:var(--text-muted); font-size:0.8rem;">#<?= (int)$work['id'] ?></span>
<span style="margin-left:0.35rem;"><?= e((string)$work['title']) ?></span>
</td>
<td><span class="badge badge-stopped"><?= e((string)$work['project_slug']) ?></span></td>
<td><?= $work['epic'] ? e((string)$work['epic']) : '<span style="color:var(--text-muted);">—</span>' ?></td>
<td><?= rmBadge((string)$work['status']) ?></td>
<td><strong><?= e((string)$work['claimed_by']) ?></strong></td>
<td style="color:var(--text-muted);"><?= e(dc_ago((string)$work['claimed_at'])) ?></td>
<td>
<?php $left = (int)$work['lease_minutes_left']; ?>
<span class="badge <?= $left < 15 ? 'badge-down' : 'badge-warning' ?>">
noch <?= $left ?> min
</span>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<div class="card">
<div class="card-header">
<h2 class="card-title">🗂️ Themenbereiche im Überblick</h2>
</div>
<table>
<thead>
<tr>
<th style="width:60px;">Kürzel</th><th>Bereich</th>
<?php if ($rmProject === 'all'): ?><th>Projekt</th><?php endif; ?>
<th style="width:220px;">Fortschritt</th>
<th style="width:90px;">📋</th><th style="width:90px;">🔨</th>
<th style="width:90px;">✅</th><th style="width:90px;">🧊 / ❌</th>
</tr>
</thead>
<tbody>
<?php foreach ($roadmapEpics as $rmE): ?>
<?php
$total = (int)$rmE['total'];
$done = (int)$rmE['done'];
// Zurueckgestelltes und Verworfenes zaehlt nicht in den
// Nenner: Ein Bereich, in dem die Haelfte verworfen wurde,
// ist nicht zur Haelfte fertig - er ist kleiner geworden.
$scope = $total - (int)$rmE['deferred'] - (int)$rmE['rejected'];
$percent = $scope > 0 ? (int)round($done / $scope * 100) : 0;
?>
<tr>
<td><strong style="font-family:'Roboto Mono';"><?= e((string)$rmE['epic']) ?></strong></td>
<td>
<a href="<?= e(rmUrl(['rm_epic' => (string)$rmE['epic'], 'rm_project' => (string)$rmE['project_slug']])) ?>">
<?= e((string)$rmE['title']) ?>
</a>
<?php if (!empty($rmE['summary'])): ?>
<div style="font-size:0.78rem; color:var(--text-muted); margin-top:0.15rem;">
<?= e((string)$rmE['summary']) ?>
</div>
<?php endif; ?>
</td>
<?php if ($rmProject === 'all'): ?>
<td><span class="badge badge-stopped"><?= e((string)$rmE['project_slug']) ?></span></td>
<?php endif; ?>
<td>
<div style="background:rgba(255,255,255,0.06); border-radius:4px; height:8px; overflow:hidden;">
<div style="width:<?= $percent ?>%; height:100%; background:var(--success);"></div>
</div>
<span style="font-size:0.75rem; color:var(--text-muted);">
<?= $done ?> / <?= $scope ?> <?= $scope > 0 ? '(' . $percent . '%)' : '' ?>
</span>
</td>
<td><?= (int)$rmE['pending'] ?></td>
<td><?= (int)$rmE['working'] ?></td>
<td><?= $done ?></td>
<td style="color:var(--text-muted);"><?= (int)$rmE['deferred'] ?> / <?= (int)$rmE['rejected'] ?></td>
</tr>
<?php endforeach; ?>
<?php if ($roadmapEpics === []): ?>
<tr><td colspan="8" style="color:var(--text-muted); padding:1rem;">
Noch keine Themenbereiche angelegt. Unter „Themenbereiche“ anfangen.
</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<!-- =============== Subtab: Alle Vorhaben =============== -->
<div id="sub-roadmap-list" class="subtab-content">
<div class="card">
<div class="card-header" style="display:flex; flex-wrap:wrap; justify-content:space-between; align-items:center; gap:0.75rem;">
<h2 class="card-title" style="margin:0;">
🗺️ Vorhaben
<span class="badge badge-stopped" style="margin-left:0.5rem;"><?= (int)$roadmapTotal ?> Treffer</span>
</h2>
<span style="font-size:0.8rem; color:var(--text-muted);">
Filter oben unter „Übersicht“ — sie gelten für beide Ansichten.
</span>
</div>
<table>
<thead>
<tr>
<th style="width:60px;">Rang</th>
<th style="width:70px;">Item</th>
<th>Vorhaben</th>
<th style="width:90px;">Bereich</th>
<th style="width:150px;">Stand</th>
<th style="width:100px;">Ziel</th>
<th style="width:120px;">Wer</th>
<th style="width:60px;">Doku</th>
<th style="width:120px;"></th>
</tr>
</thead>
<tbody>
<?php $rmLastEpic = null; ?>
<?php foreach ($roadmapItems as $item): ?>
<?php
$itemEpic = (string)($item['epic'] ?? '');
// Der Schluessel traegt das Projekt mit: Ueber alle
// Projekte hinweg gibt es "A" mehrfach, und zwei
// gleichnamige Bereiche duerfen nicht unter einer
// Ueberschrift landen.
$epicKey = $item['project_slug'] . '/' . $itemEpic;
?>
<?php if ($epicKey !== $rmLastEpic): ?>
<tr style="background:rgba(255,255,255,0.02);">
<td colspan="9" style="padding:0.55rem 0.75rem; font-weight:700; font-size:0.82rem;">
<?= e($itemEpic) ?> — <?= e($roadmapEpicTitles[$epicKey] ?? 'Unbekannter Bereich') ?>
<?php if ($rmProject === 'all'): ?>
<span class="badge badge-stopped" style="margin-left:0.4rem;"><?= e((string)$item['project_slug']) ?></span>
<?php endif; ?>
</td>
</tr>
<?php $rmLastEpic = $epicKey; ?>
<?php endif; ?>
<tr>
<td style="font-family:'Roboto Mono'; color:var(--text-muted);">
<?= $item['roadmap_rank'] !== null ? (int)$item['roadmap_rank'] : '—' ?>
</td>
<td style="font-family:'Roboto Mono'; font-size:0.8rem;">#<?= (int)$item['id'] ?></td>
<td>
<strong><?= e((string)$item['title']) ?></strong>
<?php if ($rmProject === 'all'): ?>
<span class="badge badge-stopped" style="margin-left:0.4rem;"><?= e((string)$item['project_slug']) ?></span>
<?php endif; ?>
<?php if ((string)$item['status'] === 'deferred' && !empty($item['defer_trigger'])): ?>
<div style="font-size:0.78rem; color:var(--text-muted); margin-top:0.2rem;">
🧊 Auslöser: <?= e((string)$item['defer_trigger']) ?>
</div>
<?php endif; ?>
</td>
<td><?= e($itemEpic) ?></td>
<td><?= rmBadge((string)$item['status']) ?></td>
<td><?= $item['target_build'] ? '<code>' . e((string)$item['target_build']) . '</code>' : '—' ?></td>
<td><?= $item['claimed_by'] ? e((string)$item['claimed_by']) : '<span style="color:var(--text-muted);">frei</span>' ?></td>
<td style="text-align:center;">
<?php if (!empty($item['doc_anchor'])): ?>
<span title="<?= e((string)$item['doc_anchor']) ?>">📄</span>
<?php else: ?>
<span style="color:var(--text-muted);" title="Keine Begründung hinterlegt">—</span>
<?php endif; ?>
</td>
<td style="white-space:nowrap;">
<form method="POST" action="index.php#tab-roadmap" style="display:inline">
<?= $csrfField ?>
<input type="hidden" name="action" value="rm_move">
<input type="hidden" name="item_id" value="<?= (int)$item['id'] ?>">
<input type="hidden" name="direction" value="up">
<button type="submit" class="btn btn-sm btn-secondary" title="Nach oben">▲</button>
</form>
<form method="POST" action="index.php#tab-roadmap" style="display:inline">
<?= $csrfField ?>
<input type="hidden" name="action" value="rm_move">
<input type="hidden" name="item_id" value="<?= (int)$item['id'] ?>">
<input type="hidden" name="direction" value="down">
<button type="submit" class="btn btn-sm btn-secondary" title="Nach unten">▼</button>
</form>
<button type="button" class="btn btn-sm btn-secondary"
onclick='openRoadmapEdit(<?= htmlspecialchars(json_encode([
"id" => (int)$item["id"],
"title" => (string)$item["title"],
"epic" => $itemEpic,
"roadmap_rank" => $item["roadmap_rank"],
"target_build" => (string)($item["target_build"] ?? ""),
"defer_trigger" => (string)($item["defer_trigger"] ?? ""),
"doc_anchor" => (string)($item["doc_anchor"] ?? ""),
"status" => (string)$item["status"],
], JSON_UNESCAPED_UNICODE), ENT_QUOTES) ?>)'>✏️</button>
</td>
</tr>
<?php endforeach; ?>
<?php if ($roadmapItems === []): ?>
<tr><td colspan="9" style="color:var(--text-muted); padding:1rem;">
Keine Vorhaben mit diesen Filtern. Unter „Aufnahmeliste“ stehen die
Einträge, die noch keinen Bereich haben.
</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<!-- =============== Subtab: Aufnahmeliste =============== -->
<div id="sub-roadmap-intake" class="subtab-content">
<div class="card">
<div class="card-header">
<h2 class="card-title">➕ Noch nicht auf der Roadmap</h2>
</div>
<p style="color:var(--text-muted); font-size:0.85rem; margin-bottom:0.9rem;">
Offene Feature-Requests ohne Themenbereich. Ein Vorhaben steht auf der
Roadmap, sobald es einen Bereich hat — ein zweites Kennzeichen daneben
gibt es bewusst nicht, sonst könnten beide auseinanderlaufen.
</p>
<table>
<thead>
<tr><th style="width:70px;">Item</th><th>Titel</th><th style="width:110px;">Projekt</th>
<th style="width:110px;">Schweregrad</th><th style="width:110px;">Gemeldet</th><th style="width:90px;"></th></tr>
</thead>
<tbody>
<?php foreach ($roadmapCandidates as $cand): ?>
<tr>
<td style="font-family:'Roboto Mono'; font-size:0.8rem;">#<?= (int)$cand['id'] ?></td>
<td><?= e((string)$cand['title']) ?></td>
<td><span class="badge badge-stopped"><?= e((string)$cand['project_slug']) ?></span></td>
<td><?= e((string)$cand['severity']) ?></td>
<td style="color:var(--text-muted);"><?= e(dc_ago((string)$cand['first_seen_at'])) ?></td>
<td>
<button type="button" class="btn btn-sm"
onclick='openRoadmapEdit(<?= htmlspecialchars(json_encode([
"id" => (int)$cand["id"],
"title" => (string)$cand["title"],
"epic" => "",
"roadmap_rank" => null,
"target_build" => "",
"defer_trigger" => "",
"doc_anchor" => "",
"status" => (string)$cand["status"],
], JSON_UNESCAPED_UNICODE), ENT_QUOTES) ?>)'>Aufnehmen</button>
</td>
</tr>
<?php endforeach; ?>
<?php if ($roadmapCandidates === []): ?>
<tr><td colspan="6" style="color:var(--text-muted); padding:1rem;">
Nichts offen ohne Bereich — die Roadmap ist vollständig.
</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<!-- =============== Subtab: Themenbereiche =============== -->
<div id="sub-roadmap-epics" class="subtab-content">
<div class="card">
<div class="card-header">
<h2 class="card-title">🗂️ Themenbereich anlegen oder ändern</h2>
</div>
<!--
Der Bereichstitel steht hier und nicht an jedem Item:
sonst waere er in jeder Zeile anders schreibbar, und
drei Tippfehler ergaeben drei Bereiche.
-->
<form method="POST" action="index.php#sub-roadmap-epics">
<?= $csrfField ?>
<input type="hidden" name="action" value="rm_save_epic">
<div class="form-grid">
<div class="form-group">
<label class="form-label">Projekt</label>
<select name="project_slug" class="form-input" required>
<?php foreach ($projects as $p): ?>
<option value="<?= e($p['slug']) ?>" <?= $rmProject === $p['slug'] ? 'selected' : '' ?>>
<?= e($p['name']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label class="form-label">Kürzel (z. B. A, B, INFRA)</label>
<input type="text" name="epic" class="form-input" maxlength="24" required>
</div>
<div class="form-group">
<label class="form-label">Titel</label>
<input type="text" name="title" class="form-input" maxlength="160" required>
</div>
<div class="form-group">
<label class="form-label">Reihenfolge</label>
<input type="number" name="sort_order" class="form-input" value="100">
</div>
</div>
<div class="form-group" style="margin-top:0.9rem;">
<label class="form-label">Kurzbeschreibung (optional)</label>
<input type="text" name="summary" class="form-input"
placeholder="Wofür steht dieser Bereich? Ein Satz genügt.">
</div>
<button type="submit" class="btn" style="margin-top:1rem;">Bereich speichern</button>
</form>
</div>
<div class="card">
<div class="card-header"><h2 class="card-title">Vorhandene Bereiche</h2></div>
<table>
<thead>
<tr><th style="width:80px;">Kürzel</th><th>Titel</th><th style="width:120px;">Projekt</th>
<th style="width:90px;">Einträge</th><th style="width:90px;">Sortierung</th><th style="width:110px;"></th></tr>
</thead>
<tbody>
<?php foreach ($roadmapEpics as $rmE): ?>
<tr>
<td><strong style="font-family:'Roboto Mono';"><?= e((string)$rmE['epic']) ?></strong></td>
<td>
<?= e((string)$rmE['title']) ?>
<?php if (!empty($rmE['summary'])): ?>
<div style="font-size:0.78rem; color:var(--text-muted);"><?= e((string)$rmE['summary']) ?></div>
<?php endif; ?>
</td>
<td><span class="badge badge-stopped"><?= e((string)$rmE['project_slug']) ?></span></td>
<td><?= (int)$rmE['total'] ?></td>
<td style="color:var(--text-muted);"><?= (int)$rmE['sort_order'] ?></td>
<td>
<?php if ((int)$rmE['total'] === 0): ?>
<form method="POST" action="index.php#sub-roadmap-epics" style="display:inline"
onsubmit="return confirm('Bereich <?= e((string)$rmE['epic']) ?> löschen?');">
<?= $csrfField ?>
<input type="hidden" name="action" value="rm_delete_epic">
<input type="hidden" name="project_slug" value="<?= e((string)$rmE['project_slug']) ?>">
<input type="hidden" name="epic" value="<?= e((string)$rmE['epic']) ?>">
<button type="submit" class="btn btn-sm btn-danger">Löschen</button>
</form>
<?php else: ?>
<span style="font-size:0.78rem; color:var(--text-muted);">hat Einträge</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<!-- =============== Dialog: Roadmap-Angaben =============== -->
<div id="roadmapEditModal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.75); backdrop-filter:blur(8px); z-index:9999; align-items:center; justify-content:center; padding:1rem;">
<div class="card" style="max-width:720px; width:100%; max-height:90vh; overflow-y:auto; margin:0;">
<div class="card-header">
<h2 class="card-title">Roadmap-Angaben zu <span id="rmEditLabel"></span></h2>
</div>
<form method="POST" action="index.php#tab-roadmap">
<?= $csrfField ?>
<input type="hidden" name="action" value="rm_set">
<input type="hidden" name="item_id" id="rmEditId">
<div class="form-grid">
<div class="form-group">
<label class="form-label">Themenbereich</label>
<select name="epic" id="rmEditEpic" class="form-input">
<option value="">— kein Bereich (nicht auf der Roadmap) —</option>
<?php foreach ($roadmapEpics as $rmE): ?>
<option value="<?= e((string)$rmE['epic']) ?>">
<?= e((string)$rmE['epic']) ?> — <?= e((string)$rmE['title']) ?>
(<?= e((string)$rmE['project_slug']) ?>)
</option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label class="form-label">Rang (leer = ans Ende)</label>
<input type="number" name="roadmap_rank" id="rmEditRank" class="form-input">
</div>
<div class="form-group">
<label class="form-label">Zielfassung</label>
<input type="text" name="target_build" id="rmEditBuild" class="form-input" placeholder="z. B. 2.7.0">
</div>
<div class="form-group">
<label class="form-label">Status</label>
<select name="status" id="rmEditStatus" class="form-input" onchange="rmToggleTrigger()">
<?php foreach (BugRepo::STATUSES as $st): ?>
<option value="<?= e($st) ?>">
<?= e((Roadmap::MARKERS[$st] ?? '·') . ' ' . (Roadmap::STATUS_LABELS[$st] ?? $st)) ?>
</option>
<?php endforeach; ?>
</select>
</div>
</div>
<div class="form-group" style="margin-top:0.9rem;">
<label class="form-label">
Wo steht die Begründung? (Pfad und Anker in der Projektdatei)
</label>
<input type="text" name="doc_anchor" id="rmEditAnchor" class="form-input"
placeholder="docs/ROADMAP.md#142-symbol-upload">
</div>
<div class="form-group" id="rmTriggerGroup" style="margin-top:0.9rem;">
<label class="form-label">
🧊 Auslöser — woran erkennt man, dass „später“ jetzt ist?
</label>
<textarea name="defer_trigger" id="rmEditTrigger" class="form-input" rows="2"
placeholder="z. B.: sobald Logauszüge strukturiert hereinkommen"></textarea>
<div style="font-size:0.78rem; color:var(--text-muted); margin-top:0.3rem;">
Pflicht beim Zurückstellen. Ohne Auslöser ist ein Vorhaben nicht
zurückgestellt, sondern vergessen.
</div>
</div>
<div style="display:flex; justify-content:flex-end; gap:0.75rem; margin-top:1.2rem;">
<button type="button" class="btn btn-secondary" onclick="closeRoadmapEdit()">Abbrechen</button>
<button type="submit" class="btn">Speichern</button>
</div>
</form>
</div>
</div>
</div>
<!-- ================= MODULE: BUGTRACKER & FEATURE-TRACKER ================= -->
<div id="tab-bugtracker" class="tab-content">
@@ -3704,6 +4514,13 @@ chmod +x update-agent
// Filtereintrag wären sie nur unter "Alle Status" zu
// finden - ein Eingang, den niemand sieht, ist keiner.
'triage_external' => '📥 Fremdmeldungen',
// Die Nachpruefung braucht einen eigenen Einstieg:
// Wer wissen will, welche Fixes noch unbestaetigt
// sind, findet sie sonst nur unter "Alle Status"
// zwischen allem anderen.
'awaiting_verification,in_verification'
=> '🔎 In Nachprüfung',
'fix_insufficient' => '❗ Fix trägt nicht',
];
foreach ($statusOptions as $value => $label): ?>
<option value="<?= e($value) ?>" <?= $btFilters['status'] === $value ? 'selected' : '' ?>><?= e($label) ?></option>
@@ -3775,6 +4592,9 @@ chmod +x update-agent
'resolved' => '<span class="badge badge-up">GELÖST</span>',
'rejected' => '<span class="badge badge-stopped">ABGELEHNT</span>',
'triage_external' => '<span class="badge" style="background:rgba(120,160,255,0.2); color:#7aa2ff; border:1px solid #7aa2ff;">📥 FREMDMELDUNG</span>',
'awaiting_verification' => '<span class="badge" style="background:rgba(0,210,255,0.18); color:#5ce1e6; border:1px solid #5ce1e6;">🔎 WARTET AUF PRÜFUNG</span>',
'in_verification' => '<span class="badge" style="background:rgba(0,210,255,0.3); color:#5ce1e6; border:1px solid #5ce1e6;">🔎 WIRD GEPRÜFT</span>',
'fix_insufficient' => '<span class="badge badge-down" style="font-weight:bold;">❗ FIX TRÄGT NICHT</span>',
default => '<span class="badge badge-stopped">' . e(strtoupper((string)$item['status'])) . '</span>',
};
@@ -4585,6 +5405,7 @@ Antwort:
'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'],
'license:dev' => ['🧪 DEV-Lizenz abholen', 'Befristeten Testschlüssel selbst ausstellen — nur für Werkbank-Token auf Testmaschinen'],
];
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;">
@@ -5009,6 +5830,12 @@ SYSTEM
{ id: 'sub-update-guard', label: '🔒 Zugangsschutz' },
{ id: 'sub-update-publish', label: '➕ Release Veröffentlichen' }
],
'roadmap': [
{ id: 'sub-roadmap-board', label: '🗺️ Übersicht', active: true },
{ id: 'sub-roadmap-list', label: '📋 Alle Vorhaben' },
{ id: 'sub-roadmap-intake', label: '➕ Aufnahmeliste' },
{ id: 'sub-roadmap-epics', label: '🗂️ Themenbereiche' }
],
'bugtracker': [
{ id: 'sub-bugtracker-items', label: '🐛 Bugs & Features', active: true },
{ id: 'sub-bugtracker-errors', label: '⚡ Fehler-Stream' },
@@ -5191,6 +6018,51 @@ SYSTEM
// GET-Parameter - das skaliert, ueberlebt den Auto-Refresh und laesst
// sich als Link teilen.
// ---------------------------------------------------------------
// Roadmap
// ---------------------------------------------------------------
/**
* Oeffnet den Dialog fuer die Roadmap-Angaben eines Items.
*
* Derselbe Dialog dient dem Aufnehmen (Bereich setzen) und dem
* Aendern. Ein eigener Aufnahmedialog waere ein zweites Formular fuer
* dieselben vier Felder gewesen.
*/
function openRoadmapEdit(item) {
document.getElementById('rmEditId').value = item.id;
document.getElementById('rmEditLabel').innerText = '#' + item.id + ' — ' + item.title;
document.getElementById('rmEditEpic').value = item.epic || '';
document.getElementById('rmEditRank').value = item.roadmap_rank === null ? '' : item.roadmap_rank;
document.getElementById('rmEditBuild').value = item.target_build || '';
document.getElementById('rmEditAnchor').value = item.doc_anchor || '';
document.getElementById('rmEditTrigger').value = item.defer_trigger || '';
document.getElementById('rmEditStatus').value = item.status;
rmToggleTrigger();
document.getElementById('roadmapEditModal').style.display = 'flex';
}
function closeRoadmapEdit() {
document.getElementById('roadmapEditModal').style.display = 'none';
}
/**
* Der Ausloeser ist nur beim Zurueckstellen von Belang.
*
* Das Feld bleibt sichtbar, wird aber hervorgehoben und als Pflicht
* markiert - ausblenden waere schlechter, weil ein bereits
* hinterlegter Ausloeser dann unsichtbar mitliefe.
*/
function rmToggleTrigger() {
const deferred = document.getElementById('rmEditStatus').value === 'deferred';
const group = document.getElementById('rmTriggerGroup');
const field = document.getElementById('rmEditTrigger');
group.style.opacity = deferred ? '1' : '0.55';
field.required = deferred;
}
// Open Bugtracker Edit Modal
function openEditBugtrackerModal(item) {
document.getElementById('btEditItemId').value = item.id;
@@ -5834,6 +6706,9 @@ SYSTEM
<option value="closed">Geschlossen</option>
<option value="rejected">Abgelehnt</option>
<option value="triage_external">📥 Fremdmeldung (ungesichtet)</option>
<option value="awaiting_verification">🔎 Wartet auf Nachprüfung</option>
<option value="in_verification">🔎 Wird gerade geprüft</option>
<option value="fix_insufficient">❗ Fix trägt nicht</option>
</select>
</div>
<div class="form-group">