commit 1d26b611118449258940b3768a0ddbdca4328020 Author: Anton Afanasyeu Date: Tue Jun 23 12:29:35 2026 +0200 initial diff --git a/README.md b/README.md new file mode 100644 index 0000000..2803404 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# ac-be-tickets + +Thin BE UI. Remote: `git://f0xx.org/ac/ac-be-tickets` diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..e5a424f --- /dev/null +++ b/composer.json @@ -0,0 +1,19 @@ +{ + "name": "androidcast/be-tickets", + "description": "Tickets console UI", + "require": { + "php": ">=8.1", + "androidcast/platform-php": "dev-next", + "androidcast/platform-web": "dev-next" + }, + "repositories": [ + { + "type": "vcs", + "url": "git://f0xx.org/ac/ac-platform-php" + }, + { + "type": "vcs", + "url": "git://f0xx.org/ac/ac-platform-web" + } + ] +} diff --git a/public/assets/js/tickets.js b/public/assets/js/tickets.js new file mode 100644 index 0000000..83e7f9a --- /dev/null +++ b/public/assets/js/tickets.js @@ -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, '"'); + } + + 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 = + ''; + for (let i = 1; i <= 5; i++) { + html += ''; + } + return html + ''; + } + + 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 '' + label + ''; + } + return ( + '' + ); + } + + 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 = + '' + escapeHtml(t('tickets.empty')) + ''; + 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 + ? '
' + escapeHtml(row.brief) + '
' + : ''; + html += + ''; + html += + ''; + html += + '' + + issueTitle + + '' + + issueBrief + + ''; + html += '' + formatTime(row.opened_at_ms) + ''; + html += + '' + + escapeHtml(row.env_brief || '') + + '
' + + escapeHtml(row.device_model || '') + + ''; + html += '' + formatStars(row.rating) + ''; + const tagEditBtn = canTagEdit() + ? '' + : ''; + const tags = (row.tags || []).map(renderTagPill).join(''); + html += + '
' + + tags + + tagEditBtn + + '
'; + html += ''; + html += + '

' + + escapeHtml(t('row.open_report')) + + '

'; + }); + 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 = '
'; + html += + ''; + html += + 'Page ' + + page + + ' / ' + + pages + + ' · ' + + total + + ' tickets'; + html += + '
'; + 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); + } +})(); diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..e8271bc --- /dev/null +++ b/public/index.php @@ -0,0 +1,2 @@ + + * Commit: 5d8e82d2e60a21fff3138d2a394ee4e8b4c6dcb8 + * Contributors: + * - Anton Afanasyeu (2 commits, 86 lines) + * - Cursor Agent (project assistant) + * Digest: SHA256 21e56cd12df6a94df41a1c0d67be530bd01454865ee1f032cef5f8d2e497509a + */ +?> + + + + + + <?= h($pageTitle ?? 'Console') ?> — <?= h(cfg('app_name')) ?> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + > + +
+ +
+ + + + + + + +
+
+

Tickets

+
+ + + + +
+
+

Loading…

+
+ + + + + + + + + + + + +
IssueOpenedApp / OSRatingTags
+
+ +
+ +
+
+

Issues

+
+ + + + + + +
+
+
+ Filter by tag +
+
+ +
+ +
+ + +
+ +
+

Loading…

+
+ + + + +
+
+ +
+ +
+
+

AndroidCast analytics

+
+ + + +
+
+ + +

Loading…

+ +
+

Your activity

+
+

Sessions / day

+

Unique devices / day

+

Avg duration (s) / day

+

Send sessions / day

+

Receive sessions / day

+

Success ratio

+

Outbound kbps / day

+

Inbound kbps / day

+

Issues / day

+
+
+ + + + + +
+

Live cast

+
+

Live casts / day

+

Avg cast length (s) / day

+

Cast platforms

+

Join platforms

+

Video codecs

+

Audio codecs

+

Bandwidth modes

+

Top casters

+
+
+ + +
+ +
+
+

Live cast sessions

+
+ + Education demo +
+
+ +

Loading…

+

Read-only session tree for all signed-in users. Sorted by last heartbeat (most recent first).

+
+ + + + + + + + + + + + + + +
StatusUserPlatformVideoDurationLast heartbeatJoin opensLink
+
+
+ +
+
+

Remote access

+
+ +
+
+ +

Loading…

+ +
+

Devices

+

+ Devices appear after they poll remote access (dev settings → WireGuard or RSSH). Sort is by last seen — top rows are actively polling. + Whitelist rows marked Needs whitelist (opted in, not yet allowed). + Stale rows have not polled in 7+ days. + Poll source IP is the address BE sees on the HTTP request (often a router or proxy on 10.7.x.x) — not the device WAN or LAN. +

+
+ + + + +
+
+
+ + + +
+
+ +
+

Active / pending sessions

+
+ + + + + + + + + + + + +
SessionDeviceStatusTunnelEndpointActions
+
+
+ +
+

