Compare commits

..

12 commits

Author SHA1 Message Date
db359d35b2 fix(search): qualify ambiguous column names with table alias in FTS JOIN
Both log_fts and log_entries have timestamp_iso, severity, source_id, and
matched_patterns columns. After the JOIN, unqualified references to any of
these caused SQLite to raise 'ambiguous column name', silently falling back
to the non-FTS scan path on every time-filtered or severity-filtered query.

Prefix all filter conditions that touch FTS-mirror columns with f. to
resolve the ambiguity. The e. prefix on tenant_id was already correct since
tenant_id is not present in the FTS virtual table.
2026-06-17 11:27:38 -07:00
04013757e7 chore: bump version to v0.7.0
Beta milestone complete: all open beta tickets closed.
2026-06-17 09:41:10 -07:00
5da8db2bcd fix(diagnose): pass full timeline clusters and hypothesis descriptions to synthesizer LLM
Stage 5 (SummarySynthesizer) was only sending aggregate timeline stats to the
LLM (cluster count, burst count, gap count) — the actual sequenced cluster data
that Stage 1 reconstructed was never included. The LLM had no per-cluster
timestamps, severity, burst flags, silence gaps, or representative text to
write the TIMELINE section from.

Added _build_timeline_block() to emit a numbered per-cluster summary matching
the format Stage 3 uses for the hypothesizer, and included it in the user
message alongside the hypothesis block.

Also fixed _build_hypothesis_block() to include the 2-4 sentence description
each hypothesis carries — previously only the title and novelty score reached
the LLM.

11 new tests cover _build_timeline_block() directly (burst label, gap threshold,
pattern tags, text truncation at 200 chars, null start_iso, multi-cluster
numbering). 529 tests passing.
2026-06-16 21:46:01 -07:00
4c1940d12e fix: strip reasoning-model thinking tags; surface untracked node names
- app/services/diagnose/_llm_client.py: strip <think>…</think> blocks
  (case-insensitive, multiline) from LLM response content before it
  reaches the UI or any JSON parser — affects DeepSeek-R1, Qwen QwQ,
  and any other model that emits chain-of-thought in content
- app/rest.py: suggest_sources now also returns untracked_names — query
  tokens that look like hostnames/service names but don't appear in any
  monitored source, so the UI can prompt the user to add them
- web/src/components/ChatDiagnose.vue: show amber "Not monitoring: X"
  banner with "Add as a log source →" link when untracked_names present
- tests/test_llm_client.py: 13 tests covering think-strip edge cases
  (single/multi-line, multiple blocks, case-insensitive, only-thinking)
  plus existing extract_content and JSON-fence helpers
