diff --git a/examples/app_hub/assets/i18n/hub-en.json b/examples/app_hub/assets/i18n/hub-en.json
index 895dddf..2464a9a 100644
--- a/examples/app_hub/assets/i18n/hub-en.json
+++ b/examples/app_hub/assets/i18n/hub-en.json
@@ -15,6 +15,9 @@
"nav.remote": "Remote access",
"nav.builder": "Builder",
"nav.toggle": "Navigation",
+ "nav.security": "Security",
+ "nav.logout": "Logout",
+ "nav.sign_in": "Sign in",
"lang.label": "Language",
"theme.label": "Theme",
"rail.label": "Page navigation",
diff --git a/examples/app_hub/assets/i18n/hub-ru.json b/examples/app_hub/assets/i18n/hub-ru.json
index 209e1d5..215bad6 100644
--- a/examples/app_hub/assets/i18n/hub-ru.json
+++ b/examples/app_hub/assets/i18n/hub-ru.json
@@ -15,6 +15,9 @@
"nav.remote": "Удалённый доступ",
"nav.builder": "Сборщик",
"nav.toggle": "Навигация",
+ "nav.security": "Безопасность",
+ "nav.logout": "Выход",
+ "nav.sign_in": "Вход",
"lang.label": "Язык",
"theme.label": "Тема",
"rail.label": "Навигация по страницам",
diff --git a/examples/app_hub/hub-landing.css b/examples/app_hub/hub-landing.css
index 52f80bf..c3dfb1f 100644
--- a/examples/app_hub/hub-landing.css
+++ b/examples/app_hub/hub-landing.css
@@ -158,12 +158,26 @@
/* (2) Left shell — console nav-pane */
.landing-left.nav-pane {
position: relative;
+ display: flex;
+ flex-direction: column;
flex: 0 0 var(--landing-left-w);
width: var(--landing-left-w);
height: 100%;
+ max-height: 100%;
+ overflow: hidden;
z-index: 3;
pointer-events: auto;
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 {
width: 200px;
diff --git a/examples/app_hub/index.php b/examples/app_hub/index.php
index 74362d4..fe4e52b 100644
--- a/examples/app_hub/index.php
+++ b/examples/app_hub/index.php
@@ -4,7 +4,22 @@ if (!is_file($__platformDir . '/footer.php')) {
$__platformDir = dirname(__DIR__) . '/platform';
}
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');
+}
?>
@@ -129,6 +144,36 @@ unset($__platformDir);
Remote access
Builder
+
diff --git a/examples/crash_reporter/backend/public/assets/css/app.css b/examples/crash_reporter/backend/public/assets/css/app.css
index d7cfcc9..e6a01cf 100644
--- a/examples/crash_reporter/backend/public/assets/css/app.css
+++ b/examples/crash_reporter/backend/public/assets/css/app.css
@@ -1576,6 +1576,51 @@ button.report-tag--filter:hover {
.tag-modal-close:hover { color: var(--text); background: var(--row-hover); }
.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,
.console-quick-links {
display: flex;
diff --git a/examples/crash_reporter/backend/public/assets/i18n/en.json b/examples/crash_reporter/backend/public/assets/i18n/en.json
index 4286852..cc17e99 100644
--- a/examples/crash_reporter/backend/public/assets/i18n/en.json
+++ b/examples/crash_reporter/backend/public/assets/i18n/en.json
@@ -11,8 +11,15 @@
"lang.label": "Language",
"lang.en": "English",
"lang.ru": "Russian",
- "home.title": "Issues 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.title": "Console",
+ "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.by_time": "By time",
"reports.grouped": "Grouped",
@@ -146,6 +153,7 @@
"nav.security": "Security",
"footer.copyright": "© Anton Afanaasyeu, {year}",
"tickets.title": "Tickets",
+ "issues.new": "New issue",
"tickets.new": "New ticket",
"ticket.create": "Create",
"ticket.cancel": "Cancel",
diff --git a/examples/crash_reporter/backend/public/assets/i18n/ru.json b/examples/crash_reporter/backend/public/assets/i18n/ru.json
index 7aa9f0a..db53e57 100644
--- a/examples/crash_reporter/backend/public/assets/i18n/ru.json
+++ b/examples/crash_reporter/backend/public/assets/i18n/ru.json
@@ -4,7 +4,8 @@
"nav.reports": "Задачи (Issues)",
"nav.tickets": "Задачи",
"tickets.title": "Задачи",
- "tickets.new": "Новая задача",
+ "issues.new": "Новая проблема",
+ "tickets.new": "Новый тикет",
"ticket.create": "Создать",
"ticket.cancel": "Отмена",
"ticket.title": "Заголовок",
@@ -20,8 +21,15 @@
"lang.label": "Язык",
"lang.en": "English",
"lang.ru": "Русский",
- "home.title": "Консоль сбоев",
- "home.intro": "Операторская консоль для триажа сбоев и тикетов — не публичный API загрузки. Откройте Issues для просмотра и группировки отпечатков или ссылки ниже.",
+ "home.title": "Консоль",
+ "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.by_time": "По времени",
"reports.grouped": "Группы",
diff --git a/examples/crash_reporter/backend/public/assets/js/app.js b/examples/crash_reporter/backend/public/assets/js/app.js
index c4a3864..8d606f8 100644
--- a/examples/crash_reporter/backend/public/assets/js/app.js
+++ b/examples/crash_reporter/backend/public/assets/js/app.js
@@ -1284,6 +1284,20 @@
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) {
const entityType = cfg.entityType === 'ticket' ? 'ticket' : 'report';
const state = {
@@ -1357,6 +1371,9 @@
state.workflow.find((x) => x.id === id) ||
state.presets.find((x) => x.id === id);
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 });
renderAll();
});
@@ -1436,6 +1453,7 @@
if (els.saveBtn) {
els.saveBtn.addEventListener('click', () => {
setStatus(t('tag.saving'), true);
+ const sentTags = state.tags.map((t) => ({ ...t }));
const xhr = new XMLHttpRequest();
xhr.open('POST', tagsApiUrl(state.entityType), true);
xhr.setRequestHeader('Content-Type', 'application/json');
@@ -1453,7 +1471,8 @@
}
state.tags = data.tags || [];
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);
};
xhr.onerror = function () {
diff --git a/examples/crash_reporter/backend/public/assets/js/ticket_create.js b/examples/crash_reporter/backend/public/assets/js/ticket_create.js
index ac6077f..3a8a792 100644
--- a/examples/crash_reporter/backend/public/assets/js/ticket_create.js
+++ b/examples/crash_reporter/backend/public/assets/js/ticket_create.js
@@ -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 () {
+ const META = {
+ ticket: { id: 'ticket', label: 'ticket', bg: '#6366f1' },
+ issue: { id: 'issue', label: 'issue', bg: '#0d9488' },
+ };
+
function basePath() {
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 form = document.getElementById('ticket-create-form');
const status = document.getElementById('ticket-create-status');
+ const heading = document.getElementById('ticket-create-heading');
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 = '';
form.reset();
if (typeof dialog.showModal === 'function') {
@@ -26,10 +44,16 @@
const status = document.getElementById('ticket-create-status');
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) => {
btn.addEventListener('click', (ev) => {
ev.preventDefault();
- openDialog();
+ openDialog('ticket');
});
});
@@ -47,10 +71,16 @@
const titleEl = document.getElementById('ticket-create-title');
const briefEl = document.getElementById('ticket-create-brief');
const bodyEl = document.getElementById('ticket-create-body');
+ const kind = form.dataset.createKind === 'issue' ? 'issue' : 'ticket';
+ const meta = META[kind] || META.ticket;
const payload = {
title: titleEl && titleEl.value ? titleEl.value.trim() : '',
brief: briefEl && briefEl.value ? briefEl.value.trim() : '',
body: bodyEl && bodyEl.value ? bodyEl.value.trim() : '',
+ tags: [
+ meta,
+ { id: 'open', label: 'open', bg: '#22c55e' },
+ ],
};
if (!payload.title) return;
if (status) status.textContent = 'Creating…';
diff --git a/examples/crash_reporter/backend/src/TicketTagCatalog.php b/examples/crash_reporter/backend/src/TicketTagCatalog.php
index a71d5db..1c9758e 100644
--- a/examples/crash_reporter/backend/src/TicketTagCatalog.php
+++ b/examples/crash_reporter/backend/src/TicketTagCatalog.php
@@ -1,10 +1,19 @@
*/
+ public const META_IDS = ['ticket', 'issue'];
+
+ /** @var array */
+ private const META = [
+ 'ticket' => ['label' => 'ticket', 'bg' => '#6366f1'],
+ 'issue' => ['label' => 'issue', 'bg' => '#0d9488'],
+ ];
+
/** @var array */
private const STATUS = [
'open' => ['label' => 'open', 'bg' => '#22c55e'],
@@ -25,54 +34,72 @@ final class TicketTagCatalog {
/** @return list */
public static function presets(): array {
- $out = [
- ['id' => self::META_ID, 'label' => 'ticket', 'bg' => '#6366f1'],
- ];
+ $out = [];
+ foreach (self::META as $id => $def) {
+ $out[] = ['id' => $id, 'label' => $def['label'], 'bg' => $def['bg']];
+ }
foreach (self::STATUS as $id => $def) {
$out[] = ['id' => $id, 'label' => $def['label'], 'bg' => $def['bg']];
}
return $out;
}
+ public static function isMetaId(string $id): bool {
+ return isset(self::META[$id]);
+ }
+
/**
* @param list $tags
* @return list
*/
public static function normalize(array $tags): array {
$normalized = normalize_report_tags($tags);
- $hasMeta = false;
+ $metaId = null;
$hasStatus = false;
+ $rest = [];
foreach ($normalized as $t) {
- if ($t['id'] === self::META_ID) {
- $hasMeta = true;
+ if (self::isMetaId($t['id'])) {
+ $metaId = $t['id'];
+ continue;
}
if (isset(self::STATUS[$t['id']])) {
$hasStatus = true;
}
+ $rest[] = $t;
}
- if (!$hasMeta) {
- $normalized[] = ['id' => self::META_ID, 'label' => 'ticket', 'bg' => '#6366f1'];
+ if ($metaId === null) {
+ $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) {
- $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 {
- $normalized = self::normalize($tags);
- $hasMeta = false;
+ $normalized = normalize_report_tags($tags);
+ $metaCount = 0;
$hasStatus = false;
foreach ($normalized as $t) {
- if ($t['id'] === self::META_ID) {
- $hasMeta = true;
+ if (self::isMetaId($t['id'])) {
+ $metaCount++;
}
if (isset(self::STATUS[$t['id']])) {
$hasStatus = true;
}
}
- if (!$hasMeta) {
- return 'tags must include ticket meta tag';
+ if ($metaCount === 0) {
+ return 'tags must include a type tag (ticket or issue)';
+ }
+ if ($metaCount > 1) {
+ return 'only one type tag allowed: ticket or issue';
}
if (!$hasStatus) {
return 'tags must include a status tag (open, in-progress, closed, …)';
diff --git a/examples/crash_reporter/backend/src/TicketWorkflow.php b/examples/crash_reporter/backend/src/TicketWorkflow.php
index 297e3d1..7954480 100644
--- a/examples/crash_reporter/backend/src/TicketWorkflow.php
+++ b/examples/crash_reporter/backend/src/TicketWorkflow.php
@@ -62,7 +62,7 @@ final class TicketWorkflow {
$keep = [];
$statusIds = array_merge(TicketTagCatalog::statusIds(), TicketWorkflow::all());
foreach (normalize_report_tags($tags) as $t) {
- if ($t['id'] === TicketTagCatalog::META_ID) {
+ if (TicketTagCatalog::isMetaId($t['id'])) {
$keep[] = $t;
continue;
}
diff --git a/examples/crash_reporter/backend/views/layout.php b/examples/crash_reporter/backend/views/layout.php
index ab9acd6..f675d98 100644
--- a/examples/crash_reporter/backend/views/layout.php
+++ b/examples/crash_reporter/backend/views/layout.php
@@ -163,9 +163,7 @@
- Issues console
- Operator console for crash triage and tickets — not the public upload API. Open Issues to browse and group fingerprints, or use the shortcuts below.
-
+
@@ -229,7 +227,7 @@