fix(webui): Ansichtsrettung suchte am falschen Ort, Suche findet Nummern

#288 war mit dem Transport allein nicht behoben. Die Ansichtsrettung fragte
dokumentweit nach der offenen Unteransicht:

    document.querySelector('.subtab-content.active')

Im Markup tragen aber ACHT Unteransichten diese Klasse - jedes Modul hat seine
eigene Startansicht, und switchMainTab() raeumt nur innerhalb des Moduls auf,
das es oeffnet. Zurueck kam deshalb immer die erste in Dokumentreihenfolge
(sub-projects-list), voellig unabhaengig davon, was gerade sichtbar war. Wer im
Bugtracker filterte, waere also auf der Projektverwaltung gelandet.

Dass die Rettung fuer POST-Formulare zu funktionieren schien, lag an den
wenigen, die ihren Anker fest in der action stehen haben. Gesucht wird jetzt
erst das aktive Modul und darin die Unteransicht.

Zusaetzlich tragen die drei Filterformulare das Feld "view" fest im Markup.
Dort ist ohne Zweifel bekannt, wo sie stehen - das haengt an keiner DOM-Abfrage
mehr. Und restoreViewFromHash() faellt bei einer unbekannten Unteransicht auf
das Modul zurueck, statt wortlos im Dashboard zu landen.

Nachgewiesen an einer DOM-Probe, die die Modulstruktur aus index.php nachbaut
und die echten Funktionen darauf laufen laesst: alte Fassung -> tab-projects,
neue Fassung -> sub-bugtracker-items.

