websocket add

This commit is contained in:
2026-05-31 17:17:30 +03:00
parent 18f88a6928
commit e17f4f64a9
14 changed files with 590 additions and 141 deletions

View File

@@ -11,7 +11,7 @@
<script>
import { onMount, onDestroy } from 'svelte';
import { selectedTask } from '../lib/stores.js';
import { api } from '../lib/api.js';
import { api, getTaskEventsWsUrl } from '../lib/api.js';
import { t } from '../lib/i18n';
/** @type {string | undefined} — if set, only shows tasks with this plugin_id (e.g. "superset-migration") */
@@ -20,10 +20,10 @@
let tasks = $state([]);
let loading = $state(true);
let error = $state("");
let interval;
let taskWs = null;
// #region fetchTasks:Function [TYPE Function]
// @PURPOSE: Fetches the list of recent tasks from the API.
// @PURPOSE: Fetches the list of recent tasks from the API (initial load).
// @PRE: None.
// @POST: tasks array is updated and selectedTask status synchronized.
async function fetchTasks() {
@@ -31,13 +31,6 @@
const filterParam = pluginId ? `&plugin_id=${encodeURIComponent(pluginId)}` : '';
tasks = await api.fetchApi(`/tasks?limit=10${filterParam}`);
// [DEBUG] Check for tasks requiring attention
tasks.forEach(t => {
if (t.status === 'AWAITING_MAPPING' || t.status === 'AWAITING_INPUT') {
console.log(`[TaskHistory] Task ${t.id} is in state ${t.status}. Input required: ${t.input_required}`);
}
});
// Update selected task if it exists in the list (for status updates)
if ($selectedTask) {
const updatedTask = tasks.find(t => t.id === $selectedTask.id);
@@ -52,7 +45,60 @@
}
}
// #endregion fetchTasks:Function
// #region connectTaskEventsWs:Function [TYPE Function]
// @PURPOSE: Opens a WebSocket to /ws/task-events for real-time task status updates.
// @POST: WebSocket is connected and handlers attached.
function connectTaskEventsWs() {
try {
const url = getTaskEventsWsUrl();
taskWs = new WebSocket(url);
taskWs.onopen = () => {
console.log("[TaskHistory][WS] Connected to task events");
};
taskWs.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === "task_status") {
const updatedTask = data.task;
const idx = tasks.findIndex(t => t.id === updatedTask.id);
if (idx >= 0) {
// Create new array to trigger $state reactivity
tasks = tasks.map((t, i) =>
i === idx ? { ...t, ...updatedTask } : t
);
} else if (!pluginId || updatedTask.plugin_id === pluginId) {
// New task — add to top, keep max 10
tasks = [updatedTask, ...tasks].slice(0, 10);
}
}
} catch (_e) {
// Ignore parse errors
}
};
taskWs.onerror = () => {
console.warn("[TaskHistory][WS] Error, will retry");
// Don't set taskWs to null — onclose will handle reconnect
};
taskWs.onclose = () => {
console.log("[TaskHistory][WS] Disconnected");
taskWs = null;
// Reconnect after 10s
setTimeout(() => {
if (taskWs) return; // already reconnected
connectTaskEventsWs();
}, 10000);
};
} catch (_e) {
taskWs = null;
}
}
// #endregion connectTaskEventsWs:Function
// #region clearTasks:Function [TYPE Function]
// @PURPOSE: Clears tasks from the history, optionally filtered by status.
// @PRE: User confirms deletion via prompt.
@@ -92,33 +138,35 @@
// @PRE: status string is provided.
// @POST: Returns tailwind color class string.
function getStatusColor(status) {
switch (status) {
case 'SUCCESS': return 'bg-green-100 text-green-800';
case 'FAILED': return 'bg-red-100 text-red-800';
case 'RUNNING': return 'bg-blue-100 text-blue-800';
case 'AWAITING_INPUT': return 'bg-orange-100 text-orange-800';
case 'AWAITING_MAPPING': return 'bg-yellow-100 text-yellow-800';
default: return 'bg-gray-100 text-gray-800';
}
const s = status?.toLowerCase();
if (s === "success" || s === "completed") return "bg-green-100 text-green-800";
if (s === "failed" || s === "error") return "bg-red-100 text-red-800";
if (s === "running" || s === "in_progress") return "bg-blue-100 text-blue-800";
if (s === "partial" || s === "partial_success") return "bg-amber-100 text-amber-800";
return "bg-gray-100 text-gray-800";
}
// #endregion getStatusColor:Function
// #region onMount:Function [TYPE Function]
// @PURPOSE: Initializes the component by fetching tasks and starting polling.
// @PURPOSE: Initializes the component by fetching tasks and starting WebSocket.
// @PRE: Component is mounting.
// @POST: Tasks are fetched and 5s polling interval is started.
// @POST: Tasks are fetched via HTTP, WebSocket connects for real-time updates.
onMount(() => {
fetchTasks();
interval = setInterval(fetchTasks, 5000); // Poll every 5s
connectTaskEventsWs();
});
// #endregion onMount:Function
// #region onDestroy:Function [TYPE Function]
// @PURPOSE: Cleans up the polling interval when the component is destroyed.
// @PURPOSE: Closes the WebSocket when the component is destroyed.
// @PRE: Component is being destroyed.
// @POST: Polling interval is cleared.
// @POST: WebSocket is closed.
onDestroy(() => {
clearInterval(interval);
if (taskWs) {
taskWs.onclose = null; // prevent reconnect
taskWs.close();
taskWs = null;
}
});
// #endregion onDestroy:Function
</script>

