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:
@@ -1,15 +1,190 @@
|
|||||||
<template>
|
<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>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import {onMounted} from "vue";
|
import { onMounted, ref } from "vue";
|
||||||
import {useLogsStore} from "@/stores/logs.js";
|
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 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());
|
onMounted(async () => logs.fetchLogsFromServer());
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -3,14 +3,19 @@ import {apiGet} from "@/utils/http.js";
|
|||||||
|
|
||||||
export const useLogsStore = defineStore('logs', {
|
export const useLogsStore = defineStore('logs', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
lines: '',
|
logs: [],
|
||||||
|
loading: false,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
actions: {
|
actions: {
|
||||||
async fetchLogsFromServer() {
|
async fetchLogsFromServer() {
|
||||||
if (this.lines) return;
|
this.loading = true;
|
||||||
const response = await apiGet('getLogs');
|
try {
|
||||||
this.lines = response.data;
|
const response = await apiGet('getLogs');
|
||||||
|
this.logs = response.data || [];
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,17 +19,154 @@ class LogsHandler
|
|||||||
|
|
||||||
public function getLogs(): JsonResponse
|
public function getLogs(): JsonResponse
|
||||||
{
|
{
|
||||||
$data = [];
|
$parsedLogs = [];
|
||||||
|
|
||||||
$logsPath = $this->findLastLogsFileInDir(
|
$logsPath = $this->findLastLogsFileInDir(
|
||||||
$this->settings->get('logs.path')
|
$this->settings->get('logs.path')
|
||||||
);
|
);
|
||||||
|
|
||||||
if ($logsPath) {
|
if ($logsPath) {
|
||||||
$data = implode(PHP_EOL, $this->readLastLogsRows($logsPath, 100));
|
$lines = $this->readLastLogsRows($logsPath, 100);
|
||||||
|
$parsedLogs = $this->parseLogLines($lines);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new JsonResponse(compact('data'));
|
return new JsonResponse(['data' => $parsedLogs]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parseLogLines(array $lines): array
|
||||||
|
{
|
||||||
|
$parsed = [];
|
||||||
|
|
||||||
|
// Регулярка для формата Monolog с ISO 8601 датой: [YYYY-MM-DDTHH:MM:SS.microseconds+timezone] channel.LEVEL: message {context} [extra]
|
||||||
|
// Пример: [2025-11-23T14:28:21.772518+00:00] TeleCart.ERROR: Invalid Telegram Signature. {"exception":"..."} []
|
||||||
|
// Поддерживает также формат без контекста и extra: [2025-11-23T14:28:21.772518+00:00] TeleCart.INFO: Message text
|
||||||
|
$pattern = '/^\[([^\]]+)\]\s+([^.]+)\.(\w+):\s+(.+)$/s';
|
||||||
|
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
$line = trim($line);
|
||||||
|
if (empty($line)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match($pattern, $line, $matches)) {
|
||||||
|
$datetime = $matches[1] ?? '';
|
||||||
|
$channel = $matches[2] ?? '';
|
||||||
|
$level = $matches[3] ?? '';
|
||||||
|
$rest = $matches[4] ?? '';
|
||||||
|
|
||||||
|
// Извлекаем сообщение и контекст
|
||||||
|
// Контекст начинается с { и заканчивается соответствующим }
|
||||||
|
$message = $rest;
|
||||||
|
$context = null;
|
||||||
|
|
||||||
|
// Ищем JSON контекст (начинается с {, может быть после пробела или сразу)
|
||||||
|
$jsonStart = strpos($rest, ' {');
|
||||||
|
if ($jsonStart === false) {
|
||||||
|
$jsonStart = strpos($rest, '{');
|
||||||
|
} else {
|
||||||
|
$jsonStart++; // Пропускаем пробел перед {
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($jsonStart !== false) {
|
||||||
|
$message = trim(substr($rest, 0, $jsonStart));
|
||||||
|
$jsonPart = substr($rest, $jsonStart);
|
||||||
|
|
||||||
|
// Находим конец JSON объекта, учитывая вложенность
|
||||||
|
$jsonEnd = $this->findJsonEnd($jsonPart);
|
||||||
|
if ($jsonEnd !== false) {
|
||||||
|
$contextJson = substr($jsonPart, 0, $jsonEnd + 1);
|
||||||
|
$decoded = json_decode($contextJson, true);
|
||||||
|
if (json_last_error() === JSON_ERROR_NONE) {
|
||||||
|
$context = $decoded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Форматируем дату для отображения (убираем микросекунды и временную зону для читаемости)
|
||||||
|
$formattedDatetime = $this->formatDateTime($datetime);
|
||||||
|
|
||||||
|
$parsed[] = [
|
||||||
|
'datetime' => $formattedDatetime,
|
||||||
|
'datetime_raw' => $datetime,
|
||||||
|
'channel' => $channel,
|
||||||
|
'level' => $level,
|
||||||
|
'message' => $message,
|
||||||
|
'context' => $context,
|
||||||
|
'raw' => $line,
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
// Если строка не соответствует формату, сохраняем как есть
|
||||||
|
$parsed[] = [
|
||||||
|
'datetime' => '',
|
||||||
|
'datetime_raw' => '',
|
||||||
|
'channel' => '',
|
||||||
|
'level' => '',
|
||||||
|
'message' => $line,
|
||||||
|
'context' => null,
|
||||||
|
'raw' => $line,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Находит позицию конца JSON объекта, учитывая вложенность
|
||||||
|
* @param string $json JSON строка, начинающаяся с {
|
||||||
|
* @return int|false Позиция закрывающей скобки или false, если не найдено
|
||||||
|
*/
|
||||||
|
private function findJsonEnd(string $json)
|
||||||
|
{
|
||||||
|
$depth = 0;
|
||||||
|
$inString = false;
|
||||||
|
$escape = false;
|
||||||
|
$len = strlen($json);
|
||||||
|
|
||||||
|
for ($i = 0; $i < $len; $i++) {
|
||||||
|
$char = $json[$i];
|
||||||
|
|
||||||
|
if ($escape) {
|
||||||
|
$escape = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($char === '\\') {
|
||||||
|
$escape = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($char === '"') {
|
||||||
|
$inString = !$inString;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($inString) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($char === '{') {
|
||||||
|
$depth++;
|
||||||
|
} elseif ($char === '}') {
|
||||||
|
$depth--;
|
||||||
|
if ($depth === 0) {
|
||||||
|
return $i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function formatDateTime(string $datetime): string
|
||||||
|
{
|
||||||
|
// Парсим ISO 8601 формат: 2025-11-23T14:28:21.772518+00:00
|
||||||
|
// Преобразуем в более читаемый формат: 2025-11-23 14:28:21
|
||||||
|
if (preg_match('/^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})/', $datetime, $dateMatches)) {
|
||||||
|
return $dateMatches[1] . ' ' . $dateMatches[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $datetime;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function readLastLogsRows(string $path, int $lines = 1000, int $buffer = 4096): array
|
private function readLastLogsRows(string $path, int $lines = 1000, int $buffer = 4096): array
|
||||||
|
|||||||
Reference in New Issue
Block a user