6 Commits

Author SHA1 Message Date
86966bb57f chore(release): bump version to 1.0.3
Some checks failed
CI / lint-and-test (push) Failing after 21s
Release / build-and-push (push) Failing after 23s
2026-04-16 18:51:12 +02:00
3761aa6d74 feat(tags): add bulk tagging and tag-based filtering
Some checks failed
CI / lint-and-test (push) Failing after 1m24s
- Add include_tags/exclude_tags query params to /api/events
- Add POST /api/events/bulk-tags endpoint with append/replace modes
- Frontend: add Include tags / Exclude tags filter inputs
- Frontend: add Bulk tag matching button with prompt for tag and mode
- Update filter layout to accommodate new tag fields
- Add tests for tag filtering and bulk tag append/replace
2026-04-16 18:50:57 +02:00
6d00d7cf32 ci: use GITHUB_TOKEN secret for Gitea registry login compatibility
Some checks failed
CI / lint-and-test (push) Failing after 2m40s
Release / build-and-push (push) Failing after 20s
2026-04-16 12:12:59 +02:00
de9ea45e1e chore(release): bump version to 1.0.2
Some checks failed
CI / lint-and-test (push) Has been cancelled
Release / build-and-push (push) Has been cancelled
2026-04-16 12:12:08 +02:00
bade860fd4 ci: push Docker images to Gitea container registry on release tags
Some checks failed
CI / lint-and-test (push) Has been cancelled
- Update release workflow to build and push to git.cqre.net/cqrenet/aoc-backend
- Update docker-compose.yml to pull from Gitea registry
2026-04-16 12:11:38 +02:00
9f4601c4d9 ci: migrate workflows from GitHub Actions to Gitea Actions
Some checks failed
CI / lint-and-test (push) Has been cancelled
- Move CI workflow from .github/workflows/ to .gitea/workflows/
- Add Gitea Actions release workflow for tag builds
- Remove GitHub-specific release workflow
2026-04-16 11:55:23 +02:00
9 changed files with 243 additions and 69 deletions

View File

@@ -0,0 +1,22 @@
name: Release
on:
push:
tags:
- "v*"
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Log in to Gitea Container Registry
run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login git.cqre.net -u ${{ gitea.actor }} --password-stdin
- name: Build Docker image
run: docker build ./backend --tag git.cqre.net/cqrenet/aoc-backend:${{ gitea.ref_name }}
- name: Push Docker image
run: docker push git.cqre.net/cqrenet/aoc-backend:${{ gitea.ref_name }}

View File

@@ -1,55 +0,0 @@
name: Release
on:
push:
tags:
- "v*"
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}-backend
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
attestations: write
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Build and push Docker image
id: push
uses: docker/build-push-action@v6
with:
context: ./backend
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Generate artifact attestation
uses: actions/attest-build-provenance@v1
with:
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true

View File

