384 lines
12 KiB
JavaScript
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`
|
|
}
|