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.
This commit is contained in:
rcourtman 2026-03-01 23:28:33 +00:00
parent 5bd0563283
commit d43dfbc490
6 changed files with 580 additions and 1 deletions

View file

@ -0,0 +1,31 @@
import { Component } from 'solid-js';
interface HostRemoveActionButtonProps {
onClick: () => void;
disabled?: boolean;
loading?: boolean;
class?: string;
}
export const HostRemoveActionButton: Component<HostRemoveActionButtonProps> = (props) => {
return (
<button
type="button"
onClick={props.onClick}
disabled={props.disabled}
class={`flex w-full items-center gap-2 rounded-md px-2 py-2 text-left text-sm text-red-600 transition-colors hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-60 dark:text-red-300 dark:hover:bg-red-900/20 ${props.class ?? ''}`.trim()}
>
{props.loading ? (
<svg class="h-3.5 w-3.5 animate-spin" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke-width="3" />
<path class="opacity-75" stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M4 12a8 8 0 018-8" />
</svg>
) : (
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6M9 7V4a1 1 0 011-1h4a1 1 0 011 1v3m-7 0h8" />
</svg>
)}
<span>{props.loading ? 'Removing...' : 'Remove host from Pulse'}</span>
</button>
);
};

View file

@ -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<Record<string, HostMetadata>>({});
const [hostMetadataVersion, setHostMetadataVersion] = createSignal(0);
const [canRemoveHostAgents, setCanRemoveHostAgents] = createSignal(false);
const [removingHostIds, setRemovingHostIds] = createSignal<Record<string, boolean>>({});
const [actionsMenuHostId, setActionsMenuHostId] = createSignal<string | null>(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<void> => {
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 = () => {
<th class={thClass} title="Linux Software RAID (mdadm) Status">RAID</th>
</Show>
<Show when={isColVisible('link')}>
<th class={thClass}></th>
<th class={thClass} style={{ "width": isMobile() ? "64px" : "84px" }}></th>
</Show>
</tr>
</thead>
@ -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)}
/>
)}
</For>
@ -1113,6 +1275,31 @@ export const HostsOverview: Component = () => {
</Card>
</Show>
</Show>
<Show when={menuHost() && actionsMenuPosition() && canRemoveHostAgents()}>
<Portal mount={document.body}>
<div
data-host-actions-menu
class="fixed z-[9999] w-[200px] rounded-lg border border-gray-200 bg-white p-1.5 shadow-xl dark:border-gray-700 dark:bg-gray-800"
style={{
top: `${actionsMenuPosition()!.top}px`,
left: `${actionsMenuPosition()!.left}px`,
}}
>
<HostRemoveActionButton
loading={Boolean(menuHost() && removingHostIds()[menuHost()!.id])}
disabled={Boolean(menuHost() && removingHostIds()[menuHost()!.id])}
onClick={() => {
const host = menuHost();
if (!host) {
return;
}
void handleRemoveHostAgent(host);
}}
/>
</div>
</Portal>
</Show>
</div >
);
};
@ -1129,6 +1316,11 @@ interface HostRowProps {
isExpanded: boolean;
onToggleExpand: () => void;
totalColumns: number;
canRemoveHost: boolean;
isRemovingHost: boolean;
onRemoveHost: (host: Host) => Promise<void>;
isActionsMenuOpen: boolean;
onToggleActionsMenu: (event: MouseEvent) => void;
}
const HostRow: Component<HostRowProps> = (props) => {
@ -1413,6 +1605,19 @@ const HostRow: Component<HostRowProps> = (props) => {
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
</button>
<Show when={props.canRemoveHost}>
<button
type="button"
data-host-actions-trigger
onClick={props.onToggleActionsMenu}
class={`inline-flex justify-center items-center text-gray-400 transition-colors ${props.isActionsMenuOpen ? 'text-gray-700 dark:text-gray-200' : 'hover:text-gray-600 dark:hover:text-gray-300'}`}
title="Host actions"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 5h.01M12 12h.01M12 19h.01" />
</svg>
</button>
</Show>
</div>
</td>
</Show>

View file

@ -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) => <div>{props.children}</div>,
}));
vi.mock('@/components/shared/EmptyState', () => ({
EmptyState: (props: { title?: string; description?: string }) => (
<div>
<div>{props.title}</div>
<div>{props.description}</div>
</div>
),
}));
vi.mock('@/components/shared/ScrollableTable', () => ({
ScrollableTable: (props: any) => <div>{props.children}</div>,
}));
vi.mock('@/components/Hosts/HostsFilter', () => ({
HostsFilter: () => <div data-testid="hosts-filter" />,
}));
vi.mock('@/components/Hosts/HostDrawer', () => ({
HostDrawer: () => <div data-testid="host-drawer" />,
}));
vi.mock('@/components/shared/StatusDot', () => ({
StatusDot: () => <span data-testid="status-dot" />,
}));
vi.mock('@/components/Dashboard/EnhancedCPUBar', () => ({
EnhancedCPUBar: () => <div data-testid="cpu-bar" />,
}));
vi.mock('@/components/Dashboard/StackedMemoryBar', () => ({
StackedMemoryBar: () => <div data-testid="memory-bar" />,
}));
vi.mock('@/components/Dashboard/StackedDiskBar', () => ({
StackedDiskBar: () => <div data-testid="disk-bar" />,
}));
const createHost = (overrides: Partial<Host> = {}): 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(() => (
<Router>
<Route path="/" component={() => <HostsOverview />} />
</Router>
));
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');
});
});

View file

@ -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);
});
});

View file

@ -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);
};

View file

@ -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)