# Dashboard

A minimal web dashboard for monitoring the Claude email service's task queue and
execution history.

## Overview

The email service processes requests asynchronously via email. This dashboard
provides visibility into queued, in-progress, and completed tasks without
requiring direct access to logs or the server console.

**Key principle**: Monitoring interface with minimal actions (stop running
tasks).

## Requirements

### Core Features

- Display tasks in three states: queued, in-progress, completed
- Show original email subject line for each task
- Show timing information:
  - Queued: time since added to queue
  - In-progress: execution duration so far
  - Completed: total execution time
- Allow inspecting individual task details
- In-memory task tracking for active tasks
- Load past tasks from disk when accessed via direct URL

### Non-Goals

- Authentication (reverse proxy handles this)
- Populating completed tasks list from disk on restart

## Architecture

### Components

The dashboard consists of these components in `airut/dashboard/`:

- **VersionClock + VersionedStore** (`versioned.py`): Global monotonic version
  counter and thread-safe versioned state containers. Every state mutation ticks
  the clock. SSE endpoints wait on it and wake on any change.
- **SSE** (`sse.py`): Server-Sent Events support for real-time dashboard
  updates. Includes SSE message formatting, state stream generator, and
  connection manager to enforce concurrent connection limits.
- **TaskTracker** (`tracker.py`): Thread-safe in-memory task state management
  with bounded history (default 100 completed tasks). Integrates with the shared
  `VersionClock`.
- **DashboardServer** (`server.py`): WSGI application using Werkzeug, runs in
  background thread.

### State Management

All dashboard-visible state flows through versioned interfaces. See
[live-dashboard.md](live-dashboard.md) for the full versioned state design
(VersionClock, VersionedStore, immutability contract, and append-only log
streaming).

Key stores:

- **BootState**: Frozen dataclass wrapped in `VersionedStore[BootState]`.
- **RepoStates**: Frozen dataclass collection wrapped in
  `VersionedStore[tuple[RepoState, ...]]`.
- **TaskTracker**: Uses internal locking and calls `VersionClock.tick()` on
  every mutation. Provides `get_snapshot()` for atomic reads.

### Data Flow

1. Email arrives → `TaskTracker.add_task()` → State: QUEUED
2. Thread pool picks up task → `TaskTracker.start_task()` → State: IN_PROGRESS
3. Execution completes → `TaskTracker.complete_task()` → State: COMPLETED
4. Dashboard request → Render task list or detail view

### Boot Progress Reporting

The dashboard starts immediately when the service launches — before the full
boot sequence (proxy setup, repo initialization) completes. This provides
visibility into startup progress and errors from the moment the service starts.

**Boot phases** (displayed as a banner on the main dashboard):

1. **Starting** — Service initializing
2. **Proxy** — Building proxy image and creating egress network
3. **Repos** — Starting repository listeners (git mirror update, IMAP
   connection)
4. **Ready** — Boot complete, service operational (banner hidden)
5. **Failed** — Boot error with full details (error type, message, traceback)

The main dashboard receives real-time updates via Server-Sent Events (SSE). When
SSE is unavailable, it falls back to ETag-based polling (5-second interval). See
`spec/live-dashboard.md` for the full SSE specification.

The `/api/health` endpoint reflects boot state: `"booting"` during startup,
`"error"` on boot failure, `"ok"` when running with live repos, `"degraded"`
when no repos are live.

**Resilient mode** (`--resilient` CLI flag): When enabled, boot failures don't
crash the service. Instead, the error is displayed on the dashboard and the
service stays alive. The systemd service unit uses this flag to avoid restart
loops caused by configuration issues. Default behavior (without the flag) is to
exit on boot failure.

### Graceful Repo Initialization

During service startup, each repository is initialized independently:

1. Service attempts to start listener for each configured repo
2. If a repo fails (e.g., IMAP auth error, git clone failure), error is recorded
3. Service continues with repos that succeeded
4. If ALL repos fail, service raises RuntimeError and exits (unless resilient
   mode)
