Files
dbgate/packages/web/src/tabs/AppLogTab.svelte
2025-11-24 16:48:29 +01:00

528 lines
16 KiB
Svelte

<script lang="ts" context="module">
export const matchingProps = [];
</script>
<script lang="ts">
import _ from 'lodash';
import ToolStripContainer from '../buttons/ToolStripContainer.svelte';
import ToolStripButton from '../buttons/ToolStripButton.svelte';
import { apiCall, apiOff, apiOn } from '../utility/api';
import { format, startOfDay, endOfDay } from 'date-fns';
import { getIntSettingsValue } from '../settings/settingsTools';
import DateRangeSelector from '../elements/DateRangeSelector.svelte';
import Chip from '../elements/Chip.svelte';
import TabControl from '../elements/TabControl.svelte';
import Link from '../elements/Link.svelte';
import SelectField from '../forms/SelectField.svelte';
import { onDestroy, onMount, tick } from 'svelte';
import DropDownButton from '../buttons/DropDownButton.svelte';
import { showModal } from '../modals/modalTools';
import ValueLookupModal from '../modals/ValueLookupModal.svelte';
import { createLogCompoudCondition } from 'dbgate-sqltree';
import { exportQuickExportFile } from '../utility/exportFileTools';
import ToolStripExportButton, {
createQuickExportHandlerRef,
registerQuickExportHandler,
} from '../buttons/ToolStripExportButton.svelte';
import { _t } from '../translations';
let loadedRows = [];
let loadedAll = false;
let domLoadNext;
let observer;
let dateFilter = [new Date(), new Date()];
let selectedLogIndex = null;
let filters = {};
let mode = 'recent';
let autoScroll = true;
let domTable;
let jslid;
const quickExportHandlerRef = createQuickExportHandlerRef();
function formatPossibleUuid(value) {
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (_.isString(value) && value.match(uuidRegex)) {
return value.slice(0, 8);
}
if (value == null) {
return 'N/A';
}
return value;
}
async function loadNextRows() {
const pageSize = getIntSettingsValue('dataGrid.pageSize', 100, 5, 50000);
const rows = await apiCall('jsldata/get-rows', {
jslid,
offset: loadedRows.length,
limit: pageSize,
filters: createLogCompoudCondition(
filters,
'time',
startOfDay(dateFilter[0]).getTime(),
endOfDay(dateFilter[1]).getTime()
),
});
loadedRows = [...loadedRows, ...rows];
if (rows.length < 10) {
loadedAll = true;
}
}
function startObserver(dom) {
if (observer) {
observer.disconnect();
observer = null;
}
if (dom) {
observer = new IntersectionObserver(entries => {
if (entries.find(x => x.isIntersecting)) {
loadNextRows();
}
});
observer.observe(dom);
}
}
$: if (mode == 'date') {
startObserver(domLoadNext);
}
async function reloadData(createNewJslId = true) {
switch (mode) {
case 'recent':
loadedRows = await apiCall('files/get-recent-app-log', { limit: 100 });
await tick();
scrollToRecent();
break;
case 'date':
if (createNewJslId) {
const resp = await apiCall('files/fill-app-logs', {
dateFrom: startOfDay(dateFilter[0]).getTime(),
dateTo: endOfDay(dateFilter[1]).getTime(),
});
jslid = resp.jslid;
}
loadedRows = [];
loadedAll = false;
break;
}
}
function doSetFilter(field, values) {
filters = {
...filters,
[field]: values,
};
reloadData(false);
}
const ColumnNamesMap = {
msgcode: 'Code',
};
function handleLogMessage(msg) {
// console.log('AppLogTab: handleLogMessage', msg);
if (mode !== 'recent') return;
if (loadedRows.find(x => x.counter == msg.counter)) {
return; // Already loaded
}
loadedRows = [...loadedRows, msg];
scrollToRecent();
}
function scrollToRecent() {
if (autoScroll && domTable) {
domTable.scrollTop = domTable.scrollHeight;
}
}
function filterBy(field) {
showModal(ValueLookupModal, {
jslid,
field,
multiselect: true,
onConfirm: values => {
doSetFilter(field, values);
},
});
}
onMount(() => {
apiOn('applog-event', handleLogMessage);
reloadData();
});
onDestroy(() => {
apiOff('applog-event', handleLogMessage);
});
const quickExportHandler = fmt => async () => {
const resp =
mode == 'recent'
? await apiCall('files/fill-app-logs', {
dateFrom: startOfDay(new Date()).getTime(),
dateTo: endOfDay(new Date()).getTime(),
prepareForExport: true,
})
: await apiCall('files/fill-app-logs', {
dateFrom: startOfDay(dateFilter[0]).getTime(),
dateTo: endOfDay(dateFilter[1]).getTime(),
prepareForExport: true,
});
exportQuickExportFile(
'Log',
{
functionName: 'jslDataReader',
props: {
jslid: resp.jslid,
},
},
fmt
);
};
registerQuickExportHandler(quickExportHandler);
</script>
<ToolStripContainer>
<div class="wrapper classicform">
<div class="filters">
<div class="filter-label">Mode:</div>
<SelectField
isNative
options={[
{ label: _t('logs.recentLogs', { defaultMessage: 'Recent logs' }), value: 'recent' },
{ label: _t('logs.chooseDate', { defaultMessage: 'Choose date' }), value: 'date' },
]}
value={mode}
on:change={e => {
mode = e.detail;
reloadData();
}}
/>
{#if mode === 'recent'}
<div class="filter-label ml-2">{_t('logs.autoScroll', { defaultMessage: 'Auto-scroll' })}</div>
<input
type="checkbox"
checked={autoScroll}
on:change={e => {
autoScroll = e.target['checked'];
}}
/>
{/if}
{#if mode === 'date'}
<div class="filter-label">{_t('logs.date', { defaultMessage: 'Date:' })}</div>
<DateRangeSelector
onChange={value => {
dateFilter = value;
reloadData();
}}
/>
<div class="ml-2">
<DropDownButton
data-testid="AdminAuditLogTab_addFilter"
icon="icon filter"
menu={[
{ text: _t('logs.connectionId', { defaultMessage: 'Connection ID' }), onClick: () => filterBy('conid') },
{ text: _t('logs.database', { defaultMessage: 'Database' }), onClick: () => filterBy('database') },
{ text: _t('logs.engine', { defaultMessage: 'Engine' }), onClick: () => filterBy('engine') },
{ text: _t('logs.messageCode', { defaultMessage: 'Message code' }), onClick: () => filterBy('msgcode') },
{ text: _t('logs.caller', { defaultMessage: 'Caller' }), onClick: () => filterBy('caller') },
{ text: _t('logs.name', { defaultMessage: 'Name' }), onClick: () => filterBy('name') },
]}
/>
</div>
{#each Object.keys(filters) as filterKey}
<div class="ml-2">
<span class="filter-label">{ColumnNamesMap[filterKey] || filterKey}:</span>
{#each filters[filterKey] as value}
<Chip
onClose={() => {
filters = { ...filters, [filterKey]: filters[filterKey].filter(x => x !== value) };
if (!filters[filterKey].length) {
filters = _.omit(filters, filterKey);
}
reloadData(false);
}}
>
{formatPossibleUuid(value)}
</Chip>
{/each}
</div>
{/each}
{/if}
</div>
<div class="tablewrap" bind:this={domTable}>
<table>
<thead>
<tr>
<th style="width:80px">{_t('logs.dateTab', { defaultMessage: 'Date' })}</th>
<th>{_t('logs.timeTab', { defaultMessage: 'Time' })}</th>
<th>{_t('logs.codeTab', { defaultMessage: 'Code' })}</th>
<th>{_t('logs.messageTab', { defaultMessage: 'Message' })}</th>
<th>{_t('logs.connectionTab', { defaultMessage: 'Connection' })}</th>
<th>{_t('logs.databaseTab', { defaultMessage: 'Database' })}</th>
<th>{_t('logs.engineTab', { defaultMessage: 'Engine' })}</th>
<th>{_t('logs.callerTab', { defaultMessage: 'Caller' })}</th>
<th>{_t('logs.nameTab', { defaultMessage: 'Name' })}</th>
</tr>
</thead>
<tbody>
{#each loadedRows as row, index}
<tr
class="clickable"
on:click={() => {
if (selectedLogIndex === index) {
selectedLogIndex = null;
} else {
selectedLogIndex = index;
}
}}
>
<td>{row.time ? format(new Date(parseInt(row.time)), 'yyyy-MM-dd') : ''}</td>
<td>{row.time ? format(new Date(parseInt(row.time)), 'HH:mm:ss') : ''}</td>
<td>{row.msgcode || ''}</td>
<td>{row.msg}</td>
<td>{formatPossibleUuid(row.conid) || ''}</td>
<td>{row.database || ''}</td>
<td>{row.engine?.includes('@') ? row.engine.split('@')[0] : row.engine || ''}</td>
<td>{row.caller || ''}</td>
<td>{row.name || ''}</td>
</tr>
{#if index === selectedLogIndex}
<tr>
<td colspan="9">
<TabControl
isInline
tabs={_.compact([
{ label: _t('logs.details', { defaultMessage: 'Details' }), slot: 1 },
{ label: 'JSON', slot: 2 },
])}
>
<svelte:fragment slot="1">
<div class="details-wrap">
<div class="row">
<div>{_t('logs.messageCode', { defaultMessage: 'Message code:' })}</div>
{#if mode == 'date'}
<Link onClick={() => doSetFilter('msgcode', [row.msgcode])}>{row.msgcode || 'N/A'}</Link>
{:else}
{row.msgcode || 'N/A'}
{/if}
</div>
<div class="row">
<div>{_t('logs.message', { defaultMessage: 'Message:' })}</div>
{row.msg}
</div>
<div class="row">
<div>{_t('logs.time', { defaultMessage: 'Time:' })}</div>
<b>{row.time ? format(new Date(parseInt(row.time)), 'yyyy-MM-dd HH:mm:ss') : ''}</b>
</div>
<div class="row">
<div>{_t('logs.caller', { defaultMessage: 'Caller:' })}</div>
{#if mode == 'date'}
<Link onClick={() => doSetFilter('caller', [row.caller])}>{row.caller || 'N/A'}</Link>
{:else}
{row.caller || 'N/A'}
{/if}
</div>
<div class="row">
<div>{_t('logs.name', { defaultMessage: 'Name:' })}</div>
{#if mode == 'date'}
<Link onClick={() => doSetFilter('name', [row.name])}>{row.name || 'N/A'}</Link>
{:else}
{row.name || 'N/A'}
{/if}
</div>
{#if row.conid}
<div class="row">
<div>{_t('logs.connectionId', { defaultMessage: 'Connection ID:' })}</div>
{#if mode == 'date'}
<Link onClick={() => doSetFilter('conid', [row.conid])}
>{formatPossibleUuid(row.conid)}</Link
>
{:else}
{formatPossibleUuid(row.conid)}
{/if}
</div>
{/if}
{#if row.database}
<div class="row">
<div>{_t('logs.database', { defaultMessage: 'Database:' })}</div>
{#if mode == 'date'}
<Link onClick={() => doSetFilter('database', [row.database])}>{row.database}</Link>
{:else}
{row.database}
{/if}
</div>
{/if}
{#if row.engine}
<div class="row">
<div>{_t('logs.engine', { defaultMessage: 'Engine:' })}</div>
{#if mode == 'date'}
<Link onClick={() => doSetFilter('engine', [row.engine])}>{row.engine}</Link>
{:else}
{row.engine}
{/if}
</div>
{/if}
</div></svelte:fragment
>
<svelte:fragment slot="2">
<pre>{JSON.stringify(row, null, 2)}</pre>
</svelte:fragment>
</TabControl>
</td>
</tr>
{/if}
{/each}
{#if !loadedRows?.length && mode === 'date'}
<tr>
<td colspan="6">{_t('logs.noDataForSelectedDate', { defaultMessage: "No data for selected date" })}</td>
</tr>
{/if}
{#if !loadedAll && mode === 'date'}
{#key loadedRows}
<tr>
<td colspan="6" bind:this={domLoadNext}>{_t('logs.loadingNextRows', { defaultMessage: "Loading next rows..." })}</td>
</tr>
{/key}
{/if}
</tbody>
</table>
</div>
</div>
<svelte:fragment slot="toolstrip">
<ToolStripButton
icon="icon refresh"
data-testid="AdminAuditLogTab_refreshButton"
on:click={() => {
reloadData();
}}>{_t('logs.refresh', { defaultMessage: 'Refresh' })}</ToolStripButton
>
<ToolStripExportButton {quickExportHandlerRef} />
</svelte:fragment>
</ToolStripContainer>
<style>
.editor-wrap {
height: 200px;
}
.tablewrap {
overflow: auto;
flex: 1;
}
.wrapper {
flex: 1;
display: flex;
flex-direction: column;
}
table.disableFocusOutline:focus {
outline: none;
}
table {
border-collapse: collapse;
width: 100%;
}
table.selectable {
user-select: none;
}
tbody tr {
background: var(--theme-bg-0);
}
tbody tr.selected {
background: var(--theme-bg-3);
}
table:focus tbody tr.selected {
background: var(--theme-bg-selected);
}
tbody tr.clickable:hover {
background: var(--theme-bg-hover);
}
thead th {
border: 1px solid var(--theme-border);
background-color: var(--theme-bg-1);
padding: 5px;
}
tbody td {
border: 1px solid var(--theme-border);
}
tbody td {
padding: 5px;
}
td.isHighlighted {
background-color: var(--theme-bg-1);
}
td.clickable {
cursor: pointer;
}
thead {
position: sticky;
top: 0;
z-index: 1;
border-top: 1px solid var(--theme-border);
}
table th {
border-left: none;
}
thead :global(tr:first-child) :global(th) {
border-top: 1px solid var(--theme-border);
}
table td {
border: 0px;
border-bottom: 1px solid var(--theme-border);
border-right: 1px solid var(--theme-border);
}
table {
border-spacing: 0;
border-collapse: separate;
border-left: 1px solid var(--theme-border);
}
.empty-cell {
background-color: var(--theme-bg-1);
}
.filters {
display: flex;
align-items: center;
flex-wrap: wrap;
}
.filter-label {
margin-right: 5px;
color: var(--theme-font-2);
}
.details-wrap {
padding: 10px;
display: flex;
flex-direction: column;
}
.details-wrap .row {
display: flex;
}
.details-wrap .row div:first-child {
width: 150px;
}
pre {
overflow: auto;
max-width: 50vw;
}
</style>