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:
@@ -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 |
|
||||
|
||||
69
examples/crash_reporter/backend/public/api/ticket_tags.php
Normal file
69
examples/crash_reporter/backend/public/api/ticket_tags.php
Normal 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);
|
||||
}
|
||||
47
examples/crash_reporter/backend/public/api/ticket_update.php
Normal file
47
examples/crash_reporter/backend/public/api/ticket_update.php
Normal 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);
|
||||
}
|
||||
42
examples/crash_reporter/backend/public/api/ticket_upload.php
Normal file
42
examples/crash_reporter/backend/public/api/ticket_upload.php
Normal 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);
|
||||
}
|
||||
28
examples/crash_reporter/backend/public/api/tickets.php
Normal file
28
examples/crash_reporter/backend/public/api/tickets.php
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "Тема",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
305
examples/crash_reporter/backend/public/assets/js/tickets.js
Normal file
305
examples/crash_reporter/backend/public/assets/js/tickets.js
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
})();
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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');
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
274
examples/crash_reporter/backend/src/TicketRepository.php
Normal file
274
examples/crash_reporter/backend/src/TicketRepository.php
Normal 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 : ''));
|
||||
}
|
||||
80
examples/crash_reporter/backend/src/TicketTagCatalog.php
Normal file
80
examples/crash_reporter/backend/src/TicketTagCatalog.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
205
examples/crash_reporter/backend/views/ticket_detail.php
Normal file
205
examples/crash_reporter/backend/views/ticket_detail.php
Normal 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>
|
||||
Reference in New Issue
Block a user