396 lines
17 KiB
Svelte
Executable File
396 lines
17 KiB
Svelte
Executable File
<!-- [DEF:TaskRunner:Component] -->
|
|
<!--
|
|
@SEMANTICS: task, runner, logs, websocket
|
|
@PURPOSE: Connects to a WebSocket to display real-time logs for a running task.
|
|
@LAYER: UI
|
|
@RELATION: DEPENDS_ON -> frontend/src/lib/stores.js
|
|
|
|
@PROPS: None
|
|
@EVENTS: None
|
|
-->
|
|
<script>
|
|
// [SECTION: IMPORTS]
|
|
import { onMount, onDestroy } from 'svelte';
|
|
import { get } from 'svelte/store';
|
|
import { selectedTask, taskLogs } from '../lib/stores.js';
|
|
import { getWsUrl } from '../lib/api.js';
|
|
import { addToast } from '../lib/toasts.js';
|
|
import MissingMappingModal from './MissingMappingModal.svelte';
|
|
import PasswordPrompt from './PasswordPrompt.svelte';
|
|
// [/SECTION]
|
|
|
|
let ws;
|
|
let reconnectAttempts = 0;
|
|
let maxReconnectAttempts = 10;
|
|
let initialReconnectDelay = 1000;
|
|
let maxReconnectDelay = 30000;
|
|
let reconnectTimeout;
|
|
let waitingForData = false;
|
|
let dataTimeout;
|
|
let connectionStatus = 'disconnected'; // 'connecting', 'connected', 'disconnected', 'waiting', 'completed', 'awaiting_mapping', 'awaiting_input'
|
|
let showMappingModal = false;
|
|
let missingDbInfo = { name: '', uuid: '' };
|
|
let targetDatabases = [];
|
|
|
|
let showPasswordPrompt = false;
|
|
let passwordPromptData = { databases: [], errorMessage: '' };
|
|
|
|
// [DEF:connect:Function]
|
|
/**
|
|
* @purpose Establishes WebSocket connection with exponential backoff.
|
|
* @pre selectedTask must be set in the store.
|
|
* @post WebSocket instance created and listeners attached.
|
|
*/
|
|
function connect() {
|
|
const task = get(selectedTask);
|
|
if (!task || connectionStatus === 'completed') return;
|
|
|
|
console.log(`[TaskRunner][Entry] Connecting to logs for task: ${task.id} (Attempt ${reconnectAttempts + 1})`);
|
|
connectionStatus = 'connecting';
|
|
|
|
const wsUrl = getWsUrl(task.id);
|
|
ws = new WebSocket(wsUrl);
|
|
|
|
ws.onopen = () => {
|
|
console.log('[TaskRunner][Coherence:OK] WebSocket connection established');
|
|
connectionStatus = 'connected';
|
|
reconnectAttempts = 0;
|
|
startDataTimeout();
|
|
};
|
|
|
|
ws.onmessage = (event) => {
|
|
const logEntry = JSON.parse(event.data);
|
|
taskLogs.update(logs => [...logs, logEntry]);
|
|
resetDataTimeout();
|
|
|
|
// Check for completion message (if backend sends one)
|
|
if (logEntry.message && logEntry.message.includes('Task completed successfully')) {
|
|
connectionStatus = 'completed';
|
|
ws.close();
|
|
}
|
|
|
|
// Check for missing mapping signal
|
|
if (logEntry.message && logEntry.message.includes('Missing mapping for database UUID')) {
|
|
const uuidMatch = logEntry.message.match(/UUID: ([\w-]+)/);
|
|
if (uuidMatch) {
|
|
missingDbInfo = { name: 'Unknown', uuid: uuidMatch[1] };
|
|
connectionStatus = 'awaiting_mapping';
|
|
fetchTargetDatabases();
|
|
showMappingModal = true;
|
|
}
|
|
}
|
|
|
|
// Check for password request via log context or message
|
|
// Note: The backend logs "Task paused for user input" with context
|
|
if (logEntry.message && logEntry.message.includes('Task paused for user input') && logEntry.context && logEntry.context.input_request) {
|
|
const request = logEntry.context.input_request;
|
|
if (request.type === 'database_password') {
|
|
connectionStatus = 'awaiting_input';
|
|
passwordPromptData = {
|
|
databases: request.databases || [],
|
|
errorMessage: request.error_message || ''
|
|
};
|
|
showPasswordPrompt = true;
|
|
}
|
|
}
|
|
};
|
|
|
|
// Check if task is already awaiting input (e.g. when re-selecting task)
|
|
// We use the 'task' variable from the outer scope (connect function)
|
|
if (task && task.status === 'AWAITING_INPUT' && task.input_request && task.input_request.type === 'database_password') {
|
|
connectionStatus = 'awaiting_input';
|
|
passwordPromptData = {
|
|
databases: task.input_request.databases || [],
|
|
errorMessage: task.input_request.error_message || ''
|
|
};
|
|
showPasswordPrompt = true;
|
|
}
|
|
|
|
ws.onerror = (error) => {
|
|
console.error('[TaskRunner][Coherence:Failed] WebSocket error:', error);
|
|
connectionStatus = 'disconnected';
|
|
};
|
|
|
|
ws.onclose = (event) => {
|
|
console.log(`[TaskRunner][Exit] WebSocket connection closed (Code: ${event.code})`);
|
|
clearTimeout(dataTimeout);
|
|
waitingForData = false;
|
|
|
|
if (connectionStatus !== 'completed' && reconnectAttempts < maxReconnectAttempts) {
|
|
const delay = Math.min(initialReconnectDelay * Math.pow(2, reconnectAttempts), maxReconnectDelay);
|
|
console.log(`[TaskRunner][Action] Reconnecting in ${delay}ms...`);
|
|
reconnectTimeout = setTimeout(() => {
|
|
reconnectAttempts++;
|
|
connect();
|
|
}, delay);
|
|
} else if (reconnectAttempts >= maxReconnectAttempts) {
|
|
console.error('[TaskRunner][Coherence:Failed] Max reconnect attempts reached.');
|
|
addToast('Failed to connect to log stream after multiple attempts.', 'error');
|
|
}
|
|
};
|
|
}
|
|
// [/DEF:connect:Function]
|
|
|
|
// [DEF:fetchTargetDatabases:Function]
|
|
// @PURPOSE: Fetches the list of databases in the target environment.
|
|
// @PRE: task must be selected and have a target environment parameter.
|
|
// @POST: targetDatabases array is populated with database objects.
|
|
async function fetchTargetDatabases() {
|
|
const task = get(selectedTask);
|
|
if (!task || !task.params.to_env) return;
|
|
|
|
try {
|
|
// We need to find the environment ID by name first
|
|
const envsRes = await fetch('/api/environments');
|
|
const envs = await envsRes.json();
|
|
const targetEnv = envs.find(e => e.name === task.params.to_env);
|
|
|
|
if (targetEnv) {
|
|
const res = await fetch(`/api/environments/${targetEnv.id}/databases`);
|
|
targetDatabases = await res.json();
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to fetch target databases', e);
|
|
}
|
|
}
|
|
// [/DEF:fetchTargetDatabases:Function]
|
|
|
|
// [DEF:handleMappingResolve:Function]
|
|
// @PURPOSE: Handles the resolution of a missing database mapping.
|
|
// @PRE: event.detail contains sourceDbUuid, targetDbUuid, and targetDbName.
|
|
// @POST: Mapping is saved and task is resumed.
|
|
async function handleMappingResolve(event) {
|
|
const task = get(selectedTask);
|
|
const { sourceDbUuid, targetDbUuid, targetDbName } = event.detail;
|
|
|
|
try {
|
|
// 1. Save mapping to backend
|
|
const envsRes = await fetch('/api/environments');
|
|
const envs = await envsRes.json();
|
|
const srcEnv = envs.find(e => e.name === task.params.from_env);
|
|
const tgtEnv = envs.find(e => e.name === task.params.to_env);
|
|
|
|
await fetch('/api/mappings', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
source_env_id: srcEnv.id,
|
|
target_env_id: tgtEnv.id,
|
|
source_db_uuid: sourceDbUuid,
|
|
target_db_uuid: targetDbUuid,
|
|
source_db_name: missingDbInfo.name,
|
|
target_db_name: targetDbName
|
|
})
|
|
});
|
|
|
|
// 2. Resolve task
|
|
await fetch(`/api/tasks/${task.id}/resolve`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
resolution_params: { resolved_mapping: { [sourceDbUuid]: targetDbUuid } }
|
|
})
|
|
});
|
|
|
|
connectionStatus = 'connected';
|
|
addToast('Mapping resolved, migration continuing...', 'success');
|
|
} catch (e) {
|
|
addToast('Failed to resolve mapping: ' + e.message, 'error');
|
|
}
|
|
}
|
|
// [/DEF:handleMappingResolve:Function]
|
|
|
|
// [DEF:handlePasswordResume:Function]
|
|
// @PURPOSE: Handles the submission of database passwords to resume a task.
|
|
// @PRE: event.detail contains passwords dictionary.
|
|
// @POST: Task resume endpoint is called with passwords.
|
|
async function handlePasswordResume(event) {
|
|
const task = get(selectedTask);
|
|
const { passwords } = event.detail;
|
|
|
|
try {
|
|
await fetch(`/api/tasks/${task.id}/resume`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ passwords })
|
|
});
|
|
|
|
showPasswordPrompt = false;
|
|
connectionStatus = 'connected';
|
|
addToast('Passwords submitted, resuming migration...', 'success');
|
|
} catch (e) {
|
|
addToast('Failed to resume task: ' + e.message, 'error');
|
|
}
|
|
}
|
|
// [/DEF:handlePasswordResume:Function]
|
|
|
|
// [DEF:startDataTimeout:Function]
|
|
// @PURPOSE: Starts a timeout to detect when the log stream has stalled.
|
|
// @PRE: None.
|
|
// @POST: dataTimeout is set to check connection status after 5s.
|
|
function startDataTimeout() {
|
|
waitingForData = false;
|
|
dataTimeout = setTimeout(() => {
|
|
if (connectionStatus === 'connected') {
|
|
waitingForData = true;
|
|
}
|
|
}, 5000);
|
|
}
|
|
// [/DEF:startDataTimeout:Function]
|
|
|
|
// [DEF:resetDataTimeout:Function]
|
|
// @PURPOSE: Resets the data stall timeout.
|
|
// @PRE: dataTimeout must be active.
|
|
// @POST: dataTimeout is cleared and restarted.
|
|
function resetDataTimeout() {
|
|
clearTimeout(dataTimeout);
|
|
waitingForData = false;
|
|
startDataTimeout();
|
|
}
|
|
// [/DEF:resetDataTimeout:Function]
|
|
|
|
// [DEF:onMount:Function]
|
|
// @PURPOSE: Initializes the component and subscribes to task selection changes.
|
|
// @PRE: Svelte component is mounting.
|
|
// @POST: Store subscription is created and returned for cleanup.
|
|
onMount(() => {
|
|
// Subscribe to selectedTask changes
|
|
const unsubscribe = selectedTask.subscribe(task => {
|
|
if (task) {
|
|
console.log(`[TaskRunner][Action] Task selected: ${task.id}. Initializing connection.`);
|
|
if (ws) ws.close();
|
|
clearTimeout(reconnectTimeout);
|
|
reconnectAttempts = 0;
|
|
connectionStatus = 'disconnected';
|
|
|
|
// Initialize logs from the task object if available
|
|
if (task.logs && Array.isArray(task.logs)) {
|
|
console.log(`[TaskRunner] Loaded ${task.logs.length} existing logs.`);
|
|
taskLogs.set(task.logs);
|
|
} else {
|
|
taskLogs.set([]);
|
|
}
|
|
|
|
connect();
|
|
}
|
|
});
|
|
return unsubscribe;
|
|
});
|
|
// [/DEF:onMount:Function]
|
|
|
|
// [DEF:onDestroy:Function]
|
|
/**
|
|
* @purpose Close WebSocket connection when the component is destroyed.
|
|
* @pre Component is being destroyed.
|
|
* @post WebSocket is closed and timeouts are cleared.
|
|
*/
|
|
onDestroy(() => {
|
|
clearTimeout(reconnectTimeout);
|
|
clearTimeout(dataTimeout);
|
|
if (ws) {
|
|
console.log("[TaskRunner][Action] Closing WebSocket connection.");
|
|
ws.close();
|
|
}
|
|
});
|
|
// [/DEF:onDestroy:Function]
|
|
</script>
|
|
|
|
<!-- [SECTION: TEMPLATE] -->
|
|
<div class="p-4 border rounded-lg bg-white shadow-md">
|
|
{#if $selectedTask}
|
|
<div class="flex justify-between items-center mb-2">
|
|
<h2 class="text-xl font-semibold">Task: {$selectedTask.plugin_id}</h2>
|
|
<div class="flex items-center space-x-2">
|
|
{#if connectionStatus === 'connecting'}
|
|
<span class="flex h-3 w-3 relative">
|
|
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-yellow-400 opacity-75"></span>
|
|
<span class="relative inline-flex rounded-full h-3 w-3 bg-yellow-500"></span>
|
|
</span>
|
|
<span class="text-xs text-gray-500">Connecting...</span>
|
|
{:else if connectionStatus === 'connected'}
|
|
<span class="h-3 w-3 rounded-full bg-green-500"></span>
|
|
<span class="text-xs text-gray-500">Live</span>
|
|
{:else if connectionStatus === 'completed'}
|
|
<span class="h-3 w-3 rounded-full bg-blue-500"></span>
|
|
<span class="text-xs text-gray-500">Completed</span>
|
|
{:else if connectionStatus === 'awaiting_mapping'}
|
|
<span class="h-3 w-3 rounded-full bg-orange-500 animate-pulse"></span>
|
|
<span class="text-xs text-gray-500">Awaiting Mapping</span>
|
|
{:else if connectionStatus === 'awaiting_input'}
|
|
<span class="h-3 w-3 rounded-full bg-orange-500 animate-pulse"></span>
|
|
<span class="text-xs text-gray-500">Awaiting Input</span>
|
|
{:else}
|
|
<span class="h-3 w-3 rounded-full bg-red-500"></span>
|
|
<span class="text-xs text-gray-500">Disconnected</span>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Task Info Section -->
|
|
<div class="mb-4 bg-gray-50 p-3 rounded text-sm border border-gray-200">
|
|
<details open>
|
|
<summary class="cursor-pointer font-medium text-gray-700 focus:outline-none hover:text-indigo-600">Task Details & Parameters</summary>
|
|
<div class="mt-2 pl-2 border-l-2 border-indigo-200">
|
|
<div class="grid grid-cols-2 gap-2 mb-2">
|
|
<div><span class="font-semibold">ID:</span> <span class="text-gray-600">{$selectedTask.id}</span></div>
|
|
<div><span class="font-semibold">Status:</span> <span class="text-gray-600">{$selectedTask.status}</span></div>
|
|
<div><span class="font-semibold">Started:</span> <span class="text-gray-600">{new Date($selectedTask.started_at || $selectedTask.created_at || Date.now()).toLocaleString()}</span></div>
|
|
<div><span class="font-semibold">Plugin:</span> <span class="text-gray-600">{$selectedTask.plugin_id}</span></div>
|
|
</div>
|
|
<div class="mt-1">
|
|
<span class="font-semibold">Parameters:</span>
|
|
<pre class="text-xs bg-gray-100 p-2 rounded mt-1 overflow-x-auto border border-gray-200">{JSON.stringify($selectedTask.params, null, 2)}</pre>
|
|
</div>
|
|
</div>
|
|
</details>
|
|
</div>
|
|
|
|
<div class="bg-gray-900 text-white font-mono text-sm p-4 rounded-md h-96 overflow-y-auto relative shadow-inner">
|
|
{#if $taskLogs.length === 0}
|
|
<div class="text-gray-500 italic text-center mt-10">No logs available for this task.</div>
|
|
{/if}
|
|
{#each $taskLogs as log}
|
|
<div class="hover:bg-gray-800 px-1 rounded">
|
|
<span class="text-gray-500 select-none text-xs w-20 inline-block">{new Date(log.timestamp).toLocaleTimeString()}</span>
|
|
<span class="{log.level === 'ERROR' ? 'text-red-500 font-bold' : log.level === 'WARNING' ? 'text-yellow-400' : 'text-green-400'} w-16 inline-block">[{log.level}]</span>
|
|
<span>{log.message}</span>
|
|
{#if log.context}
|
|
<details class="ml-24">
|
|
<summary class="text-xs text-gray-500 cursor-pointer hover:text-gray-300">Context</summary>
|
|
<pre class="text-xs text-gray-400 pl-2 border-l border-gray-700 mt-1">{JSON.stringify(log.context, null, 2)}</pre>
|
|
</details>
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
|
|
{#if waitingForData && connectionStatus === 'connected'}
|
|
<div class="text-gray-500 italic mt-2 animate-pulse border-t border-gray-800 pt-2">
|
|
Waiting for new logs...
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{:else}
|
|
<p>No task selected.</p>
|
|
{/if}
|
|
</div>
|
|
|
|
<MissingMappingModal
|
|
bind:show={showMappingModal}
|
|
sourceDbName={missingDbInfo.name}
|
|
sourceDbUuid={missingDbInfo.uuid}
|
|
{targetDatabases}
|
|
on:resolve={handleMappingResolve}
|
|
on:cancel={() => { connectionStatus = 'disconnected'; ws.close(); }}
|
|
/>
|
|
|
|
<PasswordPrompt
|
|
bind:show={showPasswordPrompt}
|
|
databases={passwordPromptData.databases}
|
|
errorMessage={passwordPromptData.errorMessage}
|
|
on:resume={handlePasswordResume}
|
|
on:cancel={() => { showPasswordPrompt = false; }}
|
|
/>
|
|
<!-- [/SECTION] -->
|
|
|
|
<!-- [/DEF:TaskRunner:Component] -->
|