mirror of
git://f0xx.org/android_cast
synced 2026-07-29 06:58:51 +03:00
Merge branch 'feature/crash-triage-tags-api' into next
This commit is contained in:
@@ -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.
|
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
|
## Implementation order
|
||||||
|
|
||||||
1. Document fields; parse and store in payload (Phase A).
|
1. ~~Workflow tags + list filter~~ (done)
|
||||||
2. List filter `ingest_source` in `ReportRepository` + UI chip.
|
2. Document fields; parse and store in payload (Phase A ingest).
|
||||||
3. API keys table + `X-Crash-Api-Key` on upload.
|
3. List filter `ingest_source` in `ReportRepository` + UI chip.
|
||||||
4. Grouped bulk endpoint for duplicate review.
|
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`.
|
See also [CRASH_REPORTER.md](CRASH_REPORTER.md) and `examples/crash_reporter/ROADMAP.md`.
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ See [COMMERCIAL.md](COMMERCIAL.md). Pro/OSS module split: [PRO_AAR.md](PRO_AAR.m
|
|||||||
|
|
||||||
| Item | Status |
|
| 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) |
|
| `ingest.source` / run metadata in payload | Design — [OPEN_API.md](OPEN_API.md) |
|
||||||
| List filters + synthetic tagging | TODO |
|
| List filters + synthetic tagging | TODO |
|
||||||
| API key upload auth (per company) | TODO |
|
| API key upload auth (per company) | TODO |
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ if ($method === 'GET') {
|
|||||||
'ok' => true,
|
'ok' => true,
|
||||||
'id' => $id,
|
'id' => $id,
|
||||||
'tags' => $report['tags'] ?? [],
|
'tags' => $report['tags'] ?? [],
|
||||||
'presets' => ReportRepository::listTagPresets(),
|
'workflow' => TagCatalog::workflowPresets(),
|
||||||
|
'presets' => TagCatalog::editorPresets(),
|
||||||
'can_edit' => Auth::canEditTags((int) ($report['company_id'] ?? 0)),
|
'can_edit' => Auth::canEditTags((int) ($report['company_id'] ?? 0)),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,14 @@ $similarTo = max(0, (int) ($_GET['similar_to'] ?? 0));
|
|||||||
$searchQ = trim((string) ($_GET['q'] ?? ''));
|
$searchQ = trim((string) ($_GET['q'] ?? ''));
|
||||||
$suggest = isset($_GET['suggest']) && $_GET['suggest'] === '1';
|
$suggest = isset($_GET['suggest']) && $_GET['suggest'] === '1';
|
||||||
|
|
||||||
|
$tagIds = request_tag_filter_ids();
|
||||||
|
$tagMode = request_tag_filter_mode();
|
||||||
|
|
||||||
$filters = [];
|
$filters = [];
|
||||||
|
if ($tagIds !== []) {
|
||||||
|
$filters['tags'] = $tagIds;
|
||||||
|
$filters['tag_mode'] = $tagMode;
|
||||||
|
}
|
||||||
$device = trim((string) ($_GET['filter_device'] ?? ''));
|
$device = trim((string) ($_GET['filter_device'] ?? ''));
|
||||||
if ($device !== '') {
|
if ($device !== '') {
|
||||||
$filters['device'] = $device;
|
$filters['device'] = $device;
|
||||||
@@ -59,11 +66,14 @@ try {
|
|||||||
} elseif ($similarTo > 0) {
|
} elseif ($similarTo > 0) {
|
||||||
$data = ReportRepository::listSimilar($similarTo, $page, $perPage, $sinceMs);
|
$data = ReportRepository::listSimilar($similarTo, $page, $perPage, $sinceMs);
|
||||||
} elseif ($filters !== []) {
|
} elseif ($filters !== []) {
|
||||||
|
if ($tagIds !== []) {
|
||||||
|
$grouped = false;
|
||||||
|
}
|
||||||
$data = ReportRepository::listFiltered($filters, $page, $perPage, $sort, $dir, $sinceMs);
|
$data = ReportRepository::listFiltered($filters, $page, $perPage, $sort, $dir, $sinceMs);
|
||||||
} else {
|
} else {
|
||||||
$data = ReportRepository::listPage($grouped, $page, $perPage, $sort, $dir, $sinceMs);
|
$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) {
|
} catch (Throwable $e) {
|
||||||
error_log('reports api: ' . $e->getMessage());
|
error_log('reports api: ' . $e->getMessage());
|
||||||
$out = ['ok' => false, 'error' => 'list failed'];
|
$out = ['ok' => false, 'error' => 'list failed'];
|
||||||
|
|||||||
@@ -1159,6 +1159,48 @@ a:hover { text-decoration: underline; }
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.tag-preset-chip:hover { filter: brightness(1.12); }
|
.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 {
|
.report-tag {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
@@ -83,6 +83,11 @@
|
|||||||
"tag.saving": "Saving…",
|
"tag.saving": "Saving…",
|
||||||
"tag.saved": "Saved",
|
"tag.saved": "Saved",
|
||||||
"tag.save_failed": "Save failed",
|
"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.back": "Back to list",
|
||||||
"detail.title": "Crash report",
|
"detail.title": "Crash report",
|
||||||
"detail.report_meta": "Report {id} · {type}",
|
"detail.report_meta": "Report {id} · {type}",
|
||||||
|
|||||||
@@ -84,6 +84,11 @@
|
|||||||
"tag.saving": "Сохранение…",
|
"tag.saving": "Сохранение…",
|
||||||
"tag.saved": "Сохранено",
|
"tag.saved": "Сохранено",
|
||||||
"tag.save_failed": "Не удалось сохранить",
|
"tag.save_failed": "Не удалось сохранить",
|
||||||
|
"tag.workflow": "Статус",
|
||||||
|
"tag.filter_label": "Фильтр по метке",
|
||||||
|
"tag.filter_mode": "Совпадение",
|
||||||
|
"tag.filter_and": "Все метки (И)",
|
||||||
|
"tag.filter_or": "Любая метка (ИЛИ)",
|
||||||
"detail.back": "К списку",
|
"detail.back": "К списку",
|
||||||
"detail.title": "Отчёт о сбое",
|
"detail.title": "Отчёт о сбое",
|
||||||
"detail.report_meta": "Отчёт {id} · {type}",
|
"detail.report_meta": "Отчёт {id} · {type}",
|
||||||
|
|||||||
@@ -495,6 +495,11 @@
|
|||||||
|
|
||||||
function parseListContext() {
|
function parseListContext() {
|
||||||
const p = new URLSearchParams(window.location.search);
|
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 {
|
return {
|
||||||
similarTo: Number(p.get('similar_to')) || 0,
|
similarTo: Number(p.get('similar_to')) || 0,
|
||||||
filterDevice: p.get('filter_device') || '',
|
filterDevice: p.get('filter_device') || '',
|
||||||
@@ -503,6 +508,8 @@
|
|||||||
filterOsRelease: p.get('filter_os_release') || '',
|
filterOsRelease: p.get('filter_os_release') || '',
|
||||||
filterAppVersion: p.get('filter_app_version') || '',
|
filterAppVersion: p.get('filter_app_version') || '',
|
||||||
filterBuild: p.get('filter_build') || '',
|
filterBuild: p.get('filter_build') || '',
|
||||||
|
filterTags,
|
||||||
|
tagMode: p.get('tag_mode') === 'or' ? 'or' : 'and',
|
||||||
searchQuery: p.get('q') || '',
|
searchQuery: p.get('q') || '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -516,10 +523,33 @@
|
|||||||
!!ctx.filterOsRelease ||
|
!!ctx.filterOsRelease ||
|
||||||
!!ctx.filterAppVersion ||
|
!!ctx.filterAppVersion ||
|
||||||
!!ctx.filterBuild ||
|
!!ctx.filterBuild ||
|
||||||
|
(ctx.filterTags && ctx.filterTags.length > 0) ||
|
||||||
!!ctx.searchQuery
|
!!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) {
|
function updateFilterBanner(ctx, bannerEl) {
|
||||||
if (!bannerEl) return;
|
if (!bannerEl) return;
|
||||||
const parts = [];
|
const parts = [];
|
||||||
@@ -544,6 +574,10 @@
|
|||||||
if (ctx.filterBuild) {
|
if (ctx.filterBuild) {
|
||||||
parts.push('Build ' + 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) {
|
if (ctx.searchQuery) {
|
||||||
parts.push('Search: “' + ctx.searchQuery + '”');
|
parts.push('Search: “' + ctx.searchQuery + '”');
|
||||||
}
|
}
|
||||||
@@ -614,9 +648,12 @@
|
|||||||
const table = document.getElementById('reports-tree');
|
const table = document.getElementById('reports-tree');
|
||||||
const filterBanner = document.getElementById('reports-filter-banner');
|
const filterBanner = document.getElementById('reports-filter-banner');
|
||||||
const listContext = parseListContext();
|
const listContext = parseListContext();
|
||||||
let grouped = listContextActive(listContext)
|
let grouped =
|
||||||
? false
|
listContext.filterTags && listContext.filterTags.length > 0
|
||||||
: app.getAttribute('data-grouped') === '1';
|
? false
|
||||||
|
: listContextActive(listContext)
|
||||||
|
? false
|
||||||
|
: app.getAttribute('data-grouped') === '1';
|
||||||
let page = 1;
|
let page = 1;
|
||||||
let perPage = Number(lsGet(STORAGE.perPage, '50')) || 50;
|
let perPage = Number(lsGet(STORAGE.perPage, '50')) || 50;
|
||||||
let listTotal = 0;
|
let listTotal = 0;
|
||||||
@@ -654,6 +691,7 @@
|
|||||||
|
|
||||||
bindReportsTableLayout(table, thead, layoutCtx);
|
bindReportsTableLayout(table, thead, layoutCtx);
|
||||||
updateFilterBanner(listContext, filterBanner);
|
updateFilterBanner(listContext, filterBanner);
|
||||||
|
initTagFilterBar(listContext);
|
||||||
|
|
||||||
if (listContextActive(listContext)) {
|
if (listContextActive(listContext)) {
|
||||||
app.setAttribute('data-grouped', '0');
|
app.setAttribute('data-grouped', '0');
|
||||||
@@ -951,6 +989,12 @@
|
|||||||
if (listContext.filterBuild) {
|
if (listContext.filterBuild) {
|
||||||
qs += '&filter_build=' + encodeURIComponent(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();
|
const xhr = new XMLHttpRequest();
|
||||||
xhr.open('GET', basePath() + '/api/reports.php?' + qs, true);
|
xhr.open('GET', basePath() + '/api/reports.php?' + qs, true);
|
||||||
@@ -1167,6 +1211,7 @@
|
|||||||
const entityType = cfg.entityType === 'ticket' ? 'ticket' : 'report';
|
const entityType = cfg.entityType === 'ticket' ? 'ticket' : 'report';
|
||||||
const state = {
|
const state = {
|
||||||
tags: [...(cfg.initialTags || [])],
|
tags: [...(cfg.initialTags || [])],
|
||||||
|
workflow: [],
|
||||||
presets: [],
|
presets: [],
|
||||||
entityId: cfg.entityId ?? cfg.reportId ?? cfg.ticketId ?? 0,
|
entityId: cfg.entityId ?? cfg.reportId ?? cfg.ticketId ?? 0,
|
||||||
entityType,
|
entityType,
|
||||||
@@ -1214,29 +1259,26 @@
|
|||||||
: '<span class="muted">No custom tags</span>';
|
: '<span class="muted">No custom tags</span>';
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPresets() {
|
function presetChipHtml(p) {
|
||||||
if (!els.presetBar || !els.presetChips) return;
|
return (
|
||||||
if (!state.presets.length) {
|
'<button type="button" class="tag-preset-chip" data-preset-id="' +
|
||||||
els.presetBar.hidden = true;
|
escapeHtml(p.id) +
|
||||||
return;
|
'" style="--tag-bg:' +
|
||||||
}
|
escapeHtml(p.bg) +
|
||||||
els.presetBar.hidden = false;
|
'">' +
|
||||||
els.presetChips.innerHTML = state.presets
|
escapeHtml(p.label) +
|
||||||
.map(
|
'</button>'
|
||||||
(p) =>
|
);
|
||||||
'<button type="button" class="tag-preset-chip" data-preset-id="' +
|
}
|
||||||
escapeHtml(p.id) +
|
|
||||||
'" style="--tag-bg:' +
|
function wirePresetChips(root) {
|
||||||
escapeHtml(p.bg) +
|
if (!root) return;
|
||||||
'">' +
|
root.querySelectorAll('.tag-preset-chip').forEach((btn) => {
|
||||||
escapeHtml(p.label) +
|
|
||||||
'</button>'
|
|
||||||
)
|
|
||||||
.join('');
|
|
||||||
els.presetChips.querySelectorAll('.tag-preset-chip').forEach((btn) => {
|
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
const id = btn.getAttribute('data-preset-id');
|
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;
|
if (!p || state.tags.some((t) => t.id === p.id)) return;
|
||||||
state.tags.push({ ...p });
|
state.tags.push({ ...p });
|
||||||
renderAll();
|
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() {
|
function renderAll() {
|
||||||
renderList();
|
renderList();
|
||||||
renderPreview();
|
renderPreview();
|
||||||
@@ -1324,6 +1398,7 @@
|
|||||||
}
|
}
|
||||||
if (data.ok) {
|
if (data.ok) {
|
||||||
state.tags = data.tags || [];
|
state.tags = data.tags || [];
|
||||||
|
state.workflow = data.workflow || [];
|
||||||
state.presets = data.presets || [];
|
state.presets = data.presets || [];
|
||||||
renderAll();
|
renderAll();
|
||||||
renderPresets();
|
renderPresets();
|
||||||
@@ -1804,6 +1879,60 @@
|
|||||||
};
|
};
|
||||||
xhr.send(JSON.stringify(body));
|
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) {
|
function initTagEditButtons(tbody, reloadFn) {
|
||||||
|
|||||||
@@ -105,6 +105,11 @@ if ($route === '/api/hub_deploy_docs.php' || str_ends_with($route, '/api/hub_dep
|
|||||||
exit;
|
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') {
|
if ($route === '/logout') {
|
||||||
Auth::logout();
|
Auth::logout();
|
||||||
header('Location: ' . $base . '/login');
|
header('Location: ' . $base . '/login');
|
||||||
|
|||||||
@@ -62,8 +62,20 @@ final class ReportRepository {
|
|||||||
int $perPage,
|
int $perPage,
|
||||||
string $sort,
|
string $sort,
|
||||||
string $dir,
|
string $dir,
|
||||||
int $sinceMs = 0
|
int $sinceMs = 0,
|
||||||
|
array $tagFilters = [],
|
||||||
|
string $tagMode = 'and'
|
||||||
): array {
|
): array {
|
||||||
|
if ($tagFilters !== []) {
|
||||||
|
return self::listFiltered(
|
||||||
|
['tags' => $tagFilters, 'tag_mode' => $tagMode],
|
||||||
|
$page,
|
||||||
|
$perPage,
|
||||||
|
$sort,
|
||||||
|
$dir,
|
||||||
|
$sinceMs
|
||||||
|
);
|
||||||
|
}
|
||||||
$pdo = Database::pdo();
|
$pdo = Database::pdo();
|
||||||
$page = max(1, $page);
|
$page = max(1, $page);
|
||||||
$perPage = max(1, min(200, $perPage));
|
$perPage = max(1, min(200, $perPage));
|
||||||
@@ -214,6 +226,7 @@ final class ReportRepository {
|
|||||||
$where[] = 'r.payload_json LIKE ?';
|
$where[] = 'r.payload_json LIKE ?';
|
||||||
$params[] = '%' . $build . '%';
|
$params[] = '%' . $build . '%';
|
||||||
}
|
}
|
||||||
|
self::appendTagFilters($where, $params, $filters);
|
||||||
$whereSql = implode(' AND ', $where);
|
$whereSql = implode(' AND ', $where);
|
||||||
$countStmt = $pdo->prepare("SELECT COUNT(*) FROM reports r WHERE $whereSql");
|
$countStmt = $pdo->prepare("SELECT COUNT(*) FROM reports r WHERE $whereSql");
|
||||||
$countStmt->execute($params);
|
$countStmt->execute($params);
|
||||||
@@ -661,6 +674,36 @@ final class ReportRepository {
|
|||||||
Rbac::appendCompanyScope($column, $where, $params);
|
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 {
|
private static function fingerprintCountSql(): string {
|
||||||
$scope = Rbac::fingerprintCountSql('r', 'r2');
|
$scope = Rbac::fingerprintCountSql('r', 'r2');
|
||||||
return "(SELECT COUNT(*) FROM reports r2 WHERE r2.fingerprint = r.fingerprint{$scope})";
|
return "(SELECT COUNT(*) FROM reports r2 WHERE r2.fingerprint = r.fingerprint{$scope})";
|
||||||
|
|||||||
@@ -476,6 +476,25 @@ function normalize_report_tags(array $tags): array {
|
|||||||
return $out;
|
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}> */
|
/** @return list<array{id:string,label:string,bg:string}> */
|
||||||
function parse_report_tags(?string $json): array {
|
function parse_report_tags(?string $json): array {
|
||||||
if ($json === null || $json === '') {
|
if ($json === null || $json === '') {
|
||||||
|
|||||||
@@ -201,6 +201,13 @@
|
|||||||
<option value="light" data-i18n="theme.light">Light</option>
|
<option value="light" data-i18n="theme.light">Light</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</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">
|
<label class="toolbar-select">
|
||||||
<span data-i18n="reports.per_page">Per page</span>
|
<span data-i18n="reports.per_page">Per page</span>
|
||||||
<select id="per-page-select" aria-label="Reports per page">
|
<select id="per-page-select" aria-label="Reports per page">
|
||||||
@@ -213,6 +220,10 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<p id="reports-filter-banner" class="reports-filter-banner muted" hidden></p>
|
||||||
<div class="reports-search-row">
|
<div class="reports-search-row">
|
||||||
<label class="reports-search-label" for="reports-search-input" data-i18n="reports.search">Search</label>
|
<label class="reports-search-label" for="reports-search-input" data-i18n="reports.search">Search</label>
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ if [[ -z "$(mount | grep BE | grep var | grep f0xx)" && -d "tmp/BE_alpine/var/ww
|
|||||||
set +x
|
set +x
|
||||||
fi
|
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() {
|
||||||
umount "${1}"
|
umount "${1}"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user