Skip to content
Open
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
3 changes: 3 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,13 @@
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.6",
"@types/node": "^24.13.2",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
"jsdom": "^26.1.0",
"oxlint": "^1.71.0",
Comment on lines 31 to 34
"tailwindcss": "^4.3.3",
"typescript": "~6.0.2",
Expand Down
90 changes: 90 additions & 0 deletions apps/web/src/components/DeleteHostDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import { Button } from '@/components/ui/button'
import type { Host } from '@/types/api'

type DeleteHostDialogProps = {
host: Host
pending?: boolean
onCancel: () => void
onConfirm: () => void
}

export function DeleteHostDialog({
host,
pending = false,
onCancel,
onConfirm,
}: DeleteHostDialogProps) {
const panelRef = useRef<HTMLDivElement | null>(null)

useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && !pending) {
onCancel()
}
}
const previousOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
document.addEventListener('keydown', onKeyDown)
panelRef.current?.focus()
return () => {
document.body.style.overflow = previousOverflow
document.removeEventListener('keydown', onKeyDown)
}
}, [onCancel, pending])

return createPortal(
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div
className="animate-overlay-in absolute inset-0 bg-slate-950/40 backdrop-blur-[2px]"
onClick={pending ? undefined : onCancel}
aria-hidden
/>
<div
ref={panelRef}
role="alertdialog"
aria-modal="true"
tabIndex={-1}
aria-labelledby="delete-host-title"
aria-describedby="delete-host-description"
className="animate-pop-in relative w-full max-w-md rounded-lg border border-border bg-card p-5 shadow-2xl outline-none"
>
<h2
id="delete-host-title"
className="text-heading font-semibold tracking-tight"
>
Delete {host.hostname}?
</h2>
<p
id="delete-host-description"
className="mt-2 text-sm leading-relaxed text-muted-foreground"
>
This host has been inactive for at least seven days. Removing it
deletes the DockSight record. This cannot be undone.
</p>
<div className="mt-5 flex justify-end gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={onCancel}
disabled={pending}
>
Cancel
</Button>
<Button
type="button"
variant="danger"
size="sm"
onClick={onConfirm}
disabled={pending}
>
{pending ? 'Deleting…' : 'Delete host'}
</Button>
</div>
</div>
</div>,
document.body,
)
}
102 changes: 102 additions & 0 deletions apps/web/src/components/HostCard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/** @vitest-environment jsdom */

