@@ -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"]