306 lines
11 KiB
Svelte
306 lines
11 KiB
Svelte
<!-- [DEF:TaskDrawer:Component] -->
|
|
<script>
|
|
/**
|
|
* @TIER: CRITICAL
|
|
* @PURPOSE: Global task drawer for monitoring background operations
|
|
* @LAYER: UI
|
|
* @RELATION: BINDS_TO -> taskDrawerStore, WebSocket
|
|
* @SEMANTICS: TaskLogViewer
|
|
* @INVARIANT: Drawer shows logs for active task or remains closed
|
|
*
|
|
* @UX_STATE: Closed -> Drawer hidden, no active task
|
|
* @UX_STATE: Open/ListMode -> Drawer visible, showing recent tasks list
|
|
* @UX_STATE: Open/TaskDetail -> Drawer visible, showing logs for selected task
|
|
* @UX_STATE: InputRequired -> Interactive form rendered in drawer
|
|
* @UX_FEEDBACK: Close button allows task to continue running
|
|
* @UX_FEEDBACK: Back button returns to task list
|
|
* @UX_RECOVERY: Click outside or X button closes drawer
|
|
* @UX_RECOVERY: Back button shows task list when viewing task details
|
|
*/
|
|
|
|
import { onMount, onDestroy } from "svelte";
|
|
import { taskDrawerStore, closeDrawer } from "$lib/stores/taskDrawer.js";
|
|
import TaskLogViewer from "../../../components/TaskLogViewer.svelte";
|
|
import PasswordPrompt from "../../../components/PasswordPrompt.svelte";
|
|
import { t } from "$lib/i18n";
|
|
import { api } from "$lib/api.js";
|
|
import Icon from "$lib/ui/Icon.svelte";
|
|
|
|
let isOpen = false;
|
|
let activeTaskId = null;
|
|
let ws = null;
|
|
let realTimeLogs = [];
|
|
let taskStatus = null;
|
|
let recentTasks = [];
|
|
let loadingTasks = false;
|
|
|
|
// Subscribe to task drawer store
|
|
$: if ($taskDrawerStore) {
|
|
isOpen = $taskDrawerStore.isOpen;
|
|
activeTaskId = $taskDrawerStore.activeTaskId;
|
|
}
|
|
|
|
// Derive short task ID for display
|
|
$: shortTaskId = activeTaskId
|
|
? typeof activeTaskId === "string"
|
|
? activeTaskId.substring(0, 8)
|
|
: (activeTaskId?.id || activeTaskId?.task_id || "")
|
|
.toString()
|
|
.substring(0, 8)
|
|
: "";
|
|
|
|
// Close drawer
|
|
function handleClose() {
|
|
console.log("[TaskDrawer][Action] Close drawer");
|
|
closeDrawer();
|
|
}
|
|
|
|
function goToReportsPage() {
|
|
closeDrawer();
|
|
window.location.href = "/reports";
|
|
}
|
|
|
|
// Handle overlay click
|
|
function handleOverlayClick(event) {
|
|
if (event.target === event.currentTarget) {
|
|
handleClose();
|
|
}
|
|
}
|
|
|
|
// Connect to WebSocket for real-time logs
|
|
function connectWebSocket() {
|
|
if (!activeTaskId) return;
|
|
|
|
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
const host = window.location.host;
|
|
let taskId = "";
|
|
if (typeof activeTaskId === "string") {
|
|
const match = activeTaskId.match(
|
|
/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/i,
|
|
);
|
|
taskId = match ? match[0] : activeTaskId;
|
|
} else {
|
|
taskId = activeTaskId?.id || activeTaskId?.task_id || activeTaskId;
|
|
}
|
|
const wsUrl = `${protocol}//${host}/ws/logs/${taskId}`;
|
|
|
|
console.log(`[TaskDrawer][Action] Connecting to WebSocket: ${wsUrl}`);
|
|
|
|
ws = new WebSocket(wsUrl);
|
|
|
|
ws.onopen = () => {
|
|
console.log("[TaskDrawer][Coherence:OK] WebSocket connected");
|
|
};
|
|
|
|
ws.onmessage = (event) => {
|
|
const data = JSON.parse(event.data);
|
|
console.log("[TaskDrawer][WebSocket] Received message:", data);
|
|
|
|
realTimeLogs = [...realTimeLogs, data];
|
|
|
|
if (data.message?.includes("Task completed successfully")) {
|
|
taskStatus = "SUCCESS";
|
|
} else if (data.message?.includes("Task failed")) {
|
|
taskStatus = "FAILED";
|
|
}
|
|
};
|
|
|
|
ws.onerror = (error) => {
|
|
console.error("[TaskDrawer][Coherence:Failed] WebSocket error:", error);
|
|
};
|
|
|
|
ws.onclose = () => {
|
|
console.log("[TaskDrawer][WebSocket] Connection closed");
|
|
};
|
|
}
|
|
|
|
// Disconnect WebSocket
|
|
function disconnectWebSocket() {
|
|
if (ws) {
|
|
ws.close();
|
|
ws = null;
|
|
}
|
|
}
|
|
|
|
// [DEF:loadRecentTasks:Function]
|
|
/**
|
|
* @PURPOSE: Load recent tasks for list mode display
|
|
* @POST: recentTasks array populated with task list
|
|
*/
|
|
async function loadRecentTasks() {
|
|
loadingTasks = true;
|
|
try {
|
|
// API returns List[Task] directly, not {tasks: [...]}
|
|
const response = await api.getTasks();
|
|
recentTasks = Array.isArray(response) ? response : (response.tasks || []);
|
|
console.log("[TaskDrawer][Action] Loaded recent tasks:", recentTasks.length);
|
|
} catch (err) {
|
|
console.error("[TaskDrawer][Coherence:Failed] Failed to load tasks:", err);
|
|
recentTasks = [];
|
|
} finally {
|
|
loadingTasks = false;
|
|
}
|
|
}
|
|
// [/DEF:loadRecentTasks:Function]
|
|
|
|
// [DEF:selectTask:Function]
|
|
/**
|
|
* @PURPOSE: Select a task from list to view details
|
|
*/
|
|
function selectTask(task) {
|
|
taskDrawerStore.update(state => ({
|
|
...state,
|
|
activeTaskId: task.id
|
|
}));
|
|
}
|
|
// [/DEF:selectTask:Function]
|
|
|
|
// [DEF:goBackToList:Function]
|
|
/**
|
|
* @PURPOSE: Return to task list view from task details
|
|
*/
|
|
function goBackToList() {
|
|
taskDrawerStore.update(state => ({
|
|
...state,
|
|
activeTaskId: null
|
|
}));
|
|
// Reload the task list
|
|
loadRecentTasks();
|
|
}
|
|
// [/DEF:goBackToList:Function]
|
|
|
|
// Reconnect when active task changes
|
|
$: if (isOpen) {
|
|
if (activeTaskId) {
|
|
disconnectWebSocket();
|
|
realTimeLogs = [];
|
|
taskStatus = "RUNNING";
|
|
connectWebSocket();
|
|
} else {
|
|
// List mode - load recent tasks
|
|
loadRecentTasks();
|
|
}
|
|
}
|
|
|
|
// Cleanup on destroy
|
|
onDestroy(() => {
|
|
disconnectWebSocket();
|
|
});
|
|
</script>
|
|
|
|
<!-- Drawer Overlay -->
|
|
{#if isOpen}
|
|
<div
|
|
class="fixed inset-0 bg-black/40 backdrop-blur-sm z-50"
|
|
on:click={handleOverlayClick}
|
|
on:keydown={(e) => e.key === 'Escape' && handleClose()}
|
|
role="button"
|
|
tabindex="0"
|
|
aria-label="Close drawer"
|
|
>
|
|
<!-- Drawer Panel -->
|
|
<div
|
|
class="fixed right-0 top-0 h-full w-full max-w-[560px] bg-slate-900 shadow-[-8px_0_30px_rgba(0,0,0,0.3)] flex flex-col z-50 transition-transform duration-300 ease-out"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label="Task drawer"
|
|
>
|
|
<!-- Header -->
|
|
<div class="flex items-center justify-between px-5 py-3.5 border-b border-slate-800 bg-slate-900">
|
|
<div class="flex items-center gap-2.5">
|
|
{#if !activeTaskId && recentTasks.length > 0}
|
|
<span class="flex items-center justify-center p-1.5 mr-1 text-cyan-400">
|
|
<Icon name="list" size={16} strokeWidth={2} />
|
|
</span>
|
|
{:else if activeTaskId}
|
|
<button
|
|
class="flex items-center justify-center p-1.5 rounded-md text-slate-500 bg-transparent border-none cursor-pointer transition-all hover:text-slate-100 hover:bg-slate-800"
|
|
on:click={goBackToList}
|
|
aria-label="Back to task list"
|
|
>
|
|
<Icon name="back" size={16} strokeWidth={2} />
|
|
</button>
|
|
{/if}
|
|
<h2 class="text-sm font-semibold text-slate-100 tracking-tight">
|
|
{activeTaskId ? ($t.tasks?.details_logs || 'Task Details & Logs') : 'Recent Tasks'}
|
|
</h2>
|
|
{#if shortTaskId}
|
|
<span class="text-xs font-mono text-slate-500 bg-slate-800 px-2 py-0.5 rounded">{shortTaskId}…</span>
|
|
{/if}
|
|
{#if taskStatus}
|
|
<span class="text-xs font-semibold uppercase tracking-wider px-2 py-0.5 rounded-full {taskStatus.toLowerCase() === 'running' ? 'text-cyan-400 bg-cyan-400/10 border border-cyan-400/20' : taskStatus.toLowerCase() === 'success' ? 'text-green-400 bg-green-400/10 border border-green-400/20' : 'text-red-400 bg-red-400/10 border border-red-400/20'}"
|
|
>{taskStatus}</span
|
|
>
|
|
{/if}
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<button
|
|
class="px-2.5 py-1 text-xs font-semibold rounded-md border border-slate-700 text-slate-300 bg-slate-800/60 hover:bg-slate-800 transition-colors"
|
|
on:click={goToReportsPage}
|
|
>
|
|
{$t.nav?.reports || "Reports"}
|
|
</button>
|
|
<button
|
|
class="p-1.5 rounded-md text-slate-500 bg-transparent border-none cursor-pointer transition-all hover:text-slate-100 hover:bg-slate-800"
|
|
on:click={handleClose}
|
|
aria-label="Close drawer"
|
|
>
|
|
<Icon name="close" size={18} strokeWidth={2} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Content -->
|
|
<div class="flex-1 overflow-hidden flex flex-col">
|
|
{#if activeTaskId}
|
|
<TaskLogViewer
|
|
inline={true}
|
|
taskId={activeTaskId}
|
|
{taskStatus}
|
|
{realTimeLogs}
|
|
/>
|
|
{:else if loadingTasks}
|
|
<div class="flex flex-col items-center justify-center p-12 text-slate-500">
|
|
<div class="w-8 h-8 border-2 border-slate-200 border-t-blue-500 rounded-full animate-spin mb-4"></div>
|
|
<p>Loading tasks...</p>
|
|
</div>
|
|
{:else if recentTasks.length > 0}
|
|
<div class="p-4">
|
|
<h3 class="text-sm font-semibold text-slate-100 mb-4 pb-2 border-b border-slate-800">Recent Tasks</h3>
|
|
{#each recentTasks as task}
|
|
<button
|
|
class="flex items-center gap-3 w-full p-3 mb-2 bg-slate-800 border border-slate-700 rounded-lg cursor-pointer transition-all hover:bg-slate-700 hover:border-slate-600 text-left"
|
|
on:click={() => selectTask(task)}
|
|
>
|
|
<span class="font-mono text-xs text-slate-500">{task.id?.substring(0, 8) || 'N/A'}...</span>
|
|
<span class="flex-1 text-sm text-slate-100 font-medium">{task.plugin_id || 'Unknown'}</span>
|
|
<span class="text-xs font-semibold uppercase px-2 py-1 rounded-full {task.status?.toLowerCase() === 'running' || task.status?.toLowerCase() === 'pending' ? 'bg-cyan-500/15 text-cyan-400' : task.status?.toLowerCase() === 'completed' || task.status?.toLowerCase() === 'success' ? 'bg-green-500/15 text-green-400' : task.status?.toLowerCase() === 'failed' || task.status?.toLowerCase() === 'error' ? 'bg-red-500/15 text-red-400' : 'bg-slate-500/15 text-slate-400'}">{task.status || 'UNKNOWN'}</span>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{:else}
|
|
<div class="flex flex-col items-center justify-center h-full text-slate-500">
|
|
<Icon
|
|
name="clipboard"
|
|
size={48}
|
|
strokeWidth={1.6}
|
|
className="mb-3 text-slate-700"
|
|
/>
|
|
<p>{$t.tasks?.select_task || 'No recent tasks'}</p>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Footer -->
|
|
<div class="flex items-center gap-2 justify-center px-4 py-2.5 border-t border-slate-800 bg-slate-900">
|
|
<div class="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse"></div>
|
|
<p class="text-xs text-slate-500">
|
|
{$t.tasks?.footer_text || 'Task continues running in background'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- [/DEF:TaskDrawer:Component] -->
|