feat(rust): advance powershell migration
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "aw-1c-ingest"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
calamine.workspace = true
|
||||
chrono.workspace = true
|
||||
clap.workspace = true
|
||||
csv.workspace = true
|
||||
filetime.workspace = true
|
||||
fs2.workspace = true
|
||||
regex.workspace = true
|
||||
reqwest.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
serde_yaml.workspace = true
|
||||
sha1.workspace = true
|
||||
tempfile.workspace = true
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
||||
[package]
|
||||
name = "aw-windows-telemetry"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
base64.workspace = true
|
||||
chrono.workspace = true
|
||||
clap.workspace = true
|
||||
csv.workspace = true
|
||||
notify.workspace = true
|
||||
reqwest.workspace = true
|
||||
regex.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
sha2.workspace = true
|
||||
tempfile.workspace = true
|
||||
url.workspace = true
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows = { version = "0.59", features = ["Win32_Foundation", "Win32_System_Com", "Win32_UI_Accessibility", "Win32_UI_WindowsAndMessaging"] }
|
||||
windows-sys = { workspace = true, features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_DataExchange", "Win32_System_Diagnostics_Debug", "Win32_System_Diagnostics_ToolHelp", "Win32_System_Memory", "Win32_System_RemoteDesktop", "Win32_System_SystemInformation", "Win32_System_Threading", "Win32_UI_WindowsAndMessaging"] }
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@ use anyhow::{Context, Result};
|
||||
use clap::Parser;
|
||||
|
||||
const DEFAULT_SSH_TARGET: &str = "igor@192.0.2.13";
|
||||
const DEFAULT_REMOTE_COMMAND: &str = "sudo -n /usr/local/bin/dlp-health-check --json";
|
||||
const DEFAULT_REMOTE_COMMAND: &str = "sudo -n env AW_DLP_HEALTH_ENDPOINT_SEND_FAILURE_WARN_COUNT=10 AW_DLP_HEALTH_FILEOPS_SEND_FAILURE_WARN_COUNT=10 /usr/local/bin/dlp-health-check --json";
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(about = "Run the DetMir DLP health check on the AW server over SSH.")]
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::process::CommandExt;
|
||||
@@ -29,7 +29,7 @@ const APP_JS: &str = include_str!("static/app.js");
|
||||
const API_CONTRACT_OPENAPI: &str = include_str!("contracts/openapi.json");
|
||||
const API_CONTRACT_TYPESCRIPT: &str = include_str!("contracts/typescript.d.ts");
|
||||
const UEBA_BASELINE_MIN_SAMPLES: usize = 3;
|
||||
const SNAPSHOT_CACHE_TTL: Duration = Duration::from_secs(5);
|
||||
const SNAPSHOT_CACHE_TTL: Duration = Duration::from_secs(120);
|
||||
const DEFAULT_DEPARTMENT_LABEL: &str = "Без подразделения";
|
||||
|
||||
#[cfg(unix)]
|
||||
@@ -1308,10 +1308,7 @@ fn handle_request(request: Request, args: &Cli, snapshot_cache: &SnapshotCache)
|
||||
API_CONTRACT_TYPESCRIPT,
|
||||
"text/plain; charset=utf-8",
|
||||
),
|
||||
"/api/health" => respond_json(
|
||||
request,
|
||||
&build_health(&cached_snapshot(args, snapshot_cache)),
|
||||
),
|
||||
"/api/health" => respond_json(request, &build_fast_health(snapshot_cache)),
|
||||
"/api/readiness/latest" => respond_json(request, &readiness_latest(args)),
|
||||
"/api/readiness/bundle" => respond_json(request, &readiness_bundle(args)),
|
||||
"/api/readiness/verify" => respond_json(request, &readiness_verify(args)),
|
||||
@@ -1642,6 +1639,27 @@ fn cached_snapshot(args: &Cli, cache: &SnapshotCache) -> Snapshot {
|
||||
snapshot
|
||||
}
|
||||
|
||||
fn build_fast_health(cache: &SnapshotCache) -> HealthResponse {
|
||||
match cache.try_lock() {
|
||||
Ok(guard) => guard
|
||||
.as_ref()
|
||||
.map(|cached| build_health(&cached.snapshot))
|
||||
.unwrap_or_else(lightweight_health),
|
||||
Err(_) => lightweight_health(),
|
||||
}
|
||||
}
|
||||
|
||||
fn lightweight_health() -> HealthResponse {
|
||||
let mut sources = BTreeMap::new();
|
||||
sources.insert("portal".to_string(), true);
|
||||
HealthResponse {
|
||||
ok: true,
|
||||
generated_at_utc: now(),
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
sources,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_snapshot(args: &Cli) -> Snapshot {
|
||||
let timeout = Duration::from_secs(args.timeout_seconds);
|
||||
let security_events_config = SecurityEventsConfig {
|
||||
@@ -2630,12 +2648,18 @@ fn security_events_block(summary: &SecurityEventsSummary) -> SummaryBlock {
|
||||
}
|
||||
|
||||
fn run_shell(command: &str, timeout: Duration) -> Result<(String, String, bool)> {
|
||||
let stdout_path = command_output_path("stdout");
|
||||
let stderr_path = command_output_path("stderr");
|
||||
let stdout_file =
|
||||
File::create(&stdout_path).with_context(|| format!("create {}", stdout_path.display()))?;
|
||||
let stderr_file =
|
||||
File::create(&stderr_path).with_context(|| format!("create {}", stderr_path.display()))?;
|
||||
let mut shell = Command::new("/bin/sh");
|
||||
shell
|
||||
.arg("-lc")
|
||||
.arg(command)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
.stdout(Stdio::from(stdout_file))
|
||||
.stderr(Stdio::from(stderr_file));
|
||||
#[cfg(unix)]
|
||||
unsafe {
|
||||
shell.pre_exec(|| {
|
||||
@@ -2652,19 +2676,15 @@ fn run_shell(command: &str, timeout: Duration) -> Result<(String, String, bool)>
|
||||
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)?;
|
||||
}
|
||||
let stdout = read_command_output(&stdout_path)?;
|
||||
let stderr = read_command_output(&stderr_path)?;
|
||||
cleanup_command_output(&stdout_path, &stderr_path);
|
||||
return Ok((stdout, stderr, status.success()));
|
||||
}
|
||||
if started.elapsed() > timeout {
|
||||
kill_shell_tree(&mut child);
|
||||
let _ = child.wait();
|
||||
cleanup_command_output(&stdout_path, &stderr_path);
|
||||
return Err(anyhow!(
|
||||
"command timed out after {}s: {command}",
|
||||
timeout.as_secs()
|
||||
@@ -2674,6 +2694,27 @@ fn run_shell(command: &str, timeout: Duration) -> Result<(String, String, bool)>
|
||||
}
|
||||
}
|
||||
|
||||
fn command_output_path(stream: &str) -> PathBuf {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos();
|
||||
std::env::temp_dir().join(format!(
|
||||
"detmir-portal-command-{}-{nanos}-{stream}.log",
|
||||
std::process::id()
|
||||
))
|
||||
}
|
||||
|
||||
fn read_command_output(path: &Path) -> Result<String> {
|
||||
let bytes = fs::read(path).with_context(|| format!("read {}", path.display()))?;
|
||||
Ok(String::from_utf8_lossy(&bytes).into_owned())
|
||||
}
|
||||
|
||||
fn cleanup_command_output(stdout_path: &Path, stderr_path: &Path) {
|
||||
let _ = fs::remove_file(stdout_path);
|
||||
let _ = fs::remove_file(stderr_path);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn kill_shell_tree(child: &mut std::process::Child) {
|
||||
let pgid = child.id() as i32;
|
||||
@@ -3519,6 +3560,9 @@ fn build_risk_incident_candidates(
|
||||
candidates.extend(low_trust_risk_candidates(risks, snapshot));
|
||||
candidates.extend(agent_quality_candidates(snapshot, &problem_nodes_by_host));
|
||||
candidates.extend(agent_coverage_candidates(snapshot));
|
||||
if candidates.is_empty() {
|
||||
candidates.extend(workforce_insight_risk_candidates(snapshot, risks));
|
||||
}
|
||||
|
||||
candidates.sort_by(|left, right| {
|
||||
risk_incident_candidate_rank(right)
|
||||
@@ -4720,6 +4764,64 @@ fn agent_coverage_candidates(snapshot: &Snapshot) -> Vec<RiskIncidentCandidate>
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn workforce_insight_risk_candidates(
|
||||
snapshot: &Snapshot,
|
||||
risks: &[BusinessRiskItem],
|
||||
) -> Vec<RiskIncidentCandidate> {
|
||||
let primary_risk = risks.first();
|
||||
let department = primary_risk
|
||||
.map(|item| item.department.clone())
|
||||
.unwrap_or_else(|| DEFAULT_DEPARTMENT_LABEL.to_string());
|
||||
let risk_level = primary_risk
|
||||
.map(|item| item.risk_level.clone())
|
||||
.unwrap_or_else(|| "MEDIUM".to_string());
|
||||
let recommendation = primary_risk
|
||||
.map(|item| item.recommendation.clone())
|
||||
.unwrap_or_else(|| {
|
||||
"Проверить первичные события ActivityWatch и причины отклонения активности.".to_string()
|
||||
});
|
||||
workforce_insight_items(snapshot)
|
||||
.into_iter()
|
||||
.filter(|item| {
|
||||
let status = item.get("status").and_then(Value::as_str).unwrap_or("INFO");
|
||||
!matches!(status, "OK" | "INFO")
|
||||
})
|
||||
.take(3)
|
||||
.map(|item| {
|
||||
let label = item
|
||||
.get("label")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("Отклонение активности");
|
||||
let value = item.get("value").and_then(Value::as_str).unwrap_or("");
|
||||
RiskIncidentCandidate {
|
||||
id: risk_candidate_id(
|
||||
"workforce-insight",
|
||||
&department,
|
||||
&format!("{label}:{value}"),
|
||||
),
|
||||
department: Some(department.clone()),
|
||||
owner: None,
|
||||
hostname: None,
|
||||
risk_level: Some(risk_level.clone()),
|
||||
reason: Some(label.to_string()),
|
||||
evidence: vec![
|
||||
format!("activity_signal={label}"),
|
||||
format!("details={value}"),
|
||||
format!(
|
||||
"security_events_24h={}",
|
||||
snapshot.security_events_summary.events_24h
|
||||
),
|
||||
],
|
||||
first_seen_utc: Some(snapshot.generated_at_utc.clone()),
|
||||
last_seen_utc: Some(snapshot.generated_at_utc.clone()),
|
||||
recommendation: Some(recommendation.clone()),
|
||||
incident_review: IncidentReviewState::default(),
|
||||
incident_review_audit: Vec::new(),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn agent_quality_candidate_level(node: &AgentQualityNodeItem) -> String {
|
||||
match node.status.as_str() {
|
||||
"DEGRADED" => "HIGH",
|
||||
@@ -9458,6 +9560,17 @@ mod tests {
|
||||
assert!(err.to_string().contains("command timed out"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_shell_does_not_block_on_background_stdout_handle() {
|
||||
let started = Instant::now();
|
||||
let (stdout, _, success) =
|
||||
run_shell("sh -c 'sleep 5 & printf ok'", Duration::from_secs(2)).unwrap();
|
||||
assert!(started.elapsed() < Duration::from_secs(2));
|
||||
assert!(success);
|
||||
assert_eq!(stdout, "ok");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_json_source_accepts_valid_json_with_nonzero_exit() {
|
||||
let source = command_json_source(
|
||||
|
||||
@@ -123,6 +123,10 @@ function displayText(value) {
|
||||
.replaceAll("Grafana dashboards", "графики")
|
||||
.replaceAll("Grafana data", "данные графиков")
|
||||
.replaceAll("Grafana", "Графики")
|
||||
.replaceAll("SECURITY_EVENTS_BACKEND", "настройка источника событий")
|
||||
.replaceAll("CLICKHOUSE_*", "параметры источника событий")
|
||||
.replaceAll("ClickHouse", "источник событий")
|
||||
.replaceAll("local_fallback", "резервный локальный источник")
|
||||
.replaceAll("DetMir ActivityWatch", "Журнал активности")
|
||||
.replaceAll("AW UI", "Журнал активности")
|
||||
.replaceAll("bundle", "пакет проверки")
|
||||
@@ -166,9 +170,20 @@ function displayText(value) {
|
||||
.replaceAll("daily", "день")
|
||||
.replaceAll("weekly", "неделя")
|
||||
.replaceAll("monthly", "месяц")
|
||||
.replaceAll("OK", "OK")
|
||||
.replaceAll("FAIL", "FAIL")
|
||||
.replaceAll("WARN", "WARN");
|
||||
.replace(/\bCRITICAL\b/g, "критично")
|
||||
.replace(/\bWARNING\b/g, "требует внимания")
|
||||
.replace(/\bUNKNOWN\b/g, "нет данных")
|
||||
.replace(/\bDISABLED\b/g, "отключено")
|
||||
.replace(/\bREADY\b/g, "готово")
|
||||
.replace(/\bOPEN\b/g, "открыто")
|
||||
.replace(/\bINFO\b/g, "информация")
|
||||
.replace(/\bHIGH\b/g, "высокий риск")
|
||||
.replace(/\bMEDIUM\b/g, "средний риск")
|
||||
.replace(/\bLOW\b/g, "низкий риск")
|
||||
.replace(/\bFAIL\b/g, "критично")
|
||||
.replace(/\bWARN\b/g, "требует внимания")
|
||||
.replace(/\bNO\b/g, "нет")
|
||||
.replace(/\bOK\b/g, "в норме");
|
||||
}
|
||||
|
||||
function ui(value) {
|
||||
@@ -233,7 +248,7 @@ function setViewMode(mode) {
|
||||
function renderSummary(summary, readiness) {
|
||||
const global = document.getElementById("globalStatus");
|
||||
global.className = `status-pill ${statusClass(summary.severity)}`;
|
||||
global.textContent = `Сбор данных ${summary.operator_ok ? "OK" : "NO"} · ${summary.severity}`;
|
||||
global.textContent = `Сбор данных ${summary.operator_ok ? "в норме" : "нет данных"} · ${displayText(summary.severity)}`;
|
||||
const blocks = Object.entries(summary.blocks || {});
|
||||
const readinessCard = renderReadinessSummaryCard(readiness);
|
||||
document.getElementById("summary").innerHTML = readinessCard + blocks.map(([name, block]) => `
|
||||
@@ -268,8 +283,8 @@ function renderReadinessSummaryCard(readiness) {
|
||||
</div>
|
||||
<div class="readiness-metrics">
|
||||
<div><span class="muted">Дата</span><strong>${escapeHtml(generated)}</strong></div>
|
||||
<div><span class="muted">Подпись</span><strong class="${signatureOk ? "text-ok" : "text-fail"}">${signatureOk ? "OK" : "FAIL"}</strong></div>
|
||||
<div><span class="muted">Контрольная сумма</span><strong class="${checksumOk ? "text-ok" : "text-fail"}">${checksumOk ? "OK" : "FAIL"}</strong></div>
|
||||
<div><span class="muted">Подпись</span><strong class="${signatureOk ? "text-ok" : "text-fail"}">${signatureOk ? "подтверждена" : "не подтверждена"}</strong></div>
|
||||
<div><span class="muted">Контрольная сумма</span><strong class="${checksumOk ? "text-ok" : "text-fail"}">${checksumOk ? "подтверждена" : "не подтверждена"}</strong></div>
|
||||
</div>
|
||||
<p class="muted small">Отпечаток ключа: <code>${escapeHtml(shortFingerprint(fingerprint))}</code></p>
|
||||
<p id="readinessVerifyStatus" class="muted small">${escapeHtml(verificationText)}</p>
|
||||
@@ -774,7 +789,7 @@ function departmentRows(report) {
|
||||
total: parsed.total,
|
||||
hhmm: parsed.hhmm,
|
||||
trend,
|
||||
risk: status === "FAIL" ? "FAIL — требуется действие" : status === "WARN" ? "WARN — требуется внимание" : "LOW — все нормально",
|
||||
risk: status === "FAIL" ? "критично — требуется действие" : status === "WARN" ? "требует внимания" : "низкий риск — все нормально",
|
||||
reason: departmentRiskReason({ item, parsed, activity, status, trend }),
|
||||
check: departmentCheckText({ parsed, status, trend }),
|
||||
};
|
||||
@@ -808,8 +823,8 @@ function executiveDashboardKpis(report) {
|
||||
return [
|
||||
metricCard("Сотрудников в работе", latest.active_users ?? findKpi(report, "Сотрудники")?.value ?? "нет данных", "OK", "активны сегодня"),
|
||||
metricCard("Средний индекс активности", Number.isFinite(average) ? `${average}%` : findKpi(report, "Индекс активности")?.value, average === null ? "UNKNOWN" : workforceIndexStatus(average), "по подразделениям"),
|
||||
metricCard("WARN подразделений", rows.filter(row => row.status === "WARN").length, rows.some(row => row.status === "WARN") ? "WARN" : "OK", "требуется внимание"),
|
||||
metricCard("FAIL подразделений", rows.filter(row => row.status === "FAIL").length, rows.some(row => row.status === "FAIL") ? "FAIL" : "OK", "требуется действие"),
|
||||
metricCard("Требуют внимания", rows.filter(row => row.status === "WARN").length, rows.some(row => row.status === "WARN") ? "WARN" : "OK", "подразделения"),
|
||||
metricCard("Критичные подразделения", rows.filter(row => row.status === "FAIL").length, rows.some(row => row.status === "FAIL") ? "FAIL" : "OK", "требуется действие"),
|
||||
metricCard("Критических рисков", criticalRisks, criticalRisks ? "FAIL" : "OK", "приоритетный разбор"),
|
||||
metricCard("Тренд недели", signedPercent(weeklyTrendPct(report)), Number(weeklyTrendPct(report)) < 0 ? "WARN" : "OK", "динамика активности"),
|
||||
].join("");
|
||||
@@ -906,7 +921,7 @@ function renderDepartmentRanking(report) {
|
||||
<span class="badge ${statusClass(row.status)}">${ui(row.status)}</span>
|
||||
</div>
|
||||
`).join("")}</div>`;
|
||||
const emptyRows = `<div class="list compact-list"><div class="row compact-row"><strong>Нет данных</strong><span class="muted">Подразделения пока не рассчитаны.</span><span class="badge status-unknown">UNKNOWN</span></div></div>`;
|
||||
const emptyRows = `<div class="list compact-list"><div class="row compact-row"><strong>Нет данных</strong><span class="muted">Подразделения пока не рассчитаны.</span><span class="badge status-unknown">нет данных</span></div></div>`;
|
||||
return `
|
||||
<section class="ranking-grid">
|
||||
<article class="card">
|
||||
@@ -994,7 +1009,7 @@ function renderDepartmentHeatMap(report) {
|
||||
<td><strong>Нет данных</strong><small>Подразделения пока не рассчитаны.</small></td>
|
||||
<td>-</td>
|
||||
<td>-</td>
|
||||
<td><span class="badge status-unknown">UNKNOWN</span></td>
|
||||
<td><span class="badge status-unknown">нет данных</span></td>
|
||||
<td>-</td>
|
||||
<td><button class="small-button" data-open-investigation="true">Открыть расследование</button></td>
|
||||
</tr>
|
||||
@@ -1149,7 +1164,7 @@ function renderSourceList(data) {
|
||||
<div class="row">
|
||||
<strong>${ui(name)}</strong>
|
||||
<span class="muted">${ui(source?.summary || source?.error || "нет данных")}</span>
|
||||
<span class="badge ${statusClass(source?.status || source?.ok)}">${escapeHtml(source?.status || (source?.ok ? "OK" : "FAIL"))}</span>
|
||||
<span class="badge ${statusClass(source?.status || source?.ok)}">${ui(source?.status || (source?.ok ? "OK" : "FAIL"))}</span>
|
||||
</div>
|
||||
`).join("")}</div>`;
|
||||
}
|
||||
@@ -1243,7 +1258,7 @@ function renderOperationsErrors(data, report) {
|
||||
if (report?.security_events_summary?.fallback_used) {
|
||||
rows.push([
|
||||
"События безопасности",
|
||||
report.security_events_summary.error || "ClickHouse недоступен",
|
||||
report.security_events_summary.error || "Источник событий недоступен",
|
||||
"WARN",
|
||||
]);
|
||||
}
|
||||
@@ -1282,7 +1297,7 @@ function renderOperationsErrors(data, report) {
|
||||
<div class="row compact-row">
|
||||
<strong>Критичных ошибок нет</strong>
|
||||
<span class="muted">Портал не видит ошибок коллектора или проблемных рабочих мест в текущем срезе.</span>
|
||||
<span class="badge status-ok">OK</span>
|
||||
<span class="badge status-ok">в норме</span>
|
||||
</div>
|
||||
`}</div>
|
||||
</section>
|
||||
@@ -1335,7 +1350,7 @@ function renderInvestigationPacks(candidates) {
|
||||
<div class="row compact-row">
|
||||
<strong>Пакетов нет</strong>
|
||||
<span class="muted">Нет записей, требующих выгрузки материалов.</span>
|
||||
<span class="badge status-ok">OK</span>
|
||||
<span class="badge status-ok">в норме</span>
|
||||
</div>
|
||||
`}</div>
|
||||
</section>
|
||||
@@ -1855,7 +1870,7 @@ function renderCases(cases) {
|
||||
<tr>
|
||||
<td>Нет дел</td>
|
||||
<td>-</td>
|
||||
<td><span class="badge status-ok">OK</span></td>
|
||||
<td><span class="badge status-ok">в норме</span></td>
|
||||
<td>-</td>
|
||||
<td>Создайте дело из подтвержденного кандидата.</td>
|
||||
<td>-</td>
|
||||
@@ -1941,7 +1956,7 @@ function renderUebaRisk(risk) {
|
||||
<span class="muted">${ui(item.value || "")} · ${ui(item.recommendation || "")}</span>
|
||||
<span class="badge ${statusClass(item.status || item.severity)}">+${escapeHtml(item.points || 0)}</span>
|
||||
</div>
|
||||
`).join("") : `<div class="row compact-row"><strong>Сигналы</strong><span class="muted">Существенных риск-сигналов в текущем срезе нет.</span><span class="badge status-ok">OK</span></div>`}</div>
|
||||
`).join("") : `<div class="row compact-row"><strong>Сигналы</strong><span class="muted">Существенных риск-сигналов в текущем срезе нет.</span><span class="badge status-ok">в норме</span></div>`}</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
@@ -2038,7 +2053,7 @@ function renderAgentQualityHistory(history, summary) {
|
||||
<span class="badge ${statusClass(status)}">${ui(status)}</span>
|
||||
</div>
|
||||
<div class="quality-decision">
|
||||
<div><span class="muted">OK дней</span><strong>${escapeHtml(s.ok_days ?? 0)}</strong></div>
|
||||
<div><span class="muted">Дней в норме</span><strong>${escapeHtml(s.ok_days ?? 0)}</strong></div>
|
||||
<div><span class="muted">Проблемных дней</span><strong>${escapeHtml(unstableDays)}</strong></div>
|
||||
<div><span class="muted">Показатели подтверждены</span><strong>${escapeHtml(s.kpi_accepted_pct ?? 0)}%</strong></div>
|
||||
</div>
|
||||
@@ -2051,7 +2066,7 @@ function renderAgentQualityHistory(history, summary) {
|
||||
<span class="muted">источник=${ui(item.source || "unknown")} · показатели=${item.kpi_accepted ? "да" : "нет"}${item.collector_error ? ` · ${ui(item.collector_error)}` : ""}</span>
|
||||
<span class="badge ${statusClass(item.status)}">${ui(item.status || "UNKNOWN")}</span>
|
||||
</div>
|
||||
`).join("") : `<div class="row compact-row"><strong>История</strong><span class="muted">История качества агента за период отсутствует.</span><span class="badge status-unknown">UNKNOWN</span></div>`}</div>
|
||||
`).join("") : `<div class="row compact-row"><strong>История</strong><span class="muted">История качества агента за период отсутствует.</span><span class="badge status-unknown">нет данных</span></div>`}</div>
|
||||
</details>
|
||||
</section>
|
||||
`;
|
||||
@@ -2076,7 +2091,7 @@ function renderAgentQualityNodes(nodes, summary) {
|
||||
</div>
|
||||
<div class="quality-decision">
|
||||
<div><span class="muted">Всего узлов</span><strong>${escapeHtml(s.total_nodes ?? 0)}</strong></div>
|
||||
<div><span class="muted">OK</span><strong>${escapeHtml(s.ok_nodes ?? 0)}</strong></div>
|
||||
<div><span class="muted">В норме</span><strong>${escapeHtml(s.ok_nodes ?? 0)}</strong></div>
|
||||
<div><span class="muted">Проблемных</span><strong>${escapeHtml(Number(s.degraded_nodes || 0) + Number(s.unknown_nodes || 0))}</strong></div>
|
||||
<div><span class="muted">Показатели подтверждены</span><strong>${escapeHtml(s.accepted_kpi_nodes_pct ?? 0)}%</strong></div>
|
||||
</div>
|
||||
@@ -2106,7 +2121,7 @@ function renderAgentQualityNodes(nodes, summary) {
|
||||
`).join("") : `
|
||||
<tr>
|
||||
<td>Нет данных</td>
|
||||
<td><span class="badge status-unknown">UNKNOWN</span></td>
|
||||
<td><span class="badge status-unknown">нет данных</span></td>
|
||||
<td>unknown</td>
|
||||
<td>-</td>
|
||||
<td>нет</td>
|
||||
@@ -2207,7 +2222,7 @@ function renderSecurityEventsSummary(summary, options = {}) {
|
||||
: "Агрегированная сводка без сырых журналов и без автоматического создания инцидентов.";
|
||||
const top = Array.isArray(s.top_departments) ? s.top_departments.slice(0, 5) : [];
|
||||
const warning = fallback
|
||||
? `<div class="quality-warning">События безопасности временно недоступны. Проверьте ClickHouse и переменные SECURITY_EVENTS_BACKEND/CLICKHOUSE_*.</div>`
|
||||
? `<div class="quality-warning">События безопасности временно недоступны. Проверьте подключение источника событий.</div>`
|
||||
: "";
|
||||
if (options.compact) {
|
||||
return `
|
||||
@@ -2247,7 +2262,7 @@ function renderSecurityEventsSummary(summary, options = {}) {
|
||||
</div>
|
||||
${warning}
|
||||
${s.error ? `<p class="muted small">Причина: ${ui(s.error)}</p>` : ""}
|
||||
${disabled ? `<p class="muted small">Для включения задайте SECURITY_EVENTS_BACKEND=clickhouse и параметры CLICKHOUSE_*.</p>` : ""}
|
||||
${disabled ? `<p class="muted small">Источник событий безопасности не включен в текущем режиме.</p>` : ""}
|
||||
${options.compact ? "" : `
|
||||
<div class="table-scroll">
|
||||
<table class="data-table">
|
||||
@@ -2313,7 +2328,7 @@ function renderBusinessRisk(items) {
|
||||
`).join("") : `
|
||||
<tr>
|
||||
<td>Нет данных</td>
|
||||
<td><span class="badge status-unknown">UNKNOWN</span></td>
|
||||
<td><span class="badge status-unknown">нет данных</span></td>
|
||||
<td>нет данных</td>
|
||||
<td>0</td>
|
||||
<td>Дождаться расчета подразделений.</td>
|
||||
@@ -2370,10 +2385,10 @@ function renderRiskHeatmap(items) {
|
||||
`).join("") : `
|
||||
<tr>
|
||||
<td>Нет данных</td>
|
||||
<td>UNKNOWN</td>
|
||||
<td>UNKNOWN</td>
|
||||
<td>UNKNOWN</td>
|
||||
<td><span class="badge status-unknown">UNKNOWN</span></td>
|
||||
<td>нет данных</td>
|
||||
<td>нет данных</td>
|
||||
<td>нет данных</td>
|
||||
<td><span class="badge status-unknown">нет данных</span></td>
|
||||
<td>0</td>
|
||||
<td>0</td>
|
||||
<td>-</td>
|
||||
@@ -2454,9 +2469,9 @@ function renderSecurityCorrelation(items) {
|
||||
`).join("") : `
|
||||
<tr>
|
||||
<td>Нет данных</td>
|
||||
<td>UNKNOWN</td>
|
||||
<td>UNKNOWN</td>
|
||||
<td><span class="badge status-unknown">UNKNOWN</span></td>
|
||||
<td>нет данных</td>
|
||||
<td>нет данных</td>
|
||||
<td><span class="badge status-unknown">нет данных</span></td>
|
||||
<td>проверить 0 · расследования 0</td>
|
||||
<td>0</td>
|
||||
<td>0/100</td>
|
||||
@@ -2529,7 +2544,7 @@ function renderBusinessRiskTimeline(history, summary) {
|
||||
<tr>
|
||||
<td>-</td>
|
||||
<td>История не накоплена</td>
|
||||
<td><span class="badge status-unknown">UNKNOWN</span></td>
|
||||
<td><span class="badge status-unknown">нет данных</span></td>
|
||||
<td>Нужно дождаться daily history.</td>
|
||||
</tr>
|
||||
`}
|
||||
@@ -2583,7 +2598,7 @@ function renderRiskIncidentCandidates(items) {
|
||||
<td>-</td>
|
||||
<td>Нет кандидатов</td>
|
||||
<td>-</td>
|
||||
<td><span class="badge status-ok">OK</span></td>
|
||||
<td><span class="badge status-ok">в норме</span></td>
|
||||
<td><span class="badge status-unknown">NEW</span></td>
|
||||
<td>очередь проверки пуста</td>
|
||||
<td>Действий не требуется.</td>
|
||||
@@ -2645,6 +2660,7 @@ function renderCandidateReviewActions(item) {
|
||||
<div class="button-row compact-actions">${actions.map(([status, label]) => `
|
||||
<button class="small-button" data-review-status="${escapeHtml(status)}" data-candidate-id="${escapeHtml(id)}">${ui(label)}</button>
|
||||
`).join("")}</div>
|
||||
<button class="small-button" data-open-investigation="true">Открыть расследование</button>
|
||||
<a class="small-button investigation-pack-button" href="${escapeHtml(packUrl)}" download>Скачать пакет расследования</a>
|
||||
${createCase}
|
||||
`;
|
||||
@@ -2753,8 +2769,8 @@ function settingRows(report) {
|
||||
return [
|
||||
["Период расчета", period.label, period.key === "today" ? "оперативный дневной срез" : `${period.days} календарных дней`],
|
||||
["Рабочий день", `${workdayHours} ч`, `роль: ${policy.role_label || activeRole.label || policy.default_role || "default"}`],
|
||||
["Порог WARN", ">= 15 баллов", "любой ненормальный риск попадает в очередь проверки"],
|
||||
["Порог FAIL", ">= 70 баллов", "высокий риск требует приоритетного разбора"],
|
||||
["Порог внимания", ">= 15 баллов", "любой ненормальный риск попадает в очередь проверки"],
|
||||
["Порог критического риска", ">= 70 баллов", "высокий риск требует приоритетного разбора"],
|
||||
["Источник правил", policy.configured ? basename(policy.path) : "встроенные правила", risk.policy_configured ? `правила оценки риска: ${basename(risk.policy_path)}` : "правила оценки риска: встроенная модель"],
|
||||
["Дата последнего пересчета", report?.generated_at_utc || "-", `версия политики: ${risk.policy_version || "ueba-rule-v1"}`],
|
||||
];
|
||||
@@ -2786,24 +2802,27 @@ async function refresh(options = {}) {
|
||||
const content = document.getElementById("content");
|
||||
const background = Boolean(options.background);
|
||||
const stage = options.stage || "Получение данных";
|
||||
const progress = (status, label, value) => {
|
||||
if (!background) setLoadStatus(status, label, value);
|
||||
};
|
||||
try {
|
||||
setLoadStatus("LOADING", stage, background ? 35 : 8);
|
||||
progress("LOADING", stage, 8);
|
||||
if (!background && content) content.innerHTML = renderLoadingContent(stage);
|
||||
if (!state.links) {
|
||||
setLoadStatus("LOADING", "Получение данных", 18);
|
||||
progress("LOADING", "Получение данных", 18);
|
||||
state.links = await loadJson("/links");
|
||||
}
|
||||
setLoadStatus("LOADING", "Расчёт показателей", 34);
|
||||
progress("LOADING", "Расчёт показателей", 34);
|
||||
const summary = await loadJson("/summary");
|
||||
setLoadStatus("LOADING", "Расчёт показателей", 46);
|
||||
progress("LOADING", "Расчёт показателей", 46);
|
||||
state.readiness = {
|
||||
bundle: await loadJson("/readiness/bundle").catch(error => ({ ok: false, error: error.message })),
|
||||
verify: state.readiness?.verify || null
|
||||
};
|
||||
renderSummary(summary, state.readiness);
|
||||
setLoadStatus("LOADING", "Формирование главного вывода", 68);
|
||||
progress("LOADING", "Формирование главного вывода", 68);
|
||||
const tabResult = await loadCurrentTab();
|
||||
setLoadStatus("LOADING", "Подготовка разделов", 88);
|
||||
progress("LOADING", "Подготовка разделов", 88);
|
||||
if (!hasTabData(state.tab, tabResult)) {
|
||||
setLoadStatus("EMPTY", "Данные отсутствуют", 100);
|
||||
if (content) {
|
||||
@@ -3031,7 +3050,7 @@ async function verifyReadinessBundle(button) {
|
||||
};
|
||||
const status = document.getElementById("readinessVerifyStatus");
|
||||
if (status) {
|
||||
status.textContent = `Проверено: контрольная сумма ${verify.checksum_verified ? "OK" : "FAIL"} · подпись ${verify.signature_verified ? "OK" : "FAIL"}`;
|
||||
status.textContent = `Проверено: контрольная сумма ${verify.checksum_verified ? "подтверждена" : "не подтверждена"} · подпись ${verify.signature_verified ? "подтверждена" : "не подтверждена"}`;
|
||||
}
|
||||
button.disabled = false;
|
||||
button.textContent = "Проверить пакет";
|
||||
|
||||
Reference in New Issue
Block a user