2026-06-16 09:42:44 -07:00
6039ab2464 feat: incident ticket export — Notion and Jira integration (#12)
- app/services/ticket_export.py: plugin-dispatch architecture; Notion
  exporter (Notion API v1, blocks-based, 50 entry cap, 2000-char
  truncation per block); Jira exporter (REST API v3, Basic Auth, ADF
  description, configurable issue type defaulting to Bug)
- app/rest.py: POST /api/incidents/{id}/export endpoint; Notion/Jira
  credential fields added to SettingsBody and PATCH /api/settings handler
- web/src/views/IncidentsView.vue: "Export ticket ▾" dropdown in
  incident detail drawer — click-outside close, inline URL link on success
- web/src/views/SettingsView.vue: Ticket Trackers section with Notion
  token + database ID, Jira URL/email/token/project/issue-type; show/hide
  for secret fields
- tests/test_ticket_export.py: 17 tests covering dispatch, Notion
  success/error/config/payload/truncation paths, Jira success/error/
  auth/project/summary/default-issue-type
2026-06-14 15:46:11 -07:00
b8f766fb74 feat: SSH target manager — GUI editor for remote host configuration (#24)
- app/services/ssh_targets.py: full CRUD service with lazy paramiko
  import, key-path validation, permission warning, and test_connection
- app/db/schema.py: ssh_targets table (id, label, host, port, user,
  key_path, last_tested, last_ok, last_error, timestamps)
- app/rest.py: GET/POST /api/ssh-targets, PATCH/DELETE /{id},
  POST /{id}/test — key contents never returned in any response
- web/src/views/SettingsView.vue: Remote Hosts section with add/edit
  form, inline connection status badges, test-connection flow, delete
  with confirmation; new Set() pattern for reactive sshTesting state
- tests/test_ssh_targets.py: 22 tests — schema, CRUD, validation,
  key-warning, serialization, paramiko-absent path
2026-06-14 15:27:12 -07:00
7a2ab0bb46 feat(orchard): auto-enrollment API for branch node provisioning (#27)
Implements the Orchard branch grafting system for harvest.circuitforge.tech:

- POST /api/orchard/graft: provisions data dir, starts a new
  turnstone-submissions-<slug> Docker container on the next free port
  (ORCHARD_PORT_BASE=8538+), injects a handle_path block into the
  Caddyfile dynamic-branches marker section, restarts caddy-proxy,
  returns {submit_endpoint, api_key}
- GET /api/orchard/branches: list active/inactive branches (admin-only)
- DELETE /api/orchard/branches/<slug>: deactivate branch + stop container
- POST /api/orchard/branches/<slug>/anonymize: HMAC-based IP/username
  pseudonymization worker over a branch DB
- POST /api/glean/batch: optional TURNSTONE_BRANCH_KEY auth guard
- anonymized column added to log_entries schema (migration-safe)
- Updated Caddyfile with /huginn/* route (port 8536), /node2/* (8537),
  and dynamic-branch marker section
- All endpoints admin-gated via TURNSTONE_ORCHARD_ADMIN_KEY

Closes: #27
2026-06-14 14:30:18 -07:00
600e5a9eac feat(sources): context-aware filesystem log scanner (#23)
Add scan_log_directories() to discover.py that recursively walks
/var/log and /opt, filters to readable log files, and scores each
candidate by recency (mtime, 0.7 weight), file size (0.3), and
keyword match against an optional problem-context query (shifts
weights to 0.4/0.2/0.4 when a query is provided).

- GET /api/setup/scan?query=...&max_results=N — new API endpoint
- SourcesView: "Scan" button opens a panel with ranked candidates,
  checkboxes, and "Add selected" to write to sources.yaml
- 13 new unit tests, 466 passing total

Closes: #23
2026-06-14 14:01:45 -07:00
7ed01fbd48 chore: sanitize contributor names and personal node IDs
- docker-compose.submissions.yml: rename submissions-contrib1/contrib2
  to submissions-contrib1/contrib2; update paths and host env vars
- podman-standalone.sh: replace 'Contributor's instance' with generic
  'WireGuard-connected Docker hosts'
- docker-standalone.sh: replace personal node-id in harvest endpoint
2026-06-13 22:17:38 -07:00
58680b3b27 chore: replace vendor product name with generic ext_device throughout
- Rename _EXT_DEVICE_CODES → _EXT_DEVICE_CODES, gen_ext_device → gen_ext_device
- Rename corpus output directory ext_device/ → ext_device/
- Update default.yaml placeholder pattern name and description
- Update tests to match new directory and class names
- Corresponding Forgejo issue titles updated (#43, #44, #54)
2026-06-13 22:03:26 -07:00
be134a4465 chore: replace personal node-id in harvest endpoint example 2026-06-13 21:58:22 -07:00
8006d79a11 Merge feat/42-50-postgres-multitenant: dual-backend + full feature set
Brings in 18 commits since v0.6.2:
- Dual-backend SQLite/Postgres + multi-tenant source namespacing
- Anomaly scoring pipeline + cybersec zero-shot scoring
- Security alerts tab — full scorer integration
- Audio domain patterns (PipeWire/ALSA xrun, quantum)
- Incidents: auto-incident detection, timeline visualizer
- Diagnose: conversational chat mode, NL source discovery
- Corpus: synthetic log generator, watermark-preserving updates
- UI: security alert dedup/collapse, clickable criticals with inline
  LLM explanation, loading shimmer animations, default diagnose prompt
- Backend: DB-lock retry in anomaly scorer, FTS build via get_conn(),
  timeline_events in stats_summary
- Sanitize: internal hostnames and IPs replaced with generic placeholders
2026-06-13 10:02:59 -07:00
25 changed files with 2686 additions and 36 deletions

View file

@ -86,3 +86,19 @@
# When set, all /api/ requests require: Authorization: Bearer <token>
# Generate a token: python -c "import secrets; print(secrets.token_urlsafe(32))"
# TURNSTONE_API_KEY=your-secret-token-here
# --- The Orchard (harvest receiver only) ---
# Set on the central harvest.circuitforge.tech instance to enable branch management.
# TURNSTONE_ORCHARD_ADMIN_KEY=your-admin-secret-here
# TURNSTONE_ORCHARD_DATA_ROOT=/devl/docker/turnstone-submissions
# TURNSTONE_ORCHARD_CADDYFILE=/devl/caddy-proxy/Caddyfile
# TURNSTONE_ORCHARD_CADDY_CONTAINER=caddy-proxy
# TURNSTONE_ORCHARD_HARVEST_HOST=https://harvest.circuitforge.tech
# TURNSTONE_ORCHARD_PORT_BASE=8538
# TURNSTONE_ORCHARD_IMAGE=localhost/turnstone:latest
# --- Orchard branch (submitting node) ---
# Set TURNSTONE_SUBMIT_ENDPOINT to push pattern-matched log entries to the harvest receiver.
# Generate your branch slug and API key via: POST /api/orchard/graft on the harvest instance.
# TURNSTONE_SUBMIT_ENDPOINT=https://harvest.circuitforge.tech/your-slug
# TURNSTONE_BRANCH_KEY=api-key-from-graft-response

View file

@ -144,6 +144,20 @@ CREATE INDEX IF NOT EXISTS idx_blocklist_device ON blocklist_candidates(source_
CREATE INDEX IF NOT EXISTS idx_blocklist_status ON blocklist_candidates(status);
CREATE INDEX IF NOT EXISTS idx_blocklist_domain ON blocklist_candidates(domain_or_ip);
CREATE INDEX IF NOT EXISTS idx_blocklist_tenant ON blocklist_candidates(tenant_id);
CREATE TABLE IF NOT EXISTS ssh_targets (
id TEXT PRIMARY KEY,
label TEXT NOT NULL,
host TEXT NOT NULL,
port INTEGER NOT NULL DEFAULT 22,
user TEXT NOT NULL,
key_path TEXT NOT NULL,
last_tested TEXT,
last_ok INTEGER DEFAULT NULL,
last_error TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
"""
_CONTEXT_SCHEMA_SQLITE = """
@ -404,6 +418,7 @@ _MAIN_MIGRATIONS_SQLITE = [
"ALTER TABLE log_entries ADD COLUMN ml_label TEXT",
"ALTER TABLE log_entries ADD COLUMN ml_scored_at TEXT",
"ALTER TABLE detections ADD COLUMN scorer TEXT NOT NULL DEFAULT 'anomaly'",
"ALTER TABLE log_entries ADD COLUMN anonymized INTEGER DEFAULT NULL",
]
_CONTEXT_MIGRATIONS_SQLITE = [

View file

@ -50,7 +50,8 @@ CREATE TABLE IF NOT EXISTS log_entries (
repeat_count INTEGER DEFAULT 1,
out_of_order INTEGER DEFAULT 0,
matched_patterns TEXT DEFAULT '[]',
text TEXT NOT NULL
text TEXT NOT NULL,
anonymized INTEGER DEFAULT NULL
);
CREATE INDEX IF NOT EXISTS idx_source ON log_entries(source_id);
CREATE INDEX IF NOT EXISTS idx_timestamp ON log_entries(timestamp_iso);

View file

@ -30,7 +30,7 @@ from typing import Annotated
import yaml
from fastapi import APIRouter, BackgroundTasks, Depends, FastAPI, HTTPException, Query, Request, UploadFile
from fastapi import APIRouter, BackgroundTasks, Depends, FastAPI, Header, HTTPException, Query, Request, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, RedirectResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
@ -52,8 +52,10 @@ from app.services.blocklist import (
update_candidate_status,
)
from app.services.pihole import PiholeClient
from app.services.discover import discover_all, build_sources_yaml, validate_source
from app.services.discover import discover_all, build_sources_yaml, validate_source, scan_log_directories
from app.services.nl_source import interpret as _nl_interpret
from app.services import orchard as _orchard
from app.services import ssh_targets as _ssh_targets
from app.services.incidents import (
build_bundle,
create_incident,
@ -124,6 +126,9 @@ AUTO_INCIDENT = os.environ.get("TURNSTONE_AUTO_INCIDENT", "true").lower() not in
# When set, all /api/ routes require Authorization: Bearer <key>.
# Unset (default) means no authentication — suitable for local-only deployments.
_API_KEY: str | None = os.environ.get("TURNSTONE_API_KEY") or None
# Admin key for The Orchard graft/deactivate endpoints on the harvest receiver.
# If unset, the orchard management endpoints return 501.
_ORCHARD_ADMIN_KEY: str | None = os.environ.get("TURNSTONE_ORCHARD_ADMIN_KEY") or None
# GPU inference server URL.
# Priority: GPU_SERVER_URL → CF_ORCH_URL (backward compat) → orch.circuitforge.tech (Paid+).
@ -209,7 +214,7 @@ async def _lifespan(app: FastAPI):
close_pool() # no-op if SQLite backend
app = FastAPI(title="Turnstone API", version="0.6.2", docs_url="/turnstone/docs", redoc_url=None, lifespan=_lifespan)
app = FastAPI(title="Turnstone API", version="0.7.0", docs_url="/turnstone/docs", redoc_url=None, lifespan=_lifespan)
app.add_middleware(
CORSMiddleware,
@ -302,6 +307,13 @@ class SettingsBody(BaseModel):
pihole_api_key: str | None = None
router_source_ids: str | None = None
device_names: str | None = None
notion_token: str | None = None
notion_database_id: str | None = None
jira_url: str | None = None
jira_email: str | None = None
jira_api_token: str | None = None
jira_project_key: str | None = None
jira_issue_type: str | None = None
class IncidentCreate(BaseModel):
@ -543,24 +555,35 @@ _SUGGEST_STOPWORDS = frozenset({
@router.post("/api/sources/suggest")
def suggest_sources(body: SourceSuggestRequest) -> dict:
"""Return source IDs ranked by relevance to a natural-language problem description."""
"""Return source IDs ranked by relevance to a natural-language problem description.
Also returns ``untracked_names`` query tokens that look like hostnames or
service names but do not appear in any monitored source, so the UI can
prompt the user to add them.
"""
all_sources = _list_sources(DB_PATH)
query_tokens = {
t.lower()
for t in re.findall(r"[a-zA-Z]+", body.query)
for t in re.findall(r"[a-zA-Z][a-zA-Z0-9_-]*", body.query)
if len(t) > 2 and t.lower() not in _SUGGEST_STOPWORDS
}
suggestions = []
# Build a flat set of every token present in any source ID
all_source_tokens: set[str] = set()
source_token_map: dict[str, set[str]] = {}
for src in all_sources:
src_id: str = src["source_id"]
# Tokenise source ID: split on colon, dash, underscore, digits
parts = {
p.lower()
for seg in re.split(r"[:\-_\d]+", src_id)
for p in [seg.strip()]
if len(p) > 2
}
source_token_map[src_id] = parts
all_source_tokens |= parts
suggestions = []
for src_id, parts in source_token_map.items():
matched = query_tokens & parts
if matched:
score = round(len(matched) / max(len(parts), 1), 3)
@ -571,8 +594,13 @@ def suggest_sources(body: SourceSuggestRequest) -> dict:
})
suggestions.sort(key=lambda x: x["score"], reverse=True)
# Tokens that look like host/service names but aren't in any source
untracked = sorted(query_tokens - all_source_tokens)
return {
"suggested": suggestions,
"untracked_names": untracked,
"all_source_ids": [s["source_id"] for s in all_sources],
}
@ -613,6 +641,20 @@ def patch_settings(body: SettingsBody) -> dict:
prefs["router_source_ids"] = body.router_source_ids
if body.device_names is not None:
prefs["device_names"] = body.device_names
if body.notion_token is not None:
prefs["notion_token"] = body.notion_token
if body.notion_database_id is not None:
prefs["notion_database_id"] = body.notion_database_id
if body.jira_url is not None:
prefs["jira_url"] = body.jira_url
if body.jira_email is not None:
prefs["jira_email"] = body.jira_email
if body.jira_api_token is not None:
prefs["jira_api_token"] = body.jira_api_token
if body.jira_project_key is not None:
prefs["jira_project_key"] = body.jira_project_key
if body.jira_issue_type is not None:
prefs["jira_issue_type"] = body.jira_issue_type
_save_prefs(prefs)
return prefs
@ -797,6 +839,89 @@ class BatchGleanRequest(BaseModel):
entries: list[BatchEntry]
# ── SSH target manager ─────────────────────────────────────────────────────
class SshTargetCreate(BaseModel):
label: str
host: str
port: int = 22
user: str
key_path: str
class SshTargetUpdate(BaseModel):
label: str | None = None
host: str | None = None
port: int | None = None
user: str | None = None
key_path: str | None = None
@router.get("/api/ssh-targets")
def list_ssh_targets() -> dict:
"""List all configured SSH targets (never returns key contents)."""
targets = _ssh_targets.list_targets(DB_PATH)
return {"targets": [_ssh_targets.target_to_dict(t, include_warning=True) for t in targets]}
@router.post("/api/ssh-targets")
def create_ssh_target(body: SshTargetCreate) -> dict:
"""Create a new SSH target."""
try:
target = _ssh_targets.create_target(
DB_PATH,
label=body.label,
host=body.host,
port=body.port,
user=body.user,
key_path=body.key_path,
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc))
d = _ssh_targets.target_to_dict(target, include_warning=True)
return d
@router.patch("/api/ssh-targets/{target_id}")
def update_ssh_target(target_id: str, body: SshTargetUpdate) -> dict:
"""Update an existing SSH target."""
try:
target = _ssh_targets.update_target(
DB_PATH,
target_id,
label=body.label,
host=body.host,
port=body.port,
user=body.user,
key_path=body.key_path,
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc))
if target is None:
raise HTTPException(status_code=404, detail=f"SSH target {target_id!r} not found")
return _ssh_targets.target_to_dict(target, include_warning=True)
@router.delete("/api/ssh-targets/{target_id}")
def delete_ssh_target(target_id: str) -> dict:
"""Remove an SSH target."""
if not _ssh_targets.delete_target(DB_PATH, target_id):
raise HTTPException(status_code=404, detail=f"SSH target {target_id!r} not found")
return {"deleted": target_id}
@router.post("/api/ssh-targets/{target_id}/test")
def test_ssh_target(target_id: str) -> dict:
"""Test an SSH connection by running a no-op remote command.
Records the result in the DB so the UI can show a persistent status badge.
"""
try:
return _ssh_targets.test_connection(DB_PATH, target_id)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc))
# ── Setup / Onboarding wizard ──────────────────────────────────────────────
class SetupWriteBody(BaseModel):
@ -820,6 +945,28 @@ def setup_discover() -> dict:
return discover_all()
@router.get("/api/setup/scan")
def setup_scan(
query: str = "",
dirs: str = "",
max_results: int = 25,
) -> dict:
"""Scan the filesystem for log files ranked by recency and keyword match.
Accepts an optional ?query= to weight results toward files matching the
problem context (e.g. 'nginx 502', 'docker timeout', 'ssh refused').
Accepts an optional ?dirs= comma-separated list to override default scan
directories (/var/log, /opt).
"""
scan_dirs = [d.strip() for d in dirs.split(",") if d.strip()] or None
candidates = scan_log_directories(
query=query or None,
dirs=scan_dirs,
max_results=min(max_results, 100),
)
return {"candidates": candidates, "query": query or None}
@router.post("/api/setup/write")
def setup_write(body: SetupWriteBody, background_tasks: BackgroundTasks) -> dict:
"""Validate and write sources.yaml from a list of selected source definitions.
@ -888,12 +1035,24 @@ def setup_interpret(body: NLInterpretBody) -> dict:
@router.post("/api/glean/batch")
def glean_batch(payload: BatchGleanRequest, background_tasks: BackgroundTasks) -> dict:
def glean_batch(
payload: BatchGleanRequest,
background_tasks: BackgroundTasks,
authorization: str | None = Header(default=None),
) -> dict:
"""Accept pre-parsed log entries from a remote Turnstone instance (submission protocol).
Used by nodes with TURNSTONE_SUBMIT_ENDPOINT configured to push their
pattern-matched entries to a central receiving instance.
When TURNSTONE_ORCHARD_ADMIN_KEY is set on the receiver, requests must
include Authorization: Bearer <api_key> where the key was issued at graft time.
"""
branch_key_env = os.environ.get("TURNSTONE_BRANCH_KEY", "")
if branch_key_env:
provided = (authorization or "").removeprefix("Bearer ").strip()
if not provided or provided != branch_key_env:
raise HTTPException(status_code=401, detail="Invalid branch API key")
if not payload.entries:
return {"gleaned": 0}
conn = sqlite3.connect(str(DB_PATH), timeout=30.0)
@ -929,6 +1088,86 @@ def glean_batch(payload: BatchGleanRequest, background_tasks: BackgroundTasks) -
return {"gleaned": len(payload.entries), "source_host": payload.source_host}
def _require_orchard_admin(authorization: str | None) -> None:
"""Raise 401/501 if the Orchard admin key check fails."""
if _ORCHARD_ADMIN_KEY is None:
raise HTTPException(status_code=501, detail="Orchard management not enabled on this instance — set TURNSTONE_ORCHARD_ADMIN_KEY")
provided = (authorization or "").removeprefix("Bearer ").strip()
if not hmac.compare_digest(_ORCHARD_ADMIN_KEY, provided):
raise HTTPException(status_code=401, detail="Invalid Orchard admin key")
class GraftRequest(BaseModel):
slug: str
contact_email: str
agreed_to_terms: bool = False
@router.post("/api/orchard/graft")
def orchard_graft(
body: GraftRequest,
authorization: str | None = Header(default=None),
) -> dict:
"""Provision a new Orchard branch node.
Admin-only: requires Authorization: Bearer <TURNSTONE_ORCHARD_ADMIN_KEY>.
Returns the submit endpoint and a one-time API key.
"""
_require_orchard_admin(authorization)
try:
result = _orchard.graft(body.slug, body.contact_email, body.agreed_to_terms)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc))
except Exception as exc:
logger.error("Orchard graft failed: %s", exc)
raise HTTPException(status_code=500, detail=str(exc))
return result
@router.get("/api/orchard/branches")
def orchard_list_branches(
authorization: str | None = Header(default=None),
) -> dict:
"""List all Orchard branches. Admin-only."""
_require_orchard_admin(authorization)
branches = _orchard.list_branches()
# Strip api_key_hash from public response
safe = [{k: v for k, v in b.items() if k != "api_key_hash"} for b in branches]
return {"branches": safe}
@router.delete("/api/orchard/branches/{slug}")
def orchard_deactivate(
slug: str,
authorization: str | None = Header(default=None),
) -> dict:
"""Deactivate a branch: stop its container and remove its Caddy route. Admin-only."""
_require_orchard_admin(authorization)
try:
return _orchard.deactivate(slug)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc))
except Exception as exc:
logger.error("Orchard deactivate failed: %s", exc)
raise HTTPException(status_code=500, detail=str(exc))
@router.post("/api/orchard/branches/{slug}/anonymize")
def orchard_anonymize(
slug: str,
authorization: str | None = Header(default=None),
) -> dict:
"""Run the anonymization worker over a branch DB. Admin-only."""
_require_orchard_admin(authorization)
try:
return _orchard.run_anonymization(slug)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc))
except Exception as exc:
logger.error("Orchard anonymize failed: %s", exc)
raise HTTPException(status_code=500, detail=str(exc))
@router.get("/api/tasks/glean/status")
def glean_task_status() -> dict:
"""Return the current state of the periodic glean scheduler."""
@ -1123,6 +1362,41 @@ def send_incident_bundle(incident_id: str, sanitize: bool = False) -> dict:
raise HTTPException(status_code=502, detail=f"Send failed: {exc}") from exc
class TicketExportRequest(BaseModel):
target: str # "notion" | "jira"
@router.post("/api/incidents/{incident_id}/export")
def export_incident_ticket(incident_id: str, body: TicketExportRequest) -> dict:
"""Push an incident to an external ticket tracker (Notion or Jira)."""
from app.services.ticket_export import export_incident, available_targets
incident = get_incident(INCIDENTS_DB_PATH, incident_id)
if not incident:
raise HTTPException(status_code=404, detail="Incident not found")
if body.target not in available_targets():
raise HTTPException(status_code=422, detail=f"Unknown target. Supported: {available_targets()}")
prefs = _load_prefs()
config = {k: prefs.get(k, "") for k in (
"notion_token", "notion_database_id",
"jira_url", "jira_email", "jira_api_token", "jira_project_key", "jira_issue_type",
)}
from app.services.incidents import get_incident_entries
raw_entries = get_incident_entries(DB_PATH, incident)
entries = [dataclasses.asdict(e) for e in raw_entries]
incident_dict = dataclasses.asdict(incident)
try:
result = export_incident(body.target, incident_dict, entries, config)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
except RuntimeError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return {"target": body.target, "url": result["url"], "ticket_id": result["ticket_id"]}
@router.post("/api/bundles")
def receive_bundle(bundle: dict) -> dict:
record = store_bundle(INCIDENTS_DB_PATH, bundle)

View file

@ -23,16 +23,30 @@ _JSON_FENCE_RE = re.compile(
re.MULTILINE,
)
# Reasoning models (DeepSeek-R1, Qwen QwQ, Llama thinking variants) embed
# chain-of-thought inside <think>…</think> tags in the content field.
# Strip them so only the final response reaches the UI.
_THINK_TAG_RE = re.compile(r"<think>.*?</think>", re.DOTALL | re.IGNORECASE)
def _strip_thinking(text: str) -> str:
"""Remove <think>…</think> blocks and trim surrounding whitespace."""
return _THINK_TAG_RE.sub("", text).strip()
def extract_content(resp_json: dict) -> str | None:
"""Pull text content from an OpenAI-compat chat completion response.
Strips reasoning-model thinking tags before returning.
Returns None when the response has no choices or empty content.
"""
choices = resp_json.get("choices") or []
if not choices:
return None
return (choices[0].get("message", {}).get("content") or "").strip() or None
raw = (choices[0].get("message", {}).get("content") or "").strip()
if not raw:
return None
return _strip_thinking(raw) or None
def strip_json_fences(raw: str) -> str:

View file

@ -64,13 +64,43 @@ def _build_hypothesis_block(ranked: list[RankedHypothesis]) -> str:
h = rh.hypothesis
conf_pct = int(h.confidence * 100)
novelty = f"{rh.novelty_score:.2f}"
desc = f"\n {h.description}" if h.description else ""
lines.append(
f"- [{h.severity}, {conf_pct}%] {h.title}\n"
f" Novelty: {novelty}"
f"- [{h.severity}, {conf_pct}% conf, novelty {novelty}] {h.title}{desc}"
)
return "\n".join(lines)
def _build_timeline_block(timeline: TimelineResult) -> str:
"""Build a sequenced cluster block so the synthesizer can narrate what happened.
Mirrors the format used by the hypothesizer, but adds gap information so the
LLM can reason about silence windows between bursts.
"""
if not timeline.clusters:
return "(no clusters)"
lines: list[str] = []
for i, c in enumerate(timeline.clusters):
ts = c.start_iso or "unknown"
sources = ", ".join(list(c.source_ids)[:3])
tags = ", ".join(list(c.pattern_tags)[:4])
burst_label = " [BURST]" if c.burst else ""
gap_label = (
f" (+{int(c.gap_before_seconds)}s silence)"
if c.gap_before_seconds > 30
else ""
)
text_preview = c.representative_text[:200]
line = (
f"Cluster {i + 1}{burst_label}{gap_label} @ {ts} [{c.severity}] "
f"({sources}) — {text_preview}"
)
if tags:
line += f" [patterns: {tags}]"
lines.append(line)
return "\n".join(lines)
def _build_context_block(ctx: RetrievedContext) -> str:
"""Build the runbook context block for the prompt."""
parts: list[str] = []
@ -144,17 +174,18 @@ class SummarySynthesizer:
system_prompt = _SYSTEM_PROMPTS.get(tech_level, _SYSTEM_PROMPTS["sysadmin"])
hypothesis_block = _build_hypothesis_block(ranked)
timeline_block = _build_timeline_block(timeline)
context_block = _build_context_block(ctx)
dominant = ", ".join(timeline.dominant_sources[:5]) or "none"
user_message = (
f"Query: {query}\n\n"
f"Timeline summary:\n"
f"- {len(timeline.clusters)} clusters, "
f"Timeline ({len(timeline.clusters)} clusters, "
f"{timeline.burst_count} bursts, "
f"{timeline.gap_count} silence gaps\n"
f"- Primary sources: {dominant}\n\n"
f"Top hypotheses:\n{hypothesis_block}\n\n"
f"{timeline.gap_count} silence gaps; "
f"primary sources: {dominant}):\n"
f"{timeline_block}\n\n"
f"Root-cause hypotheses:\n{hypothesis_block}\n\n"
f"Context from runbooks:\n{context_block}"
)

View file

@ -8,8 +8,10 @@ from __future__ import annotations
import json
import logging
import os
import re
import shutil
import subprocess
import time
from pathlib import Path
from typing import Any
@ -171,3 +173,113 @@ def validate_source(src: dict[str, Any]) -> str | None:
if src_type == "docker" and not src.get("container"):
return f"Docker source '{src['id']}' is missing 'container'"
return None
# Extensions considered as log files in the filesystem scanner.
_LOG_EXTENSIONS = {"", ".log", ".txt", ".out", ".err"}
# Max file size to consider (500 MB).
_MAX_SIZE = 500 * 1024 * 1024
# Recency half-life in days — files older than this are scored near 0.
_RECENCY_HALFLIFE_DAYS = 30
def _path_to_source_id(path: Path) -> str:
"""Convert an absolute path to a kebab-case source ID."""
raw = re.sub(r"[^a-zA-Z0-9]+", "-", str(path)).strip("-").lower()
return raw[:64]
def scan_log_directories(
query: str | None = None,
dirs: list[str] | None = None,
max_depth: int = 4,
max_results: int = 25,
) -> list[dict[str, Any]]:
"""Scan filesystem directories for log files ranked by recency and keyword match.
Scoring weights:
- Recency (0-1): mtime within the last 30 days, decays exponentially
- Size (0-1): prefer 1 KB 50 MB; empty or huge files score low
- Keyword (0-1): stem matches between query words and path components
Returns up to *max_results* candidates sorted by descending score.
"""
if dirs is None:
dirs = ["/var/log", "/opt"]
now = time.time()
query_stems: list[str] = []
if query:
query_stems = [w.lower() for w in re.split(r"\W+", query) if len(w) >= 3]
candidates: list[dict[str, Any]] = []
def _walk(root: Path, depth: int) -> None:
if depth > max_depth:
return
try:
entries = list(root.iterdir())
except OSError:
return
for entry in entries:
if entry.name.startswith("."):
continue
if entry.is_symlink():
continue
if entry.is_dir():
_walk(entry, depth + 1)
continue
if not entry.is_file():
continue
if entry.suffix.lower() not in _LOG_EXTENSIONS:
continue
# Skip compressed archives
if entry.name.endswith((".gz", ".bz2", ".xz", ".zst")):
continue
try:
stat = entry.stat()
except OSError:
continue
if stat.st_size == 0 or stat.st_size > _MAX_SIZE:
continue
if not os.access(entry, os.R_OK):
continue
age_days = (now - stat.st_mtime) / 86400
recency = max(0.0, 1.0 - age_days / _RECENCY_HALFLIFE_DAYS)
if stat.st_size < 1024:
size_score = 0.3
elif stat.st_size <= 50 * 1024 * 1024:
size_score = 1.0
else:
# Large files: linear decay from 50 MB to 500 MB
size_score = max(0.1, 1.0 - (stat.st_size - 50 * 1024 * 1024) / _MAX_SIZE)
keyword_score = 0.0
if query_stems:
path_lower = str(entry).lower()
matches = sum(1 for stem in query_stems if stem in path_lower)
keyword_score = min(1.0, matches / max(len(query_stems), 1))
if query_stems:
total = recency * 0.4 + size_score * 0.2 + keyword_score * 0.4
else:
total = recency * 0.7 + size_score * 0.3
candidates.append({
"type": "file",
"id": _path_to_source_id(entry),
"path": str(entry),
"label": entry.name,
"size_bytes": stat.st_size,
"mtime": stat.st_mtime,
"score": round(total, 3),
"available": True,
})
for d in dirs:
_walk(Path(d), depth=0)
candidates.sort(key=lambda c: c["score"], reverse=True)
return candidates[:max_results]

327
app/services/orchard.py Normal file
View file

@ -0,0 +1,327 @@
"""The Orchard — auto-enrollment of new Turnstone branch nodes.
A "branch" is an external Turnstone instance that submits pattern-matched log
entries to a central harvest receiver (harvest.circuitforge.tech). Grafting
provisions the receiving infrastructure for a new branch:
1. Creates a data dir at ORCHARD_DATA_ROOT/<slug>/
2. Starts a new turnstone-submissions-<slug> Docker container
3. Injects a handle_path block into the Caddyfile marker section
4. Restarts caddy-proxy to activate the route
5. Persists the branch registry to orchard-branches.yaml
Admin auth: the graft/deactivate endpoints require
Authorization: Bearer <TURNSTONE_ORCHARD_ADMIN_KEY>
Set TURNSTONE_ORCHARD_ADMIN_KEY in the environment on the harvest instance.
If unset, the endpoints return 501 Not Implemented (feature is off).
Anonymization: a separate pass (run_anonymization) replaces IPs, hostnames,
and usernames in branch DBs with stable pseudonyms before Avocet reads them.
"""
from __future__ import annotations
import hashlib
import hmac
import ipaddress
import json
import logging
import os
import re
import secrets
import sqlite3
import subprocess
import time
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Config (read from env on the harvest instance)
# ---------------------------------------------------------------------------
ORCHARD_DATA_ROOT = Path(os.environ.get("TURNSTONE_ORCHARD_DATA_ROOT", "/devl/docker/turnstone-submissions"))
ORCHARD_CADDYFILE = Path(os.environ.get("TURNSTONE_ORCHARD_CADDYFILE", "/devl/caddy-proxy/Caddyfile"))
ORCHARD_CADDY_CONTAINER = os.environ.get("TURNSTONE_ORCHARD_CADDY_CONTAINER", "caddy-proxy")
ORCHARD_HARVEST_HOST = os.environ.get("TURNSTONE_ORCHARD_HARVEST_HOST", "https://harvest.circuitforge.tech")
ORCHARD_IMAGE = os.environ.get("TURNSTONE_ORCHARD_IMAGE", "localhost/turnstone:latest")
# Ports for submission containers start here and scan upward.
ORCHARD_PORT_BASE = int(os.environ.get("TURNSTONE_ORCHARD_PORT_BASE", "8538"))
_REGISTRY_FILE = ORCHARD_DATA_ROOT / "orchard-branches.yaml"
_CADDY_BRANCH_START = "# --- ORCHARD BRANCHES: auto-managed by POST /api/orchard/graft, do not edit manually ---"
_CADDY_BRANCH_END = "# --- END ORCHARD BRANCHES ---"
_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]{1,30}[a-z0-9]$")
# ---------------------------------------------------------------------------
# Branch registry
# ---------------------------------------------------------------------------
def _load_registry() -> list[dict[str, Any]]:
if not _REGISTRY_FILE.exists():
return []
import yaml as _yaml
try:
data = _yaml.safe_load(_REGISTRY_FILE.read_text()) or {}
return data.get("branches", [])
except Exception:
return []
def _save_registry(branches: list[dict[str, Any]]) -> None:
import yaml as _yaml
_REGISTRY_FILE.parent.mkdir(parents=True, exist_ok=True)
_REGISTRY_FILE.write_text(_yaml.dump({"branches": branches}, default_flow_style=False))
def list_branches() -> list[dict[str, Any]]:
return _load_registry()
# ---------------------------------------------------------------------------
# Port allocation
# ---------------------------------------------------------------------------
def _next_free_port() -> int:
used = {b["port"] for b in _load_registry() if "port" in b}
port = ORCHARD_PORT_BASE
while port in used:
port += 1
return port
# ---------------------------------------------------------------------------
# Caddy route injection
# ---------------------------------------------------------------------------
def _build_branch_block(slug: str, port: int) -> str:
return (
f" handle_path /{slug}/* {{\n"
f" reverse_proxy http://host.docker.internal:{port} {{\n"
f" header_up X-Real-IP {{remote_host}}\n"
f" header_up X-Forwarded-Proto {{scheme}}\n"
f" flush_interval -1\n"
f" transport http {{\n"
f" response_header_timeout 0\n"
f" read_timeout 0\n"
f" }}\n"
f" }}\n"
f" }}"
)
def _rewrite_caddy_branches(branches: list[dict[str, Any]]) -> None:
"""Replace the auto-managed section in the Caddyfile with current branches."""
if not ORCHARD_CADDYFILE.exists():
raise RuntimeError(f"Caddyfile not found at {ORCHARD_CADDYFILE}")
text = ORCHARD_CADDYFILE.read_text()
start_idx = text.find(_CADDY_BRANCH_START)
end_idx = text.find(_CADDY_BRANCH_END)
if start_idx == -1 or end_idx == -1:
raise RuntimeError("Caddyfile is missing the ORCHARD BRANCHES marker section")
active = [b for b in branches if b.get("active", True)]
blocks = "\n".join(_build_branch_block(b["slug"], b["port"]) for b in active)
replacement = f"{_CADDY_BRANCH_START}\n{blocks}\n {_CADDY_BRANCH_END}"
new_text = text[:start_idx] + replacement + text[end_idx + len(_CADDY_BRANCH_END):]
ORCHARD_CADDYFILE.write_text(new_text)
logger.info("Caddyfile updated with %d active branch routes", len(active))
def _reload_caddy() -> None:
result = subprocess.run(
["docker", "restart", ORCHARD_CADDY_CONTAINER],
capture_output=True, text=True, timeout=30,
)
if result.returncode != 0:
raise RuntimeError(f"docker restart {ORCHARD_CADDY_CONTAINER} failed: {result.stderr}")
logger.info("Restarted %s", ORCHARD_CADDY_CONTAINER)
# ---------------------------------------------------------------------------
# Container provisioning
# ---------------------------------------------------------------------------
def _start_branch_container(slug: str, port: int, data_dir: Path) -> None:
patterns_dir = data_dir / "patterns"
patterns_dir.mkdir(parents=True, exist_ok=True)
data_dir.mkdir(parents=True, exist_ok=True)
# Seed default patterns if not already present
repo_patterns = Path(__file__).parent.parent.parent / "patterns"
for yaml_file in ("default.yaml", "sources-example.yaml"):
src = repo_patterns / yaml_file
dst = patterns_dir / yaml_file
if src.exists() and not dst.exists():
dst.write_text(src.read_text())
container_name = f"turnstone-submissions-{slug}"
cmd = [
"docker", "run", "-d",
"--name", container_name,
"--restart", "unless-stopped",
"-p", f"{port}:8534",
"-v", f"{data_dir}:/data",
"-v", f"{patterns_dir}:/patterns",
"-e", f"TURNSTONE_DB=/data/turnstone.db",
"-e", f"TURNSTONE_SOURCE_HOST={slug}",
"-e", "PYTHONUNBUFFERED=1",
"-e", "TZ=America/Los_Angeles",
ORCHARD_IMAGE,
]
# Remove any stale container with the same name first
subprocess.run(["docker", "rm", "-f", container_name], capture_output=True)
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
if result.returncode != 0:
raise RuntimeError(f"docker run for {container_name} failed: {result.stderr}")
logger.info("Started container %s on port %d", container_name, port)
def _stop_branch_container(slug: str) -> None:
container_name = f"turnstone-submissions-{slug}"
subprocess.run(["docker", "rm", "-f", container_name], capture_output=True, timeout=30)
logger.info("Removed container %s", container_name)
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def graft(slug: str, contact_email: str, agreed_to_terms: bool) -> dict[str, Any]:
"""Provision a new Orchard branch and return connection details."""
if not agreed_to_terms:
raise ValueError("agreed_to_terms must be true")
if not _SLUG_RE.match(slug):
raise ValueError(
f"Invalid slug {slug!r}: must be 2-32 lowercase alphanumeric/hyphen, "
"cannot start or end with a hyphen"
)
branches = _load_registry()
if any(b["slug"] == slug for b in branches):
raise ValueError(f"Branch {slug!r} already exists")
port = _next_free_port()
data_dir = ORCHARD_DATA_ROOT / slug
api_key = secrets.token_urlsafe(32)
branch: dict[str, Any] = {
"slug": slug,
"port": port,
"contact_email": contact_email,
"api_key_hash": hashlib.sha256(api_key.encode()).hexdigest(),
"grafted_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"active": True,
}
_start_branch_container(slug, port, data_dir)
branches.append(branch)
_save_registry(branches)
_rewrite_caddy_branches(branches)
_reload_caddy()
submit_endpoint = f"{ORCHARD_HARVEST_HOST}/{slug}"
logger.info("Grafted branch %r at %s", slug, submit_endpoint)
return {
"slug": slug,
"submit_endpoint": submit_endpoint,
"api_key": api_key,
"port": port,
}
def deactivate(slug: str) -> dict[str, Any]:
"""Deactivate a branch: stop its container and remove its Caddy route."""
branches = _load_registry()
branch = next((b for b in branches if b["slug"] == slug), None)
if branch is None:
raise KeyError(f"Branch {slug!r} not found")
_stop_branch_container(slug)
branch["active"] = False
_save_registry(branches)
_rewrite_caddy_branches(branches)
_reload_caddy()
return {"slug": slug, "deactivated": True}
def verify_api_key(slug: str, key: str) -> bool:
"""Check whether *key* is valid for the given branch slug."""
branches = _load_registry()
branch = next((b for b in branches if b["slug"] == slug and b.get("active")), None)
if branch is None:
return False
expected = branch.get("api_key_hash", "")
provided = hashlib.sha256(key.encode()).hexdigest()
return hmac.compare_digest(expected, provided)
# ---------------------------------------------------------------------------
# Anonymization worker
# ---------------------------------------------------------------------------
_IP_RE = re.compile(
r"\b(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\b"
)
_USERNAME_RE = re.compile(r"\bfor\s+(\w+)\b|\buser\s+(\w+)\b|\bsession\s+opened\s+for\s+(\w+)\b", re.IGNORECASE)
def _pseudonym(value: str, salt: bytes, prefix: str) -> str:
digest = hmac.new(salt, value.encode(), "sha256").hexdigest()[:10]
return f"{prefix}-{digest}"
def _anonymize_text(text: str, salt: bytes) -> str:
def replace_ip(m: re.Match) -> str:
return _pseudonym(m.group(), salt, "ip")
def replace_user(m: re.Match) -> str:
user = next(g for g in m.groups() if g)
return m.group().replace(user, _pseudonym(user, salt, "user"))
text = _IP_RE.sub(replace_ip, text)
text = _USERNAME_RE.sub(replace_user, text)
return text
def run_anonymization(slug: str) -> dict[str, Any]:
"""Anonymize IPs and usernames in a branch DB in-place.
Uses a stable per-branch salt so pseudonyms are consistent across runs
but not reversible without the salt.
"""
branch = next((b for b in _load_registry() if b["slug"] == slug), None)
if branch is None:
raise KeyError(f"Branch {slug!r} not found")
db_path = ORCHARD_DATA_ROOT / slug / "turnstone.db"
if not db_path.exists():
return {"slug": slug, "anonymized": 0}
# Per-branch salt derived from api_key_hash for stability
salt = branch["api_key_hash"].encode()[:32].ljust(32, b"0")
conn = sqlite3.connect(str(db_path), timeout=30)
conn.execute("PRAGMA journal_mode=WAL")
rows = conn.execute("SELECT id, text FROM log_entries WHERE anonymized IS NULL OR anonymized = 0").fetchall()
updated = 0
for row_id, text in rows:
clean = _anonymize_text(text or "", salt)
if clean != text:
conn.execute("UPDATE log_entries SET text = ?, anonymized = 1 WHERE id = ?", (clean, row_id))
updated += 1
else:
conn.execute("UPDATE log_entries SET anonymized = 1 WHERE id = ?", (row_id,))
conn.commit()
conn.close()
logger.info("Anonymized %d/%d entries in branch %r", updated, len(rows), slug)
return {"slug": slug, "anonymized": updated, "total_processed": len(rows)}

View file

@ -244,19 +244,19 @@ def _sqlite_fts_search(
params: list = [fts_query, tid]
if severity:
conditions.append("severity = ?")
conditions.append("f.severity = ?")
params.append(severity.upper())
if source_filter:
conditions.append("source_id LIKE ?")
conditions.append("f.source_id LIKE ?")
params.append(f"%{source_filter}%")
if pattern_filter:
conditions.append("matched_patterns LIKE ?")
conditions.append("f.matched_patterns LIKE ?")
params.append(f'%"{pattern_filter}"%')
if since:
conditions.append("timestamp_iso >= ?")
conditions.append("f.timestamp_iso >= ?")
params.append(since)
if until:
conditions.append("timestamp_iso <= ?")
conditions.append("f.timestamp_iso <= ?")
params.append(until)
if not include_repeats:
conditions.append("f.repeat_count = 1")

265
app/services/ssh_targets.py Normal file
View file

@ -0,0 +1,265 @@
"""SSH target registry — persisted in the main SQLite DB.
Targets are stored as path references only. The private key is never
read into the database, logged, or returned by any API response.
"""
from __future__ import annotations
import os
import sqlite3
import stat
import time
import uuid
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@dataclass
class SshTarget:
id: str
label: str
host: str
port: int
user: str
key_path: str
last_tested: str | None
last_ok: bool | None
last_error: str | None
created_at: str
updated_at: str
def _row_to_target(row: tuple) -> SshTarget:
return SshTarget(
id=row[0],
label=row[1],
host=row[2],
port=row[3],
user=row[4],
key_path=row[5],
last_tested=row[6],
last_ok=bool(row[7]) if row[7] is not None else None,
last_error=row[8],
created_at=row[9],
updated_at=row[10],
)
def _now() -> str:
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
# ---------------------------------------------------------------------------
# CRUD
# ---------------------------------------------------------------------------
def list_targets(db_path: Path) -> list[SshTarget]:
conn = sqlite3.connect(str(db_path), timeout=10)
rows = conn.execute(
"SELECT id, label, host, port, user, key_path, last_tested, last_ok, last_error, created_at, updated_at "
"FROM ssh_targets ORDER BY label"
).fetchall()
conn.close()
return [_row_to_target(r) for r in rows]
def get_target(db_path: Path, target_id: str) -> SshTarget | None:
conn = sqlite3.connect(str(db_path), timeout=10)
row = conn.execute(
"SELECT id, label, host, port, user, key_path, last_tested, last_ok, last_error, created_at, updated_at "
"FROM ssh_targets WHERE id = ?",
(target_id,),
).fetchone()
conn.close()
return _row_to_target(row) if row else None
def create_target(
db_path: Path,
label: str,
host: str,
port: int,
user: str,
key_path: str,
) -> SshTarget:
resolved = _validate_key_path(key_path)
now = _now()
target_id = str(uuid.uuid4())
conn = sqlite3.connect(str(db_path), timeout=10)
conn.execute(
"INSERT INTO ssh_targets (id, label, host, port, user, key_path, created_at, updated_at) "
"VALUES (?,?,?,?,?,?,?,?)",
(target_id, label, host, port, user, str(resolved), now, now),
)
conn.commit()
conn.close()
return get_target(db_path, target_id) # type: ignore[return-value]
def update_target(
db_path: Path,
target_id: str,
*,
label: str | None = None,
host: str | None = None,
port: int | None = None,
user: str | None = None,
key_path: str | None = None,
) -> SshTarget | None:
existing = get_target(db_path, target_id)
if existing is None:
return None
resolved_key = str(_validate_key_path(key_path)) if key_path else existing.key_path
conn = sqlite3.connect(str(db_path), timeout=10)
conn.execute(
"UPDATE ssh_targets SET label=?, host=?, port=?, user=?, key_path=?, updated_at=? WHERE id=?",
(
label if label is not None else existing.label,
host if host is not None else existing.host,
port if port is not None else existing.port,
user if user is not None else existing.user,
resolved_key,
_now(),
target_id,
),
)
conn.commit()
conn.close()
return get_target(db_path, target_id)
def delete_target(db_path: Path, target_id: str) -> bool:
conn = sqlite3.connect(str(db_path), timeout=10)
cur = conn.execute("DELETE FROM ssh_targets WHERE id = ?", (target_id,))
conn.commit()
conn.close()
return cur.rowcount > 0
# ---------------------------------------------------------------------------
# Test connection
# ---------------------------------------------------------------------------
def test_connection(db_path: Path, target_id: str) -> dict[str, Any]:
"""Attempt an SSH no-op and record the result.
Runs `true` on the remote host no data is pulled. Returns
{ok: bool, error: str|null, tested_at: str}.
"""
target = get_target(db_path, target_id)
if target is None:
raise KeyError(f"SSH target {target_id!r} not found")
# Lazy import — paramiko is optional
try:
from paramiko import SSHClient, AutoAddPolicy, AuthenticationException, SSHException
except ImportError:
_record_test(db_path, target_id, ok=False, error="paramiko not installed")
return {"ok": False, "error": "paramiko not installed — run: pip install paramiko", "tested_at": _now()}
key_path = str(Path(target.key_path).expanduser())
error: str | None = None
ok = False
try:
client = SSHClient()
client.set_missing_host_key_policy(AutoAddPolicy())
client.connect(
hostname=target.host,
port=target.port,
username=target.user,
key_filename=key_path,
timeout=10,
banner_timeout=10,
)
stdin, stdout, stderr = client.exec_command("true", timeout=10)
exit_code = stdout.channel.recv_exit_status()
client.close()
ok = exit_code == 0
if not ok:
error = f"Remote command exited with code {exit_code}"
except AuthenticationException:
error = f"Authentication failed — check key path and remote authorized_keys"
except SSHException as exc:
error = f"SSH error: {exc}"
except OSError as exc:
error = f"Connection failed: {exc}"
except Exception as exc:
error = f"Unexpected error: {exc}"
tested_at = _now()
_record_test(db_path, target_id, ok=ok, error=error, tested_at=tested_at)
return {"ok": ok, "error": error, "tested_at": tested_at}
def _record_test(
db_path: Path,
target_id: str,
*,
ok: bool,
error: str | None,
tested_at: str | None = None,
) -> None:
if tested_at is None:
tested_at = _now()
conn = sqlite3.connect(str(db_path), timeout=10)
conn.execute(
"UPDATE ssh_targets SET last_tested=?, last_ok=?, last_error=?, updated_at=? WHERE id=?",
(tested_at, 1 if ok else 0, error, _now(), target_id),
)
conn.commit()
conn.close()
# ---------------------------------------------------------------------------
# Validation
# ---------------------------------------------------------------------------
def _validate_key_path(raw: str) -> Path:
"""Resolve and validate the SSH key path.
Returns the resolved Path. Raises ValueError with a user-readable message
on any problem (does not raise on world-readable just returns a warning
to the caller so the UI can display it non-blocking).
"""
p = Path(raw).expanduser()
if not p.exists():
raise ValueError(f"Key file not found: {p}")
if not p.is_file():
raise ValueError(f"Key path is not a file: {p}")
return p
def key_path_warning(key_path: str) -> str | None:
"""Return a warning string if the key file has overly permissive mode, else None."""
try:
p = Path(key_path).expanduser()
mode = p.stat().st_mode
if mode & (stat.S_IRGRP | stat.S_IWGRP | stat.S_IROTH | stat.S_IWOTH):
perms = oct(mode & 0o777)
return f"Key file permissions are too open ({perms}). SSH may refuse to use it — run: chmod 600 {p}"
except OSError:
pass
return None
def target_to_dict(t: SshTarget, include_warning: bool = False) -> dict[str, Any]:
"""Serialize a target for API responses. Never includes key contents."""
d: dict[str, Any] = {
"id": t.id,
"label": t.label,
"host": t.host,
"port": t.port,
"user": t.user,
"key_path": t.key_path,
"last_tested": t.last_tested,
"last_ok": t.last_ok,
"last_error": t.last_error,
"created_at": t.created_at,
"updated_at": t.updated_at,
}
if include_warning:
d["key_warning"] = key_path_warning(t.key_path)
return d

View file

@ -0,0 +1,213 @@
"""Incident ticket export — push Turnstone incidents to external trackers.
Supported targets: "notion", "jira"
Each exporter receives the incident dict and a list of log entry dicts,
and returns {"url": str, "ticket_id": str}.
"""
from __future__ import annotations
import json
from typing import Any
import httpx
# ---------------------------------------------------------------------------
# Notion exporter
# ---------------------------------------------------------------------------
def _notion_export(
incident: dict[str, Any],
entries: list[dict[str, Any]],
token: str,
database_id: str,
) -> dict[str, str]:
"""Create a Notion page in *database_id* from an incident.
Notion block types used: heading_2, bulleted_list_item, paragraph.
Rich text max length is 2000 chars per block.
"""
if not token or not database_id:
raise ValueError("Notion not configured — set notion_token and notion_database_id in Settings")
def _text(s: str, bold: bool = False) -> dict:
chunk: dict[str, Any] = {"type": "text", "text": {"content": s[:2000]}}
if bold:
chunk["annotations"] = {"bold": True}
return chunk
log_blocks: list[dict] = []
for e in entries[:50]: # Notion has page size limits
line = f"[{e.get('severity') or '?'}] {e.get('source_id', '')}{e.get('text', '')[:160]}"
log_blocks.append({"object": "block", "type": "bulleted_list_item",
"bulleted_list_item": {"rich_text": [_text(line)]}})
sev = incident.get("severity", "medium").upper()
issue_type = incident.get("issue_type") or ""
window = f"{incident.get('started_at') or '?'}{incident.get('ended_at') or 'ongoing'}"
children: list[dict] = [
{"object": "block", "type": "heading_2",
"heading_2": {"rich_text": [_text("Incident Details", bold=True)]}},
{"object": "block", "type": "paragraph",
"paragraph": {"rich_text": [
_text("Severity: ", bold=True), _text(sev),
_text(" Type: ", bold=True), _text(issue_type),
_text(" Window: ", bold=True), _text(window),
]}},
]
if incident.get("notes"):
children.append({"object": "block", "type": "paragraph",
"paragraph": {"rich_text": [_text("Notes: ", bold=True), _text(incident["notes"])]}})
children.append({"object": "block", "type": "heading_2",
"heading_2": {"rich_text": [_text("Log Evidence")]}})
children.extend(log_blocks)
payload = {
"parent": {"database_id": database_id},
"properties": {
"title": {"title": [_text(incident.get("label", "Unnamed Incident"))]},
},
"children": children,
}
resp = httpx.post(
"https://api.notion.com/v1/pages",
headers={
"Authorization": f"Bearer {token}",
"Notion-Version": "2022-06-28",
"Content-Type": "application/json",
},
json=payload,
timeout=15,
)
if not resp.is_success:
raise RuntimeError(f"Notion API error {resp.status_code}: {resp.text[:300]}")
page = resp.json()
page_id = page["id"]
url = page.get("url") or f"https://notion.so/{page_id.replace('-', '')}"
return {"url": url, "ticket_id": page_id}
# ---------------------------------------------------------------------------
# Jira exporter
# ---------------------------------------------------------------------------
def _jira_export(
incident: dict[str, Any],
entries: list[dict[str, Any]],
jira_url: str,
email: str,
api_token: str,
project_key: str,
issue_type: str = "Bug",
) -> dict[str, str]:
"""Create a Jira issue via REST API v3 (cloud or Server 8.4+)."""
if not jira_url or not email or not api_token or not project_key:
raise ValueError("Jira not configured — set jira_url, jira_email, jira_api_token, and jira_project_key in Settings")
base = jira_url.rstrip("/")
sev = incident.get("severity", "medium").upper()
inc_type = incident.get("issue_type") or "incident"
window = f"{incident.get('started_at') or '?'}{incident.get('ended_at') or 'ongoing'}"
log_lines = "\n".join(
f"[{e.get('severity') or '?'}] {e.get('source_id', '')}{e.get('text', '')[:160]}"
for e in entries[:40]
)
description = (
f"*Severity:* {sev} | *Type:* {inc_type} | *Window:* {window}\n\n"
+ (f"*Notes:* {incident['notes']}\n\n" if incident.get("notes") else "")
+ "h2. Log Evidence\n\n{{code}}\n" + log_lines + "\n{{code}}"
)
# Jira REST v3 uses Atlassian Document Format for description
adf_body = {
"type": "doc",
"version": 1,
"content": [
{"type": "paragraph", "content": [{"type": "text", "text": description}]},
],
}
payload = {
"fields": {
"project": {"key": project_key},
"summary": incident.get("label", "Unnamed Incident"),
"issuetype": {"name": issue_type},
"description": adf_body,
}
}
import base64 as _b64
creds = _b64.b64encode(f"{email}:{api_token}".encode()).decode()
resp = httpx.post(
f"{base}/rest/api/3/issue",
headers={
"Authorization": f"Basic {creds}",
"Content-Type": "application/json",
"Accept": "application/json",
},
json=payload,
timeout=15,
)
if not resp.is_success:
raise RuntimeError(f"Jira API error {resp.status_code}: {resp.text[:300]}")
data = resp.json()
issue_key = data["key"]
url = f"{base}/browse/{issue_key}"
return {"url": url, "ticket_id": issue_key}
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
_EXPORTERS = {
"notion": _notion_export,
"jira": _jira_export,
}
def available_targets() -> list[str]:
return list(_EXPORTERS.keys())
def export_incident(
target: str,
incident: dict[str, Any],
entries: list[dict[str, Any]],
config: dict[str, str],
) -> dict[str, str]:
"""Dispatch to the appropriate exporter.
*config* is pulled from the settings pref dict callers pass the relevant
subset so this service stays stateless and testable.
Returns {"url": str, "ticket_id": str}.
Raises ValueError for unknown target or missing config.
Raises RuntimeError on API-level failures.
"""
if target not in _EXPORTERS:
raise ValueError(f"Unknown ticket target: {target!r}. Supported: {list(_EXPORTERS)}")
if target == "notion":
return _notion_export(
incident, entries,
token=config.get("notion_token", ""),
database_id=config.get("notion_database_id", ""),
)
if target == "jira":
return _jira_export(
incident, entries,
jira_url=config.get("jira_url", ""),
email=config.get("jira_email", ""),
api_token=config.get("jira_api_token", ""),
project_key=config.get("jira_project_key", ""),
issue_type=config.get("jira_issue_type", "Bug"),
)
raise ValueError(f"Unhandled target: {target!r}")

View file

@ -82,7 +82,7 @@ TZ="${TZ:-America/Los_Angeles}"
# receiving instance after each glean run. Only matched entries are sent —
# no raw log content. Used to build Avocet training data.
#
# export TURNSTONE_SUBMIT_ENDPOINT=https://harvest.circuitforge.tech/contrib1
# export TURNSTONE_SUBMIT_ENDPOINT=https://harvest.circuitforge.tech/your-node-id
# bash ~/turnstone/docker-standalone.sh
#

View file

@ -211,10 +211,10 @@ patterns:
domain: media
description: Plex EasyAudioEncoder (EAC3 Dolby audio transcoder) crashed — service restart required
# - name: ext_device_device_error
# - name: ext_device_error
# pattern: "ERR-\d{4}"
# severity: ERROR
# description: EXT_DEVICE device error code
# description: vendor device structured error code
# ── VPN / tunnel patterns ──────────────────────────────────────────────────

View file

@ -80,7 +80,7 @@ TZ="${TZ:-America/Los_Angeles}"
# receiving instance after each glean run. Only matched entries are sent —
# no raw log content. Used to build Avocet training data.
#
# export TURNSTONE_SUBMIT_ENDPOINT=https://harvest.circuitforge.tech/contrib2
# export TURNSTONE_SUBMIT_ENDPOINT=https://harvest.circuitforge.tech/your-node-id
# bash /opt/turnstone/podman-standalone.sh
#
# TURNSTONE_SOURCE_HOST is auto-detected from `hostname` — override if needed.
@ -99,7 +99,7 @@ TZ="${TZ:-America/Los_Angeles}"
# export TURNSTONE_MULTI_AGENT_DIAGNOSE=true
# sudo bash /opt/turnstone/podman-standalone.sh
#
# For Contributor's instance (Huginn) — WireGuard reaches Heimdall LAN directly,
# For WireGuard-connected Docker hosts — WireGuard reaches Heimdall LAN directly,
# use docker-standalone.sh (not this script — Docker host):
# export GPU_SERVER_URL=http://<YOUR_HOST_IP>:7700
# export TURNSTONE_MULTI_AGENT_DIAGNOSE=true

View file

@ -12,7 +12,7 @@ Output tree:
<out>/journald/system.jsonl systemd/kernel journald JSON
<out>/docker/services.jsonl containerised app stdout
<out>/qbittorrent/qbt.log hotio-format qBittorrent log
<out>/ext_device/device.log EXT_DEVICE device plaintext log
<out>/ext_device/device.log vendor device plaintext log
"""
from __future__ import annotations
@ -308,7 +308,7 @@ def gen_qbittorrent(path: Path, start: datetime, end: datetime, rng: random.Rand
def gen_ext_device(path: Path, start: datetime, end: datetime, rng: random.Random, error_rate: float) -> int:
"""Emit EXT_DEVICE device plaintext log (ISO timestamp + level + ERR/SYS/NET code + message)."""
"""Emit vendor device plaintext log (ISO timestamp + level + ERR/SYS/NET code + message)."""
lines = 0
with path.open("w") as fh:
for dt in _ts_seq(start, end, rng):

View file

@ -7,8 +7,8 @@ from __future__ import annotations
from unittest.mock import MagicMock, patch
from app.context.retriever import RetrievedContext
from app.services.diagnose.models import Hypothesis, RankedHypothesis, TimelineResult
from app.services.diagnose.synthesizer import SummarySynthesizer
from app.services.diagnose.models import EventCluster, Hypothesis, RankedHypothesis, TimelineResult
from app.services.diagnose.synthesizer import SummarySynthesizer, _build_timeline_block
# ---------------------------------------------------------------------------
@ -50,12 +50,38 @@ def _make_ranked(
)
def _make_cluster(
cluster_id: str = "c1",
start_iso: str | None = "2026-01-01T00:05:00+00:00",
severity: str = "ERROR",
source_ids: tuple[str, ...] = ("syslog",),
pattern_tags: tuple[str, ...] = ("ssh_auth_failure",),
burst: bool = False,
gap_before_seconds: float = 0.0,
representative_text: str = "Failed password for root from 1.2.3.4 port 22",
) -> EventCluster:
return EventCluster(
cluster_id=cluster_id,
entries=("e1",),
start_iso=start_iso,
end_iso=None,
duration_seconds=30.0,
source_ids=source_ids,
pattern_tags=pattern_tags,
severity=severity, # type: ignore[arg-type]
burst=burst,
gap_before_seconds=gap_before_seconds,
representative_text=representative_text,
)
def _make_timeline(
total_entries: int = 42,
n_clusters: int = 3,
clusters: tuple[EventCluster, ...] | None = None,
) -> TimelineResult:
return TimelineResult(
clusters=tuple(),
clusters=clusters if clusters is not None else tuple(),
total_entries=total_entries,
window_start="2026-01-01T00:00:00+00:00",
window_end="2026-01-01T01:00:00+00:00",
@ -283,3 +309,88 @@ class TestSynthesizerEmptyRanked:
assert isinstance(result, str)
assert len(result) > 0
class TestBuildTimelineBlock:
"""Unit tests for _build_timeline_block helper."""
def test_empty_clusters_returns_placeholder(self):
timeline = _make_timeline(clusters=tuple())
assert _build_timeline_block(timeline) == "(no clusters)"
def test_single_cluster_basic_fields(self):
cluster = _make_cluster(
start_iso="2026-01-01T00:05:00+00:00",
severity="ERROR",
source_ids=("syslog",),
representative_text="Failed password for root",
)
timeline = _make_timeline(clusters=(cluster,))
block = _build_timeline_block(timeline)
assert "Cluster 1" in block
assert "2026-01-01T00:05:00+00:00" in block
assert "[ERROR]" in block
assert "syslog" in block
assert "Failed password for root" in block
def test_burst_label_applied(self):
cluster = _make_cluster(burst=True)
timeline = _make_timeline(clusters=(cluster,))
block = _build_timeline_block(timeline)
assert "[BURST]" in block
def test_no_burst_label_when_not_burst(self):
cluster = _make_cluster(burst=False)
timeline = _make_timeline(clusters=(cluster,))
block = _build_timeline_block(timeline)
assert "[BURST]" not in block
def test_gap_label_applied_when_over_threshold(self):
cluster = _make_cluster(gap_before_seconds=120.0)
timeline = _make_timeline(clusters=(cluster,))
block = _build_timeline_block(timeline)
assert "silence" in block
assert "120s" in block
def test_gap_label_omitted_when_under_threshold(self):
cluster = _make_cluster(gap_before_seconds=10.0)
timeline = _make_timeline(clusters=(cluster,))
block = _build_timeline_block(timeline)
assert "silence" not in block
def test_pattern_tags_included(self):
cluster = _make_cluster(pattern_tags=("ssh_auth_failure", "brute_force"))
timeline = _make_timeline(clusters=(cluster,))
block = _build_timeline_block(timeline)
assert "ssh_auth_failure" in block
assert "brute_force" in block
def test_no_patterns_section_when_empty(self):
cluster = _make_cluster(pattern_tags=tuple())
timeline = _make_timeline(clusters=(cluster,))
block = _build_timeline_block(timeline)
assert "[patterns:" not in block
def test_multiple_clusters_numbered(self):
c1 = _make_cluster(cluster_id="c1", representative_text="first event")
c2 = _make_cluster(cluster_id="c2", representative_text="second event")
timeline = _make_timeline(clusters=(c1, c2))
block = _build_timeline_block(timeline)
assert "Cluster 1" in block
assert "Cluster 2" in block
assert "first event" in block
assert "second event" in block
def test_representative_text_truncated_at_200_chars(self):
long_text = "x" * 300
cluster = _make_cluster(representative_text=long_text)
timeline = _make_timeline(clusters=(cluster,))
block = _build_timeline_block(timeline)
assert "x" * 200 in block
assert "x" * 201 not in block
def test_null_start_iso_renders_as_unknown(self):
cluster = _make_cluster(start_iso=None)
timeline = _make_timeline(clusters=(cluster,))
block = _build_timeline_block(timeline)
assert "unknown" in block

133
tests/test_discover_scan.py Normal file
View file

@ -0,0 +1,133 @@
"""Tests for scan_log_directories in app.services.discover."""
from __future__ import annotations
import os
import time
from pathlib import Path
import pytest
from app.services.discover import scan_log_directories, _path_to_source_id
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_log(tmp_path: Path, name: str, content: str = "hello\n", age_days: float = 0) -> Path:
p = tmp_path / name
p.write_text(content)
mtime = time.time() - age_days * 86400
os.utime(p, (mtime, mtime))
return p
# ---------------------------------------------------------------------------
# _path_to_source_id
# ---------------------------------------------------------------------------
def test_path_to_source_id_basic():
result = _path_to_source_id(Path("/var/log/nginx/access.log"))
assert result.startswith("var-log-nginx-access")
assert "/" not in result
assert " " not in result
def test_path_to_source_id_max_length():
long_path = Path("/" + "a" * 200 + ".log")
assert len(_path_to_source_id(long_path)) <= 64
# ---------------------------------------------------------------------------
# scan_log_directories
# ---------------------------------------------------------------------------
def test_scan_finds_log_files(tmp_path):
_make_log(tmp_path, "app.log", "error: something\n")
_make_log(tmp_path, "system.log", "kernel: ok\n")
results = scan_log_directories(dirs=[str(tmp_path)])
paths = [r["path"] for r in results]
assert str(tmp_path / "app.log") in paths
assert str(tmp_path / "system.log") in paths
def test_scan_ignores_empty_files(tmp_path):
_make_log(tmp_path, "empty.log", "")
results = scan_log_directories(dirs=[str(tmp_path)])
assert not any(r["label"] == "empty.log" for r in results)
def test_scan_ignores_non_log_extensions(tmp_path):
(tmp_path / "config.yaml").write_text("key: value\n")
(tmp_path / "data.json").write_text('{"a":1}\n')
results = scan_log_directories(dirs=[str(tmp_path)])
names = [r["label"] for r in results]
assert "config.yaml" not in names
assert "data.json" not in names
def test_scan_ignores_compressed(tmp_path):
_make_log(tmp_path, "old.log.gz", "compressed content")
results = scan_log_directories(dirs=[str(tmp_path)])
assert not any(r["label"].endswith(".gz") for r in results)
def test_scan_respects_max_results(tmp_path):
for i in range(20):
_make_log(tmp_path, f"app{i}.log", f"log line {i}\n")
results = scan_log_directories(dirs=[str(tmp_path)], max_results=5)
assert len(results) <= 5
def test_scan_recent_files_score_higher(tmp_path):
recent = _make_log(tmp_path, "recent.log", "new stuff\n", age_days=0)
old = _make_log(tmp_path, "old.log", "old stuff\n", age_days=60)
results = scan_log_directories(dirs=[str(tmp_path)])
scores = {r["path"]: r["score"] for r in results}
assert scores[str(recent)] > scores[str(old)]
def test_scan_keyword_match_boosts_score(tmp_path):
nginx_log = _make_log(tmp_path, "nginx.log", "GET / 200\n", age_days=5)
other_log = _make_log(tmp_path, "kernel.log", "boot ok\n", age_days=5)
results = scan_log_directories(query="nginx 502 error", dirs=[str(tmp_path)])
scores = {r["path"]: r["score"] for r in results}
assert scores[str(nginx_log)] > scores[str(other_log)]
def test_scan_returns_required_fields(tmp_path):
_make_log(tmp_path, "test.log", "data\n")
results = scan_log_directories(dirs=[str(tmp_path)])
assert results
r = results[0]
assert r["type"] == "file"
assert "id" in r
assert "path" in r
assert "label" in r
assert "size_bytes" in r
assert "mtime" in r
assert "score" in r
assert r["available"] is True
def test_scan_missing_dir_is_graceful():
results = scan_log_directories(dirs=["/nonexistent/path/xyz"])
assert results == []
def test_scan_subdirectory_recursive(tmp_path):
subdir = tmp_path / "subapp"
subdir.mkdir()
_make_log(subdir, "subapp.log", "nested log\n")
results = scan_log_directories(dirs=[str(tmp_path)])
paths = [r["path"] for r in results]
assert str(subdir / "subapp.log") in paths
def test_scan_no_query_weights_recency_heavily(tmp_path):
"""Without a query, recency (0.7) dominates over size (0.3)."""
fresh = _make_log(tmp_path, "fresh.log", "x" * 100, age_days=0)
stale = _make_log(tmp_path, "stale.log", "x" * 10000, age_days=20)
results = scan_log_directories(query=None, dirs=[str(tmp_path)])
scores = {r["path"]: r["score"] for r in results}
assert scores[str(fresh)] > scores[str(stale)]

View file

@ -129,7 +129,7 @@ class TestQbittorrentFormat:
assert severities <= {"INFO", "WARN", "CRITICAL"}
# ── EXT_DEVICE format ────────────────────────────────────────────────────────────────
# ── Vendor device format ────────────────────────────────────────────────────────────────
class TestAvcxFormat:
def test_iso_timestamp_prefix(self, tmp_path: Path) -> None:

87
tests/test_llm_client.py Normal file
View file

@ -0,0 +1,87 @@
"""Tests for diagnose/_llm_client.py — thinking-tag stripping and content extraction."""
from __future__ import annotations
import pytest
def _resp(content: str | None) -> dict:
if content is None:
return {"choices": []}
return {"choices": [{"message": {"content": content}}]}
class TestExtractContent:
def test_returns_plain_content(self):
from app.services.diagnose._llm_client import extract_content
assert extract_content(_resp("hello world")) == "hello world"
def test_returns_none_on_empty_choices(self):
from app.services.diagnose._llm_client import extract_content
assert extract_content({"choices": []}) is None
def test_returns_none_on_empty_content(self):
from app.services.diagnose._llm_client import extract_content
assert extract_content(_resp("")) is None
def test_strips_single_think_block(self):
from app.services.diagnose._llm_client import extract_content
raw = "<think>Let me reason about this…</think>\nThe answer is 42."
assert extract_content(_resp(raw)) == "The answer is 42."
def test_strips_multi_line_think_block(self):
from app.services.diagnose._llm_client import extract_content
raw = "<think>\nStep 1: consider X\nStep 2: consider Y\n</think>\n\nFinal answer here."
result = extract_content(_resp(raw))
assert result == "Final answer here."
assert "<think>" not in result
def test_strips_multiple_think_blocks(self):
from app.services.diagnose._llm_client import extract_content
raw = "<think>first</think> actual <think>second</think> content"
result = extract_content(_resp(raw))
assert "<think>" not in result
assert "actual" in result
assert "content" in result
def test_strips_case_insensitive(self):
from app.services.diagnose._llm_client import extract_content
raw = "<THINK>hidden</THINK> visible"
result = extract_content(_resp(raw))
assert result == "visible"
def test_returns_none_when_only_thinking_remains(self):
from app.services.diagnose._llm_client import extract_content
raw = "<think>only thinking, no output</think>"
assert extract_content(_resp(raw)) is None
def test_content_without_thinking_unchanged(self):
from app.services.diagnose._llm_client import extract_content
raw = "Redis OOM at 03:00 — key eviction triggered by batch job."
assert extract_content(_resp(raw)) == raw
class TestStripJsonFences:
def test_strips_json_fence(self):
from app.services.diagnose._llm_client import strip_json_fences
raw = "```json\n[{\"a\": 1}]\n```"
assert strip_json_fences(raw) == '[{"a": 1}]'
def test_strips_plain_fence(self):
from app.services.diagnose._llm_client import strip_json_fences
raw = "```\nhello\n```"
assert "```" not in strip_json_fences(raw)
class TestExtractFirstJsonArray:
def test_extracts_array_from_mixed_text(self):
from app.services.diagnose._llm_client import extract_first_json_array
raw = 'Here is the result:\n[{"id": 1}, {"id": 2}]\nThat is all.'
result = extract_first_json_array(raw)
import json
parsed = json.loads(result)
assert len(parsed) == 2
def test_returns_original_when_no_array(self):
from app.services.diagnose._llm_client import extract_first_json_array
raw = "no array here"
assert extract_first_json_array(raw) == raw

245
tests/test_ssh_targets.py Normal file
View file

@ -0,0 +1,245 @@
"""Tests for ssh_targets service — CRUD, validation, serialization."""
from __future__ import annotations
import stat
import sqlite3
from pathlib import Path
import pytest
def _make_db(tmp_path: Path) -> Path:
"""Create a minimal DB with the ssh_targets table via ensure_schema."""
from app.glean.pipeline import ensure_schema
db = tmp_path / "test.db"
ensure_schema(db)
return db
def _make_key(tmp_path: Path, mode: int = 0o600) -> Path:
"""Write a fake SSH private key file with the given permission mode."""
key = tmp_path / "id_ed25519"
key.write_text("-----BEGIN OPENSSH PRIVATE KEY-----\nfake\n-----END OPENSSH PRIVATE KEY-----\n")
key.chmod(mode)
return key
# ---------------------------------------------------------------------------
# Schema
# ---------------------------------------------------------------------------
class TestSchema:
def test_ssh_targets_table_exists(self, tmp_path):
db = _make_db(tmp_path)
conn = sqlite3.connect(str(db))
tables = {r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()}
assert "ssh_targets" in tables
conn.close()
def test_ssh_targets_columns(self, tmp_path):
db = _make_db(tmp_path)
conn = sqlite3.connect(str(db))
cols = {r[1] for r in conn.execute("PRAGMA table_info(ssh_targets)").fetchall()}
assert cols >= {"id", "label", "host", "port", "user", "key_path",
"last_tested", "last_ok", "last_error", "created_at", "updated_at"}
conn.close()
# ---------------------------------------------------------------------------
# CRUD
# ---------------------------------------------------------------------------
class TestCrud:
def test_create_and_list(self, tmp_path):
from app.services.ssh_targets import create_target, list_targets
db = _make_db(tmp_path)
key = _make_key(tmp_path)
t = create_target(db, label="server-01", host="10.0.0.1", port=22, user="alan", key_path=str(key))
assert t.label == "server-01"
assert t.host == "10.0.0.1"
assert t.port == 22
assert t.user == "alan"
targets = list_targets(db)
assert len(targets) == 1
assert targets[0].id == t.id
def test_create_resolves_tilde(self, tmp_path):
from app.services.ssh_targets import create_target
from unittest.mock import patch
db = _make_db(tmp_path)
key = _make_key(tmp_path)
with patch("pathlib.Path.expanduser", return_value=key):
t = create_target(db, label="x", host="h", port=22, user="u", key_path="~/id_ed25519")
assert "~" not in t.key_path
def test_get_returns_none_for_missing(self, tmp_path):
from app.services.ssh_targets import get_target
db = _make_db(tmp_path)
assert get_target(db, "nonexistent-id") is None
def test_update_partial(self, tmp_path):
from app.services.ssh_targets import create_target, update_target
db = _make_db(tmp_path)
key = _make_key(tmp_path)
t = create_target(db, label="old-label", host="10.0.0.1", port=22, user="alan", key_path=str(key))
updated = update_target(db, t.id, label="new-label")
assert updated is not None
assert updated.label == "new-label"
assert updated.host == "10.0.0.1" # unchanged
def test_update_missing_target_returns_none(self, tmp_path):
from app.services.ssh_targets import update_target
db = _make_db(tmp_path)
assert update_target(db, "no-such-id", label="x") is None
def test_delete_returns_true_on_success(self, tmp_path):
from app.services.ssh_targets import create_target, delete_target, list_targets
db = _make_db(tmp_path)
key = _make_key(tmp_path)
t = create_target(db, label="x", host="h", port=22, user="u", key_path=str(key))
assert delete_target(db, t.id) is True
assert list_targets(db) == []
def test_delete_returns_false_for_missing(self, tmp_path):
from app.services.ssh_targets import delete_target
db = _make_db(tmp_path)
assert delete_target(db, "no-such-id") is False
def test_list_sorted_by_label(self, tmp_path):
from app.services.ssh_targets import create_target, list_targets
db = _make_db(tmp_path)
key = _make_key(tmp_path)
create_target(db, label="zebra", host="h", port=22, user="u", key_path=str(key))
create_target(db, label="alpha", host="h", port=22, user="u", key_path=str(key))
labels = [t.label for t in list_targets(db)]
assert labels == ["alpha", "zebra"]
# ---------------------------------------------------------------------------
# Validation
# ---------------------------------------------------------------------------
class TestValidation:
def test_create_raises_on_missing_key_file(self, tmp_path):
from app.services.ssh_targets import create_target
db = _make_db(tmp_path)
with pytest.raises(ValueError, match="not found"):
create_target(db, label="x", host="h", port=22, user="u", key_path="/nonexistent/key")
def test_create_raises_on_directory_as_key(self, tmp_path):
from app.services.ssh_targets import create_target
db = _make_db(tmp_path)
with pytest.raises(ValueError, match="not a file"):
create_target(db, label="x", host="h", port=22, user="u", key_path=str(tmp_path))
def test_update_raises_on_bad_key_path(self, tmp_path):
from app.services.ssh_targets import create_target, update_target
db = _make_db(tmp_path)
key = _make_key(tmp_path)
t = create_target(db, label="x", host="h", port=22, user="u", key_path=str(key))
with pytest.raises(ValueError):
update_target(db, t.id, key_path="/does/not/exist")
# ---------------------------------------------------------------------------
# Key warning
# ---------------------------------------------------------------------------
class TestKeyWarning:
def test_no_warning_for_600(self, tmp_path):
from app.services.ssh_targets import key_path_warning
key = _make_key(tmp_path, mode=0o600)
assert key_path_warning(str(key)) is None
def test_warning_for_644(self, tmp_path):
from app.services.ssh_targets import key_path_warning
key = _make_key(tmp_path, mode=0o644)
warning = key_path_warning(str(key))
assert warning is not None
assert "chmod 600" in warning
def test_no_warning_for_nonexistent_file(self, tmp_path):
from app.services.ssh_targets import key_path_warning
# Should not raise — just return None
result = key_path_warning("/nonexistent/path")
assert result is None
# ---------------------------------------------------------------------------
# Serialization
# ---------------------------------------------------------------------------
class TestTargetToDict:
def test_basic_fields_present(self, tmp_path):
from app.services.ssh_targets import create_target, target_to_dict
db = _make_db(tmp_path)
key = _make_key(tmp_path)
t = create_target(db, label="server", host="10.0.0.1", port=2222, user="admin", key_path=str(key))
d = target_to_dict(t)
assert d["label"] == "server"
assert d["host"] == "10.0.0.1"
assert d["port"] == 2222
assert d["user"] == "admin"
assert "key_path" in d
assert "key_warning" not in d # not included by default
def test_key_contents_never_in_dict(self, tmp_path):
from app.services.ssh_targets import create_target, target_to_dict
db = _make_db(tmp_path)
key = _make_key(tmp_path)
t = create_target(db, label="x", host="h", port=22, user="u", key_path=str(key))
d = target_to_dict(t, include_warning=True)
for v in d.values():
if isinstance(v, str):
assert "BEGIN" not in v, "Key contents must never be included in serialized output"
def test_include_warning_adds_field(self, tmp_path):
from app.services.ssh_targets import create_target, target_to_dict
db = _make_db(tmp_path)
key = _make_key(tmp_path, mode=0o644)
t = create_target(db, label="x", host="h", port=22, user="u", key_path=str(key))
d = target_to_dict(t, include_warning=True)
assert "key_warning" in d
assert d["key_warning"] is not None
def test_last_ok_is_none_before_test(self, tmp_path):
from app.services.ssh_targets import create_target, target_to_dict
db = _make_db(tmp_path)
key = _make_key(tmp_path)
t = create_target(db, label="x", host="h", port=22, user="u", key_path=str(key))
d = target_to_dict(t)
assert d["last_ok"] is None
assert d["last_tested"] is None
# ---------------------------------------------------------------------------
# test_connection (paramiko not available path)
# ---------------------------------------------------------------------------
class TestConnectionNoParamiko:
def test_returns_error_when_paramiko_missing(self, tmp_path):
from app.services.ssh_targets import create_target, test_connection
import sys
db = _make_db(tmp_path)
key = _make_key(tmp_path)
t = create_target(db, label="x", host="127.0.0.1", port=22, user="u", key_path=str(key))
# Temporarily hide paramiko from the import system
original = sys.modules.get("paramiko")
sys.modules["paramiko"] = None # type: ignore[assignment]
try:
result = test_connection(db, t.id)
finally:
if original is None:
del sys.modules["paramiko"]
else:
sys.modules["paramiko"] = original
assert result["ok"] is False
assert "paramiko" in result["error"].lower()
def test_raises_key_error_for_missing_target(self, tmp_path):
from app.services.ssh_targets import test_connection
db = _make_db(tmp_path)
with pytest.raises(KeyError):
test_connection(db, "no-such-id")

224
tests/test_ticket_export.py Normal file
View file

@ -0,0 +1,224 @@
"""Tests for ticket_export service — Notion and Jira exporters."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
INCIDENT = {
"id": "inc-1",
"label": "Redis OOM — key eviction flood",
"issue_type": "memory",
"started_at": "2026-06-01T03:00:00Z",
"ended_at": "2026-06-01T03:45:00Z",
"notes": "Triggered by batch job at 03:00",
"severity": "high",
}
ENTRIES = [
{"entry_id": "e1", "source_id": "host:redis", "severity": "ERROR", "text": "maxmemory reached, evicting keys"},
{"entry_id": "e2", "source_id": "host:app", "severity": "WARN", "text": "Redis NOEVICTION response"},
]
def _mock_response(status_code: int, body: dict):
resp = MagicMock()
resp.is_success = (status_code < 400)
resp.status_code = status_code
resp.json.return_value = body
resp.text = str(body)
return resp
# ---------------------------------------------------------------------------
# available_targets
# ---------------------------------------------------------------------------
def test_available_targets_lists_known_integrations():
from app.services.ticket_export import available_targets
targets = available_targets()
assert "notion" in targets
assert "jira" in targets
# ---------------------------------------------------------------------------
# export_incident dispatch
# ---------------------------------------------------------------------------
def test_export_incident_raises_for_unknown_target():
from app.services.ticket_export import export_incident
with pytest.raises(ValueError, match="Unknown ticket target"):
export_incident("linear", INCIDENT, ENTRIES, {})
# ---------------------------------------------------------------------------
# Notion exporter
# ---------------------------------------------------------------------------
class TestNotionExport:
def test_successful_export_returns_url_and_id(self):
from app.services.ticket_export import export_incident
page_id = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
mock_resp = _mock_response(200, {"id": page_id, "url": f"https://notion.so/{page_id}"})
with patch("app.services.ticket_export.httpx.post", return_value=mock_resp) as mock_post:
result = export_incident("notion", INCIDENT, ENTRIES, {
"notion_token": "secret_abc123",
"notion_database_id": "db-id-xyz",
})
assert result["ticket_id"] == page_id
assert "notion.so" in result["url"]
mock_post.assert_called_once()
def test_raises_value_error_when_not_configured(self):
from app.services.ticket_export import export_incident
with pytest.raises(ValueError, match="Notion not configured"):
export_incident("notion", INCIDENT, ENTRIES, {
"notion_token": "",
"notion_database_id": "db-id",
})
def test_raises_value_error_when_database_id_missing(self):
from app.services.ticket_export import export_incident
with pytest.raises(ValueError, match="Notion not configured"):
export_incident("notion", INCIDENT, ENTRIES, {
"notion_token": "secret_abc",
"notion_database_id": "",
})
def test_raises_runtime_error_on_api_error(self):
from app.services.ticket_export import export_incident
mock_resp = _mock_response(401, {"message": "Unauthorized"})
with patch("app.services.ticket_export.httpx.post", return_value=mock_resp):
with pytest.raises(RuntimeError, match="Notion API error 401"):
export_incident("notion", INCIDENT, ENTRIES, {
"notion_token": "bad-token",
"notion_database_id": "db-id",
})
def test_sends_correct_database_id(self):
from app.services.ticket_export import export_incident
db_id = "my-database-uuid"
mock_resp = _mock_response(200, {"id": "page-id", "url": "https://notion.so/page-id"})
with patch("app.services.ticket_export.httpx.post", return_value=mock_resp) as mock_post:
export_incident("notion", INCIDENT, ENTRIES, {
"notion_token": "secret_abc123",
"notion_database_id": db_id,
})
call_kwargs = mock_post.call_args
payload = call_kwargs.kwargs.get("json") or call_kwargs.args[1] if len(call_kwargs.args) > 1 else call_kwargs.kwargs["json"]
assert payload["parent"]["database_id"] == db_id
def test_incident_label_becomes_page_title(self):
from app.services.ticket_export import export_incident
mock_resp = _mock_response(200, {"id": "pid", "url": "https://notion.so/pid"})
with patch("app.services.ticket_export.httpx.post", return_value=mock_resp) as mock_post:
export_incident("notion", INCIDENT, ENTRIES, {
"notion_token": "tok",
"notion_database_id": "dbid",
})
payload = mock_post.call_args.kwargs["json"]
title_text = payload["properties"]["title"]["title"][0]["text"]["content"]
assert INCIDENT["label"] in title_text
def test_url_falls_back_to_constructed_url(self):
from app.services.ticket_export import export_incident
page_id = "abc123"
mock_resp = _mock_response(200, {"id": page_id}) # no 'url' in response
with patch("app.services.ticket_export.httpx.post", return_value=mock_resp):
result = export_incident("notion", INCIDENT, ENTRIES, {
"notion_token": "tok",
"notion_database_id": "dbid",
})
assert "notion.so" in result["url"] or page_id in result["url"]
def test_long_text_truncated_to_notion_limit(self):
from app.services.ticket_export import export_incident
mock_resp = _mock_response(200, {"id": "pid", "url": "https://notion.so/pid"})
long_entries = [{"entry_id": f"e{i}", "source_id": "host:svc", "severity": "ERROR",
"text": "x" * 300} for i in range(60)]
with patch("app.services.ticket_export.httpx.post", return_value=mock_resp) as mock_post:
export_incident("notion", INCIDENT, long_entries, {
"notion_token": "tok",
"notion_database_id": "dbid",
})
payload = mock_post.call_args.kwargs["json"]
for block in payload.get("children", []):
for rt in block.get("bulleted_list_item", {}).get("rich_text", []):
assert len(rt["text"]["content"]) <= 2000
# ---------------------------------------------------------------------------
# Jira exporter
# ---------------------------------------------------------------------------
class TestJiraExport:
_config = {
"jira_url": "https://myorg.atlassian.net",
"jira_email": "ops@example.com",
"jira_api_token": "ATATT3xFfGF0abc123",
"jira_project_key": "OPS",
"jira_issue_type": "Bug",
}
def test_successful_export_returns_url_and_key(self):
from app.services.ticket_export import export_incident
mock_resp = _mock_response(201, {"id": "10042", "key": "OPS-42", "self": "https://myorg.atlassian.net/rest/api/3/issue/10042"})
with patch("app.services.ticket_export.httpx.post", return_value=mock_resp):
result = export_incident("jira", INCIDENT, ENTRIES, self._config)
assert result["ticket_id"] == "OPS-42"
assert "OPS-42" in result["url"]
assert "myorg.atlassian.net" in result["url"]
def test_raises_value_error_when_not_configured(self):
from app.services.ticket_export import export_incident
with pytest.raises(ValueError, match="Jira not configured"):
export_incident("jira", INCIDENT, ENTRIES, {
"jira_url": "",
"jira_email": "a@b.com",
"jira_api_token": "tok",
"jira_project_key": "OPS",
})
def test_raises_runtime_error_on_api_error(self):
from app.services.ticket_export import export_incident
mock_resp = _mock_response(403, {"errorMessages": ["Forbidden"]})
with patch("app.services.ticket_export.httpx.post", return_value=mock_resp):
with pytest.raises(RuntimeError, match="Jira API error 403"):
export_incident("jira", INCIDENT, ENTRIES, self._config)
def test_sends_basic_auth_header(self):
from app.services.ticket_export import export_incident
import base64
mock_resp = _mock_response(201, {"key": "OPS-1", "id": "1"})
with patch("app.services.ticket_export.httpx.post", return_value=mock_resp) as mock_post:
export_incident("jira", INCIDENT, ENTRIES, self._config)
call_kwargs = mock_post.call_args.kwargs
auth_header = call_kwargs["headers"]["Authorization"]
assert auth_header.startswith("Basic ")
decoded = base64.b64decode(auth_header[6:]).decode()
assert "ops@example.com" in decoded
def test_uses_correct_project_key(self):
from app.services.ticket_export import export_incident
mock_resp = _mock_response(201, {"key": "OPS-7", "id": "7"})
with patch("app.services.ticket_export.httpx.post", return_value=mock_resp) as mock_post:
export_incident("jira", INCIDENT, ENTRIES, self._config)
payload = mock_post.call_args.kwargs["json"]
assert payload["fields"]["project"]["key"] == "OPS"
def test_incident_label_becomes_summary(self):
from app.services.ticket_export import export_incident
mock_resp = _mock_response(201, {"key": "OPS-8", "id": "8"})
with patch("app.services.ticket_export.httpx.post", return_value=mock_resp) as mock_post:
export_incident("jira", INCIDENT, ENTRIES, self._config)
payload = mock_post.call_args.kwargs["json"]
assert payload["fields"]["summary"] == INCIDENT["label"]
def test_default_issue_type_is_bug(self):
from app.services.ticket_export import export_incident
config = {k: v for k, v in self._config.items() if k != "jira_issue_type"}
mock_resp = _mock_response(201, {"key": "OPS-9", "id": "9"})
with patch("app.services.ticket_export.httpx.post", return_value=mock_resp) as mock_post:
export_incident("jira", INCIDENT, ENTRIES, config)
payload = mock_post.call_args.kwargs["json"]
assert payload["fields"]["issuetype"]["name"] == "Bug"

View file

@ -137,6 +137,24 @@
</div>
</div>
<!-- Untracked name nudge -->
<div
v-if="untrackedNames.length && !activeTurn"
class="mb-3 p-3 rounded border border-yellow-700/40 bg-yellow-900/10"
>
<p class="text-xs text-yellow-400 mb-1">Not monitoring:
<span
v-for="name in untrackedNames"
:key="name"
class="font-mono ml-1 px-1.5 py-0.5 rounded bg-yellow-900/30 border border-yellow-700/30"
>{{ name }}</span>
</p>
<RouterLink
to="/sources"
class="text-xs text-accent hover:underline"
>Add as a log source </RouterLink>
</div>
<!-- Input row -->
<div class="border-t border-surface-border pt-3">
<div class="flex gap-2 items-end">
@ -215,6 +233,7 @@ interface Turn {
const turns = ref<Turn[]>([])
const draft = ref('')
const suggestedSources = ref<SuggestedSource[]>([])
const untrackedNames = ref<string[]>([])
const excludedSources = ref(new Set<string>())
const activeTurn = ref<Turn | null>(null)
const scrollEl = ref<HTMLElement | null>(null)
@ -237,6 +256,7 @@ function onInput() {
suggestTimer = setTimeout(fetchSuggestions, 400)
} else {
suggestedSources.value = []
untrackedNames.value = []
}
}
@ -250,6 +270,7 @@ async function fetchSuggestions() {
if (!res.ok) return
const data = await res.json()
suggestedSources.value = (data.suggested ?? []).slice(0, 6)
untrackedNames.value = data.untracked_names ?? []
// Reset exclusions when suggestions change
excludedSources.value = new Set()
} catch { /* non-critical */ }

View file

@ -86,6 +86,29 @@
{{ sending ? 'Sending…' : 'Send Bundle' }}
</button>
<span v-if="sendStatus" :class="sendStatus.ok ? 'text-green-500' : 'text-sev-error'" class="text-xs">{{ sendStatus.msg }}</span>
<!-- Export to ticket tracker -->
<div class="relative" ref="exportMenuRef">
<button
@click="exportMenuOpen = !exportMenuOpen"
:disabled="exporting"
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"
>{{ exporting ? 'Exporting…' : 'Export ticket ▾' }}</button>
<div
v-if="exportMenuOpen"
class="absolute right-0 top-full mt-1 w-32 bg-surface border border-surface-border rounded shadow-lg z-10"
>
<button
v-for="target in exportTargets"
:key="target.key"
@click="exportTicket(selected!.id, target.key)"
class="block w-full text-left px-3 py-2 text-xs text-text-primary hover:bg-surface-raised transition-colors"
>{{ target.label }}</button>
</div>
</div>
<span v-if="exportStatus" :class="exportStatus.ok ? 'text-green-400' : 'text-sev-error'" class="text-xs">
<a v-if="exportStatus.url" :href="exportStatus.url" target="_blank" rel="noopener" class="underline">{{ exportStatus.msg }}</a>
<span v-else>{{ exportStatus.msg }}</span>
</span>
<button @click="selected = null" class="text-text-dim hover:text-text-primary text-xs ml-auto sm:ml-0"> close</button>
</div>
</div>
@ -149,7 +172,7 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { RouterLink } from 'vue-router'
import IncidentTimeline from '@/components/IncidentTimeline.vue'
@ -238,6 +261,47 @@ async function sendBundle(id: string) {
}
}
// ticket export
const exportTargets = [
{ key: 'notion', label: 'Notion' },
{ key: 'jira', label: 'Jira' },
]
const exporting = ref(false)
const exportMenuOpen = ref(false)
const exportMenuRef = ref<HTMLElement | null>(null)
const exportStatus = ref<{ ok: boolean; msg: string; url?: string } | null>(null)
function handleExportClickOutside(e: MouseEvent) {
if (exportMenuRef.value && !exportMenuRef.value.contains(e.target as Node)) {
exportMenuOpen.value = false
}
}
onMounted(() => { document.addEventListener('click', handleExportClickOutside) })
onBeforeUnmount(() => { document.removeEventListener('click', handleExportClickOutside) })
async function exportTicket(incident_id: string, target: string) {
exportMenuOpen.value = false
exporting.value = true
exportStatus.value = null
try {
const res = await fetch(`${BASE}/api/incidents/${incident_id}/export`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target }),
})
const data = await res.json()
if (res.ok) {
exportStatus.value = { ok: true, msg: `Created ${data.ticket_id}`, url: data.url }
} else {
exportStatus.value = { ok: false, msg: data.detail ?? 'Export failed' }
}
} catch {
exportStatus.value = { ok: false, msg: 'Network error' }
} finally {
exporting.value = false
}
}
// timeline interaction
const highlightIdx = ref<number | null>(null)

