diff --git a/docs/OPEN_API.md b/docs/OPEN_API.md index da1c4fc..7c1232d 100644 --- a/docs/OPEN_API.md +++ b/docs/OPEN_API.md @@ -66,11 +66,37 @@ Manual: console **Clear synthetic tag** / **Promote to real** on detail page. No change required for v1. Optional later: dev setting to set `ingest.source=manual` for dogfood builds. +## Triage tags (implemented v1) + +Workflow labels (multi-tag per report, any combination): + +| id | Meaning | +|----|---------| +| `in-progress` | Someone is working on it | +| `fixed` | Fix landed | +| `rejected` | Won't process | +| `done` | Closed / EOL for this issue | +| `not-reproducible` | Cannot reproduce | +| `not-a-bug` | Expected behaviour | +| `duplicate` | Duplicate of another report | + +**API** + +| Endpoint | Purpose | +|----------|---------| +| `GET /api/tag_catalog.php` | Workflow presets + editor suggestions | +| `GET /api/report_tags.php?id=N` | Tags on one report | +| `POST /api/report_tags.php` | Body `{"id":N,"tags":[...]}` — replace custom tags | +| `GET /api/reports.php?tag=fixed&tag=duplicate&tag_mode=and` | List filter (AND default, OR optional) | + +Smoke test: `examples/crash_reporter/backend/scripts/test_tags_api.sh` + ## Implementation order -1. Document fields; parse and store in payload (Phase A). -2. List filter `ingest_source` in `ReportRepository` + UI chip. -3. API keys table + `X-Crash-Api-Key` on upload. -4. Grouped bulk endpoint for duplicate review. +1. ~~Workflow tags + list filter~~ (done) +2. Document fields; parse and store in payload (Phase A ingest). +3. List filter `ingest_source` in `ReportRepository` + UI chip. +4. API keys table + `X-Crash-Api-Key` on upload. +5. Grouped bulk endpoint for duplicate review. See also [CRASH_REPORTER.md](CRASH_REPORTER.md) and `examples/crash_reporter/ROADMAP.md`. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 67116a8..0dae7a7 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -80,6 +80,8 @@ See [COMMERCIAL.md](COMMERCIAL.md). Pro/OSS module split: [PRO_AAR.md](PRO_AAR.m | Item | Status | |------|--------| +| Workflow triage tags + multi-tag (duplicate+fixed, etc.) | Done — [OPEN_API.md](OPEN_API.md) | +| `GET /api/reports.php?tag=…&tag_mode=and\|or` | Done | | `ingest.source` / run metadata in payload | Design — [OPEN_API.md](OPEN_API.md) | | List filters + synthetic tagging | TODO | | API key upload auth (per company) | TODO | diff --git a/examples/crash_reporter/backend/public/api/report_tags.php b/examples/crash_reporter/backend/public/api/report_tags.php index 8862f1e..db4b094 100644 --- a/examples/crash_reporter/backend/public/api/report_tags.php +++ b/examples/crash_reporter/backend/public/api/report_tags.php @@ -21,7 +21,8 @@ if ($method === 'GET') { 'ok' => true, 'id' => $id, 'tags' => $report['tags'] ?? [], - 'presets' => ReportRepository::listTagPresets(), + 'workflow' => TagCatalog::workflowPresets(), + 'presets' => TagCatalog::editorPresets(), 'can_edit' => Auth::canEditTags((int) ($report['company_id'] ?? 0)), ]); } diff --git a/examples/crash_reporter/backend/public/api/reports.php b/examples/crash_reporter/backend/public/api/reports.php index d0b785c..72182fc 100644 --- a/examples/crash_reporter/backend/public/api/reports.php +++ b/examples/crash_reporter/backend/public/api/reports.php @@ -20,7 +20,14 @@ $similarTo = max(0, (int) ($_GET['similar_to'] ?? 0)); $searchQ = trim((string) ($_GET['q'] ?? '')); $suggest = isset($_GET['suggest']) && $_GET['suggest'] === '1'; +$tagIds = request_tag_filter_ids(); +$tagMode = request_tag_filter_mode(); + $filters = []; +if ($tagIds !== []) { + $filters['tags'] = $tagIds; + $filters['tag_mode'] = $tagMode; +} $device = trim((string) ($_GET['filter_device'] ?? '')); if ($device !== '') { $filters['device'] = $device; @@ -59,11 +66,14 @@ try { } elseif ($similarTo > 0) { $data = ReportRepository::listSimilar($similarTo, $page, $perPage, $sinceMs); } elseif ($filters !== []) { + if ($tagIds !== []) { + $grouped = false; + } $data = ReportRepository::listFiltered($filters, $page, $perPage, $sort, $dir, $sinceMs); } else { $data = ReportRepository::listPage($grouped, $page, $perPage, $sort, $dir, $sinceMs); } - json_out(['ok' => true] + $data); + json_out(['ok' => true, 'tag_filter' => $tagIds, 'tag_mode' => $tagMode] + $data); } catch (Throwable $e) { error_log('reports api: ' . $e->getMessage()); $out = ['ok' => false, 'error' => 'list failed']; diff --git a/examples/crash_reporter/backend/public/assets/css/app.css b/examples/crash_reporter/backend/public/assets/css/app.css index cb185f0..bd85fe9 100644 --- a/examples/crash_reporter/backend/public/assets/css/app.css +++ b/examples/crash_reporter/backend/public/assets/css/app.css @@ -1128,6 +1128,48 @@ a:hover { text-decoration: underline; } cursor: pointer; } .tag-preset-chip:hover { filter: brightness(1.12); } +.tag-preset-group { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} +.tag-preset-group:last-child { margin-bottom: 0; } +.tag-preset-group-label { font-size: 12px; min-width: 5.5rem; } + +.tag-filter-bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + margin: 0 0 12px; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface-2, rgba(255, 255, 255, .03)); +} +.tag-filter-label { font-size: 13px; } +.tag-filter-chips { display: flex; flex-wrap: wrap; gap: 6px; } +.tag-filter-chip { + display: inline-block; + padding: 4px 12px; + border-radius: 999px; + font-size: 12px; + font-weight: 600; + color: #fff; + background: var(--tag-bg, #5c6b82); + text-decoration: none; + border: 2px solid transparent; + opacity: 0.72; +} +.tag-filter-chip:hover { filter: brightness(1.1); opacity: 1; } +.tag-filter-chip--active { + opacity: 1; + border-color: var(--accent, #3d8bfd); + box-shadow: 0 0 0 1px var(--accent, #3d8bfd); +} +.tag-mode-select-wrap { margin-left: auto; } .report-tag { position: relative; diff --git a/examples/crash_reporter/backend/public/assets/i18n/en.json b/examples/crash_reporter/backend/public/assets/i18n/en.json index 711e13b..7196d5d 100644 --- a/examples/crash_reporter/backend/public/assets/i18n/en.json +++ b/examples/crash_reporter/backend/public/assets/i18n/en.json @@ -83,6 +83,11 @@ "tag.saving": "Saving…", "tag.saved": "Saved", "tag.save_failed": "Save failed", + "tag.workflow": "Workflow", + "tag.filter_label": "Filter by tag", + "tag.filter_mode": "Match", + "tag.filter_and": "All tags (AND)", + "tag.filter_or": "Any tag (OR)", "detail.back": "Back to list", "detail.title": "Crash report", "detail.report_meta": "Report {id} · {type}", diff --git a/examples/crash_reporter/backend/public/assets/i18n/ru.json b/examples/crash_reporter/backend/public/assets/i18n/ru.json index f2719fe..0dd3dc2 100644 --- a/examples/crash_reporter/backend/public/assets/i18n/ru.json +++ b/examples/crash_reporter/backend/public/assets/i18n/ru.json @@ -84,6 +84,11 @@ "tag.saving": "Сохранение…", "tag.saved": "Сохранено", "tag.save_failed": "Не удалось сохранить", + "tag.workflow": "Статус", + "tag.filter_label": "Фильтр по метке", + "tag.filter_mode": "Совпадение", + "tag.filter_and": "Все метки (И)", + "tag.filter_or": "Любая метка (ИЛИ)", "detail.back": "К списку", "detail.title": "Отчёт о сбое", "detail.report_meta": "Отчёт {id} · {type}", diff --git a/examples/crash_reporter/backend/public/assets/js/app.js b/examples/crash_reporter/backend/public/assets/js/app.js index cee52b2..d68a603 100644 --- a/examples/crash_reporter/backend/public/assets/js/app.js +++ b/examples/crash_reporter/backend/public/assets/js/app.js @@ -495,6 +495,11 @@ function parseListContext() { const p = new URLSearchParams(window.location.search); + const filterTags = []; + p.getAll('tag').forEach((t) => { + const id = String(t).trim().toLowerCase(); + if (id && !filterTags.includes(id)) filterTags.push(id); + }); return { similarTo: Number(p.get('similar_to')) || 0, filterDevice: p.get('filter_device') || '', @@ -503,6 +508,8 @@ filterOsRelease: p.get('filter_os_release') || '', filterAppVersion: p.get('filter_app_version') || '', filterBuild: p.get('filter_build') || '', + filterTags, + tagMode: p.get('tag_mode') === 'or' ? 'or' : 'and', searchQuery: p.get('q') || '', }; } @@ -516,10 +523,33 @@ !!ctx.filterOsRelease || !!ctx.filterAppVersion || !!ctx.filterBuild || + (ctx.filterTags && ctx.filterTags.length > 0) || !!ctx.searchQuery ); } + function reportsUrlWithTagToggle(tagId, tagMode) { + const p = new URLSearchParams(window.location.search); + p.set('view', 'reports'); + const tags = p.getAll('tag'); + const idx = tags.indexOf(tagId); + if (idx >= 0) { + tags.splice(idx, 1); + } else { + tags.push(tagId); + } + p.delete('tag'); + tags.forEach((t) => p.append('tag', t)); + if (tags.length) { + p.set('tag_mode', tagMode === 'or' ? 'or' : 'and'); + } else { + p.delete('tag_mode'); + } + p.delete('group'); + p.delete('page'); + return basePath() + '/?' + p.toString(); + } + function updateFilterBanner(ctx, bannerEl) { if (!bannerEl) return; const parts = []; @@ -544,6 +574,10 @@ if (ctx.filterBuild) { parts.push('Build ' + ctx.filterBuild); } + if (ctx.filterTags && ctx.filterTags.length) { + const modeLabel = ctx.tagMode === 'or' ? 'OR' : 'AND'; + parts.push('Tags (' + modeLabel + '): ' + ctx.filterTags.join(', ')); + } if (ctx.searchQuery) { parts.push('Search: “' + ctx.searchQuery + '”'); } @@ -614,9 +648,12 @@ const table = document.getElementById('reports-tree'); const filterBanner = document.getElementById('reports-filter-banner'); const listContext = parseListContext(); - let grouped = listContextActive(listContext) - ? false - : app.getAttribute('data-grouped') === '1'; + let grouped = + listContext.filterTags && listContext.filterTags.length > 0 + ? false + : listContextActive(listContext) + ? false + : app.getAttribute('data-grouped') === '1'; let page = 1; let perPage = Number(lsGet(STORAGE.perPage, '50')) || 50; let listTotal = 0; @@ -654,6 +691,7 @@ bindReportsTableLayout(table, thead, layoutCtx); updateFilterBanner(listContext, filterBanner); + initTagFilterBar(listContext); if (listContextActive(listContext)) { app.setAttribute('data-grouped', '0'); @@ -951,6 +989,12 @@ if (listContext.filterBuild) { qs += '&filter_build=' + encodeURIComponent(listContext.filterBuild); } + if (listContext.filterTags && listContext.filterTags.length) { + listContext.filterTags.forEach((tagId) => { + qs += '&tag=' + encodeURIComponent(tagId); + }); + qs += '&tag_mode=' + encodeURIComponent(listContext.tagMode || 'and'); + } } const xhr = new XMLHttpRequest(); xhr.open('GET', basePath() + '/api/reports.php?' + qs, true); @@ -1167,6 +1211,7 @@ const entityType = cfg.entityType === 'ticket' ? 'ticket' : 'report'; const state = { tags: [...(cfg.initialTags || [])], + workflow: [], presets: [], entityId: cfg.entityId ?? cfg.reportId ?? cfg.ticketId ?? 0, entityType, @@ -1214,29 +1259,26 @@ : 'No custom tags'; } - function renderPresets() { - if (!els.presetBar || !els.presetChips) return; - if (!state.presets.length) { - els.presetBar.hidden = true; - return; - } - els.presetBar.hidden = false; - els.presetChips.innerHTML = state.presets - .map( - (p) => - '' - ) - .join(''); - els.presetChips.querySelectorAll('.tag-preset-chip').forEach((btn) => { + function presetChipHtml(p) { + return ( + '' + ); + } + + function wirePresetChips(root) { + if (!root) return; + root.querySelectorAll('.tag-preset-chip').forEach((btn) => { btn.addEventListener('click', () => { const id = btn.getAttribute('data-preset-id'); - const p = state.presets.find((x) => x.id === id); + const p = + state.workflow.find((x) => x.id === id) || + state.presets.find((x) => x.id === id); if (!p || state.tags.some((t) => t.id === p.id)) return; state.tags.push({ ...p }); renderAll(); @@ -1244,6 +1286,38 @@ }); } + function renderPresets() { + if (!els.presetBar || !els.presetChips) return; + const wf = state.workflow || []; + const hist = (state.presets || []).filter( + (p) => !wf.some((w) => w.id === p.id) + ); + if (!wf.length && !hist.length) { + els.presetBar.hidden = true; + return; + } + els.presetBar.hidden = false; + let html = ''; + if (wf.length) { + html += + '