From e04fd3d5356ee289a2028477aa6beaa389f282e9 Mon Sep 17 00:00:00 2001
From: Devin Holland <50112339+Devin-Holland@users.noreply.github.com>
Date: Thu, 22 Oct 2026 09:18:27 -0400
Subject: [PATCH 1/3] feat(admin): tidy the Hosts table and move per-host
detail into a panel
The Organizations column lists only the organizations a host is dedicated to
(or "Shared"); the organizations with instances on a host, with their counts,
move to a details panel that opens from the row. Locations show the location
code and provider, cells get spacing, CPU reads as the share of cores busy in
the last heartbeat sample, and the -1 central-manager stores for an unreported
value shows as unknown.
Co-Authored-By: Claude Opus 5.5
---
src/features/admin/hosts/index.test.tsx | 74 +++++++++-
src/features/admin/hosts/index.tsx | 175 +++++++++++++++++++-----
2 files changed, 207 insertions(+), 42 deletions(-)
diff --git a/src/features/admin/hosts/index.test.tsx b/src/features/admin/hosts/index.test.tsx
index 97223a369..3cb16f1f8 100644
--- a/src/features/admin/hosts/index.test.tsx
+++ b/src/features/admin/hosts/index.test.tsx
@@ -4,14 +4,16 @@
import { apiClient } from '@/config/apiClient';
import {
describeUnresolvedOrganization,
+ formatCpu,
+ formatUsage,
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';
+import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@/config/apiClient', () => ({ apiClient: { get: vi.fn() } }));
const mockedGet = vi.mocked(apiClient.get);
@@ -99,6 +101,14 @@ beforeEach(() => {
});
afterEach(cleanup);
+beforeAll(() => {
+ // Radix Dialog relies on DOM APIs jsdom doesn't implement.
+ Element.prototype.hasPointerCapture ??= () => false;
+ Element.prototype.setPointerCapture ??= () => undefined;
+ Element.prototype.releasePointerCapture ??= () => undefined;
+ Element.prototype.scrollIntoView ??= () => undefined;
+});
+
function renderPage() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
@@ -115,13 +125,52 @@ describe('HostsIndex', () => {
expect(hostRequests()).toEqual(['/Admin/Host/?status=RUNNING']);
});
- it('labels tenant and dedicated organizations by name', async () => {
+ it('lists only the organizations a host is dedicated to, by name', async () => {
renderPage();
- await waitFor(() => expect(screen.getByText('org-1 (Acme)')).toBeTruthy());
- expect(screen.getByText('Dedicated to org-2 (Globex)')).toBeTruthy();
+ await waitFor(() => expect(screen.getByText('org-2 (Globex)')).toBeTruthy());
+ expect(screen.getByText('Shared')).toBeTruthy();
+ // org-1 only has instances on the shared host.
+ expect(screen.queryByText('org-1 (Acme)')).toBeNull();
expect(screen.getByText('GPU 1× nvidia-l4')).toBeTruthy();
});
+ it("opens a host's details from its row, with the organizations that have instances on it", async () => {
+ renderPage();
+ await screen.findByText('shared-1');
+ expect(screen.queryByRole('dialog')).toBeNull();
+ await act(async () => {
+ fireEvent.click(screen.getByText('h-shared'));
+ });
+ const panel = screen.getByRole('dialog');
+ expect(await within(panel).findByText('org-1 (Acme)')).toBeTruthy();
+ expect(within(panel).getByText('3 instances')).toBeTruthy();
+ expect(within(panel).getByText('10.0.0.7')).toBeTruthy();
+ });
+
+ it('opens the details from the host name for keyboard users', async () => {
+ renderPage();
+ await act(async () => {
+ fireEvent.click(await screen.findByRole('button', { name: 'dedicated-1' }));
+ });
+ expect(within(screen.getByRole('dialog')).getByText('None.')).toBeTruthy();
+ });
+
+ it('shows the location code and provider, and a host id only when it differs from the name', async () => {
+ mockedGet.mockImplementation(async (url) => {
+ const path = url as string;
+ if (path.startsWith('/Admin/Organization/')) { return { data: ORGS } as never; }
+ if (path.startsWith('/Location/')) { return { data: [] } as never; }
+ return { data: [...HOSTS, host({ id: 'hos-same', name: 'hos-same', fqdn: 'hos-same.example.com' })] } as never;
+ });
+ renderPage();
+ await screen.findByText('shared-1');
+ expect(screen.getAllByText('us-ord · linode')).toHaveLength(3);
+ expect(screen.queryByText('Chicago, Illinois, USA')).toBeNull();
+ expect(screen.getByText('h-shared')).toBeTruthy();
+ expect(screen.getAllByText('hos-same')).toHaveLength(1);
+ expect(screen.getByText('hos-same.example.com')).toBeTruthy();
+ });
+
it('narrows the rows client-side without another request', async () => {
renderPage();
await screen.findByText('shared-1');
@@ -208,6 +257,21 @@ describe('HostsIndex', () => {
});
});
+describe('formatCpu / formatUsage', () => {
+ it('shows sampled CPU as a share of the host cores', () => {
+ expect(formatCpu(2, 8)).toBe('25% of 8 cores');
+ expect(formatCpu(0.1, 4)).toBe('3% of 4 cores');
+ expect(formatCpu(null, 4)).toBe('? of 4 cores');
+ expect(formatCpu(1, null)).toBe('—');
+ });
+
+ it('treats the -1 a host leaves for an unreported value as unknown', () => {
+ expect(formatCpu(-1, 4)).toBe('? of 4 cores');
+ expect(formatUsage(-1, 16384, 1024)).toBe('? / 16');
+ expect(formatUsage(8499.2, 16384, 1024)).toBe('8.3 / 16');
+ });
+});
+
describe('describeUnresolvedOrganization', () => {
it('claims no match only for a fully loaded directory', () => {
expect(describeUnresolvedOrganization('Acme', 'complete')).toBe('No organization matches “Acme”.');
diff --git a/src/features/admin/hosts/index.tsx b/src/features/admin/hosts/index.tsx
index cda7456ff..eb5bfb3b1 100644
--- a/src/features/admin/hosts/index.tsx
+++ b/src/features/admin/hosts/index.tsx
@@ -1,6 +1,7 @@
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import {
AdminHostFilters,
@@ -20,7 +21,7 @@ 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';
+import { ReactNode, useMemo, useState } from 'react';
const ANY = 'any';
const STATUS_OPTIONS: Array<{ value: HostStatusFilter; label: string }> = [
@@ -87,45 +88,50 @@ export function hostMatchesSearch(host: AdminHost, search: string): boolean {
);
}
-function formatUsage(used: number | null, max: number | null, divisor = 1): string {
+// Central-manager stores -1 when a host's report left the value out.
+function reported(value: number | null): number | null {
+ return value != null && value >= 0 ? value : null;
+}
+
+export 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)}`;
+ const value = reported(used);
+ return `${value == null ? '?' : format(value)} / ${format(max)}`;
+}
+
+/** `usedCPUCores` is the cores busy during the host's last 100 ms sample, not an allocation. */
+export function formatCpu(used: number | null, max: number | null): string {
+ if (max == null || max <= 0) { return '—'; }
+ const value = reported(used);
+ return `${value == null ? '?' : `${Math.round((value / max) * 100)}%`} of ${max} cores`;
}
function HostOrganizations(
{ host, orgNameById }: { host: AdminHost; orgNameById: Map },
) {
const dedicated = host.organizationIds ?? [];
- if (dedicated.length === 0 && host.organizations.length === 0) {
- return — ;
- }
+ if (dedicated.length === 0) { return Shared ; }
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}`}
-
- ))}
+ {dedicated.map((id) => {formatOrgLabel(id, orgNameById.get(id))} )}
);
}
-function HostFlags({ host }: { host: AdminHost }) {
+function hostFlags(host: AdminHost): string[] {
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'); }
+ return flags;
+}
+
+function HostFlags({ host }: { host: AdminHost }) {
+ const flags = hostFlags(host);
if (flags.length === 0) { return null; }
return (
@@ -134,6 +140,85 @@ function HostFlags({ host }: { host: AdminHost }) {
);
}
+function DetailRow({ label, children }: { label: string; children: ReactNode }) {
+ return (
+
+
{label}
+ {children}
+
+ );
+}
+
+function HostDetails(
+ { host, orgNameById, onClose }: {
+ host: AdminHost | null;
+ orgNameById: Map;
+ onClose: () => void;
+ },
+) {
+ return (
+ !open && onClose()}>
+
+ {host && (
+ <>
+
+ {host.name}
+ {host.id}
+
+
+ {host.status ?? '—'}
+ {host.fqdn ?? '—'}
+ {host.ipAddress ?? '—'}
+
+ {[host.locationName, [host.location, host.cloudProvider].filter(Boolean).join(' · ')]
+ .filter(Boolean)
+ .join(' — ') || host.locationId || '—'}
+
+ {host.regions.join(', ') || '—'}
+
+ {[host.cloudInstanceType, host.cloudInstanceId].filter(Boolean).join(' · ') || '—'}
+
+
+
+
+
+ {host.dynamicallyAssignable == null ? '—' : host.dynamicallyAssignable ? 'Yes' : 'No'}
+
+ {hostFlags(host).length > 0 && (
+
+
+
+ )}
+ {formatCpu(host.usedCPUCores, host.maxCPUCores)}
+ {formatUsage(host.usedMemoryMb, host.maxMemoryMb, 1024)}
+ {formatUsage(host.usedStorageGb, host.maxStorageGb)}
+ {host.createdAt ?? '—'}
+ {host.terminatedAt && {host.terminatedAt} }
+
+
+ Organizations with instances here
+ {host.organizations.length === 0
+ ? None.
+ : (
+
+ {host.organizations.map((org) => (
+
+ {formatOrgLabel(org.id, orgNameById.get(org.id))}
+
+ {org.instanceCount} {org.instanceCount === 1 ? 'instance' : 'instances'}
+
+
+ ))}
+
+ )}
+
+ >
+ )}
+
+
+ );
+}
+
export function HostsIndex() {
const [status, setStatus] = useState('RUNNING');
const [organizationText, setOrganizationText] = useState('');
@@ -141,6 +226,7 @@ export function HostsIndex() {
const [locationId, setLocationId] = useState(ANY);
const [region, setRegion] = useState(ANY);
const [search, setSearch] = useState('');
+ const [selectedHostId, setSelectedHostId] = useState(null);
const debouncedOrganizationText = useDebounce(organizationText, 300);
// The directory endpoint needs org:read, which this page's host:read does not imply.
@@ -187,6 +273,7 @@ export function HostsIndex() {
...getHostsQueryOptions(filters),
enabled: organizationProblem === null,
});
+ const selectedHost = hosts?.find((host) => host.id === selectedHostId) ?? null;
const visible = useMemo(
() => (search.trim() ? (hosts ?? []).filter((host) => hostMatchesSearch(host, search)) : hosts ?? []),
[hosts, search],
@@ -298,7 +385,7 @@ export function HostsIndex() {
: `${visible.length} of ${hosts?.length} hosts`}
-
+
Host
@@ -306,21 +393,40 @@ export function HostsIndex() {
Location
Organizations
Instances
- CPU
+
+ CPU
+
Memory (GB)
Disk (GB)
{visible.map((host) => (
-
+ setSelectedHostId(host.id)}
+ >
- {host.name}
-
- {host.id}
- {(host.fqdn ?? host.ipAddress) && ` · ${host.fqdn ?? host.ipAddress}`}
-
+ setSelectedHostId(host.id)}
+ >
+ {host.name}
+
+ {host.id !== host.name && (
+ {host.id}
+ )}
+ {(host.fqdn ?? host.ipAddress) && (
+
+ {host.fqdn ?? host.ipAddress}
+
+ )}
@@ -330,15 +436,8 @@ export function HostsIndex() {
: — }
- {host.locationName
- ? (
-
- {host.locationName}
-
- {[host.location, host.cloudProvider].filter(Boolean).join(' · ')}
-
-
- )
+ {host.location
+ ? [host.location, host.cloudProvider].filter(Boolean).join(' · ')
: {host.locationId ?? '—'} }
@@ -346,7 +445,7 @@ export function HostsIndex() {
{host.instanceCount}
- {formatUsage(host.usedCPUCores, host.maxCPUCores)}
+ {formatCpu(host.usedCPUCores, host.maxCPUCores)}
{formatUsage(host.usedMemoryMb, host.maxMemoryMb, 1024)}
@@ -362,6 +461,8 @@ export function HostsIndex() {
>
)}
+
+ setSelectedHostId(null)} />
);
}
From 55dbf0bbc98f962df4ea96e17b940d84d88e065e Mon Sep 17 00:00:00 2001
From: Devin Holland <50112339+Devin-Holland@users.noreply.github.com>
Date: Thu, 22 Oct 2026 09:27:01 -0400
Subject: [PATCH 2/3] fix(admin): keep the host panel on the clicked host and
return focus on close
The panel holds the clicked host, refreshed from the list when present, so a
refetch or filter change that drops it neither closes nor later reopens it.
Closing returns focus to the row's host name. A missing or -1 capacity shows
as unknown for memory and disk as it does for CPU, and the table keeps the
provider when a host has no location code.
Co-Authored-By: Claude Opus 5.5
---
src/features/admin/hosts/index.test.tsx | 39 +++++++++++++-
src/features/admin/hosts/index.tsx | 67 ++++++++++++++++++-------
2 files changed, 85 insertions(+), 21 deletions(-)
diff --git a/src/features/admin/hosts/index.test.tsx b/src/features/admin/hosts/index.test.tsx
index 3cb16f1f8..3921e3876 100644
--- a/src/features/admin/hosts/index.test.tsx
+++ b/src/features/admin/hosts/index.test.tsx
@@ -129,7 +129,6 @@ describe('HostsIndex', () => {
renderPage();
await waitFor(() => expect(screen.getByText('org-2 (Globex)')).toBeTruthy());
expect(screen.getByText('Shared')).toBeTruthy();
- // org-1 only has instances on the shared host.
expect(screen.queryByText('org-1 (Acme)')).toBeNull();
expect(screen.getByText('GPU 1× nvidia-l4')).toBeTruthy();
});
@@ -147,7 +146,7 @@ describe('HostsIndex', () => {
expect(within(panel).getByText('10.0.0.7')).toBeTruthy();
});
- it('opens the details from the host name for keyboard users', async () => {
+ it('opens the details from the host name button', async () => {
renderPage();
await act(async () => {
fireEvent.click(await screen.findByRole('button', { name: 'dedicated-1' }));
@@ -155,6 +154,38 @@ describe('HostsIndex', () => {
expect(within(screen.getByRole('dialog')).getByText('None.')).toBeTruthy();
});
+ it('keeps the panel on the clicked host while a filter change refetches without it', async () => {
+ renderPage();
+ await act(async () => {
+ fireEvent.click(await screen.findByText('h-shared'));
+ });
+ mockedGet.mockImplementation(async (url) => {
+ const path = url as string;
+ if (path.startsWith('/Admin/Organization/')) { return { data: ORGS } as never; }
+ if (path.startsWith('/Location/')) { return { data: [] } as never; }
+ return { data: HOSTS.filter((candidate) => candidate.id !== 'h-shared') } as never;
+ });
+ await act(async () => {
+ fireEvent.change(screen.getByLabelText('Organization'), { target: { value: 'org-2' } });
+ });
+ await waitFor(() => expect(hostRequests()).toContain('/Admin/Host/?status=RUNNING&organizationId=org-2'));
+ await waitFor(() => expect(screen.queryByText('dedicated-1')).toBeTruthy());
+ expect(within(screen.getByRole('dialog')).getByText('shared-1')).toBeTruthy();
+ });
+
+ it('returns focus to the host name when the details close', async () => {
+ renderPage();
+ const button = await screen.findByRole('button', { name: 'shared-1' });
+ await act(async () => {
+ fireEvent.click(button);
+ });
+ await act(async () => {
+ fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' });
+ });
+ await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
+ expect(document.activeElement).toBe(button);
+ });
+
it('shows the location code and provider, and a host id only when it differs from the name', async () => {
mockedGet.mockImplementation(async (url) => {
const path = url as string;
@@ -166,6 +197,7 @@ describe('HostsIndex', () => {
await screen.findByText('shared-1');
expect(screen.getAllByText('us-ord · linode')).toHaveLength(3);
expect(screen.queryByText('Chicago, Illinois, USA')).toBeNull();
+ expect(screen.getAllByText('25% of 8 cores')).toHaveLength(3);
expect(screen.getByText('h-shared')).toBeTruthy();
expect(screen.getAllByText('hos-same')).toHaveLength(1);
expect(screen.getByText('hos-same.example.com')).toBeTruthy();
@@ -269,6 +301,9 @@ describe('formatCpu / formatUsage', () => {
expect(formatCpu(-1, 4)).toBe('? of 4 cores');
expect(formatUsage(-1, 16384, 1024)).toBe('? / 16');
expect(formatUsage(8499.2, 16384, 1024)).toBe('8.3 / 16');
+ expect(formatUsage(5, -1)).toBe('—');
+ expect(formatUsage(5, 0)).toBe('—');
+ expect(formatCpu(1, -1)).toBe('—');
});
});
diff --git a/src/features/admin/hosts/index.tsx b/src/features/admin/hosts/index.tsx
index eb5bfb3b1..12bf541dc 100644
--- a/src/features/admin/hosts/index.tsx
+++ b/src/features/admin/hosts/index.tsx
@@ -21,7 +21,7 @@ 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 { ReactNode, useMemo, useState } from 'react';
+import { ReactNode, useMemo, useRef, useState } from 'react';
const ANY = 'any';
const STATUS_OPTIONS: Array<{ value: HostStatusFilter; label: string }> = [
@@ -93,18 +93,28 @@ function reported(value: number | null): number | null {
return value != null && value >= 0 ? value : null;
}
+function capacity(max: number | null): number | null {
+ const value = reported(max);
+ return value ? value : null;
+}
+
export function formatUsage(used: number | null, max: number | null, divisor = 1): string {
- if (max == null) { return '—'; }
+ const limit = capacity(max);
+ if (limit == null) { return '—'; }
const format = (value: number) => Number((value / divisor).toFixed(1)).toString();
const value = reported(used);
- return `${value == null ? '?' : format(value)} / ${format(max)}`;
+ return `${value == null ? '?' : format(value)} / ${format(limit)}`;
}
-/** `usedCPUCores` is the cores busy during the host's last 100 ms sample, not an allocation. */
export function formatCpu(used: number | null, max: number | null): string {
- if (max == null || max <= 0) { return '—'; }
+ const limit = capacity(max);
+ if (limit == null) { return '—'; }
const value = reported(used);
- return `${value == null ? '?' : `${Math.round((value / max) * 100)}%`} of ${max} cores`;
+ return `${value == null ? '?' : `${Math.round((value / limit) * 100)}%`} of ${limit} cores`;
+}
+
+function formatLocation(host: AdminHost): string | null {
+ return [host.location, host.cloudProvider].filter(Boolean).join(' · ') || null;
}
function HostOrganizations(
@@ -150,15 +160,16 @@ function DetailRow({ label, children }: { label: string; children: ReactNode })
}
function HostDetails(
- { host, orgNameById, onClose }: {
+ { host, orgNameById, onClose, onCloseAutoFocus }: {
host: AdminHost | null;
orgNameById: Map;
onClose: () => void;
+ onCloseAutoFocus: (event: Event) => void;
},
) {
return (
!open && onClose()}>
-
+
{host && (
<>
@@ -170,9 +181,7 @@ function HostDetails(
{host.fqdn ?? '—'}
{host.ipAddress ?? '—'}
- {[host.locationName, [host.location, host.cloudProvider].filter(Boolean).join(' · ')]
- .filter(Boolean)
- .join(' — ') || host.locationId || '—'}
+ {[host.locationName, formatLocation(host)].filter(Boolean).join(' — ') || host.locationId || '—'}
{host.regions.join(', ') || '—'}
@@ -226,7 +235,15 @@ export function HostsIndex() {
const [locationId, setLocationId] = useState(ANY);
const [region, setRegion] = useState(ANY);
const [search, setSearch] = useState('');
- const [selectedHostId, setSelectedHostId] = useState(null);
+ // The clicked row, so a refetch or filter change that drops the host cannot close or reopen the panel.
+ const [selected, setSelected] = useState(null);
+ const hostButtons = useRef(new Map());
+ // Outlives `selected`, which is already null by the time the closing sheet asks where focus goes.
+ const openedHostId = useRef(null);
+ const openHost = (host: AdminHost) => {
+ openedHostId.current = host.id;
+ setSelected(host);
+ };
const debouncedOrganizationText = useDebounce(organizationText, 300);
// The directory endpoint needs org:read, which this page's host:read does not imply.
@@ -273,7 +290,7 @@ export function HostsIndex() {
...getHostsQueryOptions(filters),
enabled: organizationProblem === null,
});
- const selectedHost = hosts?.find((host) => host.id === selectedHostId) ?? null;
+ const selectedHost = selected && (hosts?.find((host) => host.id === selected.id) ?? selected);
const visible = useMemo(
() => (search.trim() ? (hosts ?? []).filter((host) => hostMatchesSearch(host, search)) : hosts ?? []),
[hosts, search],
@@ -408,14 +425,17 @@ export function HostsIndex() {
setSelectedHostId(host.id)}
+ onClick={() => openHost(host)}
>
{
+ if (button) { hostButtons.current.set(host.id, button); }
+ else { hostButtons.current.delete(host.id); }
+ }}
className="self-start text-left font-medium hover:underline"
- onClick={() => setSelectedHostId(host.id)}
>
{host.name}
@@ -436,9 +456,9 @@ export function HostsIndex() {
:
— }
- {host.location
- ? [host.location, host.cloudProvider].filter(Boolean).join(' · ')
- : {host.locationId ?? '—'} }
+ {formatLocation(host) ?? (
+ {host.locationId ?? '—'}
+ )}
@@ -462,7 +482,16 @@ export function HostsIndex() {
)}
- setSelectedHostId(null)} />
+ setSelected(null)}
+ // Opened from state, not a SheetTrigger, so Radix has nowhere to return focus on its own.
+ onCloseAutoFocus={(event) => {
+ event.preventDefault();
+ if (openedHostId.current) { hostButtons.current.get(openedHostId.current)?.focus(); }
+ }}
+ />
);
}
From 23cddf66c8c2795d670528ead95d69f19391fe7e Mon Sep 17 00:00:00 2001
From: Devin Holland <50112339+Devin-Holland@users.noreply.github.com>
Date: Thu, 22 Oct 2026 09:38:11 -0400
Subject: [PATCH 3/3] fix(admin): keep the host panel's content while it slides
closed
Closing now only clears an open flag; the clicked host stays selected, so the
panel no longer empties during its exit animation, and focus return reads it
directly instead of a separate ref.
Co-Authored-By: Claude Opus 5.5
---
src/features/admin/hosts/index.tsx | 16 +++++++++-------
1 file changed, 9 insertions(+), 7 deletions(-)
diff --git a/src/features/admin/hosts/index.tsx b/src/features/admin/hosts/index.tsx
index 12bf541dc..808719ab4 100644
--- a/src/features/admin/hosts/index.tsx
+++ b/src/features/admin/hosts/index.tsx
@@ -160,15 +160,16 @@ function DetailRow({ label, children }: { label: string; children: ReactNode })
}
function HostDetails(
- { host, orgNameById, onClose, onCloseAutoFocus }: {
+ { host, open, orgNameById, onClose, onCloseAutoFocus }: {
host: AdminHost | null;
+ open: boolean;
orgNameById: Map;
onClose: () => void;
onCloseAutoFocus: (event: Event) => void;
},
) {
return (
- !open && onClose()}>
+ !isOpen && onClose()}>
{host && (
<>
@@ -236,13 +237,13 @@ export function HostsIndex() {
const [region, setRegion] = useState(ANY);
const [search, setSearch] = useState('');
// The clicked row, so a refetch or filter change that drops the host cannot close or reopen the panel.
+ // Kept after closing so the panel still has its content while it slides out.
const [selected, setSelected] = useState(null);
+ const [detailsOpen, setDetailsOpen] = useState(false);
const hostButtons = useRef(new Map());
- // Outlives `selected`, which is already null by the time the closing sheet asks where focus goes.
- const openedHostId = useRef(null);
const openHost = (host: AdminHost) => {
- openedHostId.current = host.id;
setSelected(host);
+ setDetailsOpen(true);
};
const debouncedOrganizationText = useDebounce(organizationText, 300);
@@ -484,12 +485,13 @@ export function HostsIndex() {
setSelected(null)}
+ onClose={() => setDetailsOpen(false)}
// Opened from state, not a SheetTrigger, so Radix has nowhere to return focus on its own.
onCloseAutoFocus={(event) => {
event.preventDefault();
- if (openedHostId.current) { hostButtons.current.get(openedHostId.current)?.focus(); }
+ if (selected) { hostButtons.current.get(selected.id)?.focus(); }
}}
/>