mirror of
git://f0xx.org/android_cast
synced 2026-07-29 05:58:14 +03:00
sync BE
This commit is contained in:
@@ -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'],
|
||||
],
|
||||
|
||||
43
examples/build_console/backend/public/api/build_actions.php
Normal file
43
examples/build_console/backend/public/api/build_actions.php
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'])) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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' => ''];
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -20,6 +20,14 @@ mkdir -p "$(dirname "$LOG_FILE")" "$OUT_DIR"
|
||||
echo "[runner] image=${IMAGE} ci_version=${CI_VERSION} build_id=${BUILD_ID}" | tee -a "$LOG_FILE"
|
||||
echo "[runner] workdir=${WORK_DIR} out=${OUT_DIR}" | tee -a "$LOG_FILE"
|
||||
|
||||
if [[ -n "${GIT_REF:-}" ]] && [[ -d "${WORK_DIR}/.git" ]]; then
|
||||
echo "[runner] syncing git ref=${GIT_REF}" | tee -a "$LOG_FILE"
|
||||
git -C "${WORK_DIR}" fetch origin --prune 2>&1 | tee -a "$LOG_FILE" || true
|
||||
git -C "${WORK_DIR}" checkout "${GIT_REF}" 2>&1 | tee -a "$LOG_FILE" || true
|
||||
git -C "${WORK_DIR}" pull --rebase origin "${GIT_REF}" 2>&1 | tee -a "$LOG_FILE" || true
|
||||
git -C "${WORK_DIR}" rev-parse HEAD 2>&1 | tee -a "$LOG_FILE" || true
|
||||
fi
|
||||
|
||||
docker build \
|
||||
--build-arg ANDROIDCAST_CI_VERSION="${CI_VERSION}" \
|
||||
-t "$IMAGE" \
|
||||
|
||||
Reference in New Issue
Block a user