1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 07:20:00 +03:00
This commit is contained in:
Anton Afanasyeu
2026-06-05 15:19:48 +02:00
parent 5df496c0a5
commit db91842dd9
12 changed files with 520 additions and 25 deletions

View File

@@ -30,6 +30,7 @@ return [
'ota_mount' => '/workspace/orchestration/runtime/ota-artifacts',
'ota_base_url' => 'http://localhost:8080',
'runner_script' => '/workspace/scripts/docker-build-runner.sh',
// Repo copy preferred; generated YAML used if missing on builder host
'pipeline_config' => '/workspace/build.config.yml',
'default_channels' => ['stable', 'staging', 'dev', 'nightly'],
],

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../src/bootstrap.php';
Auth::check();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
json_out(['ok' => false, 'error' => 'POST required'], 405);
}
$raw = file_get_contents('php://input') ?: '{}';
$body = json_decode($raw, true);
if (!is_array($body)) {
$body = $_POST;
}
$action = (string) ($body['action'] ?? '');
$buildId = (int) ($body['build_id'] ?? 0);
if ($buildId <= 0) {
json_out(['ok' => false, 'error' => 'build_id required'], 400);
}
$user = Auth::user();
$userId = isset($user['id']) ? (int) $user['id'] : null;
try {
if ($action === 'stop') {
$ok = BuildRunner::stopBuild($buildId);
json_out(['ok' => $ok, 'build' => BuildRepository::getById($buildId)]);
}
if ($action === 'rerun') {
$build = BuildRunner::rerunBuild($buildId, null, $userId);
json_out(['ok' => true, 'build' => $build]);
}
if ($action === 'rerun_as') {
$override = is_array($body['params'] ?? null) ? $body['params'] : [];
$build = BuildRunner::rerunBuild($buildId, $override, $userId);
json_out(['ok' => true, 'build' => $build]);
}
json_out(['ok' => false, 'error' => 'unknown_action'], 400);
} catch (Throwable $e) {
error_log('build_actions: ' . $e->getMessage());
json_out(['ok' => false, 'error' => $e->getMessage()], 500);
}

View File

@@ -13,8 +13,7 @@ if (!is_array($params)) {
$params = $_POST;
}
$pipelinePath = (string) cfg('build.pipeline_config', '/workspace/build.config.yml');
$params['pipeline_yaml'] = is_file($pipelinePath) ? (string) file_get_contents($pipelinePath) : null;
$params['pipeline_yaml'] = BuildRunner::resolvePipelineYaml($params);
$params['run_tests'] = !empty($params['run_tests']);
$params['run_native'] = array_key_exists('run_native', $params) ? !empty($params['run_native']) : true;
$params['run_apk'] = array_key_exists('run_apk', $params) ? !empty($params['run_apk']) : true;

View File

