Files
portal/app/resources/js/views/admin/AdminIncidentsView.vue
T
Дмитрий 5f209a2fcc fix(ui): косметика UI-аудита — даты дд.мм.гггг, инлайн-валидация, формат денег, aria, тосты, статус-метки, админка
Раунд 2 минор-фиксы (Playwright-аудит):
- RuDateField (новый): даты дд.мм.гггг через ru date-picker вместо нативного
  <input type=date> (показывал мм/дд/гггг на en-локали) — Отчёты + Сделки.
- BalanceCapacityIndicator: разделитель тысяч «1 000 ₽», эмодзи→mdi.
- dealsApiMapper/DealDetailBody: статус-смена в активности русскими метками
  (было «viewed → new» сырыми слагами).
- ProfileTab: инлайн-валидация Имя/Фамилия (под полем, как в Реквизитах).
- RequisitesTab: проверка формата телефона на клиенте.
- ApiTab: eye-toggle с aria-label (показать/скрыть ключ и секрет).
- DashboardView: «3 / 0» → скрываем «/ N» и «лимит тарифа» при лимите 0.
- KanbanView: тост-подтверждение при смене статуса (+ цветной фейл-тост).
- NotificationsTab: убран жаргон «users.notification_preferences в БД».
- Админка: TenantsTable «ИНН не указан» вместо пустого «ИНН »; PricingTiers
  epoch-дата «1970»→«начала» + ru-формат цены; Incidents empty-state «Инцидентов
  нет»; SupplierIntegration/PdSubjectRequests — window.confirm/alert → v-dialog/snackbar.

Верификация: type-check, build, Playwright (даты дд.мм.гггг подтверждены).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 17:08:51 +03:00

265 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-ФЗ).
*
* 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">
<div
v-if="filteredRows.length === 0"
class="pa-6 text-center text-body-2 text-medium-emphasis"
data-testid="incidents-empty"
>
Инцидентов нет.
</div>
<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>