/** * Alpine.data('adminDashboard') - Admin dashboard with deferred widget loading. * * The Blade view ships only the lightweight stat tiles + registration status * synchronously. All heavy widgets (User Statistics, System Resources history, * Recent Activity, Site Status, System Status) render skeleton placeholders * and are populated by fetching the cached `admin.api.dashboard-data` payload * on mount. The cache is warmed every minute by the scheduler so this fetch * is essentially free. */ import Alpine from '@alpinejs/csp'; import Chart from 'chart.js/auto'; const STATUS_BADGE_CLASSES = { operational: 'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200', degraded: 'bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200', maintenance: 'bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200', partial_outage: 'bg-orange-100 dark:bg-orange-900 text-orange-800 dark:text-orange-200', major_outage: 'bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200', }; const IMPACT_BADGE_CLASSES = { critical: 'bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200', major: 'bg-orange-100 dark:bg-orange-900 text-orange-800 dark:text-orange-200', minor: 'bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200', }; const SYSTEM_SERVICE_SLUGS = ['database', 'redis', 'queue', 'disk']; // Mirror of \App\Models\Settings::REGISTER_STATUS_*. const REGISTRATION_BADGE_BASE = 'inline-flex items-center justify-center rounded-full px-3 py-1.5 text-sm font-semibold '; const REGISTRATION_BADGE_CLASSES = { 0: 'border border-emerald-500/30 bg-emerald-600 text-white shadow-sm dark:border-emerald-300/20 dark:bg-emerald-500 dark:text-slate-950', 1: 'border border-amber-500/30 bg-amber-500 text-slate-950 shadow-sm dark:border-amber-200/20 dark:bg-amber-400 dark:text-slate-950', 2: 'border border-rose-500/30 bg-rose-600 text-white shadow-sm dark:border-rose-200/20 dark:bg-rose-500 dark:text-white', }; const formatNumber = (value) => Number(value || 0).toLocaleString(); const escapeHtml = (str) => String(str ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); const badgeClass = (status) => 'px-2 py-0.5 inline-flex text-xs leading-5 font-semibold rounded-full ' + (STATUS_BADGE_CLASSES[status] ?? 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200'); const registrationBadgeClass = (status) => REGISTRATION_BADGE_BASE + (REGISTRATION_BADGE_CLASSES[status] ?? REGISTRATION_BADGE_CLASSES[2]); Alpine.data('adminDashboard', () => ({ _charts: [], _refreshInterval: null, _visibilityHandler: null, _lastRefreshAt: Date.now(), _isRefreshing: false, init() { this._loadDashboardData(); this._scheduleRefresh(); }, _scheduleRefresh() { const interval = Number.parseInt(this.$el.dataset.refreshInterval ?? '', 10) || (15 * 60 * 1000); this._refreshInterval = window.setInterval(() => this._loadDashboardData(), interval); this._visibilityHandler = () => { if (!document.hidden && Date.now() - this._lastRefreshAt >= interval) { this._loadDashboardData(); } }; document.addEventListener('visibilitychange', this._visibilityHandler); }, _loadDashboardData() { const url = this.$el.dataset.dataUrl; if (!url || this._isRefreshing) { return; } this._isRefreshing = true; fetch(url, { method: 'GET', headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' }, credentials: 'same-origin', }) .then(response => { if (!response.ok) throw new Error('Failed to load dashboard data.'); return response.json(); }) .then(payload => { this._renderHeadlineStats(payload.stats); this._renderRegistrationStatus(payload.registrationStatus); this._renderLastRefresh(payload.generated_at_time); this._renderUserStats(payload.userStats); this._renderSystemMetrics(payload.systemMetrics); this._renderRecentActivity(payload.recent_activity); this._renderRecentPayments(payload.recent_payments); this._renderSiteStatus(payload.serviceStatuses, payload.activeIncidents); this._renderSystemServices(payload.serviceStatuses); this._lastRefreshAt = Date.now(); }) .catch(() => {}) .finally(() => { this._isRefreshing = false; }); }, _swapWidget(name) { const widget = this.$el.querySelector(`[data-widget="${name}"]`); if (!widget) return null; widget.querySelector('[data-widget-loading]')?.classList.add('hidden'); const content = widget.querySelector('[data-widget-content]'); content?.classList.remove('hidden'); return widget; }, _setStatText(key, value) { const el = this.$el.querySelector(`[data-stat="${key}"]`); if (el) el.textContent = value; }, _renderHeadlineStats(stats) { if (!stats) return; const soft = Number(stats.soft_deleted_users ?? 0); const perm = Number(stats.permanently_deleted_users ?? 0); this._setStatText('releases', formatNumber(stats.releases)); this._setStatText('releases-today', formatNumber(stats.releases_today)); this._setStatText('users', formatNumber(stats.users)); this._setStatText('users-today', formatNumber(stats.users_today)); this._setStatText('active-groups', formatNumber(stats.active_groups)); this._setStatText('groups', formatNumber(stats.groups)); this._setStatText('failed', formatNumber(stats.failed)); this._setStatText('reported', formatNumber(stats.reported)); this._setStatText('soft-deleted-users', formatNumber(soft)); this._setStatText('permanently-deleted-users', formatNumber(perm)); this._setStatText('deleted-users-total', formatNumber(soft + perm)); }, _renderRegistrationStatus(reg) { if (!reg) return; const effective = this.$el.querySelector('[data-registration="effective-badge"]'); if (effective) { effective.className = registrationBadgeClass(Number(reg.effective_status)); effective.textContent = String(reg.effective_status_label ?? ''); } const manual = this.$el.querySelector('[data-registration="manual-badge"]'); if (manual) { manual.className = registrationBadgeClass(Number(reg.manual_status)); manual.textContent = String(reg.manual_status_label ?? ''); } const override = this.$el.querySelector('[data-registration="scheduled-override"]'); if (override) { override.classList.toggle('hidden', !reg.scheduled_override_active); } const message = this.$el.querySelector('[data-registration="message"]'); if (message) message.textContent = String(reg.message ?? ''); }, _renderLastRefresh(timeText) { // "Last dashboard refresh" reflects when the JS last successfully // fetched data — drive it from the browser clock so the indicator // always advances on a successful tick, even if the server snapshot // happened to be served from cache (Cache::flexible) with an older // `generated_at`. The server timestamp (when supplied) is used as a // fallback only — see AdminPageController::getDashboardData(). const clientNow = new Date().toLocaleTimeString(); this._setStatText('last-refresh', clientNow || timeText || ''); }, _renderUserStats(stats) { if (!stats) return; const widget = this._swapWidget('user-stats'); if (!widget) return; const summary = stats.summary ?? {}; const cards = [ { label: 'Total Users', value: summary.total_users, theme: 'blue' }, { label: 'Downloads Today', value: summary.downloads_today, theme: 'green' }, { label: 'Downloads (last 7d incl. today)', value: summary.downloads_week, theme: 'purple' }, { label: 'API Hits Today', value: summary.api_hits_today, theme: 'orange' }, { label: 'API Hits (last 7d incl. today)', value: summary.api_hits_week, theme: 'pink' }, ]; const summaryGrid = widget.querySelector('[data-summary-grid]'); if (summaryGrid) { summaryGrid.innerHTML = cards.map(c => `

