diff --git a/.gitignore b/.gitignore index 3613d6b..c4024fc 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,5 @@ __pycache__/ .turnstone-api.pid web/node_modules/ web/dist/ +.superpowers/ +.worktrees/ diff --git a/app/rest.py b/app/rest.py index 9da8a62..98d8461 100644 --- a/app/rest.py +++ b/app/rest.py @@ -39,8 +39,10 @@ from app.services.search import ( stats_summary as _stats, format_results, ) +from app.services.diagnose import diagnose as _diagnose DB_PATH = Path(os.environ.get("TURNSTONE_DB", Path(__file__).parent.parent / "data" / "turnstone.db")) +PREFS_PATH = DB_PATH.parent / "preferences.json" DIST_DIR = Path(__file__).parent.parent / "web" / "dist" SOURCE_HOST = os.environ.get("TURNSTONE_SOURCE_HOST", "unknown") BUNDLE_ENDPOINT = os.environ.get("TURNSTONE_BUNDLE_ENDPOINT", "") @@ -50,7 +52,7 @@ app = FastAPI(title="Turnstone API", version="0.1.0", docs_url="/turnstone/docs" app.add_middleware( CORSMiddleware, allow_origins=["*"], - allow_methods=["GET", "POST", "DELETE"], + allow_methods=["GET", "POST", "DELETE", "PATCH"], allow_headers=["*"], ) @@ -60,6 +62,29 @@ def _startup() -> None: ensure_schema(DB_PATH) +def _load_prefs() -> dict[str, str]: + if PREFS_PATH.exists(): + try: + return json.loads(PREFS_PATH.read_text()) + except (json.JSONDecodeError, OSError): + pass + return {"entry_point_style": "topbar"} + + +def _save_prefs(data: dict[str, str]) -> None: + PREFS_PATH.write_text(json.dumps(data)) + + +class DiagnoseRequest(BaseModel): + query: str + since: str | None = None + until: str | None = None + + +class SettingsBody(BaseModel): + entry_point_style: str + + class IncidentCreate(BaseModel): label: str issue_type: str = "" @@ -167,6 +192,38 @@ def diagnose( } +@router.post("/api/diagnose") +def diagnose_post(body: DiagnoseRequest) -> dict: + if not body.query.strip(): + return { + "summary": { + "total": 0, "window_start": None, "window_end": None, + "time_detected": False, "by_severity": {}, "by_source": {}, + }, + "entries": [], + } + result = _diagnose(DB_PATH, query=body.query, since=body.since, until=body.until) + return { + "summary": result["summary"], + "entries": [dataclasses.asdict(r) for r in result["entries"]], + } + + +@router.get("/api/settings") +def get_settings() -> dict: + return _load_prefs() + + +@router.patch("/api/settings") +def patch_settings(body: SettingsBody) -> dict: + if body.entry_point_style not in ("topbar", "fab"): + raise HTTPException(status_code=422, detail="entry_point_style must be 'topbar' or 'fab'") + prefs = _load_prefs() + prefs["entry_point_style"] = body.entry_point_style + _save_prefs(prefs) + return prefs + + @router.get("/api/sources") def list_sources() -> dict: return {"sources": _list_sources(DB_PATH)} diff --git a/app/services/diagnose.py b/app/services/diagnose.py new file mode 100644 index 0000000..8665b15 --- /dev/null +++ b/app/services/diagnose.py @@ -0,0 +1,100 @@ +"""Frictionless diagnose service — NL time extraction + layered log search.""" +from __future__ import annotations + +import logging +import re +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any + +from app.services.search import SearchResult, entries_in_window, search + +logger = logging.getLogger(__name__) + +try: + from dateparser.search import search_dates as _search_dates # type: ignore[import] + _HAS_DATEPARSER = True +except ImportError: + _search_dates = None # type: ignore[assignment] + _HAS_DATEPARSER = False + + +def parse_time_window(query: str) -> tuple[str | None, str | None, str]: + """Extract a time window from a natural-language query string. + + Returns (since_iso, until_iso, keywords) where keywords is the query with + the matched time phrase stripped. Falls back to last-60-min window. + """ + if _HAS_DATEPARSER and _search_dates is not None: + try: + results = _search_dates(query, languages=["en"], settings={"PREFER_DATES_FROM": "past"}) + except Exception: + logger.warning("dateparser failed on query %r — falling back to 60-min window", query) + results = None + if results: + phrase, dt = results[0] + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + since = (dt - timedelta(minutes=30)).isoformat() + until = (dt + timedelta(minutes=30)).isoformat() + keywords = re.sub(r"\s{2,}", " ", query.replace(phrase, " ").strip()) + return since, until, keywords or query + + return _last_n_minutes(60), _now_iso(), query + + +def diagnose( + db_path: Path, + query: str, + since: str | None = None, + until: str | None = None, +) -> dict[str, Any]: + """Run layered log search with NL time extraction. Returns summary + entries.""" + time_detected = since is not None and until is not None + if not time_detected: + parsed_since, parsed_until, keywords = parse_time_window(query) + since = since or parsed_since + until = until or parsed_until + time_detected = keywords != query + else: + keywords = query + + keyword_hits = search(db_path, query=keywords, since=since, until=until, limit=150, or_mode=True) + window_hits = entries_in_window(db_path, since=since, until=until, limit=50) + + seen: set[str] = set() + merged: list[SearchResult] = [] + for r in keyword_hits + window_hits: + if r.entry_id not in seen: + seen.add(r.entry_id) + merged.append(r) + + combined = sorted(merged, key=lambda r: (r.timestamp_iso or "\xff", r.sequence))[:200] + + by_severity: dict[str, int] = {"CRITICAL": 0, "ERROR": 0, "WARN": 0, "INFO": 0} + by_source: dict[str, int] = {} + for r in combined: + sev = (r.severity or "INFO").upper() + if sev in by_severity: + by_severity[sev] += 1 + by_source[r.source_id] = by_source.get(r.source_id, 0) + 1 + + return { + "summary": { + "total": len(combined), + "window_start": since, + "window_end": until, + "time_detected": time_detected, + "by_severity": by_severity, + "by_source": by_source, + }, + "entries": combined, + } + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _last_n_minutes(n: int) -> str: + return (datetime.now(timezone.utc) - timedelta(minutes=n)).isoformat() diff --git a/requirements.txt b/requirements.txt index 9408a53..14a443b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,4 @@ pydantic>=2.0.0 pyyaml>=6.0 aiofiles>=23.0.0 python-multipart>=0.0.9 +dateparser>=1.2.0 diff --git a/tests/test_services_diagnose.py b/tests/test_services_diagnose.py new file mode 100644 index 0000000..2cb0e01 --- /dev/null +++ b/tests/test_services_diagnose.py @@ -0,0 +1,63 @@ +"""Tests for app/services/diagnose.py — parse_time_window.""" +from __future__ import annotations + +from datetime import datetime, timezone +from unittest.mock import patch + + +from app.services.diagnose import diagnose, parse_time_window + + +def test_no_time_phrase_falls_back_to_last_60_min(): + with patch("app.services.diagnose._HAS_DATEPARSER", True), \ + patch("app.services.diagnose._search_dates", return_value=None): + since, until, keywords = parse_time_window("plex stopped playing audio") + assert since is not None and until is not None + assert keywords == "plex stopped playing audio" + diff = (datetime.fromisoformat(until) - datetime.fromisoformat(since)).total_seconds() + assert abs(diff - 3600) < 5 + + +def test_time_phrase_detected_produces_60min_window(): + dt = datetime(2026, 5, 11, 14, 0, tzinfo=timezone.utc) + with patch("app.services.diagnose._HAS_DATEPARSER", True), \ + patch("app.services.diagnose._search_dates", return_value=[("around 2pm", dt)]): + since, until, keywords = parse_time_window("plex stopped audio around 2pm") + diff = (datetime.fromisoformat(until) - datetime.fromisoformat(since)).total_seconds() + assert abs(diff - 3600) < 5 + + +def test_time_phrase_stripped_from_keywords(): + dt = datetime(2026, 5, 11, 14, 0, tzinfo=timezone.utc) + with patch("app.services.diagnose._HAS_DATEPARSER", True), \ + patch("app.services.diagnose._search_dates", return_value=[("around 2pm", dt)]): + _, _, keywords = parse_time_window("plex stopped audio around 2pm") + assert "around 2pm" not in keywords + assert "plex" in keywords + + +def test_no_dateparser_falls_back_to_60min(): + with patch("app.services.diagnose._HAS_DATEPARSER", False), \ + patch("app.services.diagnose._search_dates", None): + since, until, keywords = parse_time_window("plex stopped playing audio") + assert keywords == "plex stopped playing audio" + diff = (datetime.fromisoformat(until) - datetime.fromisoformat(since)).total_seconds() + assert abs(diff - 3600) < 5 + + +def test_keywords_cleaned_of_extra_spaces(): + dt = datetime(2026, 5, 11, 14, 0, tzinfo=timezone.utc) + with patch("app.services.diagnose._HAS_DATEPARSER", True), \ + patch("app.services.diagnose._search_dates", return_value=[("around 2pm", dt)]): + _, _, keywords = parse_time_window("plex stopped audio around 2pm extra") + assert " " not in keywords + + +def test_diagnose_with_explicit_window_sets_time_detected(tmp_path): + from app.ingest.pipeline import ensure_schema + db = tmp_path / "test.db" + ensure_schema(db) + result = diagnose(db, query="plex", since="2026-05-11T14:00:00+00:00", until="2026-05-11T15:00:00+00:00") + assert result["summary"]["time_detected"] is True + assert result["summary"]["total"] == 0 # empty DB + assert result["summary"]["window_start"] == "2026-05-11T14:00:00+00:00" diff --git a/web/src/App.vue b/web/src/App.vue index cd42978..688a38d 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -12,11 +12,15 @@ :to="link.to" class="px-3 py-1 rounded text-sm text-text-muted hover:text-text-primary hover:bg-surface-raised transition-colors" active-class="text-accent bg-accent-muted" - > - {{ link.label }} - + >{{ link.label }}
+
+ + + + + + + diff --git a/web/src/components/IncidentForm.vue b/web/src/components/IncidentForm.vue new file mode 100644 index 0000000..ea6b466 --- /dev/null +++ b/web/src/components/IncidentForm.vue @@ -0,0 +1,222 @@ + + + diff --git a/web/src/components/QuickCapture.vue b/web/src/components/QuickCapture.vue new file mode 100644 index 0000000..8f41c3e --- /dev/null +++ b/web/src/components/QuickCapture.vue @@ -0,0 +1,252 @@ + + + diff --git a/web/src/components/QuickCaptureBar.vue b/web/src/components/QuickCaptureBar.vue new file mode 100644 index 0000000..1fa80cf --- /dev/null +++ b/web/src/components/QuickCaptureBar.vue @@ -0,0 +1,31 @@ + + + diff --git a/web/src/components/QuickCaptureFab.vue b/web/src/components/QuickCaptureFab.vue new file mode 100644 index 0000000..90306b6 --- /dev/null +++ b/web/src/components/QuickCaptureFab.vue @@ -0,0 +1,60 @@ + + + diff --git a/web/src/router/index.ts b/web/src/router/index.ts index 42dbafe..ba7c788 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -5,6 +5,7 @@ import DiagnoseView from '@/views/DiagnoseView.vue' import SourcesView from '@/views/SourcesView.vue' import IncidentsView from '@/views/IncidentsView.vue' import BundlesView from '@/views/BundlesView.vue' +import SettingsView from '@/views/SettingsView.vue' export default createRouter({ history: createWebHistory(import.meta.env.BASE_URL), @@ -16,5 +17,6 @@ export default createRouter({ { path: '/incidents', component: IncidentsView }, { path: '/bundles', component: BundlesView }, { path: '/sources', component: SourcesView }, + { path: '/settings', component: SettingsView }, ], }) diff --git a/web/src/views/DiagnoseView.vue b/web/src/views/DiagnoseView.vue index a56dcfa..8872260 100644 --- a/web/src/views/DiagnoseView.vue +++ b/web/src/views/DiagnoseView.vue @@ -1,108 +1,69 @@