feat(detmir): deploy read-only operator portal
This commit is contained in:
Generated
+13
@@ -572,6 +572,19 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "detmir-portal"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"chrono",
|
||||||
|
"clap",
|
||||||
|
"reqwest",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tiny_http",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "detmir-state"
|
name = "detmir-state"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ members = [
|
|||||||
"crates/aw-browser-smoke",
|
"crates/aw-browser-smoke",
|
||||||
"crates/diag-and-manual-restart",
|
"crates/diag-and-manual-restart",
|
||||||
"crates/detmir-grafana-check",
|
"crates/detmir-grafana-check",
|
||||||
|
"crates/detmir-portal",
|
||||||
"crates/aw-slo-monitor",
|
"crates/aw-slo-monitor",
|
||||||
"crates/aw-rus-healthd",
|
"crates/aw-rus-healthd",
|
||||||
"crates/detmir-check",
|
"crates/detmir-check",
|
||||||
|
|||||||
+38
-2
@@ -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,
|
timer active, Grafana CT failed units 0, Proxmox failed units 0,
|
||||||
`detmir-check` `service_failures=0`, `detmir-auto --no-heal` rc 0,
|
`detmir-check` `service_failures=0`, `detmir-auto --no-heal` rc 0,
|
||||||
`detmir-status` OK / `ok_for_operator=true`.
|
`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`;
|
`docs/DETMIR_PORTAL_GUI_PLAN_RU.md`;
|
||||||
- перенос Telegram bot runtime снят с плана: Python остается постоянным
|
- перенос Telegram bot runtime снят с плана: Python остается постоянным
|
||||||
runtime, Rust используется только для backend helpers;
|
runtime, Rust используется только для backend helpers;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -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<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
payload: Option<Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct HealthResponse {
|
||||||
|
ok: bool,
|
||||||
|
generated_at_utc: String,
|
||||||
|
version: String,
|
||||||
|
sources: BTreeMap<String, bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<String, SummaryBlock>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<i32> {
|
||||||
|
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::<Value>(&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::<Value>() {
|
||||||
|
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<IncidentItem> {
|
||||||
|
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<String> {
|
||||||
|
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<Value> {
|
||||||
|
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<bool> {
|
||||||
|
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<T: Serialize>(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> {
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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]) => `
|
||||||
|
<article class="card">
|
||||||
|
<span class="badge ${statusClass(block.status)}">${escapeHtml(block.status)}</span>
|
||||||
|
<h3>${escapeHtml(label(name))}</h3>
|
||||||
|
<p class="muted">${escapeHtml(block.text)}</p>
|
||||||
|
</article>
|
||||||
|
`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function label(name) {
|
||||||
|
return {
|
||||||
|
collection: "Сбор данных",
|
||||||
|
grafana: "Grafana",
|
||||||
|
dlp: "DLP",
|
||||||
|
worktime: "Работа сегодня",
|
||||||
|
one_c: "1С"
|
||||||
|
}[name] || name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLinks(links) {
|
||||||
|
return `<div class="links">
|
||||||
|
<a class="button" href="${links.detmir_activitywatch}">DetMir ActivityWatch</a>
|
||||||
|
<a class="button" href="${links.grafana_dashboards}">Grafana</a>
|
||||||
|
<a class="button" href="${links.aw_ui}">AW UI</a>
|
||||||
|
<a class="button" href="${links.worktime_report}">Worktime</a>
|
||||||
|
<a class="button" href="${links.file1c_brief}">1C brief</a>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSourceList(data) {
|
||||||
|
const sources = [
|
||||||
|
["DetMir", data.detmir_status],
|
||||||
|
["Проверки", data.detmir_check],
|
||||||
|
["Systemd", data.failed_units],
|
||||||
|
["Grafana data", data.grafana_data]
|
||||||
|
];
|
||||||
|
return `<div class="list">${sources.map(([name, source]) => `
|
||||||
|
<div class="row">
|
||||||
|
<strong>${escapeHtml(name)}</strong>
|
||||||
|
<span class="muted">${escapeHtml(source?.summary || source?.error || "нет данных")}</span>
|
||||||
|
<span class="badge ${statusClass(source?.status || source?.ok)}">${escapeHtml(source?.status || (source?.ok ? "OK" : "FAIL"))}</span>
|
||||||
|
</div>
|
||||||
|
`).join("")}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderOperator(data) {
|
||||||
|
return `
|
||||||
|
<h2 class="section-title">Оператор</h2>
|
||||||
|
<div class="grid-2">
|
||||||
|
<section class="card">
|
||||||
|
<h3>Контур</h3>
|
||||||
|
${renderSourceList(data)}
|
||||||
|
</section>
|
||||||
|
<section class="card">
|
||||||
|
<h3>Быстрые переходы</h3>
|
||||||
|
${renderLinks(data.links)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<h3 class="section-title">Проблемы</h3>
|
||||||
|
${renderIncidentsList(data.incidents)}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderManager(data) {
|
||||||
|
return `
|
||||||
|
<h2 class="section-title">Руководитель</h2>
|
||||||
|
<div class="grid-2">
|
||||||
|
<section class="card">
|
||||||
|
<h3>Работа сегодня</h3>
|
||||||
|
<p class="muted">Сотрудников: ${data.users_count}; активных часов: ${Number(data.total_active_hours || 0).toFixed(1)}</p>
|
||||||
|
<p class="muted">${escapeHtml(data.status?.text || "")}</p>
|
||||||
|
</section>
|
||||||
|
<section class="card">
|
||||||
|
<h3>Приложения</h3>
|
||||||
|
<div class="list">${(data.applications || []).slice(0, 8).map(app => `
|
||||||
|
<div class="row">
|
||||||
|
<strong>${escapeHtml(app.application)}</strong>
|
||||||
|
<span class="muted">${escapeHtml(app.proved_work_human || "")}</span>
|
||||||
|
<span class="badge status-ok">${escapeHtml(app.evidence_events || 0)}</span>
|
||||||
|
</div>
|
||||||
|
`).join("")}</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<h3 class="section-title">Сотрудники</h3>
|
||||||
|
<div class="list">${(data.users || []).map(user => `
|
||||||
|
<div class="row">
|
||||||
|
<strong>${escapeHtml(user.user)}</strong>
|
||||||
|
<span class="muted">Активно: ${escapeHtml(user.active_hhmm || "00:00")} · последнее: ${escapeHtml(user.last_activity || "-")}</span>
|
||||||
|
<span class="badge status-ok">${escapeHtml(user.sessions_count || 0)} сесс.</span>
|
||||||
|
</div>
|
||||||
|
`).join("")}</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderOwner(data) {
|
||||||
|
const cards = Object.entries(data.cards || {});
|
||||||
|
return `
|
||||||
|
<h2 class="section-title">Владелец</h2>
|
||||||
|
<div class="summary-grid">${cards.map(([name, block]) => `
|
||||||
|
<article class="card">
|
||||||
|
<span class="badge ${statusClass(block.status)}">${escapeHtml(block.status)}</span>
|
||||||
|
<h3>${escapeHtml(label(name))}</h3>
|
||||||
|
<p class="muted">${escapeHtml(block.text)}</p>
|
||||||
|
</article>
|
||||||
|
`).join("")}</div>
|
||||||
|
<section class="card">
|
||||||
|
<h3>Что сделать</h3>
|
||||||
|
<div class="list">${(data.recommendations || []).map(item => `<div class="row"><strong>Рекомендация</strong><span class="muted">${escapeHtml(item)}</span><span></span></div>`).join("")}</div>
|
||||||
|
</section>
|
||||||
|
<section class="card">
|
||||||
|
<h3>Переходы</h3>
|
||||||
|
${renderLinks(data.links)}
|
||||||
|
</section>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderIncidentsList(items) {
|
||||||
|
if (!items || items.length === 0) return `<p class="muted">Активных проблем нет.</p>`;
|
||||||
|
return `<div class="list">${items.map(item => `
|
||||||
|
<div class="row">
|
||||||
|
<strong>${escapeHtml(item.source)}</strong>
|
||||||
|
<span class="muted">${escapeHtml(item.summary)}</span>
|
||||||
|
<span class="badge ${statusClass(item.status)}">${escapeHtml(item.status)}</span>
|
||||||
|
</div>
|
||||||
|
`).join("")}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderIncidents(data) {
|
||||||
|
return `
|
||||||
|
<h2 class="section-title">Инциденты</h2>
|
||||||
|
${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 = `<p class="muted">Загрузка...</p>`;
|
||||||
|
refresh().catch(showError);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(error) {
|
||||||
|
document.getElementById("content").innerHTML = `<pre>${escapeHtml(error.stack || error.message || error)}</pre>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll(".tab").forEach(btn => {
|
||||||
|
btn.addEventListener("click", () => setTab(btn.dataset.tab));
|
||||||
|
});
|
||||||
|
|
||||||
|
refresh().catch(showError);
|
||||||
|
setInterval(() => refresh().catch(showError), 60000);
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>DetMir Portal</title>
|
||||||
|
<link rel="stylesheet" href="app.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="shell">
|
||||||
|
<header class="topbar">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">DetMir Portal</p>
|
||||||
|
<h1>Контур компании</h1>
|
||||||
|
</div>
|
||||||
|
<div id="globalStatus" class="status-pill status-unknown">Загрузка</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav class="tabs" aria-label="Разделы портала">
|
||||||
|
<button class="tab is-active" data-tab="operator">Оператор</button>
|
||||||
|
<button class="tab" data-tab="manager">Руководитель</button>
|
||||||
|
<button class="tab" data-tab="owner">Владелец</button>
|
||||||
|
<button class="tab" data-tab="incidents">Инциденты</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<section id="summary" class="summary-grid"></section>
|
||||||
|
<section id="content" class="content-panel" aria-live="polite"></section>
|
||||||
|
</main>
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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
|
||||||
@@ -102,6 +102,13 @@
|
|||||||
proxy_path: "/r/aw-worktime"
|
proxy_path: "/r/aw-worktime"
|
||||||
proxy_target_url: "http://10.10.10.13:5610/reports/worktime/management?day=today"
|
proxy_target_url: "http://10.10.10.13:5610/reports/worktime/management?day=today"
|
||||||
external_enabled: true
|
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:
|
tasks:
|
||||||
- name: Установить nginx
|
- name: Установить nginx
|
||||||
@@ -325,6 +332,23 @@
|
|||||||
status_code: 200
|
status_code: 200
|
||||||
changed_when: false
|
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
|
- name: Проверить browser-origin AW API query без 403
|
||||||
ansible.builtin.uri:
|
ansible.builtin.uri:
|
||||||
url: "https://127.0.0.1/api/0/query/"
|
url: "https://127.0.0.1/api/0/query/"
|
||||||
|
|||||||
@@ -257,6 +257,16 @@ server {
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% 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/ {
|
location /go/ {
|
||||||
try_files /index.html =404;
|
try_files /index.html =404;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
Сделать единый web GUI для работы с контуром DetMir:
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ required_bins=(
|
|||||||
aw-browser-smoke
|
aw-browser-smoke
|
||||||
diag-and-manual-restart
|
diag-and-manual-restart
|
||||||
detmir-grafana-check
|
detmir-grafana-check
|
||||||
|
detmir-portal
|
||||||
dlp-health-check
|
dlp-health-check
|
||||||
dlp-content-analyzer
|
dlp-content-analyzer
|
||||||
dlp-admin-cli
|
dlp-admin-cli
|
||||||
|
|||||||
Reference in New Issue
Block a user