View File

@@ -12,7 +12,6 @@
@INVARIANT: Real-time logs are always appended without duplicates.
-->
<script>
import { onDestroy } from "svelte";
import { getTaskLogs } from "../services/taskService.js";
import { t } from "../lib/i18n";
import TaskLogPanel from "./tasks/TaskLogPanel.svelte";
@@ -30,7 +29,6 @@
let logs = $state([]);
let loading = $state(false);
let error = $state("");
let interval;
let autoScroll = $state(true);
let shouldShow = $derived(inline || show);
@@ -72,27 +70,13 @@
$effect(() => {
if (shouldShow && taskId) {
if (interval) clearInterval(interval);
logs = [];
loading = true;
error = "";
// Load historical logs once on open — real-time logs come via realTimeLogs prop from WebSocket
fetchLogs();
if (
taskStatus === "RUNNING" ||
taskStatus === "AWAITING_INPUT" ||
taskStatus === "AWAITING_MAPPING"
) {
interval = setInterval(fetchLogs, 5000);
}
} else {
if (interval) clearInterval(interval);
}
});
onDestroy(() => {
if (interval) clearInterval(interval);
});
</script>
{#if shouldShow}

View File

@@ -3,7 +3,7 @@
// @PURPOSE: Unit tests for TaskLogViewer component by mounting it and observing the DOM.
// @LAYER UI (Tests)
// @RELATION DEPENDS_ON -> [TaskLogViewer]
// @INVARIANT: Duplicate logs are never appended. Polling only active for in-progress tasks.
// @INVARIANT: Duplicate logs are never appended. Historical logs loaded once on open; real-time logs via prop.
// @TEST_CONTRACT: TaskLogViewerPropsAndLogStream -> RenderedLogTimeline
// @TEST_SCENARIO: historical_and_realtime_merge -> Historical logs render and realtime logs append without duplication.
// @TEST_FIXTURE: valid_viewer -> INLINE_JSON

View File

@@ -59,6 +59,38 @@ export const getWsUrl = (taskId) => {
return url;
};
/**
* Build a WebSocket URL for global task events, including auth token.
*/
export const getTaskEventsWsUrl = () => {
const protocol = typeof window !== 'undefined' && window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = typeof window !== 'development' && typeof window !== 'undefined' ? window.location.host : 'localhost:8000';
let url = `${protocol}//${host}/ws/task-events`;
if (typeof window !== 'undefined') {
const token = localStorage.getItem('auth_token');
if (token) {
url += `?token=${encodeURIComponent(token)}`;
}
}
return url;
};
/**
* Build a WebSocket URL for maintenance events, including auth token.
*/
export const getMaintenanceEventsWsUrl = () => {
const protocol = typeof window !== 'undefined' && window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = typeof window !== 'development' && typeof window !== 'undefined' ? window.location.host : 'localhost:8000';
let url = `${protocol}//${host}/ws/maintenance/events`;
if (typeof window !== 'undefined') {
const token = localStorage.getItem('auth_token');
if (token) {
url += `?token=${encodeURIComponent(token)}`;
}
}
return url;
};
/**
* Build a WebSocket URL for translation run progress streaming.
* The token is appended as a query param for WebSocket auth.

View File

@@ -66,13 +66,13 @@
import { gitService } from "../../../services/gitService.js";
let ws = null;
let wsReconnectTimer = null;
let realTimeLogs = $state([]);
let taskStatus = $state(null);
let recentTasks = $state([]);
let loadingTasks = $state(false);
let activeTaskDetails = $state(null);
let environmentOptions = [];
let taskDetailsPollInterval = null;
let diffText = $state("");
let showDiff = $state(false);
let isDiffLoading = $state(false);
@@ -168,13 +168,6 @@
return "bg-slate-100 text-slate-700 ring-1 ring-slate-200";
}
function stopTaskDetailsPolling() {
if (taskDetailsPollInterval) {
clearInterval(taskDetailsPollInterval);
taskDetailsPollInterval = null;
}
}
async function loadEnvironmentOptions() {
try {
const response = await api.getEnvironments();
@@ -222,21 +215,6 @@
return `${baseUrl}/superset/dashboard/${encodeURIComponent(String(dashboardId))}/`;
}
async function loadActiveTaskDetails() {
const taskId = normalizeTaskId(activeTaskId);
if (!taskId) return;
try {
const task = await api.getTask(taskId);
activeTaskDetails = task;
taskStatus = task?.status || taskStatus;
if (isTaskFinished(task?.status)) {
stopTaskDetailsPolling();
}
} catch (err) {
console.error("[TaskDrawer][loadActiveTaskDetails][FAILED]", err);
}
}
function extractPrimaryDashboardId(task) {
const result = task?.result || {};
const params = task?.params || {};
@@ -426,8 +404,18 @@
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log(`[TaskDrawer][WebSocket][Message_Received] ${data.message}`);
// ── Task status update from WebSocket ──
if (data.type === "task_status") {
const taskData = data.task;
console.log(`[TaskDrawer][WebSocket][Status_Update] ${taskData.status}`, taskData);
activeTaskDetails = taskData;
taskStatus = taskData.status;
return;
}
// ── Log entry (backward compatible, no type field) ──
console.log(`[TaskDrawer][WebSocket][Log_Received] ${data.message}`);
realTimeLogs = [...realTimeLogs, data];
if (data.message?.includes("Task completed successfully")) {
@@ -442,7 +430,11 @@
};
ws.onclose = () => {
console.log("[TaskDrawer][WebSocket] Connection closed");
console.log("[TaskDrawer][WebSocket] Disconnected — reconnecting in 10s");
ws = null;
wsReconnectTimer = setTimeout(() => {
if (activeTaskId) connectWebSocket();
}, 10000);
};
}
@@ -456,7 +448,12 @@
*/
function disconnectWebSocket() {
console.log("[TaskDrawer][WebSocket][disconnectWebSocket:START]");
if (wsReconnectTimer) {
clearTimeout(wsReconnectTimer);
wsReconnectTimer = null;
}
if (ws) {
ws.onclose = null; // prevent reconnect
ws.close();
ws = null;
}
@@ -532,18 +529,14 @@
showDiff = false;
diffText = "";
taskStatus = "RUNNING";
activeTaskDetails = null;
connectWebSocket();
void loadEnvironmentOptions();
void loadActiveTaskDetails();
stopTaskDetailsPolling();
taskDetailsPollInterval = setInterval(loadActiveTaskDetails, 4000);
} else {
stopTaskDetailsPolling();
activeTaskDetails = null;
void loadRecentTasks();
}
} else {
stopTaskDetailsPolling();
activeTaskDetails = null;
}
});
@@ -555,7 +548,9 @@
// Cleanup on destroy
onDestroy(() => {
stopTaskDetailsPolling();
if (wsReconnectTimer) {
clearTimeout(wsReconnectTimer);
}
disconnectWebSocket();
});
</script>

