398 lines
13 KiB
JavaScript
398 lines
13 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 || '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`
|
||
}
|