feat: saved searches (bookmarks)
All checks were successful
CI / lint-and-test (push) Successful in 23s
All checks were successful
CI / lint-and-test (push) Successful in 23s
- Add saved_searches_collection to database.py with index on created_by+created_at - New routes/saved_searches.py: GET /api/saved-searches, POST, DELETE - Saved searches are scoped per user (created_by = token sub) - Mount router in main.py - Frontend: Save filters button, saved search pills with load/delete - loadSavedSearches called on initApp - applySavedSearch restores filters and validates services against current options - Add CSS for saved-searches row - Add tests for CRUD, delete 404, and name validation
This commit is contained in:
@@ -7,6 +7,7 @@ from pymongo import ASCENDING, DESCENDING, TEXT, MongoClient
|
|||||||
client = MongoClient(MONGO_URI or "mongodb://localhost:27017")
|
client = MongoClient(MONGO_URI or "mongodb://localhost:27017")
|
||||||
db = client[DB_NAME]
|
db = client[DB_NAME]
|
||||||
events_collection = db["events"]
|
events_collection = db["events"]
|
||||||
|
saved_searches_collection = db["saved_searches"]
|
||||||
logger = structlog.get_logger("aoc.database")
|
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([("timestamp", DESCENDING)])
|
||||||
events_collection.create_index([("service", ASCENDING), ("timestamp", DESCENDING)])
|
events_collection.create_index([("service", ASCENDING), ("timestamp", DESCENDING)])
|
||||||
events_collection.create_index("id")
|
events_collection.create_index("id")
|
||||||
|
saved_searches_collection.create_index([("created_by", ASCENDING), ("created_at", DESCENDING)])
|
||||||
events_collection.create_index(
|
events_collection.create_index(
|
||||||
[("actor_display", TEXT), ("raw_text", TEXT), ("operation", TEXT)],
|
[("actor_display", TEXT), ("raw_text", TEXT), ("operation", TEXT)],
|
||||||
name="text_search_index",
|
name="text_search_index",
|
||||||
|
|||||||
@@ -112,11 +112,23 @@
|
|||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button type="submit">Apply filters</button>
|
<button type="submit">Apply filters</button>
|
||||||
<button type="button" id="clearBtn" class="ghost" @click="clearFilters()">Clear</button>
|
<button type="button" id="clearBtn" class="ghost" @click="clearFilters()">Clear</button>
|
||||||
|
<button type="button" class="ghost" @click="saveCurrentFilters()">Save filters</button>
|
||||||
<button type="button" class="ghost" @click="bulkTagMatching()">Bulk tag matching</button>
|
<button type="button" class="ghost" @click="bulkTagMatching()">Bulk tag matching</button>
|
||||||
<button type="button" class="ghost" @click="exportJSON()">Export JSON</button>
|
<button type="button" class="ghost" @click="exportJSON()">Export JSON</button>
|
||||||
<button type="button" class="ghost" @click="exportCSV()">Export CSV</button>
|
<button type="button" class="ghost" @click="exportCSV()">Export CSV</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="filter-row" x-show="savedSearches.length">
|
||||||
|
<div class="saved-searches">
|
||||||
|
<span>Saved:</span>
|
||||||
|
<template x-for="ss in savedSearches" :key="ss.id">
|
||||||
|
<span class="pill pill--tag" style="cursor:pointer;" @click="applySavedSearch(ss)">
|
||||||
|
<span x-text="ss.name"></span>
|
||||||
|
<button type="button" class="link" style="margin-left:4px;" @click.stop="deleteSavedSearch(ss.id)">×</button>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -255,6 +267,7 @@
|
|||||||
actor: '', selectedServices: [], search: '', operation: '', result: '', start: '', end: '', limit: 100, includeTags: '', excludeTags: '',
|
actor: '', selectedServices: [], search: '', operation: '', result: '', start: '', end: '', limit: 100, includeTags: '', excludeTags: '',
|
||||||
},
|
},
|
||||||
options: { actors: [], services: [], operations: [], results: [] },
|
options: { actors: [], services: [], operations: [], results: [] },
|
||||||
|
savedSearches: [],
|
||||||
appVersion: '',
|
appVersion: '',
|
||||||
aiFeaturesEnabled: true,
|
aiFeaturesEnabled: true,
|
||||||
askQuestionText: '',
|
askQuestionText: '',
|
||||||
@@ -271,6 +284,7 @@
|
|||||||
this.loadSavedFilters();
|
this.loadSavedFilters();
|
||||||
if (!this.authConfig?.auth_enabled || this.accessToken) {
|
if (!this.authConfig?.auth_enabled || this.accessToken) {
|
||||||
await this.loadFilterOptions();
|
await this.loadFilterOptions();
|
||||||
|
await this.loadSavedSearches();
|
||||||
await this.loadSourceHealth();
|
await this.loadSourceHealth();
|
||||||
await this.loadEvents();
|
await this.loadEvents();
|
||||||
}
|
}
|
||||||
@@ -529,6 +543,59 @@
|
|||||||
} catch {}
|
} 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() {
|
resetPagination() {
|
||||||
this.cursorStack = [];
|
this.cursorStack = [];
|
||||||
this.nextCursor = null;
|
this.nextCursor = null;
|
||||||
|
|||||||
@@ -370,6 +370,14 @@ input {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.saved-searches {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
.modal__explanation {
|
.modal__explanation {
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: rgba(255, 255, 255, 0.03);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from routes.fetch import router as fetch_router
|
|||||||
from routes.fetch import run_fetch
|
from routes.fetch import run_fetch
|
||||||
from routes.health import router as health_router
|
from routes.health import router as health_router
|
||||||
from routes.rules import router as rules_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
|
from routes.webhooks import router as webhooks_router
|
||||||
|
|
||||||
|
|
||||||
@@ -119,6 +120,7 @@ if AI_FEATURES_ENABLED:
|
|||||||
from routes.mcp import mcp_asgi
|
from routes.mcp import mcp_asgi
|
||||||
|
|
||||||
app.mount("/mcp", mcp_asgi)
|
app.mount("/mcp", mcp_asgi)
|
||||||
|
app.include_router(saved_searches_router, prefix="/api")
|
||||||
app.include_router(rules_router, prefix="/api")
|
app.include_router(rules_router, prefix="/api")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
60
backend/routes/saved_searches.py
Normal file
60
backend/routes/saved_searches.py
Normal file
@@ -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"}
|
||||||
@@ -22,9 +22,11 @@ def mock_watermarks_collection():
|
|||||||
@pytest.fixture(scope="function")
|
@pytest.fixture(scope="function")
|
||||||
def client(mock_events_collection, mock_watermarks_collection, monkeypatch):
|
def client(mock_events_collection, mock_watermarks_collection, monkeypatch):
|
||||||
monkeypatch.setattr("database.events_collection", mock_events_collection)
|
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.fetch.events_collection", mock_events_collection)
|
||||||
monkeypatch.setattr("routes.events.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.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("watermark.watermarks_collection", mock_watermarks_collection)
|
||||||
monkeypatch.setattr("routes.health.watermarks_collection", mock_watermarks_collection)
|
monkeypatch.setattr("routes.health.watermarks_collection", mock_watermarks_collection)
|
||||||
monkeypatch.setattr("routes.fetch.get_watermark", lambda source: None)
|
monkeypatch.setattr("routes.fetch.get_watermark", lambda source: None)
|
||||||
|
|||||||
@@ -107,6 +107,48 @@ def test_explain_event_with_llm_mock(client, mock_events_collection, monkeypatch
|
|||||||
assert data["llm_used"] is True
|
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):
|
def test_health(client):
|
||||||
response = client.get("/health")
|
response = client.get("/health")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|||||||
Reference in New Issue
Block a user