feat(portal): harden pilot v1 role contracts

This commit is contained in:
igor04091968
2026-06-06 12:54:00 +03:00
parent 9c57d6d2ce
commit 067ad0939c
24 changed files with 1991 additions and 208 deletions
+105 -7
View File
@@ -25,6 +25,13 @@ const VIEW_MODES = {
description: "Главный вывод, сводка руководителя, риски подразделений и карта рисков.",
stage: "Формирование главного вывода",
},
manager: {
label: "Менеджер",
title: "Роль менеджера",
heading: "Представление менеджера",
description: "Workforce, сравнение подразделений, ответственные, тренды и отчет.",
stage: "Расчет Workforce и подразделений",
},
security: {
label: "Безопасность",
title: "Роль безопасности",
@@ -32,6 +39,20 @@ const VIEW_MODES = {
description: "Очередь проверки, расследования, аудит решений и пакеты расследований.",
stage: "Подготовка разделов проверки и расследований",
},
forensics: {
label: "Расследования",
title: "Роль расследований",
heading: "Представление расследований",
description: "Карточки расследований, timeline, связка user / host / app / network event и экспорт.",
stage: "Подготовка timeline расследований",
},
admin: {
label: "Администратор",
title: "Роль администратора",
heading: "Представление администратора",
description: "Настройки, качество данных, источники и эксплуатационные ошибки.",
stage: "Проверка настроек и источников",
},
operations: {
label: "Эксплуатация",
title: "Роль эксплуатации",
@@ -44,7 +65,7 @@ const VIEW_MODES = {
function initialViewMode() {
try {
const stored = window.localStorage?.getItem("detmir.portal.viewMode");
return ["executive", "security", "operations"].includes(stored) ? stored : "executive";
return ["executive", "manager", "security", "forensics", "admin", "operations"].includes(stored) ? stored : "executive";
} catch {
return "executive";
}
@@ -55,8 +76,27 @@ function apiBase() {
return path.startsWith("/portal") ? "/portal/api" : "/api";
}
function apiRole() {
if (state.tab === "employees" || state.tab === "departments") return "manager";
if (state.tab === "owner" || state.tab === "perimeter") return "security";
if (state.tab === "incidents") return "forensics";
if (state.tab === "settings") return "admin";
const mode = currentViewMode();
if (mode === "operations") return "admin";
return ["executive", "manager", "security", "forensics", "admin"].includes(mode) ? mode : "executive";
}
function roleHeaders() {
return { "X-AWatch-Role": apiRole() };
}
function withRole(path, role = apiRole()) {
const separator = path.includes("?") ? "&" : "?";
return `${path}${separator}role=${encodeURIComponent(role)}`;
}
async function loadJson(path) {
const response = await fetch(`${apiBase()}${path}`, { cache: "no-store" });
const response = await fetch(`${apiBase()}${path}`, { cache: "no-store", headers: roleHeaders() });
if (!response.ok) throw new Error(`${path}: HTTP ${response.status}`);
return response.json();
}
@@ -64,7 +104,7 @@ async function loadJson(path) {
async function postJson(path, payload) {
const response = await fetch(`${apiBase()}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: { "Content-Type": "application/json", ...roleHeaders() },
body: JSON.stringify(payload)
});
if (!response.ok) throw new Error(`${path}: HTTP ${response.status}`);
@@ -1202,7 +1242,10 @@ function renderRoleViewSummary(meta) {
}
function renderOperatorRoleContent(mode, data, report, extras = {}) {
if (mode === "manager") return renderManagerView(report);
if (mode === "security") return renderSecurityView(data, report, extras);
if (mode === "forensics") return renderForensicsView(data, report, extras);
if (mode === "admin") return renderOperationsView(data, report);
if (mode === "operations") return renderOperationsView(data, report);
return renderExecutiveView(report, extras.incidents || []);
}
@@ -1234,6 +1277,30 @@ function renderSecurityView(data, report, extras = {}) {
`;
}
function renderManagerView(report) {
return `
${renderExecutiveDashboard(report)}
${renderDepartmentRanking(report)}
${renderDepartmentHeatMap(report)}
${renderOverviewAnalytics(report)}
<section class="dashboard-band">
<div class="band-head"><h3>Markdown-отчет</h3><span class="muted">экспорт управленческого среза Workforce</span></div>
<pre class="markdown-preview">${ui((report?.markdown || "").slice(0, 2000))}</pre>
</section>
`;
}
function renderForensicsView(data, report, extras = {}) {
const cases = Array.isArray(extras.cases?.cases) ? extras.cases.cases : [];
return `
${renderRiskIncidentCandidates(report?.risk_incident_candidates)}
${renderCases(cases)}
${renderInvestigationTimeline(report)}
${renderInvestigationPacks(report?.risk_incident_candidates)}
${renderIncidentReviewAuditSummary(report)}
`;
}
function renderOperationsView(data, report) {
return `
${renderAgentCoverageSla(report?.agent_coverage_sla)}
@@ -1344,7 +1411,7 @@ function renderInvestigationPacks(candidates) {
<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>
<a class="small-button" href="${apiBase()}${withRole(`/investigation-pack/${encodeURIComponent(item.id || "")}?format=markdown`, "forensics")}" download>Скачать</a>
</div>
`).join("") : `
<div class="row compact-row">
@@ -1357,6 +1424,37 @@ function renderInvestigationPacks(candidates) {
`;
}
function renderInvestigationTimeline(report) {
const investigations = Array.isArray(report?.forensics?.investigations) ? report.forensics.investigations : [];
const timelines = investigations.flatMap(item => Array.isArray(item.timeline)
? item.timeline.map(event => ({ ...event, investigation_id: item.investigation_id }))
: []);
return `
<section class="card investigation-timeline-card">
<div class="section-head">
<div>
<h3>Timeline событий</h3>
<p class="muted">Связка user / host / app / network event для ручного расследования.</p>
</div>
<span class="badge ${statusClass(timelines.length ? "INFO" : "UNKNOWN")}">${timelines.length}</span>
</div>
<div class="list compact-list">${timelines.length ? timelines.slice(0, 20).map(event => `
<div class="row compact-row">
<strong>${ui(event.timestamp || "-")}</strong>
<span class="muted">${ui(event.kind || "event")} · ${ui(event.entity || "-")} · ${ui(event.summary || "")}</span>
<span class="badge status-ok">${ui(event.source || "portal")}</span>
</div>
`).join("") : `
<div class="row compact-row">
<strong>Timeline пуст</strong>
<span class="muted">Нет кандидатов для расследования в текущем срезе.</span>
<span class="badge status-ok">в норме</span>
</div>
`}</div>
</section>
`;
}
function renderOverviewAnalytics(report) {
const workforce = findSection(report, "Работа");
const insights = findSection(report, "Выводы по активности") || findSection(report, "Выводы Workforce");
@@ -1885,7 +1983,7 @@ function renderCases(cases) {
function renderCaseActions(item) {
const id = item?.case_id || "";
const packUrl = `/portal/api/cases/${encodeURIComponent(id)}?format=markdown`;
const packUrl = `/portal/api/cases/${encodeURIComponent(id)}?format=markdown&role=forensics`;
return `
<div class="button-row compact-actions">
<button class="small-button" data-case-status="IN_PROGRESS" data-case-id="${escapeHtml(id)}">В работу</button>
@@ -2652,7 +2750,7 @@ function renderCandidateReviewActions(item) {
["FALSE_POSITIVE", "Ложный"],
["POSTPONED", "Отложить"],
];
const packUrl = `/portal/api/investigation-pack/${encodeURIComponent(id)}?format=markdown`;
const packUrl = `/portal/api/investigation-pack/${encodeURIComponent(id)}?format=markdown&role=forensics`;
const createCase = reviewStatus === "CONFIRMED"
? `<button class="small-button investigation-pack-button primary" data-create-case="true" data-candidate-id="${escapeHtml(id)}">Создать дело</button>`
: "";
@@ -2843,7 +2941,7 @@ async function loadCurrentTab() {
const data = await loadJson("/operator");
state.operatorData = data;
state.reports = await loadJson("/reports").catch(() => state.reports);
if (currentViewMode() === "security") {
if (currentViewMode() === "security" || currentViewMode() === "forensics") {
state.cases = await loadJson("/cases").catch(error => ({ ok: false, error: error.message, cases: [] }));
}
updateFilters(state.reports);