582 lines
32 KiB
TypeScript
582 lines
32 KiB
TypeScript
import React, { useState, useMemo } from 'react';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import {
|
||
Building2,
|
||
Users,
|
||
CreditCard,
|
||
Smartphone,
|
||
Server,
|
||
ShieldAlert,
|
||
ArrowUpRight,
|
||
Activity,
|
||
AlertTriangle,
|
||
CheckCircle2,
|
||
Clock,
|
||
Layers,
|
||
HelpCircle,
|
||
Database,
|
||
} from 'lucide-react';
|
||
import { useKccStore, useOperatorRole } from '../store/kccStore';
|
||
import { MoneyDisplay } from '../components/MoneyDisplay';
|
||
import { StatusBadge } from '../components/StatusBadge';
|
||
import { HeartbeatTimeline } from '../components/HeartbeatTimeline';
|
||
import { RU_ROLES, RU_INCIDENT_SEVERITY, RU_INCIDENT_STATUS } from '../locales/ru';
|
||
|
||
type OverviewTab = 'attention' | 'metrics' | 'infrastructure';
|
||
|
||
export const OverviewPage: React.FC = () => {
|
||
const navigate = useNavigate();
|
||
const { role, operator } = useOperatorRole();
|
||
|
||
const organizations = useKccStore((state) => state.organizations);
|
||
const subscriptions = useKccStore((state) => state.subscriptions);
|
||
const installations = useKccStore((state) => state.installations);
|
||
const instances = useKccStore((state) => state.instances);
|
||
const servers = useKccStore((state) => state.servers);
|
||
const incidents = useKccStore((state) => state.incidents);
|
||
const openInspector = useKccStore((state) => state.openInspector);
|
||
const serverMonitorOutage = useKccStore((state) => state.serverMonitorOutage);
|
||
|
||
// Aggregates
|
||
const totalOrgs = organizations.length;
|
||
const activeOrgs = organizations.filter((o) => o.platformStatus === 'active').length;
|
||
const trialOrgs = organizations.filter((o) => o.platformStatus === 'trial').length;
|
||
const graceOrgs = organizations.filter((o) => o.platformStatus === 'grace').length;
|
||
const suspendedOrgs = organizations.filter((o) => o.platformStatus === 'suspended').length;
|
||
|
||
const totalUsers = organizations.reduce((sum, o) => sum + o.userCount, 0);
|
||
const totalBranches = organizations.reduce((sum, o) => sum + o.branchCount, 0);
|
||
const totalMrrMinorUnits = organizations.reduce((sum, o) => sum + o.mrrMinorUnits, 0);
|
||
|
||
const expiring7d = subscriptions.filter((s) => s.status === 'expiring_7d');
|
||
const expiring30d = subscriptions.filter((s) => s.status === 'expiring_30d');
|
||
const overdueSubs = subscriptions.filter((s) => s.status === 'overdue');
|
||
|
||
const webInstalls = installations.filter((i) => i.kind === 'web').length;
|
||
const desktopInstalls = installations.filter((i) => i.kind === 'desktop').length;
|
||
const androidInstalls = installations.filter((i) => i.kind === 'android').length;
|
||
const outdatedAndroid = installations.filter((i) => i.kind === 'android' && i.status === 'outdated').length;
|
||
|
||
const instancesOffline = instances.filter((i) => i.health === 'offline');
|
||
const instancesDegraded = instances.filter((i) => i.health === 'degraded');
|
||
const instancesHealthy = instances.filter((i) => i.health === 'healthy');
|
||
|
||
const serversHealthy = servers.filter((s) => s.status === 'healthy').length;
|
||
const serversProblematic = servers.filter((s) => s.status !== 'healthy');
|
||
const failedBackups = servers.filter((s) => s.backup.state === 'failed');
|
||
const openIncidents = incidents.filter((i) => i.status !== 'resolved');
|
||
|
||
// Total attention items count
|
||
const attentionCount = useMemo(() => {
|
||
let count = openIncidents.length;
|
||
count += serversProblematic.length;
|
||
count += instancesOffline.length;
|
||
count += instancesDegraded.length;
|
||
count += expiring7d.length;
|
||
count += overdueSubs.length;
|
||
if (serverMonitorOutage) count += 1;
|
||
return count;
|
||
}, [openIncidents, serversProblematic, instancesOffline, instancesDegraded, expiring7d, overdueSubs, serverMonitorOutage]);
|
||
|
||
// Default tab is 'attention' if there are problems, otherwise 'metrics'
|
||
const [activeTab, setActiveTab] = useState<OverviewTab>(() => (attentionCount > 0 ? 'attention' : 'metrics'));
|
||
|
||
return (
|
||
<div className="space-y-4 sm:space-y-6" data-testid="overview-page">
|
||
{/* Top Headline */}
|
||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2.5 border-b border-krona-border pb-4">
|
||
<div>
|
||
<h1 className="text-lg sm:text-xl font-bold text-krona-primary flex items-center gap-2 flex-wrap">
|
||
<span>Сводная панель платформы</span>
|
||
<span className="text-[11px] px-2.5 py-0.5 rounded-full bg-status-success-soft text-status-success-text border border-status-success-border font-medium">
|
||
Контур платформы
|
||
</span>
|
||
</h1>
|
||
<p className="text-xs text-krona-secondary mt-0.5">
|
||
Оперативный контроль организаций, лицензий, модульных инстансов и серверных узлов Krona
|
||
</p>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2 text-xs">
|
||
<span className="text-krona-tertiary">Оператор:</span>
|
||
<span className="font-semibold text-krona-gold bg-krona-gold/10 px-2.5 py-1 rounded-md border border-krona-gold/20">
|
||
{operator.name} ({RU_ROLES[role]?.shortLabel || role})
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Tab Switcher (Mobile-First Priority Controls) */}
|
||
<div className="flex items-center gap-1.5 p-1 bg-krona-surface border border-krona-border rounded-xl max-w-full overflow-x-auto">
|
||
<button
|
||
type="button"
|
||
onClick={() => setActiveTab('attention')}
|
||
className={`flex-1 min-h-[44px] min-w-[120px] px-3 py-2 rounded-lg text-xs font-semibold flex items-center justify-center gap-2 transition-colors ${
|
||
activeTab === 'attention'
|
||
? 'bg-krona-workspace text-krona-primary shadow-xs border border-krona-border font-bold'
|
||
: 'text-krona-secondary hover:text-krona-primary hover:bg-krona-hover'
|
||
}`}
|
||
data-testid="tab-attention"
|
||
>
|
||
<ShieldAlert className={`w-4 h-4 shrink-0 ${attentionCount > 0 ? 'text-status-danger-text' : 'text-status-success-text'}`} />
|
||
<span>Требует внимания</span>
|
||
{attentionCount > 0 && (
|
||
<span className="px-1.5 py-0.2 rounded-full text-[10px] font-mono font-bold bg-status-danger-soft text-status-danger-text border border-status-danger-border">
|
||
{attentionCount}
|
||
</span>
|
||
)}
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => setActiveTab('metrics')}
|
||
className={`flex-1 min-h-[44px] min-w-[110px] px-3 py-2 rounded-lg text-xs font-semibold flex items-center justify-center gap-2 transition-colors ${
|
||
activeTab === 'metrics'
|
||
? 'bg-krona-workspace text-krona-primary shadow-xs border border-krona-border font-bold'
|
||
: 'text-krona-secondary hover:text-krona-primary hover:bg-krona-hover'
|
||
}`}
|
||
data-testid="tab-metrics"
|
||
>
|
||
<Activity className="w-4 h-4 shrink-0 text-krona-gold" />
|
||
<span>Показатели</span>
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => setActiveTab('infrastructure')}
|
||
className={`flex-1 min-h-[44px] min-w-[120px] px-3 py-2 rounded-lg text-xs font-semibold flex items-center justify-center gap-2 transition-colors ${
|
||
activeTab === 'infrastructure'
|
||
? 'bg-krona-workspace text-krona-primary shadow-xs border border-krona-border font-bold'
|
||
: 'text-krona-secondary hover:text-krona-primary hover:bg-krona-hover'
|
||
}`}
|
||
data-testid="tab-infrastructure"
|
||
>
|
||
<Server className="w-4 h-4 shrink-0 text-krona-gold" />
|
||
<span>Инфраструктура</span>
|
||
</button>
|
||
</div>
|
||
|
||
{/* ========================================================================= */}
|
||
{/* TAB 1: ТРЕБУЕТ ВНИМАНИЯ (CRITICAL / PROBLEMS PRIORITY) */}
|
||
{/* ========================================================================= */}
|
||
{activeTab === 'attention' && (
|
||
<div className="space-y-4 animate-in fade-in duration-150" data-testid="attention-section">
|
||
{attentionCount === 0 && (
|
||
<div className="p-6 rounded-xl border border-status-success-border bg-status-success-soft text-status-success-text text-center space-y-2">
|
||
<CheckCircle2 className="w-10 h-10 mx-auto text-status-success-text" />
|
||
<div className="font-bold text-base">Все системы работают в штатном режиме</div>
|
||
<p className="text-xs opacity-90 max-w-md mx-auto">
|
||
Нет открытых аварий, серверов с деградацией или просроченных подписок, требующих вмешательства оператора.
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* 1. Открытые инциденты платформы */}
|
||
{openIncidents.length > 0 && (
|
||
<div className="rounded-xl border border-krona-border bg-krona-surface p-4 space-y-3">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-2">
|
||
<ShieldAlert className="w-4 h-4 text-status-danger-text" />
|
||
<span className="font-bold text-sm text-krona-primary">
|
||
Открытые инциденты ({openIncidents.length})
|
||
</span>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => navigate('/control/incidents')}
|
||
className="min-h-[44px] px-3 text-xs text-krona-gold hover:underline flex items-center gap-1 font-medium"
|
||
>
|
||
<span>Все инциденты</span>
|
||
<ArrowUpRight className="w-3.5 h-3.5" />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
{openIncidents.map((inc) => (
|
||
<div
|
||
key={inc.id}
|
||
onClick={() => openInspector({ type: 'incident', id: inc.id, title: inc.title, data: inc })}
|
||
className="p-3.5 rounded-lg border border-status-danger-border bg-status-danger-soft cursor-pointer hover:opacity-95 transition-opacity space-y-1.5"
|
||
>
|
||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-[10px] font-mono font-bold bg-status-danger-text text-white px-2 py-0.5 rounded-sm">
|
||
{RU_INCIDENT_SEVERITY[inc.severity]?.short || inc.severity}
|
||
</span>
|
||
<span className="font-bold text-xs text-status-danger-text">{inc.title}</span>
|
||
</div>
|
||
<span className="text-[11px] font-medium text-status-danger-text">
|
||
{RU_INCIDENT_STATUS[inc.status] || inc.status}
|
||
</span>
|
||
</div>
|
||
<p className="text-xs text-krona-secondary line-clamp-2">{inc.impactSummary}</p>
|
||
<div className="text-[11px] text-krona-tertiary">
|
||
Затронуто: {inc.affectedTenants.length} орг. • Узлы: {inc.affectedNodes.join(', ')}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 2. Проблемные серверы */}
|
||
{serversProblematic.length > 0 && (
|
||
<div className="rounded-xl border border-krona-border bg-krona-surface p-4 space-y-3">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-2">
|
||
<Server className="w-4 h-4 text-status-warning-text" />
|
||
<span className="font-bold text-sm text-krona-primary">
|
||
Серверы с предупреждениями ({serversProblematic.length})
|
||
</span>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => navigate('/control/infrastructure')}
|
||
className="min-h-[44px] px-3 text-xs text-krona-gold hover:underline flex items-center gap-1 font-medium"
|
||
>
|
||
<span>Инфраструктура</span>
|
||
<ArrowUpRight className="w-3.5 h-3.5" />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||
{serversProblematic.map((srv) => (
|
||
<div
|
||
key={srv.id}
|
||
onClick={() => openInspector({ type: 'server', id: srv.id, title: srv.hostname, data: srv })}
|
||
className="p-3.5 rounded-lg border border-krona-border bg-krona-workspace cursor-pointer hover:border-krona-gold/40 transition-colors space-y-2"
|
||
>
|
||
<div className="flex items-center justify-between">
|
||
<div className="font-bold text-xs text-krona-primary font-mono">{srv.hostname}</div>
|
||
<StatusBadge status={srv.status} size="sm" />
|
||
</div>
|
||
<div className="grid grid-cols-3 gap-1 text-[11px] text-krona-secondary font-mono">
|
||
<div>CPU: <span className={srv.metrics.cpuPercent > 80 ? 'text-status-danger-text font-bold' : ''}>{srv.metrics.cpuPercent}%</span></div>
|
||
<div>RAM: <span className={srv.metrics.ramPercent > 80 ? 'text-status-danger-text font-bold' : ''}>{srv.metrics.ramPercent}%</span></div>
|
||
<div>Диск: <span className={srv.metrics.diskPercent > 85 ? 'text-status-danger-text font-bold' : ''}>{srv.metrics.diskPercent}%</span></div>
|
||
</div>
|
||
<div className="text-[11px] text-krona-tertiary">
|
||
Организация: {srv.organizationName}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 3. Модули с деградацией или сбоем */}
|
||
{(instancesOffline.length > 0 || instancesDegraded.length > 0) && (
|
||
<div className="rounded-xl border border-krona-border bg-krona-surface p-4 space-y-3">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-2">
|
||
<Layers className="w-4 h-4 text-status-warning-text" />
|
||
<span className="font-bold text-sm text-krona-primary">
|
||
Модули с деградацией ({instancesOffline.length + instancesDegraded.length})
|
||
</span>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => navigate('/control/modules')}
|
||
className="min-h-[44px] px-3 text-xs text-krona-gold hover:underline flex items-center gap-1 font-medium"
|
||
>
|
||
<span>Каталог</span>
|
||
<ArrowUpRight className="w-3.5 h-3.5" />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
|
||
{[...instancesOffline, ...instancesDegraded].map((inst) => (
|
||
<div
|
||
key={inst.id}
|
||
onClick={() => openInspector({ type: 'instance', id: inst.id, title: inst.moduleCode, data: inst })}
|
||
className="p-3 rounded-lg border border-krona-border bg-krona-workspace cursor-pointer hover:border-krona-gold/40 transition-colors flex items-center justify-between"
|
||
>
|
||
<div>
|
||
<div className="font-bold text-xs text-krona-primary">{inst.moduleCode} v{inst.version}</div>
|
||
<div className="text-[11px] text-krona-tertiary">Организация: {inst.organizationId}</div>
|
||
</div>
|
||
<StatusBadge status={inst.health} size="sm" />
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 4. Истекающие и просроченные подписки */}
|
||
{(expiring7d.length > 0 || overdueSubs.length > 0) && (
|
||
<div className="rounded-xl border border-krona-border bg-krona-surface p-4 space-y-3">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-2">
|
||
<CreditCard className="w-4 h-4 text-status-warning-text" />
|
||
<span className="font-bold text-sm text-krona-primary">
|
||
Истекающие подписки ({expiring7d.length + overdueSubs.length})
|
||
</span>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => navigate('/control/billing')}
|
||
className="min-h-[44px] px-3 text-xs text-krona-gold hover:underline flex items-center gap-1 font-medium"
|
||
>
|
||
<span>Биллинг</span>
|
||
<ArrowUpRight className="w-3.5 h-3.5" />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
|
||
{[...overdueSubs, ...expiring7d].map((sub) => (
|
||
<div
|
||
key={sub.id}
|
||
onClick={() => openInspector({ type: 'subscription', id: sub.id, title: sub.organizationName, data: sub })}
|
||
className="p-3 rounded-lg border border-krona-border bg-krona-workspace cursor-pointer hover:border-krona-gold/40 transition-colors flex items-center justify-between"
|
||
>
|
||
<div>
|
||
<div className="font-bold text-xs text-krona-primary">{sub.organizationName}</div>
|
||
<div className="text-[11px] text-krona-tertiary">
|
||
Тариф: {sub.planName} • До: {new Date(sub.validUntil).toLocaleDateString('ru-RU')}
|
||
</div>
|
||
</div>
|
||
<div className="text-right">
|
||
<MoneyDisplay minorUnits={sub.priceMinorUnits} currency={sub.currency} className="font-bold text-xs text-krona-primary" />
|
||
<div className="mt-1"><StatusBadge status={sub.status} size="sm" /></div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* ========================================================================= */}
|
||
{/* TAB 2: ПОКАЗАТЕЛИ (KEY METRICS & KPI) */}
|
||
{/* ========================================================================= */}
|
||
{activeTab === 'metrics' && (
|
||
<div className="space-y-4 animate-in fade-in duration-150" data-testid="metrics-section">
|
||
{/* 6 Key KPI Cards */}
|
||
<div className="grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-6 gap-2.5 sm:gap-3">
|
||
{/* Orgs */}
|
||
<div
|
||
onClick={() => navigate('/control/organizations')}
|
||
className="p-3.5 rounded-xl border border-krona-border bg-krona-surface hover:border-krona-gold/40 cursor-pointer transition-colors"
|
||
>
|
||
<div className="flex items-center justify-between text-krona-tertiary mb-1">
|
||
<span className="text-[11px] font-medium">Организации</span>
|
||
<Building2 className="w-4 h-4 text-krona-gold" />
|
||
</div>
|
||
<div className="text-lg sm:text-xl font-bold text-krona-primary">{totalOrgs}</div>
|
||
<div className="text-[11px] text-status-success-text mt-0.5">{activeOrgs} активных</div>
|
||
</div>
|
||
|
||
{/* Users */}
|
||
<div className="p-3.5 rounded-xl border border-krona-border bg-krona-surface">
|
||
<div className="flex items-center justify-between text-krona-tertiary mb-1">
|
||
<span className="text-[11px] font-medium">Пользователи</span>
|
||
<Users className="w-4 h-4 text-krona-gold" />
|
||
</div>
|
||
<div className="text-lg sm:text-xl font-bold text-krona-primary">{totalUsers}</div>
|
||
<div className="text-[11px] text-krona-secondary mt-0.5">{totalBranches} филиалов</div>
|
||
</div>
|
||
|
||
{/* MRR */}
|
||
<div
|
||
onClick={() => navigate('/control/billing')}
|
||
className="p-3.5 rounded-xl border border-krona-border bg-krona-surface hover:border-krona-gold/40 cursor-pointer transition-colors col-span-2 sm:col-span-1"
|
||
>
|
||
<div className="flex items-center justify-between text-krona-tertiary mb-1">
|
||
<span className="text-[11px] font-medium">Выручка (MRR)</span>
|
||
<CreditCard className="w-4 h-4 text-krona-gold" />
|
||
</div>
|
||
<MoneyDisplay
|
||
minorUnits={totalMrrMinorUnits}
|
||
currency="RUB"
|
||
className="text-lg sm:text-xl font-bold text-krona-gold"
|
||
/>
|
||
<div className="text-[11px] text-krona-secondary mt-0.5">В месяц</div>
|
||
</div>
|
||
|
||
{/* Subscriptions */}
|
||
<div
|
||
onClick={() => navigate('/control/billing')}
|
||
className="p-3.5 rounded-xl border border-krona-border bg-krona-surface hover:border-krona-gold/40 cursor-pointer transition-colors"
|
||
>
|
||
<div className="flex items-center justify-between text-krona-tertiary mb-1">
|
||
<span className="text-[11px] font-medium">Подписки</span>
|
||
<Clock className="w-4 h-4 text-krona-gold" />
|
||
</div>
|
||
<div className="text-lg sm:text-xl font-bold text-krona-primary">{subscriptions.length}</div>
|
||
<div className="text-[11px] text-krona-secondary mt-0.5">{expiring7d.length} истекают</div>
|
||
</div>
|
||
|
||
{/* Installations */}
|
||
<div
|
||
onClick={() => navigate('/control/installations')}
|
||
className="p-3.5 rounded-xl border border-krona-border bg-krona-surface hover:border-krona-gold/40 cursor-pointer transition-colors"
|
||
>
|
||
<div className="flex items-center justify-between text-krona-tertiary mb-1">
|
||
<span className="text-[11px] font-medium">Установки</span>
|
||
<Smartphone className="w-4 h-4 text-krona-gold" />
|
||
</div>
|
||
<div className="text-lg sm:text-xl font-bold text-krona-primary">{installations.length}</div>
|
||
<div className="text-[11px] text-krona-secondary mt-0.5">Android: {androidInstalls}</div>
|
||
</div>
|
||
|
||
{/* Servers */}
|
||
<div
|
||
onClick={() => navigate('/control/infrastructure')}
|
||
className="p-3.5 rounded-xl border border-krona-border bg-krona-surface hover:border-krona-gold/40 cursor-pointer transition-colors"
|
||
>
|
||
<div className="flex items-center justify-between text-krona-tertiary mb-1">
|
||
<span className="text-[11px] font-medium">Серверы</span>
|
||
<Server className="w-4 h-4 text-krona-gold" />
|
||
</div>
|
||
<div className="text-lg sm:text-xl font-bold text-krona-primary">{servers.length}</div>
|
||
<div className="text-[11px] text-status-success-text mt-0.5">{serversHealthy} в норме</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Detailed Statistics Cards */}
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
{/* Orgs Breakdown */}
|
||
<div className="rounded-xl border border-krona-border bg-krona-surface p-4 space-y-3">
|
||
<div className="font-bold text-sm text-krona-primary flex items-center justify-between">
|
||
<span>Статус организаций</span>
|
||
<span className="text-xs text-krona-secondary font-normal">Всего: {totalOrgs}</span>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<div className="flex items-center justify-between text-xs">
|
||
<span className="flex items-center gap-2">
|
||
<span className="w-2.5 h-2.5 rounded-full bg-status-success-text" />
|
||
<span className="text-krona-secondary">Активные организации</span>
|
||
</span>
|
||
<span className="font-bold text-krona-primary">{activeOrgs}</span>
|
||
</div>
|
||
<div className="flex items-center justify-between text-xs">
|
||
<span className="flex items-center gap-2">
|
||
<span className="w-2.5 h-2.5 rounded-full bg-status-info-text" />
|
||
<span className="text-krona-secondary">Пробный период (Trial)</span>
|
||
</span>
|
||
<span className="font-bold text-krona-primary">{trialOrgs}</span>
|
||
</div>
|
||
<div className="flex items-center justify-between text-xs">
|
||
<span className="flex items-center gap-2">
|
||
<span className="w-2.5 h-2.5 rounded-full bg-status-warning-text" />
|
||
<span className="text-krona-secondary">Льготный период (Grace)</span>
|
||
</span>
|
||
<span className="font-bold text-krona-primary">{graceOrgs}</span>
|
||
</div>
|
||
<div className="flex items-center justify-between text-xs">
|
||
<span className="flex items-center gap-2">
|
||
<span className="w-2.5 h-2.5 rounded-full bg-status-danger-text" />
|
||
<span className="text-krona-secondary">Приостановлены (Suspended)</span>
|
||
</span>
|
||
<span className="font-bold text-krona-primary">{suspendedOrgs}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Installations Breakdown */}
|
||
<div className="rounded-xl border border-krona-border bg-krona-surface p-4 space-y-3">
|
||
<div className="font-bold text-sm text-krona-primary flex items-center justify-between">
|
||
<span>Платформы клиентов</span>
|
||
<span className="text-xs text-krona-secondary font-normal">Всего: {installations.length}</span>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<div className="flex items-center justify-between text-xs">
|
||
<span className="text-krona-secondary">Веб-приложения (Web SPA)</span>
|
||
<span className="font-bold text-krona-primary">{webInstalls}</span>
|
||
</div>
|
||
<div className="flex items-center justify-between text-xs">
|
||
<span className="text-krona-secondary">Рабочие станции (Desktop)</span>
|
||
<span className="font-bold text-krona-primary">{desktopInstalls}</span>
|
||
</div>
|
||
<div className="flex items-center justify-between text-xs">
|
||
<span className="text-krona-secondary">Мобильные кассы (Android)</span>
|
||
<span className="font-bold text-krona-primary">{androidInstalls}</span>
|
||
</div>
|
||
{outdatedAndroid > 0 && (
|
||
<div className="flex items-center justify-between text-xs text-status-warning-text font-medium pt-1 border-t border-krona-border">
|
||
<span>Устаревшие версии Android:</span>
|
||
<span className="font-bold">{outdatedAndroid}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ========================================================================= */}
|
||
{/* TAB 3: ИНФРАСТРУКТУРА (SERVERS & TELEMETRY) */}
|
||
{/* ========================================================================= */}
|
||
{activeTab === 'infrastructure' && (
|
||
<div className="space-y-4 animate-in fade-in duration-150" data-testid="infrastructure-section">
|
||
<div className="rounded-xl border border-krona-border bg-krona-surface p-4 space-y-3">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h3 className="font-bold text-sm text-krona-primary">Инфраструктурные серверы Krona</h3>
|
||
<p className="text-xs text-krona-secondary">Текущая загрузка вычислительных узлов платформы</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => navigate('/control/infrastructure')}
|
||
className="min-h-[44px] px-3 text-xs text-krona-gold hover:underline flex items-center gap-1 font-medium"
|
||
>
|
||
<span>Управление</span>
|
||
<ArrowUpRight className="w-3.5 h-3.5" />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||
{servers.map((srv) => (
|
||
<div
|
||
key={srv.id}
|
||
onClick={() => openInspector({ type: 'server', id: srv.id, title: srv.hostname, data: srv })}
|
||
className="p-3.5 rounded-lg border border-krona-border bg-krona-workspace cursor-pointer hover:border-krona-gold/40 transition-colors space-y-2.5"
|
||
>
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<div className="font-bold text-xs text-krona-primary font-mono">{srv.hostname}</div>
|
||
<div className="text-[11px] text-krona-tertiary">{srv.organizationName}</div>
|
||
</div>
|
||
<StatusBadge status={srv.status} size="sm" />
|
||
</div>
|
||
|
||
<div className="grid grid-cols-3 gap-2 text-center text-xs py-1 bg-krona-surface/40 rounded-md border border-krona-border">
|
||
<div>
|
||
<div className="text-[10px] text-krona-tertiary">CPU</div>
|
||
<div className={`font-mono font-bold ${srv.metrics.cpuPercent > 80 ? 'text-status-danger-text' : 'text-krona-primary'}`}>
|
||
{srv.metrics.cpuPercent}%
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div className="text-[10px] text-krona-tertiary">RAM</div>
|
||
<div className={`font-mono font-bold ${srv.metrics.ramPercent > 80 ? 'text-status-danger-text' : 'text-krona-primary'}`}>
|
||
{srv.metrics.ramPercent}%
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div className="text-[10px] text-krona-tertiary">Диск</div>
|
||
<div className={`font-mono font-bold ${srv.metrics.diskPercent > 85 ? 'text-status-danger-text' : 'text-krona-primary'}`}>
|
||
{srv.metrics.diskPercent}%
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="text-[11px] text-krona-tertiary flex items-center justify-between">
|
||
<span>Резервная копия:</span>
|
||
<span className={srv.backup.state === 'failed' ? 'text-status-danger-text font-bold' : 'text-status-success-text'}>
|
||
{srv.backup.state === 'healthy' ? 'Успешно' : srv.backup.state === 'failed' ? 'Ошибка' : srv.backup.state}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="pt-1">
|
||
<HeartbeatTimeline history={srv.metrics.heartbeatHistory} compact />
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|