Files
peardata/ui/data-manager.js
T
Raven Scott 4b8d194b21
CI / test (push) Successful in 1m5s
Release rolling / release (push) Successful in 7m6s
Default to 1 year retension
2026-07-19 12:56:44 -04:00

384 lines
12 KiB
JavaScript

/**
* 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<any>, 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 || '1y')
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()
els.lastPrune.textContent = `Last prune: ${when} · deleted ${config.lastPruneDeleted || 0} pts (~${formatBytes(config.lastPruneBytesFreed || 0)})`
} 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 || '1y'
const diskId = els.diskPreset?.value || 'unlimited'
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'
setStatus(`${verb} ${res.deleted ?? 0} points (~${formatBytes(res.approxBytesFreed || 0)})`)
log(`Prune${dryRun ? ' dry-run' : ''}: ${res.deleted ?? 0} deleted`)
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`
}