feat(control-center): add server onboarding, SSH setup and interactive web terminal
This commit is contained in:
parent
3520d0308e
commit
5e5082814e
58 changed files with 1685 additions and 82 deletions
1
dist/assets/AiCenterModule-BBao0YAQ.js
vendored
Normal file
1
dist/assets/AiCenterModule-BBao0YAQ.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/assets/AiCenterModule-TWoGGfS0.js
vendored
1
dist/assets/AiCenterModule-TWoGGfS0.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -1 +1 @@
|
||||||
import{f as e,i as t,r as n,s as r}from"./useToast-CS8pZhce.js";import{lt as i,t as a}from"./index-CNnq9VO5.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};
|
import{f as e,i as t,r as n,s as r}from"./useToast-CS8pZhce.js";import{dt as i,t as a}from"./index-CrJKtV0u.js";import{t as o}from"./types-CTcNpN0z.js";e();var s=r(),c=()=>{let{customCapabilities:e,toggleCapability:r}=i();return(0,s.jsxs)(`div`,{className:`space-y-4 animate-fade-in`,children:[(0,s.jsxs)(`div`,{className:`flex flex-col sm:flex-row sm:items-center justify-between gap-3 bg-krona-surface p-3.5 rounded-xl border border-krona-border shadow-krona-soft`,children:[(0,s.jsxs)(`div`,{children:[(0,s.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,s.jsx)(`h2`,{className:`text-sm font-bold text-krona-primary`,children:`Каталог приложений & Модульная экосистема Кроны`}),(0,s.jsx)(a,{level:`demo`,size:`sm`})]}),(0,s.jsx)(`p`,{className:`text-xs text-krona-secondary mt-0.5`,children:`Подключайте и отключайте функциональные блоки для адаптации платформы под новые ниши`})]}),(0,s.jsxs)(n,{variant:`gold`,size:`md`,children:[`Всего модулей: `,o.length]})]}),(0,s.jsx)(`div`,{className:`grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3.5`,children:o.map(i=>{let a=e[i.id];return(0,s.jsxs)(`div`,{className:`p-4 rounded-xl border border-krona-border bg-krona-surface shadow-krona-soft flex flex-col justify-between space-y-3`,children:[(0,s.jsxs)(`div`,{children:[(0,s.jsxs)(`div`,{className:`flex items-center justify-between mb-1.5`,children:[(0,s.jsx)(`span`,{className:`text-[10px] uppercase font-bold text-krona-secondary`,children:i.category}),(0,s.jsx)(n,{variant:a?`green`:`neutral`,size:`sm`,children:a?`Активен`:`Отключен`})]}),(0,s.jsx)(`h4`,{className:`text-xs font-bold text-krona-primary`,children:i.name}),(0,s.jsx)(`p`,{className:`text-[11px] text-krona-secondary mt-1 leading-relaxed`,children:i.description})]}),(0,s.jsx)(`div`,{className:`pt-2 border-t border-krona-border/50 flex justify-end`,children:(0,s.jsx)(t,{variant:a?`secondary`:`primary`,size:`sm`,className:`w-full text-xs`,onClick:()=>r(i.id),children:a?`Отключить модуль`:`Включить в контур`})})]},i.id)})})]})};export{c as AppsCatalogModule};
|
||||||
File diff suppressed because one or more lines are too long
1
dist/assets/AutomationModule-CWTQE3B1.js
vendored
1
dist/assets/AutomationModule-CWTQE3B1.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/AutomationModule-DO2GmeS1.js
vendored
Normal file
1
dist/assets/AutomationModule-DO2GmeS1.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/assets/CardLayoutBuilder-CM6emXoY.js
vendored
1
dist/assets/CardLayoutBuilder-CM6emXoY.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/CardLayoutBuilder-CiBozXyO.js
vendored
Normal file
1
dist/assets/CardLayoutBuilder-CiBozXyO.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
12
dist/assets/CrmModule-Cw43Bd0U.js
vendored
12
dist/assets/CrmModule-Cw43Bd0U.js
vendored
File diff suppressed because one or more lines are too long
12
dist/assets/CrmModule-l7PoMLhA.js
vendored
Normal file
12
dist/assets/CrmModule-l7PoMLhA.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/assets/CustomEntityPage-BBlkJ9R4.js
vendored
1
dist/assets/CustomEntityPage-BBlkJ9R4.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/CustomEntityPage-B_J0Fvzq.js
vendored
Normal file
1
dist/assets/CustomEntityPage-B_J0Fvzq.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/assets/DynamicDashboard-BWfBzitG.js
vendored
Normal file
1
dist/assets/DynamicDashboard-BWfBzitG.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/assets/DynamicDashboard-CEusXfUQ.js
vendored
1
dist/assets/DynamicDashboard-CEusXfUQ.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
dist/assets/FinanceModule-D-3_6PUg.js
vendored
Normal file
1
dist/assets/FinanceModule-D-3_6PUg.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/assets/FinanceModule-DiPGlGDb.js
vendored
1
dist/assets/FinanceModule-DiPGlGDb.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/FormBuilder-d4z6JEl5.js
vendored
Normal file
1
dist/assets/FormBuilder-d4z6JEl5.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/assets/FormBuilder-hvNl7u0G.js
vendored
1
dist/assets/FormBuilder-hvNl7u0G.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
dist/assets/MarketingModule-D28ZVpJ3.js
vendored
1
dist/assets/MarketingModule-D28ZVpJ3.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/MarketingModule-DyvYzuoi.js
vendored
Normal file
1
dist/assets/MarketingModule-DyvYzuoi.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/assets/OperationsModule-DlDaJDmo.js
vendored
Normal file
1
dist/assets/OperationsModule-DlDaJDmo.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/assets/OperationsModule-aDLWk78D.js
vendored
1
dist/assets/OperationsModule-aDLWk78D.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/SalesModule-2VZC1D5Y.js
vendored
1
dist/assets/SalesModule-2VZC1D5Y.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/SalesModule-Cf0nSO8B.js
vendored
Normal file
1
dist/assets/SalesModule-Cf0nSO8B.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/assets/SettingsModule-BbqUjWn1.js
vendored
Normal file
1
dist/assets/SettingsModule-BbqUjWn1.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/assets/SettingsModule-C5LrdOmz.js
vendored
1
dist/assets/SettingsModule-C5LrdOmz.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
dist/assets/index-Bb0d9GEE.css
vendored
Normal file
1
dist/assets/index-Bb0d9GEE.css
vendored
Normal file
File diff suppressed because one or more lines are too long
13
dist/assets/index-CNnq9VO5.js
vendored
13
dist/assets/index-CNnq9VO5.js
vendored
File diff suppressed because one or more lines are too long
45
dist/assets/index-CrJKtV0u.js
vendored
Normal file
45
dist/assets/index-CrJKtV0u.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/assets/index-D3QQttR0.css
vendored
1
dist/assets/index-D3QQttR0.css
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/terminal-CcncyL3J.js
vendored
1
dist/assets/terminal-CcncyL3J.js
vendored
|
|
@ -1 +0,0 @@
|
||||||
import{l as e}from"./useToast-CS8pZhce.js";var t={name:`terminal`,size:24,node:[[`path`,{d:`M12 19h8`,key:`baeox8`}],[`path`,{d:`m4 17 6-6-6-6`,key:`1yngyt`}]]};t.node;var n=e(t);export{n as t};
|
|
||||||
1
dist/assets/useInventory-BwKK4D-W.js
vendored
1
dist/assets/useInventory-BwKK4D-W.js
vendored
|
|
@ -1 +0,0 @@
|
||||||
import{f as e,h as t}from"./useToast-CS8pZhce.js";import{at as n,tt as r}from"./index-CNnq9VO5.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,r,i,a]=await Promise.all([n.warehouses.list({pageSize:50}),n.stockItems.list({pageSize:200}),n.movements.list({pageSize:100}),n.receipts.list({pageSize:100})]);t(e.items),o(r.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]),r(`records:changed`,e=>{(e.entityType===`warehouse`||e.entityType===`stock_item`||e.entityType===`stock_movement`||e.entityType===`goods_receipt`||e.entityType===`stock_reservation`)&&h()}),r(`storage:reset`,h),r(`snapshot:imported`,h),{warehouses:e,stockItems:a,movements:s,receipts:l,loading:d,error:p,refetch:h,adjustStock:(0,i.useCallback)(async(e,t,r,i)=>n.adjustStock(e,t,r,i),[]),reserveStock:(0,i.useCallback)(async(e,t,r,i)=>n.reserveStock(e,t,r,i),[]),cancelOrderReservations:(0,i.useCallback)(async e=>n.cancelOrderReservations(e),[]),receiveGoods:(0,i.useCallback)(async e=>n.receiveGoods(e),[]),createAndReceiveGoods:(0,i.useCallback)(async(e,t,r,i)=>n.createAndReceiveGoods(e,t,r,i),[])}}export{a as t};
|
|
||||||
1
dist/assets/useInventory-C7Fo8GE1.js
vendored
Normal file
1
dist/assets/useInventory-C7Fo8GE1.js
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
import{f as e,h as t}from"./useToast-CS8pZhce.js";import{rt as n,st as r}from"./index-CrJKtV0u.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 +1 @@
|
||||||
import{f as e,h as t}from"./useToast-CS8pZhce.js";import{nt as n,tt as r}from"./index-CNnq9VO5.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,r,i,a]=await Promise.all([n.orders.list({pageSize:100}),n.products.list({pageSize:100}),n.payments.list({pageSize:100}),n.shipments.list({pageSize:100})]);t(e.items),o(r.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]),r(`records:changed`,e=>{(e.entityType===`order`||e.entityType===`product`||e.entityType===`payment`||e.entityType===`shipment`)&&h()}),r(`storage:reset`,h),r(`snapshot:imported`,h),{orders:e,products:a,payments:s,shipments:l,loading:d,error:p,refetch:h,createOrder:(0,i.useCallback)(async e=>n.orders.create(e),[]),updateStatus:(0,i.useCallback)(async(e,t)=>n.updateOrderStatus(e,t),[]),confirmOrder:(0,i.useCallback)(async(e,t)=>n.confirmOrder(e,t),[]),cancelOrder:(0,i.useCallback)(async e=>n.cancelOrder(e),[]),createPayment:(0,i.useCallback)(async(e,t,r,i=!0)=>n.createPayment(e,t,r,i),[]),createShipment:(0,i.useCallback)(async(e,t,r)=>n.createShipment(e,t,r),[]),processReturn:(0,i.useCallback)(async(e,t,r,i=`wh_main`,a=!0)=>n.processReturn(e,t,r,i,a),[])}}export{a as t};
|
import{f as e,h as t}from"./useToast-CS8pZhce.js";import{it as n,rt as r}from"./index-CrJKtV0u.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,r,i,a]=await Promise.all([n.orders.list({pageSize:100}),n.products.list({pageSize:100}),n.payments.list({pageSize:100}),n.shipments.list({pageSize:100})]);t(e.items),o(r.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]),r(`records:changed`,e=>{(e.entityType===`order`||e.entityType===`product`||e.entityType===`payment`||e.entityType===`shipment`)&&h()}),r(`storage:reset`,h),r(`snapshot:imported`,h),{orders:e,products:a,payments:s,shipments:l,loading:d,error:p,refetch:h,createOrder:(0,i.useCallback)(async e=>n.orders.create(e),[]),updateStatus:(0,i.useCallback)(async(e,t)=>n.updateOrderStatus(e,t),[]),confirmOrder:(0,i.useCallback)(async(e,t)=>n.confirmOrder(e,t),[]),cancelOrder:(0,i.useCallback)(async e=>n.cancelOrder(e),[]),createPayment:(0,i.useCallback)(async(e,t,r,i=!0)=>n.createPayment(e,t,r,i),[]),createShipment:(0,i.useCallback)(async(e,t,r)=>n.createShipment(e,t,r),[]),processReturn:(0,i.useCallback)(async(e,t,r,i=`wh_main`,a=!0)=>n.processReturn(e,t,r,i,a),[])}}export{a as t};
|
||||||
4
dist/index.html
vendored
4
dist/index.html
vendored
|
|
@ -8,11 +8,11 @@
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||||
<script type="module" crossorigin src="/assets/index-CNnq9VO5.js"></script>
|
<script type="module" crossorigin src="/assets/index-CrJKtV0u.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="/assets/useToast-CS8pZhce.js">
|
<link rel="modulepreload" crossorigin href="/assets/useToast-CS8pZhce.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/demo-D1nHL60J.js">
|
<link rel="modulepreload" crossorigin href="/assets/demo-D1nHL60J.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/indexedDb-D30TbhA6.js">
|
<link rel="modulepreload" crossorigin href="/assets/indexedDb-D30TbhA6.js">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-D3QQttR0.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-Bb0d9GEE.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>
|
||||||
|
|
|
||||||
|
|
@ -282,4 +282,70 @@ test.describe('Krona Control Center - Mobile-First & Production UX Suite', () =>
|
||||||
await expect(page.locator('h1:has-text("Неизменяемый журнал аудита")')).toBeVisible();
|
await expect(page.locator('h1:has-text("Неизменяемый журнал аудита")')).toBeVisible();
|
||||||
await expect(page.locator('tbody tr')).toHaveCount(initialRowsCount);
|
await expect(page.locator('tbody tr')).toHaveCount(initialRowsCount);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('11. Connect new infrastructure server through onboarding wizard', async ({ page }) => {
|
||||||
|
await page.goto('/control/infrastructure');
|
||||||
|
await page.waitForLoadState('domcontentloaded');
|
||||||
|
|
||||||
|
// Click "+ Подключить сервер"
|
||||||
|
const addBtn = page.locator('[data-testid="btn-open-connect-server"]:visible').first();
|
||||||
|
await expect(addBtn).toBeVisible();
|
||||||
|
await addBtn.click();
|
||||||
|
|
||||||
|
// Verify modal is open
|
||||||
|
const modal = page.locator('[data-testid="connect-server-modal"]');
|
||||||
|
await expect(modal).toBeVisible();
|
||||||
|
|
||||||
|
// Fill Step 1
|
||||||
|
await page.locator('[data-testid="input-server-name"]').fill('vps-sel-e2e-99');
|
||||||
|
await page.locator('[data-testid="input-server-ip"]').fill('178.212.13.240');
|
||||||
|
await page.locator('[data-testid="btn-connect-next"]').click();
|
||||||
|
|
||||||
|
// Step 2: SSH config
|
||||||
|
await expect(page.locator('*:has-text("Настройка SSH"):visible').first()).toBeVisible();
|
||||||
|
await page.locator('[data-testid="btn-connect-next"]').click();
|
||||||
|
|
||||||
|
// Step 3: Test connection & Finish
|
||||||
|
await expect(page.locator('*:has-text("Проверка связи"):visible').first()).toBeVisible();
|
||||||
|
await page.locator('[data-testid="btn-test-ssh"]').click();
|
||||||
|
await expect(page.locator('*:has-text("SSH-соединение успешно установлено"):visible').first()).toBeVisible();
|
||||||
|
|
||||||
|
await page.locator('[data-testid="btn-connect-finish"]').click();
|
||||||
|
|
||||||
|
// Verify modal closed and server is present in the list
|
||||||
|
await expect(modal).not.toBeVisible();
|
||||||
|
await expect(page.locator('*:has-text("vps-sel-e2e-99"):visible').first()).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('12. Open interactive terminal and execute diagnostic commands', async ({ page }) => {
|
||||||
|
await page.goto('/control/infrastructure');
|
||||||
|
await page.waitForLoadState('domcontentloaded');
|
||||||
|
|
||||||
|
// Click terminal button on first server card
|
||||||
|
const termBtn = page.locator('[data-testid^="terminal-server-"]:visible').first();
|
||||||
|
await expect(termBtn).toBeVisible();
|
||||||
|
await termBtn.click();
|
||||||
|
|
||||||
|
// Verify terminal modal is open
|
||||||
|
const termModal = page.locator('[data-testid="server-terminal-modal"]');
|
||||||
|
await expect(termModal).toBeVisible();
|
||||||
|
|
||||||
|
// Enter bash command
|
||||||
|
const termInput = page.locator('[data-testid="terminal-input"]');
|
||||||
|
await termInput.fill('uptime');
|
||||||
|
await termInput.press('Enter');
|
||||||
|
|
||||||
|
// Verify command output
|
||||||
|
await expect(termModal.locator('*:has-text("load average:"):visible').first()).toBeVisible();
|
||||||
|
|
||||||
|
// Use quick action chip 'docker ps'
|
||||||
|
const dockerChip = termModal.locator('button:has-text("docker ps"):visible').first();
|
||||||
|
await dockerChip.click();
|
||||||
|
await expect(termModal.locator('*:has-text("krona-reverse-proxy"):visible').first()).toBeVisible();
|
||||||
|
|
||||||
|
// Close terminal modal
|
||||||
|
await termModal.locator('button[title="Закрыть терминал"]').click();
|
||||||
|
await expect(termModal).not.toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
|
||||||
485
src/features/control-center/components/ConnectServerModal.tsx
Normal file
485
src/features/control-center/components/ConnectServerModal.tsx
Normal file
|
|
@ -0,0 +1,485 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
X,
|
||||||
|
Server,
|
||||||
|
Key,
|
||||||
|
CheckCircle2,
|
||||||
|
AlertCircle,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
ChevronRight,
|
||||||
|
ChevronLeft,
|
||||||
|
Loader2,
|
||||||
|
Terminal,
|
||||||
|
Shield,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useKccStore } from '../store/kccStore';
|
||||||
|
import { RU_KCC } from '../locales/ru';
|
||||||
|
import type { NewServerPayload } from '../contracts';
|
||||||
|
|
||||||
|
interface ConnectServerModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onServerAdded?: (serverId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ConnectServerModal: React.FC<ConnectServerModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onServerAdded,
|
||||||
|
}) => {
|
||||||
|
const addServerNode = useKccStore((state) => state.addServerNode);
|
||||||
|
|
||||||
|
const [step, setStep] = useState<1 | 2 | 3>(1);
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [hostname, setHostname] = useState('');
|
||||||
|
const [ipAddress, setIpAddress] = useState('');
|
||||||
|
const [hostingProvider, setHostingProvider] = useState<
|
||||||
|
'Selectel' | 'Timeweb Cloud' | 'VK Cloud' | 'Self-hosted'
|
||||||
|
>('Selectel');
|
||||||
|
const [region, setRegion] = useState('ru-spb-1');
|
||||||
|
|
||||||
|
// SSH Configuration
|
||||||
|
const [port, setPort] = useState(22);
|
||||||
|
const [username, setUsername] = useState('krona');
|
||||||
|
const [authMethod, setAuthMethod] = useState<'key' | 'password'>('key');
|
||||||
|
const [publicKey, setPublicKey] = useState(
|
||||||
|
'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPeQZ5+614cK1c3Qz1xL3rY7uK9zM8v2n1j4b krona@ochenstarik.ru'
|
||||||
|
);
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [copiedKey, setCopiedKey] = useState(false);
|
||||||
|
|
||||||
|
// Verification & Status
|
||||||
|
const [isTesting, setIsTesting] = useState(false);
|
||||||
|
const [testResult, setTestResult] = useState<'none' | 'success' | 'failed'>('none');
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleCopyKey = () => {
|
||||||
|
navigator.clipboard.writeText(publicKey);
|
||||||
|
setCopiedKey(true);
|
||||||
|
setTimeout(() => setCopiedKey(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGenerateKey = () => {
|
||||||
|
const randomHex = Array.from({ length: 32 }, () =>
|
||||||
|
Math.floor(Math.random() * 16).toString(16)
|
||||||
|
).join('');
|
||||||
|
setPublicKey(
|
||||||
|
`ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI${randomHex.slice(0, 43)} kcc-agent@${name || 'krona-node'}`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTestConnection = async () => {
|
||||||
|
setIsTesting(true);
|
||||||
|
setTestResult('none');
|
||||||
|
setErrorMsg(null);
|
||||||
|
try {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 800));
|
||||||
|
if (ipAddress.trim() === '0.0.0.0') {
|
||||||
|
setTestResult('failed');
|
||||||
|
setErrorMsg('Таймаут соединения: хост не отвечает по порту ' + port);
|
||||||
|
} else {
|
||||||
|
setTestResult('success');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsTesting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!name.trim() || !ipAddress.trim()) {
|
||||||
|
setErrorMsg('Пожалуйста, заполните имя сервера и IP-адрес');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
const payload: NewServerPayload = {
|
||||||
|
name: name.trim(),
|
||||||
|
hostname: hostname.trim() || `${name.trim().toLowerCase()}.infra.krona.systems`,
|
||||||
|
ipAddress: ipAddress.trim(),
|
||||||
|
hostingProvider,
|
||||||
|
region: region.trim() || 'ru-dc1',
|
||||||
|
sshConfig: {
|
||||||
|
port: Number(port) || 22,
|
||||||
|
username: username.trim() || 'root',
|
||||||
|
authMethod,
|
||||||
|
publicKey: authMethod === 'key' ? publicKey : undefined,
|
||||||
|
fingerprint: `SHA256:${Array.from({ length: 24 }, () => Math.floor(Math.random() * 16).toString(16)).join('')}`,
|
||||||
|
status: 'connected',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const newServer = await addServerNode(payload, 'Подключение нового инфраструктурного сервера через мастер онбординга');
|
||||||
|
onServerAdded?.(newServer.serverId);
|
||||||
|
onClose();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setErrorMsg(err instanceof Error ? err.message : 'Ошибка при добавлении сервера');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-4 bg-black/60 backdrop-blur-xs overflow-y-auto"
|
||||||
|
data-testid="connect-server-modal"
|
||||||
|
>
|
||||||
|
<div className="relative w-full max-w-lg bg-krona-surface border border-krona-border rounded-2xl shadow-2xl overflow-hidden my-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-4 sm:p-5 border-b border-krona-border">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="w-9 h-9 rounded-xl bg-krona-gold/10 border border-krona-gold/30 flex items-center justify-center text-krona-gold">
|
||||||
|
<Server className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-bold text-krona-primary leading-tight">
|
||||||
|
{RU_KCC.connectServer.title}
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-krona-secondary mt-0.5">
|
||||||
|
Шаг {step} из 3: {step === 1 ? RU_KCC.connectServer.step1 : step === 2 ? RU_KCC.connectServer.step2 : RU_KCC.connectServer.step3}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Закрыть"
|
||||||
|
className="min-h-[44px] min-w-[44px] p-2 text-krona-secondary hover:text-krona-primary rounded-xl hover:bg-krona-workspace transition-colors flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Bar */}
|
||||||
|
<div className="w-full bg-krona-workspace h-1.5 flex">
|
||||||
|
<div
|
||||||
|
className="h-full bg-krona-gold transition-all duration-300"
|
||||||
|
style={{ width: `${(step / 3) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form Body */}
|
||||||
|
<div className="p-4 sm:p-5 space-y-4 max-h-[70vh] overflow-y-auto">
|
||||||
|
{errorMsg && (
|
||||||
|
<div className="p-3 rounded-xl bg-rose-500/10 border border-rose-500/30 text-rose-600 dark:text-rose-400 text-xs flex items-center gap-2">
|
||||||
|
<AlertCircle className="w-4 h-4 shrink-0" />
|
||||||
|
<span>{errorMsg}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* STEP 1: General Info */}
|
||||||
|
{step === 1 && (
|
||||||
|
<div className="space-y-3.5 text-xs">
|
||||||
|
<div>
|
||||||
|
<label className="block font-semibold text-krona-primary mb-1">
|
||||||
|
{RU_KCC.connectServer.serverName} <span className="text-rose-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder={RU_KCC.connectServer.serverNamePlaceholder}
|
||||||
|
className="w-full min-h-[44px] px-3 py-2 bg-krona-workspace rounded-xl border border-krona-border text-krona-primary text-xs focus:outline-hidden focus:ring-1 focus:ring-krona-gold"
|
||||||
|
data-testid="input-server-name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block font-semibold text-krona-primary mb-1">
|
||||||
|
{RU_KCC.connectServer.ipAddress} <span className="text-rose-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={ipAddress}
|
||||||
|
onChange={(e) => setIpAddress(e.target.value)}
|
||||||
|
placeholder={RU_KCC.connectServer.ipAddressPlaceholder}
|
||||||
|
className="w-full min-h-[44px] px-3 py-2 bg-krona-workspace rounded-xl border border-krona-border text-krona-primary text-xs font-mono focus:outline-hidden focus:ring-1 focus:ring-krona-gold"
|
||||||
|
data-testid="input-server-ip"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block font-semibold text-krona-primary mb-1">
|
||||||
|
{RU_KCC.connectServer.hostname}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={hostname}
|
||||||
|
onChange={(e) => setHostname(e.target.value)}
|
||||||
|
placeholder={RU_KCC.connectServer.hostnamePlaceholder}
|
||||||
|
className="w-full min-h-[44px] px-3 py-2 bg-krona-workspace rounded-xl border border-krona-border text-krona-primary text-xs font-mono focus:outline-hidden focus:ring-1 focus:ring-krona-gold"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block font-semibold text-krona-primary mb-1">
|
||||||
|
{RU_KCC.connectServer.provider}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={hostingProvider}
|
||||||
|
onChange={(e) =>
|
||||||
|
setHostingProvider(
|
||||||
|
e.target.value as 'Selectel' | 'Timeweb Cloud' | 'VK Cloud' | 'Self-hosted'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="w-full min-h-[44px] px-3 py-2 bg-krona-workspace rounded-xl border border-krona-border text-krona-primary text-xs focus:outline-hidden focus:ring-1 focus:ring-krona-gold"
|
||||||
|
>
|
||||||
|
<option value="Selectel">Selectel</option>
|
||||||
|
<option value="Timeweb Cloud">Timeweb Cloud</option>
|
||||||
|
<option value="VK Cloud">VK Cloud</option>
|
||||||
|
<option value="Self-hosted">Self-hosted / Свой ДЦ</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block font-semibold text-krona-primary mb-1">
|
||||||
|
{RU_KCC.connectServer.region}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={region}
|
||||||
|
onChange={(e) => setRegion(e.target.value)}
|
||||||
|
placeholder={RU_KCC.connectServer.regionPlaceholder}
|
||||||
|
className="w-full min-h-[44px] px-3 py-2 bg-krona-workspace rounded-xl border border-krona-border text-krona-primary text-xs focus:outline-hidden focus:ring-1 focus:ring-krona-gold"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* STEP 2: SSH Settings */}
|
||||||
|
{step === 2 && (
|
||||||
|
<div className="space-y-3.5 text-xs">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block font-semibold text-krona-primary mb-1">
|
||||||
|
{RU_KCC.connectServer.sshPort}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={port}
|
||||||
|
onChange={(e) => setPort(Number(e.target.value))}
|
||||||
|
className="w-full min-h-[44px] px-3 py-2 bg-krona-workspace rounded-xl border border-krona-border text-krona-primary text-xs font-mono focus:outline-hidden focus:ring-1 focus:ring-krona-gold"
|
||||||
|
data-testid="input-ssh-port"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block font-semibold text-krona-primary mb-1">
|
||||||
|
{RU_KCC.connectServer.sshUser}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
placeholder="krona или root"
|
||||||
|
className="w-full min-h-[44px] px-3 py-2 bg-krona-workspace rounded-xl border border-krona-border text-krona-primary text-xs font-mono focus:outline-hidden focus:ring-1 focus:ring-krona-gold"
|
||||||
|
data-testid="input-ssh-username"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block font-semibold text-krona-primary mb-1.5">
|
||||||
|
{RU_KCC.connectServer.authMethod}
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAuthMethod('key')}
|
||||||
|
className={`min-h-[44px] p-2.5 rounded-xl border flex items-center justify-center gap-2 text-xs font-medium transition-colors ${
|
||||||
|
authMethod === 'key'
|
||||||
|
? 'bg-krona-gold/10 border-krona-gold text-krona-gold'
|
||||||
|
: 'bg-krona-workspace border-krona-border text-krona-secondary hover:text-krona-primary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Key className="w-4 h-4" />
|
||||||
|
<span>SSH-ключ (ed25519)</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAuthMethod('password')}
|
||||||
|
className={`min-h-[44px] p-2.5 rounded-xl border flex items-center justify-center gap-2 text-xs font-medium transition-colors ${
|
||||||
|
authMethod === 'password'
|
||||||
|
? 'bg-krona-gold/10 border-krona-gold text-krona-gold'
|
||||||
|
: 'bg-krona-workspace border-krona-border text-krona-secondary hover:text-krona-primary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Shield className="w-4 h-4" />
|
||||||
|
<span>Пароль</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{authMethod === 'key' ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="font-semibold text-krona-primary">
|
||||||
|
{RU_KCC.connectServer.publicKey}
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleGenerateKey}
|
||||||
|
className="text-krona-gold hover:underline text-[11px]"
|
||||||
|
>
|
||||||
|
{RU_KCC.connectServer.generateKey}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<textarea
|
||||||
|
readOnly
|
||||||
|
rows={3}
|
||||||
|
value={publicKey}
|
||||||
|
className="w-full p-2.5 bg-krona-workspace rounded-xl border border-krona-border text-krona-primary text-[11px] font-mono resize-none focus:outline-hidden"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopyKey}
|
||||||
|
className="absolute top-2 right-2 min-h-[36px] min-w-[36px] px-2 py-1 bg-krona-surface border border-krona-border rounded-lg text-krona-secondary hover:text-krona-primary flex items-center gap-1 text-[11px] shadow-xs"
|
||||||
|
title={RU_KCC.connectServer.copyKey}
|
||||||
|
>
|
||||||
|
{copiedKey ? <Check className="w-3.5 h-3.5 text-emerald-500" /> : <Copy className="w-3.5 h-3.5" />}
|
||||||
|
<span>{copiedKey ? 'Скопировано' : 'Копировать'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-krona-secondary">
|
||||||
|
Скопируйте этот ключ и добавьте его в файл <code className="text-krona-primary font-mono">~/.ssh/authorized_keys</code> на целевом сервере.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<label className="block font-semibold text-krona-primary mb-1">
|
||||||
|
Пароль пользователя SSH
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="Введите пароль..."
|
||||||
|
className="w-full min-h-[44px] px-3 py-2 bg-krona-workspace rounded-xl border border-krona-border text-krona-primary text-xs focus:outline-hidden focus:ring-1 focus:ring-krona-gold"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* STEP 3: Verification & Summary */}
|
||||||
|
{step === 3 && (
|
||||||
|
<div className="space-y-4 text-xs">
|
||||||
|
<div className="p-3.5 rounded-xl bg-krona-workspace border border-krona-border space-y-2">
|
||||||
|
<div className="font-semibold text-krona-primary flex items-center justify-between">
|
||||||
|
<span>Сводка параметров узла:</span>
|
||||||
|
<span className="font-mono text-krona-gold">{hostingProvider}</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-krona-secondary text-[11px]">
|
||||||
|
<div>Сервер: <span className="font-semibold text-krona-primary">{name || '—'}</span></div>
|
||||||
|
<div>IP-адрес: <span className="font-mono text-krona-primary">{ipAddress || '—'}</span></div>
|
||||||
|
<div>SSH порт: <span className="font-mono text-krona-primary">{port}</span></div>
|
||||||
|
<div>Пользователь: <span className="font-mono text-krona-primary">{username}</span></div>
|
||||||
|
<div>Метод входа: <span className="text-krona-primary">{authMethod === 'key' ? 'SSH-ключ ed25519' : 'Пароль'}</span></div>
|
||||||
|
<div>Регион: <span className="text-krona-primary">{region}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Connection Test Action */}
|
||||||
|
<div className="p-3.5 rounded-xl bg-krona-workspace/60 border border-krona-border space-y-2.5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-semibold text-krona-primary">Проверка связи с сервером:</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleTestConnection}
|
||||||
|
disabled={isTesting}
|
||||||
|
className="min-h-[44px] px-3.5 py-1.5 rounded-xl bg-krona-surface border border-krona-border text-krona-primary hover:bg-krona-workspace text-xs font-medium flex items-center gap-1.5 disabled:opacity-50 transition-colors"
|
||||||
|
data-testid="btn-test-ssh"
|
||||||
|
>
|
||||||
|
{isTesting ? (
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin text-krona-gold" />
|
||||||
|
) : (
|
||||||
|
<Terminal className="w-3.5 h-3.5 text-sky-400" />
|
||||||
|
)}
|
||||||
|
<span>{isTesting ? RU_KCC.connectServer.connecting : RU_KCC.connectServer.testConnection}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{testResult === 'success' && (
|
||||||
|
<div className="p-2.5 rounded-lg bg-emerald-500/10 border border-emerald-500/30 text-emerald-600 dark:text-emerald-400 text-xs flex items-center gap-2">
|
||||||
|
<CheckCircle2 className="w-4 h-4 shrink-0 text-emerald-500" />
|
||||||
|
<span>{RU_KCC.connectServer.connectionSuccess}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{testResult === 'failed' && (
|
||||||
|
<div className="p-2.5 rounded-lg bg-rose-500/10 border border-rose-500/30 text-rose-600 dark:text-rose-400 text-xs flex items-center gap-2">
|
||||||
|
<AlertCircle className="w-4 h-4 shrink-0 text-rose-500" />
|
||||||
|
<span>{RU_KCC.connectServer.connectionFailed}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Footer */}
|
||||||
|
<div className="flex items-center justify-between p-4 sm:p-5 border-t border-krona-border bg-krona-workspace/40">
|
||||||
|
{step > 1 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStep((s) => (s - 1) as 1 | 2 | 3)}
|
||||||
|
className="min-h-[44px] px-4 py-2 rounded-xl text-xs font-medium text-krona-secondary hover:text-krona-primary border border-krona-border hover:bg-krona-workspace flex items-center gap-1 transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
<span>{RU_KCC.connectServer.back}</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="min-h-[44px] px-4 py-2 rounded-xl text-xs font-medium text-krona-secondary hover:text-krona-primary transition-colors"
|
||||||
|
>
|
||||||
|
{RU_KCC.connectServer.cancel}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step < 3 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (step === 1 && (!name.trim() || !ipAddress.trim())) {
|
||||||
|
setErrorMsg('Заполните название сервера и IP-адрес');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setErrorMsg(null);
|
||||||
|
setStep((s) => (s + 1) as 1 | 2 | 3);
|
||||||
|
}}
|
||||||
|
className="min-h-[44px] px-5 py-2 rounded-xl text-xs font-semibold bg-krona-gold text-krona-gold-contrast hover:brightness-110 flex items-center gap-1 shadow-md transition-all"
|
||||||
|
data-testid="btn-connect-next"
|
||||||
|
>
|
||||||
|
<span>{RU_KCC.connectServer.next}</span>
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="min-h-[44px] px-5 py-2 rounded-xl text-xs font-semibold bg-emerald-500 text-white hover:bg-emerald-600 flex items-center gap-1.5 shadow-md disabled:opacity-50 transition-all"
|
||||||
|
data-testid="btn-connect-finish"
|
||||||
|
>
|
||||||
|
{isSubmitting ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
|
||||||
|
<span>{RU_KCC.connectServer.finish}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -5,11 +5,14 @@ import {
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
Clock,
|
Clock,
|
||||||
Activity,
|
Activity,
|
||||||
|
Terminal,
|
||||||
|
Key,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useKccStore, useOperatorRole } from '../store/kccStore';
|
import { useKccStore, useOperatorRole } from '../store/kccStore';
|
||||||
import { StatusBadge } from './StatusBadge';
|
import { StatusBadge } from './StatusBadge';
|
||||||
import { MoneyDisplay } from './MoneyDisplay';
|
import { MoneyDisplay } from './MoneyDisplay';
|
||||||
import { HeartbeatTimeline } from './HeartbeatTimeline';
|
import { HeartbeatTimeline } from './HeartbeatTimeline';
|
||||||
|
import { ServerTerminalModal } from './ServerTerminalModal';
|
||||||
import { PlatformStatus } from '../contracts';
|
import { PlatformStatus } from '../contracts';
|
||||||
import { RU_INCIDENT_SEVERITY, RU_PLATFORMS, RU_ROLES } from '../locales/ru';
|
import { RU_INCIDENT_SEVERITY, RU_PLATFORMS, RU_ROLES } from '../locales/ru';
|
||||||
|
|
||||||
|
|
@ -39,6 +42,7 @@ export const InspectorDrawer: React.FC = () => {
|
||||||
>('overview');
|
>('overview');
|
||||||
const [selectedTrialModule, setSelectedTrialModule] = useState('krona-omnichannel');
|
const [selectedTrialModule, setSelectedTrialModule] = useState('krona-omnichannel');
|
||||||
const [trialDurationDays, setTrialDurationDays] = useState(14);
|
const [trialDurationDays, setTrialDurationDays] = useState(14);
|
||||||
|
const [isTerminalOpen, setIsTerminalOpen] = useState(false);
|
||||||
|
|
||||||
if (!inspectorTarget) return null;
|
if (!inspectorTarget) return null;
|
||||||
|
|
||||||
|
|
@ -402,6 +406,15 @@ export const InspectorDrawer: React.FC = () => {
|
||||||
<span>Операции с инфраструктурным узлом</span>
|
<span>Операции с инфраструктурным узлом</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsTerminalOpen(true)}
|
||||||
|
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 transition-colors flex items-center gap-1.5"
|
||||||
|
data-testid="btn-inspector-terminal"
|
||||||
|
>
|
||||||
|
<Terminal className="w-3.5 h-3.5" />
|
||||||
|
<span>Открыть терминал</span>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
disabled={!permissions.canRequestDiagnostics}
|
disabled={!permissions.canRequestDiagnostics}
|
||||||
onClick={handleDiagnostics}
|
onClick={handleDiagnostics}
|
||||||
|
|
@ -421,6 +434,44 @@ export const InspectorDrawer: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* SSH Configuration Card */}
|
||||||
|
{srv.sshConfig && (
|
||||||
|
<div className="rounded-xl bg-krona-workspace/60 border border-krona-border p-3.5 space-y-2.5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="font-semibold text-krona-primary flex items-center gap-1.5">
|
||||||
|
<Key className="w-4 h-4 text-krona-gold" />
|
||||||
|
<span>SSH-конфигурация узла:</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`text-[11px] font-medium px-2 py-0.5 rounded-md border ${
|
||||||
|
srv.sshConfig.status === 'connected'
|
||||||
|
? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/30'
|
||||||
|
: srv.sshConfig.status === 'unreachable'
|
||||||
|
? 'bg-rose-500/10 text-rose-600 dark:text-rose-400 border-rose-500/30'
|
||||||
|
: 'bg-krona-surface text-krona-secondary border-krona-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{srv.sshConfig.status === 'connected'
|
||||||
|
? 'SSH доступен'
|
||||||
|
: srv.sshConfig.status === 'unreachable'
|
||||||
|
? 'Хост недоступен'
|
||||||
|
: srv.sshConfig.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-krona-secondary text-[11px]">
|
||||||
|
<div>Порт SSH: <span className="font-mono text-krona-primary font-semibold">{srv.sshConfig.port}</span></div>
|
||||||
|
<div>Пользователь: <span className="font-mono text-krona-primary font-semibold">{srv.sshConfig.username}</span></div>
|
||||||
|
<div>Метод входа: <span className="text-krona-primary font-medium">{srv.sshConfig.authMethod === 'key' ? 'SSH-ключ ed25519' : 'Пароль'}</span></div>
|
||||||
|
<div>Fingerprint: <span className="font-mono text-krona-primary truncate block">{srv.sshConfig.fingerprint || '—'}</span></div>
|
||||||
|
</div>
|
||||||
|
{srv.sshConfig.lastConnectedAt && (
|
||||||
|
<div className="text-[10px] text-krona-tertiary pt-1 border-t border-krona-border/60">
|
||||||
|
Последний сеанс SSH: {new Date(srv.sshConfig.lastConnectedAt).toLocaleString('ru-RU')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Telemetry Metrics */}
|
{/* Telemetry Metrics */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="font-semibold text-krona-primary flex items-center justify-between">
|
<div className="font-semibold text-krona-primary flex items-center justify-between">
|
||||||
|
|
@ -530,6 +581,12 @@ export const InspectorDrawer: React.FC = () => {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ServerTerminalModal
|
||||||
|
server={srv}
|
||||||
|
isOpen={isTerminalOpen}
|
||||||
|
onClose={() => setIsTerminalOpen(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
319
src/features/control-center/components/ServerTerminalModal.tsx
Normal file
319
src/features/control-center/components/ServerTerminalModal.tsx
Normal file
|
|
@ -0,0 +1,319 @@
|
||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
X,
|
||||||
|
Terminal as TerminalIcon,
|
||||||
|
Trash2,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
CornerDownLeft,
|
||||||
|
Loader2,
|
||||||
|
Maximize2,
|
||||||
|
Minimize2,
|
||||||
|
Activity,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useKccStore } from '../store/kccStore';
|
||||||
|
import { RU_KCC, RU_TERMINAL_QUICK_COMMANDS } from '../locales/ru';
|
||||||
|
import type { ServerNode } from '../contracts';
|
||||||
|
|
||||||
|
interface ServerTerminalModalProps {
|
||||||
|
server: ServerNode | null;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommandHistoryItem {
|
||||||
|
id: string;
|
||||||
|
command: string;
|
||||||
|
output: string;
|
||||||
|
exitCode: number;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createInitialHistory(srv: ServerNode): CommandHistoryItem[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'init',
|
||||||
|
command: `ssh -p ${srv.sshConfig?.port || 22} ${srv.sshConfig?.username || 'krona'}@${srv.ipAddress}`,
|
||||||
|
output: `Welcome to Ubuntu 24.04 LTS (GNU/Linux 6.8.0-40-generic x86_64)\n` +
|
||||||
|
` * Documentation: https://help.ubuntu.com\n` +
|
||||||
|
` * Management: https://landscape.canonical.com\n` +
|
||||||
|
` * Support: https://ubuntu.com/pro\n\n` +
|
||||||
|
`System information as of ${new Date().toUTCString()}:\n` +
|
||||||
|
` System load: ${(srv.cpuLoadPercent / 100).toFixed(2)} Processes: 164\n` +
|
||||||
|
` Usage of /: ${Math.round((srv.diskUsedBytes / srv.diskTotalBytes) * 100)}% of ${Math.round(srv.diskTotalBytes / (1024 * 1024 * 1024))}GB Users logged in: 1\n` +
|
||||||
|
` Memory usage: ${Math.round((srv.ramUsedBytes / srv.ramTotalBytes) * 100)}% of ${Math.round(srv.ramTotalBytes / (1024 * 1024 * 1024))}GB IP address for eth0: ${srv.ipAddress}\n\n` +
|
||||||
|
`Last login: ${srv.sshConfig?.lastConnectedAt ? new Date(srv.sshConfig.lastConnectedAt).toLocaleString('ru-RU') : 'recently'} from 178.212.13.102 (mTLS control session)`,
|
||||||
|
exitCode: 0,
|
||||||
|
timestamp: new Date().toLocaleTimeString('ru-RU'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ServerTerminalModal: React.FC<ServerTerminalModalProps> = ({
|
||||||
|
server,
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
|
if (!isOpen || !server) return null;
|
||||||
|
|
||||||
|
return <ServerTerminalContent key={server.serverId} server={server} onClose={onClose} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ServerTerminalContent: React.FC<{ server: ServerNode; onClose: () => void }> = ({
|
||||||
|
server,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
|
const executeTerminalCommand = useKccStore((state) => state.executeTerminalCommand);
|
||||||
|
|
||||||
|
const [inputCommand, setInputCommand] = useState('');
|
||||||
|
const [history, setHistory] = useState<CommandHistoryItem[]>(() => createInitialHistory(server));
|
||||||
|
const [historyIndex, setHistoryIndex] = useState<number>(-1);
|
||||||
|
const [sentCommands, setSentCommands] = useState<string[]>([]);
|
||||||
|
const [isExecuting, setIsExecuting] = useState(false);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
|
||||||
|
const terminalEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Scroll to bottom on new output
|
||||||
|
useEffect(() => {
|
||||||
|
terminalEndRef.current?.scrollIntoView?.({ behavior: 'smooth' });
|
||||||
|
}, [history, isExecuting]);
|
||||||
|
|
||||||
|
// Focus input on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => inputRef.current?.focus(), 150);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleExecute = async (cmdToRun?: string) => {
|
||||||
|
const cmd = (cmdToRun !== undefined ? cmdToRun : inputCommand).trim();
|
||||||
|
if (!cmd || isExecuting) return;
|
||||||
|
|
||||||
|
if (cmd.toLowerCase() === 'clear') {
|
||||||
|
setHistory([]);
|
||||||
|
setInputCommand('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsExecuting(true);
|
||||||
|
setSentCommands((prev) => [...prev, cmd]);
|
||||||
|
setHistoryIndex(-1);
|
||||||
|
setInputCommand('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await executeTerminalCommand(server.serverId, cmd);
|
||||||
|
const newItem: CommandHistoryItem = {
|
||||||
|
id: `cmd-${Date.now()}`,
|
||||||
|
command: cmd,
|
||||||
|
output: res.output,
|
||||||
|
exitCode: res.exitCode,
|
||||||
|
timestamp: new Date().toLocaleTimeString('ru-RU'),
|
||||||
|
};
|
||||||
|
setHistory((prev) => [...prev, newItem]);
|
||||||
|
} finally {
|
||||||
|
setIsExecuting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleExecute();
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (sentCommands.length === 0) return;
|
||||||
|
const nextIdx = historyIndex === -1 ? sentCommands.length - 1 : Math.max(0, historyIndex - 1);
|
||||||
|
setHistoryIndex(nextIdx);
|
||||||
|
setInputCommand(sentCommands[nextIdx] || '');
|
||||||
|
} else if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (historyIndex === -1) return;
|
||||||
|
const nextIdx = historyIndex + 1;
|
||||||
|
if (nextIdx >= sentCommands.length) {
|
||||||
|
setHistoryIndex(-1);
|
||||||
|
setInputCommand('');
|
||||||
|
} else {
|
||||||
|
setHistoryIndex(nextIdx);
|
||||||
|
setInputCommand(sentCommands[nextIdx] || '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyOutput = () => {
|
||||||
|
const text = history
|
||||||
|
.map(
|
||||||
|
(h) =>
|
||||||
|
`[${server.sshConfig?.username || 'krona'}@${server.name}:~]$ ${h.command}\n${h.output}`
|
||||||
|
)
|
||||||
|
.join('\n\n');
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClear = () => {
|
||||||
|
setHistory([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const promptUser = server.sshConfig?.username || 'krona';
|
||||||
|
const promptHost = server.name;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center p-2 sm:p-4 bg-black/75 backdrop-blur-xs"
|
||||||
|
data-testid="server-terminal-modal"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`relative w-full flex flex-col bg-gray-950 border border-gray-800 rounded-2xl shadow-2xl overflow-hidden text-gray-200 transition-all duration-200 ${
|
||||||
|
isFullscreen ? 'h-full max-h-screen' : 'max-w-4xl h-[85vh] max-h-[750px]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Terminal Title Bar */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 bg-gray-900 border-b border-gray-800 shrink-0 select-none">
|
||||||
|
<div className="flex items-center gap-2.5 min-w-0">
|
||||||
|
{/* macOS / Linux style dots */}
|
||||||
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
|
<span className="w-3 h-3 rounded-full bg-rose-500/80 inline-block" />
|
||||||
|
<span className="w-3 h-3 rounded-full bg-amber-500/80 inline-block" />
|
||||||
|
<span className="w-3 h-3 rounded-full bg-emerald-500/80 inline-block" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 min-w-0 ml-1">
|
||||||
|
<TerminalIcon className="w-4 h-4 text-emerald-400 shrink-0" />
|
||||||
|
<span className="text-xs font-semibold text-gray-200 truncate">
|
||||||
|
{promptUser}@{promptHost}
|
||||||
|
</span>
|
||||||
|
<span className="hidden sm:inline-block text-[11px] font-mono text-gray-500 truncate">
|
||||||
|
({server.ipAddress}:{server.sshConfig?.port || 22})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopyOutput}
|
||||||
|
title={RU_KCC.terminal.copy}
|
||||||
|
className="min-h-[44px] min-w-[44px] p-2 text-gray-400 hover:text-gray-200 rounded-lg hover:bg-gray-800 flex items-center justify-center transition-colors"
|
||||||
|
>
|
||||||
|
{copied ? <Check className="w-4 h-4 text-emerald-400" /> : <Copy className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClear}
|
||||||
|
title={RU_KCC.terminal.clear}
|
||||||
|
className="min-h-[44px] min-w-[44px] p-2 text-gray-400 hover:text-gray-200 rounded-lg hover:bg-gray-800 flex items-center justify-center transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsFullscreen((f) => !f)}
|
||||||
|
title={isFullscreen ? 'Свернуть' : 'Развернуть'}
|
||||||
|
className="hidden sm:flex min-h-[44px] min-w-[44px] p-2 text-gray-400 hover:text-gray-200 rounded-lg hover:bg-gray-800 items-center justify-center transition-colors"
|
||||||
|
>
|
||||||
|
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
title={RU_KCC.terminal.close}
|
||||||
|
className="min-h-[44px] min-w-[44px] p-2 text-gray-400 hover:text-rose-400 rounded-lg hover:bg-gray-800 flex items-center justify-center transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Action Chips Bar */}
|
||||||
|
<div className="px-3 py-2 bg-gray-900/60 border-b border-gray-800/80 flex items-center gap-1.5 overflow-x-auto shrink-0 no-scrollbar">
|
||||||
|
<span className="text-[10px] text-gray-500 uppercase tracking-wider font-semibold shrink-0 mr-1 flex items-center gap-1">
|
||||||
|
<Activity className="w-3 h-3 text-sky-400" />
|
||||||
|
<span>Команды:</span>
|
||||||
|
</span>
|
||||||
|
{RU_TERMINAL_QUICK_COMMANDS.map((action) => (
|
||||||
|
<button
|
||||||
|
key={action.cmd}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleExecute(action.cmd)}
|
||||||
|
disabled={isExecuting}
|
||||||
|
className="min-h-[44px] px-2.5 py-1 rounded-lg bg-gray-800 hover:bg-gray-700 text-gray-300 hover:text-emerald-300 text-[11px] font-mono border border-gray-700/60 transition-colors shrink-0 disabled:opacity-50 flex items-center justify-center"
|
||||||
|
>
|
||||||
|
{action.cmd}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Terminal Screen Body */}
|
||||||
|
<div
|
||||||
|
className="flex-1 p-3 sm:p-4 overflow-y-auto font-mono text-xs sm:text-[13px] leading-relaxed space-y-3 bg-gray-950 text-gray-300 select-text"
|
||||||
|
onClick={() => inputRef.current?.focus()}
|
||||||
|
>
|
||||||
|
{history.map((item) => (
|
||||||
|
<div key={item.id} className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2 text-emerald-400 flex-wrap">
|
||||||
|
<span className="text-emerald-500 font-bold">
|
||||||
|
{promptUser}@{promptHost}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-500">:</span>
|
||||||
|
<span className="text-sky-400">~</span>
|
||||||
|
<span className="text-gray-400">$</span>
|
||||||
|
<span className="text-gray-100 font-bold">{item.command}</span>
|
||||||
|
<span className="ml-auto text-[10px] text-gray-600 font-sans">{item.timestamp}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{item.output && (
|
||||||
|
<pre className="text-gray-300 whitespace-pre-wrap break-all font-mono pl-2 border-l-2 border-gray-800/80">
|
||||||
|
{item.output}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{isExecuting && (
|
||||||
|
<div className="flex items-center gap-2 text-gray-500 py-1">
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin text-emerald-400" />
|
||||||
|
<span className="text-xs">{RU_KCC.terminal.executing}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div ref={terminalEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Terminal Input Prompt */}
|
||||||
|
<div className="p-3 bg-gray-900 border-t border-gray-800 shrink-0">
|
||||||
|
<div className="flex items-center gap-2 bg-gray-950 border border-gray-700 rounded-xl px-3 py-1.5 focus-within:border-emerald-500 transition-colors">
|
||||||
|
<span className="text-emerald-500 font-bold text-xs font-mono shrink-0 select-none">
|
||||||
|
{promptUser}@{promptHost}:$
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={inputCommand}
|
||||||
|
onChange={(e) => setInputCommand(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
disabled={isExecuting}
|
||||||
|
placeholder={RU_KCC.terminal.placeholder}
|
||||||
|
className="flex-1 bg-transparent text-gray-100 text-xs sm:text-sm font-mono focus:outline-hidden min-h-[44px]"
|
||||||
|
data-testid="terminal-input"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleExecute()}
|
||||||
|
disabled={!inputCommand.trim() || isExecuting}
|
||||||
|
className="min-h-[44px] min-w-[44px] p-2 text-gray-400 hover:text-emerald-400 rounded-lg hover:bg-gray-800 disabled:opacity-30 transition-colors flex items-center justify-center shrink-0"
|
||||||
|
title={RU_KCC.terminal.enterPrompt}
|
||||||
|
>
|
||||||
|
<CornerDownLeft className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -281,6 +281,37 @@ export interface RestoreDrillStatus {
|
||||||
drillDurationSeconds?: number;
|
drillDurationSeconds?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ServerSshConfig {
|
||||||
|
port: number;
|
||||||
|
username: string;
|
||||||
|
authMethod: 'key' | 'password';
|
||||||
|
publicKey?: string;
|
||||||
|
privateKeyName?: string;
|
||||||
|
fingerprint?: string;
|
||||||
|
lastConnectedAt?: IsoDateTime | null;
|
||||||
|
status: 'connected' | 'disconnected' | 'auth_failed' | 'unreachable';
|
||||||
|
allowPasswordFallback?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TerminalCommandLog {
|
||||||
|
id: string;
|
||||||
|
serverId: UUID;
|
||||||
|
command: string;
|
||||||
|
output: string;
|
||||||
|
exitCode: number;
|
||||||
|
executedAt: IsoDateTime;
|
||||||
|
executedBy: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NewServerPayload {
|
||||||
|
name: string;
|
||||||
|
hostname: string;
|
||||||
|
ipAddress: string;
|
||||||
|
hostingProvider: 'Selectel' | 'Timeweb Cloud' | 'VK Cloud' | 'Self-hosted';
|
||||||
|
region: string;
|
||||||
|
sshConfig: ServerSshConfig;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ServerNode {
|
export interface ServerNode {
|
||||||
serverId: UUID;
|
serverId: UUID;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -307,6 +338,7 @@ export interface ServerNode {
|
||||||
lastSeenAt: IsoDateTime;
|
lastSeenAt: IsoDateTime;
|
||||||
backup: BackupStatus;
|
backup: BackupStatus;
|
||||||
restoreDrill: RestoreDrillStatus;
|
restoreDrill: RestoreDrillStatus;
|
||||||
|
sshConfig?: ServerSshConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HeartbeatPoint {
|
export interface HeartbeatPoint {
|
||||||
|
|
|
||||||
|
|
@ -753,6 +753,16 @@ export const SEED_SERVER_NODES: ServerNode[] = [
|
||||||
verifiedAt: '2026-08-15T04:22:00Z',
|
verifiedAt: '2026-08-15T04:22:00Z',
|
||||||
drillDurationSeconds: 1320,
|
drillDurationSeconds: 1320,
|
||||||
},
|
},
|
||||||
|
sshConfig: {
|
||||||
|
port: 22,
|
||||||
|
username: 'krona',
|
||||||
|
authMethod: 'key',
|
||||||
|
publicKey: 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPeQZ5+614cK1c3Qz1xL3rY7uK9zM8v2n1j4b krona@ochenstarik.ru',
|
||||||
|
privateKeyName: 'id_ed25519_ochenstarik',
|
||||||
|
fingerprint: 'SHA256:KccSpbProd01Ed25519HostKeyFingerprint',
|
||||||
|
lastConnectedAt: '2026-09-10T18:40:00Z',
|
||||||
|
status: 'connected',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
serverId: 'srv-002-sel-msk',
|
serverId: 'srv-002-sel-msk',
|
||||||
|
|
@ -791,6 +801,16 @@ export const SEED_SERVER_NODES: ServerNode[] = [
|
||||||
verifiedAt: '2026-08-20T04:35:00Z',
|
verifiedAt: '2026-08-20T04:35:00Z',
|
||||||
drillDurationSeconds: 2100,
|
drillDurationSeconds: 2100,
|
||||||
},
|
},
|
||||||
|
sshConfig: {
|
||||||
|
port: 22,
|
||||||
|
username: 'krona-admin',
|
||||||
|
authMethod: 'key',
|
||||||
|
publicKey: 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMskProd02KeyAdminKronaInfrastructure',
|
||||||
|
privateKeyName: 'id_ed25519_msk_admin',
|
||||||
|
fingerprint: 'SHA256:MskProd02Ed25519KeyFingerprint9912',
|
||||||
|
lastConnectedAt: '2026-09-10T18:35:00Z',
|
||||||
|
status: 'connected',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
serverId: 'srv-003-tw-msk',
|
serverId: 'srv-003-tw-msk',
|
||||||
|
|
@ -829,6 +849,15 @@ export const SEED_SERVER_NODES: ServerNode[] = [
|
||||||
verifiedAt: '2026-05-10T03:40:00Z',
|
verifiedAt: '2026-05-10T03:40:00Z',
|
||||||
drillDurationSeconds: 2400,
|
drillDurationSeconds: 2400,
|
||||||
},
|
},
|
||||||
|
sshConfig: {
|
||||||
|
port: 2222,
|
||||||
|
username: 'root',
|
||||||
|
authMethod: 'key',
|
||||||
|
publicKey: 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITwClusterMskKeyAdminKronaInfrastructure',
|
||||||
|
fingerprint: 'SHA256:TwMskCluster03KeyFingerprint1190',
|
||||||
|
lastConnectedAt: '2026-09-10T17:20:00Z',
|
||||||
|
status: 'connected',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
serverId: 'srv-004-tw-spb',
|
serverId: 'srv-004-tw-spb',
|
||||||
|
|
@ -867,6 +896,15 @@ export const SEED_SERVER_NODES: ServerNode[] = [
|
||||||
verifiedAt: null,
|
verifiedAt: null,
|
||||||
drillDurationSeconds: 0,
|
drillDurationSeconds: 0,
|
||||||
},
|
},
|
||||||
|
sshConfig: {
|
||||||
|
port: 22,
|
||||||
|
username: 'krona',
|
||||||
|
authMethod: 'password',
|
||||||
|
fingerprint: 'SHA256:TwSpbOmni04KeyFingerprint8810',
|
||||||
|
lastConnectedAt: '2026-09-09T14:10:00Z',
|
||||||
|
status: 'connected',
|
||||||
|
allowPasswordFallback: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
serverId: 'srv-005-sel-msk',
|
serverId: 'srv-005-sel-msk',
|
||||||
|
|
@ -905,6 +943,15 @@ export const SEED_SERVER_NODES: ServerNode[] = [
|
||||||
verifiedAt: '2026-08-01T02:55:00Z',
|
verifiedAt: '2026-08-01T02:55:00Z',
|
||||||
drillDurationSeconds: 3300,
|
drillDurationSeconds: 3300,
|
||||||
},
|
},
|
||||||
|
sshConfig: {
|
||||||
|
port: 2200,
|
||||||
|
username: 'postgres-ops',
|
||||||
|
authMethod: 'key',
|
||||||
|
publicKey: 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDbReplicaOpsKeyAdminKronaInfrastructure',
|
||||||
|
fingerprint: 'SHA256:SelMskDb05KeyFingerprint4412',
|
||||||
|
lastConnectedAt: '2026-09-10T18:42:00Z',
|
||||||
|
status: 'connected',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
serverId: 'srv-006-tw-kzn',
|
serverId: 'srv-006-tw-kzn',
|
||||||
|
|
@ -942,6 +989,14 @@ export const SEED_SERVER_NODES: ServerNode[] = [
|
||||||
lastAttemptAt: null,
|
lastAttemptAt: null,
|
||||||
verifiedAt: null,
|
verifiedAt: null,
|
||||||
},
|
},
|
||||||
|
sshConfig: {
|
||||||
|
port: 22,
|
||||||
|
username: 'root',
|
||||||
|
authMethod: 'key',
|
||||||
|
fingerprint: 'SHA256:TwKznPilot06KeyFingerprint0019',
|
||||||
|
lastConnectedAt: null,
|
||||||
|
status: 'unreachable',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
serverId: 'srv-007-sel-spb',
|
serverId: 'srv-007-sel-spb',
|
||||||
|
|
@ -980,6 +1035,15 @@ export const SEED_SERVER_NODES: ServerNode[] = [
|
||||||
verifiedAt: '2026-07-28T05:25:00Z',
|
verifiedAt: '2026-07-28T05:25:00Z',
|
||||||
drillDurationSeconds: 1500,
|
drillDurationSeconds: 1500,
|
||||||
},
|
},
|
||||||
|
sshConfig: {
|
||||||
|
port: 22,
|
||||||
|
username: 'worker-admin',
|
||||||
|
authMethod: 'key',
|
||||||
|
publicKey: 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAISpbWorker07KeyAdminKronaInfrastructure',
|
||||||
|
fingerprint: 'SHA256:SelSpbWorker07Fingerprint3310',
|
||||||
|
lastConnectedAt: '2026-09-10T18:15:00Z',
|
||||||
|
status: 'connected',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
serverId: 'srv-008-tw-msk',
|
serverId: 'srv-008-tw-msk',
|
||||||
|
|
@ -1018,6 +1082,15 @@ export const SEED_SERVER_NODES: ServerNode[] = [
|
||||||
verifiedAt: '2026-08-10T03:15:00Z',
|
verifiedAt: '2026-08-10T03:15:00Z',
|
||||||
drillDurationSeconds: 900,
|
drillDurationSeconds: 900,
|
||||||
},
|
},
|
||||||
|
sshConfig: {
|
||||||
|
port: 22,
|
||||||
|
username: 'krona',
|
||||||
|
authMethod: 'key',
|
||||||
|
publicKey: 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIngress08KeyAdminKronaInfrastructure',
|
||||||
|
fingerprint: 'SHA256:TwMskIngress08Fingerprint2201',
|
||||||
|
lastConnectedAt: '2026-09-10T18:30:00Z',
|
||||||
|
status: 'connected',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
serverId: 'srv-009-sel-nov',
|
serverId: 'srv-009-sel-nov',
|
||||||
|
|
@ -1056,6 +1129,15 @@ export const SEED_SERVER_NODES: ServerNode[] = [
|
||||||
verifiedAt: '2026-07-15T04:30:00Z',
|
verifiedAt: '2026-07-15T04:30:00Z',
|
||||||
drillDurationSeconds: 1800,
|
drillDurationSeconds: 1800,
|
||||||
},
|
},
|
||||||
|
sshConfig: {
|
||||||
|
port: 2222,
|
||||||
|
username: 'edge-operator',
|
||||||
|
authMethod: 'key',
|
||||||
|
publicKey: 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEdgeNov09KeyAdminKronaInfrastructure',
|
||||||
|
fingerprint: 'SHA256:SelNovEdge09Fingerprint7719',
|
||||||
|
lastConnectedAt: '2026-09-10T16:50:00Z',
|
||||||
|
status: 'connected',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
serverId: 'srv-010-tw-spb',
|
serverId: 'srv-010-tw-spb',
|
||||||
|
|
@ -1094,6 +1176,15 @@ export const SEED_SERVER_NODES: ServerNode[] = [
|
||||||
verifiedAt: '2026-08-25T04:40:00Z',
|
verifiedAt: '2026-08-25T04:40:00Z',
|
||||||
drillDurationSeconds: 2400,
|
drillDurationSeconds: 2400,
|
||||||
},
|
},
|
||||||
|
sshConfig: {
|
||||||
|
port: 22,
|
||||||
|
username: 'bi-ops',
|
||||||
|
authMethod: 'key',
|
||||||
|
publicKey: 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBiAnalytics10KeyAdminKronaInfrastructure',
|
||||||
|
fingerprint: 'SHA256:TwSpbAnalytics10Fingerprint6640',
|
||||||
|
lastConnectedAt: '2026-09-10T18:00:00Z',
|
||||||
|
status: 'connected',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -195,4 +195,67 @@ export const RU_UI = {
|
||||||
exitToClient: 'Перейти в Krona Business OS',
|
exitToClient: 'Перейти в Krona Business OS',
|
||||||
operatorProfile: 'Профиль оператора',
|
operatorProfile: 'Профиль оператора',
|
||||||
},
|
},
|
||||||
|
connectServer: {
|
||||||
|
title: 'Подключение нового сервера',
|
||||||
|
step1: 'Параметры узла',
|
||||||
|
step2: 'Настройка SSH',
|
||||||
|
step3: 'Проверка связи',
|
||||||
|
serverName: 'Название сервера',
|
||||||
|
serverNamePlaceholder: 'например, vps-sel-msk-worker-01',
|
||||||
|
hostname: 'Сетевое имя (FQDN)',
|
||||||
|
hostnamePlaceholder: 'node-worker-01.infra.krona.systems',
|
||||||
|
ipAddress: 'IP-адрес',
|
||||||
|
ipAddressPlaceholder: '178.212.13.102',
|
||||||
|
provider: 'Провайдер хостинга',
|
||||||
|
region: 'Регион / ДЦ',
|
||||||
|
regionPlaceholder: 'ru-spb-1 или msk-dc1',
|
||||||
|
sshPort: 'SSH порт',
|
||||||
|
sshUser: 'Пользователь SSH',
|
||||||
|
authMethod: 'Метод аутентификации',
|
||||||
|
authKey: 'SSH-ключ (рекомендуется)',
|
||||||
|
authPassword: 'Пароль (fallback)',
|
||||||
|
publicKey: 'Публичный ключ SSH',
|
||||||
|
generateKey: 'Сгенерировать ed25519 ключ',
|
||||||
|
copyKey: 'Скопировать публичный ключ',
|
||||||
|
testConnection: 'Проверить SSH-соединение',
|
||||||
|
connecting: 'Проверка соединения с сервером...',
|
||||||
|
connectionSuccess: 'SSH-соединение успешно установлено (RTT: 14ms, OpenSSH 9.3)',
|
||||||
|
connectionFailed: 'Не удалось установить соединение по SSH',
|
||||||
|
finish: 'Подключить сервер',
|
||||||
|
cancel: 'Отмена',
|
||||||
|
next: 'Далее',
|
||||||
|
back: 'Назад',
|
||||||
|
},
|
||||||
|
terminal: {
|
||||||
|
title: 'Терминал сервера',
|
||||||
|
connectedTo: 'Подключено к',
|
||||||
|
placeholder: 'Введите bash команду (например: docker ps, uptime, free -h)...',
|
||||||
|
clear: 'Очистить экран',
|
||||||
|
copy: 'Копировать вывод',
|
||||||
|
copied: 'Скопировано в буфер',
|
||||||
|
close: 'Закрыть терминал',
|
||||||
|
quickActions: 'Быстрые команды диагностики',
|
||||||
|
executing: 'Выполнение команды...',
|
||||||
|
enterPrompt: 'Нажмите Enter для отправки',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const RU_SSH_STATUS: Record<string, { label: string; badgeVariant: 'success' | 'warning' | 'danger' | 'neutral' }> = {
|
||||||
|
connected: { label: 'SSH доступен', badgeVariant: 'success' },
|
||||||
|
disconnected: { label: 'SSH отключен', badgeVariant: 'neutral' },
|
||||||
|
auth_failed: { label: 'Ошибка SSH ключа', badgeVariant: 'danger' },
|
||||||
|
unreachable: { label: 'Хост недоступен', badgeVariant: 'danger' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RU_TERMINAL_QUICK_COMMANDS = [
|
||||||
|
{ cmd: 'uptime', label: 'Аптайм и нагрузка' },
|
||||||
|
{ cmd: 'docker ps', label: 'Docker контейнеры' },
|
||||||
|
{ cmd: 'free -h', label: 'Использование RAM' },
|
||||||
|
{ cmd: 'df -h', label: 'Свободное место' },
|
||||||
|
{ cmd: 'systemctl status krona', label: 'Статус Krona' },
|
||||||
|
{ cmd: 'docker logs --tail 20 krona-reverse-proxy', label: 'Логи Reverse Proxy' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const RU_KCC = RU_UI;
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,14 @@ import {
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Clock,
|
Clock,
|
||||||
|
Plus,
|
||||||
|
Terminal,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useKccStore } from '../store/kccStore';
|
import { useKccStore } from '../store/kccStore';
|
||||||
import { StatusBadge } from '../components/StatusBadge';
|
import { StatusBadge } from '../components/StatusBadge';
|
||||||
import { HeartbeatTimeline } from '../components/HeartbeatTimeline';
|
import { HeartbeatTimeline } from '../components/HeartbeatTimeline';
|
||||||
|
import { ConnectServerModal } from '../components/ConnectServerModal';
|
||||||
|
import { ServerTerminalModal } from '../components/ServerTerminalModal';
|
||||||
import { ServerNode } from '../contracts';
|
import { ServerNode } from '../contracts';
|
||||||
|
|
||||||
export const InfrastructurePage: React.FC = () => {
|
export const InfrastructurePage: React.FC = () => {
|
||||||
|
|
@ -23,6 +27,8 @@ export const InfrastructurePage: React.FC = () => {
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [providerFilter, setProviderFilter] = useState<'all' | 'Selectel' | 'Timeweb Cloud'>('all');
|
const [providerFilter, setProviderFilter] = useState<'all' | 'Selectel' | 'Timeweb Cloud'>('all');
|
||||||
const [expandedDetails, setExpandedDetails] = useState<Record<string, boolean>>({});
|
const [expandedDetails, setExpandedDetails] = useState<Record<string, boolean>>({});
|
||||||
|
const [isConnectModalOpen, setIsConnectModalOpen] = useState(false);
|
||||||
|
const [terminalServer, setTerminalServer] = useState<ServerNode | null>(null);
|
||||||
|
|
||||||
const toggleDetails = (id: string, e: React.MouseEvent) => {
|
const toggleDetails = (id: string, e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
@ -78,22 +84,34 @@ export const InfrastructurePage: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 sm:space-y-6" data-testid="infrastructure-page">
|
<div className="space-y-4 sm:space-y-6" data-testid="infrastructure-page">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2.5 border-b border-krona-border pb-4">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 border-b border-krona-border pb-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-lg sm:text-xl font-bold text-krona-primary flex items-center gap-2">
|
<h1 className="text-lg sm:text-xl font-bold text-krona-primary flex items-center gap-2">
|
||||||
<Server className="w-5 h-5 text-krona-gold" />
|
<Server className="w-5 h-5 text-krona-gold" />
|
||||||
<span>Инфраструктурные серверы</span>
|
<span>Инфраструктурные серверы</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xs text-krona-secondary mt-0.5">
|
<p className="text-xs text-krona-secondary mt-0.5">
|
||||||
Управление вычислительными узлами, телеметрией mTLS, бэкапами и нагрузкой
|
Управление вычислительными узлами, SSH-доступом, телеметрией mTLS, бэкапами и нагрузкой
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 self-start sm:self-auto flex-wrap">
|
||||||
<div className="text-xs text-krona-secondary">
|
<div className="text-xs text-krona-secondary">
|
||||||
Всего узлов: <span className="font-bold text-krona-primary">{servers.length}</span> (Selectel:{' '}
|
Узлов: <span className="font-bold text-krona-primary">{servers.length}</span> (Selectel:{' '}
|
||||||
<span className="font-bold text-krona-gold">{servers.filter((s) => s.hostingProvider === 'Selectel').length}</span>, Timeweb:{' '}
|
<span className="font-bold text-krona-gold">{servers.filter((s) => s.hostingProvider === 'Selectel').length}</span>, Timeweb:{' '}
|
||||||
<span className="font-bold text-krona-gold">{servers.filter((s) => s.hostingProvider === 'Timeweb Cloud').length}</span>)
|
<span className="font-bold text-krona-gold">{servers.filter((s) => s.hostingProvider === 'Timeweb Cloud').length}</span>)
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsConnectModalOpen(true)}
|
||||||
|
className="min-h-[44px] px-3.5 py-2 bg-krona-gold text-krona-gold-contrast hover:brightness-110 font-bold text-xs rounded-xl shadow-md flex items-center gap-2 transition-all shrink-0"
|
||||||
|
data-testid="btn-open-connect-server"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
<span>Подключить сервер</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filter and Search Bar */}
|
{/* Filter and Search Bar */}
|
||||||
|
|
@ -170,10 +188,25 @@ export const InfrastructurePage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Agent version badge */}
|
{/* Badges */}
|
||||||
<span className="text-[10px] text-krona-tertiary font-mono bg-krona-workspace px-2 py-0.5 rounded-sm shrink-0 border border-krona-border">
|
<div className="flex items-center gap-1.5 shrink-0 flex-wrap justify-end">
|
||||||
Агент v{srv.agentVersion}
|
{srv.sshConfig && (
|
||||||
|
<span
|
||||||
|
className={`text-[10px] font-mono px-2 py-0.5 rounded-sm border ${
|
||||||
|
srv.sshConfig.status === 'connected'
|
||||||
|
? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/30'
|
||||||
|
: srv.sshConfig.status === 'unreachable'
|
||||||
|
? 'bg-rose-500/10 text-rose-600 dark:text-rose-400 border-rose-500/30'
|
||||||
|
: 'bg-krona-workspace text-krona-secondary border-krona-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
SSH:{srv.sshConfig.port}
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-[10px] text-krona-tertiary font-mono bg-krona-workspace px-2 py-0.5 rounded-sm border border-krona-border">
|
||||||
|
v{srv.agentVersion}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Destination / Provider */}
|
{/* Destination / Provider */}
|
||||||
|
|
@ -286,7 +319,7 @@ export const InfrastructurePage: React.FC = () => {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Action Buttons Row */}
|
{/* Action Buttons Row */}
|
||||||
<div className="flex items-center justify-between pt-1 gap-2">
|
<div className="flex items-center justify-between pt-1 gap-2 flex-wrap">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => toggleDetails(srv.serverId, e)}
|
onClick={(e) => toggleDetails(srv.serverId, e)}
|
||||||
|
|
@ -306,6 +339,21 @@ export const InfrastructurePage: React.FC = () => {
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 ml-auto">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setTerminalServer(srv);
|
||||||
|
}}
|
||||||
|
className="min-h-[44px] px-3 flex items-center gap-1.5 text-xs font-semibold text-emerald-600 dark:text-emerald-400 bg-emerald-500/10 hover:bg-emerald-500/20 rounded-lg border border-emerald-500/30 transition-colors"
|
||||||
|
data-testid={`terminal-server-${srv.serverId}`}
|
||||||
|
title="Открыть терминал сервера"
|
||||||
|
>
|
||||||
|
<Terminal className="w-3.5 h-3.5" />
|
||||||
|
<span>Терминал</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
|
@ -320,6 +368,7 @@ export const InfrastructurePage: React.FC = () => {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
|
@ -329,6 +378,19 @@ export const InfrastructurePage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Modals */}
|
||||||
|
<ConnectServerModal
|
||||||
|
isOpen={isConnectModalOpen}
|
||||||
|
onClose={() => setIsConnectModalOpen(false)}
|
||||||
|
onServerAdded={(id) => openInspector('server', id)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ServerTerminalModal
|
||||||
|
server={terminalServer}
|
||||||
|
isOpen={!!terminalServer}
|
||||||
|
onClose={() => setTerminalServer(null)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ import type {
|
||||||
Installation,
|
Installation,
|
||||||
ModuleInstance,
|
ModuleInstance,
|
||||||
ServerNode,
|
ServerNode,
|
||||||
|
ServerSshConfig,
|
||||||
|
NewServerPayload,
|
||||||
Incident,
|
Incident,
|
||||||
Announcement,
|
Announcement,
|
||||||
Rollout,
|
Rollout,
|
||||||
|
|
@ -115,6 +117,9 @@ interface KccStoreState {
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
requestServerDiagnostics: (serverId: string, reason: string) => Promise<void>;
|
requestServerDiagnostics: (serverId: string, reason: string) => Promise<void>;
|
||||||
scheduleServerMaintenance: (serverId: string, reason: string) => Promise<void>;
|
scheduleServerMaintenance: (serverId: string, reason: string) => Promise<void>;
|
||||||
|
addServerNode: (payload: NewServerPayload, reason: string) => Promise<ServerNode>;
|
||||||
|
updateServerSshConfig: (serverId: string, config: Partial<ServerSshConfig>, reason: string) => Promise<void>;
|
||||||
|
executeTerminalCommand: (serverId: string, command: string) => Promise<{ output: string; exitCode: number }>;
|
||||||
resetToDefaults: () => Promise<void>;
|
resetToDefaults: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -625,6 +630,180 @@ export const useKccStore = create<KccStoreState>((set, get) => ({
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
addServerNode: async (payload, reason) => {
|
||||||
|
const { recordAuditEvent } = get();
|
||||||
|
const serverId = `srv-${Date.now().toString(36)}`;
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
const newServer: ServerNode = {
|
||||||
|
serverId,
|
||||||
|
name: payload.name,
|
||||||
|
hostname: payload.hostname,
|
||||||
|
ipAddress: payload.ipAddress,
|
||||||
|
hostingProvider: payload.hostingProvider,
|
||||||
|
region: payload.region,
|
||||||
|
status: 'healthy',
|
||||||
|
agentVersion: '1.4.2',
|
||||||
|
agentStatus: 'enrolled',
|
||||||
|
certificateExpiresAt: new Date(Date.now() + 365 * 24 * 3600 * 1000).toISOString(),
|
||||||
|
certificateDaysRemaining: 365,
|
||||||
|
cpuLoadPercent: 12,
|
||||||
|
ramUsedBytes: 3221225472, // 3GB
|
||||||
|
ramTotalBytes: 17179869184, // 16GB
|
||||||
|
diskUsedBytes: 21474836480, // 20GB
|
||||||
|
diskTotalBytes: 161061273600, // 150GB
|
||||||
|
inodeUsedPercent: 18,
|
||||||
|
networkRxBytesPerSec: 1200000,
|
||||||
|
networkTxBytesPerSec: 1800000,
|
||||||
|
uptimeSeconds: 60,
|
||||||
|
latencyMs: 15,
|
||||||
|
deployedInstancesCount: 0,
|
||||||
|
lastSeenAt: now,
|
||||||
|
backup: {
|
||||||
|
state: 'ok',
|
||||||
|
lastSuccessfulAt: now,
|
||||||
|
sizeBytes: 1073741824,
|
||||||
|
encryptedExternalCopy: true,
|
||||||
|
targetProvider: `${payload.hostingProvider} S3 Cold`,
|
||||||
|
},
|
||||||
|
restoreDrill: {
|
||||||
|
state: 'passed',
|
||||||
|
lastAttemptAt: now,
|
||||||
|
verifiedAt: now,
|
||||||
|
drillDurationSeconds: 600,
|
||||||
|
},
|
||||||
|
sshConfig: {
|
||||||
|
...payload.sshConfig,
|
||||||
|
lastConnectedAt: now,
|
||||||
|
status: 'connected',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await persistItem('servers', newServer);
|
||||||
|
set((state) => ({ servers: [newServer, ...state.servers] }));
|
||||||
|
|
||||||
|
await recordAuditEvent({
|
||||||
|
action: 'infrastructure.server_connected',
|
||||||
|
target: {
|
||||||
|
type: 'infrastructure',
|
||||||
|
id: serverId,
|
||||||
|
displayName: newServer.name,
|
||||||
|
},
|
||||||
|
reason,
|
||||||
|
impactSummary: `Подключен новый инфраструктурный узел ${newServer.name} (${newServer.ipAddress}) по SSH (порт ${newServer.sshConfig?.port || 22}, польз. ${newServer.sshConfig?.username || 'root'})`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return newServer;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateServerSshConfig: async (serverId, config, reason) => {
|
||||||
|
const { servers, recordAuditEvent } = get();
|
||||||
|
const srv = servers.find((s) => s.serverId === serverId);
|
||||||
|
if (!srv) return;
|
||||||
|
|
||||||
|
const updatedSsh: ServerSshConfig = {
|
||||||
|
port: 22,
|
||||||
|
username: 'root',
|
||||||
|
authMethod: 'key',
|
||||||
|
status: 'connected',
|
||||||
|
...srv.sshConfig,
|
||||||
|
...config,
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedServer: ServerNode = {
|
||||||
|
...srv,
|
||||||
|
sshConfig: updatedSsh,
|
||||||
|
};
|
||||||
|
|
||||||
|
await persistItem('servers', updatedServer);
|
||||||
|
set((state) => ({
|
||||||
|
servers: state.servers.map((s) => (s.serverId === serverId ? updatedServer : s)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
await recordAuditEvent({
|
||||||
|
action: 'infrastructure.ssh_config_updated',
|
||||||
|
target: {
|
||||||
|
type: 'infrastructure',
|
||||||
|
id: serverId,
|
||||||
|
displayName: srv.name,
|
||||||
|
},
|
||||||
|
reason,
|
||||||
|
impactSummary: `Обновлены параметры SSH-подключения для узла ${srv.name}: порт ${updatedSsh.port}, пользователь ${updatedSsh.username}, аутентификация ${updatedSsh.authMethod}`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
executeTerminalCommand: async (serverId, command) => {
|
||||||
|
const { servers, recordAuditEvent } = get();
|
||||||
|
const srv = servers.find((s) => s.serverId === serverId);
|
||||||
|
const cmd = command.trim();
|
||||||
|
const cmdLower = cmd.toLowerCase();
|
||||||
|
|
||||||
|
let output = '';
|
||||||
|
let exitCode = 0;
|
||||||
|
|
||||||
|
if (cmdLower === 'uptime') {
|
||||||
|
output = `${new Date().toTimeString().split(' ')[0]} up 91 days, 4:12, 1 user, load average: 0.24, 0.31, 0.28`;
|
||||||
|
} else if (cmdLower === 'docker ps' || cmdLower.startsWith('docker ps ')) {
|
||||||
|
output = `CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES\n` +
|
||||||
|
`a1b2c3d4e5f6 krona-reverse-proxy:latest "/entrypoint.sh cadd…" 3 weeks ago Up 3 weeks 0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp krona-reverse-proxy\n` +
|
||||||
|
`f7e8d9c0b1a2 krona-control-center:demo "nginx -g 'daemon of…" 2 days ago Up 2 days 0.0.0.0:8080->80/tcp kccdemo-web\n` +
|
||||||
|
`b3c4d5e6f7a8 krona-core-api:1.4.2 "node dist/server.js" 10 days ago Up 10 days 127.0.0.1:3000->3000/tcp krona-core\n` +
|
||||||
|
`c5d6e7f8a9b0 postgres:16-alpine "docker-entrypoint.s…" 2 months ago Up 2 months 127.0.0.1:5432->5432/tcp krona-postgres`;
|
||||||
|
} else if (cmdLower.startsWith('free')) {
|
||||||
|
output = ` total used free shared buff/cache available\n` +
|
||||||
|
`Mem: 16Gi 6.4Gi 7.8Gi 142Mi 1.8Gi 9.2Gi\n` +
|
||||||
|
`Swap: 4.0Gi 180Mi 3.8Gi`;
|
||||||
|
} else if (cmdLower.startsWith('df')) {
|
||||||
|
output = `Filesystem Size Used Avail Use% Mounted on\n` +
|
||||||
|
`/dev/vda1 148G 50G 92G 35% /\n` +
|
||||||
|
`devtmpfs 7.8G 0 7.8G 0% /dev\n` +
|
||||||
|
`tmpfs 7.9G 0 7.9G 0% /dev/shm\n` +
|
||||||
|
`/dev/vdb1 200G 24G 166G 13% /mnt/backups`;
|
||||||
|
} else if (cmdLower.includes('systemctl status')) {
|
||||||
|
output = `● krona.service - Krona Business OS Platform Daemon\n` +
|
||||||
|
` Loaded: loaded (/etc/systemd/system/krona.service; enabled; vendor preset: enabled)\n` +
|
||||||
|
` Active: active (running) since Thu 2026-06-11 14:32:00 MSK; 91 days ago\n` +
|
||||||
|
` Main PID: 1248 (krona-orchestra)\n` +
|
||||||
|
` Tasks: 28 (limit: 19124)\n` +
|
||||||
|
` Memory: 412.0M\n` +
|
||||||
|
` CPU: 1h 24min 12.441s\n` +
|
||||||
|
` CGroup: /system.slice/krona.service\n` +
|
||||||
|
` └─1248 /usr/local/bin/krona-orchestrator --config /etc/krona/daemon.yaml`;
|
||||||
|
} else if (cmdLower.includes('docker logs')) {
|
||||||
|
output = `{"level":"info","ts":1789091800,"logger":"tls.obtain","msg":"certificate valid","identifier":"kccdemo.ochenstarik.ru"}\n` +
|
||||||
|
`{"level":"info","ts":1789092110,"logger":"http.log.access","msg":"handled request","request":{"remote_ip":"178.212.13.102","method":"GET","uri":"/control/infrastructure","proto":"HTTP/2.0"},"status":200}\n` +
|
||||||
|
`{"level":"info","ts":1789092115,"logger":"tls.cache","msg":"cleaned expired certificates","count":0}\n` +
|
||||||
|
`{"level":"info","ts":1789092200,"logger":"admin.api","msg":"received telemetry ping","source":"node-spb-01"}`;
|
||||||
|
} else if (cmdLower === 'uname -a') {
|
||||||
|
output = `Linux ${srv?.hostname || 'node-spb-01.infra.krona.systems'} 6.8.0-40-generic #40-Ubuntu SMP PREEMPT_DYNAMIC x86_64 x86_64 x86_64 GNU/Linux`;
|
||||||
|
} else if (cmdLower === 'whoami') {
|
||||||
|
output = srv?.sshConfig?.username || 'krona';
|
||||||
|
} else if (cmdLower === 'hostname') {
|
||||||
|
output = srv?.hostname || 'node-spb-01.infra.krona.systems';
|
||||||
|
} else if (cmdLower.includes('os-release')) {
|
||||||
|
output = `PRETTY_NAME="Ubuntu 24.04 LTS"\nNAME="Ubuntu"\nVERSION_ID="24.04"\nVERSION="24.04 LTS (Noble Numbat)"\nID=ubuntu`;
|
||||||
|
} else if (cmdLower === 'clear') {
|
||||||
|
output = '';
|
||||||
|
} else {
|
||||||
|
output = `[${srv?.sshConfig?.username || 'krona'}@${srv?.name || 'server'}:~]$ ${cmd}\nВыполнено успешно (exit code: 0)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (srv && cmdLower !== 'clear') {
|
||||||
|
await recordAuditEvent({
|
||||||
|
action: 'infrastructure.terminal_command',
|
||||||
|
target: {
|
||||||
|
type: 'infrastructure',
|
||||||
|
id: serverId,
|
||||||
|
displayName: srv.name,
|
||||||
|
},
|
||||||
|
reason: 'Выполнение оператором команды в интерактивном терминале',
|
||||||
|
impactSummary: `Выполнена команда \`${cmd}\` на узле ${srv.name} (${srv.ipAddress})`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { output, exitCode };
|
||||||
|
},
|
||||||
|
|
||||||
resetToDefaults: async () => {
|
resetToDefaults: async () => {
|
||||||
await get().initialize(true);
|
await get().initialize(true);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
218
src/features/control-center/tests/serverManagement.test.tsx
Normal file
218
src/features/control-center/tests/serverManagement.test.tsx
Normal file
|
|
@ -0,0 +1,218 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import React from 'react';
|
||||||
|
import { ConnectServerModal } from '../components/ConnectServerModal';
|
||||||
|
import { ServerTerminalModal } from '../components/ServerTerminalModal';
|
||||||
|
import { useKccStore } from '../store/kccStore';
|
||||||
|
import type { ServerNode } from '../contracts';
|
||||||
|
|
||||||
|
describe('Control Center: Server Onboarding & Terminal', () => {
|
||||||
|
const mockServer: ServerNode = {
|
||||||
|
serverId: 'srv-test-01',
|
||||||
|
name: 'vps-test-spb-01',
|
||||||
|
hostname: 'node-test-01.infra.krona.systems',
|
||||||
|
ipAddress: '178.212.13.102',
|
||||||
|
hostingProvider: 'Selectel',
|
||||||
|
region: 'ru-spb-1',
|
||||||
|
status: 'healthy',
|
||||||
|
agentVersion: '1.4.2',
|
||||||
|
agentStatus: 'enrolled',
|
||||||
|
certificateExpiresAt: '2027-08-15T00:00:00Z',
|
||||||
|
certificateDaysRemaining: 338,
|
||||||
|
cpuLoadPercent: 24,
|
||||||
|
ramUsedBytes: 6871947673,
|
||||||
|
ramTotalBytes: 17179869184,
|
||||||
|
diskUsedBytes: 53687091200,
|
||||||
|
diskTotalBytes: 161061273600,
|
||||||
|
inodeUsedPercent: 31,
|
||||||
|
networkRxBytesPerSec: 1420000,
|
||||||
|
networkTxBytesPerSec: 2890000,
|
||||||
|
uptimeSeconds: 7894000,
|
||||||
|
latencyMs: 12,
|
||||||
|
deployedInstancesCount: 2,
|
||||||
|
lastSeenAt: '2026-09-10T18:45:00Z',
|
||||||
|
backup: {
|
||||||
|
state: 'ok',
|
||||||
|
lastSuccessfulAt: '2026-09-10T03:00:00Z',
|
||||||
|
sizeBytes: 12884901888,
|
||||||
|
encryptedExternalCopy: true,
|
||||||
|
targetProvider: 'Selectel S3 Cold',
|
||||||
|
},
|
||||||
|
restoreDrill: {
|
||||||
|
state: 'passed',
|
||||||
|
lastAttemptAt: '2026-08-15T04:00:00Z',
|
||||||
|
verifiedAt: '2026-08-15T04:22:00Z',
|
||||||
|
drillDurationSeconds: 1320,
|
||||||
|
},
|
||||||
|
sshConfig: {
|
||||||
|
port: 22,
|
||||||
|
username: 'krona',
|
||||||
|
authMethod: 'key',
|
||||||
|
publicKey: 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPeQZ5+614cK1c3Qz1xL3rY7uK9zM8v2n1j4b krona@ochenstarik.ru',
|
||||||
|
fingerprint: 'SHA256:TestFingerprint1234',
|
||||||
|
lastConnectedAt: '2026-09-10T18:40:00Z',
|
||||||
|
status: 'connected',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
useKccStore.setState({
|
||||||
|
servers: [mockServer],
|
||||||
|
auditLog: [],
|
||||||
|
activeOperator: {
|
||||||
|
id: 'usr-owner-01',
|
||||||
|
name: 'Виктор Власов',
|
||||||
|
role: 'platform_owner',
|
||||||
|
email: 'vlasov@krona.platform',
|
||||||
|
title: 'Chief Platform Architect & Owner',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ConnectServerModal', () => {
|
||||||
|
it('does not render when isOpen is false', () => {
|
||||||
|
render(<ConnectServerModal isOpen={false} onClose={vi.fn()} />);
|
||||||
|
expect(screen.queryByTestId('connect-server-modal')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates required fields on step 1 and advances to step 2', async () => {
|
||||||
|
render(<ConnectServerModal isOpen={true} onClose={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('connect-server-modal')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Шаг 1 из 3: Параметры узла')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Click Next without inputs -> shows error
|
||||||
|
fireEvent.click(screen.getByTestId('btn-connect-next'));
|
||||||
|
expect(screen.getByText(/Заполните название сервера и IP-адрес/)).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Fill in required inputs
|
||||||
|
fireEvent.change(screen.getByTestId('input-server-name'), {
|
||||||
|
target: { value: 'vps-test-edge-02' },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByTestId('input-server-ip'), {
|
||||||
|
target: { value: '185.129.50.150' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click Next -> advances to Step 2
|
||||||
|
fireEvent.click(screen.getByTestId('btn-connect-next'));
|
||||||
|
expect(screen.getByText('Шаг 2 из 3: Настройка SSH')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('input-ssh-port')).toHaveValue(22);
|
||||||
|
expect(screen.getByTestId('input-ssh-username')).toHaveValue('krona');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('completes the full onboarding wizard and calls addServerNode', async () => {
|
||||||
|
const handleServerAdded = vi.fn();
|
||||||
|
const handleClose = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ConnectServerModal
|
||||||
|
isOpen={true}
|
||||||
|
onClose={handleClose}
|
||||||
|
onServerAdded={handleServerAdded}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Step 1
|
||||||
|
fireEvent.change(screen.getByTestId('input-server-name'), {
|
||||||
|
target: { value: 'vps-new-node-03' },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByTestId('input-server-ip'), {
|
||||||
|
target: { value: '194.87.112.55' },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByTestId('btn-connect-next'));
|
||||||
|
|
||||||
|
// Step 2
|
||||||
|
expect(screen.getByText('Шаг 2 из 3: Настройка SSH')).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByTestId('btn-connect-next'));
|
||||||
|
|
||||||
|
// Step 3
|
||||||
|
expect(screen.getByText('Шаг 3 из 3: Проверка связи')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('vps-new-node-03')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('194.87.112.55')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Finish
|
||||||
|
fireEvent.click(screen.getByTestId('btn-connect-finish'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(handleClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify server added to store
|
||||||
|
const servers = useKccStore.getState().servers;
|
||||||
|
const added = servers.find((s) => s.name === 'vps-new-node-03');
|
||||||
|
expect(added).toBeDefined();
|
||||||
|
expect(added?.ipAddress).toBe('194.87.112.55');
|
||||||
|
expect(added?.sshConfig?.status).toBe('connected');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ServerTerminalModal', () => {
|
||||||
|
it('does not render when isOpen is false', () => {
|
||||||
|
render(<ServerTerminalModal server={mockServer} isOpen={false} onClose={vi.fn()} />);
|
||||||
|
expect(screen.queryByTestId('server-terminal-modal')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders terminal window and executes commands with output', async () => {
|
||||||
|
render(<ServerTerminalModal server={mockServer} isOpen={true} onClose={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('server-terminal-modal')).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText(/krona@vps-test-spb-01/).length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getByText(/Welcome to Ubuntu 24.04 LTS/)).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Type and submit command 'uptime'
|
||||||
|
const input = screen.getByTestId('terminal-input');
|
||||||
|
fireEvent.change(input, { target: { value: 'uptime' } });
|
||||||
|
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/load average:/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Quick action chip 'docker ps'
|
||||||
|
const dockerPsBtn = screen.getByRole('button', { name: 'docker ps' });
|
||||||
|
fireEvent.click(dockerPsBtn);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/krona-reverse-proxy/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles clear command and empties screen', async () => {
|
||||||
|
render(<ServerTerminalModal server={mockServer} isOpen={true} onClose={vi.fn()} />);
|
||||||
|
|
||||||
|
const input = screen.getByTestId('terminal-input');
|
||||||
|
fireEvent.change(input, { target: { value: 'clear' } });
|
||||||
|
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText(/Welcome to Ubuntu 24.04 LTS/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Store server actions', () => {
|
||||||
|
it('updates SSH config and creates audit record', async () => {
|
||||||
|
await useKccStore.getState().updateServerSshConfig(
|
||||||
|
mockServer.serverId,
|
||||||
|
{ port: 2222, username: 'admin' },
|
||||||
|
'Смена порта SSH на нестандартный'
|
||||||
|
);
|
||||||
|
|
||||||
|
const server = useKccStore.getState().servers.find((s) => s.serverId === mockServer.serverId);
|
||||||
|
expect(server?.sshConfig?.port).toBe(2222);
|
||||||
|
expect(server?.sshConfig?.username).toBe('admin');
|
||||||
|
|
||||||
|
const audit = useKccStore.getState().auditLog;
|
||||||
|
expect(audit.some((a) => a.action === 'infrastructure.ssh_config_updated')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('executes terminal command and creates audit record', async () => {
|
||||||
|
const result = await useKccStore.getState().executeTerminalCommand(mockServer.serverId, 'systemctl status krona');
|
||||||
|
expect(result.exitCode).toBe(0);
|
||||||
|
expect(result.output).toContain('krona.service - Krona Business OS Platform Daemon');
|
||||||
|
|
||||||
|
const audit = useKccStore.getState().auditLog;
|
||||||
|
expect(audit.some((a) => a.action === 'infrastructure.terminal_command')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue