feat: events date filter and preserve list page in URL (SAC 0.7.1)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-01 10:32:24 +10:00
parent de29270a25
commit 06a8ed8614
8 changed files with 230 additions and 36 deletions
+12 -4
View File
@@ -1,5 +1,5 @@
import logging import logging
from datetime import datetime from datetime import datetime, timezone
from typing import Any from typing import Any
from fastapi import APIRouter, Body, Depends, HTTPException, Query 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")) 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: if not value:
return None 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) @router.get("", response_model=EventListResponse)
@@ -137,7 +145,7 @@ def list_events(
stmt = stmt.where(Host.hostname.ilike(like) | Host.display_name.ilike(like)) 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)) count_stmt = count_stmt.where(Host.hostname.ilike(like) | Host.display_name.ilike(like))
dt_from = _parse_optional_dt(from_time) 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: if dt_from:
stmt = stmt.where(Event.occurred_at >= dt_from) stmt = stmt.where(Event.occurred_at >= dt_from)
count_stmt = count_stmt.where(Event.occurred_at >= dt_from) count_stmt = count_stmt.where(Event.occurred_at >= dt_from)
+1 -1
View File
@@ -1,5 +1,5 @@
"""Единый источник версии SAC (API, health, логи, OpenAPI).""" """Единый источник версии SAC (API, health, логи, OpenAPI)."""
APP_NAME = "Security Alert Center" APP_NAME = "Security Alert Center"
APP_VERSION = "0.7.0" APP_VERSION = "0.7.1"
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}" APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
+2 -2
View File
@@ -4,6 +4,6 @@ from app.version import APP_NAME, APP_VERSION, APP_VERSION_LABEL
def test_version_constants(): def test_version_constants():
assert APP_VERSION == "0.7.0" assert APP_VERSION == "0.7.1"
assert APP_NAME == "Security Alert Center" 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"
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "sac-ui", "name": "sac-ui",
"private": true, "private": true,
"version": "0.7.0", "version": "0.7.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+80
View File
@@ -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<string, string> {
const q: Record<string, string> = {};
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<string, string> {
const out: Record<string, string> = {};
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;
}
+1 -1
View File
@@ -1,3 +1,3 @@
export const APP_NAME = "Security Alert Center"; 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}`; export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
+4 -1
View File
@@ -4,7 +4,7 @@
<RouterLink v-if="fromReports" :to="{ path: '/reports', query: backToReportsQuery }"> Отчёты</RouterLink> <RouterLink v-if="fromReports" :to="{ path: '/reports', query: backToReportsQuery }"> Отчёты</RouterLink>
<RouterLink v-else to="/events"> События</RouterLink> <RouterLink v-else :to="{ path: '/events', query: backToEventsQuery }"> События</RouterLink>
</p> </p>
@@ -105,6 +105,7 @@ import { apiFetch, type EventDetail } from "../api";
import ReportBodyCard from "../components/ReportBodyCard.vue"; import ReportBodyCard from "../components/ReportBodyCard.vue";
import { eventsBackQueryFromDetail } from "../utils/eventsListQuery";
import { isDailyReportType } from "../utils/reportDisplay"; import { isDailyReportType } from "../utils/reportDisplay";
@@ -134,6 +135,8 @@ const backToReportsQuery = computed(() => {
return q; return q;
}); });
const backToEventsQuery = computed(() => eventsBackQueryFromDetail(route.query));
function formatDt(iso: string) { function formatDt(iso: string) {
+129 -26
View File
@@ -1,17 +1,26 @@
<template> <template>
<h1>События</h1> <h1>События</h1>
<div class="filters"> <div class="filters">
<input v-model="q" placeholder="Поиск в summary/title" @keyup.enter="load(1)" /> <input v-model="q" placeholder="Поиск в summary/title" @keyup.enter="applyFilters" />
<select v-model="severity" @change="load(1)"> <select v-model="severity" @change="applyFilters">
<option value="">Все severity</option> <option value="">Все severity</option>
<option>info</option> <option>info</option>
<option>warning</option> <option>warning</option>
<option>high</option> <option>high</option>
<option>critical</option> <option>critical</option>
</select> </select>
<input v-model="typeFilter" placeholder="type" @keyup.enter="load(1)" /> <input v-model="typeFilter" placeholder="type" @keyup.enter="applyFilters" />
<input v-model="hostnameFilter" placeholder="hostname / имя сервера" @keyup.enter="load(1)" /> <input v-model="hostnameFilter" placeholder="hostname / имя сервера" @keyup.enter="applyFilters" />
<button type="button" @click="load(1)">Применить</button> <label class="events-date-field">
<span class="events-date-label">С</span>
<input v-model="dateFrom" type="date" @change="applyFilters" />
</label>
<label class="events-date-field">
<span class="events-date-label">По</span>
<input v-model="dateTo" type="date" @change="applyFilters" />
</label>
<button type="button" @click="applyFilters">Применить</button>
<button v-if="hasActiveFilters" type="button" class="secondary" @click="clearFilters">Сбросить</button>
</div> </div>
<p v-if="error" class="error">{{ error }}</p> <p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading">Загрузка</p> <p v-else-if="loading">Загрузка</p>
@@ -32,7 +41,7 @@
<tbody> <tbody>
<tr v-for="e in data?.items ?? []" :key="e.id"> <tr v-for="e in data?.items ?? []" :key="e.id">
<td> <td>
<RouterLink :to="`/events/${e.id}`">{{ e.id }}</RouterLink> <RouterLink :to="{ path: `/events/${e.id}`, query: detailLinkQuery }">{{ e.id }}</RouterLink>
</td> </td>
<td>{{ formatDt(e.occurred_at) }}</td> <td>{{ formatDt(e.occurred_at) }}</td>
<td>{{ e.hostname }}</td> <td>{{ e.hostname }}</td>
@@ -59,12 +68,20 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from "vue"; import { computed, ref, watch } from "vue";
import { useRoute } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { apiFetch, type EventListResponse } from "../api"; import { apiFetch, type EventListResponse } from "../api";
import {
apiParamsFromState,
buildEventsListQuery,
eventsListQueryMatches,
parseEventsListQuery,
type EventsListState,
} from "../utils/eventsListQuery";
import { formatServerName } from "../utils/hostDisplay"; import { formatServerName } from "../utils/hostDisplay";
const route = useRoute(); const route = useRoute();
const router = useRouter();
const data = ref<EventListResponse | null>(null); const data = ref<EventListResponse | null>(null);
const loading = ref(false); const loading = ref(false);
@@ -75,25 +92,72 @@ const q = ref("");
const severity = ref(""); const severity = ref("");
const typeFilter = ref(""); const typeFilter = ref("");
const hostnameFilter = ref(""); const hostnameFilter = ref("");
const dateFrom = ref("");
const dateTo = ref("");
const syncingFromRoute = ref(false);
const listState = computed(
(): EventsListState => ({
page: page.value,
q: q.value,
severity: severity.value,
type: typeFilter.value,
hostname: hostnameFilter.value,
dateFrom: dateFrom.value,
dateTo: dateTo.value,
}),
);
const detailLinkQuery = computed(() => buildEventsListQuery(listState.value));
const hasActiveFilters = computed(
() =>
Boolean(
q.value.trim() ||
severity.value ||
typeFilter.value.trim() ||
hostnameFilter.value.trim() ||
dateFrom.value ||
dateTo.value,
),
);
function formatDt(iso: string) { function formatDt(iso: string) {
return new Date(iso).toLocaleString("ru-RU"); return new Date(iso).toLocaleString("ru-RU");
} }
async function load(p: number) { function applyStateFromQuery() {
page.value = p; const parsed = parseEventsListQuery(route.query);
page.value = parsed.page;
q.value = parsed.q;
severity.value = parsed.severity;
typeFilter.value = parsed.type;
hostnameFilter.value = parsed.hostname;
dateFrom.value = parsed.dateFrom;
dateTo.value = parsed.dateTo;
}
async function syncRouteQuery() {
if (eventsListQueryMatches(listState.value, route.query)) return;
syncingFromRoute.value = true;
try {
await router.replace({ path: "/events", query: buildEventsListQuery(listState.value) });
} finally {
syncingFromRoute.value = false;
}
}
async function load(p?: number, options?: { syncUrl?: boolean }) {
if (p !== undefined) page.value = p;
loading.value = true; loading.value = true;
error.value = ""; error.value = "";
try { try {
const params = new URLSearchParams({ const params = apiParamsFromState(listState.value, pageSize);
page: String(page.value),
page_size: String(pageSize),
});
if (severity.value) params.set("severity", severity.value);
if (typeFilter.value) params.set("type", typeFilter.value);
if (hostnameFilter.value.trim()) params.set("hostname", hostnameFilter.value.trim());
if (q.value.trim()) params.set("q", q.value.trim());
data.value = await apiFetch<EventListResponse>(`/api/v1/events?${params}`); data.value = await apiFetch<EventListResponse>(`/api/v1/events?${params}`);
if (options?.syncUrl !== false && !syncingFromRoute.value) {
await syncRouteQuery();
}
} catch (e) { } catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка загрузки"; error.value = e instanceof Error ? e.message : "Ошибка загрузки";
} finally { } finally {
@@ -101,13 +165,52 @@ async function load(p: number) {
} }
} }
onMounted(() => { function applyFilters() {
const t = route.query.type;
if (typeof t === "string" && t) typeFilter.value = t;
const sev = route.query.severity;
if (typeof sev === "string" && sev) severity.value = sev;
const h = route.query.hostname;
if (typeof h === "string" && h) hostnameFilter.value = h;
load(1); load(1);
}); }
function clearFilters() {
q.value = "";
severity.value = "";
typeFilter.value = "";
hostnameFilter.value = "";
dateFrom.value = "";
dateTo.value = "";
load(1);
}
watch(
() => route.fullPath,
async () => {
if (route.path !== "/events") return;
const matched = eventsListQueryMatches(listState.value, route.query);
if (!matched) {
syncingFromRoute.value = true;
applyStateFromQuery();
syncingFromRoute.value = false;
}
if (!matched || !data.value) {
await load(undefined, { syncUrl: false });
}
},
{ immediate: true },
);
</script> </script>
<style scoped>
.events-date-field {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.events-date-label {
color: #9aa4b2;
font-size: 0.9rem;
white-space: nowrap;
}
.events-date-field input[type="date"] {
min-width: 9.5rem;
}
</style>