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