Teren-app/resources/js/Pages/Imports/Partials/LogsTable.vue
Simon Pocrnjič dea7432deb changes
2025-12-26 22:39:58 +01:00

390 lines
14 KiB
Vue

<script setup>
import { ref, computed } from "vue";
import Dropdown from "@/Components/Dropdown.vue";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/Components/ui/table';
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/Components/ui/select';
import { Button } from '@/Components/ui/button';
import { Badge } from '@/Components/ui/badge';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/Components/ui/dialog';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/Components/ui/accordion';
const props = defineProps({
events: Array,
loading: Boolean,
limit: Number,
});
const emits = defineEmits(["update:limit", "refresh"]);
function onLimit(val) {
emits("update:limit", Number(val));
emits("refresh");
}
// Level filter (all | error | warning | info/other)
const levelFilter = ref("all");
const levelOptions = [
{ value: "all", label: "All" },
{ value: "error", label: "Error" },
{ value: "warning", label: "Warning" },
{ value: "info", label: "Info / Other" },
];
const filteredEvents = computed(() => {
if (levelFilter.value === "all") return props.events || [];
if (levelFilter.value === "info") {
return (props.events || []).filter(
(e) => e.level !== "error" && e.level !== "warning"
);
}
return (props.events || []).filter((e) => e.level === levelFilter.value);
});
// Expanded state per event id
const expanded = ref(new Set());
function isExpanded(id) {
return expanded.value.has(id);
}
function toggleExpand(id) {
if (expanded.value.has(id)) {
expanded.value.delete(id);
} else {
expanded.value.add(id);
}
expanded.value = new Set(expanded.value);
}
// Entity details dialog
const detailsDialog = ref(false);
const selectedEvent = ref(null);
function hasEntityDetails(ev) {
const ctx = tryJson(ev.context);
return ctx && Array.isArray(ctx.entity_details) && ctx.entity_details.length > 0;
}
function showEntityDetails(ev) {
selectedEvent.value = ev;
detailsDialog.value = true;
}
function getEntityDetails(ev) {
if (!ev) return [];
const ctx = tryJson(ev.context);
return ctx?.entity_details || [];
}
function getRawData(ev) {
if (!ev) return {};
const ctx = tryJson(ev.context);
return ctx?.raw_data || {};
}
function isLong(msg) {
return msg && String(msg).length > 160;
}
function shortMsg(msg) {
if (!msg) return "";
const s = String(msg);
return s.length <= 160 ? s : s.slice(0, 160) + "…";
}
function tryJson(val) {
if (val == null) return null;
if (typeof val === "object") return val;
if (typeof val === "string") {
const t = val.trim();
if (
(t.startsWith("{") && t.endsWith("}")) ||
(t.startsWith("[") && t.endsWith("]"))
) {
try {
return JSON.parse(t);
} catch {
return null;
}
}
}
return null;
}
function contextPreview(ctx) {
if (!ctx) return "";
const obj = tryJson(ctx) || ctx;
let str = typeof obj === "string" ? obj : JSON.stringify(obj);
if (str.length > 60) str = str.slice(0, 60) + "…";
return str;
}
// JSON formatting & lightweight syntax highlight
function htmlEscape(s) {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function prettyJson(val) {
const obj = tryJson(val);
if (!obj) {
return htmlEscape(typeof val === "string" ? val : String(val ?? ""));
}
try {
return htmlEscape(JSON.stringify(obj, null, 2));
} catch {
return htmlEscape(String(val ?? ""));
}
}
function highlightJson(val) {
const src = prettyJson(val);
return src.replace(
/(\"([^"\\]|\\.)*\"\s*:)|(\"([^"\\]|\\.)*\")|\b(true|false|null)\b|-?\b\d+(?:\.\d+)?(?:[eE][+\-]?\d+)?/g,
(match) => {
if (/^\"([^"\\]|\\.)*\"\s*:/.test(match)) {
return `<span class=\"text-indigo-600\">${match}</span>`; // key
}
if (/^\"/.test(match)) {
return `<span class=\"text-emerald-700\">${match}</span>`; // string
}
if (/true|false/.test(match)) {
return `<span class=\"text-orange-600 font-medium\">${match}</span>`; // boolean
}
if (/null/.test(match)) {
return `<span class=\"text-gray-500 italic\">${match}</span>`; // null
}
if (/^-?\d/.test(match)) {
return `<span class=\"text-fuchsia-700\">${match}</span>`; // number
}
return match;
}
);
}
function formattedContext(ctx) {
return highlightJson(ctx);
}
</script>
<template>
<div class="pt-4">
<div class="flex items-center justify-between mb-2">
<h3 class="font-semibold">Logs</h3>
<div class="flex items-center flex-wrap gap-2 text-sm">
<span class="text-muted-foreground">Show</span>
<Select :model-value="limit.toString()" @update:model-value="onLimit">
<SelectTrigger class="w-20 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem>
<SelectItem value="200">200</SelectItem>
<SelectItem value="500">500</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<span class="text-muted-foreground ml-2">Level</span>
<Select v-model="levelFilter">
<SelectTrigger class="w-32 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem v-for="opt in levelOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
@click.prevent="$emit('refresh')"
:disabled="loading"
>
{{ loading ? "Refreshing…" : "Refresh" }}
</Button>
</div>
</div>
<div class="overflow-x-auto max-h-[30rem] overflow-y-auto rounded-lg border">
<Table>
<TableHeader class="sticky top-0 z-10">
<TableRow>
<TableHead class="w-[160px]">Time</TableHead>
<TableHead class="w-[80px]">Level</TableHead>
<TableHead class="w-[160px]">Event</TableHead>
<TableHead>Message</TableHead>
<TableHead class="w-[64px]">Row</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="ev in filteredEvents" :key="ev.id">
<TableCell class="whitespace-nowrap">
{{ new Date(ev.created_at).toLocaleString() }}
</TableCell>
<TableCell>
<Badge
:variant="ev.level === 'error' ? 'destructive' : ev.level === 'warning' ? 'default' : 'secondary'"
:class="[
'text-xs',
ev.level === 'warning' ? 'bg-amber-100 text-amber-800 hover:bg-amber-100' : ''
]"
>{{ ev.level }}</Badge>
</TableCell>
<TableCell class="max-w-[9rem]">
<span class="block truncate" :title="ev.event">{{ ev.event }}</span>
</TableCell>
<TableCell class="max-w-[28rem]">
<div class="space-y-1 break-words">
<div class="leading-snug whitespace-pre-wrap">
<span v-if="!isLong(ev.message)">{{ ev.message }}</span>
<span v-else>
<span v-if="!isExpanded(ev.id)">{{ shortMsg(ev.message) }}</span>
<span v-else>{{ ev.message }}</span>
<button
type="button"
class="ml-2 inline-flex items-center gap-0.5 text-xs text-indigo-600 hover:underline"
@click="toggleExpand(ev.id)"
>
{{ isExpanded(ev.id) ? "Show less" : "Read more" }}
</button>
</span>
</div>
<div v-if="ev.context" class="text-xs text-gray-600 flex items-center gap-2">
<button
v-if="hasEntityDetails(ev)"
type="button"
class="px-2 py-1 rounded border border-indigo-300 bg-indigo-50 hover:bg-indigo-100 text-indigo-700 transition text-[11px] font-medium"
@click="showEntityDetails(ev)"
>
📋 Entity Details
</button>
<Dropdown
align="left"
width="wide"
:content-classes="[
'p-3',
'bg-white',
'text-xs',
'break-words',
'space-y-2',
'max-h-[28rem]',
'overflow-auto',
'max-w-[34rem]',
]"
:close-on-content-click="false"
>
<template #trigger>
<button
type="button"
class="px-1.5 py-0.5 rounded border border-gray-300 bg-gray-50 hover:bg-gray-100 text-gray-700 transition text-[11px] font-medium"
>
Context: {{ contextPreview(ev.context) }}
</button>
</template>
<template #content>
<div
class="font-medium text-gray-700 mb-1 flex items-center justify-between"
>
<span>Context JSON</span>
<span class="text-[10px] text-gray-400">ID: {{ ev.id }}</span>
</div>
<pre
class="whitespace-pre break-words text-gray-800 text-[11px] leading-snug"
>
<code v-html="formattedContext(ev.context)"></code>
</pre>
</template>
</Dropdown>
</div>
</div>
</TableCell>
<TableCell>{{ ev.import_row_id ?? "—" }}</TableCell>
</TableRow>
<TableRow v-if="!filteredEvents.length">
<TableCell colspan="5" class="text-center text-muted-foreground">No events yet</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<!-- Entity Details Dialog -->
<Dialog v-model:open="detailsDialog">
<DialogContent class="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Entity Processing Details</DialogTitle>
<DialogDescription v-if="selectedEvent">
Row {{ tryJson(selectedEvent.context)?.row || '' }} - {{ selectedEvent.event }}
</DialogDescription>
</DialogHeader>
<div v-if="selectedEvent" class="space-y-3 mt-4">
<div
v-for="(detail, idx) in getEntityDetails(selectedEvent)"
:key="idx"
class="p-3 rounded-lg border"
:class="{
'bg-red-50 border-red-200': detail.level === 'error',
'bg-amber-50 border-amber-200': detail.level === 'warning',
'bg-green-50 border-green-200': detail.level === 'info' && detail.action === 'inserted',
'bg-blue-50 border-blue-200': detail.level === 'info' && detail.action === 'updated',
'bg-gray-50 border-gray-200': detail.level === 'info' && detail.action === 'skipped'
}"
>
<div class="flex items-start justify-between mb-2">
<div class="font-medium text-sm capitalize">{{ detail.entity }}</div>
<Badge
:variant="detail.level === 'error' ? 'destructive' : detail.level === 'warning' ? 'default' : 'secondary'"
:class="[
'text-xs',
detail.level === 'warning' ? 'bg-amber-100 text-amber-800 hover:bg-amber-100' : '',
detail.action === 'inserted' ? 'bg-green-100 text-green-800 hover:bg-green-100' : '',
detail.action === 'updated' ? 'bg-blue-100 text-blue-800 hover:bg-blue-100' : '',
detail.action === 'skipped' ? 'bg-gray-200 text-gray-700 hover:bg-gray-200' : ''
]"
>
{{ detail.action }}{{ detail.count > 1 ? ` (${detail.count})` : '' }}
</Badge>
</div>
<div v-if="detail.message" class="text-sm text-gray-700 mb-1">
{{ detail.message }}
</div>
<div v-if="detail.errors && detail.errors.length" class="mt-2 space-y-1">
<div class="text-xs font-medium text-red-700">Errors:</div>
<div
v-for="(err, errIdx) in detail.errors"
:key="errIdx"
class="text-xs text-red-600 pl-3"
>
• {{ err }}
</div>
</div>
<div v-if="detail.exception" class="mt-2 p-2 bg-red-100 rounded border border-red-200">
<div class="text-xs font-semibold text-red-800 mb-1">Exception:</div>
<div class="text-xs text-red-700">{{ detail.exception.message }}</div>
<div v-if="detail.exception.file" class="text-xs text-red-600 mt-1">
{{ detail.exception.file }}:{{ detail.exception.line }}
</div>
</div>
</div>
<div v-if="getEntityDetails(selectedEvent).length === 0" class="text-center text-muted-foreground py-4">
No entity details available
</div>
<!-- Raw Row Data Accordion -->
<Accordion type="single" collapsible class="mt-4 border-t pt-4">
<AccordionItem value="raw-data" class="border-b-0">
<AccordionTrigger class="text-sm font-medium hover:no-underline py-2">
📄 Raw Row Data (JSON)
</AccordionTrigger>
<AccordionContent>
<pre class="text-xs bg-gray-900 text-gray-100 p-3 rounded overflow-x-auto mt-2">{{ JSON.stringify(getRawData(selectedEvent), null, 2) }}</pre>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</DialogContent>
</Dialog>
</div>
</template>