From 06a8ed86148b2d969c059c7ab9a04cb6fdb8024b Mon Sep 17 00:00:00 2001
From: PTah
Date: Mon, 1 Jun 2026 10:32:24 +1000
Subject: [PATCH] feat: events date filter and preserve list page in URL (SAC
0.7.1)
Co-authored-by: Cursor
---
backend/app/api/v1/events.py | 16 ++-
backend/app/version.py | 2 +-
backend/tests/test_health.py | 4 +-
frontend/package.json | 2 +-
frontend/src/utils/eventsListQuery.ts | 80 +++++++++++++
frontend/src/version.ts | 2 +-
frontend/src/views/EventDetailView.vue | 5 +-
frontend/src/views/EventsView.vue | 155 ++++++++++++++++++++-----
8 files changed, 230 insertions(+), 36 deletions(-)
create mode 100644 frontend/src/utils/eventsListQuery.ts
diff --git a/backend/app/api/v1/events.py b/backend/app/api/v1/events.py
index 13aacd4..847558b 100644
--- a/backend/app/api/v1/events.py
+++ b/backend/app/api/v1/events.py
@@ -1,5 +1,5 @@
import logging
-from datetime import datetime
+from datetime import datetime, timezone
from typing import Any
from fastapi import APIRouter, Body, Depends, HTTPException, Query
@@ -100,10 +100,18 @@ def post_event(
return JSONResponse(status_code=status_code, content=body.model_dump(mode="json"))
-def _parse_optional_dt(value: str | None) -> datetime | None:
+def _parse_optional_dt(value: str | None, *, end_of_day: bool = False) -> datetime | None:
if not value:
return None
- return datetime.fromisoformat(value.replace("Z", "+00:00"))
+ raw = value.strip()
+ if len(raw) == 10 and raw[4] == "-" and raw[7] == "-":
+ dt = datetime.fromisoformat(raw)
+ if dt.tzinfo is None:
+ dt = dt.replace(tzinfo=timezone.utc)
+ if end_of_day:
+ return dt.replace(hour=23, minute=59, second=59, microsecond=999999)
+ return dt.replace(hour=0, minute=0, second=0, microsecond=0)
+ return datetime.fromisoformat(raw.replace("Z", "+00:00"))
@router.get("", response_model=EventListResponse)
@@ -137,7 +145,7 @@ def list_events(
stmt = stmt.where(Host.hostname.ilike(like) | Host.display_name.ilike(like))
count_stmt = count_stmt.where(Host.hostname.ilike(like) | Host.display_name.ilike(like))
dt_from = _parse_optional_dt(from_time)
- dt_to = _parse_optional_dt(to_time)
+ dt_to = _parse_optional_dt(to_time, end_of_day=True)
if dt_from:
stmt = stmt.where(Event.occurred_at >= dt_from)
count_stmt = count_stmt.where(Event.occurred_at >= dt_from)
diff --git a/backend/app/version.py b/backend/app/version.py
index 37fd662..5759b2c 100644
--- a/backend/app/version.py
+++ b/backend/app/version.py
@@ -1,5 +1,5 @@
"""Единый источник версии SAC (API, health, логи, OpenAPI)."""
APP_NAME = "Security Alert Center"
-APP_VERSION = "0.7.0"
+APP_VERSION = "0.7.1"
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py
index 3ecb3ba..a4e941e 100644
--- a/backend/tests/test_health.py
+++ b/backend/tests/test_health.py
@@ -4,6 +4,6 @@ from app.version import APP_NAME, APP_VERSION, APP_VERSION_LABEL
def test_version_constants():
- assert APP_VERSION == "0.7.0"
+ assert APP_VERSION == "0.7.1"
assert APP_NAME == "Security Alert Center"
- assert APP_VERSION_LABEL == "Security Alert Center v.0.7.0"
+ assert APP_VERSION_LABEL == "Security Alert Center v.0.7.1"
diff --git a/frontend/package.json b/frontend/package.json
index 3fd9200..5cf835a 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "sac-ui",
"private": true,
- "version": "0.7.0",
+ "version": "0.7.1",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/frontend/src/utils/eventsListQuery.ts b/frontend/src/utils/eventsListQuery.ts
new file mode 100644
index 0000000..4ce30d1
--- /dev/null
+++ b/frontend/src/utils/eventsListQuery.ts
@@ -0,0 +1,80 @@
+/** Query params for /events list — preserved when opening event detail and going back. */
+
+import type { LocationQuery } from "vue-router";
+
+export interface EventsListState {
+ page: number;
+ q: string;
+ severity: string;
+ type: string;
+ hostname: string;
+ dateFrom: string;
+ dateTo: string;
+}
+
+const LIST_KEYS = ["page", "q", "severity", "type", "hostname", "from", "to"] as const;
+
+function queryString(value: unknown): string {
+ if (typeof value === "string") return value;
+ if (Array.isArray(value) && typeof value[0] === "string") return value[0];
+ return "";
+}
+
+export function parseEventsListQuery(query: LocationQuery): EventsListState {
+ const pageRaw = parseInt(queryString(query.page), 10);
+ return {
+ page: Number.isFinite(pageRaw) && pageRaw > 0 ? pageRaw : 1,
+ q: queryString(query.q),
+ severity: queryString(query.severity),
+ type: queryString(query.type),
+ hostname: queryString(query.hostname),
+ dateFrom: queryString(query.from),
+ dateTo: queryString(query.to),
+ };
+}
+
+export function buildEventsListQuery(state: EventsListState): Record {
+ const q: Record = {};
+ if (state.page > 1) q.page = String(state.page);
+ if (state.q.trim()) q.q = state.q.trim();
+ if (state.severity) q.severity = state.severity;
+ if (state.type.trim()) q.type = state.type.trim();
+ if (state.hostname.trim()) q.hostname = state.hostname.trim();
+ if (state.dateFrom) q.from = state.dateFrom;
+ if (state.dateTo) q.to = state.dateTo;
+ return q;
+}
+
+/** Subset of detail route query to restore the events list (excludes `from=reports`). */
+export function eventsBackQueryFromDetail(query: LocationQuery): Record {
+ const out: Record = {};
+ for (const key of LIST_KEYS) {
+ const v = queryString(query[key]);
+ if (v) out[key] = v;
+ }
+ return out;
+}
+
+export function apiParamsFromState(state: EventsListState, pageSize: number): URLSearchParams {
+ const params = new URLSearchParams({
+ page: String(state.page),
+ page_size: String(pageSize),
+ });
+ if (state.severity) params.set("severity", state.severity);
+ if (state.type.trim()) params.set("type", state.type.trim());
+ if (state.hostname.trim()) params.set("hostname", state.hostname.trim());
+ if (state.q.trim()) params.set("q", state.q.trim());
+ if (state.dateFrom) params.set("from", state.dateFrom);
+ if (state.dateTo) params.set("to", state.dateTo);
+ return params;
+}
+
+export function eventsListQueryMatches(state: EventsListState, query: LocationQuery): boolean {
+ const built = buildEventsListQuery(state);
+ const fromRoute = buildEventsListQuery(parseEventsListQuery(query));
+ const keys = new Set([...Object.keys(built), ...Object.keys(fromRoute)]);
+ for (const key of keys) {
+ if ((built[key] ?? "") !== (fromRoute[key] ?? "")) return false;
+ }
+ return true;
+}
diff --git a/frontend/src/version.ts b/frontend/src/version.ts
index 36d05c4..ce77a57 100644
--- a/frontend/src/version.ts
+++ b/frontend/src/version.ts
@@ -1,3 +1,3 @@
export const APP_NAME = "Security Alert Center";
-export const APP_VERSION = "0.7.0";
+export const APP_VERSION = "0.7.1";
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
diff --git a/frontend/src/views/EventDetailView.vue b/frontend/src/views/EventDetailView.vue
index c717395..638378c 100644
--- a/frontend/src/views/EventDetailView.vue
+++ b/frontend/src/views/EventDetailView.vue
@@ -4,7 +4,7 @@
← Отчёты
- ← События
+ ← События
@@ -105,6 +105,7 @@ import { apiFetch, type EventDetail } from "../api";
import ReportBodyCard from "../components/ReportBodyCard.vue";
+import { eventsBackQueryFromDetail } from "../utils/eventsListQuery";
import { isDailyReportType } from "../utils/reportDisplay";
@@ -134,6 +135,8 @@ const backToReportsQuery = computed(() => {
return q;
});
+const backToEventsQuery = computed(() => eventsBackQueryFromDetail(route.query));
+
function formatDt(iso: string) {
diff --git a/frontend/src/views/EventsView.vue b/frontend/src/views/EventsView.vue
index be4d350..1015d76 100644
--- a/frontend/src/views/EventsView.vue
+++ b/frontend/src/views/EventsView.vue
@@ -1,17 +1,26 @@
События
-
-
{{ error }}
Загрузка…
@@ -32,7 +41,7 @@
|
- {{ e.id }}
+ {{ e.id }}
|
{{ formatDt(e.occurred_at) }} |
{{ e.hostname }} |
@@ -59,12 +68,20 @@
+
+