/** * Shorebird Self-Hosted Dashboard - Complete SPA Application * Vanilla JS, no framework dependencies. */ // ---- State ---- const state = { token: localStorage.getItem('shorebird_token') || null, user: null, apps: [], users: [], organizations: [], currentApp: null, currentRelease: null, confirmCallback: null, }; // ---- API Client ---- const api = { base: '/api/v1', auth: '/auth', headers() { const h = { 'Content-Type': 'application/json' }; if (state.token) h['Authorization'] = `Bearer ${state.token}`; return h; }, async request(method, path, body) { const url = path.startsWith('/auth') ? path : `${this.base}${path}`; const opts = { method, headers: this.headers() }; if (body) opts.body = JSON.stringify(body); const res = await fetch(url, opts); if (res.status === 204) return null; const data = await res.json(); if (!res.ok) { if (res.status === 401) { logout(); throw new Error('Unauthorized'); } throw new Error(data.message || 'Request failed'); } return data; }, get(path) { return this.request('GET', path); }, post(path, body) { return this.request('POST', path, body); }, patch(path, body) { return this.request('PATCH', path, body); }, del(path) { return this.request('DELETE', path); }, }; // ---- Navigation ---- function navigate(page) { document.querySelectorAll('.page-section').forEach(s => s.classList.remove('active')); const section = document.getElementById(`page-${page}`); if (section) section.classList.add('active'); document.querySelectorAll('#sidebarNav a').forEach(a => a.classList.remove('active')); const link = document.querySelector(`#sidebarNav a[data-page="${page}"]`); if (link) link.classList.add('active'); const titles = { dashboard: 'Dashboard', apps: 'Apps', users: 'Users', organizations: 'Organizations', settings: 'Settings' }; document.getElementById('pageTitle').textContent = titles[page] || page; if (page === 'dashboard') loadDashboard(); if (page === 'apps') loadApps(); if (page === 'users') loadUsers(); if (page === 'organizations') loadOrganizations(); if (page === 'settings') loadSettings(); } // ---- Toast Notifications ---- function showToast(message, type) { const container = document.getElementById('toastContainer'); const toast = document.createElement('div'); toast.className = `toast toast-${type}`; toast.textContent = message; container.appendChild(toast); setTimeout(() => { toast.remove(); }, 4000); } // ---- Modals ---- function openModal(id) { document.getElementById(id).classList.add('active'); } function closeModal(id) { document.getElementById(id).classList.remove('active'); } function confirmAction(title, message, callback) { document.getElementById('confirmTitle').textContent = title; document.getElementById('confirmMessage').textContent = message; state.confirmCallback = callback; openModal('confirmModal'); } function executeConfirm() { if (state.confirmCallback) state.confirmCallback(); closeModal('confirmModal'); } // ---- Auth ---- async function login(email, password) { const data = await api.request('POST', '/auth/token', { email, password }); state.token = data.token; state.mustChangePassword = data.must_change_password; localStorage.setItem('shorebird_token', data.token); await loadCurrentUser(); showApp(); if (state.mustChangePassword || state.user.must_change_password) { navigate('settings'); openModal('forcePasswordOverlay'); } else { navigate('dashboard'); } } async function loadPublicSettings() { try { const data = await api.request('GET', '/auth/public-settings'); document.getElementById('ssoLoginBtn').style.display = data.sso_enabled ? 'inline-flex' : 'none'; const canRegister = data.registration_enabled && !data.sso_only_registration; document.getElementById('registerPrompt').style.display = canRegister ? 'inline' : 'none'; } catch { document.getElementById('ssoLoginBtn').style.display = 'none'; } } function logout() { state.token = null; state.user = null; state.apps = []; localStorage.removeItem('shorebird_token'); document.getElementById('appContainer').classList.remove('active'); document.getElementById('loginPage').style.display = 'flex'; } async function loadCurrentUser() { try { state.user = await api.get('/users/me'); document.getElementById('currentUserEmail').textContent = state.user.email || '-'; if (state.user.must_change_password) setTimeout(() => openModal('forcePasswordOverlay'), 0); } catch (e) { state.user = { email: 'admin' }; } } function showApp() { document.getElementById('loginPage').style.display = 'none'; document.getElementById('appContainer').classList.add('active'); } async function register(name, email, password) { await api.request('POST', '/auth/register', { name, email, password }); closeModal('registerOverlay'); showToast('Account created. Check your email to verify before signing in.', 'success'); } // ---- Dashboard ---- async function loadDashboard() { try { const appsResp = await api.get('/apps'); state.apps = appsResp.apps || []; document.getElementById('statApps').textContent = state.apps.length; document.getElementById('statPatches').textContent = '-'; // Try to get users try { const usersResp = await api.get('/admin/users'); state.users = usersResp.users || []; document.getElementById('statUsers').textContent = state.users.length; } catch { document.getElementById('statUsers').textContent = '-'; } // Try to get organizations try { const orgsResp = await api.get('/organizations'); const orgs = orgsResp.organizations || []; document.getElementById('statOrganizations').textContent = orgs.length; } catch { document.getElementById('statOrganizations').textContent = '-'; } // Recent apps table const tableDiv = document.getElementById('recentAppsTable'); if (state.apps.length === 0) { tableDiv.innerHTML = '
Apps

