1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 03:57:50 +03:00

orchestration beginning

This commit is contained in:
Anton Afanasyeu
2026-05-30 17:55:36 +02:00
parent 95f521c4a0
commit a9a9590698
31 changed files with 1238 additions and 27 deletions

View File

@@ -0,0 +1,42 @@
<?php
return [
'app_name' => 'Android Cast Builder',
'base_path' => '/app/androidcast_project/build',
'session_name' => 'ac_crash_sess',
'session_cookie_path' => '/app/androidcast_project',
'db' => [
'driver' => 'mysql',
'sqlite_path' => __DIR__ . '/../../crash_reporter/backend/data/crashes.sqlite',
'mysql' => [
'host' => 'mariadb',
'port' => 3306,
'socket' => '',
'database' => 'androidcast_crashes',
'username' => 'androidcast',
'password' => 'androidcast',
'charset' => 'utf8mb4',
],
],
'debug' => true,
'rbac' => [
'default_company_slug' => 'default',
'default_company_id' => 1,
],
'build' => [
'docker_image' => 'androidcast-ci:latest',
'ci_version' => '00.01.00.1000',
'repo_root' => '/workspace',
'artifacts_root' => '/workspace/out/builds',
'ota_mount' => '/workspace/orchestration/runtime/ota-artifacts',
'ota_base_url' => 'http://localhost:8080',
'runner_script' => '/workspace/scripts/docker-build-runner.sh',
'pipeline_config' => '/workspace/build.config.yml',
'default_channels' => ['stable', 'staging', 'dev', 'nightly'],
],
'links' => [
'hub' => '/app/androidcast_project/',
'crashes' => '/app/androidcast_project/crashes/',
'git' => '/app/androidcast_project/git/',
'tickets' => '/app/androidcast_project/crashes/?view=tickets',
],
];

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../src/bootstrap.php';
Auth::check();
$id = (int) ($_GET['id'] ?? 0);
$offset = (int) ($_GET['offset'] ?? 0);
if ($id <= 0) {
json_out(['ok' => false, 'error' => 'id required'], 400);
}
$build = BuildRepository::getById($id);
if (!$build) {
json_out(['ok' => false, 'error' => 'not found'], 404);
}
$tail = BuildRunner::readLogTail($build['log_path'] ?? null, $offset);
if ($build['status'] === 'running' && $tail['eof'] && is_file(BuildRunner::artifactDir($id) . '/BUILD_INFO.json')) {
BuildRunner::finalizeFromArtifacts($id);
$build = BuildRepository::getById($id);
}
json_out([
'ok' => true,
'build' => $build,
'log' => $tail,
]);

View File

