1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 05:17:39 +03:00

Add crash console tickets (list, detail, upload API).

Tickets mirror the reports UI: Issue/Opened/App·OS/Rating/Tags list,
report-style detail with tags, device/app/timing cards, workflow fields
(owner, verified by, rating), and ticket_upload for ingest including
alpha smoke script.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Anton Afanasyeu
2026-05-24 21:16:27 +02:00
parent 0fa9a59962
commit 670b6b5f5f
21 changed files with 1627 additions and 17 deletions

View File

@@ -135,6 +135,48 @@ See `sample_crash_java.json`. Key fields:
Backend stores full JSON in `reports.payload_json` for faithful replay in the UI.
## Tickets (tasks / QA)
Web UI: `/?view=tickets` — list columns **Issue**, **Opened**, **App/OS**, **Rating**, **Tags**.
Detail: `/?view=ticket&id=N` — same layout family as crash reports (tags block, device/app/timing cards, collapsible raw JSON). Editable when you are **owner** or company **admin+**: caption, summary, body, owner, verified by, rating, tags.
**Upload API** (no session; same as crashes):
```http
POST /api/ticket_upload.php
Content-Type: application/json
```
```json
{
"schema_version": 1,
"ingest_kind": "ticket",
"ticket_id": "unique-id",
"title": "Short caption",
"brief": "One-line summary for the list",
"body": "Full description",
"opened_at_epoch_ms": 1710000000000,
"rating": 3,
"owner": "admin",
"tags": [
{"id": "ticket", "label": "ticket", "bg": "#6366f1"},
{"id": "open", "label": "open", "bg": "#22c55e"}
],
"device": { "model": "BL6000 Pro", "sdk_int": 29, "release": "10" },
"app": { "package": "com.foxx.androidcast", "version_name": "0.1.0" }
}
```
Tags must include **`ticket`** plus at least one status tag (`open`, `in-progress`, `closed`, `rejected`, `confirmed`, `postponed`, `urgent`).
**Ingest alpha smoke results** from repo `tmp/alpha_smoke_round1.txt`:
```bash
php scripts/ingest_alpha_smoke_tickets.php --url=https://apps.f0xx.org/app/androidcast_project/crashes
```
MariaDB: `mysql … < sql/migrations/003_tickets.sql` (or auto-create on first request via SQLite dev).
## Default accounts
| User | Password | Role |

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../src/bootstrap.php';
if (!Auth::user()) {
json_out(['ok' => false, 'error' => 'unauthorized'], 401);
}
$method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET');
$id = (int) ($_GET['id'] ?? 0);
if ($method === 'GET') {
if ($id <= 0) {
json_out(['ok' => false, 'error' => 'invalid id'], 400);
}
$ticket = TicketRepository::getById($id);
if (!$ticket) {
json_out(['ok' => false, 'error' => 'not found'], 404);
}
json_out([
'ok' => true,
'id' => $id,
'tags' => $ticket['tags'] ?? [],
'presets' => TicketRepository::listTagPresets(),
'can_edit' => !empty($ticket['can_edit']),
]);
}
if ($method !== 'PUT' && $method !== 'POST') {
json_out(['ok' => false, 'error' => 'method not allowed'], 405);
}
$raw = file_get_contents('php://input');
$data = is_string($raw) && $raw !== '' ? json_decode($raw, true) : $_POST;
if (!is_array($data)) {
json_out(['ok' => false, 'error' => 'invalid json'], 400);
}
$id = (int) ($data['id'] ?? $id);
if ($id <= 0) {
json_out(['ok' => false, 'error' => 'invalid id'], 400);
}
$ticket = TicketRepository::getById($id);
if (!$ticket) {
json_out(['ok' => false, 'error' => 'not found'], 404);
}
if (empty($ticket['can_edit'])) {
json_out(['ok' => false, 'error' => 'forbidden'], 403);
}
$tagsIn = $data['tags'] ?? [];
if (!is_array($tagsIn)) {
json_out(['ok' => false, 'error' => 'tags must be array'], 400);
}
try {
TicketRepository::setCustomTags($id, $tagsIn);
$updated = TicketRepository::getById($id);
json_out([
'ok' => true,
'id' => $id,
'tags' => $updated['tags'] ?? [],
]);
} catch (InvalidArgumentException $e) {
json_out(['ok' => false, 'error' => $e->getMessage()], 400);
} catch (Throwable $e) {
error_log('ticket_tags: ' . $e->getMessage());
json_out(['ok' => false, 'error' => 'save failed'], 500);
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../src/bootstrap.php';
if (!Auth::user()) {
json_out(['ok' => false, 'error' => 'unauthorized'], 401);
}
if (strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'PUT' && strtoupper($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
json_out(['ok' => false, 'error' => 'method not allowed'], 405);
}
$raw = file_get_contents('php://input');
$data = is_string($raw) && $raw !== '' ? json_decode($raw, true) : $_POST;
if (!is_array($data)) {
json_out(['ok' => false, 'error' => 'invalid json'], 400);
}
$id = (int) ($data['id'] ?? 0);
if ($id <= 0) {
json_out(['ok' => false, 'error' => 'invalid id'], 400);
}
$ticket = TicketRepository::getById($id);
if (!$ticket) {
json_out(['ok' => false, 'error' => 'not found'], 404);
}
if (empty($ticket['can_edit'])) {
json_out(['ok' => false, 'error' => 'forbidden'], 403);
}
$fields = [];
foreach (['title', 'brief', 'body', 'rating', 'owner_username', 'verified_by_username'] as $key) {
if (array_key_exists($key, $data)) {
$fields[$key] = $data[$key];
}
}
try {
TicketRepository::update($id, $fields);
$updated = TicketRepository::getById($id);
json_out(['ok' => true, 'ticket' => $updated]);
} catch (InvalidArgumentException $e) {
json_out(['ok' => false, 'error' => $e->getMessage()], 400);
} catch (Throwable $e) {
error_log('ticket_update: ' . $e->getMessage());
json_out(['ok' => false, 'error' => 'save failed'], 500);
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../src/bootstrap.php';
$raw = file_get_contents('php://input');
if ($raw === false || $raw === '') {
json_out(['ok' => false, 'error' => 'empty body'], 400);
}
$data = json_decode($raw, true);
if (!is_array($data)) {
json_out(['ok' => false, 'error' => 'invalid json'], 400);
}
if ((int) ($data['schema_version'] ?? 0) !== 1) {
json_out(['ok' => false, 'error' => 'unsupported schema_version'], 400);
}
if (($data['ingest_kind'] ?? 'ticket') !== 'ticket') {
json_out(['ok' => false, 'error' => 'ingest_kind must be ticket'], 400);
}
try {
TicketRepository::insert($data);
json_out(['ok' => true, 'ticket_id' => $data['ticket_id'] ?? null]);
} catch (InvalidArgumentException $e) {
json_out(['ok' => false, 'error' => $e->getMessage()], 400);
} catch (PDOException $e) {
$msg = $e->getMessage();
if ($e->getCode() === '23000' || stripos($msg, 'UNIQUE') !== false) {
json_out(['ok' => false, 'error' => 'duplicate ticket_id'], 409);
}
error_log('ticket upload PDO: ' . $msg);
$out = ['ok' => false, 'error' => 'store failed'];
if (cfg('debug')) {
$out['hint'] = $msg;
}
json_out($out, 500);
} catch (Throwable $e) {
error_log('ticket upload: ' . $e->getMessage());
$out = ['ok' => false, 'error' => 'store failed'];
if (cfg('debug')) {
$out['hint'] = $e->getMessage();
}
json_out($out, 500);
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../src/bootstrap.php';
if (!Auth::user()) {
json_out(['ok' => false, 'error' => 'unauthorized'], 401);
}
$page = max(1, (int) ($_GET['page'] ?? 1));
$perPage = (int) ($_GET['per_page'] ?? 50);
if (!in_array($perPage, [25, 50, 75, 100, 200], true)) {
$perPage = 50;
}
$sort = (string) ($_GET['sort'] ?? 'opened_at_ms');
$dir = (string) ($_GET['dir'] ?? 'desc');
$tag = trim((string) ($_GET['tag'] ?? ''));
try {
$data = TicketRepository::listPage($page, $perPage, $sort, $dir, $tag);
json_out(['ok' => true] + $data);
} catch (Throwable $e) {
error_log('tickets api: ' . $e->getMessage());
$out = ['ok' => false, 'error' => 'list failed'];
if (cfg('debug')) {
$out['hint'] = $e->getMessage();
}
json_out($out, 500);
}

View File

@@ -196,6 +196,46 @@ a:hover { text-decoration: underline; }
}
.nav-icon--reports::before { top: 5px; }
.nav-icon--reports::after { top: 10px; width: 70%; }
.nav-icon--tickets {
width: 16px;
height: 16px;
border: 2px solid currentColor;
border-radius: 2px;
}
.nav-icon--tickets::before {
content: "";
position: absolute;
top: 3px;
left: 3px;
width: 6px;
height: 2px;
background: currentColor;
box-shadow: 0 4px 0 currentColor;
}
.ticket-col-issue { min-width: 200px; max-width: 420px; }
.ticket-issue-title { font-weight: 600; }
.ticket-issue-brief { font-size: 0.88em; margin-top: 4px; }
.ticket-col-env { font-size: 0.9em; max-width: 220px; }
.ticket-edit-form { display: grid; grid-template-columns: 1fr min(320px, 100%); gap: 16px; align-items: start; }
.ticket-edit-form .card--wide { grid-column: 1 / -1; }
@media (min-width: 900px) {
.ticket-edit-form { grid-template-columns: 1fr 280px; }
.ticket-edit-form .card--wide { grid-column: 1; grid-row: 1 / span 2; }
}
.ticket-field { display: block; margin-bottom: 12px; }
.ticket-field span { display: block; font-size: 0.85em; color: var(--muted); margin-bottom: 4px; }
.ticket-field input,
.ticket-field textarea,
.ticket-field select {
width: 100%;
box-sizing: border-box;
padding: 8px 10px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
}
.ticket-body { white-space: pre-wrap; word-break: break-word; }
/* User */
.nav-icon--user {
width: 18px;

View File

@@ -2,6 +2,7 @@
"app_name": "Android Cast Crashes",
"nav.home": "Home",
"nav.reports": "Reports",
"nav.tickets": "Tickets",
"nav.logout": "Logout",
"nav.toggle": "Navigation",
"theme.label": "Theme",
@@ -14,6 +15,7 @@
"home.intro": "Anonymous Android Cast crash ingest is active. Use Reports to analyze grouped fingerprints.",
"home.upload_api": "Upload API:",
"home.schema": "Schema:",
"home.ticket_upload_api": "Ticket upload API:",
"reports.title": "Crash reports",
"reports.by_time": "By time",
"reports.grouped": "Grouped",
@@ -112,5 +114,30 @@
"login.register": "Register",
"login.register_soon": "(coming soon)",
"login.error": "Invalid credentials",
"footer.copyright": "(c) Anton Afanaasyeu, {year}"
"footer.copyright": "(c) Anton Afanaasyeu, {year}",
"tickets.title": "Tickets",
"tickets.empty": "No tickets yet.",
"tickets.col_issue": "Issue",
"tickets.col_opened": "Opened",
"tickets.col_env": "App / OS",
"tickets.filter_tag": "Status tag",
"tickets.filter_all": "All",
"ticket.back": "Back to tickets",
"ticket.detail_title": "Ticket",
"ticket.id_label": "ID",
"ticket.tag_hint": "Must include ticket and a status tag (open, in progress, closed, …).",
"ticket.tag_readonly": "Only the owner or an admin can edit tags.",
"ticket.issue": "Issue",
"ticket.caption": "Caption",
"ticket.summary": "Summary",
"ticket.description": "Description",
"ticket.workflow": "Workflow",
"ticket.owner": "Owner",
"ticket.verified_by": "Verified by",
"ticket.timing": "Timing",
"ticket.opened": "Opened",
"ticket.created": "Created",
"ticket.updated": "Updated",
"ticket.save": "Save ticket",
"ticket.source": "Source / context"
}

View File

@@ -2,6 +2,10 @@
"app_name": "Сбои Android Cast",
"nav.home": "Главная",
"nav.reports": "Отчёты",
"nav.tickets": "Задачи",
"tickets.title": "Задачи",
"tickets.empty": "Задач пока нет.",
"ticket.detail_title": "Задача",
"nav.logout": "Выход",
"nav.toggle": "Навигация",
"theme.label": "Тема",

View File

@@ -1159,8 +1159,18 @@
return html + '</span>';
}
function tagsApiUrl(entityType) {
return basePath() + (entityType === 'ticket' ? '/api/ticket_tags.php' : '/api/report_tags.php');
}
function createTagEditor(cfg) {
const state = { tags: [...(cfg.initialTags || [])], presets: [], reportId: cfg.reportId };
const entityType = cfg.entityType === 'ticket' ? 'ticket' : 'report';
const state = {
tags: [...(cfg.initialTags || [])],
presets: [],
entityId: cfg.entityId ?? cfg.reportId ?? cfg.ticketId ?? 0,
entityType,
};
const els = cfg.elements;
function renderList() {
@@ -1276,7 +1286,7 @@
els.saveBtn.addEventListener('click', () => {
setStatus(t('tag.saving'), true);
const xhr = new XMLHttpRequest();
xhr.open('POST', basePath() + '/api/report_tags.php', true);
xhr.open('POST', tagsApiUrl(state.entityType), true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onload = function () {
let data;
@@ -1293,18 +1303,18 @@
state.tags = data.tags || [];
renderAll();
setStatus('Saved', true);
if (cfg.onSaved) cfg.onSaved(state.tags, state.reportId);
if (cfg.onSaved) cfg.onSaved(state.tags, state.entityId);
};
xhr.onerror = function () {
setStatus(t('reports.network_error'), false);
};
xhr.send(JSON.stringify({ id: state.reportId, tags: state.tags }));
xhr.send(JSON.stringify({ id: state.entityId, tags: state.tags }));
});
}
function loadFromApi() {
const xhr = new XMLHttpRequest();
xhr.open('GET', basePath() + '/api/report_tags.php?id=' + state.reportId, true);
xhr.open('GET', tagsApiUrl(state.entityType) + '?id=' + state.entityId, true);
xhr.onload = function () {
let data;
try {
@@ -1341,15 +1351,19 @@
let tagModalEditor = null;
function openTagModal(reportId) {
function openTagModal(entityId, entityType) {
const modal = document.getElementById('tag-modal');
const editorEl = document.getElementById('tag-editor-modal');
if (!modal || !editorEl || !canTagEdit()) return;
editorEl.setAttribute('data-report-id', String(reportId));
const et = entityType === 'ticket' ? 'ticket' : 'report';
editorEl.setAttribute('data-report-id', et === 'report' ? String(entityId) : '0');
editorEl.setAttribute('data-ticket-id', et === 'ticket' ? String(entityId) : '0');
editorEl.setAttribute('data-entity-type', et);
modal.hidden = false;
modal.setAttribute('aria-hidden', 'false');
tagModalEditor = createTagEditor({
reportId: Number(reportId),
entityId: Number(entityId),
entityType: et,
initialTags: [],
fetchPresets: true,
elements: {
@@ -1363,9 +1377,11 @@
},
onSaved() {
if (typeof reportsReloadRef === 'function') reportsReloadRef();
if (typeof window.ticketsReloadRef === 'function') window.ticketsReloadRef();
},
});
}
window.openTagModal = openTagModal;
function closeTagModal() {
const modal = document.getElementById('tag-modal');
@@ -1388,7 +1404,9 @@
function initTagEditorReadOnly() {
const preview = document.getElementById('tag-editor-preview');
const dataEl = document.getElementById('report-custom-tags-data');
const dataEl =
document.getElementById('ticket-custom-tags-data') ||
document.getElementById('report-custom-tags-data');
if (!preview || !dataEl || canTagEdit()) return;
try {
const tags = JSON.parse(dataEl.textContent || '[]');
@@ -1403,20 +1421,27 @@
function initTagEditorDetail() {
const root = document.getElementById('tag-editor');
if (!root) return;
if (!canTagEdit()) {
const entityType = root.getAttribute('data-entity-type') === 'ticket' ? 'ticket' : 'report';
const entityId = Number(
root.getAttribute(entityType === 'ticket' ? 'data-ticket-id' : 'data-report-id')
);
const canEdit = entityType === 'ticket' ? !!root.querySelector('#tag-editor-list') : canTagEdit();
if (!canEdit) {
initTagEditorReadOnly();
return;
}
const reportId = Number(root.getAttribute('data-report-id'));
let initial = [];
const dataEl = document.getElementById('report-custom-tags-data');
const dataEl =
document.getElementById('ticket-custom-tags-data') ||
document.getElementById('report-custom-tags-data');
if (dataEl) {
try {
initial = JSON.parse(dataEl.textContent || '[]');
} catch { /* ignore */ }
}
createTagEditor({
reportId,
entityId,
entityType,
initialTags: initial,
fetchPresets: true,
elements: {
@@ -1431,6 +1456,63 @@
});
}
function initTicketDetail() {
const id = document.body.getAttribute('data-ticket-id');
if (!id) return;
const tree = document.getElementById('detail-tree');
if (tree) bindDetailTree(tree);
const form = document.getElementById('ticket-edit-form');
if (!form) return;
const statusEl = document.getElementById('ticket-save-status');
const ratingSel = form.querySelector('select[name="rating"]');
const starsPreview = form.querySelector('.ticket-stars-preview .star-rating');
if (ratingSel && starsPreview) {
ratingSel.addEventListener('change', () => {
const n = Math.max(0, Math.min(5, Number(ratingSel.value) || 0));
let html = '';
for (let i = 1; i <= 5; i++) {
html += '<span class="star' + (i <= n ? ' star--on' : '') + '"></span>';
}
starsPreview.innerHTML = html;
});
}
form.addEventListener('submit', (e) => {
e.preventDefault();
if (statusEl) statusEl.textContent = 'Saving…';
const fd = new FormData(form);
const body = {
id: Number(form.getAttribute('data-ticket-id')),
title: fd.get('title'),
brief: fd.get('brief'),
body: fd.get('body'),
owner_username: fd.get('owner_username'),
verified_by_username: fd.get('verified_by_username'),
rating: Number(fd.get('rating')),
};
const xhr = new XMLHttpRequest();
xhr.open('PUT', basePath() + '/api/ticket_update.php', true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onload = function () {
let data;
try {
data = JSON.parse(xhr.responseText);
} catch {
if (statusEl) statusEl.textContent = 'Invalid response';
return;
}
if (!data.ok) {
if (statusEl) statusEl.textContent = data.error || 'Save failed';
return;
}
if (statusEl) statusEl.textContent = 'Saved';
};
xhr.onerror = function () {
if (statusEl) statusEl.textContent = t('reports.network_error');
};
xhr.send(JSON.stringify(body));
});
}
function initTagEditButtons(tbody, reloadFn) {
reportsReloadRef = reloadFn;
if (!tbody) return;
@@ -1439,7 +1521,7 @@
if (!btn) return;
e.stopPropagation();
e.preventDefault();
openTagModal(Number(btn.getAttribute('data-report-id')));
openTagModal(Number(btn.getAttribute('data-report-id')), 'report');
});
}
@@ -1448,6 +1530,7 @@
initNav();
initTagModal();
initReportDetail();
initTicketDetail();
initTagEditorDetail();
initReportsApp();
}

View File

@@ -0,0 +1,305 @@
(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, '&amp;')
.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) {
return (
'<span class="report-tag" style="--tag-bg:' +
escapeHtml(tag.bg || '#5c6b82') +
'">' +
escapeHtml(tag.label || tag.id) +
'</span>'
);
}
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 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')) return;
go();
});
el.addEventListener('keydown', (e) => {
if (e.target.closest('.report-tree-toggle, .tag-edit-btn')) 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');
}
});
});
}
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();
});
});
}
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);
}
})();