No apps yet. Create your first app to get started.

'; } else { const recent = state.apps.slice(0, 5); tableDiv.innerHTML = renderTable( ['App Name', 'App ID', 'Created'], recent.map(a => [ a.display_name, `${a.app_id}`, new Date(a.created_at).toLocaleDateString(), ]) ); } } catch (e) { showToast('Failed to load dashboard', 'error'); } } // ---- Apps ---- async function loadApps() { try { const resp = await api.get('/apps'); state.apps = resp.apps || []; const tableDiv = document.getElementById('appsTable'); if (state.apps.length === 0) { tableDiv.innerHTML = '
Apps

No apps yet. Create your first app.

'; return; } tableDiv.innerHTML = renderTable( ['App Name', 'App ID', 'Created', 'Actions'], state.apps.map(a => [ a.display_name, `${a.app_id}`, new Date(a.created_at).toLocaleDateString(), ` `, ]) ); } catch (e) { showToast('Failed to load apps', 'error'); } } async function createApp(displayName, organizationId) { try { await api.post('/apps', { display_name: displayName, organization_id: Number(organizationId || 0) }); closeModal('createAppOverlay'); showToast(`App "${displayName}" created`, 'success'); loadApps(); loadDashboard(); } catch (e) { showToast(e.message || 'Failed to create app', 'error'); } } function confirmDeleteApp(appId, name) { confirmAction('Delete App', `Are you sure you want to delete "${name}"? This action cannot be undone.`, async () => { try { await api.del(`/apps/${appId}`); showToast(`App "${name}" deleted`, 'success'); loadApps(); loadDashboard(); } catch (e) { showToast(e.message || 'Failed to delete app', 'error'); } }); } function openCreateAppModal() { document.getElementById('appDisplayName').value = ''; const select = document.getElementById('appOrganizationId'); select.innerHTML = ''; api.get('/organizations').then(resp => { state.organizations = resp.organizations || []; if (state.organizations.length > 0) { select.innerHTML = state.organizations .map(m => ``) .join(''); } }).catch(() => {}); openModal('createAppOverlay'); } // ---- Users ---- async function loadUsers() { try { const resp = await api.get('/admin/users'); state.users = resp.users || []; const tableDiv = document.getElementById('usersTable'); if (state.users.length === 0) { tableDiv.innerHTML = '
Users

No users found.

'; return; } tableDiv.innerHTML = renderTable( ['ID', 'Email', 'Name', 'Role', 'Created'], state.users.map(u => [ u.id, u.email, u.name || '-', u.role ? `${u.role}` : `member`, u.created_at ? new Date(u.created_at).toLocaleDateString() : '-', ]) ); } catch (e) { // If admin/users endpoint is not available, show a message document.getElementById('usersTable').innerHTML = '
Users

User management API not available. Check server configuration.

