1
0
mirror of git://f0xx.org/ac/ac-be-tickets synced 2026-07-29 00:58:35 +03:00
This commit is contained in:
Anton Afanasyeu
2026-06-23 12:29:35 +02:00
commit 1d26b61111
9 changed files with 1660 additions and 0 deletions

3
README.md Normal file
View File

@@ -0,0 +1,3 @@
# ac-be-tickets
Thin BE UI. Remote: `git://f0xx.org/ac/ac-be-tickets`

19
composer.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "androidcast/be-tickets",
"description": "Tickets console UI",
"require": {
"php": ">=8.1",
"androidcast/platform-php": "dev-next",
"androidcast/platform-web": "dev-next"
},
"repositories": [
{
"type": "vcs",
"url": "git://f0xx.org/ac/ac-platform-php"
},
{
"type": "vcs",
"url": "git://f0xx.org/ac/ac-platform-web"
}
]
}

373
public/assets/js/tickets.js Normal file
View File

@@ -0,0 +1,373 @@
(function () {
function t(key, params) {
return window.CrashI18n ? window.CrashI18n.t(key, params) : key;
}
function basePath() {
return document.body.getAttribute('data-base-path') || '';
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function formatTime(ms) {
if (!ms) return '—';
const d = new Date(Number(ms));
return d.toISOString().replace('T', ' ').slice(0, 19);
}
function formatStars(n) {
const score = Math.max(0, Math.min(5, Number(n) || 0));
let html =
'<span class="star-rating" role="img" aria-label="' + score + ' out of 5">';
for (let i = 1; i <= 5; i++) {
html += '<span class="star' + (i <= score ? ' star--on' : '') + '"></span>';
}
return html + '</span>';
}
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 (
'<button type="button" class="report-tag report-tag--filter" data-tag-id="' +
escapeHtml(id) +
'" style="--tag-bg:' +
bg +
'" title="Filter by tag">' +
label +
'</button>'
);
}
function canTagEdit() {
return document.body.getAttribute('data-can-tag-edit') === '1';
}
function initTicketsApp() {
const app = document.getElementById('tickets-app');
const tbody = document.getElementById('tickets-tbody');
const statusEl = document.getElementById('tickets-status');
const pagination = document.getElementById('tickets-pagination');
const perPageSel = document.getElementById('tickets-per-page');
const tagFilter = document.getElementById('tickets-tag-filter');
const thead = document.querySelector('#tickets-table thead');
if (!app || !tbody) return;
let page = 1;
let perPage = 50;
let sort = 'opened_at_ms';
let dir = 'desc';
let tag = '';
function load() {
statusEl.textContent = t('reports.loading');
const q =
'?page=' +
page +
'&per_page=' +
perPage +
'&sort=' +
encodeURIComponent(sort) +
'&dir=' +
encodeURIComponent(dir) +
(tag ? '&tag=' + encodeURIComponent(tag) : '');
const xhr = new XMLHttpRequest();
xhr.open('GET', basePath() + '/api/tickets.php' + q, true);
xhr.onload = function () {
let data;
try {
data = JSON.parse(xhr.responseText);
} catch {
statusEl.textContent = t('reports.invalid_response');
return;
}
if (!data.ok) {
statusEl.textContent = data.hint || data.error || t('reports.load_failed');
return;
}
renderRows(data.items || []);
renderPagination(data.total || 0, data.page || 1);
const from = data.total ? (data.page - 1) * data.per_page + 1 : 0;
const to = Math.min(data.page * data.per_page, data.total);
statusEl.textContent = t('reports.status_showing', {
from,
to,
total: data.total,
});
};
xhr.onerror = function () {
statusEl.textContent = t('reports.network_error');
};
xhr.send();
}
window.ticketsReloadRef = load;
function renderRows(items) {
if (!items.length) {
tbody.innerHTML =
'<tr><td colspan="6" class="muted">' + escapeHtml(t('tickets.empty')) + '</td></tr>';
return;
}
let html = '';
items.forEach((row, i) => {
const briefId = 'tkt-brief-' + row.id + '-' + i;
const openUrl = basePath() + '/?view=ticket&id=' + row.id;
const issueTitle = escapeHtml(row.title || '');
const issueBrief = row.brief
? '<div class="ticket-issue-brief muted">' + escapeHtml(row.brief) + '</div>'
: '';
html +=
'<tr class="report-row report-row--nav" data-href="' +
escapeHtml(openUrl) +
'" tabindex="0" role="link">';
html +=
'<td class="report-tree-cell"><button type="button" class="report-tree-toggle" aria-expanded="false" aria-controls="' +
briefId +
'"><span class="report-tree-arrow"></span></button></td>';
html +=
'<td class="ticket-col-issue"><strong class="ticket-issue-title">' +
issueTitle +
'</strong>' +
issueBrief +
'</td>';
html += '<td>' + formatTime(row.opened_at_ms) + '</td>';
html +=
'<td class="ticket-col-env"><span>' +
escapeHtml(row.env_brief || '') +
'</span><br><span class="muted">' +
escapeHtml(row.device_model || '') +
'</span></td>';
html += '<td>' + formatStars(row.rating) + '</td>';
const tagEditBtn = canTagEdit()
? '<button type="button" class="tag-edit-btn" data-ticket-id="' +
row.id +
'" title="Edit tags">✎</button>'
: '';
const tags = (row.tags || []).map(renderTagPill).join('');
html +=
'<td class="col-tags"><div class="report-tags">' +
tags +
tagEditBtn +
'</div></td>';
html += '</tr>';
html +=
'<tr class="report-brief-row" id="' +
briefId +
'" hidden><td colspan="6"><div class="report-brief-panel report-row--nav" data-href="' +
escapeHtml(openUrl) +
'" tabindex="0" role="link"><p class="muted">' +
escapeHtml(t('row.open_report')) +
'</p><ul class="report-brief-lines">';
[row.brief, row.env_brief, 'Owner: ' + (row.owner_username || '—')]
.filter(Boolean)
.forEach((line) => {
html += '<li>' + escapeHtml(String(line)) + '</li>';
});
html += '</ul></div></td></tr>';
});
tbody.innerHTML = html;
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) => {
e.stopPropagation();
e.preventDefault();
const controls = btn.getAttribute('aria-controls');
const row = controls ? document.getElementById(controls) : null;
if (!row) return;
const open = row.hidden;
row.hidden = !open;
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
});
});
tbody.querySelectorAll('[data-href]').forEach((el) => {
const href = el.getAttribute('data-href');
if (!href) return;
const go = () => {
window.location.href = href;
};
el.addEventListener('click', (e) => {
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, .report-tag--filter')) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
go();
}
});
});
tbody.querySelectorAll('.tag-edit-btn').forEach((btn) => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
e.preventDefault();
if (typeof window.openTagModal === 'function') {
window.openTagModal(Number(btn.getAttribute('data-ticket-id')), 'ticket');
}
});
});
tbody.querySelectorAll('.report-tag--filter').forEach((btn) => {
btn.addEventListener('click', (e) => {
activateTag(btn.getAttribute('data-tag-id') || '', e);
});
});
}
function renderPagination(total, currentPage) {
if (!pagination) return;
page = currentPage;
const pages = Math.max(1, Math.ceil(total / perPage));
const prev = page > 1 ? page - 1 : null;
const next = page < pages ? page + 1 : null;
let html = '<div class="pagination-inner">';
html +=
'<button type="button" class="btn page-btn" data-page="' +
(prev || '') +
'" ' +
(prev ? '' : 'disabled') +
'>← Previous</button>';
html +=
'<span class="page-info">Page ' +
page +
' / ' +
pages +
' · ' +
total +
' tickets</span>';
html +=
'<button type="button" class="btn page-btn" data-page="' +
(next || '') +
'" ' +
(next ? '' : 'disabled') +
'>Next →</button></div>';
pagination.innerHTML = html;
pagination.querySelectorAll('.page-btn').forEach((btn) => {
btn.addEventListener('click', () => {
const p = Number(btn.getAttribute('data-page'));
if (!p) return;
page = p;
load();
});
});
}
if (perPageSel) {
perPageSel.addEventListener('change', () => {
perPage = Number(perPageSel.value) || 50;
page = 1;
load();
});
}
if (tagFilter) {
tagFilter.addEventListener('change', () => {
tag = tagFilter.value || '';
page = 1;
load();
});
}
if (thead) {
thead.querySelectorAll('.sortable').forEach((th) => {
th.addEventListener('click', () => {
const key = th.getAttribute('data-sort');
if (!key) return;
if (sort === key) {
dir = dir === 'asc' ? 'desc' : 'asc';
} else {
sort = key;
dir = 'desc';
}
thead.querySelectorAll('.sortable').forEach((x) => {
x.classList.toggle('sortable--active', x === th);
});
page = 1;
load();
});
});
}
window.__ticketsReload = function () {
page = 1;
load();
};
load();
}
function onReady(fn) {
if (window.CrashI18n && window.CrashI18n.ready) {
window.CrashI18n.ready.then(fn);
} else {
fn();
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => onReady(initTicketsApp));
} else {
onReady(initTicketsApp);
}
})();

