01c20e7b6c
Закрывает последний unblocked production-TODO «Polling/SSE для real-time».
Manual reload-btn остаётся как fast-path; polling — фоновый автообновитель.
Composable (composables/usePolling.ts):
- usePolling(loader, {intervalMs=30_000, enabled=true}).
- Page Visibility API: при document.hidden=true interval останавливается;
при visibilitychange с возвратом hidden=false — restart + немедленный
loader() (не ждать следующего interval'а).
- Cleanup на onBeforeUnmount: clearInterval + removeEventListener.
- enabled=false — composable не стартует (feature-flag).
Integration:
- DealsView + KanbanView → loadDeals.
- AdminTenantsView → loadTenants.
- AdminBillingView → loadBilling.
- AdminIncidentsView → loadIncidents.
Vitest +6 (usePolling.spec.ts) с vi.useFakeTimers:
- Вызов каждые intervalMs / default 30 сек / skip при document.hidden /
cleanup на unmount / enabled=false → no-op / visibilitychange
pause+resume с немедленным loader.
Регресс:
- Lint+type-check+format passed.
- Vitest 319/319 за 18.67 сек (+6 от 313).
- Vite build 899 ms.
- Pint + PHPStan passed.
- Pest 266/266 за 28.62 сек (backend не тронут).
Реестр v1.71→v1.72 / CLAUDE.md v1.62→v1.63.
ВСЕ unblocked production-TODO закрыты.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
270 lines
10 KiB
Vue
270 lines
10 KiB
Vue
<script setup lang="ts">
|
||
/**
|
||
* Админка SaaS → Инциденты.
|
||
*
|
||
* Журнал инцидентов SaaS-уровня (incidents_log по schema v8.7 §9).
|
||
* Категории: PDN-breach, service_outage, security, billing, data_loss.
|
||
* При PDN-breach — обязательное уведомление РКН за 24 ч (152-ФЗ).
|
||
*
|
||
* MVP — display + фильтр по статусу/severity. Backend `/api/admin/incidents`
|
||
* подключается отдельным коммитом.
|
||
*/
|
||
import { ADMIN_INCIDENTS } from '../../composables/mockAdmin';
|
||
import { computed, onMounted, reactive, ref } from 'vue';
|
||
import { usePolling } from '../../composables/usePolling';
|
||
import * as adminApi from '../../api/admin';
|
||
|
||
interface IncidentRow {
|
||
id: number;
|
||
incident_id: string;
|
||
title: string;
|
||
severity: 'low' | 'medium' | 'high' | 'critical';
|
||
/** Backend type или mock-category — оба строки. */
|
||
category: string;
|
||
/** Backend: open/investigating/resolved. Mock включает closed — поддерживаем для legacy. */
|
||
status: string;
|
||
detected_at: string;
|
||
affected_tenants: number;
|
||
rkn_notified: boolean;
|
||
rkn_deadline_at: string | null;
|
||
}
|
||
|
||
const filterStatus = ref<string>('all');
|
||
|
||
const statusMap: Record<string, { label: string; color: string }> = {
|
||
open: { label: 'Открыт', color: 'error' },
|
||
investigating: { label: 'Расследуется', color: 'warning' },
|
||
resolved: { label: 'Решён', color: 'info' },
|
||
closed: { label: 'Закрыт', color: 'success' },
|
||
};
|
||
function statusInfo(s: string) {
|
||
return statusMap[s] ?? { label: s, color: 'default' };
|
||
}
|
||
|
||
const severityMap: Record<string, { label: string; color: string }> = {
|
||
critical: { label: 'Critical', color: 'error' },
|
||
high: { label: 'High', color: 'warning' },
|
||
medium: { label: 'Medium', color: 'info' },
|
||
low: { label: 'Low', color: 'success' },
|
||
};
|
||
function severityInfo(s: string) {
|
||
return severityMap[s] ?? { label: s, color: 'default' };
|
||
}
|
||
|
||
// Backend type slugs мапятся на UI-категории. data_breach → pdn_breach (UI-naming).
|
||
const categoryMap: Record<string, string> = {
|
||
data_breach: 'Утечка ПДн',
|
||
pdn_breach: 'Утечка ПДн',
|
||
service_outage: 'Сбой сервиса',
|
||
security_incident: 'Безопасность',
|
||
security: 'Безопасность',
|
||
billing_failure: 'Биллинг',
|
||
billing: 'Биллинг',
|
||
data_corruption: 'Потеря данных',
|
||
data_loss: 'Потеря данных',
|
||
integration_failure: 'Сбой интеграции',
|
||
performance_degradation: 'Деградация производительности',
|
||
other: 'Прочее',
|
||
};
|
||
function categoryLabel(c: string): string {
|
||
return categoryMap[c] ?? c;
|
||
}
|
||
|
||
// Reactive — initial = MOCK; replace на API на mount.
|
||
const rowsState = reactive<IncidentRow[]>(
|
||
ADMIN_INCIDENTS.map((r) => ({
|
||
id: r.id,
|
||
incident_id: r.incident_id,
|
||
title: r.title,
|
||
severity: r.severity,
|
||
category: r.category,
|
||
status: r.status,
|
||
detected_at: r.detected_at,
|
||
affected_tenants: r.affected_tenants,
|
||
rkn_notified: r.rkn_notified,
|
||
rkn_deadline_at: r.rkn_deadline_at,
|
||
})),
|
||
);
|
||
const stats = reactive({ open: 0, investigating: 0, rkn_pending: 0 });
|
||
const loading = ref(false);
|
||
const fetchError = ref(false);
|
||
|
||
// Initial stats из mock (UI consistency без backend'а).
|
||
stats.open = rowsState.filter((r) => r.status === 'open').length;
|
||
stats.investigating = rowsState.filter((r) => r.status === 'investigating').length;
|
||
stats.rkn_pending = rowsState.filter(
|
||
(r) => (r.category === 'pdn_breach' || r.category === 'data_breach') && !r.rkn_notified,
|
||
).length;
|
||
|
||
async function loadIncidents() {
|
||
loading.value = true;
|
||
fetchError.value = false;
|
||
try {
|
||
const res = await adminApi.listAdminIncidents();
|
||
const mapped: IncidentRow[] = res.incidents.map((i) => ({
|
||
id: i.id,
|
||
incident_id: i.incident_id,
|
||
title: i.summary,
|
||
severity: i.severity,
|
||
category: i.type, // backend: data_breach/service_outage/etc.
|
||
status: i.status,
|
||
detected_at: i.detected_at,
|
||
affected_tenants: i.affected_tenants_count,
|
||
rkn_notified: i.rkn_notified,
|
||
rkn_deadline_at: i.rkn_deadline_at,
|
||
}));
|
||
rowsState.splice(0, rowsState.length, ...mapped);
|
||
stats.open = res.summary.open;
|
||
stats.investigating = res.summary.investigating;
|
||
stats.rkn_pending = res.summary.rkn_pending;
|
||
} catch {
|
||
fetchError.value = true;
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
onMounted(loadIncidents);
|
||
usePolling(loadIncidents);
|
||
|
||
const filteredRows = computed(() =>
|
||
filterStatus.value === 'all' ? rowsState : rowsState.filter((r) => r.status === filterStatus.value),
|
||
);
|
||
|
||
defineExpose({ rowsState, stats, loading, fetchError, loadIncidents });
|
||
|
||
function formatDate(iso: string): string {
|
||
return new Date(iso).toLocaleString('ru-RU', {
|
||
day: '2-digit',
|
||
month: '2-digit',
|
||
year: 'numeric',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
});
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<v-container fluid class="admin-incidents pa-6">
|
||
<header class="page-head mb-4 d-flex justify-space-between align-start">
|
||
<div>
|
||
<h1 class="text-h4 page-title">Инциденты</h1>
|
||
<p class="text-body-2 text-medium-emphasis ma-0">
|
||
Журнал инцидентов SaaS-уровня. PDN-breach — уведомление РКН за 24 ч (152-ФЗ).
|
||
</p>
|
||
</div>
|
||
<v-btn
|
||
variant="outlined"
|
||
prepend-icon="mdi-refresh"
|
||
:loading="loading"
|
||
data-testid="reload-btn"
|
||
@click="loadIncidents"
|
||
>
|
||
Обновить
|
||
</v-btn>
|
||
</header>
|
||
|
||
<v-alert
|
||
v-if="fetchError"
|
||
type="warning"
|
||
variant="tonal"
|
||
density="compact"
|
||
closable
|
||
class="mb-4"
|
||
data-testid="fetch-error-alert"
|
||
>
|
||
Backend недоступен — показаны mock-данные.
|
||
</v-alert>
|
||
|
||
<v-row class="mb-4" data-testid="incidents-stats">
|
||
<v-col cols="12" sm="4">
|
||
<v-card variant="outlined" class="pa-3">
|
||
<div class="text-caption text-medium-emphasis">Открыто</div>
|
||
<div class="text-h6 text-error">{{ stats.open }}</div>
|
||
</v-card>
|
||
</v-col>
|
||
<v-col cols="12" sm="4">
|
||
<v-card variant="outlined" class="pa-3">
|
||
<div class="text-caption text-medium-emphasis">Расследуется</div>
|
||
<div class="text-h6 text-warning">{{ stats.investigating }}</div>
|
||
</v-card>
|
||
</v-col>
|
||
<v-col cols="12" sm="4">
|
||
<v-card variant="outlined" class="pa-3">
|
||
<div class="text-caption text-medium-emphasis">РКН-уведомлений</div>
|
||
<div class="text-h6 text-error">{{ stats.rkn_pending }}</div>
|
||
</v-card>
|
||
</v-col>
|
||
</v-row>
|
||
|
||
<v-card variant="outlined" class="pa-4">
|
||
<div class="d-flex justify-space-between align-center mb-3 flex-wrap ga-2">
|
||
<h2 class="text-h6 ma-0">События</h2>
|
||
<v-btn-toggle v-model="filterStatus" mandatory density="comfortable" variant="outlined">
|
||
<v-btn value="all" size="small">Все</v-btn>
|
||
<v-btn value="open" size="small">Открыты</v-btn>
|
||
<v-btn value="investigating" size="small">Расследуются</v-btn>
|
||
<v-btn value="resolved" size="small">Решены</v-btn>
|
||
<v-btn value="closed" size="small">Закрыты</v-btn>
|
||
</v-btn-toggle>
|
||
</div>
|
||
|
||
<v-list lines="three" class="incidents-list">
|
||
<v-list-item v-for="row in filteredRows" :key="row.id" class="incident-row">
|
||
<div class="incident-header">
|
||
<span class="font-mono text-caption text-medium-emphasis">{{ row.incident_id }}</span>
|
||
<v-chip :color="severityInfo(row.severity).color" size="x-small" variant="tonal" class="ml-2">
|
||
{{ severityInfo(row.severity).label }}
|
||
</v-chip>
|
||
<v-chip :color="statusInfo(row.status).color" size="x-small" variant="tonal" class="ml-2">
|
||
{{ statusInfo(row.status).label }}
|
||
</v-chip>
|
||
<v-chip
|
||
v-if="
|
||
(row.category === 'pdn_breach' || row.category === 'data_breach') && !row.rkn_notified
|
||
"
|
||
color="error"
|
||
size="x-small"
|
||
variant="flat"
|
||
class="ml-2"
|
||
>
|
||
РКН pending
|
||
</v-chip>
|
||
</div>
|
||
<div class="font-weight-medium mt-1">{{ row.title }}</div>
|
||
<div class="text-caption text-medium-emphasis mt-1">
|
||
Категория: {{ categoryLabel(row.category) }} · Затронуто тенантов: {{ row.affected_tenants }} ·
|
||
Обнаружен: {{ formatDate(row.detected_at) }}
|
||
<span v-if="row.rkn_deadline_at"> · Дедлайн РКН: {{ formatDate(row.rkn_deadline_at) }}</span>
|
||
</div>
|
||
</v-list-item>
|
||
</v-list>
|
||
</v-card>
|
||
</v-container>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.admin-incidents {
|
||
max-width: 1200px;
|
||
}
|
||
.page-title {
|
||
font-variation-settings: 'opsz' 28;
|
||
letter-spacing: -0.018em;
|
||
}
|
||
.incident-row {
|
||
padding-block: 12px;
|
||
border-bottom: 1px solid #e1eeea;
|
||
}
|
||
.incident-row:last-child {
|
||
border-bottom: none;
|
||
}
|
||
.incident-header {
|
||
display: flex;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
}
|
||
.font-mono {
|
||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||
}
|
||
</style>
|