Files
peardata/ui/data-manager.js
T
Raven Scott c541c27ad8
CI / test (push) Successful in 1m17s
Release rolling / release (push) Successful in 8m15s
Prune work
2026-07-30 12:38:17 -04:00

398 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 || '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`
}