Files
ss-tools/frontend/src/components/TaskLogViewer.svelte
2026-05-26 09:30:41 +03:00

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">&#8203;</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 -->