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:
@@ -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,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}"
|
||||||
|
|||||||
@@ -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,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",
|
||||||
|
|||||||
@@ -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,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,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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user