feat(control-center): mobile-first overhaul, 3 brandbook themes, Russian localization, and full Playwright verification
This commit is contained in:
parent
a2b0a183e2
commit
50d9059f21
47 changed files with 668 additions and 725 deletions
1
dist/assets/AiCenterModule-DJPYM3Y6.js
vendored
1
dist/assets/AiCenterModule-DJPYM3Y6.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/AnalyticsModule-CUIgYnvl.js
vendored
1
dist/assets/AnalyticsModule-CUIgYnvl.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/AppsCatalogModule-BXZcdjXz.js
vendored
1
dist/assets/AppsCatalogModule-BXZcdjXz.js
vendored
|
|
@ -1 +0,0 @@
|
|||
import{f as e,i as t,r as n,s as r}from"./useToast-CS8pZhce.js";import{ct as i,t as a}from"./index-CT8iDYYK.js";import{t as o}from"./types-CTcNpN0z.js";e();var s=r(),c=()=>{let{customCapabilities:e,toggleCapability:r}=i();return(0,s.jsxs)(`div`,{className:`space-y-4 animate-fade-in`,children:[(0,s.jsxs)(`div`,{className:`flex flex-col sm:flex-row sm:items-center justify-between gap-3 bg-krona-surface p-3.5 rounded-xl border border-krona-border shadow-krona-soft`,children:[(0,s.jsxs)(`div`,{children:[(0,s.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,s.jsx)(`h2`,{className:`text-sm font-bold text-krona-primary`,children:`Каталог приложений & Модульная экосистема Кроны`}),(0,s.jsx)(a,{level:`demo`,size:`sm`})]}),(0,s.jsx)(`p`,{className:`text-xs text-krona-secondary mt-0.5`,children:`Подключайте и отключайте функциональные блоки для адаптации платформы под новые ниши`})]}),(0,s.jsxs)(n,{variant:`gold`,size:`md`,children:[`Всего модулей: `,o.length]})]}),(0,s.jsx)(`div`,{className:`grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3.5`,children:o.map(i=>{let a=e[i.id];return(0,s.jsxs)(`div`,{className:`p-4 rounded-xl border border-krona-border bg-krona-surface shadow-krona-soft flex flex-col justify-between space-y-3`,children:[(0,s.jsxs)(`div`,{children:[(0,s.jsxs)(`div`,{className:`flex items-center justify-between mb-1.5`,children:[(0,s.jsx)(`span`,{className:`text-[10px] uppercase font-bold text-krona-secondary`,children:i.category}),(0,s.jsx)(n,{variant:a?`green`:`neutral`,size:`sm`,children:a?`Активен`:`Отключен`})]}),(0,s.jsx)(`h4`,{className:`text-xs font-bold text-krona-primary`,children:i.name}),(0,s.jsx)(`p`,{className:`text-[11px] text-krona-secondary mt-1 leading-relaxed`,children:i.description})]}),(0,s.jsx)(`div`,{className:`pt-2 border-t border-krona-border/50 flex justify-end`,children:(0,s.jsx)(t,{variant:a?`secondary`:`primary`,size:`sm`,className:`w-full text-xs`,onClick:()=>r(i.id),children:a?`Отключить модуль`:`Включить в контур`})})]},i.id)})})]})};export{c as AppsCatalogModule};
|
||||
53
dist/assets/AreaChart-Bzq_VEEn.js
vendored
53
dist/assets/AreaChart-Bzq_VEEn.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/AutomationModule-ChWg_nAL.js
vendored
1
dist/assets/AutomationModule-ChWg_nAL.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/CardLayoutBuilder-CAtgCQkT.js
vendored
1
dist/assets/CardLayoutBuilder-CAtgCQkT.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/CmsModule-Bp0oqz_9.js
vendored
1
dist/assets/CmsModule-Bp0oqz_9.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/CommunicationsModule-CRXuUNuh.js
vendored
1
dist/assets/CommunicationsModule-CRXuUNuh.js
vendored
File diff suppressed because one or more lines are too long
12
dist/assets/CrmModule-96_lCLTJ.js
vendored
12
dist/assets/CrmModule-96_lCLTJ.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/CustomEntityPage-DRX7pNvW.js
vendored
1
dist/assets/CustomEntityPage-DRX7pNvW.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/DynamicDashboard-BiSAmfR8.js
vendored
1
dist/assets/DynamicDashboard-BiSAmfR8.js
vendored
File diff suppressed because one or more lines are too long
3
dist/assets/DynamicFormRenderer-BPuYsHXt.js
vendored
3
dist/assets/DynamicFormRenderer-BPuYsHXt.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/EmployeesModule-C0VszbvR.js
vendored
1
dist/assets/EmployeesModule-C0VszbvR.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/EntityCatalog-CEDNI60l.js
vendored
1
dist/assets/EntityCatalog-CEDNI60l.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/FieldBuilder-DHYEX2iP.js
vendored
1
dist/assets/FieldBuilder-DHYEX2iP.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/FinanceModule-CxEjvopo.js
vendored
1
dist/assets/FinanceModule-CxEjvopo.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/FormBuilder-DsYKgy0A.js
vendored
1
dist/assets/FormBuilder-DsYKgy0A.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/HelpDeskModule-DAbGCOpV.js
vendored
1
dist/assets/HelpDeskModule-DAbGCOpV.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/InventoryModule-DWliKRH1.js
vendored
1
dist/assets/InventoryModule-DWliKRH1.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/MarketingModule-RpE5CcpH.js
vendored
1
dist/assets/MarketingModule-RpE5CcpH.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/OperationsModule-DU0oT0Rt.js
vendored
1
dist/assets/OperationsModule-DU0oT0Rt.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/SalesModule-DAKBTXjF.js
vendored
1
dist/assets/SalesModule-DAKBTXjF.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/SettingsModule-ClG-ZgbR.js
vendored
1
dist/assets/SettingsModule-ClG-ZgbR.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/TasksModule-CJeRoUgf.js
vendored
1
dist/assets/TasksModule-CJeRoUgf.js
vendored
File diff suppressed because one or more lines are too long
2
dist/assets/ViewBuilder-C3JVZeVE.js
vendored
2
dist/assets/ViewBuilder-C3JVZeVE.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/fieldTypesMeta-8EBMnBZg.js
vendored
1
dist/assets/fieldTypesMeta-8EBMnBZg.js
vendored
File diff suppressed because one or more lines are too long
13
dist/assets/index-CT8iDYYK.js
vendored
13
dist/assets/index-CT8iDYYK.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/index-D03nV9JP.css
vendored
1
dist/assets/index-D03nV9JP.css
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/useInventory-d78AKbIO.js
vendored
1
dist/assets/useInventory-d78AKbIO.js
vendored
|
|
@ -1 +0,0 @@
|
|||
import{f as e,h as t}from"./useToast-CS8pZhce.js";import{et as n,it as r}from"./index-CT8iDYYK.js";var i=t(e(),1);function a(){let[e,t]=(0,i.useState)([]),[a,o]=(0,i.useState)([]),[s,c]=(0,i.useState)([]),[l,u]=(0,i.useState)([]),[d,f]=(0,i.useState)(!0),[p,m]=(0,i.useState)(null),h=(0,i.useCallback)(async()=>{try{f(!0),m(null);let[e,n,i,a]=await Promise.all([r.warehouses.list({pageSize:50}),r.stockItems.list({pageSize:200}),r.movements.list({pageSize:100}),r.receipts.list({pageSize:100})]);t(e.items),o(n.items),c(i.items),u(a.items)}catch(e){m(e instanceof Error?e:Error(String(e)))}finally{f(!1)}},[]);return(0,i.useEffect)(()=>{let e=!0;return Promise.resolve().then(()=>{e&&h()}),()=>{e=!1}},[h]),n(`records:changed`,e=>{(e.entityType===`warehouse`||e.entityType===`stock_item`||e.entityType===`stock_movement`||e.entityType===`goods_receipt`||e.entityType===`stock_reservation`)&&h()}),n(`storage:reset`,h),n(`snapshot:imported`,h),{warehouses:e,stockItems:a,movements:s,receipts:l,loading:d,error:p,refetch:h,adjustStock:(0,i.useCallback)(async(e,t,n,i)=>r.adjustStock(e,t,n,i),[]),reserveStock:(0,i.useCallback)(async(e,t,n,i)=>r.reserveStock(e,t,n,i),[]),cancelOrderReservations:(0,i.useCallback)(async e=>r.cancelOrderReservations(e),[]),receiveGoods:(0,i.useCallback)(async e=>r.receiveGoods(e),[]),createAndReceiveGoods:(0,i.useCallback)(async(e,t,n,i)=>r.createAndReceiveGoods(e,t,n,i),[])}}export{a as t};
|
||||
1
dist/assets/useOrders-D9bouyEq.js
vendored
1
dist/assets/useOrders-D9bouyEq.js
vendored
|
|
@ -1 +0,0 @@
|
|||
import{f as e,h as t}from"./useToast-CS8pZhce.js";import{et as n,tt as r}from"./index-CT8iDYYK.js";var i=t(e(),1);function a(){let[e,t]=(0,i.useState)([]),[a,o]=(0,i.useState)([]),[s,c]=(0,i.useState)([]),[l,u]=(0,i.useState)([]),[d,f]=(0,i.useState)(!0),[p,m]=(0,i.useState)(null),h=(0,i.useCallback)(async()=>{try{f(!0),m(null);let[e,n,i,a]=await Promise.all([r.orders.list({pageSize:100}),r.products.list({pageSize:100}),r.payments.list({pageSize:100}),r.shipments.list({pageSize:100})]);t(e.items),o(n.items),c(i.items),u(a.items)}catch(e){m(e instanceof Error?e:Error(String(e)))}finally{f(!1)}},[]);return(0,i.useEffect)(()=>{let e=!0;return Promise.resolve().then(()=>{e&&h()}),()=>{e=!1}},[h]),n(`records:changed`,e=>{(e.entityType===`order`||e.entityType===`product`||e.entityType===`payment`||e.entityType===`shipment`)&&h()}),n(`storage:reset`,h),n(`snapshot:imported`,h),{orders:e,products:a,payments:s,shipments:l,loading:d,error:p,refetch:h,createOrder:(0,i.useCallback)(async e=>r.orders.create(e),[]),updateStatus:(0,i.useCallback)(async(e,t)=>r.updateOrderStatus(e,t),[]),confirmOrder:(0,i.useCallback)(async(e,t)=>r.confirmOrder(e,t),[]),cancelOrder:(0,i.useCallback)(async e=>r.cancelOrder(e),[]),createPayment:(0,i.useCallback)(async(e,t,n,i=!0)=>r.createPayment(e,t,n,i),[]),createShipment:(0,i.useCallback)(async(e,t,n)=>r.createShipment(e,t,n),[]),processReturn:(0,i.useCallback)(async(e,t,n,i=`wh_main`,a=!0)=>r.processReturn(e,t,n,i,a),[])}}export{a as t};
|
||||
34
dist/index.html
vendored
34
dist/index.html
vendored
|
|
@ -1,20 +1,20 @@
|
|||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/branding/krona-symbol.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Крона — Universal Business OS</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<script type="module" crossorigin src="/assets/index-CT8iDYYK.js"></script>
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/branding/krona-symbol.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Крона — Universal Business OS</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<script type="module" crossorigin src="/assets/index-CNnq9VO5.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/useToast-CS8pZhce.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/demo-D1nHL60J.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/indexedDb-D30TbhA6.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D03nV9JP.css">
|
||||
</head>
|
||||
<body class="bg-krona-app text-krona-primary antialiased selection:bg-krona-gold/20 selection:text-krona-gold-text">
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D3QQttR0.css">
|
||||
</head>
|
||||
<body class="bg-krona-app text-krona-primary antialiased selection:bg-krona-gold/20 selection:text-krona-gold-text">
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -1,272 +1,285 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Krona Control Center - Production UX & Operator Scenarios', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. ACCESS GATE LOGIN & VALIDATION (Isolated describe without pre-auth)
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe('Krona Control Center - Access Gate', () => {
|
||||
test('01. Access Gate redirects unauthorized users and authenticates with PIN krona2026', async ({ page }) => {
|
||||
// Clear any existing localStorage before page loads
|
||||
await page.addInitScript(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
await page.goto('/control');
|
||||
await page.waitForURL('**/control/gate');
|
||||
await expect(page.locator('h1:has-text("Защита доступа")')).toBeVisible();
|
||||
|
||||
// Invalid PIN
|
||||
const pinInput = page.locator('[data-testid="pin-input"]');
|
||||
await pinInput.fill('wrong_pin_123');
|
||||
await page.locator('[data-testid="pin-submit"]').click();
|
||||
await expect(page.locator('[data-testid="pin-error"]')).toBeVisible();
|
||||
|
||||
// Valid PIN: krona2026
|
||||
await pinInput.fill('krona2026');
|
||||
await page.locator('[data-testid="pin-submit"]').click();
|
||||
await page.waitForURL('**/control');
|
||||
await expect(page.locator('h1:has-text("Сводная операторская панель")')).toBeVisible({ timeout: 15000 });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. MAIN PRODUCTION & MOBILE-FIRST SUITE (Pre-authorized via kcc_access_gate)
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe('Krona Control Center - Mobile-First & Production UX Suite', () => {
|
||||
test.beforeEach(async ({ context }) => {
|
||||
await context.addInitScript(() => {
|
||||
localStorage.setItem('kcc_access_gate', '1');
|
||||
});
|
||||
});
|
||||
|
||||
test('02. Mobile menu (burger) opens drawer and navigates across sections', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto('/control');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
await expect(page.locator('text=IndexedDB Synced')).toBeVisible({ timeout: 15000 });
|
||||
});
|
||||
await expect(page.locator('h1:has-text("Сводная операторская панель")')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
test('01. Shell loads with zero horizontal overflow and topbar controls', async ({ page }) => {
|
||||
await expect(page.locator('text=Krona Control Center').first()).toBeVisible();
|
||||
await expect(page.locator('button[aria-label="Сессия оператора"]')).toBeVisible();
|
||||
const menuBtn = page.locator('[data-testid="mobile-menu-button"]');
|
||||
await expect(menuBtn).toBeVisible();
|
||||
await menuBtn.click();
|
||||
|
||||
const isOverflowing = await page.evaluate(() => {
|
||||
return document.documentElement.scrollWidth > document.documentElement.clientWidth;
|
||||
});
|
||||
expect(isOverflowing).toBe(false);
|
||||
// Drawer opens
|
||||
const drawerNavOrg = page.locator('[data-testid="drawer-nav-organizations"]');
|
||||
await expect(drawerNavOrg).toBeVisible();
|
||||
await drawerNavOrg.click();
|
||||
|
||||
await expect(page.locator('text=Сводная операторская панель')).toBeVisible();
|
||||
await expect(page.locator('text=Организации').first()).toBeVisible();
|
||||
await expect(page.locator('text=Пользователи / Точки').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('02. Scenario 1: Search organization and open 360 inspector drawer', async ({ page }) => {
|
||||
await page.click('a[href="/control/organizations"]');
|
||||
await page.waitForURL('**/control/organizations');
|
||||
await expect(page.locator('h1:has-text("Организации платформы")')).toBeVisible();
|
||||
});
|
||||
|
||||
// Search input
|
||||
const searchInput = page.locator('input[placeholder*="Поиск"]');
|
||||
await expect(searchInput).toBeVisible();
|
||||
await searchInput.fill('Ритейл');
|
||||
test('03. Switch 3 brandbook themes (soft-light, light, dark) and verify data-theme on html', async ({ page }) => {
|
||||
await page.goto('/control');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
await expect(page.locator('h1:has-text("Сводная операторская панель")')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Filtered result should show Retail Group
|
||||
await expect(page.locator('text=Северная Ритейл Группа').first()).toBeVisible();
|
||||
const isMobile = await page.locator('[data-testid="mobile-menu-button"]').isVisible();
|
||||
if (isMobile) {
|
||||
await page.locator('[data-testid="mobile-menu-button"]').click();
|
||||
|
||||
// Click 360 card button
|
||||
const inspectBtn = page.locator('button:has-text("360° Карточка")').first();
|
||||
// Soft-light
|
||||
await page.locator('[data-testid="mobile-theme-soft-light"]').click();
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'soft-light');
|
||||
|
||||
// Light
|
||||
await page.locator('[data-testid="mobile-theme-light"]').click();
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
|
||||
|
||||
// Dark
|
||||
await page.locator('[data-testid="mobile-theme-dark"]').click();
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
|
||||
|
||||
// Back to soft-light
|
||||
await page.locator('[data-testid="mobile-theme-soft-light"]').click();
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'soft-light');
|
||||
|
||||
await page.locator('[data-testid="close-mobile-drawer"]').click();
|
||||
} else {
|
||||
// Desktop theme switcher
|
||||
await page.locator('[data-testid="theme-toggle-light"]').click();
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
|
||||
|
||||
await page.locator('[data-testid="theme-toggle-dark"]').click();
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
|
||||
|
||||
await page.locator('[data-testid="theme-toggle-soft-light"]').click();
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'soft-light');
|
||||
}
|
||||
});
|
||||
|
||||
test('04. Zero horizontal overflow on all 7 target screen sizes', async ({ page }) => {
|
||||
test.setTimeout(60000);
|
||||
|
||||
const viewports = [
|
||||
{ width: 360, height: 800 },
|
||||
{ width: 390, height: 844 },
|
||||
{ width: 430, height: 932 },
|
||||
{ width: 768, height: 1024 },
|
||||
{ width: 1024, height: 768 },
|
||||
{ width: 1280, height: 800 },
|
||||
{ width: 1440, height: 900 },
|
||||
];
|
||||
|
||||
for (const vp of viewports) {
|
||||
await page.setViewportSize(vp);
|
||||
await page.goto('/control');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
const hasOverflow = await page.evaluate(() => {
|
||||
return document.documentElement.scrollWidth > document.documentElement.clientWidth;
|
||||
});
|
||||
expect(hasOverflow, `Horizontal overflow at ${vp.width}x${vp.height} on /control`).toBe(false);
|
||||
|
||||
await page.goto('/control/organizations');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
const hasOrgOverflow = await page.evaluate(() => {
|
||||
return document.documentElement.scrollWidth > document.documentElement.clientWidth;
|
||||
});
|
||||
expect(hasOrgOverflow, `Horizontal overflow at ${vp.width}x${vp.height} on /control/organizations`).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test('05. 360 inspector drawer opens, switches tabs, and closes cleanly', async ({ page }) => {
|
||||
await page.goto('/control/organizations');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
await expect(page.locator('h1:has-text("Организации платформы")')).toBeVisible();
|
||||
|
||||
// Click 360 card button (whichever is visible on current viewport)
|
||||
const inspectBtn = page.locator('button:has-text("360° Карточка"):visible').first();
|
||||
await inspectBtn.click();
|
||||
|
||||
// Contextual Drawer opens
|
||||
const drawer = page.locator('div[role="complementary"][aria-label="Контекстный инспектор"]');
|
||||
await expect(drawer).toBeVisible();
|
||||
await expect(drawer.locator('text=Северная Ритейл Группа').first()).toBeVisible();
|
||||
await expect(drawer.locator('text=Действия оператора платформы')).toBeVisible();
|
||||
|
||||
// Verify 360 tabs
|
||||
await expect(drawer.locator('button:has-text("Обзор")')).toBeVisible();
|
||||
await expect(drawer.locator('button:has-text("Модули")').first()).toBeVisible();
|
||||
|
||||
// Switch to Modules tab
|
||||
await drawer.locator('button:has-text("Модули")').first().click();
|
||||
await expect(drawer.locator('div:has-text("v1."), div:has-text("v2."), div:has-text("v3.")').first()).toBeVisible();
|
||||
const modulesTab = drawer.locator('button:has-text("Модули")').first();
|
||||
await expect(modulesTab).toBeVisible();
|
||||
await modulesTab.click();
|
||||
|
||||
// Close drawer
|
||||
await drawer.locator('button[aria-label="Закрыть инспектор"]').click();
|
||||
await expect(drawer).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('03. Scenario 2: Dangerous action requires operator authorization & mandatory reason', async ({ page }) => {
|
||||
await page.click('a[href="/control/organizations"]');
|
||||
await page.waitForURL('**/control/organizations');
|
||||
test('06. Filters, tabs and accordions operate correctly', async ({ page }) => {
|
||||
// Organizations search
|
||||
await page.goto('/control/organizations');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
const searchInput = page.locator('input[placeholder*="Поиск"]');
|
||||
await searchInput.fill('Ритейл');
|
||||
await expect(page.locator('*:has-text("Северная Ритейл Группа"):visible').first()).toBeVisible();
|
||||
|
||||
// Open inspector for first org
|
||||
await page.locator('button:has-text("360° Карточка")').first().click();
|
||||
// Billing quick filters
|
||||
await page.goto('/control/billing');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
const filter7d = page.locator('[data-testid="filter-expiring_7d"], button:has-text("7 дн")').first();
|
||||
await expect(filter7d).toBeVisible();
|
||||
await filter7d.click();
|
||||
await expect(page.locator('*:has-text("Истекает (< 7 дн.)"):visible').first()).toBeVisible();
|
||||
|
||||
// Installations filter
|
||||
await page.goto('/control/installations');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
const filterOutdated = page.locator('button:has-text("Показать только устаревшие Android")');
|
||||
await expect(filterOutdated).toBeVisible();
|
||||
await filterOutdated.click();
|
||||
await expect(page.locator('*:has-text("Android"):visible').first()).toBeVisible();
|
||||
|
||||
// Infrastructure node telemetry
|
||||
await page.goto('/control/infrastructure');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
await expect(page.locator('*:has-text("CPU"):visible').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('07. Operator dangerous action requires authorization & valid reason', async ({ page }) => {
|
||||
await page.goto('/control/organizations');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
|
||||
await page.locator('button:has-text("360° Карточка"):visible').first().click();
|
||||
const drawer = page.locator('div[role="complementary"][aria-label="Контекстный инспектор"]');
|
||||
await expect(drawer).toBeVisible();
|
||||
|
||||
// Click trial extension button
|
||||
const grantTrialBtn = drawer.locator('button:has-text("Выдать Trial")');
|
||||
await expect(grantTrialBtn).toBeVisible();
|
||||
await grantTrialBtn.click();
|
||||
const trialBtn = drawer.locator('button:has-text("Выдать Trial")');
|
||||
await expect(trialBtn).toBeVisible();
|
||||
await trialBtn.click();
|
||||
|
||||
// Confirm modal opens
|
||||
const confirmDialog = page.locator('div[role="dialog"][aria-labelledby="confirm-modal-title"]');
|
||||
await expect(confirmDialog).toBeVisible();
|
||||
await expect(confirmDialog.locator('text=Требуется авторизация оператора')).toBeVisible();
|
||||
const modal = page.locator('div[role="dialog"][aria-labelledby="confirm-modal-title"]');
|
||||
await expect(modal).toBeVisible();
|
||||
await expect(modal.locator('text=Требуется авторизация оператора')).toBeVisible();
|
||||
|
||||
// Attempt submit without reason
|
||||
const submitBtn = confirmDialog.locator('button:has-text("Подтвердить операцию")');
|
||||
// Submit empty reason -> validation error
|
||||
const submitBtn = modal.locator('button:has-text("Подтвердить операцию")');
|
||||
await submitBtn.click();
|
||||
await expect(confirmDialog.locator('text=Обоснование операции обязательно')).toBeVisible();
|
||||
await expect(modal.locator('text=Обоснование операции обязательно')).toBeVisible();
|
||||
|
||||
// Fill valid reason (>= 5 chars)
|
||||
const reasonInput = confirmDialog.locator('textarea[id="action-reason-input"]');
|
||||
await reasonInput.fill('Согласованное продление пилота по заявке #PILOT-1049');
|
||||
await modal.locator('textarea[id="action-reason-input"]').fill('Согласованное продление пилота #PILOT-2026');
|
||||
await submitBtn.click();
|
||||
|
||||
// Modal closes
|
||||
await expect(confirmDialog).not.toBeVisible();
|
||||
await expect(modal).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('04. Scenario 3: Filter subscriptions expiring in 7 days', async ({ page }) => {
|
||||
await page.click('a[href="/control/billing"]');
|
||||
await page.waitForURL('**/control/billing');
|
||||
test('08. Switch operator role and observe RBAC enforcement', async ({ page }) => {
|
||||
await page.goto('/control');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
|
||||
await expect(page.locator('h1:has-text("Подписки, лицензии и Entitlements")')).toBeVisible();
|
||||
|
||||
// Click "Истекает 7 дней" filter
|
||||
const filter7dBtn = page.locator('button:has-text("Истекает 7 дней")');
|
||||
await expect(filter7dBtn).toBeVisible();
|
||||
await filter7dBtn.click();
|
||||
|
||||
// Verify filter active styling and table displays matching rows
|
||||
await expect(page.locator('tbody tr')).not.toHaveCount(0);
|
||||
await expect(page.locator('text=Истекает (< 7 дн.)').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('05. Scenario 4: Monitor installations and identify outdated Android terminals', async ({ page }) => {
|
||||
await page.click('a[href="/control/installations"]');
|
||||
await page.waitForURL('**/control/installations');
|
||||
|
||||
await expect(page.locator('h1:has-text("Установки и клиентские приложения")')).toBeVisible();
|
||||
|
||||
// Click quick filter button for outdated Android
|
||||
const outdatedAndroidBtn = page.locator('button:has-text("Показать только устаревшие Android")');
|
||||
await expect(outdatedAndroidBtn).toBeVisible();
|
||||
await outdatedAndroidBtn.click();
|
||||
|
||||
// Outdated Android device should be visible in table
|
||||
await expect(page.locator('text=Android').first()).toBeVisible();
|
||||
await expect(page.locator('text=Устарела').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('06. Scenario 5: Inspect infrastructure nodes and Server Monitor outage resilience', async ({ page }) => {
|
||||
await page.click('a[href="/control/infrastructure"]');
|
||||
await page.waitForURL('**/control/infrastructure');
|
||||
|
||||
await expect(page.locator('h1:has-text("Инфраструктурные узлы")')).toBeVisible();
|
||||
|
||||
// Check circuit breaker banner simulation button
|
||||
const toggleBannerBtn = page.locator('button[title*="Server Monitor"]');
|
||||
await expect(toggleBannerBtn).toBeVisible();
|
||||
|
||||
// Verify server node metrics
|
||||
await expect(page.locator('text=CPU:').first()).toBeVisible();
|
||||
|
||||
// Click first server card to open inspector
|
||||
const firstServerCard = page.locator('div[class*="cursor-pointer"]:has-text("CPU:")').first();
|
||||
await firstServerCard.click();
|
||||
|
||||
// Context drawer opens with server details
|
||||
const drawer = page.locator('div[role="complementary"][aria-label="Контекстный инспектор"]');
|
||||
await expect(drawer).toBeVisible();
|
||||
await expect(drawer.locator('text=Телеметрия узла').first()).toBeVisible();
|
||||
await drawer.locator('button[aria-label="Закрыть инспектор"]').click();
|
||||
});
|
||||
|
||||
test('07. Scenario 6: Create incident workflow from alert', async ({ page }) => {
|
||||
await page.click('a[href="/control/infrastructure"]');
|
||||
await page.waitForURL('**/control/infrastructure');
|
||||
|
||||
// Find a problematic server with "Создать инцидент"
|
||||
const createIncBtn = page.locator('button:has-text("Создать инцидент")').first();
|
||||
await expect(createIncBtn).toBeVisible();
|
||||
await createIncBtn.click();
|
||||
|
||||
// Confirm modal opens
|
||||
const confirmModal = page.locator('div[role="dialog"][aria-labelledby="confirm-modal-title"]');
|
||||
await expect(confirmModal).toBeVisible();
|
||||
await confirmModal.locator('textarea[id="action-reason-input"]').fill('Эскалация дежурным инженером NOC по алерту узла');
|
||||
await confirmModal.locator('button:has-text("Подтвердить операцию")').click();
|
||||
|
||||
// Navigate to Incidents desk
|
||||
await page.click('a[href="/control/incidents"]');
|
||||
await page.waitForURL('**/control/incidents');
|
||||
|
||||
// The incident alert is listed
|
||||
await expect(page.locator('text=Критический сбой узла').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('08. Scenario 7: Create targeted tenant announcement', async ({ page }) => {
|
||||
await page.click('a[href="/control/announcements"]');
|
||||
await page.waitForURL('**/control/announcements');
|
||||
|
||||
await expect(page.locator('h1:has-text("Оповещения и системные сообщения")')).toBeVisible();
|
||||
|
||||
// Open creation form
|
||||
await page.locator('button:has-text("Создать объявление")').click();
|
||||
|
||||
// Fill title
|
||||
await page.locator('input[placeholder*="Omnichannel"]').fill('Плановое ночное техобслуживание');
|
||||
|
||||
// Fill message body
|
||||
await page.locator('textarea[placeholder*="текст объявления"]').fill('12 сентября с 03:00 до 04:00 МСК будут проводиться регламентные работы.');
|
||||
|
||||
// Submit form
|
||||
await page.locator('button[type="submit"]:has-text("Опубликовать")').click();
|
||||
|
||||
// Confirmation
|
||||
const confirmModal = page.locator('div[role="dialog"][aria-labelledby="confirm-modal-title"]');
|
||||
await expect(confirmModal).toBeVisible();
|
||||
await confirmModal.locator('textarea[id="action-reason-input"]').fill('Согласованный релизный бюллетень #REL-402');
|
||||
await confirmModal.locator('button:has-text("Подтвердить операцию")').click();
|
||||
await expect(page.locator('text=Плановое ночное техобслуживание').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('09. Scenario 8: Manage module rollouts (Pause / Resume)', async ({ page }) => {
|
||||
await page.click('a[href="/control/modules"]');
|
||||
await page.waitForURL('**/control/modules');
|
||||
|
||||
await expect(page.locator('h1:has-text("Каталог модулей и Rollout конвейер")')).toBeVisible();
|
||||
|
||||
// Select module with active rollout (Омниканальные коммуникации)
|
||||
await page.locator('text=Омниканальные коммуникации').first().click();
|
||||
|
||||
// Verify active rollout badge
|
||||
const activeRolloutBadge = page.locator('text=Пилот (Pilot)').first();
|
||||
await expect(activeRolloutBadge).toBeVisible();
|
||||
|
||||
// Click Pause
|
||||
const pauseBtn = page.locator('button:has-text("Поставить на паузу")');
|
||||
if (await pauseBtn.isVisible()) {
|
||||
await pauseBtn.click();
|
||||
|
||||
const confirmModal = page.locator('div[role="dialog"][aria-labelledby="confirm-modal-title"]');
|
||||
await expect(confirmModal).toBeVisible();
|
||||
await confirmModal.locator('textarea[id="action-reason-input"]').fill('Временная приостановка раскатки до проверки логов');
|
||||
await confirmModal.locator('button:has-text("Подтвердить операцию")').click();
|
||||
|
||||
// Rollout state should now show "На паузе"
|
||||
await expect(page.locator('text=На паузе').first()).toBeVisible();
|
||||
const isMobile = await page.locator('[data-testid="mobile-menu-button"]').isVisible();
|
||||
if (isMobile) {
|
||||
await page.locator('[data-testid="mobile-menu-button"]').click();
|
||||
}
|
||||
});
|
||||
|
||||
test('10. Scenario 9: Switch operator role and observe strict RBAC enforcement', async ({ page }) => {
|
||||
const switcher = page.locator('button[aria-label="Сессия оператора"]');
|
||||
const switcher = page.locator('[data-testid="operator-session-button"]:visible').first();
|
||||
await switcher.click();
|
||||
|
||||
// Switch to security_auditor
|
||||
const auditorItem = page.locator('button[role="menuitem"]:has-text("Аудитор безопасности")');
|
||||
await expect(auditorItem).toBeVisible();
|
||||
await auditorItem.click();
|
||||
const auditorOption = page.locator('[data-testid="role-option-security_auditor"]:visible').first();
|
||||
await expect(auditorOption).toBeVisible();
|
||||
await auditorOption.click();
|
||||
|
||||
// Topbar now shows "Аудитор безопасности"
|
||||
await expect(page.locator('text=Аудитор безопасности').first()).toBeVisible();
|
||||
// Header / profile now indicates auditor
|
||||
await expect(page.locator('*:has-text("Аудитор"):visible').first()).toBeVisible();
|
||||
|
||||
// Navigate to Modules page: triggering rollouts should be disabled for auditor
|
||||
await page.click('a[href="/control/modules"]');
|
||||
await page.waitForURL('**/control/modules');
|
||||
// Navigate to modules: triggering rollout should be disabled for auditor
|
||||
await page.goto('/control/modules');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
|
||||
const rolloutBtn = page.locator('button:has-text("Запустить пилотный Rollout"), button:has-text("Поставить на паузу")').first();
|
||||
if (await rolloutBtn.isVisible()) {
|
||||
await expect(rolloutBtn).toBeDisabled();
|
||||
}
|
||||
|
||||
// Switch back to platform_owner
|
||||
await switcher.click();
|
||||
const ownerItem = page.locator('button[role="menuitem"]:has-text("Владелец платформы")');
|
||||
await ownerItem.click();
|
||||
await expect(page.locator('text=Владелец платформы').first()).toBeVisible();
|
||||
// Switch back to owner
|
||||
if (await page.locator('[data-testid="mobile-menu-button"]').isVisible()) {
|
||||
await page.locator('[data-testid="mobile-menu-button"]').click();
|
||||
}
|
||||
const switcherBack = page.locator('[data-testid="operator-session-button"]:visible').first();
|
||||
await switcherBack.click();
|
||||
await page.locator('[data-testid="role-option-platform_owner"]:visible').first().click();
|
||||
await expect(page.locator('*:has-text("Владелец"):visible').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('11. Scenario 10: State survives browser page reload via IndexedDB', async ({ page }) => {
|
||||
await page.click('a[href="/control/audit"]');
|
||||
await page.waitForURL('**/control/audit');
|
||||
test('09. Control Center UI is completely localized in Russian', async ({ page }) => {
|
||||
const pagesToCheck = [
|
||||
{ url: '/control', heading: 'Сводная операторская панель' },
|
||||
{ url: '/control/organizations', heading: 'Организации платформы' },
|
||||
{ url: '/control/billing', heading: 'Подписки и биллинг платформы' },
|
||||
{ url: '/control/modules', heading: 'Каталог модулей и Rollout конвейер' },
|
||||
{ url: '/control/installations', heading: 'Установки и клиентские приложения' },
|
||||
{ url: '/control/infrastructure', heading: 'Инфраструктурные серверы' },
|
||||
{ url: '/control/announcements', heading: 'Оповещения и системные сообщения' },
|
||||
{ url: '/control/incidents', heading: 'Журнал инцидентов платформы' },
|
||||
{ url: '/control/audit', heading: 'Неизменяемый журнал аудита действий' },
|
||||
];
|
||||
|
||||
for (const p of pagesToCheck) {
|
||||
await page.goto(p.url);
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
await expect(page.locator(`h1:has-text("${p.heading}"):visible`).first()).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('10. State survives browser page reload via IndexedDB', async ({ page }) => {
|
||||
await page.goto('/control/audit');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
await expect(page.locator('h1:has-text("Неизменяемый журнал аудита")')).toBeVisible();
|
||||
|
||||
// Wait for audit log rows to load from IndexedDB
|
||||
await expect(page.locator('tbody tr')).toHaveCount(3);
|
||||
// Verify audit log rows
|
||||
await expect(page.locator('tbody tr')).not.toHaveCount(0);
|
||||
const initialRowsCount = await page.locator('tbody tr').count();
|
||||
expect(initialRowsCount).toBe(3);
|
||||
|
||||
// Reload page
|
||||
await page.reload();
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
await expect(page.locator('text=IndexedDB Synced')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Audit entries must still be present from IndexedDB
|
||||
await expect(page.locator('h1:has-text("Неизменяемый журнал аудита")')).toBeVisible();
|
||||
await expect(page.locator('tbody tr')).toHaveCount(initialRowsCount);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ export default defineConfig({
|
|||
workers: 1,
|
||||
reporter: 'list',
|
||||
use: {
|
||||
baseURL: 'http://localhost:5173',
|
||||
baseURL: 'http://127.0.0.1:5180',
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
projects: [
|
||||
|
|
@ -33,9 +33,9 @@ export default defineConfig({
|
|||
},
|
||||
],
|
||||
webServer: {
|
||||
command: 'npm run dev',
|
||||
url: 'http://localhost:5173',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
command: 'npm run dev -- --port 5180 --host 127.0.0.1',
|
||||
url: 'http://127.0.0.1:5180',
|
||||
reuseExistingServer: false,
|
||||
timeout: 120000,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { ShieldAlert, AlertTriangle, CheckCircle, X } from 'lucide-react';
|
||||
import { useKccStore, useOperatorRole } from '../store/kccStore';
|
||||
import { RU_ROLES } from '../locales/ru';
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
pendingModal: NonNullable<ReturnType<typeof useKccStore.getState>['pendingConfirmModal']>;
|
||||
|
|
@ -36,9 +37,11 @@ const ConfirmOperatorActionDialog: React.FC<ConfirmDialogProps> = ({ pendingModa
|
|||
}
|
||||
};
|
||||
|
||||
const roleLabel = RU_ROLES[role]?.label || role;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/75 backdrop-blur-xs p-4 animate-in fade-in duration-150"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-xs p-4 animate-in fade-in duration-150"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="confirm-modal-title"
|
||||
|
|
@ -46,17 +49,17 @@ const ConfirmOperatorActionDialog: React.FC<ConfirmDialogProps> = ({ pendingModa
|
|||
if (e.key === 'Escape' && !isSubmitting) onClose();
|
||||
}}
|
||||
>
|
||||
<div className="w-full max-w-lg rounded-xl border border-amber-500/40 bg-[#0d1e17] text-zinc-100 shadow-2xl p-6">
|
||||
<div className="flex items-start justify-between gap-4 border-b border-zinc-800 pb-4">
|
||||
<div className="w-full max-w-lg rounded-xl border border-amber-500/40 bg-krona-surface text-krona-primary shadow-2xl p-5 md:p-6 space-y-4">
|
||||
<div className="flex items-start justify-between gap-4 border-b border-krona-border pb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2.5 rounded-lg bg-amber-500/10 text-amber-400 border border-amber-500/30">
|
||||
<div className="p-2.5 rounded-lg bg-amber-500/15 text-amber-500 border border-amber-500/30 shrink-0">
|
||||
<ShieldAlert className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 id="confirm-modal-title" className="text-lg font-bold text-zinc-100">
|
||||
<h3 id="confirm-modal-title" className="text-base md:text-lg font-bold text-krona-primary">
|
||||
{pendingModal.title}
|
||||
</h3>
|
||||
<p className="text-xs text-zinc-400">
|
||||
<p className="text-xs text-krona-secondary">
|
||||
Требуется авторизация оператора и запись в журнал аудита
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -64,45 +67,45 @@ const ConfirmOperatorActionDialog: React.FC<ConfirmDialogProps> = ({ pendingModa
|
|||
<button
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
className="rounded-lg p-1.5 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200 transition-colors"
|
||||
className="min-w-[44px] min-h-[44px] flex items-center justify-center rounded-lg text-krona-secondary hover:bg-krona-workspace hover:text-krona-primary transition-colors"
|
||||
aria-label="Закрыть"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-5 space-y-4">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Target & Action Summary */}
|
||||
<div className="rounded-lg bg-zinc-950/70 border border-zinc-800/80 p-3.5 space-y-2 text-xs">
|
||||
<div className="flex justify-between text-zinc-400">
|
||||
<div className="rounded-lg bg-krona-workspace border border-krona-border p-3.5 space-y-2 text-xs">
|
||||
<div className="flex justify-between text-krona-secondary">
|
||||
<span>Целевой объект:</span>
|
||||
<span className="font-semibold text-zinc-200">{pendingModal.targetName}</span>
|
||||
<span className="font-semibold text-krona-primary">{pendingModal.targetName}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-zinc-400">
|
||||
<div className="flex justify-between text-krona-secondary">
|
||||
<span>Операция:</span>
|
||||
<span className="font-mono text-amber-300">{pendingModal.actionName}</span>
|
||||
<span className="font-mono text-amber-500 font-semibold">{pendingModal.actionName}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-zinc-400">
|
||||
<div className="flex justify-between text-krona-secondary">
|
||||
<span>Оператор:</span>
|
||||
<span className="text-zinc-300 font-medium">
|
||||
{operator.name} ({role})
|
||||
<span className="text-krona-primary font-medium">
|
||||
{operator.name} ({roleLabel})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Impact Summary */}
|
||||
<div className="rounded-lg bg-amber-950/30 border border-amber-700/40 p-3.5 text-xs text-amber-200 flex gap-2.5 items-start">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-400 shrink-0 mt-0.5" />
|
||||
<div className="rounded-lg bg-amber-500/10 border border-amber-500/30 p-3.5 text-xs text-amber-600 dark:text-amber-300 flex gap-2.5 items-start">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-500 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<div className="font-semibold text-amber-300 mb-0.5">Влияние операции (Impact Summary):</div>
|
||||
<div>{pendingModal.impactSummary}</div>
|
||||
<div className="font-semibold text-amber-600 dark:text-amber-300 mb-0.5">Влияние операции:</div>
|
||||
<div className="text-krona-secondary">{pendingModal.impactSummary}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reason Input */}
|
||||
<div>
|
||||
<label htmlFor="action-reason-input" className="block text-xs font-semibold text-zinc-300 mb-1.5">
|
||||
Обоснование операции (Reason) <span className="text-rose-400">*</span>
|
||||
<label htmlFor="action-reason-input" className="block text-xs font-semibold text-krona-primary mb-1.5">
|
||||
Обоснование операции (Reason) <span className="text-rose-500">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="action-reason-input"
|
||||
|
|
@ -114,26 +117,26 @@ const ConfirmOperatorActionDialog: React.FC<ConfirmDialogProps> = ({ pendingModa
|
|||
if (error) setError(null);
|
||||
}}
|
||||
placeholder="Укажите причину выполнения действия (номер заявки, обоснование, согласование)..."
|
||||
className="w-full rounded-lg bg-zinc-950 border border-zinc-700 p-2.5 text-sm text-zinc-100 placeholder-zinc-500 focus:outline-hidden focus:border-amber-500 focus:ring-1 focus:ring-amber-500 transition-colors resize-none"
|
||||
className="w-full rounded-lg bg-krona-workspace border border-krona-border p-3 text-sm text-krona-primary placeholder:text-krona-secondary/60 focus:outline-hidden focus:border-amber-500 transition-colors resize-none"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
{error && <p className="mt-1 text-xs text-rose-400 font-medium">{error}</p>}
|
||||
{error && <p className="mt-1 text-xs text-rose-500 font-medium">{error}</p>}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-3 pt-3 border-t border-zinc-800">
|
||||
{/* Actions with min 44px touch targets */}
|
||||
<div className="flex flex-col-reverse sm:flex-row items-center justify-end gap-2 pt-3 border-t border-krona-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
className="px-4 py-2 text-xs font-medium rounded-lg border border-zinc-700 text-zinc-300 hover:bg-zinc-800 transition-colors"
|
||||
className="w-full sm:w-auto min-h-[44px] px-4 py-2.5 text-xs font-medium rounded-lg border border-krona-border text-krona-secondary hover:text-krona-primary transition-colors flex items-center justify-center"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="px-4 py-2 text-xs font-bold rounded-lg bg-amber-500 hover:bg-amber-400 text-zinc-950 transition-colors flex items-center gap-1.5 shadow-md shadow-amber-900/30 disabled:opacity-50"
|
||||
className="w-full sm:w-auto min-h-[44px] px-5 py-2.5 text-xs font-bold rounded-lg bg-amber-500 hover:bg-amber-400 text-zinc-950 transition-colors flex items-center justify-center gap-2 shadow-xs disabled:opacity-50"
|
||||
>
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
<span>{isSubmitting ? 'Подтверждение...' : 'Подтвердить операцию'}</span>
|
||||
|
|
@ -153,3 +156,4 @@ export const ConfirmOperatorActionModal: React.FC = () => {
|
|||
|
||||
return <ConfirmOperatorActionDialog pendingModal={pendingModal} onClose={closeModal} />;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -33,6 +33,18 @@ import { ServerMonitorOutageBanner } from './ServerMonitorOutageBanner';
|
|||
import { useThemeStore, ThemeMode } from '../../../stores/theme';
|
||||
import { RU_ROLES, RU_THEMES, RU_UI } from '../locales/ru';
|
||||
|
||||
const NAV_DEFINITIONS = [
|
||||
{ to: '/control', label: 'Обзор', icon: LayoutDashboard, end: true },
|
||||
{ to: '/control/organizations', label: 'Организации', icon: Building2 },
|
||||
{ to: '/control/billing', label: 'Подписки и платежи', icon: CreditCard },
|
||||
{ to: '/control/modules', label: 'Каталог модулей', icon: Package },
|
||||
{ to: '/control/installations', label: 'Установки', icon: Smartphone },
|
||||
{ to: '/control/infrastructure', label: 'Серверы', icon: Server },
|
||||
{ to: '/control/announcements', label: 'Оповещения', icon: BellRing },
|
||||
{ to: '/control/incidents', label: 'Инциденты', icon: AlertTriangle },
|
||||
{ to: '/control/audit', label: 'Журнал аудита', icon: FileCheck2 },
|
||||
];
|
||||
|
||||
export const ControlCenterShell: React.FC = () => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
|
@ -56,27 +68,32 @@ export const ControlCenterShell: React.FC = () => {
|
|||
const [isMobileDrawerOpen, setIsMobileDrawerOpen] = useState(false);
|
||||
const [isTabletSidebarCollapsed, setIsTabletSidebarCollapsed] = useState(false);
|
||||
|
||||
// Close mobile drawer on route change (render phase pattern)
|
||||
const [prevPathname, setPrevPathname] = useState(location.pathname);
|
||||
if (prevPathname !== location.pathname) {
|
||||
setPrevPathname(location.pathname);
|
||||
setIsMobileDrawerOpen(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInitialized) {
|
||||
initialize();
|
||||
}
|
||||
}, [isInitialized, initialize]);
|
||||
|
||||
// UTC clock ticker
|
||||
useEffect(() => {
|
||||
const updateTime = () => {
|
||||
const now = new Date();
|
||||
setUtcTime(now.toISOString().replace('T', ' ').slice(0, 19) + ' UTC');
|
||||
setUtcTime(
|
||||
`${now.toUTCString().slice(17, 25)} UTC`
|
||||
);
|
||||
};
|
||||
updateTime();
|
||||
const interval = setInterval(updateTime, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// Close mobile drawer on route change
|
||||
useEffect(() => {
|
||||
setIsMobileDrawerOpen(false);
|
||||
}, [location.pathname]);
|
||||
|
||||
const handleReset = async () => {
|
||||
if (confirm('Сбросить демонстрационные данные платформы к исходному состоянию?')) {
|
||||
setIsResetting(true);
|
||||
|
|
@ -92,46 +109,33 @@ export const ControlCenterShell: React.FC = () => {
|
|||
let count = 0;
|
||||
if (serverMonitorOutage) count += 1;
|
||||
count += incidents.filter(
|
||||
(i) => i.status !== 'resolved' && (i.severity === 'P0_CRITICAL' || i.severity === 'P1_MAJOR')
|
||||
(i) => i.status !== 'resolved' && (i.severity === 'critical' || i.severity === 'high')
|
||||
).length;
|
||||
return count;
|
||||
}, [serverMonitorOutage, incidents]);
|
||||
|
||||
// Navigation Items
|
||||
const primaryMobileNav = [
|
||||
const primaryMobileNav = useMemo(() => [
|
||||
{ to: '/control', label: 'Обзор', icon: LayoutDashboard, end: true },
|
||||
{ to: '/control/organizations', label: 'Организации', icon: Building2 },
|
||||
{ to: '/control/incidents', label: 'Инциденты', icon: AlertTriangle, badge: criticalCount > 0 ? criticalCount : undefined },
|
||||
{ to: '/control/infrastructure', label: 'Серверы', icon: Server },
|
||||
];
|
||||
], [criticalCount]);
|
||||
|
||||
const allNavItems = [
|
||||
{ to: '/control', label: 'Обзор', icon: LayoutDashboard, end: true },
|
||||
{ to: '/control/organizations', label: 'Организации', icon: Building2 },
|
||||
{ to: '/control/billing', label: 'Подписки и платежи', icon: CreditCard },
|
||||
{ to: '/control/modules', label: 'Каталог модулей', icon: Package },
|
||||
{ to: '/control/installations', label: 'Установки', icon: Smartphone },
|
||||
{ to: '/control/infrastructure', label: 'Серверы', icon: Server },
|
||||
{ to: '/control/announcements', label: 'Оповещения', icon: BellRing },
|
||||
{ to: '/control/incidents', label: 'Инциденты', icon: AlertTriangle, badge: criticalCount > 0 ? criticalCount : undefined },
|
||||
{ to: '/control/audit', label: 'Журнал аудита', icon: FileCheck2 },
|
||||
];
|
||||
|
||||
const moreNavItems = [
|
||||
{ to: '/control/billing', label: 'Подписки и платежи', icon: CreditCard },
|
||||
{ to: '/control/modules', label: 'Каталог модулей', icon: Package },
|
||||
{ to: '/control/installations', label: 'Установки', icon: Smartphone },
|
||||
{ to: '/control/announcements', label: 'Оповещения', icon: BellRing },
|
||||
{ to: '/control/audit', label: 'Журнал аудита', icon: FileCheck2 },
|
||||
];
|
||||
const allNavItems = useMemo(() => {
|
||||
return NAV_DEFINITIONS.map((item) => ({
|
||||
...item,
|
||||
badge: item.to === '/control/incidents' && criticalCount > 0 ? criticalCount : undefined,
|
||||
}));
|
||||
}, [criticalCount]);
|
||||
|
||||
// Current section title
|
||||
const currentTitle = useMemo(() => {
|
||||
const current = allNavItems.find((item) =>
|
||||
const current = NAV_DEFINITIONS.find((item) =>
|
||||
item.end ? location.pathname === item.to : location.pathname.startsWith(item.to)
|
||||
);
|
||||
return current?.label || 'Control Center';
|
||||
}, [location.pathname, allNavItems]);
|
||||
}, [location.pathname]);
|
||||
|
||||
const currentRoleLabel = RU_ROLES[role]?.shortLabel || role;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useKccStore } from '../store/kccStore';
|
||||
import { ControlForbiddenPage } from '../pages/ControlForbiddenPage';
|
||||
|
||||
|
|
@ -17,6 +17,11 @@ interface ControlRouteGuardProps {
|
|||
|
||||
export const ControlRouteGuard: React.FC<ControlRouteGuardProps> = ({ children }) => {
|
||||
const activeRole = useKccStore((state) => state.activeRole);
|
||||
const isAccessGatePassed = useKccStore((state) => state.isAccessGatePassed);
|
||||
|
||||
if (!isAccessGatePassed) {
|
||||
return <Navigate to="/control/gate" replace />;
|
||||
}
|
||||
|
||||
if (!ALLOWED_OPERATOR_ROLES.includes(activeRole)) {
|
||||
return <ControlForbiddenPage />;
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { StatusBadge } from './StatusBadge';
|
|||
import { MoneyDisplay } from './MoneyDisplay';
|
||||
import { HeartbeatTimeline } from './HeartbeatTimeline';
|
||||
import { PlatformStatus } from '../contracts';
|
||||
import { RU_INCIDENT_SEVERITY, RU_PLATFORMS, RU_ROLES } from '../locales/ru';
|
||||
|
||||
export const InspectorDrawer: React.FC = () => {
|
||||
const inspectorTarget = useKccStore((state) => state.inspectorTarget);
|
||||
|
|
@ -45,7 +46,7 @@ export const InspectorDrawer: React.FC = () => {
|
|||
switch (inspectorTarget.type) {
|
||||
case 'organization': {
|
||||
const org = organizations.find((o) => o.tenantId === inspectorTarget.id);
|
||||
if (!org) return <div className="p-6 text-zinc-400">Организация не найдена</div>;
|
||||
if (!org) return <div className="p-6 text-krona-secondary">Организация не найдена</div>;
|
||||
|
||||
const sub = subscriptions.find((s) => s.tenantId === org.tenantId);
|
||||
const orgInstances = instances.filter((i) => i.tenantId === org.tenantId);
|
||||
|
|
@ -88,16 +89,16 @@ export const InspectorDrawer: React.FC = () => {
|
|||
<div>
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<StatusBadge status={org.platformStatus} />
|
||||
<span className="text-xs text-zinc-400 font-mono">ИНН: {org.inn}</span>
|
||||
<span className="text-xs text-krona-secondary font-mono">ИНН: {org.inn}</span>
|
||||
</div>
|
||||
<h2 className="text-lg font-bold text-zinc-100">{org.displayName}</h2>
|
||||
<p className="text-xs text-zinc-400 font-mono">{org.domain} • ID: {org.tenantId.slice(0, 8)}...</p>
|
||||
<h2 className="text-lg font-bold text-krona-primary">{org.displayName}</h2>
|
||||
<p className="text-xs text-krona-secondary font-mono">{org.domain} • ID: {org.tenantId.slice(0, 8)}...</p>
|
||||
</div>
|
||||
|
||||
{/* Operator Actions Bar */}
|
||||
<div className="rounded-xl bg-zinc-950/70 border border-zinc-800 p-3.5 space-y-3">
|
||||
<div className="text-xs font-semibold text-zinc-300 flex items-center gap-1.5">
|
||||
<ShieldAlert className="w-4 h-4 text-amber-400" />
|
||||
<div className="rounded-xl bg-krona-workspace/60 border border-krona-border p-3.5 space-y-3">
|
||||
<div className="text-xs font-semibold text-krona-primary flex items-center gap-1.5">
|
||||
<ShieldAlert className="w-4 h-4 text-amber-500" />
|
||||
<span>Действия оператора платформы</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
|
|
@ -105,7 +106,7 @@ export const InspectorDrawer: React.FC = () => {
|
|||
<button
|
||||
disabled={!permissions.canManagePlatformStatus}
|
||||
onClick={() => handleStatusChange('active')}
|
||||
className="px-2.5 py-1.5 rounded-lg text-xs font-medium bg-emerald-950/70 text-emerald-300 border border-emerald-700/50 hover:bg-emerald-900/50 disabled:opacity-40 transition-colors"
|
||||
className="min-h-[44px] px-3 py-2 rounded-lg text-xs font-medium bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/30 hover:bg-emerald-500/20 disabled:opacity-40 transition-colors flex items-center justify-center"
|
||||
>
|
||||
Активировать (Active)
|
||||
</button>
|
||||
|
|
@ -114,7 +115,7 @@ export const InspectorDrawer: React.FC = () => {
|
|||
<button
|
||||
disabled={!permissions.canGrantTrialGrace}
|
||||
onClick={() => handleStatusChange('grace')}
|
||||
className="px-2.5 py-1.5 rounded-lg text-xs font-medium bg-orange-950/70 text-orange-300 border border-orange-700/50 hover:bg-orange-900/50 disabled:opacity-40 transition-colors"
|
||||
className="min-h-[44px] px-3 py-2 rounded-lg text-xs font-medium bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/30 hover:bg-amber-500/20 disabled:opacity-40 transition-colors flex items-center justify-center"
|
||||
>
|
||||
Назначить Grace
|
||||
</button>
|
||||
|
|
@ -123,7 +124,7 @@ export const InspectorDrawer: React.FC = () => {
|
|||
<button
|
||||
disabled={!permissions.canManagePlatformStatus}
|
||||
onClick={() => handleStatusChange('suspended')}
|
||||
className="px-2.5 py-1.5 rounded-lg text-xs font-medium bg-rose-950/70 text-rose-300 border border-rose-700/50 hover:bg-rose-900/50 disabled:opacity-40 transition-colors"
|
||||
className="min-h-[44px] px-3 py-2 rounded-lg text-xs font-medium bg-rose-500/10 text-rose-600 dark:text-rose-400 border border-rose-500/30 hover:bg-rose-500/20 disabled:opacity-40 transition-colors flex items-center justify-center"
|
||||
>
|
||||
Заблокировать (Suspend)
|
||||
</button>
|
||||
|
|
@ -132,13 +133,13 @@ export const InspectorDrawer: React.FC = () => {
|
|||
|
||||
{/* Trial grant inline form */}
|
||||
{permissions.canGrantTrialGrace && (
|
||||
<div className="pt-2 border-t border-zinc-800/80 space-y-2 text-xs">
|
||||
<div className="font-semibold text-zinc-300">Выдать Trial на модуль:</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="pt-2 border-t border-krona-border space-y-2 text-xs">
|
||||
<div className="font-semibold text-krona-primary">Выдать Trial на модуль:</div>
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
<select
|
||||
value={selectedTrialModule}
|
||||
onChange={(e) => setSelectedTrialModule(e.target.value)}
|
||||
className="bg-zinc-900 border border-zinc-700 rounded-md px-2 py-1 text-zinc-200 text-xs focus:outline-hidden"
|
||||
className="min-h-[44px] bg-krona-surface border border-krona-border rounded-lg px-2.5 py-1.5 text-krona-primary text-xs focus:outline-hidden focus:ring-1 focus:ring-amber-500"
|
||||
>
|
||||
{modules.map((m) => (
|
||||
<option key={m.moduleId} value={m.moduleId}>
|
||||
|
|
@ -149,7 +150,7 @@ export const InspectorDrawer: React.FC = () => {
|
|||
<select
|
||||
value={trialDurationDays}
|
||||
onChange={(e) => setTrialDurationDays(Number(e.target.value))}
|
||||
className="bg-zinc-900 border border-zinc-700 rounded-md px-2 py-1 text-zinc-200 text-xs focus:outline-hidden"
|
||||
className="min-h-[44px] bg-krona-surface border border-krona-border rounded-lg px-2.5 py-1.5 text-krona-primary text-xs focus:outline-hidden focus:ring-1 focus:ring-amber-500"
|
||||
>
|
||||
<option value={7}>7 дней</option>
|
||||
<option value={14}>14 дней</option>
|
||||
|
|
@ -158,7 +159,7 @@ export const InspectorDrawer: React.FC = () => {
|
|||
<button
|
||||
type="button"
|
||||
onClick={handleGrantTrial}
|
||||
className="px-3 py-1 rounded-md bg-amber-500 hover:bg-amber-400 text-zinc-950 font-bold text-xs transition-colors shrink-0"
|
||||
className="min-h-[44px] px-3 py-2 rounded-lg bg-amber-500 hover:bg-amber-400 text-zinc-950 font-bold text-xs transition-colors shrink-0 flex items-center justify-center"
|
||||
>
|
||||
Выдать Trial
|
||||
</button>
|
||||
|
|
@ -168,7 +169,7 @@ export const InspectorDrawer: React.FC = () => {
|
|||
</div>
|
||||
|
||||
{/* Navigation Tabs */}
|
||||
<div className="flex items-center gap-1 border-b border-zinc-800 overflow-x-auto pb-1 text-xs">
|
||||
<div className="flex items-center gap-1 border-b border-krona-border overflow-x-auto pb-1 text-xs">
|
||||
{[
|
||||
{ id: 'overview', label: 'Обзор' },
|
||||
{ id: 'users', label: `Пользователи (${org.userCount})` },
|
||||
|
|
@ -181,10 +182,10 @@ export const InspectorDrawer: React.FC = () => {
|
|||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setOrgActiveTab(tab.id as typeof orgActiveTab)}
|
||||
className={`px-3 py-1.5 rounded-t-md font-medium whitespace-nowrap transition-colors ${
|
||||
className={`min-h-[44px] px-3 py-2 rounded-t-lg font-medium whitespace-nowrap transition-colors flex items-center ${
|
||||
orgActiveTab === tab.id
|
||||
? 'bg-zinc-800/80 text-amber-300 border-b-2 border-amber-400'
|
||||
: 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-900'
|
||||
? 'bg-amber-500/10 text-amber-500 border-b-2 border-amber-500'
|
||||
: 'text-krona-secondary hover:text-krona-primary hover:bg-krona-workspace'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
|
|
@ -196,57 +197,57 @@ export const InspectorDrawer: React.FC = () => {
|
|||
{orgActiveTab === 'overview' && (
|
||||
<div className="space-y-4 text-xs">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="p-3 rounded-lg bg-zinc-950/60 border border-zinc-800/80">
|
||||
<div className="text-zinc-400 mb-1">Тарифный план</div>
|
||||
<div className="font-semibold text-zinc-200 text-sm">{org.tariffPlan}</div>
|
||||
<div className="p-3 rounded-lg bg-krona-workspace/60 border border-krona-border">
|
||||
<div className="text-krona-secondary mb-1">Тарифный план</div>
|
||||
<div className="font-semibold text-krona-primary text-sm">{org.tariffPlan}</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-zinc-950/60 border border-zinc-800/80">
|
||||
<div className="text-zinc-400 mb-1">MRR доход</div>
|
||||
<MoneyDisplay minorUnits={org.mrrMinorUnits} className="text-sm font-bold text-amber-400" />
|
||||
<div className="p-3 rounded-lg bg-krona-workspace/60 border border-krona-border">
|
||||
<div className="text-krona-secondary mb-1">MRR доход</div>
|
||||
<MoneyDisplay minorUnits={org.mrrMinorUnits} className="text-sm font-bold text-amber-500" />
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-zinc-950/60 border border-zinc-800/80">
|
||||
<div className="text-zinc-400 mb-1">Филиалов / точек</div>
|
||||
<div className="font-semibold text-zinc-200 text-sm">{org.branchCount}</div>
|
||||
<div className="p-3 rounded-lg bg-krona-workspace/60 border border-krona-border">
|
||||
<div className="text-krona-secondary mb-1">Филиалов / точек</div>
|
||||
<div className="font-semibold text-krona-primary text-sm">{org.branchCount}</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-zinc-950/60 border border-zinc-800/80">
|
||||
<div className="text-zinc-400 mb-1">Задолженность</div>
|
||||
<div className="p-3 rounded-lg bg-krona-workspace/60 border border-krona-border">
|
||||
<div className="text-krona-secondary mb-1">Задолженность</div>
|
||||
<MoneyDisplay
|
||||
minorUnits={org.outstandingDebtMinorUnits}
|
||||
className={`text-sm font-bold ${org.outstandingDebtMinorUnits > 0 ? 'text-rose-400' : 'text-emerald-400'}`}
|
||||
className={`text-sm font-bold ${org.outstandingDebtMinorUnits > 0 ? 'text-rose-500' : 'text-emerald-500'}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-zinc-950/60 border border-zinc-800 p-3 space-y-2">
|
||||
<div className="font-semibold text-zinc-300">Контакты организации:</div>
|
||||
<div className="text-zinc-400">Email: <span className="text-zinc-200">{org.contactEmail}</span></div>
|
||||
<div className="text-zinc-400">Телефон: <span className="text-zinc-200">{org.contactPhone}</span></div>
|
||||
<div className="text-zinc-400">Последняя активность: <span className="text-zinc-200">{new Date(org.lastActiveAt).toLocaleString('ru-RU')}</span></div>
|
||||
<div className="text-zinc-400">Окончание подписки: <span className="text-zinc-200">{new Date(org.subscriptionEndAt).toLocaleDateString('ru-RU')}</span></div>
|
||||
<div className="rounded-lg bg-krona-workspace/60 border border-krona-border p-3 space-y-2">
|
||||
<div className="font-semibold text-krona-primary">Контакты организации:</div>
|
||||
<div className="text-krona-secondary">Email: <span className="text-krona-primary">{org.contactEmail}</span></div>
|
||||
<div className="text-krona-secondary">Телефон: <span className="text-krona-primary">{org.contactPhone}</span></div>
|
||||
<div className="text-krona-secondary">Последняя активность: <span className="text-krona-primary">{new Date(org.lastActiveAt).toLocaleString('ru-RU')}</span></div>
|
||||
<div className="text-krona-secondary">Окончание подписки: <span className="text-krona-primary">{new Date(org.subscriptionEndAt).toLocaleDateString('ru-RU')}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{orgActiveTab === 'users' && (
|
||||
<div className="space-y-3 text-xs">
|
||||
<div className="p-3 rounded-lg bg-zinc-950/60 border border-zinc-800">
|
||||
<div className="p-3 rounded-lg bg-krona-workspace/60 border border-krona-border">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="font-semibold text-zinc-200">Пользователи-агрегаты</span>
|
||||
<span className="text-amber-400 font-bold">{org.userCount} учетных записей</span>
|
||||
<span className="font-semibold text-krona-primary">Пользователи-агрегаты</span>
|
||||
<span className="text-amber-500 font-bold">{org.userCount} учетных записей</span>
|
||||
</div>
|
||||
<p className="text-zinc-400 text-[11px] leading-relaxed">
|
||||
<p className="text-krona-secondary text-[11px] leading-relaxed">
|
||||
В соответствии с контрактом безопасности Control Center отображает только деперсонализированные
|
||||
агрегаты тенанта. Персональные данные сотрудников защищены контуром организации.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-zinc-300">
|
||||
<div className="p-2.5 rounded-lg bg-zinc-900 border border-zinc-800">
|
||||
<div className="text-zinc-400">Администраторы</div>
|
||||
<div className="font-bold text-sm text-zinc-100">2</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-krona-primary">
|
||||
<div className="p-2.5 rounded-lg bg-krona-workspace border border-krona-border">
|
||||
<div className="text-krona-secondary">Администраторы</div>
|
||||
<div className="font-bold text-sm text-krona-primary">2</div>
|
||||
</div>
|
||||
<div className="p-2.5 rounded-lg bg-zinc-900 border border-zinc-800">
|
||||
<div className="text-zinc-400">Менеджеры / операторы</div>
|
||||
<div className="font-bold text-sm text-zinc-100">{Math.max(0, org.userCount - 6)}</div>
|
||||
<div className="p-2.5 rounded-lg bg-krona-workspace border border-krona-border">
|
||||
<div className="text-krona-secondary">Менеджеры / операторы</div>
|
||||
<div className="font-bold text-sm text-krona-primary">{Math.max(0, org.userCount - 6)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -255,10 +256,10 @@ export const InspectorDrawer: React.FC = () => {
|
|||
{orgActiveTab === 'modules' && (
|
||||
<div className="space-y-2 text-xs">
|
||||
{orgInstances.map((inst) => (
|
||||
<div key={inst.moduleInstanceId} className="p-3 rounded-lg bg-zinc-950/60 border border-zinc-800 flex items-center justify-between">
|
||||
<div key={inst.moduleInstanceId} className="p-3 rounded-lg bg-krona-workspace/60 border border-krona-border flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-semibold text-zinc-200">{inst.moduleName}</div>
|
||||
<div className="text-[11px] text-zinc-400 font-mono mt-0.5">
|
||||
<div className="font-semibold text-krona-primary">{inst.moduleName}</div>
|
||||
<div className="text-[11px] text-krona-secondary font-mono mt-0.5">
|
||||
v{inst.currentVersion} • {inst.serverName}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -271,46 +272,46 @@ export const InspectorDrawer: React.FC = () => {
|
|||
{orgActiveTab === 'installations' && (
|
||||
<div className="space-y-2 text-xs">
|
||||
{orgInstalls.map((ins) => (
|
||||
<div key={ins.installationId} className="p-3 rounded-lg bg-zinc-950/60 border border-zinc-800 flex items-center justify-between">
|
||||
<div key={ins.installationId} className="p-3 rounded-lg bg-krona-workspace/60 border border-krona-border flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-semibold text-zinc-200 flex items-center gap-1.5">
|
||||
<Smartphone className="w-3.5 h-3.5 text-zinc-400" />
|
||||
<div className="font-semibold text-krona-primary flex items-center gap-1.5">
|
||||
<Smartphone className="w-3.5 h-3.5 text-krona-secondary" />
|
||||
<span>{ins.deviceName}</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-zinc-400 font-mono mt-0.5">
|
||||
{ins.kind.toUpperCase()} • v{ins.appVersion} • {ins.osVersion}
|
||||
<div className="text-[11px] text-krona-secondary font-mono mt-0.5">
|
||||
{RU_PLATFORMS[ins.kind] || ins.kind} • v{ins.appVersion} • {ins.osVersion}
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={ins.status} size="sm" />
|
||||
</div>
|
||||
))}
|
||||
{orgInstalls.length === 0 && <div className="text-zinc-500 py-4 text-center">Нет активных установок</div>}
|
||||
{orgInstalls.length === 0 && <div className="text-krona-secondary py-4 text-center">Нет активных установок</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{orgActiveTab === 'billing' && (
|
||||
<div className="space-y-3 text-xs">
|
||||
{sub ? (
|
||||
<div className="p-3 rounded-lg bg-zinc-950/60 border border-zinc-800 space-y-2">
|
||||
<div className="p-3 rounded-lg bg-krona-workspace/60 border border-krona-border space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-semibold text-zinc-200">Тарифная подписка</span>
|
||||
<span className="font-semibold text-krona-primary">Тарифная подписка</span>
|
||||
<StatusBadge status={sub.status} size="sm" />
|
||||
</div>
|
||||
<div className="flex justify-between text-zinc-400">
|
||||
<div className="flex justify-between text-krona-secondary">
|
||||
<span>Период:</span>
|
||||
<span className="text-zinc-200">{sub.period === 'year' ? 'Годовой' : 'Ежемесячный'}</span>
|
||||
<span className="text-krona-primary">{sub.period === 'year' ? 'Годовой' : 'Ежемесячный'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-zinc-400">
|
||||
<div className="flex justify-between text-krona-secondary">
|
||||
<span>Сумма тарифа:</span>
|
||||
<MoneyDisplay minorUnits={sub.amountMinorUnits} className="text-amber-400 font-bold" />
|
||||
<MoneyDisplay minorUnits={sub.amountMinorUnits} className="text-amber-500 font-bold" />
|
||||
</div>
|
||||
<div className="flex justify-between text-zinc-400">
|
||||
<div className="flex justify-between text-krona-secondary">
|
||||
<span>Действует до:</span>
|
||||
<span className="text-zinc-200">{new Date(sub.validUntil).toLocaleDateString('ru-RU')}</span>
|
||||
<span className="text-krona-primary">{new Date(sub.validUntil).toLocaleDateString('ru-RU')}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-zinc-500 py-4 text-center">Данные подписки отсутствуют</div>
|
||||
<div className="text-krona-secondary py-4 text-center">Данные подписки отсутствуют</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -318,31 +319,31 @@ export const InspectorDrawer: React.FC = () => {
|
|||
{orgActiveTab === 'incidents' && (
|
||||
<div className="space-y-2 text-xs">
|
||||
{orgIncidents.map((inc) => (
|
||||
<div key={inc.incidentId} className="p-3 rounded-lg bg-zinc-950/60 border border-zinc-800 space-y-1.5">
|
||||
<div key={inc.incidentId} className="p-3 rounded-lg bg-krona-workspace/60 border border-krona-border space-y-1.5">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-bold text-zinc-200 font-mono">{inc.incidentNumber}</span>
|
||||
<span className="font-bold text-krona-primary font-mono">{inc.incidentNumber}</span>
|
||||
<StatusBadge status={inc.status} size="sm" />
|
||||
</div>
|
||||
<p className="text-zinc-300">{inc.summary}</p>
|
||||
<p className="text-krona-primary">{inc.summary}</p>
|
||||
</div>
|
||||
))}
|
||||
{orgIncidents.length === 0 && <div className="text-zinc-500 py-4 text-center">Инцидентов нет</div>}
|
||||
{orgIncidents.length === 0 && <div className="text-krona-secondary py-4 text-center">Инцидентов нет</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{orgActiveTab === 'audit' && (
|
||||
<div className="space-y-2 text-xs">
|
||||
{orgAudits.map((a) => (
|
||||
<div key={a.eventId} className="p-2.5 rounded-lg bg-zinc-950/60 border border-zinc-800 space-y-1">
|
||||
<div className="flex justify-between text-zinc-400 font-mono text-[10px]">
|
||||
<div key={a.eventId} className="p-2.5 rounded-lg bg-krona-workspace/60 border border-krona-border space-y-1">
|
||||
<div className="flex justify-between text-krona-secondary font-mono text-[10px]">
|
||||
<span>{a.action}</span>
|
||||
<span>{new Date(a.occurredAt).toLocaleTimeString('ru-RU')}</span>
|
||||
</div>
|
||||
<p className="text-zinc-200 text-[11px]">{a.reason}</p>
|
||||
<div className="text-[10px] text-amber-400/80">{a.operatorName} ({a.operatorRole})</div>
|
||||
<p className="text-krona-primary text-[11px]">{a.reason}</p>
|
||||
<div className="text-[10px] text-amber-500">{a.operatorName} ({RU_ROLES[a.operatorRole]?.shortLabel || a.operatorRole})</div>
|
||||
</div>
|
||||
))}
|
||||
{orgAudits.length === 0 && <div className="text-zinc-500 py-4 text-center">Записей аудита нет</div>}
|
||||
{orgAudits.length === 0 && <div className="text-krona-secondary py-4 text-center">Записей аудита нет</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -351,7 +352,7 @@ export const InspectorDrawer: React.FC = () => {
|
|||
|
||||
case 'server': {
|
||||
const srv = servers.find((s) => s.serverId === inspectorTarget.id);
|
||||
if (!srv) return <div className="p-6 text-zinc-400">Сервер не найден</div>;
|
||||
if (!srv) return <div className="p-6 text-krona-secondary">Сервер не найден</div>;
|
||||
|
||||
const srvInstances = instances.filter((i) => i.serverId === srv.serverId);
|
||||
|
||||
|
|
@ -388,23 +389,23 @@ export const InspectorDrawer: React.FC = () => {
|
|||
<div>
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<StatusBadge status={srv.status} />
|
||||
<span className="text-xs text-zinc-400 font-mono">{srv.hostingProvider} • {srv.region}</span>
|
||||
<span className="text-xs text-krona-secondary font-mono">{srv.hostingProvider} • {srv.region}</span>
|
||||
</div>
|
||||
<h2 className="text-lg font-bold text-zinc-100">{srv.name}</h2>
|
||||
<p className="text-xs text-zinc-400 font-mono">{srv.ipAddress} • {srv.hostname}</p>
|
||||
<h2 className="text-lg font-bold text-krona-primary">{srv.name}</h2>
|
||||
<p className="text-xs text-krona-secondary font-mono">{srv.ipAddress} • {srv.hostname}</p>
|
||||
</div>
|
||||
|
||||
{/* Operator Actions */}
|
||||
<div className="rounded-xl bg-zinc-950/70 border border-zinc-800 p-3.5 space-y-2.5">
|
||||
<div className="text-xs font-semibold text-zinc-300 flex items-center gap-1.5">
|
||||
<ShieldAlert className="w-4 h-4 text-amber-400" />
|
||||
<div className="rounded-xl bg-krona-workspace/60 border border-krona-border p-3.5 space-y-2.5">
|
||||
<div className="text-xs font-semibold text-krona-primary flex items-center gap-1.5">
|
||||
<ShieldAlert className="w-4 h-4 text-amber-500" />
|
||||
<span>Операции с инфраструктурным узлом</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
disabled={!permissions.canRequestDiagnostics}
|
||||
onClick={handleDiagnostics}
|
||||
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-zinc-800 text-zinc-200 hover:bg-zinc-700 disabled:opacity-40 transition-colors flex items-center gap-1.5"
|
||||
className="min-h-[44px] px-3 py-2 rounded-lg text-xs font-medium bg-krona-workspace text-krona-primary hover:bg-krona-surface border border-krona-border disabled:opacity-40 transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
<Activity className="w-3.5 h-3.5 text-sky-400" />
|
||||
<span>Запросить диагностику</span>
|
||||
|
|
@ -412,9 +413,9 @@ export const InspectorDrawer: React.FC = () => {
|
|||
<button
|
||||
disabled={!permissions.canScheduleMaintenance}
|
||||
onClick={handleMaintenance}
|
||||
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-amber-950/60 text-amber-300 border border-amber-700/50 hover:bg-amber-900/40 disabled:opacity-40 transition-colors flex items-center gap-1.5"
|
||||
className="min-h-[44px] px-3 py-2 rounded-lg text-xs font-medium bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/30 hover:bg-amber-500/20 disabled:opacity-40 transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
<Clock className="w-3.5 h-3.5 text-amber-400" />
|
||||
<Clock className="w-3.5 h-3.5 text-amber-500" />
|
||||
<span>Запланировать ТО</span>
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -422,18 +423,18 @@ export const InspectorDrawer: React.FC = () => {
|
|||
|
||||
{/* Telemetry Metrics */}
|
||||
<div className="space-y-3">
|
||||
<div className="font-semibold text-zinc-300 flex items-center justify-between">
|
||||
<div className="font-semibold text-krona-primary flex items-center justify-between">
|
||||
<span>Телеметрия узла (Server Monitor Agent):</span>
|
||||
<span className="text-[11px] text-zinc-400 font-mono">Agent v{srv.agentVersion} ({srv.agentStatus})</span>
|
||||
<span className="text-[11px] text-krona-secondary font-mono">Agent v{srv.agentVersion} ({srv.agentStatus})</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="p-3 rounded-lg bg-zinc-950 border border-zinc-800">
|
||||
<div className="flex justify-between text-zinc-400 mb-1">
|
||||
<div className="p-3 rounded-lg bg-krona-workspace/60 border border-krona-border">
|
||||
<div className="flex justify-between text-krona-secondary mb-1">
|
||||
<span>CPU Нагрузка:</span>
|
||||
<span className="font-bold text-zinc-100">{srv.cpuLoadPercent}%</span>
|
||||
<span className="font-bold text-krona-primary">{srv.cpuLoadPercent}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-zinc-800 h-1.5 rounded-full overflow-hidden">
|
||||
<div className="w-full bg-krona-surface h-1.5 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${srv.cpuLoadPercent > 80 ? 'bg-rose-500' : 'bg-emerald-500'}`}
|
||||
style={{ width: `${srv.cpuLoadPercent}%` }}
|
||||
|
|
@ -441,14 +442,14 @@ export const InspectorDrawer: React.FC = () => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 rounded-lg bg-zinc-950 border border-zinc-800">
|
||||
<div className="flex justify-between text-zinc-400 mb-1">
|
||||
<div className="p-3 rounded-lg bg-krona-workspace/60 border border-krona-border">
|
||||
<div className="flex justify-between text-krona-secondary mb-1">
|
||||
<span>RAM Память:</span>
|
||||
<span className="font-bold text-zinc-100">
|
||||
<span className="font-bold text-krona-primary">
|
||||
{Math.round(srv.ramUsedBytes / (1024 * 1024 * 1024))} / {Math.round(srv.ramTotalBytes / (1024 * 1024 * 1024))} GB
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-zinc-800 h-1.5 rounded-full overflow-hidden">
|
||||
<div className="w-full bg-krona-surface h-1.5 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-emerald-500"
|
||||
style={{ width: `${(srv.ramUsedBytes / srv.ramTotalBytes) * 100}%` }}
|
||||
|
|
@ -456,60 +457,60 @@ export const InspectorDrawer: React.FC = () => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 rounded-lg bg-zinc-950 border border-zinc-800">
|
||||
<div className="flex justify-between text-zinc-400 mb-1">
|
||||
<div className="p-3 rounded-lg bg-krona-workspace/60 border border-krona-border">
|
||||
<div className="flex justify-between text-krona-secondary mb-1">
|
||||
<span>Диск:</span>
|
||||
<span className="font-bold text-zinc-100">
|
||||
<span className="font-bold text-krona-primary">
|
||||
{Math.round(srv.diskUsedBytes / (1024 * 1024 * 1024))} / {Math.round(srv.diskTotalBytes / (1024 * 1024 * 1024))} GB
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-zinc-800 h-1.5 rounded-full overflow-hidden">
|
||||
<div className="w-full bg-krona-surface h-1.5 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${srv.diskUsedBytes / srv.diskTotalBytes > 0.85 ? 'bg-rose-500' : 'bg-amber-400'}`}
|
||||
className={`h-full ${srv.diskUsedBytes / srv.diskTotalBytes > 0.85 ? 'bg-rose-500' : 'bg-amber-500'}`}
|
||||
style={{ width: `${(srv.diskUsedBytes / srv.diskTotalBytes) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 rounded-lg bg-zinc-950 border border-zinc-800">
|
||||
<div className="text-zinc-400 mb-1">Сеть (Rx / Tx):</div>
|
||||
<div className="font-mono text-zinc-200">
|
||||
<div className="p-3 rounded-lg bg-krona-workspace/60 border border-krona-border">
|
||||
<div className="text-krona-secondary mb-1">Сеть (Rx / Tx):</div>
|
||||
<div className="font-mono text-krona-primary">
|
||||
{(srv.networkRxBytesPerSec / 1024 / 1024).toFixed(1)} / {(srv.networkTxBytesPerSec / 1024 / 1024).toFixed(1)} MB/s
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Heartbeat timeline */}
|
||||
<div className="p-3 rounded-lg bg-zinc-950 border border-zinc-800 space-y-2">
|
||||
<div className="p-3 rounded-lg bg-krona-workspace/60 border border-krona-border space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-semibold text-zinc-300">Пульс узла (Heartbeat 24h):</span>
|
||||
<span className="text-[11px] text-zinc-400 font-mono">RTT: {srv.latencyMs}ms</span>
|
||||
<span className="font-semibold text-krona-primary">Пульс узла (Heartbeat 24h):</span>
|
||||
<span className="text-[11px] text-krona-secondary font-mono">RTT: {srv.latencyMs}ms</span>
|
||||
</div>
|
||||
<HeartbeatTimeline status={srv.status} count={24} />
|
||||
</div>
|
||||
|
||||
{/* Backup & Restore Drill */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="p-3 rounded-lg bg-zinc-950 border border-zinc-800 space-y-1">
|
||||
<div className="font-semibold text-zinc-300 flex items-center justify-between">
|
||||
<span>Бэкап:</span>
|
||||
<StatusBadge status={srv.backup.state === 'ok' ? 'healthy' : srv.backup.state === 'failed' ? 'critical' : 'warning'} size="sm" customLabel={srv.backup.state.toUpperCase()} />
|
||||
<div className="p-3 rounded-lg bg-krona-workspace/60 border border-krona-border space-y-1">
|
||||
<div className="font-semibold text-krona-primary flex items-center justify-between">
|
||||
<span>Резервная копия:</span>
|
||||
<StatusBadge status={srv.backup.state === 'ok' ? 'healthy' : srv.backup.state === 'failed' ? 'critical' : 'warning'} size="sm" customLabel={srv.backup.state === 'ok' ? 'УСПЕШНО' : srv.backup.state === 'failed' ? 'ОШИБКА' : 'В ПРОЦЕССЕ'} />
|
||||
</div>
|
||||
<div className="text-zinc-400 text-[11px]">
|
||||
<div className="text-krona-secondary text-[11px]">
|
||||
{srv.backup.lastSuccessfulAt ? new Date(srv.backup.lastSuccessfulAt).toLocaleString('ru-RU') : 'Нет успешных копий'}
|
||||
</div>
|
||||
<div className="text-zinc-500 text-[10px]">{srv.backup.targetProvider}</div>
|
||||
<div className="text-krona-secondary text-[10px]">{srv.backup.targetProvider}</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 rounded-lg bg-zinc-950 border border-zinc-800 space-y-1">
|
||||
<div className="font-semibold text-zinc-300 flex items-center justify-between">
|
||||
<span>Restore Drill:</span>
|
||||
<StatusBadge status={srv.restoreDrill.state === 'passed' ? 'healthy' : srv.restoreDrill.state === 'failed' ? 'critical' : 'warning'} size="sm" customLabel={srv.restoreDrill.state.toUpperCase()} />
|
||||
<div className="p-3 rounded-lg bg-krona-workspace/60 border border-krona-border space-y-1">
|
||||
<div className="font-semibold text-krona-primary flex items-center justify-between">
|
||||
<span>Тест восстановления:</span>
|
||||
<StatusBadge status={srv.restoreDrill.state === 'passed' ? 'healthy' : srv.restoreDrill.state === 'failed' ? 'critical' : 'warning'} size="sm" customLabel={srv.restoreDrill.state === 'passed' ? 'ПРОЙДЕН' : srv.restoreDrill.state === 'failed' ? 'СБОЙ' : 'ОЖИДАНИЕ'} />
|
||||
</div>
|
||||
<div className="text-zinc-400 text-[11px]">
|
||||
<div className="text-krona-secondary text-[11px]">
|
||||
{srv.restoreDrill.verifiedAt ? new Date(srv.restoreDrill.verifiedAt).toLocaleDateString('ru-RU') : 'Никогда / Устарел'}
|
||||
</div>
|
||||
<div className="text-zinc-500 text-[10px]">
|
||||
<div className="text-krona-secondary text-[10px]">
|
||||
{srv.restoreDrill.drillDurationSeconds ? `Время восстановления: ${Math.round(srv.restoreDrill.drillDurationSeconds / 60)} мин.` : 'Тест не проводился'}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -517,12 +518,12 @@ export const InspectorDrawer: React.FC = () => {
|
|||
|
||||
{/* Module instances on server */}
|
||||
<div className="space-y-2">
|
||||
<div className="font-semibold text-zinc-300">Модули на узле ({srvInstances.length}):</div>
|
||||
<div className="font-semibold text-krona-primary">Модули на узле ({srvInstances.length}):</div>
|
||||
{srvInstances.map((inst) => (
|
||||
<div key={inst.moduleInstanceId} className="p-2.5 rounded-lg bg-zinc-950 border border-zinc-800 flex justify-between items-center">
|
||||
<div key={inst.moduleInstanceId} className="p-2.5 rounded-lg bg-krona-workspace/60 border border-krona-border flex justify-between items-center">
|
||||
<div>
|
||||
<div className="font-semibold text-zinc-200">{inst.moduleName}</div>
|
||||
<div className="text-zinc-400 text-[11px]">{inst.tenantName}</div>
|
||||
<div className="font-semibold text-krona-primary">{inst.moduleName}</div>
|
||||
<div className="text-krona-secondary text-[11px]">{inst.tenantName}</div>
|
||||
</div>
|
||||
<StatusBadge status={inst.health} size="sm" />
|
||||
</div>
|
||||
|
|
@ -535,7 +536,7 @@ export const InspectorDrawer: React.FC = () => {
|
|||
|
||||
case 'incident': {
|
||||
const inc = incidents.find((i) => i.incidentId === inspectorTarget.id);
|
||||
if (!inc) return <div className="p-6 text-zinc-400">Инцидент не найден</div>;
|
||||
if (!inc) return <div className="p-6 text-krona-secondary">Инцидент не найден</div>;
|
||||
|
||||
const handleResolve = () => {
|
||||
openConfirmModal({
|
||||
|
|
@ -556,47 +557,47 @@ export const InspectorDrawer: React.FC = () => {
|
|||
<div>
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<StatusBadge status={inc.status} />
|
||||
<span className="font-bold text-amber-400 uppercase tracking-wider">{inc.severity} Severity</span>
|
||||
<span className="font-bold text-amber-500 uppercase tracking-wider">{RU_INCIDENT_SEVERITY[inc.severity]?.label || inc.severity}</span>
|
||||
</div>
|
||||
<h2 className="text-lg font-bold text-zinc-100">{inc.incidentNumber}</h2>
|
||||
<p className="text-sm text-zinc-200 mt-1 font-medium">{inc.summary}</p>
|
||||
<h2 className="text-lg font-bold text-krona-primary">{inc.incidentNumber}</h2>
|
||||
<p className="text-sm text-krona-primary mt-1 font-medium">{inc.summary}</p>
|
||||
</div>
|
||||
|
||||
{inc.status !== 'resolved' && (
|
||||
<div className="p-3.5 rounded-xl bg-zinc-950 border border-zinc-800 flex justify-between items-center">
|
||||
<div className="p-3.5 rounded-xl bg-krona-workspace/60 border border-krona-border flex flex-wrap gap-2 justify-between items-center">
|
||||
<div>
|
||||
<div className="font-semibold text-zinc-200">Действие оператора:</div>
|
||||
<div className="text-zinc-400 text-[11px]">Отметить инцидент как устраненный с обоснованием</div>
|
||||
<div className="font-semibold text-krona-primary">Действие оператора:</div>
|
||||
<div className="text-krona-secondary text-[11px]">Отметить инцидент как устраненный с обоснованием</div>
|
||||
</div>
|
||||
<button
|
||||
disabled={!permissions.canManageIncidents}
|
||||
onClick={handleResolve}
|
||||
className="px-3 py-1.5 rounded-lg bg-emerald-600 hover:bg-emerald-500 text-zinc-950 font-bold text-xs transition-colors disabled:opacity-40"
|
||||
className="min-h-[44px] px-4 py-2 rounded-lg bg-emerald-600 hover:bg-emerald-500 text-zinc-950 font-bold text-xs transition-colors disabled:opacity-40 flex items-center justify-center"
|
||||
>
|
||||
Разрешить инцидент
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-lg bg-zinc-950 p-3.5 border border-zinc-800 space-y-2">
|
||||
<div className="font-semibold text-zinc-300">Описание инцидента:</div>
|
||||
<p className="text-zinc-300 leading-relaxed">{inc.description}</p>
|
||||
<div className="text-zinc-400 pt-2 border-t border-zinc-800">
|
||||
Ответственный: <span className="text-zinc-200">{inc.ownerName}</span>
|
||||
<div className="rounded-lg bg-krona-workspace/60 p-3.5 border border-krona-border space-y-2">
|
||||
<div className="font-semibold text-krona-primary">Описание инцидента:</div>
|
||||
<p className="text-krona-primary leading-relaxed">{inc.description}</p>
|
||||
<div className="text-krona-secondary pt-2 border-t border-krona-border">
|
||||
Ответственный: <span className="text-krona-primary">{inc.ownerName}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="font-semibold text-zinc-300">Хронология расследования (Timeline):</div>
|
||||
<div className="space-y-2 relative border-l border-zinc-800 ml-2 pl-3">
|
||||
<div className="font-semibold text-krona-primary">Хронология расследования (Timeline):</div>
|
||||
<div className="space-y-2 relative border-l border-krona-border ml-2 pl-3">
|
||||
{inc.timeline.map((step, idx) => (
|
||||
<div key={idx} className="relative space-y-1">
|
||||
<div className="absolute -left-[17px] top-1 w-2 h-2 rounded-full bg-amber-400" />
|
||||
<div className="flex justify-between text-zinc-400 text-[10px]">
|
||||
<span className="font-bold text-zinc-300">{step.author}</span>
|
||||
<div className="absolute -left-[17px] top-1 w-2 h-2 rounded-full bg-amber-500" />
|
||||
<div className="flex justify-between text-krona-secondary text-[10px]">
|
||||
<span className="font-bold text-krona-primary">{step.author}</span>
|
||||
<span>{new Date(step.timestamp).toLocaleTimeString('ru-RU')}</span>
|
||||
</div>
|
||||
<p className="text-zinc-300">{step.message}</p>
|
||||
<p className="text-krona-primary">{step.message}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -606,34 +607,41 @@ export const InspectorDrawer: React.FC = () => {
|
|||
}
|
||||
|
||||
default:
|
||||
return <div className="p-6 text-zinc-400">Детальная информация для данного типа пока формируется</div>;
|
||||
return <div className="p-6 text-krona-secondary">Детальная информация для данного типа пока формируется</div>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-y-0 right-0 z-40 w-full max-w-xl bg-[#0d1e17] border-l border-zinc-800 shadow-2xl flex flex-col text-zinc-100 animate-in slide-in-from-right duration-200"
|
||||
role="complementary"
|
||||
aria-label="Контекстный инспектор"
|
||||
>
|
||||
{/* Drawer Header */}
|
||||
<div className="px-6 py-4 border-b border-zinc-800 flex items-center justify-between bg-zinc-950/60">
|
||||
<div className="flex items-center gap-2 text-xs font-semibold text-zinc-400 uppercase tracking-wider">
|
||||
<Activity className="w-4 h-4 text-amber-400" />
|
||||
<span>Контекстный инспектор</span>
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/60 backdrop-blur-xs transition-opacity"
|
||||
onClick={closeInspector}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
className="fixed inset-y-0 right-0 z-50 w-full sm:max-w-xl bg-krona-surface border-l border-krona-border shadow-2xl flex flex-col text-krona-primary animate-in slide-in-from-right duration-200"
|
||||
role="complementary"
|
||||
aria-label="Контекстный инспектор"
|
||||
>
|
||||
{/* Drawer Header */}
|
||||
<div className="px-5 md:px-6 py-4 border-b border-krona-border flex items-center justify-between bg-krona-workspace/80">
|
||||
<div className="flex items-center gap-2 text-xs font-semibold text-krona-secondary uppercase tracking-wider">
|
||||
<Activity className="w-4 h-4 text-amber-500" />
|
||||
<span>Контекстный инспектор объекта</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeInspector}
|
||||
className="min-w-[44px] min-h-[44px] rounded-lg flex items-center justify-center text-krona-secondary hover:bg-krona-workspace hover:text-krona-primary transition-colors"
|
||||
aria-label="Закрыть инспектор"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeInspector}
|
||||
className="rounded-lg p-1.5 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200 transition-colors"
|
||||
aria-label="Закрыть инспектор"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Drawer Body */}
|
||||
<div className="flex-1 overflow-y-auto p-6">{renderContent()}</div>
|
||||
</div>
|
||||
{/* Drawer Body */}
|
||||
<div className="flex-1 overflow-y-auto p-4 md:p-6">{renderContent()}</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@ export const StatusBadge: React.FC<StatusBadgeProps> = ({ status, customLabel, s
|
|||
icon: <CheckCircle2 className={iconSize} aria-hidden="true" />,
|
||||
},
|
||||
degraded: {
|
||||
label: 'Работает с ограничениями',
|
||||
label: 'Деградация',
|
||||
className: 'bg-status-warning-soft text-status-warning-text border-status-warning-border',
|
||||
icon: <AlertTriangle className={iconSize} aria-hidden="true" />,
|
||||
},
|
||||
|
|
@ -220,9 +220,10 @@ export const StatusBadge: React.FC<StatusBadgeProps> = ({ status, customLabel, s
|
|||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 font-medium transition-colors ${
|
||||
isSmall ? 'text-[11px] py-0.5 px-2' : 'text-xs'
|
||||
isSmall ? 'text-xs py-0.5 px-2' : 'text-xs'
|
||||
} ${current.className}`}
|
||||
data-testid={`status-badge-${status}`}
|
||||
data-testid="status-badge"
|
||||
data-status={status}
|
||||
>
|
||||
{current.icon}
|
||||
<span>{displayText}</span>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,10 @@
|
|||
import type { OperatorRole, ServerStatus, SubscriptionStatus, IncidentSeverity, IncidentStatus, AuditActionType } from '../contracts';
|
||||
import type { OperatorRole } from '../contracts';
|
||||
|
||||
export type ServerStatus = 'healthy' | 'warning' | 'critical' | 'offline' | 'degraded';
|
||||
export type SubscriptionStatus = 'normal' | 'expiring_7d' | 'expiring_30d' | 'overdue' | 'grace' | 'suspended' | 'trial' | 'active' | 'canceled';
|
||||
export type IncidentSeverity = 'low' | 'medium' | 'high' | 'critical' | 'P0_CRITICAL' | 'P1_MAJOR' | 'P2_DEGRADED' | 'P3_INFO';
|
||||
export type IncidentStatus = 'open' | 'mitigating' | 'resolved' | 'investigating' | 'identified' | 'monitoring';
|
||||
export type AuditActionType = string;
|
||||
|
||||
export const RU_ROLES: Record<OperatorRole, { label: string; shortLabel: string; description: string }> = {
|
||||
platform_owner: {
|
||||
|
|
@ -33,32 +39,44 @@ export const RU_ROLES: Record<OperatorRole, { label: string; shortLabel: string;
|
|||
},
|
||||
};
|
||||
|
||||
export const RU_SERVER_STATUS: Record<ServerStatus, { label: string; badgeVariant: 'success' | 'warning' | 'danger' | 'neutral' }> = {
|
||||
export const RU_SERVER_STATUS: Record<string, { label: string; badgeVariant: 'success' | 'warning' | 'danger' | 'neutral' }> = {
|
||||
healthy: { label: 'Работает нормально', badgeVariant: 'success' },
|
||||
warning: { label: 'Предупреждение', badgeVariant: 'warning' },
|
||||
degraded: { label: 'Работает с ограничениями', badgeVariant: 'warning' },
|
||||
critical: { label: 'Критический сбой', badgeVariant: 'danger' },
|
||||
offline: { label: 'Недоступен', badgeVariant: 'danger' },
|
||||
};
|
||||
|
||||
export const RU_SUBSCRIPTION_STATUS: Record<SubscriptionStatus, { label: string; badgeVariant: 'success' | 'warning' | 'danger' | 'info' | 'neutral' }> = {
|
||||
trial: { label: 'Пробный период', badgeVariant: 'info' },
|
||||
export const RU_SUBSCRIPTION_STATUS: Record<string, { label: string; badgeVariant: 'success' | 'warning' | 'danger' | 'info' | 'neutral' }> = {
|
||||
normal: { label: 'Активна', badgeVariant: 'success' },
|
||||
active: { label: 'Активна', badgeVariant: 'success' },
|
||||
trial: { label: 'Пробный период', badgeVariant: 'info' },
|
||||
expiring_7d: { label: 'Истекает (< 7 дн.)', badgeVariant: 'warning' },
|
||||
expiring_30d: { label: 'Истекает (< 30 дн.)', badgeVariant: 'warning' },
|
||||
grace: { label: 'Льготный период', badgeVariant: 'warning' },
|
||||
overdue: { label: 'Просрочена', badgeVariant: 'danger' },
|
||||
suspended: { label: 'Приостановлена', badgeVariant: 'danger' },
|
||||
canceled: { label: 'Отменена', badgeVariant: 'neutral' },
|
||||
};
|
||||
|
||||
export const RU_INCIDENT_SEVERITY: Record<IncidentSeverity, { label: string; short: string }> = {
|
||||
export const RU_INCIDENT_SEVERITY: Record<string, { label: string; short: string }> = {
|
||||
critical: { label: 'Критическая авария', short: 'Критическая' },
|
||||
high: { label: 'Высокая важность', short: 'Высокая' },
|
||||
medium: { label: 'Средняя важность', short: 'Средняя' },
|
||||
low: { label: 'Низкая важность', short: 'Низкая' },
|
||||
P0_CRITICAL: { label: 'P0 Критическая авария', short: 'P0 Критическая' },
|
||||
P1_MAJOR: { label: 'P1 Серьёзный инцидент', short: 'P1 Серьёзный' },
|
||||
P2_DEGRADED: { label: 'P2 Ограничение работы', short: 'P2 Деградация' },
|
||||
P3_INFO: { label: 'P3 Информационный', short: 'P3 Инфо' },
|
||||
};
|
||||
|
||||
export const RU_INCIDENT_STATUS: Record<IncidentStatus, string> = {
|
||||
export const RU_INCIDENT_STATUS: Record<string, string> = {
|
||||
open: 'Открыт',
|
||||
mitigating: 'Устраняется',
|
||||
resolved: 'Устранён',
|
||||
investigating: 'Расследуется',
|
||||
identified: 'Локализован',
|
||||
monitoring: 'Мониторинг устранения',
|
||||
resolved: 'Устранён',
|
||||
};
|
||||
|
||||
export const RU_AUDIT_ACTION: Record<AuditActionType, string> = {
|
||||
|
|
|
|||
|
|
@ -2,11 +2,9 @@ import React, { useState } from 'react';
|
|||
import {
|
||||
CreditCard,
|
||||
RefreshCw,
|
||||
SlidersHorizontal,
|
||||
Calendar,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
ShieldAlert,
|
||||
} from 'lucide-react';
|
||||
import { useKccStore, useOperatorRole } from '../store/kccStore';
|
||||
import { StatusBadge } from '../components/StatusBadge';
|
||||
|
|
@ -46,12 +44,12 @@ export const BillingPage: React.FC = () => {
|
|||
const handleRenew = () => {
|
||||
if (!selectedSub) return;
|
||||
openConfirmModal({
|
||||
title: `Продление подписки: ${selectedSub.organizationName}`,
|
||||
title: `Продление подписки: ${selectedSub.tenantName}`,
|
||||
actionName: 'grant_grace_period',
|
||||
impactSummary: `Подписка будет продлена на ${newPeriod === 'year' ? '1 год' : '1 месяц'}. Состав модулей: ${activeModulesToDisplay.join(', ')}. Расчётная сумма: ${calculatedTotal / 100} ₽.`,
|
||||
targetType: 'subscription',
|
||||
targetId: selectedSub.subscriptionId,
|
||||
targetName: selectedSub.organizationName,
|
||||
targetName: selectedSub.tenantName,
|
||||
onConfirm: async (reason) => {
|
||||
await renewSubscription(selectedSub.subscriptionId, newPeriod, activeModulesToDisplay, reason);
|
||||
},
|
||||
|
|
@ -61,12 +59,12 @@ export const BillingPage: React.FC = () => {
|
|||
const handleGrantGrace = () => {
|
||||
if (!selectedSub) return;
|
||||
openConfirmModal({
|
||||
title: `Предоставление льготного периода: ${selectedSub.organizationName}`,
|
||||
title: `Предоставление льготного периода: ${selectedSub.tenantName}`,
|
||||
actionName: 'grant_grace_period',
|
||||
impactSummary: `Организации будет предоставлен льготный период (Grace Period) на 14 дней с сохранением доступа ко всем модулям.`,
|
||||
targetType: 'subscription',
|
||||
targetId: selectedSub.subscriptionId,
|
||||
targetName: selectedSub.organizationName,
|
||||
targetName: selectedSub.tenantName,
|
||||
onConfirm: async (reason) => {
|
||||
await grantGracePeriod(selectedSub.subscriptionId, 14, reason);
|
||||
},
|
||||
|
|
@ -113,6 +111,7 @@ export const BillingPage: React.FC = () => {
|
|||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
data-testid={`filter-${tab.id}`}
|
||||
onClick={() => setFilter(tab.id as typeof filter)}
|
||||
className={`min-h-[44px] px-3.5 py-2 rounded-lg font-medium transition-colors shrink-0 ${
|
||||
filter === tab.id
|
||||
|
|
@ -149,9 +148,9 @@ export const BillingPage: React.FC = () => {
|
|||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="font-bold text-sm text-krona-primary">{sub.organizationName}</h3>
|
||||
<h3 className="font-bold text-sm text-krona-primary">{sub.tenantName}</h3>
|
||||
<div className="text-[11px] text-krona-tertiary mt-0.5">
|
||||
Тарифный план: <span className="font-semibold text-krona-primary">{sub.planName}</span>
|
||||
Тарифный план: <span className="font-semibold text-krona-primary">{sub.planCode}</span>
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={sub.status} size="sm" />
|
||||
|
|
@ -160,7 +159,7 @@ export const BillingPage: React.FC = () => {
|
|||
<div className="grid grid-cols-2 gap-2 text-xs py-2 border-y border-krona-border">
|
||||
<div>
|
||||
<div className="text-[10px] text-krona-tertiary">Стоимость в месяц</div>
|
||||
<MoneyDisplay minorUnits={sub.priceMinorUnits} currency={sub.currency} className="font-bold text-krona-gold text-xs" />
|
||||
<MoneyDisplay minorUnits={sub.amountMinorUnits} currency={sub.currency} className="font-bold text-krona-gold text-xs" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] text-krona-tertiary">Действует до</div>
|
||||
|
|
@ -179,7 +178,7 @@ export const BillingPage: React.FC = () => {
|
|||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openInspector({ type: 'subscription', id: sub.subscriptionId, title: sub.organizationName, data: sub });
|
||||
openInspector('organization', sub.tenantId);
|
||||
}}
|
||||
className="min-h-[44px] px-3 text-xs font-semibold text-krona-gold hover:underline flex items-center gap-1"
|
||||
>
|
||||
|
|
@ -205,7 +204,7 @@ export const BillingPage: React.FC = () => {
|
|||
<div className="text-[11px] font-semibold text-krona-gold uppercase tracking-wider">
|
||||
Управление подпиской
|
||||
</div>
|
||||
<h2 className="text-base font-bold text-krona-primary mt-0.5">{selectedSub.organizationName}</h2>
|
||||
<h2 className="text-base font-bold text-krona-primary mt-0.5">{selectedSub.tenantName}</h2>
|
||||
<div className="text-xs text-krona-secondary mt-0.5">
|
||||
Текущий статус: <StatusBadge status={selectedSub.status} size="sm" />
|
||||
</div>
|
||||
|
|
@ -284,8 +283,9 @@ export const BillingPage: React.FC = () => {
|
|||
<div className="space-y-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!permissions.canGrantTrialGrace}
|
||||
onClick={handleRenew}
|
||||
className="w-full min-h-[44px] px-4 py-2.5 rounded-lg bg-krona-gold hover:bg-krona-gold-hover text-zinc-950 font-bold text-xs flex items-center justify-center gap-2 transition-colors shadow-sm"
|
||||
className="w-full min-h-[44px] px-4 py-2.5 rounded-lg bg-krona-gold hover:bg-krona-gold-hover text-zinc-950 font-bold text-xs flex items-center justify-center gap-2 transition-colors shadow-sm disabled:opacity-40"
|
||||
data-testid="renew-subscription-btn"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
|
|
@ -294,8 +294,9 @@ export const BillingPage: React.FC = () => {
|
|||
|
||||
<button
|
||||
type="button"
|
||||
disabled={!permissions.canGrantTrialGrace}
|
||||
onClick={handleGrantGrace}
|
||||
className="w-full min-h-[44px] px-4 py-2 rounded-lg border border-status-warning-border bg-status-warning-soft text-status-warning-text font-semibold text-xs flex items-center justify-center gap-2 transition-colors"
|
||||
className="w-full min-h-[44px] px-4 py-2 rounded-lg border border-status-warning-border bg-status-warning-soft text-status-warning-text font-semibold text-xs flex items-center justify-center gap-2 transition-colors disabled:opacity-40"
|
||||
data-testid="grant-grace-btn"
|
||||
>
|
||||
<Clock className="w-4 h-4" />
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import React, { useState } from 'react';
|
|||
import { useNavigate } from 'react-router-dom';
|
||||
import { Lock, ArrowRight, Sun, Moon, Sparkles } from 'lucide-react';
|
||||
import { useKccStore } from '../store/kccStore';
|
||||
import { useThemeStore } from '../../../shared/model/themeStore';
|
||||
import { useThemeStore, ThemeMode } from '../../../stores/theme';
|
||||
import { RU_THEMES, RU_UI } from '../locales/ru';
|
||||
|
||||
export const ControlAccessGatePage: React.FC = () => {
|
||||
|
|
@ -11,10 +11,10 @@ export const ControlAccessGatePage: React.FC = () => {
|
|||
const [pin, setPin] = useState('');
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
const theme = useThemeStore((state) => state.theme);
|
||||
const themeMode = useThemeStore((state) => state.themeMode);
|
||||
const setTheme = useThemeStore((state) => state.setTheme);
|
||||
|
||||
const themes: Array<{ id: 'soft-light' | 'light' | 'dark'; label: string; icon: React.ReactNode }> = [
|
||||
const themes: Array<{ id: ThemeMode; label: string; icon: React.ReactNode }> = [
|
||||
{ id: 'soft-light', label: RU_THEMES['soft-light'], icon: <Sparkles className="w-3.5 h-3.5" /> },
|
||||
{ id: 'light', label: RU_THEMES['light'], icon: <Sun className="w-3.5 h-3.5" /> },
|
||||
{ id: 'dark', label: RU_THEMES['dark'], icon: <Moon className="w-3.5 h-3.5" /> },
|
||||
|
|
@ -46,7 +46,7 @@ export const ControlAccessGatePage: React.FC = () => {
|
|||
onClick={() => setTheme(t.id)}
|
||||
title={t.label}
|
||||
className={`min-w-[40px] min-h-[40px] px-2.5 py-1 rounded-lg text-xs font-medium flex items-center gap-1.5 transition-colors ${
|
||||
theme === t.id
|
||||
themeMode === t.id
|
||||
? 'bg-amber-500 text-zinc-950 font-bold shadow-xs'
|
||||
: 'text-krona-secondary hover:text-krona-primary hover:bg-krona-workspace'
|
||||
}`}
|
||||
|
|
@ -79,6 +79,7 @@ export const ControlAccessGatePage: React.FC = () => {
|
|||
<input
|
||||
type="password"
|
||||
value={pin}
|
||||
data-testid="pin-input"
|
||||
onChange={(e) => {
|
||||
setPin(e.target.value);
|
||||
if (error) setError(false);
|
||||
|
|
@ -87,7 +88,7 @@ export const ControlAccessGatePage: React.FC = () => {
|
|||
className="w-full bg-krona-workspace border border-krona-border rounded-xl px-4 py-3 text-center text-sm text-krona-primary placeholder:text-krona-secondary/60 focus:outline-hidden focus:border-amber-500 tracking-wider font-mono min-h-[48px]"
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-xs text-rose-500 font-medium mt-1.5">
|
||||
<p className="text-xs text-rose-500 font-medium mt-1.5" data-testid="pin-error">
|
||||
{RU_UI.gateError}
|
||||
</p>
|
||||
)}
|
||||
|
|
@ -95,6 +96,7 @@ export const ControlAccessGatePage: React.FC = () => {
|
|||
|
||||
<button
|
||||
type="submit"
|
||||
data-testid="pin-submit"
|
||||
className="w-full min-h-[48px] py-3 px-4 rounded-xl bg-amber-500 hover:bg-amber-400 text-zinc-950 font-bold text-xs flex items-center justify-center gap-2 transition-colors shadow-xs"
|
||||
>
|
||||
<span>{RU_UI.gateSubmit}</span>
|
||||
|
|
@ -105,6 +107,7 @@ export const ControlAccessGatePage: React.FC = () => {
|
|||
<div className="pt-2 border-t border-krona-border">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="pin-demo-login"
|
||||
onClick={handleDemoBypass}
|
||||
className="min-h-[44px] inline-flex items-center text-xs text-krona-secondary hover:text-amber-500 transition-colors underline underline-offset-4"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -26,21 +26,13 @@ export const IncidentsPage: React.FC = () => {
|
|||
inc.summary.toLowerCase().includes(search.toLowerCase()) ||
|
||||
inc.description.toLowerCase().includes(search.toLowerCase());
|
||||
|
||||
let matchStatus = true;
|
||||
if (statusFilter === 'open') {
|
||||
matchStatus = inc.status === 'investigating';
|
||||
} else if (statusFilter === 'mitigating') {
|
||||
matchStatus = inc.status === 'identified' || inc.status === 'monitoring';
|
||||
} else if (statusFilter === 'resolved') {
|
||||
matchStatus = inc.status === 'resolved';
|
||||
}
|
||||
|
||||
const matchStatus = statusFilter === 'all' || inc.status === statusFilter;
|
||||
return matchSearch && matchStatus;
|
||||
});
|
||||
|
||||
const unresolvedCount = incidents.filter((i) => i.status !== 'resolved').length;
|
||||
const criticalCount = incidents.filter(
|
||||
(i) => i.status !== 'resolved' && (i.severity === 'P0_CRITICAL' || i.severity === 'P1_MAJOR')
|
||||
(i) => i.status !== 'resolved' && (i.severity === 'critical' || i.severity === 'high')
|
||||
).length;
|
||||
|
||||
return (
|
||||
|
|
@ -111,8 +103,8 @@ export const IncidentsPage: React.FC = () => {
|
|||
{/* Incidents List - Mobile-First Cards */}
|
||||
<div className="space-y-3">
|
||||
{filtered.map((inc) => {
|
||||
const isCritical = inc.severity === 'P0_CRITICAL';
|
||||
const isMajor = inc.severity === 'P1_MAJOR';
|
||||
const isCritical = inc.severity === 'critical';
|
||||
const isMajor = inc.severity === 'high';
|
||||
const severityInfo = RU_INCIDENT_SEVERITY[inc.severity] || {
|
||||
label: inc.severity,
|
||||
short: inc.severity,
|
||||
|
|
|
|||
|
|
@ -5,14 +5,9 @@ import {
|
|||
ChevronDown,
|
||||
ChevronUp,
|
||||
ChevronRight,
|
||||
ShieldAlert,
|
||||
Activity,
|
||||
HardDrive,
|
||||
Cpu,
|
||||
Clock,
|
||||
ExternalLink,
|
||||
} from 'lucide-react';
|
||||
import { useKccStore, useOperatorRole } from '../store/kccStore';
|
||||
import { useKccStore } from '../store/kccStore';
|
||||
import { StatusBadge } from '../components/StatusBadge';
|
||||
import { HeartbeatTimeline } from '../components/HeartbeatTimeline';
|
||||
import { ServerNode } from '../contracts';
|
||||
|
|
@ -24,7 +19,6 @@ export const InfrastructurePage: React.FC = () => {
|
|||
const openConfirmModal = useKccStore((state) => state.openConfirmModal);
|
||||
const createIncidentFromAlert = useKccStore((state) => state.createIncidentFromAlert);
|
||||
const requestServerDiagnostics = useKccStore((state) => state.requestServerDiagnostics);
|
||||
const { permissions } = useOperatorRole();
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [providerFilter, setProviderFilter] = useState<'all' | 'Selectel' | 'Timeweb Cloud'>('all');
|
||||
|
|
@ -41,7 +35,7 @@ export const InfrastructurePage: React.FC = () => {
|
|||
srv.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
srv.ipAddress.includes(search) ||
|
||||
srv.hostname.toLowerCase().includes(search.toLowerCase()) ||
|
||||
srv.organizationName.toLowerCase().includes(search.toLowerCase());
|
||||
srv.hostingProvider.toLowerCase().includes(search.toLowerCase());
|
||||
const matchProvider = providerFilter === 'all' || srv.hostingProvider === providerFilter;
|
||||
return matchSearch && matchProvider;
|
||||
});
|
||||
|
|
@ -52,7 +46,7 @@ export const InfrastructurePage: React.FC = () => {
|
|||
title: `Создать инцидент для сервера: ${srv.name}`,
|
||||
actionName: 'circuit_breaker_trip',
|
||||
impactSummary: `Будет открыт критический инцидент платформы, затронутые организации получат уведомление о расследовании.`,
|
||||
targetType: 'server',
|
||||
targetType: 'infrastructure',
|
||||
targetId: srv.serverId,
|
||||
targetName: srv.name,
|
||||
onConfirm: async (reason) => {
|
||||
|
|
@ -72,7 +66,7 @@ export const InfrastructurePage: React.FC = () => {
|
|||
title: `Запрос телеметрии: ${srv.name}`,
|
||||
actionName: 'invalidate_cache',
|
||||
impactSummary: `Агенту мониторинга v${srv.agentVersion} будет направлен запрос выгрузки моментального снимка состояния.`,
|
||||
targetType: 'server',
|
||||
targetType: 'infrastructure',
|
||||
targetId: srv.serverId,
|
||||
targetName: srv.name,
|
||||
onConfirm: async (reason) => {
|
||||
|
|
@ -144,12 +138,15 @@ export const InfrastructurePage: React.FC = () => {
|
|||
{filteredServers.map((srv) => {
|
||||
const srvInstances = instances.filter((i) => i.serverId === srv.serverId);
|
||||
const isExpanded = !!expandedDetails[srv.serverId];
|
||||
const lastSignalDate = new Date(srv.metrics.lastHeartbeatAt);
|
||||
const lastSignalDate = new Date(srv.lastSeenAt);
|
||||
const cpuPercent = srv.cpuLoadPercent;
|
||||
const ramPercent = Math.round((srv.ramUsedBytes / (srv.ramTotalBytes || 1)) * 100);
|
||||
const diskPercent = Math.round((srv.diskUsedBytes / (srv.diskTotalBytes || 1)) * 100);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={srv.serverId}
|
||||
onClick={() => openInspector({ type: 'server', id: srv.serverId, title: srv.name, data: srv })}
|
||||
onClick={() => openInspector('server', srv.serverId)}
|
||||
className={`p-4 rounded-xl border bg-krona-surface hover:border-krona-gold/40 cursor-pointer transition-all space-y-3 relative shadow-xs ${
|
||||
srv.status === 'critical' || srv.status === 'offline'
|
||||
? 'border-status-danger-border bg-status-danger-soft/20'
|
||||
|
|
@ -179,11 +176,11 @@ export const InfrastructurePage: React.FC = () => {
|
|||
</span>
|
||||
</div>
|
||||
|
||||
{/* Destination / Organization */}
|
||||
{/* Destination / Provider */}
|
||||
<div className="text-xs text-krona-secondary flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-krona-tertiary">Назначение: </span>
|
||||
<span className="font-semibold text-krona-primary">{srv.organizationName}</span>
|
||||
<span className="text-krona-tertiary">Провайдер: </span>
|
||||
<span className="font-semibold text-krona-primary">{srv.hostingProvider}</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-krona-tertiary flex items-center gap-1">
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
|
|
@ -197,14 +194,14 @@ export const InfrastructurePage: React.FC = () => {
|
|||
<div className="p-2 rounded-lg bg-krona-workspace border border-krona-border space-y-1">
|
||||
<div className="flex justify-between text-[11px] text-krona-tertiary">
|
||||
<span>CPU</span>
|
||||
<span className={`font-bold font-mono ${srv.metrics.cpuPercent > 80 ? 'text-status-danger-text' : 'text-krona-primary'}`}>
|
||||
{srv.metrics.cpuPercent}%
|
||||
<span className={`font-bold font-mono ${cpuPercent > 80 ? 'text-status-danger-text' : 'text-krona-primary'}`}>
|
||||
{cpuPercent}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-krona-surface h-1.5 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${srv.metrics.cpuPercent > 80 ? 'bg-red-500' : 'bg-emerald-500'}`}
|
||||
style={{ width: `${srv.metrics.cpuPercent}%` }}
|
||||
className={`h-full ${cpuPercent > 80 ? 'bg-red-500' : 'bg-emerald-500'}`}
|
||||
style={{ width: `${cpuPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -213,14 +210,14 @@ export const InfrastructurePage: React.FC = () => {
|
|||
<div className="p-2 rounded-lg bg-krona-workspace border border-krona-border space-y-1">
|
||||
<div className="flex justify-between text-[11px] text-krona-tertiary">
|
||||
<span>RAM</span>
|
||||
<span className={`font-bold font-mono ${srv.metrics.ramPercent > 80 ? 'text-status-danger-text' : 'text-krona-primary'}`}>
|
||||
{srv.metrics.ramPercent}%
|
||||
<span className={`font-bold font-mono ${ramPercent > 80 ? 'text-status-danger-text' : 'text-krona-primary'}`}>
|
||||
{ramPercent}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-krona-surface h-1.5 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${srv.metrics.ramPercent > 80 ? 'bg-red-500' : 'bg-emerald-500'}`}
|
||||
style={{ width: `${srv.metrics.ramPercent}%` }}
|
||||
className={`h-full ${ramPercent > 80 ? 'bg-red-500' : 'bg-emerald-500'}`}
|
||||
style={{ width: `${ramPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -229,14 +226,14 @@ export const InfrastructurePage: React.FC = () => {
|
|||
<div className="p-2 rounded-lg bg-krona-workspace border border-krona-border space-y-1">
|
||||
<div className="flex justify-between text-[11px] text-krona-tertiary">
|
||||
<span>Диск</span>
|
||||
<span className={`font-bold font-mono ${srv.metrics.diskPercent > 85 ? 'text-status-danger-text' : 'text-krona-primary'}`}>
|
||||
{srv.metrics.diskPercent}%
|
||||
<span className={`font-bold font-mono ${diskPercent > 85 ? 'text-status-danger-text' : 'text-krona-primary'}`}>
|
||||
{diskPercent}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-krona-surface h-1.5 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${srv.metrics.diskPercent > 85 ? 'bg-red-500' : 'bg-emerald-500'}`}
|
||||
style={{ width: `${srv.metrics.diskPercent}%` }}
|
||||
className={`h-full ${diskPercent > 85 ? 'bg-red-500' : 'bg-emerald-500'}`}
|
||||
style={{ width: `${diskPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -249,18 +246,22 @@ export const InfrastructurePage: React.FC = () => {
|
|||
<div>
|
||||
Резервная копия:{' '}
|
||||
<span className={srv.backup.state === 'failed' ? 'text-status-danger-text font-bold' : 'text-status-success-text font-semibold'}>
|
||||
{srv.backup.state === 'healthy' ? 'В норме' : srv.backup.state === 'failed' ? 'Ошибка' : srv.backup.state}
|
||||
{srv.backup.state === 'ok' ? 'В норме' : srv.backup.state === 'failed' ? 'Ошибка' : srv.backup.state}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
Срок mTLS-сертификата:{' '}
|
||||
<span className="font-mono text-krona-primary font-semibold">{srv.certificateDaysRemaining} дней</span>
|
||||
</div>
|
||||
<div>
|
||||
Активных модулей:{' '}
|
||||
<span className="font-mono text-krona-primary font-semibold">{srvInstances.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-[11px] text-krona-tertiary mb-1">История сигналов (Heartbeat):</div>
|
||||
<HeartbeatTimeline history={srv.metrics.heartbeatHistory} compact />
|
||||
<HeartbeatTimeline status={srv.status} count={24} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
|
|
@ -309,7 +310,7 @@ export const InfrastructurePage: React.FC = () => {
|
|||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openInspector({ type: 'server', id: srv.serverId, title: srv.name, data: srv });
|
||||
openInspector('server', srv.serverId);
|
||||
}}
|
||||
className="min-h-[44px] px-3 flex items-center gap-1.5 text-xs font-semibold text-krona-gold bg-krona-gold/10 hover:bg-krona-gold/20 rounded-lg border border-krona-gold/30 transition-colors"
|
||||
data-testid={`inspect-server-${srv.serverId}`}
|
||||
|
|
|
|||
|
|
@ -182,7 +182,7 @@ export const InstallationsPage: React.FC = () => {
|
|||
<span className="font-bold text-sm text-krona-primary">{item.deviceName}</span>
|
||||
</div>
|
||||
<div className="text-xs text-krona-secondary mt-0.5">
|
||||
{item.tenantName}
|
||||
{item.tenantName} • {platformLabel}
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={item.status} size="sm" customLabel={statusLabel} />
|
||||
|
|
|
|||
|
|
@ -3,10 +3,6 @@ import {
|
|||
Building2,
|
||||
Search,
|
||||
ChevronRight,
|
||||
Filter,
|
||||
Users,
|
||||
CreditCard,
|
||||
Layers,
|
||||
} from 'lucide-react';
|
||||
import { useKccStore } from '../store/kccStore';
|
||||
import { StatusBadge } from '../components/StatusBadge';
|
||||
|
|
@ -96,7 +92,7 @@ export const OrganizationsPage: React.FC = () => {
|
|||
{filtered.map((org) => (
|
||||
<div
|
||||
key={org.tenantId}
|
||||
onClick={() => openInspector({ type: 'organization', id: org.tenantId, title: org.displayName, data: org })}
|
||||
onClick={() => openInspector('organization', org.tenantId)}
|
||||
className="rounded-xl border border-krona-border bg-krona-surface p-4 space-y-3 shadow-sm hover:border-krona-gold/40 transition-colors cursor-pointer"
|
||||
>
|
||||
{/* Header: Title + Badge */}
|
||||
|
|
@ -135,12 +131,12 @@ export const OrganizationsPage: React.FC = () => {
|
|||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openInspector({ type: 'organization', id: org.tenantId, title: org.displayName, data: org });
|
||||
openInspector('organization', org.tenantId);
|
||||
}}
|
||||
className="min-h-[44px] min-w-[44px] px-3 flex items-center gap-1 text-xs font-semibold text-krona-gold bg-krona-gold/10 hover:bg-krona-gold/20 rounded-lg border border-krona-gold/30 transition-colors"
|
||||
data-testid={`inspect-org-${org.tenantId}`}
|
||||
>
|
||||
<span>360° Инспектор</span>
|
||||
<span>360° Карточка</span>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -177,7 +173,7 @@ export const OrganizationsPage: React.FC = () => {
|
|||
{filtered.map((org) => (
|
||||
<tr
|
||||
key={org.tenantId}
|
||||
onClick={() => openInspector({ type: 'organization', id: org.tenantId, title: org.displayName, data: org })}
|
||||
onClick={() => openInspector('organization', org.tenantId)}
|
||||
className="hover:bg-krona-hover cursor-pointer transition-colors group"
|
||||
>
|
||||
<td className="py-3 px-4">
|
||||
|
|
@ -221,12 +217,12 @@ export const OrganizationsPage: React.FC = () => {
|
|||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openInspector({ type: 'organization', id: org.tenantId, title: org.displayName, data: org });
|
||||
openInspector('organization', org.tenantId);
|
||||
}}
|
||||
className="inline-flex items-center gap-1 px-3 py-2 min-h-[38px] rounded-lg bg-krona-workspace border border-krona-border text-krona-primary hover:text-krona-gold hover:border-krona-gold/40 text-[11px] font-medium transition-colors"
|
||||
data-testid={`inspect-org-btn-${org.tenantId}`}
|
||||
>
|
||||
<span>360° Инспектор</span>
|
||||
<span>360° Карточка</span>
|
||||
<ChevronRight className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</td>
|
||||
|
|
|
|||
|
|
@ -9,12 +9,9 @@ import {
|
|||
ShieldAlert,
|
||||
ArrowUpRight,
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Layers,
|
||||
HelpCircle,
|
||||
Database,
|
||||
} from 'lucide-react';
|
||||
import { useKccStore, useOperatorRole } from '../store/kccStore';
|
||||
import { MoneyDisplay } from '../components/MoneyDisplay';
|
||||
|
|
@ -49,7 +46,6 @@ export const OverviewPage: React.FC = () => {
|
|||
const totalMrrMinorUnits = organizations.reduce((sum, o) => sum + o.mrrMinorUnits, 0);
|
||||
|
||||
const expiring7d = subscriptions.filter((s) => s.status === 'expiring_7d');
|
||||
const expiring30d = subscriptions.filter((s) => s.status === 'expiring_30d');
|
||||
const overdueSubs = subscriptions.filter((s) => s.status === 'overdue');
|
||||
|
||||
const webInstalls = installations.filter((i) => i.kind === 'web').length;
|
||||
|
|
@ -59,11 +55,9 @@ export const OverviewPage: React.FC = () => {
|
|||
|
||||
const instancesOffline = instances.filter((i) => i.health === 'offline');
|
||||
const instancesDegraded = instances.filter((i) => i.health === 'degraded');
|
||||
const instancesHealthy = instances.filter((i) => i.health === 'healthy');
|
||||
|
||||
const serversHealthy = servers.filter((s) => s.status === 'healthy').length;
|
||||
const serversProblematic = servers.filter((s) => s.status !== 'healthy');
|
||||
const failedBackups = servers.filter((s) => s.backup.state === 'failed');
|
||||
const openIncidents = incidents.filter((i) => i.status !== 'resolved');
|
||||
|
||||
// Total attention items count
|
||||
|
|
@ -87,7 +81,7 @@ export const OverviewPage: React.FC = () => {
|
|||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2.5 border-b border-krona-border pb-4">
|
||||
<div>
|
||||
<h1 className="text-lg sm:text-xl font-bold text-krona-primary flex items-center gap-2 flex-wrap">
|
||||
<span>Сводная панель платформы</span>
|
||||
<span>Сводная операторская панель</span>
|
||||
<span className="text-[11px] px-2.5 py-0.5 rounded-full bg-status-success-soft text-status-success-text border border-status-success-border font-medium">
|
||||
Контур платформы
|
||||
</span>
|
||||
|
|
@ -193,8 +187,8 @@ export const OverviewPage: React.FC = () => {
|
|||
<div className="space-y-2">
|
||||
{openIncidents.map((inc) => (
|
||||
<div
|
||||
key={inc.id}
|
||||
onClick={() => openInspector({ type: 'incident', id: inc.id, title: inc.title, data: inc })}
|
||||
key={inc.incidentId}
|
||||
onClick={() => openInspector('incident', inc.incidentId)}
|
||||
className="p-3.5 rounded-lg border border-status-danger-border bg-status-danger-soft cursor-pointer hover:opacity-95 transition-opacity space-y-1.5"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
|
|
@ -202,15 +196,15 @@ export const OverviewPage: React.FC = () => {
|
|||
<span className="text-[10px] font-mono font-bold bg-status-danger-text text-white px-2 py-0.5 rounded-sm">
|
||||
{RU_INCIDENT_SEVERITY[inc.severity]?.short || inc.severity}
|
||||
</span>
|
||||
<span className="font-bold text-xs text-status-danger-text">{inc.title}</span>
|
||||
<span className="font-bold text-xs text-status-danger-text">{inc.summary}</span>
|
||||
</div>
|
||||
<span className="text-[11px] font-medium text-status-danger-text">
|
||||
{RU_INCIDENT_STATUS[inc.status] || inc.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-krona-secondary line-clamp-2">{inc.impactSummary}</p>
|
||||
<p className="text-xs text-krona-secondary line-clamp-2">{inc.description}</p>
|
||||
<div className="text-[11px] text-krona-tertiary">
|
||||
Затронуто: {inc.affectedTenants.length} орг. • Узлы: {inc.affectedNodes.join(', ')}
|
||||
Затронуто: {inc.affectedTenantIds.length} орг. • Узлов: {inc.affectedServerIds.length}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -239,26 +233,31 @@ export const OverviewPage: React.FC = () => {
|
|||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{serversProblematic.map((srv) => (
|
||||
<div
|
||||
key={srv.id}
|
||||
onClick={() => openInspector({ type: 'server', id: srv.id, title: srv.hostname, data: srv })}
|
||||
className="p-3.5 rounded-lg border border-krona-border bg-krona-workspace cursor-pointer hover:border-krona-gold/40 transition-colors space-y-2"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="font-bold text-xs text-krona-primary font-mono">{srv.hostname}</div>
|
||||
<StatusBadge status={srv.status} size="sm" />
|
||||
{serversProblematic.map((srv) => {
|
||||
const cpuPercent = srv.cpuLoadPercent;
|
||||
const ramPercent = Math.round((srv.ramUsedBytes / (srv.ramTotalBytes || 1)) * 100);
|
||||
const diskPercent = Math.round((srv.diskUsedBytes / (srv.diskTotalBytes || 1)) * 100);
|
||||
return (
|
||||
<div
|
||||
key={srv.serverId}
|
||||
onClick={() => openInspector('server', srv.serverId)}
|
||||
className="p-3.5 rounded-lg border border-krona-border bg-krona-workspace cursor-pointer hover:border-krona-gold/40 transition-colors space-y-2"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="font-bold text-xs text-krona-primary font-mono">{srv.hostname}</div>
|
||||
<StatusBadge status={srv.status} size="sm" />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-1 text-[11px] text-krona-secondary font-mono">
|
||||
<div>CPU: <span className={cpuPercent > 80 ? 'text-status-danger-text font-bold' : ''}>{cpuPercent}%</span></div>
|
||||
<div>RAM: <span className={ramPercent > 80 ? 'text-status-danger-text font-bold' : ''}>{ramPercent}%</span></div>
|
||||
<div>Диск: <span className={diskPercent > 85 ? 'text-status-danger-text font-bold' : ''}>{diskPercent}%</span></div>
|
||||
</div>
|
||||
<div className="text-[11px] text-krona-tertiary">
|
||||
Провайдер: {srv.hostingProvider} ({srv.region})
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-1 text-[11px] text-krona-secondary font-mono">
|
||||
<div>CPU: <span className={srv.metrics.cpuPercent > 80 ? 'text-status-danger-text font-bold' : ''}>{srv.metrics.cpuPercent}%</span></div>
|
||||
<div>RAM: <span className={srv.metrics.ramPercent > 80 ? 'text-status-danger-text font-bold' : ''}>{srv.metrics.ramPercent}%</span></div>
|
||||
<div>Диск: <span className={srv.metrics.diskPercent > 85 ? 'text-status-danger-text font-bold' : ''}>{srv.metrics.diskPercent}%</span></div>
|
||||
</div>
|
||||
<div className="text-[11px] text-krona-tertiary">
|
||||
Организация: {srv.organizationName}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -286,13 +285,13 @@ export const OverviewPage: React.FC = () => {
|
|||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
|
||||
{[...instancesOffline, ...instancesDegraded].map((inst) => (
|
||||
<div
|
||||
key={inst.id}
|
||||
onClick={() => openInspector({ type: 'instance', id: inst.id, title: inst.moduleCode, data: inst })}
|
||||
key={inst.moduleInstanceId}
|
||||
onClick={() => openInspector('organization', inst.tenantId)}
|
||||
className="p-3 rounded-lg border border-krona-border bg-krona-workspace cursor-pointer hover:border-krona-gold/40 transition-colors flex items-center justify-between"
|
||||
>
|
||||
<div>
|
||||
<div className="font-bold text-xs text-krona-primary">{inst.moduleCode} v{inst.version}</div>
|
||||
<div className="text-[11px] text-krona-tertiary">Организация: {inst.organizationId}</div>
|
||||
<div className="font-bold text-xs text-krona-primary">{inst.moduleName} v{inst.currentVersion}</div>
|
||||
<div className="text-[11px] text-krona-tertiary">Тенант: {inst.tenantName}</div>
|
||||
</div>
|
||||
<StatusBadge status={inst.health} size="sm" />
|
||||
</div>
|
||||
|
|
@ -324,18 +323,18 @@ export const OverviewPage: React.FC = () => {
|
|||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
|
||||
{[...overdueSubs, ...expiring7d].map((sub) => (
|
||||
<div
|
||||
key={sub.id}
|
||||
onClick={() => openInspector({ type: 'subscription', id: sub.id, title: sub.organizationName, data: sub })}
|
||||
key={sub.subscriptionId}
|
||||
onClick={() => openInspector('organization', sub.tenantId)}
|
||||
className="p-3 rounded-lg border border-krona-border bg-krona-workspace cursor-pointer hover:border-krona-gold/40 transition-colors flex items-center justify-between"
|
||||
>
|
||||
<div>
|
||||
<div className="font-bold text-xs text-krona-primary">{sub.organizationName}</div>
|
||||
<div className="font-bold text-xs text-krona-primary">{sub.tenantName}</div>
|
||||
<div className="text-[11px] text-krona-tertiary">
|
||||
Тариф: {sub.planName} • До: {new Date(sub.validUntil).toLocaleDateString('ru-RU')}
|
||||
Тариф: {sub.planCode} • До: {new Date(sub.validUntil).toLocaleDateString('ru-RU')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<MoneyDisplay minorUnits={sub.priceMinorUnits} currency={sub.currency} className="font-bold text-xs text-krona-primary" />
|
||||
<MoneyDisplay minorUnits={sub.amountMinorUnits} currency={sub.currency} className="font-bold text-xs text-krona-primary" />
|
||||
<div className="mt-1"><StatusBadge status={sub.status} size="sm" /></div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -526,53 +525,59 @@ export const OverviewPage: React.FC = () => {
|
|||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{servers.map((srv) => (
|
||||
<div
|
||||
key={srv.id}
|
||||
onClick={() => openInspector({ type: 'server', id: srv.id, title: srv.hostname, data: srv })}
|
||||
className="p-3.5 rounded-lg border border-krona-border bg-krona-workspace cursor-pointer hover:border-krona-gold/40 transition-colors space-y-2.5"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-bold text-xs text-krona-primary font-mono">{srv.hostname}</div>
|
||||
<div className="text-[11px] text-krona-tertiary">{srv.organizationName}</div>
|
||||
</div>
|
||||
<StatusBadge status={srv.status} size="sm" />
|
||||
</div>
|
||||
{servers.map((srv) => {
|
||||
const cpuPercent = srv.cpuLoadPercent;
|
||||
const ramPercent = Math.round((srv.ramUsedBytes / (srv.ramTotalBytes || 1)) * 100);
|
||||
const diskPercent = Math.round((srv.diskUsedBytes / (srv.diskTotalBytes || 1)) * 100);
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 text-center text-xs py-1 bg-krona-surface/40 rounded-md border border-krona-border">
|
||||
<div>
|
||||
<div className="text-[10px] text-krona-tertiary">CPU</div>
|
||||
<div className={`font-mono font-bold ${srv.metrics.cpuPercent > 80 ? 'text-status-danger-text' : 'text-krona-primary'}`}>
|
||||
{srv.metrics.cpuPercent}%
|
||||
return (
|
||||
<div
|
||||
key={srv.serverId}
|
||||
onClick={() => openInspector('server', srv.serverId)}
|
||||
className="p-3.5 rounded-lg border border-krona-border bg-krona-workspace cursor-pointer hover:border-krona-gold/40 transition-colors space-y-2.5"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-bold text-xs text-krona-primary font-mono">{srv.hostname}</div>
|
||||
<div className="text-[11px] text-krona-tertiary">{srv.hostingProvider} ({srv.region})</div>
|
||||
</div>
|
||||
<StatusBadge status={srv.status} size="sm" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 text-center text-xs py-1 bg-krona-surface/40 rounded-md border border-krona-border">
|
||||
<div>
|
||||
<div className="text-[10px] text-krona-tertiary">CPU</div>
|
||||
<div className={`font-mono font-bold ${cpuPercent > 80 ? 'text-status-danger-text' : 'text-krona-primary'}`}>
|
||||
{cpuPercent}%
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] text-krona-tertiary">RAM</div>
|
||||
<div className={`font-mono font-bold ${ramPercent > 80 ? 'text-status-danger-text' : 'text-krona-primary'}`}>
|
||||
{ramPercent}%
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] text-krona-tertiary">Диск</div>
|
||||
<div className={`font-mono font-bold ${diskPercent > 85 ? 'text-status-danger-text' : 'text-krona-primary'}`}>
|
||||
{diskPercent}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] text-krona-tertiary">RAM</div>
|
||||
<div className={`font-mono font-bold ${srv.metrics.ramPercent > 80 ? 'text-status-danger-text' : 'text-krona-primary'}`}>
|
||||
{srv.metrics.ramPercent}%
|
||||
</div>
|
||||
|
||||
<div className="text-[11px] text-krona-tertiary flex items-center justify-between">
|
||||
<span>Резервная копия:</span>
|
||||
<span className={srv.backup.state === 'failed' ? 'text-status-danger-text font-bold' : 'text-status-success-text'}>
|
||||
{srv.backup.state === 'ok' ? 'Успешно' : srv.backup.state === 'failed' ? 'Ошибка' : srv.backup.state}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] text-krona-tertiary">Диск</div>
|
||||
<div className={`font-mono font-bold ${srv.metrics.diskPercent > 85 ? 'text-status-danger-text' : 'text-krona-primary'}`}>
|
||||
{srv.metrics.diskPercent}%
|
||||
</div>
|
||||
|
||||
<div className="pt-1">
|
||||
<HeartbeatTimeline status={srv.status} count={24} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-[11px] text-krona-tertiary flex items-center justify-between">
|
||||
<span>Резервная копия:</span>
|
||||
<span className={srv.backup.state === 'failed' ? 'text-status-danger-text font-bold' : 'text-status-success-text'}>
|
||||
{srv.backup.state === 'healthy' ? 'Успешно' : srv.backup.state === 'failed' ? 'Ошибка' : srv.backup.state}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="pt-1">
|
||||
<HeartbeatTimeline history={srv.metrics.heartbeatHistory} compact />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue