277 lines
8.7 KiB
Svelte
277 lines
8.7 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.
|
|
* @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";
|
|
|
|
export let show = false;
|
|
export let inline = false;
|
|
export let taskId = null;
|
|
export let taskStatus = null;
|
|
export let realTimeLogs = [];
|
|
|
|
const dispatch = createEventDispatcher();
|
|
|
|
let logs = [];
|
|
let loading = false;
|
|
let error = "";
|
|
let interval;
|
|
let autoScroll = true;
|
|
|
|
$: shouldShow = inline || show;
|
|
|
|
// [DEF:handleRealTimeLogs:Action]
|
|
/** @PURPOSE Append real-time logs as they arrive from WebSocket, preventing duplicates */
|
|
$: 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];
|
|
console.log(
|
|
`[TaskLogViewer][Action] Appended real-time log, total=${logs.length}`,
|
|
);
|
|
}
|
|
}
|
|
// [/DEF:handleRealTimeLogs:Action]
|
|
|
|
// [DEF:fetchLogs:Function]
|
|
/**
|
|
* @PURPOSE Fetches logs for the current task from API (polling fallback).
|
|
* @PRE taskId must be set.
|
|
* @POST logs array is updated with data from taskService.
|
|
* @SIDE_EFFECT Updates logs, loading, and error state.
|
|
*/
|
|
async function fetchLogs() {
|
|
if (!taskId) return;
|
|
console.log(`[TaskLogViewer][Action] Fetching logs for task=${taskId}`);
|
|
try {
|
|
logs = await getTaskLogs(taskId);
|
|
console.log(
|
|
`[TaskLogViewer][Coherence:OK] Logs fetched count=${logs.length}`,
|
|
);
|
|
} catch (e) {
|
|
error = e.message;
|
|
console.error(
|
|
`[TaskLogViewer][Coherence:Failed] Error: ${e.message}`,
|
|
);
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
}
|
|
// [/DEF:fetchLogs:Function]
|
|
|
|
function handleFilterChange(event) {
|
|
const { source, level } = event.detail;
|
|
console.log(
|
|
`[TaskLogViewer][Action] Filter changed: source=${source}, level=${level}`,
|
|
);
|
|
}
|
|
|
|
function handleRefresh() {
|
|
console.log(`[TaskLogViewer][Action] Manual refresh`);
|
|
fetchLogs();
|
|
}
|
|
|
|
// React to changes in show/taskId/taskStatus
|
|
$: 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}
|
|
<div class="log-viewer-inline">
|
|
{#if loading && logs.length === 0}
|
|
<div class="loading-state">
|
|
<div class="loading-spinner"></div>
|
|
<span>{$t.tasks?.loading || "Loading logs..."}</span>
|
|
</div>
|
|
{:else if error}
|
|
<div class="error-state">
|
|
<span class="error-icon">⚠</span>
|
|
<span>{error}</span>
|
|
<button class="retry-btn" on:click={handleRefresh}
|
|
>Retry</button
|
|
>
|
|
</div>
|
|
{:else}
|
|
<TaskLogPanel
|
|
{taskId}
|
|
{logs}
|
|
{autoScroll}
|
|
on:filterChange={handleFilterChange}
|
|
on:refresh={handleRefresh}
|
|
/>
|
|
{/if}
|
|
</div>
|
|
{:else}
|
|
<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 bg-opacity-75 transition-opacity"
|
|
aria-hidden="true"
|
|
on:click={() => {
|
|
show = false;
|
|
dispatch("close");
|
|
}}
|
|
on:keydown={(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"
|
|
on:click={() => {
|
|
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:TaskLogViewer:Component] -->
|
|
|
|
<style>
|
|
.log-viewer-inline {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100%;
|
|
width: 100%;
|
|
}
|
|
|
|
.loading-state {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 0.75rem;
|
|
height: 100%;
|
|
color: #64748b;
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.loading-spinner {
|
|
width: 1.25rem;
|
|
height: 1.25rem;
|
|
border: 2px solid #334155;
|
|
border-top-color: #3b82f6;
|
|
border-radius: 50%;
|
|
animation: spin 0.8s linear infinite;
|
|
}
|
|
|
|
@keyframes spin {
|
|
to {
|
|
transform: rotate(360deg);
|
|
}
|
|
}
|
|
|
|
.error-state {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 0.5rem;
|
|
height: 100%;
|
|
color: #f87171;
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.error-icon {
|
|
font-size: 1.25rem;
|
|
}
|
|
|
|
.retry-btn {
|
|
background-color: #1e293b;
|
|
color: #94a3b8;
|
|
border: 1px solid #334155;
|
|
border-radius: 0.375rem;
|
|
padding: 0.25rem 0.75rem;
|
|
font-size: 0.75rem;
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
}
|
|
|
|
.retry-btn:hover {
|
|
background-color: #334155;
|
|
color: #e2e8f0;
|
|
}
|
|
</style>
|