Files
ss-tools/frontend/src/components/TaskLogViewer.svelte
2026-02-24 21:08:12 +03:00

242 lines
8.8 KiB
Svelte

<!-- [DEF:TaskLogViewer:Component] -->
<!--
@TIER: CRITICAL
@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 -> frontend/src/services/taskService.js
@RELATION: USES -> frontend/src/components/tasks/TaskLogPanel.svelte
@INVARIANT: Real-time logs are always appended without duplicates.
-->
<script>
/**
* @TIER CRITICAL
* @PURPOSE Displays detailed logs for a specific task inline or in a modal using TaskLogPanel.
* @PRE Needs a valid taskId to fetch logs for.
* @POST task logs are displayed and updated in real time.
* @UX_STATE Loading -> Shows spinner/text while fetching initial logs
* @UX_STATE Streaming -> Displays logs with auto-scroll, real-time appending
* @UX_STATE Error -> Shows error message with recovery option
* @UX_FEEDBACK Auto-scroll keeps newest logs visible
* @UX_RECOVERY Refresh button re-fetches logs from API
*/
import { createEventDispatcher, onDestroy } from "svelte";
import { getTaskLogs } from "../services/taskService.js";
import { t } from "../lib/i18n";
import TaskLogPanel from "./tasks/TaskLogPanel.svelte";
let {
show = $bindable(false),
inline = false,
taskId = null,
taskStatus = null,
realTimeLogs = [],
} = $props();
const dispatch = createEventDispatcher();
let logs = $state([]);
let loading = $state(false);
let error = $state("");
let interval;
let autoScroll = $state(true);
let shouldShow = $derived(inline || show);
// [DEF:handleRealTimeLogs:Action]
// @PURPOSE: Sync real-time logs to the current log list
// @PRE: None
// @POST: logs are updated with new real-time log entries
$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];
}
}
});
// [/DEF:handleRealTimeLogs:Action]
// [DEF:fetchLogs:Function]
// @PURPOSE: Fetches logs for a given task ID
// @PRE: taskId is set
// @POST: logs are populated with API response
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;
}
}
// [/DEF:fetchLogs:Function]
// [DEF:handleFilterChange:Function]
// @PURPOSE: Updates filter conditions for the log viewer
// @PRE: event contains detail with source and level
// @POST: Log viewer filters updated
function handleFilterChange(event) {
console.log("[TaskLogViewer][UI][handleFilterChange:START]");
const { source, level } = event.detail;
}
// [/DEF:handleFilterChange:Function]
// [DEF:handleRefresh:Function]
// @PURPOSE: Refreshes the logs by polling the API
// @PRE: None
// @POST: Logs refetched
function handleRefresh() {
console.log("[TaskLogViewer][UI][handleRefresh:START]");
fetchLogs();
}
// [/DEF:handleRefresh:Function]
$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}
<!-- [DEF:showInline:Component] -->
<!-- @PURPOSE: Shows inline logs -->
<!-- @LAYER: UI -->
<!-- @SEMANTICS: logs, inline -->
<!-- @TIER: STANDARD -->
{#if inline}
<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-terminal-text-subtle text-sm"
>
<div
class="w-5 h-5 border-2 border-terminal-border border-t-primary rounded-full animate-spin"
></div>
<span>{$t.tasks?.loading || "Loading logs..."}</span>
</div>
{:else if error}
<div
class="flex items-center justify-center gap-2 h-full text-log-error text-sm"
>
<span class="text-xl"></span>
<span>{error}</span>
<button
class="bg-terminal-surface text-terminal-text-subtle border border-terminal-border rounded-md px-3 py-1 text-xs cursor-pointer transition-all hover:bg-terminal-border hover:text-terminal-text-bright"
onclick={handleRefresh}>Retry</button
>
</div>
{:else}
<TaskLogPanel
{taskId}
{logs}
{autoScroll}
on:filterChange={handleFilterChange}
on:refresh={handleRefresh}
/>
{/if}
</div>
<!-- [/DEF:showInline:Component] -->
{:else}
<!-- [DEF:showModal:Component] -->
<!-- @PURPOSE: Shows modal logs -->
<!-- @LAYER: UI -->
<!-- @SEMANTICS: logs, modal -->
<!-- @TIER: STANDARD -->
<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"
>
<div
class="fixed inset-0 bg-gray-500/75 transition-opacity"
aria-hidden="true"
onclick={() => {
show = false;
dispatch("close");
}}
onkeydown={(e) => e.key === "Escape" && (show = false)}
role="presentation"
></div>
<div
class="inline-block align-bottom bg-gray-900 rounded-lg text-left overflow-hidden shadow-xl 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-4">
<h3
class="text-lg font-medium text-gray-100"
id="modal-title"
>
{$t.tasks?.logs_title || "Task Logs"}
</h3>
<button
class="text-gray-500 hover:text-gray-300"
onclick={() => {
show = false;
dispatch("close");
}}
aria-label="Close"></button
>
</div>
<div class="h-[500px]">
{#if loading && logs.length === 0}
<p class="text-gray-500 text-center">
{$t.tasks?.loading || "Loading..."}
</p>
{:else if error}
<p class="text-red-400 text-center">{error}</p>
{:else}
<TaskLogPanel
{taskId}
{logs}
{autoScroll}
on:filterChange={handleFilterChange}
/>
{/if}
</div>
</div>
</div>
</div>
</div>
{/if}
{/if}
// [/DEF:showModal:Component]
<!-- [/DEF:TaskLogViewer:Component] -->