feat: reports page with formatted daily report cards

This commit is contained in:
PTah
2026-05-27 14:15:15 +10:00
parent 04301ab359
commit 61c9f9b673
10 changed files with 453 additions and 49 deletions
+3 -1
View File
@@ -14,7 +14,9 @@ SAC отображает их в **События** и на **Dashboard / Хос
## UI SAC
- **Dashboard:** счётчики heartbeat/отчётов за 24 ч, хосты с устаревшим heartbeat.
- **Хосты:** колонки *Агент* (`online` / `stale` / `unknown`), время последнего heartbeat и отчёта.
- **Хосты:** колонки *Агент* (`online` / `stale` / `unknown`), время последнего heartbeat и отчёта (ссылка на **Отчёты** по hostname).
- **Отчёты** (`/reports`): список `report.daily.*` по узлам; клик по строке — карточка с метриками и полным текстом (HTML/`report_body` в `details`).
- Старые события без `details.report_body` показывают подсказку обновить агент.
---
+1
View File
@@ -4,6 +4,7 @@
<span class="brand">{{ appTitle }}</span>
<RouterLink to="/dashboard">Dashboard</RouterLink>
<RouterLink to="/events">События</RouterLink>
<RouterLink to="/reports">Отчёты</RouterLink>
<RouterLink to="/problems">Проблемы</RouterLink>
<RouterLink to="/hosts">Хосты</RouterLink>
<button type="button" class="secondary" @click="logout">Выход</button>
@@ -0,0 +1,77 @@
<template>
<div class="report-card">
<div v-if="statsEntries.length" class="report-stats">
<div v-for="item in statsEntries" :key="item.key" class="report-stat">
<div class="report-stat-value">{{ item.value }}</div>
<div class="report-stat-label">{{ item.label }}</div>
</div>
</div>
<div v-if="htmlContent" class="report-body-html" v-html="htmlContent" />
<pre v-else-if="plainBody" class="report-body-plain">{{ plainBody }}</pre>
<p v-else-if="summary && summary !== 'Отчёт за сутки'" class="report-fallback">{{ summary }}</p>
<p v-else class="report-empty">
Полный текст отчёта не сохранён (старая версия агента). Обновите ssh-monitor и дождитесь следующего
ежедневного отчёта.
</p>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import {
reportBodyFromDetails,
reportHtmlFromDetails,
reportStatsFromDetails,
sanitizeAgentHtml,
type DailyReportStats,
} from "../utils/reportDisplay";
const props = defineProps<{
type: string;
summary: string;
details: Record<string, unknown> | null;
}>();
const stats = computed(() => reportStatsFromDetails(props.details));
const statLabels: Record<keyof DailyReportStats, string> = {
successful_ssh: "Успешных SSH",
failed_ssh: "Неудачных SSH",
sudo_commands: "Sudo",
active_bans: "Активных банов",
active_sessions: "Сессий (SSH)",
active_sessions_rdp: "Сессий (RDP)",
};
const statsEntries = computed(() => {
const s = stats.value;
if (!s) return [];
const out: { key: string; label: string; value: string | number }[] = [];
for (const [key, label] of Object.entries(statLabels)) {
const k = key as keyof DailyReportStats;
const v = s[k];
if (typeof v === "number") {
out.push({ key, label, value: v });
}
}
if (Array.isArray(s.unique_users) && s.unique_users.length) {
out.push({
key: "unique_users",
label: "Уникальные логины",
value: s.unique_users.length,
});
}
return out;
});
const htmlContent = computed(() => {
const raw = reportHtmlFromDetails(props.details);
return raw ? sanitizeAgentHtml(raw) : null;
});
const plainBody = computed(() => reportBodyFromDetails(props.details));
</script>
+2
View File
@@ -6,6 +6,7 @@ import HostsView from "./views/HostsView.vue";
import LoginView from "./views/LoginView.vue";
import ProblemsView from "./views/ProblemsView.vue";
import DashboardView from "./views/DashboardView.vue";
import ReportsView from "./views/ReportsView.vue";
const router = createRouter({
history: createWebHistory(),
@@ -15,6 +16,7 @@ const router = createRouter({
{ path: "/login", component: LoginView },
{ path: "/events", component: EventsView },
{ path: "/events/:id", component: EventDetailView, props: true },
{ path: "/reports", component: ReportsView },
{ path: "/hosts", component: HostsView },
{ path: "/problems", component: ProblemsView },
],
+99
View File
@@ -189,3 +189,102 @@ pre {
text-transform: uppercase;
letter-spacing: 0.05em;
}
.report-intro {
color: #9aa4b2;
margin-top: -0.5rem;
}
.reports-table .report-row {
cursor: pointer;
}
.reports-table .report-row:hover,
.reports-table .report-row:focus {
background: #1a2332;
outline: none;
}
.report-summary-cell {
max-width: 420px;
}
.report-card {
background: #1a2332;
border: 1px solid #2a3441;
border-radius: 8px;
padding: 1.25rem;
margin-bottom: 1rem;
}
.report-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 0.75rem;
margin-bottom: 1.25rem;
}
.report-stat {
background: #0f1419;
border: 1px solid #2a3441;
border-radius: 8px;
padding: 0.75rem;
text-align: center;
}
.report-stat-value {
font-size: 1.5rem;
font-weight: 700;
color: #fff;
}
.report-stat-label {
font-size: 0.8rem;
color: #9aa4b2;
margin-top: 0.25rem;
}
.report-body-html {
line-height: 1.55;
font-size: 0.95rem;
}
.report-body-html .agent-report {
white-space: normal;
}
.report-body-plain {
white-space: pre-wrap;
word-break: break-word;
margin: 0;
font-family: inherit;
font-size: 0.9rem;
background: #0f1419;
padding: 1rem;
border-radius: 6px;
border: 1px solid #2a3441;
}
.report-empty,
.report-fallback {
color: #9aa4b2;
}
.report-summary-line {
color: #9aa4b2;
margin-bottom: 0.75rem;
}
.report-meta {
margin-bottom: 1rem;
}
.raw-details {
margin-top: 1rem;
}
.raw-details summary {
cursor: pointer;
color: #9aa4b2;
margin-bottom: 0.5rem;
}
+49
View File
@@ -0,0 +1,49 @@
/** Безопасный вывод HTML из агента (только базовая разметка). */
export function sanitizeAgentHtml(html: string): string {
const allowed = /^(b|strong|i|em|u|code|pre|br|p|div|span|ul|ol|li|hr)$/i;
return html.replace(/<\/?([a-z][a-z0-9]*)\b[^>]*>/gi, (tag, name: string) => {
if (allowed.test(name)) {
if (tag.toLowerCase().startsWith("</")) {
return `</${name.toLowerCase()}>`;
}
if (name.toLowerCase() === "br") {
return "<br>";
}
return `<${name.toLowerCase()}>`;
}
return "";
});
}
export interface DailyReportStats {
successful_ssh?: number;
failed_ssh?: number;
sudo_commands?: number;
active_bans?: number;
active_sessions?: number;
active_sessions_rdp?: number;
unique_users?: string[];
}
export function isDailyReportType(type: string): boolean {
return type === "report.daily.ssh" || type === "report.daily.rdp";
}
export function reportStatsFromDetails(details: Record<string, unknown> | null): DailyReportStats | null {
if (!details || typeof details.stats !== "object" || details.stats === null) {
return null;
}
return details.stats as DailyReportStats;
}
export function reportHtmlFromDetails(details: Record<string, unknown> | null): string | null {
if (!details) return null;
const html = details.report_html;
return typeof html === "string" && html.trim() ? html : null;
}
export function reportBodyFromDetails(details: Record<string, unknown> | null): string | null {
if (!details) return null;
const body = details.report_body;
return typeof body === "string" && body.trim() ? body : null;
}
+1 -1
View File
@@ -32,7 +32,7 @@
<div class="dash-card">
<div class="dash-value">{{ data.daily_reports_24h }}</div>
<div class="dash-label">Отчёты за 24 ч</div>
<RouterLink to="/events?type=report.daily.ssh">События </RouterLink>
<RouterLink to="/reports">Отчёты </RouterLink>
</div>
</div>
+49 -9
View File
@@ -1,34 +1,73 @@
<template>
<p><RouterLink to="/events"> События</RouterLink></p>
<p>
<RouterLink v-if="fromReports" to="/reports"> Отчёты</RouterLink>
<RouterLink v-else to="/events"> События</RouterLink>
</p>
<div class="card">
<p><strong>ID:</strong> {{ event.id }} · <strong>event_id:</strong> {{ event.event_id }}</p>
<p><strong>Хост:</strong> {{ event.hostname }} · <strong>Severity:</strong>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading">Загрузка</p>
<template v-else-if="event">
<h1>{{ event.title }}</h1>
<div class="card report-meta">
<p>
<p><strong>Occurred:</strong> {{ event.occurred_at }}</p>
<p>{{ event.summary }}</p>
<strong>ID:</strong> {{ event.id }} · <strong>event_id:</strong> {{ event.event_id }}
</p>
<h2>details</h2>
<p>
<strong>Хост:</strong>
<RouterLink :to="{ path: '/reports', query: { hostname: event.hostname } }">
{{ event.hostname }}
</RouterLink>
<h2>payload</h2>
· <strong>Severity:</strong>
<span :class="'sev-' + event.severity">{{ event.severity }}</span>
</p>
<p><strong>Type:</strong> <code>{{ event.type }}</code></p>
<p><strong>Occurred:</strong> {{ formatDt(event.occurred_at) }}</p>
<p v-if="!isReport"><strong>Summary:</strong> {{ event.summary }}</p>
</div>
import { onMounted, ref, watch } from "vue";
<template v-if="isReport">
<h2>Отчёт</h2>
<p class="report-summary-line">{{ event.summary }}</p>
<ReportBodyCard :type="event.type" :summary="event.summary" :details="event.details" />
</template>
<details v-if="event.details && !isReport" class="raw-details">
<summary>details (JSON)</summary>
<pre>{{ JSON.stringify(event.details, null, 2) }}</pre>
</details>
@@ -44,3 +83,4 @@ async function load() {
<details class="raw-details">
+9 -1
View File
@@ -28,7 +28,15 @@
<td>{{ h.ipv4 || "—" }}</td>
<td :class="'agent-' + h.agent_status">{{ agentLabel(h.agent_status) }}</td>
<td>{{ h.last_heartbeat_at ? formatDt(h.last_heartbeat_at) : "—" }}</td>
<td>{{ h.last_daily_report_at ? formatDt(h.last_daily_report_at) : "—" }}</td>
<td>
<RouterLink
v-if="h.last_daily_report_at"
:to="{ path: '/reports', query: { hostname: h.hostname } }"
>
{{ formatDt(h.last_daily_report_at) }}
</RouterLink>
<span v-else></span>
</td>
<td>{{ formatDt(h.last_seen_at) }}</td>
<td>{{ h.event_count ?? 0 }}</td>
</tr>
+126
View File
@@ -0,0 +1,126 @@
<template>
<h1>Отчёты агентов</h1>
<p class="report-intro">
Ежедневные сводки с узлов (<code>report.daily.ssh</code>, <code>report.daily.rdp</code>). Нажмите на строку,
чтобы открыть полный отчёт с форматированием.
</p>
<div class="filters">
<input v-model="hostname" placeholder="hostname" @keyup.enter="load(1)" />
<select v-model="reportType" @change="load(1)">
<option value="report.daily.ssh">SSH (report.daily.ssh)</option>
<option value="report.daily.rdp">RDP (report.daily.rdp)</option>
</select>
<button type="button" @click="load(1)">Применить</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 class="reports-table">
<thead>
<tr>
<th>Время</th>
<th>Хост</th>
<th>Тип</th>
<th>Сводка</th>
</tr>
</thead>
<tbody>
<tr
v-for="e in data?.items ?? []"
:key="e.id"
class="report-row"
tabindex="0"
@click="openReport(e.id)"
@keyup.enter="openReport(e.id)"
>
<td>{{ formatDt(e.occurred_at) }}</td>
<td>
<strong>{{ e.hostname }}</strong>
</td>
<td><code>{{ e.type }}</code></td>
<td class="report-summary-cell">{{ e.summary }}</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 { onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { apiFetch, type EventListResponse } from "../api";
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 = 30;
const hostname = ref("");
const reportType = ref("report.daily.ssh");
function formatDt(iso: string) {
return new Date(iso).toLocaleString("ru-RU");
}
function openReport(id: number) {
router.push({ path: `/events/${id}`, query: { from: "reports" } });
}
async function load(p: number) {
page.value = p;
loading.value = true;
error.value = "";
try {
const params = new URLSearchParams({
page: String(page.value),
page_size: String(pageSize),
severity: "info",
});
params.set("type", reportType.value);
if (hostname.value.trim()) {
params.set("hostname", hostname.value.trim());
}
data.value = await apiFetch<EventListResponse>(`/api/v1/events?${params}`);
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка загрузки";
} finally {
loading.value = false;
}
}
onMounted(() => {
const h = route.query.hostname;
if (typeof h === "string" && h) hostname.value = h;
const t = route.query.type;
if (typeof t === "string" && t) reportType.value = t;
load(1);
});
watch(
() => route.query.hostname,
(h) => {
if (typeof h === "string") {
hostname.value = h;
load(1);
}
}
);
</script>