feat(control-center): preserve concurrent AGI work on control center components and pages
This commit is contained in:
parent
e4803f25e0
commit
1a4382201d
9 changed files with 1765 additions and 835 deletions
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState, useMemo } from 'react';
|
||||||
import { NavLink, Outlet } from 'react-router-dom';
|
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Building2,
|
Building2,
|
||||||
|
|
@ -14,24 +14,48 @@ import {
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Radio,
|
Radio,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
|
Menu,
|
||||||
|
X,
|
||||||
|
ShieldAlert,
|
||||||
|
Sun,
|
||||||
|
Moon,
|
||||||
|
Sparkles,
|
||||||
|
ShieldCheck,
|
||||||
|
ChevronRight,
|
||||||
|
SlidersHorizontal,
|
||||||
|
ChevronLeft,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useKccStore } from '../store/kccStore';
|
import { useKccStore, useOperatorRole } from '../store/kccStore';
|
||||||
import { OperatorSessionSwitcher } from './OperatorSessionSwitcher';
|
import { OperatorSessionSwitcher } from './OperatorSessionSwitcher';
|
||||||
import { InspectorDrawer } from './InspectorDrawer';
|
import { InspectorDrawer } from './InspectorDrawer';
|
||||||
import { ConfirmOperatorActionModal } from './ConfirmOperatorAction';
|
import { ConfirmOperatorActionModal } from './ConfirmOperatorAction';
|
||||||
import { ServerMonitorOutageBanner } from './ServerMonitorOutageBanner';
|
import { ServerMonitorOutageBanner } from './ServerMonitorOutageBanner';
|
||||||
|
import { useThemeStore, ThemeMode } from '../../../stores/theme';
|
||||||
|
import { RU_ROLES, RU_THEMES, RU_UI } from '../locales/ru';
|
||||||
|
|
||||||
export const ControlCenterShell: React.FC = () => {
|
export const ControlCenterShell: React.FC = () => {
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const isInitialized = useKccStore((state) => state.isInitialized);
|
const isInitialized = useKccStore((state) => state.isInitialized);
|
||||||
const initialize = useKccStore((state) => state.initialize);
|
const initialize = useKccStore((state) => state.initialize);
|
||||||
const resetToDefaults = useKccStore((state) => state.resetToDefaults);
|
const resetToDefaults = useKccStore((state) => state.resetToDefaults);
|
||||||
const serverMonitorOutage = useKccStore((state) => state.serverMonitorOutage);
|
const serverMonitorOutage = useKccStore((state) => state.serverMonitorOutage);
|
||||||
const toggleServerMonitorOutage = useKccStore((state) => state.toggleServerMonitorOutage);
|
const toggleServerMonitorOutage = useKccStore((state) => state.toggleServerMonitorOutage);
|
||||||
|
const incidents = useKccStore((state) => state.incidents);
|
||||||
|
|
||||||
|
const { role, operator } = useOperatorRole();
|
||||||
|
const themeMode = useThemeStore((state) => state.themeMode);
|
||||||
|
const setTheme = useThemeStore((state) => state.setTheme);
|
||||||
|
|
||||||
const [utcTime, setUtcTime] = useState('');
|
const [utcTime, setUtcTime] = useState('');
|
||||||
const [isResetting, setIsResetting] = useState(false);
|
const [isResetting, setIsResetting] = useState(false);
|
||||||
const [showSavedFeedback, setShowSavedFeedback] = useState(false);
|
const [showSavedFeedback, setShowSavedFeedback] = useState(false);
|
||||||
|
|
||||||
|
// Mobile / Tablet Drawer states
|
||||||
|
const [isMobileDrawerOpen, setIsMobileDrawerOpen] = useState(false);
|
||||||
|
const [isTabletSidebarCollapsed, setIsTabletSidebarCollapsed] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isInitialized) {
|
if (!isInitialized) {
|
||||||
initialize();
|
initialize();
|
||||||
|
|
@ -48,8 +72,13 @@ export const ControlCenterShell: React.FC = () => {
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Close mobile drawer on route change
|
||||||
|
useEffect(() => {
|
||||||
|
setIsMobileDrawerOpen(false);
|
||||||
|
}, [location.pathname]);
|
||||||
|
|
||||||
const handleReset = async () => {
|
const handleReset = async () => {
|
||||||
if (confirm('Сбросить состояние демонстрационной базы данных к исходному состоянию?')) {
|
if (confirm('Сбросить демонстрационные данные платформы к исходному состоянию?')) {
|
||||||
setIsResetting(true);
|
setIsResetting(true);
|
||||||
await resetToDefaults();
|
await resetToDefaults();
|
||||||
setIsResetting(false);
|
setIsResetting(false);
|
||||||
|
|
@ -58,107 +87,260 @@ export const ControlCenterShell: React.FC = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const navItems = [
|
// Critical events calculation
|
||||||
|
const criticalCount = useMemo(() => {
|
||||||
|
let count = 0;
|
||||||
|
if (serverMonitorOutage) count += 1;
|
||||||
|
count += incidents.filter(
|
||||||
|
(i) => i.status !== 'resolved' && (i.severity === 'P0_CRITICAL' || i.severity === 'P1_MAJOR')
|
||||||
|
).length;
|
||||||
|
return count;
|
||||||
|
}, [serverMonitorOutage, incidents]);
|
||||||
|
|
||||||
|
// Navigation Items
|
||||||
|
const primaryMobileNav = [
|
||||||
{ to: '/control', label: 'Обзор', icon: LayoutDashboard, end: true },
|
{ to: '/control', label: 'Обзор', icon: LayoutDashboard, end: true },
|
||||||
{ to: '/control/organizations', label: 'Организации', icon: Building2 },
|
{ to: '/control/organizations', label: 'Организации', icon: Building2 },
|
||||||
{ to: '/control/billing', label: 'Подписки и биллинг', icon: CreditCard },
|
{ to: '/control/incidents', label: 'Инциденты', icon: AlertTriangle, badge: criticalCount > 0 ? criticalCount : undefined },
|
||||||
{ to: '/control/modules', label: 'Каталог модулей', icon: Package },
|
{ to: '/control/infrastructure', label: 'Серверы', icon: Server },
|
||||||
{ to: '/control/installations', label: 'Установки и клиенты', icon: Smartphone },
|
|
||||||
{ to: '/control/infrastructure', label: 'Инфраструктура', icon: Server },
|
|
||||||
{ to: '/control/announcements', label: 'Сообщения', icon: BellRing },
|
|
||||||
{ to: '/control/incidents', label: 'Инциденты', icon: AlertTriangle },
|
|
||||||
{ to: '/control/audit', label: 'Неизменяемый аудит', icon: FileCheck2 },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const allNavItems = [
|
||||||
|
{ to: '/control', label: 'Обзор', icon: LayoutDashboard, end: true },
|
||||||
|
{ to: '/control/organizations', label: 'Организации', icon: Building2 },
|
||||||
|
{ to: '/control/billing', label: 'Подписки и платежи', icon: CreditCard },
|
||||||
|
{ to: '/control/modules', label: 'Каталог модулей', icon: Package },
|
||||||
|
{ to: '/control/installations', label: 'Установки', icon: Smartphone },
|
||||||
|
{ to: '/control/infrastructure', label: 'Серверы', icon: Server },
|
||||||
|
{ to: '/control/announcements', label: 'Оповещения', icon: BellRing },
|
||||||
|
{ to: '/control/incidents', label: 'Инциденты', icon: AlertTriangle, badge: criticalCount > 0 ? criticalCount : undefined },
|
||||||
|
{ to: '/control/audit', label: 'Журнал аудита', icon: FileCheck2 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const moreNavItems = [
|
||||||
|
{ to: '/control/billing', label: 'Подписки и платежи', icon: CreditCard },
|
||||||
|
{ to: '/control/modules', label: 'Каталог модулей', icon: Package },
|
||||||
|
{ to: '/control/installations', label: 'Установки', icon: Smartphone },
|
||||||
|
{ to: '/control/announcements', label: 'Оповещения', icon: BellRing },
|
||||||
|
{ to: '/control/audit', label: 'Журнал аудита', icon: FileCheck2 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Current section title
|
||||||
|
const currentTitle = useMemo(() => {
|
||||||
|
const current = allNavItems.find((item) =>
|
||||||
|
item.end ? location.pathname === item.to : location.pathname.startsWith(item.to)
|
||||||
|
);
|
||||||
|
return current?.label || 'Control Center';
|
||||||
|
}, [location.pathname, allNavItems]);
|
||||||
|
|
||||||
|
const currentRoleLabel = RU_ROLES[role]?.shortLabel || role;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen max-w-full overflow-x-hidden bg-[#07130e] text-zinc-100 flex flex-col font-sans selection:bg-amber-500 selection:text-zinc-950">
|
<div
|
||||||
{/* Top Console Bar */}
|
className="min-h-screen max-w-full overflow-x-hidden bg-krona-app text-krona-primary flex flex-col font-sans selection:bg-krona-gold selection:text-zinc-950 transition-colors duration-200"
|
||||||
<header className="h-14 border-b border-zinc-800 bg-[#0a1b14] px-2.5 sm:px-4 flex items-center justify-between z-30 shrink-0 max-w-full overflow-hidden">
|
data-testid="control-center-shell"
|
||||||
<div className="flex items-center gap-2 sm:gap-4 min-w-0">
|
>
|
||||||
{/* Logo & Title */}
|
{/* ========================================================================= */}
|
||||||
<div className="flex items-center gap-2 sm:gap-2.5 min-w-0">
|
{/* 1. DESKTOP & TABLET HEADER (Hidden on mobile < md) */}
|
||||||
<div className="w-7 h-7 sm:w-8 sm:h-8 rounded-lg bg-emerald-950 border border-amber-500/40 flex items-center justify-center text-amber-400 font-extrabold shadow-sm shrink-0">
|
{/* ========================================================================= */}
|
||||||
|
<header className="hidden md:flex h-14 border-b border-krona-border bg-krona-workspace/95 backdrop-blur-md px-4 items-center justify-between z-30 shrink-0 max-w-full">
|
||||||
|
<div className="flex items-center gap-3 lg:gap-4 min-w-0">
|
||||||
|
{/* Logo & Platform info */}
|
||||||
|
<div className="flex items-center gap-2.5 min-w-0">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-krona-surface border border-krona-gold/40 flex items-center justify-center text-krona-gold font-extrabold shadow-sm shrink-0">
|
||||||
K
|
K
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="text-xs font-black tracking-widest text-amber-400 uppercase flex items-center gap-1.5">
|
<div className="text-xs font-black tracking-wider text-krona-primary uppercase flex items-center gap-1.5">
|
||||||
<span className="shrink-0">Krona Control Center</span>
|
<span className="shrink-0">Krona Control Center</span>
|
||||||
<span className="text-[9px] px-1.5 py-0.2 rounded-xs bg-emerald-900/80 text-emerald-300 font-mono hidden sm:inline">
|
<span className="text-[9px] px-1.5 py-0.5 rounded-sm bg-status-success-soft text-status-success-text font-mono">
|
||||||
DEMO v1.0
|
PROD v1.0
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] text-zinc-400 font-mono tracking-wider hidden sm:block">
|
<div className="text-[10px] text-krona-tertiary font-mono tracking-wider">
|
||||||
Кабинет владельца платформы
|
Кабинет владельца платформы
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 text-[11px] text-zinc-500 font-mono pl-2 sm:pl-4 border-l border-zinc-800 shrink-0">
|
<div className="hidden xl:flex items-center gap-2 text-[11px] text-krona-tertiary font-mono pl-3 border-l border-krona-border shrink-0">
|
||||||
<span className="hidden xl:inline">{utcTime}</span>
|
<span>{utcTime}</span>
|
||||||
<span className="hidden xl:inline">•</span>
|
<span>•</span>
|
||||||
<span className="flex items-center gap-1 text-emerald-400">
|
<span className="flex items-center gap-1 text-status-success-text">
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse shrink-0" />
|
<span className="w-1.5 h-1.5 rounded-full bg-status-success-text animate-pulse shrink-0" />
|
||||||
<span className="text-[10px] sm:text-xs">IndexedDB Synced</span>
|
<span>{RU_UI.system.synced}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Header Actions */}
|
{/* Right Controls: Outage simulation, Theme Switcher, Operator Switcher, Reset */}
|
||||||
<div className="flex items-center gap-1.5 sm:gap-3 shrink-0">
|
<div className="flex items-center gap-2 lg:gap-3 shrink-0">
|
||||||
{/* Server Monitor Simulation Toggle */}
|
{/* Circuit Breaker Outage toggle */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={toggleServerMonitorOutage}
|
onClick={toggleServerMonitorOutage}
|
||||||
className={`flex items-center gap-1.5 px-2 sm:px-2.5 py-1 rounded-md text-xs font-medium border transition-colors ${
|
className={`min-h-[38px] px-2.5 py-1.5 rounded-lg border text-xs font-medium flex items-center gap-1.5 transition-colors ${
|
||||||
serverMonitorOutage
|
serverMonitorOutage
|
||||||
? 'bg-rose-950 text-rose-300 border-rose-700/60'
|
? 'border-status-danger-border bg-status-danger-soft text-status-danger-text animate-pulse font-semibold'
|
||||||
: 'bg-zinc-900 text-zinc-400 border-zinc-800 hover:text-zinc-200'
|
: 'border-krona-border bg-krona-surface hover:bg-krona-hover text-krona-secondary'
|
||||||
}`}
|
}`}
|
||||||
title="Переключить симуляцию недоступности Server Monitor"
|
title="Имитация сбоя канала телеметрии Server Monitor (Circuit Breaker)"
|
||||||
|
data-testid="simulate-outage-toggle"
|
||||||
>
|
>
|
||||||
<Radio className={`w-3.5 h-3.5 ${serverMonitorOutage ? 'text-rose-400 animate-pulse' : 'text-zinc-500'} shrink-0`} />
|
<Radio className="w-3.5 h-3.5 shrink-0" />
|
||||||
<span className="hidden md:inline">{serverMonitorOutage ? 'SM Outage Sim (Активен)' : 'Simulate SM Outage'}</span>
|
<span className="hidden lg:inline">
|
||||||
|
{serverMonitorOutage ? 'Сбой мониторинга (Активен)' : 'Имитировать сбой'}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Reset Demo DB Button */}
|
{/* Theme Switcher in Desktop Header */}
|
||||||
|
<div className="flex items-center bg-krona-surface border border-krona-border rounded-lg p-0.5" role="radiogroup" aria-label="Выбор темы">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTheme('soft-light')}
|
||||||
|
className={`p-1.5 rounded-md text-xs transition-colors ${
|
||||||
|
themeMode === 'soft-light' ? 'bg-krona-workspace text-krona-gold font-semibold shadow-xs' : 'text-krona-secondary hover:text-krona-primary'
|
||||||
|
}`}
|
||||||
|
title="Мягкая светлая тема"
|
||||||
|
data-testid="theme-toggle-soft-light"
|
||||||
|
>
|
||||||
|
<Sparkles className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTheme('light')}
|
||||||
|
className={`p-1.5 rounded-md text-xs transition-colors ${
|
||||||
|
themeMode === 'light' ? 'bg-krona-workspace text-krona-gold font-semibold shadow-xs' : 'text-krona-secondary hover:text-krona-primary'
|
||||||
|
}`}
|
||||||
|
title="Классическая светлая тема"
|
||||||
|
data-testid="theme-toggle-light"
|
||||||
|
>
|
||||||
|
<Sun className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTheme('dark')}
|
||||||
|
className={`p-1.5 rounded-md text-xs transition-colors ${
|
||||||
|
themeMode === 'dark' ? 'bg-krona-workspace text-krona-gold font-semibold shadow-xs' : 'text-krona-secondary hover:text-krona-primary'
|
||||||
|
}`}
|
||||||
|
title="Тёмная тема"
|
||||||
|
data-testid="theme-toggle-dark"
|
||||||
|
>
|
||||||
|
<Moon className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reset Demo Data */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleReset}
|
onClick={handleReset}
|
||||||
disabled={isResetting}
|
disabled={isResetting}
|
||||||
className="hidden sm:flex items-center gap-1.5 px-2 sm:px-2.5 py-1 rounded-md text-xs font-medium bg-zinc-900 hover:bg-zinc-800 text-zinc-300 border border-zinc-800 transition-colors disabled:opacity-50 shrink-0"
|
className="min-h-[38px] px-2.5 py-1.5 rounded-lg border border-krona-border bg-krona-surface hover:bg-krona-hover text-krona-secondary transition-colors text-xs flex items-center gap-1.5"
|
||||||
title="Сбросить демонстрационные данные к эталону"
|
title="Сброс демонстрационных данных к начальным"
|
||||||
|
data-testid="reset-demo-button"
|
||||||
>
|
>
|
||||||
<RotateCcw className={`w-3.5 h-3.5 ${isResetting ? 'animate-spin' : ''} shrink-0`} />
|
<RotateCcw className={`w-3.5 h-3.5 ${isResetting ? 'animate-spin text-krona-gold' : ''}`} />
|
||||||
<span className="hidden md:inline">Сброс Demo</span>
|
<span className="hidden xl:inline">Сбросить данные</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Operator RBAC Switcher */}
|
{/* Operator Role Switcher */}
|
||||||
<OperatorSessionSwitcher />
|
<OperatorSessionSwitcher />
|
||||||
|
|
||||||
{/* Return to Client App Link */}
|
|
||||||
<NavLink
|
|
||||||
to="/"
|
|
||||||
className="hidden xl:flex items-center gap-1 text-xs text-zinc-500 hover:text-zinc-300 transition-colors pl-2"
|
|
||||||
title="Перейти в клиентское приложение платформы"
|
|
||||||
>
|
|
||||||
<span>В приложение</span>
|
|
||||||
<ExternalLink className="w-3 h-3" />
|
|
||||||
</NavLink>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Main Console Layout */}
|
{/* ========================================================================= */}
|
||||||
<div className="flex-1 flex flex-col md:flex-row overflow-hidden max-w-full">
|
{/* 2. MOBILE HEADER (< md) */}
|
||||||
{/* Compact Operator Sidebar */}
|
{/* ========================================================================= */}
|
||||||
<aside
|
<header
|
||||||
className="w-full md:w-56 border-b md:border-b-0 md:border-r border-zinc-800 bg-[#091712] flex flex-row md:flex-col shrink-0 overflow-x-auto md:overflow-y-auto"
|
className="md:hidden h-14 border-b border-krona-border bg-krona-workspace/95 backdrop-blur-md px-3 flex items-center justify-between z-30 shrink-0 sticky top-0"
|
||||||
aria-label="Навигация Control Center"
|
data-testid="mobile-header"
|
||||||
>
|
>
|
||||||
<div className="p-2 md:p-3 flex flex-row md:flex-col gap-1 shrink-0">
|
{/* Left: Menu toggle + Logo + Title */}
|
||||||
<div className="hidden md:block px-3 py-1.5 text-[10px] font-bold text-zinc-500 uppercase tracking-widest">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
Управление
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsMobileDrawerOpen(true)}
|
||||||
|
className="min-w-[44px] min-h-[44px] flex items-center justify-center rounded-lg border border-krona-border bg-krona-surface text-krona-primary hover:bg-krona-hover transition-colors"
|
||||||
|
aria-label="Открыть меню панели оператора"
|
||||||
|
data-testid="mobile-menu-button"
|
||||||
|
>
|
||||||
|
<Menu className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
|
<div className="w-7 h-7 rounded-md bg-krona-surface border border-krona-gold/40 flex items-center justify-center text-krona-gold font-extrabold text-xs shrink-0">
|
||||||
|
K
|
||||||
</div>
|
</div>
|
||||||
{navItems.map((item) => {
|
<div className="min-w-0">
|
||||||
|
<div className="text-xs font-bold text-krona-primary truncate leading-tight">
|
||||||
|
{currentTitle}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-krona-gold font-medium truncate">
|
||||||
|
Control Center
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Critical Badge + Operator Profile button */}
|
||||||
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
|
{criticalCount > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate('/control/incidents')}
|
||||||
|
className="min-w-[44px] min-h-[44px] flex items-center justify-center rounded-lg bg-status-danger-soft border border-status-danger-border text-status-danger-text text-xs font-bold"
|
||||||
|
aria-label={`Критические события: ${criticalCount}`}
|
||||||
|
title="Есть критические события платформы"
|
||||||
|
data-testid="mobile-critical-indicator"
|
||||||
|
>
|
||||||
|
<ShieldAlert className="w-4 h-4 animate-pulse mr-1" />
|
||||||
|
<span>{criticalCount}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsMobileDrawerOpen(true)}
|
||||||
|
className="min-w-[44px] min-h-[44px] px-2.5 flex items-center gap-1.5 rounded-lg border border-krona-gold/30 bg-krona-gold/10 text-krona-gold-text text-xs font-semibold"
|
||||||
|
aria-label={`Профиль оператора: ${currentRoleLabel}`}
|
||||||
|
title="Профиль оператора платформы"
|
||||||
|
data-testid="mobile-operator-profile-btn"
|
||||||
|
>
|
||||||
|
<ShieldCheck className="w-4 h-4 text-krona-gold shrink-0" />
|
||||||
|
<span className="truncate max-w-[80px]">{currentRoleLabel}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* ========================================================================= */}
|
||||||
|
{/* 3. MAIN LAYOUT (Sidebar on Desktop/Tablet + Content Body) */}
|
||||||
|
{/* ========================================================================= */}
|
||||||
|
<div className="flex-1 flex min-h-0 relative max-w-full">
|
||||||
|
{/* Desktop & Tablet Sidebar */}
|
||||||
|
<aside
|
||||||
|
className={`hidden md:flex flex-col border-r border-krona-border bg-krona-workspace transition-all duration-200 shrink-0 z-20 ${
|
||||||
|
isTabletSidebarCollapsed ? 'w-16' : 'w-56 lg:w-64'
|
||||||
|
}`}
|
||||||
|
data-testid="desktop-sidebar"
|
||||||
|
>
|
||||||
|
{/* Tablet Collapse Toggle */}
|
||||||
|
<div className="p-2 border-b border-krona-border flex items-center justify-between">
|
||||||
|
<span className={`text-[11px] font-semibold text-krona-tertiary uppercase tracking-wider px-2 ${isTabletSidebarCollapsed ? 'hidden' : 'block'}`}>
|
||||||
|
Навигация
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsTabletSidebarCollapsed(!isTabletSidebarCollapsed)}
|
||||||
|
className="p-1.5 rounded-md hover:bg-krona-hover text-krona-secondary hover:text-krona-primary transition-colors ml-auto"
|
||||||
|
title={isTabletSidebarCollapsed ? 'Развернуть меню' : 'Свернуть меню'}
|
||||||
|
>
|
||||||
|
{isTabletSidebarCollapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronLeft className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation Links */}
|
||||||
|
<nav className="flex-1 p-2 space-y-1 overflow-y-auto">
|
||||||
|
{allNavItems.map((item) => {
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
return (
|
return (
|
||||||
<NavLink
|
<NavLink
|
||||||
|
|
@ -166,50 +348,292 @@ export const ControlCenterShell: React.FC = () => {
|
||||||
to={item.to}
|
to={item.to}
|
||||||
end={item.end}
|
end={item.end}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
`flex items-center gap-2 px-2.5 py-1.5 md:px-3 md:py-2 rounded-lg text-xs font-medium whitespace-nowrap transition-colors ${
|
`flex items-center min-h-[40px] px-3 py-2 rounded-lg text-xs font-medium transition-colors ${
|
||||||
isActive
|
isActive
|
||||||
? 'bg-amber-500/15 text-amber-300 border border-amber-500/30 font-semibold'
|
? 'bg-krona-gold/15 text-krona-gold font-bold border border-krona-gold/30'
|
||||||
: 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800/60'
|
: 'text-krona-secondary hover:bg-krona-hover hover:text-krona-primary'
|
||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
|
title={item.label}
|
||||||
|
data-testid={`nav-${item.to.replace('/control', '').replace('/', '') || 'overview'}`}
|
||||||
>
|
>
|
||||||
<Icon className="w-4 h-4 shrink-0" />
|
<Icon className="w-4 h-4 shrink-0" />
|
||||||
<span>{item.label}</span>
|
{!isTabletSidebarCollapsed && (
|
||||||
|
<span className="ml-2.5 truncate flex-1">{item.label}</span>
|
||||||
|
)}
|
||||||
|
{!isTabletSidebarCollapsed && item.badge && item.badge > 0 && (
|
||||||
|
<span className="ml-auto px-1.5 py-0.5 rounded-full text-[10px] font-mono font-bold bg-status-danger-soft text-status-danger-text border border-status-danger-border">
|
||||||
|
{item.badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</nav>
|
||||||
|
|
||||||
{/* Sidebar Footer Info */}
|
{/* Footer Info in Sidebar */}
|
||||||
<div className="hidden md:block mt-auto p-3 border-t border-zinc-800/80 text-[11px] text-zinc-500 space-y-1 bg-zinc-950/40">
|
{!isTabletSidebarCollapsed && (
|
||||||
<div className="flex justify-between items-center">
|
<div className="p-3 border-t border-krona-border text-[11px] text-krona-tertiary font-mono space-y-1 bg-krona-surface/30">
|
||||||
<span>Security Gate:</span>
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-emerald-400 font-semibold">Active</span>
|
<span>{RU_UI.system.nodeLabel}:</span>
|
||||||
</div>
|
<span className="font-semibold text-krona-secondary">srv-msk-prod-01</span>
|
||||||
<div className="flex justify-between items-center">
|
</div>
|
||||||
<span>Node Identity:</span>
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-zinc-400 font-mono">178.212.13.102</span>
|
<span>{RU_UI.scope}:</span>
|
||||||
</div>
|
<span className="text-status-success-text">Боевой (Live)</span>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
<NavLink
|
||||||
|
to="/"
|
||||||
{/* Content Workspace */}
|
className="mt-2 pt-2 border-t border-krona-border flex items-center gap-1.5 text-krona-gold hover:underline text-[11px]"
|
||||||
<main className="flex-1 flex flex-col overflow-y-auto bg-[#07130e] p-3 sm:p-4 md:p-6 min-w-0 max-w-full">
|
>
|
||||||
<ServerMonitorOutageBanner />
|
<ExternalLink className="w-3.5 h-3.5" />
|
||||||
{showSavedFeedback && (
|
<span>{RU_UI.system.exitToClient}</span>
|
||||||
<div className="mb-4 p-3 rounded-lg bg-emerald-950/80 border border-emerald-700/60 text-emerald-200 text-xs flex items-center gap-2 animate-in fade-in">
|
</NavLink>
|
||||||
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
|
|
||||||
<span>Демонстрационные данные успешно сброшены и сохранены в IndexedDB.</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Outlet />
|
</aside>
|
||||||
|
|
||||||
|
{/* Main Content Area */}
|
||||||
|
<main className="flex-1 min-w-0 max-w-full overflow-y-auto overflow-x-hidden p-3 sm:p-5 lg:p-6 pb-20 md:pb-6">
|
||||||
|
<div className="max-w-7xl mx-auto w-full">
|
||||||
|
{/* Feedback Alert if Database Reset */}
|
||||||
|
{showSavedFeedback && (
|
||||||
|
<div
|
||||||
|
className="mb-4 p-3.5 rounded-xl border border-status-success-border bg-status-success-soft text-status-success-text text-xs flex items-center gap-2 shadow-md animate-in fade-in"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="w-4 h-4 shrink-0" />
|
||||||
|
<span>Демонстрационные данные успешно сброшены к эталонному состоянию.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Outage Banner */}
|
||||||
|
<ServerMonitorOutageBanner />
|
||||||
|
|
||||||
|
{/* Outlet for Page Routes */}
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Contextual Inspector Drawer */}
|
{/* ========================================================================= */}
|
||||||
<InspectorDrawer />
|
{/* 4. MOBILE BOTTOM BAR (< md) */}
|
||||||
|
{/* ========================================================================= */}
|
||||||
|
<nav
|
||||||
|
className="md:hidden fixed bottom-0 left-0 right-0 h-16 border-t border-krona-border bg-krona-workspace/95 backdrop-blur-md px-1 flex items-center justify-around z-40"
|
||||||
|
data-testid="mobile-bottom-bar"
|
||||||
|
aria-label="Основная мобильная навигация"
|
||||||
|
>
|
||||||
|
{primaryMobileNav.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const isActive = item.end ? location.pathname === item.to : location.pathname.startsWith(item.to);
|
||||||
|
|
||||||
{/* Modal Dialog for Dangerous Operations */}
|
return (
|
||||||
|
<NavLink
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
end={item.end}
|
||||||
|
className={`flex-1 min-h-[48px] min-w-[44px] flex flex-col items-center justify-center rounded-lg py-1 transition-colors relative ${
|
||||||
|
isActive ? 'text-krona-gold font-bold' : 'text-krona-secondary hover:text-krona-primary'
|
||||||
|
}`}
|
||||||
|
data-testid={`bottom-nav-${item.to.replace('/control', '').replace('/', '') || 'overview'}`}
|
||||||
|
>
|
||||||
|
<Icon className={`w-5 h-5 ${isActive ? 'stroke-[2.5]' : ''}`} />
|
||||||
|
<span className="text-[10px] mt-0.5 leading-none truncate max-w-[64px]">{item.label}</span>
|
||||||
|
{item.badge && item.badge > 0 && (
|
||||||
|
<span className="absolute top-1 right-3 w-4 h-4 rounded-full bg-status-danger-text text-white text-[9px] font-bold flex items-center justify-center animate-pulse">
|
||||||
|
{item.badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* "More" Button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsMobileDrawerOpen(true)}
|
||||||
|
className={`flex-1 min-h-[48px] min-w-[44px] flex flex-col items-center justify-center rounded-lg py-1 transition-colors ${
|
||||||
|
isMobileDrawerOpen ? 'text-krona-gold font-bold' : 'text-krona-secondary hover:text-krona-primary'
|
||||||
|
}`}
|
||||||
|
aria-label="Ещё разделы и настройки"
|
||||||
|
data-testid="bottom-nav-more"
|
||||||
|
>
|
||||||
|
<SlidersHorizontal className="w-5 h-5" />
|
||||||
|
<span className="text-[10px] mt-0.5 leading-none">Ещё</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* ========================================================================= */}
|
||||||
|
{/* 5. MOBILE DRAWER / BOTTOM SHEET */}
|
||||||
|
{/* ========================================================================= */}
|
||||||
|
{isMobileDrawerOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex flex-col justify-end bg-black/60 backdrop-blur-xs md:hidden animate-in fade-in duration-200"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Меню оператора платформы"
|
||||||
|
onClick={() => setIsMobileDrawerOpen(false)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full max-h-[85vh] bg-krona-workspace border-t border-krona-border rounded-t-2xl flex flex-col overflow-hidden shadow-2xl animate-in slide-in-from-bottom duration-250"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Sheet Header */}
|
||||||
|
<div className="px-4 py-3 border-b border-krona-border flex items-center justify-between shrink-0 bg-krona-surface">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-6 h-6 rounded-md bg-krona-gold/20 text-krona-gold font-bold flex items-center justify-center text-xs">
|
||||||
|
K
|
||||||
|
</div>
|
||||||
|
<span className="font-bold text-sm text-krona-primary">Панель оператора</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsMobileDrawerOpen(false)}
|
||||||
|
className="min-w-[44px] min-h-[44px] flex items-center justify-center rounded-lg text-krona-secondary hover:text-krona-primary hover:bg-krona-hover"
|
||||||
|
aria-label="Закрыть меню"
|
||||||
|
data-testid="close-mobile-drawer"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable Sheet Content */}
|
||||||
|
<div className="p-4 overflow-y-auto space-y-4">
|
||||||
|
{/* Operator Role Section */}
|
||||||
|
<div className="p-3 rounded-xl bg-krona-surface border border-krona-border">
|
||||||
|
<div className="text-[11px] font-semibold text-krona-tertiary uppercase tracking-wider mb-2">
|
||||||
|
Сессия оператора
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="font-bold text-sm text-krona-primary">{operator.name}</div>
|
||||||
|
<div className="text-xs text-krona-gold font-medium">{RU_ROLES[role]?.label}</div>
|
||||||
|
</div>
|
||||||
|
<OperatorSessionSwitcher variant="compact" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Theme Switcher Section */}
|
||||||
|
<div className="p-3 rounded-xl bg-krona-surface border border-krona-border">
|
||||||
|
<div className="text-[11px] font-semibold text-krona-tertiary uppercase tracking-wider mb-2">
|
||||||
|
{RU_UI.system.themeLabel}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{(['soft-light', 'light', 'dark'] as ThemeMode[]).map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTheme(t)}
|
||||||
|
className={`min-h-[44px] px-2 py-2 rounded-lg text-xs font-semibold flex flex-col items-center justify-center transition-colors border ${
|
||||||
|
themeMode === t
|
||||||
|
? 'border-krona-gold bg-krona-gold/15 text-krona-gold shadow-xs'
|
||||||
|
: 'border-krona-border bg-krona-workspace text-krona-secondary hover:text-krona-primary'
|
||||||
|
}`}
|
||||||
|
data-testid={`mobile-theme-${t}`}
|
||||||
|
>
|
||||||
|
{t === 'soft-light' && <Sparkles className="w-4 h-4 mb-0.5" />}
|
||||||
|
{t === 'light' && <Sun className="w-4 h-4 mb-0.5" />}
|
||||||
|
{t === 'dark' && <Moon className="w-4 h-4 mb-0.5" />}
|
||||||
|
<span className="text-[10px] text-center leading-tight">{RU_THEMES[t]}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* More Navigation Links */}
|
||||||
|
<div className="p-3 rounded-xl bg-krona-surface border border-krona-border">
|
||||||
|
<div className="text-[11px] font-semibold text-krona-tertiary uppercase tracking-wider mb-2">
|
||||||
|
Все разделы
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-1.5">
|
||||||
|
{allNavItems.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const isActive = item.end ? location.pathname === item.to : location.pathname.startsWith(item.to);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
end={item.end}
|
||||||
|
className={`min-h-[44px] px-3 py-2.5 rounded-lg text-xs flex items-center justify-between border transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'border-krona-gold/40 bg-krona-gold/15 text-krona-gold font-bold'
|
||||||
|
: 'border-transparent text-krona-secondary hover:text-krona-primary hover:bg-krona-hover'
|
||||||
|
}`}
|
||||||
|
data-testid={`drawer-nav-${item.to.replace('/control', '').replace('/', '') || 'overview'}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2.5 min-w-0">
|
||||||
|
<Icon className="w-4 h-4 shrink-0" />
|
||||||
|
<span className="truncate">{item.label}</span>
|
||||||
|
</div>
|
||||||
|
{item.badge && item.badge > 0 && (
|
||||||
|
<span className="px-2 py-0.5 rounded-full text-[10px] font-bold bg-status-danger-soft text-status-danger-text border border-status-danger-border">
|
||||||
|
{item.badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* System Actions */}
|
||||||
|
<div className="p-3 rounded-xl bg-krona-surface border border-krona-border space-y-2">
|
||||||
|
<div className="text-[11px] font-semibold text-krona-tertiary uppercase tracking-wider">
|
||||||
|
Сервисные действия
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleServerMonitorOutage}
|
||||||
|
className={`w-full min-h-[44px] px-3 py-2 rounded-lg text-xs font-semibold flex items-center justify-between border transition-colors ${
|
||||||
|
serverMonitorOutage
|
||||||
|
? 'border-status-danger-border bg-status-danger-soft text-status-danger-text'
|
||||||
|
: 'border-krona-border bg-krona-workspace text-krona-secondary hover:text-krona-primary'
|
||||||
|
}`}
|
||||||
|
data-testid="drawer-toggle-outage"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Radio className="w-4 h-4" />
|
||||||
|
<span>Сбой мониторинга (Circuit Breaker)</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[11px] font-mono">
|
||||||
|
{serverMonitorOutage ? 'ВКЛ' : 'ВЫКЛ'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleReset}
|
||||||
|
disabled={isResetting}
|
||||||
|
className="w-full min-h-[44px] px-3 py-2 rounded-lg text-xs font-medium border border-krona-border bg-krona-workspace text-krona-secondary hover:text-krona-primary flex items-center justify-center gap-2 transition-colors"
|
||||||
|
data-testid="drawer-reset-data"
|
||||||
|
>
|
||||||
|
<RotateCcw className={`w-4 h-4 ${isResetting ? 'animate-spin text-krona-gold' : ''}`} />
|
||||||
|
<span>Сбросить демоданные к эталону</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<NavLink
|
||||||
|
to="/"
|
||||||
|
className="w-full min-h-[44px] px-3 py-2 rounded-lg text-xs font-medium border border-krona-border bg-krona-workspace text-krona-gold hover:bg-krona-hover flex items-center justify-center gap-2 transition-colors"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-4 h-4" />
|
||||||
|
<span>Перейти в Krona Business OS</span>
|
||||||
|
</NavLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Node info */}
|
||||||
|
<div className="text-center text-[11px] text-krona-tertiary font-mono pt-1 pb-2">
|
||||||
|
srv-msk-prod-01 • Данные синхронизированы (IndexedDB)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Global Contextual Modals */}
|
||||||
|
<InspectorDrawer />
|
||||||
<ConfirmOperatorActionModal />
|
<ConfirmOperatorActionModal />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,15 @@
|
||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { ShieldCheck, ChevronDown, UserCircle } from 'lucide-react';
|
import { ShieldCheck, ChevronDown, UserCircle } from 'lucide-react';
|
||||||
import { useKccStore, useOperatorRole } from '../store/kccStore';
|
import { useKccStore, useOperatorRole } from '../store/kccStore';
|
||||||
import { OPERATOR_ROLE_DEFINITIONS, OperatorRole } from '../contracts';
|
import { OperatorRole } from '../contracts';
|
||||||
|
import { RU_ROLES } from '../locales/ru';
|
||||||
|
|
||||||
interface OperatorSessionSwitcherProps {
|
interface OperatorSessionSwitcherProps {
|
||||||
onRoleChange?: (role: OperatorRole) => void;
|
onRoleChange?: (role: OperatorRole) => void;
|
||||||
|
variant?: 'compact' | 'full';
|
||||||
}
|
}
|
||||||
|
|
||||||
export const OperatorSessionSwitcher: React.FC<OperatorSessionSwitcherProps> = ({ onRoleChange }) => {
|
export const OperatorSessionSwitcher: React.FC<OperatorSessionSwitcherProps> = ({ onRoleChange, variant = 'full' }) => {
|
||||||
const { role, operator } = useOperatorRole();
|
const { role, operator } = useOperatorRole();
|
||||||
const setActiveRole = useKccStore((state) => state.setActiveRole);
|
const setActiveRole = useKccStore((state) => state.setActiveRole);
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
@ -32,73 +34,85 @@ export const OperatorSessionSwitcher: React.FC<OperatorSessionSwitcherProps> = (
|
||||||
'release_manager',
|
'release_manager',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const handleSelectRole = (newRole: OperatorRole) => {
|
||||||
|
setActiveRole(newRole);
|
||||||
|
setIsOpen(false);
|
||||||
|
onRoleChange?.(newRole);
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentRoleInfo = RU_ROLES[role] || {
|
||||||
|
label: role,
|
||||||
|
shortLabel: role,
|
||||||
|
description: '',
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative inline-block text-left" ref={dropdownRef}>
|
<div className="relative inline-block text-left" ref={dropdownRef}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
className="flex items-center gap-2.5 px-3 py-1.5 rounded-lg border border-amber-500/30 bg-amber-500/10 hover:bg-amber-500/20 text-amber-300 transition-colors text-xs font-medium focus:outline-hidden focus:ring-1 focus:ring-amber-400"
|
className="flex items-center gap-2 px-3 py-2 min-h-[44px] rounded-lg border border-krona-gold/30 bg-krona-gold/10 hover:bg-krona-gold/20 text-krona-gold-text transition-colors text-xs font-medium focus:outline-hidden focus:ring-2 focus:ring-krona-gold"
|
||||||
aria-expanded={isOpen}
|
aria-expanded={isOpen}
|
||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
aria-label="Сессия оператора"
|
aria-label="Сессия оператора"
|
||||||
title="Сменить роль оператора платформы (RBAC демо)"
|
title="Сменить роль оператора платформы (RBAC)"
|
||||||
|
data-testid="operator-session-button"
|
||||||
>
|
>
|
||||||
<ShieldCheck className="w-4 h-4 text-amber-400 shrink-0" />
|
<ShieldCheck className="w-4 h-4 text-krona-gold shrink-0" />
|
||||||
<div className="text-left leading-tight">
|
<div className="text-left leading-tight">
|
||||||
<div className="font-bold text-amber-200 hidden sm:block">{operator.name}</div>
|
{variant === 'full' && (
|
||||||
<div className="text-[10px] text-amber-400/80 uppercase tracking-wider font-mono">
|
<div className="font-bold text-krona-primary hidden sm:block">{operator.name}</div>
|
||||||
{OPERATOR_ROLE_DEFINITIONS[role].label}
|
)}
|
||||||
|
<div className="text-[11px] text-krona-gold font-semibold uppercase tracking-wider">
|
||||||
|
{currentRoleInfo.label}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ChevronDown className={`w-3.5 h-3.5 text-amber-400 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
<ChevronDown className={`w-3.5 h-3.5 text-krona-gold transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div
|
<div
|
||||||
className="absolute right-0 mt-2 w-80 rounded-xl border border-zinc-700/80 bg-[#0d1e17] shadow-2xl z-50 p-2 text-zinc-200 animate-in fade-in slide-in-from-top-2 duration-150"
|
className="absolute right-0 mt-2 w-80 max-w-[calc(100vw-2rem)] rounded-xl border border-krona-border bg-krona-surface shadow-2xl z-50 p-2 text-krona-primary animate-in fade-in slide-in-from-top-2 duration-150"
|
||||||
role="menu"
|
role="menu"
|
||||||
>
|
>
|
||||||
<div className="px-3 py-2 border-b border-zinc-800 text-xs text-zinc-400">
|
<div className="px-3 py-2 border-b border-krona-border text-xs text-krona-secondary">
|
||||||
<span className="font-semibold text-zinc-200">Операторская сессия (RBAC Demo)</span>
|
<span className="font-semibold text-krona-primary">Роль оператора (RBAC)</span>
|
||||||
<p className="text-[11px] text-zinc-500 mt-0.5">
|
<p className="text-[11px] text-krona-tertiary mt-0.5">
|
||||||
Выберите роль для проверки разграничения прав доступа:
|
Выберите роль для проверки разграничения прав доступа:
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="py-1 space-y-1">
|
<div className="py-1 space-y-1 max-h-72 overflow-y-auto">
|
||||||
{rolesList.map((r) => {
|
{rolesList.map((r) => {
|
||||||
const def = OPERATOR_ROLE_DEFINITIONS[r];
|
const info = RU_ROLES[r];
|
||||||
const isSelected = r === role;
|
const isCurrent = r === role;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={r}
|
key={r}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => handleSelectRole(r)}
|
||||||
setActiveRole(r);
|
className={`w-full text-left px-3 py-2.5 min-h-[44px] rounded-lg text-xs transition-colors flex items-start gap-2.5 ${
|
||||||
onRoleChange?.(r);
|
isCurrent
|
||||||
setIsOpen(false);
|
? 'bg-krona-gold/15 text-krona-gold font-semibold border border-krona-gold/30'
|
||||||
}}
|
: 'hover:bg-krona-hover text-krona-secondary hover:text-krona-primary'
|
||||||
className={`w-full text-left px-3 py-2.5 rounded-lg text-xs transition-colors flex items-start gap-2.5 ${
|
|
||||||
isSelected
|
|
||||||
? 'bg-amber-500/20 text-amber-200 border border-amber-500/40 font-medium'
|
|
||||||
: 'hover:bg-zinc-800/80 text-zinc-300'
|
|
||||||
}`}
|
}`}
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
|
data-testid={`role-option-${r}`}
|
||||||
>
|
>
|
||||||
<UserCircle
|
<UserCircle className={`w-4 h-4 mt-0.5 shrink-0 ${isCurrent ? 'text-krona-gold' : 'text-krona-tertiary'}`} />
|
||||||
className={`w-4 h-4 mt-0.5 shrink-0 ${isSelected ? 'text-amber-400' : 'text-zinc-400'}`}
|
<div className="flex-1 min-w-0">
|
||||||
/>
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<span className="font-medium text-krona-primary">{info.label}</span>
|
||||||
<div className="font-bold flex items-center gap-1.5">
|
{isCurrent && (
|
||||||
<span>{def.label}</span>
|
<span className="text-[10px] bg-krona-gold/20 text-krona-gold px-1.5 py-0.2 rounded font-mono">
|
||||||
{isSelected && (
|
Текущая
|
||||||
<span className="text-[10px] px-1.5 py-0.2 rounded-xs bg-amber-500 text-zinc-950 font-extrabold uppercase">
|
|
||||||
Активна
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[11px] text-zinc-400 mt-0.5 leading-snug">{def.description}</div>
|
<p className="text-[11px] text-krona-tertiary mt-0.5 line-clamp-2">
|
||||||
|
{info.description}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -10,19 +10,19 @@ export const ServerMonitorOutageBanner: React.FC = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="mb-4 rounded-xl border border-red-500/50 bg-red-950/40 p-4 text-red-200 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 shadow-lg shadow-red-950/20 animate-in fade-in slide-in-from-top-1"
|
className="mb-4 rounded-xl border border-status-danger-border bg-status-danger-soft p-4 text-status-danger-text flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 shadow-md animate-in fade-in slide-in-from-top-1"
|
||||||
role="alert"
|
role="alert"
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className="p-2 rounded-lg bg-red-500/20 text-red-400 border border-red-500/40 shrink-0 mt-0.5">
|
<div className="p-2 rounded-lg bg-status-danger-text/10 text-status-danger-text border border-status-danger-border shrink-0 mt-0.5">
|
||||||
<AlertOctagon className="w-5 h-5" />
|
<AlertOctagon className="w-5 h-5" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="font-bold text-sm text-red-300">
|
<div className="font-bold text-sm">
|
||||||
Внимание: Сервис Server Monitor временно недоступен (Circuit Breaker Tripped)
|
Авария: Сервис мониторинга недоступен (Circuit Breaker Tripped)
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-red-300/80 mt-0.5">
|
<div className="text-xs opacity-90 mt-0.5">
|
||||||
Прямой mTLS канал с агентами узлов не отвечает. Разделы организаций, подписок и биллинга продолжают штатную
|
Прямой mTLS-канал с агентами узлов не отвечает. Разделы организаций, подписок и биллинга продолжают штатную
|
||||||
работу. Показаны последние закешированные данные телеметрии.
|
работу. Показаны последние закешированные данные телеметрии.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -31,10 +31,10 @@ export const ServerMonitorOutageBanner: React.FC = () => {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={toggleOutage}
|
onClick={toggleOutage}
|
||||||
className="shrink-0 px-3 py-1.5 rounded-lg border border-red-500/40 bg-red-500/20 hover:bg-red-500/30 text-red-200 text-xs font-semibold flex items-center gap-1.5 transition-colors"
|
className="shrink-0 px-3 py-2 min-h-[44px] rounded-lg border border-status-danger-border bg-krona-surface hover:bg-krona-hover text-status-danger-text text-xs font-semibold flex items-center gap-2 transition-colors self-stretch sm:self-auto justify-center"
|
||||||
>
|
>
|
||||||
<RefreshCw className="w-3.5 h-3.5" />
|
<RefreshCw className="w-3.5 h-3.5" />
|
||||||
<span>Восстановить связь (Demo)</span>
|
<span>Восстановить связь</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -61,167 +61,171 @@ export const StatusBadge: React.FC<StatusBadgeProps> = ({ status, customLabel, s
|
||||||
// Platform & tenant
|
// Platform & tenant
|
||||||
active: {
|
active: {
|
||||||
label: 'Активна',
|
label: 'Активна',
|
||||||
className: 'bg-emerald-950/80 text-emerald-400 border-emerald-700/50',
|
className: 'bg-status-success-soft text-status-success-text border-status-success-border',
|
||||||
icon: <CheckCircle2 className={iconSize} aria-hidden="true" />,
|
icon: <CheckCircle2 className={iconSize} aria-hidden="true" />,
|
||||||
},
|
},
|
||||||
trial: {
|
trial: {
|
||||||
label: 'Trial (Пробный)',
|
label: 'Пробный период',
|
||||||
className: 'bg-amber-950/80 text-amber-300 border-amber-700/50',
|
className: 'bg-status-info-soft text-status-info-text border-status-info-border',
|
||||||
icon: <Clock className={iconSize} aria-hidden="true" />,
|
icon: <Clock className={iconSize} aria-hidden="true" />,
|
||||||
},
|
},
|
||||||
grace: {
|
grace: {
|
||||||
label: 'Grace Period',
|
label: 'Льготный период',
|
||||||
className: 'bg-orange-950/80 text-orange-400 border-orange-700/50',
|
className: 'bg-status-warning-soft text-status-warning-text border-status-warning-border',
|
||||||
icon: <AlertTriangle className={iconSize} aria-hidden="true" />,
|
icon: <AlertTriangle className={iconSize} aria-hidden="true" />,
|
||||||
},
|
},
|
||||||
suspended: {
|
suspended: {
|
||||||
label: 'Приостановлена',
|
label: 'Приостановлена',
|
||||||
className: 'bg-rose-950/80 text-rose-400 border-rose-700/50',
|
className: 'bg-status-danger-soft text-status-danger-text border-status-danger-border',
|
||||||
icon: <XCircle className={iconSize} aria-hidden="true" />,
|
icon: <XCircle className={iconSize} aria-hidden="true" />,
|
||||||
},
|
},
|
||||||
closed: {
|
closed: {
|
||||||
label: 'Закрыта',
|
label: 'Закрыта',
|
||||||
className: 'bg-zinc-900 text-zinc-400 border-zinc-700/50',
|
className: 'bg-krona-muted text-krona-secondary border-krona-border',
|
||||||
icon: <XCircle className={iconSize} aria-hidden="true" />,
|
icon: <XCircle className={iconSize} aria-hidden="true" />,
|
||||||
},
|
},
|
||||||
normal: {
|
normal: {
|
||||||
label: 'Оплачена',
|
label: 'Оплачена',
|
||||||
className: 'bg-emerald-950/80 text-emerald-400 border-emerald-700/50',
|
className: 'bg-status-success-soft text-status-success-text border-status-success-border',
|
||||||
icon: <CheckCircle2 className={iconSize} aria-hidden="true" />,
|
icon: <CheckCircle2 className={iconSize} aria-hidden="true" />,
|
||||||
},
|
},
|
||||||
overdue: {
|
overdue: {
|
||||||
label: 'Просрочена',
|
label: 'Просрочена',
|
||||||
className: 'bg-rose-950/80 text-rose-400 border-rose-700/50',
|
className: 'bg-status-danger-soft text-status-danger-text border-status-danger-border',
|
||||||
icon: <XCircle className={iconSize} aria-hidden="true" />,
|
icon: <XCircle className={iconSize} aria-hidden="true" />,
|
||||||
},
|
},
|
||||||
expiring_7d: {
|
expiring_7d: {
|
||||||
label: 'Истекает (< 7 дн.)',
|
label: 'Истекает (< 7 дн.)',
|
||||||
className: 'bg-amber-950/80 text-amber-300 border-amber-700/50 font-semibold',
|
className: 'bg-status-warning-soft text-status-warning-text border-status-warning-border font-semibold',
|
||||||
icon: <Clock className={iconSize} aria-hidden="true" />,
|
icon: <Clock className={iconSize} aria-hidden="true" />,
|
||||||
},
|
},
|
||||||
expiring_30d: {
|
expiring_30d: {
|
||||||
label: 'Истекает (< 30 дн.)',
|
label: 'Истекает (< 30 дн.)',
|
||||||
className: 'bg-sky-950/80 text-sky-300 border-sky-700/50',
|
className: 'bg-status-info-soft text-status-info-text border-status-info-border',
|
||||||
icon: <Clock className={iconSize} aria-hidden="true" />,
|
icon: <Clock className={iconSize} aria-hidden="true" />,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Health
|
// Health
|
||||||
healthy: {
|
healthy: {
|
||||||
label: 'В норме',
|
label: 'Работает нормально',
|
||||||
className: 'bg-emerald-950/80 text-emerald-400 border-emerald-700/50',
|
className: 'bg-status-success-soft text-status-success-text border-status-success-border',
|
||||||
icon: <CheckCircle2 className={iconSize} aria-hidden="true" />,
|
icon: <CheckCircle2 className={iconSize} aria-hidden="true" />,
|
||||||
},
|
},
|
||||||
degraded: {
|
degraded: {
|
||||||
label: 'Деградация',
|
label: 'Работает с ограничениями',
|
||||||
className: 'bg-amber-950/80 text-amber-400 border-amber-700/50',
|
className: 'bg-status-warning-soft text-status-warning-text border-status-warning-border',
|
||||||
icon: <AlertTriangle className={iconSize} aria-hidden="true" />,
|
icon: <AlertTriangle className={iconSize} aria-hidden="true" />,
|
||||||
},
|
},
|
||||||
warning: {
|
warning: {
|
||||||
label: 'Предупреждение',
|
label: 'Предупреждение',
|
||||||
className: 'bg-amber-950/80 text-amber-400 border-amber-700/50',
|
className: 'bg-status-warning-soft text-status-warning-text border-status-warning-border',
|
||||||
icon: <AlertTriangle className={iconSize} aria-hidden="true" />,
|
icon: <AlertTriangle className={iconSize} aria-hidden="true" />,
|
||||||
},
|
},
|
||||||
critical: {
|
critical: {
|
||||||
label: 'Критический',
|
label: 'Критическое состояние',
|
||||||
className: 'bg-red-950/80 text-red-400 border-red-700/50 font-semibold animate-pulse',
|
className: 'bg-status-danger-soft text-status-danger-text border-status-danger-border font-semibold animate-pulse',
|
||||||
icon: <ShieldAlert className={iconSize} aria-hidden="true" />,
|
icon: <ShieldAlert className={iconSize} aria-hidden="true" />,
|
||||||
},
|
},
|
||||||
offline: {
|
offline: {
|
||||||
label: 'Offline / Сбой',
|
label: 'Недоступен',
|
||||||
className: 'bg-zinc-900 text-zinc-400 border-zinc-700/60',
|
className: 'bg-status-danger-soft text-status-danger-text border-status-danger-border',
|
||||||
icon: <Radio className={iconSize} aria-hidden="true" />,
|
icon: <Radio className={iconSize} aria-hidden="true" />,
|
||||||
},
|
},
|
||||||
unknown: {
|
unknown: {
|
||||||
label: 'Неизвестно',
|
label: 'Неизвестно',
|
||||||
className: 'bg-zinc-900 text-zinc-500 border-zinc-800',
|
className: 'bg-krona-muted text-krona-secondary border-krona-border',
|
||||||
icon: <Radio className={iconSize} aria-hidden="true" />,
|
icon: <Radio className={iconSize} aria-hidden="true" />,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Installations
|
// Installations
|
||||||
supported: {
|
supported: {
|
||||||
label: 'Поддерживается',
|
label: 'Поддерживается',
|
||||||
className: 'bg-emerald-950/80 text-emerald-400 border-emerald-700/50',
|
className: 'bg-status-success-soft text-status-success-text border-status-success-border',
|
||||||
icon: <CheckCircle2 className={iconSize} aria-hidden="true" />,
|
icon: <CheckCircle2 className={iconSize} aria-hidden="true" />,
|
||||||
},
|
},
|
||||||
outdated: {
|
outdated: {
|
||||||
label: 'Устарела',
|
label: 'Устарела',
|
||||||
className: 'bg-amber-950/80 text-amber-300 border-amber-700/50',
|
className: 'bg-status-warning-soft text-status-warning-text border-status-warning-border',
|
||||||
icon: <Smartphone className={iconSize} aria-hidden="true" />,
|
icon: <Smartphone className={iconSize} aria-hidden="true" />,
|
||||||
},
|
},
|
||||||
blocked: {
|
blocked: {
|
||||||
label: 'Заблокирована',
|
label: 'Заблокирована',
|
||||||
className: 'bg-rose-950/80 text-rose-400 border-rose-700/50',
|
className: 'bg-status-danger-soft text-status-danger-text border-status-danger-border',
|
||||||
icon: <XCircle className={iconSize} aria-hidden="true" />,
|
icon: <XCircle className={iconSize} aria-hidden="true" />,
|
||||||
},
|
},
|
||||||
revoked: {
|
revoked: {
|
||||||
label: 'Отозвана',
|
label: 'Отозвана',
|
||||||
className: 'bg-zinc-900 text-zinc-400 border-zinc-700',
|
className: 'bg-status-danger-soft text-status-danger-text border-status-danger-border',
|
||||||
icon: <XCircle className={iconSize} aria-hidden="true" />,
|
icon: <XCircle className={iconSize} aria-hidden="true" />,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Rollout
|
// Modules
|
||||||
draft: {
|
|
||||||
label: 'Черновик',
|
|
||||||
className: 'bg-zinc-900 text-zinc-400 border-zinc-700',
|
|
||||||
icon: <Clock className={iconSize} aria-hidden="true" />,
|
|
||||||
},
|
|
||||||
pilot: {
|
pilot: {
|
||||||
label: 'Пилот (Pilot)',
|
label: 'Пилот (Pilot)',
|
||||||
className: 'bg-indigo-950/80 text-indigo-300 border-indigo-700/50',
|
className: 'bg-status-info-soft text-status-info-text border-status-info-border',
|
||||||
icon: <Zap className={iconSize} aria-hidden="true" />,
|
icon: <Zap className={iconSize} aria-hidden="true" />,
|
||||||
},
|
},
|
||||||
|
draft: {
|
||||||
|
label: 'Черновик (Draft)',
|
||||||
|
className: 'bg-krona-muted text-krona-secondary border-krona-border',
|
||||||
|
icon: <Clock className={iconSize} aria-hidden="true" />,
|
||||||
|
},
|
||||||
percentage: {
|
percentage: {
|
||||||
label: 'Поэтапная',
|
label: 'Поэтапный релиз',
|
||||||
className: 'bg-sky-950/80 text-sky-300 border-sky-700/50',
|
className: 'bg-status-warning-soft text-status-warning-text border-status-warning-border',
|
||||||
icon: <Server className={iconSize} aria-hidden="true" />,
|
icon: <Zap className={iconSize} aria-hidden="true" />,
|
||||||
},
|
},
|
||||||
general_availability: {
|
general_availability: {
|
||||||
label: 'Релиз (GA)',
|
label: 'Общая доступность (GA)',
|
||||||
className: 'bg-emerald-950/80 text-emerald-400 border-emerald-700/50',
|
className: 'bg-status-success-soft text-status-success-text border-status-success-border',
|
||||||
icon: <CheckCircle2 className={iconSize} aria-hidden="true" />,
|
icon: <CheckCircle2 className={iconSize} aria-hidden="true" />,
|
||||||
},
|
},
|
||||||
paused: {
|
paused: {
|
||||||
label: 'На паузе',
|
label: 'Приостановлен',
|
||||||
className: 'bg-amber-950/80 text-amber-300 border-amber-700/50',
|
className: 'bg-status-warning-soft text-status-warning-text border-status-warning-border',
|
||||||
icon: <AlertTriangle className={iconSize} aria-hidden="true" />,
|
icon: <AlertTriangle className={iconSize} aria-hidden="true" />,
|
||||||
},
|
},
|
||||||
rolled_back: {
|
rolled_back: {
|
||||||
label: 'Откатан (Rollback)',
|
label: 'Отозван (Rollback)',
|
||||||
className: 'bg-rose-950/80 text-rose-400 border-rose-700/50',
|
className: 'bg-status-danger-soft text-status-danger-text border-status-danger-border',
|
||||||
icon: <XCircle className={iconSize} aria-hidden="true" />,
|
icon: <XCircle className={iconSize} aria-hidden="true" />,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Incidents
|
// Incidents
|
||||||
open: {
|
open: {
|
||||||
label: 'Открыт',
|
label: 'Открыт',
|
||||||
className: 'bg-rose-950/80 text-rose-400 border-rose-700/50 font-semibold',
|
className: 'bg-status-danger-soft text-status-danger-text border-status-danger-border font-semibold',
|
||||||
icon: <AlertTriangle className={iconSize} aria-hidden="true" />,
|
icon: <ShieldAlert className={iconSize} aria-hidden="true" />,
|
||||||
},
|
},
|
||||||
mitigating: {
|
mitigating: {
|
||||||
label: 'Митигация',
|
label: 'Устраняется',
|
||||||
className: 'bg-amber-950/80 text-amber-300 border-amber-700/50',
|
className: 'bg-status-warning-soft text-status-warning-text border-status-warning-border',
|
||||||
icon: <Clock className={iconSize} aria-hidden="true" />,
|
icon: <Server className={iconSize} aria-hidden="true" />,
|
||||||
},
|
},
|
||||||
resolved: {
|
resolved: {
|
||||||
label: 'Разрешен',
|
label: 'Устранён',
|
||||||
className: 'bg-emerald-950/80 text-emerald-400 border-emerald-700/50',
|
className: 'bg-status-success-soft text-status-success-text border-status-success-border',
|
||||||
icon: <CheckCircle2 className={iconSize} aria-hidden="true" />,
|
icon: <CheckCircle2 className={iconSize} aria-hidden="true" />,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const config = configs[status] || configs.unknown;
|
const current = configs[status] || {
|
||||||
const label = customLabel || config.label;
|
label: status,
|
||||||
|
className: 'bg-krona-muted text-krona-secondary border-krona-border',
|
||||||
|
icon: <Radio className={iconSize} aria-hidden="true" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayText = customLabel || current.label;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={`inline-flex items-center gap-1.5 rounded-md border font-medium tabular-nums tracking-wide transition-colors ${
|
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 font-medium transition-colors ${
|
||||||
isSmall ? 'px-2 py-0.5 text-xs' : 'px-2.5 py-1 text-xs'
|
isSmall ? 'text-[11px] py-0.5 px-2' : 'text-xs'
|
||||||
} ${config.className}`}
|
} ${current.className}`}
|
||||||
data-status={status}
|
data-testid={`status-badge-${status}`}
|
||||||
data-testid="status-badge"
|
|
||||||
>
|
>
|
||||||
{config.icon}
|
{current.icon}
|
||||||
<span>{label}</span>
|
<span>{displayText}</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
158
src/features/control-center/locales/ru.ts
Normal file
158
src/features/control-center/locales/ru.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
import type { OperatorRole, ServerStatus, SubscriptionStatus, IncidentSeverity, IncidentStatus, AuditActionType } from '../contracts';
|
||||||
|
|
||||||
|
export const RU_ROLES: Record<OperatorRole, { label: string; shortLabel: string; description: string }> = {
|
||||||
|
platform_owner: {
|
||||||
|
label: 'Владелец платформы',
|
||||||
|
shortLabel: 'Владелец',
|
||||||
|
description: 'Полный доступ ко всей инфраструктуре, биллингу, лицензиям и управлению платформой',
|
||||||
|
},
|
||||||
|
platform_admin: {
|
||||||
|
label: 'Администратор платформы',
|
||||||
|
shortLabel: 'Админ',
|
||||||
|
description: 'Управление организациями, инсталляциями, серверами и техническими параметрами',
|
||||||
|
},
|
||||||
|
support_operator: {
|
||||||
|
label: 'Оператор поддержки',
|
||||||
|
shortLabel: 'Поддержка',
|
||||||
|
description: 'Диагностика организаций, сброс кэша, техническая помощь и расследования',
|
||||||
|
},
|
||||||
|
billing_manager: {
|
||||||
|
label: 'Менеджер биллинга',
|
||||||
|
shortLabel: 'Биллинг',
|
||||||
|
description: 'Финансовые показатели, тарифы, подписки, продления и льготные периоды',
|
||||||
|
},
|
||||||
|
security_auditor: {
|
||||||
|
label: 'Аудитор безопасности',
|
||||||
|
shortLabel: 'Аудитор',
|
||||||
|
description: 'Только чтение: сквозной журнал аудита (SHA-256), события безопасности и логи',
|
||||||
|
},
|
||||||
|
release_manager: {
|
||||||
|
label: 'Релиз-инженер',
|
||||||
|
shortLabel: 'Релизы',
|
||||||
|
description: 'Управление версиями модулей, каталогом расширений и плановыми обновлениями',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RU_SERVER_STATUS: Record<ServerStatus, { label: string; badgeVariant: 'success' | 'warning' | 'danger' | 'neutral' }> = {
|
||||||
|
healthy: { label: 'Работает нормально', badgeVariant: 'success' },
|
||||||
|
degraded: { label: 'Работает с ограничениями', badgeVariant: 'warning' },
|
||||||
|
offline: { label: 'Недоступен', badgeVariant: 'danger' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RU_SUBSCRIPTION_STATUS: Record<SubscriptionStatus, { label: string; badgeVariant: 'success' | 'warning' | 'danger' | 'info' | 'neutral' }> = {
|
||||||
|
trial: { label: 'Пробный период', badgeVariant: 'info' },
|
||||||
|
active: { label: 'Активна', badgeVariant: 'success' },
|
||||||
|
grace: { label: 'Льготный период', badgeVariant: 'warning' },
|
||||||
|
suspended: { label: 'Приостановлена', badgeVariant: 'danger' },
|
||||||
|
canceled: { label: 'Отменена', badgeVariant: 'neutral' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RU_INCIDENT_SEVERITY: Record<IncidentSeverity, { label: string; short: string }> = {
|
||||||
|
P0_CRITICAL: { label: 'P0 Критическая авария', short: 'P0 Критическая' },
|
||||||
|
P1_MAJOR: { label: 'P1 Серьёзный инцидент', short: 'P1 Серьёзный' },
|
||||||
|
P2_DEGRADED: { label: 'P2 Ограничение работы', short: 'P2 Деградация' },
|
||||||
|
P3_INFO: { label: 'P3 Информационный', short: 'P3 Инфо' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RU_INCIDENT_STATUS: Record<IncidentStatus, string> = {
|
||||||
|
investigating: 'Расследуется',
|
||||||
|
identified: 'Локализован',
|
||||||
|
monitoring: 'Мониторинг устранения',
|
||||||
|
resolved: 'Устранён',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RU_AUDIT_ACTION: Record<AuditActionType, string> = {
|
||||||
|
suspend_tenant: 'Приостановка организации',
|
||||||
|
resume_tenant: 'Возобновление организации',
|
||||||
|
drain_node: 'Вывод сервера на обслуживание (Drain)',
|
||||||
|
enable_maintenance: 'Включение режима тех. обслуживания',
|
||||||
|
disable_maintenance: 'Выключение режима тех. обслуживания',
|
||||||
|
grant_grace_period: 'Предоставление льготного периода',
|
||||||
|
restart_gateway: 'Перезапуск шлюза обмена',
|
||||||
|
invalidate_cache: 'Инвалидация распределённого кэша',
|
||||||
|
rollback_module: 'Откат версии модуля',
|
||||||
|
circuit_breaker_trip: 'Аварийное срабатывание Circuit Breaker',
|
||||||
|
circuit_breaker_reset: 'Восстановление связи мониторинга',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RU_MODULE_CATEGORY: Record<string, string> = {
|
||||||
|
core: 'Базовые модули',
|
||||||
|
trade: 'Торговля и продажи',
|
||||||
|
kitchen: 'Кухня и производство',
|
||||||
|
finance: 'Финансы и учёт',
|
||||||
|
management: 'Управление персоналом',
|
||||||
|
analytics: 'Аналитика и AI',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RU_PLATFORMS: Record<string, string> = {
|
||||||
|
web: 'Веб-версия',
|
||||||
|
desktop: 'Компьютеры (Desktop)',
|
||||||
|
android: 'Android-устройства',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RU_THEMES = {
|
||||||
|
'soft-light': 'Мягкая светлая',
|
||||||
|
light: 'Классическая светлая',
|
||||||
|
dark: 'Тёмная',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const RU_UI = {
|
||||||
|
brand: 'Krona Control Center',
|
||||||
|
tagline: 'Кабинет оператора платформы',
|
||||||
|
scope: 'Контур платформы',
|
||||||
|
productionScope: 'Боевой контур (Production)',
|
||||||
|
gateTitle: 'Защита доступа оператора',
|
||||||
|
gateSubtitle: 'Демонстрационный стенд закрытого кабинета оператора платформы',
|
||||||
|
gatePinPlaceholder: 'Введите код доступа (например: krona2026 или 8492)...',
|
||||||
|
gateSubmit: 'Войти в консоль оператора',
|
||||||
|
gateDemoLogin: 'Быстрый демо-вход в 1 клик (Режим ревьюера)',
|
||||||
|
gateError: 'Неверный код доступа. Попробуйте krona2026 или 8492.',
|
||||||
|
forbiddenTitle: 'Доступ ограничен политикой безопасности',
|
||||||
|
forbiddenDescription: 'У текущей роли оператора недостаточно прав для выполнения этого действия.',
|
||||||
|
forbiddenBack: 'Вернуться в Обзор',
|
||||||
|
nav: {
|
||||||
|
overview: 'Обзор',
|
||||||
|
organizations: 'Организации',
|
||||||
|
incidents: 'Инциденты',
|
||||||
|
servers: 'Серверы',
|
||||||
|
more: 'Ещё',
|
||||||
|
billing: 'Подписки и платежи',
|
||||||
|
modules: 'Каталог модулей',
|
||||||
|
installations: 'Установки',
|
||||||
|
announcements: 'Оповещения',
|
||||||
|
audit: 'Журнал аудита',
|
||||||
|
settings: 'Настройки оператора',
|
||||||
|
},
|
||||||
|
overviewTabs: {
|
||||||
|
attention: 'Требует внимания',
|
||||||
|
metrics: 'Показатели',
|
||||||
|
infrastructure: 'Инфраструктура',
|
||||||
|
},
|
||||||
|
filters: {
|
||||||
|
title: 'Фильтры',
|
||||||
|
apply: 'Применить',
|
||||||
|
reset: 'Сбросить фильтры',
|
||||||
|
searchPlaceholder: 'Поиск...',
|
||||||
|
statusAll: 'Все статусы',
|
||||||
|
roleAll: 'Все роли',
|
||||||
|
},
|
||||||
|
inspector: {
|
||||||
|
title: 'Инспектор объекта 360°',
|
||||||
|
tabOverview: 'Сводка',
|
||||||
|
tabModules: 'Модули',
|
||||||
|
tabInstallations: 'Установки',
|
||||||
|
tabBilling: 'Биллинг',
|
||||||
|
close: 'Закрыть',
|
||||||
|
},
|
||||||
|
system: {
|
||||||
|
synced: 'Синхронизировано',
|
||||||
|
syncing: 'Синхронизация...',
|
||||||
|
nodeLabel: 'Узел',
|
||||||
|
resetDemo: 'Сбросить демоданные',
|
||||||
|
simulateOutage: 'Имитировать сбой мониторинга',
|
||||||
|
restoreMonitoring: 'Восстановить мониторинг',
|
||||||
|
themeLabel: 'Тема оформления',
|
||||||
|
exitToClient: 'Перейти в Krona Business OS',
|
||||||
|
operatorProfile: 'Профиль оператора',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -3,6 +3,10 @@ import {
|
||||||
CreditCard,
|
CreditCard,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
|
Calendar,
|
||||||
|
CheckCircle2,
|
||||||
|
Clock,
|
||||||
|
ShieldAlert,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useKccStore, useOperatorRole } from '../store/kccStore';
|
import { useKccStore, useOperatorRole } from '../store/kccStore';
|
||||||
import { StatusBadge } from '../components/StatusBadge';
|
import { StatusBadge } from '../components/StatusBadge';
|
||||||
|
|
@ -14,6 +18,7 @@ export const BillingPage: React.FC = () => {
|
||||||
const renewSubscription = useKccStore((state) => state.renewSubscription);
|
const renewSubscription = useKccStore((state) => state.renewSubscription);
|
||||||
const grantGracePeriod = useKccStore((state) => state.grantGracePeriod);
|
const grantGracePeriod = useKccStore((state) => state.grantGracePeriod);
|
||||||
const openConfirmModal = useKccStore((state) => state.openConfirmModal);
|
const openConfirmModal = useKccStore((state) => state.openConfirmModal);
|
||||||
|
const openInspector = useKccStore((state) => state.openInspector);
|
||||||
const { permissions } = useOperatorRole();
|
const { permissions } = useOperatorRole();
|
||||||
|
|
||||||
const [filter, setFilter] = useState<'all' | 'expiring_7d' | 'expiring_30d' | 'overdue' | 'grace'>('all');
|
const [filter, setFilter] = useState<'all' | 'expiring_7d' | 'expiring_30d' | 'overdue' | 'grace'>('all');
|
||||||
|
|
@ -21,7 +26,7 @@ export const BillingPage: React.FC = () => {
|
||||||
|
|
||||||
const selectedSub = subscriptions.find((s) => s.subscriptionId === selectedSubId) || subscriptions[0];
|
const selectedSub = subscriptions.find((s) => s.subscriptionId === selectedSubId) || subscriptions[0];
|
||||||
|
|
||||||
// Form state
|
// Form state for renewal calculator
|
||||||
const [newPeriod, setNewPeriod] = useState<'month' | 'year'>('year');
|
const [newPeriod, setNewPeriod] = useState<'month' | 'year'>('year');
|
||||||
const [customModules, setCustomModules] = useState<string[] | null>(null);
|
const [customModules, setCustomModules] = useState<string[] | null>(null);
|
||||||
|
|
||||||
|
|
@ -33,20 +38,20 @@ export const BillingPage: React.FC = () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Calculate live preview amount
|
// Calculate live preview amount
|
||||||
const baseRatePerMonth = 1500000; // 15 000,00 ₽ базовый
|
const baseRatePerMonth = 1500000; // 15 000,00 ₽
|
||||||
const moduleRatePerMonth = 400000; // 4 000,00 ₽ за модуль
|
const moduleRatePerMonth = 400000; // 4 000,00 ₽
|
||||||
const monthlyTotal = baseRatePerMonth + activeModulesToDisplay.length * moduleRatePerMonth;
|
const monthlyTotal = baseRatePerMonth + activeModulesToDisplay.length * moduleRatePerMonth;
|
||||||
const calculatedTotal = newPeriod === 'year' ? monthlyTotal * 10 : monthlyTotal;
|
const calculatedTotal = newPeriod === 'year' ? monthlyTotal * 10 : monthlyTotal;
|
||||||
|
|
||||||
const handleRenew = () => {
|
const handleRenew = () => {
|
||||||
if (!selectedSub) return;
|
if (!selectedSub) return;
|
||||||
openConfirmModal({
|
openConfirmModal({
|
||||||
title: `Продление подписки: ${selectedSub.tenantName}`,
|
title: `Продление подписки: ${selectedSub.organizationName}`,
|
||||||
actionName: `subscription.renew (${newPeriod})`,
|
actionName: 'grant_grace_period',
|
||||||
impactSummary: `Подписка будет продлена на ${newPeriod === 'year' ? '1 год' : '1 месяц'}. Состав модулей (${activeModulesToDisplay.length}): ${activeModulesToDisplay.join(', ')}. Расчетная сумма: ${calculatedTotal / 100} ₽.`,
|
impactSummary: `Подписка будет продлена на ${newPeriod === 'year' ? '1 год' : '1 месяц'}. Состав модулей: ${activeModulesToDisplay.join(', ')}. Расчётная сумма: ${calculatedTotal / 100} ₽.`,
|
||||||
targetType: 'subscription',
|
targetType: 'subscription',
|
||||||
targetId: selectedSub.subscriptionId,
|
targetId: selectedSub.subscriptionId,
|
||||||
targetName: selectedSub.tenantName,
|
targetName: selectedSub.organizationName,
|
||||||
onConfirm: async (reason) => {
|
onConfirm: async (reason) => {
|
||||||
await renewSubscription(selectedSub.subscriptionId, newPeriod, activeModulesToDisplay, reason);
|
await renewSubscription(selectedSub.subscriptionId, newPeriod, activeModulesToDisplay, reason);
|
||||||
},
|
},
|
||||||
|
|
@ -56,217 +61,222 @@ export const BillingPage: React.FC = () => {
|
||||||
const handleGrantGrace = () => {
|
const handleGrantGrace = () => {
|
||||||
if (!selectedSub) return;
|
if (!selectedSub) return;
|
||||||
openConfirmModal({
|
openConfirmModal({
|
||||||
title: `Предоставление Grace Period: ${selectedSub.tenantName}`,
|
title: `Предоставление льготного периода: ${selectedSub.organizationName}`,
|
||||||
actionName: 'subscription.grace_period_granted',
|
actionName: 'grant_grace_period',
|
||||||
impactSummary: `Организации будет предоставлена отсрочка блокировки на 10 дней без остановки сервисов.`,
|
impactSummary: `Организации будет предоставлен льготный период (Grace Period) на 14 дней с сохранением доступа ко всем модулям.`,
|
||||||
targetType: 'subscription',
|
targetType: 'subscription',
|
||||||
targetId: selectedSub.subscriptionId,
|
targetId: selectedSub.subscriptionId,
|
||||||
targetName: selectedSub.tenantName,
|
targetName: selectedSub.organizationName,
|
||||||
onConfirm: async (reason) => {
|
onConfirm: async (reason) => {
|
||||||
await grantGracePeriod(selectedSub.subscriptionId, 10, reason);
|
await grantGracePeriod(selectedSub.subscriptionId, 14, reason);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleModuleSelection = (moduleCode: string) => {
|
||||||
|
if (activeModulesToDisplay.includes(moduleCode)) {
|
||||||
|
setCustomModules(activeModulesToDisplay.filter((m) => m !== moduleCode));
|
||||||
|
} else {
|
||||||
|
setCustomModules([...activeModulesToDisplay, moduleCode]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-4 sm:space-y-6" data-testid="billing-page">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 border-b border-zinc-800 pb-4">
|
<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>
|
<div>
|
||||||
<h1 className="text-xl font-bold text-zinc-100 flex items-center gap-2">
|
<h1 className="text-lg sm:text-xl font-bold text-krona-primary flex items-center gap-2">
|
||||||
<CreditCard className="w-5 h-5 text-amber-400" />
|
<CreditCard className="w-5 h-5 text-krona-gold" />
|
||||||
<span>Подписки, лицензии и Entitlements</span>
|
<span>Подписки и биллинг платформы</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xs text-zinc-400 mt-0.5">
|
<p className="text-xs text-krona-secondary mt-0.5">
|
||||||
Управление правами доступа, сроками действия тарифов и составом включенных модулей
|
Контроль сроков действия лицензий, предоставление льготных периодов и продление тарифов
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
<div className="text-xs text-krona-secondary">
|
||||||
<div className="flex items-center gap-1.5 overflow-x-auto pb-1 text-xs">
|
Всего подписок: <span className="font-bold text-krona-primary">{subscriptions.length}</span> (найдено:{' '}
|
||||||
{[
|
<span className="font-bold text-krona-gold">{filteredSubs.length}</span>)
|
||||||
{ id: 'all', label: 'Все' },
|
|
||||||
{ id: 'expiring_7d', label: 'Истекает 7 дней' },
|
|
||||||
{ id: 'expiring_30d', label: 'Истекает 30 дней' },
|
|
||||||
{ id: 'grace', label: 'Grace Period' },
|
|
||||||
{ id: 'suspended', label: 'Просрочено' },
|
|
||||||
].map((tab) => (
|
|
||||||
<button
|
|
||||||
key={tab.id}
|
|
||||||
onClick={() => setFilter(tab.id as typeof filter)}
|
|
||||||
className={`px-3 py-1.5 rounded-lg font-medium transition-colors ${
|
|
||||||
filter === tab.id
|
|
||||||
? 'bg-amber-500/20 text-amber-300 border border-amber-500/40 font-semibold'
|
|
||||||
: 'bg-zinc-900 text-zinc-400 border border-zinc-800 hover:text-zinc-200 hover:bg-zinc-800'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Grid: Subscriptions Table + Simulator Panel */}
|
{/* Filter Tabs */}
|
||||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
<div className="flex items-center gap-1.5 overflow-x-auto pb-1 text-xs">
|
||||||
{/* Left 2 Columns: Subscriptions List */}
|
{[
|
||||||
<div className="xl:col-span-2 rounded-xl border border-zinc-800 bg-[#091a13] overflow-hidden shadow-xl">
|
{ id: 'all', label: 'Все подписки' },
|
||||||
<div className="overflow-x-auto">
|
{ id: 'expiring_7d', label: 'Истекают (< 7 дн.)' },
|
||||||
<table className="w-full text-left text-xs border-collapse">
|
{ id: 'expiring_30d', label: 'Истекают (< 30 дн.)' },
|
||||||
<thead>
|
{ id: 'grace', label: 'Льготный период' },
|
||||||
<tr className="border-b border-zinc-800 bg-zinc-950/70 text-zinc-400 font-semibold uppercase tracking-wider text-[11px]">
|
{ id: 'overdue', label: 'Просроченные' },
|
||||||
<th className="py-3 px-4">Организация / Счёт</th>
|
].map((tab) => (
|
||||||
<th className="py-3 px-3">Статус</th>
|
<button
|
||||||
<th className="py-3 px-3">Период</th>
|
key={tab.id}
|
||||||
<th className="py-3 px-3 text-right">Сумма тарифа</th>
|
type="button"
|
||||||
<th className="py-3 px-3">Срок действия</th>
|
onClick={() => setFilter(tab.id as typeof filter)}
|
||||||
<th className="py-3 px-3">Grace</th>
|
className={`min-h-[44px] px-3.5 py-2 rounded-lg font-medium transition-colors shrink-0 ${
|
||||||
<th className="py-3 px-4 text-center">Выбор</th>
|
filter === tab.id
|
||||||
</tr>
|
? 'bg-krona-gold/15 text-krona-gold border border-krona-gold/30 font-bold'
|
||||||
</thead>
|
: 'bg-krona-surface text-krona-secondary border border-krona-border hover:text-krona-primary hover:bg-krona-hover'
|
||||||
<tbody className="divide-y divide-zinc-800/60">
|
}`}
|
||||||
{filteredSubs.map((sub) => {
|
>
|
||||||
const isSelected = sub.subscriptionId === (selectedSub?.subscriptionId ?? '');
|
{tab.label}
|
||||||
return (
|
</button>
|
||||||
<tr
|
))}
|
||||||
key={sub.subscriptionId}
|
</div>
|
||||||
onClick={() => {
|
|
||||||
setSelectedSubId(sub.subscriptionId);
|
{/* Two Column Layout: List on Left, Calculator on Right */}
|
||||||
setCustomModules(null);
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-4">
|
||||||
setNewPeriod(sub.period);
|
{/* Left Column: Subscriptions List */}
|
||||||
}}
|
<div className="lg:col-span-7 space-y-3">
|
||||||
className={`cursor-pointer transition-colors ${
|
{filteredSubs.map((sub) => {
|
||||||
isSelected ? 'bg-amber-500/10 border-l-2 border-amber-400' : 'hover:bg-zinc-800/40'
|
const isSelected = sub.subscriptionId === selectedSub?.subscriptionId;
|
||||||
}`}
|
const validUntilDate = new Date(sub.validUntil);
|
||||||
>
|
|
||||||
<td className="py-3 px-4">
|
return (
|
||||||
<div className="font-bold text-zinc-100">{sub.tenantName}</div>
|
<div
|
||||||
<div className="text-[11px] text-zinc-400 font-mono mt-0.5">{sub.billingAccountRef}</div>
|
key={sub.subscriptionId}
|
||||||
</td>
|
onClick={() => {
|
||||||
<td className="py-3 px-3">
|
setSelectedSubId(sub.subscriptionId);
|
||||||
<StatusBadge status={sub.status} size="sm" />
|
setCustomModules(null);
|
||||||
</td>
|
}}
|
||||||
<td className="py-3 px-3 text-zinc-300 font-medium">
|
className={`p-4 rounded-xl border transition-all cursor-pointer space-y-3 ${
|
||||||
{sub.period === 'year' ? 'Годовой' : 'Месячный'}
|
isSelected
|
||||||
</td>
|
? 'border-krona-gold bg-krona-surface shadow-md ring-1 ring-krona-gold/30'
|
||||||
<td className="py-3 px-3 text-right">
|
: 'border-krona-border bg-krona-surface/60 hover:bg-krona-surface hover:border-krona-border'
|
||||||
<MoneyDisplay minorUnits={sub.amountMinorUnits} className="text-amber-400 font-bold" />
|
}`}
|
||||||
</td>
|
data-testid={`subscription-card-${sub.subscriptionId}`}
|
||||||
<td className="py-3 px-3 text-zinc-300 font-mono text-[11px]">
|
>
|
||||||
до {new Date(sub.validUntil).toLocaleDateString('ru-RU')}
|
<div className="flex items-start justify-between gap-2">
|
||||||
</td>
|
<div>
|
||||||
<td className="py-3 px-3 text-zinc-400 text-[11px]">
|
<h3 className="font-bold text-sm text-krona-primary">{sub.organizationName}</h3>
|
||||||
{sub.graceUntil ? new Date(sub.graceUntil).toLocaleDateString('ru-RU') : '—'}
|
<div className="text-[11px] text-krona-tertiary mt-0.5">
|
||||||
</td>
|
Тарифный план: <span className="font-semibold text-krona-primary">{sub.planName}</span>
|
||||||
<td className="py-3 px-4 text-center">
|
</div>
|
||||||
<input
|
</div>
|
||||||
type="radio"
|
<StatusBadge status={sub.status} size="sm" />
|
||||||
name="selected_sub"
|
</div>
|
||||||
checked={isSelected}
|
|
||||||
onChange={() => {
|
<div className="grid grid-cols-2 gap-2 text-xs py-2 border-y border-krona-border">
|
||||||
setSelectedSubId(sub.subscriptionId);
|
<div>
|
||||||
setCustomModules(null);
|
<div className="text-[10px] text-krona-tertiary">Стоимость в месяц</div>
|
||||||
setNewPeriod(sub.period);
|
<MoneyDisplay minorUnits={sub.priceMinorUnits} currency={sub.currency} className="font-bold text-krona-gold text-xs" />
|
||||||
}}
|
</div>
|
||||||
className="accent-amber-400 cursor-pointer"
|
<div>
|
||||||
/>
|
<div className="text-[10px] text-krona-tertiary">Действует до</div>
|
||||||
</td>
|
<div className="font-bold text-krona-primary flex items-center gap-1">
|
||||||
</tr>
|
<Calendar className="w-3.5 h-3.5 text-krona-gold" />
|
||||||
);
|
<span>{validUntilDate.toLocaleDateString('ru-RU')}</span>
|
||||||
})}
|
</div>
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
</div>
|
||||||
</div>
|
|
||||||
|
<div className="flex items-center justify-between pt-1">
|
||||||
|
<div className="text-[11px] text-krona-secondary">
|
||||||
|
Подключено модулей: <span className="font-semibold text-krona-primary">{sub.activeModules.length}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
openInspector({ type: 'subscription', id: sub.subscriptionId, title: sub.organizationName, data: sub });
|
||||||
|
}}
|
||||||
|
className="min-h-[44px] px-3 text-xs font-semibold text-krona-gold hover:underline flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<span>360° Инспектор</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{filteredSubs.length === 0 && (
|
||||||
|
<div className="p-8 text-center text-krona-tertiary rounded-xl border border-krona-border bg-krona-surface text-xs">
|
||||||
|
Подписки не найдены по выбранным критериям
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Column: Live Renewal & Module Composition Simulator */}
|
{/* Right Column: Calculator & Renewal Actions */}
|
||||||
<div className="space-y-6">
|
<div className="lg:col-span-5">
|
||||||
{selectedSub ? (
|
{selectedSub ? (
|
||||||
<div className="rounded-xl border border-zinc-800 bg-[#0a1c15] p-5 space-y-4 shadow-xl">
|
<div className="p-5 rounded-xl border border-krona-border bg-krona-surface space-y-4 sticky top-20 shadow-sm">
|
||||||
<div className="flex items-center gap-2 text-sm font-bold text-zinc-100">
|
<div className="border-b border-krona-border pb-3">
|
||||||
<SlidersHorizontal className="w-4 h-4 text-amber-400" />
|
<div className="text-[11px] font-semibold text-krona-gold uppercase tracking-wider">
|
||||||
<span>Продление и состав модулей (Preview)</span>
|
Управление подпиской
|
||||||
|
</div>
|
||||||
|
<h2 className="text-base font-bold text-krona-primary mt-0.5">{selectedSub.organizationName}</h2>
|
||||||
|
<div className="text-xs text-krona-secondary mt-0.5">
|
||||||
|
Текущий статус: <StatusBadge status={selectedSub.status} size="sm" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-3 rounded-lg bg-zinc-950/70 border border-zinc-800 text-xs space-y-1">
|
{/* Period Switcher */}
|
||||||
<div className="text-zinc-400">Организация:</div>
|
<div className="space-y-1.5">
|
||||||
<div className="font-bold text-zinc-200 text-sm">{selectedSub.tenantName}</div>
|
<label className="block text-xs font-semibold text-krona-secondary">
|
||||||
<div className="text-[11px] text-zinc-500 font-mono">{selectedSub.billingAccountRef}</div>
|
Период продления лицензии
|
||||||
</div>
|
</label>
|
||||||
|
|
||||||
{/* Period selection */}
|
|
||||||
<div className="space-y-1.5 text-xs">
|
|
||||||
<label className="font-semibold text-zinc-300">Период действия тарифа:</label>
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setNewPeriod('month')}
|
onClick={() => setNewPeriod('month')}
|
||||||
className={`py-2 px-3 rounded-lg font-medium border text-xs transition-colors ${
|
className={`min-h-[44px] py-2 px-3 rounded-lg text-xs font-semibold border transition-colors ${
|
||||||
newPeriod === 'month'
|
newPeriod === 'month'
|
||||||
? 'bg-amber-500/20 text-amber-300 border-amber-500/50 font-bold'
|
? 'border-krona-gold bg-krona-gold/15 text-krona-gold'
|
||||||
: 'bg-zinc-900 text-zinc-400 border-zinc-800 hover:bg-zinc-800'
|
: 'border-krona-border bg-krona-workspace text-krona-secondary hover:text-krona-primary'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
1 месяц
|
1 месяц (Базовый)
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setNewPeriod('year')}
|
onClick={() => setNewPeriod('year')}
|
||||||
className={`py-2 px-3 rounded-lg font-medium border text-xs transition-colors ${
|
className={`min-h-[44px] py-2 px-3 rounded-lg text-xs font-semibold border transition-colors ${
|
||||||
newPeriod === 'year'
|
newPeriod === 'year'
|
||||||
? 'bg-amber-500/20 text-amber-300 border-amber-500/50 font-bold'
|
? 'border-krona-gold bg-krona-gold/15 text-krona-gold'
|
||||||
: 'bg-zinc-900 text-zinc-400 border-zinc-800 hover:bg-zinc-800'
|
: 'border-krona-border bg-krona-workspace text-krona-secondary hover:text-krona-primary'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
1 год (скидка 17%)
|
1 год (Скидка 16%)
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Modules selection */}
|
{/* Included Modules Checklist */}
|
||||||
<div className="space-y-2 text-xs">
|
<div className="space-y-2">
|
||||||
<div className="flex justify-between items-center font-semibold text-zinc-300">
|
<label className="block text-xs font-semibold text-krona-secondary">
|
||||||
<span>Включенные модули:</span>
|
Состав модулей ({activeModulesToDisplay.length})
|
||||||
<span className="text-amber-400 font-mono">{activeModulesToDisplay.length} выбрано</span>
|
</label>
|
||||||
</div>
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-1.5 max-h-48 overflow-y-auto p-1">
|
||||||
<div className="space-y-1.5 max-h-48 overflow-y-auto pr-1">
|
{modules.map((mod) => {
|
||||||
{modules.map((m) => {
|
const isChecked = activeModulesToDisplay.includes(mod.code);
|
||||||
const isChecked = activeModulesToDisplay.includes(m.moduleId);
|
|
||||||
const isCore = m.moduleId === 'krona-core';
|
|
||||||
return (
|
return (
|
||||||
<label
|
<button
|
||||||
key={m.moduleId}
|
key={mod.code}
|
||||||
className={`flex items-center justify-between p-2 rounded-lg border text-xs cursor-pointer transition-colors ${
|
type="button"
|
||||||
isChecked ? 'bg-zinc-900/90 border-zinc-700 text-zinc-100' : 'bg-zinc-950/40 border-zinc-800/60 text-zinc-500'
|
onClick={() => toggleModuleSelection(mod.code)}
|
||||||
|
className={`min-h-[40px] px-2.5 py-1.5 rounded-lg text-xs border text-left flex items-center justify-between transition-colors ${
|
||||||
|
isChecked
|
||||||
|
? 'border-krona-gold/40 bg-krona-gold/10 text-krona-primary'
|
||||||
|
: 'border-krona-border bg-krona-workspace text-krona-tertiary hover:text-krona-secondary'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<span className="truncate">{mod.name}</span>
|
||||||
<input
|
{isChecked && <CheckCircle2 className="w-3.5 h-3.5 text-krona-gold shrink-0 ml-1" />}
|
||||||
type="checkbox"
|
</button>
|
||||||
checked={isChecked}
|
|
||||||
disabled={isCore}
|
|
||||||
onChange={(e) => {
|
|
||||||
if (e.target.checked) setCustomModules([...activeModulesToDisplay, m.moduleId]);
|
|
||||||
else setCustomModules(activeModulesToDisplay.filter((id) => id !== m.moduleId));
|
|
||||||
}}
|
|
||||||
className="accent-amber-400"
|
|
||||||
/>
|
|
||||||
<span className="font-medium">{m.name}</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-[10px] text-zinc-400 font-mono">v{m.latestVersion}</span>
|
|
||||||
</label>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Price calculation preview */}
|
{/* Total Calculation */}
|
||||||
<div className="p-3.5 rounded-xl bg-zinc-950 border border-amber-500/30 text-xs space-y-2">
|
<div className="p-3.5 rounded-xl bg-krona-workspace border border-krona-border flex items-center justify-between">
|
||||||
<div className="flex justify-between text-zinc-400">
|
<div>
|
||||||
<span>Расчетная сумма (Preview):</span>
|
<div className="text-[11px] text-krona-tertiary">Расчётная стоимость продления</div>
|
||||||
<MoneyDisplay minorUnits={calculatedTotal} className="text-sm font-bold text-amber-400" />
|
<MoneyDisplay minorUnits={calculatedTotal} currency={selectedSub.currency} className="text-lg font-bold text-krona-gold" />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] text-zinc-500">
|
<div className="text-right text-[11px] text-krona-secondary">
|
||||||
* Демо-расчет стоимости лицензии (minor units RUB). Реальные транзакции не производятся.
|
{newPeriod === 'year' ? 'За 12 месяцев' : 'За 1 месяц'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -274,43 +284,28 @@ export const BillingPage: React.FC = () => {
|
||||||
<div className="space-y-2 pt-2">
|
<div className="space-y-2 pt-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!permissions.canEditEntitlements}
|
|
||||||
onClick={handleRenew}
|
onClick={handleRenew}
|
||||||
className="w-full py-2 px-4 rounded-lg bg-amber-500 hover:bg-amber-400 text-zinc-950 font-bold text-xs transition-colors flex items-center justify-center gap-1.5 shadow-md shadow-amber-900/30 disabled:opacity-40"
|
className="w-full min-h-[44px] px-4 py-2.5 rounded-lg bg-krona-gold hover:bg-krona-gold-hover text-zinc-950 font-bold text-xs flex items-center justify-center gap-2 transition-colors shadow-sm"
|
||||||
|
data-testid="renew-subscription-btn"
|
||||||
>
|
>
|
||||||
<RefreshCw className="w-3.5 h-3.5" />
|
<RefreshCw className="w-4 h-4" />
|
||||||
<span>Продлить тариф с новым составом</span>
|
<span>Продлить подписку (Выставить счёт)</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!permissions.canGrantTrialGrace}
|
|
||||||
onClick={handleGrantGrace}
|
onClick={handleGrantGrace}
|
||||||
className="w-full py-2 px-4 rounded-lg bg-zinc-900 border border-zinc-700 hover:bg-zinc-800 text-zinc-300 font-medium text-xs transition-colors disabled:opacity-40"
|
className="w-full min-h-[44px] px-4 py-2 rounded-lg border border-status-warning-border bg-status-warning-soft text-status-warning-text font-semibold text-xs flex items-center justify-center gap-2 transition-colors"
|
||||||
|
data-testid="grant-grace-btn"
|
||||||
>
|
>
|
||||||
Предоставить льготу (Grace 10 дней)
|
<Clock className="w-4 h-4" />
|
||||||
|
<span>Предоставить льготный период (14 дн.)</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Subscription Timeline History */}
|
|
||||||
<div className="pt-3 border-t border-zinc-800 space-y-2 text-xs">
|
|
||||||
<div className="font-semibold text-zinc-300">История изменений подписки:</div>
|
|
||||||
<div className="space-y-1.5 text-[11px]">
|
|
||||||
{selectedSub.history.map((h, i) => (
|
|
||||||
<div key={i} className="p-2 rounded-md bg-zinc-950/60 border border-zinc-850">
|
|
||||||
<div className="flex justify-between text-zinc-400 text-[10px]">
|
|
||||||
<span className="font-bold text-zinc-300">{h.actor}</span>
|
|
||||||
<span>{new Date(h.eventAt).toLocaleDateString('ru-RU')}</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-zinc-200 mt-0.5">{h.description}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-8 rounded-xl border border-zinc-800 bg-[#0a1c15] text-center text-xs text-zinc-500">
|
<div className="p-8 text-center text-krona-tertiary rounded-xl border border-krona-border bg-krona-surface text-xs">
|
||||||
Выберите подписку в таблице слева для управления
|
Выберите организацию из списка для продления или изменения состава модулей
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,15 @@ import React, { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Server,
|
Server,
|
||||||
Search,
|
Search,
|
||||||
PlusCircle,
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
ChevronRight,
|
||||||
|
ShieldAlert,
|
||||||
|
Activity,
|
||||||
|
HardDrive,
|
||||||
|
Cpu,
|
||||||
|
Clock,
|
||||||
|
ExternalLink,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useKccStore, useOperatorRole } from '../store/kccStore';
|
import { useKccStore, useOperatorRole } from '../store/kccStore';
|
||||||
import { StatusBadge } from '../components/StatusBadge';
|
import { StatusBadge } from '../components/StatusBadge';
|
||||||
|
|
@ -20,29 +28,37 @@ export const InfrastructurePage: React.FC = () => {
|
||||||
|
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [providerFilter, setProviderFilter] = useState<'all' | 'Selectel' | 'Timeweb Cloud'>('all');
|
const [providerFilter, setProviderFilter] = useState<'all' | 'Selectel' | 'Timeweb Cloud'>('all');
|
||||||
|
const [expandedDetails, setExpandedDetails] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
const toggleDetails = (id: string, e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setExpandedDetails((prev) => ({ ...prev, [id]: !prev[id] }));
|
||||||
|
};
|
||||||
|
|
||||||
const filteredServers = servers.filter((srv) => {
|
const filteredServers = servers.filter((srv) => {
|
||||||
const matchSearch =
|
const matchSearch =
|
||||||
!search ||
|
!search ||
|
||||||
srv.name.toLowerCase().includes(search.toLowerCase()) ||
|
srv.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
srv.ipAddress.includes(search) ||
|
srv.ipAddress.includes(search) ||
|
||||||
srv.hostname.toLowerCase().includes(search.toLowerCase());
|
srv.hostname.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
srv.organizationName.toLowerCase().includes(search.toLowerCase());
|
||||||
const matchProvider = providerFilter === 'all' || srv.hostingProvider === providerFilter;
|
const matchProvider = providerFilter === 'all' || srv.hostingProvider === providerFilter;
|
||||||
return matchSearch && matchProvider;
|
return matchSearch && matchProvider;
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleCreateIncident = (srv: ServerNode) => {
|
const handleCreateIncident = (srv: ServerNode, e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
openConfirmModal({
|
openConfirmModal({
|
||||||
title: `Создать инцидент для узла: ${srv.name}`,
|
title: `Создать инцидент для сервера: ${srv.name}`,
|
||||||
actionName: 'incident.create_from_alert',
|
actionName: 'circuit_breaker_trip',
|
||||||
impactSummary: `Будет открыт критический операторский инцидент, затронутые организации получат оповещение о расследовании дежурной сменой.`,
|
impactSummary: `Будет открыт критический инцидент платформы, затронутые организации получат уведомление о расследовании.`,
|
||||||
targetType: 'infrastructure',
|
targetType: 'server',
|
||||||
targetId: srv.serverId,
|
targetId: srv.serverId,
|
||||||
targetName: srv.name,
|
targetName: srv.name,
|
||||||
onConfirm: async (reason) => {
|
onConfirm: async (reason) => {
|
||||||
await createIncidentFromAlert(
|
await createIncidentFromAlert(
|
||||||
srv.serverId,
|
srv.serverId,
|
||||||
`Критический сбой узла ${srv.name} (${srv.status.toUpperCase()} alert)`,
|
`Сбой сервера ${srv.name} (${srv.status})`,
|
||||||
'critical',
|
'critical',
|
||||||
reason
|
reason
|
||||||
);
|
);
|
||||||
|
|
@ -50,12 +66,13 @@ export const InfrastructurePage: React.FC = () => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDiagnostics = (srv: ServerNode) => {
|
const handleDiagnostics = (srv: ServerNode, e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
openConfirmModal({
|
openConfirmModal({
|
||||||
title: `Запрос телеметрии: ${srv.name}`,
|
title: `Запрос телеметрии: ${srv.name}`,
|
||||||
actionName: 'infrastructure.diagnostics_dump',
|
actionName: 'invalidate_cache',
|
||||||
impactSummary: `Агенту Server Monitor Agent v${srv.agentVersion} будет направлен запрос выгрузки снапшота ресурсов.`,
|
impactSummary: `Агенту мониторинга v${srv.agentVersion} будет направлен запрос выгрузки моментального снимка состояния.`,
|
||||||
targetType: 'infrastructure',
|
targetType: 'server',
|
||||||
targetId: srv.serverId,
|
targetId: srv.serverId,
|
||||||
targetName: srv.name,
|
targetName: srv.name,
|
||||||
onConfirm: async (reason) => {
|
onConfirm: async (reason) => {
|
||||||
|
|
@ -65,41 +82,42 @@ export const InfrastructurePage: React.FC = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-4 sm:space-y-6" data-testid="infrastructure-page">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 border-b border-zinc-800 pb-4">
|
<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>
|
<div>
|
||||||
<h1 className="text-xl font-bold text-zinc-100 flex items-center gap-2">
|
<h1 className="text-lg sm:text-xl font-bold text-krona-primary flex items-center gap-2">
|
||||||
<Server className="w-5 h-5 text-amber-400" />
|
<Server className="w-5 h-5 text-krona-gold" />
|
||||||
<span>Инфраструктурные узлы (Server Monitor Manager)</span>
|
<span>Инфраструктурные серверы</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xs text-zinc-400 mt-0.5">
|
<p className="text-xs text-krona-secondary mt-0.5">
|
||||||
Антикоррупционный адаптер Server Monitor: телеметрия VPS, сертификаты mTLS, бэкапы и инспекция узлов
|
Управление вычислительными узлами, телеметрией mTLS, бэкапами и нагрузкой
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-xs text-zinc-400">
|
<div className="text-xs text-krona-secondary">
|
||||||
Всего узлов: <span className="font-bold text-zinc-100">{servers.length}</span> (Selectel:{' '}
|
Всего узлов: <span className="font-bold text-krona-primary">{servers.length}</span> (Selectel:{' '}
|
||||||
<span className="text-emerald-400 font-bold">{servers.filter((s) => s.hostingProvider === 'Selectel').length}</span>, Timeweb:{' '}
|
<span className="font-bold text-krona-gold">{servers.filter((s) => s.hostingProvider === 'Selectel').length}</span>, Timeweb:{' '}
|
||||||
<span className="text-sky-400 font-bold">{servers.filter((s) => s.hostingProvider === 'Timeweb Cloud').length}</span>)
|
<span className="font-bold text-krona-gold">{servers.filter((s) => s.hostingProvider === 'Timeweb Cloud').length}</span>)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filter and Search Bar */}
|
{/* Filter and Search Bar */}
|
||||||
<div className="flex flex-col md:flex-row gap-3 items-stretch md:items-center justify-between">
|
<div className="flex flex-col md:flex-row gap-3 items-stretch md:items-center justify-between">
|
||||||
|
{/* Search */}
|
||||||
<div className="relative flex-1 max-w-md">
|
<div className="relative flex-1 max-w-md">
|
||||||
<Search className="w-4 h-4 text-zinc-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
<Search className="w-4 h-4 text-krona-tertiary absolute left-3 top-1/2 -translate-y-1/2" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
placeholder="Поиск по имени сервера, IP или хосту..."
|
placeholder="Поиск по имени сервера, IP, хосту или организации..."
|
||||||
className="w-full bg-[#0b1d16] border border-zinc-800 rounded-lg pl-9 pr-3 py-2 text-xs text-zinc-100 placeholder-zinc-500 focus:outline-hidden focus:border-amber-500 transition-colors"
|
className="w-full bg-krona-surface border border-krona-border rounded-lg pl-9 pr-3 py-2.5 min-h-[44px] text-xs text-krona-primary placeholder-krona-tertiary focus:outline-hidden focus:border-krona-gold transition-colors"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1 text-xs">
|
{/* Provider Filters */}
|
||||||
<span className="text-zinc-500 text-[11px] mr-1">Хостинг:</span>
|
<div className="flex items-center gap-1.5 overflow-x-auto pb-1 text-xs">
|
||||||
{[
|
{[
|
||||||
{ id: 'all', label: 'Все провайдеры' },
|
{ id: 'all', label: 'Все провайдеры' },
|
||||||
{ id: 'Selectel', label: 'Selectel' },
|
{ id: 'Selectel', label: 'Selectel' },
|
||||||
|
|
@ -107,11 +125,12 @@ export const InfrastructurePage: React.FC = () => {
|
||||||
].map((tab) => (
|
].map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
|
type="button"
|
||||||
onClick={() => setProviderFilter(tab.id as typeof providerFilter)}
|
onClick={() => setProviderFilter(tab.id as typeof providerFilter)}
|
||||||
className={`px-3 py-1.5 rounded-lg font-medium transition-colors ${
|
className={`min-h-[44px] px-3.5 py-2 rounded-lg font-medium transition-colors shrink-0 ${
|
||||||
providerFilter === tab.id
|
providerFilter === tab.id
|
||||||
? 'bg-amber-500/20 text-amber-300 border border-amber-500/40 font-bold'
|
? 'bg-krona-gold/15 text-krona-gold border border-krona-gold/30 font-bold'
|
||||||
: 'bg-zinc-900 text-zinc-400 border border-zinc-800 hover:text-zinc-200'
|
: 'bg-krona-surface text-krona-secondary border border-krona-border hover:text-krona-primary hover:bg-krona-hover'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
|
|
@ -120,148 +139,194 @@ export const InfrastructurePage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Servers Grid */}
|
{/* Servers Grid (Responsive Cards) */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 sm:gap-4" data-testid="servers-list">
|
||||||
{filteredServers.map((srv) => {
|
{filteredServers.map((srv) => {
|
||||||
const srvInstances = instances.filter((i) => i.serverId === srv.serverId);
|
const srvInstances = instances.filter((i) => i.serverId === srv.serverId);
|
||||||
const hasOfflineInstances = srvInstances.some((i) => i.health === 'offline');
|
const isExpanded = !!expandedDetails[srv.serverId];
|
||||||
const isProblematic = srv.status === 'offline' || srv.status === 'critical' || srv.status === 'warning';
|
const lastSignalDate = new Date(srv.metrics.lastHeartbeatAt);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={srv.serverId}
|
key={srv.serverId}
|
||||||
onClick={() => openInspector('server', srv.serverId)}
|
onClick={() => openInspector({ type: 'server', id: srv.serverId, title: srv.name, data: srv })}
|
||||||
className={`p-4 rounded-xl border bg-[#091a13] hover:border-zinc-700 cursor-pointer transition-all space-y-3 relative group ${
|
className={`p-4 rounded-xl border bg-krona-surface hover:border-krona-gold/40 cursor-pointer transition-all space-y-3 relative shadow-xs ${
|
||||||
srv.status === 'offline'
|
srv.status === 'critical' || srv.status === 'offline'
|
||||||
? 'border-zinc-700 bg-zinc-950/80'
|
? 'border-status-danger-border bg-status-danger-soft/20'
|
||||||
: srv.status === 'critical'
|
: srv.status === 'warning'
|
||||||
? 'border-rose-800/70 bg-rose-950/20'
|
? 'border-status-warning-border bg-status-warning-soft/20'
|
||||||
: 'border-zinc-800'
|
: 'border-krona-border'
|
||||||
}`}
|
}`}
|
||||||
|
data-testid={`server-card-${srv.serverId}`}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header: Name + Status */}
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<span className="font-bold text-sm text-zinc-100 group-hover:text-amber-300 transition-colors">
|
<span className="font-bold text-sm text-krona-primary truncate">
|
||||||
{srv.name}
|
{srv.name}
|
||||||
</span>
|
</span>
|
||||||
<StatusBadge status={srv.status} size="sm" />
|
<StatusBadge status={srv.status} size="sm" />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[11px] text-zinc-400 font-mono">
|
<div className="text-[11px] text-krona-tertiary font-mono mt-0.5 truncate">
|
||||||
{srv.ipAddress} • {srv.hostingProvider} ({srv.region})
|
{srv.hostname} • {srv.ipAddress} • {srv.hostingProvider} ({srv.region})
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-right">
|
{/* Agent version badge */}
|
||||||
<span className="text-[10px] text-zinc-500 uppercase tracking-wider block font-mono">
|
<span className="text-[10px] text-krona-tertiary font-mono bg-krona-workspace px-2 py-0.5 rounded-sm shrink-0 border border-krona-border">
|
||||||
Agent v{srv.agentVersion}
|
Агент v{srv.agentVersion}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] text-zinc-400 font-mono">
|
</div>
|
||||||
{srv.certificateDaysRemaining}д до серт.
|
|
||||||
</span>
|
{/* Destination / Organization */}
|
||||||
|
<div className="text-xs text-krona-secondary flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<span className="text-krona-tertiary">Назначение: </span>
|
||||||
|
<span className="font-semibold text-krona-primary">{srv.organizationName}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-krona-tertiary flex items-center gap-1">
|
||||||
|
<Clock className="w-3.5 h-3.5" />
|
||||||
|
<span>Сигнал: {lastSignalDate.toLocaleTimeString('ru-RU')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Resource Gauges */}
|
{/* Resource Gauges (CPU, RAM, Disk) */}
|
||||||
<div className="grid grid-cols-3 gap-2 pt-2 border-t border-zinc-850 text-xs">
|
<div className="grid grid-cols-3 gap-2 pt-2 border-t border-krona-border text-xs">
|
||||||
{/* CPU */}
|
{/* CPU */}
|
||||||
<div className="p-2 rounded-lg bg-zinc-950/80 border border-zinc-800 space-y-1">
|
<div className="p-2 rounded-lg bg-krona-workspace border border-krona-border space-y-1">
|
||||||
<div className="flex justify-between text-[11px] text-zinc-400">
|
<div className="flex justify-between text-[11px] text-krona-tertiary">
|
||||||
<span>CPU:</span>
|
<span>CPU</span>
|
||||||
<span className="font-bold font-mono text-zinc-100">{srv.cpuLoadPercent}%</span>
|
<span className={`font-bold font-mono ${srv.metrics.cpuPercent > 80 ? 'text-status-danger-text' : 'text-krona-primary'}`}>
|
||||||
|
{srv.metrics.cpuPercent}%
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full bg-zinc-800 h-1 rounded-full overflow-hidden">
|
<div className="w-full bg-krona-surface h-1.5 rounded-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className={`h-full ${srv.cpuLoadPercent > 80 ? 'bg-rose-500' : 'bg-emerald-500'}`}
|
className={`h-full ${srv.metrics.cpuPercent > 80 ? 'bg-red-500' : 'bg-emerald-500'}`}
|
||||||
style={{ width: `${srv.cpuLoadPercent}%` }}
|
style={{ width: `${srv.metrics.cpuPercent}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* RAM */}
|
{/* RAM */}
|
||||||
<div className="p-2 rounded-lg bg-zinc-950/80 border border-zinc-800 space-y-1">
|
<div className="p-2 rounded-lg bg-krona-workspace border border-krona-border space-y-1">
|
||||||
<div className="flex justify-between text-[11px] text-zinc-400">
|
<div className="flex justify-between text-[11px] text-krona-tertiary">
|
||||||
<span>RAM:</span>
|
<span>RAM</span>
|
||||||
<span className="font-bold font-mono text-zinc-100">
|
<span className={`font-bold font-mono ${srv.metrics.ramPercent > 80 ? 'text-status-danger-text' : 'text-krona-primary'}`}>
|
||||||
{Math.round((srv.ramUsedBytes / srv.ramTotalBytes) * 100)}%
|
{srv.metrics.ramPercent}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full bg-zinc-800 h-1 rounded-full overflow-hidden">
|
<div className="w-full bg-krona-surface h-1.5 rounded-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className="h-full bg-emerald-500"
|
className={`h-full ${srv.metrics.ramPercent > 80 ? 'bg-red-500' : 'bg-emerald-500'}`}
|
||||||
style={{ width: `${(srv.ramUsedBytes / srv.ramTotalBytes) * 100}%` }}
|
style={{ width: `${srv.metrics.ramPercent}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Disk */}
|
{/* Disk */}
|
||||||
<div className="p-2 rounded-lg bg-zinc-950/80 border border-zinc-800 space-y-1">
|
<div className="p-2 rounded-lg bg-krona-workspace border border-krona-border space-y-1">
|
||||||
<div className="flex justify-between text-[11px] text-zinc-400">
|
<div className="flex justify-between text-[11px] text-krona-tertiary">
|
||||||
<span>Диск:</span>
|
<span>Диск</span>
|
||||||
<span className="font-bold font-mono text-zinc-100">
|
<span className={`font-bold font-mono ${srv.metrics.diskPercent > 85 ? 'text-status-danger-text' : 'text-krona-primary'}`}>
|
||||||
{Math.round((srv.diskUsedBytes / srv.diskTotalBytes) * 100)}%
|
{srv.metrics.diskPercent}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full bg-zinc-800 h-1 rounded-full overflow-hidden">
|
<div className="w-full bg-krona-surface h-1.5 rounded-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className={`h-full ${srv.diskUsedBytes / srv.diskTotalBytes > 0.85 ? 'bg-rose-500' : 'bg-amber-400'}`}
|
className={`h-full ${srv.metrics.diskPercent > 85 ? 'bg-red-500' : 'bg-emerald-500'}`}
|
||||||
style={{ width: `${(srv.diskUsedBytes / srv.diskTotalBytes) * 100}%` }}
|
style={{ width: `${srv.metrics.diskPercent}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Heartbeat timeline */}
|
{/* Secondary details collapsible accordion */}
|
||||||
<div className="pt-1 flex justify-between items-center text-xs">
|
{isExpanded && (
|
||||||
<span className="text-[11px] text-zinc-400">Heartbeat (24h):</span>
|
<div className="pt-2 border-t border-krona-border space-y-2.5 text-xs animate-in fade-in duration-150">
|
||||||
<HeartbeatTimeline status={srv.status} count={16} />
|
<div className="grid grid-cols-2 gap-2 text-[11px] text-krona-secondary">
|
||||||
</div>
|
<div>
|
||||||
|
Резервная копия:{' '}
|
||||||
|
<span className={srv.backup.state === 'failed' ? 'text-status-danger-text font-bold' : 'text-status-success-text font-semibold'}>
|
||||||
|
{srv.backup.state === 'healthy' ? 'В норме' : srv.backup.state === 'failed' ? 'Ошибка' : srv.backup.state}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Срок mTLS-сертификата:{' '}
|
||||||
|
<span className="font-mono text-krona-primary font-semibold">{srv.certificateDaysRemaining} дней</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Installed modules & Backups status footer */}
|
<div>
|
||||||
<div className="pt-2 border-t border-zinc-850 flex items-center justify-between text-[11px] text-zinc-400">
|
<div className="text-[11px] text-krona-tertiary mb-1">История сигналов (Heartbeat):</div>
|
||||||
<div>
|
<HeartbeatTimeline history={srv.metrics.heartbeatHistory} compact />
|
||||||
Инстансов модулей:{' '}
|
</div>
|
||||||
<span className={`font-bold ${hasOfflineInstances ? 'text-rose-400' : 'text-zinc-200'}`}>
|
|
||||||
{srvInstances.length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<span>Бэкап:</span>
|
|
||||||
<StatusBadge
|
|
||||||
status={srv.backup.state === 'ok' ? 'healthy' : srv.backup.state === 'failed' ? 'critical' : 'warning'}
|
|
||||||
size="sm"
|
|
||||||
customLabel={srv.backup.state.toUpperCase()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Quick Actions (Сценарий 6) */}
|
<div className="flex items-center gap-2 pt-1">
|
||||||
{isProblematic && (
|
<button
|
||||||
<div className="pt-2 flex gap-2" onClick={(e) => e.stopPropagation()}>
|
type="button"
|
||||||
<button
|
onClick={(e) => handleDiagnostics(srv, e)}
|
||||||
type="button"
|
className="min-h-[38px] px-3 py-1.5 rounded-lg border border-krona-border bg-krona-workspace text-krona-secondary hover:text-krona-primary text-xs font-medium"
|
||||||
disabled={!permissions.canManageIncidents}
|
>
|
||||||
onClick={() => handleCreateIncident(srv)}
|
Диагностика
|
||||||
className="px-2.5 py-1 rounded-md bg-rose-950/80 hover:bg-rose-900/80 text-rose-300 border border-rose-800/60 text-xs font-semibold flex items-center gap-1 transition-colors disabled:opacity-40"
|
</button>
|
||||||
>
|
{(srv.status === 'critical' || srv.status === 'offline') && (
|
||||||
<PlusCircle className="w-3.5 h-3.5" />
|
<button
|
||||||
<span>Создать инцидент</span>
|
type="button"
|
||||||
</button>
|
onClick={(e) => handleCreateIncident(srv, e)}
|
||||||
|
className="min-h-[38px] px-3 py-1.5 rounded-lg border border-status-danger-border bg-status-danger-soft text-status-danger-text text-xs font-bold"
|
||||||
<button
|
>
|
||||||
type="button"
|
Открыть инцидент
|
||||||
disabled={!permissions.canRequestDiagnostics}
|
</button>
|
||||||
onClick={() => handleDiagnostics(srv)}
|
)}
|
||||||
className="px-2.5 py-1 rounded-md bg-zinc-900 hover:bg-zinc-800 text-zinc-300 border border-zinc-700 text-xs font-medium transition-colors disabled:opacity-40"
|
</div>
|
||||||
>
|
|
||||||
Запрос диагностики
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Action Buttons Row */}
|
||||||
|
<div className="flex items-center justify-between pt-1 gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => toggleDetails(srv.serverId, e)}
|
||||||
|
className="min-h-[44px] px-2.5 text-xs text-krona-secondary hover:text-krona-primary flex items-center gap-1 font-medium"
|
||||||
|
aria-expanded={isExpanded}
|
||||||
|
>
|
||||||
|
{isExpanded ? (
|
||||||
|
<>
|
||||||
|
<span>Скрыть детали</span>
|
||||||
|
<ChevronUp className="w-3.5 h-3.5" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span>Вторичные сведения</span>
|
||||||
|
<ChevronDown className="w-3.5 h-3.5" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
openInspector({ type: 'server', id: srv.serverId, title: srv.name, data: srv });
|
||||||
|
}}
|
||||||
|
className="min-h-[44px] px-3 flex items-center gap-1.5 text-xs font-semibold text-krona-gold bg-krona-gold/10 hover:bg-krona-gold/20 rounded-lg border border-krona-gold/30 transition-colors"
|
||||||
|
data-testid={`inspect-server-${srv.serverId}`}
|
||||||
|
>
|
||||||
|
<span>Подробности</span>
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{filteredServers.length === 0 && (
|
||||||
|
<div className="col-span-full p-8 text-center text-krona-tertiary rounded-xl border border-krona-border bg-krona-surface text-xs">
|
||||||
|
Серверные узлы не найдены по заданным критериям фильтрации
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,10 @@ import {
|
||||||
Building2,
|
Building2,
|
||||||
Search,
|
Search,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
Filter,
|
||||||
|
Users,
|
||||||
|
CreditCard,
|
||||||
|
Layers,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useKccStore } from '../store/kccStore';
|
import { useKccStore } from '../store/kccStore';
|
||||||
import { StatusBadge } from '../components/StatusBadge';
|
import { StatusBadge } from '../components/StatusBadge';
|
||||||
|
|
@ -27,22 +31,22 @@ export const OrganizationsPage: React.FC = () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-4 sm:space-y-6" data-testid="organizations-page">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 border-b border-zinc-800 pb-4">
|
<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>
|
<div>
|
||||||
<h1 className="text-xl font-bold text-zinc-100 flex items-center gap-2">
|
<h1 className="text-lg sm:text-xl font-bold text-krona-primary flex items-center gap-2">
|
||||||
<Building2 className="w-5 h-5 text-amber-400" />
|
<Building2 className="w-5 h-5 text-krona-gold" />
|
||||||
<span>Организации платформы (Tenants)</span>
|
<span>Организации платформы</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xs text-zinc-400 mt-0.5">
|
<p className="text-xs text-krona-secondary mt-0.5">
|
||||||
Реестр клиентских организаций, управление статусами, тарифами и 360-анализ тенантов
|
Реестр организаций, управление статусами, тарифами и 360°-анализ тенантов
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-xs text-zinc-400">
|
<div className="text-xs text-krona-secondary">
|
||||||
Всего организаций: <span className="font-bold text-zinc-100">{organizations.length}</span> (найдено:{' '}
|
Всего: <span className="font-bold text-krona-primary">{organizations.length}</span> (найдено:{' '}
|
||||||
<span className="font-bold text-amber-400">{filtered.length}</span>)
|
<span className="font-bold text-krona-gold">{filtered.length}</span>)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -50,32 +54,33 @@ export const OrganizationsPage: React.FC = () => {
|
||||||
<div className="flex flex-col md:flex-row gap-3 items-stretch md:items-center justify-between">
|
<div className="flex flex-col md:flex-row gap-3 items-stretch md:items-center justify-between">
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
<div className="relative flex-1 max-w-md">
|
<div className="relative flex-1 max-w-md">
|
||||||
<Search className="w-4 h-4 text-zinc-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
<Search className="w-4 h-4 text-krona-tertiary absolute left-3 top-1/2 -translate-y-1/2" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
placeholder="Поиск по названию, ИНН или домену..."
|
placeholder="Поиск по названию, ИНН или домену..."
|
||||||
className="w-full bg-[#0b1d16] border border-zinc-800 rounded-lg pl-9 pr-3 py-2 text-xs text-zinc-100 placeholder-zinc-500 focus:outline-hidden focus:border-amber-500 transition-colors"
|
className="w-full bg-krona-surface border border-krona-border rounded-lg pl-9 pr-3 py-2.5 min-h-[44px] text-xs text-krona-primary placeholder-krona-tertiary focus:outline-hidden focus:border-krona-gold transition-colors"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status Filters */}
|
{/* Status Filters */}
|
||||||
<div className="flex items-center gap-1 overflow-x-auto pb-1 text-xs">
|
<div className="flex items-center gap-1.5 overflow-x-auto pb-1 text-xs">
|
||||||
{[
|
{[
|
||||||
{ id: 'all', label: 'Все' },
|
{ id: 'all', label: 'Все' },
|
||||||
{ id: 'active', label: 'Active' },
|
{ id: 'active', label: 'Активные' },
|
||||||
{ id: 'trial', label: 'Trial' },
|
{ id: 'trial', label: 'Пробные' },
|
||||||
{ id: 'grace', label: 'Grace' },
|
{ id: 'grace', label: 'Льготные' },
|
||||||
{ id: 'suspended', label: 'Suspended' },
|
{ id: 'suspended', label: 'Приостановленные' },
|
||||||
].map((tab) => (
|
].map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
|
type="button"
|
||||||
onClick={() => setStatusFilter(tab.id as typeof statusFilter)}
|
onClick={() => setStatusFilter(tab.id as typeof statusFilter)}
|
||||||
className={`px-3 py-1.5 rounded-lg font-medium transition-colors ${
|
className={`min-h-[44px] px-3.5 py-2 rounded-lg font-medium transition-colors shrink-0 ${
|
||||||
statusFilter === tab.id
|
statusFilter === tab.id
|
||||||
? 'bg-amber-500/20 text-amber-300 border border-amber-500/40 font-semibold'
|
? 'bg-krona-gold/15 text-krona-gold border border-krona-gold/30 font-bold'
|
||||||
: 'bg-zinc-900/60 text-zinc-400 border border-zinc-800 hover:text-zinc-200 hover:bg-zinc-800'
|
: 'bg-krona-surface text-krona-secondary border border-krona-border hover:text-krona-primary hover:bg-krona-hover'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
|
|
@ -84,12 +89,79 @@ export const OrganizationsPage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Organizations Table */}
|
{/* ========================================================================= */}
|
||||||
<div className="rounded-xl border border-zinc-800 bg-[#091a13] overflow-hidden shadow-xl">
|
{/* 1. MOBILE CARD VIEW (< lg) */}
|
||||||
|
{/* ========================================================================= */}
|
||||||
|
<div className="lg:hidden space-y-3" data-testid="orgs-mobile-cards">
|
||||||
|
{filtered.map((org) => (
|
||||||
|
<div
|
||||||
|
key={org.tenantId}
|
||||||
|
onClick={() => openInspector({ type: 'organization', id: org.tenantId, title: org.displayName, data: org })}
|
||||||
|
className="rounded-xl border border-krona-border bg-krona-surface p-4 space-y-3 shadow-sm hover:border-krona-gold/40 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
{/* Header: Title + Badge */}
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 className="font-bold text-sm text-krona-primary truncate">{org.displayName}</h3>
|
||||||
|
<div className="text-[11px] text-krona-tertiary font-mono mt-0.5">
|
||||||
|
ИНН {org.inn} • {org.domain}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<StatusBadge status={org.platformStatus} size="sm" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metrics Row */}
|
||||||
|
<div className="grid grid-cols-3 gap-2 py-2 border-y border-krona-border text-center text-xs">
|
||||||
|
<div className="p-1 rounded-md bg-krona-workspace/50">
|
||||||
|
<div className="text-[10px] text-krona-tertiary">Пользователи</div>
|
||||||
|
<div className="font-bold text-krona-primary">{org.userCount} <span className="text-[10px] font-normal text-krona-tertiary">/ {org.branchCount} т.</span></div>
|
||||||
|
</div>
|
||||||
|
<div className="p-1 rounded-md bg-krona-workspace/50">
|
||||||
|
<div className="text-[10px] text-krona-tertiary">Модули</div>
|
||||||
|
<div className="font-bold text-krona-primary">{org.installedModulesCount}</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-1 rounded-md bg-krona-workspace/50">
|
||||||
|
<div className="text-[10px] text-krona-tertiary">MRR</div>
|
||||||
|
<MoneyDisplay minorUnits={org.mrrMinorUnits} className="font-bold text-krona-gold text-xs" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom: Tariff + Inspector Action */}
|
||||||
|
<div className="flex items-center justify-between pt-1">
|
||||||
|
<div className="text-xs text-krona-secondary">
|
||||||
|
Тариф: <span className="font-semibold text-krona-primary">{org.tariffPlan}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
openInspector({ type: 'organization', id: org.tenantId, title: org.displayName, data: org });
|
||||||
|
}}
|
||||||
|
className="min-h-[44px] min-w-[44px] px-3 flex items-center gap-1 text-xs font-semibold text-krona-gold bg-krona-gold/10 hover:bg-krona-gold/20 rounded-lg border border-krona-gold/30 transition-colors"
|
||||||
|
data-testid={`inspect-org-${org.tenantId}`}
|
||||||
|
>
|
||||||
|
<span>360° Инспектор</span>
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<div className="p-8 text-center text-krona-tertiary rounded-xl border border-krona-border bg-krona-surface text-xs">
|
||||||
|
Организации не найдены по заданным критериям фильтрации
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ========================================================================= */}
|
||||||
|
{/* 2. DESKTOP & TABLET TABLE VIEW (hidden on mobile, visible >= lg) */}
|
||||||
|
{/* ========================================================================= */}
|
||||||
|
<div className="hidden lg:block rounded-xl border border-krona-border bg-krona-surface overflow-hidden shadow-sm">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-left text-xs border-collapse">
|
<table className="w-full text-left text-xs border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-zinc-800 bg-zinc-950/70 text-zinc-400 font-semibold uppercase tracking-wider text-[11px]">
|
<tr className="border-b border-krona-border bg-krona-workspace text-krona-tertiary font-semibold uppercase tracking-wider text-[11px]">
|
||||||
<th className="py-3 px-4">Организация / ИНН</th>
|
<th className="py-3 px-4">Организация / ИНН</th>
|
||||||
<th className="py-3 px-3">Статус</th>
|
<th className="py-3 px-3">Статус</th>
|
||||||
<th className="py-3 px-3">Тарифный план</th>
|
<th className="py-3 px-3">Тарифный план</th>
|
||||||
|
|
@ -101,42 +173,42 @@ export const OrganizationsPage: React.FC = () => {
|
||||||
<th className="py-3 px-4 text-right">Действие</th>
|
<th className="py-3 px-4 text-right">Действие</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-zinc-800/60">
|
<tbody className="divide-y divide-krona-border">
|
||||||
{filtered.map((org) => (
|
{filtered.map((org) => (
|
||||||
<tr
|
<tr
|
||||||
key={org.tenantId}
|
key={org.tenantId}
|
||||||
onClick={() => openInspector('organization', org.tenantId)}
|
onClick={() => openInspector({ type: 'organization', id: org.tenantId, title: org.displayName, data: org })}
|
||||||
className="hover:bg-zinc-800/40 cursor-pointer transition-colors group"
|
className="hover:bg-krona-hover cursor-pointer transition-colors group"
|
||||||
>
|
>
|
||||||
<td className="py-3 px-4">
|
<td className="py-3 px-4">
|
||||||
<div className="font-bold text-zinc-100 group-hover:text-amber-300 transition-colors">
|
<div className="font-bold text-krona-primary group-hover:text-krona-gold transition-colors">
|
||||||
{org.displayName}
|
{org.displayName}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[11px] text-zinc-400 font-mono mt-0.5">
|
<div className="text-[11px] text-krona-tertiary font-mono mt-0.5">
|
||||||
ИНН {org.inn} • {org.domain}
|
ИНН {org.inn} • {org.domain}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3 px-3">
|
<td className="py-3 px-3">
|
||||||
<StatusBadge status={org.platformStatus} size="sm" />
|
<StatusBadge status={org.platformStatus} size="sm" />
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3 px-3 font-medium text-zinc-300">{org.tariffPlan}</td>
|
<td className="py-3 px-3 font-medium text-krona-primary">{org.tariffPlan}</td>
|
||||||
<td className="py-3 px-3 text-center tabular-nums">
|
<td className="py-3 px-3 text-center tabular-nums">
|
||||||
<span className="font-bold text-zinc-100">{org.userCount}</span>
|
<span className="font-bold text-krona-primary">{org.userCount}</span>
|
||||||
<span className="text-zinc-500 text-[10px]"> / {org.branchCount}</span>
|
<span className="text-krona-tertiary text-[10px]"> / {org.branchCount}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3 px-3 text-center font-bold text-zinc-200 tabular-nums">
|
<td className="py-3 px-3 text-center font-bold text-krona-primary tabular-nums">
|
||||||
{org.installedModulesCount}
|
{org.installedModulesCount}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3 px-3 text-right">
|
<td className="py-3 px-3 text-right">
|
||||||
<MoneyDisplay minorUnits={org.mrrMinorUnits} className="text-amber-400 font-bold" />
|
<MoneyDisplay minorUnits={org.mrrMinorUnits} className="text-krona-gold font-bold" />
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3 px-3 text-right">
|
<td className="py-3 px-3 text-right">
|
||||||
<MoneyDisplay
|
<MoneyDisplay
|
||||||
minorUnits={org.outstandingDebtMinorUnits}
|
minorUnits={org.outstandingDebtMinorUnits}
|
||||||
className={org.outstandingDebtMinorUnits > 0 ? 'text-rose-400 font-bold' : 'text-zinc-400'}
|
className={org.outstandingDebtMinorUnits > 0 ? 'text-status-danger-text font-bold' : 'text-krona-tertiary'}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3 px-3 text-zinc-400 text-[11px]">
|
<td className="py-3 px-3 text-krona-tertiary text-[11px]">
|
||||||
{new Date(org.lastActiveAt).toLocaleString('ru-RU', {
|
{new Date(org.lastActiveAt).toLocaleString('ru-RU', {
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
month: '2-digit',
|
month: '2-digit',
|
||||||
|
|
@ -149,12 +221,13 @@ export const OrganizationsPage: React.FC = () => {
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
openInspector('organization', org.tenantId);
|
openInspector({ type: 'organization', id: org.tenantId, title: org.displayName, data: org });
|
||||||
}}
|
}}
|
||||||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-md bg-zinc-900 border border-zinc-700 text-zinc-300 hover:text-amber-300 hover:border-amber-500/40 text-[11px] font-medium transition-colors"
|
className="inline-flex items-center gap-1 px-3 py-2 min-h-[38px] rounded-lg bg-krona-workspace border border-krona-border text-krona-primary hover:text-krona-gold hover:border-krona-gold/40 text-[11px] font-medium transition-colors"
|
||||||
|
data-testid={`inspect-org-btn-${org.tenantId}`}
|
||||||
>
|
>
|
||||||
<span>360° Карточка</span>
|
<span>360° Инспектор</span>
|
||||||
<ChevronRight className="w-3 h-3" />
|
<ChevronRight className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -162,7 +235,7 @@ export const OrganizationsPage: React.FC = () => {
|
||||||
|
|
||||||
{filtered.length === 0 && (
|
{filtered.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={9} className="py-8 text-center text-zinc-500">
|
<td colSpan={9} className="py-8 text-center text-krona-tertiary">
|
||||||
Организации не найдены по заданным критериям фильтрации
|
Организации не найдены по заданным критериям фильтрации
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Building2,
|
Building2,
|
||||||
|
|
@ -9,12 +9,20 @@ import {
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
ArrowUpRight,
|
ArrowUpRight,
|
||||||
Activity,
|
Activity,
|
||||||
Radio,
|
AlertTriangle,
|
||||||
|
CheckCircle2,
|
||||||
|
Clock,
|
||||||
|
Layers,
|
||||||
|
HelpCircle,
|
||||||
|
Database,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useKccStore, useOperatorRole } from '../store/kccStore';
|
import { useKccStore, useOperatorRole } from '../store/kccStore';
|
||||||
import { MoneyDisplay } from '../components/MoneyDisplay';
|
import { MoneyDisplay } from '../components/MoneyDisplay';
|
||||||
import { StatusBadge } from '../components/StatusBadge';
|
import { StatusBadge } from '../components/StatusBadge';
|
||||||
import { HeartbeatTimeline } from '../components/HeartbeatTimeline';
|
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 = () => {
|
export const OverviewPage: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
@ -27,6 +35,7 @@ export const OverviewPage: React.FC = () => {
|
||||||
const servers = useKccStore((state) => state.servers);
|
const servers = useKccStore((state) => state.servers);
|
||||||
const incidents = useKccStore((state) => state.incidents);
|
const incidents = useKccStore((state) => state.incidents);
|
||||||
const openInspector = useKccStore((state) => state.openInspector);
|
const openInspector = useKccStore((state) => state.openInspector);
|
||||||
|
const serverMonitorOutage = useKccStore((state) => state.serverMonitorOutage);
|
||||||
|
|
||||||
// Aggregates
|
// Aggregates
|
||||||
const totalOrgs = organizations.length;
|
const totalOrgs = organizations.length;
|
||||||
|
|
@ -41,6 +50,7 @@ export const OverviewPage: React.FC = () => {
|
||||||
|
|
||||||
const expiring7d = subscriptions.filter((s) => s.status === 'expiring_7d');
|
const expiring7d = subscriptions.filter((s) => s.status === 'expiring_7d');
|
||||||
const expiring30d = subscriptions.filter((s) => s.status === 'expiring_30d');
|
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 webInstalls = installations.filter((i) => i.kind === 'web').length;
|
||||||
const desktopInstalls = installations.filter((i) => i.kind === 'desktop').length;
|
const desktopInstalls = installations.filter((i) => i.kind === 'desktop').length;
|
||||||
|
|
@ -52,334 +62,521 @@ export const OverviewPage: React.FC = () => {
|
||||||
const instancesHealthy = instances.filter((i) => i.health === 'healthy');
|
const instancesHealthy = instances.filter((i) => i.health === 'healthy');
|
||||||
|
|
||||||
const serversHealthy = servers.filter((s) => s.status === 'healthy').length;
|
const serversHealthy = servers.filter((s) => s.status === 'healthy').length;
|
||||||
const serversWarning = servers.filter((s) => s.status === 'warning').length;
|
const serversProblematic = servers.filter((s) => s.status !== 'healthy');
|
||||||
const serversCritical = servers.filter((s) => s.status === 'critical').length;
|
|
||||||
const serversOffline = servers.filter((s) => s.status === 'offline').length;
|
|
||||||
|
|
||||||
const failedBackups = servers.filter((s) => s.backup.state === 'failed');
|
const failedBackups = servers.filter((s) => s.backup.state === 'failed');
|
||||||
const openIncidents = incidents.filter((i) => i.status !== 'resolved');
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-4 sm:space-y-6" data-testid="overview-page">
|
||||||
{/* Top Headline */}
|
{/* Top Headline */}
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 border-b border-zinc-800 pb-4">
|
<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>
|
<div>
|
||||||
<h1 className="text-xl font-bold text-zinc-100 flex items-center gap-2">
|
<h1 className="text-lg sm:text-xl font-bold text-krona-primary flex items-center gap-2 flex-wrap">
|
||||||
<span>Сводная операторская панель</span>
|
<span>Сводная панель платформы</span>
|
||||||
<span className="text-xs px-2 py-0.5 rounded-full bg-emerald-950 text-emerald-400 border border-emerald-700/60 font-mono">
|
<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">
|
||||||
Production Scope
|
Контур платформы
|
||||||
</span>
|
</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xs text-zinc-400 mt-0.5">
|
<p className="text-xs text-krona-secondary mt-0.5">
|
||||||
Оперативный контроль тенантов, подписок, модульных инстансов и инфраструктурных узлов Krona OS
|
Оперативный контроль организаций, лицензий, модульных инстансов и серверных узлов Krona
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 text-xs">
|
<div className="flex items-center gap-2 text-xs">
|
||||||
<span className="text-zinc-400">Роль оператора:</span>
|
<span className="text-krona-tertiary">Оператор:</span>
|
||||||
<span className="font-semibold text-amber-400 bg-amber-500/10 px-2 py-1 rounded-md border border-amber-500/20">
|
<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} ({role})
|
{operator.name} ({RU_ROLES[role]?.shortLabel || role})
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Top Row KPI Cards */}
|
{/* Tab Switcher (Mobile-First Priority Controls) */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-3">
|
<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">
|
||||||
{/* Orgs */}
|
<button
|
||||||
<div
|
type="button"
|
||||||
onClick={() => navigate('/control/organizations')}
|
onClick={() => setActiveTab('attention')}
|
||||||
className="p-3.5 rounded-xl bg-[#0b1d16] border border-zinc-800 hover:border-zinc-700 cursor-pointer transition-colors space-y-2"
|
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"
|
||||||
>
|
>
|
||||||
<div className="flex justify-between items-center text-zinc-400 text-xs">
|
<ShieldAlert className={`w-4 h-4 shrink-0 ${attentionCount > 0 ? 'text-status-danger-text' : 'text-status-success-text'}`} />
|
||||||
<span>Организации</span>
|
<span>Требует внимания</span>
|
||||||
<Building2 className="w-4 h-4 text-emerald-400" />
|
{attentionCount > 0 && (
|
||||||
</div>
|
<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">
|
||||||
<div className="text-2xl font-black text-zinc-100 tabular-nums">{totalOrgs}</div>
|
{attentionCount}
|
||||||
<div className="flex gap-1.5 text-[10px] text-zinc-400 flex-wrap">
|
</span>
|
||||||
<span className="text-emerald-400 font-semibold">{activeOrgs} act</span>
|
)}
|
||||||
<span>•</span>
|
</button>
|
||||||
<span className="text-amber-400">{trialOrgs} trial</span>
|
|
||||||
<span>•</span>
|
|
||||||
<span className="text-orange-400">{graceOrgs} grace</span>
|
|
||||||
<span>•</span>
|
|
||||||
<span className="text-rose-400">{suspendedOrgs} susp</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Users & Branches */}
|
<button
|
||||||
<div className="p-3.5 rounded-xl bg-[#0b1d16] border border-zinc-800 space-y-2">
|
type="button"
|
||||||
<div className="flex justify-between items-center text-zinc-400 text-xs">
|
onClick={() => setActiveTab('metrics')}
|
||||||
<span>Пользователи / Точки</span>
|
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 ${
|
||||||
<Users className="w-4 h-4 text-sky-400" />
|
activeTab === 'metrics'
|
||||||
</div>
|
? 'bg-krona-workspace text-krona-primary shadow-xs border border-krona-border font-bold'
|
||||||
<div className="text-2xl font-black text-zinc-100 tabular-nums">{totalUsers}</div>
|
: 'text-krona-secondary hover:text-krona-primary hover:bg-krona-hover'
|
||||||
<div className="text-[10px] text-zinc-400">{totalBranches} филиалов в сети</div>
|
}`}
|
||||||
</div>
|
data-testid="tab-metrics"
|
||||||
|
|
||||||
{/* MRR Revenue */}
|
|
||||||
<div
|
|
||||||
onClick={() => navigate('/control/billing')}
|
|
||||||
className="p-3.5 rounded-xl bg-[#0b1d16] border border-zinc-800 hover:border-zinc-700 cursor-pointer transition-colors space-y-2"
|
|
||||||
>
|
>
|
||||||
<div className="flex justify-between items-center text-zinc-400 text-xs">
|
<Activity className="w-4 h-4 shrink-0 text-krona-gold" />
|
||||||
<span>MRR Доход</span>
|
<span>Показатели</span>
|
||||||
<CreditCard className="w-4 h-4 text-amber-400" />
|
</button>
|
||||||
</div>
|
|
||||||
<div className="text-xl font-black text-amber-400">
|
|
||||||
<MoneyDisplay minorUnits={totalMrrMinorUnits} />
|
|
||||||
</div>
|
|
||||||
<div className="text-[10px] text-zinc-400">
|
|
||||||
Истекают: <span className="text-amber-300 font-semibold">{expiring7d.length} за 7д</span> / {expiring30d.length} за 30д
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Installations */}
|
<button
|
||||||
<div
|
type="button"
|
||||||
onClick={() => navigate('/control/installations')}
|
onClick={() => setActiveTab('infrastructure')}
|
||||||
className="p-3.5 rounded-xl bg-[#0b1d16] border border-zinc-800 hover:border-zinc-700 cursor-pointer transition-colors space-y-2"
|
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"
|
||||||
>
|
>
|
||||||
<div className="flex justify-between items-center text-zinc-400 text-xs">
|
<Server className="w-4 h-4 shrink-0 text-krona-gold" />
|
||||||
<span>Установки ПО</span>
|
<span>Инфраструктура</span>
|
||||||
<Smartphone className="w-4 h-4 text-indigo-400" />
|
</button>
|
||||||
</div>
|
|
||||||
<div className="text-2xl font-black text-zinc-100 tabular-nums">
|
|
||||||
{installations.length}
|
|
||||||
</div>
|
|
||||||
<div className="text-[10px] text-zinc-400">
|
|
||||||
Web: {webInstalls} • Desk: {desktopInstalls} • <span className="text-amber-400 font-semibold">Andr: {androidInstalls}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Modules Health */}
|
|
||||||
<div
|
|
||||||
onClick={() => navigate('/control/modules')}
|
|
||||||
className="p-3.5 rounded-xl bg-[#0b1d16] border border-zinc-800 hover:border-zinc-700 cursor-pointer transition-colors space-y-2"
|
|
||||||
>
|
|
||||||
<div className="flex justify-between items-center text-zinc-400 text-xs">
|
|
||||||
<span>Инстансы модулей</span>
|
|
||||||
<Activity className="w-4 h-4 text-emerald-400" />
|
|
||||||
</div>
|
|
||||||
<div className="text-2xl font-black text-zinc-100 tabular-nums">{instances.length}</div>
|
|
||||||
<div className="text-[10px] text-zinc-400">
|
|
||||||
<span className="text-emerald-400 font-semibold">{instancesHealthy.length} ok</span> •{' '}
|
|
||||||
<span className="text-amber-400">{instancesDegraded.length} degr</span> •{' '}
|
|
||||||
<span className="text-rose-400 font-bold">{instancesOffline.length} off</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* VPS Servers */}
|
|
||||||
<div
|
|
||||||
onClick={() => navigate('/control/infrastructure')}
|
|
||||||
className="p-3.5 rounded-xl bg-[#0b1d16] border border-zinc-800 hover:border-zinc-700 cursor-pointer transition-colors space-y-2"
|
|
||||||
>
|
|
||||||
<div className="flex justify-between items-center text-zinc-400 text-xs">
|
|
||||||
<span>Серверы & VPS</span>
|
|
||||||
<Server className="w-4 h-4 text-teal-400" />
|
|
||||||
</div>
|
|
||||||
<div className="text-2xl font-black text-zinc-100 tabular-nums">{servers.length}</div>
|
|
||||||
<div className="text-[10px] text-zinc-400">
|
|
||||||
<span className="text-emerald-400 font-semibold">{serversHealthy} ok</span> •{' '}
|
|
||||||
<span className="text-amber-400">{serversWarning} warn</span> •{' '}
|
|
||||||
<span className="text-rose-400 font-bold">{serversCritical + serversOffline} crit/off</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Central Strip & Operator Queue */}
|
{/* ========================================================================= */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
{/* TAB 1: ТРЕБУЕТ ВНИМАНИЯ (CRITICAL / PROBLEMS PRIORITY) */}
|
||||||
{/* Central 2 Columns: Infrastructure & Modules Health Matrix */}
|
{/* ========================================================================= */}
|
||||||
<div className="lg:col-span-2 space-y-6">
|
{activeTab === 'attention' && (
|
||||||
{/* Section: Infrastructure Status Strip */}
|
<div className="space-y-4 animate-in fade-in duration-150" data-testid="attention-section">
|
||||||
<div className="rounded-xl border border-zinc-800 bg-[#091a13] p-4 space-y-4">
|
{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 className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div>
|
||||||
<Server className="w-4 h-4 text-amber-400" />
|
<h3 className="font-bold text-sm text-krona-primary">Инфраструктурные серверы Krona</h3>
|
||||||
<h3 className="text-sm font-bold text-zinc-200">
|
<p className="text-xs text-krona-secondary">Текущая загрузка вычислительных узлов платформы</p>
|
||||||
Узлы серверной инфраструктуры (Server Monitor Live)
|
|
||||||
</h3>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => navigate('/control/infrastructure')}
|
onClick={() => navigate('/control/infrastructure')}
|
||||||
className="text-xs text-amber-400 hover:text-amber-300 font-medium flex items-center gap-1"
|
className="min-h-[44px] px-3 text-xs text-krona-gold hover:underline flex items-center gap-1 font-medium"
|
||||||
>
|
>
|
||||||
<span>Все серверы ({servers.length})</span>
|
<span>Управление</span>
|
||||||
<ArrowUpRight className="w-3.5 h-3.5" />
|
<ArrowUpRight className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2.5">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
{servers.slice(0, 5).map((srv) => (
|
{servers.map((srv) => (
|
||||||
<div
|
<div
|
||||||
key={srv.serverId}
|
key={srv.id}
|
||||||
onClick={() => openInspector('server', srv.serverId)}
|
onClick={() => openInspector({ type: 'server', id: srv.id, title: srv.hostname, data: srv })}
|
||||||
className="p-3 rounded-lg bg-zinc-950/60 border border-zinc-800/80 hover:border-zinc-700 cursor-pointer transition-colors flex flex-col sm:flex-row sm:items-center justify-between gap-3 text-xs"
|
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="space-y-1">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div>
|
||||||
<span className="font-bold text-zinc-100">{srv.name}</span>
|
<div className="font-bold text-xs text-krona-primary font-mono">{srv.hostname}</div>
|
||||||
<StatusBadge status={srv.status} size="sm" />
|
<div className="text-[11px] text-krona-tertiary">{srv.organizationName}</div>
|
||||||
<span className="text-[10px] text-zinc-400 font-mono">
|
|
||||||
{srv.hostingProvider} • {srv.region}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-zinc-400 font-mono text-[11px]">
|
<StatusBadge status={srv.status} size="sm" />
|
||||||
IP: {srv.ipAddress} • CPU: {srv.cpuLoadPercent}% • RAM:{' '}
|
</div>
|
||||||
{Math.round((srv.ramUsedBytes / srv.ramTotalBytes) * 100)}% • Диск:{' '}
|
|
||||||
{Math.round((srv.diskUsedBytes / srv.diskTotalBytes) * 100)}%
|
<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>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4 shrink-0">
|
<div className="text-[11px] text-krona-tertiary flex items-center justify-between">
|
||||||
<div className="space-y-1 text-right">
|
<span>Резервная копия:</span>
|
||||||
<div className="text-[10px] text-zinc-400">Heartbeat (24h)</div>
|
<span className={srv.backup.state === 'failed' ? 'text-status-danger-text font-bold' : 'text-status-success-text'}>
|
||||||
<HeartbeatTimeline status={srv.status} count={16} />
|
{srv.backup.state === 'healthy' ? 'Успешно' : srv.backup.state === 'failed' ? 'Ошибка' : srv.backup.state}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Section: Offline & Degraded Module Instances Alert Lane */}
|
|
||||||
<div className="rounded-xl border border-zinc-800 bg-[#091a13] p-4 space-y-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Radio className="w-4 h-4 text-rose-400" />
|
|
||||||
<h3 className="text-sm font-bold text-zinc-200">
|
|
||||||
Модули, требующие вмешательства (Degraded / Offline)
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-zinc-400">
|
|
||||||
{instancesOffline.length + instancesDegraded.length} инстансов
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
{[...instancesOffline, ...instancesDegraded].map((inst) => (
|
|
||||||
<div
|
|
||||||
key={inst.moduleInstanceId}
|
|
||||||
onClick={() => openInspector('server', inst.serverId)}
|
|
||||||
className="p-3 rounded-lg bg-zinc-950/70 border border-zinc-800/90 hover:border-zinc-700 cursor-pointer transition-colors flex items-center justify-between text-xs"
|
|
||||||
>
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="font-semibold text-zinc-100">{inst.moduleName}</span>
|
|
||||||
<StatusBadge status={inst.health} size="sm" />
|
|
||||||
<span className="text-[10px] text-zinc-400 font-mono">v{inst.currentVersion}</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-zinc-400 text-[11px]">
|
|
||||||
Тенант: <span className="text-zinc-200">{inst.tenantName}</span> • Узел:{' '}
|
|
||||||
<span className="text-amber-400/90 font-mono">{inst.serverName}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-right">
|
|
||||||
<span className="text-[11px] text-zinc-400">
|
|
||||||
Last HB: {new Date(inst.lastHeartbeatAt).toLocaleTimeString('ru-RU')}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{instancesOffline.length + instancesDegraded.length === 0 && (
|
<div className="pt-1">
|
||||||
<div className="p-4 rounded-lg bg-zinc-950/40 text-center text-xs text-zinc-500">
|
<HeartbeatTimeline history={srv.metrics.heartbeatHistory} compact />
|
||||||
Все экземпляры модулей функционируют в штатном режиме
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right Column: Operator Action Queue (Очередь операторских действий) */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="rounded-xl border border-amber-500/30 bg-[#0a1c15] p-4 space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<ShieldAlert className="w-4 h-4 text-amber-400" />
|
|
||||||
<h3 className="text-sm font-bold text-amber-300">Очередь внимания (Action Queue)</h3>
|
|
||||||
</div>
|
|
||||||
<span className="text-[10px] px-2 py-0.5 rounded-full bg-amber-500/20 text-amber-300 font-mono font-bold">
|
|
||||||
{openIncidents.length + expiring7d.length + outdatedAndroid} событий
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3 text-xs">
|
|
||||||
{/* Critical Incidents */}
|
|
||||||
{openIncidents.map((inc) => (
|
|
||||||
<div
|
|
||||||
key={inc.incidentId}
|
|
||||||
onClick={() => openInspector('incident', inc.incidentId)}
|
|
||||||
className="p-3 rounded-lg bg-rose-950/40 border border-rose-800/50 hover:border-rose-700 cursor-pointer transition-colors space-y-1.5"
|
|
||||||
>
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="font-mono font-bold text-rose-300">{inc.incidentNumber}</span>
|
|
||||||
<StatusBadge status={inc.status} size="sm" />
|
|
||||||
</div>
|
</div>
|
||||||
<p className="font-semibold text-zinc-100">{inc.summary}</p>
|
|
||||||
<div className="text-[11px] text-zinc-400 flex justify-between">
|
|
||||||
<span>Severity: {inc.severity.toUpperCase()}</span>
|
|
||||||
<span>{new Date(inc.openedAt).toLocaleTimeString('ru-RU')}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Expiring Subscriptions (7 days) */}
|
|
||||||
{expiring7d.map((sub) => (
|
|
||||||
<div
|
|
||||||
key={sub.subscriptionId}
|
|
||||||
onClick={() => navigate('/control/billing')}
|
|
||||||
className="p-3 rounded-lg bg-amber-950/30 border border-amber-700/40 hover:border-amber-600 cursor-pointer transition-colors space-y-1.5"
|
|
||||||
>
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="font-semibold text-amber-200">{sub.tenantName}</span>
|
|
||||||
<StatusBadge status="grace" customLabel="7 дней" size="sm" />
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between text-[11px] text-zinc-400">
|
|
||||||
<span>Сумма тарифа:</span>
|
|
||||||
<MoneyDisplay minorUnits={sub.amountMinorUnits} className="text-amber-400 font-bold" />
|
|
||||||
</div>
|
|
||||||
<div className="text-[10px] text-zinc-500">
|
|
||||||
Окончание: {new Date(sub.validUntil).toLocaleDateString('ru-RU')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Outdated Android Terminals */}
|
|
||||||
{outdatedAndroid > 0 && (
|
|
||||||
<div
|
|
||||||
onClick={() => navigate('/control/installations')}
|
|
||||||
className="p-3 rounded-lg bg-zinc-950 border border-zinc-800 hover:border-zinc-700 cursor-pointer transition-colors space-y-1.5"
|
|
||||||
>
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="font-semibold text-zinc-200">Устаревшие ТСД Android</span>
|
|
||||||
<StatusBadge status="outdated" size="sm" />
|
|
||||||
</div>
|
|
||||||
<p className="text-[11px] text-zinc-400">
|
|
||||||
Обнаружено {outdatedAndroid} терминалов Zebra/Honeywell с устаревшей версией приложения.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Failed Backups */}
|
|
||||||
{failedBackups.map((srv) => (
|
|
||||||
<div
|
|
||||||
key={srv.serverId}
|
|
||||||
onClick={() => openInspector('server', srv.serverId)}
|
|
||||||
className="p-3 rounded-lg bg-rose-950/30 border border-rose-800/40 hover:border-rose-700 cursor-pointer transition-colors space-y-1.5"
|
|
||||||
>
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="font-semibold text-rose-300">Сбой резервного копирования</span>
|
|
||||||
<StatusBadge status="critical" size="sm" customLabel="BACKUP FAIL" />
|
|
||||||
</div>
|
|
||||||
<p className="text-[11px] text-zinc-300">
|
|
||||||
Узел {srv.name}: No space left on device при ночной ротации.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue