From 61c9f9b67390b6b1898bb8b8898239e0a0d38a96 Mon Sep 17 00:00:00 2001 From: PTah Date: Wed, 27 May 2026 14:15:15 +1000 Subject: [PATCH] feat: reports page with formatted daily report cards --- docs/pilot-2.2-heartbeat-reports.md | 4 +- frontend/src/App.vue | 1 + frontend/src/components/ReportBodyCard.vue | 77 ++++++++++++ frontend/src/router.ts | 2 + frontend/src/style.css | 99 ++++++++++++++++ frontend/src/utils/reportDisplay.ts | 49 ++++++++ frontend/src/views/DashboardView.vue | 2 +- frontend/src/views/EventDetailView.vue | 132 ++++++++++++++------- frontend/src/views/HostsView.vue | 10 +- frontend/src/views/ReportsView.vue | 126 ++++++++++++++++++++ 10 files changed, 453 insertions(+), 49 deletions(-) create mode 100644 frontend/src/components/ReportBodyCard.vue create mode 100644 frontend/src/utils/reportDisplay.ts create mode 100644 frontend/src/views/ReportsView.vue diff --git a/docs/pilot-2.2-heartbeat-reports.md b/docs/pilot-2.2-heartbeat-reports.md index 01c80a5..06e52a6 100644 --- a/docs/pilot-2.2-heartbeat-reports.md +++ b/docs/pilot-2.2-heartbeat-reports.md @@ -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` показывают подсказку обновить агент. --- diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 02981c8..ff40a33 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -4,6 +4,7 @@ {{ appTitle }} Dashboard События + Отчёты Проблемы Хосты diff --git a/frontend/src/components/ReportBodyCard.vue b/frontend/src/components/ReportBodyCard.vue new file mode 100644 index 0000000..db4c6de --- /dev/null +++ b/frontend/src/components/ReportBodyCard.vue @@ -0,0 +1,77 @@ + + + diff --git a/frontend/src/router.ts b/frontend/src/router.ts index f9a34c5..057ab8c 100644 --- a/frontend/src/router.ts +++ b/frontend/src/router.ts @@ -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 }, ], diff --git a/frontend/src/style.css b/frontend/src/style.css index 36b9cb5..5692080 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -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; +} diff --git a/frontend/src/utils/reportDisplay.ts b/frontend/src/utils/reportDisplay.ts new file mode 100644 index 0000000..2e760d8 --- /dev/null +++ b/frontend/src/utils/reportDisplay.ts @@ -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("`; + } + if (name.toLowerCase() === "br") { + return "
"; + } + 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 | null): DailyReportStats | null { + if (!details || typeof details.stats !== "object" || details.stats === null) { + return null; + } + return details.stats as DailyReportStats; +} + +export function reportHtmlFromDetails(details: Record | 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 | null): string | null { + if (!details) return null; + const body = details.report_body; + return typeof body === "string" && body.trim() ? body : null; +} diff --git a/frontend/src/views/DashboardView.vue b/frontend/src/views/DashboardView.vue index 2be6459..1147cb8 100644 --- a/frontend/src/views/DashboardView.vue +++ b/frontend/src/views/DashboardView.vue @@ -32,7 +32,7 @@
{{ data.daily_reports_24h }}
Отчёты за 24 ч
- События → + Отчёты →
diff --git a/frontend/src/views/EventDetailView.vue b/frontend/src/views/EventDetailView.vue index 3a5d2e6..adc266e 100644 --- a/frontend/src/views/EventDetailView.vue +++ b/frontend/src/views/EventDetailView.vue @@ -1,46 +1,86 @@ - - - + + + + \ No newline at end of file diff --git a/frontend/src/views/HostsView.vue b/frontend/src/views/HostsView.vue index 2e329c7..bfe2668 100644 --- a/frontend/src/views/HostsView.vue +++ b/frontend/src/views/HostsView.vue @@ -28,7 +28,15 @@ {{ h.ipv4 || "—" }} {{ agentLabel(h.agent_status) }} {{ h.last_heartbeat_at ? formatDt(h.last_heartbeat_at) : "—" }} - {{ h.last_daily_report_at ? formatDt(h.last_daily_report_at) : "—" }} + + + {{ formatDt(h.last_daily_report_at) }} + + + {{ formatDt(h.last_seen_at) }} {{ h.event_count ?? 0 }} diff --git a/frontend/src/views/ReportsView.vue b/frontend/src/views/ReportsView.vue new file mode 100644 index 0000000..9dd2090 --- /dev/null +++ b/frontend/src/views/ReportsView.vue @@ -0,0 +1,126 @@ + + +