2
public/index.php Normal file
View File

@@ -0,0 +1,2 @@
<?php
require __DIR__ . '/../views/layout.php';

825
views/layout.php Normal file
View File

@@ -0,0 +1,825 @@
<?php
/*
* package examples/crash_reporter/backend/views/layout.php
* layout.php
* Created at: Wed 20 May 2026 14:31:55 +0200
* Updated at: Wed 20 May 2026 15:17:13 +0200 by Anton Afanasyeu <a.afanasieff@gmail.com>
* Commit: 5d8e82d2e60a21fff3138d2a394ee4e8b4c6dcb8
* Contributors:
* - Anton Afanasyeu <a.afanasieff@gmail.com> (2 commits, 86 lines)
* - Cursor Agent (project assistant)
* Digest: SHA256 21e56cd12df6a94df41a1c0d67be530bd01454865ee1f032cef5f8d2e497509a
*/
?>
<!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 ?? 'Console') ?> — <?= 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(Auth::basePath()) ?>/assets/css/app.css">
<script src="<?= h(Auth::basePath()) ?>/assets/js/nav_shell.js" defer></script>
<script src="<?= h(Auth::basePath()) ?>/assets/js/i18n.js" defer></script>
<script src="<?= h(Auth::basePath()) ?>/assets/js/app.js" defer></script>
<script src="<?= h(Auth::basePath()) ?>/assets/js/cookie_consent.js" defer></script>
<script src="<?= h(Auth::basePath()) ?>/assets/js/ticket_create.js" defer></script>
<?php if (in_array($view ?? '', ['tickets', 'ticket'], true)): ?>
<script src="<?= h(Auth::basePath()) ?>/assets/js/tickets.js" defer></script>
<?php endif; ?>
<?php if (($view ?? '') === 'graphs'): ?>
<script src="<?= h(Auth::basePath()) ?>/assets/js/graphs.js" defer></script>
<?php endif; ?>
<?php if (($view ?? '') === 'live_sessions'): ?>
<script src="<?= h(Auth::basePath()) ?>/assets/js/live_sessions.js" defer></script>
<?php endif; ?>
<?php if (($view ?? '') === 'remote_access'): ?>
<script src="<?= h(Auth::basePath()) ?>/assets/js/remote_access.js" defer></script>
<?php endif; ?>
<?php if (($view ?? '') === 'short_links'): ?>
<script src="<?= h(Auth::basePath()) ?>/assets/js/short_links.js" defer></script>
<?php endif; ?>
<?php if (($view ?? '') === 'rbac'): ?>
<script src="<?= h(Auth::basePath()) ?>/assets/js/rbac_admin.js" defer></script>
<?php endif; ?>
<?php AnalyticsHead::render('crashes'); ?>
</head>
<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'] . '"' : '' ?>
<?= (($view ?? '') === 'ticket' && !empty($ticket['id'])) ? ' data-ticket-id="' . (int) $ticket['id'] . '"' : '' ?>
<?= Rbac::can('remote_access_operate') ? ' data-can-ra-operate="1"' : '' ?>
<?= Rbac::can('remote_access_admin') ? ' data-can-ra-admin="1"' : '' ?>
<?= Rbac::can('short_links_operate') ? ' data-can-sl-operate="1"' : '' ?>
<?= Rbac::isGlobalAdmin() ? ' data-can-sl-admin="1"' : '' ?>
<?= Rbac::isRoot() ? ' data-can-rbac-root="1"' : '' ?>>
<header class="top-menu" hidden aria-hidden="true"></header>
<div class="shell<?= ($view ?? '') === 'graphs' ? ' shell--graphs-full' : '' ?>">
<nav class="nav-pane" id="nav-pane" aria-label="Console navigation">
<button type="button" class="nav-handle" id="nav-handle" data-i18n-aria="nav.toggle" data-i18n-title="nav.toggle" aria-label="Toggle navigation" title="Navigation">
<span class="nav-icon nav-icon--menu" aria-hidden="true"></span>
</button>
<ul class="nav-list">
<li>
<a href="<?= h(Auth::basePath()) ?>/?view=home"
class="nav-link <?= ($view ?? '') === 'home' ? 'active' : '' ?>"
data-i18n-aria="nav.home" data-i18n-title="nav.home"
aria-label="Home"
title="Home">
<span class="nav-icon nav-icon--home" aria-hidden="true"></span>
<span class="nav-text">
<span class="nav-label" data-i18n="nav.home">Home</span>
<span class="nav-desc" data-i18n="nav.home_desc">Console home and workspace cards.</span>
</span>
</a>
</li>
<li>
<a href="<?= h(Auth::basePath()) ?>/?view=reports"
class="nav-link <?= ($view ?? '') === 'reports' || ($view ?? '') === 'report' ? 'active' : '' ?>"
data-i18n-aria="nav.reports" data-i18n-title="nav.reports"
aria-label="Reports"
title="Reports">
<span class="nav-icon nav-icon--reports" aria-hidden="true"></span>
<span class="nav-text">
<span class="nav-label" data-i18n="nav.reports">Reports</span>
<span class="nav-desc" data-i18n="nav.reports_desc">Browse and triage issues.</span>
</span>
</a>
</li>
<li>
<a href="<?= h(Auth::basePath()) ?>/?view=tickets"
class="nav-link <?= ($view ?? '') === 'tickets' || ($view ?? '') === 'ticket' ? 'active' : '' ?>"
data-i18n-aria="nav.tickets" data-i18n-title="nav.tickets"
aria-label="Tickets"
title="Tickets">
<span class="nav-icon nav-icon--tickets" aria-hidden="true"></span>
<span class="nav-text">
<span class="nav-label" data-i18n="nav.tickets">Tickets</span>
<span class="nav-desc" data-i18n="nav.tickets_desc">Roadmap tasks, QA items, and tags.</span>
</span>
</a>
</li>
<li>
<a href="/app/androidcast_project/graphs/"
class="nav-link <?= ($view ?? '') === 'graphs' ? 'active' : '' ?>"
aria-label="Analytics"
title="Analytics">
<span class="nav-icon nav-icon--graphs" aria-hidden="true"></span>
<span class="nav-text">
<span class="nav-label" data-i18n="nav.graphs">Analytics</span>
<span class="nav-desc" data-i18n="nav.graphs_desc">Sessions, issues, and device activity.</span>
</span>
</a>
</li>
<li>
<a href="<?= h(Auth::basePath()) ?>/?view=live_sessions"
class="nav-link <?= ($view ?? '') === 'live_sessions' ? 'active' : '' ?>"
aria-label="Live sessions"
title="Live sessions">
<span class="nav-icon nav-icon--live" aria-hidden="true"></span>
<span class="nav-text">
<span class="nav-label">Live sessions</span>
<span class="nav-desc">Cast intents, join stats, and session history.</span>
</span>
</a>
</li>
<li>
<a href="<?= h(Auth::basePath()) ?>/live/education"
class="nav-link"
aria-label="Education demo"
title="Education demo">
<span class="nav-icon nav-icon--education" aria-hidden="true"></span>
<span class="nav-text">
<span class="nav-label">Education demo</span>
<span class="nav-desc">Browser screen-share trial (5 min).</span>
</span>
</a>
</li>
<?php if (Rbac::can('remote_access_view')): ?>
<li>
<a href="<?= h(Auth::basePath()) ?>/?view=remote_access"
class="nav-link <?= ($view ?? '') === 'remote_access' ? 'active' : '' ?>"
aria-label="Remote access"
title="Remote access">
<span class="nav-icon nav-icon--remote" aria-hidden="true"></span>
<span class="nav-text">
<span class="nav-label" data-i18n="nav.remote">Remote access</span>
<span class="nav-desc" data-i18n="nav.remote_desc">WireGuard sessions and device reachability.</span>
</span>
</a>
</li>
<?php endif; ?>
<?php if (Rbac::can('short_links_view')): ?>
<li>
<a href="<?= h(Auth::basePath()) ?>/?view=short_links"
class="nav-link <?= ($view ?? '') === 'short_links' ? 'active' : '' ?>"
aria-label="Short links"
title="Short links">
<span class="nav-icon nav-icon--link" aria-hidden="true"></span>
<span class="nav-text">
<span class="nav-label" data-i18n="home.card_short_links">Short links</span>
<span class="nav-desc" data-i18n="home.card_short_links_desc">Mint bearer tokens and shorten URLs for s.f0xx.org.</span>
</span>
</a>
</li>
<?php endif; ?>
<?php if (Rbac::canManageRbac()): ?>
<li>
<a href="<?= h(Auth::basePath()) ?>/?view=rbac"
class="nav-link <?= ($view ?? '') === 'rbac' ? 'active' : '' ?>"
aria-label="Access control"
title="Access control">
<span class="nav-icon nav-icon--tickets" aria-hidden="true"></span>
<span class="nav-text">
<span class="nav-label" data-i18n="nav.access">Access</span>
<span class="nav-desc" data-i18n="nav.access_desc">Roles, privilege sets, and operators.</span>
</span>
</a>
</li>
<?php endif; ?>
<li>
<a href="/app/androidcast_project/build/"
class="nav-link"
aria-label="Builder"
title="Builder">
<span class="nav-icon nav-icon--builder" aria-hidden="true"></span>
<span class="nav-text">
<span class="nav-label" data-i18n="nav.builder">Builder</span>
<span class="nav-desc" data-i18n="nav.builder_desc">Docker CI for APK baking and OTA artifacts.</span>
</span>
</a>
</li>
</ul>
<div class="nav-locale">
<label class="toolbar-select locale-toolbar-select" data-i18n-title="lang.label" title="Language">
<span class="locale-flag" id="locale-flag" aria-hidden="true">🇬🇧</span>
<select class="lang-select" id="lang-select" data-i18n-aria="lang.label" aria-label="Language">
<option value="en">EN</option>
<option value="ru">RU</option>
</select>
</label>
</div>
<div class="nav-user">
<span class="nav-user-name" title="<?= h(Auth::user()['username'] ?? '') ?>">
<span class="nav-icon nav-icon--user" aria-hidden="true"></span>
<span class="nav-label"><?= h(Auth::user()['username'] ?? '') ?></span>
</span>
<a href="<?= h(Auth::basePath()) ?>/account-security"
class="nav-link"
data-i18n-aria="nav.security" data-i18n-title="nav.security"
aria-label="Security"
title="Security">
<span class="nav-icon nav-icon--user" aria-hidden="true"></span>
<span class="nav-label" data-i18n="nav.security">Security</span>
</a>
<a href="<?= h(Auth::authUrl('/logout')) ?>"
class="nav-link nav-link--logout"
data-i18n-aria="nav.logout" data-i18n-title="nav.logout"
aria-label="Logout"
title="Logout">
<span class="nav-icon nav-icon--logout" aria-hidden="true"></span>
<span class="nav-label" data-i18n="nav.logout">Logout</span>
</a>
</div>
</nav>
<main class="main-pane">
<?php if (($view ?? 'home') === 'home'): ?>
<?php require __DIR__ . '/partials/console_home_landing.php'; ?>
<?php elseif (($view ?? '') === 'report' && !empty($report)): ?>
<?php require __DIR__ . '/report_detail.php'; ?>
<?php elseif (($view ?? '') === 'ticket' && !empty($ticket)): ?>
<?php require __DIR__ . '/ticket_detail.php'; ?>
<?php elseif (($view ?? '') === 'tickets'): ?>
<div id="tickets-app" class="reports-app tickets-app">
<div class="toolbar reports-toolbar">
<h1 data-i18n="tickets.title">Tickets</h1>
<div class="toolbar-actions">
<button type="button" class="btn btn-primary js-new-ticket-btn" data-i18n="tickets.new">New ticket</button>
<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>
<label class="toolbar-select">
<span data-i18n="reports.per_page">Per page</span>
<select id="tickets-per-page" aria-label="Tickets per page">
<option value="25">25</option>
<option value="50" selected>50</option>
<option value="75">75</option>
<option value="100">100</option>
</select>
</label>
<label class="toolbar-select">
<span data-i18n="tickets.filter_tag">Status tag</span>
<select id="tickets-tag-filter" aria-label="Filter by tag">
<option value="" data-i18n="tickets.filter_all">All</option>
<option value="open">open</option>
<option value="in-progress">in progress</option>
<option value="confirmed">confirmed</option>
<option value="closed">closed</option>
<option value="urgent">urgent</option>
<option value="20260604">20260604 (roadmap)</option>
</select>
</label>
</div>
</div>
<p id="tickets-status" class="reports-status muted" aria-live="polite" data-i18n="reports.loading">Loading…</p>
<div class="reports-table-wrap">
<table class="data-table reports-tree reports-table--cols" id="tickets-table">
<thead>
<tr>
<th class="report-tree-head" aria-label="Expand"></th>
<th class="sortable" data-sort="title" scope="col" data-i18n="tickets.col_issue">Issue</th>
<th class="sortable sortable--active" data-sort="opened_at_ms" scope="col" data-i18n="tickets.col_opened">Opened</th>
<th scope="col" data-i18n="tickets.col_env">App / OS</th>
<th class="sortable" data-sort="rating" scope="col" data-i18n="col.rating">Rating</th>
<th class="col-tags" scope="col" data-i18n="col.tags">Tags</th>
</tr>
</thead>
<tbody id="tickets-tbody"></tbody>
</table>
</div>
<nav class="reports-pagination" id="tickets-pagination" aria-label="Tickets pages"></nav>
</div>
<?php elseif (($view ?? '') === 'reports'): ?>
<div id="reports-app" class="reports-app" data-grouped="0">
<div class="toolbar reports-toolbar">
<h1 data-i18n="reports.title">Issues</h1>
<div class="toolbar-actions">
<button type="button" class="btn btn-primary js-new-issue-btn" data-i18n="issues.new">New issue</button>
<button type="button" class="btn reports-mode-btn active" data-grouped="0" data-i18n="reports.by_time">By time</button>
<button type="button" class="btn reports-mode-btn" data-grouped="1" data-i18n="reports.grouped">Grouped</button>
<label class="toolbar-select">
<span data-i18n="theme.label">Theme</span>
<select id="theme-select" aria-label="UI theme" data-i18n-options="theme">
<option value="dark" data-i18n="theme.dark">Dark</option>
<option value="light" data-i18n="theme.light">Light</option>
</select>
</label>
<label class="toolbar-select tag-mode-select-wrap" id="tag-mode-wrap" hidden>
<span data-i18n="tag.filter_mode">Match</span>
<select id="tag-mode-select" aria-label="Tag filter mode">
<option value="and" data-i18n="tag.filter_and">All tags (AND)</option>
<option value="or" data-i18n="tag.filter_or">Any tag (OR)</option>
</select>
</label>
<label class="toolbar-select">
<span data-i18n="reports.per_page">Per page</span>
<select id="per-page-select" aria-label="Reports per page">
<option value="25">25</option>
<option value="50" selected>50</option>
<option value="75">75</option>
<option value="100">100</option>
<option value="200">200</option>
</select>
</label>
</div>
</div>
<div class="tag-filter-bar" id="tag-filter-bar">
<span class="muted tag-filter-label" data-i18n="tag.filter_label">Filter by tag</span>
<div class="tag-filter-chips" id="tag-filter-chips" role="group" aria-label="Tag filters"></div>
</div>
<p id="reports-filter-banner" class="reports-filter-banner muted" hidden></p>
<div class="reports-search-row">
<label class="reports-search-label" for="reports-search-input" data-i18n="reports.search">Search</label>
<div class="reports-search-field">
<input type="search" id="reports-search-input" class="reports-search-input"
data-i18n-placeholder="reports.search_placeholder"
placeholder="Keywords (exceptions, devices, stack traces…)" autocomplete="off" spellcheck="false">
<ul id="reports-search-suggest" class="reports-search-suggest" hidden role="listbox"></ul>
</div>
<button type="button" class="btn" id="reports-search-clear" hidden data-i18n="reports.clear">Clear</button>
</div>
<p id="reports-status" class="reports-status muted" aria-live="polite" data-i18n="reports.loading">Loading…</p>
<div class="reports-table-wrap">
<table class="data-table reports-tree reports-table--cols" id="reports-tree">
<colgroup id="reports-colgroup"></colgroup>
<thead id="reports-thead"></thead>
<tbody id="reports-tbody"></tbody>
</table>
</div>
<nav class="reports-pagination" id="reports-pagination" aria-label="Reports pages"></nav>
</div>
<?php elseif (($view ?? '') === 'graphs'): ?>
<div id="graphs-app" class="reports-app graphs-app">
<div class="toolbar reports-toolbar">
<h1>AndroidCast analytics</h1>
<div class="toolbar-actions">
<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>
<label class="toolbar-select">
<span>Window</span>
<select id="graphs-days" aria-label="Graphs window">
<option value="1">1d</option>
<option value="7">7d</option>
<option value="14" selected>14d</option>
<option value="30">30d</option>
</select>
</label>
<label class="toolbar-select">
<span>Columns</span>
<select id="graphs-columns" aria-label="Graph grid columns">
<option value="1">1</option>
<option value="2" selected>2</option>
<option value="4">4</option>
</select>
</label>
</div>
</div>
<?php require __DIR__ . '/partials/console_quick_links.php'; ?>
<p id="graphs-session-notice" class="graphs-session-notice" role="status" hidden data-i18n="graphs.session_deficit">
Session charts are empty. On the device: Developer settings → enable “Grab session stats”, cast a session, then wait for upload to graph_upload.php.
</p>
<p id="graphs-status" class="reports-status muted" aria-live="polite">Loading…</p>
<section class="graphs-scope" data-graph-scope="user">
<h2 class="graphs-scope-title">Your activity</h2>
<div class="cards cards--lift graphs-grid">
<article class="card card--lift"><h3>Sessions / day</h3><canvas id="graph-user-sessions" width="520" height="160"></canvas></article>
<article class="card card--lift"><h3>Unique devices / day</h3><canvas id="graph-user-devices" width="520" height="160"></canvas></article>
<article class="card card--lift"><h3>Avg duration (s) / day</h3><canvas id="graph-user-duration" width="520" height="160"></canvas></article>
<article class="card card--lift"><h3>Send sessions / day</h3><canvas id="graph-user-send" width="520" height="160"></canvas></article>
<article class="card card--lift"><h3>Receive sessions / day</h3><canvas id="graph-user-recv" width="520" height="160"></canvas></article>
<article class="card card--lift"><h3>Success ratio</h3><p id="graph-user-success" class="graph-kpi">—</p></article>
<article class="card card--lift"><h3>Outbound kbps / day</h3><canvas id="graph-user-bitrate" width="520" height="160"></canvas></article>
<article class="card card--lift"><h3>Inbound kbps / day</h3><canvas id="graph-user-recv-kbps" width="520" height="160"></canvas></article>
<article class="card card--lift"><h3>Issues / day</h3><canvas id="graph-user-crashes" width="520" height="160"></canvas></article>
</div>
</section>
<section class="graphs-scope" data-graph-scope="slug_admin" hidden>
<h2 class="graphs-scope-title">Company (slug admin)</h2>
<div class="cards cards--lift graphs-grid">
<article class="card card--lift"><h3>Sessions / day</h3><canvas id="graph-slug-sessions" width="520" height="160"></canvas></article>
<article class="card card--lift"><h3>Unique devices / day</h3><canvas id="graph-slug-devices" width="520" height="160"></canvas></article>
<article class="card card--lift"><h3>Install sources</h3><canvas id="graph-slug-install-pie" width="520" height="180"></canvas></article>
<article class="card card--lift"><h3>NTP sources</h3><canvas id="graph-slug-ntp-pie" width="520" height="180"></canvas></article>
<article class="card card--lift"><h3>Transport mix</h3><div id="graph-slug-transport" class="graph-breakdown"></div></article>
<article class="card card--lift"><h3>App versions</h3><div id="graph-slug-versions" class="graph-breakdown"></div></article>
<article class="card card--lift"><h3>Issues / day</h3><canvas id="graph-slug-crashes" width="520" height="160"></canvas></article>
<article class="card card--lift"><h3>Tickets / day</h3><canvas id="graph-slug-tickets" width="520" height="160"></canvas></article>
</div>
</section>
<section class="graphs-scope" data-graph-scope="platform_admin" hidden>
<h2 class="graphs-scope-title">Platform reliability</h2>
<div class="cards cards--lift graphs-grid">
<article class="card card--lift"><h3>Sessions / day (all slugs)</h3><canvas id="graph-platform-sessions" width="520" height="160"></canvas></article>
<article class="card card--lift"><h3>Issues / day</h3><canvas id="graph-platform-crashes" width="520" height="160"></canvas></article>
<article class="card card--lift"><h3>Tickets / day</h3><canvas id="graph-platform-tickets" width="520" height="160"></canvas></article>
<article class="card card--lift"><h3>Install sources</h3><canvas id="graph-platform-install-pie" width="520" height="180"></canvas></article>
<article class="card card--lift"><h3>NTP correction</h3><p id="graph-platform-ntp-avg" class="graph-kpi muted">—</p></article>
<article class="card card--lift"><h3>Top issue fingerprints</h3><div id="graph-platform-fingerprints" class="graph-breakdown"></div></article>
<article class="card card--lift"><h3>Issues by app version</h3><canvas id="graph-platform-crash-versions" width="520" height="180"></canvas></article>
</div>
</section>
<section class="graphs-scope" id="graphs-live-cast-section">
<h2 class="graphs-scope-title">Live cast</h2>
<div class="cards cards--lift graphs-grid">
<article class="card card--lift"><h3>Live casts / day</h3><canvas id="graph-live-casts" width="520" height="160"></canvas></article>
<article class="card card--lift"><h3>Avg cast length (s) / day</h3><canvas id="graph-live-avg-length" width="520" height="160"></canvas></article>
<article class="card card--lift"><h3>Cast platforms</h3><canvas id="graph-live-platform-pie" width="520" height="180"></canvas></article>
<article class="card card--lift"><h3>Join platforms</h3><canvas id="graph-live-join-platform-pie" width="520" height="180"></canvas></article>
<article class="card card--lift"><h3>Video codecs</h3><canvas id="graph-live-video-codec-pie" width="520" height="180"></canvas></article>
<article class="card card--lift"><h3>Audio codecs</h3><canvas id="graph-live-audio-codec-pie" width="520" height="180"></canvas></article>
<article class="card card--lift"><h3>Bandwidth modes</h3><canvas id="graph-live-bw-pie" width="520" height="180"></canvas></article>
<article class="card card--lift"><h3>Top casters</h3><div id="graph-live-top-users" class="graph-breakdown"></div></article>
</div>
</section>
<div id="graph-detail-overlay" class="graph-detail-overlay" hidden>
<div class="graph-detail-panel" role="dialog" aria-modal="true" aria-labelledby="graph-detail-title">
<header class="graph-detail-header">
<div class="graph-detail-header-text">
<h2 id="graph-detail-title">Graph</h2>
<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>
<div id="graph-detail-body" class="graph-detail-body"></div>
<p id="graph-detail-tooltip" class="graph-detail-tooltip" hidden></p>
</div>
</div>
</div>
</div>
<?php elseif (($view ?? '') === 'live_sessions'): ?>
<div id="live-sessions-app" class="reports-app">
<div class="toolbar reports-toolbar">
<h1>Live cast sessions</h1>
<div class="toolbar-actions">
<label class="toolbar-select">
<span>Window</span>
<select id="live-sessions-days" aria-label="Sessions window">
<option value="1">1d</option>
<option value="7">7d</option>
<option value="14" selected>14d</option>
<option value="30">30d</option>
</select>
</label>
<a class="btn" href="<?= h(Auth::basePath()) ?>/live/education">Education demo</a>
</div>
</div>
<?php require __DIR__ . '/partials/console_quick_links.php'; ?>
<p id="live-sessions-status" class="reports-status muted" aria-live="polite">Loading…</p>
<p class="muted graphs-footnote">Read-only session tree for all signed-in users. Sorted by last heartbeat (most recent first).</p>
<div class="reports-table-wrap">
<table class="data-table reports-table--cols" id="live-sessions-table">
<thead>
<tr>
<th>Status</th>
<th>User</th>
<th>Platform</th>
<th>Video</th>
<th>Duration</th>
<th>Last heartbeat</th>
<th>Join opens</th>
<th>Link</th>
</tr>
</thead>
<tbody id="live-sessions-tbody"></tbody>
</table>
</div>
</div>
<?php elseif (($view ?? '') === 'remote_access'): ?>
<div id="remote-access-app" class="reports-app">
<div class="toolbar reports-toolbar">
<h1>Remote access</h1>
<div class="toolbar-actions">
<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>
<?php require __DIR__ . '/partials/console_quick_links.php'; ?>
<p id="ra-status" class="reports-status muted" aria-live="polite">Loading…</p>
<section class="graphs-scope">
<h2 class="graphs-scope-title">Devices</h2>
<p class="muted graphs-footnote ra-whitelist-hint">
Devices appear after they poll remote access (dev settings → WireGuard or RSSH). Sort is by <strong>last seen</strong> — top rows are actively polling.
Whitelist rows marked <span class="tag-pill tag-pill--warn">Needs whitelist</span> (opted in, not yet allowed).
Stale rows have not polled in 7+ days.
<strong>Poll source IP</strong> is the address BE sees on the HTTP request (often a router or proxy on <code>10.7.x.x</code>) — not the device WAN or LAN.
</p>
<div class="reports-table-wrap">
<table class="data-table reports-tree reports-table--cols" id="ra-devices-table">
<colgroup id="ra-devices-colgroup"></colgroup>
<thead id="ra-devices-thead"></thead>
<tbody id="ra-devices-tbody"></tbody>
</table>
</div>
<form id="ra-whitelist-form" class="tag-editor-add" style="margin-top:1rem">
<label class="tag-field"><span>Device ID</span><input type="text" name="device_id" required maxlength="128"></label>
<label class="tag-field"><span>Notes</span><input type="text" name="notes" maxlength="255"></label>
<button type="submit" class="btn">Add / whitelist</button>
</form>
</section>
<section class="graphs-scope">
<h2 class="graphs-scope-title">Active / pending sessions</h2>
<div class="reports-table-wrap">
<table class="data-table reports-table--cols" id="ra-active-table">
<thead>
<tr>
<th scope="col">Session</th>
<th scope="col">Device</th>
<th scope="col">Status</th>
<th scope="col">Tunnel</th>
<th scope="col">Endpoint</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody id="ra-active-tbody"></tbody>
</table>
</div>
</section>
<section class="graphs-scope">
<h2 class="graphs-scope-title">Inactive / closed sessions</h2>
<div class="reports-table-wrap">
<table class="data-table reports-table--cols" id="ra-inactive-table">
<thead>
<tr>
<th scope="col">Session</th>
<th scope="col">Device</th>
<th scope="col">Status</th>
<th scope="col">Closed</th>
<th scope="col">Reason</th>
</tr>
</thead>
<tbody id="ra-inactive-tbody"></tbody>
</table>
</div>
</section>
<section class="graphs-scope">
<h2 class="graphs-scope-title">Audit log</h2>
<div class="reports-table-wrap">
<table class="data-table reports-table--cols" id="ra-events-table">
<thead>
<tr>
<th scope="col">Time</th>
<th scope="col">Device</th>
<th scope="col">Action</th>
<th scope="col">Reason</th>
</tr>
</thead>
<tbody id="ra-events-tbody"></tbody>
</table>
</div>
</section>
</div>
<?php elseif (($view ?? '') === 'short_links'): ?>
<div id="short-links-app" class="reports-app">
<div class="toolbar reports-toolbar">
<h1>Short links</h1>
<div class="toolbar-actions">
<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>
<?php require __DIR__ . '/partials/console_quick_links.php'; ?>
<p id="sl-status" class="reports-status muted" aria-live="polite">Loading…</p>
<p class="muted graphs-footnote">
Public API: <code>https://s.f0xx.org/api/v1/shorten</code> · machine clients use bearer tokens minted here.
</p>
<section class="graphs-scope">
<h2 class="graphs-scope-title">Bearer tokens</h2>
<div class="reports-table-wrap">
<table class="data-table reports-table--cols" id="sl-bearers-table">
<thead>
<tr>
<th scope="col">ID</th>
<th scope="col">Label</th>
<th scope="col">Status</th>
<th scope="col">Caps</th>
<th scope="col">Links</th>
<th scope="col">Rate/h</th>
<th scope="col">Created</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody id="sl-bearers-tbody"></tbody>
</table>
</div>
<form id="sl-mint-form" class="tag-editor-add" style="margin-top:1rem"<?= Rbac::can('short_links_operate') ? '' : ' hidden' ?>>
<label class="tag-field"><span>Label</span><input type="text" name="label" required maxlength="128" placeholder="prod-hub"></label>
<label class="tag-field"><span>Rate limit / hour</span><input type="number" name="rate_limit" value="1000" min="1" max="100000"></label>
<label class="tag-field tag-field--check"><span><input type="checkbox" name="can_temporary" value="1" checked> Temporary links</span></label>
<label class="tag-field tag-field--check"><span><input type="checkbox" name="can_permanent" value="1" id="sl-can-permanent"> Permanent links</span></label>
<label class="tag-field tag-field--check" id="sl-mint-signer-wrap" hidden><span><input type="checkbox" name="mint_signer" value="1" checked> Mint signer key</span></label>
<button type="submit" class="btn btn-primary">Mint bearer</button>
<?php if (Rbac::isGlobalAdmin()): ?>
<button type="button" class="btn" id="sl-purge-btn">Purge expired</button>
<?php endif; ?>
</form>
</section>
<section class="graphs-scope">
<h2 class="graphs-scope-title">Signer keys (permanent links)</h2>
<div class="reports-table-wrap">
<table class="data-table reports-table--cols" id="sl-signers-table">
<thead>
<tr>
<th scope="col">ID</th>
<th scope="col">Label</th>
<th scope="col">Status</th>
<th scope="col">Created</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody id="sl-signers-tbody"></tbody>
</table>
</div>
<form id="sl-mint-signer-form" class="tag-editor-add" style="margin-top:1rem"<?= Rbac::can('short_links_operate') ? '' : ' hidden' ?>>
<label class="tag-field"><span>Label</span><input type="text" name="label" required maxlength="128" placeholder="prod-perm"></label>
<button type="submit" class="btn">Mint signer</button>
</form>
</section>
<section class="graphs-scope" id="sl-links-section" hidden>
<h2 class="graphs-scope-title">Links for bearer <code id="sl-links-bearer-label">—</code></h2>
<form id="sl-shorten-form" class="tag-editor-add" style="margin-bottom:1rem"<?= Rbac::can('short_links_operate') ? '' : ' hidden' ?>>
<input type="hidden" name="bearer_id" id="sl-shorten-bearer-id" value="">
<label class="tag-field tag-field--wide"><span>Long URL</span><input type="url" name="url" required maxlength="2048" placeholder="https://example.com/path"></label>
<label class="tag-field tag-field--check"><span><input type="checkbox" name="permanent" value="1" id="sl-permanent-cb"> Permanent (ttl=0)</span></label>
<label class="tag-field" id="sl-ttl-wrap"><span>TTL (seconds)</span><input type="number" name="ttl" id="sl-ttl-input" value="86400" min="60" max="31536000"></label>
<label class="tag-field tag-field--wide" id="sl-signer-wrap" hidden><span>Signer key</span><input type="password" name="signer" id="sl-signer-input" autocomplete="off" placeholder="required for permanent"></label>
<button type="submit" class="btn btn-primary">Shorten</button>
</form>
<div class="reports-table-wrap">
<table class="data-table reports-table--cols" id="sl-links-table">
<thead>
<tr>
<th scope="col">Short URL</th>
<th scope="col">Origin</th>
<th scope="col">Expires</th>
<th scope="col">Hits</th>
<th scope="col">QR</th>
</tr>
</thead>
<tbody id="sl-links-tbody"></tbody>
</table>
</div>
</section>
<section class="graphs-scope">
<h2 class="graphs-scope-title">Audit log</h2>
<div class="reports-table-wrap">
<table class="data-table reports-table--cols" id="sl-audit-table">
<thead>
<tr>
<th scope="col">Time</th>
<th scope="col">Event</th>
<th scope="col">Bearer</th>
<th scope="col">Slug</th>
<th scope="col">Detail</th>
</tr>
</thead>
<tbody id="sl-audit-tbody"></tbody>
</table>
</div>
</section>
<div id="sl-token-modal" class="graph-detail-overlay" hidden>
<div class="graph-detail-panel" role="dialog" aria-modal="true" aria-labelledby="sl-token-title">
<header class="graph-detail-header">
<h2 id="sl-token-title">Bearer token — copy now</h2>
<button type="button" class="btn graph-detail-close" id="sl-token-close">Close</button>
</header>
<p class="muted">Shown once. Store in a secrets manager or <code>BEARER=…</code> for curl scripts.</p>
<p><code id="sl-token-value" style="word-break:break-all"></code></p>
<button type="button" class="btn btn-primary" id="sl-token-copy">Copy token</button>
</div>
</div>
<div id="sl-signer-modal" class="graph-detail-overlay" hidden>
<div class="graph-detail-panel" role="dialog" aria-modal="true" aria-labelledby="sl-signer-title">
<header class="graph-detail-header">
<h2 id="sl-signer-title">Signer key — copy now</h2>
<button type="button" class="btn graph-detail-close" id="sl-signer-close">Close</button>
</header>
<p class="muted">Shown once. Required for <code>ttl=0</code> permanent links with a bearer that has permanent capability.</p>
<p><code id="sl-signer-value" style="word-break:break-all"></code></p>
<button type="button" class="btn btn-primary" id="sl-signer-copy">Copy signer</button>
</div>
</div>
</div>
<?php elseif (($view ?? '') === 'rbac'): ?>
<div id="rbac-app" class="reports-app">
<div class="toolbar reports-toolbar">
<h1>Access control</h1>
</div>
<p id="rbac-status" class="reports-status muted" aria-live="polite">Loading…</p>
<p id="rbac-scope-hint" class="muted graphs-footnote"></p>
<section class="graphs-scope">
<h2 class="graphs-scope-title">Users &amp; company roles</h2>
<div class="reports-table-wrap">
<table class="data-table reports-table--cols" id="rbac-users-table">
<thead>
<tr>
<th scope="col">User</th>
<th scope="col">Global role</th>
<th scope="col">Company</th>
<th scope="col">Company role</th>
<th scope="col">Privilege set</th>
<th scope="col">Auth lockouts</th>
</tr>
</thead>
<tbody id="rbac-users-tbody"></tbody>
</table>
</div>
</section>
<p class="muted graphs-footnote">
API: <code><?= h(Auth::basePath()) ?>/api/rbac.php</code> ·
Root edits global roles; company owner/admin edits memberships and remote-access privilege sets.
</p>
</div>
<?php endif; ?>
</main>
</div>
<?php if (Auth::canEditTags()): ?>
<div id="tag-modal" class="tag-modal" hidden aria-hidden="true">
<div class="tag-modal-backdrop" data-tag-modal-close></div>
<div class="tag-modal-panel" role="dialog" aria-labelledby="tag-modal-title">
<header class="tag-modal-header">
<h2 id="tag-modal-title" data-i18n="tag.edit_title">Edit tags</h2>
<button type="button" class="tag-modal-close" data-tag-modal-close data-i18n-aria="tag.close" aria-label="Close">×</button>
</header>
<div class="tag-editor tag-editor--modal" id="tag-editor-modal" data-report-id="0">
<div class="tag-editor-preview" id="tag-modal-preview"></div>
<ul class="tag-editor-list" id="tag-modal-list"></ul>
<form class="tag-editor-add" id="tag-modal-add">
<label class="tag-field"><span data-i18n="tag.label">Label</span><input type="text" name="label" maxlength="40" required></label>
<label class="tag-field"><span data-i18n="tag.color">Color</span><input type="color" name="bg" value="#5c6b82"></label>
<button type="submit" class="btn" data-i18n="tag.add">Add</button>
</form>
<div class="tag-preset-bar" id="tag-modal-presets" hidden>
<span class="muted" data-i18n="tag.suggestions">Suggestions:</span>
<div class="tag-preset-chips" id="tag-modal-preset-chips"></div>
</div>
<div class="tag-editor-actions">
<button type="button" class="btn btn-primary" id="tag-modal-save">Save</button>
<span class="tag-editor-status muted" id="tag-modal-status"></span>
</div>
</div>
</div>
</div>
<?php endif; ?>
<dialog id="ticket-create-dialog" class="ticket-create-dialog">
<form method="dialog" id="ticket-create-form" class="ticket-create-form">
<header class="ticket-create-header">
<h2 id="ticket-create-heading" data-i18n="tickets.new">New ticket</h2>
<button type="button" class="ticket-create-close" id="ticket-create-close" data-i18n-aria="tag.close" aria-label="Close">×</button>
</header>
<div class="ticket-create-body">
<label><span data-i18n="ticket.title">Title</span>
<input name="title" id="ticket-create-title" required maxlength="256" autofocus></label>
<label><span data-i18n="ticket.brief">Brief</span>
<input name="brief" id="ticket-create-brief" maxlength="512"></label>
<label><span data-i18n="ticket.body">Description</span>
<textarea name="body" id="ticket-create-body" rows="6"></textarea></label>
</div>
<footer class="ticket-create-footer">
<p id="ticket-create-status" class="muted" aria-live="polite"></p>
<div class="ticket-create-actions">
<button type="button" class="btn" id="ticket-create-cancel" data-i18n="ticket.cancel">Cancel</button>
<button type="submit" class="btn btn-primary" data-i18n="ticket.create">Create</button>
</div>
</footer>
</form>
</dialog>
<?php require __DIR__ . '/partials/cookie_consent.php'; ?>
<?php platform_render_footer(); ?>
</body>
</html>

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
$bp = Auth::basePath();
$proj = '/app/androidcast_project';
?>
<div class="console-home">
<div class="toolbar reports-toolbar console-home-toolbar">
<h1 data-i18n="home.title">Console</h1>
<div class="toolbar-actions">
<label class="toolbar-select">
<span data-i18n="theme.label">Theme</span>
<select id="theme-select" aria-label="UI theme" data-i18n-options="theme">
<option value="dark" data-i18n="theme.dark">Dark</option>
<option value="light" data-i18n="theme.light">Light</option>
</select>
</label>
</div>
</div>
<p class="console-home-intro" data-i18n="home.intro">Issue triage, tickets, and session metrics — pick a workspace below.</p>
<div class="cards cards--lift console-home-cards">
<a href="<?= h($bp) ?>/?view=reports" class="card card--lift console-home-card">
<span class="nav-icon nav-icon--reports" aria-hidden="true"></span>
<h2 data-i18n="home.card_issues">Issues</h2>
<p class="muted" data-i18n="home.card_issues_desc">Browse and triage issues, tags, and fingerprints.</p>
</a>
<button type="button" class="card card--lift console-home-card console-home-card--btn js-new-issue-btn">
<span class="nav-icon nav-icon--reports" aria-hidden="true"></span>
<h2 data-i18n="issues.new">New issue</h2>
<p class="muted" data-i18n="home.card_new_issue_desc">Log a new issue or task in one step.</p>
</button>
<a href="<?= h($bp) ?>/?view=tickets" class="card card--lift console-home-card">
<span class="nav-icon nav-icon--tickets" aria-hidden="true"></span>
<h2 data-i18n="nav.tickets">Tickets</h2>
<p class="muted" data-i18n="home.card_tickets_desc">Roadmap tasks, QA items, and workflow tags.</p>
</a>
<button type="button" class="card card--lift console-home-card console-home-card--btn js-new-ticket-btn">
<span class="nav-icon nav-icon--tickets" aria-hidden="true"></span>
<h2 data-i18n="tickets.new">New ticket</h2>
<p class="muted" data-i18n="home.card_new_ticket_desc">Create a roadmap or QA ticket in one step.</p>
</button>
<a href="<?= h($proj) ?>/graphs/" class="card card--lift console-home-card">
<span class="nav-icon nav-icon--graphs" aria-hidden="true"></span>
<h2 data-i18n="home.card_graphs">Graphs</h2>
<p class="muted" data-i18n="home.card_graphs_desc">Sessions, issues, and device activity over time.</p>
</a>
<?php if (Rbac::can('short_links_view')): ?>
<a href="<?= h($bp) ?>/?view=short_links" class="card card--lift console-home-card">
<span class="nav-icon nav-icon--link" aria-hidden="true"></span>
<h2 data-i18n="home.card_short_links">Short links</h2>
<p class="muted" data-i18n="home.card_short_links_desc">Mint bearer tokens and shorten URLs for s.f0xx.org.</p>
</a>
<?php endif; ?>
</div>
<h2 class="console-home-more" data-i18n="home.more">All services</h2>
<?php $skip_home_link = true; require __DIR__ . '/console_quick_links.php'; ?>
</div>

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
/**
* Horizontal service links with the same icons as the left nav (8px gap).
* @var string|null $extra_class optional class on <nav>
* @var bool $skip_home_link omit "Console home" (use on home view)
*/
$bp = Auth::basePath();
$proj = '/app/androidcast_project';
$navClass = 'console-quick-links graphs-quick-links' . (isset($extra_class) ? ' ' . $extra_class : '');
$items = [];
if (empty($skip_home_link)) {
$items[] = ['Console home', $bp . '/?view=home', 'nav-icon--home'];
}
$items[] = ['Issues', $bp . '/?view=reports', 'nav-icon--reports'];
$items[] = ['Tickets', $bp . '/?view=tickets', 'nav-icon--tickets'];
if (Rbac::can('remote_access_view')) {
$items[] = ['Remote access', $bp . '/?view=remote_access', 'nav-icon--remote'];
}
if (Rbac::can('short_links_view')) {
$items[] = ['Short links', $bp . '/?view=short_links', 'nav-icon--link'];
}
if (Rbac::canManageRbac()) {
$items[] = ['Access control', $bp . '/?view=rbac', 'nav-icon--tickets'];
}
$items[] = ['Graphs', $proj . '/graphs/', 'nav-icon--graphs'];
$items[] = ['Builder', $proj . '/build/', 'nav-icon--builder'];
$items[] = ['Hub', $proj . '/', 'nav-icon--home'];
?>
<nav class="<?= h($navClass) ?>" aria-label="Related services">
<?php foreach ($items as [$label, $href, $icon]): ?>
<a href="<?= h($href) ?>" class="console-quick-link">
<span class="nav-icon <?= h($icon) ?>" aria-hidden="true"></span>
<span><?= h($label) ?></span>
</a>
<?php endforeach; ?>
</nav>