View File

@@ -45,6 +45,26 @@ if ($route === '/api/report_tags.php' || str_ends_with($route, '/api/report_tags
exit;
}
if ($route === '/api/ticket_upload.php' || str_ends_with($route, '/api/ticket_upload.php')) {
require __DIR__ . '/api/ticket_upload.php';
exit;
}
if ($route === '/api/tickets.php' || str_ends_with($route, '/api/tickets.php')) {
require __DIR__ . '/api/tickets.php';
exit;
}
if ($route === '/api/ticket_tags.php' || str_ends_with($route, '/api/ticket_tags.php')) {
require __DIR__ . '/api/ticket_tags.php';
exit;
}
if ($route === '/api/ticket_update.php' || str_ends_with($route, '/api/ticket_update.php')) {
require __DIR__ . '/api/ticket_update.php';
exit;
}
if ($route === '/logout') {
Auth::logout();
header('Location: ' . $base . '/login');
@@ -104,5 +124,28 @@ if ($view === 'report' && isset($_GET['id'])) {
exit;
}
$pageTitle = $view === 'home' ? 'Home' : 'Crash reports';
if ($view === 'ticket' && isset($_GET['id'])) {
try {
$ticket = TicketRepository::getById((int) $_GET['id']);
if (!$ticket) {
http_response_code(404);
echo 'Not found';
exit;
}
$pageTitle = 'Ticket';
require __DIR__ . '/../views/layout.php';
} catch (Throwable $e) {
error_log('ticket view: ' . $e->getMessage());
http_response_code(500);
echo cfg('debug') ? 'Ticket view failed: ' . $e->getMessage() : 'Ticket view failed';
}
exit;
}
$pageTitle = match ($view) {
'home' => 'Home',
'tickets' => 'Tickets',
'reports', 'report' => 'Crash reports',
default => 'Console',
};
require __DIR__ . '/../views/layout.php';

View File

@@ -0,0 +1,159 @@
#!/usr/bin/env php
<?php
/**
* Parse tmp/alpha_smoke_round1.txt and POST tickets to ticket_upload.php.
*
* Usage:
* php scripts/ingest_alpha_smoke_tickets.php [--file=path] [--url=BASE] [--owner=admin] [--dry-run]
*/
declare(strict_types=1);
$repoRoot = dirname(__DIR__, 3);
$defaultFile = $repoRoot . '/tmp/alpha_smoke_round1.txt';
$opts = getopt('', ['file::', 'url::', 'owner::', 'dry-run', 'device::', 'app-version::']);
$file = $opts['file'] ?? $defaultFile;
$base = rtrim($opts['url'] ?? 'https://apps.f0xx.org/app/androidcast_project/crashes', '/');
$owner = $opts['owner'] ?? 'admin';
$dryRun = isset($opts['dry-run']);
$uploadUrl = $base . '/api/ticket_upload.php';
if (!is_readable($file)) {
fwrite(STDERR, "File not found: $file\n");
exit(1);
}
$lines = file($file, FILE_IGNORE_NEW_LINES);
$rows = [];
foreach ($lines as $line) {
$line = trim($line);
if ($line === '' || str_starts_with($line, 'block |')) {
continue;
}
$parts = array_map('trim', explode('|', $line, 3));
if (count($parts) < 3) {
continue;
}
[$block, $expected, $actual] = $parts;
$rows[] = compact('block', 'expected', 'actual');
}
$device = [
'manufacturer' => 'DOOGEE',
'brand' => 'DOOGEE',
'model' => $opts['device'] ?? 'BL6000 Pro',
'sdk_int' => 29,
'release' => '10',
'abis' => ['arm64-v8a', 'armeabi-v7a'],
];
$app = [
'package' => 'com.foxx.androidcast',
'version_name' => $opts['app-version'] ?? '0.1.0-alpha',
'version_code' => 1,
];
function classify_row(array $row): array {
$actual = $row['actual'];
$pass = (bool) preg_match('/^\s*pass\b/i', $actual) && !preg_match('/\bNOK\b/i', $actual);
$needsTicket = !$pass
|| preg_match('/needs review|remark|poor|low-res|frame drop|lifecycle|VU-meter|laggy|wrong/i', $actual);
if (!$needsTicket) {
return ['skip' => true];
}
$status = 'open';
$rating = 3;
if (preg_match('/\bNOK\b/i', $actual)) {
$status = 'urgent';
$rating = 4;
} elseif ($pass) {
$status = 'confirmed';
$rating = 2;
}
if (preg_match('/needs review/i', $actual)) {
$status = 'in-progress';
}
return ['skip' => false, 'status' => $status, 'rating' => $rating];
}
function ticket_tags(string $status): array {
$map = [
'open' => ['id' => 'open', 'label' => 'open', 'bg' => '#22c55e'],
'in-progress' => ['id' => 'in-progress', 'label' => 'in progress', 'bg' => '#3b82f6'],
'confirmed' => ['id' => 'confirmed', 'label' => 'confirmed', 'bg' => '#047857'],
'urgent' => ['id' => 'urgent', 'label' => 'urgent', 'bg' => '#dc2626'],
'closed' => ['id' => 'closed', 'label' => 'closed', 'bg' => '#6b7280'],
];
$tags = [
['id' => 'ticket', 'label' => 'ticket', 'bg' => '#6366f1'],
['id' => 'alpha-smoke', 'label' => 'alpha-smoke', 'bg' => '#8b5cf6'],
$map[$status] ?? $map['open'],
];
return $tags;
}
$now = (int) round(microtime(true) * 1000);
$ok = 0;
$skip = 0;
$fail = 0;
foreach ($rows as $row) {
$class = classify_row($row);
if (!empty($class['skip'])) {
$skip++;
continue;
}
$block = $row['block'];
$title = 'Alpha smoke ' . $block . ': ' . mb_substr($row['expected'], 0, 80);
$brief = mb_substr($row['actual'], 0, 240);
$body = "Block: {$block}\n\nExpected:\n{$row['expected']}\n\nActual:\n{$row['actual']}\n\nSource: alpha_smoke_round1.txt";
$payload = [
'schema_version' => 1,
'ingest_kind' => 'ticket',
'ticket_id' => 'alpha-smoke-' . strtolower($block) . '-' . substr(sha1($body), 0, 12),
'title' => $title,
'brief' => $brief,
'body' => $body,
'opened_at_epoch_ms' => $now,
'rating' => $class['rating'],
'owner' => $owner,
'verified_by' => '',
'tags' => ticket_tags($class['status']),
'device' => $device,
'app' => $app,
'alpha_block' => $block,
'source_file' => basename($file),
'source_excerpt' => $body,
];
$json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if ($dryRun) {
echo "DRY $block{$payload['ticket_id']} [{$class['status']}]\n";
$ok++;
continue;
}
$ctx = stream_context_create([
'http' => [
'method' => 'POST',
'header' => "Content-Type: application/json\r\n",
'content' => $json,
'ignore_errors' => true,
],
]);
$resp = @file_get_contents($uploadUrl, false, $ctx);
$code = 0;
if (isset($http_response_header[0]) && preg_match('/\s(\d{3})\s/', $http_response_header[0], $m)) {
$code = (int) $m[1];
}
if ($code === 200) {
echo "OK $block\n";
$ok++;
} elseif ($code === 409) {
echo "DUP $block\n";
$ok++;
} else {
echo "FAIL $block HTTP $code$resp\n";
$fail++;
}
}
echo "Done: uploaded=$ok skipped=$skip failed=$fail\n";
exit($fail > 0 ? 1 : 0);

View File

@@ -0,0 +1,32 @@
-- Tickets (tasks / QA / user issues) — same console as crash reports.
-- Run: mysql -u root -p androidcast_crashes < sql/migrations/003_tickets.sql
CREATE TABLE IF NOT EXISTS tickets (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
company_id INT UNSIGNED NOT NULL DEFAULT 1,
device_id BIGINT UNSIGNED NULL,
ticket_id VARCHAR(128) NOT NULL,
title VARCHAR(256) NOT NULL,
brief TEXT NULL,
body LONGTEXT NOT NULL,
opened_at_ms BIGINT NOT NULL,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
owner_username VARCHAR(64) NOT NULL DEFAULT '',
verified_by_username VARCHAR(64) NULL,
device_model VARCHAR(128) NULL,
app_package VARCHAR(128) NULL,
app_version VARCHAR(64) NULL,
os_name VARCHAR(64) NULL,
os_version VARCHAR(128) NULL,
rating TINYINT UNSIGNED NOT NULL DEFAULT 0,
tags_json LONGTEXT NOT NULL,
payload_json LONGTEXT NOT NULL,
UNIQUE KEY uq_tickets_ticket_id (ticket_id),
KEY idx_tickets_company (company_id),
KEY idx_tickets_opened (opened_at_ms),
KEY idx_tickets_updated (updated_at_ms),
KEY idx_tickets_owner (owner_username),
CONSTRAINT fk_tickets_company FOREIGN KEY (company_id) REFERENCES companies (id),
CONSTRAINT fk_tickets_device FOREIGN KEY (device_id) REFERENCES devices (id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -0,0 +1,27 @@
CREATE TABLE IF NOT EXISTS tickets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
company_id INTEGER NOT NULL DEFAULT 1,
device_id INTEGER NULL,
ticket_id TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
brief TEXT,
body TEXT NOT NULL,
opened_at_ms INTEGER NOT NULL,
created_at_ms INTEGER NOT NULL,
updated_at_ms INTEGER NOT NULL,
owner_username TEXT NOT NULL DEFAULT '',
verified_by_username TEXT,
device_model TEXT,
app_package TEXT,
app_version TEXT,
os_name TEXT,
os_version TEXT,
rating INTEGER NOT NULL DEFAULT 0,
tags_json TEXT NOT NULL DEFAULT '[]',
payload_json TEXT NOT NULL,
FOREIGN KEY (company_id) REFERENCES companies(id),
FOREIGN KEY (device_id) REFERENCES devices(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_tickets_company ON tickets(company_id);
CREATE INDEX IF NOT EXISTS idx_tickets_opened ON tickets(opened_at_ms DESC);

View File

@@ -49,6 +49,19 @@ final class Auth {
return Rbac::can('edit_tags', $companyId);
}
/** Ticket owner or company admin+ (same gate as tag edit for alpha). */
public static function canEditTicket(array $ticket, ?int $companyId = null): bool {
if (self::canEditTags($companyId)) {
return true;
}
$user = self::user();
if (!$user) {
return false;
}
$owner = trim((string) ($ticket['owner_username'] ?? ''));
return $owner !== '' && strcasecmp($owner, (string) ($user['username'] ?? '')) === 0;
}
public static function login(string $username, string $password): bool {
$pdo = Database::pdo();
$stmt = $pdo->prepare('SELECT * FROM users WHERE username = ? LIMIT 1');

View File

@@ -156,6 +156,25 @@ final class Database {
}
self::ensureMysqlReportViewsTable($pdo);
self::ensureRbacSchema($pdo);
self::ensureTicketsTable($pdo);
}
public static function ensureTicketsTable(?PDO $pdo = null): void {
$pdo ??= self::$pdo;
if ($pdo === null || self::tableExists($pdo, 'tickets')) {
return;
}
if (self::isMysql()) {
$sql = file_get_contents(__DIR__ . '/../sql/migrations/003_tickets.sql') ?: '';
$sql = preg_replace('/^\s*USE\s+[^;]+;\s*/mi', '', $sql) ?? $sql;
self::execSchemaSql($pdo, $sql, 'create tickets mysql');
return;
}
self::execSchemaSql(
$pdo,
file_get_contents(__DIR__ . '/../sql/migrations/003_tickets.sqlite.sql') ?: '',
'create tickets sqlite'
);
}
/**

View File

@@ -0,0 +1,274 @@
<?php
declare(strict_types=1);
final class TicketRepository {
private const LIST_SORT = [
'opened_at_ms',
'updated_at_ms',
'title',
'rating',
'app_version',
'device_model',
'owner_username',
];
public static function insert(array $payload): void {
$err = TicketTagCatalog::validate(is_array($payload['tags'] ?? null) ? $payload['tags'] : []);
if ($err !== null) {
throw new InvalidArgumentException($err);
}
$pdo = Database::pdo();
$device = is_array($payload['device'] ?? null) ? $payload['device'] : [];
$app = is_array($payload['app'] ?? null) ? $payload['app'] : [];
$tags = TicketTagCatalog::normalize($payload['tags'] ?? []);
$tagsJson = json_encode($tags, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if ($json === false || $tagsJson === false) {
throw new RuntimeException('json_encode failed');
}
$now = (int) round(microtime(true) * 1000);
$opened = (int) ($payload['opened_at_epoch_ms'] ?? $now);
$companyId = Rbac::defaultCompanyId();
$deviceId = DeviceRepository::upsertFromPayload($companyId, $device, 'auto');
$title = trim((string) ($payload['title'] ?? ''));
if ($title === '') {
throw new InvalidArgumentException('title required');
}
$brief = trim((string) ($payload['brief'] ?? ''));
$body = (string) ($payload['body'] ?? $brief);
$rating = max(0, min(5, (int) ($payload['rating'] ?? 0)));
$owner = trim((string) ($payload['owner'] ?? $payload['owner_username'] ?? ''));
$verified = trim((string) ($payload['verified_by'] ?? $payload['verified_by_username'] ?? ''));
$osName = device_os_name($device);
$osVer = ticket_format_os_version($device);
$stmt = $pdo->prepare(
'INSERT INTO tickets (company_id, device_id, ticket_id, title, brief, body,
opened_at_ms, created_at_ms, updated_at_ms, owner_username, verified_by_username,
device_model, app_package, app_version, os_name, os_version, rating, tags_json, payload_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
);
$stmt->execute([
$companyId,
$deviceId > 0 ? $deviceId : null,
$payload['ticket_id'] ?? uniqid('tkt_', true),
mb_substr($title, 0, 256),
$brief !== '' ? $brief : null,
$body,
$opened,
$now,
$now,
mb_substr($owner, 0, 64),
$verified !== '' ? mb_substr($verified, 0, 64) : null,
$device['model'] ?? null,
$app['package'] ?? 'com.foxx.androidcast',
$app['version_name'] ?? null,
$osName !== '' ? $osName : null,
$osVer !== '' ? $osVer : null,
$rating,
$tagsJson,
$json,
]);
}
public static function listPage(int $page, int $perPage, string $sort, string $dir, string $tagFilter = ''): array {
$pdo = Database::pdo();
$page = max(1, $page);
$perPage = max(1, min(200, $perPage));
$dir = strtolower($dir) === 'asc' ? 'ASC' : 'DESC';
$sortCol = in_array($sort, self::LIST_SORT, true) ? $sort : 'opened_at_ms';
$offset = ($page - 1) * $perPage;
$where = ['1=1'];
$params = [];
self::appendCompanyScope('company_id', $where, $params);
if ($tagFilter !== '') {
$where[] = 'tags_json LIKE ?';
$params[] = '%"id":"' . str_replace(['%', '_'], ['\\%', '\\_'], $tagFilter) . '"%';
}
$whereSql = implode(' AND ', $where);
$countStmt = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE $whereSql");
$countStmt->execute($params);
$total = (int) $countStmt->fetchColumn();
$sql = "SELECT id, ticket_id, title, brief, opened_at_ms, updated_at_ms, created_at_ms,
owner_username, verified_by_username, device_model, app_package, app_version,
os_name, os_version, rating, tags_json
FROM tickets WHERE $whereSql ORDER BY $sortCol $dir LIMIT $perPage OFFSET $offset";
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$items = [];
foreach ($stmt->fetchAll() as $row) {
$items[] = self::mapListRow($row);
}
return [
'items' => $items,
'total' => $total,
'page' => $page,
'per_page' => $perPage,
'sort' => $sortCol,
'dir' => strtolower($dir),
];
}
public static function getById(int $id): ?array {
$pdo = Database::pdo();
$where = ['id = ?'];
$params = [$id];
self::appendCompanyScope('company_id', $where, $params);
$stmt = $pdo->prepare(
'SELECT * FROM tickets WHERE ' . implode(' AND ', $where) . ' LIMIT 1'
);
$stmt->execute($params);
$row = $stmt->fetch();
if (!$row) {
return null;
}
return self::mapDetailRow($row);
}
public static function setCustomTags(int $id, array $tagsIn): void {
$ticket = self::getById($id);
if (!$ticket) {
throw new RuntimeException('not found');
}
$err = TicketTagCatalog::validate($tagsIn);
if ($err !== null) {
throw new InvalidArgumentException($err);
}
$tags = TicketTagCatalog::normalize($tagsIn);
$now = (int) round(microtime(true) * 1000);
$pdo = Database::pdo();
$stmt = $pdo->prepare('UPDATE tickets SET tags_json = ?, updated_at_ms = ? WHERE id = ?');
$stmt->execute([
json_encode($tags, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
$now,
$id,
]);
}
/** @param array<string, mixed> $fields */
public static function update(int $id, array $fields): void {
$ticket = self::getById($id);
if (!$ticket) {
throw new RuntimeException('not found');
}
$sets = [];
$params = [];
if (isset($fields['title'])) {
$title = trim((string) $fields['title']);
if ($title === '') {
throw new InvalidArgumentException('title required');
}
$sets[] = 'title = ?';
$params[] = mb_substr($title, 0, 256);
}
if (array_key_exists('brief', $fields)) {
$sets[] = 'brief = ?';
$params[] = trim((string) $fields['brief']) ?: null;
}
if (isset($fields['body'])) {
$sets[] = 'body = ?';
$params[] = (string) $fields['body'];
}
if (isset($fields['rating'])) {
$sets[] = 'rating = ?';
$params[] = max(0, min(5, (int) $fields['rating']));
}
if (isset($fields['owner_username'])) {
$sets[] = 'owner_username = ?';
$params[] = mb_substr(trim((string) $fields['owner_username']), 0, 64);
}
if (array_key_exists('verified_by_username', $fields)) {
$v = trim((string) $fields['verified_by_username']);
$sets[] = 'verified_by_username = ?';
$params[] = $v !== '' ? mb_substr($v, 0, 64) : null;
}
if ($sets === []) {
return;
}
$now = (int) round(microtime(true) * 1000);
$sets[] = 'updated_at_ms = ?';
$params[] = $now;
$params[] = $id;
$pdo = Database::pdo();
$stmt = $pdo->prepare('UPDATE tickets SET ' . implode(', ', $sets) . ' WHERE id = ?');
$stmt->execute($params);
}
public static function listTagPresets(): array {
return TicketTagCatalog::presets();
}
private static function appendCompanyScope(string $column, array &$where, array &$params): void {
$allowed = Rbac::allowedCompanyIds();
if ($allowed === null) {
return;
}
if ($allowed === []) {
$where[] = '0=1';
return;
}
$placeholders = implode(',', array_fill(0, count($allowed), '?'));
$where[] = "$column IN ($placeholders)";
foreach ($allowed as $id) {
$params[] = $id;
}
}
/** @param array<string, mixed> $row */
private static function mapListRow(array $row): array {
$tags = parse_report_tags($row['tags_json'] ?? '[]');
$pkg = (string) ($row['app_package'] ?? 'com.foxx.androidcast');
$ver = (string) ($row['app_version'] ?? '');
$os = trim(((string) ($row['os_name'] ?? '')) . ' ' . ((string) ($row['os_version'] ?? '')));
return [
'id' => (int) $row['id'],
'ticket_id' => (string) $row['ticket_id'],
'title' => (string) $row['title'],
'brief' => (string) ($row['brief'] ?? ''),
'opened_at_ms' => (int) $row['opened_at_ms'],
'updated_at_ms' => (int) $row['updated_at_ms'],
'owner_username' => (string) ($row['owner_username'] ?? ''),
'verified_by_username' => (string) ($row['verified_by_username'] ?? ''),
'device_model' => (string) ($row['device_model'] ?? ''),
'app_package' => $pkg,
'app_version' => $ver,
'os_name' => (string) ($row['os_name'] ?? ''),
'os_version' => (string) ($row['os_version'] ?? ''),
'env_brief' => ticket_env_brief($pkg, $ver, (string) ($row['os_name'] ?? ''), (string) ($row['os_version'] ?? '')),
'rating' => (int) ($row['rating'] ?? 0),
'tags' => $tags,
];
}
/** @param array<string, mixed> $row */
private static function mapDetailRow(array $row): array {
$list = self::mapListRow($row);
$payload = json_decode($row['payload_json'] ?? '', true);
$list['body'] = (string) ($row['body'] ?? '');
$list['created_at_ms'] = (int) ($row['created_at_ms'] ?? 0);
$list['payload'] = is_array($payload) ? $payload : [];
$list['can_edit'] = Auth::canEditTicket($list);
return $list;
}
}
function ticket_format_os_version(array $device): string {
$rel = (string) ($device['release'] ?? '');
$sdk = (int) ($device['sdk_int'] ?? 0);
if ($rel !== '' && $sdk > 0) {
return $rel . ' (API ' . $sdk . ')';
}
if ($rel !== '') {
return $rel;
}
if ($sdk > 0) {
return 'API ' . $sdk;
}
return '';
}
function ticket_env_brief(string $pkg, string $version, string $osName, string $osVersion): string {
$app = str_contains($pkg, 'androidcast') ? 'Android Cast' : ($pkg !== '' ? $pkg : 'app');
$line = trim($app . ($version !== '' ? ' ' . $version : ''));
$os = trim($osName . ($osVersion !== '' ? ' · ' . $osVersion : ''));
return trim($line . ($os !== '' ? ' · ' . $os : ''));
}

View File

@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
/** Workflow tags for tickets (must include meta tag {@see META_ID}). */
final class TicketTagCatalog {
public const META_ID = 'ticket';
/** @var array<string, array{label:string,bg:string}> */
private const STATUS = [
'open' => ['label' => 'open', 'bg' => '#22c55e'],
'in-progress' => ['label' => 'in progress', 'bg' => '#3b82f6'],
'closed' => ['label' => 'closed', 'bg' => '#6b7280'],
'rejected' => ['label' => 'rejected', 'bg' => '#b91c1c'],
'confirmed' => ['label' => 'confirmed', 'bg' => '#047857'],
'postponed' => ['label' => 'postponed', 'bg' => '#a16207'],
'urgent' => ['label' => 'urgent', 'bg' => '#dc2626'],
];
/** @return list<string> */
public static function statusIds(): array {
return array_keys(self::STATUS);
}
/** @return list<array{id:string,label:string,bg:string}> */
public static function presets(): array {
$out = [
['id' => self::META_ID, 'label' => 'ticket', 'bg' => '#6366f1'],
];
foreach (self::STATUS as $id => $def) {
$out[] = ['id' => $id, 'label' => $def['label'], 'bg' => $def['bg']];
}
return $out;
}
/**
* @param list<mixed> $tags
* @return list<array{id:string,label:string,bg:string}>
*/
public static function normalize(array $tags): array {
$normalized = normalize_report_tags($tags);
$hasMeta = false;
$hasStatus = false;
foreach ($normalized as $t) {
if ($t['id'] === self::META_ID) {
$hasMeta = true;
}
if (isset(self::STATUS[$t['id']])) {
$hasStatus = true;
}
}
if (!$hasMeta) {
$normalized[] = ['id' => self::META_ID, 'label' => 'ticket', 'bg' => '#6366f1'];
}
if (!$hasStatus) {
$normalized[] = ['id' => 'open', 'label' => 'open', 'bg' => '#22c55e'];
}
return $normalized;
}
public static function validate(array $tags): ?string {
$normalized = self::normalize($tags);
$hasMeta = false;
$hasStatus = false;
foreach ($normalized as $t) {
if ($t['id'] === self::META_ID) {
$hasMeta = true;
}
if (isset(self::STATUS[$t['id']])) {
$hasStatus = true;
}
}
if (!$hasMeta) {
return 'tags must include ticket meta tag';
}
if (!$hasStatus) {
return 'tags must include a status tag (open, in-progress, closed, …)';
}
return null;
}
}

View File

@@ -37,6 +37,8 @@ require_once __DIR__ . '/Rbac.php';
require_once __DIR__ . '/DeviceRepository.php';
require_once __DIR__ . '/Auth.php';
require_once __DIR__ . '/ReportRepository.php';
require_once __DIR__ . '/TicketTagCatalog.php';
require_once __DIR__ . '/TicketRepository.php';
function cfg(string $key, $default = null) {
global $config;

View File

@@ -28,11 +28,15 @@
<link rel="stylesheet" href="<?= h(Auth::basePath()) ?>/assets/css/app.css">
<script src="<?= h(Auth::basePath()) ?>/assets/js/i18n.js" defer></script>
<script src="<?= h(Auth::basePath()) ?>/assets/js/app.js" defer></script>
<?php if (in_array($view ?? '', ['tickets', 'ticket'], true)): ?>
<script src="<?= h(Auth::basePath()) ?>/assets/js/tickets.js" defer></script>
<?php endif; ?>
</head>
<body data-base-path="<?= h(Auth::basePath()) ?>"
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 ?? '') === 'report' && !empty($report['id'])) ? ' data-report-id="' . (int) $report['id'] . '"' : '' ?>
<?= (($view ?? '') === 'ticket' && !empty($ticket['id'])) ? ' data-ticket-id="' . (int) $ticket['id'] . '"' : '' ?>>
<header class="top-menu" hidden aria-hidden="true"></header>
<div class="shell">
<nav class="nav-pane" id="nav-pane" aria-label="Console navigation">
@@ -60,6 +64,16 @@
<span class="nav-label" data-i18n="nav.reports">Reports</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-label" data-i18n="nav.tickets">Tickets</span>
</a>
</li>
</ul>
<div class="nav-locale">
<div class="locale-picker" data-i18n-title="lang.label" title="Language">
@@ -92,9 +106,64 @@
<ul>
<li><span data-i18n="home.upload_api">Upload API:</span> <code><?= h(Auth::basePath()) ?>/api/upload.php</code></li>
<li><span data-i18n="home.schema">Schema:</span> <code>schema_version: 1</code></li>
<li><span data-i18n="home.ticket_upload_api">Ticket upload API:</span> <code><?= h(Auth::basePath()) ?>/api/ticket_upload.php</code></li>
</ul>
<?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">
<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>
</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">

View File

@@ -0,0 +1,205 @@
<?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 = 'tickets';
$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);
$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">Must include <strong>ticket</strong> and a status tag (open, in progress, closed, …).</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="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>
<?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.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>
<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>