chore(architecture): remove dpd portal legacy
This commit is contained in:
@@ -62,6 +62,7 @@ runtime, OCR/content-analysis, 1C/AI/ETL integration и MCP/dev helpers. Эти
|
||||
- [Release manifest 2026-06](docs/RELEASE_MANIFEST_2026-06.md)
|
||||
- [Эксплуатационный профиль](docs/OPERATIONAL_PROOF_PROFILE_RU.md)
|
||||
- [Коммерческие модули DetMir](docs/DETMIR_COMMERCIAL_MODULES_RU.md)
|
||||
- [Архитектурный baseline](docs/ARCHITECTURE_BASELINE_RU.md)
|
||||
- [Сторонние компоненты](THIRD_PARTY_COMPONENTS.md)
|
||||
- [Сторонние лицензии](THIRD_PARTY_LICENSES_RU.md)
|
||||
- [Архитектура](docs/ARCHITECTURE_RU.md)
|
||||
|
||||
Generated
-11
@@ -563,17 +563,6 @@ 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,7 +28,6 @@ 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",
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
[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
|
||||
@@ -1,366 +0,0 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use clap::Parser;
|
||||
use reqwest::blocking::Client;
|
||||
use reqwest::header::{
|
||||
CONNECTION, CONTENT_DISPOSITION, CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue, LOCATION,
|
||||
};
|
||||
use serde_json::json;
|
||||
use tiny_http::{Header, 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 parallel portal gateway")]
|
||||
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",
|
||||
env = "DETMIR_DPD_UPSTREAM_BASE"
|
||||
)]
|
||||
upstream_base: 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 path = normalize_path_with_query(request.url());
|
||||
match path_no_query(&path) {
|
||||
"/_dpd/health" => respond_json_text(
|
||||
request,
|
||||
StatusCode(200),
|
||||
&json!({
|
||||
"ok": true,
|
||||
"portal": "detmir-dpd-portal",
|
||||
"mode": "parallel-full-mirror",
|
||||
"upstream": sanitize_upstream(&args.upstream_base),
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
"/preview" => respond_redirect(request, "preview/"),
|
||||
"/preview/" => respond_text(
|
||||
request,
|
||||
StatusCode(200),
|
||||
INDEX_HTML,
|
||||
"text/html; charset=utf-8",
|
||||
),
|
||||
"/preview/app.css" => {
|
||||
respond_text(request, StatusCode(200), APP_CSS, "text/css; charset=utf-8")
|
||||
}
|
||||
"/preview/app.js" => respond_text(
|
||||
request,
|
||||
StatusCode(200),
|
||||
APP_JS,
|
||||
"application/javascript; charset=utf-8",
|
||||
),
|
||||
_ => proxy_to_upstream(request, args, &path),
|
||||
}
|
||||
}
|
||||
|
||||
fn proxy_to_upstream(mut request: Request, args: &Cli, path: &str) -> Result<()> {
|
||||
let method = reqwest::Method::from_bytes(request.method().as_str().as_bytes())
|
||||
.map_err(|err| anyhow!("unsupported method {}: {err}", request.method()))?;
|
||||
let forwarded_headers = forwarded_request_headers(&request)?;
|
||||
let mut body = Vec::new();
|
||||
request
|
||||
.as_reader()
|
||||
.read_to_end(&mut body)
|
||||
.context("read request body")?;
|
||||
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(args.timeout_seconds))
|
||||
.no_proxy()
|
||||
.build()
|
||||
.context("upstream HTTP client")?;
|
||||
|
||||
let url = format!("{}{}", args.upstream_base.trim_end_matches('/'), path);
|
||||
let mut upstream = client
|
||||
.request(method, &url)
|
||||
.header(CONNECTION, HeaderValue::from_static("close"))
|
||||
.body(body);
|
||||
for (name, value) in forwarded_headers {
|
||||
upstream = upstream.header(name, value);
|
||||
}
|
||||
|
||||
let upstream_response = upstream
|
||||
.send()
|
||||
.with_context(|| format!("proxy upstream {url}"))?;
|
||||
let status = StatusCode(upstream_response.status().as_u16());
|
||||
let response_headers = mirrored_response_headers(upstream_response.headers())?;
|
||||
let mut bytes = upstream_response
|
||||
.bytes()
|
||||
.context("upstream response body")?
|
||||
.to_vec();
|
||||
let content_type = response_headers
|
||||
.iter()
|
||||
.find(|(name, _)| name.eq_ignore_ascii_case("Content-Type"))
|
||||
.map(|(_, value)| value.as_str());
|
||||
rewrite_mirrored_body(&mut bytes, content_type);
|
||||
respond_bytes(request, status, bytes, response_headers)
|
||||
}
|
||||
|
||||
fn forwarded_request_headers(request: &Request) -> Result<Vec<(HeaderName, String)>> {
|
||||
let mut headers = Vec::new();
|
||||
for header in request.headers() {
|
||||
let name = header.field.as_str();
|
||||
let lower = name.to_string().to_ascii_lowercase();
|
||||
if is_hop_by_hop_header(&lower) {
|
||||
continue;
|
||||
}
|
||||
if should_forward_header(&lower) {
|
||||
let name = HeaderName::from_bytes(name.as_bytes())
|
||||
.with_context(|| format!("invalid request header name {name}"))?;
|
||||
headers.push((name, header.value.as_str().to_string()));
|
||||
}
|
||||
}
|
||||
Ok(headers)
|
||||
}
|
||||
|
||||
fn should_forward_header(lower_name: &str) -> bool {
|
||||
matches!(
|
||||
lower_name,
|
||||
"accept"
|
||||
| "accept-language"
|
||||
| "authorization"
|
||||
| "content-type"
|
||||
| "cookie"
|
||||
| "origin"
|
||||
| "referer"
|
||||
| "user-agent"
|
||||
| "x-forwarded-for"
|
||||
| "x-forwarded-host"
|
||||
| "x-forwarded-proto"
|
||||
| "x-gateway-user"
|
||||
| "x-real-ip"
|
||||
| "x-remote-user"
|
||||
)
|
||||
}
|
||||
|
||||
fn is_hop_by_hop_header(lower_name: &str) -> bool {
|
||||
matches!(
|
||||
lower_name,
|
||||
"connection"
|
||||
| "keep-alive"
|
||||
| "proxy-authenticate"
|
||||
| "proxy-authorization"
|
||||
| "te"
|
||||
| "trailer"
|
||||
| "transfer-encoding"
|
||||
| "upgrade"
|
||||
)
|
||||
}
|
||||
|
||||
fn mirrored_response_headers(headers: &HeaderMap) -> Result<Vec<(String, String)>> {
|
||||
let mut out = Vec::new();
|
||||
for name in [CONTENT_TYPE, CONTENT_DISPOSITION, LOCATION] {
|
||||
if let Some(value) = headers.get(&name) {
|
||||
let mut value = value
|
||||
.to_str()
|
||||
.with_context(|| format!("invalid upstream {name} header"))?
|
||||
.to_string();
|
||||
if name == LOCATION {
|
||||
value = rewrite_mirrored_text(&value);
|
||||
}
|
||||
out.push((name.as_str().to_string(), value));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn normalize_path_with_query(url: &str) -> String {
|
||||
let path = url.trim();
|
||||
for prefix in ["/portal-dpd", "/dpd"] {
|
||||
if let Some(stripped) = path.strip_prefix(prefix) {
|
||||
return if stripped.is_empty() {
|
||||
"/".to_string()
|
||||
} else {
|
||||
stripped.to_string()
|
||||
};
|
||||
}
|
||||
}
|
||||
if path.is_empty() {
|
||||
"/".to_string()
|
||||
} else {
|
||||
path.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn path_no_query(path: &str) -> &str {
|
||||
path.split('?').next().unwrap_or(path)
|
||||
}
|
||||
|
||||
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".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")
|
||||
}
|
||||
|
||||
fn respond_redirect(request: Request, location: &str) -> Result<()> {
|
||||
let response = Response::from_string("")
|
||||
.with_status_code(StatusCode(302))
|
||||
.with_header(
|
||||
Header::from_bytes("Location", location)
|
||||
.map_err(|_| anyhow!("invalid Location header"))?,
|
||||
)
|
||||
.with_header(
|
||||
Header::from_bytes("Cache-Control", "no-store")
|
||||
.map_err(|_| anyhow!("invalid Cache-Control header"))?,
|
||||
);
|
||||
request.respond(response)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn respond_bytes(
|
||||
request: Request,
|
||||
status: StatusCode,
|
||||
body: Vec<u8>,
|
||||
response_headers: Vec<(String, String)>,
|
||||
) -> Result<()> {
|
||||
let mut response = Response::from_data(body)
|
||||
.with_status_code(status)
|
||||
.with_header(
|
||||
Header::from_bytes("Cache-Control", "no-store")
|
||||
.map_err(|_| anyhow!("invalid Cache-Control header"))?,
|
||||
);
|
||||
for (name, value) in response_headers {
|
||||
response = response.with_header(
|
||||
Header::from_bytes(name, value)
|
||||
.map_err(|_| anyhow!("invalid mirrored response header"))?,
|
||||
);
|
||||
}
|
||||
request.respond(response)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rewrite_mirrored_body(body: &mut Vec<u8>, content_type: Option<&str>) {
|
||||
let Some(content_type) = content_type else {
|
||||
return;
|
||||
};
|
||||
if !(content_type.contains("text/html") || content_type.contains("javascript")) {
|
||||
return;
|
||||
}
|
||||
let Ok(text) = std::str::from_utf8(body) else {
|
||||
return;
|
||||
};
|
||||
if !text.contains("/portal") {
|
||||
return;
|
||||
}
|
||||
*body = rewrite_mirrored_text(text).into_bytes();
|
||||
}
|
||||
|
||||
fn rewrite_mirrored_text(text: &str) -> String {
|
||||
text.replace("/portal", "/dpd")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn normalizes_dpd_prefix_and_preserves_query() {
|
||||
assert_eq!(normalize_path_with_query("/dpd/"), "/");
|
||||
assert_eq!(
|
||||
normalize_path_with_query("/dpd/api/reports?anonymize=1"),
|
||||
"/api/reports?anonymize=1"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_path_with_query("/portal-dpd/api/cases/c1?format=markdown"),
|
||||
"/api/cases/c1?format=markdown"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_paths_are_not_upstream_mirrored() {
|
||||
assert_eq!(path_no_query("/preview/app.js?v=1"), "/preview/app.js");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrites_portal_absolute_paths_for_dpd_mirror() {
|
||||
let mut body = br#"href="/portal/api/reports"; const x = `/portal/api/cases/c1`;"#.to_vec();
|
||||
rewrite_mirrored_body(&mut body, Some("application/javascript; charset=utf-8"));
|
||||
let text = String::from_utf8(body).unwrap();
|
||||
assert!(text.contains("/dpd/api/reports"));
|
||||
assert!(text.contains("/dpd/api/cases/c1"));
|
||||
assert!(!text.contains("/portal/api"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forwards_operator_identity_headers_but_not_hop_by_hop_headers() {
|
||||
assert!(should_forward_header("x-remote-user"));
|
||||
assert!(should_forward_header("x-gateway-user"));
|
||||
assert!(should_forward_header("authorization"));
|
||||
assert!(should_forward_header("cookie"));
|
||||
assert!(!should_forward_header("x-debug-private"));
|
||||
assert!(is_hop_by_hop_header("connection"));
|
||||
assert!(is_hop_by_hop_header("transfer-encoding"));
|
||||
assert!(!is_hop_by_hop_header("x-remote-user"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrites_portal_locations_for_dpd_mirror() {
|
||||
assert_eq!(
|
||||
rewrite_mirrored_text("/portal/reports?format=markdown"),
|
||||
"/dpd/reports?format=markdown"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
: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; }
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
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}`));
|
||||
@@ -1,76 +0,0 @@
|
||||
<!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 интерфейс для оценки будущего React/Tauri-подхода.</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>
|
||||
@@ -9,10 +9,6 @@
|
||||
{
|
||||
"url": "/api",
|
||||
"description": "Gateway-relative API base"
|
||||
},
|
||||
{
|
||||
"url": "/dpd/api",
|
||||
"description": "DPD mirror API base"
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# Архитектурный baseline AWatch-rus
|
||||
|
||||
Дата фиксации: 2026-06-05
|
||||
|
||||
## Целевая архитектура
|
||||
|
||||
```text
|
||||
Backend/API: Rust
|
||||
Agent: Rust
|
||||
Portal: Rust SSR + HTML + HTMX
|
||||
Future Enterprise UI: React + TypeScript
|
||||
Future Desktop Forensics: Tauri + React + Rust core
|
||||
```
|
||||
|
||||
## Поддерживаемый контур
|
||||
|
||||
- Текущий production/pilot портал: Rust server-rendered HTML с HTMX-ready
|
||||
поведением и обычным JavaScript без отдельного frontend framework.
|
||||
- Контрактный слой для будущих интерфейсов: JSON API, OpenAPI 3.1 и
|
||||
TypeScript declarations.
|
||||
- Бизнес-логика, расчёты, workflow и работа с JSON-хранилищами остаются на
|
||||
стороне Rust backend/API.
|
||||
- Future Enterprise UI может быть реализован на React + TypeScript только
|
||||
поверх опубликованных JSON API.
|
||||
- Future Desktop Forensics может быть реализован как Tauri + React + Rust core,
|
||||
не ломая текущий портал.
|
||||
|
||||
## Исключено из roadmap
|
||||
|
||||
Dioxus и DPD Portal исключены из архитектурного roadmap проекта.
|
||||
|
||||
Это означает:
|
||||
|
||||
- новые Dioxus/DPD crate не добавляются;
|
||||
- `/dpd/` и `/dpd/api/*` не являются частью публичного API-контракта;
|
||||
- будущий UI не должен парсить HTML текущего портала;
|
||||
- новые UI-фреймворки требуют отдельного architecture decision;
|
||||
- breaking changes API требуют version bump контракта и migration window.
|
||||
|
||||
## Compatibility baseline
|
||||
|
||||
Обязательные production endpoints:
|
||||
|
||||
- `/portal`;
|
||||
- `/reports`;
|
||||
- `/portal/reports`;
|
||||
- `/api/reports`;
|
||||
- `/api/contracts`;
|
||||
- `/api/contracts/openapi.json`;
|
||||
- `/api/contracts/typescript.d.ts`.
|
||||
|
||||
Правила совместимости:
|
||||
|
||||
- изменения API должны быть additive;
|
||||
- клиенты игнорируют неизвестные поля;
|
||||
- optional-поля могут отсутствовать;
|
||||
- `null` не должен ломать UI;
|
||||
- публичные JSON-поля не переименовываются без новой версии контракта.
|
||||
@@ -0,0 +1,46 @@
|
||||
# Аудит очистки архитектуры от DPD/Dioxus
|
||||
|
||||
Дата: 2026-06-05
|
||||
|
||||
## Цель
|
||||
|
||||
Зафиксировать результат проверки наследия DPD/Dioxus после усиления API
|
||||
контрактов в коммите `bc2f56e`.
|
||||
|
||||
## Найденные DPD-артефакты до очистки
|
||||
|
||||
- workspace crate `adk-rust/crates/detmir-dpd-portal`;
|
||||
- запись workspace member `crates/detmir-dpd-portal` в `adk-rust/Cargo.toml`;
|
||||
- package entry `detmir-dpd-portal` в `adk-rust/Cargo.lock`;
|
||||
- документ `docs/DPD_PORTAL_RU.md`;
|
||||
- OpenAPI server base `/dpd/api`;
|
||||
- упоминания DPD mirror в `docs/PORTAL_API_CONTRACTS_RU.md`;
|
||||
- упоминания DPD mirror в `docs/UI_ARCHITECTURE_BASELINE_RU.md`.
|
||||
|
||||
## Найденные Dioxus-артефакты до очистки
|
||||
|
||||
Production-код, workspace crates, scripts, examples и OpenAPI/TypeScript
|
||||
контракты не содержали Dioxus-зависимостей или `rsx!`-кода.
|
||||
|
||||
Оставались только запретительные упоминания в архитектурном baseline:
|
||||
|
||||
- Dioxus не является частью roadmap;
|
||||
- Dioxus не добавлять без отдельного architecture decision.
|
||||
|
||||
## Выполненное решение
|
||||
|
||||
- DPD Portal исключён из workspace и публичного contract layer.
|
||||
- Документ `docs/DPD_PORTAL_RU.md` удалён как описание экспериментальной ветки.
|
||||
- OpenAPI больше не публикует `/dpd/api`.
|
||||
- Runtime-слой DPD Portal выведен из эксплуатации: app-service остановлен и
|
||||
выключен, gateway route удалён, перед удалением создан локальный backup.
|
||||
- `docs/ARCHITECTURE_BASELINE_RU.md`,
|
||||
`docs/UI_ARCHITECTURE_BASELINE_RU.md` и
|
||||
`docs/PORTAL_API_CONTRACTS_RU.md` фиксируют единственную целевую архитектуру:
|
||||
Rust backend/API, Rust agent, Rust SSR + HTML + HTMX portal, будущий
|
||||
React/TypeScript UI и будущий Tauri + React + Rust core desktop forensics.
|
||||
|
||||
## Статус
|
||||
|
||||
DPD/Dioxus остаются только как исторически упомянутые исключённые направления.
|
||||
Они не являются production runtime, публичным API или частью roadmap.
|
||||
@@ -1,127 +0,0 @@
|
||||
# DPD Parallel Portal
|
||||
|
||||
`detmir-dpd-portal` - параллельный gateway-портал DetMir. Он не заменяет
|
||||
текущий `detmir-portal`, а повторяет его функциональность через отдельный
|
||||
маршрут `/dpd/`.
|
||||
|
||||
## Назначение
|
||||
|
||||
DPD нужен для безопасной эволюции интерфейса:
|
||||
|
||||
- основной HTML-портал `/portal/` остаётся стабильным;
|
||||
- `/dpd/` работает как полный mirror текущего портала;
|
||||
- будущий React/Tauri UI сможет использовать те же API-контракты;
|
||||
- новые UI-решения проверяются без cutover и без дублирования бизнес-логики.
|
||||
- будущий UI не парсит HTML текущего портала как источник данных.
|
||||
|
||||
DPD проксирует:
|
||||
|
||||
- вкладки;
|
||||
- API;
|
||||
- действия проверки;
|
||||
- дела;
|
||||
- расследования;
|
||||
- markdown/download endpoints;
|
||||
- материалы проверки.
|
||||
|
||||
DPD gateway сохраняет операторский контекст для симметрии с основным порталом:
|
||||
`X-Remote-User`, `X-Gateway-User`, `X-Forwarded-*`, `User-Agent`, `Referer`,
|
||||
`Origin`, `Cookie` и `Authorization` передаются в upstream, hop-by-hop
|
||||
заголовки не передаются. Это нужно, чтобы audit/review/case-действия через
|
||||
`/dpd/` фиксировались так же, как через `/portal/`.
|
||||
|
||||
## Архитектурное решение
|
||||
|
||||
Выбран зрелый путь:
|
||||
|
||||
```text
|
||||
detmir-portal Rust backend
|
||||
|
|
||||
| stable /api/contracts
|
||||
v
|
||||
current HTML UI + DPD mirror + future React/Tauri UI
|
||||
```
|
||||
|
||||
Не используется отдельный экспериментальный UI-фреймворк в production path.
|
||||
Сначала фиксируются API-контракты, тестируется совместимость и только затем
|
||||
добавляется новый frontend.
|
||||
|
||||
## Запуск
|
||||
|
||||
```bash
|
||||
detmir-dpd-portal \
|
||||
--bind 127.0.0.1:8722 \
|
||||
--upstream-base http://127.0.0.1:8720
|
||||
```
|
||||
|
||||
Переменные окружения:
|
||||
|
||||
- `DETMIR_DPD_BIND`;
|
||||
- `DETMIR_DPD_UPSTREAM_BASE`;
|
||||
- `DETMIR_DPD_TIMEOUT_SECONDS` - по умолчанию 60 секунд, чтобы первый
|
||||
холодный `/api/reports` после рестарта не выглядел как отказ DPD-портала.
|
||||
|
||||
## Маршруты
|
||||
|
||||
- `/dpd/` - полный параллельный mirror текущего портала.
|
||||
- `/dpd/_dpd/health` - health самого DPD gateway.
|
||||
- `/dpd/preview/` - компактный read-only preview-экран.
|
||||
|
||||
## API-контракты
|
||||
|
||||
Контракты публикует основной `detmir-portal`, а DPD зеркалирует их:
|
||||
|
||||
- `/api/contracts`;
|
||||
- `/api/contracts/openapi.json`;
|
||||
- `/api/contracts/typescript.d.ts`;
|
||||
- `/dpd/api/contracts`;
|
||||
- `/dpd/api/contracts/openapi.json`;
|
||||
- `/dpd/api/contracts/typescript.d.ts`.
|
||||
|
||||
Правила совместимости:
|
||||
|
||||
- изменения API должны быть additive;
|
||||
- клиенты React/Tauri обязаны игнорировать неизвестные поля;
|
||||
- optional-поля могут отсутствовать;
|
||||
- `null` не должен ломать UI;
|
||||
- breaking changes требуют version bump контракта и отдельного architecture
|
||||
decision;
|
||||
- будущий React/Tauri UI не должен парсить HTML текущего портала.
|
||||
|
||||
## Проверка симметрии
|
||||
|
||||
Минимальная проверка на сервере:
|
||||
|
||||
```bash
|
||||
systemctl is-active detmir-dpd-portal detmir-portal nginx --no-pager
|
||||
curl -sS http://127.0.0.1:8722/_dpd/health
|
||||
curl -sS -o /dev/null -w 'dpd_index=%{http_code}\n' http://127.0.0.1:8722/
|
||||
curl -sS -o /dev/null -w 'dpd_reports=%{http_code}\n' http://127.0.0.1:8722/api/reports
|
||||
curl -sS -o /dev/null -w 'dpd_contracts=%{http_code}\n' http://127.0.0.1:8722/api/contracts
|
||||
```
|
||||
|
||||
Браузерная проверка с ноутбука:
|
||||
|
||||
```bash
|
||||
ssh -o ExitOnForwardFailure=yes -o ServerAliveInterval=15 -o ServerAliveCountMax=3 \
|
||||
-N -L 18720:127.0.0.1:8720 <GATEWAY_HOST>
|
||||
|
||||
detmir-dpd-portal \
|
||||
--bind 127.0.0.1:18722 \
|
||||
--upstream-base http://127.0.0.1:18720
|
||||
|
||||
DETMIR_PORTAL_SMOKE_URL=http://127.0.0.1:18722/ \
|
||||
DETMIR_PORTAL_SMOKE_TIMEOUT_MS=70000 \
|
||||
node scripts/detmir-portal-tabs-smoke.mjs
|
||||
```
|
||||
|
||||
Ожидаемый результат: `ok=true`, все вкладки работают, нет JS/API ошибок,
|
||||
абсолютные `/portal/...` ссылки в HTML/JS переписаны в `/dpd/...`.
|
||||
|
||||
## Ограничения
|
||||
|
||||
- DPD gateway не добавляет новые бизнес-сущности.
|
||||
- DPD gateway не заменяет текущий портал.
|
||||
- Данные берутся из существующего портала/API.
|
||||
- Новый React/Tauri UI должен появляться поверх контрактов, а не через
|
||||
копирование backend-логики.
|
||||
@@ -16,12 +16,6 @@ React/Tauri-интерфейса без переписывания backend-ло
|
||||
- `GET /api/contracts/openapi.json`
|
||||
- `GET /api/contracts/typescript.d.ts`
|
||||
|
||||
DPD mirror:
|
||||
|
||||
- `GET /dpd/api/contracts`
|
||||
- `GET /dpd/api/contracts/openapi.json`
|
||||
- `GET /dpd/api/contracts/typescript.d.ts`
|
||||
|
||||
## Правила совместимости
|
||||
|
||||
- Изменения API должны быть additive.
|
||||
@@ -56,9 +50,9 @@ DPD mirror:
|
||||
|
||||
- HTML-портал не удаляется.
|
||||
- Маршрут `/portal/` остаётся стабильным.
|
||||
- DPD `/dpd/` остаётся параллельным mirror.
|
||||
- Backend-расчёты, JSON-хранилища и workflow не дублируются во frontend.
|
||||
- Публичные JSON-поля не переименовываются без новой версии контракта.
|
||||
- Dioxus и DPD Portal исключены из архитектурного roadmap проекта.
|
||||
|
||||
## Проверка
|
||||
|
||||
@@ -66,7 +60,6 @@ DPD mirror:
|
||||
curl -sS http://127.0.0.1:8720/api/contracts | jq .
|
||||
curl -sS http://127.0.0.1:8720/api/contracts/openapi.json | jq .openapi
|
||||
curl -sS http://127.0.0.1:8720/api/contracts/typescript.d.ts | head
|
||||
curl -sS http://127.0.0.1:8722/api/contracts | jq .
|
||||
```
|
||||
|
||||
Ожидаемый результат:
|
||||
@@ -75,4 +68,4 @@ curl -sS http://127.0.0.1:8722/api/contracts | jq .
|
||||
- `contract_version` заполнен;
|
||||
- OpenAPI JSON валиден;
|
||||
- TypeScript declarations доступны;
|
||||
- DPD отдаёт те же контрактные endpoints через mirror.
|
||||
- будущий React/Tauri UI может использовать JSON API без HTML-парсинга.
|
||||
|
||||
@@ -10,7 +10,8 @@ Agent: Rust
|
||||
Current Portal: Rust server-rendered HTML + HTMX
|
||||
Future Enterprise UI: React + TypeScript
|
||||
Future Desktop Forensics: Tauri + React + Rust core
|
||||
Dioxus: out of scope / не рассматривается
|
||||
Dioxus: excluded from roadmap
|
||||
DPD Portal: excluded from roadmap
|
||||
```
|
||||
|
||||
## Правила
|
||||
@@ -21,7 +22,7 @@ Dioxus: out of scope / не рассматривается
|
||||
- Будущий React/Tauri UI не должен ломать текущий портал.
|
||||
- Agent и backend остаются Rust-first.
|
||||
- Новые UI-фреймворки не добавлять без отдельного architecture decision.
|
||||
- Dioxus не добавлять и не рассматривать.
|
||||
- Dioxus и DPD Portal исключены из архитектурного roadmap проекта.
|
||||
- Будущий React/Tauri UI не должен парсить HTML текущего портала как источник
|
||||
данных.
|
||||
|
||||
@@ -31,9 +32,8 @@ Dioxus: out of scope / не рассматривается
|
||||
|
||||
1. Укрепить текущий Rust web portal.
|
||||
2. Зафиксировать стабильные JSON API-контракты.
|
||||
3. Поддерживать DPD `/dpd/` как параллельный mirror для проверки совместимости.
|
||||
4. Готовить будущий React/Tauri UI только поверх опубликованных контрактов.
|
||||
5. Любые breaking changes проводить только через version bump контракта и
|
||||
3. Готовить будущий React/Tauri UI только поверх опубликованных контрактов.
|
||||
4. Любые breaking changes проводить только через version bump контракта и
|
||||
отдельное architecture decision.
|
||||
|
||||
## Контрактный слой
|
||||
@@ -44,12 +44,6 @@ Dioxus: out of scope / не рассматривается
|
||||
- `/api/contracts/openapi.json`;
|
||||
- `/api/contracts/typescript.d.ts`.
|
||||
|
||||
DPD mirror endpoints:
|
||||
|
||||
- `/dpd/api/contracts`;
|
||||
- `/dpd/api/contracts/openapi.json`;
|
||||
- `/dpd/api/contracts/typescript.d.ts`.
|
||||
|
||||
Правило совместимости: изменения API должны быть additive. Клиенты обязаны
|
||||
игнорировать неизвестные поля, корректно обрабатывать отсутствующие optional
|
||||
поля и не падать на `null`.
|
||||
|
||||
Reference in New Issue
Block a user