From d43dfbc490a0ee51a53552d313ba207170cfecd3 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sun, 1 Mar 2026 23:28:33 +0000 Subject: [PATCH] feat(ui): add host removal action to hosts table Add an actions menu to the hosts overview with a "Remove host from Pulse" button. Includes permission checks (requires settings:write scope), confirmation handling, and a security regression test for the delete endpoint scope enforcement. --- .../Hosts/HostRemoveActionButton.tsx | 31 ++ .../src/components/Hosts/HostsOverview.tsx | 207 ++++++++++++- .../__tests__/HostsOverview.actions.test.tsx | 278 ++++++++++++++++++ .../Hosts/__tests__/permissions.test.ts | 25 ++ .../src/components/Hosts/permissions.ts | 9 + internal/api/security_regression_test.go | 31 ++ 6 files changed, 580 insertions(+), 1 deletion(-) create mode 100644 frontend-modern/src/components/Hosts/HostRemoveActionButton.tsx create mode 100644 frontend-modern/src/components/Hosts/__tests__/HostsOverview.actions.test.tsx create mode 100644 frontend-modern/src/components/Hosts/__tests__/permissions.test.ts create mode 100644 frontend-modern/src/components/Hosts/permissions.ts diff --git a/frontend-modern/src/components/Hosts/HostRemoveActionButton.tsx b/frontend-modern/src/components/Hosts/HostRemoveActionButton.tsx new file mode 100644 index 000000000..f82745b61 --- /dev/null +++ b/frontend-modern/src/components/Hosts/HostRemoveActionButton.tsx @@ -0,0 +1,31 @@ +import { Component } from 'solid-js'; + +interface HostRemoveActionButtonProps { + onClick: () => void; + disabled?: boolean; + loading?: boolean; + class?: string; +} + +export const HostRemoveActionButton: Component = (props) => { + return ( + + ); +}; diff --git a/frontend-modern/src/components/Hosts/HostsOverview.tsx b/frontend-modern/src/components/Hosts/HostsOverview.tsx index 336e3a25b..a0f74bd37 100644 --- a/frontend-modern/src/components/Hosts/HostsOverview.tsx +++ b/frontend-modern/src/components/Hosts/HostsOverview.tsx @@ -21,12 +21,16 @@ import { STORAGE_KEYS } from '@/utils/localStorage'; import { useResourcesAsLegacy } from '@/hooks/useResources'; import { useAlertsActivation } from '@/stores/alertsActivation'; import { HostMetadataAPI, type HostMetadata } from '@/api/hostMetadata'; +import { MonitoringAPI } from '@/api/monitoring'; +import { SecurityAPI } from '@/api/security'; +import { hasSettingsWriteAccess } from './permissions'; import { logger } from '@/utils/logger'; import { ScrollableTable } from '@/components/shared/ScrollableTable'; import { buildMetricKey } from '@/utils/metricsKeys'; import { isKioskMode, subscribeToKioskMode } from '@/utils/url'; import { HostDrawer } from './HostDrawer'; +import { HostRemoveActionButton } from './HostRemoveActionButton'; import { UrlEditPopover, createUrlEditState } from '@/components/shared/UrlEditPopover'; import { showSuccess, showError } from '@/utils/toast'; @@ -653,6 +657,7 @@ function HostRAIDStatusCell(props: HostRAIDStatusCellProps) { } type SortKey = 'name' | 'platform' | 'cpu' | 'memory' | 'disk' | 'uptime'; + export const HostsOverview: Component = () => { const navigate = useNavigate(); const wsContext = useWebSocket(); @@ -695,6 +700,10 @@ export const HostsOverview: Component = () => { // Host metadata management (for custom URLs) const [hostMetadata, setHostMetadata] = createSignal>({}); const [hostMetadataVersion, setHostMetadataVersion] = createSignal(0); + const [canRemoveHostAgents, setCanRemoveHostAgents] = createSignal(false); + const [removingHostIds, setRemovingHostIds] = createSignal>({}); + const [actionsMenuHostId, setActionsMenuHostId] = createSignal(null); + const [actionsMenuPosition, setActionsMenuPosition] = createSignal<{ top: number; left: number } | null>(null); // Load host metadata on mount createEffect(() => { @@ -708,6 +717,53 @@ export const HostsOverview: Component = () => { }); }); + onMount(() => { + let isActive = true; + void SecurityAPI.getStatus() + .then((status) => { + if (!isActive) return; + setCanRemoveHostAgents(hasSettingsWriteAccess(status.tokenScopes)); + }) + .catch((err) => { + if (!isActive) return; + setCanRemoveHostAgents(false); + logger.debug('Failed to load token scopes for host removal action', { error: err }); + }); + + return () => { + isActive = false; + }; + }); + + onMount(() => { + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as HTMLElement | null; + if (!target) return; + + const clickedMenu = target.closest('[data-host-actions-menu]'); + const clickedTrigger = target.closest('[data-host-actions-trigger]'); + if (!clickedMenu && !clickedTrigger) { + setActionsMenuHostId(null); + setActionsMenuPosition(null); + } + }; + + const handleMenuEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setActionsMenuHostId(null); + setActionsMenuPosition(null); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + document.addEventListener('keydown', handleMenuEscape); + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('keydown', handleMenuEscape); + }; + }); + // Get custom URL for a host const getHostCustomUrl = (hostId: string): string | undefined => { // Access version to trigger reactivity when metadata changes @@ -750,6 +806,63 @@ export const HostsOverview: Component = () => { } }; + const handleRemoveHostAgent = async (host: Host): Promise => { + setActionsMenuHostId(null); + setActionsMenuPosition(null); + + const hostName = host.displayName || host.hostname || host.id; + const currentRemoving = removingHostIds(); + if (currentRemoving[host.id]) { + return; + } + + const confirmed = confirm( + `Remove "${hostName}" from Pulse?\n\nThis only removes it from the Pulse host list. It does not uninstall the agent from the server.`, + ); + if (!confirmed) { + return; + } + + setRemovingHostIds(prev => ({ ...prev, [host.id]: true })); + + try { + await MonitoringAPI.deleteHostAgent(host.id); + + try { + await HostMetadataAPI.deleteMetadata(host.id); + setHostMetadata(prev => { + if (!prev[host.id]) { + return prev; + } + const next = { ...prev }; + delete next[host.id]; + return next; + }); + setHostMetadataVersion(v => v + 1); + } catch (metaErr) { + logger.debug('Failed to clean up host metadata after host removal', { hostId: host.id, error: metaErr }); + } + + setExpandedHostId(prev => (prev === host.id ? null : prev)); + showSuccess(`${hostName} removed from Pulse`); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to remove host'; + const normalized = message.toLowerCase(); + if (normalized.includes('settings:write') || normalized.includes('forbidden') || normalized.includes('status 403')) { + showError('You need settings:write permission to remove hosts.'); + } else { + showError(message); + } + logger.error('Failed to remove host from Pulse', { hostId: host.id, error: err }); + } finally { + setRemovingHostIds(prev => { + const next = { ...prev }; + delete next[host.id]; + return next; + }); + } + }; + const handleSort = (key: SortKey) => { if (sortKey() === key) { setSortDirection(sortDirection() === 'asc' ? 'desc' : 'asc'); @@ -796,6 +909,50 @@ export const HostsOverview: Component = () => { // Access asHosts() directly inside the memo to maintain reactivity const hosts = () => asHosts() as Host[]; + const menuHost = createMemo(() => { + const hostID = actionsMenuHostId(); + if (!hostID) { + return null; + } + return hosts().find(h => h.id === hostID) || null; + }); + + const toggleRowActionsMenu = (hostId: string, event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + + if (actionsMenuHostId() === hostId) { + setActionsMenuHostId(null); + setActionsMenuPosition(null); + return; + } + + const trigger = event.currentTarget as HTMLElement; + const rect = trigger.getBoundingClientRect(); + const menuWidth = 200; + const menuHeight = 80; + const viewportWidth = window.visualViewport?.width ?? window.innerWidth; + const viewportHeight = window.visualViewport?.height ?? window.innerHeight; + const viewportOffsetTop = window.visualViewport?.offsetTop ?? 0; + + const spaceBelow = viewportHeight - (rect.bottom - viewportOffsetTop); + const top = spaceBelow >= menuHeight + 8 ? rect.bottom + 6 : rect.top - menuHeight - 6; + const left = Math.max(8, Math.min(rect.right - menuWidth, viewportWidth - menuWidth - 8)); + + setActionsMenuHostId(hostId); + setActionsMenuPosition({ top, left }); + }; + + createEffect(() => { + const hostID = actionsMenuHostId(); + if (!hostID) { + return; + } + if (!hosts().some(h => h.id === hostID)) { + setActionsMenuHostId(null); + setActionsMenuPosition(null); + } + }); const isInitialLoading = createMemo(() => { return !connected() && !reconnecting() && hosts().length === 0; @@ -1054,7 +1211,7 @@ export const HostsOverview: Component = () => { RAID - + @@ -1072,6 +1229,11 @@ export const HostsOverview: Component = () => { isExpanded={expandedHostId() === host.id} onToggleExpand={() => toggleHostExpand(host.id)} totalColumns={visibleColumnIds().length} + canRemoveHost={canRemoveHostAgents()} + isRemovingHost={Boolean(removingHostIds()[host.id])} + onRemoveHost={handleRemoveHostAgent} + isActionsMenuOpen={actionsMenuHostId() === host.id} + onToggleActionsMenu={(event) => toggleRowActionsMenu(host.id, event)} /> )} @@ -1113,6 +1275,31 @@ export const HostsOverview: Component = () => { + + + +
+ { + const host = menuHost(); + if (!host) { + return; + } + void handleRemoveHostAgent(host); + }} + /> +
+
+
); }; @@ -1129,6 +1316,11 @@ interface HostRowProps { isExpanded: boolean; onToggleExpand: () => void; totalColumns: number; + canRemoveHost: boolean; + isRemovingHost: boolean; + onRemoveHost: (host: Host) => Promise; + isActionsMenuOpen: boolean; + onToggleActionsMenu: (event: MouseEvent) => void; } const HostRow: Component = (props) => { @@ -1413,6 +1605,19 @@ const HostRow: Component = (props) => { + + + diff --git a/frontend-modern/src/components/Hosts/__tests__/HostsOverview.actions.test.tsx b/frontend-modern/src/components/Hosts/__tests__/HostsOverview.actions.test.tsx new file mode 100644 index 000000000..5e65f0f78 --- /dev/null +++ b/frontend-modern/src/components/Hosts/__tests__/HostsOverview.actions.test.tsx @@ -0,0 +1,278 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, waitFor, cleanup } from '@solidjs/testing-library'; +import { Router, Route } from '@solidjs/router'; +import type { Host } from '@/types/api'; +import { HostsOverview } from '@/components/Hosts/HostsOverview'; + +let currentHosts: Host[] = []; + +const deleteHostAgentMock = vi.fn(); +const getSecurityStatusMock = vi.fn(); +const getAllHostMetadataMock = vi.fn(); +const deleteHostMetadataMock = vi.fn(); +const showSuccessMock = vi.fn(); +const showErrorMock = vi.fn(); + +vi.mock('@/App', () => ({ + useWebSocket: () => ({ + connected: () => true, + reconnecting: () => false, + reconnect: vi.fn(), + state: {}, + activeAlerts: [], + }), +})); + +vi.mock('@/hooks/useResources', () => ({ + useResourcesAsLegacy: () => ({ + asHosts: () => currentHosts, + }), +})); + +vi.mock('@/hooks/useBreakpoint', () => ({ + useBreakpoint: () => ({ + isMobile: () => false, + }), +})); + +vi.mock('@/hooks/useColumnVisibility', () => ({ + useColumnVisibility: (_storageKey: string, columns: unknown[]) => ({ + visibleColumns: () => columns, + availableToggles: () => columns, + isHiddenByUser: () => false, + toggle: vi.fn(), + resetToDefaults: vi.fn(), + }), +})); + +vi.mock('@/stores/alertsActivation', () => ({ + useAlertsActivation: () => ({ + getTemperatureThreshold: () => 80, + }), +})); + +vi.mock('@/api/security', () => ({ + SecurityAPI: { + getStatus: (...args: unknown[]) => getSecurityStatusMock(...args), + }, +})); + +vi.mock('@/api/monitoring', () => ({ + MonitoringAPI: { + deleteHostAgent: (...args: unknown[]) => deleteHostAgentMock(...args), + }, +})); + +vi.mock('@/api/hostMetadata', () => ({ + HostMetadataAPI: { + getAllMetadata: (...args: unknown[]) => getAllHostMetadataMock(...args), + deleteMetadata: (...args: unknown[]) => deleteHostMetadataMock(...args), + updateMetadata: vi.fn().mockResolvedValue(undefined), + }, +})); + +vi.mock('@/utils/toast', () => ({ + showSuccess: (...args: unknown[]) => showSuccessMock(...args), + showError: (...args: unknown[]) => showErrorMock(...args), +})); + +vi.mock('@/utils/url', () => ({ + isKioskMode: () => false, + subscribeToKioskMode: () => () => undefined, +})); + +vi.mock('@/components/shared/Card', () => ({ + Card: (props: any) =>
{props.children}
, +})); + +vi.mock('@/components/shared/EmptyState', () => ({ + EmptyState: (props: { title?: string; description?: string }) => ( +
+
{props.title}
+
{props.description}
+
+ ), +})); + +vi.mock('@/components/shared/ScrollableTable', () => ({ + ScrollableTable: (props: any) =>
{props.children}
, +})); + +vi.mock('@/components/Hosts/HostsFilter', () => ({ + HostsFilter: () =>
, +})); + +vi.mock('@/components/Hosts/HostDrawer', () => ({ + HostDrawer: () =>
, +})); + +vi.mock('@/components/shared/StatusDot', () => ({ + StatusDot: () => , +})); + +vi.mock('@/components/Dashboard/EnhancedCPUBar', () => ({ + EnhancedCPUBar: () =>
, +})); + +vi.mock('@/components/Dashboard/StackedMemoryBar', () => ({ + StackedMemoryBar: () =>
, +})); + +vi.mock('@/components/Dashboard/StackedDiskBar', () => ({ + StackedDiskBar: () =>
, +})); + +const createHost = (overrides: Partial = {}): Host => ({ + id: 'host-1', + hostname: 'host-1.local', + displayName: 'Host One', + platform: 'linux', + osName: 'Ubuntu', + osVersion: '24.04', + kernelVersion: '6.8.0', + architecture: 'x86_64', + cpuCount: 8, + cpuUsage: 10, + loadAverage: [0.2], + memory: { + total: 16 * 1024 * 1024 * 1024, + used: 8 * 1024 * 1024 * 1024, + free: 8 * 1024 * 1024 * 1024, + usage: 50, + balloon: 0, + swapUsed: 0, + swapTotal: 0, + }, + disks: [], + networkInterfaces: [], + sensors: { + temperatureCelsius: {}, + fanRpm: {}, + additional: {}, + }, + raid: [], + status: 'online', + uptimeSeconds: 12345, + lastSeen: Date.now(), + intervalSeconds: 30, + agentVersion: '1.0.0', + ...overrides, +}); + +const renderComponent = () => + render(() => ( + + } /> + + )); + +describe('HostsOverview row actions menu', () => { + beforeEach(() => { + currentHosts = [createHost()]; + + deleteHostAgentMock.mockReset().mockResolvedValue(undefined); + getSecurityStatusMock.mockReset().mockResolvedValue({ tokenScopes: ['settings:write'] }); + getAllHostMetadataMock.mockReset().mockResolvedValue({}); + deleteHostMetadataMock.mockReset().mockResolvedValue(undefined); + showSuccessMock.mockReset(); + showErrorMock.mockReset(); + }); + + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + }); + + it('opens and closes the row actions menu via outside click', async () => { + renderComponent(); + + const trigger = await screen.findByTitle('Host actions'); + fireEvent.click(trigger); + + expect(await screen.findByRole('button', { name: /remove host from pulse/i })).toBeInTheDocument(); + + fireEvent.mouseDown(document.body); + + await waitFor(() => { + expect(screen.queryByRole('button', { name: /remove host from pulse/i })).not.toBeInTheDocument(); + }); + }); + + it('closes the row actions menu when Escape is pressed', async () => { + renderComponent(); + + const trigger = await screen.findByTitle('Host actions'); + fireEvent.click(trigger); + expect(await screen.findByRole('button', { name: /remove host from pulse/i })).toBeInTheDocument(); + + fireEvent.keyDown(document, { key: 'Escape' }); + + await waitFor(() => { + expect(screen.queryByRole('button', { name: /remove host from pulse/i })).not.toBeInTheDocument(); + }); + }); + + it('removes a host from the row actions menu', async () => { + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true); + renderComponent(); + + const trigger = await screen.findByTitle('Host actions'); + fireEvent.click(trigger); + + const removeButton = await screen.findByRole('button', { name: /remove host from pulse/i }); + fireEvent.click(removeButton); + + await waitFor(() => { + expect(deleteHostAgentMock).toHaveBeenCalledWith('host-1'); + }); + expect(deleteHostMetadataMock).toHaveBeenCalledWith('host-1'); + await waitFor(() => { + expect(showSuccessMock).toHaveBeenCalledWith('Host One removed from Pulse'); + }); + expect(confirmSpy).toHaveBeenCalled(); + }); + + it('hides row actions for read-only scoped tokens', async () => { + getSecurityStatusMock.mockResolvedValue({ tokenScopes: ['monitoring:read'] }); + renderComponent(); + + await waitFor(() => expect(getSecurityStatusMock).toHaveBeenCalled()); + + expect(screen.queryByTitle('Host actions')).not.toBeInTheDocument(); + }); + + it('positions menu within viewport bounds near the trigger', async () => { + Object.defineProperty(window, 'visualViewport', { + configurable: true, + value: { + width: 320, + height: 200, + offsetTop: 0, + }, + }); + + renderComponent(); + + const trigger = await screen.findByTitle('Host actions'); + vi.spyOn(trigger, 'getBoundingClientRect').mockReturnValue({ + x: 302, + y: 180, + top: 180, + left: 302, + bottom: 196, + right: 318, + width: 16, + height: 16, + toJSON: () => ({}), + } as DOMRect); + + fireEvent.click(trigger); + + const removeButton = await screen.findByRole('button', { name: /remove host from pulse/i }); + const menu = removeButton.closest('[data-host-actions-menu]') as HTMLElement; + + expect(menu).toBeInTheDocument(); + expect(menu.style.top).toBe('94px'); + expect(menu.style.left).toBe('112px'); + }); +}); diff --git a/frontend-modern/src/components/Hosts/__tests__/permissions.test.ts b/frontend-modern/src/components/Hosts/__tests__/permissions.test.ts new file mode 100644 index 000000000..35dffc02a --- /dev/null +++ b/frontend-modern/src/components/Hosts/__tests__/permissions.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { hasSettingsWriteAccess } from '@/components/Hosts/permissions'; + +describe('hasSettingsWriteAccess', () => { + it('allows session-based auth with no scoped token', () => { + expect(hasSettingsWriteAccess(undefined)).toBe(true); + expect(hasSettingsWriteAccess([])).toBe(true); + }); + + it('allows wildcard-scoped tokens', () => { + expect(hasSettingsWriteAccess(['*'])).toBe(true); + expect(hasSettingsWriteAccess(['monitoring:read', '*'])).toBe(true); + }); + + it('allows tokens with explicit settings:write scope', () => { + expect(hasSettingsWriteAccess(['settings:write'])).toBe(true); + expect(hasSettingsWriteAccess(['monitoring:read', 'settings:write'])).toBe(true); + }); + + it('denies tokens without settings:write', () => { + expect(hasSettingsWriteAccess(['monitoring:read'])).toBe(false); + expect(hasSettingsWriteAccess(['settings:read'])).toBe(false); + expect(hasSettingsWriteAccess(['monitoring:read', 'host-agent:report'])).toBe(false); + }); +}); diff --git a/frontend-modern/src/components/Hosts/permissions.ts b/frontend-modern/src/components/Hosts/permissions.ts new file mode 100644 index 000000000..baafd184e --- /dev/null +++ b/frontend-modern/src/components/Hosts/permissions.ts @@ -0,0 +1,9 @@ +import { SETTINGS_WRITE_SCOPE } from '@/constants/apiScopes'; + +export const hasSettingsWriteAccess = (tokenScopes?: string[]): boolean => { + if (!tokenScopes || tokenScopes.length === 0) { + return true; + } + + return tokenScopes.includes('*') || tokenScopes.includes(SETTINGS_WRITE_SCOPE); +}; diff --git a/internal/api/security_regression_test.go b/internal/api/security_regression_test.go index 51167b588..3c5ad3af6 100644 --- a/internal/api/security_regression_test.go +++ b/internal/api/security_regression_test.go @@ -510,6 +510,37 @@ func TestHostAgentManagementRequiresSettingsWriteScope(t *testing.T) { } } +func TestHostAgentDeleteMissingSettingsWriteErrorContract(t *testing.T) { + rawToken := "host-delete-token-123.12345678" + record := newTokenRecord(t, rawToken, []string{config.ScopeHostManage}, nil) + cfg := newTestConfigWithTokens(t, record) + router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0") + + req := httptest.NewRequest(http.MethodDelete, "/api/agents/host/agent-1", nil) + req.Header.Set("X-API-Token", rawToken) + rec := httptest.NewRecorder() + router.Handler().ServeHTTP(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("expected 403 for missing settings:write scope, got %d", rec.Code) + } + + var body struct { + Error string `json:"error"` + RequiredScope string `json:"requiredScope"` + } + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatalf("expected JSON error contract, decode failed: %v", err) + } + + if body.Error != "missing_scope" { + t.Fatalf("expected error code %q, got %q", "missing_scope", body.Error) + } + if body.RequiredScope != config.ScopeSettingsWrite { + t.Fatalf("expected required scope %q, got %q", config.ScopeSettingsWrite, body.RequiredScope) + } +} + func TestTestNotificationRequiresSettingsWriteScope(t *testing.T) { rawToken := "notify-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil)