feat(control-center): mobile-first overhaul, 3 brandbook themes, Russian localization, and full Playwright verification

This commit is contained in:
ochenstarik 2026-09-11 10:33:30 +07:00
parent a2b0a183e2
commit 50d9059f21
47 changed files with 668 additions and 725 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1 +0,0 @@
import{f as e,i as t,r as n,s as r}from"./useToast-CS8pZhce.js";import{ct as i,t as a}from"./index-CT8iDYYK.js";import{t as o}from"./types-CTcNpN0z.js";e();var s=r(),c=()=>{let{customCapabilities:e,toggleCapability:r}=i();return(0,s.jsxs)(`div`,{className:`space-y-4 animate-fade-in`,children:[(0,s.jsxs)(`div`,{className:`flex flex-col sm:flex-row sm:items-center justify-between gap-3 bg-krona-surface p-3.5 rounded-xl border border-krona-border shadow-krona-soft`,children:[(0,s.jsxs)(`div`,{children:[(0,s.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,s.jsx)(`h2`,{className:`text-sm font-bold text-krona-primary`,children:`Каталог приложений & Модульная экосистема Кроны`}),(0,s.jsx)(a,{level:`demo`,size:`sm`})]}),(0,s.jsx)(`p`,{className:`text-xs text-krona-secondary mt-0.5`,children:`Подключайте и отключайте функциональные блоки для адаптации платформы под новые ниши`})]}),(0,s.jsxs)(n,{variant:`gold`,size:`md`,children:[`Всего модулей: `,o.length]})]}),(0,s.jsx)(`div`,{className:`grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3.5`,children:o.map(i=>{let a=e[i.id];return(0,s.jsxs)(`div`,{className:`p-4 rounded-xl border border-krona-border bg-krona-surface shadow-krona-soft flex flex-col justify-between space-y-3`,children:[(0,s.jsxs)(`div`,{children:[(0,s.jsxs)(`div`,{className:`flex items-center justify-between mb-1.5`,children:[(0,s.jsx)(`span`,{className:`text-[10px] uppercase font-bold text-krona-secondary`,children:i.category}),(0,s.jsx)(n,{variant:a?`green`:`neutral`,size:`sm`,children:a?`Активен`:`Отключен`})]}),(0,s.jsx)(`h4`,{className:`text-xs font-bold text-krona-primary`,children:i.name}),(0,s.jsx)(`p`,{className:`text-[11px] text-krona-secondary mt-1 leading-relaxed`,children:i.description})]}),(0,s.jsx)(`div`,{className:`pt-2 border-t border-krona-border/50 flex justify-end`,children:(0,s.jsx)(t,{variant:a?`secondary`:`primary`,size:`sm`,className:`w-full text-xs`,onClick:()=>r(i.id),children:a?`Отключить модуль`:`Включить в контур`})})]},i.id)})})]})};export{c as AppsCatalogModule};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1 +0,0 @@
import{f as e,h as t}from"./useToast-CS8pZhce.js";import{et as n,it as r}from"./index-CT8iDYYK.js";var i=t(e(),1);function a(){let[e,t]=(0,i.useState)([]),[a,o]=(0,i.useState)([]),[s,c]=(0,i.useState)([]),[l,u]=(0,i.useState)([]),[d,f]=(0,i.useState)(!0),[p,m]=(0,i.useState)(null),h=(0,i.useCallback)(async()=>{try{f(!0),m(null);let[e,n,i,a]=await Promise.all([r.warehouses.list({pageSize:50}),r.stockItems.list({pageSize:200}),r.movements.list({pageSize:100}),r.receipts.list({pageSize:100})]);t(e.items),o(n.items),c(i.items),u(a.items)}catch(e){m(e instanceof Error?e:Error(String(e)))}finally{f(!1)}},[]);return(0,i.useEffect)(()=>{let e=!0;return Promise.resolve().then(()=>{e&&h()}),()=>{e=!1}},[h]),n(`records:changed`,e=>{(e.entityType===`warehouse`||e.entityType===`stock_item`||e.entityType===`stock_movement`||e.entityType===`goods_receipt`||e.entityType===`stock_reservation`)&&h()}),n(`storage:reset`,h),n(`snapshot:imported`,h),{warehouses:e,stockItems:a,movements:s,receipts:l,loading:d,error:p,refetch:h,adjustStock:(0,i.useCallback)(async(e,t,n,i)=>r.adjustStock(e,t,n,i),[]),reserveStock:(0,i.useCallback)(async(e,t,n,i)=>r.reserveStock(e,t,n,i),[]),cancelOrderReservations:(0,i.useCallback)(async e=>r.cancelOrderReservations(e),[]),receiveGoods:(0,i.useCallback)(async e=>r.receiveGoods(e),[]),createAndReceiveGoods:(0,i.useCallback)(async(e,t,n,i)=>r.createAndReceiveGoods(e,t,n,i),[])}}export{a as t};

View file

@ -1 +0,0 @@
import{f as e,h as t}from"./useToast-CS8pZhce.js";import{et as n,tt as r}from"./index-CT8iDYYK.js";var i=t(e(),1);function a(){let[e,t]=(0,i.useState)([]),[a,o]=(0,i.useState)([]),[s,c]=(0,i.useState)([]),[l,u]=(0,i.useState)([]),[d,f]=(0,i.useState)(!0),[p,m]=(0,i.useState)(null),h=(0,i.useCallback)(async()=>{try{f(!0),m(null);let[e,n,i,a]=await Promise.all([r.orders.list({pageSize:100}),r.products.list({pageSize:100}),r.payments.list({pageSize:100}),r.shipments.list({pageSize:100})]);t(e.items),o(n.items),c(i.items),u(a.items)}catch(e){m(e instanceof Error?e:Error(String(e)))}finally{f(!1)}},[]);return(0,i.useEffect)(()=>{let e=!0;return Promise.resolve().then(()=>{e&&h()}),()=>{e=!1}},[h]),n(`records:changed`,e=>{(e.entityType===`order`||e.entityType===`product`||e.entityType===`payment`||e.entityType===`shipment`)&&h()}),n(`storage:reset`,h),n(`snapshot:imported`,h),{orders:e,products:a,payments:s,shipments:l,loading:d,error:p,refetch:h,createOrder:(0,i.useCallback)(async e=>r.orders.create(e),[]),updateStatus:(0,i.useCallback)(async(e,t)=>r.updateOrderStatus(e,t),[]),confirmOrder:(0,i.useCallback)(async(e,t)=>r.confirmOrder(e,t),[]),cancelOrder:(0,i.useCallback)(async e=>r.cancelOrder(e),[]),createPayment:(0,i.useCallback)(async(e,t,n,i=!0)=>r.createPayment(e,t,n,i),[]),createShipment:(0,i.useCallback)(async(e,t,n)=>r.createShipment(e,t,n),[]),processReturn:(0,i.useCallback)(async(e,t,n,i=`wh_main`,a=!0)=>r.processReturn(e,t,n,i,a),[])}}export{a as t};

34
dist/index.html vendored
View file

@ -1,20 +1,20 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/branding/krona-symbol.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Крона — Universal Business OS</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<script type="module" crossorigin src="/assets/index-CT8iDYYK.js"></script>
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/branding/krona-symbol.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Крона — Universal Business OS</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<script type="module" crossorigin src="/assets/index-CNnq9VO5.js"></script>
<link rel="modulepreload" crossorigin href="/assets/useToast-CS8pZhce.js">
<link rel="modulepreload" crossorigin href="/assets/demo-D1nHL60J.js">
<link rel="modulepreload" crossorigin href="/assets/indexedDb-D30TbhA6.js">
<link rel="stylesheet" crossorigin href="/assets/index-D03nV9JP.css">
</head>
<body class="bg-krona-app text-krona-primary antialiased selection:bg-krona-gold/20 selection:text-krona-gold-text">
<div id="root"></div>
</body>
</html>
<link rel="stylesheet" crossorigin href="/assets/index-D3QQttR0.css">
</head>
<body class="bg-krona-app text-krona-primary antialiased selection:bg-krona-gold/20 selection:text-krona-gold-text">
<div id="root"></div>
</body>
</html>

View file

