Files
portal/app/resources/js/views/admin/AdminIncidentsView.vue
T

258 lines
9.8 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-ФЗ).
*
* Display + фильтр по статусу/severity. Данные с backend GET /api/admin/incidents.
*/
import { computed, onMounted, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
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 router = useRouter();
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 — наполняется через loadIncidents (API).
const rowsState = reactive<IncidentRow[]>([]);
const stats = reactive({ open: 0, investigating: 0, rkn_pending: 0 });
const loading = ref(false);
const fetchError = ref(false);
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"
>
Не удалось загрузить инциденты. Попробуйте обновить.
</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"
:data-testid="`incident-row-${row.id}`"
style="cursor: pointer"
@click="router.push({ name: 'admin-incident-detail', params: { id: row.id } })"
>
<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>