Files
ss-tools/frontend/src/components/TaskRunner.svelte
busya 2ffc3cc68f feat(migration): implement interactive mapping resolution workflow
- Add SQLite database integration for environments and mappings
- Update TaskManager to support pausing tasks (AWAITING_MAPPING)
- Modify MigrationPlugin to detect missing mappings and wait for resolution
- Add frontend UI for handling missing mappings interactively
- Create dedicated migration routes and API endpoints
- Update .gitignore and project documentation
2025-12-25 22:27:29 +03:00

269 lines
10 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';
// [/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'
let showMappingModal = false;
let missingDbInfo = { name: '', uuid: '' };
let targetDatabases = [];
// [DEF:connect:Function]
/**
* @purpose Establishes WebSocket connection with exponential backoff.
*/
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;
}
}
};
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]
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);
}
}
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');
}
}
function startDataTimeout() {
waitingForData = false;
dataTimeout = setTimeout(() => {
if (connectionStatus === 'connected') {
waitingForData = true;
}
}, 5000);
}
function resetDataTimeout() {
clearTimeout(dataTimeout);
waitingForData = false;
startDataTimeout();
}
// [DEF:onMount:Function]
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';
taskLogs.set([]);
connect();
}
});
return unsubscribe;
});
// [/DEF:onMount]
// [DEF:onDestroy:Function]
/**
* @purpose Close WebSocket connection when the component is destroyed.
*/
onDestroy(() => {
clearTimeout(reconnectTimeout);
clearTimeout(dataTimeout);
if (ws) {
console.log("[TaskRunner][Action] Closing WebSocket connection.");
ws.close();
}
});
// [/DEF:onDestroy]
</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}
<span class="h-3 w-3 rounded-full bg-red-500"></span>
<span class="text-xs text-gray-500">Disconnected</span>
{/if}
</div>
</div>
<div class="bg-gray-900 text-white font-mono text-sm p-4 rounded-md h-96 overflow-y-auto relative">
{#each $taskLogs as log}
<div>
<span class="text-gray-400">{new Date(log.timestamp).toLocaleTimeString()}</span>
<span class="{log.level === 'ERROR' ? 'text-red-500' : 'text-green-400'}">[{log.level}]</span>
<span>{log.message}</span>
</div>
{/each}
{#if waitingForData}
<div class="text-gray-500 italic mt-2 animate-pulse">
Waiting for data...
</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(); }}
/>
<!-- [/SECTION] -->
<!-- [/DEF:TaskRunner] -->