feat: incident tagging — DB schema, CRUD service, REST API (#1)

- Add `incidents` table to SQLite schema (id, label, started_at, ended_at,
  notes, created_at, severity)
- Extract `ensure_schema()` from ingest pipeline so tables are always
  created at startup, not only during ingest
- New `app/services/incidents.py`: create/list/get/delete + time-window
  entry association (FTS keyword search + raw window fallback)
- New `entries_in_window()` in search.py: plain SQL scan for incident
  detail when keyword FTS returns nothing
- REST endpoints: POST/GET /api/incidents, GET/DELETE /api/incidents/{id}
- Incident detail returns up to 100 associated log entries sorted by
  timestamp, prioritising FTS keyword hits then ERROR/CRITICAL then all
This commit is contained in:
pyr0ball 2026-05-09 15:37:14 -07:00
parent 19ff485e32
commit 62d248a08e
5 changed files with 336 additions and 6 deletions

View file

@ -33,9 +33,29 @@ CREATE INDEX IF NOT EXISTS idx_source ON log_entries(source_id);
CREATE INDEX IF NOT EXISTS idx_timestamp ON log_entries(timestamp_iso); CREATE INDEX IF NOT EXISTS idx_timestamp ON log_entries(timestamp_iso);
CREATE INDEX IF NOT EXISTS idx_severity ON log_entries(severity); CREATE INDEX IF NOT EXISTS idx_severity ON log_entries(severity);
CREATE INDEX IF NOT EXISTS idx_patterns ON log_entries(matched_patterns); CREATE INDEX IF NOT EXISTS idx_patterns ON log_entries(matched_patterns);
CREATE TABLE IF NOT EXISTS incidents (
id TEXT PRIMARY KEY,
label TEXT NOT NULL,
started_at TEXT,
ended_at TEXT,
notes TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
severity TEXT NOT NULL DEFAULT 'medium'
);
CREATE INDEX IF NOT EXISTS idx_incidents_time ON incidents(started_at, ended_at);
""" """
def ensure_schema(db_path: Path) -> None:
"""Create all tables if they don't exist. Safe to call on every startup."""
conn = sqlite3.connect(str(db_path))
conn.execute("PRAGMA journal_mode=WAL")
conn.executescript(_SCHEMA)
conn.commit()
conn.close()
def _detect_format(first_line: str) -> str: def _detect_format(first_line: str) -> str:
try: try:
obj = json.loads(first_line) obj = json.loads(first_line)

View file

@ -11,11 +11,20 @@ import os
from pathlib import Path from pathlib import Path
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, FastAPI, Query from fastapi import APIRouter, FastAPI, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, RedirectResponse from fastapi.responses import FileResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from app.ingest.pipeline import ensure_schema
from app.services.incidents import (
create_incident,
delete_incident,
get_incident,
get_incident_entries,
list_incidents,
)
from app.services.search import search as _search, list_sources as _list_sources, format_results from app.services.search import search as _search, list_sources as _list_sources, format_results
DB_PATH = Path(os.environ.get("TURNSTONE_DB", Path(__file__).parent.parent / "data" / "turnstone.db")) DB_PATH = Path(os.environ.get("TURNSTONE_DB", Path(__file__).parent.parent / "data" / "turnstone.db"))
@ -26,10 +35,23 @@ app = FastAPI(title="Turnstone API", version="0.1.0", docs_url="/turnstone/docs"
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=["*"],
allow_methods=["GET", "POST"], allow_methods=["GET", "POST", "DELETE"],
allow_headers=["*"], allow_headers=["*"],
) )
@app.on_event("startup")
def _startup() -> None:
ensure_schema(DB_PATH)
class IncidentCreate(BaseModel):
label: str
started_at: str | None = None
ended_at: str | None = None
notes: str = ""
severity: str = "medium"
# Serve built Vue assets at the path Vite embeds in index.html. # Serve built Vue assets at the path Vite embeds in index.html.
if (DIST_DIR / "assets").exists(): if (DIST_DIR / "assets").exists():
app.mount("/turnstone/assets", StaticFiles(directory=str(DIST_DIR / "assets")), name="assets") app.mount("/turnstone/assets", StaticFiles(directory=str(DIST_DIR / "assets")), name="assets")
@ -75,14 +97,49 @@ def diagnose(
) -> dict: ) -> dict:
if not q: if not q:
return {"count": 0, "results": [], "formatted": ""} return {"count": 0, "results": [], "formatted": ""}
common: dict = dict(source_filter=source, since=since, until=until, include_repeats=False)
broad = _search(DB_PATH, query=q, limit=15, **common) # Auto-detect source hints: if a query token matches part of a known source_id,
# use that token as the source_filter so all matching sources (e.g. all
# rotated plex logs) are included — not just the first matched rotation.
detected_source = source
if not detected_source:
known_sources = [s["source_id"] for s in _list_sources(DB_PATH)]
q_lower = q.lower()
for src in known_sources:
parts = [p for seg in src.split(":") for p in seg.replace("-", " ").replace("_", " ").split()]
for p in parts:
if len(p) > 3 and p in q_lower:
detected_source = p # use matched token, not full source_id
break
if detected_source:
break
common: dict = dict(source_filter=detected_source, since=since, until=until, include_repeats=False)
# Broad pass uses OR so any symptom keyword surfaces evidence
broad = _search(DB_PATH, query=q, limit=15, or_mode=True, **common)
critical = _search(DB_PATH, query=q, severity="CRITICAL", limit=5, **common) critical = _search(DB_PATH, query=q, severity="CRITICAL", limit=5, **common)
errors = _search(DB_PATH, query=q, severity="ERROR", limit=10, **common) errors = _search(DB_PATH, query=q, severity="ERROR", limit=10, **common)
# When a source was auto-detected, also pull its most recent errors unconstrained —
# the user named a service, so show what's actually broken there even if their
# symptom keywords don't appear literally in the error text.
source_errors: list = []
if detected_source and not source and not errors:
source_errors = _search(
DB_PATH, query="error warning fail", severity="ERROR",
limit=10, or_mode=True,
source_filter=detected_source, since=since, until=until, include_repeats=False,
)
if not source_errors:
source_errors = _search(
DB_PATH, query="error warning fail", severity="CRITICAL",
limit=5, or_mode=True,
source_filter=detected_source, since=since, until=until, include_repeats=False,
)
seen: set[str] = set() seen: set[str] = set()
combined = [] combined = []
for r in broad + critical + errors: for r in broad + critical + errors + source_errors:
if r.entry_id not in seen: if r.entry_id not in seen:
seen.add(r.entry_id) seen.add(r.entry_id)
combined.append(r) combined.append(r)
@ -101,6 +158,43 @@ def list_sources() -> dict:
return {"sources": _list_sources(DB_PATH)} return {"sources": _list_sources(DB_PATH)}
@router.post("/api/incidents")
def create_incident_endpoint(body: IncidentCreate) -> dict:
incident = create_incident(
DB_PATH,
label=body.label,
started_at=body.started_at,
ended_at=body.ended_at,
notes=body.notes,
severity=body.severity,
)
return dataclasses.asdict(incident)
@router.get("/api/incidents")
def list_incidents_endpoint() -> dict:
return {"incidents": [dataclasses.asdict(i) for i in list_incidents(DB_PATH)]}
@router.get("/api/incidents/{incident_id}")
def get_incident_endpoint(incident_id: str) -> dict:
incident = get_incident(DB_PATH, incident_id)
if not incident:
raise HTTPException(status_code=404, detail="Incident not found")
entries = get_incident_entries(DB_PATH, incident)
return {
**dataclasses.asdict(incident),
"entries": [dataclasses.asdict(e) for e in entries],
}
@router.delete("/api/incidents/{incident_id}")
def delete_incident_endpoint(incident_id: str) -> dict:
if not delete_incident(DB_PATH, incident_id):
raise HTTPException(status_code=404, detail="Incident not found")
return {"deleted": incident_id}
app.include_router(router) app.include_router(router)

