"use strict"; (() => { let loading = false, offset = 0, generation = 0; let liveChartScope = "", liveChartPrevious = null, liveChartPreviousAt = 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; for (const values of Object.values(liveChartSamples)) values.length = 0; 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 = Math.max(1, ...liveChartSamples.hits, ...liveChartSamples.misses, ...liveChartSamples.bypasses); $("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) }, rates = { hits: 0, misses: 0, bypasses: 0 }; if (liveChartPrevious && liveChartPreviousAt != null) { const elapsed = Math.max(.001, (now - liveChartPreviousAt) / 1000); for (const key of Object.keys(rates)) if (current[key] >= liveChartPrevious[key]) rates[key] = (current[key] - liveChartPrevious[key]) / elapsed; } liveChartPrevious = current; liveChartPreviousAt = now; for (const key of Object.keys(rates)) { liveChartSamples[key].push(rates[key]); liveChartSamples[key] = liveChartSamples[key].slice(-liveChartLimit); } redrawLiveChart(); $("cache-live-hit-rate").textContent = `${rates.hits.toFixed(1)}/s`; $("cache-live-miss-rate").textContent = `${rates.misses.toFixed(1)}/s`; $("cache-live-bypass-rate").textContent = `${rates.bypasses.toFixed(1)}/s`; $("cache-live-chart-value").textContent = `Σ ${(rates.hits + rates.misses + rates.bypasses).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.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(); })();