Inactive / closed sessions

+
+ + + + + + + + + + + +
SessionDeviceStatusClosedReason
+
+
+ +
+

Audit log

+
+ + + + + + + + + + +
TimeDeviceActionReason
+
+
+
+ + + +
+
+

Access control

+
+

Loading…

+

+
+

Users & company roles

+
+ + + + + + + + + + + + +
UserGlobal roleCompanyCompany rolePrivilege setAuth lockouts
+
+
+

+ API: /api/rbac.php · + Root edits global roles; company owner/admin edits memberships and remote-access privilege sets. +

+
+ +
+
+ + + + +
+
+

New ticket

+ +
+
+ + + +
+
+

+
+ + +
+
+
+
+ + + + diff --git a/views/partials/console_home_landing.php b/views/partials/console_home_landing.php new file mode 100644 index 0000000..b8d660a --- /dev/null +++ b/views/partials/console_home_landing.php @@ -0,0 +1,58 @@ + +
+
+

Console

+
+ +
+
+

Issue triage, tickets, and session metrics — pick a workspace below.

+ + + +

All services

+ +
diff --git a/views/partials/console_quick_links.php b/views/partials/console_quick_links.php new file mode 100644 index 0000000..9106fd4 --- /dev/null +++ b/views/partials/console_quick_links.php @@ -0,0 +1,37 @@ + + * @var bool $skip_home_link omit "Console home" (use on home view) + */ +$bp = Auth::basePath(); +$proj = '/app/androidcast_project'; +$navClass = 'console-quick-links graphs-quick-links' . (isset($extra_class) ? ' ' . $extra_class : ''); +$items = []; +if (empty($skip_home_link)) { + $items[] = ['Console home', $bp . '/?view=home', 'nav-icon--home']; +} +$items[] = ['Issues', $bp . '/?view=reports', 'nav-icon--reports']; +$items[] = ['Tickets', $bp . '/?view=tickets', 'nav-icon--tickets']; +if (Rbac::can('remote_access_view')) { + $items[] = ['Remote access', $bp . '/?view=remote_access', 'nav-icon--remote']; +} +if (Rbac::can('short_links_view')) { + $items[] = ['Short links', $bp . '/?view=short_links', 'nav-icon--link']; +} +if (Rbac::canManageRbac()) { + $items[] = ['Access control', $bp . '/?view=rbac', 'nav-icon--tickets']; +} +$items[] = ['Graphs', $proj . '/graphs/', 'nav-icon--graphs']; +$items[] = ['Builder', $proj . '/build/', 'nav-icon--builder']; +$items[] = ['Hub', $proj . '/', 'nav-icon--home']; +?> + diff --git a/views/partials/cookie_consent.php b/views/partials/cookie_consent.php new file mode 100644 index 0000000..0a0e5ed --- /dev/null +++ b/views/partials/cookie_consent.php @@ -0,0 +1,15 @@ + + diff --git a/views/ticket_detail.php b/views/ticket_detail.php new file mode 100644 index 0000000..b2c34e2 --- /dev/null +++ b/views/ticket_detail.php @@ -0,0 +1,328 @@ +'; +} +?> +
+ + + +
+

Ticket

+

+ ID + + · + +

+
+
+ +
+
+

Tags

+

Include one type tag (ticket or issue) and a status tag (open, in progress, closed, …). Swap type via suggestions — remove the current type tag, then add the other.

+
+
+ +
    +
    + + + +
    + +
    + + +
    + +

    Only the owner or an admin can edit tags.

    + +
    + + + +
    +
    +

    Issue

    + + + +
    +
    +

    Workflow

    + + + +
    + Assignees +
    +
    + + +
    +
    + +

    + +

    +
    + + +
    +
    +
    + + +
    +

    + +

    + +
    +
    + + +
    +

    Device

    + +

    + +

    + +

    Android 0 ? ' (SDK ' . $sdk . ')' : '' ?>

    + +

    ABIs:

    + +
    +

    App

    +

    +

    v + + () + +

    +

    +
    +

    Timing

    +

    Opened: +

    +

    Created: +

    +

    Updated: +

    +
    + +

    Workflow

    +

    Lifecycle: +

    +

    Assignees: + + + + + + () + + +

    +

    Owner:

    +

    Verified by: +

    +

    Rating: +

    +
    + +
    + +
    +

    Attachments

    +
      + +
    • + + + + + + + ( KB) + + + + + + + + +
    • + +
    + +
    + +
    +

    Upload file

    +

    Max 16 MiB — images, logs, pdf, zip, …

    + + + + +
    +
    + +
    + +
    +

    Comments

    +
      + +
    • +
      + + +
      +
      +
    • + +
    + +
    + + + +
    + +
    + +
    + +
    +
    + + + + Source / context +
    + +
    + +
    +
    + + + + Raw JSON +
    + +
    +