From 10f0d15407a1222faa6d708cc73dcd4dba7f43b4 Mon Sep 17 00:00:00 2001 From: igor04091968 Date: Thu, 4 Jun 2026 20:32:27 +0300 Subject: [PATCH] feat(portal): add role views --- .../crates/detmir-portal/src/static/app.css | 52 +++- .../crates/detmir-portal/src/static/app.js | 272 ++++++++++++++++-- .../detmir-portal/src/static/index.html | 6 + docs/PORTAL_RU.md | 15 + scripts/detmir-portal-tabs-smoke.mjs | 98 +++++-- 5 files changed, 387 insertions(+), 56 deletions(-) diff --git a/adk-rust/crates/detmir-portal/src/static/app.css b/adk-rust/crates/detmir-portal/src/static/app.css index 94f9f44..c2a0468 100644 --- a/adk-rust/crates/detmir-portal/src/static/app.css +++ b/adk-rust/crates/detmir-portal/src/static/app.css @@ -142,13 +142,58 @@ h1 { } .control-strip, -.header-actions { +.header-actions, +.view-switch { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; } +.view-switch { + align-items: center; + padding: 4px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); +} + +.view-switch > span { + padding: 0 6px; + color: var(--muted); + font-size: 12px; + font-weight: 700; +} + +.view-button { + min-height: 30px; + border: 1px solid transparent; + border-radius: 6px; + background: transparent; + color: var(--muted); + padding: 5px 9px; + font: inherit; + font-size: 13px; + font-weight: 700; + cursor: pointer; +} + +.view-button.is-active { + border-color: #1d4ed8; + background: #2563eb; + color: #ffffff; +} + +.view-button:hover { + color: var(--ink); + background: rgba(37, 99, 235, 0.08); +} + +.view-button.is-active:hover { + color: #ffffff; + background: #1d4ed8; +} + .control-strip label { display: grid; gap: 4px; @@ -1114,8 +1159,11 @@ pre { .topbar { flex-direction: column; } .topbar-side { justify-items: stretch; min-width: 0; width: 100%; } .control-strip, - .header-actions { justify-content: flex-start; } + .header-actions, + .view-switch { justify-content: flex-start; } .control-strip label { min-width: 100%; } + .view-switch > span { flex-basis: 100%; } + .view-button { flex: 1 1 120px; } h1 { font-size: 24px; } .page-head { flex-direction: column; } .period-banner { flex-direction: column; align-items: 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 b638f76..c57fdb3 100644 --- a/adk-rust/crates/detmir-portal/src/static/app.js +++ b/adk-rust/crates/detmir-portal/src/static/app.js @@ -1,9 +1,11 @@ const state = { tab: "operator", + viewMode: initialViewMode(), period: "today", links: null, readiness: null, reports: null, + cases: null, pendingScrollSelector: null, load: { status: "LOADING", @@ -14,6 +16,39 @@ const state = { }, }; +const VIEW_MODES = { + executive: { + label: "Руководитель", + title: "Роль руководителя", + heading: "Представление руководителя", + description: "Главный вывод, сводка руководителя, риски подразделений и карта рисков.", + stage: "Загрузка представления руководителя", + }, + security: { + label: "Безопасность", + title: "Роль безопасности", + heading: "Представление безопасности", + description: "Очередь проверки, расследования, аудит решений и пакеты расследований.", + stage: "Загрузка представления безопасности", + }, + operations: { + label: "Эксплуатация", + title: "Роль эксплуатации", + heading: "Представление эксплуатации", + description: "Полнота данных, качество сбора, ошибки и телеметрия рабочих мест.", + stage: "Загрузка представления эксплуатации", + }, +}; + +function initialViewMode() { + try { + const stored = window.localStorage?.getItem("detmir.portal.viewMode"); + return ["executive", "security", "operations"].includes(stored) ? stored : "executive"; + } catch { + return "executive"; + } +} + function apiBase() { const path = window.location.pathname; return path.startsWith("/portal") ? "/portal/api" : "/api"; @@ -142,6 +177,36 @@ function tooltip(value) { return `title="${ui(value)}"`; } +function currentViewMode() { + return VIEW_MODES[state.viewMode] ? state.viewMode : "executive"; +} + +function currentViewMeta() { + return VIEW_MODES[currentViewMode()]; +} + +function updateViewModeButtons() { + document.querySelectorAll("[data-view-mode]").forEach(button => { + button.classList.toggle("is-active", button.dataset.viewMode === currentViewMode()); + }); +} + +function setViewMode(mode) { + if (!VIEW_MODES[mode]) return; + state.viewMode = mode; + try { + window.localStorage?.setItem("detmir.portal.viewMode", mode); + } catch { + // View mode is a UI preference only; ignore storage failures. + } + updateViewModeButtons(); + if (state.tab !== "operator") { + setTab("operator"); + return; + } + refresh({ stage: VIEW_MODES[mode].stage }); +} + function renderSummary(summary, readiness) { const global = document.getElementById("globalStatus"); global.className = `status-pill ${statusClass(summary.severity)}`; @@ -945,7 +1010,7 @@ function renderDepartmentLeaderCard(report) { `; } -function renderOverviewAnalytics(report) { +function renderOperatorDetailBands(report) { return ` ${renderDepartmentRanking(report)} ${renderAttentionBlock(report)} @@ -1053,35 +1118,189 @@ function renderSourceList(data) { `).join("")}`; } -function renderOperator(data, report) { +function renderOperator(data, report, extras = {}) { report = periodReport(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, "Действия"); + const meta = currentViewMeta(); return `
-

Управленческая сводка

-

За 10 секунд: работают ли люди, где просадка, где риск и что требует внимания.

+

${ui(meta.heading)}

+

${ui(meta.description)}

- ${escapeHtml(report?.headline || "Оперативный срез")} + ${ui(meta.label)}
${renderPeriodBanner(report)} + ${renderRoleViewSummary(meta)} + ${renderOperatorRoleContent(currentViewMode(), data, report, { ...extras, incidents })} + `; +} + +function renderRoleViewSummary(meta) { + return ` +
+
+
+

${ui(meta.heading)}

+

${ui(meta.description)}

+
+ ${ui(meta.label)} +
+
+ `; +} + +function renderOperatorRoleContent(mode, data, report, extras = {}) { + if (mode === "security") return renderSecurityView(data, report, extras); + if (mode === "operations") return renderOperationsView(data, report); + return renderExecutiveView(report, extras.incidents || []); +} + +function renderExecutiveView(report, incidents) { + return ` ${renderRiskNarrative(report)} ${renderExecutiveDashboard(report)} - ${renderExecutiveMetrics(report, incidents)} - ${renderAgentQuality(report?.agent_quality, report?.agent_quality_explain)} - ${renderAgentQualityHistory(report?.agent_quality_history, report?.agent_quality_history_summary)} - ${renderAgentQualityNodes(report?.agent_quality_nodes, report?.agent_quality_nodes_summary)} - ${renderAgentCoverageSla(report?.agent_coverage_sla)} - ${renderRiskHeatmap(report?.risk_heatmap)} - ${renderSecurityCorrelation(report?.security_correlation)} ${renderBusinessRisk(report?.business_risk)} - ${renderBusinessRiskTimeline(report?.business_risk_history, report?.business_risk_history_summary)} - ${renderRiskIncidentCandidates(report?.risk_incident_candidates)} + ${renderRiskHeatmap(report?.risk_heatmap)} ${renderOverviewAnalytics(report)} + `; +} + +function renderSecurityView(data, report, extras = {}) { + const cases = Array.isArray(extras.cases?.cases) ? extras.cases.cases : []; + return ` + ${renderRiskIncidentCandidates(report?.risk_incident_candidates)} + ${renderCases(cases)} + ${renderIncidentReviewAuditSummary(report)} + ${renderInvestigationPacks(report?.risk_incident_candidates)} +
+

Расследования

ручная проверка, решения и материалы
+ ${renderIncidentsList(extras.incidents || data.incidents || [])} +
+ `; +} + +function renderOperationsView(data, report) { + return ` + ${renderAgentCoverageSla(report?.agent_coverage_sla)} + ${renderAgentQuality(report?.agent_quality, report?.agent_quality_explain)} + ${renderOperationsErrors(data, report)} +
+

Телеметрия

свежесть, стабильность и источники данных
+ ${renderAgentQualityHistory(report?.agent_quality_history, report?.agent_quality_history_summary)} + ${renderAgentQualityNodes(report?.agent_quality_nodes, report?.agent_quality_nodes_summary)} + ${renderSourceList(data)} +
+ `; +} + +function renderOperationsErrors(data, report) { + const rows = []; + const collectorError = report?.agent_quality?.collector_error; + if (collectorError) { + rows.push(["Ошибка коллектора", collectorError, report?.agent_quality?.quality_status || "DEGRADED"]); + } + const problemNodes = Array.isArray(report?.agent_coverage_sla?.problem_nodes) + ? report.agent_coverage_sla.problem_nodes + : []; + for (const node of problemNodes.slice(0, 8)) { + rows.push([ + node.hostname || "unknown", + `${node.status || "UNKNOWN"} · ${node.recommendation || "Проверить рабочее место"}`, + node.status || "WARNING", + ]); + } + for (const [name, source] of Object.entries(data || {})) { + if (!source || typeof source !== "object") continue; + const status = source.status || (source.ok === false ? "FAIL" : ""); + if (!status || ["OK", "READY", "INFO"].includes(String(status).toUpperCase())) continue; + rows.push([label(name), source.summary || source.error || "требуется проверка", status]); + } + return ` +
+
+
+

Ошибки

+

Что мешает достоверному сбору и эксплуатационной готовности.

+
+ ${rows.length} +
+
${rows.length ? rows.map(([name, text, status]) => ` +
+ ${ui(name)} + ${ui(text)} + ${ui(status)} +
+ `).join("") : ` +
+ Критичных ошибок нет + Портал не видит ошибок коллектора или проблемных рабочих мест в текущем срезе. + OK +
+ `}
+
+ `; +} + +function renderIncidentReviewAuditSummary(report) { + const summary = report?.incident_review_audit_summary || {}; + const rows = [ + ["Всего изменений", summary.total_changes ?? 0], + ["Подтверждено", summary.confirmed_count ?? 0], + ["Ложные срабатывания", summary.false_positive_count ?? 0], + ["Отложено", summary.postponed_count ?? 0], + ["Последнее изменение", summary.last_change_utc || "-"], + ]; + return ` +
+
+
+

Аудит

+

Кто и когда менял статус записей, требующих проверки.

+
+ ${ui(summary.total_changes ?? 0)} +
+
${rows.map(([name, value]) => ` +
${ui(name)}${ui(value)}
+ `).join("")}
+
+ `; +} + +function renderInvestigationPacks(candidates) { + const rows = Array.isArray(candidates) ? candidates.slice(0, 10) : []; + return ` +
+
+
+

Пакеты расследований

+

Выгружаемые материалы по каждой записи для ручной проверки.

+
+ ${rows.length} +
+
${rows.length ? rows.map(item => ` +
+ ${ui(item.department || "Без подразделения")} + ${ui(item.reason || "требуется проверка")} · ${ui(item.hostname || "-")} + Скачать +
+ `).join("") : ` +
+ Пакетов нет + Нет записей, требующих выгрузки материалов. + OK +
+ `}
+
+ `; +} + +function renderOverviewAnalytics(report) { + const workforce = findSection(report, "Работа"); + const insights = findSection(report, "Выводы по активности") || findSection(report, "Выводы Workforce"); + const security = findSection(report, "ИБ"); + const actions = findSection(report, "Действия"); + return `

Рабочая активность сотрудников

загрузка, простои, перегруз и дисциплина процессов
${renderSectionItems(workforce, "Срез по работе сотрудников пока не сформирован.")} @@ -1093,13 +1312,8 @@ function renderOperator(data, report) {

Что требует внимания

операторские действия и открытые вопросы
- ${renderIncidentsList(incidents)} ${renderSectionItems(actions, "Рекомендаций нет.")}
-
-

Технический статус источников

вторичный слой для оператора
- ${renderSourceList(data)} -
`; } @@ -2457,8 +2671,11 @@ async function loadCurrentTab() { if (state.tab === "operator") { const data = await loadJson("/operator"); state.reports = await loadJson("/reports").catch(() => state.reports); + if (currentViewMode() === "security") { + state.cases = await loadJson("/cases").catch(error => ({ ok: false, error: error.message, cases: [] })); + } updateFilters(state.reports); - return { data, report: state.reports, html: renderOperator(data, state.reports) }; + return { data, report: state.reports, cases: state.cases, html: renderOperator(data, state.reports, { cases: state.cases }) }; } if (state.tab === "manager") { const data = await loadJson("/manager"); @@ -2510,8 +2727,8 @@ async function loadCurrentTab() { } function tabLoadingStage(tab) { + if (tab === "operator") return currentViewMeta().stage; return { - operator: "Загрузка связанной картины риска", employees: "Загрузка карточек сотрудников", departments: "Загрузка подразделений", owner: "Загрузка рисков", @@ -2568,6 +2785,10 @@ document.querySelectorAll(".tab").forEach(btn => { btn.addEventListener("click", () => setTab(btn.dataset.tab)); }); +document.querySelectorAll("[data-view-mode]").forEach(btn => { + btn.addEventListener("click", () => setViewMode(btn.dataset.viewMode)); +}); + document.getElementById("periodFilter")?.addEventListener("change", event => { state.period = periodFromSelectValue(event.target.value); refresh({ stage: "Обновление периода отчета" }); @@ -2768,6 +2989,7 @@ async function caseStatusAction(button) { await refresh({ stage: "Обновление статуса дела" }); } +updateViewModeButtons(); applySecurityMode(state.tab); renderLoadStatus(); refresh({ stage: "Первичная загрузка портала" }); diff --git a/adk-rust/crates/detmir-portal/src/static/index.html b/adk-rust/crates/detmir-portal/src/static/index.html index a7fe5a3..758989b 100644 --- a/adk-rust/crates/detmir-portal/src/static/index.html +++ b/adk-rust/crates/detmir-portal/src/static/index.html @@ -49,6 +49,12 @@ +
+ Представление + + + +
diff --git a/docs/PORTAL_RU.md b/docs/PORTAL_RU.md index 1265196..81d65f4 100644 --- a/docs/PORTAL_RU.md +++ b/docs/PORTAL_RU.md @@ -19,6 +19,21 @@ - `Отчеты` - текстовый отчет, печать/PDF, выгрузка данных и управленческий текст. - `Настройки` - параметры расчета только для чтения. +## Ролевые представления + +Во вкладке `Обзор` есть переключатель представления: + +- `Руководитель` - главный вывод, сводка руководителя, риски подразделений и + карта рисков; +- `Безопасность` - записи, которые требуют проверки, расследования, аудит + решений и пакеты расследований; +- `Эксплуатация` - полнота данных, качество сбора, ошибки и телеметрия рабочих + мест. + +Переключатель не меняет модель данных и не создает новые API. Он только +группирует существующие блоки портала под разные категории пользователей: +директор, сотрудник безопасности и администратор эксплуатации. + ## Загрузка и актуальность данных В верхней части портала есть глобальная полоса `Загрузка и актуальность diff --git a/scripts/detmir-portal-tabs-smoke.mjs b/scripts/detmir-portal-tabs-smoke.mjs index 24ce979..10cabd2 100644 --- a/scripts/detmir-portal-tabs-smoke.mjs +++ b/scripts/detmir-portal-tabs-smoke.mjs @@ -59,7 +59,7 @@ function assertStaticTabHandlers() { } const expectedTabs = [ - { tab: "operator", label: "Обзор", marker: "Управленческая сводка" }, + { tab: "operator", label: "Обзор", marker: "Представление руководителя" }, { tab: "employees", label: "Сотрудники", marker: "Карточки сотрудников" }, { tab: "departments", label: "Подразделения", marker: "Рейтинг подразделений" }, { tab: "owner", label: "Риски", marker: "Контроль безопасности" }, @@ -69,6 +69,8 @@ const expectedTabs = [ { tab: "settings", label: "Настройки", marker: "Параметры расчета" }, ]; +let smokeStep = "start"; + async function main() { const staticTabs = assertStaticTabHandlers(); const missingExpected = expectedTabs.filter((item) => !staticTabs.includes(item.tab)); @@ -102,8 +104,11 @@ async function main() { const results = []; const checks = []; try { + smokeStep = "open_portal"; await page.goto(url, { waitUntil: "domcontentloaded", timeout }); + smokeStep = "wait_tabs"; await page.waitForSelector(".tab", { timeout }); + smokeStep = "wait_initial_ready"; await page.waitForFunction( () => document.querySelector("#loadingStateText")?.textContent === "READY", null, @@ -117,6 +122,7 @@ async function main() { && !(await page.locator("body").innerText({ timeout })).includes("Данные загружаются"), }); for (const item of expectedTabs) { + smokeStep = `tab:${item.tab}`; await page.click(`button[data-tab="${item.tab}"]`, { timeout }); await page.waitForFunction( (marker) => document.body && document.body.innerText.includes(marker), @@ -129,32 +135,18 @@ async function main() { 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 === "operator") { - await page.waitForFunction( - () => document.body && document.body.innerText.includes("Качество данных по рабочим местам"), - null, - { timeout }, - ); const readyBodyText = await page.locator("#content").innerText({ timeout }); const requiredExecutive = [ "Главный вывод", - "Сотрудников в работе", - "Средний индекс активности", + "Достоверность показателей", + "Полнота данных", "Главная причина риска", "Подтверждающие слои", - "Качество данных", - "Стабильность агента за 7 дней", - "Качество данных по рабочим местам", - "Полнота данных", "Карта рисков", - "Связь рисков и активности", "Риски подразделений", - "Динамика рисков", - "Требует проверки", - "ТОП-5 лучших подразделений", - "ТОП-5 проблемных подразделений", - "Требует внимания", - "Heat Map подразделений", - "Карточка руководителя подразделения", + "Рабочая активность сотрудников", + "Контроль безопасности", + "Что требует внимания", ]; checks.push({ name: "executive_dashboard_layer", @@ -166,25 +158,70 @@ async function main() { ); const riskNarrativeIndex = cardHeadings.indexOf("Главный вывод"); const executiveIndex = cardHeadings.indexOf("Сводка руководителя"); - const trustIndex = cardHeadings.indexOf("Качество данных"); const businessRiskIndex = cardHeadings.indexOf("Риски подразделений"); - const candidatesIndex = cardHeadings.indexOf("Требует проверки"); + const heatmapIndex = cardHeadings.indexOf("Карта рисков"); checks.push({ name: "management_block_order", ok: riskNarrativeIndex >= 0 && executiveIndex > riskNarrativeIndex && - trustIndex > executiveIndex && - businessRiskIndex > trustIndex && - candidatesIndex > businessRiskIndex, + businessRiskIndex > executiveIndex && + heatmapIndex > businessRiskIndex, order: [ "Главный вывод", "Сводка руководителя", - "Качество данных", "Риски подразделений", - "Требует проверки", + "Карта рисков", ], }); + checks.push({ + name: "role_view_switcher_present", + ok: + (await page.locator('[data-view-mode="executive"]').count()) === 1 + && (await page.locator('[data-view-mode="security"]').count()) === 1 + && (await page.locator('[data-view-mode="operations"]').count()) === 1, + }); + + smokeStep = "role:security"; + await page.click('[data-view-mode="security"]', { timeout }); + await page.waitForFunction( + () => document.querySelector("#loadingStateText")?.textContent === "READY" + && document.body.innerText.includes("Пакеты расследований"), + null, + { timeout }, + ); + const securityText = await page.locator("#content").innerText({ timeout }); + checks.push({ + name: "security_role_view", + ok: [ + "Представление безопасности", + "Требует проверки", + "Расследования", + "Аудит", + "Пакеты расследований", + ].every((marker) => containsText(securityText, marker)), + }); + + smokeStep = "role:operations"; + await page.click('[data-view-mode="operations"]', { timeout }); + await page.waitForFunction( + () => document.querySelector("#loadingStateText")?.textContent === "READY" + && document.body.innerText.includes("Телеметрия"), + null, + { timeout }, + ); + const operationsText = await page.locator("#content").innerText({ timeout }); + checks.push({ + name: "operations_role_view", + ok: [ + "Представление эксплуатации", + "Полнота данных", + "Качество данных", + "Качество данных по рабочим местам", + "Ошибки", + "Телеметрия", + ].every((marker) => containsText(operationsText, marker)), + }); } if (item.tab === "settings") { const requiredSettings = [ @@ -202,9 +239,12 @@ async function main() { }); } } + smokeStep = "security:investigation_navigation"; await page.click('button[data-tab="operator"]', { timeout }); + await page.click('[data-view-mode="security"]', { timeout }); await page.waitForFunction( - () => document.body && document.body.innerText.includes("Топ рисков"), + () => document.querySelector("#loadingStateText")?.textContent === "READY" + && document.body.innerText.includes("Требует проверки"), null, { timeout }, ); @@ -255,6 +295,6 @@ async function main() { } main().catch((error) => { - process.stdout.write(`${JSON.stringify({ ok: false, error: error.message }, null, 2)}\n`); + process.stdout.write(`${JSON.stringify({ ok: false, step: smokeStep, error: error.message, stack: error.stack }, null, 2)}\n`); process.exitCode = 2; });