5. Dashboard displays status of all repos (live and failed)

This enables partial operation when some repos have issues (e.g., temporary
credential problems) while others continue processing emails.

### HTTP Endpoints

| Route                                   | Method | Description                                 |
| --------------------------------------- | ------ | ------------------------------------------- |
| `/`                                     | GET    | Main dashboard with task lists              |
| `/task/{task_id}`                       | GET    | Task detail view (primary detail page)      |
| `/conversation/{conv_id}`               | GET    | Conversation overview (all tasks + replies) |
| `/conversation/{conv_id}/actions`       | GET    | Actions timeline viewer                     |
| `/conversation/{conv_id}/network`       | GET    | Network logs viewer                         |
| `/repo/{repo_id}`                       | GET    | Repository detail view                      |
| `/api/version`                          | GET    | Structured version info (JSON)              |
| `/api/update`                           | GET    | Upstream update check (JSON)                |
| `/api/repos`                            | GET    | JSON API for repository status (ETag)       |
| `/api/conversations`                    | GET    | JSON API for task list (ETag)               |
| `/api/task/{task_id}`                   | GET    | JSON API for single task by task_id         |
| `/api/conversation/{id}`                | GET    | JSON API for single task by conversation    |
| `/api/conversation/{id}/stop`           | POST   | Stop a running task                         |
| `/api/tracker`                          | GET    | JSON API for full tracker state (ETag)      |
| `/api/events/stream`                    | GET    | SSE state stream (real-time updates)        |
| `/api/conversation/{id}/events/stream`  | GET    | SSE event log stream (per-task)             |
| `/api/conversation/{id}/network/stream` | GET    | SSE network log stream (per-task)           |
| `/api/health`                           | GET    | Health check endpoint (ETag)                |

### `GET /api/tracker`

Returns an atomic snapshot of the full task tracker state. Designed for
integration tests and monitoring tools that need a single request to retrieve
all task data with status counts.

**Response** (`application/json`):

```json
{
  "version": 42,
  "counts": {
    "queued": 0,
    "authenticating": 0,
    "pending": 0,
    "executing": 1,
    "completed": 10
  },
  "tasks": [
    {
      "task_id": "a1b2c3d4e5f6",
      "conversation_id": "a1b2c3d4",
      "display_title": "Help with task",
      "repo_id": "my-repo",
      "sender": "user@example.com",
      "authenticated_sender": "user@example.com",
      "status": "completed",
      "completion_reason": "success",
      "queued_at": 1700000000.0,
      "started_at": 1700000001.0,
      "completed_at": 1700000060.0,
      "model": "sonnet",
      "reply_index": 0
    }
  ]
}
```

| Field                       | Type             | Description                                                                                   |
| --------------------------- | ---------------- | --------------------------------------------------------------------------------------------- |
| `version`                   | `int`            | Monotonic version clock value at time of snapshot                                             |
| `counts`                    | `dict`           | Task counts keyed by status (`queued`, `authenticating`, `pending`, `executing`, `completed`) |
| `tasks`                     | `list[object]`   | All tracked tasks (active + completed history)                                                |
| `tasks[].task_id`           | `string`         | Stable unique task identifier (12-char hex UUID)                                              |
| `tasks[].conversation_id`   | `string`         | Conversation ID (empty until assigned after authentication)                                   |
| `tasks[].status`            | `string`         | One of `queued`, `authenticating`, `pending`, `executing`, `completed`                        |
| `tasks[].completion_reason` | `string \| null` | Why the task completed (e.g., `"success"`, `"auth_failed"`), or `null` while active           |
| `tasks[].model`             | `string \| null` | Claude model used (e.g., `"sonnet"`, `"opus"`), or `null` if not yet known                    |
| `tasks[].reply_index`       | `int \| null`    | Index of this task's reply in the conversation (0-based), or `null` if not yet assigned       |

