242 lines
8.8 KiB
Svelte
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] -->
|