feat(portal): add parallel DPD pilot portal
This commit is contained in:
Generated
+11
@@ -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"
|
||||
|
||||
@@ -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) => ({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
"\"": """,
|
||||
"'": "'",
|
||||
}[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>
|
||||
@@ -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 настраивается отдельно.
|
||||
Reference in New Issue
Block a user