feat(portal): harden readiness and explain workforce KPI
This commit is contained in:
@@ -197,6 +197,8 @@ collectors.
|
||||
- [Pilot v1 demo](docs/PILOT_DEMO_SCENARIO_RU.md)
|
||||
- [Pilot v1.0 acceptance checklist](docs/PILOT_V1_ACCEPTANCE_CHECKLIST_RU.md)
|
||||
- [Pilot v1.0 evidence](docs/PILOT_V1_EVIDENCE_RU.md)
|
||||
- [Production readiness портала](docs/PRODUCTION_READINESS_RU.md)
|
||||
- [Explainable Workforce KPI](docs/EXPLAINABLE_KPI_RU.md)
|
||||
- [Итог production-расследования 2026-06-07](docs/PRODUCTION_INCIDENT_REPORT_2026-06-07_RU.md)
|
||||
- [Runbook восстановления worktime reports](docs/OPERATIONS_RUNBOOK_WORKTIME_RU.md)
|
||||
- [Позиционирование продукта](docs/PRODUCT_POSITIONING_RU.md)
|
||||
|
||||
@@ -766,6 +766,67 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/workforce/kpi/explain": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"workforce"
|
||||
],
|
||||
"summary": "Rule-based Workforce KPI explanation",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "date",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "date"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "department",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "owner",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "role",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PortalRole"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Explainable Workforce KPI payload",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/WorkforceKpiExplain"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Query limits rejected"
|
||||
},
|
||||
"403": {
|
||||
"description": "Role denied"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
@@ -1172,6 +1233,118 @@
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"WorkforceKpiExplainFactor": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"label",
|
||||
"impact",
|
||||
"explanation"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"impact": {
|
||||
"type": "string"
|
||||
},
|
||||
"explanation": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"WorkforceKpiExplain": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ok",
|
||||
"kpi_score",
|
||||
"confidence",
|
||||
"coverage",
|
||||
"factors",
|
||||
"top_applications",
|
||||
"warnings",
|
||||
"recommendations"
|
||||
],
|
||||
"properties": {
|
||||
"ok": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"role_context": {
|
||||
"$ref": "#/components/schemas/RoleContext"
|
||||
},
|
||||
"scope": {
|
||||
"type": "string"
|
||||
},
|
||||
"kpi_score": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 100
|
||||
},
|
||||
"kpi_status": {
|
||||
"type": "string"
|
||||
},
|
||||
"confidence": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"high",
|
||||
"medium",
|
||||
"low"
|
||||
]
|
||||
},
|
||||
"coverage": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"agent_coverage_percent": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 100
|
||||
},
|
||||
"data_freshness": {
|
||||
"type": "string"
|
||||
},
|
||||
"missing_sources": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"factors": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/WorkforceKpiExplainFactor"
|
||||
}
|
||||
},
|
||||
"top_applications": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/JsonObject"
|
||||
}
|
||||
},
|
||||
"warnings": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"recommendations": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"model": {
|
||||
"$ref": "#/components/schemas/JsonObject"
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,35 @@ export interface AgentCoverageSla {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface WorkforceKpiExplainFactor {
|
||||
name: string;
|
||||
label: string;
|
||||
impact: string;
|
||||
explanation: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface WorkforceKpiExplainResponse {
|
||||
ok: boolean;
|
||||
role_context?: RoleContext;
|
||||
scope?: "aggregate" | string;
|
||||
kpi_score: number;
|
||||
kpi_status?: string;
|
||||
confidence: "high" | "medium" | "low" | string;
|
||||
coverage: {
|
||||
agent_coverage_percent: number;
|
||||
data_freshness: "fresh" | "stale" | "missing" | string;
|
||||
missing_sources: string[];
|
||||
[key: string]: unknown;
|
||||
};
|
||||
factors: WorkforceKpiExplainFactor[];
|
||||
top_applications: JsonObject[];
|
||||
warnings: string[];
|
||||
recommendations: string[];
|
||||
model?: JsonObject;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface BusinessRiskItem {
|
||||
department?: string;
|
||||
trust_score?: number;
|
||||
@@ -263,4 +292,10 @@ export interface DetMirPortalApi {
|
||||
getReadinessBundle(): Promise<JsonObject>;
|
||||
verifyReadiness(): Promise<JsonObject>;
|
||||
getWorkforcePolicyExplain(options?: { anonymize?: boolean }): Promise<JsonObject>;
|
||||
getWorkforceKpiExplain(options?: {
|
||||
date?: string;
|
||||
department?: string;
|
||||
owner?: string;
|
||||
role?: PortalRole;
|
||||
}): Promise<WorkforceKpiExplainResponse>;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -979,7 +979,8 @@ h1 {
|
||||
min-height: 180px;
|
||||
}
|
||||
|
||||
.index-explain-card {
|
||||
.index-explain-card,
|
||||
.kpi-explain-card {
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
@@ -1015,6 +1016,23 @@ h1 {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.soft-panel {
|
||||
min-width: 0;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--soft);
|
||||
}
|
||||
|
||||
.soft-panel h4 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.kpi-app-list {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.app-weight-list {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ const state = {
|
||||
operatorData: null,
|
||||
reports: null,
|
||||
cases: null,
|
||||
kpiExplain: null,
|
||||
pendingScrollSelector: null,
|
||||
load: {
|
||||
status: "LOADING",
|
||||
@@ -1242,6 +1243,7 @@ function renderExecutiveView(report, incidents) {
|
||||
return `
|
||||
${renderRiskNarrative(report)}
|
||||
${renderExecutiveDashboard(report)}
|
||||
${renderKpiExplain(report?.workforce_kpi_explain)}
|
||||
${renderSecurityEventsSummary(report?.security_events_summary, { compact: true })}
|
||||
${renderBusinessRisk(report?.business_risk)}
|
||||
${renderRiskHeatmap(report?.risk_heatmap)}
|
||||
@@ -1268,6 +1270,7 @@ function renderSecurityView(data, report, extras = {}) {
|
||||
function renderManagerView(report) {
|
||||
return `
|
||||
${renderExecutiveDashboard(report)}
|
||||
${renderKpiExplain(report?.workforce_kpi_explain)}
|
||||
${renderDepartmentRanking(report)}
|
||||
${renderDepartmentHeatMap(report)}
|
||||
${renderOverviewAnalytics(report)}
|
||||
@@ -1465,7 +1468,7 @@ function renderOverviewAnalytics(report) {
|
||||
`;
|
||||
}
|
||||
|
||||
function renderManager(data, policyExplain) {
|
||||
function renderManager(data, policyExplain, kpiExplain) {
|
||||
const workforceIndex = workforceIndexText(data.users_count, data.total_active_seconds);
|
||||
return `
|
||||
<div class="page-head">
|
||||
@@ -1495,6 +1498,7 @@ function renderManager(data, policyExplain) {
|
||||
`).join("")}</div>
|
||||
</section>
|
||||
</div>
|
||||
${renderKpiExplain(kpiExplain)}
|
||||
${renderWorkforceIndexExplanation(policyExplain)}
|
||||
<h3 class="section-title">Сотрудники без активности и с аномалиями</h3>
|
||||
<div class="list">${(data.users || []).map(user => `
|
||||
@@ -1538,7 +1542,7 @@ function renderDepartments(report) {
|
||||
`;
|
||||
}
|
||||
|
||||
function renderEmployees(data, policyExplain) {
|
||||
function renderEmployees(data, policyExplain, kpiExplain) {
|
||||
const employees = Array.isArray(policyExplain?.employee_details) ? policyExplain.employee_details : [];
|
||||
const users = Array.isArray(data.users) ? data.users : [];
|
||||
const selected = users.slice(0, 12);
|
||||
@@ -1552,6 +1556,7 @@ function renderEmployees(data, policyExplain) {
|
||||
</div>
|
||||
${renderDailyDetailNotice()}
|
||||
<div class="employee-card-grid">${selected.map(user => renderEmployeeCard(user, employees)).join("") || `<p class="muted">Сотрудники пока не найдены.</p>`}</div>
|
||||
${renderKpiExplain(kpiExplain)}
|
||||
${renderWorkforceIndexExplanation(policyExplain)}
|
||||
`;
|
||||
}
|
||||
@@ -1610,6 +1615,87 @@ function pctText(value) {
|
||||
return `${Math.round(n * 100)}%`;
|
||||
}
|
||||
|
||||
function renderKpiExplain(explain) {
|
||||
if (!explain || explain.ok === false) return "";
|
||||
const coverage = explain.coverage || {};
|
||||
const factors = Array.isArray(explain.factors) ? explain.factors : [];
|
||||
const positive = factors.filter(item => String(item.impact || "").startsWith("+"));
|
||||
const negative = factors.filter(item => String(item.impact || "").startsWith("-"));
|
||||
const apps = Array.isArray(explain.top_applications) ? explain.top_applications.slice(0, 6) : [];
|
||||
const warnings = Array.isArray(explain.warnings) ? explain.warnings : [];
|
||||
const recommendations = Array.isArray(explain.recommendations) ? explain.recommendations : [];
|
||||
return `
|
||||
<section class="card kpi-explain-card">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h3>Почему такой индекс активности?</h3>
|
||||
<p class="muted">Детерминированное объяснение: активность, рабочие приложения, простой, полнота и свежесть данных.</p>
|
||||
</div>
|
||||
<span class="badge ${confidenceStatusClass(explain.confidence)}">confidence ${ui(explain.confidence || "low")}</span>
|
||||
</div>
|
||||
<div class="index-metrics">
|
||||
<div><span class="muted">KPI</span><strong>${ui(explain.kpi_score ?? 0)}%</strong></div>
|
||||
<div><span class="muted">Покрытие</span><strong>${ui(coverage.agent_coverage_percent ?? 0)}%</strong></div>
|
||||
<div><span class="muted">Свежесть</span><strong>${ui(coverage.data_freshness || "missing")}</strong></div>
|
||||
<div><span class="muted">Пропуски</span><strong>${ui((coverage.missing_sources || []).length)}</strong></div>
|
||||
</div>
|
||||
<div class="grid-2">
|
||||
<section class="soft-panel">
|
||||
<h4>Что повышает индекс</h4>
|
||||
<div class="list compact-list">${renderKpiFactorRows(positive, "Положительных факторов нет.")}</div>
|
||||
</section>
|
||||
<section class="soft-panel">
|
||||
<h4>Что снижает индекс</h4>
|
||||
<div class="list compact-list">${renderKpiFactorRows(negative, "Отрицательных факторов нет.")}</div>
|
||||
</section>
|
||||
</div>
|
||||
${apps.length ? `
|
||||
<div class="list compact-list kpi-app-list">
|
||||
${apps.map(app => `
|
||||
<div class="row compact-row">
|
||||
<strong>${ui(app.name || "Приложение")}</strong>
|
||||
<span class="muted">${ui(app.category || "other")} · ${ui(app.active_minutes ?? 0)} мин.</span>
|
||||
<span class="badge ${app.contribution === "positive" ? "status-ok" : "status-unknown"}">${ui(app.contribution || "neutral")}</span>
|
||||
</div>
|
||||
`).join("")}
|
||||
</div>
|
||||
` : ""}
|
||||
${renderKpiNotes("Предупреждения", warnings)}
|
||||
${renderKpiNotes("Рекомендации", recommendations)}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function confidenceStatusClass(confidence) {
|
||||
const value = String(confidence || "low").toLowerCase();
|
||||
if (value === "high") return "status-ok";
|
||||
if (value === "medium") return "status-warn";
|
||||
return "status-degraded";
|
||||
}
|
||||
|
||||
function renderKpiFactorRows(items, emptyText) {
|
||||
if (!items.length) {
|
||||
return `<div class="row compact-row"><strong>Нет</strong><span class="muted">${ui(emptyText)}</span><span></span></div>`;
|
||||
}
|
||||
return items.map(item => `
|
||||
<div class="row compact-row">
|
||||
<strong>${ui(item.label || item.name || "Фактор")}</strong>
|
||||
<span class="muted">${ui(item.explanation || "")}</span>
|
||||
<span class="badge ${String(item.impact || "").startsWith("-") ? "status-warn" : "status-ok"}">${ui(item.impact || "0")}</span>
|
||||
</div>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function renderKpiNotes(title, items) {
|
||||
if (!items.length) return "";
|
||||
return `
|
||||
<div class="audit-note">
|
||||
<strong>${ui(title)}</strong>
|
||||
<span class="muted">${items.map(item => ui(item)).join(" · ")}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderWorkforceIndexExplanation(policy) {
|
||||
if (!policy || !policy.configured) {
|
||||
return `
|
||||
@@ -2824,6 +2910,7 @@ function renderReports(data) {
|
||||
</div>
|
||||
<h3 class="section-title">Ключевые показатели</h3>
|
||||
${renderKpiCards(data.kpis)}
|
||||
${renderKpiExplain(data.workforce_kpi_explain)}
|
||||
${renderAgentQuality(data.agent_quality, data.agent_quality_explain)}
|
||||
${renderAgentQualityHistory(data.agent_quality_history, data.agent_quality_history_summary)}
|
||||
${renderAgentQualityNodes(data.agent_quality_nodes, data.agent_quality_nodes_summary)}
|
||||
@@ -2944,7 +3031,9 @@ async function loadCurrentTab() {
|
||||
if (state.tab === "manager") {
|
||||
const data = await loadJson("/manager");
|
||||
const policyExplain = await loadJson("/workforce/policy/explain").catch(() => null);
|
||||
return { data, policyExplain, html: renderManager(data, policyExplain) };
|
||||
const kpiExplain = await loadJson("/workforce/kpi/explain").catch(() => null);
|
||||
state.kpiExplain = kpiExplain;
|
||||
return { data, policyExplain, kpiExplain, html: renderManager(data, policyExplain, kpiExplain) };
|
||||
}
|
||||
if (state.tab === "departments") {
|
||||
const data = await loadJson("/reports");
|
||||
@@ -2955,7 +3044,9 @@ async function loadCurrentTab() {
|
||||
if (state.tab === "employees") {
|
||||
const data = await loadJson("/manager");
|
||||
const policyExplain = await loadJson("/workforce/policy/explain").catch(() => null);
|
||||
return { data, policyExplain, html: renderEmployees(data, policyExplain) };
|
||||
const kpiExplain = await loadJson("/workforce/kpi/explain").catch(() => null);
|
||||
state.kpiExplain = kpiExplain;
|
||||
return { data, policyExplain, kpiExplain, html: renderEmployees(data, policyExplain, kpiExplain) };
|
||||
}
|
||||
if (state.tab === "owner") {
|
||||
const data = await loadJson("/owner");
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
# Explainable Workforce KPI
|
||||
|
||||
Explainable Workforce KPI отвечает на вопрос: почему получился такой индекс
|
||||
активности. Слой предназначен для руководителя, ИБ и администратора, но не
|
||||
является HR-оценкой сотрудника и не использует ML/LLM.
|
||||
|
||||
## API
|
||||
|
||||
Endpoint:
|
||||
|
||||
```http
|
||||
GET /api/workforce/kpi/explain
|
||||
```
|
||||
|
||||
Поддерживаемые параметры:
|
||||
|
||||
- `date`;
|
||||
- `department`;
|
||||
- `owner`;
|
||||
- `role`.
|
||||
|
||||
`employee_id` намеренно не добавлен: отдельная безопасная модель доступа к
|
||||
персональному explainability-контракту в Pilot v1 не утверждена.
|
||||
|
||||
## Модель ответа
|
||||
|
||||
Ответ содержит:
|
||||
|
||||
- `kpi_score`: итоговый индекс 0-100;
|
||||
- `confidence`: `high`, `medium` или `low`;
|
||||
- `coverage`: покрытие агента, свежесть данных, отсутствующие источники;
|
||||
- `factors`: детерминированные факторы с вкладом и объяснением;
|
||||
- `top_applications`: агрегированные приложения, влияющие на индекс;
|
||||
- `warnings`: предупреждения о качестве KPI;
|
||||
- `recommendations`: действия для проверки или улучшения данных.
|
||||
|
||||
## Факторы
|
||||
|
||||
Минимальный набор факторов:
|
||||
|
||||
| Factor | Смысл |
|
||||
| --- | --- |
|
||||
| `productive_activity` | Доля активности относительно планового рабочего времени |
|
||||
| `business_app_usage` | Наличие рабочих приложений и правил весов |
|
||||
| `idle_time` | Простой в рабочее время |
|
||||
| `afterhours_activity` | Активность вне рабочего окна |
|
||||
| `remote_session_activity` | Подтверждение активности через удаленные сессии |
|
||||
| `data_coverage` | Полнота агентских данных |
|
||||
| `missing_data` | Пропущенные источники |
|
||||
| `trend_change` | Наличие дневной/недельной/месячной истории |
|
||||
|
||||
Факторы rule-based, порядок стабильный, объяснения детерминированные.
|
||||
|
||||
## Confidence
|
||||
|
||||
`high`:
|
||||
|
||||
- хорошее покрытие;
|
||||
- свежие данные;
|
||||
- нет критичных пропусков.
|
||||
|
||||
`medium`:
|
||||
|
||||
- есть частичные пропуски;
|
||||
- свежесть или покрытие требуют проверки.
|
||||
|
||||
`low`:
|
||||
|
||||
- нет worktime-данных;
|
||||
- мало данных;
|
||||
- слабое покрытие;
|
||||
- источник отсутствует или недоступен.
|
||||
|
||||
## Роли
|
||||
|
||||
| Роль | Видимость |
|
||||
| --- | --- |
|
||||
| `executive` | Агрегированный KPI, без персональных деталей |
|
||||
| `manager` | Workforce KPI по доступному управленческому срезу |
|
||||
| `security` | Только факторы, релевантные ИБ и надежности данных |
|
||||
| `forensics` | Контекст расследования: временные отклонения и пропуски данных |
|
||||
| `admin` | Техническое покрытие и состояние источников |
|
||||
|
||||
Security и Forensics не получают Workforce Dashboard через `/api/reports` по
|
||||
умолчанию. Для explainability используется отдельный endpoint с серверной
|
||||
фильтрацией.
|
||||
|
||||
## UI и Markdown
|
||||
|
||||
Портал показывает блок:
|
||||
|
||||
```text
|
||||
Почему такой индекс активности?
|
||||
```
|
||||
|
||||
В Markdown-отчет добавлен раздел:
|
||||
|
||||
```markdown
|
||||
## Объяснение индекса активности
|
||||
```
|
||||
|
||||
Раздел содержит KPI score, confidence, coverage, факторы, warnings и
|
||||
рекомендации.
|
||||
|
||||
## Ограничения Pilot v1
|
||||
|
||||
- Это не ML и не LLM.
|
||||
- Это не predictive scoring.
|
||||
- Это не дисциплинарная HR-оценка.
|
||||
- Персональные выводы не формируются.
|
||||
- Качество KPI зависит от свежести ActivityWatch/worktime/agent data.
|
||||
@@ -10,6 +10,115 @@
|
||||
- реальную запись в InfluxDB;
|
||||
- health Grafana datasource.
|
||||
|
||||
## Portal production hardening
|
||||
|
||||
Портал AWatch-rus дополнен отдельным production-hardening слоем. Он не заменяет
|
||||
`detmir-readiness`, а закрывает HTTP/API надежность портала: liveness,
|
||||
readiness, version metadata, Prometheus metrics, request id/correlation id,
|
||||
bounded payload/query limits и role-gate smoke.
|
||||
|
||||
### HTTP endpoints
|
||||
|
||||
| Endpoint | Назначение | Внешние зависимости |
|
||||
| --- | --- | --- |
|
||||
| `GET /healthz` | Liveness процесса; возвращает `200 OK`, если процесс отвечает. | Не проверяет |
|
||||
| `GET /readyz` | Готовность приложения обслуживать запросы. | Только реально настроенные локальные зависимости |
|
||||
| `GET /version` | Версия приложения, schema version, build metadata. | Не проверяет |
|
||||
| `GET /metrics` | Prometheus text format. | Не проверяет |
|
||||
|
||||
`/readyz` не заявляет SIEM, DLP ingestion или pfSense ingestion. pfSense
|
||||
отображается как `contract_only`: контрактная готовность, не реальный
|
||||
полноценный сборщик.
|
||||
|
||||
### Конфигурация и лимиты
|
||||
|
||||
Портал валидирует конфигурацию при старте и завершает работу с понятной
|
||||
ошибкой, если значение небезопасно или некорректно. Секреты в ошибку не
|
||||
попадают.
|
||||
|
||||
| Параметр | Env | Назначение |
|
||||
| --- | --- | --- |
|
||||
| `--bind` | `DETMIR_PORTAL_BIND` | `host:port` HTTP-сервера |
|
||||
| `--max-page-size` | `AWATCH_PORTAL_MAX_PAGE_SIZE` | Верхний предел `page_size`/`limit` |
|
||||
| `--default-page-size` | `AWATCH_PORTAL_DEFAULT_PAGE_SIZE` | Значение по умолчанию для страниц |
|
||||
| `--max-report-date-range-days` | `AWATCH_PORTAL_MAX_REPORT_DATE_RANGE_DAYS` | Максимальный диапазон отчетов |
|
||||
| `--request-timeout-seconds` | `AWATCH_PORTAL_REQUEST_TIMEOUT_SECONDS` | Целевой timeout запроса/операции |
|
||||
| `--max-request-body-bytes` | `AWATCH_PORTAL_MAX_REQUEST_BODY_BYTES` | Общий лимит тела запроса |
|
||||
| `--slow-request-log-ms` | `AWATCH_PORTAL_SLOW_REQUEST_LOG_MS` | Порог медленного запроса для логов |
|
||||
| `--environment` | `AWATCH_PORTAL_ENVIRONMENT` | Безопасное имя окружения |
|
||||
| `--enabled-modules` | `AWATCH_PORTAL_ENABLED_MODULES` | Разрешенные модули портала |
|
||||
|
||||
Ограничения применяются к тяжелым API:
|
||||
|
||||
- `/api/reports`;
|
||||
- `/api/executive`;
|
||||
- `/api/workforce`;
|
||||
- `/api/security`;
|
||||
- `/api/forensics`;
|
||||
- `/api/ueba`;
|
||||
- `/api/pfsense`;
|
||||
- `/api/workforce/kpi/explain`.
|
||||
|
||||
Поведение:
|
||||
|
||||
- слишком большой `page_size` или `limit` возвращает `400`;
|
||||
- слишком широкий диапазон `date_from/date_to`, `from/to`, `start/end`
|
||||
возвращает `400`;
|
||||
- слишком большое тело запроса возвращает `413`;
|
||||
- role gate возвращает `403`.
|
||||
|
||||
### Request ID, logs и metrics
|
||||
|
||||
Портал принимает `X-Request-Id` и `X-Correlation-Id`. Если заголовки не
|
||||
переданы, `X-Request-Id` генерируется сервером, а `X-Correlation-Id` получает
|
||||
то же значение. Оба заголовка возвращаются в ответе.
|
||||
|
||||
HTTP-ответы пишутся в stderr как JSON-строки с полями:
|
||||
|
||||
- `timestamp`;
|
||||
- `level`;
|
||||
- `request_id`;
|
||||
- `correlation_id`;
|
||||
- `method`;
|
||||
- `path` без query params;
|
||||
- `route`;
|
||||
- `status`;
|
||||
- `latency_ms`;
|
||||
- `user_role`;
|
||||
- `module`;
|
||||
- `error_code`;
|
||||
- `response_bytes`.
|
||||
|
||||
В логах не должно быть токенов, тел запросов, IP-адресов клиента,
|
||||
`employee_id`, сырых query params или персональных данных.
|
||||
|
||||
`GET /metrics` возвращает:
|
||||
|
||||
- `awatch_http_requests_total`;
|
||||
- `awatch_http_request_duration_seconds`;
|
||||
- `awatch_reports_generated_total`;
|
||||
- `awatch_ingestion_records_total`;
|
||||
- `awatch_ingestion_rejected_total`;
|
||||
- `awatch_role_denied_total`;
|
||||
- `awatch_readyz_status`.
|
||||
|
||||
Labels ограничены низкой кардинальностью: `method`, `route`, `status`,
|
||||
`module`. Запрещены high-cardinality labels: `user_id`, `employee_id`, IP, raw
|
||||
URL, query params.
|
||||
|
||||
### Portal smoke
|
||||
|
||||
Минимальный smoke:
|
||||
|
||||
```bash
|
||||
AWATCH_PORTAL_SMOKE_URL=http://127.0.0.1:8720 \
|
||||
node scripts/awatch-production-hardening-smoke.mjs
|
||||
```
|
||||
|
||||
Smoke проверяет `/healthz`, `/readyz`, `/version`, `/metrics`, возврат
|
||||
`X-Request-Id`, reject слишком большого `page_size`, reject слишком широкого
|
||||
report range, role gates и `/api/workforce/kpi/explain`.
|
||||
|
||||
## Базовый запуск
|
||||
|
||||
На AW server:
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const baseUrl = (process.env.AWATCH_PORTAL_SMOKE_URL || "http://127.0.0.1:8720").replace(/\/+$/, "");
|
||||
|
||||
async function request(path, options = {}) {
|
||||
const response = await fetch(`${baseUrl}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
"X-AWatch-Role": "executive",
|
||||
"X-Request-Id": "smoke-production-hardening",
|
||||
...(options.headers || {}),
|
||||
},
|
||||
});
|
||||
const text = await response.text();
|
||||
let json = null;
|
||||
try {
|
||||
json = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
json = null;
|
||||
}
|
||||
return { response, text, json };
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const health = await request("/healthz");
|
||||
assert(health.response.status === 200, "/healthz must return 200");
|
||||
assert(health.json?.status === "ok", "/healthz must return status=ok");
|
||||
assert(
|
||||
health.response.headers.get("x-request-id") === "smoke-production-hardening",
|
||||
"X-Request-Id must be returned",
|
||||
);
|
||||
assert(
|
||||
health.response.headers.get("x-correlation-id") === "smoke-production-hardening",
|
||||
"X-Correlation-Id must be returned",
|
||||
);
|
||||
|
||||
const ready = await request("/readyz");
|
||||
assert([200, 503].includes(ready.response.status), "/readyz must return controlled status");
|
||||
assert(ready.json?.checks && typeof ready.json.checks === "object", "/readyz must return checks JSON");
|
||||
|
||||
const version = await request("/version");
|
||||
assert(version.response.status === 200, "/version must return 200");
|
||||
assert(version.json?.app_version, "/version must include app_version");
|
||||
assert(version.json?.schema_version === "pilot-v1", "/version must include schema_version=pilot-v1");
|
||||
|
||||
const metrics = await request("/metrics");
|
||||
assert(metrics.response.status === 200, "/metrics must return 200");
|
||||
assert(metrics.text.includes("awatch_http_requests_total"), "/metrics must include HTTP metric");
|
||||
assert(metrics.text.includes("awatch_readyz_status"), "/metrics must include readyz gauge");
|
||||
|
||||
const pageTooLarge = await request("/api/reports?page_size=999999&role=executive");
|
||||
assert(pageTooLarge.response.status === 400, "too large page_size must be rejected");
|
||||
assert(pageTooLarge.json?.error_code === "invalid_page_size", "page_size reject must explain error_code");
|
||||
|
||||
const rangeTooLarge = await request("/api/reports?date_from=2026-01-01&date_to=2026-12-31&role=executive");
|
||||
assert(rangeTooLarge.response.status === 400, "too wide report range must be rejected");
|
||||
assert(rangeTooLarge.json?.error_code === "report_range_too_large", "range reject must explain error_code");
|
||||
|
||||
const roleDenied = await request("/api/security?role=manager", {
|
||||
headers: { "X-AWatch-Role": "manager" },
|
||||
});
|
||||
assert(roleDenied.response.status === 403, "manager must not access security scope");
|
||||
|
||||
const kpiExplain = await request("/api/workforce/kpi/explain?role=executive");
|
||||
assert(kpiExplain.response.status === 200, "KPI explain must return 200");
|
||||
assert(typeof kpiExplain.json?.kpi_score === "number", "KPI explain must include kpi_score");
|
||||
assert(Array.isArray(kpiExplain.json?.factors), "KPI explain must include factors");
|
||||
assert(kpiExplain.json.factors.some((item) => item.name === "productive_activity"), "KPI explain factors must be deterministic");
|
||||
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
baseUrl,
|
||||
checked: [
|
||||
"/healthz",
|
||||
"/readyz",
|
||||
"/version",
|
||||
"/metrics",
|
||||
"query limits",
|
||||
"role gates",
|
||||
"/api/workforce/kpi/explain",
|
||||
],
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(JSON.stringify({ ok: false, baseUrl, error: error.message }, null, 2));
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user