feat(admin): refactor logs viewer with table display and detailed dialog

Backend changes:
- Update LogsHandler.php to parse Monolog logs using regex
- Add parseLogLines() method to extract structured data from logs
- Support ISO 8601 format with microseconds and timezone
- Parse JSON context with nested objects and escaped characters support
- Add formatDateTime() method for readable date formatting
- Add findJsonEnd() method for correct JSON object extraction
- Return data in JSON format instead of plain string

Frontend changes:
- Update logs.js store: change data structure from string to array of objects
- Add loading flag for loading indicator
- Remove caching check for data freshness

- Completely refactor LogsViewer.vue component:
  * Replace textarea with PrimeVue DataTable with pagination and sorting
  * Add "Actions" column with view button (eye icon)
  * Use Badge component for log levels with color indicators
  * Remove "Context" column from table (unreadable in table view)
  * Add dialog with detailed log information:
    - Date and time (formatted and raw)
    - Level with color indicator
    - Channel
    - Message
    - Context (formatted JSON)
    - Raw string
  * Add word wrap for all text fields in dialog
  * Dialog closes on outside click (dismissableMask)
  * Configure pagination: 15 records per page by default

UX improvements:
- Improved log readability with structured display
- Easy navigation through large number of records via pagination
- Quick access to detailed information through dialog
- Color-coded levels for quick visual assessment
This commit is contained in:
2025-11-24 00:07:12 +03:00
committed by Nikita Kiselev
parent 7a5eebec91
commit b39a344a7d
3 changed files with 328 additions and 11 deletions

View File