Supports `ETag` / `If-None-Match` conditional requests — returns
`304 Not Modified` when the version clock has not advanced since the client's
last request.

## Configuration

### Environment Variables

| Variable             | Default     | Description                                     |
| -------------------- | ----------- | ----------------------------------------------- |
| `DASHBOARD_ENABLED`  | `true`      | Enable/disable dashboard                        |
| `DASHBOARD_HOST`     | `127.0.0.1` | Host to bind to                                 |
| `DASHBOARD_PORT`     | `5200`      | Port to bind to                                 |
| `DASHBOARD_BASE_URL` | (optional)  | Public URL for dashboard links in email replies |

Port 5200 chosen to avoid conflict with Fava (5100) and stay in unprivileged
range.

When `DASHBOARD_BASE_URL` is set, acknowledgment emails include a link to track
task progress (e.g.,
`https://dashboard.example.com/conversation/{conversation_id}`).

## Dependencies

Uses only existing project dependencies:

- **werkzeug**: WSGI utilities (via fava dependency)
- **threading**, **dataclasses**, **json**, **html**: stdlib

No new dependencies required.

## UI Design

### Main Dashboard

Three-column layout showing queued, in-progress, and completed tasks.

**Header** displays version information:

- Git commit SHA or version tag (monospace, links to GitHub release/commit page)
- Update status badge: green "up to date" or yellow "update available" (fetched
  asynchronously from `/api/update` to avoid blocking page load). Hover shows
  current and latest version.
- Service start timestamp

**Repository status section** (below header):

- Lists all configured repositories with status indicators
- Green dot for live repos, red dot for failed repos
- Summary count: "N live, M failed"
- Each repo links to its detail page
- Failed repos show error type as a hint

**Task cards** display:

- Task ID (linked to `/task/{task_id}` detail view)
- Conversation ID badge (linked to `/conversation/{conv_id}`, shown only when
  assigned — auth-failed tasks with no conversation_id omit the badge)
- Email subject (truncated)
- Timing information
- Success/failure indicator for completed tasks

### Task Detail (`/task/{task_id}`)

Primary detail view for a single task. Every task has a `task_id` from creation,
so this works for all tasks including auth failures that never receive a
`conversation_id`.

**Summary card** — subject, repository, sender, status, action buttons. If
`conversation_id` is assigned, shows a link to `/conversation/{conv_id}`. Action
buttons (View Actions, View Network) are hidden when there is no
conversation_id.

**Progress section** (active tasks only) — live-updating checklist of Claude's
TodoWrite items. Shows completed (checkmark), in-progress (spinner with
activeForm label), and pending (circle) items. Updated in real-time via the
global SSE state stream (filtered by `task_id`), with automatic fallback to
ETag-based polling when SSE is unavailable. Todo state is tracked as a list of
`TodoItem` dataclass instances (`content`, `status`, `active_form`) — a typed
contract independent of the Claude output parser types. Todos are cleared when a
task completes (success or failure) so that stale progress data is never exposed
via the API or carried over when a conversation is resumed.

**Task Details card** — model, timestamps (queued, started, completed), duration
breakdowns (queue time, execution time, total), message count, cost, and turns
for this task's specific reply.

**Reply section** — shows only the reply associated with this specific task
(matched by `reply_index`). If the task has no associated reply (auth failure,
in-progress, etc.), shows the pending request text or nothing.

### Conversation Overview (`/conversation/{conv_id}`)

Aggregate view showing all tasks for a conversation. Email acknowledgment links
(`{base_url}/conversation/{conv_id}`) point here.

**Summary card** — conversation ID, aggregate stats (total reply count, total
cost, total turns, model).

**Task list** — all tasks for this conversation, each showing:

- Task ID (linked to `/task/{task_id}`)
- Status badge with icon
- Display title (truncated)
- Timing info (queue time, execution duration, etc.)