View file

@ -282,6 +282,200 @@
</div>
</div>
<!-- Remote Hosts (SSH targets) -->
<div>
<h2 class="text-text-primary text-sm font-semibold mb-1">Remote Hosts</h2>
<p class="text-text-dim text-xs mb-3">
SSH hosts to pull logs from. Private keys are stored as path references only key contents are never read or transmitted.
</p>
<!-- Target list -->
<div v-if="sshTargets.length > 0" class="space-y-2 mb-3">
<div
v-for="t in sshTargets"
:key="t.id"
class="rounded border border-surface-border bg-surface p-3"
>
<div class="flex items-start gap-3">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span class="text-sm text-text-primary font-medium">{{ t.label }}</span>
<span class="font-mono text-xs text-text-dim">{{ t.user }}@{{ t.host }}:{{ t.port }}</span>
<!-- Connection status badge -->
<span
v-if="t.last_ok === true"
class="text-[10px] px-1.5 py-0.5 rounded bg-green-900/30 text-green-400 border border-green-800/40"
>Connected</span>
<span
v-else-if="t.last_ok === false"
class="text-[10px] px-1.5 py-0.5 rounded bg-red-900/30 text-sev-error border border-red-800/40"
:title="t.last_error ?? ''"
>Unreachable</span>
<span
v-else
class="text-[10px] px-1.5 py-0.5 rounded bg-surface-raised text-text-dim border border-surface-border"
>Not tested</span>
</div>
<p class="text-xs text-text-dim font-mono mt-0.5 truncate">{{ t.key_path }}</p>
<p v-if="t.key_warning" class="text-xs text-yellow-400 mt-0.5"> {{ t.key_warning }}</p>
<!-- Test result (persistent inline, not a toast) -->
<p
v-if="sshTestResults[t.id]"
class="text-xs mt-1"
:class="sshTestResults[t.id]!.ok ? 'text-green-400' : 'text-sev-error'"
>
{{ sshTestResults[t.id]!.ok ? 'Connection OK' : sshTestResults[t.id]!.error }}
</p>
</div>
<div class="flex items-center gap-2 shrink-0">
<button
@click="testSshTarget(t.id)"
:disabled="sshTesting.has(t.id)"
class="text-xs text-text-dim hover:text-accent transition-colors px-2 py-1 rounded hover:bg-surface disabled:opacity-40"
>{{ sshTesting.has(t.id) ? 'Testing…' : 'Test' }}</button>
<button
@click="editSshTarget(t)"
class="text-xs text-text-dim hover:text-accent transition-colors px-2 py-1 rounded hover:bg-surface"
>Edit</button>
<button
@click="deleteSshTarget(t.id, t.label)"
class="text-xs text-text-dim hover:text-sev-error transition-colors px-2 py-1 rounded hover:bg-surface"
>Delete</button>
</div>
</div>
</div>
</div>
<p v-else class="text-text-dim text-xs mb-3">
No remote hosts configured. Add an SSH host to pull logs from remote machines without manual file exports.
</p>
<!-- Add / Edit form -->
<div v-if="sshForm.open" class="rounded border border-surface-border bg-surface p-3 space-y-3 mb-3">
<h3 class="text-text-primary text-xs font-medium">{{ sshForm.editId ? 'Edit host' : 'Add remote host' }}</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label class="block text-xs text-text-dim mb-1">Display name</label>
<input v-model="sshForm.label" type="text" placeholder="e.g. rack-server-01"
class="w-full bg-surface-raised border border-surface-border rounded px-2 py-1.5 text-sm text-text-primary placeholder-text-dim focus:outline-none focus:border-accent" />
</div>
<div>
<label class="block text-xs text-text-dim mb-1">Host</label>
<input v-model="sshForm.host" type="text" placeholder="192.168.1.10 or server.example.com"
class="w-full bg-surface-raised border border-surface-border rounded px-2 py-1.5 text-sm text-text-primary placeholder-text-dim focus:outline-none focus:border-accent" />
</div>
<div>
<label class="block text-xs text-text-dim mb-1">Port</label>
<input v-model.number="sshForm.port" type="number" min="1" max="65535" placeholder="22"
class="w-full bg-surface-raised border border-surface-border rounded px-2 py-1.5 text-sm text-text-primary focus:outline-none focus:border-accent" />
</div>
<div>
<label class="block text-xs text-text-dim mb-1">Username</label>
<input v-model="sshForm.user" type="text" placeholder="root or alan"
class="w-full bg-surface-raised border border-surface-border rounded px-2 py-1.5 text-sm text-text-primary placeholder-text-dim focus:outline-none focus:border-accent" />
</div>
<div class="sm:col-span-2">
<label class="block text-xs text-text-dim mb-1">SSH key path</label>
<input v-model="sshForm.key_path" type="text" placeholder="~/.ssh/id_ed25519"
class="w-full bg-surface-raised border border-surface-border rounded px-2 py-1.5 text-sm font-mono text-text-primary placeholder-text-dim focus:outline-none focus:border-accent" />
</div>
</div>
<p v-if="sshFormError" class="text-sev-error text-xs">{{ sshFormError }}</p>
<div class="flex gap-2">
<button @click="saveSshTarget" :disabled="sshFormSaving"
class="px-3 py-1.5 bg-accent text-surface text-xs rounded font-medium hover:opacity-90 transition-opacity disabled:opacity-50">
{{ sshFormSaving ? 'Saving…' : (sshForm.editId ? 'Save changes' : 'Add host') }}
</button>
<button @click="closeSshForm" class="text-text-dim hover:text-text-primary text-xs">Cancel</button>
</div>
</div>
<button v-if="!sshForm.open" @click="sshForm.open = true" class="text-accent text-xs hover:underline">
+ Add remote host
</button>
</div>
<!-- Ticket Trackers -->
<div>
<h2 class="text-text-primary text-sm font-semibold mb-1">Ticket Trackers</h2>
<p class="text-text-dim text-xs mb-4">
Connect external issue trackers to export incidents with one click from the Incidents view.
</p>
<!-- Notion -->
<div class="mb-4">
<h3 class="text-text-primary text-xs font-medium mb-2">Notion</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-3">
<div class="sm:col-span-2">
<label class="block text-xs text-text-dim mb-1">Integration token</label>
<div class="relative">
<input v-model="prefs.notion_token" :type="showNotionToken ? 'text' : 'password'"
placeholder="secret_xxxxxxxxxxxx"
class="w-full bg-surface-raised border border-surface-border rounded px-2 py-1.5 text-sm font-mono text-text-primary placeholder-text-dim focus:outline-none focus:border-accent pr-14" />
<button @click="showNotionToken = !showNotionToken"
class="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-text-dim hover:text-accent">
{{ showNotionToken ? 'hide' : 'show' }}
</button>
</div>
</div>
<div class="sm:col-span-2">
<label class="block text-xs text-text-dim mb-1">Database ID</label>
<input v-model="prefs.notion_database_id" type="text"
placeholder="8-4-4-4-12 UUID from the database URL"
class="w-full bg-surface-raised border border-surface-border rounded px-2 py-1.5 text-sm font-mono text-text-primary placeholder-text-dim focus:outline-none focus:border-accent" />
</div>
</div>
</div>
<!-- Jira -->
<div class="mb-4">
<h3 class="text-text-primary text-xs font-medium mb-2">Jira</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-3">
<div class="sm:col-span-2">
<label class="block text-xs text-text-dim mb-1">Jira URL</label>
<input v-model="prefs.jira_url" type="url"
placeholder="https://yourorg.atlassian.net"
class="w-full bg-surface-raised border border-surface-border rounded px-2 py-1.5 text-sm font-mono text-text-primary placeholder-text-dim focus:outline-none focus:border-accent" />
</div>
<div>
<label class="block text-xs text-text-dim mb-1">Account email</label>
<input v-model="prefs.jira_email" type="email"
placeholder="you@example.com"
class="w-full bg-surface-raised border border-surface-border rounded px-2 py-1.5 text-sm text-text-primary placeholder-text-dim focus:outline-none focus:border-accent" />
</div>
<div>
<label class="block text-xs text-text-dim mb-1">API token</label>
<div class="relative">
<input v-model="prefs.jira_api_token" :type="showJiraToken ? 'text' : 'password'"
placeholder="Atlassian API token"
class="w-full bg-surface-raised border border-surface-border rounded px-2 py-1.5 text-sm font-mono text-text-primary placeholder-text-dim focus:outline-none focus:border-accent pr-14" />
<button @click="showJiraToken = !showJiraToken"
class="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-text-dim hover:text-accent">
{{ showJiraToken ? 'hide' : 'show' }}
</button>
</div>
</div>
<div>
<label class="block text-xs text-text-dim mb-1">Project key</label>
<input v-model="prefs.jira_project_key" type="text"
placeholder="OPS"
class="w-full bg-surface-raised border border-surface-border rounded px-2 py-1.5 text-sm font-mono text-text-primary placeholder-text-dim focus:outline-none focus:border-accent" />
</div>
<div>
<label class="block text-xs text-text-dim mb-1">Issue type</label>
<input v-model="prefs.jira_issue_type" type="text"
placeholder="Bug"
class="w-full bg-surface-raised border border-surface-border rounded px-2 py-1.5 text-sm text-text-primary placeholder-text-dim focus:outline-none focus:border-accent" />
</div>
</div>
</div>
<button @click="saveTicketTrackers"
class="px-4 py-2 bg-accent text-surface text-sm rounded font-medium hover:opacity-90 transition-opacity">
Save tracker settings
</button>
<span v-if="ticketSaveStatus" :class="ticketSaveStatus.ok ? 'text-green-400' : 'text-sev-error'" class="text-xs ml-3">{{ ticketSaveStatus.msg }}</span>
</div>
<p
v-if="saveStatus"
role="status"
@ -320,6 +514,26 @@ interface Prefs {
pihole_api_key: string
router_source_ids: string
device_names: string
notion_token: string
notion_database_id: string
jira_url: string
jira_email: string
jira_api_token: string
jira_project_key: string
jira_issue_type: string
}
interface SshTarget {
id: string
label: string
host: string
port: number
user: string
key_path: string
last_tested: string | null
last_ok: boolean | null
last_error: string | null
key_warning?: string | null
}
const techLevelOptions: { value: 'homelab' | 'sysadmin' | 'executive'; label: string; desc: string }[] = [
@ -356,13 +570,32 @@ async function setTechLevel(level: 'homelab' | 'sysadmin' | 'executive') {
}
}
const prefs = ref<Prefs>({ entry_point_style: 'topbar', llm_url: '', llm_model: '', llm_api_key: '', tech_level: 'sysadmin', severity_overrides: [], pihole_url: '', pihole_version: 'v6', pihole_api_key: '', router_source_ids: '', device_names: '' })
const prefs = ref<Prefs>({ entry_point_style: 'topbar', llm_url: '', llm_model: '', llm_api_key: '', tech_level: 'sysadmin', severity_overrides: [], pihole_url: '', pihole_version: 'v6', pihole_api_key: '', router_source_ids: '', device_names: '', notion_token: '', notion_database_id: '', jira_url: '', jira_email: '', jira_api_token: '', jira_project_key: '', jira_issue_type: 'Bug' })
const saveStatus = ref<{ ok: boolean; msg: string } | null>(null)
const showAddOverride = ref(false)
const showApiKey = ref(false)
const showPiholeKey = ref(false)
const showNotionToken = ref(false)
const showJiraToken = ref(false)
const piholeStatus = ref<{ ok: boolean; msg: string } | null>(null)
const ticketSaveStatus = ref<{ ok: boolean; msg: string } | null>(null)
const newRule = ref<SeverityOverride>({ name: '', pattern: '', override_severity: 'WARN', enabled: true })
// SSH targets
const sshTargets = ref<SshTarget[]>([])
const sshTestResults = ref<Record<string, { ok: boolean; error: string | null }>>({})
const sshTesting = ref<Set<string>>(new Set())
const sshFormSaving = ref(false)
const sshFormError = ref<string | null>(null)
const sshForm = ref({
open: false,
editId: null as string | null,
label: '',
host: '',
port: 22,
user: '',
key_path: '',
})
const entryPointBtnRefs = ref<HTMLButtonElement[]>([])
const entryPointOptions = [
@ -391,6 +624,7 @@ onMounted(async () => {
const res = await fetch(`${BASE}/api/settings`)
if (res.ok) prefs.value = await res.json()
} catch { /* non-critical — defaults stay */ }
await loadSshTargets()
})
async function patch(body: Partial<Prefs>) {
@ -490,4 +724,99 @@ async function testPihole() {
piholeStatus.value = { ok: false, msg: 'Network error' }
}
}
// --- Ticket tracker settings ---
async function saveTicketTrackers() {
ticketSaveStatus.value = null
try {
await patch({
notion_token: prefs.value.notion_token,
notion_database_id: prefs.value.notion_database_id,
jira_url: prefs.value.jira_url,
jira_email: prefs.value.jira_email,
jira_api_token: prefs.value.jira_api_token,
jira_project_key: prefs.value.jira_project_key,
jira_issue_type: prefs.value.jira_issue_type,
})
ticketSaveStatus.value = { ok: true, msg: 'Tracker settings saved' }
setTimeout(() => { ticketSaveStatus.value = null }, 2000)
} catch {
ticketSaveStatus.value = { ok: false, msg: 'Save failed — check server connection' }
}
}
// --- SSH target management ---
async function loadSshTargets() {
try {
const res = await fetch(`${BASE}/api/ssh-targets`)
if (res.ok) sshTargets.value = await res.json()
} catch { /* non-critical */ }
}
async function testSshTarget(id: string) {
sshTesting.value = new Set([...sshTesting.value, id])
try {
const res = await fetch(`${BASE}/api/ssh-targets/${id}/test`, { method: 'POST' })
const data = await res.json()
sshTestResults.value = { ...sshTestResults.value, [id]: { ok: data.ok, error: data.error ?? null } }
// Refresh list so last_ok badge updates
await loadSshTargets()
} catch {
sshTestResults.value = { ...sshTestResults.value, [id]: { ok: false, error: 'Network error' } }
} finally {
const next = new Set(sshTesting.value)
next.delete(id)
sshTesting.value = next
}
}
function editSshTarget(t: SshTarget) {
sshFormError.value = null
sshForm.value = { open: true, editId: t.id, label: t.label, host: t.host, port: t.port, user: t.user, key_path: t.key_path }
}
async function deleteSshTarget(id: string, label: string) {
if (!confirm(`Delete remote host "${label}"?`)) return
try {
await fetch(`${BASE}/api/ssh-targets/${id}`, { method: 'DELETE' })
await loadSshTargets()
} catch { /* ignore */ }
}
async function saveSshTarget() {
const f = sshForm.value
if (!f.label.trim() || !f.host.trim() || !f.user.trim() || !f.key_path.trim()) {
sshFormError.value = 'All fields are required'
return
}
sshFormSaving.value = true
sshFormError.value = null
try {
const url = f.editId ? `${BASE}/api/ssh-targets/${f.editId}` : `${BASE}/api/ssh-targets`
const method = f.editId ? 'PATCH' : 'POST'
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ label: f.label, host: f.host, port: f.port, user: f.user, key_path: f.key_path }),
})
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: 'Save failed' }))
sshFormError.value = err.detail ?? 'Save failed'
return
}
closeSshForm()
await loadSshTargets()
} catch {
sshFormError.value = 'Network error'
} finally {
sshFormSaving.value = false
}
}
function closeSshForm() {
sshForm.value = { open: false, editId: null, label: '', host: '', port: 22, user: '', key_path: '' }
sshFormError.value = null
}
</script>

