Mass changes
This commit is contained in:
@@ -0,0 +1,268 @@
|
||||
<script setup>
|
||||
import { ref, computed } from "vue";
|
||||
import Dropdown from "@/Components/Dropdown.vue";
|
||||
|
||||
const props = defineProps({
|
||||
events: Array,
|
||||
loading: Boolean,
|
||||
limit: Number,
|
||||
});
|
||||
const emits = defineEmits(["update:limit", "refresh"]);
|
||||
function onLimit(e) {
|
||||
emits("update:limit", Number(e.target.value));
|
||||
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);
|
||||
}
|
||||
|
||||
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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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">
|
||||
<label class="text-gray-600">Show</label>
|
||||
<select :value="limit" class="border rounded p-1" @change="onLimit">
|
||||
<option :value="50">50</option>
|
||||
<option :value="100">100</option>
|
||||
<option :value="200">200</option>
|
||||
<option :value="500">500</option>
|
||||
</select>
|
||||
<label class="text-gray-600 ml-2">Level</label>
|
||||
<select v-model="levelFilter" class="border rounded p-1">
|
||||
<option v-for="opt in levelOptions" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</select>
|
||||
<button
|
||||
@click.prevent="$emit('refresh')"
|
||||
class="px-2 py-1 border rounded text-sm"
|
||||
:disabled="loading"
|
||||
>
|
||||
{{ loading ? "Refreshing…" : "Refresh" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-x-auto max-h-[30rem] overflow-y-auto rounded border">
|
||||
<table class="min-w-full bg-white text-sm table-fixed">
|
||||
<colgroup>
|
||||
<col class="w-40" />
|
||||
<col class="w-20" />
|
||||
<col class="w-40" />
|
||||
<col />
|
||||
<col class="w-16" />
|
||||
</colgroup>
|
||||
<thead class="bg-gray-50 sticky top-0 z-10 shadow">
|
||||
<tr class="text-left text-xs uppercase text-gray-600">
|
||||
<th class="p-2 border">Time</th>
|
||||
<th class="p-2 border">Level</th>
|
||||
<th class="p-2 border">Event</th>
|
||||
<th class="p-2 border">Message</th>
|
||||
<th class="p-2 border">Row</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="ev in filteredEvents" :key="ev.id" class="border-t align-top">
|
||||
<td class="p-2 border whitespace-nowrap">
|
||||
{{ new Date(ev.created_at).toLocaleString() }}
|
||||
</td>
|
||||
<td class="p-2 border">
|
||||
<span
|
||||
:class="[
|
||||
'px-2 py-0.5 rounded text-xs',
|
||||
ev.level === 'error'
|
||||
? 'bg-red-100 text-red-800'
|
||||
: ev.level === 'warning'
|
||||
? 'bg-amber-100 text-amber-800'
|
||||
: 'bg-gray-100 text-gray-700',
|
||||
]"
|
||||
>{{ ev.level }}</span
|
||||
>
|
||||
</td>
|
||||
<td class="p-2 border break-words max-w-[9rem]">
|
||||
<span class="block truncate" :title="ev.event">{{ ev.event }}</span>
|
||||
</td>
|
||||
<td class="p-2 border align-top 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">
|
||||
<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>
|
||||
</td>
|
||||
<td class="p-2 border">{{ ev.import_row_id ?? "—" }}</td>
|
||||
</tr>
|
||||
<tr v-if="!filteredEvents.length">
|
||||
<td class="p-3 text-center text-gray-500" colspan="5">No events yet</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user