Files
security-alert-center/frontend/src/views/DashboardView.vue
T
PapaTramp 985d51c4ba feat: show RDP session duration in Overview and Events (0.5.16)
Add session_duration_sec to EventSummary and a Duration column (HH:MM:SS / Nd HH:MM:SS).
2026-07-14 16:18:50 +10:00

665 lines
12 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>
<div class="overview-page">
<h1>Обзор</h1>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading">Загрузка</p>
<template v-else-if="data">
<div class="dashboard-cards">
<div class="dash-card">
<div class="dash-value">{{ data.events_last_24h }}</div>
<div class="dash-label">Событий за 24 ч</div>
<RouterLink to="/events">Все события </RouterLink>
</div>
<div class="dash-card">
<div class="dash-value">{{ data.problems_open }}</div>
<div class="dash-label">Открытых проблем</div>
<RouterLink :to="{ path: '/problems', query: { status: 'open' } }">Проблемы </RouterLink>
</div>
<div class="dash-card">
<div class="dash-value">{{ data.problems_opened_24h }}</div>
<div class="dash-label">Новых проблем за 24 ч</div>
<RouterLink :to="{ path: '/problems', query: { created_within_hours: '24' } }">Новые за 24 ч </RouterLink>
</div>
<div class="dash-card">
<div class="dash-value">{{ data.problems_resolved_24h }}</div>
<div class="dash-label">Закрыто за 24 ч</div>
<RouterLink :to="{ path: '/problems', query: { resolved_within_hours: '24' } }">Закрытые за 24 ч </RouterLink>
</div>
<div class="dash-card">
<div class="dash-value">{{ data.hosts_total }}</div>
<div class="dash-label">Хостов</div>
<RouterLink to="/hosts">Хосты </RouterLink>
</div>
<div class="dash-card" :class="{ 'dash-warn': data.hosts_stale > 0 }">
<div class="dash-value">{{ data.hosts_stale }}</div>
<div class="dash-label">Устаревший heartbeat</div>
<RouterLink :to="{ path: '/hosts', query: { agent_status: 'stale' } }">Stale-хосты </RouterLink>
</div>
<div class="dash-card">
<div class="dash-value">{{ data.heartbeats_24h }}</div>
<div class="dash-label">Heartbeat за 24 ч</div>
<RouterLink :to="{ path: '/events', query: { type: 'agent.heartbeat' } }">События </RouterLink>
</div>
<div class="dash-card">
<div class="dash-value">{{ data.daily_reports_24h }}</div>
<div class="dash-label">Отчёты за 24 ч</div>
<RouterLink to="/reports">Отчёты </RouterLink>
</div>
</div>
<div class="dashboard-panels">
<section class="dash-panel">
<h2>Топ хостов (24 ч)</h2>
<div v-if="data.top_hosts.length" class="dash-table-scroll">
<table class="dash-table">
<thead>
<tr>
<th>Хост</th>
<th>Событий</th>
</tr>
</thead>
<tbody>
<tr v-for="h in data.top_hosts" :key="h.host_id">
<td>
<RouterLink :to="{ path: '/events', query: { hostname: h.hostname } }">
{{ h.hostname }}
</RouterLink>
</td>
<td>{{ h.count }}</td>
</tr>
</tbody>
</table>
</div>
<p v-else class="muted">Нет событий за 24 ч</p>
</section>
<section class="dash-panel">
<h2>Топ типов (24 ч)</h2>
<div v-if="data.top_event_types.length" class="dash-table-scroll">
<table class="dash-table">
<thead>
<tr>
<th>Type</th>
<th>Событий</th>
</tr>
</thead>
<tbody>
<tr v-for="t in data.top_event_types" :key="t.type">
<td>
<RouterLink :to="{ path: '/events', query: { type: t.type } }">
<code>{{ t.type }}</code>
</RouterLink>
</td>
<td>{{ t.count }}</td>
</tr>
</tbody>
</table>
</div>
<p v-else class="muted">Нет событий за 24 ч</p>
</section>
</div>
<h2>Severity за 24 ч</h2>
<ul class="severity-list">
<li v-for="(count, sev) in data.severity_24h" :key="sev">
<RouterLink :to="{ path: '/events', query: { severity: sev } }">
<span :class="'sev-' + sev">{{ sev }}</span>: {{ count }}
</RouterLink>
</li>
<li v-if="!Object.keys(data.severity_24h).length">Нет событий</li>
</ul>
<h2>Последние события</h2>
<p class="muted dash-recent-hint">
Обновляется при поступлении событий (live, ~5 с). Время момент приёма SAC.
</p>
<table>
<thead>
<tr>
<th>ID</th>
<th>Получено</th>
<th>Хост</th>
<th>Имя сервера</th>
<th>Пользователь</th>
<th title="Длительность сессии (из session_duration_sec)">Длительность</th>
<th>Версия агента</th>
<th>Severity</th>
<th>Title</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
<tr v-for="e in data.recent_events" :key="e.id">
<td>
<RouterLink :to="`/events/${e.id}`">{{ e.id }}</RouterLink>
</td>
<td>{{ formatDt(e.received_at) }}</td>
<td>
<RouterLink :to="{ path: '/events', query: { hostname: e.hostname } }">
{{ e.hostname }}
</RouterLink>
</td>
<td>{{ formatServerName(e.display_name) }}</td>
<td>{{ e.actor_user || "—" }}</td>
<td>{{ formatSessionDuration(e.session_duration_sec) }}</td>
<td>{{ e.product_version || "—" }}</td>
<td :class="'sev-' + e.severity">{{ e.severity }}</td>
<td>
<RdgAccessBadge v-if="e.rdg_access_path" :event="e" />
<RdgFlapBadge v-if="hasRdgFlapUi(e)" :event="e" />
{{ e.title }}
</td>
<td class="dash-actions">
<button
v-if="rdgQwinstaEventId(e)"
type="button"
class="secondary dash-qwinsta-btn"
:disabled="qwinstaLoadingId === e.id"
title="qwinsta и завершение сеанса на рабочем ПК"
@click="runQwinsta(e)"
>
Обрыв RDS
</button>
<button
v-if="showEventSessionTerminateButton(e)"
type="button"
class="secondary dash-qwinsta-btn"
:disabled="sessionTerminateLoadingId === e.id"
@click="runSessionTerminate(e)"
>
{{ sessionTerminateLoadingId === e.id ? "…" : "Завершить" }}
</button>
</td>
</tr>
</tbody>
</table>
<RdgQwinstaModal
:modal="qwinstaModal"
@close="closeQwinstaModal"
@logoff="runLogoff"
/>
<div class="filters" style="margin-top: 1rem">
<button type="button" class="secondary" @click="load">Обновить всё</button>
<span v-if="live" class="live-badge">live</span>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from "vue";
import {
apiFetch,
ApiError,
getToken,
postEventTerminateSession,
type DashboardSummary,
type EventSummary,
} from "../api";
import RdgAccessBadge from "../components/RdgAccessBadge.vue";
import RdgFlapBadge from "../components/RdgFlapBadge.vue";
import RdgQwinstaModal from "../components/RdgQwinstaModal.vue";
import { useRdgQwinsta } from "../composables/useRdgQwinsta";
import { formatServerName } from "../utils/hostDisplay";
import { hasRdgFlapUi, rdgQwinstaEventId } from "../utils/rdgFlap";
import { formatSessionDuration } from "../utils/sessionDuration";
import { markEventSessionTerminatedInList, showEventSessionTerminateButton } from "../utils/sessionActions";
const { qwinstaLoadingId, qwinstaModal, closeQwinstaModal, runQwinsta, runLogoff } = useRdgQwinsta();
const sessionTerminateLoadingId = ref<number | null>(null);
const data = ref<DashboardSummary | null>(null);
const loading = ref(false);
const error = ref("");
const live = ref(false);
const lastKnownEventDbId = ref<number | null>(null);
let eventSource: EventSource | null = null;
let refreshingRecent = false;
function formatDt(iso: string) {
return new Date(iso).toLocaleString("ru-RU");
}
async function runSessionTerminate(event: EventSummary) {
const label = event.actor_user || event.title;
if (!window.confirm(`Завершить сессию пользователя ${label}?`)) return;
sessionTerminateLoadingId.value = event.id;
try {
const res = await postEventTerminateSession(event.id);
if (!res.ok) {
window.alert(res.message);
} else {
markEventSessionTerminatedInList(data.value?.recent_events, event.id);
}
} catch (e) {
if (e instanceof ApiError && e.status === 409) {
markEventSessionTerminatedInList(data.value?.recent_events, event.id);
}
window.alert(e instanceof Error ? e.message : "Ошибка завершения сессии");
} finally {
sessionTerminateLoadingId.value = null;
}
}
function syncLastKnownFromRecent() {
const top = data.value?.recent_events?.[0];
lastKnownEventDbId.value = top?.id ?? null;
}
async function refreshRecentEvents() {
if (!data.value || refreshingRecent) return;
refreshingRecent = true;
try {
const events = await apiFetch<EventSummary[]>(
"/api/v1/dashboards/recent-events?limit=8",
);
data.value.recent_events = events;
syncLastKnownFromRecent();
} catch {
/* keep table on transient errors */
} finally {
refreshingRecent = false;
}
}
async function load() {
loading.value = true;
error.value = "";
try {
data.value = await apiFetch<DashboardSummary>("/api/v1/dashboards/summary");
syncLastKnownFromRecent();
} catch (e) {
error.value = e instanceof Error ? e.message : String(e);
} finally {
loading.value = false;
}
}
function applyDashboardCounters(msg: {
events_last_24h?: number;
problems_open?: number;
hosts_stale?: number;
heartbeats_24h?: number;
daily_reports_24h?: number;
last_event_id?: number | null;
}) {
if (!data.value) return;
if (typeof msg.events_last_24h === "number") {
data.value.events_last_24h = msg.events_last_24h;
}
if (typeof msg.problems_open === "number") {
data.value.problems_open = msg.problems_open;
}
if (typeof msg.hosts_stale === "number") {
data.value.hosts_stale = msg.hosts_stale;
}
if (typeof msg.heartbeats_24h === "number") {
data.value.heartbeats_24h = msg.heartbeats_24h;
}
if (typeof msg.daily_reports_24h === "number") {
data.value.daily_reports_24h = msg.daily_reports_24h;
}
const incoming = msg.last_event_id;
if (incoming === undefined) return;
const prev = lastKnownEventDbId.value;
if (incoming === null) {
if (prev !== null) void refreshRecentEvents();
lastKnownEventDbId.value = null;
return;
}
if (prev === null || incoming !== prev) {
void refreshRecentEvents();
}
lastKnownEventDbId.value = incoming;
}
function connectLive() {
const token = getToken();
if (!token) return;
eventSource?.close();
const url = `/api/v1/stream/events`;
eventSource = new EventSource(url);
eventSource.onopen = () => {
live.value = true;
};
eventSource.onmessage = (ev) => {
try {
const msg = JSON.parse(ev.data) as {
type?: string;
events_last_24h?: number;
problems_open?: number;
hosts_stale?: number;
heartbeats_24h?: number;
daily_reports_24h?: number;
last_event_id?: number | null;
};
if (msg.type !== "dashboard" || !data.value) return;
applyDashboardCounters(msg);
} catch {
/* ignore malformed SSE */
}
};
eventSource.onerror = () => {
live.value = false;
};
}
onMounted(() => {
load().then(() => connectLive());
});
onUnmounted(() => {
eventSource?.close();
eventSource = null;
});
</script>
<style scoped>
.dash-table-scroll {
max-height: 21rem;
overflow-y: auto;
}
.dash-recent-hint {
margin: -0.25rem 0 0.75rem;
font-size: 0.9rem;
}
.dash-actions {
white-space: nowrap;
}
.dash-qwinsta-btn {
font-size: 0.85rem;
padding: 0.2rem 0.55rem;
}
</style>