@@ -0,0 +1,28 @@
<?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') ?: '{}';
$params = json_decode($raw, true);
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['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;
$params['auto_ota'] = !empty($params['auto_ota']);
$params['auto_deploy'] = !empty($params['auto_deploy']);
$params['ota_channel'] = $params['ota_channel'] ?? 'staging';
$params['gradle_task'] = $params['gradle_task'] ?? 'assembleDebug';
$user = Auth::user();
$build = BuildRunner::enqueue($params, isset($user['id']) ? (int) $user['id'] : null);
json_out(['ok' => true, 'build' => $build]);

View File

@@ -0,0 +1,5 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../src/bootstrap.php';
Auth::check();
json_out(['ok' => true, 'builds' => BuildRepository::listRecent(10)]);

View File

@@ -0,0 +1,4 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../src/bootstrap.php';
json_out(['ok' => true, 'health' => BuildRepository::healthSummary()]);

View File

@@ -0,0 +1,74 @@
(function () {
'use strict';
var base = document.body.getAttribute('data-base-path') || '';
var themeSel = document.getElementById('theme-select');
if (themeSel) {
var cur = localStorage.getItem('crash_console_theme') || 'dark';
themeSel.value = cur;
themeSel.addEventListener('change', function () {
document.documentElement.setAttribute('data-theme', themeSel.value);
localStorage.setItem('crash_console_theme', themeSel.value);
});
}
var form = document.getElementById('build-trigger-form');
if (form) {
form.addEventListener('submit', function (ev) {
ev.preventDefault();
var status = document.getElementById('build-trigger-status');
var fd = new FormData(form);
var payload = {
git_ref: fd.get('git_ref') || 'next',
gradle_task: fd.get('gradle_task') || 'assembleDebug',
ota_channel: fd.get('ota_channel') || 'staging',
run_tests: !!form.querySelector('[name=run_tests]').checked,
run_native: !!form.querySelector('[name=run_native]').checked,
run_apk: !!form.querySelector('[name=run_apk]').checked,
auto_ota: !!form.querySelector('[name=auto_ota]').checked,
auto_deploy: !!form.querySelector('[name=auto_deploy]').checked
};
status.textContent = 'Starting…';
fetch(base + '/api/build_trigger.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
}).then(function (r) { return r.json(); }).then(function (j) {
if (j.ok && j.build && j.build.id) {
window.location.href = base + '/?view=build&id=' + j.build.id;
} else {
status.textContent = j.error || 'Failed';
}
}).catch(function () { status.textContent = 'Network error'; });
});
}
var buildId = document.body.getAttribute('data-build-id');
var logEl = document.getElementById('build-log-tail');
if (buildId && logEl) {
var offset = 0;
var full = '';
function poll() {
fetch(base + '/api/build_log.php?id=' + encodeURIComponent(buildId) + '&offset=' + offset)
.then(function (r) { return r.json(); })
.then(function (j) {
if (!j.ok) return;
if (j.log && j.log.text) {
full += j.log.text;
offset = j.log.offset || offset;
logEl.textContent = full.slice(-12000);
logEl.scrollTop = logEl.scrollHeight;
}
if (j.build && j.build.status === 'running') {
setTimeout(poll, 1000);
}
}).catch(function () { setTimeout(poll, 2000); });
}
poll();
var expand = document.getElementById('build-log-expand');
if (expand) {
expand.addEventListener('click', function () {
logEl.textContent = full;
});
}
}
})();

View File

@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../src/bootstrap.php';
$uri = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
$base = Auth::basePath();
if ($base !== '' && str_starts_with($uri, $base)) {
$uri = substr($uri, strlen($base)) ?: '/';
}
$route = rtrim($uri, '/') ?: '/';
if ($route === '/api/health.php' || str_ends_with($route, '/api/health.php')) {
require __DIR__ . '/api/health.php';
exit;
}
if ($route === '/api/builds.php' || str_ends_with($route, '/api/builds.php')) {
require __DIR__ . '/api/builds.php';
exit;
}
if ($route === '/api/build_trigger.php' || str_ends_with($route, '/api/build_trigger.php')) {
require __DIR__ . '/api/build_trigger.php';
exit;
}
if ($route === '/api/build_log.php' || str_ends_with($route, '/api/build_log.php')) {
require __DIR__ . '/api/build_log.php';
exit;
}
if ($route === '/logout') {
Auth::logout();
header('Location: ' . $base . '/login');
exit;
}
if ($route === '/login' && $_SERVER['REQUEST_METHOD'] === 'POST') {
$user = trim($_POST['username'] ?? '');
$pass = $_POST['password'] ?? '';
if (Auth::login($user, $pass)) {
header('Location: ' . $base . '/');
exit;
}
$loginError = 'Invalid credentials';
require __DIR__ . '/../views/login.php';
exit;
}
if ($route === '/login') {
require __DIR__ . '/../views/login.php';
exit;
}
Auth::check();
$view = $_GET['view'] ?? 'home';
if ($view === 'build' && isset($_GET['id'])) {
$build = BuildRepository::getById((int) $_GET['id']);
if (!$build) {
http_response_code(404);
echo 'Not found';
exit;
}
if ($build['status'] === 'running' && is_file(BuildRunner::artifactDir((int) $build['id']) . '/android_cast-latest.apk')) {
BuildRunner::finalizeFromArtifacts((int) $build['id']);
$build = BuildRepository::getById((int) $_GET['id']);
}
$view = 'build';
$pageTitle = 'Build ' . ($build['build_code'] ?? '');
require __DIR__ . '/../views/layout.php';
exit;
}
$view = 'home';
$health = BuildRepository::healthSummary();
$builds = BuildRepository::listRecent(10);
$pageTitle = 'Builder';
require __DIR__ . '/../views/layout.php';

View File

