feat(portal): add role views
This commit is contained in:
@@ -142,13 +142,58 @@ h1 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.control-strip,
|
.control-strip,
|
||||||
.header-actions {
|
.header-actions,
|
||||||
|
.view-switch {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: 8px;
|
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 {
|
.control-strip label {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
@@ -1114,8 +1159,11 @@ pre {
|
|||||||
.topbar { flex-direction: column; }
|
.topbar { flex-direction: column; }
|
||||||
.topbar-side { justify-items: stretch; min-width: 0; width: 100%; }
|
.topbar-side { justify-items: stretch; min-width: 0; width: 100%; }
|
||||||
.control-strip,
|
.control-strip,
|
||||||
.header-actions { justify-content: flex-start; }
|
.header-actions,
|
||||||
|
.view-switch { justify-content: flex-start; }
|
||||||
.control-strip label { min-width: 100%; }
|
.control-strip label { min-width: 100%; }
|
||||||
|
.view-switch > span { flex-basis: 100%; }
|
||||||
|
.view-button { flex: 1 1 120px; }
|
||||||
h1 { font-size: 24px; }
|
h1 { font-size: 24px; }
|
||||||
.page-head { flex-direction: column; }
|
.page-head { flex-direction: column; }
|
||||||
.period-banner { flex-direction: column; align-items: flex-start; }
|
.period-banner { flex-direction: column; align-items: flex-start; }
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
const state = {
|
const state = {
|
||||||
tab: "operator",
|
tab: "operator",
|
||||||
|
viewMode: initialViewMode(),
|
||||||
period: "today",
|
period: "today",
|
||||||
links: null,
|
links: null,
|
||||||
readiness: null,
|
readiness: null,
|
||||||
reports: null,
|
reports: null,
|
||||||
|
cases: null,
|
||||||
pendingScrollSelector: null,
|
pendingScrollSelector: null,
|
||||||
load: {
|
load: {
|
||||||
status: "LOADING",
|
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() {
|
function apiBase() {
|
||||||
const path = window.location.pathname;
|
const path = window.location.pathname;
|
||||||
return path.startsWith("/portal") ? "/portal/api" : "/api";
|
return path.startsWith("/portal") ? "/portal/api" : "/api";
|
||||||
@@ -142,6 +177,36 @@ function tooltip(value) {
|
|||||||
return `title="${ui(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) {
|
function renderSummary(summary, readiness) {
|
||||||
const global = document.getElementById("globalStatus");
|
const global = document.getElementById("globalStatus");
|
||||||
global.className = `status-pill ${statusClass(summary.severity)}`;
|
global.className = `status-pill ${statusClass(summary.severity)}`;
|
||||||
@@ -945,7 +1010,7 @@ function renderDepartmentLeaderCard(report) {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderOverviewAnalytics(report) {
|
function renderOperatorDetailBands(report) {
|
||||||
return `
|
return `
|
||||||
${renderDepartmentRanking(report)}
|
${renderDepartmentRanking(report)}
|
||||||
${renderAttentionBlock(report)}
|
${renderAttentionBlock(report)}
|
||||||
@@ -1053,35 +1118,189 @@ function renderSourceList(data) {
|
|||||||
`).join("")}</div>`;
|
`).join("")}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderOperator(data, report) {
|
function renderOperator(data, report, extras = {}) {
|
||||||
report = periodReport(report);
|
report = periodReport(report);
|
||||||
const incidents = Array.isArray(data.incidents) ? data.incidents : [];
|
const incidents = Array.isArray(data.incidents) ? data.incidents : [];
|
||||||
const workforce = findSection(report, "Работа");
|
const meta = currentViewMeta();
|
||||||
const insights = findSection(report, "Выводы Workforce");
|
|
||||||
const security = findSection(report, "ИБ");
|
|
||||||
const actions = findSection(report, "Действия");
|
|
||||||
return `
|
return `
|
||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="section-title">Управленческая сводка</h2>
|
<h2 class="section-title">${ui(meta.heading)}</h2>
|
||||||
<p class="muted">За 10 секунд: работают ли люди, где просадка, где риск и что требует внимания.</p>
|
<p class="muted">${ui(meta.description)}</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="badge ${statusClass(report?.severity)}">${escapeHtml(report?.headline || "Оперативный срез")}</span>
|
<span class="badge ${statusClass(report?.severity)}">${ui(meta.label)}</span>
|
||||||
</div>
|
</div>
|
||||||
${renderPeriodBanner(report)}
|
${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)}
|
${renderRiskNarrative(report)}
|
||||||
${renderExecutiveDashboard(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)}
|
${renderBusinessRisk(report?.business_risk)}
|
||||||
${renderBusinessRiskTimeline(report?.business_risk_history, report?.business_risk_history_summary)}
|
${renderRiskHeatmap(report?.risk_heatmap)}
|
||||||
${renderRiskIncidentCandidates(report?.risk_incident_candidates)}
|
|
||||||
${renderOverviewAnalytics(report)}
|
${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">
|
<section class="dashboard-band">
|
||||||
<div class="band-head"><h3>Рабочая активность сотрудников</h3><span class="muted">загрузка, простои, перегруз и дисциплина процессов</span></div>
|
<div class="band-head"><h3>Рабочая активность сотрудников</h3><span class="muted">загрузка, простои, перегруз и дисциплина процессов</span></div>
|
||||||
${renderSectionItems(workforce, "Срез по работе сотрудников пока не сформирован.")}
|
${renderSectionItems(workforce, "Срез по работе сотрудников пока не сформирован.")}
|
||||||
@@ -1093,13 +1312,8 @@ function renderOperator(data, report) {
|
|||||||
</section>
|
</section>
|
||||||
<section class="dashboard-band">
|
<section class="dashboard-band">
|
||||||
<div class="band-head"><h3>Что требует внимания</h3><span class="muted">операторские действия и открытые вопросы</span></div>
|
<div class="band-head"><h3>Что требует внимания</h3><span class="muted">операторские действия и открытые вопросы</span></div>
|
||||||
${renderIncidentsList(incidents)}
|
|
||||||
${renderSectionItems(actions, "Рекомендаций нет.")}
|
${renderSectionItems(actions, "Рекомендаций нет.")}
|
||||||
</section>
|
</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") {
|
if (state.tab === "operator") {
|
||||||
const data = await loadJson("/operator");
|
const data = await loadJson("/operator");
|
||||||
state.reports = await loadJson("/reports").catch(() => state.reports);
|
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);
|
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") {
|
if (state.tab === "manager") {
|
||||||
const data = await loadJson("/manager");
|
const data = await loadJson("/manager");
|
||||||
@@ -2510,8 +2727,8 @@ async function loadCurrentTab() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function tabLoadingStage(tab) {
|
function tabLoadingStage(tab) {
|
||||||
|
if (tab === "operator") return currentViewMeta().stage;
|
||||||
return {
|
return {
|
||||||
operator: "Загрузка связанной картины риска",
|
|
||||||
employees: "Загрузка карточек сотрудников",
|
employees: "Загрузка карточек сотрудников",
|
||||||
departments: "Загрузка подразделений",
|
departments: "Загрузка подразделений",
|
||||||
owner: "Загрузка рисков",
|
owner: "Загрузка рисков",
|
||||||
@@ -2568,6 +2785,10 @@ document.querySelectorAll(".tab").forEach(btn => {
|
|||||||
btn.addEventListener("click", () => setTab(btn.dataset.tab));
|
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 => {
|
document.getElementById("periodFilter")?.addEventListener("change", event => {
|
||||||
state.period = periodFromSelectValue(event.target.value);
|
state.period = periodFromSelectValue(event.target.value);
|
||||||
refresh({ stage: "Обновление периода отчета" });
|
refresh({ stage: "Обновление периода отчета" });
|
||||||
@@ -2768,6 +2989,7 @@ async function caseStatusAction(button) {
|
|||||||
await refresh({ stage: "Обновление статуса дела" });
|
await refresh({ stage: "Обновление статуса дела" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateViewModeButtons();
|
||||||
applySecurityMode(state.tab);
|
applySecurityMode(state.tab);
|
||||||
renderLoadStatus();
|
renderLoadStatus();
|
||||||
refresh({ stage: "Первичная загрузка портала" });
|
refresh({ stage: "Первичная загрузка портала" });
|
||||||
|
|||||||
@@ -49,6 +49,12 @@
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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">
|
<div class="header-actions">
|
||||||
<button class="small-button primary" data-open-reports="true">Сформировать отчет</button>
|
<button class="small-button primary" data-open-reports="true">Сформировать отчет</button>
|
||||||
<button class="small-button" data-export-markdown="true">Markdown</button>
|
<button class="small-button" data-export-markdown="true">Markdown</button>
|
||||||
|
|||||||
@@ -19,6 +19,21 @@
|
|||||||
- `Отчеты` - текстовый отчет, печать/PDF, выгрузка данных и управленческий текст.
|
- `Отчеты` - текстовый отчет, печать/PDF, выгрузка данных и управленческий текст.
|
||||||
- `Настройки` - параметры расчета только для чтения.
|
- `Настройки` - параметры расчета только для чтения.
|
||||||
|
|
||||||
|
## Ролевые представления
|
||||||
|
|
||||||
|
Во вкладке `Обзор` есть переключатель представления:
|
||||||
|
|
||||||
|
- `Руководитель` - главный вывод, сводка руководителя, риски подразделений и
|
||||||
|
карта рисков;
|
||||||
|
- `Безопасность` - записи, которые требуют проверки, расследования, аудит
|
||||||
|
решений и пакеты расследований;
|
||||||
|
- `Эксплуатация` - полнота данных, качество сбора, ошибки и телеметрия рабочих
|
||||||
|
мест.
|
||||||
|
|
||||||
|
Переключатель не меняет модель данных и не создает новые API. Он только
|
||||||
|
группирует существующие блоки портала под разные категории пользователей:
|
||||||
|
директор, сотрудник безопасности и администратор эксплуатации.
|
||||||
|
|
||||||
## Загрузка и актуальность данных
|
## Загрузка и актуальность данных
|
||||||
|
|
||||||
В верхней части портала есть глобальная полоса `Загрузка и актуальность
|
В верхней части портала есть глобальная полоса `Загрузка и актуальность
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ function assertStaticTabHandlers() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const expectedTabs = [
|
const expectedTabs = [
|
||||||
{ tab: "operator", label: "Обзор", marker: "Управленческая сводка" },
|
{ tab: "operator", label: "Обзор", marker: "Представление руководителя" },
|
||||||
{ tab: "employees", label: "Сотрудники", marker: "Карточки сотрудников" },
|
{ tab: "employees", label: "Сотрудники", marker: "Карточки сотрудников" },
|
||||||
{ tab: "departments", label: "Подразделения", marker: "Рейтинг подразделений" },
|
{ tab: "departments", label: "Подразделения", marker: "Рейтинг подразделений" },
|
||||||
{ tab: "owner", label: "Риски", marker: "Контроль безопасности" },
|
{ tab: "owner", label: "Риски", marker: "Контроль безопасности" },
|
||||||
@@ -69,6 +69,8 @@ const expectedTabs = [
|
|||||||
{ tab: "settings", label: "Настройки", marker: "Параметры расчета" },
|
{ tab: "settings", label: "Настройки", marker: "Параметры расчета" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
let smokeStep = "start";
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const staticTabs = assertStaticTabHandlers();
|
const staticTabs = assertStaticTabHandlers();
|
||||||
const missingExpected = expectedTabs.filter((item) => !staticTabs.includes(item.tab));
|
const missingExpected = expectedTabs.filter((item) => !staticTabs.includes(item.tab));
|
||||||
@@ -102,8 +104,11 @@ async function main() {
|
|||||||
const results = [];
|
const results = [];
|
||||||
const checks = [];
|
const checks = [];
|
||||||
try {
|
try {
|
||||||
|
smokeStep = "open_portal";
|
||||||
await page.goto(url, { waitUntil: "domcontentloaded", timeout });
|
await page.goto(url, { waitUntil: "domcontentloaded", timeout });
|
||||||
|
smokeStep = "wait_tabs";
|
||||||
await page.waitForSelector(".tab", { timeout });
|
await page.waitForSelector(".tab", { timeout });
|
||||||
|
smokeStep = "wait_initial_ready";
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
() => document.querySelector("#loadingStateText")?.textContent === "READY",
|
() => document.querySelector("#loadingStateText")?.textContent === "READY",
|
||||||
null,
|
null,
|
||||||
@@ -117,6 +122,7 @@ async function main() {
|
|||||||
&& !(await page.locator("body").innerText({ timeout })).includes("Данные загружаются"),
|
&& !(await page.locator("body").innerText({ timeout })).includes("Данные загружаются"),
|
||||||
});
|
});
|
||||||
for (const item of expectedTabs) {
|
for (const item of expectedTabs) {
|
||||||
|
smokeStep = `tab:${item.tab}`;
|
||||||
await page.click(`button[data-tab="${item.tab}"]`, { timeout });
|
await page.click(`button[data-tab="${item.tab}"]`, { timeout });
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
(marker) => document.body && document.body.innerText.includes(marker),
|
(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;
|
const ok = activeTab.trim() === item.label && bodyText.includes(item.marker) && !stillLoading;
|
||||||
results.push({ tab: item.tab, label: item.label, marker: item.marker, ok });
|
results.push({ tab: item.tab, label: item.label, marker: item.marker, ok });
|
||||||
if (item.tab === "operator") {
|
if (item.tab === "operator") {
|
||||||
await page.waitForFunction(
|
|
||||||
() => document.body && document.body.innerText.includes("Качество данных по рабочим местам"),
|
|
||||||
null,
|
|
||||||
{ timeout },
|
|
||||||
);
|
|
||||||
const readyBodyText = await page.locator("#content").innerText({ timeout });
|
const readyBodyText = await page.locator("#content").innerText({ timeout });
|
||||||
const requiredExecutive = [
|
const requiredExecutive = [
|
||||||
"Главный вывод",
|
"Главный вывод",
|
||||||
"Сотрудников в работе",
|
"Достоверность показателей",
|
||||||
"Средний индекс активности",
|
"Полнота данных",
|
||||||
"Главная причина риска",
|
"Главная причина риска",
|
||||||
"Подтверждающие слои",
|
"Подтверждающие слои",
|
||||||
"Качество данных",
|
|
||||||
"Стабильность агента за 7 дней",
|
|
||||||
"Качество данных по рабочим местам",
|
|
||||||
"Полнота данных",
|
|
||||||
"Карта рисков",
|
"Карта рисков",
|
||||||
"Связь рисков и активности",
|
|
||||||
"Риски подразделений",
|
"Риски подразделений",
|
||||||
"Динамика рисков",
|
"Рабочая активность сотрудников",
|
||||||
"Требует проверки",
|
"Контроль безопасности",
|
||||||
"ТОП-5 лучших подразделений",
|
"Что требует внимания",
|
||||||
"ТОП-5 проблемных подразделений",
|
|
||||||
"Требует внимания",
|
|
||||||
"Heat Map подразделений",
|
|
||||||
"Карточка руководителя подразделения",
|
|
||||||
];
|
];
|
||||||
checks.push({
|
checks.push({
|
||||||
name: "executive_dashboard_layer",
|
name: "executive_dashboard_layer",
|
||||||
@@ -166,25 +158,70 @@ async function main() {
|
|||||||
);
|
);
|
||||||
const riskNarrativeIndex = cardHeadings.indexOf("Главный вывод");
|
const riskNarrativeIndex = cardHeadings.indexOf("Главный вывод");
|
||||||
const executiveIndex = cardHeadings.indexOf("Сводка руководителя");
|
const executiveIndex = cardHeadings.indexOf("Сводка руководителя");
|
||||||
const trustIndex = cardHeadings.indexOf("Качество данных");
|
|
||||||
const businessRiskIndex = cardHeadings.indexOf("Риски подразделений");
|
const businessRiskIndex = cardHeadings.indexOf("Риски подразделений");
|
||||||
const candidatesIndex = cardHeadings.indexOf("Требует проверки");
|
const heatmapIndex = cardHeadings.indexOf("Карта рисков");
|
||||||
checks.push({
|
checks.push({
|
||||||
name: "management_block_order",
|
name: "management_block_order",
|
||||||
ok:
|
ok:
|
||||||
riskNarrativeIndex >= 0 &&
|
riskNarrativeIndex >= 0 &&
|
||||||
executiveIndex > riskNarrativeIndex &&
|
executiveIndex > riskNarrativeIndex &&
|
||||||
trustIndex > executiveIndex &&
|
businessRiskIndex > executiveIndex &&
|
||||||
businessRiskIndex > trustIndex &&
|
heatmapIndex > businessRiskIndex,
|
||||||
candidatesIndex > businessRiskIndex,
|
|
||||||
order: [
|
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") {
|
if (item.tab === "settings") {
|
||||||
const requiredSettings = [
|
const requiredSettings = [
|
||||||
@@ -202,9 +239,12 @@ async function main() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
smokeStep = "security:investigation_navigation";
|
||||||
await page.click('button[data-tab="operator"]', { timeout });
|
await page.click('button[data-tab="operator"]', { timeout });
|
||||||
|
await page.click('[data-view-mode="security"]', { timeout });
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
() => document.body && document.body.innerText.includes("Топ рисков"),
|
() => document.querySelector("#loadingStateText")?.textContent === "READY"
|
||||||
|
&& document.body.innerText.includes("Требует проверки"),
|
||||||
null,
|
null,
|
||||||
{ timeout },
|
{ timeout },
|
||||||
);
|
);
|
||||||
@@ -255,6 +295,6 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
main().catch((error) => {
|
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;
|
process.exitCode = 2;
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user