chore(architecture): remove dpd portal legacy

This commit is contained in:
igor04091968
2026-06-05 08:55:13 +03:00
parent bc2f56ef8f
commit 2ec1836e8c
14 changed files with 112 additions and 915 deletions
+1
View File
@@ -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)
-11
View File
@@ -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"
-1
View File
@@ -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) => ({
"&": "&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}`));
@@ -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": [
+58
View File
@@ -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.
-127
View File
@@ -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-логики.
+2 -9
View File
@@ -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-парсинга.
+5 -11
View File
@@ -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`.