'; } } async function createUser(name, email, password) { try { await api.request('POST', '/auth/register', { name, email, password }); closeModal('createUserOverlay'); showToast(`User "${email}" created`, 'success'); loadUsers(); } catch (e) { showToast(e.message || 'Failed to create user', 'error'); } } function openCreateUserModal() { document.getElementById('newUserName').value = ''; document.getElementById('newUserEmail').value = ''; document.getElementById('newUserPassword').value = ''; openModal('createUserOverlay'); } // ---- Settings ---- async function loadSettings() { // Try to get server info from health endpoint let backendInfo = { storage: 'Unknown', database: 'Unknown' }; try { const resp = await fetch('/health'); const data = await resp.json(); if (data.backend) backendInfo = data.backend; } catch (e) { /* use defaults */ } const infoDiv = document.getElementById('serverInfo'); infoDiv.innerHTML = `
Server Version
Shorebird Self-Hosted v1.0
API Base URL
${window.location.origin}/api/v1
Auth Endpoint
${window.location.origin}/auth
Storage Backend
${backendInfo.storage}
Database Backend
${backendInfo.database}
UI Version
1.0.0
`; document.getElementById('apiTokenDisplay').value = state.token || 'Not authenticated'; } // ---- Helpers ---- function renderTable(headers, rows) { let html = ''; headers.forEach(h => { html += ``; }); html += ''; rows.forEach(row => { html += ''; row.forEach(cell => { html += ``; }); html += ''; }); html += '
${h}
${cell}
'; return html; } function escapeHtml(str) { const div = document.createElement('div'); div.textContent = str; return div.innerHTML; } function copyToClipboard(text) { navigator.clipboard.writeText(text).then(() => { showToast('Copied to clipboard', 'success'); }).catch(() => { // Fallback const input = document.createElement('input'); input.value = text; document.body.appendChild(input); input.select(); document.execCommand('copy'); document.body.removeChild(input); showToast('Copied to clipboard', 'success'); }); } // ---- Admin Users ---- async function loadUsers() { try { const resp = await api.get('/admin/users'); state.users = resp.users || []; const tableDiv = document.getElementById('usersTable'); if (state.users.length === 0) { tableDiv.innerHTML = '

No users found.

'; return; } tableDiv.innerHTML = renderTable( ['ID', 'Email', 'Name', 'Role', 'Verified', 'Admin', 'Actions'], state.users.map(u => [ u.id, escapeHtml(u.email), escapeHtml(u.name || '-'), u.role ? `${escapeHtml(u.role)}` : 'member', u.email_verified ? 'verified' : 'pending', u.is_admin ? 'admin' : 'user', ` `, ]) ); } catch { document.getElementById('usersTable').innerHTML = '

Admin access required for user management.

'; } } async function verifyUserEmail(userId) { try { await api.post(`/admin/users/${userId}/verify-email`, {}); showToast('User email verified', 'success'); loadUsers(); } catch (e) { showToast(e.message || 'Failed to verify email', 'error'); } } async function setUserAdmin(userId, isAdmin) { try { await api.post(`/admin/users/${userId}/admin`, { is_admin: isAdmin }); showToast('Admin status updated', 'success'); loadUsers(); } catch (e) { showToast(e.message || 'Failed to update admin status', 'error'); } } // ---- Organizations ---- async function loadOrganizations() { try { const resp = await api.get('/organizations'); state.organizations = resp.organizations || []; const tableDiv = document.getElementById('organizationsTable'); if (state.organizations.length === 0) { tableDiv.innerHTML = '

No organizations found.

'; document.getElementById('organizationMembersTable').innerHTML = ''; return; } tableDiv.innerHTML = renderTable( ['ID', 'Name', 'Type', 'Your Role', 'Actions'], state.organizations.map(m => [ m.organization.id, escapeHtml(m.organization.name), escapeHtml(m.organization.organization_type), `${escapeHtml(m.role)}`, ` `, ]) ); loadOrganizationMembers(state.organizations[0].organization.id); } catch { document.getElementById('organizationsTable').innerHTML = '

Failed to load organizations.

'; } } async function loadOrganizationMembers(orgId) { try { const resp = await api.get(`/organizations/${orgId}/users`); const users = resp.users || []; document.getElementById('organizationMembersTable').innerHTML = renderTable( ['Email', 'Name', 'Role', 'Verified', 'Admin'], users.map(u => [ escapeHtml(u.email), escapeHtml(u.name || '-'), `${escapeHtml(u.role)}`, u.email_verified ? 'verified' : 'pending', u.is_admin ? 'admin' : 'user', ]) ); } catch { document.getElementById('organizationMembersTable').innerHTML = '

Organization admin access required to view members.

'; } } async function createOrganization(name) { try { await api.post('/organizations', { name, organization_type: 'team' }); closeModal('createOrgOverlay'); showToast('Organization created', 'success'); loadOrganizations(); } catch (e) { showToast(e.message || 'Failed to create organization', 'error'); } } function openCreateOrgModal() { document.getElementById('orgName').value = ''; openModal('createOrgOverlay'); } function openAddOrgUserModal(orgId) { document.getElementById('orgUserOrgId').value = orgId; document.getElementById('orgUserEmail').value = ''; document.getElementById('orgUserRole').value = 'member'; openModal('addOrgUserOverlay'); } async function addOrganizationUser(orgId, email, role) { try { await api.post(`/organizations/${orgId}/users`, { email, role }); closeModal('addOrgUserOverlay'); showToast('User added to organization', 'success'); loadOrganizationMembers(orgId); } catch (e) { showToast(e.message || 'Failed to add user', 'error'); } } // ---- Settings override ---- async function loadSettings() { let backendInfo = { storage: 'Unknown', database: 'Unknown' }; try { const resp = await fetch('/health'); const data = await resp.json(); if (data.backend) backendInfo = data.backend; } catch {} document.getElementById('serverInfo').innerHTML = `
Server Version
Shorebird Self-Hosted v1.0
API Base URL
${window.location.origin}/api/v1
Auth Endpoint
${window.location.origin}/auth
Storage Backend
${backendInfo.storage}
Database Backend
${backendInfo.database}
Current User
${escapeHtml(state.user?.email || '-')}
`; document.getElementById('apiTokenDisplay').value = state.token || 'Not authenticated'; try { const resp = await api.get('/admin/settings'); const settings = resp.settings || {}; document.getElementById('settingRegistrationEnabled').checked = settings.registration_enabled === 'true'; document.getElementById('settingSsoRegistrationEnabled').checked = settings.sso_registration_enabled === 'true'; document.getElementById('settingSsoOnlyRegistration').checked = settings.sso_only_registration === 'true'; document.getElementById('casdoorEndpoint').value = settings.casdoor_endpoint || ''; document.getElementById('casdoorClientId').value = settings.casdoor_client_id || ''; document.getElementById('casdoorClientSecret').value = ''; document.getElementById('casdoorOrganization').value = settings.casdoor_organization || 'built-in'; } catch { document.getElementById('adminSettingsForm').innerHTML = '

Admin access required for server settings.

'; } } // ---- Event Listeners ---- document.addEventListener('DOMContentLoaded', () => { loadPublicSettings(); // Login form document.getElementById('loginForm').addEventListener('submit', async e => { e.preventDefault(); const email = document.getElementById('loginEmail').value; const password = document.getElementById('loginPassword').value; const errorDiv = document.getElementById('loginError'); errorDiv.style.display = 'none'; try { await login(email, password); } catch (err) { errorDiv.textContent = err.message || 'Login failed. Check your credentials.'; errorDiv.style.display = 'block'; } }); // Register form (modal) document.getElementById('registerForm').addEventListener('submit', async e => { e.preventDefault(); const name = document.getElementById('regName').value; const email = document.getElementById('regEmail').value; const password = document.getElementById('regPassword').value; try { await register(name, email, password); } catch (err) { showToast(err.message || 'Registration failed', 'error'); } }); // Show register modal document.getElementById('showRegister').addEventListener('click', e => { e.preventDefault(); document.getElementById('regName').value = ''; document.getElementById('regEmail').value = ''; document.getElementById('regPassword').value = ''; openModal('registerOverlay'); }); document.getElementById('showPasswordReset').addEventListener('click', e => { e.preventDefault(); document.getElementById('resetEmail').value = document.getElementById('loginEmail').value || ''; openModal('passwordResetOverlay'); }); function closeRegister() { closeModal('registerOverlay'); } window.closeRegister = closeRegister; // Create app form document.getElementById('createAppForm').addEventListener('submit', async e => { e.preventDefault(); const name = document.getElementById('appDisplayName').value; const orgId = document.getElementById('appOrganizationId').value; await createApp(name, orgId); }); // Create user form document.getElementById('createUserForm').addEventListener('submit', async e => { e.preventDefault(); const name = document.getElementById('newUserName').value; const email = document.getElementById('newUserEmail').value; const password = document.getElementById('newUserPassword').value; await createUser(name, email, password); }); document.getElementById('passwordResetForm').addEventListener('submit', async e => { e.preventDefault(); const email = document.getElementById('resetEmail').value; try { await api.request('POST', '/auth/password-reset/request', { email }); closeModal('passwordResetOverlay'); showToast('Password reset email sent if the account exists', 'success'); } catch (err) { showToast(err.message || 'Failed to request reset', 'error'); } }); document.getElementById('changePasswordForm').addEventListener('submit', async e => { e.preventDefault(); try { await api.patch('/users/me/password', { current_password: document.getElementById('currentPassword').value, new_password: document.getElementById('newPassword').value, }); document.getElementById('changePasswordForm').reset(); showToast('Password changed', 'success'); } catch (err) { showToast(err.message || 'Failed to change password', 'error'); } }); document.getElementById('forcePasswordForm').addEventListener('submit', async e => { e.preventDefault(); try { await api.patch('/users/me/password', { current_password: '', new_password: document.getElementById('forceNewPassword').value, }); state.mustChangePassword = false; if (state.user) state.user.must_change_password = false; closeModal('forcePasswordOverlay'); document.getElementById('forcePasswordForm').reset(); showToast('Password updated', 'success'); navigate('dashboard'); } catch (err) { showToast(err.message || 'Failed to update password', 'error'); } }); document.getElementById('adminSettingsForm').addEventListener('submit', async e => { e.preventDefault(); const body = { registration_enabled: String(document.getElementById('settingRegistrationEnabled').checked), sso_registration_enabled: String(document.getElementById('settingSsoRegistrationEnabled').checked), sso_only_registration: String(document.getElementById('settingSsoOnlyRegistration').checked), casdoor_endpoint: document.getElementById('casdoorEndpoint').value, casdoor_client_id: document.getElementById('casdoorClientId').value, casdoor_client_secret: document.getElementById('casdoorClientSecret').value, casdoor_organization: document.getElementById('casdoorOrganization').value, }; try { await api.request('PUT', '/admin/settings', body); showToast('Settings saved', 'success'); loadPublicSettings(); loadSettings(); } catch (err) { showToast(err.message || 'Failed to save settings', 'error'); } }); document.getElementById('createOrgForm').addEventListener('submit', async e => { e.preventDefault(); await createOrganization(document.getElementById('orgName').value); }); document.getElementById('addOrgUserForm').addEventListener('submit', async e => { e.preventDefault(); await addOrganizationUser( document.getElementById('orgUserOrgId').value, document.getElementById('orgUserEmail').value, document.getElementById('orgUserRole').value, ); }); // Sidebar navigation document.querySelectorAll('#sidebarNav a').forEach(link => { link.addEventListener('click', e => { e.preventDefault(); const page = link.dataset.page; navigate(page); window.location.hash = page; }); }); // Logout document.getElementById('logoutBtn').addEventListener('click', () => { logout(); window.location.hash = ''; }); // Close modals on overlay click document.querySelectorAll('.modal-overlay').forEach(overlay => { overlay.addEventListener('click', e => { if (e.target === overlay) closeModal(overlay.id); }); }); // Auto-login if token exists if (state.token) { loadCurrentUser().then(() => { showApp(); if (state.user.must_change_password) { navigate('settings'); openModal('forcePasswordOverlay'); } else { const page = window.location.hash.replace('#', '') || 'dashboard'; navigate(page); } }).catch(() => { logout(); }); } }); // ---- App Detail Views ---- function setActivePage(page) { document.querySelectorAll('.page-section').forEach(s => s.classList.remove('active')); const section = document.getElementById(`page-${page}`); if (section) section.classList.add('active'); } function platformBadges(statuses) { const entries = Object.entries(statuses || {}); if (entries.length === 0) return 'no platforms'; return entries.map(([platform, status]) => `${escapeHtml(platform)}`).join(' '); } function humanSize(bytes) { if (!bytes) return '0 B'; const units = ['B', 'KB', 'MB', 'GB']; let size = bytes; let unit = 0; while (size >= 1024 && unit < units.length - 1) { size /= 1024; unit++; } return `${size.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`; } async function loadApps() { try { const resp = await api.get('/apps'); state.apps = resp.apps || []; const tableDiv = document.getElementById('appsTable'); if (state.apps.length === 0) { tableDiv.innerHTML = '

No apps yet. Create your first app.

'; return; } tableDiv.innerHTML = renderTable( ['App Name', 'App ID', 'Created', 'Actions'], state.apps.map(a => [ ``, `${a.app_id}`, new Date(a.created_at).toLocaleDateString(), ` `, ]) ); } catch { showToast('Failed to load apps', 'error'); } } async function openAppPage(appId) { const app = state.apps.find(a => a.app_id === appId) || { app_id: appId, display_name: appId }; state.currentApp = app; setActivePage('app-detail'); document.getElementById('pageTitle').textContent = app.display_name; document.getElementById('appDetailTitle').textContent = app.display_name; showAppTab('releases'); } async function showAppTab(tab) { document.querySelectorAll('[data-app-tab]').forEach(b => b.classList.toggle('active', b.dataset.appTab === tab)); document.querySelectorAll('.app-tab').forEach(el => { el.style.display = el.id === `appTab-${tab}` ? 'block' : 'none'; }); if (!state.currentApp) return; if (tab === 'releases') await renderAppReleases(); if (tab === 'insights') await renderAppInsights(); if (tab === 'collaborators') await renderAppCollaborators(); if (tab === 'tracks') await renderAppTracks(); if (tab === 'settings') await renderAppSettings(); } async function renderAppReleases() { const div = document.getElementById('appTab-releases'); const resp = await api.get(`/apps/${state.currentApp.app_id}/releases`); state.currentApp.releases = resp.releases || []; if (state.currentApp.releases.length === 0) { div.innerHTML = '

No releases yet.

'; return; } div.innerHTML = renderTable( ['Version', 'Platforms', 'Flutter', 'Created', 'Actions'], state.currentApp.releases.map(r => [ ``, platformBadges(r.platform_statuses), escapeHtml(r.flutter_version || r.flutter_revision || '-'), new Date(r.created_at).toLocaleDateString(), ``, ]) ); } async function renderAppInsights() { const releases = state.currentApp.releases || (await api.get(`/apps/${state.currentApp.app_id}/releases`)).releases || []; document.getElementById('appTab-insights').innerHTML = `
${releases.length}
Releases
${new Set(releases.flatMap(r => Object.keys(r.platform_statuses || {}))).size}
Platforms
0
Downloads tracked

Download metrics are shown when artifact/download events are recorded.

`; } async function renderAppCollaborators() { const orgs = await api.get('/organizations'); const membership = (orgs.organizations || []).find(m => m.organization.id === state.currentApp.organization_id); if (!membership) { document.getElementById('appTab-collaborators').innerHTML = '

No organization membership found.

'; return; } try { const resp = await api.get(`/organizations/${membership.organization.id}/users`); document.getElementById('appTab-collaborators').innerHTML = renderTable( ['Email', 'Name', 'Role'], (resp.users || []).map(u => [escapeHtml(u.email), escapeHtml(u.name || '-'), `${escapeHtml(u.role)}`]) ); } catch { document.getElementById('appTab-collaborators').innerHTML = '

Organization admin access required.

'; } } async function renderAppTracks() { try { const channels = await api.get(`/apps/${state.currentApp.app_id}/channels`); document.getElementById('appTab-tracks').innerHTML = renderTable( ['Track', 'ID'], (channels || []).map(c => [escapeHtml(c.name), c.id]) ); } catch { document.getElementById('appTab-tracks').innerHTML = '

No tracks found.

'; } } async function renderAppSettings() { const orgs = await api.get('/organizations').catch(() => ({ organizations: [] })); const options = (orgs.organizations || []).map(m => ``).join(''); document.getElementById('appTab-settings').innerHTML = `
`; } async function transferCurrentApp() { const orgId = Number(document.getElementById('transferOrgId').value); await api.patch(`/apps/${state.currentApp.app_id}/transfer`, { organization_id: orgId }); state.currentApp.organization_id = orgId; showToast('App ownership transferred', 'success'); } function confirmDeleteCurrentApp() { confirmAction('Delete App', `Delete "${state.currentApp.display_name}"?`, async () => { await api.del(`/apps/${state.currentApp.app_id}`); showToast('App deleted', 'success'); navigate('apps'); }); } async function openReleasePage(releaseId) { const releases = state.currentApp.releases || (await api.get(`/apps/${state.currentApp.app_id}/releases`)).releases || []; state.currentRelease = releases.find(r => r.id === releaseId); setActivePage('release-detail'); document.getElementById('pageTitle').textContent = `Release ${state.currentRelease.version}`; document.getElementById('releaseDetailTitle').textContent = `Release ${state.currentRelease.version}`; showReleaseTab('overview'); } async function showReleaseTab(tab) { document.querySelectorAll('[data-release-tab]').forEach(b => b.classList.toggle('active', b.dataset.releaseTab === tab)); document.querySelectorAll('.release-tab').forEach(el => { el.style.display = el.id === `releaseTab-${tab}` ? 'block' : 'none'; }); if (tab === 'overview') await renderReleaseOverview(); if (tab === 'insights') await renderReleaseInsights(); if (tab === 'artifacts') await renderReleaseArtifacts(); if (tab === 'settings') renderReleaseSettings(); } async function renderReleaseOverview() { const resp = await api.get(`/apps/${state.currentApp.app_id}/releases/${state.currentRelease.id}/patches`).catch(() => ({ patches: [] })); document.getElementById('releaseTab-overview').innerHTML = `
${(resp.patches || []).length}
Patches
${Object.keys(state.currentRelease.platform_statuses || {}).length}
Platforms
${renderTable(['Patch', 'Created'], (resp.patches || []).map(p => [p.patch_number || p.number || p.id, p.created_at ? new Date(p.created_at).toLocaleDateString() : '-']))}`; } async function renderReleaseInsights() { const artifacts = await api.get(`/apps/${state.currentApp.app_id}/releases/${state.currentRelease.id}/artifacts`).catch(() => ({ artifacts: [] })); const bytes = (artifacts.artifacts || []).reduce((sum, a) => sum + (a.size || 0), 0); document.getElementById('releaseTab-insights').innerHTML = `
${(artifacts.artifacts || []).length}
Artifacts
${humanSize(bytes)}
Artifact bytes
0
Downloads tracked
`; } async function renderReleaseArtifacts() { const resp = await api.get(`/apps/${state.currentApp.app_id}/releases/${state.currentRelease.id}/artifacts`); document.getElementById('releaseTab-artifacts').innerHTML = renderTable( ['Platform', 'Arch', 'Size', 'Hash', 'Actions'], (resp.artifacts || []).map(a => [ escapeHtml(a.platform), escapeHtml(a.arch), humanSize(a.size), `${escapeHtml(a.hash || '-')}`, `Download`, ]) ); } function renderReleaseSettings() { document.getElementById('releaseTab-settings').innerHTML = ``; } function confirmDeleteCurrentRelease() { confirmAction('Delete Release', `Delete release ${state.currentRelease.version}?`, async () => { await api.del(`/apps/${state.currentApp.app_id}/releases/${state.currentRelease.id}`); showToast('Release deleted', 'success'); await openAppPage(state.currentApp.app_id); }); } // Export functions for inline onclick handlers window.openCreateAppModal = openCreateAppModal; window.openCreateUserModal = openCreateUserModal; window.openCreateOrgModal = openCreateOrgModal; window.openAddOrgUserModal = openAddOrgUserModal; window.loadOrganizationMembers = loadOrganizationMembers; window.openAppPage = openAppPage; window.showAppTab = showAppTab; window.transferCurrentApp = transferCurrentApp; window.confirmDeleteCurrentApp = confirmDeleteCurrentApp; window.openReleasePage = openReleasePage; window.showReleaseTab = showReleaseTab; window.confirmDeleteCurrentRelease = confirmDeleteCurrentRelease; window.verifyUserEmail = verifyUserEmail; window.setUserAdmin = setUserAdmin; window.confirmDeleteApp = confirmDeleteApp; window.copyToClipboard = copyToClipboard; window.closeModal = closeModal;