diff --git a/.nfs0000000000bbcf52000002e7 b/.nfs0000000000bbcf52000002e7 new file mode 100755 index 0000000..a491754 --- /dev/null +++ b/.nfs0000000000bbcf52000002e7 @@ -0,0 +1,308 @@ +#!/usr/bin/env bash +# manage.sh — Turnstone diagnostic intelligence layer +# Usage: ./manage.sh [args] +set -euo pipefail + +# Only emit color codes when stdout is a real terminal +if [[ -t 1 ]]; then + RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m' +else + RED=''; GREEN=''; YELLOW=''; BLUE=''; NC='' +fi +info() { echo -e "${BLUE}[turnstone]${NC} $*"; } +success() { echo -e "${GREEN}[turnstone]${NC} $*"; } +warn() { echo -e "${YELLOW}[turnstone]${NC} $*"; } +error() { echo -e "${RED}[turnstone]${NC} $*" >&2; exit 1; } + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +API_PORT=8534 # FastAPI: serves REST API + built Vue SPA +VITE_PORT=5174 # Vite HMR port in dev mode (proxies /api → 8534) + +LOG_DIR="log" +API_PID_FILE=".turnstone-api.pid" + +DB="${TURNSTONE_DB:-${SCRIPT_DIR}/data/turnstone.db}" + +CONDA_BASE="${CONDA_BASE:-/devl/miniconda3}" +PYTHON="${CONDA_BASE}/envs/cf/bin/python" + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +_is_alive() { + local pid_file="$1" + [[ -f "$pid_file" ]] && kill -0 "$(<"$pid_file")" 2>/dev/null +} + +_kill_pid_file() { + local pid_file="$1" label="$2" + if [[ -f "$pid_file" ]]; then + local pid + pid=$(<"$pid_file") + if kill -0 "$pid" 2>/dev/null; then + kill "$pid" && rm -f "$pid_file" + success "$label stopped (PID $pid)." + else + warn "Stale PID file for $label (PID $pid not running). Cleaning up." + rm -f "$pid_file" + fi + else + warn "$label not running." + fi +} + +_wait_for_port() { + local port="$1" label="$2" pid_file="$3" + for _i in $(seq 1 20); do + sleep 0.5 + (echo "" >/dev/tcp/127.0.0.1/"$port") 2>/dev/null && return 0 + if ! _is_alive "$pid_file"; then + rm -f "$pid_file" + error "$label died during startup. Check ${LOG_DIR}/api.log" + fi + done + error "$label did not bind to port $port within 10 s." +} + +# ── Usage ───────────────────────────────────────────────────────────────────── + +usage() { + echo "" + echo -e " ${BLUE}Turnstone — Diagnostic Log Intelligence${NC}" + echo "" + echo " Usage: ./manage.sh [args]" + echo "" + echo " Production-like (built SPA + uvicorn):" + echo -e " ${GREEN}start${NC} Build Vue SPA, start FastAPI + SPA on :${API_PORT}" + echo -e " ${GREEN}stop${NC} Stop the server" + echo -e " ${GREEN}restart${NC} Stop then start" + echo -e " ${GREEN}status${NC} Show running process" + echo -e " ${GREEN}logs${NC} Tail server log" + echo -e " ${GREEN}open${NC} Open UI in browser" + echo "" + echo " Development (hot-reload):" + echo -e " ${GREEN}dev${NC} uvicorn --reload (:${API_PORT}) + Vite HMR (:${VITE_PORT})" + echo "" + echo " Data:" + echo -e " ${GREEN}ingest PATH [DB]${NC} Ingest a log file or corpus directory" + echo -e " ${GREEN}ingest-plex [HOST]${NC} Pull Plex log from Cass (or HOST) and ingest" + echo -e " ${GREEN}ingest-qbit [HOST]${NC} Pull qBittorrent log locally or from HOST via SSH" + echo -e " ${GREEN}build-fts${NC} Rebuild the FTS search index" + echo "" + echo " Tests:" + echo -e " ${GREEN}test [args]${NC} Run pytest suite" + echo "" + echo " DB: ${DB}" + echo " Conda env: cf" + echo "" + echo " Examples:" + echo " ./manage.sh start" + echo " ./manage.sh dev" + echo " ./manage.sh ingest corpus/raw/" + echo " ./manage.sh ingest corpus/raw/ data/custom.db" + echo "" +} + +# ── Commands ────────────────────────────────────────────────────────────────── + +CMD="${1:-help}" +shift || true + +case "$CMD" in + + start) + if _is_alive "$API_PID_FILE"; then + warn "Already running (PID $(<"$API_PID_FILE")) — use 'restart' to rebuild." + exit 0 + fi + mkdir -p "$LOG_DIR" data + + info "Building Vue SPA…" + (cd web && npm run build) 2>&1 | tee "${LOG_DIR}/build.log" | grep -E "built in|error" || true + success "SPA built → web/dist/" + + info "Starting on port ${API_PORT}…" + TURNSTONE_DB="$DB" nohup "$PYTHON" -m uvicorn app.rest:app \ + --host 0.0.0.0 --port "$API_PORT" \ + >> "${LOG_DIR}/api.log" 2>&1 & + echo $! > "$API_PID_FILE" + _wait_for_port "$API_PORT" "Turnstone" "$API_PID_FILE" + success "Running → http://localhost:${API_PORT} (PID $(<"$API_PID_FILE"))" + ;; + + stop) + _kill_pid_file "$API_PID_FILE" "Turnstone" + ;; + + restart) + bash "$0" stop + exec bash "$0" start + ;; + + status) + echo "" + if _is_alive "$API_PID_FILE"; then + success "Turnstone RUNNING PID $(<"$API_PID_FILE") → http://localhost:${API_PORT}" + else + echo -e " Turnstone ${RED}STOPPED${NC}" + fi + echo "" + ;; + + logs) + tail -f "${LOG_DIR}/api.log" + ;; + + open) + URL="http://localhost:${API_PORT}" + info "Opening ${URL}" + if command -v xdg-open &>/dev/null; then xdg-open "$URL" + elif command -v open &>/dev/null; then open "$URL" + else echo "$URL" + fi + ;; + + dev) + DEV_API_PID=".turnstone-dev-api.pid" + mkdir -p "$LOG_DIR" data + + if _is_alive "$DEV_API_PID"; then + warn "Dev API already running (PID $(<"$DEV_API_PID"))" + else + info "Starting uvicorn --reload on port ${API_PORT}…" + TURNSTONE_DB="$DB" nohup "$PYTHON" -m uvicorn app.rest:app \ + --host 0.0.0.0 --port "$API_PORT" --reload \ + >> "${LOG_DIR}/api.log" 2>&1 & + echo $! > "$DEV_API_PID" + _wait_for_port "$API_PORT" "FastAPI (dev)" "$DEV_API_PID" + success "API (hot-reload) → http://localhost:${API_PORT}" + fi + + _cleanup_dev() { + local pid + pid=$(<"$DEV_API_PID" 2>/dev/null) || true + [[ -n "${pid:-}" ]] && kill "$pid" 2>/dev/null && rm -f "$DEV_API_PID" + info "Dev servers stopped." + } + trap _cleanup_dev EXIT INT TERM + + info "Starting Vite HMR on port ${VITE_PORT}…" + success "Frontend (HMR) → http://localhost:${VITE_PORT}" + (cd web && npm run dev -- --port "$VITE_PORT") + ;; + + ingest) + if [[ $# -lt 1 ]]; then + error "Usage: ./manage.sh ingest [DB_PATH]" + fi + info "Ingesting $1 → ${2:-$DB}…" + "$PYTHON" scripts/ingest_corpus.py "$1" "${2:-$DB}" + ;; + + ingest-plex) + PLEX_HOST="${1:-cass}" + PLEX_LOG_DIR="/var/lib/plexmediaserver/Library/Application Support/Plex Media Server/Logs" + TMP_DIR="/tmp/turnstone-plex-$$" + mkdir -p "$TMP_DIR" + + info "Listing Plex logs on ${PLEX_HOST}…" + # Get list of all rotated + active Plex logs + mapfile -t REMOTE_LOGS < <(ssh "$PLEX_HOST" \ + "ls '${PLEX_LOG_DIR}'/Plex\ Media\ Server*.log 2>/dev/null") \ + || { rm -rf "$TMP_DIR"; error "SSH to ${PLEX_HOST} failed."; } + + if [[ ${#REMOTE_LOGS[@]} -eq 0 ]]; then + rm -rf "$TMP_DIR" + error "No Plex logs found on ${PLEX_HOST} at ${PLEX_LOG_DIR}" + fi + + for remote_path in "${REMOTE_LOGS[@]}"; do + # Plex Media Server.1.log → cass-plex_media_server.1.log + local_name="${PLEX_HOST}-$(basename "$remote_path" | tr ' ' '_' | tr '[:upper:]' '[:lower:]')" + local_path="${TMP_DIR}/${local_name}" + info " ← $(basename "$remote_path")" + ssh "$PLEX_HOST" "cat '${remote_path}'" > "$local_path" + done + + info "Ingesting ${#REMOTE_LOGS[@]} log file(s) into ${DB}…" + for f in "$TMP_DIR"/*.log; do + "$PYTHON" scripts/ingest_corpus.py "$f" "$DB" + done + rm -rf "$TMP_DIR" + info "Done. Restarting server…" + exec bash "$0" restart + ;; + + ingest-qbit) + QBIT_HOST="${1:-}" + # Default log locations in priority order + QBIT_LOG_PATHS=( + "$HOME/.local/share/qBittorrent/logs/qbittorrent.log" + "$HOME/.config/qBittorrent/logs/qbittorrent.log" + "/var/log/qbittorrent/qbittorrent.log" + ) + TMP_DIR="/tmp/turnstone-qbit-$$" + mkdir -p "$TMP_DIR" + + if [[ -n "$QBIT_HOST" ]]; then + info "Fetching qBittorrent log from ${QBIT_HOST}…" + REMOTE_LOG="" + for p in "${QBIT_LOG_PATHS[@]}"; do + if ssh "$QBIT_HOST" "test -f '$p'" 2>/dev/null; then + REMOTE_LOG="$p" + break + fi + done + if [[ -z "$REMOTE_LOG" ]]; then + rm -rf "$TMP_DIR" + error "No qBittorrent log found on ${QBIT_HOST}. Tried: ${QBIT_LOG_PATHS[*]}" + fi + local_name="${QBIT_HOST}-qbittorrent.log" + ssh "$QBIT_HOST" "cat '$REMOTE_LOG'" > "${TMP_DIR}/${local_name}" + info " ← ${REMOTE_LOG} (${QBIT_HOST})" + else + LOCAL_LOG="" + for p in "${QBIT_LOG_PATHS[@]}"; do + if [[ -f "$p" ]]; then + LOCAL_LOG="$p" + break + fi + done + if [[ -z "$LOCAL_LOG" ]]; then + rm -rf "$TMP_DIR" + error "No qBittorrent log found locally. Tried: ${QBIT_LOG_PATHS[*]}" + fi + cp "$LOCAL_LOG" "${TMP_DIR}/qbittorrent.log" + info " ← ${LOCAL_LOG}" + fi + + info "Ingesting into ${DB}…" + "$PYTHON" scripts/ingest_corpus.py "${TMP_DIR}"/*.log "$DB" + rm -rf "$TMP_DIR" + info "Done. Restarting server…" + exec bash "$0" restart + ;; + + build-fts) + info "Rebuilding FTS index for ${DB}…" + TURNSTONE_DB="$DB" "$PYTHON" scripts/build_fts_index.py "$DB" + success "FTS index rebuilt." + ;; + + test) + info "Running test suite…" + PYTEST="${CONDA_BASE}/envs/cf/bin/pytest" + [[ -x "$PYTEST" ]] || error "pytest not found in cf env at ${PYTEST}" + TURNSTONE_DB=":memory:" "$PYTEST" tests/ -v "$@" + ;; + + help|--help|-h) + usage + ;; + + *) + error "Unknown command: ${CMD}. Run './manage.sh help' for usage." + ;; + +esac diff --git a/app/context/embedder.py b/app/context/embedder.py index 7bd17e0..68db2a2 100644 --- a/app/context/embedder.py +++ b/app/context/embedder.py @@ -45,7 +45,7 @@ def embed_chunks( if embedder is None: return 0 - conn = sqlite3.connect(str(db_path)) + conn = sqlite3.connect(str(db_path), timeout=30.0) conn.execute("PRAGMA journal_mode=WAL") conn.row_factory = sqlite3.Row diff --git a/app/context/retriever.py b/app/context/retriever.py index c4b511e..88f3a19 100644 --- a/app/context/retriever.py +++ b/app/context/retriever.py @@ -37,7 +37,7 @@ class RetrievedContext: def get_relevant_facts(db_path: Path, query: str) -> list[dict[str, str]]: """Keyword match against context_facts. Always runs — Free tier.""" try: - conn = sqlite3.connect(str(db_path)) + conn = sqlite3.connect(str(db_path), timeout=30.0) conn.execute("PRAGMA journal_mode=WAL") conn.row_factory = sqlite3.Row keywords = [w.lower() for w in query.split() if len(w) > 2] @@ -92,7 +92,7 @@ def _search_chunks_vector( return [] try: - conn = sqlite3.connect(str(db_path)) + conn = sqlite3.connect(str(db_path), timeout=30.0) conn.execute("PRAGMA journal_mode=WAL") conn.row_factory = sqlite3.Row rows = conn.execute( @@ -127,7 +127,7 @@ def _search_chunks_vector( def _search_chunks_keyword(db_path: Path, query: str) -> list[dict[str, str]]: """LIKE-based keyword search across context_chunks. Fallback when no embedder.""" try: - conn = sqlite3.connect(str(db_path)) + conn = sqlite3.connect(str(db_path), timeout=30.0) conn.execute("PRAGMA journal_mode=WAL") conn.row_factory = sqlite3.Row keywords = [w.lower() for w in query.split() if len(w) > 2][:5] diff --git a/app/glean/doc_upload.py b/app/glean/doc_upload.py index bc4a3b7..c2d4d9a 100644 --- a/app/glean/doc_upload.py +++ b/app/glean/doc_upload.py @@ -25,7 +25,7 @@ def glean_upload(db_path: Path, filename: str, content: bytes) -> dict[str, Any] for fact in facts: add_fact(db_path, fact.category, fact.key, fact.value, source="upload") - conn = sqlite3.connect(str(db_path)) + conn = sqlite3.connect(str(db_path), timeout=30.0) conn.execute("PRAGMA journal_mode=WAL") for i, chunk_text in enumerate(chunks): conn.execute( diff --git a/app/glean/mqtt_subscriber.py b/app/glean/mqtt_subscriber.py index efa514d..4f8cf2e 100644 --- a/app/glean/mqtt_subscriber.py +++ b/app/glean/mqtt_subscriber.py @@ -59,7 +59,7 @@ def _make_entry_id(source_id: str, seq: int, text: str) -> str: def _write_entry(db_path: Path, entry: RetrievedEntry) -> None: - with sqlite3.connect(db_path) as conn: + with sqlite3.connect(db_path, timeout=30.0) as conn: conn.execute( """ INSERT OR IGNORE INTO log_entries diff --git a/app/mcp_server.py b/app/mcp_server.py index 4c30cdf..2d1564a 100644 --- a/app/mcp_server.py +++ b/app/mcp_server.py @@ -59,7 +59,7 @@ def _ensure_index() -> None: return try: - conn = sqlite3.connect(str(DB_PATH)) + conn = sqlite3.connect(str(DB_PATH), timeout=30.0) count = conn.execute("SELECT COUNT(*) FROM log_fts").fetchone()[0] conn.close() if count > 0: diff --git a/app/rest.py b/app/rest.py index 391638d..8801b39 100644 --- a/app/rest.py +++ b/app/rest.py @@ -509,7 +509,7 @@ def list_configured_sources() -> dict: @router.delete("/api/sources/{source_id}") def delete_source(source_id: str) -> dict: """Delete all log entries (and FTS index rows) for a given source.""" - conn = sqlite3.connect(str(DB_PATH)) + conn = sqlite3.connect(str(DB_PATH), timeout=30.0) conn.execute("PRAGMA journal_mode=WAL") try: conn.execute("DELETE FROM log_fts WHERE source_id = ?", (source_id,)) @@ -616,7 +616,7 @@ def glean_batch(payload: BatchGleanRequest, background_tasks: BackgroundTasks) - """ if not payload.entries: return {"gleaned": 0} - conn = sqlite3.connect(str(DB_PATH)) + conn = sqlite3.connect(str(DB_PATH), timeout=30.0) conn.execute("PRAGMA journal_mode=WAL") conn.executemany( """ @@ -710,7 +710,7 @@ async def glean_wazuh_alert( compiled = load_compiled_patterns(PATTERN_FILE) entries = list(_parse_wazuh(iter([json.dumps(alert)]), sid, compiled, ingest_time)) if entries: - conn = sqlite3.connect(str(DB_PATH)) + conn = sqlite3.connect(str(DB_PATH), timeout=30.0) conn.execute("PRAGMA journal_mode=WAL") conn.executemany( """ @@ -892,7 +892,7 @@ def glean_tautulli( compiled = _compiled_patterns entry = _parse_tautulli(payload, compiled) - conn = sqlite3.connect(str(DB_PATH)) + conn = sqlite3.connect(str(DB_PATH), timeout=30.0) conn.execute("PRAGMA journal_mode=WAL") try: _tautulli_write_entry(conn, entry) diff --git a/app/services/blocklist.py b/app/services/blocklist.py index 5801537..998014a 100644 --- a/app/services/blocklist.py +++ b/app/services/blocklist.py @@ -172,7 +172,7 @@ def run_scan( now = _now_iso() count = 0 - conn = sqlite3.connect(str(db_path)) + conn = sqlite3.connect(str(db_path), timeout=30.0) try: rows = conn.execute( f"SELECT id, text FROM log_entries WHERE source_id IN ({placeholders})", @@ -226,7 +226,7 @@ def list_candidates( status: str | None = None, device_ip: str | None = None, ) -> list[BlocklistCandidate]: - conn = sqlite3.connect(str(db_path)) + conn = sqlite3.connect(str(db_path), timeout=30.0) try: query = f"{_CANDIDATE_SELECT} WHERE 1=1" params: list = [] @@ -255,7 +255,7 @@ def _get_candidate(conn: sqlite3.Connection, candidate_id: str) -> BlocklistCand def get_candidate(db_path: Path, candidate_id: str) -> BlocklistCandidate: """Fetch a single candidate by ID. Raises KeyError if not found.""" - conn = sqlite3.connect(str(db_path)) + conn = sqlite3.connect(str(db_path), timeout=30.0) try: return _get_candidate(conn, candidate_id) finally: @@ -265,7 +265,7 @@ def get_candidate(db_path: Path, candidate_id: str) -> BlocklistCandidate: def update_candidate_status(db_path: Path, candidate_id: str, new_status: str) -> BlocklistCandidate: if new_status not in _VALID_STATUSES: raise ValueError(f"Invalid status {new_status!r}. Must be one of {_VALID_STATUSES}") - conn = sqlite3.connect(str(db_path)) + conn = sqlite3.connect(str(db_path), timeout=30.0) try: conn.execute("UPDATE blocklist_candidates SET status=? WHERE id=?", (new_status, candidate_id)) conn.commit() @@ -275,7 +275,7 @@ def update_candidate_status(db_path: Path, candidate_id: str, new_status: str) - def mark_pushed(db_path: Path, candidate_id: str) -> BlocklistCandidate: - conn = sqlite3.connect(str(db_path)) + conn = sqlite3.connect(str(db_path), timeout=30.0) try: conn.execute( "UPDATE blocklist_candidates SET status='pushed', pushed_at=? WHERE id=?", @@ -288,7 +288,7 @@ def mark_pushed(db_path: Path, candidate_id: str) -> BlocklistCandidate: def mark_unblocked(db_path: Path, candidate_id: str) -> BlocklistCandidate: - conn = sqlite3.connect(str(db_path)) + conn = sqlite3.connect(str(db_path), timeout=30.0) try: conn.execute("UPDATE blocklist_candidates SET status='unblocked' WHERE id=?", (candidate_id,)) conn.commit() diff --git a/app/services/diagnose/suppressor.py b/app/services/diagnose/suppressor.py index a77d74d..9f1b016 100644 --- a/app/services/diagnose/suppressor.py +++ b/app/services/diagnose/suppressor.py @@ -72,7 +72,7 @@ def _fetch_resolved_incidents(db_path: Path) -> list[str]: Returns an empty list on any error (missing table, connection failure, etc.). """ try: - with sqlite3.connect(str(db_path)) as conn: + with sqlite3.connect(str(db_path), timeout=30.0) as conn: cursor = conn.execute( "SELECT label, notes FROM incidents WHERE ended_at IS NOT NULL LIMIT 200" ) diff --git a/app/services/incidents.py b/app/services/incidents.py index dd758c1..9714cb9 100644 --- a/app/services/incidents.py +++ b/app/services/incidents.py @@ -57,7 +57,7 @@ def create_incident( created_at=now_iso(), severity=severity, ) - conn = sqlite3.connect(str(db_path)) + conn = sqlite3.connect(str(db_path), timeout=30.0) conn.execute("PRAGMA journal_mode=WAL") conn.execute( "INSERT INTO incidents (id, label, issue_type, started_at, ended_at, notes, created_at, severity) " @@ -71,7 +71,7 @@ def create_incident( def list_incidents(db_path: Path) -> list[Incident]: - conn = sqlite3.connect(str(db_path)) + conn = sqlite3.connect(str(db_path), timeout=30.0) conn.execute("PRAGMA journal_mode=WAL") conn.row_factory = sqlite3.Row rows = conn.execute( @@ -82,7 +82,7 @@ def list_incidents(db_path: Path) -> list[Incident]: def get_incident(db_path: Path, incident_id: str) -> Incident | None: - conn = sqlite3.connect(str(db_path)) + conn = sqlite3.connect(str(db_path), timeout=30.0) conn.execute("PRAGMA journal_mode=WAL") conn.row_factory = sqlite3.Row row = conn.execute( @@ -93,7 +93,7 @@ def get_incident(db_path: Path, incident_id: str) -> Incident | None: def delete_incident(db_path: Path, incident_id: str) -> bool: - conn = sqlite3.connect(str(db_path)) + conn = sqlite3.connect(str(db_path), timeout=30.0) conn.execute("PRAGMA journal_mode=WAL") cur = conn.execute("DELETE FROM incidents WHERE id = ?", (incident_id,)) conn.commit() @@ -186,7 +186,7 @@ def store_bundle(db_path: Path, bundle: dict) -> ReceivedBundle: entry_count=len(bundle.get("log_entries", [])), bundle_json=json.dumps(bundle), ) - conn = sqlite3.connect(str(db_path)) + conn = sqlite3.connect(str(db_path), timeout=30.0) conn.execute("PRAGMA journal_mode=WAL") conn.execute( "INSERT INTO received_bundles " @@ -201,7 +201,7 @@ def store_bundle(db_path: Path, bundle: dict) -> ReceivedBundle: def list_bundles(db_path: Path) -> list[ReceivedBundle]: - conn = sqlite3.connect(str(db_path)) + conn = sqlite3.connect(str(db_path), timeout=30.0) conn.execute("PRAGMA journal_mode=WAL") conn.row_factory = sqlite3.Row rows = conn.execute( @@ -213,7 +213,7 @@ def list_bundles(db_path: Path) -> list[ReceivedBundle]: def get_bundle(db_path: Path, bundle_id: str) -> ReceivedBundle | None: - conn = sqlite3.connect(str(db_path)) + conn = sqlite3.connect(str(db_path), timeout=30.0) conn.execute("PRAGMA journal_mode=WAL") conn.row_factory = sqlite3.Row row = conn.execute( diff --git a/app/services/search.py b/app/services/search.py index 7252272..a39ef6b 100644 --- a/app/services/search.py +++ b/app/services/search.py @@ -30,7 +30,7 @@ def build_fts_index(db_path: Path) -> None: Drops and recreates the table if the schema is stale (missing sequence column). """ - conn = sqlite3.connect(str(db_path)) + conn = sqlite3.connect(str(db_path), timeout=30.0) conn.execute("PRAGMA journal_mode=WAL") # Check whether existing table has the sequence column; rebuild if not. @@ -98,7 +98,7 @@ def search( or_mode: bool = False, ) -> list[SearchResult]: """Full-text search with optional filters. Returns results ranked by relevance.""" - conn = sqlite3.connect(str(db_path)) + conn = sqlite3.connect(str(db_path), timeout=30.0) conn.execute("PRAGMA journal_mode=WAL") conn.row_factory = sqlite3.Row @@ -181,7 +181,7 @@ def entries_in_window( (e.g. network-syslog) don't crowd out lower-volume but more interesting ones. Errors/warnings are ranked first within each source partition. """ - conn = sqlite3.connect(str(db_path)) + conn = sqlite3.connect(str(db_path), timeout=30.0) conn.execute("PRAGMA journal_mode=WAL") conn.row_factory = sqlite3.Row @@ -275,7 +275,7 @@ def recent_source_errors( Bypasses FTS ranking so text content doesn't affect which errors surface. Used by diagnose when FTS keyword search returns nothing for a known source. """ - conn = sqlite3.connect(str(db_path)) + conn = sqlite3.connect(str(db_path), timeout=30.0) conn.execute("PRAGMA journal_mode=WAL") conn.row_factory = sqlite3.Row @@ -328,7 +328,7 @@ def recent_source_errors( def list_sources(db_path: Path) -> list[dict]: """Return distinct sources with entry counts and time ranges.""" - conn = sqlite3.connect(str(db_path)) + conn = sqlite3.connect(str(db_path), timeout=30.0) conn.execute("PRAGMA journal_mode=WAL") rows = conn.execute(""" SELECT @@ -381,7 +381,7 @@ def stats_summary(db_path: Path, window_hours: int = 24, severity_overrides: lis """ rules = _compile_overrides(severity_overrides or []) - conn = sqlite3.connect(str(db_path)) + conn = sqlite3.connect(str(db_path), timeout=30.0) conn.execute("PRAGMA journal_mode=WAL") conn.row_factory = sqlite3.Row diff --git a/app/tasks/glean_scheduler.py b/app/tasks/glean_scheduler.py index dc80393..02c6567 100644 --- a/app/tasks/glean_scheduler.py +++ b/app/tasks/glean_scheduler.py @@ -49,7 +49,7 @@ def get_state() -> IngestState: def _query_matched_since(db_path: Path, since: str | None) -> list[dict]: """Return entries with non-empty matched_patterns, optionally filtered by ingest_time.""" - conn = sqlite3.connect(str(db_path)) + conn = sqlite3.connect(str(db_path), timeout=30.0) conn.row_factory = sqlite3.Row try: if since: diff --git a/app/watch/watcher.py b/app/watch/watcher.py index a9490d1..1108087 100644 --- a/app/watch/watcher.py +++ b/app/watch/watcher.py @@ -111,7 +111,7 @@ class WatchSource: patterns = load_patterns(self.pattern_file) compiled = _compile(patterns) - conn = sqlite3.connect(str(self.db_path)) + conn = sqlite3.connect(str(self.db_path), timeout=30.0) conn.execute("PRAGMA journal_mode=WAL") conn.executescript(_SCHEMA) conn.commit() diff --git a/scripts/collect_cluster_logs.sh b/scripts/collect_cluster_logs.sh index 5ab9b48..c7e875e 100644 --- a/scripts/collect_cluster_logs.sh +++ b/scripts/collect_cluster_logs.sh @@ -99,6 +99,21 @@ else echo "navi: unreachable, skipping docker logs" fi +# ── Navi qBittorrent app logs (volume-mounted files, not in docker logs) ────── +# qBit writes rich per-torrent events to a file inside the compose volume. +# These are NOT captured by `docker logs` — must be pulled directly. +QBIT_LOG_BASE="/opt/containers/arr" +for instance in qbit-tb0 qbit-tb1 qbit-tb2; do + remote_log="${QBIT_LOG_BASE}/${instance}/qBittorrent/logs/qbittorrent.log" + local_out="${NAVI_DIR}/${instance}-app.log" + if ssh ${SSH_OPTS} navi "test -f '${remote_log}'" 2>/dev/null; then + ssh ${SSH_OPTS} navi "cat '${remote_log}'" > "${local_out}" 2>/dev/null || : > "${local_out}" + else + : > "${local_out}" + fi +done +echo "navi qbit app logs: $(cat "${NAVI_DIR}"/qbit-tb*.log 2>/dev/null | wc -l) lines" + # ── Strahl Docker containers ────────────────────────────────────────────────── STRAHL_DIR="${DATA_DIR}/docker-strahl" mkdir -p "${STRAHL_DIR}"