krona-front/src/features/control-center/components/InspectorDrawer.tsx

639 lines
34 KiB
TypeScript
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.

import React, { useState } from 'react';
import {
X,
Smartphone,
ShieldAlert,
Clock,
Activity,
} from 'lucide-react';
import { useKccStore, useOperatorRole } from '../store/kccStore';
import { StatusBadge } from './StatusBadge';
import { MoneyDisplay } from './MoneyDisplay';
import { HeartbeatTimeline } from './HeartbeatTimeline';
import { PlatformStatus } from '../contracts';
export const InspectorDrawer: React.FC = () => {
const inspectorTarget = useKccStore((state) => state.inspectorTarget);
const closeInspector = useKccStore((state) => state.closeInspector);
const { permissions } = useOperatorRole();
const organizations = useKccStore((state) => state.organizations);
const servers = useKccStore((state) => state.servers);
const incidents = useKccStore((state) => state.incidents);
const modules = useKccStore((state) => state.modules);
const installations = useKccStore((state) => state.installations);
const subscriptions = useKccStore((state) => state.subscriptions);
const instances = useKccStore((state) => state.instances);
const auditLog = useKccStore((state) => state.auditLog);
const openConfirmModal = useKccStore((state) => state.openConfirmModal);
const updateOrganizationStatus = useKccStore((state) => state.updateOrganizationStatus);
const grantModuleTrial = useKccStore((state) => state.grantModuleTrial);
const resolveIncident = useKccStore((state) => state.resolveIncident);
const requestServerDiagnostics = useKccStore((state) => state.requestServerDiagnostics);
const scheduleServerMaintenance = useKccStore((state) => state.scheduleServerMaintenance);
const [orgActiveTab, setOrgActiveTab] = useState<
'overview' | 'users' | 'modules' | 'installations' | 'billing' | 'incidents' | 'audit'
>('overview');
const [selectedTrialModule, setSelectedTrialModule] = useState('krona-omnichannel');
const [trialDurationDays, setTrialDurationDays] = useState(14);
if (!inspectorTarget) return null;
const renderContent = () => {
switch (inspectorTarget.type) {
case 'organization': {
const org = organizations.find((o) => o.tenantId === inspectorTarget.id);
if (!org) return <div className="p-6 text-zinc-400">Организация не найдена</div>;
const sub = subscriptions.find((s) => s.tenantId === org.tenantId);
const orgInstances = instances.filter((i) => i.tenantId === org.tenantId);
const orgInstalls = installations.filter((i) => i.tenantId === org.tenantId);
const orgIncidents = incidents.filter((i) => i.affectedTenantIds.includes(org.tenantId));
const orgAudits = auditLog.filter((a) => a.target.tenantId === org.tenantId || a.target.id === org.tenantId);
const handleStatusChange = (newStatus: PlatformStatus) => {
openConfirmModal({
title: `Смена статуса организации: ${org.displayName}`,
actionName: `organization.status_changed -> ${newStatus}`,
impactSummary: `Доступ всех ${org.userCount} пользователей к платформе будет переведен в состояние «${newStatus}».`,
targetType: 'organization',
targetId: org.tenantId,
targetName: org.displayName,
onConfirm: async (reason) => {
await updateOrganizationStatus(org.tenantId, newStatus, reason);
},
});
};
const handleGrantTrial = () => {
const mod = modules.find((m) => m.moduleId === selectedTrialModule);
openConfirmModal({
title: `Выдача Trial: ${mod?.name || selectedTrialModule}`,
actionName: `entitlement.grant_trial (${selectedTrialModule})`,
impactSummary: `Организации будет предоставлен временный доступ к модулю ${mod?.name} на ${trialDurationDays} дней без списания средств.`,
targetType: 'organization',
targetId: org.tenantId,
targetName: org.displayName,
onConfirm: async (reason) => {
await grantModuleTrial(org.tenantId, selectedTrialModule, trialDurationDays, reason);
},
});
};
return (
<div className="space-y-6">
{/* Header */}
<div>
<div className="flex items-center gap-2 mb-1.5">
<StatusBadge status={org.platformStatus} />
<span className="text-xs text-zinc-400 font-mono">ИНН: {org.inn}</span>
</div>
<h2 className="text-lg font-bold text-zinc-100">{org.displayName}</h2>
<p className="text-xs text-zinc-400 font-mono">{org.domain} • ID: {org.tenantId.slice(0, 8)}...</p>
</div>
{/* Operator Actions Bar */}
<div className="rounded-xl bg-zinc-950/70 border border-zinc-800 p-3.5 space-y-3">
<div className="text-xs font-semibold text-zinc-300 flex items-center gap-1.5">
<ShieldAlert className="w-4 h-4 text-amber-400" />
<span>Действия оператора платформы</span>
</div>
<div className="flex flex-wrap gap-2">
{org.platformStatus !== 'active' && (
<button
disabled={!permissions.canManagePlatformStatus}
onClick={() => handleStatusChange('active')}
className="px-2.5 py-1.5 rounded-lg text-xs font-medium bg-emerald-950/70 text-emerald-300 border border-emerald-700/50 hover:bg-emerald-900/50 disabled:opacity-40 transition-colors"
>
Активировать (Active)
</button>
)}
{org.platformStatus !== 'grace' && (
<button
disabled={!permissions.canGrantTrialGrace}
onClick={() => handleStatusChange('grace')}
className="px-2.5 py-1.5 rounded-lg text-xs font-medium bg-orange-950/70 text-orange-300 border border-orange-700/50 hover:bg-orange-900/50 disabled:opacity-40 transition-colors"
>
Назначить Grace
</button>
)}
{org.platformStatus !== 'suspended' && (
<button
disabled={!permissions.canManagePlatformStatus}
onClick={() => handleStatusChange('suspended')}
className="px-2.5 py-1.5 rounded-lg text-xs font-medium bg-rose-950/70 text-rose-300 border border-rose-700/50 hover:bg-rose-900/50 disabled:opacity-40 transition-colors"
>
Заблокировать (Suspend)
</button>
)}
</div>
{/* Trial grant inline form */}
{permissions.canGrantTrialGrace && (
<div className="pt-2 border-t border-zinc-800/80 space-y-2 text-xs">
<div className="font-semibold text-zinc-300">Выдать Trial на модуль:</div>
<div className="flex gap-2 items-center">
<select
value={selectedTrialModule}
onChange={(e) => setSelectedTrialModule(e.target.value)}
className="bg-zinc-900 border border-zinc-700 rounded-md px-2 py-1 text-zinc-200 text-xs focus:outline-hidden"
>
{modules.map((m) => (
<option key={m.moduleId} value={m.moduleId}>
{m.name}
</option>
))}
</select>
<select
value={trialDurationDays}
onChange={(e) => setTrialDurationDays(Number(e.target.value))}
className="bg-zinc-900 border border-zinc-700 rounded-md px-2 py-1 text-zinc-200 text-xs focus:outline-hidden"
>
<option value={7}>7 дней</option>
<option value={14}>14 дней</option>
<option value={30}>30 дней</option>
</select>
<button
type="button"
onClick={handleGrantTrial}
className="px-3 py-1 rounded-md bg-amber-500 hover:bg-amber-400 text-zinc-950 font-bold text-xs transition-colors shrink-0"
>
Выдать Trial
</button>
</div>
</div>
)}
</div>
{/* Navigation Tabs */}
<div className="flex items-center gap-1 border-b border-zinc-800 overflow-x-auto pb-1 text-xs">
{[
{ id: 'overview', label: 'Обзор' },
{ id: 'users', label: `Пользователи (${org.userCount})` },
{ id: 'modules', label: `Модули (${orgInstances.length})` },
{ id: 'installations', label: `Установки (${orgInstalls.length})` },
{ id: 'billing', label: 'Платежи' },
{ id: 'incidents', label: `Инциденты (${orgIncidents.length})` },
{ id: 'audit', label: 'Аудит' },
].map((tab) => (
<button
key={tab.id}
onClick={() => setOrgActiveTab(tab.id as typeof orgActiveTab)}
className={`px-3 py-1.5 rounded-t-md font-medium whitespace-nowrap transition-colors ${
orgActiveTab === tab.id
? 'bg-zinc-800/80 text-amber-300 border-b-2 border-amber-400'
: 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-900'
}`}
>
{tab.label}
</button>
))}
</div>
{/* Tab Contents */}
{orgActiveTab === 'overview' && (
<div className="space-y-4 text-xs">
<div className="grid grid-cols-2 gap-3">
<div className="p-3 rounded-lg bg-zinc-950/60 border border-zinc-800/80">
<div className="text-zinc-400 mb-1">Тарифный план</div>
<div className="font-semibold text-zinc-200 text-sm">{org.tariffPlan}</div>
</div>
<div className="p-3 rounded-lg bg-zinc-950/60 border border-zinc-800/80">
<div className="text-zinc-400 mb-1">MRR доход</div>
<MoneyDisplay minorUnits={org.mrrMinorUnits} className="text-sm font-bold text-amber-400" />
</div>
<div className="p-3 rounded-lg bg-zinc-950/60 border border-zinc-800/80">
<div className="text-zinc-400 mb-1">Филиалов / точек</div>
<div className="font-semibold text-zinc-200 text-sm">{org.branchCount}</div>
</div>
<div className="p-3 rounded-lg bg-zinc-950/60 border border-zinc-800/80">
<div className="text-zinc-400 mb-1">Задолженность</div>
<MoneyDisplay
minorUnits={org.outstandingDebtMinorUnits}
className={`text-sm font-bold ${org.outstandingDebtMinorUnits > 0 ? 'text-rose-400' : 'text-emerald-400'}`}
/>
</div>
</div>
<div className="rounded-lg bg-zinc-950/60 border border-zinc-800 p-3 space-y-2">
<div className="font-semibold text-zinc-300">Контакты организации:</div>
<div className="text-zinc-400">Email: <span className="text-zinc-200">{org.contactEmail}</span></div>
<div className="text-zinc-400">Телефон: <span className="text-zinc-200">{org.contactPhone}</span></div>
<div className="text-zinc-400">Последняя активность: <span className="text-zinc-200">{new Date(org.lastActiveAt).toLocaleString('ru-RU')}</span></div>
<div className="text-zinc-400">Окончание подписки: <span className="text-zinc-200">{new Date(org.subscriptionEndAt).toLocaleDateString('ru-RU')}</span></div>
</div>
</div>
)}
{orgActiveTab === 'users' && (
<div className="space-y-3 text-xs">
<div className="p-3 rounded-lg bg-zinc-950/60 border border-zinc-800">
<div className="flex justify-between items-center mb-2">
<span className="font-semibold text-zinc-200">Пользователи-агрегаты</span>
<span className="text-amber-400 font-bold">{org.userCount} учетных записей</span>
</div>
<p className="text-zinc-400 text-[11px] leading-relaxed">
В соответствии с контрактом безопасности Control Center отображает только деперсонализированные
агрегаты тенанта. Персональные данные сотрудников защищены контуром организации.
</p>
</div>
<div className="grid grid-cols-2 gap-2 text-zinc-300">
<div className="p-2.5 rounded-lg bg-zinc-900 border border-zinc-800">
<div className="text-zinc-400">Администраторы</div>
<div className="font-bold text-sm text-zinc-100">2</div>
</div>
<div className="p-2.5 rounded-lg bg-zinc-900 border border-zinc-800">
<div className="text-zinc-400">Менеджеры / операторы</div>
<div className="font-bold text-sm text-zinc-100">{Math.max(0, org.userCount - 6)}</div>
</div>
</div>
</div>
)}
{orgActiveTab === 'modules' && (
<div className="space-y-2 text-xs">
{orgInstances.map((inst) => (
<div key={inst.moduleInstanceId} className="p-3 rounded-lg bg-zinc-950/60 border border-zinc-800 flex items-center justify-between">
<div>
<div className="font-semibold text-zinc-200">{inst.moduleName}</div>
<div className="text-[11px] text-zinc-400 font-mono mt-0.5">
v{inst.currentVersion} • {inst.serverName}
</div>
</div>
<StatusBadge status={inst.health} size="sm" />
</div>
))}
</div>
)}
{orgActiveTab === 'installations' && (
<div className="space-y-2 text-xs">
{orgInstalls.map((ins) => (
<div key={ins.installationId} className="p-3 rounded-lg bg-zinc-950/60 border border-zinc-800 flex items-center justify-between">
<div>
<div className="font-semibold text-zinc-200 flex items-center gap-1.5">
<Smartphone className="w-3.5 h-3.5 text-zinc-400" />
<span>{ins.deviceName}</span>
</div>
<div className="text-[11px] text-zinc-400 font-mono mt-0.5">
{ins.kind.toUpperCase()} • v{ins.appVersion} • {ins.osVersion}
</div>
</div>
<StatusBadge status={ins.status} size="sm" />
</div>
))}
{orgInstalls.length === 0 && <div className="text-zinc-500 py-4 text-center">Нет активных установок</div>}
</div>
)}
{orgActiveTab === 'billing' && (
<div className="space-y-3 text-xs">
{sub ? (
<div className="p-3 rounded-lg bg-zinc-950/60 border border-zinc-800 space-y-2">
<div className="flex justify-between items-center">
<span className="font-semibold text-zinc-200">Тарифная подписка</span>
<StatusBadge status={sub.status} size="sm" />
</div>
<div className="flex justify-between text-zinc-400">
<span>Период:</span>
<span className="text-zinc-200">{sub.period === 'year' ? 'Годовой' : 'Ежемесячный'}</span>
</div>
<div className="flex justify-between text-zinc-400">
<span>Сумма тарифа:</span>
<MoneyDisplay minorUnits={sub.amountMinorUnits} className="text-amber-400 font-bold" />
</div>
<div className="flex justify-between text-zinc-400">
<span>Действует до:</span>
<span className="text-zinc-200">{new Date(sub.validUntil).toLocaleDateString('ru-RU')}</span>
</div>
</div>
) : (
<div className="text-zinc-500 py-4 text-center">Данные подписки отсутствуют</div>
)}
</div>
)}
{orgActiveTab === 'incidents' && (
<div className="space-y-2 text-xs">
{orgIncidents.map((inc) => (
<div key={inc.incidentId} className="p-3 rounded-lg bg-zinc-950/60 border border-zinc-800 space-y-1.5">
<div className="flex justify-between items-center">
<span className="font-bold text-zinc-200 font-mono">{inc.incidentNumber}</span>
<StatusBadge status={inc.status} size="sm" />
</div>
<p className="text-zinc-300">{inc.summary}</p>
</div>
))}
{orgIncidents.length === 0 && <div className="text-zinc-500 py-4 text-center">Инцидентов нет</div>}
</div>
)}
{orgActiveTab === 'audit' && (
<div className="space-y-2 text-xs">
{orgAudits.map((a) => (
<div key={a.eventId} className="p-2.5 rounded-lg bg-zinc-950/60 border border-zinc-800 space-y-1">
<div className="flex justify-between text-zinc-400 font-mono text-[10px]">
<span>{a.action}</span>
<span>{new Date(a.occurredAt).toLocaleTimeString('ru-RU')}</span>
</div>
<p className="text-zinc-200 text-[11px]">{a.reason}</p>
<div className="text-[10px] text-amber-400/80">{a.operatorName} ({a.operatorRole})</div>
</div>
))}
{orgAudits.length === 0 && <div className="text-zinc-500 py-4 text-center">Записей аудита нет</div>}
</div>
)}
</div>
);
}
case 'server': {
const srv = servers.find((s) => s.serverId === inspectorTarget.id);
if (!srv) return <div className="p-6 text-zinc-400">Сервер не найден</div>;
const srvInstances = instances.filter((i) => i.serverId === srv.serverId);
const handleDiagnostics = () => {
openConfirmModal({
title: `Запрос диагностики: ${srv.name}`,
actionName: 'infrastructure.request_diagnostics',
impactSummary: `На узел ${srv.ipAddress} будет отправлен запрос сбора расширенного дампа метрик, журнала dmesg и состояния процессов без раскрытия секретов.`,
targetType: 'infrastructure',
targetId: srv.serverId,
targetName: srv.name,
onConfirm: async (reason) => {
await requestServerDiagnostics(srv.serverId, reason);
},
});
};
const handleMaintenance = () => {
openConfirmModal({
title: `Запланировать ТО: ${srv.name}`,
actionName: 'infrastructure.schedule_maintenance',
impactSummary: `Узел ${srv.name} будет помечен как обслуживаемый. Маршрутизация трафика будет плавно перенаправлена на резервные ноды.`,
targetType: 'infrastructure',
targetId: srv.serverId,
targetName: srv.name,
onConfirm: async (reason) => {
await scheduleServerMaintenance(srv.serverId, reason);
},
});
};
return (
<div className="space-y-6 text-xs">
<div>
<div className="flex items-center gap-2 mb-1.5">
<StatusBadge status={srv.status} />
<span className="text-xs text-zinc-400 font-mono">{srv.hostingProvider} • {srv.region}</span>
</div>
<h2 className="text-lg font-bold text-zinc-100">{srv.name}</h2>
<p className="text-xs text-zinc-400 font-mono">{srv.ipAddress} • {srv.hostname}</p>
</div>
{/* Operator Actions */}
<div className="rounded-xl bg-zinc-950/70 border border-zinc-800 p-3.5 space-y-2.5">
<div className="text-xs font-semibold text-zinc-300 flex items-center gap-1.5">
<ShieldAlert className="w-4 h-4 text-amber-400" />
<span>Операции с инфраструктурным узлом</span>
</div>
<div className="flex gap-2">
<button
disabled={!permissions.canRequestDiagnostics}
onClick={handleDiagnostics}
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-zinc-800 text-zinc-200 hover:bg-zinc-700 disabled:opacity-40 transition-colors flex items-center gap-1.5"
>
<Activity className="w-3.5 h-3.5 text-sky-400" />
<span>Запросить диагностику</span>
</button>
<button
disabled={!permissions.canScheduleMaintenance}
onClick={handleMaintenance}
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-amber-950/60 text-amber-300 border border-amber-700/50 hover:bg-amber-900/40 disabled:opacity-40 transition-colors flex items-center gap-1.5"
>
<Clock className="w-3.5 h-3.5 text-amber-400" />
<span>Запланировать ТО</span>
</button>
</div>
</div>
{/* Telemetry Metrics */}
<div className="space-y-3">
<div className="font-semibold text-zinc-300 flex items-center justify-between">
<span>Телеметрия узла (Server Monitor Agent):</span>
<span className="text-[11px] text-zinc-400 font-mono">Agent v{srv.agentVersion} ({srv.agentStatus})</span>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="p-3 rounded-lg bg-zinc-950 border border-zinc-800">
<div className="flex justify-between text-zinc-400 mb-1">
<span>CPU Нагрузка:</span>
<span className="font-bold text-zinc-100">{srv.cpuLoadPercent}%</span>
</div>
<div className="w-full bg-zinc-800 h-1.5 rounded-full overflow-hidden">
<div
className={`h-full ${srv.cpuLoadPercent > 80 ? 'bg-rose-500' : 'bg-emerald-500'}`}
style={{ width: `${srv.cpuLoadPercent}%` }}
/>
</div>
</div>
<div className="p-3 rounded-lg bg-zinc-950 border border-zinc-800">
<div className="flex justify-between text-zinc-400 mb-1">
<span>RAM Память:</span>
<span className="font-bold text-zinc-100">
{Math.round(srv.ramUsedBytes / (1024 * 1024 * 1024))} / {Math.round(srv.ramTotalBytes / (1024 * 1024 * 1024))} GB
</span>
</div>
<div className="w-full bg-zinc-800 h-1.5 rounded-full overflow-hidden">
<div
className="h-full bg-emerald-500"
style={{ width: `${(srv.ramUsedBytes / srv.ramTotalBytes) * 100}%` }}
/>
</div>
</div>
<div className="p-3 rounded-lg bg-zinc-950 border border-zinc-800">
<div className="flex justify-between text-zinc-400 mb-1">
<span>Диск:</span>
<span className="font-bold text-zinc-100">
{Math.round(srv.diskUsedBytes / (1024 * 1024 * 1024))} / {Math.round(srv.diskTotalBytes / (1024 * 1024 * 1024))} GB
</span>
</div>
<div className="w-full bg-zinc-800 h-1.5 rounded-full overflow-hidden">
<div
className={`h-full ${srv.diskUsedBytes / srv.diskTotalBytes > 0.85 ? 'bg-rose-500' : 'bg-amber-400'}`}
style={{ width: `${(srv.diskUsedBytes / srv.diskTotalBytes) * 100}%` }}
/>
</div>
</div>
<div className="p-3 rounded-lg bg-zinc-950 border border-zinc-800">
<div className="text-zinc-400 mb-1">Сеть (Rx / Tx):</div>
<div className="font-mono text-zinc-200">
{(srv.networkRxBytesPerSec / 1024 / 1024).toFixed(1)} / {(srv.networkTxBytesPerSec / 1024 / 1024).toFixed(1)} MB/s
</div>
</div>
</div>
{/* Heartbeat timeline */}
<div className="p-3 rounded-lg bg-zinc-950 border border-zinc-800 space-y-2">
<div className="flex justify-between items-center">
<span className="font-semibold text-zinc-300">Пульс узла (Heartbeat 24h):</span>
<span className="text-[11px] text-zinc-400 font-mono">RTT: {srv.latencyMs}ms</span>
</div>
<HeartbeatTimeline status={srv.status} count={24} />
</div>
{/* Backup & Restore Drill */}
<div className="grid grid-cols-2 gap-3">
<div className="p-3 rounded-lg bg-zinc-950 border border-zinc-800 space-y-1">
<div className="font-semibold text-zinc-300 flex items-center justify-between">
<span>Бэкап:</span>
<StatusBadge status={srv.backup.state === 'ok' ? 'healthy' : srv.backup.state === 'failed' ? 'critical' : 'warning'} size="sm" customLabel={srv.backup.state.toUpperCase()} />
</div>
<div className="text-zinc-400 text-[11px]">
{srv.backup.lastSuccessfulAt ? new Date(srv.backup.lastSuccessfulAt).toLocaleString('ru-RU') : 'Нет успешных копий'}
</div>
<div className="text-zinc-500 text-[10px]">{srv.backup.targetProvider}</div>
</div>
<div className="p-3 rounded-lg bg-zinc-950 border border-zinc-800 space-y-1">
<div className="font-semibold text-zinc-300 flex items-center justify-between">
<span>Restore Drill:</span>
<StatusBadge status={srv.restoreDrill.state === 'passed' ? 'healthy' : srv.restoreDrill.state === 'failed' ? 'critical' : 'warning'} size="sm" customLabel={srv.restoreDrill.state.toUpperCase()} />
</div>
<div className="text-zinc-400 text-[11px]">
{srv.restoreDrill.verifiedAt ? new Date(srv.restoreDrill.verifiedAt).toLocaleDateString('ru-RU') : 'Никогда / Устарел'}
</div>
<div className="text-zinc-500 text-[10px]">
{srv.restoreDrill.drillDurationSeconds ? `Время восстановления: ${Math.round(srv.restoreDrill.drillDurationSeconds / 60)} мин.` : 'Тест не проводился'}
</div>
</div>
</div>
{/* Module instances on server */}
<div className="space-y-2">
<div className="font-semibold text-zinc-300">Модули на узле ({srvInstances.length}):</div>
{srvInstances.map((inst) => (
<div key={inst.moduleInstanceId} className="p-2.5 rounded-lg bg-zinc-950 border border-zinc-800 flex justify-between items-center">
<div>
<div className="font-semibold text-zinc-200">{inst.moduleName}</div>
<div className="text-zinc-400 text-[11px]">{inst.tenantName}</div>
</div>
<StatusBadge status={inst.health} size="sm" />
</div>
))}
</div>
</div>
</div>
);
}
case 'incident': {
const inc = incidents.find((i) => i.incidentId === inspectorTarget.id);
if (!inc) return <div className="p-6 text-zinc-400">Инцидент не найден</div>;
const handleResolve = () => {
openConfirmModal({
title: `Разрешение инцидента ${inc.incidentNumber}`,
actionName: 'incident.resolve',
impactSummary: `Инцидент будет переведен в статус resolved, дежурная смена будет оповещена об устранении причин.`,
targetType: 'incident',
targetId: inc.incidentId,
targetName: inc.incidentNumber,
onConfirm: async (reason) => {
await resolveIncident(inc.incidentId, reason);
},
});
};
return (
<div className="space-y-6 text-xs">
<div>
<div className="flex items-center gap-2 mb-1.5">
<StatusBadge status={inc.status} />
<span className="font-bold text-amber-400 uppercase tracking-wider">{inc.severity} Severity</span>
</div>
<h2 className="text-lg font-bold text-zinc-100">{inc.incidentNumber}</h2>
<p className="text-sm text-zinc-200 mt-1 font-medium">{inc.summary}</p>
</div>
{inc.status !== 'resolved' && (
<div className="p-3.5 rounded-xl bg-zinc-950 border border-zinc-800 flex justify-between items-center">
<div>
<div className="font-semibold text-zinc-200">Действие оператора:</div>
<div className="text-zinc-400 text-[11px]">Отметить инцидент как устраненный с обоснованием</div>
</div>
<button
disabled={!permissions.canManageIncidents}
onClick={handleResolve}
className="px-3 py-1.5 rounded-lg bg-emerald-600 hover:bg-emerald-500 text-zinc-950 font-bold text-xs transition-colors disabled:opacity-40"
>
Разрешить инцидент
</button>
</div>
)}
<div className="rounded-lg bg-zinc-950 p-3.5 border border-zinc-800 space-y-2">
<div className="font-semibold text-zinc-300">Описание инцидента:</div>
<p className="text-zinc-300 leading-relaxed">{inc.description}</p>
<div className="text-zinc-400 pt-2 border-t border-zinc-800">
Ответственный: <span className="text-zinc-200">{inc.ownerName}</span>
</div>
</div>
<div className="space-y-2">
<div className="font-semibold text-zinc-300">Хронология расследования (Timeline):</div>
<div className="space-y-2 relative border-l border-zinc-800 ml-2 pl-3">
{inc.timeline.map((step, idx) => (
<div key={idx} className="relative space-y-1">
<div className="absolute -left-[17px] top-1 w-2 h-2 rounded-full bg-amber-400" />
<div className="flex justify-between text-zinc-400 text-[10px]">
<span className="font-bold text-zinc-300">{step.author}</span>
<span>{new Date(step.timestamp).toLocaleTimeString('ru-RU')}</span>
</div>
<p className="text-zinc-300">{step.message}</p>
</div>
))}
</div>
</div>
</div>
);
}
default:
return <div className="p-6 text-zinc-400">Детальная информация для данного типа пока формируется</div>;
}
};
return (
<div
className="fixed inset-y-0 right-0 z-40 w-full max-w-xl bg-[#0d1e17] border-l border-zinc-800 shadow-2xl flex flex-col text-zinc-100 animate-in slide-in-from-right duration-200"
role="complementary"
aria-label="Контекстный инспектор"
>
{/* Drawer Header */}
<div className="px-6 py-4 border-b border-zinc-800 flex items-center justify-between bg-zinc-950/60">
<div className="flex items-center gap-2 text-xs font-semibold text-zinc-400 uppercase tracking-wider">
<Activity className="w-4 h-4 text-amber-400" />
<span>Контекстный инспектор</span>
</div>
<button
type="button"
onClick={closeInspector}
className="rounded-lg p-1.5 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200 transition-colors"
aria-label="Закрыть инспектор"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Drawer Body */}
<div className="flex-1 overflow-y-auto p-6">{renderContent()}</div>
</div>
);
};