CxWebApp/static/js/lib/ui.js
CxAI Agent 75153b7fe9
Some checks are pending
build-and-push / image (push) Waiting to run
feat(cxwebapp): comprehensive pane enhancements
- 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.
2026-05-17 09:36:19 -05:00

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 => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[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;
}