diff --git a/frontend/src/lib/models/AgentChat.ConnectionManager.svelte.ts b/frontend/src/lib/models/AgentChat.ConnectionManager.svelte.ts new file mode 100644 index 00000000..cbe1f231 --- /dev/null +++ b/frontend/src/lib/models/AgentChat.ConnectionManager.svelte.ts @@ -0,0 +1,107 @@ +// frontend/src/lib/models/AgentChat.ConnectionManager.svelte.ts +// #region AgentChat.ConnectionManager [C:4] [TYPE Class] [SEMANTICS agent-chat,connection,reconnect] +// @ingroup AgentChat +// @BRIEF Manages Gradio client connection lifecycle — disconnect detection, exponential reconnect loop, and retry. +// @RELATION CALLED_BY -> [AgentChat.Model] +// @INVARIANT Max 5 reconnect attempts before permanent disconnected state. +// @INVARIANT Reconnect loop is idempotent: only one timer runs at a time. +// @RATIONALE Extracted from AgentChat.Model (630→~340 lines) per decomposition gate. Owns reconnect timer + attempt tracking, delegates client creation and state mutation via callbacks. +import { Client, type Client as GradioClient } from "@gradio/client"; +import { log } from "$lib/cot-logger"; +import type { ConnectionState, StreamingState } from "./AgentChatTypes.js"; + +// ── Reconnect constants ────────────────────────────────────────── + +export const RECONNECT_MAX_ATTEMPTS = 5; +export const RECONNECT_INTERVAL_MS = 5_000; + +// ── Callback interface ─────────────────────────────────────────── + +export interface ConnectionManagerCallbacks { + onConnected: (client: GradioClient) => void; + onDisconnected: () => void; + onDisconnectedPermanent: () => void; + onStreamingStateChange: (s: StreamingState) => void; +} + +// ═══ ConnectionManager ═══════════════════════════════════════════ + +export class ConnectionManager { + private _reconnectAttempts = 0; + private _reconnectTimer: ReturnType | null = null; + + constructor(private cb: ConnectionManagerCallbacks) {} + + // ── Public actions ────────────────────────────────────────────── + + async retryConnection(): Promise { + log("AgentChat.ConnectionManager", "REASON", "Manual reconnect"); + this._reconnectAttempts = 0; + try { + const client = await Client.connect("/api/agent/gradio"); + this.cb.onConnected(client); + this.cb.onStreamingStateChange("idle"); + log("AgentChat.ConnectionManager", "REFLECT", "Reconnected successfully"); + } catch (e: unknown) { + this.cb.onDisconnectedPermanent(); + log("AgentChat.ConnectionManager", "EXPLORE", "Reconnect failed", {}, e instanceof Error ? e.message : "Unknown"); + } + } + + /** Call when the Gradio client emits a disconnect event. */ + onDisconnect(): void { + this.cb.onDisconnected(); + this._reconnectAttempts = 0; + log("AgentChat.ConnectionManager", "EXPLORE", "Gradio disconnected"); + this._startReconnectLoop(); + } + + /** Call when the Gradio client reconnects. Resets attempt counter and clears timer. */ + onReconnect(client: GradioClient): void { + this.cb.onConnected(client); + this.cb.onStreamingStateChange("idle"); + this._reconnectAttempts = 0; + this._clearTimer(); + log("AgentChat.ConnectionManager", "REFLECT", "Gradio reconnected"); + } + + /** Stop the reconnect loop. */ + stopReconnect(): void { + this._clearTimer(); + this._reconnectAttempts = 0; + } + + /** Bump attempts to force stop the reconnect loop (legacy escape hatch). */ + setReconnectAttempts(n: number): void { + this._reconnectAttempts = n; + } + + // ── Private ───────────────────────────────────────────────────── + + private _startReconnectLoop(): void { + if (this._reconnectTimer) return; + this._reconnectTimer = setInterval(async () => { + this._reconnectAttempts++; + if (this._reconnectAttempts > RECONNECT_MAX_ATTEMPTS) { + this.cb.onDisconnectedPermanent(); + this._clearTimer(); + log("AgentChat.ConnectionManager", "EXPLORE", "Max reconnect attempts reached", { attempts: this._reconnectAttempts }); + return; + } + try { + const client = await Client.connect("/api/agent/gradio"); + this.onReconnect(client); + } catch { + log("AgentChat.ConnectionManager", "EXPLORE", "Reconnect attempt failed", { attempt: this._reconnectAttempts }); + } + }, RECONNECT_INTERVAL_MS); + } + + private _clearTimer(): void { + if (this._reconnectTimer) { + clearInterval(this._reconnectTimer); + this._reconnectTimer = null; + } + } +} +// #endregion AgentChat.ConnectionManager diff --git a/frontend/src/lib/models/AgentChat.LocalStorage.ts b/frontend/src/lib/models/AgentChat.LocalStorage.ts new file mode 100644 index 00000000..9d595955 --- /dev/null +++ b/frontend/src/lib/models/AgentChat.LocalStorage.ts @@ -0,0 +1,56 @@ +// frontend/src/lib/models/AgentChat.LocalStorage.ts +// #region AgentChat.LocalStorage [C:2] [TYPE Class] [SEMANTICS agent-chat,persistence,localstorage] +// @ingroup AgentChat +// @BRIEF Browser localStorage persistence for agent chat messages — save/load/clear per conversation. +// @RELATION CALLED_BY -> [AgentChat.Model] +// @RATIONALE Extracted from AgentChat.Model per decomposition gate. Pure utility with no reactive state — plain class, no runes needed. +import type { AgentMessage } from "./AgentChatTypes.js"; + +export class LocalStorageManager { + readonly STORAGE_PREFIX = "agentchat:"; + + save( + conversationId: string, + messages: AgentMessage[], + partialText: string, + ): void { + try { + const key = `${this.STORAGE_PREFIX}messages:${conversationId}`; + const data = { messages, partialText, conversationId }; + localStorage.setItem(key, JSON.stringify(data)); + } catch { + // localStorage may be full or unavailable — silently ignore + } + } + + load( + conversationId: string, + ): { messages: AgentMessage[]; partialText: string; conversationId: string } | null { + try { + const key = `${this.STORAGE_PREFIX}messages:${conversationId}`; + const raw = localStorage.getItem(key); + if (!raw) return null; + const data = JSON.parse(raw); + if (data?.messages && Array.isArray(data.messages)) { + return { + messages: data.messages, + partialText: data.partialText ?? "", + conversationId: data.conversationId || conversationId, + }; + } + return null; + } catch { + return null; + } + } + + clear(conversationId: string): void { + try { + const key = `${this.STORAGE_PREFIX}messages:${conversationId}`; + localStorage.removeItem(key); + } catch { + // silent + } + } +} +// #endregion AgentChat.LocalStorage diff --git a/frontend/src/lib/models/AgentChat.StreamProcessor.svelte.ts b/frontend/src/lib/models/AgentChat.StreamProcessor.svelte.ts new file mode 100644 index 00000000..0ad76acb --- /dev/null +++ b/frontend/src/lib/models/AgentChat.StreamProcessor.svelte.ts @@ -0,0 +1,189 @@ +// frontend/src/lib/models/AgentChat.StreamProcessor.svelte.ts +// #region AgentChat.StreamProcessor [C:4] [TYPE Class] [SEMANTICS agent-chat,stream,metadata,events] +// @ingroup AgentChat +// @BRIEF Processes Gradio submit() stream events — parses event data into AgentMessage, dispatches metadata types (stream_token, tool_start/end/error, confirm_required/resolved, error), and provides SSE close watcher. +// @RELATION CALLED_BY -> [AgentChat.Model] +// @RATIONALE Extracted from AgentChat.Model per decomposition gate. Pure mutation logic that writes to a host interface (the main model's reactive atoms). No external dependencies beyond types and log — keeps the main model focused on orchestration. +import { type Client as GradioClient } from "@gradio/client"; +import { log } from "$lib/cot-logger"; +import type { + StreamingState, + StreamMetadata, + ToolCall, + AgentMessage, +} from "./AgentChatTypes.js"; + +// ── Host interface ────────────────────────────────────────────── + +export interface StreamProcessorHost { + streamingState: StreamingState; + partialText: string; + partialTokens: string[]; + activeToolCalls: ToolCall[]; + error: string | null; + pendingThreadId: string | null; + currentConversationId: string | null; + captureConversationId(meta: StreamMetadata | undefined, convIdFromEvent: string): void; +} + +// ═══ StreamProcessor ════════════════════════════════════════════ + +export class StreamProcessor { + constructor(private host: StreamProcessorHost) {} + + /** Iterate Gradio submit() events and update host state. Returns true when stream completes. */ + async processStream( + submission: ReturnType, + convId: string | null, + ): Promise { + for await (const event of submission) { + if (event.type === "heartbeat") continue; + + const parsed = this._parseEvent(event, convId); + this.host.captureConversationId(parsed.metadata, parsed.conversation_id); + this._onStreamData(parsed); + } + return true; + } + + /** Watch Client.stream_status.open — when SSE stream closes via close_stream(), + * return false so Promise.race terminates the iterator via .return(). + * + * stream_status starts as { open: false }. We wait for open=true (SSE opened), + * then open=false (SSE closed). Without this step the watcher fires immediately + * on the initial false. */ + async streamCloseWatcher(client: GradioClient | null, maxWaitMs: number): Promise { + const deadline = Date.now() + maxWaitMs; + let wasOpen = false; + while (Date.now() < deadline) { + const ss = (client as any)?.stream_status; + if (ss) { + if (!wasOpen && ss.open === true) wasOpen = true; + if (wasOpen && ss.open === false) return false; + } + await new Promise((r) => setTimeout(r, 100)); + } + return false; + } + + // ── Private — event parsing ───────────────────────────────────── + + private _parseEvent(event: any, convId: string | null): AgentMessage { + const raw = event.data; + + if (Array.isArray(raw)) { + const jsonStr = raw[0]; + if (typeof jsonStr === "string") { + try { + const obj = JSON.parse(jsonStr); + return { + id: "", conversation_id: convId ?? "", role: "assistant", + text: obj.content ?? jsonStr, metadata: obj.metadata, + toolCalls: [], created_at: "", + }; + } catch { + return { + id: "", conversation_id: "", role: "assistant", + text: jsonStr, toolCalls: [], created_at: "", + }; + } + } else { + return { + id: "", conversation_id: "", role: "assistant", + text: jsonStr?.text ?? "", metadata: jsonStr?.metadata, + toolCalls: [], created_at: "", + }; + } + } else if (typeof raw === "string") { + try { + const obj = JSON.parse(raw); + return { + id: "", conversation_id: convId ?? "", role: "assistant", + text: obj.content ?? raw, metadata: obj.metadata, + toolCalls: [], created_at: "", + }; + } catch { + return { + id: "", conversation_id: "", role: "assistant", + text: raw, toolCalls: [], created_at: "", + }; + } + } else { + const obj = raw as Record; + return { + id: "", conversation_id: convId ?? "", role: "assistant", + text: (obj.content as string) ?? obj.text as string ?? "", + metadata: obj.metadata as StreamMetadata, + toolCalls: [], created_at: "", + }; + } + } + + /** Dispatch metadata-driven state mutations on the host. */ + private _onStreamData(msg: AgentMessage): void { + const meta = msg.metadata; + + if (!meta || !meta.type) { + // Plain text token — append to last assistant message + this.host.partialText += msg.text; + return; + } + + switch (meta.type) { + case "stream_token": { + const token = meta.token ?? ""; + // Dedup: skip if token is already at the end of accumulated text + if (token && this.host.partialText.endsWith(token)) break; + this.host.partialText += token; + this.host.partialTokens = [...this.host.partialTokens, token]; + break; + } + + case "tool_start": + this.host.activeToolCalls = [ + ...this.host.activeToolCalls, + { + tool: meta.tool ?? "unknown", + input: meta.input ?? {}, + status: "executing", + }, + ]; + break; + + case "tool_end": + this.host.activeToolCalls = this.host.activeToolCalls.map((tc) => + tc.tool === meta.tool + ? { ...tc, output: meta.output, status: "completed" as const } + : tc, + ); + break; + + case "tool_error": + this.host.activeToolCalls = this.host.activeToolCalls.map((tc) => + tc.tool === meta.tool + ? { ...tc, error: meta.error, status: "failed" as const } + : tc, + ); + break; + + case "confirm_required": + this.host.streamingState = "awaiting_confirmation"; + this.host.pendingThreadId = meta.thread_id ?? null; + break; + + case "confirm_resolved": + this.host.streamingState = meta.result === "confirmed" ? "streaming" : "idle"; + this.host.pendingThreadId = null; + break; + + case "error": + this.host.streamingState = "error"; + this.host.error = meta.detail ?? meta.code ?? "Unknown error"; + break; + + default: + log("AgentChat.StreamProcessor", "EXPLORE", "Unknown metadata type", { type: meta.type }); + } + } +} +// #endregion AgentChat.StreamProcessor diff --git a/frontend/src/lib/models/AgentChatModel.svelte.ts b/frontend/src/lib/models/AgentChatModel.svelte.ts index 119543ed..fdcc491d 100644 --- a/frontend/src/lib/models/AgentChatModel.svelte.ts +++ b/frontend/src/lib/models/AgentChatModel.svelte.ts @@ -6,6 +6,7 @@ // @INVARIANT HITL resume: confirmation via second submit() with additional_inputs=[conversationId, "confirm"|"deny"]. Primary stream TERMINATES on confirm_required, second stream resumes from checkpoint. // @INVARIANT Optimistic cancel: submission.cancel() stops generation, partial text preserved. // @INVARIANT Conversation list loaded from FastAPI REST — Gradio serves only live agent streaming. +// @INVARIANT DECOMPOSITION GATE (630→~340 lines): Stream processing → AgentChat.StreamProcessor; Connection lifecycle → AgentChat.ConnectionManager; Persistence → AgentChat.LocalStorage; Types → AgentChatTypes. // @STATE idle | streaming | awaiting_confirmation | error | disconnected | disconnected_permanent // @ACTION sendMessage(text, files?) — submit("/chat", {text, files}, [conversationId, null]), iterate stream events, process metadata. // @ACTION cancelGeneration() — submission.cancel(), optimistic UI reset. @@ -14,16 +15,17 @@ // @ACTION loadHistory(conversationId?) — fetch messages from REST. // @ACTION deleteConversation(id) — optimistic archive, rollback on failure. // @ACTION createConversation() — clear state, start new. -// @ACTION retryConnection() — Client.connect() retry. +// @ACTION retryConnection() — delegates to ConnectionManager.retryConnection(). // @RELATION BINDS_TO -> [AssistantChatStore] -// @RELATION DEPENDS_ON -> [GradioClient:@gradio/client] -// @RELATION DEPENDS_ON -> [AssistantApi] -// @RATIONALE Model-first: extracted from AssistantChatPanel.svelte (1029 lines). Centralizes Gradio submit() lifecycle, metadata parsing, HITL resume, and reconnect logic in a testable unit. Without a model, streaming + metadata dispatch + confirmation + reconnect logic scattered across event handlers would be untestable. +// @RELATION DEPENDS_ON -> [AgentChat.ConnectionManager] +// @RELATION DEPENDS_ON -> [AgentChat.StreamProcessor] +// @RELATION DEPENDS_ON -> [AgentChat.LocalStorage] +// @RELATION DEPENDS_ON -> [AgentChatTypes] +// @RATIONALE Model-first: extracted from AssistantChatPanel.svelte (1029 lines). Decomposed further per DECOMPOSITION GATE into sub-helpers (ConnectionManager, StreamProcessor, LocalStorage) to stay under 400-line limit. // @REJECTED Inline $state in AssistantChatPanel.svelte — rejected because component already exceeds 400-line guideline. // @REJECTED WebSocket-based model — rejected with custom WebSocket protocol. // @REJECTED Active/follower multi-tab — rejected in favor of client-side gate. - -import { Client, type Client as GradioClient } from "@gradio/client"; +import { type Client as GradioClient } from "@gradio/client"; import { assistantChatStore, setAssistantConversationId, @@ -35,68 +37,14 @@ import { } from "$lib/api/assistant.js"; import { addToast } from "$lib/toasts.svelte.js"; import { log } from "$lib/cot-logger"; +import { type ConnectionManagerCallbacks, ConnectionManager } from "./AgentChat.ConnectionManager.svelte.js"; +import { type StreamProcessorHost, StreamProcessor } from "./AgentChat.StreamProcessor.svelte.js"; +import { LocalStorageManager } from "./AgentChat.LocalStorage.js"; +import type { + StreamingState, ConnectionState, StreamMetadata, ToolCall, AgentMessage, Conversation, +} from "./AgentChatTypes.js"; -// ── Type definitions ───────────────────────────────────────────── - -type StreamingState = - | "idle" - | "streaming" - | "awaiting_confirmation" - | "error" - | "disconnected" - | "disconnected_permanent"; - -type ConnectionState = - | "connected" - | "disconnected" - | "disconnected_permanent"; - -interface StreamMetadata { - type?: "stream_token" | "tool_start" | "tool_end" | "tool_error" - | "confirm_required" | "confirm_resolved" | "error"; - token?: string; - tool?: string; - input?: Record; - output?: Record; - error?: string; - prompt?: string; - thread_id?: string; - result?: "confirmed" | "denied"; - code?: string; - detail?: string; -} - -interface ToolCall { - tool: string; - input: Record; - output?: Record; - error?: string; - status: "executing" | "completed" | "failed"; -} - -interface AgentMessage { - id: string; - conversation_id: string; - role: "user" | "assistant" | "system" | "tool"; - text: string; - metadata?: StreamMetadata; - state?: string; - task_id?: string; - toolCalls: ToolCall[]; - created_at: string; -} - -interface Conversation { - id: string; - title: string; - updated_at: string; - message_count: number; -} - -// ── Reconnect constants ────────────────────────────────────────── - -const RECONNECT_MAX_ATTEMPTS = 5; -const RECONNECT_INTERVAL_MS = 5_000; +// ═══ Main model ═════════════════════════════════════════════════ export class AgentChatModel { // ── Atoms ────────────────────────────────────────────────────── @@ -107,24 +55,27 @@ export class AgentChatModel { connectionState: ConnectionState = $state("connected"); error: string | null = $state(null); partialText: string = $state(""); - partialTokens: string[] = $state([]); // raw tokens for dedup + partialTokens: string[] = $state([]); activeToolCalls: ToolCall[] = $state([]); isLoadingHistory: boolean = $state(false); inputText: string = $state(""); - pendingThreadId: string | null = $state(null); // for HITL resume + pendingThreadId: string | null = $state(null); - // ── Private atoms ────────────────────────────────────────────── - private _client: GradioClient | null = null; + // ── Private fields ───────────────────────────────────────────── + _client: GradioClient | null = null; // non-private for legacy Object.assign usage private _submission: ReturnType | null = null; private _conversationsPage: number = 1; private _conversationsHasNext: boolean = $state(false); private _historyPage: number = 1; private _historyHasNext: boolean = $state(false); - private _reconnectAttempts: number = 0; - private _reconnectTimer: ReturnType | null = null; private _messageQueue: Array<{ text: string; files?: File[] }> = $state([]); private _processingQueue: boolean = false; + // ── Sub-helpers ──────────────────────────────────────────────── + readonly connection: ConnectionManager; + private readonly streamProcessor: StreamProcessor; + private readonly storage: LocalStorageManager; + // ── Derived ──────────────────────────────────────────────────── isStreaming = $derived( this.streamingState === "streaming" || @@ -143,24 +94,34 @@ export class AgentChatModel { queuePosition = $derived(this._messageQueue.length); conversationsHasNext = $derived(this._conversationsHasNext); - // ── Actions — P1 ─────────────────────────────────────────────── + constructor() { + const connectionCbs: ConnectionManagerCallbacks = { + onConnected: (client) => { this._client = client; this.connectionState = "connected"; }, + onDisconnected: () => { this.connectionState = "disconnected"; }, + onDisconnectedPermanent: () => { this.connectionState = "disconnected_permanent"; }, + onStreamingStateChange: (s) => { this.streamingState = s; }, + }; + this.connection = new ConnectionManager(connectionCbs); + this.streamProcessor = new StreamProcessor(this as unknown as StreamProcessorHost); + this.storage = new LocalStorageManager(); + } + + // ── Actions — P1 (streaming) ─────────────────────────────────── async sendMessage(text: string, files?: File[]): Promise { if (!text.trim() && (!files || files.length === 0)) return; if (this.connectionState !== "connected") return; - // If currently streaming, enqueue message if (this.streamingState !== "idle") { this._messageQueue = [...this._messageQueue, { text, files }]; log("AgentChat.Model", "REASON", "Message queued", { queueSize: this._messageQueue.length }); return; } - await this._sendNow(text, files); } private async _sendNow(text: string, files?: File[]): Promise { - if (this._processingQueue) return; // prevent re-entry from queue drain + if (this._processingQueue) return; const convId = this.currentConversationId; this.streamingState = "streaming"; this.error = null; @@ -174,26 +135,20 @@ export class AgentChatModel { [{ text, files }, null, convId, null], ); - // Stream watcher: polls Client.stream_status.open. Когда SSE стрим закрыт - // (close_stream → abort controller), дёргаем submission.return() чтобы - // штатно завершить итератор. Если за 120с стрим не закрылся — абсолютный fallback. - // Без этого for-await висит вечно: @gradio/client v1.19.1 close_stream() - // не вызывает close() на итераторе submit(). + // Stream watcher: polls Client.stream_status.open. When SSE stream closes + // (close_stream → abort controller), we call submission.return() to + // terminate the iterator. If stream doesn't close in 120s — absolute fallback. const streamDone = await Promise.race([ - this._processStream(this._submission, convId), - this._streamCloseWatcher(120_000), + this.streamProcessor.processStream(this._submission, convId), + this.streamProcessor.streamCloseWatcher(this._client, 120_000), ]); if (streamDone === false) { - // SSE stream closed — принудительно завершаем итератор try { this._submission?.return?.(); } catch { /* ignore */ } - this._submission = null; } this.streamingState = "idle"; this._submission = null; this._persistMessages(); - - // Drain queue after stream completes await this._drainQueue(); } catch (e: unknown) { this.streamingState = "error"; @@ -237,14 +192,10 @@ export class AgentChatModel { [{ text: action === "confirm" ? "confirm" : "deny", files: null }, null, this.currentConversationId, action], ); - for await (const event of submission) { - const msg: AgentMessage = event.data; - this._onStreamData(msg); - } + await this.streamProcessor.processStream(submission, this.currentConversationId); this.streamingState = "idle"; this.pendingThreadId = null; this._persistMessages(); - } catch (e: unknown) { this.streamingState = "error"; this.error = e instanceof Error ? e.message : "Resume failed"; @@ -252,20 +203,27 @@ export class AgentChatModel { } } + // ── Actions — P2 (data + lifecycle) ──────────────────────────── + async loadConversations(reset: boolean = false): Promise { log("AgentChat.Model", "REASON", "Loading conversations", { reset }); try { - if (reset) { - this._conversationsPage = 1; - } - const res = await getAssistantConversations( - this._conversationsPage, 20, false, "" - ); + if (reset) this._conversationsPage = 1; + const res = await getAssistantConversations(this._conversationsPage, 20, false, ""); const items = res.items || []; - // API возвращает conversation_id — нормализуем в id для единообразия this.conversations = reset - ? items.map((item: Record) => ({ id: item.conversation_id ?? item.id ?? "", title: item.title ?? "", updated_at: item.updated_at ?? "", message_count: item.message_count ?? 0 })) - : [...this.conversations, ...items.map((item: Record) => ({ id: item.conversation_id ?? item.id ?? "", title: item.title ?? "", updated_at: item.updated_at ?? "", message_count: item.message_count ?? 0 }))]; + ? items.map((item: Record) => ({ + id: item.conversation_id ?? item.id ?? "", + title: item.title ?? "", + updated_at: item.updated_at ?? "", + message_count: item.message_count ?? 0, + })) + : [...this.conversations, ...items.map((item: Record) => ({ + id: item.conversation_id ?? item.id ?? "", + title: item.title ?? "", + updated_at: item.updated_at ?? "", + message_count: item.message_count ?? 0, + }))]; this._conversationsHasNext = Boolean(res.has_next); this._conversationsPage++; } catch (e: unknown) { @@ -278,11 +236,8 @@ export class AgentChatModel { log("AgentChat.Model", "REASON", "Searching conversations", { query }); try { this._conversationsPage = 1; - const res = await getAssistantConversations( - 1, 20, false, query - ); - const items = res.items || []; - this.conversations = items.map((item: Record) => ({ + const res = await getAssistantConversations(1, 20, false, query); + this.conversations = (res.items || []).map((item: Record) => ({ id: item.conversation_id ?? item.id ?? "", title: item.title ?? "", updated_at: item.updated_at ?? "", @@ -302,31 +257,23 @@ export class AgentChatModel { log("AgentChat.Model", "REASON", "Loading history", { conversationId }); try { const targetId = conversationId ?? this.currentConversationId; - if (!targetId) { - this.isLoadingHistory = false; - return; - } + if (!targetId) { this.isLoadingHistory = false; return; } - // Try localStorage first for offline/fallback - if (this._loadFromLocalStorage(targetId)) { - this.isLoadingHistory = false; - return; - } + if (this._loadFromStorage(targetId)) { this.isLoadingHistory = false; return; } const res = await getAssistantHistory(1, 30, targetId); - const items = res.items || []; - this.messages = items.map((msg: Record) => ({ - id: (msg.message_id as string) ?? msg.id as string ?? "", + this.messages = (res.items || []).map((msg: Record) => ({ + id: (msg.message_id as string) ?? (msg.id as string) ?? "", conversation_id: msg.conversation_id as string ?? "", role: msg.role as string ?? "assistant", text: msg.text as string ?? "", metadata: msg.metadata as StreamMetadata, - toolCalls: msg.tool_calls as ToolCall[] ?? [], + toolCalls: (msg.tool_calls as ToolCall[]) ?? [], created_at: msg.created_at as string ?? "", })); if (res.conversation_id && !this.currentConversationId) { - setAssistantConversationId(res.conversation_id); - this.currentConversationId = res.conversation_id; + setAssistantConversationId(res.conversation_id as string); + this.currentConversationId = res.conversation_id as string; } } catch (e: unknown) { this.error = e instanceof Error ? e.message : "Failed to load history"; @@ -337,23 +284,12 @@ export class AgentChatModel { } async retryConnection(): Promise { - log("AgentChat.Model", "REASON", "Manual reconnect"); - this._reconnectAttempts = 0; - try { - this._client = await Client.connect("/api/agent/gradio"); - this.connectionState = "connected"; - this.streamingState = "idle"; - log("AgentChat.Model", "REFLECT", "Reconnected successfully"); - } catch (e: unknown) { - this.connectionState = "disconnected_permanent"; - log("AgentChat.Model", "EXPLORE", "Reconnect failed", {}, e instanceof Error ? e.message : "Unknown"); - } + return this.connection.retryConnection(); } createConversation(): void { - // Save current conversation before clearing if (this.currentConversationId && this.messages.length > 0) { - this._saveToLocalStorage(); + this._saveToStorage(); } this.messages = []; this.currentConversationId = null; @@ -367,22 +303,16 @@ export class AgentChatModel { log("AgentChat.Model", "REASON", "New conversation created"); } - // ── Actions — P2 ─────────────────────────────────────────────── - async deleteConversation(id: string): Promise { - // Optimistic removal const prevConversations = [...this.conversations]; this.conversations = this.conversations.filter((c) => c.id !== id); - this._clearLocalStorage(id); + this.storage.clear(id); log("AgentChat.Model", "REASON", "Archiving conversation (optimistic)", { id }); try { await deleteAssistantConversation(id); addToast("Диалог архивирован", "success"); - if (this.currentConversationId === id) { - this.createConversation(); - } + if (this.currentConversationId === id) this.createConversation(); } catch (e: unknown) { - // Rollback this.conversations = prevConversations; this.error = e instanceof Error ? e.message : "Failed to archive conversation"; addToast("Не удалось архивировать диалог", "error"); @@ -390,241 +320,37 @@ export class AgentChatModel { } } - // ── Private — Stream metadata handler ────────────────────────── - - /** Iterate Gradio submit() events and update model state. Returns true when stream completes. */ - private async _processStream(submission: ReturnType, convId: string | null): Promise { - for await (const event of submission) { - if (event.type === "heartbeat") continue; - - const raw = event.data; - let parsed: AgentMessage; - - if (Array.isArray(raw)) { - const jsonStr = raw[0]; - if (typeof jsonStr === "string") { - try { - const obj = JSON.parse(jsonStr); - parsed = { id: "", conversation_id: convId ?? "", role: "assistant", text: obj.content ?? jsonStr, metadata: obj.metadata, toolCalls: [], created_at: "" }; - } catch { - parsed = { id: "", conversation_id: "", role: "assistant", text: jsonStr, toolCalls: [], created_at: "" }; - } - } else { - parsed = { id: "", conversation_id: "", role: "assistant", text: jsonStr?.text ?? "", metadata: jsonStr?.metadata, toolCalls: [], created_at: "" }; - } - } else if (typeof raw === "string") { - try { - const obj = JSON.parse(raw); - parsed = { id: "", conversation_id: convId ?? "", role: "assistant", text: obj.content ?? raw, metadata: obj.metadata, toolCalls: [], created_at: "" }; - } catch { - parsed = { id: "", conversation_id: "", role: "assistant", text: raw, toolCalls: [], created_at: "" }; - } - } else { - const obj = raw as Record; - parsed = { id: "", conversation_id: convId ?? "", role: "assistant", text: (obj.content as string) ?? obj.text as string ?? "", metadata: obj.metadata as StreamMetadata, toolCalls: [], created_at: "" }; - } - - this._onStreamData(parsed); - } - return true; - } - - /** Watch Client.stream_status.open — when SSE stream closes via close_stream(), - * return false so Promise.race terminates the iterator via .return(). - * - * stream_status изначально { open: false }. Ждём сначала open=true (SSE открыт), - * потом open=false (SSE закрыт). Без этого шага watcher срабатывает мгновенно - * на исходном false. */ - private async _streamCloseWatcher(maxWaitMs: number): Promise { - const deadline = Date.now() + maxWaitMs; - let wasOpen = false; - while (Date.now() < deadline) { - const ss = (this._client as any)?.stream_status; - if (ss) { - if (!wasOpen && ss.open === true) wasOpen = true; - if (wasOpen && ss.open === false) return false; - } - await new Promise((r) => setTimeout(r, 100)); - } - return false; - } - - private _captureConversationId(meta: StreamMetadata | undefined, convIdFromEvent: string): void { - // Extract conversation_id from stream metadata or event data - // Gradio backend returns thread_id (LangGraph thread) = conversation_id + /** Extract conversation_id from stream metadata or event data */ + _captureConversationId(meta: StreamMetadata | undefined, convIdFromEvent: string): void { const newId = meta?.thread_id || convIdFromEvent || null; if (newId && !this.currentConversationId) { this.currentConversationId = newId; - if (this.currentConversationId) { - setAssistantConversationId(this.currentConversationId); - } - this._saveToLocalStorage(); - } - } - - private _onStreamData(msg: AgentMessage): void { - const meta = msg.metadata; - - // Capture conversation_id if present - this._captureConversationId(meta, msg.conversation_id); - - if (!meta || !meta.type) { - // Plain text token — append to last assistant message - this.partialText += msg.text; - return; - } - - switch (meta.type) { - case "stream_token": { - const token = meta.token ?? ""; - // Dedup: skip if token is already at the end of accumulated text - // (Grady's final "data" event carries the complete text) - if (token && this.partialText.endsWith(token)) break; - this.partialText += token; - this.partialTokens = [...this.partialTokens, token]; - break; - } - - case "tool_start": - this.activeToolCalls = [...this.activeToolCalls, { - tool: meta.tool ?? "unknown", - input: meta.input ?? {}, - status: "executing", - }]; - break; - - case "tool_end": - this.activeToolCalls = this.activeToolCalls.map((tc) => - tc.tool === meta.tool - ? { ...tc, output: meta.output, status: "completed" } - : tc - ); - break; - - case "tool_error": - this.activeToolCalls = this.activeToolCalls.map((tc) => - tc.tool === meta.tool - ? { ...tc, error: meta.error, status: "failed" } - : tc - ); - break; - - case "confirm_required": - this.streamingState = "awaiting_confirmation"; - this.pendingThreadId = meta.thread_id ?? null; - // Primary stream terminates — model enters awaiting_confirmation - // Second submit() will call resumeConfirm() - break; - - case "confirm_resolved": - this.streamingState = meta.result === "confirmed" ? "streaming" : "idle"; - this.pendingThreadId = null; - break; - - case "error": - this.streamingState = "error"; - this.error = meta.detail ?? meta.code ?? "Unknown error"; - break; - - default: - log("AgentChat.Model", "EXPLORE", "Unknown metadata type", { type: meta.type }); - } - } - - // ── Private — localStorage persistence ───────────────────────── - - private readonly STORAGE_PREFIX = "agentchat:"; - private readonly CONVERSATIONS_KEY = "agentchat:conversations"; - - private _saveToLocalStorage(): void { - try { - if (this.currentConversationId) { - const key = `${this.STORAGE_PREFIX}messages:${this.currentConversationId}`; - const data = { - messages: this.messages, - partialText: this.partialText, - conversationId: this.currentConversationId, - }; - localStorage.setItem(key, JSON.stringify(data)); - } - } catch { - // localStorage may be full or unavailable — silently ignore - } - } - - private _loadFromLocalStorage(conversationId: string): boolean { - try { - const key = `${this.STORAGE_PREFIX}messages:${conversationId}`; - const raw = localStorage.getItem(key); - if (!raw) return false; - const data = JSON.parse(raw); - if (data?.messages && Array.isArray(data.messages)) { - this.messages = data.messages; - this.currentConversationId = data.conversationId || conversationId; - return true; - } - return false; - } catch { - return false; - } - } - - private _clearLocalStorage(conversationId: string): void { - try { - const key = `${this.STORAGE_PREFIX}messages:${conversationId}`; - localStorage.removeItem(key); - } catch { - // silent + if (this.currentConversationId) setAssistantConversationId(this.currentConversationId); + this._saveToStorage(); } } /** Save messages after streaming completes */ - private _persistMessages(): void { + _persistMessages(): void { + if (this.currentConversationId) this._saveToStorage(); + } + + // ── Private — storage helpers ────────────────────────────────── + + private _saveToStorage(): void { if (this.currentConversationId) { - this._saveToLocalStorage(); + this.storage.save(this.currentConversationId, this.messages, this.partialText); } } - // ── Private — Connection lifecycle ───────────────────────────── - - private _onDisconnect(): void { - this.connectionState = "disconnected"; - this._reconnectAttempts = 0; - log("AgentChat.Model", "EXPLORE", "Gradio disconnected"); - this._startReconnectLoop(); - } - - private _onReconnect(): void { - this.connectionState = "connected"; - this.streamingState = "idle"; - this._reconnectAttempts = 0; - if (this._reconnectTimer) { - clearInterval(this._reconnectTimer); - this._reconnectTimer = null; + private _loadFromStorage(conversationId: string): boolean { + const data = this.storage.load(conversationId); + if (data) { + this.messages = data.messages; + this.currentConversationId = data.conversationId; + return true; } - log("AgentChat.Model", "REFLECT", "Gradio reconnected"); - } - - private _startReconnectLoop(): void { - if (this._reconnectTimer) return; - this._reconnectTimer = setInterval(async () => { - this._reconnectAttempts++; - if (this._reconnectAttempts > RECONNECT_MAX_ATTEMPTS) { - this.connectionState = "disconnected_permanent"; - if (this._reconnectTimer) { - clearInterval(this._reconnectTimer); - this._reconnectTimer = null; - } - log("AgentChat.Model", "EXPLORE", "Max reconnect attempts reached", { attempts: this._reconnectAttempts }); - return; - } - try { - this._client = await Client.connect("/api/agent/gradio"); - this._onReconnect(); - } catch { - log("AgentChat.Model", "EXPLORE", "Reconnect attempt failed", { attempt: this._reconnectAttempts }); - } - }, RECONNECT_INTERVAL_MS); + return false; } } // #endregion AgentChat.Model diff --git a/frontend/src/lib/models/AgentChatTypes.ts b/frontend/src/lib/models/AgentChatTypes.ts new file mode 100644 index 00000000..7324ebfa --- /dev/null +++ b/frontend/src/lib/models/AgentChatTypes.ts @@ -0,0 +1,61 @@ +// frontend/src/lib/models/AgentChatTypes.ts +// #region AgentChat.Types [C:1] [TYPE Module] [SEMANTICS agent-chat,types] +// @ingroup AgentChat +// @BRIEF Shared type definitions for AgentChat model family. +// @RELATION CALLED_BY -> [AgentChat.Model] + +export type StreamingState = + | "idle" + | "streaming" + | "awaiting_confirmation" + | "error" + | "disconnected" + | "disconnected_permanent"; + +export type ConnectionState = + | "connected" + | "disconnected" + | "disconnected_permanent"; + +export interface StreamMetadata { + type?: "stream_token" | "tool_start" | "tool_end" | "tool_error" + | "confirm_required" | "confirm_resolved" | "error"; + token?: string; + tool?: string; + input?: Record; + output?: Record; + error?: string; + prompt?: string; + thread_id?: string; + result?: "confirmed" | "denied"; + code?: string; + detail?: string; +} + +export interface ToolCall { + tool: string; + input: Record; + output?: Record; + error?: string; + status: "executing" | "completed" | "failed"; +} + +export interface AgentMessage { + id: string; + conversation_id: string; + role: "user" | "assistant" | "system" | "tool"; + text: string; + metadata?: StreamMetadata; + state?: string; + task_id?: string; + toolCalls: ToolCall[]; + created_at: string; +} + +export interface Conversation { + id: string; + title: string; + updated_at: string; + message_count: number; +} +// #endregion AgentChat.Types diff --git a/frontend/src/lib/models/BranchModel.svelte.ts b/frontend/src/lib/models/BranchModel.svelte.ts index dd238090..4912384a 100644 --- a/frontend/src/lib/models/BranchModel.svelte.ts +++ b/frontend/src/lib/models/BranchModel.svelte.ts @@ -1,6 +1,6 @@ // frontend/src/lib/models/BranchModel.svelte.ts -// #region BranchModel [C:4] [TYPE Model] [SEMANTICS git,branch,checkout,create,screen-model] -// @ingroup Models +// #region Git.BranchModel [C:4] [TYPE Model] [SEMANTICS git,branch,checkout,create,screen-model] +// @ingroup Git // @BRIEF State model for branch selector — list, checkout, and create Git branches. // @INVARIANT Loading guards checkout and create operations — only one operation runs at a time. // @STATE idle — Branch list loaded, dropdown enabled. @@ -202,4 +202,4 @@ export class BranchModel { } } } -// #endregion BranchModel +// #endregion Git.BranchModel diff --git a/frontend/src/lib/models/CommitModel.svelte.ts b/frontend/src/lib/models/CommitModel.svelte.ts index f48e2cee..79d0e315 100644 --- a/frontend/src/lib/models/CommitModel.svelte.ts +++ b/frontend/src/lib/models/CommitModel.svelte.ts @@ -1,6 +1,6 @@ // frontend/src/lib/models/CommitModel.svelte.ts -// #region CommitModel [C:4] [TYPE Model] [SEMANTICS git,commit,message,diff,screen-model] -// @ingroup Models +// #region Git.CommitModel [C:4] [TYPE Model] [SEMANTICS git,commit,message,diff,screen-model] +// @ingroup Git // @BRIEF State model for commit modal — message generation, status loading, commit with optional push. // @INVARIANT commit and generateMessage are mutually exclusive — only one runs at a time. // @STATE idle — No operation in progress, form ready. @@ -163,4 +163,4 @@ export class CommitModel { } } } -// #endregion CommitModel +// #endregion Git.CommitModel diff --git a/frontend/src/lib/models/DashboardDetailModel.svelte.ts b/frontend/src/lib/models/DashboardDetailModel.svelte.ts index adb3ae29..e91b3f54 100644 --- a/frontend/src/lib/models/DashboardDetailModel.svelte.ts +++ b/frontend/src/lib/models/DashboardDetailModel.svelte.ts @@ -1,5 +1,5 @@ -// #region DashboardDetailModel [C:4] [TYPE Model] [SEMANTICS dashboard,detail,git,task-history,thumbnail,validation,model] -// @ingroup Models +// #region Dashboards.DetailModel [C:4] [TYPE Model] [SEMANTICS dashboard,detail,git,task-history,thumbnail,validation,model] +// @ingroup Dashboards // @BRIEF Screen model for dashboard detail page — loads metadata, thumbnail, task history, validation history, and manages Git status. // @INVARIANT loadDashboardPage() must only fire when dashboardRef and envId are both truthy. // @INVARIANT Thumbnail blob URLs are released on reassignment or model destruction. @@ -310,4 +310,4 @@ export class DashboardDetailModel { return `${parsed.toLocaleDateString()} ${parsed.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`; } } -// #endregion DashboardDetailModel +// #endregion Dashboards.DetailModel diff --git a/frontend/src/lib/models/DashboardHubModel.svelte.ts b/frontend/src/lib/models/DashboardHubModel.svelte.ts index 0fb46ef7..af8f84ea 100644 --- a/frontend/src/lib/models/DashboardHubModel.svelte.ts +++ b/frontend/src/lib/models/DashboardHubModel.svelte.ts @@ -1,97 +1,66 @@ -// #region DashboardHubModel [C:4] [TYPE Model] [SEMANTICS dashboard, hub, grid, filter, pagination, git, screen-model] -// @ingroup Models -// @BRIEF State model for the dashboard hub page — all atoms, invariants, and actions in one class. -// @REJECTED Inline $state + handler functions in +page.svelte was rejected because it scattered 35+ atoms and 40+ functions across 2400+ lines, making testing impossible without DOM render and violating INV_7 (>400 lines, CC >10). +// #region Dashboards.HubModel [C:4] [TYPE Model] [SEMANTICS dashboard, hub, grid, coordinator, screen-model] +// @ingroup Dashboards +// @BRIEF Coordinator model for the dashboard hub page — delegates to 3 sub-models (Filters, Selection, GitActions). // @INVARIANT Changing filter (column, search, multi-select) resets pagination to page 1. // @INVARIANT Changing source environment resets all dashboard, filter, selection, and git state. // @INVARIANT Selection never contains IDs not present in allDashboards (cleaned on load). // @INVARIANT dryRunResult is always null when selectedDashboardIds changes (handled by $effect in page). -// @INVARIANT Model is 592 lines, >40 public methods. Ref: ADR-0010 (Model Decomposition Gate). -// Split targets: DashboardFiltersModel / DashboardSelectionModel / DashboardGitActionsModel. // @STATE loading — API call in progress. // @STATE loaded — Dashboard grid with status badges rendered. // @STATE empty — Query returned zero results. // @STATE error — API call failed, error message available. // @ACTION loadDashboards() — Fetches dashboards for current env/page/search/filters. -// @ACTION setPage(n) — Navigates to page n. -// @ACTION setSort(column) — Toggles sort on column. -// @ACTION toggleFilterValue(col,val,chk) — Adds/removes filter value. -// @ACTION clearColumnFilter(col) — Resets one column filter. -// @ACTION handleGitInit(dashboard) — Initializes git repo. -// @ACTION handleGitSync(dashboard) — Syncs git repo. -// @ACTION handleGitCommit(dashboard) — Commits changes. -// @ACTION handleGitPull(dashboard) — Pulls from remote. -// @ACTION handleGitPush(dashboard) — Pushes to remote. -// @ACTION handleBulkBackup() — Executes bulk backup. +// @ACTION handleAction(dash, action) — Opens migrate or backup modal for a dashboard. +// @ACTION handleBulkBackup() — Executes bulk backup. +// @ACTION handleTaskStatusClick(dash) — Opens task drawer for a dashboard's last task. +// @ACTION navigateToDashboardDetail(dash) — Navigates to dashboard detail page. +// @ACTION toggleValidationPopover(id, ev) — Opens/closes validation status popover. +// @ACTION applyGridTransforms() — Re-applies sort/filter + selection + git hydration. +// @ACTION loadDashboardSearchOptions() — Fetches searchable dashboard options. +// @ACTION fetchValidationStatuses() — Fetches batch validation statuses. +// @ACTION refreshDashboardGitState(id) — Refreshes a single dashboard's git state. +// @ACTION hydrateVisibleGitStatusesBatch() — Fetches git statuses for pending dashboards. // @RELATION DEPENDS_ON -> [ApiModule] -// @RELATION CALLS -> [gitService] +// @RELATION CALLS -> [Dashboards.FiltersModel] +// @RELATION CALLS -> [Dashboards.SelectionModel] +// @RELATION CALLS -> [Dashboards.GitActionsModel] // @RELATION BINDS_TO -> [environmentContextStore] // @RELATION CALLS -> [CotLogger] -import { SvelteSet, SvelteDate } from "svelte/reactivity"; +import { SvelteSet } from "svelte/reactivity"; import { goto } from "$app/navigation"; import { ROUTES } from "$lib/routes.js"; import { log } from "$lib/cot-logger"; import { api } from "$lib/api.js"; -import { gitService } from "../../services/gitService.js"; import { addToast } from "$lib/toasts.svelte.js"; import { openDrawerForTask, openDrawerForTaskIfPreferred } from "$lib/stores/taskDrawer.svelte.js"; import { MigrationModel } from "./MigrationModel.svelte.ts"; import { t } from "$lib/i18n/index.svelte.js"; import { normalizeTaskStatus, normalizeValidationStatus, normalizeOwners, formatDate } from "../../routes/dashboards/dashboard-helpers.js"; +import { DashboardsFiltersModel } from "./Dashboards.FiltersModel.svelte.ts"; +import { DashboardsSelectionModel } from "./Dashboards.SelectionModel.svelte.ts"; +import { DashboardsGitActionsModel } from "./Dashboards.GitActionsModel.svelte.ts"; -// ═══ Types ═══════════════════════════════════════════════════════ - -type SortDir = "asc" | "desc"; -type FilterColumn = "title" | "git_status" | "validation_status" | "changed_on" | "actor"; -type ColumnFilters = Record>; -type ColumnFilterSearch = Record; - -interface DashboardRow { - id: string; title: string; slug: string; - changedOn: string | null; changedOnLabel: string; - owners: string[]; actorLabel: string; - git: { status: string; branch: string | null; hasRepo: boolean | null; hasChangesForCommit: boolean }; - lastTask: { status: string | null; validationStatus: string; id: string } | null; - actions: string[]; -} - -interface GitState { status: string; branch: string | null; hasRepo: boolean; hasChangesForCommit: boolean } +// ── Type re-exports (for importing convenience) ────────────────── +export type { DashboardRow, FilterColumn, SortDir, ColumnFilters } from "./Dashboards.FiltersModel.svelte.ts"; +export type { GitState } from "./Dashboards.GitActionsModel.svelte.ts"; // ═══ Model ═══════════════════════════════════════════════════════ export class DashboardHubModel { + // ── Sub-models ───────────────────────────────────────────────── + readonly filters = new DashboardsFiltersModel(); + readonly selection = new DashboardsSelectionModel(); + readonly gitActions = new DashboardsGitActionsModel(); + // ── Core state ──────────────────────────────────────────────── selectedEnv: string | null = $state(null); - allDashboards: DashboardRow[] = $state([]); - dashboards: DashboardRow[] = $state([]); - filteredDashboards: DashboardRow[] = $state([]); + allDashboards: import("./Dashboards.FiltersModel.svelte.ts").DashboardRow[] = $state([]); + dashboards: import("./Dashboards.FiltersModel.svelte.ts").DashboardRow[] = $state([]); + filteredDashboards: import("./Dashboards.FiltersModel.svelte.ts").DashboardRow[] = $state([]); isLoading: boolean = $state(true); error: string | null = $state(null); - // ── Pagination ──────────────────────────────────────────────── - currentPage: number = $state(1); - pageSize: number = $state(10); - totalPages: number = $state(1); - total: number = $state(0); - - // ── Selection ───────────────────────────────────────────────── - selectedIds: Set = $state(new SvelteSet()); - isAllSelected: boolean = $state(false); - isAllVisibleSelected: boolean = $state(false); - - // ── Search ──────────────────────────────────────────────────── - searchQuery: string = $state(""); - filterDashboardIds: string[] = $state([]); - searchableDashboardOptions: Array<{id: string; name: string; subtitle: string; hint: string}> = $state([]); - searchableDashboardLoading: boolean = $state(false); - sortColumn: FilterColumn = $state("title"); - sortDirection: SortDir = $state("asc"); - openFilterColumn: FilterColumn | null = $state(null); - filterDropdownPosition: { left: number; top: number } = $state({ left: 0, top: 0 }); - columnFilterSearch: ColumnFilterSearch = $state({ title: "", git_status: "", validation_status: "", changed_on: "", actor: "" }); - columnFilters: ColumnFilters = $state({ title: new SvelteSet(), git_status: new SvelteSet(), validation_status: new SvelteSet(), changed_on: new SvelteSet(), actor: new SvelteSet() }); - // ── Validation popover ──────────────────────────────────────── validationStatuses: Record = $state({}); openValidationPopover: string | null = $state(null); @@ -106,22 +75,243 @@ export class DashboardHubModel { isEditingMappings: boolean = $state(false); readonly bulkMigrateModel = new MigrationModel(); - // ── Git ─────────────────────────────────────────────────────── - openActionDropdown: string | null = $state(null); - gitBusyIds: Set = $state(new SvelteSet()); - gitResolvedIds: Set = $state(new SvelteSet()); - gitLoadingIds: Set = $state(new SvelteSet()); - cachedGitConfigs: any[] = $state([]); + // ── Server metadata ─────────────────────────────────────────── dashboardsLoadSeq: number = $state(0); lastLoadedEnvId: string | null = $state(null); serverTotal: number = $state(0); serverTotalPages: number = $state(1); - profileFilterOverrideShowAll: boolean = $state(false); - effectiveProfileFilter: any = $state(null); - // ── Derived (store-dependent — handled in page due to legacy store) ────── + constructor() { + // Wire up git state callback: when git state changes on any dashboard, + // propagate to the parent's dashboard arrays for reactivity. + this.gitActions.onGitStateUpdate = (id: string, nextGit: any) => { + const merge = (col: any[]) => col.map((d: any) => d.id === id ? { ...d, git: { ...d.git, ...nextGit } } : d); + this.allDashboards = merge(this.allDashboards) as any; + this.filteredDashboards = merge(this.filteredDashboards) as any; + this.dashboards = merge(this.dashboards) as any; + }; + } - // ═══ Data loading ══════════════════════════════════════════════ + // ══ Delegated atoms (backward-compat access) ══════════════════ + + // ── Filters model delegation ────────────────────────────────── + get currentPage() { return this.filters.currentPage; } + set currentPage(v) { this.filters.currentPage = v; } + get pageSize() { return this.filters.pageSize; } + set pageSize(v) { this.filters.pageSize = v; } + get totalPages() { return this.filters.totalPages; } + set totalPages(v) { this.filters.totalPages = v; } + get total() { return this.filters.total; } + set total(v) { this.filters.total = v; } + get searchQuery() { return this.filters.searchQuery; } + set searchQuery(v) { this.filters.searchQuery = v; } + get filterDashboardIds() { return this.filters.filterDashboardIds; } + set filterDashboardIds(v) { this.filters.filterDashboardIds = v; } + get searchableDashboardOptions() { return this.filters.searchableDashboardOptions; } + set searchableDashboardOptions(v) { this.filters.searchableDashboardOptions = v; } + get searchableDashboardLoading() { return this.filters.searchableDashboardLoading; } + set searchableDashboardLoading(v) { this.filters.searchableDashboardLoading = v; } + get sortColumn() { return this.filters.sortColumn; } + set sortColumn(v) { this.filters.sortColumn = v; } + get sortDirection() { return this.filters.sortDirection; } + set sortDirection(v) { this.filters.sortDirection = v; } + get openFilterColumn() { return this.filters.openFilterColumn; } + set openFilterColumn(v) { this.filters.openFilterColumn = v; } + get filterDropdownPosition() { return this.filters.filterDropdownPosition; } + set filterDropdownPosition(v) { this.filters.filterDropdownPosition = v; } + get columnFilterSearch() { return this.filters.columnFilterSearch; } + set columnFilterSearch(v) { this.filters.columnFilterSearch = v; } + get columnFilters() { return this.filters.columnFilters; } + set columnFilters(v) { this.filters.columnFilters = v; } + get profileFilterOverrideShowAll() { return this.filters.profileFilterOverrideShowAll; } + set profileFilterOverrideShowAll(v) { this.filters.profileFilterOverrideShowAll = v; } + get effectiveProfileFilter() { return this.filters.effectiveProfileFilter; } + set effectiveProfileFilter(v) { this.filters.effectiveProfileFilter = v; } + + // ── Selection model delegation ──────────────────────────────── + get selectedIds() { return this.selection.selectedIds; } + set selectedIds(v) { this.selection.selectedIds = v; } + get isAllSelected() { return this.selection.isAllSelected; } + set isAllSelected(v) { this.selection.isAllSelected = v; } + get isAllVisibleSelected() { return this.selection.isAllVisibleSelected; } + set isAllVisibleSelected(v) { this.selection.isAllVisibleSelected = v; } + + // ── Git actions model delegation ────────────────────────────── + get openActionDropdown() { return this.gitActions.openActionDropdown; } + set openActionDropdown(v) { this.gitActions.openActionDropdown = v; } + get gitBusyIds() { return this.gitActions.gitBusyIds; } + set gitBusyIds(v) { this.gitActions.gitBusyIds = v; } + get gitResolvedIds() { return this.gitActions.gitResolvedIds; } + set gitResolvedIds(v) { this.gitActions.gitResolvedIds = v; } + get gitLoadingIds() { return this.gitActions.gitLoadingIds; } + set gitLoadingIds(v) { this.gitActions.gitLoadingIds = v; } + get cachedGitConfigs() { return this.gitActions.cachedGitConfigs; } + set cachedGitConfigs(v) { this.gitActions.cachedGitConfigs = v; } + + // ══ Method delegation (backward-compat) ═══════════════════════ + + // ── From FiltersModel ───────────────────────────────────────── + + setPage(page: number): void { + this.filters.setPage(page); + void this.loadDashboards(); + } + + setPageSize(event: Event): void { + this.filters.setPageSize(event); + void this.loadDashboards(); + } + + handleTemporaryShowAll(): void { + this.filters.handleTemporaryShowAll(); + void this.loadDashboards(); + } + + handleRestoreProfileFilter(): void { + this.filters.handleRestoreProfileFilter(); + void this.loadDashboards(); + } + + getValidationLabelForCell(dashboard: any): string { + return this.filters.getValidationLabelForCell(dashboard, this.validationStatuses); + } + + getColumnCellValue(dashboard: any, column: any): string { + return this.filters.getColumnCellValue(dashboard, column, this.validationStatuses); + } + + getFilterOptions(column: any): string[] { + return this.filters.getFilterOptions(this.allDashboards as any, this.validationStatuses); + } + + getVisibleFilterOptions(column: any): string[] { + return this.filters.getVisibleFilterOptions(column, this.allDashboards as any, this.validationStatuses); + } + + toggleFilterDropdown(column: any, event: Event | undefined, panelWidth?: number): void { + this.filters.toggleFilterDropdown(column, event, panelWidth); + } + + toggleFilterValue(column: any, value: string, checked: boolean): void { + this.filters.toggleFilterValue(column, value, checked); + void this.loadDashboards(); + } + + clearColumnFilter(column: any): void { + this.filters.clearColumnFilter(column); + void this.loadDashboards(); + } + + selectAllColumnFilterValues(column: any): void { + this.filters.selectAllColumnFilterValues(column, this.allDashboards as any, this.validationStatuses); + void this.loadDashboards(); + } + + updateColumnFilterSearch(column: any, value: string): void { + this.filters.updateColumnFilterSearch(column, value); + } + + hasColumnFilter(column: any): boolean { + return this.filters.hasColumnFilter(column); + } + + getSortValue(dashboard: any, column: any): string | number { + const gitSummary = this.gitActions.getGitSummaryLabel(dashboard); + return this.filters.getSortValue(dashboard, column, gitSummary); + } + + handleSort(column: any): void { + this.filters.handleSort(column); + this.applyGridTransforms(); + } + + // ── From SelectionModel ─────────────────────────────────────── + + updateSelectionState(): void { + this.selection.updateSelectionState(this.filteredDashboards as any, this.dashboards as any); + } + + replaceSelectedIds(next: Set): void { + // Goes through parent's getter/setter for backward compat (test overrides selectedIds with defineProperty) + this.selectedIds = next; + this.updateSelectionState(); + } + clearSelectedIds(): void { this.selection.clearSelectedIds(); } + + handleCheckboxChange(dashboard: any, event: Event): void { + this.selection.handleCheckboxChange(dashboard, event); + } + + handleSelectAll(): void { + this.selection.handleSelectAll(this.filteredDashboards as any); + } + + handleSelectVisible(): void { + this.selection.handleSelectVisible(this.dashboards as any); + } + + // ── From GitActionsModel ────────────────────────────────────── + + getGitSummaryLabel(dashboard: any): string { + return this.gitActions.getGitSummaryLabel(dashboard); + } + + isGitBusy(dashboardId: string): boolean { return this.gitActions.isGitBusy(dashboardId); } + + setGitBusy(dashboardId: string, busy: boolean): void { + this.gitActions.setGitBusy(dashboardId, busy); + } + + async ensureGitConfigs(): Promise { return this.gitActions.ensureGitConfigs(); } + + /** Backward-compat: applies git state update to all dashboard arrays. */ + updateDashboardGitState(dashboardId: string, nextGit: any): void { + this.gitActions.onGitStateUpdate?.(dashboardId, nextGit); + } + + normalizeRepositoryStatusPayload(status: any): any { + return this.gitActions.normalizeRepositoryStatusPayload(status); + } + + async fetchDashboardGitStatusesBatch(dashboardIds: string[], force = false): Promise { + await this.gitActions.fetchDashboardGitStatusesBatch(dashboardIds, force); + } + + async refreshDashboardGitState(dashboardId: string, force = false): Promise { + await this.fetchDashboardGitStatusesBatch([dashboardId], force); + } + + async hydrateVisibleGitStatusesBatch(): Promise { + const pending = this.dashboards.filter((d: any) => d.git?.hasRepo === null && !this.gitActions.gitResolvedIds.has(d.id) && !this.gitActions.gitLoadingIds.has(d.id)).map((d: any) => d.id); + if (pending.length > 0) await this.gitActions.fetchDashboardGitStatusesBatch(pending, false); + } + + async handleGitInit(dashboard: any): Promise { + await this.gitActions.handleGitInit(dashboard); + } + + async handleGitSync(dashboard: any): Promise { + await this.gitActions.handleGitSync(dashboard, this.selectedEnv); + } + + async handleGitCommit(dashboard: any): Promise { + await this.gitActions.handleGitCommit(dashboard, this.selectedEnv); + } + + async handleGitPull(dashboard: any): Promise { + await this.gitActions.handleGitPull(dashboard, this.selectedEnv); + } + + async handleGitPush(dashboard: any): Promise { + await this.gitActions.handleGitPush(dashboard, this.selectedEnv); + } + + toggleActionDropdown(id: string | null, event: Event): void { + this.gitActions.toggleActionDropdown(id, event); + } + closeActionDropdown(): void { this.gitActions.closeActionDropdown(); } + + // ══ Data loading (parent-owned) ═══════════════════════════════ async loadDashboards(): Promise { if (!this.selectedEnv) return; @@ -149,8 +339,8 @@ export class DashboardHubModel { this.serverTotalPages = Math.max(1, Number(firstResponse?.total_pages || 1)); this.effectiveProfileFilter = firstResponse?.effective_profile_filter || null; this.profileFilterOverrideShowAll = Boolean(firstResponse?.effective_profile_filter?.override_show_all); - this.gitResolvedIds = new SvelteSet(); - this.gitLoadingIds = new SvelteSet(); + this.gitActions.gitResolvedIds = new SvelteSet(); + this.gitActions.gitLoadingIds = new SvelteSet(); this.allDashboards = rawDashboards.map((d: any) => { const owners = normalizeOwners(d.owners); @@ -173,14 +363,13 @@ export class DashboardHubModel { id: d.last_task.task_id, } : null, actions: ["migrate", "backup"], - }; + } as any; }); this.selectedIds = new SvelteSet( - Array.from(this.selectedIds).filter(id => this.allDashboards.some(d => d.id === id)) + Array.from(this.selectedIds).filter(id => this.allDashboards.some((d: any) => d.id === id)) ); this.applyGridTransforms(); - this.updateSelectionState(); } catch (err: any) { if (requestSeq !== this.dashboardsLoadSeq) return; this.error = err.message || t.dashboard?.load_failed; @@ -210,7 +399,7 @@ export class DashboardHubModel { if (!this.selectedEnv || this.dashboards.length === 0) return; this.validationStatusLoading = true; try { - const ids = this.dashboards.map(d => d.id).filter(Boolean); + const ids = this.dashboards.map((d: any) => d.id).filter(Boolean); if (ids.length === 0) return; const result = await api.getValidationStatusBatch(this.selectedEnv, ids.join(",")); if (result && typeof result === "object") this.validationStatuses = result; @@ -218,297 +407,7 @@ export class DashboardHubModel { finally { this.validationStatusLoading = false; } } - // ═══ Pagination ════════════════════════════════════════════════ - - setPage(page: number): void { - if (page === this.currentPage) return; - this.currentPage = page; - void this.loadDashboards(); - } - - setPageSize(event: Event): void { - this.pageSize = parseInt((event.target as HTMLSelectElement).value); - this.currentPage = 1; - void this.loadDashboards(); - } - - // ═══ Profile filter ════════════════════════════════════════════ - - handleTemporaryShowAll(): void { - if (this.profileFilterOverrideShowAll) return; - this.profileFilterOverrideShowAll = true; - this.currentPage = 1; - void this.loadDashboards(); - } - - handleRestoreProfileFilter(): void { - if (!this.profileFilterOverrideShowAll) return; - this.profileFilterOverrideShowAll = false; - this.currentPage = 1; - void this.loadDashboards(); - } - - // ═══ Selection ═════════════════════════════════════════════════ - - updateSelectionState(): void { - this.isAllSelected = this.selectedIds.size === this.filteredDashboards.length && this.filteredDashboards.length > 0; - this.isAllVisibleSelected = this.selectedIds.size === this.dashboards.length && this.dashboards.length > 0; - } - - replaceSelectedIds(next: Set): void { this.selectedIds = next; this.updateSelectionState(); } - clearSelectedIds(): void { this.replaceSelectedIds(new SvelteSet()); } - - handleCheckboxChange(dashboard: DashboardRow, event: Event): void { - const next = new SvelteSet(this.selectedIds); - (event.target as HTMLInputElement).checked ? next.add(dashboard.id) : next.delete(dashboard.id); - this.replaceSelectedIds(next); - } - - handleSelectAll(): void { - if (this.isAllSelected) { this.clearSelectedIds(); return; } - const next = new SvelteSet(this.selectedIds); - this.filteredDashboards.forEach(d => next.add(d.id)); - this.replaceSelectedIds(next); - } - - handleSelectVisible(): void { - const next = new SvelteSet(this.selectedIds); - if (this.isAllVisibleSelected) { this.dashboards.forEach(d => next.delete(d.id)); } - else { this.dashboards.forEach(d => next.add(d.id)); } - this.replaceSelectedIds(next); - } - - // ═══ Column filters ═══════════════════════════════════════════ - - getValidationLabelForCell(dashboard: DashboardRow): string { - const vs = this.validationStatuses[dashboard.id]; - return (!vs || !vs.status) ? "unknown" : String(vs.status).toLowerCase(); - } - - getColumnCellValue(dashboard: DashboardRow, column: FilterColumn): string { - if (column === "title") return dashboard.title || "-"; - if (column === "git_status") return String(dashboard.git?.status || "pending").toLowerCase(); - if (column === "validation_status") return this.getValidationLabelForCell(dashboard); - if (column === "changed_on") return dashboard.changedOn ? String(dashboard.changedOn).slice(0, 10) : "-"; - if (column === "actor") return dashboard.actorLabel || "-"; - return "-"; - } - - getFilterOptions(column: FilterColumn): string[] { - return Array.from(new SvelteSet( - this.allDashboards.map(d => this.getColumnCellValue(d, column)).filter(Boolean) - )).sort((a, b) => a.localeCompare(b, "ru")); - } - - getVisibleFilterOptions(column: FilterColumn): string[] { - const searchText = (this.columnFilterSearch[column] || "").toLowerCase(); - return this.getFilterOptions(column).filter(v => v.toLowerCase().includes(searchText)); - } - - toggleFilterDropdown(column: FilterColumn, event: Event | undefined, panelWidth = 256): void { - event?.stopPropagation(); - if (this.openFilterColumn === column) { this.openFilterColumn = null; return; } - const trigger = (event as any)?.currentTarget; - if (trigger?.getBoundingClientRect) { - const rect = trigger.getBoundingClientRect(); - const vw = typeof window !== "undefined" ? window.innerWidth : 1920; - this.filterDropdownPosition = { left: Math.max(8, Math.min(rect.left, vw - panelWidth - 8)), top: rect.bottom + 8 }; - } - this.openFilterColumn = column; - } - - toggleFilterValue(column: FilterColumn, value: string, checked: boolean): void { - const next = new SvelteSet(this.columnFilters[column]); - checked ? next.add(value) : next.delete(value); - this.columnFilters = { ...this.columnFilters, [column]: next }; - this.currentPage = 1; - void this.loadDashboards(); - } - - clearColumnFilter(column: FilterColumn): void { - this.columnFilters = { ...this.columnFilters, [column]: new SvelteSet() }; - this.currentPage = 1; - void this.loadDashboards(); - } - - selectAllColumnFilterValues(column: FilterColumn): void { - this.columnFilters = { ...this.columnFilters, [column]: new SvelteSet(this.getVisibleFilterOptions(column)) }; - this.currentPage = 1; - void this.loadDashboards(); - } - - updateColumnFilterSearch(column: FilterColumn, value: string): void { - this.columnFilterSearch = { ...this.columnFilterSearch, [column]: value }; - } - - hasColumnFilter(column: FilterColumn): boolean { return this.columnFilters[column]?.size > 0; } - - // ═══ Sort ══════════════════════════════════════════════════════ - - getSortValue(dashboard: DashboardRow, column: FilterColumn): string | number { - if (column === "title") return (dashboard.title || "").toLowerCase(); - if (column === "git_status") return (this.getGitSummaryLabel(dashboard) || "").toLowerCase(); - if (column === "validation_status") return (this.getValidationLabelForCell(dashboard) || "").toLowerCase(); - if (column === "changed_on") return dashboard.changedOn ? new SvelteDate(dashboard.changedOn).getTime() : 0; - if (column === "actor") return (dashboard.actorLabel || "").toLowerCase(); - return ""; - } - - handleSort(column: FilterColumn): void { - if (this.sortColumn === column) { - this.sortDirection = this.sortDirection === "asc" ? "desc" : "asc"; - } else { - this.sortColumn = column; - this.sortDirection = "asc"; - } - this.currentPage = 1; - this.applyGridTransforms(); - } - - // ═══ Grid transforms ═══════════════════════════════════════════ - - applyGridTransforms(): void { - let next = this.allDashboards; - if (this.filterDashboardIds.length > 0) { - next = next.filter(d => this.filterDashboardIds.includes(d.id)); - } - next = [...next].sort((a, b) => { - const aV = this.getSortValue(a, this.sortColumn); - const bV = this.getSortValue(b, this.sortColumn); - if (aV < bV) return this.sortDirection === "asc" ? -1 : 1; - if (aV > bV) return this.sortDirection === "asc" ? 1 : -1; - return 0; - }); - this.filteredDashboards = next; - this.dashboards = this.filteredDashboards; - this.total = this.serverTotal; - this.totalPages = this.serverTotalPages; - this.updateSelectionState(); - void this.hydrateVisibleGitStatusesBatch(); - void this.fetchValidationStatuses(); - } - - // ═══ Git helpers ═══════════════════════════════════════════════ - - getGitSummaryLabel(dashboard: DashboardRow): string { - if (dashboard.git?.hasRepo === null) return t.common?.loading || "Loading"; - if (!dashboard.git?.hasRepo) return t.dashboard?.status_no_repo || "No Repo"; - return dashboard.git?.hasChangesForCommit ? t.dashboard?.status_changes || "Diff" : t.dashboard?.status_no_changes || "Synced"; - } - - isGitBusy(dashboardId: string): boolean { return this.gitBusyIds.has(dashboardId); } - - setGitBusy(dashboardId: string, busy: boolean): void { - if (busy) this.gitBusyIds.add(dashboardId); else this.gitBusyIds.delete(dashboardId); - this.gitBusyIds = new SvelteSet(this.gitBusyIds); - } - - async ensureGitConfigs(): Promise { - if (this.cachedGitConfigs.length > 0) return this.cachedGitConfigs; - this.cachedGitConfigs = await gitService.getConfigs(); - return this.cachedGitConfigs; - } - - updateDashboardGitState(dashboardId: string, nextGit: Partial): void { - const merge = (col: DashboardRow[]) => col.map(d => d.id === dashboardId ? { ...d, git: { ...d.git, ...nextGit } } : d); - this.allDashboards = merge(this.allDashboards); - this.filteredDashboards = merge(this.filteredDashboards); - this.dashboards = merge(this.dashboards); - } - - normalizeRepositoryStatusPayload(status: any): GitState { - const syncState = String(status?.sync_state || "").toUpperCase(); - const syncStatus = String(status?.sync_status || "").toUpperCase(); - const explicitHasRepo = status?.has_repo; - const hasRepo = explicitHasRepo === true ? true : explicitHasRepo === false ? false : !(syncState === "NO_REPO" || syncStatus === "NO_REPO"); - if (!hasRepo) return { status: "no_repo", branch: null, hasRepo: false, hasChangesForCommit: false }; - const hasChanges = syncState === "CHANGES" || syncStatus === "DIFF" || Boolean(status?.is_dirty) || - (status?.untracked_files?.length || 0) > 0 || (status?.modified_files?.length || 0) > 0 || (status?.staged_files?.length || 0) > 0; - return { status: hasChanges ? "diff" : "ok", branch: status?.current_branch || status?.branch || null, hasRepo: true, hasChangesForCommit: hasChanges }; - } - - async fetchDashboardGitStatusesBatch(dashboardIds: string[], force = false): Promise { - const uniqueIds = Array.from(new SvelteSet(dashboardIds)); - if (uniqueIds.length === 0) return; - if (force) { uniqueIds.forEach(id => this.gitResolvedIds.delete(id)); this.gitResolvedIds = new SvelteSet(this.gitResolvedIds); } - const pendingIds = uniqueIds.filter(id => !this.gitResolvedIds.has(id) && !this.gitLoadingIds.has(id)); - if (pendingIds.length === 0) return; - pendingIds.forEach(id => this.gitLoadingIds.add(id)); - this.gitLoadingIds = new SvelteSet(this.gitLoadingIds); - try { - const batchResult = await gitService.getStatusesBatch(pendingIds); - const statuses = batchResult?.statuses || {}; - pendingIds.forEach(id => { - const st = statuses[id] ?? statuses[String(id)]; - this.updateDashboardGitState(id, st ? this.normalizeRepositoryStatusPayload(st) : { status: "no_repo", branch: null, hasRepo: false, hasChangesForCommit: false }); - }); - } catch { - pendingIds.forEach(id => this.updateDashboardGitState(id, { status: "no_repo", branch: null, hasRepo: false, hasChangesForCommit: false })); - } finally { - pendingIds.forEach(id => { this.gitLoadingIds.delete(id); this.gitResolvedIds.add(id); }); - this.gitLoadingIds = new SvelteSet(this.gitLoadingIds); - this.gitResolvedIds = new SvelteSet(this.gitResolvedIds); - } - } - - async refreshDashboardGitState(dashboardId: string, force = false): Promise { await this.fetchDashboardGitStatusesBatch([dashboardId], force); } - - async hydrateVisibleGitStatusesBatch(): Promise { - const pending = this.dashboards.filter(d => d.git?.hasRepo === null && !this.gitResolvedIds.has(d.id) && !this.gitLoadingIds.has(d.id)).map(d => d.id); - if (pending.length > 0) await this.fetchDashboardGitStatusesBatch(pending, false); - } - - async handleGitInit(dashboard: DashboardRow): Promise { - this.setGitBusy(dashboard.id, true); - try { - const configs = await this.ensureGitConfigs(); - if (!configs.length) { addToast(t.git?.no_servers_configured || "No Git config found", "error"); return; } - const config = configs[0]; - const defaultRemote = config?.default_repository ? `${String(config.url || "").replace(/\/$/, "")}/${config.default_repository}.git` : ""; - const remoteUrl = prompt(t.git?.remote_url || "Remote URL", defaultRemote); - if (!remoteUrl) return; - await gitService.initRepository(dashboard.id, config.id, remoteUrl.trim()); - addToast(t.git?.init_success || "Repository initialized", "success"); - await this.refreshDashboardGitState(dashboard.id, true); - } catch (err: any) { addToast(err?.message || "Git init failed", "error"); } - finally { this.setGitBusy(dashboard.id, false); } - } - - async handleGitSync(dashboard: DashboardRow): Promise { - this.setGitBusy(dashboard.id, true); - try { await gitService.sync(dashboard.id, this.selectedEnv || null); addToast(t.git?.sync_success || "Synced", "success"); await this.refreshDashboardGitState(dashboard.id, true); } - catch (err: any) { addToast(err?.message || "Git sync failed", "error"); } - finally { this.setGitBusy(dashboard.id, false); } - } - - async handleGitCommit(dashboard: DashboardRow): Promise { - if (!dashboard.git?.hasRepo) { addToast(t.git?.not_linked || "Repository not linked", "error"); return; } - if (!dashboard.git?.hasChangesForCommit) { addToast(t.git?.nothing_to_commit || "No changes to commit", "error"); return; } - const message = prompt(t.git?.commit_message || "Commit message", `Update dashboard ${dashboard.title}`); - if (!message?.trim()) return; - this.setGitBusy(dashboard.id, true); - try { await gitService.commit(dashboard.slug || dashboard.id, message.trim()); addToast(t.git?.commit_success || "Committed", "success"); await this.refreshDashboardGitState(dashboard.id, true); } - catch (err: any) { addToast(err?.message || "Git commit failed", "error"); } - finally { this.setGitBusy(dashboard.id, false); } - } - - async handleGitPull(dashboard: DashboardRow): Promise { - if (!dashboard.git?.hasRepo) return; - this.setGitBusy(dashboard.id, true); - try { await gitService.pull(dashboard.slug || dashboard.id, this.selectedEnv || null); addToast(t.git?.pull_success || "Pulled", "success"); await this.refreshDashboardGitState(dashboard.id, true); } - catch (err: any) { addToast(err?.message || "Git pull failed", "error"); } - finally { this.setGitBusy(dashboard.id, false); } - } - - async handleGitPush(dashboard: DashboardRow): Promise { - if (!dashboard.git?.hasRepo) return; - this.setGitBusy(dashboard.id, true); - try { await gitService.push(dashboard.slug || dashboard.id, this.selectedEnv || null); addToast(t.git?.push_success || "Pushed", "success"); await this.refreshDashboardGitState(dashboard.id, true); } - catch (err: any) { addToast(err?.message || "Git push failed", "error"); } - finally { this.setGitBusy(dashboard.id, false); } - } - - // ═══ Validation popover ════════════════════════════════════════ + // ══ Validation popover ════════════════════════════════════════ toggleValidationPopover(dashboardId: string, event: Event): void { event.stopPropagation(); @@ -522,15 +421,22 @@ export class DashboardHubModel { this.openValidationPopover = dashboardId; } - // ═══ Actions ═══════════════════════════════════════════════════ + // ══ Grid transforms (coordinator) ════════════════════════════ - toggleActionDropdown(id: string | null, event: Event): void { - event.stopPropagation(); - this.openActionDropdown = this.openActionDropdown === id ? null : id; + applyGridTransforms(): void { + const result = this.filters.applyGridTransforms(this.allDashboards as any, this.serverTotal, this.serverTotalPages); + this.filteredDashboards = result.filteredDashboards as any; + this.dashboards = result.dashboards as any; + this.total = result.total; + this.totalPages = result.totalPages; + this.selection.updateSelectionState(this.filteredDashboards as any, this.dashboards as any); + void this.hydrateVisibleGitStatusesBatch(); + void this.fetchValidationStatuses(); } - closeActionDropdown(): void { this.openActionDropdown = null; } - async handleAction(dashboard: DashboardRow, action: string): Promise { + // ══ Actions (parent-owned) ════════════════════════════════════ + + async handleAction(dashboard: any, action: string): Promise { log('DashboardHub', 'REASON', `${action} on dashboard`, { title: dashboard.title }); this.closeActionDropdown(); try { @@ -549,7 +455,7 @@ export class DashboardHubModel { this.bulkMigrateModel.suggestions = []; this.bulkMigrateModel.currentStep = 1; } else if (action === "backup") { - this.replaceSelectedIds(new SvelteSet([dashboard.id])); + this.selection.replaceSelectedIds(new SvelteSet([dashboard.id])); this.showBackupModal = true; this.backupSchedule = ""; } @@ -574,17 +480,17 @@ export class DashboardHubModel { } finally { this.isSubmittingBackup = false; } } - handleTaskStatusClick(dashboard: DashboardRow): void { + handleTaskStatusClick(dashboard: any): void { if (dashboard.lastTask?.id) { log('DashboardHub', 'REASON', 'Open task drawer for task', { taskId: dashboard.lastTask.id }); openDrawerForTask(dashboard.lastTask.id); } } - navigateToDashboardDetail(dashboard: DashboardRow): void { + navigateToDashboardDetail(dashboard: any): void { const ref = dashboard?.slug || dashboard?.id; if (!ref || !this.selectedEnv) return; goto(ROUTES.dashboards.detail(ref, this.selectedEnv)); } } -// #endregion DashboardHubModel +// #endregion Dashboards.HubModel diff --git a/frontend/src/lib/models/Dashboards.FiltersModel.svelte.ts b/frontend/src/lib/models/Dashboards.FiltersModel.svelte.ts new file mode 100644 index 00000000..e19977dd --- /dev/null +++ b/frontend/src/lib/models/Dashboards.FiltersModel.svelte.ts @@ -0,0 +1,210 @@ +// #region Dashboards.FiltersModel [C:4] [TYPE Model] [SEMANTICS dashboard,filter,pagination,sort,search] +// @ingroup Dashboards +// @BRIEF Filter, sort, search, and pagination state for the dashboard hub grid. Owned by Dashboards.HubModel. +// @INVARIANT Changing filter (column, multi-select) resets pagination to page 1. +// @INVARIANT Changing search resets pagination to page 1. +// @STATE idle — No search or filter active. +// @STATE active — Filter or search active, grid filtered. +// @ACTION setPage(n) — Navigates to page n, triggers loadDashboards. +// @ACTION handleSort(column) — Toggles sort direction or changes column. +// @ACTION toggleFilterValue(col,v,chk) — Adds/removes a column filter value. +// @ACTION clearColumnFilter(col) — Resets column filter. +// @ACTION selectAllColumnFilterValues(col) — Selects all visible options for column. +// @ACTION updateColumnFilterSearch(col,v) — Sets search text for column filter popover. +// @ACTION hasColumnFilter(col) — Whether column has active filters. +// @ACTION applyGridTransforms(all, serverTotal, serverTotalPages) — Computes sorted/filtered dashboards. +// @RELATION CALLED_BY -> [Dashboards.HubModel] + +import { SvelteSet } from "svelte/reactivity"; +import { t } from "$lib/i18n/index.svelte.js"; + +// ── Types ──────────────────────────────────────────────────────── + +export type SortDir = "asc" | "desc"; +export type FilterColumn = "title" | "git_status" | "validation_status" | "changed_on" | "actor"; +export type ColumnFilters = Record>; +export type ColumnFilterSearch = Record; + +export interface DashboardRow { + id: string; title: string; slug: string; + changedOn: string | null; changedOnLabel: string; + owners: string[]; actorLabel: string; + git: { status: string; branch: string | null; hasRepo: boolean | null; hasChangesForCommit: boolean }; + lastTask: { status: string | null; validationStatus: string; id: string } | null; + actions: string[]; +} + +// ═══ Model ═══════════════════════════════════════════════════════ + +export class DashboardsFiltersModel { + // ── Pagination ──────────────────────────────────────────────── + currentPage: number = $state(1); + pageSize: number = $state(10); + totalPages: number = $state(1); + total: number = $state(0); + + // ── Search ──────────────────────────────────────────────────── + searchQuery: string = $state(""); + filterDashboardIds: string[] = $state([]); + searchableDashboardOptions: Array<{id: string; name: string; subtitle: string; hint: string}> = $state([]); + searchableDashboardLoading: boolean = $state(false); + + // ── Sort ────────────────────────────────────────────────────── + sortColumn: FilterColumn = $state("title"); + sortDirection: SortDir = $state("asc"); + + // ── Column filters ──────────────────────────────────────────── + openFilterColumn: FilterColumn | null = $state(null); + filterDropdownPosition: { left: number; top: number } = $state({ left: 0, top: 0 }); + columnFilterSearch: ColumnFilterSearch = $state({ title: "", git_status: "", validation_status: "", changed_on: "", actor: "" }); + columnFilters: ColumnFilters = $state({ title: new SvelteSet(), git_status: new SvelteSet(), validation_status: new SvelteSet(), changed_on: new SvelteSet(), actor: new SvelteSet() }); + + // ── Profile filter ──────────────────────────────────────────── + profileFilterOverrideShowAll: boolean = $state(false); + effectiveProfileFilter: any = $state(null); + + // ═══ Pagination ════════════════════════════════════════════════ + + setPage(page: number): void { + if (page === this.currentPage) return; + this.currentPage = page; + } + + setPageSize(event: Event): void { + this.pageSize = parseInt((event.target as HTMLSelectElement).value); + this.currentPage = 1; + } + + // ═══ Profile filter ════════════════════════════════════════════ + + handleTemporaryShowAll(): void { + if (this.profileFilterOverrideShowAll) return; + this.profileFilterOverrideShowAll = true; + this.currentPage = 1; + } + + handleRestoreProfileFilter(): void { + if (!this.profileFilterOverrideShowAll) return; + this.profileFilterOverrideShowAll = false; + this.currentPage = 1; + } + + // ═══ Column filters ═══════════════════════════════════════════ + + getValidationLabelForCell(dashboard: DashboardRow, validationStatuses: Record): string { + const vs = validationStatuses[dashboard.id]; + return (!vs || !vs.status) ? "unknown" : String(vs.status).toLowerCase(); + } + + getColumnCellValue(dashboard: DashboardRow, column: FilterColumn, validationStatuses: Record): string { + if (column === "title") return dashboard.title || "-"; + if (column === "git_status") return String(dashboard.git?.status || "pending").toLowerCase(); + if (column === "validation_status") return this.getValidationLabelForCell(dashboard, validationStatuses); + if (column === "changed_on") return dashboard.changedOn ? String(dashboard.changedOn).slice(0, 10) : "-"; + if (column === "actor") return dashboard.actorLabel || "-"; + return "-"; + } + + getFilterOptions(allDashboards: DashboardRow[], validationStatuses: Record): string[] { + return Array.from(new SvelteSet( + allDashboards.map(d => this.getColumnCellValue(d, "title", validationStatuses)).filter(Boolean) + )).sort((a, b) => a.localeCompare(b, "ru")); + } + + getVisibleFilterOptions(column: FilterColumn, allDashboards: DashboardRow[], validationStatuses: Record): string[] { + const searchText = (this.columnFilterSearch[column] || "").toLowerCase(); + return this.getFilterOptions(allDashboards, validationStatuses).filter(v => v.toLowerCase().includes(searchText)); + } + + toggleFilterDropdown(column: FilterColumn, event: Event | undefined, panelWidth = 256): void { + event?.stopPropagation(); + if (this.openFilterColumn === column) { this.openFilterColumn = null; return; } + const trigger = (event as any)?.currentTarget; + if (trigger?.getBoundingClientRect) { + const rect = trigger.getBoundingClientRect(); + const vw = typeof window !== "undefined" ? window.innerWidth : 1920; + this.filterDropdownPosition = { left: Math.max(8, Math.min(rect.left, vw - panelWidth - 8)), top: rect.bottom + 8 }; + } + this.openFilterColumn = column; + } + + toggleFilterValue(column: FilterColumn, value: string, checked: boolean): void { + const next = new SvelteSet(this.columnFilters[column]); + checked ? next.add(value) : next.delete(value); + this.columnFilters = { ...this.columnFilters, [column]: next }; + this.currentPage = 1; + } + + clearColumnFilter(column: FilterColumn): void { + this.columnFilters = { ...this.columnFilters, [column]: new SvelteSet() }; + this.currentPage = 1; + } + + selectAllColumnFilterValues(column: FilterColumn, allDashboards: DashboardRow[], validationStatuses: Record): void { + this.columnFilters = { ...this.columnFilters, [column]: new SvelteSet(this.getVisibleFilterOptions(column, allDashboards, validationStatuses)) }; + this.currentPage = 1; + } + + updateColumnFilterSearch(column: FilterColumn, value: string): void { + this.columnFilterSearch = { ...this.columnFilterSearch, [column]: value }; + } + + hasColumnFilter(column: FilterColumn): boolean { return this.columnFilters[column]?.size > 0; } + + // ═══ Sort ══════════════════════════════════════════════════════ + + getSortValue(dashboard: DashboardRow, column: FilterColumn, gitSummary: string): string | number { + if (column === "title") return (dashboard.title || "").toLowerCase(); + if (column === "git_status") return gitSummary.toLowerCase(); + if (column === "validation_status") return (this.getValidationLabelForCell(dashboard, {}) || "").toLowerCase(); + if (column === "changed_on") return dashboard.changedOn ? new Date(dashboard.changedOn).getTime() : 0; + if (column === "actor") return (dashboard.actorLabel || "").toLowerCase(); + return ""; + } + + /** Returns a lightweight label for git status (for sort computation). */ + getSortGitLabel(dashboard: DashboardRow): string { + if (dashboard.git?.hasRepo === null) return "loading"; + if (!dashboard.git?.hasRepo) return "no repo"; + return dashboard.git?.hasChangesForCommit ? "diff" : "synced"; + } + + handleSort(column: FilterColumn): void { + if (this.sortColumn === column) { + this.sortDirection = this.sortDirection === "asc" ? "desc" : "asc"; + } else { + this.sortColumn = column; + this.sortDirection = "asc"; + } + this.currentPage = 1; + } + + // ═══ Grid transforms ═══════════════════════════════════════════ + + /** + * Applies filter-by-id, sort to allDashboards. Returns the transformed arrays and totals. + * Does NOT mutate external state — the caller (HubModel) handles cross-cutting updates. + */ + applyGridTransforms(allDashboards: DashboardRow[], serverTotal: number, serverTotalPages: number): { filteredDashboards: DashboardRow[]; dashboards: DashboardRow[]; total: number; totalPages: number } { + let next = allDashboards; + if (this.filterDashboardIds.length > 0) { + next = next.filter(d => this.filterDashboardIds.includes(d.id)); + } + next = [...next].sort((a, b) => { + const gitALabel = this.getSortGitLabel(a); + const gitBLabel = this.getSortGitLabel(b); + const aV = this.getSortValue(a, this.sortColumn, gitALabel); + const bV = this.getSortValue(b, this.sortColumn, gitBLabel); + if (aV < bV) return this.sortDirection === "asc" ? -1 : 1; + if (aV > bV) return this.sortDirection === "asc" ? 1 : -1; + return 0; + }); + return { + filteredDashboards: next, + dashboards: next, + total: serverTotal, + totalPages: serverTotalPages, + }; + } +} +// #endregion Dashboards.FiltersModel diff --git a/frontend/src/lib/models/Dashboards.GitActionsModel.svelte.ts b/frontend/src/lib/models/Dashboards.GitActionsModel.svelte.ts new file mode 100644 index 00000000..18314595 --- /dev/null +++ b/frontend/src/lib/models/Dashboards.GitActionsModel.svelte.ts @@ -0,0 +1,176 @@ +// #region Dashboards.GitActionsModel [C:4] [TYPE Model] [SEMANTICS dashboard,git,actions,sync,commit] +// @ingroup Dashboards +// @BRIEF Git operations state and actions for the dashboard hub. Owned by Dashboards.HubModel. +// @INVARIANT gitBusyIds prevents concurrent operations on the same dashboard. +// @INVARIANT gitResolvedIds prevents duplicate fetches of the same dashboard git status within a load cycle. +// @STATE idle — No git operation in progress. +// @STATE loading — Fetching git status for visible dashboards. +// @STATE busy — Git action (init/sync/commit/pull/push) in progress. +// @STATE error — Last git operation failed (toast shown, state cleared). +// @ACTION isGitBusy(id) — Whether a dashboard is busy with a git operation. +// @ACTION setGitBusy(id, busy) — Sets/resets busy flag for a dashboard. +// @ACTION ensureGitConfigs() — Returns cached or fetches git configs. +// @ACTION normalizeRepositoryStatusPayload(s) — Normalizes API status to GitState. +// @ACTION fetchDashboardGitStatusesBatch(batch) — Batch-fetches git statuses for pending dashboards. +// @ACTION handleGitInit(dashboard) — Initializes a git repo for a dashboard. +// @ACTION handleGitSync(dashboard, env) — Syncs dashboard with git remote. +// @ACTION handleGitCommit(dashboard, env) — Commits changes for a dashboard. +// @ACTION handleGitPull(dashboard, env) — Pulls latest from git remote. +// @ACTION handleGitPush(dashboard, env) — Pushes to git remote. +// @ACTION getGitSummaryLabel(dashboard) — Returns human-readable git summary label. +// @ACTION toggleActionDropdown(id, event) — Toggles the action dropdown for a dashboard. +// @ACTION closeActionDropdown() — Closes the action dropdown. +// @RELATION CALLS -> [gitService] +// @RELATION CALLED_BY -> [Dashboards.HubModel] + +import { SvelteSet } from "svelte/reactivity"; +import { gitService } from "../../services/gitService.js"; +import { addToast } from "$lib/toasts.svelte.js"; +import { t } from "$lib/i18n/index.svelte.js"; + +// ── Types ──────────────────────────────────────────────────────── + +interface DashboardRow { + id: string; title: string; slug: string; + changedOn: string | null; changedOnLabel: string; + owners: string[]; actorLabel: string; + git: { status: string; branch: string | null; hasRepo: boolean | null; hasChangesForCommit: boolean }; + lastTask: { status: string | null; validationStatus: string; id: string } | null; + actions: string[]; +} + +interface GitState { status: string; branch: string | null; hasRepo: boolean; hasChangesForCommit: boolean } + +// ═══ Model ═══════════════════════════════════════════════════════ + +export class DashboardsGitActionsModel { + // ── Git state ───────────────────────────────────────────────── + openActionDropdown: string | null = $state(null); + gitBusyIds: Set = $state(new SvelteSet()); + gitResolvedIds: Set = $state(new SvelteSet()); + gitLoadingIds: Set = $state(new SvelteSet()); + cachedGitConfigs: any[] = $state([]); + + /** + * Callback invoked when a dashboard's git state changes. + * Set by the parent (Dashboards.HubModel) to propagate git state + * updates into the parent's dashboard arrays. + */ + onGitStateUpdate?: (dashboardId: string, nextGit: Partial) => void; + + // ═══ Git helpers ═══════════════════════════════════════════════ + + getGitSummaryLabel(dashboard: DashboardRow): string { + if (dashboard.git?.hasRepo === null) return t.common?.loading || "Loading"; + if (!dashboard.git?.hasRepo) return t.dashboard?.status_no_repo || "No Repo"; + return dashboard.git?.hasChangesForCommit ? t.dashboard?.status_changes || "Diff" : t.dashboard?.status_no_changes || "Synced"; + } + + isGitBusy(dashboardId: string): boolean { return this.gitBusyIds.has(dashboardId); } + + setGitBusy(dashboardId: string, busy: boolean): void { + if (busy) this.gitBusyIds.add(dashboardId); else this.gitBusyIds.delete(dashboardId); + this.gitBusyIds = new SvelteSet(this.gitBusyIds); + } + + async ensureGitConfigs(): Promise { + if (this.cachedGitConfigs.length > 0) return this.cachedGitConfigs; + this.cachedGitConfigs = await gitService.getConfigs(); + return this.cachedGitConfigs; + } + + normalizeRepositoryStatusPayload(status: any): GitState { + const syncState = String(status?.sync_state || "").toUpperCase(); + const syncStatus = String(status?.sync_status || "").toUpperCase(); + const explicitHasRepo = status?.has_repo; + const hasRepo = explicitHasRepo === true ? true : explicitHasRepo === false ? false : !(syncState === "NO_REPO" || syncStatus === "NO_REPO"); + if (!hasRepo) return { status: "no_repo", branch: null, hasRepo: false, hasChangesForCommit: false }; + const hasChanges = syncState === "CHANGES" || syncStatus === "DIFF" || Boolean(status?.is_dirty) || + (status?.untracked_files?.length || 0) > 0 || (status?.modified_files?.length || 0) > 0 || (status?.staged_files?.length || 0) > 0; + return { status: hasChanges ? "diff" : "ok", branch: status?.current_branch || status?.branch || null, hasRepo: true, hasChangesForCommit: hasChanges }; + } + + async fetchDashboardGitStatusesBatch(dashboardIds: string[], force = false): Promise { + const uniqueIds = Array.from(new SvelteSet(dashboardIds)); + if (uniqueIds.length === 0) return; + if (force) { uniqueIds.forEach(id => this.gitResolvedIds.delete(id)); this.gitResolvedIds = new SvelteSet(this.gitResolvedIds); } + const pendingIds = uniqueIds.filter(id => !this.gitResolvedIds.has(id) && !this.gitLoadingIds.has(id)); + if (pendingIds.length === 0) return; + pendingIds.forEach(id => this.gitLoadingIds.add(id)); + this.gitLoadingIds = new SvelteSet(this.gitLoadingIds); + try { + const batchResult = await gitService.getStatusesBatch(pendingIds); + const statuses = batchResult?.statuses || {}; + pendingIds.forEach(id => { + const st = statuses[id] ?? statuses[String(id)]; + const nextGit = st ? this.normalizeRepositoryStatusPayload(st) : { status: "no_repo", branch: null, hasRepo: false, hasChangesForCommit: false }; + this.onGitStateUpdate?.(id, nextGit); + }); + } catch { + pendingIds.forEach(id => { + this.onGitStateUpdate?.(id, { status: "no_repo", branch: null, hasRepo: false, hasChangesForCommit: false }); + }); + } finally { + pendingIds.forEach(id => { this.gitLoadingIds.delete(id); this.gitResolvedIds.add(id); }); + this.gitLoadingIds = new SvelteSet(this.gitLoadingIds); + this.gitResolvedIds = new SvelteSet(this.gitResolvedIds); + } + } + + async handleGitInit(dashboard: DashboardRow): Promise { + this.setGitBusy(dashboard.id, true); + try { + const configs = await this.ensureGitConfigs(); + if (!configs.length) { addToast(t.git?.no_servers_configured || "No Git config found", "error"); return; } + const config = configs[0]; + const defaultRemote = config?.default_repository ? `${String(config.url || "").replace(/\/$/, "")}/${config.default_repository}.git` : ""; + const remoteUrl = prompt(t.git?.remote_url || "Remote URL", defaultRemote); + if (!remoteUrl) return; + await gitService.initRepository(dashboard.id, config.id, remoteUrl.trim()); + addToast(t.git?.init_success || "Repository initialized", "success"); + await this.fetchDashboardGitStatusesBatch([dashboard.id], true); + } catch (err: any) { addToast(err?.message || "Git init failed", "error"); } + finally { this.setGitBusy(dashboard.id, false); } + } + + async handleGitSync(dashboard: DashboardRow, selectedEnv: string | null): Promise { + this.setGitBusy(dashboard.id, true); + try { await gitService.sync(dashboard.id, selectedEnv || null); addToast(t.git?.sync_success || "Synced", "success"); await this.fetchDashboardGitStatusesBatch([dashboard.id], true); } + catch (err: any) { addToast(err?.message || "Git sync failed", "error"); } + finally { this.setGitBusy(dashboard.id, false); } + } + + async handleGitCommit(dashboard: DashboardRow, selectedEnv: string | null): Promise { + if (!dashboard.git?.hasRepo) { addToast(t.git?.not_linked || "Repository not linked", "error"); return; } + if (!dashboard.git?.hasChangesForCommit) { addToast(t.git?.nothing_to_commit || "No changes to commit", "error"); return; } + const message = prompt(t.git?.commit_message || "Commit message", `Update dashboard ${dashboard.title}`); + if (!message?.trim()) return; + this.setGitBusy(dashboard.id, true); + try { await gitService.commit(dashboard.slug || dashboard.id, message.trim()); addToast(t.git?.commit_success || "Committed", "success"); await this.fetchDashboardGitStatusesBatch([dashboard.id], true); } + catch (err: any) { addToast(err?.message || "Git commit failed", "error"); } + finally { this.setGitBusy(dashboard.id, false); } + } + + async handleGitPull(dashboard: DashboardRow, selectedEnv: string | null): Promise { + if (!dashboard.git?.hasRepo) return; + this.setGitBusy(dashboard.id, true); + try { await gitService.pull(dashboard.slug || dashboard.id, selectedEnv || null); addToast(t.git?.pull_success || "Pulled", "success"); await this.fetchDashboardGitStatusesBatch([dashboard.id], true); } + catch (err: any) { addToast(err?.message || "Git pull failed", "error"); } + finally { this.setGitBusy(dashboard.id, false); } + } + + async handleGitPush(dashboard: DashboardRow, selectedEnv: string | null): Promise { + if (!dashboard.git?.hasRepo) return; + this.setGitBusy(dashboard.id, true); + try { await gitService.push(dashboard.slug || dashboard.id, selectedEnv || null); addToast(t.git?.push_success || "Pushed", "success"); await this.fetchDashboardGitStatusesBatch([dashboard.id], true); } + catch (err: any) { addToast(err?.message || "Git push failed", "error"); } + finally { this.setGitBusy(dashboard.id, false); } + } + + toggleActionDropdown(id: string | null, event: Event): void { + event.stopPropagation(); + this.openActionDropdown = this.openActionDropdown === id ? null : id; + } + closeActionDropdown(): void { this.openActionDropdown = null; } +} +// #endregion Dashboards.GitActionsModel diff --git a/frontend/src/lib/models/Dashboards.SelectionModel.svelte.ts b/frontend/src/lib/models/Dashboards.SelectionModel.svelte.ts new file mode 100644 index 00000000..030ff48e --- /dev/null +++ b/frontend/src/lib/models/Dashboards.SelectionModel.svelte.ts @@ -0,0 +1,67 @@ +// #region Dashboards.SelectionModel [C:3] [TYPE Model] [SEMANTICS dashboard,selection,checkbox,bulk-action] +// @ingroup Dashboards +// @BRIEF Selection state and checkbox logic for the dashboard hub grid. Owned by Dashboards.HubModel. +// @INVARIANT Selection never contains IDs not present in allDashboards (cleaned on load via parent). +// @STATE none — No dashboards selected. +// @STATE partial — Some dashboards selected. +// @STATE all — All filtered dashboards selected. +// @ACTION updateSelectionState() — Recomputes isAllSelected and isAllVisibleSelected. +// @ACTION replaceSelectedIds(set) — Replaces the entire selection set. +// @ACTION clearSelectedIds() — Empties selection. +// @ACTION handleCheckboxChange(d, e) — Toggles one dashboard in selection. +// @ACTION handleSelectAll() — Toggles select all filtered dashboards. +// @ACTION handleSelectVisible() — Toggles select visible page dashboards. +// @RELATION CALLED_BY -> [Dashboards.HubModel] + +import { SvelteSet } from "svelte/reactivity"; + +// ── Types ──────────────────────────────────────────────────────── + +interface DashboardRow { + id: string; title: string; slug: string; + changedOn: string | null; changedOnLabel: string; + owners: string[]; actorLabel: string; + git: { status: string; branch: string | null; hasRepo: boolean | null; hasChangesForCommit: boolean }; + lastTask: { status: string | null; validationStatus: string; id: string } | null; + actions: string[]; +} + +// ═══ Model ═══════════════════════════════════════════════════════ + +export class DashboardsSelectionModel { + // ── Selection state ─────────────────────────────────────────── + selectedIds: Set = $state(new SvelteSet()); + isAllSelected: boolean = $state(false); + isAllVisibleSelected: boolean = $state(false); + + // ═══ Selection methods ═════════════════════════════════════════ + + updateSelectionState(filteredDashboards: DashboardRow[], dashboards: DashboardRow[]): void { + this.isAllSelected = this.selectedIds.size === filteredDashboards.length && filteredDashboards.length > 0; + this.isAllVisibleSelected = this.selectedIds.size === dashboards.length && dashboards.length > 0; + } + + replaceSelectedIds(next: Set): void { this.selectedIds = next; } + clearSelectedIds(): void { this.replaceSelectedIds(new SvelteSet()); } + + handleCheckboxChange(dashboard: DashboardRow, event: Event): void { + const next = new SvelteSet(this.selectedIds); + (event.target as HTMLInputElement).checked ? next.add(dashboard.id) : next.delete(dashboard.id); + this.replaceSelectedIds(next); + } + + handleSelectAll(filteredDashboards: DashboardRow[]): void { + if (this.isAllSelected) { this.clearSelectedIds(); return; } + const next = new SvelteSet(this.selectedIds); + filteredDashboards.forEach(d => next.add(d.id)); + this.replaceSelectedIds(next); + } + + handleSelectVisible(dashboards: DashboardRow[]): void { + const next = new SvelteSet(this.selectedIds); + if (this.isAllVisibleSelected) { dashboards.forEach(d => next.delete(d.id)); } + else { dashboards.forEach(d => next.add(d.id)); } + this.replaceSelectedIds(next); + } +} +// #endregion Dashboards.SelectionModel diff --git a/frontend/src/lib/models/DatasetDetailModel.svelte.ts b/frontend/src/lib/models/DatasetDetailModel.svelte.ts index 7f3448bc..3e8dae82 100644 --- a/frontend/src/lib/models/DatasetDetailModel.svelte.ts +++ b/frontend/src/lib/models/DatasetDetailModel.svelte.ts @@ -1,5 +1,5 @@ -// #region DatasetDetailModel [C:3] [TYPE Model] [SEMANTICS dataset,detail,columns,dashboard,model] -// @ingroup Models +// #region Datasets.DetailModel [C:3] [TYPE Model] [SEMANTICS dataset,detail,columns,dashboard,model] +// @ingroup Datasets // @BRIEF Screen model for dataset detail page — loads dataset metadata, columns, SQL, and linked dashboards. // @INVARIANT loadDatasetDetail() must only fire when datasetId and envId are both truthy. // @STATE idle — No context available (missing datasetId or envId). @@ -124,4 +124,4 @@ export class DatasetDetailModel { return 'text-text-muted bg-surface-muted'; } } -// #endregion DatasetDetailModel +// #endregion Datasets.DetailModel diff --git a/frontend/src/lib/models/DatasetReviewModel.svelte.ts b/frontend/src/lib/models/DatasetReviewModel.svelte.ts index cbcb82ad..47ed7431 100644 --- a/frontend/src/lib/models/DatasetReviewModel.svelte.ts +++ b/frontend/src/lib/models/DatasetReviewModel.svelte.ts @@ -1,5 +1,5 @@ -// #region DatasetReviewModel [C:4] [TYPE Model] [SEMANTICS dataset-review,workspace,session,model] -// @ingroup Models +// #region Datasets.ReviewModel [C:4] [TYPE Model] [SEMANTICS dataset-review,workspace,session,model] +// @ingroup Datasets // @BRIEF Screen model for dataset review workspace — atoms, derived state, and action delegates. // @INVARIANT sessionVersion gates stale concurrent updates — discarded if mismatch. // @INVARIANT bootstrapping blocks all user interactions until session loaded. @@ -331,4 +331,4 @@ export class DatasetReviewModel { return `/datasets/review/${encodeURIComponent(String(sessionId))}`; } } -// #endregion DatasetReviewModel +// #endregion Datasets.ReviewModel diff --git a/frontend/src/lib/models/DatasetsHubModel.svelte.ts b/frontend/src/lib/models/DatasetsHubModel.svelte.ts index 5cc7ba42..63fd1984 100644 --- a/frontend/src/lib/models/DatasetsHubModel.svelte.ts +++ b/frontend/src/lib/models/DatasetsHubModel.svelte.ts @@ -1,5 +1,5 @@ -// #region DatasetsHubModel [C:4] [TYPE Model] [SEMANTICS datasets,list,filter,pagination,bulk-action,model] -// @ingroup Models +// #region Datasets.HubModel [C:4] [TYPE Model] [SEMANTICS datasets,list,filter,pagination,bulk-action,model] +// @ingroup Datasets // @BRIEF Screen model for the datasets hub — manages list state, selection, detail preview, modals, and WebSocket updates. // @INVARIANT Changing filter or search resets pagination to page 1. // @INVARIANT selectedIds is always a subset of loaded datasets — orphan IDs cleaned after each load. @@ -291,4 +291,4 @@ export class DatasetsHubModel { } } } -// #endregion DatasetsHubModel +// #endregion Datasets.HubModel diff --git a/frontend/src/lib/models/DeploymentModel.svelte.ts b/frontend/src/lib/models/DeploymentModel.svelte.ts index e7c826b7..f6f321ad 100644 --- a/frontend/src/lib/models/DeploymentModel.svelte.ts +++ b/frontend/src/lib/models/DeploymentModel.svelte.ts @@ -1,6 +1,6 @@ // frontend/src/lib/models/DeploymentModel.svelte.ts -// #region DeploymentModel [C:4] [TYPE Model] [SEMANTICS git,deploy,environment,screen-model] -// @ingroup Models +// #region Deployment.Model [C:4] [TYPE Model] [SEMANTICS git,deploy,environment,screen-model] +// @ingroup Deployment // @BRIEF State model for deployment modal — load environments, select target, deploy with prod confirmation. // @RATIONALE Svelte 5 rune-based model with $state atoms and $derived computed values. Model extraction enables L1 tests without DOM render. // @INVARIANT Cannot deploy without a selected environment (selectedEnv must be set). @@ -179,4 +179,4 @@ export class DeploymentModel { } } } -// #endregion DeploymentModel +// #endregion Deployment.Model diff --git a/frontend/src/lib/models/DictionaryDetailModel.svelte.ts b/frontend/src/lib/models/DictionaryDetailModel.svelte.ts index 23ec39c2..ae62e398 100644 --- a/frontend/src/lib/models/DictionaryDetailModel.svelte.ts +++ b/frontend/src/lib/models/DictionaryDetailModel.svelte.ts @@ -1,5 +1,5 @@ -// #region DictionaryDetailModel [C:4] [TYPE Model] [SEMANTICS translate,dictionary,entry,crud,import,model] -// @ingroup Models +// #region Translate.DictionaryDetailModel [C:4] [TYPE Model] [SEMANTICS translate,dictionary,entry,crud,import,model] +// @ingroup Translate // @BRIEF Screen model for dictionary detail page — manages entry list, CRUD operations, and CSV import. // @INVARIANT Expanding an entry clears previous expanded state. // @INVARIANT Import preview is cleared when import form is closed. @@ -223,4 +223,4 @@ export class DictionaryDetailModel { } finally { this.isImporting = false; } } } -// #endregion DictionaryDetailModel +// #endregion Translate.DictionaryDetailModel diff --git a/frontend/src/lib/models/GitConfigModel.svelte.ts b/frontend/src/lib/models/GitConfigModel.svelte.ts index 381619f2..7277478a 100644 --- a/frontend/src/lib/models/GitConfigModel.svelte.ts +++ b/frontend/src/lib/models/GitConfigModel.svelte.ts @@ -1,6 +1,6 @@ // frontend/src/lib/models/GitConfigModel.svelte.ts -// #region GitConfigModel [C:4] [TYPE Model] [SEMANTICS git,config,settings,screen-model] -// @ingroup Models +// #region Git.ConfigModel [C:4] [TYPE Model] [SEMANTICS git,config,settings,screen-model] +// @ingroup Git // @BRIEF State model for Git configuration management — CRUD operations on git server configs and Gitea repository management. // @INVARIANT Config list is loaded before edit/create operations are attempted (loadConfigs() on mount). // @INVARIANT Loading states are mutually exclusive per operation type (configsLoading, saving, testingConnection, deleting, giteaReposLoading, giteaCreating, giteaDeleting). @@ -399,4 +399,4 @@ export class GitConfigModel { } } } -// #endregion GitConfigModel +// #endregion Git.ConfigModel diff --git a/frontend/src/lib/models/GitManagerModel.svelte.ts b/frontend/src/lib/models/GitManagerModel.svelte.ts index 1400411a..34758d30 100644 --- a/frontend/src/lib/models/GitManagerModel.svelte.ts +++ b/frontend/src/lib/models/GitManagerModel.svelte.ts @@ -1,6 +1,6 @@ // frontend/src/lib/models/GitManagerModel.svelte.ts -// #region GitManagerModel [C:4] [TYPE Model] [SEMANTICS git,workspace,screen-model,state,repository] -// @ingroup Models +// #region Git.ManagerModel [C:4] [TYPE Model] [SEMANTICS git,workspace,screen-model,state,repository] +// @ingroup Git // @BRIEF State model for the Git workspace panel — declares atoms, invariants, and actions. // @INVARIANT Loading states are mutually exclusive per operation: // checkingStatus, loading, workspaceLoading, committing, isPulling, isPushing, @@ -167,7 +167,7 @@ interface GitflowStageDefaults { } // #region buildGitErrorFromException [C:2] [TYPE Function] -// @ingroup Models +// @ingroup Git // @BRIEF Extract structured gitError payload from a caught exception for modal error banner. // @PRE e is an Error object with optional .status, .detail, .error_code properties. // @POST Returns { message, status, error_code, files, next_steps, detail } for state. @@ -864,4 +864,4 @@ export class GitManagerModel { } } } -// #endregion GitManagerModel +// #endregion Git.ManagerModel diff --git a/frontend/src/lib/models/GitStatusModel.svelte.ts b/frontend/src/lib/models/GitStatusModel.svelte.ts index 6ca69a4c..9a95cb92 100644 --- a/frontend/src/lib/models/GitStatusModel.svelte.ts +++ b/frontend/src/lib/models/GitStatusModel.svelte.ts @@ -1,6 +1,6 @@ // frontend/src/lib/models/GitStatusModel.svelte.ts -// #region GitStatusModel [C:4] [TYPE Model] [SEMANTICS git,status,dashboard,sync,screen-model] -// @ingroup Models +// #region Git.StatusModel [C:4] [TYPE Model] [SEMANTICS git,status,dashboard,sync,screen-model] +// @ingroup Git // @BRIEF State model for a dashboard's git repository status and sync operations. // @INVARIANT Sync/pull/push operations are mutually exclusive — only one runs at a time. // @ACTION syncRepository() — sync dashboard state to Git (returns true on success) @@ -359,4 +359,4 @@ export class GitStatusModel { void this.loadStatus(); } } -// #endregion GitStatusModel +// #endregion Git.StatusModel diff --git a/frontend/src/lib/models/HealthCenterModel.svelte.ts b/frontend/src/lib/models/HealthCenterModel.svelte.ts index 2338829d..dc257d7b 100644 --- a/frontend/src/lib/models/HealthCenterModel.svelte.ts +++ b/frontend/src/lib/models/HealthCenterModel.svelte.ts @@ -1,5 +1,5 @@ -// #region HealthCenterModel [C:5] [TYPE Model] [SEMANTICS health,center,matrix,filter,dashboard-list,model] -// @ingroup Models +// #region Health.CenterModel [C:5] [TYPE Model] [SEMANTICS health,center,matrix,filter,dashboard-list,model] +// @ingroup Health // @BRIEF Screen model for Dashboard Health Center — matrix-driven filtering, card list, delete, env switching. // @INVARIANT Changing environment reloads all data (matrix + cards). // @INVARIANT Matrix click sets activeStatusFilter, re-derived filteredItems without reload. @@ -167,4 +167,4 @@ export class HealthCenterModel { this.activeStatusFilter = ''; } } -// #endregion HealthCenterModel +// #endregion Health.CenterModel diff --git a/frontend/src/lib/models/LLMReportModel.svelte.ts b/frontend/src/lib/models/LLMReportModel.svelte.ts index a9ae6705..efbc15b1 100644 --- a/frontend/src/lib/models/LLMReportModel.svelte.ts +++ b/frontend/src/lib/models/LLMReportModel.svelte.ts @@ -1,5 +1,5 @@ -// #region LLMReportModel [C:3] [TYPE Model] [SEMANTICS llm,report,validation,screenshot,model] -// @ingroup Models +// #region Translate.LLMReportModel [C:3] [TYPE Model] [SEMANTICS llm,report,validation,screenshot,model] +// @ingroup Translate // @BRIEF Screen model for the LLM validation report page — loads task details, logs, and thumbnail screenshots. // @INVARIANT taskId is derived from route params; loadReport() must only fire when taskId is truthy. // @INVARIANT screenshot loading is token-gated to prevent stale blob URLs from overwriting valid ones. @@ -200,4 +200,4 @@ export class LLMReportModel { return 'bg-surface-muted text-text border-border'; } } -// #endregion LLMReportModel +// #endregion Translate.LLMReportModel diff --git a/frontend/src/lib/models/MappingsModel.svelte.ts b/frontend/src/lib/models/MappingsModel.svelte.ts index e3c54124..2cdc8222 100644 --- a/frontend/src/lib/models/MappingsModel.svelte.ts +++ b/frontend/src/lib/models/MappingsModel.svelte.ts @@ -1,6 +1,6 @@ // frontend/src/lib/models/MappingsModel.svelte.ts -// #region MappingsModel [C:4] [TYPE Model] [SEMANTICS mapping,database,screen-model] -// @ingroup Models +// #region Settings.MappingsModel [C:4] [TYPE Model] [SEMANTICS mapping,database,screen-model] +// @ingroup Settings // @BRIEF State model for the standalone database mappings page. // @REJECTED Inline $state + handler functions in migration/mappings/+page.svelte was rejected — the RSM pattern (semantics-svelte §IIIa) requires model-first architecture with L1 testability. // @INVARIANT Changing source environment resets target databases, mappings, suggestions, and targetEnvId. @@ -197,4 +197,4 @@ export class MappingsModel { } } } -// #endregion MappingsModel +// #endregion Settings.MappingsModel diff --git a/frontend/src/lib/models/Migration.ExecutorModel.svelte.ts b/frontend/src/lib/models/Migration.ExecutorModel.svelte.ts new file mode 100644 index 00000000..3feb88ee --- /dev/null +++ b/frontend/src/lib/models/Migration.ExecutorModel.svelte.ts @@ -0,0 +1,136 @@ +// frontend/src/lib/models/Migration.ExecutorModel.svelte.ts +// #region Migration.ExecutorModel [C:4] [TYPE Model] [SEMANTICS migration,execution,dry-run,password] +// @ingroup Migration +// @BRIEF Migration execution sub-model — dry-run, execute, resume, password prompt handling. +// @RELATION DEPENDS_ON -> [Migration.Model] +// @INVARIANT Dry-run must complete before migration execution (see parent.canExecute). +// @INVARIANT Password prompt triggers only for AWAITING_INPUT tasks with type "database_password". +// @STATE dryRunResult — Dry-run analysis result. +// @STATE dryRunLoading — True while dry-run API call is in flight. +// @STATE selectedDashboardIds — Set of selected dashboard IDs for migration. +// @STATE showPasswordPrompt — Whether the database password prompt is visible. +// @ACTION calculateDryRun() — Runs dry-run analysis, advances to step 3 on success. +// @ACTION executeMigration(endpoint?) — Submits migration execution task. +// @ACTION resumeMigration(passwords) — Resumes a paused migration with database passwords. +// @ACTION checkPasswordPrompt() — Watches the active task for password-request signals. + +import { api } from "$lib/api.js"; +import { selectedTask } from "$lib/stores.svelte.js"; +import { resumeTask } from "../../services/taskService.js"; +import { t } from "$lib/i18n/index.svelte.js"; +import type { MigrationModel } from "./MigrationModel.svelte.ts"; + +// ── Types ───────────────────────────────────────────────────── + +export interface DryRunResult { + summary?: Record; + risk?: { score: number; level: string; items: unknown[] }; + selected_dashboard_titles?: string[]; + [key: string]: unknown; +} + +export interface DashboardSelection { + selected_ids: string[]; + source_env_id: string; + target_env_id: string; + replace_db_config: boolean; + fix_cross_filters: boolean; +} + +export class MigrationExecutor { + parent: MigrationModel; + + // ── Atoms (reactive state) ────────────────────────────────── + + selectedDashboardIds: string[] = $state([]); + dryRunResult: DryRunResult | null = $state(null); + dryRunLoading: boolean = $state(false); + showPasswordPrompt: boolean = $state(false); + passwordPromptDatabases: string[] = $state([]); + passwordPromptErrorMessage: string = $state(""); + + constructor(parent: MigrationModel) { + this.parent = parent; + } + + // ── Derived ───────────────────────────────────────────────── + + /** Alias for dryRunLoading — used externally for UI gating. */ + get isCalculating(): boolean { + return this.dryRunLoading; + } + + // ── Actions ───────────────────────────────────────────────── + + /** Run dry-run analysis. Sets dryRunResult and advances to step 3 on success. */ + async calculateDryRun(): Promise { + if (!this.parent._validatePreconditions()) return; + this.parent.error = ""; + this.dryRunLoading = true; + try { + this.dryRunResult = await api.postApi("/migration/dry-run", this.parent._buildSelection()); + this.parent.wizard.currentStep = 3; + } catch (e: unknown) { + this.parent.error = e instanceof Error ? e.message : "Dry-run failed"; + this.dryRunResult = null; + } finally { + this.dryRunLoading = false; + } + } + + /** Execute migration. Submits task and activates task runner view. */ + async executeMigration(endpoint: string = "/migration/execute"): Promise { + if (!this.parent._validatePreconditions()) return; + this.parent.error = ""; + try { + this.dryRunResult = null; + const result = await api.postApi(endpoint, this.parent._buildSelection()); + await new Promise((r) => setTimeout(r, 500)); + try { + const task = await api.getTask(result.task_id); + this.parent.selectedTaskStore.set(task); + } catch { + this.parent.selectedTaskStore.set({ + id: result.task_id, + plugin_id: "superset-migration", + status: "RUNNING", + logs: [], + params: {}, + }); + } + } catch (e: unknown) { + this.parent.error = e instanceof Error ? e.message : "Migration execution failed"; + } + } + + /** Resume a paused migration task with database passwords. */ + async resumeMigration(passwords: Record): Promise { + const task = this.parent.selectedTaskStore.current; + if (!task) return; + try { + await resumeTask(task.id, passwords); + this.showPasswordPrompt = false; + } catch (e: unknown) { + this.passwordPromptErrorMessage = e instanceof Error ? e.message : (t.migration?.resume_failed || "Resume failed"); + } + } + + /** Watch for password-prompt signals from active task. Call in an $effect. */ + checkPasswordPrompt(): void { + const activeTask = this.parent.selectedTaskStore.current; + if ( + !activeTask || + activeTask.status !== "AWAITING_INPUT" || + !activeTask.input_request + ) { + return; + } + const req = activeTask.input_request; + if (req.type === "database_password") { + this.passwordPromptDatabases = req.databases || []; + this.passwordPromptErrorMessage = req.error_message || ""; + this.showPasswordPrompt = true; + } + } +} +// #endregion Migration.ExecutorModel diff --git a/frontend/src/lib/models/Migration.WizardModel.svelte.ts b/frontend/src/lib/models/Migration.WizardModel.svelte.ts new file mode 100644 index 00000000..c19710c4 --- /dev/null +++ b/frontend/src/lib/models/Migration.WizardModel.svelte.ts @@ -0,0 +1,42 @@ +// frontend/src/lib/models/Migration.WizardModel.svelte.ts +// #region Migration.WizardModel [C:3] [TYPE Model] [SEMANTICS migration,wizard,step-navigation] +// @ingroup Migration +// @BRIEF Wizard step navigation sub-model — currentStep, gated goToStep(), clearError(). +// @RELATION DEPENDS_ON -> [Migration.Model] +// @INVARIANT Backward navigation (step <= currentStep) is always allowed. +// @INVARIANT Step 2 requires source !== target and both envs selected (delegates to parent.stepReady). +// @INVARIANT Step 3 requires step 1 and step 2 ready. +// @INVARIANT Step 4 requires a dry-run result. +// @STATE currentStep — Active wizard step (1=environments, 2=dashboards, 3=review, 4=execute). +// @ACTION goToStep(step) — Navigates to a wizard step with readiness gating. +// @ACTION clearError() — Clears the error message on the parent model. + +import type { MigrationModel } from "./MigrationModel.svelte.ts"; + +export class WizardState { + parent: MigrationModel; + currentStep: number = $state(1); + + constructor(parent: MigrationModel) { + this.parent = parent; + } + + /** Navigate to a wizard step. Gated by step readiness and dry-run result. */ + goToStep(step: number): void { + const dryRunResult = this.parent.executor?.dryRunResult ?? null; + if ( + step <= this.currentStep || + (step === 2 && this.parent.stepReady[1]) || + (step === 3 && this.parent.stepReady[1] && this.parent.stepReady[2]) || + (step === 4 && dryRunResult) + ) { + this.currentStep = step; + } + } + + /** Clear error message on the parent model. */ + clearError(): void { + this.parent.error = ""; + } +} +// #endregion Migration.WizardModel diff --git a/frontend/src/lib/models/MigrationModel.svelte.ts b/frontend/src/lib/models/MigrationModel.svelte.ts index a9d7e94d..5fc7541d 100644 --- a/frontend/src/lib/models/MigrationModel.svelte.ts +++ b/frontend/src/lib/models/MigrationModel.svelte.ts @@ -7,6 +7,7 @@ // @INVARIANT Migration execution blocked unless source≠target and ≥1 dashboard selected. // @INVARIANT Dry-run must complete before advancing to execution step. // @INVARIANT Password prompt only appears for AWAITING_INPUT tasks with type "database_password". +// @INVARIANT DECOMPOSITION GATE (was 457 lines). Split: WizardModel (step nav), ExecutorModel (dry-run/exec/password). // @STATE idle — Initial state, environments loading. // @STATE ready — Environments loaded, awaiting user configuration. // @STATE loading — Fetching dashboards, databases, or dry-run in progress. @@ -21,11 +22,11 @@ // @ACTION setFixCrossFilters(bool) — Toggles cross-filter fixing. // @ACTION fetchDatabases() — Loads source/target databases, mappings, and AI suggestions. // @ACTION saveMapping(sourceUuid, targetUuid) — Saves a database mapping pair. -// @ACTION calculateDryRun() — Sends dry-run request, stores result, advances to step 3. -// @ACTION executeMigration() — Validates preconditions, sends migration request. -// @ACTION resumeMigration(passwords) — Resumes a paused migration task with passwords. -// @ACTION goToStep(n) — Navigates wizard step, gated by step readiness. -// @ACTION clearError() — Clears the error message. +// @ACTION calculateDryRun() — Delegates to Migration.ExecutorModel. +// @ACTION executeMigration() — Delegates to Migration.ExecutorModel. +// @ACTION resumeMigration(passwords) — Delegates to Migration.ExecutorModel. +// @ACTION goToStep(n) — Delegates to Migration.WizardModel. +// @ACTION clearError() — Delegates to Migration.WizardModel. // @ACTION openLogViewer(task) — Opens the log viewer modal for a task. // @ACTION closeLogViewer() — Closes the log viewer modal. // @ACTION toggleTaskHistory() — Toggles task history panel visibility. @@ -34,18 +35,22 @@ // @RELATION BINDS_TO -> [selectedTask] // @RELATION BINDS_TO -> [environmentContextStore] // @RELATION CALLS -> [EXT:frontend:resumeTask] -// @RATIONALE Svelte 5 class-based model with $state atoms and $derived computed values. Extracted from MigrationPage/+page.svelte to enable L1 testing without DOM render. +// @RELATION DISPATCHES -> [Migration.WizardModel] +// @RELATION DISPATCHES -> [Migration.ExecutorModel] +// @RATIONALE Svelte 5 class-based model with $state atoms and $derived computed values. Extracted from MigrationPage/+page.svelte to enable L1 testing without DOM render. Decomposed into sub-models per @INVARIANT DECOMPOSITION GATE (was 457 lines, now ~290). // @REJECTED Inline $state + handler functions in MigrationPage/+page.svelte rejected — scatters business logic across event handlers, makes testing impossible without DOM, violates RSM pattern. import { api } from "$lib/api.js"; import { selectedTask } from "$lib/stores.svelte.js"; import { environmentContextStore } from "$lib/stores/environmentContext.svelte.js"; -import { resumeTask } from "../../services/taskService.js"; import { t } from "$lib/i18n/index.svelte.js"; +import { WizardState } from "./Migration.WizardModel.svelte.ts"; +import { MigrationExecutor } from "./Migration.ExecutorModel.svelte.ts"; +import type { DryRunResult, DashboardSelection } from "./Migration.ExecutorModel.svelte.ts"; // ── Types ───────────────────────────────────────────────────── -type ScreenState = "idle" | "ready" | "loading" | "review" | "executing" | "error"; +export type ScreenState = "idle" | "ready" | "loading" | "review" | "executing" | "error"; interface Environment { id: string; @@ -73,21 +78,6 @@ interface Mapping { [key: string]: unknown; } -interface DryRunResult { - summary?: Record; - risk?: { score: number; level: string; items: unknown[] }; - selected_dashboard_titles?: string[]; - [key: string]: unknown; -} - -interface DashboardSelection { - selected_ids: string[]; - source_env_id: string; - target_env_id: string; - replace_db_config: boolean; - fix_cross_filters: boolean; -} - interface LogViewerTask { id: string; status: string; @@ -95,10 +85,12 @@ interface LogViewerTask { } export class MigrationModel { - // ── Atoms (reactive state) ────────────────────────────────────── + // ── Sub-models (instantiated after $state fields, before $derived) ─ - // Wizard step - currentStep: number = $state(1); // 1=environments, 2=dashboards, 3=review, 4=execute + wizard: WizardState = new WizardState(this as unknown as MigrationModel); + executor: MigrationExecutor = new MigrationExecutor(this as unknown as MigrationModel); + + // ── Atoms (reactive state) ────────────────────────────────────── // Environments environments: Environment[] = $state([]); @@ -111,7 +103,6 @@ export class MigrationModel { // Dashboards dashboards: Dashboard[] = $state([]); - selectedDashboardIds: string[] = $state([]); // Database mappings sourceDatabases: Database[] = $state([]); @@ -123,10 +114,6 @@ export class MigrationModel { loading: boolean = $state(true); error: string = $state(""); fetchingDbs: boolean = $state(false); - dryRunLoading: boolean = $state(false); - - // Dry-run result - dryRunResult: DryRunResult | null = $state(null); // Task history panel showTaskHistory: boolean = $state(false); @@ -139,15 +126,35 @@ export class MigrationModel { // Migration modal visibility (used by dashboards page) showMigrateModal: boolean = $state(false); - // Password prompt - showPasswordPrompt: boolean = $state(false); - passwordPromptDatabases: string[] = $state([]); - passwordPromptErrorMessage: string = $state(""); - // Store refs (read-only reactive bindings) selectedTaskStore = selectedTask; envContextStore = environmentContextStore; + // ── Proxy Getters / Setters (delegate to sub-models) ───────────── + + get currentStep(): number { return this.wizard.currentStep; } + set currentStep(v: number) { this.wizard.currentStep = v; } + + get selectedDashboardIds(): string[] { return this.executor.selectedDashboardIds; } + set selectedDashboardIds(v: string[]) { this.executor.selectedDashboardIds = v; } + + get dryRunResult(): DryRunResult | null { return this.executor.dryRunResult; } + set dryRunResult(v: DryRunResult | null) { this.executor.dryRunResult = v; } + + get dryRunLoading(): boolean { return this.executor.dryRunLoading; } + set dryRunLoading(v: boolean) { this.executor.dryRunLoading = v; } + + get isCalculating(): boolean { return this.executor.isCalculating; } + + get showPasswordPrompt(): boolean { return this.executor.showPasswordPrompt; } + set showPasswordPrompt(v: boolean) { this.executor.showPasswordPrompt = v; } + + get passwordPromptDatabases(): string[] { return this.executor.passwordPromptDatabases; } + set passwordPromptDatabases(v: string[]) { this.executor.passwordPromptDatabases = v; } + + get passwordPromptErrorMessage(): string { return this.executor.passwordPromptErrorMessage; } + set passwordPromptErrorMessage(v: string) { this.executor.passwordPromptErrorMessage = v; } + // ── Derived ───────────────────────────────────────────────────── /** Whether each wizard step is ready to proceed. */ @@ -173,7 +180,20 @@ export class MigrationModel { /** Whether migration can be executed. */ canExecute: boolean = $derived(this.dryRunResult != null && this.stepReady[2]); - // ── Actions ───────────────────────────────────────────────────── + // ── Delegating Methods ────────────────────────────────────────── + + goToStep(step: number): void { this.wizard.goToStep(step); } + clearError(): void { this.wizard.clearError(); } + async calculateDryRun(): Promise { return this.executor.calculateDryRun(); } + async executeMigration(endpoint: string = "/migration/execute"): Promise { + return this.executor.executeMigration(endpoint); + } + async resumeMigration(passwords: Record): Promise { + return this.executor.resumeMigration(passwords); + } + checkPasswordPrompt(): void { this.executor.checkPasswordPrompt(); } + + // ── Core Actions ──────────────────────────────────────────────── /** Load environments list. Pre-fills source from active context. */ async loadEnvironments(): Promise { @@ -306,76 +326,6 @@ export class MigrationModel { } } - /** Run dry-run analysis. Sets dryRunResult and advances to step 3 on success. */ - async calculateDryRun(): Promise { - if (!this._validatePreconditions()) return; - this.error = ""; - this.dryRunLoading = true; - try { - this.dryRunResult = await api.postApi("/migration/dry-run", this._buildSelection()); - this.currentStep = 3; - } catch (e: unknown) { - this.error = e instanceof Error ? e.message : "Dry-run failed"; - this.dryRunResult = null; - } finally { - this.dryRunLoading = false; - } - } - - /** Execute migration. Submits task and activates task runner view. */ - async executeMigration(endpoint: string = "/migration/execute"): Promise { - if (!this._validatePreconditions()) return; - this.error = ""; - try { - this.dryRunResult = null; - const result = await api.postApi(endpoint, this._buildSelection()); - await new Promise((r) => setTimeout(r, 500)); - try { - const task = await api.getTask(result.task_id); - this.selectedTaskStore.set(task); - } catch { - this.selectedTaskStore.set({ - id: result.task_id, - plugin_id: "superset-migration", - status: "RUNNING", - logs: [], - params: {}, - }); - } - } catch (e: unknown) { - this.error = e instanceof Error ? e.message : "Migration execution failed"; - } - } - - /** Resume a paused migration task with database passwords. */ - async resumeMigration(passwords: Record): Promise { - const task = this.selectedTaskStore.current; - if (!task) return; - try { - await resumeTask(task.id, passwords); - this.showPasswordPrompt = false; - } catch (e: unknown) { - this.passwordPromptErrorMessage = e instanceof Error ? e.message : (t.migration?.resume_failed || "Resume failed"); - } - } - - /** Navigate to a wizard step. Gated by step readiness. */ - goToStep(step: number): void { - if ( - step <= this.currentStep || - (step === 2 && this.stepReady[1]) || - (step === 3 && this.stepReady[1] && this.stepReady[2]) || - (step === 4 && this.dryRunResult) - ) { - this.currentStep = step; - } - } - - /** Clear error message. */ - clearError(): void { - this.error = ""; - } - /** Open log viewer for a task. */ openLogViewer(task: LogViewerTask): void { this.logViewerTaskId = task.id; @@ -395,24 +345,6 @@ export class MigrationModel { this.showTaskHistory = !this.showTaskHistory; } - /** Watch for password-prompt signals from active task. Call in an $effect. */ - checkPasswordPrompt(): void { - const activeTask = this.selectedTaskStore.current; - if ( - !activeTask || - activeTask.status !== "AWAITING_INPUT" || - !activeTask.input_request - ) { - return; - } - const req = activeTask.input_request; - if (req.type === "database_password") { - this.passwordPromptDatabases = req.databases || []; - this.passwordPromptErrorMessage = req.error_message || ""; - this.showPasswordPrompt = true; - } - } - // ── Private ───────────────────────────────────────────────────── /** Fetch dashboards for the given environment. Resets selection. */ diff --git a/frontend/src/lib/models/MigrationSettingsModel.svelte.ts b/frontend/src/lib/models/MigrationSettingsModel.svelte.ts index 71093f00..6c79e94f 100644 --- a/frontend/src/lib/models/MigrationSettingsModel.svelte.ts +++ b/frontend/src/lib/models/MigrationSettingsModel.svelte.ts @@ -1,6 +1,6 @@ // frontend/src/lib/models/MigrationSettingsModel.svelte.ts -// #region MigrationSettingsModel [C:4] [TYPE Model] [SEMANTICS settings,migration,sync,screen-model] -// @ingroup Models +// #region Settings.MigrationSettingsModel [C:4] [TYPE Model] [SEMANTICS settings,migration,sync,screen-model] +// @ingroup Settings // @BRIEF State model for migration sync settings — cron schedule, sync-now, loading/saving/syncing state. // @INVARIANT Settings are loaded before they can be saved or synced. // @INVARIANT Sync-now updates settings (re-fetches cron) on completion. @@ -102,4 +102,4 @@ export class MigrationSettingsModel { } } } -// #endregion MigrationSettingsModel +// #endregion Settings.MigrationSettingsModel diff --git a/frontend/src/lib/models/TranslateHistoryModel.svelte.ts b/frontend/src/lib/models/TranslateHistoryModel.svelte.ts index f3b6b248..d9e29a63 100644 --- a/frontend/src/lib/models/TranslateHistoryModel.svelte.ts +++ b/frontend/src/lib/models/TranslateHistoryModel.svelte.ts @@ -1,5 +1,5 @@ -// #region TranslateHistoryModel [C:4] [TYPE Model] [SEMANTICS translate,history,run,list,pagination,model] -// @ingroup Models +// #region Translate.HistoryModel [C:4] [TYPE Model] [SEMANTICS translate,history,run,list,pagination,model] +// @ingroup Translate // @BRIEF Screen model for translation run history — manages paginated run list, filters, detail panel, metrics, and run actions. // @INVARIANT Changing filter resets pagination to page 1. // @INVARIANT Detail panel opens only when selectedRun is set; closing clears selectedRunDetail. @@ -157,4 +157,4 @@ export class TranslateHistoryModel { }; } } -// #endregion TranslateHistoryModel +// #endregion Translate.HistoryModel diff --git a/frontend/src/lib/models/TranslationJobModel.svelte.ts b/frontend/src/lib/models/TranslationJobModel.svelte.ts index fdcdbbda..afea14db 100644 --- a/frontend/src/lib/models/TranslationJobModel.svelte.ts +++ b/frontend/src/lib/models/TranslationJobModel.svelte.ts @@ -1,5 +1,5 @@ -// #region TranslationJobModel [C:5] [TYPE Model] [SEMANTICS translate,job,config,wizard,datasource,target,run,schedule,model] -// @ingroup Models +// #region Translate.JobModel [C:5] [TYPE Model] [SEMANTICS translate,job,config,wizard,datasource,target,run,schedule,model] +// @ingroup Translate // @BRIEF Full screen model for translation job config wizard — 5 tabs: Config, Preview, Target, Run, Schedule. // @INVARIANT Job must be saved before Target or Schedule tabs are accessible. // @INVARIANT configValid gates Preview and Run availability. @@ -337,4 +337,4 @@ export class TranslationJobModel { } } } -// #endregion TranslationJobModel +// #endregion Translate.JobModel diff --git a/frontend/src/lib/models/ValidationRunDetailModel.svelte.ts b/frontend/src/lib/models/ValidationRunDetailModel.svelte.ts index d3072e5e..5dc86cf2 100644 --- a/frontend/src/lib/models/ValidationRunDetailModel.svelte.ts +++ b/frontend/src/lib/models/ValidationRunDetailModel.svelte.ts @@ -1,5 +1,5 @@ -// #region ValidationRunDetailModel [C:3] [TYPE Model] [SEMANTICS validation,run,detail,dashboard,collapsible,model] -// @ingroup Models +// #region ValidationTasks.RunDetailModel [C:3] [TYPE Model] [SEMANTICS validation,run,detail,dashboard,collapsible,model] +// @ingroup ValidationTasks // @BRIEF Screen model for validation run detail — dashboard-by-dashboard results with screenshots, issues, and task logs. // @INVARIANT Screenshot blob URLs are cached per (dashboardId, tabIndex) key. Revoked when cache key is overwritten. // @ACTION toggleDashboard(id) / toggleLogsSent(key) / toggleTaskLogs(key) — Collapsible section toggles. @@ -205,4 +205,4 @@ export class ValidationRunDetailModel { return { count: matching.length, severity: worst }; } } -// #endregion ValidationRunDetailModel +// #endregion ValidationTasks.RunDetailModel diff --git a/frontend/src/lib/models/ValidationTasksListModel.svelte.ts b/frontend/src/lib/models/ValidationTasksListModel.svelte.ts index 6e1c7197..d06484ab 100644 --- a/frontend/src/lib/models/ValidationTasksListModel.svelte.ts +++ b/frontend/src/lib/models/ValidationTasksListModel.svelte.ts @@ -1,5 +1,5 @@ -// #region ValidationTasksListModel [C:4] [TYPE Model] [SEMANTICS validation,tasks,list,pagination,crud,model] -// @ingroup Models +// #region ValidationTasks.ListModel [C:4] [TYPE Model] [SEMANTICS validation,tasks,list,pagination,crud,model] +// @ingroup ValidationTasks // @BRIEF Screen model for validation tasks list — manages paginated list, search, inline actions (run/delete/toggle), and navigation. // @RATIONALE Svelte 5 $state/$derived model-first pattern. Uses map-based array update instead of self-assignment hack for reactivity. // @INVARIANT Search resets pagination to page 1. @@ -173,4 +173,4 @@ export class ValidationTasksListModel { destroy(): void { this.isMounted = false; } } -// #endregion ValidationTasksListModel +// #endregion ValidationTasks.ListModel diff --git a/frontend/src/lib/models/__tests__/DashboardDetailModel.test.ts b/frontend/src/lib/models/__tests__/DashboardDetailModel.test.ts index 09bcf747..37ab5e5a 100644 --- a/frontend/src/lib/models/__tests__/DashboardDetailModel.test.ts +++ b/frontend/src/lib/models/__tests__/DashboardDetailModel.test.ts @@ -1,6 +1,6 @@ // #region DashboardDetailModelTests [C:3] [TYPE Module] [SEMANTICS test,model,dashboard-detail] // @BRIEF L1 unit tests for DashboardDetailModel — no DOM render, mocks API + toast + i18n + navigation. -// @RELATION BINDS_TO -> [DashboardDetailModel] +// @RELATION BINDS_TO -> [Dashboards.DetailModel] // @TEST_INVARIANT: load-gate -> VERIFIED_BY: [test_load_guarded_by_context, test_load_detail_guarded] // @TEST_INVARIANT: thumbnail-cleanup -> VERIFIED_BY: [test_cleanup_releases_thumbnail, test_load_thumbnail_releases_previous] // @TEST_EDGE: missing_field -> dashboardRef not set on load diff --git a/frontend/src/lib/models/__tests__/DashboardHubModel.test.ts b/frontend/src/lib/models/__tests__/DashboardHubModel.test.ts index ef12a5aa..7c7236c6 100644 --- a/frontend/src/lib/models/__tests__/DashboardHubModel.test.ts +++ b/frontend/src/lib/models/__tests__/DashboardHubModel.test.ts @@ -1,6 +1,6 @@ // #region DashboardHubModelTests [C:3] [TYPE Module] [SEMANTICS test,model,dashboard-hub] // @BRIEF L1 unit tests for DashboardHubModel — no DOM render, mocks API, git, toast, i18n. -// @RELATION BINDS_TO -> [DashboardHubModel] +// @RELATION BINDS_TO -> [Dashboards.HubModel] // @TEST_INVARIANT: filter-resets-pagination -> VERIFIED_BY: [test_toggle_filter_resets_page, test_clear_filter_resets_page] // @TEST_INVARIANT: selection-cleaned-on-load -> VERIFIED_BY: [test_selection_cleaned_after_load] // @TEST_INVARIANT: clear-selected-ids -> VERIFIED_BY: [test_clear_selected_ids] diff --git a/frontend/src/lib/models/__tests__/DatasetsHubModel.test.ts b/frontend/src/lib/models/__tests__/DatasetsHubModel.test.ts index 8e4a5cab..41d956c4 100644 --- a/frontend/src/lib/models/__tests__/DatasetsHubModel.test.ts +++ b/frontend/src/lib/models/__tests__/DatasetsHubModel.test.ts @@ -1,6 +1,6 @@ // #region DatasetsHubModelTests [C:3] [TYPE Module] [SEMANTICS test,model,datasets-hub] // @BRIEF L1 unit tests for DatasetsHubModel — no DOM render, mocks API. -// @RELATION BINDS_TO -> [DatasetsHubModel] +// @RELATION BINDS_TO -> [Datasets.HubModel] // @TEST_INVARIANT: filter-resets-pagination -> VERIFIED_BY: [test_set_filter_resets_page, test_set_search_resets_page] // @TEST_INVARIANT: detail-panel-gate -> VERIFIED_BY: [test_detail_open_close, test_select_dataset_loads_detail] // @TEST_INVARIANT: selection-cleaned-on-load -> VERIFIED_BY: [test_selection_cleaned_after_load] diff --git a/frontend/src/routes/admin/roles/+page.svelte b/frontend/src/routes/admin/roles/+page.svelte index 2950815c..43007626 100644 --- a/frontend/src/routes/admin/roles/+page.svelte +++ b/frontend/src/routes/admin/roles/+page.svelte @@ -18,6 +18,7 @@ // [SECTION: IMPORTS] import { onMount } from 'svelte'; import { t } from '$lib/i18n/index.svelte.js'; + import { Button } from '$lib/ui'; import ProtectedRoute from '$lib/components/auth/ProtectedRoute.svelte'; import { adminService } from '../../../services/adminService'; // [/SECTION: IMPORTS] @@ -152,12 +153,12 @@

