Compare commits
12 commits
feat/42-50
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| db359d35b2 | |||
| 04013757e7 | |||
| 5da8db2bcd | |||
| 4c1940d12e | |||
| 6039ab2464 | |||
| b8f766fb74 | |||
| 7a2ab0bb46 | |||
| 600e5a9eac | |||
| 7ed01fbd48 | |||
| 58680b3b27 | |||
| be134a4465 | |||
| 8006d79a11 |
25 changed files with 2686 additions and 36 deletions
16
.env.example
16
.env.example
|
|
@ -86,3 +86,19 @@
|
||||||
# When set, all /api/ requests require: Authorization: Bearer <token>
|
# When set, all /api/ requests require: Authorization: Bearer <token>
|
||||||
# Generate a token: python -c "import secrets; print(secrets.token_urlsafe(32))"
|
# Generate a token: python -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||||
# TURNSTONE_API_KEY=your-secret-token-here
|
# 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
|
||||||
|
|
|
||||||
|
|
@ -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_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_domain ON blocklist_candidates(domain_or_ip);
|
||||||
CREATE INDEX IF NOT EXISTS idx_blocklist_tenant ON blocklist_candidates(tenant_id);
|
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 = """
|
_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_label TEXT",
|
||||||
"ALTER TABLE log_entries ADD COLUMN ml_scored_at TEXT",
|
"ALTER TABLE log_entries ADD COLUMN ml_scored_at TEXT",
|
||||||
"ALTER TABLE detections ADD COLUMN scorer TEXT NOT NULL DEFAULT 'anomaly'",
|
"ALTER TABLE detections ADD COLUMN scorer TEXT NOT NULL DEFAULT 'anomaly'",
|
||||||
|
"ALTER TABLE log_entries ADD COLUMN anonymized INTEGER DEFAULT NULL",
|
||||||
]
|
]
|
||||||
|
|
||||||
_CONTEXT_MIGRATIONS_SQLITE = [
|
_CONTEXT_MIGRATIONS_SQLITE = [
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,8 @@ CREATE TABLE IF NOT EXISTS log_entries (
|
||||||
repeat_count INTEGER DEFAULT 1,
|
repeat_count INTEGER DEFAULT 1,
|
||||||
out_of_order INTEGER DEFAULT 0,
|
out_of_order INTEGER DEFAULT 0,
|
||||||
matched_patterns TEXT DEFAULT '[]',
|
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_source ON log_entries(source_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_timestamp ON log_entries(timestamp_iso);
|
CREATE INDEX IF NOT EXISTS idx_timestamp ON log_entries(timestamp_iso);
|
||||||
|
|
|
||||||
290
app/rest.py
290
app/rest.py
|
|
@ -30,7 +30,7 @@ from typing import Annotated
|
||||||
|
|
||||||
import yaml
|
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.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import FileResponse, RedirectResponse, StreamingResponse
|
from fastapi.responses import FileResponse, RedirectResponse, StreamingResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
@ -52,8 +52,10 @@ from app.services.blocklist import (
|
||||||
update_candidate_status,
|
update_candidate_status,
|
||||||
)
|
)
|
||||||
from app.services.pihole import PiholeClient
|
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.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 (
|
from app.services.incidents import (
|
||||||
build_bundle,
|
build_bundle,
|
||||||
create_incident,
|
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>.
|
# When set, all /api/ routes require Authorization: Bearer <key>.
|
||||||
# Unset (default) means no authentication — suitable for local-only deployments.
|
# Unset (default) means no authentication — suitable for local-only deployments.
|
||||||
_API_KEY: str | None = os.environ.get("TURNSTONE_API_KEY") or None
|
_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.
|
# GPU inference server URL.
|
||||||
# Priority: GPU_SERVER_URL → CF_ORCH_URL (backward compat) → orch.circuitforge.tech (Paid+).
|
# 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
|
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(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
|
|
@ -302,6 +307,13 @@ class SettingsBody(BaseModel):
|
||||||
pihole_api_key: str | None = None
|
pihole_api_key: str | None = None
|
||||||
router_source_ids: str | None = None
|
router_source_ids: str | None = None
|
||||||
device_names: 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):
|
class IncidentCreate(BaseModel):
|
||||||
|
|
@ -543,24 +555,35 @@ _SUGGEST_STOPWORDS = frozenset({
|
||||||
|
|
||||||
@router.post("/api/sources/suggest")
|
@router.post("/api/sources/suggest")
|
||||||
def suggest_sources(body: SourceSuggestRequest) -> dict:
|
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)
|
all_sources = _list_sources(DB_PATH)
|
||||||
query_tokens = {
|
query_tokens = {
|
||||||
t.lower()
|
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
|
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:
|
for src in all_sources:
|
||||||
src_id: str = src["source_id"]
|
src_id: str = src["source_id"]
|
||||||
# Tokenise source ID: split on colon, dash, underscore, digits
|
|
||||||
parts = {
|
parts = {
|
||||||
p.lower()
|
p.lower()
|
||||||
for seg in re.split(r"[:\-_\d]+", src_id)
|
for seg in re.split(r"[:\-_\d]+", src_id)
|
||||||
for p in [seg.strip()]
|
for p in [seg.strip()]
|
||||||
if len(p) > 2
|
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
|
matched = query_tokens & parts
|
||||||
if matched:
|
if matched:
|
||||||
score = round(len(matched) / max(len(parts), 1), 3)
|
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)
|
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 {
|
return {
|
||||||
"suggested": suggestions,
|
"suggested": suggestions,
|
||||||
|
"untracked_names": untracked,
|
||||||
"all_source_ids": [s["source_id"] for s in all_sources],
|
"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
|
prefs["router_source_ids"] = body.router_source_ids
|
||||||
if body.device_names is not None:
|
if body.device_names is not None:
|
||||||
prefs["device_names"] = body.device_names
|
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)
|
_save_prefs(prefs)
|
||||||
return prefs
|
return prefs
|
||||||
|
|
||||||
|
|
@ -797,6 +839,89 @@ class BatchGleanRequest(BaseModel):
|
||||||
entries: list[BatchEntry]
|
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 ──────────────────────────────────────────────
|
# ── Setup / Onboarding wizard ──────────────────────────────────────────────
|
||||||
|
|
||||||
class SetupWriteBody(BaseModel):
|
class SetupWriteBody(BaseModel):
|
||||||
|
|
@ -820,6 +945,28 @@ def setup_discover() -> dict:
|
||||||
return discover_all()
|
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")
|
@router.post("/api/setup/write")
|
||||||
def setup_write(body: SetupWriteBody, background_tasks: BackgroundTasks) -> dict:
|
def setup_write(body: SetupWriteBody, background_tasks: BackgroundTasks) -> dict:
|
||||||
"""Validate and write sources.yaml from a list of selected source definitions.
|
"""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")
|
@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).
|
"""Accept pre-parsed log entries from a remote Turnstone instance (submission protocol).
|
||||||
|
|
||||||
Used by nodes with TURNSTONE_SUBMIT_ENDPOINT configured to push their
|
Used by nodes with TURNSTONE_SUBMIT_ENDPOINT configured to push their
|
||||||
pattern-matched entries to a central receiving instance.
|
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:
|
if not payload.entries:
|
||||||
return {"gleaned": 0}
|
return {"gleaned": 0}
|
||||||
conn = sqlite3.connect(str(DB_PATH), timeout=30.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}
|
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")
|
@router.get("/api/tasks/glean/status")
|
||||||
def glean_task_status() -> dict:
|
def glean_task_status() -> dict:
|
||||||
"""Return the current state of the periodic glean scheduler."""
|
"""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
|
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")
|
@router.post("/api/bundles")
|
||||||
def receive_bundle(bundle: dict) -> dict:
|
def receive_bundle(bundle: dict) -> dict:
|
||||||
record = store_bundle(INCIDENTS_DB_PATH, bundle)
|
record = store_bundle(INCIDENTS_DB_PATH, bundle)
|
||||||
|
|
|
||||||
|
|
@ -23,16 +23,30 @@ _JSON_FENCE_RE = re.compile(
|
||||||
re.MULTILINE,
|
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:
|
def extract_content(resp_json: dict) -> str | None:
|
||||||
"""Pull text content from an OpenAI-compat chat completion response.
|
"""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.
|
Returns None when the response has no choices or empty content.
|
||||||
"""
|
"""
|
||||||
choices = resp_json.get("choices") or []
|
choices = resp_json.get("choices") or []
|
||||||
if not choices:
|
if not choices:
|
||||||
return None
|
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:
|
def strip_json_fences(raw: str) -> str:
|
||||||
|
|
|
||||||
|
|
@ -64,13 +64,43 @@ def _build_hypothesis_block(ranked: list[RankedHypothesis]) -> str:
|
||||||
h = rh.hypothesis
|
h = rh.hypothesis
|
||||||
conf_pct = int(h.confidence * 100)
|
conf_pct = int(h.confidence * 100)
|
||||||
novelty = f"{rh.novelty_score:.2f}"
|
novelty = f"{rh.novelty_score:.2f}"
|
||||||
|
desc = f"\n {h.description}" if h.description else ""
|
||||||
lines.append(
|
lines.append(
|
||||||
f"- [{h.severity}, {conf_pct}%] {h.title}\n"
|
f"- [{h.severity}, {conf_pct}% conf, novelty {novelty}] {h.title}{desc}"
|
||||||
f" Novelty: {novelty}"
|
|
||||||
)
|
)
|
||||||
return "\n".join(lines)
|
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:
|
def _build_context_block(ctx: RetrievedContext) -> str:
|
||||||
"""Build the runbook context block for the prompt."""
|
"""Build the runbook context block for the prompt."""
|
||||||
parts: list[str] = []
|
parts: list[str] = []
|
||||||
|
|
@ -144,17 +174,18 @@ class SummarySynthesizer:
|
||||||
|
|
||||||
system_prompt = _SYSTEM_PROMPTS.get(tech_level, _SYSTEM_PROMPTS["sysadmin"])
|
system_prompt = _SYSTEM_PROMPTS.get(tech_level, _SYSTEM_PROMPTS["sysadmin"])
|
||||||
hypothesis_block = _build_hypothesis_block(ranked)
|
hypothesis_block = _build_hypothesis_block(ranked)
|
||||||
|
timeline_block = _build_timeline_block(timeline)
|
||||||
context_block = _build_context_block(ctx)
|
context_block = _build_context_block(ctx)
|
||||||
dominant = ", ".join(timeline.dominant_sources[:5]) or "none"
|
dominant = ", ".join(timeline.dominant_sources[:5]) or "none"
|
||||||
|
|
||||||
user_message = (
|
user_message = (
|
||||||
f"Query: {query}\n\n"
|
f"Query: {query}\n\n"
|
||||||
f"Timeline summary:\n"
|
f"Timeline ({len(timeline.clusters)} clusters, "
|
||||||
f"- {len(timeline.clusters)} clusters, "
|
|
||||||
f"{timeline.burst_count} bursts, "
|
f"{timeline.burst_count} bursts, "
|
||||||
f"{timeline.gap_count} silence gaps\n"
|
f"{timeline.gap_count} silence gaps; "
|
||||||
f"- Primary sources: {dominant}\n\n"
|
f"primary sources: {dominant}):\n"
|
||||||
f"Top hypotheses:\n{hypothesis_block}\n\n"
|
f"{timeline_block}\n\n"
|
||||||
|
f"Root-cause hypotheses:\n{hypothesis_block}\n\n"
|
||||||
f"Context from runbooks:\n{context_block}"
|
f"Context from runbooks:\n{context_block}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,10 @@ from __future__ import annotations
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
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"):
|
if src_type == "docker" and not src.get("container"):
|
||||||
return f"Docker source '{src['id']}' is missing 'container'"
|
return f"Docker source '{src['id']}' is missing 'container'"
|
||||||
return None
|
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
327
app/services/orchard.py
Normal 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)}
|
||||||
|
|
@ -244,19 +244,19 @@ def _sqlite_fts_search(
|
||||||
params: list = [fts_query, tid]
|
params: list = [fts_query, tid]
|
||||||
|
|
||||||
if severity:
|
if severity:
|
||||||
conditions.append("severity = ?")
|
conditions.append("f.severity = ?")
|
||||||
params.append(severity.upper())
|
params.append(severity.upper())
|
||||||
if source_filter:
|
if source_filter:
|
||||||
conditions.append("source_id LIKE ?")
|
conditions.append("f.source_id LIKE ?")
|
||||||
params.append(f"%{source_filter}%")
|
params.append(f"%{source_filter}%")
|
||||||
if pattern_filter:
|
if pattern_filter:
|
||||||
conditions.append("matched_patterns LIKE ?")
|
conditions.append("f.matched_patterns LIKE ?")
|
||||||
params.append(f'%"{pattern_filter}"%')
|
params.append(f'%"{pattern_filter}"%')
|
||||||
if since:
|
if since:
|
||||||
conditions.append("timestamp_iso >= ?")
|
conditions.append("f.timestamp_iso >= ?")
|
||||||
params.append(since)
|
params.append(since)
|
||||||
if until:
|
if until:
|
||||||
conditions.append("timestamp_iso <= ?")
|
conditions.append("f.timestamp_iso <= ?")
|
||||||
params.append(until)
|
params.append(until)
|
||||||
if not include_repeats:
|
if not include_repeats:
|
||||||
conditions.append("f.repeat_count = 1")
|
conditions.append("f.repeat_count = 1")
|
||||||
|
|
|
||||||
265
app/services/ssh_targets.py
Normal file
265
app/services/ssh_targets.py
Normal 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
|
||||||
213
app/services/ticket_export.py
Normal file
213
app/services/ticket_export.py
Normal 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}")
|
||||||
|
|
@ -82,7 +82,7 @@ TZ="${TZ:-America/Los_Angeles}"
|
||||||
# receiving instance after each glean run. Only matched entries are sent —
|
# receiving instance after each glean run. Only matched entries are sent —
|
||||||
# no raw log content. Used to build Avocet training data.
|
# 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
|
# bash ~/turnstone/docker-standalone.sh
|
||||||
#
|
#
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -211,10 +211,10 @@ patterns:
|
||||||
domain: media
|
domain: media
|
||||||
description: Plex EasyAudioEncoder (EAC3 Dolby audio transcoder) crashed — service restart required
|
description: Plex EasyAudioEncoder (EAC3 Dolby audio transcoder) crashed — service restart required
|
||||||
|
|
||||||
# - name: ext_device_device_error
|
# - name: ext_device_error
|
||||||
# pattern: "ERR-\d{4}"
|
# pattern: "ERR-\d{4}"
|
||||||
# severity: ERROR
|
# severity: ERROR
|
||||||
# description: EXT_DEVICE device error code
|
# description: vendor device structured error code
|
||||||
|
|
||||||
# ── VPN / tunnel patterns ──────────────────────────────────────────────────
|
# ── VPN / tunnel patterns ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,7 @@ TZ="${TZ:-America/Los_Angeles}"
|
||||||
# receiving instance after each glean run. Only matched entries are sent —
|
# receiving instance after each glean run. Only matched entries are sent —
|
||||||
# no raw log content. Used to build Avocet training data.
|
# 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
|
# bash /opt/turnstone/podman-standalone.sh
|
||||||
#
|
#
|
||||||
# TURNSTONE_SOURCE_HOST is auto-detected from `hostname` — override if needed.
|
# 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
|
# export TURNSTONE_MULTI_AGENT_DIAGNOSE=true
|
||||||
# sudo bash /opt/turnstone/podman-standalone.sh
|
# 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):
|
# use docker-standalone.sh (not this script — Docker host):
|
||||||
# export GPU_SERVER_URL=http://<YOUR_HOST_IP>:7700
|
# export GPU_SERVER_URL=http://<YOUR_HOST_IP>:7700
|
||||||
# export TURNSTONE_MULTI_AGENT_DIAGNOSE=true
|
# export TURNSTONE_MULTI_AGENT_DIAGNOSE=true
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ Output tree:
|
||||||
<out>/journald/system.jsonl — systemd/kernel journald JSON
|
<out>/journald/system.jsonl — systemd/kernel journald JSON
|
||||||
<out>/docker/services.jsonl — containerised app stdout
|
<out>/docker/services.jsonl — containerised app stdout
|
||||||
<out>/qbittorrent/qbt.log — hotio-format qBittorrent log
|
<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
|
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:
|
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
|
lines = 0
|
||||||
with path.open("w") as fh:
|
with path.open("w") as fh:
|
||||||
for dt in _ts_seq(start, end, rng):
|
for dt in _ts_seq(start, end, rng):
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,8 @@ from __future__ import annotations
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from app.context.retriever import RetrievedContext
|
from app.context.retriever import RetrievedContext
|
||||||
from app.services.diagnose.models import Hypothesis, RankedHypothesis, TimelineResult
|
from app.services.diagnose.models import EventCluster, Hypothesis, RankedHypothesis, TimelineResult
|
||||||
from app.services.diagnose.synthesizer import SummarySynthesizer
|
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(
|
def _make_timeline(
|
||||||
total_entries: int = 42,
|
total_entries: int = 42,
|
||||||
n_clusters: int = 3,
|
n_clusters: int = 3,
|
||||||
|
clusters: tuple[EventCluster, ...] | None = None,
|
||||||
) -> TimelineResult:
|
) -> TimelineResult:
|
||||||
return TimelineResult(
|
return TimelineResult(
|
||||||
clusters=tuple(),
|
clusters=clusters if clusters is not None else tuple(),
|
||||||
total_entries=total_entries,
|
total_entries=total_entries,
|
||||||
window_start="2026-01-01T00:00:00+00:00",
|
window_start="2026-01-01T00:00:00+00:00",
|
||||||
window_end="2026-01-01T01:00:00+00:00",
|
window_end="2026-01-01T01:00:00+00:00",
|
||||||
|
|
@ -283,3 +309,88 @@ class TestSynthesizerEmptyRanked:
|
||||||
|
|
||||||
assert isinstance(result, str)
|
assert isinstance(result, str)
|
||||||
assert len(result) > 0
|
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
133
tests/test_discover_scan.py
Normal 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)]
|
||||||
|
|
@ -129,7 +129,7 @@ class TestQbittorrentFormat:
|
||||||
assert severities <= {"INFO", "WARN", "CRITICAL"}
|
assert severities <= {"INFO", "WARN", "CRITICAL"}
|
||||||
|
|
||||||
|
|
||||||
# ── EXT_DEVICE format ────────────────────────────────────────────────────────────────
|
# ── Vendor device format ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class TestAvcxFormat:
|
class TestAvcxFormat:
|
||||||
def test_iso_timestamp_prefix(self, tmp_path: Path) -> None:
|
def test_iso_timestamp_prefix(self, tmp_path: Path) -> None:
|
||||||
|
|
|
||||||
87
tests/test_llm_client.py
Normal file
87
tests/test_llm_client.py
Normal 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
245
tests/test_ssh_targets.py
Normal 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
224
tests/test_ticket_export.py
Normal 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"
|
||||||
|
|
@ -137,6 +137,24 @@
|
||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Input row -->
|
||||||
<div class="border-t border-surface-border pt-3">
|
<div class="border-t border-surface-border pt-3">
|
||||||
<div class="flex gap-2 items-end">
|
<div class="flex gap-2 items-end">
|
||||||
|
|
@ -215,6 +233,7 @@ interface Turn {
|
||||||
const turns = ref<Turn[]>([])
|
const turns = ref<Turn[]>([])
|
||||||
const draft = ref('')
|
const draft = ref('')
|
||||||
const suggestedSources = ref<SuggestedSource[]>([])
|
const suggestedSources = ref<SuggestedSource[]>([])
|
||||||
|
const untrackedNames = ref<string[]>([])
|
||||||
const excludedSources = ref(new Set<string>())
|
const excludedSources = ref(new Set<string>())
|
||||||
const activeTurn = ref<Turn | null>(null)
|
const activeTurn = ref<Turn | null>(null)
|
||||||
const scrollEl = ref<HTMLElement | null>(null)
|
const scrollEl = ref<HTMLElement | null>(null)
|
||||||
|
|
@ -237,6 +256,7 @@ function onInput() {
|
||||||
suggestTimer = setTimeout(fetchSuggestions, 400)
|
suggestTimer = setTimeout(fetchSuggestions, 400)
|
||||||
} else {
|
} else {
|
||||||
suggestedSources.value = []
|
suggestedSources.value = []
|
||||||
|
untrackedNames.value = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -250,6 +270,7 @@ async function fetchSuggestions() {
|
||||||
if (!res.ok) return
|
if (!res.ok) return
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
suggestedSources.value = (data.suggested ?? []).slice(0, 6)
|
suggestedSources.value = (data.suggested ?? []).slice(0, 6)
|
||||||
|
untrackedNames.value = data.untracked_names ?? []
|
||||||
// Reset exclusions when suggestions change
|
// Reset exclusions when suggestions change
|
||||||
excludedSources.value = new Set()
|
excludedSources.value = new Set()
|
||||||
} catch { /* non-critical */ }
|
} catch { /* non-critical */ }
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,29 @@
|
||||||
{{ sending ? 'Sending…' : 'Send Bundle' }}
|
{{ sending ? 'Sending…' : 'Send Bundle' }}
|
||||||
</button>
|
</button>
|
||||||
<span v-if="sendStatus" :class="sendStatus.ok ? 'text-green-500' : 'text-sev-error'" class="text-xs">{{ sendStatus.msg }}</span>
|
<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>
|
<button @click="selected = null" class="text-text-dim hover:text-text-primary text-xs ml-auto sm:ml-0">✕ close</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -149,7 +172,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||||
import { RouterLink } from 'vue-router'
|
import { RouterLink } from 'vue-router'
|
||||||
import IncidentTimeline from '@/components/IncidentTimeline.vue'
|
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 ──────────────────────────────────────
|
// ── timeline interaction ──────────────────────────────────────
|
||||||
const highlightIdx = ref<number | null>(null)
|
const highlightIdx = ref<number | null>(null)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -282,6 +282,200 @@
|
||||||
</div>
|
</div>
|
||||||
</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
|
<p
|
||||||
v-if="saveStatus"
|
v-if="saveStatus"
|
||||||
role="status"
|
role="status"
|
||||||
|
|
@ -320,6 +514,26 @@ interface Prefs {
|
||||||
pihole_api_key: string
|
pihole_api_key: string
|
||||||
router_source_ids: string
|
router_source_ids: string
|
||||||
device_names: 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 }[] = [
|
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 saveStatus = ref<{ ok: boolean; msg: string } | null>(null)
|
||||||
const showAddOverride = ref(false)
|
const showAddOverride = ref(false)
|
||||||
const showApiKey = ref(false)
|
const showApiKey = ref(false)
|
||||||
const showPiholeKey = ref(false)
|
const showPiholeKey = ref(false)
|
||||||
|
const showNotionToken = ref(false)
|
||||||
|
const showJiraToken = ref(false)
|
||||||
const piholeStatus = ref<{ ok: boolean; msg: string } | null>(null)
|
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 })
|
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 entryPointBtnRefs = ref<HTMLButtonElement[]>([])
|
||||||
|
|
||||||
const entryPointOptions = [
|
const entryPointOptions = [
|
||||||
|
|
@ -391,6 +624,7 @@ onMounted(async () => {
|
||||||
const res = await fetch(`${BASE}/api/settings`)
|
const res = await fetch(`${BASE}/api/settings`)
|
||||||
if (res.ok) prefs.value = await res.json()
|
if (res.ok) prefs.value = await res.json()
|
||||||
} catch { /* non-critical — defaults stay */ }
|
} catch { /* non-critical — defaults stay */ }
|
||||||
|
await loadSshTargets()
|
||||||
})
|
})
|
||||||
|
|
||||||
async function patch(body: Partial<Prefs>) {
|
async function patch(body: Partial<Prefs>) {
|
||||||
|
|
@ -490,4 +724,99 @@ async function testPihole() {
|
||||||
piholeStatus.value = { ok: false, msg: 'Network error' }
|
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>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,12 @@
|
||||||
<p class="text-text-dim text-sm">All hosts and services in the gleaned corpus.</p>
|
<p class="text-text-dim text-sm">All hosts and services in the gleaned corpus.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2 shrink-0">
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
|
<button
|
||||||
|
@click="toggleScanPanel"
|
||||||
|
class="btn-secondary text-sm"
|
||||||
|
>
|
||||||
|
Scan
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="showAddPanel = !showAddPanel"
|
@click="showAddPanel = !showAddPanel"
|
||||||
class="btn-secondary text-sm"
|
class="btn-secondary text-sm"
|
||||||
|
|
@ -27,6 +33,73 @@
|
||||||
/>
|
/>
|
||||||
</div>
|
</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) -->
|
<!-- Post-setup Add Source panel (condensed wizard steps 1-2) -->
|
||||||
<div v-else-if="showAddPanel" class="mb-6">
|
<div v-else-if="showAddPanel" class="mb-6">
|
||||||
<SetupWizard
|
<SetupWizard
|
||||||
|
|
@ -184,6 +257,17 @@ interface DbSource {
|
||||||
latest: string | null
|
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 sources = ref<SourceRow[]>([])
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const busy = ref(new Set<string>())
|
const busy = ref(new Set<string>())
|
||||||
|
|
@ -191,6 +275,14 @@ const actionMsg = ref('')
|
||||||
const actionError = ref(false)
|
const actionError = ref(false)
|
||||||
const showWizard = ref(false)
|
const showWizard = ref(false)
|
||||||
const showAddPanel = 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(/\/$/, '')
|
const BASE = import.meta.env.BASE_URL.replace(/\/$/, '')
|
||||||
|
|
||||||
|
|
@ -347,6 +439,82 @@ async function handleUpload(e: Event): Promise<void> {
|
||||||
;(e.target as HTMLInputElement).value = ''
|
;(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 {
|
function formatTs(iso: string | null): string {
|
||||||
if (!iso) return '—'
|
if (!iso) return '—'
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue