feat(portal): add api contracts for future ui
This commit is contained in:
@@ -3,7 +3,9 @@ use std::time::Duration;
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use clap::Parser;
|
||||
use reqwest::blocking::Client;
|
||||
use reqwest::header::{ACCEPT, CONNECTION, CONTENT_DISPOSITION, CONTENT_TYPE, HeaderValue};
|
||||
use reqwest::header::{
|
||||
CONNECTION, CONTENT_DISPOSITION, CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue, LOCATION,
|
||||
};
|
||||
use serde_json::json;
|
||||
use tiny_http::{Header, Request, Response, Server, StatusCode};
|
||||
|
||||
@@ -12,7 +14,7 @@ const APP_CSS: &str = include_str!("static/app.css");
|
||||
const APP_JS: &str = include_str!("static/app.js");
|
||||
|
||||
#[derive(Clone, Debug, Parser)]
|
||||
#[command(about = "DetMir DPD/Dioxus pilot portal")]
|
||||
#[command(about = "DetMir DPD parallel portal gateway")]
|
||||
struct Cli {
|
||||
#[arg(long, default_value = "127.0.0.1:8722", env = "DETMIR_DPD_BIND")]
|
||||
bind: String,
|
||||
@@ -91,8 +93,7 @@ fn handle_request(request: Request, args: &Cli) -> Result<()> {
|
||||
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 content_type = request_header(&request, "Content-Type");
|
||||
let accept = request_header(&request, "Accept");
|
||||
let forwarded_headers = forwarded_request_headers(&request)?;
|
||||
let mut body = Vec::new();
|
||||
request
|
||||
.as_reader()
|
||||
@@ -110,41 +111,93 @@ fn proxy_to_upstream(mut request: Request, args: &Cli, path: &str) -> Result<()>
|
||||
.request(method, &url)
|
||||
.header(CONNECTION, HeaderValue::from_static("close"))
|
||||
.body(body);
|
||||
if let Some(value) = content_type {
|
||||
upstream = upstream.header(CONTENT_TYPE, value);
|
||||
}
|
||||
if let Some(value) = accept {
|
||||
upstream = upstream.header(ACCEPT, value);
|
||||
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 content_type = upstream_response
|
||||
.headers()
|
||||
.get(CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(ToString::to_string);
|
||||
let content_disposition = upstream_response
|
||||
.headers()
|
||||
.get(CONTENT_DISPOSITION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(ToString::to_string);
|
||||
let response_headers = mirrored_response_headers(upstream_response.headers())?;
|
||||
let mut bytes = upstream_response
|
||||
.bytes()
|
||||
.context("upstream response body")?
|
||||
.to_vec();
|
||||
rewrite_mirrored_body(&mut bytes, content_type.as_deref());
|
||||
respond_bytes(request, status, bytes, content_type, content_disposition)
|
||||
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 request_header(request: &Request, name: &'static str) -> Option<String> {
|
||||
request
|
||||
.headers()
|
||||
.iter()
|
||||
.find(|header| header.field.equiv(name))
|
||||
.map(|header| header.value.as_str().to_string())
|
||||
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 {
|
||||
@@ -221,8 +274,7 @@ fn respond_bytes(
|
||||
request: Request,
|
||||
status: StatusCode,
|
||||
body: Vec<u8>,
|
||||
content_type: Option<String>,
|
||||
content_disposition: Option<String>,
|
||||
response_headers: Vec<(String, String)>,
|
||||
) -> Result<()> {
|
||||
let mut response = Response::from_data(body)
|
||||
.with_status_code(status)
|
||||
@@ -230,16 +282,10 @@ fn respond_bytes(
|
||||
Header::from_bytes("Cache-Control", "no-store")
|
||||
.map_err(|_| anyhow!("invalid Cache-Control header"))?,
|
||||
);
|
||||
if let Some(value) = content_type {
|
||||
for (name, value) in response_headers {
|
||||
response = response.with_header(
|
||||
Header::from_bytes("Content-Type", value)
|
||||
.map_err(|_| anyhow!("invalid upstream Content-Type header"))?,
|
||||
);
|
||||
}
|
||||
if let Some(value) = content_disposition {
|
||||
response = response.with_header(
|
||||
Header::from_bytes("Content-Disposition", value)
|
||||
.map_err(|_| anyhow!("invalid upstream Content-Disposition header"))?,
|
||||
Header::from_bytes(name, value)
|
||||
.map_err(|_| anyhow!("invalid mirrored response header"))?,
|
||||
);
|
||||
}
|
||||
request.respond(response)?;
|
||||
@@ -259,7 +305,11 @@ fn rewrite_mirrored_body(body: &mut Vec<u8>, content_type: Option<&str>) {
|
||||
if !text.contains("/portal") {
|
||||
return;
|
||||
}
|
||||
*body = text.replace("/portal", "/dpd").into_bytes();
|
||||
*body = rewrite_mirrored_text(text).into_bytes();
|
||||
}
|
||||
|
||||
fn rewrite_mirrored_text(text: &str) -> String {
|
||||
text.replace("/portal", "/dpd")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -293,4 +343,24 @@ mod tests {
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<div>
|
||||
<p class="eyebrow">DetMir DPD</p>
|
||||
<h1>Пилотный портал</h1>
|
||||
<p class="lead">Параллельный read-only интерфейс для оценки будущего Dioxus-подхода.</p>
|
||||
<p class="lead">Параллельный read-only интерфейс для оценки будущего React/Tauri-подхода.</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button id="refreshButton" type="button">Обновить</button>
|
||||
|
||||
@@ -0,0 +1,584 @@
|
||||
{
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "AWatch-rus DetMir Portal API",
|
||||
"version": "2026-06-05.v1",
|
||||
"description": "Stable additive API contract for the current HTML portal and future React/Tauri clients. Clients must ignore unknown fields and tolerate missing optional fields."
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
"url": "/api",
|
||||
"description": "Gateway-relative API base"
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
{ "name": "contracts" },
|
||||
{ "name": "portal" },
|
||||
{ "name": "reports" },
|
||||
{ "name": "incidents" },
|
||||
{ "name": "cases" },
|
||||
{ "name": "readiness" },
|
||||
{ "name": "telemetry" }
|
||||
],
|
||||
"paths": {
|
||||
"/contracts": {
|
||||
"get": {
|
||||
"tags": ["contracts"],
|
||||
"summary": "Contract index",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Contract index",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/ContractIndex" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/contracts/openapi.json": {
|
||||
"get": {
|
||||
"tags": ["contracts"],
|
||||
"summary": "OpenAPI document",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OpenAPI 3.1 document",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "type": "object", "additionalProperties": true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/contracts/typescript.d.ts": {
|
||||
"get": {
|
||||
"tags": ["contracts"],
|
||||
"summary": "TypeScript declarations",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "TypeScript declaration file",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/health": {
|
||||
"get": {
|
||||
"tags": ["portal"],
|
||||
"summary": "Light service health",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Health payload",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/JsonObject" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/operator": {
|
||||
"get": {
|
||||
"tags": ["portal"],
|
||||
"summary": "Operator overview payload",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Operator payload used by the HTML portal",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/JsonObject" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/manager": {
|
||||
"get": {
|
||||
"tags": ["portal"],
|
||||
"summary": "Manager payload",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Manager payload",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/JsonObject" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/owner": {
|
||||
"get": {
|
||||
"tags": ["portal"],
|
||||
"summary": "Owner/security payload",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Owner payload",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/JsonObject" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/reports": {
|
||||
"get": {
|
||||
"tags": ["reports"],
|
||||
"summary": "Management report payload",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "anonymize",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": { "type": "boolean" },
|
||||
"description": "Return anonymized values when supported"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Report payload",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/ReportPayload" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/workforce/policy/explain": {
|
||||
"get": {
|
||||
"tags": ["reports"],
|
||||
"summary": "Workforce scoring policy explanation",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "anonymize",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": { "type": "boolean" }
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Policy explanation",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/JsonObject" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/incidents": {
|
||||
"get": {
|
||||
"tags": ["incidents"],
|
||||
"summary": "Incident list and current state",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Incident payload",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/JsonObject" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/incident-review": {
|
||||
"post": {
|
||||
"tags": ["incidents"],
|
||||
"summary": "Set manual review status for an incident candidate",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/IncidentReviewRequest" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Updated review state",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/JsonObject" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/investigation-pack/{candidate_id}": {
|
||||
"get": {
|
||||
"tags": ["incidents"],
|
||||
"summary": "Export investigation pack for a candidate",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "candidate_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": { "type": "string" }
|
||||
},
|
||||
{
|
||||
"name": "format",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": { "type": "string", "enum": ["json", "markdown"] }
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Investigation pack",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/JsonObject" }
|
||||
},
|
||||
"text/markdown": {
|
||||
"schema": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/cases": {
|
||||
"get": {
|
||||
"tags": ["cases"],
|
||||
"summary": "Case list",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Case list",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/CaseListResponse" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"tags": ["cases"],
|
||||
"summary": "Create a manual case from a confirmed candidate",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/CreateCaseRequest" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Created case",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/JsonObject" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/cases/{case_id}": {
|
||||
"get": {
|
||||
"tags": ["cases"],
|
||||
"summary": "Case details",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "case_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": { "type": "string" }
|
||||
},
|
||||
{
|
||||
"name": "format",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": { "type": "string", "enum": ["json", "markdown"] }
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Case details",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/JsonObject" }
|
||||
},
|
||||
"text/markdown": {
|
||||
"schema": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/cases/{case_id}/status": {
|
||||
"post": {
|
||||
"tags": ["cases"],
|
||||
"summary": "Set manual case status",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "case_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": { "type": "string" }
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/CaseStatusRequest" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Updated case",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/JsonObject" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/dlp/evidence": {
|
||||
"get": {
|
||||
"tags": ["incidents"],
|
||||
"summary": "DLP evidence list",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Evidence list",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/JsonObject" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/readiness/latest": {
|
||||
"get": {
|
||||
"tags": ["readiness"],
|
||||
"summary": "Latest readiness bundle status",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Readiness status",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/JsonObject" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/readiness/bundle": {
|
||||
"get": {
|
||||
"tags": ["readiness"],
|
||||
"summary": "Readiness bundle artifact list",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Readiness bundle",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/JsonObject" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/readiness/verify": {
|
||||
"get": {
|
||||
"tags": ["readiness"],
|
||||
"summary": "Verify readiness checksums and signature",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Verification result",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/JsonObject" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/links": {
|
||||
"get": {
|
||||
"tags": ["portal"],
|
||||
"summary": "Gateway-relative portal links",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Link map",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/JsonObject" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/telemetry": {
|
||||
"post": {
|
||||
"tags": ["telemetry"],
|
||||
"summary": "Agent telemetry ingest",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/JsonObject" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Accepted telemetry",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/JsonObject" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid telemetry API key"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"JsonObject": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
},
|
||||
"ContractIndex": {
|
||||
"type": "object",
|
||||
"required": ["ok", "contract_version", "api_base", "artifacts", "stable_endpoints"],
|
||||
"properties": {
|
||||
"ok": { "type": "boolean" },
|
||||
"contract_version": { "type": "string" },
|
||||
"generated_by": { "type": "string" },
|
||||
"api_base": { "type": "string" },
|
||||
"compatibility": { "$ref": "#/components/schemas/JsonObject" },
|
||||
"targets": { "type": "array", "items": { "type": "string" } },
|
||||
"artifacts": { "$ref": "#/components/schemas/JsonObject" },
|
||||
"stable_endpoints": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/components/schemas/EndpointDescriptor" }
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"EndpointDescriptor": {
|
||||
"type": "object",
|
||||
"required": ["method", "path"],
|
||||
"properties": {
|
||||
"method": { "type": "string" },
|
||||
"path": { "type": "string" },
|
||||
"purpose": { "type": "string" }
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"ReportPayload": {
|
||||
"type": "object",
|
||||
"required": ["ok"],
|
||||
"properties": {
|
||||
"ok": { "type": "boolean" },
|
||||
"generated_at_utc": { "type": "string" },
|
||||
"executive_points": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"executive_dashboard": { "$ref": "#/components/schemas/JsonObject" },
|
||||
"risk_narrative": { "$ref": "#/components/schemas/JsonObject" },
|
||||
"agent_quality": { "$ref": "#/components/schemas/JsonObject" },
|
||||
"agent_coverage_sla": { "$ref": "#/components/schemas/JsonObject" },
|
||||
"business_risk": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/components/schemas/JsonObject" }
|
||||
},
|
||||
"risk_incident_candidates": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/components/schemas/JsonObject" }
|
||||
},
|
||||
"cases": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/components/schemas/JsonObject" }
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"IncidentReviewRequest": {
|
||||
"type": "object",
|
||||
"required": ["candidate_id", "status"],
|
||||
"properties": {
|
||||
"candidate_id": { "type": "string" },
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["NEW", "IN_REVIEW", "CONFIRMED", "FALSE_POSITIVE", "POSTPONED"]
|
||||
},
|
||||
"reviewer": { "type": "string" },
|
||||
"comment": { "type": "string" }
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"CreateCaseRequest": {
|
||||
"type": "object",
|
||||
"required": ["candidate_id"],
|
||||
"properties": {
|
||||
"candidate_id": { "type": "string" },
|
||||
"title": { "type": "string" },
|
||||
"owner": { "type": "string" },
|
||||
"summary": { "type": "string" },
|
||||
"decision": { "type": "string" }
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"CaseStatusRequest": {
|
||||
"type": "object",
|
||||
"required": ["status"],
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["OPEN", "IN_PROGRESS", "RESOLVED", "REJECTED", "ARCHIVED"]
|
||||
},
|
||||
"decision": { "type": "string" }
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"CaseListResponse": {
|
||||
"type": "object",
|
||||
"required": ["ok", "cases"],
|
||||
"properties": {
|
||||
"ok": { "type": "boolean" },
|
||||
"cases": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/components/schemas/JsonObject" }
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
export type ISODateTime = string;
|
||||
|
||||
export type RiskLevel = "LOW" | "MEDIUM" | "HIGH" | "CRITICAL" | "UNKNOWN";
|
||||
export type ReviewStatus = "NEW" | "IN_REVIEW" | "CONFIRMED" | "FALSE_POSITIVE" | "POSTPONED";
|
||||
export type CaseStatus = "OPEN" | "IN_PROGRESS" | "RESOLVED" | "REJECTED" | "ARCHIVED";
|
||||
|
||||
export interface JsonObject {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface EndpointDescriptor {
|
||||
method: string;
|
||||
path: string;
|
||||
purpose?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ContractIndex {
|
||||
ok: boolean;
|
||||
contract_version: string;
|
||||
generated_by?: string;
|
||||
api_base: "/api" | string;
|
||||
compatibility?: JsonObject;
|
||||
targets?: string[];
|
||||
artifacts: {
|
||||
openapi: string;
|
||||
typescript: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
stable_endpoints: EndpointDescriptor[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ExecutiveDashboard {
|
||||
trust_kpi_score?: number;
|
||||
agent_coverage_pct?: number;
|
||||
high_risk_departments?: number;
|
||||
critical_candidates?: number;
|
||||
open_cases?: number;
|
||||
resolved_cases_30d?: number;
|
||||
forensics_readiness?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface RiskNarrative {
|
||||
status?: "NORMAL" | "ATTENTION" | "HIGH_RISK" | "CRITICAL" | string;
|
||||
title?: string;
|
||||
summary?: string;
|
||||
main_reason?: string;
|
||||
recommendation?: string;
|
||||
supporting_layers?: JsonObject[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface AgentQuality {
|
||||
collector_source?: string;
|
||||
collector_error?: string | null;
|
||||
sessions_collected_total?: number;
|
||||
active_sessions_total?: number;
|
||||
rdp_sessions_total?: number;
|
||||
quality_status?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface AgentCoverageSla {
|
||||
expected_nodes?: number;
|
||||
reporting_nodes_24h?: number;
|
||||
stale_nodes?: number;
|
||||
missing_nodes?: number;
|
||||
coverage_pct?: number;
|
||||
freshness_pct?: number;
|
||||
sla_status?: "OK" | "WARNING" | "CRITICAL" | "UNKNOWN" | string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface BusinessRiskItem {
|
||||
department?: string;
|
||||
trust_score?: number;
|
||||
activity_score?: number;
|
||||
trend?: string;
|
||||
risk_level?: RiskLevel;
|
||||
reasons?: string[];
|
||||
recommendation?: string;
|
||||
problem_nodes_count?: number;
|
||||
missing_nodes_count?: number;
|
||||
stale_nodes_count?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IncidentCandidate {
|
||||
id?: string;
|
||||
department?: string;
|
||||
owner?: string;
|
||||
hostname?: string;
|
||||
risk_level?: RiskLevel;
|
||||
reason?: string;
|
||||
evidence?: unknown;
|
||||
first_seen_utc?: ISODateTime;
|
||||
last_seen_utc?: ISODateTime;
|
||||
recommendation?: string;
|
||||
review?: IncidentReview;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IncidentReview {
|
||||
candidate_id: string;
|
||||
status: ReviewStatus;
|
||||
reviewer?: string;
|
||||
comment?: string;
|
||||
updated_at?: ISODateTime;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IncidentReviewRequest {
|
||||
candidate_id: string;
|
||||
status: ReviewStatus;
|
||||
reviewer?: string;
|
||||
comment?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface CaseItem {
|
||||
case_id?: string;
|
||||
candidate_id?: string;
|
||||
title?: string;
|
||||
status?: CaseStatus;
|
||||
owner?: string;
|
||||
created_at_utc?: ISODateTime;
|
||||
updated_at_utc?: ISODateTime;
|
||||
summary?: string;
|
||||
decision?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface CreateCaseRequest {
|
||||
candidate_id: string;
|
||||
title?: string;
|
||||
owner?: string;
|
||||
summary?: string;
|
||||
decision?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface CaseStatusRequest {
|
||||
status: CaseStatus;
|
||||
decision?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ReportsResponse {
|
||||
ok: boolean;
|
||||
generated_at_utc?: ISODateTime;
|
||||
executive_points?: string[];
|
||||
executive_dashboard?: ExecutiveDashboard;
|
||||
risk_narrative?: RiskNarrative;
|
||||
agent_quality?: AgentQuality;
|
||||
agent_coverage_sla?: AgentCoverageSla;
|
||||
business_risk?: BusinessRiskItem[];
|
||||
risk_incident_candidates?: IncidentCandidate[];
|
||||
cases?: CaseItem[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface CaseListResponse {
|
||||
ok: boolean;
|
||||
cases: CaseItem[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface DetMirPortalApi {
|
||||
getContracts(): Promise<ContractIndex>;
|
||||
getHealth(): Promise<JsonObject>;
|
||||
getOperator(): Promise<JsonObject>;
|
||||
getManager(): Promise<JsonObject>;
|
||||
getOwner(): Promise<JsonObject>;
|
||||
getReports(options?: { anonymize?: boolean }): Promise<ReportsResponse>;
|
||||
getIncidents(): Promise<JsonObject>;
|
||||
getCases(): Promise<CaseListResponse>;
|
||||
createCase(request: CreateCaseRequest): Promise<JsonObject>;
|
||||
setCaseStatus(caseId: string, request: CaseStatusRequest): Promise<JsonObject>;
|
||||
setIncidentReview(request: IncidentReviewRequest): Promise<JsonObject>;
|
||||
getInvestigationPack(candidateId: string, options?: { format?: "json" | "markdown" }): Promise<JsonObject | string>;
|
||||
getDlpEvidence(): Promise<JsonObject>;
|
||||
getReadinessLatest(): Promise<JsonObject>;
|
||||
getReadinessBundle(): Promise<JsonObject>;
|
||||
verifyReadiness(): Promise<JsonObject>;
|
||||
getWorkforcePolicyExplain(options?: { anonymize?: boolean }): Promise<JsonObject>;
|
||||
}
|
||||
@@ -26,6 +26,8 @@ use tiny_http::{Header, Method, Request, Response, Server, StatusCode};
|
||||
const INDEX_HTML: &str = include_str!("static/index.html");
|
||||
const APP_CSS: &str = include_str!("static/app.css");
|
||||
const APP_JS: &str = include_str!("static/app.js");
|
||||
const API_CONTRACT_OPENAPI: &str = include_str!("contracts/openapi.json");
|
||||
const API_CONTRACT_TYPESCRIPT: &str = include_str!("contracts/typescript.d.ts");
|
||||
const UEBA_BASELINE_MIN_SAMPLES: usize = 3;
|
||||
const SNAPSHOT_CACHE_TTL: Duration = Duration::from_secs(5);
|
||||
const DEFAULT_DEPARTMENT_LABEL: &str = "Без подразделения";
|
||||
@@ -1293,6 +1295,19 @@ fn handle_request(request: Request, args: &Cli, snapshot_cache: &SnapshotCache)
|
||||
"application/javascript; charset=utf-8",
|
||||
),
|
||||
"/favicon.ico" => respond_text(request, StatusCode(204), "", "image/x-icon"),
|
||||
"/api/contracts" => respond_json(request, &api_contract_summary()),
|
||||
"/api/contracts/openapi.json" => respond_text(
|
||||
request,
|
||||
StatusCode(200),
|
||||
API_CONTRACT_OPENAPI,
|
||||
"application/json; charset=utf-8",
|
||||
),
|
||||
"/api/contracts/typescript.d.ts" => respond_text(
|
||||
request,
|
||||
StatusCode(200),
|
||||
API_CONTRACT_TYPESCRIPT,
|
||||
"text/plain; charset=utf-8",
|
||||
),
|
||||
"/api/health" => respond_json(
|
||||
request,
|
||||
&build_health(&cached_snapshot(args, snapshot_cache)),
|
||||
@@ -1421,6 +1436,42 @@ fn normalize_path(url: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn api_contract_summary() -> Value {
|
||||
json!({
|
||||
"ok": true,
|
||||
"contract_version": "2026-06-05.v1",
|
||||
"generated_by": "detmir-portal",
|
||||
"api_base": "/api",
|
||||
"compatibility": {
|
||||
"policy": "additive",
|
||||
"existing_html_portal": "unchanged",
|
||||
"unknown_fields": "clients must ignore unknown fields",
|
||||
"nullable_fields": "clients must tolerate null and missing optional fields"
|
||||
},
|
||||
"targets": ["current-html", "future-react", "future-tauri"],
|
||||
"artifacts": {
|
||||
"openapi": "/api/contracts/openapi.json",
|
||||
"typescript": "/api/contracts/typescript.d.ts"
|
||||
},
|
||||
"stable_endpoints": [
|
||||
{"method": "GET", "path": "/api/health", "purpose": "light service health"},
|
||||
{"method": "GET", "path": "/api/contracts", "purpose": "contract index"},
|
||||
{"method": "GET", "path": "/api/contracts/openapi.json", "purpose": "OpenAPI contract"},
|
||||
{"method": "GET", "path": "/api/contracts/typescript.d.ts", "purpose": "TypeScript declarations"},
|
||||
{"method": "GET", "path": "/api/operator", "purpose": "portal overview data"},
|
||||
{"method": "GET", "path": "/api/reports", "purpose": "management report payload"},
|
||||
{"method": "GET", "path": "/api/incidents", "purpose": "incident and DLP evidence summary"},
|
||||
{"method": "GET", "path": "/api/cases", "purpose": "case list"},
|
||||
{"method": "POST", "path": "/api/incident-review", "purpose": "manual candidate review status"},
|
||||
{"method": "POST", "path": "/api/cases", "purpose": "manual case creation"},
|
||||
{"method": "GET", "path": "/api/investigation-pack/{candidate_id}", "purpose": "candidate investigation pack"},
|
||||
{"method": "GET", "path": "/api/dlp/evidence", "purpose": "DLP evidence list"},
|
||||
{"method": "GET", "path": "/api/readiness/latest", "purpose": "latest readiness status"},
|
||||
{"method": "GET", "path": "/api/workforce/policy/explain", "purpose": "workforce policy explanation"}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
fn readiness_latest(args: &Cli) -> Value {
|
||||
read_json_file(
|
||||
&args
|
||||
@@ -9157,6 +9208,31 @@ mod tests {
|
||||
assert_eq!(safe_download_stem("risk:candidate/1"), "risk_candidate_1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_contract_artifacts_are_valid_and_future_ui_ready() {
|
||||
let openapi: Value =
|
||||
serde_json::from_str(API_CONTRACT_OPENAPI).expect("OpenAPI contract must be JSON");
|
||||
assert_eq!(openapi["openapi"], "3.1.0");
|
||||
assert!(openapi["paths"]["/reports"].is_object());
|
||||
assert!(openapi["paths"]["/incident-review"].is_object());
|
||||
assert!(openapi["paths"]["/cases"].is_object());
|
||||
assert!(API_CONTRACT_TYPESCRIPT.contains("export interface DetMirPortalApi"));
|
||||
assert!(API_CONTRACT_TYPESCRIPT.contains("ReportsResponse"));
|
||||
assert!(API_CONTRACT_TYPESCRIPT.contains("IncidentReviewRequest"));
|
||||
|
||||
let summary = api_contract_summary();
|
||||
assert_eq!(summary["ok"], true);
|
||||
assert_eq!(summary["api_base"], "/api");
|
||||
assert_eq!(
|
||||
summary["artifacts"]["openapi"],
|
||||
"/api/contracts/openapi.json"
|
||||
);
|
||||
assert_eq!(
|
||||
summary["artifacts"]["typescript"],
|
||||
"/api/contracts/typescript.d.ts"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn links_are_gateway_relative() {
|
||||
let links = links();
|
||||
|
||||
+82
-25
@@ -1,13 +1,19 @@
|
||||
# DPD/Dioxus Pilot Portal
|
||||
# DPD Parallel Portal
|
||||
|
||||
`detmir-dpd-portal` - параллельный DPD-портал DetMir. Основной режим работает
|
||||
как полный mirror текущего `detmir-portal`, а экспериментальный компактный экран
|
||||
оставлен отдельно как preview.
|
||||
`detmir-dpd-portal` - параллельный gateway-портал DetMir. Он не заменяет
|
||||
текущий `detmir-portal`, а повторяет его функциональность через отдельный
|
||||
маршрут `/dpd/`.
|
||||
|
||||
## Назначение
|
||||
|
||||
Портал не заменяет текущий `detmir-portal`. Он работает рядом с ним и
|
||||
проксирует все функции текущего портала через отдельный маршрут `/dpd/`:
|
||||
DPD нужен для безопасной эволюции интерфейса:
|
||||
|
||||
- основной HTML-портал `/portal/` остаётся стабильным;
|
||||
- `/dpd/` работает как полный mirror текущего портала;
|
||||
- будущий React/Tauri UI сможет использовать те же API-контракты;
|
||||
- новые UI-решения проверяются без cutover и без дублирования бизнес-логики.
|
||||
|
||||
DPD проксирует:
|
||||
|
||||
- вкладки;
|
||||
- API;
|
||||
@@ -17,22 +23,27 @@
|
||||
- markdown/download endpoints;
|
||||
- материалы проверки.
|
||||
|
||||
Компактный experimental preview доступен отдельно и показывает:
|
||||
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/`.
|
||||
|
||||
- главный вывод;
|
||||
- события безопасности;
|
||||
- полнота данных;
|
||||
- риски подразделений;
|
||||
- очередь проверки;
|
||||
- связь рисков и активности.
|
||||
## Архитектурное решение
|
||||
|
||||
## Почему отдельный бинарник
|
||||
Выбран зрелый путь:
|
||||
|
||||
Dioxus 0.7 fullstack использует разделение client/server и отдельные web/server
|
||||
feature flags. Для безопасного первого этапа создан параллельный Rust shell,
|
||||
который не меняет боевой портал, но уже повторяет его функциональность через
|
||||
mirror-proxy. Это оставляет место для постепенного переноса UI на
|
||||
Dioxus-компоненты без риска разъезда данных и API.
|
||||
```text
|
||||
detmir-portal Rust backend
|
||||
|
|
||||
| stable /api/contracts
|
||||
v
|
||||
current HTML UI + DPD mirror + future React/Tauri UI
|
||||
```
|
||||
|
||||
Не используется отдельный экспериментальный UI-фреймворк в production path.
|
||||
Сначала фиксируются API-контракты, тестируется совместимость и только затем
|
||||
добавляется новый frontend.
|
||||
|
||||
## Запуск
|
||||
|
||||
@@ -53,11 +64,57 @@ detmir-dpd-portal \
|
||||
|
||||
- `/dpd/` - полный параллельный mirror текущего портала.
|
||||
- `/dpd/_dpd/health` - health самого DPD gateway.
|
||||
- `/dpd/preview/` - компактный preview-экран для будущего Dioxus-подхода.
|
||||
- `/dpd/preview/` - компактный read-only preview-экран.
|
||||
|
||||
## Ограничения v0.1
|
||||
## API-контракты
|
||||
|
||||
- DPD gateway не добавляет новые бизнес-сущности;
|
||||
- без замены текущего портала;
|
||||
- данные берутся из существующего портала/API;
|
||||
- preview-экран остается read-only.
|
||||
Контракты публикует основной `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-поля.
|
||||
|
||||
## Проверка симметрии
|
||||
|
||||
Минимальная проверка на сервере:
|
||||
|
||||
```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-логики.
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# API-контракты портала DetMir
|
||||
|
||||
## Цель
|
||||
|
||||
Закрепить стабильный слой API для текущего HTML-портала и будущего
|
||||
React/Tauri-интерфейса без переписывания backend-логики.
|
||||
|
||||
Текущий HTML-портал остаётся рабочим и основным. Новый UI должен подключаться к
|
||||
тем же endpoint-ам через documented contract.
|
||||
|
||||
## Контрактные endpoint-ы
|
||||
|
||||
Основной портал:
|
||||
|
||||
- `GET /api/contracts`
|
||||
- `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.
|
||||
- Нельзя удалять существующие поля без отдельной migration window.
|
||||
- Клиенты обязаны игнорировать неизвестные поля.
|
||||
- Клиенты обязаны корректно обрабатывать отсутствующие optional-поля.
|
||||
- Поля с `null` не должны ломать UI.
|
||||
- Ошибки API должны отображаться пользователю на русском языке.
|
||||
|
||||
## Минимальный набор для будущего React/Tauri UI
|
||||
|
||||
Для первого production-grade клиента достаточно:
|
||||
|
||||
- `GET /api/contracts` - discovery и версия контракта;
|
||||
- `GET /api/reports` - главный управленческий payload;
|
||||
- `GET /api/operator` - обзор портала;
|
||||
- `GET /api/incidents` - проверки и события;
|
||||
- `GET /api/cases` - расследования;
|
||||
- `POST /api/incident-review` - ручное решение по кандидату;
|
||||
- `POST /api/cases` - ручное создание дела;
|
||||
- `GET /api/investigation-pack/{candidate_id}` - пакет расследования;
|
||||
- `GET /api/readiness/latest` - готовность системы;
|
||||
- `GET /api/workforce/policy/explain` - объяснение расчёта показателей.
|
||||
|
||||
## Что не меняется
|
||||
|
||||
- HTML-портал не удаляется.
|
||||
- Маршрут `/portal/` остаётся стабильным.
|
||||
- DPD `/dpd/` остаётся параллельным mirror.
|
||||
- Backend-расчёты, JSON-хранилища и workflow не дублируются во frontend.
|
||||
|
||||
## Проверка
|
||||
|
||||
```bash
|
||||
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 .
|
||||
```
|
||||
|
||||
Ожидаемый результат:
|
||||
|
||||
- `ok=true`;
|
||||
- `contract_version` заполнен;
|
||||
- OpenAPI JSON валиден;
|
||||
- TypeScript declarations доступны;
|
||||
- DPD отдаёт те же контрактные endpoints через mirror.
|
||||
@@ -0,0 +1,52 @@
|
||||
# Архитектурный baseline UI AWatch-rus
|
||||
|
||||
Дата фиксации: 2026-06-05
|
||||
|
||||
## Baseline
|
||||
|
||||
```text
|
||||
Backend/API: Rust
|
||||
Agent: Rust
|
||||
Current Portal: Rust server-rendered HTML + HTMX-ready static UI
|
||||
Future Enterprise UI: React + TypeScript
|
||||
Future Desktop Forensics: Tauri + React + Rust core
|
||||
Dioxus: out of scope / не рассматривается
|
||||
```
|
||||
|
||||
## Правила
|
||||
|
||||
- Текущий HTML/HTMX-ready портал остаётся основным pilot/production
|
||||
интерфейсом.
|
||||
- JSON API является контрактным слоем для будущих UI.
|
||||
- Бизнес-логика не должна зависеть от HTML.
|
||||
- Будущий React/Tauri UI не должен ломать текущий портал.
|
||||
- Agent и backend остаются Rust-first.
|
||||
- Новые UI-фреймворки не добавлять без отдельного architecture decision.
|
||||
- Dioxus не добавлять и не рассматривать.
|
||||
|
||||
## Практическое следствие
|
||||
|
||||
Развитие интерфейса выполняется в таком порядке:
|
||||
|
||||
1. Укрепить текущий Rust web portal.
|
||||
2. Зафиксировать стабильные JSON API-контракты.
|
||||
3. Поддерживать DPD `/dpd/` как параллельный mirror для проверки совместимости.
|
||||
4. Готовить будущий React/Tauri UI только поверх опубликованных контрактов.
|
||||
|
||||
## Контрактный слой
|
||||
|
||||
Основные endpoints:
|
||||
|
||||
- `/api/contracts`;
|
||||
- `/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
|
||||
поля.
|
||||
Reference in New Issue
Block a user