diff --git a/adk-rust/crates/aw-browser-smoke/src/main.rs b/adk-rust/crates/aw-browser-smoke/src/main.rs index bd6e2cc..889ef72 100644 --- a/adk-rust/crates/aw-browser-smoke/src/main.rs +++ b/adk-rust/crates/aw-browser-smoke/src/main.rs @@ -71,9 +71,7 @@ fn default_node_path(current: Option<&OsStr>) -> Option { if current.is_some_and(|value| !value.is_empty()) { return None; } - let Some(home) = std::env::var_os("HOME") else { - return None; - }; + let home = std::env::var_os("HOME")?; let path = PathBuf::from(home) .join(".agents") .join("skills") diff --git a/adk-rust/crates/detmir-portal/src/static/app.css b/adk-rust/crates/detmir-portal/src/static/app.css index 7dde6aa..39d7588 100644 --- a/adk-rust/crates/detmir-portal/src/static/app.css +++ b/adk-rust/crates/detmir-portal/src/static/app.css @@ -402,7 +402,7 @@ h1 { .analytics-grid { display: grid; - grid-template-columns: minmax(280px, 1fr) minmax(280px, 1fr) minmax(280px, 1fr); + grid-template-columns: minmax(430px, 1.25fr) minmax(320px, 1fr) minmax(300px, 0.9fr); gap: 12px; margin-bottom: 14px; } @@ -415,12 +415,34 @@ h1 { background: var(--panel); } +.department-panel { + grid-column: 1 / -1; + min-height: auto; +} + +.anomaly-panel { + grid-column: span 2; +} + +.risk-panel { + grid-column: span 1; +} + .data-table { width: 100%; border-collapse: collapse; font-size: 13px; } +.table-scroll { + max-width: 100%; + overflow-x: auto; +} + +.department-table { + min-width: 0; +} + .data-table th, .data-table td { padding: 9px 8px; @@ -454,18 +476,44 @@ h1 { background: var(--soft); } +.rank-list small { + display: block; + margin-top: 3px; + color: var(--muted); + font-size: 12px; + line-height: 1.35; +} + +.risk-list li { + grid-template-columns: auto minmax(0, 1fr) auto auto; +} + .anomaly-list li { grid-template-columns: auto 1fr; align-items: start; } -.anomaly-list li strong { +.anomaly-list li > strong { grid-column: 2; color: var(--muted); font-size: 12px; line-height: 1.35; } +.anomaly-list p { + margin: 4px 0 0; + color: var(--muted); + font-size: 12px; + line-height: 1.4; +} + +.department-table th, +.department-table td, +.settings-table th, +.settings-table td { + white-space: normal; +} + .dashboard-band { margin-top: 12px; padding: 14px; @@ -720,6 +768,9 @@ pre { @media (max-width: 1280px) { .executive-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } .analytics-grid { grid-template-columns: 1fr; } + .department-panel, + .anomaly-panel, + .risk-panel { grid-column: auto; } } @media (max-width: 640px) { @@ -749,6 +800,7 @@ pre { .app-weight-row { grid-template-columns: 1fr; } .employee-index-row { grid-template-columns: 1fr; } .employee-metrics { grid-template-columns: 1fr; } + .risk-list li { grid-template-columns: 1fr; } .section-head { flex-direction: column; } .actions { justify-content: flex-start; } } diff --git a/adk-rust/crates/detmir-portal/src/static/app.js b/adk-rust/crates/detmir-portal/src/static/app.js index 1b40b76..5be7f49 100644 --- a/adk-rust/crates/detmir-portal/src/static/app.js +++ b/adk-rust/crates/detmir-portal/src/static/app.js @@ -339,45 +339,98 @@ function trendArrow(status) { return "→"; } +function firstPercent(value) { + const match = String(value || "").match(/-?\d+(?:[.,]\d+)?\s*%/); + return match ? match[0].replace(",", ".").replace(/\s+/g, "") : "-"; +} + +function departmentDeviation(item) { + const status = String(item?.status || "INFO").toUpperCase(); + const value = item?.value || ""; + if (status === "OK") return "в норме"; + if (status === "WARN") return `отклонение: ${firstPercent(value)}`; + if (status === "FAIL") return `критическая просадка: ${firstPercent(value)}`; + return "требует данных"; +} + +function departmentResponsible(owners, index) { + const item = Array.isArray(owners) ? owners[index] : null; + return item?.label || item?.title || "не назначен"; +} + function renderDepartmentTable(report) { const items = (report?.workforce?.department_comparison || []).slice(0, 8); + const owners = report?.workforce?.owner_comparison || []; if (!items.length) return `

Подразделения пока не рассчитаны.

`; return ` - - - ${items.map(item => ` +
+
ПодразделениеАктивностьТрендРиск
+ + ${items.map((item, index) => ` - - - + + + + `).join("")}
ПодразделениеИндекс активностиОтклонениеСтатусОтветственный
${ui(item.label)}${ui(item.value || "-")}${escapeHtml(trendArrow(item.status))}${ui(statusRiskLabel(item.status))}${ui(item.index_activity || firstPercent(item.value) || "-")}${ui(item.deviation || departmentDeviation(item))}${ui(item.status || "INFO")}${ui(item.responsible || departmentResponsible(owners, index))}
+ `; } +function anomalyExplanation(item) { + const title = item?.label || item?.title || "Аномалия"; + const evidence = item?.value || item?.subject || "система отметила отклонение от обычного среза"; + const status = String(item?.status || "INFO").toUpperCase(); + const text = `${title} ${evidence}`.toLowerCase(); + let why = "это может указывать на изменение загрузки, дисциплины процесса или качества сбора данных"; + let check = "проверить первичные события ActivityWatch, рабочий план и контекст подразделения"; + if (text.includes("копирован") || text.includes("dlp") || text.includes("файл")) { + why = "есть риск неконтролируемого движения данных или подготовки выгрузки"; + check = "открыть материалы проверки, сверить файл, пользователя, время и разрешенную бизнес-задачу"; + } else if (text.includes("ноч") || text.includes("вне рабочего")) { + why = "активность вне обычного окна может быть переработкой, удаленным доступом или нарушением регламента"; + check = "сверить RDP-сессию, задачу сотрудника и журнал входов"; + } else if (text.includes("недогруз") || text.includes("просад") || text.includes("coverage")) { + why = "просадка активности может означать простой, неверный план работ или сбой сбора"; + check = "сравнить план задач, присутствие в системе и свежесть данных коллектора"; + } else if (status === "FAIL") { + why = "событие имеет высокий приоритет и может влиять на безопасность или управляемость"; + check = "назначить ручную проверку и сопоставить событие с журналами источников"; + } + return { title, evidence, why, check }; +} + function renderAnomalies(report) { const insights = Array.isArray(report?.workforce?.insights) ? report.workforce.insights : []; const items = insights.filter(item => item.status !== "OK").slice(0, 8); if (!items.length) return `

Существенных аномалий за выбранный период нет.

`; - return ``; } function renderTopRisks(report) { const reasons = Array.isArray(report?.ueba_risk?.reasons) ? report.ueba_risk.reasons : []; if (!reasons.length) return `

Существенных риск-сигналов нет.

`; - return `
    ${reasons.slice(0, 8).map((item, index) => ` + return `
      ${reasons.slice(0, 8).map((item, index) => `
    1. ${index + 1} - ${ui(item.label || item.code || "Риск")} + ${ui(item.label || item.code || "Риск")}${ui(item.recommendation || item.value || "")} +${escapeHtml(item.points || 0)} +
    2. `).join("")}
    `; } @@ -385,15 +438,15 @@ function renderTopRisks(report) { function renderOverviewAnalytics(report) { return `
    -
    +

    Подразделения сегодня

    ${renderDepartmentTable(report)}
    -
    +

    Аномалии

    ${renderAnomalies(report)}
    -
    +

    Топ рисков

    ${renderTopRisks(report)}
    @@ -1006,33 +1059,48 @@ function renderReports(data) { `; } -function renderSettings() { +function basename(value) { + const text = String(value || "").trim(); + if (!text) return "-"; + return text.split(/[\\/]/).filter(Boolean).pop() || text; +} + +function settingRows(report) { + const policy = report?.workforce_policy || {}; + const risk = report?.ueba_risk || {}; + const roles = Array.isArray(policy.available_roles) ? policy.available_roles : []; + const activeRole = roles.find(item => item.role === policy.default_role || item.role === policy.role) || roles[0] || {}; + const workdayHours = activeRole.planned_hours_per_day || (Number(policy.planned_seconds || 0) > 0 ? (Number(policy.planned_seconds) / 3600).toFixed(1) : 8); + const period = periodConfig(); + return [ + ["Период расчета", period.label, period.key === "today" ? "оперативный дневной срез" : `${period.days} календарных дней`], + ["Рабочий день", `${workdayHours} ч`, `роль: ${policy.role_label || activeRole.label || policy.default_role || "default"}`], + ["Порог WARN", ">= 15 баллов", "любой ненормальный риск попадает в очередь проверки"], + ["Порог FAIL", ">= 70 баллов", "высокий риск требует приоритетного разбора"], + ["Источник правил", policy.configured ? basename(policy.path) : "встроенные правила", risk.policy_configured ? `UEBA policy: ${basename(risk.policy_path)}` : "UEBA policy: встроенная модель"], + ["Дата последнего пересчета", report?.generated_at_utc || "-", `версия политики: ${risk.policy_version || "ueba-rule-v1"}`], + ]; +} + +function renderSettings(report) { + const rows = settingRows(periodReport(report || state.reports || {})); return `

    Настройки

    -

    Параметры отображения портала и границы интерпретации данных.

    +

    Read-only параметры расчета и границы интерпретации данных.

    только чтение
    -
    -
    -

    Периоды

    -
    -
    Сегоднятекущий дневной срезготово
    -
    7 днейсводка по накопленной дневной историизависит от истории
    -
    30 днеймесячная управленческая интерпретациязависит от истории
    -
    -
    -
    -

    Модули продукта

    -
    -
    Workforceактивность, рабочее время, подразделения, сотрудникиактивно
    -
    UEBAправила риска, аномалии, обычный профильv1
    -
    DLP-liteсобытия данных, материалы проверки, расследованияread-only
    -
    -
    -
    +
    +

    Параметры расчета

    + + + ${rows.map(([name, value, note]) => ` + + `).join("")} +
    ПараметрЗначениеКомментарий
    ${ui(name)}${ui(value)}${ui(note)}
    +
    `; } @@ -1089,7 +1157,10 @@ async function refresh() { updateFilters(data); } if (state.tab === "settings") { - content.innerHTML = renderSettings(); + const data = await loadJson("/reports").catch(() => state.reports || {}); + state.reports = data; + content.innerHTML = renderSettings(data); + updateFilters(data); } } @@ -1127,6 +1198,12 @@ document.addEventListener("click", event => { setTab("reports"); }); +document.addEventListener("click", event => { + const button = event.target.closest("[data-open-investigation]"); + if (!button) return; + setTab("incidents"); +}); + document.addEventListener("click", event => { const button = event.target.closest("[data-incident-action]"); if (!button) return; diff --git a/docs/PORTAL_WORKCABINET_UI_2026-06-04_RU.md b/docs/PORTAL_WORKCABINET_UI_2026-06-04_RU.md index 33e9caf..5ca14d2 100644 --- a/docs/PORTAL_WORKCABINET_UI_2026-06-04_RU.md +++ b/docs/PORTAL_WORKCABINET_UI_2026-06-04_RU.md @@ -35,10 +35,11 @@ - риск-индекс; - аномалии; - инциденты ИБ. -- Главный аналитический блок содержит 3 колонки: - - подразделения сегодня; - - аномалии; - - топ рисков. +- Главный аналитический блок содержит: + - широкую таблицу `Подразделения сегодня` с колонками: подразделение, индекс активности, отклонение, статус, ответственный; + - блок `Аномалии` с объяснением: что произошло, почему это риск, что проверить; + - блок `Топ рисков` с read-only переходом к расследованиям. +- Раздел `Настройки` работает только на чтение и показывает: период расчета, рабочий день, пороги WARN/FAIL, источник правил, дату последнего пересчета. - Добавлены отдельные промышленные представления: - экран подразделений: рейтинг групп, ответственные, просадки и отклонения; - экран сотрудников: карточка сотрудника, активность, сессии, плановое время, последнее событие; @@ -79,7 +80,7 @@ export CARGO_TARGET_DIR=/tmp/detmir-adk-rust-target node --check adk-rust/crates/detmir-portal/src/static/app.js git diff --check cargo test --manifest-path adk-rust/Cargo.toml -p detmir-portal -cargo clippy --manifest-path adk-rust/Cargo.toml -p detmir-portal --all-targets -- -D warnings +cargo clippy --manifest-path adk-rust/Cargo.toml --all-targets --all-features -- -D warnings cargo build --release --manifest-path adk-rust/Cargo.toml -p detmir-portal ``` @@ -107,3 +108,4 @@ Playwright smoke: - API-ответов с кодом 400+ нет; - раздел рисков включает темную тему. - смена периода `Сегодня` -> `Неделя` меняет содержимое страницы и показывает плашку периода. +- `scripts/detmir-portal-tabs-smoke.mjs` проверяет все `data-tab`, наличие обработчиков в `app.js`, строки read-only настроек и read-only переход из топ-рисков в расследования. diff --git a/scripts/detmir-portal-tabs-smoke.mjs b/scripts/detmir-portal-tabs-smoke.mjs new file mode 100644 index 0000000..2d11f9f --- /dev/null +++ b/scripts/detmir-portal-tabs-smoke.mjs @@ -0,0 +1,181 @@ +#!/usr/bin/env node +import { createRequire } from "node:module"; +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +const require = createRequire(import.meta.url); +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +function loadPlaywright() { + const home = process.env.HOME || ""; + const candidates = [ + "playwright", + "playwright-core", + process.env.PLAYWRIGHT_CORE_MODULE, + home ? path.join(home, ".agents/skills/playwright/node_modules/playwright-core") : "", + ].filter(Boolean); + for (const candidate of candidates) { + try { + return require(candidate); + } catch { + // Try the next local Playwright installation. + } + } + throw new Error("Playwright module not found"); +} + +function env(name, fallback = "") { + const value = process.env[name]; + return value && value.trim() ? value.trim() : fallback; +} + +function authHeaders() { + const explicit = env("DETMIR_PORTAL_SMOKE_AUTH_HEADER"); + if (explicit) return { Authorization: explicit }; + const basic = env("DETMIR_PORTAL_SMOKE_BASIC_AUTH", env("DETMIR_BASIC_AUTH")); + if (basic) return { Authorization: `Basic ${basic}` }; + return {}; +} + +function extractDataTabs(html) { + return [...html.matchAll(/data-tab=["']([^"']+)["']/g)].map((match) => match[1]); +} + +function assertStaticTabHandlers() { + const index = fs.readFileSync(path.join(root, "adk-rust/crates/detmir-portal/src/static/index.html"), "utf8"); + const app = fs.readFileSync(path.join(root, "adk-rust/crates/detmir-portal/src/static/app.js"), "utf8"); + const tabs = [...new Set(extractDataTabs(index))]; + const missing = tabs.filter((tab) => !app.includes(`state.tab === "${tab}"`)); + if (missing.length) { + throw new Error(`data-tab without app.js handler: ${missing.join(", ")}`); + } + return tabs; +} + +const expectedTabs = [ + { tab: "operator", label: "Обзор", marker: "Управленческая сводка" }, + { tab: "employees", label: "Сотрудники", marker: "Карточки сотрудников" }, + { tab: "departments", label: "Подразделения", marker: "Рейтинг подразделений" }, + { tab: "owner", label: "Риски", marker: "Контроль безопасности" }, + { tab: "incidents", label: "Расследования", marker: "Расследования и доказательная база" }, + { tab: "perimeter", label: "Сетевой периметр", marker: "Состояние доступных сигналов" }, + { tab: "reports", label: "Отчеты", marker: "Срезы отчета" }, + { tab: "settings", label: "Настройки", marker: "Параметры расчета" }, +]; + +async function main() { + const staticTabs = assertStaticTabHandlers(); + const missingExpected = expectedTabs.filter((item) => !staticTabs.includes(item.tab)); + if (missingExpected.length) { + throw new Error(`expected tabs absent in index.html: ${missingExpected.map((item) => item.tab).join(", ")}`); + } + + const { chromium } = loadPlaywright(); + const url = env("DETMIR_PORTAL_SMOKE_URL", "http://127.0.0.1:8720/portal/"); + const timeout = Number(env("DETMIR_PORTAL_SMOKE_TIMEOUT_MS", "30000")); + const browser = await chromium.launch({ + headless: true, + args: ["--no-sandbox", "--disable-dev-shm-usage"], + }); + const context = await browser.newContext({ + ignoreHTTPSErrors: true, + viewport: { width: 1440, height: 950 }, + extraHTTPHeaders: authHeaders(), + }); + const page = await context.newPage(); + const errors = []; + const badResponses = []; + page.on("pageerror", (error) => errors.push(error.message)); + page.on("console", (message) => { + if (message.type() === "error") errors.push(message.text()); + }); + page.on("response", (response) => { + if (response.status() >= 400) badResponses.push({ url: response.url(), status: response.status() }); + }); + + const results = []; + const checks = []; + try { + await page.goto(url, { waitUntil: "domcontentloaded", timeout }); + await page.waitForSelector(".tab", { timeout }); + for (const item of expectedTabs) { + await page.click(`button[data-tab="${item.tab}"]`, { timeout }); + await page.waitForFunction( + (marker) => document.body && document.body.innerText.includes(marker), + item.marker, + { timeout }, + ); + const activeTab = await page.locator(".tab.is-active").innerText({ timeout }); + const bodyText = await page.locator("#content").innerText({ timeout }); + const stillLoading = bodyText.includes("Загрузка") || bodyText.includes("Обновление периода"); + const ok = activeTab.trim() === item.label && bodyText.includes(item.marker) && !stillLoading; + results.push({ tab: item.tab, label: item.label, marker: item.marker, ok }); + if (item.tab === "settings") { + const requiredSettings = [ + "Период расчета", + "Рабочий день", + "Порог WARN", + "Порог FAIL", + "Источник правил", + "Дата последнего пересчета", + ]; + checks.push({ + name: "settings_readonly_rows", + ok: requiredSettings.every((marker) => bodyText.includes(marker)), + required: requiredSettings, + }); + } + } + await page.click('button[data-tab="operator"]', { timeout }); + await page.waitForFunction( + () => document.body && document.body.innerText.includes("Топ рисков"), + null, + { timeout }, + ); + const investigationButtons = await page.locator("[data-open-investigation]").count(); + if (investigationButtons > 0) { + await page.locator("[data-open-investigation]").first().click({ timeout }); + await page.waitForFunction( + () => document.body && document.body.innerText.includes("Расследования и доказательная база"), + null, + { timeout }, + ); + const activeTab = await page.locator(".tab.is-active").innerText({ timeout }); + checks.push({ + name: "risk_open_investigation_readonly_navigation", + ok: activeTab.trim() === "Расследования", + buttons: investigationButtons, + }); + } else { + checks.push({ + name: "risk_open_investigation_readonly_navigation", + ok: true, + buttons: 0, + skipped: "no risk rows in current report", + }); + } + } finally { + await browser.close(); + } + + const ok = results.every((item) => item.ok) && checks.every((item) => item.ok) && errors.length === 0 && badResponses.length === 0; + const result = { + ok, + generated_at_utc: new Date().toISOString(), + url, + static_tabs: staticTabs, + tabs: results, + checks, + errors, + bad_responses: badResponses, + }; + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + process.exitCode = ok ? 0 : 2; +} + +main().catch((error) => { + process.stdout.write(`${JSON.stringify({ ok: false, error: error.message }, null, 2)}\n`); + process.exitCode = 2; +});