${escapeHtml(c.label)}

${formatNumber(c.value)}

`).join(''); } const roles = stats.users_by_role ?? []; const totalUsers = roles.reduce((sum, r) => sum + Number(r.count || 0), 0); const rolesTbody = widget.querySelector('[data-roles-tbody]'); if (rolesTbody) { rolesTbody.innerHTML = roles.length === 0 ? 'No data available' : roles.map(r => { const pct = totalUsers > 0 ? ((Number(r.count) / totalUsers) * 100).toFixed(1) : '0'; const role = String(r.role ?? ''); const roleLabel = role ? role.charAt(0).toUpperCase() + role.slice(1) : ''; return ` ${escapeHtml(roleLabel)} ${formatNumber(r.count)} ${pct}% `; }).join(''); } const downloaders = stats.top_downloaders ?? []; const downloadersTbody = widget.querySelector('[data-downloaders-tbody]'); if (downloadersTbody) { const medalIcons = ['fa-medal text-yellow-500', 'fa-medal text-gray-400', 'fa-medal text-orange-600']; downloadersTbody.innerHTML = downloaders.length === 0 ? 'No downloads in the last 7 days' : downloaders.map((d, i) => { const rank = i < 3 ? `` : `${i + 1}`; return ` ${rank} ${escapeHtml(d.username)} ${formatNumber(d.download_count)} `; }).join(''); } this._renderChart('downloadsChart', 'bar', 'Downloads', stats.downloads_per_hour ?? [], 'rgba(34,197,94,0.7)', 'rgba(34,197,94,1)', false); this._renderChart('apiHitsChart', 'line', 'API Hits', stats.api_hits_per_hour ?? [], 'rgba(168,85,247,0.2)', 'rgba(168,85,247,1)', true); this._renderChart('downloadsMinuteChart', 'line', 'Downloads', stats.downloads_per_minute ?? [], 'rgba(34,197,94,0.2)', 'rgba(34,197,94,1)', true); this._renderChart('apiHitsMinuteChart', 'line', 'API Hits', stats.api_hits_per_minute ?? [], 'rgba(168,85,247,0.2)', 'rgba(168,85,247,1)', true); }, _renderSystemMetrics(metrics) { if (!metrics) return; const widget = this._swapWidget('system-metrics'); if (!widget) return; const setText = (selector, text) => { const el = widget.querySelector(`[data-metric="${selector}"]`); if (el) el.textContent = text; }; const cpu = metrics.cpu ?? {}; const ram = metrics.ram ?? {}; const load = cpu.load_average ?? {}; setText('cpu-current', `${Number(cpu.current ?? 0).toFixed(1)}%`); setText('cpu-cores', cpu.cores ?? '—'); setText('cpu-threads', cpu.threads ?? '—'); setText('cpu-load-1min', load['1min'] ?? '—'); setText('cpu-load-5min', load['5min'] ?? '—'); setText('cpu-load-15min', load['15min'] ?? '—'); const cpuModel = widget.querySelector('[data-metric="cpu-model"]'); if (cpuModel && cpu.model && cpu.model !== 'Unknown') { cpuModel.title = cpu.model; cpuModel.innerHTML = `${escapeHtml(cpu.model)}`; } setText('ram-current', `${Number(ram.percentage ?? 0).toFixed(1)}%`); setText('ram-details', `${Number(ram.used ?? 0).toFixed(2)} GB / ${Number(ram.total ?? 0).toFixed(2)} GB`); this._renderPercentChart('cpuHistory24hChart', 'CPU Usage %', cpu.history_24h ?? [], 'rgba(249,115,22,0.2)', 'rgba(249,115,22,1)'); this._renderPercentChart('ramHistory24hChart', 'RAM Usage %', ram.history_24h ?? [], 'rgba(6,182,212,0.2)', 'rgba(6,182,212,1)'); this._renderPercentChart('cpuHistory30dChart', 'CPU Usage %', cpu.history_30d ?? [], 'rgba(249,115,22,0.2)', 'rgba(249,115,22,1)'); this._renderPercentChart('ramHistory30dChart', 'RAM Usage %', ram.history_30d ?? [], 'rgba(6,182,212,0.2)', 'rgba(6,182,212,1)'); }, _renderRecentActivity(activities) { const widget = this._swapWidget('recent-activity'); if (!widget) return; const updated = widget.querySelector('[data-metric="activity-updated"]'); if (updated) { updated.textContent = `Updated ${new Date().toLocaleTimeString()}`; } const container = widget.querySelector('#recent-activity-container'); if (!container) return; if (!Array.isArray(activities) || activities.length === 0) { container.innerHTML = '

No recent activity

'; return; } container.innerHTML = activities.map(activity => { let extra = ''; if (activity.type === 'deleted' && activity.metadata && activity.metadata.deleted_by) { const permanent = activity.metadata.permanent ? ', permanent' : ''; extra = ` (by ${escapeHtml(activity.metadata.deleted_by)}${escapeHtml(permanent)})`; } return `

