merge newui branch

This commit is contained in:
Gani Georgiev
2026-04-18 16:29:34 +03:00
parent 58f605e90c
commit 4c44044c0c
804 changed files with 58660 additions and 56663 deletions

View File

@@ -0,0 +1,13 @@
export function defaultLogLevels() {
return t.div(
{ className: "inline-flex gap-5" },
t.span(null, "Default log levels:"),
() => {
const result = [];
for (const level in app.utils.logLevels) {
result.push(t.code(null, `${level}:${app.utils.logLevels[level].label}`));
}
return result;
},
);
}

8
ui/src/logs/logLevel.js Normal file
View File

@@ -0,0 +1,8 @@
export function logLevel(log) {
return t.div(
{ className: () => `label log-level-label level-${log.level}` },
t.span({ className: "txt" }, () => {
return `${app.utils.logLevels[log.level]?.label || "UNKN"} (${log.level})`;
}),
);
}

View File

@@ -0,0 +1,276 @@
import { logLevel } from "./logLevel";
window.app = window.app || {};
window.app.modals = window.app.modals || {};
window.app.modals.openLogPreview = function(logIdOrModel, settings = {
onbeforeopen: null,
onafteropen: null,
onbeforeclose: null,
onafterclose: null,
}) {
const modal = logPreviewModal(logIdOrModel, settings);
if (!modal) {
return;
}
document.body.appendChild(modal);
app.modals.open(modal);
};
const priotizedKeys = [
"execTime",
"type",
"auth",
"authId",
"status",
"method",
"url",
"referer",
"remoteIP",
"userIP",
"userAgent",
"error",
"details",
];
function downloadJSON(log) {
app.utils.downloadJSON(log, "log_" + log.created.replaceAll(/[-:\. ]/gi, "") + ".json");
}
function copyJSON(log) {
app.utils.copyToClipboard(JSON.stringify(log, null, 2));
app.toasts.success("Log copied to clipboard!");
}
function logPreviewModal(logIdOrModel, settings) {
let modal;
const data = store({
isLoading: false,
log: null,
get isRequest() {
return data.log?.data?.type == "request";
},
get orderedDataKeys() {
const result = new Set();
if (!data.log?.data) {
return result;
}
for (let key of priotizedKeys) {
if (typeof data.log.data[key] != "undefined") {
result.add(key);
}
}
for (let key in data.log.data) {
result.add(key);
}
return result;
},
});
async function load() {
data.isLoading = true;
try {
if (app.utils.isObject(logIdOrModel)) {
data.log = JSON.parse(JSON.stringify(logIdOrModel));
} else {
data.log = await app.pb.logs.getOne(logIdOrModel, {
requestKey: "log_preview",
});
}
data.isLoading = false;
} catch (err) {
if (!err.isAbort) {
data.isLoading = false;
app.checkApiError(err);
}
}
}
modal = t.div(
{
pbEvent: "logPreviewModal",
className: "modal log-preview-modal",
onbeforeopen: (el) => {
load();
return settings.onbeforeopen?.(el);
},
onafteropen: (el) => {
settings.onafteropen?.(el);
},
onbeforeclose: (el) => {
return settings.onbeforeclose?.(el);
},
onafterclose: (el) => {
settings.onafterclose?.(el);
el?.remove();
},
},
t.header(
{ className: "modal-header" },
t.h5(null, "Log details"),
t.button(
{
"className": "btn sm circle transparent m-l-auto",
"html-popovertarget": "log-meta-dropdown",
},
t.i({ className: "ri-more-line" }),
),
t.div({ id: "log-meta-dropdown", className: "dropdown", popover: "auto" }, (el) => {
return t.button(
{
className: "dropdown-item",
onclick: () => {
copyJSON(data.log);
el.hidePopover();
},
},
t.i({ className: "ri-braces-line" }),
t.span({ className: "txt" }, "Copy JSON"),
);
}),
),
t.div({ className: "modal-content" }, () => {
if (!data.log || data.isLoading) {
return t.div({ className: "block txt-center" }, t.span({ className: "loader" }));
}
return t.table(
{
pbEvent: "logPreviewTable",
className: "log-view-table responsive-table",
},
t.tbody(
null,
t.tr(
null,
t.th({ className: "col-field-name-id p-r-0" }, "id"),
t.td(null, () => data.log.id),
t.td({ className: "col-copy min-width" }, app.components.copyButton(data.log.id)),
),
t.tr(
null,
t.th({ className: "col-field-name-level p-r-0" }, "level"),
t.td(null, () => logLevel(data.log)),
t.td({ className: "col-copy min-width" }, app.components.copyButton(data.log.level)),
),
t.tr(
null,
t.th({ className: "col-field-name-created p-r-0" }, "created"),
t.td(
null,
app.components.formattedDate({
value: () => data.log.created,
short: false,
}),
),
t.td({ className: "col-copy min-width" }, app.components.copyButton(data.log.created)),
),
() => {
if (!data.isRequest) {
return t.tr(
null,
t.th({ className: "col-field-name-message p-r-0" }, "message"),
t.td(null, () => app.utils.truncate(data.log.message, 1000)),
t.td(
{ className: "col-copy min-width" },
app.components.copyButton(data.log.message),
),
);
}
},
() => {
const rows = [];
for (let key of data.orderedDataKeys) {
let value = data.log.data?.[key];
if (app.utils.logDataFormatters[key]) {
value = app.utils.logDataFormatters[key](data.log);
}
const isEmpty = app.utils.isEmpty(value);
const isJSON = !isEmpty && app.utils.isObject(value);
if (isJSON) {
value = JSON.stringify(value, null, 2);
}
rows.push(
t.tr(
{
rid: "log_data_" + data.log.id + "_" + key,
},
t.th({ className: "min-width p-r-0" }, "data." + key),
t.td(null, () => {
if (isEmpty) {
return t.span({
className: "txt txt-hint",
textContent: "N/A",
});
}
if (key === "error") {
return t.span({
className: `label danger log-error-label ${isJSON ? "txt-code" : ""}`,
textContent: value,
});
}
if (key == "details") {
return t.span({
className: `label warning log-details-label ${
isJSON ? "txt-code" : ""
}`,
textContent: value,
});
}
if (isJSON) {
return app.components.codeBlock({ value });
}
return t.span({
className: "txt",
textContent: app.utils.stringifyValue(value, "N/A", 1000),
});
}),
t.td({ className: "col-copy min-width" }, app.components.copyButton(value)),
),
);
}
return rows;
},
),
);
}),
t.footer(
{ className: "modal-footer" },
t.button(
{
type: "button",
className: "btn transparent m-r-auto",
onclick: () => app.modals.close(modal),
},
t.span({ className: "txt" }, "Close"),
),
t.button(
{
type: "button",
className: "btn",
onclick: () => downloadJSON(data.log),
},
t.i({ className: "ri-download-line" }),
t.span({ className: "txt" }, "Download JSON"),
),
),
);
return modal;
}

