From ca4944b978f24f90e078c06199cf05ca7ec23479 Mon Sep 17 00:00:00 2001 From: igor04091968 Date: Tue, 2 Jun 2026 20:35:58 +0300 Subject: [PATCH] feat(detmir): deploy read-only operator portal --- adk-rust/Cargo.lock | 13 + adk-rust/Cargo.toml | 1 + adk-rust/RUNBOOK.md | 40 +- adk-rust/crates/detmir-portal/Cargo.toml | 17 + adk-rust/crates/detmir-portal/src/main.rs | 947 ++++++++++++++++++ .../crates/detmir-portal/src/static/app.css | 182 ++++ .../crates/detmir-portal/src/static/app.js | 198 ++++ .../detmir-portal/src/static/index.html | 31 + ansible/deploy_detmir_portal.yml | 93 ++ ansible/deploy_proxmox_web_gateway.yml | 24 + ansible/templates/proxmox-web-gateway.conf.j2 | 10 + docs/DETMIR_PORTAL_GUI_PLAN_RU.md | 17 + .../check_detmir_rust_release_artifacts.sh | 1 + 13 files changed, 1572 insertions(+), 2 deletions(-) create mode 100644 adk-rust/crates/detmir-portal/Cargo.toml create mode 100644 adk-rust/crates/detmir-portal/src/main.rs create mode 100644 adk-rust/crates/detmir-portal/src/static/app.css create mode 100644 adk-rust/crates/detmir-portal/src/static/app.js create mode 100644 adk-rust/crates/detmir-portal/src/static/index.html create mode 100644 ansible/deploy_detmir_portal.yml diff --git a/adk-rust/Cargo.lock b/adk-rust/Cargo.lock index b7012fe..7861ead 100644 --- a/adk-rust/Cargo.lock +++ b/adk-rust/Cargo.lock @@ -572,6 +572,19 @@ dependencies = [ "serde_json", ] +[[package]] +name = "detmir-portal" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "reqwest", + "serde", + "serde_json", + "tiny_http", +] + [[package]] name = "detmir-state" version = "0.1.0" diff --git a/adk-rust/Cargo.toml b/adk-rust/Cargo.toml index 4577da0..ee0feee 100644 --- a/adk-rust/Cargo.toml +++ b/adk-rust/Cargo.toml @@ -25,6 +25,7 @@ members = [ "crates/aw-browser-smoke", "crates/diag-and-manual-restart", "crates/detmir-grafana-check", + "crates/detmir-portal", "crates/aw-slo-monitor", "crates/aw-rus-healthd", "crates/detmir-check", diff --git a/adk-rust/RUNBOOK.md b/adk-rust/RUNBOOK.md index 68a0522..b7f3a53 100644 --- a/adk-rust/RUNBOOK.md +++ b/adk-rust/RUNBOOK.md @@ -1546,11 +1546,47 @@ systemctl is-active tsj-guardian-bot tsj-guardian-watchdog gost-tg timer active, Grafana CT failed units 0, Proxmox failed units 0, `detmir-check` `service_failures=0`, `detmir-auto --no-heal` rc 0, `detmir-status` OK / `ok_for_operator=true`. +56. `[done]` Реализовать read-only MVP `detmir-portal`: + - добавлен crate `detmir-portal`; + - portal работает как Rust web service на Proxmox: + `/usr/local/bin/detmir-portal`, `detmir-portal.service`, + bind `127.0.0.1:8720`; + - внешний route добавлен в существующий nginx gateway: + `https://dm.iri1968.dpdns.org/portal/`; + - UI содержит вкладки `Оператор`, `Руководитель`, `Владелец`, + `Инциденты`; + - API реализованы: `/api/health`, `/api/summary`, `/api/operator`, + `/api/manager`, `/api/owner`, `/api/incidents`, `/api/links`; + - источники read-only: `detmir-status --json`, `detmir-check --json`, + `systemctl --failed`, AW Worktime API, 1C analytics health; + - portal не делает write/heal/restart actions и не трогает pfSense, + Telegram runtime, NAT/DNS/VPN; + - добавлен deployment playbook `ansible/deploy_detmir_portal.yml`; + - gateway playbook теперь проверяет `/portal/api/health` с auth; + - `scripts/check_detmir_rust_release_artifacts.sh` теперь требует + `detmir-portal`; + - gates: `cargo fmt --all -- --check`, `cargo test -p detmir-portal` + (`3 passed`), `cargo clippy -p detmir-portal --all-targets -- -D + warnings`, release build OK, `ansible-playbook + deploy_detmir_portal.yml --syntax-check`, `ansible-playbook + deploy_proxmox_web_gateway.yml --syntax-check`, artifact check OK; + - production verification: `detmir-portal` active, nginx active, + `/portal/api/health` OK through gateway auth, all sources true, + external `/portal/` returns protected `401` without auth, gateway + `/healthz` returns `ok`, `detmir-status` OK / `ok_for_operator=true`, + Proxmox failed units 0; + - browser verification through production tunnel: desktop and mobile + nonblank, tabs work, API requests 200, console errors 0. Screenshots: + `.playwright-cli/page-2026-06-02T17-31-49-049Z.png`, + `.playwright-cli/page-2026-06-02T17-33-50-318Z.png`, + `.playwright-cli/page-2026-06-02T17-34-04-725Z.png`. Отложить: -- новый этап: сделать `detmir-portal` - единый read-only Rust web portal для - оператора, руководителя и владельца. Детальный план: +- post-MVP развитие `detmir-portal`: role-aware views, incident comments, + acknowledge/assign, safe check-now action, daily owner report, historical + trends, AI summary with strict source citations, action buttons with + allowlist and audit log. Детальный план: `docs/DETMIR_PORTAL_GUI_PLAN_RU.md`; - перенос Telegram bot runtime снят с плана: Python остается постоянным runtime, Rust используется только для backend helpers; diff --git a/adk-rust/crates/detmir-portal/Cargo.toml b/adk-rust/crates/detmir-portal/Cargo.toml new file mode 100644 index 0000000..6b0d9e2 --- /dev/null +++ b/adk-rust/crates/detmir-portal/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "detmir-portal" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish.workspace = true + +[dependencies] +anyhow.workspace = true +chrono.workspace = true +clap.workspace = true +reqwest.workspace = true +serde.workspace = true +serde_json.workspace = true +tiny_http.workspace = true + diff --git a/adk-rust/crates/detmir-portal/src/main.rs b/adk-rust/crates/detmir-portal/src/main.rs new file mode 100644 index 0000000..d5f7971 --- /dev/null +++ b/adk-rust/crates/detmir-portal/src/main.rs @@ -0,0 +1,947 @@ +use std::collections::BTreeMap; +use std::io::Read; +use std::process::{Command, Stdio}; +use std::thread; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result, anyhow}; +use chrono::{SecondsFormat, Utc}; +use clap::Parser; +use reqwest::blocking::Client; +use reqwest::header::{CONNECTION, HeaderValue}; +use serde::Serialize; +use serde_json::{Value, json}; +use tiny_http::{Header, Method, Request, Response, Server, StatusCode}; + +const INDEX_HTML: &str = include_str!("static/index.html"); +const APP_CSS: &str = include_str!("static/app.css"); +const APP_JS: &str = include_str!("static/app.js"); + +#[derive(Debug, Parser)] +#[command(about = "Read-only DetMir operator/manager/owner web portal")] +struct Cli { + #[arg(long, default_value = "127.0.0.1:8720", env = "DETMIR_PORTAL_BIND")] + bind: String, + + #[arg( + long, + default_value = "detmir-status --json", + env = "DETMIR_PORTAL_STATUS_CMD" + )] + status_cmd: String, + + #[arg( + long, + default_value = "detmir-check --json", + env = "DETMIR_PORTAL_CHECK_CMD" + )] + check_cmd: String, + + #[arg( + long, + default_value = "systemctl --failed --no-pager", + env = "DETMIR_PORTAL_FAILED_UNITS_CMD" + )] + failed_units_cmd: String, + + #[arg( + long, + default_value = "http://10.10.10.13:5610", + env = "DETMIR_PORTAL_WORKTIME_URL" + )] + worktime_url: String, + + #[arg( + long, + default_value = "http://10.10.10.2:8710", + env = "DETMIR_PORTAL_ONE_C_URL" + )] + one_c_url: String, + + #[arg(long, default_value_t = 10, env = "DETMIR_PORTAL_TIMEOUT_SECONDS")] + timeout_seconds: u64, + + #[arg(long)] + json_smoke: bool, +} + +#[derive(Debug, Serialize)] +struct SourceStatus { + ok: bool, + status: String, + summary: String, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, + #[serde(skip_serializing_if = "Option::is_none")] + payload: Option, +} + +#[derive(Debug, Serialize)] +struct HealthResponse { + ok: bool, + generated_at_utc: String, + version: String, + sources: BTreeMap, +} + +#[derive(Debug, Serialize)] +struct SummaryBlock { + status: String, + text: String, +} + +#[derive(Debug, Serialize)] +struct SummaryResponse { + severity: String, + operator_ok: bool, + headline: String, + generated_at_utc: String, + blocks: BTreeMap, +} + +#[derive(Debug, Serialize)] +struct IncidentItem { + status: String, + kind: String, + source: String, + summary: String, + generated_at_utc: String, + link: String, +} + +#[derive(Debug, Serialize)] +struct PortalLinks { + portal: String, + grafana_dashboards: String, + detmir_activitywatch: String, + aw_ui: String, + worktime_report: String, + file1c_brief: String, + file1c_actions: String, +} + +#[derive(Debug)] +struct Snapshot { + generated_at_utc: String, + detmir_status: SourceStatus, + detmir_check: SourceStatus, + failed_units: SourceStatus, + worktime: SourceStatus, + one_c: SourceStatus, +} + +fn main() { + let code = match run() { + Ok(code) => code, + Err(err) => { + eprintln!("{err:#}"); + 1 + } + }; + std::process::exit(code); +} + +fn run() -> Result { + let args = Cli::parse(); + if args.json_smoke { + let snapshot = build_snapshot(&args); + let smoke = json!({ + "health": build_health(&snapshot), + "summary": build_summary(&snapshot), + "incidents": build_incidents(&snapshot), + }); + println!("{}", serde_json::to_string_pretty(&smoke)?); + return Ok(if build_health(&snapshot).ok { 0 } else { 2 }); + } + + let server = Server::http(&args.bind).map_err(|err| anyhow!("bind {}: {err}", args.bind))?; + eprintln!("detmir-portal listening on http://{}", args.bind); + for request in server.incoming_requests() { + if let Err(err) = handle_request(request, &args) { + eprintln!("detmir-portal request failed: {err:#}"); + } + } + Ok(0) +} + +fn handle_request(request: Request, args: &Cli) -> Result<()> { + if request.method() != &Method::Get { + return respond_text(request, StatusCode(405), "Method Not Allowed", "text/plain"); + } + let path = normalize_path(request.url()); + match path.as_str() { + "/" | "/operator" | "/manager" | "/owner" | "/incidents" => respond_text( + request, + StatusCode(200), + INDEX_HTML, + "text/html; charset=utf-8", + ), + "/app.css" => respond_text(request, StatusCode(200), APP_CSS, "text/css; charset=utf-8"), + "/app.js" => respond_text( + request, + StatusCode(200), + APP_JS, + "application/javascript; charset=utf-8", + ), + "/favicon.ico" => respond_text(request, StatusCode(204), "", "image/x-icon"), + "/api/health" => respond_json(request, &build_health(&build_snapshot(args))), + "/api/summary" => respond_json(request, &build_summary(&build_snapshot(args))), + "/api/operator" => { + let snapshot = build_snapshot(args); + respond_json(request, &build_operator(&snapshot)) + } + "/api/manager" => { + let snapshot = build_snapshot(args); + respond_json(request, &build_manager(&snapshot)) + } + "/api/owner" => { + let snapshot = build_snapshot(args); + respond_json(request, &build_owner(&snapshot)) + } + "/api/incidents" => { + let snapshot = build_snapshot(args); + respond_json(request, &build_incidents(&snapshot)) + } + "/api/links" => respond_json(request, &links()), + _ => respond_text( + request, + StatusCode(404), + "Not Found", + "text/plain; charset=utf-8", + ), + } +} + +fn normalize_path(url: &str) -> String { + let path = url.split('?').next().unwrap_or("/"); + let path = path.strip_prefix("/portal").unwrap_or(path); + if path.is_empty() { + "/".to_string() + } else { + path.to_string() + } +} + +fn build_snapshot(args: &Cli) -> Snapshot { + let timeout = Duration::from_secs(args.timeout_seconds); + Snapshot { + generated_at_utc: now(), + detmir_status: command_json_source("detmir_status", &args.status_cmd, timeout), + detmir_check: command_json_source("detmir_check", &args.check_cmd, timeout), + failed_units: command_text_source("failed_units", &args.failed_units_cmd, timeout), + worktime: http_json_source( + "worktime_api", + &format!( + "{}/reports/worktime/today", + args.worktime_url.trim_end_matches('/') + ), + timeout, + ), + one_c: http_json_source( + "one_c", + &format!("{}/api/health", args.one_c_url.trim_end_matches('/')), + timeout, + ), + } +} + +fn command_json_source(name: &str, command: &str, timeout: Duration) -> SourceStatus { + match run_shell(command, timeout) { + Ok((stdout, stderr, success)) => { + if !success { + return SourceStatus { + ok: false, + status: "FAIL".to_string(), + summary: format!("{name} command returned non-zero status"), + error: Some(stderr.trim().to_string()), + payload: None, + }; + } + match serde_json::from_str::(&stdout) { + Ok(payload) => SourceStatus { + ok: payload_bool(&payload, "/ok").unwrap_or(true), + status: status_from_payload(&payload), + summary: source_summary(name, &payload), + error: None, + payload: Some(payload), + }, + Err(err) => SourceStatus { + ok: false, + status: "FAIL".to_string(), + summary: format!("{name} returned invalid JSON"), + error: Some(err.to_string()), + payload: None, + }, + } + } + Err(err) => SourceStatus { + ok: false, + status: "FAIL".to_string(), + summary: format!("{name} command failed"), + error: Some(err.to_string()), + payload: None, + }, + } +} + +fn command_text_source(name: &str, command: &str, timeout: Duration) -> SourceStatus { + match run_shell(command, timeout) { + Ok((stdout, stderr, success)) => { + let text = stdout.trim(); + let no_failed_units = + text.contains("0 loaded units listed") || text.contains("UNIT LOAD ACTIVE SUB"); + SourceStatus { + ok: success || no_failed_units, + status: if success || no_failed_units { + "OK" + } else { + "WARN" + } + .to_string(), + summary: if success || no_failed_units { + "failed units not reported".to_string() + } else { + "failed units command returned non-zero".to_string() + }, + error: if stderr.trim().is_empty() { + None + } else { + Some(stderr.trim().to_string()) + }, + payload: Some(json!({ "stdout": text })), + } + } + Err(err) => SourceStatus { + ok: false, + status: "FAIL".to_string(), + summary: format!("{name} command failed"), + error: Some(err.to_string()), + payload: None, + }, + } +} + +fn http_json_source(name: &str, url: &str, timeout: Duration) -> SourceStatus { + let client = match Client::builder().timeout(timeout).no_proxy().build() { + Ok(client) => client, + Err(err) => { + return SourceStatus { + ok: false, + status: "FAIL".to_string(), + summary: format!("{name} HTTP client failed"), + error: Some(err.to_string()), + payload: None, + }; + } + }; + match client + .get(url) + .header(CONNECTION, HeaderValue::from_static("close")) + .send() + { + Ok(response) => match response.error_for_status() { + Ok(response) => match response.json::() { + Ok(payload) => SourceStatus { + ok: true, + status: status_from_payload(&payload), + summary: source_summary(name, &payload), + error: None, + payload: Some(payload), + }, + Err(err) => SourceStatus { + ok: false, + status: "FAIL".to_string(), + summary: format!("{name} returned invalid JSON"), + error: Some(err.to_string()), + payload: None, + }, + }, + Err(err) => SourceStatus { + ok: false, + status: "FAIL".to_string(), + summary: format!("{name} HTTP status failed"), + error: Some(err.to_string()), + payload: None, + }, + }, + Err(err) => SourceStatus { + ok: false, + status: "FAIL".to_string(), + summary: format!("{name} request failed"), + error: Some(err.to_string()), + payload: None, + }, + } +} + +fn run_shell(command: &str, timeout: Duration) -> Result<(String, String, bool)> { + let mut child = Command::new("/bin/sh") + .arg("-lc") + .arg(command) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .with_context(|| format!("spawn {command}"))?; + let started = Instant::now(); + loop { + if let Some(status) = child.try_wait()? { + let mut stdout = String::new(); + let mut stderr = String::new(); + if let Some(mut pipe) = child.stdout.take() { + pipe.read_to_string(&mut stdout)?; + } + if let Some(mut pipe) = child.stderr.take() { + pipe.read_to_string(&mut stderr)?; + } + return Ok((stdout, stderr, status.success())); + } + if started.elapsed() > timeout { + let _ = child.kill(); + let _ = child.wait(); + return Err(anyhow!( + "command timed out after {}s: {command}", + timeout.as_secs() + )); + } + thread::sleep(Duration::from_millis(100)); + } +} + +fn build_health(snapshot: &Snapshot) -> HealthResponse { + let mut sources = BTreeMap::new(); + sources.insert("detmir_status".to_string(), snapshot.detmir_status.ok); + sources.insert("detmir_check".to_string(), snapshot.detmir_check.ok); + sources.insert("grafana_check".to_string(), grafana_data_ok(snapshot)); + sources.insert("worktime_api".to_string(), snapshot.worktime.ok); + sources.insert("dlp_health".to_string(), dlp_ok(snapshot)); + sources.insert("one_c".to_string(), snapshot.one_c.ok); + HealthResponse { + ok: sources.values().all(|value| *value), + generated_at_utc: snapshot.generated_at_utc.clone(), + version: env!("CARGO_PKG_VERSION").to_string(), + sources, + } +} + +fn build_summary(snapshot: &Snapshot) -> SummaryResponse { + let severity = snapshot + .detmir_status + .payload + .as_ref() + .and_then(|value| value.get("severity")) + .and_then(Value::as_str) + .unwrap_or(if snapshot.detmir_status.ok { + "OK" + } else { + "FAIL" + }) + .to_string(); + let operator_ok = snapshot + .detmir_status + .payload + .as_ref() + .and_then(|value| value.get("ok_for_operator")) + .and_then(Value::as_bool) + .unwrap_or(false); + let mut blocks = BTreeMap::new(); + blocks.insert( + "collection".to_string(), + collection_block(snapshot.detmir_check.payload.as_ref()), + ); + blocks.insert("grafana".to_string(), grafana_block(snapshot)); + blocks.insert("dlp".to_string(), dlp_block(snapshot)); + blocks.insert("worktime".to_string(), worktime_block(snapshot)); + blocks.insert("one_c".to_string(), one_c_block(snapshot)); + SummaryResponse { + severity: severity.clone(), + operator_ok, + headline: if operator_ok && severity == "OK" { + "Контур работает штатно".to_string() + } else { + "Контур требует внимания".to_string() + }, + generated_at_utc: snapshot.generated_at_utc.clone(), + blocks, + } +} + +fn build_operator(snapshot: &Snapshot) -> Value { + json!({ + "generated_at_utc": snapshot.generated_at_utc, + "summary": build_summary(snapshot), + "detmir_status": snapshot.detmir_status, + "detmir_check": snapshot.detmir_check, + "failed_units": snapshot.failed_units, + "grafana_data": grafana_service(snapshot), + "links": links(), + "incidents": build_incidents(snapshot), + }) +} + +fn build_manager(snapshot: &Snapshot) -> Value { + let worktime = snapshot + .worktime + .payload + .clone() + .unwrap_or_else(|| json!({})); + let rows = worktime + .get("rows") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let apps = worktime + .get("true_active_apps") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let total_active_seconds: i64 = rows + .iter() + .filter_map(|row| row.get("active_seconds").and_then(Value::as_i64)) + .sum(); + json!({ + "generated_at_utc": snapshot.generated_at_utc, + "status": worktime_block(snapshot), + "report_date": worktime.get("report_date"), + "users_count": rows.len(), + "total_active_seconds": total_active_seconds, + "total_active_hours": (total_active_seconds as f64 / 3600.0), + "users": rows, + "applications": apps, + "links": links(), + "source": snapshot.worktime, + }) +} + +fn build_owner(snapshot: &Snapshot) -> Value { + let summary = build_summary(snapshot); + let recommendations = owner_recommendations(snapshot, &summary); + json!({ + "generated_at_utc": snapshot.generated_at_utc, + "summary": summary, + "cards": { + "work": worktime_block(snapshot), + "security": dlp_block(snapshot), + "one_c": one_c_block(snapshot), + "collection": collection_block(snapshot.detmir_check.payload.as_ref()), + "grafana": grafana_block(snapshot) + }, + "recommendations": recommendations, + "links": links(), + }) +} + +fn build_incidents(snapshot: &Snapshot) -> Vec { + let mut incidents = Vec::new(); + for source in [ + ("detmir_status", &snapshot.detmir_status), + ("detmir_check", &snapshot.detmir_check), + ("failed_units", &snapshot.failed_units), + ("worktime", &snapshot.worktime), + ("one_c", &snapshot.one_c), + ] { + if !source.1.ok { + incidents.push(IncidentItem { + status: source.1.status.clone(), + kind: "health".to_string(), + source: source.0.to_string(), + summary: source.1.summary.clone(), + generated_at_utc: snapshot.generated_at_utc.clone(), + link: "/portal/operator".to_string(), + }); + } + } + if let Some(check) = snapshot.detmir_check.payload.as_ref() { + if let Some(services) = check.get("services").and_then(Value::as_array) { + for service in services { + if service.get("ok").and_then(Value::as_bool) == Some(false) { + incidents.push(IncidentItem { + status: if service.get("required").and_then(Value::as_bool) == Some(true) { + "FAIL" + } else { + "WARN" + } + .to_string(), + kind: "service".to_string(), + source: service + .get("name") + .and_then(Value::as_str) + .unwrap_or("service") + .to_string(), + summary: service + .get("error") + .and_then(Value::as_str) + .unwrap_or("service check failed") + .to_string(), + generated_at_utc: snapshot.generated_at_utc.clone(), + link: "/portal/operator".to_string(), + }); + } + } + } + if let Some(buckets) = check.get("buckets").and_then(Value::as_array) { + for bucket in buckets { + if bucket.get("ok").and_then(Value::as_bool) == Some(false) { + incidents.push(IncidentItem { + status: bucket + .get("status") + .and_then(Value::as_str) + .unwrap_or("WARN") + .to_string(), + kind: "collector".to_string(), + source: bucket + .get("bucket") + .and_then(Value::as_str) + .unwrap_or("bucket") + .to_string(), + summary: format!( + "{} is {}", + bucket + .get("label") + .and_then(Value::as_str) + .unwrap_or("bucket"), + bucket + .get("status") + .and_then(Value::as_str) + .unwrap_or("not OK") + ), + generated_at_utc: snapshot.generated_at_utc.clone(), + link: "/portal/operator".to_string(), + }); + } + } + } + } + incidents +} + +fn links() -> PortalLinks { + PortalLinks { + portal: "/portal/".to_string(), + grafana_dashboards: "/dashboards".to_string(), + detmir_activitywatch: + "/d/detmir-aw-main/detmir-activitywatch?orgId=1&from=now-48h&to=now&timezone=browser&var-host=SHARKON2025&refresh=5m" + .to_string(), + aw_ui: "/r/aw/".to_string(), + worktime_report: "/r/aw-worktime".to_string(), + file1c_brief: "/r/file1c/brief".to_string(), + file1c_actions: "/r/file1c/actions".to_string(), + } +} + +fn collection_block(check: Option<&Value>) -> SummaryBlock { + let Some(check) = check else { + return block("UNKNOWN", "Нет данных detmir-check"); + }; + let summary = check.get("summary").unwrap_or(&Value::Null); + let stale = summary + .get("bucket_stale") + .and_then(Value::as_u64) + .unwrap_or(0); + let dead = summary + .get("bucket_dead") + .and_then(Value::as_u64) + .unwrap_or(0); + let failures = summary + .get("service_failures") + .and_then(Value::as_u64) + .unwrap_or(0); + if stale == 0 && dead == 0 && failures == 0 { + block("OK", "Сбор данных свежий") + } else { + block( + "FAIL", + &format!("Проблемы сбора: stale={stale}, dead={dead}, service_fail={failures}"), + ) + } +} + +fn grafana_block(snapshot: &Snapshot) -> SummaryBlock { + let Some(service) = grafana_service(snapshot) else { + return block("WARN", "Grafana check не найден"); + }; + let ok = service.get("ok").and_then(Value::as_bool).unwrap_or(false); + let age = service + .pointer("/payload/age_seconds") + .and_then(Value::as_i64) + .unwrap_or(-1); + let fail_count = service + .pointer("/payload/fail_count") + .and_then(Value::as_i64) + .unwrap_or(-1); + if ok { + block( + "OK", + &format!("Grafana данные актуальны, возраст {} сек", age), + ) + } else { + block( + "FAIL", + &format!("Grafana check fail_count={fail_count}, age={age}"), + ) + } +} + +fn dlp_block(snapshot: &Snapshot) -> SummaryBlock { + let Some(status) = snapshot.detmir_status.payload.as_ref() else { + return block("UNKNOWN", "Нет DLP данных"); + }; + let counts = status.get("dlp_counts").unwrap_or(&Value::Null); + let ok = counts.get("ok").and_then(Value::as_u64).unwrap_or(0); + let warn = counts.get("warn").and_then(Value::as_u64).unwrap_or(0); + let fail = counts.get("fail").and_then(Value::as_u64).unwrap_or(0); + if warn == 0 && fail == 0 { + block("OK", &format!("DLP проверки OK: {ok}")) + } else { + block("WARN", &format!("DLP: ok={ok}, warn={warn}, fail={fail}")) + } +} + +fn worktime_block(snapshot: &Snapshot) -> SummaryBlock { + if !snapshot.worktime.ok { + return block("FAIL", "Worktime API не отвечает"); + } + let Some(payload) = snapshot.worktime.payload.as_ref() else { + return block("UNKNOWN", "Нет Worktime JSON"); + }; + let rows = payload + .get("rows") + .and_then(Value::as_array) + .map(Vec::len) + .unwrap_or(0); + let apps = payload + .get("true_active_apps") + .and_then(Value::as_array) + .map(Vec::len) + .unwrap_or(0); + if rows > 0 { + block( + "OK", + &format!("Есть данные за сегодня: сотрудников={rows}, приложений={apps}"), + ) + } else { + block("WARN", "Worktime API ответил, но строк сотрудников нет") + } +} + +fn one_c_block(snapshot: &Snapshot) -> SummaryBlock { + if !snapshot.one_c.ok { + return block("FAIL", "1C analytics API не отвечает"); + } + let companies = snapshot + .one_c + .payload + .as_ref() + .and_then(|value| value.get("companies_total")) + .and_then(Value::as_u64) + .unwrap_or(0); + block( + "OK", + &format!("1C analytics отвечает, компаний={companies}"), + ) +} + +fn owner_recommendations(snapshot: &Snapshot, summary: &SummaryResponse) -> Vec { + let mut out = Vec::new(); + if !summary.operator_ok { + out.push("Проверить технический контур: общий статус не готов для оператора".to_string()); + } + if !grafana_data_ok(snapshot) { + out.push( + "Проверить Grafana data pipeline: dashboard freshness или panel query не OK" + .to_string(), + ); + } + if !snapshot.worktime.ok { + out.push( + "Проверить Worktime API и RDP collectors: нет надежного отчета за сегодня".to_string(), + ); + } + if !dlp_ok(snapshot) { + out.push("Открыть DLP обзор: есть предупреждения или ошибки DLP".to_string()); + } + if !snapshot.one_c.ok { + out.push("Проверить 1C analytics API: управленческий блок может быть неполным".to_string()); + } + if out.is_empty() { + out.push("Критичных действий сейчас не требуется".to_string()); + } + out +} + +fn grafana_data_ok(snapshot: &Snapshot) -> bool { + grafana_service(snapshot) + .and_then(|service| service.get("ok").and_then(Value::as_bool)) + .unwrap_or(false) +} + +fn grafana_service(snapshot: &Snapshot) -> Option { + snapshot + .detmir_check + .payload + .as_ref() + .and_then(|check| check.get("services")) + .and_then(Value::as_array) + .and_then(|services| { + services + .iter() + .find(|service| service.get("name").and_then(Value::as_str) == Some("grafana-data")) + }) + .cloned() +} + +fn dlp_ok(snapshot: &Snapshot) -> bool { + snapshot + .detmir_status + .payload + .as_ref() + .and_then(|status| status.get("dlp_ok")) + .and_then(Value::as_bool) + .unwrap_or(false) +} + +fn block(status: &str, text: &str) -> SummaryBlock { + SummaryBlock { + status: status.to_string(), + text: text.to_string(), + } +} + +fn payload_bool(payload: &Value, pointer: &str) -> Option { + payload.pointer(pointer).and_then(Value::as_bool) +} + +fn status_from_payload(payload: &Value) -> String { + payload + .get("severity") + .or_else(|| payload.get("status")) + .and_then(Value::as_str) + .unwrap_or_else(|| { + if payload_bool(payload, "/ok") == Some(false) { + "FAIL" + } else { + "OK" + } + }) + .to_string() +} + +fn source_summary(name: &str, payload: &Value) -> String { + match name { + "detmir_status" => format!( + "severity={}, operator_ok={}", + payload + .get("severity") + .and_then(Value::as_str) + .unwrap_or("UNKNOWN"), + payload + .get("ok_for_operator") + .and_then(Value::as_bool) + .unwrap_or(false) + ), + "detmir_check" => { + let summary = payload.get("summary").unwrap_or(&Value::Null); + format!( + "bucket_ok={}, stale={}, dead={}, service_fail={}", + summary + .get("bucket_ok") + .and_then(Value::as_u64) + .unwrap_or(0), + summary + .get("bucket_stale") + .and_then(Value::as_u64) + .unwrap_or(0), + summary + .get("bucket_dead") + .and_then(Value::as_u64) + .unwrap_or(0), + summary + .get("service_failures") + .and_then(Value::as_u64) + .unwrap_or(0), + ) + } + "worktime_api" => format!( + "rows={}, apps={}", + payload + .get("rows") + .and_then(Value::as_array) + .map(Vec::len) + .unwrap_or(0), + payload + .get("true_active_apps") + .and_then(Value::as_array) + .map(Vec::len) + .unwrap_or(0) + ), + "one_c" => format!( + "status={}, companies={}", + payload + .get("status") + .and_then(Value::as_str) + .unwrap_or("unknown"), + payload + .get("companies_total") + .and_then(Value::as_u64) + .unwrap_or(0) + ), + _ => "source loaded".to_string(), + } +} + +fn respond_json(request: Request, value: &T) -> Result<()> { + let body = serde_json::to_string_pretty(value)?; + respond_text( + request, + StatusCode(200), + &body, + "application/json; charset=utf-8", + ) +} + +fn respond_text( + request: Request, + status: StatusCode, + body: &str, + content_type: &str, +) -> Result<()> { + let response = Response::from_string(body.to_string()) + .with_status_code(status) + .with_header(header("Content-Type", content_type)?) + .with_header(header("Cache-Control", "no-store")?); + request.respond(response).map_err(|err| anyhow!("{err}")) +} + +fn header(name: &str, value: &str) -> Result
{ + Header::from_bytes(name.as_bytes(), value.as_bytes()) + .map_err(|_| anyhow!("invalid header {name}: {value}")) +} + +fn now() -> String { + Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalizes_gateway_prefix() { + assert_eq!(normalize_path("/portal/api/health?x=1"), "/api/health"); + assert_eq!(normalize_path("/portal/"), "/"); + assert_eq!(normalize_path("/api/health"), "/api/health"); + } + + #[test] + fn links_are_gateway_relative() { + let links = links(); + assert!(links.detmir_activitywatch.starts_with("/d/")); + assert_eq!(links.worktime_report, "/r/aw-worktime"); + } + + #[test] + fn collection_block_detects_green_summary() { + let value = json!({"summary":{"bucket_stale":0,"bucket_dead":0,"service_failures":0}}); + let block = collection_block(Some(&value)); + assert_eq!(block.status, "OK"); + } +} diff --git a/adk-rust/crates/detmir-portal/src/static/app.css b/adk-rust/crates/detmir-portal/src/static/app.css new file mode 100644 index 0000000..4c93de9 --- /dev/null +++ b/adk-rust/crates/detmir-portal/src/static/app.css @@ -0,0 +1,182 @@ +:root { + color-scheme: light; + --bg: #f4f6f8; + --panel: #ffffff; + --ink: #172026; + --muted: #5d6872; + --line: #d8dee5; + --ok: #167347; + --ok-bg: #e5f4ec; + --warn: #9a6500; + --warn-bg: #fff2cc; + --fail: #b42318; + --fail-bg: #fde7e5; + --unknown: #59636e; + --unknown-bg: #e9edf1; + --link: #075985; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + background: var(--bg); + color: var(--ink); + font-family: Inter, "Segoe UI", Arial, sans-serif; +} + +.shell { + max-width: 1280px; + margin: 0 auto; + padding: 22px; +} + +.topbar { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 16px; +} + +.eyebrow { + margin: 0 0 4px; + color: var(--muted); + font-size: 13px; + font-weight: 700; + text-transform: uppercase; +} + +h1 { + margin: 0; + font-size: 30px; + line-height: 1.1; +} + +.tabs { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 16px; +} + +.tab { + border: 1px solid var(--line); + background: var(--panel); + color: var(--ink); + border-radius: 8px; + padding: 10px 14px; + font: inherit; + font-weight: 700; + cursor: pointer; +} + +.tab.is-active { + border-color: var(--link); + color: var(--link); +} + +.summary-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); + gap: 10px; + margin-bottom: 16px; +} + +.card, +.content-panel { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 8px; +} + +.card { + padding: 14px; + min-height: 104px; +} + +.content-panel { + padding: 16px; +} + +.card h3, +.section-title { + margin: 0 0 8px; + font-size: 16px; +} + +.muted { + color: var(--muted); +} + +.status-pill, +.badge { + display: inline-flex; + align-items: center; + width: fit-content; + border-radius: 8px; + padding: 6px 10px; + font-weight: 800; + font-size: 13px; +} + +.status-ok { background: var(--ok-bg); color: var(--ok); } +.status-warn { background: var(--warn-bg); color: var(--warn); } +.status-fail { background: var(--fail-bg); color: var(--fail); } +.status-unknown { background: var(--unknown-bg); color: var(--unknown); } + +.grid-2 { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 12px; +} + +.list { + display: grid; + gap: 8px; +} + +.row { + display: grid; + grid-template-columns: minmax(130px, 1fr) minmax(160px, 2fr) auto; + gap: 10px; + align-items: center; + padding: 10px; + border: 1px solid var(--line); + border-radius: 8px; +} + +.links { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +a.button { + display: inline-flex; + align-items: center; + min-height: 36px; + padding: 8px 10px; + border: 1px solid var(--line); + border-radius: 8px; + color: var(--link); + text-decoration: none; + font-weight: 700; + background: #fff; +} + +pre { + overflow: auto; + padding: 12px; + border-radius: 8px; + background: #111827; + color: #f9fafb; + font-size: 12px; +} + +@media (max-width: 640px) { + .shell { padding: 14px; } + .topbar { flex-direction: column; } + h1 { font-size: 24px; } + .row { grid-template-columns: 1fr; } +} diff --git a/adk-rust/crates/detmir-portal/src/static/app.js b/adk-rust/crates/detmir-portal/src/static/app.js new file mode 100644 index 0000000..29dd079 --- /dev/null +++ b/adk-rust/crates/detmir-portal/src/static/app.js @@ -0,0 +1,198 @@ +const state = { tab: "operator", links: null }; + +function apiBase() { + const path = window.location.pathname; + return path.startsWith("/portal") ? "/portal/api" : "/api"; +} + +async function loadJson(path) { + const response = await fetch(`${apiBase()}${path}`, { cache: "no-store" }); + if (!response.ok) throw new Error(`${path}: HTTP ${response.status}`); + return response.json(); +} + +function statusClass(status) { + const s = String(status || "UNKNOWN").toLowerCase(); + if (s === "ok" || s === "true") return "status-ok"; + if (s === "warn" || s === "warning") return "status-warn"; + if (s === "fail" || s === "false") return "status-fail"; + return "status-unknown"; +} + +function escapeHtml(value) { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + +function renderSummary(summary) { + const global = document.getElementById("globalStatus"); + global.className = `status-pill ${statusClass(summary.severity)}`; + global.textContent = `${summary.severity} · operator ${summary.operator_ok ? "OK" : "NO"}`; + const blocks = Object.entries(summary.blocks || {}); + document.getElementById("summary").innerHTML = blocks.map(([name, block]) => ` +
+ ${escapeHtml(block.status)} +

${escapeHtml(label(name))}

+

${escapeHtml(block.text)}

+
+ `).join(""); +} + +function label(name) { + return { + collection: "Сбор данных", + grafana: "Grafana", + dlp: "DLP", + worktime: "Работа сегодня", + one_c: "1С" + }[name] || name; +} + +function renderLinks(links) { + return ``; +} + +function renderSourceList(data) { + const sources = [ + ["DetMir", data.detmir_status], + ["Проверки", data.detmir_check], + ["Systemd", data.failed_units], + ["Grafana data", data.grafana_data] + ]; + return `
${sources.map(([name, source]) => ` +
+ ${escapeHtml(name)} + ${escapeHtml(source?.summary || source?.error || "нет данных")} + ${escapeHtml(source?.status || (source?.ok ? "OK" : "FAIL"))} +
+ `).join("")}
`; +} + +function renderOperator(data) { + return ` +

Оператор

+
+
+

Контур

+ ${renderSourceList(data)} +
+
+

Быстрые переходы

+ ${renderLinks(data.links)} +
+
+

Проблемы

+ ${renderIncidentsList(data.incidents)} + `; +} + +function renderManager(data) { + return ` +

Руководитель

+
+
+

Работа сегодня

+

Сотрудников: ${data.users_count}; активных часов: ${Number(data.total_active_hours || 0).toFixed(1)}

+

${escapeHtml(data.status?.text || "")}

+
+
+

Приложения

+
${(data.applications || []).slice(0, 8).map(app => ` +
+ ${escapeHtml(app.application)} + ${escapeHtml(app.proved_work_human || "")} + ${escapeHtml(app.evidence_events || 0)} +
+ `).join("")}
+
+
+

Сотрудники

+
${(data.users || []).map(user => ` +
+ ${escapeHtml(user.user)} + Активно: ${escapeHtml(user.active_hhmm || "00:00")} · последнее: ${escapeHtml(user.last_activity || "-")} + ${escapeHtml(user.sessions_count || 0)} сесс. +
+ `).join("")}
+ `; +} + +function renderOwner(data) { + const cards = Object.entries(data.cards || {}); + return ` +

Владелец

+
${cards.map(([name, block]) => ` +
+ ${escapeHtml(block.status)} +

${escapeHtml(label(name))}

+

${escapeHtml(block.text)}

+
+ `).join("")}
+
+

Что сделать

+
${(data.recommendations || []).map(item => `
Рекомендация${escapeHtml(item)}
`).join("")}
+
+
+

Переходы

+ ${renderLinks(data.links)} +
+ `; +} + +function renderIncidentsList(items) { + if (!items || items.length === 0) return `

Активных проблем нет.

`; + return `
${items.map(item => ` +
+ ${escapeHtml(item.source)} + ${escapeHtml(item.summary)} + ${escapeHtml(item.status)} +
+ `).join("")}
`; +} + +function renderIncidents(data) { + return ` +

Инциденты

+ ${renderIncidentsList(data)} + `; +} + +async function refresh() { + const summary = await loadJson("/summary"); + renderSummary(summary); + const content = document.getElementById("content"); + const data = await loadJson(`/${state.tab}`); + if (state.tab === "operator") content.innerHTML = renderOperator(data); + if (state.tab === "manager") content.innerHTML = renderManager(data); + if (state.tab === "owner") content.innerHTML = renderOwner(data); + if (state.tab === "incidents") content.innerHTML = renderIncidents(data); +} + +function setTab(tab) { + state.tab = tab; + document.querySelectorAll(".tab").forEach(btn => { + btn.classList.toggle("is-active", btn.dataset.tab === tab); + }); + document.getElementById("content").innerHTML = `

Загрузка...

`; + refresh().catch(showError); +} + +function showError(error) { + document.getElementById("content").innerHTML = `
${escapeHtml(error.stack || error.message || error)}
`; +} + +document.querySelectorAll(".tab").forEach(btn => { + btn.addEventListener("click", () => setTab(btn.dataset.tab)); +}); + +refresh().catch(showError); +setInterval(() => refresh().catch(showError), 60000); diff --git a/adk-rust/crates/detmir-portal/src/static/index.html b/adk-rust/crates/detmir-portal/src/static/index.html new file mode 100644 index 0000000..1c7799c --- /dev/null +++ b/adk-rust/crates/detmir-portal/src/static/index.html @@ -0,0 +1,31 @@ + + + + + + DetMir Portal + + + +
+
+
+

DetMir Portal

+

Контур компании

+
+
Загрузка
+
+ + + +
+
+
+ + + diff --git a/ansible/deploy_detmir_portal.yml b/ansible/deploy_detmir_portal.yml new file mode 100644 index 0000000..a240b5b --- /dev/null +++ b/ansible/deploy_detmir_portal.yml @@ -0,0 +1,93 @@ +--- +- name: Deploy DetMir read-only portal + hosts: proxmox + become: true + gather_facts: false + + vars: + aw_repo_root: "{{ playbook_dir | dirname }}" + aw_rust_release_dir: "{{ (lookup('env', 'CARGO_TARGET_DIR') | default(aw_repo_root + '/adk-rust/target', true)) + '/release' }}" + detmir_portal_bind: "{{ detmir_portal_bind_override | default('127.0.0.1:8720') }}" + detmir_portal_env_path: "/etc/detmir-portal.env" + + tasks: + - name: Check local detmir-portal binary + ansible.builtin.stat: + path: "{{ aw_rust_release_dir }}/detmir-portal" + delegate_to: localhost + become: false + register: detmir_portal_binary + + - name: Fail when detmir-portal binary is absent + ansible.builtin.fail: + msg: "Missing {{ aw_rust_release_dir }}/detmir-portal. Build with cargo build --release -p detmir-portal." + when: not (detmir_portal_binary.stat.exists | default(false)) + + - name: Install detmir-portal binary + ansible.builtin.copy: + src: "{{ aw_rust_release_dir }}/detmir-portal" + dest: /usr/local/bin/detmir-portal + owner: root + group: root + mode: "0755" + + - name: Install detmir-portal environment + ansible.builtin.copy: + dest: "{{ detmir_portal_env_path }}" + owner: root + group: root + mode: "0644" + content: | + DETMIR_PORTAL_BIND={{ detmir_portal_bind }} + DETMIR_PORTAL_STATUS_CMD=detmir-status --json + DETMIR_PORTAL_CHECK_CMD=detmir-check --json + DETMIR_PORTAL_FAILED_UNITS_CMD=systemctl --failed --no-pager + DETMIR_PORTAL_WORKTIME_URL=http://10.10.10.13:5610 + DETMIR_PORTAL_ONE_C_URL=http://10.10.10.2:8710 + DETMIR_PORTAL_TIMEOUT_SECONDS=10 + + - name: Install detmir-portal systemd service + ansible.builtin.copy: + dest: /etc/systemd/system/detmir-portal.service + owner: root + group: root + mode: "0644" + content: | + [Unit] + Description=DetMir Operator Portal + After=network-online.target + Wants=network-online.target + + [Service] + Type=simple + EnvironmentFile=-{{ detmir_portal_env_path }} + ExecStart=/usr/local/bin/detmir-portal + Restart=on-failure + RestartSec=5s + + [Install] + WantedBy=multi-user.target + register: detmir_portal_service_unit + + - name: Reload systemd + ansible.builtin.systemd: + daemon_reload: true + when: detmir_portal_service_unit.changed + + - name: Enable and restart detmir-portal + ansible.builtin.systemd: + name: detmir-portal.service + enabled: true + state: restarted + + - name: Verify detmir-portal local health + ansible.builtin.uri: + url: "http://{{ detmir_portal_bind }}/api/health" + method: GET + status_code: 200 + return_content: true + register: detmir_portal_health + failed_when: + - detmir_portal_health.status != 200 + - "'sources' not in detmir_portal_health.content" + changed_when: false diff --git a/ansible/deploy_proxmox_web_gateway.yml b/ansible/deploy_proxmox_web_gateway.yml index 744a752..9a6467f 100644 --- a/ansible/deploy_proxmox_web_gateway.yml +++ b/ansible/deploy_proxmox_web_gateway.yml @@ -102,6 +102,13 @@ proxy_path: "/r/aw-worktime" proxy_target_url: "http://10.10.10.13:5610/reports/worktime/management?day=today" external_enabled: true + - slug: "detmir-portal" + title: "DetMir Portal" + category: "Operations" + description: "Единый read-only портал для оператора, руководителя и владельца." + target_url: "http://127.0.0.1:8720/" + proxy_path: "/portal/" + external_enabled: true tasks: - name: Установить nginx @@ -325,6 +332,23 @@ status_code: 200 changed_when: false + - name: Проверить reverse proxy к DetMir Portal health с auth + ansible.builtin.uri: + url: "https://127.0.0.1/portal/api/health" + headers: + Host: "{{ proxmox_web_gateway_public_hostname }}" + url_username: "{{ proxmox_web_gateway_auth_user }}" + url_password: "{{ proxmox_web_gateway_auth_password }}" + force_basic_auth: true + validate_certs: false + status_code: 200 + return_content: true + register: proxmox_web_gateway_portal_health + failed_when: + - proxmox_web_gateway_portal_health.status != 200 + - "'sources' not in proxmox_web_gateway_portal_health.content" + changed_when: false + - name: Проверить browser-origin AW API query без 403 ansible.builtin.uri: url: "https://127.0.0.1/api/0/query/" diff --git a/ansible/templates/proxmox-web-gateway.conf.j2 b/ansible/templates/proxmox-web-gateway.conf.j2 index ea6aaeb..1d3be16 100644 --- a/ansible/templates/proxmox-web-gateway.conf.j2 +++ b/ansible/templates/proxmox-web-gateway.conf.j2 @@ -257,6 +257,16 @@ server { {% endif %} {% endfor %} + location = /portal { + return 302 /portal/; + } + + location /portal/ { + proxy_set_header Authorization ""; + proxy_pass http://127.0.0.1:8720/; + proxy_redirect off; + } + location /go/ { try_files /index.html =404; } diff --git a/docs/DETMIR_PORTAL_GUI_PLAN_RU.md b/docs/DETMIR_PORTAL_GUI_PLAN_RU.md index 169d123..b16cad6 100644 --- a/docs/DETMIR_PORTAL_GUI_PLAN_RU.md +++ b/docs/DETMIR_PORTAL_GUI_PLAN_RU.md @@ -4,6 +4,23 @@ надо угадывать архитектуру: идти по фазам, проверять каждый слой, не ломать текущий контур. +## Статус На 2026-06-02 + +Read-only MVP выполнен и развернут: + +- Rust crate: `adk-rust/crates/detmir-portal`; +- production service: `detmir-portal.service` на Proxmox; +- bind: `127.0.0.1:8720`; +- gateway route: `https://dm.iri1968.dpdns.org/portal/`; +- API: `/api/health`, `/api/summary`, `/api/operator`, `/api/manager`, + `/api/owner`, `/api/incidents`, `/api/links`; +- UI tabs: `Оператор`, `Руководитель`, `Владелец`, `Инциденты`; +- verification: local Rust gates OK, Ansible deploy OK, gateway health OK, + Playwright desktop/mobile smoke OK. + +Следующий агент не должен начинать MVP заново. Работать дальше от deployed +baseline и раздела `Phase 8: Post-MVP Enhancements`. + ## Цель Сделать единый web GUI для работы с контуром DetMir: diff --git a/scripts/check_detmir_rust_release_artifacts.sh b/scripts/check_detmir_rust_release_artifacts.sh index 2b24e94..8058678 100644 --- a/scripts/check_detmir_rust_release_artifacts.sh +++ b/scripts/check_detmir_rust_release_artifacts.sh @@ -37,6 +37,7 @@ required_bins=( aw-browser-smoke diag-and-manual-restart detmir-grafana-check + detmir-portal dlp-health-check dlp-content-analyzer dlp-admin-cli