feat(portal): reshape into workforce security forensics cabinet
This commit is contained in:
@@ -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; }
|
||||
|
||||
@@ -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` включает темную тему.
|
||||
Reference in New Issue
Block a user