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

300 lines
12 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">
/**
* Карточка инцидента (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>