diff --git a/apps/web/src/components/HostCard.tsx b/apps/web/src/components/HostCard.tsx index 9b1415b..24bc0c2 100644 --- a/apps/web/src/components/HostCard.tsx +++ b/apps/web/src/components/HostCard.tsx @@ -1,13 +1,16 @@ +import { useState } from 'react' import { useNavigate } from 'react-router-dom' import { ArrowUpRight, Cpu, MemoryStick, MoreHorizontal, + Pencil, PlugZap, RefreshCw, } from 'lucide-react' import { OsIcon } from '@/components/OsIcon' +import { RenameHostDialog } from '@/components/RenameHostDialog' import { StatusBadge } from '@/components/StatusBadge' import { Meter } from '@/components/charts/Sparkline' import { MockBadge } from '@/components/ui/badge' @@ -15,8 +18,10 @@ import { Button } from '@/components/ui/button' import { Card } from '@/components/ui/card' import { Dropdown, DropdownItem, DropdownSeparator } from '@/components/ui/dropdown' import { formatBytes, formatRelativeTime, osLabel } from '@/lib/format' +import { hostDisplayName } from '@/lib/host-name' import { isStale, toHostResources } from '@/lib/metrics' import { cn } from '@/lib/utils' +import { useIsAdmin } from '@/stores/auth' import type { Host } from '@/types/api' type HostCardProps = { @@ -36,6 +41,9 @@ export function HostCard({ onRefresh, }: HostCardProps) { const navigate = useNavigate() + const isAdmin = useIsAdmin() + const [renaming, setRenaming] = useState(false) + const label = hostDisplayName(host) // Pushed by the agent on `metrics.host` and embedded in the /hosts response, // so the card needs no extra request. const resources = toHostResources(host.metrics) @@ -55,10 +63,10 @@ export function HostCard({
-

- {host.hostname} -

+

{label}

+ {host.hostname} + {' · '} {osLabel(host.os)} · {host.architecture} · Docker {host.version}

@@ -71,7 +79,7 @@ export function HostCard({ + + {isAdmin && renaming ? ( + setRenaming(false)} /> + ) : null} ) } diff --git a/apps/web/src/components/HostSelect.tsx b/apps/web/src/components/HostSelect.tsx index 1ed83ef..6683342 100644 --- a/apps/web/src/components/HostSelect.tsx +++ b/apps/web/src/components/HostSelect.tsx @@ -2,6 +2,7 @@ import { ChevronDown, Server } from 'lucide-react' import { StatusDot } from '@/components/ui/badge' import { Dropdown, DropdownItem, DropdownLabel } from '@/components/ui/dropdown' import { statusTone } from '@/lib/status' +import { hostDisplayName } from '@/lib/host-name' import type { Host } from '@/types/api' export function HostSelect({ @@ -27,7 +28,7 @@ export function HostSelect({ > - {selected?.hostname ?? 'Select host'} + {selected ? hostDisplayName(selected) : 'Select host'} {selected ? : null} @@ -51,7 +52,7 @@ export function HostSelect({ close() }} > - {host.hostname} + {hostDisplayName(host)} )) )} diff --git a/apps/web/src/components/RenameHostDialog.tsx b/apps/web/src/components/RenameHostDialog.tsx new file mode 100644 index 0000000..a132937 --- /dev/null +++ b/apps/web/src/components/RenameHostDialog.tsx @@ -0,0 +1,130 @@ +import { useEffect, useId, useState, type FormEvent } from 'react' +import { createPortal } from 'react-dom' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { useRenameHost } from '@/hooks/useRenameHost' +import { hostDisplayName, validateHostDisplayName } from '@/lib/host-name' +import { ApiError } from '@/services/api' +import type { Host } from '@/types/api' + +export function RenameHostDialog({ + host, + onClose, +}: { + host: Host + onClose: () => void +}) { + const headingId = useId() + const errorId = useId() + const mutation = useRenameHost() + const [value, setValue] = useState(hostDisplayName(host)) + const [localError, setLocalError] = useState(null) + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape' && !mutation.isPending) { + onClose() + } + } + const previousOverflow = document.body.style.overflow + document.body.style.overflow = 'hidden' + document.addEventListener('keydown', onKeyDown) + return () => { + document.body.style.overflow = previousOverflow + document.removeEventListener('keydown', onKeyDown) + } + }, [onClose, mutation.isPending]) + + const apiError = + mutation.error instanceof ApiError || mutation.error instanceof Error + ? mutation.error.message + : null + const error = localError ?? apiError + + async function onSubmit(event: FormEvent) { + event.preventDefault() + const invalid = validateHostDisplayName(value) + if (invalid) { + setLocalError(invalid) + return + } + setLocalError(null) + try { + await mutation.mutateAsync({ + hostId: host.id, + displayName: value.trim(), + }) + onClose() + } catch { + // Shown via mutation.error on the form. + } + } + + return createPortal( +
+
{ + if (!mutation.isPending) { + onClose() + } + }} + aria-hidden + /> +
{ + void onSubmit(event) + }} + onClick={(event) => event.stopPropagation()} + className="relative w-full max-w-sm rounded-lg border border-border bg-card p-5 shadow-2xl" + > +

