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:
2026-06-04 16:17:03 +03:00
parent 380fd4c2fa
commit 26dfde81a5
4 changed files with 472 additions and 142 deletions

View 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

View 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

View File

@@ -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>

View File

@@ -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