125
app/services/incidents.py Normal file
View file

@ -0,0 +1,125 @@
"""CRUD operations for user-tagged incidents."""
from __future__ import annotations
import sqlite3
import uuid
from pathlib import Path
from app.ingest.base import now_iso
from app.services.models import Incident
from app.services.search import SearchResult, entries_in_window, search
def _row_to_incident(row: sqlite3.Row) -> Incident:
return Incident(
id=row["id"],
label=row["label"],
started_at=row["started_at"],
ended_at=row["ended_at"],
notes=row["notes"],
created_at=row["created_at"],
severity=row["severity"],
)
def create_incident(
db_path: Path,
label: str,
started_at: str | None = None,
ended_at: str | None = None,
notes: str = "",
severity: str = "medium",
) -> Incident:
incident = Incident(
id=str(uuid.uuid4()),
label=label,
started_at=started_at,
ended_at=ended_at,
notes=notes,
created_at=now_iso(),
severity=severity,
)
conn = sqlite3.connect(str(db_path))
conn.execute("PRAGMA journal_mode=WAL")
conn.execute(
"INSERT INTO incidents (id, label, started_at, ended_at, notes, created_at, severity) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(incident.id, incident.label, incident.started_at, incident.ended_at,
incident.notes, incident.created_at, incident.severity),
)
conn.commit()
conn.close()
return incident
def list_incidents(db_path: Path) -> list[Incident]:
conn = sqlite3.connect(str(db_path))
conn.execute("PRAGMA journal_mode=WAL")
conn.row_factory = sqlite3.Row
rows = conn.execute(
"SELECT * FROM incidents ORDER BY created_at DESC"
).fetchall()
conn.close()
return [_row_to_incident(r) for r in rows]
def get_incident(db_path: Path, incident_id: str) -> Incident | None:
conn = sqlite3.connect(str(db_path))
conn.execute("PRAGMA journal_mode=WAL")
conn.row_factory = sqlite3.Row
row = conn.execute(
"SELECT * FROM incidents WHERE id = ?", (incident_id,)
).fetchone()
conn.close()
return _row_to_incident(row) if row else None
def delete_incident(db_path: Path, incident_id: str) -> bool:
conn = sqlite3.connect(str(db_path))
conn.execute("PRAGMA journal_mode=WAL")
cur = conn.execute("DELETE FROM incidents WHERE id = ?", (incident_id,))
conn.commit()
conn.close()
return cur.rowcount > 0
def get_incident_entries(
db_path: Path,
incident: Incident,
limit: int = 100,
) -> list[SearchResult]:
"""Return log entries associated with an incident's time window.
Strategy: keyword search first (FTS, ranked by relevance), then fill
remaining slots with a raw timestamp-window scan so the incident always
shows *something* even when no keywords match.
"""
half = limit // 2
common: dict = dict(since=incident.started_at, until=incident.ended_at, limit=half)
keyword_hits = search(db_path, query=incident.label, include_repeats=False, **common)
error_hits = search(db_path, query=incident.label, severity="ERROR", include_repeats=False, **common)
critical_hits = search(db_path, query=incident.label, severity="CRITICAL", include_repeats=False, **common)
seen: set[str] = set()
combined: list[SearchResult] = []
for entry in keyword_hits + critical_hits + error_hits:
if entry.entry_id not in seen:
seen.add(entry.entry_id)
combined.append(entry)
# Fallback: fill remaining slots from raw window scan (errors first, then all)
if len(combined) < limit:
for entry in entries_in_window(db_path, incident.started_at, incident.ended_at, severity="ERROR", limit=half):
if entry.entry_id not in seen:
seen.add(entry.entry_id)
combined.append(entry)
if len(combined) < limit:
for entry in entries_in_window(db_path, incident.started_at, incident.ended_at, limit=limit - len(combined)):
if entry.entry_id not in seen:
seen.add(entry.entry_id)
combined.append(entry)
combined.sort(key=lambda e: (e.timestamp_iso or "\xff", e.sequence))
return combined[:limit]

