# Story 2.3: Health Monitoring Dashboard

## Story

**As a** system administrator,
**I want** a health dashboard showing system status, recent job statistics, and Akeneo connectivity at a glance,
**So that** I can confirm the system is operating normally and catch problems before they affect data stewards.

## Status

done

## Acceptance Criteria

**AC1:** Given an authenticated admin navigates to `/admin` or `/admin/health`, when the page loads, then three stat cards are displayed in a Bootstrap 3-column grid row: "Akeneo Connection", "Import Jobs (Last 7 Days)", "Categorization Batches (Last 7 Days)". Below the cards, a full-width table shows the 10 most recent import jobs with columns: Job ID, filename, status, row count, created at. The page auto-refreshes stat values every 30 seconds via AJAX without a full page reload.

**AC2:** Given the Akeneo Connection card is rendered, when the last connection test result is retrieved from `system_settings`, then the card shows status label ("Connected" in green, "Disconnected" in red, "Untested" in gray) and the timestamp of the last test. A "Test Now" button triggers the same connection test as Story 2.1 and updates the card in-place.

**AC3:** Given the Import Jobs card is rendered, when import job counts are queried for the last 7 days, then the card shows total, completed (green), failed (red), and in-progress (blue). If `import_jobs` table does not exist, the card shows "No data yet."

**AC4:** Given the Categorization Batches card is rendered, when batch counts are queried for the last 7 days, then the card shows total, auto-approved, sent-to-workflow, and failed. If `categorization_batches` table does not exist, the card shows "No data yet."

**AC5:** Given the recent jobs table is rendered, when fewer than 10 import jobs exist only the available rows are shown; when no jobs exist at all the empty state message "No import jobs yet." is displayed.

**AC6:** Given a non-admin authenticated user navigates to any `/admin/*` route, they are redirected to the main dashboard with a forbidden error. Unauthenticated users are redirected to `/login?redirect=...`.

## Tasks / Subtasks

- [x] **Task 1: Create `HealthService`** (AC: 3, 4, 5)
  - [x] 1.1: Create `src/Services/HealthService.php` with `getImportJobStats()`, `getCategorizationStats()`, `getRecentImportJobs(int $limit = 10)`. Each method wraps its PDO query in `try/catch (\Throwable)` and returns `['available' => false]` / `[]` on any exception — so the dashboard degrades gracefully before Story 3.1 / 5.x create the required tables.
  - [x] 1.2: `getImportJobStats()` uses PostgreSQL `FILTER (WHERE ...)` aggregation to return total / completed / failed / in_progress counts for the last 7 days.
  - [x] 1.3: `getCategorizationStats()` returns total / auto_approved / sent_to_workflow / failed counts.
  - [x] 1.4: `getRecentImportJobs()` SELECTs `id, filename, status, row_count, created_at` ordered by `created_at DESC`.

- [x] **Task 2: Update `AdminController`** (AC: 1, 2, 3, 4, 5)
  - [x] 2.1: Add `HealthService` as 4th constructor argument. Wire in `public/index.php`.
  - [x] 2.2: Update `testConnection()` to persist `akeneo_last_connection_status` (`'connected'` / `'disconnected'`) and `akeneo_last_connection_at` (ISO 8601 UTC) to `system_settings` after each test. This makes connection history available to the health dashboard without an extra live call.
  - [x] 2.3: Add `viewHealth(): void` — reads akeneo status keys, calls all three `HealthService` methods, sets `Cache-Control: no-store`, requires `src/Views/admin/health.php`.
  - [x] 2.4: Add `getHealthStats(): void` — JSON endpoint returning `{akeneo, import_jobs, categorization, recent_jobs}` for AJAX polling.

- [x] **Task 3: Update `_admin_tabs.php`** (AC: 1)
  - [x] 3.1: Add "Health" tab linking to `/admin/health` as the first tab. Active state driven by `$activeTab === 'health'`.

- [x] **Task 4: Create `health.php` view** (AC: 1, 2, 3, 4, 5)
  - [x] 4.1: Sets `$activePage = 'admin'`, `$activeTab = 'health'`. Includes tabs partial.
  - [x] 4.2: Bootstrap 3-column grid row with one card per stat. Akeneo card includes status badge, last-tested timestamp, "Test Now" button, and spinner. Import / cat cards render counts or "No data yet." Empty-state handled on server-render; AJAX updates the same elements.
  - [x] 4.3: Recent import jobs table below the cards. Status column uses `badge-status-*` classes matching the existing badge palette.
  - [x] 4.4: Hidden `#health-csrf-token` input supplies the token for the Test Now fetch.

- [x] **Task 5: Create `admin-health.js`** (AC: 1, 2)
  - [x] 5.1: `setInterval(fetchAndUpdate, 30000)` polls `GET /api/admin/health-stats`. On success, DOM-updates all four regions (`#akeneo-status-content`, `#import-jobs-content`, `#cat-batches-content`, `#recent-jobs-content`) in-place. Silent catch — stale data is better than a broken page.
  - [x] 5.2: Test Now button posts to `/api/admin/test-akeneo-connection` with the CSRF token, then calls `fetchAndUpdate()` to refresh the Akeneo card with the persisted result.
  - [x] 5.3: `updateAkeneoCard()` replaces the full `#akeneo-status-content` innerHTML (not just `outerHTML` of the badge) to keep the element reference stable across repeated polls.

