feat(portal): harden industrial dashboard tabs smoke

This commit is contained in:
igor04091968
2026-06-04 08:21:53 +03:00
parent 2645082183
commit 9f5f99ae65
5 changed files with 356 additions and 46 deletions
+1 -3
View File
@@ -71,9 +71,7 @@ fn default_node_path(current: Option<&OsStr>) -> Option<OsString> {
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")
@@ -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; }
}
+111 -34
View File
@@ -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 `<p class="muted">Подразделения пока не рассчитаны.</p>`;
return `
<table class="data-table">
<thead><tr><th>Подразделение</th><th>Активность</th><th>Тренд</th><th>Риск</th></tr></thead>
<tbody>${items.map(item => `
<div class="table-scroll">
<table class="data-table department-table">
<thead><tr><th>Подразделение</th><th>Индекс активности</th><th>Отклонение</th><th>Статус</th><th>Ответственный</th></tr></thead>
<tbody>${items.map((item, index) => `
<tr>
<td><strong>${ui(item.label)}</strong></td>
<td>${ui(item.value || "-")}</td>
<td>${escapeHtml(trendArrow(item.status))}</td>
<td><span class="badge ${statusClass(item.status)}">${ui(statusRiskLabel(item.status))}</span></td>
<td>${ui(item.index_activity || firstPercent(item.value) || "-")}</td>
<td>${ui(item.deviation || departmentDeviation(item))}</td>
<td><span class="badge ${statusClass(item.status)}">${ui(item.status || "INFO")}</span></td>
<td>${ui(item.responsible || departmentResponsible(owners, index))}</td>
</tr>
`).join("")}</tbody>
</table>
</div>
`;
}
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 `<p class="muted">Существенных аномалий за выбранный период нет.</p>`;
return `<ul class="rank-list anomaly-list">${items.map(item => `
return `<ul class="rank-list anomaly-list">${items.map(item => {
const explanation = anomalyExplanation(item);
return `
<li>
<span class="badge ${statusClass(item.status)}">${ui(statusRiskLabel(item.status))}</span>
<span>${ui(item.label || item.title || "Аномалия")}</span>
<strong>${ui(item.value || item.subject || "")}</strong>
<div>
<strong>${ui(explanation.title)}</strong>
<p><b>Что произошло:</b> ${ui(explanation.evidence)}</p>
<p><b>Почему это риск:</b> ${ui(explanation.why)}</p>
<p><b>Что проверить:</b> ${ui(explanation.check)}</p>
</div>
</li>
`).join("")}</ul>`;
`; }).join("")}</ul>`;
}
function renderTopRisks(report) {
const reasons = Array.isArray(report?.ueba_risk?.reasons) ? report.ueba_risk.reasons : [];
if (!reasons.length) return `<p class="muted">Существенных риск-сигналов нет.</p>`;
return `<ol class="rank-list">${reasons.slice(0, 8).map((item, index) => `
return `<ol class="rank-list risk-list">${reasons.slice(0, 8).map((item, index) => `
<li>
<strong>${index + 1}</strong>
<span>${ui(item.label || item.code || "Риск")}</span>
<span>${ui(item.label || item.code || "Риск")}<small>${ui(item.recommendation || item.value || "")}</small></span>
<span class="badge ${statusClass(item.status || item.severity)}">+${escapeHtml(item.points || 0)}</span>
<button class="small-button" data-open-investigation="true">Открыть расследование</button>
</li>
`).join("")}</ol>`;
}
@@ -385,15 +438,15 @@ function renderTopRisks(report) {
function renderOverviewAnalytics(report) {
return `
<section class="analytics-grid">
<article class="analytics-panel">
<article class="analytics-panel department-panel">
<h3>Подразделения сегодня</h3>
${renderDepartmentTable(report)}
</article>
<article class="analytics-panel">
<article class="analytics-panel anomaly-panel">
<h3>Аномалии</h3>
${renderAnomalies(report)}
</article>
<article class="analytics-panel">
<article class="analytics-panel risk-panel">
<h3>Топ рисков</h3>
${renderTopRisks(report)}
</article>
@@ -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 `
<div class="page-head">
<div>
<h2 class="section-title">Настройки</h2>
<p class="muted">Параметры отображения портала и границы интерпретации данных.</p>
<p class="muted">Read-only параметры расчета и границы интерпретации данных.</p>
</div>
<span class="badge status-ok">только чтение</span>
</div>
<div class="grid-2">
<section class="card">
<h3>Периоды</h3>
<div class="list compact-list">
<div class="row compact-row"><strong>Сегодня</strong><span class="muted">текущий дневной срез</span><span class="badge status-ok">готово</span></div>
<div class="row compact-row"><strong>7 дней</strong><span class="muted">сводка по накопленной дневной истории</span><span class="badge status-warn">зависит от истории</span></div>
<div class="row compact-row"><strong>30 дней</strong><span class="muted">месячная управленческая интерпретация</span><span class="badge status-warn">зависит от истории</span></div>
</div>
<h3>Параметры расчета</h3>
<table class="data-table settings-table">
<thead><tr><th>Параметр</th><th>Значение</th><th>Комментарий</th></tr></thead>
<tbody>${rows.map(([name, value, note]) => `
<tr><td><strong>${ui(name)}</strong></td><td>${ui(value)}</td><td>${ui(note)}</td></tr>
`).join("")}</tbody>
</table>
</section>
<section class="card">
<h3>Модули продукта</h3>
<div class="list compact-list">
<div class="row compact-row"><strong>Workforce</strong><span class="muted">активность, рабочее время, подразделения, сотрудники</span><span class="badge status-ok">активно</span></div>
<div class="row compact-row"><strong>UEBA</strong><span class="muted">правила риска, аномалии, обычный профиль</span><span class="badge status-warn">v1</span></div>
<div class="row compact-row"><strong>DLP-lite</strong><span class="muted">события данных, материалы проверки, расследования</span><span class="badge status-warn">read-only</span></div>
</div>
</section>
</div>
`;
}
@@ -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;
+7 -5
View File
@@ -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 переход из топ-рисков в расследования.
+181
View File
@@ -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;
});