From 3a4e70c3b7d50afcfa90828690f43b6ebf93dd9d Mon Sep 17 00:00:00 2001 From: igor04091968 Date: Wed, 3 Jun 2026 10:36:42 +0300 Subject: [PATCH] feat(portal): add commercial reports view --- adk-rust/crates/detmir-portal/src/main.rs | 379 +++++++++++++++++- .../crates/detmir-portal/src/static/app.css | 36 ++ .../crates/detmir-portal/src/static/app.js | 54 +++ .../detmir-portal/src/static/index.html | 1 + ansible/deploy_detmir_portal.yml | 31 +- docs/DETMIR_PORTAL_GUI_PLAN_RU.md | 24 +- 6 files changed, 512 insertions(+), 13 deletions(-) diff --git a/adk-rust/crates/detmir-portal/src/main.rs b/adk-rust/crates/detmir-portal/src/main.rs index f1b2e34..565b77d 100644 --- a/adk-rust/crates/detmir-portal/src/main.rs +++ b/adk-rust/crates/detmir-portal/src/main.rs @@ -357,6 +357,20 @@ struct Snapshot { one_c: SourceStatus, } +#[derive(Debug)] +struct ReportMetrics { + users_count: usize, + active_seconds: i64, + apps_count: usize, + dlp_ok: u64, + dlp_warn: u64, + dlp_fail: u64, + evidence_total: usize, + evidence_screenshots: usize, + open_incidents: usize, + acknowledged_incidents: usize, +} + fn main() { let code = match run() { Ok(code) => code, @@ -376,6 +390,7 @@ fn run() -> Result { let smoke = json!({ "health": build_health(&snapshot), "summary": build_summary(&snapshot), + "reports": build_reports(&snapshot, &incident_state, &build_dlp_evidence_response(&args)), "incidents": build_incidents(&snapshot, &incident_state), "dlp_evidence": build_dlp_evidence_response(&args), }); @@ -414,7 +429,7 @@ fn handle_request(request: Request, args: &Cli) -> Result<()> { return handle_evidence_screenshot(request, args, &evidence_id, download); } match path.as_str() { - "/" | "/operator" | "/manager" | "/owner" | "/incidents" => respond_text( + "/" | "/operator" | "/manager" | "/owner" | "/incidents" | "/reports" => respond_text( request, StatusCode(200), INDEX_HTML, @@ -443,6 +458,15 @@ fn handle_request(request: Request, args: &Cli) -> Result<()> { let snapshot = build_snapshot(args); respond_json(request, &build_owner(&snapshot)) } + "/api/reports" => { + let snapshot = build_snapshot(args); + let incident_state = load_incident_state_best_effort(args); + let evidence = build_dlp_evidence_response(args); + respond_json( + request, + &build_reports(&snapshot, &incident_state, &evidence), + ) + } "/api/incidents" => { let snapshot = build_snapshot(args); let incident_state = load_incident_state_best_effort(args); @@ -812,6 +836,244 @@ fn build_owner(snapshot: &Snapshot) -> Value { }) } +fn build_reports( + snapshot: &Snapshot, + incident_state: &IncidentStateFile, + evidence: &DlpEvidenceResponse, +) -> Value { + let summary = build_summary(snapshot); + let incidents = build_incidents(snapshot, incident_state); + let (users_count, active_seconds, apps_count) = worktime_totals(snapshot); + let dlp = dlp_counts(snapshot); + let metrics = ReportMetrics { + users_count, + active_seconds, + apps_count, + dlp_ok: dlp.0, + dlp_warn: dlp.1, + dlp_fail: dlp.2, + evidence_total: evidence.items.len(), + evidence_screenshots: evidence + .items + .iter() + .filter(|item| item.screenshot_available) + .count(), + open_incidents: incidents.iter().filter(|item| !item.acknowledged).count(), + acknowledged_incidents: incidents.iter().filter(|item| item.acknowledged).count(), + }; + let grafana = grafana_block(snapshot); + let collection = collection_block(snapshot.detmir_check.payload.as_ref()); + let worktime = worktime_block(snapshot); + let one_c = one_c_block(snapshot); + let dlp_block_value = dlp_block(snapshot); + let headline = if summary.operator_ok && summary.severity == "OK" && metrics.open_incidents == 0 + { + "Контур DetMir работает штатно, критичных действий не требуется" + } else if metrics.open_incidents > 0 { + "Контур DetMir работает, есть открытые вопросы для оператора" + } else { + "Контур DetMir требует технической проверки" + }; + let executive_points = vec![ + format!("Сбор данных: {}. {}", collection.status, collection.text), + format!( + "Работа сегодня: сотрудников={}, активное время={}", + metrics.users_count, + human_duration(metrics.active_seconds) + ), + format!( + "DLP/ИБ: ok={}, warn={}, fail={}, evidence={}, screenshots={}", + metrics.dlp_ok, + metrics.dlp_warn, + metrics.dlp_fail, + metrics.evidence_total, + metrics.evidence_screenshots + ), + format!( + "Открытые вопросы: {}, в работе: {}", + metrics.open_incidents, metrics.acknowledged_incidents + ), + ]; + let recommendations = owner_recommendations(snapshot, &summary); + let markdown = render_report_markdown(snapshot, headline, &summary, &metrics, &recommendations); + json!({ + "generated_at_utc": snapshot.generated_at_utc, + "period": "оперативный срез за сегодня и текущий runtime", + "severity": summary.severity, + "operator_ok": summary.operator_ok, + "headline": headline, + "executive_points": executive_points, + "kpis": [ + report_kpi("Сотрудники", metrics.users_count.to_string(), worktime.status.clone(), "строки worktime за сегодня"), + report_kpi("Активное время", human_duration(metrics.active_seconds), worktime.status.clone(), "сумма active_seconds"), + report_kpi("Приложения", metrics.apps_count.to_string(), worktime.status.clone(), "true active applications"), + report_kpi("DLP WARN/FAIL", format!("{}/{}", metrics.dlp_warn, metrics.dlp_fail), dlp_block_value.status.clone(), "технические сигналы DLP"), + report_kpi("Evidence", format!("{}/{}", metrics.evidence_screenshots, metrics.evidence_total), evidence_status(evidence), "скриншоты / все evidence items"), + report_kpi("Открытые вопросы", metrics.open_incidents.to_string(), incident_status(metrics.open_incidents), "не взятые в работу items") + ], + "sections": [ + { + "title": "Надежность контура", + "items": [ + report_item("DetMir status", snapshot.detmir_status.status.clone(), snapshot.detmir_status.summary.clone()), + report_item("Сбор данных", collection.status.clone(), collection.text.clone()), + report_item("Grafana", grafana.status.clone(), grafana.text.clone()), + report_item("1C analytics", one_c.status.clone(), one_c.text.clone()) + ] + }, + { + "title": "Работа и управляемость", + "items": [ + report_item("Worktime", worktime.status.clone(), worktime.text.clone()), + report_item("Активное время", worktime.status.clone(), human_duration(metrics.active_seconds)), + report_item("Приложения", worktime.status.clone(), metrics.apps_count.to_string()), + report_item("Отчет", "OK", "готов к передаче руководителю") + ] + }, + { + "title": "ИБ и evidence", + "items": [ + report_item("DLP", dlp_block_value.status.clone(), dlp_block_value.text.clone()), + report_item("Evidence metadata", evidence_status(evidence), format!("items={}", metrics.evidence_total)), + report_item("Скриншоты", evidence_status(evidence), format!("available={}", metrics.evidence_screenshots)), + report_item("Формулировка", "OK", "derived detections/cases, не сертифицированная СЗИ") + ] + }, + { + "title": "Действия", + "items": recommendations.iter().map(|item| report_item("Рекомендация", "INFO", item)).collect::>() + } + ], + "markdown": markdown, + "links": links() + }) +} + +fn report_kpi(label: &str, value: String, status: String, context: &str) -> Value { + json!({ + "label": label, + "value": value, + "status": status, + "context": context, + }) +} + +fn report_item(label: &str, status: impl Into, value: impl Into) -> Value { + json!({ + "label": label, + "status": status.into(), + "value": value.into(), + }) +} + +fn render_report_markdown( + snapshot: &Snapshot, + headline: &str, + summary: &SummaryResponse, + metrics: &ReportMetrics, + recommendations: &[String], +) -> String { + let mut text = String::new(); + text.push_str("# DetMir оперативный отчет\n\n"); + text.push_str(&format!("Дата снимка: {}\n\n", snapshot.generated_at_utc)); + text.push_str(&format!("Итог: {headline}\n\n")); + text.push_str("## KPI\n\n"); + text.push_str(&format!("- Общий статус: {}\n", summary.severity)); + text.push_str(&format!( + "- Готовность для оператора: {}\n", + if summary.operator_ok { + "да" + } else { + "нет" + } + )); + text.push_str(&format!( + "- Сотрудники за сегодня: {}\n", + metrics.users_count + )); + text.push_str(&format!( + "- Активное время: {}\n", + human_duration(metrics.active_seconds) + )); + text.push_str(&format!("- Активные приложения: {}\n", metrics.apps_count)); + text.push_str(&format!( + "- DLP технические сигналы: ok={}, warn={}, fail={}\n", + metrics.dlp_ok, metrics.dlp_warn, metrics.dlp_fail + )); + text.push_str(&format!( + "- Evidence: items={}, screenshots={}\n", + metrics.evidence_total, metrics.evidence_screenshots + )); + text.push_str(&format!( + "- Открытые вопросы: {}, в работе: {}\n\n", + metrics.open_incidents, metrics.acknowledged_incidents + )); + text.push_str("## Рекомендации\n\n"); + for item in recommendations { + text.push_str(&format!("- {item}\n")); + } + text.push_str("\nПримечание: DLP/case показатели являются derived detections/cases и требуют регламентной валидации перед подачей как подтвержденные инциденты.\n"); + text +} + +fn worktime_totals(snapshot: &Snapshot) -> (usize, i64, usize) { + let Some(payload) = snapshot.worktime.payload.as_ref() else { + return (0, 0, 0); + }; + let rows = payload + .get("rows") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let apps = payload + .get("true_active_apps") + .and_then(Value::as_array) + .map(Vec::len) + .unwrap_or(0); + let active_seconds = rows + .iter() + .filter_map(|row| row.get("active_seconds").and_then(Value::as_i64)) + .sum(); + (rows.len(), active_seconds, apps) +} + +fn dlp_counts(snapshot: &Snapshot) -> (u64, u64, u64) { + let counts = snapshot + .detmir_status + .payload + .as_ref() + .and_then(|status| status.get("dlp_counts")) + .unwrap_or(&Value::Null); + ( + counts.get("ok").and_then(Value::as_u64).unwrap_or(0), + counts.get("warn").and_then(Value::as_u64).unwrap_or(0), + counts.get("fail").and_then(Value::as_u64).unwrap_or(0), + ) +} + +fn evidence_status(evidence: &DlpEvidenceResponse) -> String { + if evidence.ok { + "OK".to_string() + } else { + "WARN".to_string() + } +} + +fn incident_status(open_incidents: usize) -> String { + if open_incidents == 0 { + "OK".to_string() + } else { + "WARN".to_string() + } +} + +fn human_duration(seconds: i64) -> String { + let seconds = seconds.max(0); + let hours = seconds / 3600; + let minutes = (seconds % 3600) / 60; + format!("{hours:02}:{minutes:02}") +} + fn build_incidents(snapshot: &Snapshot, state: &IncidentStateFile) -> Vec { let mut incidents = Vec::new(); for source in [ @@ -2321,4 +2583,119 @@ mod tests { assert!(!constant_time_eq(b"secret", b"other")); assert!(!constant_time_eq(b"secret", b"secret2")); } + + #[test] + fn human_duration_formats_hhmm() { + assert_eq!(human_duration(0), "00:00"); + assert_eq!(human_duration(3660), "01:01"); + assert_eq!(human_duration(-10), "00:00"); + } + + #[test] + fn reports_include_commercial_kpis_and_disclaimer() { + let snapshot = Snapshot { + generated_at_utc: "2026-06-03T10:00:00Z".to_string(), + detmir_status: SourceStatus { + ok: true, + status: "OK".to_string(), + summary: "severity=OK, operator_ok=true".to_string(), + error: None, + payload: Some(json!({ + "severity": "OK", + "ok_for_operator": true, + "dlp_ok": true, + "dlp_counts": {"ok": 22, "warn": 0, "fail": 0} + })), + }, + detmir_check: SourceStatus { + ok: true, + status: "OK".to_string(), + summary: "bucket_ok=8, stale=0, dead=0, service_fail=0".to_string(), + error: None, + payload: Some(json!({ + "summary": { + "bucket_ok": 8, + "bucket_stale": 0, + "bucket_dead": 0, + "service_failures": 0 + }, + "services": [ + {"name": "grafana-data", "ok": true, "payload": {"age_seconds": 60, "fail_count": 0}} + ] + })), + }, + failed_units: SourceStatus { + ok: true, + status: "OK".to_string(), + summary: "failed units not reported".to_string(), + error: None, + payload: Some(json!({"stdout": "0 loaded units listed"})), + }, + worktime: SourceStatus { + ok: true, + status: "OK".to_string(), + summary: "rows=2, apps=1".to_string(), + error: None, + payload: Some(json!({ + "rows": [ + {"user": "USER-1", "active_seconds": 3600}, + {"user": "USER-2", "active_seconds": 1800} + ], + "true_active_apps": [ + {"application": "ERP", "proved_work_human": "00:30"} + ] + })), + }, + one_c: SourceStatus { + ok: true, + status: "OK".to_string(), + summary: "status=ok, companies=47".to_string(), + error: None, + payload: Some(json!({"status": "ok", "companies_total": 47})), + }, + }; + let evidence = DlpEvidenceResponse { + ok: true, + generated_at_utc: "2026-06-03T10:00:00Z".to_string(), + db_available: true, + screenshot_root_available: true, + limit: 10, + items: vec![DlpEvidenceItem { + id: "ev-1-0000000000000000".to_string(), + event_ts: "2026-06-03T10:00:00Z".to_string(), + bucket_id: "aw-dlp-incidents_HOST-EXAMPLE".to_string(), + event_id: "event-1".to_string(), + stream_type: "dlp_incident".to_string(), + hostname: "HOST-EXAMPLE".to_string(), + username: None, + severity: Some("medium".to_string()), + signal_type: Some("clipboard".to_string()), + rule_id: None, + action: None, + source: None, + message: None, + file_path: None, + has_screenshot_metadata: true, + screenshot_available: true, + source_file: Some("shot.png".to_string()), + screenshot_sha256: Some("0".repeat(64)), + screenshot_width: None, + screenshot_height: None, + preview_url: None, + download_url: None, + blocked_reason: None, + }], + error: None, + }; + let report = build_reports(&snapshot, &IncidentStateFile::default(), &evidence); + assert_eq!(report["operator_ok"], true); + assert_eq!(report["severity"], "OK"); + assert!( + report["markdown"] + .as_str() + .unwrap() + .contains("derived detections/cases") + ); + assert!(report["kpis"].as_array().unwrap().len() >= 6); + } } diff --git a/adk-rust/crates/detmir-portal/src/static/app.css b/adk-rust/crates/detmir-portal/src/static/app.css index e187d35..366446a 100644 --- a/adk-rust/crates/detmir-portal/src/static/app.css +++ b/adk-rust/crates/detmir-portal/src/static/app.css @@ -160,6 +160,42 @@ h1 { margin-top: 12px; } +.report-hero { + min-height: 140px; +} + +.kpi-grid { + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); +} + +.kpi-card { + min-height: 132px; +} + +.kpi-value { + margin: 10px 0 6px; + font-size: 28px; + font-weight: 800; + line-height: 1.1; +} + +.report-section { + min-height: 180px; +} + +.compact-list { + gap: 6px; +} + +.compact-row { + grid-template-columns: minmax(110px, 1fr) minmax(170px, 2fr) auto; + padding: 8px; +} + +.markdown-card { + margin-top: 12px; +} + .evidence-row { grid-template-columns: minmax(180px, 1fr) minmax(260px, 2fr) auto auto; } diff --git a/adk-rust/crates/detmir-portal/src/static/app.js b/adk-rust/crates/detmir-portal/src/static/app.js index 525b166..1d3213f 100644 --- a/adk-rust/crates/detmir-portal/src/static/app.js +++ b/adk-rust/crates/detmir-portal/src/static/app.js @@ -249,6 +249,59 @@ function renderIncidents(data) { `; } +function renderKpiCards(items) { + return `
${(items || []).map(item => ` +
+ ${escapeHtml(item.status || "INFO")} +

${escapeHtml(item.label)}

+

${escapeHtml(item.value)}

+

${escapeHtml(item.context || "")}

+
+ `).join("")}
`; +} + +function renderReportSections(sections) { + return `
${(sections || []).map(section => ` +
+

${escapeHtml(section.title)}

+
${(section.items || []).map(item => ` +
+ ${escapeHtml(item.label)} + ${escapeHtml(item.value)} + ${escapeHtml(item.status || "INFO")} +
+ `).join("")}
+
+ `).join("")}
`; +} + +function renderReports(data) { + return ` +

Отчеты

+
+
+ ${escapeHtml(data.severity)} +

${escapeHtml(data.headline)}

+

${escapeHtml(data.period || "")} · обновлено ${escapeHtml(data.generated_at_utc || "")}

+
+
+

Для руководителя

+
${(data.executive_points || []).map(point => ` +
Итог${escapeHtml(point)}
+ `).join("")}
+
+
+

Ключевые показатели

+ ${renderKpiCards(data.kpis)} +

Срезы отчета

+ ${renderReportSections(data.sections)} +
+

Markdown для отчета

+
${escapeHtml(data.markdown || "")}
+
+ `; +} + async function refresh() { if (!state.links) state.links = await loadJson("/links"); const summary = await loadJson("/summary"); @@ -262,6 +315,7 @@ async function refresh() { const evidence = await loadJson("/dlp/evidence").catch(error => ({ ok: false, error: error.message, items: [] })); content.innerHTML = renderIncidents({ incidents: data, evidence }); } + if (state.tab === "reports") content.innerHTML = renderReports(data); } function setTab(tab) { diff --git a/adk-rust/crates/detmir-portal/src/static/index.html b/adk-rust/crates/detmir-portal/src/static/index.html index db5688b..4fc648c 100644 --- a/adk-rust/crates/detmir-portal/src/static/index.html +++ b/adk-rust/crates/detmir-portal/src/static/index.html @@ -21,6 +21,7 @@ +
diff --git a/ansible/deploy_detmir_portal.yml b/ansible/deploy_detmir_portal.yml index b39c2b3..d6cf6f2 100644 --- a/ansible/deploy_detmir_portal.yml +++ b/ansible/deploy_detmir_portal.yml @@ -9,6 +9,9 @@ aw_rust_release_dir: "{{ (lookup('env', 'CARGO_TARGET_DIR') | default(aw_repo_root + '/adk-rust/target', true)) + '/release' }}" detmir_portal_bind: "{{ detmir_portal_bind_override | default('127.0.0.1:8720') }}" detmir_portal_env_path: "/etc/detmir-portal.env" + detmir_portal_worktime_url: "{{ detmir_portal_worktime_url_override | default(aw_worktime_report_base | default('http://192.0.2.13:5610', true), true) }}" + detmir_portal_one_c_host: "{{ hostvars[(groups['proxmox'] | default([]) | first) | default('192.0.2.2', true)].ansible_host | default((groups['proxmox'] | default([]) | first) | default('192.0.2.2', true), true) }}" + detmir_portal_one_c_url: "{{ detmir_portal_one_c_url_override | default('http://' + detmir_portal_one_c_host + ':8710', true) }}" tasks: - name: Check local detmir-portal binary @@ -42,8 +45,8 @@ DETMIR_PORTAL_STATUS_CMD=detmir-status --json DETMIR_PORTAL_CHECK_CMD=detmir-check --json DETMIR_PORTAL_FAILED_UNITS_CMD=systemctl --failed --no-pager - DETMIR_PORTAL_WORKTIME_URL=http://192.0.2.13:5610 - DETMIR_PORTAL_ONE_C_URL=http://192.0.2.2:8710 + DETMIR_PORTAL_WORKTIME_URL={{ detmir_portal_worktime_url }} + DETMIR_PORTAL_ONE_C_URL={{ detmir_portal_one_c_url }} DETMIR_PORTAL_TIMEOUT_SECONDS=10 DETMIR_PORTAL_STATE_DIR=/var/lib/detmir-portal DETMIR_PORTAL_DLP_DB_PATH=/var/lib/activitywatch/dlp_warehouse.sqlite @@ -104,9 +107,20 @@ status_code: 200 return_content: true register: detmir_portal_health - failed_when: - - detmir_portal_health.status != 200 - - "'sources' not in detmir_portal_health.content" + failed_when: detmir_portal_health.status != 200 or detmir_portal_health.json.sources is not defined + changed_when: false + + - name: Verify detmir-portal reports API + ansible.builtin.uri: + url: "http://{{ detmir_portal_bind }}/api/reports" + method: GET + status_code: 200 + return_content: true + register: detmir_portal_reports + failed_when: > + detmir_portal_reports.status != 200 + or detmir_portal_reports.json.kpis is not defined + or 'derived detections/cases' not in (detmir_portal_reports.json.markdown | default('')) changed_when: false - name: Deploy DetMir DLP evidence API on AW server @@ -117,7 +131,8 @@ vars: aw_repo_root: "{{ playbook_dir | dirname }}" aw_rust_release_dir: "{{ (lookup('env', 'CARGO_TARGET_DIR') | default(aw_repo_root + '/adk-rust/target', true)) + '/release' }}" - detmir_evidence_bind: "{{ detmir_evidence_bind_override | default('192.0.2.13:8721') }}" + detmir_evidence_bind_host: "{{ ansible_host | default(inventory_hostname, true) }}" + detmir_evidence_bind: "{{ detmir_evidence_bind_override | default(detmir_evidence_bind_host + ':8721', true) }}" detmir_evidence_env_path: "/etc/detmir-portal-evidence.env" detmir_evidence_upload_token_path: "/var/lib/activitywatch/dlp-evidence/upload-token" @@ -244,7 +259,5 @@ status_code: 200 return_content: true register: detmir_evidence_health - failed_when: - - detmir_evidence_health.status != 200 - - "'evidence-only' not in detmir_evidence_health.content" + failed_when: detmir_evidence_health.status != 200 or detmir_evidence_health.json.mode | default('') != 'evidence-only' changed_when: false diff --git a/docs/DETMIR_PORTAL_GUI_PLAN_RU.md b/docs/DETMIR_PORTAL_GUI_PLAN_RU.md index 185c483..9a2244b 100644 --- a/docs/DETMIR_PORTAL_GUI_PLAN_RU.md +++ b/docs/DETMIR_PORTAL_GUI_PLAN_RU.md @@ -21,6 +21,23 @@ Read-only MVP выполнен и развернут: Следующий агент не должен начинать MVP заново. Работать дальше от deployed baseline и раздела `Phase 8: Post-MVP Enhancements`. +## Статус На 2026-06-03 + +Коммерческий post-MVP слой отчетов выполнен и развернут: + +- API: `GET /api/reports`; +- HTML route: `/reports` и `/portal/reports`; +- UI tab: `Отчеты`; +- отчет содержит KPI для владельца/руководителя: worktime users, active time, + active applications, DLP WARN/FAIL, evidence screenshots/items, open issues; +- отчет содержит Markdown export для передачи руководителю или заказчику; +- формулировка DLP/case показателей зафиксирована как + `derived detections/cases`, не как вручную подтвержденные инциденты; +- Ansible deploy gate теперь проверяет `/api/reports`, наличие `kpis` и + обязательный disclaimer; +- playbook больше не пишет TEST-NET defaults в live env, если в ignored + inventory доступны реальные hosts. + ## Цель Сделать единый web GUI для работы с контуром DetMir: @@ -776,11 +793,12 @@ Never rollback by deleting unrelated gateway routes. Only after read-only portal is stable: 1. role-aware views based on gateway username; -2. incident comments; -3. acknowledge/assign incident; +2. incident comments - done for incident action metadata; +3. acknowledge/assign incident - done with audit log; 4. safe "run check now"; 5. safe "open Telegram status"; -6. PDF/HTML daily owner report; +6. PDF/HTML daily owner report - partially done as `/api/reports` plus + portal Markdown export; PDF/HTML file generation remains future work; 7. historical trends; 8. AI summary with strict source citations; 9. action buttons with explicit allowlist and audit log.