300 lines
12 KiB
Vue
300 lines
12 KiB
Vue
<script setup lang="ts">
|
||
/**
|
||
* Карточка инцидента (drill-down из AdminIncidentsView).
|
||
*
|
||
* Sprint 3D G5/G6: детальный просмотр инцидента + кнопка «Уведомить РКН»
|
||
* (152-ФЗ — обязательное уведомление РКН для data_breach за 24ч).
|
||
*
|
||
* Маршрут: /admin/incidents/:id
|
||
*/
|
||
import { computed, onMounted, ref, watch } from 'vue';
|
||
import { useRoute, useRouter } from 'vue-router';
|
||
import { getAdminIncidentDetail, notifyIncidentRkn } from '../../api/admin';
|
||
import type { ApiAdminIncidentDetail } from '../../api/admin';
|
||
import { extractErrorMessage } from '../../api/client';
|
||
|
||
const route = useRoute();
|
||
const router = useRouter();
|
||
|
||
const id = computed(() => Number(route.params.id));
|
||
|
||
const incident = ref<ApiAdminIncidentDetail | null>(null);
|
||
const loading = ref(false);
|
||
const notFound = ref(false);
|
||
const fetchError = ref<string | null>(null);
|
||
const rknError = ref('');
|
||
const rknLoading = ref(false);
|
||
const rknDialog = ref(false);
|
||
|
||
async function loadIncident(): Promise<void> {
|
||
loading.value = true;
|
||
fetchError.value = null;
|
||
notFound.value = false;
|
||
try {
|
||
incident.value = await getAdminIncidentDetail(id.value);
|
||
} catch (e: unknown) {
|
||
const status = (e as { response?: { status?: number } })?.response?.status;
|
||
if (status === 404) {
|
||
notFound.value = true;
|
||
incident.value = null;
|
||
} else {
|
||
fetchError.value = extractErrorMessage(e);
|
||
}
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
onMounted(() => void loadIncident());
|
||
watch(id, () => void loadIncident());
|
||
|
||
async function confirmRkn(): Promise<void> {
|
||
rknLoading.value = true;
|
||
rknError.value = '';
|
||
try {
|
||
incident.value = await notifyIncidentRkn(id.value);
|
||
rknDialog.value = false;
|
||
} catch (e: unknown) {
|
||
rknError.value = extractErrorMessage(e);
|
||
// dialog stays open so error is visible
|
||
} finally {
|
||
rknLoading.value = false;
|
||
}
|
||
}
|
||
|
||
function goBack(): void {
|
||
void router.push({ name: 'admin-incidents' });
|
||
}
|
||
|
||
// Helpers (copied from AdminIncidentsView for self-containment)
|
||
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' };
|
||
}
|
||
|
||
function formatDate(iso: string | null): string {
|
||
if (!iso) return '—';
|
||
return new Date(iso).toLocaleString('ru-RU', {
|
||
day: '2-digit',
|
||
month: '2-digit',
|
||
year: 'numeric',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
});
|
||
}
|
||
|
||
defineExpose({
|
||
incident,
|
||
loading,
|
||
notFound,
|
||
fetchError,
|
||
rknError,
|
||
rknLoading,
|
||
rknDialog,
|
||
loadIncident,
|
||
confirmRkn,
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<!-- Loading -->
|
||
<v-container v-if="loading" fluid class="pa-6" data-testid="incident-loading">
|
||
<v-progress-circular indeterminate color="primary" />
|
||
<span class="ml-3 text-medium-emphasis">Загрузка…</span>
|
||
</v-container>
|
||
|
||
<!-- Not found -->
|
||
<v-container v-else-if="notFound" fluid class="pa-6" data-testid="incident-not-found">
|
||
<v-alert type="error" variant="tonal" class="mb-4">
|
||
Инцидент <strong>#{{ id }}</strong> не найден.
|
||
</v-alert>
|
||
<v-btn variant="outlined" prepend-icon="mdi-arrow-left" @click="goBack">К списку инцидентов</v-btn>
|
||
</v-container>
|
||
|
||
<!-- Fetch error -->
|
||
<v-container v-else-if="fetchError" fluid class="pa-6" data-testid="incident-fetch-error">
|
||
<v-alert type="warning" variant="tonal" class="mb-4">
|
||
Не удалось загрузить инцидент: {{ fetchError }}
|
||
</v-alert>
|
||
<div class="d-flex ga-2">
|
||
<v-btn variant="outlined" prepend-icon="mdi-refresh" @click="loadIncident">Повторить</v-btn>
|
||
<v-btn variant="text" prepend-icon="mdi-arrow-left" @click="goBack">К списку</v-btn>
|
||
</div>
|
||
</v-container>
|
||
|
||
<!-- Content -->
|
||
<v-container v-else-if="incident" fluid class="incident-detail pa-6">
|
||
<!-- Header -->
|
||
<header class="d-flex justify-space-between align-start mb-4 flex-wrap ga-2">
|
||
<div>
|
||
<div class="d-flex align-center ga-2 mb-1">
|
||
<span class="font-mono text-caption text-medium-emphasis">{{ incident.incident_id }}</span>
|
||
<v-chip :color="severityInfo(incident.severity).color" size="x-small" variant="tonal">
|
||
{{ severityInfo(incident.severity).label }}
|
||
</v-chip>
|
||
<v-chip :color="statusInfo(incident.status).color" size="x-small" variant="tonal">
|
||
{{ statusInfo(incident.status).label }}
|
||
</v-chip>
|
||
</div>
|
||
<h1 class="text-h5 font-weight-medium">{{ incident.summary }}</h1>
|
||
</div>
|
||
<v-btn variant="outlined" prepend-icon="mdi-arrow-left" @click="goBack">Назад</v-btn>
|
||
</header>
|
||
|
||
<v-row>
|
||
<!-- Main details -->
|
||
<v-col cols="12" md="8">
|
||
<v-card variant="outlined" class="pa-4 mb-4">
|
||
<h2 class="text-h6 mb-3">Детали инцидента</h2>
|
||
|
||
<div v-if="incident.root_cause" class="mb-3">
|
||
<div class="text-caption text-medium-emphasis">Корневая причина</div>
|
||
<div>{{ incident.root_cause }}</div>
|
||
</div>
|
||
|
||
<div v-if="incident.postmortem_url" class="mb-3">
|
||
<div class="text-caption text-medium-emphasis">Postmortem</div>
|
||
<a :href="incident.postmortem_url" target="_blank" rel="noopener">
|
||
{{ incident.postmortem_url }}
|
||
</a>
|
||
</div>
|
||
|
||
<v-divider class="my-3" />
|
||
|
||
<v-row dense>
|
||
<v-col cols="6">
|
||
<div class="text-caption text-medium-emphasis">Начался</div>
|
||
<div>{{ formatDate(incident.started_at) }}</div>
|
||
</v-col>
|
||
<v-col cols="6">
|
||
<div class="text-caption text-medium-emphasis">Обнаружен</div>
|
||
<div>{{ formatDate(incident.detected_at) }}</div>
|
||
</v-col>
|
||
<v-col cols="6" class="mt-2">
|
||
<div class="text-caption text-medium-emphasis">Решён</div>
|
||
<div>{{ formatDate(incident.resolved_at) }}</div>
|
||
</v-col>
|
||
<v-col v-if="incident.affected_users_count !== null" cols="6" class="mt-2">
|
||
<div class="text-caption text-medium-emphasis">Затронуто пользователей</div>
|
||
<div>{{ incident.affected_users_count }}</div>
|
||
</v-col>
|
||
</v-row>
|
||
</v-card>
|
||
|
||
<!-- Affected tenants -->
|
||
<v-card variant="outlined" class="pa-4 mb-4">
|
||
<h2 class="text-h6 mb-3">Затронутые тенанты ({{ incident.affected_tenants.length }})</h2>
|
||
<div v-if="incident.affected_tenants.length === 0" class="text-medium-emphasis text-body-2">
|
||
Нет данных
|
||
</div>
|
||
<v-list v-else density="compact">
|
||
<v-list-item
|
||
v-for="t in incident.affected_tenants"
|
||
:key="t.id"
|
||
:title="t.organization_name"
|
||
:subtitle="`ID: ${t.id}`"
|
||
/>
|
||
</v-list>
|
||
</v-card>
|
||
</v-col>
|
||
|
||
<!-- РКН section -->
|
||
<v-col cols="12" md="4">
|
||
<v-card v-if="incident.type === 'data_breach'" variant="outlined" class="pa-4 mb-4">
|
||
<h2 class="text-h6 mb-3">Уведомление РКН (152-ФЗ)</h2>
|
||
|
||
<div v-if="incident.rkn_notified">
|
||
<v-icon color="success" class="mr-1">mdi-check-circle</v-icon>
|
||
РКН уведомлён {{ formatDate(incident.rkn_notified_at) }}
|
||
</div>
|
||
|
||
<template v-else>
|
||
<div v-if="incident.rkn_deadline_at" class="mb-3">
|
||
<div class="text-caption text-medium-emphasis">Дедлайн</div>
|
||
<div class="text-error font-weight-medium">{{ formatDate(incident.rkn_deadline_at) }}</div>
|
||
</div>
|
||
|
||
<v-btn
|
||
data-testid="rkn-notify-btn"
|
||
color="error"
|
||
:loading="rknLoading"
|
||
block
|
||
@click="rknDialog = true"
|
||
>
|
||
Уведомить РКН
|
||
</v-btn>
|
||
</template>
|
||
|
||
<v-alert
|
||
v-if="rknError"
|
||
type="error"
|
||
variant="tonal"
|
||
density="compact"
|
||
class="mt-3"
|
||
data-testid="rkn-error"
|
||
>
|
||
{{ rknError }}
|
||
</v-alert>
|
||
</v-card>
|
||
|
||
<!-- Admin meta -->
|
||
<v-card variant="outlined" class="pa-4">
|
||
<h2 class="text-h6 mb-3">Служебная информация</h2>
|
||
<div v-if="incident.created_by_admin" class="mb-2">
|
||
<div class="text-caption text-medium-emphasis">Создал</div>
|
||
<div>{{ incident.created_by_admin }}</div>
|
||
</div>
|
||
<div v-if="incident.closed_by_admin" class="mb-2">
|
||
<div class="text-caption text-medium-emphasis">Закрыл</div>
|
||
<div>{{ incident.closed_by_admin }}</div>
|
||
</div>
|
||
<div v-if="incident.created_at">
|
||
<div class="text-caption text-medium-emphasis">Создан</div>
|
||
<div>{{ formatDate(incident.created_at) }}</div>
|
||
</div>
|
||
</v-card>
|
||
</v-col>
|
||
</v-row>
|
||
|
||
<!-- РКН confirm dialog -->
|
||
<v-dialog v-model="rknDialog" max-width="480">
|
||
<v-card>
|
||
<v-card-title class="text-h6">Подтверждение уведомления РКН</v-card-title>
|
||
<v-card-text>
|
||
Это юридически значимое действие. После подтверждения будет зафиксировано время уведомления
|
||
регулятора (152-ФЗ). Продолжить?
|
||
</v-card-text>
|
||
<v-card-actions>
|
||
<v-spacer />
|
||
<v-btn variant="text" @click="rknDialog = false">Отмена</v-btn>
|
||
<v-btn color="error" :loading="rknLoading" @click="confirmRkn">Подтвердить</v-btn>
|
||
</v-card-actions>
|
||
</v-card>
|
||
</v-dialog>
|
||
</v-container>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.incident-detail {
|
||
max-width: 1200px;
|
||
}
|
||
.font-mono {
|
||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||
}
|
||
</style>
|