task panel
This commit is contained in:
@@ -1,21 +1,33 @@
|
||||
<!-- [DEF:TaskLogViewer:Component] -->
|
||||
<!--
|
||||
@SEMANTICS: task, log, viewer, modal, inline
|
||||
@PURPOSE: Displays detailed logs for a specific task in a modal or inline using TaskLogPanel.
|
||||
@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, frontend/src/components/tasks/TaskLogPanel.svelte
|
||||
@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>
|
||||
import { createEventDispatcher, onMount, onDestroy } from 'svelte';
|
||||
import { getTaskLogs } from '../services/taskService.js';
|
||||
import { t } from '../lib/i18n';
|
||||
import { Button } from '../lib/ui';
|
||||
import TaskLogPanel from './tasks/TaskLogPanel.svelte';
|
||||
/**
|
||||
* @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; // To know if we should poll
|
||||
export let taskStatus = null;
|
||||
export let realTimeLogs = [];
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
@@ -24,29 +36,47 @@
|
||||
let error = "";
|
||||
let interval;
|
||||
let autoScroll = true;
|
||||
let selectedSource = 'all';
|
||||
let selectedLevel = 'all';
|
||||
|
||||
$: 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.
|
||||
* @pre taskId must be set.
|
||||
* @post logs array is updated with data from taskService.
|
||||
* @side_effect Updates logs, loading, and error state.
|
||||
* @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(`[fetchLogs][Action] Fetching logs for task context={{'taskId': '${taskId}', 'source': '${selectedSource}', 'level': '${selectedLevel}'}}`);
|
||||
console.log(`[TaskLogViewer][Action] Fetching logs for task=${taskId}`);
|
||||
try {
|
||||
// Note: getTaskLogs currently doesn't support filters, but we can filter client-side for now
|
||||
// or update taskService later. For US1, the WebSocket handles real-time filtering.
|
||||
logs = await getTaskLogs(taskId);
|
||||
console.log(`[fetchLogs][Coherence:OK] Logs fetched context={{'count': ${logs.length}}}`);
|
||||
console.log(
|
||||
`[TaskLogViewer][Coherence:OK] Logs fetched count=${logs.length}`,
|
||||
);
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
console.error(`[fetchLogs][Coherence:Failed] Error fetching logs context={{'error': '${e.message}'}}`);
|
||||
console.error(
|
||||
`[TaskLogViewer][Coherence:Failed] Error: ${e.message}`,
|
||||
);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
@@ -55,121 +85,192 @@
|
||||
|
||||
function handleFilterChange(event) {
|
||||
const { source, level } = event.detail;
|
||||
selectedSource = source;
|
||||
selectedLevel = level;
|
||||
// Re-fetch or re-filter if needed.
|
||||
// For now, we just log it as the WebSocket will handle real-time updates with filters.
|
||||
console.log(`[TaskLogViewer] Filter changed: source=${source}, level=${level}`);
|
||||
console.log(
|
||||
`[TaskLogViewer][Action] Filter changed: source=${source}, level=${level}`,
|
||||
);
|
||||
}
|
||||
|
||||
// [DEF:close:Function]
|
||||
/**
|
||||
* @purpose Closes the log viewer modal.
|
||||
* @pre Modal is open.
|
||||
* @post Modal is closed and close event is dispatched.
|
||||
*/
|
||||
function close() {
|
||||
dispatch('close');
|
||||
show = false;
|
||||
function handleRefresh() {
|
||||
console.log(`[TaskLogViewer][Action] Manual refresh`);
|
||||
fetchLogs();
|
||||
}
|
||||
// [/DEF:close:Function]
|
||||
|
||||
// React to changes in show/taskId/taskStatus
|
||||
$: if (shouldShow && taskId) {
|
||||
if (interval) clearInterval(interval);
|
||||
|
||||
logs = [];
|
||||
loading = true;
|
||||
error = "";
|
||||
fetchLogs();
|
||||
|
||||
// Poll if task is running (Fallback for when WS is not used)
|
||||
if (taskStatus === 'RUNNING' || taskStatus === 'AWAITING_INPUT' || taskStatus === 'AWAITING_MAPPING') {
|
||||
interval = setInterval(fetchLogs, 3000);
|
||||
|
||||
if (
|
||||
taskStatus === "RUNNING" ||
|
||||
taskStatus === "AWAITING_INPUT" ||
|
||||
taskStatus === "AWAITING_MAPPING"
|
||||
) {
|
||||
interval = setInterval(fetchLogs, 5000);
|
||||
}
|
||||
} else {
|
||||
if (interval) clearInterval(interval);
|
||||
}
|
||||
|
||||
// [DEF:onDestroy:Function]
|
||||
/**
|
||||
* @purpose Cleans up the polling interval.
|
||||
* @pre Component is being destroyed.
|
||||
* @post Polling interval is cleared.
|
||||
*/
|
||||
onDestroy(() => {
|
||||
if (interval) clearInterval(interval);
|
||||
});
|
||||
// [/DEF:onDestroy:Function]
|
||||
</script>
|
||||
|
||||
{#if shouldShow}
|
||||
{#if inline}
|
||||
<div class="flex flex-col h-full w-full p-4">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-medium text-gray-900">
|
||||
{$t.tasks?.logs_title} <span class="text-sm text-gray-500 font-normal">({taskId})</span>
|
||||
</h3>
|
||||
<Button variant="ghost" size="sm" on:click={fetchLogs} class="text-blue-600">{$t.tasks?.refresh}</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-h-[400px]">
|
||||
{#if loading && logs.length === 0}
|
||||
<p class="text-gray-500 text-center">{$t.tasks?.loading}</p>
|
||||
{:else if error}
|
||||
<p class="text-red-500 text-center">{error}</p>
|
||||
{:else}
|
||||
<TaskLogPanel
|
||||
{taskId}
|
||||
{logs}
|
||||
{autoScroll}
|
||||
on:filterChange={handleFilterChange}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<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">
|
||||
<!-- Background overlay -->
|
||||
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true" on:click={close}></div>
|
||||
<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>
|
||||
|
||||
<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-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="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||
<div class="sm:flex sm:items-start">
|
||||
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left w-full">
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900 flex justify-between items-center mb-4" id="modal-title">
|
||||
<span>{$t.tasks.logs_title} <span class="text-sm text-gray-500 font-normal">({taskId})</span></span>
|
||||
<Button variant="ghost" size="sm" on:click={fetchLogs} class="text-blue-600">{$t.tasks.refresh}</Button>
|
||||
</h3>
|
||||
|
||||
<div class="h-[500px]">
|
||||
{#if loading && logs.length === 0}
|
||||
<p class="text-gray-500 text-center">{$t.tasks.loading}</p>
|
||||
{:else if error}
|
||||
<p class="text-red-500 text-center">{error}</p>
|
||||
{:else}
|
||||
<TaskLogPanel
|
||||
{taskId}
|
||||
{logs}
|
||||
{autoScroll}
|
||||
on:filterChange={handleFilterChange}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</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 class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
||||
<Button variant="secondary" on:click={close}>
|
||||
{$t.common.cancel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
<!-- [/DEF:TaskLogViewer:Component] -->
|
||||
|
||||
<!-- [/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>
|
||||
|
||||
Reference in New Issue
Block a user