/* estate.js — Vigo's Estate API client * * Fetches Estate API data and populates dynamic sections: homepage pulse * cards and full estate dashboard. * * Primary source: live Estate API via /api/ (nginx reverse proxy to * localhost:8000). Fallback: build-time JSON snapshots from /data/*.json * (generated by prebuild-fetch.sh). */ const DATA_BASE = '/data'; const API_BASE = (function () { if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') { return 'http://127.0.0.1:8000'; } return '/api'; })(); /* ── Helpers ────────────────────────────────────────────────────── */ function $(id) { return document.getElementById(id); } async function loadJSON(path) { const res = await fetch(path); if (!res.ok) throw new Error(`${path}: ${res.status}`); return res.json(); } /** * fetchFromAPI — try live API endpoint first, fall back to static data file. * @param {string} endpoint - API path (e.g. 'summary', 'health') * @param {string} dataFile - static data file path (e.g. '/data/summary.json') * @returns {object} parsed JSON */ async function fetchFromAPI(endpoint, dataFile) { const apiUrl = API_BASE + '/' + endpoint; try { const res = await fetch(apiUrl); if (res.ok) return await res.json(); throw new Error('HTTP ' + res.status); } catch (err) { console.log('[estate] live API unreachable (' + apiUrl + '), falling back to ' + dataFile); return loadJSON(dataFile); } } function fmtPct(v) { return (typeof v === 'number') ? v + '%' : v; } function fmtTime(t) { if (!t) return '—'; try { return new Date(t).toLocaleString(); } catch { return t; } } /* ── Homepage pulse cards ──────────────────────────────────────── */ async function fetchPulse() { try { const summary = await fetchFromAPI('summary', DATA_BASE + '/summary.json'); const trends = await fetchFromAPI('trends?limit=5', DATA_BASE + '/trends-limit-5.json'); const repos = await fetchFromAPI('repos', DATA_BASE + '/repos.json').catch(() => null); // Disk const diskPct = summary?.estate?.disk_latest; if ($('disk-value')) $('disk-value').textContent = diskPct ? diskPct + '%' : '—'; if ($('disk-detail')) $('disk-detail').textContent = diskPct ? `used` : 'n/a'; // Health const healthStatus = summary?.estate?.health_status || '—'; if ($('health-value')) $('health-value').textContent = healthStatus === 'ok' ? 'ok' : healthStatus; if ($('health-detail')) $('health-detail').textContent = healthStatus === 'ok' ? 'estate nominal' : 'check estate'; // Events const eventCount = summary?.estate?.recent_events?.length || 0; if ($('events-value')) $('events-value').textContent = eventCount > 0 ? eventCount : '—'; if ($('events-detail')) $('events-detail').textContent = eventCount === 1 ? 'event' : eventCount + ' events'; // Session count from trends const sessions = trends?.data?.[0]?.vault?.sessions || null; if ($('vault-sessions-value')) $('vault-sessions-value').textContent = sessions !== null ? sessions : '—'; // Repo count from repos const repoData = repos?.forgejo_repos || repos?.data || []; const repoCount = Array.isArray(repoData) ? repoData.length : 0; if ($('estate-repo-count-pulse')) $('estate-repo-count-pulse').textContent = repoCount > 0 ? repoCount.toLocaleString() : '—'; // Session count standalone (in the intro block) if ($('session-count')) { $('session-count').textContent = sessions !== null ? sessions.toLocaleString() + ' sessions' : '? sessions'; } // Timestamp const updated = summary?.estate?.recent_events?.[0]?.timestamp; if ($('pulse-timestamp')) { $('pulse-timestamp').textContent = updated ? 'Last update: ' + fmtTime(updated) : 'Estate data live'; } } catch (err) { console.warn('estate.js: pulse fetch failed', err); if ($('pulse-timestamp')) $('pulse-timestamp').textContent = 'Estate API offline'; // Leave placeholder dashes in the cards } } /* ── Estate dashboard (full) ───────────────────────────────────── */ async function fetchEstate() { const el = (id) => $(id); const setText = (id, val) => { const e = el(id); if (e) e.textContent = val; }; const setHTML = (id, html) => { const e = el(id); if (e) e.innerHTML = html; }; try { const [summary, health, disk, events, repos, providers, builds, trends] = await Promise.all([ fetchFromAPI('summary', DATA_BASE + '/summary.json'), fetchFromAPI('health', DATA_BASE + '/health.json').catch(() => ({ error: true, data: [] })), fetchFromAPI('disk', DATA_BASE + '/disk.json').catch(() => ({ error: true })), fetchFromAPI('events?limit=10', DATA_BASE + '/events-limit-10.json').catch(() => ({ error: true, data: [] })), fetchFromAPI('repos', DATA_BASE + '/repos.json').catch(() => ({ error: true, data: [] })), fetchFromAPI('providers', DATA_BASE + '/providers.json').catch(() => ({ error: true, data: [] })), fetchFromAPI('builds', DATA_BASE + '/builds.json').catch(() => ({ error: true, data: [] })), fetchFromAPI('trends?limit=10', DATA_BASE + '/trends-limit-5.json').catch(() => ({ error: true, data: [] })), ]); // ── Summary cards ── setText('estate-api-version', summary?.api_version || '—'); setText('estate-disk', summary?.estate?.disk_latest ? summary.estate.disk_latest + '%' : '—'); const healthStatus = summary?.estate?.health_status || '—'; const healthBadge = healthStatus === 'ok' ? 'ok' : healthStatus.includes('check') ? '' + healthStatus + '' : '' + healthStatus + ''; setHTML('estate-health', healthBadge); setText('estate-sources', summary?.sources?.length || '—'); const sourceRows = (summary?.sources || []).map(s => `${s.source}${s.available ? '✓' : '✗'}${s.count}${s.last_updated || '—'}` ).join(''); setHTML('estate-sources-table', sourceRows || 'No source data'); // ── Health ── if (health?.error) { setHTML('estate-health-table', 'Health data unavailable'); } else { const healthRows = (Array.isArray(health?.data) ? health.data : health?.data ? [health.data] : []).slice(0, 10).map(h => { const badgeClass = h.status === 'ok' || h.status === 'healthy' ? 'ok' : (h.status || '').includes('warn') || (h.status || '').includes('degraded') ? 'warn' : 'err'; return `${h.timestamp || '—'}${h.status || '—'}${(h.detail || '').substring(0, 80)}`; }).join(''); setHTML('estate-health-table', healthRows || 'No health entries'); } // ── Disk ── if (disk?.error) { setHTML('estate-disk-info', '

Disk data unavailable

'); } else { const diskLatest = disk?.latest || disk; const diskHtml = Object.entries(diskLatest || {}).map(([k, v]) => `${k}${v}` ).join(''); setHTML('estate-disk-info', diskHtml ? '' + diskHtml + '
MetricValue
' : '

No disk data

'); } // ── Events ── const evts = Array.isArray(events?.data) ? events.data : (events?.data ? [events.data] : []); const eventRows = evts.slice(0, 10).map(e => `${fmtTime(e.timestamp)}${e.source || '—'}${(e.detail || '').substring(0, 90)}` ).join(''); setHTML('estate-events-table', eventRows || 'No events'); // ── Repos ── const repoData = repos?.forgejo_repos || repos?.data || (repos?.repos?.data ? repos.repos.data : []); const repoList = Array.isArray(repoData) ? repoData : []; // Update summary card if we have repo data if (repoList.length > 0) { setText('estate-repo-count', repoList.length.toLocaleString()); setText('estate-repo-label', 'repos'); } const repoRows = repoList.slice(0, 15).map(r => `${r.name || r.path || '—'}${r.url || '—'}${r.branch || r.status || '—'}` ).join(''); setHTML('estate-repos-table', repoRows || 'No repo data'); // ── Providers ── const provList = Array.isArray(providers?.data) ? providers.data : (providers?.providers || []); const provRows = provList.slice(0, 10).map(p => { const reachable = p.status === 'ok' || p.reachable === true; return `${p.name || '—'}${reachable ? '✓' : '✗'}${p.model || '—'}`; }).join(''); setHTML('estate-providers-table', provRows || 'No provider data'); // ── Builds ── const buildList = Array.isArray(builds?.data) ? builds.data : []; const buildRows = buildList.slice(0, 10).map(b => `${b.timestamp || '—'}${b.repo || b.project || '—'}${b.status || '—'}` ).join(''); setHTML('estate-builds-table', buildRows || 'No builds'); // ── Trends ── const trendData = Array.isArray(trends?.data) ? trends.data : []; const trendRows = trendData.slice(0, 10).map(t => `${fmtTime(t.timestamp)}${t.vault?.sessions || '—'}${t.vault?.notes || '—'}${t.disk?.used_pct || '—'}%${t.system?.mem_used_pct || '—'}%` ).join(''); setHTML('estate-trends-table', trendRows || 'No trend data'); // Estate loaded indicator setHTML('estate-loading', ''); } catch (err) { console.warn('estate.js: full estate fetch failed', err); setHTML('estate-loading', '

Estate API unavailable

'); } } /* ── State files ───────────────────────────────────────────────── */ /* NOTE: Only metadata (name, size) is shown for security. Full content requires API authentication. */ async function fetchStateFiles() { const el = (id) => $(id); const setHTML = (id, html) => { const e = el(id); if (e) e.innerHTML = html; }; try { const state = await fetchFromAPI('state', DATA_BASE + '/state.json'); const files = Array.isArray(state?.files) ? state.files : []; const fileCards = files.map(f => { return `
${f.name}

${(f.size_bytes || 0).toLocaleString()} bytes

Estate state file — authenticated API required to view content

`; }).join(''); setHTML('estate-state-files', fileCards || '

No state files

'); } catch (err) { console.warn('estate.js: state file fetch failed', err); setHTML('estate-state-files', '

State data unavailable

'); } } function escHtml(s) { const div = document.createElement('div'); div.textContent = s; return div.innerHTML; } /* ── Init ──────────────────────────────────────────────────────── */ document.addEventListener('DOMContentLoaded', () => { // Always fetch pulse (homepage widget) fetchPulse(); // If we're on the estate dashboard, fetch full data if ($('estate-dashboard')) { fetchEstate(); fetchStateFiles(); } });