1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 06:39:09 +03:00
This commit is contained in:
Anton Afanasyeu
2026-06-10 09:15:19 +02:00
parent 9f6d33cb26
commit e234a7b535
14 changed files with 280 additions and 34 deletions

View File

@@ -15,6 +15,9 @@
"nav.remote": "Remote access", "nav.remote": "Remote access",
"nav.builder": "Builder", "nav.builder": "Builder",
"nav.toggle": "Navigation", "nav.toggle": "Navigation",
"nav.security": "Security",
"nav.logout": "Logout",
"nav.sign_in": "Sign in",
"lang.label": "Language", "lang.label": "Language",
"theme.label": "Theme", "theme.label": "Theme",
"rail.label": "Page navigation", "rail.label": "Page navigation",

View File

@@ -15,6 +15,9 @@
"nav.remote": "Удалённый доступ", "nav.remote": "Удалённый доступ",
"nav.builder": "Сборщик", "nav.builder": "Сборщик",
"nav.toggle": "Навигация", "nav.toggle": "Навигация",
"nav.security": "Безопасность",
"nav.logout": "Выход",
"nav.sign_in": "Вход",
"lang.label": "Язык", "lang.label": "Язык",
"theme.label": "Тема", "theme.label": "Тема",
"rail.label": "Навигация по страницам", "rail.label": "Навигация по страницам",

View File

