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
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "sac-ui",
"private": true,
"version": "0.7.0",
"version": "0.7.1",
"type": "module",
"scripts": {
"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_VERSION = "0.7.0";
export const APP_VERSION = "0.7.1";
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-else to="/events"> События</RouterLink>
<RouterLink v-else :to="{ path: '/events', query: backToEventsQuery }"> События</RouterLink>
</p>
@@ -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) {
+129 -26
View File
@@ -1,17 +1,26 @@
<template>
<h1>События</h1>
<div class="filters">
<input v-model="q" placeholder="Поиск в summary/title" @keyup.enter="load(1)" />
<select v-model="severity" @change="load(1)">
<input v-model="q" placeholder="Поиск в summary/title" @keyup.enter="applyFilters" />
<select v-model="severity" @change="applyFilters">
<option value="">Все severity</option>
<option>info</option>
<option>warning</option>
<option>high</option>
<option>critical</option>
</select>
<input v-model="typeFilter" placeholder="type" @keyup.enter="load(1)" />
<input v-model="hostnameFilter" placeholder="hostname / имя сервера" @keyup.enter="load(1)" />
<button type="button" @click="load(1)">Применить</button>
<input v-model="typeFilter" placeholder="type" @keyup.enter="applyFilters" />
<input v-model="hostnameFilter" placeholder="hostname / имя сервера" @keyup.enter="applyFilters" />
<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>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading">Загрузка</p>
@@ -32,7 +41,7 @@
<tbody>
<tr v-for="e in data?.items ?? []" :key="e.id">
<td>
<RouterLink :to="`/events/${e.id}`">{{ e.id }}</RouterLink>
<RouterLink :to="{ path: `/events/${e.id}`, query: detailLinkQuery }">{{ e.id }}</RouterLink>
</td>
<td>{{ formatDt(e.occurred_at) }}</td>
<td>{{ e.hostname }}</td>
@@ -59,12 +68,20 @@
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { useRoute } from "vue-router";
import { computed, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { apiFetch, type EventListResponse } from "../api";
import {
apiParamsFromState,
buildEventsListQuery,
eventsListQueryMatches,
parseEventsListQuery,
type EventsListState,
} from "../utils/eventsListQuery";
import { formatServerName } from "../utils/hostDisplay";
const route = useRoute();
const router = useRouter();
const data = ref<EventListResponse | null>(null);
const loading = ref(false);
@@ -75,25 +92,72 @@ const q = ref("");
const severity = ref("");
const typeFilter = 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) {
return new Date(iso).toLocaleString("ru-RU");
}
async function load(p: number) {
page.value = p;
function applyStateFromQuery() {
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;
error.value = "";
try {
const params = new URLSearchParams({
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());
const params = apiParamsFromState(listState.value, pageSize);
data.value = await apiFetch<EventListResponse>(`/api/v1/events?${params}`);
if (options?.syncUrl !== false && !syncingFromRoute.value) {
await syncRouteQuery();
}
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка загрузки";
} finally {
@@ -101,13 +165,52 @@ async function load(p: number) {
}
}
onMounted(() => {
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;
function applyFilters() {
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>
<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>