feat: incident labeling, bundle export, and push/receive flow

Turnstone incidents now carry an issue_type tag (free-text with datalist
suggestions) used to categorize patterns for signature building.

Backend:
- Incident model gains issue_type; additive ALTER TABLE migration keeps
  existing DBs working without a full schema rebuild
- New received_bundles table stores incoming JSON bundles with indexes on
  bundled_at and issue_type
- build_bundle() assembles incident + related log entries into a versioned
  bundle dict; store_bundle()/list_bundles()/get_bundle() for the receiver
- POST /api/incidents/{id}/send — pushes bundle to TURNSTONE_BUNDLE_ENDPOINT
- GET  /api/incidents/{id}/bundle — export without sending
- POST /api/bundles — receive and store an incoming bundle
- GET  /api/bundles — list all received bundles
- TURNSTONE_SOURCE_HOST and TURNSTONE_BUNDLE_ENDPOINT env vars; auto-set
  source host from hostname in podman-standalone.sh

Frontend:
- Incidents form: issue_type field with datalist suggestions; Type column
  in the table; Send Bundle button + status feedback in the detail drawer
- New BundlesView: collapsible bundle rows, inline JSON parse (no extra
  round-trip), Export JSON download button
- Router and nav updated with /bundles route
This commit is contained in:
pyr0ball 2026-05-11 05:23:55 -07:00
parent 9e2ed0a932
commit 8d5324f1fe
9 changed files with 482 additions and 20 deletions

View file

