Files
Vector/apps/web/src/components/hosts/HostTimeline.tsx
T
josh db8e86b749
CI / Lint · Typecheck · Test · Build (push) Failing after 36s
CI / Playwright (smoke) (push) Has been skipped
CI / Build & push images (push) Has been skipped
feat: remove FM feature from Vector
FMs move to a separate application. Drops Fm/FmPart tables + Repair.fmId
column, deletes FM_OPENED/FM_CLOSED PartEvent rows, strips FM enums +
webhook events + shared contracts, removes FM routes/services/pages/UI,
and collapses dashboard admin ops to Repairs 7d/30d + trend + custody
backlog.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 18:46:40 -04:00

211 lines
6.7 KiB
TypeScript

import { useState } from 'react';
import { Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import {
ArrowRight,
ArrowRightLeft,
LogIn,
LogOut,
Pencil,
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,
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 '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 deployed' : '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 '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>
);
}