feat: host detail page + FM host context
CI / Lint · Typecheck · Test · Build (push) Successful in 44s
CI / Playwright (smoke) (push) Has been skipped
CI / Build & push images (push) Successful in 1m5s

Add /hosts/:id detail page with unified timeline (HostEvents + FMs + Repairs
+ part arrivals/departures) and a deployed-parts table. Hosts list rows now
link to the page. FM list + detail surface inline State/Stack badges next
to the asset ID, with the asset ID linking to the host page.

HostEvent audit model added; create/update in the hosts service now diff
and log state, stack, and field changes the same way parts.ts does.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-17 14:04:07 -04:00
parent 60255f20bb
commit b0e9c5d1d0
19 changed files with 1228 additions and 91 deletions
+2
View File
@@ -18,6 +18,7 @@ import FmDetail from './pages/FmDetail.js';
import Repairs from './pages/Repairs.js';
import MyCustody from './pages/MyCustody.js';
import Hosts from './pages/Hosts.js';
import HostDetail from './pages/HostDetail.js';
import Users from './pages/admin/Users.js';
import Webhooks from './pages/admin/Webhooks.js';
@@ -64,6 +65,7 @@ export default function App() {
<Route path="/repairs" element={<Repairs />} />
<Route path="/custody" element={<MyCustody />} />
<Route path="/hosts" element={<Hosts />} />
<Route path="/hosts/:id" element={<HostDetail />} />
<Route
path="/admin/users"
element={
@@ -0,0 +1,20 @@
import type { HostStack, HostState } from '@vector/shared';
import { Badge, type BadgeProps } from '@vector/ui';
const STATE_VARIANT: Record<HostState, BadgeProps['variant']> = {
DEPLOYED: 'secondary',
DEGRADED: 'destructive',
TESTING: 'outline',
};
export function HostStateBadge({ state }: { state: HostState }) {
return <Badge variant={STATE_VARIANT[state]}>{state}</Badge>;
}
export function HostStackBadge({ stack }: { stack: HostStack }) {
return (
<Badge variant="outline" className="text-xs">
{stack}
</Badge>
);
}
@@ -0,0 +1,234 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import {
ArrowRight,
ArrowRightLeft,
CheckCircle2,
LogIn,
LogOut,
Pencil,
Wrench,
type LucideIcon,
} from 'lucide-react';
import { Button, Skeleton } from '@vector/ui';
import { listHostTimeline } from '../../lib/api/hosts.js';
import { queryKeys } from '../../lib/queryKeys.js';
import type { HostTimelineEntry } from '../../lib/api/types.js';
const ENTRY_ICON: Record<HostTimelineEntry['type'], LucideIcon> = {
HOST_EVENT: Pencil,
FM_OPENED: Wrench,
FM_CLOSED: Wrench,
REPAIR: ArrowRightLeft,
PART_ARRIVED: LogIn,
PART_DEPARTED: LogOut,
};
const HOST_EVENT_TITLE: Record<string, string> = {
CREATED: 'Created',
STATE_CHANGED: 'State changed',
STACK_CHANGED: 'Stack changed',
FIELD_UPDATED: 'Field updated',
};
function formatWhen(iso: string) {
return new Date(iso).toLocaleString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
function EntryRow({ entry }: { entry: HostTimelineEntry }) {
switch (entry.type) {
case 'HOST_EVENT': {
const { hostEvent } = entry;
return (
<>
<div className="flex flex-wrap items-center gap-x-2 text-sm">
<span className="font-medium text-foreground">
{HOST_EVENT_TITLE[hostEvent.type] ?? hostEvent.type}
</span>
{hostEvent.field && (
<span className="text-xs text-muted-foreground">· {hostEvent.field}</span>
)}
{(hostEvent.oldValue || hostEvent.newValue) && (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<span className="font-mono">{hostEvent.oldValue ?? '—'}</span>
<ArrowRight className="h-3 w-3" />
<span className="font-mono text-foreground">{hostEvent.newValue ?? '—'}</span>
</span>
)}
</div>
<div className="mt-0.5 text-xs text-muted-foreground">
{formatWhen(entry.at)}
{hostEvent.user?.username ? ` · ${hostEvent.user.username}` : ''}
</div>
</>
);
}
case 'FM_OPENED':
case 'FM_CLOSED': {
const { fm } = entry;
const label = entry.type === 'FM_OPENED' ? 'FM opened' : 'FM closed';
return (
<>
<div className="flex flex-wrap items-center gap-x-2 text-sm">
<span className="font-medium text-foreground">{label}</span>
<Link to={`/fms/${fm.id}`} className="text-xs text-muted-foreground hover:underline">
{fm.problem}
</Link>
</div>
<div className="mt-0.5 text-xs text-muted-foreground">{formatWhen(entry.at)}</div>
</>
);
}
case 'REPAIR': {
const { repair } = entry;
return (
<>
<div className="flex flex-wrap items-center gap-x-2 text-sm">
<span className="font-medium text-foreground">Repair</span>
<span className="inline-flex flex-wrap items-center gap-1 text-xs">
<Link
to={`/parts/${repair.brokenPart.id}`}
className="font-mono text-muted-foreground hover:underline"
>
{repair.brokenPart.serialNumber}
</Link>
<span className="text-muted-foreground"> BROKEN</span>
<span className="text-muted-foreground">·</span>
<Link
to={`/parts/${repair.replacement.id}`}
className="font-mono text-foreground hover:underline"
>
{repair.replacement.serialNumber}
</Link>
<span className="text-muted-foreground"> DEPLOYED</span>
</span>
</div>
<div className="mt-0.5 text-xs text-muted-foreground">
{formatWhen(entry.at)}
{repair.performedBy?.username ? ` · ${repair.performedBy.username}` : ''}
</div>
</>
);
}
case 'PART_ARRIVED':
case 'PART_DEPARTED': {
const { part } = entry;
const label = entry.type === 'PART_ARRIVED' ? 'Part arrived' : 'Part departed';
return (
<>
<div className="flex flex-wrap items-center gap-x-2 text-sm">
<span className="font-medium text-foreground">{label}</span>
<Link
to={`/parts/${part.id}`}
className="font-mono text-xs text-muted-foreground hover:underline"
>
{part.serialNumber}
</Link>
<span className="text-xs text-muted-foreground">· {part.mpn}</span>
</div>
<div className="mt-0.5 text-xs text-muted-foreground">{formatWhen(entry.at)}</div>
</>
);
}
}
}
function entryKey(entry: HostTimelineEntry): string {
switch (entry.type) {
case 'HOST_EVENT':
return `he-${entry.hostEvent.id}`;
case 'FM_OPENED':
return `fo-${entry.fm.id}`;
case 'FM_CLOSED':
return `fc-${entry.fm.id}`;
case 'REPAIR':
return `r-${entry.repair.id}`;
case 'PART_ARRIVED':
return `pa-${entry.partEventId}`;
case 'PART_DEPARTED':
return `pd-${entry.partEventId}`;
}
}
export function HostTimeline({ hostId }: { hostId: string }) {
const [page, setPage] = useState(1);
const pageSize = 20;
const query = useQuery({
queryKey: queryKeys.hosts.timeline(hostId, { page, pageSize }),
queryFn: () => listHostTimeline(hostId, { page, pageSize }),
placeholderData: (prev) => prev,
});
if (query.isPending) {
return (
<div className="space-y-2">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
);
}
if (query.isError) {
return <p className="text-sm text-destructive">Could not load history.</p>;
}
const entries = query.data?.data ?? [];
const total = query.data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pageSize));
if (entries.length === 0) {
return <p className="text-sm text-muted-foreground">No activity yet.</p>;
}
return (
<div className="space-y-1">
<ol className="relative ml-3 border-l border-border">
{entries.map((entry) => {
const Icon = ENTRY_ICON[entry.type];
return (
<li key={entryKey(entry)} className="relative pl-6 pb-4 last:pb-0">
<span className="absolute -left-[11px] top-0 flex h-5 w-5 items-center justify-center rounded-full border border-border bg-background">
<Icon className="h-3 w-3 text-muted-foreground" />
</span>
<EntryRow entry={entry} />
</li>
);
})}
</ol>
{pageCount > 1 && (
<div className="flex items-center justify-end gap-2 pt-2 text-xs text-muted-foreground">
<span>
Page {page} of {pageCount}
</span>
<Button
variant="ghost"
size="sm"
className="h-7"
disabled={page <= 1 || query.isFetching}
onClick={() => setPage(page - 1)}
>
Previous
</Button>
<Button
variant="ghost"
size="sm"
className="h-7"
disabled={page >= pageCount || query.isFetching}
onClick={() => setPage(page + 1)}
>
Next
</Button>
</div>
)}
</div>
);
}
+5 -1
View File
@@ -1,7 +1,7 @@
import type { CreateHostRequest, UpdateHostRequest } from '@vector/shared';
import { api } from './client.js';
import { getList } from './paginated.js';
import type { Host, Part } from './types.js';
import type { Host, HostTimelineEntry, Part } from './types.js';
export type HostListFilters = {
page?: number;
@@ -25,6 +25,10 @@ export async function listHostDeployedParts(id: string): Promise<Part[]> {
return res.data;
}
export function listHostTimeline(id: string, filters: { page?: number; pageSize?: number } = {}) {
return getList<HostTimelineEntry>(`/hosts/${id}/timeline`, filters);
}
export async function createHost(input: CreateHostRequest): Promise<Host> {
const res = await api.post<Host>('/hosts', input);
return res.data;
+40
View File
@@ -113,6 +113,46 @@ export interface Host {
updatedAt: string;
}
export interface HostEvent {
id: string;
type: string;
field: string | null;
oldValue: string | null;
newValue: string | null;
createdAt: string;
user: { username: string } | null;
}
interface FmTimelineSummary {
id: string;
status: FmStatus;
problem: string;
openedAt: string;
closedAt: string | null;
}
interface RepairTimelineSummary {
id: string;
performedAt: string;
brokenPart: { id: string; serialNumber: string; mpn: string };
replacement: { id: string; serialNumber: string; mpn: string };
performedBy: { username: string } | null;
}
interface PartTimelineRef {
id: string;
serialNumber: string;
mpn: string;
}
export type HostTimelineEntry =
| { type: 'HOST_EVENT'; at: string; hostEvent: HostEvent }
| { type: 'FM_OPENED'; at: string; fm: FmTimelineSummary }
| { type: 'FM_CLOSED'; at: string; fm: FmTimelineSummary }
| { type: 'REPAIR'; at: string; repair: RepairTimelineSummary }
| { type: 'PART_ARRIVED'; at: string; part: PartTimelineRef; partEventId: string }
| { type: 'PART_DEPARTED'; at: string; part: PartTimelineRef; partEventId: string };
export interface Tag {
id: string;
name: string;
+2
View File
@@ -48,6 +48,8 @@ export const queryKeys = {
[...queryKeys.hosts.all, 'list', filters ?? {}] as const,
detail: (id: string) => [...queryKeys.hosts.all, 'detail', id] as const,
deployedParts: (id: string) => [...queryKeys.hosts.all, 'deployed-parts', id] as const,
timeline: (id: string, filters?: Record<string, unknown>) =>
[...queryKeys.hosts.all, 'timeline', id, filters ?? {}] as const,
},
fms: {
all: ['fms'] as const,
+11 -3
View File
@@ -22,6 +22,7 @@ import { ApiRequestError } from '../lib/api/client.js';
import { queryKeys } from '../lib/queryKeys.js';
import type { Fm } from '../lib/api/types.js';
import { PartStateBadge } from '../components/parts/PartStateBadge.js';
import { HostStackBadge, HostStateBadge } from '../components/hosts/HostStateBadge.js';
export default function FmDetail() {
const { id } = useParams<{ id: string }>();
@@ -95,16 +96,23 @@ export default function FmDetail() {
<div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs uppercase tracking-wide text-muted-foreground">Asset</span>
<span className="font-mono text-2xl font-semibold tracking-tight text-foreground">
<Link
to={`/hosts/${fm.host.id}`}
className="font-mono text-2xl font-semibold tracking-tight text-foreground hover:underline"
>
{fm.host.assetId}
</span>
</Link>
<HostStateBadge state={fm.host.state} />
<HostStackBadge stack={fm.host.stack} />
<Badge variant={closed ? 'secondary' : 'warning'}>
{closed ? 'Closed' : 'Open'}
</Badge>
</div>
<div className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
<Server className="h-3 w-3" />
<span>{fm.host.name}</span>
<Link to={`/hosts/${fm.host.id}`} className="hover:underline">
{fm.host.name}
</Link>
{fm.host.location && <span>· {fm.host.location}</span>}
</div>
</div>
+19 -7
View File
@@ -22,6 +22,7 @@ import {
import { PageHeader } from '../components/layout/PageHeader.js';
import { DataTable } from '../components/data-table/DataTable.js';
import { FmFormDialog } from '../components/fms/FmFormDialog.js';
import { HostStackBadge, HostStateBadge } from '../components/hosts/HostStateBadge.js';
import { ConfirmDialog } from '../components/ConfirmDialog.js';
import { deleteFm, listFms } from '../lib/api/fms.js';
import { ApiRequestError } from '../lib/api/client.js';
@@ -78,18 +79,29 @@ export default function Fms() {
id: 'assetId',
header: 'Asset ID',
cell: ({ row }) => (
<Link
to={`/fms/${row.original.id}`}
className="font-mono text-xs font-medium text-foreground hover:underline"
>
{row.original.host.assetId}
</Link>
<div className="flex items-center gap-1.5">
<Link
to={`/fms/${row.original.id}`}
className="font-mono text-xs font-medium text-foreground hover:underline"
>
{row.original.host.assetId}
</Link>
<HostStateBadge state={row.original.host.state} />
<HostStackBadge stack={row.original.host.stack} />
</div>
),
},
{
id: 'host',
header: 'Host',
cell: ({ row }) => <span className="text-sm">{row.original.host.name}</span>,
cell: ({ row }) => (
<Link
to={`/hosts/${row.original.host.id}`}
className="text-sm hover:underline"
>
{row.original.host.name}
</Link>
),
},
{
id: 'problem',
+245
View File
@@ -0,0 +1,245 @@
import { useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { ArrowLeft, Edit, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import {
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Separator,
Skeleton,
} from '@vector/ui';
import { deleteHost, getHost, listHostDeployedParts } from '../lib/api/hosts.js';
import { ApiRequestError } from '../lib/api/client.js';
import { queryKeys } from '../lib/queryKeys.js';
import { useAuth } from '../contexts/AuthContext.js';
import { HostStateBadge, HostStackBadge } from '../components/hosts/HostStateBadge.js';
import { HostTimeline } from '../components/hosts/HostTimeline.js';
import { HostFormDialog } from '../components/hosts/HostFormDialog.js';
import { PartStateBadge } from '../components/parts/PartStateBadge.js';
import { ConfirmDialog } from '../components/ConfirmDialog.js';
function DetailRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="grid grid-cols-[10rem_1fr] gap-2 text-sm">
<dt className="text-muted-foreground">{label}</dt>
<dd className="text-foreground">{value}</dd>
</div>
);
}
export default function HostDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { user } = useAuth();
const isAdmin = user?.role === 'ADMIN';
const [editOpen, setEditOpen] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const { data: host, isPending, isError, error } = useQuery({
queryKey: queryKeys.hosts.detail(id!),
queryFn: () => getHost(id!),
enabled: Boolean(id),
});
const deployedPartsQuery = useQuery({
queryKey: queryKeys.hosts.deployedParts(id!),
queryFn: () => listHostDeployedParts(id!),
enabled: Boolean(id),
});
const deleteMutation = useMutation({
mutationFn: () => deleteHost(id!),
onSuccess: () => {
toast.success('Host deleted');
queryClient.invalidateQueries({ queryKey: queryKeys.hosts.all });
navigate('/hosts', { replace: true });
},
onError: (err) => {
toast.error(err instanceof ApiRequestError ? err.body.message : 'Delete failed');
},
});
if (isPending) {
return (
<div className="space-y-4">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-32 w-full" />
<Skeleton className="h-48 w-full" />
</div>
);
}
if (isError || !host) {
const msg = error instanceof ApiRequestError ? error.body.message : 'Host not found.';
return (
<Card>
<CardHeader>
<CardTitle>Host unavailable</CardTitle>
<CardDescription>{msg}</CardDescription>
</CardHeader>
<CardContent>
<Button variant="outline" onClick={() => navigate('/hosts')}>
<ArrowLeft className="h-4 w-4" />
Back to hosts
</Button>
</CardContent>
</Card>
);
}
const deployedParts = deployedPartsQuery.data ?? [];
return (
<div className="space-y-5">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={() => navigate('/hosts')} aria-label="Back">
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-lg font-semibold tracking-tight">{host.name}</h1>
<p className="text-xs text-muted-foreground">
<span className="font-mono">{host.assetId}</span>
{host.location ? ` · ${host.location}` : ''}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<HostStateBadge state={host.state} />
<HostStackBadge stack={host.stack} />
<Button variant="outline" size="sm" onClick={() => setEditOpen(true)}>
<Edit className="h-3.5 w-3.5" />
Edit
</Button>
{isAdmin && (
<Button
variant="outline"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => setConfirmDelete(true)}
>
<Trash2 className="h-3.5 w-3.5" />
Delete
</Button>
)}
</div>
</div>
<div className="grid gap-4 md:grid-cols-[1fr_1.2fr]">
<Card>
<CardHeader>
<CardTitle className="text-base">Summary</CardTitle>
</CardHeader>
<CardContent>
<dl className="space-y-2">
<DetailRow
label="Asset ID"
value={<span className="font-mono text-xs">{host.assetId}</span>}
/>
<DetailRow label="Name" value={host.name} />
<DetailRow label="State" value={<HostStateBadge state={host.state} />} />
<DetailRow label="Stack" value={<HostStackBadge stack={host.stack} />} />
<DetailRow
label="Location"
value={
host.location ?? <span className="text-muted-foreground italic"></span>
}
/>
<Separator className="my-2" />
<DetailRow label="Created" value={new Date(host.createdAt).toLocaleString()} />
<DetailRow label="Updated" value={new Date(host.updatedAt).toLocaleString()} />
</dl>
{host.notes && (
<>
<Separator className="my-3" />
<div>
<p className="mb-1 text-xs font-medium text-muted-foreground">Notes</p>
<p className="whitespace-pre-wrap text-sm text-foreground">{host.notes}</p>
</div>
</>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">History</CardTitle>
<CardDescription>
FMs, repairs, part swaps, and host field changes.
</CardDescription>
</CardHeader>
<CardContent>
<HostTimeline hostId={host.id} />
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">Deployed parts</CardTitle>
<CardDescription>Parts currently installed on this host.</CardDescription>
</CardHeader>
<CardContent>
{deployedPartsQuery.isPending ? (
<Skeleton className="h-16 w-full" />
) : deployedParts.length === 0 ? (
<p className="text-sm text-muted-foreground">No parts deployed here.</p>
) : (
<div className="overflow-hidden rounded-md border">
<table className="w-full text-sm">
<thead className="bg-muted/40 text-xs text-muted-foreground">
<tr>
<th className="px-3 py-2 text-left font-medium">Serial</th>
<th className="px-3 py-2 text-left font-medium">MPN</th>
<th className="px-3 py-2 text-left font-medium">Manufacturer</th>
<th className="px-3 py-2 text-left font-medium">State</th>
</tr>
</thead>
<tbody>
{deployedParts.map((p) => (
<tr key={p.id} className="border-t">
<td className="px-3 py-2">
<Link
to={`/parts/${p.id}`}
className="font-mono text-xs hover:underline"
>
{p.serialNumber}
</Link>
</td>
<td className="px-3 py-2 font-mono text-xs">{p.partModel.mpn}</td>
<td className="px-3 py-2 text-muted-foreground">
{p.manufacturer.name}
</td>
<td className="px-3 py-2">
<PartStateBadge state={p.state} />
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
<HostFormDialog open={editOpen} onOpenChange={setEditOpen} host={host} />
<ConfirmDialog
open={confirmDelete}
onOpenChange={setConfirmDelete}
title="Delete host?"
description={`Permanently remove ${host.name}. Fails if any repair jobs reference it.`}
confirmLabel="Delete"
destructive
pending={deleteMutation.isPending}
onConfirm={() => deleteMutation.mutate()}
/>
</div>
);
}
+49 -40
View File
@@ -1,10 +1,10 @@
import { useMemo, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import type { ColumnDef } from '@tanstack/react-table';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Edit, MoreHorizontal, Plus, Server, Trash2 } from 'lucide-react';
import { Edit, Eye, MoreHorizontal, Plus, Server, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import {
Badge,
Button,
DropdownMenu,
DropdownMenuContent,
@@ -15,6 +15,7 @@ import {
import { PageHeader } from '../components/layout/PageHeader.js';
import { DataTable } from '../components/data-table/DataTable.js';
import { HostFormDialog } from '../components/hosts/HostFormDialog.js';
import { HostStackBadge, HostStateBadge } from '../components/hosts/HostStateBadge.js';
import { ConfirmDialog } from '../components/ConfirmDialog.js';
import { deleteHost, listHosts } from '../lib/api/hosts.js';
import { ApiRequestError } from '../lib/api/client.js';
@@ -26,6 +27,7 @@ export default function Hosts() {
const { user } = useAuth();
const isAdmin = user?.role === 'ADMIN';
const queryClient = useQueryClient();
const navigate = useNavigate();
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<Host | null>(null);
@@ -48,32 +50,32 @@ export default function Hosts() {
accessorKey: 'assetId',
header: 'Asset ID',
cell: ({ row }) => (
<span className="font-mono text-xs font-medium">{row.original.assetId}</span>
<Link
to={`/hosts/${row.original.id}`}
className="font-mono text-xs font-medium hover:underline"
>
{row.original.assetId}
</Link>
),
},
{
accessorKey: 'name',
header: 'Name',
cell: ({ row }) => <span className="font-medium">{row.original.name}</span>,
cell: ({ row }) => (
<Link to={`/hosts/${row.original.id}`} className="font-medium hover:underline">
{row.original.name}
</Link>
),
},
{
accessorKey: 'state',
header: 'State',
cell: ({ row }) => {
const s = row.original.state;
const variant =
s === 'DEPLOYED' ? 'secondary' : s === 'DEGRADED' ? 'destructive' : 'outline';
return <Badge variant={variant}>{s}</Badge>;
},
cell: ({ row }) => <HostStateBadge state={row.original.state} />,
},
{
accessorKey: 'stack',
header: 'Stack',
cell: ({ row }) => (
<Badge variant="outline" className="text-xs">
{row.original.stack}
</Badge>
),
cell: ({ row }) => <HostStackBadge stack={row.original.stack} />,
},
{
accessorKey: 'location',
@@ -97,33 +99,40 @@ export default function Hosts() {
id: 'actions',
header: () => <span className="sr-only">Actions</span>,
size: 40,
cell: ({ row }) =>
isAdmin ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-36">
<DropdownMenuItem onSelect={() => setEditing(row.original)}>
<Edit className="h-3.5 w-3.5" />
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => setDeleting(row.original)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null,
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-36">
<DropdownMenuItem onSelect={() => navigate(`/hosts/${row.original.id}`)}>
<Eye className="h-3.5 w-3.5" />
View
</DropdownMenuItem>
{isAdmin && (
<>
<DropdownMenuItem onSelect={() => setEditing(row.original)}>
<Edit className="h-3.5 w-3.5" />
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => setDeleting(row.original)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
),
},
],
[isAdmin],
[isAdmin, navigate],
);
return (