diff --git a/backend/database.py b/backend/database.py index cc35d55..003358b 100644 --- a/backend/database.py +++ b/backend/database.py @@ -7,6 +7,7 @@ from pymongo import ASCENDING, DESCENDING, TEXT, MongoClient client = MongoClient(MONGO_URI or "mongodb://localhost:27017") db = client[DB_NAME] events_collection = db["events"] +saved_searches_collection = db["saved_searches"] logger = structlog.get_logger("aoc.database") @@ -20,6 +21,7 @@ def setup_indexes(max_retries: int = 5, delay: float = 2.0): events_collection.create_index([("timestamp", DESCENDING)]) events_collection.create_index([("service", ASCENDING), ("timestamp", DESCENDING)]) events_collection.create_index("id") + saved_searches_collection.create_index([("created_by", ASCENDING), ("created_at", DESCENDING)]) events_collection.create_index( [("actor_display", TEXT), ("raw_text", TEXT), ("operation", TEXT)], name="text_search_index", diff --git a/backend/frontend/index.html b/backend/frontend/index.html index a2caf97..1d7b195 100644 --- a/backend/frontend/index.html +++ b/backend/frontend/index.html @@ -112,11 +112,23 @@
+
+
+
+ Saved: + +
+
@@ -255,6 +267,7 @@ actor: '', selectedServices: [], search: '', operation: '', result: '', start: '', end: '', limit: 100, includeTags: '', excludeTags: '', }, options: { actors: [], services: [], operations: [], results: [] }, + savedSearches: [], appVersion: '', aiFeaturesEnabled: true, askQuestionText: '', @@ -271,6 +284,7 @@ this.loadSavedFilters(); if (!this.authConfig?.auth_enabled || this.accessToken) { await this.loadFilterOptions(); + await this.loadSavedSearches(); await this.loadSourceHealth(); await this.loadEvents(); } @@ -529,6 +543,59 @@ } catch {} }, + async loadSavedSearches() { + try { + const res = await fetch('/api/saved-searches', { headers: this.authHeader() }); + if (!res.ok) return; + this.savedSearches = await res.json(); + } catch {} + }, + + async saveCurrentFilters() { + const name = prompt('Name this saved filter:'); + if (!name || !name.trim()) return; + try { + const res = await fetch('/api/saved-searches', { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...this.authHeader() }, + body: JSON.stringify({ name: name.trim(), filters: { ...this.filters } }), + }); + if (!res.ok) throw new Error(await res.text()); + const created = await res.json(); + this.savedSearches.unshift(created); + this.statusText = 'Filters saved.'; + setTimeout(() => { if (this.statusText === 'Filters saved.') this.statusText = ''; }, 2000); + } catch (err) { + this.statusText = err.message || 'Failed to save filters.'; + } + }, + + applySavedSearch(ss) { + if (!ss || !ss.filters) return; + const fields = ['actor', 'selectedServices', 'search', 'operation', 'result', 'start', 'end', 'limit', 'includeTags', 'excludeTags']; + fields.forEach((f) => { + if (ss.filters[f] !== undefined) this.filters[f] = ss.filters[f]; + }); + // Validate selectedServices against current options + this.filters.selectedServices = this.filters.selectedServices.filter((s) => this.options.services.includes(s)); + this.resetPagination(); + this.loadEvents(); + }, + + async deleteSavedSearch(id) { + if (!confirm('Delete this saved search?')) return; + try { + const res = await fetch(`/api/saved-searches/${id}`, { + method: 'DELETE', + headers: this.authHeader(), + }); + if (!res.ok) throw new Error(await res.text()); + this.savedSearches = this.savedSearches.filter((s) => s.id !== id); + } catch (err) { + this.statusText = err.message || 'Failed to delete saved search.'; + } + }, + resetPagination() { this.cursorStack = []; this.nextCursor = null; diff --git a/backend/frontend/style.css b/backend/frontend/style.css index 262e1b4..5f4c98b 100644 --- a/backend/frontend/style.css +++ b/backend/frontend/style.css @@ -370,6 +370,14 @@ input { align-items: center; } +.saved-searches { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + font-size: 13px; +} + .modal__explanation { background: rgba(255, 255, 255, 0.03); border: 1px solid var(--border); diff --git a/backend/main.py b/backend/main.py index f0d05d4..53851e5 100644 --- a/backend/main.py +++ b/backend/main.py @@ -20,6 +20,7 @@ from routes.fetch import router as fetch_router from routes.fetch import run_fetch from routes.health import router as health_router from routes.rules import router as rules_router +from routes.saved_searches import router as saved_searches_router from routes.webhooks import router as webhooks_router @@ -119,6 +120,7 @@ if AI_FEATURES_ENABLED: from routes.mcp import mcp_asgi app.mount("/mcp", mcp_asgi) +app.include_router(saved_searches_router, prefix="/api") app.include_router(rules_router, prefix="/api") diff --git a/backend/routes/saved_searches.py b/backend/routes/saved_searches.py new file mode 100644 index 0000000..0ccf218 --- /dev/null +++ b/backend/routes/saved_searches.py @@ -0,0 +1,60 @@ +"""CRUD for saved filter searches (bookmarks).""" + +import uuid +from datetime import UTC, datetime + +import structlog +from auth import require_auth +from database import saved_searches_collection +from fastapi import APIRouter, Depends, HTTPException + +router = APIRouter(dependencies=[Depends(require_auth)]) +logger = structlog.get_logger("aoc.saved_searches") + + +def _user_sub(user: dict) -> str: + return user.get("sub", "anonymous") + + +@router.get("/saved-searches") +async def list_saved_searches(user: dict = Depends(require_auth)): + """Return saved searches for the current user.""" + sub = _user_sub(user) + cursor = saved_searches_collection.find({"created_by": sub}).sort("created_at", -1) + items = [] + for doc in cursor: + doc["id"] = doc.pop("_id") + items.append(doc) + return items + + +@router.post("/saved-searches") +async def create_saved_search(body: dict, user: dict = Depends(require_auth)): + """Save the current filter set.""" + name = (body.get("name") or "").strip() + if not name: + raise HTTPException(status_code=400, detail="Name is required") + + filters = body.get("filters") or {} + doc = { + "_id": str(uuid.uuid4()), + "name": name, + "filters": filters, + "created_at": datetime.now(UTC).isoformat().replace("+00:00", "Z"), + "created_by": _user_sub(user), + } + saved_searches_collection.insert_one(doc) + logger.info("Saved search created", name=name, user=doc["created_by"]) + doc["id"] = doc.pop("_id") + return doc + + +@router.delete("/saved-searches/{search_id}") +async def delete_saved_search(search_id: str, user: dict = Depends(require_auth)): + """Delete a saved search (only if owned by current user).""" + sub = _user_sub(user) + result = saved_searches_collection.delete_one({"_id": search_id, "created_by": sub}) + if result.deleted_count == 0: + raise HTTPException(status_code=404, detail="Saved search not found") + logger.info("Saved search deleted", search_id=search_id, user=sub) + return {"status": "deleted"} diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index b69216b..6bf52a8 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -22,9 +22,11 @@ def mock_watermarks_collection(): @pytest.fixture(scope="function") def client(mock_events_collection, mock_watermarks_collection, monkeypatch): monkeypatch.setattr("database.events_collection", mock_events_collection) + monkeypatch.setattr("database.saved_searches_collection", mock_events_collection) monkeypatch.setattr("routes.fetch.events_collection", mock_events_collection) monkeypatch.setattr("routes.events.events_collection", mock_events_collection) monkeypatch.setattr("routes.ask.events_collection", mock_events_collection) + monkeypatch.setattr("routes.saved_searches.saved_searches_collection", mock_events_collection) monkeypatch.setattr("watermark.watermarks_collection", mock_watermarks_collection) monkeypatch.setattr("routes.health.watermarks_collection", mock_watermarks_collection) monkeypatch.setattr("routes.fetch.get_watermark", lambda source: None) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index e14a844..47b3900 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -107,6 +107,48 @@ def test_explain_event_with_llm_mock(client, mock_events_collection, monkeypatch assert data["llm_used"] is True +def test_saved_searches_crud(client, monkeypatch): + monkeypatch.setattr("auth.AUTH_ENABLED", False) + + # Create + response = client.post( + "/api/saved-searches", json={"name": "Test search", "filters": {"actor": "alice", "result": "success"}} + ) + assert response.status_code == 200 + created = response.json() + assert created["name"] == "Test search" + assert created["filters"]["actor"] == "alice" + search_id = created["id"] + + # List + response2 = client.get("/api/saved-searches") + assert response2.status_code == 200 + items = response2.json() + assert len(items) == 1 + assert items[0]["name"] == "Test search" + + # Delete + response3 = client.delete(f"/api/saved-searches/{search_id}") + assert response3.status_code == 200 + + # List empty + response4 = client.get("/api/saved-searches") + assert response4.status_code == 200 + assert len(response4.json()) == 0 + + +def test_saved_searches_delete_not_found(client, monkeypatch): + monkeypatch.setattr("auth.AUTH_ENABLED", False) + response = client.delete("/api/saved-searches/nonexistent") + assert response.status_code == 404 + + +def test_saved_searches_create_validation(client, monkeypatch): + monkeypatch.setattr("auth.AUTH_ENABLED", False) + response = client.post("/api/saved-searches", json={"name": " ", "filters": {}}) + assert response.status_code == 400 + + def test_health(client): response = client.get("/health") assert response.status_code == 200