392 lines
13 KiB
Svelte
392 lines
13 KiB
Svelte
<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 Scroller from "@/components/base/Scroller.svelte";
|
|
import LogLevel from "@/components/logs/LogLevel.svelte";
|
|
import LogDate from "@/components/logs/LogDate.svelte";
|
|
|
|
const dispatch = createEventDispatcher();
|
|
|
|
const perPage = 50;
|
|
|
|
export let filter = "";
|
|
export let presets = "";
|
|
export let sort = "-rowid";
|
|
|
|
let logs = [];
|
|
let currentPage = 1;
|
|
let lastLoadCount = 0;
|
|
let isLoading = false;
|
|
let yieldedId = 0;
|
|
let bulkSelected = {};
|
|
|
|
$: if (typeof sort !== "undefined" || typeof filter !== "undefined" || typeof presets !== "undefined") {
|
|
clearList();
|
|
load(1);
|
|
}
|
|
|
|
$: canLoadMore = lastLoadCount >= perPage;
|
|
|
|
$: totalBulkSelected = Object.keys(bulkSelected).length;
|
|
|
|
$: areAllLogsSelected = logs.length && totalBulkSelected === logs.length;
|
|
|
|
export async function load(page = 1, breakTasks = true) {
|
|
isLoading = true;
|
|
|
|
const normalizedFilter = [presets, CommonHelper.normalizeLogsFilter(filter)]
|
|
.filter(Boolean)
|
|
.join("&&");
|
|
|
|
return ApiClient.logs
|
|
.getList(page, perPage, {
|
|
sort: sort,
|
|
skipTotal: 1,
|
|
filter: normalizedFilter,
|
|
})
|
|
.then(async (result) => {
|
|
if (page <= 1) {
|
|
clearList();
|
|
}
|
|
|
|
isLoading = false;
|
|
currentPage = result.page;
|
|
lastLoadCount = result.items.length;
|
|
dispatch("load", logs.concat(result.items));
|
|
|
|
// optimize the logs listing by rendering the rows in task batches
|
|
if (breakTasks) {
|
|
const currentYieldId = ++yieldedId;
|
|
while (result.items.length) {
|
|
if (yieldedId != currentYieldId) {
|
|
break; // new yeild has been started
|
|
}
|
|
|
|
const subset = result.items.splice(0, 10);
|
|
for (let item of subset) {
|
|
CommonHelper.pushOrReplaceByKey(logs, item);
|
|
}
|
|
|
|
logs = logs;
|
|
|
|
await CommonHelper.yieldToMain();
|
|
}
|
|
} else {
|
|
for (let item of result.items) {
|
|
CommonHelper.pushOrReplaceByKey(logs, item);
|
|
}
|
|
|
|
logs = logs;
|
|
}
|
|
})
|
|
.catch((err) => {
|
|
if (!err?.isAbort) {
|
|
isLoading = false;
|
|
console.warn(err);
|
|
clearList();
|
|
ApiClient.error(err, !normalizedFilter || err?.status != 400); // silence filter errors
|
|
}
|
|
});
|
|
}
|
|
|
|
function clearList() {
|
|
logs = [];
|
|
bulkSelected = {};
|
|
currentPage = 1;
|
|
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`);
|
|
}
|
|
|
|
function getLogPreviewKeys(log) {
|
|
let keys = [];
|
|
|
|
if (!log.data) {
|
|
return keys;
|
|
}
|
|
|
|
if (log.data.type == "request") {
|
|
const requestKeys = ["status", "execTime", "auth", "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: "label-danger" });
|
|
}
|
|
if (log.data.details) {
|
|
keys.push({ key: "details", label: "label-warning" });
|
|
}
|
|
|
|
return keys;
|
|
}
|
|
</script>
|
|
|
|
<Scroller class="table-wrapper">
|
|
<table class="table" class:table-loading={isLoading}>
|
|
<thead>
|
|
<tr>
|
|
<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-bookmark-line" />
|
|
<span class="txt">level</span>
|
|
</div>
|
|
</SortHeader>
|
|
|
|
<SortHeader disable class="col-type-text col-field-message" name="data" bind:sort>
|
|
<div class="col-header-content">
|
|
<i class="ri-file-list-2-line" />
|
|
<span class="txt">message</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>
|
|
</div>
|
|
</SortHeader>
|
|
|
|
<th class="col-type-action min-width" />
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{#each logs as log (log.id)}
|
|
{@const isRequest = log.data?.type == "request"}
|
|
{@const previewKeys = getLogPreviewKeys(log)}
|
|
<tr
|
|
tabindex="0"
|
|
class="row-handle"
|
|
on:click={() => dispatch("select", log)}
|
|
on:keydown={(e) => {
|
|
if (e.code === "Enter") {
|
|
e.preventDefault();
|
|
dispatch("select", log);
|
|
}
|
|
}}
|
|
>
|
|
<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-level min-width">
|
|
<LogLevel level={log.level} />
|
|
</td>
|
|
|
|
<td class="col-type-text col-field-message">
|
|
<div class="flex flex-gap-10">
|
|
<span class="txt-ellipsis">{log.message}</span>
|
|
</div>
|
|
{#if previewKeys.length}
|
|
<div class="flex flex-wrap flex-gap-10 m-t-10">
|
|
{#each previewKeys as keyItem}
|
|
{#if isRequest && keyItem.key == "execTime"}
|
|
<span class="label label-sm">
|
|
{keyItem.key}: {log.data[keyItem.key]}ms
|
|
</span>
|
|
{:else}
|
|
<span class="label label-sm {keyItem.label}">
|
|
{keyItem.key}: {CommonHelper.stringifyValue(
|
|
log.data[keyItem.key],
|
|
"-",
|
|
80
|
|
)}
|
|
</span>
|
|
{/if}
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</td>
|
|
|
|
<td class="col-type-date col-field-created">
|
|
<LogDate date={log.created} />
|
|
</td>
|
|
|
|
<td class="col-type-action min-width">
|
|
<i class="ri-arrow-right-line" />
|
|
</td>
|
|
</tr>
|
|
{:else}
|
|
{#if isLoading}
|
|
<tr>
|
|
<td colspan="99" class="p-xs">
|
|
<span class="skeleton-loader m-0" />
|
|
</td>
|
|
</tr>
|
|
{:else}
|
|
<tr>
|
|
<td colspan="99" class="txt-center txt-hint p-xs">
|
|
<h6>No logs found.</h6>
|
|
{#if filter?.length}
|
|
<button
|
|
type="button"
|
|
class="btn btn-hint btn-expanded m-t-sm"
|
|
on:click={() => (filter = "")}
|
|
>
|
|
<span class="txt">Clear filters</span>
|
|
</button>
|
|
{/if}
|
|
</td>
|
|
</tr>
|
|
{/if}
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
</Scroller>
|
|
|
|
{#if logs.length && canLoadMore}
|
|
<div class="block txt-center m-t-sm">
|
|
<button
|
|
type="button"
|
|
class="btn btn-lg btn-secondary btn-expanded"
|
|
class:btn-loading={isLoading}
|
|
class:btn-disabled={isLoading}
|
|
on:click={() => load(currentPage + 1)}
|
|
>
|
|
<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);
|
|
}
|
|
.col-field-level {
|
|
min-width: 100px;
|
|
}
|
|
.col-field-message {
|
|
min-width: 600px;
|
|
}
|
|
</style>
|