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:
parent
ec62b47de2
commit
18bb93abc9
9 changed files with 482 additions and 20 deletions
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
63
app/rest.py
63
app/rest.py
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" \
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
],
|
||||
})
|
||||
|
|
|
|||
183
web/src/views/BundlesView.vue
Normal file
183
web/src/views/BundlesView.vue
Normal 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>
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
Loading…
Reference in a new issue