Compare commits

..

No commits in common. "50d9059f2138b097d234d639e5ab3cbfc78d4ad9" and "41aa560d382e8d785a5008d3c22d272c3a3663b0" have entirely different histories.

48 changed files with 858 additions and 898 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

@ -0,0 +1 @@
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};

53
dist/assets/AreaChart-Bzq_VEEn.js vendored Normal file

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

1
dist/assets/CmsModule-Bp0oqz_9.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

12
dist/assets/CrmModule-96_lCLTJ.js vendored Normal file

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

1
dist/assets/EntityCatalog-CEDNI60l.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/FieldBuilder-DHYEX2iP.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/FinanceModule-CxEjvopo.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/FormBuilder-DsYKgy0A.js vendored Normal file

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

1
dist/assets/SalesModule-DAKBTXjF.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
dist/assets/TasksModule-CJeRoUgf.js vendored Normal file

File diff suppressed because one or more lines are too long

2
dist/assets/ViewBuilder-C3JVZeVE.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

13
dist/assets/index-CT8iDYYK.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/index-D03nV9JP.css vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/useInventory-d78AKbIO.js vendored Normal file
View file

@ -0,0 +1 @@
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};

1
dist/assets/useOrders-D9bouyEq.js vendored Normal file
View file

@ -0,0 +1 @@
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};

4
dist/index.html vendored
View file

@ -8,11 +8,11 @@
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <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"> <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> <script type="module" crossorigin src="/assets/index-CT8iDYYK.js"></script>
<link rel="modulepreload" crossorigin href="/assets/useToast-CS8pZhce.js"> <link rel="modulepreload" crossorigin href="/assets/useToast-CS8pZhce.js">
<link rel="modulepreload" crossorigin href="/assets/demo-D1nHL60J.js"> <link rel="modulepreload" crossorigin href="/assets/demo-D1nHL60J.js">
<link rel="modulepreload" crossorigin href="/assets/indexedDb-D30TbhA6.js"> <link rel="modulepreload" crossorigin href="/assets/indexedDb-D30TbhA6.js">
<link rel="stylesheet" crossorigin href="/assets/index-D3QQttR0.css"> <link rel="stylesheet" crossorigin href="/assets/index-D03nV9JP.css">
</head> </head>
<body class="bg-krona-app text-krona-primary antialiased selection:bg-krona-gold/20 selection:text-krona-gold-text"> <body class="bg-krona-app text-krona-primary antialiased selection:bg-krona-gold/20 selection:text-krona-gold-text">
<div id="root"></div> <div id="root"></div>

View file

