Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions e2e/specs/admin/node_heatmaps.spec.ts
Original file line number Diff line number Diff line change
@@ -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: '<!doctype html><title>Fixture heatmap</title><body style="background:#16273f;color:white">Synthetic CPU / disk I/O heatmap</body>' });
});
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);
});
}
100 changes: 100 additions & 0 deletions src/components/cluster/NodeHeatmaps.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Heatmaps.Provider value={{ baseUrl, open: (url, name) => setSelected({ url, name }) }}>
{children}
<Modal
open={selected !== null}
title={t('nodes.heatmap.title', { node: selected?.name ?? '' })}
onClose={() => setSelected(null)}
size="xl"
mobileFullScreen
testId="nodes.heatmap.modal"
footer={
<div className="flex flex-wrap items-center justify-between gap-2">
{selected ? (
<Button href={selected.url} target="_blank" rel="noopener noreferrer" variant="secondary" size="sm" testId="nodes.heatmap.external">
<ExternalLink className="h-4 w-4" aria-hidden="true" />
{t('nodes.heatmap.external')}
</Button>
) : null}
<Button onClick={() => setSelected(null)} variant="secondary" size="sm" testId="nodes.heatmap.close">{t('common.close')}</Button>
</div>
}
>
{selected ? (
<div className="space-y-3">
<p className="text-sm text-muted">{t('nodes.heatmap.description')}</p>
<iframe
key={selected.url}
src={selected.url}
title={t('nodes.heatmap.title', { node: selected.name })}
className="h-heatmap w-full rounded-lg border border-border bg-surface"
referrerPolicy="no-referrer"
data-testid="nodes.heatmap.frame"
/>
<p className="text-xs text-muted">{t('nodes.heatmap.fallback')}</p>
</div>
) : null}
</Modal>
</Heatmaps.Provider>
);
}

export function useNodeHeatmapsAvailable(nodes: HeatmapNode[]): boolean {
const { baseUrl } = useContext(Heatmaps);
return nodes.some((node) => nodeHeatmapUrl(baseUrl, node) !== null);
}

