feat: snapshot UX (manual trigger + list + restore + preview) (T99)

This commit is contained in:
Joseph Doherty
2026-04-27 03:46:49 -04:00
parent 3dbe1a01ff
commit a5f0e69d44
5 changed files with 441 additions and 0 deletions
+2
View File
@@ -33,6 +33,7 @@ from chat.web.kickoff import router as kickoff_router
from chat.web.middleware import FirstRunRedirectMiddleware
from chat.web.nav import router as nav_router
from chat.web.settings import router as settings_router
from chat.web.snapshots import router as snapshots_router
from chat.web.sse import router as sse_router
from chat.web.turns import router as turns_router
@@ -137,6 +138,7 @@ async def http_exception_handler(request: Request, exc: StarletteHTTPException):
app.include_router(bots_router)
app.include_router(kickoff_router)
app.include_router(settings_router)
app.include_router(snapshots_router)
app.include_router(nav_router)
app.include_router(chat_router)
app.include_router(drawer_router)
+1
View File
@@ -5,6 +5,7 @@
<ul>
<li><a href="/chats" class="{% if active_nav == 'chats' %}active{% endif %}">Chats</a></li>
<li><a href="/bots" class="{% if active_nav == 'bots' %}active{% endif %}">Bots</a></li>
<li><a href="/snapshots" class="{% if active_nav == 'snapshots' %}active{% endif %}">Snapshots</a></li>
<li><a href="/settings" class="{% if active_nav == 'settings' %}active{% endif %}">Settings</a></li>
</ul>
</nav>
+66
View File
@@ -0,0 +1,66 @@
{% extends "layout.html" %}
{% block title %}Snapshots - chat{% endblock %}
{% block content %}
<header class="page-header">
<h1>Snapshots</h1>
<form method="post" action="/snapshots/take" class="inline-edit">
<button type="submit">Take snapshot now</button>
</form>
</header>
{% if preview %}
<section class="snapshot-preview">
<h2>Preview: {{ preview.snapshot_id }}</h2>
<dl>
<dt>kind</dt><dd>{{ preview.kind }}</dd>
<dt>filename</dt><dd>{{ preview.filename }}</dd>
<dt>file size (bytes)</dt><dd>{{ preview.file_size_bytes }}</dd>
<dt>snapshot last_event_id</dt><dd>{{ preview.last_event_id }}</dd>
<dt>current event_log max id</dt><dd>{{ preview.current_event_log_max_id }}</dd>
<dt>events since snapshot</dt><dd>{{ preview.event_delta }}</dd>
<dt>events stored in snapshot</dt><dd>{{ preview.event_log_rows_in_snapshot }}</dd>
</dl>
</section>
{% endif %}
{% if snapshots %}
<table class="snapshot-list">
<thead>
<tr>
<th>ID</th>
<th>Kind</th>
<th>Created (UTC)</th>
<th>Size (bytes)</th>
<th>last_event_id</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for snap in snapshots %}
<tr>
<td>{{ snap.snapshot_id }}</td>
<td>{{ snap.kind }}</td>
<td>{{ snap.created_at }}</td>
<td>{{ snap.file_size_bytes }}</td>
<td>{{ snap.last_event_id if snap.last_event_id is not none else '?' }}</td>
<td>
<a href="/snapshots/{{ snap.snapshot_id }}/preview?kind={{ snap.kind }}">Preview</a>
<details class="snapshot-row-restore">
<summary>Restore</summary>
<form method="post" action="/snapshots/restore/{{ snap.snapshot_id }}" class="inline-edit">
<input type="hidden" name="kind" value="{{ snap.kind }}">
<label>Type "{{ snap.snapshot_id }}" to confirm:
<input type="text" name="confirm_id" required>
</label>
<button type="submit">Restore from this snapshot</button>
</form>
</details>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="muted">No snapshots yet. Use "Take snapshot now" to create one.</p>
{% endif %}
{% endblock %}
+190
View File
@@ -0,0 +1,190 @@
"""Snapshot UX routes (T99).
Surfaces the existing snapshot service (``chat/services/snapshot.py``)
through HTML so the user can see, take, restore, and preview snapshots
without dropping to a shell.
Routes:
* ``GET /snapshots`` list all snapshots (both kinds)
* ``POST /snapshots/take`` take a periodic snapshot now
* ``POST /snapshots/restore/{id}`` restore (requires matching ``confirm_id``)
* ``GET /snapshots/{id}/preview`` show metadata + delta vs current
The ``snapshot_id`` is the filename stem (the UTC timestamp written by
:func:`chat.services.snapshot.take_snapshot`) — there's no separate UUID,
and the timestamp filename is already unique per snapshot kind. Both
periodic and rewind snapshots share the same id space lookup-wise, so
the restore + preview routes accept ``kind`` as a form/query param to
disambiguate.
"""
from __future__ import annotations
import json
from pathlib import Path
from fastapi import APIRouter, Depends, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from chat.services.snapshot import (
restore_from_snapshot,
take_snapshot,
)
from chat.web.bots import get_conn
TEMPLATES = Jinja2Templates(
directory=str(Path(__file__).resolve().parent.parent / "templates")
)
router = APIRouter()
SNAPSHOT_KINDS = ("periodic", "rewind")
def _list_all_snapshots(data_dir: Path) -> list[dict]:
"""Walk ``data/snapshots/{kind}/`` for both kinds and collect metadata.
Each entry exposes the fields the template needs: ``snapshot_id``
(filename stem), ``kind``, ``created_at`` (file mtime as ISO), the
on-disk ``file_size_bytes``, and the snapshot's stored
``last_event_id`` (parsed from the JSON body — small enough that
listing isn't a performance concern for the handful of files we keep).
"""
from datetime import datetime, timezone
rows: list[dict] = []
for kind in SNAPSHOT_KINDS:
snap_dir = data_dir / "snapshots" / kind
if not snap_dir.exists():
continue
for path in sorted(snap_dir.glob("*.json")):
try:
dump = json.loads(path.read_text())
last_event_id = dump.get("last_event_id", 0)
except (OSError, json.JSONDecodeError):
# Corrupt or unreadable files still get listed so the
# user can see and delete them; just don't crash here.
last_event_id = None
stat = path.stat()
rows.append(
{
"snapshot_id": path.stem,
"kind": kind,
"created_at": datetime.fromtimestamp(
stat.st_mtime, tz=timezone.utc
).isoformat(),
"file_size_bytes": stat.st_size,
"last_event_id": last_event_id,
"filename": path.name,
}
)
# Newest first for display.
rows.sort(key=lambda r: r["created_at"], reverse=True)
return rows
def _resolve_snapshot_path(
data_dir: Path, snapshot_id: str, kind: str
) -> Path:
"""Map an ``(id, kind)`` pair to the on-disk file, or 404."""
if kind not in SNAPSHOT_KINDS:
raise HTTPException(status_code=400, detail=f"unknown kind: {kind}")
path = data_dir / "snapshots" / kind / f"{snapshot_id}.json"
if not path.exists():
raise HTTPException(status_code=404, detail="snapshot not found")
return path
@router.get("/snapshots", response_class=HTMLResponse)
async def snapshots_list(request: Request):
settings = request.app.state.settings
rows = _list_all_snapshots(settings.data_dir)
return TEMPLATES.TemplateResponse(
request,
"snapshots.html",
{"snapshots": rows, "active_nav": "snapshots"},
)
@router.post("/snapshots/take")
async def snapshots_take(request: Request, conn=Depends(get_conn)):
"""Take a periodic snapshot now.
We use ``kind="periodic"`` for manual snapshots since they're
user-initiated checkpoints, not pre-rewind safety dumps. They count
against the 5-snapshot retention but that's fine — manual ones are
the most recent so they're the last to be pruned.
"""
settings = request.app.state.settings
take_snapshot(conn, data_dir=settings.data_dir, kind="periodic")
return RedirectResponse(url="/snapshots", status_code=303)
@router.post("/snapshots/restore/{snapshot_id}")
async def snapshots_restore(
snapshot_id: str,
request: Request,
confirm_id: str = Form(""),
kind: str = Form("periodic"),
conn=Depends(get_conn),
):
"""Hard-confirm restore: ``confirm_id`` must equal the path id.
Mismatched confirm → 400 (without touching the DB). On match, the
existing :func:`restore_from_snapshot` clears projected tables and
re-loads them from the dump.
"""
if confirm_id != snapshot_id:
raise HTTPException(
status_code=400,
detail="confirm_id does not match snapshot id",
)
settings = request.app.state.settings
path = _resolve_snapshot_path(settings.data_dir, snapshot_id, kind)
restore_from_snapshot(conn, path)
return RedirectResponse(url="/snapshots", status_code=303)
@router.get("/snapshots/{snapshot_id}/preview", response_class=HTMLResponse)
async def snapshots_preview(
snapshot_id: str,
request: Request,
kind: str = "periodic",
conn=Depends(get_conn),
):
"""Show snapshot metadata + a basic delta against the current event log.
Phase 4 keeps this simple: the snapshot's ``last_event_id`` plus the
current ``MAX(event_log.id)`` is enough to tell the user how far the
log has moved on. A richer per-table diff is a Phase 4.5+ concern.
"""
settings = request.app.state.settings
path = _resolve_snapshot_path(settings.data_dir, snapshot_id, kind)
dump = json.loads(path.read_text())
last_event_id = dump.get("last_event_id", 0)
cur = conn.execute("SELECT MAX(id) FROM event_log")
row = cur.fetchone()
current_max_id = row[0] if row[0] is not None else 0
stat = path.stat()
return TEMPLATES.TemplateResponse(
request,
"snapshots.html",
{
"snapshots": _list_all_snapshots(settings.data_dir),
"active_nav": "snapshots",
"preview": {
"snapshot_id": snapshot_id,
"kind": kind,
"filename": path.name,
"file_size_bytes": stat.st_size,
"last_event_id": last_event_id,
"current_event_log_max_id": current_max_id,
"event_delta": current_max_id - last_event_id,
"event_log_rows_in_snapshot": len(dump.get("event_log", [])),
},
},
)