639 lines
34 KiB
TypeScript
639 lines
34 KiB
TypeScript
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>
|
||
);
|
||
};
|