(() => { let inventory = null, offset = 0, lastTotal = 0, busy = false; let currentEntries = [], selectedIndex = null; let preferencesLoaded = false, preferenceSaveRunning = false, preferenceSaveQueued = false; let searchTimer = null; 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"); function sourceOption(source) { 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 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)]; 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 currentLimit = () => Math.max(1, Math.min(500, Number($("file-log-limit").value) || 100)); function entryTime(entry) { if (entry.time_ms) return formatDateTime(Number(entry.time_ms)); if (entry.timestamp) return String(entry.timestamp); return "—"; } function entrySummary(entry) { if (entry.reason || entry.protocol_error) return entry.reason || entry.protocol_error; if (entry.method || entry.path) return `${entry.method || ""} ${entry.host || entry.authority || ""}${entry.path || ""}`.trim(); if (entry.error || entry.error_detail) return entry.error || entry.error_detail; if (entry.message) return entry.message; if (entry.raw) return entry.raw; return entry.outcome || "—"; } 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() { 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(...orderedColumns().map(id => { const label = el("label", undefined, "check log-column-option"); 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(id); else visibleColumns.delete(id); syncHeaders(); renderRows(); void savePreferences(); }); label.append(input, text); return label; })); } function applyPreferences(preferences) { const selected = Array.isArray(preferences?.file_log_columns) ? 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(); } async function savePreferences() { if (!authenticated || !preferencesLoaded) return; if (preferenceSaveRunning) { preferenceSaveQueued = true; return; } preferenceSaveRunning = true; try { do { preferenceSaveQueued = false; 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 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) nodes.push(detailRow(entry)); }); $("file-log-rows").replaceChildren(...nodes); $("file-logs-empty").hidden = entries.length !== 0; } function queryString() { const params = new URLSearchParams(); 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(); } async function refresh(reset = false) { if (!authenticated || busy) return; if (reset) offset = 0; busy = true; $("file-logs-refresh").disabled = true; try { 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") .replace("{from}", fmt(from)).replace("{to}", fmt(to)).replace("{total}", fmt(lastTotal)) .replace("{bytes}", bytes(Number(value.scanned_bytes) || 0)).replace("{lines}", fmt(Number(value.scanned_lines) || 0)) + (value.truncated ? ` · ${t("fileLogsTruncated")}` : ""); $("file-logs-prev").disabled = offset === 0; $("file-logs-next").disabled = offset + currentLimit() >= lastTotal; } catch (error) { toast(error.message, true); } finally { busy = false; $("file-logs-refresh").disabled = false; } } function reset() { inventory = null; offset = 0; lastTotal = 0; currentEntries = []; selectedIndex = null; 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-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(); } }); syncColumnPicker(); syncHeaders(); window.rustProxyFileLogs = { refresh: () => refresh(false), reset, inventory: loadInventory, applyPreferences }; })();