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 (
id TEXT PRIMARY KEY,
label TEXT NOT NULL,
issue_type TEXT NOT NULL DEFAULT '',
started_at TEXT,
ended_at TEXT,
notes TEXT NOT NULL DEFAULT '',
@ -45,14 +46,36 @@ CREATE TABLE IF NOT EXISTS incidents (
severity TEXT NOT NULL DEFAULT 'medium'
);
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:
"""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.execute("PRAGMA journal_mode=WAL")
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.close()

View file

@ -7,7 +7,10 @@ Caddy (menagerie.circuitforge.tech/turnstone) without prefix stripping.
from __future__ import annotations
import dataclasses
import json
import os
import urllib.error
import urllib.request
from pathlib import Path
from typing import Annotated
@ -19,11 +22,15 @@ from pydantic import BaseModel
from app.ingest.pipeline import ensure_schema
from app.services.incidents import (
build_bundle,
create_incident,
delete_incident,
get_bundle,
get_incident,
get_incident_entries,
list_bundles,
list_incidents,
store_bundle,
)
from app.services.search import (
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"))
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)
@ -53,6 +62,7 @@ def _startup() -> None:
class IncidentCreate(BaseModel):
label: str
issue_type: str = ""
started_at: str | None = None
ended_at: str | None = None
notes: str = ""
@ -174,6 +184,7 @@ def create_incident_endpoint(body: IncidentCreate) -> dict:
incident = create_incident(
DB_PATH,
label=body.label,
issue_type=body.issue_type,
started_at=body.started_at,
ended_at=body.ended_at,
notes=body.notes,
@ -206,6 +217,58 @@ def delete_incident_endpoint(incident_id: str) -> dict:
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)

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
import json
import sqlite3
import uuid
from pathlib import Path
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
@ -14,6 +15,7 @@ def _row_to_incident(row: sqlite3.Row) -> Incident:
return Incident(
id=row["id"],
label=row["label"],
issue_type=row["issue_type"] if "issue_type" in row.keys() else "",
started_at=row["started_at"],
ended_at=row["ended_at"],
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(
db_path: Path,
label: str,
issue_type: str = "",
started_at: str | None = None,
ended_at: str | None = None,
notes: str = "",
@ -33,6 +50,7 @@ def create_incident(
incident = Incident(
id=str(uuid.uuid4()),
label=label,
issue_type=issue_type,
started_at=started_at,
ended_at=ended_at,
notes=notes,
@ -42,10 +60,10 @@ def create_incident(
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),
"INSERT INTO incidents (id, label, issue_type, started_at, ended_at, notes, created_at, severity) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(incident.id, incident.label, incident.issue_type, incident.started_at,
incident.ended_at, incident.notes, incident.created_at, incident.severity),
)
conn.commit()
conn.close()
@ -88,12 +106,7 @@ def get_incident_entries(
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.
"""
"""Return log entries associated with an incident's time window."""
half = limit // 2
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)
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:
@ -123,3 +135,89 @@ def get_incident_entries(
combined.sort(key=lambda e: (e.timestamp_iso or "\xff", e.sequence))
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
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
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
@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
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 ────────────────────────────────────────────────────
# Add or remove mount flags below for each service whose logs you want to ingest.
# Inside the container, paths appear under /logs/<service>/
@ -81,6 +90,8 @@ podman run -d \
-v "${PATTERNS_DIR}:/patterns:Z" \
-v "${QBIT_LOGS}:/logs/qbittorrent:ro" \
-e TURNSTONE_DB=/data/turnstone.db \
-e TURNSTONE_SOURCE_HOST="$(hostname)" \
-e TURNSTONE_BUNDLE_ENDPOINT="${TURNSTONE_BUNDLE_ENDPOINT:-}" \
-e PYTHONUNBUFFERED=1 \
-e TZ="${TZ}" \
--health-cmd="curl -f http://localhost:8534/turnstone/health || exit 1" \

View file

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

View file

@ -4,6 +4,7 @@ import LogSearchView from '@/views/LogSearchView.vue'
import DiagnoseView from '@/views/DiagnoseView.vue'
import SourcesView from '@/views/SourcesView.vue'
import IncidentsView from '@/views/IncidentsView.vue'
import BundlesView from '@/views/BundlesView.vue'
export default createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
@ -12,7 +13,8 @@ export default createRouter({
{ path: '/dashboard', component: DashboardView },
{ path: '/search', component: LogSearchView },
{ path: '/diagnose', component: DiagnoseView },
{ path: '/sources', component: SourcesView },
{ 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>
</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 -->
<div class="flex flex-wrap gap-4">
<div>
@ -134,6 +156,7 @@
<thead class="bg-surface-raised border-b border-surface-border">
<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">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">Window</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)"
>
<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">
<span class="px-2 py-0.5 rounded text-xs font-medium border" :style="severityStyle(inc.severity)">
{{ inc.severity }}
@ -171,8 +198,21 @@
<!-- Incident detail drawer -->
<div v-if="selected" class="mt-6 rounded border border-accent bg-surface p-5">
<div class="flex items-center justify-between mb-4">
<h2 class="text-text-primary text-sm font-semibold">{{ selected.label }}</h2>
<button @click="selected = null" class="text-text-dim hover:text-text-primary text-xs"> close</button>
<div>
<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>
</div>
</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 {
id: string
label: string
issue_type: string
started_at: string | null
ended_at: string | null
notes: string
@ -253,6 +294,7 @@ function getQuickRange(preset: string): { started_at: string | null; ended_at: s
// form state
const form = ref({
label: '',
issue_type: '',
severity: 'medium',
notes: '',
started_at: null as string | null,
@ -307,6 +349,7 @@ async function submitIncident() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
label: form.value.label,
issue_type: form.value.issue_type,
severity: form.value.severity,
notes: form.value.notes,
started_at,
@ -316,7 +359,7 @@ async function submitIncident() {
if (!res.ok) throw new Error(await res.text())
const created: Incident = await res.json()
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
showCustomPicker.value = false
} catch (e: unknown) {
@ -346,14 +389,17 @@ async function deleteIncident(id: string) {
}
// detail drawer
const selected = ref<Incident | null>(null)
const selected = ref<Incident | null>(null)
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) {
selected.value = inc
selectedEntries.value = []
entriesLoading.value = true
sendStatus.value = null
try {
const res = await fetch(`${BASE}/api/incidents/${inc.id}`)
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
function severityStyle(sev: string): Record<string, string> {
const k = sev?.toLowerCase() ?? 'low'