176 lines
7.6 KiB
Svelte
176 lines
7.6 KiB
Svelte
<!-- #region TaskLogViewer [C:3] [TYPE Component] [SEMANTICS task, log, viewer, realtime, websocket] -->
|
|
<!-- @BRIEF Component component: components/TaskLogViewer.svelte -->
|
|
<!-- @LAYER UI -->
|
|
<!--
|
|
@UX_STATE: Loading -> Default
|
|
|
|
@SEMANTICS: task, log, viewer, inline, realtime
|
|
@PURPOSE: Displays task logs inline (in drawer) or as modal. Merges real-time WebSocket logs with polled historical logs.
|
|
@LAYER UI
|
|
@RELATION USES -> [EXT:frontend:taskService]
|
|
@RELATION USES -> [TaskLogPanel]
|
|
@INVARIANT: Real-time logs are always appended without duplicates.
|
|
-->
|
|
<script>
|
|
import { onDestroy } from "svelte";
|
|
import { getTaskLogs } from "../services/taskService.js";
|
|
import { t } from "../lib/i18n";
|
|
import TaskLogPanel from "./tasks/TaskLogPanel.svelte";
|
|
import Icon from "../lib/ui/Icon.svelte";
|
|
|
|
let {
|
|
show = $bindable(false),
|
|
inline = false,
|
|
taskId = null,
|
|
taskStatus = null,
|
|
realTimeLogs = [],
|
|
onclose = () => {},
|
|
} = $props();
|
|
|
|
let logs = $state([]);
|
|
let loading = $state(false);
|
|
let error = $state("");
|
|
let interval;
|
|
let autoScroll = $state(true);
|
|
|
|
let shouldShow = $derived(inline || show);
|
|
|
|
$effect(() => {
|
|
if (realTimeLogs && realTimeLogs.length > 0) {
|
|
const lastLog = realTimeLogs[realTimeLogs.length - 1];
|
|
const exists = logs.some(
|
|
(l) =>
|
|
l.timestamp === lastLog.timestamp &&
|
|
l.message === lastLog.message,
|
|
);
|
|
if (!exists) {
|
|
logs = [...logs, lastLog];
|
|
}
|
|
}
|
|
});
|
|
|
|
async function fetchLogs() {
|
|
if (!taskId) return;
|
|
try {
|
|
console.log(`[TaskLogViewer][API][fetchLogs:STARTED] id=${taskId}`);
|
|
logs = await getTaskLogs(taskId);
|
|
console.log(`[TaskLogViewer][API][fetchLogs:SUCCESS] id=${taskId}`);
|
|
} catch (e) {
|
|
console.error(
|
|
`[TaskLogViewer][API][fetchLogs:FAILED] id=${taskId}`,
|
|
e,
|
|
);
|
|
error = e.message;
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
}
|
|
|
|
function handleRefresh() {
|
|
fetchLogs();
|
|
}
|
|
|
|
$effect(() => {
|
|
if (shouldShow && taskId) {
|
|
if (interval) clearInterval(interval);
|
|
logs = [];
|
|
loading = true;
|
|
error = "";
|
|
fetchLogs();
|
|
|
|
if (
|
|
taskStatus === "RUNNING" ||
|
|
taskStatus === "AWAITING_INPUT" ||
|
|
taskStatus === "AWAITING_MAPPING"
|
|
) {
|
|
interval = setInterval(fetchLogs, 5000);
|
|
}
|
|
} else {
|
|
if (interval) clearInterval(interval);
|
|
}
|
|
});
|
|
|
|
onDestroy(() => {
|
|
if (interval) clearInterval(interval);
|
|
});
|
|
</script>
|
|
|
|
{#if shouldShow}
|
|
{#if inline}
|
|
<!-- ═══ INLINE MODE ═══ -->
|
|
<div class="flex flex-col h-full w-full">
|
|
{#if loading && logs.length === 0}
|
|
<div class="flex items-center justify-center gap-3 h-full text-gray-400 text-sm">
|
|
<div class="w-5 h-5 border-2 border-gray-200 border-t-primary rounded-full animate-spin"></div>
|
|
<span class="font-medium">{$t.tasks?.loading}</span>
|
|
</div>
|
|
{:else if error}
|
|
<div class="flex flex-col items-center justify-center gap-3 h-full text-gray-500 text-sm p-4 text-center">
|
|
<div class="w-10 h-10 rounded-full bg-red-50 flex items-center justify-center text-red-500 text-lg">⚠</div>
|
|
<span class="font-medium text-red-600">{error}</span>
|
|
<button
|
|
class="bg-white text-gray-700 border border-gray-200 rounded-md px-4 py-1.5 text-xs font-semibold cursor-pointer transition-all hover:bg-gray-50 hover:border-gray-300"
|
|
onclick={handleRefresh}>{$t.common?.retry}</button>
|
|
</div>
|
|
{:else}
|
|
<TaskLogPanel {taskId} {logs} {autoScroll} />
|
|
{/if}
|
|
</div>
|
|
{:else}
|
|
<!-- ═══ MODAL MODE ═══ -->
|
|
<div class="fixed inset-0 z-50 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
|
|
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
|
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
|
<div
|
|
class="fixed inset-0 bg-slate-900/30 backdrop-blur-[2px] transition-opacity"
|
|
aria-hidden="true"
|
|
onclick={() => { show = false; onclose(); }}
|
|
role="presentation"
|
|
></div>
|
|
|
|
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">​</span>
|
|
|
|
<div class="inline-block align-bottom bg-white rounded-xl text-left overflow-hidden shadow-2xl border border-gray-200 transform transition-all sm:my-8 sm:align-middle sm:max-w-4xl sm:w-full">
|
|
<div class="p-6">
|
|
<div class="flex justify-between items-center mb-5 pb-4 border-b border-gray-100">
|
|
<h3 class="text-lg font-bold tracking-tight text-gray-900" id="modal-title">
|
|
{$t.tasks?.logs_title}
|
|
</h3>
|
|
<button
|
|
class="p-1.5 rounded-md text-gray-400 bg-transparent border-none cursor-pointer transition-all hover:text-gray-900 hover:bg-gray-100"
|
|
onclick={() => { show = false; onclose(); }}
|
|
aria-label={$t.common?.close}
|
|
>
|
|
<Icon name="close" size={20} strokeWidth={2.5} />
|
|
</button>
|
|
</div>
|
|
<div class="h-[550px] overflow-hidden">
|
|
{#if loading && logs.length === 0}
|
|
<div class="flex flex-col items-center justify-center h-full space-y-4 text-gray-400">
|
|
<div class="w-10 h-10 border-4 border-gray-200 border-t-primary rounded-full animate-spin"></div>
|
|
<p class="text-sm font-semibold tracking-wide uppercase">{$t.tasks?.loading}</p>
|
|
</div>
|
|
{:else if error}
|
|
<div class="flex flex-col items-center justify-center h-full p-8 text-center space-y-4">
|
|
<div class="w-14 h-14 rounded-full bg-red-50 flex items-center justify-center text-red-500 shadow-inner">
|
|
<span class="text-3xl">⚠</span>
|
|
</div>
|
|
<p class="text-red-600 font-semibold text-lg">{error}</p>
|
|
<button
|
|
class="mt-2 rounded-lg border border-gray-200 bg-white px-5 py-2.5 text-sm font-bold text-gray-700 hover:bg-gray-50 hover:border-gray-300 transition-all shadow-sm"
|
|
onclick={handleRefresh}>
|
|
{$t.common?.retry}
|
|
</button>
|
|
</div>
|
|
{:else}
|
|
<TaskLogPanel {taskId} {logs} {autoScroll} />
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
<!-- #endregion TaskLogViewer -->
|