- [x] **Task 6: Wire routes** (AC: 1, 6)
  - [x] 6.1: `GET /admin` → 302 redirect to `/admin/health`.
  - [x] 6.2: `GET /admin/health` → `AuthMiddleware::requireRole('admin')` + `$admin->viewHealth()`.
  - [x] 6.3: `GET /api/admin/health-stats` → inline JSON auth check (same pattern as test-akeneo-connection) + `$admin->getHealthStats()`.

- [x] **Task 7: Tests** (AC: 3, 4, 5)
  - [x] 7.1: Create `tests/Unit/HealthServiceTest.php`. Tests: each public method returns an array and never throws regardless of DB state; `available` key is boolean; `getRecentImportJobs()` respects limit. Shape-when-available tests skip automatically with `markTestSkipped` when the tables don't exist yet.
  - [x] 7.2: Run full suite — 66 tests, 3 skipped (pending table creation in Story 3.1 / 5.x), 0 failures.

### Review Findings

- [x] [Review][Decision] Extra "Akeneo Product Sync" card not in spec — kept as intentional enhancement; draws on `akeneo_sync_log` (Epic 4) and adds operational value with no harm
- [x] [Review][Patch] `updateAkeneoCard()` drops `#akeneo-status-badge` ID on AJAX update — fixed: ID added to all `akeneoBadge()` return values [`public/assets/js/admin-health.js`]
- [x] [Review][Patch] `akeneoBadge()` renders raw status text for disconnected/failed instead of capitalized label — fixed: split into separate branches returning "Disconnected" / "Failed" [`public/assets/js/admin-health.js`]
- [x] [Review][Patch] `updateSyncCard()` returns `undefined` not `false` on null-record branch — fixed: explicit `return false` [`public/assets/js/admin-health.js`]
- [x] [Review][Patch] `testConnection()` omits `$updatedBy` from `settings->set()` calls — fixed: `$updatedBy` captured from session and passed to both calls [`src/Controllers/AdminController.php`]
- [x] [Review][Defer] `/admin` redirect does not auth-check before redirecting — unauthenticated request gets 302 to `/admin/health`, then redirected to `/login?redirect=/admin/health` rather than `/login?redirect=/admin`; functional outcome is the same but the entry URL is lost [`public/index.php:57-60`] — deferred, low impact
- [x] [Review][Defer] `getRecentImportJobs()` has no upper bound on `$limit` parameter — only called internally with default 10 today but method signature accepts any int [`src/Services/HealthService.php:88`] — deferred, pre-existing risk
- [x] [Review][Defer] Sync card can show "running" state indefinitely — no staleness check on `akeneo_sync_log`; if `sync_akeneo.php` crashes without updating status, the sync button stays disabled and poll cadence stays at 10 s forever [`src/Services/HealthService.php:112`] — deferred, operational concern
- [x] [Review][Defer] `$row === false` dead-code guard in aggregate queries — `COUNT(*)` always returns one row so the early-return is unreachable; harmless but misleading [`src/Services/HealthService.php:30, 66`] — deferred, cosmetic
- [x] [Review][Defer] Timer cadence switch is not atomic with overlapping fetch responses — two concurrent in-flight `fetchAndUpdate()` calls can flip `syncRunning` based on stale response ordering [`public/assets/js/admin-health.js:207-213`] — deferred, edge case

## Dev Notes

### Graceful table-missing fallback

`HealthService` wraps every query in `try/catch (\Throwable)`. PostgreSQL throws `PDOException` (SQLSTATE 42P01) when a table is undefined. The catch converts this to `['available' => false]` / `[]`. This means the health page works from Story 2.3 onward even though `import_jobs` (Story 3.1) and `categorization_batches` (Story 5.x) don't exist yet. No schema migrations ship with this story.

### Connection status persistence

`testConnection()` in `AdminController` now persists the result to `system_settings` (keys `akeneo_last_connection_status`, `akeneo_last_connection_at`). Previously the test result was ephemeral (returned to the caller only). The health page reads these stored values — no live Akeneo call on page load.

### `/admin` redirect

`GET /admin` redirects to `/admin/health` (not `/admin/settings`). The sidebar "Admin" link still targets `/admin/settings` (unchanged per spec); `/admin` is a convenience entry point.

### AJAX auth on health-stats

`/api/admin/health-stats` uses the same inline JSON auth check pattern established for `/api/admin/test-akeneo-connection` (W18 in deferred items). Both endpoints are exempt from HTML redirects.

### File list

**New files:**
- `src/Services/HealthService.php`
- `src/Views/admin/health.php`
- `public/assets/js/admin-health.js`
- `tests/Unit/HealthServiceTest.php`

**Modified files:**
- `src/Controllers/AdminController.php` (constructor + 3 new methods + persistence in testConnection)
- `src/Views/admin/_admin_tabs.php` (Health tab added)
- `public/index.php` (3 new routes + HealthService wiring)
