From 89571613174da351f12974f9550c45e561104d1b Mon Sep 17 00:00:00 2001 From: igor04091968 Date: Thu, 4 Jun 2026 22:16:12 +0300 Subject: [PATCH] fix(portal): polish security events fallback states --- adk-rust/crates/detmir-portal/src/main.rs | 47 ++++++++++++++- .../crates/detmir-portal/src/static/app.js | 29 ++++++++- ansible/deploy_detmir_portal.yml | 2 +- docs/SECURITY_EVENTS_CLICKHOUSE_RU.md | 25 ++++++++ scripts/detmir-portal-tabs-smoke.mjs | 60 +++++++++++++++++++ 5 files changed, 157 insertions(+), 6 deletions(-) diff --git a/adk-rust/crates/detmir-portal/src/main.rs b/adk-rust/crates/detmir-portal/src/main.rs index 4bd03c1..673b582 100644 --- a/adk-rust/crates/detmir-portal/src/main.rs +++ b/adk-rust/crates/detmir-portal/src/main.rs @@ -2529,16 +2529,17 @@ fn security_events_summary_status(summary: &SecurityEventsSummary) -> String { fn security_events_summary_text(summary: &SecurityEventsSummary) -> String { if summary.backend == "disabled" || summary.status == "disabled" { - return "агрегированные события безопасности отключены".to_string(); + return "Источник событий безопасности отключён. Используется локальный режим без ClickHouse." + .to_string(); } if summary.fallback_used { return format!( - "ClickHouse недоступен, используется локальный режим; причина: {}", + "События безопасности временно недоступны. Используется локальный режим без ClickHouse; причина: {}", summary.error.as_deref().unwrap_or("неизвестная ошибка") ); } format!( - "событий={}, неуспешных входов={}, подозрительных входов={}, RDP={}, изменения учетных записей={}, ошибки агентов={}", + "События безопасности доступны: событий={}, неуспешных входов={}, подозрительных входов={}, RDP={}, изменения учетных записей={}, ошибки агентов={}", summary.events_24h, summary.failed_logins_24h, summary.suspicious_logins_24h, @@ -10135,6 +10136,46 @@ mod tests { ); } + #[test] + fn security_events_summary_texts_are_pilot_safe() { + let disabled = SecurityEventsSummary::disabled(); + assert!( + security_events_summary_text(&disabled) + .contains("Источник событий безопасности отключён") + ); + assert!( + security_events_summary_text(&disabled) + .contains("Используется локальный режим без ClickHouse") + ); + + let fallback = SecurityEventsSummary::fallback("network unavailable", 1); + assert!( + security_events_summary_text(&fallback) + .contains("События безопасности временно недоступны") + ); + assert!( + security_events_summary_text(&fallback) + .contains("Используется локальный режим без ClickHouse") + ); + + let available = SecurityEventsSummary { + status: "ok".to_string(), + backend: "clickhouse".to_string(), + events_24h: 3, + failed_logins_24h: 1, + suspicious_logins_24h: 0, + rdp_sessions_24h: 2, + account_changes_24h: 0, + agent_errors_24h: 0, + top_departments: Vec::new(), + last_event_utc: Some("2026-06-04T10:00:00Z".to_string()), + query_ms: 7, + fallback_used: false, + error: None, + }; + assert!(security_events_summary_text(&available).contains("События безопасности доступны")); + } + #[test] fn reports_include_commercial_kpis_and_disclaimer() { let snapshot = Snapshot { diff --git a/adk-rust/crates/detmir-portal/src/static/app.js b/adk-rust/crates/detmir-portal/src/static/app.js index d142cd6..8fcfdab 100644 --- a/adk-rust/crates/detmir-portal/src/static/app.js +++ b/adk-rust/crates/detmir-portal/src/static/app.js @@ -2156,18 +2156,42 @@ function renderSecurityEventsSummary(summary, options = {}) { const disabled = backend === "disabled" || s.status === "disabled" || !summary; const fallback = Boolean(s.fallback_used); const status = disabled ? "UNKNOWN" : fallback ? "WARN" : Number(s.events_24h || 0) > 0 ? "WARN" : "OK"; + const stateText = disabled + ? "Источник событий безопасности отключён" + : fallback + ? "События безопасности временно недоступны" + : "События безопасности доступны"; + const localModeText = disabled ? "Используется локальный режим без ClickHouse" : ""; const title = options.compact ? "События безопасности" : "События безопасности за 24 часа"; const subtitle = disabled - ? "Агрегированный источник событий безопасности отключён." + ? `${stateText}. ${localModeText}.` : fallback - ? "ClickHouse недоступен, портал работает в резервном режиме без событий безопасности." + ? "Портал работает в резервном режиме без событий безопасности." : "Агрегированная сводка без сырых журналов и без автоматического создания инцидентов."; const top = Array.isArray(s.top_departments) ? s.top_departments.slice(0, 5) : []; const warning = fallback ? `
События безопасности временно недоступны. Проверьте ClickHouse и переменные SECURITY_EVENTS_BACKEND/CLICKHOUSE_*.
` : ""; + if (options.compact) { + return ` +
+
+
+

${ui(title)}

+

${ui(stateText)}

+
+ ${ui(status)} +
+
+
Состояние${ui(stateText)}
+
Событий за 24 часа${ui(s.events_24h ?? 0)}
+
Режим${ui(disabled ? localModeText : "Агрегированная сводка")}
+
+
+ `; + } return `
@@ -2178,6 +2202,7 @@ function renderSecurityEventsSummary(summary, options = {}) { ${ui(status)}
+
Состояние${ui(stateText)}
Источник${ui(backend)}
Событий${ui(s.events_24h ?? 0)}
Неуспешные входы${ui(s.failed_logins_24h ?? 0)}
diff --git a/ansible/deploy_detmir_portal.yml b/ansible/deploy_detmir_portal.yml index 9856b62..ab1f799 100644 --- a/ansible/deploy_detmir_portal.yml +++ b/ansible/deploy_detmir_portal.yml @@ -46,7 +46,7 @@ content: | DETMIR_PORTAL_BIND={{ detmir_portal_bind }} DETMIR_PORTAL_STATUS_CMD=detmir-status --json - DETMIR_PORTAL_CHECK_CMD=detmir-check --json --aw-api "$DETMIR_AW_API" --worktime-url "$DETMIR_WORKTIME_URL" --one-c-url "$DETMIR_ONE_C_URL" --rdp-host "$DETMIR_RDP_HOST" --hostname "$DETMIR_HOSTNAME" + DETMIR_PORTAL_CHECK_CMD='timeout 8s detmir-check --json --aw-api "$DETMIR_AW_API" --worktime-url "$DETMIR_WORKTIME_URL" --one-c-url "$DETMIR_ONE_C_URL" --rdp-host "$DETMIR_RDP_HOST" --hostname "$DETMIR_HOSTNAME"' DETMIR_PORTAL_FAILED_UNITS_CMD=systemctl --failed --no-pager DETMIR_PORTAL_WORKTIME_URL={{ detmir_portal_worktime_url }} DETMIR_PORTAL_ONE_C_URL={{ detmir_portal_one_c_url }} diff --git a/docs/SECURITY_EVENTS_CLICKHOUSE_RU.md b/docs/SECURITY_EVENTS_CLICKHOUSE_RU.md index 22865d8..68c7733 100644 --- a/docs/SECURITY_EVENTS_CLICKHOUSE_RU.md +++ b/docs/SECURITY_EVENTS_CLICKHOUSE_RU.md @@ -100,3 +100,28 @@ detmir-check --json Ожидаемый результат при недоступном ClickHouse: портал возвращает `fallback_used=true`, а `detmir-check` показывает предупреждение `security-events-clickhouse` с `required=false`. + +## Пилотная проверка UI + +Для демонстрационного стенда проверяются три режима: + +1. `SECURITY_EVENTS_BACKEND=disabled` - штатный режим без ClickHouse. +2. `SECURITY_EVENTS_BACKEND=clickhouse` и доступный ClickHouse - сводка + показывает “События безопасности доступны”. +3. `SECURITY_EVENTS_BACKEND=clickhouse` и недоступный ClickHouse - портал + показывает “События безопасности временно недоступны”, но `/api/reports` + остается валидным. + +Smoke-тест поддерживает явное ожидание режима: + +```bash +DETMIR_PORTAL_SMOKE_SECURITY_EVENTS_EXPECT=disabled \ +node scripts/detmir-portal-tabs-smoke.mjs + +DETMIR_PORTAL_SMOKE_SECURITY_EVENTS_EXPECT=fallback \ +node scripts/detmir-portal-tabs-smoke.mjs +``` + +В представлении руководителя не выводятся технические параметры +`SECURITY_EVENTS_BACKEND` и `CLICKHOUSE_*`. Подробная причина отказа видна в +представлении эксплуатации. diff --git a/scripts/detmir-portal-tabs-smoke.mjs b/scripts/detmir-portal-tabs-smoke.mjs index aa32082..a93ec77 100644 --- a/scripts/detmir-portal-tabs-smoke.mjs +++ b/scripts/detmir-portal-tabs-smoke.mjs @@ -47,6 +47,20 @@ function containsText(text, marker) { return String(text || "").toLocaleLowerCase("ru-RU").includes(String(marker || "").toLocaleLowerCase("ru-RU")); } +function securityEventsMode(summary) { + const s = summary || {}; + if (!summary || s.backend === "disabled" || s.status === "disabled") return "disabled"; + if (s.fallback_used) return "fallback"; + return "available"; +} + +function expectedSecurityEventsText(mode) { + if (mode === "disabled") return "Источник событий безопасности отключён"; + if (mode === "fallback") return "События безопасности временно недоступны"; + if (mode === "available") return "События безопасности доступны"; + return ""; +} + function assertStaticTabHandlers() { const index = fs.readFileSync(path.join(root, "adk-rust/crates/detmir-portal/src/static/index.html"), "utf8"); const app = fs.readFileSync(path.join(root, "adk-rust/crates/detmir-portal/src/static/app.js"), "utf8"); @@ -121,6 +135,26 @@ async function main() { && (await page.locator("#loadingStageText").innerText({ timeout })).includes("Данные загружены") && !(await page.locator("body").innerText({ timeout })).includes("Данные загружаются"), }); + smokeStep = "api:security_events_summary"; + const reportsPayload = await page.evaluate(async () => { + const response = await fetch("api/reports", { cache: "no-store" }); + return { ok: response.ok, status: response.status, json: await response.json() }; + }); + const securitySummary = reportsPayload.json?.security_events_summary || null; + const securityMode = securityEventsMode(securitySummary); + const expectedSecurityMode = env("DETMIR_PORTAL_SMOKE_SECURITY_EVENTS_EXPECT", "auto").toLowerCase(); + checks.push({ + name: "security_events_api_json", + ok: reportsPayload.ok && Boolean(securitySummary), + mode: securityMode, + status: reportsPayload.status, + }); + checks.push({ + name: "security_events_expected_mode", + ok: expectedSecurityMode === "auto" || expectedSecurityMode === securityMode, + expected: expectedSecurityMode, + actual: securityMode, + }); for (const item of expectedTabs) { smokeStep = `tab:${item.tab}`; await page.click(`button[data-tab="${item.tab}"]`, { timeout }); @@ -181,6 +215,17 @@ async function main() { && (await page.locator('[data-view-mode="security"]').count()) === 1 && (await page.locator('[data-view-mode="operations"]').count()) === 1, }); + const expectedSecurityText = expectedSecurityEventsText(securityMode); + checks.push({ + name: "security_events_executive_text", + ok: + !expectedSecurityText + || (containsText(readyBodyText, expectedSecurityText) + && !containsText(readyBodyText, "SECURITY_EVENTS_BACKEND") + && !containsText(readyBodyText, "CLICKHOUSE_*")), + mode: securityMode, + expected_text: expectedSecurityText, + }); smokeStep = "role:security"; await page.click('[data-view-mode="security"]', { timeout }); @@ -203,6 +248,12 @@ async function main() { "Связь рисков и активности", ].every((marker) => containsText(securityText, marker)), }); + checks.push({ + name: "security_events_security_text", + ok: !expectedSecurityText || containsText(securityText, expectedSecurityText), + mode: securityMode, + expected_text: expectedSecurityText, + }); smokeStep = "role:operations"; await page.click('[data-view-mode="operations"]', { timeout }); @@ -225,6 +276,15 @@ async function main() { "Телеметрия", ].every((marker) => containsText(operationsText, marker)), }); + checks.push({ + name: "security_events_operations_text", + ok: + !expectedSecurityText + || (containsText(operationsText, expectedSecurityText) + && (securityMode !== "fallback" || containsText(operationsText, "События безопасности временно недоступны"))), + mode: securityMode, + expected_text: expectedSecurityText, + }); } if (item.tab === "settings") { const requiredSettings = [