482
ui/src/logs/logsChart.js Normal file
View File

@@ -0,0 +1,482 @@
function getChartHeight() {
return document.body.clientWidth > 600 && document.body.clientHeight >= 720 ? 150 : 100;
}
export function logsChart(logsSettings) {
const data = store({
stats: [],
});
async function loadAndInit(el) {
if (!el) {
return;
}
logsSettings.isChartLoading = true;
try {
const normalizedFilter = (logsSettings.presets || []).concat(
app.utils.normalizeSearchFilter(logsSettings.filter, ["level", "message", "data"]),
);
const stats = await app.pb.logs.getStats({
filter: normalizedFilter
.filter(Boolean)
.map((f) => "(" + f + ")")
.join("&&"),
});
const totalStats = stats.length;
const timestamps = [];
const totals = [];
logsSettings.totalFound = 0;
for (let i = 0; i < totalStats; i++) {
const unix = new Date(stats[i].date.replace(" ", "T")).getTime() / 1000;
timestamps.push(unix);
totals.push(stats[i].total);
logsSettings.totalFound += stats[i].total;
// if between the current and next point there is more than 1h difference
// insert a 0 point for a flat 1h line in the stepped chart visualization
if (stats[i + 1]?.date) {
const unixNext = new Date(stats[i + 1].date.replace(" ", "T")).getTime() / 1000;
if (unixNext - unix > 3600) {
timestamps.push(unix + 3600);
totals.push(0);
}
} else if (i + 1 == totalStats) {
timestamps.push(unix + 3600);
totals.push(0);
}
}
data.stats = stats;
logsSettings.isChartLoading = false;
initChart(el, [timestamps, totals], logsSettings);
} catch (err) {
if (!err?.isAbort) {
logsSettings.isChartLoading = false;
// only log to avoid showing multiple errors with the logs listing
// app.checkApiError(err)
console.warn("failed to load logs chart:", err);
}
}
}
const watchers = [];
return t.div(
{
pbEvent: "logsChart",
className: () =>
[
"logs-chart",
logsSettings.isChartLoading ? "loading" : null,
logsSettings.zoom?.min && logsSettings.zoom?.max ? "zoomed" : "",
!data.stats.length || !logsSettings.isFirstLoadReady ? "nodata" : null,
].filter(Boolean).join(" "),
onmount: (el) => {
// init and refresh chart
watchers.push(
watch(
() => [logsSettings.reset, logsSettings.filter, logsSettings.presets?.length],
() => loadAndInit(el),
),
);
el._resizeChartFunc = () => {
clearTimeout(el._resizeTimeoutId);
el._resizeTimeoutId = setTimeout(() => {
if (!el?._uplot) {
return;
}
el._uplot.setSize({
width: el.clientWidth,
height: getChartHeight(),
});
}, 100);
};
window.addEventListener("resize", el._resizeChartFunc);
},
onunmount: (el) => {
watchers.forEach((w) => w?.unwatch());
el._uplot?.destroy();
if (el._resizeChartFunc) {
clearTimeout(el._resizeTimeoutId);
window.removeEventListener("resize", el._resizeChartFunc);
el._resizeChartFunc = null;
el._resizeTimeoutId = null;
}
},
},
t.button(
{
type: "button",
className: () =>
`logs-reset-zoom-ctrl ${logsSettings.zoom?.min && logsSettings.zoom?.max ? "" : "hidden"}`,
onclick() {
logsSettings.zoom = {};
},
},
t.div({ className: "content-primary" }, "Reset zoom"),
t.div({ className: "content-secondary" }, "(drag the timeline to pan)"),
),
t.span({
hidden: () => !logsSettings.isChartLoading,
className: () => "loader logs-chart-loader",
}),
);
}
function resetChartZoom(chart) {
const data = chart?.data?.[0];
if (!data) {
return;
}
chart.setScale("x", {
min: data[0],
max: data[data.length - 1],
});
}
function initChart(el, dataPoints, logsSettings) {
const computedStyle = window.getComputedStyle(el);
const gridTxtColor = computedStyle.getPropertyValue("--surfaceTxtHintColor");
const gridColor = computedStyle.getPropertyValue("--surfaceAlt1Color");
const strokeColor = computedStyle.getPropertyValue("--surfaceAlt4Color");
const fillColor = computedStyle.getPropertyValue("--surfaceAlt2Color");
const opts = {
width: el.clientWidth,
height: getChartHeight(),
legend: {
show: false,
},
cursor: {
x: false,
y: false,
},
scales: {
x: {
range: (self, newMin, newMax) => {
// disallow pan beyond the dataPoints edges
if (newMin < dataPoints[0][0] || newMax > dataPoints[0][dataPoints[0].length - 1]) {
return [self.scales.x.min, self.scales.x.max];
}
// allow zoom
return [newMin, newMax];
},
},
y: {
range: {
min: {
pad: 0.2,
soft: 0,
mode: 1,
},
max: {
pad: 0.2,
soft: 0,
mode: 2,
},
},
},
},
series: [
{},
{
paths: uPlot.paths.stepped({ align: 1 }),
points: {
show: false,
size: 1,
},
width: 2,
fill: fillColor,
stroke: strokeColor,
},
],
axes: [
{
show: true,
size: 35,
lineGap: 1,
space: 60,
stroke: gridTxtColor,
incrs: [
// minutes
60 * 5,
60 * 10,
60 * 15,
60 * 30,
// hours
3600,
3600 * 2,
3600 * 3,
3600 * 4,
3600 * 6,
3600 * 12,
// days
86400,
86400 * 2,
],
// dprint-ignore
values: [
// incr default year month day hour min sec mode
[3600, "{h}{aa}", "\n{MMM} {DD}", null, "\n{MMM} {DD}", null, null, null, 1],
[60, "{h}:{mm}{aa}", "\n{MMM} {DD}", null, "\n{MMM} {DD}", null, null, null, 1],
],
grid: {
show: true,
stroke: gridColor,
width: 1,
},
ticks: {
show: true,
stroke: gridColor,
width: 1,
size: 5,
},
},
{
show: true,
stroke: gridTxtColor,
grid: {
show: true,
stroke: gridColor,
width: 1,
},
ticks: {
show: true,
stroke: gridColor,
width: 1,
size: 5,
},
},
],
plugins: [tooltipsPlugin(), zoomSetPlugin(logsSettings), xPanPlugin(logsSettings)],
};
el._uplot?.destroy();
el._uplot = new uPlot(opts, dataPoints, el);
}
// based on view-source:https://leeoniya.github.io/uPlot/demos/zoom-fetch.html
function zoomSetPlugin(logsSettings) {
let zoomWatcher;
return {
hooks: {
init: (u) => {
u.over.ondblclick = (e) => {
logsSettings.zoom = {};
};
zoomWatcher = watch(() => {
if (!logsSettings.zoom?.min || !logsSettings.zoom?.max) {
resetChartZoom(u);
} else {
u.setScale("x", {
min: logsSettings.zoom.min,
max: logsSettings.zoom.max,
});
}
});
},
destroy: (u) => {
zoomWatcher?.unwatch();
},
setSelect: (u) => {
if (u.select.width > 0) {
logsSettings.zoom = {
min: u.posToVal(u.select.left, "x"),
max: u.posToVal(u.select.left + u.select.width, "x"),
};
}
},
},
};
}
// based on https://leeoniya.github.io/uPlot/demos/y-scale-drag.html
function xPanPlugin(logsSettings) {
let debounceTimeout;
return {
hooks: {
init: (u) => {
let axisElems = u.root.querySelectorAll(".u-axis");
if (!axisElems.length) {
console.warn("xPanPlugin requires x axis to be defined");
return;
}
axisElems[0].addEventListener("mousedown", (e) => {
if (!logsSettings.zoom?.min) {
return; // no zoom
}
let x0 = e.clientX;
let scale = u.scales.x;
let { min, max } = scale;
let dim = u.bbox.width;
let unitsPerPx = (max - min) / (dim / uPlot.pxRatio);
const mousemoveFunc = (e) => {
let diff = x0 - e.clientX;
let shiftBy = diff * unitsPerPx;
u.setScale("x", {
min: min + shiftBy,
max: max + shiftBy,
});
clearTimeout(debounceTimeout);
debounceTimeout = setTimeout(() => {
if (u?.scales?.x) {
logsSettings.zoom = {
min: u.scales.x.min,
max: u.scales.x.max,
};
}
}, 100);
};
const mouseupFunc = (e) => {
document.removeEventListener("mousemove", mousemoveFunc);
document.removeEventListener("mouseup", mouseupFunc);
};
document.addEventListener("mousemove", mousemoveFunc);
document.addEventListener("mouseup", mouseupFunc);
});
},
destroy: (u) => {
if (debounceTimeout) {
clearTimeout(debounceTimeout);
}
},
},
};
}
// based on https://leeoniya.github.io/uPlot/demos/tooltips.html
function tooltipsPlugin(logsSettings) {
let tooltip;
return {
hooks: {
init: (u) => {
const over = u.over;
tooltip = store({
date: "",
total: 0,
left: 0,
top: 0,
show: false,
});
const tooltipEl = t.div(
{
className: () => `chart-tooltip ${tooltip.show ? "" : "hidden"}`,
onmount(el) {
el._positionWatcher?.unwatch();
el._positionWatcher = watch(
() => [tooltip.left, tooltip.top],
() => {
if (!el) {
return;
}
const rect = el.getBoundingClientRect();
let left = tooltip.left;
if (left < 0) {
left = 0;
} else if (left + rect.width > over.clientWidth) {
left = over.clientWidth - rect.width;
}
el.style.left = left + "px";
const vOffset = 5;
let top = tooltip.top - rect.height - vOffset;
if (top < 0) {
top = tooltip.top + vOffset;
if (top + rect.high > over.clientHeight) {
top = over.clientHeight - rect.height;
}
}
el.style.top = top + "px";
},
);
},
onunmount(el) {
el._positionWatcher?.unwatch();
},
},
t.div(
{ className: "content-primary" },
() => `${tooltip.total} ${tooltip.total == 1 ? "request" : "requests"}`,
),
t.div({ className: "content-secondary" }, () => tooltip.date),
);
over.appendChild(tooltipEl);
over.addEventListener("mouseleave", () => {
if (tooltip) {
tooltip.show = false;
}
});
},
destroy: () => {
tooltip.show = false;
},
setCursor: (u) => {
if (!tooltip) {
return;
}
const xVal = u.data[0][u.cursor.idx] || 0;
const yVal = u.data[1][u.cursor.idx] || 0;
// skip zero points
if (xVal == 0 || yVal == 0) {
tooltip.show = false;
return;
}
tooltip.show = true;
tooltip.total = yVal;
const xDateStart = new Date(xVal * 1000);
const xDateEnd = new Date(xVal * 1000 + 3600000); // all stats are hourly based
const monthName = xDateStart.toLocaleString("default", { month: "short" });
const day = xDateStart.getDate().toString().padStart(2, "0");
tooltip.date = `${monthName} ${day} ${dateHour(xDateStart)}-${dateHour(xDateEnd)}`;
tooltip.left = Math.round(u.valToPos(xVal, "x"));
tooltip.top = Math.round(u.valToPos(yVal, "y"));
},
},
};
}
function dateHour(date) {
let hours = date.getHours();
let ampm = hours >= 12 ? "pm" : "am";
// normalize
hours = hours % 12 || 12;
return hours + ampm;
}

