77e167b6e3
Co-authored-by: Cursor <cursoragent@cursor.com>
221 lines
6.1 KiB
Vue
221 lines
6.1 KiB
Vue
<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>
|