@ -38,6 +38,7 @@ CREATE INDEX IF NOT EXISTS idx_patterns ON log_entries(matched_patterns);
CREATE TABLE IF NOT EXISTS incidents ( CREATE TABLE IF NOT EXISTS incidents (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
label TEXT NOT NULL, label TEXT NOT NULL,
issue_type TEXT NOT NULL DEFAULT '',
started_at TEXT, started_at TEXT,
ended_at TEXT, ended_at TEXT,
notes TEXT NOT NULL DEFAULT '', notes TEXT NOT NULL DEFAULT '',
@ -45,14 +46,36 @@ CREATE TABLE IF NOT EXISTS incidents (
severity TEXT NOT NULL DEFAULT 'medium' severity TEXT NOT NULL DEFAULT 'medium'
); );
CREATE INDEX IF NOT EXISTS idx_incidents_time ON incidents(started_at, ended_at); CREATE INDEX IF NOT EXISTS idx_incidents_time ON incidents(started_at, ended_at);
CREATE TABLE IF NOT EXISTS received_bundles (
id TEXT PRIMARY KEY,
source_host TEXT NOT NULL,
issue_type TEXT NOT NULL DEFAULT '',
label TEXT NOT NULL,
severity TEXT NOT NULL DEFAULT 'medium',
started_at TEXT,
bundled_at TEXT NOT NULL,
entry_count INTEGER NOT NULL DEFAULT 0,
bundle_json TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_bundles_bundled ON received_bundles(bundled_at);
CREATE INDEX IF NOT EXISTS idx_bundles_type ON received_bundles(issue_type);
""" """
def ensure_schema(db_path: Path) -> None: def ensure_schema(db_path: Path) -> None:
"""Create all tables if they don't exist. Safe to call on every startup.""" """Create all tables and apply additive migrations. Safe to call on every startup."""
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.executescript(_SCHEMA) conn.executescript(_SCHEMA)
# Additive column migrations — ALTER TABLE silently skips if column exists
for stmt in [
"ALTER TABLE incidents ADD COLUMN issue_type TEXT NOT NULL DEFAULT ''",
]:
try:
conn.execute(stmt)
except sqlite3.OperationalError:
pass
conn.commit() conn.commit()
conn.close() conn.close()

View file

@ -7,7 +7,10 @@ Caddy (menagerie.circuitforge.tech/turnstone) without prefix stripping.
from __future__ import annotations from __future__ import annotations
import dataclasses import dataclasses
import json
import os import os
import urllib.error
import urllib.request
from pathlib import Path from pathlib import Path
from typing import Annotated from typing import Annotated
@ -19,11 +22,15 @@ from pydantic import BaseModel
from app.ingest.pipeline import ensure_schema from app.ingest.pipeline import ensure_schema
from app.services.incidents import ( from app.services.incidents import (
build_bundle,
create_incident, create_incident,
delete_incident, delete_incident,
get_bundle,
get_incident, get_incident,
get_incident_entries, get_incident_entries,
list_bundles,
list_incidents, list_incidents,
store_bundle,
) )
from app.services.search import ( from app.services.search import (
search as _search, search as _search,
@ -35,6 +42,8 @@ from app.services.search import (
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"))
DIST_DIR = Path(__file__).parent.parent / "web" / "dist" DIST_DIR = Path(__file__).parent.parent / "web" / "dist"
SOURCE_HOST = os.environ.get("TURNSTONE_SOURCE_HOST", "unknown")
BUNDLE_ENDPOINT = os.environ.get("TURNSTONE_BUNDLE_ENDPOINT", "")
app = FastAPI(title="Turnstone API", version="0.1.0", docs_url="/turnstone/docs", redoc_url=None) app = FastAPI(title="Turnstone API", version="0.1.0", docs_url="/turnstone/docs", redoc_url=None)
@ -53,6 +62,7 @@ def _startup() -> None:
class IncidentCreate(BaseModel): class IncidentCreate(BaseModel):
label: str label: str
issue_type: str = ""
started_at: str | None = None started_at: str | None = None
ended_at: str | None = None ended_at: str | None = None
notes: str = "" notes: str = ""
@ -174,6 +184,7 @@ def create_incident_endpoint(body: IncidentCreate) -> dict:
incident = create_incident( incident = create_incident(
DB_PATH, DB_PATH,
label=body.label, label=body.label,
issue_type=body.issue_type,
started_at=body.started_at, started_at=body.started_at,
ended_at=body.ended_at, ended_at=body.ended_at,
notes=body.notes, notes=body.notes,
@ -206,6 +217,58 @@ def delete_incident_endpoint(incident_id: str) -> dict:
return {"deleted": incident_id} return {"deleted": incident_id}
@router.get("/api/incidents/{incident_id}/bundle")
def get_incident_bundle(incident_id: str) -> dict:
incident = get_incident(DB_PATH, incident_id)
if not incident:
raise HTTPException(status_code=404, detail="Incident not found")
return build_bundle(DB_PATH, incident, source_host=SOURCE_HOST)
@router.post("/api/incidents/{incident_id}/send")
def send_incident_bundle(incident_id: str) -> dict:
if not BUNDLE_ENDPOINT:
raise HTTPException(status_code=503, detail="TURNSTONE_BUNDLE_ENDPOINT not configured")
incident = get_incident(DB_PATH, incident_id)
if not incident:
raise HTTPException(status_code=404, detail="Incident not found")
bundle = build_bundle(DB_PATH, incident, source_host=SOURCE_HOST)
payload = json.dumps(bundle).encode()
req = urllib.request.Request(
BUNDLE_ENDPOINT,
data=payload,
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=15) as resp:
return {"sent": True, "status": resp.status, "entry_count": len(bundle["log_entries"])}
except urllib.error.HTTPError as exc:
raise HTTPException(status_code=502, detail=f"Receiver returned {exc.code}") from exc
except OSError as exc:
raise HTTPException(status_code=502, detail=f"Send failed: {exc}") from exc
@router.post("/api/bundles")
def receive_bundle(bundle: dict) -> dict:
record = store_bundle(DB_PATH, bundle)
return {"id": record.id, "entry_count": record.entry_count}
@router.get("/api/bundles")
def list_bundles_endpoint() -> dict:
bundles = list_bundles(DB_PATH)
return {"bundles": [dataclasses.asdict(b) for b in bundles]}
@router.get("/api/bundles/{bundle_id}")
def get_bundle_endpoint(bundle_id: str) -> dict:
bundle = get_bundle(DB_PATH, bundle_id)
if not bundle:
raise HTTPException(status_code=404, detail="Bundle not found")
return dataclasses.asdict(bundle)
app.include_router(router) app.include_router(router)

View file

@ -1,12 +1,13 @@
"""CRUD operations for user-tagged incidents.""" """CRUD operations for user-tagged incidents and received log bundles."""
from __future__ import annotations from __future__ import annotations
import json
import sqlite3 import sqlite3
import uuid import uuid
from pathlib import Path from pathlib import Path
from app.ingest.base import now_iso from app.ingest.base import now_iso
from app.services.models import Incident from app.services.models import Incident, ReceivedBundle
from app.services.search import SearchResult, entries_in_window, search from app.services.search import SearchResult, entries_in_window, search
@ -14,6 +15,7 @@ def _row_to_incident(row: sqlite3.Row) -> Incident:
return Incident( return Incident(
id=row["id"], id=row["id"],
label=row["label"], label=row["label"],
issue_type=row["issue_type"] if "issue_type" in row.keys() else "",
started_at=row["started_at"], started_at=row["started_at"],
ended_at=row["ended_at"], ended_at=row["ended_at"],
notes=row["notes"], notes=row["notes"],
@ -22,9 +24,24 @@ def _row_to_incident(row: sqlite3.Row) -> Incident:
) )
def _row_to_bundle(row: sqlite3.Row) -> ReceivedBundle:
return ReceivedBundle(
id=row["id"],
source_host=row["source_host"],
issue_type=row["issue_type"],
label=row["label"],
severity=row["severity"],
started_at=row["started_at"],
bundled_at=row["bundled_at"],
entry_count=row["entry_count"],
bundle_json=row["bundle_json"],
)
def create_incident( def create_incident(
db_path: Path, db_path: Path,
label: str, label: str,
issue_type: str = "",
started_at: str | None = None, started_at: str | None = None,
ended_at: str | None = None, ended_at: str | None = None,
notes: str = "", notes: str = "",
@ -33,6 +50,7 @@ def create_incident(
incident = Incident( incident = Incident(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
label=label, label=label,
issue_type=issue_type,
started_at=started_at, started_at=started_at,
ended_at=ended_at, ended_at=ended_at,
notes=notes, notes=notes,
@ -42,10 +60,10 @@ def create_incident(
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.execute( conn.execute(
"INSERT INTO incidents (id, label, started_at, ended_at, notes, created_at, severity) " "INSERT INTO incidents (id, label, issue_type, started_at, ended_at, notes, created_at, severity) "
"VALUES (?, ?, ?, ?, ?, ?, ?)", "VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(incident.id, incident.label, incident.started_at, incident.ended_at, (incident.id, incident.label, incident.issue_type, incident.started_at,
incident.notes, incident.created_at, incident.severity), incident.ended_at, incident.notes, incident.created_at, incident.severity),
) )
conn.commit() conn.commit()
conn.close() conn.close()
@ -88,12 +106,7 @@ def get_incident_entries(
incident: Incident, incident: Incident,
limit: int = 100, limit: int = 100,
) -> list[SearchResult]: ) -> list[SearchResult]:
"""Return log entries associated with an incident's time window. """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 half = limit // 2
common: dict = dict(since=incident.started_at, until=incident.ended_at, limit=half) common: dict = dict(since=incident.started_at, until=incident.ended_at, limit=half)
@ -108,7 +121,6 @@ def get_incident_entries(
seen.add(entry.entry_id) seen.add(entry.entry_id)
combined.append(entry) combined.append(entry)
# Fallback: fill remaining slots from raw window scan (errors first, then all)
if len(combined) < limit: if len(combined) < limit:
for entry in entries_in_window(db_path, incident.started_at, incident.ended_at, severity="ERROR", limit=half): 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: if entry.entry_id not in seen:
@ -123,3 +135,89 @@ def get_incident_entries(
combined.sort(key=lambda e: (e.timestamp_iso or "\xff", e.sequence)) combined.sort(key=lambda e: (e.timestamp_iso or "\xff", e.sequence))
return combined[:limit] return combined[:limit]
def build_bundle(
db_path: Path,
incident: Incident,
source_host: str,
limit: int = 200,
) -> dict:
"""Assemble a labeled bundle: incident metadata + related log entries."""
entries = get_incident_entries(db_path, incident, limit=limit)
return {
"bundle_version": 1,
"source_host": source_host,
"bundled_at": now_iso(),
"incident": {
"id": incident.id,
"label": incident.label,
"issue_type": incident.issue_type,
"started_at": incident.started_at,
"ended_at": incident.ended_at,
"severity": incident.severity,
"notes": incident.notes,
},
"log_entries": [
{
"entry_id": e.entry_id,
"source_id": e.source_id,
"timestamp_iso": e.timestamp_iso,
"severity": e.severity,
"text": e.text,
"matched_patterns": list(e.matched_patterns),
}
for e in entries
],
}
def store_bundle(db_path: Path, bundle: dict) -> ReceivedBundle:
"""Store an incoming bundle from a remote Turnstone instance."""
inc = bundle.get("incident", {})
record = ReceivedBundle(
id=str(uuid.uuid4()),
source_host=bundle.get("source_host", "unknown"),
issue_type=inc.get("issue_type", ""),
label=inc.get("label", ""),
severity=inc.get("severity", "medium"),
started_at=inc.get("started_at"),
bundled_at=bundle.get("bundled_at", now_iso()),
entry_count=len(bundle.get("log_entries", [])),
bundle_json=json.dumps(bundle),
)
conn = sqlite3.connect(str(db_path))
conn.execute("PRAGMA journal_mode=WAL")
conn.execute(
"INSERT INTO received_bundles "
"(id, source_host, issue_type, label, severity, started_at, bundled_at, entry_count, bundle_json) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(record.id, record.source_host, record.issue_type, record.label,
record.severity, record.started_at, record.bundled_at, record.entry_count, record.bundle_json),
)
conn.commit()
conn.close()
return record
def list_bundles(db_path: Path) -> list[ReceivedBundle]:
conn = sqlite3.connect(str(db_path))
conn.execute("PRAGMA journal_mode=WAL")
conn.row_factory = sqlite3.Row
rows = conn.execute(
"SELECT id, source_host, issue_type, label, severity, started_at, bundled_at, entry_count, bundle_json "
"FROM received_bundles ORDER BY bundled_at DESC"
).fetchall()
conn.close()
return [_row_to_bundle(r) for r in rows]
def get_bundle(db_path: Path, bundle_id: str) -> ReceivedBundle | None:
conn = sqlite3.connect(str(db_path))
conn.execute("PRAGMA journal_mode=WAL")
conn.row_factory = sqlite3.Row
row = conn.execute(
"SELECT * FROM received_bundles WHERE id = ?", (bundle_id,)
).fetchone()
conn.close()
return _row_to_bundle(row) if row else None

View file

@ -39,8 +39,24 @@ class Incident:
id: str # UUID id: str # UUID
label: str # free-text description ("plex crash", "audio broken") label: str # free-text description ("plex crash", "audio broken")
issue_type: str # short category tag for pattern building ("qbit_stall", "auth_failure")
started_at: str | None # ISO timestamp; None = open-ended start started_at: str | None # ISO timestamp; None = open-ended start
ended_at: str | None # ISO timestamp; None = open-ended end ended_at: str | None # ISO timestamp; None = open-ended end
notes: str # additional context notes: str # additional context
created_at: str # wall-clock when this was tagged created_at: str # wall-clock when this was tagged
severity: str # user-assigned: low / medium / high / critical severity: str # user-assigned: low / medium / high / critical
@dataclass(frozen=True)
class ReceivedBundle:
"""A labeled incident bundle received from a remote Turnstone instance."""
id: str
source_host: str
issue_type: str
label: str
severity: str
started_at: str | None
bundled_at: str
entry_count: int
bundle_json: str # full bundle serialized as JSON string

View file

@ -59,6 +59,15 @@ DATA_DIR=/opt/turnstone/data
PATTERNS_DIR=/opt/turnstone/patterns PATTERNS_DIR=/opt/turnstone/patterns
TZ=America/Los_Angeles TZ=America/Los_Angeles
# ── Bundle push configuration ────────────────────────────────────────────────
# Set TURNSTONE_BUNDLE_ENDPOINT before running this script to enable the
# "Send Bundle" button in the Incidents UI:
#
# export TURNSTONE_BUNDLE_ENDPOINT=https://turnstone.circuitforge.tech/turnstone/api/bundles
# bash /opt/turnstone/podman-standalone.sh
#
# TURNSTONE_SOURCE_HOST is auto-detected from `hostname` — override if needed.
# ── Log source bind mounts ──────────────────────────────────────────────────── # ── Log source bind mounts ────────────────────────────────────────────────────
# Add or remove mount flags below for each service whose logs you want to ingest. # Add or remove mount flags below for each service whose logs you want to ingest.
# Inside the container, paths appear under /logs/<service>/ # Inside the container, paths appear under /logs/<service>/
@ -81,6 +90,8 @@ podman run -d \
-v "${PATTERNS_DIR}:/patterns:Z" \ -v "${PATTERNS_DIR}:/patterns:Z" \
-v "${QBIT_LOGS}:/logs/qbittorrent:ro" \ -v "${QBIT_LOGS}:/logs/qbittorrent:ro" \
-e TURNSTONE_DB=/data/turnstone.db \ -e TURNSTONE_DB=/data/turnstone.db \
-e TURNSTONE_SOURCE_HOST="$(hostname)" \
-e TURNSTONE_BUNDLE_ENDPOINT="${TURNSTONE_BUNDLE_ENDPOINT:-}" \
-e PYTHONUNBUFFERED=1 \ -e PYTHONUNBUFFERED=1 \
-e TZ="${TZ}" \ -e TZ="${TZ}" \
--health-cmd="curl -f http://localhost:8534/turnstone/health || exit 1" \ --health-cmd="curl -f http://localhost:8534/turnstone/health || exit 1" \

View file

@ -40,6 +40,7 @@ const navLinks = [
{ to: '/search', label: 'Search' }, { to: '/search', label: 'Search' },
{ to: '/diagnose', label: 'Diagnose' }, { to: '/diagnose', label: 'Diagnose' },
{ to: '/incidents', label: 'Incidents' }, { to: '/incidents', label: 'Incidents' },
{ to: '/bundles', label: 'Bundles' },
{ to: '/sources', label: 'Sources' }, { to: '/sources', label: 'Sources' },
] ]

View file

@ -4,6 +4,7 @@ import LogSearchView from '@/views/LogSearchView.vue'
import DiagnoseView from '@/views/DiagnoseView.vue' import DiagnoseView from '@/views/DiagnoseView.vue'
import SourcesView from '@/views/SourcesView.vue' import SourcesView from '@/views/SourcesView.vue'
import IncidentsView from '@/views/IncidentsView.vue' import IncidentsView from '@/views/IncidentsView.vue'
import BundlesView from '@/views/BundlesView.vue'
export default createRouter({ export default createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
@ -12,7 +13,8 @@ export default createRouter({
{ path: '/dashboard', component: DashboardView }, { path: '/dashboard', component: DashboardView },
{ path: '/search', component: LogSearchView }, { path: '/search', component: LogSearchView },
{ path: '/diagnose', component: DiagnoseView }, { path: '/diagnose', component: DiagnoseView },
{ path: '/sources', component: SourcesView },
{ path: '/incidents', component: IncidentsView }, { path: '/incidents', component: IncidentsView },
{ path: '/bundles', component: BundlesView },
{ path: '/sources', component: SourcesView },
], ],
}) })

View file

@ -0,0 +1,183 @@
<template>
<div class="p-6 max-w-5xl mx-auto">
<!-- Header -->
<div class="mb-6">
<h1 class="text-text-primary text-xl font-semibold mb-1">Received Bundles</h1>
<p class="text-text-dim text-sm">Labeled incident bundles sent from remote Turnstone instances. Use these to build detection signatures.</p>
</div>
<div v-if="loading" class="text-text-dim py-8 text-center text-sm">Loading</div>
<div v-else-if="bundles.length === 0" class="rounded border border-surface-border bg-surface-raised p-8 text-center">
<p class="text-text-muted text-base mb-1">No bundles received yet.</p>
<p class="text-text-dim text-sm">Bundles arrive when a remote Turnstone instance sends a labeled incident.</p>
</div>
<div v-else class="space-y-3">
<div
v-for="b in bundles"
:key="b.id"
class="rounded border bg-surface-raised overflow-hidden"
:class="selected?.id === b.id ? 'border-accent' : 'border-surface-border'"
>
<!-- Bundle header row -->
<div
class="flex items-center gap-3 px-4 py-3 cursor-pointer hover:bg-surface transition-colors"
@click="toggleBundle(b)"
>
<span class="font-mono text-xs text-accent bg-surface px-1.5 py-0.5 rounded border border-surface-border shrink-0">
{{ b.issue_type || 'untyped' }}
</span>
<span class="text-text-primary text-sm flex-1 min-w-0 truncate">{{ b.label }}</span>
<span class="text-text-dim text-xs shrink-0">{{ b.source_host }}</span>
<span class="px-2 py-0.5 rounded text-xs font-medium border shrink-0" :style="severityStyle(b.severity)">{{ b.severity }}</span>
<span class="text-text-dim text-xs shrink-0">{{ b.entry_count }} entries</span>
<span class="text-text-dim text-xs shrink-0">{{ formatTs(b.bundled_at) }}</span>
<span class="text-text-dim text-xs shrink-0">{{ selected?.id === b.id ? '▲' : '▼' }}</span>
</div>
<!-- Expanded entries -->
<div v-if="selected?.id === b.id" class="border-t border-surface-border">
<div v-if="expandLoading" class="text-text-dim text-sm px-4 py-4">Loading entries</div>
<div v-else-if="expandedEntries.length === 0" class="text-text-dim text-sm px-4 py-4">No entries in bundle.</div>
<div v-else class="p-4 space-y-1 max-h-[32rem] overflow-y-auto">
<div class="flex items-center gap-2 mb-3">
<p class="text-text-dim text-xs">{{ expandedEntries.length }} log entries</p>
<button
@click="exportBundle(b)"
class="ml-auto text-xs px-2 py-1 rounded border border-surface-border text-text-muted hover:text-accent hover:border-accent transition-colors"
>
Export JSON
</button>
</div>
<div
v-for="entry in expandedEntries"
:key="entry.entry_id"
class="font-mono text-xs py-1 px-2 rounded bg-surface border border-surface-border"
>
<span class="text-text-dim mr-2">{{ shortTs(entry.timestamp_iso) }}</span>
<span :class="['mr-2', severityClass(entry.severity)]">{{ entry.severity || '?' }}</span>
<span class="text-text-muted">{{ lastPart(entry.source_id) }}</span>
<span class="text-text-dim mx-1">|</span>
<span class="text-text-primary">{{ entry.text.slice(0, 200) }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
const BASE = import.meta.env.BASE_URL.replace(/\/$/, '')
interface BundleSummary {
id: string
source_host: string
issue_type: string
label: string
severity: string
started_at: string | null
bundled_at: string
entry_count: number
bundle_json: string
}
interface LogEntry {
entry_id: string
source_id: string
timestamp_iso: string | null
severity: string | null
text: string
matched_patterns: string[]
}
const bundles = ref<BundleSummary[]>([])
const loading = ref(true)
const selected = ref<BundleSummary | null>(null)
const expandedEntries = ref<LogEntry[]>([])
const expandLoading = ref(false)
onMounted(async () => {
try {
const res = await fetch(`${BASE}/api/bundles`)
if (res.ok) bundles.value = (await res.json()).bundles
} finally {
loading.value = false
}
})
async function toggleBundle(b: BundleSummary) {
if (selected.value?.id === b.id) {
selected.value = null
expandedEntries.value = []
return
}
selected.value = b
expandedEntries.value = []
expandLoading.value = true
try {
// bundle_json is stored inline parse it directly, no round-trip needed
const parsed = JSON.parse(b.bundle_json)
expandedEntries.value = parsed.log_entries ?? []
} catch {
expandLoading.value = false
} finally {
expandLoading.value = false
}
}
function exportBundle(b: BundleSummary) {
const blob = new Blob([b.bundle_json], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `bundle-${b.issue_type || 'untyped'}-${b.id.slice(0, 8)}.json`
a.click()
URL.revokeObjectURL(url)
}
function severityStyle(sev: string): Record<string, string> {
const k = sev?.toLowerCase() ?? 'low'
const known = ['low', 'medium', 'high', 'critical']
const key = known.includes(k) ? k : 'low'
return {
backgroundColor: `var(--badge-${key}-bg)`,
color: `var(--badge-${key}-text)`,
borderColor: `var(--badge-${key}-text)`,
}
}
function severityClass(sev: string | null): string {
return {
ERROR: 'text-sev-error',
CRITICAL: 'text-sev-critical',
WARN: 'text-sev-warn',
WARNING: 'text-sev-warn',
INFO: 'text-sev-info',
DEBUG: 'text-text-dim',
}[sev?.toUpperCase() ?? ''] ?? 'text-text-dim'
}
function lastPart(sourceId: string): string {
return sourceId.split(':').pop() ?? sourceId
}
function formatTs(iso: string | null): string {
if (!iso) return '—'
try {
return new Date(iso).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
} catch { return iso }
}
function shortTs(iso: string | null): string {
if (!iso) return '?'
try {
return new Date(iso).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' })
} catch { return iso }
}
</script>

View file

@ -83,6 +83,28 @@
</p> </p>
</div> </div>
<!-- Issue type -->
<div>
<label class="block text-xs text-text-dim mb-1">Issue type <span class="text-text-dim">(optional short tag for pattern building)</span></label>
<input
v-model="form.issue_type"
type="text"
list="issue-type-suggestions"
placeholder='e.g. "qbit_stall", "auth_failure", "disk_full"'
class="w-full bg-surface border border-surface-border rounded px-3 py-2 text-sm text-text-primary placeholder-text-dim focus:outline-none focus:border-accent font-mono"
/>
<datalist id="issue-type-suggestions">
<option value="qbit_stall" />
<option value="qbit_crash" />
<option value="auth_failure" />
<option value="disk_full" />
<option value="network_drop" />
<option value="service_crash" />
<option value="permission_error" />
<option value="oom" />
</datalist>
</div>
<!-- Severity + Notes row --> <!-- Severity + Notes row -->
<div class="flex flex-wrap gap-4"> <div class="flex flex-wrap gap-4">
<div> <div>
@ -134,6 +156,7 @@
<thead class="bg-surface-raised border-b border-surface-border"> <thead class="bg-surface-raised border-b border-surface-border">
<tr> <tr>
<th class="text-left px-4 py-2.5 text-text-dim font-medium text-xs uppercase tracking-wider">Description</th> <th class="text-left px-4 py-2.5 text-text-dim font-medium text-xs uppercase tracking-wider">Description</th>
<th class="text-left px-4 py-2.5 text-text-dim font-medium text-xs uppercase tracking-wider">Type</th>
<th class="text-left px-4 py-2.5 text-text-dim font-medium text-xs uppercase tracking-wider">Severity</th> <th class="text-left px-4 py-2.5 text-text-dim font-medium text-xs uppercase tracking-wider">Severity</th>
<th class="text-left px-4 py-2.5 text-text-dim font-medium text-xs uppercase tracking-wider">Window</th> <th class="text-left px-4 py-2.5 text-text-dim font-medium text-xs uppercase tracking-wider">Window</th>
<th class="text-left px-4 py-2.5 text-text-dim font-medium text-xs uppercase tracking-wider">Tagged</th> <th class="text-left px-4 py-2.5 text-text-dim font-medium text-xs uppercase tracking-wider">Tagged</th>
@ -148,6 +171,10 @@
@click="selectIncident(inc)" @click="selectIncident(inc)"
> >
<td class="px-4 py-2.5 text-text-primary">{{ inc.label }}</td> <td class="px-4 py-2.5 text-text-primary">{{ inc.label }}</td>
<td class="px-4 py-2.5">
<span v-if="inc.issue_type" class="font-mono text-xs text-accent bg-surface px-1.5 py-0.5 rounded border border-surface-border">{{ inc.issue_type }}</span>
<span v-else class="text-text-dim text-xs"></span>
</td>
<td class="px-4 py-2.5"> <td class="px-4 py-2.5">
<span class="px-2 py-0.5 rounded text-xs font-medium border" :style="severityStyle(inc.severity)"> <span class="px-2 py-0.5 rounded text-xs font-medium border" :style="severityStyle(inc.severity)">
{{ inc.severity }} {{ inc.severity }}
@ -171,9 +198,22 @@
<!-- Incident detail drawer --> <!-- Incident detail drawer -->
<div v-if="selected" class="mt-6 rounded border border-accent bg-surface p-5"> <div v-if="selected" class="mt-6 rounded border border-accent bg-surface p-5">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<div>
<h2 class="text-text-primary text-sm font-semibold">{{ selected.label }}</h2> <h2 class="text-text-primary text-sm font-semibold">{{ selected.label }}</h2>
<span v-if="selected.issue_type" class="font-mono text-xs text-accent">{{ selected.issue_type }}</span>
</div>
<div class="flex items-center gap-3">
<button
@click="sendBundle(selected.id)"
:disabled="sending"
class="px-3 py-1.5 text-xs rounded border border-surface-border text-text-muted hover:text-accent hover:border-accent transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
{{ sending ? 'Sending…' : 'Send Bundle' }}
</button>
<span v-if="sendStatus" :class="sendStatus.ok ? 'text-green-500' : 'text-sev-error'" class="text-xs">{{ sendStatus.msg }}</span>
<button @click="selected = null" class="text-text-dim hover:text-text-primary text-xs"> close</button> <button @click="selected = null" class="text-text-dim hover:text-text-primary text-xs"> close</button>
</div> </div>
</div>
<div v-if="entriesLoading" class="text-text-dim text-sm py-4">Fetching log entries</div> <div v-if="entriesLoading" class="text-text-dim text-sm py-4">Fetching log entries</div>
@ -210,6 +250,7 @@ const BASE = import.meta.env.BASE_URL.replace(/\/$/, '')
interface Incident { interface Incident {
id: string id: string
label: string label: string
issue_type: string
started_at: string | null started_at: string | null
ended_at: string | null ended_at: string | null
notes: string notes: string
@ -253,6 +294,7 @@ function getQuickRange(preset: string): { started_at: string | null; ended_at: s
// form state // form state
const form = ref({ const form = ref({
label: '', label: '',
issue_type: '',
severity: 'medium', severity: 'medium',
notes: '', notes: '',
started_at: null as string | null, started_at: null as string | null,
@ -307,6 +349,7 @@ async function submitIncident() {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
label: form.value.label, label: form.value.label,
issue_type: form.value.issue_type,
severity: form.value.severity, severity: form.value.severity,
notes: form.value.notes, notes: form.value.notes,
started_at, started_at,
@ -316,7 +359,7 @@ async function submitIncident() {
if (!res.ok) throw new Error(await res.text()) if (!res.ok) throw new Error(await res.text())
const created: Incident = await res.json() const created: Incident = await res.json()
incidents.value.unshift(created) incidents.value.unshift(created)
form.value = { label: '', severity: 'medium', notes: '', started_at: null, ended_at: null, started_at_local: '', ended_at_local: '' } form.value = { label: '', issue_type: '', severity: 'medium', notes: '', started_at: null, ended_at: null, started_at_local: '', ended_at_local: '' }
activePreset.value = null activePreset.value = null
showCustomPicker.value = false showCustomPicker.value = false
} catch (e: unknown) { } catch (e: unknown) {
@ -349,11 +392,14 @@ async function deleteIncident(id: string) {
const selected = ref<Incident | null>(null) const selected = ref<Incident | null>(null)
const selectedEntries = ref<Entry[]>([]) const selectedEntries = ref<Entry[]>([])
const entriesLoading = ref(false) const entriesLoading = ref(false)
const sending = ref(false)
const sendStatus = ref<{ ok: boolean; msg: string } | null>(null)
async function selectIncident(inc: Incident) { async function selectIncident(inc: Incident) {
selected.value = inc selected.value = inc
selectedEntries.value = [] selectedEntries.value = []
entriesLoading.value = true entriesLoading.value = true
sendStatus.value = null
try { try {
const res = await fetch(`${BASE}/api/incidents/${inc.id}`) const res = await fetch(`${BASE}/api/incidents/${inc.id}`)
if (res.ok) { if (res.ok) {
@ -365,6 +411,25 @@ async function selectIncident(inc: Incident) {
} }
} }
async function sendBundle(id: string) {
sending.value = true
sendStatus.value = null
try {
const res = await fetch(`${BASE}/api/incidents/${id}/send`, { method: 'POST' })
if (res.ok) {
const data = await res.json()
sendStatus.value = { ok: true, msg: `Sent ${data.entry_count} entries` }
} else {
const err = await res.json().catch(() => ({ detail: res.statusText }))
sendStatus.value = { ok: false, msg: err.detail ?? 'Send failed' }
}
} catch (e) {
sendStatus.value = { ok: false, msg: 'Network error' }
} finally {
sending.value = false
}
}
// helpers // helpers
function severityStyle(sev: string): Record<string, string> { function severityStyle(sev: string): Record<string, string> {
const k = sev?.toLowerCase() ?? 'low' const k = sev?.toLowerCase() ?? 'low'