import { cleanup, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { MemoryRouter } from 'react-router-dom'
import { HostCard } from '@/components/HostCard'
import type { Host } from '@/types/api'

afterEach(() => {
cleanup()
})

function makeHost(overrides: Partial<Host> = {}): Host {
return {
id: 'host-1',
uuid: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
hostname: 'old-box',
os: 'linux',
architecture: 'amd64',
version: '27.0.0',
status: 'OFFLINE',
lastSeen: '2026-01-01T00:00:00Z',
canDelete: true,
metrics: {
hostId: 'host-1',
cpu: null,
memory: null,
collectedAt: null,
},
...overrides,
}
}

function renderCard(
overrides: {
host?: Host
canManage?: boolean
onDelete?: (host: Host) => Promise<void> | void
} = {},
) {
const onDelete = overrides.onDelete ?? vi.fn()
render(
<MemoryRouter>
<HostCard
host={overrides.host ?? makeHost()}
canManage={overrides.canManage ?? true}
onDelete={onDelete}
/>
</MemoryRouter>,
)
return { onDelete }
}

async function openActions() {
const user = userEvent.setup()
await user.click(screen.getByRole('button', { name: 'Actions for old-box' }))
return user
}

describe('HostCard delete action', () => {
it('shows delete for an admin when the host is eligible', async () => {
renderCard()
await openActions()
expect(
screen.getByRole('menuitem', { name: 'Delete host' }),
).toBeTruthy()
})

it('hides delete for an ineligible host', async () => {
renderCard({ host: makeHost({ canDelete: false, status: 'ONLINE' }) })
await openActions()
expect(screen.queryByRole('menuitem', { name: 'Delete host' })).toBeNull()
})

it('hides delete for a viewer', async () => {
renderCard({ canManage: false })
await openActions()
expect(screen.queryByRole('menuitem', { name: 'Delete host' })).toBeNull()
})

it('does not delete when confirmation is cancelled', async () => {
const { onDelete } = renderCard()
const user = await openActions()
await user.click(screen.getByRole('menuitem', { name: 'Delete host' }))
expect(screen.getByRole('alertdialog')).toBeTruthy()
await user.click(screen.getByRole('button', { name: 'Cancel' }))
expect(screen.queryByRole('alertdialog')).toBeNull()
expect(onDelete).not.toHaveBeenCalled()
})

it('deletes after explicit confirmation', async () => {
const { onDelete } = renderCard()
const user = await openActions()
await user.click(screen.getByRole('menuitem', { name: 'Delete host' }))
await user.click(screen.getByRole('button', { name: 'Delete host' }))
expect(onDelete).toHaveBeenCalledTimes(1)
expect(onDelete).toHaveBeenCalledWith(
expect.objectContaining({ id: 'host-1', hostname: 'old-box' }),
)
})
})
55 changes: 55 additions & 0 deletions apps/web/src/components/HostCard.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import {
ArrowUpRight,
Expand All @@ -6,7 +7,9 @@ import {
MoreHorizontal,
PlugZap,
RefreshCw,
Trash2,
} from 'lucide-react'
import { DeleteHostDialog } from '@/components/DeleteHostDialog'
import { OsIcon } from '@/components/OsIcon'
import { StatusBadge } from '@/components/StatusBadge'
import { Meter } from '@/components/charts/Sparkline'
Expand All @@ -15,6 +18,7 @@ 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 { canShowDeleteHost } from '@/lib/hosts'
import { isStale, toHostResources } from '@/lib/metrics'
import { cn } from '@/lib/utils'
import type { Host } from '@/types/api'
Expand All @@ -25,24 +29,45 @@ type HostCardProps = {
containerCount?: number
runningCount?: number
countsLoading?: boolean
/** Cosmetic gate; the API still enforces ADMIN on delete. */
canManage?: boolean
onRefresh?: (hostId: string) => void
onDelete?: (host: Host) => Promise<void> | void
}

export function HostCard({
host,
containerCount,
runningCount,
countsLoading = false,
canManage = false,
onRefresh,
onDelete,
}: HostCardProps) {
const navigate = useNavigate()
const [confirmingDelete, setConfirmingDelete] = useState(false)
const [deleting, setDeleting] = useState(false)
const showDelete = Boolean(onDelete) && canShowDeleteHost(host, canManage)
// 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)
// A disconnected agent leaves its last sample behind; say so rather than
// presenting a frozen number as current.
const stale = resources.hasData && isStale(resources.collectedAt)

async function confirmDelete() {
if (!onDelete || deleting) {
return
}
setDeleting(true)
try {
await onDelete(host)
setConfirmingDelete(false)
} finally {
setDeleting(false)
}
}

return (
<Card
interactive
Expand Down Expand Up @@ -116,6 +141,21 @@ export function HostCard({
title="The server has no agent-disconnect route yet"
/>
</div>
{showDelete ? (
<>
<DropdownSeparator />
<DropdownItem
icon={<Trash2 className="h-4 w-4" />}
destructive
onSelect={() => {
setConfirmingDelete(true)
close()
}}
>
Delete host
</DropdownItem>
</>
) : null}
</div>
)}
</Dropdown>
Expand Down Expand Up @@ -218,6 +258,21 @@ export function HostCard({
Disconnect
</Button>
</div>

{confirmingDelete ? (
<DeleteHostDialog
host={host}
pending={deleting}
onCancel={() => {
if (!deleting) {
setConfirmingDelete(false)
}
}}
onConfirm={() => {
void confirmDelete()
}}
/>
) : null}
</Card>
)
}
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/features/dashboard/DashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { Button } from '@/components/ui/button'
import { CardGridSkeleton, TableSkeleton } from '@/components/ui/skeleton'
import { EmptyState } from '@/components/ui/empty-state'
import { useContainerCommands } from '@/hooks/useContainerCommands'
import { useDeleteHost } from '@/hooks/useDeleteHost'
import { useHostInventory } from '@/hooks/useHostInventory'
import { useHosts } from '@/hooks/useHosts'
import { useIsAdmin } from '@/stores/auth'
Expand All @@ -37,6 +38,7 @@ export function DashboardPage() {
const [viewingLogs, setViewingLogs] = useState<ContainerRow | null>(null)
const commands = useContainerCommands(undefined, () => inventory.refetchAll())
const isAdmin = useIsAdmin()
const deleteHost = useDeleteHost()

const onlineHosts = hosts.filter((host) => host.status === 'ONLINE')
const totalContainers = inventory.all.length
Expand Down Expand Up @@ -146,7 +148,9 @@ export function DashboardPage() {
containerCount={entry?.total}
runningCount={entry?.running}
countsLoading={entry?.isLoading}
canManage={isAdmin}
onRefresh={() => inventory.refetchAll()}
onDelete={(target) => deleteHost.run(target)}
/>
)
})}
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/features/hosts/HostsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ import { FilterChips } from '@/components/ui/tabs'
import { StatusDot } from '@/components/ui/badge'
import { useHostInventory } from '@/hooks/useHostInventory'
import { useHosts } from '@/hooks/useHosts'
import { useDeleteHost } from '@/hooks/useDeleteHost'
import { useDocumentTitle } from '@/hooks/useDocumentTitle'
import { useIsAdmin } from '@/stores/auth'

type HostFilter = 'all' | 'online' | 'offline'

Expand All @@ -21,6 +23,8 @@ export function HostsPage() {
const hostsQuery = useHosts()
const hosts = useMemo(() => hostsQuery.data ?? [], [hostsQuery.data])
const inventory = useHostInventory(hosts)
const isAdmin = useIsAdmin()
const deleteHost = useDeleteHost()
const [query, setQuery] = useState('')
const [filter, setFilter] = useState<HostFilter>('all')

Expand Down Expand Up @@ -141,7 +145,9 @@ export function HostsPage() {
containerCount={entry?.total}
runningCount={entry?.running}
countsLoading={entry?.isLoading}
canManage={isAdmin}
onRefresh={() => inventory.refetchAll()}
onDelete={(target) => deleteHost.run(target)}
/>
)
})}
Expand Down
Loading
Loading