diff --git a/frontend/src/lib/models/HealthCenterModel.svelte.ts b/frontend/src/lib/models/HealthCenterModel.svelte.ts new file mode 100644 index 00000000..e03d9119 --- /dev/null +++ b/frontend/src/lib/models/HealthCenterModel.svelte.ts @@ -0,0 +1,169 @@ +// #region HealthCenterModel [C:5] [TYPE Model] [SEMANTICS health,center,matrix,filter,dashboard-list,model] +// @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. +// @INVARIANT Delete removes item locally only via SvelteSet tracking; full reload keeps state consistent. +// @STATE loading — Initial load or environment switch in progress. +// @STATE loaded — Health data displayed, matrix + cards rendered. +// @STATE error — API call failed, error message visible. +// @ACTION loadData() — Fetch health summary, environments, settings. +// @ACTION handleEnvChange(id) — Switch environment and reload. +// @ACTION handleMatrixSelect(st) — Set active status filter (cross-widget: matrix → card list). +// @ACTION handleDeleteReport(item) — Delete single health record. +// @ACTION clearFilter() — Reset activeStatusFilter to ''. +// @RELATION DEPENDS_ON -> [ApiModule] +// @RELATION DEPENDS_ON -> [HealthStore] +// @RELATION CALLS -> [CotLogger] + +import { SvelteSet } from 'svelte/reactivity'; +import { getHealthSummary, getEnvironments, getConsolidatedSettings, requestApi } from '$lib/api.js'; +import { healthStore } from '$lib/stores/health.svelte.js'; +import { addToast } from '$lib/toasts.svelte.js'; +import { log } from '$lib/cot-logger'; + +// ── Types ──────────────────────────────────────────────────────── + +export interface HealthItem { + record_id?: string; + dashboard_id: string; + dashboard_slug?: string; + dashboard_title?: string; + dashboard_name?: string; + environment_id: string; + status: string; + last_check: string; + task_id?: string; + run_id?: string; + policy_id?: string; + summary?: string; + execution_path?: string; + issues_count?: number; + timings?: Record; + chunk_count?: number; +} + +interface HealthSummary { + items: HealthItem[]; + pass_count: number; + warn_count: number; + fail_count: number; + unknown_count: number; +} + +type ScreenState = 'loading' | 'loaded' | 'error'; + +// ── Model ──────────────────────────────────────────────────────── + +export class HealthCenterModel { + // ── Atoms ──────────────────────────────────────────────────── + /** Raw health data response (sum of all statuses). */ + healthData: HealthSummary = $state({ items: [], pass_count: 0, warn_count: 0, fail_count: 0, unknown_count: 0 }); + /** Available environments from API. */ + environments: Array<{ id: string; name?: string }> = $state([]); + /** Currently selected environment ID (empty = all). */ + selectedEnvId: string = $state(''); + /** App timezone from consolidated settings. */ + appTimezone: string = $state('Europe/Moscow'); + /** UI screen state. */ + screenState: ScreenState = $state('loading'); + /** Human-readable error message. */ + error: string | null = $state(null); + /** Set of record_ids currently being deleted (tracks in-flight deletes). */ + deletingReportIds = new SvelteSet(); + /** Active status filter — set by matrix click, clears to show all. */ + activeStatusFilter: string = $state(''); + + // ── Derived ───────────────────────────────────────────────── + /** Cards filtered by matrix selection. @INVARIANT: no reload needed. */ + filteredItems: HealthItem[] = $derived( + this.activeStatusFilter + ? this.healthData.items.filter((item) => item.status === this.activeStatusFilter) + : this.healthData.items, + ); + + // ── Actions ───────────────────────────────────────────────── + + /** + * Fetch health summary, environments, and consolidated settings. + * @POST healthData, environments, appTimezone populated on success. + * @POST screenState → 'loaded' or 'error'. + */ + async loadData(): Promise { + log('HealthCenterModel', 'REASON', 'Loading health center data', { env: this.selectedEnvId || 'all' }); + this.screenState = 'loading'; + this.error = null; + try { + const summaryPromise = this.selectedEnvId + ? getHealthSummary(this.selectedEnvId) + : healthStore.refresh(); + const [summary, envs, consolidated] = await Promise.all([ + summaryPromise, + getEnvironments(), + getConsolidatedSettings().catch(() => ({ app_timezone: 'Europe/Moscow' })), + ]); + this.healthData = summary as HealthSummary; + this.environments = envs as Array<{ id: string; name?: string }>; + this.appTimezone = (consolidated as { app_timezone?: string }).app_timezone || 'Europe/Moscow'; + + if (!this.selectedEnvId && envs.length > 0) { + // Try to restore from environment context store + const { get } = await import('svelte/store'); + const envCtx = await import('$lib/stores/environmentContext.svelte.js'); + const state = get(envCtx.environmentContextStore); + if (state.selectedEnvId && envs.some((e: { id: string }) => e.id === state.selectedEnvId)) { + this.selectedEnvId = state.selectedEnvId; + } + } + this.screenState = 'loaded'; + log('HealthCenterModel', 'REFLECT', 'Health data loaded', { items: this.healthData.items.length }); + } catch (e: unknown) { + this.error = e instanceof Error ? e.message : String(e); + this.screenState = 'error'; + log('HealthCenterModel', 'EXPLORE', 'Failed to load health data', { env: this.selectedEnvId }, String(this.error)); + } + } + + /** + * Switch environment and reload all data. + * @POST selectedEnvId updated; loadData() called. + */ + handleEnvChange(envId: string): void { + this.selectedEnvId = envId; + this.activeStatusFilter = ''; + this.error = null; + void this.loadData(); + } + + /** + * Set active status filter — triggered by matrix cell click. + * @POST filteredItems re-derived. Empty string shows all. + */ + handleMatrixSelect(status: string): void { + this.activeStatusFilter = status; + } + + /** + * Delete a single health report record. + * @SIDE_EFFECT DELETE API call; reloads data on success. + */ + async handleDeleteReport(item: HealthItem): Promise { + if (!item?.record_id || this.deletingReportIds.has(item.record_id)) return; + this.deletingReportIds.add(item.record_id); + try { + await requestApi(`/health/summary/${item.record_id}`, 'DELETE'); + // Reload to keep state consistent + await this.loadData(); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : 'Unknown'; + addToast(`Failed to delete: ${msg}`, 'error'); + } finally { + this.deletingReportIds.delete(item.record_id); + } + } + + /** Reset active status filter to show all dashboards. */ + clearFilter(): void { + this.activeStatusFilter = ''; + } +} +// #endregion HealthCenterModel diff --git a/frontend/src/lib/models/__tests__/HealthCenterModel.test.ts b/frontend/src/lib/models/__tests__/HealthCenterModel.test.ts new file mode 100644 index 00000000..2b3b8e61 --- /dev/null +++ b/frontend/src/lib/models/__tests__/HealthCenterModel.test.ts @@ -0,0 +1,239 @@ +// #region HealthCenterModelTests [C:3] [TYPE Module] [SEMANTICS test,model,health-center] +// @BRIEF L1 unit tests for HealthCenterModel — no DOM render, mocks API + healthStore + toast. +// @RELATION BINDS_TO -> [HealthCenterModel] +// @TEST_INVARIANT: env-reload -> VERIFIED_BY: [test_env_change_reloads_data] +// @TEST_INVARIANT: matrix-filter-works -> VERIFIED_BY: [test_matrix_select_filters_items] +// @TEST_INVARIANT: delete-tracking -> VERIFIED_BY: [test_delete_adds_to_tracking_set] +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// ── Mocks ──────────────────────────────────────────────────────── + +vi.mock("$lib/api.js", () => ({ + getHealthSummary: vi.fn(), + getEnvironments: vi.fn().mockResolvedValue([]), + getConsolidatedSettings: vi.fn().mockResolvedValue({ app_timezone: "UTC" }), + requestApi: vi.fn(), +})); + +vi.mock("$lib/stores/health.svelte.js", () => ({ + healthStore: { + refresh: vi.fn().mockResolvedValue({ + items: [], + pass_count: 0, + warn_count: 0, + fail_count: 0, + unknown_count: 0, + }), + }, +})); + +vi.mock("$lib/toasts.svelte.js", () => ({ + addToast: vi.fn(), +})); + +vi.mock("$lib/cot-logger", () => ({ + log: vi.fn(), +})); + +import { HealthCenterModel } from "../HealthCenterModel.svelte.ts"; +import { getHealthSummary, getEnvironments, getConsolidatedSettings, requestApi } from "$lib/api.js"; +import { healthStore } from "$lib/stores/health.svelte.js"; +import { addToast } from "$lib/toasts.svelte.js"; + +// ── Fixtures ───────────────────────────────────────────────────── + +function makeHealthSummary(overrides = {}) { + return { + items: [ + { dashboard_id: "d1", status: "PASS", environment_id: "env-1", last_check: "2026-06-01T00:00:00Z", record_id: "r1" }, + { dashboard_id: "d2", status: "WARN", environment_id: "env-1", last_check: "2026-06-01T00:00:00Z" }, + { dashboard_id: "d3", status: "FAIL", environment_id: "env-1", last_check: "2026-06-01T00:00:00Z" }, + ], + pass_count: 1, + warn_count: 1, + fail_count: 1, + unknown_count: 0, + ...overrides, + }; +} + +function makeEnvironments() { + return [ + { id: "env-1", name: "DEV" }, + { id: "env-2", name: "PROD" }, + ]; +} + +// ── Suite ──────────────────────────────────────────────────────── + +describe("HealthCenterModel — L1 invariants (no render)", () => { + let model: HealthCenterModel; + + beforeEach(() => { + vi.clearAllMocks(); + model = new HealthCenterModel(); + }); + + // ═══════════════════════════════════════════════════════════════ + // @INVARIANT: Changing environment reloads all data + // ═══════════════════════════════════════════════════════════════ + describe("handleEnvChange", () => { + it("sets selectedEnvId, resets filter, and triggers data reload", async () => { + vi.mocked(getHealthSummary).mockResolvedValue(makeHealthSummary()); + vi.mocked(getEnvironments).mockResolvedValue(makeEnvironments()); + vi.mocked(getConsolidatedSettings).mockResolvedValue({ app_timezone: "UTC" }); + + model.activeStatusFilter = "FAIL"; + model.handleEnvChange("env-2"); + + // Synchronous effects + expect(model.selectedEnvId).toBe("env-2"); + expect(model.activeStatusFilter).toBe(""); + expect(model.error).toBeNull(); + + // Async: loadData should be called + await vi.waitFor(() => { + expect(getHealthSummary).toHaveBeenCalledWith("env-2"); + }); + await vi.waitFor(() => { + expect(model.screenState).toBe("loaded"); + }); + }); + + it("resets activeStatusFilter on env change", () => { + model.activeStatusFilter = "FAIL"; + model.handleEnvChange("env-1"); + expect(model.activeStatusFilter).toBe(""); + }); + }); + + // ═══════════════════════════════════════════════════════════════ + // @INVARIANT: Matrix click sets activeStatusFilter, re-derived filteredItems + // ═══════════════════════════════════════════════════════════════ + describe("matrix filter", () => { + it("sets filter and re-derives filteredItems", () => { + model.healthData = makeHealthSummary(); + expect(model.filteredItems.length).toBe(3); + + model.handleMatrixSelect("FAIL"); + + expect(model.activeStatusFilter).toBe("FAIL"); + expect(model.filteredItems).toHaveLength(1); + expect(model.filteredItems[0].dashboard_id).toBe("d3"); + }); + + it("clearing filter shows all items", () => { + model.healthData = makeHealthSummary(); + model.handleMatrixSelect("FAIL"); + expect(model.filteredItems).toHaveLength(1); + + model.clearFilter(); + + expect(model.activeStatusFilter).toBe(""); + expect(model.filteredItems).toHaveLength(3); + }); + }); + + // ═══════════════════════════════════════════════════════════════ + // @INVARIANT: Delete tracking via SvelteSet + // ═══════════════════════════════════════════════════════════════ + describe("handleDeleteReport", () => { + it("adds record_id to deletingReportIds during deletion", async () => { + vi.mocked(requestApi).mockResolvedValue({}); + vi.mocked(getHealthSummary).mockResolvedValue(makeHealthSummary()); + vi.mocked(getEnvironments).mockResolvedValue(makeEnvironments()); + vi.mocked(getConsolidatedSettings).mockResolvedValue({ app_timezone: "UTC" }); + + const item = { dashboard_id: "d1", environment_id: "env-1", last_check: "", record_id: "r1", status: "PASS" }; + + // Start deletion — it's async, but deletingReportIds is set synchronously + const promise = model.handleDeleteReport(item); + + expect(model.deletingReportIds.has("r1")).toBe(true); + + await promise; + }); + + it("skips delete if record_id is missing", async () => { + const item = { dashboard_id: "d1", environment_id: "env-1", last_check: "", status: "PASS" }; + await model.handleDeleteReport(item); + expect(requestApi).not.toHaveBeenCalled(); + }); + + it("reloads data after successful delete", async () => { + model.selectedEnvId = "env-1"; + vi.mocked(requestApi).mockResolvedValue({}); + vi.mocked(getHealthSummary).mockResolvedValue(makeHealthSummary()); + vi.mocked(getEnvironments).mockResolvedValue(makeEnvironments()); + vi.mocked(getConsolidatedSettings).mockResolvedValue({ app_timezone: "UTC" }); + + const item = { dashboard_id: "d1", environment_id: "env-1", last_check: "", record_id: "r1", status: "PASS" }; + await model.handleDeleteReport(item); + + expect(requestApi).toHaveBeenCalledWith("/health/summary/r1", "DELETE"); + // After delete, loadData reloads — getHealthSummary should be called again + await vi.waitFor(() => { + expect(getHealthSummary).toHaveBeenCalled(); + }); + }); + }); + + // ═══════════════════════════════════════════════════════════════ + // loadData lifecycle + // ═══════════════════════════════════════════════════════════════ + describe("loadData", () => { + it("sets screenState to loaded on success with env selected", async () => { + model.selectedEnvId = "env-1"; + vi.mocked(getHealthSummary).mockResolvedValue(makeHealthSummary()); + vi.mocked(getEnvironments).mockResolvedValue(makeEnvironments()); + vi.mocked(getConsolidatedSettings).mockResolvedValue({ app_timezone: "UTC" }); + + await model.loadData(); + + expect(model.screenState).toBe("loaded"); + expect(model.healthData.items).toHaveLength(3); + expect(model.environments).toHaveLength(2); + }); + + it("loads via healthStore.refresh when no env selected", async () => { + vi.mocked(getEnvironments).mockResolvedValue(makeEnvironments()); + vi.mocked(getConsolidatedSettings).mockResolvedValue({ app_timezone: "UTC" }); + + await model.loadData(); + + expect(model.screenState).toBe("loaded"); + expect(model.healthData).toBeDefined(); + }); + + it("sets screenState to error when healthSummary fails with an env selected", async () => { + // With selectedEnvId set, loadData calls getHealthSummary + model.selectedEnvId = "env-1"; + vi.mocked(getHealthSummary).mockRejectedValue(new Error("API down")); + vi.mocked(getEnvironments).mockResolvedValue([]); + vi.mocked(getConsolidatedSettings).mockResolvedValue({ app_timezone: "UTC" }); + + await model.loadData(); + + expect(model.screenState).toBe("error"); + expect(model.error).toBe("API down"); + }); + + it("catches error from env fetch", async () => { + // With selectedEnvId set, loadData calls getHealthSummary + model.selectedEnvId = "env-1"; + vi.mocked(getHealthSummary).mockResolvedValue(makeHealthSummary()); + vi.mocked(getEnvironments).mockRejectedValue(new Error("Envs failed")); + vi.mocked(getConsolidatedSettings).mockResolvedValue({ app_timezone: "UTC" }); + + await model.loadData(); + + expect(model.screenState).toBe("error"); + expect(model.error).toBe("Envs failed"); + }); + + it("inits with loading state", () => { + expect(model.screenState).toBe("loading"); + }); + }); +}); +// #endregion HealthCenterModelTests diff --git a/frontend/src/routes/dashboards/health/+page.svelte b/frontend/src/routes/dashboards/health/+page.svelte index 06764f99..e72d09dc 100644 --- a/frontend/src/routes/dashboards/health/+page.svelte +++ b/frontend/src/routes/dashboards/health/+page.svelte @@ -1,120 +1,27 @@ - - + + + + + +