feat(portal): add parallel DPD pilot portal

This commit is contained in:
igor04091968
2026-06-05 00:08:14 +03:00
parent 36fbcb1d63
commit a74fa24996
8 changed files with 608 additions and 0 deletions
+11
View File
@@ -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"
+1
View File
@@ -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",
@@ -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
@@ -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<String> {
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")
}
@@ -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; }
}
@@ -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 `<span class="badge ${value.toLowerCase()}">${value}</span>`;
}
function esc(value) {
return text(value, "").replace(/[&<>"']/g, (ch) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
"\"": "&quot;",
"'": "&#39;",
}[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 = `<p class="muted">Риски подразделений не выявлены.</p>`;
return;
}
box.innerHTML = rows.map((item) => `
<div class="row">
<div>
<strong>${esc(item.department)}</strong>
<small>${esc((item.reasons || []).join("; ") || "причина не указана")}</small>
</div>
${badge(item.risk_level)}
</div>
`).join("");
}
function renderCandidates(items) {
const box = $("candidates");
const rows = items.slice(0, 5);
if (!rows.length) {
box.innerHTML = `<p class="muted">Кандидатов на проверку сейчас нет.</p>`;
return;
}
box.innerHTML = rows.map((item) => `
<div class="row">
<div>
<strong>${esc(item.department || item.hostname || item.id)}</strong>
<small>${esc(item.reason || item.recommendation || "требует проверки")}</small>
</div>
${badge(item.risk_level)}
</div>
`).join("");
}
function renderCorrelation(items) {
const box = $("correlation");
const rows = items.slice(0, 8);
if (!rows.length) {
box.innerHTML = `<p class="muted">Связь рисков и активности пока не выражена.</p>`;
return;
}
box.innerHTML = `
<table>
<thead>
<tr><th>Подразделение</th><th>Уровень взаимосвязи</th><th>Причина</th><th>Риск</th></tr>
</thead>
<tbody>
${rows.map((item) => `
<tr>
<td>${esc(item.department)}</td>
<td>${esc(item.correlation_score ?? 0)}/100</td>
<td>${esc(item.correlation_reason || "нет пояснения")}</td>
<td>${badge(item.business_risk_level)}</td>
</tr>
`).join("")}
</tbody>
</table>
`;
}
$("refreshButton").addEventListener("click", () => {
loadReports().catch((error) => setStatus("error", `Ошибка получения данных: ${error.message}`));
});
loadReports().catch((error) => setStatus("error", `Ошибка получения данных: ${error.message}`));
@@ -0,0 +1,76 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>DetMir DPD Portal</title>
<link rel="stylesheet" href="app.css?v=20260604-dpd">
</head>
<body>
<main class="layout">
<header class="topbar">
<div>
<p class="eyebrow">DetMir DPD</p>
<h1>Пилотный портал</h1>
<p class="lead">Параллельный read-only интерфейс для оценки будущего Dioxus-подхода.</p>
</div>
<div class="actions">
<button id="refreshButton" type="button">Обновить</button>
<a href="/portal/" target="_blank" rel="noopener noreferrer">Боевой портал</a>
</div>
</header>
<section id="status" class="status loading">Загрузка данных</section>
<section class="hero-grid">
<article class="panel narrative-panel">
<span class="label">Главный вывод</span>
<h2 id="mainRisk">Данные загружаются</h2>
<p id="mainRiskText">Получение управленческого среза из текущего DetMir API.</p>
</article>
<article class="panel">
<span class="label">События безопасности</span>
<strong id="securityEvents">-</strong>
<p id="securityStatus">Источник проверяется</p>
</article>
<article class="panel">
<span class="label">Расследования</span>
<strong id="casesCount">-</strong>
<p>активные дела и проверки</p>
</article>
<article class="panel">
<span class="label">Полнота данных</span>
<strong id="coveragePct">-</strong>
<p id="coverageStatus">оценка покрытия агентов</p>
</article>
</section>
<section class="work-area">
<article class="panel">
<div class="section-head">
<h2>Риски подразделений</h2>
<span>top-5</span>
</div>
<div id="businessRisk" class="list"></div>
</article>
<article class="panel">
<div class="section-head">
<h2>Требует проверки</h2>
<span>очередь</span>
</div>
<div id="candidates" class="list"></div>
</article>
<article class="panel wide">
<div class="section-head">
<h2>Связь рисков и активности</h2>
<span>read-only</span>
</div>
<div id="correlation" class="table"></div>
</article>
</section>
</main>
<script src="app.js?v=20260604-dpd"></script>
</body>
</html>
+46
View File
@@ -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 настраивается отдельно.