diff --git a/e2e/specs/admin/node_heatmaps.spec.ts b/e2e/specs/admin/node_heatmaps.spec.ts new file mode 100644 index 00000000..da84a236 --- /dev/null +++ b/e2e/specs/admin/node_heatmaps.spec.ts @@ -0,0 +1,77 @@ +import { expect, test } from '@playwright/test'; +import { bootstrapVpsAdminWindow, failEnvelope, installHaveApiMock, jsonFulfill, setUiSettingsLocalStorage } from '../../fixtures'; + +const nodes = [ + { id: 1, name: 'node1', fqdn: 'node1.prg.example', type: 'node', maintenance_lock: 'no', status: true, location: { label: 'Praha' } }, + { id: 2, name: 'backup1', fqdn: 'backup1.prg.example', type: 'storage', maintenance_lock: 'no', status: true, location: { label: 'Praha' } }, + { id: 3, name: 'locked', fqdn: 'locked.prg.example', type: 'node', maintenance_lock: 'lock', status: true, location: { label: 'Praha' } }, + { id: 4, name: 'dns', fqdn: 'dns.prg.example', type: 'dns_server', maintenance_lock: 'no', status: true, location: { label: 'Praha' } }, +]; + +for (const language of ['cs', 'en'] as const) { + for (const entry of ['/', '/admin/nodes', '/app/nodes']) { + test(`@pr-smoke @pr-smoke-mobile heatmaps use legacy configuration and eligibility in ${language} at ${entry}`, async ({ page }) => { + await setUiSettingsLocalStorage(page, { language }); + await bootstrapVpsAdminWindow(page, { sessionToken: 'TEST' }); + await installHaveApiMock(page, { + user: { id: 1, login: 'test', level: entry === '/admin/nodes' ? 99 : 1 }, + handlers: { + ...(entry === '/' ? { 'GET users/current': () => jsonFulfill(failEnvelope('Unauthorized'), 401) } : {}), + 'GET system_configs/webui/goresheat_url': () => ({ system_config: { value: 'https://heatmap.example/charts/' } }), + 'GET nodes': () => ({ nodes }), + 'GET nodes/public_status': () => ({ nodes }), + 'GET cluster/public_stats': () => ({ public_stats: { user_count: 1, vps_count: 1, ipv4_left: 1 } }), + }, + }); + let frameRequests = 0; + await page.route('https://heatmap.example/**', route => { + frameRequests++; + return route.fulfill({ contentType: 'text/html', body: 'Fixture heatmapSynthetic CPU / disk I/O heatmap' }); + }); + await page.goto(entry); + const trigger = page.locator('[data-testid="nodes.heatmap.open.node1.prg.example"]:visible'); + await expect(trigger).toBeVisible(); + await expect(trigger).toHaveText(language === 'cs' ? 'Heatmapa' : 'Heatmap'); + await expect(page.locator('[data-testid="nodes.heatmap.open.backup1.prg.example"]:visible')).toBeVisible(); + await expect(page.getByTestId('nodes.heatmap.open.locked.prg.example')).toHaveCount(0); + await expect(page.getByTestId('nodes.heatmap.open.dns.prg.example')).toHaveCount(0); + expect(frameRequests).toBe(0); + await trigger.scrollIntoViewIfNeeded(); + await page.screenshot({ path: test.info().outputPath('heatmap-list.png') }); + const previousUrl = page.url(); + await trigger.click(); + await expect(page).toHaveURL(previousUrl); + const modal = page.getByTestId('nodes.heatmap.modal'); + await expect(modal).toBeVisible(); + await expect(modal).toContainText('node1.prg.example'); + await expect(page.getByTestId('nodes.heatmap.frame')).toHaveAttribute('src', 'https://heatmap.example/charts/node1.prg.example/'); + await expect(page.frameLocator('[data-testid="nodes.heatmap.frame"]').getByText('Synthetic CPU / disk I/O heatmap')).toBeVisible(); + await expect(page.getByTestId('nodes.heatmap.external')).toHaveAttribute('href', 'https://heatmap.example/charts/node1.prg.example/'); + const box = await modal.boundingBox(); + expect(box!.x).toBeGreaterThanOrEqual(0); + expect(box!.width).toBeLessThanOrEqual(page.viewportSize()!.width); + await page.screenshot({ path: test.info().outputPath('heatmap-dialog.png') }); + await page.getByTestId('nodes.heatmap.close').click(); + await expect(modal).toHaveCount(0); + await expect(page.getByTestId('nodes.heatmap.frame')).toHaveCount(0); + await expect(trigger).toBeFocused(); + await trigger.click(); + await page.keyboard.press('Escape'); + await expect(modal).toHaveCount(0); + }); + } +} + +for (const unavailable of [false, true]) { +test(`missing heatmap configuration leaves the public node list usable (error: ${unavailable})`, async ({ page }) => { + await bootstrapVpsAdminWindow(page, { sessionToken: 'TEST' }); + await installHaveApiMock(page, { handlers: { + 'GET users/current': () => jsonFulfill(failEnvelope('Unauthorized'), 401), + 'GET system_configs/webui/goresheat_url': () => unavailable ? jsonFulfill(failEnvelope('Unavailable'), 503) : ({ system_config: { value: '' } }), + 'GET nodes/public_status': () => ({ nodes }), + } }); + await page.goto('/'); + await expect(page.getByTestId('public.nodes.section')).toContainText('node1'); + await expect(page.locator('[data-testid^="nodes.heatmap.open."]')).toHaveCount(0); +}); +} diff --git a/src/components/cluster/NodeHeatmaps.tsx b/src/components/cluster/NodeHeatmaps.tsx new file mode 100644 index 00000000..25f936bf --- /dev/null +++ b/src/components/cluster/NodeHeatmaps.tsx @@ -0,0 +1,100 @@ +import React, { createContext, useContext, useEffect, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { ExternalLink, Grid2X2 } from 'lucide-react'; +import { useLocation } from 'react-router-dom'; + +import { useI18n } from '../../app/i18n'; +import { publicApiCall } from '../../lib/api/public'; +import { nodeHeatmapUrl, type HeatmapNode } from '../../lib/nodeHeatmap'; +import { Button } from '../ui/Button'; +import { Modal } from '../ui/Modal'; + +const Heatmaps = createContext<{ + baseUrl?: unknown; + open: (url: string, name: string) => void; +}>({ open: () => {} }); + +/** The legacy webui plugin exposes this setting publicly (min_user_level: 0). */ +export function NodeHeatmapProvider({ children }: { children: React.ReactNode }) { + const { t } = useI18n(); + const location = useLocation(); + const config = useQuery({ + queryKey: ['public', 'system_config', 'webui', 'goresheat_url'], + queryFn: async () => (await publicApiCall<{ value?: unknown }>({ + path: '/system_configs/webui/goresheat_url', + })).data, + staleTime: 5 * 60 * 1000, + retry: false, + }); + const [selected, setSelected] = useState<{ url: string; name: string } | null>(null); + const baseUrl = config.data?.value; + + useEffect(() => { setSelected(null); }, [location.pathname, baseUrl]); + + return ( + setSelected({ url, name }) }}> + {children} + setSelected(null)} + size="xl" + mobileFullScreen + testId="nodes.heatmap.modal" + footer={ +
+ {selected ? ( + + ) : null} + +
+ } + > + {selected ? ( +
+

{t('nodes.heatmap.description')}

+