1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 06:39:09 +03:00

Reapply crash triage tag API updates after syncing the branch with next.

This resolves stash replay conflicts and keeps the report/tag UI and backend endpoints aligned for the feature branch review.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Anton Afanasyeu
2026-06-02 16:05:46 +02:00
parent 9f9d617730
commit d7dc2cbcc2
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. 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`.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,7 +648,10 @@
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 =
listContext.filterTags && listContext.filterTags.length > 0
? false
: listContextActive(listContext)
? false ? false
: app.getAttribute('data-grouped') === '1'; : app.getAttribute('data-grouped') === '1';
let page = 1; let page = 1;
@@ -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,16 +1259,8 @@
: '<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) {
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="' + '<button type="button" class="tag-preset-chip" data-preset-id="' +
escapeHtml(p.id) + escapeHtml(p.id) +
'" style="--tag-bg:' + '" style="--tag-bg:' +
@@ -1231,12 +1268,17 @@
'">' + '">' +
escapeHtml(p.label) + escapeHtml(p.label) +
'</button>' '</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', () => { 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) {

View File

@@ -95,6 +95,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');

View File

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

View File

@@ -474,6 +474,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 === '') {

View File

@@ -189,6 +189,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">
@@ -201,6 +208,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>

View File

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