468
ui/src/logs/logsList.js Normal file
View File

@@ -0,0 +1,468 @@
import { logLevel } from "./logLevel";
const perPage = 50;
export function logsList(logsSettings) {
const data = store({
logs: [],
lastLoadCount: 0,
lastPage: 1,
bulkSelected: {},
get canLoadMore() {
return data.lastLoadCount >= perPage;
},
get totalSelected() {
return Object.keys(data.bulkSelected).length;
},
get areAllSelected() {
return data.logs.length && data.logs.length == data.totalSelected;
},
});
async function load(reset = false) {
logsSettings.isListLoading = true;
try {
const page = reset ? 1 : data.lastPage + 1;
const normalizedFilter = (logsSettings.presets || []).concat(
app.utils.normalizeSearchFilter(logsSettings.filter, ["level", "message", "data"]),
);
if (logsSettings.zoom?.min && logsSettings.zoom?.max) {
const minDate = new Date(logsSettings.zoom.min * 1000);
minDate.setSeconds(0);
minDate.setMilliseconds(0);
const min = app.utils.toRFC3339Datetime(minDate);
const maxDate = new Date(logsSettings.zoom.max * 1000);
maxDate.setSeconds(59);
maxDate.setMilliseconds(999);
const max = app.utils.toRFC3339Datetime(maxDate);
normalizedFilter.push(`created >= "${min}" && created <= "${max}"`);
} else if (page > 1) {
// minimize duplicates in case there were new logs that push the old ones to later pages
normalizedFilter.push(`created <= "${data.logs[data.logs.length - 1].created}"`);
}
const result = await app.pb.logs.getList(page, perPage, {
skipTotal: 1,
sort: "-@rowid",
requestKey: "logs_list",
filter: normalizedFilter
.filter(Boolean)
.map((f) => "(" + f + ")")
.join("&&"),
});
if (result.page == 1) {
data.logs = [];
data.bulkSelected = {};
}
data.lastPage = result.page;
data.lastLoadCount = result.items.length;
for (let i = 0; i < result.items.length; i++) {
app.utils.pushOrReplaceObject(data.logs, result.items[i]);
// yield to main (with room to "breathe")
if (i > 1 && i % 20 == 0) {
await new Promise((r) => setTimeout(r, 20));
}
}
logsSettings.isListLoading = false;
if (!logsSettings.isFirstLoadReady) {
logsSettings.isFirstLoadReady = true;
}
} catch (err) {
if (!err.isAbort) {
logsSettings.isListLoading = false;
app.checkApiError(err);
}
}
}
// note: allways assign a new object to trigger the getter's Object.keys
function selectAll(state = true) {
const selected = {};
if (state) {
for (let log of data.logs) {
selected[log.id] = log;
}
}
data.bulkSelected = selected;
}
function getLogPreviewKeys(log) {
let keys = [];
if (!log.data) {
return keys;
}
if (log.data.type == "request") {
const requestKeys = ["status", "execTime", "auth", "authId", "userIP"];
for (let key of requestKeys) {
if (typeof log.data[key] != "undefined") {
keys.push({ key });
}
}
// add the referer if it is from a different source
if (log.data.referer && !log.data.referer.includes(window.location.host)) {
keys.push({ key: "referer" });
}
} else {
// extract the first 6 keys (excluding the error and details)
const allKeys = Object.keys(log.data);
for (const key of allKeys) {
if (key != "error" && key != "details" && keys.length < 6) {
keys.push({ key });
}
}
}
// ensure that the error and detail keys are last
if (log.data.error) {
keys.push({ key: "error", label: "danger" });
}
if (log.data.details) {
keys.push({ key: "details", label: "warning" });
}
return keys;
}
const dateFilenameRegex = /[-:\. ]/gi;
function downloadSelected() {
// extract the bulk selected log objects sorted desc
const selected = Object.values(data.bulkSelected).sort((a, b) => {
if (a.created < b.created) {
return 1;
}
if (a.created > b.created) {
return -1;
}
return 0;
});
if (!selected.length) {
return; // nothing to download
}
if (selected.length == 1) {
return app.utils.downloadJSON(
selected[0],
"log_" + selected[0].created.replaceAll(dateFilenameRegex, "") + ".json",
);
}
const to = selected[0].created.replaceAll(dateFilenameRegex, "");
const from = selected[selected.length - 1].created.replaceAll(dateFilenameRegex, "");
return app.utils.downloadJSON(selected, `${selected.length}_logs_${from}_to_${to}.json`);
}
const watchers = [];
return t.div(
{
pbEvent: "logsList",
className: "page-table-wrapper",
onmount(el) {
watchers.push(
// always reset zoom on filter or preset change
watch(
() => [logsSettings.filter, logsSettings.presets?.length],
() => {
logsSettings.zoom = {};
},
),
watch(
() => [
logsSettings.reset,
logsSettings.filter,
logsSettings.presets?.length,
logsSettings.zoom?.min,
logsSettings.zoom?.max,
],
() => {
load(true);
if (el) {
el.scrollTop = 0;
}
},
),
);
},
onunmount() {
watchers.forEach((w) => w?.unwatch());
},
},
t.table(
{ className: () => `logs-table ${data.logs?.length > perPage ? "optimize" : ""}` },
t.thead(
null,
t.tr(
null,
t.th(
{ className: "col-bulk-select" },
t.div(
{
className: "field",
hidden: () => logsSettings.isLoading,
},
t.input({
id: "logs_select_all",
type: "checkbox",
checked: () => data.areAllSelected,
onchange: (e) => selectAll(e.target.checked),
}),
t.label({ htmlFor: "logs_select_all" }),
),
t.span({
className: "loader",
hidden: () => !logsSettings.isLoading,
}),
),
t.th(
{ className: "col-field-name-level" },
t.div(
{ className: "inline-flex gap-5" },
t.i({ className: "ri-bookmark-line" }),
t.span({ textContent: "Level" }),
),
),
t.th(
{ className: "col-field-name-message" },
t.div(
{ className: "inline-flex gap-5" },
t.i({ className: "ri-file-list-2-line" }),
t.span({ textContent: "Message" }),
),
),
t.th(
{ className: "col-field-type-date col-field-name-created" },
t.div(
{ className: "inline-flex gap-5" },
t.i({ className: "ri-calendar-line" }),
t.span({ textContent: "Created" }),
),
),
t.th({ className: "col-meta" }),
),
),
t.tbody(
null,
() => {
if (!data.logs?.length) {
return t.tr(
null,
t.td(
{ colSpan: 99 },
() => {
if (logsSettings.isListLoading) {
return t.span({ className: "skeleton-loader" });
}
return t.div(
{ className: "sticky-content txt-center txt-hint" },
t.p({ className: "txt-bold" }, "No logs found."),
t.button(
{
hidden: () =>
logsSettings.filter?.length
|| app.utils.isEmpty(logsSettings.zoom),
type: "button",
className: "btn secondary expanded-lg",
onclick() {
logsSettings.zoom = {};
},
},
t.span({ className: "txt" }, "Reset zoom"),
),
t.button(
{
hidden: () => !logsSettings.filter?.length,
type: "button",
className: "btn secondary expanded-lg",
onclick() {
logsSettings.filter = "";
},
},
t.span({ className: "txt" }, "Clear search"),
),
);
},
),
);
}
return data.logs.map((log) => {
return t.tr(
{
rid: log.id,
tabIndex: 0,
role: "button",
className: () => `handle ${log.data.type == "request" ? "log-request" : ""}`,
onclick: () => {
logsSettings.activeLogIdOrModel = log;
},
onkeypress: (e) => {
if (e.key == "Enter" || e.key == " ") {
e.preventDefault();
logsSettings.activeLogIdOrModel = log;
}
},
},
() => {
return [
t.td(
{
className: "col-bulk-select",
onclick: (e) => e.stopPropagation(),
onkeypress: (e) => e.stopPropagation(),
},
t.div(
{ className: "field" },
t.input({
id: "cb_" + log.id,
type: "checkbox",
checked: () => !!data.bulkSelected[log.id],
onchange: (e) => {
const bulkSelected = JSON.parse(
JSON.stringify(data.bulkSelected),
);
if (e.target.checked) {
bulkSelected[log.id] = true;
} else {
delete bulkSelected[log.id];
}
// reassign to trigger the getter's Object.keys
data.bulkSelected = bulkSelected;
},
}),
t.label({ htmlFor: "cb_" + log.id }),
),
),
t.td({ className: "col-field-name-level" }, logLevel(log)),
t.td(
{ className: "col-field-name-message" },
t.div(
{ className: "content-primary" },
() => app.utils.truncate(log.message, 1000),
),
t.div(
{
className: "content-secondary",
},
() => {
const labels = [];
const keyItems = getLogPreviewKeys(log);
for (const keyItem of keyItems) {
let value;
if (app.utils.logDataFormatters[keyItem.key]) {
value = app.utils.logDataFormatters[keyItem.key](log);
} else {
value = app.utils.stringifyValue(
log.data[keyItem.key],
"N/A",
80,
);
}
labels.push(
t.span(
{
className: `label sm ${keyItem.label || ""}`,
},
`${keyItem.key}: ${value}`,
),
);
}
return labels;
},
),
),
t.td(
{
className: "col-field-type-date col-field-name-created",
},
app.components.formattedDate({
value: () => log.created,
short: false,
}),
),
t.td(
{ className: "col-meta" },
t.i({ className: "ri-arrow-right-line" }),
),
];
},
);
});
},
t.tr(
{ hidden: () => !data.canLoadMore },
t.td(
{ colSpan: 99 },
t.button(
{
className: () =>
`btn lg secondary load-more-btn ${
logsSettings.isListLoading ? "transparent loading" : ""
}`,
disabled: () => logsSettings.isListLoading,
onclick: () => load(),
},
t.span({ className: "txt" }, "Load older"),
),
),
),
),
),
t.div(
{ className: "bulkbar-wrapper" },
t.div(
{
hidden: () => data.totalSelected == 0,
className: "bulkbar logs-bulkbar",
},
t.span(
{ className: "txt" },
"Selected ",
t.strong(null, () => data.totalSelected),
() => ` ${data.totalSelected == 1 ? "log" : "logs"}`,
),
t.button(
{
type: "button",
className: "btn sm secondary pill m-r-auto",
onclick: () => selectAll(false),
},
t.span({ className: "txt" }, "Reset"),
),
t.button(
{
type: "button",
className: "btn sm pill",
onclick: () => downloadSelected(),
},
t.i({ className: "ri-download-line" }),
t.span({ className: "txt" }, "JSON"),
),
),
),
);
}