View file

@ -6,6 +6,12 @@
<p class="text-text-dim text-sm">All hosts and services in the gleaned corpus.</p>
</div>
<div class="flex items-center gap-2 shrink-0">
<button
@click="toggleScanPanel"
class="btn-secondary text-sm"
>
Scan
</button>
<button
@click="showAddPanel = !showAddPanel"
class="btn-secondary text-sm"
@ -27,6 +33,73 @@
/>
</div>
<!-- Filesystem scan panel -->
<div v-if="showScanPanel && !showWizard" class="mb-6 rounded border border-surface-border bg-surface-raised p-4">
<h2 class="text-text-primary font-medium text-sm mb-3">Scan for log files</h2>
<div class="flex gap-2 mb-4">
<input
v-model="scanQuery"
type="text"
placeholder="Optional: describe the problem (e.g. 'nginx 502 gateway error')"
class="input-field flex-1 text-sm"
@keydown.enter="runScan"
/>
<button @click="runScan" :disabled="scanning" class="btn-primary text-sm px-4">
{{ scanning ? 'Scanning…' : 'Scan' }}
</button>
</div>
<div v-if="scanError" class="text-sev-error text-sm mb-3">{{ scanError }}</div>
<div v-if="scanCandidates.length > 0">
<p class="text-text-dim text-xs mb-2">
{{ scanCandidates.length }} file{{ scanCandidates.length === 1 ? '' : 's' }} found ranked by recency{{ scanQuery ? ' and keyword match' : '' }}.
Select files to add as sources.
</p>
<div class="divide-y divide-surface-border border border-surface-border rounded overflow-hidden mb-3">
<label
v-for="c in scanCandidates"
:key="c.path"
class="flex items-start gap-3 px-3 py-2 hover:bg-surface cursor-pointer"
>
<input
type="checkbox"
:value="c"
v-model="scanSelected"
class="mt-0.5 shrink-0"
/>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 flex-wrap">
<span class="font-mono text-xs text-accent truncate">{{ c.path }}</span>
<span class="text-text-dim text-xs shrink-0">{{ formatBytes(c.size_bytes) }}</span>
<span class="text-text-dim text-xs shrink-0">{{ formatAge(c.mtime) }}</span>
<span
v-if="scanQuery"
class="text-text-dim text-xs shrink-0"
:title="`Relevance score: ${c.score}`"
>score {{ (c.score * 100).toFixed(0) }}%</span>
</div>
</div>
</label>
</div>
<div class="flex items-center gap-3">
<button
:disabled="scanSelected.length === 0 || scanAdding"
@click="addScanSelected"
class="btn-primary text-sm"
>
{{ scanAdding ? 'Adding…' : `Add ${scanSelected.length || ''} selected` }}
</button>
<button @click="scanSelected = []" class="btn-secondary text-sm">Deselect all</button>
<button @click="scanSelected = [...scanCandidates]" class="btn-secondary text-sm">Select all</button>
</div>
</div>
<div v-else-if="scanRan && !scanning" class="text-text-dim text-sm">
No log files found in the scanned directories.
</div>
</div>
<!-- Post-setup Add Source panel (condensed wizard steps 1-2) -->
<div v-else-if="showAddPanel" class="mb-6">
<SetupWizard
@ -184,6 +257,17 @@ interface DbSource {
latest: string | null
}
interface ScanCandidate {
type: string
id: string
path: string
label: string
size_bytes: number
mtime: number
score: number
available: boolean
}
const sources = ref<SourceRow[]>([])
const loading = ref(true)
const busy = ref(new Set<string>())
@ -191,6 +275,14 @@ const actionMsg = ref('')
const actionError = ref(false)
const showWizard = ref(false)
const showAddPanel = ref(false)
const showScanPanel = ref(false)
const scanQuery = ref('')
const scanning = ref(false)
const scanRan = ref(false)
const scanError = ref('')
const scanCandidates = ref<ScanCandidate[]>([])
const scanSelected = ref<ScanCandidate[]>([])
const scanAdding = ref(false)
const BASE = import.meta.env.BASE_URL.replace(/\/$/, '')
@ -347,6 +439,82 @@ async function handleUpload(e: Event): Promise<void> {
;(e.target as HTMLInputElement).value = ''
}
function toggleScanPanel(): void {
showScanPanel.value = !showScanPanel.value
if (!showScanPanel.value) {
scanCandidates.value = []
scanSelected.value = []
scanRan.value = false
scanError.value = ''
}
}
async function runScan(): Promise<void> {
scanning.value = true
scanError.value = ''
scanCandidates.value = []
scanSelected.value = []
try {
const params = new URLSearchParams({ max_results: '30' })
if (scanQuery.value.trim()) params.set('query', scanQuery.value.trim())
const res = await fetch(`${BASE}/api/setup/scan?${params}`)
if (!res.ok) {
const data = await res.json().catch(() => ({}))
scanError.value = data.detail ?? 'Scan failed'
return
}
const data = await res.json()
scanCandidates.value = data.candidates ?? []
scanRan.value = true
} catch (err) {
scanError.value = String(err)
} finally {
scanning.value = false
}
}
async function addScanSelected(): Promise<void> {
if (scanSelected.value.length === 0) return
scanAdding.value = true
actionMsg.value = ''
try {
const res = await fetch(`${BASE}/api/setup/write`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sources: scanSelected.value }),
})
const data = await res.json()
if (res.ok) {
actionMsg.value = `Added ${scanSelected.value.length} source${scanSelected.value.length === 1 ? '' : 's'} to sources.yaml`
actionError.value = false
showScanPanel.value = false
scanCandidates.value = []
scanSelected.value = []
scanRan.value = false
await loadSources()
} else {
actionMsg.value = data.detail ?? 'Failed to add sources'
actionError.value = true
}
} finally {
scanAdding.value = false
}
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
function formatAge(mtime: number): string {
const ageDays = (Date.now() / 1000 - mtime) / 86400
if (ageDays < 1) return 'today'
if (ageDays < 2) return 'yesterday'
if (ageDays < 30) return `${Math.floor(ageDays)}d ago`
return `${Math.floor(ageDays / 30)}mo ago`
}
function formatTs(iso: string | null): string {
if (!iso) return '—'
try {