${escapeHtml(activity.message)}${extra}

${escapeHtml(activity.created_at_human ?? '')}

`; }).join(''); }, _renderRecentPayments(payments) { const hasPayments = Array.isArray(payments) && payments.length > 0; const existing = this.$el.querySelector('[data-widget="recent-payments"]'); if (!hasPayments) { if (existing) existing.classList.add('hidden'); return; } if (existing) existing.classList.remove('hidden'); const widget = this._swapWidget('recent-payments'); if (!widget) return; const tbody = widget.querySelector('[data-payments-tbody]'); if (!tbody) return; const settledClasses = 'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200'; const pendingClasses = 'bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200'; const otherClasses = 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200'; tbody.innerHTML = payments.map(p => { const status = String(p.payment_status ?? ''); const isSettled = status.toLowerCase() === 'settled'; const isPending = status === '' || status.toLowerCase() === 'pending'; const badge = isSettled ? settledClasses : (isPending ? pendingClasses : otherClasses); const statusLabel = status || 'Pending'; const dateText = p.created_at_formatted ?? p.created_at_human ?? ''; const item = p.item_description ?? ''; const itemTrunc = item.length > 40 ? item.slice(0, 40) + '…' : item; return ` ${escapeHtml(dateText)} ${escapeHtml(p.username ?? '')} ${escapeHtml(itemTrunc)} ${escapeHtml(p.invoice_amount ?? '')} ${escapeHtml(statusLabel)} `; }).join(''); }, _renderSiteStatus(services, incidents) { const widget = this._swapWidget('site-status'); if (!widget) return; const grid = widget.querySelector('[data-services-grid]'); if (grid) { grid.innerHTML = (services ?? []).map(svc => `
${escapeHtml(svc.name)} ${Number(svc.uptime_percentage).toFixed(2)}% uptime
${escapeHtml(svc.status_label)}
`).join(''); } const region = widget.querySelector('[data-incidents-region]'); if (!region) return; if (!Array.isArray(incidents) || incidents.length === 0) { region.innerHTML = '

No active incidents

'; return; } const visible = incidents.slice(0, 5); const overflow = incidents.length - visible.length; const items = visible.map(inc => { const impactClass = 'px-2 py-0.5 inline-flex text-xs leading-5 font-semibold rounded-full shrink-0 ' + (IMPACT_BADGE_CLASSES[inc.impact] ?? 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200'); const autoBadge = inc.is_auto ? 'Auto' : ''; const title = String(inc.title ?? ''); const truncated = title.length > 50 ? `${title.slice(0, 50)}…` : title; return `
${escapeHtml(truncated)} ${escapeHtml((inc.services ?? []).join(', '))} · ${escapeHtml(inc.started_at_human ?? '')}${autoBadge}
${escapeHtml(inc.impact_label)}
`; }).join(''); region.innerHTML = `

