diff --git a/src/features/admin/__tests__/adminRoutes.test.ts b/src/features/admin/__tests__/adminRoutes.test.ts index f37a87295..599e54c64 100644 --- a/src/features/admin/__tests__/adminRoutes.test.ts +++ b/src/features/admin/__tests__/adminRoutes.test.ts @@ -26,6 +26,7 @@ describe('admin section routes', () => { it.each([ ['/admin/notifications', '/notifications'], ['/admin/regions', '/regions'], + ['/admin/hosts', '/hosts'], ['/admin/api-token', '/api-token'], ])('%s resolves to its own route', (path, expectedId) => { expect(matchLeaf(path).routeId).toContain(expectedId); diff --git a/src/features/admin/__tests__/adminShellVisibility.test.ts b/src/features/admin/__tests__/adminShellVisibility.test.ts index 6a2df4813..8128ca2f2 100644 --- a/src/features/admin/__tests__/adminShellVisibility.test.ts +++ b/src/features/admin/__tests__/adminShellVisibility.test.ts @@ -10,9 +10,9 @@ const user = (fabricRole: User['fabricRole'], staffPermissions?: string[]) => const labels = (u: User | null) => visibleAdminItems(u).map((item) => item.label); describe('admin section visibility', () => { - it('shows every page to a holder of all three page permissions', () => { - const admin = user('fabric_admin', ['systemStatus:write', 'region:read', 'apiToken:create']); - expect(labels(admin)).toEqual(['Notifications', 'Regions', 'API Token']); + it('shows every page to a holder of all four page permissions', () => { + const admin = user('fabric_admin', ['systemStatus:write', 'region:read', 'host:read', 'apiToken:create']); + expect(labels(admin)).toEqual(['Notifications', 'Regions', 'Hosts', 'API Token']); }); it('shows only the pages a narrower role holds', () => { @@ -21,6 +21,7 @@ describe('admin section visibility', () => { 'Regions', ]); expect(labels(user('fabric_readonly', ['region:read']))).toEqual(['Regions']); + expect(labels(user('fabric_readonly', ['region:read', 'host:read']))).toEqual(['Regions', 'Hosts']); }); it('hides the section entirely from customers', () => { @@ -31,13 +32,13 @@ describe('admin section visibility', () => { // super_user may password-login; without a Google SSO session the token mint // 403s, so the API Token page stays hidden even though it holds the permission. it('never shows the API Token page to super_user', () => { - const withField = user('super_user', ['systemStatus:write', 'region:read', 'apiToken:create']); - expect(labels(withField)).toEqual(['Notifications', 'Regions']); + const withField = user('super_user', ['systemStatus:write', 'region:read', 'host:read', 'apiToken:create']); + expect(labels(withField)).toEqual(['Notifications', 'Regions', 'Hosts']); // Legacy API without staffPermissions: same carve-out via the fallback. - expect(labels(user('super_user'))).toEqual(['Notifications', 'Regions']); + expect(labels(user('super_user'))).toEqual(['Notifications', 'Regions', 'Hosts']); }); it('keeps the whole section for a legacy-API fabric_admin', () => { - expect(labels(user('fabric_admin'))).toEqual(['Notifications', 'Regions', 'API Token']); + expect(labels(user('fabric_admin'))).toEqual(['Notifications', 'Regions', 'Hosts', 'API Token']); }); }); diff --git a/src/features/admin/components/AdminShell.tsx b/src/features/admin/components/AdminShell.tsx index 8b3547f68..f80180801 100644 --- a/src/features/admin/components/AdminShell.tsx +++ b/src/features/admin/components/AdminShell.tsx @@ -2,7 +2,7 @@ import { SubNavItem, SubNavRail } from '@/components/SubNavRail'; import { hasStaffPermission, useCloudAuth } from '@/hooks/useAuth'; import { LocalUser, StaffPermission, User } from '@/integrations/api/api.patch'; import { Navigate, Outlet, useLocation } from '@tanstack/react-router'; -import { BellIcon, GlobeIcon, KeyRoundIcon } from 'lucide-react'; +import { BellIcon, GlobeIcon, KeyRoundIcon, ServerIcon } from 'lucide-react'; /** * Shell for the Admin section: a responsive sub-nav rail (so future admin @@ -16,6 +16,7 @@ import { BellIcon, GlobeIcon, KeyRoundIcon } from 'lucide-react'; const items: Array = [ { to: '/admin/notifications', label: 'Notifications', icon: BellIcon, permission: 'systemStatus:write' }, { to: '/admin/regions', label: 'Regions', icon: GlobeIcon, permission: 'region:read' }, + { to: '/admin/hosts', label: 'Hosts', icon: ServerIcon, permission: 'host:read' }, // Minting a token requires the Google SSO session only staff sign-ins have; // super_user may password-login, so the mint would 403 for it. { diff --git a/src/features/admin/hosts/index.test.tsx b/src/features/admin/hosts/index.test.tsx new file mode 100644 index 000000000..97223a369 --- /dev/null +++ b/src/features/admin/hosts/index.test.tsx @@ -0,0 +1,252 @@ +/** + * @vitest-environment jsdom + */ +import { apiClient } from '@/config/apiClient'; +import { + describeUnresolvedOrganization, + hostMatchesSearch, + HostsIndex, + resolveOrganization, +} from '@/features/admin/hosts/index'; +import type { AdminHost } from '@/integrations/api/api.patch'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@/config/apiClient', () => ({ apiClient: { get: vi.fn() } })); +const mockedGet = vi.mocked(apiClient.get); + +const auth = vi.hoisted(() => ({ canReadOrganizations: true })); +vi.mock('@/hooks/useAuth', () => ({ + useStaffPermission: (permission: string) => permission !== 'org:read' || auth.canReadOrganizations, +})); + +function host(overrides: Partial): AdminHost { + return { + id: 'h-1', + name: 'host-1', + status: 'RUNNING', + fqdn: null, + ipAddress: null, + cloudInstanceType: null, + cloudInstanceId: null, + locationId: 'linode-us-ord', + location: 'us-ord', + locationName: 'Chicago, Illinois, USA', + cloudProvider: 'linode', + regions: ['US'], + organizationIds: null, + dynamicallyAssignable: null, + underMaintenance: null, + gpu: null, + gpuType: null, + gpuCount: null, + embedHub: null, + canaryHost: null, + maxCPUCores: 8, + maxMemoryMb: 16384, + maxStorageGb: 100, + usedCPUCores: 2, + usedMemoryMb: 4096, + usedStorageGb: 25, + instanceCount: 0, + organizations: [], + createdAt: null, + updated: null, + terminatedAt: null, + ...overrides, + }; +} + +const ORGS = [{ id: 'org-1', name: 'Acme' }, { id: 'org-2', name: 'Globex' }, { id: 'org-3', name: 'Acme' }]; +const HOSTS = [ + host({ + id: 'h-shared', + name: 'shared-1', + ipAddress: '10.0.0.7', + instanceCount: 3, + organizations: [{ id: 'org-1', instanceCount: 3 }], + }), + host({ + id: 'h-dedicated', + name: 'dedicated-1', + organizationIds: ['org-2'], + gpu: true, + gpuCount: 1, + gpuType: 'nvidia-l4', + }), +]; + +const requests = (prefix: string) => + mockedGet.mock.calls.map(([url]) => url as string).filter((url) => url.startsWith(prefix)); +const hostRequests = () => requests('/Admin/Host/'); + +let failHosts = false; +let organizationsResponse: () => Promise; + +beforeEach(() => { + auth.canReadOrganizations = true; + failHosts = false; + organizationsResponse = async () => ({ data: ORGS }); + mockedGet.mockReset(); + mockedGet.mockImplementation(async (url) => { + const path = url as string; + if (path.startsWith('/Admin/Organization/')) { return organizationsResponse() as never; } + if (path.startsWith('/Location/')) { return { data: [] } as never; } + if (failHosts) { throw new Error('boom'); } + return { data: HOSTS } as never; + }); +}); +afterEach(cleanup); + +function renderPage() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + , + ); +} + +describe('HostsIndex', () => { + it('asks for RUNNING hosts by default', async () => { + renderPage(); + await screen.findByText('shared-1'); + expect(hostRequests()).toEqual(['/Admin/Host/?status=RUNNING']); + }); + + it('labels tenant and dedicated organizations by name', async () => { + renderPage(); + await waitFor(() => expect(screen.getByText('org-1 (Acme)')).toBeTruthy()); + expect(screen.getByText('Dedicated to org-2 (Globex)')).toBeTruthy(); + expect(screen.getByText('GPU 1× nvidia-l4')).toBeTruthy(); + }); + + it('narrows the rows client-side without another request', async () => { + renderPage(); + await screen.findByText('shared-1'); + await act(async () => { + fireEvent.change(screen.getByLabelText('Search hosts'), { target: { value: '10.0.0.7' } }); + }); + expect(screen.queryByText('dedicated-1')).toBeNull(); + expect(screen.getByText('1 of 2 hosts')).toBeTruthy(); + expect(hostRequests()).toHaveLength(1); + }); + + it('sends the picked organization to the server', async () => { + renderPage(); + await screen.findByText('shared-1'); + await act(async () => { + fireEvent.change(screen.getByLabelText('Organization'), { target: { value: 'org-1 (Acme)' } }); + }); + await waitFor(() => expect(hostRequests()).toContain('/Admin/Host/?status=RUNNING&organizationId=org-1')); + }); + + async function typeOrganization(value: string) { + await screen.findByText('shared-1'); + await act(async () => { + fireEvent.change(screen.getByLabelText('Organization'), { target: { value } }); + }); + } + + it('never sends text that names no organization', async () => { + renderPage(); + await typeOrganization('Ac'); + expect(await screen.findByText('No organization matches “Ac”.')).toBeTruthy(); + expect(hostRequests()).toEqual(['/Admin/Host/?status=RUNNING']); + }); + + it('asks which organization when a name is shared', async () => { + renderPage(); + await typeOrganization('acme'); + expect(await screen.findByText('2 organizations are named “acme” — pick one from the list.')).toBeTruthy(); + expect(hostRequests()).toEqual(['/Admin/Host/?status=RUNNING']); + }); + + it('skips the directory without org:read but still accepts a pasted id', async () => { + auth.canReadOrganizations = false; + renderPage(); + await typeOrganization('org-2'); + await waitFor(() => expect(hostRequests()).toContain('/Admin/Host/?status=RUNNING&organizationId=org-2')); + expect(requests('/Admin/Organization/')).toEqual([]); + + await act(async () => { + fireEvent.change(screen.getByLabelText('Organization'), { target: { value: 'Globex' } }); + }); + expect(await screen.findByText('Enter an organization id (org-…).')).toBeTruthy(); + }); + + // A name the directory hasn't listed yet is not a name that matches nothing. + it('says the organization list is loading rather than that nothing matches', async () => { + organizationsResponse = () => new Promise(() => {}); + renderPage(); + await typeOrganization('Globex'); + expect(await screen.findByText('The organization list is still loading. Paste an org- id to filter now.')) + .toBeTruthy(); + expect(screen.queryByText(/No organization matches/)).toBeNull(); + expect(hostRequests()).toEqual(['/Admin/Host/?status=RUNNING']); + + await act(async () => { + fireEvent.change(screen.getByLabelText('Organization'), { target: { value: 'org-2' } }); + }); + await waitFor(() => expect(hostRequests()).toContain('/Admin/Host/?status=RUNNING&organizationId=org-2')); + }); + + it('says the organization list failed when it cannot load', async () => { + organizationsResponse = async () => { + throw new Error('boom'); + }; + renderPage(); + await typeOrganization('Globex'); + expect(await screen.findByText("Couldn't load the organization list. Paste an org- id to filter.")).toBeTruthy(); + }); + + it('says so when the host list fails to load', async () => { + failHosts = true; + renderPage(); + expect(await screen.findByText("Couldn't load hosts.")).toBeTruthy(); + }); +}); + +describe('describeUnresolvedOrganization', () => { + it('claims no match only for a fully loaded directory', () => { + expect(describeUnresolvedOrganization('Acme', 'complete')).toBe('No organization matches “Acme”.'); + for (const state of ['loading', 'failed', 'truncated', 'unavailable'] as const) { + expect(describeUnresolvedOrganization('Acme', state)).not.toMatch(/No organization matches/); + expect(describeUnresolvedOrganization('Acme', state)).toMatch(/org-/); + } + }); +}); + +describe('resolveOrganization', () => { + it('maps a picked label, an exact id, or a unique name to the id', () => { + expect(resolveOrganization('org-1 (Acme)', ORGS)).toEqual({ id: 'org-1' }); + expect(resolveOrganization(' org-2 ', ORGS)).toEqual({ id: 'org-2' }); + expect(resolveOrganization('globex', ORGS)).toEqual({ id: 'org-2' }); + }); + + // Names repeat across tenants; picking the first match would show the wrong tenant's fleet. + it('refuses a name more than one organization shares', () => { + expect(resolveOrganization('Acme', ORGS)).toEqual({ ambiguous: 2 }); + }); + + it('accepts a pasted id the directory does not list, and nothing else', () => { + expect(resolveOrganization('org-pasted9', ORGS)).toEqual({ id: 'org-pasted9' }); + expect(resolveOrganization('Ac', ORGS)).toEqual({ unresolved: true }); + expect(resolveOrganization('Org-Chart', [])).toEqual({ unresolved: true }); + }); + + it('treats blank as no filter', () => { + expect(resolveOrganization(' ', ORGS)).toBeNull(); + }); +}); + +describe('hostMatchesSearch', () => { + it('matches name, id, FQDN, IP and cloud instance id, case-insensitively', () => { + const target = host({ name: 'Edge-1', fqdn: 'edge-1.example.net', ipAddress: '10.1.2.3', cloudInstanceId: '987' }); + for (const query of ['edge-1', 'H-1', 'example.net', '10.1.2', '987', '']) { + expect(hostMatchesSearch(target, query)).toBe(true); + } + expect(hostMatchesSearch(target, 'nope')).toBe(false); + }); +}); diff --git a/src/features/admin/hosts/index.tsx b/src/features/admin/hosts/index.tsx new file mode 100644 index 000000000..cda7456ff --- /dev/null +++ b/src/features/admin/hosts/index.tsx @@ -0,0 +1,367 @@ +import { Badge } from '@/components/ui/badge'; +import { Input } from '@/components/ui/input'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { + AdminHostFilters, + ALL_STATUSES, + getHostsQueryOptions, + HostStatusFilter, + RUNNING_OR_DEGRADED, +} from '@/features/admin/hosts/queries/getHosts'; +import { getLocationsQueryOptions } from '@/features/admin/regions/queries/getLocations'; +import { + AdminOrganizationSummary, + formatOrgLabel, + getOrganizationsQueryOptions, +} from '@/features/admin/regions/queries/getOrganizations'; +import { useStaffPermission } from '@/hooks/useAuth'; +import { useDebounce } from '@/hooks/useDebounce'; +import { AdminHost, HostStatus } from '@/integrations/api/api.patch'; +import { detectEntityId } from '@/lib/string/entityId'; +import { useQuery } from '@tanstack/react-query'; +import { useMemo, useState } from 'react'; + +const ANY = 'any'; +const STATUS_OPTIONS: Array<{ value: HostStatusFilter; label: string }> = [ + { value: 'RUNNING', label: 'Running' }, + { value: RUNNING_OR_DEGRADED, label: 'Running + degraded' }, + { value: 'DEGRADED', label: 'Degraded' }, + { value: 'PROVISIONING', label: 'Provisioning' }, + { value: 'REMOVING', label: 'Removing' }, + { value: 'REMOVED', label: 'Removed' }, + { value: 'TERMINATED', label: 'Terminated' }, + { value: ALL_STATUSES, label: 'All statuses' }, +]; +const STATUS_BADGE: Record = { + RUNNING: 'success', + DEGRADED: 'warning', + PROVISIONING: 'secondary', + REMOVING: 'secondary', + REMOVED: 'outline', + TERMINATED: 'outline', +}; + +export type OrganizationPick = { id: string } | { ambiguous: number } | { unresolved: true }; + +/** + * Names are not unique, so a bare name resolves only when one organization has it. A pasted id is accepted + * without the directory, which accounts without org:read never load. + */ +export function resolveOrganization(text: string, organizations: AdminOrganizationSummary[]): OrganizationPick | null { + const trimmed = text.trim(); + if (!trimmed) { return null; } + const exact = organizations.find((org) => org.id === trimmed || formatOrgLabel(org.id, org.name) === trimmed); + if (exact) { return { id: exact.id }; } + const lower = trimmed.toLowerCase(); + const named = organizations.filter((org) => org.name?.toLowerCase() === lower); + if (named.length === 1) { return { id: named[0].id }; } + if (named.length > 1) { return { ambiguous: named.length }; } + const pasted = detectEntityId(trimmed); + return pasted?.kind === 'organization' ? { id: pasted.id } : { unresolved: true }; +} + +export type DirectoryState = 'complete' | 'loading' | 'failed' | 'truncated' | 'unavailable'; + +/** "No match" is only true of a directory that fully loaded; otherwise a pasted id is the way through. */ +export function describeUnresolvedOrganization(text: string, directory: DirectoryState): string { + switch (directory) { + case 'complete': + return `No organization matches “${text}”.`; + case 'loading': + return 'The organization list is still loading. Paste an org- id to filter now.'; + case 'failed': + return "Couldn't load the organization list. Paste an org- id to filter."; + case 'truncated': + return `“${text}” isn't among the organizations loaded. Paste its org- id.`; + case 'unavailable': + return 'Enter an organization id (org-…).'; + } +} + +export function hostMatchesSearch(host: AdminHost, search: string): boolean { + const query = search.trim().toLowerCase(); + if (!query) { return true; } + return [host.name, host.id, host.fqdn, host.ipAddress, host.cloudInstanceId].some((value) => + value?.toLowerCase().includes(query) + ); +} + +function formatUsage(used: number | null, max: number | null, divisor = 1): string { + if (max == null) { return '—'; } + const format = (value: number) => Number((value / divisor).toFixed(1)).toString(); + return `${used == null ? '?' : format(used)} / ${format(max)}`; +} + +function HostOrganizations( + { host, orgNameById }: { host: AdminHost; orgNameById: Map }, +) { + const dedicated = host.organizationIds ?? []; + if (dedicated.length === 0 && host.organizations.length === 0) { + return —; + } + return ( +
+ {dedicated.length > 0 && ( + + Dedicated to {dedicated.map((id) => formatOrgLabel(id, orgNameById.get(id))).join(', ')} + + )} + {host.organizations.map((org) => ( + + {formatOrgLabel(org.id, orgNameById.get(org.id))} + {` · ${org.instanceCount}`} + + ))} +
+ ); +} + +function HostFlags({ host }: { host: AdminHost }) { + const flags: string[] = []; + if (host.gpu) { + flags.push(['GPU', host.gpuCount && `${host.gpuCount}×`, host.gpuType].filter(Boolean).join(' ')); + } + if ((host.organizationIds ?? []).length > 0) { flags.push('Dedicated'); } + if (host.underMaintenance) { flags.push('Maintenance'); } + if (host.embedHub) { flags.push('Embed hub'); } + if (host.canaryHost) { flags.push('Canary'); } + if (flags.length === 0) { return null; } + return ( + + {flags.map((flag) => {flag})} + + ); +} + +export function HostsIndex() { + const [status, setStatus] = useState('RUNNING'); + const [organizationText, setOrganizationText] = useState(''); + const [cloudProvider, setCloudProvider] = useState(ANY); + const [locationId, setLocationId] = useState(ANY); + const [region, setRegion] = useState(ANY); + const [search, setSearch] = useState(''); + const debouncedOrganizationText = useDebounce(organizationText, 300); + + // The directory endpoint needs org:read, which this page's host:read does not imply. + const canReadOrganizations = useStaffPermission('org:read'); + const { data: orgResult, isError: organizationsFailed } = useQuery({ + ...getOrganizationsQueryOptions(), + enabled: canReadOrganizations, + }); + const directory: DirectoryState = !canReadOrganizations + ? 'unavailable' + : organizationsFailed + ? 'failed' + : !orgResult + ? 'loading' + : orgResult.truncated + ? 'truncated' + : 'complete'; + const { data: locations } = useQuery(getLocationsQueryOptions()); + const organizations = useMemo(() => orgResult?.organizations ?? [], [orgResult]); + const orgNameById = useMemo(() => new Map(organizations.map((o) => [o.id, o.name])), [organizations]); + const organizationOptions = useMemo( + () => organizations.map((org) =>