export function NodeHeatmapButton({ node }: { node: HeatmapNode }) {
const { t } = useI18n();
const { baseUrl, open } = useContext(Heatmaps);
const url = nodeHeatmapUrl(baseUrl, node);
if (!url) return null;
const name = node.fqdn as string;

return (
<Button
variant="secondary"
size="sm"
onClick={() => open(url, name)}
title={t('nodes.heatmap.title', { node: name })}
ariaLabel={t('nodes.heatmap.title', { node: name })}
testId={`nodes.heatmap.open.${name}`}
>
<Grid2X2 className="h-4 w-4 text-accent" aria-hidden="true" />
{t('nodes.heatmap.action')}
</Button>
);
}
18 changes: 11 additions & 7 deletions src/components/ui/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export function Modal(props: {
onClose: () => void;
children: React.ReactNode;
footer?: React.ReactNode;
size?: 'sm' | 'md' | 'lg';
size?: 'sm' | 'md' | 'lg' | 'xl';

/**
* When enabled, the modal becomes full-screen on small viewports.
Expand Down Expand Up @@ -62,13 +62,17 @@ export function Modal(props: {
? mobileFullScreen
? 'max-w-none sm:max-w-sm'
: 'max-w-sm'
: props.size === 'lg'
: props.size === 'xl'
? mobileFullScreen
? 'max-w-none sm:max-w-3xl'
: 'max-w-3xl'
: mobileFullScreen
? 'max-w-none sm:max-w-xl'
: 'max-w-xl';
? 'max-w-none sm:max-w-6xl'
: 'max-w-6xl'
: props.size === 'lg'
? mobileFullScreen
? 'max-w-none sm:max-w-3xl'
: 'max-w-3xl'
: mobileFullScreen
? 'max-w-none sm:max-w-xl'
: 'max-w-xl';

return createPortal(
<div className={clsx('fixed inset-0 z-50 flex items-center justify-center', mobileFullScreen ? 'p-0 sm:p-4' : 'p-4')}>
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/locales/cs/admin/nodes.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
// Admin locale chunk: nodes
export const csAdmin_nodes = {
"nodes.heatmap.action": "Heatmapa",
"nodes.heatmap.title": "Heatmapa · {node}",
"nodes.heatmap.external": "Otevřít v nové kartě",
"nodes.heatmap.description": "Živé vytížení CPU (user, system, idle) a diskové I/O nodu.",
"nodes.heatmap.fallback": "Pokud se grafy nenačtou, otevři heatmapu v nové kartě.",
"admin.nodes.action.vpses": "VPS",
"admin.nodes.advanced.hint": "Všechny filtry zde jsou v URL (lze sdílet).",
"admin.nodes.advanced.issues.hint": "Nedostupné nebo zamčené pro údržbu.",
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/locales/en/admin/nodes.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
// Admin locale chunk: nodes
export const enAdmin_nodes = {
"nodes.heatmap.action": "Heatmap",
"nodes.heatmap.title": "Heatmap · {node}",
"nodes.heatmap.external": "Open in new tab",
"nodes.heatmap.description": "Live CPU usage (user, system, idle) and disk I/O on this node.",
"nodes.heatmap.fallback": "If the charts do not load, open the heatmap in a new tab.",
"admin.nodes.action.vpses": "VPS",
"admin.nodes.advanced.hint": "All filters here are reflected in the URL (shareable).",
"admin.nodes.advanced.issues.hint": "Down or maintenance-locked nodes.",
Expand Down
24 changes: 24 additions & 0 deletions src/lib/nodeHeatmap.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { nodeHeatmapUrl } from './nodeHeatmap';

const node = { fqdn: 'node1.prg.example', type: 'node', maintenance_lock: 'no' };
describe('legacy node heatmap links', () => {
it('keeps configured subpaths and the actual FQDN', () => {
expect(nodeHeatmapUrl('https://charts.example/heat///?old=1#old', node))
.toBe('https://charts.example/heat/node1.prg.example/');
expect(nodeHeatmapUrl('https://charts.example', { ...node, type: 'storage' }))
.toBe('https://charts.example/node1.prg.example/');
});
it.each(['lock', 'master_lock', undefined])('does not offer nodes with lock %s', (maintenance_lock) => {
expect(nodeHeatmapUrl('https://charts.example', { ...node, maintenance_lock })).toBeNull();
});
it.each(['mailer', 'dns_server', undefined])('does not offer node type %s', (type) => {
expect(nodeHeatmapUrl('https://charts.example', { ...node, type })).toBeNull();
});
it.each(['', undefined, 'javascript:alert(1)', 'http://charts.example', 'https://user:secret@charts.example', '/charts'])('rejects unsafe or missing base %s', (base) => {
expect(nodeHeatmapUrl(base, node)).toBeNull();
});
it.each(['../other', 'x/y', 'x?y', 'x#y', 'x..y', '', undefined])('rejects unsafe or missing FQDN %s', (fqdn) => {
expect(nodeHeatmapUrl('https://charts.example', { ...node, fqdn })).toBeNull();
});
});
25 changes: 25 additions & 0 deletions src/lib/nodeHeatmap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
export interface HeatmapNode {
fqdn?: unknown;
type?: unknown;
maintenance_lock?: unknown;
}

/** Same eligibility and per-FQDN path as legacy page_index.php. */
export function nodeHeatmapUrl(baseUrl: unknown, node: HeatmapNode): string | null {
if (node.type !== 'node' && node.type !== 'storage') return null;
if (node.maintenance_lock !== 'no') return null;
if (typeof baseUrl !== 'string' || !baseUrl.trim()) return null;
if (typeof node.fqdn !== 'string' || !/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/i.test(node.fqdn)) return null;
if (node.fqdn.includes('..')) return null;
try {
const base = new URL(baseUrl.trim());
// Heatmaps run in HTTPS UI deployments; do not embed mixed content or credentials.
if (base.protocol !== 'https:' || base.username || base.password) return null;
base.search = '';
base.hash = '';
base.pathname = `${base.pathname.replace(/\/+$/, '')}/${node.fqdn}/`;
return base.href;
} catch {
return null;
}
}
3 changes: 3 additions & 0 deletions src/pages/app/admin/NodesListContent.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React from 'react';
import { NodeHeatmapButton } from '../../../components/cluster/NodeHeatmaps';
import { Server } from 'lucide-react';
import { Link } from 'react-router-dom';

Expand Down Expand Up @@ -74,6 +75,7 @@ function NodesRowActions(props: { row: NodeRow; basePath: string; t: NodesPageTr
if (compact) {
return (
<div className="flex items-center justify-end gap-1">
<NodeHeatmapButton node={row} />
{copyValue ? (
<CopyButton
text={copyValue}
Expand Down Expand Up @@ -103,6 +105,7 @@ function NodesRowActions(props: { row: NodeRow; basePath: string; t: NodesPageTr

return (
<div className="flex flex-wrap items-center gap-2">
<NodeHeatmapButton node={row} />
{row.fqdn ? <CopyButton text={row.fqdn} /> : row.name ? <CopyButton text={row.name} /> : null}
{typeof row.id === 'number' ? (
<LinkButton to={`${basePath}/nodes/${row.id}`} variant="secondary" size="sm">
Expand Down
5 changes: 4 additions & 1 deletion src/pages/app/admin/NodesModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export interface NodeRow {
id?: number;
name: string;
fqdn?: string;
type?: string;
domain_name?: string;
locationLabel?: string;

Expand Down Expand Up @@ -189,7 +190,8 @@ function rowFromNode(node: Node, statusIndex: Map<string, PublicNodeStatus>): No
return {
id,
name,
fqdn: stringField(node, 'fqdn'),
fqdn: stringField(node, 'fqdn') ?? stringField(status, 'fqdn'),
type: stringField(status, 'type') ?? stringField(node, 'type'),
domain_name: stringField(node, 'domain_name'),
locationLabel: locationLabel(unknownField(node, 'location')),

Expand All @@ -214,6 +216,7 @@ function rowFromPublicStatus(status: PublicNodeStatus): NodeRow {
id,
name,
fqdn: stringField(status, 'fqdn'),
type: stringField(status, 'type'),
domain_name: stringField(status, 'domain_name'),
locationLabel: locationLabel(unknownField(status, 'location')),

Expand Down
43 changes: 23 additions & 20 deletions src/pages/app/admin/NodesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';

import { NodeHeatmapProvider } from '../../../components/cluster/NodeHeatmaps';
import { useAppMode } from '../../../app/appMode';
import { useAuth } from '../../../app/auth';
import { useI18n } from '../../../app/i18n';
Expand Down Expand Up @@ -457,26 +458,28 @@ function NodesPageContent() {
onListRefresh={() => nodesQ.refetch()}
/>
) : null}
<NodesListContent
t={t}
basePath={basePath}
rows={filtered}
stats={stats}
statsScopeLabel={statsScopeLabel}
filtersActive={filtersActive}
onClearFilters={clearFilters}
onRetry={refetchAll}
isBlockingError={nodesQ.isError && statusQ.isError && rows.length === 0}
nodesError={nodesQ.error}
statusError={statusQ.error}
showAuthIndexUnavailable={nodesQ.isError && Boolean(statusQ.data)}
showPublicStatusUnavailable={statusQ.isError && Boolean(nodesQ.data)}
isLoading={nodesQ.isLoading && !nodesQ.data && statusQ.isLoading && !statusQ.data}
canPaginate={canPaginate}
canNext={canNext}
pageCursor={pageCursor}
pagination={pagination}
/>
<NodeHeatmapProvider>
<NodesListContent
t={t}
basePath={basePath}
rows={filtered}
stats={stats}
statsScopeLabel={statsScopeLabel}
filtersActive={filtersActive}
onClearFilters={clearFilters}
onRetry={refetchAll}
isBlockingError={nodesQ.isError && statusQ.isError && rows.length === 0}
nodesError={nodesQ.error}
statusError={statusQ.error}
showAuthIndexUnavailable={nodesQ.isError && Boolean(statusQ.data)}
showPublicStatusUnavailable={statusQ.isError && Boolean(nodesQ.data)}
isLoading={nodesQ.isLoading && !nodesQ.data && statusQ.isLoading && !statusQ.data}
canPaginate={canPaginate}
canNext={canNext}
pageCursor={pageCursor}
pagination={pagination}
/>
</NodeHeatmapProvider>
<NodeCreateModal
open={createOpen}
capabilityAvailable={auth.role === 'admin' && createCapabilityQ.isSuccess}
Expand Down
Loading
Loading