View File

@@ -0,0 +1,15 @@
<?php declare(strict_types=1); ?>
<div id="cookie-consent-banner" class="cookie-consent" hidden role="dialog" aria-labelledby="cookie-consent-title" aria-modal="false">
<div class="cookie-consent-inner">
<p id="cookie-consent-title" class="cookie-consent-title">Cookies on AndroidCast</p>
<p class="cookie-consent-text muted">
We use necessary session cookies to sign you in. With your consent we also load analytics to improve the service.
You can change this later in your browser by clearing site data.
</p>
<div class="cookie-consent-actions">
<button type="button" class="btn btn--primary" id="cookie-consent-all">Accept all</button>
<button type="button" class="btn" id="cookie-consent-necessary">Necessary only</button>
<button type="button" class="btn btn--ghost" id="cookie-consent-reject">Reject optional</button>
</div>
</div>
</div>

328
views/ticket_detail.php Normal file
View File

@@ -0,0 +1,328 @@
<?php
/** Ticket detail — layout mirrors {@see report_detail.php} with workflow fields. */
$p = is_array($ticket['payload'] ?? null) ? $ticket['payload'] : [];
$device = is_array($p['device'] ?? null) ? $p['device'] : [];
$app = is_array($p['app'] ?? null) ? $p['app'] : [];
$pageTitle = 'Ticket';
$view = 'ticket';
$jsonFlags = JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE;
$rawJson = safe_json_encode($p, $jsonFlags);
$bp = Auth::basePath();
$ticketDbId = (int) ($ticket['id'] ?? 0);
$canEdit = !empty($ticket['can_edit']);
$deviceName = device_display_name($device);
$sdk = (int) ($device['sdk_int'] ?? 0);
$release = (string) ($device['release'] ?? '');
$abiList = [];
if (!empty($device['abis']) && is_array($device['abis'])) {
foreach ($device['abis'] as $abi) {
if (is_scalar($abi) && trim((string) $abi) !== '') {
$abiList[] = trim((string) $abi);
}
}
}
$rating = (int) ($ticket['rating'] ?? 0);
$workflowState = TicketWorkflow::normalize((string) ($ticket['workflow_state'] ?? 'triage'));
$assignees = is_array($ticket['assignees'] ?? null) ? $ticket['assignees'] : [];
$comments = is_array($ticket['comments'] ?? null) ? $ticket['comments'] : [];
$attachments = is_array($ticket['attachments'] ?? null) ? $ticket['attachments'] : [];
$canAttach = (bool) Auth::user();
$starsHtml = '';
for ($i = 1; $i <= 5; $i++) {
$starsHtml .= '<span class="star' . ($i <= $rating ? ' star--on' : '') . '" aria-hidden="true"></span>';
}
?>
<div class="detail-header">
<a href="<?= h($bp) ?>/?view=tickets" class="back-link" data-i18n-aria="ticket.back" data-i18n-title="ticket.back"
aria-label="Back to tickets list" title="Back to list">
<span class="back-icon" aria-hidden="true"></span>
</a>
<div>
<h1 data-i18n="ticket.detail_title">Ticket</h1>
<p class="muted ticket-detail-meta">
<span data-i18n="ticket.id_label">ID</span> <?= h($ticket['ticket_id'] ?? '') ?>
<?php if (!empty($ticket['brief'])): ?>
· <?= h($ticket['brief']) ?>
<?php endif; ?>
</p>
</div>
</div>
<section class="tag-editor" id="tag-editor" data-entity-type="ticket" data-ticket-id="<?= $ticketDbId ?>">
<div class="tag-editor-head">
<h2 data-i18n="detail.tags">Tags</h2>
<p class="muted tag-editor-hint" data-i18n="ticket.tag_hint">Include one type tag (<strong>ticket</strong> or <strong>issue</strong>) and a status tag (open, in progress, closed, …). Swap type via suggestions — remove the current type tag, then add the other.</p>
</div>
<div class="tag-editor-preview" id="tag-editor-preview" aria-live="polite"></div>
<?php if ($canEdit): ?>
<ul class="tag-editor-list" id="tag-editor-list"></ul>
<form class="tag-editor-add" id="tag-editor-add">
<label class="tag-field">
<span data-i18n="tag.label">Label</span>
<input type="text" name="label" maxlength="40" required data-i18n-placeholder="tag.placeholder" placeholder="e.g. alpha">
</label>
<label class="tag-field">
<span data-i18n="tag.color">Color</span>
<input type="color" name="bg" value="#5c6b82">
</label>
<button type="submit" class="btn" data-i18n="tag.add_tag">Add tag</button>
</form>
<div class="tag-preset-bar" id="tag-preset-bar" hidden>
<span class="muted" data-i18n="tag.suggestions">Suggestions:</span>
<div class="tag-preset-chips" id="tag-preset-chips"></div>
</div>
<div class="tag-editor-actions">
<button type="button" class="btn btn-primary" id="tag-editor-save" data-i18n="tag.save_tags">Save tags</button>
<span class="tag-editor-status muted" id="tag-editor-status"></span>
</div>
<?php else: ?>
<p class="muted" data-i18n="ticket.tag_readonly">Only the owner or an admin can edit tags.</p>
<?php endif; ?>
</section>
<script type="application/json" id="ticket-custom-tags-data"><?=
safe_json_encode($ticket['tags'] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
?></script>
<?php if ($canEdit): ?>
<form class="ticket-edit-form cards cards--lift" id="ticket-edit-form" data-ticket-id="<?= $ticketDbId ?>">
<div class="card card--lift card--wide">
<h3 data-i18n="ticket.issue">Issue</h3>
<label class="ticket-field">
<span data-i18n="ticket.caption">Caption</span>
<input type="text" name="title" maxlength="256" required value="<?= h($ticket['title'] ?? '') ?>">
</label>
<label class="ticket-field">
<span data-i18n="ticket.summary">Summary</span>
<input type="text" name="brief" maxlength="512" value="<?= h($ticket['brief'] ?? '') ?>">
</label>
<label class="ticket-field">
<span data-i18n="ticket.description">Description</span>
<textarea name="body" rows="12"><?= h($ticket['body'] ?? '') ?></textarea>
</label>
</div>
<div class="card card--lift">
<h3 data-i18n="ticket.workflow">Workflow</h3>
<label class="ticket-field">
<span data-i18n="ticket.owner">Owner</span>
<input type="text" name="owner_username" maxlength="64" value="<?= h($ticket['owner_username'] ?? '') ?>">
</label>
<label class="ticket-field">
<span data-i18n="ticket.verified_by">Verified by</span>
<input type="text" name="verified_by_username" maxlength="64"
value="<?= h($ticket['verified_by_username'] ?? '') ?>">
</label>
<label class="ticket-field">
<span data-i18n="ticket.lifecycle">Lifecycle</span>
<select name="workflow_state" id="ticket-workflow-state">
<?php foreach (TicketWorkflow::all() as $ws): ?>
<option value="<?= h($ws) ?>"<?= $workflowState === $ws ? ' selected' : '' ?>>
<?= h(TicketWorkflow::labelDef($ws)['label']) ?>
</option>
<?php endforeach; ?>
</select>
</label>
<div class="ticket-field ticket-assignees-field">
<span data-i18n="ticket.assignees">Assignees</span>
<div class="assignee-editor" id="assignee-editor" aria-live="polite"></div>
<div class="assignee-add-row">
<select id="assignee-user-pick" aria-label="Add assignee">
<option value="">—</option>
</select>
<button type="button" class="btn" id="assignee-add-btn" data-i18n="ticket.assignee_add">Add</button>
</div>
</div>
<label class="ticket-field">
<span data-i18n="col.rating">Rating</span>
<select name="rating">
<?php for ($r = 0; $r <= 5; $r++): ?>
<option value="<?= $r ?>"<?= $rating === $r ? ' selected' : '' ?>><?= $r ?> ★</option>
<?php endfor; ?>
</select>
</label>
<p class="ticket-stars-preview">
<span class="star-rating" role="img" aria-label="<?= $rating ?> out of 5"><?= $starsHtml ?></span>
</p>
<div class="tag-editor-actions">
<button type="submit" class="btn btn-primary" data-i18n="ticket.save">Save ticket</button>
<span class="ticket-save-status muted" id="ticket-save-status"></span>
</div>
</div>
</form>
<script type="application/json" id="ticket-assignees-data"><?=
safe_json_encode($assignees, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
?></script>
<?php else: ?>
<section class="stack-block ticket-readonly-issue">
<h2><?= h($ticket['title'] ?? '') ?></h2>
<?php if (!empty($ticket['brief'])): ?>
<p class="muted"><?= h($ticket['brief']) ?></p>
<?php endif; ?>
<pre class="stack ticket-body"><?= h($ticket['body'] ?? '') ?></pre>
</section>
<?php endif; ?>
<div class="cards cards--lift">
<div class="card card--lift"><h3 data-i18n="detail.device">Device</h3>
<?php if ($deviceName !== ''): ?>
<p><?= h($deviceName) ?></p>
<?php else: ?>
<p class="muted">—</p>
<?php endif; ?>
<p>Android <?= h($release) ?><?= $sdk > 0 ? ' (SDK ' . $sdk . ')' : '' ?></p>
<?php if ($abiList !== []): ?>
<p class="muted detail-abi-row">ABIs: <?= h(implode(', ', $abiList)) ?></p>
<?php endif; ?>
</div>
<div class="card card--lift"><h3 data-i18n="detail.app">App</h3>
<p><?= h($app['package'] ?? $ticket['app_package'] ?? '') ?></p>
<p>v<?= h($app['version_name'] ?? $ticket['app_version'] ?? '') ?>
<?php if (!empty($app['version_code'])): ?>
<span class="muted">(<?= h((string) $app['version_code']) ?>)</span>
<?php endif; ?>
</p>
<p class="muted"><?= h($ticket['env_brief'] ?? '') ?></p>
</div>
<div class="card card--lift"><h3 data-i18n="ticket.timing">Timing</h3>
<p><span data-i18n="ticket.opened">Opened</span>:
<?= h(date('c', (int) (($ticket['opened_at_ms'] ?? 0) / 1000))) ?></p>
<p><span data-i18n="ticket.created">Created</span>:
<?= h(date('c', (int) (($ticket['created_at_ms'] ?? 0) / 1000))) ?></p>
<p><span data-i18n="ticket.updated">Updated</span>:
<?= h(date('c', (int) (($ticket['updated_at_ms'] ?? 0) / 1000))) ?></p>
</div>
<?php if (!$canEdit): ?>
<div class="card card--lift"><h3 data-i18n="ticket.workflow">Workflow</h3>
<p><span data-i18n="ticket.lifecycle">Lifecycle</span>:
<?= h(TicketWorkflow::labelDef($workflowState)['label']) ?></p>
<p><span data-i18n="ticket.assignees">Assignees</span>:
<?php if ($assignees === []): ?>
<span class="muted">—</span>
<?php else: ?>
<?php foreach ($assignees as $a): ?>
<span class="assignee-pill"><?= h($a['username']) ?><?php if (!empty($a['role'])): ?>
<span class="muted">(<?= h($a['role']) ?>)</span><?php endif; ?></span>
<?php endforeach; ?>
<?php endif; ?>
</p>
<p><span data-i18n="ticket.owner">Owner</span>: <?= h($ticket['owner_username'] ?? '—') ?></p>
<p><span data-i18n="ticket.verified_by">Verified by</span>:
<?= h($ticket['verified_by_username'] ?? '—') ?></p>
<p><span data-i18n="col.rating">Rating</span>:
<span class="star-rating" role="img"><?= $starsHtml ?></span></p>
</div>
<?php endif; ?>
</div>
<section class="ticket-attachments" id="ticket-attachments" data-ticket-id="<?= $ticketDbId ?>">
<h2 data-i18n="ticket.attachments">Attachments</h2>
<ul class="ticket-attachment-list" id="ticket-attachment-list">
<?php foreach ($attachments as $att): ?>
<li class="ticket-attachment ticket-attachment--<?= h($att['kind']) ?>"
data-attachment-id="<?= (int) ($att['id'] ?? 0) ?>">
<span class="attachment-provider"><?= h($att['provider_label'] ?? 'File') ?></span>
<?php if (($att['kind'] ?? '') === 'link' && !empty($att['external_url'])): ?>
<a href="<?= h($att['external_url']) ?>" target="_blank" rel="noopener noreferrer"><?= h($att['title'] ?: $att['external_url']) ?></a>
<?php elseif (!empty($att['download_url'])): ?>
<a href="<?= h($att['download_url']) ?>"><?= h($att['title'] ?: $att['original_name']) ?></a>
<?php if (!empty($att['size_bytes'])): ?>
<span class="muted">(<?= h(number_format((int) $att['size_bytes'] / 1024, 1)) ?> KB)</span>
<?php endif; ?>
<?php else: ?>
<span><?= h($att['title'] ?? '') ?></span>
<?php endif; ?>
<span class="muted attachment-meta"><?= h($att['created_by'] ?? '') ?></span>
<?php if ($canAttach): ?>
<button type="button" class="btn btn-small attachment-remove" data-id="<?= (int) ($att['id'] ?? 0) ?>">×</button>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
<?php if ($canAttach): ?>
<div class="ticket-attachment-add cards cards--lift">
<form class="ticket-link-form" id="ticket-link-form">
<h3 data-i18n="ticket.attach_link">Add link</h3>
<p class="muted" data-i18n="ticket.attach_link_hint">Google Docs, Sheets, Drive, Meet, or any HTTPS URL.</p>
<label class="ticket-field">
<span data-i18n="ticket.link_title">Title (optional)</span>
<input type="text" name="title" maxlength="256">
</label>
<label class="ticket-field">
<span>URL</span>
<input type="url" name="url" required placeholder="https://docs.google.com/...">
</label>
<button type="submit" class="btn" data-i18n="ticket.attach_link_btn">Attach link</button>
<span class="muted" id="ticket-link-status"></span>
</form>
<form class="ticket-file-form" id="ticket-file-form" enctype="multipart/form-data">
<h3 data-i18n="ticket.attach_file">Upload file</h3>
<p class="muted" data-i18n="ticket.attach_file_hint">Max 16 MiB — images, logs, pdf, zip, …</p>
<label class="ticket-field">
<span data-i18n="ticket.link_title">Title (optional)</span>
<input type="text" name="title" maxlength="256">
</label>
<label class="ticket-field">
<span data-i18n="ticket.file_pick">File</span>
<input type="file" name="file" required>
</label>
<button type="submit" class="btn btn-primary" data-i18n="ticket.attach_file_btn">Upload</button>
<span class="muted" id="ticket-file-status"></span>
</form>
</div>
<?php endif; ?>
</section>
<section class="ticket-comments" id="ticket-comments" data-ticket-id="<?= $ticketDbId ?>">
<h2 data-i18n="ticket.comments">Comments</h2>
<ul class="ticket-comment-list" id="ticket-comment-list">
<?php foreach ($comments as $c): ?>
<li class="ticket-comment">
<div class="ticket-comment-meta">
<strong><?= h($c['author_username']) ?></strong>
<span class="muted"><?= h(date('c', (int) (($c['created_at_ms'] ?? 0) / 1000))) ?></span>
</div>
<pre class="ticket-comment-body"><?= h($c['body']) ?></pre>
</li>
<?php endforeach; ?>
</ul>
<?php if ($canEdit || Auth::user()): ?>
<form class="ticket-comment-form" id="ticket-comment-form">
<label class="ticket-field">
<span data-i18n="ticket.comment_add">Add comment</span>
<textarea name="body" rows="4" required></textarea>
</label>
<button type="submit" class="btn btn-primary" data-i18n="ticket.comment_post">Post</button>
<span class="muted" id="ticket-comment-status"></span>
</form>
<?php endif; ?>
</section>
<div class="detail-tree" id="detail-tree">
<?php if (!empty($p['source_file']) || !empty($p['alpha_block'])): ?>
<div class="detail-tree-item">
<div class="detail-tree-row report-row" role="button" tabindex="0" data-tree-toggle data-controls="detail-source">
<span class="report-tree-cell">
<button type="button" class="report-tree-toggle" aria-expanded="false" aria-controls="detail-source" tabindex="-1">
<span class="report-tree-arrow" aria-hidden="true"></span>
</button>
</span>
<span class="detail-tree-caption" data-i18n="ticket.source">Source / context</span>
</div>
<div class="detail-tree-body" id="detail-source" hidden>
<pre class="stack"><?= h((string) ($p['source_excerpt'] ?? $p['notes'] ?? '')) ?></pre>
</div>
</div>
<?php endif; ?>
<div class="detail-tree-item">
<div class="detail-tree-row report-row" role="button" tabindex="0" data-tree-toggle data-controls="detail-raw-json">
<span class="report-tree-cell">
<button type="button" class="report-tree-toggle" aria-expanded="false" aria-controls="detail-raw-json" tabindex="-1">
<span class="report-tree-arrow" aria-hidden="true"></span>
</button>
</span>
<span class="detail-tree-caption" data-i18n="detail.raw_json">Raw JSON</span>
</div>
<div class="detail-tree-body" id="detail-raw-json" hidden>
<pre class="stack raw-json"><?= h($rawJson) ?></pre>
</div>
</div>
</div>