+ Rename host +

+

+ Agent hostname: {host.hostname} +

+ + { + setValue(event.target.value) + setLocalError(null) + mutation.reset() + }} + className="mt-1.5" + /> + {error ? ( +

+ {error} +

+ ) : null} +
+ + +
+
+
, + document.body, + ) +} diff --git a/apps/web/src/components/layout/Topbar.tsx b/apps/web/src/components/layout/Topbar.tsx index 214cb4f..a4f7d34 100644 --- a/apps/web/src/components/layout/Topbar.tsx +++ b/apps/web/src/components/layout/Topbar.tsx @@ -20,6 +20,7 @@ import { DropdownSeparator, } from '@/components/ui/dropdown' import { useHosts } from '@/hooks/useHosts' +import { hostDisplayName } from '@/lib/host-name' import { MOCK_NOTIFICATIONS, MOCK_WORKSPACE } from '@/lib/mock' import { initialsFor } from '@/lib/format' import { statusTone } from '@/lib/status' @@ -68,6 +69,7 @@ function GlobalSearch() { return (hostsQuery.data ?? []) .filter( (host) => + hostDisplayName(host).toLowerCase().includes(value) || host.hostname.toLowerCase().includes(value) || host.os.toLowerCase().includes(value) || host.uuid.toLowerCase().includes(value), @@ -143,10 +145,10 @@ function GlobalSearch() { - {host.hostname} + {hostDisplayName(host)} - {host.os} · {host.architecture} + {host.hostname} · {host.os} · {host.architecture} diff --git a/apps/web/src/features/hosts/HostDetailsPage.tsx b/apps/web/src/features/hosts/HostDetailsPage.tsx index c895d23..9dcd1c6 100644 --- a/apps/web/src/features/hosts/HostDetailsPage.tsx +++ b/apps/web/src/features/hosts/HostDetailsPage.tsx @@ -54,6 +54,7 @@ import { formatRelativeTime, osLabel, } from '@/lib/format' +import { hostDisplayName } from '@/lib/host-name' import type { HostResources } from '@/lib/metrics' import { useDocumentTitle } from '@/hooks/useDocumentTitle' @@ -83,7 +84,8 @@ export function HostDetailsPage() { const hostsQuery = useHosts() const host = hostsQuery.data?.find((entry) => entry.id === hostId) - useDocumentTitle(host?.hostname ?? (hostsQuery.isLoading ? 'Host' : 'Host not found')) + const hostLabel = host ? hostDisplayName(host) : undefined + useDocumentTitle(hostLabel ?? (hostsQuery.isLoading ? 'Host' : 'Host not found')) const containersQuery = useContainers(hostId) const containers = containersQuery.data?.containers ?? [] const { resources } = useHostMetrics(hostId) @@ -155,6 +157,7 @@ export function HostDetailsPage() { } const refreshing = hostsQuery.isFetching || containersQuery.isFetching + const label = hostDisplayName(host) return ( @@ -165,7 +168,7 @@ export function HostDetailsPage() { Hosts - {host.hostname} + {label} } title={ @@ -173,12 +176,14 @@ export function HostDetailsPage() { - {host.hostname} + {label} } description={ + {host.hostname} + {osLabel(host.os)} {host.architecture} @@ -253,7 +258,7 @@ export function HostDetailsPage() { onAction={commands.run} onInspect={setInspecting} onViewLogs={setViewingLogs} - emptyDescription={`${host.hostname} has no containers. Anything the Docker daemon reports will appear here within 20 seconds.`} + emptyDescription={`${label} has no containers. Anything the Docker daemon reports will appear here within 20 seconds.`} /> ) ) : null} @@ -363,6 +368,7 @@ function OverviewTab({ ({ ...container, hostId: host.id, - hostname: host.hostname, + hostname: hostInventoryLabel(host), })), ) diff --git a/apps/web/src/hooks/useRenameHost.ts b/apps/web/src/hooks/useRenameHost.ts new file mode 100644 index 0000000..20f6c7e --- /dev/null +++ b/apps/web/src/hooks/useRenameHost.ts @@ -0,0 +1,36 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { useToast } from '@/components/ToastProvider' +import { hostsQueryKey } from '@/hooks/useHosts' +import { renameHost } from '@/services/hosts' +import type { Host } from '@/types/api' + +export function useRenameHost() { + const toast = useToast() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: ({ + hostId, + displayName, + }: { + hostId: string + displayName: string + }) => renameHost(hostId, displayName), + onSuccess: async (updated) => { + queryClient.setQueryData(hostsQueryKey, (current) => { + if (!current) { + return current + } + return current.map((host) => + host.id === updated.id ? { ...host, ...updated } : host, + ) + }) + await queryClient.invalidateQueries({ queryKey: hostsQueryKey }) + toast.push({ + tone: 'success', + title: 'Host renamed', + description: updated.displayName ?? updated.hostname, + }) + }, + }) +} diff --git a/apps/web/src/lib/host-name.test.ts b/apps/web/src/lib/host-name.test.ts new file mode 100644 index 0000000..e5e905d --- /dev/null +++ b/apps/web/src/lib/host-name.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import { + HOST_DISPLAY_NAME_MAX_LENGTH, + hostDisplayName, + hostInventoryLabel, + validateHostDisplayName, +} from './host-name' + +describe('hostDisplayName', () => { + it('falls back to hostname when displayName is missing', () => { + expect(hostDisplayName({ hostname: 'ip-10-0-0-1' })).toBe('ip-10-0-0-1') + }) + + it('uses the stored display name when set', () => { + expect( + hostDisplayName({ hostname: 'ip-10-0-0-1', displayName: 'prod-web-1' }), + ).toBe('prod-web-1') + }) +}) + +describe('hostInventoryLabel', () => { + it('is just the hostname when no display name is set', () => { + expect(hostInventoryLabel({ hostname: 'ip-10-0-0-1' })).toBe('ip-10-0-0-1') + }) + + it('keeps the agent hostname next to the display name', () => { + expect( + hostInventoryLabel({ hostname: 'ip-10-0-0-1', displayName: 'prod-web-1' }), + ).toBe('prod-web-1 (ip-10-0-0-1)') + }) +}) + +describe('validateHostDisplayName', () => { + it('rejects empty and whitespace-only names', () => { + expect(validateHostDisplayName('')).toMatch(/empty/i) + expect(validateHostDisplayName(' ')).toMatch(/empty/i) + }) + + it('rejects names that are too long', () => { + expect( + validateHostDisplayName('a'.repeat(HOST_DISPLAY_NAME_MAX_LENGTH + 1)), + ).toMatch(/at most/) + }) + + it('accepts a trimmed name', () => { + expect(validateHostDisplayName(' prod-web-1 ')).toBeNull() + }) +}) diff --git a/apps/web/src/lib/host-name.ts b/apps/web/src/lib/host-name.ts new file mode 100644 index 0000000..938edb0 --- /dev/null +++ b/apps/web/src/lib/host-name.ts @@ -0,0 +1,26 @@ +import type { Host } from '@/types/api' + +export const HOST_DISPLAY_NAME_MAX_LENGTH = 64 + +export function hostDisplayName(host: Pick): string { + return host.displayName?.trim() || host.hostname +} + +/** Friendly name plus agent hostname so container search still matches both. */ +export function hostInventoryLabel( + host: Pick, +): string { + const label = hostDisplayName(host) + return label === host.hostname ? host.hostname : `${label} (${host.hostname})` +} + +export function validateHostDisplayName(value: string): string | null { + const name = value.trim() + if (!name) { + return 'Display name must not be empty' + } + if (name.length > HOST_DISPLAY_NAME_MAX_LENGTH) { + return `Display name must be at most ${HOST_DISPLAY_NAME_MAX_LENGTH} characters` + } + return null +} diff --git a/apps/web/src/services/api.ts b/apps/web/src/services/api.ts index 0dca617..3a4627e 100644 --- a/apps/web/src/services/api.ts +++ b/apps/web/src/services/api.ts @@ -51,7 +51,7 @@ type RequestOptions = { } async function request( - method: 'GET' | 'POST', + method: 'GET' | 'POST' | 'PATCH', path: string, body?: unknown, options: RequestOptions = {}, @@ -111,7 +111,16 @@ export async function apiPost( return request('POST', path, body ?? {}, options) } +export async function apiPatch( + path: string, + body?: unknown, + options?: RequestOptions, +): Promise { + return request('PATCH', path, body ?? {}, options) +} + export const apiClient = { get: apiGet, post: apiPost, + patch: apiPatch, } diff --git a/apps/web/src/services/hosts.test.ts b/apps/web/src/services/hosts.test.ts new file mode 100644 index 0000000..54c98da --- /dev/null +++ b/apps/web/src/services/hosts.test.ts @@ -0,0 +1,33 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { apiClient } from '@/services/api' +import { renameHost } from '@/services/hosts' + +vi.mock('@/services/api', () => ({ + apiClient: { + patch: vi.fn(), + }, +})) + +describe('renameHost', () => { + beforeEach(() => { + vi.mocked(apiClient.patch).mockReset() + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + it('PATCHes the host display name', async () => { + const updated = { + id: 'host-1', + hostname: 'ip-10-0-0-1', + displayName: 'prod-web-1', + } + vi.mocked(apiClient.patch).mockResolvedValueOnce(updated) + + await expect(renameHost('host-1', 'prod-web-1')).resolves.toEqual(updated) + expect(apiClient.patch).toHaveBeenCalledWith('/hosts/host-1', { + displayName: 'prod-web-1', + }) + }) +}) diff --git a/apps/web/src/services/hosts.ts b/apps/web/src/services/hosts.ts index bcd29a5..6970d89 100644 --- a/apps/web/src/services/hosts.ts +++ b/apps/web/src/services/hosts.ts @@ -12,6 +12,12 @@ export function fetchHosts(): Promise { return apiClient.get('/hosts') } +export function renameHost(hostId: string, displayName: string): Promise { + return apiClient.patch(`/hosts/${encodeURIComponent(hostId)}`, { + displayName, + }) +} + export function fetchHostMetrics(hostId: string): Promise { return apiClient.get(`/hosts/${hostId}/metrics`) } diff --git a/apps/web/src/types/api.ts b/apps/web/src/types/api.ts index e1d3b5b..6a11755 100644 --- a/apps/web/src/types/api.ts +++ b/apps/web/src/types/api.ts @@ -31,6 +31,8 @@ export type Host = { id: string uuid: string hostname: string + /** Operator label from the API; omitted or equal to hostname on older servers. */ + displayName?: string os: string architecture: string version: string