merge newui branch
This commit is contained in:
13
ui/src/logs/defaultLogLevels.js
Normal file
13
ui/src/logs/defaultLogLevels.js
Normal 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
8
ui/src/logs/logLevel.js
Normal 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})`;
|
||||
}),
|
||||
);
|
||||
}
|
||||
276
ui/src/logs/logPreviewModal.js
Normal file
276
ui/src/logs/logPreviewModal.js
Normal 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
482
ui/src/logs/logsChart.js
Normal 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
468
ui/src/logs/logsList.js
Normal 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"),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
233
ui/src/logs/logsSettingsModal.js
Normal file
233
ui/src/logs/logsSettingsModal.js
Normal 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
179
ui/src/logs/pageLogs.js
Normal 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(),
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user