@@ -1,15 +1,190 @@
<template>
<textarea v-text="logs.lines" rows="40" class="tw:w-full" readonly/>
<div>
<DataTable
:value="logs.logs"
:loading="logs.loading"
paginator
:rows="15"
:rowsPerPageOptions="[15, 50, 100, 200]"
showGridlines
stripedRows
size="small"
sortField="datetime"
:sortOrder="-1"
removableSort
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
:currentPageReportTemplate="`Показано {first} - {last} из {totalRecords} записей`"
>
<template #header>
<div class="tw:flex tw:items-center tw:justify-between tw:gap-2">
<Button
icon="fa fa-refresh"
@click="logs.fetchLogsFromServer()"
v-tooltip.top="'Обновить журнал'"
size="small"
:loading="logs.loading"
/>
</div>
</template>
<Column header="Действия" :exportable="false" headerStyle="width: 5rem">
<template #body="{ data }">
<Button
icon="fa fa-eye"
severity="secondary"
text
rounded
size="small"
@click="openLogDetails(data)"
v-tooltip.top="'Просмотреть подробности'"
/>
</template>
</Column>
<Column field="datetime" header="Дата и время" sortable style="min-width: 180px">
<template #body="{ data }">
<span v-if="data.datetime">{{ data.datetime }}</span>
<span v-else class="tw:text-gray-400"></span>
</template>
</Column>
<Column field="level" header="Уровень" sortable style="min-width: 100px">
<template #body="{ data }">
<Badge
v-if="data.level"
:value="data.level"
:severity="getLevelSeverity(data.level)"
/>
<span v-else class="tw:text-gray-400"></span>
</template>
</Column>
<Column field="channel" header="Канал" sortable style="min-width: 120px">
<template #body="{ data }">
<span v-if="data.channel">{{ data.channel }}</span>
<span v-else class="tw:text-gray-400"></span>
</template>
</Column>
<Column field="message" header="Сообщение" style="min-width: 300px">
<template #body="{ data }">
<div class="tw:break-words">{{ data.message }}</div>
</template>
</Column>
</DataTable>
<Dialog
v-model:visible="showLogDetailsDialog"
modal
header="Подробности лога"
:style="{ width: '800px', maxWidth: '90vw' }"
:closable="true"
:dismissableMask="true"
>
<div v-if="selectedLog" class="tw:space-y-4">
<div>
<label class="tw:block tw:font-semibold tw:mb-1 tw:text-sm">Дата и время:</label>
<div class="tw:text-sm">
<div v-if="selectedLog.datetime">{{ selectedLog.datetime }}</div>
<div v-if="selectedLog.datetime_raw && selectedLog.datetime_raw !== selectedLog.datetime" class="tw:text-gray-500 tw:text-xs tw:mt-1">
({{ selectedLog.datetime_raw }})
</div>
<span v-if="!selectedLog.datetime" class="tw:text-gray-400"></span>
</div>
</div>
<div>
<label class="tw:block tw:font-semibold tw:mb-1 tw:text-sm">Уровень:</label>
<span
v-if="selectedLog.level"
:class="{
'tw:text-red-600 tw:font-bold': selectedLog.level === 'ERROR' || selectedLog.level === 'CRITICAL',
'tw:text-orange-600': selectedLog.level === 'WARNING',
'tw:text-blue-600': selectedLog.level === 'INFO',
'tw:text-gray-600': selectedLog.level === 'DEBUG',
}"
class="tw:text-sm"
>
{{ selectedLog.level }}
</span>
<span v-else class="tw:text-gray-400 tw:text-sm"></span>
</div>
<div>
<label class="tw:block tw:font-semibold tw:mb-1 tw:text-sm">Канал:</label>
<span v-if="selectedLog.channel" class="tw:text-sm">{{ selectedLog.channel }}</span>
<span v-else class="tw:text-gray-400 tw:text-sm"></span>
</div>
<div>
<label class="tw:block tw:font-semibold tw:mb-1 tw:text-sm">Сообщение:</label>
<div class="tw:text-sm tw:bg-gray-50 tw:p-3 tw:rounded tw:break-words tw:whitespace-pre-wrap">{{ selectedLog.message || '—' }}</div>
</div>
<div v-if="selectedLog.context">
<label class="tw:block tw:font-semibold tw:mb-1 tw:text-sm">Контекст:</label>
<pre class="tw:text-xs tw:bg-gray-100 tw:p-3 tw:rounded tw:overflow-auto tw:max-h-96 tw:border tw:border-gray-200 tw:whitespace-pre-wrap tw:break-words">{{ JSON.stringify(selectedLog.context, null, 2) }}</pre>
</div>
<div>
<label class="tw:block tw:font-semibold tw:mb-1 tw:text-sm">Исходная строка:</label>
<pre class="tw:text-xs tw:bg-gray-100 tw:p-3 tw:rounded tw:overflow-auto tw:max-h-48 tw:border tw:border-gray-200 tw:whitespace-pre-wrap tw:break-words">{{ selectedLog.raw }}</pre>
</div>
</div>
<template #footer>
<Button
label="Закрыть"
icon="fa fa-times"
severity="secondary"
@click="closeLogDetailsDialog"
/>
</template>
</Dialog>
</div>
</template>
<script setup>
import {onMounted} from "vue";
import {useLogsStore} from "@/stores/logs.js";
import { onMounted, ref } from "vue";
import { useLogsStore } from "@/stores/logs.js";
import DataTable from "primevue/datatable";
import Column from "primevue/column";
import Button from "primevue/button";
import Dialog from "primevue/dialog";
import Badge from "primevue/badge";
const logs = useLogsStore();
const showLogDetailsDialog = ref(false);
const selectedLog = ref(null);
function openLogDetails(log) {
selectedLog.value = log;
showLogDetailsDialog.value = true;
}
function closeLogDetailsDialog() {
showLogDetailsDialog.value = false;
selectedLog.value = null;
}
function getLevelSeverity(level) {
switch (level) {
case 'ERROR':
case 'CRITICAL':
return 'danger';
case 'WARNING':
return 'warn';
case 'INFO':
return 'info';
case 'DEBUG':
return 'secondary';
default:
return null;
}
}
onMounted(async () => logs.fetchLogsFromServer());
</script>
<style scoped>
</style>

View File

@@ -3,14 +3,19 @@ import {apiGet} from "@/utils/http.js";
export const useLogsStore = defineStore('logs', {
state: () => ({
lines: '',
logs: [],
loading: false,
}),
actions: {
async fetchLogsFromServer() {
if (this.lines) return;
const response = await apiGet('getLogs');
this.lines = response.data;
this.loading = true;
try {
const response = await apiGet('getLogs');
this.logs = response.data || [];
} finally {
this.loading = false;
}
},
},
});