# Dashboard

Source: https://docs.qiick.io/externa/dashboard

The dashboard is the Fortify home page: `DashboardController` renders Inertia `dashboard` with cached activity, storage, upload-health, Insights MVP widgets, and a **Health** tab fed by `DashboardHealthMetrics`. Access requires `can-show-dashboard`.

<!-- screenshot omitted -->

> **Related**
> Authorization: [Effective permissions](https://docs.qiick.io/externa/permissions). Activity: [Activity & notifications](https://docs.qiick.io/externa/activity-and-notifications). Ops stack: [Pulse & Health](https://docs.qiick.io/externa/pulse-and-health) · [Horizon](https://docs.qiick.io/externa/horizon) · [Reverb & Echo](https://docs.qiick.io/externa/reverb-and-echo).

## Access

| Item       | Detail                                                    |
| ---------- | --------------------------------------------------------- |
| Controller | `App\Http\Controllers\DashboardController`                |
| Method     | `index`                                                   |
| Route      | `GET /dashboard` (`dashboard`)                            |
| Middleware | `auth`, `verified`, `permission:can-show-dashboard`       |
| Permission | `can-show-dashboard` (`PermissionEnum::CanShowDashboard`) |
| Frontend   | `resources/js/pages/dashboard.tsx`                        |
| Defined in | `routes/web.php`                                          |

Unauthenticated visitors never reach this page; Fortify’s home path is `/dashboard`.

## Tabs: Overview | Health

Compact inline tabs (underline, same row as the description) switch content. There are **no** primary “Open Pulse” / “Open Horizon” CTAs on the Health tab — metrics render as native Externa cards/charts. Livewire UIs at `/pulse` and `/horizon` remain for super-admin deep dives only (bookmark / type URL).

| Tab          | Contents                                                                                     |
| ------------ | -------------------------------------------------------------------------------------------- |
| **Overview** | Insights / activity / storage / upload-health widgets (below)                                |
| **Health**   | Native cards from `DashboardHealthMetrics` (system + Pulse aggregates + Horizon/Redis queue) |

Deep-link: `/dashboard?tab=health` opens the Health tab. Switching tabs updates the query via `history.replaceState`.

Authenticated Inertia pages still share `healthBadge` (`ok` / `warn` / `fail` + label) from `DashboardHealthMetrics::badge()` (cached ~60s) for clients that need it; there is no sidebar chrome entry — open Health from the Dashboard tab. Fail when Redis is down or Horizon masters are stopped; warn when failed jobs / exceptions / Horizon failed counts are non-zero.

See also [Pulse & Health tab](https://docs.qiick.io/externa/pulse-and-health).

## Overview widgets

`DashboardController` builds Inertia props from Eloquent / Spatie Activity queries wrapped in `Cache::remember`. There is no dedicated dashboard service class for Overview. There is **no Insights panel builder** — fixed MVP panels only.

### Insights MVP (~60s cache)

| Prop                    | Cache key                               | Contents                                                                     |
| ----------------------- | --------------------------------------- | ---------------------------------------------------------------------------- |
| `collectionCounts`      | `dashboard:collection-counts`           | Each collection’s `items_count` via `withCount('items')`                     |
| `activityOverTime`      | `dashboard:activity-over-time:30d`      | Daily activity counts for the last 30 days                                   |
| `contentEventBreakdown` | `dashboard:content-event-breakdown:30d` | `created` / `updated` / `deleted` counts for `CollectionItem` subjects (30d) |

Charts use **Recharts** (last 7 days of `activityOverTime` + event breakdown bars).

### ~60 second cache (ops widgets)

| Prop                 | Cache key                         | Contents                                                                                  |
| -------------------- | --------------------------------- | ----------------------------------------------------------------------------------------- |
| `latestActivity`     | `dashboard:latest-activity`       | Last 10 `Spatie\Activitylog\Models\Activity` rows with causer/subject                     |
| `fileStats`          | `dashboard:file-stats`            | `files_count`, `folders_count`, `files_size_sum` from `App\Models\File` by `FileTypeEnum` |
| `uploadHealth`       | `dashboard:upload-health`         | `in_progress_count`, `stale_count` from `App\Models\FileUpload`                           |
| `mostActiveUsers`    | `dashboard:most-active-users:24h` | Top 5 activity causers in the last 24 hours                                               |
| `suspiciousEvents`   | `dashboard:suspicious-events:1h`  | Last 25 activities with `event = 'failed'` in the last hour                               |
| `stuckOrphanUploads` | `dashboard:stuck-orphan-uploads`  | Up to 10 orphan / expired / stale uploads                                                 |
| `largestFiles`       | `dashboard:largest-files`         | Top 10 files by size                                                                      |
| `fileStatsByDisk`    | `dashboard:file-stats-by-disk`    | Per-disk stats, loaded with `Inertia::defer` (group `storage`)                            |

TTL for these keys is **60 seconds**.

### Longer cache

| Prop | Cache key | TTL | Contents |
| --- | --- | --- |
| `storageTrend` | `dashboard:storage-trend:14d` | **1 hour** | 14-day series of bytes added |

> Product copy often says “~60s widgets.” That matches activity, Insights, file counts/size, and upload health. The storage trend series is intentionally cached for one hour. This is **not** the notifications unread poll — that poll runs only when realtime broadcasting is off ([Reverb & Echo](https://docs.qiick.io/externa/reverb-and-echo)).

## Health tab (native metrics)

Prop `health` comes from `App\Services\Dashboard\DashboardHealthMetrics::summary()` (not Livewire). Empty / unavailable states show when Pulse tables or Horizon Redis APIs are missing.

### System strip

| Field                  | Meaning                                               |
| ---------------------- | ----------------------------------------------------- |
| `queue_connection`     | `config('queue.default')`                             |
| `broadcast_connection` | `config('broadcasting.default')`                      |
| `redis_ok`             | `Redis::connection()->ping()`                         |
| `pending_jobs`         | Database `jobs` count or Redis `queues:{name}` length |
| `failed_jobs`          | `failed_jobs` table count                             |

### Pulse card + chart (24h aggregates)

Requires `PULSE_ENABLED` and `pulse_aggregates` table (`pulse_available`).

| Field              | Pulse type   |
| ------------------ | ------------ |
| `exceptions_24h`   | `exception`  |
| `slow_jobs_24h`    | `slow_job`   |
| `slow_queries_24h` | `slow_query` |

Also surfaces `pulse_enabled` / `pulse_ingest` for the card description. Quiet empty state when all three counts are zero.

### Horizon card + workloads table

| Field                                      | Meaning                                               |
| ------------------------------------------ | ----------------------------------------------------- |
| `horizon.available`                        | Horizon contracts resolvable                          |
| `horizon.status`                           | `running` if master supervisors exist, else `stopped` |
| `horizon.pending` / `failed` / `processes` | From workload + job repos                             |
| `horizon.jobs_per_minute` / `throughput`   | Metrics repository                                    |
| `horizon.workloads[]`                      | Per-queue `name`, `length`, `wait`, `processes`       |

## What each area answers

- **Insights** — collection size; activity volume; create/update/delete mix on content items.
- **Activity** — recent events and causers; failed auth under suspicious events.
- **File counts / size** — files, folders, bytes, largest files, optional per-disk breakdown.
- **Upload health** — in-progress and stale chunked uploads; stuck/orphan rows.
- **Health** — queue/broadcast/Redis status; Pulse exception/slow-job/slow-query volume; Horizon pending/failed/throughput/workloads.

## Processes needed for Health data

| Process                         | Why                                                                         |
| ------------------------------- | --------------------------------------------------------------------------- |
| `php artisan horizon`           | Redis queue worker + Horizon metrics                                        |
| `php artisan pulse:work`        | Drain Pulse redis ingest into aggregates                                    |
| Herd Reverb (or `reverb:start`) | Not required for Health metrics, but needed for Echo notifications/presence |

See [Minimal vs full stack](https://docs.qiick.io/externa/minimal-vs-full-stack).

## Source map

| Concern         | Location                                            |
| --------------- | --------------------------------------------------- |
| Controller      | `app/Http/Controllers/DashboardController.php`      |
| Health metrics  | `app/Services/Dashboard/DashboardHealthMetrics.php` |
| Page            | `resources/js/pages/dashboard.tsx`                  |
| Permission enum | `app/Enums/PermissionEnum.php` → `CanShowDashboard` |
| File model      | `app/Models/File.php`                               |
| Upload model    | `app/Models/FileUpload.php`                         |
