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>
|
<!doctype html>
|
||||||
<html lang="ru">
|
<html lang="ru">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/png" href="/branding/krona-symbol.png" />
|
<link rel="icon" type="image/png" href="/branding/krona-symbol.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Крона — Universal Business OS</title>
|
<title>Крона — Universal Business OS</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||||
<script type="module" crossorigin src="/assets/index-CT8iDYYK.js"></script>
|
<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/useToast-CS8pZhce.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/demo-D1nHL60J.js">
|
<link rel="modulepreload" crossorigin href="/assets/demo-D1nHL60J.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/indexedDb-D30TbhA6.js">
|
<link rel="modulepreload" crossorigin href="/assets/indexedDb-D30TbhA6.js">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-D03nV9JP.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-D3QQttR0.css">
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-krona-app text-krona-primary antialiased selection:bg-krona-gold/20 selection:text-krona-gold-text">
|
<body class="bg-krona-app text-krona-primary antialiased selection:bg-krona-gold/20 selection:text-krona-gold-text">
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -1,272 +1,285 @@
|
||||||
import { test, expect } from '@playwright/test';
|
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.goto('/control');
|
||||||
await page.waitForLoadState('domcontentloaded');
|
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 }) => {
|
const menuBtn = page.locator('[data-testid="mobile-menu-button"]');
|
||||||
await expect(page.locator('text=Krona Control Center').first()).toBeVisible();
|
await expect(menuBtn).toBeVisible();
|
||||||
await expect(page.locator('button[aria-label="Сессия оператора"]')).toBeVisible();
|
await menuBtn.click();
|
||||||
|
|
||||||
const isOverflowing = await page.evaluate(() => {
|
// Drawer opens
|
||||||
return document.documentElement.scrollWidth > document.documentElement.clientWidth;
|
const drawerNavOrg = page.locator('[data-testid="drawer-nav-organizations"]');
|
||||||
});
|
await expect(drawerNavOrg).toBeVisible();
|
||||||
expect(isOverflowing).toBe(false);
|
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 page.waitForURL('**/control/organizations');
|
||||||
await expect(page.locator('h1:has-text("Организации платформы")')).toBeVisible();
|
await expect(page.locator('h1:has-text("Организации платформы")')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
// Search input
|
test('03. Switch 3 brandbook themes (soft-light, light, dark) and verify data-theme on html', async ({ page }) => {
|
||||||
const searchInput = page.locator('input[placeholder*="Поиск"]');
|
await page.goto('/control');
|
||||||
await expect(searchInput).toBeVisible();
|
await page.waitForLoadState('domcontentloaded');
|
||||||
await searchInput.fill('Ритейл');
|
await expect(page.locator('h1:has-text("Сводная операторская панель")')).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
// Filtered result should show Retail Group
|
const isMobile = await page.locator('[data-testid="mobile-menu-button"]').isVisible();
|
||||||
await expect(page.locator('text=Северная Ритейл Группа').first()).toBeVisible();
|
if (isMobile) {
|
||||||
|
await page.locator('[data-testid="mobile-menu-button"]').click();
|
||||||
|
|
||||||
// Click 360 card button
|
// Soft-light
|
||||||
const inspectBtn = page.locator('button:has-text("360° Карточка")').first();
|
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();
|
await inspectBtn.click();
|
||||||
|
|
||||||
// Contextual Drawer opens
|
// Contextual Drawer opens
|
||||||
const drawer = page.locator('div[role="complementary"][aria-label="Контекстный инспектор"]');
|
const drawer = page.locator('div[role="complementary"][aria-label="Контекстный инспектор"]');
|
||||||
await expect(drawer).toBeVisible();
|
await expect(drawer).toBeVisible();
|
||||||
await expect(drawer.locator('text=Северная Ритейл Группа').first()).toBeVisible();
|
|
||||||
await expect(drawer.locator('text=Действия оператора платформы')).toBeVisible();
|
await expect(drawer.locator('text=Действия оператора платформы')).toBeVisible();
|
||||||
|
|
||||||
// Verify 360 tabs
|
// Verify 360 tabs
|
||||||
await expect(drawer.locator('button:has-text("Обзор")')).toBeVisible();
|
await expect(drawer.locator('button:has-text("Обзор")')).toBeVisible();
|
||||||
await expect(drawer.locator('button:has-text("Модули")').first()).toBeVisible();
|
const modulesTab = drawer.locator('button:has-text("Модули")').first();
|
||||||
|
await expect(modulesTab).toBeVisible();
|
||||||
// Switch to Modules tab
|
await modulesTab.click();
|
||||||
await drawer.locator('button:has-text("Модули")').first().click();
|
|
||||||
await expect(drawer.locator('div:has-text("v1."), div:has-text("v2."), div:has-text("v3.")').first()).toBeVisible();
|
|
||||||
|
|
||||||
// Close drawer
|
// Close drawer
|
||||||
await drawer.locator('button[aria-label="Закрыть инспектор"]').click();
|
await drawer.locator('button[aria-label="Закрыть инспектор"]').click();
|
||||||
await expect(drawer).not.toBeVisible();
|
await expect(drawer).not.toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('03. Scenario 2: Dangerous action requires operator authorization & mandatory reason', async ({ page }) => {
|
test('06. Filters, tabs and accordions operate correctly', async ({ page }) => {
|
||||||
await page.click('a[href="/control/organizations"]');
|
// Organizations search
|
||||||
await page.waitForURL('**/control/organizations');
|
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
|
// Billing quick filters
|
||||||
await page.locator('button:has-text("360° Карточка")').first().click();
|
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="Контекстный инспектор"]');
|
const drawer = page.locator('div[role="complementary"][aria-label="Контекстный инспектор"]');
|
||||||
await expect(drawer).toBeVisible();
|
await expect(drawer).toBeVisible();
|
||||||
|
|
||||||
// Click trial extension button
|
const trialBtn = drawer.locator('button:has-text("Выдать Trial")');
|
||||||
const grantTrialBtn = drawer.locator('button:has-text("Выдать Trial")');
|
await expect(trialBtn).toBeVisible();
|
||||||
await expect(grantTrialBtn).toBeVisible();
|
await trialBtn.click();
|
||||||
await grantTrialBtn.click();
|
|
||||||
|
|
||||||
// Confirm modal opens
|
const modal = page.locator('div[role="dialog"][aria-labelledby="confirm-modal-title"]');
|
||||||
const confirmDialog = page.locator('div[role="dialog"][aria-labelledby="confirm-modal-title"]');
|
await expect(modal).toBeVisible();
|
||||||
await expect(confirmDialog).toBeVisible();
|
await expect(modal.locator('text=Требуется авторизация оператора')).toBeVisible();
|
||||||
await expect(confirmDialog.locator('text=Требуется авторизация оператора')).toBeVisible();
|
|
||||||
|
|
||||||
// Attempt submit without reason
|
// Submit empty reason -> validation error
|
||||||
const submitBtn = confirmDialog.locator('button:has-text("Подтвердить операцию")');
|
const submitBtn = modal.locator('button:has-text("Подтвердить операцию")');
|
||||||
await submitBtn.click();
|
await submitBtn.click();
|
||||||
await expect(confirmDialog.locator('text=Обоснование операции обязательно')).toBeVisible();
|
await expect(modal.locator('text=Обоснование операции обязательно')).toBeVisible();
|
||||||
|
|
||||||
// Fill valid reason (>= 5 chars)
|
// Fill valid reason (>= 5 chars)
|
||||||
const reasonInput = confirmDialog.locator('textarea[id="action-reason-input"]');
|
await modal.locator('textarea[id="action-reason-input"]').fill('Согласованное продление пилота #PILOT-2026');
|
||||||
await reasonInput.fill('Согласованное продление пилота по заявке #PILOT-1049');
|
|
||||||
await submitBtn.click();
|
await submitBtn.click();
|
||||||
|
await expect(modal).not.toBeVisible();
|
||||||
// Modal closes
|
|
||||||
await expect(confirmDialog).not.toBeVisible();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('04. Scenario 3: Filter subscriptions expiring in 7 days', async ({ page }) => {
|
test('08. Switch operator role and observe RBAC enforcement', async ({ page }) => {
|
||||||
await page.click('a[href="/control/billing"]');
|
await page.goto('/control');
|
||||||
await page.waitForURL('**/control/billing');
|
await page.waitForLoadState('domcontentloaded');
|
||||||
|
|
||||||
await expect(page.locator('h1:has-text("Подписки, лицензии и Entitlements")')).toBeVisible();
|
const isMobile = await page.locator('[data-testid="mobile-menu-button"]').isVisible();
|
||||||
|
if (isMobile) {
|
||||||
// Click "Истекает 7 дней" filter
|
await page.locator('[data-testid="mobile-menu-button"]').click();
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
test('10. Scenario 9: Switch operator role and observe strict RBAC enforcement', async ({ page }) => {
|
const switcher = page.locator('[data-testid="operator-session-button"]:visible').first();
|
||||||
const switcher = page.locator('button[aria-label="Сессия оператора"]');
|
|
||||||
await switcher.click();
|
await switcher.click();
|
||||||
|
|
||||||
// Switch to security_auditor
|
const auditorOption = page.locator('[data-testid="role-option-security_auditor"]:visible').first();
|
||||||
const auditorItem = page.locator('button[role="menuitem"]:has-text("Аудитор безопасности")');
|
await expect(auditorOption).toBeVisible();
|
||||||
await expect(auditorItem).toBeVisible();
|
await auditorOption.click();
|
||||||
await auditorItem.click();
|
|
||||||
|
|
||||||
// Topbar now shows "Аудитор безопасности"
|
// Header / profile now indicates auditor
|
||||||
await expect(page.locator('text=Аудитор безопасности').first()).toBeVisible();
|
await expect(page.locator('*:has-text("Аудитор"):visible').first()).toBeVisible();
|
||||||
|
|
||||||
// Navigate to Modules page: triggering rollouts should be disabled for auditor
|
// Navigate to modules: triggering rollout should be disabled for auditor
|
||||||
await page.click('a[href="/control/modules"]');
|
await page.goto('/control/modules');
|
||||||
await page.waitForURL('**/control/modules');
|
await page.waitForLoadState('domcontentloaded');
|
||||||
|
|
||||||
const rolloutBtn = page.locator('button:has-text("Запустить пилотный Rollout"), button:has-text("Поставить на паузу")').first();
|
const rolloutBtn = page.locator('button:has-text("Запустить пилотный Rollout"), button:has-text("Поставить на паузу")').first();
|
||||||
if (await rolloutBtn.isVisible()) {
|
if (await rolloutBtn.isVisible()) {
|
||||||
await expect(rolloutBtn).toBeDisabled();
|
await expect(rolloutBtn).toBeDisabled();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Switch back to platform_owner
|
// Switch back to owner
|
||||||
await switcher.click();
|
if (await page.locator('[data-testid="mobile-menu-button"]').isVisible()) {
|
||||||
const ownerItem = page.locator('button[role="menuitem"]:has-text("Владелец платформы")');
|
await page.locator('[data-testid="mobile-menu-button"]').click();
|
||||||
await ownerItem.click();
|
}
|
||||||
await expect(page.locator('text=Владелец платформы').first()).toBeVisible();
|
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 }) => {
|
test('09. Control Center UI is completely localized in Russian', async ({ page }) => {
|
||||||
await page.click('a[href="/control/audit"]');
|
const pagesToCheck = [
|
||||||
await page.waitForURL('**/control/audit');
|
{ 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();
|
await expect(page.locator('h1:has-text("Неизменяемый журнал аудита")')).toBeVisible();
|
||||||
|
|
||||||
// Wait for audit log rows to load from IndexedDB
|
// Verify audit log rows
|
||||||
await expect(page.locator('tbody tr')).toHaveCount(3);
|
await expect(page.locator('tbody tr')).not.toHaveCount(0);
|
||||||
const initialRowsCount = await page.locator('tbody tr').count();
|
const initialRowsCount = await page.locator('tbody tr').count();
|
||||||
expect(initialRowsCount).toBe(3);
|
|
||||||
|
|
||||||
// Reload page
|
// Reload page
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await page.waitForLoadState('domcontentloaded');
|
await page.waitForLoadState('domcontentloaded');
|
||||||
await expect(page.locator('text=IndexedDB Synced')).toBeVisible({ timeout: 15000 });
|
await expect(page.locator('h1:has-text("Неизменяемый журнал аудита")')).toBeVisible();
|
||||||
|
|
||||||
// Audit entries must still be present from IndexedDB
|
|
||||||
await expect(page.locator('tbody tr')).toHaveCount(initialRowsCount);
|
await expect(page.locator('tbody tr')).toHaveCount(initialRowsCount);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ export default defineConfig({
|
||||||
workers: 1,
|
workers: 1,
|
||||||
reporter: 'list',
|
reporter: 'list',
|
||||||
use: {
|
use: {
|
||||||
baseURL: 'http://localhost:5173',
|
baseURL: 'http://127.0.0.1:5180',
|
||||||
trace: 'on-first-retry',
|
trace: 'on-first-retry',
|
||||||
},
|
},
|
||||||
projects: [
|
projects: [
|
||||||
|
|
@ -33,9 +33,9 @@ export default defineConfig({
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
webServer: {
|
webServer: {
|
||||||
command: 'npm run dev',
|
command: 'npm run dev -- --port 5180 --host 127.0.0.1',
|
||||||
url: 'http://localhost:5173',
|
url: 'http://127.0.0.1:5180',
|
||||||
reuseExistingServer: !process.env.CI,
|
reuseExistingServer: false,
|
||||||
timeout: 120000,
|
timeout: 120000,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { ShieldAlert, AlertTriangle, CheckCircle, X } from 'lucide-react';
|
import { ShieldAlert, AlertTriangle, CheckCircle, X } from 'lucide-react';
|
||||||
import { useKccStore, useOperatorRole } from '../store/kccStore';
|
import { useKccStore, useOperatorRole } from '../store/kccStore';
|
||||||
|
import { RU_ROLES } from '../locales/ru';
|
||||||
|
|
||||||
interface ConfirmDialogProps {
|
interface ConfirmDialogProps {
|
||||||
pendingModal: NonNullable<ReturnType<typeof useKccStore.getState>['pendingConfirmModal']>;
|
pendingModal: NonNullable<ReturnType<typeof useKccStore.getState>['pendingConfirmModal']>;
|
||||||
|
|
@ -36,9 +37,11 @@ const ConfirmOperatorActionDialog: React.FC<ConfirmDialogProps> = ({ pendingModa
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const roleLabel = RU_ROLES[role]?.label || role;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/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"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="confirm-modal-title"
|
aria-labelledby="confirm-modal-title"
|
||||||
|
|
@ -46,17 +49,17 @@ const ConfirmOperatorActionDialog: React.FC<ConfirmDialogProps> = ({ pendingModa
|
||||||
if (e.key === 'Escape' && !isSubmitting) onClose();
|
if (e.key === 'Escape' && !isSubmitting) onClose();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="w-full max-w-lg rounded-xl border border-amber-500/40 bg-[#0d1e17] text-zinc-100 shadow-2xl p-6">
|
<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-zinc-800 pb-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="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" />
|
<ShieldAlert className="w-6 h-6" />
|
||||||
</div>
|
</div>
|
||||||
<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}
|
{pendingModal.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-zinc-400">
|
<p className="text-xs text-krona-secondary">
|
||||||
Требуется авторизация оператора и запись в журнал аудита
|
Требуется авторизация оператора и запись в журнал аудита
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -64,45 +67,45 @@ const ConfirmOperatorActionDialog: React.FC<ConfirmDialogProps> = ({ pendingModa
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
disabled={isSubmitting}
|
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="Закрыть"
|
aria-label="Закрыть"
|
||||||
>
|
>
|
||||||
<X className="w-5 h-5" />
|
<X className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="mt-5 space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
{/* Target & Action Summary */}
|
{/* 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="rounded-lg bg-krona-workspace border border-krona-border p-3.5 space-y-2 text-xs">
|
||||||
<div className="flex justify-between text-zinc-400">
|
<div className="flex justify-between text-krona-secondary">
|
||||||
<span>Целевой объект:</span>
|
<span>Целевой объект:</span>
|
||||||
<span className="font-semibold text-zinc-200">{pendingModal.targetName}</span>
|
<span className="font-semibold text-krona-primary">{pendingModal.targetName}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between text-zinc-400">
|
<div className="flex justify-between text-krona-secondary">
|
||||||
<span>Операция:</span>
|
<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>
|
||||||
<div className="flex justify-between text-zinc-400">
|
<div className="flex justify-between text-krona-secondary">
|
||||||
<span>Оператор:</span>
|
<span>Оператор:</span>
|
||||||
<span className="text-zinc-300 font-medium">
|
<span className="text-krona-primary font-medium">
|
||||||
{operator.name} ({role})
|
{operator.name} ({roleLabel})
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Impact Summary */}
|
{/* 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">
|
<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-400 shrink-0 mt-0.5" />
|
<AlertTriangle className="w-4 h-4 text-amber-500 shrink-0 mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
<div className="font-semibold text-amber-300 mb-0.5">Влияние операции (Impact Summary):</div>
|
<div className="font-semibold text-amber-600 dark:text-amber-300 mb-0.5">Влияние операции:</div>
|
||||||
<div>{pendingModal.impactSummary}</div>
|
<div className="text-krona-secondary">{pendingModal.impactSummary}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Reason Input */}
|
{/* Reason Input */}
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="action-reason-input" className="block text-xs font-semibold text-zinc-300 mb-1.5">
|
<label htmlFor="action-reason-input" className="block text-xs font-semibold text-krona-primary mb-1.5">
|
||||||
Обоснование операции (Reason) <span className="text-rose-400">*</span>
|
Обоснование операции (Reason) <span className="text-rose-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="action-reason-input"
|
id="action-reason-input"
|
||||||
|
|
@ -114,26 +117,26 @@ const ConfirmOperatorActionDialog: React.FC<ConfirmDialogProps> = ({ pendingModa
|
||||||
if (error) setError(null);
|
if (error) setError(null);
|
||||||
}}
|
}}
|
||||||
placeholder="Укажите причину выполнения действия (номер заявки, обоснование, согласование)..."
|
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}
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions with min 44px touch targets */}
|
||||||
<div className="flex items-center justify-end gap-3 pt-3 border-t border-zinc-800">
|
<div className="flex flex-col-reverse sm:flex-row items-center justify-end gap-2 pt-3 border-t border-krona-border">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
disabled={isSubmitting}
|
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>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isSubmitting}
|
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" />
|
<CheckCircle className="w-4 h-4" />
|
||||||
<span>{isSubmitting ? 'Подтверждение...' : 'Подтвердить операцию'}</span>
|
<span>{isSubmitting ? 'Подтверждение...' : 'Подтвердить операцию'}</span>
|
||||||
|
|
@ -153,3 +156,4 @@ export const ConfirmOperatorActionModal: React.FC = () => {
|
||||||
|
|
||||||
return <ConfirmOperatorActionDialog pendingModal={pendingModal} onClose={closeModal} />;
|
return <ConfirmOperatorActionDialog pendingModal={pendingModal} onClose={closeModal} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,18 @@ import { ServerMonitorOutageBanner } from './ServerMonitorOutageBanner';
|
||||||
import { useThemeStore, ThemeMode } from '../../../stores/theme';
|
import { useThemeStore, ThemeMode } from '../../../stores/theme';
|
||||||
import { RU_ROLES, RU_THEMES, RU_UI } from '../locales/ru';
|
import { RU_ROLES, RU_THEMES, RU_UI } from '../locales/ru';
|
||||||
|
|
||||||
|
const NAV_DEFINITIONS = [
|
||||||
|
{ to: '/control', label: 'Обзор', icon: LayoutDashboard, end: true },
|
||||||
|
{ to: '/control/organizations', label: 'Организации', icon: Building2 },
|
||||||
|
{ to: '/control/billing', label: 'Подписки и платежи', icon: CreditCard },
|
||||||
|
{ to: '/control/modules', label: 'Каталог модулей', icon: Package },
|
||||||
|
{ to: '/control/installations', label: 'Установки', icon: Smartphone },
|
||||||
|
{ to: '/control/infrastructure', label: 'Серверы', icon: Server },
|
||||||
|
{ to: '/control/announcements', label: 'Оповещения', icon: BellRing },
|
||||||
|
{ to: '/control/incidents', label: 'Инциденты', icon: AlertTriangle },
|
||||||
|
{ to: '/control/audit', label: 'Журнал аудита', icon: FileCheck2 },
|
||||||
|
];
|
||||||
|
|
||||||
export const ControlCenterShell: React.FC = () => {
|
export const ControlCenterShell: React.FC = () => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
@ -56,27 +68,32 @@ export const ControlCenterShell: React.FC = () => {
|
||||||
const [isMobileDrawerOpen, setIsMobileDrawerOpen] = useState(false);
|
const [isMobileDrawerOpen, setIsMobileDrawerOpen] = useState(false);
|
||||||
const [isTabletSidebarCollapsed, setIsTabletSidebarCollapsed] = useState(false);
|
const [isTabletSidebarCollapsed, setIsTabletSidebarCollapsed] = useState(false);
|
||||||
|
|
||||||
|
// Close mobile drawer on route change (render phase pattern)
|
||||||
|
const [prevPathname, setPrevPathname] = useState(location.pathname);
|
||||||
|
if (prevPathname !== location.pathname) {
|
||||||
|
setPrevPathname(location.pathname);
|
||||||
|
setIsMobileDrawerOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isInitialized) {
|
if (!isInitialized) {
|
||||||
initialize();
|
initialize();
|
||||||
}
|
}
|
||||||
}, [isInitialized, initialize]);
|
}, [isInitialized, initialize]);
|
||||||
|
|
||||||
|
// UTC clock ticker
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const updateTime = () => {
|
const updateTime = () => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
setUtcTime(now.toISOString().replace('T', ' ').slice(0, 19) + ' UTC');
|
setUtcTime(
|
||||||
|
`${now.toUTCString().slice(17, 25)} UTC`
|
||||||
|
);
|
||||||
};
|
};
|
||||||
updateTime();
|
updateTime();
|
||||||
const interval = setInterval(updateTime, 1000);
|
const interval = setInterval(updateTime, 1000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Close mobile drawer on route change
|
|
||||||
useEffect(() => {
|
|
||||||
setIsMobileDrawerOpen(false);
|
|
||||||
}, [location.pathname]);
|
|
||||||
|
|
||||||
const handleReset = async () => {
|
const handleReset = async () => {
|
||||||
if (confirm('Сбросить демонстрационные данные платформы к исходному состоянию?')) {
|
if (confirm('Сбросить демонстрационные данные платформы к исходному состоянию?')) {
|
||||||
setIsResetting(true);
|
setIsResetting(true);
|
||||||
|
|
@ -92,46 +109,33 @@ export const ControlCenterShell: React.FC = () => {
|
||||||
let count = 0;
|
let count = 0;
|
||||||
if (serverMonitorOutage) count += 1;
|
if (serverMonitorOutage) count += 1;
|
||||||
count += incidents.filter(
|
count += incidents.filter(
|
||||||
(i) => i.status !== 'resolved' && (i.severity === 'P0_CRITICAL' || i.severity === 'P1_MAJOR')
|
(i) => i.status !== 'resolved' && (i.severity === 'critical' || i.severity === 'high')
|
||||||
).length;
|
).length;
|
||||||
return count;
|
return count;
|
||||||
}, [serverMonitorOutage, incidents]);
|
}, [serverMonitorOutage, incidents]);
|
||||||
|
|
||||||
// Navigation Items
|
// Navigation Items
|
||||||
const primaryMobileNav = [
|
const primaryMobileNav = useMemo(() => [
|
||||||
{ to: '/control', label: 'Обзор', icon: LayoutDashboard, end: true },
|
{ to: '/control', label: 'Обзор', icon: LayoutDashboard, end: true },
|
||||||
{ to: '/control/organizations', label: 'Организации', icon: Building2 },
|
{ to: '/control/organizations', label: 'Организации', icon: Building2 },
|
||||||
{ to: '/control/incidents', label: 'Инциденты', icon: AlertTriangle, badge: criticalCount > 0 ? criticalCount : undefined },
|
{ to: '/control/incidents', label: 'Инциденты', icon: AlertTriangle, badge: criticalCount > 0 ? criticalCount : undefined },
|
||||||
{ to: '/control/infrastructure', label: 'Серверы', icon: Server },
|
{ to: '/control/infrastructure', label: 'Серверы', icon: Server },
|
||||||
];
|
], [criticalCount]);
|
||||||
|
|
||||||
const allNavItems = [
|
const allNavItems = useMemo(() => {
|
||||||
{ to: '/control', label: 'Обзор', icon: LayoutDashboard, end: true },
|
return NAV_DEFINITIONS.map((item) => ({
|
||||||
{ to: '/control/organizations', label: 'Организации', icon: Building2 },
|
...item,
|
||||||
{ to: '/control/billing', label: 'Подписки и платежи', icon: CreditCard },
|
badge: item.to === '/control/incidents' && criticalCount > 0 ? criticalCount : undefined,
|
||||||
{ to: '/control/modules', label: 'Каталог модулей', icon: Package },
|
}));
|
||||||
{ to: '/control/installations', label: 'Установки', icon: Smartphone },
|
}, [criticalCount]);
|
||||||
{ to: '/control/infrastructure', label: 'Серверы', icon: Server },
|
|
||||||
{ to: '/control/announcements', label: 'Оповещения', icon: BellRing },
|
|
||||||
{ to: '/control/incidents', label: 'Инциденты', icon: AlertTriangle, badge: criticalCount > 0 ? criticalCount : undefined },
|
|
||||||
{ to: '/control/audit', label: 'Журнал аудита', icon: FileCheck2 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const moreNavItems = [
|
|
||||||
{ to: '/control/billing', label: 'Подписки и платежи', icon: CreditCard },
|
|
||||||
{ to: '/control/modules', label: 'Каталог модулей', icon: Package },
|
|
||||||
{ to: '/control/installations', label: 'Установки', icon: Smartphone },
|
|
||||||
{ to: '/control/announcements', label: 'Оповещения', icon: BellRing },
|
|
||||||
{ to: '/control/audit', label: 'Журнал аудита', icon: FileCheck2 },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Current section title
|
// Current section title
|
||||||
const currentTitle = useMemo(() => {
|
const currentTitle = useMemo(() => {
|
||||||
const current = allNavItems.find((item) =>
|
const current = NAV_DEFINITIONS.find((item) =>
|
||||||
item.end ? location.pathname === item.to : location.pathname.startsWith(item.to)
|
item.end ? location.pathname === item.to : location.pathname.startsWith(item.to)
|
||||||
);
|
);
|
||||||
return current?.label || 'Control Center';
|
return current?.label || 'Control Center';
|
||||||
}, [location.pathname, allNavItems]);
|
}, [location.pathname]);
|
||||||
|
|
||||||
const currentRoleLabel = RU_ROLES[role]?.shortLabel || role;
|
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 { useKccStore } from '../store/kccStore';
|
||||||
import { ControlForbiddenPage } from '../pages/ControlForbiddenPage';
|
import { ControlForbiddenPage } from '../pages/ControlForbiddenPage';
|
||||||
|
|
||||||
|
|
@ -17,6 +17,11 @@ interface ControlRouteGuardProps {
|
||||||
|
|
||||||
export const ControlRouteGuard: React.FC<ControlRouteGuardProps> = ({ children }) => {
|
export const ControlRouteGuard: React.FC<ControlRouteGuardProps> = ({ children }) => {
|
||||||
const activeRole = useKccStore((state) => state.activeRole);
|
const activeRole = useKccStore((state) => state.activeRole);
|
||||||
|
const isAccessGatePassed = useKccStore((state) => state.isAccessGatePassed);
|
||||||
|
|
||||||
|
if (!isAccessGatePassed) {
|
||||||
|
return <Navigate to="/control/gate" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
if (!ALLOWED_OPERATOR_ROLES.includes(activeRole)) {
|
if (!ALLOWED_OPERATOR_ROLES.includes(activeRole)) {
|
||||||
return <ControlForbiddenPage />;
|
return <ControlForbiddenPage />;
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { StatusBadge } from './StatusBadge';
|
||||||
import { MoneyDisplay } from './MoneyDisplay';
|
import { MoneyDisplay } from './MoneyDisplay';
|
||||||
import { HeartbeatTimeline } from './HeartbeatTimeline';
|
import { HeartbeatTimeline } from './HeartbeatTimeline';
|
||||||
import { PlatformStatus } from '../contracts';
|
import { PlatformStatus } from '../contracts';
|
||||||
|
import { RU_INCIDENT_SEVERITY, RU_PLATFORMS, RU_ROLES } from '../locales/ru';
|
||||||
|
|
||||||
export const InspectorDrawer: React.FC = () => {
|
export const InspectorDrawer: React.FC = () => {
|
||||||
const inspectorTarget = useKccStore((state) => state.inspectorTarget);
|
const inspectorTarget = useKccStore((state) => state.inspectorTarget);
|
||||||
|
|
@ -45,7 +46,7 @@ export const InspectorDrawer: React.FC = () => {
|
||||||
switch (inspectorTarget.type) {
|
switch (inspectorTarget.type) {
|
||||||
case 'organization': {
|
case 'organization': {
|
||||||
const org = organizations.find((o) => o.tenantId === inspectorTarget.id);
|
const org = organizations.find((o) => o.tenantId === inspectorTarget.id);
|
||||||
if (!org) return <div className="p-6 text-zinc-400">Организация не найдена</div>;
|
if (!org) return <div className="p-6 text-krona-secondary">Организация не найдена</div>;
|
||||||
|
|
||||||
const sub = subscriptions.find((s) => s.tenantId === org.tenantId);
|
const sub = subscriptions.find((s) => s.tenantId === org.tenantId);
|
||||||
const orgInstances = instances.filter((i) => i.tenantId === org.tenantId);
|
const orgInstances = instances.filter((i) => i.tenantId === org.tenantId);
|
||||||
|
|
@ -88,16 +89,16 @@ export const InspectorDrawer: React.FC = () => {
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2 mb-1.5">
|
<div className="flex items-center gap-2 mb-1.5">
|
||||||
<StatusBadge status={org.platformStatus} />
|
<StatusBadge status={org.platformStatus} />
|
||||||
<span className="text-xs text-zinc-400 font-mono">ИНН: {org.inn}</span>
|
<span className="text-xs text-krona-secondary font-mono">ИНН: {org.inn}</span>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-lg font-bold text-zinc-100">{org.displayName}</h2>
|
<h2 className="text-lg font-bold text-krona-primary">{org.displayName}</h2>
|
||||||
<p className="text-xs text-zinc-400 font-mono">{org.domain} • ID: {org.tenantId.slice(0, 8)}...</p>
|
<p className="text-xs text-krona-secondary font-mono">{org.domain} • ID: {org.tenantId.slice(0, 8)}...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Operator Actions Bar */}
|
{/* Operator Actions Bar */}
|
||||||
<div className="rounded-xl bg-zinc-950/70 border border-zinc-800 p-3.5 space-y-3">
|
<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-zinc-300 flex items-center gap-1.5">
|
<div className="text-xs font-semibold text-krona-primary flex items-center gap-1.5">
|
||||||
<ShieldAlert className="w-4 h-4 text-amber-400" />
|
<ShieldAlert className="w-4 h-4 text-amber-500" />
|
||||||
<span>Действия оператора платформы</span>
|
<span>Действия оператора платформы</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
|
|
@ -105,7 +106,7 @@ export const InspectorDrawer: React.FC = () => {
|
||||||
<button
|
<button
|
||||||
disabled={!permissions.canManagePlatformStatus}
|
disabled={!permissions.canManagePlatformStatus}
|
||||||
onClick={() => handleStatusChange('active')}
|
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)
|
Активировать (Active)
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -114,7 +115,7 @@ export const InspectorDrawer: React.FC = () => {
|
||||||
<button
|
<button
|
||||||
disabled={!permissions.canGrantTrialGrace}
|
disabled={!permissions.canGrantTrialGrace}
|
||||||
onClick={() => handleStatusChange('grace')}
|
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
|
Назначить Grace
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -123,7 +124,7 @@ export const InspectorDrawer: React.FC = () => {
|
||||||
<button
|
<button
|
||||||
disabled={!permissions.canManagePlatformStatus}
|
disabled={!permissions.canManagePlatformStatus}
|
||||||
onClick={() => handleStatusChange('suspended')}
|
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)
|
Заблокировать (Suspend)
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -132,13 +133,13 @@ export const InspectorDrawer: React.FC = () => {
|
||||||
|
|
||||||
{/* Trial grant inline form */}
|
{/* Trial grant inline form */}
|
||||||
{permissions.canGrantTrialGrace && (
|
{permissions.canGrantTrialGrace && (
|
||||||
<div className="pt-2 border-t border-zinc-800/80 space-y-2 text-xs">
|
<div className="pt-2 border-t border-krona-border space-y-2 text-xs">
|
||||||
<div className="font-semibold text-zinc-300">Выдать Trial на модуль:</div>
|
<div className="font-semibold text-krona-primary">Выдать Trial на модуль:</div>
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex flex-wrap gap-2 items-center">
|
||||||
<select
|
<select
|
||||||
value={selectedTrialModule}
|
value={selectedTrialModule}
|
||||||
onChange={(e) => setSelectedTrialModule(e.target.value)}
|
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) => (
|
{modules.map((m) => (
|
||||||
<option key={m.moduleId} value={m.moduleId}>
|
<option key={m.moduleId} value={m.moduleId}>
|
||||||
|
|
@ -149,7 +150,7 @@ export const InspectorDrawer: React.FC = () => {
|
||||||
<select
|
<select
|
||||||
value={trialDurationDays}
|
value={trialDurationDays}
|
||||||
onChange={(e) => setTrialDurationDays(Number(e.target.value))}
|
onChange={(e) => setTrialDurationDays(Number(e.target.value))}
|
||||||
className="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={7}>7 дней</option>
|
||||||
<option value={14}>14 дней</option>
|
<option value={14}>14 дней</option>
|
||||||
|
|
@ -158,7 +159,7 @@ export const InspectorDrawer: React.FC = () => {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleGrantTrial}
|
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
|
Выдать Trial
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -168,7 +169,7 @@ export const InspectorDrawer: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation Tabs */}
|
{/* 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: 'overview', label: 'Обзор' },
|
||||||
{ id: 'users', label: `Пользователи (${org.userCount})` },
|
{ id: 'users', label: `Пользователи (${org.userCount})` },
|
||||||
|
|
@ -181,10 +182,10 @@ export const InspectorDrawer: React.FC = () => {
|
||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
onClick={() => setOrgActiveTab(tab.id as typeof orgActiveTab)}
|
onClick={() => setOrgActiveTab(tab.id as typeof orgActiveTab)}
|
||||||
className={`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
|
orgActiveTab === tab.id
|
||||||
? 'bg-zinc-800/80 text-amber-300 border-b-2 border-amber-400'
|
? 'bg-amber-500/10 text-amber-500 border-b-2 border-amber-500'
|
||||||
: 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-900'
|
: 'text-krona-secondary hover:text-krona-primary hover:bg-krona-workspace'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
|
|
@ -196,57 +197,57 @@ export const InspectorDrawer: React.FC = () => {
|
||||||
{orgActiveTab === 'overview' && (
|
{orgActiveTab === 'overview' && (
|
||||||
<div className="space-y-4 text-xs">
|
<div className="space-y-4 text-xs">
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="p-3 rounded-lg bg-zinc-950/60 border border-zinc-800/80">
|
<div className="p-3 rounded-lg bg-krona-workspace/60 border border-krona-border">
|
||||||
<div className="text-zinc-400 mb-1">Тарифный план</div>
|
<div className="text-krona-secondary mb-1">Тарифный план</div>
|
||||||
<div className="font-semibold text-zinc-200 text-sm">{org.tariffPlan}</div>
|
<div className="font-semibold text-krona-primary text-sm">{org.tariffPlan}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 rounded-lg bg-zinc-950/60 border border-zinc-800/80">
|
<div className="p-3 rounded-lg bg-krona-workspace/60 border border-krona-border">
|
||||||
<div className="text-zinc-400 mb-1">MRR доход</div>
|
<div className="text-krona-secondary mb-1">MRR доход</div>
|
||||||
<MoneyDisplay minorUnits={org.mrrMinorUnits} className="text-sm font-bold text-amber-400" />
|
<MoneyDisplay minorUnits={org.mrrMinorUnits} className="text-sm font-bold text-amber-500" />
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 rounded-lg bg-zinc-950/60 border border-zinc-800/80">
|
<div className="p-3 rounded-lg bg-krona-workspace/60 border border-krona-border">
|
||||||
<div className="text-zinc-400 mb-1">Филиалов / точек</div>
|
<div className="text-krona-secondary mb-1">Филиалов / точек</div>
|
||||||
<div className="font-semibold text-zinc-200 text-sm">{org.branchCount}</div>
|
<div className="font-semibold text-krona-primary text-sm">{org.branchCount}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 rounded-lg bg-zinc-950/60 border border-zinc-800/80">
|
<div className="p-3 rounded-lg bg-krona-workspace/60 border border-krona-border">
|
||||||
<div className="text-zinc-400 mb-1">Задолженность</div>
|
<div className="text-krona-secondary mb-1">Задолженность</div>
|
||||||
<MoneyDisplay
|
<MoneyDisplay
|
||||||
minorUnits={org.outstandingDebtMinorUnits}
|
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>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg bg-zinc-950/60 border border-zinc-800 p-3 space-y-2">
|
<div className="rounded-lg bg-krona-workspace/60 border border-krona-border p-3 space-y-2">
|
||||||
<div className="font-semibold text-zinc-300">Контакты организации:</div>
|
<div className="font-semibold text-krona-primary">Контакты организации:</div>
|
||||||
<div className="text-zinc-400">Email: <span className="text-zinc-200">{org.contactEmail}</span></div>
|
<div className="text-krona-secondary">Email: <span className="text-krona-primary">{org.contactEmail}</span></div>
|
||||||
<div className="text-zinc-400">Телефон: <span className="text-zinc-200">{org.contactPhone}</span></div>
|
<div className="text-krona-secondary">Телефон: <span className="text-krona-primary">{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-krona-secondary">Последняя активность: <span className="text-krona-primary">{new Date(org.lastActiveAt).toLocaleString('ru-RU')}</span></div>
|
||||||
<div className="text-zinc-400">Окончание подписки: <span className="text-zinc-200">{new Date(org.subscriptionEndAt).toLocaleDateString('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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{orgActiveTab === 'users' && (
|
{orgActiveTab === 'users' && (
|
||||||
<div className="space-y-3 text-xs">
|
<div className="space-y-3 text-xs">
|
||||||
<div className="p-3 rounded-lg bg-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">
|
<div className="flex justify-between items-center mb-2">
|
||||||
<span className="font-semibold text-zinc-200">Пользователи-агрегаты</span>
|
<span className="font-semibold text-krona-primary">Пользователи-агрегаты</span>
|
||||||
<span className="text-amber-400 font-bold">{org.userCount} учетных записей</span>
|
<span className="text-amber-500 font-bold">{org.userCount} учетных записей</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-zinc-400 text-[11px] leading-relaxed">
|
<p className="text-krona-secondary text-[11px] leading-relaxed">
|
||||||
В соответствии с контрактом безопасности Control Center отображает только деперсонализированные
|
В соответствии с контрактом безопасности Control Center отображает только деперсонализированные
|
||||||
агрегаты тенанта. Персональные данные сотрудников защищены контуром организации.
|
агрегаты тенанта. Персональные данные сотрудников защищены контуром организации.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-2 text-zinc-300">
|
<div className="grid grid-cols-2 gap-2 text-krona-primary">
|
||||||
<div className="p-2.5 rounded-lg bg-zinc-900 border border-zinc-800">
|
<div className="p-2.5 rounded-lg bg-krona-workspace border border-krona-border">
|
||||||
<div className="text-zinc-400">Администраторы</div>
|
<div className="text-krona-secondary">Администраторы</div>
|
||||||
<div className="font-bold text-sm text-zinc-100">2</div>
|
<div className="font-bold text-sm text-krona-primary">2</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-2.5 rounded-lg bg-zinc-900 border border-zinc-800">
|
<div className="p-2.5 rounded-lg bg-krona-workspace border border-krona-border">
|
||||||
<div className="text-zinc-400">Менеджеры / операторы</div>
|
<div className="text-krona-secondary">Менеджеры / операторы</div>
|
||||||
<div className="font-bold text-sm text-zinc-100">{Math.max(0, org.userCount - 6)}</div>
|
<div className="font-bold text-sm text-krona-primary">{Math.max(0, org.userCount - 6)}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -255,10 +256,10 @@ export const InspectorDrawer: React.FC = () => {
|
||||||
{orgActiveTab === 'modules' && (
|
{orgActiveTab === 'modules' && (
|
||||||
<div className="space-y-2 text-xs">
|
<div className="space-y-2 text-xs">
|
||||||
{orgInstances.map((inst) => (
|
{orgInstances.map((inst) => (
|
||||||
<div key={inst.moduleInstanceId} className="p-3 rounded-lg bg-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>
|
||||||
<div className="font-semibold text-zinc-200">{inst.moduleName}</div>
|
<div className="font-semibold text-krona-primary">{inst.moduleName}</div>
|
||||||
<div className="text-[11px] text-zinc-400 font-mono mt-0.5">
|
<div className="text-[11px] text-krona-secondary font-mono mt-0.5">
|
||||||
v{inst.currentVersion} • {inst.serverName}
|
v{inst.currentVersion} • {inst.serverName}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -271,46 +272,46 @@ export const InspectorDrawer: React.FC = () => {
|
||||||
{orgActiveTab === 'installations' && (
|
{orgActiveTab === 'installations' && (
|
||||||
<div className="space-y-2 text-xs">
|
<div className="space-y-2 text-xs">
|
||||||
{orgInstalls.map((ins) => (
|
{orgInstalls.map((ins) => (
|
||||||
<div key={ins.installationId} className="p-3 rounded-lg bg-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>
|
||||||
<div className="font-semibold text-zinc-200 flex items-center gap-1.5">
|
<div className="font-semibold text-krona-primary flex items-center gap-1.5">
|
||||||
<Smartphone className="w-3.5 h-3.5 text-zinc-400" />
|
<Smartphone className="w-3.5 h-3.5 text-krona-secondary" />
|
||||||
<span>{ins.deviceName}</span>
|
<span>{ins.deviceName}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[11px] text-zinc-400 font-mono mt-0.5">
|
<div className="text-[11px] text-krona-secondary font-mono mt-0.5">
|
||||||
{ins.kind.toUpperCase()} • v{ins.appVersion} • {ins.osVersion}
|
{RU_PLATFORMS[ins.kind] || ins.kind} • v{ins.appVersion} • {ins.osVersion}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<StatusBadge status={ins.status} size="sm" />
|
<StatusBadge status={ins.status} size="sm" />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{orgInstalls.length === 0 && <div className="text-zinc-500 py-4 text-center">Нет активных установок</div>}
|
{orgInstalls.length === 0 && <div className="text-krona-secondary py-4 text-center">Нет активных установок</div>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{orgActiveTab === 'billing' && (
|
{orgActiveTab === 'billing' && (
|
||||||
<div className="space-y-3 text-xs">
|
<div className="space-y-3 text-xs">
|
||||||
{sub ? (
|
{sub ? (
|
||||||
<div className="p-3 rounded-lg bg-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">
|
<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" />
|
<StatusBadge status={sub.status} size="sm" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between text-zinc-400">
|
<div className="flex justify-between text-krona-secondary">
|
||||||
<span>Период:</span>
|
<span>Период:</span>
|
||||||
<span className="text-zinc-200">{sub.period === 'year' ? 'Годовой' : 'Ежемесячный'}</span>
|
<span className="text-krona-primary">{sub.period === 'year' ? 'Годовой' : 'Ежемесячный'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between text-zinc-400">
|
<div className="flex justify-between text-krona-secondary">
|
||||||
<span>Сумма тарифа:</span>
|
<span>Сумма тарифа:</span>
|
||||||
<MoneyDisplay minorUnits={sub.amountMinorUnits} className="text-amber-400 font-bold" />
|
<MoneyDisplay minorUnits={sub.amountMinorUnits} className="text-amber-500 font-bold" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between text-zinc-400">
|
<div className="flex justify-between text-krona-secondary">
|
||||||
<span>Действует до:</span>
|
<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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-zinc-500 py-4 text-center">Данные подписки отсутствуют</div>
|
<div className="text-krona-secondary py-4 text-center">Данные подписки отсутствуют</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -318,31 +319,31 @@ export const InspectorDrawer: React.FC = () => {
|
||||||
{orgActiveTab === 'incidents' && (
|
{orgActiveTab === 'incidents' && (
|
||||||
<div className="space-y-2 text-xs">
|
<div className="space-y-2 text-xs">
|
||||||
{orgIncidents.map((inc) => (
|
{orgIncidents.map((inc) => (
|
||||||
<div key={inc.incidentId} className="p-3 rounded-lg bg-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">
|
<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" />
|
<StatusBadge status={inc.status} size="sm" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-zinc-300">{inc.summary}</p>
|
<p className="text-krona-primary">{inc.summary}</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{orgActiveTab === 'audit' && (
|
{orgActiveTab === 'audit' && (
|
||||||
<div className="space-y-2 text-xs">
|
<div className="space-y-2 text-xs">
|
||||||
{orgAudits.map((a) => (
|
{orgAudits.map((a) => (
|
||||||
<div key={a.eventId} className="p-2.5 rounded-lg bg-zinc-950/60 border border-zinc-800 space-y-1">
|
<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-zinc-400 font-mono text-[10px]">
|
<div className="flex justify-between text-krona-secondary font-mono text-[10px]">
|
||||||
<span>{a.action}</span>
|
<span>{a.action}</span>
|
||||||
<span>{new Date(a.occurredAt).toLocaleTimeString('ru-RU')}</span>
|
<span>{new Date(a.occurredAt).toLocaleTimeString('ru-RU')}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-zinc-200 text-[11px]">{a.reason}</p>
|
<p className="text-krona-primary text-[11px]">{a.reason}</p>
|
||||||
<div className="text-[10px] text-amber-400/80">{a.operatorName} ({a.operatorRole})</div>
|
<div className="text-[10px] text-amber-500">{a.operatorName} ({RU_ROLES[a.operatorRole]?.shortLabel || a.operatorRole})</div>
|
||||||
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -351,7 +352,7 @@ export const InspectorDrawer: React.FC = () => {
|
||||||
|
|
||||||
case 'server': {
|
case 'server': {
|
||||||
const srv = servers.find((s) => s.serverId === inspectorTarget.id);
|
const srv = servers.find((s) => s.serverId === inspectorTarget.id);
|
||||||
if (!srv) return <div className="p-6 text-zinc-400">Сервер не найден</div>;
|
if (!srv) return <div className="p-6 text-krona-secondary">Сервер не найден</div>;
|
||||||
|
|
||||||
const srvInstances = instances.filter((i) => i.serverId === srv.serverId);
|
const srvInstances = instances.filter((i) => i.serverId === srv.serverId);
|
||||||
|
|
||||||
|
|
@ -388,23 +389,23 @@ export const InspectorDrawer: React.FC = () => {
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2 mb-1.5">
|
<div className="flex items-center gap-2 mb-1.5">
|
||||||
<StatusBadge status={srv.status} />
|
<StatusBadge status={srv.status} />
|
||||||
<span className="text-xs text-zinc-400 font-mono">{srv.hostingProvider} • {srv.region}</span>
|
<span className="text-xs text-krona-secondary font-mono">{srv.hostingProvider} • {srv.region}</span>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-lg font-bold text-zinc-100">{srv.name}</h2>
|
<h2 className="text-lg font-bold text-krona-primary">{srv.name}</h2>
|
||||||
<p className="text-xs text-zinc-400 font-mono">{srv.ipAddress} • {srv.hostname}</p>
|
<p className="text-xs text-krona-secondary font-mono">{srv.ipAddress} • {srv.hostname}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Operator Actions */}
|
{/* Operator Actions */}
|
||||||
<div className="rounded-xl bg-zinc-950/70 border border-zinc-800 p-3.5 space-y-2.5">
|
<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-zinc-300 flex items-center gap-1.5">
|
<div className="text-xs font-semibold text-krona-primary flex items-center gap-1.5">
|
||||||
<ShieldAlert className="w-4 h-4 text-amber-400" />
|
<ShieldAlert className="w-4 h-4 text-amber-500" />
|
||||||
<span>Операции с инфраструктурным узлом</span>
|
<span>Операции с инфраструктурным узлом</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<button
|
<button
|
||||||
disabled={!permissions.canRequestDiagnostics}
|
disabled={!permissions.canRequestDiagnostics}
|
||||||
onClick={handleDiagnostics}
|
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" />
|
<Activity className="w-3.5 h-3.5 text-sky-400" />
|
||||||
<span>Запросить диагностику</span>
|
<span>Запросить диагностику</span>
|
||||||
|
|
@ -412,9 +413,9 @@ export const InspectorDrawer: React.FC = () => {
|
||||||
<button
|
<button
|
||||||
disabled={!permissions.canScheduleMaintenance}
|
disabled={!permissions.canScheduleMaintenance}
|
||||||
onClick={handleMaintenance}
|
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>
|
<span>Запланировать ТО</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -422,18 +423,18 @@ export const InspectorDrawer: React.FC = () => {
|
||||||
|
|
||||||
{/* Telemetry Metrics */}
|
{/* Telemetry Metrics */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="font-semibold text-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>Телеметрия узла (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>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="p-3 rounded-lg bg-zinc-950 border border-zinc-800">
|
<div className="p-3 rounded-lg bg-krona-workspace/60 border border-krona-border">
|
||||||
<div className="flex justify-between text-zinc-400 mb-1">
|
<div className="flex justify-between text-krona-secondary mb-1">
|
||||||
<span>CPU Нагрузка:</span>
|
<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>
|
||||||
<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
|
<div
|
||||||
className={`h-full ${srv.cpuLoadPercent > 80 ? 'bg-rose-500' : 'bg-emerald-500'}`}
|
className={`h-full ${srv.cpuLoadPercent > 80 ? 'bg-rose-500' : 'bg-emerald-500'}`}
|
||||||
style={{ width: `${srv.cpuLoadPercent}%` }}
|
style={{ width: `${srv.cpuLoadPercent}%` }}
|
||||||
|
|
@ -441,14 +442,14 @@ export const InspectorDrawer: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-3 rounded-lg bg-zinc-950 border border-zinc-800">
|
<div className="p-3 rounded-lg bg-krona-workspace/60 border border-krona-border">
|
||||||
<div className="flex justify-between text-zinc-400 mb-1">
|
<div className="flex justify-between text-krona-secondary mb-1">
|
||||||
<span>RAM Память:</span>
|
<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
|
{Math.round(srv.ramUsedBytes / (1024 * 1024 * 1024))} / {Math.round(srv.ramTotalBytes / (1024 * 1024 * 1024))} GB
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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
|
<div
|
||||||
className="h-full bg-emerald-500"
|
className="h-full bg-emerald-500"
|
||||||
style={{ width: `${(srv.ramUsedBytes / srv.ramTotalBytes) * 100}%` }}
|
style={{ width: `${(srv.ramUsedBytes / srv.ramTotalBytes) * 100}%` }}
|
||||||
|
|
@ -456,60 +457,60 @@ export const InspectorDrawer: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-3 rounded-lg bg-zinc-950 border border-zinc-800">
|
<div className="p-3 rounded-lg bg-krona-workspace/60 border border-krona-border">
|
||||||
<div className="flex justify-between text-zinc-400 mb-1">
|
<div className="flex justify-between text-krona-secondary mb-1">
|
||||||
<span>Диск:</span>
|
<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
|
{Math.round(srv.diskUsedBytes / (1024 * 1024 * 1024))} / {Math.round(srv.diskTotalBytes / (1024 * 1024 * 1024))} GB
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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
|
<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}%` }}
|
style={{ width: `${(srv.diskUsedBytes / srv.diskTotalBytes) * 100}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-3 rounded-lg bg-zinc-950 border border-zinc-800">
|
<div className="p-3 rounded-lg bg-krona-workspace/60 border border-krona-border">
|
||||||
<div className="text-zinc-400 mb-1">Сеть (Rx / Tx):</div>
|
<div className="text-krona-secondary mb-1">Сеть (Rx / Tx):</div>
|
||||||
<div className="font-mono text-zinc-200">
|
<div className="font-mono text-krona-primary">
|
||||||
{(srv.networkRxBytesPerSec / 1024 / 1024).toFixed(1)} / {(srv.networkTxBytesPerSec / 1024 / 1024).toFixed(1)} MB/s
|
{(srv.networkRxBytesPerSec / 1024 / 1024).toFixed(1)} / {(srv.networkTxBytesPerSec / 1024 / 1024).toFixed(1)} MB/s
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Heartbeat timeline */}
|
{/* Heartbeat timeline */}
|
||||||
<div className="p-3 rounded-lg bg-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">
|
<div className="flex justify-between items-center">
|
||||||
<span className="font-semibold text-zinc-300">Пульс узла (Heartbeat 24h):</span>
|
<span className="font-semibold text-krona-primary">Пульс узла (Heartbeat 24h):</span>
|
||||||
<span className="text-[11px] text-zinc-400 font-mono">RTT: {srv.latencyMs}ms</span>
|
<span className="text-[11px] text-krona-secondary font-mono">RTT: {srv.latencyMs}ms</span>
|
||||||
</div>
|
</div>
|
||||||
<HeartbeatTimeline status={srv.status} count={24} />
|
<HeartbeatTimeline status={srv.status} count={24} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Backup & Restore Drill */}
|
{/* Backup & Restore Drill */}
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="p-3 rounded-lg bg-zinc-950 border border-zinc-800 space-y-1">
|
<div className="p-3 rounded-lg bg-krona-workspace/60 border border-krona-border space-y-1">
|
||||||
<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>Бэкап:</span>
|
<span>Резервная копия:</span>
|
||||||
<StatusBadge status={srv.backup.state === 'ok' ? 'healthy' : srv.backup.state === 'failed' ? 'critical' : 'warning'} size="sm" customLabel={srv.backup.state.toUpperCase()} />
|
<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>
|
||||||
<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') : 'Нет успешных копий'}
|
{srv.backup.lastSuccessfulAt ? new Date(srv.backup.lastSuccessfulAt).toLocaleString('ru-RU') : 'Нет успешных копий'}
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="p-3 rounded-lg bg-zinc-950 border border-zinc-800 space-y-1">
|
<div className="p-3 rounded-lg bg-krona-workspace/60 border border-krona-border space-y-1">
|
||||||
<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>Restore Drill:</span>
|
<span>Тест восстановления:</span>
|
||||||
<StatusBadge status={srv.restoreDrill.state === 'passed' ? 'healthy' : srv.restoreDrill.state === 'failed' ? 'critical' : 'warning'} size="sm" customLabel={srv.restoreDrill.state.toUpperCase()} />
|
<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>
|
||||||
<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') : 'Никогда / Устарел'}
|
{srv.restoreDrill.verifiedAt ? new Date(srv.restoreDrill.verifiedAt).toLocaleDateString('ru-RU') : 'Никогда / Устарел'}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-zinc-500 text-[10px]">
|
<div className="text-krona-secondary text-[10px]">
|
||||||
{srv.restoreDrill.drillDurationSeconds ? `Время восстановления: ${Math.round(srv.restoreDrill.drillDurationSeconds / 60)} мин.` : 'Тест не проводился'}
|
{srv.restoreDrill.drillDurationSeconds ? `Время восстановления: ${Math.round(srv.restoreDrill.drillDurationSeconds / 60)} мин.` : 'Тест не проводился'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -517,12 +518,12 @@ export const InspectorDrawer: React.FC = () => {
|
||||||
|
|
||||||
{/* Module instances on server */}
|
{/* Module instances on server */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="font-semibold text-zinc-300">Модули на узле ({srvInstances.length}):</div>
|
<div className="font-semibold text-krona-primary">Модули на узле ({srvInstances.length}):</div>
|
||||||
{srvInstances.map((inst) => (
|
{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>
|
||||||
<div className="font-semibold text-zinc-200">{inst.moduleName}</div>
|
<div className="font-semibold text-krona-primary">{inst.moduleName}</div>
|
||||||
<div className="text-zinc-400 text-[11px]">{inst.tenantName}</div>
|
<div className="text-krona-secondary text-[11px]">{inst.tenantName}</div>
|
||||||
</div>
|
</div>
|
||||||
<StatusBadge status={inst.health} size="sm" />
|
<StatusBadge status={inst.health} size="sm" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -535,7 +536,7 @@ export const InspectorDrawer: React.FC = () => {
|
||||||
|
|
||||||
case 'incident': {
|
case 'incident': {
|
||||||
const inc = incidents.find((i) => i.incidentId === inspectorTarget.id);
|
const inc = incidents.find((i) => i.incidentId === inspectorTarget.id);
|
||||||
if (!inc) return <div className="p-6 text-zinc-400">Инцидент не найден</div>;
|
if (!inc) return <div className="p-6 text-krona-secondary">Инцидент не найден</div>;
|
||||||
|
|
||||||
const handleResolve = () => {
|
const handleResolve = () => {
|
||||||
openConfirmModal({
|
openConfirmModal({
|
||||||
|
|
@ -556,47 +557,47 @@ export const InspectorDrawer: React.FC = () => {
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2 mb-1.5">
|
<div className="flex items-center gap-2 mb-1.5">
|
||||||
<StatusBadge status={inc.status} />
|
<StatusBadge status={inc.status} />
|
||||||
<span className="font-bold text-amber-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>
|
</div>
|
||||||
<h2 className="text-lg font-bold text-zinc-100">{inc.incidentNumber}</h2>
|
<h2 className="text-lg font-bold text-krona-primary">{inc.incidentNumber}</h2>
|
||||||
<p className="text-sm text-zinc-200 mt-1 font-medium">{inc.summary}</p>
|
<p className="text-sm text-krona-primary mt-1 font-medium">{inc.summary}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{inc.status !== 'resolved' && (
|
{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>
|
||||||
<div className="font-semibold text-zinc-200">Действие оператора:</div>
|
<div className="font-semibold text-krona-primary">Действие оператора:</div>
|
||||||
<div className="text-zinc-400 text-[11px]">Отметить инцидент как устраненный с обоснованием</div>
|
<div className="text-krona-secondary text-[11px]">Отметить инцидент как устраненный с обоснованием</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
disabled={!permissions.canManageIncidents}
|
disabled={!permissions.canManageIncidents}
|
||||||
onClick={handleResolve}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="rounded-lg bg-zinc-950 p-3.5 border border-zinc-800 space-y-2">
|
<div className="rounded-lg bg-krona-workspace/60 p-3.5 border border-krona-border space-y-2">
|
||||||
<div className="font-semibold text-zinc-300">Описание инцидента:</div>
|
<div className="font-semibold text-krona-primary">Описание инцидента:</div>
|
||||||
<p className="text-zinc-300 leading-relaxed">{inc.description}</p>
|
<p className="text-krona-primary leading-relaxed">{inc.description}</p>
|
||||||
<div className="text-zinc-400 pt-2 border-t border-zinc-800">
|
<div className="text-krona-secondary pt-2 border-t border-krona-border">
|
||||||
Ответственный: <span className="text-zinc-200">{inc.ownerName}</span>
|
Ответственный: <span className="text-krona-primary">{inc.ownerName}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="font-semibold text-zinc-300">Хронология расследования (Timeline):</div>
|
<div className="font-semibold text-krona-primary">Хронология расследования (Timeline):</div>
|
||||||
<div className="space-y-2 relative border-l border-zinc-800 ml-2 pl-3">
|
<div className="space-y-2 relative border-l border-krona-border ml-2 pl-3">
|
||||||
{inc.timeline.map((step, idx) => (
|
{inc.timeline.map((step, idx) => (
|
||||||
<div key={idx} className="relative space-y-1">
|
<div key={idx} className="relative space-y-1">
|
||||||
<div className="absolute -left-[17px] top-1 w-2 h-2 rounded-full bg-amber-400" />
|
<div className="absolute -left-[17px] top-1 w-2 h-2 rounded-full bg-amber-500" />
|
||||||
<div className="flex justify-between text-zinc-400 text-[10px]">
|
<div className="flex justify-between text-krona-secondary text-[10px]">
|
||||||
<span className="font-bold text-zinc-300">{step.author}</span>
|
<span className="font-bold text-krona-primary">{step.author}</span>
|
||||||
<span>{new Date(step.timestamp).toLocaleTimeString('ru-RU')}</span>
|
<span>{new Date(step.timestamp).toLocaleTimeString('ru-RU')}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-zinc-300">{step.message}</p>
|
<p className="text-krona-primary">{step.message}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -606,34 +607,41 @@ export const InspectorDrawer: React.FC = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return <div className="p-6 text-zinc-400">Детальная информация для данного типа пока формируется</div>;
|
return <div className="p-6 text-krona-secondary">Детальная информация для данного типа пока формируется</div>;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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"
|
<div
|
||||||
role="complementary"
|
className="fixed inset-0 z-40 bg-black/60 backdrop-blur-xs transition-opacity"
|
||||||
aria-label="Контекстный инспектор"
|
onClick={closeInspector}
|
||||||
>
|
aria-hidden="true"
|
||||||
{/* Drawer Header */}
|
/>
|
||||||
<div className="px-6 py-4 border-b border-zinc-800 flex items-center justify-between bg-zinc-950/60">
|
<div
|
||||||
<div className="flex items-center gap-2 text-xs font-semibold text-zinc-400 uppercase tracking-wider">
|
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"
|
||||||
<Activity className="w-4 h-4 text-amber-400" />
|
role="complementary"
|
||||||
<span>Контекстный инспектор</span>
|
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>
|
</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 */}
|
{/* Drawer Body */}
|
||||||
<div className="flex-1 overflow-y-auto p-6">{renderContent()}</div>
|
<div className="flex-1 overflow-y-auto p-4 md:p-6">{renderContent()}</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -112,7 +112,7 @@ export const StatusBadge: React.FC<StatusBadgeProps> = ({ status, customLabel, s
|
||||||
icon: <CheckCircle2 className={iconSize} aria-hidden="true" />,
|
icon: <CheckCircle2 className={iconSize} aria-hidden="true" />,
|
||||||
},
|
},
|
||||||
degraded: {
|
degraded: {
|
||||||
label: 'Работает с ограничениями',
|
label: 'Деградация',
|
||||||
className: 'bg-status-warning-soft text-status-warning-text border-status-warning-border',
|
className: 'bg-status-warning-soft text-status-warning-text border-status-warning-border',
|
||||||
icon: <AlertTriangle className={iconSize} aria-hidden="true" />,
|
icon: <AlertTriangle className={iconSize} aria-hidden="true" />,
|
||||||
},
|
},
|
||||||
|
|
@ -220,9 +220,10 @@ export const StatusBadge: React.FC<StatusBadgeProps> = ({ status, customLabel, s
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 font-medium transition-colors ${
|
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 font-medium transition-colors ${
|
||||||
isSmall ? 'text-[11px] py-0.5 px-2' : 'text-xs'
|
isSmall ? 'text-xs py-0.5 px-2' : 'text-xs'
|
||||||
} ${current.className}`}
|
} ${current.className}`}
|
||||||
data-testid={`status-badge-${status}`}
|
data-testid="status-badge"
|
||||||
|
data-status={status}
|
||||||
>
|
>
|
||||||
{current.icon}
|
{current.icon}
|
||||||
<span>{displayText}</span>
|
<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 }> = {
|
export const RU_ROLES: Record<OperatorRole, { label: string; shortLabel: string; description: string }> = {
|
||||||
platform_owner: {
|
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' },
|
healthy: { label: 'Работает нормально', badgeVariant: 'success' },
|
||||||
|
warning: { label: 'Предупреждение', badgeVariant: 'warning' },
|
||||||
degraded: { label: 'Работает с ограничениями', badgeVariant: 'warning' },
|
degraded: { label: 'Работает с ограничениями', badgeVariant: 'warning' },
|
||||||
|
critical: { label: 'Критический сбой', badgeVariant: 'danger' },
|
||||||
offline: { label: 'Недоступен', badgeVariant: 'danger' },
|
offline: { label: 'Недоступен', badgeVariant: 'danger' },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const RU_SUBSCRIPTION_STATUS: Record<SubscriptionStatus, { label: string; badgeVariant: 'success' | 'warning' | 'danger' | 'info' | 'neutral' }> = {
|
export const RU_SUBSCRIPTION_STATUS: Record<string, { label: string; badgeVariant: 'success' | 'warning' | 'danger' | 'info' | 'neutral' }> = {
|
||||||
trial: { label: 'Пробный период', badgeVariant: 'info' },
|
normal: { label: 'Активна', badgeVariant: 'success' },
|
||||||
active: { 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' },
|
grace: { label: 'Льготный период', badgeVariant: 'warning' },
|
||||||
|
overdue: { label: 'Просрочена', badgeVariant: 'danger' },
|
||||||
suspended: { label: 'Приостановлена', badgeVariant: 'danger' },
|
suspended: { label: 'Приостановлена', badgeVariant: 'danger' },
|
||||||
canceled: { label: 'Отменена', badgeVariant: 'neutral' },
|
canceled: { label: 'Отменена', badgeVariant: 'neutral' },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const RU_INCIDENT_SEVERITY: Record<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 Критическая' },
|
P0_CRITICAL: { label: 'P0 Критическая авария', short: 'P0 Критическая' },
|
||||||
P1_MAJOR: { label: 'P1 Серьёзный инцидент', short: 'P1 Серьёзный' },
|
P1_MAJOR: { label: 'P1 Серьёзный инцидент', short: 'P1 Серьёзный' },
|
||||||
P2_DEGRADED: { label: 'P2 Ограничение работы', short: 'P2 Деградация' },
|
P2_DEGRADED: { label: 'P2 Ограничение работы', short: 'P2 Деградация' },
|
||||||
P3_INFO: { label: 'P3 Информационный', short: 'P3 Инфо' },
|
P3_INFO: { label: 'P3 Информационный', short: 'P3 Инфо' },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const RU_INCIDENT_STATUS: Record<IncidentStatus, string> = {
|
export const RU_INCIDENT_STATUS: Record<string, string> = {
|
||||||
|
open: 'Открыт',
|
||||||
|
mitigating: 'Устраняется',
|
||||||
|
resolved: 'Устранён',
|
||||||
investigating: 'Расследуется',
|
investigating: 'Расследуется',
|
||||||
identified: 'Локализован',
|
identified: 'Локализован',
|
||||||
monitoring: 'Мониторинг устранения',
|
monitoring: 'Мониторинг устранения',
|
||||||
resolved: 'Устранён',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const RU_AUDIT_ACTION: Record<AuditActionType, string> = {
|
export const RU_AUDIT_ACTION: Record<AuditActionType, string> = {
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,9 @@ import React, { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
CreditCard,
|
CreditCard,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
SlidersHorizontal,
|
|
||||||
Calendar,
|
Calendar,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Clock,
|
Clock,
|
||||||
ShieldAlert,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useKccStore, useOperatorRole } from '../store/kccStore';
|
import { useKccStore, useOperatorRole } from '../store/kccStore';
|
||||||
import { StatusBadge } from '../components/StatusBadge';
|
import { StatusBadge } from '../components/StatusBadge';
|
||||||
|
|
@ -46,12 +44,12 @@ export const BillingPage: React.FC = () => {
|
||||||
const handleRenew = () => {
|
const handleRenew = () => {
|
||||||
if (!selectedSub) return;
|
if (!selectedSub) return;
|
||||||
openConfirmModal({
|
openConfirmModal({
|
||||||
title: `Продление подписки: ${selectedSub.organizationName}`,
|
title: `Продление подписки: ${selectedSub.tenantName}`,
|
||||||
actionName: 'grant_grace_period',
|
actionName: 'grant_grace_period',
|
||||||
impactSummary: `Подписка будет продлена на ${newPeriod === 'year' ? '1 год' : '1 месяц'}. Состав модулей: ${activeModulesToDisplay.join(', ')}. Расчётная сумма: ${calculatedTotal / 100} ₽.`,
|
impactSummary: `Подписка будет продлена на ${newPeriod === 'year' ? '1 год' : '1 месяц'}. Состав модулей: ${activeModulesToDisplay.join(', ')}. Расчётная сумма: ${calculatedTotal / 100} ₽.`,
|
||||||
targetType: 'subscription',
|
targetType: 'subscription',
|
||||||
targetId: selectedSub.subscriptionId,
|
targetId: selectedSub.subscriptionId,
|
||||||
targetName: selectedSub.organizationName,
|
targetName: selectedSub.tenantName,
|
||||||
onConfirm: async (reason) => {
|
onConfirm: async (reason) => {
|
||||||
await renewSubscription(selectedSub.subscriptionId, newPeriod, activeModulesToDisplay, reason);
|
await renewSubscription(selectedSub.subscriptionId, newPeriod, activeModulesToDisplay, reason);
|
||||||
},
|
},
|
||||||
|
|
@ -61,12 +59,12 @@ export const BillingPage: React.FC = () => {
|
||||||
const handleGrantGrace = () => {
|
const handleGrantGrace = () => {
|
||||||
if (!selectedSub) return;
|
if (!selectedSub) return;
|
||||||
openConfirmModal({
|
openConfirmModal({
|
||||||
title: `Предоставление льготного периода: ${selectedSub.organizationName}`,
|
title: `Предоставление льготного периода: ${selectedSub.tenantName}`,
|
||||||
actionName: 'grant_grace_period',
|
actionName: 'grant_grace_period',
|
||||||
impactSummary: `Организации будет предоставлен льготный период (Grace Period) на 14 дней с сохранением доступа ко всем модулям.`,
|
impactSummary: `Организации будет предоставлен льготный период (Grace Period) на 14 дней с сохранением доступа ко всем модулям.`,
|
||||||
targetType: 'subscription',
|
targetType: 'subscription',
|
||||||
targetId: selectedSub.subscriptionId,
|
targetId: selectedSub.subscriptionId,
|
||||||
targetName: selectedSub.organizationName,
|
targetName: selectedSub.tenantName,
|
||||||
onConfirm: async (reason) => {
|
onConfirm: async (reason) => {
|
||||||
await grantGracePeriod(selectedSub.subscriptionId, 14, reason);
|
await grantGracePeriod(selectedSub.subscriptionId, 14, reason);
|
||||||
},
|
},
|
||||||
|
|
@ -113,6 +111,7 @@ export const BillingPage: React.FC = () => {
|
||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
type="button"
|
type="button"
|
||||||
|
data-testid={`filter-${tab.id}`}
|
||||||
onClick={() => setFilter(tab.id as typeof filter)}
|
onClick={() => setFilter(tab.id as typeof filter)}
|
||||||
className={`min-h-[44px] px-3.5 py-2 rounded-lg font-medium transition-colors shrink-0 ${
|
className={`min-h-[44px] px-3.5 py-2 rounded-lg font-medium transition-colors shrink-0 ${
|
||||||
filter === tab.id
|
filter === tab.id
|
||||||
|
|
@ -149,9 +148,9 @@ export const BillingPage: React.FC = () => {
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-bold text-sm text-krona-primary">{sub.organizationName}</h3>
|
<h3 className="font-bold text-sm text-krona-primary">{sub.tenantName}</h3>
|
||||||
<div className="text-[11px] text-krona-tertiary mt-0.5">
|
<div className="text-[11px] text-krona-tertiary mt-0.5">
|
||||||
Тарифный план: <span className="font-semibold text-krona-primary">{sub.planName}</span>
|
Тарифный план: <span className="font-semibold text-krona-primary">{sub.planCode}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<StatusBadge status={sub.status} size="sm" />
|
<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 className="grid grid-cols-2 gap-2 text-xs py-2 border-y border-krona-border">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[10px] text-krona-tertiary">Стоимость в месяц</div>
|
<div className="text-[10px] text-krona-tertiary">Стоимость в месяц</div>
|
||||||
<MoneyDisplay minorUnits={sub.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>
|
<div>
|
||||||
<div className="text-[10px] text-krona-tertiary">Действует до</div>
|
<div className="text-[10px] text-krona-tertiary">Действует до</div>
|
||||||
|
|
@ -179,7 +178,7 @@ export const BillingPage: React.FC = () => {
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
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"
|
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 className="text-[11px] font-semibold text-krona-gold uppercase tracking-wider">
|
||||||
Управление подпиской
|
Управление подпиской
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-base font-bold text-krona-primary mt-0.5">{selectedSub.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">
|
<div className="text-xs text-krona-secondary mt-0.5">
|
||||||
Текущий статус: <StatusBadge status={selectedSub.status} size="sm" />
|
Текущий статус: <StatusBadge status={selectedSub.status} size="sm" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -284,8 +283,9 @@ export const BillingPage: React.FC = () => {
|
||||||
<div className="space-y-2 pt-2">
|
<div className="space-y-2 pt-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
disabled={!permissions.canGrantTrialGrace}
|
||||||
onClick={handleRenew}
|
onClick={handleRenew}
|
||||||
className="w-full min-h-[44px] px-4 py-2.5 rounded-lg bg-krona-gold hover:bg-krona-gold-hover text-zinc-950 font-bold text-xs flex items-center justify-center gap-2 transition-colors shadow-sm"
|
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"
|
data-testid="renew-subscription-btn"
|
||||||
>
|
>
|
||||||
<RefreshCw className="w-4 h-4" />
|
<RefreshCw className="w-4 h-4" />
|
||||||
|
|
@ -294,8 +294,9 @@ export const BillingPage: React.FC = () => {
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
disabled={!permissions.canGrantTrialGrace}
|
||||||
onClick={handleGrantGrace}
|
onClick={handleGrantGrace}
|
||||||
className="w-full min-h-[44px] px-4 py-2 rounded-lg border border-status-warning-border bg-status-warning-soft text-status-warning-text font-semibold text-xs flex items-center justify-center gap-2 transition-colors"
|
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"
|
data-testid="grant-grace-btn"
|
||||||
>
|
>
|
||||||
<Clock className="w-4 h-4" />
|
<Clock className="w-4 h-4" />
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import React, { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Lock, ArrowRight, Sun, Moon, Sparkles } from 'lucide-react';
|
import { Lock, ArrowRight, Sun, Moon, Sparkles } from 'lucide-react';
|
||||||
import { useKccStore } from '../store/kccStore';
|
import { useKccStore } from '../store/kccStore';
|
||||||
import { useThemeStore } from '../../../shared/model/themeStore';
|
import { useThemeStore, ThemeMode } from '../../../stores/theme';
|
||||||
import { RU_THEMES, RU_UI } from '../locales/ru';
|
import { RU_THEMES, RU_UI } from '../locales/ru';
|
||||||
|
|
||||||
export const ControlAccessGatePage: React.FC = () => {
|
export const ControlAccessGatePage: React.FC = () => {
|
||||||
|
|
@ -11,10 +11,10 @@ export const ControlAccessGatePage: React.FC = () => {
|
||||||
const [pin, setPin] = useState('');
|
const [pin, setPin] = useState('');
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
const theme = useThemeStore((state) => state.theme);
|
const themeMode = useThemeStore((state) => state.themeMode);
|
||||||
const setTheme = useThemeStore((state) => state.setTheme);
|
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: 'soft-light', label: RU_THEMES['soft-light'], icon: <Sparkles className="w-3.5 h-3.5" /> },
|
||||||
{ id: 'light', label: RU_THEMES['light'], icon: <Sun className="w-3.5 h-3.5" /> },
|
{ id: 'light', label: RU_THEMES['light'], icon: <Sun className="w-3.5 h-3.5" /> },
|
||||||
{ id: 'dark', label: RU_THEMES['dark'], icon: <Moon className="w-3.5 h-3.5" /> },
|
{ id: 'dark', label: RU_THEMES['dark'], icon: <Moon className="w-3.5 h-3.5" /> },
|
||||||
|
|
@ -46,7 +46,7 @@ export const ControlAccessGatePage: React.FC = () => {
|
||||||
onClick={() => setTheme(t.id)}
|
onClick={() => setTheme(t.id)}
|
||||||
title={t.label}
|
title={t.label}
|
||||||
className={`min-w-[40px] min-h-[40px] px-2.5 py-1 rounded-lg text-xs font-medium flex items-center gap-1.5 transition-colors ${
|
className={`min-w-[40px] min-h-[40px] px-2.5 py-1 rounded-lg text-xs font-medium flex items-center gap-1.5 transition-colors ${
|
||||||
theme === t.id
|
themeMode === t.id
|
||||||
? 'bg-amber-500 text-zinc-950 font-bold shadow-xs'
|
? 'bg-amber-500 text-zinc-950 font-bold shadow-xs'
|
||||||
: 'text-krona-secondary hover:text-krona-primary hover:bg-krona-workspace'
|
: 'text-krona-secondary hover:text-krona-primary hover:bg-krona-workspace'
|
||||||
}`}
|
}`}
|
||||||
|
|
@ -79,6 +79,7 @@ export const ControlAccessGatePage: React.FC = () => {
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={pin}
|
value={pin}
|
||||||
|
data-testid="pin-input"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setPin(e.target.value);
|
setPin(e.target.value);
|
||||||
if (error) setError(false);
|
if (error) setError(false);
|
||||||
|
|
@ -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]"
|
className="w-full bg-krona-workspace border border-krona-border rounded-xl px-4 py-3 text-center text-sm text-krona-primary placeholder:text-krona-secondary/60 focus:outline-hidden focus:border-amber-500 tracking-wider font-mono min-h-[48px]"
|
||||||
/>
|
/>
|
||||||
{error && (
|
{error && (
|
||||||
<p className="text-xs text-rose-500 font-medium mt-1.5">
|
<p className="text-xs text-rose-500 font-medium mt-1.5" data-testid="pin-error">
|
||||||
{RU_UI.gateError}
|
{RU_UI.gateError}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
@ -95,6 +96,7 @@ export const ControlAccessGatePage: React.FC = () => {
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
data-testid="pin-submit"
|
||||||
className="w-full min-h-[48px] py-3 px-4 rounded-xl bg-amber-500 hover:bg-amber-400 text-zinc-950 font-bold text-xs flex items-center justify-center gap-2 transition-colors shadow-xs"
|
className="w-full min-h-[48px] py-3 px-4 rounded-xl bg-amber-500 hover:bg-amber-400 text-zinc-950 font-bold text-xs flex items-center justify-center gap-2 transition-colors shadow-xs"
|
||||||
>
|
>
|
||||||
<span>{RU_UI.gateSubmit}</span>
|
<span>{RU_UI.gateSubmit}</span>
|
||||||
|
|
@ -105,6 +107,7 @@ export const ControlAccessGatePage: React.FC = () => {
|
||||||
<div className="pt-2 border-t border-krona-border">
|
<div className="pt-2 border-t border-krona-border">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
data-testid="pin-demo-login"
|
||||||
onClick={handleDemoBypass}
|
onClick={handleDemoBypass}
|
||||||
className="min-h-[44px] inline-flex items-center text-xs text-krona-secondary hover:text-amber-500 transition-colors underline underline-offset-4"
|
className="min-h-[44px] inline-flex items-center text-xs text-krona-secondary hover:text-amber-500 transition-colors underline underline-offset-4"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -26,21 +26,13 @@ export const IncidentsPage: React.FC = () => {
|
||||||
inc.summary.toLowerCase().includes(search.toLowerCase()) ||
|
inc.summary.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
inc.description.toLowerCase().includes(search.toLowerCase());
|
inc.description.toLowerCase().includes(search.toLowerCase());
|
||||||
|
|
||||||
let matchStatus = true;
|
const matchStatus = statusFilter === 'all' || inc.status === statusFilter;
|
||||||
if (statusFilter === 'open') {
|
|
||||||
matchStatus = inc.status === 'investigating';
|
|
||||||
} else if (statusFilter === 'mitigating') {
|
|
||||||
matchStatus = inc.status === 'identified' || inc.status === 'monitoring';
|
|
||||||
} else if (statusFilter === 'resolved') {
|
|
||||||
matchStatus = inc.status === 'resolved';
|
|
||||||
}
|
|
||||||
|
|
||||||
return matchSearch && matchStatus;
|
return matchSearch && matchStatus;
|
||||||
});
|
});
|
||||||
|
|
||||||
const unresolvedCount = incidents.filter((i) => i.status !== 'resolved').length;
|
const unresolvedCount = incidents.filter((i) => i.status !== 'resolved').length;
|
||||||
const criticalCount = incidents.filter(
|
const criticalCount = incidents.filter(
|
||||||
(i) => i.status !== 'resolved' && (i.severity === 'P0_CRITICAL' || i.severity === 'P1_MAJOR')
|
(i) => i.status !== 'resolved' && (i.severity === 'critical' || i.severity === 'high')
|
||||||
).length;
|
).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -111,8 +103,8 @@ export const IncidentsPage: React.FC = () => {
|
||||||
{/* Incidents List - Mobile-First Cards */}
|
{/* Incidents List - Mobile-First Cards */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{filtered.map((inc) => {
|
{filtered.map((inc) => {
|
||||||
const isCritical = inc.severity === 'P0_CRITICAL';
|
const isCritical = inc.severity === 'critical';
|
||||||
const isMajor = inc.severity === 'P1_MAJOR';
|
const isMajor = inc.severity === 'high';
|
||||||
const severityInfo = RU_INCIDENT_SEVERITY[inc.severity] || {
|
const severityInfo = RU_INCIDENT_SEVERITY[inc.severity] || {
|
||||||
label: inc.severity,
|
label: inc.severity,
|
||||||
short: inc.severity,
|
short: inc.severity,
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,9 @@ import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
ShieldAlert,
|
|
||||||
Activity,
|
|
||||||
HardDrive,
|
|
||||||
Cpu,
|
|
||||||
Clock,
|
Clock,
|
||||||
ExternalLink,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useKccStore, useOperatorRole } from '../store/kccStore';
|
import { useKccStore } from '../store/kccStore';
|
||||||
import { StatusBadge } from '../components/StatusBadge';
|
import { StatusBadge } from '../components/StatusBadge';
|
||||||
import { HeartbeatTimeline } from '../components/HeartbeatTimeline';
|
import { HeartbeatTimeline } from '../components/HeartbeatTimeline';
|
||||||
import { ServerNode } from '../contracts';
|
import { ServerNode } from '../contracts';
|
||||||
|
|
@ -24,7 +19,6 @@ export const InfrastructurePage: React.FC = () => {
|
||||||
const openConfirmModal = useKccStore((state) => state.openConfirmModal);
|
const openConfirmModal = useKccStore((state) => state.openConfirmModal);
|
||||||
const createIncidentFromAlert = useKccStore((state) => state.createIncidentFromAlert);
|
const createIncidentFromAlert = useKccStore((state) => state.createIncidentFromAlert);
|
||||||
const requestServerDiagnostics = useKccStore((state) => state.requestServerDiagnostics);
|
const requestServerDiagnostics = useKccStore((state) => state.requestServerDiagnostics);
|
||||||
const { permissions } = useOperatorRole();
|
|
||||||
|
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [providerFilter, setProviderFilter] = useState<'all' | 'Selectel' | 'Timeweb Cloud'>('all');
|
const [providerFilter, setProviderFilter] = useState<'all' | 'Selectel' | 'Timeweb Cloud'>('all');
|
||||||
|
|
@ -41,7 +35,7 @@ export const InfrastructurePage: React.FC = () => {
|
||||||
srv.name.toLowerCase().includes(search.toLowerCase()) ||
|
srv.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
srv.ipAddress.includes(search) ||
|
srv.ipAddress.includes(search) ||
|
||||||
srv.hostname.toLowerCase().includes(search.toLowerCase()) ||
|
srv.hostname.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
srv.organizationName.toLowerCase().includes(search.toLowerCase());
|
srv.hostingProvider.toLowerCase().includes(search.toLowerCase());
|
||||||
const matchProvider = providerFilter === 'all' || srv.hostingProvider === providerFilter;
|
const matchProvider = providerFilter === 'all' || srv.hostingProvider === providerFilter;
|
||||||
return matchSearch && matchProvider;
|
return matchSearch && matchProvider;
|
||||||
});
|
});
|
||||||
|
|
@ -52,7 +46,7 @@ export const InfrastructurePage: React.FC = () => {
|
||||||
title: `Создать инцидент для сервера: ${srv.name}`,
|
title: `Создать инцидент для сервера: ${srv.name}`,
|
||||||
actionName: 'circuit_breaker_trip',
|
actionName: 'circuit_breaker_trip',
|
||||||
impactSummary: `Будет открыт критический инцидент платформы, затронутые организации получат уведомление о расследовании.`,
|
impactSummary: `Будет открыт критический инцидент платформы, затронутые организации получат уведомление о расследовании.`,
|
||||||
targetType: 'server',
|
targetType: 'infrastructure',
|
||||||
targetId: srv.serverId,
|
targetId: srv.serverId,
|
||||||
targetName: srv.name,
|
targetName: srv.name,
|
||||||
onConfirm: async (reason) => {
|
onConfirm: async (reason) => {
|
||||||
|
|
@ -72,7 +66,7 @@ export const InfrastructurePage: React.FC = () => {
|
||||||
title: `Запрос телеметрии: ${srv.name}`,
|
title: `Запрос телеметрии: ${srv.name}`,
|
||||||
actionName: 'invalidate_cache',
|
actionName: 'invalidate_cache',
|
||||||
impactSummary: `Агенту мониторинга v${srv.agentVersion} будет направлен запрос выгрузки моментального снимка состояния.`,
|
impactSummary: `Агенту мониторинга v${srv.agentVersion} будет направлен запрос выгрузки моментального снимка состояния.`,
|
||||||
targetType: 'server',
|
targetType: 'infrastructure',
|
||||||
targetId: srv.serverId,
|
targetId: srv.serverId,
|
||||||
targetName: srv.name,
|
targetName: srv.name,
|
||||||
onConfirm: async (reason) => {
|
onConfirm: async (reason) => {
|
||||||
|
|
@ -144,12 +138,15 @@ export const InfrastructurePage: React.FC = () => {
|
||||||
{filteredServers.map((srv) => {
|
{filteredServers.map((srv) => {
|
||||||
const srvInstances = instances.filter((i) => i.serverId === srv.serverId);
|
const srvInstances = instances.filter((i) => i.serverId === srv.serverId);
|
||||||
const isExpanded = !!expandedDetails[srv.serverId];
|
const isExpanded = !!expandedDetails[srv.serverId];
|
||||||
const lastSignalDate = new Date(srv.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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={srv.serverId}
|
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 ${
|
className={`p-4 rounded-xl border bg-krona-surface hover:border-krona-gold/40 cursor-pointer transition-all space-y-3 relative shadow-xs ${
|
||||||
srv.status === 'critical' || srv.status === 'offline'
|
srv.status === 'critical' || srv.status === 'offline'
|
||||||
? 'border-status-danger-border bg-status-danger-soft/20'
|
? 'border-status-danger-border bg-status-danger-soft/20'
|
||||||
|
|
@ -179,11 +176,11 @@ export const InfrastructurePage: React.FC = () => {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Destination / Organization */}
|
{/* Destination / Provider */}
|
||||||
<div className="text-xs text-krona-secondary flex items-center justify-between">
|
<div className="text-xs text-krona-secondary flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-krona-tertiary">Назначение: </span>
|
<span className="text-krona-tertiary">Провайдер: </span>
|
||||||
<span className="font-semibold text-krona-primary">{srv.organizationName}</span>
|
<span className="font-semibold text-krona-primary">{srv.hostingProvider}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[11px] text-krona-tertiary flex items-center gap-1">
|
<div className="text-[11px] text-krona-tertiary flex items-center gap-1">
|
||||||
<Clock className="w-3.5 h-3.5" />
|
<Clock className="w-3.5 h-3.5" />
|
||||||
|
|
@ -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="p-2 rounded-lg bg-krona-workspace border border-krona-border space-y-1">
|
||||||
<div className="flex justify-between text-[11px] text-krona-tertiary">
|
<div className="flex justify-between text-[11px] text-krona-tertiary">
|
||||||
<span>CPU</span>
|
<span>CPU</span>
|
||||||
<span className={`font-bold font-mono ${srv.metrics.cpuPercent > 80 ? 'text-status-danger-text' : 'text-krona-primary'}`}>
|
<span className={`font-bold font-mono ${cpuPercent > 80 ? 'text-status-danger-text' : 'text-krona-primary'}`}>
|
||||||
{srv.metrics.cpuPercent}%
|
{cpuPercent}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full bg-krona-surface h-1.5 rounded-full overflow-hidden">
|
<div className="w-full bg-krona-surface h-1.5 rounded-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className={`h-full ${srv.metrics.cpuPercent > 80 ? 'bg-red-500' : 'bg-emerald-500'}`}
|
className={`h-full ${cpuPercent > 80 ? 'bg-red-500' : 'bg-emerald-500'}`}
|
||||||
style={{ width: `${srv.metrics.cpuPercent}%` }}
|
style={{ width: `${cpuPercent}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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="p-2 rounded-lg bg-krona-workspace border border-krona-border space-y-1">
|
||||||
<div className="flex justify-between text-[11px] text-krona-tertiary">
|
<div className="flex justify-between text-[11px] text-krona-tertiary">
|
||||||
<span>RAM</span>
|
<span>RAM</span>
|
||||||
<span className={`font-bold font-mono ${srv.metrics.ramPercent > 80 ? 'text-status-danger-text' : 'text-krona-primary'}`}>
|
<span className={`font-bold font-mono ${ramPercent > 80 ? 'text-status-danger-text' : 'text-krona-primary'}`}>
|
||||||
{srv.metrics.ramPercent}%
|
{ramPercent}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full bg-krona-surface h-1.5 rounded-full overflow-hidden">
|
<div className="w-full bg-krona-surface h-1.5 rounded-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className={`h-full ${srv.metrics.ramPercent > 80 ? 'bg-red-500' : 'bg-emerald-500'}`}
|
className={`h-full ${ramPercent > 80 ? 'bg-red-500' : 'bg-emerald-500'}`}
|
||||||
style={{ width: `${srv.metrics.ramPercent}%` }}
|
style={{ width: `${ramPercent}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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="p-2 rounded-lg bg-krona-workspace border border-krona-border space-y-1">
|
||||||
<div className="flex justify-between text-[11px] text-krona-tertiary">
|
<div className="flex justify-between text-[11px] text-krona-tertiary">
|
||||||
<span>Диск</span>
|
<span>Диск</span>
|
||||||
<span className={`font-bold font-mono ${srv.metrics.diskPercent > 85 ? 'text-status-danger-text' : 'text-krona-primary'}`}>
|
<span className={`font-bold font-mono ${diskPercent > 85 ? 'text-status-danger-text' : 'text-krona-primary'}`}>
|
||||||
{srv.metrics.diskPercent}%
|
{diskPercent}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full bg-krona-surface h-1.5 rounded-full overflow-hidden">
|
<div className="w-full bg-krona-surface h-1.5 rounded-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className={`h-full ${srv.metrics.diskPercent > 85 ? 'bg-red-500' : 'bg-emerald-500'}`}
|
className={`h-full ${diskPercent > 85 ? 'bg-red-500' : 'bg-emerald-500'}`}
|
||||||
style={{ width: `${srv.metrics.diskPercent}%` }}
|
style={{ width: `${diskPercent}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -249,18 +246,22 @@ export const InfrastructurePage: React.FC = () => {
|
||||||
<div>
|
<div>
|
||||||
Резервная копия:{' '}
|
Резервная копия:{' '}
|
||||||
<span className={srv.backup.state === 'failed' ? 'text-status-danger-text font-bold' : 'text-status-success-text font-semibold'}>
|
<span className={srv.backup.state === 'failed' ? 'text-status-danger-text font-bold' : 'text-status-success-text font-semibold'}>
|
||||||
{srv.backup.state === 'healthy' ? 'В норме' : srv.backup.state === 'failed' ? 'Ошибка' : srv.backup.state}
|
{srv.backup.state === 'ok' ? 'В норме' : srv.backup.state === 'failed' ? 'Ошибка' : srv.backup.state}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
Срок mTLS-сертификата:{' '}
|
Срок mTLS-сертификата:{' '}
|
||||||
<span className="font-mono text-krona-primary font-semibold">{srv.certificateDaysRemaining} дней</span>
|
<span className="font-mono text-krona-primary font-semibold">{srv.certificateDaysRemaining} дней</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
Активных модулей:{' '}
|
||||||
|
<span className="font-mono text-krona-primary font-semibold">{srvInstances.length}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[11px] text-krona-tertiary mb-1">История сигналов (Heartbeat):</div>
|
<div className="text-[11px] text-krona-tertiary mb-1">История сигналов (Heartbeat):</div>
|
||||||
<HeartbeatTimeline history={srv.metrics.heartbeatHistory} compact />
|
<HeartbeatTimeline status={srv.status} count={24} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 pt-1">
|
<div className="flex items-center gap-2 pt-1">
|
||||||
|
|
@ -309,7 +310,7 @@ export const InfrastructurePage: React.FC = () => {
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
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"
|
className="min-h-[44px] px-3 flex items-center gap-1.5 text-xs font-semibold text-krona-gold bg-krona-gold/10 hover:bg-krona-gold/20 rounded-lg border border-krona-gold/30 transition-colors"
|
||||||
data-testid={`inspect-server-${srv.serverId}`}
|
data-testid={`inspect-server-${srv.serverId}`}
|
||||||
|
|
|
||||||
|
|
@ -182,7 +182,7 @@ export const InstallationsPage: React.FC = () => {
|
||||||
<span className="font-bold text-sm text-krona-primary">{item.deviceName}</span>
|
<span className="font-bold text-sm text-krona-primary">{item.deviceName}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-krona-secondary mt-0.5">
|
<div className="text-xs text-krona-secondary mt-0.5">
|
||||||
{item.tenantName}
|
{item.tenantName} • {platformLabel}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<StatusBadge status={item.status} size="sm" customLabel={statusLabel} />
|
<StatusBadge status={item.status} size="sm" customLabel={statusLabel} />
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,6 @@ import {
|
||||||
Building2,
|
Building2,
|
||||||
Search,
|
Search,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Filter,
|
|
||||||
Users,
|
|
||||||
CreditCard,
|
|
||||||
Layers,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useKccStore } from '../store/kccStore';
|
import { useKccStore } from '../store/kccStore';
|
||||||
import { StatusBadge } from '../components/StatusBadge';
|
import { StatusBadge } from '../components/StatusBadge';
|
||||||
|
|
@ -96,7 +92,7 @@ export const OrganizationsPage: React.FC = () => {
|
||||||
{filtered.map((org) => (
|
{filtered.map((org) => (
|
||||||
<div
|
<div
|
||||||
key={org.tenantId}
|
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"
|
className="rounded-xl border border-krona-border bg-krona-surface p-4 space-y-3 shadow-sm hover:border-krona-gold/40 transition-colors cursor-pointer"
|
||||||
>
|
>
|
||||||
{/* Header: Title + Badge */}
|
{/* Header: Title + Badge */}
|
||||||
|
|
@ -135,12 +131,12 @@ export const OrganizationsPage: React.FC = () => {
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
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"
|
className="min-h-[44px] min-w-[44px] px-3 flex items-center gap-1 text-xs font-semibold text-krona-gold bg-krona-gold/10 hover:bg-krona-gold/20 rounded-lg border border-krona-gold/30 transition-colors"
|
||||||
data-testid={`inspect-org-${org.tenantId}`}
|
data-testid={`inspect-org-${org.tenantId}`}
|
||||||
>
|
>
|
||||||
<span>360° Инспектор</span>
|
<span>360° Карточка</span>
|
||||||
<ChevronRight className="w-4 h-4" />
|
<ChevronRight className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -177,7 +173,7 @@ export const OrganizationsPage: React.FC = () => {
|
||||||
{filtered.map((org) => (
|
{filtered.map((org) => (
|
||||||
<tr
|
<tr
|
||||||
key={org.tenantId}
|
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"
|
className="hover:bg-krona-hover cursor-pointer transition-colors group"
|
||||||
>
|
>
|
||||||
<td className="py-3 px-4">
|
<td className="py-3 px-4">
|
||||||
|
|
@ -221,12 +217,12 @@ export const OrganizationsPage: React.FC = () => {
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
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"
|
className="inline-flex items-center gap-1 px-3 py-2 min-h-[38px] rounded-lg bg-krona-workspace border border-krona-border text-krona-primary hover:text-krona-gold hover:border-krona-gold/40 text-[11px] font-medium transition-colors"
|
||||||
data-testid={`inspect-org-btn-${org.tenantId}`}
|
data-testid={`inspect-org-btn-${org.tenantId}`}
|
||||||
>
|
>
|
||||||
<span>360° Инспектор</span>
|
<span>360° Карточка</span>
|
||||||
<ChevronRight className="w-3.5 h-3.5" />
|
<ChevronRight className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,9 @@ import {
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
ArrowUpRight,
|
ArrowUpRight,
|
||||||
Activity,
|
Activity,
|
||||||
AlertTriangle,
|
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Clock,
|
Clock,
|
||||||
Layers,
|
Layers,
|
||||||
HelpCircle,
|
|
||||||
Database,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useKccStore, useOperatorRole } from '../store/kccStore';
|
import { useKccStore, useOperatorRole } from '../store/kccStore';
|
||||||
import { MoneyDisplay } from '../components/MoneyDisplay';
|
import { MoneyDisplay } from '../components/MoneyDisplay';
|
||||||
|
|
@ -49,7 +46,6 @@ export const OverviewPage: React.FC = () => {
|
||||||
const totalMrrMinorUnits = organizations.reduce((sum, o) => sum + o.mrrMinorUnits, 0);
|
const totalMrrMinorUnits = organizations.reduce((sum, o) => sum + o.mrrMinorUnits, 0);
|
||||||
|
|
||||||
const expiring7d = subscriptions.filter((s) => s.status === 'expiring_7d');
|
const expiring7d = subscriptions.filter((s) => s.status === 'expiring_7d');
|
||||||
const expiring30d = subscriptions.filter((s) => s.status === 'expiring_30d');
|
|
||||||
const overdueSubs = subscriptions.filter((s) => s.status === 'overdue');
|
const overdueSubs = subscriptions.filter((s) => s.status === 'overdue');
|
||||||
|
|
||||||
const webInstalls = installations.filter((i) => i.kind === 'web').length;
|
const webInstalls = installations.filter((i) => i.kind === 'web').length;
|
||||||
|
|
@ -59,11 +55,9 @@ export const OverviewPage: React.FC = () => {
|
||||||
|
|
||||||
const instancesOffline = instances.filter((i) => i.health === 'offline');
|
const instancesOffline = instances.filter((i) => i.health === 'offline');
|
||||||
const instancesDegraded = instances.filter((i) => i.health === 'degraded');
|
const instancesDegraded = instances.filter((i) => i.health === 'degraded');
|
||||||
const instancesHealthy = instances.filter((i) => i.health === 'healthy');
|
|
||||||
|
|
||||||
const serversHealthy = servers.filter((s) => s.status === 'healthy').length;
|
const serversHealthy = servers.filter((s) => s.status === 'healthy').length;
|
||||||
const serversProblematic = servers.filter((s) => s.status !== 'healthy');
|
const serversProblematic = servers.filter((s) => s.status !== 'healthy');
|
||||||
const failedBackups = servers.filter((s) => s.backup.state === 'failed');
|
|
||||||
const openIncidents = incidents.filter((i) => i.status !== 'resolved');
|
const openIncidents = incidents.filter((i) => i.status !== 'resolved');
|
||||||
|
|
||||||
// Total attention items count
|
// Total attention items count
|
||||||
|
|
@ -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 className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2.5 border-b border-krona-border pb-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-lg sm:text-xl font-bold text-krona-primary flex items-center gap-2 flex-wrap">
|
<h1 className="text-lg sm:text-xl font-bold text-krona-primary flex items-center gap-2 flex-wrap">
|
||||||
<span>Сводная панель платформы</span>
|
<span>Сводная операторская панель</span>
|
||||||
<span className="text-[11px] px-2.5 py-0.5 rounded-full bg-status-success-soft text-status-success-text border border-status-success-border font-medium">
|
<span className="text-[11px] px-2.5 py-0.5 rounded-full bg-status-success-soft text-status-success-text border border-status-success-border font-medium">
|
||||||
Контур платформы
|
Контур платформы
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -193,8 +187,8 @@ export const OverviewPage: React.FC = () => {
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{openIncidents.map((inc) => (
|
{openIncidents.map((inc) => (
|
||||||
<div
|
<div
|
||||||
key={inc.id}
|
key={inc.incidentId}
|
||||||
onClick={() => openInspector({ type: 'incident', id: inc.id, title: inc.title, data: inc })}
|
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"
|
className="p-3.5 rounded-lg border border-status-danger-border bg-status-danger-soft cursor-pointer hover:opacity-95 transition-opacity space-y-1.5"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||||
|
|
@ -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">
|
<span className="text-[10px] font-mono font-bold bg-status-danger-text text-white px-2 py-0.5 rounded-sm">
|
||||||
{RU_INCIDENT_SEVERITY[inc.severity]?.short || inc.severity}
|
{RU_INCIDENT_SEVERITY[inc.severity]?.short || inc.severity}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-bold text-xs text-status-danger-text">{inc.title}</span>
|
<span className="font-bold text-xs text-status-danger-text">{inc.summary}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[11px] font-medium text-status-danger-text">
|
<span className="text-[11px] font-medium text-status-danger-text">
|
||||||
{RU_INCIDENT_STATUS[inc.status] || inc.status}
|
{RU_INCIDENT_STATUS[inc.status] || inc.status}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-krona-secondary line-clamp-2">{inc.impactSummary}</p>
|
<p className="text-xs text-krona-secondary line-clamp-2">{inc.description}</p>
|
||||||
<div className="text-[11px] text-krona-tertiary">
|
<div className="text-[11px] text-krona-tertiary">
|
||||||
Затронуто: {inc.affectedTenants.length} орг. • Узлы: {inc.affectedNodes.join(', ')}
|
Затронуто: {inc.affectedTenantIds.length} орг. • Узлов: {inc.affectedServerIds.length}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -239,26 +233,31 @@ export const OverviewPage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
{serversProblematic.map((srv) => (
|
{serversProblematic.map((srv) => {
|
||||||
<div
|
const cpuPercent = srv.cpuLoadPercent;
|
||||||
key={srv.id}
|
const ramPercent = Math.round((srv.ramUsedBytes / (srv.ramTotalBytes || 1)) * 100);
|
||||||
onClick={() => openInspector({ type: 'server', id: srv.id, title: srv.hostname, data: srv })}
|
const diskPercent = Math.round((srv.diskUsedBytes / (srv.diskTotalBytes || 1)) * 100);
|
||||||
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"
|
return (
|
||||||
>
|
<div
|
||||||
<div className="flex items-center justify-between">
|
key={srv.serverId}
|
||||||
<div className="font-bold text-xs text-krona-primary font-mono">{srv.hostname}</div>
|
onClick={() => openInspector('server', srv.serverId)}
|
||||||
<StatusBadge status={srv.status} size="sm" />
|
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>
|
||||||
<div className="grid grid-cols-3 gap-1 text-[11px] text-krona-secondary font-mono">
|
);
|
||||||
<div>CPU: <span className={srv.metrics.cpuPercent > 80 ? 'text-status-danger-text font-bold' : ''}>{srv.metrics.cpuPercent}%</span></div>
|
})}
|
||||||
<div>RAM: <span className={srv.metrics.ramPercent > 80 ? 'text-status-danger-text font-bold' : ''}>{srv.metrics.ramPercent}%</span></div>
|
|
||||||
<div>Диск: <span className={srv.metrics.diskPercent > 85 ? 'text-status-danger-text font-bold' : ''}>{srv.metrics.diskPercent}%</span></div>
|
|
||||||
</div>
|
|
||||||
<div className="text-[11px] text-krona-tertiary">
|
|
||||||
Организация: {srv.organizationName}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -286,13 +285,13 @@ export const OverviewPage: React.FC = () => {
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
|
||||||
{[...instancesOffline, ...instancesDegraded].map((inst) => (
|
{[...instancesOffline, ...instancesDegraded].map((inst) => (
|
||||||
<div
|
<div
|
||||||
key={inst.id}
|
key={inst.moduleInstanceId}
|
||||||
onClick={() => openInspector({ type: 'instance', id: inst.id, title: inst.moduleCode, data: inst })}
|
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"
|
className="p-3 rounded-lg border border-krona-border bg-krona-workspace cursor-pointer hover:border-krona-gold/40 transition-colors flex items-center justify-between"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<div className="font-bold text-xs text-krona-primary">{inst.moduleCode} v{inst.version}</div>
|
<div className="font-bold text-xs text-krona-primary">{inst.moduleName} v{inst.currentVersion}</div>
|
||||||
<div className="text-[11px] text-krona-tertiary">Организация: {inst.organizationId}</div>
|
<div className="text-[11px] text-krona-tertiary">Тенант: {inst.tenantName}</div>
|
||||||
</div>
|
</div>
|
||||||
<StatusBadge status={inst.health} size="sm" />
|
<StatusBadge status={inst.health} size="sm" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -324,18 +323,18 @@ export const OverviewPage: React.FC = () => {
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
|
||||||
{[...overdueSubs, ...expiring7d].map((sub) => (
|
{[...overdueSubs, ...expiring7d].map((sub) => (
|
||||||
<div
|
<div
|
||||||
key={sub.id}
|
key={sub.subscriptionId}
|
||||||
onClick={() => openInspector({ type: 'subscription', id: sub.id, title: sub.organizationName, data: sub })}
|
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"
|
className="p-3 rounded-lg border border-krona-border bg-krona-workspace cursor-pointer hover:border-krona-gold/40 transition-colors flex items-center justify-between"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<div className="font-bold text-xs text-krona-primary">{sub.organizationName}</div>
|
<div className="font-bold text-xs text-krona-primary">{sub.tenantName}</div>
|
||||||
<div className="text-[11px] text-krona-tertiary">
|
<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>
|
</div>
|
||||||
<div className="text-right">
|
<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 className="mt-1"><StatusBadge status={sub.status} size="sm" /></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -526,53 +525,59 @@ export const OverviewPage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
{servers.map((srv) => (
|
{servers.map((srv) => {
|
||||||
<div
|
const cpuPercent = srv.cpuLoadPercent;
|
||||||
key={srv.id}
|
const ramPercent = Math.round((srv.ramUsedBytes / (srv.ramTotalBytes || 1)) * 100);
|
||||||
onClick={() => openInspector({ type: 'server', id: srv.id, title: srv.hostname, data: srv })}
|
const diskPercent = Math.round((srv.diskUsedBytes / (srv.diskTotalBytes || 1)) * 100);
|
||||||
className="p-3.5 rounded-lg border border-krona-border bg-krona-workspace cursor-pointer hover:border-krona-gold/40 transition-colors space-y-2.5"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<div className="font-bold text-xs text-krona-primary font-mono">{srv.hostname}</div>
|
|
||||||
<div className="text-[11px] text-krona-tertiary">{srv.organizationName}</div>
|
|
||||||
</div>
|
|
||||||
<StatusBadge status={srv.status} size="sm" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-2 text-center text-xs py-1 bg-krona-surface/40 rounded-md border border-krona-border">
|
return (
|
||||||
<div>
|
<div
|
||||||
<div className="text-[10px] text-krona-tertiary">CPU</div>
|
key={srv.serverId}
|
||||||
<div className={`font-mono font-bold ${srv.metrics.cpuPercent > 80 ? 'text-status-danger-text' : 'text-krona-primary'}`}>
|
onClick={() => openInspector('server', srv.serverId)}
|
||||||
{srv.metrics.cpuPercent}%
|
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>
|
||||||
<div>
|
|
||||||
<div className="text-[10px] text-krona-tertiary">RAM</div>
|
<div className="text-[11px] text-krona-tertiary flex items-center justify-between">
|
||||||
<div className={`font-mono font-bold ${srv.metrics.ramPercent > 80 ? 'text-status-danger-text' : 'text-krona-primary'}`}>
|
<span>Резервная копия:</span>
|
||||||
{srv.metrics.ramPercent}%
|
<span className={srv.backup.state === 'failed' ? 'text-status-danger-text font-bold' : 'text-status-success-text'}>
|
||||||
</div>
|
{srv.backup.state === 'ok' ? 'Успешно' : srv.backup.state === 'failed' ? 'Ошибка' : srv.backup.state}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<div className="text-[10px] text-krona-tertiary">Диск</div>
|
<div className="pt-1">
|
||||||
<div className={`font-mono font-bold ${srv.metrics.diskPercent > 85 ? 'text-status-danger-text' : 'text-krona-primary'}`}>
|
<HeartbeatTimeline status={srv.status} count={24} />
|
||||||
{srv.metrics.diskPercent}%
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
<div className="text-[11px] text-krona-tertiary flex items-center justify-between">
|
})}
|
||||||
<span>Резервная копия:</span>
|
|
||||||
<span className={srv.backup.state === 'failed' ? 'text-status-danger-text font-bold' : 'text-status-success-text'}>
|
|
||||||
{srv.backup.state === 'healthy' ? 'Успешно' : srv.backup.state === 'failed' ? 'Ошибка' : srv.backup.state}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="pt-1">
|
|
||||||
<HeartbeatTimeline history={srv.metrics.heartbeatHistory} compact />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue