Files
ss-tools/frontend/src/lib/components/layout/TaskDrawer.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] -->