Some checks are pending
build-and-push / image (push) Waiting to run
- All 14 panes rewritten with rich controls: KPIs, sparklines, meter gauges, presets, history sidebars, search/filter, bulk actions, schema-driven forms, copy buttons, auto-refresh. - Backend /api/system enriched with cpu_count, rss_bytes, mem_total, mem_used_pct, loadavg[], user/system CPU times, max_rss_kb. - CSS additions: .meter/.gauge, .seg, .chip(s), .spark, .slider, .dl, .link-card, details.card-d plus utility classes. - ui.js helpers: copyToClipboard, withCopy/bindCopyButtons, sparkline, meterRow, fmtBytes/fmtNum, historyStore, inputFromSchema, collectSchemaForm. - Files pane added; iCloud duplicate '*2' files removed.
286 lines
11 KiB
JavaScript
286 lines
11 KiB
JavaScript
// lib/ui.js — toasts, command palette, helpers.
|
|
|
|
const toastsEl = () => document.getElementById('toasts');
|
|
|
|
export function toast({ kind = 'info', title = '', body = '', ttl = 3800 } = {}) {
|
|
const host = toastsEl();
|
|
if (!host) return;
|
|
const el = document.createElement('div');
|
|
el.className = `toast ${kind}`;
|
|
el.innerHTML = `<div class="ttl"></div><div class="body"></div>`;
|
|
el.querySelector('.ttl').textContent = title || ({
|
|
ok: 'Success', warn: 'Warning', err: 'Error', info: 'Info',
|
|
}[kind] || 'Info');
|
|
el.querySelector('.body').textContent = body || '';
|
|
host.appendChild(el);
|
|
setTimeout(() => {
|
|
el.style.opacity = '0';
|
|
el.style.transform = 'translateY(8px)';
|
|
el.style.transition = 'all 200ms';
|
|
setTimeout(() => el.remove(), 220);
|
|
}, ttl);
|
|
}
|
|
|
|
export const ok = (body, title) => toast({ kind: 'ok', body, title });
|
|
export const warn = (body, title) => toast({ kind: 'warn', body, title });
|
|
export const err = (body, title) => toast({ kind: 'err', body, title });
|
|
|
|
// ---------- Command palette ----------
|
|
const paletteCommands = [];
|
|
let paletteOpen = false;
|
|
let paletteFiltered = [];
|
|
let paletteIndex = 0;
|
|
|
|
export function registerCommand(cmd) {
|
|
// cmd: { id, label, group, run(), keywords?: string }
|
|
paletteCommands.push(cmd);
|
|
}
|
|
|
|
function renderPalette() {
|
|
const list = document.getElementById('palette-list');
|
|
if (!list) return;
|
|
list.innerHTML = paletteFiltered.map((c, i) => `
|
|
<li class="${i === paletteIndex ? 'active' : ''}" data-i="${i}">
|
|
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
|
|
<span class="grow">${escapeHtml(c.label)}</span>
|
|
<span class="group">${escapeHtml(c.group || '')}</span>
|
|
</li>
|
|
`).join('');
|
|
list.querySelectorAll('li').forEach(li => {
|
|
li.addEventListener('click', () => {
|
|
const idx = parseInt(li.dataset.i, 10);
|
|
if (!Number.isNaN(idx)) runFiltered(idx);
|
|
});
|
|
});
|
|
}
|
|
|
|
function filterPalette(q) {
|
|
q = (q || '').trim().toLowerCase();
|
|
if (!q) paletteFiltered = paletteCommands.slice(0, 30);
|
|
else {
|
|
paletteFiltered = paletteCommands.filter(c => {
|
|
const hay = (c.label + ' ' + (c.group || '') + ' ' + (c.keywords || '')).toLowerCase();
|
|
return hay.includes(q);
|
|
}).slice(0, 40);
|
|
}
|
|
paletteIndex = 0;
|
|
renderPalette();
|
|
}
|
|
|
|
function runFiltered(i) {
|
|
const c = paletteFiltered[i];
|
|
if (!c) return;
|
|
closePalette();
|
|
try { c.run(); } catch (e) { err(String(e)); }
|
|
}
|
|
|
|
export function openPalette(prefill = '') {
|
|
const bg = document.getElementById('palette-bg');
|
|
const inp = document.getElementById('palette-input');
|
|
if (!bg || !inp) return;
|
|
paletteOpen = true;
|
|
bg.classList.add('open');
|
|
bg.setAttribute('aria-hidden', 'false');
|
|
inp.value = prefill;
|
|
filterPalette(prefill);
|
|
setTimeout(() => inp.focus(), 10);
|
|
}
|
|
export function closePalette() {
|
|
const bg = document.getElementById('palette-bg');
|
|
if (!bg) return;
|
|
paletteOpen = false;
|
|
bg.classList.remove('open');
|
|
bg.setAttribute('aria-hidden', 'true');
|
|
}
|
|
|
|
export function initPalette() {
|
|
const trigger = document.getElementById('cmd-trigger');
|
|
const bg = document.getElementById('palette-bg');
|
|
const inp = document.getElementById('palette-input');
|
|
if (!trigger || !bg || !inp) return;
|
|
trigger.addEventListener('click', () => openPalette());
|
|
bg.addEventListener('click', (e) => { if (e.target === bg) closePalette(); });
|
|
inp.addEventListener('input', (e) => filterPalette(e.target.value));
|
|
inp.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape') return closePalette();
|
|
if (e.key === 'ArrowDown') { e.preventDefault(); paletteIndex = Math.min(paletteFiltered.length - 1, paletteIndex + 1); renderPalette(); }
|
|
if (e.key === 'ArrowUp') { e.preventDefault(); paletteIndex = Math.max(0, paletteIndex - 1); renderPalette(); }
|
|
if (e.key === 'Enter') { e.preventDefault(); runFiltered(paletteIndex); }
|
|
});
|
|
window.addEventListener('keydown', (e) => {
|
|
const k = (e.key || '').toLowerCase();
|
|
if ((e.metaKey || e.ctrlKey) && k === 'k') { e.preventDefault(); paletteOpen ? closePalette() : openPalette(); }
|
|
if (paletteOpen && e.key === 'Escape') closePalette();
|
|
});
|
|
}
|
|
|
|
// ---------- helpers ----------
|
|
export function escapeHtml(s) {
|
|
return String(s ?? '').replace(/[&<>"']/g, c => ({
|
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
|
}[c]));
|
|
}
|
|
|
|
export function skeleton(lines = 3) {
|
|
return Array.from({ length: lines }).map((_, i) =>
|
|
`<div class="skel" style="height: ${i === 0 ? 18 : 12}px; width: ${100 - i * 12}%; margin-top: ${i ? 8 : 0}px;"></div>`
|
|
).join('');
|
|
}
|
|
|
|
export function fmtJSON(v) {
|
|
if (v == null) return '';
|
|
if (typeof v === 'string') return v;
|
|
try { return JSON.stringify(v, null, 2); } catch { return String(v); }
|
|
}
|
|
|
|
export function setHealthPill(state, text) {
|
|
const pill = document.getElementById('health-pill');
|
|
const txt = document.getElementById('health-text');
|
|
if (!pill || !txt) return;
|
|
pill.classList.remove('ok', 'warn', 'err', 'info', 'muted');
|
|
pill.classList.add(state || 'muted');
|
|
txt.textContent = text || state || '—';
|
|
}
|
|
|
|
export function setBrandVer(text) {
|
|
const el = document.getElementById('brand-ver');
|
|
if (el) el.textContent = text || '—';
|
|
}
|
|
export function setFooterBuild(text) {
|
|
const el = document.getElementById('footer-build');
|
|
if (el) el.textContent = text || 'build —';
|
|
}
|
|
|
|
// ---------- additional reusable helpers ----------
|
|
|
|
export async function copyToClipboard(text, label = 'copied') {
|
|
try {
|
|
await navigator.clipboard.writeText(text);
|
|
ok(label);
|
|
} catch (_) {
|
|
// fallback for non-secure contexts
|
|
const ta = document.createElement('textarea');
|
|
ta.value = text;
|
|
document.body.appendChild(ta);
|
|
ta.select();
|
|
try { document.execCommand('copy'); ok(label); } catch { err('copy failed'); }
|
|
ta.remove();
|
|
}
|
|
}
|
|
|
|
// Wraps any `<pre>` so it gets a copy button on hover.
|
|
export function withCopy(html, id = '') {
|
|
const safeId = id || `c${Math.random().toString(36).slice(2, 7)}`;
|
|
return `<div class="code-wrap">
|
|
<pre class="code" id="${safeId}">${html}</pre>
|
|
<button class="copy-btn" data-copy-target="${safeId}" title="Copy to clipboard">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></svg>
|
|
</button>
|
|
</div>`;
|
|
}
|
|
// Wire up any .copy-btn[data-copy-target] inside `host` after rendering.
|
|
export function bindCopyButtons(host) {
|
|
host.querySelectorAll('.copy-btn[data-copy-target]').forEach(btn => {
|
|
if (btn.dataset.bound) return;
|
|
btn.dataset.bound = '1';
|
|
btn.addEventListener('click', () => {
|
|
const t = host.querySelector('#' + btn.dataset.copyTarget);
|
|
if (t) copyToClipboard(t.textContent || '');
|
|
});
|
|
});
|
|
}
|
|
|
|
// Renders a tiny sparkline (svg) for an array of numbers (0..N values).
|
|
export function sparkline(values, { w = 220, h = 36 } = {}) {
|
|
if (!values || values.length === 0) return `<svg class="spark" viewBox="0 0 ${w} ${h}"></svg>`;
|
|
const min = Math.min(...values);
|
|
const max = Math.max(...values);
|
|
const span = max - min || 1;
|
|
const dx = w / Math.max(1, values.length - 1);
|
|
const pts = values.map((v, i) => `${(i * dx).toFixed(1)},${(h - ((v - min) / span) * (h - 4) - 2).toFixed(1)}`);
|
|
const line = `M ${pts.join(' L ')}`;
|
|
const area = `${line} L ${w},${h} L 0,${h} Z`;
|
|
return `<svg class="spark" viewBox="0 0 ${w} ${h}" preserveAspectRatio="none">
|
|
<path class="a" d="${area}" />
|
|
<path class="l" d="${line}" />
|
|
</svg>`;
|
|
}
|
|
|
|
// Render a horizontal meter row (0..100). State: ok/warn/err/none.
|
|
export function meterRow(label, pct, value, state = '') {
|
|
const safe = Math.max(0, Math.min(100, Number(pct) || 0));
|
|
return `<div class="gauge">
|
|
<span class="gname">${escapeHtml(label)}</span>
|
|
<div class="meter ${escapeHtml(state)}"><span style="width:${safe}%"></span></div>
|
|
<span class="gval">${escapeHtml(value)}</span>
|
|
</div>`;
|
|
}
|
|
|
|
// Pretty-print numbers (bytes / counts).
|
|
export function fmtBytes(n) {
|
|
if (!n && n !== 0) return '—';
|
|
const u = ['B','KB','MB','GB','TB'];
|
|
let i = 0, v = +n;
|
|
while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; }
|
|
return `${v.toFixed(v >= 100 || i === 0 ? 0 : v >= 10 ? 1 : 2)} ${u[i]}`;
|
|
}
|
|
export function fmtNum(n) {
|
|
if (n == null || Number.isNaN(+n)) return '—';
|
|
return (+n).toLocaleString();
|
|
}
|
|
|
|
// Tiny localStorage-backed history of items (most recent first).
|
|
export function historyStore(key, max = 25) {
|
|
const read = () => {
|
|
try { return JSON.parse(localStorage.getItem(key) || '[]'); } catch { return []; }
|
|
};
|
|
return {
|
|
list: read,
|
|
push(item) {
|
|
const arr = read();
|
|
arr.unshift({ ...item, _ts: Date.now() });
|
|
while (arr.length > max) arr.pop();
|
|
try { localStorage.setItem(key, JSON.stringify(arr)); } catch {}
|
|
},
|
|
clear() { try { localStorage.removeItem(key); } catch {} },
|
|
};
|
|
}
|
|
|
|
// Generate a quick HTML form input set from a JSON Schema (best-effort, flat).
|
|
export function inputFromSchema(name, schema) {
|
|
const lbl = name + (schema?.description ? ` — ${schema.description}` : '');
|
|
if (!schema) return `<label class="field"><span class="lbl">${escapeHtml(name)}</span><input class="input" data-k="${escapeHtml(name)}"></label>`;
|
|
if (schema.enum?.length) {
|
|
const opts = schema.enum.map(v => `<option value="${escapeHtml(String(v))}">${escapeHtml(String(v))}</option>`).join('');
|
|
return `<label class="field"><span class="lbl">${escapeHtml(lbl)}</span><select class="input" data-k="${escapeHtml(name)}">${opts}</select></label>`;
|
|
}
|
|
if (schema.type === 'boolean') {
|
|
return `<label class="check" style="margin-top:8px"><input type="checkbox" data-k="${escapeHtml(name)}" data-kind="bool" ${schema.default ? 'checked' : ''}/> ${escapeHtml(lbl)}</label>`;
|
|
}
|
|
if (schema.type === 'integer' || schema.type === 'number') {
|
|
return `<label class="field"><span class="lbl">${escapeHtml(lbl)}</span>
|
|
<input class="input" type="number" data-k="${escapeHtml(name)}" data-kind="num"${schema.default != null ? ` value="${schema.default}"` : ''}/></label>`;
|
|
}
|
|
if (schema.type === 'object' || schema.type === 'array') {
|
|
return `<label class="field"><span class="lbl">${escapeHtml(lbl)} <span class="muted xsmall">(JSON)</span></span>
|
|
<textarea class="input" rows="3" data-k="${escapeHtml(name)}" data-kind="json">${schema.default != null ? escapeHtml(JSON.stringify(schema.default)) : ''}</textarea></label>`;
|
|
}
|
|
return `<label class="field"><span class="lbl">${escapeHtml(lbl)}</span>
|
|
<input class="input" data-k="${escapeHtml(name)}"${schema.default != null ? ` value="${escapeHtml(String(schema.default))}"` : ''}/></label>`;
|
|
}
|
|
export function collectSchemaForm(container) {
|
|
const out = {};
|
|
container.querySelectorAll('[data-k]').forEach(el => {
|
|
const k = el.dataset.k;
|
|
const kind = el.dataset.kind || (el.type === 'number' ? 'num' : '');
|
|
let v;
|
|
if (el.type === 'checkbox') v = el.checked;
|
|
else v = el.value;
|
|
if (v === '' || v == null) return;
|
|
if (kind === 'num') v = Number(v);
|
|
else if (kind === 'json') { try { v = JSON.parse(v); } catch { /* leave as string */ } }
|
|
out[k] = v;
|
|
});
|
|
return out;
|
|
}
|