614 lines
15 KiB
Svelte
614 lines
15 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";
|
|
|
|
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();
|
|
}
|
|
|
|
// 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="drawer-overlay"
|
|
on:click={handleOverlayClick}
|
|
on:keydown={(e) => e.key === "Escape" && handleClose()}
|
|
role="button"
|
|
tabindex="0"
|
|
aria-label="Close drawer"
|
|
>
|
|
<!-- Drawer Panel -->
|
|
<div
|
|
class="drawer"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label="Task drawer"
|
|
>
|
|
<!-- Header -->
|
|
<div class="drawer-header">
|
|
<div class="header-left">
|
|
{#if !activeTaskId && recentTasks.length > 0}
|
|
<!-- Показываем индикатор что это режим списка -->
|
|
<span class="list-indicator">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/>
|
|
</svg>
|
|
</span>
|
|
{:else if activeTaskId}
|
|
<button
|
|
class="back-btn"
|
|
on:click={goBackToList}
|
|
aria-label="Back to task list"
|
|
>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="16"
|
|
height="16"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
>
|
|
<path d="M19 12H5M12 19l-7-7 7-7" />
|
|
</svg>
|
|
</button>
|
|
{/if}
|
|
<h2 class="drawer-title">
|
|
{activeTaskId ? ($t.tasks?.details_logs || "Task Details & Logs") : "Recent Tasks"}
|
|
</h2>
|
|
{#if shortTaskId}
|
|
<span class="task-id-badge">{shortTaskId}…</span>
|
|
{/if}
|
|
{#if taskStatus}
|
|
<span class="status-badge {taskStatus.toLowerCase()}"
|
|
>{taskStatus}</span
|
|
>
|
|
{/if}
|
|
</div>
|
|
<button
|
|
class="close-btn"
|
|
on:click={handleClose}
|
|
aria-label="Close drawer"
|
|
>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="18"
|
|
height="18"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
>
|
|
<path d="M18 6L6 18M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Content -->
|
|
<div class="drawer-content">
|
|
{#if activeTaskId}
|
|
<TaskLogViewer
|
|
inline={true}
|
|
taskId={activeTaskId}
|
|
{taskStatus}
|
|
{realTimeLogs}
|
|
/>
|
|
{:else if loadingTasks}
|
|
<!-- Loading State -->
|
|
<div class="loading-state">
|
|
<div class="spinner"></div>
|
|
<p>Loading tasks...</p>
|
|
</div>
|
|
{:else if recentTasks.length > 0}
|
|
<!-- Task List -->
|
|
<div class="task-list">
|
|
<h3 class="task-list-title">Recent Tasks</h3>
|
|
{#each recentTasks as task}
|
|
<button
|
|
class="task-item"
|
|
on:click={() => selectTask(task)}
|
|
>
|
|
<span class="task-item-id">{task.id?.substring(0, 8) || 'N/A'}...</span>
|
|
<span class="task-item-plugin">{task.plugin_id || 'Unknown'}</span>
|
|
<span class="task-item-status {task.status?.toLowerCase()}">{task.status || 'UNKNOWN'}</span>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{:else}
|
|
<!-- Empty State -->
|
|
<div class="empty-state">
|
|
<svg
|
|
class="empty-icon"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="1.5"
|
|
>
|
|
<path
|
|
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
|
|
/>
|
|
</svg>
|
|
<p>{$t.tasks?.select_task || "No recent tasks"}</p>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Footer -->
|
|
<div class="drawer-footer">
|
|
<div class="footer-pulse"></div>
|
|
<p class="drawer-footer-text">
|
|
{$t.tasks?.footer_text || "Task continues running in background"}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- [/DEF:TaskDrawer:Component] -->
|
|
|
|
<style>
|
|
.drawer-overlay {
|
|
position: fixed;
|
|
inset: 0;
|
|
background-color: rgba(0, 0, 0, 0.4);
|
|
backdrop-filter: blur(2px);
|
|
z-index: 50;
|
|
}
|
|
|
|
.drawer {
|
|
position: fixed;
|
|
right: 0;
|
|
top: 0;
|
|
height: 100%;
|
|
width: 100%;
|
|
max-width: 560px;
|
|
background-color: #0f172a;
|
|
box-shadow: -8px 0 30px rgba(0, 0, 0, 0.3);
|
|
display: flex;
|
|
flex-direction: column;
|
|
z-index: 50;
|
|
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
}
|
|
|
|
.drawer-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 0.875rem 1.25rem;
|
|
border-bottom: 1px solid #1e293b;
|
|
background-color: #0f172a;
|
|
}
|
|
|
|
.header-left {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.625rem;
|
|
}
|
|
|
|
.drawer-title {
|
|
font-size: 0.875rem;
|
|
font-weight: 600;
|
|
color: #f1f5f9;
|
|
letter-spacing: -0.01em;
|
|
}
|
|
|
|
.task-id-badge {
|
|
font-size: 0.6875rem;
|
|
font-family: "JetBrains Mono", "Fira Code", monospace;
|
|
color: #64748b;
|
|
background-color: #1e293b;
|
|
padding: 0.125rem 0.5rem;
|
|
border-radius: 0.25rem;
|
|
}
|
|
|
|
.status-badge {
|
|
font-size: 0.625rem;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
padding: 0.125rem 0.5rem;
|
|
border-radius: 9999px;
|
|
}
|
|
|
|
.status-badge.running {
|
|
color: #22d3ee;
|
|
background-color: rgba(34, 211, 238, 0.1);
|
|
border: 1px solid rgba(34, 211, 238, 0.2);
|
|
}
|
|
|
|
.status-badge.success {
|
|
color: #4ade80;
|
|
background-color: rgba(74, 222, 128, 0.1);
|
|
border: 1px solid rgba(74, 222, 128, 0.2);
|
|
}
|
|
|
|
.status-badge.failed,
|
|
.status-badge.error {
|
|
color: #f87171;
|
|
background-color: rgba(248, 113, 113, 0.1);
|
|
border: 1px solid rgba(248, 113, 113, 0.2);
|
|
}
|
|
|
|
.close-btn {
|
|
padding: 0.375rem;
|
|
border-radius: 0.375rem;
|
|
color: #64748b;
|
|
background: none;
|
|
border: none;
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
}
|
|
|
|
.close-btn:hover {
|
|
color: #f1f5f9;
|
|
background-color: #1e293b;
|
|
}
|
|
|
|
.back-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 0.375rem;
|
|
border-radius: 0.375rem;
|
|
color: #64748b;
|
|
background: none;
|
|
border: none;
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
margin-right: 0.25rem;
|
|
}
|
|
|
|
.back-btn:hover {
|
|
color: #f1f5f9;
|
|
background-color: #1e293b;
|
|
}
|
|
|
|
.list-indicator {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 0.375rem;
|
|
margin-right: 0.25rem;
|
|
color: #22d3ee;
|
|
}
|
|
|
|
.drawer-content {
|
|
flex: 1;
|
|
overflow: hidden;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.drawer-footer {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
justify-content: center;
|
|
padding: 0.625rem 1rem;
|
|
border-top: 1px solid #1e293b;
|
|
background-color: #0f172a;
|
|
}
|
|
|
|
.footer-pulse {
|
|
width: 6px;
|
|
height: 6px;
|
|
border-radius: 50%;
|
|
background-color: #22d3ee;
|
|
animation: pulse 2s infinite;
|
|
}
|
|
|
|
@keyframes pulse {
|
|
0%,
|
|
100% {
|
|
opacity: 1;
|
|
}
|
|
50% {
|
|
opacity: 0.3;
|
|
}
|
|
}
|
|
|
|
.drawer-footer-text {
|
|
font-size: 0.75rem;
|
|
color: #64748b;
|
|
}
|
|
|
|
.loading-state {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 3rem;
|
|
color: #6b7280;
|
|
}
|
|
|
|
.spinner {
|
|
width: 32px;
|
|
height: 32px;
|
|
border: 3px solid #e5e7eb;
|
|
border-top-color: #3b82f6;
|
|
border-radius: 50%;
|
|
animation: spin 0.8s linear infinite;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
@keyframes spin {
|
|
to {
|
|
transform: rotate(360deg);
|
|
}
|
|
}
|
|
|
|
.task-list {
|
|
padding: 1rem;
|
|
}
|
|
|
|
.task-list-title {
|
|
font-size: 0.875rem;
|
|
font-weight: 600;
|
|
color: #f1f5f9;
|
|
margin-bottom: 1rem;
|
|
padding-bottom: 0.5rem;
|
|
border-bottom: 1px solid #1e293b;
|
|
}
|
|
|
|
.task-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
width: 100%;
|
|
padding: 0.75rem;
|
|
margin-bottom: 0.5rem;
|
|
background: #1e293b;
|
|
border: 1px solid #334155;
|
|
border-radius: 0.5rem;
|
|
cursor: pointer;
|
|
transition: all 0.15s ease;
|
|
text-align: left;
|
|
}
|
|
|
|
.task-item:hover {
|
|
background: #334155;
|
|
border-color: #475569;
|
|
}
|
|
|
|
.task-item-id {
|
|
font-family: monospace;
|
|
font-size: 0.75rem;
|
|
color: #64748b;
|
|
}
|
|
|
|
.task-item-plugin {
|
|
flex: 1;
|
|
font-size: 0.875rem;
|
|
color: #f1f5f9;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.task-item-status {
|
|
font-size: 0.625rem;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
padding: 0.25rem 0.5rem;
|
|
border-radius: 9999px;
|
|
}
|
|
|
|
.task-item-status.running,
|
|
.task-item-status.pending {
|
|
background: rgba(34, 211, 238, 0.15);
|
|
color: #22d3ee;
|
|
}
|
|
|
|
.task-item-status.completed,
|
|
.task-item-status.success {
|
|
background: rgba(74, 222, 128, 0.15);
|
|
color: #4ade80;
|
|
}
|
|
|
|
.task-item-status.failed,
|
|
.task-item-status.error {
|
|
background: rgba(248, 113, 113, 0.15);
|
|
color: #f87171;
|
|
}
|
|
|
|
.task-item-status.cancelled {
|
|
background: rgba(100, 116, 139, 0.15);
|
|
color: #94a3b8;
|
|
}
|
|
|
|
.empty-state {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
height: 100%;
|
|
color: #475569;
|
|
}
|
|
|
|
.empty-icon {
|
|
width: 3rem;
|
|
height: 3rem;
|
|
margin-bottom: 0.75rem;
|
|
color: #334155;
|
|
}
|
|
|
|
.empty-state p {
|
|
font-size: 0.875rem;
|
|
color: #475569;
|
|
}
|
|
</style>
|