feat(portal): add commercial reports view
This commit is contained in:
@@ -357,6 +357,20 @@ struct Snapshot {
|
|||||||
one_c: SourceStatus,
|
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() {
|
fn main() {
|
||||||
let code = match run() {
|
let code = match run() {
|
||||||
Ok(code) => code,
|
Ok(code) => code,
|
||||||
@@ -376,6 +390,7 @@ fn run() -> Result<i32> {
|
|||||||
let smoke = json!({
|
let smoke = json!({
|
||||||
"health": build_health(&snapshot),
|
"health": build_health(&snapshot),
|
||||||
"summary": build_summary(&snapshot),
|
"summary": build_summary(&snapshot),
|
||||||
|
"reports": build_reports(&snapshot, &incident_state, &build_dlp_evidence_response(&args)),
|
||||||
"incidents": build_incidents(&snapshot, &incident_state),
|
"incidents": build_incidents(&snapshot, &incident_state),
|
||||||
"dlp_evidence": build_dlp_evidence_response(&args),
|
"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);
|
return handle_evidence_screenshot(request, args, &evidence_id, download);
|
||||||
}
|
}
|
||||||
match path.as_str() {
|
match path.as_str() {
|
||||||
"/" | "/operator" | "/manager" | "/owner" | "/incidents" => respond_text(
|
"/" | "/operator" | "/manager" | "/owner" | "/incidents" | "/reports" => respond_text(
|
||||||
request,
|
request,
|
||||||
StatusCode(200),
|
StatusCode(200),
|
||||||
INDEX_HTML,
|
INDEX_HTML,
|
||||||
@@ -443,6 +458,15 @@ fn handle_request(request: Request, args: &Cli) -> Result<()> {
|
|||||||
let snapshot = build_snapshot(args);
|
let snapshot = build_snapshot(args);
|
||||||
respond_json(request, &build_owner(&snapshot))
|
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" => {
|
"/api/incidents" => {
|
||||||
let snapshot = build_snapshot(args);
|
let snapshot = build_snapshot(args);
|
||||||
let incident_state = load_incident_state_best_effort(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> {
|
fn build_incidents(snapshot: &Snapshot, state: &IncidentStateFile) -> Vec<IncidentItem> {
|
||||||
let mut incidents = Vec::new();
|
let mut incidents = Vec::new();
|
||||||
for source in [
|
for source in [
|
||||||
@@ -2321,4 +2583,119 @@ mod tests {
|
|||||||
assert!(!constant_time_eq(b"secret", b"other"));
|
assert!(!constant_time_eq(b"secret", b"other"));
|
||||||
assert!(!constant_time_eq(b"secret", b"secret2"));
|
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;
|
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 {
|
.evidence-row {
|
||||||
grid-template-columns: minmax(180px, 1fr) minmax(260px, 2fr) auto auto;
|
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() {
|
async function refresh() {
|
||||||
if (!state.links) state.links = await loadJson("/links");
|
if (!state.links) state.links = await loadJson("/links");
|
||||||
const summary = await loadJson("/summary");
|
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: [] }));
|
const evidence = await loadJson("/dlp/evidence").catch(error => ({ ok: false, error: error.message, items: [] }));
|
||||||
content.innerHTML = renderIncidents({ incidents: data, evidence });
|
content.innerHTML = renderIncidents({ incidents: data, evidence });
|
||||||
}
|
}
|
||||||
|
if (state.tab === "reports") content.innerHTML = renderReports(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setTab(tab) {
|
function setTab(tab) {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
<button class="tab" data-tab="manager">Руководитель</button>
|
<button class="tab" data-tab="manager">Руководитель</button>
|
||||||
<button class="tab" data-tab="owner">Владелец</button>
|
<button class="tab" data-tab="owner">Владелец</button>
|
||||||
<button class="tab" data-tab="incidents">Инциденты ИБ</button>
|
<button class="tab" data-tab="incidents">Инциденты ИБ</button>
|
||||||
|
<button class="tab" data-tab="reports">Отчеты</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<section id="summary" class="summary-grid"></section>
|
<section id="summary" class="summary-grid"></section>
|
||||||
|
|||||||
@@ -9,6 +9,9 @@
|
|||||||
aw_rust_release_dir: "{{ (lookup('env', 'CARGO_TARGET_DIR') | default(aw_repo_root + '/adk-rust/target', true)) + '/release' }}"
|
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_bind: "{{ detmir_portal_bind_override | default('127.0.0.1:8720') }}"
|
||||||
detmir_portal_env_path: "/etc/detmir-portal.env"
|
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:
|
tasks:
|
||||||
- name: Check local detmir-portal binary
|
- name: Check local detmir-portal binary
|
||||||
@@ -42,8 +45,8 @@
|
|||||||
DETMIR_PORTAL_STATUS_CMD=detmir-status --json
|
DETMIR_PORTAL_STATUS_CMD=detmir-status --json
|
||||||
DETMIR_PORTAL_CHECK_CMD=detmir-check --json
|
DETMIR_PORTAL_CHECK_CMD=detmir-check --json
|
||||||
DETMIR_PORTAL_FAILED_UNITS_CMD=systemctl --failed --no-pager
|
DETMIR_PORTAL_FAILED_UNITS_CMD=systemctl --failed --no-pager
|
||||||
DETMIR_PORTAL_WORKTIME_URL=http://192.0.2.13:5610
|
DETMIR_PORTAL_WORKTIME_URL={{ detmir_portal_worktime_url }}
|
||||||
DETMIR_PORTAL_ONE_C_URL=http://192.0.2.2:8710
|
DETMIR_PORTAL_ONE_C_URL={{ detmir_portal_one_c_url }}
|
||||||
DETMIR_PORTAL_TIMEOUT_SECONDS=10
|
DETMIR_PORTAL_TIMEOUT_SECONDS=10
|
||||||
DETMIR_PORTAL_STATE_DIR=/var/lib/detmir-portal
|
DETMIR_PORTAL_STATE_DIR=/var/lib/detmir-portal
|
||||||
DETMIR_PORTAL_DLP_DB_PATH=/var/lib/activitywatch/dlp_warehouse.sqlite
|
DETMIR_PORTAL_DLP_DB_PATH=/var/lib/activitywatch/dlp_warehouse.sqlite
|
||||||
@@ -104,9 +107,20 @@
|
|||||||
status_code: 200
|
status_code: 200
|
||||||
return_content: true
|
return_content: true
|
||||||
register: detmir_portal_health
|
register: detmir_portal_health
|
||||||
failed_when:
|
failed_when: detmir_portal_health.status != 200 or detmir_portal_health.json.sources is not defined
|
||||||
- detmir_portal_health.status != 200
|
changed_when: false
|
||||||
- "'sources' not in detmir_portal_health.content"
|
|
||||||
|
- 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
|
changed_when: false
|
||||||
|
|
||||||
- name: Deploy DetMir DLP evidence API on AW server
|
- name: Deploy DetMir DLP evidence API on AW server
|
||||||
@@ -117,7 +131,8 @@
|
|||||||
vars:
|
vars:
|
||||||
aw_repo_root: "{{ playbook_dir | dirname }}"
|
aw_repo_root: "{{ playbook_dir | dirname }}"
|
||||||
aw_rust_release_dir: "{{ (lookup('env', 'CARGO_TARGET_DIR') | default(aw_repo_root + '/adk-rust/target', true)) + '/release' }}"
|
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_env_path: "/etc/detmir-portal-evidence.env"
|
||||||
detmir_evidence_upload_token_path: "/var/lib/activitywatch/dlp-evidence/upload-token"
|
detmir_evidence_upload_token_path: "/var/lib/activitywatch/dlp-evidence/upload-token"
|
||||||
|
|
||||||
@@ -244,7 +259,5 @@
|
|||||||
status_code: 200
|
status_code: 200
|
||||||
return_content: true
|
return_content: true
|
||||||
register: detmir_evidence_health
|
register: detmir_evidence_health
|
||||||
failed_when:
|
failed_when: detmir_evidence_health.status != 200 or detmir_evidence_health.json.mode | default('') != 'evidence-only'
|
||||||
- detmir_evidence_health.status != 200
|
|
||||||
- "'evidence-only' not in detmir_evidence_health.content"
|
|
||||||
changed_when: false
|
changed_when: false
|
||||||
|
|||||||
@@ -21,6 +21,23 @@ Read-only MVP выполнен и развернут:
|
|||||||
Следующий агент не должен начинать MVP заново. Работать дальше от deployed
|
Следующий агент не должен начинать MVP заново. Работать дальше от deployed
|
||||||
baseline и раздела `Phase 8: Post-MVP Enhancements`.
|
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:
|
Сделать единый web GUI для работы с контуром DetMir:
|
||||||
@@ -776,11 +793,12 @@ Never rollback by deleting unrelated gateway routes.
|
|||||||
Only after read-only portal is stable:
|
Only after read-only portal is stable:
|
||||||
|
|
||||||
1. role-aware views based on gateway username;
|
1. role-aware views based on gateway username;
|
||||||
2. incident comments;
|
2. incident comments - done for incident action metadata;
|
||||||
3. acknowledge/assign incident;
|
3. acknowledge/assign incident - done with audit log;
|
||||||
4. safe "run check now";
|
4. safe "run check now";
|
||||||
5. safe "open Telegram status";
|
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;
|
7. historical trends;
|
||||||
8. AI summary with strict source citations;
|
8. AI summary with strict source citations;
|
||||||
9. action buttons with explicit allowlist and audit log.
|
9. action buttons with explicit allowlist and audit log.
|
||||||
|
|||||||
Reference in New Issue
Block a user