Files
security-alert-center/frontend/src/views/EventsView.vue
T

221 lines
6.1 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<h1>События</h1>
<div class="filters">
<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="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>
<template v-else>
<p>Всего: {{ data?.total ?? 0 }}</p>
<table>
<thead>
<tr>
<th>ID</th>
<th>Время</th>
<th>Хост</th>
<th>Имя сервера</th>
<th>Пользователь</th>
<th>Версия агента</th>
<th>Severity</th>
<th>Type</th>
<th>Title</th>
</tr>
</thead>
<tbody>
<tr v-for="e in data?.items ?? []" :key="e.id">
<td>
<RouterLink :to="{ path: `/events/${e.id}`, query: detailLinkQuery }">{{ e.id }}</RouterLink>
</td>
<td>{{ formatDt(e.occurred_at) }}</td>
<td>{{ e.hostname }}</td>
<td>{{ formatServerName(e.display_name) }}</td>
<td>{{ e.actor_user || "—" }}</td>
<td>{{ e.product_version || "—" }}</td>
<td :class="'sev-' + e.severity">{{ e.severity }}</td>
<td><code>{{ e.type }}</code></td>
<td>{{ e.title }}</td>
</tr>
</tbody>
</table>
<div class="filters" style="margin-top: 1rem">
<button type="button" class="secondary" :disabled="page <= 1" @click="load(page - 1)">Назад</button>
<span>Стр. {{ page }}</span>
<button
type="button"
class="secondary"
:disabled="!data || page * pageSize >= data.total"
@click="load(page + 1)"
>
Вперёд
</button>
</div>
</template>
</template>
<script setup lang="ts">
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);
const error = ref("");
const page = ref(1);
const pageSize = 50;
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");
}
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 = 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 {
loading.value = false;
}
}
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>