Files
RustProxy/src/admin/web/transform-panel.js
T
2026-10-10 20:56:36 +02:00

184 lines
11 KiB
JavaScript

"use strict";
(() => {
let loading = false, generation = 0, lastData = null;
let liveChartScope = "", liveChartPrevious = null, liveChartPreviousAt = 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;
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)
}, rates = { applied: 0, cache_hits: 0, skipped: 0, errors: 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();
$("transform-live-applied-rate").textContent = `${rates.applied.toFixed(1)}/s`;
$("transform-live-cache-rate").textContent = `${rates.cache_hits.toFixed(1)}/s`;
$("transform-live-skipped-rate").textContent = `${rates.skipped.toFixed(1)}/s`;
$("transform-live-error-rate").textContent = `${rates.errors.toFixed(1)}/s`;
$("transform-live-chart-value").textContent = `Σ ${(rates.applied + rates.cache_hits + rates.skipped + rates.errors).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 = "";
}
};
})();