@ -1,285 +1,272 @@
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
// --------------------------------------------------------------------------- test.describe('Krona Control Center - Production UX & Operator Scenarios', () => {
// 1. ACCESS GATE LOGIN & VALIDATION (Isolated describe without pre-auth) test.beforeEach(async ({ page }) => {
// ---------------------------------------------------------------------------
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.goto('/control');
await page.waitForLoadState('domcontentloaded'); await page.waitForLoadState('domcontentloaded');
await expect(page.locator('h1:has-text("Сводная операторская панель")')).toBeVisible({ timeout: 15000 }); await expect(page.locator('text=IndexedDB Synced')).toBeVisible({ timeout: 15000 });
});
const menuBtn = page.locator('[data-testid="mobile-menu-button"]'); test('01. Shell loads with zero horizontal overflow and topbar controls', async ({ page }) => {
await expect(menuBtn).toBeVisible(); await expect(page.locator('text=Krona Control Center').first()).toBeVisible();
await menuBtn.click(); await expect(page.locator('button[aria-label="Сессия оператора"]')).toBeVisible();
// Drawer opens const isOverflowing = await page.evaluate(() => {
const drawerNavOrg = page.locator('[data-testid="drawer-nav-organizations"]'); return document.documentElement.scrollWidth > document.documentElement.clientWidth;
await expect(drawerNavOrg).toBeVisible(); });
await drawerNavOrg.click(); expect(isOverflowing).toBe(false);
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 page.waitForURL('**/control/organizations');
await expect(page.locator('h1:has-text("Организации платформы")')).toBeVisible(); await expect(page.locator('h1:has-text("Организации платформы")')).toBeVisible();
});
test('03. Switch 3 brandbook themes (soft-light, light, dark) and verify data-theme on html', async ({ page }) => { // Search input
await page.goto('/control'); const searchInput = page.locator('input[placeholder*="Поиск"]');
await page.waitForLoadState('domcontentloaded'); await expect(searchInput).toBeVisible();
await expect(page.locator('h1:has-text("Сводная операторская панель")')).toBeVisible({ timeout: 15000 }); await searchInput.fill('Ритейл');
const isMobile = await page.locator('[data-testid="mobile-menu-button"]').isVisible(); // Filtered result should show Retail Group
if (isMobile) { await expect(page.locator('text=Северная Ритейл Группа').first()).toBeVisible();
await page.locator('[data-testid="mobile-menu-button"]').click();
// Soft-light // Click 360 card button
await page.locator('[data-testid="mobile-theme-soft-light"]').click(); const inspectBtn = page.locator('button:has-text("360° Карточка")').first();
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(); await inspectBtn.click();
// Contextual Drawer opens // Contextual Drawer opens
const drawer = page.locator('div[role="complementary"][aria-label="Контекстный инспектор"]'); const drawer = page.locator('div[role="complementary"][aria-label="Контекстный инспектор"]');
await expect(drawer).toBeVisible(); await expect(drawer).toBeVisible();
await expect(drawer.locator('text=Северная Ритейл Группа').first()).toBeVisible();
await expect(drawer.locator('text=Действия оператора платформы')).toBeVisible(); await expect(drawer.locator('text=Действия оператора платформы')).toBeVisible();
// Verify 360 tabs // Verify 360 tabs
await expect(drawer.locator('button:has-text("Обзор")')).toBeVisible(); await expect(drawer.locator('button:has-text("Обзор")')).toBeVisible();
const modulesTab = drawer.locator('button:has-text("Модули")').first(); await expect(drawer.locator('button:has-text("Модули")').first()).toBeVisible();
await expect(modulesTab).toBeVisible();
await modulesTab.click(); // 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();
// Close drawer // Close drawer
await drawer.locator('button[aria-label="Закрыть инспектор"]').click(); await drawer.locator('button[aria-label="Закрыть инспектор"]').click();
await expect(drawer).not.toBeVisible(); await expect(drawer).not.toBeVisible();
}); });
test('06. Filters, tabs and accordions operate correctly', async ({ page }) => { test('03. Scenario 2: Dangerous action requires operator authorization & mandatory reason', async ({ page }) => {
// Organizations search await page.click('a[href="/control/organizations"]');
await page.goto('/control/organizations'); await page.waitForURL('**/control/organizations');
await page.waitForLoadState('domcontentloaded');
const searchInput = page.locator('input[placeholder*="Поиск"]');
await searchInput.fill('Ритейл');
await expect(page.locator('*:has-text("Северная Ритейл Группа"):visible').first()).toBeVisible();
// Billing quick filters // Open inspector for first org
await page.goto('/control/billing'); await page.locator('button:has-text("360° Карточка")').first().click();
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="Контекстный инспектор"]'); const drawer = page.locator('div[role="complementary"][aria-label="Контекстный инспектор"]');
await expect(drawer).toBeVisible(); await expect(drawer).toBeVisible();
const trialBtn = drawer.locator('button:has-text("Выдать Trial")'); // Click trial extension button
await expect(trialBtn).toBeVisible(); const grantTrialBtn = drawer.locator('button:has-text("Выдать Trial")');
await trialBtn.click(); await expect(grantTrialBtn).toBeVisible();
await grantTrialBtn.click();
const modal = page.locator('div[role="dialog"][aria-labelledby="confirm-modal-title"]'); // Confirm modal opens
await expect(modal).toBeVisible(); const confirmDialog = page.locator('div[role="dialog"][aria-labelledby="confirm-modal-title"]');
await expect(modal.locator('text=Требуется авторизация оператора')).toBeVisible(); await expect(confirmDialog).toBeVisible();
await expect(confirmDialog.locator('text=Требуется авторизация оператора')).toBeVisible();
// Submit empty reason -> validation error // Attempt submit without reason
const submitBtn = modal.locator('button:has-text("Подтвердить операцию")'); const submitBtn = confirmDialog.locator('button:has-text("Подтвердить операцию")');
await submitBtn.click(); await submitBtn.click();
await expect(modal.locator('text=Обоснование операции обязательно')).toBeVisible(); await expect(confirmDialog.locator('text=Обоснование операции обязательно')).toBeVisible();
// Fill valid reason (>= 5 chars) // Fill valid reason (>= 5 chars)
await modal.locator('textarea[id="action-reason-input"]').fill('Согласованное продление пилота #PILOT-2026'); const reasonInput = confirmDialog.locator('textarea[id="action-reason-input"]');
await reasonInput.fill('Согласованное продление пилота по заявке #PILOT-1049');
await submitBtn.click(); await submitBtn.click();
await expect(modal).not.toBeVisible();
// Modal closes
await expect(confirmDialog).not.toBeVisible();
}); });
test('08. Switch operator role and observe RBAC enforcement', async ({ page }) => { test('04. Scenario 3: Filter subscriptions expiring in 7 days', async ({ page }) => {
await page.goto('/control'); await page.click('a[href="/control/billing"]');
await page.waitForLoadState('domcontentloaded'); await page.waitForURL('**/control/billing');
const isMobile = await page.locator('[data-testid="mobile-menu-button"]').isVisible(); await expect(page.locator('h1:has-text("Подписки, лицензии и Entitlements")')).toBeVisible();
if (isMobile) {
await page.locator('[data-testid="mobile-menu-button"]').click(); // 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 switcher = page.locator('[data-testid="operator-session-button"]:visible').first(); test('10. Scenario 9: Switch operator role and observe strict RBAC enforcement', async ({ page }) => {
const switcher = page.locator('button[aria-label="Сессия оператора"]');
await switcher.click(); await switcher.click();
const auditorOption = page.locator('[data-testid="role-option-security_auditor"]:visible').first(); // Switch to security_auditor
await expect(auditorOption).toBeVisible(); const auditorItem = page.locator('button[role="menuitem"]:has-text("Аудитор безопасности")');
await auditorOption.click(); await expect(auditorItem).toBeVisible();
await auditorItem.click();
// Header / profile now indicates auditor // Topbar now shows "Аудитор безопасности"
await expect(page.locator('*:has-text("Аудитор"):visible').first()).toBeVisible(); await expect(page.locator('text=Аудитор безопасности').first()).toBeVisible();
// Navigate to modules: triggering rollout should be disabled for auditor // Navigate to Modules page: triggering rollouts should be disabled for auditor
await page.goto('/control/modules'); await page.click('a[href="/control/modules"]');
await page.waitForLoadState('domcontentloaded'); await page.waitForURL('**/control/modules');
const rolloutBtn = page.locator('button:has-text("Запустить пилотный Rollout"), button:has-text("Поставить на паузу")').first(); const rolloutBtn = page.locator('button:has-text("Запустить пилотный Rollout"), button:has-text("Поставить на паузу")').first();
if (await rolloutBtn.isVisible()) { if (await rolloutBtn.isVisible()) {
await expect(rolloutBtn).toBeDisabled(); await expect(rolloutBtn).toBeDisabled();
} }
// Switch back to owner // Switch back to platform_owner
if (await page.locator('[data-testid="mobile-menu-button"]').isVisible()) { await switcher.click();
await page.locator('[data-testid="mobile-menu-button"]').click(); const ownerItem = page.locator('button[role="menuitem"]:has-text("Владелец платформы")');
} await ownerItem.click();
const switcherBack = page.locator('[data-testid="operator-session-button"]:visible').first(); await expect(page.locator('text=Владелец платформы').first()).toBeVisible();
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('09. Control Center UI is completely localized in Russian', async ({ page }) => { test('11. Scenario 10: State survives browser page reload via IndexedDB', async ({ page }) => {
const pagesToCheck = [ await page.click('a[href="/control/audit"]');
{ url: '/control', heading: 'Сводная операторская панель' }, await page.waitForURL('**/control/audit');
{ 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(); await expect(page.locator('h1:has-text("Неизменяемый журнал аудита")')).toBeVisible();
// Verify audit log rows // Wait for audit log rows to load from IndexedDB
await expect(page.locator('tbody tr')).not.toHaveCount(0); await expect(page.locator('tbody tr')).toHaveCount(3);
const initialRowsCount = await page.locator('tbody tr').count(); const initialRowsCount = await page.locator('tbody tr').count();
expect(initialRowsCount).toBe(3);
// Reload page // Reload page
await page.reload(); await page.reload();
await page.waitForLoadState('domcontentloaded'); await page.waitForLoadState('domcontentloaded');
await expect(page.locator('h1:has-text("Неизменяемый журнал аудита")')).toBeVisible(); await expect(page.locator('text=IndexedDB Synced')).toBeVisible({ timeout: 15000 });
// Audit entries must still be present from IndexedDB
await expect(page.locator('tbody tr')).toHaveCount(initialRowsCount); await expect(page.locator('tbody tr')).toHaveCount(initialRowsCount);
}); });
}); });

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -2,7 +2,7 @@ import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Lock, ArrowRight, Sun, Moon, Sparkles } from 'lucide-react'; import { Lock, ArrowRight, Sun, Moon, Sparkles } from 'lucide-react';
import { useKccStore } from '../store/kccStore'; import { useKccStore } from '../store/kccStore';
import { useThemeStore, ThemeMode } from '../../../stores/theme'; import { useThemeStore } from '../../../shared/model/themeStore';
import { RU_THEMES, RU_UI } from '../locales/ru'; import { RU_THEMES, RU_UI } from '../locales/ru';
export const ControlAccessGatePage: React.FC = () => { export const ControlAccessGatePage: React.FC = () => {
@ -11,10 +11,10 @@ export const ControlAccessGatePage: React.FC = () => {
const [pin, setPin] = useState(''); const [pin, setPin] = useState('');
const [error, setError] = useState(false); const [error, setError] = useState(false);
const themeMode = useThemeStore((state) => state.themeMode); const theme = useThemeStore((state) => state.theme);
const setTheme = useThemeStore((state) => state.setTheme); const setTheme = useThemeStore((state) => state.setTheme);
const themes: Array<{ id: ThemeMode; label: string; icon: React.ReactNode }> = [ const themes: Array<{ id: 'soft-light' | 'light' | 'dark'; label: string; icon: React.ReactNode }> = [
{ id: 'soft-light', label: RU_THEMES['soft-light'], icon: <Sparkles className="w-3.5 h-3.5" /> }, { 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: '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" /> }, { 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)} onClick={() => setTheme(t.id)}
title={t.label} 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 ${ 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 ${
themeMode === t.id theme === t.id
? 'bg-amber-500 text-zinc-950 font-bold shadow-xs' ? 'bg-amber-500 text-zinc-950 font-bold shadow-xs'
: 'text-krona-secondary hover:text-krona-primary hover:bg-krona-workspace' : 'text-krona-secondary hover:text-krona-primary hover:bg-krona-workspace'
}`} }`}
@ -79,7 +79,6 @@ export const ControlAccessGatePage: React.FC = () => {
<input <input
type="password" type="password"
value={pin} value={pin}
data-testid="pin-input"
onChange={(e) => { onChange={(e) => {
setPin(e.target.value); setPin(e.target.value);
if (error) setError(false); if (error) setError(false);
@ -88,7 +87,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]" 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 && ( {error && (
<p className="text-xs text-rose-500 font-medium mt-1.5" data-testid="pin-error"> <p className="text-xs text-rose-500 font-medium mt-1.5">
{RU_UI.gateError} {RU_UI.gateError}
</p> </p>
)} )}
@ -96,7 +95,6 @@ export const ControlAccessGatePage: React.FC = () => {
<button <button
type="submit" 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" 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> <span>{RU_UI.gateSubmit}</span>
@ -107,7 +105,6 @@ export const ControlAccessGatePage: React.FC = () => {
<div className="pt-2 border-t border-krona-border"> <div className="pt-2 border-t border-krona-border">
<button <button
type="button" type="button"
data-testid="pin-demo-login"
onClick={handleDemoBypass} 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" 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,13 +26,21 @@ export const IncidentsPage: React.FC = () => {
inc.summary.toLowerCase().includes(search.toLowerCase()) || inc.summary.toLowerCase().includes(search.toLowerCase()) ||
inc.description.toLowerCase().includes(search.toLowerCase()); inc.description.toLowerCase().includes(search.toLowerCase());
const matchStatus = statusFilter === 'all' || inc.status === statusFilter; 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';
}
return matchSearch && matchStatus; return matchSearch && matchStatus;
}); });
const unresolvedCount = incidents.filter((i) => i.status !== 'resolved').length; const unresolvedCount = incidents.filter((i) => i.status !== 'resolved').length;
const criticalCount = incidents.filter( const criticalCount = incidents.filter(
(i) => i.status !== 'resolved' && (i.severity === 'critical' || i.severity === 'high') (i) => i.status !== 'resolved' && (i.severity === 'P0_CRITICAL' || i.severity === 'P1_MAJOR')
).length; ).length;
return ( return (
@ -103,8 +111,8 @@ export const IncidentsPage: React.FC = () => {
{/* Incidents List - Mobile-First Cards */} {/* Incidents List - Mobile-First Cards */}
<div className="space-y-3"> <div className="space-y-3">
{filtered.map((inc) => { {filtered.map((inc) => {
const isCritical = inc.severity === 'critical'; const isCritical = inc.severity === 'P0_CRITICAL';
const isMajor = inc.severity === 'high'; const isMajor = inc.severity === 'P1_MAJOR';
const severityInfo = RU_INCIDENT_SEVERITY[inc.severity] || { const severityInfo = RU_INCIDENT_SEVERITY[inc.severity] || {
label: inc.severity, label: inc.severity,
short: inc.severity, short: inc.severity,

View file

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

View file

@ -5,12 +5,10 @@ import {
Monitor, Monitor,
Globe, Globe,
AlertTriangle, AlertTriangle,
Radio,
} from 'lucide-react'; } from 'lucide-react';
import { useKccStore } from '../store/kccStore'; import { useKccStore } from '../store/kccStore';
import { StatusBadge } from '../components/StatusBadge'; import { StatusBadge } from '../components/StatusBadge';
import { InstallationKind, InstallationStatus } from '../contracts'; import { InstallationKind, InstallationStatus } from '../contracts';
import { RU_PLATFORMS, RU_INSTALLATION_STATUS } from '../locales/ru';
export const InstallationsPage: React.FC = () => { export const InstallationsPage: React.FC = () => {
const installations = useKccStore((state) => state.installations); const installations = useKccStore((state) => state.installations);
@ -20,13 +18,11 @@ export const InstallationsPage: React.FC = () => {
const [statusFilter, setStatusFilter] = useState<'all' | InstallationStatus>('all'); const [statusFilter, setStatusFilter] = useState<'all' | InstallationStatus>('all');
const filtered = installations.filter((item) => { const filtered = installations.filter((item) => {
const platformRu = RU_PLATFORMS[item.kind] || '';
const matchSearch = const matchSearch =
!search || !search ||
item.deviceName.toLowerCase().includes(search.toLowerCase()) || item.deviceName.toLowerCase().includes(search.toLowerCase()) ||
item.tenantName.toLowerCase().includes(search.toLowerCase()) || item.tenantName.toLowerCase().includes(search.toLowerCase()) ||
item.appVersion.includes(search) || item.appVersion.includes(search);
platformRu.toLowerCase().includes(search.toLowerCase());
const matchPlatform = platformFilter === 'all' || item.kind === platformFilter; const matchPlatform = platformFilter === 'all' || item.kind === platformFilter;
const matchStatus = statusFilter === 'all' || item.status === statusFilter; const matchStatus = statusFilter === 'all' || item.status === statusFilter;
return matchSearch && matchPlatform && matchStatus; return matchSearch && matchPlatform && matchStatus;
@ -39,50 +35,46 @@ export const InstallationsPage: React.FC = () => {
const getPlatformIcon = (kind: InstallationKind) => { const getPlatformIcon = (kind: InstallationKind) => {
switch (kind) { switch (kind) {
case 'android': case 'android':
return <Smartphone className="w-4 h-4 text-emerald-500" />; return <Smartphone className="w-4 h-4 text-emerald-400" />;
case 'desktop': case 'desktop':
return <Monitor className="w-4 h-4 text-sky-500" />; return <Monitor className="w-4 h-4 text-sky-400" />;
case 'web': case 'web':
return <Globe className="w-4 h-4 text-amber-500" />; return <Globe className="w-4 h-4 text-amber-400" />;
} }
}; };
return ( return (
<div className="space-y-4 md:space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 border-b border-krona-border pb-4"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 border-b border-zinc-800 pb-4">
<div> <div>
<h1 className="text-lg md:text-xl font-bold text-krona-primary flex items-center gap-2"> <h1 className="text-xl font-bold text-zinc-100 flex items-center gap-2">
<Smartphone className="w-5 h-5 text-amber-500 shrink-0" /> <Smartphone className="w-5 h-5 text-amber-400" />
<span>Установки и клиентские приложения</span> <span>Установки и клиентские приложения</span>
</h1> </h1>
<p className="text-xs text-krona-secondary mt-0.5"> <p className="text-xs text-zinc-400 mt-0.5">
Мониторинг клиентских устройств Web, Desktop и Android (ТСД терминалы сбора данных и кассы) Мониторинг клиентских устройств Web, Desktop и Android (ТСД терминалы сбора данных и кассы)
</p> </p>
</div> </div>
<div className="flex items-center gap-2 text-xs text-krona-secondary"> <div className="text-xs text-zinc-400">
<span className="px-2.5 py-1 rounded-md bg-krona-workspace border border-krona-border"> Всего установок: <span className="font-bold text-zinc-100">{installations.length}</span> (найдено:{' '}
Всего: <strong className="text-krona-primary">{installations.length}</strong> <span className="font-bold text-amber-400">{filtered.length}</span>)
</span>
<span className="px-2.5 py-1 rounded-md bg-amber-500/10 border border-amber-500/30 text-amber-500 font-semibold">
Найдено: <strong>{filtered.length}</strong>
</span>
</div> </div>
</div> </div>
{/* Outdated Android Callout */} {/* Outdated Android Callout (Сценарий 4) */}
{outdatedAndroidList.length > 0 && ( {outdatedAndroidList.length > 0 && (
<div className="rounded-xl border border-amber-500/40 bg-amber-500/10 p-4 flex flex-col md:flex-row items-start md:items-center justify-between gap-3 shadow-xs"> <div className="rounded-xl border border-amber-500/40 bg-amber-950/20 p-4 flex flex-col md:flex-row items-start md:items-center justify-between gap-3 shadow-lg">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="p-2 rounded-lg bg-amber-500/20 text-amber-500 border border-amber-500/30 shrink-0 mt-0.5"> <div className="p-2 rounded-lg bg-amber-500/20 text-amber-400 border border-amber-500/30 shrink-0 mt-0.5">
<AlertTriangle className="w-5 h-5" /> <AlertTriangle className="w-5 h-5" />
</div> </div>
<div> <div>
<div className="text-sm font-bold text-amber-600 dark:text-amber-300"> <div className="text-sm font-bold text-amber-300">
Обнаружены устаревшие Android-установки ({outdatedAndroidList.length} устройств) Обнаружены устаревшие Android-установки ({outdatedAndroidList.length} устройств)
</div> </div>
<div className="text-xs text-krona-secondary mt-0.5 leading-relaxed"> <div className="text-xs text-amber-300/80 mt-0.5">
Терминалы сбора данных Zebra, Honeywell и кассы Эвотор работают на устаревших версиях клиента (v1.0.4 – v1.2.0). Терминалы сбора данных Zebra, Honeywell и кассы Эвотор работают на устаревших версиях клиента (v1.0.4 – v1.2.0).
Рекомендуется запланировать push-обновление APK пакета через MDM канал. Рекомендуется запланировать push-обновление APK пакета через MDM канал.
</div> </div>
@ -95,7 +87,7 @@ export const InstallationsPage: React.FC = () => {
setPlatformFilter('android'); setPlatformFilter('android');
setStatusFilter('outdated'); setStatusFilter('outdated');
}} }}
className="shrink-0 min-h-[44px] px-4 py-2 rounded-lg bg-amber-500 hover:bg-amber-400 text-zinc-950 font-bold text-xs transition-colors shadow-xs" className="shrink-0 px-3 py-1.5 rounded-lg bg-amber-500 hover:bg-amber-400 text-zinc-950 font-bold text-xs transition-colors"
> >
Показать только устаревшие Android Показать только устаревшие Android
</button> </button>
@ -106,20 +98,20 @@ export const InstallationsPage: React.FC = () => {
<div className="flex flex-col md:flex-row gap-3 items-stretch md:items-center justify-between"> <div className="flex flex-col md:flex-row gap-3 items-stretch md:items-center justify-between">
{/* Search */} {/* Search */}
<div className="relative flex-1 max-w-md"> <div className="relative flex-1 max-w-md">
<Search className="w-4 h-4 text-krona-secondary absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none" /> <Search className="w-4 h-4 text-zinc-400 absolute left-3 top-1/2 -translate-y-1/2" />
<input <input
type="text" type="text"
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
placeholder="Поиск по устройству, организации или версии..." placeholder="Поиск по устройству, организации или версии..."
className="w-full bg-krona-workspace border border-krona-border rounded-lg pl-9 pr-3 py-2.5 text-xs text-krona-primary placeholder:text-krona-secondary/60 focus:outline-hidden focus:border-amber-500 transition-colors min-h-[44px]" className="w-full bg-[#0b1d16] border border-zinc-800 rounded-lg pl-9 pr-3 py-2 text-xs text-zinc-100 placeholder-zinc-500 focus:outline-hidden focus:border-amber-500 transition-colors"
/> />
</div> </div>
{/* Platform & Status Filters */} {/* Platform & Status Filters */}
<div className="flex flex-wrap items-center gap-2 text-xs"> <div className="flex flex-wrap items-center gap-2 text-xs">
<div className="flex items-center gap-1 overflow-x-auto pb-1"> <div className="flex items-center gap-1">
<span className="text-krona-secondary text-[11px] mr-1 hidden sm:inline">Платформа:</span> <span className="text-zinc-500 text-[11px] mr-1">Платформа:</span>
{[ {[
{ id: 'all', label: 'Все' }, { id: 'all', label: 'Все' },
{ id: 'android', label: 'Android' }, { id: 'android', label: 'Android' },
@ -129,10 +121,10 @@ export const InstallationsPage: React.FC = () => {
<button <button
key={tab.id} key={tab.id}
onClick={() => setPlatformFilter(tab.id as typeof platformFilter)} onClick={() => setPlatformFilter(tab.id as typeof platformFilter)}
className={`min-h-[44px] px-3 py-2 rounded-lg font-medium transition-colors ${ className={`px-2.5 py-1.5 rounded-lg font-medium transition-colors ${
platformFilter === tab.id platformFilter === tab.id
? 'bg-amber-500/15 text-amber-600 dark:text-amber-300 border border-amber-500/40 font-bold shadow-xs' ? 'bg-amber-500/20 text-amber-300 border border-amber-500/40 font-bold'
: 'bg-krona-workspace text-krona-secondary border border-krona-border hover:text-krona-primary' : 'bg-zinc-900 text-zinc-400 border border-zinc-800 hover:text-zinc-200'
}`} }`}
> >
{tab.label} {tab.label}
@ -140,8 +132,8 @@ export const InstallationsPage: React.FC = () => {
))} ))}
</div> </div>
<div className="flex items-center gap-1 pl-2 border-l border-krona-border overflow-x-auto pb-1"> <div className="flex items-center gap-1 pl-2 border-l border-zinc-800">
<span className="text-krona-secondary text-[11px] mr-1 hidden sm:inline">Статус:</span> <span className="text-zinc-500 text-[11px] mr-1">Статус:</span>
{[ {[
{ id: 'all', label: 'Все' }, { id: 'all', label: 'Все' },
{ id: 'outdated', label: 'Устарели' }, { id: 'outdated', label: 'Устарели' },
@ -151,10 +143,10 @@ export const InstallationsPage: React.FC = () => {
<button <button
key={tab.id} key={tab.id}
onClick={() => setStatusFilter(tab.id as typeof statusFilter)} onClick={() => setStatusFilter(tab.id as typeof statusFilter)}
className={`min-h-[44px] px-3 py-2 rounded-lg font-medium transition-colors ${ className={`px-2.5 py-1.5 rounded-lg font-medium transition-colors ${
statusFilter === tab.id statusFilter === tab.id
? 'bg-amber-500/15 text-amber-600 dark:text-amber-300 border border-amber-500/40 font-bold shadow-xs' ? 'bg-amber-500/20 text-amber-300 border border-amber-500/40 font-bold'
: 'bg-krona-workspace text-krona-secondary border border-krona-border hover:text-krona-primary' : 'bg-zinc-900 text-zinc-400 border border-zinc-800 hover:text-zinc-200'
}`} }`}
> >
{tab.label} {tab.label}
@ -164,70 +156,15 @@ export const InstallationsPage: React.FC = () => {
</div> </div>
</div> </div>
{/* Mobile Cards View (block lg:hidden) */} {/* Installations Table */}
<div className="block lg:hidden space-y-3"> <div className="rounded-xl border border-zinc-800 bg-[#091a13] overflow-hidden shadow-xl">
{filtered.map((item) => {
const platformLabel = RU_PLATFORMS[item.kind] || item.kind;
const statusLabel = RU_INSTALLATION_STATUS[item.status] || item.status;
return (
<div
key={item.installationId}
className="p-4 rounded-xl border border-krona-border bg-krona-surface space-y-3 shadow-xs"
>
<div className="flex justify-between items-start gap-2 border-b border-krona-border pb-2.5">
<div>
<div className="flex items-center gap-2">
{getPlatformIcon(item.kind)}
<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} • {platformLabel}
</div>
</div>
<StatusBadge status={item.status} size="sm" customLabel={statusLabel} />
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="p-2 rounded-lg bg-krona-workspace border border-krona-border">
<span className="text-[10px] text-krona-secondary block">Версия клиента</span>
<span className="font-mono font-bold text-amber-500">v{item.appVersion}</span>
</div>
<div className="p-2 rounded-lg bg-krona-workspace border border-krona-border">
<span className="text-[10px] text-krona-secondary block">ОС устройства</span>
<span className="font-mono text-krona-primary truncate block">{item.osVersion}</span>
</div>
</div>
<div className="pt-2 border-t border-krona-border flex items-center justify-between text-[11px] text-krona-secondary">
<span className="flex items-center gap-1 font-mono">
<Radio className="w-3 h-3 text-emerald-500" />
{item.ipAddressMasked}
</span>
<span>
Активность: {new Date(item.lastSeenAt).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
</div>
);
})}
{filtered.length === 0 && (
<div className="p-8 rounded-xl border border-krona-border bg-krona-surface text-center text-xs text-krona-secondary">
Установки не найдены по выбранным критериям
</div>
)}
</div>
{/* Desktop Installations Table (hidden lg:block) */}
<div className="hidden lg:block rounded-xl border border-krona-border bg-krona-surface overflow-hidden shadow-xs">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-left text-xs border-collapse"> <table className="w-full text-left text-xs border-collapse">
<thead> <thead>
<tr className="border-b border-krona-border bg-krona-workspace text-krona-secondary font-semibold uppercase tracking-wider text-[11px]"> <tr className="border-b border-zinc-800 bg-zinc-950/70 text-zinc-400 font-semibold uppercase tracking-wider text-[11px]">
<th className="py-3 px-4">Платформа</th> <th className="py-3 px-4">Платформа</th>
<th className="py-3 px-3">Устройство / Модель</th> <th className="py-3 px-3">Устройство / Модель</th>
<th className="py-3 px-3">Организация</th> <th className="py-3 px-3">Организация (Tenant)</th>
<th className="py-3 px-3 font-mono">Версия клиента</th> <th className="py-3 px-3 font-mono">Версия клиента</th>
<th className="py-3 px-3">ОС устройства</th> <th className="py-3 px-3">ОС устройства</th>
<th className="py-3 px-3">Статус поддержки</th> <th className="py-3 px-3">Статус поддержки</th>
@ -235,27 +172,23 @@ export const InstallationsPage: React.FC = () => {
<th className="py-3 px-4">Сегмент IP</th> <th className="py-3 px-4">Сегмент IP</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-krona-border/60"> <tbody className="divide-y divide-zinc-800/60">
{filtered.map((item) => ( {filtered.map((item) => (
<tr key={item.installationId} className="hover:bg-krona-workspace/50 transition-colors"> <tr key={item.installationId} className="hover:bg-zinc-800/40 transition-colors">
<td className="py-3 px-4"> <td className="py-3 px-4">
<div className="flex items-center gap-2 font-medium text-krona-primary"> <div className="flex items-center gap-2 font-medium capitalize text-zinc-200">
{getPlatformIcon(item.kind)} {getPlatformIcon(item.kind)}
<span>{RU_PLATFORMS[item.kind] || item.kind}</span> <span>{item.kind}</span>
</div> </div>
</td> </td>
<td className="py-3 px-3 font-semibold text-krona-primary">{item.deviceName}</td> <td className="py-3 px-3 font-semibold text-zinc-100">{item.deviceName}</td>
<td className="py-3 px-3 text-krona-secondary font-medium">{item.tenantName}</td> <td className="py-3 px-3 text-zinc-300 font-medium">{item.tenantName}</td>
<td className="py-3 px-3 font-mono font-bold text-amber-500">v{item.appVersion}</td> <td className="py-3 px-3 font-mono font-bold text-amber-300">v{item.appVersion}</td>
<td className="py-3 px-3 text-krona-secondary font-mono text-[11px]">{item.osVersion}</td> <td className="py-3 px-3 text-zinc-400 font-mono text-[11px]">{item.osVersion}</td>
<td className="py-3 px-3"> <td className="py-3 px-3">
<StatusBadge <StatusBadge status={item.status} size="sm" />
status={item.status}
size="sm"
customLabel={RU_INSTALLATION_STATUS[item.status] || item.status}
/>
</td> </td>
<td className="py-3 px-3 text-krona-secondary text-[11px]"> <td className="py-3 px-3 text-zinc-400 text-[11px]">
{new Date(item.lastSeenAt).toLocaleString('ru-RU', { {new Date(item.lastSeenAt).toLocaleString('ru-RU', {
day: '2-digit', day: '2-digit',
month: '2-digit', month: '2-digit',
@ -263,13 +196,13 @@ export const InstallationsPage: React.FC = () => {
minute: '2-digit', minute: '2-digit',
})} })}
</td> </td>
<td className="py-3 px-4 font-mono text-krona-secondary text-[11px]">{item.ipAddressMasked}</td> <td className="py-3 px-4 font-mono text-zinc-500 text-[11px]">{item.ipAddressMasked}</td>
</tr> </tr>
))} ))}
{filtered.length === 0 && ( {filtered.length === 0 && (
<tr> <tr>
<td colSpan={8} className="py-8 text-center text-krona-secondary"> <td colSpan={8} className="py-8 text-center text-zinc-500">
Установки не найдены по выбранным критериям фильтрации Установки не найдены по выбранным критериям фильтрации
</td> </td>
</tr> </tr>

View file

@ -5,12 +5,10 @@ import {
Pause, Pause,
RotateCcw, RotateCcw,
Sliders, Sliders,
Sparkles,
} from 'lucide-react'; } from 'lucide-react';
import { useKccStore, useOperatorRole } from '../store/kccStore'; import { useKccStore, useOperatorRole } from '../store/kccStore';
import { StatusBadge } from '../components/StatusBadge'; import { StatusBadge } from '../components/StatusBadge';
import { RolloutStage } from '../contracts'; import { RolloutStage } from '../contracts';
import { RU_ROLLOUT_STAGE, RU_MODULE_CATEGORY } from '../locales/ru';
export const ModulesPage: React.FC = () => { export const ModulesPage: React.FC = () => {
const modules = useKccStore((state) => state.modules); const modules = useKccStore((state) => state.modules);
@ -33,11 +31,10 @@ export const ModulesPage: React.FC = () => {
const handleStageChange = (stage: RolloutStage, percentage: number) => { const handleStageChange = (stage: RolloutStage, percentage: number) => {
if (!activeRollout) return; if (!activeRollout) return;
const stageLabel = RU_ROLLOUT_STAGE[stage] || stage;
openConfirmModal({ openConfirmModal({
title: `Смена фазы раскатки: ${activeRollout.moduleName}`, title: `Смена фазы раскатки: ${activeRollout.moduleName}`,
actionName: `rollout.stage_${stage}`, actionName: `rollout.stage_${stage}`,
impactSummary: `Конвейер раскатки будет переведен в состояние «${stageLabel}» (${percentage}% трафика).`, impactSummary: `Конвейер раскатки будет переведен в состояние «${stage}» (${percentage}% трафика).`,
targetType: 'rollout', targetType: 'rollout',
targetId: activeRollout.rolloutId, targetId: activeRollout.rolloutId,
targetName: activeRollout.moduleName, targetName: activeRollout.moduleName,
@ -71,39 +68,34 @@ export const ModulesPage: React.FC = () => {
}; };
return ( return (
<div className="space-y-4 md:space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 border-b border-krona-border pb-4"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 border-b border-zinc-800 pb-4">
<div> <div>
<h1 className="text-lg md:text-xl font-bold text-krona-primary flex items-center gap-2"> <h1 className="text-xl font-bold text-zinc-100 flex items-center gap-2">
<Package className="w-5 h-5 text-amber-500 shrink-0" /> <Package className="w-5 h-5 text-amber-400" />
<span>Каталог модулей и Rollout конвейер</span> <span>Каталог модулей и Rollout конвейер</span>
</h1> </h1>
<p className="text-xs text-krona-secondary mt-0.5"> <p className="text-xs text-zinc-400 mt-0.5">
Управление версиями модулей, совместимостью с ядром, пилотными группами и канареечным развертыванием Управление манифестами, compatibility core/schema, пилотными организациями и фазовыми релизами
</p> </p>
</div> </div>
<div className="flex items-center gap-2 text-xs text-krona-secondary"> <div className="text-xs text-zinc-400">
<span className="px-2.5 py-1 rounded-md bg-krona-workspace border border-krona-border"> Всего модулей: <span className="font-bold text-zinc-100">{modules.length}</span> • Активных раскаток:{' '}
Всего модулей: <strong className="text-krona-primary">{modules.length}</strong> <span className="font-bold text-amber-400">{rollouts.length}</span>
</span>
<span className="px-2.5 py-1 rounded-md bg-amber-500/10 border border-amber-500/30 text-amber-500 font-semibold">
Активных раскаток: <strong>{rollouts.length}</strong>
</span>
</div> </div>
</div> </div>
{/* Grid: Module Selector + Module Detail & Rollout Manager */} {/* Grid: Module Selector + Module Detail & Rollout Manager */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 md:gap-6"> <div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
{/* Left Column: Modules Catalog List */} {/* Left Column: Modules Catalog List */}
<div className="rounded-xl border border-krona-border bg-krona-surface p-3 md:p-4 space-y-3 shadow-xs"> <div className="rounded-xl border border-zinc-800 bg-[#091a13] p-4 space-y-3">
<div className="text-xs font-semibold text-krona-secondary uppercase tracking-wider px-1"> <div className="text-xs font-semibold text-zinc-400 uppercase tracking-wider mb-1">
Каталог системных модулей Каталог системных модулей
</div> </div>
{/* Module list with min 44px items */} <div className="space-y-2">
<div className="space-y-2 max-h-[480px] lg:max-h-none overflow-y-auto">
{modules.map((m) => { {modules.map((m) => {
const isSelected = m.moduleId === selectedModuleId; const isSelected = m.moduleId === selectedModuleId;
const hasRollout = rollouts.some((r) => r.moduleId === m.moduleId); const hasRollout = rollouts.some((r) => r.moduleId === m.moduleId);
@ -112,24 +104,24 @@ export const ModulesPage: React.FC = () => {
<div <div
key={m.moduleId} key={m.moduleId}
onClick={() => setSelectedModuleId(m.moduleId)} onClick={() => setSelectedModuleId(m.moduleId)}
className={`min-h-[44px] p-3 rounded-lg border text-xs cursor-pointer transition-colors space-y-1.5 ${ className={`p-3 rounded-lg border text-xs cursor-pointer transition-colors space-y-1.5 ${
isSelected isSelected
? 'bg-amber-500/15 border-amber-500/40 text-krona-primary shadow-xs' ? 'bg-amber-500/15 border-amber-500/40 text-zinc-100'
: 'bg-krona-workspace border-krona-border text-krona-secondary hover:bg-krona-surface hover:text-krona-primary' : 'bg-zinc-950/60 border-zinc-800 text-zinc-400 hover:bg-zinc-800/50 hover:text-zinc-200'
}`} }`}
> >
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="font-bold text-krona-primary">{m.name}</span> <span className="font-bold text-zinc-100">{m.name}</span>
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded-md bg-krona-surface border border-krona-border text-amber-500 font-semibold"> <span className="text-[10px] font-mono px-1.5 py-0.5 rounded-xs bg-zinc-900 border border-zinc-700 text-amber-300">
v{m.latestVersion} v{m.latestVersion}
</span> </span>
</div> </div>
<p className="text-[11px] text-krona-secondary line-clamp-1">{m.description}</p> <p className="text-[11px] text-zinc-400 line-clamp-1">{m.description}</p>
<div className="flex justify-between items-center text-[10px] text-krona-secondary/80 pt-1"> <div className="flex justify-between items-center text-[10px] text-zinc-500 pt-1">
<span>Организаций: {m.activeTenantsCount} • Установок: {m.instancesCount}</span> <span>Тенантов: {m.activeTenantsCount} • Инстансов: {m.instancesCount}</span>
{hasRollout && ( {hasRollout && (
<span className="px-1.5 py-0.5 rounded-md bg-amber-500/15 text-amber-600 dark:text-amber-300 border border-amber-500/30 font-bold uppercase text-[9px]"> <span className="px-1.5 py-0.5 rounded-xs bg-indigo-950 text-indigo-300 border border-indigo-700 font-bold uppercase">
Раскатка Rollout
</span> </span>
)} )}
</div> </div>
@ -140,115 +132,97 @@ export const ModulesPage: React.FC = () => {
</div> </div>
{/* Right 2 Columns: Module Details & Rollout Controller */} {/* Right 2 Columns: Module Details & Rollout Controller */}
<div className="lg:col-span-2 space-y-4 md:space-y-6"> <div className="xl:col-span-2 space-y-6">
{selectedModule && ( {selectedModule && (
<> <>
{/* Module Header Card */} {/* Module Header Card */}
<div className="rounded-xl border border-krona-border bg-krona-surface p-4 md:p-5 space-y-4 shadow-xs"> <div className="rounded-xl border border-zinc-800 bg-[#0a1c15] p-5 space-y-4">
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-start gap-3"> <div className="flex justify-between items-start">
<div> <div>
<div className="flex flex-wrap items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<span className="font-mono text-xs text-amber-500 font-bold">{selectedModule.code}</span> <span className="font-mono text-xs text-amber-400 font-bold">{selectedModule.code}</span>
<span className="text-krona-secondary">•</span> <span className="text-zinc-600">•</span>
<span className="text-xs text-krona-secondary"> <span className="text-xs text-zinc-400">Категория: {selectedModule.category}</span>
Категория: {RU_MODULE_CATEGORY[selectedModule.category] || selectedModule.category}
</span>
</div> </div>
<h2 className="text-base md:text-lg font-bold text-krona-primary">{selectedModule.name}</h2> <h2 className="text-lg font-bold text-zinc-100">{selectedModule.name}</h2>
<p className="text-xs text-krona-secondary mt-1">{selectedModule.description}</p> <p className="text-xs text-zinc-300 mt-1">{selectedModule.description}</p>
</div> </div>
<div className="text-left sm:text-right shrink-0"> <div className="text-right">
<span className="text-xs text-krona-secondary">Текущая версия:</span> <span className="text-xs text-zinc-400">Текущая стабильная версия:</span>
<div className="font-mono font-bold text-lg text-emerald-600 dark:text-emerald-400"> <div className="font-mono font-bold text-lg text-emerald-400">v{selectedModule.latestVersion}</div>
v{selectedModule.latestVersion}
</div>
</div> </div>
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2 text-xs pt-3 border-t border-krona-border"> <div className="grid grid-cols-3 gap-3 text-xs pt-3 border-t border-zinc-800">
<div className="p-2.5 rounded-lg bg-krona-workspace border border-krona-border"> <div className="p-2.5 rounded-lg bg-zinc-950/70 border border-zinc-800">
<div className="text-krona-secondary mb-0.5">Канал релиза:</div> <div className="text-zinc-400 mb-0.5">Канал релиза:</div>
<div className="font-semibold text-krona-primary uppercase">{selectedModule.rolloutChannel}</div> <div className="font-semibold text-zinc-200 uppercase">{selectedModule.rolloutChannel}</div>
</div> </div>
<div className="p-2.5 rounded-lg bg-krona-workspace border border-krona-border"> <div className="p-2.5 rounded-lg bg-zinc-950/70 border border-zinc-800">
<div className="text-krona-secondary mb-0.5">Манифест:</div> <div className="text-zinc-400 mb-0.5">Манифест:</div>
<div className="font-semibold text-krona-primary font-mono">v{selectedModule.manifestVersion}</div> <div className="font-semibold text-zinc-200 font-mono">v{selectedModule.manifestVersion}</div>
</div> </div>
<div className="p-2.5 rounded-lg bg-krona-workspace border border-krona-border"> <div className="p-2.5 rounded-lg bg-zinc-950/70 border border-zinc-800">
<div className="text-krona-secondary mb-0.5">Зависимости:</div> <div className="text-zinc-400 mb-0.5">Зависимости:</div>
<div className="font-semibold text-krona-primary truncate"> <div className="font-semibold text-zinc-200">
{selectedModule.dependencies.length > 0 ? selectedModule.dependencies.join(', ') : 'Базовый'} {selectedModule.dependencies.length > 0 ? selectedModule.dependencies.join(', ') : 'Нет (Базовый)'}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{/* Rollout Control Panel */} {/* Rollout Control Panel (Сценарий 8) */}
<div className="rounded-xl border border-krona-border bg-krona-surface p-4 md:p-5 space-y-4 shadow-xs"> <div className="rounded-xl border border-zinc-800 bg-[#091a13] p-5 space-y-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2"> <div className="flex justify-between items-center">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Sliders className="w-4 h-4 text-amber-500" /> <Sliders className="w-4 h-4 text-amber-400" />
<h3 className="text-sm font-bold text-krona-primary"> <h3 className="text-sm font-bold text-zinc-200">Управление раскаткой (Rollout Pipeline)</h3>
Управление конвейером раскатки (Rollout)
</h3>
</div> </div>
{activeRollout && ( {activeRollout && <StatusBadge status={activeRollout.stage} size="sm" />}
<StatusBadge
status={activeRollout.stage}
size="sm"
customLabel={RU_ROLLOUT_STAGE[activeRollout.stage] || activeRollout.stage}
/>
)}
</div> </div>
{activeRollout ? ( {activeRollout ? (
<div className="space-y-4 text-xs"> <div className="space-y-4 text-xs">
{/* Progress visualizer */} {/* Progress visualizer */}
<div className="p-4 rounded-xl bg-krona-workspace border border-krona-border space-y-3"> <div className="p-4 rounded-xl bg-zinc-950 border border-zinc-800 space-y-3">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="font-semibold text-krona-primary"> <span className="font-semibold text-zinc-200">
Целевая версия:{' '} Целевая версия: <span className="font-mono text-amber-300">v{activeRollout.targetVersion}</span>
<span className="font-mono text-amber-500 font-bold">v{activeRollout.targetVersion}</span>
</span> </span>
<span className="font-bold text-amber-500 font-mono text-sm">{activeRollout.percentage}%</span> <span className="font-bold text-amber-400 font-mono text-sm">{activeRollout.percentage}%</span>
</div> </div>
<div className="w-full bg-krona-border h-2 rounded-full overflow-hidden"> <div className="w-full bg-zinc-800 h-2 rounded-full overflow-hidden">
<div <div
className="h-full bg-amber-500 transition-all duration-300 rounded-full" className="h-full bg-amber-400 transition-all duration-300"
style={{ width: `${activeRollout.percentage}%` }} style={{ width: `${activeRollout.percentage}%` }}
/> />
</div> </div>
<div className="flex flex-wrap justify-between gap-2 text-[11px] text-krona-secondary"> <div className="flex justify-between text-[11px] text-zinc-400">
<span> <span>Стадия: <strong className="text-zinc-200 uppercase">{activeRollout.stage}</strong></span>
Стадия:{' '}
<strong className="text-krona-primary">
{RU_ROLLOUT_STAGE[activeRollout.stage] || activeRollout.stage}
</strong>
</span>
<span>Пилотных организаций: {activeRollout.pilotTenantIds.length}</span> <span>Пилотных организаций: {activeRollout.pilotTenantIds.length}</span>
<span>Старт: {new Date(activeRollout.startedAt).toLocaleDateString('ru-RU')}</span> <span>Старт: {new Date(activeRollout.startedAt).toLocaleDateString('ru-RU')}</span>
</div> </div>
</div> </div>
{/* Rollout Action Buttons with min 44px touch targets */} {/* Rollout Action Buttons */}
<div className="flex flex-wrap gap-2 pt-2"> <div className="flex flex-wrap gap-2 pt-2">
<button <button
type="button" type="button"
disabled={!permissions.canTriggerRollouts || activeRollout.stage === 'pilot'} disabled={!permissions.canTriggerRollouts || activeRollout.stage === 'pilot'}
onClick={() => handleStageChange('pilot', 15)} onClick={() => handleStageChange('pilot', 15)}
className="min-h-[44px] px-3.5 py-2 rounded-lg bg-krona-workspace border border-krona-border text-krona-primary hover:bg-krona-surface text-xs font-medium disabled:opacity-40 transition-colors shadow-xs" className="px-3 py-1.5 rounded-lg bg-zinc-900 border border-zinc-700 text-zinc-300 hover:bg-zinc-800 text-xs font-medium disabled:opacity-40 transition-colors"
> >
Фаза 1: Пилот (15%) Фаза 1: Pilot (15%)
</button> </button>
<button <button
type="button" type="button"
disabled={!permissions.canTriggerRollouts || activeRollout.stage === 'percentage'} disabled={!permissions.canTriggerRollouts || activeRollout.stage === 'percentage'}
onClick={() => handleStageChange('percentage', 50)} onClick={() => handleStageChange('percentage', 50)}
className="min-h-[44px] px-3.5 py-2 rounded-lg bg-krona-workspace border border-krona-border text-krona-primary hover:bg-krona-surface text-xs font-medium disabled:opacity-40 transition-colors shadow-xs" className="px-3 py-1.5 rounded-lg bg-zinc-900 border border-zinc-700 text-zinc-300 hover:bg-zinc-800 text-xs font-medium disabled:opacity-40 transition-colors"
> >
Фаза 2: 50% организаций Фаза 2: 50% организаций
</button> </button>
@ -257,7 +231,7 @@ export const ModulesPage: React.FC = () => {
type="button" type="button"
disabled={!permissions.canTriggerRollouts || activeRollout.stage === 'general_availability'} disabled={!permissions.canTriggerRollouts || activeRollout.stage === 'general_availability'}
onClick={() => handleStageChange('general_availability', 100)} onClick={() => handleStageChange('general_availability', 100)}
className="min-h-[44px] px-4 py-2 rounded-lg bg-emerald-500/15 text-emerald-600 dark:text-emerald-300 border border-emerald-500/40 hover:bg-emerald-500/25 text-xs font-bold disabled:opacity-40 transition-colors shadow-xs" className="px-3 py-1.5 rounded-lg bg-emerald-950 text-emerald-300 border border-emerald-700 hover:bg-emerald-900 text-xs font-bold disabled:opacity-40 transition-colors"
> >
Фаза 3: Полный релиз (100% GA) Фаза 3: Полный релиз (100% GA)
</button> </button>
@ -267,19 +241,19 @@ export const ModulesPage: React.FC = () => {
type="button" type="button"
disabled={!permissions.canPauseRollbackRollouts} disabled={!permissions.canPauseRollbackRollouts}
onClick={() => handleStageChange('paused', activeRollout.percentage)} onClick={() => handleStageChange('paused', activeRollout.percentage)}
className="min-h-[44px] px-3.5 py-2 rounded-lg bg-amber-500/15 text-amber-600 dark:text-amber-300 border border-amber-500/40 hover:bg-amber-500/25 text-xs font-bold disabled:opacity-40 transition-colors flex items-center gap-1.5 shadow-xs" className="px-3 py-1.5 rounded-lg bg-amber-950/70 text-amber-300 border border-amber-700 hover:bg-amber-900/60 text-xs font-bold disabled:opacity-40 transition-colors flex items-center gap-1.5"
> >
<Pause className="w-4 h-4" /> <Pause className="w-3.5 h-3.5" />
<span>На паузу</span> <span>Поставить на паузу</span>
</button> </button>
) : ( ) : (
<button <button
type="button" type="button"
disabled={!permissions.canTriggerRollouts} disabled={!permissions.canTriggerRollouts}
onClick={() => handleStageChange('pilot', activeRollout.percentage)} onClick={() => handleStageChange('pilot', activeRollout.percentage)}
className="min-h-[44px] px-3.5 py-2 rounded-lg bg-amber-500 text-zinc-950 font-bold text-xs hover:bg-amber-400 transition-colors flex items-center gap-1.5 shadow-xs" className="px-3 py-1.5 rounded-lg bg-amber-500 text-zinc-950 font-bold text-xs hover:bg-amber-400 transition-colors flex items-center gap-1.5"
> >
<Play className="w-4 h-4" /> <Play className="w-3.5 h-3.5" />
<span>Снять с паузы</span> <span>Снять с паузы</span>
</button> </button>
)} )}
@ -288,50 +262,47 @@ export const ModulesPage: React.FC = () => {
type="button" type="button"
disabled={!permissions.canPauseRollbackRollouts || activeRollout.stage === 'rolled_back'} disabled={!permissions.canPauseRollbackRollouts || activeRollout.stage === 'rolled_back'}
onClick={() => handleStageChange('rolled_back', 0)} onClick={() => handleStageChange('rolled_back', 0)}
className="min-h-[44px] px-3.5 py-2 rounded-lg bg-rose-500/15 text-rose-600 dark:text-rose-300 border border-rose-500/40 hover:bg-rose-500/25 text-xs font-bold disabled:opacity-40 transition-colors flex items-center gap-1.5 shadow-xs" className="px-3 py-1.5 rounded-lg bg-rose-950 text-rose-300 border border-rose-700 hover:bg-rose-900 text-xs font-bold disabled:opacity-40 transition-colors flex items-center gap-1.5"
> >
<RotateCcw className="w-4 h-4" /> <RotateCcw className="w-3.5 h-3.5" />
<span>Откат (Rollback)</span> <span>Rollback (Откат)</span>
</button> </button>
</div> </div>
</div> </div>
) : ( ) : (
<div className="p-4 rounded-xl bg-krona-workspace border border-krona-border text-xs space-y-3"> <div className="p-4 rounded-xl bg-zinc-950 border border-zinc-800 text-xs space-y-3">
<p className="text-krona-secondary"> <p className="text-zinc-400">
Для модуля <strong className="text-krona-primary">{selectedModule.name}</strong> нет активных раскаток. Для модуля <strong className="text-zinc-200">{selectedModule.name}</strong> нет активных раскаток.
Вы можете запустить пилотный релиз новой версии для проверки совместимости. Вы можете запустить пилотный релиз новой версии.
</p> </p>
<button <button
type="button" type="button"
disabled={!permissions.canTriggerRollouts} disabled={!permissions.canTriggerRollouts}
onClick={handleCreateRollout} onClick={handleCreateRollout}
className="min-h-[44px] px-4 py-2.5 rounded-lg bg-amber-500 hover:bg-amber-400 text-zinc-950 font-bold text-xs transition-colors flex items-center gap-2 disabled:opacity-40 shadow-xs" className="px-4 py-2 rounded-lg bg-amber-500 hover:bg-amber-400 text-zinc-950 font-bold text-xs transition-colors flex items-center gap-1.5 disabled:opacity-40"
> >
<Play className="w-4 h-4" /> <Play className="w-3.5 h-3.5" />
<span>Запустить пилотную раскатку</span> <span>Запустить пилотный Rollout</span>
</button> </button>
</div> </div>
)} )}
</div> </div>
{/* Release History & Deployments */} {/* Release History & Deployments */}
<div className="rounded-xl border border-krona-border bg-krona-surface p-4 md:p-5 space-y-3 text-xs shadow-xs"> <div className="rounded-xl border border-zinc-800 bg-[#0a1c15] p-5 space-y-3 text-xs">
<h3 className="text-sm font-bold text-krona-primary flex items-center gap-1.5"> <h3 className="text-sm font-bold text-zinc-200">История релизов и совместимость (Compatibility)</h3>
<Sparkles className="w-4 h-4 text-amber-500" />
<span>История релизов и совместимость</span>
</h3>
<div className="space-y-2"> <div className="space-y-2">
{selectedModule.releaseHistory.map((rel) => ( {selectedModule.releaseHistory.map((rel) => (
<div key={rel.version} className="p-3 rounded-lg bg-krona-workspace border border-krona-border space-y-1"> <div key={rel.version} className="p-3 rounded-lg bg-zinc-950 border border-zinc-800 space-y-1">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="font-bold text-amber-500 font-mono">v{rel.version}</span> <span className="font-bold text-amber-300 font-mono">v{rel.version}</span>
<span className="text-[11px] text-krona-secondary"> <span className="text-[11px] text-zinc-500">
{new Date(rel.releasedAt).toLocaleDateString('ru-RU')} {new Date(rel.releasedAt).toLocaleDateString('ru-RU')}
</span> </span>
</div> </div>
<p className="text-krona-primary text-[11px]">{rel.changelog}</p> <p className="text-zinc-300 text-[11px]">{rel.changelog}</p>
<div className="text-[10px] text-krona-secondary font-mono pt-1"> <div className="text-[10px] text-zinc-400 font-mono pt-1">
Совместимость с ядром: {rel.compatibleCore} Совместимость с Core: {rel.compatibleCore}
</div> </div>
</div> </div>
))} ))}
@ -344,4 +315,3 @@ export const ModulesPage: React.FC = () => {
</div> </div>
); );
}; };

View file

@ -3,6 +3,10 @@ import {
Building2, Building2,
Search, Search,
ChevronRight, ChevronRight,
Filter,
Users,
CreditCard,
Layers,
} from 'lucide-react'; } from 'lucide-react';
import { useKccStore } from '../store/kccStore'; import { useKccStore } from '../store/kccStore';
import { StatusBadge } from '../components/StatusBadge'; import { StatusBadge } from '../components/StatusBadge';
@ -92,7 +96,7 @@ export const OrganizationsPage: React.FC = () => {
{filtered.map((org) => ( {filtered.map((org) => (
<div <div
key={org.tenantId} key={org.tenantId}
onClick={() => openInspector('organization', org.tenantId)} onClick={() => openInspector({ type: 'organization', id: org.tenantId, title: org.displayName, data: org })}
className="rounded-xl border border-krona-border bg-krona-surface p-4 space-y-3 shadow-sm hover:border-krona-gold/40 transition-colors cursor-pointer" 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 */} {/* Header: Title + Badge */}
@ -131,12 +135,12 @@ export const OrganizationsPage: React.FC = () => {
type="button" type="button"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
openInspector('organization', org.tenantId); openInspector({ type: 'organization', id: org.tenantId, title: org.displayName, data: org });
}} }}
className="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" 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}`} data-testid={`inspect-org-${org.tenantId}`}
> >
<span>360° Карточка</span> <span>360° Инспектор</span>
<ChevronRight className="w-4 h-4" /> <ChevronRight className="w-4 h-4" />
</button> </button>
</div> </div>
@ -173,7 +177,7 @@ export const OrganizationsPage: React.FC = () => {
{filtered.map((org) => ( {filtered.map((org) => (
<tr <tr
key={org.tenantId} key={org.tenantId}
onClick={() => openInspector('organization', org.tenantId)} onClick={() => openInspector({ type: 'organization', id: org.tenantId, title: org.displayName, data: org })}
className="hover:bg-krona-hover cursor-pointer transition-colors group" className="hover:bg-krona-hover cursor-pointer transition-colors group"
> >
<td className="py-3 px-4"> <td className="py-3 px-4">
@ -217,12 +221,12 @@ export const OrganizationsPage: React.FC = () => {
type="button" type="button"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
openInspector('organization', org.tenantId); openInspector({ type: 'organization', id: org.tenantId, title: org.displayName, data: org });
}} }}
className="inline-flex items-center gap-1 px-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" 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}`} data-testid={`inspect-org-btn-${org.tenantId}`}
> >
<span>360° Карточка</span> <span>360° Инспектор</span>
<ChevronRight className="w-3.5 h-3.5" /> <ChevronRight className="w-3.5 h-3.5" />
</button> </button>
</td> </td>

View file

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

File diff suppressed because one or more lines are too long