refactor(frontend): migrate health center page to HealthCenterModel
Extract state management from inline health page into HealthCenterModel: - HealthCenterModel.svelte.ts (new): hosts all state atoms (), derived values (), and core actions (load, filter, delete) - health page reduced from ~120 to ~27 lines of script — thin shell delegating to model; only DOM/template concerns remain - Integration test updated for model-based architecture - HealthCenterModel.test.ts (new): model invariant tests
This commit is contained in:
169
frontend/src/lib/models/HealthCenterModel.svelte.ts
Normal file
169
frontend/src/lib/models/HealthCenterModel.svelte.ts
Normal file
@@ -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<string, unknown>;
|
||||
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<string>();
|
||||
/** 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<void> {
|
||||
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<void> {
|
||||
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
|
||||
239
frontend/src/lib/models/__tests__/HealthCenterModel.test.ts
Normal file
239
frontend/src/lib/models/__tests__/HealthCenterModel.test.ts
Normal file
@@ -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
|
||||
@@ -1,120 +1,27 @@
|
||||
<!-- #region HealthCenterPage [C:5] [TYPE Page] [SEMANTICS sveltekit, health, matrix, dashboard-cards, validation, environment] -->
|
||||
<!-- @BRIEF Dashboard Health Center — clickable health matrix + card-based dashboard list with integrated schedule.
|
||||
<!-- @BRIEF Dashboard Health Center — thin page shell delegating state logic to HealthCenterModel.
|
||||
Cards replace the old table; matrix clicks filter the list. -->
|
||||
<!-- @LAYER Page -->
|
||||
<!-- @RELATION DEPENDS_ON -> [HealthMatrix] -->
|
||||
<!-- @RELATION DEPENDS_ON -> [ScheduleAtAGlance] -->
|
||||
<!-- @RELATION CALLS -> [EXT:frontend:api_module] -->
|
||||
<!-- @RELATION BINDS_TO -> [HealthCenterModel] -->
|
||||
<!-- @UX_STATE Loading -> Skeleton loaders, matrix dimmed. -->
|
||||
<!-- @UX_STATE Loaded -> Health matrix + card-based dashboard list. -->
|
||||
<!-- @UX_STATE Error -> Error message with retry button. -->
|
||||
<!-- @UX_FEEDBACK Toast on delete success/failure. -->
|
||||
<!-- @UX_RECOVERY Refresh button reloads data; Clear filter resets matrix filter. -->
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import { ROUTES } from '$lib/routes.js';
|
||||
import HealthMatrix from '$lib/components/health/HealthMatrix.svelte';
|
||||
import ScheduleAtAGlance from '$lib/components/health/ScheduleAtAGlance.svelte';
|
||||
import { getHealthSummary, getEnvironments, getConsolidatedSettings, requestApi } from '$lib/api.js';
|
||||
import { healthStore } from '$lib/stores/health.svelte.js';
|
||||
import { t } from '$lib/i18n/index.svelte.js';
|
||||
import { addToast } from '$lib/toasts.svelte.js';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { HealthCenterModel } from '$lib/models/HealthCenterModel.svelte.ts';
|
||||
|
||||
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<string, unknown>;
|
||||
chunk_count?: number;
|
||||
}
|
||||
const m = new HealthCenterModel();
|
||||
|
||||
let healthData = $state<{
|
||||
items: HealthItem[];
|
||||
pass_count: number;
|
||||
warn_count: number;
|
||||
fail_count: number;
|
||||
unknown_count: number;
|
||||
}>({ items: [], pass_count: 0, warn_count: 0, fail_count: 0, unknown_count: 0 });
|
||||
let environments = $state<Array<{ id: string; name?: string }>>([]);
|
||||
let selectedEnvId = $state('');
|
||||
let appTimezone = $state('Europe/Moscow');
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let deletingReportIds = new SvelteSet<string>();
|
||||
let activeStatusFilter = $state('');
|
||||
|
||||
// Derived: filtered items based on matrix selection
|
||||
let filteredItems = $derived(
|
||||
activeStatusFilter
|
||||
? healthData.items.filter((item) => item.status === activeStatusFilter)
|
||||
: healthData.items
|
||||
);
|
||||
|
||||
async function loadData() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const summaryPromise = selectedEnvId
|
||||
? getHealthSummary(selectedEnvId)
|
||||
: healthStore.refresh();
|
||||
const [summary, envs, consolidated] = await Promise.all([
|
||||
summaryPromise,
|
||||
getEnvironments(),
|
||||
getConsolidatedSettings().catch(() => ({ app_timezone: 'Europe/Moscow' }))
|
||||
]);
|
||||
healthData = summary as typeof healthData;
|
||||
environments = envs as Array<{ id: string; name?: string }>;
|
||||
appTimezone = consolidated.app_timezone || 'Europe/Moscow';
|
||||
if (!selectedEnvId && envs.length > 0) {
|
||||
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)) {
|
||||
selectedEnvId = state.selectedEnvId;
|
||||
}
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => { loadData(); });
|
||||
|
||||
function handleEnvChange(e: Event) {
|
||||
selectedEnvId = (e.target as HTMLSelectElement).value;
|
||||
loadData();
|
||||
}
|
||||
|
||||
function handleMatrixSelect(status: string) {
|
||||
activeStatusFilter = status;
|
||||
}
|
||||
|
||||
async function handleDeleteReport(item: HealthItem) {
|
||||
if (!item?.record_id || deletingReportIds.has(item.record_id)) return;
|
||||
const slug = item.dashboard_slug || item.dashboard_name || item.dashboard_id;
|
||||
if (!confirm(($t.health?.delete_confirm || 'Delete report for {slug}?').replace('{slug}', slug))) return;
|
||||
|
||||
deletingReportIds.add(item.record_id);
|
||||
try {
|
||||
await requestApi(`/health/summary/${item.record_id}`, 'DELETE');
|
||||
addToast(($t.health?.delete_success || 'Report for {slug} deleted').replace('{slug}', slug), 'success');
|
||||
await loadData();
|
||||
} catch (e: unknown) {
|
||||
addToast(($t.health?.delete_failed || 'Failed to delete: {error}').replace('{error}', e instanceof Error ? e.message : 'Unknown'), 'error');
|
||||
} finally {
|
||||
deletingReportIds.delete(item.record_id);
|
||||
}
|
||||
}
|
||||
onMount(() => { m.loadData(); });
|
||||
|
||||
function formatRelativeDate(ts: string): string {
|
||||
return formatDistanceToNow(new Date(ts), { addSuffix: true });
|
||||
@@ -139,21 +46,21 @@
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<select
|
||||
value={selectedEnvId}
|
||||
onchange={handleEnvChange}
|
||||
value={m.selectedEnvId}
|
||||
onchange={(e) => m.handleEnvChange((e.target as HTMLSelectElement).value)}
|
||||
class="w-44 px-3 py-2 text-sm border-border-strong rounded-lg bg-surface-card shadow-sm focus:ring-primary-ring focus:border-primary-ring"
|
||||
>
|
||||
<option value="">{$t.health?.all_environments || 'All'}</option>
|
||||
{#each environments as env (env.id)}
|
||||
{#each m.environments as env (env.id)}
|
||||
<option value={env.id}>{env.name || env.id}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<button
|
||||
onclick={loadData}
|
||||
disabled={loading}
|
||||
onclick={() => m.loadData()}
|
||||
disabled={m.screenState === 'loading'}
|
||||
class="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium rounded-lg text-white bg-primary hover:bg-primary-hover disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<svg class="w-4 h-4" class:animate-spin={loading} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg class="w-4 h-4" class:animate-spin={m.screenState === 'loading'} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
{$t.common?.refresh || 'Refresh'}
|
||||
@@ -163,22 +70,22 @@
|
||||
|
||||
<!-- Clickable Health Matrix -->
|
||||
<HealthMatrix
|
||||
pass_count={healthData.pass_count}
|
||||
warn_count={healthData.warn_count}
|
||||
fail_count={healthData.fail_count}
|
||||
unknown_count={healthData.unknown_count}
|
||||
selectedFilter={activeStatusFilter}
|
||||
onSelect={handleMatrixSelect}
|
||||
{loading}
|
||||
{error}
|
||||
pass_count={m.healthData.pass_count}
|
||||
warn_count={m.healthData.warn_count}
|
||||
fail_count={m.healthData.fail_count}
|
||||
unknown_count={m.healthData.unknown_count}
|
||||
selectedFilter={m.activeStatusFilter}
|
||||
onSelect={(s) => m.handleMatrixSelect(s)}
|
||||
loading={m.screenState === 'loading'}
|
||||
error={m.error}
|
||||
/>
|
||||
|
||||
<!-- Schedule (compact) -->
|
||||
<div class="mt-5">
|
||||
<ScheduleAtAGlance
|
||||
healthItems={healthData.items}
|
||||
{selectedEnvId}
|
||||
{appTimezone}
|
||||
healthItems={m.healthData.items}
|
||||
selectedEnvId={m.selectedEnvId}
|
||||
appTimezone={m.appTimezone}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -187,14 +94,14 @@
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="text-sm font-semibold text-text-muted uppercase tracking-wider">
|
||||
{$t.health?.validation_details || 'Dashboards'}
|
||||
{#if activeStatusFilter}
|
||||
<span class="font-normal normal-case text-text-subtle"> · filtered by {activeStatusFilter}</span>
|
||||
{#if m.activeStatusFilter}
|
||||
<span class="font-normal normal-case text-text-subtle"> · filtered by {m.activeStatusFilter}</span>
|
||||
{/if}
|
||||
</h2>
|
||||
<span class="text-xs text-text-subtle">{filteredItems.length} {filteredItems.length === 1 ? 'dashboard' : 'dashboards'}</span>
|
||||
<span class="text-xs text-text-subtle">{m.filteredItems.length} {m.filteredItems.length === 1 ? 'dashboard' : 'dashboards'}</span>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
{#if m.screenState === 'loading'}
|
||||
<div class="space-y-3">
|
||||
{#each Array(4) as _, i (i)}
|
||||
<div class="animate-pulse bg-surface-card border border-border rounded-xl p-5">
|
||||
@@ -207,21 +114,21 @@
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if filteredItems.length === 0}
|
||||
{:else if m.filteredItems.length === 0}
|
||||
<div class="bg-surface-card border-2 border-dashed border-border rounded-xl p-12 text-center">
|
||||
<svg class="w-12 h-12 mx-auto mb-3 text-text-subtle" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
<p class="text-sm text-text-subtle">{$t.health?.no_records || 'No validation records found'}</p>
|
||||
{#if activeStatusFilter}
|
||||
<button onclick={() => { activeStatusFilter = ''; }} class="mt-3 text-xs text-primary hover:text-primary underline">
|
||||
{#if m.activeStatusFilter}
|
||||
<button onclick={() => m.clearFilter()} class="mt-3 text-xs text-primary hover:text-primary underline">
|
||||
{$t.validation?.clear_filters || 'Clear filter'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each filteredItems as item (item.record_id || item.dashboard_id)}
|
||||
{#each m.filteredItems as item (item.record_id || item.dashboard_id)}
|
||||
<div class="bg-surface-card border border-border rounded-xl p-5 hover:border-border-strong hover:shadow-sm transition-all">
|
||||
<!-- Card top: name + status + last check -->
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
@@ -304,13 +211,13 @@
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 text-xs text-text-subtle hover:text-destructive transition-colors ml-auto"
|
||||
onclick={() => handleDeleteReport(item)}
|
||||
disabled={deletingReportIds.has(item.record_id!)}
|
||||
onclick={() => m.handleDeleteReport(item)}
|
||||
disabled={m.deletingReportIds.has(item.record_id!)}
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" 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>
|
||||
{deletingReportIds.has(item.record_id!) ? '...' : $t.common?.delete || 'Delete'}
|
||||
{m.deletingReportIds.has(item.record_id!) ? '...' : $t.common?.delete || 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
// #region HealthPageIntegrationTest:Module [TYPE Function]
|
||||
// @SEMANTICS: health-page, integration-test, slug-link, delete-flow
|
||||
// @PURPOSE: Lock dashboard health page contract for slug navigation and report deletion.
|
||||
// @SEMANTICS: health-page, integration-test, slug-link, delete-flow, model-contract
|
||||
// @PURPOSE: Lock dashboard health page contract for slug navigation and model-delegated report deletion.
|
||||
// @LAYER UI (Tests)
|
||||
// @RELATION DEPENDS_ON -> [HealthCenterPage]
|
||||
// @RELATION DEPENDS_ON -> [HealthCenterModel]
|
||||
// @RATIONALE Tests 2 and 3 now verify the HealthCenterModel instead of the page,
|
||||
// since delete/store logic was extracted to the model per model-first architecture.
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
@@ -12,6 +15,10 @@ const PAGE_PATH = path.resolve(
|
||||
process.cwd(),
|
||||
'src/routes/dashboards/health/+page.svelte',
|
||||
);
|
||||
const MODEL_PATH = path.resolve(
|
||||
process.cwd(),
|
||||
'src/lib/models/HealthCenterModel.svelte.ts',
|
||||
);
|
||||
|
||||
describe('Dashboard health page contract', () => {
|
||||
it('renders slug-first dashboard link bound to environment route context', () => {
|
||||
@@ -22,21 +29,29 @@ describe('Dashboard health page contract', () => {
|
||||
expect(source).toContain("ROUTES.dashboards.detail(item.dashboard_slug || item.dashboard_id, item.environment_id)");
|
||||
});
|
||||
|
||||
it('keeps explicit delete report flow with confirm and DELETE request', () => {
|
||||
it('keeps explicit delete report flow with confirm and DELETE request — delegated to HealthCenterModel', () => {
|
||||
const source = fs.readFileSync(PAGE_PATH, 'utf-8');
|
||||
const modelSource = fs.readFileSync(MODEL_PATH, 'utf-8');
|
||||
|
||||
expect(source).toContain('async function handleDeleteReport(item: HealthItem)');
|
||||
expect(source).toContain("requestApi(`/health/summary/${item.record_id}`, 'DELETE')");
|
||||
expect(source).toContain(".replace('{slug}', slug)");
|
||||
expect(source).toContain("$t.common?.delete");
|
||||
// Page BINDS_TO model
|
||||
expect(source).toContain('BINDS_TO -> [HealthCenterModel]');
|
||||
// Page delegates delete to model
|
||||
expect(source).toContain('m.handleDeleteReport(item)');
|
||||
// Model contains the delete logic
|
||||
expect(modelSource).toContain('async handleDeleteReport(item: HealthItem)');
|
||||
expect(modelSource).toContain("requestApi(`/health/summary/${item.record_id}`, 'DELETE')");
|
||||
});
|
||||
|
||||
it('reuses global health store refresh for all-environment summary loads', () => {
|
||||
it('reuses global health store refresh for all-environment summary loads — delegated to HealthCenterModel', () => {
|
||||
const source = fs.readFileSync(PAGE_PATH, 'utf-8');
|
||||
const modelSource = fs.readFileSync(MODEL_PATH, 'utf-8');
|
||||
|
||||
expect(source).toContain("import { healthStore } from '$lib/stores/health.svelte.js';");
|
||||
expect(source).toContain('? getHealthSummary(selectedEnvId)');
|
||||
expect(source).toContain(': healthStore.refresh();');
|
||||
// Page BINDS_TO model
|
||||
expect(source).toContain('m.healthData');
|
||||
// Model imports healthStore
|
||||
expect(modelSource).toContain("import { healthStore } from '$lib/stores/health.svelte.js';");
|
||||
expect(modelSource).toContain('? getHealthSummary(this.selectedEnvId)');
|
||||
expect(modelSource).toContain(': healthStore.refresh();');
|
||||
});
|
||||
});
|
||||
// #endregion HealthPageIntegrationTest:Module
|
||||
|
||||
Reference in New Issue
Block a user