mirror of
git://f0xx.org/android_cast
synced 2026-07-29 09:17:51 +03:00
sync
This commit is contained in:
@@ -41,6 +41,32 @@ php -S 127.0.0.1:8080 -t public
|
||||
|
||||
Open http://127.0.0.1:8080/ — login **admin** / **admin**.
|
||||
|
||||
## RBAC (phase 1)
|
||||
|
||||
Multi-tenant foundation (no admin UI yet):
|
||||
|
||||
- **Global roles** on `users.role`: `root`, `platform_admin` (legacy `admin` works), `viewer`
|
||||
- **Companies** + **company_memberships** (`owner` / `admin` / `member`)
|
||||
- **Devices** auto-registered on upload; reports get `company_id` + `device_id`
|
||||
- Default company slug `default` — existing rows migrate on first request (`Database::ensureRbacSchema()`)
|
||||
|
||||
Config (`config.php`):
|
||||
|
||||
```php
|
||||
'rbac' => [
|
||||
'default_company_slug' => 'default',
|
||||
'default_company_id' => 1,
|
||||
],
|
||||
```
|
||||
|
||||
Permission checks: `Rbac::can('edit_tags', $companyId)` (used by `Auth::canEditTags()`).
|
||||
Manual SQL: `sql/migrations/001_rbac_companies.sql` (MariaDB). Re-login after deploy so session includes company memberships.
|
||||
|
||||
## UI languages
|
||||
|
||||
JSON catalogs under `public/assets/i18n/` (`en.json`, `ru.json`) — same key style as Android `strings.xml`.
|
||||
Default: English. User choice is stored in `localStorage` (`crash_console_lang`). Language selector is in the reports toolbar next to **Theme**, and in the nav on other pages (login included).
|
||||
|
||||
Upload test payload:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -32,4 +32,9 @@ return [
|
||||
'session_name' => 'ac_crash_sess',
|
||||
// When true, upload API may include a short error hint (do not enable on public prod long-term)
|
||||
'debug' => false,
|
||||
// Phase 1 RBAC: anonymous uploads and legacy rows attach to this company
|
||||
'rbac' => [
|
||||
'default_company_slug' => 'default',
|
||||
'default_company_id' => 1,
|
||||
],
|
||||
];
|
||||
|
||||
BIN
examples/crash_reporter/backend/data/test-rbac.sqlite
Normal file
BIN
examples/crash_reporter/backend/data/test-rbac.sqlite
Normal file
Binary file not shown.
@@ -22,14 +22,10 @@ if ($method === 'GET') {
|
||||
'id' => $id,
|
||||
'tags' => $report['tags'] ?? [],
|
||||
'presets' => ReportRepository::listTagPresets(),
|
||||
'can_edit' => Auth::canEditTags(),
|
||||
'can_edit' => Auth::canEditTags((int) ($report['company_id'] ?? 0)),
|
||||
]);
|
||||
}
|
||||
|
||||
if (!Auth::canEditTags()) {
|
||||
json_out(['ok' => false, 'error' => 'forbidden'], 403);
|
||||
}
|
||||
|
||||
if ($method !== 'PUT' && $method !== 'POST') {
|
||||
json_out(['ok' => false, 'error' => 'method not allowed'], 405);
|
||||
}
|
||||
@@ -54,6 +50,10 @@ if (!$report) {
|
||||
json_out(['ok' => false, 'error' => 'not found'], 404);
|
||||
}
|
||||
|
||||
if (!Auth::canEditTags((int) ($report['company_id'] ?? 0))) {
|
||||
json_out(['ok' => false, 'error' => 'forbidden'], 403);
|
||||
}
|
||||
|
||||
try {
|
||||
ReportRepository::setCustomTags($id, $tagsIn);
|
||||
$updated = ReportRepository::getById($id);
|
||||
|
||||
@@ -435,6 +435,20 @@ a:hover { text-decoration: underline; }
|
||||
.stack { background: var(--surface2); padding: 16px; border-radius: 8px; overflow-x: auto; font-size: 13px; line-height: 1.5; }
|
||||
.reports-toolbar .toolbar-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
||||
.toolbar-select { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: var(--muted); }
|
||||
.nav-locale {
|
||||
padding: 10px 12px 6px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.nav-locale--toolbar-dup { display: none; }
|
||||
.toolbar-select--nav { width: 100%; }
|
||||
.toolbar-select--nav select { flex: 1; min-width: 0; }
|
||||
.login-locale {
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
z-index: 5;
|
||||
}
|
||||
.login-page .login-card { margin-top: 2.5rem; }
|
||||
.toolbar-select select {
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
@@ -683,16 +697,32 @@ a:hover { text-decoration: underline; }
|
||||
.detail-filter-link--tag {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
margin: 0;
|
||||
border-radius: 999px;
|
||||
background: var(--surface2);
|
||||
border: 1px solid var(--border);
|
||||
font-size: 12px;
|
||||
border: none;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
color: #fff;
|
||||
background: var(--tag-bg, #5c6b82);
|
||||
}
|
||||
.detail-filter-link--tag:hover {
|
||||
border-color: var(--accent);
|
||||
.detail-filter-link--tag:hover,
|
||||
.detail-filter-link--tag:focus-visible {
|
||||
color: #fff;
|
||||
background: var(--tag-bg, #5c6b82);
|
||||
filter: brightness(1.12);
|
||||
outline: none;
|
||||
}
|
||||
.detail-abi-row,
|
||||
.detail-meta-row {
|
||||
line-height: 1.8;
|
||||
}
|
||||
.detail-meta-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.detail-abi-row { line-height: 1.8; }
|
||||
|
||||
.exc { color: var(--danger); font-weight: 600; }
|
||||
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 16px; margin: 20px 0; }
|
||||
|
||||
116
examples/crash_reporter/backend/public/assets/i18n/en.json
Normal file
116
examples/crash_reporter/backend/public/assets/i18n/en.json
Normal file
@@ -0,0 +1,116 @@
|
||||
{
|
||||
"app_name": "Android Cast Crashes",
|
||||
"nav.home": "Home",
|
||||
"nav.reports": "Reports",
|
||||
"nav.logout": "Logout",
|
||||
"nav.toggle": "Navigation",
|
||||
"theme.label": "Theme",
|
||||
"theme.dark": "Dark",
|
||||
"theme.light": "Light",
|
||||
"lang.label": "Language",
|
||||
"lang.en": "English",
|
||||
"lang.ru": "Russian",
|
||||
"home.title": "Crash console",
|
||||
"home.intro": "Anonymous Android Cast crash ingest is active. Use Reports to analyze grouped fingerprints.",
|
||||
"home.upload_api": "Upload API:",
|
||||
"home.schema": "Schema:",
|
||||
"reports.title": "Crash reports",
|
||||
"reports.by_time": "By time",
|
||||
"reports.grouped": "Grouped",
|
||||
"reports.per_page": "Per page",
|
||||
"reports.search": "Search",
|
||||
"reports.search_placeholder": "Keywords (exceptions, devices, stack traces…)",
|
||||
"reports.clear": "Clear",
|
||||
"reports.loading": "Loading…",
|
||||
"reports.empty": "No reports yet.",
|
||||
"reports.status_showing": "Showing {from}–{to} of {total}",
|
||||
"reports.invalid_response": "Invalid response",
|
||||
"reports.load_failed": "Load failed",
|
||||
"reports.network_error": "Network error",
|
||||
"reports.mode_disabled_filter": "Disabled while a filter is active",
|
||||
"filter.banner_similar": "Similar reports for #{id}",
|
||||
"filter.banner_device": "Device: “{value}”",
|
||||
"filter.banner_abi": "ABI: {value}",
|
||||
"filter.banner_sdk": "SDK {value}",
|
||||
"filter.banner_os": "Android {value}",
|
||||
"filter.banner_app": "App {value}",
|
||||
"filter.banner_build": "Build {value}",
|
||||
"filter.banner_search": "Search: “{value}”",
|
||||
"filter.clear": "Clear filters",
|
||||
"pagination.previous": "← Previous",
|
||||
"pagination.next": "Next →",
|
||||
"pagination.previous_range": "← Previous ({before}/{through})",
|
||||
"pagination.next_range": "Next ({start}/{end}) →",
|
||||
"col.device": "Device",
|
||||
"col.app": "App",
|
||||
"col.os": "OS",
|
||||
"col.os_ver": "OS ver",
|
||||
"col.received": "Received",
|
||||
"col.rating": "Rating",
|
||||
"col.count": "Count",
|
||||
"col.fingerprint": "Fingerprint",
|
||||
"col.type": "Type",
|
||||
"col.last_generated": "Last generated",
|
||||
"col.last_received": "Last received",
|
||||
"col.tags": "Tags",
|
||||
"col.expand": "Expand",
|
||||
"col.drag": "Drag column",
|
||||
"col.drag_reorder": "Drag column to reorder",
|
||||
"row.show_summary": "Show summary",
|
||||
"row.open_report": "Click to open full report",
|
||||
"row.edit_tags": "Edit tags",
|
||||
"tag.new": "new",
|
||||
"tag.java": "Java",
|
||||
"tag.ndk": "NDK",
|
||||
"tag.android": "Android",
|
||||
"tag.edit_title": "Edit tags",
|
||||
"tag.close": "Close",
|
||||
"tag.label": "Label",
|
||||
"tag.color": "Color",
|
||||
"tag.add": "Add",
|
||||
"tag.add_tag": "Add tag",
|
||||
"tag.save": "Save",
|
||||
"tag.save_tags": "Save tags",
|
||||
"tag.suggestions": "Suggestions:",
|
||||
"tag.placeholder": "e.g. regression",
|
||||
"tag.hint_auto": "Auto tags (NDK / Java / Android / new) appear in the list. Add custom labels below.",
|
||||
"tag.hint_admin": "Sign in as admin to edit custom tags.",
|
||||
"tag.none_custom": "No custom tags yet.",
|
||||
"tag.none_preview": "No custom tags",
|
||||
"tag.remove": "Remove tag",
|
||||
"tag.saving": "Saving…",
|
||||
"tag.saved": "Saved",
|
||||
"tag.save_failed": "Save failed",
|
||||
"detail.back": "Back to list",
|
||||
"detail.title": "Crash report",
|
||||
"detail.report_meta": "Report {id} · {type}",
|
||||
"detail.tags": "Tags",
|
||||
"detail.device": "Device",
|
||||
"detail.app": "App",
|
||||
"detail.timing": "Timing",
|
||||
"detail.abis": "ABIs:",
|
||||
"detail.build": "Build:",
|
||||
"detail.generated": "Generated:",
|
||||
"detail.received": "Received:",
|
||||
"detail.fingerprint": "Fingerprint:",
|
||||
"detail.filter_device": "Show reports for similar devices",
|
||||
"detail.filter_os": "Show reports on this OS",
|
||||
"detail.filter_version": "Show reports for this app version",
|
||||
"detail.filter_build": "Show reports from this build",
|
||||
"detail.java_exception": "Java exception",
|
||||
"detail.native_crash": "Native crash",
|
||||
"detail.find_similar": "Find similar reports",
|
||||
"detail.thread": "Thread:",
|
||||
"detail.signal": "Signal:",
|
||||
"detail.session_context": "Session context (crash)",
|
||||
"detail.raw_json": "Raw JSON",
|
||||
"login.sign_in_hint": "Sign in to view anonymous crash reports",
|
||||
"login.username": "Username",
|
||||
"login.password": "Password",
|
||||
"login.submit": "Sign in",
|
||||
"login.hint_default": "Default: admin / admin",
|
||||
"login.register": "Register",
|
||||
"login.register_soon": "(coming soon)",
|
||||
"login.error": "Invalid credentials",
|
||||
"footer.copyright": "(c) Anton Afanaasyeu, {year}"
|
||||
}
|
||||
115
examples/crash_reporter/backend/public/assets/i18n/ru.json
Normal file
115
examples/crash_reporter/backend/public/assets/i18n/ru.json
Normal file
@@ -0,0 +1,115 @@
|
||||
{
|
||||
"app_name": "Сбои Android Cast",
|
||||
"nav.home": "Главная",
|
||||
"nav.reports": "Отчёты",
|
||||
"nav.logout": "Выход",
|
||||
"nav.toggle": "Навигация",
|
||||
"theme.label": "Тема",
|
||||
"theme.dark": "Тёмная",
|
||||
"theme.light": "Светлая",
|
||||
"lang.label": "Язык",
|
||||
"lang.en": "English",
|
||||
"lang.ru": "Русский",
|
||||
"home.title": "Консоль сбоев",
|
||||
"home.intro": "Приём анонимных отчётов Android Cast активен. Откройте «Отчёты» для анализа сгруппированных отпечатков.",
|
||||
"home.upload_api": "API загрузки:",
|
||||
"home.schema": "Схема:",
|
||||
"reports.title": "Отчёты о сбоях",
|
||||
"reports.by_time": "По времени",
|
||||
"reports.grouped": "Группы",
|
||||
"reports.per_page": "На странице",
|
||||
"reports.search": "Поиск",
|
||||
"reports.search_placeholder": "Ключевые слова (исключения, устройства, стеки…)",
|
||||
"reports.clear": "Очистить",
|
||||
"reports.loading": "Загрузка…",
|
||||
"reports.empty": "Отчётов пока нет.",
|
||||
"reports.status_showing": "Показано {from}–{to} из {total}",
|
||||
"reports.invalid_response": "Некорректный ответ",
|
||||
"reports.load_failed": "Ошибка загрузки",
|
||||
"reports.network_error": "Сетевая ошибка",
|
||||
"reports.mode_disabled_filter": "Недоступно при активном фильтре",
|
||||
"filter.banner_similar": "Похожие отчёты для №{id}",
|
||||
"filter.banner_device": "Устройство: «{value}»",
|
||||
"filter.banner_abi": "ABI: {value}",
|
||||
"filter.banner_sdk": "SDK {value}",
|
||||
"filter.banner_os": "Android {value}",
|
||||
"filter.banner_app": "Приложение {value}",
|
||||
"filter.banner_build": "Сборка {value}",
|
||||
"filter.banner_search": "Поиск: «{value}»",
|
||||
"filter.clear": "Сбросить фильтры",
|
||||
"pagination.previous": "← Назад",
|
||||
"pagination.next": "Вперёд →",
|
||||
"pagination.previous_range": "← Назад ({before}/{through})",
|
||||
"pagination.next_range": "Вперёд ({start}/{end}) →",
|
||||
"col.device": "Устройство",
|
||||
"col.app": "Приложение",
|
||||
"col.os": "ОС",
|
||||
"col.os_ver": "Версия ОС",
|
||||
"col.received": "Получен",
|
||||
"col.rating": "Рейтинг",
|
||||
"col.count": "Кол-во",
|
||||
"col.fingerprint": "Отпечаток",
|
||||
"col.type": "Тип",
|
||||
"col.last_generated": "Создан",
|
||||
"col.last_received": "Получен",
|
||||
"col.tags": "Метки",
|
||||
"col.expand": "Развернуть",
|
||||
"col.drag": "Перетащить столбец",
|
||||
"col.drag_reorder": "Перетащите для смены порядка столбцов",
|
||||
"row.show_summary": "Показать сводку",
|
||||
"row.open_report": "Нажмите, чтобы открыть полный отчёт",
|
||||
"row.edit_tags": "Редактировать метки",
|
||||
"tag.new": "новый",
|
||||
"tag.java": "Java",
|
||||
"tag.ndk": "NDK",
|
||||
"tag.android": "Android",
|
||||
"tag.edit_title": "Редактирование меток",
|
||||
"tag.close": "Закрыть",
|
||||
"tag.label": "Название",
|
||||
"tag.color": "Цвет",
|
||||
"tag.add": "Добавить",
|
||||
"tag.add_tag": "Добавить метку",
|
||||
"tag.save": "Сохранить",
|
||||
"tag.save_tags": "Сохранить метки",
|
||||
"tag.suggestions": "Подсказки:",
|
||||
"tag.hint_auto": "Автометки (NDK / Java / Android / новый) в списке. Ниже можно добавить свои.",
|
||||
"tag.hint_admin": "Войдите как администратор, чтобы редактировать метки.",
|
||||
"tag.none_custom": "Пользовательских меток пока нет.",
|
||||
"tag.none_preview": "Нет пользовательских меток",
|
||||
"tag.remove": "Удалить метку",
|
||||
"tag.saving": "Сохранение…",
|
||||
"tag.saved": "Сохранено",
|
||||
"tag.save_failed": "Не удалось сохранить",
|
||||
"detail.back": "К списку",
|
||||
"detail.title": "Отчёт о сбое",
|
||||
"detail.report_meta": "Отчёт {id} · {type}",
|
||||
"detail.tags": "Метки",
|
||||
"detail.device": "Устройство",
|
||||
"detail.app": "Приложение",
|
||||
"detail.timing": "Время",
|
||||
"detail.abis": "ABI:",
|
||||
"detail.build": "Сборка:",
|
||||
"detail.generated": "Создан:",
|
||||
"detail.received": "Получен:",
|
||||
"detail.fingerprint": "Отпечаток:",
|
||||
"detail.filter_device": "Показать отчёты для похожих устройств",
|
||||
"detail.filter_os": "Показать отчёты с этой ОС",
|
||||
"detail.filter_version": "Показать отчёты для этой версии приложения",
|
||||
"detail.filter_build": "Показать отчёты этой сборки",
|
||||
"detail.java_exception": "Исключение Java",
|
||||
"detail.native_crash": "Нативный сбой",
|
||||
"detail.find_similar": "Найти похожие отчёты",
|
||||
"detail.thread": "Поток:",
|
||||
"detail.signal": "Сигнал:",
|
||||
"detail.session_context": "Контекст сессии (сбой)",
|
||||
"detail.raw_json": "Исходный JSON",
|
||||
"login.sign_in_hint": "Войдите для просмотра анонимных отчётов о сбоях",
|
||||
"login.username": "Имя пользователя",
|
||||
"login.password": "Пароль",
|
||||
"login.submit": "Войти",
|
||||
"login.hint_default": "По умолчанию: admin / admin",
|
||||
"login.register": "Регистрация",
|
||||
"login.register_soon": "(скоро)",
|
||||
"login.error": "Неверные учётные данные",
|
||||
"footer.copyright": "(c) Anton Afanaasyeu, {year}"
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
(function () {
|
||||
function t(key, params) {
|
||||
return window.CrashI18n ? window.CrashI18n.t(key, params) : key;
|
||||
}
|
||||
|
||||
const STORAGE = {
|
||||
theme: 'crash_console_theme',
|
||||
perPage: 'crash_console_per_page',
|
||||
@@ -21,20 +25,17 @@
|
||||
tags: 128,
|
||||
};
|
||||
|
||||
const TAG_NEW = { label: 'new', bg: '#3d8bfd' };
|
||||
const CRASH_KIND_TAGS = {
|
||||
native: { label: 'NDK', bg: '#b45309' },
|
||||
java: { label: 'Java', bg: '#6d28d9' },
|
||||
android: { label: 'Android', bg: '#047857' },
|
||||
};
|
||||
function tagNew() {
|
||||
return { label: t('tag.new'), bg: '#3d8bfd' };
|
||||
}
|
||||
|
||||
const LIST_COLUMNS = [
|
||||
{ key: 'device_model', label: 'Device' },
|
||||
{ key: 'app_version', label: 'App' },
|
||||
{ key: 'os_name', label: 'OS' },
|
||||
{ key: 'os_version', label: 'OS ver' },
|
||||
{ key: 'received_at_ms', label: 'Received', fmt: 'time' },
|
||||
{ key: 'rating', label: 'Rating', fmt: 'stars' },
|
||||
{ key: 'device_model', labelKey: 'col.device' },
|
||||
{ key: 'app_version', labelKey: 'col.app' },
|
||||
{ key: 'os_name', labelKey: 'col.os' },
|
||||
{ key: 'os_version', labelKey: 'col.os_ver' },
|
||||
{ key: 'received_at_ms', labelKey: 'col.received', fmt: 'time' },
|
||||
{ key: 'rating', labelKey: 'col.rating', fmt: 'stars' },
|
||||
];
|
||||
|
||||
const SORT_API_MAP = {
|
||||
@@ -45,14 +46,19 @@
|
||||
};
|
||||
|
||||
const GROUP_COLUMNS = [
|
||||
{ key: 'cnt', label: 'Count' },
|
||||
{ key: 'rating', label: 'Rating', fmt: 'stars' },
|
||||
{ key: 'fingerprint', label: 'Fingerprint', fmt: 'fp' },
|
||||
{ key: 'crash_type', label: 'Type' },
|
||||
{ key: 'last_generated_ms', label: 'Last generated', fmt: 'time' },
|
||||
{ key: 'last_received_ms', label: 'Last received', fmt: 'time' },
|
||||
{ key: 'cnt', labelKey: 'col.count' },
|
||||
{ key: 'rating', labelKey: 'col.rating', fmt: 'stars' },
|
||||
{ key: 'fingerprint', labelKey: 'col.fingerprint', fmt: 'fp' },
|
||||
{ key: 'crash_type', labelKey: 'col.type' },
|
||||
{ key: 'last_generated_ms', labelKey: 'col.last_generated', fmt: 'time' },
|
||||
{ key: 'last_received_ms', labelKey: 'col.last_received', fmt: 'time' },
|
||||
];
|
||||
|
||||
function colLabel(col) {
|
||||
if (!col) return '';
|
||||
return t(col.labelKey || 'col.' + col.key);
|
||||
}
|
||||
|
||||
function basePath() {
|
||||
return document.body.getAttribute('data-base-path') || '';
|
||||
}
|
||||
@@ -207,8 +213,15 @@
|
||||
}
|
||||
|
||||
function crashKindTag(crashType) {
|
||||
const t = String(crashType || '').toLowerCase();
|
||||
return CRASH_KIND_TAGS[t] || { label: t || '?', bg: '#5c6b82' };
|
||||
const kind = String(crashType || '').toLowerCase();
|
||||
const map = {
|
||||
native: { labelKey: 'tag.ndk', bg: '#b45309' },
|
||||
java: { labelKey: 'tag.java', bg: '#6d28d9' },
|
||||
android: { labelKey: 'tag.android', bg: '#047857' },
|
||||
};
|
||||
const hit = map[kind];
|
||||
if (hit) return { label: t(hit.labelKey), bg: hit.bg };
|
||||
return { label: kind || '?', bg: '#5c6b82' };
|
||||
}
|
||||
|
||||
function renderTagPills(tags) {
|
||||
@@ -233,7 +246,7 @@
|
||||
const viewed = row.viewed || getViewedIds().has(Number(row.id));
|
||||
const sinceVisit = Number(row.received_at_ms) > lastVisitMs();
|
||||
if (!viewed && (lastVisitMs() === 0 || sinceVisit)) {
|
||||
tags.push(TAG_NEW);
|
||||
tags.push(tagNew());
|
||||
}
|
||||
}
|
||||
if (Array.isArray(row.tags)) {
|
||||
@@ -333,7 +346,11 @@
|
||||
|
||||
function columnLabelByKey(key) {
|
||||
const c = LIST_COLUMNS.find((col) => col.key === key);
|
||||
return c ? c.label : key;
|
||||
if (c) return colLabel(c);
|
||||
const g = GROUP_COLUMNS.find((col) => col.key === key);
|
||||
if (g) return colLabel(g);
|
||||
if (key === 'tags') return t('col.tags');
|
||||
return key;
|
||||
}
|
||||
|
||||
function clearColumnDropMarkers(thead) {
|
||||
@@ -708,7 +725,9 @@
|
||||
thDragAttrs =
|
||||
' draggable="true" data-col-key="' +
|
||||
escapeHtml(c.key) +
|
||||
'" title="Drag column to reorder"';
|
||||
'" title="' +
|
||||
escapeHtml(t('col.drag_reorder')) +
|
||||
'"';
|
||||
}
|
||||
html +=
|
||||
'<th class="' +
|
||||
@@ -722,9 +741,11 @@
|
||||
' scope="col"><span class="th-inner">' +
|
||||
(grouped
|
||||
? ''
|
||||
: '<span class="th-drag" aria-hidden="true" title="Drag column">⋮⋮</span>') +
|
||||
: '<span class="th-drag" aria-hidden="true" title="' +
|
||||
escapeHtml(t('col.drag')) +
|
||||
'">⋮⋮</span>') +
|
||||
'<span class="th-label">' +
|
||||
escapeHtml(c.label) +
|
||||
escapeHtml(colLabel(c)) +
|
||||
'</span> <span class="sort-ind" aria-hidden="true">' +
|
||||
arrow +
|
||||
'</span></span>' +
|
||||
@@ -820,7 +841,9 @@
|
||||
tbody.innerHTML =
|
||||
'<tr><td colspan="' +
|
||||
colspan() +
|
||||
'" class="muted">No reports yet.</td></tr>';
|
||||
'" class="muted">' +
|
||||
escapeHtml(t('reports.empty')) +
|
||||
'</td></tr>';
|
||||
return;
|
||||
}
|
||||
let html = '';
|
||||
@@ -838,7 +861,9 @@
|
||||
html +=
|
||||
'<td class="report-tree-cell"><button type="button" class="report-tree-toggle" aria-expanded="false" aria-controls="' +
|
||||
briefId +
|
||||
'" title="Show summary"><span class="report-tree-arrow" aria-hidden="true"></span></button></td>';
|
||||
'" title="' +
|
||||
escapeHtml(t('row.show_summary')) +
|
||||
'"><span class="report-tree-arrow" aria-hidden="true"></span></button></td>';
|
||||
cols.forEach((c) => {
|
||||
if (c.key === 'cnt') {
|
||||
html += '<td><span class="badge">' + escapeHtml(row.cnt) + '</span></td>';
|
||||
@@ -890,7 +915,7 @@
|
||||
function load() {
|
||||
if (loading) return;
|
||||
loading = true;
|
||||
statusEl.textContent = 'Loading…';
|
||||
statusEl.textContent = t('reports.loading');
|
||||
let qs =
|
||||
'page=' +
|
||||
page +
|
||||
@@ -1129,7 +1154,7 @@
|
||||
html +=
|
||||
'<button type="button" class="tag-remove" data-remove-id="' +
|
||||
escapeHtml(tag.id) +
|
||||
'" aria-label="Remove tag">×</button>';
|
||||
'" aria-label="' + escapeHtml(t('tag.remove')) + '">×</button>';
|
||||
}
|
||||
return html + '</span>';
|
||||
}
|
||||
@@ -1249,7 +1274,7 @@
|
||||
|
||||
if (els.saveBtn) {
|
||||
els.saveBtn.addEventListener('click', () => {
|
||||
setStatus('Saving…', true);
|
||||
setStatus(t('tag.saving'), true);
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', basePath() + '/api/report_tags.php', true);
|
||||
xhr.setRequestHeader('Content-Type', 'application/json');
|
||||
@@ -1271,7 +1296,7 @@
|
||||
if (cfg.onSaved) cfg.onSaved(state.tags, state.reportId);
|
||||
};
|
||||
xhr.onerror = function () {
|
||||
setStatus('Network error', false);
|
||||
setStatus(t('reports.network_error'), false);
|
||||
};
|
||||
xhr.send(JSON.stringify({ id: state.reportId, tags: state.tags }));
|
||||
});
|
||||
@@ -1418,12 +1443,29 @@
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
function boot() {
|
||||
initTheme();
|
||||
initNav();
|
||||
initTagModal();
|
||||
initReportDetail();
|
||||
initTagEditorDetail();
|
||||
initReportsApp();
|
||||
});
|
||||
}
|
||||
|
||||
function onReady(fn) {
|
||||
const run = () => {
|
||||
if (window.CrashI18n && window.CrashI18n.ready) {
|
||||
window.CrashI18n.ready.then(fn);
|
||||
} else {
|
||||
fn();
|
||||
}
|
||||
};
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', run);
|
||||
} else {
|
||||
run();
|
||||
}
|
||||
}
|
||||
|
||||
onReady(boot);
|
||||
})();
|
||||
|
||||
168
examples/crash_reporter/backend/public/assets/js/i18n.js
Normal file
168
examples/crash_reporter/backend/public/assets/js/i18n.js
Normal file
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Crash console i18n — JSON catalogs (en default), same key style as Android strings.xml.
|
||||
*/
|
||||
(function (global) {
|
||||
const STORAGE_KEY = 'crash_console_lang';
|
||||
const SUPPORTED = ['en', 'ru'];
|
||||
const DEFAULT_LANG = 'en';
|
||||
|
||||
let lang = DEFAULT_LANG;
|
||||
let catalog = {};
|
||||
let fallback = {};
|
||||
let readyResolve;
|
||||
const ready = new Promise((resolve) => {
|
||||
readyResolve = resolve;
|
||||
});
|
||||
|
||||
function basePath() {
|
||||
const body = document.body;
|
||||
return body ? body.getAttribute('data-base-path') || '' : '';
|
||||
}
|
||||
|
||||
function storedLang() {
|
||||
try {
|
||||
const v = localStorage.getItem(STORAGE_KEY);
|
||||
return SUPPORTED.includes(v) ? v : DEFAULT_LANG;
|
||||
} catch {
|
||||
return DEFAULT_LANG;
|
||||
}
|
||||
}
|
||||
|
||||
function persistLang(code) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, code);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function fetchCatalog(code) {
|
||||
const url = basePath() + '/assets/i18n/' + encodeURIComponent(code) + '.json';
|
||||
return fetch(url, { credentials: 'same-origin' }).then((res) => {
|
||||
if (!res.ok) throw new Error('i18n load failed: ' + code);
|
||||
return res.json();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
* @param {Record<string, string|number>|undefined} params
|
||||
*/
|
||||
function t(key, params) {
|
||||
let s = catalog[key] ?? fallback[key] ?? key;
|
||||
if (params) {
|
||||
Object.keys(params).forEach((k) => {
|
||||
s = s.split('{' + k + '}').join(String(params[k]));
|
||||
});
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function apply(root) {
|
||||
const scope = root || document;
|
||||
scope.querySelectorAll('[data-i18n]').forEach((el) => {
|
||||
const key = el.getAttribute('data-i18n');
|
||||
if (!key) return;
|
||||
if (el.hasAttribute('data-i18n-html')) {
|
||||
el.innerHTML = t(key);
|
||||
} else {
|
||||
el.textContent = t(key);
|
||||
}
|
||||
});
|
||||
scope.querySelectorAll('[data-i18n-placeholder]').forEach((el) => {
|
||||
const key = el.getAttribute('data-i18n-placeholder');
|
||||
if (key) el.setAttribute('placeholder', t(key));
|
||||
});
|
||||
scope.querySelectorAll('[data-i18n-title]').forEach((el) => {
|
||||
const key = el.getAttribute('data-i18n-title');
|
||||
if (key) el.setAttribute('title', t(key));
|
||||
});
|
||||
scope.querySelectorAll('[data-i18n-aria]').forEach((el) => {
|
||||
const key = el.getAttribute('data-i18n-aria');
|
||||
if (key) el.setAttribute('aria-label', t(key));
|
||||
});
|
||||
scope.querySelectorAll('select[data-i18n-options]').forEach((sel) => {
|
||||
const prefix = sel.getAttribute('data-i18n-options');
|
||||
if (!prefix) return;
|
||||
sel.querySelectorAll('option[data-i18n]').forEach((opt) => {
|
||||
const k = opt.getAttribute('data-i18n');
|
||||
if (k) opt.textContent = t(k);
|
||||
});
|
||||
});
|
||||
const footer = document.querySelector('[data-i18n="footer.copyright"]');
|
||||
if (footer) {
|
||||
const year = footer.getAttribute('data-year') || String(new Date().getFullYear());
|
||||
footer.textContent = t('footer.copyright', { year });
|
||||
}
|
||||
scope.querySelectorAll('[data-i18n="detail.report_meta"]').forEach((el) => {
|
||||
el.textContent = t('detail.report_meta', {
|
||||
id: el.getAttribute('data-i18n-id') || '',
|
||||
type: el.getAttribute('data-i18n-type') || '',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function syncLangSelect() {
|
||||
document.querySelectorAll('.lang-select').forEach((sel) => {
|
||||
if (sel.value !== lang) sel.value = lang;
|
||||
});
|
||||
}
|
||||
|
||||
async function setLang(code, options) {
|
||||
const opts = options || {};
|
||||
if (!SUPPORTED.includes(code)) code = DEFAULT_LANG;
|
||||
fallback = await fetchCatalog(DEFAULT_LANG);
|
||||
catalog = code === DEFAULT_LANG ? { ...fallback } : await fetchCatalog(code);
|
||||
lang = code;
|
||||
document.documentElement.setAttribute('lang', code);
|
||||
persistLang(code);
|
||||
syncLangSelect();
|
||||
if (!opts.silent) {
|
||||
apply(document);
|
||||
document.dispatchEvent(new CustomEvent('crash-i18n-change', { detail: { lang: code } }));
|
||||
}
|
||||
}
|
||||
|
||||
function getLang() {
|
||||
return lang;
|
||||
}
|
||||
|
||||
function initLangSelect() {
|
||||
document.querySelectorAll('.lang-select').forEach((sel) => {
|
||||
sel.value = lang;
|
||||
if (sel.dataset.i18nBound === '1') return;
|
||||
sel.dataset.i18nBound = '1';
|
||||
sel.addEventListener('change', () => {
|
||||
setLang(sel.value).catch(() => setLang(DEFAULT_LANG));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function init() {
|
||||
lang = storedLang();
|
||||
document.documentElement.setAttribute('lang', lang);
|
||||
await setLang(lang, { silent: true });
|
||||
apply(document);
|
||||
initLangSelect();
|
||||
readyResolve();
|
||||
}
|
||||
|
||||
global.CrashI18n = {
|
||||
t,
|
||||
apply,
|
||||
setLang,
|
||||
getLang,
|
||||
init,
|
||||
ready,
|
||||
SUPPORTED,
|
||||
};
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
init().catch(() => {
|
||||
lang = DEFAULT_LANG;
|
||||
readyResolve();
|
||||
});
|
||||
});
|
||||
} else {
|
||||
init().catch(() => readyResolve());
|
||||
}
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
@@ -0,0 +1,46 @@
|
||||
-- Phase 1: companies, memberships, devices, report scoping.
|
||||
-- Idempotent on fresh DB; existing installs: prefer app auto-migration (Database::ensureRbacSchema).
|
||||
-- Manual run: mysql -u root -p androidcast_crashes < sql/migrations/001_rbac_companies.sql
|
||||
|
||||
USE androidcast_crashes;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS companies (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
slug VARCHAR(64) NOT NULL,
|
||||
name VARCHAR(128) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_companies_slug (slug)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS company_memberships (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT UNSIGNED NOT NULL,
|
||||
company_id INT UNSIGNED NOT NULL,
|
||||
role ENUM('owner', 'admin', 'member') NOT NULL DEFAULT 'member',
|
||||
permissions_json LONGTEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_membership_user_company (user_id, company_id),
|
||||
KEY idx_membership_company (company_id),
|
||||
CONSTRAINT fk_membership_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_membership_company FOREIGN KEY (company_id) REFERENCES companies (id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS devices (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
company_id INT UNSIGNED NOT NULL,
|
||||
external_id VARCHAR(128) NOT NULL,
|
||||
display_name VARCHAR(256) NULL,
|
||||
manufacturer VARCHAR(128) NULL,
|
||||
model VARCHAR(128) NULL,
|
||||
source ENUM('auto', 'manual', 'barcode') NOT NULL DEFAULT 'auto',
|
||||
registered_at_ms BIGINT NOT NULL,
|
||||
last_seen_at_ms BIGINT NOT NULL,
|
||||
meta_json LONGTEXT NULL,
|
||||
UNIQUE KEY uq_devices_company_external (company_id, external_id),
|
||||
KEY idx_devices_company (company_id),
|
||||
CONSTRAINT fk_devices_company FOREIGN KEY (company_id) REFERENCES companies (id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- reports.company_id / reports.device_id added by application migration when missing
|
||||
|
||||
INSERT IGNORE INTO companies (id, slug, name) VALUES (1, 'default', 'Default');
|
||||
@@ -8,17 +8,56 @@ CREATE DATABASE IF NOT EXISTS androidcast_crashes
|
||||
|
||||
USE androidcast_crashes;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS companies (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
slug VARCHAR(64) NOT NULL,
|
||||
name VARCHAR(128) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_companies_slug (slug)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
username VARCHAR(64) NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
role ENUM('root', 'admin', 'viewer') NOT NULL DEFAULT 'viewer',
|
||||
role ENUM('root', 'platform_admin', 'admin', 'viewer') NOT NULL DEFAULT 'viewer',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_users_username (username)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS company_memberships (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT UNSIGNED NOT NULL,
|
||||
company_id INT UNSIGNED NOT NULL,
|
||||
role ENUM('owner', 'admin', 'member') NOT NULL DEFAULT 'member',
|
||||
permissions_json LONGTEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_membership_user_company (user_id, company_id),
|
||||
KEY idx_membership_company (company_id),
|
||||
CONSTRAINT fk_membership_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_membership_company FOREIGN KEY (company_id) REFERENCES companies (id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS devices (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
company_id INT UNSIGNED NOT NULL,
|
||||
external_id VARCHAR(128) NOT NULL,
|
||||
display_name VARCHAR(256) NULL,
|
||||
manufacturer VARCHAR(128) NULL,
|
||||
model VARCHAR(128) NULL,
|
||||
source ENUM('auto', 'manual', 'barcode') NOT NULL DEFAULT 'auto',
|
||||
registered_at_ms BIGINT NOT NULL,
|
||||
last_seen_at_ms BIGINT NOT NULL,
|
||||
meta_json LONGTEXT NULL,
|
||||
UNIQUE KEY uq_devices_company_external (company_id, external_id),
|
||||
KEY idx_devices_company (company_id),
|
||||
CONSTRAINT fk_devices_company FOREIGN KEY (company_id) REFERENCES companies (id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS reports (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
company_id INT UNSIGNED NOT NULL DEFAULT 1,
|
||||
device_id BIGINT UNSIGNED NULL,
|
||||
report_id VARCHAR(128) NOT NULL,
|
||||
fingerprint VARCHAR(64) NOT NULL,
|
||||
crash_type VARCHAR(32) NOT NULL,
|
||||
@@ -29,10 +68,14 @@ CREATE TABLE IF NOT EXISTS reports (
|
||||
payload_json LONGTEXT NOT NULL,
|
||||
tags_json LONGTEXT NOT NULL,
|
||||
UNIQUE KEY uq_reports_report_id (report_id),
|
||||
KEY idx_reports_company (company_id),
|
||||
KEY idx_reports_device (device_id),
|
||||
KEY idx_reports_generated (generated_at_ms),
|
||||
KEY idx_reports_received (received_at_ms),
|
||||
KEY idx_reports_fingerprint (fingerprint),
|
||||
KEY idx_reports_fp_received (fingerprint, received_at_ms)
|
||||
KEY idx_reports_fp_received (fingerprint, received_at_ms),
|
||||
CONSTRAINT fk_reports_company FOREIGN KEY (company_id) REFERENCES companies (id),
|
||||
CONSTRAINT fk_reports_device FOREIGN KEY (device_id) REFERENCES devices (id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS report_views (
|
||||
@@ -44,6 +87,11 @@ CREATE TABLE IF NOT EXISTS report_views (
|
||||
FOREIGN KEY (report_id) REFERENCES reports (id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
INSERT IGNORE INTO companies (id, slug, name) VALUES (1, 'default', 'Default');
|
||||
|
||||
INSERT IGNORE INTO users (id, username, password_hash, role)
|
||||
VALUES (1, 'admin', '$2y$10$t2JyYCyjBwRxAHfmLwsnG.APoj/in0i6nxpKcQggdl.AkhFJR13o.', 'root');
|
||||
-- default password: admin — change in production
|
||||
|
||||
INSERT IGNORE INTO company_memberships (user_id, company_id, role)
|
||||
VALUES (1, 1, 'owner');
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS companies (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
@@ -8,8 +15,37 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS company_memberships (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
company_id INTEGER NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'member',
|
||||
permissions_json TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE (user_id, company_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS devices (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
company_id INTEGER NOT NULL,
|
||||
external_id TEXT NOT NULL,
|
||||
display_name TEXT,
|
||||
manufacturer TEXT,
|
||||
model TEXT,
|
||||
source TEXT NOT NULL DEFAULT 'auto',
|
||||
registered_at_ms INTEGER NOT NULL,
|
||||
last_seen_at_ms INTEGER NOT NULL,
|
||||
meta_json TEXT,
|
||||
UNIQUE (company_id, external_id),
|
||||
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS reports (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
company_id INTEGER NOT NULL DEFAULT 1,
|
||||
device_id INTEGER,
|
||||
report_id TEXT NOT NULL UNIQUE,
|
||||
fingerprint TEXT NOT NULL,
|
||||
crash_type TEXT NOT NULL,
|
||||
@@ -18,7 +54,9 @@ CREATE TABLE IF NOT EXISTS reports (
|
||||
device_model TEXT,
|
||||
app_version TEXT,
|
||||
payload_json TEXT NOT NULL,
|
||||
tags_json TEXT NOT NULL DEFAULT '[]'
|
||||
tags_json TEXT NOT NULL DEFAULT '[]',
|
||||
FOREIGN KEY (company_id) REFERENCES companies(id),
|
||||
FOREIGN KEY (device_id) REFERENCES devices(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS report_views (
|
||||
@@ -29,10 +67,16 @@ CREATE TABLE IF NOT EXISTS report_views (
|
||||
FOREIGN KEY (report_id) REFERENCES reports(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_reports_company ON reports(company_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_reports_generated ON reports(generated_at_ms DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_reports_received ON reports(received_at_ms DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_reports_fingerprint ON reports(fingerprint);
|
||||
|
||||
INSERT OR IGNORE INTO companies (id, slug, name) VALUES (1, 'default', 'Default');
|
||||
|
||||
INSERT OR IGNORE INTO users (id, username, password_hash, role)
|
||||
VALUES (1, 'admin', '$2y$10$t2JyYCyjBwRxAHfmLwsnG.APoj/in0i6nxpKcQggdl.AkhFJR13o.', 'root');
|
||||
-- password: admin
|
||||
|
||||
INSERT OR IGNORE INTO company_memberships (user_id, company_id, role)
|
||||
VALUES (1, 1, 'owner');
|
||||
|
||||
@@ -14,7 +14,24 @@ declare(strict_types=1);
|
||||
|
||||
final class Auth {
|
||||
public static function user(): ?array {
|
||||
return $_SESSION['user'] ?? null;
|
||||
$u = $_SESSION['user'] ?? null;
|
||||
if (!$u || !empty($u['companies'])) {
|
||||
return $u;
|
||||
}
|
||||
$uid = (int) ($u['id'] ?? 0);
|
||||
if ($uid <= 0) {
|
||||
return $u;
|
||||
}
|
||||
$stmt = Database::pdo()->prepare('SELECT * FROM users WHERE id = ? LIMIT 1');
|
||||
$stmt->execute([$uid]);
|
||||
$row = $stmt->fetch();
|
||||
if (!$row) {
|
||||
return $u;
|
||||
}
|
||||
Database::ensureRbacSchema();
|
||||
Rbac::seedMembershipsForUser($uid, (string) ($row['role'] ?? 'viewer'));
|
||||
$_SESSION['user'] = Rbac::buildSessionUser($row);
|
||||
return $_SESSION['user'];
|
||||
}
|
||||
|
||||
public static function check(): void {
|
||||
@@ -25,19 +42,11 @@ final class Auth {
|
||||
}
|
||||
|
||||
public static function can(string $action): bool {
|
||||
$u = self::user();
|
||||
if (!$u) {
|
||||
return false;
|
||||
}
|
||||
$role = $u['role'] ?? 'viewer';
|
||||
if ($role === 'root' || $role === 'admin') {
|
||||
return true;
|
||||
}
|
||||
return $action === 'view';
|
||||
return Rbac::can($action);
|
||||
}
|
||||
|
||||
public static function canEditTags(): bool {
|
||||
return self::can('tag_edit');
|
||||
public static function canEditTags(?int $companyId = null): bool {
|
||||
return Rbac::can('edit_tags', $companyId);
|
||||
}
|
||||
|
||||
public static function login(string $username, string $password): bool {
|
||||
@@ -48,11 +57,7 @@ final class Auth {
|
||||
if (!$row || !password_verify($password, $row['password_hash'])) {
|
||||
return false;
|
||||
}
|
||||
$_SESSION['user'] = [
|
||||
'id' => (int) $row['id'],
|
||||
'username' => $row['username'],
|
||||
'role' => $row['role'],
|
||||
];
|
||||
$_SESSION['user'] = Rbac::buildSessionUser($row);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -98,6 +98,7 @@ final class Database {
|
||||
if (!$need('users') && !$need('reports') && self::reportsSchemaOk($pdo)) {
|
||||
self::ensureReportColumns($pdo);
|
||||
self::ensureReportViewsTable($pdo);
|
||||
self::ensureRbacSchema($pdo);
|
||||
return;
|
||||
}
|
||||
$schemaFile = __DIR__ . '/../sql/schema.sqlite.sql';
|
||||
@@ -110,6 +111,7 @@ final class Database {
|
||||
$pdo->exec((string) file_get_contents($schemaFile));
|
||||
self::ensureReportColumns($pdo);
|
||||
self::ensureReportViewsTable($pdo);
|
||||
self::ensureRbacSchema($pdo);
|
||||
}
|
||||
|
||||
private static function ensureMysqlSchema(PDO $pdo): void {
|
||||
@@ -153,6 +155,186 @@ final class Database {
|
||||
self::ensureMysqlReportColumns($pdo);
|
||||
}
|
||||
self::ensureMysqlReportViewsTable($pdo);
|
||||
self::ensureRbacSchema($pdo);
|
||||
}
|
||||
|
||||
/** Phase 1 RBAC tables + report company/device columns (idempotent). */
|
||||
public static function ensureRbacSchema(?PDO $pdo = null): void {
|
||||
$pdo ??= self::$pdo;
|
||||
if ($pdo === null) {
|
||||
return;
|
||||
}
|
||||
self::ensureCompaniesTable($pdo);
|
||||
self::seedDefaultCompany($pdo);
|
||||
self::ensureCompanyMembershipsTable($pdo);
|
||||
self::ensureDevicesTable($pdo);
|
||||
self::ensureReportCompanyColumns($pdo);
|
||||
self::backfillCompanyMemberships($pdo);
|
||||
}
|
||||
|
||||
private static function ensureCompaniesTable(PDO $pdo): void {
|
||||
if (self::tableExists($pdo, 'companies')) {
|
||||
return;
|
||||
}
|
||||
if (self::isMysql()) {
|
||||
$pdo->exec(
|
||||
"CREATE TABLE companies (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
slug VARCHAR(64) NOT NULL,
|
||||
name VARCHAR(128) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_companies_slug (slug)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
return;
|
||||
}
|
||||
$pdo->exec(
|
||||
"CREATE TABLE companies (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)"
|
||||
);
|
||||
}
|
||||
|
||||
private static function seedDefaultCompany(PDO $pdo): void {
|
||||
if (self::isMysql()) {
|
||||
$pdo->exec("INSERT IGNORE INTO companies (id, slug, name) VALUES (1, 'default', 'Default')");
|
||||
return;
|
||||
}
|
||||
$pdo->exec("INSERT OR IGNORE INTO companies (id, slug, name) VALUES (1, 'default', 'Default')");
|
||||
}
|
||||
|
||||
private static function ensureCompanyMembershipsTable(PDO $pdo): void {
|
||||
if (self::tableExists($pdo, 'company_memberships')) {
|
||||
return;
|
||||
}
|
||||
if (self::isMysql()) {
|
||||
$pdo->exec(
|
||||
"CREATE TABLE company_memberships (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT UNSIGNED NOT NULL,
|
||||
company_id INT UNSIGNED NOT NULL,
|
||||
role ENUM('owner', 'admin', 'member') NOT NULL DEFAULT 'member',
|
||||
permissions_json LONGTEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_membership_user_company (user_id, company_id),
|
||||
KEY idx_membership_company (company_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
return;
|
||||
}
|
||||
$pdo->exec(
|
||||
"CREATE TABLE company_memberships (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
company_id INTEGER NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'member',
|
||||
permissions_json TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE (user_id, company_id)
|
||||
)"
|
||||
);
|
||||
}
|
||||
|
||||
private static function ensureDevicesTable(PDO $pdo): void {
|
||||
if (self::tableExists($pdo, 'devices')) {
|
||||
return;
|
||||
}
|
||||
if (self::isMysql()) {
|
||||
$pdo->exec(
|
||||
"CREATE TABLE devices (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
company_id INT UNSIGNED NOT NULL,
|
||||
external_id VARCHAR(128) NOT NULL,
|
||||
display_name VARCHAR(256) NULL,
|
||||
manufacturer VARCHAR(128) NULL,
|
||||
model VARCHAR(128) NULL,
|
||||
source ENUM('auto', 'manual', 'barcode') NOT NULL DEFAULT 'auto',
|
||||
registered_at_ms BIGINT NOT NULL,
|
||||
last_seen_at_ms BIGINT NOT NULL,
|
||||
meta_json LONGTEXT NULL,
|
||||
UNIQUE KEY uq_devices_company_external (company_id, external_id),
|
||||
KEY idx_devices_company (company_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
return;
|
||||
}
|
||||
$pdo->exec(
|
||||
"CREATE TABLE devices (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
company_id INTEGER NOT NULL,
|
||||
external_id TEXT NOT NULL,
|
||||
display_name TEXT,
|
||||
manufacturer TEXT,
|
||||
model TEXT,
|
||||
source TEXT NOT NULL DEFAULT 'auto',
|
||||
registered_at_ms INTEGER NOT NULL,
|
||||
last_seen_at_ms INTEGER NOT NULL,
|
||||
meta_json TEXT,
|
||||
UNIQUE (company_id, external_id)
|
||||
)"
|
||||
);
|
||||
}
|
||||
|
||||
private static function ensureReportCompanyColumns(PDO $pdo): void {
|
||||
if (!self::tableExists($pdo, 'reports')) {
|
||||
return;
|
||||
}
|
||||
$names = self::columnNames($pdo, 'reports');
|
||||
if (!in_array('company_id', $names, true)) {
|
||||
if (self::isMysql()) {
|
||||
$pdo->exec(
|
||||
'ALTER TABLE reports ADD COLUMN company_id INT UNSIGNED NOT NULL DEFAULT 1'
|
||||
);
|
||||
$pdo->exec('ALTER TABLE reports ADD KEY idx_reports_company (company_id)');
|
||||
} else {
|
||||
$pdo->exec('ALTER TABLE reports ADD COLUMN company_id INTEGER NOT NULL DEFAULT 1');
|
||||
}
|
||||
$pdo->exec('UPDATE reports SET company_id = 1 WHERE company_id IS NULL OR company_id = 0');
|
||||
}
|
||||
$names = self::columnNames($pdo, 'reports');
|
||||
if (!in_array('device_id', $names, true)) {
|
||||
if (self::isMysql()) {
|
||||
$pdo->exec('ALTER TABLE reports ADD COLUMN device_id BIGINT UNSIGNED NULL');
|
||||
$pdo->exec('ALTER TABLE reports ADD KEY idx_reports_device (device_id)');
|
||||
} else {
|
||||
$pdo->exec('ALTER TABLE reports ADD COLUMN device_id INTEGER NULL');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static function backfillCompanyMemberships(PDO $pdo): void {
|
||||
if (!self::tableExists($pdo, 'users') || !self::tableExists($pdo, 'company_memberships')) {
|
||||
return;
|
||||
}
|
||||
$companyId = 1;
|
||||
$rows = $pdo->query('SELECT id, role FROM users')->fetchAll(PDO::FETCH_ASSOC);
|
||||
foreach ($rows as $row) {
|
||||
$uid = (int) $row['id'];
|
||||
$globalRole = strtolower((string) ($row['role'] ?? 'viewer'));
|
||||
$companyRole = in_array($globalRole, ['root', 'admin', 'platform_admin'], true) ? 'owner' : 'member';
|
||||
if (self::isMysql()) {
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT IGNORE INTO company_memberships (user_id, company_id, role) VALUES (?, ?, ?)'
|
||||
);
|
||||
} else {
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT OR IGNORE INTO company_memberships (user_id, company_id, role) VALUES (?, ?, ?)'
|
||||
);
|
||||
}
|
||||
$stmt->execute([$uid, $companyId, $companyRole]);
|
||||
}
|
||||
if (self::isMysql()) {
|
||||
$pdo->exec(
|
||||
"INSERT IGNORE INTO company_memberships (user_id, company_id, role) VALUES (1, 1, 'owner')"
|
||||
);
|
||||
} else {
|
||||
$pdo->exec(
|
||||
"INSERT OR IGNORE INTO company_memberships (user_id, company_id, role) VALUES (1, 1, 'owner')"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private static function ensureMysqlReportColumns(PDO $pdo): void {
|
||||
|
||||
103
examples/crash_reporter/backend/src/DeviceRepository.php
Normal file
103
examples/crash_reporter/backend/src/DeviceRepository.php
Normal file
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/** Phase 1 device registry — auto-register on crash upload. */
|
||||
final class DeviceRepository {
|
||||
public static function externalKey(array $device): string {
|
||||
$parts = array_filter([
|
||||
(string) ($device['manufacturer'] ?? ''),
|
||||
(string) ($device['brand'] ?? ''),
|
||||
(string) ($device['model'] ?? ''),
|
||||
(string) ($device['product'] ?? ''),
|
||||
(string) ($device['device'] ?? ''),
|
||||
(string) ($device['serial'] ?? ''),
|
||||
(string) ($device['android_id'] ?? ''),
|
||||
], static fn ($v) => $v !== '');
|
||||
if ($parts === []) {
|
||||
return 'unknown';
|
||||
}
|
||||
return substr(hash('sha256', implode('|', $parts)), 0, 32);
|
||||
}
|
||||
|
||||
public static function upsertFromPayload(int $companyId, array $device, string $source = 'auto'): int {
|
||||
$companyId = max(1, $companyId);
|
||||
$externalId = self::externalKey($device);
|
||||
$now = (int) round(microtime(true) * 1000);
|
||||
$manufacturer = trim((string) ($device['manufacturer'] ?? ''));
|
||||
$model = trim((string) ($device['model'] ?? ''));
|
||||
$display = trim($manufacturer . ' ' . $model);
|
||||
$meta = json_encode($device, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
if ($meta === false) {
|
||||
$meta = '{}';
|
||||
}
|
||||
|
||||
$pdo = Database::pdo();
|
||||
if (Database::isMysql()) {
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO devices (company_id, external_id, display_name, manufacturer, model, source,
|
||||
registered_at_ms, last_seen_at_ms, meta_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
display_name = VALUES(display_name),
|
||||
manufacturer = VALUES(manufacturer),
|
||||
model = VALUES(model),
|
||||
last_seen_at_ms = VALUES(last_seen_at_ms),
|
||||
meta_json = VALUES(meta_json)'
|
||||
);
|
||||
$stmt->execute([
|
||||
$companyId,
|
||||
$externalId,
|
||||
$display !== '' ? $display : null,
|
||||
$manufacturer !== '' ? $manufacturer : null,
|
||||
$model !== '' ? $model : null,
|
||||
$source,
|
||||
$now,
|
||||
$now,
|
||||
$meta,
|
||||
]);
|
||||
$sel = $pdo->prepare(
|
||||
'SELECT id FROM devices WHERE company_id = ? AND external_id = ? LIMIT 1'
|
||||
);
|
||||
$sel->execute([$companyId, $externalId]);
|
||||
return (int) $sel->fetchColumn();
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT id FROM devices WHERE company_id = ? AND external_id = ? LIMIT 1'
|
||||
);
|
||||
$stmt->execute([$companyId, $externalId]);
|
||||
$existing = $stmt->fetchColumn();
|
||||
if ($existing !== false) {
|
||||
$upd = $pdo->prepare(
|
||||
'UPDATE devices SET display_name = ?, manufacturer = ?, model = ?,
|
||||
last_seen_at_ms = ?, meta_json = ? WHERE id = ?'
|
||||
);
|
||||
$upd->execute([
|
||||
$display !== '' ? $display : null,
|
||||
$manufacturer !== '' ? $manufacturer : null,
|
||||
$model !== '' ? $model : null,
|
||||
$now,
|
||||
$meta,
|
||||
(int) $existing,
|
||||
]);
|
||||
return (int) $existing;
|
||||
}
|
||||
$ins = $pdo->prepare(
|
||||
'INSERT INTO devices (company_id, external_id, display_name, manufacturer, model, source,
|
||||
registered_at_ms, last_seen_at_ms, meta_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
$ins->execute([
|
||||
$companyId,
|
||||
$externalId,
|
||||
$display !== '' ? $display : null,
|
||||
$manufacturer !== '' ? $manufacturer : null,
|
||||
$model !== '' ? $model : null,
|
||||
$source,
|
||||
$now,
|
||||
$now,
|
||||
$meta,
|
||||
]);
|
||||
return (int) $pdo->lastInsertId();
|
||||
}
|
||||
}
|
||||
290
examples/crash_reporter/backend/src/Rbac.php
Normal file
290
examples/crash_reporter/backend/src/Rbac.php
Normal file
@@ -0,0 +1,290 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Phase 1 RBAC: global platform roles + company memberships.
|
||||
* MariaDB-style: global scope (root/platform) vs company scope (owner/admin/member).
|
||||
*/
|
||||
final class Rbac {
|
||||
/** Global roles stored on users.role */
|
||||
public const GLOBAL_ROOT = 'root';
|
||||
public const GLOBAL_PLATFORM_ADMIN = 'platform_admin';
|
||||
/** Legacy alias — treated as platform_admin */
|
||||
public const GLOBAL_ADMIN_LEGACY = 'admin';
|
||||
public const GLOBAL_VIEWER = 'viewer';
|
||||
|
||||
public const COMPANY_OWNER = 'owner';
|
||||
public const COMPANY_ADMIN = 'admin';
|
||||
public const COMPANY_MEMBER = 'member';
|
||||
|
||||
/** @var list<string> */
|
||||
private const GLOBAL_ADMIN_ROLES = [self::GLOBAL_ROOT, self::GLOBAL_PLATFORM_ADMIN, self::GLOBAL_ADMIN_LEGACY];
|
||||
|
||||
/** Company role => granted actions (unless global admin). */
|
||||
private const COMPANY_ROLE_ACTIONS = [
|
||||
self::COMPANY_OWNER => [
|
||||
'view_reports',
|
||||
'edit_tags',
|
||||
'upload_reports',
|
||||
'manage_devices',
|
||||
'manage_company_users',
|
||||
'manage_company_settings',
|
||||
'manage_self',
|
||||
],
|
||||
self::COMPANY_ADMIN => [
|
||||
'view_reports',
|
||||
'edit_tags',
|
||||
'upload_reports',
|
||||
'manage_devices',
|
||||
'manage_company_users',
|
||||
'manage_self',
|
||||
],
|
||||
self::COMPANY_MEMBER => [
|
||||
'view_reports',
|
||||
'manage_self',
|
||||
],
|
||||
];
|
||||
|
||||
public static function normalizeGlobalRole(string $role): string {
|
||||
$role = strtolower(trim($role));
|
||||
if ($role === self::GLOBAL_ADMIN_LEGACY) {
|
||||
return self::GLOBAL_PLATFORM_ADMIN;
|
||||
}
|
||||
return $role;
|
||||
}
|
||||
|
||||
public static function isGlobalAdmin(?array $user = null): bool {
|
||||
$user ??= Auth::user();
|
||||
if (!$user) {
|
||||
return false;
|
||||
}
|
||||
$role = self::normalizeGlobalRole((string) ($user['role'] ?? ''));
|
||||
return in_array($role, self::GLOBAL_ADMIN_ROLES, true);
|
||||
}
|
||||
|
||||
/** @return list<int>|null null = all companies (global admin) */
|
||||
public static function allowedCompanyIds(?array $user = null): ?array {
|
||||
if (self::isGlobalAdmin($user)) {
|
||||
return null;
|
||||
}
|
||||
$user ??= Auth::user();
|
||||
if (!$user) {
|
||||
return [];
|
||||
}
|
||||
$ids = [];
|
||||
foreach ($user['companies'] ?? [] as $c) {
|
||||
if (isset($c['id'])) {
|
||||
$ids[] = (int) $c['id'];
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
public static function activeCompanyId(?array $user = null): ?int {
|
||||
$user ??= Auth::user();
|
||||
if (!$user) {
|
||||
return null;
|
||||
}
|
||||
$id = (int) ($user['active_company_id'] ?? 0);
|
||||
return $id > 0 ? $id : null;
|
||||
}
|
||||
|
||||
public static function companyRole(int $userId, int $companyId): ?string {
|
||||
$stmt = Database::pdo()->prepare(
|
||||
'SELECT role FROM company_memberships WHERE user_id = ? AND company_id = ? LIMIT 1'
|
||||
);
|
||||
$stmt->execute([$userId, $companyId]);
|
||||
$role = $stmt->fetchColumn();
|
||||
return is_string($role) && $role !== '' ? $role : null;
|
||||
}
|
||||
|
||||
public static function can(string $action, ?int $companyId = null, ?array $user = null): bool {
|
||||
$user ??= Auth::user();
|
||||
if (!$user) {
|
||||
return false;
|
||||
}
|
||||
if (self::isGlobalAdmin($user)) {
|
||||
return true;
|
||||
}
|
||||
if ($action === 'manage_platform') {
|
||||
return false;
|
||||
}
|
||||
if ($action === 'manage_self') {
|
||||
return true;
|
||||
}
|
||||
$companyId ??= self::activeCompanyId($user);
|
||||
if ($companyId === null || $companyId <= 0) {
|
||||
return false;
|
||||
}
|
||||
$role = self::membershipRoleForUser($user, $companyId);
|
||||
if ($role === null) {
|
||||
return false;
|
||||
}
|
||||
$grants = self::COMPANY_ROLE_ACTIONS[$role] ?? [];
|
||||
if (in_array($action, $grants, true)) {
|
||||
return true;
|
||||
}
|
||||
return self::hasPermissionOverride($user, $companyId, $action);
|
||||
}
|
||||
|
||||
public static function canAccessCompany(int $companyId, ?array $user = null): bool {
|
||||
$allowed = self::allowedCompanyIds($user);
|
||||
if ($allowed === null) {
|
||||
return true;
|
||||
}
|
||||
return in_array($companyId, $allowed, true);
|
||||
}
|
||||
|
||||
public static function canAccessReport(array $report, ?array $user = null): bool {
|
||||
$companyId = (int) ($report['company_id'] ?? 0);
|
||||
if ($companyId <= 0) {
|
||||
return self::isGlobalAdmin($user);
|
||||
}
|
||||
return self::canAccessCompany($companyId, $user);
|
||||
}
|
||||
|
||||
public static function defaultCompanyId(): int {
|
||||
static $cached = 0;
|
||||
if ($cached > 0) {
|
||||
return $cached;
|
||||
}
|
||||
$fromCfg = (int) cfg('rbac.default_company_id', 0);
|
||||
if ($fromCfg > 0) {
|
||||
$cached = $fromCfg;
|
||||
return $cached;
|
||||
}
|
||||
$slug = (string) cfg('rbac.default_company_slug', 'default');
|
||||
$pdo = Database::pdo();
|
||||
$stmt = $pdo->prepare('SELECT id FROM companies WHERE slug = ? LIMIT 1');
|
||||
$stmt->execute([$slug]);
|
||||
$id = (int) $stmt->fetchColumn();
|
||||
if ($id > 0) {
|
||||
$cached = $id;
|
||||
return $cached;
|
||||
}
|
||||
$cached = 1;
|
||||
return $cached;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append company scope to WHERE fragments.
|
||||
*
|
||||
* @param list<string> $where
|
||||
* @param list<mixed> $params
|
||||
*/
|
||||
public static function appendCompanyScope(string $column, array &$where, array &$params, ?array $user = null): void {
|
||||
$allowed = self::allowedCompanyIds($user);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/** Match fingerprint counts within the same company. */
|
||||
public static function fingerprintCountSql(string $rAlias = 'r', string $r2Alias = 'r2'): string {
|
||||
return " AND $r2Alias.company_id = $rAlias.company_id";
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{id:int,slug:string,name:string,role:string}>
|
||||
*/
|
||||
public static function fetchMemberships(int $userId): array {
|
||||
$stmt = Database::pdo()->prepare(
|
||||
'SELECT c.id, c.slug, c.name, m.role
|
||||
FROM company_memberships m
|
||||
INNER JOIN companies c ON c.id = m.company_id
|
||||
WHERE m.user_id = ?
|
||||
ORDER BY c.name ASC'
|
||||
);
|
||||
$stmt->execute([$userId]);
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
$out[] = [
|
||||
'id' => (int) $row['id'],
|
||||
'slug' => (string) $row['slug'],
|
||||
'name' => (string) $row['name'],
|
||||
'role' => (string) $row['role'],
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{id:int,slug:string,name:string,role:string}> $memberships
|
||||
*/
|
||||
public static function pickActiveCompanyId(array $memberships): int {
|
||||
if ($memberships === []) {
|
||||
return self::defaultCompanyId();
|
||||
}
|
||||
return (int) $memberships[0]['id'];
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> session user payload */
|
||||
public static function buildSessionUser(array $dbUser): array {
|
||||
$uid = (int) $dbUser['id'];
|
||||
$memberships = self::fetchMemberships($uid);
|
||||
return [
|
||||
'id' => $uid,
|
||||
'username' => (string) $dbUser['username'],
|
||||
'role' => self::normalizeGlobalRole((string) ($dbUser['role'] ?? self::GLOBAL_VIEWER)),
|
||||
'companies' => $memberships,
|
||||
'active_company_id' => self::pickActiveCompanyId($memberships),
|
||||
];
|
||||
}
|
||||
|
||||
public static function seedMembershipsForUser(int $userId, string $globalRole): void {
|
||||
$pdo = Database::pdo();
|
||||
$companyId = self::defaultCompanyId();
|
||||
$globalRole = self::normalizeGlobalRole($globalRole);
|
||||
$companyRole = self::COMPANY_MEMBER;
|
||||
if (in_array($globalRole, self::GLOBAL_ADMIN_ROLES, true)) {
|
||||
$companyRole = self::COMPANY_OWNER;
|
||||
}
|
||||
if (Database::isMysql()) {
|
||||
$sql = 'INSERT IGNORE INTO company_memberships (user_id, company_id, role) VALUES (?, ?, ?)';
|
||||
} else {
|
||||
$sql = 'INSERT OR IGNORE INTO company_memberships (user_id, company_id, role) VALUES (?, ?, ?)';
|
||||
}
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute([$userId, $companyId, $companyRole]);
|
||||
}
|
||||
|
||||
private static function membershipRoleForUser(array $user, int $companyId): ?string {
|
||||
foreach ($user['companies'] ?? [] as $c) {
|
||||
if ((int) ($c['id'] ?? 0) === $companyId) {
|
||||
return (string) ($c['role'] ?? '');
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function hasPermissionOverride(array $user, int $companyId, string $action): bool {
|
||||
$pdo = Database::pdo();
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT permissions_json FROM company_memberships WHERE user_id = ? AND company_id = ? LIMIT 1'
|
||||
);
|
||||
$stmt->execute([(int) $user['id'], $companyId]);
|
||||
$raw = $stmt->fetchColumn();
|
||||
if (!is_string($raw) || $raw === '') {
|
||||
return false;
|
||||
}
|
||||
$decoded = json_decode($raw, true);
|
||||
if (!is_array($decoded)) {
|
||||
return false;
|
||||
}
|
||||
$grants = $decoded['grants'] ?? $decoded;
|
||||
if (!is_array($grants)) {
|
||||
return false;
|
||||
}
|
||||
return in_array($action, $grants, true);
|
||||
}
|
||||
}
|
||||
@@ -23,19 +23,23 @@ final class ReportRepository {
|
||||
|
||||
public static function insert(array $payload): void {
|
||||
$pdo = Database::pdo();
|
||||
$device = $payload['device'] ?? [];
|
||||
$app = $payload['app'] ?? [];
|
||||
$device = is_array($payload['device'] ?? null) ? $payload['device'] : [];
|
||||
$app = is_array($payload['app'] ?? null) ? $payload['app'] : [];
|
||||
$json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
if ($json === false) {
|
||||
throw new RuntimeException('payload json_encode failed: ' . json_last_error_msg());
|
||||
}
|
||||
$tags = json_encode($payload['tags'] ?? [], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
$companyId = Rbac::defaultCompanyId();
|
||||
$deviceId = DeviceRepository::upsertFromPayload($companyId, $device, 'auto');
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO reports (report_id, fingerprint, crash_type, generated_at_ms, received_at_ms,
|
||||
device_model, app_version, payload_json, tags_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
'INSERT INTO reports (company_id, device_id, report_id, fingerprint, crash_type, generated_at_ms,
|
||||
received_at_ms, device_model, app_version, payload_json, tags_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
$stmt->execute([
|
||||
$companyId,
|
||||
$deviceId > 0 ? $deviceId : null,
|
||||
$payload['report_id'] ?? uniqid('rpt_', true),
|
||||
$payload['fingerprint'] ?? 'unknown',
|
||||
$payload['crash_type'] ?? 'java',
|
||||
@@ -69,9 +73,17 @@ final class ReportRepository {
|
||||
|
||||
if ($grouped) {
|
||||
$sortCol = in_array($sort, self::GROUP_SORT, true) ? $sort : 'cnt';
|
||||
$total = (int) $pdo->query(
|
||||
'SELECT COUNT(*) FROM (SELECT 1 FROM reports GROUP BY fingerprint, crash_type)'
|
||||
)->fetchColumn();
|
||||
$gWhere = ['1=1'];
|
||||
$gParams = [];
|
||||
self::appendCompanyScope('company_id', $gWhere, $gParams);
|
||||
$gWhereSql = implode(' AND ', $gWhere);
|
||||
$countStmt = $pdo->prepare(
|
||||
"SELECT COUNT(*) FROM (
|
||||
SELECT 1 FROM reports WHERE $gWhereSql GROUP BY fingerprint, crash_type
|
||||
) t"
|
||||
);
|
||||
$countStmt->execute($gParams);
|
||||
$total = (int) $countStmt->fetchColumn();
|
||||
$sql = "SELECT fingerprint, crash_type,
|
||||
MIN(device_model) AS device_model,
|
||||
MAX(app_version) AS app_version,
|
||||
@@ -79,10 +91,13 @@ final class ReportRepository {
|
||||
MAX(generated_at_ms) AS last_generated_ms,
|
||||
MAX(received_at_ms) AS last_received_ms
|
||||
FROM reports
|
||||
WHERE $gWhereSql
|
||||
GROUP BY fingerprint, crash_type
|
||||
ORDER BY $sortCol $dir
|
||||
LIMIT $perPage OFFSET $offset";
|
||||
$items = $pdo->query($sql)->fetchAll();
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute($gParams);
|
||||
$items = $stmt->fetchAll();
|
||||
foreach ($items as &$row) {
|
||||
$row['fingerprint_cnt'] = (int) ($row['cnt'] ?? 0);
|
||||
$row['fingerprint_recent_cnt'] = $sinceMs > 0 && (int) ($row['last_received_ms'] ?? 0) > $sinceMs
|
||||
@@ -109,19 +124,27 @@ final class ReportRepository {
|
||||
$sortCol = 'fingerprint_cnt';
|
||||
}
|
||||
$orderBy = self::buildListOrder($sortCol === 'priority' ? 'priority' : $sortCol, $dir);
|
||||
$total = (int) $pdo->query('SELECT COUNT(*) FROM reports')->fetchColumn();
|
||||
$where = ['1=1'];
|
||||
$scopeParams = [];
|
||||
self::appendCompanyScope('r.company_id', $where, $scopeParams);
|
||||
$whereSql = implode(' AND ', $where);
|
||||
$fpCnt = self::fingerprintCountSql();
|
||||
$countStmt = $pdo->prepare("SELECT COUNT(*) FROM reports r WHERE $whereSql");
|
||||
$countStmt->execute($scopeParams);
|
||||
$total = (int) $countStmt->fetchColumn();
|
||||
$sql = "SELECT r.id, r.report_id, r.fingerprint, r.crash_type, r.generated_at_ms, r.received_at_ms,
|
||||
r.device_model, r.app_version, r.payload_json, r.tags_json,
|
||||
CASE WHEN v.viewed_at_ms IS NOT NULL THEN 1 ELSE 0 END AS viewed,
|
||||
(SELECT COUNT(*) FROM reports r2 WHERE r2.fingerprint = r.fingerprint) AS fingerprint_cnt,
|
||||
{$fpCnt} AS fingerprint_cnt,
|
||||
(SELECT COUNT(*) FROM reports r2 WHERE r2.fingerprint = r.fingerprint
|
||||
AND r2.received_at_ms > ?) AS fingerprint_recent_cnt
|
||||
AND r2.company_id = r.company_id AND r2.received_at_ms > ?) AS fingerprint_recent_cnt
|
||||
FROM reports r
|
||||
LEFT JOIN report_views v ON v.report_id = r.id AND v.user_id = ?
|
||||
WHERE $whereSql
|
||||
ORDER BY $orderBy
|
||||
LIMIT $perPage OFFSET $offset";
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute([$sinceMs, $userId]);
|
||||
$stmt->execute(array_merge($scopeParams, [$sinceMs, $userId]));
|
||||
$items = $stmt->fetchAll();
|
||||
self::finishListRows($items, $sinceMs, false);
|
||||
|
||||
@@ -154,6 +177,7 @@ final class ReportRepository {
|
||||
$userId = (int) (Auth::user()['id'] ?? 0);
|
||||
$where = ['1=1'];
|
||||
$params = [];
|
||||
self::appendCompanyScope('r.company_id', $where, $params);
|
||||
$device = trim((string) ($filters['device'] ?? ''));
|
||||
if ($device !== '') {
|
||||
$like = '%' . $device . '%';
|
||||
@@ -200,19 +224,20 @@ final class ReportRepository {
|
||||
}
|
||||
$orderBy = self::buildListOrder($sortCol === 'priority' ? 'priority' : $sortCol, $dir);
|
||||
$offset = ($page - 1) * $perPage;
|
||||
$fpCnt = self::fingerprintCountSql();
|
||||
$sql = "SELECT r.id, r.report_id, r.fingerprint, r.crash_type, r.generated_at_ms, r.received_at_ms,
|
||||
r.device_model, r.app_version, r.payload_json, r.tags_json,
|
||||
CASE WHEN v.viewed_at_ms IS NOT NULL THEN 1 ELSE 0 END AS viewed,
|
||||
(SELECT COUNT(*) FROM reports r2 WHERE r2.fingerprint = r.fingerprint) AS fingerprint_cnt,
|
||||
{$fpCnt} AS fingerprint_cnt,
|
||||
(SELECT COUNT(*) FROM reports r2 WHERE r2.fingerprint = r.fingerprint
|
||||
AND r2.received_at_ms > ?) AS fingerprint_recent_cnt
|
||||
AND r2.company_id = r.company_id AND r2.received_at_ms > ?) AS fingerprint_recent_cnt
|
||||
FROM reports r
|
||||
LEFT JOIN report_views v ON v.report_id = r.id AND v.user_id = ?
|
||||
WHERE $whereSql
|
||||
ORDER BY $orderBy
|
||||
LIMIT $perPage OFFSET $offset";
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute(array_merge([$sinceMs, $userId], $params));
|
||||
$stmt->execute(array_merge($params, [$sinceMs, $userId]));
|
||||
$items = $stmt->fetchAll();
|
||||
self::finishListRows($items, $sinceMs, false);
|
||||
return [
|
||||
@@ -260,6 +285,7 @@ final class ReportRepository {
|
||||
|
||||
$where = ['r.id != ?'];
|
||||
$params = [$anchorId];
|
||||
self::appendCompanyScope('r.company_id', $where, $params);
|
||||
$or = [];
|
||||
if ($fp !== '') {
|
||||
$or[] = 'r.fingerprint = ?';
|
||||
@@ -288,19 +314,20 @@ final class ReportRepository {
|
||||
$where[] = '(' . implode(' OR ', $or) . ')';
|
||||
$whereSql = implode(' AND ', $where);
|
||||
|
||||
$fpCnt = self::fingerprintCountSql();
|
||||
$sql = "SELECT r.id, r.report_id, r.fingerprint, r.crash_type, r.generated_at_ms, r.received_at_ms,
|
||||
r.device_model, r.app_version, r.payload_json, r.tags_json,
|
||||
CASE WHEN v.viewed_at_ms IS NOT NULL THEN 1 ELSE 0 END AS viewed,
|
||||
(SELECT COUNT(*) FROM reports r2 WHERE r2.fingerprint = r.fingerprint) AS fingerprint_cnt,
|
||||
{$fpCnt} AS fingerprint_cnt,
|
||||
(SELECT COUNT(*) FROM reports r2 WHERE r2.fingerprint = r.fingerprint
|
||||
AND r2.received_at_ms > ?) AS fingerprint_recent_cnt
|
||||
AND r2.company_id = r.company_id AND r2.received_at_ms > ?) AS fingerprint_recent_cnt
|
||||
FROM reports r
|
||||
LEFT JOIN report_views v ON v.report_id = r.id AND v.user_id = ?
|
||||
WHERE $whereSql
|
||||
ORDER BY r.received_at_ms DESC
|
||||
LIMIT 1200";
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute(array_merge([$sinceMs, $userId], $params));
|
||||
$stmt->execute(array_merge($params, [$sinceMs, $userId]));
|
||||
$rows = $stmt->fetchAll();
|
||||
$scored = [];
|
||||
foreach ($rows as $row) {
|
||||
@@ -386,10 +413,15 @@ final class ReportRepository {
|
||||
return [];
|
||||
}
|
||||
$pdo = Database::pdo();
|
||||
$stmt = $pdo->query(
|
||||
'SELECT payload_json, device_model, app_version, crash_type FROM reports
|
||||
ORDER BY received_at_ms DESC LIMIT 500'
|
||||
$where = ['1=1'];
|
||||
$params = [];
|
||||
self::appendCompanyScope('company_id', $where, $params);
|
||||
$whereSql = implode(' AND ', $where);
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT payload_json, device_model, app_version, crash_type FROM reports
|
||||
WHERE $whereSql ORDER BY received_at_ms DESC LIMIT 500"
|
||||
);
|
||||
$stmt->execute($params);
|
||||
$freq = [];
|
||||
while ($row = $stmt->fetch()) {
|
||||
$payload = json_decode($row['payload_json'] ?? '', true);
|
||||
@@ -450,28 +482,32 @@ final class ReportRepository {
|
||||
$page = max(1, $page);
|
||||
$perPage = max(1, min(200, $perPage));
|
||||
$userId = (int) (Auth::user()['id'] ?? 0);
|
||||
$orParts = [];
|
||||
$where = ['1=1'];
|
||||
$params = [];
|
||||
self::appendCompanyScope('r.company_id', $where, $params);
|
||||
$orParts = [];
|
||||
foreach ($tokens as $token) {
|
||||
$like = '%' . $token . '%';
|
||||
$orParts[] = '(r.payload_json LIKE ? OR r.device_model LIKE ? OR r.app_version LIKE ?
|
||||
OR r.fingerprint LIKE ? OR r.crash_type LIKE ?)';
|
||||
array_push($params, $like, $like, $like, $like, $like);
|
||||
}
|
||||
$whereSql = '(' . implode(' OR ', $orParts) . ')';
|
||||
$where[] = '(' . implode(' OR ', $orParts) . ')';
|
||||
$whereSql = implode(' AND ', $where);
|
||||
$fpCnt = self::fingerprintCountSql();
|
||||
$sql = "SELECT r.id, r.report_id, r.fingerprint, r.crash_type, r.generated_at_ms, r.received_at_ms,
|
||||
r.device_model, r.app_version, r.payload_json, r.tags_json,
|
||||
CASE WHEN v.viewed_at_ms IS NOT NULL THEN 1 ELSE 0 END AS viewed,
|
||||
(SELECT COUNT(*) FROM reports r2 WHERE r2.fingerprint = r.fingerprint) AS fingerprint_cnt,
|
||||
{$fpCnt} AS fingerprint_cnt,
|
||||
(SELECT COUNT(*) FROM reports r2 WHERE r2.fingerprint = r.fingerprint
|
||||
AND r2.received_at_ms > ?) AS fingerprint_recent_cnt
|
||||
AND r2.company_id = r.company_id AND r2.received_at_ms > ?) AS fingerprint_recent_cnt
|
||||
FROM reports r
|
||||
LEFT JOIN report_views v ON v.report_id = r.id AND v.user_id = ?
|
||||
WHERE $whereSql
|
||||
ORDER BY r.received_at_ms DESC
|
||||
LIMIT 1500";
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute(array_merge([$sinceMs, $userId], $params));
|
||||
$stmt->execute(array_merge($params, [$sinceMs, $userId]));
|
||||
$scored = [];
|
||||
while ($row = $stmt->fetch()) {
|
||||
$payload = json_decode($row['payload_json'] ?? '', true);
|
||||
@@ -569,8 +605,13 @@ final class ReportRepository {
|
||||
/** Distinct custom tags used across reports (for suggestions). */
|
||||
public static function listTagPresets(int $limit = 40): array {
|
||||
$pdo = Database::pdo();
|
||||
$rows = $pdo->query('SELECT tags_json FROM reports WHERE tags_json IS NOT NULL AND tags_json != \'[]\' LIMIT 500')
|
||||
->fetchAll();
|
||||
$where = ['tags_json IS NOT NULL', "tags_json != '[]'"];
|
||||
$params = [];
|
||||
self::appendCompanyScope('company_id', $where, $params);
|
||||
$whereSql = implode(' AND ', $where);
|
||||
$stmt = $pdo->prepare("SELECT tags_json FROM reports WHERE $whereSql LIMIT 500");
|
||||
$stmt->execute($params);
|
||||
$rows = $stmt->fetchAll();
|
||||
$map = [];
|
||||
foreach ($rows as $row) {
|
||||
foreach (parse_report_tags($row['tags_json'] ?? '[]') as $tag) {
|
||||
@@ -602,10 +643,26 @@ final class ReportRepository {
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
if (!Rbac::canAccessReport($row)) {
|
||||
return null;
|
||||
}
|
||||
$decoded = json_decode($row['payload_json'], true);
|
||||
$row['payload'] = is_array($decoded) ? $decoded : [];
|
||||
$row['tags'] = parse_report_tags($row['tags_json'] ?? '[]');
|
||||
$row['viewed'] = (bool) ($row['viewed'] ?? false);
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $where
|
||||
* @param list<mixed> $params
|
||||
*/
|
||||
private static function appendCompanyScope(string $column, array &$where, array &$params): void {
|
||||
Rbac::appendCompanyScope($column, $where, $params);
|
||||
}
|
||||
|
||||
private static function fingerprintCountSql(): string {
|
||||
$scope = Rbac::fingerprintCountSql('r', 'r2');
|
||||
return "(SELECT COUNT(*) FROM reports r2 WHERE r2.fingerprint = r.fingerprint{$scope})";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,8 @@ if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/Database.php';
|
||||
require_once __DIR__ . '/Rbac.php';
|
||||
require_once __DIR__ . '/DeviceRepository.php';
|
||||
require_once __DIR__ . '/Auth.php';
|
||||
require_once __DIR__ . '/ReportRepository.php';
|
||||
|
||||
|
||||
@@ -21,9 +21,12 @@
|
||||
(function () {
|
||||
var t = localStorage.getItem('crash_console_theme');
|
||||
if (t === 'light' || t === 'dark') document.documentElement.setAttribute('data-theme', t);
|
||||
var l = localStorage.getItem('crash_console_lang');
|
||||
if (l === 'en' || l === 'ru') document.documentElement.setAttribute('lang', l);
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="<?= h(Auth::basePath()) ?>/assets/css/app.css">
|
||||
<script src="<?= h(Auth::basePath()) ?>/assets/js/i18n.js" defer></script>
|
||||
<script src="<?= h(Auth::basePath()) ?>/assets/js/app.js" defer></script>
|
||||
</head>
|
||||
<body data-base-path="<?= h(Auth::basePath()) ?>"
|
||||
@@ -33,29 +36,40 @@
|
||||
<header class="top-menu" hidden aria-hidden="true"></header>
|
||||
<div class="shell">
|
||||
<nav class="nav-pane" id="nav-pane" aria-label="Console navigation">
|
||||
<button type="button" class="nav-handle" id="nav-handle" aria-label="Toggle navigation" title="Navigation">
|
||||
<button type="button" class="nav-handle" id="nav-handle" data-i18n-aria="nav.toggle" data-i18n-title="nav.toggle" aria-label="Toggle navigation" title="Navigation">
|
||||
<span class="nav-icon nav-icon--menu" aria-hidden="true"></span>
|
||||
</button>
|
||||
<ul class="nav-list">
|
||||
<li>
|
||||
<a href="<?= h(Auth::basePath()) ?>/?view=home"
|
||||
class="nav-link <?= ($view ?? '') === 'home' ? 'active' : '' ?>"
|
||||
data-i18n-aria="nav.home" data-i18n-title="nav.home"
|
||||
aria-label="Home"
|
||||
title="Home">
|
||||
<span class="nav-icon nav-icon--home" aria-hidden="true"></span>
|
||||
<span class="nav-label">Home</span>
|
||||
<span class="nav-label" data-i18n="nav.home">Home</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="<?= h(Auth::basePath()) ?>/?view=reports"
|
||||
class="nav-link <?= ($view ?? '') === 'reports' || ($view ?? '') === 'report' ? 'active' : '' ?>"
|
||||
data-i18n-aria="nav.reports" data-i18n-title="nav.reports"
|
||||
aria-label="Reports"
|
||||
title="Reports">
|
||||
<span class="nav-icon nav-icon--reports" aria-hidden="true"></span>
|
||||
<span class="nav-label">Reports</span>
|
||||
<span class="nav-label" data-i18n="nav.reports">Reports</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="nav-locale<?= ($view ?? '') === 'reports' ? ' nav-locale--toolbar-dup' : '' ?>">
|
||||
<label class="toolbar-select toolbar-select--nav">
|
||||
<span data-i18n="lang.label">Language</span>
|
||||
<select class="lang-select" aria-label="Language">
|
||||
<option value="en" data-i18n="lang.en">English</option>
|
||||
<option value="ru" data-i18n="lang.ru">Russian</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="nav-user">
|
||||
<span class="nav-user-name" title="<?= h(Auth::user()['username'] ?? '') ?>">
|
||||
<span class="nav-icon nav-icon--user" aria-hidden="true"></span>
|
||||
@@ -63,39 +77,47 @@
|
||||
</span>
|
||||
<a href="<?= h(Auth::basePath()) ?>/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">Logout</span>
|
||||
<span class="nav-label" data-i18n="nav.logout">Logout</span>
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
<main class="main-pane">
|
||||
<?php if (($view ?? 'home') === 'home'): ?>
|
||||
<h1>Crash console</h1>
|
||||
<p>Anonymous Android Cast crash ingest is active. Use <strong>Reports</strong> to analyze grouped fingerprints.</p>
|
||||
<h1 data-i18n="home.title">Crash console</h1>
|
||||
<p data-i18n="home.intro">Anonymous Android Cast crash ingest is active. Use <strong>Reports</strong> to analyze grouped fingerprints.</p>
|
||||
<ul>
|
||||
<li>Upload API: <code><?= h(Auth::basePath()) ?>/api/upload.php</code></li>
|
||||
<li>Schema: <code>schema_version: 1</code></li>
|
||||
<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>
|
||||
</ul>
|
||||
<?php elseif (($view ?? '') === 'report' && !empty($report)): ?>
|
||||
<?php require __DIR__ . '/report_detail.php'; ?>
|
||||
<?php elseif (($view ?? '') === 'reports'): ?>
|
||||
<div id="reports-app" class="reports-app" data-grouped="0">
|
||||
<div class="toolbar reports-toolbar">
|
||||
<h1>Crash reports</h1>
|
||||
<h1 data-i18n="reports.title">Crash reports</h1>
|
||||
<div class="toolbar-actions">
|
||||
<button type="button" class="btn reports-mode-btn active" data-grouped="0">By time</button>
|
||||
<button type="button" class="btn reports-mode-btn" data-grouped="1">Grouped</button>
|
||||
<button type="button" class="btn reports-mode-btn active" data-grouped="0" data-i18n="reports.by_time">By time</button>
|
||||
<button type="button" class="btn reports-mode-btn" data-grouped="1" data-i18n="reports.grouped">Grouped</button>
|
||||
<label class="toolbar-select">
|
||||
Theme
|
||||
<select id="theme-select" aria-label="UI theme">
|
||||
<option value="dark">Dark</option>
|
||||
<option value="light">Light</option>
|
||||
<span data-i18n="lang.label">Language</span>
|
||||
<select class="lang-select" id="lang-select" aria-label="Language" data-i18n-options="lang">
|
||||
<option value="en" data-i18n="lang.en">English</option>
|
||||
<option value="ru" data-i18n="lang.ru">Russian</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="toolbar-select">
|
||||
Per page
|
||||
<span data-i18n="theme.label">Theme</span>
|
||||
<select id="theme-select" aria-label="UI theme" data-i18n-options="theme">
|
||||
<option value="dark" data-i18n="theme.dark">Dark</option>
|
||||
<option value="light" data-i18n="theme.light">Light</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="toolbar-select">
|
||||
<span data-i18n="reports.per_page">Per page</span>
|
||||
<select id="per-page-select" aria-label="Reports per page">
|
||||
<option value="25">25</option>
|
||||
<option value="50" selected>50</option>
|
||||
@@ -108,15 +130,16 @@
|
||||
</div>
|
||||
<p id="reports-filter-banner" class="reports-filter-banner muted" hidden></p>
|
||||
<div class="reports-search-row">
|
||||
<label class="reports-search-label" for="reports-search-input">Search</label>
|
||||
<label class="reports-search-label" for="reports-search-input" data-i18n="reports.search">Search</label>
|
||||
<div class="reports-search-field">
|
||||
<input type="search" id="reports-search-input" class="reports-search-input"
|
||||
data-i18n-placeholder="reports.search_placeholder"
|
||||
placeholder="Keywords (exceptions, devices, stack traces…)" autocomplete="off" spellcheck="false">
|
||||
<ul id="reports-search-suggest" class="reports-search-suggest" hidden role="listbox"></ul>
|
||||
</div>
|
||||
<button type="button" class="btn" id="reports-search-clear" hidden>Clear</button>
|
||||
<button type="button" class="btn" id="reports-search-clear" hidden data-i18n="reports.clear">Clear</button>
|
||||
</div>
|
||||
<p id="reports-status" class="reports-status muted" aria-live="polite">Loading…</p>
|
||||
<p id="reports-status" class="reports-status muted" aria-live="polite" data-i18n="reports.loading">Loading…</p>
|
||||
<div class="reports-table-wrap">
|
||||
<table class="data-table reports-tree reports-table--cols" id="reports-tree">
|
||||
<colgroup id="reports-colgroup"></colgroup>
|
||||
@@ -134,19 +157,19 @@
|
||||
<div class="tag-modal-backdrop" data-tag-modal-close></div>
|
||||
<div class="tag-modal-panel" role="dialog" aria-labelledby="tag-modal-title">
|
||||
<header class="tag-modal-header">
|
||||
<h2 id="tag-modal-title">Edit tags</h2>
|
||||
<button type="button" class="tag-modal-close" data-tag-modal-close aria-label="Close">×</button>
|
||||
<h2 id="tag-modal-title" data-i18n="tag.edit_title">Edit tags</h2>
|
||||
<button type="button" class="tag-modal-close" data-tag-modal-close data-i18n-aria="tag.close" aria-label="Close">×</button>
|
||||
</header>
|
||||
<div class="tag-editor tag-editor--modal" id="tag-editor-modal" data-report-id="0">
|
||||
<div class="tag-editor-preview" id="tag-modal-preview"></div>
|
||||
<ul class="tag-editor-list" id="tag-modal-list"></ul>
|
||||
<form class="tag-editor-add" id="tag-modal-add">
|
||||
<label class="tag-field"><span>Label</span><input type="text" name="label" maxlength="40" required></label>
|
||||
<label class="tag-field"><span>Color</span><input type="color" name="bg" value="#5c6b82"></label>
|
||||
<button type="submit" class="btn">Add</button>
|
||||
<label class="tag-field"><span data-i18n="tag.label">Label</span><input type="text" name="label" maxlength="40" required></label>
|
||||
<label class="tag-field"><span data-i18n="tag.color">Color</span><input type="color" name="bg" value="#5c6b82"></label>
|
||||
<button type="submit" class="btn" data-i18n="tag.add">Add</button>
|
||||
</form>
|
||||
<div class="tag-preset-bar" id="tag-modal-presets" hidden>
|
||||
<span class="muted">Suggestions:</span>
|
||||
<span class="muted" data-i18n="tag.suggestions">Suggestions:</span>
|
||||
<div class="tag-preset-chips" id="tag-modal-preset-chips"></div>
|
||||
</div>
|
||||
<div class="tag-editor-actions">
|
||||
@@ -157,6 +180,6 @@
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<footer class="bottom-bar">Android Cast crash reporter — placeholder footer</footer>
|
||||
<footer class="bottom-bar" data-i18n="footer.copyright" data-year="<?= (int) date('Y') ?>">(c) Anton Afanaasyeu, <?= (int) date('Y') ?></footer>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
* - Cursor Agent (project assistant)
|
||||
* Digest: SHA256 9bef983f4d34cb790922cda355ccf5d14b565b29273d2ebfe48b3173cd2e8f9f
|
||||
*/
|
||||
$bp = Auth::basePath();
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
@@ -17,20 +18,36 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Login — <?= h(cfg('app_name')) ?></title>
|
||||
<link rel="stylesheet" href="<?= h(Auth::basePath()) ?>/assets/css/app.css">
|
||||
<script>
|
||||
(function () {
|
||||
var l = localStorage.getItem('crash_console_lang');
|
||||
if (l === 'en' || l === 'ru') document.documentElement.setAttribute('lang', l);
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="<?= h($bp) ?>/assets/css/app.css">
|
||||
<script src="<?= h($bp) ?>/assets/js/i18n.js" defer></script>
|
||||
</head>
|
||||
<body class="login-page">
|
||||
<form class="login-card" method="post" action="<?= h(Auth::basePath()) ?>/login">
|
||||
<body class="login-page" data-base-path="<?= h($bp) ?>">
|
||||
<div class="login-locale">
|
||||
<label class="toolbar-select">
|
||||
<span data-i18n="lang.label">Language</span>
|
||||
<select class="lang-select" aria-label="Language">
|
||||
<option value="en" data-i18n="lang.en">English</option>
|
||||
<option value="ru" data-i18n="lang.ru">Russian</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<form class="login-card" method="post" action="<?= h($bp) ?>/login">
|
||||
<h1><?= h(cfg('app_name')) ?></h1>
|
||||
<p class="muted">Sign in to view anonymous crash reports</p>
|
||||
<p class="muted" data-i18n="login.sign_in_hint">Sign in to view anonymous crash reports</p>
|
||||
<?php if (!empty($loginError)): ?>
|
||||
<div class="alert"><?= h($loginError) ?></div>
|
||||
<div class="alert" data-i18n="login.error"><?= h($loginError) ?></div>
|
||||
<?php endif; ?>
|
||||
<label>Username<input name="username" autocomplete="username" required></label>
|
||||
<label>Password<input name="password" type="password" autocomplete="current-password" required></label>
|
||||
<button type="submit">Sign in</button>
|
||||
<p class="hint">Default: admin / admin</p>
|
||||
<p class="hint"><a href="#">Register</a> (coming soon)</p>
|
||||
<label><span data-i18n="login.username">Username</span><input name="username" autocomplete="username" required></label>
|
||||
<label><span data-i18n="login.password">Password</span><input name="password" type="password" autocomplete="current-password" required></label>
|
||||
<button type="submit" data-i18n="login.submit">Sign in</button>
|
||||
<p class="hint" data-i18n="login.hint_default">Default: admin / admin</p>
|
||||
<p class="hint"><span data-i18n="login.register">Register</span> <span data-i18n="login.register_soon">(coming soon)</span></p>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -34,36 +34,36 @@ if (!empty($device['abis']) && is_array($device['abis'])) {
|
||||
}
|
||||
?>
|
||||
<div class="detail-header">
|
||||
<a href="<?= h($bp) ?>/?view=reports" class="back-link" aria-label="Back to reports list" title="Back to list">
|
||||
<a href="<?= h($bp) ?>/?view=reports" class="back-link" data-i18n-aria="detail.back" data-i18n-title="detail.back" aria-label="Back to reports list" title="Back to list">
|
||||
<span class="back-icon" aria-hidden="true"></span>
|
||||
</a>
|
||||
<div>
|
||||
<h1>Crash report</h1>
|
||||
<p class="muted">Report <?= h($report['report_id'] ?? '') ?> · <?= h($p['crash_type'] ?? '') ?></p>
|
||||
<h1 data-i18n="detail.title">Crash report</h1>
|
||||
<p class="muted" data-i18n="detail.report_meta" data-i18n-id="<?= h($report['report_id'] ?? '') ?>" data-i18n-type="<?= h($p['crash_type'] ?? '') ?>">Report <?= h($report['report_id'] ?? '') ?> · <?= h($p['crash_type'] ?? '') ?></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="tag-editor" id="tag-editor" data-report-id="<?= $reportDbId ?>">
|
||||
<div class="tag-editor-head">
|
||||
<h2>Tags</h2>
|
||||
<p class="muted tag-editor-hint">Auto tags (NDK / Java / Android / new) appear in the list. Add custom labels below.</p>
|
||||
<h2 data-i18n="detail.tags">Tags</h2>
|
||||
<p class="muted tag-editor-hint" data-i18n="tag.hint_auto">Auto tags (NDK / Java / Android / new) appear in the list. Add custom labels below.</p>
|
||||
</div>
|
||||
<div class="tag-editor-preview" id="tag-editor-preview" aria-live="polite"></div>
|
||||
<?php if (Auth::canEditTags()): ?>
|
||||
<ul class="tag-editor-list" id="tag-editor-list"></ul>
|
||||
<form class="tag-editor-add" id="tag-editor-add">
|
||||
<label class="tag-field">
|
||||
<span>Label</span>
|
||||
<input type="text" name="label" maxlength="40" required placeholder="e.g. regression">
|
||||
<span data-i18n="tag.label">Label</span>
|
||||
<input type="text" name="label" maxlength="40" required data-i18n-placeholder="tag.placeholder" placeholder="e.g. regression">
|
||||
</label>
|
||||
<label class="tag-field">
|
||||
<span>Color</span>
|
||||
<span data-i18n="tag.color">Color</span>
|
||||
<input type="color" name="bg" value="#5c6b82">
|
||||
</label>
|
||||
<button type="submit" class="btn">Add tag</button>
|
||||
<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">Suggestions:</span>
|
||||
<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">
|
||||
@@ -71,7 +71,7 @@ if (!empty($device['abis']) && is_array($device['abis'])) {
|
||||
<span class="tag-editor-status muted" id="tag-editor-status"></span>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<p class="muted">Sign in as admin to edit custom tags.</p>
|
||||
<p class="muted" data-i18n="tag.hint_admin">Sign in as admin to edit custom tags.</p>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
<script type="application/json" id="report-custom-tags-data"><?=
|
||||
@@ -80,12 +80,12 @@ if (!empty($device['abis']) && is_array($device['abis'])) {
|
||||
<div class="cards cards--lift">
|
||||
<div class="card card--lift"><h3>Device</h3>
|
||||
<?php if ($deviceName !== ''): ?>
|
||||
<p><a class="detail-filter-link" href="<?= h($filterDeviceUrl) ?>" title="Show reports for similar devices"><?= h($deviceName) ?></a></p>
|
||||
<p><a class="detail-filter-link detail-filter-link--tag" href="<?= h($filterDeviceUrl) ?>" title="Show reports for similar devices"><?= h($deviceName) ?></a></p>
|
||||
<?php else: ?>
|
||||
<p class="muted">—</p>
|
||||
<?php endif; ?>
|
||||
<?php if ($filterOsUrl !== ''): ?>
|
||||
<p><a class="detail-filter-link" href="<?= h($filterOsUrl) ?>" title="Show reports on this OS">Android <?= h($release) ?> (SDK <?= $sdk ?>)</a></p>
|
||||
<p><a class="detail-filter-link detail-filter-link--tag" href="<?= h($filterOsUrl) ?>" title="Show reports on this OS">Android <?= h($release) ?> (SDK <?= $sdk ?>)</a></p>
|
||||
<?php else: ?>
|
||||
<p>Android <?= h($release) ?> (SDK <?= $sdk ?>)</p>
|
||||
<?php endif; ?>
|
||||
@@ -98,7 +98,7 @@ if (!empty($device['abis']) && is_array($device['abis'])) {
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="card card--lift"><h3>App</h3>
|
||||
<div class="card card--lift"><h3 data-i18n="detail.app">App</h3>
|
||||
<p><?= h($app['package'] ?? '') ?></p>
|
||||
<?php
|
||||
$versionName = (string) ($app['version_name'] ?? '');
|
||||
@@ -107,7 +107,7 @@ if (!empty($device['abis']) && is_array($device['abis'])) {
|
||||
: '';
|
||||
?>
|
||||
<?php if ($filterAppUrl !== ''): ?>
|
||||
<p><a class="detail-filter-link" href="<?= h($filterAppUrl) ?>" title="Show reports for this app version">v<?= h($versionName) ?></a> (<?= h((string)($app['version_code'] ?? '')) ?>)</p>
|
||||
<p><a class="detail-filter-link detail-filter-link--tag" href="<?= h($filterAppUrl) ?>" data-i18n-title="detail.filter_version" title="Show reports for this app version">v<?= h($versionName) ?></a> <span class="muted">(<?= h((string)($app['version_code'] ?? '')) ?>)</span></p>
|
||||
<?php else: ?>
|
||||
<p>v<?= h($versionName) ?> (<?= h((string)($app['version_code'] ?? '')) ?>)</p>
|
||||
<?php endif; ?>
|
||||
@@ -119,9 +119,9 @@ if (!empty($device['abis']) && is_array($device['abis'])) {
|
||||
: '';
|
||||
?>
|
||||
<?php if ($filterBuildUrl !== ''): ?>
|
||||
<p class="muted">Build: <a class="detail-filter-link" href="<?= h($filterBuildUrl) ?>" title="Show reports from this build"><?= h($buildId) ?></a></p>
|
||||
<p><span class="muted" data-i18n="detail.build">Build:</span> <a class="detail-filter-link detail-filter-link--tag" href="<?= h($filterBuildUrl) ?>" data-i18n-title="detail.filter_build" title="Show reports from this build"><?= h($buildId) ?></a></p>
|
||||
<?php else: ?>
|
||||
<p class="muted">Build: <?= h($buildId) ?></p>
|
||||
<p class="muted"><span data-i18n="detail.build">Build:</span> <?= h($buildId) ?></p>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
@@ -138,7 +138,7 @@ if (!empty($device['abis']) && is_array($device['abis'])) {
|
||||
<a class="btn btn-similar" href="<?= h($similarUrl) ?>">Find similar reports</a>
|
||||
</div>
|
||||
<p class="exc"><?= h($j['exception'] ?? '') ?><?= !empty($j['message']) ? ': ' . h($j['message']) : '' ?></p>
|
||||
<p class="muted">Thread: <?= h($j['thread'] ?? '') ?></p>
|
||||
<p class="muted"><span data-i18n="detail.thread">Thread:</span> <?= h($j['thread'] ?? '') ?></p>
|
||||
<pre class="stack"><?php foreach ($j['stack_frames'] ?? [] as $frame) { echo h((string)$frame) . "\n"; } ?></pre>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
@@ -176,7 +176,7 @@ if (!empty($device['abis']) && is_array($device['abis'])) {
|
||||
<span class="report-tree-arrow" aria-hidden="true"></span>
|
||||
</button>
|
||||
</span>
|
||||
<span class="detail-tree-caption">Raw JSON</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>
|
||||
|
||||
Reference in New Issue
Block a user