1
0
mirror of git://f0xx.org/ac/ac-be-builder synced 2026-07-29 00:58:04 +03:00

feat(builder): shared nav shell, fail notify, sanitizer wiring

Signed-off-by: Anton Afanasyeu <a.afanasieff@gmail.com>
This commit is contained in:
Anton Afanasyeu
2026-07-12 13:53:37 +02:00
parent 608f4fdb2e
commit 4200ae8f12
12 changed files with 439 additions and 32 deletions

View File

@@ -37,6 +37,14 @@ return [
// Repo copy preferred; generated YAML used if missing on builder host
'pipeline_config' => '/workspace/build.config.yml',
'default_channels' => ['stable', 'staging', 'dev', 'nightly'],
'mobile_subdir' => 'ac-mobile-android',
'scripts_dir' => '/workspace/ac-scripts',
'notify' => [
'admin_email' => 'bestcastr@gmail.com',
'broadcast_config' => '/var/www/ac/workspace/ac-ms-broadcast/config/config.php',
'telegram_token' => '',
'telegram_chat_id' => '',
],
],
'links' => [
'hub' => '/app/androidcast_project/',

View File

@@ -21,6 +21,15 @@ $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';
$params['notify_on_fail'] = array_key_exists('notify_on_fail', $params) ? !empty($params['notify_on_fail']) : true;
$params['notify_channel'] = in_array(
(string) ($params['notify_channel'] ?? 'email'),
['email', 'telegram', 'both'],
true
) ? (string) $params['notify_channel'] : 'email';
if (!isset($params['trigger_source'])) {
$params['trigger_source'] = 'manual';
}
$user = Auth::user();
$build = BuildRunner::enqueue($params, isset($user['id']) ? (int) $user['id'] : null);

View File

@@ -139,7 +139,10 @@
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
auto_deploy: !!form.querySelector('[name=auto_deploy]').checked,
notify_on_fail: !!form.querySelector('[name=notify_on_fail]').checked,
notify_channel: fd.get('notify_channel') || 'email',
trigger_source: 'manual'
};
status.textContent = 'Starting…';
fetch(base + '/api/build_trigger.php', {

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
/** Strip URLs, paths with secrets, and credential-like tokens from user-visible build errors. */
final class BuildErrorSanitizer {
public static function sanitize(string $message): string {
if ($message === '') {
return '';
}
$out = $message;
$out = preg_replace('#https?://[^\s\'"<>]+#i', '***', $out) ?? $out;
$out = preg_replace('#git://[^\s\'"<>]+#i', '***', $out) ?? $out;
$out = preg_replace('#ssh://[^\s\'"<>]+#i', '***', $out) ?? $out;
$out = preg_replace(
'/\b(password|passwd|pass|token|secret|api[_-]?key|bearer|authorization)\s*[:=]\s*\S+/i',
'$1=***',
$out
) ?? $out;
$out = preg_replace(
'/(?:\/[^\s]+)?\/(?:\.env|credentials(?:\.json)?|secrets?\.(?:json|ya?ml|env))[^\s]*/i',
'***',
$out
) ?? $out;
$out = preg_replace('#/var/www/[^\s\'"]+#', '***', $out) ?? $out;
$out = preg_replace('#/home/[^\s\'"]+#', '***', $out) ?? $out;
return trim(preg_replace('/\s{2,}/', ' ', $out) ?? $out);
}
}

236
src/BuildNotifier.php Normal file
View File

@@ -0,0 +1,236 @@
<?php
declare(strict_types=1);
/** Failed-build alerts via email and Telegram (admin + optional user channel). */
final class BuildNotifier {
/** @param array<string, mixed> $build */
public static function onFailure(int $buildId, array $build, string $errorMessage): void {
$params = json_decode((string) ($build['params_json'] ?? '{}'), true);
if (!is_array($params)) {
$params = [];
}
$trigger = (string) ($params['trigger_source'] ?? 'manual');
$safeError = BuildErrorSanitizer::sanitize($errorMessage);
$code = (string) ($build['build_code'] ?? ('#' . $buildId));
$ref = (string) ($build['git_ref'] ?? $build['branch'] ?? '—');
$basePath = rtrim((string) cfg('base_path', '/app/androidcast_project/build'), '/');
$detailUrl = self::publicBaseUrl() . $basePath . '/?view=build&id=' . $buildId;
$subject = 'Android Cast build failed: ' . $code;
$bodyText = "Build {$code} failed (ref: {$ref}).\n\n"
. $safeError . "\n\n"
. "Details: {$detailUrl}\n";
if (in_array($trigger, ['autobuild', 'agent', 'ci'], true)) {
self::notifyAdmin($subject, $bodyText, $code, $safeError, $detailUrl);
return;
}
if (empty($params['notify_on_fail'])) {
return;
}
$channel = (string) ($params['notify_channel'] ?? 'email');
$userId = (int) ($build['created_by_user_id'] ?? 0);
$username = self::usernameFor($userId);
$userPrefix = $username !== '' ? "User {$username}: " : '';
if ($channel === 'telegram' || $channel === 'both') {
self::sendTelegram(
$userPrefix . $subject,
$bodyText,
self::telegramConfig()
);
}
if ($channel === 'email' || $channel === 'both') {
$to = self::emailForUser($userId);
if ($to !== '') {
self::sendPlainEmail($to, $subject, nl2br(h($bodyText)), self::smtpConfig());
}
}
}
private static function notifyAdmin(string $subject, string $bodyText, string $code, string $safeError, string $detailUrl): void {
$tg = self::telegramConfig();
if ($tg['token'] !== '' && $tg['chat_id'] !== '') {
self::sendTelegram($subject, $bodyText, $tg);
}
$admin = (string) cfg('build.notify.admin_email', 'bestcastr@gmail.com');
if ($admin !== '') {
$html = '<p><strong>' . h($code) . '</strong> failed.</p>'
. '<pre style="white-space:pre-wrap">' . h($safeError) . '</pre>'
. '<p><a href="' . h($detailUrl) . '">Open build log</a></p>';
self::sendPlainEmail($admin, $subject, $html, self::smtpConfig());
}
}
/** @return array{token:string,chat_id:string} */
private static function telegramConfig(): array {
$token = trim((string) cfg('build.notify.telegram_token', ''));
$chatId = trim((string) cfg('build.notify.telegram_chat_id', ''));
if ($token === '' || $chatId === '') {
$bc = self::loadBroadcastConfig();
$token = $token !== '' ? $token : trim((string) ($bc['telegram_token'] ?? ''));
$chatId = $chatId !== '' ? $chatId : trim((string) ($bc['telegram_chat_id'] ?? ''));
}
return ['token' => $token, 'chat_id' => $chatId];
}
/** @return array<string, mixed> */
private static function smtpConfig(): array {
$mail = cfg('mail', []);
if (is_array($mail) && !empty($mail['smtp'])) {
$smtp = $mail['smtp'];
return [
'host' => (string) ($smtp['host'] ?? 'smtp.gmail.com'),
'port' => (int) ($smtp['port'] ?? 587),
'user' => (string) ($smtp['user'] ?? $smtp['username'] ?? ''),
'pass' => (string) ($smtp['password'] ?? $smtp['pass'] ?? ''),
'from' => (string) ($mail['from'] ?? $smtp['user'] ?? ''),
'from_name' => (string) ($mail['from_name'] ?? 'AndroidCast Builder'),
];
}
$bc = self::loadBroadcastConfig();
return [
'host' => (string) ($bc['smtp_host'] ?? 'smtp.gmail.com'),
'port' => (int) ($bc['smtp_port'] ?? 587),
'user' => (string) ($bc['smtp_user'] ?? ''),
'pass' => (string) ($bc['smtp_password'] ?? ''),
'from' => (string) ($bc['smtp_from'] ?? $bc['smtp_user'] ?? ''),
'from_name' => (string) ($bc['smtp_from_name'] ?? 'AndroidCast Builder'),
];
}
/** @return array<string, mixed> */
private static function loadBroadcastConfig(): array {
static $cached = null;
if (is_array($cached)) {
return $cached;
}
$path = (string) cfg(
'build.notify.broadcast_config',
'/var/www/ac/workspace/ac-ms-broadcast/config/config.php'
);
if ($path !== '' && is_file($path)) {
$cfg = require $path;
$cached = is_array($cfg) ? $cfg : [];
return $cached;
}
$cached = [];
return $cached;
}
private static function publicBaseUrl(): string {
$host = $_SERVER['HTTP_X_FORWARDED_HOST'] ?? $_SERVER['HTTP_HOST'] ?? 'apps.f0xx.org';
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|| (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https')
? 'https' : 'http';
return $scheme . '://' . explode(',', (string) $host)[0];
}
private static function emailForUser(int $userId): string {
if ($userId <= 0) {
return '';
}
$stmt = Database::pdo()->prepare(
'SELECT email_normalized, username FROM users WHERE id = ? LIMIT 1'
);
$stmt->execute([$userId]);
$row = $stmt->fetch();
if (!$row) {
return '';
}
$email = trim((string) ($row['email_normalized'] ?? ''));
if ($email !== '' && str_contains($email, '@')) {
return $email;
}
return '';
}
private static function usernameFor(int $userId): string {
if ($userId <= 0) {
return '';
}
$stmt = Database::pdo()->prepare('SELECT username FROM users WHERE id = ? LIMIT 1');
$stmt->execute([$userId]);
$row = $stmt->fetch();
return $row ? (string) ($row['username'] ?? '') : '';
}
/** @param array{token:string,chat_id:string} $tg */
private static function sendTelegram(string $title, string $body, array $tg): void {
if ($tg['token'] === '' || $tg['chat_id'] === '') {
return;
}
$text = '<b>' . htmlspecialchars($title, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . "</b>\n\n"
. htmlspecialchars($body, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$payload = http_build_query([
'chat_id' => $tg['chat_id'],
'text' => $text,
'parse_mode' => 'HTML',
'disable_web_page_preview' => '1',
]);
$ctx = stream_context_create([
'http' => [
'method' => 'POST',
'header' => "Content-Type: application/x-www-form-urlencoded\r\n",
'content' => $payload,
'timeout' => 15,
'ignore_errors' => true,
],
]);
@file_get_contents(
'https://api.telegram.org/bot' . $tg['token'] . '/sendMessage',
false,
$ctx
);
}
/** @param array<string, mixed> $smtp */
private static function sendPlainEmail(string $to, string $subject, string $htmlBody, array $smtp): void {
if ($to === '' || empty($smtp['user']) || empty($smtp['pass'])) {
return;
}
$host = (string) ($smtp['host'] ?? 'smtp.gmail.com');
$port = (int) ($smtp['port'] ?? 587);
$from = (string) ($smtp['from'] ?? $smtp['user']);
$fromName = (string) ($smtp['from_name'] ?? 'AndroidCast Builder');
$boundary = bin2hex(random_bytes(8));
$headers = [
"From: {$fromName} <{$from}>",
"To: {$to}",
'Subject: =?UTF-8?B?' . base64_encode($subject) . '?=',
'MIME-Version: 1.0',
"Content-Type: multipart/alternative; boundary=\"{$boundary}\"",
];
$plain = strip_tags(str_replace(['<br>', '<br/>', '<br />'], "\n", $htmlBody));
$body = "--{$boundary}\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n{$plain}\r\n"
. "--{$boundary}\r\nContent-Type: text/html; charset=utf-8\r\n\r\n{$htmlBody}\r\n"
. "--{$boundary}--";
$sock = @fsockopen("tls://{$host}", $port, $errno, $errstr, 15);
if (!$sock) {
return;
}
$read = static fn(): string => (string) fgets($sock, 1024);
$send = static function (string $cmd) use ($sock): void {
fwrite($sock, $cmd . "\r\n");
};
$read();
$send('EHLO builder.local');
$read();
$send('AUTH LOGIN');
$read();
$send(base64_encode((string) $smtp['user']));
$read();
$send(base64_encode((string) $smtp['pass']));
$read();
$send("MAIL FROM:<{$from}>");
$read();
$send("RCPT TO:<{$to}>");
$read();
$send('DATA');
$read();
fwrite($sock, implode("\r\n", $headers) . "\r\n\r\n" . $body . "\r\n.\r\n");
$send('QUIT');
fclose($sock);
}
}

View File

@@ -94,6 +94,9 @@ YAML;
public static function enqueue(array $params, ?int $userId): array {
BuildRepository::ensureSchema();
if (!isset($params['trigger_source'])) {
$params['trigger_source'] = $userId !== null ? 'manual' : 'agent';
}
$params['pipeline_yaml'] = $params['pipeline_yaml'] ?? self::resolvePipelineYaml($params);
$buildCode = self::generateBuildCode();
$id = BuildRepository::create([
@@ -138,6 +141,8 @@ YAML;
. ' -C ' . escapeshellarg($repo)
. ' remote get-url origin 2>/dev/null'
));
$mobileSub = trim((string) cfg('build.mobile_subdir', 'ac-mobile-android'));
$scriptsDir = trim((string) cfg('build.scripts_dir', dirname((string) cfg('build.runner_script', ''))));
$env = [
'BUILD_ID=' . $id,
'BUILD_LOG_FILE=' . escapeshellarg($logPath),
@@ -146,6 +151,9 @@ YAML;
'BUILD_GIT_DEPTH=' . max(1, (int) cfg('build.git_clone_depth', 1)),
'BUILD_WORK_DIR=' . escapeshellarg($repo),
'BUILD_OUT_DIR=' . escapeshellarg($dir),
'BUILD_MOBILE_SUBDIR=' . escapeshellarg($mobileSub),
'BUILD_SCRIPTS_DIR=' . escapeshellarg($scriptsDir !== '' ? $scriptsDir : dirname($runner)),
'BUILD_DOCKERFILE=' . escapeshellarg((string) cfg('build.dockerfile', '')),
'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'] ?? '')),
@@ -185,15 +193,20 @@ YAML;
}
public static function failBuild(int $id, string $message, ?string $logPath = null): void {
$safe = BuildErrorSanitizer::sanitize($message);
if ($logPath) {
file_put_contents($logPath, "\n[builder] ERROR: {$message}\n", FILE_APPEND);
file_put_contents($logPath, "\n[builder] ERROR: {$safe}\n", FILE_APPEND);
}
BuildRepository::update($id, [
'status' => 'failed',
'phase' => 'error',
'error_message' => substr($message, 0, 512),
'error_message' => substr($safe, 0, 512),
'finished_at' => gmdate('Y-m-d H:i:s'),
]);
$build = BuildRepository::getById($id);
if ($build) {
BuildNotifier::onFailure($id, $build, $safe);
}
}
public static function stopBuild(int $id): bool {
@@ -276,6 +289,14 @@ YAML;
/** CircleCI-style: resume from the step that failed (git → docker-build → docker-run). */
public static function detectFailedStep(int $buildId): string {
$build = BuildRepository::getById($buildId);
if ($build) {
$err = (string) ($build['error_message'] ?? '');
$started = (string) ($build['started_at'] ?? '');
if ($started === '' && str_contains($err, 'Docker daemon not available')) {
return 'docker-setup';
}
}
$dir = self::artifactDir($buildId);
$logTail = '';
$logPath = $dir . '/build.log';
@@ -456,7 +477,7 @@ YAML;
if (strlen($summary) > 480) {
$summary = '…' . substr($summary, -477);
}
return 'Build runner exited unexpectedly: ' . $summary;
return BuildErrorSanitizer::sanitize('Build runner exited unexpectedly: ' . $summary);
}
public static function readLogTail(?string $logPath, int $offset = 0, int $maxBytes = 65536): array {
@@ -508,13 +529,27 @@ YAML;
$versionApp = $info['gitSha'] ?? null;
}
}
BuildRepository::update($buildId, [
$update = [
'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 ($status === 'failed') {
$update['error_message'] = substr(
BuildErrorSanitizer::sanitize('Build finished without APK artifact'),
0,
512
);
}
BuildRepository::update($buildId, $update);
if ($status === 'failed') {
$build = BuildRepository::getById($buildId);
if ($build) {
BuildNotifier::onFailure($buildId, $build, (string) ($update['error_message'] ?? 'Build failed'));
}
}
if (!empty($artifacts['ota']) && cfg('build.ota_mount')) {
$build = BuildRepository::getById($buildId);
if (!empty($build['auto_deploy'])) {
@@ -533,11 +568,86 @@ YAML;
return;
}
$build = BuildRepository::getById($buildId);
$channel = $build['ota_channel'] ?? 'staging';
$channel = (string) ($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'));
$channelJson = $src . '/ota/channel/' . $channel . '.json';
if (is_file($channelJson)) {
shell_exec(
'mkdir -p '
. escapeshellarg($dest . '/ota/channel')
. ' && cp -a '
. escapeshellarg($channelJson)
. ' '
. escapeshellarg($dest . '/ota/channel/' . $channel . '.json')
);
}
self::publishBrowserApk($src, $channel);
}
/** Copy installable .apk to hub /v0/downloads/ for browser sideload. */
private static function publishBrowserApk(string $otaV0Dir, string $channel): void {
$downloads = rtrim((string) cfg('build.downloads_mount', ''), '/');
if ($downloads === '') {
$parent = dirname(rtrim((string) cfg('build.ota_mount', ''), '/'));
$downloads = $parent !== '' && $parent !== '.'
? $parent . '/downloads'
: '/var/www/localhost/htdocs/apps/app/androidcast_project/downloads';
}
if (!is_dir($downloads) && !@mkdir($downloads, 0775, true) && !is_dir($downloads)) {
return;
}
$apkPath = self::resolveBrowserApkPath($otaV0Dir, $channel);
if ($apkPath === null || !is_file($apkPath)) {
return;
}
$latest = $downloads . '/android_cast-latest.apk';
$versioned = $downloads . '/' . basename($apkPath);
@copy($apkPath, $latest);
@copy($apkPath, $versioned);
@chown($latest, 'nginx');
@chgrp($latest, 'nginx');
@chown($versioned, 'nginx');
@chgrp($versioned, 'nginx');
}
private static function resolveBrowserApkPath(string $otaV0Dir, string $channel): ?string {
$channelFile = $otaV0Dir . '/ota/channel/' . $channel . '.json';
if (is_file($channelFile)) {
$channelData = json_decode((string) file_get_contents($channelFile), true);
$manifestUrl = is_array($channelData) ? (string) ($channelData['manifestUrl'] ?? '') : '';
if ($manifestUrl !== '') {
$manifestPath = self::otaUrlToLocalPath($manifestUrl, $otaV0Dir);
if ($manifestPath !== null && is_file($manifestPath)) {
$manifest = json_decode((string) file_get_contents($manifestPath), true);
if (is_array($manifest)) {
$browser = (string) ($manifest['browserApkUrl'] ?? '');
if ($browser !== '') {
$local = self::otaUrlToLocalPath($browser, $otaV0Dir);
if ($local !== null && is_file($local)) {
return $local;
}
}
}
}
}
}
$glob = glob($otaV0Dir . '/ota/*/*/*/*.apk') ?: [];
if ($glob !== []) {
usort($glob, static fn(string $a, string $b): int => filemtime($b) <=> filemtime($a));
return $glob[0];
}
return null;
}
private static function otaUrlToLocalPath(string $url, string $otaV0Dir): ?string {
$path = (string) parse_url($url, PHP_URL_PATH);
$prefix = '/v0/ota/';
if (!str_starts_with($path, $prefix)) {
return null;
}
$rel = substr($path, strlen($prefix));
$local = $otaV0Dir . '/ota/' . $rel;
return is_file($local) ? $local : null;
}
}

View File

@@ -75,6 +75,8 @@ foreach ([
}
require_once __DIR__ . '/BuildRepository.php';
require_once __DIR__ . '/BuildErrorSanitizer.php';
require_once __DIR__ . '/BuildNotifier.php';
require_once __DIR__ . '/BuildRunner.php';
function cfg(string $key, $default = null) {

View File

@@ -15,6 +15,7 @@ if ($pipelineYaml === '') {
}
$stepLabels = [
'git' => 'Git checkout / shallow clone',
'docker-setup' => 'Builder host (Docker daemon)',
'docker-build' => 'Docker image build',
'docker-run' => 'Gradle pipeline (docker run)',
];

View File

@@ -32,6 +32,14 @@
<label><input type="checkbox" name="auto_ota"> Create OTA artifacts</label>
<label><input type="checkbox" name="auto_deploy"> Publish OTA to mount</label>
</div>
<div class="build-form-grid" style="margin-top:10px">
<label><input type="checkbox" name="notify_on_fail" checked> Alert me if this build fails</label>
<label>Alert channel<select name="notify_channel">
<option value="email" selected>Email</option>
<option value="telegram">Telegram</option>
<option value="both">Email + Telegram</option>
</select></label>
</div>
<button type="submit" class="btn btn-primary">Start build</button>
<span id="build-trigger-status" class="muted"></span>
</form>

View File

@@ -1,6 +1,11 @@
<?php declare(strict_types=1);
$ab = assets_base();
$links = cfg('links', []);
$projectBase = Auth::projectBasePath();
$issuesBase = rtrim((string) cfg('links.crashes', $projectBase . '/issues'), '/');
$navActive = 'builder';
$user = Auth::user();
$navUser = is_array($user) ? trim((string) ($user['username'] ?? '')) : '';
?>
<!DOCTYPE html>
<html lang="en" data-theme="dark">
@@ -17,6 +22,7 @@ $links = cfg('links', []);
})();
</script>
<link rel="stylesheet" href="<?= h($ab) ?>/assets/css/app.css">
<script src="<?= h($ab) ?>/assets/js/nav_shell.js" defer></script>
<script src="<?= h($ab) ?>/assets/js/i18n.js" defer></script>
<script src="<?= h(Auth::basePath()) ?>/assets/js/builder.js" defer></script>
</head>
@@ -25,28 +31,24 @@ $links = cfg('links', []);
<?= (($view ?? '') === 'build' && !empty($build['id'])) ? ' data-build-id="' . (int) $build['id'] . '"' : '' ?>
<?= (($view ?? '') === 'build' && !empty($build['status'])) ? ' data-build-status="' . h((string) $build['status']) . '"' : '' ?>>
<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>
<?php platform_render_nav_shell([
'project_base' => $projectBase,
'issues_base' => $issuesBase,
'active' => $navActive,
'username' => $navUser,
]); ?>
<main class="main-pane">
<div class="toolbar reports-toolbar" style="margin-bottom:12px">
<div class="toolbar-actions" style="margin-left:auto">
<label class="toolbar-select">
<span data-i18n="theme.label">Theme</span>
<select id="theme-select" aria-label="UI theme">
<option value="dark">Dark</option>
<option value="light">Light</option>
</select>
</label>
</div>
</div>
<?php if (($view ?? '') === 'build' && !empty($build)): ?>
<?php require __DIR__ . '/build_detail.php'; ?>
<?php else: ?>