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
+19 -2
View File
@@ -3,6 +3,7 @@ import { prisma } from '@vector/db';
import type {
CreateHostRequest,
HostListQuery,
HostTimelineQuery,
UpdateHostRequest,
} from '@vector/shared';
import * as svc from '../services/hosts.js';
@@ -31,7 +32,7 @@ export async function get(req: Request<{ id: string }>, res: Response, next: Nex
export async function create(req: Request, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as CreateHostRequest;
const host = await prisma.$transaction((tx) => svc.create(tx, input));
const host = await prisma.$transaction((tx) => svc.create(tx, input, req.user ?? null));
res.status(201).json(host);
} catch (err) {
next(err);
@@ -41,7 +42,9 @@ export async function create(req: Request, res: Response, next: NextFunction) {
export async function update(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as UpdateHostRequest;
const host = await prisma.$transaction((tx) => svc.update(tx, req.params.id, input));
const host = await prisma.$transaction((tx) =>
svc.update(tx, req.params.id, input, req.user ?? null),
);
res.json(host);
} catch (err) {
next(err);
@@ -63,6 +66,20 @@ export async function listDeployedParts(
}
}
export async function getTimeline(
req: Request<{ id: string }>,
res: Response,
next: NextFunction,
) {
try {
const q = req.validated!.query as HostTimelineQuery;
const result = await prisma.$transaction((tx) => svc.getTimeline(tx, req.params.id, q));
res.json(result);
} catch (err) {
next(err);
}
}
export async function remove(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
await prisma.$transaction((tx) => svc.remove(tx, req.params.id));
+2
View File
@@ -2,6 +2,7 @@ import { Router } from 'express';
import {
CreateHostRequest,
HostListQuery,
HostTimelineQuery,
UpdateHostRequest,
} from '@vector/shared';
import * as ctrl from '../controllers/hosts.js';
@@ -14,6 +15,7 @@ router.get('/', requireAuth, validate('query', HostListQuery), ctrl.list);
router.post('/', requireAuth, requireRole('ADMIN'), validate('body', CreateHostRequest), ctrl.create);
router.get('/:id', requireAuth, ctrl.get);
router.get('/:id/deployed-parts', requireAuth, ctrl.listDeployedParts);
router.get('/:id/timeline', requireAuth, validate('query', HostTimelineQuery), ctrl.getTimeline);
router.patch('/:id', requireAuth, requireRole('ADMIN'), validate('body', UpdateHostRequest), ctrl.update);
router.delete('/:id', requireAuth, requireRole('ADMIN'), ctrl.remove);
+255 -33
View File
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from 'vitest';
import type { Tx } from './types.js';
import { create, update } from './hosts.js';
import type { Actor, Tx } from './types.js';
import { create, getTimeline, update } from './hosts.js';
interface HostRow {
id: string;
@@ -12,8 +12,20 @@ interface HostRow {
stack: string;
}
interface HostEventRow {
hostId: string;
userId: string | null;
type: string;
field: string | null;
oldValue: string | null;
newValue: string | null;
}
const ACTOR: Actor = { id: 'user-1', username: 'admin', role: 'ADMIN' };
function buildTx(seed: HostRow[] = []) {
const registry = new Map(seed.map((h) => [h.id, h]));
const hostEvents: HostEventRow[] = [];
const tx = {
host: {
@@ -30,6 +42,10 @@ function buildTx(seed: HostRow[] = []) {
registry.set(row.id, row);
return row;
}),
findUnique: vi.fn(async (args: { where: { id: string } }) => {
const row = registry.get(args.where.id);
return row ? { ...row } : null;
}),
update: vi.fn(async (args: { where: { id: string }; data: Record<string, unknown> }) => {
const current = registry.get(args.where.id);
if (!current) throw new Error(`No host ${args.where.id}`);
@@ -43,30 +59,68 @@ function buildTx(seed: HostRow[] = []) {
return current;
}),
},
hostEvent: {
create: vi.fn(async (args: { data: HostEventRow }) => {
hostEvents.push({
hostId: args.data.hostId,
userId: args.data.userId ?? null,
type: args.data.type,
field: args.data.field ?? null,
oldValue: args.data.oldValue ?? null,
newValue: args.data.newValue ?? null,
});
return args.data;
}),
createMany: vi.fn(async (args: { data: HostEventRow[] }) => {
for (const row of args.data) {
hostEvents.push({
hostId: row.hostId,
userId: row.userId ?? null,
type: row.type,
field: row.field ?? null,
oldValue: row.oldValue ?? null,
newValue: row.newValue ?? null,
});
}
return { count: args.data.length };
}),
},
} as unknown as Tx;
return { tx, registry };
return { tx, registry, hostEvents };
}
describe('hosts.create', () => {
it('defaults state=DEPLOYED and stack=PRODUCTION when not supplied', async () => {
const { tx } = buildTx();
const host = await create(tx, { assetId: 'A-1', name: 'rack-1' });
const host = await create(tx, { assetId: 'A-1', name: 'rack-1' }, ACTOR);
expect(host.state).toBe('DEPLOYED');
expect(host.stack).toBe('PRODUCTION');
});
it('emits a CREATED HostEvent', async () => {
const { tx, hostEvents } = buildTx();
await create(tx, { assetId: 'A-1', name: 'rack-1' }, ACTOR);
expect(hostEvents).toHaveLength(1);
expect(hostEvents[0]).toMatchObject({
type: 'CREATED',
newValue: 'A-1',
userId: 'user-1',
});
});
it('persists explicit state and stack', async () => {
const { tx } = buildTx();
const host = await create(tx, {
assetId: 'A-2',
name: 'rack-2',
state: 'TESTING',
stack: 'VETTING',
});
const host = await create(
tx,
{ assetId: 'A-2', name: 'rack-2', state: 'TESTING', stack: 'VETTING' },
ACTOR,
);
expect(host.state).toBe('TESTING');
expect(host.stack).toBe('VETTING');
@@ -74,40 +128,61 @@ describe('hosts.create', () => {
});
describe('hosts.update', () => {
it('updates state and stack when provided', async () => {
const { tx, registry } = buildTx([
{
id: 'host-1',
assetId: 'A-1',
name: 'rack-1',
location: null,
notes: null,
state: 'DEPLOYED',
stack: 'PRODUCTION',
},
]);
const seedHost: HostRow = {
id: 'host-1',
assetId: 'A-1',
name: 'rack-1',
location: null,
notes: null,
state: 'DEPLOYED',
stack: 'PRODUCTION',
};
await update(tx, 'host-1', { state: 'DEGRADED', stack: 'VETTING' });
it('updates state and stack when provided', async () => {
const { tx, registry } = buildTx([{ ...seedHost }]);
await update(tx, 'host-1', { state: 'DEGRADED', stack: 'VETTING' }, ACTOR);
const row = registry.get('host-1')!;
expect(row.state).toBe('DEGRADED');
expect(row.stack).toBe('VETTING');
});
it('emits one HostEvent per changed field', async () => {
const { tx, hostEvents } = buildTx([{ ...seedHost }]);
await update(
tx,
'host-1',
{ state: 'DEGRADED', stack: 'VETTING', name: 'rack-renamed' },
ACTOR,
);
const types = hostEvents.map((e) => ({ type: e.type, field: e.field }));
expect(types).toEqual(
expect.arrayContaining([
{ type: 'STATE_CHANGED', field: 'state' },
{ type: 'STACK_CHANGED', field: 'stack' },
{ type: 'FIELD_UPDATED', field: 'name' },
]),
);
expect(hostEvents).toHaveLength(3);
});
it('emits no HostEvent when values match current', async () => {
const { tx, hostEvents } = buildTx([{ ...seedHost }]);
await update(tx, 'host-1', { state: 'DEPLOYED', stack: 'PRODUCTION' }, ACTOR);
expect(hostEvents).toHaveLength(0);
});
it('leaves state/stack untouched when not provided', async () => {
const { tx, registry } = buildTx([
{
id: 'host-1',
assetId: 'A-1',
name: 'rack-1',
location: null,
notes: null,
state: 'TESTING',
stack: 'VETTING',
},
{ ...seedHost, state: 'TESTING', stack: 'VETTING' },
]);
await update(tx, 'host-1', { name: 'rack-1-renamed' });
await update(tx, 'host-1', { name: 'rack-1-renamed' }, ACTOR);
const row = registry.get('host-1')!;
expect(row.state).toBe('TESTING');
@@ -115,3 +190,150 @@ describe('hosts.update', () => {
expect(row.name).toBe('rack-1-renamed');
});
});
describe('hosts.getTimeline', () => {
it('merges HostEvents, Fms, Repairs, PartEvents in reverse-chronological order', async () => {
const hostId = 'host-1';
const hostName = 'Vela';
const now = Date.now();
const t = (offsetMin: number) => new Date(now - offsetMin * 60_000);
const tx = {
host: {
findUnique: vi.fn(async () => ({ id: hostId, name: hostName })),
},
hostEvent: {
findMany: vi.fn(async () => [
{
id: 'he-1',
type: 'STATE_CHANGED',
field: 'state',
oldValue: 'DEPLOYED',
newValue: 'DEGRADED',
createdAt: t(10),
user: { username: 'alice' },
},
]),
},
fm: {
findMany: vi.fn(async () => [
{
id: 'fm-1',
status: 'OPEN',
problem: 'bad disk',
openedAt: t(30),
closedAt: null,
},
]),
},
repair: {
findMany: vi.fn(async () => [
{
id: 'r-1',
performedAt: t(20),
brokenPart: {
id: 'p-1',
serialNumber: 'CPU1',
partModel: { mpn: 'MPN-A' },
},
replacement: {
id: 'p-2',
serialNumber: 'CPU2',
partModel: { mpn: 'MPN-A' },
},
performedBy: { username: 'bob' },
},
]),
},
partEvent: {
findMany: vi.fn(async () => []),
},
} as unknown as Tx;
const result = await getTimeline(tx, hostId, { page: 1, pageSize: 20 });
expect(result.total).toBe(3);
expect(result.data.map((e) => e.type)).toEqual([
'HOST_EVENT',
'REPAIR',
'FM_OPENED',
]);
});
it('emits FM_CLOSED only when closedAt is set', async () => {
const hostId = 'host-1';
const hostName = 'Vela';
const now = Date.now();
const tx = {
host: {
findUnique: vi.fn(async () => ({ id: hostId, name: hostName })),
},
hostEvent: { findMany: vi.fn(async () => []) },
fm: {
findMany: vi.fn(async () => [
{
id: 'fm-1',
status: 'CLOSED',
problem: 'p',
openedAt: new Date(now - 60_000),
closedAt: new Date(now - 30_000),
},
]),
},
repair: { findMany: vi.fn(async () => []) },
partEvent: { findMany: vi.fn(async () => []) },
} as unknown as Tx;
const result = await getTimeline(tx, hostId, { page: 1, pageSize: 20 });
expect(result.total).toBe(2);
expect(result.data.map((e) => e.type)).toEqual(['FM_CLOSED', 'FM_OPENED']);
});
it('classifies PartEvents as ARRIVED/DEPARTED by host name match', async () => {
const hostId = 'host-1';
const hostName = 'Vela';
const now = Date.now();
const tx = {
host: {
findUnique: vi.fn(async () => ({ id: hostId, name: hostName })),
},
hostEvent: { findMany: vi.fn(async () => []) },
fm: { findMany: vi.fn(async () => []) },
repair: { findMany: vi.fn(async () => []) },
partEvent: {
findMany: vi.fn(async () => [
{
id: 'pe-1',
createdAt: new Date(now - 60_000),
oldValue: null,
newValue: hostName,
part: {
id: 'p-1',
serialNumber: 'CPU1',
partModel: { mpn: 'MPN-A' },
},
},
{
id: 'pe-2',
createdAt: new Date(now - 30_000),
oldValue: hostName,
newValue: null,
part: {
id: 'p-1',
serialNumber: 'CPU1',
partModel: { mpn: 'MPN-A' },
},
},
]),
},
} as unknown as Tx;
const result = await getTimeline(tx, hostId, { page: 1, pageSize: 20 });
expect(result.data.map((e) => e.type)).toEqual(['PART_DEPARTED', 'PART_ARRIVED']);
});
});
+274 -5
View File
@@ -2,10 +2,11 @@ import { Prisma } from '@vector/db';
import type {
CreateHostRequest,
HostListQuery,
HostTimelineQuery,
UpdateHostRequest,
} from '@vector/shared';
import { errors } from '../lib/http-error.js';
import type { Tx } from './types.js';
import type { Actor, Tx } from './types.js';
function mapUniqueViolation(target: unknown): string {
if (Array.isArray(target) && target.includes('assetId')) return 'Asset ID already in use';
@@ -48,9 +49,10 @@ export function listDeployedParts(tx: Tx, hostId: string) {
});
}
export async function create(tx: Tx, input: CreateHostRequest) {
export async function create(tx: Tx, input: CreateHostRequest, actor: Actor | null) {
let host;
try {
return await tx.host.create({
host = await tx.host.create({
data: {
assetId: input.assetId,
name: input.name,
@@ -66,9 +68,28 @@ export async function create(tx: Tx, input: CreateHostRequest) {
}
throw err;
}
await tx.hostEvent.create({
data: {
hostId: host.id,
userId: actor?.id ?? null,
type: 'CREATED',
newValue: host.assetId,
},
});
return host;
}
export async function update(tx: Tx, id: string, input: UpdateHostRequest) {
export async function update(
tx: Tx,
id: string,
input: UpdateHostRequest,
actor: Actor | null,
) {
const current = await tx.host.findUnique({ where: { id } });
if (!current) throw errors.notFound('Host');
const data: Prisma.HostUpdateInput = {};
if (input.assetId !== undefined) data.assetId = input.assetId;
if (input.name !== undefined) data.name = input.name;
@@ -76,8 +97,10 @@ export async function update(tx: Tx, id: string, input: UpdateHostRequest) {
if (input.notes !== undefined) data.notes = input.notes;
if (input.state !== undefined) data.state = input.state;
if (input.stack !== undefined) data.stack = input.stack;
let host;
try {
return await tx.host.update({ where: { id }, data });
host = await tx.host.update({ where: { id }, data });
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError) {
if (err.code === 'P2025') throw errors.notFound('Host');
@@ -85,6 +108,74 @@ export async function update(tx: Tx, id: string, input: UpdateHostRequest) {
}
throw err;
}
const userId = actor?.id ?? null;
const events: Prisma.HostEventCreateManyInput[] = [];
if (input.state !== undefined && input.state !== current.state) {
events.push({
hostId: host.id,
userId,
type: 'STATE_CHANGED',
field: 'state',
oldValue: current.state,
newValue: host.state,
});
}
if (input.stack !== undefined && input.stack !== current.stack) {
events.push({
hostId: host.id,
userId,
type: 'STACK_CHANGED',
field: 'stack',
oldValue: current.stack,
newValue: host.stack,
});
}
if (input.assetId !== undefined && input.assetId !== current.assetId) {
events.push({
hostId: host.id,
userId,
type: 'FIELD_UPDATED',
field: 'assetId',
oldValue: current.assetId,
newValue: host.assetId,
});
}
if (input.name !== undefined && input.name !== current.name) {
events.push({
hostId: host.id,
userId,
type: 'FIELD_UPDATED',
field: 'name',
oldValue: current.name,
newValue: host.name,
});
}
if (input.location !== undefined && (input.location ?? null) !== (current.location ?? null)) {
events.push({
hostId: host.id,
userId,
type: 'FIELD_UPDATED',
field: 'location',
oldValue: current.location ?? null,
newValue: host.location ?? null,
});
}
if (input.notes !== undefined && (input.notes ?? null) !== (current.notes ?? null)) {
events.push({
hostId: host.id,
userId,
type: 'FIELD_UPDATED',
field: 'notes',
oldValue: current.notes ?? null,
newValue: host.notes ?? null,
});
}
if (events.length > 0) await tx.hostEvent.createMany({ data: events });
return host;
}
export async function remove(tx: Tx, id: string) {
@@ -98,3 +189,181 @@ export async function remove(tx: Tx, id: string) {
throw err;
}
}
// Unified host timeline. Merges four sources:
// - HostEvents (state/stack/field changes on the host)
// - Fms (FM_OPENED at openedAt, FM_CLOSED at closedAt when present)
// - Repairs on this host (captures broken/replacement part swaps)
// - PartEvents where a part's host field changed to or from this host
// (covers ad-hoc arrivals/departures outside the repair flow).
//
// The four sources are merged in memory and paginated after the sort; the resulting page
// will be small because we cap each source fetch at a safe upper bound. This avoids the
// complexity of a UNION query while still giving correct reverse-chronological ordering.
export type HostTimelineEntry =
| { type: 'HOST_EVENT'; at: Date; hostEvent: HostEventPayload }
| { type: 'FM_OPENED'; at: Date; fm: FmSummary }
| { type: 'FM_CLOSED'; at: Date; fm: FmSummary }
| { type: 'REPAIR'; at: Date; repair: RepairSummary }
| { type: 'PART_ARRIVED'; at: Date; part: PartRef; partEventId: string }
| { type: 'PART_DEPARTED'; at: Date; part: PartRef; partEventId: string };
interface HostEventPayload {
id: string;
type: string;
field: string | null;
oldValue: string | null;
newValue: string | null;
createdAt: Date;
user: { username: string } | null;
}
interface FmSummary {
id: string;
status: string;
problem: string;
openedAt: Date;
closedAt: Date | null;
}
interface RepairSummary {
id: string;
performedAt: Date;
brokenPart: { id: string; serialNumber: string; mpn: string };
replacement: { id: string; serialNumber: string; mpn: string };
performedBy: { username: string } | null;
}
interface PartRef {
id: string;
serialNumber: string;
mpn: string;
}
const TIMELINE_SOURCE_CAP = 500;
export async function getTimeline(tx: Tx, hostId: string, q: HostTimelineQuery) {
const { page, pageSize } = q;
const host = await tx.host.findUnique({
where: { id: hostId },
select: { id: true, name: true },
});
if (!host) throw errors.notFound('Host');
// PartEvent stores the host's name in oldValue/newValue (see parts.ts), not the id.
const [hostEvents, fms, repairs, partEventRows] = await Promise.all([
tx.hostEvent.findMany({
where: { hostId },
orderBy: { createdAt: 'desc' },
take: TIMELINE_SOURCE_CAP,
include: { user: { select: { username: true } } },
}),
tx.fm.findMany({
where: { hostId },
orderBy: { openedAt: 'desc' },
take: TIMELINE_SOURCE_CAP,
}),
tx.repair.findMany({
where: { hostId },
orderBy: { performedAt: 'desc' },
take: TIMELINE_SOURCE_CAP,
include: {
brokenPart: { include: { partModel: { select: { mpn: true } } } },
replacement: { include: { partModel: { select: { mpn: true } } } },
performedBy: { select: { username: true } },
},
}),
tx.partEvent.findMany({
where: {
type: 'LOCATION_CHANGED',
field: 'host',
OR: [{ oldValue: host.name }, { newValue: host.name }],
},
orderBy: { createdAt: 'desc' },
take: TIMELINE_SOURCE_CAP,
include: {
part: {
select: { id: true, serialNumber: true, partModel: { select: { mpn: true } } },
},
},
}),
]);
const entries: HostTimelineEntry[] = [];
for (const e of hostEvents) {
entries.push({
type: 'HOST_EVENT',
at: e.createdAt,
hostEvent: {
id: e.id,
type: e.type,
field: e.field,
oldValue: e.oldValue,
newValue: e.newValue,
createdAt: e.createdAt,
user: e.user,
},
});
}
for (const f of fms) {
const summary: FmSummary = {
id: f.id,
status: f.status,
problem: f.problem,
openedAt: f.openedAt,
closedAt: f.closedAt,
};
entries.push({ type: 'FM_OPENED', at: f.openedAt, fm: summary });
if (f.closedAt) entries.push({ type: 'FM_CLOSED', at: f.closedAt, fm: summary });
}
for (const r of repairs) {
entries.push({
type: 'REPAIR',
at: r.performedAt,
repair: {
id: r.id,
performedAt: r.performedAt,
brokenPart: {
id: r.brokenPart.id,
serialNumber: r.brokenPart.serialNumber,
mpn: r.brokenPart.partModel.mpn,
},
replacement: {
id: r.replacement.id,
serialNumber: r.replacement.serialNumber,
mpn: r.replacement.partModel.mpn,
},
performedBy: r.performedBy ? { username: r.performedBy.username } : null,
},
});
}
for (const pe of partEventRows) {
if (!pe.part) continue;
const partRef: PartRef = {
id: pe.part.id,
serialNumber: pe.part.serialNumber,
mpn: pe.part.partModel.mpn,
};
// newValue = this host's name → arrival; oldValue = this host's name → departure.
if (pe.newValue === host.name) {
entries.push({ type: 'PART_ARRIVED', at: pe.createdAt, part: partRef, partEventId: pe.id });
}
if (pe.oldValue === host.name) {
entries.push({
type: 'PART_DEPARTED',
at: pe.createdAt,
part: partRef,
partEventId: pe.id,
});
}
}
entries.sort((a, b) => b.at.getTime() - a.at.getTime());
const total = entries.length;
const start = (page - 1) * pageSize;
const data = entries.slice(start, start + pageSize);
return { data, page, pageSize, total };
}