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:
@@ -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_VERSION = "0.7.0";
|
||||
export const APP_VERSION = "0.7.1";
|
||||
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-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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user