304 lines
11 KiB
JavaScript
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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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';
|
|
});
|
|
}
|
|
|