@ -1,272 +1,285 @@
import { test, expect } from '@playwright/test';
test.describe('Krona Control Center - Production UX & Operator Scenarios', () => {
test.beforeEach(async ({ page }) => {
// ---------------------------------------------------------------------------
// 1. ACCESS GATE LOGIN & VALIDATION (Isolated describe without pre-auth)
// ---------------------------------------------------------------------------
test.describe('Krona Control Center - Access Gate', () => {
test('01. Access Gate redirects unauthorized users and authenticates with PIN krona2026', async ({ page }) => {
// Clear any existing localStorage before page loads
await page.addInitScript(() => {
localStorage.clear();
});
await page.goto('/control');
await page.waitForURL('**/control/gate');
await expect(page.locator('h1:has-text("Защита доступа")')).toBeVisible();
// Invalid PIN
const pinInput = page.locator('[data-testid="pin-input"]');
await pinInput.fill('wrong_pin_123');
await page.locator('[data-testid="pin-submit"]').click();
await expect(page.locator('[data-testid="pin-error"]')).toBeVisible();
// Valid PIN: krona2026
await pinInput.fill('krona2026');
await page.locator('[data-testid="pin-submit"]').click();
await page.waitForURL('**/control');
await expect(page.locator('h1:has-text("Сводная операторская панель")')).toBeVisible({ timeout: 15000 });
});
});
// ---------------------------------------------------------------------------
// 2. MAIN PRODUCTION & MOBILE-FIRST SUITE (Pre-authorized via kcc_access_gate)
// ---------------------------------------------------------------------------
test.describe('Krona Control Center - Mobile-First & Production UX Suite', () => {
test.beforeEach(async ({ context }) => {
await context.addInitScript(() => {
localStorage.setItem('kcc_access_gate', '1');
});
});
test('02. Mobile menu (burger) opens drawer and navigates across sections', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/control');
await page.waitForLoadState('domcontentloaded');
await expect(page.locator('text=IndexedDB Synced')).toBeVisible({ timeout: 15000 });
});
await expect(page.locator('h1:has-text("Сводная операторская панель")')).toBeVisible({ timeout: 15000 });
test('01. Shell loads with zero horizontal overflow and topbar controls', async ({ page }) => {
await expect(page.locator('text=Krona Control Center').first()).toBeVisible();
await expect(page.locator('button[aria-label="Сессия оператора"]')).toBeVisible();
const menuBtn = page.locator('[data-testid="mobile-menu-button"]');
await expect(menuBtn).toBeVisible();
await menuBtn.click();
const isOverflowing = await page.evaluate(() => {
return document.documentElement.scrollWidth > document.documentElement.clientWidth;
});
expect(isOverflowing).toBe(false);
// Drawer opens
const drawerNavOrg = page.locator('[data-testid="drawer-nav-organizations"]');
await expect(drawerNavOrg).toBeVisible();
await drawerNavOrg.click();
await expect(page.locator('text=Сводная операторская панель')).toBeVisible();
await expect(page.locator('text=Организации').first()).toBeVisible();
await expect(page.locator('text=Пользователи / Точки').first()).toBeVisible();
});
test('02. Scenario 1: Search organization and open 360 inspector drawer', async ({ page }) => {
await page.click('a[href="/control/organizations"]');
await page.waitForURL('**/control/organizations');
await expect(page.locator('h1:has-text("Организации платформы")')).toBeVisible();
});
// Search input
const searchInput = page.locator('input[placeholder*="Поиск"]');
await expect(searchInput).toBeVisible();
await searchInput.fill('Ритейл');
test('03. Switch 3 brandbook themes (soft-light, light, dark) and verify data-theme on html', async ({ page }) => {
await page.goto('/control');
await page.waitForLoadState('domcontentloaded');
await expect(page.locator('h1:has-text("Сводная операторская панель")')).toBeVisible({ timeout: 15000 });
// Filtered result should show Retail Group
await expect(page.locator('text=Северная Ритейл Группа').first()).toBeVisible();
const isMobile = await page.locator('[data-testid="mobile-menu-button"]').isVisible();
if (isMobile) {
await page.locator('[data-testid="mobile-menu-button"]').click();
// Click 360 card button
const inspectBtn = page.locator('button:has-text("360° Карточка")').first();
// Soft-light
await page.locator('[data-testid="mobile-theme-soft-light"]').click();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'soft-light');
// Light
await page.locator('[data-testid="mobile-theme-light"]').click();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
// Dark
await page.locator('[data-testid="mobile-theme-dark"]').click();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
// Back to soft-light
await page.locator('[data-testid="mobile-theme-soft-light"]').click();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'soft-light');
await page.locator('[data-testid="close-mobile-drawer"]').click();
} else {
// Desktop theme switcher
await page.locator('[data-testid="theme-toggle-light"]').click();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
await page.locator('[data-testid="theme-toggle-dark"]').click();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
await page.locator('[data-testid="theme-toggle-soft-light"]').click();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'soft-light');
}
});
test('04. Zero horizontal overflow on all 7 target screen sizes', async ({ page }) => {
test.setTimeout(60000);
const viewports = [
{ width: 360, height: 800 },
{ width: 390, height: 844 },
{ width: 430, height: 932 },
{ width: 768, height: 1024 },
{ width: 1024, height: 768 },
{ width: 1280, height: 800 },
{ width: 1440, height: 900 },
];
for (const vp of viewports) {
await page.setViewportSize(vp);
await page.goto('/control');
await page.waitForLoadState('domcontentloaded');
const hasOverflow = await page.evaluate(() => {
return document.documentElement.scrollWidth > document.documentElement.clientWidth;
});
expect(hasOverflow, `Horizontal overflow at ${vp.width}x${vp.height} on /control`).toBe(false);
await page.goto('/control/organizations');
await page.waitForLoadState('domcontentloaded');
const hasOrgOverflow = await page.evaluate(() => {
return document.documentElement.scrollWidth > document.documentElement.clientWidth;
});
expect(hasOrgOverflow, `Horizontal overflow at ${vp.width}x${vp.height} on /control/organizations`).toBe(false);
}
});
test('05. 360 inspector drawer opens, switches tabs, and closes cleanly', async ({ page }) => {
await page.goto('/control/organizations');
await page.waitForLoadState('domcontentloaded');
await expect(page.locator('h1:has-text("Организации платформы")')).toBeVisible();
// Click 360 card button (whichever is visible on current viewport)
const inspectBtn = page.locator('button:has-text("360° Карточка"):visible').first();
await inspectBtn.click();
// Contextual Drawer opens
const drawer = page.locator('div[role="complementary"][aria-label="Контекстный инспектор"]');
await expect(drawer).toBeVisible();
await expect(drawer.locator('text=Северная Ритейл Группа').first()).toBeVisible();
await expect(drawer.locator('text=Действия оператора платформы')).toBeVisible();
// Verify 360 tabs
await expect(drawer.locator('button:has-text("Обзор")')).toBeVisible();
await expect(drawer.locator('button:has-text("Модули")').first()).toBeVisible();
// Switch to Modules tab
await drawer.locator('button:has-text("Модули")').first().click();
await expect(drawer.locator('div:has-text("v1."), div:has-text("v2."), div:has-text("v3.")').first()).toBeVisible();
const modulesTab = drawer.locator('button:has-text("Модули")').first();
await expect(modulesTab).toBeVisible();
await modulesTab.click();
// Close drawer
await drawer.locator('button[aria-label="Закрыть инспектор"]').click();
await expect(drawer).not.toBeVisible();
});
test('03. Scenario 2: Dangerous action requires operator authorization & mandatory reason', async ({ page }) => {
await page.click('a[href="/control/organizations"]');
await page.waitForURL('**/control/organizations');
test('06. Filters, tabs and accordions operate correctly', async ({ page }) => {
// Organizations search
await page.goto('/control/organizations');
await page.waitForLoadState('domcontentloaded');
const searchInput = page.locator('input[placeholder*="Поиск"]');
await searchInput.fill('Ритейл');
await expect(page.locator('*:has-text("Северная Ритейл Группа"):visible').first()).toBeVisible();
// Open inspector for first org
await page.locator('button:has-text("360° Карточка")').first().click();
// Billing quick filters
await page.goto('/control/billing');
await page.waitForLoadState('domcontentloaded');
const filter7d = page.locator('[data-testid="filter-expiring_7d"], button:has-text("7 дн")').first();
await expect(filter7d).toBeVisible();
await filter7d.click();
await expect(page.locator('*:has-text("Истекает (< 7 дн.)"):visible').first()).toBeVisible();
// Installations filter
await page.goto('/control/installations');
await page.waitForLoadState('domcontentloaded');
const filterOutdated = page.locator('button:has-text("Показать только устаревшие Android")');
await expect(filterOutdated).toBeVisible();
await filterOutdated.click();
await expect(page.locator('*:has-text("Android"):visible').first()).toBeVisible();
// Infrastructure node telemetry
await page.goto('/control/infrastructure');
await page.waitForLoadState('domcontentloaded');
await expect(page.locator('*:has-text("CPU"):visible').first()).toBeVisible();
});
test('07. Operator dangerous action requires authorization & valid reason', async ({ page }) => {
await page.goto('/control/organizations');
await page.waitForLoadState('domcontentloaded');
await page.locator('button:has-text("360° Карточка"):visible').first().click();
const drawer = page.locator('div[role="complementary"][aria-label="Контекстный инспектор"]');
await expect(drawer).toBeVisible();
// Click trial extension button
const grantTrialBtn = drawer.locator('button:has-text("Выдать Trial")');
await expect(grantTrialBtn).toBeVisible();
await grantTrialBtn.click();
const trialBtn = drawer.locator('button:has-text("Выдать Trial")');
await expect(trialBtn).toBeVisible();
await trialBtn.click();
// Confirm modal opens
const confirmDialog = page.locator('div[role="dialog"][aria-labelledby="confirm-modal-title"]');
await expect(confirmDialog).toBeVisible();
await expect(confirmDialog.locator('text=Требуется авторизация оператора')).toBeVisible();
const modal = page.locator('div[role="dialog"][aria-labelledby="confirm-modal-title"]');
await expect(modal).toBeVisible();
await expect(modal.locator('text=Требуется авторизация оператора')).toBeVisible();
// Attempt submit without reason
const submitBtn = confirmDialog.locator('button:has-text("Подтвердить операцию")');
// Submit empty reason -> validation error
const submitBtn = modal.locator('button:has-text("Подтвердить операцию")');
await submitBtn.click();
await expect(confirmDialog.locator('text=Обоснование операции обязательно')).toBeVisible();
await expect(modal.locator('text=Обоснование операции обязательно')).toBeVisible();
// Fill valid reason (>= 5 chars)
const reasonInput = confirmDialog.locator('textarea[id="action-reason-input"]');
await reasonInput.fill('Согласованное продление пилота по заявке #PILOT-1049');
await modal.locator('textarea[id="action-reason-input"]').fill('Согласованное продление пилота #PILOT-2026');
await submitBtn.click();
// Modal closes
await expect(confirmDialog).not.toBeVisible();
await expect(modal).not.toBeVisible();
});
test('04. Scenario 3: Filter subscriptions expiring in 7 days', async ({ page }) => {
await page.click('a[href="/control/billing"]');
await page.waitForURL('**/control/billing');
test('08. Switch operator role and observe RBAC enforcement', async ({ page }) => {
await page.goto('/control');
await page.waitForLoadState('domcontentloaded');
await expect(page.locator('h1:has-text("Подписки, лицензии и Entitlements")')).toBeVisible();
// Click "Истекает 7 дней" filter
const filter7dBtn = page.locator('button:has-text("Истекает 7 дней")');
await expect(filter7dBtn).toBeVisible();
await filter7dBtn.click();
// Verify filter active styling and table displays matching rows
await expect(page.locator('tbody tr')).not.toHaveCount(0);
await expect(page.locator('text=Истекает (< 7 дн.)').first()).toBeVisible();
});
test('05. Scenario 4: Monitor installations and identify outdated Android terminals', async ({ page }) => {
await page.click('a[href="/control/installations"]');
await page.waitForURL('**/control/installations');
await expect(page.locator('h1:has-text("Установки и клиентские приложения")')).toBeVisible();
// Click quick filter button for outdated Android
const outdatedAndroidBtn = page.locator('button:has-text("Показать только устаревшие Android")');
await expect(outdatedAndroidBtn).toBeVisible();
await outdatedAndroidBtn.click();
// Outdated Android device should be visible in table
await expect(page.locator('text=Android').first()).toBeVisible();
await expect(page.locator('text=Устарела').first()).toBeVisible();
});
test('06. Scenario 5: Inspect infrastructure nodes and Server Monitor outage resilience', async ({ page }) => {
await page.click('a[href="/control/infrastructure"]');
await page.waitForURL('**/control/infrastructure');
await expect(page.locator('h1:has-text("Инфраструктурные узлы")')).toBeVisible();
// Check circuit breaker banner simulation button
const toggleBannerBtn = page.locator('button[title*="Server Monitor"]');
await expect(toggleBannerBtn).toBeVisible();
// Verify server node metrics
await expect(page.locator('text=CPU:').first()).toBeVisible();
// Click first server card to open inspector
const firstServerCard = page.locator('div[class*="cursor-pointer"]:has-text("CPU:")').first();
await firstServerCard.click();
// Context drawer opens with server details
const drawer = page.locator('div[role="complementary"][aria-label="Контекстный инспектор"]');
await expect(drawer).toBeVisible();
await expect(drawer.locator('text=Телеметрия узла').first()).toBeVisible();
await drawer.locator('button[aria-label="Закрыть инспектор"]').click();
});
test('07. Scenario 6: Create incident workflow from alert', async ({ page }) => {
await page.click('a[href="/control/infrastructure"]');
await page.waitForURL('**/control/infrastructure');
// Find a problematic server with "Создать инцидент"
const createIncBtn = page.locator('button:has-text("Создать инцидент")').first();
await expect(createIncBtn).toBeVisible();
await createIncBtn.click();
// Confirm modal opens
const confirmModal = page.locator('div[role="dialog"][aria-labelledby="confirm-modal-title"]');
await expect(confirmModal).toBeVisible();
await confirmModal.locator('textarea[id="action-reason-input"]').fill('Эскалация дежурным инженером NOC по алерту узла');
await confirmModal.locator('button:has-text("Подтвердить операцию")').click();
// Navigate to Incidents desk
await page.click('a[href="/control/incidents"]');
await page.waitForURL('**/control/incidents');
// The incident alert is listed
await expect(page.locator('text=Критический сбой узла').first()).toBeVisible();
});
test('08. Scenario 7: Create targeted tenant announcement', async ({ page }) => {
await page.click('a[href="/control/announcements"]');
await page.waitForURL('**/control/announcements');
await expect(page.locator('h1:has-text("Оповещения и системные сообщения")')).toBeVisible();
// Open creation form
await page.locator('button:has-text("Создать объявление")').click();
// Fill title
await page.locator('input[placeholder*="Omnichannel"]').fill('Плановое ночное техобслуживание');
// Fill message body
await page.locator('textarea[placeholder*="текст объявления"]').fill('12 сентября с 03:00 до 04:00 МСК будут проводиться регламентные работы.');
// Submit form
await page.locator('button[type="submit"]:has-text("Опубликовать")').click();
// Confirmation
const confirmModal = page.locator('div[role="dialog"][aria-labelledby="confirm-modal-title"]');
await expect(confirmModal).toBeVisible();
await confirmModal.locator('textarea[id="action-reason-input"]').fill('Согласованный релизный бюллетень #REL-402');
await confirmModal.locator('button:has-text("Подтвердить операцию")').click();
await expect(page.locator('text=Плановое ночное техобслуживание').first()).toBeVisible();
});
test('09. Scenario 8: Manage module rollouts (Pause / Resume)', async ({ page }) => {
await page.click('a[href="/control/modules"]');
await page.waitForURL('**/control/modules');
await expect(page.locator('h1:has-text("Каталог модулей и Rollout конвейер")')).toBeVisible();
// Select module with active rollout (Омниканальные коммуникации)
await page.locator('text=Омниканальные коммуникации').first().click();
// Verify active rollout badge
const activeRolloutBadge = page.locator('text=Пилот (Pilot)').first();
await expect(activeRolloutBadge).toBeVisible();
// Click Pause
const pauseBtn = page.locator('button:has-text("Поставить на паузу")');
if (await pauseBtn.isVisible()) {
await pauseBtn.click();
const confirmModal = page.locator('div[role="dialog"][aria-labelledby="confirm-modal-title"]');
await expect(confirmModal).toBeVisible();
await confirmModal.locator('textarea[id="action-reason-input"]').fill('Временная приостановка раскатки до проверки логов');
await confirmModal.locator('button:has-text("Подтвердить операцию")').click();
// Rollout state should now show "На паузе"
await expect(page.locator('text=На паузе').first()).toBeVisible();
const isMobile = await page.locator('[data-testid="mobile-menu-button"]').isVisible();
if (isMobile) {
await page.locator('[data-testid="mobile-menu-button"]').click();
}
});
test('10. Scenario 9: Switch operator role and observe strict RBAC enforcement', async ({ page }) => {
const switcher = page.locator('button[aria-label="Сессия оператора"]');
const switcher = page.locator('[data-testid="operator-session-button"]:visible').first();
await switcher.click();
// Switch to security_auditor
const auditorItem = page.locator('button[role="menuitem"]:has-text("Аудитор безопасности")');
await expect(auditorItem).toBeVisible();
await auditorItem.click();
const auditorOption = page.locator('[data-testid="role-option-security_auditor"]:visible').first();
await expect(auditorOption).toBeVisible();
await auditorOption.click();
// Topbar now shows "Аудитор безопасности"
await expect(page.locator('text=Аудитор безопасности').first()).toBeVisible();
// Header / profile now indicates auditor
await expect(page.locator('*:has-text("Аудитор"):visible').first()).toBeVisible();
// Navigate to Modules page: triggering rollouts should be disabled for auditor
await page.click('a[href="/control/modules"]');
await page.waitForURL('**/control/modules');
// Navigate to modules: triggering rollout should be disabled for auditor
await page.goto('/control/modules');
await page.waitForLoadState('domcontentloaded');
const rolloutBtn = page.locator('button:has-text("Запустить пилотный Rollout"), button:has-text("Поставить на паузу")').first();
if (await rolloutBtn.isVisible()) {
await expect(rolloutBtn).toBeDisabled();
}
// Switch back to platform_owner
await switcher.click();
const ownerItem = page.locator('button[role="menuitem"]:has-text("Владелец платформы")');
await ownerItem.click();
await expect(page.locator('text=Владелец платформы').first()).toBeVisible();
// Switch back to owner
if (await page.locator('[data-testid="mobile-menu-button"]').isVisible()) {
await page.locator('[data-testid="mobile-menu-button"]').click();
}
const switcherBack = page.locator('[data-testid="operator-session-button"]:visible').first();
await switcherBack.click();
await page.locator('[data-testid="role-option-platform_owner"]:visible').first().click();
await expect(page.locator('*:has-text("Владелец"):visible').first()).toBeVisible();
});
test('11. Scenario 10: State survives browser page reload via IndexedDB', async ({ page }) => {
await page.click('a[href="/control/audit"]');
await page.waitForURL('**/control/audit');
test('09. Control Center UI is completely localized in Russian', async ({ page }) => {
const pagesToCheck = [
{ url: '/control', heading: 'Сводная операторская панель' },
{ url: '/control/organizations', heading: 'Организации платформы' },
{ url: '/control/billing', heading: 'Подписки и биллинг платформы' },
{ url: '/control/modules', heading: 'Каталог модулей и Rollout конвейер' },
{ url: '/control/installations', heading: 'Установки и клиентские приложения' },
{ url: '/control/infrastructure', heading: 'Инфраструктурные серверы' },
{ url: '/control/announcements', heading: 'Оповещения и системные сообщения' },
{ url: '/control/incidents', heading: 'Журнал инцидентов платформы' },
{ url: '/control/audit', heading: 'Неизменяемый журнал аудита действий' },
];
for (const p of pagesToCheck) {
await page.goto(p.url);
await page.waitForLoadState('domcontentloaded');
await expect(page.locator(`h1:has-text("${p.heading}"):visible`).first()).toBeVisible();
}
});
test('10. State survives browser page reload via IndexedDB', async ({ page }) => {
await page.goto('/control/audit');
await page.waitForLoadState('domcontentloaded');
await expect(page.locator('h1:has-text("Неизменяемый журнал аудита")')).toBeVisible();
// Wait for audit log rows to load from IndexedDB
await expect(page.locator('tbody tr')).toHaveCount(3);
// Verify audit log rows
await expect(page.locator('tbody tr')).not.toHaveCount(0);
const initialRowsCount = await page.locator('tbody tr').count();
expect(initialRowsCount).toBe(3);
// Reload page
await page.reload();
await page.waitForLoadState('domcontentloaded');
await expect(page.locator('text=IndexedDB Synced')).toBeVisible({ timeout: 15000 });
// Audit entries must still be present from IndexedDB
await expect(page.locator('h1:has-text("Неизменяемый журнал аудита")')).toBeVisible();
await expect(page.locator('tbody tr')).toHaveCount(initialRowsCount);
});
});

View file

@ -9,7 +9,7 @@ export default defineConfig({
workers: 1,
reporter: 'list',
use: {
baseURL: 'http://localhost:5173',
baseURL: 'http://127.0.0.1:5180',
trace: 'on-first-retry',
},
projects: [
@ -33,9 +33,9 @@ export default defineConfig({
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
command: 'npm run dev -- --port 5180 --host 127.0.0.1',
url: 'http://127.0.0.1:5180',
reuseExistingServer: false,
timeout: 120000,
},
});

View file

@ -1,6 +1,7 @@
import React, { useState, useEffect, useRef } from 'react';
import { ShieldAlert, AlertTriangle, CheckCircle, X } from 'lucide-react';
import { useKccStore, useOperatorRole } from '../store/kccStore';
import { RU_ROLES } from '../locales/ru';
interface ConfirmDialogProps {
pendingModal: NonNullable<ReturnType<typeof useKccStore.getState>['pendingConfirmModal']>;
@ -36,9 +37,11 @@ const ConfirmOperatorActionDialog: React.FC<ConfirmDialogProps> = ({ pendingModa
}
};
const roleLabel = RU_ROLES[role]?.label || role;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/75 backdrop-blur-xs p-4 animate-in fade-in duration-150"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-xs p-4 animate-in fade-in duration-150"
role="dialog"
aria-modal="true"
aria-labelledby="confirm-modal-title"
@ -46,17 +49,17 @@ const ConfirmOperatorActionDialog: React.FC<ConfirmDialogProps> = ({ pendingModa
if (e.key === 'Escape' && !isSubmitting) onClose();
}}
>
<div className="w-full max-w-lg rounded-xl border border-amber-500/40 bg-[#0d1e17] text-zinc-100 shadow-2xl p-6">
<div className="flex items-start justify-between gap-4 border-b border-zinc-800 pb-4">
<div className="w-full max-w-lg rounded-xl border border-amber-500/40 bg-krona-surface text-krona-primary shadow-2xl p-5 md:p-6 space-y-4">
<div className="flex items-start justify-between gap-4 border-b border-krona-border pb-4">
<div className="flex items-center gap-3">
<div className="p-2.5 rounded-lg bg-amber-500/10 text-amber-400 border border-amber-500/30">
<div className="p-2.5 rounded-lg bg-amber-500/15 text-amber-500 border border-amber-500/30 shrink-0">
<ShieldAlert className="w-6 h-6" />
</div>
<div>
<h3 id="confirm-modal-title" className="text-lg font-bold text-zinc-100">
<h3 id="confirm-modal-title" className="text-base md:text-lg font-bold text-krona-primary">
{pendingModal.title}
</h3>
<p className="text-xs text-zinc-400">
<p className="text-xs text-krona-secondary">
Требуется авторизация оператора и запись в журнал аудита
</p>
</div>
@ -64,45 +67,45 @@ const ConfirmOperatorActionDialog: React.FC<ConfirmDialogProps> = ({ pendingModa
<button
onClick={onClose}
disabled={isSubmitting}
className="rounded-lg p-1.5 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200 transition-colors"
className="min-w-[44px] min-h-[44px] flex items-center justify-center rounded-lg text-krona-secondary hover:bg-krona-workspace hover:text-krona-primary transition-colors"
aria-label="Закрыть"
>
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="mt-5 space-y-4">
<form onSubmit={handleSubmit} className="space-y-4">
{/* Target & Action Summary */}
<div className="rounded-lg bg-zinc-950/70 border border-zinc-800/80 p-3.5 space-y-2 text-xs">
<div className="flex justify-between text-zinc-400">
<div className="rounded-lg bg-krona-workspace border border-krona-border p-3.5 space-y-2 text-xs">
<div className="flex justify-between text-krona-secondary">
<span>Целевой объект:</span>
<span className="font-semibold text-zinc-200">{pendingModal.targetName}</span>
<span className="font-semibold text-krona-primary">{pendingModal.targetName}</span>
</div>
<div className="flex justify-between text-zinc-400">
<div className="flex justify-between text-krona-secondary">
<span>Операция:</span>
<span className="font-mono text-amber-300">{pendingModal.actionName}</span>
<span className="font-mono text-amber-500 font-semibold">{pendingModal.actionName}</span>
</div>
<div className="flex justify-between text-zinc-400">
<div className="flex justify-between text-krona-secondary">
<span>Оператор:</span>
<span className="text-zinc-300 font-medium">
{operator.name} ({role})
<span className="text-krona-primary font-medium">
{operator.name} ({roleLabel})
</span>
</div>
</div>
{/* Impact Summary */}
<div className="rounded-lg bg-amber-950/30 border border-amber-700/40 p-3.5 text-xs text-amber-200 flex gap-2.5 items-start">
<AlertTriangle className="w-4 h-4 text-amber-400 shrink-0 mt-0.5" />
<div className="rounded-lg bg-amber-500/10 border border-amber-500/30 p-3.5 text-xs text-amber-600 dark:text-amber-300 flex gap-2.5 items-start">
<AlertTriangle className="w-4 h-4 text-amber-500 shrink-0 mt-0.5" />
<div>
<div className="font-semibold text-amber-300 mb-0.5">Влияние операции (Impact Summary):</div>
<div>{pendingModal.impactSummary}</div>
<div className="font-semibold text-amber-600 dark:text-amber-300 mb-0.5">Влияние операции:</div>
<div className="text-krona-secondary">{pendingModal.impactSummary}</div>
</div>
</div>
{/* Reason Input */}
<div>
<label htmlFor="action-reason-input" className="block text-xs font-semibold text-zinc-300 mb-1.5">
Обоснование операции (Reason) <span className="text-rose-400">*</span>
<label htmlFor="action-reason-input" className="block text-xs font-semibold text-krona-primary mb-1.5">
Обоснование операции (Reason) <span className="text-rose-500">*</span>
</label>
<textarea
id="action-reason-input"
@ -114,26 +117,26 @@ const ConfirmOperatorActionDialog: React.FC<ConfirmDialogProps> = ({ pendingModa
if (error) setError(null);
}}
placeholder="Укажите причину выполнения действия (номер заявки, обоснование, согласование)..."
className="w-full rounded-lg bg-zinc-950 border border-zinc-700 p-2.5 text-sm text-zinc-100 placeholder-zinc-500 focus:outline-hidden focus:border-amber-500 focus:ring-1 focus:ring-amber-500 transition-colors resize-none"
className="w-full rounded-lg bg-krona-workspace border border-krona-border p-3 text-sm text-krona-primary placeholder:text-krona-secondary/60 focus:outline-hidden focus:border-amber-500 transition-colors resize-none"
disabled={isSubmitting}
/>
{error && <p className="mt-1 text-xs text-rose-400 font-medium">{error}</p>}
{error && <p className="mt-1 text-xs text-rose-500 font-medium">{error}</p>}
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-3 pt-3 border-t border-zinc-800">
{/* Actions with min 44px touch targets */}
<div className="flex flex-col-reverse sm:flex-row items-center justify-end gap-2 pt-3 border-t border-krona-border">
<button
type="button"
onClick={onClose}
disabled={isSubmitting}
className="px-4 py-2 text-xs font-medium rounded-lg border border-zinc-700 text-zinc-300 hover:bg-zinc-800 transition-colors"
className="w-full sm:w-auto min-h-[44px] px-4 py-2.5 text-xs font-medium rounded-lg border border-krona-border text-krona-secondary hover:text-krona-primary transition-colors flex items-center justify-center"
>
Отмена
</button>
<button
type="submit"
disabled={isSubmitting}
className="px-4 py-2 text-xs font-bold rounded-lg bg-amber-500 hover:bg-amber-400 text-zinc-950 transition-colors flex items-center gap-1.5 shadow-md shadow-amber-900/30 disabled:opacity-50"
className="w-full sm:w-auto min-h-[44px] px-5 py-2.5 text-xs font-bold rounded-lg bg-amber-500 hover:bg-amber-400 text-zinc-950 transition-colors flex items-center justify-center gap-2 shadow-xs disabled:opacity-50"
>
<CheckCircle className="w-4 h-4" />
<span>{isSubmitting ? 'Подтверждение...' : 'Подтвердить операцию'}</span>
@ -153,3 +156,4 @@ export const ConfirmOperatorActionModal: React.FC = () => {
return <ConfirmOperatorActionDialog pendingModal={pendingModal} onClose={closeModal} />;
};

View file

@ -33,6 +33,18 @@ import { ServerMonitorOutageBanner } from './ServerMonitorOutageBanner';
import { useThemeStore, ThemeMode } from '../../../stores/theme';
import { RU_ROLES, RU_THEMES, RU_UI } from '../locales/ru';
const NAV_DEFINITIONS = [
{ 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 },
{ to: '/control/audit', label: 'Журнал аудита', icon: FileCheck2 },
];
export const ControlCenterShell: React.FC = () => {
const location = useLocation();
const navigate = useNavigate();
@ -56,27 +68,32 @@ export const ControlCenterShell: React.FC = () => {
const [isMobileDrawerOpen, setIsMobileDrawerOpen] = useState(false);
const [isTabletSidebarCollapsed, setIsTabletSidebarCollapsed] = useState(false);
// Close mobile drawer on route change (render phase pattern)
const [prevPathname, setPrevPathname] = useState(location.pathname);
if (prevPathname !== location.pathname) {
setPrevPathname(location.pathname);
setIsMobileDrawerOpen(false);
}
useEffect(() => {
if (!isInitialized) {
initialize();
}
}, [isInitialized, initialize]);
// UTC clock ticker
useEffect(() => {
const updateTime = () => {
const now = new Date();
setUtcTime(now.toISOString().replace('T', ' ').slice(0, 19) + ' UTC');
setUtcTime(
`${now.toUTCString().slice(17, 25)} UTC`
);
};
updateTime();
const interval = setInterval(updateTime, 1000);
return () => clearInterval(interval);
}, []);
// Close mobile drawer on route change
useEffect(() => {
setIsMobileDrawerOpen(false);
}, [location.pathname]);
const handleReset = async () => {
if (confirm('Сбросить демонстрационные данные платформы к исходному состоянию?')) {
setIsResetting(true);
@ -92,46 +109,33 @@ export const ControlCenterShell: React.FC = () => {
let count = 0;
if (serverMonitorOutage) count += 1;
count += incidents.filter(
(i) => i.status !== 'resolved' && (i.severity === 'P0_CRITICAL' || i.severity === 'P1_MAJOR')
(i) => i.status !== 'resolved' && (i.severity === 'critical' || i.severity === 'high')
).length;
return count;
}, [serverMonitorOutage, incidents]);
// Navigation Items
const primaryMobileNav = [
const primaryMobileNav = useMemo(() => [
{ to: '/control', label: 'Обзор', icon: LayoutDashboard, end: true },
{ to: '/control/organizations', label: 'Организации', icon: Building2 },
{ to: '/control/incidents', label: 'Инциденты', icon: AlertTriangle, badge: criticalCount > 0 ? criticalCount : undefined },
{ to: '/control/infrastructure', label: 'Серверы', icon: Server },
];
], [criticalCount]);
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 },
];
const allNavItems = useMemo(() => {
return NAV_DEFINITIONS.map((item) => ({
...item,
badge: item.to === '/control/incidents' && criticalCount > 0 ? criticalCount : undefined,
}));
}, [criticalCount]);
// Current section title
const currentTitle = useMemo(() => {
const current = allNavItems.find((item) =>
const current = NAV_DEFINITIONS.find((item) =>
item.end ? location.pathname === item.to : location.pathname.startsWith(item.to)
);
return current?.label || 'Control Center';
}, [location.pathname, allNavItems]);
}, [location.pathname]);
const currentRoleLabel = RU_ROLES[role]?.shortLabel || role;

View file

@ -1,4 +1,4 @@
import React from 'react';
import { Navigate } from 'react-router-dom';
import { useKccStore } from '../store/kccStore';
import { ControlForbiddenPage } from '../pages/ControlForbiddenPage';
@ -17,6 +17,11 @@ interface ControlRouteGuardProps {
export const ControlRouteGuard: React.FC<ControlRouteGuardProps> = ({ children }) => {
const activeRole = useKccStore((state) => state.activeRole);
const isAccessGatePassed = useKccStore((state) => state.isAccessGatePassed);
if (!isAccessGatePassed) {
return <Navigate to="/control/gate" replace />;
}
if (!ALLOWED_OPERATOR_ROLES.includes(activeRole)) {
return <ControlForbiddenPage />;

View file

@ -11,6 +11,7 @@ import { StatusBadge } from './StatusBadge';
import { MoneyDisplay } from './MoneyDisplay';
import { HeartbeatTimeline } from './HeartbeatTimeline';
import { PlatformStatus } from '../contracts';
import { RU_INCIDENT_SEVERITY, RU_PLATFORMS, RU_ROLES } from '../locales/ru';
export const InspectorDrawer: React.FC = () => {
const inspectorTarget = useKccStore((state) => state.inspectorTarget);
@ -45,7 +46,7 @@ export const InspectorDrawer: React.FC = () => {
switch (inspectorTarget.type) {
case 'organization': {
const org = organizations.find((o) => o.tenantId === inspectorTarget.id);
if (!org) return <div className="p-6 text-zinc-400">Организация не найдена</div>;
if (!org) return <div className="p-6 text-krona-secondary">Организация не найдена</div>;
const sub = subscriptions.find((s) => s.tenantId === org.tenantId);
const orgInstances = instances.filter((i) => i.tenantId === org.tenantId);
@ -88,16 +89,16 @@ export const InspectorDrawer: React.FC = () => {
<div>
<div className="flex items-center gap-2 mb-1.5">
<StatusBadge status={org.platformStatus} />
<span className="text-xs text-zinc-400 font-mono">ИНН: {org.inn}</span>
<span className="text-xs text-krona-secondary font-mono">ИНН: {org.inn}</span>
</div>
<h2 className="text-lg font-bold text-zinc-100">{org.displayName}</h2>
<p className="text-xs text-zinc-400 font-mono">{org.domain} • ID: {org.tenantId.slice(0, 8)}...</p>
<h2 className="text-lg font-bold text-krona-primary">{org.displayName}</h2>
<p className="text-xs text-krona-secondary font-mono">{org.domain} • ID: {org.tenantId.slice(0, 8)}...</p>
</div>
{/* Operator Actions Bar */}
<div className="rounded-xl bg-zinc-950/70 border border-zinc-800 p-3.5 space-y-3">
<div className="text-xs font-semibold text-zinc-300 flex items-center gap-1.5">
<ShieldAlert className="w-4 h-4 text-amber-400" />
<div className="rounded-xl bg-krona-workspace/60 border border-krona-border p-3.5 space-y-3">
<div className="text-xs font-semibold text-krona-primary flex items-center gap-1.5">
<ShieldAlert className="w-4 h-4 text-amber-500" />
<span>Действия оператора платформы</span>
</div>
<div className="flex flex-wrap gap-2">
@ -105,7 +106,7 @@ export const InspectorDrawer: React.FC = () => {
<button
disabled={!permissions.canManagePlatformStatus}
onClick={() => handleStatusChange('active')}
className="px-2.5 py-1.5 rounded-lg text-xs font-medium bg-emerald-950/70 text-emerald-300 border border-emerald-700/50 hover:bg-emerald-900/50 disabled:opacity-40 transition-colors"
className="min-h-[44px] px-3 py-2 rounded-lg text-xs font-medium bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/30 hover:bg-emerald-500/20 disabled:opacity-40 transition-colors flex items-center justify-center"
>
Активировать (Active)
</button>
@ -114,7 +115,7 @@ export const InspectorDrawer: React.FC = () => {
<button
disabled={!permissions.canGrantTrialGrace}
onClick={() => handleStatusChange('grace')}
className="px-2.5 py-1.5 rounded-lg text-xs font-medium bg-orange-950/70 text-orange-300 border border-orange-700/50 hover:bg-orange-900/50 disabled:opacity-40 transition-colors"
className="min-h-[44px] px-3 py-2 rounded-lg text-xs font-medium bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/30 hover:bg-amber-500/20 disabled:opacity-40 transition-colors flex items-center justify-center"
>
Назначить Grace
</button>
@ -123,7 +124,7 @@ export const InspectorDrawer: React.FC = () => {
<button
disabled={!permissions.canManagePlatformStatus}
onClick={() => handleStatusChange('suspended')}
className="px-2.5 py-1.5 rounded-lg text-xs font-medium bg-rose-950/70 text-rose-300 border border-rose-700/50 hover:bg-rose-900/50 disabled:opacity-40 transition-colors"
className="min-h-[44px] px-3 py-2 rounded-lg text-xs font-medium bg-rose-500/10 text-rose-600 dark:text-rose-400 border border-rose-500/30 hover:bg-rose-500/20 disabled:opacity-40 transition-colors flex items-center justify-center"
>
Заблокировать (Suspend)
</button>
@ -132,13 +133,13 @@ export const InspectorDrawer: React.FC = () => {
{/* Trial grant inline form */}
{permissions.canGrantTrialGrace && (
<div className="pt-2 border-t border-zinc-800/80 space-y-2 text-xs">
<div className="font-semibold text-zinc-300">Выдать Trial на модуль:</div>
<div className="flex gap-2 items-center">
<div className="pt-2 border-t border-krona-border space-y-2 text-xs">
<div className="font-semibold text-krona-primary">Выдать Trial на модуль:</div>
<div className="flex flex-wrap gap-2 items-center">
<select
value={selectedTrialModule}
onChange={(e) => setSelectedTrialModule(e.target.value)}
className="bg-zinc-900 border border-zinc-700 rounded-md px-2 py-1 text-zinc-200 text-xs focus:outline-hidden"
className="min-h-[44px] bg-krona-surface border border-krona-border rounded-lg px-2.5 py-1.5 text-krona-primary text-xs focus:outline-hidden focus:ring-1 focus:ring-amber-500"
>
{modules.map((m) => (
<option key={m.moduleId} value={m.moduleId}>
@ -149,7 +150,7 @@ export const InspectorDrawer: React.FC = () => {
<select
value={trialDurationDays}
onChange={(e) => setTrialDurationDays(Number(e.target.value))}
className="bg-zinc-900 border border-zinc-700 rounded-md px-2 py-1 text-zinc-200 text-xs focus:outline-hidden"
className="min-h-[44px] bg-krona-surface border border-krona-border rounded-lg px-2.5 py-1.5 text-krona-primary text-xs focus:outline-hidden focus:ring-1 focus:ring-amber-500"
>
<option value={7}>7 дней</option>
<option value={14}>14 дней</option>
@ -158,7 +159,7 @@ export const InspectorDrawer: React.FC = () => {
<button
type="button"
onClick={handleGrantTrial}
className="px-3 py-1 rounded-md bg-amber-500 hover:bg-amber-400 text-zinc-950 font-bold text-xs transition-colors shrink-0"
className="min-h-[44px] px-3 py-2 rounded-lg bg-amber-500 hover:bg-amber-400 text-zinc-950 font-bold text-xs transition-colors shrink-0 flex items-center justify-center"
>
Выдать Trial
</button>
@ -168,7 +169,7 @@ export const InspectorDrawer: React.FC = () => {
</div>
{/* Navigation Tabs */}
<div className="flex items-center gap-1 border-b border-zinc-800 overflow-x-auto pb-1 text-xs">
<div className="flex items-center gap-1 border-b border-krona-border overflow-x-auto pb-1 text-xs">
{[
{ id: 'overview', label: 'Обзор' },
{ id: 'users', label: `Пользователи (${org.userCount})` },
@ -181,10 +182,10 @@ export const InspectorDrawer: React.FC = () => {
<button
key={tab.id}
onClick={() => setOrgActiveTab(tab.id as typeof orgActiveTab)}
className={`px-3 py-1.5 rounded-t-md font-medium whitespace-nowrap transition-colors ${
className={`min-h-[44px] px-3 py-2 rounded-t-lg font-medium whitespace-nowrap transition-colors flex items-center ${
orgActiveTab === tab.id
? 'bg-zinc-800/80 text-amber-300 border-b-2 border-amber-400'
: 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-900'
? 'bg-amber-500/10 text-amber-500 border-b-2 border-amber-500'
: 'text-krona-secondary hover:text-krona-primary hover:bg-krona-workspace'
}`}
>
{tab.label}
@ -196,57 +197,57 @@ export const InspectorDrawer: React.FC = () => {
{orgActiveTab === 'overview' && (
<div className="space-y-4 text-xs">
<div className="grid grid-cols-2 gap-3">
<div className="p-3 rounded-lg bg-zinc-950/60 border border-zinc-800/80">
<div className="text-zinc-400 mb-1">Тарифный план</div>
<div className="font-semibold text-zinc-200 text-sm">{org.tariffPlan}</div>
<div className="p-3 rounded-lg bg-krona-workspace/60 border border-krona-border">
<div className="text-krona-secondary mb-1">Тарифный план</div>
<div className="font-semibold text-krona-primary text-sm">{org.tariffPlan}</div>
</div>
<div className="p-3 rounded-lg bg-zinc-950/60 border border-zinc-800/80">
<div className="text-zinc-400 mb-1">MRR доход</div>
<MoneyDisplay minorUnits={org.mrrMinorUnits} className="text-sm font-bold text-amber-400" />
<div className="p-3 rounded-lg bg-krona-workspace/60 border border-krona-border">
<div className="text-krona-secondary mb-1">MRR доход</div>
<MoneyDisplay minorUnits={org.mrrMinorUnits} className="text-sm font-bold text-amber-500" />
</div>
<div className="p-3 rounded-lg bg-zinc-950/60 border border-zinc-800/80">
<div className="text-zinc-400 mb-1">Филиалов / точек</div>
<div className="font-semibold text-zinc-200 text-sm">{org.branchCount}</div>
<div className="p-3 rounded-lg bg-krona-workspace/60 border border-krona-border">
<div className="text-krona-secondary mb-1">Филиалов / точек</div>
<div className="font-semibold text-krona-primary text-sm">{org.branchCount}</div>
</div>
<div className="p-3 rounded-lg bg-zinc-950/60 border border-zinc-800/80">
<div className="text-zinc-400 mb-1">Задолженность</div>
<div className="p-3 rounded-lg bg-krona-workspace/60 border border-krona-border">
<div className="text-krona-secondary mb-1">Задолженность</div>
<MoneyDisplay
minorUnits={org.outstandingDebtMinorUnits}
className={`text-sm font-bold ${org.outstandingDebtMinorUnits > 0 ? 'text-rose-400' : 'text-emerald-400'}`}
className={`text-sm font-bold ${org.outstandingDebtMinorUnits > 0 ? 'text-rose-500' : 'text-emerald-500'}`}
/>
</div>
</div>
<div className="rounded-lg bg-zinc-950/60 border border-zinc-800 p-3 space-y-2">
<div className="font-semibold text-zinc-300">Контакты организации:</div>
<div className="text-zinc-400">Email: <span className="text-zinc-200">{org.contactEmail}</span></div>
<div className="text-zinc-400">Телефон: <span className="text-zinc-200">{org.contactPhone}</span></div>
<div className="text-zinc-400">Последняя активность: <span className="text-zinc-200">{new Date(org.lastActiveAt).toLocaleString('ru-RU')}</span></div>
<div className="text-zinc-400">Окончание подписки: <span className="text-zinc-200">{new Date(org.subscriptionEndAt).toLocaleDateString('ru-RU')}</span></div>
<div className="rounded-lg bg-krona-workspace/60 border border-krona-border p-3 space-y-2">
<div className="font-semibold text-krona-primary">Контакты организации:</div>
<div className="text-krona-secondary">Email: <span className="text-krona-primary">{org.contactEmail}</span></div>
<div className="text-krona-secondary">Телефон: <span className="text-krona-primary">{org.contactPhone}</span></div>
<div className="text-krona-secondary">Последняя активность: <span className="text-krona-primary">{new Date(org.lastActiveAt).toLocaleString('ru-RU')}</span></div>
<div className="text-krona-secondary">Окончание подписки: <span className="text-krona-primary">{new Date(org.subscriptionEndAt).toLocaleDateString('ru-RU')}</span></div>
</div>
</div>
)}
{orgActiveTab === 'users' && (
<div className="space-y-3 text-xs">
<div className="p-3 rounded-lg bg-zinc-950/60 border border-zinc-800">
<div className="p-3 rounded-lg bg-krona-workspace/60 border border-krona-border">
<div className="flex justify-between items-center mb-2">
<span className="font-semibold text-zinc-200">Пользователи-агрегаты</span>
<span className="text-amber-400 font-bold">{org.userCount} учетных записей</span>
<span className="font-semibold text-krona-primary">Пользователи-агрегаты</span>
<span className="text-amber-500 font-bold">{org.userCount} учетных записей</span>
</div>
<p className="text-zinc-400 text-[11px] leading-relaxed">
<p className="text-krona-secondary text-[11px] leading-relaxed">
В соответствии с контрактом безопасности Control Center отображает только деперсонализированные
агрегаты тенанта. Персональные данные сотрудников защищены контуром организации.
</p>
</div>
<div className="grid grid-cols-2 gap-2 text-zinc-300">
<div className="p-2.5 rounded-lg bg-zinc-900 border border-zinc-800">
<div className="text-zinc-400">Администраторы</div>
<div className="font-bold text-sm text-zinc-100">2</div>
<div className="grid grid-cols-2 gap-2 text-krona-primary">
<div className="p-2.5 rounded-lg bg-krona-workspace border border-krona-border">
<div className="text-krona-secondary">Администраторы</div>
<div className="font-bold text-sm text-krona-primary">2</div>
</div>
<div className="p-2.5 rounded-lg bg-zinc-900 border border-zinc-800">
<div className="text-zinc-400">Менеджеры / операторы</div>
<div className="font-bold text-sm text-zinc-100">{Math.max(0, org.userCount - 6)}</div>
<div className="p-2.5 rounded-lg bg-krona-workspace border border-krona-border">
<div className="text-krona-secondary">Менеджеры / операторы</div>
<div className="font-bold text-sm text-krona-primary">{Math.max(0, org.userCount - 6)}</div>
</div>
</div>
</div>
@ -255,10 +256,10 @@ export const InspectorDrawer: React.FC = () => {
{orgActiveTab === 'modules' && (
<div className="space-y-2 text-xs">
{orgInstances.map((inst) => (
<div key={inst.moduleInstanceId} className="p-3 rounded-lg bg-zinc-950/60 border border-zinc-800 flex items-center justify-between">
<div key={inst.moduleInstanceId} className="p-3 rounded-lg bg-krona-workspace/60 border border-krona-border flex items-center justify-between">
<div>
<div className="font-semibold text-zinc-200">{inst.moduleName}</div>
<div className="text-[11px] text-zinc-400 font-mono mt-0.5">
<div className="font-semibold text-krona-primary">{inst.moduleName}</div>
<div className="text-[11px] text-krona-secondary font-mono mt-0.5">
v{inst.currentVersion} • {inst.serverName}
</div>
</div>
@ -271,46 +272,46 @@ export const InspectorDrawer: React.FC = () => {
{orgActiveTab === 'installations' && (
<div className="space-y-2 text-xs">
{orgInstalls.map((ins) => (
<div key={ins.installationId} className="p-3 rounded-lg bg-zinc-950/60 border border-zinc-800 flex items-center justify-between">
<div key={ins.installationId} className="p-3 rounded-lg bg-krona-workspace/60 border border-krona-border flex items-center justify-between">
<div>
<div className="font-semibold text-zinc-200 flex items-center gap-1.5">
<Smartphone className="w-3.5 h-3.5 text-zinc-400" />
<div className="font-semibold text-krona-primary flex items-center gap-1.5">
<Smartphone className="w-3.5 h-3.5 text-krona-secondary" />
<span>{ins.deviceName}</span>
</div>
<div className="text-[11px] text-zinc-400 font-mono mt-0.5">
{ins.kind.toUpperCase()} • v{ins.appVersion} • {ins.osVersion}
<div className="text-[11px] text-krona-secondary font-mono mt-0.5">
{RU_PLATFORMS[ins.kind] || ins.kind} • v{ins.appVersion} • {ins.osVersion}
</div>
</div>
<StatusBadge status={ins.status} size="sm" />
</div>
))}
{orgInstalls.length === 0 && <div className="text-zinc-500 py-4 text-center">Нет активных установок</div>}
{orgInstalls.length === 0 && <div className="text-krona-secondary py-4 text-center">Нет активных установок</div>}
</div>
)}
{orgActiveTab === 'billing' && (
<div className="space-y-3 text-xs">
{sub ? (
<div className="p-3 rounded-lg bg-zinc-950/60 border border-zinc-800 space-y-2">
<div className="p-3 rounded-lg bg-krona-workspace/60 border border-krona-border space-y-2">
<div className="flex justify-between items-center">
<span className="font-semibold text-zinc-200">Тарифная подписка</span>
<span className="font-semibold text-krona-primary">Тарифная подписка</span>
<StatusBadge status={sub.status} size="sm" />
</div>
<div className="flex justify-between text-zinc-400">
<div className="flex justify-between text-krona-secondary">
<span>Период:</span>
<span className="text-zinc-200">{sub.period === 'year' ? 'Годовой' : 'Ежемесячный'}</span>
<span className="text-krona-primary">{sub.period === 'year' ? 'Годовой' : 'Ежемесячный'}</span>
</div>
<div className="flex justify-between text-zinc-400">
<div className="flex justify-between text-krona-secondary">
<span>Сумма тарифа:</span>
<MoneyDisplay minorUnits={sub.amountMinorUnits} className="text-amber-400 font-bold" />
<MoneyDisplay minorUnits={sub.amountMinorUnits} className="text-amber-500 font-bold" />
</div>
<div className="flex justify-between text-zinc-400">
<div className="flex justify-between text-krona-secondary">
<span>Действует до:</span>
<span className="text-zinc-200">{new Date(sub.validUntil).toLocaleDateString('ru-RU')}</span>
<span className="text-krona-primary">{new Date(sub.validUntil).toLocaleDateString('ru-RU')}</span>
</div>
</div>
) : (
<div className="text-zinc-500 py-4 text-center">Данные подписки отсутствуют</div>
<div className="text-krona-secondary py-4 text-center">Данные подписки отсутствуют</div>
)}
</div>
)}
@ -318,31 +319,31 @@ export const InspectorDrawer: React.FC = () => {
{orgActiveTab === 'incidents' && (
<div className="space-y-2 text-xs">
{orgIncidents.map((inc) => (
<div key={inc.incidentId} className="p-3 rounded-lg bg-zinc-950/60 border border-zinc-800 space-y-1.5">
<div key={inc.incidentId} className="p-3 rounded-lg bg-krona-workspace/60 border border-krona-border space-y-1.5">
<div className="flex justify-between items-center">
<span className="font-bold text-zinc-200 font-mono">{inc.incidentNumber}</span>
<span className="font-bold text-krona-primary font-mono">{inc.incidentNumber}</span>
<StatusBadge status={inc.status} size="sm" />
</div>
<p className="text-zinc-300">{inc.summary}</p>
<p className="text-krona-primary">{inc.summary}</p>
</div>
))}
{orgIncidents.length === 0 && <div className="text-zinc-500 py-4 text-center">Инцидентов нет</div>}
{orgIncidents.length === 0 && <div className="text-krona-secondary py-4 text-center">Инцидентов нет</div>}
</div>
)}
{orgActiveTab === 'audit' && (
<div className="space-y-2 text-xs">
{orgAudits.map((a) => (
<div key={a.eventId} className="p-2.5 rounded-lg bg-zinc-950/60 border border-zinc-800 space-y-1">
<div className="flex justify-between text-zinc-400 font-mono text-[10px]">
<div key={a.eventId} className="p-2.5 rounded-lg bg-krona-workspace/60 border border-krona-border space-y-1">
<div className="flex justify-between text-krona-secondary font-mono text-[10px]">
<span>{a.action}</span>
<span>{new Date(a.occurredAt).toLocaleTimeString('ru-RU')}</span>
</div>
<p className="text-zinc-200 text-[11px]">{a.reason}</p>
<div className="text-[10px] text-amber-400/80">{a.operatorName} ({a.operatorRole})</div>
<p className="text-krona-primary text-[11px]">{a.reason}</p>
<div className="text-[10px] text-amber-500">{a.operatorName} ({RU_ROLES[a.operatorRole]?.shortLabel || a.operatorRole})</div>
</div>
))}
{orgAudits.length === 0 && <div className="text-zinc-500 py-4 text-center">Записей аудита нет</div>}
{orgAudits.length === 0 && <div className="text-krona-secondary py-4 text-center">Записей аудита нет</div>}
</div>
)}
</div>
@ -351,7 +352,7 @@ export const InspectorDrawer: React.FC = () => {
case 'server': {
const srv = servers.find((s) => s.serverId === inspectorTarget.id);
if (!srv) return <div className="p-6 text-zinc-400">Сервер не найден</div>;
if (!srv) return <div className="p-6 text-krona-secondary">Сервер не найден</div>;
const srvInstances = instances.filter((i) => i.serverId === srv.serverId);
@ -388,23 +389,23 @@ export const InspectorDrawer: React.FC = () => {
<div>
<div className="flex items-center gap-2 mb-1.5">
<StatusBadge status={srv.status} />
<span className="text-xs text-zinc-400 font-mono">{srv.hostingProvider} • {srv.region}</span>
<span className="text-xs text-krona-secondary font-mono">{srv.hostingProvider} • {srv.region}</span>
</div>
<h2 className="text-lg font-bold text-zinc-100">{srv.name}</h2>
<p className="text-xs text-zinc-400 font-mono">{srv.ipAddress} • {srv.hostname}</p>
<h2 className="text-lg font-bold text-krona-primary">{srv.name}</h2>
<p className="text-xs text-krona-secondary font-mono">{srv.ipAddress} • {srv.hostname}</p>
</div>
{/* Operator Actions */}
<div className="rounded-xl bg-zinc-950/70 border border-zinc-800 p-3.5 space-y-2.5">
<div className="text-xs font-semibold text-zinc-300 flex items-center gap-1.5">
<ShieldAlert className="w-4 h-4 text-amber-400" />
<div className="rounded-xl bg-krona-workspace/60 border border-krona-border p-3.5 space-y-2.5">
<div className="text-xs font-semibold text-krona-primary flex items-center gap-1.5">
<ShieldAlert className="w-4 h-4 text-amber-500" />
<span>Операции с инфраструктурным узлом</span>
</div>
<div className="flex gap-2">
<div className="flex flex-wrap gap-2">
<button
disabled={!permissions.canRequestDiagnostics}
onClick={handleDiagnostics}
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-zinc-800 text-zinc-200 hover:bg-zinc-700 disabled:opacity-40 transition-colors flex items-center gap-1.5"
className="min-h-[44px] px-3 py-2 rounded-lg text-xs font-medium bg-krona-workspace text-krona-primary hover:bg-krona-surface border border-krona-border disabled:opacity-40 transition-colors flex items-center gap-1.5"
>
<Activity className="w-3.5 h-3.5 text-sky-400" />
<span>Запросить диагностику</span>
@ -412,9 +413,9 @@ export const InspectorDrawer: React.FC = () => {
<button
disabled={!permissions.canScheduleMaintenance}
onClick={handleMaintenance}
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-amber-950/60 text-amber-300 border border-amber-700/50 hover:bg-amber-900/40 disabled:opacity-40 transition-colors flex items-center gap-1.5"
className="min-h-[44px] px-3 py-2 rounded-lg text-xs font-medium bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/30 hover:bg-amber-500/20 disabled:opacity-40 transition-colors flex items-center gap-1.5"
>
<Clock className="w-3.5 h-3.5 text-amber-400" />
<Clock className="w-3.5 h-3.5 text-amber-500" />
<span>Запланировать ТО</span>
</button>
</div>
@ -422,18 +423,18 @@ export const InspectorDrawer: React.FC = () => {
{/* Telemetry Metrics */}
<div className="space-y-3">
<div className="font-semibold text-zinc-300 flex items-center justify-between">
<div className="font-semibold text-krona-primary flex items-center justify-between">
<span>Телеметрия узла (Server Monitor Agent):</span>
<span className="text-[11px] text-zinc-400 font-mono">Agent v{srv.agentVersion} ({srv.agentStatus})</span>
<span className="text-[11px] text-krona-secondary font-mono">Agent v{srv.agentVersion} ({srv.agentStatus})</span>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="p-3 rounded-lg bg-zinc-950 border border-zinc-800">
<div className="flex justify-between text-zinc-400 mb-1">
<div className="p-3 rounded-lg bg-krona-workspace/60 border border-krona-border">
<div className="flex justify-between text-krona-secondary mb-1">
<span>CPU Нагрузка:</span>
<span className="font-bold text-zinc-100">{srv.cpuLoadPercent}%</span>
<span className="font-bold text-krona-primary">{srv.cpuLoadPercent}%</span>
</div>
<div className="w-full bg-zinc-800 h-1.5 rounded-full overflow-hidden">
<div className="w-full bg-krona-surface h-1.5 rounded-full overflow-hidden">
<div
className={`h-full ${srv.cpuLoadPercent > 80 ? 'bg-rose-500' : 'bg-emerald-500'}`}
style={{ width: `${srv.cpuLoadPercent}%` }}
@ -441,14 +442,14 @@ export const InspectorDrawer: React.FC = () => {
</div>
</div>
<div className="p-3 rounded-lg bg-zinc-950 border border-zinc-800">
<div className="flex justify-between text-zinc-400 mb-1">
<div className="p-3 rounded-lg bg-krona-workspace/60 border border-krona-border">
<div className="flex justify-between text-krona-secondary mb-1">
<span>RAM Память:</span>
<span className="font-bold text-zinc-100">
<span className="font-bold text-krona-primary">
{Math.round(srv.ramUsedBytes / (1024 * 1024 * 1024))} / {Math.round(srv.ramTotalBytes / (1024 * 1024 * 1024))} GB
</span>
</div>
<div className="w-full bg-zinc-800 h-1.5 rounded-full overflow-hidden">
<div className="w-full bg-krona-surface h-1.5 rounded-full overflow-hidden">
<div
className="h-full bg-emerald-500"
style={{ width: `${(srv.ramUsedBytes / srv.ramTotalBytes) * 100}%` }}
@ -456,60 +457,60 @@ export const InspectorDrawer: React.FC = () => {
</div>
</div>
<div className="p-3 rounded-lg bg-zinc-950 border border-zinc-800">
<div className="flex justify-between text-zinc-400 mb-1">
<div className="p-3 rounded-lg bg-krona-workspace/60 border border-krona-border">
<div className="flex justify-between text-krona-secondary mb-1">
<span>Диск:</span>
<span className="font-bold text-zinc-100">
<span className="font-bold text-krona-primary">
{Math.round(srv.diskUsedBytes / (1024 * 1024 * 1024))} / {Math.round(srv.diskTotalBytes / (1024 * 1024 * 1024))} GB
</span>
</div>
<div className="w-full bg-zinc-800 h-1.5 rounded-full overflow-hidden">
<div className="w-full bg-krona-surface h-1.5 rounded-full overflow-hidden">
<div
className={`h-full ${srv.diskUsedBytes / srv.diskTotalBytes > 0.85 ? 'bg-rose-500' : 'bg-amber-400'}`}
className={`h-full ${srv.diskUsedBytes / srv.diskTotalBytes > 0.85 ? 'bg-rose-500' : 'bg-amber-500'}`}
style={{ width: `${(srv.diskUsedBytes / srv.diskTotalBytes) * 100}%` }}
/>
</div>
</div>
<div className="p-3 rounded-lg bg-zinc-950 border border-zinc-800">
<div className="text-zinc-400 mb-1">Сеть (Rx / Tx):</div>
<div className="font-mono text-zinc-200">
<div className="p-3 rounded-lg bg-krona-workspace/60 border border-krona-border">
<div className="text-krona-secondary mb-1">Сеть (Rx / Tx):</div>
<div className="font-mono text-krona-primary">
{(srv.networkRxBytesPerSec / 1024 / 1024).toFixed(1)} / {(srv.networkTxBytesPerSec / 1024 / 1024).toFixed(1)} MB/s
</div>
</div>
</div>
{/* Heartbeat timeline */}
<div className="p-3 rounded-lg bg-zinc-950 border border-zinc-800 space-y-2">
<div className="p-3 rounded-lg bg-krona-workspace/60 border border-krona-border space-y-2">
<div className="flex justify-between items-center">
<span className="font-semibold text-zinc-300">Пульс узла (Heartbeat 24h):</span>
<span className="text-[11px] text-zinc-400 font-mono">RTT: {srv.latencyMs}ms</span>
<span className="font-semibold text-krona-primary">Пульс узла (Heartbeat 24h):</span>
<span className="text-[11px] text-krona-secondary font-mono">RTT: {srv.latencyMs}ms</span>
</div>
<HeartbeatTimeline status={srv.status} count={24} />
</div>
{/* Backup & Restore Drill */}
<div className="grid grid-cols-2 gap-3">
<div className="p-3 rounded-lg bg-zinc-950 border border-zinc-800 space-y-1">
<div className="font-semibold text-zinc-300 flex items-center justify-between">
<span>Бэкап:</span>
<StatusBadge status={srv.backup.state === 'ok' ? 'healthy' : srv.backup.state === 'failed' ? 'critical' : 'warning'} size="sm" customLabel={srv.backup.state.toUpperCase()} />
<div className="p-3 rounded-lg bg-krona-workspace/60 border border-krona-border space-y-1">
<div className="font-semibold text-krona-primary flex items-center justify-between">
<span>Резервная копия:</span>
<StatusBadge status={srv.backup.state === 'ok' ? 'healthy' : srv.backup.state === 'failed' ? 'critical' : 'warning'} size="sm" customLabel={srv.backup.state === 'ok' ? 'УСПЕШНО' : srv.backup.state === 'failed' ? 'ОШИБКА' : 'В ПРОЦЕССЕ'} />
</div>
<div className="text-zinc-400 text-[11px]">
<div className="text-krona-secondary text-[11px]">
{srv.backup.lastSuccessfulAt ? new Date(srv.backup.lastSuccessfulAt).toLocaleString('ru-RU') : 'Нет успешных копий'}
</div>
<div className="text-zinc-500 text-[10px]">{srv.backup.targetProvider}</div>
<div className="text-krona-secondary text-[10px]">{srv.backup.targetProvider}</div>
</div>
<div className="p-3 rounded-lg bg-zinc-950 border border-zinc-800 space-y-1">
<div className="font-semibold text-zinc-300 flex items-center justify-between">
<span>Restore Drill:</span>
<StatusBadge status={srv.restoreDrill.state === 'passed' ? 'healthy' : srv.restoreDrill.state === 'failed' ? 'critical' : 'warning'} size="sm" customLabel={srv.restoreDrill.state.toUpperCase()} />
<div className="p-3 rounded-lg bg-krona-workspace/60 border border-krona-border space-y-1">
<div className="font-semibold text-krona-primary flex items-center justify-between">
<span>Тест восстановления:</span>
<StatusBadge status={srv.restoreDrill.state === 'passed' ? 'healthy' : srv.restoreDrill.state === 'failed' ? 'critical' : 'warning'} size="sm" customLabel={srv.restoreDrill.state === 'passed' ? 'ПРОЙДЕН' : srv.restoreDrill.state === 'failed' ? 'СБОЙ' : 'ОЖИДАНИЕ'} />
</div>
<div className="text-zinc-400 text-[11px]">
<div className="text-krona-secondary text-[11px]">
{srv.restoreDrill.verifiedAt ? new Date(srv.restoreDrill.verifiedAt).toLocaleDateString('ru-RU') : 'Никогда / Устарел'}
</div>
<div className="text-zinc-500 text-[10px]">
<div className="text-krona-secondary text-[10px]">
{srv.restoreDrill.drillDurationSeconds ? `Время восстановления: ${Math.round(srv.restoreDrill.drillDurationSeconds / 60)} мин.` : 'Тест не проводился'}
</div>
</div>
@ -517,12 +518,12 @@ export const InspectorDrawer: React.FC = () => {
{/* Module instances on server */}
<div className="space-y-2">
<div className="font-semibold text-zinc-300">Модули на узле ({srvInstances.length}):</div>
<div className="font-semibold text-krona-primary">Модули на узле ({srvInstances.length}):</div>
{srvInstances.map((inst) => (
<div key={inst.moduleInstanceId} className="p-2.5 rounded-lg bg-zinc-950 border border-zinc-800 flex justify-between items-center">
<div key={inst.moduleInstanceId} className="p-2.5 rounded-lg bg-krona-workspace/60 border border-krona-border flex justify-between items-center">
<div>
<div className="font-semibold text-zinc-200">{inst.moduleName}</div>
<div className="text-zinc-400 text-[11px]">{inst.tenantName}</div>
<div className="font-semibold text-krona-primary">{inst.moduleName}</div>
<div className="text-krona-secondary text-[11px]">{inst.tenantName}</div>
</div>
<StatusBadge status={inst.health} size="sm" />
</div>
@ -535,7 +536,7 @@ export const InspectorDrawer: React.FC = () => {
case 'incident': {
const inc = incidents.find((i) => i.incidentId === inspectorTarget.id);
if (!inc) return <div className="p-6 text-zinc-400">Инцидент не найден</div>;
if (!inc) return <div className="p-6 text-krona-secondary">Инцидент не найден</div>;
const handleResolve = () => {
openConfirmModal({
@ -556,47 +557,47 @@ export const InspectorDrawer: React.FC = () => {
<div>
<div className="flex items-center gap-2 mb-1.5">
<StatusBadge status={inc.status} />
<span className="font-bold text-amber-400 uppercase tracking-wider">{inc.severity} Severity</span>
<span className="font-bold text-amber-500 uppercase tracking-wider">{RU_INCIDENT_SEVERITY[inc.severity]?.label || inc.severity}</span>
</div>
<h2 className="text-lg font-bold text-zinc-100">{inc.incidentNumber}</h2>
<p className="text-sm text-zinc-200 mt-1 font-medium">{inc.summary}</p>
<h2 className="text-lg font-bold text-krona-primary">{inc.incidentNumber}</h2>
<p className="text-sm text-krona-primary mt-1 font-medium">{inc.summary}</p>
</div>
{inc.status !== 'resolved' && (
<div className="p-3.5 rounded-xl bg-zinc-950 border border-zinc-800 flex justify-between items-center">
<div className="p-3.5 rounded-xl bg-krona-workspace/60 border border-krona-border flex flex-wrap gap-2 justify-between items-center">
<div>
<div className="font-semibold text-zinc-200">Действие оператора:</div>
<div className="text-zinc-400 text-[11px]">Отметить инцидент как устраненный с обоснованием</div>
<div className="font-semibold text-krona-primary">Действие оператора:</div>
<div className="text-krona-secondary text-[11px]">Отметить инцидент как устраненный с обоснованием</div>
</div>
<button
disabled={!permissions.canManageIncidents}
onClick={handleResolve}
className="px-3 py-1.5 rounded-lg bg-emerald-600 hover:bg-emerald-500 text-zinc-950 font-bold text-xs transition-colors disabled:opacity-40"
className="min-h-[44px] px-4 py-2 rounded-lg bg-emerald-600 hover:bg-emerald-500 text-zinc-950 font-bold text-xs transition-colors disabled:opacity-40 flex items-center justify-center"
>
Разрешить инцидент
</button>
</div>
)}
<div className="rounded-lg bg-zinc-950 p-3.5 border border-zinc-800 space-y-2">
<div className="font-semibold text-zinc-300">Описание инцидента:</div>
<p className="text-zinc-300 leading-relaxed">{inc.description}</p>
<div className="text-zinc-400 pt-2 border-t border-zinc-800">
Ответственный: <span className="text-zinc-200">{inc.ownerName}</span>
<div className="rounded-lg bg-krona-workspace/60 p-3.5 border border-krona-border space-y-2">
<div className="font-semibold text-krona-primary">Описание инцидента:</div>
<p className="text-krona-primary leading-relaxed">{inc.description}</p>
<div className="text-krona-secondary pt-2 border-t border-krona-border">
Ответственный: <span className="text-krona-primary">{inc.ownerName}</span>
</div>
</div>
<div className="space-y-2">
<div className="font-semibold text-zinc-300">Хронология расследования (Timeline):</div>
<div className="space-y-2 relative border-l border-zinc-800 ml-2 pl-3">
<div className="font-semibold text-krona-primary">Хронология расследования (Timeline):</div>
<div className="space-y-2 relative border-l border-krona-border ml-2 pl-3">
{inc.timeline.map((step, idx) => (
<div key={idx} className="relative space-y-1">
<div className="absolute -left-[17px] top-1 w-2 h-2 rounded-full bg-amber-400" />
<div className="flex justify-between text-zinc-400 text-[10px]">
<span className="font-bold text-zinc-300">{step.author}</span>
<div className="absolute -left-[17px] top-1 w-2 h-2 rounded-full bg-amber-500" />
<div className="flex justify-between text-krona-secondary text-[10px]">
<span className="font-bold text-krona-primary">{step.author}</span>
<span>{new Date(step.timestamp).toLocaleTimeString('ru-RU')}</span>
</div>
<p className="text-zinc-300">{step.message}</p>
<p className="text-krona-primary">{step.message}</p>
</div>
))}
</div>
@ -606,34 +607,41 @@ export const InspectorDrawer: React.FC = () => {
}
default:
return <div className="p-6 text-zinc-400">Детальная информация для данного типа пока формируется</div>;
return <div className="p-6 text-krona-secondary">Детальная информация для данного типа пока формируется</div>;
}
};
return (
<div
className="fixed inset-y-0 right-0 z-40 w-full max-w-xl bg-[#0d1e17] border-l border-zinc-800 shadow-2xl flex flex-col text-zinc-100 animate-in slide-in-from-right duration-200"
role="complementary"
aria-label="Контекстный инспектор"
>
{/* Drawer Header */}
<div className="px-6 py-4 border-b border-zinc-800 flex items-center justify-between bg-zinc-950/60">
<div className="flex items-center gap-2 text-xs font-semibold text-zinc-400 uppercase tracking-wider">
<Activity className="w-4 h-4 text-amber-400" />
<span>Контекстный инспектор</span>
<>
<div
className="fixed inset-0 z-40 bg-black/60 backdrop-blur-xs transition-opacity"
onClick={closeInspector}
aria-hidden="true"
/>
<div
className="fixed inset-y-0 right-0 z-50 w-full sm:max-w-xl bg-krona-surface border-l border-krona-border shadow-2xl flex flex-col text-krona-primary animate-in slide-in-from-right duration-200"
role="complementary"
aria-label="Контекстный инспектор"
>
{/* Drawer Header */}
<div className="px-5 md:px-6 py-4 border-b border-krona-border flex items-center justify-between bg-krona-workspace/80">
<div className="flex items-center gap-2 text-xs font-semibold text-krona-secondary uppercase tracking-wider">
<Activity className="w-4 h-4 text-amber-500" />
<span>Контекстный инспектор объекта</span>
</div>
<button
type="button"
onClick={closeInspector}
className="min-w-[44px] min-h-[44px] rounded-lg flex items-center justify-center text-krona-secondary hover:bg-krona-workspace hover:text-krona-primary transition-colors"
aria-label="Закрыть инспектор"
>
<X className="w-5 h-5" />
</button>
</div>
<button
type="button"
onClick={closeInspector}
className="rounded-lg p-1.5 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200 transition-colors"
aria-label="Закрыть инспектор"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Drawer Body */}
<div className="flex-1 overflow-y-auto p-6">{renderContent()}</div>
</div>
{/* Drawer Body */}
<div className="flex-1 overflow-y-auto p-4 md:p-6">{renderContent()}</div>
</div>
</>
);
};

View file

@ -112,7 +112,7 @@ export const StatusBadge: React.FC<StatusBadgeProps> = ({ status, customLabel, s
icon: <CheckCircle2 className={iconSize} aria-hidden="true" />,
},
degraded: {
label: 'Работает с ограничениями',
label: 'Деградация',
className: 'bg-status-warning-soft text-status-warning-text border-status-warning-border',
icon: <AlertTriangle className={iconSize} aria-hidden="true" />,
},
@ -220,9 +220,10 @@ export const StatusBadge: React.FC<StatusBadgeProps> = ({ status, customLabel, s
return (
<span
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 font-medium transition-colors ${
isSmall ? 'text-[11px] py-0.5 px-2' : 'text-xs'
isSmall ? 'text-xs py-0.5 px-2' : 'text-xs'
} ${current.className}`}
data-testid={`status-badge-${status}`}
data-testid="status-badge"
data-status={status}
>
{current.icon}
<span>{displayText}</span>

View file

@ -1,4 +1,10 @@
import type { OperatorRole, ServerStatus, SubscriptionStatus, IncidentSeverity, IncidentStatus, AuditActionType } from '../contracts';
import type { OperatorRole } from '../contracts';
export type ServerStatus = 'healthy' | 'warning' | 'critical' | 'offline' | 'degraded';
export type SubscriptionStatus = 'normal' | 'expiring_7d' | 'expiring_30d' | 'overdue' | 'grace' | 'suspended' | 'trial' | 'active' | 'canceled';
export type IncidentSeverity = 'low' | 'medium' | 'high' | 'critical' | 'P0_CRITICAL' | 'P1_MAJOR' | 'P2_DEGRADED' | 'P3_INFO';
export type IncidentStatus = 'open' | 'mitigating' | 'resolved' | 'investigating' | 'identified' | 'monitoring';
export type AuditActionType = string;
export const RU_ROLES: Record<OperatorRole, { label: string; shortLabel: string; description: string }> = {
platform_owner: {
@ -33,32 +39,44 @@ export const RU_ROLES: Record<OperatorRole, { label: string; shortLabel: string;
},
};
export const RU_SERVER_STATUS: Record<ServerStatus, { label: string; badgeVariant: 'success' | 'warning' | 'danger' | 'neutral' }> = {
export const RU_SERVER_STATUS: Record<string, { label: string; badgeVariant: 'success' | 'warning' | 'danger' | 'neutral' }> = {
healthy: { label: 'Работает нормально', badgeVariant: 'success' },
warning: { label: 'Предупреждение', badgeVariant: 'warning' },
degraded: { label: 'Работает с ограничениями', badgeVariant: 'warning' },
critical: { label: 'Критический сбой', badgeVariant: 'danger' },
offline: { label: 'Недоступен', badgeVariant: 'danger' },
};
export const RU_SUBSCRIPTION_STATUS: Record<SubscriptionStatus, { label: string; badgeVariant: 'success' | 'warning' | 'danger' | 'info' | 'neutral' }> = {
trial: { label: 'Пробный период', badgeVariant: 'info' },
export const RU_SUBSCRIPTION_STATUS: Record<string, { label: string; badgeVariant: 'success' | 'warning' | 'danger' | 'info' | 'neutral' }> = {
normal: { label: 'Активна', badgeVariant: 'success' },
active: { label: 'Активна', badgeVariant: 'success' },
trial: { label: 'Пробный период', badgeVariant: 'info' },
expiring_7d: { label: 'Истекает (< 7 дн.)', badgeVariant: 'warning' },
expiring_30d: { label: 'Истекает (< 30 дн.)', badgeVariant: 'warning' },
grace: { label: 'Льготный период', badgeVariant: 'warning' },
overdue: { label: 'Просрочена', badgeVariant: 'danger' },
suspended: { label: 'Приостановлена', badgeVariant: 'danger' },
canceled: { label: 'Отменена', badgeVariant: 'neutral' },
};
export const RU_INCIDENT_SEVERITY: Record<IncidentSeverity, { label: string; short: string }> = {
export const RU_INCIDENT_SEVERITY: Record<string, { label: string; short: string }> = {
critical: { label: 'Критическая авария', short: 'Критическая' },
high: { label: 'Высокая важность', short: 'Высокая' },
medium: { label: 'Средняя важность', short: 'Средняя' },
low: { label: 'Низкая важность', short: 'Низкая' },
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> = {
export const RU_INCIDENT_STATUS: Record<string, string> = {
open: 'Открыт',
mitigating: 'Устраняется',
resolved: 'Устранён',
investigating: 'Расследуется',
identified: 'Локализован',
monitoring: 'Мониторинг устранения',
resolved: 'Устранён',
};
export const RU_AUDIT_ACTION: Record<AuditActionType, string> = {

View file

@ -2,11 +2,9 @@ import React, { useState } from 'react';
import {
CreditCard,
RefreshCw,
SlidersHorizontal,
Calendar,
CheckCircle2,
Clock,
ShieldAlert,
} from 'lucide-react';
import { useKccStore, useOperatorRole } from '../store/kccStore';
import { StatusBadge } from '../components/StatusBadge';
@ -46,12 +44,12 @@ export const BillingPage: React.FC = () => {
const handleRenew = () => {
if (!selectedSub) return;
openConfirmModal({
title: `Продление подписки: ${selectedSub.organizationName}`,
title: `Продление подписки: ${selectedSub.tenantName}`,
actionName: 'grant_grace_period',
impactSummary: `Подписка будет продлена на ${newPeriod === 'year' ? '1 год' : '1 месяц'}. Состав модулей: ${activeModulesToDisplay.join(', ')}. Расчётная сумма: ${calculatedTotal / 100} ₽.`,
targetType: 'subscription',
targetId: selectedSub.subscriptionId,
targetName: selectedSub.organizationName,
targetName: selectedSub.tenantName,
onConfirm: async (reason) => {
await renewSubscription(selectedSub.subscriptionId, newPeriod, activeModulesToDisplay, reason);
},
@ -61,12 +59,12 @@ export const BillingPage: React.FC = () => {
const handleGrantGrace = () => {
if (!selectedSub) return;
openConfirmModal({
title: `Предоставление льготного периода: ${selectedSub.organizationName}`,
title: `Предоставление льготного периода: ${selectedSub.tenantName}`,
actionName: 'grant_grace_period',
impactSummary: `Организации будет предоставлен льготный период (Grace Period) на 14 дней с сохранением доступа ко всем модулям.`,
targetType: 'subscription',
targetId: selectedSub.subscriptionId,
targetName: selectedSub.organizationName,
targetName: selectedSub.tenantName,
onConfirm: async (reason) => {
await grantGracePeriod(selectedSub.subscriptionId, 14, reason);
},
@ -113,6 +111,7 @@ export const BillingPage: React.FC = () => {
<button
key={tab.id}
type="button"
data-testid={`filter-${tab.id}`}
onClick={() => setFilter(tab.id as typeof filter)}
className={`min-h-[44px] px-3.5 py-2 rounded-lg font-medium transition-colors shrink-0 ${
filter === tab.id
@ -149,9 +148,9 @@ export const BillingPage: React.FC = () => {
>
<div className="flex items-start justify-between gap-2">
<div>
<h3 className="font-bold text-sm text-krona-primary">{sub.organizationName}</h3>
<h3 className="font-bold text-sm text-krona-primary">{sub.tenantName}</h3>
<div className="text-[11px] text-krona-tertiary mt-0.5">
Тарифный план: <span className="font-semibold text-krona-primary">{sub.planName}</span>
Тарифный план: <span className="font-semibold text-krona-primary">{sub.planCode}</span>
</div>
</div>
<StatusBadge status={sub.status} size="sm" />
@ -160,7 +159,7 @@ export const BillingPage: React.FC = () => {
<div className="grid grid-cols-2 gap-2 text-xs py-2 border-y border-krona-border">
<div>
<div className="text-[10px] text-krona-tertiary">Стоимость в месяц</div>
<MoneyDisplay minorUnits={sub.priceMinorUnits} currency={sub.currency} className="font-bold text-krona-gold text-xs" />
<MoneyDisplay minorUnits={sub.amountMinorUnits} currency={sub.currency} className="font-bold text-krona-gold text-xs" />
</div>
<div>
<div className="text-[10px] text-krona-tertiary">Действует до</div>
@ -179,7 +178,7 @@ export const BillingPage: React.FC = () => {
type="button"
onClick={(e) => {
e.stopPropagation();
openInspector({ type: 'subscription', id: sub.subscriptionId, title: sub.organizationName, data: sub });
openInspector('organization', sub.tenantId);
}}
className="min-h-[44px] px-3 text-xs font-semibold text-krona-gold hover:underline flex items-center gap-1"
>
@ -205,7 +204,7 @@ export const BillingPage: React.FC = () => {
<div className="text-[11px] font-semibold text-krona-gold uppercase tracking-wider">
Управление подпиской
</div>
<h2 className="text-base font-bold text-krona-primary mt-0.5">{selectedSub.organizationName}</h2>
<h2 className="text-base font-bold text-krona-primary mt-0.5">{selectedSub.tenantName}</h2>
<div className="text-xs text-krona-secondary mt-0.5">
Текущий статус: <StatusBadge status={selectedSub.status} size="sm" />
</div>
@ -284,8 +283,9 @@ export const BillingPage: React.FC = () => {
<div className="space-y-2 pt-2">
<button
type="button"
disabled={!permissions.canGrantTrialGrace}
onClick={handleRenew}
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"
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 disabled:opacity-40"
data-testid="renew-subscription-btn"
>
<RefreshCw className="w-4 h-4" />
@ -294,8 +294,9 @@ export const BillingPage: React.FC = () => {
<button
type="button"
disabled={!permissions.canGrantTrialGrace}
onClick={handleGrantGrace}
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"
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 disabled:opacity-40"
data-testid="grant-grace-btn"
>
<Clock className="w-4 h-4" />

View file

@ -2,7 +2,7 @@ import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Lock, ArrowRight, Sun, Moon, Sparkles } from 'lucide-react';
import { useKccStore } from '../store/kccStore';
import { useThemeStore } from '../../../shared/model/themeStore';
import { useThemeStore, ThemeMode } from '../../../stores/theme';
import { RU_THEMES, RU_UI } from '../locales/ru';
export const ControlAccessGatePage: React.FC = () => {
@ -11,10 +11,10 @@ export const ControlAccessGatePage: React.FC = () => {
const [pin, setPin] = useState('');
const [error, setError] = useState(false);
const theme = useThemeStore((state) => state.theme);
const themeMode = useThemeStore((state) => state.themeMode);
const setTheme = useThemeStore((state) => state.setTheme);
const themes: Array<{ id: 'soft-light' | 'light' | 'dark'; label: string; icon: React.ReactNode }> = [
const themes: Array<{ id: ThemeMode; label: string; icon: React.ReactNode }> = [
{ id: 'soft-light', label: RU_THEMES['soft-light'], icon: <Sparkles className="w-3.5 h-3.5" /> },
{ id: 'light', label: RU_THEMES['light'], icon: <Sun className="w-3.5 h-3.5" /> },
{ id: 'dark', label: RU_THEMES['dark'], icon: <Moon className="w-3.5 h-3.5" /> },
@ -46,7 +46,7 @@ export const ControlAccessGatePage: React.FC = () => {
onClick={() => setTheme(t.id)}
title={t.label}
className={`min-w-[40px] min-h-[40px] px-2.5 py-1 rounded-lg text-xs font-medium flex items-center gap-1.5 transition-colors ${
theme === t.id
themeMode === t.id
? 'bg-amber-500 text-zinc-950 font-bold shadow-xs'
: 'text-krona-secondary hover:text-krona-primary hover:bg-krona-workspace'
}`}
@ -79,6 +79,7 @@ export const ControlAccessGatePage: React.FC = () => {
<input
type="password"
value={pin}
data-testid="pin-input"
onChange={(e) => {
setPin(e.target.value);
if (error) setError(false);
@ -87,7 +88,7 @@ export const ControlAccessGatePage: React.FC = () => {
className="w-full bg-krona-workspace border border-krona-border rounded-xl px-4 py-3 text-center text-sm text-krona-primary placeholder:text-krona-secondary/60 focus:outline-hidden focus:border-amber-500 tracking-wider font-mono min-h-[48px]"
/>
{error && (
<p className="text-xs text-rose-500 font-medium mt-1.5">
<p className="text-xs text-rose-500 font-medium mt-1.5" data-testid="pin-error">
{RU_UI.gateError}
</p>
)}
@ -95,6 +96,7 @@ export const ControlAccessGatePage: React.FC = () => {
<button
type="submit"
data-testid="pin-submit"
className="w-full min-h-[48px] py-3 px-4 rounded-xl bg-amber-500 hover:bg-amber-400 text-zinc-950 font-bold text-xs flex items-center justify-center gap-2 transition-colors shadow-xs"
>
<span>{RU_UI.gateSubmit}</span>
@ -105,6 +107,7 @@ export const ControlAccessGatePage: React.FC = () => {
<div className="pt-2 border-t border-krona-border">
<button
type="button"
data-testid="pin-demo-login"
onClick={handleDemoBypass}
className="min-h-[44px] inline-flex items-center text-xs text-krona-secondary hover:text-amber-500 transition-colors underline underline-offset-4"
>

View file

@ -26,21 +26,13 @@ export const IncidentsPage: React.FC = () => {
inc.summary.toLowerCase().includes(search.toLowerCase()) ||
inc.description.toLowerCase().includes(search.toLowerCase());
let matchStatus = true;
if (statusFilter === 'open') {
matchStatus = inc.status === 'investigating';
} else if (statusFilter === 'mitigating') {
matchStatus = inc.status === 'identified' || inc.status === 'monitoring';
} else if (statusFilter === 'resolved') {
matchStatus = inc.status === 'resolved';
}
const matchStatus = statusFilter === 'all' || inc.status === statusFilter;
return matchSearch && matchStatus;
});
const unresolvedCount = incidents.filter((i) => i.status !== 'resolved').length;
const criticalCount = incidents.filter(
(i) => i.status !== 'resolved' && (i.severity === 'P0_CRITICAL' || i.severity === 'P1_MAJOR')
(i) => i.status !== 'resolved' && (i.severity === 'critical' || i.severity === 'high')
).length;
return (
@ -111,8 +103,8 @@ export const IncidentsPage: React.FC = () => {
{/* Incidents List - Mobile-First Cards */}
<div className="space-y-3">
{filtered.map((inc) => {
const isCritical = inc.severity === 'P0_CRITICAL';
const isMajor = inc.severity === 'P1_MAJOR';
const isCritical = inc.severity === 'critical';
const isMajor = inc.severity === 'high';
const severityInfo = RU_INCIDENT_SEVERITY[inc.severity] || {
label: inc.severity,
short: inc.severity,

View file

@ -5,14 +5,9 @@ import {
ChevronDown,
ChevronUp,
ChevronRight,
ShieldAlert,
Activity,
HardDrive,
Cpu,
Clock,
ExternalLink,
} from 'lucide-react';
import { useKccStore, useOperatorRole } from '../store/kccStore';
import { useKccStore } from '../store/kccStore';
import { StatusBadge } from '../components/StatusBadge';
import { HeartbeatTimeline } from '../components/HeartbeatTimeline';
import { ServerNode } from '../contracts';
@ -24,7 +19,6 @@ export const InfrastructurePage: React.FC = () => {
const openConfirmModal = useKccStore((state) => state.openConfirmModal);
const createIncidentFromAlert = useKccStore((state) => state.createIncidentFromAlert);
const requestServerDiagnostics = useKccStore((state) => state.requestServerDiagnostics);
const { permissions } = useOperatorRole();
const [search, setSearch] = useState('');
const [providerFilter, setProviderFilter] = useState<'all' | 'Selectel' | 'Timeweb Cloud'>('all');
@ -41,7 +35,7 @@ export const InfrastructurePage: React.FC = () => {
srv.name.toLowerCase().includes(search.toLowerCase()) ||
srv.ipAddress.includes(search) ||
srv.hostname.toLowerCase().includes(search.toLowerCase()) ||
srv.organizationName.toLowerCase().includes(search.toLowerCase());
srv.hostingProvider.toLowerCase().includes(search.toLowerCase());
const matchProvider = providerFilter === 'all' || srv.hostingProvider === providerFilter;
return matchSearch && matchProvider;
});
@ -52,7 +46,7 @@ export const InfrastructurePage: React.FC = () => {
title: `Создать инцидент для сервера: ${srv.name}`,
actionName: 'circuit_breaker_trip',
impactSummary: `Будет открыт критический инцидент платформы, затронутые организации получат уведомление о расследовании.`,
targetType: 'server',
targetType: 'infrastructure',
targetId: srv.serverId,
targetName: srv.name,
onConfirm: async (reason) => {
@ -72,7 +66,7 @@ export const InfrastructurePage: React.FC = () => {
title: `Запрос телеметрии: ${srv.name}`,
actionName: 'invalidate_cache',
impactSummary: `Агенту мониторинга v${srv.agentVersion} будет направлен запрос выгрузки моментального снимка состояния.`,
targetType: 'server',
targetType: 'infrastructure',
targetId: srv.serverId,
targetName: srv.name,
onConfirm: async (reason) => {
@ -144,12 +138,15 @@ export const InfrastructurePage: React.FC = () => {
{filteredServers.map((srv) => {
const srvInstances = instances.filter((i) => i.serverId === srv.serverId);
const isExpanded = !!expandedDetails[srv.serverId];
const lastSignalDate = new Date(srv.metrics.lastHeartbeatAt);
const lastSignalDate = new Date(srv.lastSeenAt);
const cpuPercent = srv.cpuLoadPercent;
const ramPercent = Math.round((srv.ramUsedBytes / (srv.ramTotalBytes || 1)) * 100);
const diskPercent = Math.round((srv.diskUsedBytes / (srv.diskTotalBytes || 1)) * 100);
return (
<div
key={srv.serverId}
onClick={() => openInspector({ type: 'server', id: srv.serverId, title: srv.name, data: srv })}
onClick={() => openInspector('server', srv.serverId)}
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 === 'critical' || srv.status === 'offline'
? 'border-status-danger-border bg-status-danger-soft/20'
@ -179,11 +176,11 @@ export const InfrastructurePage: React.FC = () => {
</span>
</div>
{/* Destination / Organization */}
{/* Destination / Provider */}
<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>
<span className="text-krona-tertiary">Провайдер: </span>
<span className="font-semibold text-krona-primary">{srv.hostingProvider}</span>
</div>
<div className="text-[11px] text-krona-tertiary flex items-center gap-1">
<Clock className="w-3.5 h-3.5" />
@ -197,14 +194,14 @@ export const InfrastructurePage: React.FC = () => {
<div className="p-2 rounded-lg bg-krona-workspace border border-krona-border space-y-1">
<div className="flex justify-between text-[11px] text-krona-tertiary">
<span>CPU</span>
<span className={`font-bold font-mono ${srv.metrics.cpuPercent > 80 ? 'text-status-danger-text' : 'text-krona-primary'}`}>
{srv.metrics.cpuPercent}%
<span className={`font-bold font-mono ${cpuPercent > 80 ? 'text-status-danger-text' : 'text-krona-primary'}`}>
{cpuPercent}%
</span>
</div>
<div className="w-full bg-krona-surface h-1.5 rounded-full overflow-hidden">
<div
className={`h-full ${srv.metrics.cpuPercent > 80 ? 'bg-red-500' : 'bg-emerald-500'}`}
style={{ width: `${srv.metrics.cpuPercent}%` }}
className={`h-full ${cpuPercent > 80 ? 'bg-red-500' : 'bg-emerald-500'}`}
style={{ width: `${cpuPercent}%` }}
/>
</div>
</div>
@ -213,14 +210,14 @@ export const InfrastructurePage: React.FC = () => {
<div className="p-2 rounded-lg bg-krona-workspace border border-krona-border space-y-1">
<div className="flex justify-between text-[11px] text-krona-tertiary">
<span>RAM</span>
<span className={`font-bold font-mono ${srv.metrics.ramPercent > 80 ? 'text-status-danger-text' : 'text-krona-primary'}`}>
{srv.metrics.ramPercent}%
<span className={`font-bold font-mono ${ramPercent > 80 ? 'text-status-danger-text' : 'text-krona-primary'}`}>
{ramPercent}%
</span>
</div>
<div className="w-full bg-krona-surface h-1.5 rounded-full overflow-hidden">
<div
className={`h-full ${srv.metrics.ramPercent > 80 ? 'bg-red-500' : 'bg-emerald-500'}`}
style={{ width: `${srv.metrics.ramPercent}%` }}
className={`h-full ${ramPercent > 80 ? 'bg-red-500' : 'bg-emerald-500'}`}
style={{ width: `${ramPercent}%` }}
/>
</div>
</div>
@ -229,14 +226,14 @@ export const InfrastructurePage: React.FC = () => {
<div className="p-2 rounded-lg bg-krona-workspace border border-krona-border space-y-1">
<div className="flex justify-between text-[11px] text-krona-tertiary">
<span>Диск</span>
<span className={`font-bold font-mono ${srv.metrics.diskPercent > 85 ? 'text-status-danger-text' : 'text-krona-primary'}`}>
{srv.metrics.diskPercent}%
<span className={`font-bold font-mono ${diskPercent > 85 ? 'text-status-danger-text' : 'text-krona-primary'}`}>
{diskPercent}%
</span>
</div>
<div className="w-full bg-krona-surface h-1.5 rounded-full overflow-hidden">
<div
className={`h-full ${srv.metrics.diskPercent > 85 ? 'bg-red-500' : 'bg-emerald-500'}`}
style={{ width: `${srv.metrics.diskPercent}%` }}
className={`h-full ${diskPercent > 85 ? 'bg-red-500' : 'bg-emerald-500'}`}
style={{ width: `${diskPercent}%` }}
/>
</div>
</div>
@ -249,18 +246,22 @@ export const InfrastructurePage: React.FC = () => {
<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}
{srv.backup.state === 'ok' ? 'В норме' : 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>
Активных модулей:{' '}
<span className="font-mono text-krona-primary font-semibold">{srvInstances.length}</span>
</div>
</div>
<div>
<div className="text-[11px] text-krona-tertiary mb-1">История сигналов (Heartbeat):</div>
<HeartbeatTimeline history={srv.metrics.heartbeatHistory} compact />
<HeartbeatTimeline status={srv.status} count={24} />
</div>
<div className="flex items-center gap-2 pt-1">
@ -309,7 +310,7 @@ export const InfrastructurePage: React.FC = () => {
type="button"
onClick={(e) => {
e.stopPropagation();
openInspector({ type: 'server', id: srv.serverId, title: srv.name, data: srv });
openInspector('server', srv.serverId);
}}
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}`}

View file

@ -182,7 +182,7 @@ export const InstallationsPage: React.FC = () => {
<span className="font-bold text-sm text-krona-primary">{item.deviceName}</span>
</div>
<div className="text-xs text-krona-secondary mt-0.5">
{item.tenantName}
{item.tenantName} • {platformLabel}
</div>
</div>
<StatusBadge status={item.status} size="sm" customLabel={statusLabel} />

View file

@ -3,10 +3,6 @@ import {
Building2,
Search,
ChevronRight,
Filter,
Users,
CreditCard,
Layers,
} from 'lucide-react';
import { useKccStore } from '../store/kccStore';
import { StatusBadge } from '../components/StatusBadge';
@ -96,7 +92,7 @@ export const OrganizationsPage: React.FC = () => {
{filtered.map((org) => (
<div
key={org.tenantId}
onClick={() => openInspector({ type: 'organization', id: org.tenantId, title: org.displayName, data: org })}
onClick={() => openInspector('organization', org.tenantId)}
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 */}
@ -135,12 +131,12 @@ export const OrganizationsPage: React.FC = () => {
type="button"
onClick={(e) => {
e.stopPropagation();
openInspector({ type: 'organization', id: org.tenantId, title: org.displayName, data: org });
openInspector('organization', org.tenantId);
}}
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>
<span>360° Карточка</span>
<ChevronRight className="w-4 h-4" />
</button>
</div>
@ -177,7 +173,7 @@ export const OrganizationsPage: React.FC = () => {
{filtered.map((org) => (
<tr
key={org.tenantId}
onClick={() => openInspector({ type: 'organization', id: org.tenantId, title: org.displayName, data: org })}
onClick={() => openInspector('organization', org.tenantId)}
className="hover:bg-krona-hover cursor-pointer transition-colors group"
>
<td className="py-3 px-4">
@ -221,12 +217,12 @@ export const OrganizationsPage: React.FC = () => {
type="button"
onClick={(e) => {
e.stopPropagation();
openInspector({ type: 'organization', id: org.tenantId, title: org.displayName, data: org });
openInspector('organization', org.tenantId);
}}
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.5 h-3.5" />
</button>
</td>

View file

@ -9,12 +9,9 @@ import {
ShieldAlert,
ArrowUpRight,
Activity,
AlertTriangle,
CheckCircle2,
Clock,
Layers,
HelpCircle,
Database,
} from 'lucide-react';
import { useKccStore, useOperatorRole } from '../store/kccStore';
import { MoneyDisplay } from '../components/MoneyDisplay';
@ -49,7 +46,6 @@ export const OverviewPage: React.FC = () => {
const totalMrrMinorUnits = organizations.reduce((sum, o) => sum + o.mrrMinorUnits, 0);
const expiring7d = subscriptions.filter((s) => s.status === 'expiring_7d');
const expiring30d = subscriptions.filter((s) => s.status === 'expiring_30d');
const overdueSubs = subscriptions.filter((s) => s.status === 'overdue');
const webInstalls = installations.filter((i) => i.kind === 'web').length;
@ -59,11 +55,9 @@ export const OverviewPage: React.FC = () => {
const instancesOffline = instances.filter((i) => i.health === 'offline');
const instancesDegraded = instances.filter((i) => i.health === 'degraded');
const instancesHealthy = instances.filter((i) => i.health === 'healthy');
const serversHealthy = servers.filter((s) => s.status === 'healthy').length;
const serversProblematic = servers.filter((s) => s.status !== 'healthy');
const failedBackups = servers.filter((s) => s.backup.state === 'failed');
const openIncidents = incidents.filter((i) => i.status !== 'resolved');
// Total attention items count
@ -87,7 +81,7 @@ export const OverviewPage: React.FC = () => {
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2.5 border-b border-krona-border pb-4">
<div>
<h1 className="text-lg sm:text-xl font-bold text-krona-primary flex items-center gap-2 flex-wrap">
<span>Сводная панель платформы</span>
<span>Сводная операторская панель</span>
<span className="text-[11px] px-2.5 py-0.5 rounded-full bg-status-success-soft text-status-success-text border border-status-success-border font-medium">
Контур платформы
</span>
@ -193,8 +187,8 @@ export const OverviewPage: React.FC = () => {
<div className="space-y-2">
{openIncidents.map((inc) => (
<div
key={inc.id}
onClick={() => openInspector({ type: 'incident', id: inc.id, title: inc.title, data: inc })}
key={inc.incidentId}
onClick={() => openInspector('incident', inc.incidentId)}
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">
@ -202,15 +196,15 @@ export const OverviewPage: React.FC = () => {
<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>
<span className="font-bold text-xs text-status-danger-text">{inc.summary}</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>
<p className="text-xs text-krona-secondary line-clamp-2">{inc.description}</p>
<div className="text-[11px] text-krona-tertiary">
Затронуто: {inc.affectedTenants.length} орг. • Узлы: {inc.affectedNodes.join(', ')}
Затронуто: {inc.affectedTenantIds.length} орг. • Узлов: {inc.affectedServerIds.length}
</div>
</div>
))}
@ -239,26 +233,31 @@ export const OverviewPage: React.FC = () => {
</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" />
{serversProblematic.map((srv) => {
const cpuPercent = srv.cpuLoadPercent;
const ramPercent = Math.round((srv.ramUsedBytes / (srv.ramTotalBytes || 1)) * 100);
const diskPercent = Math.round((srv.diskUsedBytes / (srv.diskTotalBytes || 1)) * 100);
return (
<div
key={srv.serverId}
onClick={() => openInspector('server', srv.serverId)}
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={cpuPercent > 80 ? 'text-status-danger-text font-bold' : ''}>{cpuPercent}%</span></div>
<div>RAM: <span className={ramPercent > 80 ? 'text-status-danger-text font-bold' : ''}>{ramPercent}%</span></div>
<div>Диск: <span className={diskPercent > 85 ? 'text-status-danger-text font-bold' : ''}>{diskPercent}%</span></div>
</div>
<div className="text-[11px] text-krona-tertiary">
Провайдер: {srv.hostingProvider} ({srv.region})
</div>
</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>
)}
@ -286,13 +285,13 @@ export const OverviewPage: React.FC = () => {
<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 })}
key={inst.moduleInstanceId}
onClick={() => openInspector('organization', inst.tenantId)}
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 className="font-bold text-xs text-krona-primary">{inst.moduleName} v{inst.currentVersion}</div>
<div className="text-[11px] text-krona-tertiary">Тенант: {inst.tenantName}</div>
</div>
<StatusBadge status={inst.health} size="sm" />
</div>
@ -324,18 +323,18 @@ export const OverviewPage: React.FC = () => {
<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 })}
key={sub.subscriptionId}
onClick={() => openInspector('organization', sub.tenantId)}
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="font-bold text-xs text-krona-primary">{sub.tenantName}</div>
<div className="text-[11px] text-krona-tertiary">
Тариф: {sub.planName} • До: {new Date(sub.validUntil).toLocaleDateString('ru-RU')}
Тариф: {sub.planCode} • До: {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" />
<MoneyDisplay minorUnits={sub.amountMinorUnits} currency={sub.currency} className="font-bold text-xs text-krona-primary" />
<div className="mt-1"><StatusBadge status={sub.status} size="sm" /></div>
</div>
</div>
@ -526,53 +525,59 @@ export const OverviewPage: React.FC = () => {
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{servers.map((srv) => (
<div
key={srv.id}
onClick={() => openInspector({ type: 'server', id: srv.id, title: srv.hostname, data: srv })}
className="p-3.5 rounded-lg border border-krona-border bg-krona-workspace cursor-pointer hover:border-krona-gold/40 transition-colors space-y-2.5"
>
<div className="flex items-center justify-between">
<div>
<div className="font-bold text-xs text-krona-primary font-mono">{srv.hostname}</div>
<div className="text-[11px] text-krona-tertiary">{srv.organizationName}</div>
</div>
<StatusBadge status={srv.status} size="sm" />
</div>
{servers.map((srv) => {
const cpuPercent = srv.cpuLoadPercent;
const ramPercent = Math.round((srv.ramUsedBytes / (srv.ramTotalBytes || 1)) * 100);
const diskPercent = Math.round((srv.diskUsedBytes / (srv.diskTotalBytes || 1)) * 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}%
return (
<div
key={srv.serverId}
onClick={() => openInspector('server', srv.serverId)}
className="p-3.5 rounded-lg border border-krona-border bg-krona-workspace cursor-pointer hover:border-krona-gold/40 transition-colors space-y-2.5"
>
<div className="flex items-center justify-between">
<div>
<div className="font-bold text-xs text-krona-primary font-mono">{srv.hostname}</div>
<div className="text-[11px] text-krona-tertiary">{srv.hostingProvider} ({srv.region})</div>
</div>
<StatusBadge status={srv.status} size="sm" />
</div>
<div className="grid grid-cols-3 gap-2 text-center text-xs py-1 bg-krona-surface/40 rounded-md border border-krona-border">
<div>
<div className="text-[10px] text-krona-tertiary">CPU</div>
<div className={`font-mono font-bold ${cpuPercent > 80 ? 'text-status-danger-text' : 'text-krona-primary'}`}>
{cpuPercent}%
</div>
</div>
<div>
<div className="text-[10px] text-krona-tertiary">RAM</div>
<div className={`font-mono font-bold ${ramPercent > 80 ? 'text-status-danger-text' : 'text-krona-primary'}`}>
{ramPercent}%
</div>
</div>
<div>
<div className="text-[10px] text-krona-tertiary">Диск</div>
<div className={`font-mono font-bold ${diskPercent > 85 ? 'text-status-danger-text' : 'text-krona-primary'}`}>
{diskPercent}%
</div>
</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 className="text-[11px] text-krona-tertiary flex items-center justify-between">
<span>Резервная копия:</span>
<span className={srv.backup.state === 'failed' ? 'text-status-danger-text font-bold' : 'text-status-success-text'}>
{srv.backup.state === 'ok' ? 'Успешно' : srv.backup.state === 'failed' ? 'Ошибка' : srv.backup.state}
</span>
</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 className="pt-1">
<HeartbeatTimeline status={srv.status} count={24} />
</div>
</div>
<div className="text-[11px] text-krona-tertiary flex items-center justify-between">
<span>Резервная копия:</span>
<span className={srv.backup.state === 'failed' ? 'text-status-danger-text font-bold' : 'text-status-success-text'}>
{srv.backup.state === 'healthy' ? 'Успешно' : srv.backup.state === 'failed' ? 'Ошибка' : srv.backup.state}
</span>
</div>
<div className="pt-1">
<HeartbeatTimeline history={srv.metrics.heartbeatHistory} compact />
</div>
</div>
))}
);
})}
</div>
</div>
</div>

File diff suppressed because one or more lines are too long