Files
portal/app/resources/js/views/admin/AdminIncidentsView.vue
T
Дмитрий 01c20e7b6c phase2(polling): usePolling composable 30 сек + Page Visibility pause
Закрывает последний 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>
2026-05-09 10:17:51 +03:00

270 lines
10 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>