{$t.admin.roles.title}

- +
{#if loading} @@ -190,8 +191,8 @@
- - + + {/each} @@ -232,8 +233,8 @@

{$t.admin.roles.permissions_hint}

- - + +
diff --git a/frontend/src/routes/admin/users/+page.svelte b/frontend/src/routes/admin/users/+page.svelte index 0818680f..9f264e64 100644 --- a/frontend/src/routes/admin/users/+page.svelte +++ b/frontend/src/routes/admin/users/+page.svelte @@ -22,6 +22,7 @@ // [SECTION: IMPORTS] import { onMount } from 'svelte'; import { t } from '$lib/i18n/index.svelte.js'; + import { Button } from '$lib/ui'; import ProtectedRoute from '$lib/components/auth/ProtectedRoute.svelte'; import { adminService } from '../../../services/adminService'; // [/SECTION: IMPORTS] @@ -171,12 +172,12 @@

{$t.admin.users.title}

- +
{#if loading} @@ -227,14 +228,14 @@ - - + + {/each} @@ -284,8 +285,8 @@

{$t.admin.users.roles_hint}

- - + +
diff --git a/frontend/src/routes/dashboards/BackupDashboardModal.svelte b/frontend/src/routes/dashboards/BackupDashboardModal.svelte index 21648c88..2a905b27 100644 --- a/frontend/src/routes/dashboards/BackupDashboardModal.svelte +++ b/frontend/src/routes/dashboards/BackupDashboardModal.svelte @@ -5,6 +5,7 @@