Files
RustProxy/src/admin/web/cache-panel.js
T
2026-10-17 22:33:00 +02:00

351 lines
24 KiB
JavaScript

"use strict";
(() => {
let loading = false, offset = 0, generation = 0;
let liveChartScope = "", liveChartPrevious = null, liveChartPreviousAt = null, liveChartGeneration = null;
const liveChartSamples = { hits: [], misses: [], bypasses: [] };
const LIVE_CHART_LIMITS = new Set([60, 120, 300, 600]);
const LIVE_CHART_STORAGE_KEY = "rustproxy.cache.liveChartSamples";
let liveChartLimit = 60;
const scope = () => $("cache-scope").value;
const domainFilter = () => $("cache-domain-filter").value;
const number = v => fmt(v ?? 0);
const occupancy = (used, limit) => {
const boundedLimit = Number(limit) || 0;
if (boundedLimit <= 0) return 0;
return Math.min(100, Math.max(0, (Number(used) || 0) / boundedLimit * 100));
};
const setMeter = (id, used, limit) => {
const value = occupancy(used, limit), meter = $(id);
if (meter) {
meter.value = value;
meter.setAttribute("aria-valuetext", `${value.toFixed(1)}%`);
}
return value;
};
const operationMessage = (id, value = "", error = false) => {
const box = $(id);
if (!box) return;
box.textContent = value;
box.classList.toggle("error", Boolean(value) && error);
};
async function copyText(value) {
if (navigator.clipboard?.writeText) {
try { await navigator.clipboard.writeText(value); return; } catch { }
}
const area = document.createElement("textarea");
area.value = value;
area.setAttribute("readonly", "");
area.style.position = "fixed";
area.style.opacity = "0";
document.body.append(area);
area.select();
document.execCommand("copy");
area.remove();
}
function makeCopyCell(value) {
const td = el("td", value, "cache-copy-cell");
td.title = value;
td.tabIndex = 0;
td.setAttribute("role", "button");
const copy = () => void copyText(value);
td.addEventListener("click", copy);
td.addEventListener("keydown", event => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
copy();
});
return td;
}
const cacheEnabledFor = (data, row) => {
const scoped = (data.vhosts || []).find(vhost => vhost.id === row.vhost);
return scoped?.settings?.enabled ?? data.settings?.enabled ?? false;
};
const visibleDomains = (data, groups) => {
const filter = domainFilter();
if (filter === "all") return groups;
const wanted = filter === "enabled";
return groups.filter(row => cacheEnabledFor(data, row) === wanted);
};
const reason = value => { const key = `cacheReason_${value}`, text = t(key); return text === key ? value : text; };
function resetLiveChart(selected = scope()) {
liveChartScope = selected; liveChartPrevious = null; liveChartPreviousAt = null; liveChartGeneration = null;
for (const values of Object.values(liveChartSamples)) values.length = 0;
window.rustProxyChartScale.reset("cache-live");
for (const id of ["cache-live-hit-line", "cache-live-miss-line", "cache-live-bypass-line"]) $(id)?.setAttribute("points", "0,170 900,170");
for (const id of ["cache-live-hit-rate", "cache-live-miss-rate", "cache-live-bypass-rate"]) if ($(id)) $(id).textContent = "0.0/s";
if ($("cache-live-chart-value")) $("cache-live-chart-value").textContent = "-";
}
function redrawLiveChart() {
const peak = window.rustProxyChartScale.peak("cache-live", "rate", Object.values(liveChartSamples));
$("cache-live-hit-line")?.setAttribute("points", sparklinePoints(liveChartSamples.hits, 900, 180, 10, peak));
$("cache-live-miss-line")?.setAttribute("points", sparklinePoints(liveChartSamples.misses, 900, 180, 10, peak));
$("cache-live-bypass-line")?.setAttribute("points", sparklinePoints(liveChartSamples.bypasses, 900, 180, 10, peak));
}
function setLiveChartLimit(value, persist = true) {
const parsed = Number(value);
liveChartLimit = LIVE_CHART_LIMITS.has(parsed) ? parsed : 60;
for (const key of Object.keys(liveChartSamples)) liveChartSamples[key] = liveChartSamples[key].slice(-liveChartLimit);
if ($("cache-live-samples")) $("cache-live-samples").value = String(liveChartLimit);
if (persist) { try { localStorage.setItem(LIVE_CHART_STORAGE_KEY, String(liveChartLimit)); } catch { } }
redrawLiveChart();
}
function initializeLiveChartLimit() {
let saved = 60;
try { saved = Number(localStorage.getItem(LIVE_CHART_STORAGE_KEY) || 60); } catch { }
setLiveChartLimit(saved, false);
}
function renderLiveChart(counters) {
const selected = scope();
if (liveChartScope !== selected) resetLiveChart(selected);
const now = performance.now(), current = {
hits: Math.max(0, Number(counters.hits) || 0),
misses: Math.max(0, Number(counters.misses) || 0),
bypasses: Math.max(0, Number(counters.bypasses) || 0)
};
const keys = Object.keys(current);
const previous = liveChartPrevious && liveChartPreviousAt != null
? { values: keys.map(key => liveChartPrevious[key]), at: liveChartPreviousAt, generation: liveChartGeneration } : null;
const sample = rustProxyRateSampling.difference(previous, keys.map(key => current[key]), now, rustProxyRateSampling.cadence("cache_seconds"));
liveChartPrevious = current; liveChartPreviousAt = now; liveChartGeneration = sample.next.generation;
if (sample.deltas) {
keys.forEach((key, i) => { liveChartSamples[key].push(sample.deltas[i] / sample.seconds); liveChartSamples[key] = liveChartSamples[key].slice(-liveChartLimit); });
}
redrawLiveChart();
const rate = key => sample.deltas ? `${(sample.deltas[keys.indexOf(key)] / sample.seconds).toFixed(1)}/s` : "-";
$("cache-live-hit-rate").textContent = rate("hits");
$("cache-live-miss-rate").textContent = rate("misses");
$("cache-live-bypass-rate").textContent = rate("bypasses");
$("cache-live-chart-value").textContent = sample.deltas ? `Σ ${(sample.deltas.reduce((a, b) => a + b, 0) / sample.seconds).toFixed(1)}/s` : "-";
}
function configure() {
const selected = scope(); const items = [el("option", t("logAllVhosts"))]; items[0].value = "*";
for (const host of envelope?.config.vhosts || []) { const item = el("option", `${host.id} / ${host.frontend.hosts.join(", ")}`); item.value = host.id; items.push(item); }
$("cache-scope").replaceChildren(...items); $("cache-scope").value = items.some(e => e.value === selected) ? selected : "*";
}
function render(data) {
const m = data.metrics || {}, d = data.dashboard || { domains: [], objects: [], matching_objects: 0 }, groups = d.domains || [], shownGroups = visibleDomains(data, groups);
const total = field => groups.reduce((n, row) => n + (row[field] || 0), 0), all = scope() === "*";
const hits = total("hits"), misses = total("misses");
const hotEntries = all ? (m.entries || 0) : total("hot_entries"), coldEntries = all ? (m.cold_entries || 0) : total("cold_entries");
const hotEntryLimit = Number(data.settings?.max_entries) || 0;
const hotEntryPercent = setMeter("cp-hot-entries-meter", hotEntries, hotEntryLimit);
$("cp-entries").textContent = number(hotEntries);
$("cp-capacity").textContent = `${t("cacheGlobalLimit")}: ${number(hotEntryLimit)} · ${hotEntryPercent.toFixed(1)}%`;
const resident = all ? (m.hot_allocated_bytes ?? m.resident_bytes ?? 0) : total("resident_bytes");
const logical = all ? (m.hot_logical_bytes ?? 0) : total("logical_bytes");
const hotLimit = m.hot_limit_bytes ?? data.settings?.max_bytes ?? 0;
const hotPercent = setMeter("cp-memory", resident, hotLimit);
$("cp-bytes").textContent = fmtBytes(resident);
$("cp-memory-detail").textContent = `${t("cacheLogicalShort")}: ${fmtBytes(logical)} · ${t("cacheLimitShort")}: ${fmtBytes(hotLimit)} · ${hotPercent.toFixed(1)}%`;
$("cp-transient").textContent = `${fmtBytes(m.transient_bytes)} / ${fmtBytes(m.transient_limit_bytes ?? data.settings?.transient_max_bytes)}`;
$("cp-transient-detail").textContent = `${t("cacheFillShort")}: ${fmtBytes(m.fill_bytes_active)} · ${t("cacheQueueShort")}: ${fmtBytes(m.cold_queue_bytes)} / ${fmtBytes(m.cold_queue_limit_bytes ?? data.settings?.cold_queue_max_bytes)} · ${t("cacheIoShort")}: ${fmtBytes((Number(m.cold_write_bytes_active) || 0) + (Number(m.cold_read_bytes_active) || 0))}`;
const coldBytes = all ? (m.cold_bytes || 0) : total("cold_bytes");
const coldEntryLimit = Number(data.settings?.cold_max_entries) || 0;
const coldEntryPercent = setMeter("cp-cold-entries-meter", coldEntries, coldEntryLimit);
$("cp-cold-entries").textContent = number(coldEntries);
$("cp-cold-capacity").textContent = `${t("cacheGlobalLimit")}: ${number(coldEntryLimit)} · ${coldEntryPercent.toFixed(1)}%`;
const coldLimit = Number(data.settings?.cold_max_bytes) || 0;
const coldPercent = setMeter("cp-cold-disk", coldBytes, coldLimit);
$("cp-cold-bytes").textContent = fmtBytes(coldBytes);
$("cp-cold-disk-detail").textContent = `${t("cacheLimitShort")}: ${fmtBytes(coldLimit)} · ${coldPercent.toFixed(1)}%`;
$("cp-ratio").textContent = `${fmt((all ? (m.hit_ratio || 0) : (hits + misses ? hits / (hits + misses) : 0)) * 100, 1)}%`;
$("cp-requests").textContent = `${number(all ? m.hits : hits)} HIT / ${number(all ? m.misses : misses)} MISS`;
const bypasses = all ? (Number(m.bypasses) || 0) : total("bypasses");
$("cp-bypass").textContent = number(bypasses);
renderLiveChart({ hits: all ? (Number(m.hits) || 0) : hits, misses: all ? (Number(m.misses) || 0) : misses, bypasses });
$("cp-fills").textContent = `${t("cacheFills")}: ${number(m.fills_active)} · ${t("cacheCollapseWaiters")}: ${number(m.collapse_waiters)} · ${t("cacheCollapsed")}: ${number(m.collapsed_fills)}`;
$("cp-saved").textContent = fmtBytes(total("saved_body_bytes"));
$("cp-tier-moves").textContent = `${number(m.hot_demotions)} ↓ / ${number(m.cold_promotions)} ↑`;
$("cp-tier-errors").textContent = `${number(m.cold_hits)} ${t("cacheColdHits")} · ${number(m.cold_errors)} ${t("errors")} · ${number(m.cold_queue_dropped)} ${t("loggingDropped")}`;
$("cp-expired").textContent = `${number(m.evictions)} / ${number(m.expired)}`;
$("cp-rejected").textContent = `${t("cacheRejected")}: ${number(m.rejected_fills)} · ${t("cacheMemoryPressureShort")}: ${number(m.memory_pressure_rejections)} · ${t("cacheNukeLimitShort")}: ${number(m.hot_eviction_limited)}`;
$("cache-domain-rows").replaceChildren(...shownGroups.map(row => {
const tr = el("tr"); for (const value of [row.vhost || t("cacheLegacy"), row.domain, number(row.hot_entries), number(row.cold_entries), fmtBytes(row.resident_bytes), fmtBytes(row.cold_bytes), `${number(row.hits)} / ${number(row.misses)} / ${number(row.bypasses)}`, `${fmt((row.hit_ratio || 0) * 100, 1)}%`]) tr.append(el("td", value));
const td = el("td", undefined, "cache-reasons"); for (const [key, count] of Object.entries(row.reasons || {}).sort((a, b) => b[1] - a[1])) { const tag = el("span", `${reason(key)}: ${number(count)}`, "cache-reason"); tag.title = key; td.append(tag); } tr.append(td); return tr;
}));
const domainEmpty = $("cache-domains-empty");
domainEmpty.textContent = groups.length ? t("cacheNoDomainsFiltered") : t("cacheNoDomains");
domainEmpty.hidden = !!shownGroups.length;
$("cache-object-rows").replaceChildren(...(d.objects || []).map(row => {
const tr = el("tr"), resident = Number(row.resident_bytes) || 0, disk = Number(row.storage_bytes) || 0, body = Number(row.body_bytes) || 0, storage = resident || disk;
tr.append(el("td", row.vhost || t("cacheLegacy")), el("td", row.domain));
const targetValue = row.target || row.path || "-", target = makeCopyCell(targetValue);
if (row.transformed) target.append(el("span", t("transformBadge"), "cache-transform-badge"));
tr.append(target);
const keyTags = `${row.cache_key || "-"}${row.tags?.length ? ` · ${row.tags.join(", ")}` : ""}`;
tr.append(makeCopyCell(keyTags));
for (const value of [row.tier === "cold" ? t("cacheCold") : t("cacheHot"), fmtBytes(body), fmtBytes(storage), fmtBytes(Math.max(0, storage - body)), number(row.hits), `${number(row.age_seconds)} s`, `${number(row.resident_age_seconds)} s`, `${number(row.remaining_ttl_seconds)} s`]) tr.append(el("td", value));
const action = el("td"), button = el("button", t("cachePurgeUrl"), "quiet"); button.type = "button"; button.addEventListener("click", () => void purgeObject(row)); action.append(button); tr.append(action); return tr;
}));
const bans = data.bans || [];
$("cache-ban-rows").replaceChildren(...bans.map(rule => {
const tr = el("tr");
for (const value of [String(rule.id), rule.selector, rule.value, rule.host || "*", rule.vhost || "*", formatDateTime(rule.expires_at_ms)]) tr.append(el("td", value));
const action = el("td"), button = el("button", t("cacheRemoveBan"), "quiet"); button.type = "button"; button.addEventListener("click", () => void removeBan(rule.id)); action.append(button); tr.append(action); return tr;
}));
$("cache-bans-empty").hidden = !!bans.length;
const bodyBytes = Number(d.matching_body_bytes) || 0, residentBytes = Number(d.matching_resident_bytes) || 0, diskBytes = Number(d.matching_cold_bytes) || 0;
$("cache-object-summary").textContent = `${number(d.matching_objects)} ${t("cacheObjectCount")} · ${t("cacheBodySize")}: ${fmtBytes(bodyBytes)} · ${t("cacheHotMemory")}: ${fmtBytes(residentBytes)} · ${t("cacheColdDisk")}: ${fmtBytes(diskBytes)}`;
$("cache-objects-empty").hidden = !!d.objects?.length;
$("cache-page").textContent = `${d.matching_objects ? offset + 1 : 0}-${Math.min(offset + (d.objects?.length || 0), d.matching_objects || 0)} / ${number(d.matching_objects)}`;
$("cache-prev").disabled = offset === 0; $("cache-next").disabled = offset + 50 >= (d.matching_objects || 0);
$("cache-panel-state").textContent = `${t("cacheUpdated")}: ${formatTime(new Date())}`;
$("cache-panel-cleanup").disabled = !data.settings?.cold_enabled;
}
async function runInvalidation(payload, messageTarget = "cache-invalidation-status") {
const result = await api("/api/cache/invalidate", { method: "POST", body: JSON.stringify(payload) });
const detail = result.result || {};
const message = detail.operation === "ban"
? `${t("cacheBanCreated")}: ${detail.ban_id}`
: `${t("cachePurged")}: ${number(detail.affected_entries)}`;
operationMessage(messageTarget, message);
offset = 0; generation++; await refresh();
}
async function purgeObject(row) {
if (!authenticated || !(await appConfirm(t("cacheConfirmPurgeUrl"), { danger: true }))) return;
operationMessage("cache-toolbar-operation-status");
try {
await runInvalidation(
{ operation: "purge", selector: "exact", value: row.target || row.path, host: row.authority || row.domain, vhost: row.vhost || null, ttl_seconds: null, confirm_wide: false },
"cache-toolbar-operation-status"
);
} catch (error) { operationMessage("cache-toolbar-operation-status", error.message, true); }
}
async function removeBan(id) {
if (!authenticated || !(await appConfirm(t("cacheConfirmRemoveBan"), { danger: true }))) return;
operationMessage("cache-bans-status");
try {
await api(`/api/cache/bans/${encodeURIComponent(id)}`, { method: "DELETE" });
operationMessage("cache-bans-status", t("cacheBanRemoved"));
generation++; await refresh();
} catch (error) { operationMessage("cache-bans-status", error.message, true); }
}
async function loadCache({ signal, isCurrent }) {
if (!authenticated || !envelope || currentPanel !== "cache" || loading) return;
loading = true;
const epoch = authEpoch, token = generation;
const query = new URLSearchParams({ offset: String(offset), limit: "50" });
if (scope() !== "*") query.set("vhost", scope());
try {
const data = await api(`/api/cache?${query}`, { signal });
if (isCurrent() && token === generation) render(data);
} catch (error) {
if (error?.name !== "AbortError" && epoch === authEpoch && isCurrent()) {
$("cache-panel-state").textContent = error.message;
if (error.status === 401) lock();
}
} finally { loading = false; }
}
function refresh() { return rustProxyRefresh.manual("cache"); }
initializeLiveChartLimit();
$("cache-live-samples")?.addEventListener("change", event => setLiveChartLimit(event.currentTarget.value));
$("cache-scope").addEventListener("change", () => { offset = 0; resetLiveChart(); generation++; void refresh(); });
$("cache-domain-filter").addEventListener("change", () => { generation++; void refresh(); });
$("cache-refresh").addEventListener("click", () => void refresh());
$("cache-prev").addEventListener("click", () => { offset = Math.max(0, offset - 50); generation++; void refresh(); });
$("cache-next").addEventListener("click", () => { offset += 50; generation++; void refresh(); });
$("cache-panel-settings").addEventListener("click", () => { setPanel("settings"); window.rustProxySettings?.select("cache"); $("cache-form").scrollIntoView({ block: "start" }); });
const cleanupDialog = $("cache-cleanup-dialog");
const closeCleanup = () => { if (cleanupDialog.open) cleanupDialog.close(); };
$("cache-panel-cleanup").addEventListener("click", () => {
$("cache-cleanup-status").textContent = "";
cleanupDialog.showModal();
$("cache-cleanup-unused").focus();
});
$("cache-cleanup-close").addEventListener("click", closeCleanup);
$("cache-cleanup-cancel").addEventListener("click", closeCleanup);
$("cache-cleanup-form").addEventListener("submit", async event => {
event.preventDefault();
if (!authenticated) return;
const form = event.currentTarget, submit = event.submitter, epoch = authEpoch;
const unusedValue = $("cache-cleanup-unused").value.trim(), oldestValue = $("cache-cleanup-oldest").value.trim();
const payload = {
unused_for_seconds: unusedValue ? Number(unusedValue) : null,
oldest: oldestValue ? Number(oldestValue) : null,
hits_zero: $("cache-cleanup-hits-zero").checked
};
submit.disabled = true;
try {
const started = await api("/api/cache/cold/cleanup", { method: "POST", body: JSON.stringify(payload) });
const id = started.job?.id;
if (!id) throw new Error(t("cacheColdCleanupInvalidJob"));
$("cache-cleanup-status").textContent = t("cacheColdCleanupRunning");
for (; ;) {
await new Promise(resolve => setTimeout(resolve, 300));
if (epoch !== authEpoch || !authenticated) return;
const current = await api(`/api/cache/cold/cleanup/${encodeURIComponent(id)}`);
const job = current.job || {};
if (job.status === "done") {
const message = t("cacheColdCleanupDone")
.replace("{count}", number(job.removed_entries))
.replace("{bytes}", fmtBytes(job.freed_bytes));
$("cache-cleanup-status").textContent = message; toast(message);
generation++; await refresh();
break;
}
if (job.status === "failed") throw new Error(job.error || t("cacheColdCleanupFailed"));
}
} catch (error) {
if (epoch === authEpoch) { $("cache-cleanup-status").textContent = error.message; toast(error.message, true); }
} finally { submit.disabled = false; }
});
const invalidationDialog = $("cache-invalidation-dialog"), bansDialog = $("cache-bans-dialog");
const closeInvalidation = () => { if (invalidationDialog.open) invalidationDialog.close(); };
const closeBans = () => { if (bansDialog.open) bansDialog.close(); };
$("cache-invalidation-open").addEventListener("click", () => {
operationMessage("cache-invalidation-status");
$("cache-invalidation-operation").value = "purge";
$("cache-ban-ttl").disabled = true;
if (!invalidationDialog.open) invalidationDialog.showModal();
$("cache-invalidation-value").focus();
});
$("cache-bans-open").addEventListener("click", () => {
operationMessage("cache-bans-status");
if (!bansDialog.open) bansDialog.showModal();
generation++; void refresh();
});
$("cache-invalidation-close").addEventListener("click", closeInvalidation);
$("cache-bans-close").addEventListener("click", closeBans);
$("cache-invalidation-operation").addEventListener("change", () => {
operationMessage("cache-invalidation-status");
$("cache-ban-ttl").disabled = $("cache-invalidation-operation").value !== "ban";
});
$("cache-invalidation-form").addEventListener("submit", async event => {
event.preventDefault(); if (!authenticated) return;
const operation = $("cache-invalidation-operation").value, selector = $("cache-invalidation-selector").value, value = $("cache-invalidation-value").value.trim(), host = $("cache-invalidation-host").value.trim();
if (!value) return;
const selected = scope(), wide = selector === "regex" || (selector === "prefix" && value === "/");
if (wide && !(await appConfirm(t("cacheWideInvalidationConfirm"), { danger: true }))) return;
const submit = event.submitter; submit.disabled = true; operationMessage("cache-invalidation-status");
try {
await runInvalidation({ operation, selector, value, host: host || null, vhost: selected === "*" ? null : selected, ttl_seconds: operation === "ban" ? Number($("cache-ban-ttl").value || 3600) : null, confirm_wide: wide });
} catch (error) { operationMessage("cache-invalidation-status", error.message, true); } finally { submit.disabled = false; }
});
$("cache-panel-purge").addEventListener("click", async () => {
if (!authenticated || !(await appConfirm(t("confirmPurge"), { danger: true }))) return; const button = $("cache-panel-purge"), epoch = authEpoch, selected = scope(); button.disabled = true;
operationMessage("cache-toolbar-operation-status");
try {
const path = selected === "*" ? "/api/cache/purge" : `/api/cache/purge?vhost=${encodeURIComponent(selected)}`;
const result = await api(path, { method: "POST", body: "{}" });
if (epoch !== authEpoch) return;
operationMessage("cache-toolbar-operation-status", `${t("cachePurged")}: ${result.purged_entries}`);
offset = 0; generation++; await refresh();
} catch (error) { if (epoch === authEpoch) operationMessage("cache-toolbar-operation-status", error.message, true); } finally { button.disabled = false; }
});
rustProxyRefresh.register("cache", {
interval: () => rustProxyRefresh.current().cache_seconds,
active: () => currentPanel === "cache" && !!$("cache-live")?.checked,
run: loadCache
});
$("cache-live")?.addEventListener("change", () => rustProxyRefresh.sync());
window.rustProxyCachePanel = {
refresh, configure, reason, reset() {
generation++; offset = 0; resetLiveChart(); closeInvalidation(); closeBans();
$("cache-domain-rows").replaceChildren(); $("cache-object-rows").replaceChildren(); $("cache-ban-rows").replaceChildren();
$("cache-panel-state").textContent = ""; operationMessage("cache-invalidation-status"); operationMessage("cache-bans-status"); operationMessage("cache-toolbar-operation-status");
}
};
if (envelope) configure();
})();