fix(frontend): full ADR compliance — Model-View concept, model decomposition, button migration
P0 — Model-first ADR compliance:
- Decompose DashboardHubModel (590→496 lines) into Dashboards.FiltersModel,
Dashboards.SelectionModel, Dashboards.GitActionsModel (DG split per plan)
- Decompose AgentChatModel (630→356 lines) into ConnectionManager,
StreamProcessor, LocalStorage, shared types
- Decompose MigrationModel (457→389 lines) into WizardModel, ExecutorModel
P0 — /ui atom compliance:
- Replace all raw <button> with <Button> from /ui in dashboards/+page.svelte
(~20 replacements) and 16 additional routes/ files (~70 replacements total)
P0 — Hierarchical region IDs (ATTN_2):
- Rename all 22 model #region/#endregion IDs from flat to Domain.Name format
- Update @ingroup from generic 'Models' to domain-specific (Dashboards, Git, etc.)
P1 — UX contract compliance:
- Add @UX_STATE declarations to agent/+page.svelte
- Extract Gradio Client.connect from page into AgentChatModel.retryConnection()
All new model files have proper GRACE anchors (#region/#endregion, @ingroup,
@BRIEF, @INVARIANT, @STATE, @ACTION, @RELATION).
Build: npm run build passes.
Tests: DashboardHubModel 112/112, MigrationModel 74/74 pass.
This commit is contained in:
107
frontend/src/lib/models/AgentChat.ConnectionManager.svelte.ts
Normal file
107
frontend/src/lib/models/AgentChat.ConnectionManager.svelte.ts
Normal file
@@ -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<typeof setInterval> | null = null;
|
||||
|
||||
constructor(private cb: ConnectionManagerCallbacks) {}
|
||||
|
||||
// ── Public actions ──────────────────────────────────────────────
|
||||
|
||||
async retryConnection(): Promise<void> {
|
||||
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
|
||||
56
frontend/src/lib/models/AgentChat.LocalStorage.ts
Normal file
56
frontend/src/lib/models/AgentChat.LocalStorage.ts
Normal file
@@ -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
|
||||
189
frontend/src/lib/models/AgentChat.StreamProcessor.svelte.ts
Normal file
189
frontend/src/lib/models/AgentChat.StreamProcessor.svelte.ts
Normal file
@@ -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<GradioClient["submit"]>,
|
||||
convId: string | null,
|
||||
): Promise<true> {
|
||||
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<false> {
|
||||
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<string, unknown>;
|
||||
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
|
||||
@@ -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<string, unknown>;
|
||||
output?: Record<string, unknown>;
|
||||
error?: string;
|
||||
prompt?: string;
|
||||
thread_id?: string;
|
||||
result?: "confirmed" | "denied";
|
||||
code?: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
interface ToolCall {
|
||||
tool: string;
|
||||
input: Record<string, unknown>;
|
||||
output?: Record<string, unknown>;
|
||||
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<GradioClient["submit"]> | 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<typeof setInterval> | 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string, unknown>) => ({ 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<string, unknown>) => ({ id: item.conversation_id ?? item.id ?? "", title: item.title ?? "", updated_at: item.updated_at ?? "", message_count: item.message_count ?? 0 }))];
|
||||
? items.map((item: Record<string, unknown>) => ({
|
||||
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<string, unknown>) => ({
|
||||
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<string, unknown>) => ({
|
||||
const res = await getAssistantConversations(1, 20, false, query);
|
||||
this.conversations = (res.items || []).map((item: Record<string, unknown>) => ({
|
||||
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<string, unknown>) => ({
|
||||
id: (msg.message_id as string) ?? msg.id as string ?? "",
|
||||
this.messages = (res.items || []).map((msg: Record<string, unknown>) => ({
|
||||
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<void> {
|
||||
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<void> {
|
||||
// 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<GradioClient["submit"]>, convId: string | null): Promise<true> {
|
||||
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<string, unknown>;
|
||||
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<false> {
|
||||
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
|
||||
|
||||
61
frontend/src/lib/models/AgentChatTypes.ts
Normal file
61
frontend/src/lib/models/AgentChatTypes.ts
Normal file
@@ -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<string, unknown>;
|
||||
output?: Record<string, unknown>;
|
||||
error?: string;
|
||||
prompt?: string;
|
||||
thread_id?: string;
|
||||
result?: "confirmed" | "denied";
|
||||
code?: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface ToolCall {
|
||||
tool: string;
|
||||
input: Record<string, unknown>;
|
||||
output?: Record<string, unknown>;
|
||||
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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<FilterColumn, Set<string>>;
|
||||
type ColumnFilterSearch = Record<FilterColumn, string>;
|
||||
|
||||
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<string> = $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<string, any> = $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<string> = $state(new SvelteSet());
|
||||
gitResolvedIds: Set<string> = $state(new SvelteSet());
|
||||
gitLoadingIds: Set<string> = $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<string>): 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<any[]> { 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<void> {
|
||||
await this.gitActions.fetchDashboardGitStatusesBatch(dashboardIds, force);
|
||||
}
|
||||
|
||||
async refreshDashboardGitState(dashboardId: string, force = false): Promise<void> {
|
||||
await this.fetchDashboardGitStatusesBatch([dashboardId], force);
|
||||
}
|
||||
|
||||
async hydrateVisibleGitStatusesBatch(): Promise<void> {
|
||||
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<void> {
|
||||
await this.gitActions.handleGitInit(dashboard);
|
||||
}
|
||||
|
||||
async handleGitSync(dashboard: any): Promise<void> {
|
||||
await this.gitActions.handleGitSync(dashboard, this.selectedEnv);
|
||||
}
|
||||
|
||||
async handleGitCommit(dashboard: any): Promise<void> {
|
||||
await this.gitActions.handleGitCommit(dashboard, this.selectedEnv);
|
||||
}
|
||||
|
||||
async handleGitPull(dashboard: any): Promise<void> {
|
||||
await this.gitActions.handleGitPull(dashboard, this.selectedEnv);
|
||||
}
|
||||
|
||||
async handleGitPush(dashboard: any): Promise<void> {
|
||||
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<void> {
|
||||
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<string>): 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<any[]> {
|
||||
if (this.cachedGitConfigs.length > 0) return this.cachedGitConfigs;
|
||||
this.cachedGitConfigs = await gitService.getConfigs();
|
||||
return this.cachedGitConfigs;
|
||||
}
|
||||
|
||||
updateDashboardGitState(dashboardId: string, nextGit: Partial<GitState>): 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<void> {
|
||||
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<void> { await this.fetchDashboardGitStatusesBatch([dashboardId], force); }
|
||||
|
||||
async hydrateVisibleGitStatusesBatch(): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
// ══ Actions (parent-owned) ════════════════════════════════════
|
||||
|
||||
async handleAction(dashboard: any, action: string): Promise<void> {
|
||||
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
|
||||
|
||||
210
frontend/src/lib/models/Dashboards.FiltersModel.svelte.ts
Normal file
210
frontend/src/lib/models/Dashboards.FiltersModel.svelte.ts
Normal file
@@ -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<FilterColumn, Set<string>>;
|
||||
export type ColumnFilterSearch = Record<FilterColumn, string>;
|
||||
|
||||
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, any>): string {
|
||||
const vs = validationStatuses[dashboard.id];
|
||||
return (!vs || !vs.status) ? "unknown" : String(vs.status).toLowerCase();
|
||||
}
|
||||
|
||||
getColumnCellValue(dashboard: DashboardRow, column: FilterColumn, validationStatuses: Record<string, any>): 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, any>): 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, any>): 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<string, any>): 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
|
||||
176
frontend/src/lib/models/Dashboards.GitActionsModel.svelte.ts
Normal file
176
frontend/src/lib/models/Dashboards.GitActionsModel.svelte.ts
Normal file
@@ -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<string> = $state(new SvelteSet());
|
||||
gitResolvedIds: Set<string> = $state(new SvelteSet());
|
||||
gitLoadingIds: Set<string> = $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<GitState>) => 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<any[]> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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
|
||||
67
frontend/src/lib/models/Dashboards.SelectionModel.svelte.ts
Normal file
67
frontend/src/lib/models/Dashboards.SelectionModel.svelte.ts
Normal file
@@ -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<string> = $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<string>): 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
136
frontend/src/lib/models/Migration.ExecutorModel.svelte.ts
Normal file
136
frontend/src/lib/models/Migration.ExecutorModel.svelte.ts
Normal file
@@ -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<string, unknown>;
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string, string>): Promise<void> {
|
||||
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
|
||||
42
frontend/src/lib/models/Migration.WizardModel.svelte.ts
Normal file
42
frontend/src/lib/models/Migration.WizardModel.svelte.ts
Normal file
@@ -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
|
||||
@@ -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<string, unknown>;
|
||||
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<void> { return this.executor.calculateDryRun(); }
|
||||
async executeMigration(endpoint: string = "/migration/execute"): Promise<void> {
|
||||
return this.executor.executeMigration(endpoint);
|
||||
}
|
||||
async resumeMigration(passwords: Record<string, string>): Promise<void> {
|
||||
return this.executor.resumeMigration(passwords);
|
||||
}
|
||||
checkPasswordPrompt(): void { this.executor.checkPasswordPrompt(); }
|
||||
|
||||
// ── Core Actions ────────────────────────────────────────────────
|
||||
|
||||
/** Load environments list. Pre-fills source from active context. */
|
||||
async loadEnvironments(): Promise<void> {
|
||||
@@ -306,76 +326,6 @@ export class MigrationModel {
|
||||
}
|
||||
}
|
||||
|
||||
/** Run dry-run analysis. Sets dryRunResult and advances to step 3 on success. */
|
||||
async calculateDryRun(): Promise<void> {
|
||||
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<void> {
|
||||
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<string, string>): Promise<void> {
|
||||
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. */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 @@
|
||||
<div class="container mx-auto p-4">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold">{$t.admin.roles.title}</h1>
|
||||
<button
|
||||
class="bg-primary text-white px-4 py-2 rounded hover:bg-primary-hover"
|
||||
<Button
|
||||
variant="primary"
|
||||
onclick={openCreateModal}
|
||||
>
|
||||
{$t.admin.roles.create}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
@@ -190,8 +191,8 @@
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<button onclick={() => openEditModal(role)} class="text-primary hover:text-primary mr-3">{$t.common.edit}</button>
|
||||
<button onclick={() => handleDeleteRole(role)} class="text-destructive hover:text-destructive">{$t.common.delete}</button>
|
||||
<Button onclick={() => openEditModal(role)} variant="ghost" size="sm" class="text-primary mr-3">{$t.common.edit}</Button>
|
||||
<Button onclick={() => handleDeleteRole(role)} variant="ghost" size="sm" class="text-destructive">{$t.common.delete}</Button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
@@ -232,8 +233,8 @@
|
||||
<p class="text-xs text-text-muted mt-2">{$t.admin.roles.permissions_hint}</p>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 pt-4 border-t">
|
||||
<button type="button" class="px-4 py-2 text-text-muted" onclick={() => showModal = false}>{$t.common.cancel}</button>
|
||||
<button type="submit" class="px-4 py-2 bg-primary text-white rounded hover:bg-primary-hover">{$t.common.save}</button>
|
||||
<Button type="button" variant="ghost" onclick={() => showModal = false} class="px-4 py-2 text-text-muted">{$t.common.cancel}</Button>
|
||||
<Button type="submit" variant="primary">{$t.common.save}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -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 @@
|
||||
<div class="container mx-auto p-4">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold">{$t.admin.users.title}</h1>
|
||||
<button
|
||||
class="bg-primary text-white px-4 py-2 rounded hover:bg-primary-hover transition-colors"
|
||||
<Button
|
||||
variant="primary"
|
||||
onclick={openCreateModal}
|
||||
>
|
||||
{$t.admin.users.create}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
@@ -227,14 +228,14 @@
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<button onclick={() => openEditModal(user)} class="text-primary hover:text-primary mr-3" disabled={deletingUserId === user.id}>{$t.common.edit}</button>
|
||||
<button
|
||||
<Button onclick={() => openEditModal(user)} variant="ghost" size="sm" class="text-primary mr-3" disabled={deletingUserId === user.id}>{$t.common.edit}</Button>
|
||||
<Button
|
||||
onclick={() => handleDeleteUser(user)}
|
||||
class="text-destructive hover:text-destructive disabled:opacity-50"
|
||||
variant="ghost" size="sm" class="text-destructive"
|
||||
disabled={deletingUserId === user.id}
|
||||
>
|
||||
{deletingUserId === user.id ? ($t.common.deleting ) : $t.common.delete}
|
||||
</button>
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
@@ -284,8 +285,8 @@
|
||||
<p class="text-xs text-text-muted mt-1">{$t.admin.users.roles_hint}</p>
|
||||
</div>
|
||||
<div class="flex justify-end gap-3 pt-4 border-t">
|
||||
<button type="button" class="px-4 py-2 text-text-muted hover:text-text font-medium" onclick={() => showModal = false}>{$t.common.cancel}</button>
|
||||
<button type="submit" class="px-6 py-2 bg-primary text-white rounded font-bold hover:bg-primary-hover shadow-md transition-colors">{$t.common.save}</button>
|
||||
<Button type="button" variant="ghost" onclick={() => showModal = false} class="px-4 py-2 text-text-muted hover:text-text font-medium">{$t.common.cancel}</Button>
|
||||
<Button type="submit" variant="primary" class="px-6 py-2 font-bold shadow-md">{$t.common.save}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<!-- @RELATION DEPENDS_ON -> [DashboardHubModel] -->
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n/index.svelte.js';
|
||||
import { Button } from '$lib/ui';
|
||||
import type { DashboardHubModel } from '$lib/models/DashboardHubModel.svelte';
|
||||
|
||||
let { model, environments = [] }: {
|
||||
@@ -33,16 +34,12 @@
|
||||
<h2 id="backup-modal-title" class="text-xl font-bold">
|
||||
{($t.dashboard?.backup_modal_title ?? 'Backup').replace('{count}', String(model.selectedIds.size))}
|
||||
</h2>
|
||||
<button
|
||||
onclick={() => (model.showBackupModal = false)}
|
||||
class="absolute top-4 right-4 p-2 text-text-subtle hover:text-text-muted hover:bg-surface-muted rounded-full transition-all"
|
||||
aria-label={$t.common?.close_modal}
|
||||
>
|
||||
<Button variant="ghost" onclick={() => (model.showBackupModal = false)} class="absolute top-4 right-4 p-2 text-text-subtle hover:text-text-muted hover:bg-surface-muted rounded-full transition-all" aria-label={$t.common?.close_modal}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="px-6 py-4">
|
||||
<div class="space-y-6">
|
||||
@@ -85,7 +82,7 @@
|
||||
<div class="ml-6">
|
||||
<label for="cron-expression" class="block text-xs text-text-muted mb-1">{$t.dashboard?.cron_expression}</label>
|
||||
<input id="cron-expression" type="text" class="search-input w-full text-sm" placeholder={$t.dashboard?.cron_placeholder || '0 2 * * * (daily at 2 AM)'} bind:value={model.backupSchedule} />
|
||||
<button class="text-xs text-primary hover:underline mt-1">{$t.dashboard?.cron_help}</button>
|
||||
<Button variant="ghost" size="sm" class="text-xs text-primary hover:underline mt-1">{$t.dashboard?.cron_help}</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -93,14 +90,14 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-6 py-4 border-t border-border flex justify-end gap-3">
|
||||
<button class="px-3 py-1 text-sm border border-border-strong rounded hover:bg-surface-muted transition-colors"
|
||||
<Button variant="secondary" size="sm"
|
||||
onclick={() => (model.showBackupModal = false)}
|
||||
disabled={model.isSubmittingBackup}>{$t.common?.cancel}</button>
|
||||
<button class="px-3 py-1 text-sm bg-primary text-white border-primary rounded hover:bg-primary-hover transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={model.isSubmittingBackup}>{$t.common?.cancel}</Button>
|
||||
<Button variant="primary" size="sm"
|
||||
onclick={() => model.handleBulkBackup()}
|
||||
disabled={model.selectedIds.size === 0 || model.isSubmittingBackup}>
|
||||
{model.isSubmittingBackup ? $t.dashboard?.starting : $t.dashboard?.start_backup}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<!-- @UX_STATE Open -> Popover visible at positioned coordinates with search + options. -->
|
||||
<script lang="ts">
|
||||
import { t } from "$lib/i18n/index.svelte.js";
|
||||
import { Button } from "$lib/ui";
|
||||
|
||||
let {
|
||||
filterValues = $bindable(new Set<string>()),
|
||||
@@ -42,13 +43,13 @@
|
||||
bind:value={searchText}
|
||||
/>
|
||||
<div class="mt-2 flex items-center justify-between text-xs">
|
||||
<button class="text-primary hover:underline" onclick={() => {
|
||||
<Button variant="ghost" size="sm" onclick={() => {
|
||||
// Select all currently visible options
|
||||
for (const v of options) filterValues.add(v);
|
||||
}}>{$t.dashboard?.select_all}</button>
|
||||
<button class="text-text-muted hover:underline" onclick={() => onClear?.()}>
|
||||
}}>{$t.dashboard?.select_all}</Button>
|
||||
<Button variant="ghost" size="sm" onclick={() => onClear?.()}>
|
||||
{$t.common?.clear || "Clear"}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="mt-2 max-h-44 overflow-auto space-y-1 text-xs">
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<!-- @RELATION DEPENDS_ON -> [MappingTable] -->
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n/index.svelte.js';
|
||||
import { Button } from '$lib/ui';
|
||||
import { openDrawerForTaskIfPreferred } from '$lib/stores/taskDrawer.svelte.js';
|
||||
import MappingTable from '$lib/components/ui/MappingTable.svelte';
|
||||
import type { DashboardHubModel } from '$lib/models/DashboardHubModel.svelte';
|
||||
@@ -36,16 +37,12 @@
|
||||
<h2 id="migrate-modal-title" class="text-xl font-bold">
|
||||
{($t.dashboard?.migrate_modal_title ?? 'Migrate').replace('{count}', String(model.selectedIds.size))}
|
||||
</h2>
|
||||
<button
|
||||
onclick={() => (model.showMigrateModal = false)}
|
||||
class="absolute top-4 right-4 p-2 text-text-subtle hover:text-text-muted hover:bg-surface-muted rounded-full transition-all"
|
||||
aria-label={$t.common?.close_modal}
|
||||
>
|
||||
<Button variant="ghost" onclick={() => (model.showMigrateModal = false)} class="absolute top-4 right-4 p-2 text-text-subtle hover:text-text-muted hover:bg-surface-muted rounded-full transition-all" aria-label={$t.common?.close_modal}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="px-6 py-4">
|
||||
<div class="space-y-6">
|
||||
@@ -94,9 +91,9 @@
|
||||
{#if model.bulkMigrateModel.replaceDb}
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
{#if model.bulkMigrateModel.suggestions.length > 0}
|
||||
<button class="text-sm text-primary hover:underline" onclick={() => (model.isEditingMappings = !model.isEditingMappings)}>
|
||||
<Button variant="ghost" size="sm" class="text-sm text-primary hover:underline" onclick={() => (model.isEditingMappings = !model.isEditingMappings)}>
|
||||
{model.isEditingMappings ? $t.dashboard?.view_summary : $t.dashboard?.edit_mappings}
|
||||
</button>
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -209,10 +206,10 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-6 py-4 border-t border-border flex justify-end gap-3">
|
||||
<button class="px-3 py-1 text-sm border border-border-strong rounded hover:bg-surface-muted transition-colors"
|
||||
<Button variant="secondary" size="sm"
|
||||
onclick={() => (model.showMigrateModal = false)}
|
||||
disabled={model.bulkMigrateModel.dryRunLoading || model.bulkMigrateModel.currentStep >= 4}>{$t.common?.cancel}</button>
|
||||
<button class="px-3 py-1 text-sm bg-primary text-white border-primary rounded hover:bg-primary-hover transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={model.bulkMigrateModel.dryRunLoading || model.bulkMigrateModel.currentStep >= 4}>{$t.common?.cancel}</Button>
|
||||
<Button variant="primary" size="sm"
|
||||
onclick={async () => {
|
||||
if (!model.bulkMigrateModel.canExecute) return;
|
||||
model.bulkMigrateModel.goToStep(4);
|
||||
@@ -227,7 +224,7 @@
|
||||
}}
|
||||
disabled={!model.bulkMigrateModel.canExecute || model.bulkMigrateModel.dryRunLoading || model.bulkMigrateModel.currentStep >= 4}>
|
||||
{model.bulkMigrateModel.currentStep >= 4 ? $t.dashboard?.starting : $t.migration?.start}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<!-- @UX_REACTIVITY Props -> datasets, selectedIds, isLoading, error, total, page, totalPages, pageSize, onselect, onaction, onpagechange, onsearch, oncheckbox -->
|
||||
<script lang="ts">
|
||||
import { t } from "$lib/i18n/index.svelte.js";
|
||||
import { Button } from "$lib/ui";
|
||||
|
||||
let {
|
||||
datasets = [],
|
||||
@@ -64,16 +65,16 @@
|
||||
oninput={handleSearchInput}
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="px-2 py-1 text-xs border border-border-strong rounded hover:bg-surface-muted"
|
||||
<Button
|
||||
variant="secondary" size="sm"
|
||||
title={$t.datasets?.select_all_tip}
|
||||
onclick={() => { selectedIds = new Set(datasets.map(d => d.id)); oncheckbox?.({}, 'all'); }}
|
||||
>{$t.datasets?.select_all}</button>
|
||||
<button
|
||||
class="px-2 py-1 text-xs border border-border-strong rounded hover:bg-surface-muted"
|
||||
>{$t.datasets?.select_all}</Button>
|
||||
<Button
|
||||
variant="secondary" size="sm"
|
||||
title={$t.datasets?.deselect_all_tip}
|
||||
onclick={() => { selectedIds = new Set(); oncheckbox?.({}, 'clear-all'); }}
|
||||
>{$t.datasets?.deselect_all}</button>
|
||||
>{$t.datasets?.deselect_all}</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -139,11 +140,7 @@
|
||||
</div>
|
||||
<!-- Quick actions (icon-only) -->
|
||||
<div class="flex gap-1 shrink-0 items-start">
|
||||
<button
|
||||
class="p-1 border border-border-strong text-text-subtle rounded hover:bg-surface-muted hover:text-text-muted transition-colors"
|
||||
title={$t.datasets?.map_columns_tip}
|
||||
onclick={(e) => { e.stopPropagation(); onaction?.(dataset, 'map_columns'); }}
|
||||
>
|
||||
<Button variant="ghost" size="sm" class="p-1 text-text-subtle" title={$t.datasets?.map_columns_tip} onclick={(e) => { e.stopPropagation(); onaction?.(dataset, 'map_columns'); }}>
|
||||
<!-- columns/grid icon -->
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1"/>
|
||||
@@ -151,12 +148,8 @@
|
||||
<rect x="3" y="14" width="7" height="7" rx="1"/>
|
||||
<rect x="14" y="14" width="7" height="7" rx="1"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="p-1 border border-border-strong text-text-subtle rounded hover:bg-surface-muted hover:text-text-muted transition-colors"
|
||||
title={$t.datasets?.generate_docs_tip}
|
||||
onclick={(e) => { e.stopPropagation(); onaction?.(dataset, 'generate_docs'); }}
|
||||
>
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" class="p-1 text-text-subtle" title={$t.datasets?.generate_docs_tip} onclick={(e) => { e.stopPropagation(); onaction?.(dataset, 'generate_docs'); }}>
|
||||
<!-- file/text icon -->
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
@@ -165,7 +158,7 @@
|
||||
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||
<polyline points="10 9 9 9 8 9"/>
|
||||
</svg>
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -179,11 +172,11 @@
|
||||
{($t.datasets?.showing ?? 'Showing {start}-{end} of {total}').replace('{start}', String((page - 1) * pageSize + 1)).replace('{end}', String(Math.min(page * pageSize, total))).replace('{total}', String(total))}
|
||||
</span>
|
||||
<div class="flex gap-1">
|
||||
<button class="px-2 py-1 text-xs border rounded {page <= 1 ? 'opacity-50 cursor-not-allowed' : 'hover:bg-surface-muted'}" title={$t.datasets?.pagination_first_tip} onclick={() => onpagechange?.(1)} disabled={page <= 1}>«</button>
|
||||
<button class="px-2 py-1 text-xs border rounded {page <= 1 ? 'opacity-50 cursor-not-allowed' : 'hover:bg-surface-muted'}" title={$t.datasets?.pagination_prev_tip} onclick={() => onpagechange?.(page - 1)} disabled={page <= 1}>‹</button>
|
||||
<Button variant="secondary" size="sm" class="px-2 py-1" title={$t.datasets?.pagination_first_tip} onclick={() => onpagechange?.(1)} disabled={page <= 1}>«</Button>
|
||||
<Button variant="secondary" size="sm" class="px-2 py-1" title={$t.datasets?.pagination_prev_tip} onclick={() => onpagechange?.(page - 1)} disabled={page <= 1}>‹</Button>
|
||||
<span class="px-2 py-1 text-xs">{page} / {totalPages}</span>
|
||||
<button class="px-2 py-1 text-xs border rounded {page >= totalPages ? 'opacity-50 cursor-not-allowed' : 'hover:bg-surface-muted'}" title={$t.datasets?.pagination_next_tip} onclick={() => onpagechange?.(page + 1)} disabled={page >= totalPages}>›</button>
|
||||
<button class="px-2 py-1 text-xs border rounded {page >= totalPages ? 'opacity-50 cursor-not-allowed' : 'hover:bg-surface-muted'}" title={$t.datasets?.pagination_last_tip} onclick={() => onpagechange?.(totalPages)} disabled={page >= totalPages}>»</button>
|
||||
<Button variant="secondary" size="sm" class="px-2 py-1" title={$t.datasets?.pagination_next_tip} onclick={() => onpagechange?.(page + 1)} disabled={page >= totalPages}>›</Button>
|
||||
<Button variant="secondary" size="sm" class="px-2 py-1" title={$t.datasets?.pagination_last_tip} onclick={() => onpagechange?.(totalPages)} disabled={page >= totalPages}>»</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<!-- @UX_STATE Error -> Error banner with retry. -->
|
||||
<script lang="ts">
|
||||
import { t } from "$lib/i18n/index.svelte.js";
|
||||
import { Button } from "$lib/ui";
|
||||
import { ROUTES } from "$lib/routes.js";
|
||||
import ColumnsTable from "./ColumnsTable.svelte";
|
||||
import MetricsTable from "./MetricsTable.svelte";
|
||||
@@ -31,9 +32,9 @@
|
||||
<div class="p-6 flex flex-col items-center justify-center gap-3" role="alert">
|
||||
<div class="bg-destructive-light border border-destructive-ring text-destructive px-4 py-3 rounded text-sm">{error}</div>
|
||||
{#if onretry}
|
||||
<button class="px-3 py-1 text-sm bg-primary text-white rounded hover:bg-primary-hover" onclick={onretry}>
|
||||
<Button variant="primary" size="sm" onclick={onretry}>
|
||||
{$t.common?.retry ?? 'Retry'}
|
||||
</button>
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if !dataset}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/state';
|
||||
import { t } from '$lib/i18n/index.svelte.js';
|
||||
import { Button } from '$lib/ui';
|
||||
import { DatasetDetailModel } from '$lib/models/DatasetDetailModel.svelte';
|
||||
|
||||
let datasetId = $derived(page.params.id);
|
||||
@@ -32,15 +33,12 @@
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<button
|
||||
class="flex items-center gap-2 text-text-muted hover:text-text transition-colors"
|
||||
onclick={() => model.goBack()}
|
||||
>
|
||||
<Button variant="ghost" class="flex items-center gap-2 text-text-muted hover:text-text" onclick={() => model.goBack()}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M19 12H5M12 19l-7-7 7-7" />
|
||||
</svg>
|
||||
{$t.common?.back}
|
||||
</button>
|
||||
</Button>
|
||||
{#if model.dataset}
|
||||
<h1 class="text-2xl font-bold text-text mt-4">{model.dataset.table_name}</h1>
|
||||
<p class="text-sm text-text-muted mt-1">{model.dataset.schema} • {model.dataset.database}</p>
|
||||
@@ -48,21 +46,18 @@
|
||||
<h1 class="text-2xl font-bold text-text mt-4">{$t.datasets?.detail_title}</h1>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
class="px-4 py-2 bg-destructive text-white rounded hover:bg-destructive-hover transition-colors"
|
||||
onclick={() => model.loadDatasetDetail()}
|
||||
>
|
||||
<Button variant="destructive" onclick={() => model.loadDatasetDetail()}>
|
||||
{$t.common?.refresh}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Error Banner -->
|
||||
{#if model.error}
|
||||
<div class="bg-destructive-light border border-destructive-ring text-destructive px-4 py-3 rounded mb-4 flex items-center justify-between">
|
||||
<span>{model.error}</span>
|
||||
<button class="px-4 py-2 bg-destructive text-white rounded hover:bg-destructive-hover transition-colors" onclick={() => model.loadDatasetDetail()}>
|
||||
<Button variant="destructive" onclick={() => model.loadDatasetDetail()}>
|
||||
{$t.common?.retry}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -141,8 +136,7 @@
|
||||
<h2 class="text-lg font-semibold text-text mb-4">{$t.datasets?.linked_dashboards} ({model.linkedDashCount})</h2>
|
||||
<div class="space-y-2">
|
||||
{#each model.dataset.linked_dashboards as dashboard}
|
||||
<button type="button"
|
||||
class="flex w-full items-center gap-3 p-3 border border-border rounded-lg hover:bg-surface-muted transition-colors text-left"
|
||||
<Button type="button" variant="ghost" class="flex w-full items-center gap-3 p-3 border border-border rounded-lg hover:bg-surface-muted transition-colors text-left"
|
||||
onclick={() => model.navigateToDashboard(dashboard)}>
|
||||
<div class="w-8 h-8 bg-primary-light rounded-lg flex items-center justify-center text-primary">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
@@ -158,7 +152,7 @@
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-text-subtle">
|
||||
<path d="M9 18l6-6-6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
import { page } from "$app/state";
|
||||
import { goto } from "$app/navigation";
|
||||
import { t } from "$lib/i18n/index.svelte.js";
|
||||
import { Button } from "$lib/ui";
|
||||
import SourceIntakePanel from "$lib/components/dataset-review/SourceIntakePanel.svelte";
|
||||
import ValidationFindingsPanel from "$lib/components/dataset-review/ValidationFindingsPanel.svelte";
|
||||
import SemanticLayerReview from "$lib/components/dataset-review/SemanticLayerReview.svelte";
|
||||
@@ -232,7 +233,7 @@
|
||||
</div>
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
{#each m.importedFilters.slice(0, 6) as filter}
|
||||
<button type="button" class="group inline-flex items-center gap-1.5 rounded-lg border border-border bg-surface-card px-2.5 py-1.5 text-xs text-text transition hover:border-primary-ring hover:bg-primary-light" onclick={() => handleAskAiContext({ target: `filter:${filter.filter_id}`, section: "filters", label: filter.display_name || filter.filter_name, prompt: `${filter.display_name || filter.filter_name}: ${stringifyValue($t, filter.normalized_value ?? filter.raw_value)}` })}>
|
||||
<Button variant="ghost" class="group inline-flex items-center gap-1.5 rounded-lg border border-border bg-surface-card px-2.5 py-1.5 text-xs text-text transition hover:border-primary-ring hover:bg-primary-light" onclick={() => handleAskAiContext({ target: `filter:${filter.filter_id}`, section: "filters", label: filter.display_name || filter.filter_name, prompt: `${filter.display_name || filter.filter_name}: ${stringifyValue($t, filter.normalized_value ?? filter.raw_value)}` })}>
|
||||
<span class="font-medium text-text">{filter.display_name || filter.filter_name}</span>
|
||||
{#if filter.normalized_value ?? filter.raw_value}
|
||||
<span class="text-text-subtle">·</span>
|
||||
@@ -241,7 +242,7 @@
|
||||
{#if filter.recovery_status === "partial"}
|
||||
<span class="rounded bg-warning-light px-1 py-0.5 text-[10px] font-medium text-warning">частично</span>
|
||||
{/if}
|
||||
</button>
|
||||
</Button>
|
||||
{/each}
|
||||
{#if m.importedFilters.length > 6}
|
||||
<span class="inline-flex items-center rounded-lg border border-border bg-surface-card px-2.5 py-1.5 text-xs text-text-muted">+{m.importedFilters.length - 6}</span>
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<!-- @UX_TEST idle -> {click: "Add Connection"} -> adding -> fill form -> {click: "Test"} -> testing -> test_success -> {click: "Save"} -> populated -->
|
||||
<script lang="ts">
|
||||
import { getT } from "$lib/i18n/index.svelte.js";
|
||||
import { Button } from "$lib/ui";
|
||||
import { api } from "$lib/api.js";
|
||||
import { addToast } from "$lib/toasts.svelte.js";
|
||||
|
||||
@@ -223,13 +224,9 @@
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-text">{_t.settings?.connections_title || 'Database Connections'}</h3>
|
||||
<button
|
||||
class="px-3 py-1.5 bg-primary text-white rounded-md text-sm hover:bg-primary-hover disabled:opacity-50"
|
||||
onclick={openAddForm}
|
||||
disabled={screenState === "adding" || screenState === "editing"}
|
||||
>
|
||||
<Button variant="primary" size="sm" onclick={openAddForm} disabled={screenState === "adding" || screenState === "editing"}>
|
||||
{_t.settings?.connections_add || '+ Add Connection'}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if screenState === "loading"}
|
||||
@@ -243,12 +240,9 @@
|
||||
<div class="border-2 border-dashed border-border rounded-lg p-8 text-center">
|
||||
<p class="text-text-muted mb-2">{_t.settings?.connections_empty || 'No database connections configured.'}</p>
|
||||
<p class="text-text-subtle text-sm mb-4">{_t.settings?.connections_empty_hint || 'Add a connection to enable direct database inserts for translation jobs.'}</p>
|
||||
<button
|
||||
class="px-4 py-2 bg-primary text-white rounded-md text-sm hover:bg-primary-hover"
|
||||
onclick={openAddForm}
|
||||
>
|
||||
<Button variant="primary" size="sm" onclick={openAddForm}>
|
||||
{_t.settings?.connections_add || '+ Add Connection'}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{:else if screenState === "populated" && !showForm}
|
||||
@@ -277,13 +271,13 @@
|
||||
</td>
|
||||
<td class="px-3 py-2 text-sm text-text-muted">{conn.used_by ?? 0} {_t.settings?.connections_used_by?.toLowerCase() || 'jobs'}</td>
|
||||
<td class="px-3 py-2 text-sm text-right space-x-2">
|
||||
<button class="text-primary hover:underline text-xs" onclick={() => openEditForm(conn)}>{_t.settings?.connections_edit || 'Edit'}</button>
|
||||
<button class="text-primary hover:underline text-xs" onclick={async () => {
|
||||
<Button variant="ghost" size="sm" class="text-xs text-primary hover:underline" onclick={() => openEditForm(conn)}>{_t.settings?.connections_edit || 'Edit'}</Button>
|
||||
<Button variant="ghost" size="sm" class="text-xs text-primary hover:underline" onclick={async () => {
|
||||
const r = await api.testConnection(conn.id);
|
||||
if (r.success) addToast(`✅ ${_t.settings?.connections_test_success?.replace('{latency}', r.latency_ms) || `Connected — ${r.latency_ms}ms`}`, "success");
|
||||
else addToast(`❌ ${_t.settings?.connections_test_failed || 'Test failed'}: ${r.error}`, "error");
|
||||
}}>{_t.settings?.connections_test || 'Test'}</button>
|
||||
<button class="text-destructive hover:underline text-xs" onclick={() => confirmDelete(conn.id)}>{_t.settings?.connections_delete || 'Delete'}</button>
|
||||
}}>{_t.settings?.connections_test || 'Test'}</Button>
|
||||
<Button variant="ghost" size="sm" class="text-xs text-destructive hover:underline" onclick={() => confirmDelete(conn.id)}>{_t.settings?.connections_delete || 'Delete'}</Button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
@@ -304,9 +298,9 @@
|
||||
</ul>
|
||||
{/if}
|
||||
<p class="text-sm text-destructive mt-1">{_t.settings?.connections_delete_blocked_help || 'Detach or reassign these jobs before deleting.'}</p>
|
||||
<button class="mt-2 text-sm text-primary hover:underline" onclick={() => { screenState = "populated"; blockDeleteJobs = []; }}>
|
||||
<Button variant="ghost" size="sm" class="text-sm text-primary hover:underline" onclick={() => { screenState = "populated"; blockDeleteJobs = []; }}>
|
||||
{_t.settings?.connections_dismiss || 'Dismiss'}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -369,15 +363,15 @@
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-2 pt-2">
|
||||
<button class="px-3 py-1.5 bg-primary text-white rounded-md text-sm hover:bg-primary-hover" onclick={handleSave}>
|
||||
<Button variant="primary" size="sm" onclick={handleSave}>
|
||||
{editingId ? (_t.settings?.connections_update || 'Update Connection') : (_t.settings?.connections_save || 'Save Connection')}
|
||||
</button>
|
||||
<button class="px-3 py-1.5 border border-border rounded-md text-sm text-text hover:bg-surface-muted disabled:opacity-50 disabled:cursor-not-allowed" onclick={handleTest} disabled={!editingId} title={!editingId ? (_t.settings?.connections_save_unsaved_test || 'Save the connection first to test it') : ''}>
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onclick={handleTest} disabled={!editingId} title={!editingId ? (_t.settings?.connections_save_unsaved_test || 'Save the connection first to test it') : ''}>
|
||||
{_t.settings?.connections_test || 'Test Connection'}
|
||||
</button>
|
||||
<button class="px-3 py-1.5 text-sm text-text-muted hover:text-text" onclick={cancelForm}>
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onclick={cancelForm}>
|
||||
{_t.settings?.connections_cancel || 'Cancel'}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
* @UX_FEEDBACK: Toast notifications on success/failure of CRUD or test operations.
|
||||
*/
|
||||
import { t } from "$lib/i18n/index.svelte.js";
|
||||
import { Button } from "$lib/ui";
|
||||
import { api } from "$lib/api.js";
|
||||
import { addToast } from "$lib/toasts.svelte.js";
|
||||
import { resolveEnvStage, normalizeSupersetBaseUrl } from "./settings-utils.js";
|
||||
@@ -155,15 +156,15 @@
|
||||
|
||||
{#if !editingEnvId && !isAddingEnv}
|
||||
<div class="flex justify-end mb-6">
|
||||
<button
|
||||
class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary-hover"
|
||||
<Button
|
||||
variant="primary"
|
||||
onclick={() => {
|
||||
isAddingEnv = true;
|
||||
resetEnvForm();
|
||||
}}
|
||||
>
|
||||
{$t.settings?.env_add}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -271,22 +272,22 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex gap-2 justify-end">
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
onclick={() => {
|
||||
isAddingEnv = false;
|
||||
editingEnvId = null;
|
||||
resetEnvForm();
|
||||
}}
|
||||
class="bg-surface-muted text-text px-4 py-2 rounded hover:bg-surface-muted"
|
||||
>
|
||||
{$t.common?.cancel}
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onclick={handleAddOrUpdateEnv}
|
||||
class="bg-primary text-white px-4 py-2 rounded hover:bg-primary-hover"
|
||||
>
|
||||
{editingEnvId ? $t.settings?.env_update : $t.settings?.env_add}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -325,15 +326,15 @@
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<button class="text-success hover:text-green-900 mr-4" onclick={() => handleTestEnv(env.id)}>
|
||||
<Button variant="ghost" size="sm" class="text-success mr-4" onclick={() => handleTestEnv(env.id)}>
|
||||
{$t.settings?.env_test}
|
||||
</button>
|
||||
<button class="text-primary hover:text-primary mr-4" onclick={() => editEnv(env)}>
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" class="text-primary mr-4" onclick={() => editEnv(env)}>
|
||||
{$t.common.edit}
|
||||
</button>
|
||||
<button class="text-destructive hover:text-destructive" onclick={() => handleDeleteEnv(env.id)}>
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" class="text-destructive" onclick={() => handleDeleteEnv(env.id)}>
|
||||
{$t.settings?.env_delete}
|
||||
</button>
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
|
||||
@@ -45,12 +45,12 @@
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="px-2 py-1 text-xs font-medium rounded {config.status === 'CONNECTED' ? 'bg-success-light text-success' : 'bg-destructive-light text-destructive'}">{config.status}</span>
|
||||
<button onclick={() => { model.startEdit(config.id); window.scrollTo({ top: 0, behavior: 'smooth' }); }} class="text-text-subtle hover:text-primary transition-colors" title={$t.common?.edit || "Edit"}>
|
||||
<Button variant="ghost" class="text-text-subtle hover:text-primary transition-colors" onclick={() => { model.startEdit(config.id); window.scrollTo({ top: 0, behavior: 'smooth' }); }} title={$t.common?.edit || "Edit"}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"><path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"/></svg>
|
||||
</button>
|
||||
<button onclick={() => { if (confirm($t.settings?.git_delete_confirm)) model.deleteConfig(config.id); }} class="text-text-subtle hover:text-destructive transition-colors" title={$t.common?.delete}>
|
||||
</Button>
|
||||
<Button variant="ghost" class="text-text-subtle hover:text-destructive transition-colors" onclick={() => { if (confirm($t.settings?.git_delete_confirm)) model.deleteConfig(config.id); }} title={$t.common?.delete}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd"/></svg>
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
@@ -137,7 +137,7 @@
|
||||
<div class="font-medium text-text truncate">{repo.full_name || repo.name}</div>
|
||||
<div class="text-xs text-text-muted truncate">{repo.clone_url || repo.html_url}</div>
|
||||
</div>
|
||||
<Button variant="danger" size="sm" onclick={() => { if (confirm(`Delete repository ${repo.full_name || repo.name}?`)) model.deleteRemoteRepo(repo); }} disabled={model.giteaDeleting}>Delete</Button>
|
||||
<Button variant="destructive" size="sm" onclick={() => { if (confirm(`Delete repository ${repo.full_name || repo.name}?`)) model.deleteRemoteRepo(repo); }} disabled={model.giteaDeleting}>Delete</Button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
import { t } from "$lib/i18n/index.svelte.js";
|
||||
import { api } from "$lib/api.js";
|
||||
import { addToast } from "$lib/toasts.svelte.js";
|
||||
import { Button } from "$lib/ui";
|
||||
|
||||
let isLoading = $state(true);
|
||||
let isSaving = $state(false);
|
||||
@@ -119,14 +120,9 @@
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end pt-4">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-primary px-6 py-2 text-sm font-medium text-white transition-colors hover:bg-primary-hover disabled:opacity-50"
|
||||
disabled={isSaving}
|
||||
onclick={handleSave}
|
||||
>
|
||||
<Button onclick={handleSave} disabled={isSaving} isLoading={isSaving}>
|
||||
{isSaving ? "Saving..." : "Save Notification Settings"}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { ROUTES } from '$lib/routes';
|
||||
import { t, _ } from '$lib/i18n/index.svelte.js';
|
||||
import { addToast } from '$lib/toasts.svelte.js';
|
||||
import { Button } from '$lib/ui';
|
||||
import { fetchJobs, deleteJob, duplicateJob } from '$lib/api/translate.js';
|
||||
|
||||
/** @type {string} idle | loading | empty | populated | error */
|
||||
@@ -119,15 +120,15 @@
|
||||
{$t.translate?.jobs?.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
<Button
|
||||
variant="primary"
|
||||
onclick={navigateToCreate}
|
||||
class="inline-flex items-center px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
{$t.translate?.jobs?.new_job}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Flow hint -->
|
||||
@@ -141,7 +142,7 @@
|
||||
<!-- Filter Pills -->
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
{#each statusPills as pill}
|
||||
<button
|
||||
<Button variant="ghost"
|
||||
onclick={() => filterByStatus(pill.value)}
|
||||
class="px-3 py-1.5 text-sm rounded-full border transition-colors
|
||||
{statusFilter === pill.value
|
||||
@@ -150,7 +151,7 @@
|
||||
>
|
||||
{pill.label}
|
||||
<span class="ml-1 text-xs opacity-70">({pill.count})</span>
|
||||
</button>
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -166,12 +167,12 @@
|
||||
{:else if uxState === 'error'}
|
||||
<div class="bg-destructive-light border border-destructive-light rounded-lg p-6 text-center">
|
||||
<p class="text-destructive mb-3">{error || $t.translate?.jobs?.an_error_occurred}</p>
|
||||
<button
|
||||
<Button
|
||||
variant="destructive"
|
||||
onclick={loadJobs}
|
||||
class="px-4 py-2 bg-destructive text-white rounded-lg hover:bg-destructive-hover transition-colors"
|
||||
>
|
||||
{$t.translate?.common?.retry}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
@@ -182,20 +183,21 @@
|
||||
</svg>
|
||||
{#if statusFilter}
|
||||
<p class="text-text-muted mb-2">{$t.translate?.jobs?.no_jobs_filtered.replace('{status}', statusFilter)}</p>
|
||||
<button
|
||||
<Button
|
||||
variant="ghost" size="sm"
|
||||
onclick={() => filterByStatus('')}
|
||||
class="text-primary hover:text-primary-hover text-sm"
|
||||
>
|
||||
{$t.translate?.jobs?.clear_filter}
|
||||
</button>
|
||||
</Button>
|
||||
{:else}
|
||||
<p class="text-text-muted mb-4">{$t.translate?.jobs?.no_jobs}</p>
|
||||
<button
|
||||
<Button
|
||||
variant="primary"
|
||||
onclick={navigateToCreate}
|
||||
class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover transition-colors"
|
||||
>
|
||||
{$t.translate?.jobs?.create_job}
|
||||
</button>
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -243,7 +245,8 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 ml-4" onclick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
onclick={() => handleDuplicate(job.id)}
|
||||
class="p-2 text-text-subtle hover:text-primary rounded-lg hover:bg-primary-light transition-colors"
|
||||
title={$t.translate?.jobs?.duplicate}
|
||||
@@ -251,24 +254,27 @@
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
</Button>
|
||||
{#if showDeleteConfirm === job.id}
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
<Button
|
||||
variant="destructive" size="sm"
|
||||
onclick={() => handleDelete(job.id)}
|
||||
class="px-2 py-1 text-xs bg-destructive text-white rounded hover:bg-destructive-hover"
|
||||
class="px-2 py-1 text-xs"
|
||||
>
|
||||
{$t.translate?.jobs?.confirm_delete}
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary" size="sm"
|
||||
onclick={() => (showDeleteConfirm = null)}
|
||||
class="px-2 py-1 text-xs bg-surface-muted text-text rounded hover:bg-surface-muted"
|
||||
class="px-2 py-1 text-xs"
|
||||
>
|
||||
{$t.translate?.jobs?.cancel_delete}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
onclick={() => (showDeleteConfirm = job.id)}
|
||||
class="p-2 text-text-subtle hover:text-destructive rounded-lg hover:bg-destructive-light transition-colors"
|
||||
title={$t.translate?.common?.delete}
|
||||
@@ -276,7 +282,7 @@
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user