@@ -58,6 +58,89 @@
});
}
function postBuildAction(payload) {
return fetch(base + '/api/build_actions.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify(payload)
}).then(function (r) {
return r.text().then(function (text) {
var j = null;
try { j = JSON.parse(text); } catch (e) {
throw new Error(r.status + ': ' + (text.slice(0, 120) || r.statusText));
}
if (!r.ok || !j.ok) {
throw new Error((j && j.error) || ('HTTP ' + r.status));
}
return j;
});
});
}
var rerunBtn = document.getElementById('build-rerun-btn');
var stopBtn = document.getElementById('build-stop-btn');
var rerunAsToggle = document.getElementById('build-rerun-as-toggle');
var rerunAsForm = document.getElementById('build-rerun-as-form');
var actionStatus = document.getElementById('build-action-status');
if (rerunAsToggle && rerunAsForm) {
rerunAsToggle.addEventListener('click', function () {
rerunAsForm.hidden = !rerunAsForm.hidden;
});
}
if (rerunBtn) {
rerunBtn.addEventListener('click', function () {
var id = Number(rerunBtn.getAttribute('data-build-id'));
if (!id) return;
if (actionStatus) actionStatus.textContent = 'Re-running…';
postBuildAction({ action: 'rerun', build_id: id }).then(function (j) {
if (j.build && j.build.id) {
window.location.href = base + '/?view=build&id=' + j.build.id;
}
}).catch(function (err) {
if (actionStatus) actionStatus.textContent = err.message || 'Failed';
});
});
}
if (stopBtn) {
stopBtn.addEventListener('click', function () {
var id = Number(stopBtn.getAttribute('data-build-id'));
if (!id || !confirm('Stop this build?')) return;
if (actionStatus) actionStatus.textContent = 'Stopping…';
postBuildAction({ action: 'stop', build_id: id }).then(function () {
window.location.reload();
}).catch(function (err) {
if (actionStatus) actionStatus.textContent = err.message || 'Failed';
});
});
}
if (rerunAsForm) {
rerunAsForm.addEventListener('submit', function (ev) {
ev.preventDefault();
var id = Number(rerunBtn && rerunBtn.getAttribute('data-build-id'));
if (!id) return;
var fd = new FormData(rerunAsForm);
var params = {
git_ref: fd.get('git_ref') || 'next',
gradle_task: fd.get('gradle_task') || 'assembleDebug',
ota_channel: fd.get('ota_channel') || 'staging',
run_tests: !!rerunAsForm.querySelector('[name=run_tests]').checked,
run_native: !!rerunAsForm.querySelector('[name=run_native]').checked,
run_apk: !!rerunAsForm.querySelector('[name=run_apk]').checked,
auto_ota: !!rerunAsForm.querySelector('[name=auto_ota]').checked,
auto_deploy: !!rerunAsForm.querySelector('[name=auto_deploy]').checked
};
if (actionStatus) actionStatus.textContent = 'Starting…';
postBuildAction({ action: 'rerun_as', build_id: id, params: params }).then(function (j) {
if (j.build && j.build.id) {
window.location.href = base + '/?view=build&id=' + j.build.id;
}
}).catch(function (err) {
if (actionStatus) actionStatus.textContent = err.message || 'Failed';
});
});
}
var buildId = document.body.getAttribute('data-build-id');
var logEl = document.getElementById('build-log-tail');
if (buildId && logEl) {

View File

@@ -25,6 +25,10 @@ if ($route === '/api/build_log.php' || str_ends_with($route, '/api/build_log.php
require __DIR__ . '/api/build_log.php';
exit;
}
if ($route === '/api/build_actions.php' || str_ends_with($route, '/api/build_actions.php')) {
require __DIR__ . '/api/build_actions.php';
exit;
}
if ($route === '/api/heartbeat.php' || str_ends_with($route, '/api/heartbeat.php')) {
require __DIR__ . '/api/heartbeat.php';
exit;
@@ -55,6 +59,8 @@ if ($route === '/login') {
Auth::check();
BuildRunner::reconcileRunningBuilds();
$view = $_GET['view'] ?? 'home';
if ($view === 'build' && isset($_GET['id'])) {

View File

@@ -111,6 +111,7 @@ SQL);
public static function healthSummary(): array {
self::ensureSchema();
BuildRunner::reconcileRunningBuilds();
$pdo = Database::pdo();
$running = (int) $pdo->query("SELECT COUNT(*) FROM builds WHERE status = 'running'")->fetchColumn();
$failed = (int) $pdo->query("SELECT COUNT(*) FROM builds WHERE status = 'failed' AND created_at > (NOW() - INTERVAL 7 DAY)")->fetchColumn();

View File

@@ -2,10 +2,62 @@
declare(strict_types=1);
final class BuildRunner {
public static function dockerAvailable(): bool {
return trim((string) shell_exec('docker info >/dev/null 2>&1 && echo ok')) === 'ok';
}
public static function generateBuildCode(): string {
return 'bld-' . gmdate('Ymd-His') . '-' . substr(bin2hex(random_bytes(3)), 0, 6);
}
public static function resolvePipelineYaml(array $params): string {
$candidates = array_filter([
(string) cfg('build.pipeline_config', ''),
rtrim((string) cfg('build.repo_root', ''), '/') . '/build.config.yml',
]);
foreach ($candidates as $path) {
if ($path !== '' && is_file($path)) {
$text = (string) file_get_contents($path);
if ($text !== '') {
return $text;
}
}
}
return self::generatePipelineYaml($params);
}
public static function generatePipelineYaml(array $params): string {
$gitRef = (string) ($params['git_ref'] ?? 'next');
$gradle = (string) ($params['gradle_task'] ?? 'assembleDebug');
$channel = (string) ($params['ota_channel'] ?? 'staging');
$runTests = !empty($params['run_tests']) ? 'true' : 'false';
$runNative = !empty($params['run_native']) ? 'true' : 'false';
$runApk = !empty($params['run_apk']) ? 'true' : 'false';
$autoOta = !empty($params['auto_ota']) ? 'true' : 'false';
$ciVer = (string) cfg('build.ci_version', '00.01.00.1000');
return <<<YAML
# Generated pipeline snapshot (build.config.yml missing on builder host)
version: 2.1
parameters:
git_ref: {$gitRef}
gradle_task: {$gradle}
run_tests: {$runTests}
run_native: {$runNative}
run_apk: {$runApk}
auto_ota: {$autoOta}
ota_channel: {$channel}
jobs:
android-build:
docker:
- image: androidcast-ci:{$ciVer}
steps:
- checkout
- run:
name: Gradle pipeline
command: ./scripts/ci-build-and-publish-ota.sh
YAML;
}
public static function artifactDir(int $buildId): string {
$root = rtrim((string) cfg('build.artifacts_root', '/workspace/out/builds'), '/');
return $root . '/' . $buildId;
@@ -13,6 +65,7 @@ final class BuildRunner {
public static function enqueue(array $params, ?int $userId): array {
BuildRepository::ensureSchema();
$params['pipeline_yaml'] = $params['pipeline_yaml'] ?? self::resolvePipelineYaml($params);
$buildCode = self::generateBuildCode();
$id = BuildRepository::create([
'build_code' => $buildCode,
@@ -20,7 +73,7 @@ final class BuildRunner {
'phase' => 'queued',
'git_ref' => $params['git_ref'] ?? null,
'git_sha' => $params['git_sha'] ?? null,
'branch' => $params['branch'] ?? null,
'branch' => $params['branch'] ?? ($params['git_ref'] ?? null),
'pipeline_yaml' => $params['pipeline_yaml'] ?? null,
'params_json' => json_encode($params, JSON_UNESCAPED_SLASHES),
'dockerfile_version' => cfg('build.ci_version', '00.01.00.1000'),
@@ -35,8 +88,14 @@ final class BuildRunner {
throw new RuntimeException('Cannot create artifact dir: ' . $dir);
}
$logPath = $dir . '/build.log';
BuildRepository::update($id, ['log_path' => $logPath]);
if (!self::dockerAvailable()) {
self::failBuild($id, 'Docker daemon not available', $logPath);
return BuildRepository::getById($id) ?? ['id' => $id, 'build_code' => $buildCode, 'status' => 'failed'];
}
BuildRepository::update($id, [
'log_path' => $logPath,
'status' => 'running',
'phase' => 'docker',
'started_at' => gmdate('Y-m-d H:i:s'),
@@ -67,6 +126,109 @@ final class BuildRunner {
return BuildRepository::getById($id) ?? ['id' => $id, 'build_code' => $buildCode];
}
public static function failBuild(int $id, string $message, ?string $logPath = null): void {
if ($logPath) {
file_put_contents($logPath, "\n[builder] ERROR: {$message}\n", FILE_APPEND);
}
BuildRepository::update($id, [
'status' => 'failed',
'phase' => 'error',
'error_message' => substr($message, 0, 512),
'finished_at' => gmdate('Y-m-d H:i:s'),
]);
}
public static function stopBuild(int $id): bool {
$build = BuildRepository::getById($id);
if (!$build || ($build['status'] ?? '') !== 'running') {
return false;
}
$dir = self::artifactDir($id);
$pidFile = $dir . '/runner.pid';
$pid = is_file($pidFile) ? trim((string) file_get_contents($pidFile)) : '';
if ($pid !== '' && ctype_digit($pid)) {
shell_exec('kill ' . escapeshellarg($pid) . ' 2>/dev/null');
}
$logPath = $build['log_path'] ?? ($dir . '/build.log');
if ($logPath) {
file_put_contents($logPath, "\n[builder] Stopped by operator\n", FILE_APPEND);
}
BuildRepository::update($id, [
'status' => 'cancelled',
'phase' => 'stopped',
'error_message' => 'Stopped by operator',
'finished_at' => gmdate('Y-m-d H:i:s'),
]);
return true;
}
/** @return array<string, mixed> */
public static function rerunBuild(int $id, ?array $overrideParams = null, ?int $userId = null): array {
$build = BuildRepository::getById($id);
if (!$build) {
throw new InvalidArgumentException('build_not_found');
}
$params = json_decode((string) ($build['params_json'] ?? '{}'), true);
if (!is_array($params)) {
$params = [];
}
if ($overrideParams) {
$params = array_merge($params, $overrideParams);
}
if (empty($params['git_ref'])) {
$params['git_ref'] = $build['git_ref'] ?? $build['branch'] ?? 'next';
}
$params['rerun_of'] = $id;
return self::enqueue($params, $userId);
}
public static function runnerAlive(int $buildId): bool {
$pidFile = self::artifactDir($buildId) . '/runner.pid';
$pid = is_file($pidFile) ? trim((string) file_get_contents($pidFile)) : '';
if ($pid === '' || !ctype_digit($pid)) {
return false;
}
$out = trim((string) shell_exec('kill -0 ' . escapeshellarg($pid) . ' 2>/dev/null && echo alive'));
return $out === 'alive';
}
public static function reconcileRunningBuilds(): void {
BuildRepository::ensureSchema();
$stmt = Database::pdo()->query("SELECT id, log_path FROM builds WHERE status = 'running'");
if (!$stmt) {
return;
}
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$id = (int) ($row['id'] ?? 0);
if ($id <= 0) {
continue;
}
$dir = self::artifactDir($id);
$logPath = (string) ($row['log_path'] ?? ($dir . '/build.log'));
if (is_file($dir . '/android_cast-latest.apk')) {
self::finalizeFromArtifacts($id);
continue;
}
$tail = '';
if (is_file($logPath)) {
$size = filesize($logPath) ?: 0;
$tail = (string) file_get_contents($logPath, false, null, max(0, $size - 8192));
}
if (self::runnerAlive($id)) {
continue;
}
if (str_contains($tail, 'Cannot connect to the Docker daemon') || !self::dockerAvailable()) {
self::failBuild($id, 'Docker daemon not available', $logPath);
} elseif (str_contains($tail, '[runner] finished')) {
self::finalizeFromArtifacts($id);
} elseif ($tail !== '') {
self::failBuild($id, 'Build runner exited unexpectedly', $logPath);
} else {
self::failBuild($id, 'Build runner never started', $logPath);
}
}
}
public static function readLogTail(?string $logPath, int $offset = 0, int $maxBytes = 65536): array {
if (!$logPath || !is_file($logPath)) {
return ['offset' => 0, 'eof' => true, 'text' => ''];

View File

@@ -1,9 +1,50 @@
<?php declare(strict_types=1);
$artifacts = json_decode($build['artifacts_json'] ?? '{}', true) ?: [];
$params = json_decode($build['params_json'] ?? '{}', true) ?: [];
$status = (string) ($build['status'] ?? '');
$isRunning = $status === 'running';
$pipelineYaml = trim((string) ($build['pipeline_yaml'] ?? ''));
if ($pipelineYaml === '') {
$pipelineYaml = '(none — will be generated on next trigger from build.config.yml or params)';
}
?>
<h1>Build <?= h($build['build_code'] ?? '') ?></h1>
<p class="muted">Status: <strong><?= h($build['status'] ?? '') ?></strong> · Phase: <?= h($build['phase'] ?? '') ?></p>
<p class="muted">Status: <strong><?= h($status) ?></strong> · Phase: <?= h($build['phase'] ?? '') ?>
<?php if (!empty($build['error_message'])): ?>
· <span class="error-text"><?= h($build['error_message']) ?></span>
<?php endif; ?>
</p>
<div class="build-actions card card--lift" style="margin-bottom:12px">
<h2>Actions</h2>
<div class="toolbar-actions" style="flex-wrap:wrap;gap:8px">
<button type="button" class="btn" id="build-rerun-btn" data-build-id="<?= (int) $build['id'] ?>">Re-run</button>
<button type="button" class="btn" id="build-rerun-as-toggle">Re-run as…</button>
<?php if ($isRunning): ?>
<button type="button" class="btn btn-danger" id="build-stop-btn" data-build-id="<?= (int) $build['id'] ?>">Stop</button>
<?php endif; ?>
<span id="build-action-status" class="muted"></span>
</div>
<form id="build-rerun-as-form" class="build-form" hidden style="margin-top:12px">
<div class="build-form-grid">
<label>Git ref<input name="git_ref" value="<?= h($params['git_ref'] ?? $build['git_ref'] ?? 'next') ?>"></label>
<label>Gradle task<input name="gradle_task" value="<?= h($params['gradle_task'] ?? 'assembleDebug') ?>"></label>
<label>OTA channel<select name="ota_channel">
<?php foreach (['staging', 'dev', 'nightly', 'stable'] as $ch): ?>
<option value="<?= h($ch) ?>" <?= (($params['ota_channel'] ?? $build['ota_channel'] ?? 'staging') === $ch) ? 'selected' : '' ?>><?= h($ch) ?></option>
<?php endforeach; ?>
</select></label>
</div>
<div class="build-form-checks">
<label><input type="checkbox" name="run_tests" <?= !empty($params['run_tests']) ? 'checked' : '' ?>> Unit tests</label>
<label><input type="checkbox" name="run_native" <?= !isset($params['run_native']) || !empty($params['run_native']) ? 'checked' : '' ?>> Native codecs</label>
<label><input type="checkbox" name="run_apk" <?= !isset($params['run_apk']) || !empty($params['run_apk']) ? 'checked' : '' ?>> APK output</label>
<label><input type="checkbox" name="auto_ota" <?= !empty($params['auto_ota']) ? 'checked' : '' ?>> Create OTA artifacts</label>
<label><input type="checkbox" name="auto_deploy" <?= !empty($params['auto_deploy']) ? 'checked' : '' ?>> Publish OTA to mount</label>
</div>
<button type="submit" class="btn btn-primary">Start parametrized re-run</button>
</form>
</div>
<div class="detail-grid card card--lift">
<dl>
@@ -30,7 +71,8 @@ $params = json_decode($build['params_json'] ?? '{}', true) ?: [];
<section class="card card--lift">
<h2>Pipeline (YAML)</h2>
<pre class="log-preview"><?= h($build['pipeline_yaml'] ?? '(none)') ?></pre>
<p class="muted graphs-footnote">Snapshot stored with this build. New triggers use <code>build.config.yml</code> from the repo when present, otherwise a generated default.</p>
<pre class="log-preview"><?= h($pipelineYaml) ?></pre>
</section>
<section class="card card--lift">

View File

@@ -826,6 +826,16 @@ a:hover { text-decoration: underline; }
color: #fff;
background: var(--tag-bg, #5c6b82);
}
button.report-tag--filter {
border: none;
cursor: pointer;
font: inherit;
color: inherit;
line-height: 1.3;
}
button.report-tag--filter:hover {
filter: brightness(1.12);
}
.reports-filter-banner {
margin: 8px 0 0;
padding: 10px 12px;

View File

@@ -224,17 +224,26 @@
return { label: kind || '?', bg: '#5c6b82' };
}
function renderTagPills(tags) {
function renderTagPills(tags, filterable) {
if (!tags.length) return '<span class="muted">—</span>';
return tags
.map(
(t) =>
'<span class="report-tag" style="--tag-bg:' +
escapeHtml(t.bg) +
'">' +
escapeHtml(t.label) +
'</span>'
)
.map((t) => {
const label = escapeHtml(t.label || t.id || '');
const bg = escapeHtml(t.bg || '#5c6b82');
const id = t.id ? String(t.id).trim() : '';
if (filterable && id) {
return (
'<button type="button" class="report-tag report-tag--filter" data-tag-id="' +
escapeHtml(id) +
'" style="--tag-bg:' +
bg +
'" title="Filter by tag">' +
label +
'</button>'
);
}
return '<span class="report-tag" style="--tag-bg:' + bg + '">' + label + '</span>';
})
.join('');
}
@@ -255,12 +264,13 @@
const id = String(t.id).toLowerCase();
if (id === 'new' || id === 'java' || id === 'ndk' || id === 'android') return;
tags.push({
id,
label: t.label || t.id,
bg: t.bg || '#5c6b82',
});
});
}
return renderTagPills(tags);
return renderTagPills(tags, !grouped);
}
function apiSortKey(key) {
@@ -621,12 +631,12 @@
if (!href) return;
const go = () => navigateTo(href);
const onActivate = (e) => {
if (e.target.closest('.report-tree-toggle')) return;
if (e.target.closest('.report-tree-toggle, .tag-edit-btn, .report-tag--filter')) return;
go();
};
el.addEventListener('click', onActivate);
el.addEventListener('keydown', (e) => {
if (e.target.closest('.report-tree-toggle')) return;
if (e.target.closest('.report-tree-toggle, .tag-edit-btn, .report-tag--filter')) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
go();
@@ -635,6 +645,16 @@
});
}
function bindReportTagFilters(root, onTag) {
if (!root || typeof onTag !== 'function') return;
root.querySelectorAll('.report-tag--filter').forEach((btn) => {
btn.addEventListener('click', (e) => {
const tagId = btn.getAttribute('data-tag-id') || '';
if (tagId) onTag(tagId, e);
});
});
}
function initReportsApp() {
const app = document.getElementById('reports-app');
if (!app) return;
@@ -948,6 +968,63 @@
});
tbody.innerHTML = html;
bindTreeInteractions(tbody);
bindReportTagFilters(tbody, handleReportTagClick);
}
function applyReportTagFilter(tagId) {
listContext.filterTags = [tagId];
listContext.tagMode = listContext.tagMode || 'and';
grouped = false;
app.setAttribute('data-grouped', '0');
page = 1;
updateFilterBanner(listContext, filterBanner);
const p = new URLSearchParams(window.location.search);
p.set('view', 'reports');
p.delete('tag');
p.append('tag', tagId);
p.set('tag_mode', listContext.tagMode);
p.delete('group');
p.delete('page');
history.replaceState(null, '', basePath() + '/?' + p.toString());
const modeWrap = document.getElementById('tag-mode-wrap');
if (modeWrap) modeWrap.hidden = false;
const modeSel = document.getElementById('tag-mode-select');
if (modeSel) modeSel.value = listContext.tagMode;
load();
}
function handleReportTagClick(tagId, ev) {
ev.stopPropagation();
ev.preventDefault();
if (grouped) {
applyReportTagFilter(tagId);
return;
}
const xhr = new XMLHttpRequest();
xhr.open(
'GET',
basePath() +
'/api/reports.php?page=1&per_page=2&tag=' +
encodeURIComponent(tagId) +
'&tag_mode=and&sort=received_at_ms&dir=desc&since_ms=0',
true
);
xhr.onload = function () {
let data;
try {
data = JSON.parse(xhr.responseText);
} catch {
return;
}
if (!data.ok) return;
const total = Number(data.total || 0);
if (total === 1 && data.items && data.items[0] && data.items[0].id) {
navigateTo(basePath() + '/?view=report&id=' + data.items[0].id);
return;
}
applyReportTagFilter(tagId);
};
xhr.send();
}
function load() {

View File

@@ -32,12 +32,20 @@
}
function renderTagPill(tag) {
const id = String(tag.id || '').trim();
const label = escapeHtml(tag.label || tag.id);
const bg = escapeHtml(tag.bg || '#5c6b82');
if (!id) {
return '<span class="report-tag" style="--tag-bg:' + bg + '">' + label + '</span>';
}
return (
'<span class="report-tag" style="--tag-bg:' +
escapeHtml(tag.bg || '#5c6b82') +
'">' +
escapeHtml(tag.label || tag.id) +
'</span>'
'<button type="button" class="report-tag report-tag--filter" data-tag-id="' +
escapeHtml(id) +
'" style="--tag-bg:' +
bg +
'" title="Filter by tag">' +
label +
'</button>'
);
}
@@ -172,6 +180,56 @@
bindRows();
}
function applyTagFilter(tagId) {
tag = tagId;
if (tagFilter) {
let found = false;
tagFilter.querySelectorAll('option').forEach((opt) => {
if (opt.value === tagId) found = true;
});
if (!found) {
const opt = document.createElement('option');
opt.value = tagId;
opt.textContent = tagId;
tagFilter.appendChild(opt);
}
tagFilter.value = tagId;
}
page = 1;
load();
}
function activateTag(tagId, ev) {
if (!tagId) return;
ev.stopPropagation();
ev.preventDefault();
const xhr = new XMLHttpRequest();
xhr.open(
'GET',
basePath() +
'/api/tickets.php?page=1&per_page=2&tag=' +
encodeURIComponent(tagId),
true
);
xhr.onload = function () {
let data;
try {
data = JSON.parse(xhr.responseText);
} catch {
return;
}
if (!data.ok) return;
const total = Number(data.total || 0);
if (total === 1 && data.items && data.items[0] && data.items[0].id) {
window.location.href =
basePath() + '/?view=ticket&id=' + encodeURIComponent(String(data.items[0].id));
return;
}
applyTagFilter(tagId);
};
xhr.send();
}
function bindRows() {
tbody.querySelectorAll('.report-tree-toggle').forEach((btn) => {
btn.addEventListener('click', (e) => {
@@ -192,11 +250,11 @@
window.location.href = href;
};
el.addEventListener('click', (e) => {
if (e.target.closest('.report-tree-toggle, .tag-edit-btn')) return;
if (e.target.closest('.report-tree-toggle, .tag-edit-btn, .report-tag--filter')) return;
go();
});
el.addEventListener('keydown', (e) => {
if (e.target.closest('.report-tree-toggle, .tag-edit-btn')) return;
if (e.target.closest('.report-tree-toggle, .tag-edit-btn, .report-tag--filter')) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
go();
@@ -212,6 +270,11 @@
}
});
});
tbody.querySelectorAll('.report-tag--filter').forEach((btn) => {
btn.addEventListener('click', (e) => {
activateTag(btn.getAttribute('data-tag-id') || '', e);
});
});
}
function renderPagination(total, currentPage) {