**Conversation Replies** — full per-reply history with cost, duration, turns,
session ID, token usage, and request/response text.

### Repository Detail

Per-repository view showing:

- Repository ID and status (LIVE or FAILED)
- Git repository URL
- IMAP server hostname
- Storage directory path
- For failed repos: error type and message with full details

### Past Task Loading

When a conversation URL is accessed but no tasks are in the in-memory tracker
(e.g., after a service restart), the dashboard attempts to load a synthetic task
from disk by reading the conversation file from the conversation directory.

This enables:

- Following links in acknowledgment emails after service restarts
- Viewing historical conversation details without keeping all tasks in memory
- API access to past task data including conversation statistics

Past tasks loaded from disk are marked as COMPLETED with a synthetic task_id
(`disk-{conv_id}`) and placeholder subject (`[Past conversation {id}]`). The
main dashboard completed tasks column is NOT populated from disk — only direct
URL access to `/conversation/{conv_id}` or `/task/disk-{conv_id}` triggers disk
loading. Both the HTML task detail page and the JSON API endpoint support the
`disk-{conv_id}` task ID format, falling back to disk when the task is not found
in memory.

### Actions Viewer (`/conversation/{conv_id}/actions`)

Dark-themed terminal-style page that renders the timeline of Claude streaming
events (system, assistant text, tool use/result, result summaries) for a
conversation. Events are grouped per reply, paired with request text and reply
metadata.

**Subagent event annotation**: Events produced by Claude's subagent (Task tool)
calls are visually distinguished in the timeline:

- Events with a non-null `parent_tool_use_id` field are rendered with left
  indentation (`margin-left: 20px`), a colored left border, and a badge showing
  `{subagent_type}:{short_id}` (where `short_id` is the last 6 characters of the
  `parent_tool_use_id`).
- Border and badge colors are assigned deterministically from a fixed palette
  based on the `parent_tool_use_id`, so events from the same subagent share a
  consistent color.
- The label map (`subagent_type` values like "Explore", "CodeReview", etc.) is
  built by pre-scanning the event group for Task `tool_use` blocks and
  extracting the `subagent_type` field from their `tool_input`.
- For SSE-streamed events (arriving after the initial page render), the Task
  context is not available, so a fallback label of `"subagent"` is used instead.

### Styling

- Real-time updates via SSE on all pages (no polling or meta-refresh)
- Responsive layout (single column on mobile)
- Color coding: yellow (queued), blue (in-progress), green (success), red
  (failed)
- Boot banner: blue with spinner (in-progress), red with error details (failed)

## Security

- **No authentication**: Dashboard assumes it's behind a reverse proxy
- **Localhost binding**: Default to 127.0.0.1 to prevent accidental exposure
- **Non-loopback warning**: Logs a warning at startup if bound to a non-loopback
  address (e.g., `0.0.0.0`), reminding operators to place a reverse proxy with
  authentication in front
- **Security response headers**: Every response includes defense-in-depth
  headers regardless of reverse proxy configuration:
  - `X-Content-Type-Options: nosniff` — prevents MIME sniffing
  - `X-Frame-Options: DENY` — blocks iframe embedding (clickjacking)
  - `Content-Security-Policy` — restricts resource loading origins and blocks
    framing
- **CSRF protection on mutating endpoints**: The
  `POST /api/conversation/{id}/stop` endpoint requires an `X-Requested-With`
  header. Browsers will not send custom headers on cross-origin requests without
  a CORS preflight, and the dashboard sets no `Access-Control-Allow-*` headers,
  so the preflight is denied. This prevents malicious websites from triggering
  task stops via cross-origin POST
- **Minimal actions**: Only action is stopping running tasks

The dashboard exposes task IDs, conversation IDs, email subjects, and timing
information. Acceptable for a single-user system behind authentication.

## Future Enhancements

- **Output preview**: Show last N lines of Claude output
- **Conversation browser**: View full conversation history
