"use strict"; (() => { let loading = false, generation = 0, lastData = null; let liveChartScope = "", liveChartPrevious = null, liveChartPreviousAt = null, liveChartGeneration = null; const liveChartSamples = { applied: [], cache_hits: [], skipped: [], errors: [] }; const LIVE_CHART_LIMITS = new Set([60, 120, 300, 600]); const LIVE_CHART_STORAGE_KEY = "rustproxy.transform.liveChartSamples"; let liveChartLimit = 60; const scope = () => $("transform-scope")?.value || "*"; const number = value => fmt(Number(value) || 0); const percent = counters => { const original = Number(counters?.original_bytes) || 0; const saved = Number(counters?.saved_bytes) || 0; return original > 0 ? `${fmt(saved / original * 100, 1)}%` : "0.0%"; }; const averageMs = counters => { const examined = Number(counters?.examined) || 0; const duration = Number(counters?.duration_us) || 0; return examined ? `${fmt(duration / examined / 1000, 2)} ms` : "0.00 ms"; }; const emptyCounters = () => ({ examined: 0, applied: 0, skipped: 0, errors: 0, cache_hits: 0, original_bytes: 0, transformed_bytes: 0, saved_bytes: 0, duration_us: 0, by_kind: {}, reasons: {} }); function resetLiveChart(selected = scope()) { liveChartScope = selected; liveChartPrevious = null; liveChartPreviousAt = null; liveChartGeneration = null; for (const values of Object.values(liveChartSamples)) values.length = 0; for (const id of ["transform-live-applied-line", "transform-live-cache-line", "transform-live-skipped-line", "transform-live-error-line"]) $(id)?.setAttribute("points", "0,170 900,170"); for (const id of ["transform-live-applied-rate", "transform-live-cache-rate", "transform-live-skipped-rate", "transform-live-error-rate"]) if ($(id)) $(id).textContent = "0.0/s"; if ($("transform-live-chart-value")) $("transform-live-chart-value").textContent = "-"; } function redrawLiveChart() { const peak = Math.max(1, ...liveChartSamples.applied, ...liveChartSamples.cache_hits, ...liveChartSamples.skipped, ...liveChartSamples.errors); $("transform-live-applied-line")?.setAttribute("points", sparklinePoints(liveChartSamples.applied, 900, 180, 10, peak)); $("transform-live-cache-line")?.setAttribute("points", sparklinePoints(liveChartSamples.cache_hits, 900, 180, 10, peak)); $("transform-live-skipped-line")?.setAttribute("points", sparklinePoints(liveChartSamples.skipped, 900, 180, 10, peak)); $("transform-live-error-line")?.setAttribute("points", sparklinePoints(liveChartSamples.errors, 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 ($("transform-live-samples")) $("transform-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 = { applied: Math.max(0, Number(counters.applied) || 0), cache_hits: Math.max(0, Number(counters.cache_hits) || 0), skipped: Math.max(0, Number(counters.skipped) || 0), errors: Math.max(0, Number(counters.errors) || 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, 3); 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` : "-"; $("transform-live-applied-rate").textContent = rate("applied"); $("transform-live-cache-rate").textContent = rate("cache_hits"); $("transform-live-skipped-rate").textContent = rate("skipped"); $("transform-live-error-rate").textContent = rate("errors"); $("transform-live-chart-value").textContent = sample.deltas ? `Σ ${(sample.deltas.reduce((a, b) => a + b, 0) / sample.seconds).toFixed(1)}/s` : "-"; } function countersFor(data) { if (scope() === "*") return data?.metrics?.total || emptyCounters(); return data?.metrics?.scopes?.find(row => row.vhost === scope()) || emptyCounters(); } function configure(data) { const selected = scope(), labels = new Map(); for (const vhost of data?.vhosts || []) { const hosts = Array.isArray(vhost.hosts) ? vhost.hosts.join(", ") : ""; labels.set(vhost.id, hosts ? `${vhost.id} / ${hosts}` : vhost.id); } for (const row of data?.metrics?.scopes || []) if (!labels.has(row.vhost)) labels.set(row.vhost, row.vhost); const items = [el("option", t("logAllVhosts"))]; items[0].value = "*"; for (const [id, label] of [...labels].sort((a, b) => a[0].localeCompare(b[0]))) { const option = el("option", label); option.value = id; items.push(option); } $("transform-scope").replaceChildren(...items); $("transform-scope").value = items.some(item => item.value === selected) ? selected : "*"; } function renderTypes(counters) { const kinds = [ ["javascript", "JavaScript"], ["css", "CSS"], ["html", "HTML"], ["svg", "SVG"], ["json", "JSON"] ]; $("transform-type-rows").replaceChildren(...kinds.map(([key, label]) => { const tr = el("tr"); tr.append(el("td", label), el("td", number(counters?.by_kind?.[key] || 0))); return tr; })); } function renderReasons(counters) { const entries = Object.entries(counters?.reasons || {}).sort((a, b) => b[1] - a[1]); const target = $("transform-reason-list"); target.replaceChildren(...entries.map(([key, count]) => { const tag = el("span", `${key}: ${number(count)}`, "cache-reason"); tag.title = key; return tag; })); $("transform-reasons-empty").hidden = entries.length > 0; $("tp-skip-top").textContent = entries.length ? entries.slice(0, 2).map(([key, count]) => `${key}: ${number(count)}`).join(" · ") : t("transformNoSkipsShort"); } function renderVhosts(data) { const wanted = scope(); const rows = (data?.metrics?.scopes || []).filter(row => wanted === "*" || row.vhost === wanted); $("transform-vhost-rows").replaceChildren(...rows.map(row => { const tr = el("tr"); for (const value of [ row.vhost, number(row.applied), number(row.cache_hits), number(row.skipped), number(row.errors), fmtBytes(row.saved_bytes || 0), percent(row), averageMs(row) ]) tr.append(el("td", value)); return tr; })); $("transform-vhosts-empty").hidden = rows.length > 0; } function render(data) { lastData = data; configure(data); const counters = countersFor(data); renderLiveChart(counters); $("tp-applied").textContent = number(counters.applied); $("tp-examined").textContent = `${t("transformExamined")}: ${number(counters.examined)}`; $("tp-cache-hits").textContent = number(counters.cache_hits); $("tp-saved").textContent = fmtBytes(counters.saved_bytes || 0); $("tp-byte-flow").textContent = `${fmtBytes(counters.original_bytes || 0)} → ${fmtBytes(counters.transformed_bytes || 0)}`; $("tp-reduction").textContent = percent(counters); $("tp-skipped").textContent = number(counters.skipped); $("tp-errors").textContent = number(counters.errors); $("tp-duration").textContent = `${t("transformAvgTime")}: ${averageMs(counters)}`; const resources = data?.resources || {}; const reserved = Number(resources.memory_reserved_bytes), budget = Number(resources.memory_budget_bytes); const inflight = Number(resources.inflight), maxInflight = Number(resources.max_inflight); $("tp-memory").textContent = Number.isFinite(reserved) ? fmtBytes(reserved) : "-"; $("tp-memory-detail").textContent = Number.isFinite(budget) ? `${t("transformBudgetShort")}: ${fmtBytes(budget)}${budget > 0 && Number.isFinite(reserved) ? ` · ${fmt(reserved / budget * 100, 1)}%` : ""}` : "-"; $("tp-inflight").textContent = Number.isFinite(inflight) ? number(inflight) : "-"; $("tp-inflight-detail").textContent = Number.isFinite(maxInflight) ? `${t("cacheLimitShort")}: ${number(maxInflight)}` : "-"; renderTypes(counters); renderReasons(counters); renderVhosts(data); $("transform-panel-state").textContent = `${t("cacheUpdated")}: ${formatTime(new Date())}`; } async function loadTransform({ signal, isCurrent }) { if (!authenticated || currentPanel !== "transform" || loading) return; loading = true; const epoch = authEpoch, token = generation; try { const data = await api("/api/transform", { signal }); if (isCurrent() && token === generation) render(data); } catch (error) { if (error?.name !== "AbortError" && epoch === authEpoch && isCurrent()) { $("transform-panel-state").textContent = error.message; if (error.status === 401) lock(); } } finally { loading = false; } } function refresh() { return rustProxyRefresh.manual("transform"); } initializeLiveChartLimit(); $("transform-live-samples")?.addEventListener("change", event => setLiveChartLimit(event.currentTarget.value)); $("transform-scope")?.addEventListener("change", () => { resetLiveChart(); if (lastData) render(lastData); }); $("transform-refresh")?.addEventListener("click", () => { generation++; void refresh(); }); $("transform-panel-settings")?.addEventListener("click", () => { setPanel("settings"); window.rustProxySettings?.select("transform"); $("transform-form")?.scrollIntoView({ block: "start" }); }); rustProxyRefresh.register("transform", { interval: () => 3, active: () => currentPanel === "transform" && !!$("transform-live")?.checked, run: loadTransform }); $("transform-live")?.addEventListener("change", () => rustProxyRefresh.sync()); window.rustProxyTransformPanel = { refresh, reset() { generation++; lastData = null; resetLiveChart(); $("transform-type-rows")?.replaceChildren(); $("transform-vhost-rows")?.replaceChildren(); $("transform-reason-list")?.replaceChildren(); if ($("transform-panel-state")) $("transform-panel-state").textContent = ""; } }; })();