From b0e9c5d1d0537e4d257f6c8674a6b2ebfc9a5427 Mon Sep 17 00:00:00 2001 From: josh Date: Fri, 17 Apr 2026 14:04:07 -0400 Subject: [PATCH] feat: host detail page + FM host context 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 --- apps/api/src/controllers/hosts.ts | 21 +- apps/api/src/routes/hosts.ts | 2 + apps/api/src/services/hosts.test.ts | 288 ++++++++++++++++-- apps/api/src/services/hosts.ts | 279 ++++++++++++++++- apps/web/src/App.tsx | 2 + .../src/components/hosts/HostStateBadge.tsx | 20 ++ .../web/src/components/hosts/HostTimeline.tsx | 234 ++++++++++++++ apps/web/src/lib/api/hosts.ts | 6 +- apps/web/src/lib/api/types.ts | 40 +++ apps/web/src/lib/queryKeys.ts | 2 + apps/web/src/pages/FmDetail.tsx | 14 +- apps/web/src/pages/Fms.tsx | 26 +- apps/web/src/pages/HostDetail.tsx | 245 +++++++++++++++ apps/web/src/pages/Hosts.tsx | 89 +++--- .../migration.sql | 19 ++ packages/db/prisma/schema.prisma | 18 ++ packages/shared/src/enums.ts | 8 + packages/shared/src/host-events.ts | 5 + packages/shared/src/index.ts | 1 + 19 files changed, 1228 insertions(+), 91 deletions(-) create mode 100644 apps/web/src/components/hosts/HostStateBadge.tsx create mode 100644 apps/web/src/components/hosts/HostTimeline.tsx create mode 100644 apps/web/src/pages/HostDetail.tsx create mode 100644 packages/db/prisma/migrations/20260417175214_add_host_event/migration.sql create mode 100644 packages/shared/src/host-events.ts diff --git a/apps/api/src/controllers/hosts.ts b/apps/api/src/controllers/hosts.ts index be8e229..a9150c8 100644 --- a/apps/api/src/controllers/hosts.ts +++ b/apps/api/src/controllers/hosts.ts @@ -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)); diff --git a/apps/api/src/routes/hosts.ts b/apps/api/src/routes/hosts.ts index f5c16fc..2dc579d 100644 --- a/apps/api/src/routes/hosts.ts +++ b/apps/api/src/routes/hosts.ts @@ -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); diff --git a/apps/api/src/services/hosts.test.ts b/apps/api/src/services/hosts.test.ts index 94fb7ee..c305ca4 100644 --- a/apps/api/src/services/hosts.test.ts +++ b/apps/api/src/services/hosts.test.ts @@ -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 }) => { 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']); + }); +}); diff --git a/apps/api/src/services/hosts.ts b/apps/api/src/services/hosts.ts index f34b094..56c257d 100644 --- a/apps/api/src/services/hosts.ts +++ b/apps/api/src/services/hosts.ts @@ -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 }; +} + diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 6a6b471..6f21d0f 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -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() { } /> } /> } /> + } /> = { + DEPLOYED: 'secondary', + DEGRADED: 'destructive', + TESTING: 'outline', +}; + +export function HostStateBadge({ state }: { state: HostState }) { + return {state}; +} + +export function HostStackBadge({ stack }: { stack: HostStack }) { + return ( + + {stack} + + ); +} diff --git a/apps/web/src/components/hosts/HostTimeline.tsx b/apps/web/src/components/hosts/HostTimeline.tsx new file mode 100644 index 0000000..e4dc599 --- /dev/null +++ b/apps/web/src/components/hosts/HostTimeline.tsx @@ -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 = { + HOST_EVENT: Pencil, + FM_OPENED: Wrench, + FM_CLOSED: Wrench, + REPAIR: ArrowRightLeft, + PART_ARRIVED: LogIn, + PART_DEPARTED: LogOut, +}; + +const HOST_EVENT_TITLE: Record = { + 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 ( + <> +
+ + {HOST_EVENT_TITLE[hostEvent.type] ?? hostEvent.type} + + {hostEvent.field && ( + · {hostEvent.field} + )} + {(hostEvent.oldValue || hostEvent.newValue) && ( + + {hostEvent.oldValue ?? '—'} + + {hostEvent.newValue ?? '—'} + + )} +
+
+ {formatWhen(entry.at)} + {hostEvent.user?.username ? ` · ${hostEvent.user.username}` : ''} +
+ + ); + } + case 'FM_OPENED': + case 'FM_CLOSED': { + const { fm } = entry; + const label = entry.type === 'FM_OPENED' ? 'FM opened' : 'FM closed'; + return ( + <> +
+ {label} + + {fm.problem} + +
+
{formatWhen(entry.at)}
+ + ); + } + case 'REPAIR': { + const { repair } = entry; + return ( + <> +
+ Repair + + + {repair.brokenPart.serialNumber} + + → BROKEN + · + + {repair.replacement.serialNumber} + + → DEPLOYED + +
+
+ {formatWhen(entry.at)} + {repair.performedBy?.username ? ` · ${repair.performedBy.username}` : ''} +
+ + ); + } + case 'PART_ARRIVED': + case 'PART_DEPARTED': { + const { part } = entry; + const label = entry.type === 'PART_ARRIVED' ? 'Part arrived' : 'Part departed'; + return ( + <> +
+ {label} + + {part.serialNumber} + + · {part.mpn} +
+
{formatWhen(entry.at)}
+ + ); + } + } +} + +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 ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ ); + } + + if (query.isError) { + return

Could not load history.

; + } + + 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

