Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 27 additions & 4 deletions apps/web/src/components/HostCard.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,27 @@
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'
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 = {
Expand All @@ -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)
Expand All @@ -55,10 +63,10 @@ export function HostCard({
<OsIcon os={host.os} className="h-5 w-5" />
</span>
<div className="min-w-0">
<p className="truncate font-semibold tracking-tight">
{host.hostname}
</p>
<p className="truncate font-semibold tracking-tight">{label}</p>
<p className="truncate text-xs text-muted-foreground">
{host.hostname}
{' · '}
{osLabel(host.os)} · {host.architecture} · Docker {host.version}
</p>
</div>
Expand All @@ -71,7 +79,7 @@ export function HostCard({
<button
type="button"
{...aria}
aria-label={`Actions for ${host.hostname}`}
aria-label={`Actions for ${label}`}
onClick={(event) => {
event.stopPropagation()
toggle()
Expand Down Expand Up @@ -102,6 +110,17 @@ export function HostCard({
>
Refresh inventory
</DropdownItem>
{isAdmin ? (
<DropdownItem
icon={<Pencil className="h-4 w-4" />}
onSelect={() => {
setRenaming(true)
close()
}}
>
Rename
</DropdownItem>
) : null}
<DropdownSeparator />
<DropdownItem
icon={<PlugZap className="h-4 w-4" />}
Expand Down Expand Up @@ -218,6 +237,10 @@ export function HostCard({
Disconnect
</Button>
</div>

{isAdmin && renaming ? (
<RenameHostDialog host={host} onClose={() => setRenaming(false)} />
) : null}
</Card>
)
}
Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/components/HostSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -27,7 +28,7 @@ export function HostSelect({
>
<Server className="h-4 w-4 text-muted-foreground" aria-hidden />
<span className="max-w-[12rem] truncate">
{selected?.hostname ?? 'Select host'}
{selected ? hostDisplayName(selected) : 'Select host'}
</span>
{selected ? <StatusDot tone={statusTone(selected.status)} /> : null}
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" aria-hidden />
Expand All @@ -51,7 +52,7 @@ export function HostSelect({
close()
}}
>
{host.hostname}
{hostDisplayName(host)}
</DropdownItem>
))
)}
Expand Down
130 changes: 130 additions & 0 deletions apps/web/src/components/RenameHostDialog.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(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(
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div
className="absolute inset-0 bg-slate-950/40 backdrop-blur-[2px]"
onClick={() => {
if (!mutation.isPending) {
onClose()
}
}}
aria-hidden
/>
<form
role="dialog"
aria-modal="true"
aria-labelledby={headingId}
onSubmit={(event) => {
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"
>
<h2 id={headingId} className="text-base font-semibold tracking-tight">
Rename host
</h2>
<p className="mt-1 text-xs text-muted-foreground">
Agent hostname: <span className="font-mono">{host.hostname}</span>
</p>
<label className="mt-4 block text-sm font-medium" htmlFor={`${headingId}-name`}>
Display name
</label>
<Input
id={`${headingId}-name`}
value={value}
autoFocus
aria-invalid={Boolean(error)}
aria-describedby={error ? errorId : undefined}
disabled={mutation.isPending}
onChange={(event) => {
setValue(event.target.value)
setLocalError(null)
mutation.reset()
}}
className="mt-1.5"
/>
{error ? (
<p id={errorId} className="mt-2 text-xs text-danger">
{error}
</p>
) : null}
<div className="mt-4 flex justify-end gap-2">
<Button
type="button"
variant="ghost"
size="sm"
disabled={mutation.isPending}
onClick={onClose}
>
Cancel
</Button>
<Button type="submit" size="sm" disabled={mutation.isPending}>
{mutation.isPending ? 'Saving…' : 'Save'}
</Button>
</div>
</form>
</div>,
document.body,
)
}
6 changes: 4 additions & 2 deletions apps/web/src/components/layout/Topbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -143,10 +145,10 @@ function GlobalSearch() {
<Server className="h-4 w-4 text-muted-foreground" aria-hidden />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">
{host.hostname}
{hostDisplayName(host)}
</span>
<span className="block truncate text-xs text-muted-foreground">
{host.os} · {host.architecture}
{host.hostname} · {host.os} · {host.architecture}
</span>
</span>
<StatusDot tone={statusTone(host.status)} />
Expand Down
14 changes: 10 additions & 4 deletions apps/web/src/features/hosts/HostDetailsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -155,6 +157,7 @@ export function HostDetailsPage() {
}

const refreshing = hostsQuery.isFetching || containersQuery.isFetching
const label = hostDisplayName(host)

return (
<PageContainer>
Expand All @@ -165,20 +168,22 @@ export function HostDetailsPage() {
Hosts
</Link>
<ChevronRight className="h-3 w-3" aria-hidden />
<span className="text-foreground">{host.hostname}</span>
<span className="text-foreground">{label}</span>
</nav>
}
title={
<span className="flex items-center gap-3">
<span className="flex h-10 w-10 items-center justify-center rounded-lg border border-border bg-card">
<OsIcon os={host.os} className="h-5 w-5" />
</span>
{host.hostname}
{label}
<StatusBadge status={host.status} />
</span>
}
description={
<span className="flex flex-wrap items-center gap-x-3 gap-y-1 font-mono text-[13px]">
<span>{host.hostname}</span>
<Dot />
<span>{osLabel(host.os)}</span>
<Dot />
<span>{host.architecture}</span>
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -363,6 +368,7 @@ function OverviewTab({
<CardContent>
<DataList
items={[
{ label: 'Display name', value: hostDisplayName(host) },
{ label: 'Hostname', value: host.hostname },
{ label: 'Agent UUID', value: host.uuid, mono: true, copy: host.uuid },
{ label: 'Operating system', value: osLabel(host.os) },
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/features/hosts/HostsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { FilterChips } from '@/components/ui/tabs'
import { StatusDot } from '@/components/ui/badge'
import { useHostInventory } from '@/hooks/useHostInventory'
import { useHosts } from '@/hooks/useHosts'
import { hostDisplayName } from '@/lib/host-name'
import { useDocumentTitle } from '@/hooks/useDocumentTitle'

type HostFilter = 'all' | 'online' | 'offline'
Expand Down Expand Up @@ -44,6 +45,7 @@ export function HostsPage() {
return true
}
return (
hostDisplayName(host).toLowerCase().includes(value) ||
host.hostname.toLowerCase().includes(value) ||
host.os.toLowerCase().includes(value) ||
host.architecture.toLowerCase().includes(value) ||
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/hooks/useHostInventory.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useQueries } from '@tanstack/react-query'
import { containersQueryKey } from '@/hooks/useContainers'
import { fetchHostContainers } from '@/services/hosts'
import { hostInventoryLabel } from '@/lib/host-name'
import type { Container, Host } from '@/types/api'

export type HostInventoryEntry = {
Expand Down Expand Up @@ -47,7 +48,7 @@ export function useHostInventory(hosts: Host[]) {
(results[index]?.data?.containers ?? []).map((container) => ({
...container,
hostId: host.id,
hostname: host.hostname,
hostname: hostInventoryLabel(host),
})),
)

Expand Down
Loading