Files
security-alert-center/frontend/src/views/DashboardView.vue
T
PTah 2aec488226 feat: RDG session flap rule, qwinsta/logoff queue and dashboard UI (0.9.13)
Detect 302→303 within 1–10s, Problem with 30s dedup, rdg_flap flag.
Agent command poll API, admin qwinsta/logoff from Overview. Migration 016.
2026-06-19 23:34:01 +10:00

871 lines
18 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 class="dashboard-sac-version">{{ appName }} v.{{ appVersion }}</p>
<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>Версия агента</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>{{ e.product_version || "—" }}</td>
<td :class="'sev-' + e.severity">{{ e.severity }}</td>
<td>{{ e.title }}</td>
<td class="dash-actions">
<button
v-if="e.rdg_flap"
type="button"
class="secondary dash-qwinsta-btn"
:disabled="qwinstaLoadingId === e.id"
@click="runQwinsta(e)"
>
qwinsta
</button>
</td>
</tr>
</tbody>
</table>
<div v-if="qwinstaModal.open" class="modal-backdrop" @click.self="closeQwinstaModal">
<div class="modal-card qwinsta-modal" role="dialog" aria-modal="true">
<h3>qwinsta событие #{{ qwinstaModal.eventId }}</h3>
<p v-if="qwinstaModal.error" class="error">{{ qwinstaModal.error }}</p>
<p v-else-if="qwinstaModal.loading">Ожидание ответа агента</p>
<template v-else>
<pre v-if="qwinstaModal.stdout" class="qwinsta-raw">{{ qwinstaModal.stdout }}</pre>
<p v-else class="muted">Нет вывода</p>
<table v-if="qwinstaModal.sessions.length" class="dash-table">
<thead>
<tr>
<th>SESSION</th>
<th>USER</th>
<th>ID</th>
<th>STATE</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="s in qwinstaModal.sessions" :key="s.id">
<td>{{ s.sessionName }}</td>
<td>{{ s.userName }}</td>
<td>{{ s.id }}</td>
<td>{{ s.state }}</td>
<td>
<button
type="button"
class="danger"
:disabled="qwinstaModal.logoffId === s.id"
@click="runLogoff(s.id)"
>
logoff
</button>
</td>
</tr>
</tbody>
</table>
</template>
<div class="modal-actions">
<button type="button" class="secondary" @click="closeQwinstaModal">Закрыть</button>
</div>
</div>
</div>
<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,
fetchEventAction,
getToken,
postEventLogoff,
postEventQwinsta,
type AgentCommandResponse,
type DashboardSummary,
type EventSummary,
} from "../api";
import { useSacVersion } from "../composables/useSacVersion";
import { formatServerName } from "../utils/hostDisplay";
const { appName, appVersion } = useSacVersion();
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;
let qwinstaPollTimer: ReturnType<typeof setInterval> | null = null;
const qwinstaLoadingId = ref<number | null>(null);
type QwinstaSessionRow = {
sessionName: string;
userName: string;
id: number;
state: string;
};
const qwinstaModal = ref({
open: false,
eventId: 0,
actorUser: "",
commandUuid: "",
loading: false,
error: "",
stdout: "",
sessions: [] as QwinstaSessionRow[],
logoffId: null as number | null,
});
function parseQwinstaSessions(stdout: string, actorUser: string): QwinstaSessionRow[] {
const lines = stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
const rows: QwinstaSessionRow[] = [];
for (const line of lines) {
if (/^SESSION/i.test(line) || /^---/.test(line)) continue;
const parts = line.split(/\s+/);
if (parts.length < 4) continue;
const sessionName = parts[0];
const userName = parts[1];
const id = Number.parseInt(parts[2], 10);
const state = parts.slice(3).join(" ");
if (!Number.isFinite(id)) continue;
const norm = (s: string) => s.replace(/^B26\\/i, "").toLowerCase();
const matchUser = actorUser && norm(userName).includes(norm(actorUser));
if (actorUser && !matchUser) continue;
rows.push({ sessionName, userName, id, state });
}
if (rows.length === 0 && stdout.trim()) {
for (const line of lines) {
if (/^SESSION/i.test(line) || /^---/.test(line)) continue;
const parts = line.split(/\s+/);
if (parts.length < 4) continue;
const id = Number.parseInt(parts[2], 10);
if (!Number.isFinite(id)) continue;
rows.push({
sessionName: parts[0],
userName: parts[1],
id,
state: parts.slice(3).join(" "),
});
}
}
return rows;
}
function stopQwinstaPoll() {
if (qwinstaPollTimer) {
clearInterval(qwinstaPollTimer);
qwinstaPollTimer = null;
}
}
function closeQwinstaModal() {
stopQwinstaPoll();
qwinstaModal.value.open = false;
}
function applyCommandResult(eventId: number, actorUser: string, cmd: AgentCommandResponse) {
if (cmd.status === "pending") return false;
qwinstaModal.value.loading = false;
if (cmd.status === "failed") {
qwinstaModal.value.error = cmd.result_stderr || cmd.result_stdout || "Команда завершилась с ошибкой";
return true;
}
qwinstaModal.value.stdout = cmd.result_stdout || "";
qwinstaModal.value.sessions = parseQwinstaSessions(qwinstaModal.value.stdout, actorUser);
return true;
}
function pollQwinstaCommand(eventId: number, commandUuid: string, actorUser: string) {
stopQwinstaPoll();
qwinstaPollTimer = setInterval(async () => {
try {
const cmd = await fetchEventAction(eventId, commandUuid);
if (applyCommandResult(eventId, actorUser, cmd)) {
stopQwinstaPoll();
}
} catch (e) {
qwinstaModal.value.loading = false;
qwinstaModal.value.error = e instanceof Error ? e.message : "Ошибка опроса команды";
stopQwinstaPoll();
}
}, 2000);
}
async function runQwinsta(event: EventSummary) {
qwinstaLoadingId.value = event.id;
qwinstaModal.value = {
open: true,
eventId: event.id,
actorUser: event.actor_user || "",
commandUuid: "",
loading: true,
error: "",
stdout: "",
sessions: [],
logoffId: null,
};
try {
const cmd = await postEventQwinsta(event.id);
qwinstaModal.value.commandUuid = cmd.command_uuid;
if (applyCommandResult(event.id, event.actor_user || "", cmd)) {
return;
}
pollQwinstaCommand(event.id, cmd.command_uuid, event.actor_user || "");
} catch (e) {
qwinstaModal.value.loading = false;
qwinstaModal.value.error = e instanceof Error ? e.message : "Не удалось отправить qwinsta";
} finally {
qwinstaLoadingId.value = null;
}
}
async function runLogoff(sessionId: number) {
const modal = qwinstaModal.value;
if (!modal.eventId) return;
if (!window.confirm(`Завершить сеанс ID ${sessionId}?`)) return;
modal.logoffId = sessionId;
modal.error = "";
try {
const cmd = await postEventLogoff(modal.eventId, sessionId);
if (cmd.status === "pending") {
const deadline = Date.now() + 60000;
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 2000));
const polled = await fetchEventAction(modal.eventId, cmd.command_uuid);
if (polled.status !== "pending") {
if (polled.status === "failed") {
modal.error = polled.result_stderr || "logoff failed";
} else {
modal.loading = true;
modal.stdout = "";
modal.sessions = [];
const again = await postEventQwinsta(modal.eventId);
pollQwinstaCommand(modal.eventId, again.command_uuid, modal.actorUser);
}
break;
}
}
}
} catch (e) {
modal.error = e instanceof Error ? e.message : "logoff error";
} finally {
modal.logoffId = null;
}
}
function formatDt(iso: string) {
return new Date(iso).toLocaleString("ru-RU");
}
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?token=${encodeURIComponent(token)}`;
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(() => {
stopQwinstaPoll();
eventSource?.close();
eventSource = null;
});
</script>
<style scoped>
.dash-table-scroll {
max-height: 21rem;
overflow-y: auto;
}
.dashboard-sac-version {
margin: -0.35rem 0 1rem;
color: #9aa4b2;
font-size: 0.9rem;
}
.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;
}
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.modal-card {
background: var(--card-bg, #1e2430);
border: 1px solid var(--border, #3a4556);
border-radius: 8px;
max-width: 720px;
width: 100%;
max-height: 85vh;
overflow: auto;
padding: 1rem 1.25rem;
}
.qwinsta-raw {
font-size: 0.8rem;
max-height: 12rem;
overflow: auto;
background: #0d1117;
padding: 0.75rem;
border-radius: 4px;
}
.modal-actions {
margin-top: 1rem;
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
</style>