1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 05:58:14 +03:00

Merge branch 'feature/crash-triage-tags-api' into next

This commit is contained in:
Anton Afanasyeu
2026-06-02 21:58:07 +02:00
13 changed files with 335 additions and 31 deletions

View File

@@ -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`.

View File

@@ -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 |

View File

@@ -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)),
]);
}

View File

@@ -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'];

View File

@@ -1159,6 +1159,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;

View File

@@ -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}",

View File

@@ -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}",

View File

@@ -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,7 +648,10 @@
const table = document.getElementById('reports-tree');
const filterBanner = document.getElementById('reports-filter-banner');
const listContext = parseListContext();
let grouped = listContextActive(listContext)
let grouped =
listContext.filterTags && listContext.filterTags.length > 0
? false
: listContextActive(listContext)
? false
: app.getAttribute('data-grouped') === '1';
let page = 1;
@@ -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,16 +1259,8 @@
: '<span class="muted">No custom tags</span>';
}
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) =>
function presetChipHtml(p) {
return (
'<button type="button" class="tag-preset-chip" data-preset-id="' +
escapeHtml(p.id) +
'" style="--tag-bg:' +
@@ -1231,12 +1268,17 @@
'">' +
escapeHtml(p.label) +
'</button>'
)
.join('');
els.presetChips.querySelectorAll('.tag-preset-chip').forEach((btn) => {
);
}
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 +=
'<div class="tag-preset-group"><span class="muted tag-preset-group-label">' +
escapeHtml(t('tag.workflow')) +
'</span><div class="tag-preset-chips">' +
wf.map(presetChipHtml).join('') +
'</div></div>';
}
if (hist.length) {
html +=
'<div class="tag-preset-group"><span class="muted tag-preset-group-label">' +
escapeHtml(t('tag.suggestions')) +
'</span><div class="tag-preset-chips">' +
hist.map(presetChipHtml).join('') +
'</div></div>';
}
els.presetChips.innerHTML = html;
wirePresetChips(els.presetChips);
}
function renderAll() {
renderList();
renderPreview();
@@ -1324,6 +1398,7 @@
}
if (data.ok) {
state.tags = data.tags || [];
state.workflow = data.workflow || [];
state.presets = data.presets || [];
renderAll();
renderPresets();
@@ -1804,6 +1879,60 @@
};
xhr.send(JSON.stringify(body));
});
function initTagFilterBar(listContext) {
const chipsEl = document.getElementById('tag-filter-chips');
const modeWrap = document.getElementById('tag-mode-wrap');
const modeSel = document.getElementById('tag-mode-select');
if (!chipsEl) return;
function renderFilterChips(workflow) {
const active = listContext.filterTags || [];
chipsEl.innerHTML = (workflow || [])
.map((tag) => {
const on = active.includes(tag.id);
return (
'<a class="tag-filter-chip' +
(on ? ' tag-filter-chip--active' : '') +
'" href="' +
escapeHtml(reportsUrlWithTagToggle(tag.id, listContext.tagMode)) +
'" style="--tag-bg:' +
escapeHtml(tag.bg) +
'">' +
escapeHtml(tag.label) +
'</a>'
);
})
.join('');
}
if (modeSel) {
modeSel.value = listContext.tagMode === 'or' ? 'or' : 'and';
modeSel.addEventListener('change', () => {
if (!(listContext.filterTags && listContext.filterTags.length)) return;
const p = new URLSearchParams(window.location.search);
p.set('view', 'reports');
p.set('tag_mode', modeSel.value === 'or' ? 'or' : 'and');
p.delete('page');
window.location.href = basePath() + '/?' + p.toString();
});
}
const xhr = new XMLHttpRequest();
xhr.open('GET', basePath() + '/api/tag_catalog.php', true);
xhr.onload = function () {
let data;
try {
data = JSON.parse(xhr.responseText);
} catch {
return;
}
if (!data.ok) return;
renderFilterChips(data.workflow || []);
if (modeWrap) {
modeWrap.hidden = !(listContext.filterTags && listContext.filterTags.length);
}
};
xhr.send();
}
function initTagEditButtons(tbody, reloadFn) {

View File

@@ -105,6 +105,11 @@ if ($route === '/api/hub_deploy_docs.php' || str_ends_with($route, '/api/hub_dep
exit;
}
if ($route === '/api/tag_catalog.php' || str_ends_with($route, '/api/tag_catalog.php')) {
require __DIR__ . '/api/tag_catalog.php';
exit;
}
if ($route === '/logout') {
Auth::logout();
header('Location: ' . $base . '/login');

View File

@@ -62,8 +62,20 @@ final class ReportRepository {
int $perPage,
string $sort,
string $dir,
int $sinceMs = 0
int $sinceMs = 0,
array $tagFilters = [],
string $tagMode = 'and'
): array {
if ($tagFilters !== []) {
return self::listFiltered(
['tags' => $tagFilters, 'tag_mode' => $tagMode],
$page,
$perPage,
$sort,
$dir,
$sinceMs
);
}
$pdo = Database::pdo();
$page = max(1, $page);
$perPage = max(1, min(200, $perPage));
@@ -214,6 +226,7 @@ final class ReportRepository {
$where[] = 'r.payload_json LIKE ?';
$params[] = '%' . $build . '%';
}
self::appendTagFilters($where, $params, $filters);
$whereSql = implode(' AND ', $where);
$countStmt = $pdo->prepare("SELECT COUNT(*) FROM reports r WHERE $whereSql");
$countStmt->execute($params);
@@ -661,6 +674,36 @@ final class ReportRepository {
Rbac::appendCompanyScope($column, $where, $params);
}
/**
* @param list<string> $where
* @param list<mixed> $params
* @param array<string, mixed> $filters
*/
private static function appendTagFilters(array &$where, array &$params, array $filters): void {
$tags = $filters['tags'] ?? [];
if (!is_array($tags) || $tags === []) {
return;
}
$ids = TagCatalog::normalizeFilterIds($tags);
if ($ids === []) {
return;
}
$mode = strtolower((string) ($filters['tag_mode'] ?? 'and')) === 'or' ? 'or' : 'and';
$clauses = [];
foreach ($ids as $id) {
$clauses[] = '(r.tags_json LIKE ? OR r.tags_json LIKE ?)';
$params[] = '%"id":"' . $id . '"%';
$params[] = '%"id": "' . $id . '"%';
}
if ($mode === 'or') {
$where[] = '(' . implode(' OR ', $clauses) . ')';
} else {
foreach ($clauses as $clause) {
$where[] = $clause;
}
}
}
private static function fingerprintCountSql(): string {
$scope = Rbac::fingerprintCountSql('r', 'r2');
return "(SELECT COUNT(*) FROM reports r2 WHERE r2.fingerprint = r.fingerprint{$scope})";

View File

@@ -476,6 +476,25 @@ function normalize_report_tags(array $tags): array {
return $out;
}
/** @return list<array{id:string,label:string,bg:string}> */
/**
* @return list<string>
*/
function request_tag_filter_ids(): array {
$raw = [];
if (isset($_GET['tag']) && is_array($_GET['tag'])) {
$raw = $_GET['tag'];
} elseif (isset($_GET['tag']) && is_string($_GET['tag']) && $_GET['tag'] !== '') {
$raw = [$_GET['tag']];
}
return TagCatalog::normalizeFilterIds($raw);
}
function request_tag_filter_mode(): string {
$mode = strtolower(trim((string) ($_GET['tag_mode'] ?? 'and')));
return $mode === 'or' ? 'or' : 'and';
}
/** @return list<array{id:string,label:string,bg:string}> */
function parse_report_tags(?string $json): array {
if ($json === null || $json === '') {

View File

@@ -201,6 +201,13 @@
<option value="light" data-i18n="theme.light">Light</option>
</select>
</label>
<label class="toolbar-select tag-mode-select-wrap" id="tag-mode-wrap" hidden>
<span data-i18n="tag.filter_mode">Match</span>
<select id="tag-mode-select" aria-label="Tag filter mode">
<option value="and" data-i18n="tag.filter_and">All tags (AND)</option>
<option value="or" data-i18n="tag.filter_or">Any tag (OR)</option>
</select>
</label>
<label class="toolbar-select">
<span data-i18n="reports.per_page">Per page</span>
<select id="per-page-select" aria-label="Reports per page">
@@ -213,6 +220,10 @@
</label>
</div>
</div>
<div class="tag-filter-bar" id="tag-filter-bar">
<span class="muted tag-filter-label" data-i18n="tag.filter_label">Filter by tag</span>
<div class="tag-filter-chips" id="tag-filter-chips" role="group" aria-label="Tag filters"></div>
</div>
<p id="reports-filter-banner" class="reports-filter-banner muted" hidden></p>
<div class="reports-search-row">
<label class="reports-search-label" for="reports-search-input" data-i18n="reports.search">Search</label>

View File

@@ -28,6 +28,12 @@ if [[ -z "$(mount | grep BE | grep var | grep f0xx)" && -d "tmp/BE_alpine/var/ww
set +x
fi
if [[ -z "$(mount | grep BE | grep etc | grep f0xx)" && -d "tmp/BE_alpine/etc/gitea" ]]; then
set -x
sshfs alpine-be:/etc/gitea tmp/BE_alpine/etc/gitea
set +x
fi
_umount() {
umount "${1}"
}