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 @@
+
+ Представление
+
+
+
+