diff --git a/adk-rust/Cargo.lock b/adk-rust/Cargo.lock index f4e60f9..9244b18 100644 --- a/adk-rust/Cargo.lock +++ b/adk-rust/Cargo.lock @@ -563,6 +563,17 @@ dependencies = [ "clap", ] +[[package]] +name = "detmir-dpd-portal" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "reqwest", + "serde_json", + "tiny_http", +] + [[package]] name = "detmir-grafana-check" version = "0.1.0" diff --git a/adk-rust/Cargo.toml b/adk-rust/Cargo.toml index d429e23..b32e08a 100644 --- a/adk-rust/Cargo.toml +++ b/adk-rust/Cargo.toml @@ -28,6 +28,7 @@ members = [ "crates/detmir-grafana-check", "crates/detmir-readiness", "crates/detmir-portal", + "crates/detmir-dpd-portal", "crates/aw-slo-monitor", "crates/aw-rus-healthd", "crates/detmir-check", diff --git a/adk-rust/crates/detmir-dpd-portal/Cargo.toml b/adk-rust/crates/detmir-dpd-portal/Cargo.toml new file mode 100644 index 0000000..caac18f --- /dev/null +++ b/adk-rust/crates/detmir-dpd-portal/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "detmir-dpd-portal" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish.workspace = true + +[dependencies] +anyhow.workspace = true +clap.workspace = true +reqwest.workspace = true +serde_json.workspace = true +tiny_http.workspace = true diff --git a/adk-rust/crates/detmir-dpd-portal/src/main.rs b/adk-rust/crates/detmir-dpd-portal/src/main.rs new file mode 100644 index 0000000..f20eef2 --- /dev/null +++ b/adk-rust/crates/detmir-dpd-portal/src/main.rs @@ -0,0 +1,164 @@ +use std::time::Duration; + +use anyhow::{Context, Result, anyhow}; +use clap::Parser; +use reqwest::blocking::Client; +use reqwest::header::{CONNECTION, HeaderValue}; +use serde_json::json; +use tiny_http::{Header, Method, Request, Response, Server, StatusCode}; + +const INDEX_HTML: &str = include_str!("static/index.html"); +const APP_CSS: &str = include_str!("static/app.css"); +const APP_JS: &str = include_str!("static/app.js"); + +#[derive(Clone, Debug, Parser)] +#[command(about = "DetMir DPD/Dioxus pilot portal")] +struct Cli { + #[arg(long, default_value = "127.0.0.1:8722", env = "DETMIR_DPD_BIND")] + bind: String, + + #[arg( + long, + default_value = "http://127.0.0.1:8720/portal/api", + env = "DETMIR_DPD_UPSTREAM_API" + )] + upstream_api: String, + + #[arg(long, default_value_t = 60, env = "DETMIR_DPD_TIMEOUT_SECONDS")] + timeout_seconds: u64, +} + +fn main() { + let code = match run() { + Ok(()) => 0, + Err(err) => { + eprintln!("{err:#}"); + 1 + } + }; + std::process::exit(code); +} + +fn run() -> Result<()> { + let args = Cli::parse(); + let server = Server::http(&args.bind).map_err(|err| anyhow!("bind {}: {err}", args.bind))?; + eprintln!("detmir-dpd-portal listening on http://{}", args.bind); + for request in server.incoming_requests() { + let args = args.clone(); + std::thread::spawn(move || { + if let Err(err) = handle_request(request, &args) { + eprintln!("detmir-dpd-portal request failed: {err:#}"); + } + }); + } + Ok(()) +} + +fn handle_request(request: Request, args: &Cli) -> Result<()> { + let method = request.method().clone(); + let path = normalize_path(request.url()); + if method != Method::Get { + return respond_text(request, StatusCode(405), "Method Not Allowed", "text/plain"); + } + match path.as_str() { + "/" | "/dpd" | "/dpd/" => respond_text( + request, + StatusCode(200), + INDEX_HTML, + "text/html; charset=utf-8", + ), + "/app.css" | "/dpd/app.css" => { + respond_text(request, StatusCode(200), APP_CSS, "text/css; charset=utf-8") + } + "/app.js" | "/dpd/app.js" => respond_text( + request, + StatusCode(200), + APP_JS, + "application/javascript; charset=utf-8", + ), + "/api/reports" | "/dpd/api/reports" => { + respond_json_text(request, StatusCode(200), &upstream_get(args, "reports")?) + } + "/api/health" | "/dpd/api/health" => respond_json_text( + request, + StatusCode(200), + &json!({ + "ok": true, + "portal": "detmir-dpd-portal", + "mode": "parallel-preview", + "upstream_api": sanitize_upstream(&args.upstream_api), + }) + .to_string(), + ), + _ => respond_text(request, StatusCode(404), "Not Found", "text/plain"), + } +} + +fn upstream_get(args: &Cli, endpoint: &str) -> Result { + let client = Client::builder() + .timeout(Duration::from_secs(args.timeout_seconds)) + .no_proxy() + .build() + .context("upstream HTTP client")?; + let url = format!( + "{}/{}", + args.upstream_api.trim_end_matches('/'), + endpoint.trim_start_matches('/') + ); + client + .get(&url) + .header(CONNECTION, HeaderValue::from_static("close")) + .send() + .with_context(|| format!("GET {url}"))? + .error_for_status() + .with_context(|| format!("upstream status for {url}"))? + .text() + .context("upstream response body") +} + +fn normalize_path(url: &str) -> String { + let raw = url.split('?').next().unwrap_or(url); + let path = raw.trim(); + if let Some(stripped) = path.strip_prefix("/portal-dpd") { + if stripped.is_empty() { + "/".to_string() + } else { + stripped.to_string() + } + } else { + path.to_string() + } +} + +fn sanitize_upstream(value: &str) -> String { + let trimmed = value.trim(); + if trimmed.starts_with("http://127.0.0.1") || trimmed.starts_with("http://localhost") { + "local detmir-portal api".to_string() + } else { + "configured upstream".to_string() + } +} + +fn respond_text( + request: Request, + status: StatusCode, + text: &str, + content_type: &str, +) -> Result<()> { + let response = Response::from_string(text.to_string()) + .with_status_code(status) + .with_header( + Header::from_bytes("Content-Type", content_type) + .map_err(|_| anyhow!("invalid Content-Type header"))?, + ) + .with_header( + Header::from_bytes("Cache-Control", "no-store") + .map_err(|_| anyhow!("invalid Cache-Control header"))?, + ); + request.respond(response)?; + Ok(()) +} + +fn respond_json_text(request: Request, status: StatusCode, text: &str) -> Result<()> { + respond_text(request, status, text, "application/json; charset=utf-8") +} diff --git a/adk-rust/crates/detmir-dpd-portal/src/static/app.css b/adk-rust/crates/detmir-dpd-portal/src/static/app.css new file mode 100644 index 0000000..3c2f3cf --- /dev/null +++ b/adk-rust/crates/detmir-dpd-portal/src/static/app.css @@ -0,0 +1,168 @@ +:root { + color-scheme: light; + --bg: #f8fafc; + --panel: #ffffff; + --text: #0f172a; + --muted: #64748b; + --line: #e2e8f0; + --blue: #2563eb; + --green: #16a34a; + --orange: #f97316; + --red: #dc2626; +} + +* { box-sizing: border-box; } +body { + margin: 0; + min-height: 100vh; + background: var(--bg); + color: var(--text); + font-family: Inter, Arial, sans-serif; +} + +.layout { + max-width: 1320px; + margin: 0 auto; + padding: 24px; +} + +.topbar, .section-head, .row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.topbar { + margin-bottom: 18px; +} + +.eyebrow, .label { + margin: 0 0 6px; + color: var(--blue); + font-size: 12px; + font-weight: 700; + text-transform: uppercase; +} + +h1, h2, p { margin-top: 0; } +h1 { margin-bottom: 6px; font-size: 32px; } +h2 { margin-bottom: 4px; font-size: 18px; } +.lead, .muted, .row small, .panel p { color: var(--muted); } + +.actions { + display: flex; + gap: 10px; + align-items: center; +} + +button, a { + border: 1px solid var(--line); + background: var(--panel); + color: var(--text); + border-radius: 6px; + padding: 9px 12px; + font: inherit; + text-decoration: none; +} + +button { + background: var(--blue); + color: #fff; + cursor: pointer; +} + +.status { + margin-bottom: 16px; + border-radius: 6px; + padding: 10px 12px; + border: 1px solid var(--line); + background: #fff; +} +.status.loading { color: var(--blue); } +.status.ready { color: var(--green); } +.status.error { color: var(--red); } + +.hero-grid { + display: grid; + grid-template-columns: 2fr 1fr 1fr 1fr; + gap: 14px; + margin-bottom: 14px; +} + +.work-area { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; +} + +.wide { grid-column: 1 / -1; } + +.panel { + border: 1px solid var(--line); + background: var(--panel); + border-radius: 8px; + padding: 18px; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04); +} + +.panel strong { + display: block; + font-size: 30px; + line-height: 1.15; +} + +.list { + display: grid; + gap: 10px; +} + +.row { + border-top: 1px solid var(--line); + padding-top: 10px; +} + +.row:first-child { + border-top: 0; + padding-top: 0; +} + +.badge { + border-radius: 999px; + padding: 4px 8px; + font-size: 12px; + font-weight: 700; + background: #e2e8f0; + color: #334155; +} +.badge.low, .badge.ok { background: #dcfce7; color: #166534; } +.badge.medium, .badge.warn, .badge.warning { background: #ffedd5; color: #9a3412; } +.badge.high, .badge.critical, .badge.fail { background: #fee2e2; color: #991b1b; } + +.table { + overflow-x: auto; +} + +table { + width: 100%; + border-collapse: collapse; +} + +th, td { + border-top: 1px solid var(--line); + padding: 10px 8px; + text-align: left; + vertical-align: top; +} + +th { + color: var(--muted); + font-weight: 600; +} + +@media (max-width: 980px) { + .topbar, .section-head { align-items: flex-start; flex-direction: column; } + .hero-grid, .work-area { grid-template-columns: 1fr; } + .wide { grid-column: auto; } + .layout { padding: 14px; } +} diff --git a/adk-rust/crates/detmir-dpd-portal/src/static/app.js b/adk-rust/crates/detmir-dpd-portal/src/static/app.js new file mode 100644 index 0000000..7c48b52 --- /dev/null +++ b/adk-rust/crates/detmir-dpd-portal/src/static/app.js @@ -0,0 +1,128 @@ +const state = { + reports: null, +}; + +const $ = (id) => document.getElementById(id); + +function setStatus(kind, text) { + const box = $("status"); + box.className = `status ${kind}`; + box.textContent = text; +} + +function text(value, fallback = "-") { + if (value === null || value === undefined || value === "") return fallback; + return String(value); +} + +function badge(level) { + const value = text(level, "UNKNOWN").toUpperCase(); + return `${value}`; +} + +function esc(value) { + return text(value, "").replace(/[&<>"']/g, (ch) => ({ + "&": "&", + "<": "<", + ">": ">", + "\"": """, + "'": "'", + }[ch])); +} + +async function loadReports() { + setStatus("loading", "Загрузка данных"); + const response = await fetch("api/reports", { cache: "no-store" }); + if (!response.ok) throw new Error(`API вернул ${response.status}`); + state.reports = await response.json(); + render(state.reports); + setStatus("ready", `Данные готовы · ${new Date().toLocaleTimeString("ru-RU")}`); +} + +function render(report) { + const summary = report.executive_dashboard?.summary || {}; + const security = report.security_events_summary || {}; + const coverage = report.agent_coverage_sla || {}; + const cases = report.executive_dashboard?.open_cases ?? 0; + $("mainRisk").textContent = text(summary.main_risk_cause, "Связанный риск не выражен"); + $("mainRiskText").textContent = text(summary.main_data_gap, "Критичных пробелов данных не найдено"); + $("securityEvents").textContent = text(security.events_24h, "0"); + $("securityStatus").textContent = security.status === "ok" + ? "События безопасности доступны" + : "Источник событий безопасности не активен"; + $("casesCount").textContent = text(cases, "0"); + $("coveragePct").textContent = coverage.coverage_pct === undefined ? "-" : `${coverage.coverage_pct}%`; + $("coverageStatus").textContent = text(coverage.sla_status, "статус не рассчитан"); + + renderBusinessRisk(report.business_risk || []); + renderCandidates(report.risk_incident_candidates || []); + renderCorrelation(report.security_correlation || []); +} + +function renderBusinessRisk(items) { + const box = $("businessRisk"); + const rows = items.slice(0, 5); + if (!rows.length) { + box.innerHTML = `

Риски подразделений не выявлены.

`; + return; + } + box.innerHTML = rows.map((item) => ` +
+
+ ${esc(item.department)} + ${esc((item.reasons || []).join("; ") || "причина не указана")} +
+ ${badge(item.risk_level)} +
+ `).join(""); +} + +function renderCandidates(items) { + const box = $("candidates"); + const rows = items.slice(0, 5); + if (!rows.length) { + box.innerHTML = `

Кандидатов на проверку сейчас нет.

`; + return; + } + box.innerHTML = rows.map((item) => ` +
+
+ ${esc(item.department || item.hostname || item.id)} + ${esc(item.reason || item.recommendation || "требует проверки")} +
+ ${badge(item.risk_level)} +
+ `).join(""); +} + +function renderCorrelation(items) { + const box = $("correlation"); + const rows = items.slice(0, 8); + if (!rows.length) { + box.innerHTML = `

Связь рисков и активности пока не выражена.

`; + return; + } + box.innerHTML = ` + + + + + + ${rows.map((item) => ` + + + + + + + `).join("")} + +
ПодразделениеУровень взаимосвязиПричинаРиск
${esc(item.department)}${esc(item.correlation_score ?? 0)}/100${esc(item.correlation_reason || "нет пояснения")}${badge(item.business_risk_level)}
+ `; +} + +$("refreshButton").addEventListener("click", () => { + loadReports().catch((error) => setStatus("error", `Ошибка получения данных: ${error.message}`)); +}); + +loadReports().catch((error) => setStatus("error", `Ошибка получения данных: ${error.message}`)); diff --git a/adk-rust/crates/detmir-dpd-portal/src/static/index.html b/adk-rust/crates/detmir-dpd-portal/src/static/index.html new file mode 100644 index 0000000..490692a --- /dev/null +++ b/adk-rust/crates/detmir-dpd-portal/src/static/index.html @@ -0,0 +1,76 @@ + + + + + + DetMir DPD Portal + + + +
+
+
+

DetMir DPD

+

Пилотный портал

+

Параллельный read-only интерфейс для оценки будущего Dioxus-подхода.

+
+
+ + Боевой портал +
+
+ +
Загрузка данных
+ +
+
+ Главный вывод +

Данные загружаются

+

Получение управленческого среза из текущего DetMir API.

+
+
+ События безопасности + - +

Источник проверяется

+
+
+ Расследования + - +

активные дела и проверки

+
+
+ Полнота данных + - +

оценка покрытия агентов

+
+
+ +
+
+
+

Риски подразделений

+ top-5 +
+
+
+ +
+
+

Требует проверки

+ очередь +
+
+
+ +
+
+

Связь рисков и активности

+ read-only +
+
+
+
+
+ + + diff --git a/docs/DPD_PORTAL_RU.md b/docs/DPD_PORTAL_RU.md new file mode 100644 index 0000000..3d1c18d --- /dev/null +++ b/docs/DPD_PORTAL_RU.md @@ -0,0 +1,46 @@ +# DPD/Dioxus Pilot Portal + +`detmir-dpd-portal` - первый параллельный read-only портал для проверки нового +подхода к интерфейсу DetMir. + +## Назначение + +Портал не заменяет текущий `detmir-portal`. Он работает рядом с ним, читает уже +существующий `/api/reports` и показывает компактный управленческий срез: + +- главный вывод; +- события безопасности; +- полнота данных; +- риски подразделений; +- очередь проверки; +- связь рисков и активности. + +## Почему отдельный бинарник + +Dioxus 0.7 fullstack использует разделение client/server и отдельные web/server +feature flags. Для безопасного первого этапа создан параллельный Rust shell, +который не меняет боевой портал и оставляет место для постепенного переноса UI на +Dioxus-компоненты. + +## Запуск + +```bash +detmir-dpd-portal \ + --bind 127.0.0.1:8722 \ + --upstream-api http://127.0.0.1:8720/portal/api +``` + +Переменные окружения: + +- `DETMIR_DPD_BIND`; +- `DETMIR_DPD_UPSTREAM_API`; +- `DETMIR_DPD_TIMEOUT_SECONDS` - по умолчанию 60 секунд, чтобы первый + холодный `/api/reports` после рестарта не выглядел как отказ DPD-портала. + +## Ограничения v0.1 + +- только read-only; +- без новых бизнес-сущностей; +- без замены текущего портала; +- данные берутся из существующего API; +- внешний reverse proxy настраивается отдельно.