feat(portal): add role views

This commit is contained in:
igor04091968
2026-06-04 20:32:27 +03:00
parent 0992679100
commit 10f0d15407
5 changed files with 387 additions and 56 deletions
@@ -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; }
+247 -25
View File
@@ -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("")}</div>`;
}
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 `
<div class="page-head">
<div>
<h2 class="section-title">Управленческая сводка</h2>
<p class="muted">За 10 секунд: работают ли люди, где просадка, где риск и что требует внимания.</p>
<h2 class="section-title">${ui(meta.heading)}</h2>
<p class="muted">${ui(meta.description)}</p>
</div>
<span class="badge ${statusClass(report?.severity)}">${escapeHtml(report?.headline || "Оперативный срез")}</span>
<span class="badge ${statusClass(report?.severity)}">${ui(meta.label)}</span>
</div>
${renderPeriodBanner(report)}
${renderRoleViewSummary(meta)}
${renderOperatorRoleContent(currentViewMode(), data, report, { ...extras, incidents })}
`;
}
function renderRoleViewSummary(meta) {
return `
<section class="card role-view-card">
<div class="section-head">
<div>
<h3>${ui(meta.heading)}</h3>
<p class="muted">${ui(meta.description)}</p>
</div>
<span class="badge status-ok">${ui(meta.label)}</span>
</div>
</section>
`;
}
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)}
<section class="dashboard-band security-band">
<div class="band-head"><h3>Расследования</h3><span class="muted">ручная проверка, решения и материалы</span></div>
${renderIncidentsList(extras.incidents || data.incidents || [])}
</section>
`;
}
function renderOperationsView(data, report) {
return `
${renderAgentCoverageSla(report?.agent_coverage_sla)}
${renderAgentQuality(report?.agent_quality, report?.agent_quality_explain)}
${renderOperationsErrors(data, report)}
<section class="dashboard-band technical-band">
<div class="band-head"><h3>Телеметрия</h3><span class="muted">свежесть, стабильность и источники данных</span></div>
${renderAgentQualityHistory(report?.agent_quality_history, report?.agent_quality_history_summary)}
${renderAgentQualityNodes(report?.agent_quality_nodes, report?.agent_quality_nodes_summary)}
${renderSourceList(data)}
</section>
`;
}
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 `
<section class="card operations-errors-card">
<div class="section-head">
<div>
<h3>Ошибки</h3>
<p class="muted">Что мешает достоверному сбору и эксплуатационной готовности.</p>
</div>
<span class="badge ${statusClass(rows.length ? "WARNING" : "OK")}">${rows.length}</span>
</div>
<div class="list compact-list">${rows.length ? rows.map(([name, text, status]) => `
<div class="row compact-row">
<strong>${ui(name)}</strong>
<span class="muted">${ui(text)}</span>
<span class="badge ${statusClass(status)}">${ui(status)}</span>
</div>
`).join("") : `
<div class="row compact-row">
<strong>Критичных ошибок нет</strong>
<span class="muted">Портал не видит ошибок коллектора или проблемных рабочих мест в текущем срезе.</span>
<span class="badge status-ok">OK</span>
</div>
`}</div>
</section>
`;
}
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 `
<section class="card incident-audit-card">
<div class="section-head">
<div>
<h3>Аудит</h3>
<p class="muted">Кто и когда менял статус записей, требующих проверки.</p>
</div>
<span class="badge ${statusClass(Number(summary.total_changes || 0) > 0 ? "INFO" : "UNKNOWN")}">${ui(summary.total_changes ?? 0)}</span>
</div>
<div class="quality-grid">${rows.map(([name, value]) => `
<div><span class="muted">${ui(name)}</span><strong>${ui(value)}</strong></div>
`).join("")}</div>
</section>
`;
}
function renderInvestigationPacks(candidates) {
const rows = Array.isArray(candidates) ? candidates.slice(0, 10) : [];
return `
<section class="card investigation-packs-card">
<div class="section-head">
<div>
<h3>Пакеты расследований</h3>
<p class="muted">Выгружаемые материалы по каждой записи для ручной проверки.</p>
</div>
<span class="badge ${statusClass(rows.length ? "INFO" : "UNKNOWN")}">${rows.length}</span>
</div>
<div class="list compact-list">${rows.length ? rows.map(item => `
<div class="row compact-row">
<strong>${ui(item.department || "Без подразделения")}</strong>
<span class="muted">${ui(item.reason || "требуется проверка")} · ${ui(item.hostname || "-")}</span>
<a class="small-button" href="${apiBase()}/investigation-pack/${encodeURIComponent(item.id || "")}?format=markdown" download>Скачать</a>
</div>
`).join("") : `
<div class="row compact-row">
<strong>Пакетов нет</strong>
<span class="muted">Нет записей, требующих выгрузки материалов.</span>
<span class="badge status-ok">OK</span>
</div>
`}</div>
</section>
`;
}
function renderOverviewAnalytics(report) {
const workforce = findSection(report, "Работа");
const insights = findSection(report, "Выводы по активности") || findSection(report, "Выводы Workforce");
const security = findSection(report, "ИБ");
const actions = findSection(report, "Действия");
return `
<section class="dashboard-band">
<div class="band-head"><h3>Рабочая активность сотрудников</h3><span class="muted">загрузка, простои, перегруз и дисциплина процессов</span></div>
${renderSectionItems(workforce, "Срез по работе сотрудников пока не сформирован.")}
@@ -1093,13 +1312,8 @@ function renderOperator(data, report) {
</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>
`;
}
@@ -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: "Первичная загрузка портала" });
@@ -49,6 +49,12 @@
</select>
</label>
</div>
<div class="view-switch" aria-label="Ролевое представление">
<span>Представление</span>
<button class="view-button is-active" data-view-mode="executive" type="button">Руководитель</button>
<button class="view-button" data-view-mode="security" type="button">Безопасность</button>
<button class="view-button" data-view-mode="operations" type="button">Эксплуатация</button>
</div>
<div class="header-actions">
<button class="small-button primary" data-open-reports="true">Сформировать отчет</button>
<button class="small-button" data-export-markdown="true">Markdown</button>