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 };
}
+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 (
@@ -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");
+18
View File
@@ -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
+8
View File
@@ -33,6 +33,14 @@ export const PartEventType = z.enum([
]);
export type PartEventType = z.infer<typeof PartEventType>;
export const HostEventType = z.enum([
'CREATED',
'STATE_CHANGED',
'STACK_CHANGED',
'FIELD_UPDATED',
]);
export type HostEventType = z.infer<typeof HostEventType>;
export const FmStatus = z.enum(['OPEN', 'CLOSED']);
export type FmStatus = z.infer<typeof FmStatus>;
+5
View File
@@ -0,0 +1,5 @@
import { z } from 'zod';
import { PaginationQuery } from './pagination.js';
export const HostTimelineQuery = PaginationQuery;
export type HostTimelineQuery = z.infer<typeof HostTimelineQuery>;
+1
View File
@@ -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';