feat(portal): reshape into workforce security forensics cabinet

This commit is contained in:
igor04091968
2026-06-04 06:57:02 +03:00
parent 1e869e1a14
commit 8b621a62d6
4 changed files with 493 additions and 40 deletions
@@ -14,6 +14,8 @@
--unknown: #59636e;
--unknown-bg: #e9edf1;
--link: #075985;
--accent: #234b62;
--soft: #f8fafc;
}
* { box-sizing: border-box; }
@@ -25,8 +27,34 @@ body {
font-family: Inter, "Segoe UI", Arial, sans-serif;
}
body.security-mode {
--bg: #111827;
--panel: #172026;
--ink: #e5e7eb;
--muted: #aab4bf;
--line: #334155;
--unknown: #cbd5e1;
--unknown-bg: #334155;
--link: #7dd3fc;
--accent: #38bdf8;
--soft: #1f2937;
}
body.security-mode .small-button,
body.security-mode a.button,
body.security-mode .tab,
body.security-mode .control-strip select {
background: #111827;
color: var(--ink);
}
body.security-mode .tab.is-active {
background: #0f2a3a;
color: var(--link);
}
.shell {
max-width: 1280px;
max-width: 1440px;
margin: 0 auto;
padding: 22px;
}
@@ -39,6 +67,10 @@ body {
margin-bottom: 16px;
}
.brand {
min-width: 280px;
}
.eyebrow {
margin: 0 0 4px;
color: var(--muted);
@@ -53,6 +85,47 @@ h1 {
line-height: 1.1;
}
.lead {
margin: 8px 0 0;
color: var(--muted);
font-size: 15px;
}
.topbar-side {
display: grid;
justify-items: end;
gap: 10px;
min-width: min(100%, 620px);
}
.control-strip,
.header-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
.control-strip label {
display: grid;
gap: 4px;
min-width: 150px;
color: var(--muted);
font-size: 12px;
font-weight: 700;
}
.control-strip select {
min-height: 36px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
color: var(--ink);
padding: 6px 10px;
font: inherit;
font-size: 13px;
}
.tabs {
display: flex;
flex-wrap: wrap;
@@ -74,6 +147,7 @@ h1 {
.tab.is-active {
border-color: var(--link);
color: var(--link);
background: #eef7fb;
}
.summary-grid {
@@ -99,6 +173,14 @@ h1 {
padding: 16px;
}
.page-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 14px;
margin-bottom: 14px;
}
.card h3,
.section-title {
margin: 0 0 8px;
@@ -213,6 +295,79 @@ h1 {
line-height: 1.1;
}
.executive-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
gap: 10px;
margin-bottom: 14px;
}
.metric-card {
min-height: 132px;
padding: 14px;
border: 1px solid var(--line);
border-left: 4px solid var(--accent);
border-radius: 8px;
background: var(--panel);
}
.metric-card h3,
.report-type h3 {
margin: 10px 0 6px;
font-size: 15px;
}
.metric-value {
margin: 8px 0 4px;
font-size: 26px;
font-weight: 800;
line-height: 1.1;
}
.dashboard-band {
margin-top: 12px;
padding: 14px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
}
.security-band {
border-left: 4px solid #7c2d12;
}
.technical-band {
background: var(--soft);
}
.band-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
margin-bottom: 10px;
}
.band-head h3 {
margin: 0;
font-size: 16px;
}
.report-type-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
gap: 10px;
margin-bottom: 14px;
}
.report-type {
min-height: 118px;
padding: 14px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--soft);
}
.report-section {
min-height: 180px;
}
@@ -380,7 +535,13 @@ pre {
@media (max-width: 640px) {
.shell { padding: 14px; }
.topbar { flex-direction: column; }
.topbar-side { justify-items: stretch; min-width: 0; width: 100%; }
.control-strip,
.header-actions { justify-content: flex-start; }
.control-strip label { min-width: 100%; }
h1 { font-size: 24px; }
.page-head { flex-direction: column; }
.band-head { flex-direction: column; align-items: flex-start; }
.row { grid-template-columns: 1fr; }
.incident-row { grid-template-columns: 1fr; }
.evidence-row { grid-template-columns: 1fr; }
+214 -30
View File
@@ -1,4 +1,4 @@
const state = { tab: "operator", links: null, readiness: null };
const state = { tab: "operator", links: null, readiness: null, reports: null };
function apiBase() {
const path = window.location.pathname;
@@ -40,7 +40,7 @@ function escapeHtml(value) {
function renderSummary(summary, readiness) {
const global = document.getElementById("globalStatus");
global.className = `status-pill ${statusClass(summary.severity)}`;
global.textContent = `${summary.severity} · operator ${summary.operator_ok ? "OK" : "NO"}`;
global.textContent = `Сенсоры ${summary.operator_ok ? "OK" : "NO"} · ${summary.severity}`;
const blocks = Object.entries(summary.blocks || {});
const readinessCard = renderReadinessSummaryCard(readiness);
document.getElementById("summary").innerHTML = readinessCard + blocks.map(([name, block]) => `
@@ -96,10 +96,102 @@ function label(name) {
grafana: "Grafana",
dlp: "DLP",
worktime: "Работа сегодня",
one_c: "1С"
one_c: "1С",
work: "Workforce",
security: "Security"
}[name] || name;
}
function findKpi(report, needle) {
const text = String(needle || "").toLowerCase();
return (report?.kpis || []).find(item => String(item.label || "").toLowerCase().includes(text));
}
function findSection(report, title) {
const text = String(title || "").toLowerCase();
return (report?.sections || []).find(item => String(item.title || "").toLowerCase().includes(text));
}
function metricCard(labelText, value, status, context) {
return `
<article class="metric-card">
<span class="badge ${statusClass(status)}">${escapeHtml(status || "INFO")}</span>
<h3>${escapeHtml(labelText)}</h3>
<p class="metric-value">${escapeHtml(value ?? "Нет данных")}</p>
<p class="muted">${escapeHtml(context || "")}</p>
</article>
`;
}
function renderExecutiveMetrics(report, incidents) {
const activity = findKpi(report, "Индекс активности");
const employees = findKpi(report, "Сотрудники");
const risk = findKpi(report, "UEBA риск");
const open = findKpi(report, "Открытые вопросы");
const departments = findKpi(report, "Подразделения");
const evidence = findKpi(report, "Evidence");
const openCount = Array.isArray(incidents) ? incidents.filter(item => !item.acknowledged).length : 0;
return `
<section class="executive-grid" aria-label="Управленческая сводка">
${metricCard("Индекс активности за день", activity?.value, activity?.status, activity?.context || "proxy: активное время / плановое время")}
${metricCard("Активные сотрудники", employees?.value, employees?.status, employees?.context)}
${metricCard("Отклонения от нормы", open?.value ?? openCount, open?.status || incidentStatusFromCount(openCount), "вопросы, требующие реакции")}
${metricCard("Подразделения с просадкой", departments?.value, departments?.status, departments?.context || "сравнение текущего дня")}
${metricCard("Риски ИБ", risk?.value, risk?.status, risk?.context || "rule-based UEBA v1")}
${metricCard("Новые инциденты", open?.value ?? openCount, open?.status || incidentStatusFromCount(openCount), "не взятые в работу")}
${metricCard("Готовность отчета", reportReadinessText(report), report?.operator_ok ? "OK" : report?.severity, "daily / weekly / monthly")}
${metricCard("Доказательная база", evidence?.value, evidence?.status, evidence?.context)}
</section>
`;
}
function incidentStatusFromCount(count) {
const n = Number(count || 0);
if (n === 0) return "OK";
if (n <= 3) return "WARN";
return "FAIL";
}
function reportReadinessText(report) {
const trend = report?.workforce?.trend_status || "daily_only";
if (trend === "monthly_ready") return "daily / weekly / monthly";
if (trend === "weekly_ready") return "daily / weekly";
return "daily";
}
function renderSectionItems(section, emptyText) {
const items = Array.isArray(section?.items) ? section.items : [];
if (items.length === 0) {
return `<p class="muted">${escapeHtml(emptyText || "Данных для среза пока нет.")}</p>`;
}
return `<div class="list compact-list">${items.slice(0, 12).map(item => `
<div class="row compact-row">
<strong>${escapeHtml(item.label || "-")}</strong>
<span class="muted">${escapeHtml(item.value || "")}</span>
<span class="badge ${statusClass(item.status)}">${escapeHtml(item.status || "INFO")}</span>
</div>
`).join("")}</div>`;
}
function renderReportTypeCards() {
const types = [
["Ежедневный отчет", "Оперативный срез за сегодня", "OK"],
["Недельный отчет", "Динамика после накопления daily history", "INFO"],
["Месячный отчет", "Готов для управленческой аналитики после накопления истории", "INFO"],
["По подразделению", "Сравнение загрузки и просадок", "OK"],
["По сотруднику", "Drill-down с осторожной трактовкой KPI", "WARN"],
["По инциденту", "Материалы для служебной проверки", "OK"],
["Акт пилота", "Итоги опытной эксплуатации", "OK"]
];
return `<div class="report-type-grid">${types.map(([title, text, status]) => `
<article class="report-type">
<span class="badge ${statusClass(status)}">${escapeHtml(status)}</span>
<h3>${escapeHtml(title)}</h3>
<p class="muted">${escapeHtml(text)}</p>
</article>
`).join("")}</div>`;
}
function renderLinks(links) {
const link = (text, href) => `<a class="button" href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer">${escapeHtml(text)}</a>`;
return `<div class="links">
@@ -138,38 +230,62 @@ function renderSourceList(data) {
`).join("")}</div>`;
}
function renderOperator(data) {
function renderOperator(data, report) {
const incidents = Array.isArray(data.incidents) ? data.incidents : [];
const workforce = findSection(report, "Работа");
const insights = findSection(report, "Выводы Workforce");
const security = findSection(report, "ИБ");
const actions = findSection(report, "Действия");
return `
<h2 class="section-title">Оператор</h2>
<div class="grid-2">
<section class="card">
<h3>Контур</h3>
${renderSourceList(data)}
</section>
<section class="card">
<h3>Быстрые переходы</h3>
${renderLinks(data.links)}
</section>
<div class="page-head">
<div>
<h2 class="section-title">Управленческая сводка</h2>
<p class="muted">За 10 секунд: работают ли люди, где просадка, где риск и что требует внимания.</p>
</div>
<span class="badge ${statusClass(report?.severity)}">${escapeHtml(report?.headline || "Оперативный срез")}</span>
</div>
<h3 class="section-title">Проблемы</h3>
${renderIncidentsList(data.incidents)}
${renderExecutiveMetrics(report, incidents)}
<section class="dashboard-band">
<div class="band-head"><h3>Рабочая активность сотрудников</h3><span class="muted">загрузка, простои, перегруз и дисциплина процессов</span></div>
${renderSectionItems(workforce, "Workforce-срез пока не сформирован.")}
${renderSectionItems(insights, "Отклонений Workforce пока не найдено.")}
</section>
<section class="dashboard-band security-band">
<div class="band-head"><h3>Контроль безопасности</h3><span class="muted">подозрительные события и приоритет реакции</span></div>
${renderSectionItems(security, "ИБ-событий для реакции пока нет.")}
</section>
<section class="dashboard-band">
<div class="band-head"><h3>Что требует внимания</h3><span class="muted">операторские действия и открытые вопросы</span></div>
${renderIncidentsList(incidents)}
${renderSectionItems(actions, "Рекомендаций нет.")}
</section>
<section class="dashboard-band technical-band">
<div class="band-head"><h3>Технический статус источников</h3><span class="muted">вторичный слой для оператора</span></div>
${renderSourceList(data)}
</section>
`;
}
function renderManager(data, policyExplain) {
const workforceIndex = workforceIndexText(data.users_count, data.total_active_seconds);
return `
<h2 class="section-title">Руководитель</h2>
<div class="page-head">
<div>
<h2 class="section-title">Рабочая активность сотрудников</h2>
<p class="muted">Оценка загрузки, простоев, перегруза и использования рабочих приложений.</p>
</div>
<span class="badge ${statusClass(data.status?.status)}">${escapeHtml(data.status?.status || "INFO")}</span>
</div>
<div class="grid-2">
<section class="card">
<h3>Работа сегодня</h3>
<h3>Индекс активности</h3>
<p class="kpi-value">${escapeHtml(workforceIndex)}</p>
<p class="muted">proxy: активное время / плановое рабочее время</p>
<p class="muted">Сотрудников: ${data.users_count}; активных часов: ${Number(data.total_active_hours || 0).toFixed(1)}</p>
<p class="muted">${escapeHtml(data.status?.text || "")}</p>
</section>
<section class="card">
<h3>Приложения</h3>
<h3>RDP / 1C / рабочие приложения</h3>
<div class="list">${(data.applications || []).slice(0, 8).map(app => `
<div class="row">
<strong>${escapeHtml(app.application)}</strong>
@@ -180,7 +296,7 @@ function renderManager(data, policyExplain) {
</section>
</div>
${renderWorkforceIndexExplanation(policyExplain)}
<h3 class="section-title">Сотрудники</h3>
<h3 class="section-title">Сотрудники без активности и с аномалиями</h3>
<div class="list">${(data.users || []).map(user => `
<div class="row">
<strong>${escapeHtml(user.user)}</strong>
@@ -313,7 +429,13 @@ function workforceIndexStatus(value) {
function renderOwner(data) {
const cards = Object.entries(data.cards || {});
return `
<h2 class="section-title">Владелец</h2>
<div class="page-head">
<div>
<h2 class="section-title">Контроль безопасности</h2>
<p class="muted">Приоритеты ИБ: подозрительные подключения, DLP-сигналы, RDP-аномалии и состояние периметра.</p>
</div>
<span class="badge ${statusClass(data.summary?.severity)}">${escapeHtml(data.summary?.severity || "INFO")}</span>
</div>
<div class="summary-grid">${cards.map(([name, block]) => `
<article class="card">
<span class="badge ${statusClass(block.status)}">${escapeHtml(block.status)}</span>
@@ -322,11 +444,11 @@ function renderOwner(data) {
</article>
`).join("")}</div>
<section class="card">
<h3>Что сделать</h3>
<h3>Риски с приоритетом</h3>
<div class="list">${(data.recommendations || []).map(item => `<div class="row"><strong>Рекомендация</strong><span class="muted">${escapeHtml(item)}</span><span></span></div>`).join("")}</div>
</section>
<section class="card">
<h3>Переходы</h3>
<h3>Дашборды безопасности</h3>
${renderLinks(data.links)}
</section>
`;
@@ -393,19 +515,25 @@ function renderIncidents(data) {
const incidents = Array.isArray(data) ? data : data.incidents;
const evidence = Array.isArray(data) ? null : data.evidence;
return `
<h2 class="section-title">Инциденты ИБ</h2>
<div class="page-head">
<div>
<h2 class="section-title">Расследования и доказательная база</h2>
<p class="muted">Кто, когда, откуда, куда, связанные хосты, evidence и выгрузка материалов.</p>
</div>
<span class="badge ${statusClass(incidentStatusFromCount((incidents || []).length))}">${escapeHtml((incidents || []).length)} items</span>
</div>
<div class="grid-2">
<section class="card">
<h3>DLP-инциденты</h3>
<h3>Карточки инцидентов</h3>
${renderDlpIncidentsList(incidents)}
</section>
<section class="card">
<h3>Графики и дашборды</h3>
<h3>Связанные графики и дашборды</h3>
${renderDlpLinks(links)}
</section>
</div>
<section class="card evidence-card">
<h3>Доказательства</h3>
<h3>Evidence: скриншоты, хеши, файлы</h3>
${renderDlpEvidence(evidence)}
</section>
`;
@@ -468,7 +596,14 @@ function renderUebaRisk(risk) {
function renderReports(data) {
return `
<h2 class="section-title">Отчеты</h2>
<div class="page-head">
<div>
<h2 class="section-title">Отчеты</h2>
<p class="muted">Ежедневные, недельные, месячные, по подразделению, сотруднику, инциденту и пилотной эксплуатации.</p>
</div>
<span class="badge ${statusClass(data.severity)}">${escapeHtml(data.severity)}</span>
</div>
${renderReportTypeCards()}
<div class="grid-2">
<section class="card report-hero">
<span class="badge ${statusClass(data.severity)}">${escapeHtml(data.severity)}</span>
@@ -485,7 +620,9 @@ function renderReports(data) {
</div>
<div class="report-actions">
<button class="small-button" data-anonymize-report="true">Демо без имен</button>
<button class="small-button" data-export-markdown="true">Markdown</button>
<button class="small-button" data-print-report="true">Печать / PDF</button>
<a class="small-button" href="${apiBase()}/reports" target="_blank" rel="noopener noreferrer">JSON</a>
</div>
<h3 class="section-title">Ключевые показатели</h3>
${renderKpiCards(data.kpis)}
@@ -510,7 +647,11 @@ async function refresh() {
renderSummary(summary, state.readiness);
const content = document.getElementById("content");
const data = await loadJson(`/${state.tab}`);
if (state.tab === "operator") content.innerHTML = renderOperator(data);
if (state.tab === "operator") {
state.reports = await loadJson("/reports").catch(() => state.reports);
content.innerHTML = renderOperator(data, state.reports);
updateFilters(state.reports);
}
if (state.tab === "manager") {
const policyExplain = await loadJson("/workforce/policy/explain").catch(() => null);
content.innerHTML = renderManager(data, policyExplain);
@@ -520,11 +661,16 @@ async function refresh() {
const evidence = await loadJson("/dlp/evidence").catch(error => ({ ok: false, error: error.message, items: [] }));
content.innerHTML = renderIncidents({ incidents: data, evidence });
}
if (state.tab === "reports") content.innerHTML = renderReports(data);
if (state.tab === "reports") {
state.reports = data;
content.innerHTML = renderReports(data);
updateFilters(data);
}
}
function setTab(tab) {
state.tab = tab;
applySecurityMode(tab);
document.querySelectorAll(".tab").forEach(btn => {
btn.classList.toggle("is-active", btn.dataset.tab === tab);
});
@@ -532,6 +678,10 @@ function setTab(tab) {
refresh().catch(showError);
}
function applySecurityMode(tab) {
document.body.classList.toggle("security-mode", tab === "owner" || tab === "incidents");
}
function showError(error) {
document.getElementById("content").innerHTML = `<pre>${escapeHtml(error.stack || error.message || error)}</pre>`;
}
@@ -558,6 +708,12 @@ document.addEventListener("click", event => {
anonymizeReport(button).catch(showError);
});
document.addEventListener("click", event => {
const button = event.target.closest("[data-export-markdown]");
if (!button) return;
exportMarkdown().catch(showError);
});
document.addEventListener("click", event => {
const button = event.target.closest("[data-readiness-verify]");
if (!button) return;
@@ -586,6 +742,33 @@ async function anonymizeReport(button) {
document.getElementById("content").innerHTML = renderReports(data);
}
async function exportMarkdown() {
const data = state.reports || await loadJson("/reports");
const markdown = data.markdown || "";
const blob = new Blob([markdown], { type: "text/markdown;charset=utf-8" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "awatch-rus-report.md";
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
function updateFilters(report) {
updateSelect("departmentFilter", (report?.workforce?.department_comparison || []).map(item => item.label));
updateSelect("ownerFilter", (report?.workforce?.owner_comparison || []).map(item => item.label));
}
function updateSelect(id, values) {
const select = document.getElementById(id);
if (!select || select.dataset.loaded === "true") return;
const unique = [...new Set((values || []).filter(Boolean))].slice(0, 50);
select.innerHTML = `<option>Все</option>` + unique.map(item => `<option>${escapeHtml(item)}</option>`).join("");
select.dataset.loaded = "true";
}
async function incidentAction(button) {
const id = button.dataset.incidentId;
const action = button.dataset.incidentAction;
@@ -608,5 +791,6 @@ async function incidentAction(button) {
await refresh();
}
applySecurityMode(state.tab);
refresh().catch(showError);
setInterval(() => refresh().catch(showError), 60000);
@@ -3,24 +3,53 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>DetMir Portal</title>
<title>AWatch-rus Portal</title>
<link rel="stylesheet" href="app.css">
</head>
<body>
<main class="shell">
<header class="topbar">
<div>
<p class="eyebrow">DetMir Portal</p>
<h1>Контур компании</h1>
<div class="brand">
<p class="eyebrow">AWatch-rus</p>
<h1>Workforce / Security / Forensics</h1>
<p class="lead">Рабочий кабинет руководителя, ИБ и расследователя</p>
</div>
<div class="topbar-side" aria-label="Параметры отчета">
<div class="control-strip">
<label>
<span>Период</span>
<select id="periodFilter">
<option>Сегодня</option>
<option>Неделя</option>
<option>Месяц</option>
</select>
</label>
<label>
<span>Подразделение</span>
<select id="departmentFilter">
<option>Все</option>
</select>
</label>
<label>
<span>Ответственный</span>
<select id="ownerFilter">
<option>Все</option>
</select>
</label>
</div>
<div class="header-actions">
<button class="small-button" data-export-markdown="true">Markdown</button>
<button class="small-button" data-print-report="true">PDF</button>
<div id="globalStatus" class="status-pill status-unknown">Загрузка</div>
</div>
</div>
<div id="globalStatus" class="status-pill status-unknown">Загрузка</div>
</header>
<nav class="tabs" aria-label="Разделы портала">
<button class="tab is-active" data-tab="operator">Оператор</button>
<button class="tab" data-tab="manager">Руководитель</button>
<button class="tab" data-tab="owner">Владелец</button>
<button class="tab" data-tab="incidents">Инциденты ИБ</button>
<button class="tab is-active" data-tab="operator">Сводка</button>
<button class="tab" data-tab="manager">Workforce</button>
<button class="tab" data-tab="owner">Security</button>
<button class="tab" data-tab="incidents">Forensics</button>
<button class="tab" data-tab="reports">Отчеты</button>
</nav>
@@ -0,0 +1,79 @@
# AWatch-rus Portal: рабочий кабинет
Дата: 2026-06-04
## Цель изменения
Портал переведен из технической "админки логов" в рабочий кабинет для трех ролей:
- руководитель: управленческая сводка, Workforce KPI, отклонения, отчеты;
- ИБ: риск-срез, DLP/evidence, состояние контрольных сигналов;
- расследователь: карточки инцидентов, evidence, хеши и выгрузка материалов.
## Что изменено
- Верхняя панель переименована в `AWatch-rus Workforce / Security / Forensics`.
- Добавлены элементы отчета: период, подразделение, ответственный, экспорт Markdown/PDF, статус сенсоров.
- Навигация приведена к продуктовой модели:
- `Сводка`;
- `Workforce`;
- `Security`;
- `Forensics`;
- `Отчеты`.
- Первый экран показывает executive summary:
- индекс активности за день;
- активные сотрудники;
- отклонения от нормы;
- подразделения с просадкой;
- риски ИБ;
- новые инциденты;
- готовность отчета;
- доказательная база.
- Workforce-блок показывает загрузку, простои, перегруз, приложения, объяснение индекса и policy audit.
- Security-блок использует темную тему и выводит риск/ИБ-срез без автоматического воздействия на сеть.
- Forensics-блок показывает инциденты, evidence, скриншоты, хеши и ссылки на выгрузку.
- Отчеты дополнены типами: ежедневный, недельный, месячный, по подразделению, по сотруднику, по инциденту, акт пилота.
## Важные ограничения
- Фильтры периода, подразделения и ответственного пока являются UI-контролами. Backend-фильтрация должна добавляться отдельным изменением, чтобы не искажать текущие данные.
- `Индекс активности` остается proxy-метрикой: активное время / плановое время. Weighted KPI объясняется отдельно через role/application policy.
- Security/Forensics показывают риск-срез и evidence. Это не заявляется как сертифицированная СЗИ.
- pfSense и сетевой периметр этим изменением не затрагивались.
## Проверки
Локальные проверки:
```bash
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 build --release --manifest-path adk-rust/Cargo.toml -p detmir-portal
```
Боевые проверки после деплоя:
- `detmir-portal.service`: active;
- `nginx`: active;
- `detmir-portal-evidence.service`: active;
- `/portal/`: HTTP 200;
- `/portal/api/summary`: HTTP 200;
- `/portal/api/operator`: HTTP 200;
- `/portal/api/manager`: HTTP 200;
- `/portal/api/owner`: HTTP 200;
- `/portal/api/incidents`: HTTP 200;
- `/portal/api/reports`: HTTP 200;
- `/portal/api/readiness/bundle`: HTTP 200;
- `/portal/api/workforce/policy/explain`: HTTP 200;
- `/portal/api/dlp/evidence`: HTTP 200.
Playwright smoke:
- заголовок страницы: `AWatch-rus Portal`;
- вкладки `Сводка`, `Workforce`, `Security`, `Forensics`, `Отчеты` открываются;
- JS-ошибок нет;
- API-ответов с кодом 400+ нет;
- `Security` включает темную тему.