kitestacks-homelab/apps/kitestacks-portal/public/app.js

304 lines
11 KiB
JavaScript

/* KiteStacks portal — live metrics client */
const GAUGE_CIRCUMFERENCE = 2 * Math.PI * 48; // r=48 in viewBox
const els = {
cpuHud: document.getElementById('hud-cpu'),
ramHud: document.getElementById('hud-ram'),
storageHud: document.getElementById('hud-storage'),
uptimeHud: document.getElementById('hud-uptime'),
temp: document.getElementById('hud-temp'),
weatherDesc: document.getElementById('hud-weather-desc'),
gCpu: document.getElementById('g-cpu'),
gCpuVal: document.getElementById('g-cpu-val'),
gRam: document.getElementById('g-ram'),
gRamVal: document.getElementById('g-ram-val'),
gRamSub: document.getElementById('g-ram-sub'),
gStorage: document.getElementById('g-storage'),
gStorageVal: document.getElementById('g-storage-val'),
gStorageSub: document.getElementById('g-storage-sub'),
netTx: document.getElementById('net-tx'),
netRx: document.getElementById('net-rx'),
waveCpu: document.getElementById('wave-cpu'),
waveNet: document.getElementById('wave-net'),
};
// History buffers for waveforms (last 30 samples)
const histCpu = [];
const histNet = [];
const HIST_MAX = 30;
function setGauge(el, pct) {
if (!el) return;
const v = Math.max(0, Math.min(100, pct || 0));
const offset = GAUGE_CIRCUMFERENCE * (1 - v / 100);
el.style.strokeDasharray = GAUGE_CIRCUMFERENCE.toFixed(2);
el.style.strokeDashoffset = offset.toFixed(2);
}
function fmtKbs(v) {
if (v < 1) return v.toFixed(2) + ' KB/s';
if (v < 1024) return v.toFixed(1) + ' KB/s';
return (v / 1024).toFixed(2) + ' MB/s';
}
function updateWave(polyline, history) {
if (!polyline) return;
const max = Math.max(1, ...history);
const w = 120, h = 20;
const step = w / Math.max(1, HIST_MAX - 1);
const pts = history.map((v, i) => {
const x = i * step;
const y = h - (v / max) * (h - 2) - 1;
return `${x.toFixed(1)},${y.toFixed(1)}`;
});
polyline.setAttribute('points', pts.join(' '));
}
async function fetchMetrics() {
try {
const r = await fetch('/api/metrics', { cache: 'no-store' });
if (!r.ok) throw new Error('HTTP ' + r.status);
const d = await r.json();
// HUD
els.cpuHud.textContent = d.cpu.pct.toFixed(0) + '%';
els.ramHud.textContent = `${d.ram.used_gb} GB / ${d.ram.total_gb} GB`;
els.storageHud.textContent = `${d.storage.used_gb} GB / ${d.storage.total_gb} GB`;
const u = d.uptime;
els.uptimeHud.textContent = `${u.days}d ${u.hours}h ${u.minutes}m`;
// Gauges
setGauge(els.gCpu, d.cpu.pct);
els.gCpuVal.textContent = d.cpu.pct.toFixed(0) + '%';
setGauge(els.gRam, d.ram.pct);
els.gRamVal.textContent = d.ram.pct.toFixed(0) + '%';
els.gRamSub.textContent = `${d.ram.used_gb} GB / ${d.ram.total_gb} GB`;
setGauge(els.gStorage, d.storage.pct);
els.gStorageVal.textContent = d.storage.pct.toFixed(0) + '%';
els.gStorageSub.textContent = `${d.storage.used_gb} GB / ${d.storage.total_gb} GB`;
// Network
els.netTx.textContent = fmtKbs(d.network.tx_kbs);
els.netRx.textContent = fmtKbs(d.network.rx_kbs);
// Per-core CPU
renderCores(d.cpu.cores || []);
// Waveform histories
histCpu.push(d.cpu.pct);
if (histCpu.length > HIST_MAX) histCpu.shift();
updateWave(els.waveCpu, histCpu);
histNet.push(d.network.tx_kbs + d.network.rx_kbs);
if (histNet.length > HIST_MAX) histNet.shift();
updateWave(els.waveNet, histNet);
} catch (err) {
console.warn('metrics fetch failed', err);
}
}
async function fetchWeather() {
try {
const r = await fetch('/api/weather', { cache: 'no-store' });
if (!r.ok) throw new Error('HTTP ' + r.status);
const d = await r.json();
if (d.temp_f != null) {
els.temp.textContent = d.temp_f + '°F';
els.weatherDesc.textContent = d.description || '';
} else {
els.temp.textContent = '--°F';
els.weatherDesc.textContent = d.description || 'Offline';
}
} catch (err) {
console.warn('weather fetch failed', err);
els.weatherDesc.textContent = 'Offline';
}
}
// Search — checks card data-name first, else fall through to Google
window.kiteSearch = function (e) {
e.preventDefault();
const q = document.getElementById('search-input').value.trim();
if (!q) return;
const cards = Array.from(document.querySelectorAll('.card'));
const match = cards.find(c =>
(c.dataset.name || '').toLowerCase().includes(q.toLowerCase())
);
// a valid match is a card with a real href (not a placeholder)
if (match && match.getAttribute('href') && match.getAttribute('href') !== '#') {
window.open(match.href, '_blank', 'noopener');
} else {
window.open(`https://www.google.com/search?q=${encodeURIComponent(q)}`, '_blank', 'noopener');
}
};
// Coming-soon toast for cards without a real URL
function showToast(msg) {
let t = document.getElementById('ks-toast');
if (!t) {
t = document.createElement('div');
t.id = 'ks-toast';
document.body.appendChild(t);
}
t.textContent = msg;
t.classList.add('show');
clearTimeout(t._hide);
t._hide = setTimeout(() => t.classList.remove('show'), 2200);
}
document.addEventListener('click', (e) => {
const card = e.target.closest('.card[data-coming-soon]');
if (card) {
e.preventDefault();
showToast(`${card.dataset.name} — coming soon`);
}
});
// Kick off
fetchMetrics();
fetchWeather();
setInterval(fetchMetrics, 2000);
setInterval(fetchWeather, 10 * 60 * 1000); // every 10 min
// ─── Recent Activity (Forgejo public events) ───────────────────
const ACT_ICONS = {
commit_repo: { glyph: '◆', color: '#54e8ff', label: 'Commit' },
push_tag: { glyph: '⛓', color: '#bd6cff', label: 'Tag' },
create_branch: { glyph: '⎇', color: '#39ff7a', label: 'Branch' },
create_repo: { glyph: '✦', color: '#ffd84a', label: 'Repo' },
fork_repo: { glyph: '⑂', color: '#ff5fb1', label: 'Fork' },
pull_request: { glyph: '⇄', color: '#54e8ff', label: 'PR' },
comment_pull: { glyph: '💬', color: '#bd6cff', label: 'Comment' },
comment_issue: { glyph: '💬', color: '#bd6cff', label: 'Comment' },
create_issue: { glyph: '◉', color: '#ff45c8', label: 'Issue' },
close_issue: { glyph: '⊘', color: '#39ff7a', label: 'Closed' },
merge_pull_request:{ glyph: '⮕', color: '#39ff7a', label: 'Merged' },
delete_branch: { glyph: '✕', color: '#ff5fb1', label: 'Del' },
star_repo: { glyph: '★', color: '#ffd84a', label: 'Star' },
};
function relTime(iso) {
if (!iso) return '';
const d = new Date(iso);
const diff = (Date.now() - d.getTime()) / 1000;
if (diff < 60) return Math.max(1, Math.floor(diff)) + 's ago';
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
if (diff < 86400*7) return Math.floor(diff / 86400) + 'd ago';
return d.toISOString().slice(0, 10);
}
function describeEvent(ev) {
const meta = ACT_ICONS[ev.op_type] || { glyph: '●', color: '#54e8ff', label: ev.op_type };
let text;
switch (ev.op_type) {
case 'commit_repo':
text = `${ev.actor} pushed to ${ev.repo}`;
break;
case 'create_branch':
text = `${ev.actor} created branch ${ev.ref_name || ''} in ${ev.repo}`;
break;
case 'push_tag':
text = `${ev.actor} tagged ${ev.ref_name || ''} in ${ev.repo}`;
break;
case 'create_repo':
text = `${ev.actor} created ${ev.repo}`;
break;
case 'fork_repo':
text = `${ev.actor} forked ${ev.repo}`;
break;
case 'pull_request':
case 'merge_pull_request':
text = `${ev.actor} ${ev.op_type === 'merge_pull_request' ? 'merged PR in' : 'opened PR in'} ${ev.repo}`;
break;
case 'create_issue':
text = `${ev.actor} opened issue in ${ev.repo}`;
break;
case 'close_issue':
text = `${ev.actor} closed issue in ${ev.repo}`;
break;
case 'comment_issue':
case 'comment_pull':
text = `${ev.actor} commented in ${ev.repo}`;
break;
case 'star_repo':
text = `${ev.actor} starred ${ev.repo}`;
break;
case 'delete_branch':
text = `${ev.actor} deleted ${ev.ref_name || 'branch'} in ${ev.repo}`;
break;
default:
text = `${ev.actor} · ${ev.op_type} · ${ev.repo}`;
}
return { meta, text };
}
async function fetchActivity() {
const ul = document.getElementById('activity-list');
if (!ul) return;
try {
const r = await fetch('/api/activity', { cache: 'no-store' });
if (!r.ok) throw new Error('HTTP ' + r.status);
const d = await r.json();
const items = d.items || [];
if (items.length === 0) {
ul.innerHTML = '<li class="act-empty"><span class="act-ico" style="color:#8aa3c0">●</span><span class="act-txt">No recent public activity</span></li>';
return;
}
ul.innerHTML = items.slice(0, 5).map(ev => {
const { meta, text } = describeEvent(ev);
const safeText = text.replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
return `<li>
<span class="act-ico" style="color:${meta.color}">${meta.glyph}</span>
<span class="act-txt">${safeText}</span>
<span class="act-time">${relTime(ev.created)}</span>
</li>`;
}).join('');
} catch (err) {
console.warn('activity fetch failed', err);
ul.innerHTML = '<li class="act-empty"><span class="act-ico" style="color:#ff5fb1">●</span><span class="act-txt">Forgejo unreachable</span></li>';
}
}
fetchActivity();
setInterval(fetchActivity, 60 * 1000);
// ─── Per-core CPU renderer ──────────────────────────────────────
function renderCores(cores) {
const grid = document.getElementById('cores-grid');
const meta = document.getElementById('cores-meta');
if (!grid) return;
if (!cores.length) {
meta.textContent = '--';
return;
}
meta.textContent = `${cores.length} cores · avg ${(cores.reduce((a,b)=>a+b,0)/cores.length).toFixed(0)}%`;
// Build once, then update in place to avoid layout thrash
if (grid.children.length !== cores.length) {
grid.innerHTML = cores.map((_, i) => `
<div class="core" data-load="low" data-i="${i}">
<div class="core-head">
<span class="core-label">C${i}</span>
<span class="core-pct">0%</span>
</div>
<div class="core-track"><div class="core-bar"></div></div>
</div>
`).join('');
}
cores.forEach((pct, i) => {
const el = grid.children[i];
if (!el) return;
el.querySelector('.core-pct').textContent = pct.toFixed(0) + '%';
el.querySelector('.core-bar').style.width = Math.max(0, Math.min(100, pct)) + '%';
el.dataset.load = pct > 75 ? 'high' : pct > 40 ? 'med' : 'low';
});
}