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
df87f22416
commit
457b4fd7ae
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 (
|
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()
|
||||||
|
|
||||||
|
|
|
||||||
63
app/rest.py
63
app/rest.py
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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" \
|
||||||
|
|
|
||||||
|
|
@ -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' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
|
||||||
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>
|
</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,8 +198,21 @@
|
||||||
<!-- 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">
|
||||||
<h2 class="text-text-primary text-sm font-semibold">{{ selected.label }}</h2>
|
<div>
|
||||||
<button @click="selected = null" class="text-text-dim hover:text-text-primary text-xs">✕ close</button>
|
<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>
|
||||||
|
|
||||||
<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) {
|
||||||
|
|
@ -346,14 +389,17 @@ async function deleteIncident(id: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── detail drawer ─────────────────────────────────────────────
|
// ── detail drawer ─────────────────────────────────────────────
|
||||||
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'
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue