feat(portal): add commercial reports view

This commit is contained in:
igor04091968
2026-06-03 10:36:42 +03:00
parent bf4e38f35e
commit 3a4e70c3b7
6 changed files with 512 additions and 13 deletions
+378 -1
View File
@@ -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<i32> {
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::<Vec<_>>()
}
],
"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<String>, value: impl Into<String>) -> 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<IncidentItem> {
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);
}
}
@@ -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;
}
@@ -249,6 +249,59 @@ function renderIncidents(data) {
`;
}
function renderKpiCards(items) {
return `<div class="summary-grid kpi-grid">${(items || []).map(item => `
<article class="card kpi-card">
<span class="badge ${statusClass(item.status)}">${escapeHtml(item.status || "INFO")}</span>
<h3>${escapeHtml(item.label)}</h3>
<p class="kpi-value">${escapeHtml(item.value)}</p>
<p class="muted">${escapeHtml(item.context || "")}</p>
</article>
`).join("")}</div>`;
}
function renderReportSections(sections) {
return `<div class="grid-2">${(sections || []).map(section => `
<section class="card report-section">
<h3>${escapeHtml(section.title)}</h3>
<div class="list compact-list">${(section.items || []).map(item => `
<div class="row compact-row">
<strong>${escapeHtml(item.label)}</strong>
<span class="muted">${escapeHtml(item.value)}</span>
<span class="badge ${statusClass(item.status)}">${escapeHtml(item.status || "INFO")}</span>
</div>
`).join("")}</div>
</section>
`).join("")}</div>`;
}
function renderReports(data) {
return `
<h2 class="section-title">Отчеты</h2>
<div class="grid-2">
<section class="card report-hero">
<span class="badge ${statusClass(data.severity)}">${escapeHtml(data.severity)}</span>
<h3>${escapeHtml(data.headline)}</h3>
<p class="muted">${escapeHtml(data.period || "")} · обновлено ${escapeHtml(data.generated_at_utc || "")}</p>
</section>
<section class="card">
<h3>Для руководителя</h3>
<div class="list compact-list">${(data.executive_points || []).map(point => `
<div class="row compact-row"><strong>Итог</strong><span class="muted">${escapeHtml(point)}</span><span></span></div>
`).join("")}</div>
</section>
</div>
<h3 class="section-title">Ключевые показатели</h3>
${renderKpiCards(data.kpis)}
<h3 class="section-title">Срезы отчета</h3>
${renderReportSections(data.sections)}
<section class="card markdown-card">
<h3>Markdown для отчета</h3>
<pre>${escapeHtml(data.markdown || "")}</pre>
</section>
`;
}
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) {
@@ -21,6 +21,7 @@
<button class="tab" data-tab="manager">Руководитель</button>
<button class="tab" data-tab="owner">Владелец</button>
<button class="tab" data-tab="incidents">Инциденты ИБ</button>
<button class="tab" data-tab="reports">Отчеты</button>
</nav>
<section id="summary" class="summary-grid"></section>
+22 -9
View File
@@ -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
+21 -3
View File
@@ -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.