This commit is contained in:
Mateusz Gruszczyński committed 2026-10-05 22:29:54 +02:00
1 parent b9d137eee1
commit 32e6875980
51 files changed
+1255 -452

No files matched your search

+115 -53
View File
@@ -3,44 +3,39 @@
let currentEntries = [], selectedIndex = null;
let preferencesLoaded = false, preferenceSaveRunning = false, preferenceSaveQueued = false;
let searchTimer = null;
const columns = [
{ id: "time", label: "time", value: entry => entryTime(entry) },
{ id: "event", label: "fileLogsEvent", value: entry => entry.event || "—" },
{ id: "status", label: "status", value: entry => entryStatus(entry) },
{ id: "client_ip", label: "clientIp", value: entry => entry.client_ip || entry.source_ip || "—" },
{ id: "vhost", label: "vhost", value: entry => entry.vhost || "—" },
{ id: "transport", label: "logTransport", value: entry => entry.transport ? `${entry.transport}${entry.http_version ? ` / HTTP ${entry.http_version}` : ""}` : "—" },
{ id: "matched_by", label: "fileLogsMatchedBy", value: entry => entry.matched_by || "—" },
{ id: "sni", label: "fileLogsSni", value: entry => entry.sni || entry.tls_sni || "—" },
{ id: "authority", label: "fileLogsAuthority", value: entry => entry.authority || entry.host || "—" },
{ id: "record", label: "fileLogsRecord", value: entry => entrySummary(entry) },
{ id: "source", label: "fileLogsSource", value: entry => entry._source || "—" },
];
const defaultColumnIds = ["time", "event", "status", "client_ip", "vhost", "record", "source"];
const specialLabels = new Map([
["time", "time"], ["event", "fileLogsEvent"], ["status", "status"],
["client_ip", "clientIp"], ["vhost", "vhost"], ["record", "fileLogsRecord"],
["source", "fileLogsSource"],
]);
let availableColumns = new Set(defaultColumnIds);
let visibleColumns = new Set(defaultColumnIds);
const sourceSelect = () => $("file-log-source");
const currentLimit = () => Math.max(1, Math.min(500, Number($("file-log-limit").value) || 100));
function sourceOption(source) {
const label = `${source.label} · ${source.format} · ${bytes(source.size || 0)}${source.enabled ? ` · ${t("enabled")}` : ""}`;
const label = `${source.label} · ${source.format} · ${bytes(Number(source.size) || 0)}`;
const option = el("option", label); option.value = source.id; return option;
}
function renderInventory(value) {
inventory = value;
const selected = sourceSelect().value || "all";
const select = sourceSelect();
const selected = select.value || "all";
const sources = Array.isArray(value?.sources) ? value.sources : [];
const options = [Object.assign(el("option", t("fileLogsAllSources")), { value: "all" }), ...sources.map(sourceOption)];
sourceSelect().replaceChildren(...options);
sourceSelect().value = ["all", ...sources.map(source => source.id)].includes(selected) ? selected : "all";
$("file-log-inventory").textContent = sources.length
? sources.map(source => `${source.label}: ${source.path} (${bytes(source.size || 0)})`).join(" · ")
: t("fileLogsNoSources");
select.replaceChildren(...options);
select.value = sources.some(source => source.id === selected) ? selected : "all";
}
async function loadInventory() {
const value = await api("/api/file-logs"); renderInventory(value); return value;
const value = await api("/api/file-logs");
renderInventory(value);
return value;
}
const currentLimit = () => Math.max(1, Math.min(500, Number($("file-log-limit").value) || 100));
function entryTime(entry) {
if (entry.time_ms) return new Date(Number(entry.time_ms)).toLocaleString(locale());
if (entry.timestamp) return String(entry.timestamp);
@@ -54,24 +49,67 @@
if (entry.raw) return entry.raw;
return entry.outcome || "—";
}
function entryStatus(entry) {
const status = Number(entry.status) || 0;
return status ? String(status) : "—";
function nestedValue(entry, path) {
let value = entry;
for (const part of path.split(".")) {
if (!value || typeof value !== "object" || !Object.hasOwn(value, part)) return undefined;
value = value[part];
}
return value;
}
function displayValue(value) {
if (value === undefined || value === null || value === "") return "—";
if (typeof value === "object") return JSON.stringify(value);
return String(value);
}
function columnValue(entry, id) {
if (id === "time") return entryTime(entry);
if (id === "record") return entrySummary(entry);
if (id === "source") return entry._source || "—";
return displayValue(nestedValue(entry, id));
}
function columnLabel(id) {
const key = specialLabels.get(id);
return key ? t(key) : id;
}
function normalizedField(field) {
if (field === "_source") return "source";
if (field === "_source_kind" || field === "_source_format") return null;
return field;
}
function orderedColumns() {
const preferred = defaultColumnIds.filter(id => availableColumns.has(id));
const rest = [...availableColumns].filter(id => !defaultColumnIds.includes(id)).sort((a, b) => a.localeCompare(b, locale(), { sensitivity: "base" }));
return [...preferred, ...rest];
}
function applyFields(fields = []) {
for (const field of fields) {
if (typeof field !== "string") continue;
const normalized = normalizedField(field);
if (normalized) availableColumns.add(normalized);
}
for (const id of visibleColumns) availableColumns.add(id);
syncColumnPicker(); syncHeaders();
}
function syncHeaders() {
document.querySelectorAll("#file-logs-table-head [data-file-log-column]").forEach(header => {
header.hidden = !visibleColumns.has(header.dataset.fileLogColumn);
const row = $("file-logs-table-head").querySelector("tr");
const headers = orderedColumns().filter(id => visibleColumns.has(id)).map(id => {
const header = el("th", columnLabel(id)); header.dataset.fileLogColumn = id;
const key = specialLabels.get(id); if (key) header.dataset.i18n = key;
return header;
});
row.replaceChildren(...headers);
}
function syncColumnPicker() {
const picker = $("file-log-column-options");
picker.replaceChildren(...columns.map(column => {
picker.replaceChildren(...orderedColumns().map(id => {
const label = el("label", undefined, "check log-column-option");
const input = document.createElement("input"); input.type = "checkbox"; input.checked = visibleColumns.has(column.id); input.dataset.column = column.id;
const text = el("span", t(column.label)); text.dataset.i18n = column.label;
const input = document.createElement("input"); input.type = "checkbox"; input.checked = visibleColumns.has(id); input.dataset.column = id;
const text = el("span", columnLabel(id));
const key = specialLabels.get(id); if (key) text.dataset.i18n = key;
input.addEventListener("change", () => {
if (!input.checked && visibleColumns.size === 1) { input.checked = true; toast(t("fileLogsColumnsRequired"), true); return; }
if (input.checked) visibleColumns.add(column.id); else visibleColumns.delete(column.id);
if (input.checked) visibleColumns.add(id); else visibleColumns.delete(id);
syncHeaders(); renderRows(); void savePreferences();
});
label.append(input, text); return label;
@@ -79,9 +117,10 @@
}
function applyPreferences(preferences) {
const selected = Array.isArray(preferences?.file_log_columns)
? preferences.file_log_columns.filter(id => columns.some(column => column.id === id))
? preferences.file_log_columns.filter(id => typeof id === "string" && id.length <= 128)
: [];
visibleColumns = new Set(selected.length ? selected : defaultColumnIds);
for (const id of visibleColumns) availableColumns.add(id);
preferencesLoaded = true;
syncColumnPicker(); syncHeaders(); renderRows();
}
@@ -92,45 +131,64 @@
try {
do {
preferenceSaveQueued = false;
const selected = columns.filter(column => visibleColumns.has(column.id)).map(column => column.id);
const selected = orderedColumns().filter(id => visibleColumns.has(id));
const preferences = await window.rustProxyPreferences.update({ file_log_columns: selected });
if (!preferenceSaveQueued) applyPreferences(preferences);
} while (preferenceSaveQueued);
} catch (error) { toast(error.message, true); }
finally { preferenceSaveRunning = false; }
}
function flattenFields(value, prefix = "", output = []) {
if (output.length >= 128) return output;
if (value && typeof value === "object" && !Array.isArray(value)) {
for (const [key, child] of Object.entries(value)) {
if (key === "_source_kind") continue;
const path = prefix ? `${prefix}.${key}` : key;
if (child && typeof child === "object" && !Array.isArray(child)) flattenFields(child, path, output);
else output.push([path, displayValue(child)]);
if (output.length >= 128) break;
}
} else if (prefix) output.push([prefix, displayValue(value)]);
return output;
}
function detailRow(entry) {
const row = el("tr", undefined, "file-log-detail-row");
const cell = el("td"); cell.colSpan = Math.max(1, visibleColumns.size);
const detail = el("div", undefined, "file-log-inline-detail");
const heading = el("strong", t("fileLogsParsedFields")); heading.dataset.i18n = "fileLogsParsedFields";
const fields = el("div", undefined, "file-log-field-grid");
for (const [key, value] of flattenFields(entry)) {
const item = el("div", undefined, "file-log-field");
item.append(el("code", key), el("span", value)); fields.append(item);
}
const raw = el("details", undefined, "file-log-raw-json");
const summary = el("summary", t("fileLogsRawJson")); summary.dataset.i18n = "fileLogsRawJson";
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 renderRows(entries = currentEntries) {
currentEntries = entries;
const ids = orderedColumns().filter(id => visibleColumns.has(id));
const nodes = [];
entries.forEach((entry, index) => {
const row = el("tr"); row.tabIndex = 0; row.classList.toggle("selected", selectedIndex === index);
row.setAttribute("aria-expanded", selectedIndex === index ? "true" : "false");
for (const column of columns) {
if (!visibleColumns.has(column.id)) continue;
const value = column.value(entry);
const cell = el("td", String(value)); cell.title = String(value); row.append(cell);
for (const id of ids) {
const value = columnValue(entry, id), cell = el("td", value); cell.title = value; row.append(cell);
}
const select = () => { selectedIndex = selectedIndex === index ? null : index; renderRows(); };
row.addEventListener("click", select);
row.addEventListener("keydown", event => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); select(); } });
nodes.push(row);
if (selectedIndex === index) {
const detailRow = el("tr", undefined, "file-log-detail-row");
const cell = el("td"); cell.colSpan = Math.max(1, visibleColumns.size);
const heading = el("strong", t("fileLogsDetails")); heading.dataset.i18n = "fileLogsDetails";
const pre = el("pre", JSON.stringify(entry, null, 2), "metrics-example");
const detail = el("div", undefined, "file-log-inline-detail"); detail.append(heading, pre); cell.append(detail); detailRow.append(cell); nodes.push(detailRow);
}
nodes.push(row); if (selectedIndex === index) nodes.push(detailRow(entry));
});
$("file-log-rows").replaceChildren(...nodes);
$("file-logs-empty").hidden = entries.length !== 0;
}
function queryString() {
const params = new URLSearchParams();
params.set("source", sourceSelect().value || "all");
for (const [id, name] of [["file-log-q", "q"], ["file-log-event", "event"], ["file-log-status", "status_class"], ["file-log-client", "client_ip"], ["file-log-vhost", "vhost"]]) {
const value = $(id).value.trim(); if (value && value !== "all") params.set(name, value);
}
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);
params.set("offset", String(offset)); params.set("limit", String(currentLimit()));
return params.toString();
}
@@ -142,6 +200,7 @@
if (!inventory) await loadInventory();
const value = await api(`/api/file-logs/query?${queryString()}`);
lastTotal = Number(value.total) || 0; selectedIndex = null;
applyFields(Array.isArray(value.fields) ? value.fields : []);
renderRows(Array.isArray(value.entries) ? value.entries : []);
const from = lastTotal ? offset + 1 : 0, to = Math.min(offset + currentLimit(), lastTotal);
$("file-log-summary").textContent = t("fileLogsSummary")
@@ -156,16 +215,19 @@
function reset() {
inventory = null; offset = 0; lastTotal = 0; currentEntries = []; selectedIndex = null;
preferencesLoaded = false; preferenceSaveRunning = false; preferenceSaveQueued = false;
visibleColumns = new Set(defaultColumnIds); $("file-log-rows").replaceChildren(); syncColumnPicker(); syncHeaders();
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-logs-open").addEventListener("click", () => setPanel("filelogs"));
$("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));
$("file-logs-filter").addEventListener("submit", event => { event.preventDefault(); void refresh(true); });
$("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));
$("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(); } });
sourceSelect().addEventListener("change", () => refresh(true));
syncColumnPicker(); syncHeaders();
window.rustProxyFileLogs = { refresh: () => refresh(false), reset, inventory: loadInventory, applyPreferences };
})();