@@ -1 +1 @@
1.0.1 1.0.3

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AOC Events</title> <title>AOC Events</title>
<link rel="stylesheet" href="/style.css?v=7" /> <link rel="stylesheet" href="/style.css?v=8" />
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script> <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<script src="https://alcdn.msauth.net/browser/2.37.0/js/msal-browser.min.js" crossorigin="anonymous"></script> <script src="https://alcdn.msauth.net/browser/2.37.0/js/msal-browser.min.js" crossorigin="anonymous"></script>
</head> </head>
@@ -76,12 +76,20 @@
To To
<input name="end" type="datetime-local" x-model="filters.end" /> <input name="end" type="datetime-local" x-model="filters.end" />
</label> </label>
<label>
Include tags
<input name="includeTags" type="text" placeholder="backup, critical" x-model="filters.includeTags" />
</label>
<label>
Exclude tags
<input name="excludeTags" type="text" placeholder="noise, auto" x-model="filters.excludeTags" />
</label>
</div>
<div class="filter-row">
<label class="span-2"> <label class="span-2">
Search (raw/full-text) Search (raw/full-text)
<input name="search" type="text" placeholder="Any text to search in raw/summary" x-model="filters.search" /> <input name="search" type="text" placeholder="Any text to search in raw/summary" x-model="filters.search" />
</label> </label>
</div>
<div class="filter-row filter-row--tall">
<div class="filter-group span-2"> <div class="filter-group span-2">
<span>App / Service</span> <span>App / Service</span>
<div class="multi-select"> <div class="multi-select">
@@ -104,6 +112,7 @@
<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="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>
@@ -188,7 +197,7 @@
accessToken: null, accessToken: null,
authScopes: [], authScopes: [],
filters: { 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: [] }, options: { actors: [], services: [], operations: [], results: [] },
@@ -320,6 +329,12 @@
if (this.filters.selectedServices && this.filters.selectedServices.length) { if (this.filters.selectedServices && this.filters.selectedServices.length) {
this.filters.selectedServices.forEach((s) => params.append('services', s)); 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) { if (this.filters.start) {
const d = new Date(this.filters.start); const d = new Date(this.filters.start);
if (!isNaN(d.getTime())) params.append('start', d.toISOString()); if (!isNaN(d.getTime())) params.append('start', d.toISOString());
@@ -417,11 +432,53 @@
}, },
clearFilters() { 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.resetPagination();
this.loadEvents(); 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) { displayActor(e) {
const app = e.actor?.application || e.actor?.app; const app = e.actor?.application || e.actor?.app;
if (app?.displayName) return app.displayName; if (app?.displayName) return app.displayName;

View File

@@ -54,6 +54,11 @@ class TagsUpdateRequest(BaseModel):
tags: list[str] tags: list[str]
class BulkTagsRequest(BaseModel):
tags: list[str]
mode: str = "append" # "append" or "replace"
class CommentAddRequest(BaseModel): class CommentAddRequest(BaseModel):
text: str text: str

View File

@@ -8,6 +8,7 @@ from bson import ObjectId
from database import events_collection from database import events_collection
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from models.api import ( from models.api import (
BulkTagsRequest,
CommentAddRequest, CommentAddRequest,
FilterOptionsResponse, FilterOptionsResponse,
PaginatedEventResponse, PaginatedEventResponse,
@@ -31,10 +32,9 @@ def _decode_cursor(cursor: str) -> tuple[str, str]:
raise HTTPException(status_code=400, detail="Invalid cursor") from exc raise HTTPException(status_code=400, detail="Invalid cursor") from exc
@router.get("/events", response_model=PaginatedEventResponse) def _build_query(
def list_events(
service: str | None = None, service: str | None = None,
services: list[str] | None = Query(default=None), services: list[str] | None = None,
actor: str | None = None, actor: str | None = None,
operation: str | None = None, operation: str | None = None,
result: str | None = None, result: str | None = None,
@@ -42,9 +42,9 @@ def list_events(
end: str | None = None, end: str | None = None,
search: str | None = None, search: str | None = None,
cursor: str | None = None, cursor: str | None = None,
page_size: int = Query(default=50, ge=1, le=500), include_tags: list[str] | None = None,
user: dict = Depends(require_auth), exclude_tags: list[str] | None = None,
): ) -> dict:
filters = [] filters = []
if service: 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: if cursor:
try: 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)) 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) @router.get("/filter-options", response_model=FilterOptionsResponse)
def filter_options(limit: int = Query(default=200, ge=1, le=1000)): def filter_options(limit: int = Query(default=200, ge=1, le=1000)):
safe_limit = max(1, min(limit, 1000)) safe_limit = max(1, min(limit, 1000))

View File

@@ -241,3 +241,68 @@ def test_rules_crud(client):
res5 = client.get("/api/rules") res5 = client.get("/api/rules")
assert res5.status_code == 200 assert res5.status_code == 200
assert len(res5.json()) == 0 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"]

View File

@@ -13,7 +13,7 @@ services:
backend: backend:
# For local development you can switch back to: build: ./backend # For local development you can switch back to: build: ./backend
image: ghcr.io/cqrenet/aoc-backend:v1.0.1 image: git.cqre.net/cqrenet/aoc-backend:v1.0.3
container_name: aoc-backend container_name: aoc-backend
restart: always restart: always
env_file: env_file: