logs refactoring
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
<script>
|
||||
import tooltip from "@/actions/tooltip";
|
||||
import CommonHelper from "@/utils/CommonHelper";
|
||||
|
||||
export let date;
|
||||
|
||||
const tooltipData = {
|
||||
// generate the tooltip text as getter to speed up the initial load
|
||||
// in case the component is used with large number of items
|
||||
get text() {
|
||||
return CommonHelper.formatToLocalDate(date, "yyyy-MM-dd HH:mm:ss.SSS") + " Local";
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<span class="txt-nowrap" use:tooltip={tooltipData}>
|
||||
{date.replace("Z", " UTC")}
|
||||
</span>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script>
|
||||
import { logLevels } from "@/utils/CommonHelper";
|
||||
|
||||
export let level;
|
||||
|
||||
$: label = logLevels.find((l) => l.level == level)?.label;
|
||||
</script>
|
||||
|
||||
<div class="label log-level-label level-{level}">
|
||||
<span class="txt">
|
||||
{label || "N/A"} ({level})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.log-level-label {
|
||||
min-width: 75px;
|
||||
font-weight: 600;
|
||||
font-size: var(--xsFontSize);
|
||||
&:before {
|
||||
content: "";
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 5px;
|
||||
background: var(--baseAlt4Color);
|
||||
}
|
||||
&.level--8:before {
|
||||
background: var(--primaryColor);
|
||||
}
|
||||
&.level-0:before {
|
||||
background: var(--infoColor);
|
||||
}
|
||||
&.level-4:before {
|
||||
background: var(--warningColor);
|
||||
}
|
||||
&.level-8:before {
|
||||
background: var(--dangerColor);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,14 +1,22 @@
|
||||
<script>
|
||||
import CommonHelper from "@/utils/CommonHelper";
|
||||
import CodeBlock from "@/components/base/CodeBlock.svelte";
|
||||
import FormattedDate from "@/components/base/FormattedDate.svelte";
|
||||
import OverlayPanel from "@/components/base/OverlayPanel.svelte";
|
||||
import CopyIcon from "@/components/base/CopyIcon.svelte";
|
||||
import LogLevel from "@/components/logs/LogLevel.svelte";
|
||||
import LogDate from "@/components/logs/LogDate.svelte";
|
||||
|
||||
let logPanel;
|
||||
let item = {};
|
||||
let log = {};
|
||||
|
||||
$: hasData = !CommonHelper.isEmpty(log.data);
|
||||
|
||||
export function show(model) {
|
||||
item = model;
|
||||
if (CommonHelper.isEmpty(model)) {
|
||||
return;
|
||||
}
|
||||
|
||||
log = model;
|
||||
|
||||
return logPanel?.show();
|
||||
}
|
||||
@@ -16,8 +24,54 @@
|
||||
export function hide() {
|
||||
return logPanel?.hide();
|
||||
}
|
||||
|
||||
const priotizedKeys = [
|
||||
"execTime",
|
||||
"type",
|
||||
"auth",
|
||||
"status",
|
||||
"method",
|
||||
"url",
|
||||
"referer",
|
||||
"remoteIp",
|
||||
"userIp",
|
||||
"error",
|
||||
"details",
|
||||
//
|
||||
];
|
||||
|
||||
function extractKeys(data) {
|
||||
if (!data) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let keys = [];
|
||||
|
||||
for (let key of priotizedKeys) {
|
||||
if (typeof data[key] !== "undefined") {
|
||||
keys.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
// append the rest
|
||||
const original = Object.keys(data);
|
||||
for (let key of original) {
|
||||
if (!keys.includes(key)) {
|
||||
keys.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
function downloadJson() {
|
||||
CommonHelper.downloadJson(log, "log_" + log.created.replaceAll(/[-:\. ]/gi, "") + ".json");
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<OverlayPanel bind:this={logPanel} class="overlay-panel-lg log-panel" on:hide on:show>
|
||||
<svelte:fragment slot="header">
|
||||
<h4>Request log</h4>
|
||||
@@ -26,69 +80,53 @@
|
||||
<table class="table-border">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="min-width txt-hint txt-bold">ID</td>
|
||||
<td>{item.id}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="min-width txt-hint txt-bold">Status</td>
|
||||
<td class="min-width txt-hint txt-bold">id</td>
|
||||
<td>
|
||||
<span class="label" class:label-danger={item.status >= 400}>
|
||||
{item.status}
|
||||
</span>
|
||||
<div class="label">
|
||||
<CopyIcon value={log.id} />
|
||||
<div class="txt">{log.id}</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="min-width txt-hint txt-bold">Method</td>
|
||||
<td>{item.method?.toUpperCase()}</td>
|
||||
<td class="min-width txt-hint txt-bold">level</td>
|
||||
<td><LogLevel level={log.level} /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="min-width txt-hint txt-bold">Auth</td>
|
||||
<td>{item.auth}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="min-width txt-hint txt-bold">URL</td>
|
||||
<td>{item.url}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="min-width txt-hint txt-bold">Referer</td>
|
||||
<td>
|
||||
{#if item.referer}
|
||||
<a href={item.referer} target="_blank" rel="noopener noreferrer">
|
||||
{item.referer}
|
||||
</a>
|
||||
{:else}
|
||||
<span class="txt-hint">N/A</span>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="min-width txt-hint txt-bold">Remote IP</td>
|
||||
<td>{item.remoteIp}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="min-width txt-hint txt-bold">User IP</td>
|
||||
<td>{item.userIp}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="min-width txt-hint txt-bold">UserAgent</td>
|
||||
<td>{item.userAgent}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="min-width txt-hint txt-bold">Meta</td>
|
||||
<td>
|
||||
{#if !CommonHelper.isEmpty(item.meta)}
|
||||
<div class="block">
|
||||
<CodeBlock content={JSON.stringify(item.meta, null, 2)} />
|
||||
</div>
|
||||
{:else}
|
||||
<span class="txt-hint">N/A</span>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="min-width txt-hint txt-bold">Created</td>
|
||||
<td><FormattedDate date={item.created} /></td>
|
||||
<td class="min-width txt-hint txt-bold">created</td>
|
||||
<td><LogDate date={log.created} /></td>
|
||||
</tr>
|
||||
{#if log.data?.type != "request"}
|
||||
<tr>
|
||||
<td class="min-width txt-hint txt-bold">message</td>
|
||||
<td>
|
||||
{#if log.message}
|
||||
<span class="txt">{log.message}</span>
|
||||
{:else}
|
||||
<span class="txt txt-hint">N/A</span>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{#each extractKeys(log.data) as key}
|
||||
{@const value = log.data[key]}
|
||||
<tr>
|
||||
<td class="min-width txt-hint txt-bold" class:v-align-top={hasData}>
|
||||
data.{key}
|
||||
</td>
|
||||
<td>
|
||||
{#if value !== null && typeof value == "object"}
|
||||
<CodeBlock content={JSON.stringify(value, null, 2)} />
|
||||
{:else if CommonHelper.isEmpty(value)}
|
||||
<span class="txt txt-hint">N/A</span>
|
||||
{:else}
|
||||
<span class="txt">
|
||||
{value}{key == "execTime" ? "ms" : ""}
|
||||
</span>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -96,5 +134,10 @@
|
||||
<button type="button" class="btn btn-transparent" on:click={() => hide()}>
|
||||
<span class="txt">Close</span>
|
||||
</button>
|
||||
|
||||
<button type="button" class="btn btn-primary" on:click={() => downloadJson()}>
|
||||
<i class="ri-download-line" />
|
||||
<span class="txt">Download as JSON</span>
|
||||
</button>
|
||||
</svelte:fragment>
|
||||
</OverlayPanel>
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
import { onMount } from "svelte";
|
||||
import { scale } from "svelte/transition";
|
||||
import ApiClient from "@/utils/ApiClient";
|
||||
import CommonHelper from "@/utils/CommonHelper";
|
||||
import {
|
||||
Chart,
|
||||
LineElement,
|
||||
PointElement,
|
||||
LineController,
|
||||
BarController,
|
||||
BarElement,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
TimeScale,
|
||||
Filler,
|
||||
@@ -20,7 +21,7 @@
|
||||
let chartCanvas;
|
||||
let chartInst;
|
||||
let chartData = [];
|
||||
let totalRequests = 0;
|
||||
let totalLogs = 0;
|
||||
let isLoading = false;
|
||||
|
||||
$: if (typeof filter !== "undefined" || typeof presets !== "undefined") {
|
||||
@@ -36,24 +37,19 @@
|
||||
isLoading = true;
|
||||
|
||||
return ApiClient.logs
|
||||
.getRequestsStats({
|
||||
filter: [presets, filter].filter(Boolean).join("&&"),
|
||||
.getStats({
|
||||
filter: [presets, CommonHelper.normalizeLogsFilter(filter)].filter(Boolean).join("&&"),
|
||||
})
|
||||
.then((result) => {
|
||||
resetData();
|
||||
|
||||
for (let item of result) {
|
||||
chartData.push({
|
||||
x: new Date(item.date),
|
||||
y: item.total,
|
||||
});
|
||||
totalRequests += item.total;
|
||||
totalLogs += item.total;
|
||||
}
|
||||
|
||||
// add current time marker to the chart
|
||||
chartData.push({
|
||||
x: new Date(),
|
||||
y: undefined,
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!err?.isAbort) {
|
||||
@@ -68,31 +64,31 @@
|
||||
}
|
||||
|
||||
function resetData() {
|
||||
totalRequests = 0;
|
||||
chartData = [];
|
||||
totalLogs = 0;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
Chart.register(LineElement, PointElement, LineController, LinearScale, TimeScale, Filler, Tooltip);
|
||||
Chart.register(BarController, BarElement, CategoryScale, LinearScale, TimeScale, Filler, Tooltip);
|
||||
|
||||
chartInst = new Chart(chartCanvas, {
|
||||
type: "line",
|
||||
type: "bar",
|
||||
data: {
|
||||
datasets: [
|
||||
{
|
||||
label: "Total requests",
|
||||
data: chartData,
|
||||
borderColor: "#ef4565",
|
||||
pointBackgroundColor: "#ef4565",
|
||||
backgroundColor: "rgb(239,69,101,0.05)",
|
||||
borderWidth: 2,
|
||||
pointRadius: 1,
|
||||
pointBorderWidth: 0,
|
||||
fill: true,
|
||||
backgroundColor: "#e34562",
|
||||
maxBarThickness: 40,
|
||||
borderRadius: 2,
|
||||
minBarLength: 7,
|
||||
hoverBackgroundColor: "#e34562",
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
resizeDelay: 250,
|
||||
maintainAspectRatio: false,
|
||||
animation: false,
|
||||
interaction: {
|
||||
intersect: false,
|
||||
@@ -103,25 +99,28 @@
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
color: "#edf0f3",
|
||||
borderColor: "#dee3e8",
|
||||
},
|
||||
border: {
|
||||
color: "#e4e9ec",
|
||||
},
|
||||
ticks: {
|
||||
precision: 0,
|
||||
maxTicksLimit: 6,
|
||||
maxTicksLimit: 4,
|
||||
autoSkip: true,
|
||||
color: "#666f75",
|
||||
},
|
||||
},
|
||||
x: {
|
||||
// offset: false,
|
||||
type: "time",
|
||||
time: {
|
||||
unit: "hour",
|
||||
tooltipFormat: "DD h a",
|
||||
},
|
||||
grid: {
|
||||
borderColor: "#dee3e8",
|
||||
color: (c) => (c.tick.major ? "#edf0f3" : ""),
|
||||
color: (c) => (c.tick?.major ? "#edf0f3" : ""),
|
||||
},
|
||||
color: "#e4e9ec",
|
||||
ticks: {
|
||||
maxTicksLimit: 15,
|
||||
autoSkip: true,
|
||||
@@ -129,7 +128,7 @@
|
||||
major: {
|
||||
enabled: true,
|
||||
},
|
||||
color: (c) => (c.tick.major ? "#16161a" : "#666f75"),
|
||||
color: (c) => (c.tick?.major ? "#16161a" : "#666f75"),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -146,19 +145,14 @@
|
||||
</script>
|
||||
|
||||
<div class="chart-wrapper" class:loading={isLoading}>
|
||||
<div class="total-logs entrance-right" class:hidden={isLoading}>
|
||||
Found {totalLogs}
|
||||
{totalLogs == 1 ? "log" : "logs"}
|
||||
</div>
|
||||
{#if isLoading}
|
||||
<div class="chart-loader loader" transition:scale={{ duration: 150 }} />
|
||||
{/if}
|
||||
<canvas bind:this={chartCanvas} class="chart-canvas" style="height: 250px; width: 100%;" />
|
||||
</div>
|
||||
|
||||
<div class="txt-hint m-t-xs txt-right">
|
||||
{#if isLoading}
|
||||
Loading...
|
||||
{:else}
|
||||
{totalRequests}
|
||||
{totalRequests === 1 ? "log" : "logs"}
|
||||
{/if}
|
||||
<canvas bind:this={chartCanvas} class="chart-canvas" />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@@ -166,6 +160,7 @@
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 170px;
|
||||
}
|
||||
.chart-wrapper.loading .chart-canvas {
|
||||
pointer-events: none;
|
||||
@@ -178,4 +173,11 @@
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
.total-logs {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: -50px;
|
||||
font-size: var(--smFontSize);
|
||||
color: var(--txtHintColor);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<script>
|
||||
import { logLevels } from "@/utils/CommonHelper";
|
||||
|
||||
let classes = "";
|
||||
export { classes as class }; // export reserved keyword
|
||||
</script>
|
||||
|
||||
<div class={classes}>
|
||||
Default log levels:
|
||||
<div class="inline-flex flex-gap-5">
|
||||
{#each logLevels as options}
|
||||
<code class="txt-xs">{options.level}:{options.label}</code>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,43 +1,47 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import { fly } from "svelte/transition";
|
||||
import ApiClient from "@/utils/ApiClient";
|
||||
import CommonHelper from "@/utils/CommonHelper";
|
||||
import SortHeader from "@/components/base/SortHeader.svelte";
|
||||
import FormattedDate from "@/components/base/FormattedDate.svelte";
|
||||
import Scroller from "@/components/base/Scroller.svelte";
|
||||
import LogLevel from "@/components/logs/LogLevel.svelte";
|
||||
import LogDate from "@/components/logs/LogDate.svelte";
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
const labelMethodClass = {
|
||||
get: "label-info",
|
||||
post: "label-success",
|
||||
patch: "label-warning",
|
||||
delete: "label-danger",
|
||||
};
|
||||
|
||||
const perPage = 50;
|
||||
|
||||
export let filter = "";
|
||||
export let presets = "";
|
||||
export let sort = "-rowid";
|
||||
|
||||
let items = [];
|
||||
let logs = [];
|
||||
let currentPage = 1;
|
||||
let totalItems = 0;
|
||||
let lastLoadCount = 0;
|
||||
let isLoading = false;
|
||||
let yieldedItemsId = 0;
|
||||
let yieldedId = 0;
|
||||
let bulkSelected = {};
|
||||
|
||||
$: if (typeof sort !== "undefined" || typeof filter !== "undefined" || typeof presets !== "undefined") {
|
||||
clearList();
|
||||
load(1);
|
||||
}
|
||||
|
||||
$: canLoadMore = totalItems > items.length;
|
||||
$: canLoadMore = lastLoadCount >= perPage;
|
||||
|
||||
$: totalBulkSelected = Object.keys(bulkSelected).length;
|
||||
|
||||
$: areAllLogsSelected = logs.length && totalBulkSelected === logs.length;
|
||||
|
||||
export async function load(page = 1, breakTasks = true) {
|
||||
isLoading = true;
|
||||
|
||||
return ApiClient.logs
|
||||
.getRequestsList(page, 30, {
|
||||
.getList(page, perPage, {
|
||||
sort: sort,
|
||||
filter: [presets, filter].filter(Boolean).join("&&"),
|
||||
skipTotal: 1,
|
||||
filter: [presets, CommonHelper.normalizeLogsFilter(filter)].filter(Boolean).join("&&"),
|
||||
})
|
||||
.then(async (result) => {
|
||||
if (page <= 1) {
|
||||
@@ -46,23 +50,32 @@
|
||||
|
||||
isLoading = false;
|
||||
currentPage = result.page;
|
||||
totalItems = result.totalItems;
|
||||
dispatch("load", items.concat(result.items));
|
||||
lastLoadCount = result.items.length;
|
||||
dispatch("load", logs.concat(result.items));
|
||||
|
||||
// optimize the items listing by rendering the rows in task batches
|
||||
// optimize the logs listing by rendering the rows in task batches
|
||||
if (breakTasks) {
|
||||
const currentYieldId = ++yieldedItemsId;
|
||||
const currentYieldId = ++yieldedId;
|
||||
while (result.items.length) {
|
||||
if (yieldedItemsId != currentYieldId) {
|
||||
if (yieldedId != currentYieldId) {
|
||||
break; // new yeild has been started
|
||||
}
|
||||
|
||||
items = items.concat(result.items.splice(0, 10));
|
||||
const subset = result.items.splice(0, 10);
|
||||
for (let item of subset) {
|
||||
CommonHelper.pushOrReplaceByKey(logs, item);
|
||||
}
|
||||
|
||||
logs = logs;
|
||||
|
||||
await CommonHelper.yieldToMain();
|
||||
}
|
||||
} else {
|
||||
items = items.concat(result.items);
|
||||
for (let item of result.items) {
|
||||
CommonHelper.pushOrReplaceByKey(logs, item);
|
||||
}
|
||||
|
||||
logs = logs;
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -76,9 +89,73 @@
|
||||
}
|
||||
|
||||
function clearList() {
|
||||
items = [];
|
||||
logs = [];
|
||||
bulkSelected = {};
|
||||
currentPage = 1;
|
||||
totalItems = 0;
|
||||
lastLoadCount = 0;
|
||||
}
|
||||
|
||||
function toggleSelectAllLogs() {
|
||||
if (areAllLogsSelected) {
|
||||
deselectAllLogs();
|
||||
} else {
|
||||
selectAllLogs();
|
||||
}
|
||||
}
|
||||
|
||||
function deselectAllLogs() {
|
||||
bulkSelected = {};
|
||||
}
|
||||
|
||||
function selectAllLogs() {
|
||||
for (const log of logs) {
|
||||
bulkSelected[log.id] = log;
|
||||
}
|
||||
|
||||
bulkSelected = bulkSelected;
|
||||
}
|
||||
|
||||
function toggleSelectLog(log) {
|
||||
if (!bulkSelected[log.id]) {
|
||||
bulkSelected[log.id] = log;
|
||||
} else {
|
||||
delete bulkSelected[log.id];
|
||||
}
|
||||
|
||||
bulkSelected = bulkSelected; // trigger reactivity
|
||||
}
|
||||
|
||||
const dateFilenameRegex = /[-:\. ]/gi;
|
||||
|
||||
function downloadSelected() {
|
||||
// extract the bulk selected log objects sorted desc
|
||||
const selected = Object.values(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 CommonHelper.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 CommonHelper.downloadJson(selected, `${selected.length}_logs_${from}_to_${to}.json`);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -86,45 +163,41 @@
|
||||
<table class="table" class:table-loading={isLoading}>
|
||||
<thead>
|
||||
<tr>
|
||||
<SortHeader disable class="col-field-method" name="method" bind:sort>
|
||||
<th class="bulk-select-col min-width">
|
||||
{#if isLoading}
|
||||
<span class="loader loader-sm" />
|
||||
{:else}
|
||||
<div class="form-field">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="checkbox_0"
|
||||
disabled={!logs.length}
|
||||
checked={areAllLogsSelected}
|
||||
on:change={() => toggleSelectAllLogs()}
|
||||
/>
|
||||
<label for="checkbox_0" />
|
||||
</div>
|
||||
{/if}
|
||||
</th>
|
||||
|
||||
<SortHeader disable class="col-field-level min-width" name="level" bind:sort>
|
||||
<div class="col-header-content">
|
||||
<i class="ri-global-line" />
|
||||
<span class="txt">Method</span>
|
||||
<i class="ri-bookmark-line" />
|
||||
<span class="txt">level</span>
|
||||
</div>
|
||||
</SortHeader>
|
||||
|
||||
<SortHeader disable class="col-type-text col-field-url" name="url" bind:sort>
|
||||
<SortHeader disable class="col-type-text col-field-data" name="data" bind:sort>
|
||||
<div class="col-header-content">
|
||||
<i class={CommonHelper.getFieldTypeIcon("url")} />
|
||||
<span class="txt">URL</span>
|
||||
</div>
|
||||
</SortHeader>
|
||||
|
||||
<SortHeader disable class="col-type-text col-field-referer" name="referer" bind:sort>
|
||||
<div class="col-header-content">
|
||||
<i class={CommonHelper.getFieldTypeIcon("url")} />
|
||||
<span class="txt">Referer</span>
|
||||
</div>
|
||||
</SortHeader>
|
||||
|
||||
<SortHeader disable class="col-type-number col-field-userIp" name="userIp" bind:sort>
|
||||
<div class="col-header-content">
|
||||
<i class={CommonHelper.getFieldTypeIcon("number")} />
|
||||
<span class="txt">User IP</span>
|
||||
</div>
|
||||
</SortHeader>
|
||||
|
||||
<SortHeader disable class="col-type-number col-field-status" name="status" bind:sort>
|
||||
<div class="col-header-content">
|
||||
<i class={CommonHelper.getFieldTypeIcon("number")} />
|
||||
<span class="txt">Status</span>
|
||||
<i class="ri-file-list-2-line" />
|
||||
<span class="txt">data</span>
|
||||
</div>
|
||||
</SortHeader>
|
||||
|
||||
<SortHeader disable class="col-type-date col-field-created" name="created" bind:sort>
|
||||
<div class="col-header-content">
|
||||
<i class={CommonHelper.getFieldTypeIcon("date")} />
|
||||
<span class="txt">Created</span>
|
||||
<span class="txt">created</span>
|
||||
</div>
|
||||
</SortHeader>
|
||||
|
||||
@@ -132,53 +205,78 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each items as item (item.id)}
|
||||
{#each logs as log (log.id)}
|
||||
{@const hasData = log.data && CommonHelper.isObject(log.data)}
|
||||
<tr
|
||||
tabindex="0"
|
||||
class="row-handle"
|
||||
on:click={() => dispatch("select", item)}
|
||||
on:click={() => dispatch("select", log)}
|
||||
on:keydown={(e) => {
|
||||
if (e.code === "Enter") {
|
||||
e.preventDefault();
|
||||
dispatch("select", item);
|
||||
dispatch("select", log);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<td class="col-type-text col-field-method min-width">
|
||||
<span class="label txt-uppercase {labelMethodClass[item.method.toLowerCase()]}">
|
||||
{item.method?.toUpperCase()}
|
||||
</span>
|
||||
<td class="bulk-select-col min-width">
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div class="form-field" on:click|stopPropagation>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="checkbox_{log.id}"
|
||||
checked={bulkSelected[log.id]}
|
||||
on:change={() => toggleSelectLog(log)}
|
||||
/>
|
||||
<label for="checkbox_{log.id}" />
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td class="col-type-text col-field-url">
|
||||
<span class="txt txt-ellipsis" title={item.url}>
|
||||
{item.url}
|
||||
</span>
|
||||
{#if item.meta?.errorMessage || item.meta?.errorData}
|
||||
<i class="ri-error-warning-line txt-danger m-l-5 m-r-5" title="Error" />
|
||||
<td class="col-type-text col-field-level min-width">
|
||||
<LogLevel level={log.level} />
|
||||
</td>
|
||||
|
||||
<td class="col-type-text col-field-data">
|
||||
<div class="flex flex-gap-10">
|
||||
{#if log.message}
|
||||
<span class="txt-ellipsis">{log.message}</span>
|
||||
{/if}
|
||||
|
||||
{#if hasData}
|
||||
{#if log.data.status}
|
||||
<span class="label label-sm">{log.data.status}</span>
|
||||
{/if}
|
||||
{#if log.data.execTime}
|
||||
<span class="label label-sm">{log.data.execTime}ms</span>
|
||||
{/if}
|
||||
{#if log.data.auth}
|
||||
<span class="label label-sm">{log.data.auth}</span>
|
||||
{/if}
|
||||
{#if log.data.userIp}
|
||||
<span class="label label-sm">{log.data.userIp}</span>
|
||||
{/if}
|
||||
{#if log.data.error}
|
||||
<span class="label label-sm label-danger">
|
||||
{CommonHelper.truncate(
|
||||
typeof log.data.error === "string"
|
||||
? log.data.error
|
||||
: JSON.stringify(log.data.error),
|
||||
200
|
||||
)}
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if hasData}
|
||||
<div class="block txt-mono txt-xs txt-hint txt-ellipsis m-t-5">
|
||||
{CommonHelper.truncate(JSON.stringify(log.data), 350)}
|
||||
</div>
|
||||
{/if}
|
||||
</td>
|
||||
|
||||
<td class="col-type-text col-field-referer">
|
||||
<span class="txt txt-ellipsis" class:txt-hint={!item.referer} title={item.referer}>
|
||||
{item.referer || "N/A"}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td class="col-type-number col-field-userIp">
|
||||
<span class="txt txt-ellipsis" class:txt-hint={!item.userIp} title={item.userIp}>
|
||||
{item.userIp || "N/A"}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td class="col-type-number col-field-status">
|
||||
<span class="label" class:label-danger={item.status >= 400}>
|
||||
{item.status}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td class="col-type-date col-field-created">
|
||||
<FormattedDate date={item.created} />
|
||||
<LogDate date={log.created} />
|
||||
</td>
|
||||
|
||||
<td class="col-type-action min-width">
|
||||
@@ -213,12 +311,8 @@
|
||||
</table>
|
||||
</Scroller>
|
||||
|
||||
{#if items.length}
|
||||
<small class="block txt-hint txt-right m-t-sm">Showing {items.length} of {totalItems}</small>
|
||||
{/if}
|
||||
|
||||
{#if items.length && canLoadMore}
|
||||
<div class="block txt-center m-t-xs">
|
||||
{#if logs.length && canLoadMore}
|
||||
<div class="block txt-center m-t-sm">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-lg btn-secondary btn-expanded"
|
||||
@@ -226,7 +320,35 @@
|
||||
class:btn-disabled={isLoading}
|
||||
on:click={() => load(currentPage + 1)}
|
||||
>
|
||||
<span class="txt">Load more ({totalItems - items.length})</span>
|
||||
<span class="txt">Load more</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if totalBulkSelected}
|
||||
<div class="bulkbar" transition:fly={{ duration: 150, y: 5 }}>
|
||||
<div class="txt">
|
||||
Selected <strong>{totalBulkSelected}</strong>
|
||||
{totalBulkSelected === 1 ? "log" : "logs"}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-transparent btn-outline p-l-5 p-r-5"
|
||||
on:click={() => deselectAllLogs()}
|
||||
>
|
||||
<span class="txt">Reset</span>
|
||||
</button>
|
||||
<div class="flex-fill" />
|
||||
<button type="button" class="btn btn-sm" on:click={downloadSelected}>
|
||||
<span class="txt">Download as JSON</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.bulkbar {
|
||||
position: sticky;
|
||||
margin-top: var(--smSpacing);
|
||||
bottom: var(--baseSpacing);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import CommonHelper from "@/utils/CommonHelper";
|
||||
import ApiClient from "@/utils/ApiClient";
|
||||
import { setErrors } from "@/stores/errors";
|
||||
import { addSuccessToast } from "@/stores/toasts";
|
||||
import tooltip from "@/actions/tooltip";
|
||||
import Field from "@/components/base/Field.svelte";
|
||||
import OverlayPanel from "@/components/base/OverlayPanel.svelte";
|
||||
import LogsLevelsInfo from "@/components/logs/LogsLevelsInfo.svelte";
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
const formId = "logs_settings_" + CommonHelper.randomString(3);
|
||||
|
||||
let panel;
|
||||
let isSaving = false;
|
||||
let isLoading = false;
|
||||
let originalFormSettings = {};
|
||||
let formSettings = {};
|
||||
|
||||
$: initialHash = JSON.stringify(originalFormSettings);
|
||||
|
||||
$: hasChanges = initialHash != JSON.stringify(formSettings);
|
||||
|
||||
export function show() {
|
||||
reset();
|
||||
|
||||
loadSettings();
|
||||
|
||||
return panel?.show();
|
||||
}
|
||||
|
||||
export function hide() {
|
||||
return panel?.hide();
|
||||
}
|
||||
|
||||
function reset() {
|
||||
setErrors();
|
||||
originalFormSettings = {};
|
||||
formSettings = JSON.parse(JSON.stringify(originalFormSettings || {}));
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
isLoading = true;
|
||||
|
||||
try {
|
||||
const settings = (await ApiClient.settings.getAll()) || {};
|
||||
init(settings);
|
||||
} catch (err) {
|
||||
ApiClient.error(err);
|
||||
}
|
||||
|
||||
isLoading = false;
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!hasChanges) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSaving = true;
|
||||
|
||||
try {
|
||||
const settings = await ApiClient.settings.update(CommonHelper.filterRedactedProps(formSettings));
|
||||
init(settings);
|
||||
|
||||
isSaving = false;
|
||||
|
||||
hide();
|
||||
|
||||
addSuccessToast("Successfully saved logs settings.");
|
||||
|
||||
dispatch("save", settings);
|
||||
} catch (err) {
|
||||
isSaving = false;
|
||||
ApiClient.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
function init(settings = {}) {
|
||||
formSettings = {
|
||||
logs: settings?.logs || {},
|
||||
};
|
||||
|
||||
originalFormSettings = JSON.parse(JSON.stringify(formSettings));
|
||||
}
|
||||
</script>
|
||||
|
||||
<OverlayPanel bind:this={panel} popup class="admin-panel" beforeHide={() => !isSaving} on:hide on:show>
|
||||
<svelte:fragment slot="header">
|
||||
<h4>Logs settings</h4>
|
||||
</svelte:fragment>
|
||||
|
||||
{#if isLoading}
|
||||
<div class="block txt-center">
|
||||
<div class="loader" />
|
||||
</div>
|
||||
{:else}
|
||||
<form id={formId} class="grid" autocomplete="off" on:submit|preventDefault={save}>
|
||||
<Field class="form-field required" name="logs.maxDays" let:uniqueId>
|
||||
<label for={uniqueId}>Max days retention</label>
|
||||
<input type="number" id={uniqueId} required bind:value={formSettings.logs.maxDays} />
|
||||
<div class="help-block">
|
||||
Set to <code>0</code> to disable logs persistence.
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<Field class="form-field" name="logs.minLevel" let:uniqueId>
|
||||
<label for={uniqueId}>Min log level</label>
|
||||
<input type="number" required bind:value={formSettings.logs.minLevel} min="-100" max="100" />
|
||||
<div class="help-block">
|
||||
<p>Logs with level below the minimum will be ignored.</p>
|
||||
<LogsLevelsInfo />
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<Field class="form-field form-field-toggle" name="logs.logIp" let:uniqueId>
|
||||
<input type="checkbox" id={uniqueId} bind:checked={formSettings.logs.logIp} />
|
||||
<label for={uniqueId}>Enable IP logging</label>
|
||||
</Field>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<svelte:fragment slot="footer">
|
||||
<button type="button" class="btn btn-transparent" disabled={isSaving} on:click={hide}>
|
||||
<span class="txt">Cancel</span>
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
form={formId}
|
||||
class="btn btn-expanded"
|
||||
class:btn-loading={isSaving}
|
||||
disabled={!hasChanges || isSaving}
|
||||
>
|
||||
<span class="txt">Save changes</span>
|
||||
</button>
|
||||
</svelte:fragment>
|
||||
</OverlayPanel>
|
||||
@@ -1,6 +1,8 @@
|
||||
<script>
|
||||
import { querystring } from "svelte-spa-router";
|
||||
import { pageTitle } from "@/stores/app";
|
||||
import CommonHelper from "@/utils/CommonHelper";
|
||||
import tooltip from "@/actions/tooltip";
|
||||
import Field from "@/components/base/Field.svelte";
|
||||
import PageWrapper from "@/components/base/PageWrapper.svelte";
|
||||
import Searchbar from "@/components/base/Searchbar.svelte";
|
||||
@@ -8,39 +10,47 @@
|
||||
import LogsList from "@/components/logs/LogsList.svelte";
|
||||
import LogsChart from "@/components/logs/LogsChart.svelte";
|
||||
import LogViewPanel from "@/components/logs/LogViewPanel.svelte";
|
||||
import LogsSettingsPanel from "@/components/logs/LogsSettingsPanel.svelte";
|
||||
import LogsLevelsInfo from "@/components/logs/LogsLevelsInfo.svelte";
|
||||
|
||||
const ADMIN_LOGS_LOCAL_STORAGE_KEY = "includeAdminLogs";
|
||||
$pageTitle = "Logs";
|
||||
|
||||
const autoCompleteKeys = [
|
||||
"method",
|
||||
"url",
|
||||
"remoteIp",
|
||||
"userIp",
|
||||
"referer",
|
||||
"status",
|
||||
"auth",
|
||||
"userAgent",
|
||||
"created",
|
||||
];
|
||||
const ADMIN_REQUESTS_QUERY_KEY = "adminRequests";
|
||||
const ADMIN_REQUESTS_STORAGE_KEY = "adminLogRequests";
|
||||
|
||||
$pageTitle = "Request logs";
|
||||
const initialQueryParams = new URLSearchParams($querystring);
|
||||
|
||||
let logPanel;
|
||||
let filter = "";
|
||||
let includeAdminLogs = window.localStorage?.getItem(ADMIN_LOGS_LOCAL_STORAGE_KEY) << 0;
|
||||
let logViewPanel;
|
||||
let logsSettingsPanel;
|
||||
let refreshKey = 1;
|
||||
let filter = initialQueryParams.get("filter") || "";
|
||||
let withAdminLogs =
|
||||
(initialQueryParams.get(ADMIN_REQUESTS_QUERY_KEY) ||
|
||||
window.localStorage?.getItem(ADMIN_REQUESTS_STORAGE_KEY)) << 0;
|
||||
let initialWithAdminLogs = withAdminLogs;
|
||||
|
||||
$: presets = !includeAdminLogs ? 'auth!="admin"' : "";
|
||||
$: presets = !withAdminLogs ? 'data.auth!="admin"' : "";
|
||||
|
||||
$: if (typeof includeAdminLogs !== "undefined" && window.localStorage) {
|
||||
window.localStorage.setItem(ADMIN_LOGS_LOCAL_STORAGE_KEY, includeAdminLogs << 0);
|
||||
$: if (initialWithAdminLogs != withAdminLogs) {
|
||||
initialWithAdminLogs = withAdminLogs;
|
||||
window.localStorage?.setItem(ADMIN_REQUESTS_STORAGE_KEY, withAdminLogs << 0);
|
||||
updateQueryParams();
|
||||
}
|
||||
|
||||
$: normalizedFilter = CommonHelper.normalizeSearchFilter(filter, autoCompleteKeys);
|
||||
$: if (typeof filter !== "undefined") {
|
||||
updateQueryParams();
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
refreshKey++;
|
||||
}
|
||||
|
||||
function updateQueryParams(extra = {}) {
|
||||
let queryParams = {};
|
||||
queryParams.filter = filter || null;
|
||||
queryParams[ADMIN_REQUESTS_QUERY_KEY] = withAdminLogs << 0 || null;
|
||||
CommonHelper.replaceHashQueryParams(Object.assign(queryParams, extra));
|
||||
}
|
||||
</script>
|
||||
|
||||
<PageWrapper>
|
||||
@@ -50,13 +60,23 @@
|
||||
<div class="breadcrumb-item">{$pageTitle}</div>
|
||||
</nav>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Logs settings"
|
||||
class="btn btn-transparent btn-circle"
|
||||
use:tooltip={{ text: "Logs settings", position: "right" }}
|
||||
on:click={() => logsSettingsPanel?.show()}
|
||||
>
|
||||
<i class="ri-settings-4-line" />
|
||||
</button>
|
||||
|
||||
<RefreshButton on:refresh={() => refresh()} />
|
||||
|
||||
<div class="flex-fill" />
|
||||
|
||||
<div class="inline-flex">
|
||||
<Field class="form-field form-field-toggle m-0" let:uniqueId>
|
||||
<input type="checkbox" id={uniqueId} bind:checked={includeAdminLogs} />
|
||||
<input type="checkbox" id={uniqueId} bind:checked={withAdminLogs} />
|
||||
<label for={uniqueId}>Include requests by admins</label>
|
||||
</Field>
|
||||
</div>
|
||||
@@ -64,21 +84,22 @@
|
||||
|
||||
<Searchbar
|
||||
value={filter}
|
||||
placeholder="Search term or filter like status >= 400"
|
||||
extraAutocompleteKeys={autoCompleteKeys}
|
||||
placeholder="Search term or filter like `level > 0 && data.auth = 'guest'`"
|
||||
extraAutocompleteKeys={["level", "message", "data."]}
|
||||
on:submit={(e) => (filter = e.detail)}
|
||||
/>
|
||||
|
||||
<div class="clearfix m-b-base" />
|
||||
<LogsLevelsInfo class="block txt-sm txt-hint m-t-xs m-b-base" />
|
||||
|
||||
{#key refreshKey}
|
||||
<LogsChart filter={normalizedFilter} {presets} />
|
||||
<LogsChart {filter} {presets} />
|
||||
{/key}
|
||||
</div>
|
||||
|
||||
{#key refreshKey}
|
||||
<LogsList filter={normalizedFilter} {presets} on:select={(e) => logPanel?.show(e?.detail)} />
|
||||
<LogsList bind:filter {presets} on:select={(e) => logViewPanel?.show(e?.detail)} />
|
||||
{/key}
|
||||
</PageWrapper>
|
||||
|
||||
<LogViewPanel bind:this={logPanel} />
|
||||
<LogViewPanel bind:this={logViewPanel} />
|
||||
|
||||
<LogsSettingsPanel bind:this={logsSettingsPanel} on:save={refresh} />
|
||||
|
||||
@@ -197,12 +197,19 @@
|
||||
break; // new yield has been started
|
||||
}
|
||||
|
||||
records = records.concat(result.items.splice(0, 20));
|
||||
const subset = result.items.splice(0, 20);
|
||||
for (let item of subset) {
|
||||
CommonHelper.pushOrReplaceByKey(records, item);
|
||||
}
|
||||
records = records;
|
||||
|
||||
await CommonHelper.yieldToMain();
|
||||
}
|
||||
} else {
|
||||
records = records.concat(result.items);
|
||||
for (let item of result.items) {
|
||||
CommonHelper.pushOrReplaceByKey(records, item);
|
||||
}
|
||||
records = records;
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -453,7 +460,7 @@
|
||||
<div class="flex flex-gap-5">
|
||||
<div class="label">
|
||||
<CopyIcon value={record.id} />
|
||||
<div class="txt">{record.id}</div>
|
||||
<div class="txt txt-ellipsis">{record.id}</div>
|
||||
</div>
|
||||
|
||||
{#if isAuth}
|
||||
|
||||
@@ -59,7 +59,6 @@
|
||||
|
||||
formSettings = {
|
||||
meta: settings?.meta || {},
|
||||
logs: settings?.logs || {},
|
||||
};
|
||||
|
||||
originalFormSettings = JSON.parse(JSON.stringify(formSettings));
|
||||
@@ -105,11 +104,6 @@
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field class="form-field required" name="logs.maxDays" let:uniqueId>
|
||||
<label for={uniqueId}>Logs max days retention</label>
|
||||
<input type="number" id={uniqueId} required bind:value={formSettings.logs.maxDays} />
|
||||
</Field>
|
||||
|
||||
<Field class="form-field form-field-toggle" name="meta.hideControls" let:uniqueId>
|
||||
<input type="checkbox" id={uniqueId} bind:checked={formSettings.meta.hideControls} />
|
||||
<label for={uniqueId}>
|
||||
@@ -126,6 +120,7 @@
|
||||
|
||||
<div class="col-lg-12 flex">
|
||||
<div class="flex-fill" />
|
||||
|
||||
{#if hasChanges}
|
||||
<button
|
||||
type="button"
|
||||
|
||||
Reference in New Issue
Block a user