feat(ts-migration): Phase 2.4 — all stores JS→TS with contracts

Converted 10 files:
  - stores.ts [C:3] — plugins, tasks, selection stores + fetchPlugins/fetchTasks
  - stores/health.ts [C:3] — health summary with polling dedup + derived failingCount
  - stores/activity.ts [C:2] — derived active task count for navbar badge
  - stores/sidebar.ts [C:3] — sidebar expansion, navigation, mobile overlay + localStorage persist
  - stores/taskDrawer.ts [C:3] — drawer open/close, resource-to-task mapping, auto-open pref
  - stores/environmentContext.ts [C:3] — environment selector, localStorage persist, derived env
  - stores/assistantChat.ts [C:3] — panel toggle, conversation binding, seed messages, focus target
  - stores/datasetReviewSession.ts [C:4] — session CRUD, dirty flag, UiPhase derivation
  - stores/translationRun.ts [C:4] — WebSocket + polling, uxState FSM, derived active/finished
  - stores/maintenance.svelte.ts [C:4] — Svelte 5 runes (), WS reconnect, CRUD

Build: npm run build passes
This commit is contained in:
2026-06-01 15:24:10 +03:00
parent 3ef06da8a2
commit 03557d38cc
20 changed files with 1162 additions and 1368 deletions

View File

@@ -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

View File

@@ -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<Plugin[]> = writable([]);
// #endregion plugins
// #region tasks [C:2] [TYPE Store] [SEMANTICS tasks, store]
export const tasks: Writable<Task[]> = writable([]);
// #endregion tasks
// #region selectedPlugin [C:1] [TYPE Store]
export const selectedPlugin: Writable<Plugin | null> = writable(null);
// #endregion selectedPlugin
// #region selectedTask [C:1] [TYPE Store]
export const selectedTask: Writable<Task | null> = writable(null);
// #endregion selectedTask
// #region currentPage [C:1] [TYPE Store]
export const currentPage: Writable<string> = writable('dashboard');
// #endregion currentPage
// #region taskLogs [C:1] [TYPE Store]
export const taskLogs: Writable<unknown[]> = 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<void> {
try {
const data = await api.getPlugins<Plugin[]>();
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<void> {
try {
const data = await api.getTasks<Task[]>();
tasks.set(data);
} catch (error) {
console.error(`[stores.fetchTasks] Error fetching tasks:`, error);
}
}
// #endregion fetchTasks
// #endregion StoresModule

View File

@@ -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

View File

@@ -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<typeof taskDrawerStore, ActivityState>(
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

View File

@@ -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

View File

@@ -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<AssistantChatState> = 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

View File

@@ -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<SessionDetail> | 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

View File

@@ -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<SessionDetail> | 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<string, unknown> | 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<DatasetReviewSessionState> = writable(initialState);
export function setSession(session: Record<string, unknown>): 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<string, unknown>): void {
datasetReviewSessionStore.update((state) => {
if (!state.session) return state;
return {
...state,
session: { ...state.session, ...patch },
isDirty: true,
};
});
}
export function getSessionUiPhase(session: Record<string, unknown> | 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

View File

@@ -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

View File

@@ -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<EnvironmentContextState> = 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<void> {
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<Environment[]>();
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<void> {
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

View File

@@ -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

View File

@@ -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<HealthState>({
items: [],
pass_count: 0,
warn_count: 0,
fail_count: 0,
unknown_count: 0,
loading: false,
lastUpdated: null,
});
let inflightRefresh: Promise<unknown> | null = null;
let inflightEnvironmentId: string | null = null;
let _isDisabled = false;
async function refresh(environmentId: string | null = null): Promise<unknown> {
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<HealthState>(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

View File

@@ -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

View File

@@ -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<void>;
loadSettings: () => Promise<void>;
loadDashboardBanners: () => Promise<void>;
endEvent: (maintenanceId: string) => Promise<void>;
endAllEvents: () => Promise<void>;
updateSettings: (newSettings: MaintenanceSettings) => Promise<void>;
init: () => Promise<void>;
disconnectWs: () => void;
}
export function createMaintenanceStore(): MaintenanceStore {
let activeEvents = $state<unknown[]>([]);
let completedEvents = $state<unknown[]>([]);
let settings = $state<MaintenanceSettings | null>(null);
let dashboardBanners = $state<MaintenanceBanner[]>([]);
let isLoading = $state(false);
let error = $state<string | null>(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<void> {
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<void> {
try {
const data = await maintenanceApi.listDashboardBanners<MaintenanceBanner[]>();
dashboardBanners = Array.isArray(data) ? data : [];
} catch (e: unknown) {
console.error("[MaintenanceStore] loadDashboardBanners error:", (e as Error)?.message);
dashboardBanners = [];
}
}
async function loadSettings(): Promise<void> {
try {
settings = await maintenanceApi.getSettings<MaintenanceSettings>();
} catch (e: unknown) {
console.error("[MaintenanceStore] loadSettings error:", (e as Error)?.message);
}
}
async function endEvent(maintenanceId: string): Promise<void> {
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<void> {
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<void> {
isLoading = true;
try {
settings = await maintenanceApi.updateSettings<MaintenanceSettings>(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<void> {
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

View File

@@ -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

View File

@@ -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<SidebarState> = 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

View File

@@ -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

View File

@@ -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<TaskDrawerState> = 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

View File

@@ -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<TranslationRunState>} */
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<TranslationRunState>} state
*/
export function updateTranslationRunState(state) {
translationRunStore.update(prev => ({ ...prev, ...state }));
}
/** @returns {Promise<void>} */
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

View File

@@ -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<string, unknown> | 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<string, unknown>) => 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<TranslationRunState> = 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<typeof setInterval> | null = null;
let _pollCount = 0;
const MAX_POLLS = 300;
let _onCompleteCallback: ((data: Record<string, unknown>) => 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<string, unknown>;
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<TranslationRunState>): void {
translationRunStore.update((prev) => ({ ...prev, ...state }));
}
async function pollStatus(): Promise<void> {
_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<string, unknown>;
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