feat: reports page with formatted daily report cards
This commit is contained in:
@@ -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` показывают подсказку обновить агент.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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 },
|
||||
],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -1,46 +1,86 @@
|
||||
<template>
|
||||
<p><RouterLink to="/events">← События</RouterLink></p>
|
||||
<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">
|
||||
<p><strong>ID:</strong> {{ event.id }} · <strong>event_id:</strong> {{ event.event_id }}</p>
|
||||
<p><strong>Хост:</strong> {{ event.hostname }} · <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> {{ event.occurred_at }}</p>
|
||||
<p>{{ event.summary }}</p>
|
||||
</div>
|
||||
<h2>details</h2>
|
||||
<pre>{{ JSON.stringify(event.details, null, 2) }}</pre>
|
||||
<h2>payload</h2>
|
||||
<pre>{{ JSON.stringify(event.payload, null, 2) }}</pre>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from "vue";
|
||||
import { apiFetch, type EventDetail } from "../api";
|
||||
|
||||
const props = defineProps<{ id: string }>();
|
||||
const event = ref<EventDetail | null>(null);
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
event.value = await apiFetch<EventDetail>(`/api/v1/events/${props.id}`);
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "Не найдено";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
watch(() => props.id, load);
|
||||
</script>
|
||||
<template>
|
||||
|
||||
<p>
|
||||
|
||||
<RouterLink v-if="fromReports" to="/reports">← Отчёты</RouterLink>
|
||||
|
||||
<RouterLink v-else to="/events">← События</RouterLink>
|
||||
|
||||
</p>
|
||||
|
||||
<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>
|
||||
|
||||
<strong>ID:</strong> {{ event.id }} · <strong>event_id:</strong> {{ event.event_id }}
|
||||
|
||||
</p>
|
||||
|
||||
<p>
|
||||
|
||||
<strong>Хост:</strong>
|
||||
|
||||
<RouterLink :to="{ path: '/reports', query: { hostname: event.hostname } }">
|
||||
|
||||
{{ event.hostname }}
|
||||
|
||||
</RouterLink>
|
||||
|
||||
· <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>
|
||||
|
||||
|
||||
|
||||
<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>
|
||||
|
||||
|
||||
|
||||
<details v-if="isReport && event.details" class="raw-details">
|
||||
|
||||
<summary>details (JSON)</summary>
|
||||
|
||||
<pre>{{ JSON.stringify(event.details, null, 2) }}</pre>
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
|
||||
<details class="raw-details">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user