259 lines
No EOL
12 KiB
JavaScript
259 lines
No EOL
12 KiB
JavaScript
/* 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' ? '<span class="estate-badge ok">ok</span>'
|
|
: healthStatus.includes('check') ? '<span class="estate-badge warn">' + healthStatus + '</span>'
|
|
: '<span class="estate-badge err">' + healthStatus + '</span>';
|
|
setHTML('estate-health', healthBadge);
|
|
|
|
setText('estate-sources', summary?.sources?.length || '—');
|
|
|
|
const sourceRows = (summary?.sources || []).map(s =>
|
|
`<tr><td>${s.source}</td><td>${s.available ? '✓' : '✗'}</td><td>${s.count}</td><td>${s.last_updated || '—'}</td></tr>`
|
|
).join('');
|
|
setHTML('estate-sources-table', sourceRows || '<tr><td colspan="4">No source data</td></tr>');
|
|
|
|
// ── Health ──
|
|
if (health?.error) {
|
|
setHTML('estate-health-table', '<tr><td colspan="3">Health data unavailable</td></tr>');
|
|
} 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 `<tr><td>${h.timestamp || '—'}</td><td><span class="estate-badge ${badgeClass}">${h.status || '—'}</span></td><td>${(h.detail || '').substring(0, 80)}</td></tr>`;
|
|
}).join('');
|
|
setHTML('estate-health-table', healthRows || '<tr><td colspan="3">No health entries</td></tr>');
|
|
}
|
|
|
|
// ── Disk ──
|
|
if (disk?.error) {
|
|
setHTML('estate-disk-info', '<p>Disk data unavailable</p>');
|
|
} else {
|
|
const diskLatest = disk?.latest || disk;
|
|
const diskHtml = Object.entries(diskLatest || {}).map(([k, v]) =>
|
|
`<tr><td>${k}</td><td>${v}</td></tr>`
|
|
).join('');
|
|
setHTML('estate-disk-info', diskHtml ? '<table><thead><tr><th>Metric</th><th>Value</th></tr></thead><tbody>' + diskHtml + '</tbody></table>' : '<p>No disk data</p>');
|
|
}
|
|
|
|
// ── Events ──
|
|
const evts = Array.isArray(events?.data) ? events.data : (events?.data ? [events.data] : []);
|
|
const eventRows = evts.slice(0, 10).map(e =>
|
|
`<tr><td>${fmtTime(e.timestamp)}</td><td>${e.source || '—'}</td><td>${(e.detail || '').substring(0, 90)}</td></tr>`
|
|
).join('');
|
|
setHTML('estate-events-table', eventRows || '<tr><td colspan="3">No events</td></tr>');
|
|
|
|
// ── 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 =>
|
|
`<tr><td>${r.name || r.path || '—'}</td><td>${r.url || '—'}</td><td>${r.branch || r.status || '—'}</td></tr>`
|
|
).join('');
|
|
setHTML('estate-repos-table', repoRows || '<tr><td colspan="3">No repo data</td></tr>');
|
|
|
|
// ── 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 `<tr><td>${p.name || '—'}</td><td><span class="estate-badge ${reachable ? 'ok' : 'err'}">${reachable ? '✓' : '✗'}</span></td><td>${p.model || '—'}</td></tr>`;
|
|
}).join('');
|
|
setHTML('estate-providers-table', provRows || '<tr><td colspan="3">No provider data</td></tr>');
|
|
|
|
// ── Builds ──
|
|
const buildList = Array.isArray(builds?.data) ? builds.data : [];
|
|
const buildRows = buildList.slice(0, 10).map(b =>
|
|
`<tr><td>${b.timestamp || '—'}</td><td>${b.repo || b.project || '—'}</td><td>${b.status || '—'}</td></tr>`
|
|
).join('');
|
|
setHTML('estate-builds-table', buildRows || '<tr><td colspan="3">No builds</td></tr>');
|
|
|
|
// ── Trends ──
|
|
const trendData = Array.isArray(trends?.data) ? trends.data : [];
|
|
const trendRows = trendData.slice(0, 10).map(t =>
|
|
`<tr><td>${fmtTime(t.timestamp)}</td><td>${t.vault?.sessions || '—'}</td><td>${t.vault?.notes || '—'}</td><td>${t.disk?.used_pct || '—'}%</td><td>${t.system?.mem_used_pct || '—'}%</td></tr>`
|
|
).join('');
|
|
setHTML('estate-trends-table', trendRows || '<tr><td colspan="5">No trend data</td></tr>');
|
|
|
|
// Estate loaded indicator
|
|
setHTML('estate-loading', '');
|
|
} catch (err) {
|
|
console.warn('estate.js: full estate fetch failed', err);
|
|
setHTML('estate-loading', '<p class="error">Estate API unavailable</p>');
|
|
}
|
|
}
|
|
|
|
/* ── 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 `<article data-card>
|
|
<header>${f.name}</header>
|
|
<p>${(f.size_bytes || 0).toLocaleString()} bytes</p>
|
|
<p data-text="dim" style="font-size:var(--font-size-00)">Estate state file — authenticated API required to view content</p>
|
|
</article>`;
|
|
}).join('');
|
|
|
|
setHTML('estate-state-files', fileCards || '<p>No state files</p>');
|
|
} catch (err) {
|
|
console.warn('estate.js: state file fetch failed', err);
|
|
setHTML('estate-state-files', '<p>State data unavailable</p>');
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|
|
}); |