View File

@@ -59,8 +59,6 @@ export const REPORT_TYPE_PROFILES = {
// @TEST_INVARIANT: fallbacks_to_unknown -> verifies: [invalid_type]
export function getReportTypeProfile(taskType) {
const key = typeof taskType === 'string' ? taskType : 'unknown';
console.log("[reports][ui][getReportTypeProfile][STATE:START]");
console.log("[reports][ui][getReportTypeProfile] Resolved type '" + taskType + "' to profile '" + key + "'");
return REPORT_TYPE_PROFILES[key] || REPORT_TYPE_PROFILES.unknown;
}
// #endregion getReportTypeProfile:Function

View File

@@ -1,20 +1,21 @@
// #region MaintenanceStore [C:3] [TYPE Module] [SEMANTICS store, maintenance, svelte, runes]
// @BRIEF Svelte 5 (Runes) store for maintenance banner state. Auto-polls events every 30s via interval.
// #region MaintenanceStore [C:3] [TYPE Module] [SEMANTICS store, maintenance, svelte, runes, websocket]
// @BRIEF Svelte 5 (Runes) store for maintenance banner state. Uses WebSocket for real-time events.
// @LAYER Frontend
// @RELATION DEPENDS_ON -> [MaintenanceApi]
// @RELATION DEPENDS_ON -> [EXT:frontend:addToast]
// @RELATION DEPENDS_ON -> [EXT:frontend:getMaintenanceEventsWsUrl]
// @UI_STATE Idle -> events loaded, settings loaded, banners loaded
// @UI_STATE Loading -> isLoading = true during fetches
// @UI_STATE Error -> error state populated with message
// @ADR-0007: Uses $effect(() => subscribe(...)) pattern, NOT fromStore + $derived.
// @UX_REACTIVITY $state for events/settings/banners. Manual setInterval for polling.
// @UX_RECOVERY Error toast on failure. Retry via loadEvents/loadSettings.
// @UX_REACTIVITY $state for events/settings/banners. WebSocket for real-time updates.
// @UX_RECOVERY WebSocket reconnect after 10s on disconnect. Fallback to HTTP init().
// @RATIONALE Replaced 30s polling with WebSocket for instant maintenance banner updates
// on the dashboard hub page and maintenance events page.
import { addToast } from "$lib/toasts.js";
import { getMaintenanceEventsWsUrl } from "$lib/api.js";
import * as maintenanceApi from "$lib/api/maintenance.js";
const POLL_INTERVAL_MS = 30000;
/**
* @returns {object} Maintenance store with runes state and methods.
*/
@@ -26,11 +27,64 @@ export function createMaintenanceStore() {
let dashboardBanners = $state([]);
let isLoading = $state(false);
let error = $state(null);
let _pollInterval = null;
let _ws = null;
// #endregion MaintenanceStore.state
// #region MaintenanceStore.methods [C:2] [TYPE Block]
/**
* Connect to WebSocket for real-time maintenance events.
* Replaces the 30s polling interval.
*/
function connectWs() {
if (_ws) return; // already connected
try {
const url = getMaintenanceEventsWsUrl();
_ws = new WebSocket(url);
_ws.onopen = () => {
console.log("[MaintenanceStore][WS] Connected");
};
_ws.onmessage = async (event) => {
try {
const data = JSON.parse(event.data);
console.log("[MaintenanceStore][WS] Event:", data.type);
// On any maintenance event, reload events and dashboard banners
await Promise.all([loadEvents(), loadDashboardBanners()]);
} catch (_e) {
// Ignore parse errors
}
};
_ws.onerror = () => {
console.warn("[MaintenanceStore][WS] Error");
};
_ws.onclose = () => {
console.log("[MaintenanceStore][WS] Disconnected — reconnecting in 10s");
_ws = null;
setTimeout(() => {
if (_ws) return; // already reconnected
connectWs();
}, 10000);
};
} catch (_e) {
_ws = null;
}
}
/**
* Disconnect WebSocket.
*/
function disconnectWs() {
if (_ws) {
_ws.onclose = null; // prevent reconnect
_ws.close();
_ws = null;
}
}
/**
* Load events from API.
*/
@@ -84,6 +138,7 @@ export function createMaintenanceStore() {
if (data?.task_id) {
addToast("Removal task started", "info");
}
// Immediate optimistic refresh; WS will also trigger reload
await loadEvents();
} catch (e) {
const msg = e?.message || "Failed to end maintenance event";
@@ -101,6 +156,7 @@ export function createMaintenanceStore() {
if (data?.task_id) {
addToast("Bulk removal task started", "info");
}
// Immediate optimistic refresh; WS will also trigger reload
await loadEvents();
} catch (e) {
const msg = e?.message || "Failed to end all events";
@@ -128,31 +184,11 @@ export function createMaintenanceStore() {
}
/**
* Initialize the store: load all data once and start polling.
* Initialize the store: load all data once and connect WebSocket.
*/
async function init() {
await Promise.all([loadEvents(), loadSettings(), loadDashboardBanners()]);
startPolling();
}
/**
* Start auto-polling for events every 30s.
*/
function startPolling() {
if (_pollInterval) return;
_pollInterval = setInterval(async () => {
await loadEvents();
}, POLL_INTERVAL_MS);
}
/**
* Stop auto-polling.
*/
function stopPolling() {
if (_pollInterval) {
clearInterval(_pollInterval);
_pollInterval = null;
}
connectWs();
}
// #endregion MaintenanceStore.methods
@@ -171,8 +207,7 @@ export function createMaintenanceStore() {
endAllEvents,
updateSettings,
init,
startPolling,
stopPolling,
disconnectWs,
};
}

View File

@@ -30,7 +30,7 @@
});
onDestroy(() => {
maintenanceStore.stopPolling();
maintenanceStore.disconnectWs();
});
</script>

View File

@@ -30,8 +30,8 @@ const { mockStoreState, mockStore } = vi.hoisted(() => {
endAllEvents: vi.fn(),
updateSettings: vi.fn(),
init: vi.fn(),
startPolling: vi.fn(),
stopPolling: vi.fn(),
connectWs: vi.fn(),
disconnectWs: vi.fn(),
};
return { mockStoreState: sharedState, mockStore: store };