This commit is contained in:
Mateusz Gruszczyński committed 2026-10-11 17:10:04 +02:00
1 parent d942c1a35d
commit 75104f87ba
103 files changed
+1344 -370

No files matched your search

+5 -1
View File
@@ -116,7 +116,7 @@ function appConfirm(message, options = {}) {
}
function apiArea(path) {
const section = path.match(/^\/api\/config\/([^/?]+)/)?.[1];
if (section) return ({ "listeners": "settings", "http2": "settings", "docker-agents": "dockerVhosts", "response": "settings", "pages": "settings", "cache": "cache", "transform": "settings", "logging": "settings", "geoip": "settings", "forwarding": "settings", "headers": "settings", "prometheus": "settings", "administration": "administration", "tls": "tls", "vhosts": "vhosts", "redirects": "redirects", "not_found_hosts": "notfound", "streams": "streams", "acl-profiles": "aclProfiles", "host-policies": "security" })[section] || "configuration";
if (section) return ({ "listeners": "settings", "http2": "settings", "docker-agents": "dockerVhosts", "response": "settings", "pages": "settings", "cache": "cache", "transform": "settings", "logging": "settings", "geoip": "settings", "forwarding": "settings", "headers": "settings", "prometheus": "settings", "administration": "administration", "tls": "tls", "vhosts": "vhosts", "redirects": "redirects", "not_found_hosts": "notfound", "streams": "streams", "acl-profiles": "aclProfiles", "host-policies": "security", "user-agent-blocking": "security" })[section] || "configuration";
if (path === "/api/config") return "configuration";
if (path.startsWith("/api/traffic")) return "traffic";
if (path.startsWith("/api/cache")) return "cache";
@@ -345,6 +345,9 @@ function renderConfig() {
$("host-policy-list").replaceChildren(...(config.host_policies || []).map((policy, index) => { const box = el("div", undefined, "entity"); box.append(el("span", policy.hosts.join(", "), "entity-title"), el("p", `${t("aclDefault")}: ${t(policy.acl.default || "deny")}\n${rulesText(policy.acl)}`, "entity-details")); const buttons = el("div", undefined, "entity-actions"); buttons.append(action(t("edit"), () => { $("policy-hosts").value = policy.hosts.join(", "); $("policy-default").value = policy.acl.default || "deny"; $("policy-rules").value = rulesText(policy.acl); const editor = $("security-policy-editor"); if (editor) { editor.open = true; editor.scrollIntoView({ block: "nearest" }); } }), action(t("remove"), async () => { if (await appConfirm(t("confirmDelete"), { danger: true })) { const next = structuredClone(config); next.host_policies.splice(index, 1); await saveSection("host-policies", next.host_policies); } })); box.append(buttons); return box; }));
if (!config.host_policies?.length) $("host-policy-list").append(el("p", t("noPolicies"), "empty"));
if ($("security-policy-count")) $("security-policy-count").textContent = String((config.host_policies || []).length);
const uaPolicy = config.user_agent_blocking || {};
if ($("global-ua-block-enabled")) $("global-ua-block-enabled").checked = uaPolicy.enabled === true;
if ($("global-ua-block-patterns")) $("global-ua-block-patterns").value = (uaPolicy.patterns || []).join("\n");
}
async function saveRequest(path, payload) { if (busy) { toast(t("busy"), true); return false; } if (!authenticated || !envelope) return false; busy = true; const epoch = authEpoch; try { const value = await api(path, { method: "PUT", body: JSON.stringify(payload) }); if (!authenticated || epoch !== authEpoch) return false; envelope = value; if (path === "/api/config" || path === "/api/config/tls") await refreshCertificateInventory(); if (!authenticated || epoch !== authEpoch || !envelope) return false; renderConfig(); toast(t("saved")); await refresh(true); return true; } catch (e) { toast(e.message, true); if (e.status === 401) lock(); return false; } finally { busy = false; } }
async function save(config) { return saveRequest("/api/config", { expected_revision: envelope?.revision, config }); }
@@ -464,6 +467,7 @@ document.addEventListener("click", event => { if (dashboardSidebar?.classList.co
document.addEventListener("keydown", event => { if (event.key === "Escape" && dashboardSidebar?.classList.contains("mobile-nav-open")) { setMobileMenu(false); mobileMenuToggle?.focus(); } });
window.addEventListener("popstate", () => { setPanel(panelFromLocation(), false); setMobileMenu(false); });
$("host-policy-form").addEventListener("submit", async event => { event.preventDefault(); const form = event.currentTarget; if (!envelope || busy) return; try { await withFormSubmitState(form, event.submitter, async () => { const hosts = [...new Set($("policy-hosts").value.split(",").map(h => h.trim().toLowerCase()).filter(Boolean))]; const acl = { default: $("policy-default").value, rules: parseRules($("policy-rules").value, $("policy-rules")) }; const next = structuredClone(envelope.config); next.host_policies = (next.host_policies || []).map(p => ({ ...p, hosts: p.hosts.filter(h => !hosts.includes(h)) })).filter(p => p.hosts.length); next.host_policies.push({ hosts, acl }); if (await saveSection("host-policies", next.host_policies)) { form.reset(); if ($("security-policy-editor")) $("security-policy-editor").open = false; } }); } catch (e) { toast(e.message, true); } });
$("global-ua-block-form")?.addEventListener("submit", async event => { event.preventDefault(); const form = event.currentTarget; if (!envelope || busy) return; try { await withFormSubmitState(form, event.submitter, async () => { const patterns = [...new Set($("global-ua-block-patterns").value.split(/\r?\n/).map(value => value.trim()).filter(Boolean))]; await saveSection("user-agent-blocking", { enabled: $("global-ua-block-enabled").checked, patterns }); }); } catch (e) { toast(e.message, true); } });
$("password-form").addEventListener("submit", async event => { event.preventDefault(); const form = event.currentTarget; try { await withFormSubmitState(form, event.submitter, async () => { if ($("new-password").value !== $("new-password-confirm").value) throw fieldError("new-password-confirm", t("passwordMismatch")); await api("/api/auth/password", { method: "POST", body: JSON.stringify({ current_password: $("current-password").value, new_password: $("new-password").value }) }); lock(); toast(t("passwordChanged")); }); } catch (e) { toast(e.message, true); } });
$("revoke-sessions").addEventListener("click", async () => { if (!(await appConfirm(t("confirmRevoke"), { danger: true }))) return; try { await api("/api/auth/logout-all", { method: "POST" }); lock(); toast(t("locked")); } catch (e) { toast(e.message, true); } });
$("security-audit-open").addEventListener("click", () => { const dialog = $("audit-dialog"); if (!dialog.open) dialog.showModal(); void loadAudit(); });
+4 -4
View File
@@ -6,10 +6,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>RustProxy / Docs</title>
<link rel="icon" href="/favicon.svg">
<link rel="stylesheet" href="/docs/swagger-ui.css">
<link rel="stylesheet" href="/docs/style.css">
<script defer src="/docs/swagger-ui-bundle.js"></script>
<script defer src="/docs/init.js"></script>
<link rel="stylesheet" href="/docs/swagger-ui___RUSTPROXY_ASSET_ID__.css">
<link rel="stylesheet" href="/docs/style___RUSTPROXY_ASSET_ID__.css">
<script defer src="/docs/swagger-ui-bundle___RUSTPROXY_ASSET_ID__.js"></script>
<script defer src="/docs/init___RUSTPROXY_ASSET_ID__.js"></script>
</head>
<body>
+86 -6
View File
@@ -2,7 +2,7 @@
let inventory = null, offset = 0, lastTotal = 0, busy = false;
let currentEntries = [], selectedIndex = null;
let preferencesLoaded = false, preferenceSaveRunning = false, preferenceSaveQueued = false;
let searchTimer = null;
let searchTimer = null, aggregateField = "", activeView = "entries";
const defaultColumnIds = ["time", "event", "status", "client_ip", "vhost", "record", "source"];
const specialLabels = new Map([
["time", "time"], ["event", "fileLogsEvent"], ["status", "status"],
@@ -13,17 +13,57 @@
let visibleColumns = new Set(defaultColumnIds);
const CONSOLIDATION_STORAGE_KEY = "rp-file-logs-consolidate-vhosts";
const consolidationToggle = $("file-log-consolidate-vhosts");
try {
const saved = localStorage.getItem(CONSOLIDATION_STORAGE_KEY);
if (consolidationToggle && (saved === "1" || saved === "0")) consolidationToggle.checked = saved === "1";
} catch { /* Browser storage can be unavailable; use the HTML default. */ }
const sourceSelect = () => $("file-log-source");
function setView(view) {
activeView = view === "counts" ? "counts" : "entries";
for (const name of ["entries", "counts"]) {
const selected = name === activeView;
$("file-log-tab-" + name).classList.toggle("active", selected);
$("file-log-tab-" + name).setAttribute("aria-selected", String(selected));
$("file-log-tab-" + name).tabIndex = selected ? 0 : -1;
$("file-log-view-" + name).hidden = !selected;
}
if (activeView === "counts" && aggregateField) void refresh(true);
}
function sourceOption(source) {
const label = `${source.label} · ${source.format} · ${bytes(Number(source.size) || 0)}`;
const grouped = Number(source.member_count) > 1 ? ` · ${fmt(source.member_count)} ${t("fileLogsFiles")}` : "";
const label = `${source.label}${grouped} · ${source.format} · ${bytes(Number(source.size) || 0)}`;
const option = el("option", label); option.value = source.id; return option;
}
function displaySources(sources) {
if (!$("file-log-consolidate-vhosts")?.checked) return sources;
const output = [], groups = new Map();
for (const source of sources) {
if (!String(source.group_id || "").startsWith("vhost:")) { output.push(source); continue; }
const id = source.group_id;
let group = groups.get(id);
if (!group) {
group = { ...source, id, label: id.slice("vhost:".length), size: 0, member_count: 0, compressed: false };
groups.set(id, group); output.push(group);
}
group.size += Number(source.size) || 0; group.member_count += 1;
group.modified_ms = Math.max(Number(group.modified_ms) || 0, Number(source.modified_ms) || 0);
}
return output;
}
function renderInventory(value) {
inventory = value;
const select = sourceSelect();
const selected = select.value || "all";
const sources = Array.isArray(value?.sources) ? value.sources : [];
let selected = select.value || "all";
const rawSources = Array.isArray(value?.sources) ? value.sources : [];
if ($("file-log-consolidate-vhosts")?.checked && selected !== "all") {
const member = rawSources.find(source => source.id === selected);
if (member?.group_id?.startsWith("vhost:")) selected = member.group_id;
}
const sources = displaySources(rawSources);
const options = [Object.assign(el("option", t("fileLogsAllSources")), { value: "all" }), ...sources.map(sourceOption)];
select.replaceChildren(...options);
select.value = sources.some(source => source.id === selected) ? selected : "all";
@@ -35,6 +75,13 @@
}
const currentLimit = () => Math.max(1, Math.min(500, Number($("file-log-limit").value) || 100));
async function copyText(value) {
if (navigator.clipboard?.writeText) {
try { await navigator.clipboard.writeText(value); toast(t("copied")); 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(); toast(t("copied"));
}
function entryTime(entry) {
if (entry.time_ms) return formatDateTime(Number(entry.time_ms));
@@ -89,6 +136,11 @@
if (normalized) availableColumns.add(normalized);
}
for (const id of visibleColumns) availableColumns.add(id);
const suggestions = $("file-log-field-suggestions");
if (suggestions) {
const names = new Set(["user_agent", "client_ip", "vhost", "host", "path", "method", "status", "country", "source", ...fields]);
suggestions.replaceChildren(...[...names].filter(Boolean).sort().map(name => Object.assign(el("option"), { value: normalizedField(name) || name })));
}
syncColumnPicker(); syncHeaders();
}
function syncHeaders() {
@@ -166,6 +218,19 @@
raw.append(summary, el("pre", JSON.stringify(entry, null, 2), "metrics-example"));
detail.append(heading, fields, raw); cell.append(detail); row.append(cell); return row;
}
function renderAggregation(aggregation) {
const rows = $("file-log-aggregate-rows"), empty = $("file-log-aggregate-empty"), state = $("file-log-aggregate-state");
if (!rows || !empty || !state) return;
const entries = Array.isArray(aggregation?.entries) ? aggregation.entries : [];
rows.replaceChildren(...entries.map(item => {
const row = el("tr"), value = String(item.value ?? ""), valueCell = el("td"), button = el("button", value, "quiet file-log-aggregate-copy");
button.type = "button"; button.title = t("copyValue"); button.addEventListener("click", () => void copyText(value)); valueCell.append(button);
row.append(valueCell, el("td", fmt(Number(item.count) || 0))); return row;
}));
empty.hidden = !aggregation || entries.length !== 0;
state.textContent = aggregation ? `${aggregation.field} · ${entries.length}${aggregation.truncated ? ` · ${t("fileLogsAggregateTruncated")}` : ""}` : "";
}
function renderRows(entries = currentEntries) {
currentEntries = entries;
const ids = orderedColumns().filter(id => visibleColumns.has(id));
@@ -189,6 +254,12 @@
const source = sourceSelect().value || "all";
if (source !== "all") params.set("source", source);
const q = $("file-log-q").value.trim(); if (q) params.set("q", q);
if ($("file-log-consolidate-vhosts")?.checked) params.set("consolidate_vhosts", "true");
if (activeView === "counts" && aggregateField) {
params.set("aggregate_field", aggregateField);
params.set("aggregate_min_count", String(Math.max(2, Number($("file-log-aggregate-min")?.value) || 2)));
params.set("aggregate_limit", String(Math.max(1, Number($("file-log-aggregate-limit")?.value) || 50)));
}
params.set("offset", String(offset)); params.set("limit", String(currentLimit()));
return params.toString();
}
@@ -202,6 +273,7 @@
lastTotal = Number(value.total) || 0; selectedIndex = null;
applyFields(Array.isArray(value.fields) ? value.fields : []);
renderRows(Array.isArray(value.entries) ? value.entries : []);
renderAggregation(value.aggregation || null);
const from = lastTotal ? offset + 1 : 0, to = Math.min(offset + currentLimit(), lastTotal);
$("file-log-summary").textContent = t("fileLogsSummary")
.replace("{from}", fmt(from)).replace("{to}", fmt(to)).replace("{total}", fmt(lastTotal))
@@ -213,12 +285,14 @@
finally { busy = false; $("file-logs-refresh").disabled = false; }
}
function reset() {
inventory = null; offset = 0; lastTotal = 0; currentEntries = []; selectedIndex = null;
inventory = null; offset = 0; lastTotal = 0; currentEntries = []; selectedIndex = null; aggregateField = "";
preferencesLoaded = false; preferenceSaveRunning = false; preferenceSaveQueued = false;
availableColumns = new Set(defaultColumnIds); visibleColumns = new Set(defaultColumnIds);
sourceSelect().replaceChildren(Object.assign(el("option", t("fileLogsAllSources")), { value: "all" }));
$("file-log-rows").replaceChildren(); syncColumnPicker(); syncHeaders();
$("file-log-rows").replaceChildren(); $("file-log-aggregate-rows")?.replaceChildren(); renderAggregation(null); syncColumnPicker(); syncHeaders();
}
$("file-log-tab-entries").addEventListener("click", () => setView("entries"));
$("file-log-tab-counts").addEventListener("click", () => setView("counts"));
$("file-logs-open").addEventListener("click", () => { $("logs-menu").open = false; setPanel("filelogs"); });
$("settings-file-logs-open").addEventListener("click", () => setPanel("filelogs"));
$("file-logs-refresh").addEventListener("click", () => refresh(true));
@@ -226,6 +300,12 @@
$("file-log-q").addEventListener("input", () => { clearTimeout(searchTimer); searchTimer = setTimeout(() => void refresh(true), 250); });
$("file-log-limit").addEventListener("change", () => refresh(true));
sourceSelect().addEventListener("change", () => refresh(true));
consolidationToggle?.addEventListener("change", () => {
try { localStorage.setItem(CONSOLIDATION_STORAGE_KEY, consolidationToggle.checked ? "1" : "0"); } catch { }
if (inventory) renderInventory(inventory);
void refresh(true);
});
$("file-log-aggregate-form")?.addEventListener("submit", event => { event.preventDefault(); aggregateField = $("file-log-aggregate-field").value.trim().toLowerCase(); if (!aggregateField) { renderAggregation(null); return; } void refresh(true); });
$("file-logs-prev").addEventListener("click", () => { offset = Math.max(0, offset - currentLimit()); void refresh(); });
$("file-logs-next").addEventListener("click", () => { if (offset + currentLimit() < lastTotal) { offset += currentLimit(); void refresh(); } });
syncColumnPicker(); syncHeaders();
+39 -4
View File
@@ -7,9 +7,9 @@
<meta content="width=device-width, initial-scale=1" name="viewport" />
<title>RustProxy / Control room</title>
<link href="/favicon.svg" rel="icon" />
<link href="/assets/style.css?v=__RUSTPROXY_VERSION__" rel="stylesheet" />
<link href="/assets/style___RUSTPROXY_ASSET_ID__.css" rel="stylesheet" />
<!-- One ordered JS payload: no race between module fetches and session/config API. -->
<script defer="" src="/assets/app.js?v=__RUSTPROXY_VERSION__"></script>
<script defer="" src="/assets/app___RUSTPROXY_ASSET_ID__.js"></script>
</head>
<body>
@@ -1675,6 +1675,7 @@
<code>error:"connection reset"</code>
</div>
</details>
<label class="file-log-consolidate" title="Consolidate vhost log rotations"><input checked id="file-log-consolidate-vhosts" type="checkbox" /><span data-i18n="fileLogsConsolidateShort">Merge rotations</span></label>
<button class="outline" data-i18n="refresh" id="file-logs-refresh"
type="button">Refresh</button>
</div>
@@ -1697,6 +1698,25 @@
<option>500</option>
</select></label>
</form>
<div class="file-log-tabs" role="tablist" aria-label="Stored log views">
<button class="file-log-tab active" id="file-log-tab-entries" type="button" role="tab" aria-controls="file-log-view-entries" aria-selected="true" data-i18n="fileLogsEntriesTab">Log entries</button>
<button class="file-log-tab" id="file-log-tab-counts" type="button" role="tab" aria-controls="file-log-view-counts" aria-selected="false" data-i18n="fileLogsCountsTab">Repeated values</button>
</div>
<section class="file-log-advanced-parser" id="file-log-view-counts" role="tabpanel" aria-labelledby="file-log-tab-counts" hidden>
<h3 data-i18n="fileLogsAdvancedParser">Advanced parser · repeated values</h3>
<form class="file-log-aggregate-form" id="file-log-aggregate-form">
<label><span data-i18n="fileLogsAggregateField">Field to count</span><input id="file-log-aggregate-field" list="file-log-field-suggestions" maxlength="128" placeholder="user_agent" /></label>
<datalist id="file-log-field-suggestions"></datalist>
<label><span data-i18n="fileLogsAggregateMin">Minimum repetitions</span><input id="file-log-aggregate-min" type="number" min="2" max="20000" value="2" /></label>
<label><span data-i18n="fileLogsAggregateLimit">Top values</span><select id="file-log-aggregate-limit"><option>20</option><option selected>50</option><option>100</option><option>200</option></select></label>
<button class="outline" data-i18n="fileLogsCountRepeats" type="submit">Count repeats</button>
</form>
<p class="fine" data-i18n="fileLogsAggregateHelp">Counts matching values across the bounded scanned log set, not only the current page. Use fields such as user_agent, client_ip, host, path or any parsed nested field.</p>
<div class="table-scroll file-log-aggregate-table"><table><thead><tr><th data-i18n="fileLogsAggregateValue">Value</th><th data-i18n="fileLogsAggregateCount">Count</th></tr></thead><tbody id="file-log-aggregate-rows"></tbody></table></div>
<p class="empty" data-i18n="fileLogsAggregateEmpty" id="file-log-aggregate-empty">No repeated values for the selected field.</p>
<p class="fine" id="file-log-aggregate-state" role="status"></p>
</section>
<div id="file-log-view-entries" role="tabpanel" aria-labelledby="file-log-tab-entries">
<div class="file-logs-table-toolbar">
<p class="fine" id="file-log-summary" role="status"></p>
<details class="log-column-picker file-log-column-picker">
@@ -1717,6 +1737,7 @@
<div class="file-logs-pager"><button class="quiet" data-i18n="previous" disabled
id="file-logs-prev" type="button">Previous</button><button class="quiet"
data-i18n="next" disabled id="file-logs-next" type="button">Next</button></div>
</div>
</article>
</section>
<section class="panel" hidden="" id="panel-settings">
@@ -2325,7 +2346,7 @@
<summary aria-label="curl examples for HTTP BAN and PURGE" data-i18n-aria="cacheHttpInvalidationCurlLabel">i</summary>
<div class="metric-info-popover" role="note">
<p class="fine" data-i18n="cacheHttpInvalidationCurlIntro">Run curl from an allowlisted IP against a public vhost:</p>
<code>curl -i -X PURGE 'https://site.example/assets/app.js?v=1'</code>
<code>curl -i -X PURGE 'https://site.example/assets/app_abcdef0123456789.js'</code>
<code>curl -i -X BAN 'https://site.example/assets/'</code>
<code>curl -i -X BAN 'https://site.example/' -H 'X-RustProxy-Confirm-Wide: 1'</code>
<p class="fine" data-i18n="cacheHttpInvalidationCurlNote">PURGE removes an exact URL; BAN invalidates a path prefix for 1 hour. No admin cookie is needed, but your IP must be allowed. A reverse proxy must forward these methods and trusted client IP information.</p>
@@ -2944,6 +2965,20 @@
</form>
</details>
</article>
<article class="card security-policy-card">
<div class="card-heading">
<div><span class="small-label" data-i18n="userAgentBlocking">USER-AGENT BLOCKING</span>
<h2 data-i18n="globalUaBlockingTitle">Global User-Agent blocklist</h2>
<p class="fine" data-i18n="globalUaBlockingHelp">Blocks matching User-Agent values on public proxy vhosts before an upstream is opened. Per-vhost lists are configured in Vhosts → Security.</p>
</div>
</div>
<form class="edit-form security-inline-form" id="global-ua-block-form">
<label class="check"><input id="global-ua-block-enabled" type="checkbox" /><span data-i18n="enabled">Enabled</span></label>
<label><span data-i18n="uaBlockPatterns">Blocked patterns, one per line</span><textarea id="global-ua-block-patterns" rows="6" maxlength="32768" spellcheck="false" placeholder="BadBot&#10;curl/*&#10;*scanner*"></textarea></label>
<p class="fine" data-i18n="uaBlockPatternHelp">Matching is case-insensitive. Plain text matches a substring; * is a wildcard. Requests without User-Agent are not blocked by this rule.</p>
<div class="form-actions"><button class="primary" data-i18n="apply" type="submit">Validate, save and apply</button></div>
</form>
</article>
<article class="card security-policy-card">
<div class="card-heading">
<div><span class="small-label" data-i18n="securityAccessControl">ACCESS
@@ -3012,7 +3047,7 @@
<option value="cache_key">Cache key</option>
</select></div>
<div><label data-i18n="cacheInvalidationValue" for="cache-invalidation-value">Value</label><input
id="cache-invalidation-value" maxlength="8192" placeholder="/assets/app.js" required="" />
id="cache-invalidation-value" maxlength="8192" placeholder="/assets/app___RUSTPROXY_ASSET_ID__.js" required="" />
</div>
<div><label data-i18n="cacheInvalidationHost" for="cache-invalidation-host">Host
(optional)</label><input id="cache-invalidation-host" maxlength="253"
+2 -2
View File
@@ -6,8 +6,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>RustProxy / Sign in</title>
<link rel="icon" href="/favicon.svg">
<link rel="stylesheet" href="/assets/style.css">
<script defer src="/assets/login.js"></script>
<link rel="stylesheet" href="/assets/style___RUSTPROXY_ASSET_ID__.css">
<script defer src="/assets/login___RUSTPROXY_ASSET_ID__.js"></script>
</head>
<body class="auth-page auth-loading">
+74 -1
View File
@@ -10191,4 +10191,77 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
.cache-maintenance-info .metric-info-popover code+.fine {
margin-top: 8px;
}
}
/* Stored Logs: compact header option and distinct analysis workspace. */
.file-logs-heading-actions .file-log-consolidate {
display: inline-flex;
align-items: center;
gap: .45rem;
padding: .35rem .55rem;
min-height: 0;
color: var(--muted);
font-size: .8rem;
font-weight: normal;
white-space: nowrap;
cursor: pointer;
}
.file-log-consolidate input { width: 14px; height: 14px; margin: 0; accent-color: var(--accent); }
.file-log-consolidate:hover { color: var(--text); }
.file-log-tabs { display: flex; gap: .25rem; border-bottom: 1px solid var(--line); margin: .9rem 0 1rem; }
.file-log-tab {
appearance: none; background: transparent; border: 0; border-bottom: 2px solid transparent;
border-radius: 0; padding: .65rem 1rem; color: var(--muted); cursor: pointer;
font: inherit; font-weight: 500;
}
.file-log-tab:hover { color: var(--text); }
.file-log-tab.active { color: var(--text); border-bottom-color: var(--accent); }
.file-log-tab:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
#file-log-view-counts[hidden], #file-log-view-entries[hidden] { display: none !important; }
.file-log-advanced-parser h3 { margin: 0 0 .7rem; font-size: 1rem; }
@media (max-width: 700px) {
.file-logs-heading-actions .file-log-consolidate { margin-inline-end: auto; }
.file-log-tab { padding: .6rem .7rem; }
}
/* 0.31.5 traffic/log analysis actions */
.traffic-copy-value,
.file-log-aggregate-copy {
appearance: none;
border: 0;
background: transparent;
color: inherit;
font: inherit;
text-align: left;
padding: 0;
margin: 0;
cursor: copy;
}
.traffic-copy-value:hover,
.traffic-copy-value:focus-visible,
.file-log-aggregate-copy:hover,
.file-log-aggregate-copy:focus-visible {
text-decoration: underline;
}
.file-log-advanced-parser {
margin: 0 0 1rem;
padding: .75rem 1rem;
border: 1px solid var(--border);
border-radius: var(--radius, 12px);
}
.file-log-aggregate-form {
display: grid;
grid-template-columns: minmax(220px, 2fr) minmax(140px, 1fr) minmax(120px, 1fr) auto;
gap: .75rem;
align-items: end;
margin-top: .75rem;
}
.file-log-aggregate-table {
margin-top: .75rem;
}
.file-log-aggregate-table th:last-child,
.file-log-aggregate-table td:last-child {
width: 8rem;
text-align: right;
}
@media (max-width: 800px) {
.file-log-aggregate-form { grid-template-columns: 1fr; }
}
+13 -2
View File
@@ -4,6 +4,17 @@
const chartPalette = ["var(--chart-accent)", "var(--chart-secondary)", "var(--chart-tertiary)", "var(--chart-quaternary)", "var(--chart-quinary)", "var(--chart-senary)", "var(--chart-septenary)", "var(--chart-octonary)"];
const scope = () => $("traffic-scope")?.value || "*";
const number = value => fmt(Number(value) || 0);
async function copyText(value) {
if (navigator.clipboard?.writeText) {
try { await navigator.clipboard.writeText(value); toast(t("copied")); 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(); toast(t("copied"));
}
function copyButton(value, className) {
const button = el("button", value || "-", `${className} traffic-copy-value`); button.type = "button"; button.title = t("copyValue");
button.addEventListener("click", () => void copyText(value || "-")); return button;
}
function selectTab(name) {
activeTab = name;
@@ -53,7 +64,7 @@
legend.replaceChildren(...shown.map((item, index) => {
const row = el("li"), label = el("span"), swatch = el("i", undefined, "traffic-chart-swatch");
swatch.style.background = chartPalette[index % chartPalette.length];
const text = el("span", item.value || "-", "traffic-chart-label"); text.title = item.value || "-";
const text = copyButton(item.value || "-", "traffic-chart-label"); text.title = `${t("copyValue")}: ${item.value || "-"}`;
label.append(swatch, text); row.append(label, el("b", `${(item.count / total * 100).toFixed(1)}%`)); return row;
}));
}
@@ -65,7 +76,7 @@
if (empty) empty.hidden = values.length > 0;
chart.replaceChildren(...values.map(item => {
const count = Number(item.count) || 0, row = el("div", undefined, "traffic-bar-row");
const label = el("span", item.value || "-", "traffic-bar-label"); label.title = item.value || "-";
const label = copyButton(item.value || "-", "traffic-bar-label"); label.title = `${t("copyValue")}: ${item.value || "-"}`;
const track = el("span", undefined, "traffic-bar-track"), fill = el("i", undefined, "traffic-bar-fill"); fill.style.width = `${Math.max(1.5, count / peak * 100)}%`; track.append(fill);
const value = el("span", formatter(count), "traffic-bar-value"); value.title = total ? `${(count / total * 100).toFixed(1)}%` : "";
row.append(label, track, value); return row;
+6 -1
View File
@@ -256,6 +256,10 @@
field(g, "referrer", "vhReferrer", security.referrer_policy || "", "text", { maxLength: 8192 });
field(g, "csp", "vhCsp", security.content_security_policy || "", "textarea", { rows: 2, maxLength: 8192 });
field(g, "permissions", "vhPermissions", security.permissions_policy || "", "text", { maxLength: 8192 });
const uaBlock = value.user_agent_blocking || {};
field(g, "ua-block-enabled", "vhUaBlockEnabled", uaBlock.enabled === true, "checkbox");
field(g, "ua-block-patterns", "vhUaBlockPatterns", (uaBlock.patterns || []).join("\n"), "textarea", { rows: 4, maxLength: 32768 });
note(g, "vhUaBlockHelp");
g = group("vhAdvanced", true); field(g, "paths", "vhPaths", JSON.stringify(value.routes || [], null, 2), "textarea", { rows: 5, maxLength: 131072 });
field(g, "acl", "vhAcl", JSON.stringify(value.acl ?? null, null, 2), "textarea", { rows: 5, maxLength: 32768 });
field(g, "headers", "vhHeaders", JSON.stringify(value.headers || {}, null, 2), "textarea", { rows: 5, maxLength: 65536 });
@@ -463,6 +467,7 @@
},
sticky: { mode: str("sticky-mode"), cookie_name: nullable("cookie-name"), ttl_seconds: num("cookie-ttl") }
},
security_headers: { hsts: { enabled: yes("hsts"), max_age_seconds: num("hsts-age"), include_subdomains: yes("hsts-sub"), preload: yes("hsts-preload") }, nosniff: yes("nosniff"), frame_options: str("frame"), referrer_policy: nullable("referrer"), content_security_policy: nullable("csp"), permissions_policy: nullable("permissions") }
security_headers: { hsts: { enabled: yes("hsts"), max_age_seconds: num("hsts-age"), include_subdomains: yes("hsts-sub"), preload: yes("hsts-preload") }, nosniff: yes("nosniff"), frame_options: str("frame"), referrer_policy: nullable("referrer"), content_security_policy: nullable("csp"), permissions_policy: nullable("permissions") },
user_agent_blocking: { enabled: yes("ua-block-enabled"), patterns: lines(str("ua-block-patterns")) }
};
}