diff --git a/backend/frontend/index.html b/backend/frontend/index.html index f0a2ecf..0f7f564 100644 --- a/backend/frontend/index.html +++ b/backend/frontend/index.html @@ -4,7 +4,7 @@ AOC Events - + @@ -76,12 +76,20 @@ To + + + +
-
-
App / Service
@@ -104,6 +112,7 @@
+
@@ -188,7 +197,7 @@ accessToken: null, authScopes: [], filters: { - actor: '', selectedServices: [], search: '', operation: '', result: '', start: '', end: '', limit: 100, + actor: '', selectedServices: [], search: '', operation: '', result: '', start: '', end: '', limit: 100, includeTags: '', excludeTags: '', }, options: { actors: [], services: [], operations: [], results: [] }, @@ -320,6 +329,12 @@ if (this.filters.selectedServices && this.filters.selectedServices.length) { this.filters.selectedServices.forEach((s) => params.append('services', s)); } + if (this.filters.includeTags) { + this.filters.includeTags.split(/[,;]+/).map((t) => t.trim()).filter(Boolean).forEach((t) => params.append('include_tags', t)); + } + if (this.filters.excludeTags) { + this.filters.excludeTags.split(/[,;]+/).map((t) => t.trim()).filter(Boolean).forEach((t) => params.append('exclude_tags', t)); + } if (this.filters.start) { const d = new Date(this.filters.start); if (!isNaN(d.getTime())) params.append('start', d.toISOString()); @@ -417,11 +432,53 @@ }, clearFilters() { - this.filters = { actor: '', selectedServices: [...this.options.services], search: '', operation: '', result: '', start: '', end: '', limit: 100 }; + this.filters = { actor: '', selectedServices: [...this.options.services], search: '', operation: '', result: '', start: '', end: '', limit: 100, includeTags: '', excludeTags: '' }; this.resetPagination(); this.loadEvents(); }, + async bulkTagMatching() { + const tag = prompt('Enter tag to apply to all matching events:'); + if (!tag || !tag.trim()) return; + const mode = confirm('Click OK to REPLACE existing tags.\nClick Cancel to APPEND the new tag.') ? 'replace' : 'append'; + const params = new URLSearchParams(); + ['actor', 'operation', 'result', 'search'].forEach((key) => { + const val = this.filters[key]; + if (val) params.append(key, val); + }); + if (this.filters.selectedServices && this.filters.selectedServices.length) { + this.filters.selectedServices.forEach((s) => params.append('services', s)); + } + if (this.filters.includeTags) { + this.filters.includeTags.split(/[,;]+/).map((t) => t.trim()).filter(Boolean).forEach((t) => params.append('include_tags', t)); + } + if (this.filters.excludeTags) { + this.filters.excludeTags.split(/[,;]+/).map((t) => t.trim()).filter(Boolean).forEach((t) => params.append('exclude_tags', t)); + } + if (this.filters.start) { + const d = new Date(this.filters.start); + if (!isNaN(d.getTime())) params.append('start', d.toISOString()); + } + if (this.filters.end) { + const d = new Date(this.filters.end); + if (!isNaN(d.getTime())) params.append('end', d.toISOString()); + } + this.statusText = 'Applying bulk tag…'; + try { + const res = await fetch(`/api/events/bulk-tags?${params.toString()}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...this.authHeader() }, + body: JSON.stringify({ tags: [tag.trim()], mode }), + }); + if (!res.ok) throw new Error(await res.text()); + const body = await res.json(); + this.statusText = `Tagged ${body.matched} events (${body.modified} modified).`; + await this.loadEvents(); + } catch (err) { + this.statusText = err.message || 'Failed to apply bulk tag.'; + } + }, + displayActor(e) { const app = e.actor?.application || e.actor?.app; if (app?.displayName) return app.displayName; diff --git a/backend/models/api.py b/backend/models/api.py index 35d816b..82cfe12 100644 --- a/backend/models/api.py +++ b/backend/models/api.py @@ -54,6 +54,11 @@ class TagsUpdateRequest(BaseModel): tags: list[str] +class BulkTagsRequest(BaseModel): + tags: list[str] + mode: str = "append" # "append" or "replace" + + class CommentAddRequest(BaseModel): text: str diff --git a/backend/routes/events.py b/backend/routes/events.py index a026fd6..68b227c 100644 --- a/backend/routes/events.py +++ b/backend/routes/events.py @@ -8,6 +8,7 @@ from bson import ObjectId from database import events_collection from fastapi import APIRouter, Depends, HTTPException, Query from models.api import ( + BulkTagsRequest, CommentAddRequest, FilterOptionsResponse, PaginatedEventResponse, @@ -31,10 +32,9 @@ def _decode_cursor(cursor: str) -> tuple[str, str]: raise HTTPException(status_code=400, detail="Invalid cursor") from exc -@router.get("/events", response_model=PaginatedEventResponse) -def list_events( +def _build_query( service: str | None = None, - services: list[str] | None = Query(default=None), + services: list[str] | None = None, actor: str | None = None, operation: str | None = None, result: str | None = None, @@ -42,9 +42,9 @@ def list_events( end: str | None = None, search: str | None = None, cursor: str | None = None, - page_size: int = Query(default=50, ge=1, le=500), - user: dict = Depends(require_auth), -): + include_tags: list[str] | None = None, + exclude_tags: list[str] | None = None, +) -> dict: filters = [] if service: @@ -87,6 +87,10 @@ def list_events( ] } ) + if include_tags: + filters.append({"tags": {"$all": include_tags}}) + if exclude_tags: + filters.append({"tags": {"$not": {"$all": exclude_tags}}}) if cursor: try: @@ -102,7 +106,38 @@ def list_events( } ) - query = {"$and": filters} if filters else {} + return {"$and": filters} if filters else {} + + +@router.get("/events", response_model=PaginatedEventResponse) +def list_events( + service: str | None = None, + services: list[str] | None = Query(default=None), + actor: str | None = None, + operation: str | None = None, + result: str | None = None, + start: str | None = None, + end: str | None = None, + search: str | None = None, + cursor: str | None = None, + page_size: int = Query(default=50, ge=1, le=500), + include_tags: list[str] | None = Query(default=None), + exclude_tags: list[str] | None = Query(default=None), + user: dict = Depends(require_auth), +): + query = _build_query( + service=service, + services=services, + actor=actor, + operation=operation, + result=result, + start=start, + end=end, + search=search, + cursor=cursor, + include_tags=include_tags, + exclude_tags=exclude_tags, + ) safe_page_size = max(1, min(page_size, 500)) @@ -138,6 +173,51 @@ def list_events( } +@router.post("/events/bulk-tags") +def bulk_tags( + body: BulkTagsRequest, + service: str | None = None, + services: list[str] | None = Query(default=None), + actor: str | None = None, + operation: str | None = None, + result: str | None = None, + start: str | None = None, + end: str | None = None, + search: str | None = None, + include_tags: list[str] | None = Query(default=None), + exclude_tags: list[str] | None = Query(default=None), + user: dict = Depends(require_auth), +): + query = _build_query( + service=service, + services=services, + actor=actor, + operation=operation, + result=result, + start=start, + end=end, + search=search, + include_tags=include_tags, + exclude_tags=exclude_tags, + ) + tags = [t.strip() for t in body.tags if t.strip()] + if not tags: + raise HTTPException(status_code=400, detail="No tags provided") + + if body.mode == "replace": + update = {"$set": {"tags": tags}} + else: + update = {"$addToSet": {"tags": {"$each": tags}}} + + try: + result_obj = events_collection.update_many(query, update) + except Exception as exc: + raise HTTPException(status_code=500, detail=f"Failed to update tags: {exc}") from exc + + log_action("bulk_tags", "/api/events/bulk-tags", {"tags": tags, "mode": body.mode, "matched": result_obj.matched_count}, user.get("sub", "anonymous")) + return {"matched": result_obj.matched_count, "modified": result_obj.modified_count} + + @router.get("/filter-options", response_model=FilterOptionsResponse) def filter_options(limit: int = Query(default=200, ge=1, le=1000)): safe_limit = max(1, min(limit, 1000)) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 02041ee..a6c00b4 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -241,3 +241,68 @@ def test_rules_crud(client): res5 = client.get("/api/rules") assert res5.status_code == 200 assert len(res5.json()) == 0 + + +def test_list_events_filter_by_include_tags(client, mock_events_collection): + mock_events_collection.insert_one({ + "id": "evt-tagged", + "timestamp": datetime.now(UTC).isoformat(), + "service": "Directory", + "operation": "Add user", + "result": "success", + "actor_display": "Alice", + "raw_text": "", + "tags": ["backup", "auto"], + }) + mock_events_collection.insert_one({ + "id": "evt-untagged", + "timestamp": datetime.now(UTC).isoformat(), + "service": "Directory", + "operation": "Remove user", + "result": "success", + "actor_display": "Bob", + "raw_text": "", + "tags": [], + }) + response = client.get("/api/events?include_tags=backup") + assert response.status_code == 200 + data = response.json() + assert len(data["items"]) == 1 + assert data["items"][0]["id"] == "evt-tagged" + + +def test_bulk_tags_append(client, mock_events_collection): + mock_events_collection.insert_one({ + "id": "evt-bulk", + "timestamp": datetime.now(UTC).isoformat(), + "service": "Exchange", + "operation": "Update", + "result": "success", + "actor_display": "Alice", + "raw_text": "", + "tags": ["existing"], + }) + response = client.post("/api/events/bulk-tags?service=Exchange", json={"tags": ["backup"], "mode": "append"}) + assert response.status_code == 200 + data = response.json() + assert data["matched"] == 1 + doc = mock_events_collection.find_one({"id": "evt-bulk"}) + assert "backup" in doc["tags"] + assert "existing" in doc["tags"] + + +def test_bulk_tags_replace(client, mock_events_collection): + mock_events_collection.insert_one({ + "id": "evt-bulk2", + "timestamp": datetime.now(UTC).isoformat(), + "service": "Exchange", + "operation": "Update", + "result": "success", + "actor_display": "Alice", + "raw_text": "", + "tags": ["old"], + }) + response = client.post("/api/events/bulk-tags?service=Exchange", json={"tags": ["backup"], "mode": "replace"}) + assert response.status_code == 200 + doc = mock_events_collection.find_one({"id": "evt-bulk2"}) + assert doc["tags"] == ["backup"]