diff --git a/frontend/src/lib/stores.js b/frontend/src/lib/stores.js deleted file mode 100755 index e2050d67..00000000 --- a/frontend/src/lib/stores.js +++ /dev/null @@ -1,76 +0,0 @@ -// #region StoresModule [C:3] [TYPE Module] [SEMANTICS store, state, writable, plugins, tasks] -// @BRIEF Global Svelte writable stores for plugins, tasks, and UI page state management. -// @RELATION DEPENDS_ON -> [ApiModule] - -// #region stores_module:Module [TYPE Function] -// @RELATION USES -> [EXT:frontend:App] -// @SEMANTICS: state, stores, svelte, plugins, tasks -// @PURPOSE: Global state management using Svelte stores. -// @LAYER UI - -import { writable } from 'svelte/store'; -import { api } from './api.js'; - -// #region plugins:Data [TYPE Function] -// @PURPOSE: Store for the list of available plugins. -export const plugins = writable([]); -// #endregion plugins:Data - -// #region tasks:Data [TYPE Function] -// @PURPOSE: Store for the list of tasks. -export const tasks = writable([]); -// #endregion tasks:Data - -// #region selectedPlugin:Data [TYPE Function] -// @PURPOSE: Store for the currently selected plugin. -export const selectedPlugin = writable(null); -// #endregion selectedPlugin:Data - -// #region selectedTask:Data [TYPE Function] -// @PURPOSE: Store for the currently selected task. -export const selectedTask = writable(null); -// #endregion selectedTask:Data - -// #region currentPage:Data [TYPE Function] -// @PURPOSE: Store for the current page. -export const currentPage = writable('dashboard'); -// #endregion currentPage:Data - -// #region taskLogs:Data [TYPE Function] -// @PURPOSE: Store for the logs of the currently selected task. -export const taskLogs = writable([]); -// #endregion taskLogs:Data - -// #region fetchPlugins:Function [TYPE Function] -// @PURPOSE: Fetches plugins from the API and updates the plugins store. -// @PRE: None. -// @POST: plugins store is updated with data from the API. -export async function fetchPlugins() { - try { - console.log("[stores.fetchPlugins][Action] Fetching plugins."); - const data = await api.getPlugins(); - console.log("[stores.fetchPlugins][Coherence:OK] Plugins fetched context={{'count': " + data.length + "}}"); - plugins.set(data); - } catch (error) { - console.error(`[stores.fetchPlugins][Coherence:Failed] Error fetching plugins context={{'error': '${error}'}}`); - } -} -// #endregion fetchPlugins:Function - -// #region fetchTasks:Function [TYPE Function] -// @PURPOSE: Fetches tasks from the API and updates the tasks store. -// @PRE: None. -// @POST: tasks store is updated with data from the API. -export async function fetchTasks() { - try { - console.log("[stores.fetchTasks][Action] Fetching tasks."); - const data = await api.getTasks(); - console.log("[stores.fetchTasks][Coherence:OK] Tasks fetched context={{'count': " + data.length + "}}"); - tasks.set(data); - } catch (error) { - console.error(`[stores.fetchTasks][Coherence:Failed] Error fetching tasks context={{'error': '${error}'}}`); - } -} -// #endregion fetchTasks:Function -// #endregion stores_module:Module -// #endregion StoresModule \ No newline at end of file diff --git a/frontend/src/lib/stores.ts b/frontend/src/lib/stores.ts new file mode 100644 index 00000000..68a547f3 --- /dev/null +++ b/frontend/src/lib/stores.ts @@ -0,0 +1,64 @@ +// #region StoresModule [C:3] [TYPE Module] [SEMANTICS store, state, writable, plugins, tasks] +// @BRIEF Global Svelte writable stores for plugins, tasks, and UI page state management. +// @LAYER UI +// @RELATION DEPENDS_ON -> [ApiModule] +// @POST All stores are initialized with sensible defaults. fetchPlugins/fetchTasks populate on call. + +import { writable, type Writable } from 'svelte/store'; +import { api } from './api'; +import type { Plugin, Task } from '../types/models'; + +// #region plugins [C:2] [TYPE Store] [SEMANTICS plugins, store] +export const plugins: Writable = writable([]); +// #endregion plugins + +// #region tasks [C:2] [TYPE Store] [SEMANTICS tasks, store] +export const tasks: Writable = writable([]); +// #endregion tasks + +// #region selectedPlugin [C:1] [TYPE Store] +export const selectedPlugin: Writable = writable(null); +// #endregion selectedPlugin + +// #region selectedTask [C:1] [TYPE Store] +export const selectedTask: Writable = writable(null); +// #endregion selectedTask + +// #region currentPage [C:1] [TYPE Store] +export const currentPage: Writable = writable('dashboard'); +// #endregion currentPage + +// #region taskLogs [C:1] [TYPE Store] +export const taskLogs: Writable = writable([]); +// #endregion taskLogs + +// #region fetchPlugins [C:2] [TYPE Function] [SEMANTICS plugins, fetch] +// @BRIEF Fetches plugins from the API and updates the plugins store. +// @PRE None. +// @POST plugins store is updated with data from the API. +// @SIDE_EFFECT Makes API call; updates writable store on success. +export async function fetchPlugins(): Promise { + try { + const data = await api.getPlugins(); + plugins.set(data); + } catch (error) { + console.error(`[stores.fetchPlugins] Error fetching plugins:`, error); + } +} +// #endregion fetchPlugins + +// #region fetchTasks [C:2] [TYPE Function] [SEMANTICS tasks, fetch] +// @BRIEF Fetches tasks from the API and updates the tasks store. +// @PRE None. +// @POST tasks store is updated with data from the API. +// @SIDE_EFFECT Makes API call; updates writable store on success. +export async function fetchTasks(): Promise { + try { + const data = await api.getTasks(); + tasks.set(data); + } catch (error) { + console.error(`[stores.fetchTasks] Error fetching tasks:`, error); + } +} +// #endregion fetchTasks +// #endregion StoresModule diff --git a/frontend/src/lib/stores/activity.js b/frontend/src/lib/stores/activity.js deleted file mode 100644 index a4a1c0d1..00000000 --- a/frontend/src/lib/stores/activity.js +++ /dev/null @@ -1,36 +0,0 @@ -// #region ActivityStore [C:2] [TYPE Store] [SEMANTICS activity, store, derived, task-count, badge] -// @BRIEF Derived store that counts active running tasks for navbar indicator badge. - -// #region activity:Store [TYPE Function] -// @PURPOSE: Track active task count for navbar indicator -// @LAYER UI -// @RELATION DEPENDS_ON -> [EXT:frontend:taskDrawer] - -import { derived } from 'svelte/store'; -import { taskDrawerStore } from './taskDrawer.js'; - -/** - * Derived store that counts active tasks - * @UX_STATE: Idle -> No active tasks, badge hidden - * @UX_STATE: Active -> Badge shows count of running tasks - */ -export const activityStore = derived(taskDrawerStore, ($drawer) => { - const activeCount = Object.values($drawer.resourceTaskMap) - .filter(t => t.status === 'RUNNING').length; - - console.log(`[EXT:frontend:activityStore][State] Active count: ${activeCount}`); - - return { - activeCount, - recentTasks: Object.entries($drawer.resourceTaskMap) - .map(([resourceId, taskInfo]) => ({ - taskId: taskInfo.taskId, - resourceId, - status: taskInfo.status - })) - .slice(-5) // Last 5 tasks - }; -}); - -// #endregion activity:Store -// #endregion ActivityStore diff --git a/frontend/src/lib/stores/activity.ts b/frontend/src/lib/stores/activity.ts new file mode 100644 index 00000000..fa71539a --- /dev/null +++ b/frontend/src/lib/stores/activity.ts @@ -0,0 +1,49 @@ +// #region ActivityStore [C:2] [TYPE Store] [SEMANTICS activity, store, derived, task-count, badge] +// @BRIEF Derived store that counts active running tasks for navbar indicator badge. +// @LAYER UI +// @RELATION DEPENDS_ON -> [TaskDrawerStore] +// @UX_STATE Idle -> No active tasks, badge hidden +// @UX_STATE Active -> Badge shows count of running tasks + +import { derived } from 'svelte/store'; +import { taskDrawerStore } from './taskDrawer'; + +interface TaskInfo { + taskId: string; + status: string; +} + +interface ResourceTaskMap { + [k: string]: TaskInfo; +} + +interface DrawerState { + isOpen: boolean; + activeTaskId: string | null; + resourceTaskMap: ResourceTaskMap; +} + +interface ActivityState { + activeCount: number; + recentTasks: { taskId: string; resourceId: string; status: string }[]; +} + +export const activityStore = derived( + taskDrawerStore, + ($drawer: DrawerState) => { + const activeCount = Object.values($drawer.resourceTaskMap) + .filter(t => t.status === 'RUNNING').length; + + return { + activeCount, + recentTasks: Object.entries($drawer.resourceTaskMap) + .map(([resourceId, taskInfo]) => ({ + taskId: taskInfo.taskId, + resourceId, + status: taskInfo.status, + })) + .slice(-5), + }; + } +); +// #endregion ActivityStore diff --git a/frontend/src/lib/stores/assistantChat.js b/frontend/src/lib/stores/assistantChat.js deleted file mode 100644 index c8979263..00000000 --- a/frontend/src/lib/stores/assistantChat.js +++ /dev/null @@ -1,122 +0,0 @@ -// #region AssistantChatStore [C:3] [TYPE Store] [SEMANTICS assistant, chat, store, session, panel] -// @BRIEF Control assistant chat panel visibility, conversation binding, session context, and seeded prompts. -// @RELATION BINDS_TO -> [EXT:frontend:AssistantChatPanel] -// @UX_STATE Closed -> Chat panel hidden -// @UX_STATE Open -> Chat panel visible with active conversation - -// #region assistantChat:Store [TYPE Function] -// @PURPOSE: Control assistant chat panel visibility and active conversation binding. -// @RELATION BINDS_TO -> [EXT:frontend:AssistantChatPanel] -// - -import { writable } from 'svelte/store'; - -const initialState = { - isOpen: false, - conversationId: null, - datasetReviewSessionId: null, - seedMessage: '', - focusTarget: null, -}; - -export const assistantChatStore = writable(initialState); - -// #region toggleAssistantChat:Function [TYPE Function] -// @PURPOSE: Toggle assistant panel visibility. -// @PRE: Store is initialized. -// @POST: isOpen value inverted. -export function toggleAssistantChat() { - assistantChatStore.update((state) => { - const next = { ...state, isOpen: !state.isOpen }; - console.log(`[EXT:frontend:assistantChat][${next.isOpen ? 'Open' : 'Closed'}] toggleAssistantChat`); - return next; - }); -} -// #endregion toggleAssistantChat:Function - -// #region openAssistantChat:Function [TYPE Function] -// @PURPOSE: Open assistant panel. -// @PRE: Store is initialized. -// @POST: isOpen = true. -export function openAssistantChat() { - assistantChatStore.update((state) => { - const next = { ...state, isOpen: true }; - console.log('[EXT:frontend:assistantChat][Open] openAssistantChat'); - return next; - }); -} -// #endregion openAssistantChat:Function - -// #region openAssistantChatWithContext:Function [TYPE Function] -// @PURPOSE: Open assistant panel with dataset review session context and optional seeded prompt/focus target. -// @PRE: Context payload may be partial. -// @POST: Assistant drawer opens with session binding and visible focus metadata. -export function openAssistantChatWithContext(context = {}) { - assistantChatStore.update((state) => { - const next = { - ...state, - isOpen: true, - datasetReviewSessionId: - context.datasetReviewSessionId ?? state.datasetReviewSessionId ?? null, - seedMessage: context.seedMessage ?? '', - focusTarget: context.focusTarget ?? null, - }; - console.log('[EXT:frontend:assistantChat][Open] openAssistantChatWithContext'); - return next; - }); -} -// #endregion openAssistantChatWithContext:Function - -// #region closeAssistantChat:Function [TYPE Function] -// @PURPOSE: Close assistant panel. -// @PRE: Store is initialized. -// @POST: isOpen = false. -export function closeAssistantChat() { - assistantChatStore.update((state) => { - const next = { ...state, isOpen: false }; - console.log('[EXT:frontend:assistantChat][Closed] closeAssistantChat'); - return next; - }); -} -// #endregion closeAssistantChat:Function - -// #region setAssistantConversationId:Function [TYPE Function] -// @PURPOSE: Bind current conversation id in UI state. -// @PRE: conversationId is string-like identifier. -// @POST: store.conversationId updated. -export function setAssistantConversationId(conversationId) { - assistantChatStore.update((state) => { - console.log('[EXT:frontend:assistantChat][ConversationBound] setAssistantConversationId'); - return { ...state, conversationId }; - }); -} -// #endregion setAssistantConversationId:Function - -// #region setAssistantDatasetReviewSessionId:Function [TYPE Function] -// @PURPOSE: Bind active dataset review session to assistant state. -// @PRE: session identifier may be null when workspace context is cleared. -// @POST: store.datasetReviewSessionId updated. -export function setAssistantDatasetReviewSessionId(datasetReviewSessionId) { - assistantChatStore.update((state) => ({ ...state, datasetReviewSessionId })); -} -// #endregion setAssistantDatasetReviewSessionId:Function - -// #region setAssistantSeedMessage:Function [TYPE Function] -// @PURPOSE: Stage a seeded assistant prompt for contextual send UX. -// @PRE: seedMessage can be empty to clear staged draft. -// @POST: store.seedMessage updated. -export function setAssistantSeedMessage(seedMessage) { - assistantChatStore.update((state) => ({ ...state, seedMessage: seedMessage || '' })); -} -// #endregion setAssistantSeedMessage:Function - -// #region setAssistantFocusTarget:Function [TYPE Function] -// @PURPOSE: Track the workspace entity currently referenced by assistant context. -// @PRE: focusTarget may be null to clear prior focus. -// @POST: store.focusTarget updated. -export function setAssistantFocusTarget(focusTarget) { - assistantChatStore.update((state) => ({ ...state, focusTarget: focusTarget || null })); -} -// #endregion setAssistantFocusTarget:Function -// #endregion assistantChat:Store -// #endregion AssistantChatStore diff --git a/frontend/src/lib/stores/assistantChat.ts b/frontend/src/lib/stores/assistantChat.ts new file mode 100644 index 00000000..b6a73912 --- /dev/null +++ b/frontend/src/lib/stores/assistantChat.ts @@ -0,0 +1,83 @@ +// #region AssistantChatStore [C:3] [TYPE Store] [SEMANTICS assistant, chat, store, session, panel] +// @BRIEF Control assistant chat panel visibility, conversation binding, session context, and seeded prompts. +// @LAYER UI +// @RELATION BINDS_TO -> [AssistantChatPanel] +// @UX_STATE Closed -> Chat panel hidden +// @UX_STATE Open -> Chat panel visible with active conversation + +import { writable, type Writable } from 'svelte/store'; + +interface AssistantChatState { + isOpen: boolean; + conversationId: string | null; + datasetReviewSessionId: string | null; + seedMessage: string; + focusTarget: string | null; +} + +interface ChatContextPayload { + datasetReviewSessionId?: string | null; + seedMessage?: string; + focusTarget?: string | null; +} + +const initialState: AssistantChatState = { + isOpen: false, + conversationId: null, + datasetReviewSessionId: null, + seedMessage: '', + focusTarget: null, +}; + +export const assistantChatStore: Writable = writable(initialState); + +export function toggleAssistantChat(): void { + assistantChatStore.update((state) => ({ + ...state, + isOpen: !state.isOpen, + })); +} + +export function openAssistantChat(): void { + assistantChatStore.update((state) => ({ + ...state, + isOpen: true, + })); +} + +export function openAssistantChatWithContext(context: ChatContextPayload = {}): void { + assistantChatStore.update((state) => ({ + ...state, + isOpen: true, + datasetReviewSessionId: context.datasetReviewSessionId ?? state.datasetReviewSessionId ?? null, + seedMessage: context.seedMessage ?? '', + focusTarget: context.focusTarget ?? null, + })); +} + +export function closeAssistantChat(): void { + assistantChatStore.update((state) => ({ + ...state, + isOpen: false, + })); +} + +export function setAssistantConversationId(conversationId: string): void { + assistantChatStore.update((state) => ({ + ...state, + conversationId, + })); +} + +export function setAssistantDatasetReviewSessionId(datasetReviewSessionId: string | null): void { + assistantChatStore.update((state) => ({ ...state, datasetReviewSessionId })); +} + +export function setAssistantSeedMessage(seedMessage: string): void { + assistantChatStore.update((state) => ({ ...state, seedMessage: seedMessage || '' })); +} + +export function setAssistantFocusTarget(focusTarget: string | null): void { + assistantChatStore.update((state) => ({ ...state, focusTarget: focusTarget || null })); +} +// #endregion AssistantChatStore diff --git a/frontend/src/lib/stores/datasetReviewSession.js b/frontend/src/lib/stores/datasetReviewSession.js deleted file mode 100644 index 18ab9f2d..00000000 --- a/frontend/src/lib/stores/datasetReviewSession.js +++ /dev/null @@ -1,125 +0,0 @@ -// #region DatasetReviewSessionStore [C:4] [TYPE Store] [SEMANTICS dataset, review, session, store, state] -// @BRIEF Manage active dataset review session state including loading, local edits, error capture, and reset semantics. -// @RELATION DEPENDS_ON -> [ApiModule] -// @PRE Consumers provide session-shaped payloads and initialize the store before reading derived session fields. -// @POST Store transitions keep session, loading, error, and dirty flags internally consistent after each helper call. -// @SIDE_EFFECT Mutates the writable dataset review session store in frontend memory. -// @UX_STATE Loading -> Session detail is being fetched -// @UX_STATE Ready -> Session detail available for UI binding -// @UX_STATE Saving -> Updates are being persisted -// @UX_STATE Error -> Failed to load or update session - -// #region datasetReviewSession:Store [TYPE Function] -// @PURPOSE: Manage active dataset review session state, including loading, local edits, error capture, and reset semantics for the active review workspace. -// @LAYER UI -// @RELATION DEPENDS_ON -> [EXT:frontend:api_module] -// @PRE: Consumers provide session-shaped payloads when setting or patching state and initialize the store before reading derived session fields. -// @POST: Store transitions keep session, loading, error, and dirty flags internally consistent after each helper call. -// @SIDE_EFFECT: Mutates the writable dataset review session store in frontend memory. -// @DATA_CONTRACT: Input[SessionDetail | Partial | boolean | string | null] -> Output[DatasetReviewSessionStoreState] -// -// @UX_STATE: Loading -> Session detail is being fetched. -// @UX_STATE: Ready -> Session detail is available for UI binding. -// @UX_STATE: Saving -> Updates are being persisted. -// @UX_STATE: Error -> Failed to load or update session. -// @UX_STATE: PartialPreview -> Session has preview data but launch gates are not yet satisfied. -// @UX_STATE: LaunchBlocked -> Session remains reviewable while explicit launch blockers are unresolved. -// @UX_REACTIVITY: Uses Svelte writable store for session aggregate. - -import { writable } from 'svelte/store'; - -// [SECTION: INITIAL STATE] -const initialState = { - session: null, - isLoading: false, - isSaving: false, - error: null, - lastUpdated: null, - isDirty: false -}; -// [/SECTION] - -export const datasetReviewSessionStore = writable(initialState); - -/** - * Set active session data - * @param {Object} session - Full SessionDetail aggregate - */ -export function setSession(session) { - datasetReviewSessionStore.update(state => ({ - ...state, - session, - isLoading: false, - error: null, - lastUpdated: new Date(), - isDirty: false - })); -} - -/** - * Update session loading state - * @param {boolean} isLoading - */ -export function setLoading(isLoading) { - datasetReviewSessionStore.update(state => ({ ...state, isLoading })); -} - -/** - * Set session error state - * @param {string|null} error - */ -export function setError(error) { - datasetReviewSessionStore.update(state => ({ ...state, error, isLoading: false, isSaving: false })); -} - -/** - * Mark session as dirty (unsaved changes) - * @param {boolean} isDirty - */ -export function setDirty(isDirty) { - datasetReviewSessionStore.update(state => ({ ...state, isDirty })); -} - -/** - * Reset store to initial state - */ -export function resetSession() { - datasetReviewSessionStore.set(initialState); -} - -/** - * Patch session data locally - * @param {Object} patch - Partial session data - */ -export function patchSession(patch) { - datasetReviewSessionStore.update(state => { - if (!state.session) return state; - return { - ...state, - session: { ...state.session, ...patch }, - isDirty: true - }; - }); -} - -/** - * Derive a conservative UI phase label from persisted session state. - * This helper is additive and does not alter orchestration behavior. - * - * @param {Object|null} session - * @returns {"empty"|"importing"|"review"|"partial_preview"|"launch_blocked"|"run_ready"|"run_in_progress"} - */ -export function getSessionUiPhase(session) { - if (!session) return "empty"; - - const readiness = String(session.readiness_state || ""); - if (readiness === "importing") return "importing"; - if (readiness === "partially_ready" || readiness === "compiled_preview_ready") return "partial_preview"; - if (readiness === "recovery_required") return "launch_blocked"; - if (readiness === "run_ready") return "run_ready"; - if (readiness === "run_in_progress") return "run_in_progress"; - return "review"; -} - -// #endregion datasetReviewSession:Store -// #endregion DatasetReviewSessionStore diff --git a/frontend/src/lib/stores/datasetReviewSession.ts b/frontend/src/lib/stores/datasetReviewSession.ts new file mode 100644 index 00000000..e20f6b94 --- /dev/null +++ b/frontend/src/lib/stores/datasetReviewSession.ts @@ -0,0 +1,87 @@ +// #region DatasetReviewSessionStore [C:4] [TYPE Store] [SEMANTICS dataset, review, session, store, state] +// @BRIEF Manage active dataset review session state including loading, local edits, error capture, and reset semantics. +// @LAYER UI +// @RELATION DEPENDS_ON -> [ApiModule] +// @PRE Consumers provide session-shaped payloads and initialize the store before reading derived session fields. +// @POST Store transitions keep session, loading, error, and dirty flags internally consistent after each helper call. +// @SIDE_EFFECT Mutates the writable dataset review session store in frontend memory. +// @UX_STATE Loading -> Session detail is being fetched +// @UX_STATE Ready -> Session detail available for UI binding +// @UX_STATE Saving -> Updates are being persisted +// @UX_STATE Error -> Failed to load or update session +// @DATA_CONTRACT Input[SessionDetail | Partial | boolean | string | null] -> Output[DatasetReviewSessionStoreState] + +import { writable, type Writable } from 'svelte/store'; + +export type UiPhase = 'empty' | 'importing' | 'review' | 'partial_preview' | 'launch_blocked' | 'run_ready' | 'run_in_progress'; + +interface DatasetReviewSessionState { + session: Record | null; + isLoading: boolean; + isSaving: boolean; + error: string | null; + lastUpdated: Date | null; + isDirty: boolean; +} + +const initialState: DatasetReviewSessionState = { + session: null, + isLoading: false, + isSaving: false, + error: null, + lastUpdated: null, + isDirty: false, +}; + +export const datasetReviewSessionStore: Writable = writable(initialState); + +export function setSession(session: Record): void { + datasetReviewSessionStore.update((state) => ({ + ...state, + session, + isLoading: false, + error: null, + lastUpdated: new Date(), + isDirty: false, + })); +} + +export function setLoading(isLoading: boolean): void { + datasetReviewSessionStore.update((state) => ({ ...state, isLoading })); +} + +export function setError(error: string | null): void { + datasetReviewSessionStore.update((state) => ({ ...state, error, isLoading: false, isSaving: false })); +} + +export function setDirty(isDirty: boolean): void { + datasetReviewSessionStore.update((state) => ({ ...state, isDirty })); +} + +export function resetSession(): void { + datasetReviewSessionStore.set(initialState); +} + +export function patchSession(patch: Record): void { + datasetReviewSessionStore.update((state) => { + if (!state.session) return state; + return { + ...state, + session: { ...state.session, ...patch }, + isDirty: true, + }; + }); +} + +export function getSessionUiPhase(session: Record | null): UiPhase { + if (!session) return 'empty'; + + const readiness = String(session.readiness_state || ''); + if (readiness === 'importing') return 'importing'; + if (readiness === 'partially_ready' || readiness === 'compiled_preview_ready') return 'partial_preview'; + if (readiness === 'recovery_required') return 'launch_blocked'; + if (readiness === 'run_ready') return 'run_ready'; + if (readiness === 'run_in_progress') return 'run_in_progress'; + return 'review'; +} +// #endregion DatasetReviewSessionStore diff --git a/frontend/src/lib/stores/environmentContext.js b/frontend/src/lib/stores/environmentContext.js deleted file mode 100644 index ff7879f5..00000000 --- a/frontend/src/lib/stores/environmentContext.js +++ /dev/null @@ -1,152 +0,0 @@ -// #region EnvironmentContextStore [C:3] [TYPE Store] [SEMANTICS environment, context, store, selector, filter] -// @BRIEF Global selected environment context for navigation, safety cues, and environment-based filtering. -// @RELATION DEPENDS_ON -> [ApiModule] -// @RELATION BINDS_TO -> [SidebarStore] -// @UX_STATE Loading -> Environments being fetched -// @UX_STATE Ready -> Environment selector active with environment list -// @UX_STATE Error -> Failed to load environments - -// #region environmentContext:Store [TYPE Function] -// @RELATION USES -> [EXT:frontend:App] -// @PURPOSE: Global selected environment context for navigation and safety cues. -// @LAYER UI - -import { derived, get, writable } from "svelte/store"; -import { browser } from "$app/environment"; -import { api } from "$lib/api.js"; - -const INITIAL_STATE = { - environments: [], - selectedEnvId: "", - isLoading: false, - isLoaded: false, - error: null, -}; - -const SELECTED_ENV_KEY = "selected_env_id"; -const contextStore = writable(INITIAL_STATE); - -function getStoredSelectedEnvId() { - if (!browser) return ""; - return localStorage.getItem(SELECTED_ENV_KEY) || ""; -} - -function persistSelectedEnvId(envId) { - if (!browser) return; - if (!envId) { - localStorage.removeItem(SELECTED_ENV_KEY); - return; - } - localStorage.setItem(SELECTED_ENV_KEY, envId); -} - -function hasAuthToken() { - if (!browser) return false; - return Boolean(localStorage.getItem("auth_token")); -} - -function resolveSelectedEnvId(environments, preferredEnvId) { - if (!Array.isArray(environments) || environments.length === 0) return ""; - if (preferredEnvId && environments.some((env) => env.id === preferredEnvId)) { - return preferredEnvId; - } - const stored = getStoredSelectedEnvId(); - if (stored && environments.some((env) => env.id === stored)) { - return stored; - } - return environments[0].id; -} - -function applySelectedEnvId(selectedEnvId) { - contextStore.update((state) => { - const exists = state.environments.some((env) => env.id === selectedEnvId); - const nextSelectedEnvId = exists ? selectedEnvId : ""; - persistSelectedEnvId(nextSelectedEnvId); - return { ...state, selectedEnvId: nextSelectedEnvId }; - }); -} - -async function refreshEnvironmentContext(preferredEnvId = "") { - if (!hasAuthToken()) { - contextStore.update((state) => ({ - ...state, - isLoading: false, - isLoaded: false, - error: null, - })); - return; - } - - contextStore.update((state) => ({ ...state, isLoading: true, error: null })); - try { - const environments = await api.getEnvironmentsList(); - const current = get(contextStore).selectedEnvId; - const selectedEnvId = resolveSelectedEnvId( - environments, - preferredEnvId || current, - ); - persistSelectedEnvId(selectedEnvId); - contextStore.update((state) => ({ - ...state, - environments, - selectedEnvId, - isLoading: false, - isLoaded: true, - error: null, - })); - } catch (error) { - if (error?.status === 401) { - contextStore.update((state) => ({ - ...state, - isLoading: false, - isLoaded: false, - error: null, - })); - return; - } - console.error( - "[EXT:frontend:environmentContext][Coherence:Failed] Failed to refresh environments", - error, - ); - contextStore.update((state) => ({ - ...state, - environments: [], - selectedEnvId: "", - isLoading: false, - isLoaded: true, - error: error?.message || "Failed to load environments", - })); - } -} - -async function initializeEnvironmentContext() { - const state = get(contextStore); - if (state.isLoading || state.isLoaded) return; - await refreshEnvironmentContext(); -} - -export const environmentContextStore = { - subscribe: contextStore.subscribe, -}; - -export function setSelectedEnvironment(envId) { - applySelectedEnvId(envId); -} - -export { refreshEnvironmentContext, initializeEnvironmentContext }; - -export const selectedEnvironmentStore = derived( - environmentContextStore, - ($context) => - $context.environments.find((env) => env.id === $context.selectedEnvId) || - null, -); - -export const isProductionContextStore = derived( - selectedEnvironmentStore, - ($selectedEnvironment) => - String($selectedEnvironment?.stage || "").toUpperCase() === "PROD" || - Boolean($selectedEnvironment?.is_production), -); -// #endregion environmentContext:Store -// #endregion EnvironmentContextStore diff --git a/frontend/src/lib/stores/environmentContext.ts b/frontend/src/lib/stores/environmentContext.ts new file mode 100644 index 00000000..faa2377a --- /dev/null +++ b/frontend/src/lib/stores/environmentContext.ts @@ -0,0 +1,157 @@ +// #region EnvironmentContextStore [C:3] [TYPE Store] [SEMANTICS environment, context, store, selector, filter] +// @BRIEF Global selected environment context for navigation, safety cues, and environment-based filtering. +// @LAYER UI +// @RELATION DEPENDS_ON -> [ApiModule] +// @RELATION BINDS_TO -> [SidebarStore] +// @UX_STATE Loading -> Environments being fetched +// @UX_STATE Ready -> Environment selector active with environment list +// @UX_STATE Error -> Failed to load environments + +import { derived, get, writable, type Writable } from 'svelte/store'; +import { browser } from '$app/environment'; +import { api } from '$lib/api'; + +interface Environment { + id: string; + name: string; + stage?: string; + is_production?: boolean; + [k: string]: unknown; +} + +interface EnvironmentContextState { + environments: Environment[]; + selectedEnvId: string; + isLoading: boolean; + isLoaded: boolean; + error: string | null; +} + +const INITIAL_STATE: EnvironmentContextState = { + environments: [], + selectedEnvId: '', + isLoading: false, + isLoaded: false, + error: null, +}; + +const SELECTED_ENV_KEY = 'selected_env_id'; +const contextStore: Writable = writable(INITIAL_STATE); + +function getStoredSelectedEnvId(): string { + if (!browser) return ''; + return localStorage.getItem(SELECTED_ENV_KEY) || ''; +} + +function persistSelectedEnvId(envId: string): void { + if (!browser) return; + if (!envId) { + localStorage.removeItem(SELECTED_ENV_KEY); + return; + } + localStorage.setItem(SELECTED_ENV_KEY, envId); +} + +function hasAuthToken(): boolean { + if (!browser) return false; + return Boolean(localStorage.getItem('auth_token')); +} + +function resolveSelectedEnvId(environments: Environment[], preferredEnvId: string): string { + if (!Array.isArray(environments) || environments.length === 0) return ''; + if (preferredEnvId && environments.some((env) => env.id === preferredEnvId)) { + return preferredEnvId; + } + const stored = getStoredSelectedEnvId(); + if (stored && environments.some((env) => env.id === stored)) { + return stored; + } + return environments[0].id; +} + +function applySelectedEnvId(selectedEnvId: string): void { + contextStore.update((state) => { + const exists = state.environments.some((env) => env.id === selectedEnvId); + const nextSelectedEnvId = exists ? selectedEnvId : ''; + persistSelectedEnvId(nextSelectedEnvId); + return { ...state, selectedEnvId: nextSelectedEnvId }; + }); +} + +async function refreshEnvironmentContext(preferredEnvId: string = ''): Promise { + if (!hasAuthToken()) { + contextStore.update((state) => ({ + ...state, + isLoading: false, + isLoaded: false, + error: null, + })); + return; + } + + contextStore.update((state) => ({ ...state, isLoading: true, error: null })); + try { + const environments = await api.getEnvironmentsList(); + const current = get(contextStore).selectedEnvId; + const selectedEnvId = resolveSelectedEnvId(environments, preferredEnvId || current); + persistSelectedEnvId(selectedEnvId); + contextStore.update((state) => ({ + ...state, + environments, + selectedEnvId, + isLoading: false, + isLoaded: true, + error: null, + })); + } catch (error: unknown) { + const apiError = error as { status?: number; message?: string }; + if (apiError?.status === 401) { + contextStore.update((state) => ({ + ...state, + isLoading: false, + isLoaded: false, + error: null, + })); + return; + } + console.error('[environmentContext] Failed to refresh environments', error); + contextStore.update((state) => ({ + ...state, + environments: [], + selectedEnvId: '', + isLoading: false, + isLoaded: true, + error: apiError?.message || 'Failed to load environments', + })); + } +} + +async function initializeEnvironmentContext(): Promise { + const state = get(contextStore); + if (state.isLoading || state.isLoaded) return; + await refreshEnvironmentContext(); +} + +export const environmentContextStore = { + subscribe: contextStore.subscribe, +}; + +export function setSelectedEnvironment(envId: string): void { + applySelectedEnvId(envId); +} + +export { refreshEnvironmentContext, initializeEnvironmentContext }; + +export const selectedEnvironmentStore = derived( + environmentContextStore, + ($context: EnvironmentContextState) => + $context.environments.find((env) => env.id === $context.selectedEnvId) || null, +); + +export const isProductionContextStore = derived( + selectedEnvironmentStore, + ($selectedEnvironment: Environment | null) => + String($selectedEnvironment?.stage || '').toUpperCase() === 'PROD' || + Boolean($selectedEnvironment?.is_production), +); +// #endregion EnvironmentContextStore diff --git a/frontend/src/lib/stores/health.js b/frontend/src/lib/stores/health.js deleted file mode 100644 index 571276bf..00000000 --- a/frontend/src/lib/stores/health.js +++ /dev/null @@ -1,99 +0,0 @@ -// #region HealthStore [C:3] [TYPE Store] [SEMANTICS health, store, dashboard, summary, badge] -// @BRIEF Manage dashboard health summary state and failing counts for UI badges. -// @RELATION DEPENDS_ON -> [ApiModule] -// @UX_STATE Loading -> Loading spinner, badges hidden -// @UX_STATE Ready -> Health summary displayed with pass/warn/fail counts -// @UX_STATE Error -> Error state with retry option - -// #region health_store:Store [TYPE Function] -// @UX_STATE: Loading -> Default -// @PURPOSE: Manage dashboard health summary state and failing counts for UI badges. -// @LAYER UI -// @RELATION DEPENDS_ON -> [EXT:frontend:api_module] - -import { writable, derived } from 'svelte/store'; -import { api } from '../api.js'; - -/** - * @typedef {Object} HealthState - * @property {Array} items - List of dashboard health items - * @property {number} pass_count - Number of passing dashboards - * @property {number} warn_count - Number of warning dashboards - * @property {number} fail_count - Number of failing dashboards - * @property {number} unknown_count - Number of unknown status dashboards - * @property {boolean} loading - Loading state - * @property {Date|null} lastUpdated - Last successful fetch timestamp - */ - -function createHealthStore() { - const { subscribe, set, update } = writable({ - items: [], - pass_count: 0, - warn_count: 0, - fail_count: 0, - unknown_count: 0, - loading: false, - lastUpdated: null - }); - let inflightRefresh = null; - let inflightEnvironmentId = null; - /** @type {boolean} - Set to true when the health monitor feature is disabled on the backend. Prevents further polling. */ - let _isDisabled = false; - - /** - * Refresh health summary from API - * @param {string|null} environmentId - Optional environment filter - */ - async function refresh(environmentId = null) { - if (_isDisabled) return null; - - const normalizedEnvironmentId = environmentId || null; - if (inflightRefresh && inflightEnvironmentId === normalizedEnvironmentId) { - return inflightRefresh; - } - - update(s => ({ ...s, loading: true })); - inflightEnvironmentId = normalizedEnvironmentId; - inflightRefresh = (async () => { - try { - console.log(`[HealthStore][Action] Refreshing health summary context={{'environmentId': '${environmentId}'}}`); - const summary = await api.getHealthSummary(environmentId); - set({ - ...summary, - loading: false, - lastUpdated: new Date() - }); - return summary; - } catch (error) { - const msg = error?.message || String(error); - if (msg.includes('Health monitor feature is disabled') || msg.includes('404')) { - console.warn('[HealthStore][Coherence:Disabled] Health monitor feature is disabled — stopping polling'); - _isDisabled = true; - } - console.error('[HealthStore][Coherence:Failed] Failed to fetch health summary:', error); - update(s => ({ ...s, loading: false })); - return null; - } finally { - inflightRefresh = null; - inflightEnvironmentId = null; - } - })(); - return inflightRefresh; - } - - return { - subscribe, - refresh - }; -} - -export const healthStore = createHealthStore(); - -/** - * Derived store for the total count of failing dashboards. - * Used for sidebar badges and global notifications. - */ -export const failingCount = derived(healthStore, $health => $health.fail_count); - -// #endregion health_store:Store -// #endregion HealthStore diff --git a/frontend/src/lib/stores/health.ts b/frontend/src/lib/stores/health.ts new file mode 100644 index 00000000..7c32774c --- /dev/null +++ b/frontend/src/lib/stores/health.ts @@ -0,0 +1,81 @@ +// #region HealthStore [C:3] [TYPE Store] [SEMANTICS health, store, dashboard, summary, badge] +// @BRIEF Manage dashboard health summary state and failing counts for UI badges. +// @LAYER UI +// @RELATION DEPENDS_ON -> [ApiModule] +// @UX_STATE Loading -> Loading spinner, badges hidden +// @UX_STATE Ready -> Health summary displayed with pass/warn/fail counts +// @UX_STATE Error -> Error state with retry option + +import { writable, derived, type Writable } from 'svelte/store'; +import { api } from '../api'; + +interface HealthState { + items: unknown[]; + pass_count: number; + warn_count: number; + fail_count: number; + unknown_count: number; + loading: boolean; + lastUpdated: Date | null; +} + +function createHealthStore() { + const { subscribe, set, update } = writable({ + items: [], + pass_count: 0, + warn_count: 0, + fail_count: 0, + unknown_count: 0, + loading: false, + lastUpdated: null, + }); + let inflightRefresh: Promise | null = null; + let inflightEnvironmentId: string | null = null; + let _isDisabled = false; + + async function refresh(environmentId: string | null = null): Promise { + if (_isDisabled) return null; + + const normalizedEnvironmentId = environmentId || null; + if (inflightRefresh && inflightEnvironmentId === normalizedEnvironmentId) { + return inflightRefresh; + } + + update(s => ({ ...s, loading: true })); + inflightEnvironmentId = normalizedEnvironmentId; + inflightRefresh = (async () => { + try { + const summary = await api.getHealthSummary(environmentId ?? undefined); + set({ + ...summary, + loading: false, + lastUpdated: new Date(), + }); + return summary; + } catch (error: unknown) { + const msg = (error as Error)?.message || String(error); + if (msg.includes('Health monitor feature is disabled') || msg.includes('404')) { + console.warn('[HealthStore] Health monitor feature is disabled — stopping polling'); + _isDisabled = true; + } + console.error('[HealthStore] Failed to fetch health summary:', error); + update(s => ({ ...s, loading: false })); + return null; + } finally { + inflightRefresh = null; + inflightEnvironmentId = null; + } + })(); + return inflightRefresh; + } + + return { + subscribe, + refresh, + }; +} + +export const healthStore = createHealthStore(); + +export const failingCount = derived(healthStore, ($health: HealthState) => $health.fail_count); +// #endregion HealthStore diff --git a/frontend/src/lib/stores/maintenance.svelte.js b/frontend/src/lib/stores/maintenance.svelte.js deleted file mode 100644 index 34a9500a..00000000 --- a/frontend/src/lib/stores/maintenance.svelte.js +++ /dev/null @@ -1,215 +0,0 @@ -// #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 -// @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"; -import { getMaintenanceEventsWsUrl } from "$lib/api.js"; -import * as maintenanceApi from "$lib/api/maintenance.js"; - -/** - * @returns {object} Maintenance store with runes state and methods. - */ -export function createMaintenanceStore() { - // #region MaintenanceStore.state [C:1] [TYPE Block] - let activeEvents = $state([]); - let completedEvents = $state([]); - let settings = $state(null); - let dashboardBanners = $state([]); - let isLoading = $state(false); - let error = $state(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. - */ - async function loadEvents() { - isLoading = true; - error = null; - try { - const data = await maintenanceApi.listEvents(); - activeEvents = data.active || []; - completedEvents = data.completed || []; - } catch (e) { - const msg = e?.message || "Failed to load events"; - error = msg; - console.error("[MaintenanceStore] loadEvents error:", msg); - } finally { - isLoading = false; - } - } - - /** - * Load dashboard banner states. - */ - async function loadDashboardBanners() { - try { - const data = await maintenanceApi.listDashboardBanners(); - dashboardBanners = Array.isArray(data) ? data : []; - } catch (e) { - console.error("[MaintenanceStore] loadDashboardBanners error:", e?.message); - dashboardBanners = []; - } - } - - /** - * Load settings from API. - */ - async function loadSettings() { - try { - settings = await maintenanceApi.getSettings(); - } catch (e) { - console.error("[MaintenanceStore] loadSettings error:", e?.message); - } - } - - /** - * End a specific maintenance event. - * @param {string} maintenanceId - */ - async function endEvent(maintenanceId) { - try { - const data = await maintenanceApi.endMaintenance(maintenanceId); - 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"; - addToast(msg, "error"); - throw e; - } - } - - /** - * End all active maintenance events. - */ - async function endAllEvents() { - try { - const data = await maintenanceApi.endAllMaintenance(); - 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"; - addToast(msg, "error"); - throw e; - } - } - - /** - * Update settings. - * @param {object} newSettings - */ - async function updateSettings(newSettings) { - isLoading = true; - try { - settings = await maintenanceApi.updateSettings(newSettings); - addToast("Settings saved", "success"); - } catch (e) { - const msg = e?.message || "Failed to update settings"; - addToast(msg, "error"); - throw e; - } finally { - isLoading = false; - } - } - - /** - * Initialize the store: load all data once and connect WebSocket. - */ - async function init() { - await Promise.all([loadEvents(), loadSettings(), loadDashboardBanners()]); - connectWs(); - } - - // #endregion MaintenanceStore.methods - - return { - get activeEvents() { return activeEvents; }, - get completedEvents() { return completedEvents; }, - get settings() { return settings; }, - get dashboardBanners() { return dashboardBanners; }, - get isLoading() { return isLoading; }, - get error() { return error; }, - loadEvents, - loadSettings, - loadDashboardBanners, - endEvent, - endAllEvents, - updateSettings, - init, - disconnectWs, - }; -} - -export const maintenanceStore = createMaintenanceStore(); -// #endregion MaintenanceStore diff --git a/frontend/src/lib/stores/maintenance.svelte.ts b/frontend/src/lib/stores/maintenance.svelte.ts new file mode 100644 index 00000000..d05a2cb1 --- /dev/null +++ b/frontend/src/lib/stores/maintenance.svelte.ts @@ -0,0 +1,203 @@ +// #region MaintenanceStore [C:4] [TYPE Store] [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 -> [ToastsModule] +// @RELATION DEPENDS_ON -> [ApiModule.getMaintenanceEventsWsUrl] +// @UX_STATE Idle -> events loaded, settings loaded, banners loaded +// @UX_STATE Loading -> isLoading = true during fetches +// @UX_STATE Error -> error state populated with message +// @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"; +import { getMaintenanceEventsWsUrl } from "$lib/api"; +import * as maintenanceApi from "$lib/api/maintenance"; + +export interface MaintenanceApiEvent { + active?: unknown[]; + completed?: unknown[]; +} + +export interface MaintenanceBanner { + dashboard_id?: string; + active?: boolean; + [k: string]: unknown; +} + +export interface MaintenanceSettings { + [k: string]: unknown; +} + +export interface MaintenanceStore { + readonly activeEvents: unknown[]; + readonly completedEvents: unknown[]; + readonly settings: MaintenanceSettings | null; + readonly dashboardBanners: MaintenanceBanner[]; + readonly isLoading: boolean; + readonly error: string | null; + loadEvents: () => Promise; + loadSettings: () => Promise; + loadDashboardBanners: () => Promise; + endEvent: (maintenanceId: string) => Promise; + endAllEvents: () => Promise; + updateSettings: (newSettings: MaintenanceSettings) => Promise; + init: () => Promise; + disconnectWs: () => void; +} + +export function createMaintenanceStore(): MaintenanceStore { + let activeEvents = $state([]); + let completedEvents = $state([]); + let settings = $state(null); + let dashboardBanners = $state([]); + let isLoading = $state(false); + let error = $state(null); + let _ws: WebSocket | null = null; + + function connectWs(): void { + if (_ws) return; + try { + const url = getMaintenanceEventsWsUrl(); + _ws = new WebSocket(url); + + _ws.onopen = () => { + console.log("[MaintenanceStore][WS] Connected"); + }; + + _ws.onmessage = async (_event: MessageEvent) => { + try { + 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; + connectWs(); + }, 10000); + }; + } catch (_e) { + _ws = null; + } + } + + function disconnectWs(): void { + if (_ws) { + _ws.onclose = null; + _ws.close(); + _ws = null; + } + } + + async function loadEvents(): Promise { + isLoading = true; + error = null; + try { + const data = (await maintenanceApi.listEvents()) as MaintenanceApiEvent; + activeEvents = data.active || []; + completedEvents = data.completed || []; + } catch (e: unknown) { + const msg = (e as Error)?.message || "Failed to load events"; + error = msg; + console.error("[MaintenanceStore] loadEvents error:", msg); + } finally { + isLoading = false; + } + } + + async function loadDashboardBanners(): Promise { + try { + const data = await maintenanceApi.listDashboardBanners(); + dashboardBanners = Array.isArray(data) ? data : []; + } catch (e: unknown) { + console.error("[MaintenanceStore] loadDashboardBanners error:", (e as Error)?.message); + dashboardBanners = []; + } + } + + async function loadSettings(): Promise { + try { + settings = await maintenanceApi.getSettings(); + } catch (e: unknown) { + console.error("[MaintenanceStore] loadSettings error:", (e as Error)?.message); + } + } + + async function endEvent(maintenanceId: string): Promise { + try { + const data = await maintenanceApi.endMaintenance<{ task_id?: string }>(maintenanceId); + if (data?.task_id) { + addToast("Removal task started", "info"); + } + await loadEvents(); + } catch (e: unknown) { + const msg = (e as Error)?.message || "Failed to end maintenance event"; + addToast(msg, "error"); + throw e; + } + } + + async function endAllEvents(): Promise { + try { + const data = await maintenanceApi.endAllMaintenance<{ task_id?: string }>(); + if (data?.task_id) { + addToast("Bulk removal task started", "info"); + } + await loadEvents(); + } catch (e: unknown) { + const msg = (e as Error)?.message || "Failed to end all events"; + addToast(msg, "error"); + throw e; + } + } + + async function updateSettingsFun(newSettings: MaintenanceSettings): Promise { + isLoading = true; + try { + settings = await maintenanceApi.updateSettings(newSettings); + addToast("Settings saved", "success"); + } catch (e: unknown) { + const msg = (e as Error)?.message || "Failed to update settings"; + addToast(msg, "error"); + throw e; + } finally { + isLoading = false; + } + } + + async function init(): Promise { + await Promise.all([loadEvents(), loadSettings(), loadDashboardBanners()]); + connectWs(); + } + + return { + get activeEvents() { return activeEvents; }, + get completedEvents() { return completedEvents; }, + get settings() { return settings; }, + get dashboardBanners() { return dashboardBanners; }, + get isLoading() { return isLoading; }, + get error() { return error; }, + loadEvents, + loadSettings, + loadDashboardBanners, + endEvent, + endAllEvents, + updateSettings: updateSettingsFun, + init, + disconnectWs, + }; +} + +export const maintenanceStore: MaintenanceStore = createMaintenanceStore(); +// #endregion MaintenanceStore diff --git a/frontend/src/lib/stores/sidebar.js b/frontend/src/lib/stores/sidebar.js deleted file mode 100644 index 13c15d4d..00000000 --- a/frontend/src/lib/stores/sidebar.js +++ /dev/null @@ -1,101 +0,0 @@ -// #region SidebarStore [C:3] [TYPE Store] [SEMANTICS sidebar, store, navigation, state, toggle] -// @BRIEF Manage sidebar visibility, expansion state, active navigation item, and mobile overlay. -// @RELATION BINDS_TO -> [EnvironmentContextStore] -// @UX_STATE Idle -> Sidebar visible with current state -// @UX_STATE Toggling -> Expand/collapse animation - -// #region sidebar:Store [TYPE Function] -// @RELATION USES -> [EXT:frontend:App] -// @PURPOSE: Manage sidebar visibility and navigation state -// @LAYER UI -// @INVARIANT: isExpanded state is always synced with localStorage -// -// @UX_STATE: Idle -> Sidebar visible with current state -// @UX_STATE: Toggling -> Animation plays for 200ms - -import { writable } from 'svelte/store'; -import { browser } from '$app/environment'; - -// Load from localStorage on initialization -const STORAGE_KEY = 'sidebar_state'; - -const loadState = () => { - if (!browser) return null; - try { - const saved = localStorage.getItem(STORAGE_KEY); - if (saved) { - return JSON.parse(saved); - } - } catch (e) { - console.error('[SidebarStore] Failed to load state:', e); - } - return null; -}; - -const saveState = (state) => { - if (!browser) return; - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); - } catch (e) { - console.error('[SidebarStore] Failed to save state:', e); - } -}; - -const initialState = loadState() || { - isExpanded: true, - activeCategory: 'dashboards', - activeItem: '/dashboards', - isMobileOpen: false -}; - -export const sidebarStore = writable(initialState); - -/** - * Toggle sidebar expansion state - * @UX_STATE: Toggling -> Animation plays for 200ms - */ -export function toggleSidebar() { - sidebarStore.update(state => { - const newState = { ...state, isExpanded: !state.isExpanded }; - saveState(newState); - return newState; - }); -} - -/** - * Set active category and item - * @param {string} category - Category name (dashboards, datasets, storage, admin) - * @param {string} item - Route path - */ -export function setActiveItem(category, item) { - sidebarStore.update(state => { - const newState = { ...state, activeCategory: category, activeItem: item }; - saveState(newState); - return newState; - }); -} - -/** - * Toggle mobile overlay mode - * @param {boolean} isOpen - Whether the mobile overlay should be open - */ -export function setMobileOpen(isOpen) { - sidebarStore.update(state => ({ ...state, isMobileOpen: isOpen })); -} - -/** - * Close mobile overlay - */ -export function closeMobile() { - sidebarStore.update(state => ({ ...state, isMobileOpen: false })); -} - -/** - * Toggle mobile sidebar (for hamburger menu) - */ -export function toggleMobileSidebar() { - sidebarStore.update(state => ({ ...state, isMobileOpen: !state.isMobileOpen })); -} - -// #endregion sidebar:Store -// #endregion SidebarStore diff --git a/frontend/src/lib/stores/sidebar.ts b/frontend/src/lib/stores/sidebar.ts new file mode 100644 index 00000000..95828be2 --- /dev/null +++ b/frontend/src/lib/stores/sidebar.ts @@ -0,0 +1,79 @@ +// #region SidebarStore [C:3] [TYPE Store] [SEMANTICS sidebar, store, navigation, state, toggle] +// @BRIEF Manage sidebar visibility, expansion state, active navigation item, and mobile overlay. +// @LAYER UI +// @RELATION BINDS_TO -> [EnvironmentContextStore] +// @UX_STATE Idle -> Sidebar visible with current state +// @UX_STATE Toggling -> Expand/collapse animation +// @INVARIANT isExpanded state is always synced with localStorage + +import { writable, type Writable } from 'svelte/store'; +import { browser } from '$app/environment'; + +interface SidebarState { + isExpanded: boolean; + activeCategory: string; + activeItem: string; + isMobileOpen: boolean; +} + +const STORAGE_KEY = 'sidebar_state'; + +const loadState = (): SidebarState | null => { + if (!browser) return null; + try { + const saved = localStorage.getItem(STORAGE_KEY); + if (saved) { + return JSON.parse(saved) as SidebarState; + } + } catch (e) { + console.error('[SidebarStore] Failed to load state:', e); + } + return null; +}; + +const saveState = (state: SidebarState): void => { + if (!browser) return; + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + } catch (e) { + console.error('[SidebarStore] Failed to save state:', e); + } +}; + +const initialState: SidebarState = loadState() || { + isExpanded: true, + activeCategory: 'dashboards', + activeItem: '/dashboards', + isMobileOpen: false, +}; + +export const sidebarStore: Writable = writable(initialState); + +export function toggleSidebar(): void { + sidebarStore.update((state: SidebarState) => { + const newState = { ...state, isExpanded: !state.isExpanded }; + saveState(newState); + return newState; + }); +} + +export function setActiveItem(category: string, item: string): void { + sidebarStore.update((state: SidebarState) => { + const newState = { ...state, activeCategory: category, activeItem: item }; + saveState(newState); + return newState; + }); +} + +export function setMobileOpen(isOpen: boolean): void { + sidebarStore.update((state: SidebarState) => ({ ...state, isMobileOpen: isOpen })); +} + +export function closeMobile(): void { + sidebarStore.update((state: SidebarState) => ({ ...state, isMobileOpen: false })); +} + +export function toggleMobileSidebar(): void { + sidebarStore.update((state: SidebarState) => ({ ...state, isMobileOpen: !state.isMobileOpen })); +} +// #endregion SidebarStore diff --git a/frontend/src/lib/stores/taskDrawer.js b/frontend/src/lib/stores/taskDrawer.js deleted file mode 100644 index bd2284f1..00000000 --- a/frontend/src/lib/stores/taskDrawer.js +++ /dev/null @@ -1,163 +0,0 @@ -// #region TaskDrawerStore [C:3] [TYPE Store] [SEMANTICS task, drawer, store, resource, mapping] -// @BRIEF Manage Task Drawer visibility, active task binding, and resource-to-task mapping. -// @RELATION BINDS_TO -> [ActivityStore] -// @UX_STATE Closed -> Drawer hidden, no active task -// @UX_STATE Open -> Drawer visible with task logs or task list - -// #region taskDrawer:Store [TYPE Function] -// @RELATION USES -> [EXT:frontend:App] -// @PURPOSE: Manage Task Drawer visibility and resource-to-task mapping -// -// -// { -// required_fields: {isOpen: boolean, activeTaskId: string|null, resourceTaskMap: Object}, -// invariants: [ -// "Updates isOpen and activeTaskId properly on openDrawerForTask", -// "Updates isOpen and activeTaskId=null on openDrawer", -// "Properly sets isOpen=false on closeDrawer", -// "Maintains mapping in resourceTaskMap correctly via updateResourceTask" -// ] -// } - -import { writable } from 'svelte/store'; - -const TASK_DRAWER_AUTO_OPEN_STORAGE_KEY = "ss_tools.profile.auto_open_task_drawer"; - -function readAutoOpenTaskDrawerPreference() { - if (typeof window === "undefined") { - return true; - } - const rawValue = window.localStorage.getItem(TASK_DRAWER_AUTO_OPEN_STORAGE_KEY); - if (rawValue === "false") return false; - if (rawValue === "true") return true; - return true; -} - -let autoOpenTaskDrawerPreference = readAutoOpenTaskDrawerPreference(); - -const initialState = { - isOpen: false, - activeTaskId: null, - resourceTaskMap: {} -}; - -export const taskDrawerStore = writable(initialState); - -/** - * Open drawer for a specific task - * @param {string} taskId - The task ID to show in drawer - * @UX_STATE: Open -> Drawer visible, logs streaming - */ -export function openDrawerForTask(taskId) { - if (!taskId) { - console.log("[taskDrawer.openDrawerForTask][Action] Skip open: taskId is empty"); - return false; - } - console.log(`[taskDrawer.openDrawerForTask][Action] Opening drawer for task ${taskId}`); - taskDrawerStore.update(state => ({ - ...state, - isOpen: true, - activeTaskId: taskId - })); - return true; -} - -/** - * Update user preference for automatic drawer opening. - * @param {boolean} enabled - Whether automatic opening is enabled. - */ -export function setTaskDrawerAutoOpenPreference(enabled) { - autoOpenTaskDrawerPreference = enabled !== false; - if (typeof window !== "undefined") { - window.localStorage.setItem( - TASK_DRAWER_AUTO_OPEN_STORAGE_KEY, - autoOpenTaskDrawerPreference ? "true" : "false", - ); - } -} - -/** - * Read current user preference for automatic drawer opening. - * @returns {boolean} true if automatic drawer opening is enabled. - */ -export function getTaskDrawerAutoOpenPreference() { - return autoOpenTaskDrawerPreference; -} - -/** - * Open drawer for a task only when user preference allows auto-open. - * @param {string} taskId - The task ID to show in drawer. - * @returns {boolean} true if drawer was opened. - */ -export function openDrawerForTaskIfPreferred(taskId) { - if (autoOpenTaskDrawerPreference !== true) { - console.log( - `[taskDrawer.openDrawerForTaskIfPreferred][Action] Skip auto-open for task ${taskId}`, - ); - return false; - } - return openDrawerForTask(taskId); -} - -/** - * Open drawer in list mode (no specific task) - * @UX_STATE: Open -> Drawer visible, showing recent task list - */ -export function openDrawer() { - console.log('[taskDrawer.openDrawer][Action] Opening drawer in list mode'); - taskDrawerStore.update(state => ({ - ...state, - isOpen: true, - activeTaskId: null - })); -} - -/** - * Close the drawer (task continues running) - * @UX_STATE: Closed -> Drawer hidden, no active task - */ -export function closeDrawer() { - console.log('[taskDrawer.closeDrawer][Action] Closing drawer'); - taskDrawerStore.update(state => ({ - ...state, - isOpen: false, - activeTaskId: null - })); -} - -/** - * Update resource-to-task mapping - * @param {string} resourceId - Resource ID (dashboard uuid, dataset id, etc.) - * @param {string} taskId - Task ID associated with this resource - * @param {string} status - Task status (IDLE, RUNNING, WAITING_INPUT, SUCCESS, ERROR) - */ -export function updateResourceTask(resourceId, taskId, status) { - console.log(`[taskDrawer.updateResourceTask][Action] Updating resource ${resourceId} -> task ${taskId}, status ${status}`); - taskDrawerStore.update(state => { - const newMap = { ...state.resourceTaskMap }; - if (status === 'IDLE' || status === 'SUCCESS' || status === 'ERROR') { - // Remove mapping when task completes - delete newMap[resourceId]; - } else { - // Add or update mapping - newMap[resourceId] = { taskId, status }; - } - return { ...state, resourceTaskMap: newMap }; - }); -} - -/** - * Get task status for a specific resource - * @param {string} resourceId - Resource ID - * @returns {Object|null} Task info or null if no active task - */ -export function getTaskForResource(resourceId) { - let result = null; - taskDrawerStore.subscribe(state => { - result = state.resourceTaskMap[resourceId] || null; - })(); - return result; -} - -// #endregion taskDrawer:Store -// #endregion TaskDrawerStore diff --git a/frontend/src/lib/stores/taskDrawer.ts b/frontend/src/lib/stores/taskDrawer.ts new file mode 100644 index 00000000..ea576fd7 --- /dev/null +++ b/frontend/src/lib/stores/taskDrawer.ts @@ -0,0 +1,112 @@ +// #region TaskDrawerStore [C:3] [TYPE Store] [SEMANTICS task, drawer, store, resource, mapping] +// @BRIEF Manage Task Drawer visibility, active task binding, and resource-to-task mapping. +// @LAYER UI +// @RELATION BINDS_TO -> [ActivityStore] +// @UX_STATE Closed -> Drawer hidden, no active task +// @UX_STATE Open -> Drawer visible with task logs or task list + +import { writable, type Writable } from 'svelte/store'; + +interface TaskInfo { + taskId: string; + status: string; +} + +interface ResourceTaskMap { + [k: string]: TaskInfo; +} + +interface TaskDrawerState { + isOpen: boolean; + activeTaskId: string | null; + resourceTaskMap: ResourceTaskMap; +} + +const TASK_DRAWER_AUTO_OPEN_STORAGE_KEY = 'ss_tools.profile.auto_open_task_drawer'; + +function readAutoOpenTaskDrawerPreference(): boolean { + if (typeof window === 'undefined') return true; + const rawValue = window.localStorage.getItem(TASK_DRAWER_AUTO_OPEN_STORAGE_KEY); + if (rawValue === 'false') return false; + return true; +} + +let autoOpenTaskDrawerPreference: boolean = readAutoOpenTaskDrawerPreference(); + +const initialState: TaskDrawerState = { + isOpen: false, + activeTaskId: null, + resourceTaskMap: {}, +}; + +export const taskDrawerStore: Writable = writable(initialState); + +export function openDrawerForTask(taskId: string): boolean { + if (!taskId) { + return false; + } + taskDrawerStore.update((state: TaskDrawerState) => ({ + ...state, + isOpen: true, + activeTaskId: taskId, + })); + return true; +} + +export function setTaskDrawerAutoOpenPreference(enabled: boolean): void { + autoOpenTaskDrawerPreference = enabled !== false; + if (typeof window !== 'undefined') { + window.localStorage.setItem( + TASK_DRAWER_AUTO_OPEN_STORAGE_KEY, + autoOpenTaskDrawerPreference ? 'true' : 'false', + ); + } +} + +export function getTaskDrawerAutoOpenPreference(): boolean { + return autoOpenTaskDrawerPreference; +} + +export function openDrawerForTaskIfPreferred(taskId: string): boolean { + if (autoOpenTaskDrawerPreference !== true) { + return false; + } + return openDrawerForTask(taskId); +} + +export function openDrawer(): void { + taskDrawerStore.update((state: TaskDrawerState) => ({ + ...state, + isOpen: true, + activeTaskId: null, + })); +} + +export function closeDrawer(): void { + taskDrawerStore.update((state: TaskDrawerState) => ({ + ...state, + isOpen: false, + activeTaskId: null, + })); +} + +export function updateResourceTask(resourceId: string, taskId: string, status: string): void { + taskDrawerStore.update((state: TaskDrawerState) => { + const newMap: ResourceTaskMap = { ...state.resourceTaskMap }; + if (status === 'IDLE' || status === 'SUCCESS' || status === 'ERROR') { + delete newMap[resourceId]; + } else { + newMap[resourceId] = { taskId, status }; + } + return { ...state, resourceTaskMap: newMap }; + }); +} + +export function getTaskForResource(resourceId: string): TaskInfo | null { + let result: TaskInfo | null = null; + taskDrawerStore.subscribe((state: TaskDrawerState) => { + result = state.resourceTaskMap[resourceId] || null; + })(); + return result; +} +// #endregion TaskDrawerStore diff --git a/frontend/src/lib/stores/translationRun.js b/frontend/src/lib/stores/translationRun.js deleted file mode 100644 index bd7c6482..00000000 --- a/frontend/src/lib/stores/translationRun.js +++ /dev/null @@ -1,279 +0,0 @@ -// #region TranslationRunStore [C:4] [TYPE Store] [SEMANTICS translate, run, progress, websocket, polling, store] -// @BRIEF Global store for active translation run progress — uses WebSocket for real-time -// progress + polling fallback for structured status. Survives page navigation. -// @RELATION DEPENDS_ON -> [EXT:frontend:fetchRunStatus] -// @RELATION DEPENDS_ON -> [EXT:frontend:getWsUrl] -// @UX_STATE idle -> No active run -// @UX_STATE running -> Polling + WebSocket active, progress bar updates in real-time -// @UX_STATE completed/partial/failed/insert_failed/cancelled -> Terminal states -// @UX_FEEDBACK WebSocket provides real-time batch completion events during translate phase -// @UX_FEEDBACK Polling provides structured status updates (insert phase, final counts) -// @UX_RECOVERY WebSocket disconnect -> continues with polling only -// @TYPEDEF {'idle'|'running'|'inserting'|'completed'|'partial'|'failed'|'insert_failed'|'cancelled'} UxState - - -/** - * @typedef {Object} TranslationRunState - * @property {string|null} runId - Active translation run ID - * @property {UxState} uxState - Current UX state - * @property {Object|null} status - Raw status data from API - * @property {number} totalRecords - * @property {number} successfulRecords - * @property {number} failedRecords - * @property {number} skippedRecords - * @property {number} progressPct - 0-100 - * @property {string|null} insertStatus - * @property {number} batchCount - * @property {string|null} jobId - The job this run belongs to (for navigation) - * @property {boolean} isFullRun - * @property {boolean} isActive - Derived: true when polling is active - */ - -import { writable, derived } from 'svelte/store'; -import { fetchRunStatus } from '$lib/api/translate.js'; -import { getTranslateRunWsUrl } from '$lib/api.js'; - -const initialState = { - runId: null, - uxState: 'idle', - status: null, - totalRecords: 0, - successfulRecords: 0, - failedRecords: 0, - skippedRecords: 0, - progressPct: 0, - insertStatus: null, - batchCount: 0, - jobId: null, - isFullRun: false, -}; - -/** @type {import('svelte/store').Writable} */ -export const translationRunStore = writable(initialState); - -/** Derived: true when a run is actively being polled */ -export const isTranslationActive = derived( - translationRunStore, - ($store) => $store.uxState === 'running' || $store.uxState === 'inserting' -); - -/** Derived: true when run has finished */ -export const isTranslationFinished = derived( - translationRunStore, - ($store) => - $store.uxState === 'completed' || - $store.uxState === 'partial' || - $store.uxState === 'failed' || - $store.uxState === 'insert_failed' || - $store.uxState === 'cancelled' -); - -// Internal polling + WebSocket state (not reactive) -let _pollingInterval = null; -let _pollCount = 0; -const MAX_POLLS = 300; -let _onCompleteCallback = null; -let _ws = null; - -/** - * Clear the onComplete callback without stopping polling. - * Used by page components when they unmount — the store keeps polling - * so the global indicator still works, but the page callback is detached. - */ -export function clearOnCompleteCallback() { - _onCompleteCallback = null; -} - -/** - * Start WebSocket + polling for a translation run. - * @param {string} runId - * @param {Object} [options] - * @param {string} [options.jobId] - Job ID for navigation - * @param {boolean} [options.isFullRun] - * @param {Function} [options.onComplete] - Called when run reaches terminal state - */ -export function startTranslationRun(runId, options = {}) { - if (!runId) return; - - stopTranslationRun(); - - translationRunStore.set({ - ...initialState, - runId, - uxState: 'running', - jobId: options.jobId || null, - isFullRun: options.isFullRun || false, - }); - - _onCompleteCallback = options.onComplete || null; - - // WebSocket for real-time progress events (task log stream) - _connectWebSocket(runId); - - // Polling for structured status data (fallback + insert phase) - _pollCount = 0; - _pollingInterval = setInterval(pollStatus, 2000); - pollStatus(); -} - -/** - * Connect WebSocket to /ws/translate/run/{runId} for real-time progress events. - * Receives structured run status JSON each second. - * Falls back silently to polling-only if WebSocket fails. - * @param {string} runId - */ -function _connectWebSocket(runId) { - try { - const wsUrl = getTranslateRunWsUrl(runId); - _ws = new WebSocket(wsUrl); - - _ws.onmessage = (event) => { - try { - const data = JSON.parse(event.data); - if (data.error) return; - const s = data.status; - const total = data.total_records || 0; - const done = (data.successful_records || 0) + (data.failed_records || 0) + (data.skipped_records || 0); - const pct = total > 0 ? Math.round((done / total) * 100) : 0; - const newUxState = s === 'PENDING' || s === 'RUNNING' - ? (data.insert_status === 'started' || data.insert_status === 'pending' || data.insert_status === 'running' ? 'inserting' : 'running') - : s === 'COMPLETED' - ? (data.insert_status === 'failed' || data.insert_status === 'timeout' ? 'insert_failed' : (data.failed_records > 0 ? 'partial' : 'completed')) - : s === 'FAILED' ? 'failed' - : s === 'CANCELLED' ? 'cancelled' - : null; - if (newUxState) { - translationRunStore.update(prev => ({ - ...prev, - uxState: newUxState, - status: data, - totalRecords: total, - successfulRecords: data.successful_records || 0, - failedRecords: data.failed_records || 0, - skippedRecords: data.skipped_records || 0, - progressPct: pct, - insertStatus: data.insert_status || null, - batchCount: data.batch_count || 0, - })); - if (newUxState !== 'running' && newUxState !== 'inserting') { - if (_onCompleteCallback) _onCompleteCallback(data); - } - } - } catch (_e) { - // Ignore parse errors - } - }; - - _ws.onerror = () => { - _ws = null; - }; - - _ws.onclose = () => { - _ws = null; - }; - } catch (_e) { - _ws = null; - } -} - -/** - * Stop WebSocket + polling without clearing state (run may persist). - */ -export function stopTranslationRun() { - if (_ws) { - _ws.onclose = null; // prevent reconnect - _ws.close(); - _ws = null; - } - if (_pollingInterval) { - clearInterval(_pollingInterval); - _pollingInterval = null; - } -} - -/** - * Reset store to idle. - */ -export function resetTranslationRun() { - stopTranslationRun(); - translationRunStore.set(initialState); - _onCompleteCallback = null; -} - -/** - * Manually set the run state (used by page component for lifecycle hooks). - * @param {Partial} state - */ -export function updateTranslationRunState(state) { - translationRunStore.update(prev => ({ ...prev, ...state })); -} - -/** @returns {Promise} */ -async function pollStatus() { - _pollCount++; - if (_pollCount > MAX_POLLS) { - console.warn('[translationRunStore] Max polls reached, stopping'); - stopTranslationRun(); - translationRunStore.update(s => ({ ...s, uxState: 'failed' })); - if (_onCompleteCallback) _onCompleteCallback({ status: 'TIMEOUT', error_message: 'Translation run timed out' }); - return; - } - - let currentState; - translationRunStore.subscribe(s => { currentState = s; })(); - if (!currentState || !currentState.runId) { - stopTranslationRun(); - return; - } - - try { - const data = await fetchRunStatus(currentState.runId); - if (!data) return; - - const s = data?.status; - const insertS = data?.insert_status; - - let newUxState = currentState.uxState; - - if (s === 'PENDING' || s === 'RUNNING') { - newUxState = (insertS === 'started' || insertS === 'pending' || insertS === 'running') - ? 'inserting' : 'running'; - } else if (s === 'COMPLETED') { - newUxState = (insertS === 'failed' || insertS === 'timeout') ? 'insert_failed' - : (data.failed_records > 0) ? 'partial' : 'completed'; - stopTranslationRun(); - } else if (s === 'FAILED') { - newUxState = 'failed'; - stopTranslationRun(); - } else if (s === 'CANCELLED') { - newUxState = 'cancelled'; - stopTranslationRun(); - } - - const total = data?.total_records || 0; - const pct = total > 0 - ? Math.round(((data.successful_records + data.failed_records + data.skipped_records) / total) * 100) - : 0; - - translationRunStore.update(prev => ({ - ...prev, - uxState: newUxState, - status: data, - totalRecords: total, - successfulRecords: data?.successful_records || 0, - failedRecords: data?.failed_records || 0, - skippedRecords: data?.skipped_records || 0, - progressPct: pct, - insertStatus: data?.insert_status || null, - batchCount: data?.batch_count || 0, - })); - - // Fire completion callback if terminal - if (newUxState !== 'running' && newUxState !== 'inserting') { - if (_onCompleteCallback) _onCompleteCallback(data); - } - } catch (err) { - console.warn('[translationRunStore] Poll error:', err); - } -} -// #endregion TranslationRunStore diff --git a/frontend/src/lib/stores/translationRun.ts b/frontend/src/lib/stores/translationRun.ts new file mode 100644 index 00000000..2a23505d --- /dev/null +++ b/frontend/src/lib/stores/translationRun.ts @@ -0,0 +1,247 @@ +// #region TranslationRunStore [C:4] [TYPE Store] [SEMANTICS translate, run, progress, websocket, polling, store] +// @BRIEF Global store for active translation run progress — uses WebSocket for real-time +// progress + polling fallback for structured status. Survives page navigation. +// @LAYER UI +// @RELATION DEPENDS_ON -> [TranslateRunsApi.fetchRunStatus] +// @RELATION DEPENDS_ON -> [ApiModule.getTranslateRunWsUrl] +// @UX_STATE idle -> No active run +// @UX_STATE running -> Polling + WebSocket active, progress bar updates in real-time +// @UX_STATE completed/partial/failed/insert_failed/cancelled -> Terminal states +// @UX_FEEDBACK WebSocket provides real-time batch completion events during translate phase +// @UX_FEEDBACK Polling provides structured status updates (insert phase, final counts) +// @UX_RECOVERY WebSocket disconnect -> continues with polling only + +import { writable, derived, type Writable } from 'svelte/store'; +import { fetchRunStatus } from '$lib/api/translate'; +import { getTranslateRunWsUrl } from '$lib/api'; + +export type UxState = 'idle' | 'running' | 'inserting' | 'completed' | 'partial' | 'failed' | 'insert_failed' | 'cancelled'; + +export interface TranslationRunState { + runId: string | null; + uxState: UxState; + status: Record | null; + totalRecords: number; + successfulRecords: number; + failedRecords: number; + skippedRecords: number; + progressPct: number; + insertStatus: string | null; + batchCount: number; + jobId: string | null; + isFullRun: boolean; +} + +export interface StartRunOptions { + jobId?: string; + isFullRun?: boolean; + onComplete?: (data: Record) => void; +} + +const initialState: TranslationRunState = { + runId: null, + uxState: 'idle', + status: null, + totalRecords: 0, + successfulRecords: 0, + failedRecords: 0, + skippedRecords: 0, + progressPct: 0, + insertStatus: null, + batchCount: 0, + jobId: null, + isFullRun: false, +}; + +export const translationRunStore: Writable = writable(initialState); + +export const isTranslationActive = derived( + translationRunStore, + ($store: TranslationRunState) => $store.uxState === 'running' || $store.uxState === 'inserting', +); + +export const isTranslationFinished = derived( + translationRunStore, + ($store: TranslationRunState) => + $store.uxState === 'completed' || + $store.uxState === 'partial' || + $store.uxState === 'failed' || + $store.uxState === 'insert_failed' || + $store.uxState === 'cancelled', +); + +let _pollingInterval: ReturnType | null = null; +let _pollCount = 0; +const MAX_POLLS = 300; +let _onCompleteCallback: ((data: Record) => void) | null = null; +let _ws: WebSocket | null = null; + +export function clearOnCompleteCallback(): void { + _onCompleteCallback = null; +} + +export function startTranslationRun(runId: string, options: StartRunOptions = {}): void { + if (!runId) return; + + stopTranslationRun(); + + translationRunStore.set({ + ...initialState, + runId, + uxState: 'running', + jobId: options.jobId || null, + isFullRun: options.isFullRun || false, + }); + + _onCompleteCallback = options.onComplete || null; + + _connectWebSocket(runId); + + _pollCount = 0; + _pollingInterval = setInterval(pollStatus, 2000); + pollStatus(); +} + +function _connectWebSocket(runId: string): void { + try { + const wsUrl = getTranslateRunWsUrl(runId); + _ws = new WebSocket(wsUrl); + + _ws.onmessage = (event: MessageEvent) => { + try { + const data = JSON.parse(event.data) as Record; + if (data.error) return; + const s = data.status as string; + const total = (data.total_records as number) || 0; + const done = ((data.successful_records as number) || 0) + ((data.failed_records as number) || 0) + ((data.skipped_records as number) || 0); + const pct = total > 0 ? Math.round((done / total) * 100) : 0; + const insertS = data.insert_status as string; + const newUxState: UxState | null = + s === 'PENDING' || s === 'RUNNING' + ? (insertS === 'started' || insertS === 'pending' || insertS === 'running' ? 'inserting' : 'running') + : s === 'COMPLETED' + ? (insertS === 'failed' || insertS === 'timeout' ? 'insert_failed' : ((data.failed_records as number) > 0 ? 'partial' : 'completed')) + : s === 'FAILED' ? 'failed' + : s === 'CANCELLED' ? 'cancelled' + : null; + if (newUxState) { + translationRunStore.update((prev) => ({ + ...prev, + uxState: newUxState, + status: data, + totalRecords: total, + successfulRecords: (data.successful_records as number) || 0, + failedRecords: (data.failed_records as number) || 0, + skippedRecords: (data.skipped_records as number) || 0, + progressPct: pct, + insertStatus: insertS || null, + batchCount: (data.batch_count as number) || 0, + })); + if (newUxState !== 'running' && newUxState !== 'inserting') { + if (_onCompleteCallback) _onCompleteCallback(data); + } + } + } catch (_e) { + // Ignore parse errors + } + }; + + _ws.onerror = () => { + _ws = null; + }; + + _ws.onclose = () => { + _ws = null; + }; + } catch (_e) { + _ws = null; + } +} + +export function stopTranslationRun(): void { + if (_ws) { + _ws.onclose = null; + _ws.close(); + _ws = null; + } + if (_pollingInterval) { + clearInterval(_pollingInterval); + _pollingInterval = null; + } +} + +export function resetTranslationRun(): void { + stopTranslationRun(); + translationRunStore.set(initialState); + _onCompleteCallback = null; +} + +export function updateTranslationRunState(state: Partial): void { + translationRunStore.update((prev) => ({ ...prev, ...state })); +} + +async function pollStatus(): Promise { + _pollCount++; + if (_pollCount > MAX_POLLS) { + stopTranslationRun(); + translationRunStore.update((s) => ({ ...s, uxState: 'failed' })); + if (_onCompleteCallback) _onCompleteCallback({ status: 'TIMEOUT', error_message: 'Translation run timed out' }); + return; + } + + let currentState: TranslationRunState | undefined; + translationRunStore.subscribe((s) => { currentState = s; })(); + if (!currentState || !currentState.runId) { + stopTranslationRun(); + return; + } + + try { + const data = (await fetchRunStatus(currentState.runId)) as Record; + if (!data) return; + + const s = data?.status as string; + const insertS = data?.insert_status as string; + + let newUxState: UxState = currentState.uxState; + + if (s === 'PENDING' || s === 'RUNNING') { + newUxState = (insertS === 'started' || insertS === 'pending' || insertS === 'running') ? 'inserting' : 'running'; + } else if (s === 'COMPLETED') { + newUxState = (insertS === 'failed' || insertS === 'timeout') ? 'insert_failed' + : (data.failed_records as number) > 0 ? 'partial' : 'completed'; + stopTranslationRun(); + } else if (s === 'FAILED') { + newUxState = 'failed'; + stopTranslationRun(); + } else if (s === 'CANCELLED') { + newUxState = 'cancelled'; + stopTranslationRun(); + } + + const total = (data?.total_records as number) || 0; + const pct = total > 0 + ? Math.round((((data.successful_records as number) + (data.failed_records as number) + (data.skipped_records as number)) / total) * 100) + : 0; + + translationRunStore.update((prev) => ({ + ...prev, + uxState: newUxState, + status: data, + totalRecords: total, + successfulRecords: (data?.successful_records as number) || 0, + failedRecords: (data?.failed_records as number) || 0, + skippedRecords: (data?.skipped_records as number) || 0, + progressPct: pct, + insertStatus: (data?.insert_status as string) || null, + batchCount: (data?.batch_count as number) || 0, + })); + + if (newUxState !== 'running' && newUxState !== 'inserting') { + if (_onCompleteCallback) _onCompleteCallback(data); + } + } catch (_err) { + // Poll error — will retry on next interval + } +} +// #endregion TranslationRunStore