${incidents.length} Active Incident${incidents.length === 1 ? '' : 's'}

${items}${overflow > 0 ? `

+ ${overflow} more

` : ''}
`; }, _renderSystemServices(services) { const widget = this._swapWidget('system-status'); if (!widget) return; const container = widget.querySelector('[data-system-services]'); if (!container) return; const bySlug = {}; (services ?? []).forEach(svc => { bySlug[svc.slug] = svc; }); container.innerHTML = SYSTEM_SERVICE_SLUGS.map(slug => { const svc = bySlug[slug]; const label = svc?.name ?? (slug.charAt(0).toUpperCase() + slug.slice(1)); const right = svc ? `${escapeHtml(svc.status_label)}` : 'Not configured'; return `
${escapeHtml(label)} ${right}
`; }).join(''); }, _destroyChart(canvasId) { const existing = this._charts.find(c => c.canvas?.id === canvasId); if (existing) { existing.destroy(); this._charts = this._charts.filter(c => c !== existing); } }, _renderChart(canvasId, type, label, data, backgroundColor, borderColor, fill) { const canvas = document.getElementById(canvasId); if (!canvas || !Array.isArray(data) || data.length === 0) return; this._destroyChart(canvasId); const chart = new Chart(canvas, { type, data: { labels: data.map(item => item.time), datasets: [{ label, data: data.map(item => item.count), backgroundColor, borderColor, borderWidth: type === 'bar' ? 1 : 2, fill, tension: 0.4, }], }, options: { responsive: true, maintainAspectRatio: true, scales: { y: { beginAtZero: true, ticks: { precision: 0 } } }, plugins: { legend: { display: false } }, }, }); this._charts.push(chart); }, _renderPercentChart(canvasId, label, data, backgroundColor, borderColor) { const canvas = document.getElementById(canvasId); if (!canvas || !Array.isArray(data) || data.length === 0) return; this._destroyChart(canvasId); const chart = new Chart(canvas, { type: 'line', data: { labels: data.map(item => item.time), datasets: [{ label, data: data.map(item => item.value), backgroundColor, borderColor, borderWidth: 2, fill: true, tension: 0.4, }], }, options: { responsive: true, maintainAspectRatio: true, scales: { y: { beginAtZero: true, max: 100, ticks: { callback: value => value + '%' } } }, plugins: { legend: { display: false } }, }, }); this._charts.push(chart); }, _destroyCharts() { this._charts.forEach(chart => chart.destroy()); this._charts = []; }, destroy() { if (this._refreshInterval) window.clearInterval(this._refreshInterval); if (this._visibilityHandler) document.removeEventListener('visibilitychange', this._visibilityHandler); this._destroyCharts(); }, }));