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)
|
- [Release manifest 2026-06](docs/RELEASE_MANIFEST_2026-06.md)
|
||||||
- [Эксплуатационный профиль](docs/OPERATIONAL_PROOF_PROFILE_RU.md)
|
- [Эксплуатационный профиль](docs/OPERATIONAL_PROOF_PROFILE_RU.md)
|
||||||
- [Коммерческие модули DetMir](docs/DETMIR_COMMERCIAL_MODULES_RU.md)
|
- [Коммерческие модули DetMir](docs/DETMIR_COMMERCIAL_MODULES_RU.md)
|
||||||
|
- [Архитектурный baseline](docs/ARCHITECTURE_BASELINE_RU.md)
|
||||||
- [Сторонние компоненты](THIRD_PARTY_COMPONENTS.md)
|
- [Сторонние компоненты](THIRD_PARTY_COMPONENTS.md)
|
||||||
- [Сторонние лицензии](THIRD_PARTY_LICENSES_RU.md)
|
- [Сторонние лицензии](THIRD_PARTY_LICENSES_RU.md)
|
||||||
- [Архитектура](docs/ARCHITECTURE_RU.md)
|
- [Архитектура](docs/ARCHITECTURE_RU.md)
|
||||||
|
|||||||
Generated
-11
@@ -563,17 +563,6 @@ dependencies = [
|
|||||||
"clap",
|
"clap",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "detmir-dpd-portal"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"anyhow",
|
|
||||||
"clap",
|
|
||||||
"reqwest",
|
|
||||||
"serde_json",
|
|
||||||
"tiny_http",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "detmir-grafana-check"
|
name = "detmir-grafana-check"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ members = [
|
|||||||
"crates/detmir-grafana-check",
|
"crates/detmir-grafana-check",
|
||||||
"crates/detmir-readiness",
|
"crates/detmir-readiness",
|
||||||
"crates/detmir-portal",
|
"crates/detmir-portal",
|
||||||
"crates/detmir-dpd-portal",
|
|
||||||
"crates/aw-slo-monitor",
|
"crates/aw-slo-monitor",
|
||||||
"crates/aw-rus-healthd",
|
"crates/aw-rus-healthd",
|
||||||
"crates/detmir-check",
|
"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",
|
"url": "/api",
|
||||||
"description": "Gateway-relative API base"
|
"description": "Gateway-relative API base"
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "/dpd/api",
|
|
||||||
"description": "DPD mirror API base"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"tags": [
|
"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/openapi.json`
|
||||||
- `GET /api/contracts/typescript.d.ts`
|
- `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.
|
- Изменения API должны быть additive.
|
||||||
@@ -56,9 +50,9 @@ DPD mirror:
|
|||||||
|
|
||||||
- HTML-портал не удаляется.
|
- HTML-портал не удаляется.
|
||||||
- Маршрут `/portal/` остаётся стабильным.
|
- Маршрут `/portal/` остаётся стабильным.
|
||||||
- DPD `/dpd/` остаётся параллельным mirror.
|
|
||||||
- Backend-расчёты, JSON-хранилища и workflow не дублируются во frontend.
|
- Backend-расчёты, JSON-хранилища и workflow не дублируются во frontend.
|
||||||
- Публичные JSON-поля не переименовываются без новой версии контракта.
|
- Публичные 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 | 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/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: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` заполнен;
|
- `contract_version` заполнен;
|
||||||
- OpenAPI JSON валиден;
|
- OpenAPI JSON валиден;
|
||||||
- TypeScript declarations доступны;
|
- TypeScript declarations доступны;
|
||||||
- DPD отдаёт те же контрактные endpoints через mirror.
|
- будущий React/Tauri UI может использовать JSON API без HTML-парсинга.
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ Agent: Rust
|
|||||||
Current Portal: Rust server-rendered HTML + HTMX
|
Current Portal: Rust server-rendered HTML + HTMX
|
||||||
Future Enterprise UI: React + TypeScript
|
Future Enterprise UI: React + TypeScript
|
||||||
Future Desktop Forensics: Tauri + React + Rust core
|
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 не должен ломать текущий портал.
|
- Будущий React/Tauri UI не должен ломать текущий портал.
|
||||||
- Agent и backend остаются Rust-first.
|
- Agent и backend остаются Rust-first.
|
||||||
- Новые UI-фреймворки не добавлять без отдельного architecture decision.
|
- Новые UI-фреймворки не добавлять без отдельного architecture decision.
|
||||||
- Dioxus не добавлять и не рассматривать.
|
- Dioxus и DPD Portal исключены из архитектурного roadmap проекта.
|
||||||
- Будущий React/Tauri UI не должен парсить HTML текущего портала как источник
|
- Будущий React/Tauri UI не должен парсить HTML текущего портала как источник
|
||||||
данных.
|
данных.
|
||||||
|
|
||||||
@@ -31,9 +32,8 @@ Dioxus: out of scope / не рассматривается
|
|||||||
|
|
||||||
1. Укрепить текущий Rust web portal.
|
1. Укрепить текущий Rust web portal.
|
||||||
2. Зафиксировать стабильные JSON API-контракты.
|
2. Зафиксировать стабильные JSON API-контракты.
|
||||||
3. Поддерживать DPD `/dpd/` как параллельный mirror для проверки совместимости.
|
3. Готовить будущий React/Tauri UI только поверх опубликованных контрактов.
|
||||||
4. Готовить будущий React/Tauri UI только поверх опубликованных контрактов.
|
4. Любые breaking changes проводить только через version bump контракта и
|
||||||
5. Любые breaking changes проводить только через version bump контракта и
|
|
||||||
отдельное architecture decision.
|
отдельное architecture decision.
|
||||||
|
|
||||||
## Контрактный слой
|
## Контрактный слой
|
||||||
@@ -44,12 +44,6 @@ Dioxus: out of scope / не рассматривается
|
|||||||
- `/api/contracts/openapi.json`;
|
- `/api/contracts/openapi.json`;
|
||||||
- `/api/contracts/typescript.d.ts`.
|
- `/api/contracts/typescript.d.ts`.
|
||||||
|
|
||||||
DPD mirror endpoints:
|
|
||||||
|
|
||||||
- `/dpd/api/contracts`;
|
|
||||||
- `/dpd/api/contracts/openapi.json`;
|
|
||||||
- `/dpd/api/contracts/typescript.d.ts`.
|
|
||||||
|
|
||||||
Правило совместимости: изменения API должны быть additive. Клиенты обязаны
|
Правило совместимости: изменения API должны быть additive. Клиенты обязаны
|
||||||
игнорировать неизвестные поля, корректно обрабатывать отсутствующие optional
|
игнорировать неизвестные поля, корректно обрабатывать отсутствующие optional
|
||||||
поля и не падать на `null`.
|
поля и не падать на `null`.
|
||||||
|
|||||||
Reference in New Issue
Block a user