@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
final class BuildRepository {
public static function ensureSchema(): void {
$pdo = Database::pdo();
$pdo->exec(<<<'SQL'
CREATE TABLE IF NOT EXISTS builds (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
build_code VARCHAR(64) NOT NULL,
status ENUM('queued','running','passed','failed','cancelled') NOT NULL DEFAULT 'queued',
phase VARCHAR(64) NOT NULL DEFAULT 'init',
git_ref VARCHAR(256) NULL,
git_sha VARCHAR(64) NULL,
branch VARCHAR(128) NULL,
pipeline_yaml LONGTEXT NULL,
params_json LONGTEXT NOT NULL,
dockerfile_version VARCHAR(32) NOT NULL DEFAULT '00.01.00.1000',
builder_id VARCHAR(128) NOT NULL DEFAULT 'local-docker',
version_app VARCHAR(32) NULL,
version_code INT NULL,
target_os VARCHAR(32) NOT NULL DEFAULT 'android',
min_api_level INT NOT NULL DEFAULT 24,
ota_channel VARCHAR(64) NULL,
auto_ota TINYINT(1) NOT NULL DEFAULT 0,
auto_deploy TINYINT(1) NOT NULL DEFAULT 0,
log_path VARCHAR(512) NULL,
artifacts_json LONGTEXT NULL,
error_message VARCHAR(512) NULL,
created_by_user_id INT UNSIGNED NULL,
started_at TIMESTAMP NULL,
finished_at TIMESTAMP NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_builds_code (build_code),
KEY idx_builds_status (status),
KEY idx_builds_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL);
}
public static function create(array $row): int {
self::ensureSchema();
$pdo = Database::pdo();
$stmt = $pdo->prepare(<<<'SQL'
INSERT INTO builds (
build_code, status, phase, git_ref, git_sha, branch, pipeline_yaml, params_json,
dockerfile_version, builder_id, target_os, min_api_level, ota_channel, auto_ota, auto_deploy,
log_path, created_by_user_id
) VALUES (
:build_code, :status, :phase, :git_ref, :git_sha, :branch, :pipeline_yaml, :params_json,
:dockerfile_version, :builder_id, :target_os, :min_api_level, :ota_channel, :auto_ota, :auto_deploy,
:log_path, :created_by_user_id
)
SQL);
$stmt->execute([
':build_code' => $row['build_code'],
':status' => $row['status'] ?? 'queued',
':phase' => $row['phase'] ?? 'queued',
':git_ref' => $row['git_ref'] ?? null,
':git_sha' => $row['git_sha'] ?? null,
':branch' => $row['branch'] ?? null,
':pipeline_yaml' => $row['pipeline_yaml'] ?? null,
':params_json' => $row['params_json'] ?? '{}',
':dockerfile_version' => $row['dockerfile_version'] ?? '00.01.00.1000',
':builder_id' => $row['builder_id'] ?? 'local-docker',
':target_os' => $row['target_os'] ?? 'android',
':min_api_level' => (int) ($row['min_api_level'] ?? 24),
':ota_channel' => $row['ota_channel'] ?? null,
':auto_ota' => !empty($row['auto_ota']) ? 1 : 0,
':auto_deploy' => !empty($row['auto_deploy']) ? 1 : 0,
':log_path' => $row['log_path'] ?? null,
':created_by_user_id' => $row['created_by_user_id'] ?? null,
]);
return (int) $pdo->lastInsertId();
}
public static function update(int $id, array $fields): void {
self::ensureSchema();
$allowed = ['status', 'phase', 'version_app', 'version_code', 'artifacts_json', 'error_message', 'started_at', 'finished_at', 'git_sha', 'log_path'];
$sets = [];
$params = [':id' => $id];
foreach ($fields as $k => $v) {
if (!in_array($k, $allowed, true)) {
continue;
}
$sets[] = "$k = :$k";
$params[":$k"] = $v;
}
if (!$sets) {
return;
}
$sql = 'UPDATE builds SET ' . implode(', ', $sets) . ' WHERE id = :id';
Database::pdo()->prepare($sql)->execute($params);
}
public static function getById(int $id): ?array {
self::ensureSchema();
$stmt = Database::pdo()->prepare('SELECT * FROM builds WHERE id = ? LIMIT 1');
$stmt->execute([$id]);
$row = $stmt->fetch();
return $row ?: null;
}
public static function listRecent(int $limit = 10): array {
self::ensureSchema();
$stmt = Database::pdo()->prepare('SELECT * FROM builds ORDER BY id DESC LIMIT ?');
$stmt->bindValue(1, $limit, PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll() ?: [];
}
public static function healthSummary(): array {
self::ensureSchema();
$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();
$passed = (int) $pdo->query("SELECT COUNT(*) FROM builds WHERE status = 'passed' AND created_at > (NOW() - INTERVAL 7 DAY)")->fetchColumn();
$dockerOk = trim((string) shell_exec('docker info >/dev/null 2>&1 && echo ok')) === 'ok';
return [
'docker' => $dockerOk ? 'ok' : 'down',
'running' => $running,
'passed_7d' => $passed,
'failed_7d' => $failed,
'ci_version' => cfg('build.ci_version', '00.01.00.1000'),
];
}
}

View File

@@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
final class BuildRunner {
public static function generateBuildCode(): string {
return 'bld-' . gmdate('Ymd-His') . '-' . substr(bin2hex(random_bytes(3)), 0, 6);
}
public static function artifactDir(int $buildId): string {
$root = rtrim((string) cfg('build.artifacts_root', '/workspace/out/builds'), '/');
return $root . '/' . $buildId;
}
public static function enqueue(array $params, ?int $userId): array {
BuildRepository::ensureSchema();
$buildCode = self::generateBuildCode();
$id = BuildRepository::create([
'build_code' => $buildCode,
'status' => 'queued',
'phase' => 'queued',
'git_ref' => $params['git_ref'] ?? null,
'git_sha' => $params['git_sha'] ?? null,
'branch' => $params['branch'] ?? 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'),
'builder_id' => gethostname() ?: 'builder',
'ota_channel' => $params['ota_channel'] ?? 'staging',
'auto_ota' => !empty($params['auto_ota']),
'auto_deploy' => !empty($params['auto_deploy']),
'created_by_user_id' => $userId,
]);
$dir = self::artifactDir($id);
if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) {
throw new RuntimeException('Cannot create artifact dir: ' . $dir);
}
$logPath = $dir . '/build.log';
BuildRepository::update($id, [
'log_path' => $logPath,
'status' => 'running',
'phase' => 'docker',
'started_at' => gmdate('Y-m-d H:i:s'),
]);
$runner = (string) cfg('build.runner_script', '/workspace/scripts/docker-build-runner.sh');
$repo = (string) cfg('build.repo_root', '/workspace');
$env = [
'BUILD_ID=' . $id,
'BUILD_LOG_FILE=' . escapeshellarg($logPath),
'BUILD_WORK_DIR=' . escapeshellarg($repo),
'BUILD_OUT_DIR=' . escapeshellarg($dir),
'ANDROIDCAST_CI_VERSION=' . escapeshellarg((string) cfg('build.ci_version', '00.01.00.1000')),
'GIT_REF=' . escapeshellarg((string) ($params['git_ref'] ?? '')),
'GIT_SHA=' . escapeshellarg((string) ($params['git_sha'] ?? '')),
'GRADLE_TASK=' . escapeshellarg((string) ($params['gradle_task'] ?? 'assembleDebug')),
'RUN_UNIT_TESTS=' . (!empty($params['run_tests']) ? '1' : '0'),
'RUN_NATIVE=' . (!empty($params['run_native']) ? '1' : '0'),
'RUN_APK=' . (!empty($params['run_apk']) ? '1' : '0'),
'OTA_BASE_URL=' . escapeshellarg((string) ($params['ota_base_url'] ?? cfg('build.ota_base_url', ''))),
'OTA_CHANNEL=' . escapeshellarg((string) ($params['ota_channel'] ?? 'staging')),
'AUTO_OTA=' . (!empty($params['auto_ota']) ? '1' : '0'),
];
$cmd = implode(' ', $env) . ' nohup ' . escapeshellarg($runner) . ' >> ' . escapeshellarg($logPath) . ' 2>&1 & echo $!';
$pid = trim((string) shell_exec($cmd));
file_put_contents($dir . '/runner.pid', $pid);
return BuildRepository::getById($id) ?? ['id' => $id, 'build_code' => $buildCode];
}
public static function readLogTail(?string $logPath, int $offset = 0, int $maxBytes = 65536): array {
if (!$logPath || !is_file($logPath)) {
return ['offset' => 0, 'eof' => true, 'text' => ''];
}
$size = filesize($logPath) ?: 0;
$offset = max(0, min($offset, $size));
$fh = fopen($logPath, 'rb');
if (!$fh) {
return ['offset' => $offset, 'eof' => true, 'text' => ''];
}
fseek($fh, $offset);
$chunk = fread($fh, $maxBytes) ?: '';
$newOffset = $offset + strlen($chunk);
fclose($fh);
return ['offset' => $newOffset, 'eof' => $newOffset >= $size, 'text' => $chunk];
}
public static function finalizeFromArtifacts(int $buildId): void {
$dir = self::artifactDir($buildId);
$infoPath = $dir . '/BUILD_INFO.json';
$status = is_file($dir . '/android_cast-latest.apk') ? 'passed' : 'failed';
$artifacts = [];
if (is_file($dir . '/android_cast-latest.apk')) {
$artifacts['apk'] = 'android_cast-latest.apk';
}
if (is_dir($dir . '/ota/v0')) {
$artifacts['ota'] = 'ota/v0';
}
$versionApp = null;
if (is_file($infoPath)) {
$info = json_decode((string) file_get_contents($infoPath), true);
if (is_array($info)) {
$versionApp = $info['gitSha'] ?? null;
}
}
BuildRepository::update($buildId, [
'status' => $status,
'phase' => 'done',
'artifacts_json' => json_encode($artifacts, JSON_UNESCAPED_SLASHES),
'version_app' => $versionApp,
'finished_at' => gmdate('Y-m-d H:i:s'),
]);
if (!empty($artifacts['ota']) && cfg('build.ota_mount')) {
$build = BuildRepository::getById($buildId);
if (!empty($build['auto_deploy'])) {
self::publishOta($buildId, $dir);
}
}
}
private static function publishOta(int $buildId, string $dir): void {
$mount = rtrim((string) cfg('build.ota_mount', ''), '/');
if ($mount === '' || !is_dir($mount)) {
return;
}
$src = $dir . '/ota/v0';
if (!is_dir($src)) {
return;
}
$build = BuildRepository::getById($buildId);
$channel = $build['ota_channel'] ?? 'staging';
$dest = $mount . '/v0';
shell_exec('mkdir -p ' . escapeshellarg($dest) . ' && cp -a ' . escapeshellarg($src . '/.') . ' ' . escapeshellarg($dest . '/'));
if ($channel !== '' && $channel !== 'staging') {
shell_exec('cp -a ' . escapeshellarg($src . '/ota/channel/' . $channel . '.json') . ' ' . escapeshellarg($dest . '/ota/channel/' . $channel . '.json 2>/dev/null'));
}
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__, 2) . '/platform/shared_session.php';
$crashSrc = dirname(__DIR__, 2) . '/crash_reporter/backend/src';
$configPath = __DIR__ . '/../config/config.php';
if (!is_file($configPath)) {
$configPath = __DIR__ . '/../config/config.example.php';
}
$config = require $configPath;
platform_start_session(
$config['session_name'] ?? 'ac_crash_sess',
$config['session_cookie_path'] ?? '/app/androidcast_project'
);
foreach (['Database.php', 'Rbac.php', 'Auth.php', 'UserRepository.php', 'AnalyticsHead.php'] as $f) {
require_once $crashSrc . '/' . $f;
}
require_once __DIR__ . '/BuildRepository.php';
require_once __DIR__ . '/BuildRunner.php';
function cfg(string $key, $default = null) {
global $config;
$parts = explode('.', $key);
$v = $config;
foreach ($parts as $p) {
if (!is_array($v) || !array_key_exists($p, $v)) {
return $default;
}
$v = $v[$p];
}
return $v;
}
function h(mixed $s): string {
if ($s === null || $s === false) {
return '';
}
return htmlspecialchars((string) $s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
function json_out(array $data, int $code = 200): void {
http_response_code($code);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
exit;
}
function assets_base(): string {
return rtrim((string) cfg('links.crashes', '/app/androidcast_project/crashes/'), '/');
}

View File

@@ -0,0 +1,40 @@
<?php declare(strict_types=1);
$artifacts = json_decode($build['artifacts_json'] ?? '{}', true) ?: [];
$params = json_decode($build['params_json'] ?? '{}', true) ?: [];
?>
<h1>Build <?= h($build['build_code'] ?? '') ?></h1>
<p class="muted">Status: <strong><?= h($build['status'] ?? '') ?></strong> · Phase: <?= h($build['phase'] ?? '') ?></p>
<div class="detail-grid card card--lift">
<dl>
<dt>Builder</dt><dd><?= h($build['builder_id'] ?? '') ?></dd>
<dt>CI image</dt><dd><code><?= h($build['dockerfile_version'] ?? '') ?></code></dd>
<dt>Target OS</dt><dd><?= h($build['target_os'] ?? 'android') ?> (min API <?= (int) ($build['min_api_level'] ?? 24) ?>)</dd>
<dt>Git ref</dt><dd><code><?= h($build['git_ref'] ?? '—') ?></code></dd>
<dt>Git SHA</dt><dd><code><?= h($build['git_sha'] ?? '—') ?></code></dd>
<dt>OTA channel</dt><dd><?= h($build['ota_channel'] ?? '—') ?></dd>
<dt>Gradle task</dt><dd><code><?= h($params['gradle_task'] ?? '—') ?></code></dd>
</dl>
</div>
<?php if ($artifacts): ?>
<section class="card card--lift">
<h2>Artifacts</h2>
<ul>
<?php foreach ($artifacts as $k => $v): ?>
<li><strong><?= h($k) ?>:</strong> <code><?= h((string) $v) ?></code></li>
<?php endforeach; ?>
</ul>
</section>
<?php endif; ?>
<section class="card card--lift">
<h2>Pipeline (YAML)</h2>
<pre class="log-preview"><?= h($build['pipeline_yaml'] ?? '(none)') ?></pre>
</section>
<section class="card card--lift">
<h2>Build log</h2>
<pre id="build-log-tail" class="log-preview log-preview--live" aria-live="polite"></pre>
<button type="button" class="btn" id="build-log-expand">Show full log</button>
</section>

View File

@@ -0,0 +1,68 @@
<?php declare(strict_types=1); ?>
<h1>Android Cast Builder</h1>
<p class="muted">Docker CI for APK baking, OTA staging, and pipeline history.</p>
<section class="card card--lift" style="margin-bottom:16px">
<h2>Ecosystem health</h2>
<ul>
<li>Docker: <strong><?= h($health['docker'] ?? 'unknown') ?></strong></li>
<li>CI image version: <code><?= h($health['ci_version'] ?? '') ?></code></li>
<li>Running builds: <?= (int) ($health['running'] ?? 0) ?></li>
<li>Passed (7d): <?= (int) ($health['passed_7d'] ?? 0) ?> · Failed (7d): <?= (int) ($health['failed_7d'] ?? 0) ?></li>
</ul>
</section>
<section class="card card--lift" id="build-trigger-panel">
<h2>Run pipeline</h2>
<form id="build-trigger-form" class="build-form">
<div class="build-form-grid">
<label>Git ref (branch / tag / commit)<input name="git_ref" placeholder="next" value="next"></label>
<label>Gradle task<input name="gradle_task" value="assembleDebug"></label>
<label>OTA channel<select name="ota_channel">
<option value="staging" selected>staging</option>
<option value="dev">dev</option>
<option value="nightly">nightly</option>
<option value="stable">stable / prod</option>
</select></label>
</div>
<div class="build-form-checks">
<label><input type="checkbox" name="run_tests" checked> Unit tests</label>
<label><input type="checkbox" name="run_native" checked> Native codecs</label>
<label><input type="checkbox" name="run_apk" checked> APK output</label>
<label><input type="checkbox" name="auto_ota"> Create OTA artifacts</label>
<label><input type="checkbox" name="auto_deploy"> Publish OTA to mount</label>
</div>
<button type="submit" class="btn btn-primary">Start build</button>
<span id="build-trigger-status" class="muted"></span>
</form>
</section>
<section class="card card--lift">
<h2>Recent builds (last 10)</h2>
<div class="reports-table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Code</th>
<th>Status</th>
<th>Phase</th>
<th>Branch/ref</th>
<th>Channel</th>
<th>Created</th>
</tr>
</thead>
<tbody>
<?php foreach ($builds as $b): ?>
<tr>
<td><a href="<?= h(Auth::basePath()) ?>/?view=build&id=<?= (int) $b['id'] ?>"><?= h($b['build_code']) ?></a></td>
<td><?= h($b['status']) ?></td>
<td><?= h($b['phase']) ?></td>
<td><?= h($b['git_ref'] ?? $b['branch'] ?? '—') ?></td>
<td><?= h($b['ota_channel'] ?? '—') ?></td>
<td><?= h($b['created_at'] ?? '') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</section>

View File

@@ -0,0 +1,58 @@
<?php declare(strict_types=1);
$ab = assets_base();
$links = cfg('links', []);
?>
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= h($pageTitle ?? 'Builder') ?> — <?= h(cfg('app_name')) ?></title>
<script>
(function () {
var t = localStorage.getItem('crash_console_theme');
if (t === 'light' || t === 'dark') document.documentElement.setAttribute('data-theme', t);
var l = localStorage.getItem('crash_console_lang');
if (l === 'en' || l === 'ru') document.documentElement.setAttribute('lang', l);
})();
</script>
<link rel="stylesheet" href="<?= h($ab) ?>/assets/css/app.css">
<script src="<?= h($ab) ?>/assets/js/i18n.js" defer></script>
<script src="<?= h(Auth::basePath()) ?>/assets/js/builder.js" defer></script>
</head>
<body data-base-path="<?= h(Auth::basePath()) ?>"
data-view="<?= h($view ?? 'home') ?>"
<?= (($view ?? '') === 'build' && !empty($build['id'])) ? ' data-build-id="' . (int) $build['id'] . '"' : '' ?>>
<div class="shell">
<nav class="nav-pane" id="nav-pane" aria-label="Builder navigation">
<button type="button" class="nav-handle" id="nav-handle" aria-label="Toggle navigation">
<span class="nav-icon nav-icon--menu" aria-hidden="true"></span>
</button>
<ul class="nav-list">
<li><a href="<?= h(Auth::basePath()) ?>/" class="nav-link <?= ($view ?? '') === 'home' ? 'active' : '' ?>"><span class="nav-icon nav-icon--home"></span><span class="nav-label">Builder</span></a></li>
<li><a href="<?= h($links['crashes'] ?? '') ?>?view=reports" class="nav-link"><span class="nav-icon nav-icon--reports"></span><span class="nav-label">Crashes</span></a></li>
<li><a href="<?= h($links['tickets'] ?? '') ?>" class="nav-link"><span class="nav-icon nav-icon--tickets"></span><span class="nav-label">Tickets</span></a></li>
<li><a href="<?= h($links['git'] ?? '') ?>" class="nav-link"><span class="nav-icon nav-icon--git"></span><span class="nav-label">Git</span></a></li>
<li><a href="<?= h($links['hub'] ?? '') ?>" class="nav-link"><span class="nav-icon nav-icon--home"></span><span class="nav-label">Hub</span></a></li>
</ul>
<div class="nav-locale">
<label class="toolbar-select"><span>Theme</span>
<select id="theme-select" aria-label="UI theme"><option value="dark">Dark</option><option value="light">Light</option></select>
</label>
</div>
<div class="nav-user">
<span class="nav-user-name"><?= h(Auth::user()['username'] ?? '') ?></span>
<a href="<?= h(Auth::basePath()) ?>/logout" class="nav-link nav-link--logout">Logout</a>
</div>
</nav>
<main class="main-pane">
<?php if (($view ?? '') === 'build' && !empty($build)): ?>
<?php require __DIR__ . '/build_detail.php'; ?>
<?php else: ?>
<?php require __DIR__ . '/home.php'; ?>
<?php endif; ?>
</main>
</div>
<footer class="bottom-bar">(c) Anton Afanaasyeu, <?= (int) date('Y') ?></footer>
</body>
</html>

View File

@@ -0,0 +1,25 @@
<?php declare(strict_types=1);
$bp = Auth::basePath();
$ab = assets_base();
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Login — <?= h(cfg('app_name')) ?></title>
<link rel="stylesheet" href="<?= h($ab) ?>/assets/css/app.css">
</head>
<body class="login-page">
<form class="login-card" method="post" action="<?= h($bp) ?>/login">
<h1><?= h(cfg('app_name')) ?></h1>
<p class="muted">Sign in (same credentials as Crashes / Tickets)</p>
<?php if (!empty($loginError)): ?>
<div class="alert"><?= h($loginError) ?></div>
<?php endif; ?>
<label><span>Username</span><input name="username" autocomplete="username" required></label>
<label><span>Password</span><input name="password" type="password" autocomplete="current-password" required></label>
<button type="submit">Sign in</button>
</form>
</body>
</html>