View File

@@ -0,0 +1,233 @@
import { defaultLogLevels } from "./defaultLogLevels";
window.app = window.app || {};
window.app.modals = window.app.modals || {};
window.app.modals.openLogsSettings = function(modalSettings = {
onbeforeopen: null,
onafteropen: null,
onbeforeclose: null,
onafterclose: null,
onsave: null,
}) {
const modal = logsSettingsModal(modalSettings);
if (!modal) {
return;
}
document.body.appendChild(modal);
app.modals.open(modal);
};
function logsSettingsModal(modalSettings) {
let modal;
const data = store({
isLoading: false,
isSaving: false,
formSettings: {},
initFormSettingsHash: "",
get hasChanges() {
return data.initFormSettingsHash != JSON.stringify(data.formSettings);
},
});
function init(settings = {}) {
data.formSettings = {
logs: settings?.logs || {},
};
data.initFormSettingsHash = JSON.stringify(data.formSettings);
}
async function loadSettings() {
data.isLoading = true;
try {
const settings = await app.pb.settings.getAll({
requestKey: "logsSettings",
});
init(settings);
data.isLoading = false;
} catch (err) {
if (!err.isAbort) {
data.isLoading = false;
app.checkApiError(err);
}
}
}
async function save() {
if (!data.hasChanges) {
return;
}
data.isSaving = true;
try {
const settings = await app.pb.settings.update(app.utils.filterRedactedProps(data.formSettings));
modalSettings.onsave?.(settings);
init(settings);
app.toasts.success("Successfully saved logs settings.");
data.isSaving = false;
app.modals.close(modal);
} catch (err) {
if (!err.isAbort) {
data.isSaving = false;
app.checkApiError(err);
}
}
}
modal = t.div(
{
pbEvent: "logsSettingsModal",
className: "modal popup sm logs-settings-modal",
onbeforeopen: (el) => {
loadSettings();
return modalSettings.onbeforeopen?.(el);
},
onafteropen: (el) => {
modalSettings.onafteropen?.(el);
},
onbeforeclose: (el) => {
return modalSettings.onbeforeclose?.(el);
},
onafterclose: (el) => {
modalSettings.onafterclose?.(el);
el?.remove();
},
},
t.header({ className: "modal-header" }, t.h5({ className: "m-auto" }, "Logs settings")),
() => {
if (data.isLoading) {
return t.div(
{ className: "modal-content flex", style: "min-height: 200px" },
t.span({ className: "loader m-auto" }),
);
}
return [
t.form(
{
pbEvent: "logsSettingsForm",
id: "logsSettingsForm",
className: "modal-content logs-settings-form",
onsubmit: (e) => {
e.preventDefault();
save();
},
},
t.div(
{ className: "grid" },
t.div(
{ className: "col-lg-12" },
t.field(
{ className: "field" },
t.label({ htmlFor: "logs.maxDays" }, "Max days retention"),
t.input({
type: "number",
id: "logs.maxDays",
name: "logs.maxDays",
min: 0,
required: true,
value: () => data.formSettings.logs.maxDays,
oninput: (e) => (data.formSettings.logs.maxDays = e.target.value << 0),
}),
),
t.div(
{ className: "field-help" },
"Set to ",
t.code(null, 0),
" to disable logs persistence.",
),
),
t.div(
{ className: "col-lg-12" },
t.field(
{ className: "field" },
t.label({ htmlFor: "logs.minLevel" }, "Min log level"),
t.input({
type: "number",
id: "logs.minLevel",
name: "logs.minLevel",
min: -100,
max: 100,
required: true,
value: () => data.formSettings.logs.minLevel,
oninput: (e) => (data.formSettings.logs.minLevel = e.target.value << 0),
}),
),
t.div(
{ className: "field-help" },
t.div(null, "Logs with level below the minimum will be ignored."),
defaultLogLevels(),
),
),
t.div(
{ className: "col-lg-12" },
t.field(
{ className: "field" },
t.input({
type: "checkbox",
id: "logs.logIP",
name: "logs.logIP",
className: "switch",
checked: () => data.formSettings.logs.logIP,
onchange: (e) => (data.formSettings.logs.logIP = e.target.checked),
}),
t.label({ htmlFor: "logs.logIP" }, "Enable IP logging"),
),
),
t.div(
{ className: "col-lg-12" },
t.field(
{ className: "field" },
t.input({
type: "checkbox",
id: "logs.logAuthId",
name: "logs.logAuthId",
className: "switch",
checked: () => data.formSettings.logs.logAuthId,
onchange: (e) => (data.formSettings.logs.logAuthId = e.target.checked),
}),
t.label({ htmlFor: "logs.logAuthId" }, "Enable Auth Id logging"),
),
),
),
),
t.footer(
{ className: "modal-footer" },
t.button(
{
type: "button",
className: "btn transparent m-r-auto",
onclick: () => app.modals.close(modal),
disabled: () => data.isSaving,
},
t.span({ className: "txt" }, "Close"),
),
t.button(
{
type: "submit",
"html-form": "logsSettingsForm",
className: () => `btn ${data.isSaving ? "loading" : ""}`,
disabled: () => !data.hasChanges || data.isSaving,
},
t.span({ className: "txt" }, "Save changes"),
),
),
];
},
);
return modal;
}