@@ -158,12 +158,26 @@
/* (2) Left shell — console nav-pane */ /* (2) Left shell — console nav-pane */
.landing-left.nav-pane { .landing-left.nav-pane {
position: relative; position: relative;
display: flex;
flex-direction: column;
flex: 0 0 var(--landing-left-w); flex: 0 0 var(--landing-left-w);
width: var(--landing-left-w); width: var(--landing-left-w);
height: 100%; height: 100%;
max-height: 100%;
overflow: hidden;
z-index: 3; z-index: 3;
pointer-events: auto; pointer-events: auto;
border-right: 1px solid var(--border); border-right: 1px solid var(--border);
background: var(--surface);
}
.landing-left .nav-list {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
}
.landing-left .nav-user {
margin-top: auto;
border-top: 1px solid var(--border);
} }
.landing-left.nav-pane.open { .landing-left.nav-pane.open {
width: 200px; width: 200px;

View File

@@ -4,7 +4,22 @@ if (!is_file($__platformDir . '/footer.php')) {
$__platformDir = dirname(__DIR__) . '/platform'; $__platformDir = dirname(__DIR__) . '/platform';
} }
require_once $__platformDir . '/footer.php'; require_once $__platformDir . '/footer.php';
unset($__platformDir); $__sessionFile = $__platformDir . '/shared_session.php';
$hubCrashesBase = '/app/androidcast_project/crashes';
$hubUsername = '';
if (is_file($__sessionFile) && function_exists('session_start')) {
require_once $__sessionFile;
platform_start_session('ac_crash_sess', '/app/androidcast_project');
$hubUser = $_SESSION['user'] ?? null;
if (is_array($hubUser)) {
$hubUsername = trim((string) ($hubUser['username'] ?? ''));
}
}
unset($__platformDir, $__sessionFile);
function hub_h(string $s): string {
return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" data-theme="dark" data-clock-font="mixed"> <html lang="en" data-theme="dark" data-clock-font="mixed">
@@ -129,6 +144,36 @@ unset($__platformDir);
<li><a href="crashes/?view=remote_access" class="nav-link"><span class="nav-icon nav-icon--reports"></span><span class="nav-label" data-i18n="nav.remote">Remote access</span></a></li> <li><a href="crashes/?view=remote_access" class="nav-link"><span class="nav-icon nav-icon--reports"></span><span class="nav-label" data-i18n="nav.remote">Remote access</span></a></li>
<li><a href="build/" class="nav-link"><span class="nav-icon nav-icon--builder"></span><span class="nav-label" data-i18n="nav.builder">Builder</span></a></li> <li><a href="build/" class="nav-link"><span class="nav-icon nav-icon--builder"></span><span class="nav-label" data-i18n="nav.builder">Builder</span></a></li>
</ul> </ul>
<div class="nav-user">
<?php if ($hubUsername !== ''): ?>
<span class="nav-user-name" title="<?= hub_h($hubUsername) ?>">
<span class="nav-icon nav-icon--user" aria-hidden="true"></span>
<span class="nav-label"><?= hub_h($hubUsername) ?></span>
</span>
<a href="<?= hub_h($hubCrashesBase) ?>/account-security"
class="nav-link"
data-i18n-aria="nav.security" data-i18n-title="nav.security"
aria-label="Security" title="Security">
<span class="nav-icon nav-icon--user" aria-hidden="true"></span>
<span class="nav-label" data-i18n="nav.security">Security</span>
</a>
<a href="<?= hub_h($hubCrashesBase) ?>/logout"
class="nav-link nav-link--logout"
data-i18n-aria="nav.logout" data-i18n-title="nav.logout"
aria-label="Logout" title="Logout">
<span class="nav-icon nav-icon--logout" aria-hidden="true"></span>
<span class="nav-label" data-i18n="nav.logout">Logout</span>
</a>
<?php else: ?>
<a href="<?= hub_h($hubCrashesBase) ?>/login"
class="nav-link"
data-i18n-aria="nav.sign_in" data-i18n-title="nav.sign_in"
aria-label="Sign in" title="Sign in">
<span class="nav-icon nav-icon--user" aria-hidden="true"></span>
<span class="nav-label" data-i18n="nav.sign_in">Sign in</span>
</a>
<?php endif; ?>
</div>
</nav> </nav>
<main class="landing-scroll" id="landing-scroll" tabindex="-1"> <main class="landing-scroll" id="landing-scroll" tabindex="-1">

View File

@@ -1576,6 +1576,51 @@ button.report-tag--filter:hover {
.tag-modal-close:hover { color: var(--text); background: var(--row-hover); } .tag-modal-close:hover { color: var(--text); background: var(--row-hover); }
.tag-editor--modal { margin: 0; border: none; border-radius: 0; } .tag-editor--modal { margin: 0; border: none; border-radius: 0; }
.console-home-intro {
margin: 0 0 20px;
color: var(--muted);
max-width: 42rem;
line-height: 1.5;
}
.console-home-cards {
margin: 0 0 28px;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
.console-home-card {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
text-decoration: none;
color: var(--text);
cursor: pointer;
font: inherit;
text-align: left;
width: 100%;
}
.console-home-card--btn {
border: 1px solid var(--border);
background: var(--surface);
}
.console-home-card h2 {
margin: 0;
font-size: 1.05rem;
font-weight: 600;
}
.console-home-card p {
margin: 0;
font-size: 0.88rem;
line-height: 1.45;
}
.console-home-card .nav-icon {
opacity: 0.9;
}
.console-home-more {
margin: 8px 0 12px;
font-size: 0.95rem;
font-weight: 600;
color: var(--muted);
}
.graphs-app .graphs-quick-links, .graphs-app .graphs-quick-links,
.console-quick-links { .console-quick-links {
display: flex; display: flex;

View File

@@ -11,8 +11,15 @@
"lang.label": "Language", "lang.label": "Language",
"lang.en": "English", "lang.en": "English",
"lang.ru": "Russian", "lang.ru": "Russian",
"home.title": "Issues console", "home.title": "Console",
"home.intro": "Operator console for crash triage and tickets — not the public upload API. Open Issues to browse and group fingerprints, or use the shortcuts below.", "home.intro": "Crash triage, tickets, and session metrics — pick a workspace below.",
"home.card_issues": "Issues",
"home.card_issues_desc": "Browse and triage crash reports, tags, and fingerprints.",
"home.card_new_issue_desc": "Log a new issue or task in one step.",
"home.card_tickets_desc": "Roadmap tasks, QA items, and workflow tags.",
"home.card_graphs": "Graphs",
"home.card_graphs_desc": "Sessions, crashes, and device activity over time.",
"home.more": "All services",
"reports.title": "Issues", "reports.title": "Issues",
"reports.by_time": "By time", "reports.by_time": "By time",
"reports.grouped": "Grouped", "reports.grouped": "Grouped",
@@ -146,6 +153,7 @@
"nav.security": "Security", "nav.security": "Security",
"footer.copyright": "© Anton Afanaasyeu, {year}", "footer.copyright": "© Anton Afanaasyeu, {year}",
"tickets.title": "Tickets", "tickets.title": "Tickets",
"issues.new": "New issue",
"tickets.new": "New ticket", "tickets.new": "New ticket",
"ticket.create": "Create", "ticket.create": "Create",
"ticket.cancel": "Cancel", "ticket.cancel": "Cancel",

View File

@@ -4,7 +4,8 @@
"nav.reports": "Задачи (Issues)", "nav.reports": "Задачи (Issues)",
"nav.tickets": "Задачи", "nav.tickets": "Задачи",
"tickets.title": "Задачи", "tickets.title": "Задачи",
"tickets.new": "Новая задача", "issues.new": "Новая проблема",
"tickets.new": "Новый тикет",
"ticket.create": "Создать", "ticket.create": "Создать",
"ticket.cancel": "Отмена", "ticket.cancel": "Отмена",
"ticket.title": "Заголовок", "ticket.title": "Заголовок",
@@ -20,8 +21,15 @@
"lang.label": "Язык", "lang.label": "Язык",
"lang.en": "English", "lang.en": "English",
"lang.ru": "Русский", "lang.ru": "Русский",
"home.title": "Консоль сбоев", "home.title": "Консоль",
"home.intro": "Операторская консоль для триажа сбоев и тикетов — не публичный API загрузки. Откройте Issues для просмотра и группировки отпечатков или ссылки ниже.", "home.intro": "Сбои, задачи и метрики сессий — выберите раздел ниже.",
"home.card_issues": "Проблемы",
"home.card_issues_desc": "Просмотр и разбор отчётов о сбоях, тегов и отпечатков.",
"home.card_new_issue_desc": "Создать новую проблему или задачу.",
"home.card_tickets_desc": "Задачи, QA и теги workflow.",
"home.card_graphs": "Графики",
"home.card_graphs_desc": "Сессии, сбои и активность устройств.",
"home.more": "Все сервисы",
"reports.title": "Issues", "reports.title": "Issues",
"reports.by_time": "По времени", "reports.by_time": "По времени",
"reports.grouped": "Группы", "reports.grouped": "Группы",

View File

@@ -1284,6 +1284,20 @@
return basePath() + (entityType === 'ticket' ? '/api/ticket_tags.php' : '/api/report_tags.php'); return basePath() + (entityType === 'ticket' ? '/api/ticket_tags.php' : '/api/report_tags.php');
} }
const TICKET_META_IDS = ['ticket', 'issue'];
function isTicketMetaId(id) {
return TICKET_META_IDS.indexOf(id) >= 0;
}
function stripTicketMetaTags(tags) {
return (tags || []).filter((t) => !isTicketMetaId(t.id));
}
function tagsJson(tags) {
return JSON.stringify(tags || []);
}
function createTagEditor(cfg) { function createTagEditor(cfg) {
const entityType = cfg.entityType === 'ticket' ? 'ticket' : 'report'; const entityType = cfg.entityType === 'ticket' ? 'ticket' : 'report';
const state = { const state = {
@@ -1357,6 +1371,9 @@
state.workflow.find((x) => x.id === id) || state.workflow.find((x) => x.id === id) ||
state.presets.find((x) => x.id === id); state.presets.find((x) => x.id === id);
if (!p || state.tags.some((t) => t.id === p.id)) return; if (!p || state.tags.some((t) => t.id === p.id)) return;
if (entityType === 'ticket' && isTicketMetaId(p.id)) {
state.tags = stripTicketMetaTags(state.tags);
}
state.tags.push({ ...p }); state.tags.push({ ...p });
renderAll(); renderAll();
}); });
@@ -1436,6 +1453,7 @@
if (els.saveBtn) { if (els.saveBtn) {
els.saveBtn.addEventListener('click', () => { els.saveBtn.addEventListener('click', () => {
setStatus(t('tag.saving'), true); setStatus(t('tag.saving'), true);
const sentTags = state.tags.map((t) => ({ ...t }));
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
xhr.open('POST', tagsApiUrl(state.entityType), true); xhr.open('POST', tagsApiUrl(state.entityType), true);
xhr.setRequestHeader('Content-Type', 'application/json'); xhr.setRequestHeader('Content-Type', 'application/json');
@@ -1453,7 +1471,8 @@
} }
state.tags = data.tags || []; state.tags = data.tags || [];
renderAll(); renderAll();
setStatus('Saved', true); const adjusted = tagsJson(sentTags) !== tagsJson(state.tags);
setStatus(adjusted ? 'Saved (type/status tags normalized)' : 'Saved', true);
if (cfg.onSaved) cfg.onSaved(state.tags, state.entityId); if (cfg.onSaved) cfg.onSaved(state.tags, state.entityId);
}; };
xhr.onerror = function () { xhr.onerror = function () {

View File

@@ -1,16 +1,34 @@
/** /**
* Global "New issue" dialog — one click from nav or any toolbar. * Create ticket/issue dialog — Issues toolbar uses "issue" type tag; Tickets uses "ticket".
*/ */
(function () { (function () {
const META = {
ticket: { id: 'ticket', label: 'ticket', bg: '#6366f1' },
issue: { id: 'issue', label: 'issue', bg: '#0d9488' },
};
function basePath() { function basePath() {
return document.body.getAttribute('data-base-path') || ''; return document.body.getAttribute('data-base-path') || '';
} }
function openDialog() { function t(key) {
if (window.CrashI18n && window.CrashI18n.t) {
return window.CrashI18n.t(key);
}
return key;
}
function openDialog(kind) {
const dialog = document.getElementById('ticket-create-dialog'); const dialog = document.getElementById('ticket-create-dialog');
const form = document.getElementById('ticket-create-form'); const form = document.getElementById('ticket-create-form');
const status = document.getElementById('ticket-create-status'); const status = document.getElementById('ticket-create-status');
const heading = document.getElementById('ticket-create-heading');
if (!dialog || !form) return; if (!dialog || !form) return;
const createKind = kind === 'issue' ? 'issue' : 'ticket';
form.dataset.createKind = createKind;
if (heading) {
heading.textContent = t(createKind === 'issue' ? 'issues.new' : 'tickets.new');
}
if (status) status.textContent = ''; if (status) status.textContent = '';
form.reset(); form.reset();
if (typeof dialog.showModal === 'function') { if (typeof dialog.showModal === 'function') {
@@ -26,10 +44,16 @@
const status = document.getElementById('ticket-create-status'); const status = document.getElementById('ticket-create-status');
if (!dialog || !form) return; if (!dialog || !form) return;
document.querySelectorAll('.js-new-issue-btn').forEach((btn) => {
btn.addEventListener('click', (ev) => {
ev.preventDefault();
openDialog('issue');
});
});
document.querySelectorAll('.js-new-ticket-btn').forEach((btn) => { document.querySelectorAll('.js-new-ticket-btn').forEach((btn) => {
btn.addEventListener('click', (ev) => { btn.addEventListener('click', (ev) => {
ev.preventDefault(); ev.preventDefault();
openDialog(); openDialog('ticket');
}); });
}); });
@@ -47,10 +71,16 @@
const titleEl = document.getElementById('ticket-create-title'); const titleEl = document.getElementById('ticket-create-title');
const briefEl = document.getElementById('ticket-create-brief'); const briefEl = document.getElementById('ticket-create-brief');
const bodyEl = document.getElementById('ticket-create-body'); const bodyEl = document.getElementById('ticket-create-body');
const kind = form.dataset.createKind === 'issue' ? 'issue' : 'ticket';
const meta = META[kind] || META.ticket;
const payload = { const payload = {
title: titleEl && titleEl.value ? titleEl.value.trim() : '', title: titleEl && titleEl.value ? titleEl.value.trim() : '',
brief: briefEl && briefEl.value ? briefEl.value.trim() : '', brief: briefEl && briefEl.value ? briefEl.value.trim() : '',
body: bodyEl && bodyEl.value ? bodyEl.value.trim() : '', body: bodyEl && bodyEl.value ? bodyEl.value.trim() : '',
tags: [
meta,
{ id: 'open', label: 'open', bg: '#22c55e' },
],
}; };
if (!payload.title) return; if (!payload.title) return;
if (status) status.textContent = 'Creating…'; if (status) status.textContent = 'Creating…';

View File

@@ -1,10 +1,19 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
/** Workflow tags for tickets (must include meta tag {@see META_ID}). */ /** Workflow tags for tickets (must include exactly one meta tag: ticket or issue). */
final class TicketTagCatalog { final class TicketTagCatalog {
public const META_ID = 'ticket'; public const META_ID = 'ticket';
/** @var list<string> */
public const META_IDS = ['ticket', 'issue'];
/** @var array<string, array{label:string,bg:string}> */
private const META = [
'ticket' => ['label' => 'ticket', 'bg' => '#6366f1'],
'issue' => ['label' => 'issue', 'bg' => '#0d9488'],
];
/** @var array<string, array{label:string,bg:string}> */ /** @var array<string, array{label:string,bg:string}> */
private const STATUS = [ private const STATUS = [
'open' => ['label' => 'open', 'bg' => '#22c55e'], 'open' => ['label' => 'open', 'bg' => '#22c55e'],
@@ -25,54 +34,72 @@ final class TicketTagCatalog {
/** @return list<array{id:string,label:string,bg:string}> */ /** @return list<array{id:string,label:string,bg:string}> */
public static function presets(): array { public static function presets(): array {
$out = [ $out = [];
['id' => self::META_ID, 'label' => 'ticket', 'bg' => '#6366f1'], foreach (self::META as $id => $def) {
]; $out[] = ['id' => $id, 'label' => $def['label'], 'bg' => $def['bg']];
}
foreach (self::STATUS as $id => $def) { foreach (self::STATUS as $id => $def) {
$out[] = ['id' => $id, 'label' => $def['label'], 'bg' => $def['bg']]; $out[] = ['id' => $id, 'label' => $def['label'], 'bg' => $def['bg']];
} }
return $out; return $out;
} }
public static function isMetaId(string $id): bool {
return isset(self::META[$id]);
}
/** /**
* @param list<mixed> $tags * @param list<mixed> $tags
* @return list<array{id:string,label:string,bg:string}> * @return list<array{id:string,label:string,bg:string}>
*/ */
public static function normalize(array $tags): array { public static function normalize(array $tags): array {
$normalized = normalize_report_tags($tags); $normalized = normalize_report_tags($tags);
$hasMeta = false; $metaId = null;
$hasStatus = false; $hasStatus = false;
$rest = [];
foreach ($normalized as $t) { foreach ($normalized as $t) {
if ($t['id'] === self::META_ID) { if (self::isMetaId($t['id'])) {
$hasMeta = true; $metaId = $t['id'];
continue;
} }
if (isset(self::STATUS[$t['id']])) { if (isset(self::STATUS[$t['id']])) {
$hasStatus = true; $hasStatus = true;
} }
$rest[] = $t;
} }
if (!$hasMeta) { if ($metaId === null) {
$normalized[] = ['id' => self::META_ID, 'label' => 'ticket', 'bg' => '#6366f1']; $metaId = self::META_ID;
}
$meta = self::META[$metaId];
$out = [
['id' => $metaId, 'label' => $meta['label'], 'bg' => $meta['bg']],
];
foreach ($rest as $t) {
$out[] = $t;
} }
if (!$hasStatus) { if (!$hasStatus) {
$normalized[] = ['id' => 'open', 'label' => 'open', 'bg' => '#22c55e']; $out[] = ['id' => 'open', 'label' => 'open', 'bg' => '#22c55e'];
} }
return $normalized; return $out;
} }
public static function validate(array $tags): ?string { public static function validate(array $tags): ?string {
$normalized = self::normalize($tags); $normalized = normalize_report_tags($tags);
$hasMeta = false; $metaCount = 0;
$hasStatus = false; $hasStatus = false;
foreach ($normalized as $t) { foreach ($normalized as $t) {
if ($t['id'] === self::META_ID) { if (self::isMetaId($t['id'])) {
$hasMeta = true; $metaCount++;
} }
if (isset(self::STATUS[$t['id']])) { if (isset(self::STATUS[$t['id']])) {
$hasStatus = true; $hasStatus = true;
} }
} }
if (!$hasMeta) { if ($metaCount === 0) {
return 'tags must include ticket meta tag'; return 'tags must include a type tag (ticket or issue)';
}
if ($metaCount > 1) {
return 'only one type tag allowed: ticket or issue';
} }
if (!$hasStatus) { if (!$hasStatus) {
return 'tags must include a status tag (open, in-progress, closed, …)'; return 'tags must include a status tag (open, in-progress, closed, …)';

View File

@@ -62,7 +62,7 @@ final class TicketWorkflow {
$keep = []; $keep = [];
$statusIds = array_merge(TicketTagCatalog::statusIds(), TicketWorkflow::all()); $statusIds = array_merge(TicketTagCatalog::statusIds(), TicketWorkflow::all());
foreach (normalize_report_tags($tags) as $t) { foreach (normalize_report_tags($tags) as $t) {
if ($t['id'] === TicketTagCatalog::META_ID) { if (TicketTagCatalog::isMetaId($t['id'])) {
$keep[] = $t; $keep[] = $t;
continue; continue;
} }

View File

@@ -163,9 +163,7 @@
</nav> </nav>
<main class="main-pane"> <main class="main-pane">
<?php if (($view ?? 'home') === 'home'): ?> <?php if (($view ?? 'home') === 'home'): ?>
<h1 data-i18n="home.title">Issues console</h1> <?php require __DIR__ . '/partials/console_home_landing.php'; ?>
<p data-i18n="home.intro">Operator console for crash triage and tickets — not the public upload API. Open <strong>Issues</strong> to browse and group fingerprints, or use the shortcuts below.</p>
<?php $skip_home_link = true; require __DIR__ . '/partials/console_quick_links.php'; ?>
<?php elseif (($view ?? '') === 'report' && !empty($report)): ?> <?php elseif (($view ?? '') === 'report' && !empty($report)): ?>
<?php require __DIR__ . '/report_detail.php'; ?> <?php require __DIR__ . '/report_detail.php'; ?>
<?php elseif (($view ?? '') === 'ticket' && !empty($ticket)): ?> <?php elseif (($view ?? '') === 'ticket' && !empty($ticket)): ?>
@@ -229,7 +227,7 @@
<div class="toolbar reports-toolbar"> <div class="toolbar reports-toolbar">
<h1 data-i18n="reports.title">Issues</h1> <h1 data-i18n="reports.title">Issues</h1>
<div class="toolbar-actions"> <div class="toolbar-actions">
<button type="button" class="btn btn-primary js-new-ticket-btn" data-i18n="tickets.new">New ticket</button> <button type="button" class="btn btn-primary js-new-issue-btn" data-i18n="issues.new">New issue</button>
<button type="button" class="btn reports-mode-btn active" data-grouped="0" data-i18n="reports.by_time">By time</button> <button type="button" class="btn reports-mode-btn active" data-grouped="0" data-i18n="reports.by_time">By time</button>
<button type="button" class="btn reports-mode-btn" data-grouped="1" data-i18n="reports.grouped">Grouped</button> <button type="button" class="btn reports-mode-btn" data-grouped="1" data-i18n="reports.grouped">Grouped</button>
<label class="toolbar-select"> <label class="toolbar-select">
@@ -537,7 +535,7 @@
<dialog id="ticket-create-dialog" class="ticket-create-dialog"> <dialog id="ticket-create-dialog" class="ticket-create-dialog">
<form method="dialog" id="ticket-create-form" class="ticket-create-form"> <form method="dialog" id="ticket-create-form" class="ticket-create-form">
<header class="ticket-create-header"> <header class="ticket-create-header">
<h2 data-i18n="tickets.new">New ticket</h2> <h2 id="ticket-create-heading" data-i18n="tickets.new">New ticket</h2>
<button type="button" class="ticket-create-close" id="ticket-create-close" data-i18n-aria="tag.close" aria-label="Close">×</button> <button type="button" class="ticket-create-close" id="ticket-create-close" data-i18n-aria="tag.close" aria-label="Close">×</button>
</header> </header>
<div class="ticket-create-body"> <div class="ticket-create-body">

View File

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

View File

@@ -51,7 +51,7 @@ for ($i = 1; $i <= 5; $i++) {
<section class="tag-editor" id="tag-editor" data-entity-type="ticket" data-ticket-id="<?= $ticketDbId ?>"> <section class="tag-editor" id="tag-editor" data-entity-type="ticket" data-ticket-id="<?= $ticketDbId ?>">
<div class="tag-editor-head"> <div class="tag-editor-head">
<h2 data-i18n="detail.tags">Tags</h2> <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> <p class="muted tag-editor-hint" data-i18n="ticket.tag_hint">Include one type tag (<strong>ticket</strong> or <strong>issue</strong>) and a status tag (open, in progress, closed, …). Swap type via suggestions — remove the current type tag, then add the other.</p>
</div> </div>
<div class="tag-editor-preview" id="tag-editor-preview" aria-live="polite"></div> <div class="tag-editor-preview" id="tag-editor-preview" aria-live="polite"></div>
<?php if ($canEdit): ?> <?php if ($canEdit): ?>