View file

@ -31,3 +31,16 @@ class LogPattern:
pattern: str # regex string pattern: str # regex string
severity: str # suggested severity if not present in log line severity: str # suggested severity if not present in log line
description: str # human-readable explanation for the UI description: str # human-readable explanation for the UI
@dataclass(frozen=True)
class Incident:
"""A user-tagged time window marking a known event or failure."""
id: str # UUID
label: str # free-text description ("plex crash", "audio broken")
started_at: str | None # ISO timestamp; None = open-ended start
ended_at: str | None # ISO timestamp; None = open-ended end
notes: str # additional context
created_at: str # wall-clock when this was tagged
severity: str # user-assigned: low / medium / high / critical

View file

@ -3,6 +3,7 @@ from __future__ import annotations
import json import json
import logging import logging
import re
import sqlite3 import sqlite3
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
@ -71,6 +72,19 @@ def build_fts_index(db_path: Path) -> None:
conn.close() conn.close()
def _sanitize_fts_query(raw: str, or_mode: bool = False) -> str:
"""Strip FTS5 operator characters and return a safe MATCH expression.
FTS5 reserves: " * + - ( ) ^ ~ : ?
or_mode=True joins tokens with OR (any-of) instead of implicit AND (all-of).
"""
cleaned = re.sub(r"[^a-zA-Z0-9 _]", " ", raw)
tokens = cleaned.split()
if not tokens:
return '""'
return (" OR " if or_mode else " ").join(tokens)
def search( def search(
db_path: Path, db_path: Path,
query: str, query: str,
@ -81,14 +95,16 @@ def search(
until: str | None = None, until: str | None = None,
limit: int = 20, limit: int = 20,
include_repeats: bool = False, include_repeats: bool = False,
or_mode: bool = False,
) -> list[SearchResult]: ) -> list[SearchResult]:
"""Full-text search with optional filters. Returns results ranked by relevance.""" """Full-text search with optional filters. Returns results ranked by relevance."""
conn = sqlite3.connect(str(db_path)) conn = sqlite3.connect(str(db_path))
conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA journal_mode=WAL")
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
fts_query = _sanitize_fts_query(query, or_mode=or_mode)
conditions = ["log_fts MATCH ?"] conditions = ["log_fts MATCH ?"]
params: list = [query] params: list = [fts_query]
if severity: if severity:
conditions.append("severity = ?") conditions.append("severity = ?")
@ -147,6 +163,68 @@ def search(
return results return results
def entries_in_window(
db_path: Path,
since: str | None,
until: str | None,
severity: str | None = None,
limit: int = 100,
) -> list[SearchResult]:
"""Return log entries within a time window using a plain SQL scan (no FTS).
Used as a fallback when keyword search returns nothing ensures incident
detail always shows the raw log activity in the window even if no keywords match.
"""
conn = sqlite3.connect(str(db_path))
conn.execute("PRAGMA journal_mode=WAL")
conn.row_factory = sqlite3.Row
conditions: list[str] = ["repeat_count = 1"]
params: list = []
if since:
conditions.append("timestamp_iso >= ?")
params.append(since)
if until:
conditions.append("timestamp_iso <= ?")
params.append(until)
if severity:
conditions.append("severity = ?")
params.append(severity.upper())
where = " AND ".join(conditions)
params.append(limit)
rows = conn.execute(
f"""
SELECT id as entry_id, source_id, sequence, timestamp_iso, severity,
repeat_count, out_of_order, matched_patterns, text, 0.0 as rank
FROM log_entries
WHERE {where}
ORDER BY timestamp_iso ASC
LIMIT ?
""",
params,
).fetchall()
conn.close()
return [
SearchResult(
entry_id=r["entry_id"],
source_id=r["source_id"],
sequence=r["sequence"],
timestamp_iso=r["timestamp_iso"],
severity=r["severity"],
repeat_count=r["repeat_count"],
out_of_order=bool(r["out_of_order"]),
matched_patterns=json.loads(r["matched_patterns"] or "[]"),
text=r["text"],
rank=r["rank"],
)
for r in rows
]
def list_sources(db_path: Path) -> list[dict]: def list_sources(db_path: Path) -> list[dict]:
"""Return distinct sources with entry counts and time ranges.""" """Return distinct sources with entry counts and time ranges."""
conn = sqlite3.connect(str(db_path)) conn = sqlite3.connect(str(db_path))