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')}
+
+
{t('nodes.heatmap.fallback')}
+
+ ) : null}
+
+
+ );
+}
+
+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 (
+
+ );
+}
diff --git a/src/components/ui/Modal.tsx b/src/components/ui/Modal.tsx
index d138a39a..d9d4da08 100644
--- a/src/components/ui/Modal.tsx
+++ b/src/components/ui/Modal.tsx
@@ -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.
@@ -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(
diff --git a/src/i18n/locales/cs/admin/nodes.ts b/src/i18n/locales/cs/admin/nodes.ts
index 8f022a7f..48386297 100644
--- a/src/i18n/locales/cs/admin/nodes.ts
+++ b/src/i18n/locales/cs/admin/nodes.ts
@@ -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.",
diff --git a/src/i18n/locales/en/admin/nodes.ts b/src/i18n/locales/en/admin/nodes.ts
index 678c8484..f60beb09 100644
--- a/src/i18n/locales/en/admin/nodes.ts
+++ b/src/i18n/locales/en/admin/nodes.ts
@@ -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.",
diff --git a/src/lib/nodeHeatmap.test.ts b/src/lib/nodeHeatmap.test.ts
new file mode 100644
index 00000000..3d0d473c
--- /dev/null
+++ b/src/lib/nodeHeatmap.test.ts
@@ -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();
+ });
+});
diff --git a/src/lib/nodeHeatmap.ts b/src/lib/nodeHeatmap.ts
new file mode 100644
index 00000000..fc9b8a42
--- /dev/null
+++ b/src/lib/nodeHeatmap.ts
@@ -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;
+ }
+}
diff --git a/src/pages/app/admin/NodesListContent.tsx b/src/pages/app/admin/NodesListContent.tsx
index 01065187..931359a6 100644
--- a/src/pages/app/admin/NodesListContent.tsx
+++ b/src/pages/app/admin/NodesListContent.tsx
@@ -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';
@@ -74,6 +75,7 @@ function NodesRowActions(props: { row: NodeRow; basePath: string; t: NodesPageTr
if (compact) {
return (
+
{copyValue ? (
+
{row.fqdn ? : row.name ? : null}
{typeof row.id === 'number' ? (
diff --git a/src/pages/app/admin/NodesModel.ts b/src/pages/app/admin/NodesModel.ts
index a486bca9..bfe630dc 100644
--- a/src/pages/app/admin/NodesModel.ts
+++ b/src/pages/app/admin/NodesModel.ts
@@ -7,6 +7,7 @@ export interface NodeRow {
id?: number;
name: string;
fqdn?: string;
+ type?: string;
domain_name?: string;
locationLabel?: string;
@@ -189,7 +190,8 @@ function rowFromNode(node: Node, statusIndex: Map): 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')),
@@ -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')),
diff --git a/src/pages/app/admin/NodesPage.tsx b/src/pages/app/admin/NodesPage.tsx
index 6305d02a..4517d0fe 100644
--- a/src/pages/app/admin/NodesPage.tsx
+++ b/src/pages/app/admin/NodesPage.tsx
@@ -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';
@@ -457,26 +458,28 @@ function NodesPageContent() {
onListRefresh={() => nodesQ.refetch()}
/>
) : null}
-
+
+
+
{i18n.t('public.overview.nodes.storage')}:
+
{i18n.t('public.overview.nodes.vps')}: {typeof node.vps_count === 'number' ? node.vps_count : '—'}
@@ -122,18 +124,15 @@ function NodeMobileCards(props: { group: PublicNodeLocationGroup; open: boolean
function NodeDesktopTable(props: { group: PublicNodeLocationGroup }) {
const i18n = useI18n();
+ const hasHeatmaps = useNodeHeatmapsAvailable(props.group.nodes);
return (
-
+
-
-
-
-
-
-
-
+ {(hasHeatmaps ? [17, 13, 13, 8, 12, 15, 9, 13] : [18, 16, 16, 11, 13, 16, 10]).map((width, index) => (
+
+ ))}
@@ -144,6 +143,7 @@ function NodeDesktopTable(props: { group: PublicNodeLocationGroup }) {
| {i18n.t('public.overview.nodes.table.cpu_used')} |
{i18n.t('public.overview.nodes.table.kernel')} |
{i18n.t('public.overview.nodes.table.cgroups')} |
+ {hasHeatmaps ? {i18n.t('nodes.heatmap.action')} | : null}
@@ -158,6 +158,7 @@ function NodeDesktopTable(props: { group: PublicNodeLocationGroup }) {
{cpuUsedLabel(node)} |
{node.kernel ? String(node.kernel) : '—'} |
{cgroupVersionLabel(node['cgroup_version'])} |
+ {hasHeatmaps ? | : null}
))}
diff --git a/src/pages/public/OverviewPage.tsx b/src/pages/public/OverviewPage.tsx
index c8df0983..c61325c5 100644
--- a/src/pages/public/OverviewPage.tsx
+++ b/src/pages/public/OverviewPage.tsx
@@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Link, useLocation, useNavigate, useSearchParams } from 'react-router-dom';
+import { NodeHeatmapProvider } from '../../components/cluster/NodeHeatmaps';
import { getRuntimeConfig } from '../../app/config';
import { useI18n } from '../../app/i18n';
import { StatusLandingMark } from '../../components/branding/StatusLandingMark';
@@ -158,7 +159,9 @@ export function OverviewPage() {
newsError={newsQ.isError}
/>
-
+
+
+
);
}
diff --git a/tailwind.config.js b/tailwind.config.js
index bf7c01db..24a1a105 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -15,6 +15,8 @@ module.exports = {
'scroll-registry': '420px',
},
height: {
+ // Leave room for the heatmap title, description and persistent footer.
+ heatmap: 'clamp(16rem, calc(100dvh - 15rem), 48rem)',
console: 'var(--console-h)',
'console-focus': 'var(--console-focus-h)',
},