feat(detmir): add portal incident acknowledgement
This commit is contained in:
+19
-4
@@ -1587,13 +1587,28 @@ systemctl is-active tsj-guardian-bot tsj-guardian-watchdog gost-tg
|
||||
pinned to the same HTML report. Production smoke: AW UI, Worktime, 1C
|
||||
brief, and 1C actions returned `200 text/html`; Grafana links correctly
|
||||
redirect to `/login` when no Grafana session exists.
|
||||
- incident operator workflow added to portal: incidents now have stable
|
||||
IDs, operator state is persisted in
|
||||
`/var/lib/detmir-portal/incidents-state.json`, audit events append to
|
||||
`/var/lib/detmir-portal/audit.jsonl`, nginx passes `$remote_user` as
|
||||
`X-Remote-User`, and the UI exposes `В работу` / `Назначить` actions.
|
||||
This layer only records acknowledgement/assignment metadata; it does not
|
||||
heal, restart, mutate AW/DLP/1C, or touch pfSense/Telegram. Gates:
|
||||
`cargo fmt --all -- --check`, `cargo test -p detmir-portal` (`5 passed`),
|
||||
`cargo clippy -p detmir-portal --all-targets -- -D warnings`, release
|
||||
build OK. Production smoke: `POST /portal/api/incidents/action` returned
|
||||
`200 application/json`, actor resolved to `detmir`, state/audit files
|
||||
were written, smoke state was removed after validation, portal health
|
||||
stayed `200`, `detmir-status` stayed `OK / ok_for_operator=true`, and
|
||||
failed units stayed `0`. Local Playwright smoke against a temporary
|
||||
portal instance verified `Инциденты ИБ`, `В работу`, persisted UI state,
|
||||
disabled ack button, and zero JS errors.
|
||||
|
||||
Отложить:
|
||||
|
||||
- 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. Детальный план:
|
||||
- post-MVP развитие `detmir-portal`: role-aware views, 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;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::Read;
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::{Read, Write};
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -9,7 +11,7 @@ use chrono::{SecondsFormat, Utc};
|
||||
use clap::Parser;
|
||||
use reqwest::blocking::Client;
|
||||
use reqwest::header::{CONNECTION, HeaderValue};
|
||||
use serde::Serialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use tiny_http::{Header, Method, Request, Response, Server, StatusCode};
|
||||
|
||||
@@ -61,6 +63,13 @@ struct Cli {
|
||||
#[arg(long, default_value_t = 10, env = "DETMIR_PORTAL_TIMEOUT_SECONDS")]
|
||||
timeout_seconds: u64,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "/var/lib/detmir-portal",
|
||||
env = "DETMIR_PORTAL_STATE_DIR"
|
||||
)]
|
||||
state_dir: PathBuf,
|
||||
|
||||
#[arg(long)]
|
||||
json_smoke: bool,
|
||||
}
|
||||
@@ -101,12 +110,71 @@ struct SummaryResponse {
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct IncidentItem {
|
||||
id: String,
|
||||
status: String,
|
||||
kind: String,
|
||||
source: String,
|
||||
summary: String,
|
||||
generated_at_utc: String,
|
||||
link: String,
|
||||
acknowledged: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
acknowledged_at_utc: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
actor: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
assigned_to: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
comment: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
updated_at_utc: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize, Serialize)]
|
||||
struct IncidentStateFile {
|
||||
incidents: BTreeMap<String, IncidentActionState>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
struct IncidentActionState {
|
||||
state: String,
|
||||
actor: String,
|
||||
updated_at_utc: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
acknowledged_at_utc: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
assigned_to: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
comment: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct IncidentActionRequest {
|
||||
id: String,
|
||||
action: String,
|
||||
#[serde(default)]
|
||||
assigned_to: Option<String>,
|
||||
#[serde(default)]
|
||||
comment: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct IncidentActionResponse {
|
||||
ok: bool,
|
||||
id: String,
|
||||
state: IncidentActionState,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct IncidentAuditEntry {
|
||||
generated_at_utc: String,
|
||||
actor: String,
|
||||
id: String,
|
||||
action: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
assigned_to: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
comment: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -145,10 +213,11 @@ fn run() -> Result<i32> {
|
||||
let args = Cli::parse();
|
||||
if args.json_smoke {
|
||||
let snapshot = build_snapshot(&args);
|
||||
let incident_state = load_incident_state_best_effort(&args);
|
||||
let smoke = json!({
|
||||
"health": build_health(&snapshot),
|
||||
"summary": build_summary(&snapshot),
|
||||
"incidents": build_incidents(&snapshot),
|
||||
"incidents": build_incidents(&snapshot, &incident_state),
|
||||
});
|
||||
println!("{}", serde_json::to_string_pretty(&smoke)?);
|
||||
return Ok(if build_health(&snapshot).ok { 0 } else { 2 });
|
||||
@@ -165,10 +234,14 @@ fn run() -> Result<i32> {
|
||||
}
|
||||
|
||||
fn handle_request(request: Request, args: &Cli) -> Result<()> {
|
||||
if request.method() != &Method::Get {
|
||||
let method = request.method().clone();
|
||||
let path = normalize_path(request.url());
|
||||
if method == Method::Post && path == "/api/incidents/action" {
|
||||
return handle_incident_action(request, args);
|
||||
}
|
||||
if 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,
|
||||
@@ -188,7 +261,8 @@ fn handle_request(request: Request, args: &Cli) -> Result<()> {
|
||||
"/api/summary" => respond_json(request, &build_summary(&build_snapshot(args))),
|
||||
"/api/operator" => {
|
||||
let snapshot = build_snapshot(args);
|
||||
respond_json(request, &build_operator(&snapshot))
|
||||
let incident_state = load_incident_state_best_effort(args);
|
||||
respond_json(request, &build_operator(&snapshot, &incident_state))
|
||||
}
|
||||
"/api/manager" => {
|
||||
let snapshot = build_snapshot(args);
|
||||
@@ -200,7 +274,8 @@ fn handle_request(request: Request, args: &Cli) -> Result<()> {
|
||||
}
|
||||
"/api/incidents" => {
|
||||
let snapshot = build_snapshot(args);
|
||||
respond_json(request, &build_incidents(&snapshot))
|
||||
let incident_state = load_incident_state_best_effort(args);
|
||||
respond_json(request, &build_incidents(&snapshot, &incident_state))
|
||||
}
|
||||
"/api/links" => respond_json(request, &links()),
|
||||
_ => respond_text(
|
||||
@@ -465,7 +540,7 @@ fn build_summary(snapshot: &Snapshot) -> SummaryResponse {
|
||||
}
|
||||
}
|
||||
|
||||
fn build_operator(snapshot: &Snapshot) -> Value {
|
||||
fn build_operator(snapshot: &Snapshot, incident_state: &IncidentStateFile) -> Value {
|
||||
json!({
|
||||
"generated_at_utc": snapshot.generated_at_utc,
|
||||
"summary": build_summary(snapshot),
|
||||
@@ -474,7 +549,7 @@ fn build_operator(snapshot: &Snapshot) -> Value {
|
||||
"failed_units": snapshot.failed_units,
|
||||
"grafana_data": grafana_service(snapshot),
|
||||
"links": links(),
|
||||
"incidents": build_incidents(snapshot),
|
||||
"incidents": build_incidents(snapshot, incident_state),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -530,7 +605,7 @@ fn build_owner(snapshot: &Snapshot) -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
fn build_incidents(snapshot: &Snapshot) -> Vec<IncidentItem> {
|
||||
fn build_incidents(snapshot: &Snapshot, state: &IncidentStateFile) -> Vec<IncidentItem> {
|
||||
let mut incidents = Vec::new();
|
||||
for source in [
|
||||
("detmir_status", &snapshot.detmir_status),
|
||||
@@ -540,60 +615,62 @@ fn build_incidents(snapshot: &Snapshot) -> Vec<IncidentItem> {
|
||||
("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(),
|
||||
});
|
||||
incidents.push(incident_item(
|
||||
&source.1.status,
|
||||
"health",
|
||||
source.0,
|
||||
&source.1.summary,
|
||||
&snapshot.generated_at_utc,
|
||||
"/portal/operator",
|
||||
state,
|
||||
));
|
||||
}
|
||||
}
|
||||
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(),
|
||||
});
|
||||
let status = if service.get("required").and_then(Value::as_bool) == Some(true) {
|
||||
"FAIL"
|
||||
} else {
|
||||
"WARN"
|
||||
};
|
||||
let source = service
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("service");
|
||||
let summary = service
|
||||
.get("error")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("service check failed");
|
||||
incidents.push(incident_item(
|
||||
status,
|
||||
"service",
|
||||
source,
|
||||
summary,
|
||||
&snapshot.generated_at_utc,
|
||||
"/portal/operator",
|
||||
state,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
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!(
|
||||
let status = bucket
|
||||
.get("status")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("WARN");
|
||||
let source = bucket
|
||||
.get("bucket")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("bucket");
|
||||
incidents.push(incident_item(
|
||||
status,
|
||||
"collector",
|
||||
source,
|
||||
&format!(
|
||||
"{} is {}",
|
||||
bucket
|
||||
.get("label")
|
||||
@@ -604,9 +681,10 @@ fn build_incidents(snapshot: &Snapshot) -> Vec<IncidentItem> {
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("not OK")
|
||||
),
|
||||
generated_at_utc: snapshot.generated_at_utc.clone(),
|
||||
link: "/portal/operator".to_string(),
|
||||
});
|
||||
&snapshot.generated_at_utc,
|
||||
"/portal/operator",
|
||||
state,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -614,6 +692,230 @@ fn build_incidents(snapshot: &Snapshot) -> Vec<IncidentItem> {
|
||||
incidents
|
||||
}
|
||||
|
||||
fn incident_item(
|
||||
status: &str,
|
||||
kind: &str,
|
||||
source: &str,
|
||||
summary: &str,
|
||||
generated_at_utc: &str,
|
||||
link: &str,
|
||||
state: &IncidentStateFile,
|
||||
) -> IncidentItem {
|
||||
let id = incident_id(kind, source, summary);
|
||||
let saved = state.incidents.get(&id);
|
||||
IncidentItem {
|
||||
id,
|
||||
status: status.to_string(),
|
||||
kind: kind.to_string(),
|
||||
source: source.to_string(),
|
||||
summary: summary.to_string(),
|
||||
generated_at_utc: generated_at_utc.to_string(),
|
||||
link: link.to_string(),
|
||||
acknowledged: saved
|
||||
.map(|item| item.state == "acknowledged")
|
||||
.unwrap_or(false),
|
||||
acknowledged_at_utc: saved.and_then(|item| item.acknowledged_at_utc.clone()),
|
||||
actor: saved.map(|item| item.actor.clone()),
|
||||
assigned_to: saved.and_then(|item| item.assigned_to.clone()),
|
||||
comment: saved.and_then(|item| item.comment.clone()),
|
||||
updated_at_utc: saved.map(|item| item.updated_at_utc.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn incident_id(kind: &str, source: &str, summary: &str) -> String {
|
||||
let mut hash = 0xcbf29ce484222325u64;
|
||||
for byte in format!("{kind}\n{source}\n{summary}").as_bytes() {
|
||||
hash ^= u64::from(*byte);
|
||||
hash = hash.wrapping_mul(0x100000001b3);
|
||||
}
|
||||
format!("{kind}-{hash:016x}")
|
||||
}
|
||||
|
||||
fn handle_incident_action(mut request: Request, args: &Cli) -> Result<()> {
|
||||
let actor = request_actor(&request);
|
||||
let mut body = String::new();
|
||||
request
|
||||
.as_reader()
|
||||
.take(32 * 1024)
|
||||
.read_to_string(&mut body)?;
|
||||
let response = apply_incident_action(args, &actor, &body);
|
||||
match response {
|
||||
Ok(response) => respond_json(request, &response),
|
||||
Err(err) => respond_text(
|
||||
request,
|
||||
StatusCode(400),
|
||||
&serde_json::to_string_pretty(&json!({
|
||||
"ok": false,
|
||||
"error": err.to_string()
|
||||
}))?,
|
||||
"application/json; charset=utf-8",
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_incident_action(args: &Cli, actor: &str, body: &str) -> Result<IncidentActionResponse> {
|
||||
let action: IncidentActionRequest =
|
||||
serde_json::from_str(body).map_err(|err| anyhow!("invalid incident action JSON: {err}"))?;
|
||||
let id = validate_short_token(&action.id, "id", 128)?;
|
||||
let action_name = validate_action(&action.action)?;
|
||||
let assigned_to = sanitize_optional_text(action.assigned_to, 80);
|
||||
let comment = sanitize_optional_text(action.comment, 500);
|
||||
let now = now();
|
||||
|
||||
let mut state_file = load_incident_state(args)?;
|
||||
let previous = state_file.incidents.get(&id).cloned();
|
||||
let acknowledged_at_utc = if action_name == "ack" {
|
||||
previous
|
||||
.as_ref()
|
||||
.and_then(|item| item.acknowledged_at_utc.clone())
|
||||
.or_else(|| Some(now.clone()))
|
||||
} else if action_name == "clear" {
|
||||
None
|
||||
} else {
|
||||
previous
|
||||
.as_ref()
|
||||
.and_then(|item| item.acknowledged_at_utc.clone())
|
||||
};
|
||||
let state_name = if action_name == "clear" {
|
||||
"open"
|
||||
} else if acknowledged_at_utc.is_some() {
|
||||
"acknowledged"
|
||||
} else {
|
||||
"assigned"
|
||||
};
|
||||
let next = IncidentActionState {
|
||||
state: state_name.to_string(),
|
||||
actor: actor.to_string(),
|
||||
updated_at_utc: now.clone(),
|
||||
acknowledged_at_utc,
|
||||
assigned_to: assigned_to
|
||||
.clone()
|
||||
.or_else(|| previous.and_then(|item| item.assigned_to)),
|
||||
comment: comment.clone(),
|
||||
};
|
||||
state_file.incidents.insert(id.clone(), next.clone());
|
||||
save_incident_state(args, &state_file)?;
|
||||
append_incident_audit(
|
||||
args,
|
||||
&IncidentAuditEntry {
|
||||
generated_at_utc: now,
|
||||
actor: actor.to_string(),
|
||||
id: id.clone(),
|
||||
action: action_name.to_string(),
|
||||
assigned_to,
|
||||
comment,
|
||||
},
|
||||
)?;
|
||||
Ok(IncidentActionResponse {
|
||||
ok: true,
|
||||
id,
|
||||
state: next,
|
||||
})
|
||||
}
|
||||
|
||||
fn load_incident_state_best_effort(args: &Cli) -> IncidentStateFile {
|
||||
match load_incident_state(args) {
|
||||
Ok(state) => state,
|
||||
Err(err) => {
|
||||
eprintln!("detmir-portal incident state read failed: {err:#}");
|
||||
IncidentStateFile::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn load_incident_state(args: &Cli) -> Result<IncidentStateFile> {
|
||||
let path = incident_state_path(args);
|
||||
if !path.exists() {
|
||||
return Ok(IncidentStateFile::default());
|
||||
}
|
||||
let data = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
|
||||
serde_json::from_str(&data).with_context(|| format!("parse {}", path.display()))
|
||||
}
|
||||
|
||||
fn save_incident_state(args: &Cli, state: &IncidentStateFile) -> Result<()> {
|
||||
fs::create_dir_all(&args.state_dir)
|
||||
.with_context(|| format!("create {}", args.state_dir.display()))?;
|
||||
let path = incident_state_path(args);
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
fs::write(&tmp, serde_json::to_vec_pretty(state)?)
|
||||
.with_context(|| format!("write {}", tmp.display()))?;
|
||||
fs::rename(&tmp, &path).with_context(|| format!("rename {}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn append_incident_audit(args: &Cli, entry: &IncidentAuditEntry) -> Result<()> {
|
||||
fs::create_dir_all(&args.state_dir)
|
||||
.with_context(|| format!("create {}", args.state_dir.display()))?;
|
||||
let path = args.state_dir.join("audit.jsonl");
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)
|
||||
.with_context(|| format!("open {}", path.display()))?;
|
||||
serde_json::to_writer(&mut file, entry)?;
|
||||
file.write_all(b"\n")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn incident_state_path(args: &Cli) -> PathBuf {
|
||||
args.state_dir.join("incidents-state.json")
|
||||
}
|
||||
|
||||
fn request_actor(request: &Request) -> String {
|
||||
for name in ["X-Remote-User", "X-Gateway-User", "Remote-User"] {
|
||||
if let Some(value) = request
|
||||
.headers()
|
||||
.iter()
|
||||
.find(|header| header.field.equiv(name))
|
||||
.map(|header| header.value.as_str().trim())
|
||||
{
|
||||
if !value.is_empty() {
|
||||
return sanitize_text(value, 80);
|
||||
}
|
||||
}
|
||||
}
|
||||
"local".to_string()
|
||||
}
|
||||
|
||||
fn validate_short_token(value: &str, name: &str, max_len: usize) -> Result<String> {
|
||||
let value = value.trim();
|
||||
if value.is_empty() || value.len() > max_len {
|
||||
return Err(anyhow!("{name} length is invalid"));
|
||||
}
|
||||
if !value
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | ':' | '.'))
|
||||
{
|
||||
return Err(anyhow!("{name} contains unsupported characters"));
|
||||
}
|
||||
Ok(value.to_string())
|
||||
}
|
||||
|
||||
fn validate_action(value: &str) -> Result<&'static str> {
|
||||
match value.trim() {
|
||||
"ack" | "acknowledge" => Ok("ack"),
|
||||
"assign" => Ok("assign"),
|
||||
"clear" | "reopen" => Ok("clear"),
|
||||
_ => Err(anyhow!("unsupported incident action")),
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_optional_text(value: Option<String>, max_len: usize) -> Option<String> {
|
||||
value
|
||||
.map(|text| sanitize_text(&text, max_len))
|
||||
.filter(|text| !text.is_empty())
|
||||
}
|
||||
|
||||
fn sanitize_text(value: &str, max_len: usize) -> String {
|
||||
value
|
||||
.chars()
|
||||
.filter(|ch| !ch.is_control())
|
||||
.take(max_len)
|
||||
.collect::<String>()
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn links() -> PortalLinks {
|
||||
PortalLinks {
|
||||
portal: "/portal/".to_string(),
|
||||
@@ -949,4 +1251,46 @@ mod tests {
|
||||
let block = collection_block(Some(&value));
|
||||
assert_eq!(block.status, "OK");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incident_id_is_stable() {
|
||||
assert_eq!(
|
||||
incident_id("service", "grafana-data", "stale"),
|
||||
incident_id("service", "grafana-data", "stale")
|
||||
);
|
||||
assert_ne!(
|
||||
incident_id("service", "grafana-data", "stale"),
|
||||
incident_id("service", "grafana-data", "fresh")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incident_item_applies_ack_state() {
|
||||
let id = incident_id("service", "grafana-data", "stale");
|
||||
let mut state = IncidentStateFile::default();
|
||||
state.incidents.insert(
|
||||
id.clone(),
|
||||
IncidentActionState {
|
||||
state: "acknowledged".to_string(),
|
||||
actor: "detmir".to_string(),
|
||||
updated_at_utc: "2026-06-02T18:00:00Z".to_string(),
|
||||
acknowledged_at_utc: Some("2026-06-02T18:00:00Z".to_string()),
|
||||
assigned_to: Some("operator".to_string()),
|
||||
comment: Some("checking".to_string()),
|
||||
},
|
||||
);
|
||||
let item = incident_item(
|
||||
"FAIL",
|
||||
"service",
|
||||
"grafana-data",
|
||||
"stale",
|
||||
"2026-06-02T18:01:00Z",
|
||||
"/portal/operator",
|
||||
&state,
|
||||
);
|
||||
assert_eq!(item.id, id);
|
||||
assert!(item.acknowledged);
|
||||
assert_eq!(item.actor.as_deref(), Some("detmir"));
|
||||
assert_eq!(item.assigned_to.as_deref(), Some("operator"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +109,12 @@ h1 {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.small {
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.status-pill,
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
@@ -146,6 +152,35 @@ h1 {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.incident-row {
|
||||
grid-template-columns: minmax(150px, 1fr) minmax(220px, 2fr) auto auto;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.small-button {
|
||||
min-height: 32px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: var(--link);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.small-button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -179,4 +214,6 @@ pre {
|
||||
.topbar { flex-direction: column; }
|
||||
h1 { font-size: 24px; }
|
||||
.row { grid-template-columns: 1fr; }
|
||||
.incident-row { grid-template-columns: 1fr; }
|
||||
.actions { justify-content: flex-start; }
|
||||
}
|
||||
|
||||
@@ -11,6 +11,16 @@ async function loadJson(path) {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function postJson(path, payload) {
|
||||
const response = await fetch(`${apiBase()}${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
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";
|
||||
@@ -153,10 +163,20 @@ function renderOwner(data) {
|
||||
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>
|
||||
<div class="row incident-row">
|
||||
<div>
|
||||
<strong>${escapeHtml(item.source)}</strong>
|
||||
<div class="muted small">${escapeHtml(item.kind)} · ${escapeHtml(item.id)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="muted">${escapeHtml(item.summary)}</span>
|
||||
${item.acknowledged ? `<div class="muted small">В работе: ${escapeHtml(item.assigned_to || item.actor || "оператор")} · ${escapeHtml(item.comment || "")}</div>` : ""}
|
||||
</div>
|
||||
<span class="badge ${statusClass(item.status)}">${escapeHtml(item.status)}</span>
|
||||
<div class="actions">
|
||||
<button class="small-button" data-incident-action="ack" data-incident-id="${escapeHtml(item.id)}"${item.acknowledged ? " disabled" : ""}>В работу</button>
|
||||
<button class="small-button" data-incident-action="assign" data-incident-id="${escapeHtml(item.id)}">Назначить</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join("")}</div>`;
|
||||
}
|
||||
@@ -196,5 +216,33 @@ document.querySelectorAll(".tab").forEach(btn => {
|
||||
btn.addEventListener("click", () => setTab(btn.dataset.tab));
|
||||
});
|
||||
|
||||
document.addEventListener("click", event => {
|
||||
const button = event.target.closest("[data-incident-action]");
|
||||
if (!button) return;
|
||||
incidentAction(button).catch(showError);
|
||||
});
|
||||
|
||||
async function incidentAction(button) {
|
||||
const id = button.dataset.incidentId;
|
||||
const action = button.dataset.incidentAction;
|
||||
const payload = { id, action };
|
||||
if (action === "ack") {
|
||||
const comment = window.prompt("Комментарий к взятию в работу", "");
|
||||
if (comment === null) return;
|
||||
payload.comment = comment;
|
||||
}
|
||||
if (action === "assign") {
|
||||
const assignedTo = window.prompt("Кому назначить", "");
|
||||
if (assignedTo === null || assignedTo.trim() === "") return;
|
||||
payload.assigned_to = assignedTo;
|
||||
const comment = window.prompt("Комментарий", "");
|
||||
if (comment === null) return;
|
||||
payload.comment = comment;
|
||||
}
|
||||
button.disabled = true;
|
||||
await postJson("/incidents/action", payload);
|
||||
await refresh();
|
||||
}
|
||||
|
||||
refresh().catch(showError);
|
||||
setInterval(() => refresh().catch(showError), 60000);
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
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
|
||||
DETMIR_PORTAL_STATE_DIR=/var/lib/detmir-portal
|
||||
|
||||
- name: Install detmir-portal systemd service
|
||||
ansible.builtin.copy:
|
||||
|
||||
@@ -263,6 +263,7 @@ server {
|
||||
|
||||
location /portal/ {
|
||||
proxy_set_header Authorization "";
|
||||
proxy_set_header X-Remote-User $remote_user;
|
||||
proxy_pass http://127.0.0.1:8720/;
|
||||
proxy_redirect off;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user