/** * Settings → Data Manager — agent retention, auto-prune, storage usage. */ import { Methods, Roles, roleAllows } from '../shared/protocol.js' import { formatBytes } from '../shared/format.js' import { RETENTION_PRESETS, HOT_PRESETS, WARM_MEM_PRESETS, DISK_BUDGET_PRESETS, matchPointsPreset, matchBytesPreset, } from '../shared/retention.js' /** * @param {{ * $: (id: string) => HTMLElement|null, * manager: { request: (m: string, a?: object) => Promise, active: any }, * getRole: () => string, * log: (msg: string) => void, * }} deps */ export function createDataManager(deps) { const { $, manager, getRole, log } = deps /** @type {import('../shared/retention.js').RetentionConfig|null} */ let config = null /** @type {any} */ let storage = null let busy = false const els = { usageTotal: $('dm-usage-total'), usageCorestore: $('dm-usage-corestore'), usageMemory: $('dm-usage-memory'), usageWarm: $('dm-usage-warm'), usageMeta: $('dm-usage-meta'), hotPreset: $('dm-hot-preset'), hotPoints: $('dm-hot-points'), warmMemPreset: $('dm-warm-mem-preset'), warmMemPoints: $('dm-warm-mem-points'), tier1Every: $('dm-tier1-every'), warmEnabled: $('dm-warm-enabled'), warmPreset: $('dm-warm-preset'), warmCustomDays: $('dm-warm-custom-days'), warmCustomWrap: $('dm-warm-custom-wrap'), diskPreset: $('dm-disk-preset'), diskCustomMb: $('dm-disk-custom-mb'), diskCustomWrap: $('dm-disk-custom-wrap'), warmMaxPoints: $('dm-warm-max-points'), autoPrune: $('dm-auto-prune'), pruneIntervalHours: $('dm-prune-interval-hours'), lastPrune: $('dm-last-prune'), status: $('dm-status'), btnRefresh: $('dm-refresh'), btnSave: $('dm-save'), btnPruneDry: $('dm-prune-dry'), btnPruneNow: $('dm-prune-now'), gate: $('dm-admin-gate'), } function setStatus(msg, kind = '') { if (!els.status) return els.status.textContent = msg || '' els.status.dataset.kind = kind } function canAdmin() { return roleAllows(getRole() || Roles.viewer, Roles.admin) } function syncGate() { const ok = canAdmin() && Boolean(manager.active) if (els.gate) { els.gate.classList.toggle('hidden', ok) els.gate.textContent = !manager.active ? 'Connect to an agent to manage its data.' : 'Admin role required to change retention or prune history.' } for (const el of [ els.hotPreset, els.hotPoints, els.warmMemPreset, els.warmMemPoints, els.tier1Every, els.warmEnabled, els.warmPreset, els.warmCustomDays, els.diskPreset, els.diskCustomMb, els.warmMaxPoints, els.autoPrune, els.pruneIntervalHours, els.btnSave, els.btnPruneDry, els.btnPruneNow, ]) { if (el) el.disabled = !ok || busy } if (els.btnRefresh) els.btnRefresh.disabled = !manager.active || busy } function fillSelect(select, presets, selected) { if (!select) return select.innerHTML = '' for (const p of Object.values(presets)) { const opt = document.createElement('option') opt.value = p.id opt.textContent = p.label if (p.id === selected) opt.selected = true select.appendChild(opt) } } function renderUsage() { if (!storage) { if (els.usageTotal) els.usageTotal.textContent = '—' if (els.usageCorestore) els.usageCorestore.textContent = '—' if (els.usageMemory) els.usageMemory.textContent = '—' if (els.usageWarm) els.usageWarm.textContent = '—' if (els.usageMeta) els.usageMeta.textContent = '' return } const u = storage.usage || {} const mem = storage.memory || {} const warm = storage.warm || {} if (els.usageTotal) els.usageTotal.textContent = formatBytes(u.dataDirBytes) if (els.usageCorestore) els.usageCorestore.textContent = formatBytes(u.corestoreBytes) if (els.usageMemory) { els.usageMemory.textContent = `${formatBytes(mem.approxBytes)} · ${mem.tier0Points ?? 0} hot / ${mem.tier1Points ?? 0} warm pts` } if (els.usageWarm) { const age = warm.oldestTs && warm.newestTs ? ` · ${formatAge(warm.newestTs - warm.oldestTs)} span` : '' els.usageWarm.textContent = warm.enabled ? `${warm.pointsSampled ?? 0} pts · ${warm.chartsSampled ?? 0} charts${age}${warm.sampleCapped ? ' (sampled)' : ''}` : 'HyperDB off' } if (els.usageMeta) { const dir = storage.dataDir || '' els.usageMeta.textContent = dir ? `Data dir: ${dir}` : '' } } function renderForm() { if (!config) return fillSelect(els.hotPreset, HOT_PRESETS, matchPointsPreset(config.tier0Points, HOT_PRESETS)) fillSelect( els.warmMemPreset, WARM_MEM_PRESETS, matchPointsPreset(config.tier1Points, WARM_MEM_PRESETS) ) fillSelect(els.warmPreset, RETENTION_PRESETS, config.warmRetentionPreset || '3m') fillSelect(els.diskPreset, DISK_BUDGET_PRESETS, matchBytesPreset(config.warmMaxBytes ?? 0)) if (els.hotPoints) els.hotPoints.value = String(config.tier0Points) if (els.warmMemPoints) els.warmMemPoints.value = String(config.tier1Points) if (els.tier1Every) els.tier1Every.value = String(config.tier1Every) if (els.warmEnabled) els.warmEnabled.checked = config.warmRetentionEnabled !== false if (els.warmCustomDays) { els.warmCustomDays.value = String( Math.max(1, Math.round((config.warmRetentionMs || 0) / (24 * 60 * 60 * 1000)) || 365) ) } if (els.diskCustomMb) { els.diskCustomMb.value = String( config.warmMaxBytes > 0 ? Math.round(config.warmMaxBytes / (1024 * 1024)) : 1024 ) } if (els.warmMaxPoints) els.warmMaxPoints.value = String(config.warmMaxPoints || 0) if (els.autoPrune) els.autoPrune.checked = config.autoPruneEnabled !== false if (els.pruneIntervalHours) { els.pruneIntervalHours.value = String( Math.max(1, Math.round((config.autoPruneIntervalMs || 3_600_000) / 3_600_000)) ) } if (els.lastPrune) { if (config.lastPruneAt) { const when = new Date(config.lastPruneAt).toLocaleString() const freed = config.lastPruneBytesFreed || 0 els.lastPrune.textContent = `Last prune: ${when} · deleted ${config.lastPruneDeleted || 0} pts · disk −${formatBytes(freed)}` } else { els.lastPrune.textContent = 'Last prune: never' } } toggleCustomRows() } function toggleCustomRows() { const warmCustom = els.warmPreset?.value === 'custom' const diskCustom = els.diskPreset?.value === 'custom' els.warmCustomWrap?.classList.toggle('hidden', !warmCustom) els.diskCustomWrap?.classList.toggle('hidden', !diskCustom) if (els.hotPoints) { els.hotPoints.disabled = !canAdmin() || busy || els.hotPreset?.value !== 'custom' } if (els.warmMemPoints) { els.warmMemPoints.disabled = !canAdmin() || busy || els.warmMemPreset?.value !== 'custom' } } function readForm() { const hotId = els.hotPreset?.value || '1h' const warmMemId = els.warmMemPreset?.value || '24h' const warmId = els.warmPreset?.value || '3m' const diskId = els.diskPreset?.value || '1gb' let tier0Points = hotId === 'custom' ? Number(els.hotPoints?.value) : HOT_PRESETS[hotId]?.points ?? Number(els.hotPoints?.value) let tier1Points = warmMemId === 'custom' ? Number(els.warmMemPoints?.value) : WARM_MEM_PRESETS[warmMemId]?.points ?? Number(els.warmMemPoints?.value) let warmRetentionMs = 0 let warmRetentionPreset = warmId if (warmId === 'forever') { warmRetentionMs = 0 } else if (warmId === 'custom') { const days = Math.max(1, Number(els.warmCustomDays?.value) || 7) warmRetentionMs = days * 24 * 60 * 60 * 1000 } else { warmRetentionMs = RETENTION_PRESETS[warmId]?.ms ?? RETENTION_PRESETS['1y'].ms } let warmMaxBytes = 0 if (diskId === 'custom') { warmMaxBytes = Math.max(0, Math.round(Number(els.diskCustomMb?.value) || 0) * 1024 * 1024) } else { warmMaxBytes = DISK_BUDGET_PRESETS[diskId]?.bytes ?? 0 } const hours = Math.max(1, Number(els.pruneIntervalHours?.value) || 1) return { tier0Points, tier1Points, tier1Every: Number(els.tier1Every?.value) || 60, warmRetentionEnabled: Boolean(els.warmEnabled?.checked), warmRetentionPreset, warmRetentionMs, warmMaxBytes, warmMaxPoints: Math.max(0, Math.floor(Number(els.warmMaxPoints?.value) || 0)), autoPruneEnabled: Boolean(els.autoPrune?.checked), autoPruneIntervalMs: hours * 60 * 60 * 1000, } } async function refresh() { if (!manager.active) { config = null storage = null renderUsage() setStatus('Not connected') syncGate() return } busy = true syncGate() setStatus('Loading…') try { const [info, ret] = await Promise.all([ manager.request(Methods.getStorageInfo, {}), manager.request(Methods.getRetentionConfig, {}), ]) storage = info config = ret?.config || info?.retention || null renderUsage() renderForm() setStatus('Up to date') } catch (err) { setStatus(err.message || 'Failed to load', 'error') log(`Data Manager: ${err.message || err}`) } finally { busy = false syncGate() toggleCustomRows() } } async function save() { if (!canAdmin()) return busy = true syncGate() setStatus('Saving…') try { const patch = readForm() const res = await manager.request(Methods.setRetentionConfig, patch) config = res?.config || patch setStatus('Saved on agent') log('Retention config saved') await refresh() } catch (err) { setStatus(err.message || 'Save failed', 'error') log(`Data Manager save: ${err.message || err}`) } finally { busy = false syncGate() toggleCustomRows() } } async function runPrune(dryRun) { if (!canAdmin()) return busy = true syncGate() setStatus(dryRun ? 'Dry-run…' : 'Pruning…') try { const res = await manager.request(Methods.pruneHistory, { dryRun, force: true }) if (res?.success === false) throw new Error(res.error || 'prune failed') const verb = dryRun ? 'Would delete' : 'Deleted' const freed = res.bytesFreed ?? res.approxBytesFreed ?? 0 const disk = !dryRun && res.bytesBefore != null ? ` · disk ${formatBytes(res.bytesBefore)} → ${formatBytes(res.bytesAfter ?? 0)} (−${formatBytes(freed)})` : dryRun ? '' : ` · disk −${formatBytes(freed)}` const gc = res.reclaim?.cleared || res.reclaim?.compacted ? ` · GC ${res.reclaim.clearMs ?? 0}ms clear / ${res.reclaim.compactMs ?? 0}ms compact` : '' setStatus(`${verb} ${res.deleted ?? 0} points${disk}${gc}`) log( `Prune${dryRun ? ' dry-run' : ''}: ${res.deleted ?? 0} deleted, freed ${formatBytes(freed)}` ) if (!dryRun) await refresh() } catch (err) { setStatus(err.message || 'Prune failed', 'error') log(`Data Manager prune: ${err.message || err}`) } finally { busy = false syncGate() toggleCustomRows() } } function bind() { els.btnRefresh?.addEventListener('click', () => refresh()) els.btnSave?.addEventListener('click', () => save()) els.btnPruneDry?.addEventListener('click', () => runPrune(true)) els.btnPruneNow?.addEventListener('click', () => { if ( !confirm( 'Prune warm history on the connected agent now? Points older than the retention window will be deleted.' ) ) { return } runPrune(false) }) els.hotPreset?.addEventListener('change', () => { const id = els.hotPreset.value if (id !== 'custom' && HOT_PRESETS[id]?.points != null && els.hotPoints) { els.hotPoints.value = String(HOT_PRESETS[id].points) } toggleCustomRows() }) els.warmMemPreset?.addEventListener('change', () => { const id = els.warmMemPreset.value if (id !== 'custom' && WARM_MEM_PRESETS[id]?.points != null && els.warmMemPoints) { els.warmMemPoints.value = String(WARM_MEM_PRESETS[id].points) } toggleCustomRows() }) els.warmPreset?.addEventListener('change', toggleCustomRows) els.diskPreset?.addEventListener('change', toggleCustomRows) } bind() syncGate() return { refresh, syncGate, enter() { syncGate() refresh() }, } } function formatAge(ms) { if (!Number.isFinite(ms) || ms < 0) return '—' const s = Math.floor(ms / 1000) if (s < 60) return `${s}s` const m = Math.floor(s / 60) if (m < 60) return `${m}m` const h = Math.floor(m / 60) if (h < 48) return `${h}h` const d = Math.floor(h / 24) return `${d}d` }