179
ui/src/logs/pageLogs.js Normal file
View File

@@ -0,0 +1,179 @@
import { logsChart } from "./logsChart";
import { logsList } from "./logsList";
export function pageLogs(route) {
app.store.title = "Logs";
const LOG_QUERY_KEY = "logId";
const FILTER_QUERY_KEY = "filter";
const SUPERUSER_REQUESTS_QUERY_KEY = "superuserRequests";
const SUPERUSER_REQUESTS_STORAGE_KEY = "pbLogSuperuserRequests";
const withoutSuperusersPresets = "data.auth!='_superusers'";
const queryLogId = route.query[LOG_QUERY_KEY]?.[0] || "";
const queryFilter = route.query[FILTER_QUERY_KEY]?.[0] || "";
const querySuperuserRequests = !!(route.query[SUPERUSER_REQUESTS_QUERY_KEY]?.[0] << 0)
|| !!(window.localStorage.getItem(SUPERUSER_REQUESTS_STORAGE_KEY) << 0);
const logsSettings = store({
reset: null,
isChartLoading: false,
isListLoading: false,
isFirstLoadReady: false, // used by the chart to show itself after the first list load to minimize flickering
zoom: {}, // only unidirectional from within the chart
presets: querySuperuserRequests ? [] : [withoutSuperusersPresets],
filter: queryFilter,
totalFound: null,
activeLogIdOrModel: queryLogId,
get hasIncludeRequestsBySuperusers() {
return !logsSettings.presets.includes(withoutSuperusersPresets);
},
get isLoading() {
return logsSettings.isListLoading || logsSettings.isChartLoading;
},
});
function getLogId(logIdOrModel) {
if (!logIdOrModel) {
return null;
}
return typeof logIdOrModel === "string" ? logIdOrModel : logIdOrModel?.id;
}
function refreshLogsList() {
logsSettings.reset = Date.now();
}
const watchers = [];
return [
t.div(
{ pbEvent: "logsChartContainer", className: "logs-chart-container accent-surface" },
logsChart(logsSettings),
),
t.div(
{
pbEvent: "pageLogs",
className: "page page-logs",
onmount(el) {
watchers.push(
watch(() => {
app.utils.replaceHashQueryParams({
[FILTER_QUERY_KEY]: logsSettings.filter,
});
}),
watch(() => {
const superuserRequests = logsSettings.hasIncludeRequestsBySuperusers ? 1 : 0;
app.utils.replaceHashQueryParams({
[SUPERUSER_REQUESTS_QUERY_KEY]: superuserRequests,
});
window.localStorage.setItem(SUPERUSER_REQUESTS_STORAGE_KEY, superuserRequests);
}),
watch(() => logsSettings.activeLogIdOrModel, () => {
app.utils.replaceHashQueryParams({
[LOG_QUERY_KEY]: getLogId(logsSettings.activeLogIdOrModel),
});
if (logsSettings.activeLogIdOrModel) {
// force close any previous modal
app.modals.close(null, true);
app.modals.openLogPreview(logsSettings.activeLogIdOrModel, {
onafterclose: () => {
logsSettings.activeLogIdOrModel = null;
},
});
}
}),
);
},
onunmount(el) {
clearTimeout(el._chartTiemoutId);
watchers.forEach((w) => w?.unwatch());
},
},
t.div(
{ className: "page-content full-height" },
t.header(
{ className: "page-header" },
t.nav({ className: "breadcrumbs" }, t.div(null, "Logs")),
t.div(
{ className: "inline-flex gap-sm" },
t.button(
{
className: "btn circle transparent secondary tooltip-right",
ariaDescription: app.attrs.tooltip("Logs settings"),
onclick: () =>
app.modals.openLogsSettings({
onsave: () => refreshLogsList(),
}),
},
t.i({ className: "ri-settings-3-line" }),
),
app.components.refreshButton({
onclick: refreshLogsList,
}),
),
app.components.searchbar({
className: "logs-searchbar",
historyKey: "pbLogsSearchHistory",
placeholder: "Search term or filter like `level > 0`",
value: () => logsSettings.filter || "",
onsubmit: (val) => logsSettings.filter = val,
autocomplete: [
"id",
"level",
"message",
"created",
{ value: "data.", label: "data.*" },
],
}),
t.div(
{ className: "meta m-l-auto" },
t.div(
{ className: "field logs-include-superuser-requests" },
t.input({
type: "checkbox",
id: "logs_checkbox",
className: "switch sm",
checked: () => logsSettings.hasIncludeRequestsBySuperusers,
onchange: (e) => {
if (e.target.checked) {
app.utils.removeByValue(logsSettings.presets, withoutSuperusersPresets);
} else {
app.utils.pushUnique(logsSettings.presets, withoutSuperusersPresets);
}
},
}),
t.label(
{ htmlFor: "logs_checkbox" },
t.small({ className: "txt" }, "Include requests by superusers"),
),
),
),
),
logsList(logsSettings),
t.footer(
{ className: "page-footer" },
t.span(
{ className: "txt total-logs" },
"Total: ",
() => {
if (logsSettings.totalFound == null) {
return "...";
}
return logsSettings.totalFound;
},
),
app.components.credits(),
),
),
),
];
}