1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 04:38:53 +03:00
This commit is contained in:
Anton Afanasyeu
2026-06-05 22:11:38 +02:00
parent a656f40922
commit cd2e69ad6b
12 changed files with 368 additions and 62 deletions

View File

@@ -118,10 +118,7 @@ function bakeEquirectangular(compositeData, mercatorFlatImg) {
rgba = sampleMercatorFlat(mercCtx, lon, lat);
}
} else {
rgba = sampleMercatorFlat(mercCtx, lon, lat);
if (rgba[3] < 16) {
rgba = [OCEAN_FALLBACK.r, OCEAN_FALLBACK.g, OCEAN_FALLBACK.b, 255];
}
rgba = [OCEAN_FALLBACK.r, OCEAN_FALLBACK.g, OCEAN_FALLBACK.b, 255];
}
const p = (j * EQ_WIDTH + i) * 4;
@@ -139,30 +136,60 @@ function bakeEquirectangular(compositeData, mercatorFlatImg) {
function drawEquirectSlice(ctx, eqCanvas, rotation, dx, dy, diam) {
const w = eqCanvas.width;
const h = eqCanvas.height;
const offsetPx = ((rotation / (2 * Math.PI)) % 1) * w;
const ox = ((offsetPx % w) + w) % w;
const ox = ((rotation / (2 * Math.PI)) * w % w + w) % w;
const destX = dx - diam * 0.5;
const destY = dy - diam * 0.5;
ctx.save();
ctx.beginPath();
ctx.arc(dx, dy, diam * 0.5, 0, Math.PI * 2);
ctx.clip();
ctx.drawImage(eqCanvas, -ox, 0, w, h, dx - diam * 0.5, dy - diam * 0.5, diam, diam);
ctx.drawImage(eqCanvas, w - ox, 0, w, h, dx - diam * 0.5 + diam, dy - diam * 0.5, diam, diam);
const slice1w = w - ox;
const destSlice1 = diam * (slice1w / w);
ctx.drawImage(eqCanvas, ox, 0, slice1w, h, destX, destY, destSlice1, diam);
if (ox > 0.5) {
ctx.drawImage(eqCanvas, 0, 0, ox, h, destX + destSlice1, destY, diam - destSlice1, diam);
}
ctx.restore();
}
function rotateY(x, y, z, rotY) {
return {
x: x * Math.cos(rotY) + z * Math.sin(rotY),
y,
z: -x * Math.sin(rotY) + z * Math.cos(rotY),
};
}
function strokeParallel(ctx, cx, cy, r, latRad, rotY, strokeStyle, lineWidth) {
const pts = [];
for (let i = 0; i <= 64; i++) {
const lon = -Math.PI + (i / 64) * 2 * Math.PI;
const cosLat = Math.cos(latRad);
const p = rotateY(cosLat * Math.sin(lon), Math.sin(latRad), cosLat * Math.cos(lon), rotY);
if (p.z > 0.04) {
pts.push({ x: cx + p.x * r, y: cy - p.y * r });
}
}
if (pts.length < 2) return;
ctx.beginPath();
ctx.moveTo(pts[0].x, pts[0].y);
for (let k = 1; k < pts.length; k++) ctx.lineTo(pts[k].x, pts[k].y);
ctx.strokeStyle = strokeStyle;
ctx.lineWidth = lineWidth;
ctx.lineCap = 'round';
ctx.stroke();
}
function strokeMeridian(ctx, cx, cy, r, lonRad, rotY, strokeStyle, lineWidth) {
const pts = [];
for (let i = 0; i <= 64; i++) {
const lat = -Math.PI / 2 + (i / 64) * Math.PI;
const cosLat = Math.cos(lat);
let x = cosLat * Math.sin(lonRad);
let y = Math.sin(lat);
let z = cosLat * Math.cos(lonRad);
const x2 = x * Math.cos(rotY) + z * Math.sin(rotY);
const z2 = -x * Math.sin(rotY) + z * Math.cos(rotY);
if (z2 > 0.04) {
pts.push({ x: cx + x2 * r, y: cy - y * r });
const p = rotateY(cosLat * Math.sin(lonRad), Math.sin(lat), cosLat * Math.cos(lonRad), rotY);
if (p.z > 0.04) {
pts.push({ x: cx + p.x * r, y: cy - p.y * r });
}
}
if (pts.length < 2) return;
@@ -190,26 +217,16 @@ function drawGridOverlay(ctx, rect, rotY) {
equatorGrad.addColorStop(0.5, 'rgba(219,231,246,0.88)');
equatorGrad.addColorStop(1, 'rgba(31,44,64,0.74)');
ctx.beginPath();
ctx.ellipse(cx, cy, r * 0.99, r * 0.034, 0, 0, 2 * Math.PI);
ctx.strokeStyle = equatorGrad;
ctx.lineWidth = Math.max(2, rect.scale * 0.007);
ctx.stroke();
const poleRy = r * 0.12;
ctx.beginPath();
ctx.ellipse(cx, cy - r * 0.52, r * 0.82, poleRy, 0, 0, 2 * Math.PI);
ctx.strokeStyle = 'rgba(231,236,243,0.72)';
ctx.lineWidth = Math.max(1.5, rect.scale * 0.0055);
ctx.stroke();
ctx.beginPath();
ctx.ellipse(cx, cy + r * 0.52, r * 0.82, poleRy, 0, 0, 2 * Math.PI);
ctx.stroke();
const meridianStroke = 'rgba(238,245,255,0.92)';
const primeStroke = 'rgba(238,245,255,0.98)';
const parallelStroke = 'rgba(231,236,243,0.72)';
const lw = Math.max(2.5, rect.scale * 0.008);
const lwPrime = Math.max(3, rect.scale * 0.0095);
const lwThin = Math.max(1.5, rect.scale * 0.0055);
strokeParallel(ctx, cx, cy, r, 0, rotY, equatorGrad, Math.max(2, rect.scale * 0.007));
strokeParallel(ctx, cx, cy, r, Math.PI * 0.25, rotY, parallelStroke, lwThin);
strokeParallel(ctx, cx, cy, r, -Math.PI * 0.25, rotY, parallelStroke, lwThin);
for (let lonDeg = -150; lonDeg <= 150; lonDeg += 30) {
const lon = (lonDeg * Math.PI) / 180;
@@ -330,7 +347,13 @@ export async function mountHubGlobe(stage, opts) {
ctx.clearRect(0, 0, px, px);
drawEquirectSlice(ctx, eqCanvas, rotation, cx, cy, r * 2);
drawGridOverlay(ctx, drawRect, rotation);
drawRim(ctx, composite.canvas, cx, cy, r, rim, rect.scale * dpr);
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(10,14,20,0.55)';
ctx.lineWidth = Math.max(2, rect.scale * dpr * 0.006);
ctx.stroke();
ctx.restore();
};
const onPointerDown = (ev) => {

View File

@@ -31,6 +31,10 @@ try {
$build = BuildRunner::rerunBuild($buildId, null, $userId);
json_out(['ok' => true, 'build' => $build]);
}
if ($action === 'force_rerun') {
$build = BuildRunner::forceRerun($buildId, $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);

View File

@@ -2,4 +2,6 @@
declare(strict_types=1);
require_once __DIR__ . '/../../src/bootstrap.php';
Auth::check();
BuildRunner::reconcileRunningBuilds();
BuildRunner::reconcileQueuedBuilds();
json_out(['ok' => true, 'builds' => BuildRepository::listRecent(10)]);

View File

@@ -39,27 +39,82 @@
}
}
function updateBuildRow(row, build) {
if (!row || !build) return;
row.setAttribute('data-build-status', build.status || '');
var cell = row.querySelector('[data-build-status-cell="' + build.id + '"]');
updateBuildStatusCell(cell, build.status, build.phase);
var play = row.querySelector('.build-transport-play');
var stop = row.querySelector('.build-transport-stop');
var active = build.status === 'running' || build.status === 'queued';
if (play) {
play.setAttribute('data-build-status', build.status || '');
}
if (stop) {
stop.setAttribute('data-build-status', build.status || '');
stop.disabled = !active;
}
}
function refreshRecentBuilds() {
var tbody = document.getElementById('builds-recent-tbody');
if (!tbody) return;
fetch(base + '/api/builds.php', { credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.then(function (j) {
if (!j.ok || !j.builds) return;
j.builds.forEach(function (b) {
var cell = tbody.querySelector('[data-build-status-cell="' + b.id + '"]');
updateBuildStatusCell(cell, b.status, b.phase);
});
})
.catch(function () {})
.then(function () {
setTimeout(refreshRecentBuilds, 4000);
var xhr = new XMLHttpRequest();
xhr.open('GET', base + '/api/builds.php', true);
xhr.onload = function () {
var j = null;
try { j = JSON.parse(xhr.responseText); } catch (e) { j = null; }
if (!j || !j.ok || !j.builds) return;
j.builds.forEach(function (b) {
var row = tbody.querySelector('tr[data-build-id="' + b.id + '"]');
updateBuildRow(row, b);
});
};
xhr.send();
setTimeout(refreshRecentBuilds, 1000);
}
function bindBuildListTransport() {
var tbody = document.getElementById('builds-recent-tbody');
if (!tbody) return;
tbody.addEventListener('click', function (ev) {
var play = ev.target.closest('.build-transport-play');
var stop = ev.target.closest('.build-transport-stop');
if (play) {
var pid = Number(play.getAttribute('data-build-id'));
var pst = play.getAttribute('data-build-status') || '';
if (!pid) return;
var msg = pst === 'running' || pst === 'queued'
? 'Force re-run? This stops the current run and starts a new build with the same parameters.'
: null;
if (msg && !confirm(msg)) return;
postBuildAction({ action: 'force_rerun', build_id: pid }).then(function (j) {
if (j.build && j.build.id) {
window.location.href = base + '/?view=build&id=' + j.build.id;
}
}).catch(function (err) {
alert(err.message || 'Re-run failed');
});
return;
}
if (stop) {
var sid = Number(stop.getAttribute('data-build-id'));
if (!sid || stop.disabled) return;
if (!confirm('Stop this build now?')) return;
postBuildAction({ action: 'stop', build_id: sid }).then(function () {
refreshRecentBuilds();
}).catch(function (err) {
alert(err.message || 'Stop failed');
});
}
});
}
if (document.body.getAttribute('data-view') === 'home' && document.getElementById('builds-recent-tbody')) {
setTimeout(refreshRecentBuilds, 4000);
bindBuildListTransport();
refreshRecentBuilds();
}
var themeSel = document.getElementById('theme-select');
if (themeSel) {
var cur = localStorage.getItem('crash_console_theme') || 'dark';

View File

@@ -112,6 +112,7 @@ SQL);
public static function healthSummary(): array {
self::ensureSchema();
BuildRunner::reconcileRunningBuilds();
BuildRunner::reconcileQueuedBuilds();
$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

@@ -131,12 +131,6 @@ YAML;
}
$phase = !empty($params['ssh_debug']) ? 'ssh_debug' : 'docker';
BuildRepository::update($id, [
'status' => 'running',
'phase' => $phase,
'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');
$gitRemote = trim((string) shell_exec(
@@ -176,7 +170,16 @@ YAML;
}
$cmd = implode(' ', $env) . ' nohup ' . escapeshellarg($runner) . ' >> ' . escapeshellarg($logPath) . ' 2>&1 & echo $!';
$pid = trim((string) shell_exec($cmd));
if ($pid === '' || !ctype_digit($pid)) {
self::failBuild($id, 'Build runner failed to start', $logPath);
return BuildRepository::getById($id) ?? ['id' => $id, 'build_code' => $buildCode, 'status' => 'failed'];
}
file_put_contents($dir . '/runner.pid', $pid);
BuildRepository::update($id, [
'status' => 'running',
'phase' => $phase,
'started_at' => gmdate('Y-m-d H:i:s'),
]);
return BuildRepository::getById($id) ?? ['id' => $id, 'build_code' => $buildCode];
}
@@ -195,7 +198,25 @@ YAML;
public static function stopBuild(int $id): bool {
$build = BuildRepository::getById($id);
if (!$build || ($build['status'] ?? '') !== 'running') {
if (!$build) {
return false;
}
$status = (string) ($build['status'] ?? '');
if ($status === 'queued') {
$dir = self::artifactDir($id);
$logPath = $build['log_path'] ?? ($dir . '/build.log');
if ($logPath) {
file_put_contents($logPath, "\n[builder] Stopped while queued\n", FILE_APPEND);
}
BuildRepository::update($id, [
'status' => 'cancelled',
'phase' => 'stopped',
'error_message' => 'Stopped while queued',
'finished_at' => gmdate('Y-m-d H:i:s'),
]);
return true;
}
if ($status !== 'running') {
return false;
}
$dir = self::artifactDir($id);
@@ -240,6 +261,19 @@ YAML;
return self::enqueue($params, $userId);
}
/** Stop an in-flight build (if any) and enqueue a fresh run with the same params. */
public static function forceRerun(int $id, ?int $userId = null): array {
$build = BuildRepository::getById($id);
if (!$build) {
throw new InvalidArgumentException('build_not_found');
}
$status = (string) ($build['status'] ?? '');
if (in_array($status, ['running', 'queued'], true)) {
self::stopBuild($id);
}
return self::rerunBuild($id, null, $userId);
}
/** CircleCI-style: resume from the step that failed (git → docker-build → docker-run). */
public static function detectFailedStep(int $buildId): string {
$dir = self::artifactDir($buildId);
@@ -364,6 +398,33 @@ YAML;
}
}
public static function reconcileQueuedBuilds(): void {
BuildRepository::ensureSchema();
$stmt = Database::pdo()->query("SELECT id, log_path, created_at FROM builds WHERE status = 'queued'");
if (!$stmt) {
return;
}
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$id = (int) ($row['id'] ?? 0);
if ($id <= 0) {
continue;
}
$logPath = (string) ($row['log_path'] ?? (self::artifactDir($id) . '/build.log'));
if (self::runnerAlive($id)) {
BuildRepository::update($id, [
'status' => 'running',
'phase' => 'docker',
'started_at' => gmdate('Y-m-d H:i:s'),
]);
continue;
}
$created = strtotime((string) ($row['created_at'] ?? '')) ?: 0;
if ($created > 0 && $created < time() - 120) {
self::failBuild($id, 'Build stuck in queued (runner never started)', $logPath);
}
}
}
/** Last lines / markers from build.log for reconcile error_message (max 512 chars). */
public static function summarizeLogFailure(?string $logPath): string {
if (!$logPath || !is_file($logPath)) {

View File

@@ -4,10 +4,10 @@
<section class="card card--lift" style="margin-bottom:16px">
<h2>Ecosystem health</h2>
<ul>
<li>Docker: <strong><?= h($health['docker'] ?? 'unknown') ?></strong></li>
<ul id="build-health-list">
<li>Docker: <strong id="build-health-docker"><?= 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>Running builds: <span id="build-health-running"><?= (int) ($health['running'] ?? 0) ?></span></li>
<li>Passed (7d): <?= (int) ($health['passed_7d'] ?? 0) ?> · Failed (7d): <?= (int) ($health['failed_7d'] ?? 0) ?></li>
</ul>
</section>
@@ -40,9 +40,10 @@
<section class="card card--lift">
<h2>Recent builds (last 10)</h2>
<div class="reports-table-wrap">
<table class="data-table">
<table class="data-table" id="builds-recent-table">
<thead>
<tr>
<th class="build-col-controls" aria-label="Job controls"></th>
<th>Code</th>
<th>Status</th>
<th>Phase</th>
@@ -52,13 +53,22 @@
</tr>
</thead>
<tbody id="builds-recent-tbody">
<?php foreach ($builds as $b): ?>
<tr data-build-id="<?= (int) $b['id'] ?>">
<?php foreach ($builds as $b):
$bStatus = (string) ($b['status'] ?? '');
$isActive = in_array($bStatus, ['running', 'queued'], true);
?>
<tr data-build-id="<?= (int) $b['id'] ?>" data-build-status="<?= h($bStatus) ?>">
<td class="build-row-controls">
<div class="build-transport-actions">
<button type="button" class="build-transport-btn build-transport-play" data-build-id="<?= (int) $b['id'] ?>" data-build-status="<?= h($bStatus) ?>" title="Re-run job" aria-label="Re-run build <?= h($b['build_code']) ?>"></button>
<button type="button" class="build-transport-btn build-transport-stop" data-build-id="<?= (int) $b['id'] ?>" data-build-status="<?= h($bStatus) ?>" title="Stop job" aria-label="Stop build <?= h($b['build_code']) ?>"<?= $isActive ? '' : ' disabled' ?>></button>
</div>
</td>
<td><a href="<?= h(Auth::basePath()) ?>/?view=build&id=<?= (int) $b['id'] ?>"><?= h($b['build_code']) ?></a></td>
<td>
<span class="build-status-cell-inner" data-build-status-cell="<?= (int) $b['id'] ?>">
<?= build_status_icon((string) ($b['status'] ?? '')) ?>
<span class="build-status-label"><?= h($b['status']) ?></span>
<?= build_status_icon($bStatus) ?>
<span class="build-status-label"><?= h($bStatus) ?></span>
</span>
</td>
<td class="build-phase-cell" data-build-phase-cell="<?= (int) $b['id'] ?>"><?= h($b['phase']) ?></td>

View File

@@ -1282,6 +1282,72 @@ button.report-tag--filter:hover {
pointer-events: none;
}
.build-col-controls { width: 4.5rem; }
.build-row-controls { vertical-align: middle; }
.build-transport-actions {
display: inline-flex;
align-items: center;
gap: 6px;
}
.build-transport-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
padding: 0;
border-radius: 50%;
border: 1px solid var(--border);
background: var(--surface);
cursor: pointer;
box-shadow:
0 0 0 1px rgba(61, 139, 253, .12),
0 2px 8px rgba(0, 0, 0, .25),
0 0 14px rgba(61, 139, 253, .1);
transition: box-shadow .18s ease, border-color .18s ease, transform .12s ease;
}
.build-transport-btn:hover:not(:disabled),
.build-transport-btn:focus-visible:not(:disabled) {
border-color: var(--accent);
box-shadow:
0 0 0 1px rgba(61, 139, 253, .45),
0 0 18px rgba(61, 139, 253, .35);
outline: none;
}
.build-transport-btn:active:not(:disabled) { transform: translateY(1px); }
.build-transport-btn:disabled {
opacity: .35;
cursor: not-allowed;
box-shadow: none;
}
.build-transport-play::before {
content: '';
display: block;
width: 0;
height: 0;
margin-left: 3px;
border-top: 6px solid transparent;
border-bottom: 6px solid transparent;
border-left: 10px solid var(--accent);
filter: drop-shadow(0 0 4px rgba(61, 139, 253, .45));
}
.build-transport-stop {
border-color: rgba(248, 113, 113, .55);
box-shadow:
0 0 0 1px rgba(248, 113, 113, .25),
0 2px 8px rgba(0, 0, 0, .25),
0 0 12px rgba(248, 113, 113, .18);
}
.build-transport-stop::before {
content: '';
display: block;
width: 9px;
height: 9px;
border-radius: 2px;
background: var(--danger);
box-shadow: 0 0 8px rgba(248, 113, 113, .55);
}
.tag-edit-btn {
flex-shrink: 0;
margin-left: 2px;

View File

@@ -40,6 +40,48 @@
.replace(/"/g, '&quot;');
}
function graphPermalink(scopeKey, suffix, days) {
const url = new URL(window.location.href);
url.searchParams.set('graph', scopeKey + '-' + suffix);
if (days) {
url.searchParams.set('days', String(days));
}
return url.toString();
}
function syncGraphPermalink(scopeKey, suffix) {
const daysSel = el('graphs-days');
const days = daysSel && daysSel.value ? daysSel.value : '14';
const link = graphPermalink(scopeKey, suffix, days);
if (window.history && window.history.replaceState) {
window.history.replaceState(null, '', link);
}
const share = el('graph-detail-share');
if (share) {
share.href = link;
}
}
function clearGraphPermalink() {
const url = new URL(window.location.href);
url.searchParams.delete('graph');
if (window.history && window.history.replaceState) {
window.history.replaceState(null, '', url.toString());
}
}
function openGraphFromPermalink() {
const params = new URLSearchParams(window.location.search);
const token = params.get('graph');
if (!token) return;
const node = document.getElementById('graph-' + token);
if (!node) return;
const card = node.closest('.card.card--lift');
if (card) {
openGraphDetail(card);
}
}
function parseCanvasId(id) {
const m = String(id || '').match(/^graph-(user|slug|platform)-(.+)$/);
if (!m) return null;
@@ -430,6 +472,7 @@
const overlay = el('graph-detail-overlay');
if (overlay) overlay.hidden = true;
detailState = null;
clearGraphPermalink();
const tip = el('graph-detail-tooltip');
if (tip) tip.hidden = true;
const statsEl = el('graph-detail-stats');
@@ -449,6 +492,7 @@
const scope = scopeData(parsed.scopeKey);
if (!scope) return;
const def = parsed.def;
syncGraphPermalink(parsed.scopeKey, parsed.suffix);
const fieldVal = scope[def.field];
const titleEl = el('graph-detail-title');
const canvasEl = el('graph-detail-canvas');
@@ -655,6 +699,21 @@
});
const closeBtn = el('graph-detail-close');
if (closeBtn) closeBtn.addEventListener('click', closeGraphDetail);
const copyBtn = el('graph-detail-copy-link');
if (copyBtn) {
copyBtn.addEventListener('click', function () {
const share = el('graph-detail-share');
const link = (share && share.href && share.href !== '#') ? share.href : window.location.href;
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(link).then(function () {
copyBtn.textContent = 'Copied';
setTimeout(function () { copyBtn.textContent = 'Copy link'; }, 1500);
}).catch(function () { window.prompt('Copy graph link:', link); });
} else {
window.prompt('Copy graph link:', link);
}
});
}
}
document.addEventListener('keydown', (ev) => {
if (ev.key === 'Escape') closeGraphDetail();
@@ -680,6 +739,11 @@
});
function load() {
const params = new URLSearchParams(window.location.search);
const daysParam = params.get('days');
if (daysParam && daysSel) {
daysSel.value = daysParam;
}
const days = Number(daysSel && daysSel.value ? daysSel.value : 14);
status.textContent = 'Loading…';
const xhr = new XMLHttpRequest();
@@ -708,6 +772,7 @@
markGraphBricks();
const cols = colsSel && colsSel.value ? colsSel.value : '2';
status.textContent = 'Loaded · viewer=' + viewer + ' · window=' + days + 'd · columns=' + cols;
openGraphFromPermalink();
};
xhr.onerror = function () {
status.textContent = 'Network error';

View File

@@ -246,7 +246,7 @@ final class GraphRepository {
'viewer' => $viewer,
'links' => self::serviceLinks(),
];
$payload['user'] = self::buildScope($days, $activeCompanyId, $scopeUserId);
$payload['user'] = self::buildScope($days, $activeCompanyId, null);
if ($viewer === 'user') {
return $payload;
}

View File

@@ -94,6 +94,23 @@ function json_out(array $data, int $code = 200): void {
exit;
}
/** Public URL prefix for the active console view (graphs vs crashes). */
function console_base_path(?string $view = null): string {
$view = $view ?? (string) ($_GET['view'] ?? '');
if ($view === 'graphs') {
$graphs = trim((string) cfg('graphs_base_path', ''));
if ($graphs !== '') {
return rtrim($graphs, '/');
}
$crashes = rtrim((string) cfg('base_path', ''), '/');
if (str_ends_with($crashes, '/crashes')) {
return substr($crashes, 0, -strlen('/crashes')) . '/graphs';
}
return '/app/androidcast_project/graphs';
}
return rtrim((string) cfg('base_path', ''), '/');
}
/**
* Raw POST body for crash ingest. Supports plain JSON or Content-Encoding: gzip
* (when nginx gunzip is off or the client talks to PHP directly).

View File

@@ -42,7 +42,7 @@
<?php endif; ?>
<?php AnalyticsHead::render('crashes'); ?>
</head>
<body data-base-path="<?= h(Auth::basePath()) ?>"
<body data-base-path="<?= h(console_base_path($view ?? null)) ?>"
data-view="<?= h($view ?? 'home') ?>"
data-can-tag-edit="<?= Auth::canEditTags() ? '1' : '0' ?>"
<?= (($view ?? '') === 'report' && !empty($report['id'])) ? ' data-report-id="' . (int) $report['id'] . '"' : '' ?>
@@ -357,6 +357,8 @@
<p id="graph-detail-stats" class="graph-detail-stats muted" aria-live="polite"></p>
</div>
<button type="button" class="btn graph-detail-close" id="graph-detail-close" aria-label="Close full view">Close</button>
<button type="button" class="btn" id="graph-detail-copy-link">Copy link</button>
<a class="btn" id="graph-detail-share" href="#" hidden aria-hidden="true"></a>
</header>
<div class="graph-detail-canvas-wrap">
<canvas id="graph-detail-canvas" hidden></canvas>