mirror of
git://f0xx.org/ac/ac-deploy
synced 2026-07-29 05:59:05 +03:00
cluster0: lab seeds, Gitea mirrors, compose mail, verify tooling
Freeze lab-seeds backend for COMPOSE_SKIP_MONOLITH; fix Gitea mirror scripts for 1.25 API; add secrets.lab.env template, verify-mail-lab, LAB_PUBLIC_ORIGIN, and credentials pointers for mirror token recovery. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
26
sim/cluster0/lab-seeds/backend/public/api/auth_register.php
Normal file
26
sim/cluster0/lab-seeds/backend/public/api/auth_register.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
json_out(['ok' => false, 'error' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$raw = file_get_contents('php://input');
|
||||
$data = json_decode($raw ?: '', true);
|
||||
if (!is_array($data)) {
|
||||
json_out(['ok' => false, 'error' => 'invalid_json'], 400);
|
||||
}
|
||||
|
||||
$action = (string) ($data['action'] ?? 'register');
|
||||
if ($action === 'verify') {
|
||||
$result = AuthRegistration::verifyEmailToken((string) ($data['token'] ?? ''));
|
||||
json_out($result, $result['ok'] ? 200 : 400);
|
||||
}
|
||||
|
||||
$email = (string) ($data['email'] ?? '');
|
||||
$password = (string) ($data['password'] ?? '');
|
||||
$username = (string) ($data['username'] ?? '');
|
||||
$result = AuthRegistration::register($email, $password, $username);
|
||||
json_out($result, $result['ok'] ? 200 : 400);
|
||||
77
sim/cluster0/lab-seeds/backend/public/api/diag.php
Normal file
77
sim/cluster0/lab-seeds/backend/public/api/diag.php
Normal file
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||
|
||||
if (!cfg('debug')) {
|
||||
json_out(['ok' => false, 'error' => 'diag disabled'], 403);
|
||||
}
|
||||
|
||||
$driver = Database::driver();
|
||||
$path = cfg('db.sqlite_path');
|
||||
$out = [
|
||||
'ok' => true,
|
||||
'db_driver' => $driver,
|
||||
'php_user' => get_current_user(),
|
||||
'storage_writable' => is_writable(dirname(__DIR__) . '/../storage'),
|
||||
'tables' => [],
|
||||
'reports_columns' => [],
|
||||
'test_insert' => null,
|
||||
];
|
||||
|
||||
if ($driver === 'sqlite') {
|
||||
$out['sqlite_path'] = $path;
|
||||
$out['sqlite_realpath'] = is_string($path) ? realpath($path) : null;
|
||||
$out['sqlite_exists'] = is_string($path) && is_file($path);
|
||||
$out['sqlite_writable'] = is_string($path) && is_writable($path);
|
||||
$out['data_dir_writable'] = is_string($path) && is_writable(dirname($path));
|
||||
} else {
|
||||
$m = cfg('db.mysql', []);
|
||||
$socket = trim((string) ($m['socket'] ?? ''));
|
||||
$out['mysql'] = [
|
||||
'host' => $m['host'] ?? '127.0.0.1',
|
||||
'port' => (int) ($m['port'] ?? 3306),
|
||||
'socket' => $socket,
|
||||
'socket_exists' => $socket !== '' && file_exists($socket),
|
||||
'database' => $m['database'] ?? 'androidcast_crashes',
|
||||
'username' => $m['username'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
$pdo = Database::pdo();
|
||||
$tables = Database::listTables($pdo);
|
||||
$out['tables'] = $tables;
|
||||
$out['tickets_table'] = Database::ticketsTableExists($pdo);
|
||||
if (Database::isMysql()) {
|
||||
try {
|
||||
$out['mysql_database'] = $pdo->query('SELECT DATABASE()')->fetchColumn();
|
||||
} catch (Throwable $e) {
|
||||
$out['mysql_database'] = $e->getMessage();
|
||||
}
|
||||
}
|
||||
if (!$out['tickets_table']) {
|
||||
$out['tickets_migration'] = Database::ticketsMigrationHint();
|
||||
}
|
||||
if (in_array('reports', $tables, true)) {
|
||||
$cols = Database::columnNames($pdo, 'reports');
|
||||
$out['reports_columns'] = array_map(
|
||||
static fn (string $name) => ['name' => $name],
|
||||
$cols
|
||||
);
|
||||
}
|
||||
$pdo->beginTransaction();
|
||||
$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 (?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
$rid = 'diag-' . time();
|
||||
$stmt->execute([$rid, 'diag', 'java', 1, 1, null, null, '{}', '[]']);
|
||||
$pdo->rollBack();
|
||||
$out['test_insert'] = 'ok (rolled back)';
|
||||
} catch (Throwable $e) {
|
||||
$out['ok'] = false;
|
||||
$out['test_insert'] = $e->getMessage();
|
||||
}
|
||||
|
||||
json_out($out);
|
||||
32
sim/cluster0/lab-seeds/backend/public/api/graph_upload.php
Normal file
32
sim/cluster0/lab-seeds/backend/public/api/graph_upload.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||
|
||||
$raw = file_get_contents('php://input');
|
||||
if ($raw === false || $raw === '') {
|
||||
json_out(['ok' => false, 'error' => 'empty body'], 400);
|
||||
}
|
||||
$data = json_decode($raw, true);
|
||||
if (!is_array($data)) {
|
||||
json_out(['ok' => false, 'error' => 'invalid json'], 400);
|
||||
}
|
||||
if ((int) ($data['schema_version'] ?? 0) !== 1) {
|
||||
json_out(['ok' => false, 'error' => 'unsupported schema_version'], 400);
|
||||
}
|
||||
if (($data['ingest_kind'] ?? 'graph_session') !== 'graph_session') {
|
||||
json_out(['ok' => false, 'error' => 'ingest_kind must be graph_session'], 400);
|
||||
}
|
||||
|
||||
try {
|
||||
GraphRepository::insertSession($data);
|
||||
json_out(['ok' => true, 'session_id' => $data['session_id'] ?? null]);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
json_out(['ok' => false, 'error' => $e->getMessage()], 400);
|
||||
} catch (Throwable $e) {
|
||||
error_log('graph upload: ' . $e->getMessage());
|
||||
$out = ['ok' => false, 'error' => 'store_failed'];
|
||||
if (cfg('debug')) {
|
||||
$out['hint'] = $e->getMessage();
|
||||
}
|
||||
json_out($out, 500);
|
||||
}
|
||||
22
sim/cluster0/lab-seeds/backend/public/api/graphs.php
Normal file
22
sim/cluster0/lab-seeds/backend/public/api/graphs.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||
|
||||
Auth::check();
|
||||
|
||||
$days = (int) ($_GET['days'] ?? 14);
|
||||
if ($days <= 0) {
|
||||
$days = 14;
|
||||
}
|
||||
|
||||
try {
|
||||
$payload = GraphRepository::dashboardData($days);
|
||||
json_out(['ok' => true, 'data' => $payload]);
|
||||
} catch (Throwable $e) {
|
||||
error_log('graphs api: ' . $e->getMessage());
|
||||
$out = ['ok' => false, 'error' => 'graphs_load_failed'];
|
||||
if (cfg('debug')) {
|
||||
$out['hint'] = $e->getMessage();
|
||||
}
|
||||
json_out($out, 500);
|
||||
}
|
||||
65
sim/cluster0/lab-seeds/backend/public/api/heartbeat.php
Normal file
65
sim/cluster0/lab-seeds/backend/public/api/heartbeat.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['error' => 'method_not_allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$raw = file_get_contents('php://input') ?: '';
|
||||
$req = json_decode($raw, true);
|
||||
if (!is_array($req)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'invalid_json']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$heartbeat = $req['heartbeat'] ?? [];
|
||||
if (!is_array($heartbeat)) {
|
||||
$heartbeat = [];
|
||||
}
|
||||
|
||||
$type = (string)($heartbeat['type'] ?? 'generic');
|
||||
$srvEpoch = time();
|
||||
$srvTz = date('O');
|
||||
$clientIp = (string)($_SERVER['HTTP_X_FORWARDED_FOR'] ?? ($_SERVER['REMOTE_ADDR'] ?? ''));
|
||||
|
||||
$resp = [
|
||||
'heartbeat' => [
|
||||
'type' => $type,
|
||||
'server_date' => $srvEpoch,
|
||||
'server_tz' => $srvTz,
|
||||
],
|
||||
];
|
||||
|
||||
if ($type === 'ntp') {
|
||||
$clientDate = (int)($heartbeat['date'] ?? 0);
|
||||
$clientTz = (string)($heartbeat['tz'] ?? '');
|
||||
$timeSource = trim((string)($heartbeat['time_source'] ?? 'device'));
|
||||
if ($timeSource === '') {
|
||||
$timeSource = 'device';
|
||||
}
|
||||
$correction = $clientDate > 0 ? ($srvEpoch - $clientDate) : null;
|
||||
$resp['heartbeat']['client_date'] = $clientDate;
|
||||
$resp['heartbeat']['client_tz'] = $clientTz;
|
||||
$resp['heartbeat']['time_source'] = $timeSource;
|
||||
$resp['heartbeat']['correction_seconds'] = $correction;
|
||||
$resp['heartbeat']['ntp_correction_s'] = $correction;
|
||||
$resp['heartbeat']['enabled'] = true;
|
||||
}
|
||||
|
||||
if ($type === 'ip') {
|
||||
$resp['heartbeat']['external_ip'] = $clientIp;
|
||||
}
|
||||
|
||||
if ($type === 'ra') {
|
||||
require_once __DIR__ . '/../../src/RemoteAccessRepository.php';
|
||||
$ra = RemoteAccessRepository::handleDeviceHeartbeat($heartbeat, $clientIp);
|
||||
$resp['heartbeat'] = array_merge($resp['heartbeat'], $ra);
|
||||
}
|
||||
|
||||
echo json_encode($resp, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
146
sim/cluster0/lab-seeds/backend/public/api/hub_deploy_docs.php
Normal file
146
sim/cluster0/lab-seeds/backend/public/api/hub_deploy_docs.php
Normal file
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$action = strtolower(trim((string)($_GET['action'] ?? '')));
|
||||
$section = strtolower(trim((string)($_GET['section'] ?? '')));
|
||||
|
||||
function hub_docs_toc(): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'id' => 'intro',
|
||||
'title' => 'INTRODUCTION',
|
||||
'children' => [
|
||||
['id' => 'intro-authors', 'title' => 'Authors'],
|
||||
['id' => 'intro-revision', 'title' => 'Revision details'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'id' => 'deploy',
|
||||
'title' => 'LOCAL SINGLE-STEP TEST DEPLOYMENT',
|
||||
'children' => [
|
||||
['id' => 'deploy-prep', 'title' => 'Preparations, environment'],
|
||||
['id' => 'deploy-packages', 'title' => 'Package prerequisites by distro'],
|
||||
['id' => 'deploy-checks', 'title' => 'Local checks'],
|
||||
],
|
||||
],
|
||||
['id' => 'orchestration', 'title' => 'ORCHESTRATION MODEL', 'children' => []],
|
||||
['id' => 'routing', 'title' => 'XHR SUPERVISED VIEW MODEL', 'children' => []],
|
||||
[
|
||||
'id' => 'ops',
|
||||
'title' => 'OPERATIONS CHECKLIST',
|
||||
'children' => [],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
function hub_docs_section_html(string $section): string
|
||||
{
|
||||
switch ($section) {
|
||||
case 'intro-authors':
|
||||
return '<p>Project owner: Anton Afanaasyeu. Implementation assistant: Cursor coding agent.</p>';
|
||||
case 'intro-revision':
|
||||
return '<p>Revision scope: landing compass interaction, XHR-driven deployment docs view, Docker orchestration model integration for next branch.</p>';
|
||||
case 'deploy-prep':
|
||||
return '<p>Prerequisites: docker + docker compose plugin, git checkout of android cast on branch <code>next</code>.</p>'
|
||||
. '<p>Run one command from repo: <code>cd orchestration && ./deploy.sh</code></p>';
|
||||
case 'deploy-packages':
|
||||
return '<table><thead><tr><th>Distro</th><th>Install steps</th></tr></thead><tbody>'
|
||||
. '<tr><td>Alpine</td><td><code>apk add docker docker-cli-compose bash git curl</code><br><code>rc-update add docker default && service docker start</code></td></tr>'
|
||||
. '<tr><td>Debian / Ubuntu</td><td><code>apt update && apt install -y docker.io docker-compose-plugin git curl</code><br><code>systemctl enable --now docker</code></td></tr>'
|
||||
. '<tr><td>Fedora</td><td><code>dnf install -y docker docker-compose-plugin git curl</code><br><code>systemctl enable --now docker</code></td></tr>'
|
||||
. '<tr><td>Arch</td><td><code>pacman -S --noconfirm docker docker-compose git curl</code><br><code>systemctl enable --now docker</code></td></tr>'
|
||||
. '</tbody></table>';
|
||||
case 'deploy-checks':
|
||||
return '<ul>'
|
||||
. '<li><code>http://localhost:8080/app/androidcast_project/</code> (hub)</li>'
|
||||
. '<li><code>http://localhost:8080/app/androidcast_project/crashes/</code> (crashes/tasks)</li>'
|
||||
. '<li><code>http://localhost:8080/app/androidcast_project/git/</code> (redirect to gitea FE)</li>'
|
||||
. '</ul>';
|
||||
case 'orchestration':
|
||||
return '<p>All infra runs in docker with shared source bind-mount where possible.</p>'
|
||||
. '<ul>'
|
||||
. '<li>landing FE (nginx): serves hub and routes subpaths</li>'
|
||||
. '<li>gitea FE (nginx) + gitea app</li>'
|
||||
. '<li>crashes FE (nginx) + php-fpm + mariadb</li>'
|
||||
. '<li>single command create/update: <code>orchestration/deploy.sh</code></li>'
|
||||
. '</ul>'
|
||||
. '<p>Base images: nginx, php-fpm, mariadb. This stays minimal and reproducible on laptop devices.</p>';
|
||||
case 'routing':
|
||||
return '<p>The Compass click opens a modal. Content is requested via XMLHttpRequest/fetch from BE endpoint '
|
||||
. '<code>/app/androidcast_project/crashes/api/hub_deploy_docs.php</code>.</p>'
|
||||
. '<p>Internal links in this modal use tree-list captions and are intercepted on frontend, then re-requested through BE. '
|
||||
. 'Browser URL bar is not changed.</p>'
|
||||
. '<p>Static assets remain at original paths and are not rewritten in this view.</p>';
|
||||
case 'ops':
|
||||
return '<ol>'
|
||||
. '<li>Pull latest <code>next</code></li>'
|
||||
. '<li>Run <code>orchestration/deploy.sh</code></li>'
|
||||
. '<li>Verify hub/crashes/gitea endpoints</li>'
|
||||
. '<li>For app changes: edit source in repo, rerun deploy script</li>'
|
||||
. '</ol>';
|
||||
case 'intro':
|
||||
return '<p>Select a topic in the table of contents to load section content from backend.</p>';
|
||||
default:
|
||||
return '<p class="muted">Section not found.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
function hub_docs_section_title(string $section): string
|
||||
{
|
||||
foreach (hub_docs_toc() as $node) {
|
||||
if ($node['id'] === $section) {
|
||||
return (string)$node['title'];
|
||||
}
|
||||
foreach ($node['children'] as $child) {
|
||||
if ($child['id'] === $section) {
|
||||
return (string)$child['title'];
|
||||
}
|
||||
}
|
||||
}
|
||||
return 'Section';
|
||||
}
|
||||
|
||||
function hub_docs_all_section_ids(): array
|
||||
{
|
||||
$ids = [];
|
||||
foreach (hub_docs_toc() as $node) {
|
||||
if (!empty($node['children'])) {
|
||||
foreach ($node['children'] as $child) {
|
||||
$ids[] = (string)$child['id'];
|
||||
}
|
||||
} else {
|
||||
$ids[] = (string)$node['id'];
|
||||
}
|
||||
}
|
||||
return $ids;
|
||||
}
|
||||
|
||||
if ($action === 'toc') {
|
||||
echo safe_json_encode([
|
||||
'ok' => true,
|
||||
'sections' => hub_docs_toc(),
|
||||
'default_section' => 'intro-authors',
|
||||
'preload' => hub_docs_all_section_ids(),
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($section === '') {
|
||||
$section = 'intro-authors';
|
||||
}
|
||||
|
||||
$known = hub_docs_all_section_ids();
|
||||
if (!in_array($section, $known, true)) {
|
||||
$section = 'intro-authors';
|
||||
}
|
||||
|
||||
echo safe_json_encode([
|
||||
'ok' => true,
|
||||
'section' => $section,
|
||||
'title' => hub_docs_section_title($section),
|
||||
'html' => hub_docs_section_html($section),
|
||||
]);
|
||||
114
sim/cluster0/lab-seeds/backend/public/api/live_cast.php
Normal file
114
sim/cluster0/lab-seeds/backend/public/api/live_cast.php
Normal file
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET'));
|
||||
$action = '';
|
||||
$body = [];
|
||||
|
||||
if ($method === 'GET') {
|
||||
$action = trim((string) ($_GET['action'] ?? 'active'));
|
||||
} else {
|
||||
$raw = file_get_contents('php://input') ?: '';
|
||||
$body = json_decode($raw, true);
|
||||
if (!is_array($body)) {
|
||||
json_out(['ok' => false, 'error' => 'invalid_json'], 400);
|
||||
}
|
||||
$action = trim((string) ($body['action'] ?? ''));
|
||||
}
|
||||
|
||||
$public = ($method === 'GET' && $action === 'session')
|
||||
|| ($method === 'POST' && $action === 'join_event');
|
||||
|
||||
if ($method === 'POST' && in_array($action, ['create_intent', 'heartbeat'], true)) {
|
||||
$platform = strtolower(trim((string) ($body['platform'] ?? '')));
|
||||
if ($action === 'create_intent' && str_starts_with($platform, 'education')) {
|
||||
$public = true;
|
||||
}
|
||||
if ($action === 'heartbeat') {
|
||||
$sid = trim((string) ($body['session_id'] ?? ''));
|
||||
if ($sid !== '') {
|
||||
$existing = LiveCastRepository::sessionById($sid);
|
||||
$existingPlatform = strtolower((string) ($existing['platform'] ?? ''));
|
||||
if ($existing !== null && str_starts_with($existingPlatform, 'education')) {
|
||||
$public = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$public) {
|
||||
Auth::check();
|
||||
}
|
||||
$actor = Auth::user() ?? [];
|
||||
|
||||
if ($method === 'GET') {
|
||||
if ($action === 'active') {
|
||||
$platform = trim((string) ($_GET['platform'] ?? ''));
|
||||
$rows = LiveCastRepository::activeSessions($actor, $platform !== '' ? $platform : null);
|
||||
json_out(['ok' => true, 'sessions' => $rows]);
|
||||
}
|
||||
if ($action === 'analytics') {
|
||||
$days = (int) ($_GET['days'] ?? 14);
|
||||
$payload = LiveCastRepository::analytics($days, $actor);
|
||||
json_out(['ok' => true, 'data' => $payload]);
|
||||
}
|
||||
if ($action === 'list') {
|
||||
$days = (int) ($_GET['days'] ?? 14);
|
||||
$limit = (int) ($_GET['limit'] ?? 200);
|
||||
$rows = LiveCastRepository::listSessions($days, $actor, $limit);
|
||||
json_out(['ok' => true, 'sessions' => $rows]);
|
||||
}
|
||||
if ($action === 'session') {
|
||||
$sessionId = trim((string) ($_GET['session_id'] ?? ''));
|
||||
if ($sessionId === '') {
|
||||
json_out(['ok' => false, 'error' => 'missing_session_id'], 400);
|
||||
}
|
||||
$row = LiveCastRepository::publicSession($sessionId);
|
||||
if ($row === null) {
|
||||
json_out(['ok' => false, 'error' => 'not_found'], 404);
|
||||
}
|
||||
json_out(['ok' => true, 'session' => $row]);
|
||||
}
|
||||
json_out(['ok' => false, 'error' => 'unknown_action'], 400);
|
||||
}
|
||||
|
||||
if ($method !== 'POST') {
|
||||
json_out(['ok' => false, 'error' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
if ($action === 'create_intent') {
|
||||
$row = LiveCastRepository::createIntent($body, $actor);
|
||||
if ($row === []) {
|
||||
json_out(['ok' => false, 'error' => 'create_failed'], 500);
|
||||
}
|
||||
json_out(['ok' => true, 'session' => $row]);
|
||||
}
|
||||
|
||||
if ($action === 'heartbeat') {
|
||||
$sessionId = trim((string) ($body['session_id'] ?? ''));
|
||||
if ($sessionId === '') {
|
||||
json_out(['ok' => false, 'error' => 'missing_session_id'], 400);
|
||||
}
|
||||
$row = LiveCastRepository::heartbeat($sessionId, $body, $actor);
|
||||
if ($row === null) {
|
||||
json_out(['ok' => false, 'error' => 'not_found'], 404);
|
||||
}
|
||||
json_out(['ok' => true, 'session' => $row]);
|
||||
}
|
||||
|
||||
if ($action === 'join_event') {
|
||||
$sessionId = trim((string) ($body['session_id'] ?? ''));
|
||||
if ($sessionId === '') {
|
||||
json_out(['ok' => false, 'error' => 'missing_session_id'], 400);
|
||||
}
|
||||
$ok = LiveCastRepository::registerJoin($sessionId, $body, $actor);
|
||||
if (!$ok) {
|
||||
json_out(['ok' => false, 'error' => 'not_found'], 404);
|
||||
}
|
||||
json_out(['ok' => true]);
|
||||
}
|
||||
|
||||
json_out(['ok' => false, 'error' => 'unknown_action'], 400);
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET'));
|
||||
$sessionId = trim((string) ($_GET['session_id'] ?? ''));
|
||||
$sinceId = (int) ($_GET['since_id'] ?? 0);
|
||||
$msgType = trim((string) ($_GET['msg_type'] ?? ''));
|
||||
$fromRole = trim((string) ($_GET['from_role'] ?? ''));
|
||||
$body = [];
|
||||
|
||||
if ($method === 'POST') {
|
||||
$raw = file_get_contents('php://input') ?: '';
|
||||
$body = json_decode($raw, true);
|
||||
if (!is_array($body)) {
|
||||
json_out(['ok' => false, 'error' => 'invalid_json'], 400);
|
||||
}
|
||||
$sessionId = trim((string) ($body['session_id'] ?? $sessionId));
|
||||
}
|
||||
|
||||
if ($sessionId === '') {
|
||||
json_out(['ok' => false, 'error' => 'missing_session_id'], 400);
|
||||
}
|
||||
|
||||
$session = LiveCastRepository::sessionById($sessionId);
|
||||
if ($session === null) {
|
||||
json_out(['ok' => false, 'error' => 'not_found'], 404);
|
||||
}
|
||||
|
||||
$platform = strtolower((string) ($session['platform'] ?? ''));
|
||||
$status = strtolower((string) ($session['status'] ?? ''));
|
||||
$isPublic = str_starts_with($platform, 'education')
|
||||
|| in_array($status, ['intent', 'live'], true);
|
||||
|
||||
if (!$isPublic) {
|
||||
Auth::check();
|
||||
}
|
||||
|
||||
if ($method === 'GET') {
|
||||
$action = trim((string) ($_GET['action'] ?? 'poll'));
|
||||
if ($action === 'latest') {
|
||||
$latest = LiveCastSignalingRepository::latest(
|
||||
$sessionId,
|
||||
$msgType !== '' ? $msgType : 'offer',
|
||||
$fromRole !== '' ? $fromRole : null
|
||||
);
|
||||
json_out(['ok' => true, 'message' => $latest]);
|
||||
}
|
||||
$messages = LiveCastSignalingRepository::poll(
|
||||
$sessionId,
|
||||
$sinceId,
|
||||
$msgType !== '' ? $msgType : null,
|
||||
$fromRole !== '' ? $fromRole : null
|
||||
);
|
||||
$lastId = $sinceId;
|
||||
foreach ($messages as $m) {
|
||||
$lastId = max($lastId, (int) ($m['id'] ?? 0));
|
||||
}
|
||||
json_out(['ok' => true, 'messages' => $messages, 'since_id' => $lastId]);
|
||||
}
|
||||
|
||||
if ($method !== 'POST') {
|
||||
json_out(['ok' => false, 'error' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$action = trim((string) ($body['action'] ?? 'post'));
|
||||
if ($action !== 'post') {
|
||||
json_out(['ok' => false, 'error' => 'unknown_action'], 400);
|
||||
}
|
||||
|
||||
$peerRole = trim((string) ($body['peer_role'] ?? 'caster'));
|
||||
$type = trim((string) ($body['msg_type'] ?? ''));
|
||||
$payload = $body['payload'] ?? null;
|
||||
if (!is_array($payload)) {
|
||||
$payload = ['sdp' => (string) ($body['sdp'] ?? ''), 'type' => (string) ($body['type'] ?? '')];
|
||||
}
|
||||
if ($type === '') {
|
||||
json_out(['ok' => false, 'error' => 'missing_msg_type'], 400);
|
||||
}
|
||||
|
||||
$id = LiveCastSignalingRepository::post($sessionId, $peerRole, $type, $payload);
|
||||
json_out(['ok' => true, 'id' => $id]);
|
||||
70
sim/cluster0/lab-seeds/backend/public/api/rbac.php
Normal file
70
sim/cluster0/lab-seeds/backend/public/api/rbac.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||
require_once __DIR__ . '/../../src/RbacAdminRepository.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
Auth::check();
|
||||
|
||||
if (!Rbac::canManageRbac()) {
|
||||
json_out(['ok' => false, 'error' => 'forbidden'], 403);
|
||||
}
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
|
||||
$action = trim((string) ($_GET['action'] ?? ''));
|
||||
|
||||
if ($method === 'GET' && $action === 'panel') {
|
||||
$panel = RbacAdminRepository::buildPanel();
|
||||
json_out($panel, empty($panel['ok']) ? 403 : 200);
|
||||
}
|
||||
|
||||
if ($method !== 'POST') {
|
||||
json_out(['ok' => false, 'error' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$raw = file_get_contents('php://input') ?: '';
|
||||
$body = json_decode($raw, true);
|
||||
if (!is_array($body)) {
|
||||
json_out(['ok' => false, 'error' => 'invalid_json'], 400);
|
||||
}
|
||||
|
||||
if ($action === 'set_global_role') {
|
||||
$result = RbacAdminRepository::setGlobalRole(
|
||||
(int) ($body['user_id'] ?? 0),
|
||||
(string) ($body['role'] ?? '')
|
||||
);
|
||||
json_out($result, empty($result['ok']) ? 400 : 200);
|
||||
}
|
||||
|
||||
if ($action === 'set_company_role') {
|
||||
$result = RbacAdminRepository::setCompanyRole(
|
||||
(int) ($body['user_id'] ?? 0),
|
||||
(int) ($body['company_id'] ?? 0),
|
||||
(string) ($body['role'] ?? '')
|
||||
);
|
||||
json_out($result, empty($result['ok']) ? 400 : 200);
|
||||
}
|
||||
|
||||
if ($action === 'apply_privilege_set') {
|
||||
$result = RbacAdminRepository::applyPrivilegeSet(
|
||||
(int) ($body['user_id'] ?? 0),
|
||||
(int) ($body['company_id'] ?? 0),
|
||||
(string) ($body['privilege_set'] ?? '')
|
||||
);
|
||||
json_out($result, empty($result['ok']) ? 400 : 200);
|
||||
}
|
||||
|
||||
if ($action === 'clear_auth_lockouts') {
|
||||
if (!Rbac::isRoot() && !Rbac::isGlobalAdmin()) {
|
||||
json_out(['ok' => false, 'error' => 'forbidden'], 403);
|
||||
}
|
||||
$userId = (int) ($body['user_id'] ?? 0);
|
||||
if ($userId <= 0) {
|
||||
json_out(['ok' => false, 'error' => 'invalid_user'], 400);
|
||||
}
|
||||
$deleted = AuthAttempts::clearForUser($userId);
|
||||
json_out(['ok' => true, 'cleared' => $deleted]);
|
||||
}
|
||||
|
||||
json_out(['ok' => false, 'error' => 'unknown_action'], 400);
|
||||
119
sim/cluster0/lab-seeds/backend/public/api/remote_access.php
Normal file
119
sim/cluster0/lab-seeds/backend/public/api/remote_access.php
Normal file
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||
require_once __DIR__ . '/../../src/RemoteAccessRepository.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
Auth::check();
|
||||
|
||||
if (!Rbac::can('remote_access_view')) {
|
||||
json_out(['ok' => false, 'error' => 'forbidden'], 403);
|
||||
}
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
|
||||
$action = trim((string)($_GET['action'] ?? ''));
|
||||
|
||||
if ($method === 'GET') {
|
||||
if ($action === 'devices') {
|
||||
json_out(['ok' => true, 'devices' => RemoteAccessRepository::listDevices()]);
|
||||
}
|
||||
if ($action === 'sessions') {
|
||||
$filter = trim((string)($_GET['status'] ?? ''));
|
||||
json_out(['ok' => true, 'sessions' => RemoteAccessRepository::listSessions($filter)]);
|
||||
}
|
||||
if ($action === 'events') {
|
||||
json_out(['ok' => true, 'events' => RemoteAccessRepository::listEvents((int)($_GET['limit'] ?? 100))]);
|
||||
}
|
||||
if ($action === 'dashboard') {
|
||||
$payload = RemoteAccessRepository::buildDashboardPayload(
|
||||
rtrim(Auth::basePath(), '/'),
|
||||
'/app/androidcast_project'
|
||||
);
|
||||
json_out([
|
||||
'ok' => true,
|
||||
'devices' => $payload['devices'],
|
||||
'active_sessions' => $payload['active_sessions'],
|
||||
'inactive_sessions' => $payload['inactive_sessions'],
|
||||
'recent_events' => $payload['recent_events'],
|
||||
'permissions' => [
|
||||
'can_operate' => Rbac::can('remote_access_operate'),
|
||||
'can_admin' => Rbac::can('remote_access_admin'),
|
||||
],
|
||||
'config' => [
|
||||
'wg_endpoint' => (string) cfg('remote_access.wg_endpoint', ''),
|
||||
],
|
||||
]);
|
||||
}
|
||||
json_out(['ok' => false, 'error' => 'unknown_action'], 400);
|
||||
}
|
||||
|
||||
if ($method !== 'POST') {
|
||||
json_out(['ok' => false, 'error' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$raw = file_get_contents('php://input') ?: '';
|
||||
$body = json_decode($raw, true);
|
||||
if (!is_array($body)) {
|
||||
json_out(['ok' => false, 'error' => 'invalid_json'], 400);
|
||||
}
|
||||
|
||||
$user = Auth::user();
|
||||
$userId = (int)($user['id'] ?? 0);
|
||||
|
||||
if ($action === 'whitelist') {
|
||||
if (!Rbac::can('remote_access_admin')) {
|
||||
json_out(['ok' => false, 'error' => 'forbidden'], 403);
|
||||
}
|
||||
$deviceId = trim((string)($body['device_id'] ?? ''));
|
||||
if ($deviceId === '') {
|
||||
json_out(['ok' => false, 'error' => 'missing_device_id'], 400);
|
||||
}
|
||||
if (!RemoteAccessRepository::canAccessDevice($deviceId)) {
|
||||
json_out(['ok' => false, 'error' => 'forbidden'], 403);
|
||||
}
|
||||
if (!RemoteAccessRepository::setDeviceWhitelist($deviceId, !empty($body['whitelisted']), $body['notes'] ?? null)) {
|
||||
json_out(['ok' => false, 'error' => 'forbidden'], 403);
|
||||
}
|
||||
RemoteAccessRepository::logEvent($deviceId, null, $userId, 'whitelist_update', null, [
|
||||
'whitelisted' => !empty($body['whitelisted']),
|
||||
], '');
|
||||
json_out(['ok' => true]);
|
||||
}
|
||||
|
||||
if ($action === 'open_session') {
|
||||
if (!Rbac::can('remote_access_operate')) {
|
||||
json_out(['ok' => false, 'error' => 'forbidden'], 403);
|
||||
}
|
||||
$deviceId = trim((string)($body['device_id'] ?? ''));
|
||||
if ($deviceId === '') {
|
||||
json_out(['ok' => false, 'error' => 'missing_device_id'], 400);
|
||||
}
|
||||
if (!RemoteAccessRepository::canAccessDevice($deviceId)) {
|
||||
json_out(['ok' => false, 'error' => 'forbidden'], 403);
|
||||
}
|
||||
$result = RemoteAccessRepository::openSession($deviceId, $userId);
|
||||
if (empty($result['ok'])) {
|
||||
$err = $result['error'] ?? 'failed';
|
||||
json_out(['ok' => false, 'error' => $err], $err === 'forbidden' ? 403 : 400);
|
||||
}
|
||||
json_out(['ok' => true, 'session_id' => $result['session_id'] ?? '']);
|
||||
}
|
||||
|
||||
if ($action === 'close_session') {
|
||||
if (!Rbac::can('remote_access_operate')) {
|
||||
json_out(['ok' => false, 'error' => 'forbidden'], 403);
|
||||
}
|
||||
$sessionId = trim((string)($body['session_id'] ?? ''));
|
||||
if ($sessionId === '') {
|
||||
json_out(['ok' => false, 'error' => 'missing_session_id'], 400);
|
||||
}
|
||||
if (!RemoteAccessRepository::canOperatorCloseSession($sessionId, $userId)) {
|
||||
json_out(['ok' => false, 'error' => 'forbidden'], 403);
|
||||
}
|
||||
RemoteAccessRepository::closeSession($sessionId, RemoteAccessRepository::STATUS_CLOSED, 'operator_closed');
|
||||
RemoteAccessRepository::logEvent((string)($body['device_id'] ?? ''), $sessionId, $userId, 'session_close', 'operator', null, '');
|
||||
json_out(['ok' => true]);
|
||||
}
|
||||
|
||||
json_out(['ok' => false, 'error' => 'unknown_action'], 400);
|
||||
73
sim/cluster0/lab-seeds/backend/public/api/report_tags.php
Normal file
73
sim/cluster0/lab-seeds/backend/public/api/report_tags.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||
|
||||
if (!Auth::user()) {
|
||||
json_out(['ok' => false, 'error' => 'unauthorized'], 401);
|
||||
}
|
||||
|
||||
$method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET');
|
||||
$id = (int) ($_GET['id'] ?? 0);
|
||||
|
||||
if ($method === 'GET') {
|
||||
if ($id <= 0) {
|
||||
json_out(['ok' => false, 'error' => 'invalid id'], 400);
|
||||
}
|
||||
$report = ReportRepository::getById($id);
|
||||
if (!$report) {
|
||||
json_out(['ok' => false, 'error' => 'not found'], 404);
|
||||
}
|
||||
json_out([
|
||||
'ok' => true,
|
||||
'id' => $id,
|
||||
'tags' => $report['tags'] ?? [],
|
||||
'workflow' => TagCatalog::workflowPresets(),
|
||||
'presets' => TagCatalog::editorPresets(),
|
||||
'can_edit' => Auth::canEditTags((int) ($report['company_id'] ?? 0)),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($method !== 'PUT' && $method !== 'POST') {
|
||||
json_out(['ok' => false, 'error' => 'method not allowed'], 405);
|
||||
}
|
||||
|
||||
$raw = file_get_contents('php://input');
|
||||
$data = is_string($raw) && $raw !== '' ? json_decode($raw, true) : $_POST;
|
||||
if (!is_array($data)) {
|
||||
json_out(['ok' => false, 'error' => 'invalid json'], 400);
|
||||
}
|
||||
$id = (int) ($data['id'] ?? $id);
|
||||
if ($id <= 0) {
|
||||
json_out(['ok' => false, 'error' => 'invalid id'], 400);
|
||||
}
|
||||
|
||||
$tagsIn = $data['tags'] ?? [];
|
||||
if (!is_array($tagsIn)) {
|
||||
json_out(['ok' => false, 'error' => 'tags must be array'], 400);
|
||||
}
|
||||
|
||||
$report = ReportRepository::getById($id);
|
||||
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);
|
||||
json_out([
|
||||
'ok' => true,
|
||||
'id' => $id,
|
||||
'tags' => $updated['tags'] ?? [],
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
error_log('report_tags: ' . $e->getMessage());
|
||||
$out = ['ok' => false, 'error' => 'save failed'];
|
||||
if (cfg('debug')) {
|
||||
$out['hint'] = $e->getMessage();
|
||||
}
|
||||
json_out($out, 500);
|
||||
}
|
||||
25
sim/cluster0/lab-seeds/backend/public/api/report_viewed.php
Normal file
25
sim/cluster0/lab-seeds/backend/public/api/report_viewed.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||
|
||||
if (!Auth::user()) {
|
||||
json_out(['ok' => false, 'error' => 'unauthorized'], 401);
|
||||
}
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
json_out(['ok' => false, 'error' => 'POST required'], 405);
|
||||
}
|
||||
|
||||
$raw = file_get_contents('php://input');
|
||||
$data = is_string($raw) && $raw !== '' ? json_decode($raw, true) : $_POST;
|
||||
$id = (int) ($data['id'] ?? 0);
|
||||
if ($id <= 0) {
|
||||
json_out(['ok' => false, 'error' => 'invalid id'], 400);
|
||||
}
|
||||
|
||||
try {
|
||||
ReportRepository::markViewed($id, (int) Auth::user()['id']);
|
||||
json_out(['ok' => true, 'id' => $id]);
|
||||
} catch (Throwable $e) {
|
||||
error_log('report_viewed: ' . $e->getMessage());
|
||||
json_out(['ok' => false, 'error' => 'failed'], 500);
|
||||
}
|
||||
87
sim/cluster0/lab-seeds/backend/public/api/reports.php
Normal file
87
sim/cluster0/lab-seeds/backend/public/api/reports.php
Normal file
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||
|
||||
if (!Auth::user()) {
|
||||
json_out(['ok' => false, 'error' => 'unauthorized'], 401);
|
||||
}
|
||||
|
||||
$grouped = isset($_GET['group']) && $_GET['group'] === '1';
|
||||
$page = max(1, (int) ($_GET['page'] ?? 1));
|
||||
$perPage = (int) ($_GET['per_page'] ?? 50);
|
||||
$allowedPerPage = [25, 50, 75, 100, 200];
|
||||
if (!in_array($perPage, $allowedPerPage, true)) {
|
||||
$perPage = 50;
|
||||
}
|
||||
$sort = (string) ($_GET['sort'] ?? ($grouped ? 'cnt' : 'priority'));
|
||||
$dir = (string) ($_GET['dir'] ?? 'desc');
|
||||
$sinceMs = max(0, (int) ($_GET['since_ms'] ?? 0));
|
||||
$similarTo = max(0, (int) ($_GET['similar_to'] ?? 0));
|
||||
$searchQ = trim((string) ($_GET['q'] ?? ''));
|
||||
$suggest = isset($_GET['suggest']) && $_GET['suggest'] === '1';
|
||||
|
||||
$tagIds = request_tag_filter_ids();
|
||||
$tagMode = request_tag_filter_mode();
|
||||
|
||||
$filters = [];
|
||||
if ($tagIds !== []) {
|
||||
$filters['tags'] = $tagIds;
|
||||
$filters['tag_mode'] = $tagMode;
|
||||
}
|
||||
$device = trim((string) ($_GET['filter_device'] ?? ''));
|
||||
if ($device !== '') {
|
||||
$filters['device'] = $device;
|
||||
}
|
||||
$abi = trim((string) ($_GET['filter_abi'] ?? ''));
|
||||
if ($abi !== '') {
|
||||
$filters['abi'] = $abi;
|
||||
}
|
||||
$osSdk = max(0, (int) ($_GET['filter_os_sdk'] ?? 0));
|
||||
if ($osSdk > 0) {
|
||||
$filters['os_sdk'] = $osSdk;
|
||||
}
|
||||
$osRelease = trim((string) ($_GET['filter_os_release'] ?? ''));
|
||||
if ($osRelease !== '') {
|
||||
$filters['os_release'] = $osRelease;
|
||||
}
|
||||
$appVersion = trim((string) ($_GET['filter_app_version'] ?? ''));
|
||||
if ($appVersion !== '') {
|
||||
$filters['app_version'] = $appVersion;
|
||||
}
|
||||
$build = trim((string) ($_GET['filter_build'] ?? ''));
|
||||
if ($build !== '') {
|
||||
$filters['build'] = $build;
|
||||
}
|
||||
|
||||
try {
|
||||
if ($suggest) {
|
||||
json_out([
|
||||
'ok' => true,
|
||||
'suggestions' => ReportRepository::searchSuggestions($searchQ),
|
||||
'q' => $searchQ,
|
||||
]);
|
||||
}
|
||||
if ($searchQ !== '') {
|
||||
$data = ReportRepository::search($searchQ, $page, $perPage, $sinceMs);
|
||||
} elseif ($similarTo > 0) {
|
||||
$data = ReportRepository::listSimilar($similarTo, $page, $perPage, $sinceMs);
|
||||
} elseif ($filters !== []) {
|
||||
if ($tagIds !== []) {
|
||||
$grouped = false;
|
||||
}
|
||||
$data = ReportRepository::listFiltered($filters, $page, $perPage, $sort, $dir, $sinceMs);
|
||||
} else {
|
||||
$data = ReportRepository::listPage($grouped, $page, $perPage, $sort, $dir, $sinceMs);
|
||||
}
|
||||
json_out(['ok' => true, 'tag_filter' => $tagIds, 'tag_mode' => $tagMode] + $data);
|
||||
} catch (Throwable $e) {
|
||||
error_log('reports api: ' . $e->getMessage());
|
||||
$out = ['ok' => false, 'error' => 'list failed'];
|
||||
if (cfg('debug')) {
|
||||
$out['hint'] = $e->getMessage();
|
||||
if (str_contains($e->getMessage(), '2002')) {
|
||||
$out['hint'] .= ' — use db.mysql.socket (/run/mysqld/mysqld.sock on Alpine); TCP 3306 is closed';
|
||||
}
|
||||
}
|
||||
json_out($out, 500);
|
||||
}
|
||||
18
sim/cluster0/lab-seeds/backend/public/api/sfu_health.php
Normal file
18
sim/cluster0/lab-seeds/backend/public/api/sfu_health.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$sfu = cfg('sfu', []);
|
||||
$enabled = (bool) ($sfu['enabled'] ?? false);
|
||||
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'sfu' => [
|
||||
'enabled' => $enabled,
|
||||
'status' => $enabled ? 'preview' : 'disabled',
|
||||
'signaling_url' => $enabled ? (string) ($sfu['signaling_url'] ?? '') : '',
|
||||
'janus_url' => $enabled ? (string) ($sfu['janus_url'] ?? '') : '',
|
||||
],
|
||||
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
167
sim/cluster0/lab-seeds/backend/public/api/short_links.php
Normal file
167
sim/cluster0/lab-seeds/backend/public/api/short_links.php
Normal file
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
Auth::check();
|
||||
|
||||
if (!Rbac::can('short_links_view')) {
|
||||
json_out(['ok' => false, 'error' => 'forbidden'], 403);
|
||||
}
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
|
||||
$action = trim((string) ($_GET['action'] ?? ''));
|
||||
|
||||
$avail = ShortLinksRepository::availability();
|
||||
if (!$avail['ok']) {
|
||||
json_out([
|
||||
'ok' => false,
|
||||
'error' => 'service_unavailable',
|
||||
'reason' => $avail['error'] ?? 'unknown',
|
||||
], 503);
|
||||
}
|
||||
|
||||
if ($method === 'GET') {
|
||||
if ($action === 'dashboard') {
|
||||
json_out([
|
||||
'ok' => true,
|
||||
'public_base' => UrlShortenerDatabase::publicBase(),
|
||||
'bearers' => ShortLinksRepository::listBearers(),
|
||||
'signers' => ShortLinksRepository::listSigners(),
|
||||
'audit' => ShortLinksRepository::listAudit(40),
|
||||
'permissions' => [
|
||||
'can_operate' => Rbac::can('short_links_operate'),
|
||||
'can_admin' => Rbac::isGlobalAdmin(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
if ($action === 'links') {
|
||||
$bearerId = (int) ($_GET['bearer_id'] ?? 0);
|
||||
if ($bearerId <= 0) {
|
||||
json_out(['ok' => false, 'error' => 'missing_bearer_id'], 400);
|
||||
}
|
||||
json_out([
|
||||
'ok' => true,
|
||||
'links' => ShortLinksRepository::listLinks($bearerId),
|
||||
]);
|
||||
}
|
||||
json_out(['ok' => false, 'error' => 'unknown_action'], 400);
|
||||
}
|
||||
|
||||
if ($method !== 'POST') {
|
||||
json_out(['ok' => false, 'error' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$raw = file_get_contents('php://input') ?: '';
|
||||
$body = json_decode($raw, true);
|
||||
if (!is_array($body)) {
|
||||
json_out(['ok' => false, 'error' => 'invalid_json'], 400);
|
||||
}
|
||||
|
||||
if ($action === 'mint_bearer') {
|
||||
if (!Rbac::can('short_links_operate')) {
|
||||
json_out(['ok' => false, 'error' => 'forbidden'], 403);
|
||||
}
|
||||
$result = ShortLinksRepository::mintBearer(
|
||||
(string) ($body['label'] ?? ''),
|
||||
!array_key_exists('can_temporary', $body) || !empty($body['can_temporary']),
|
||||
!empty($body['can_permanent']),
|
||||
(int) ($body['rate_limit_per_hour'] ?? 1000),
|
||||
!empty($body['mint_signer'])
|
||||
);
|
||||
if (empty($result['ok'])) {
|
||||
json_out(['ok' => false, 'error' => $result['error'] ?? 'failed'], 400);
|
||||
}
|
||||
$resp = [
|
||||
'ok' => true,
|
||||
'bearer_id' => $result['bearer_id'],
|
||||
'token' => $result['token'],
|
||||
];
|
||||
if (isset($result['signer_key'])) {
|
||||
$resp['signer_key'] = $result['signer_key'];
|
||||
$resp['signer_id'] = $result['signer_id'];
|
||||
}
|
||||
json_out($resp);
|
||||
}
|
||||
|
||||
if ($action === 'mint_signer') {
|
||||
if (!Rbac::can('short_links_operate')) {
|
||||
json_out(['ok' => false, 'error' => 'forbidden'], 403);
|
||||
}
|
||||
$result = ShortLinksRepository::mintSigner((string) ($body['label'] ?? ''));
|
||||
if (empty($result['ok'])) {
|
||||
json_out(['ok' => false, 'error' => $result['error'] ?? 'failed'], 400);
|
||||
}
|
||||
json_out([
|
||||
'ok' => true,
|
||||
'signer_id' => $result['id'],
|
||||
'signer_key' => $result['key'],
|
||||
]);
|
||||
}
|
||||
|
||||
if ($action === 'revoke_signer') {
|
||||
if (!Rbac::can('short_links_operate')) {
|
||||
json_out(['ok' => false, 'error' => 'forbidden'], 403);
|
||||
}
|
||||
$signerId = (int) ($body['signer_id'] ?? 0);
|
||||
if ($signerId <= 0) {
|
||||
json_out(['ok' => false, 'error' => 'missing_signer_id'], 400);
|
||||
}
|
||||
if (!ShortLinksRepository::revokeSigner($signerId)) {
|
||||
json_out(['ok' => false, 'error' => 'not_found_or_revoked'], 404);
|
||||
}
|
||||
json_out(['ok' => true]);
|
||||
}
|
||||
|
||||
if ($action === 'revoke_bearer') {
|
||||
if (!Rbac::can('short_links_operate')) {
|
||||
json_out(['ok' => false, 'error' => 'forbidden'], 403);
|
||||
}
|
||||
$bearerId = (int) ($body['bearer_id'] ?? 0);
|
||||
if ($bearerId <= 0) {
|
||||
json_out(['ok' => false, 'error' => 'missing_bearer_id'], 400);
|
||||
}
|
||||
if (!ShortLinksRepository::revokeBearer($bearerId)) {
|
||||
json_out(['ok' => false, 'error' => 'not_found_or_revoked'], 404);
|
||||
}
|
||||
json_out(['ok' => true]);
|
||||
}
|
||||
|
||||
if ($action === 'create_link') {
|
||||
if (!Rbac::can('short_links_operate')) {
|
||||
json_out(['ok' => false, 'error' => 'forbidden'], 403);
|
||||
}
|
||||
$bearerId = (int) ($body['bearer_id'] ?? 0);
|
||||
$url = (string) ($body['url'] ?? '');
|
||||
$ttl = (int) ($body['ttl'] ?? 86400);
|
||||
if ($bearerId <= 0 || trim($url) === '') {
|
||||
json_out(['ok' => false, 'error' => 'missing_fields'], 400);
|
||||
}
|
||||
$result = ShortLinksRepository::createLink(
|
||||
$bearerId,
|
||||
$url,
|
||||
$ttl,
|
||||
(string) ($body['signer'] ?? '')
|
||||
);
|
||||
if (empty($result['ok'])) {
|
||||
$code = match ($result['error'] ?? '') {
|
||||
'rate_limited' => 429,
|
||||
'ttl_expired' => 409,
|
||||
'signer_required' => 403,
|
||||
default => 400,
|
||||
};
|
||||
json_out(['ok' => false, 'error' => $result['error'] ?? 'failed', 'message' => $result['message'] ?? ''], $code);
|
||||
}
|
||||
json_out(['ok' => true, 'link' => $result]);
|
||||
}
|
||||
|
||||
if ($action === 'purge_expired') {
|
||||
if (!Rbac::isGlobalAdmin()) {
|
||||
json_out(['ok' => false, 'error' => 'forbidden'], 403);
|
||||
}
|
||||
$counts = ShortLinksRepository::purgeExpired();
|
||||
json_out(['ok' => true, 'purged' => $counts]);
|
||||
}
|
||||
|
||||
json_out(['ok' => false, 'error' => 'unknown_action'], 400);
|
||||
15
sim/cluster0/lab-seeds/backend/public/api/tag_catalog.php
Normal file
15
sim/cluster0/lab-seeds/backend/public/api/tag_catalog.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||
|
||||
if (!Auth::user()) {
|
||||
json_out(['ok' => false, 'error' => 'unauthorized'], 401);
|
||||
}
|
||||
|
||||
json_out([
|
||||
'ok' => true,
|
||||
'workflow' => TagCatalog::workflowPresets(),
|
||||
'presets' => TagCatalog::editorPresets(),
|
||||
'filter_mode_default' => 'and',
|
||||
'filter_mode_help' => 'AND = report has all listed tags; OR = any listed tag',
|
||||
]);
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||
|
||||
if (!Auth::user()) {
|
||||
http_response_code(401);
|
||||
echo 'Unauthorized';
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
Database::requireTicketsTable();
|
||||
} catch (RuntimeException $e) {
|
||||
http_response_code(503);
|
||||
echo 'Tickets unavailable';
|
||||
exit;
|
||||
}
|
||||
|
||||
$id = (int) ($_GET['id'] ?? 0);
|
||||
if ($id <= 0) {
|
||||
http_response_code(400);
|
||||
exit;
|
||||
}
|
||||
|
||||
$att = TicketAttachmentRepository::getById($id);
|
||||
if (!$att || ($att['kind'] ?? '') !== 'file') {
|
||||
http_response_code(404);
|
||||
exit;
|
||||
}
|
||||
|
||||
$path = TicketAttachmentRepository::filePath($att);
|
||||
if ($path === null) {
|
||||
http_response_code(404);
|
||||
exit;
|
||||
}
|
||||
|
||||
$name = (string) ($att['original_name'] ?? 'download');
|
||||
$mime = (string) ($att['mime_type'] ?? 'application/octet-stream');
|
||||
header('Content-Type: ' . $mime);
|
||||
header('Content-Length: ' . (string) filesize($path));
|
||||
header('Content-Disposition: attachment; filename="' . str_replace('"', '', $name) . '"');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
readfile($path);
|
||||
109
sim/cluster0/lab-seeds/backend/public/api/ticket_attachments.php
Normal file
109
sim/cluster0/lab-seeds/backend/public/api/ticket_attachments.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||
|
||||
if (!Auth::user()) {
|
||||
json_out(['ok' => false, 'error' => 'unauthorized'], 401);
|
||||
}
|
||||
|
||||
try {
|
||||
Database::requireTicketsTable();
|
||||
} catch (RuntimeException $e) {
|
||||
json_out(['ok' => false, 'error' => 'tickets_table_missing', 'hint' => $e->getMessage()], 503);
|
||||
}
|
||||
|
||||
$user = Auth::user();
|
||||
$method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET');
|
||||
|
||||
if ($method === 'GET') {
|
||||
$ticketId = (int) ($_GET['ticket_id'] ?? 0);
|
||||
if ($ticketId <= 0) {
|
||||
json_out(['ok' => false, 'error' => 'invalid ticket_id'], 400);
|
||||
}
|
||||
try {
|
||||
json_out(['ok' => true, 'attachments' => TicketAttachmentRepository::listForTicket($ticketId)]);
|
||||
} catch (Throwable $e) {
|
||||
if (str_contains($e->getMessage(), 'ticket_attachments') || str_contains($e->getMessage(), 'no such table')) {
|
||||
json_out(['ok' => false, 'error' => 'attachments_table_missing', 'hint' => 'Run sql/migrations/005_ticket_attachments.sql'], 503);
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
if ($method === 'DELETE') {
|
||||
$id = (int) ($_GET['id'] ?? 0);
|
||||
if ($id <= 0) {
|
||||
json_out(['ok' => false, 'error' => 'invalid id'], 400);
|
||||
}
|
||||
try {
|
||||
TicketAttachmentRepository::delete($id, (string) ($user['username'] ?? ''));
|
||||
json_out(['ok' => true]);
|
||||
} catch (RuntimeException $e) {
|
||||
$code = $e->getMessage() === 'forbidden' ? 403 : 404;
|
||||
json_out(['ok' => false, 'error' => $e->getMessage()], $code);
|
||||
} catch (Throwable $e) {
|
||||
if (str_contains($e->getMessage(), 'ticket_attachments')) {
|
||||
json_out(['ok' => false, 'error' => 'attachments_table_missing'], 503);
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
if ($method !== 'POST') {
|
||||
json_out(['ok' => false, 'error' => 'method not allowed'], 405);
|
||||
}
|
||||
|
||||
$ticketId = (int) ($_POST['ticket_id'] ?? 0);
|
||||
if ($ticketId <= 0) {
|
||||
$raw = file_get_contents('php://input');
|
||||
$data = is_string($raw) && $raw !== '' ? json_decode($raw, true) : null;
|
||||
if (is_array($data)) {
|
||||
$ticketId = (int) ($data['ticket_id'] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
if ($ticketId <= 0) {
|
||||
json_out(['ok' => false, 'error' => 'invalid ticket_id'], 400);
|
||||
}
|
||||
|
||||
$ticket = TicketRepository::getById($ticketId);
|
||||
if (!$ticket) {
|
||||
json_out(['ok' => false, 'error' => 'not found'], 404);
|
||||
}
|
||||
// Any logged-in user who can view the ticket may add attachments (same as comments).
|
||||
|
||||
try {
|
||||
if (!empty($_FILES['file']['name'])) {
|
||||
$att = TicketAttachmentRepository::addFile(
|
||||
$ticketId,
|
||||
(string) ($user['username'] ?? ''),
|
||||
$_FILES['file'],
|
||||
trim((string) ($_POST['title'] ?? ''))
|
||||
);
|
||||
json_out(['ok' => true, 'attachment' => $att]);
|
||||
}
|
||||
$raw = file_get_contents('php://input');
|
||||
$data = is_string($raw) && $raw !== '' ? json_decode($raw, true) : $_POST;
|
||||
if (!is_array($data)) {
|
||||
json_out(['ok' => false, 'error' => 'file or url required'], 400);
|
||||
}
|
||||
$url = trim((string) ($data['url'] ?? $data['external_url'] ?? ''));
|
||||
if ($url === '') {
|
||||
json_out(['ok' => false, 'error' => 'url required'], 400);
|
||||
}
|
||||
$att = TicketAttachmentRepository::addLink(
|
||||
$ticketId,
|
||||
(string) ($user['username'] ?? ''),
|
||||
$url,
|
||||
trim((string) ($data['title'] ?? ''))
|
||||
);
|
||||
json_out(['ok' => true, 'attachment' => $att]);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
json_out(['ok' => false, 'error' => $e->getMessage()], 400);
|
||||
} catch (Throwable $e) {
|
||||
if (str_contains($e->getMessage(), 'ticket_attachments')) {
|
||||
json_out(['ok' => false, 'error' => 'attachments_table_missing'], 503);
|
||||
}
|
||||
error_log('ticket_attachments: ' . $e->getMessage());
|
||||
json_out(['ok' => false, 'error' => 'save failed'], 500);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||
|
||||
if (!Auth::user()) {
|
||||
json_out(['ok' => false, 'error' => 'unauthorized'], 401);
|
||||
}
|
||||
|
||||
try {
|
||||
Database::requireTicketsTable();
|
||||
} catch (RuntimeException $e) {
|
||||
json_out(['ok' => false, 'error' => 'tickets_table_missing', 'hint' => $e->getMessage()], 503);
|
||||
}
|
||||
|
||||
$method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET');
|
||||
|
||||
if ($method === 'GET') {
|
||||
$ticketId = (int) ($_GET['ticket_id'] ?? 0);
|
||||
if ($ticketId <= 0) {
|
||||
json_out(['ok' => false, 'error' => 'invalid ticket_id'], 400);
|
||||
}
|
||||
$ticket = TicketRepository::getById($ticketId);
|
||||
if (!$ticket) {
|
||||
json_out(['ok' => false, 'error' => 'not found'], 404);
|
||||
}
|
||||
try {
|
||||
json_out(['ok' => true, 'comments' => TicketCommentRepository::listForTicket($ticketId)]);
|
||||
} catch (Throwable $e) {
|
||||
if (str_contains($e->getMessage(), 'ticket_comments') || str_contains($e->getMessage(), 'no such table')) {
|
||||
json_out(['ok' => false, 'error' => 'comments_table_missing', 'hint' => 'Run sql/migrations/004_ticket_workflow.sql'], 503);
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
if ($method !== 'POST') {
|
||||
json_out(['ok' => false, 'error' => 'method not allowed'], 405);
|
||||
}
|
||||
|
||||
$user = Auth::user();
|
||||
$raw = file_get_contents('php://input');
|
||||
$data = is_string($raw) && $raw !== '' ? json_decode($raw, true) : $_POST;
|
||||
if (!is_array($data)) {
|
||||
json_out(['ok' => false, 'error' => 'invalid json'], 400);
|
||||
}
|
||||
|
||||
$ticketId = (int) ($data['ticket_id'] ?? 0);
|
||||
if ($ticketId <= 0) {
|
||||
json_out(['ok' => false, 'error' => 'invalid ticket_id'], 400);
|
||||
}
|
||||
|
||||
$ticket = TicketRepository::getById($ticketId);
|
||||
if (!$ticket) {
|
||||
json_out(['ok' => false, 'error' => 'not found'], 404);
|
||||
}
|
||||
if (empty($ticket['can_edit']) && !Auth::canEditTags()) {
|
||||
json_out(['ok' => false, 'error' => 'forbidden'], 403);
|
||||
}
|
||||
|
||||
try {
|
||||
$comment = TicketCommentRepository::add(
|
||||
$ticketId,
|
||||
(string) ($user['username'] ?? ''),
|
||||
(string) ($data['body'] ?? '')
|
||||
);
|
||||
json_out(['ok' => true, 'comment' => $comment]);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
json_out(['ok' => false, 'error' => $e->getMessage()], 400);
|
||||
} catch (Throwable $e) {
|
||||
if (str_contains($e->getMessage(), 'ticket_comments') || str_contains($e->getMessage(), 'no such table')) {
|
||||
json_out(['ok' => false, 'error' => 'comments_table_missing', 'hint' => 'Run sql/migrations/004_ticket_workflow.sql'], 503);
|
||||
}
|
||||
error_log('ticket_comments: ' . $e->getMessage());
|
||||
json_out(['ok' => false, 'error' => 'save failed'], 500);
|
||||
}
|
||||
65
sim/cluster0/lab-seeds/backend/public/api/ticket_create.php
Normal file
65
sim/cluster0/lab-seeds/backend/public/api/ticket_create.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||
|
||||
if (!Auth::user()) {
|
||||
json_out(['ok' => false, 'error' => 'unauthorized'], 401);
|
||||
}
|
||||
|
||||
$raw = file_get_contents('php://input');
|
||||
$data = json_decode($raw !== false ? $raw : '', true);
|
||||
if (!is_array($data)) {
|
||||
json_out(['ok' => false, 'error' => 'invalid json'], 400);
|
||||
}
|
||||
|
||||
$user = Auth::user();
|
||||
$title = trim((string) ($data['title'] ?? ''));
|
||||
$body = trim((string) ($data['body'] ?? ''));
|
||||
$brief = trim((string) ($data['brief'] ?? ''));
|
||||
if ($title === '') {
|
||||
json_out(['ok' => false, 'error' => 'title required'], 400);
|
||||
}
|
||||
if ($body === '' && $brief !== '') {
|
||||
$body = $brief;
|
||||
}
|
||||
|
||||
$tags = is_array($data['tags'] ?? null) ? $data['tags'] : [];
|
||||
if ($tags === []) {
|
||||
$tags = [['id' => 'open', 'label' => 'open', 'bg' => '#22c55e']];
|
||||
}
|
||||
|
||||
$now = (int) round(microtime(true) * 1000);
|
||||
$payload = [
|
||||
'schema_version' => 1,
|
||||
'ingest_kind' => 'ticket',
|
||||
'ticket_id' => 'web-' . date('Ymd-His') . '-' . bin2hex(random_bytes(4)),
|
||||
'title' => mb_substr($title, 0, 256),
|
||||
'brief' => $brief !== '' ? mb_substr($brief, 0, 512) : mb_substr($title, 0, 512),
|
||||
'body' => $body !== '' ? $body : $title,
|
||||
'opened_at_epoch_ms' => $now,
|
||||
'rating' => max(0, min(5, (int) ($data['rating'] ?? 3))),
|
||||
'owner' => (string) ($user['username'] ?? 'admin'),
|
||||
'tags' => $tags,
|
||||
'device' => [
|
||||
'manufacturer' => 'web',
|
||||
'model' => 'console',
|
||||
'sdk_int' => 0,
|
||||
'release' => 'n/a',
|
||||
],
|
||||
'app' => [
|
||||
'package' => 'com.foxx.androidcast',
|
||||
'version_name' => 'console',
|
||||
'version_code' => 0,
|
||||
],
|
||||
];
|
||||
|
||||
try {
|
||||
Database::requireTicketsTable();
|
||||
$id = TicketRepository::insert($payload);
|
||||
json_out(['ok' => true, 'id' => $id, 'ticket_id' => $payload['ticket_id']]);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
json_out(['ok' => false, 'error' => $e->getMessage()], 400);
|
||||
} catch (Throwable $e) {
|
||||
error_log('ticket_create: ' . $e->getMessage());
|
||||
json_out(['ok' => false, 'error' => 'create failed'], 500);
|
||||
}
|
||||
75
sim/cluster0/lab-seeds/backend/public/api/ticket_tags.php
Normal file
75
sim/cluster0/lab-seeds/backend/public/api/ticket_tags.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||
|
||||
if (!Auth::user()) {
|
||||
json_out(['ok' => false, 'error' => 'unauthorized'], 401);
|
||||
}
|
||||
|
||||
try {
|
||||
Database::requireTicketsTable();
|
||||
} catch (RuntimeException $e) {
|
||||
json_out(['ok' => false, 'error' => 'tickets_table_missing', 'hint' => $e->getMessage()], 503);
|
||||
}
|
||||
|
||||
$method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET');
|
||||
$id = (int) ($_GET['id'] ?? 0);
|
||||
|
||||
if ($method === 'GET') {
|
||||
if ($id <= 0) {
|
||||
json_out(['ok' => false, 'error' => 'invalid id'], 400);
|
||||
}
|
||||
$ticket = TicketRepository::getById($id);
|
||||
if (!$ticket) {
|
||||
json_out(['ok' => false, 'error' => 'not found'], 404);
|
||||
}
|
||||
json_out([
|
||||
'ok' => true,
|
||||
'id' => $id,
|
||||
'tags' => $ticket['tags'] ?? [],
|
||||
'presets' => TicketRepository::listTagPresets(),
|
||||
'can_edit' => !empty($ticket['can_edit']),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($method !== 'PUT' && $method !== 'POST') {
|
||||
json_out(['ok' => false, 'error' => 'method not allowed'], 405);
|
||||
}
|
||||
|
||||
$raw = file_get_contents('php://input');
|
||||
$data = is_string($raw) && $raw !== '' ? json_decode($raw, true) : $_POST;
|
||||
if (!is_array($data)) {
|
||||
json_out(['ok' => false, 'error' => 'invalid json'], 400);
|
||||
}
|
||||
$id = (int) ($data['id'] ?? $id);
|
||||
if ($id <= 0) {
|
||||
json_out(['ok' => false, 'error' => 'invalid id'], 400);
|
||||
}
|
||||
|
||||
$ticket = TicketRepository::getById($id);
|
||||
if (!$ticket) {
|
||||
json_out(['ok' => false, 'error' => 'not found'], 404);
|
||||
}
|
||||
if (empty($ticket['can_edit'])) {
|
||||
json_out(['ok' => false, 'error' => 'forbidden'], 403);
|
||||
}
|
||||
|
||||
$tagsIn = $data['tags'] ?? [];
|
||||
if (!is_array($tagsIn)) {
|
||||
json_out(['ok' => false, 'error' => 'tags must be array'], 400);
|
||||
}
|
||||
|
||||
try {
|
||||
TicketRepository::setCustomTags($id, $tagsIn);
|
||||
$updated = TicketRepository::getById($id);
|
||||
json_out([
|
||||
'ok' => true,
|
||||
'id' => $id,
|
||||
'tags' => $updated['tags'] ?? [],
|
||||
]);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
json_out(['ok' => false, 'error' => $e->getMessage()], 400);
|
||||
} catch (Throwable $e) {
|
||||
error_log('ticket_tags: ' . $e->getMessage());
|
||||
json_out(['ok' => false, 'error' => 'save failed'], 500);
|
||||
}
|
||||
56
sim/cluster0/lab-seeds/backend/public/api/ticket_update.php
Normal file
56
sim/cluster0/lab-seeds/backend/public/api/ticket_update.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||
|
||||
if (!Auth::user()) {
|
||||
json_out(['ok' => false, 'error' => 'unauthorized'], 401);
|
||||
}
|
||||
|
||||
try {
|
||||
Database::requireTicketsTable();
|
||||
} catch (RuntimeException $e) {
|
||||
json_out(['ok' => false, 'error' => 'tickets_table_missing', 'hint' => $e->getMessage()], 503);
|
||||
}
|
||||
|
||||
if (strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'PUT' && strtoupper($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
|
||||
json_out(['ok' => false, 'error' => 'method not allowed'], 405);
|
||||
}
|
||||
|
||||
$raw = file_get_contents('php://input');
|
||||
$data = is_string($raw) && $raw !== '' ? json_decode($raw, true) : $_POST;
|
||||
if (!is_array($data)) {
|
||||
json_out(['ok' => false, 'error' => 'invalid json'], 400);
|
||||
}
|
||||
$id = (int) ($data['id'] ?? 0);
|
||||
if ($id <= 0) {
|
||||
json_out(['ok' => false, 'error' => 'invalid id'], 400);
|
||||
}
|
||||
|
||||
$ticket = TicketRepository::getById($id);
|
||||
if (!$ticket) {
|
||||
json_out(['ok' => false, 'error' => 'not found'], 404);
|
||||
}
|
||||
if (empty($ticket['can_edit'])) {
|
||||
json_out(['ok' => false, 'error' => 'forbidden'], 403);
|
||||
}
|
||||
|
||||
$fields = [];
|
||||
foreach (['title', 'brief', 'body', 'rating', 'owner_username', 'verified_by_username', 'workflow_state'] as $key) {
|
||||
if (array_key_exists($key, $data)) {
|
||||
$fields[$key] = $data[$key];
|
||||
}
|
||||
}
|
||||
if (array_key_exists('assignees', $data)) {
|
||||
$fields['assignees'] = $data['assignees'];
|
||||
}
|
||||
|
||||
try {
|
||||
TicketRepository::update($id, $fields);
|
||||
$updated = TicketRepository::getById($id);
|
||||
json_out(['ok' => true, 'ticket' => $updated]);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
json_out(['ok' => false, 'error' => $e->getMessage()], 400);
|
||||
} catch (Throwable $e) {
|
||||
error_log('ticket_update: ' . $e->getMessage());
|
||||
json_out(['ok' => false, 'error' => 'save failed'], 500);
|
||||
}
|
||||
46
sim/cluster0/lab-seeds/backend/public/api/ticket_upload.php
Normal file
46
sim/cluster0/lab-seeds/backend/public/api/ticket_upload.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||
|
||||
$raw = file_get_contents('php://input');
|
||||
if ($raw === false || $raw === '') {
|
||||
json_out(['ok' => false, 'error' => 'empty body'], 400);
|
||||
}
|
||||
$data = json_decode($raw, true);
|
||||
if (!is_array($data)) {
|
||||
json_out(['ok' => false, 'error' => 'invalid json'], 400);
|
||||
}
|
||||
if ((int) ($data['schema_version'] ?? 0) !== 1) {
|
||||
json_out(['ok' => false, 'error' => 'unsupported schema_version'], 400);
|
||||
}
|
||||
if (($data['ingest_kind'] ?? 'ticket') !== 'ticket') {
|
||||
json_out(['ok' => false, 'error' => 'ingest_kind must be ticket'], 400);
|
||||
}
|
||||
try {
|
||||
Database::requireTicketsTable();
|
||||
TicketRepository::insert($data);
|
||||
json_out(['ok' => true, 'ticket_id' => $data['ticket_id'] ?? null]);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
json_out(['ok' => false, 'error' => $e->getMessage()], 400);
|
||||
} catch (RuntimeException $e) {
|
||||
error_log('ticket upload: ' . $e->getMessage());
|
||||
json_out(['ok' => false, 'error' => 'tickets_table_missing', 'hint' => $e->getMessage()], 503);
|
||||
} catch (PDOException $e) {
|
||||
$msg = $e->getMessage();
|
||||
if ($e->getCode() === '23000' || stripos($msg, 'UNIQUE') !== false) {
|
||||
json_out(['ok' => false, 'error' => 'duplicate ticket_id'], 409);
|
||||
}
|
||||
error_log('ticket upload PDO: ' . $msg);
|
||||
$out = ['ok' => false, 'error' => 'store failed'];
|
||||
if (cfg('debug')) {
|
||||
$out['hint'] = $msg;
|
||||
}
|
||||
json_out($out, 500);
|
||||
} catch (Throwable $e) {
|
||||
error_log('ticket upload: ' . $e->getMessage());
|
||||
$out = ['ok' => false, 'error' => 'store failed'];
|
||||
if (cfg('debug')) {
|
||||
$out['hint'] = $e->getMessage();
|
||||
}
|
||||
json_out($out, 500);
|
||||
}
|
||||
49
sim/cluster0/lab-seeds/backend/public/api/tickets.php
Normal file
49
sim/cluster0/lab-seeds/backend/public/api/tickets.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||
|
||||
if (!Auth::user()) {
|
||||
json_out(['ok' => false, 'error' => 'unauthorized'], 401);
|
||||
}
|
||||
|
||||
$page = max(1, (int) ($_GET['page'] ?? 1));
|
||||
$perPage = (int) ($_GET['per_page'] ?? 50);
|
||||
if (!in_array($perPage, [25, 50, 75, 100, 200], true)) {
|
||||
$perPage = 50;
|
||||
}
|
||||
$sort = (string) ($_GET['sort'] ?? 'opened_at_ms');
|
||||
$dir = (string) ($_GET['dir'] ?? 'desc');
|
||||
$tag = trim((string) ($_GET['tag'] ?? ''));
|
||||
|
||||
try {
|
||||
Database::requireTicketsTable();
|
||||
$data = TicketRepository::listPage($page, $perPage, $sort, $dir, $tag);
|
||||
json_out(['ok' => true] + $data);
|
||||
} catch (RuntimeException $e) {
|
||||
error_log('tickets api: ' . $e->getMessage());
|
||||
$out = [
|
||||
'ok' => false,
|
||||
'error' => 'tickets_table_missing',
|
||||
'hint' => $e->getMessage(),
|
||||
];
|
||||
if (cfg('debug')) {
|
||||
try {
|
||||
$pdo = Database::pdo();
|
||||
$out['db_driver'] = Database::driver();
|
||||
$out['tickets_table'] = Database::ticketsTableExists($pdo);
|
||||
if (Database::isMysql()) {
|
||||
$out['mysql_database'] = $pdo->query('SELECT DATABASE()')->fetchColumn();
|
||||
}
|
||||
} catch (Throwable $diag) {
|
||||
$out['diag_error'] = $diag->getMessage();
|
||||
}
|
||||
}
|
||||
json_out($out, 503);
|
||||
} catch (Throwable $e) {
|
||||
error_log('tickets api: ' . $e->getMessage());
|
||||
$out = ['ok' => false, 'error' => 'list failed'];
|
||||
if (cfg('debug')) {
|
||||
$out['hint'] = $e->getMessage();
|
||||
}
|
||||
json_out($out, 500);
|
||||
}
|
||||
53
sim/cluster0/lab-seeds/backend/public/api/upload.php
Normal file
53
sim/cluster0/lab-seeds/backend/public/api/upload.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
/*
|
||||
* package examples/crash_reporter/backend/public/api/upload.php
|
||||
* upload.php
|
||||
* Created at: Wed 20 May 2026 14:31:55 +0200
|
||||
* Updated at: Wed 20 May 2026 15:17:13 +0200 by Anton Afanasyeu <a.afanasieff@gmail.com>
|
||||
* Commit: 5d8e82d2e60a21fff3138d2a394ee4e8b4c6dcb8
|
||||
* Contributors:
|
||||
* - Anton Afanasyeu <a.afanasieff@gmail.com> (2 commits, 25 lines)
|
||||
* - Cursor Agent (project assistant)
|
||||
* Digest: SHA256 edbab2563cb9d5ba1c9a6a6272071ac12efa782274c7d1626f088a951033d5a6
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||
|
||||
$raw = read_crash_upload_body();
|
||||
if ($raw === null) {
|
||||
json_out(['ok' => false, 'error' => 'empty body'], 400);
|
||||
}
|
||||
if ($raw === false) {
|
||||
json_out(['ok' => false, 'error' => 'invalid gzip body'], 400);
|
||||
}
|
||||
$data = json_decode($raw, true);
|
||||
if (!is_array($data)) {
|
||||
json_out(['ok' => false, 'error' => 'invalid json'], 400);
|
||||
}
|
||||
if ((int) ($data['schema_version'] ?? 0) !== 1) {
|
||||
json_out(['ok' => false, 'error' => 'unsupported schema_version'], 400);
|
||||
}
|
||||
try {
|
||||
ReportRepository::insert($data);
|
||||
json_out(['ok' => true, 'report_id' => $data['report_id'] ?? null]);
|
||||
} catch (PDOException $e) {
|
||||
$msg = $e->getMessage();
|
||||
if ($e->getCode() === '23000' || stripos($msg, 'UNIQUE') !== false) {
|
||||
json_out(['ok' => false, 'error' => 'duplicate report_id'], 409);
|
||||
}
|
||||
error_log('crash upload PDO: ' . $msg);
|
||||
log_crash_event('upload', 'PDO: ' . $msg);
|
||||
$out = ['ok' => false, 'error' => 'store failed'];
|
||||
if (cfg('debug')) {
|
||||
$out['hint'] = $msg;
|
||||
}
|
||||
json_out($out, 500);
|
||||
} catch (Throwable $e) {
|
||||
error_log('crash upload: ' . $e->getMessage());
|
||||
log_crash_event('upload', $e->getMessage());
|
||||
$out = ['ok' => false, 'error' => 'store failed'];
|
||||
if (cfg('debug')) {
|
||||
$out['hint'] = $e->getMessage();
|
||||
}
|
||||
json_out($out, 500);
|
||||
}
|
||||
9
sim/cluster0/lab-seeds/backend/public/api/users.php
Normal file
9
sim/cluster0/lab-seeds/backend/public/api/users.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||
|
||||
if (!Auth::user()) {
|
||||
json_out(['ok' => false, 'error' => 'unauthorized'], 401);
|
||||
}
|
||||
|
||||
json_out(['ok' => true, 'users' => UserRepository::listForAssignees()]);
|
||||
2423
sim/cluster0/lab-seeds/backend/public/assets/css/app.css
Normal file
2423
sim/cluster0/lab-seeds/backend/public/assets/css/app.css
Normal file
File diff suppressed because it is too large
Load Diff
218
sim/cluster0/lab-seeds/backend/public/assets/i18n/en.json
Normal file
218
sim/cluster0/lab-seeds/backend/public/assets/i18n/en.json
Normal file
@@ -0,0 +1,218 @@
|
||||
{
|
||||
"app_name": "Android Cast Issues",
|
||||
"nav.home": "Home",
|
||||
"nav.reports": "Issues",
|
||||
"nav.tickets": "Tickets",
|
||||
"nav.logout": "Logout",
|
||||
"nav.toggle": "Navigation",
|
||||
"nav.home_desc": "Console home and workspace cards.",
|
||||
"nav.reports_desc": "Browse and triage issues.",
|
||||
"nav.tickets_desc": "Roadmap tasks, QA items, and tags.",
|
||||
"nav.graphs": "Graphs",
|
||||
"nav.graphs_desc": "Sessions, issues, and device activity.",
|
||||
"nav.remote": "Remote access",
|
||||
"nav.remote_desc": "WireGuard sessions and device reachability.",
|
||||
"nav.access": "Access",
|
||||
"nav.access_desc": "Roles, privilege sets, and operators.",
|
||||
"nav.builder": "Builder",
|
||||
"nav.builder_desc": "Docker CI for APK baking and OTA artifacts.",
|
||||
"theme.label": "Theme",
|
||||
"theme.dark": "Dark",
|
||||
"theme.light": "Light",
|
||||
"lang.label": "Language",
|
||||
"lang.en": "English",
|
||||
"lang.ru": "Russian",
|
||||
"home.title": "Console",
|
||||
"home.intro": "Issue triage, tickets, and session metrics — pick a workspace below.",
|
||||
"home.card_issues": "Issues",
|
||||
"home.card_issues_desc": "Browse and triage issues, tags, and fingerprints.",
|
||||
"home.card_new_issue_desc": "Log a new issue or task in one step.",
|
||||
"home.card_new_ticket_desc": "Create a roadmap or QA ticket in one step.",
|
||||
"home.card_tickets_desc": "Roadmap tasks, QA items, and workflow tags.",
|
||||
"home.card_graphs": "Graphs",
|
||||
"home.card_graphs_desc": "Sessions, issues, and device activity over time.",
|
||||
"home.card_short_links": "Short links",
|
||||
"home.card_short_links_desc": "Mint bearer tokens and shorten URLs for s.f0xx.org.",
|
||||
"home.more": "All services",
|
||||
"reports.title": "Issues",
|
||||
"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",
|
||||
"graphs.brick_drag": "Drag chart to reorder",
|
||||
"graphs.session_deficit": "Session charts are empty. On the device: Developer settings → enable “Grab session stats”, cast a session, then wait for upload to graph_upload.php.",
|
||||
"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",
|
||||
"tag.workflow": "Workflow",
|
||||
"tag.filter_label": "Filter by tag",
|
||||
"tag.filter_mode": "Match",
|
||||
"tag.filter_and": "All tags (AND)",
|
||||
"tag.filter_or": "Any tag (OR)",
|
||||
"detail.back": "Back to list",
|
||||
"detail.title": "Issue 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 issue",
|
||||
"detail.find_similar": "Find similar issues",
|
||||
"detail.thread": "Thread:",
|
||||
"detail.signal": "Signal:",
|
||||
"detail.session_context": "Session context (issue)",
|
||||
"detail.raw_json": "Raw JSON",
|
||||
"login.sign_in_hint": "Sign in to view anonymous issue 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",
|
||||
"register.title": "Create account",
|
||||
"register.hint": "We will email a verification link before you can sign in.",
|
||||
"register.email": "Email",
|
||||
"register.username": "Username (optional)",
|
||||
"register.password": "Password",
|
||||
"register.submit": "Register",
|
||||
"register.back_login": "Back to sign in",
|
||||
"verify.title": "Email verification",
|
||||
"verify.ok": "Your email is verified. You can sign in and enroll two-factor authentication.",
|
||||
"verify.fail": "Invalid or expired link.",
|
||||
"verify.sign_in": "Sign in",
|
||||
"verify.register_again": "Register again",
|
||||
"twofa.title": "Two-factor authentication",
|
||||
"twofa.hint": "Enter the 6-digit code from your authenticator app.",
|
||||
"twofa.code": "Authentication code",
|
||||
"twofa.submit": "Continue",
|
||||
"twofa.cancel": "Cancel",
|
||||
"twofa.error": "Invalid code",
|
||||
"security.title": "Account security",
|
||||
"security.totp_intro": "Protect your account with a 6-digit authenticator app (recommended for alpha).",
|
||||
"security.start_totp": "Set up authenticator",
|
||||
"security.scan_qr": "Scan this QR code with your authenticator app, then enter a code to confirm.",
|
||||
"security.manual_secret": "Can't scan?",
|
||||
"security.confirm_totp": "Confirm enrollment",
|
||||
"security.totp_enabled": "Authenticator app is enrolled.",
|
||||
"security.remove_totp": "Remove authenticator",
|
||||
"security.back_console": "Back to console",
|
||||
"nav.security": "Security",
|
||||
"footer.copyright": "© Anton Afanaasyeu, {year}",
|
||||
"tickets.title": "Tickets",
|
||||
"issues.new": "New issue",
|
||||
"tickets.new": "New ticket",
|
||||
"ticket.create": "Create",
|
||||
"ticket.cancel": "Cancel",
|
||||
"ticket.title": "Title",
|
||||
"ticket.brief": "Brief",
|
||||
"ticket.body": "Description",
|
||||
"tickets.empty": "No tickets yet.",
|
||||
"tickets.col_issue": "Issue",
|
||||
"tickets.col_opened": "Opened",
|
||||
"tickets.col_env": "App / OS",
|
||||
"tickets.filter_tag": "Status tag",
|
||||
"tickets.filter_all": "All",
|
||||
"ticket.back": "Back to tickets",
|
||||
"ticket.detail_title": "Ticket",
|
||||
"ticket.id_label": "ID",
|
||||
"ticket.tag_hint": "Must include ticket and a status tag (open, in progress, closed, …).",
|
||||
"ticket.tag_readonly": "Only the owner or an admin can edit tags.",
|
||||
"ticket.issue": "Issue",
|
||||
"ticket.caption": "Caption",
|
||||
"ticket.summary": "Summary",
|
||||
"ticket.description": "Description",
|
||||
"ticket.workflow": "Workflow",
|
||||
"ticket.owner": "Owner",
|
||||
"ticket.verified_by": "Verified by",
|
||||
"ticket.timing": "Timing",
|
||||
"ticket.opened": "Opened",
|
||||
"ticket.created": "Created",
|
||||
"ticket.updated": "Updated",
|
||||
"ticket.save": "Save ticket",
|
||||
"ticket.source": "Source / context",
|
||||
"ticket.lifecycle": "Lifecycle",
|
||||
"ticket.assignees": "Assignees",
|
||||
"ticket.assignee_add": "Add assignee",
|
||||
"ticket.comments": "Comments",
|
||||
"ticket.comment_add": "Add comment",
|
||||
"ticket.comment_post": "Post comment",
|
||||
"ticket.attachments": "Attachments",
|
||||
"ticket.attach_link": "Add link",
|
||||
"ticket.attach_link_hint": "Google Docs, Sheets, Drive, Meet, or any HTTPS URL.",
|
||||
"ticket.attach_link_btn": "Attach link",
|
||||
"ticket.attach_file": "Upload file",
|
||||
"ticket.attach_file_hint": "Max 16 MiB — images, logs, pdf, zip, …",
|
||||
"ticket.attach_file_btn": "Upload",
|
||||
"ticket.link_title": "Title (optional)",
|
||||
"ticket.file_pick": "File"
|
||||
}
|
||||
196
sim/cluster0/lab-seeds/backend/public/assets/i18n/ru.json
Normal file
196
sim/cluster0/lab-seeds/backend/public/assets/i18n/ru.json
Normal file
@@ -0,0 +1,196 @@
|
||||
{
|
||||
"app_name": "Сбои Android Cast",
|
||||
"nav.home": "Главная",
|
||||
"nav.reports": "Задачи (Issues)",
|
||||
"nav.tickets": "Задачи",
|
||||
"tickets.title": "Задачи",
|
||||
"issues.new": "Новая проблема",
|
||||
"tickets.new": "Новый тикет",
|
||||
"ticket.create": "Создать",
|
||||
"ticket.cancel": "Отмена",
|
||||
"ticket.title": "Заголовок",
|
||||
"ticket.brief": "Кратко",
|
||||
"ticket.body": "Описание",
|
||||
"tickets.empty": "Задач пока нет.",
|
||||
"ticket.detail_title": "Задача",
|
||||
"nav.logout": "Выход",
|
||||
"nav.toggle": "Навигация",
|
||||
"nav.home_desc": "Главная консоли и карточки разделов.",
|
||||
"nav.reports_desc": "Просмотр и разбор issues.",
|
||||
"nav.tickets_desc": "Задачи roadmap, QA и теги.",
|
||||
"nav.graphs": "Графики",
|
||||
"nav.graphs_desc": "Сессии, issues и активность устройств.",
|
||||
"nav.remote": "Удалённый доступ",
|
||||
"nav.remote_desc": "Сессии WireGuard и доступ к устройствам.",
|
||||
"nav.access": "Доступ",
|
||||
"nav.access_desc": "Роли, наборы привилегий и операторы.",
|
||||
"nav.builder": "Сборщик",
|
||||
"nav.builder_desc": "Docker CI для сборки APK и OTA.",
|
||||
"theme.label": "Тема",
|
||||
"theme.dark": "Тёмная",
|
||||
"theme.light": "Светлая",
|
||||
"lang.label": "Язык",
|
||||
"lang.en": "English",
|
||||
"lang.ru": "Русский",
|
||||
"home.title": "Консоль",
|
||||
"home.intro": "Сбои, задачи и метрики сессий — выберите раздел ниже.",
|
||||
"home.card_issues": "Проблемы",
|
||||
"home.card_issues_desc": "Просмотр и разбор issues, тегов и отпечатков.",
|
||||
"home.card_new_issue_desc": "Создать новую проблему или задачу.",
|
||||
"home.card_new_ticket_desc": "Создать тикет roadmap или QA за один шаг.",
|
||||
"home.card_tickets_desc": "Задачи, QA и теги workflow.",
|
||||
"home.card_graphs": "Графики",
|
||||
"home.card_graphs_desc": "Сессии, issues и активность устройств.",
|
||||
"home.card_short_links": "Короткие ссылки",
|
||||
"home.card_short_links_desc": "Токены и сокращение URL для s.f0xx.org.",
|
||||
"home.more": "Все сервисы",
|
||||
"reports.title": "Issues",
|
||||
"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": "Перетащите для смены порядка столбцов",
|
||||
"graphs.brick_drag": "Перетащите график для смены порядка",
|
||||
"graphs.session_deficit": "Графики сессий пусты. На устройстве: Настройки разработчика → «Grab session stats», проведите сессию трансляции и дождитесь загрузки в graph_upload.php.",
|
||||
"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": "Не удалось сохранить",
|
||||
"tag.workflow": "Статус",
|
||||
"tag.filter_label": "Фильтр по метке",
|
||||
"tag.filter_mode": "Совпадение",
|
||||
"tag.filter_and": "Все метки (И)",
|
||||
"tag.filter_or": "Любая метка (ИЛИ)",
|
||||
"detail.back": "К списку",
|
||||
"detail.title": "Issue-отчёт",
|
||||
"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": "Нативный issue",
|
||||
"detail.find_similar": "Найти похожие отчёты",
|
||||
"detail.thread": "Поток:",
|
||||
"detail.signal": "Сигнал:",
|
||||
"detail.session_context": "Контекст сессии (issue)",
|
||||
"detail.raw_json": "Исходный JSON",
|
||||
"login.sign_in_hint": "Войдите для просмотра анонимных issue-отчётов",
|
||||
"login.username": "Имя пользователя",
|
||||
"login.password": "Пароль",
|
||||
"login.submit": "Войти",
|
||||
"login.hint_default": "По умолчанию: admin / admin",
|
||||
"login.register": "Регистрация",
|
||||
"login.register_soon": "(скоро)",
|
||||
"login.error": "Неверные учётные данные",
|
||||
"register.title": "Создать аккаунт",
|
||||
"register.hint": "Мы отправим ссылку для подтверждения email перед входом.",
|
||||
"register.email": "Email",
|
||||
"register.username": "Имя пользователя (необязательно)",
|
||||
"register.password": "Пароль",
|
||||
"register.submit": "Зарегистрироваться",
|
||||
"register.back_login": "Назад ко входу",
|
||||
"verify.title": "Подтверждение email",
|
||||
"verify.ok": "Email подтверждён. Войдите и настройте двухфакторную аутентификацию.",
|
||||
"verify.fail": "Недействительная или просроченная ссылка.",
|
||||
"verify.sign_in": "Войти",
|
||||
"verify.register_again": "Зарегистрироваться снова",
|
||||
"twofa.title": "Двухфакторная аутентификация",
|
||||
"twofa.hint": "Введите 6-значный код из приложения-аутентификатора.",
|
||||
"twofa.code": "Код аутентификации",
|
||||
"twofa.submit": "Продолжить",
|
||||
"twofa.cancel": "Отмена",
|
||||
"twofa.error": "Неверный код",
|
||||
"security.title": "Безопасность аккаунта",
|
||||
"security.totp_intro": "Защитите аккаунт приложением-аутентификатором (рекомендуется для альфы).",
|
||||
"security.start_totp": "Настроить аутентификатор",
|
||||
"security.scan_qr": "Отсканируйте QR-код и введите код для подтверждения.",
|
||||
"security.manual_secret": "Не удаётся сканировать?",
|
||||
"security.confirm_totp": "Подтвердить настройку",
|
||||
"security.totp_enabled": "Приложение-аутентификатор подключено.",
|
||||
"security.remove_totp": "Отключить аутентификатор",
|
||||
"security.back_console": "Назад в консоль",
|
||||
"nav.security": "Безопасность",
|
||||
"footer.copyright": "© Anton Afanaasyeu, {year}",
|
||||
"nav.tickets": "Тикеты",
|
||||
"ticket.lifecycle": "Жизненный цикл",
|
||||
"ticket.assignees": "Исполнители",
|
||||
"ticket.assignee_add": "Добавить",
|
||||
"ticket.comments": "Комментарии",
|
||||
"ticket.comment_add": "Новый комментарий",
|
||||
"ticket.comment_post": "Отправить",
|
||||
"ticket.attachments": "Вложения",
|
||||
"ticket.attach_link": "Ссылка",
|
||||
"ticket.attach_link_hint": "Google Docs, Таблицы, Drive, Meet или любой HTTPS URL.",
|
||||
"ticket.attach_link_btn": "Прикрепить",
|
||||
"ticket.attach_file": "Файл",
|
||||
"ticket.attach_file_hint": "До 16 МБ — изображения, логи, pdf, zip…",
|
||||
"ticket.attach_file_btn": "Загрузить",
|
||||
"ticket.link_title": "Название (необяз.)",
|
||||
"ticket.file_pick": "Файл"
|
||||
}
|
||||
157
sim/cluster0/lab-seeds/backend/public/assets/js/analytics.js
Normal file
157
sim/cluster0/lab-seeds/backend/public/assets/js/analytics.js
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Google Analytics 4 for Android Cast web platform (hub + crash console).
|
||||
* Set window.ANDROIDCAST_ANALYTICS before this script loads:
|
||||
* { measurementId: 'G-XXXXXXXX', platform: 'hub'|'crashes', debug: false }
|
||||
*/
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
var cfg = global.ANDROIDCAST_ANALYTICS || {};
|
||||
var measurementId = (cfg.measurementId || '').trim();
|
||||
var platform = cfg.platform || 'web';
|
||||
var debug = !!cfg.debug;
|
||||
|
||||
function log() {
|
||||
if (debug && global.console) {
|
||||
global.console.log.apply(global.console, ['[analytics]'].concat([].slice.call(arguments)));
|
||||
}
|
||||
}
|
||||
|
||||
function enabled() {
|
||||
if (global.AndroidCastCookieConsent && !global.AndroidCastCookieConsent.allowsAnalytics()) {
|
||||
var choice = global.AndroidCastCookieConsent.getChoice && global.AndroidCastCookieConsent.getChoice();
|
||||
if (choice && choice !== 'all') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return measurementId.length > 0 && /^G-[A-Z0-9]+$/i.test(measurementId);
|
||||
}
|
||||
|
||||
function ensureGtag(cb) {
|
||||
if (!enabled()) {
|
||||
return;
|
||||
}
|
||||
global.dataLayer = global.dataLayer || [];
|
||||
if (!global.gtag) {
|
||||
global.gtag = function () {
|
||||
global.dataLayer.push(arguments);
|
||||
};
|
||||
}
|
||||
if (global.__acAnalyticsLoaded) {
|
||||
if (cb) cb();
|
||||
return;
|
||||
}
|
||||
var s = document.createElement('script');
|
||||
s.async = true;
|
||||
s.src = 'https://www.googletagmanager.com/gtag/js?id=' + encodeURIComponent(measurementId);
|
||||
s.onload = function () {
|
||||
global.__acAnalyticsLoaded = true;
|
||||
global.gtag('js', new Date());
|
||||
global.gtag('config', measurementId, {
|
||||
send_page_view: false,
|
||||
anonymize_ip: true
|
||||
});
|
||||
log('loaded', measurementId);
|
||||
if (cb) cb();
|
||||
};
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
function pagePath() {
|
||||
var base = (cfg.basePath || '').replace(/\/$/, '');
|
||||
var path = global.location.pathname || '/';
|
||||
if (base && path.indexOf(base) === 0) {
|
||||
path = path.slice(base.length) || '/';
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
function pageView(extra) {
|
||||
if (!enabled()) return;
|
||||
ensureGtag(function () {
|
||||
var params = {
|
||||
page_title: document.title || '',
|
||||
page_location: global.location.href,
|
||||
page_path: pagePath(),
|
||||
platform: platform
|
||||
};
|
||||
if (extra) {
|
||||
for (var k in extra) {
|
||||
if (Object.prototype.hasOwnProperty.call(extra, k)) params[k] = extra[k];
|
||||
}
|
||||
}
|
||||
global.gtag('event', 'page_view', params);
|
||||
log('page_view', params);
|
||||
});
|
||||
}
|
||||
|
||||
function trackEvent(name, params) {
|
||||
if (!enabled() || !name) return;
|
||||
ensureGtag(function () {
|
||||
var payload = params || {};
|
||||
payload.platform = platform;
|
||||
global.gtag('event', name, payload);
|
||||
log('event', name, payload);
|
||||
});
|
||||
}
|
||||
|
||||
function bindConsoleView() {
|
||||
var body = document.body;
|
||||
if (!body) return;
|
||||
var view = body.getAttribute('data-view');
|
||||
if (view) {
|
||||
trackEvent('console_view', { view_name: view });
|
||||
}
|
||||
}
|
||||
|
||||
function bindHubInteractions() {
|
||||
document.addEventListener('click', function (ev) {
|
||||
var t = ev.target;
|
||||
if (!(t instanceof Element)) return;
|
||||
var card = t.closest('.hub-card, .hub-card-link');
|
||||
if (card) {
|
||||
var label = card.getAttribute('data-analytics-label')
|
||||
|| (card.textContent || '').trim().slice(0, 64);
|
||||
trackEvent('hub_nav_click', { link_text: label });
|
||||
return;
|
||||
}
|
||||
if (t.closest('[data-open-docs], #hub-compass-layer, .hub-logo-layer--compass')) {
|
||||
trackEvent('hub_open_docs', { source: 'compass' });
|
||||
}
|
||||
if (t.closest('[data-close-docs]')) {
|
||||
trackEvent('hub_close_docs', {});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function init() {
|
||||
if (!enabled()) {
|
||||
log('disabled (no measurement id or cookie consent)');
|
||||
return;
|
||||
}
|
||||
pageView();
|
||||
if (platform === 'crashes' || String(platform).indexOf('live_') === 0) {
|
||||
bindConsoleView();
|
||||
} else if (platform === 'hub') {
|
||||
bindHubInteractions();
|
||||
}
|
||||
}
|
||||
|
||||
global.addEventListener('ac-cookie-consent', function () {
|
||||
if (enabled()) {
|
||||
init();
|
||||
}
|
||||
});
|
||||
|
||||
global.AndroidCastAnalytics = {
|
||||
enabled: enabled,
|
||||
pageView: pageView,
|
||||
trackEvent: trackEvent
|
||||
};
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})(typeof window !== 'undefined' ? window : this);
|
||||
2067
sim/cluster0/lab-seeds/backend/public/assets/js/app.js
Normal file
2067
sim/cluster0/lab-seeds/backend/public/assets/js/app.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Cookie consent banner — stores choice in localStorage (ac_cookie_consent).
|
||||
* Values: all | necessary | reject
|
||||
*/
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
var STORAGE_KEY = 'ac_cookie_consent';
|
||||
var banner = null;
|
||||
|
||||
function getChoice() {
|
||||
try {
|
||||
var v = localStorage.getItem(STORAGE_KEY);
|
||||
if (v === 'all' || v === 'necessary' || v === 'reject') {
|
||||
return v;
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
function setChoice(value) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, value);
|
||||
} catch (e) { /* ignore */ }
|
||||
global.dispatchEvent(new CustomEvent('ac-cookie-consent', { detail: { choice: value } }));
|
||||
hideBanner();
|
||||
}
|
||||
|
||||
function allowsAnalytics() {
|
||||
return getChoice() === 'all';
|
||||
}
|
||||
|
||||
function allowsPreferenceCookies() {
|
||||
var c = getChoice();
|
||||
return c === 'all' || c === 'necessary';
|
||||
}
|
||||
|
||||
function hideBanner() {
|
||||
if (banner) {
|
||||
banner.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureBanner() {
|
||||
if (banner || getChoice()) {
|
||||
return;
|
||||
}
|
||||
banner = document.getElementById('cookie-consent-banner');
|
||||
if (!banner) {
|
||||
return;
|
||||
}
|
||||
banner.hidden = false;
|
||||
var allBtn = document.getElementById('cookie-consent-all');
|
||||
var necBtn = document.getElementById('cookie-consent-necessary');
|
||||
var rejBtn = document.getElementById('cookie-consent-reject');
|
||||
if (allBtn) {
|
||||
allBtn.addEventListener('click', function () { setChoice('all'); });
|
||||
}
|
||||
if (necBtn) {
|
||||
necBtn.addEventListener('click', function () { setChoice('necessary'); });
|
||||
}
|
||||
if (rejBtn) {
|
||||
rejBtn.addEventListener('click', function () { setChoice('reject'); });
|
||||
}
|
||||
}
|
||||
|
||||
global.AndroidCastCookieConsent = {
|
||||
getChoice: getChoice,
|
||||
allowsAnalytics: allowsAnalytics,
|
||||
allowsPreferenceCookies: allowsPreferenceCookies,
|
||||
setChoice: setChoice
|
||||
};
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', ensureBanner);
|
||||
} else {
|
||||
ensureBanner();
|
||||
}
|
||||
})(typeof window !== 'undefined' ? window : this);
|
||||
1093
sim/cluster0/lab-seeds/backend/public/assets/js/graphs.js
Normal file
1093
sim/cluster0/lab-seeds/backend/public/assets/js/graphs.js
Normal file
File diff suppressed because it is too large
Load Diff
207
sim/cluster0/lab-seeds/backend/public/assets/js/i18n.js
Normal file
207
sim/cluster0/lab-seeds/backend/public/assets/js/i18n.js
Normal file
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
/** @type {Record<string, { flag: string, code: string }>} */
|
||||
const LANG_DISPLAY = {
|
||||
en: { flag: '\uD83C\uDDEC\uD83C\uDDE7', code: 'EN' },
|
||||
ru: { flag: '\uD83C\uDDF7\uD83C\uDDFA', code: 'RU' },
|
||||
};
|
||||
|
||||
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') || '' : '';
|
||||
}
|
||||
|
||||
/** Close stray modal layers so fixed backdrops never block login/console UI. */
|
||||
function dismissPlatformOverlays() {
|
||||
document.querySelectorAll('#graph-detail-overlay, #tag-modal, #hub-docs-modal').forEach((el) => {
|
||||
el.hidden = true;
|
||||
el.setAttribute('aria-hidden', 'true');
|
||||
});
|
||||
const dialog = document.getElementById('ticket-create-dialog');
|
||||
if (dialog && dialog.open && typeof dialog.close === 'function') {
|
||||
dialog.close();
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
scope.querySelectorAll('.platform-footer__left[data-i18n], footer[data-i18n="footer.copyright"]').forEach((footer) => {
|
||||
const year = footer.getAttribute('data-year') || String(new Date().getFullYear());
|
||||
footer.textContent = t(footer.getAttribute('data-i18n') || '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 langOptionLabel(code) {
|
||||
const meta = LANG_DISPLAY[code] || LANG_DISPLAY[DEFAULT_LANG];
|
||||
return meta.flag + ' ' + meta.code;
|
||||
}
|
||||
|
||||
function syncLangSelect() {
|
||||
document.querySelectorAll('.lang-select').forEach((sel) => {
|
||||
SUPPORTED.forEach((code) => {
|
||||
const opt = sel.querySelector('option[value="' + code + '"]');
|
||||
if (opt) opt.textContent = langOptionLabel(code);
|
||||
});
|
||||
if (sel.value !== lang) sel.value = lang;
|
||||
});
|
||||
syncLocaleDisplay(lang);
|
||||
}
|
||||
|
||||
function syncLocaleDisplay(code) {
|
||||
const meta = LANG_DISPLAY[code] || LANG_DISPLAY[DEFAULT_LANG];
|
||||
document.querySelectorAll('.locale-flag').forEach((el) => {
|
||||
el.textContent = meta.flag;
|
||||
});
|
||||
document.querySelectorAll('.locale-code').forEach((el) => {
|
||||
el.textContent = meta.code;
|
||||
});
|
||||
}
|
||||
|
||||
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() {
|
||||
dismissPlatformOverlays();
|
||||
lang = storedLang();
|
||||
document.documentElement.setAttribute('lang', lang);
|
||||
await setLang(lang, { silent: true });
|
||||
apply(document);
|
||||
initLangSelect();
|
||||
syncLocaleDisplay(lang);
|
||||
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,256 @@
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
var STUN = [{ urls: 'stun:stun.l.google.com:19302' }];
|
||||
|
||||
function signalingUrl(basePath) {
|
||||
return basePath + '/api/live_cast_signaling.php';
|
||||
}
|
||||
|
||||
function castApiUrl(basePath) {
|
||||
return basePath + '/api/live_cast.php';
|
||||
}
|
||||
|
||||
function xhrJson(method, url, body, cb) {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open(method, url, true);
|
||||
if (body != null) {
|
||||
xhr.setRequestHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
}
|
||||
xhr.onload = function () {
|
||||
var payload = null;
|
||||
try {
|
||||
payload = JSON.parse(xhr.responseText);
|
||||
} catch (e) {
|
||||
cb(null);
|
||||
return;
|
||||
}
|
||||
cb(payload);
|
||||
};
|
||||
xhr.onerror = function () { cb(null); };
|
||||
xhr.send(body != null ? JSON.stringify(body) : null);
|
||||
}
|
||||
|
||||
function postSignal(basePath, sessionId, peerRole, msgType, payload, cb) {
|
||||
xhrJson('POST', signalingUrl(basePath), {
|
||||
action: 'post',
|
||||
session_id: sessionId,
|
||||
peer_role: peerRole,
|
||||
msg_type: msgType,
|
||||
payload: payload
|
||||
}, cb);
|
||||
}
|
||||
|
||||
function pollSignals(basePath, sessionId, sinceId, fromRole, cb) {
|
||||
var qs = '?action=poll&session_id=' + encodeURIComponent(sessionId)
|
||||
+ '&since_id=' + encodeURIComponent(String(sinceId || 0));
|
||||
if (fromRole) {
|
||||
qs += '&from_role=' + encodeURIComponent(fromRole);
|
||||
}
|
||||
xhrJson('GET', signalingUrl(basePath) + qs, null, cb);
|
||||
}
|
||||
|
||||
function collectStats(pc, videoEl) {
|
||||
var stats = {
|
||||
ice_state: pc ? pc.iceConnectionState : 'new',
|
||||
signaling_state: pc ? pc.signalingState : 'stable',
|
||||
fps: 0,
|
||||
inbound_kbps: 0,
|
||||
outbound_kbps: 0,
|
||||
rtt_ms: 0,
|
||||
frames_decoded: 0
|
||||
};
|
||||
if (videoEl && videoEl.videoWidth) {
|
||||
stats.frame_w = videoEl.videoWidth;
|
||||
stats.frame_h = videoEl.videoHeight;
|
||||
}
|
||||
return stats;
|
||||
}
|
||||
|
||||
function enrichStatsFromRtc(pc, stats, cb) {
|
||||
if (!pc || !pc.getStats) {
|
||||
cb(stats);
|
||||
return;
|
||||
}
|
||||
pc.getStats().then(function (report) {
|
||||
report.forEach(function (r) {
|
||||
if (r.type === 'inbound-rtp' && r.kind === 'video') {
|
||||
stats.frames_decoded = r.framesDecoded || stats.frames_decoded;
|
||||
if (r.bytesReceived && r.timestamp) {
|
||||
stats.inbound_kbps = Math.round((r.bytesReceived * 8) / 1000);
|
||||
}
|
||||
}
|
||||
if (r.type === 'outbound-rtp' && r.kind === 'video') {
|
||||
if (r.bytesSent) {
|
||||
stats.outbound_kbps = Math.round((r.bytesSent * 8) / 1000);
|
||||
}
|
||||
}
|
||||
if (r.type === 'candidate-pair' && r.state === 'succeeded' && r.currentRoundTripTime) {
|
||||
stats.rtt_ms = Math.round(r.currentRoundTripTime * 1000);
|
||||
}
|
||||
});
|
||||
cb(stats);
|
||||
}).catch(function () { cb(stats); });
|
||||
}
|
||||
|
||||
function sendHeartbeat(basePath, sessionId, status, stats, cb) {
|
||||
var body = { action: 'heartbeat', session_id: sessionId, status: status || 'active' };
|
||||
if (stats) body.stats = stats;
|
||||
xhrJson('POST', castApiUrl(basePath), body, cb || function () {});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {string} opts.basePath
|
||||
* @param {string} opts.sessionId
|
||||
* @param {'caster'|'viewer'|'mobile'} opts.role
|
||||
* @param {MediaStream|null} opts.localStream
|
||||
* @param {function(MediaStream)} [opts.onRemoteStream]
|
||||
* @param {function(object)} [opts.onStats]
|
||||
* @param {function(string)} [opts.onStatus]
|
||||
*/
|
||||
function connect(opts) {
|
||||
var basePath = opts.basePath || '';
|
||||
var sessionId = opts.sessionId || '';
|
||||
var role = opts.role || 'viewer';
|
||||
var localStream = opts.localStream || null;
|
||||
var onRemoteStream = opts.onRemoteStream || function () {};
|
||||
var onStats = opts.onStats || function () {};
|
||||
var onStatus = opts.onStatus || function () {};
|
||||
var remoteRole = role === 'caster' ? 'viewer' : 'caster';
|
||||
var sinceId = 0;
|
||||
var pc = new RTCPeerConnection({ iceServers: STUN });
|
||||
var pollTimer = null;
|
||||
var hbTimer = null;
|
||||
var remoteVideo = null;
|
||||
var stopped = false;
|
||||
|
||||
function status(msg) {
|
||||
onStatus(msg);
|
||||
}
|
||||
|
||||
function teardown() {
|
||||
stopped = true;
|
||||
if (pollTimer) clearInterval(pollTimer);
|
||||
if (hbTimer) clearInterval(hbTimer);
|
||||
pollTimer = null;
|
||||
hbTimer = null;
|
||||
try { pc.close(); } catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
pc.onicecandidate = function (ev) {
|
||||
if (!ev.candidate) return;
|
||||
postSignal(basePath, sessionId, role, 'candidate', {
|
||||
candidate: ev.candidate.candidate,
|
||||
sdpMid: ev.candidate.sdpMid,
|
||||
sdpMLineIndex: ev.candidate.sdpMLineIndex
|
||||
}, function () {});
|
||||
};
|
||||
|
||||
pc.ontrack = function (ev) {
|
||||
var stream = ev.streams && ev.streams[0] ? ev.streams[0] : new MediaStream([ev.track]);
|
||||
onRemoteStream(stream);
|
||||
};
|
||||
|
||||
pc.oniceconnectionstatechange = function () {
|
||||
status('ICE: ' + pc.iceConnectionState);
|
||||
};
|
||||
|
||||
if (localStream) {
|
||||
localStream.getTracks().forEach(function (t) { pc.addTrack(t, localStream); });
|
||||
}
|
||||
|
||||
function applyRemoteDescription(payload, cb) {
|
||||
if (!payload || !payload.type || !payload.sdp) {
|
||||
cb(false);
|
||||
return;
|
||||
}
|
||||
pc.setRemoteDescription(payload).then(function () {
|
||||
cb(true);
|
||||
}).catch(function () { cb(false); });
|
||||
}
|
||||
|
||||
function handlePollMessages(messages) {
|
||||
if (!messages || !messages.length) return;
|
||||
messages.forEach(function (msg) {
|
||||
sinceId = Math.max(sinceId, msg.id || 0);
|
||||
var payload = msg.payload || {};
|
||||
if (msg.msg_type === 'offer' && role === 'viewer') {
|
||||
applyRemoteDescription(payload, function (ok) {
|
||||
if (!ok) return;
|
||||
pc.createAnswer().then(function (answer) {
|
||||
return pc.setLocalDescription(answer);
|
||||
}).then(function () {
|
||||
postSignal(basePath, sessionId, role, 'answer', pc.localDescription, function () {});
|
||||
status('Answer sent — connecting…');
|
||||
}).catch(function () {
|
||||
status('Could not create WebRTC answer.');
|
||||
});
|
||||
});
|
||||
} else if (msg.msg_type === 'answer' && role === 'caster') {
|
||||
applyRemoteDescription(payload, function (ok) {
|
||||
if (ok) status('Viewer connected — streaming.');
|
||||
});
|
||||
} else if (msg.msg_type === 'candidate' && payload.candidate) {
|
||||
pc.addIceCandidate({
|
||||
candidate: payload.candidate,
|
||||
sdpMid: payload.sdpMid,
|
||||
sdpMLineIndex: payload.sdpMLineIndex
|
||||
}).catch(function () {});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pollTimer = setInterval(function () {
|
||||
if (stopped) return;
|
||||
pollSignals(basePath, sessionId, sinceId, remoteRole, function (resp) {
|
||||
if (!resp || !resp.ok) return;
|
||||
handlePollMessages(resp.messages || []);
|
||||
if (typeof resp.since_id === 'number') {
|
||||
sinceId = Math.max(sinceId, resp.since_id);
|
||||
}
|
||||
});
|
||||
}, 800);
|
||||
|
||||
hbTimer = setInterval(function () {
|
||||
if (stopped) return;
|
||||
enrichStatsFromRtc(pc, collectStats(pc, remoteVideo), function (stats) {
|
||||
onStats(stats);
|
||||
sendHeartbeat(basePath, sessionId, 'active', stats);
|
||||
});
|
||||
}, 5000);
|
||||
|
||||
if (role === 'caster') {
|
||||
status('Publishing offer…');
|
||||
pc.createOffer().then(function (offer) {
|
||||
return pc.setLocalDescription(offer);
|
||||
}).then(function () {
|
||||
postSignal(basePath, sessionId, role, 'offer', pc.localDescription, function (resp) {
|
||||
if (resp && resp.ok) {
|
||||
status('Waiting for viewer…');
|
||||
} else {
|
||||
status('Signaling failed.');
|
||||
}
|
||||
});
|
||||
}).catch(function () {
|
||||
status('Could not create WebRTC offer.');
|
||||
});
|
||||
} else {
|
||||
status('Waiting for caster offer…');
|
||||
}
|
||||
|
||||
return {
|
||||
setRemoteVideo: function (el) { remoteVideo = el; },
|
||||
stop: function () {
|
||||
sendHeartbeat(basePath, sessionId, 'ended', null);
|
||||
teardown();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
global.LiveCastWebRtc = {
|
||||
connect: connect,
|
||||
sendHeartbeat: sendHeartbeat,
|
||||
collectStats: collectStats
|
||||
};
|
||||
})(typeof window !== 'undefined' ? window : this);
|
||||
@@ -0,0 +1,210 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var MAX_DEMO_S = 300;
|
||||
var timer = null;
|
||||
var secondsLeft = MAX_DEMO_S;
|
||||
var mediaStream = null;
|
||||
var sessionId = '';
|
||||
var webrtc = null;
|
||||
|
||||
function basePath() {
|
||||
return document.body.getAttribute('data-base-path') || '';
|
||||
}
|
||||
|
||||
function el(id) {
|
||||
return document.getElementById(id);
|
||||
}
|
||||
|
||||
function detectPlatform() {
|
||||
var ua = navigator.userAgent || '';
|
||||
if (/Firefox\//i.test(ua)) return 'firefox';
|
||||
if (/Edg\//i.test(ua)) return 'edge';
|
||||
if (/Chrome\//i.test(ua)) return 'chrome';
|
||||
if (/Safari\//i.test(ua)) return 'safari';
|
||||
return 'desktop';
|
||||
}
|
||||
|
||||
function apiUrl() {
|
||||
return basePath() + '/api/live_cast.php';
|
||||
}
|
||||
|
||||
function postJson(body, cb) {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', apiUrl(), true);
|
||||
xhr.setRequestHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
xhr.onload = function () {
|
||||
var payload = null;
|
||||
try {
|
||||
payload = JSON.parse(xhr.responseText);
|
||||
} catch (e) {
|
||||
cb(null);
|
||||
return;
|
||||
}
|
||||
cb(payload);
|
||||
};
|
||||
xhr.onerror = function () { cb(null); };
|
||||
xhr.send(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function setStatus(text) {
|
||||
var node = el('edu-status');
|
||||
if (node) node.textContent = text;
|
||||
}
|
||||
|
||||
function updateTimer() {
|
||||
var node = el('edu-timer');
|
||||
if (!node) return;
|
||||
var m = Math.floor(secondsLeft / 60);
|
||||
var s = secondsLeft % 60;
|
||||
node.textContent = m + ':' + (s < 10 ? '0' : '') + s;
|
||||
}
|
||||
|
||||
function renderShare(session) {
|
||||
var box = el('edu-share');
|
||||
if (!box || !session) return;
|
||||
var joinUrl = session.join_short_url || '';
|
||||
var qrUrl = session.join_qr_url || '';
|
||||
var longJoin = basePath() + '/live/join?session_id=' + encodeURIComponent(session.session_id || sessionId);
|
||||
if (!joinUrl) joinUrl = longJoin;
|
||||
var html = '<h2>Share this demo</h2><p class="muted">Viewers open this link in another browser (same LAN or online).</p>';
|
||||
html += '<p><a href="' + joinUrl + '" target="_blank" rel="noopener">' + joinUrl + '</a></p>';
|
||||
if (qrUrl) {
|
||||
html += '<figure class="twofa-qr-wrap"><img src="' + qrUrl + '" width="160" height="160" alt="Viewer QR code"><figcaption class="muted">Scan to open viewer page</figcaption></figure>';
|
||||
}
|
||||
box.hidden = false;
|
||||
box.innerHTML = html;
|
||||
}
|
||||
|
||||
function stopDemo() {
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
if (webrtc) {
|
||||
webrtc.stop();
|
||||
webrtc = null;
|
||||
}
|
||||
if (mediaStream) {
|
||||
mediaStream.getTracks().forEach(function (t) { t.stop(); });
|
||||
mediaStream = null;
|
||||
}
|
||||
var preview = el('edu-preview');
|
||||
if (preview) preview.srcObject = null;
|
||||
if (sessionId) {
|
||||
postJson({ action: 'heartbeat', session_id: sessionId, status: 'ended' }, function () {});
|
||||
sessionId = '';
|
||||
}
|
||||
var share = el('edu-share');
|
||||
if (share) share.hidden = true;
|
||||
el('edu-start').disabled = false;
|
||||
el('edu-stop').disabled = true;
|
||||
setStatus('Demo ended. Thanks for trying AndroidCast education mode.');
|
||||
}
|
||||
|
||||
function startWebRtc() {
|
||||
if (!window.LiveCastWebRtc || !sessionId || !mediaStream) return;
|
||||
webrtc = window.LiveCastWebRtc.connect({
|
||||
basePath: basePath(),
|
||||
sessionId: sessionId,
|
||||
role: 'caster',
|
||||
localStream: mediaStream,
|
||||
onStatus: setStatus,
|
||||
onStats: function (stats) {
|
||||
var nerds = el('edu-stats');
|
||||
if (!nerds) return;
|
||||
nerds.hidden = false;
|
||||
nerds.textContent = 'Stats: ICE ' + (stats.ice_state || '—')
|
||||
+ ' · out ' + (stats.outbound_kbps || 0) + ' kbps'
|
||||
+ ' · RTT ' + (stats.rtt_ms || 0) + ' ms';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function startDemo() {
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) {
|
||||
setStatus('Screen sharing is not supported in this browser.');
|
||||
return;
|
||||
}
|
||||
if (!window.RTCPeerConnection) {
|
||||
setStatus('WebRTC is not available in this browser.');
|
||||
return;
|
||||
}
|
||||
setStatus('Requesting screen share permission…');
|
||||
navigator.mediaDevices.getDisplayMedia({ video: true, audio: false })
|
||||
.then(function (stream) {
|
||||
mediaStream = stream;
|
||||
var preview = el('edu-preview');
|
||||
if (preview) {
|
||||
preview.hidden = false;
|
||||
preview.srcObject = stream;
|
||||
preview.play().catch(function () {});
|
||||
}
|
||||
stream.getVideoTracks()[0].addEventListener('ended', stopDemo);
|
||||
setStatus('Creating demo session…');
|
||||
postJson({
|
||||
action: 'create_intent',
|
||||
platform: 'education_' + detectPlatform(),
|
||||
max_duration_s: MAX_DEMO_S,
|
||||
codec_video: 'browser_screen',
|
||||
codec_audio: 'none',
|
||||
bw_mode: 'demo'
|
||||
}, function (resp) {
|
||||
if (!resp || !resp.ok || !resp.session) {
|
||||
setStatus('Could not register demo session. Try again later.');
|
||||
stopDemo();
|
||||
return;
|
||||
}
|
||||
sessionId = resp.session.session_id || '';
|
||||
renderShare(resp.session);
|
||||
startWebRtc();
|
||||
secondsLeft = MAX_DEMO_S;
|
||||
updateTimer();
|
||||
el('edu-start').disabled = true;
|
||||
el('edu-stop').disabled = false;
|
||||
setStatus('Live demo — share the link so others can watch.');
|
||||
timer = setInterval(function () {
|
||||
secondsLeft -= 1;
|
||||
updateTimer();
|
||||
if (secondsLeft <= 0) {
|
||||
stopDemo();
|
||||
}
|
||||
}, 1000);
|
||||
if (window.AndroidCastAnalytics && window.AndroidCastAnalytics.trackEvent) {
|
||||
window.AndroidCastAnalytics.trackEvent('live_education_start', { platform: detectPlatform() });
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(function () {
|
||||
setStatus('Screen share permission denied or cancelled.');
|
||||
});
|
||||
}
|
||||
|
||||
function boot() {
|
||||
if (!el('live-education-app')) return;
|
||||
updateTimer();
|
||||
var startBtn = el('edu-start');
|
||||
var stopBtn = el('edu-stop');
|
||||
if (startBtn) startBtn.addEventListener('click', startDemo);
|
||||
if (stopBtn) {
|
||||
stopBtn.disabled = true;
|
||||
stopBtn.addEventListener('click', stopDemo);
|
||||
}
|
||||
var notifBtn = el('edu-notify');
|
||||
if (notifBtn && 'Notification' in window) {
|
||||
notifBtn.addEventListener('click', function () {
|
||||
Notification.requestPermission().then(function (perm) {
|
||||
setStatus(perm === 'granted' ? 'Notifications enabled.' : 'Notifications not granted.');
|
||||
});
|
||||
});
|
||||
} else if (notifBtn) {
|
||||
notifBtn.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', boot);
|
||||
} else {
|
||||
boot();
|
||||
}
|
||||
})();
|
||||
224
sim/cluster0/lab-seeds/backend/public/assets/js/live_join.js
Normal file
224
sim/cluster0/lab-seeds/backend/public/assets/js/live_join.js
Normal file
@@ -0,0 +1,224 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function basePath() {
|
||||
return document.body.getAttribute('data-base-path') || '';
|
||||
}
|
||||
|
||||
function el(id) {
|
||||
return document.getElementById(id);
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function detectPlatform() {
|
||||
var ua = navigator.userAgent || '';
|
||||
if (/Android/i.test(ua)) return 'android';
|
||||
if (/iPhone|iPad|iPod/i.test(ua)) return 'ios';
|
||||
if (/Firefox\//i.test(ua)) return 'firefox';
|
||||
if (/Edg\//i.test(ua)) return 'edge';
|
||||
if (/Chrome\//i.test(ua)) return 'chrome';
|
||||
if (/Safari\//i.test(ua)) return 'safari';
|
||||
return 'desktop';
|
||||
}
|
||||
|
||||
function anonId() {
|
||||
var key = 'ac_live_anon_id';
|
||||
try {
|
||||
var existing = localStorage.getItem(key);
|
||||
if (existing) return existing;
|
||||
var id = 'anon-' + Math.random().toString(36).slice(2) + Date.now().toString(36);
|
||||
localStorage.setItem(key, id);
|
||||
return id;
|
||||
} catch (e) {
|
||||
return 'anon-' + Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
function apiUrl() {
|
||||
return basePath() + '/api/live_cast.php';
|
||||
}
|
||||
|
||||
function postJoinEvent(sessionId, eventType, direct) {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', apiUrl(), true);
|
||||
xhr.setRequestHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
xhr.send(JSON.stringify({
|
||||
action: 'join_event',
|
||||
session_id: sessionId,
|
||||
event_type: eventType,
|
||||
platform: detectPlatform(),
|
||||
channel: direct ? 'direct_link' : 'viewer_link',
|
||||
direct: !!direct,
|
||||
anon_id: anonId()
|
||||
}));
|
||||
}
|
||||
|
||||
function fetchSession(sessionId, cb) {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', apiUrl() + '?action=session&session_id=' + encodeURIComponent(sessionId), true);
|
||||
xhr.onload = function () {
|
||||
var payload = null;
|
||||
try {
|
||||
payload = JSON.parse(xhr.responseText);
|
||||
} catch (e) {
|
||||
cb(null);
|
||||
return;
|
||||
}
|
||||
cb(payload && payload.ok ? payload.session : null);
|
||||
};
|
||||
xhr.onerror = function () { cb(null); };
|
||||
xhr.send();
|
||||
}
|
||||
|
||||
function formatStatus(session) {
|
||||
if (!session) return 'Unknown';
|
||||
var st = session.status || '';
|
||||
if (st === 'live' || st === 'intent') return 'Live';
|
||||
if (st === 'closed') return 'Ended';
|
||||
if (st === 'expired') return 'Expired';
|
||||
return st;
|
||||
}
|
||||
|
||||
function renderStats(statsEl, stats) {
|
||||
if (!statsEl || !stats) return;
|
||||
statsEl.hidden = false;
|
||||
statsEl.innerHTML =
|
||||
'<strong>Statistics for nerds</strong><br>'
|
||||
+ 'ICE: ' + escapeHtml(stats.ice_state || '—')
|
||||
+ ' · sig: ' + escapeHtml(stats.signaling_state || '—')
|
||||
+ '<br>In: ' + (stats.inbound_kbps || 0) + ' kbps · Out: ' + (stats.outbound_kbps || 0) + ' kbps'
|
||||
+ '<br>RTT: ' + (stats.rtt_ms || 0) + ' ms · frames: ' + (stats.frames_decoded || 0)
|
||||
+ (stats.frame_w ? '<br>Frame: ' + stats.frame_w + '×' + stats.frame_h : '');
|
||||
}
|
||||
|
||||
function startViewer(sessionId, session, playerEl, statusEl, statsEl) {
|
||||
if (!window.RTCPeerConnection || !window.LiveCastWebRtc) {
|
||||
playerEl.innerHTML = '<p class="muted">WebRTC is not available in this browser.</p>';
|
||||
return null;
|
||||
}
|
||||
playerEl.innerHTML =
|
||||
'<video id="live-remote-video" class="live-preview" playsinline autoplay controls></video>'
|
||||
+ '<div id="live-join-stats" class="live-stats-nerds" hidden></div>';
|
||||
var video = el('live-remote-video');
|
||||
var nerds = el('live-join-stats');
|
||||
var conn = window.LiveCastWebRtc.connect({
|
||||
basePath: basePath(),
|
||||
sessionId: sessionId,
|
||||
role: 'viewer',
|
||||
onStatus: function (msg) {
|
||||
statusEl.textContent = formatStatus(session) + ' — ' + msg;
|
||||
},
|
||||
onRemoteStream: function (stream) {
|
||||
if (video) {
|
||||
video.srcObject = stream;
|
||||
video.play().catch(function () {});
|
||||
}
|
||||
statusEl.textContent = formatStatus(session) + ' — playing stream';
|
||||
},
|
||||
onStats: function (stats) {
|
||||
renderStats(nerds || statsEl, stats);
|
||||
}
|
||||
});
|
||||
if (conn && video) conn.setRemoteVideo(video);
|
||||
return conn;
|
||||
}
|
||||
|
||||
function boot() {
|
||||
var root = el('live-join-app');
|
||||
if (!root) return;
|
||||
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var sessionId = params.get('session_id') || root.getAttribute('data-session-id') || '';
|
||||
var direct = params.get('direct') === '1' || root.getAttribute('data-direct') === '1';
|
||||
var role = params.get('role') || (direct ? 'direct' : 'viewer');
|
||||
var webrtcConn = null;
|
||||
|
||||
if (!sessionId) {
|
||||
el('live-join-status').textContent = 'Missing session_id in URL.';
|
||||
return;
|
||||
}
|
||||
|
||||
postJoinEvent(sessionId, 'open', direct);
|
||||
|
||||
var statusEl = el('live-join-status');
|
||||
var metaEl = el('live-join-meta');
|
||||
var playerEl = el('live-join-player');
|
||||
var statsEl = el('live-join-stats-panel');
|
||||
|
||||
function render(session) {
|
||||
if (!session) {
|
||||
statusEl.textContent = 'Session not found or no longer available.';
|
||||
return;
|
||||
}
|
||||
var live = session.status === 'live' || session.status === 'intent';
|
||||
statusEl.textContent = formatStatus(session) + (live ? ' — connecting…' : '');
|
||||
var owner = session.owner_username || 'host';
|
||||
var lines = [
|
||||
'Host: ' + owner,
|
||||
'Platform: ' + (session.platform || '—'),
|
||||
'Video: ' + (session.codec_video || '—'),
|
||||
'Audio: ' + (session.codec_audio || '—'),
|
||||
'Bandwidth: ' + (session.bw_mode || '—')
|
||||
];
|
||||
if (session.join_short_url) {
|
||||
lines.push('Viewer link: ' + session.join_short_url);
|
||||
}
|
||||
metaEl.innerHTML = lines.map(function (l) {
|
||||
return '<li>' + escapeHtml(l) + '</li>';
|
||||
}).join('');
|
||||
|
||||
if (direct && /android|ios/i.test(detectPlatform())) {
|
||||
playerEl.innerHTML =
|
||||
'<p class="muted">Open in the AndroidCast app for direct P2P on LAN.</p>' +
|
||||
'<p><code>androidcast://join?session_id=' + escapeHtml(sessionId) + '&direct=1</code></p>' +
|
||||
'<p class="muted">Mobile WebRTC signaling uses the same REST API when the app transport is wired.</p>';
|
||||
postJoinEvent(sessionId, 'join', true);
|
||||
} else if (live) {
|
||||
webrtcConn = startViewer(sessionId, session, playerEl, statusEl, statsEl);
|
||||
postJoinEvent(sessionId, 'join', false);
|
||||
} else {
|
||||
playerEl.innerHTML = '<p class="muted">Session is not live.</p>';
|
||||
}
|
||||
|
||||
if (window.AndroidCastAnalytics && window.AndroidCastAnalytics.trackEvent) {
|
||||
window.AndroidCastAnalytics.trackEvent('live_join_view', {
|
||||
session_id: sessionId,
|
||||
direct: direct ? 1 : 0,
|
||||
platform: detectPlatform(),
|
||||
status: session.status || ''
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fetchSession(sessionId, render);
|
||||
setInterval(function () {
|
||||
fetchSession(sessionId, function (session) {
|
||||
if (!session) return;
|
||||
if (!(session.status === 'live' || session.status === 'intent')) {
|
||||
statusEl.textContent = formatStatus(session);
|
||||
if (webrtcConn) {
|
||||
webrtcConn.stop();
|
||||
webrtcConn = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
}, 30000);
|
||||
|
||||
window.addEventListener('beforeunload', function () {
|
||||
if (webrtcConn) webrtcConn.stop();
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', boot);
|
||||
} else {
|
||||
boot();
|
||||
}
|
||||
})();
|
||||
113
sim/cluster0/lab-seeds/backend/public/assets/js/live_sessions.js
Normal file
113
sim/cluster0/lab-seeds/backend/public/assets/js/live_sessions.js
Normal file
@@ -0,0 +1,113 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function basePath() {
|
||||
return document.body.getAttribute('data-base-path') || '';
|
||||
}
|
||||
|
||||
function el(id) {
|
||||
return document.getElementById(id);
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function formatMs(ms) {
|
||||
if (!ms) return '—';
|
||||
try {
|
||||
return new Date(ms).toLocaleString();
|
||||
} catch (e) {
|
||||
return String(ms);
|
||||
}
|
||||
}
|
||||
|
||||
function durationS(startMs, endMs) {
|
||||
if (!startMs) return '—';
|
||||
var end = endMs || Date.now();
|
||||
var sec = Math.max(0, Math.round((end - startMs) / 1000));
|
||||
if (sec < 60) return sec + 's';
|
||||
return Math.floor(sec / 60) + 'm ' + (sec % 60) + 's';
|
||||
}
|
||||
|
||||
function statusClass(status) {
|
||||
if (status === 'live' || status === 'intent') return 'tag-pill tag-pill--ok';
|
||||
if (status === 'expired') return 'tag-pill tag-pill--warn';
|
||||
return 'tag-pill';
|
||||
}
|
||||
|
||||
function renderRows(sessions) {
|
||||
var tbody = el('live-sessions-tbody');
|
||||
if (!tbody) return;
|
||||
if (!sessions || !sessions.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="8" class="muted">No sessions in this window.</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = sessions.map(function (s) {
|
||||
var joinUrl = s.join_short_url || '';
|
||||
var joinCell = joinUrl
|
||||
? '<a href="' + escapeHtml(joinUrl) + '" target="_blank" rel="noopener">Open</a>'
|
||||
: '—';
|
||||
return (
|
||||
'<tr>' +
|
||||
'<td><span class="' + statusClass(s.status) + '">' + escapeHtml(s.status) + '</span></td>' +
|
||||
'<td>' + escapeHtml(s.owner_username || '—') + '</td>' +
|
||||
'<td>' + escapeHtml(s.platform || '—') + '</td>' +
|
||||
'<td>' + escapeHtml(s.codec_video || '—') + '</td>' +
|
||||
'<td>' + durationS(s.started_at_ms, s.ended_at_ms) + '</td>' +
|
||||
'<td>' + formatMs(s.last_heartbeat_ms) + '</td>' +
|
||||
'<td>' + Number(s.join_opens || 0) + '</td>' +
|
||||
'<td>' + joinCell + '</td>' +
|
||||
'</tr>'
|
||||
);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function load() {
|
||||
var status = el('live-sessions-status');
|
||||
var daysSel = el('live-sessions-days');
|
||||
var days = daysSel && daysSel.value ? daysSel.value : '14';
|
||||
if (status) status.textContent = 'Loading…';
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', basePath() + '/api/live_cast.php?action=list&days=' + encodeURIComponent(days), true);
|
||||
xhr.onload = function () {
|
||||
var payload = null;
|
||||
try {
|
||||
payload = JSON.parse(xhr.responseText);
|
||||
} catch (e) {
|
||||
if (status) status.textContent = 'Invalid response';
|
||||
return;
|
||||
}
|
||||
if (!payload || !payload.ok) {
|
||||
if (status) status.textContent = (payload && payload.error) || 'Failed';
|
||||
return;
|
||||
}
|
||||
renderRows(payload.sessions || []);
|
||||
if (status) {
|
||||
status.textContent = 'Loaded ' + (payload.sessions || []).length + ' session(s) · ' + days + 'd window';
|
||||
}
|
||||
};
|
||||
xhr.onerror = function () {
|
||||
if (status) status.textContent = 'Network error';
|
||||
};
|
||||
xhr.send();
|
||||
}
|
||||
|
||||
function boot() {
|
||||
if (!el('live-sessions-app')) return;
|
||||
var daysSel = el('live-sessions-days');
|
||||
if (daysSel) daysSel.addEventListener('change', load);
|
||||
load();
|
||||
setInterval(load, 60000);
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', boot);
|
||||
} else {
|
||||
boot();
|
||||
}
|
||||
})();
|
||||
83
sim/cluster0/lab-seeds/backend/public/assets/js/nav_shell.js
Normal file
83
sim/cluster0/lab-seeds/backend/public/assets/js/nav_shell.js
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Collapsible left nav: click toggle + horizontal drag on handle (open on drag right).
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var DRAG_THRESHOLD_PX = 20;
|
||||
|
||||
function setOpen(nav, handle, open) {
|
||||
nav.classList.toggle('open', open);
|
||||
if (handle) {
|
||||
handle.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
}
|
||||
}
|
||||
|
||||
function bindNavShell(paneId, handleId) {
|
||||
var nav = document.getElementById(paneId);
|
||||
var handle = document.getElementById(handleId);
|
||||
if (!nav || !handle) return;
|
||||
|
||||
setOpen(nav, handle, nav.classList.contains('open'));
|
||||
|
||||
var drag = null;
|
||||
var suppressClick = false;
|
||||
|
||||
handle.addEventListener('click', function (ev) {
|
||||
if (suppressClick) {
|
||||
suppressClick = false;
|
||||
ev.preventDefault();
|
||||
return;
|
||||
}
|
||||
setOpen(nav, handle, !nav.classList.contains('open'));
|
||||
});
|
||||
|
||||
handle.addEventListener('pointerdown', function (ev) {
|
||||
if (ev.button !== 0) return;
|
||||
drag = { x: ev.clientX, moved: false };
|
||||
handle.setPointerCapture(ev.pointerId);
|
||||
handle.classList.add('is-dragging');
|
||||
ev.preventDefault();
|
||||
});
|
||||
|
||||
handle.addEventListener('pointermove', function (ev) {
|
||||
if (!drag || !handle.hasPointerCapture(ev.pointerId)) return;
|
||||
var dx = ev.clientX - drag.x;
|
||||
if (Math.abs(dx) < 4) return;
|
||||
drag.moved = true;
|
||||
if (dx > DRAG_THRESHOLD_PX) {
|
||||
setOpen(nav, handle, true);
|
||||
drag.x = ev.clientX;
|
||||
} else if (dx < -DRAG_THRESHOLD_PX) {
|
||||
setOpen(nav, handle, false);
|
||||
drag.x = ev.clientX;
|
||||
}
|
||||
});
|
||||
|
||||
function endDrag(ev) {
|
||||
if (!drag) return;
|
||||
if (drag.moved) suppressClick = true;
|
||||
drag = null;
|
||||
handle.classList.remove('is-dragging');
|
||||
if (handle.hasPointerCapture(ev.pointerId)) {
|
||||
handle.releasePointerCapture(ev.pointerId);
|
||||
}
|
||||
}
|
||||
|
||||
handle.addEventListener('pointerup', endDrag);
|
||||
handle.addEventListener('pointercancel', endDrag);
|
||||
}
|
||||
|
||||
window.initNavShell = bindNavShell;
|
||||
|
||||
function autoInit() {
|
||||
bindNavShell('nav-pane', 'nav-handle');
|
||||
bindNavShell('landing-nav-pane', 'landing-nav-handle');
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', autoInit);
|
||||
} else {
|
||||
autoInit();
|
||||
}
|
||||
})();
|
||||
196
sim/cluster0/lab-seeds/backend/public/assets/js/rbac_admin.js
Normal file
196
sim/cluster0/lab-seeds/backend/public/assets/js/rbac_admin.js
Normal file
@@ -0,0 +1,196 @@
|
||||
(function () {
|
||||
function basePath() {
|
||||
return document.body.getAttribute('data-base-path') || '';
|
||||
}
|
||||
|
||||
function canEditGlobal() {
|
||||
return document.body.getAttribute('data-can-rbac-root') === '1';
|
||||
}
|
||||
|
||||
function setStatus(msg, isError) {
|
||||
const el = document.getElementById('rbac-status');
|
||||
if (!el) return;
|
||||
el.textContent = msg;
|
||||
el.classList.toggle('error', !!isError);
|
||||
}
|
||||
|
||||
async function fetchJson(url, opts) {
|
||||
const res = await fetch(url, Object.assign({ credentials: 'same-origin' }, opts || {}));
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || data.ok === false) {
|
||||
throw new Error(data.error || 'HTTP ' + res.status);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s == null ? '' : String(s);
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function renderPanel(data) {
|
||||
const tbody = document.getElementById('rbac-users-tbody');
|
||||
if (!tbody) return;
|
||||
tbody.innerHTML = '';
|
||||
const companies = data.companies || [];
|
||||
const companyRoles = data.company_roles || [];
|
||||
const globalRoles = data.global_roles || [];
|
||||
const sets = [{ key: '', label: '(role defaults only)' }].concat(data.privilege_sets || []);
|
||||
const seenAuth = new Set();
|
||||
|
||||
(data.users || []).forEach((u) => {
|
||||
(u.memberships || []).forEach((m) => {
|
||||
const tr = document.createElement('tr');
|
||||
const globalSelect = canEditGlobal()
|
||||
? '<select class="rbac-global-role" data-user-id="' + u.id + '">' +
|
||||
globalRoles.map((r) => '<option value="' + esc(r) + '"' + (u.global_role === r ? ' selected' : '') + '>' + esc(r) + '</option>').join('') +
|
||||
'</select>'
|
||||
: '<code>' + esc(u.global_role) + '</code>';
|
||||
|
||||
const roleSelect =
|
||||
'<select class="rbac-company-role" data-user-id="' + u.id + '" data-company-id="' + m.company_id + '">' +
|
||||
companyRoles
|
||||
.map((r) => '<option value="' + esc(r) + '"' + (m.role === r ? ' selected' : '') + '>' + esc(r) + '</option>')
|
||||
.join('') +
|
||||
'</select>';
|
||||
|
||||
const setSelect =
|
||||
'<select class="rbac-privilege-set" data-user-id="' + u.id + '" data-company-id="' + m.company_id + '">' +
|
||||
sets
|
||||
.map((s) => {
|
||||
const key = s.key != null ? s.key : '';
|
||||
const label = s.label || key || '(none)';
|
||||
const sel = (m.privilege_set || '') === key ? ' selected' : '';
|
||||
return '<option value="' + esc(key) + '"' + sel + '>' + esc(label) + '</option>';
|
||||
})
|
||||
.join('') +
|
||||
(m.privilege_set === 'custom' ? '<option value="" disabled>custom grants (edit via API)</option>' : '') +
|
||||
'</select>';
|
||||
|
||||
let authCell = '';
|
||||
if (!seenAuth.has(u.id)) {
|
||||
seenAuth.add(u.id);
|
||||
const fails = Number(u.auth_failures || 0);
|
||||
if (canEditGlobal() && fails > 0) {
|
||||
authCell =
|
||||
'<span class="muted">' + fails + ' fail</span> ' +
|
||||
'<button type="button" class="btn btn--secondary rbac-clear-auth" data-user-id="' + u.id + '">Clear lockouts</button>';
|
||||
} else if (fails > 0) {
|
||||
authCell = '<span class="muted">' + fails + ' recent fail</span>';
|
||||
} else {
|
||||
authCell = '<span class="muted">—</span>';
|
||||
}
|
||||
}
|
||||
|
||||
tr.innerHTML =
|
||||
'<td>' + esc(u.username) + '</td>' +
|
||||
'<td>' + globalSelect + '</td>' +
|
||||
'<td><code>' + esc(m.slug) + '</code> ' + esc(m.name) + '</td>' +
|
||||
'<td>' + roleSelect + '</td>' +
|
||||
'<td>' + setSelect + '</td>' +
|
||||
'<td>' + authCell + '</td>';
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
if (!(u.memberships || []).length && canEditGlobal()) {
|
||||
const tr = document.createElement('tr');
|
||||
const fails = Number(u.auth_failures || 0);
|
||||
let authCell = fails > 0
|
||||
? '<button type="button" class="btn btn--secondary rbac-clear-auth" data-user-id="' + u.id + '">Clear lockouts (' + fails + ')</button>'
|
||||
: '<span class="muted">—</span>';
|
||||
tr.innerHTML =
|
||||
'<td>' + esc(u.username) + '</td>' +
|
||||
'<td><code>' + esc(u.global_role) + '</code></td>' +
|
||||
'<td colspan="3" class="muted">No company membership</td>' +
|
||||
'<td>' + authCell + '</td>';
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
});
|
||||
|
||||
if (!tbody.children.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="muted">No users in scope.</td></tr>';
|
||||
}
|
||||
|
||||
const hint = document.getElementById('rbac-scope-hint');
|
||||
if (hint) {
|
||||
hint.textContent =
|
||||
'Slug admin = company owner/admin (graphs company scope). Root may change global roles. Privilege sets add remote-access grants to members.';
|
||||
if (companies.length) {
|
||||
hint.textContent += ' Companies: ' + companies.map((c) => c.slug).join(', ');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function postAction(action, body) {
|
||||
await fetchJson(basePath() + '/api/rbac.php?action=' + encodeURIComponent(action), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
function bindChanges() {
|
||||
document.addEventListener('click', async (ev) => {
|
||||
const btn = ev.target && ev.target.closest ? ev.target.closest('.rbac-clear-auth') : null;
|
||||
if (!btn || !canEditGlobal()) return;
|
||||
const userId = Number(btn.getAttribute('data-user-id'));
|
||||
if (!userId) return;
|
||||
try {
|
||||
await postAction('clear_auth_lockouts', { user_id: userId });
|
||||
setStatus('Auth lockouts cleared.');
|
||||
load();
|
||||
} catch (e) {
|
||||
setStatus(String(e.message || e), true);
|
||||
}
|
||||
});
|
||||
document.addEventListener('change', async (ev) => {
|
||||
const t = ev.target;
|
||||
if (!t || !t.classList) return;
|
||||
try {
|
||||
if (t.classList.contains('rbac-global-role') && canEditGlobal()) {
|
||||
await postAction('set_global_role', {
|
||||
user_id: Number(t.getAttribute('data-user-id')),
|
||||
role: t.value,
|
||||
});
|
||||
setStatus('Global role updated.');
|
||||
return;
|
||||
}
|
||||
if (t.classList.contains('rbac-company-role')) {
|
||||
await postAction('set_company_role', {
|
||||
user_id: Number(t.getAttribute('data-user-id')),
|
||||
company_id: Number(t.getAttribute('data-company-id')),
|
||||
role: t.value,
|
||||
});
|
||||
setStatus('Company role updated.');
|
||||
return;
|
||||
}
|
||||
if (t.classList.contains('rbac-privilege-set')) {
|
||||
await postAction('apply_privilege_set', {
|
||||
user_id: Number(t.getAttribute('data-user-id')),
|
||||
company_id: Number(t.getAttribute('data-company-id')),
|
||||
privilege_set: t.value,
|
||||
});
|
||||
setStatus('Privilege set applied.');
|
||||
}
|
||||
} catch (e) {
|
||||
setStatus(String(e.message || e), true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function load() {
|
||||
setStatus('Loading…');
|
||||
try {
|
||||
const data = await fetchJson(basePath() + '/api/rbac.php?action=panel');
|
||||
renderPanel(data);
|
||||
setStatus('Ready — changes save on selection.');
|
||||
} catch (e) {
|
||||
setStatus(String(e.message || e), true);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
bindChanges();
|
||||
load();
|
||||
});
|
||||
})();
|
||||
801
sim/cluster0/lab-seeds/backend/public/assets/js/remote_access.js
Normal file
801
sim/cluster0/lab-seeds/backend/public/assets/js/remote_access.js
Normal file
@@ -0,0 +1,801 @@
|
||||
(function () {
|
||||
const STORAGE = {
|
||||
colWidths: 'ra_console_col_widths',
|
||||
colOrder: 'ra_console_col_order',
|
||||
};
|
||||
|
||||
const RA_COLUMNS = [
|
||||
{ key: 'device_name', label: 'Device' },
|
||||
{ key: 'status', label: 'Status' },
|
||||
{ key: 'opt_in', label: 'Opt-in' },
|
||||
{ key: 'android_api', label: 'Android API' },
|
||||
{ key: 'android_version', label: 'Android version' },
|
||||
{ key: 'last_seen', label: 'Last seen' },
|
||||
{ key: 'app_version', label: 'App' },
|
||||
];
|
||||
|
||||
const DEFAULT_COL_WIDTHS = {
|
||||
expand: 40,
|
||||
device_name: 160,
|
||||
status: 120,
|
||||
opt_in: 88,
|
||||
android_api: 96,
|
||||
android_version: 112,
|
||||
last_seen: 152,
|
||||
app_version: 80,
|
||||
actions: 220,
|
||||
};
|
||||
|
||||
function basePath() {
|
||||
return document.body.getAttribute('data-base-path') || '';
|
||||
}
|
||||
|
||||
function canOperate() {
|
||||
return document.body.getAttribute('data-can-ra-operate') === '1';
|
||||
}
|
||||
|
||||
function canAdmin() {
|
||||
return document.body.getAttribute('data-can-ra-admin') === '1';
|
||||
}
|
||||
|
||||
function lsGet(key, fallback) {
|
||||
try {
|
||||
const v = localStorage.getItem(key);
|
||||
return v === null ? fallback : v;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function lsSet(key, value) {
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function apiUrl(action, params) {
|
||||
const q = new URLSearchParams(params || {});
|
||||
q.set('action', action);
|
||||
return basePath() + '/api/remote_access.php?' + q.toString();
|
||||
}
|
||||
|
||||
function setStatus(msg, isError) {
|
||||
const el = document.getElementById('ra-status');
|
||||
if (!el) return;
|
||||
el.textContent = msg;
|
||||
el.classList.toggle('error', !!isError);
|
||||
}
|
||||
|
||||
async function fetchJson(url, opts) {
|
||||
const res = await fetch(url, Object.assign({ credentials: 'same-origin' }, opts || {}));
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || data.ok === false) {
|
||||
throw new Error(data.error || ('HTTP ' + res.status));
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s == null ? '' : String(s);
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function formatBytes(n) {
|
||||
const b = Number(n) || 0;
|
||||
if (b < 1024) return b + ' B';
|
||||
if (b < 1024 * 1024) return (b / 1024).toFixed(1) + ' KiB';
|
||||
if (b < 1024 * 1024 * 1024) return (b / (1024 * 1024)).toFixed(2) + ' MiB';
|
||||
return (b / (1024 * 1024 * 1024)).toFixed(2) + ' GiB';
|
||||
}
|
||||
|
||||
function getColWidths() {
|
||||
try {
|
||||
const w = JSON.parse(lsGet(STORAGE.colWidths, '{}'));
|
||||
return { ...DEFAULT_COL_WIDTHS, ...(w && typeof w === 'object' ? w : {}) };
|
||||
} catch {
|
||||
return { ...DEFAULT_COL_WIDTHS };
|
||||
}
|
||||
}
|
||||
|
||||
function saveColWidths(widths) {
|
||||
lsSet(STORAGE.colWidths, JSON.stringify(widths));
|
||||
}
|
||||
|
||||
function getOrderedColumnKeys() {
|
||||
const defaults = RA_COLUMNS.map((c) => c.key);
|
||||
try {
|
||||
const saved = JSON.parse(lsGet(STORAGE.colOrder, '[]'));
|
||||
if (Array.isArray(saved) && saved.length) {
|
||||
const out = saved.filter((k) => defaults.includes(k));
|
||||
defaults.forEach((k) => {
|
||||
if (!out.includes(k)) out.push(k);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return defaults;
|
||||
}
|
||||
|
||||
function saveColumnOrder(keys) {
|
||||
lsSet(STORAGE.colOrder, JSON.stringify(keys));
|
||||
}
|
||||
|
||||
function getDisplayColumns() {
|
||||
return getOrderedColumnKeys()
|
||||
.map((k) => RA_COLUMNS.find((c) => c.key === k))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function columnLabelByKey(key) {
|
||||
const c = RA_COLUMNS.find((col) => col.key === key);
|
||||
if (c) return c.label;
|
||||
if (key === 'actions') return 'Actions';
|
||||
return key;
|
||||
}
|
||||
|
||||
function colspan() {
|
||||
return getDisplayColumns().length + 2;
|
||||
}
|
||||
|
||||
function applyColgroup() {
|
||||
const cg = document.getElementById('ra-devices-colgroup');
|
||||
if (!cg) return;
|
||||
const widths = getColWidths();
|
||||
const order = getOrderedColumnKeys();
|
||||
let html =
|
||||
'<col class="col-expand" style="width:' + (widths.expand || 40) + 'px" />';
|
||||
order.forEach((k) => {
|
||||
html +=
|
||||
'<col data-col="' +
|
||||
esc(k) +
|
||||
'" style="width:' +
|
||||
(widths[k] || 100) +
|
||||
'px" />';
|
||||
});
|
||||
html +=
|
||||
'<col class="col-actions" style="width:' + (widths.actions || 220) + 'px" />';
|
||||
cg.innerHTML = html;
|
||||
}
|
||||
|
||||
function clearColumnDropMarkers(thead) {
|
||||
thead.querySelectorAll('.th-drop-target').forEach((el) => {
|
||||
el.classList.remove('th-drop-target');
|
||||
});
|
||||
thead.querySelectorAll('.th-dragging').forEach((el) => {
|
||||
el.classList.remove('th-dragging');
|
||||
});
|
||||
}
|
||||
|
||||
function renderHead() {
|
||||
const thead = document.getElementById('ra-devices-thead');
|
||||
if (!thead) return;
|
||||
const cols = getDisplayColumns();
|
||||
const widths = getColWidths();
|
||||
let html =
|
||||
'<tr><th class="report-tree-head" aria-label="Expand"><span class="col-resizer" data-resize-col="expand"></span></th>';
|
||||
cols.forEach((c) => {
|
||||
const w = widths[c.key] ? ' style="width:' + widths[c.key] + 'px"' : '';
|
||||
html +=
|
||||
'<th class="th-draggable"' +
|
||||
' draggable="true" data-col-key="' +
|
||||
esc(c.key) +
|
||||
'" title="Drag to reorder"' +
|
||||
w +
|
||||
' scope="col"><span class="th-inner">' +
|
||||
'<span class="th-drag" aria-hidden="true" title="Drag column">⋮⋮</span>' +
|
||||
'<span class="th-label">' +
|
||||
esc(c.label) +
|
||||
'</span></span>' +
|
||||
'<span class="col-resizer" data-resize-col="' +
|
||||
esc(c.key) +
|
||||
'"></span></th>';
|
||||
});
|
||||
html +=
|
||||
'<th class="col-actions" scope="col" style="width:' +
|
||||
(widths.actions || 220) +
|
||||
'px">Actions<span class="col-resizer" data-resize-col="actions"></span></th></tr>';
|
||||
thead.innerHTML = html;
|
||||
}
|
||||
|
||||
function bindTableLayout() {
|
||||
const table = document.getElementById('ra-devices-table');
|
||||
const thead = document.getElementById('ra-devices-thead');
|
||||
if (!table || !thead || table.dataset.layoutBound === '1') return;
|
||||
table.dataset.layoutBound = '1';
|
||||
|
||||
thead.addEventListener('dragstart', (e) => {
|
||||
if (e.target.closest('.col-resizer')) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
const th = e.target.closest('th.th-draggable[data-col-key]');
|
||||
if (!th) return;
|
||||
const sourceKey = th.getAttribute('data-col-key');
|
||||
if (!sourceKey) return;
|
||||
table.dataset.dragColKey = sourceKey;
|
||||
th.classList.add('th-dragging');
|
||||
const ghost = document.createElement('div');
|
||||
ghost.className = 'col-drag-ghost';
|
||||
ghost.textContent = columnLabelByKey(sourceKey);
|
||||
ghost.setAttribute('aria-hidden', 'true');
|
||||
document.body.appendChild(ghost);
|
||||
table._dragGhost = ghost;
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', sourceKey);
|
||||
try {
|
||||
e.dataTransfer.setDragImage(ghost, 16, 14);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
});
|
||||
|
||||
thead.addEventListener('dragend', () => {
|
||||
clearColumnDropMarkers(thead);
|
||||
delete table.dataset.dragColKey;
|
||||
if (table._dragGhost) {
|
||||
table._dragGhost.remove();
|
||||
table._dragGhost = null;
|
||||
}
|
||||
});
|
||||
|
||||
thead.addEventListener('dragenter', (e) => {
|
||||
if (!table.dataset.dragColKey) return;
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
thead.addEventListener('dragover', (e) => {
|
||||
if (!table.dataset.dragColKey) return;
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
|
||||
const th = e.target.closest('th[data-col-key]');
|
||||
if (!th) return;
|
||||
const active = thead.querySelector('.th-drop-target');
|
||||
if (active && active !== th) active.classList.remove('th-drop-target');
|
||||
th.classList.add('th-drop-target');
|
||||
});
|
||||
|
||||
thead.addEventListener('dragleave', (e) => {
|
||||
const th = e.target.closest('th[data-col-key]');
|
||||
if (!th) return;
|
||||
const rel = e.relatedTarget;
|
||||
if (rel && th.contains(rel)) return;
|
||||
th.classList.remove('th-drop-target');
|
||||
});
|
||||
|
||||
thead.addEventListener('drop', (e) => {
|
||||
const th = e.target.closest('th[data-col-key]');
|
||||
if (!th) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const sourceKey =
|
||||
table.dataset.dragColKey ||
|
||||
(e.dataTransfer && e.dataTransfer.getData('text/plain')) ||
|
||||
'';
|
||||
const targetKey = th.getAttribute('data-col-key');
|
||||
clearColumnDropMarkers(thead);
|
||||
delete table.dataset.dragColKey;
|
||||
if (table._dragGhost) {
|
||||
table._dragGhost.remove();
|
||||
table._dragGhost = null;
|
||||
}
|
||||
if (!sourceKey || !targetKey || sourceKey === targetKey) return;
|
||||
const order = getOrderedColumnKeys();
|
||||
const from = order.indexOf(sourceKey);
|
||||
const to = order.indexOf(targetKey);
|
||||
if (from < 0 || to < 0) return;
|
||||
order.splice(from, 1);
|
||||
order.splice(to, 0, sourceKey);
|
||||
saveColumnOrder(order);
|
||||
refreshTableChrome(lastDevices, lastExpanded);
|
||||
});
|
||||
|
||||
table.addEventListener('mousedown', (e) => {
|
||||
const handle = e.target.closest('.col-resizer');
|
||||
if (!handle) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const colKey = handle.getAttribute('data-resize-col');
|
||||
const th = handle.closest('th');
|
||||
if (!colKey || !th) return;
|
||||
const widths = getColWidths();
|
||||
const startX = e.clientX;
|
||||
const startW = th.getBoundingClientRect().width;
|
||||
|
||||
const onMove = (ev) => {
|
||||
const w = Math.max(48, Math.round(startW + (ev.clientX - startX)));
|
||||
widths[colKey] = w;
|
||||
th.style.width = w + 'px';
|
||||
th.style.minWidth = w + 'px';
|
||||
th.style.maxWidth = w + 'px';
|
||||
const cg = document.getElementById('ra-devices-colgroup');
|
||||
const col = cg && cg.querySelector('col[data-col="' + colKey + '"]');
|
||||
if (col) col.style.width = w + 'px';
|
||||
else if (colKey === 'actions') {
|
||||
const actionCol = cg && cg.querySelector('col.col-actions');
|
||||
if (actionCol) actionCol.style.width = w + 'px';
|
||||
} else if (colKey === 'expand') {
|
||||
const expandCol = cg && cg.querySelector('col.col-expand');
|
||||
if (expandCol) expandCol.style.width = w + 'px';
|
||||
}
|
||||
};
|
||||
const onUp = () => {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
saveColWidths(widths);
|
||||
applyColgroup();
|
||||
};
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
});
|
||||
}
|
||||
|
||||
function statusBadge(d) {
|
||||
const label = d.status_label || '';
|
||||
if (label === 'needs_whitelist') {
|
||||
return '<span class="tag-pill tag-pill--warn">Needs whitelist</span>';
|
||||
}
|
||||
if (label === 'whitelisted') {
|
||||
return '<span class="tag-pill">Whitelisted</span>';
|
||||
}
|
||||
if (label === 'stale') {
|
||||
return '<span class="muted">Stale</span>';
|
||||
}
|
||||
if (label === 'not_opted_in') {
|
||||
return '<span class="muted">Not opted in</span>';
|
||||
}
|
||||
return '<span class="muted">Polling</span>';
|
||||
}
|
||||
|
||||
function deviceTitle(d) {
|
||||
const name = (d.device_name || d.device_display || '').trim();
|
||||
if (name) return name;
|
||||
const id = String(d.device_id || '');
|
||||
if (id.length > 14) return id.slice(0, 8) + '…' + id.slice(-4);
|
||||
return id;
|
||||
}
|
||||
|
||||
function formatAbis(abis) {
|
||||
if (!Array.isArray(abis) || !abis.length) return '—';
|
||||
return abis.join(', ');
|
||||
}
|
||||
|
||||
function formatCell(key, d) {
|
||||
switch (key) {
|
||||
case 'device_name':
|
||||
return (
|
||||
'<code class="ra-device-id" title="' +
|
||||
esc(d.device_id) +
|
||||
'">' +
|
||||
esc(deviceTitle(d)) +
|
||||
'</code>'
|
||||
);
|
||||
case 'status':
|
||||
return statusBadge(d);
|
||||
case 'opt_in':
|
||||
return esc(d.opt_in_mode || 'none');
|
||||
case 'android_api':
|
||||
return d.sdk_int != null && d.sdk_int !== '' ? esc(String(d.sdk_int)) : '—';
|
||||
case 'android_version':
|
||||
return esc(d.os_release || '—');
|
||||
case 'last_seen':
|
||||
return esc(d.last_seen_at || '—');
|
||||
case 'app_version':
|
||||
return esc(d.app_version || '—');
|
||||
default:
|
||||
return '—';
|
||||
}
|
||||
}
|
||||
|
||||
function detailLines(d) {
|
||||
const lines = [];
|
||||
lines.push('Device ID: ' + (d.device_id || '—'));
|
||||
const name = (d.device_name || d.device_display || '').trim();
|
||||
if (name) lines.push('Device name: ' + name);
|
||||
if (d.manufacturer || d.model) {
|
||||
lines.push('Hardware: ' + [d.manufacturer, d.model].filter(Boolean).join(' '));
|
||||
}
|
||||
if (d.brand) lines.push('Brand: ' + d.brand);
|
||||
if (d.product) lines.push('Product: ' + d.product);
|
||||
if (d.hardware_device) lines.push('Device codename: ' + d.hardware_device);
|
||||
if (d.sdk_int != null) lines.push('Android API: ' + d.sdk_int);
|
||||
if (d.os_release) lines.push('Android version: ' + d.os_release);
|
||||
if (d.abis && d.abis.length) lines.push('ABIs: ' + formatAbis(d.abis));
|
||||
if (d.lan_ip) lines.push('Device LAN IP: ' + d.lan_ip);
|
||||
if (d.device_wan_ip) lines.push('Device WAN IP: ' + d.device_wan_ip);
|
||||
if (d.poll_source_ip) {
|
||||
let pollLabel = 'Poll source IP (server view)';
|
||||
if (d.poll_source_is_private) {
|
||||
pollLabel += ' — intra/private hop, not device WAN';
|
||||
}
|
||||
lines.push(pollLabel + ': ' + d.poll_source_ip);
|
||||
}
|
||||
if (d.vpn_ip) lines.push('VPN IP: ' + d.vpn_ip);
|
||||
if (d.vpn_route_scope) lines.push('VPN route scope: ' + d.vpn_route_scope);
|
||||
if (d.vpn_app_scope) lines.push('VPN app scope: ' + d.vpn_app_scope);
|
||||
if (d.vpn_public_key) lines.push('VPN public key: ' + d.vpn_public_key);
|
||||
if (d.wg_rx_bytes != null || d.wg_tx_bytes != null) {
|
||||
const rx = formatBytes(d.wg_rx_bytes || 0);
|
||||
const tx = formatBytes(d.wg_tx_bytes || 0);
|
||||
lines.push('VPN traffic (BE wg): ↓' + rx + ' ↑' + tx);
|
||||
}
|
||||
if (d.wg_latest_handshake > 0) {
|
||||
lines.push('VPN last handshake: ' + new Date(d.wg_latest_handshake * 1000).toISOString());
|
||||
}
|
||||
if (d.wg_endpoint) lines.push('VPN endpoint: ' + d.wg_endpoint);
|
||||
lines.push('App version: ' + (d.app_version || '—'));
|
||||
lines.push('Opt-in mode: ' + (d.opt_in_mode || 'none'));
|
||||
lines.push('Last seen: ' + (d.last_seen_at || '—'));
|
||||
if (d.notes) lines.push('Notes: ' + d.notes);
|
||||
if (d.issue_count != null) lines.push('Linked issues: ' + d.issue_count);
|
||||
if (d.graph_session_count != null) lines.push('Graph sessions: ' + d.graph_session_count);
|
||||
return lines;
|
||||
}
|
||||
|
||||
function detailLinks(d) {
|
||||
const links = d.links || [];
|
||||
if (!links.length) return '';
|
||||
let html = '<div class="ra-detail-links">';
|
||||
links.forEach((link) => {
|
||||
html +=
|
||||
'<a class="btn btn-sm" href="' +
|
||||
esc(link.href) +
|
||||
'">' +
|
||||
esc(link.label) +
|
||||
'</a> ';
|
||||
});
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
function isInteractiveTarget(el) {
|
||||
return !!el.closest('.ra-device-actions, .ra-detail-links, .report-tree-toggle, button, a, input, select, textarea, label');
|
||||
}
|
||||
|
||||
function bindRaTree(root) {
|
||||
root.querySelectorAll('.report-row[data-device-id]').forEach((row) => {
|
||||
const btn = row.querySelector('.report-tree-toggle');
|
||||
const briefId = btn ? btn.getAttribute('aria-controls') : null;
|
||||
const briefRow = briefId ? document.getElementById(briefId) : null;
|
||||
if (!briefRow || !btn) return;
|
||||
|
||||
const toggle = (e) => {
|
||||
if (e && isInteractiveTarget(e.target)) return;
|
||||
const open = briefRow.hidden;
|
||||
briefRow.hidden = !open;
|
||||
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
row.classList.toggle('report-row--open', open);
|
||||
};
|
||||
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
toggle(e);
|
||||
});
|
||||
row.addEventListener('click', (e) => toggle(e));
|
||||
row.addEventListener('keydown', (e) => {
|
||||
if (isInteractiveTarget(e.target)) return;
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
toggle(e);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function captureExpandedDeviceIds() {
|
||||
const ids = new Set();
|
||||
const tbody = document.getElementById('ra-devices-tbody');
|
||||
if (!tbody) return ids;
|
||||
tbody.querySelectorAll('.report-row.report-row--open[data-device-id]').forEach((row) => {
|
||||
const id = row.getAttribute('data-device-id');
|
||||
if (id) ids.add(id);
|
||||
});
|
||||
return ids;
|
||||
}
|
||||
|
||||
let lastDevices = [];
|
||||
let lastExpanded = new Set();
|
||||
|
||||
function renderDevices(devices, expandedIds) {
|
||||
const tbody = document.getElementById('ra-devices-tbody');
|
||||
if (!tbody) return;
|
||||
lastDevices = devices || [];
|
||||
if (expandedIds) lastExpanded = expandedIds;
|
||||
|
||||
const cols = getDisplayColumns();
|
||||
const span = colspan();
|
||||
|
||||
if (!devices || !devices.length) {
|
||||
tbody.innerHTML =
|
||||
'<tr><td colspan="' +
|
||||
span +
|
||||
'" class="muted">No devices have polled yet. Enable remote access on a device (dev settings) and wait for the next poll.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
devices.forEach((d, i) => {
|
||||
const rowKey = 'ra-' + i;
|
||||
const briefId = 'ra-brief-' + rowKey;
|
||||
const wl = Number(d.whitelisted) === 1;
|
||||
const optIn = d.opt_in_mode || 'none';
|
||||
const openReady = wl && (optIn === 'wireguard' || optIn === 'rssh');
|
||||
const openHint = !canOperate()
|
||||
? 'Need remote_access_operate permission'
|
||||
: !wl
|
||||
? 'Whitelist device first'
|
||||
: openReady
|
||||
? 'Open session — device connects on next poll (≤7 min)'
|
||||
: 'Phone must poll with RSSH/WG enabled (dev settings on device; wait ≤7 min)';
|
||||
const openDisabled = canOperate() ? '' : ' disabled';
|
||||
const wlDisabled = canAdmin() ? '' : ' disabled';
|
||||
const isOpen = lastExpanded.has(String(d.device_id || ''));
|
||||
const rowClass =
|
||||
'report-row' +
|
||||
(d.needs_whitelist ? ' ra-device-row--needs-wl' : '') +
|
||||
(isOpen ? ' report-row--open' : '');
|
||||
|
||||
html += '<tr class="' + rowClass + '" data-device-id="' + esc(d.device_id) + '" tabindex="0">';
|
||||
html +=
|
||||
'<td class="report-tree-cell"><button type="button" class="report-tree-toggle" aria-expanded="' +
|
||||
(isOpen ? 'true' : 'false') +
|
||||
'" aria-controls="' +
|
||||
briefId +
|
||||
'" title="Show device details"><span class="report-tree-arrow" aria-hidden="true"></span></button></td>';
|
||||
|
||||
cols.forEach((c) => {
|
||||
html += '<td data-col="' + esc(c.key) + '">' + formatCell(c.key, d) + '</td>';
|
||||
});
|
||||
|
||||
html +=
|
||||
'<td class="ra-device-actions col-actions">' +
|
||||
'<button type="button" class="btn btn-sm btn-primary"' +
|
||||
openDisabled +
|
||||
' data-open-session="' +
|
||||
esc(d.device_id) +
|
||||
'" title="' +
|
||||
esc(openHint) +
|
||||
'">Open session</button> ' +
|
||||
'<button type="button" class="btn btn-sm"' +
|
||||
wlDisabled +
|
||||
' data-toggle-wl="' +
|
||||
esc(d.device_id) +
|
||||
'" data-wl="' +
|
||||
(wl ? '0' : '1') +
|
||||
'">' +
|
||||
(wl ? 'Revoke WL' : 'Whitelist') +
|
||||
'</button> ' +
|
||||
'<button type="button" class="btn btn-sm" data-copy-id="' +
|
||||
esc(d.device_id) +
|
||||
'">Copy ID</button>' +
|
||||
'</td>';
|
||||
html += '</tr>';
|
||||
|
||||
html +=
|
||||
'<tr class="report-brief-row" id="' +
|
||||
briefId +
|
||||
'"' +
|
||||
(isOpen ? '' : ' hidden') +
|
||||
'><td colspan="' +
|
||||
span +
|
||||
'" class="report-brief-cell"><div class="report-brief-panel"><div class="report-brief">';
|
||||
detailLines(d).forEach((line) => {
|
||||
html += '<p>' + esc(line) + '</p>';
|
||||
});
|
||||
html += detailLinks(d);
|
||||
html += '</div></div></td></tr>';
|
||||
});
|
||||
tbody.innerHTML = html;
|
||||
bindRaTree(tbody);
|
||||
}
|
||||
|
||||
function refreshTableChrome(devices, expandedIds) {
|
||||
applyColgroup();
|
||||
renderHead();
|
||||
renderDevices(devices || lastDevices, expandedIds || lastExpanded);
|
||||
}
|
||||
|
||||
function renderSessions(active, inactive) {
|
||||
const aBody = document.getElementById('ra-active-tbody');
|
||||
const iBody = document.getElementById('ra-inactive-tbody');
|
||||
if (aBody) {
|
||||
aBody.innerHTML = '';
|
||||
if (!active || !active.length) {
|
||||
aBody.innerHTML = '<tr><td colspan="6" class="muted">No pending or active sessions.</td></tr>';
|
||||
}
|
||||
(active || []).forEach((s) => {
|
||||
const tr = document.createElement('tr');
|
||||
const closeBtn = canOperate()
|
||||
? '<button type="button" class="btn btn-sm" data-close-session="' + esc(s.session_id) + '" data-device="' + esc(s.device_id) + '">Close</button>'
|
||||
: '';
|
||||
let endpoint = s.endpoint || '—';
|
||||
if (s.tunnel === 'ssh_reverse' && s.rssh_remote_bind) {
|
||||
endpoint = endpoint + ' → ' + esc(s.rssh_remote_bind);
|
||||
}
|
||||
let operator = '';
|
||||
if (s.rssh_operator) {
|
||||
operator =
|
||||
'<div class="ra-rssh-cmds muted"><code>' +
|
||||
esc(s.rssh_operator.shell || '') +
|
||||
'</code></div>';
|
||||
}
|
||||
tr.innerHTML =
|
||||
'<td><code>' + esc(s.session_id) + '</code></td>' +
|
||||
'<td><code>' + esc(s.device_id) + '</code></td>' +
|
||||
'<td>' + esc(s.status) + '</td>' +
|
||||
'<td>' + esc(s.tunnel) + '</td>' +
|
||||
'<td>' + endpoint + operator + '</td>' +
|
||||
'<td>' + closeBtn + '</td>';
|
||||
aBody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
if (iBody) {
|
||||
iBody.innerHTML = '';
|
||||
(inactive || []).forEach((s) => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML =
|
||||
'<td><code>' + esc(s.session_id) + '</code></td>' +
|
||||
'<td><code>' + esc(s.device_id) + '</code></td>' +
|
||||
'<td>' + esc(s.status) + '</td>' +
|
||||
'<td>' + esc(s.closed_at || '—') + '</td>' +
|
||||
'<td>' + esc(s.close_reason || '—') + '</td>';
|
||||
iBody.appendChild(tr);
|
||||
});
|
||||
if (!inactive || !inactive.length) {
|
||||
iBody.innerHTML = '<tr><td colspan="5" class="muted">No closed sessions yet.</td></tr>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderEvents(events) {
|
||||
const tbody = document.getElementById('ra-events-tbody');
|
||||
if (!tbody) return;
|
||||
tbody.innerHTML = '';
|
||||
if (!events || !events.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="4" class="muted">No audit events.</td></tr>';
|
||||
return;
|
||||
}
|
||||
(events || []).forEach((e) => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML =
|
||||
'<td>' + esc(e.created_at) + '</td>' +
|
||||
'<td><code>' + esc(e.device_id) + '</code></td>' +
|
||||
'<td>' + esc(e.action) + '</td>' +
|
||||
'<td>' + esc(e.reason || '—') + '</td>';
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
let lastConfig = {};
|
||||
|
||||
async function refresh() {
|
||||
const expanded = captureExpandedDeviceIds();
|
||||
setStatus('Loading…');
|
||||
try {
|
||||
const data = await fetchJson(apiUrl('dashboard'));
|
||||
lastConfig = data.config || {};
|
||||
refreshTableChrome(data.devices, expanded);
|
||||
renderSessions(data.active_sessions, data.inactive_sessions);
|
||||
renderEvents(data.recent_events);
|
||||
const ep = lastConfig.wg_endpoint ? ' · WG ' + lastConfig.wg_endpoint : '';
|
||||
setStatus('Updated ' + new Date().toLocaleTimeString() + ep);
|
||||
} catch (e) {
|
||||
setStatus('Failed: ' + e.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('click', async (ev) => {
|
||||
const t = ev.target;
|
||||
if (!(t instanceof HTMLElement)) return;
|
||||
if (t.closest('.report-tree-toggle, .ra-detail-links a')) return;
|
||||
const copyId = t.getAttribute('data-copy-id');
|
||||
if (copyId) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(copyId);
|
||||
setStatus('Copied device ID');
|
||||
} catch (e) {
|
||||
setStatus('Copy failed', true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const openId = t.getAttribute('data-open-session');
|
||||
if (openId) {
|
||||
if (!canOperate()) return;
|
||||
const dev = (lastDevices || []).find((d) => String(d.device_id) === openId);
|
||||
const wl = dev && Number(dev.whitelisted) === 1;
|
||||
const optIn = dev ? dev.opt_in_mode || 'none' : 'none';
|
||||
if (!wl) {
|
||||
setStatus('Whitelist device ' + openId + ' first', true);
|
||||
return;
|
||||
}
|
||||
if (optIn !== 'wireguard' && optIn !== 'rssh') {
|
||||
setStatus(
|
||||
'Device opt-in is "' +
|
||||
optIn +
|
||||
'". On phone: dev settings → Remote access → RSSH, then wait for poll (≤7 min).',
|
||||
true
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setStatus('Opening session for ' + openId + '…');
|
||||
await fetchJson(apiUrl('open_session'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ device_id: openId }),
|
||||
});
|
||||
setStatus('Session opened — device will connect on next poll (≤7 min)');
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setStatus('Open session: ' + e.message, true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const closeId = t.getAttribute('data-close-session');
|
||||
if (closeId) {
|
||||
if (!canOperate()) return;
|
||||
try {
|
||||
await fetchJson(apiUrl('close_session'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
session_id: closeId,
|
||||
device_id: t.getAttribute('data-device') || '',
|
||||
}),
|
||||
});
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setStatus('Close session: ' + e.message, true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const wlDevice = t.getAttribute('data-toggle-wl');
|
||||
if (wlDevice) {
|
||||
if (!canAdmin()) return;
|
||||
try {
|
||||
await fetchJson(apiUrl('whitelist'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
device_id: wlDevice,
|
||||
whitelisted: t.getAttribute('data-wl') === '1',
|
||||
}),
|
||||
});
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setStatus('Whitelist: ' + e.message, true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const form = document.getElementById('ra-whitelist-form');
|
||||
if (form) {
|
||||
if (!canAdmin()) form.hidden = true;
|
||||
form.addEventListener('submit', async (ev) => {
|
||||
ev.preventDefault();
|
||||
if (!canAdmin()) return;
|
||||
const fd = new FormData(form);
|
||||
try {
|
||||
await fetchJson(apiUrl('whitelist'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
device_id: fd.get('device_id'),
|
||||
notes: fd.get('notes'),
|
||||
whitelisted: true,
|
||||
}),
|
||||
});
|
||||
form.reset();
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setStatus('Whitelist add: ' + e.message, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
bindTableLayout();
|
||||
applyColgroup();
|
||||
renderHead();
|
||||
refresh();
|
||||
setInterval(refresh, 30000);
|
||||
})();
|
||||
448
sim/cluster0/lab-seeds/backend/public/assets/js/short_links.js
Normal file
448
sim/cluster0/lab-seeds/backend/public/assets/js/short_links.js
Normal file
@@ -0,0 +1,448 @@
|
||||
(function () {
|
||||
function basePath() {
|
||||
return document.body.getAttribute('data-base-path') || '';
|
||||
}
|
||||
|
||||
function canOperate() {
|
||||
return document.body.getAttribute('data-can-sl-operate') === '1';
|
||||
}
|
||||
|
||||
function canAdmin() {
|
||||
return document.body.getAttribute('data-can-sl-admin') === '1';
|
||||
}
|
||||
|
||||
function apiUrl(action, params) {
|
||||
const q = new URLSearchParams(params || {});
|
||||
q.set('action', action);
|
||||
return basePath() + '/api/short_links.php?' + q.toString();
|
||||
}
|
||||
|
||||
function setStatus(msg, isError) {
|
||||
const el = document.getElementById('sl-status');
|
||||
if (!el) return;
|
||||
el.textContent = msg;
|
||||
el.classList.toggle('error', !!isError);
|
||||
}
|
||||
|
||||
async function fetchJson(url, opts) {
|
||||
const res = await fetch(url, Object.assign({ credentials: 'same-origin' }, opts || {}));
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || data.ok === false) {
|
||||
const err = data.error || data.reason || ('HTTP ' + res.status);
|
||||
throw new Error(err);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s == null ? '' : String(s);
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function truncate(s, n) {
|
||||
const t = String(s || '');
|
||||
return t.length <= n ? t : t.slice(0, n - 1) + '…';
|
||||
}
|
||||
|
||||
let selectedBearerId = 0;
|
||||
let selectedBearerLabel = '';
|
||||
|
||||
function bearerCaps(b) {
|
||||
const parts = [];
|
||||
if (b.can_temporary) parts.push('temp');
|
||||
if (b.can_permanent) parts.push('perm');
|
||||
return parts.length ? parts.join(', ') : '—';
|
||||
}
|
||||
|
||||
function renderBearers(bearers) {
|
||||
const tbody = document.getElementById('sl-bearers-tbody');
|
||||
if (!tbody) return;
|
||||
tbody.innerHTML = '';
|
||||
if (!bearers || !bearers.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="8" class="muted">No bearer tokens yet — mint one below.</td></tr>';
|
||||
return;
|
||||
}
|
||||
(bearers || []).forEach((b) => {
|
||||
const tr = document.createElement('tr');
|
||||
const active = b.active !== false && !b.revoked_at;
|
||||
const status = active ? '<span class="tag-pill">active</span>' : 'revoked';
|
||||
const revokeBtn = canOperate() && active
|
||||
? ' <button type="button" class="btn btn-sm" data-revoke-bearer="' + esc(String(b.id)) + '">Revoke</button>'
|
||||
: '';
|
||||
const viewBtn =
|
||||
'<button type="button" class="btn btn-sm btn-primary" data-view-links="' + esc(String(b.id)) + '" data-label="' + esc(b.label || '') + '">Links</button>';
|
||||
tr.innerHTML =
|
||||
'<td>' + esc(b.id) + '</td>' +
|
||||
'<td>' + esc(b.label) + '</td>' +
|
||||
'<td>' + status + '</td>' +
|
||||
'<td><code>' + esc(bearerCaps(b)) + '</code></td>' +
|
||||
'<td>' + esc(b.link_count) + (b.expired_link_count > 0 ? ' <span class="muted">(' + esc(b.expired_link_count) + ' exp)</span>' : '') + '</td>' +
|
||||
'<td>' + esc(b.rate_limit_per_hour) + '</td>' +
|
||||
'<td>' + esc(b.created_at || '—') + '</td>' +
|
||||
'<td>' + viewBtn + revokeBtn + '</td>';
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
function renderSigners(signers) {
|
||||
const tbody = document.getElementById('sl-signers-tbody');
|
||||
if (!tbody) return;
|
||||
tbody.innerHTML = '';
|
||||
if (!signers || !signers.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="muted">No signer keys — mint one for permanent links.</td></tr>';
|
||||
return;
|
||||
}
|
||||
(signers || []).forEach((s) => {
|
||||
const tr = document.createElement('tr');
|
||||
const active = s.active !== false && !s.revoked_at;
|
||||
const status = active ? '<span class="tag-pill">active</span>' : 'revoked';
|
||||
const revokeBtn = canOperate() && active
|
||||
? ' <button type="button" class="btn btn-sm" data-revoke-signer="' + esc(String(s.id)) + '">Revoke</button>'
|
||||
: '';
|
||||
tr.innerHTML =
|
||||
'<td>' + esc(s.id) + '</td>' +
|
||||
'<td>' + esc(s.label) + '</td>' +
|
||||
'<td>' + status + '</td>' +
|
||||
'<td>' + esc(s.created_at || '—') + '</td>' +
|
||||
'<td>' + revokeBtn + '</td>';
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
function renderLinks(links) {
|
||||
const tbody = document.getElementById('sl-links-tbody');
|
||||
if (!tbody) return;
|
||||
tbody.innerHTML = '';
|
||||
if (!links || !links.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="muted">No links for this bearer.</td></tr>';
|
||||
return;
|
||||
}
|
||||
(links || []).forEach((l) => {
|
||||
const tr = document.createElement('tr');
|
||||
const exp = l.expired
|
||||
? '<span class="muted">expired</span>'
|
||||
: (l.ttl_seconds === 0 || !l.expires_at ? '<span class="tag-pill">permanent</span>' : esc(l.expires_at));
|
||||
tr.innerHTML =
|
||||
'<td><a href="' + esc(l.short_url) + '" target="_blank" rel="noopener">' + esc(l.short_url) + '</a> ' +
|
||||
'<button type="button" class="btn btn-sm" data-copy="' + esc(l.short_url) + '">Copy</button></td>' +
|
||||
'<td title="' + esc(l.origin) + '">' + esc(truncate(l.origin, 64)) + '</td>' +
|
||||
'<td>' + exp + '</td>' +
|
||||
'<td>' + esc(l.access_count) + '</td>' +
|
||||
'<td><a class="btn btn-sm" href="' + esc(l.qr_url) + '" target="_blank" rel="noopener">PNG</a></td>';
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
function renderAudit(rows) {
|
||||
const tbody = document.getElementById('sl-audit-tbody');
|
||||
if (!tbody) return;
|
||||
tbody.innerHTML = '';
|
||||
if (!rows || !rows.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="muted">No audit events.</td></tr>';
|
||||
return;
|
||||
}
|
||||
(rows || []).forEach((e) => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML =
|
||||
'<td>' + esc(e.created_at) + '</td>' +
|
||||
'<td>' + esc(e.event) + '</td>' +
|
||||
'<td>' + esc(e.bearer_id != null ? e.bearer_id : '—') + '</td>' +
|
||||
'<td>' + esc(e.slug || '—') + '</td>' +
|
||||
'<td>' + esc(e.detail || '—') + '</td>';
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
function showSecretModal(modalId, valueId, value) {
|
||||
const modal = document.getElementById(modalId);
|
||||
const val = document.getElementById(valueId);
|
||||
if (!modal || !val) return;
|
||||
val.textContent = value;
|
||||
modal.hidden = false;
|
||||
}
|
||||
|
||||
function hideModal(modalId) {
|
||||
const modal = document.getElementById(modalId);
|
||||
if (modal) modal.hidden = true;
|
||||
}
|
||||
|
||||
function syncPermanentUi() {
|
||||
const permCb = document.getElementById('sl-permanent-cb');
|
||||
const ttlWrap = document.getElementById('sl-ttl-wrap');
|
||||
const signerWrap = document.getElementById('sl-signer-wrap');
|
||||
const ttlInput = document.getElementById('sl-ttl-input');
|
||||
const permanent = permCb && permCb.checked;
|
||||
if (ttlWrap) ttlWrap.hidden = !!permanent;
|
||||
if (signerWrap) signerWrap.hidden = !permanent;
|
||||
if (ttlInput && permanent) ttlInput.value = '0';
|
||||
}
|
||||
|
||||
function syncMintPermanentUi() {
|
||||
const permCb = document.getElementById('sl-can-permanent');
|
||||
const wrap = document.getElementById('sl-mint-signer-wrap');
|
||||
if (wrap) wrap.hidden = !(permCb && permCb.checked);
|
||||
}
|
||||
|
||||
async function loadLinks(bearerId, label) {
|
||||
selectedBearerId = bearerId;
|
||||
selectedBearerLabel = label || String(bearerId);
|
||||
const section = document.getElementById('sl-links-section');
|
||||
const labelEl = document.getElementById('sl-links-bearer-label');
|
||||
const hidden = document.getElementById('sl-shorten-bearer-id');
|
||||
if (section) section.hidden = false;
|
||||
if (labelEl) labelEl.textContent = selectedBearerLabel + ' (#' + bearerId + ')';
|
||||
if (hidden) hidden.value = String(bearerId);
|
||||
setStatus('Loading links…');
|
||||
const data = await fetchJson(apiUrl('links', { bearer_id: String(bearerId) }));
|
||||
renderLinks(data.links);
|
||||
setStatus('Links for bearer #' + bearerId);
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
setStatus('Loading…');
|
||||
try {
|
||||
const data = await fetchJson(apiUrl('dashboard'));
|
||||
renderBearers(data.bearers);
|
||||
renderSigners(data.signers);
|
||||
renderAudit(data.audit);
|
||||
if (selectedBearerId > 0) {
|
||||
await loadLinks(selectedBearerId, selectedBearerLabel);
|
||||
}
|
||||
setStatus('Updated ' + new Date().toLocaleTimeString() + ' · ' + (data.public_base || ''));
|
||||
} catch (e) {
|
||||
setStatus('Failed: ' + e.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('click', async (ev) => {
|
||||
const t = ev.target;
|
||||
if (!(t instanceof HTMLElement)) return;
|
||||
|
||||
if (t.id === 'sl-token-close' || t.closest('#sl-token-close')) {
|
||||
hideModal('sl-token-modal');
|
||||
return;
|
||||
}
|
||||
if (t.id === 'sl-signer-close' || t.closest('#sl-signer-close')) {
|
||||
hideModal('sl-signer-modal');
|
||||
return;
|
||||
}
|
||||
if (t.id === 'sl-token-copy') {
|
||||
const val = document.getElementById('sl-token-value');
|
||||
if (val) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(val.textContent || '');
|
||||
setStatus('Token copied');
|
||||
} catch (e) {
|
||||
setStatus('Copy failed', true);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (t.id === 'sl-signer-copy') {
|
||||
const val = document.getElementById('sl-signer-value');
|
||||
if (val) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(val.textContent || '');
|
||||
setStatus('Signer copied');
|
||||
} catch (e) {
|
||||
setStatus('Copy failed', true);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const copy = t.getAttribute('data-copy');
|
||||
if (copy) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(copy);
|
||||
setStatus('Copied short URL');
|
||||
} catch (e) {
|
||||
setStatus('Copy failed', true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const viewId = t.getAttribute('data-view-links');
|
||||
if (viewId) {
|
||||
try {
|
||||
await loadLinks(parseInt(viewId, 10), t.getAttribute('data-label') || '');
|
||||
} catch (e) {
|
||||
setStatus('Links: ' + e.message, true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const revokeId = t.getAttribute('data-revoke-bearer');
|
||||
if (revokeId) {
|
||||
if (!canOperate()) return;
|
||||
if (!window.confirm('Revoke bearer #' + revokeId + '?')) return;
|
||||
try {
|
||||
await fetchJson(apiUrl('revoke_bearer'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ bearer_id: parseInt(revokeId, 10) }),
|
||||
});
|
||||
if (selectedBearerId === parseInt(revokeId, 10)) {
|
||||
selectedBearerId = 0;
|
||||
const section = document.getElementById('sl-links-section');
|
||||
if (section) section.hidden = true;
|
||||
}
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setStatus('Revoke: ' + e.message, true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const revokeSignerId = t.getAttribute('data-revoke-signer');
|
||||
if (revokeSignerId) {
|
||||
if (!canOperate()) return;
|
||||
if (!window.confirm('Revoke signer #' + revokeSignerId + '?')) return;
|
||||
try {
|
||||
await fetchJson(apiUrl('revoke_signer'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ signer_id: parseInt(revokeSignerId, 10) }),
|
||||
});
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setStatus('Revoke signer: ' + e.message, true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const permCb = document.getElementById('sl-permanent-cb');
|
||||
if (permCb) {
|
||||
permCb.addEventListener('change', syncPermanentUi);
|
||||
syncPermanentUi();
|
||||
}
|
||||
|
||||
const mintPermCb = document.getElementById('sl-can-permanent');
|
||||
if (mintPermCb) {
|
||||
mintPermCb.addEventListener('change', syncMintPermanentUi);
|
||||
syncMintPermanentUi();
|
||||
}
|
||||
|
||||
const mintForm = document.getElementById('sl-mint-form');
|
||||
if (mintForm) {
|
||||
mintForm.addEventListener('submit', async (ev) => {
|
||||
ev.preventDefault();
|
||||
if (!canOperate()) return;
|
||||
const fd = new FormData(mintForm);
|
||||
try {
|
||||
setStatus('Minting bearer…');
|
||||
const data = await fetchJson(apiUrl('mint_bearer'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
label: fd.get('label'),
|
||||
rate_limit_per_hour: parseInt(String(fd.get('rate_limit') || '1000'), 10),
|
||||
can_temporary: fd.get('can_temporary') === '1',
|
||||
can_permanent: fd.get('can_permanent') === '1',
|
||||
mint_signer: fd.get('mint_signer') === '1',
|
||||
}),
|
||||
});
|
||||
mintForm.reset();
|
||||
syncMintPermanentUi();
|
||||
showSecretModal('sl-token-modal', 'sl-token-value', data.token);
|
||||
if (data.signer_key) {
|
||||
showSecretModal('sl-signer-modal', 'sl-signer-value', data.signer_key);
|
||||
}
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setStatus('Mint: ' + e.message, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const mintSignerForm = document.getElementById('sl-mint-signer-form');
|
||||
if (mintSignerForm) {
|
||||
mintSignerForm.addEventListener('submit', async (ev) => {
|
||||
ev.preventDefault();
|
||||
if (!canOperate()) return;
|
||||
const fd = new FormData(mintSignerForm);
|
||||
try {
|
||||
const data = await fetchJson(apiUrl('mint_signer'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ label: fd.get('label') }),
|
||||
});
|
||||
mintSignerForm.reset();
|
||||
showSecretModal('sl-signer-modal', 'sl-signer-value', data.signer_key);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setStatus('Mint signer: ' + e.message, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const shortenForm = document.getElementById('sl-shorten-form');
|
||||
if (shortenForm) {
|
||||
shortenForm.addEventListener('submit', async (ev) => {
|
||||
ev.preventDefault();
|
||||
if (!canOperate()) return;
|
||||
const fd = new FormData(shortenForm);
|
||||
const permanent = fd.get('permanent') === '1';
|
||||
try {
|
||||
const payload = {
|
||||
bearer_id: parseInt(String(fd.get('bearer_id') || '0'), 10),
|
||||
url: fd.get('url'),
|
||||
ttl: permanent ? 0 : parseInt(String(fd.get('ttl') || '86400'), 10),
|
||||
};
|
||||
if (permanent) {
|
||||
payload.signer = fd.get('signer');
|
||||
}
|
||||
const data = await fetchJson(apiUrl('create_link'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (data.link && data.link.short_url) {
|
||||
setStatus('Short URL: ' + data.link.short_url);
|
||||
}
|
||||
shortenForm.querySelector('[name="url"]').value = '';
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setStatus('Shorten: ' + e.message, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const purgeBtn = document.getElementById('sl-purge-btn');
|
||||
if (purgeBtn) {
|
||||
if (!canAdmin()) purgeBtn.hidden = true;
|
||||
purgeBtn.addEventListener('click', async () => {
|
||||
if (!canAdmin()) return;
|
||||
if (!window.confirm('Delete all expired links and audit rows older than 90 days?')) return;
|
||||
try {
|
||||
const data = await fetchJson(apiUrl('purge_expired'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
setStatus('Purged links=' + (data.purged && data.purged.links) + ' audit=' + (data.purged && data.purged.audit));
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setStatus('Purge: ' + e.message, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
['sl-token-modal', 'sl-signer-modal'].forEach((id) => {
|
||||
const modal = document.getElementById(id);
|
||||
if (modal) {
|
||||
modal.addEventListener('click', (ev) => {
|
||||
if (ev.target === modal) hideModal(id);
|
||||
});
|
||||
}
|
||||
});
|
||||
document.addEventListener('keydown', (ev) => {
|
||||
if (ev.key === 'Escape') {
|
||||
hideModal('sl-token-modal');
|
||||
hideModal('sl-signer-modal');
|
||||
}
|
||||
});
|
||||
|
||||
refresh();
|
||||
})();
|
||||
132
sim/cluster0/lab-seeds/backend/public/assets/js/ticket_create.js
Normal file
132
sim/cluster0/lab-seeds/backend/public/assets/js/ticket_create.js
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Create ticket/issue dialog — Issues toolbar uses "issue" type tag; Tickets uses "ticket".
|
||||
*/
|
||||
(function () {
|
||||
const META = {
|
||||
ticket: { id: 'ticket', label: 'ticket', bg: '#6366f1' },
|
||||
issue: { id: 'issue', label: 'issue', bg: '#0d9488' },
|
||||
};
|
||||
|
||||
function basePath() {
|
||||
return document.body.getAttribute('data-base-path') || '';
|
||||
}
|
||||
|
||||
function t(key) {
|
||||
if (window.CrashI18n && window.CrashI18n.t) {
|
||||
return window.CrashI18n.t(key);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
function openDialog(kind) {
|
||||
const dialog = document.getElementById('ticket-create-dialog');
|
||||
const form = document.getElementById('ticket-create-form');
|
||||
const status = document.getElementById('ticket-create-status');
|
||||
const heading = document.getElementById('ticket-create-heading');
|
||||
if (!dialog || !form) return;
|
||||
const createKind = kind === 'issue' ? 'issue' : 'ticket';
|
||||
form.dataset.createKind = createKind;
|
||||
if (heading) {
|
||||
heading.textContent = t(createKind === 'issue' ? 'issues.new' : 'tickets.new');
|
||||
}
|
||||
if (status) status.textContent = '';
|
||||
form.reset();
|
||||
if (typeof dialog.showModal === 'function') {
|
||||
dialog.showModal();
|
||||
}
|
||||
}
|
||||
|
||||
function init() {
|
||||
const dialog = document.getElementById('ticket-create-dialog');
|
||||
const form = document.getElementById('ticket-create-form');
|
||||
const cancelBtn = document.getElementById('ticket-create-cancel');
|
||||
const closeBtn = document.getElementById('ticket-create-close');
|
||||
const status = document.getElementById('ticket-create-status');
|
||||
if (!dialog || !form) return;
|
||||
|
||||
document.querySelectorAll('.js-new-issue-btn').forEach((btn) => {
|
||||
btn.addEventListener('click', (ev) => {
|
||||
ev.preventDefault();
|
||||
openDialog('issue');
|
||||
});
|
||||
});
|
||||
document.querySelectorAll('.js-new-ticket-btn').forEach((btn) => {
|
||||
btn.addEventListener('click', (ev) => {
|
||||
ev.preventDefault();
|
||||
openDialog('ticket');
|
||||
});
|
||||
});
|
||||
|
||||
function closeDialog() {
|
||||
dialog.close();
|
||||
}
|
||||
if (cancelBtn) cancelBtn.addEventListener('click', closeDialog);
|
||||
if (closeBtn) closeBtn.addEventListener('click', closeDialog);
|
||||
dialog.addEventListener('click', (ev) => {
|
||||
if (ev.target === dialog) closeDialog();
|
||||
});
|
||||
|
||||
form.addEventListener('submit', (ev) => {
|
||||
ev.preventDefault();
|
||||
const titleEl = document.getElementById('ticket-create-title');
|
||||
const briefEl = document.getElementById('ticket-create-brief');
|
||||
const bodyEl = document.getElementById('ticket-create-body');
|
||||
const kind = form.dataset.createKind === 'issue' ? 'issue' : 'ticket';
|
||||
const meta = META[kind] || META.ticket;
|
||||
const payload = {
|
||||
title: titleEl && titleEl.value ? titleEl.value.trim() : '',
|
||||
brief: briefEl && briefEl.value ? briefEl.value.trim() : '',
|
||||
body: bodyEl && bodyEl.value ? bodyEl.value.trim() : '',
|
||||
tags: [
|
||||
meta,
|
||||
{ id: 'open', label: 'open', bg: '#22c55e' },
|
||||
],
|
||||
};
|
||||
if (!payload.title) return;
|
||||
if (status) status.textContent = 'Creating…';
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', basePath() + '/api/ticket_create.php', true);
|
||||
xhr.setRequestHeader('Content-Type', 'application/json');
|
||||
xhr.onload = function () {
|
||||
let data = null;
|
||||
try {
|
||||
data = JSON.parse(xhr.responseText);
|
||||
} catch {
|
||||
if (status) status.textContent = 'Invalid response';
|
||||
return;
|
||||
}
|
||||
if (!data || !data.ok) {
|
||||
if (status) status.textContent = (data && data.error) || 'Create failed';
|
||||
return;
|
||||
}
|
||||
dialog.close();
|
||||
const view = document.body.getAttribute('data-view') || '';
|
||||
if (view === 'tickets' && typeof window.__ticketsReload === 'function') {
|
||||
window.__ticketsReload();
|
||||
} else if (data.id) {
|
||||
window.location.href = basePath() + '/?view=ticket&id=' + encodeURIComponent(String(data.id));
|
||||
} else {
|
||||
window.location.href = basePath() + '/?view=tickets';
|
||||
}
|
||||
};
|
||||
xhr.onerror = function () {
|
||||
if (status) status.textContent = 'Network error';
|
||||
};
|
||||
xhr.send(JSON.stringify(payload));
|
||||
});
|
||||
}
|
||||
|
||||
function onReady(fn) {
|
||||
if (window.CrashI18n && window.CrashI18n.ready) {
|
||||
window.CrashI18n.ready.then(fn);
|
||||
} else {
|
||||
fn();
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => onReady(init));
|
||||
} else {
|
||||
onReady(init);
|
||||
}
|
||||
})();
|
||||
373
sim/cluster0/lab-seeds/backend/public/assets/js/tickets.js
Normal file
373
sim/cluster0/lab-seeds/backend/public/assets/js/tickets.js
Normal file
@@ -0,0 +1,373 @@
|
||||
(function () {
|
||||
function t(key, params) {
|
||||
return window.CrashI18n ? window.CrashI18n.t(key, params) : key;
|
||||
}
|
||||
|
||||
function basePath() {
|
||||
return document.body.getAttribute('data-base-path') || '';
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function formatTime(ms) {
|
||||
if (!ms) return '—';
|
||||
const d = new Date(Number(ms));
|
||||
return d.toISOString().replace('T', ' ').slice(0, 19);
|
||||
}
|
||||
|
||||
function formatStars(n) {
|
||||
const score = Math.max(0, Math.min(5, Number(n) || 0));
|
||||
let html =
|
||||
'<span class="star-rating" role="img" aria-label="' + score + ' out of 5">';
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
html += '<span class="star' + (i <= score ? ' star--on' : '') + '"></span>';
|
||||
}
|
||||
return html + '</span>';
|
||||
}
|
||||
|
||||
function renderTagPill(tag) {
|
||||
const id = String(tag.id || '').trim();
|
||||
const label = escapeHtml(tag.label || tag.id);
|
||||
const bg = escapeHtml(tag.bg || '#5c6b82');
|
||||
if (!id) {
|
||||
return '<span class="report-tag" style="--tag-bg:' + bg + '">' + label + '</span>';
|
||||
}
|
||||
return (
|
||||
'<button type="button" class="report-tag report-tag--filter" data-tag-id="' +
|
||||
escapeHtml(id) +
|
||||
'" style="--tag-bg:' +
|
||||
bg +
|
||||
'" title="Filter by tag">' +
|
||||
label +
|
||||
'</button>'
|
||||
);
|
||||
}
|
||||
|
||||
function canTagEdit() {
|
||||
return document.body.getAttribute('data-can-tag-edit') === '1';
|
||||
}
|
||||
|
||||
function initTicketsApp() {
|
||||
const app = document.getElementById('tickets-app');
|
||||
const tbody = document.getElementById('tickets-tbody');
|
||||
const statusEl = document.getElementById('tickets-status');
|
||||
const pagination = document.getElementById('tickets-pagination');
|
||||
const perPageSel = document.getElementById('tickets-per-page');
|
||||
const tagFilter = document.getElementById('tickets-tag-filter');
|
||||
const thead = document.querySelector('#tickets-table thead');
|
||||
if (!app || !tbody) return;
|
||||
|
||||
let page = 1;
|
||||
let perPage = 50;
|
||||
let sort = 'opened_at_ms';
|
||||
let dir = 'desc';
|
||||
let tag = '';
|
||||
|
||||
function load() {
|
||||
statusEl.textContent = t('reports.loading');
|
||||
const q =
|
||||
'?page=' +
|
||||
page +
|
||||
'&per_page=' +
|
||||
perPage +
|
||||
'&sort=' +
|
||||
encodeURIComponent(sort) +
|
||||
'&dir=' +
|
||||
encodeURIComponent(dir) +
|
||||
(tag ? '&tag=' + encodeURIComponent(tag) : '');
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', basePath() + '/api/tickets.php' + q, true);
|
||||
xhr.onload = function () {
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(xhr.responseText);
|
||||
} catch {
|
||||
statusEl.textContent = t('reports.invalid_response');
|
||||
return;
|
||||
}
|
||||
if (!data.ok) {
|
||||
statusEl.textContent = data.hint || data.error || t('reports.load_failed');
|
||||
return;
|
||||
}
|
||||
renderRows(data.items || []);
|
||||
renderPagination(data.total || 0, data.page || 1);
|
||||
const from = data.total ? (data.page - 1) * data.per_page + 1 : 0;
|
||||
const to = Math.min(data.page * data.per_page, data.total);
|
||||
statusEl.textContent = t('reports.status_showing', {
|
||||
from,
|
||||
to,
|
||||
total: data.total,
|
||||
});
|
||||
};
|
||||
xhr.onerror = function () {
|
||||
statusEl.textContent = t('reports.network_error');
|
||||
};
|
||||
xhr.send();
|
||||
}
|
||||
|
||||
window.ticketsReloadRef = load;
|
||||
|
||||
function renderRows(items) {
|
||||
if (!items.length) {
|
||||
tbody.innerHTML =
|
||||
'<tr><td colspan="6" class="muted">' + escapeHtml(t('tickets.empty')) + '</td></tr>';
|
||||
return;
|
||||
}
|
||||
let html = '';
|
||||
items.forEach((row, i) => {
|
||||
const briefId = 'tkt-brief-' + row.id + '-' + i;
|
||||
const openUrl = basePath() + '/?view=ticket&id=' + row.id;
|
||||
const issueTitle = escapeHtml(row.title || '');
|
||||
const issueBrief = row.brief
|
||||
? '<div class="ticket-issue-brief muted">' + escapeHtml(row.brief) + '</div>'
|
||||
: '';
|
||||
html +=
|
||||
'<tr class="report-row report-row--nav" data-href="' +
|
||||
escapeHtml(openUrl) +
|
||||
'" tabindex="0" role="link">';
|
||||
html +=
|
||||
'<td class="report-tree-cell"><button type="button" class="report-tree-toggle" aria-expanded="false" aria-controls="' +
|
||||
briefId +
|
||||
'"><span class="report-tree-arrow"></span></button></td>';
|
||||
html +=
|
||||
'<td class="ticket-col-issue"><strong class="ticket-issue-title">' +
|
||||
issueTitle +
|
||||
'</strong>' +
|
||||
issueBrief +
|
||||
'</td>';
|
||||
html += '<td>' + formatTime(row.opened_at_ms) + '</td>';
|
||||
html +=
|
||||
'<td class="ticket-col-env"><span>' +
|
||||
escapeHtml(row.env_brief || '') +
|
||||
'</span><br><span class="muted">' +
|
||||
escapeHtml(row.device_model || '') +
|
||||
'</span></td>';
|
||||
html += '<td>' + formatStars(row.rating) + '</td>';
|
||||
const tagEditBtn = canTagEdit()
|
||||
? '<button type="button" class="tag-edit-btn" data-ticket-id="' +
|
||||
row.id +
|
||||
'" title="Edit tags">✎</button>'
|
||||
: '';
|
||||
const tags = (row.tags || []).map(renderTagPill).join('');
|
||||
html +=
|
||||
'<td class="col-tags"><div class="report-tags">' +
|
||||
tags +
|
||||
tagEditBtn +
|
||||
'</div></td>';
|
||||
html += '</tr>';
|
||||
html +=
|
||||
'<tr class="report-brief-row" id="' +
|
||||
briefId +
|
||||
'" hidden><td colspan="6"><div class="report-brief-panel report-row--nav" data-href="' +
|
||||
escapeHtml(openUrl) +
|
||||
'" tabindex="0" role="link"><p class="muted">' +
|
||||
escapeHtml(t('row.open_report')) +
|
||||
'</p><ul class="report-brief-lines">';
|
||||
[row.brief, row.env_brief, 'Owner: ' + (row.owner_username || '—')]
|
||||
.filter(Boolean)
|
||||
.forEach((line) => {
|
||||
html += '<li>' + escapeHtml(String(line)) + '</li>';
|
||||
});
|
||||
html += '</ul></div></td></tr>';
|
||||
});
|
||||
tbody.innerHTML = html;
|
||||
bindRows();
|
||||
}
|
||||
|
||||
function applyTagFilter(tagId) {
|
||||
tag = tagId;
|
||||
if (tagFilter) {
|
||||
let found = false;
|
||||
tagFilter.querySelectorAll('option').forEach((opt) => {
|
||||
if (opt.value === tagId) found = true;
|
||||
});
|
||||
if (!found) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = tagId;
|
||||
opt.textContent = tagId;
|
||||
tagFilter.appendChild(opt);
|
||||
}
|
||||
tagFilter.value = tagId;
|
||||
}
|
||||
page = 1;
|
||||
load();
|
||||
}
|
||||
|
||||
function activateTag(tagId, ev) {
|
||||
if (!tagId) return;
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open(
|
||||
'GET',
|
||||
basePath() +
|
||||
'/api/tickets.php?page=1&per_page=2&tag=' +
|
||||
encodeURIComponent(tagId),
|
||||
true
|
||||
);
|
||||
xhr.onload = function () {
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(xhr.responseText);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!data.ok) return;
|
||||
const total = Number(data.total || 0);
|
||||
if (total === 1 && data.items && data.items[0] && data.items[0].id) {
|
||||
window.location.href =
|
||||
basePath() + '/?view=ticket&id=' + encodeURIComponent(String(data.items[0].id));
|
||||
return;
|
||||
}
|
||||
applyTagFilter(tagId);
|
||||
};
|
||||
xhr.send();
|
||||
}
|
||||
|
||||
function bindRows() {
|
||||
tbody.querySelectorAll('.report-tree-toggle').forEach((btn) => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const controls = btn.getAttribute('aria-controls');
|
||||
const row = controls ? document.getElementById(controls) : null;
|
||||
if (!row) return;
|
||||
const open = row.hidden;
|
||||
row.hidden = !open;
|
||||
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
});
|
||||
});
|
||||
tbody.querySelectorAll('[data-href]').forEach((el) => {
|
||||
const href = el.getAttribute('data-href');
|
||||
if (!href) return;
|
||||
const go = () => {
|
||||
window.location.href = href;
|
||||
};
|
||||
el.addEventListener('click', (e) => {
|
||||
if (e.target.closest('.report-tree-toggle, .tag-edit-btn, .report-tag--filter')) return;
|
||||
go();
|
||||
});
|
||||
el.addEventListener('keydown', (e) => {
|
||||
if (e.target.closest('.report-tree-toggle, .tag-edit-btn, .report-tag--filter')) return;
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
go();
|
||||
}
|
||||
});
|
||||
});
|
||||
tbody.querySelectorAll('.tag-edit-btn').forEach((btn) => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
if (typeof window.openTagModal === 'function') {
|
||||
window.openTagModal(Number(btn.getAttribute('data-ticket-id')), 'ticket');
|
||||
}
|
||||
});
|
||||
});
|
||||
tbody.querySelectorAll('.report-tag--filter').forEach((btn) => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
activateTag(btn.getAttribute('data-tag-id') || '', e);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderPagination(total, currentPage) {
|
||||
if (!pagination) return;
|
||||
page = currentPage;
|
||||
const pages = Math.max(1, Math.ceil(total / perPage));
|
||||
const prev = page > 1 ? page - 1 : null;
|
||||
const next = page < pages ? page + 1 : null;
|
||||
let html = '<div class="pagination-inner">';
|
||||
html +=
|
||||
'<button type="button" class="btn page-btn" data-page="' +
|
||||
(prev || '') +
|
||||
'" ' +
|
||||
(prev ? '' : 'disabled') +
|
||||
'>← Previous</button>';
|
||||
html +=
|
||||
'<span class="page-info">Page ' +
|
||||
page +
|
||||
' / ' +
|
||||
pages +
|
||||
' · ' +
|
||||
total +
|
||||
' tickets</span>';
|
||||
html +=
|
||||
'<button type="button" class="btn page-btn" data-page="' +
|
||||
(next || '') +
|
||||
'" ' +
|
||||
(next ? '' : 'disabled') +
|
||||
'>Next →</button></div>';
|
||||
pagination.innerHTML = html;
|
||||
pagination.querySelectorAll('.page-btn').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const p = Number(btn.getAttribute('data-page'));
|
||||
if (!p) return;
|
||||
page = p;
|
||||
load();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (perPageSel) {
|
||||
perPageSel.addEventListener('change', () => {
|
||||
perPage = Number(perPageSel.value) || 50;
|
||||
page = 1;
|
||||
load();
|
||||
});
|
||||
}
|
||||
if (tagFilter) {
|
||||
tagFilter.addEventListener('change', () => {
|
||||
tag = tagFilter.value || '';
|
||||
page = 1;
|
||||
load();
|
||||
});
|
||||
}
|
||||
if (thead) {
|
||||
thead.querySelectorAll('.sortable').forEach((th) => {
|
||||
th.addEventListener('click', () => {
|
||||
const key = th.getAttribute('data-sort');
|
||||
if (!key) return;
|
||||
if (sort === key) {
|
||||
dir = dir === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
sort = key;
|
||||
dir = 'desc';
|
||||
}
|
||||
thead.querySelectorAll('.sortable').forEach((x) => {
|
||||
x.classList.toggle('sortable--active', x === th);
|
||||
});
|
||||
page = 1;
|
||||
load();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
window.__ticketsReload = function () {
|
||||
page = 1;
|
||||
load();
|
||||
};
|
||||
|
||||
load();
|
||||
}
|
||||
|
||||
function onReady(fn) {
|
||||
if (window.CrashI18n && window.CrashI18n.ready) {
|
||||
window.CrashI18n.ready.then(fn);
|
||||
} else {
|
||||
fn();
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => onReady(initTicketsApp));
|
||||
} else {
|
||||
onReady(initTicketsApp);
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,66 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function boot() {
|
||||
var form = document.querySelector('form.login-card');
|
||||
var input = document.getElementById('twofa-code-input');
|
||||
if (!form || !input) {
|
||||
return;
|
||||
}
|
||||
input.focus();
|
||||
input.select();
|
||||
|
||||
function digitsOnly(val) {
|
||||
return String(val || '').replace(/\D/g, '').slice(0, 6);
|
||||
}
|
||||
|
||||
function maybeSubmit() {
|
||||
if (input.value.length === 6) {
|
||||
if (typeof form.requestSubmit === 'function') {
|
||||
form.requestSubmit();
|
||||
} else {
|
||||
form.submit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
input.addEventListener('input', function () {
|
||||
var next = digitsOnly(input.value);
|
||||
if (next !== input.value) {
|
||||
input.value = next;
|
||||
}
|
||||
maybeSubmit();
|
||||
});
|
||||
|
||||
input.addEventListener('paste', function (ev) {
|
||||
ev.preventDefault();
|
||||
var text = (ev.clipboardData && ev.clipboardData.getData('text')) || '';
|
||||
input.value = digitsOnly(text);
|
||||
maybeSubmit();
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', function (ev) {
|
||||
if (!/^[0-9]$/.test(ev.key)) {
|
||||
return;
|
||||
}
|
||||
var ae = document.activeElement;
|
||||
if (ae === input) {
|
||||
return;
|
||||
}
|
||||
var tag = ae && ae.tagName ? ae.tagName.toLowerCase() : '';
|
||||
if (tag === 'input' || tag === 'textarea' || tag === 'select' || (ae && ae.isContentEditable)) {
|
||||
return;
|
||||
}
|
||||
ev.preventDefault();
|
||||
input.focus();
|
||||
input.value = digitsOnly(input.value + ev.key);
|
||||
maybeSubmit();
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', boot);
|
||||
} else {
|
||||
boot();
|
||||
}
|
||||
})();
|
||||
404
sim/cluster0/lab-seeds/backend/public/index.php
Normal file
404
sim/cluster0/lab-seeds/backend/public/index.php
Normal file
@@ -0,0 +1,404 @@
|
||||
<?php
|
||||
/*
|
||||
* package examples/crash_reporter/backend/public/index.php
|
||||
* index.php
|
||||
* Created at: Wed 20 May 2026 14:31:55 +0200
|
||||
* Updated at: Wed 20 May 2026 15:17:13 +0200 by Anton Afanasyeu <a.afanasieff@gmail.com>
|
||||
* Commit: 5d8e82d2e60a21fff3138d2a394ee4e8b4c6dcb8
|
||||
* Contributors:
|
||||
* - Anton Afanasyeu <a.afanasieff@gmail.com> (2 commits, 63 lines)
|
||||
* - Cursor Agent (project assistant)
|
||||
* Digest: SHA256 90d9b287d10572f80696355281ba308437d49936dba1d421037afbabc1dc135e
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../src/bootstrap.php';
|
||||
|
||||
$uri = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
|
||||
$base = Auth::basePath();
|
||||
$route = resolve_console_route($uri);
|
||||
|
||||
if ($route === '/api/upload.php' || str_ends_with($route, '/api/upload.php')) {
|
||||
require __DIR__ . '/api/upload.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($route === '/api/diag.php' || str_ends_with($route, '/api/diag.php')) {
|
||||
require __DIR__ . '/api/diag.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($route === '/api/heartbeat.php' || str_ends_with($route, '/api/heartbeat.php')) {
|
||||
require __DIR__ . '/api/heartbeat.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($route === '/api/reports.php' || str_ends_with($route, '/api/reports.php')) {
|
||||
require __DIR__ . '/api/reports.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($route === '/api/report_viewed.php' || str_ends_with($route, '/api/report_viewed.php')) {
|
||||
require __DIR__ . '/api/report_viewed.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($route === '/api/report_tags.php' || str_ends_with($route, '/api/report_tags.php')) {
|
||||
require __DIR__ . '/api/report_tags.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($route === '/api/ticket_upload.php' || str_ends_with($route, '/api/ticket_upload.php')) {
|
||||
require __DIR__ . '/api/ticket_upload.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($route === '/api/ticket_create.php' || str_ends_with($route, '/api/ticket_create.php')) {
|
||||
require __DIR__ . '/api/ticket_create.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($route === '/api/tickets.php' || str_ends_with($route, '/api/tickets.php')) {
|
||||
require __DIR__ . '/api/tickets.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($route === '/api/ticket_tags.php' || str_ends_with($route, '/api/ticket_tags.php')) {
|
||||
require __DIR__ . '/api/ticket_tags.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($route === '/api/ticket_update.php' || str_ends_with($route, '/api/ticket_update.php')) {
|
||||
require __DIR__ . '/api/ticket_update.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($route === '/api/ticket_comments.php' || str_ends_with($route, '/api/ticket_comments.php')) {
|
||||
require __DIR__ . '/api/ticket_comments.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($route === '/api/users.php' || str_ends_with($route, '/api/users.php')) {
|
||||
require __DIR__ . '/api/users.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($route === '/api/ticket_attachments.php' || str_ends_with($route, '/api/ticket_attachments.php')) {
|
||||
require __DIR__ . '/api/ticket_attachments.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($route === '/api/graphs.php' || str_ends_with($route, '/api/graphs.php') || str_ends_with($route, '/graphs/api/graphs.php')) {
|
||||
require __DIR__ . '/api/graphs.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($route === '/api/graph_upload.php' || str_ends_with($route, '/api/graph_upload.php') || str_ends_with($route, '/graphs/api/graph_upload.php')) {
|
||||
require __DIR__ . '/api/graph_upload.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($route === '/api/remote_access.php' || str_ends_with($route, '/api/remote_access.php')) {
|
||||
require __DIR__ . '/api/remote_access.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($route === '/api/live_cast.php' || str_ends_with($route, '/api/live_cast.php')) {
|
||||
require __DIR__ . '/api/live_cast.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($route === '/api/live_cast_signaling.php' || str_ends_with($route, '/api/live_cast_signaling.php')) {
|
||||
require __DIR__ . '/api/live_cast_signaling.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($route === '/live/join' || str_ends_with($route, '/live/join')) {
|
||||
require __DIR__ . '/../views/live_join.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($route === '/live/education' || str_ends_with($route, '/live/education')) {
|
||||
require __DIR__ . '/../views/live_education.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($route === '/api/short_links.php' || str_ends_with($route, '/api/short_links.php')) {
|
||||
require __DIR__ . '/api/short_links.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($route === '/api/rbac.php' || str_ends_with($route, '/api/rbac.php')) {
|
||||
require __DIR__ . '/api/rbac.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($route === '/api/ticket_attachment.php' || str_ends_with($route, '/api/ticket_attachment.php')) {
|
||||
require __DIR__ . '/api/ticket_attachment.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($route === '/api/hub_deploy_docs.php' || str_ends_with($route, '/api/hub_deploy_docs.php')) {
|
||||
require __DIR__ . '/api/hub_deploy_docs.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($route === '/api/tag_catalog.php' || str_ends_with($route, '/api/tag_catalog.php')) {
|
||||
require __DIR__ . '/api/tag_catalog.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($route === '/api/auth_register.php' || str_ends_with($route, '/api/auth_register.php')) {
|
||||
require __DIR__ . '/api/auth_register.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($route === '/logout') {
|
||||
Auth::logout();
|
||||
header('Location: ' . Auth::authUrl('/login'));
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($route === '/login' && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$user = trim($_POST['username'] ?? '');
|
||||
$pass = $_POST['password'] ?? '';
|
||||
$result = Auth::login($user, $pass);
|
||||
if ($result === true) {
|
||||
header('Location: ' . $base . '/');
|
||||
exit;
|
||||
}
|
||||
if ($result === 'pending_2fa') {
|
||||
header('Location: ' . Auth::authUrl('/two-factor'));
|
||||
exit;
|
||||
}
|
||||
$loginError = 'Invalid credentials';
|
||||
$st = Database::pdo()->prepare('SELECT status FROM users WHERE username = ? LIMIT 1');
|
||||
$st->execute([$user]);
|
||||
$row = $st->fetch(PDO::FETCH_ASSOC);
|
||||
if (is_array($row) && ($row['status'] ?? '') === 'pending') {
|
||||
$loginError = 'Verify your email before signing in.';
|
||||
} elseif (AuthAttempts::isRateLimited($user)) {
|
||||
$loginError = 'Too many attempts — try again later or ask an admin to clear lockouts.';
|
||||
}
|
||||
require __DIR__ . '/../views/login.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($route === '/login') {
|
||||
if (Auth::pending2faUserId() > 0) {
|
||||
header('Location: ' . Auth::authUrl('/two-factor'));
|
||||
exit;
|
||||
}
|
||||
require __DIR__ . '/../views/login.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($route === '/register' && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$email = trim($_POST['email'] ?? '');
|
||||
$username = trim($_POST['username'] ?? '');
|
||||
$password = $_POST['password'] ?? '';
|
||||
$registerEmail = $email;
|
||||
$registerUsername = $username;
|
||||
$out = AuthRegistration::register($email, $password, $username);
|
||||
if ($out['ok']) {
|
||||
$registerSuccess = 'Check your email for a verification link.';
|
||||
require __DIR__ . '/../views/register.php';
|
||||
exit;
|
||||
}
|
||||
$registerError = match ($out['error'] ?? '') {
|
||||
'invalid_email' => 'Enter a valid email address.',
|
||||
'weak_password' => 'Password must be at least 10 characters.',
|
||||
'already_registered' => 'An account with this email or username already exists.',
|
||||
'rate_limited' => 'Too many attempts — try again later.',
|
||||
default => 'Registration failed.',
|
||||
};
|
||||
require __DIR__ . '/../views/register.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($route === '/register') {
|
||||
require __DIR__ . '/../views/register.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($route === '/verify-email') {
|
||||
$token = trim($_GET['token'] ?? '');
|
||||
$result = AuthRegistration::verifyEmailToken($token);
|
||||
$verifyOk = $result['ok'] ?? false;
|
||||
if (!$verifyOk) {
|
||||
$verifyError = match ($result['error'] ?? '') {
|
||||
'expired_token' => 'This link has expired.',
|
||||
'invalid_token' => 'Invalid verification link.',
|
||||
default => 'Verification failed.',
|
||||
};
|
||||
}
|
||||
require __DIR__ . '/../views/verify_email.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($route === '/two-factor' && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
if (Auth::pending2faUserId() <= 0) {
|
||||
header('Location: ' . Auth::authUrl('/login'));
|
||||
exit;
|
||||
}
|
||||
$code = trim($_POST['code'] ?? '');
|
||||
if (Auth::completeTotpLogin($code)) {
|
||||
header('Location: ' . $base . '/');
|
||||
exit;
|
||||
}
|
||||
$twofaError = 'Invalid code';
|
||||
require __DIR__ . '/../views/two_factor_challenge.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($route === '/two-factor') {
|
||||
if (Auth::pending2faUserId() <= 0) {
|
||||
header('Location: ' . Auth::authUrl('/login'));
|
||||
exit;
|
||||
}
|
||||
$twofaQr = AuthTwoFactorPage::qrPayload();
|
||||
require __DIR__ . '/../views/two_factor_challenge.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($route === '/account-security' && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
Auth::check();
|
||||
$user = Auth::user();
|
||||
$uid = (int) ($user['id'] ?? 0);
|
||||
$action = (string) ($_POST['action'] ?? '');
|
||||
if ($action === 'start_totp') {
|
||||
if (AuthFactors::hasTotp($uid)) {
|
||||
$securityError = 'Remove the current authenticator before enrolling a new one.';
|
||||
} else {
|
||||
$secret = AuthTotp::generateSecret();
|
||||
$_SESSION['totp_enroll_secret'] = $secret;
|
||||
$_SESSION['totp_enroll_exp'] = time() + 900;
|
||||
}
|
||||
} elseif ($action === 'confirm_totp') {
|
||||
if (AuthFactors::hasTotp($uid)) {
|
||||
unset($_SESSION['totp_enroll_secret'], $_SESSION['totp_enroll_exp']);
|
||||
$securityError = 'Authenticator is already enrolled.';
|
||||
} else {
|
||||
$secret = (string) ($_SESSION['totp_enroll_secret'] ?? '');
|
||||
$exp = (int) ($_SESSION['totp_enroll_exp'] ?? 0);
|
||||
$code = trim($_POST['code'] ?? '');
|
||||
if ($secret === '' || $exp < time()) {
|
||||
unset($_SESSION['totp_enroll_secret'], $_SESSION['totp_enroll_exp']);
|
||||
$securityError = 'Setup expired — start again.';
|
||||
} elseif (!AuthTotp::verify($secret, $code, 2)) {
|
||||
$securityError = 'Invalid code — check device time and try again.';
|
||||
} else {
|
||||
unset($_SESSION['totp_enroll_secret'], $_SESSION['totp_enroll_exp']);
|
||||
AuthFactors::enrollTotp($uid, $secret);
|
||||
$securitySuccess = 'Authenticator enrolled.';
|
||||
}
|
||||
}
|
||||
} elseif ($action === 'remove_totp') {
|
||||
AuthFactors::removeTotp($uid);
|
||||
$securitySuccess = 'Authenticator removed.';
|
||||
}
|
||||
if (!empty($securityError)) {
|
||||
$_SESSION['security_flash_error'] = $securityError;
|
||||
}
|
||||
if (!empty($securitySuccess)) {
|
||||
$_SESSION['security_flash_success'] = $securitySuccess;
|
||||
}
|
||||
header('Location: ' . Auth::basePath() . '/account-security', true, 303);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($route === '/account-security') {
|
||||
Auth::check();
|
||||
$user = Auth::user();
|
||||
$uid = (int) ($user['id'] ?? 0);
|
||||
$securityError = (string) ($_SESSION['security_flash_error'] ?? '');
|
||||
$securitySuccess = (string) ($_SESSION['security_flash_success'] ?? '');
|
||||
unset($_SESSION['security_flash_error'], $_SESSION['security_flash_success']);
|
||||
$totpSecret = '';
|
||||
if (!AuthFactors::hasTotp($uid)) {
|
||||
$exp = (int) ($_SESSION['totp_enroll_exp'] ?? 0);
|
||||
if ($exp >= time()) {
|
||||
$totpSecret = (string) ($_SESSION['totp_enroll_secret'] ?? '');
|
||||
}
|
||||
}
|
||||
require __DIR__ . '/../views/account_security.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($route === '/graphs' || $route === '/graphs/' || str_ends_with($route, '/app/androidcast_project/graphs') || str_ends_with($route, '/app/androidcast_project/graphs/')) {
|
||||
$_GET['view'] = 'graphs';
|
||||
$route = '/';
|
||||
}
|
||||
|
||||
Auth::check();
|
||||
|
||||
$grouped = isset($_GET['group']) && $_GET['group'] === '1';
|
||||
$view = $_GET['view'] ?? 'home';
|
||||
|
||||
if ($view === 'short_links' && !Rbac::can('short_links_view')) {
|
||||
http_response_code(403);
|
||||
echo 'Forbidden';
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($view === 'report' && isset($_GET['id'])) {
|
||||
try {
|
||||
$report = ReportRepository::getById((int) $_GET['id']);
|
||||
if (!$report) {
|
||||
http_response_code(404);
|
||||
echo 'Not found';
|
||||
exit;
|
||||
}
|
||||
$uid = (int) (Auth::user()['id'] ?? 0);
|
||||
if ($uid > 0) {
|
||||
try {
|
||||
ReportRepository::markViewed((int) $report['id'], $uid);
|
||||
} catch (Throwable $e) {
|
||||
error_log('markViewed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
$pageTitle = 'Report';
|
||||
require __DIR__ . '/../views/layout.php';
|
||||
} catch (Throwable $e) {
|
||||
error_log('report view: ' . $e->getMessage());
|
||||
http_response_code(500);
|
||||
if (cfg('debug')) {
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
echo 'Report view failed: ' . $e->getMessage();
|
||||
} else {
|
||||
echo 'Report view failed';
|
||||
}
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($view === 'ticket' && isset($_GET['id'])) {
|
||||
try {
|
||||
Database::requireTicketsTable();
|
||||
$ticket = TicketRepository::getById((int) $_GET['id']);
|
||||
if (!$ticket) {
|
||||
http_response_code(404);
|
||||
echo 'Not found';
|
||||
exit;
|
||||
}
|
||||
$pageTitle = 'Ticket';
|
||||
require __DIR__ . '/../views/layout.php';
|
||||
} catch (Throwable $e) {
|
||||
error_log('ticket view: ' . $e->getMessage());
|
||||
http_response_code(500);
|
||||
echo cfg('debug') ? 'Ticket view failed: ' . $e->getMessage() : 'Ticket view failed';
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
$pageTitle = match ($view) {
|
||||
'home' => 'Home',
|
||||
'tickets' => 'Tickets',
|
||||
'graphs' => 'Analytics',
|
||||
'live_sessions' => 'Live sessions',
|
||||
'remote_access' => 'Remote access',
|
||||
'short_links' => 'Short links',
|
||||
'rbac' => 'Access control',
|
||||
'reports', 'report' => 'Issues',
|
||||
default => 'Console',
|
||||
};
|
||||
require __DIR__ . '/../views/layout.php';
|
||||
Reference in New Issue
Block a user