No activity yet.

; + } + + return ( +
+
    + {entries.map((entry) => { + const Icon = ENTRY_ICON[entry.type]; + return ( +
  1. + + + + +
  2. + ); + })} +
+ + {pageCount > 1 && ( +
+ + Page {page} of {pageCount} + + + +
+ )} +
+ ); +} diff --git a/apps/web/src/lib/api/hosts.ts b/apps/web/src/lib/api/hosts.ts index 7949311..03e4a1b 100644 --- a/apps/web/src/lib/api/hosts.ts +++ b/apps/web/src/lib/api/hosts.ts @@ -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 { return res.data; } +export function listHostTimeline(id: string, filters: { page?: number; pageSize?: number } = {}) { + return getList(`/hosts/${id}/timeline`, filters); +} + export async function createHost(input: CreateHostRequest): Promise { const res = await api.post('/hosts', input); return res.data; diff --git a/apps/web/src/lib/api/types.ts b/apps/web/src/lib/api/types.ts index 2d00adc..f00bacb 100644 --- a/apps/web/src/lib/api/types.ts +++ b/apps/web/src/lib/api/types.ts @@ -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; diff --git a/apps/web/src/lib/queryKeys.ts b/apps/web/src/lib/queryKeys.ts index 3357ebf..74f475a 100644 --- a/apps/web/src/lib/queryKeys.ts +++ b/apps/web/src/lib/queryKeys.ts @@ -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) => + [...queryKeys.hosts.all, 'timeline', id, filters ?? {}] as const, }, fms: { all: ['fms'] as const, diff --git a/apps/web/src/pages/FmDetail.tsx b/apps/web/src/pages/FmDetail.tsx index 53902af..26f7878 100644 --- a/apps/web/src/pages/FmDetail.tsx +++ b/apps/web/src/pages/FmDetail.tsx @@ -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() {
Asset - + {fm.host.assetId} - + + + {closed ? 'Closed' : 'Open'}
- {fm.host.name} + + {fm.host.name} + {fm.host.location && · {fm.host.location}}
diff --git a/apps/web/src/pages/Fms.tsx b/apps/web/src/pages/Fms.tsx index 1f22209..16cc0e4 100644 --- a/apps/web/src/pages/Fms.tsx +++ b/apps/web/src/pages/Fms.tsx @@ -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 }) => ( - - {row.original.host.assetId} - +
+ + {row.original.host.assetId} + + + +
), }, { id: 'host', header: 'Host', - cell: ({ row }) => {row.original.host.name}, + cell: ({ row }) => ( + + {row.original.host.name} + + ), }, { id: 'problem', diff --git a/apps/web/src/pages/HostDetail.tsx b/apps/web/src/pages/HostDetail.tsx new file mode 100644 index 0000000..85ef375 --- /dev/null +++ b/apps/web/src/pages/HostDetail.tsx @@ -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 ( +
+
{label}
+
{value}
+
+ ); +} + +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 ( +
+ + + +
+ ); + } + + if (isError || !host) { + const msg = error instanceof ApiRequestError ? error.body.message : 'Host not found.'; + return ( + + + Host unavailable + {msg} + + + + + + ); + } + + const deployedParts = deployedPartsQuery.data ?? []; + + return ( +
+
+
+ +
+

{host.name}

+

+ {host.assetId} + {host.location ? ` · ${host.location}` : ''} +

+
+
+
+ + + + {isAdmin && ( + + )} +
+
+ +
+ + + Summary + + +
+ {host.assetId}} + /> + + } /> + } /> + — + } + /> + + + +
+ {host.notes && ( + <> + +
+

Notes

+

{host.notes}

+
+ + )} +
+
+ + + + History + + FMs, repairs, part swaps, and host field changes. + + + + + + +
+ + + + Deployed parts + Parts currently installed on this host. + + + {deployedPartsQuery.isPending ? ( + + ) : deployedParts.length === 0 ? ( +

No parts deployed here.

+ ) : ( +
+ + + + + + + + + + + {deployedParts.map((p) => ( + + + + + + + ))} + +
SerialMPNManufacturerState
+ + {p.serialNumber} + + {p.partModel.mpn} + {p.manufacturer.name} + + +
+
+ )} +
+
+ + + deleteMutation.mutate()} + /> +
+ ); +} diff --git a/apps/web/src/pages/Hosts.tsx b/apps/web/src/pages/Hosts.tsx index a2cf965..1592046 100644 --- a/apps/web/src/pages/Hosts.tsx +++ b/apps/web/src/pages/Hosts.tsx @@ -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(null); @@ -48,32 +50,32 @@ export default function Hosts() { accessorKey: 'assetId', header: 'Asset ID', cell: ({ row }) => ( - {row.original.assetId} + + {row.original.assetId} + ), }, { accessorKey: 'name', header: 'Name', - cell: ({ row }) => {row.original.name}, + cell: ({ row }) => ( + + {row.original.name} + + ), }, { accessorKey: 'state', header: 'State', - cell: ({ row }) => { - const s = row.original.state; - const variant = - s === 'DEPLOYED' ? 'secondary' : s === 'DEGRADED' ? 'destructive' : 'outline'; - return {s}; - }, + cell: ({ row }) => , }, { accessorKey: 'stack', header: 'Stack', - cell: ({ row }) => ( - - {row.original.stack} - - ), + cell: ({ row }) => , }, { accessorKey: 'location', @@ -97,33 +99,40 @@ export default function Hosts() { id: 'actions', header: () => Actions, size: 40, - cell: ({ row }) => - isAdmin ? ( - - - - - - setEditing(row.original)}> - - Edit - - - setDeleting(row.original)} - className="text-destructive focus:text-destructive" - > - - Delete - - - - ) : null, + cell: ({ row }) => ( + + + + + + navigate(`/hosts/${row.original.id}`)}> + + View + + {isAdmin && ( + <> + setEditing(row.original)}> + + Edit + + + setDeleting(row.original)} + className="text-destructive focus:text-destructive" + > + + Delete + + + )} + + + ), }, ], - [isAdmin], + [isAdmin, navigate], ); return ( diff --git a/packages/db/prisma/migrations/20260417175214_add_host_event/migration.sql b/packages/db/prisma/migrations/20260417175214_add_host_event/migration.sql new file mode 100644 index 0000000..72d228a --- /dev/null +++ b/packages/db/prisma/migrations/20260417175214_add_host_event/migration.sql @@ -0,0 +1,19 @@ +-- CreateTable +CREATE TABLE "HostEvent" ( + "id" TEXT NOT NULL PRIMARY KEY, + "hostId" TEXT NOT NULL, + "userId" TEXT, + "type" TEXT NOT NULL, + "field" TEXT, + "oldValue" TEXT, + "newValue" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "HostEvent_hostId_fkey" FOREIGN KEY ("hostId") REFERENCES "Host" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "HostEvent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); + +-- CreateIndex +CREATE INDEX "HostEvent_hostId_createdAt_idx" ON "HostEvent"("hostId", "createdAt" DESC); + +-- CreateIndex +CREATE INDEX "HostEvent_userId_idx" ON "HostEvent"("userId"); diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 2f3bfbd..55a9bc5 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -24,6 +24,7 @@ model User { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt partEvents PartEvent[] + hostEvents HostEvent[] refreshTokens RefreshToken[] custodyParts Part[] @relation("Custody") repairs Repair[] @@ -198,11 +199,28 @@ model Host { parts Part[] fms Fm[] repairs Repair[] + events HostEvent[] @@index([state]) @@index([stack]) } +model HostEvent { + id String @id @default(uuid()) + hostId String + host Host @relation(fields: [hostId], references: [id], onDelete: Cascade) + userId String? + user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + type String + field String? + oldValue String? + newValue String? + createdAt DateTime @default(now()) + + @@index([hostId, createdAt(sort: Desc)]) + @@index([userId]) +} + model Fm { id String @id @default(uuid()) hostId String diff --git a/packages/shared/src/enums.ts b/packages/shared/src/enums.ts index 0de753a..adaa1c1 100644 --- a/packages/shared/src/enums.ts +++ b/packages/shared/src/enums.ts @@ -33,6 +33,14 @@ export const PartEventType = z.enum([ ]); export type PartEventType = z.infer; +export const HostEventType = z.enum([ + 'CREATED', + 'STATE_CHANGED', + 'STACK_CHANGED', + 'FIELD_UPDATED', +]); +export type HostEventType = z.infer; + export const FmStatus = z.enum(['OPEN', 'CLOSED']); export type FmStatus = z.infer; diff --git a/packages/shared/src/host-events.ts b/packages/shared/src/host-events.ts new file mode 100644 index 0000000..bd85d08 --- /dev/null +++ b/packages/shared/src/host-events.ts @@ -0,0 +1,5 @@ +import { z } from 'zod'; +import { PaginationQuery } from './pagination.js'; + +export const HostTimelineQuery = PaginationQuery; +export type HostTimelineQuery = z.infer; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 7e52ea8..359f474 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -8,6 +8,7 @@ export * from './parts.js'; export * from './env.js'; export * from './pagination.js'; export * from './hosts.js'; +export * from './host-events.js'; export * from './fms.js'; export * from './repairs.js'; export * from './custody.js';