Ausserdem, aus derselben Sitzung gemeldet: Die Suche findet Eintraege jetzt
ueber ihre Nummer. Sie ist das, was ueberall zitiert wird, und stand in keiner
der durchsuchten Spalten - "288" lief ins Leere. "288" findet jetzt das Item
mit dieser Nummer und alles, was die Zahl im Text hat; "#288" ausschliesslich
dieses eine. Gilt fuer Bugtracker, Fehler-Stream und den Parameter search der
API, der bei der Gelegenheit auch in die OpenAPI-Beschreibung gekommen ist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Deploymentcenter Bot
2026-08-30 22:03:08 +02:00
co-authored by Claude Opus 5
parent 0c1d269665
commit c9c821a44a
6 changed files with 140 additions and 12 deletions
+1
View File
@@ -374,6 +374,7 @@ $spec = [
['name' => 'severity', 'in' => 'query', 'schema' => ['type' => 'string'], 'description' => 'Mehrere kommagetrennt'], ['name' => 'severity', 'in' => 'query', 'schema' => ['type' => 'string'], 'description' => 'Mehrere kommagetrennt'],
['name' => 'target_agent', 'in' => 'query', 'schema' => ['type' => 'string']], ['name' => 'target_agent', 'in' => 'query', 'schema' => ['type' => 'string']],
['name' => 'unclaimed_only', 'in' => 'query', 'schema' => ['type' => 'boolean']], ['name' => 'unclaimed_only', 'in' => 'query', 'schema' => ['type' => 'boolean']],
['name' => 'search', 'in' => 'query', 'schema' => ['type' => 'string'], 'description' => 'Volltext ueber Titel, Beschreibung, Fehlermeldung, Tags, Dateipfad. Eine Zahl findet zusaetzlich das Item mit dieser Nummer; mit vorangestellter Raute (#288) ausschliesslich dieses eine.'],
['name' => 'updated_since', 'in' => 'query', 'schema' => ['type' => 'string', 'format' => 'date-time'], 'description' => 'Delta-Abfrage fuer Polling'], ['name' => 'updated_since', 'in' => 'query', 'schema' => ['type' => 'string', 'format' => 'date-time'], 'description' => 'Delta-Abfrage fuer Polling'],
['name' => 'order', 'in' => 'query', 'schema' => ['type' => 'string', 'enum' => ['newest', 'oldest', 'updated', 'severity', 'occurrences']]], ['name' => 'order', 'in' => 'query', 'schema' => ['type' => 'string', 'enum' => ['newest', 'oldest', 'updated', 'severity', 'occurrences']]],
['name' => 'limit', 'in' => 'query', 'schema' => ['type' => 'integer', 'default' => 100, 'maximum' => 500]], ['name' => 'limit', 'in' => 'query', 'schema' => ['type' => 'integer', 'default' => 100, 'maximum' => 500]],
+1 -1
View File
@@ -275,7 +275,7 @@ curl "https://dc.mhdf.de/api/bugtracker/v1/manage?action=list&project_slug=myapp
| `status`, `severity` | Mehrere Werte kommagetrennt | | `status`, `severity` | Mehrere Werte kommagetrennt |
| `type`, `environment`, `project_slug` | Einzelwert oder `all` | | `type`, `environment`, `project_slug` | Einzelwert oder `all` |
| `target_agent`, `claimed_by`, `push_id` | Exakte Übereinstimmung | | `target_agent`, `claimed_by`, `push_id` | Exakte Übereinstimmung |
| `search` | Volltext über Titel, Beschreibung, Fehlermeldung, Tags, Dateipfad | | `search` | Volltext über Titel, Beschreibung, Fehlermeldung, Tags, Dateipfad. Eine Zahl findet zusätzlich das Item mit dieser **Nummer**, `#288` ausschließlich dieses eine |
| `unclaimed_only` | `true` — nur Items, die kein Agent bearbeitet | | `unclaimed_only` | `true` — nur Items, die kein Agent bearbeitet |
| `updated_since` | ISO-8601 — **Delta-Abfrage für effizientes Polling** | | `updated_since` | ISO-8601 — **Delta-Abfrage für effizientes Polling** |
| `order` | `newest`, `oldest`, `updated`, `severity`, `occurrences` | | `order` | `newest`, `oldest`, `updated`, `severity`, `occurrences` |
+13 -1
View File
@@ -43,13 +43,25 @@
"area": "webui", "area": "webui",
"breaking": false, "breaking": false,
"title": "Filter und Formulare werfen nicht mehr aufs Dashboard zurueck", "title": "Filter und Formulare werfen nicht mehr aufs Dashboard zurueck",
"text": "Welche Ansicht sichtbar ist, hing allein am Anker der URL. Fuer POST-Formulare gab es dafuer laengst eine Loesung (_return und dc_redirect), die Filter fielen aber durch beide Netze: Es sind GET-Formulare, und ein GET-Formular verwirft beim Absenden den Anker seiner action; ausserdem loesten sie ueber form.submit() aus, was gar kein submit-Ereignis erzeugt. Nach jeder Filteraenderung stand man wieder im Dashboard und musste zurueckklicken - beim Filtern nach mehreren Parametern jedes Mal. Jetzt haengt der zentrale Zuhoerer die offene Ansicht bei GET als Parameter 'view' an den Query-String, ausgeloest wird ueber requestSubmit(), und beim Laden wird die Ansicht aus 'view' wiederhergestellt, wenn kein Anker da ist." "text": "Welche Ansicht sichtbar ist, hing allein am Anker der URL - und die Ansichtsrettung, die das auffangen sollte, griff aus zwei Gruenden nicht.\n\nErstens fragte sie dokumentweit nach der offenen Unteransicht. Im Markup tragen aber ACHT Unteransichten die Klasse \"active\": jedes Modul hat seine eigene Startansicht, und switchMainTab() raeumt nur innerhalb des Moduls auf, das es oeffnet. Zurueck kam deshalb immer die erste in Dokumentreihenfolge, unabhaengig davon, was gerade sichtbar war. Dass die Rettung trotzdem zu funktionieren schien, lag an den wenigen Formularen, die ihren Anker fest in der action stehen haben. Gesucht wird jetzt erst das aktive Modul und darin die Unteransicht.\n\nZweitens fielen die Filter durch das Netz: Es sind GET-Formulare, und ein GET-Formular verwirft beim Absenden den Anker seiner action; ausserdem loesten sie ueber form.submit() aus, was gar kein submit-Ereignis erzeugt. Der zentrale Zuhoerer haengt die offene Ansicht bei GET jetzt als Parameter \"view\" an den Query-String, ausgeloest wird ueber requestSubmit(), und beim Laden wird die Ansicht daraus wiederhergestellt. Die drei Filterformulare tragen das Feld zusaetzlich fest im Markup - dort ist ohne Zweifel bekannt, wo sie stehen, und das haengt an keiner DOM-Abfrage mehr."
}, },
{ {
"area": "api", "area": "api",
"breaking": false, "breaking": false,
"title": "/api/bugtracker/v1/roadmap ist jetzt auch ohne .php erreichbar", "title": "/api/bugtracker/v1/roadmap ist jetzt auch ohne .php erreichbar",
"text": "Jeder andere Endpunkt kommt ohne Dateiendung aus, dieser nicht: Die Route fehlte in der .htaccess, der Aufruf lief in ein 404, und die OpenAPI-Beschreibung fuehrte deshalb als einzigen Pfad einen mit .php. Wer dem Muster der uebrigen API folgte, lief auf. Die Route ist nachgetragen, die Beschreibung nennt jetzt den Pfad ohne Endung. Der alte Pfad mit .php funktioniert unveraendert weiter - er faellt auf die allgemeine Regel fuer /api/ zurueck." "text": "Jeder andere Endpunkt kommt ohne Dateiendung aus, dieser nicht: Die Route fehlte in der .htaccess, der Aufruf lief in ein 404, und die OpenAPI-Beschreibung fuehrte deshalb als einzigen Pfad einen mit .php. Wer dem Muster der uebrigen API folgte, lief auf. Die Route ist nachgetragen, die Beschreibung nennt jetzt den Pfad ohne Endung. Der alte Pfad mit .php funktioniert unveraendert weiter - er faellt auf die allgemeine Regel fuer /api/ zurueck."
},
{
"area": "webui",
"breaking": false,
"title": "Die Suche findet Eintraege ueber ihre Nummer",
"text": "Die Nummer ist das, was ueberall zitiert wird - in Commits, in Kommentaren, im Changelog - und war ausgerechnet ueber die Suche nicht auffindbar: Sie steht in keiner der durchsuchten Spalten. Wer \"288\" eingab, bekam nichts und musste die Liste durchblaettern. Jetzt sucht die Nummer mit, im Bugtracker wie im Fehler-Stream und ebenso ueber den Parameter search der API. Zwei Schreibweisen, zwei Bedeutungen: \"288\" findet das Item mit dieser Nummer UND alles, was die Zahl im Text stehen hat; \"#288\" ist eindeutig gemeint und liefert ausschliesslich dieses eine Item."
},
{
"area": "api",
"breaking": false,
"title": "Der Parameter search ist jetzt beschrieben",
"text": "action=list kennt search seit jeher, in der OpenAPI-Beschreibung stand er nicht. Nachgetragen, mitsamt dem Verhalten bei Zahlen."
} }
] ]
}, },
+51 -4
View File
@@ -4018,6 +4018,13 @@ chmod +x update-agent
<div class="card" style="padding:0.85rem 1.1rem;"> <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;"> <form method="GET" action="index.php" style="display:flex; flex-wrap:wrap; gap:0.45rem; align-items:center;">
<!-- Die offene Ansicht faehrt als verstecktes Feld mit. Ein GET-Formular
verwirft beim Absenden den Anker seiner action, und der Anker war
bisher die einzige Quelle dafuer, was angezeigt wird - nach jeder
Filteraenderung stand man wieder im Dashboard. Fest im Markup statt
aus dem DOM gelesen: hier ist ohne Zweifel bekannt, wo das Formular
steht. -->
<input type="hidden" name="view" value="sub-roadmap-board">
<select name="rm_project" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="dcSubmit(this.form)"> <select name="rm_project" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="dcSubmit(this.form)">
<option value="all">🌐 Alle Projekte</option> <option value="all">🌐 Alle Projekte</option>
<?php foreach ($projects as $p): ?> <?php foreach ($projects as $p): ?>
@@ -4641,6 +4648,13 @@ chmod +x update-agent
und ging bei jedem Auto-Refresh verloren. und ging bei jedem Auto-Refresh verloren.
--> -->
<form method="GET" action="index.php" style="display:flex; flex-wrap:wrap; gap:0.4rem; align-items:center;"> <form method="GET" action="index.php" style="display:flex; flex-wrap:wrap; gap:0.4rem; align-items:center;">
<!-- Die offene Ansicht faehrt als verstecktes Feld mit. Ein GET-Formular
verwirft beim Absenden den Anker seiner action, und der Anker war
bisher die einzige Quelle dafuer, was angezeigt wird - nach jeder
Filteraenderung stand man wieder im Dashboard. Fest im Markup statt
aus dem DOM gelesen: hier ist ohne Zweifel bekannt, wo das Formular
steht. -->
<input type="hidden" name="view" value="sub-bugtracker-items">
<select name="bt_project" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="dcSubmit(this.form)"> <select name="bt_project" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="dcSubmit(this.form)">
<option value="all">🌐 Alle Projekte</option> <option value="all">🌐 Alle Projekte</option>
<?php foreach ($projects as $p): ?> <?php foreach ($projects as $p): ?>
@@ -4735,7 +4749,8 @@ chmod +x update-agent
</select> </select>
<input type="text" name="bt_q" value="<?= e($btFilters['search']) ?>" class="form-input" <input type="text" name="bt_q" value="<?= e($btFilters['search']) ?>" class="form-input"
style="width:170px; padding:0.3rem 0.6rem; font-size:0.8rem;" placeholder="🔍 Suchen..."> style="width:190px; padding:0.3rem 0.6rem; font-size:0.8rem;" placeholder="🔍 Suchen oder #288"
title="Volltext ueber Titel, Beschreibung, Fehlermeldung, Tags und Dateipfad. Eine Zahl findet zusaetzlich das Item mit dieser Nummer; mit Raute (#288) nur dieses eine.">
<button type="submit" class="btn btn-sm">Filtern</button> <button type="submit" class="btn btn-sm">Filtern</button>
<?php if ($btFilters['search'] !== '' || $btFilters['project_slug'] !== 'all' || $btFilters['status'] !== 'all' || $btFilters['severity'] !== 'all' || $btFilters['type'] !== 'all' || $btFilters['environment'] !== 'all'): ?> <?php if ($btFilters['search'] !== '' || $btFilters['project_slug'] !== 'all' || $btFilters['status'] !== 'all' || $btFilters['severity'] !== 'all' || $btFilters['type'] !== 'all' || $btFilters['environment'] !== 'all'): ?>
@@ -5026,6 +5041,13 @@ chmod +x update-agent
</h2> </h2>
<form method="GET" action="index.php" style="display:flex; flex-wrap:wrap; gap:0.4rem; align-items:center;"> <form method="GET" action="index.php" style="display:flex; flex-wrap:wrap; gap:0.4rem; align-items:center;">
<!-- Die offene Ansicht faehrt als verstecktes Feld mit. Ein GET-Formular
verwirft beim Absenden den Anker seiner action, und der Anker war
bisher die einzige Quelle dafuer, was angezeigt wird - nach jeder
Filteraenderung stand man wieder im Dashboard. Fest im Markup statt
aus dem DOM gelesen: hier ist ohne Zweifel bekannt, wo das Formular
steht. -->
<input type="hidden" name="view" value="sub-bugtracker-errors">
<select name="err_project" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="dcSubmit(this.form)"> <select name="err_project" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="dcSubmit(this.form)">
<option value="all">🌐 Alle Projekte</option> <option value="all">🌐 Alle Projekte</option>
<?php foreach ($projects as $p): ?> <?php foreach ($projects as $p): ?>
@@ -5089,7 +5111,8 @@ chmod +x update-agent
</select> </select>
<input type="text" name="err_q" value="<?= e($errFilters['search']) ?>" class="form-input" <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..."> style="width:190px; padding:0.3rem 0.6rem; font-size:0.8rem;" placeholder="🔍 Suchen oder #288"
title="Volltext ueber Titel, Beschreibung, Fehlermeldung, Tags und Dateipfad. Eine Zahl findet zusaetzlich das Item mit dieser Nummer; mit Raute (#288) nur dieses eine.">
<button type="submit" class="btn btn-sm">Filtern</button> <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> <a href="index.php#sub-bugtracker-errors" class="btn btn-sm btn-secondary">Zurücksetzen</a>
</form> </form>
@@ -6260,9 +6283,24 @@ SYSTEM
* Absenden aus einem onchange ueberhaupt ein submit-Ereignis entsteht. * Absenden aus einem onchange ueberhaupt ein submit-Ereignis entsteht.
*/ */
function dcCurrentView() { function dcCurrentView() {
const activeSub = document.querySelector('.subtab-content.active'); // Erst das Modul, dann die Unteransicht DARIN. Die Reihenfolge ist
// der Kern der Sache: Im Markup tragen ACHT Unteransichten die
// Klasse "active" - jedes Modul hat seine eigene Startansicht, und
// switchMainTab() raeumt nur innerhalb des Moduls auf, das es
// oeffnet. Ein dokumentweites querySelector('.subtab-content.active')
// liefert deshalb immer die erste in Dokumentreihenfolge
// (sub-projects-list), voellig unabhaengig davon, was gerade
// sichtbar ist. Genau daran ist die Ansichtsrettung fuer POST seit
// jeher gescheitert - und der Anschein, sie funktioniere, kam von
// den wenigen Formularen, die ihren Anker fest in der action
// stehen haben.
const activeTab = document.querySelector('.main-content > .tab-content.active'); const activeTab = document.querySelector('.main-content > .tab-content.active');
return activeSub ? activeSub.id : (activeTab ? activeTab.id : ''); if (!activeTab) {
return '';
}
const activeSub = activeTab.querySelector('.subtab-content.active');
return activeSub ? activeSub.id : activeTab.id;
} }
/** /**
@@ -6995,6 +7033,15 @@ SYSTEM
const moduleName = moduleOfSubtab(hash); const moduleName = moduleOfSubtab(hash);
if (!moduleName) { if (!moduleName) {
// Unbekannte Unteransicht - dann wenigstens das Modul, wenn es
// sich aus dem Namen ergibt. Wortlos im Dashboard zu landen ist
// die schlechteste aller Antworten: Der Nutzer sieht nicht,
// dass etwas schiefging, sondern nur, dass er woanders ist.
const rest = hash.substring(4);
const guess = Object.keys(subnavItems).find(m => rest.startsWith(m + '-'));
if (guess) {
switchMainTab(guess, null, null, false);
}
return; return;
} }
+33 -6
View File
@@ -1121,16 +1121,43 @@ final class BugRepo
// Volltextsuche. Jede Spalte bekommt einen eigenen Platzhalter - // Volltextsuche. Jede Spalte bekommt einen eigenen Platzhalter -
// derselbe Name mehrfach ist bei nativen Prepared Statements unzulaessig. // derselbe Name mehrfach ist bei nativen Prepared Statements unzulaessig.
//
// Die Nummer sucht mit. Sie ist das, was ueberall zitiert wird - in
// Commits, in Kommentaren, im Changelog -, und war ausgerechnet ueber
// die Suche nicht auffindbar: Sie steht in keiner der durchsuchten
// Spalten. Wer "288" eingab, bekam nichts und musste die Liste
// durchblaettern.
//
// Zwei Schreibweisen, zwei Bedeutungen:
// #288 - eindeutig gemeint ist dieses eine Item. Nur die ID.
// 288 - kann auch im Text vorkommen. ID ODER Volltext.
$search = $filters['search'] ?? null; $search = $filters['search'] ?? null;
if (is_string($search) && trim($search) !== '') { if (is_string($search) && trim($search) !== '') {
$needle = '%' . trim($search) . '%'; $roh = trim($search);
$columns = ['title', 'description', 'error_message', 'push_id', 'target_agent', 'tags', 'file_path']; $text = ltrim($roh, '#');
// Die Laengengrenze haelt die Umwandlung nach int im Rahmen; eine
// 30-stellige Zahl waere ohnehin keine Item-Nummer.
$istNummer = $text !== '' && strlen($text) <= 18 && ctype_digit($text);
$nurNummer = $istNummer && $roh !== $text;
$parts = []; $parts = [];
foreach ($columns as $i => $column) {
$ph = ':search_' . $i; if ($istNummer) {
$parts[] = $column . ' LIKE ' . $ph; $parts[] = 'id = :search_id';
$params[$ph] = $needle; $params[':search_id'] = (int)$text;
} }
if (!$nurNummer) {
$needle = '%' . ($text !== '' ? $text : $roh) . '%';
$columns = ['title', 'description', 'error_message', 'push_id', 'target_agent', 'tags', 'file_path'];
foreach ($columns as $i => $column) {
$ph = ':search_' . $i;
$parts[] = $column . ' LIKE ' . $ph;
$params[$ph] = $needle;
}
}
$where[] = '(' . implode(' OR ', $parts) . ')'; $where[] = '(' . implode(' OR ', $parts) . ')';
} }
+41
View File
@@ -184,6 +184,47 @@ def _(ctx):
erwarte(ctx['bug_id'] in ids, "Item #%s nicht in der Liste (%s)" % (ctx['bug_id'], ids[:10])) erwarte(ctx['bug_id'] in ids, "Item #%s nicht in der Liste (%s)" % (ctx['bug_id'], ids[:10]))
@test('bugtracker', 'Suche nach der Nummer findet das Item')
def _(ctx):
"""
Die Nummer ist das, was ueberall zitiert wird - und war ausgerechnet
ueber die Suche nicht auffindbar: Sie steht in keiner der durchsuchten
Spalten. Wer sie eingab, bekam nichts.
"""
r = ctx.client.get('/api/bugtracker/v1/manage',
query={'action': 'list', 'project_slug': PROJEKT,
'search': str(ctx['bug_id']), 'limit': 50})
erwarte_erfolg(r)
ids = [i.get('id') for i in _liste(r, 'items', 'bugs', 'data') if isinstance(i, dict)]
erwarte(ctx['bug_id'] in ids,
"Suche nach %s findet das Item nicht (%s)" % (ctx['bug_id'], ids[:10]))
@test('bugtracker', 'Suche mit Raute liefert genau dieses Item')
def _(ctx):
"""
Mit Raute ist die Absicht eindeutig - dann darf kein Treffer dabei sein,
der die Zahl nur im Text stehen hat.
"""
r = ctx.client.get('/api/bugtracker/v1/manage',
query={'action': 'list', 'project_slug': PROJEKT,
'search': '#%s' % ctx['bug_id'], 'limit': 50})
erwarte_erfolg(r)
ids = [i.get('id') for i in _liste(r, 'items', 'bugs', 'data') if isinstance(i, dict)]
erwarte(ids == [ctx['bug_id']],
"Erwartet genau [%s], bekommen %s" % (ctx['bug_id'], ids))
@test('bugtracker', 'Suche nach einer fremden Nummer findet nichts')
def _(ctx):
r = ctx.client.get('/api/bugtracker/v1/manage',
query={'action': 'list', 'project_slug': PROJEKT,
'search': '#999999', 'limit': 50})
erwarte_erfolg(r)
ids = [i.get('id') for i in _liste(r, 'items', 'bugs', 'data') if isinstance(i, dict)]
erwarte(ids == [], "Unerwartete Treffer: %s" % ids[:10])
@test('bugtracker', 'Einzelabruf liefert das Item') @test('bugtracker', 'Einzelabruf liefert das Item')
def _(ctx): def _(ctx):
r = ctx.client.get('/api/bugtracker/v1/manage', r = ctx.client.get('/api/bugtracker/v1/manage',