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
+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,