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:
@@ -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
|
||||
64
frontend/src/lib/stores.ts
Normal file
64
frontend/src/lib/stores.ts
Normal 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
|
||||
@@ -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
|
||||
49
frontend/src/lib/stores/activity.ts
Normal file
49
frontend/src/lib/stores/activity.ts
Normal 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
|
||||
@@ -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
|
||||
83
frontend/src/lib/stores/assistantChat.ts
Normal file
83
frontend/src/lib/stores/assistantChat.ts
Normal 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
|
||||
@@ -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
|
||||
87
frontend/src/lib/stores/datasetReviewSession.ts
Normal file
87
frontend/src/lib/stores/datasetReviewSession.ts
Normal 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
|
||||
@@ -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
|
||||
157
frontend/src/lib/stores/environmentContext.ts
Normal file
157
frontend/src/lib/stores/environmentContext.ts
Normal 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
|
||||
@@ -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
|
||||
81
frontend/src/lib/stores/health.ts
Normal file
81
frontend/src/lib/stores/health.ts
Normal 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
|
||||
@@ -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
|
||||
203
frontend/src/lib/stores/maintenance.svelte.ts
Normal file
203
frontend/src/lib/stores/maintenance.svelte.ts
Normal 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
|
||||
@@ -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
|
||||
79
frontend/src/lib/stores/sidebar.ts
Normal file
79
frontend/src/lib/stores/sidebar.ts
Normal 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
|
||||
@@ -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
|
||||
112
frontend/src/lib/stores/taskDrawer.ts
Normal file
112
frontend/src/lib/stores/taskDrawer.ts
Normal 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
|
||||
@@ -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
|
||||
247
frontend/src/lib/stores/translationRun.ts
Normal file
247
frontend/src/lib/stores/translationRun.ts
Normal 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
|
||||
Reference in New Issue
Block a user