mirror of
git://f0xx.org/android_cast
synced 2026-07-29 03:38:52 +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.
|
||||
|
||||
## 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`.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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)),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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 @@
|
||||
: '<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) =>
|
||||
'<button type="button" class="tag-preset-chip" data-preset-id="' +
|
||||
escapeHtml(p.id) +
|
||||
'" style="--tag-bg:' +
|
||||
escapeHtml(p.bg) +
|
||||
'">' +
|
||||
escapeHtml(p.label) +
|
||||
'</button>'
|
||||
)
|
||||
.join('');
|
||||
els.presetChips.querySelectorAll('.tag-preset-chip').forEach((btn) => {
|
||||
function presetChipHtml(p) {
|
||||
return (
|
||||
'<button type="button" class="tag-preset-chip" data-preset-id="' +
|
||||
escapeHtml(p.id) +
|
||||
'" style="--tag-bg:' +
|
||||
escapeHtml(p.bg) +
|
||||
'">' +
|
||||
escapeHtml(p.label) +
|
||||
'</button>'
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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})";
|
||||
|
||||
@@ -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 === '') {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user