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 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ import { prisma } from '@vector/db';
|
|||||||
import type {
|
import type {
|
||||||
CreateHostRequest,
|
CreateHostRequest,
|
||||||
HostListQuery,
|
HostListQuery,
|
||||||
|
HostTimelineQuery,
|
||||||
UpdateHostRequest,
|
UpdateHostRequest,
|
||||||
} from '@vector/shared';
|
} from '@vector/shared';
|
||||||
import * as svc from '../services/hosts.js';
|
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) {
|
export async function create(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
const input = req.validated!.body as CreateHostRequest;
|
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);
|
res.status(201).json(host);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(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) {
|
export async function update(req: Request<{ id: string }>, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
const input = req.validated!.body as UpdateHostRequest;
|
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);
|
res.json(host);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(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) {
|
export async function remove(req: Request<{ id: string }>, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
await prisma.$transaction((tx) => svc.remove(tx, req.params.id));
|
await prisma.$transaction((tx) => svc.remove(tx, req.params.id));
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Router } from 'express';
|
|||||||
import {
|
import {
|
||||||
CreateHostRequest,
|
CreateHostRequest,
|
||||||
HostListQuery,
|
HostListQuery,
|
||||||
|
HostTimelineQuery,
|
||||||
UpdateHostRequest,
|
UpdateHostRequest,
|
||||||
} from '@vector/shared';
|
} from '@vector/shared';
|
||||||
import * as ctrl from '../controllers/hosts.js';
|
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.post('/', requireAuth, requireRole('ADMIN'), validate('body', CreateHostRequest), ctrl.create);
|
||||||
router.get('/:id', requireAuth, ctrl.get);
|
router.get('/:id', requireAuth, ctrl.get);
|
||||||
router.get('/:id/deployed-parts', requireAuth, ctrl.listDeployedParts);
|
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.patch('/:id', requireAuth, requireRole('ADMIN'), validate('body', UpdateHostRequest), ctrl.update);
|
||||||
router.delete('/:id', requireAuth, requireRole('ADMIN'), ctrl.remove);
|
router.delete('/:id', requireAuth, requireRole('ADMIN'), ctrl.remove);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import type { Tx } from './types.js';
|
import type { Actor, Tx } from './types.js';
|
||||||
import { create, update } from './hosts.js';
|
import { create, getTimeline, update } from './hosts.js';
|
||||||
|
|
||||||
interface HostRow {
|
interface HostRow {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -12,8 +12,20 @@ interface HostRow {
|
|||||||
stack: string;
|
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[] = []) {
|
function buildTx(seed: HostRow[] = []) {
|
||||||
const registry = new Map(seed.map((h) => [h.id, h]));
|
const registry = new Map(seed.map((h) => [h.id, h]));
|
||||||
|
const hostEvents: HostEventRow[] = [];
|
||||||
|
|
||||||
const tx = {
|
const tx = {
|
||||||
host: {
|
host: {
|
||||||
@@ -30,6 +42,10 @@ function buildTx(seed: HostRow[] = []) {
|
|||||||
registry.set(row.id, row);
|
registry.set(row.id, row);
|
||||||
return 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> }) => {
|
update: vi.fn(async (args: { where: { id: string }; data: Record<string, unknown> }) => {
|
||||||
const current = registry.get(args.where.id);
|
const current = registry.get(args.where.id);
|
||||||
if (!current) throw new Error(`No host ${args.where.id}`);
|
if (!current) throw new Error(`No host ${args.where.id}`);
|
||||||
@@ -43,30 +59,68 @@ function buildTx(seed: HostRow[] = []) {
|
|||||||
return current;
|
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;
|
} as unknown as Tx;
|
||||||
|
|
||||||
return { tx, registry };
|
return { tx, registry, hostEvents };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('hosts.create', () => {
|
describe('hosts.create', () => {
|
||||||
it('defaults state=DEPLOYED and stack=PRODUCTION when not supplied', async () => {
|
it('defaults state=DEPLOYED and stack=PRODUCTION when not supplied', async () => {
|
||||||
const { tx } = buildTx();
|
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.state).toBe('DEPLOYED');
|
||||||
expect(host.stack).toBe('PRODUCTION');
|
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 () => {
|
it('persists explicit state and stack', async () => {
|
||||||
const { tx } = buildTx();
|
const { tx } = buildTx();
|
||||||
|
|
||||||
const host = await create(tx, {
|
const host = await create(
|
||||||
assetId: 'A-2',
|
tx,
|
||||||
name: 'rack-2',
|
{ assetId: 'A-2', name: 'rack-2', state: 'TESTING', stack: 'VETTING' },
|
||||||
state: 'TESTING',
|
ACTOR,
|
||||||
stack: 'VETTING',
|
);
|
||||||
});
|
|
||||||
|
|
||||||
expect(host.state).toBe('TESTING');
|
expect(host.state).toBe('TESTING');
|
||||||
expect(host.stack).toBe('VETTING');
|
expect(host.stack).toBe('VETTING');
|
||||||
@@ -74,40 +128,61 @@ describe('hosts.create', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('hosts.update', () => {
|
describe('hosts.update', () => {
|
||||||
it('updates state and stack when provided', async () => {
|
const seedHost: HostRow = {
|
||||||
const { tx, registry } = buildTx([
|
id: 'host-1',
|
||||||
{
|
assetId: 'A-1',
|
||||||
id: 'host-1',
|
name: 'rack-1',
|
||||||
assetId: 'A-1',
|
location: null,
|
||||||
name: 'rack-1',
|
notes: null,
|
||||||
location: null,
|
state: 'DEPLOYED',
|
||||||
notes: null,
|
stack: 'PRODUCTION',
|
||||||
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')!;
|
const row = registry.get('host-1')!;
|
||||||
expect(row.state).toBe('DEGRADED');
|
expect(row.state).toBe('DEGRADED');
|
||||||
expect(row.stack).toBe('VETTING');
|
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 () => {
|
it('leaves state/stack untouched when not provided', async () => {
|
||||||
const { tx, registry } = buildTx([
|
const { tx, registry } = buildTx([
|
||||||
{
|
{ ...seedHost, state: 'TESTING', stack: 'VETTING' },
|
||||||
id: 'host-1',
|
|
||||||
assetId: 'A-1',
|
|
||||||
name: 'rack-1',
|
|
||||||
location: null,
|
|
||||||
notes: null,
|
|
||||||
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')!;
|
const row = registry.get('host-1')!;
|
||||||
expect(row.state).toBe('TESTING');
|
expect(row.state).toBe('TESTING');
|
||||||
@@ -115,3 +190,150 @@ describe('hosts.update', () => {
|
|||||||
expect(row.name).toBe('rack-1-renamed');
|
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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ import { Prisma } from '@vector/db';
|
|||||||
import type {
|
import type {
|
||||||
CreateHostRequest,
|
CreateHostRequest,
|
||||||
HostListQuery,
|
HostListQuery,
|
||||||
|
HostTimelineQuery,
|
||||||
UpdateHostRequest,
|
UpdateHostRequest,
|
||||||
} from '@vector/shared';
|
} from '@vector/shared';
|
||||||
import { errors } from '../lib/http-error.js';
|
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 {
|
function mapUniqueViolation(target: unknown): string {
|
||||||
if (Array.isArray(target) && target.includes('assetId')) return 'Asset ID already in use';
|
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 {
|
try {
|
||||||
return await tx.host.create({
|
host = await tx.host.create({
|
||||||
data: {
|
data: {
|
||||||
assetId: input.assetId,
|
assetId: input.assetId,
|
||||||
name: input.name,
|
name: input.name,
|
||||||
@@ -66,9 +68,28 @@ export async function create(tx: Tx, input: CreateHostRequest) {
|
|||||||
}
|
}
|
||||||
throw err;
|
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 = {};
|
const data: Prisma.HostUpdateInput = {};
|
||||||
if (input.assetId !== undefined) data.assetId = input.assetId;
|
if (input.assetId !== undefined) data.assetId = input.assetId;
|
||||||
if (input.name !== undefined) data.name = input.name;
|
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.notes !== undefined) data.notes = input.notes;
|
||||||
if (input.state !== undefined) data.state = input.state;
|
if (input.state !== undefined) data.state = input.state;
|
||||||
if (input.stack !== undefined) data.stack = input.stack;
|
if (input.stack !== undefined) data.stack = input.stack;
|
||||||
|
|
||||||
|
let host;
|
||||||
try {
|
try {
|
||||||
return await tx.host.update({ where: { id }, data });
|
host = await tx.host.update({ where: { id }, data });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof Prisma.PrismaClientKnownRequestError) {
|
if (err instanceof Prisma.PrismaClientKnownRequestError) {
|
||||||
if (err.code === 'P2025') throw errors.notFound('Host');
|
if (err.code === 'P2025') throw errors.notFound('Host');
|
||||||
@@ -85,6 +108,74 @@ export async function update(tx: Tx, id: string, input: UpdateHostRequest) {
|
|||||||
}
|
}
|
||||||
throw err;
|
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) {
|
export async function remove(tx: Tx, id: string) {
|
||||||
@@ -98,3 +189,181 @@ export async function remove(tx: Tx, id: string) {
|
|||||||
throw err;
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import FmDetail from './pages/FmDetail.js';
|
|||||||
import Repairs from './pages/Repairs.js';
|
import Repairs from './pages/Repairs.js';
|
||||||
import MyCustody from './pages/MyCustody.js';
|
import MyCustody from './pages/MyCustody.js';
|
||||||
import Hosts from './pages/Hosts.js';
|
import Hosts from './pages/Hosts.js';
|
||||||
|
import HostDetail from './pages/HostDetail.js';
|
||||||
import Users from './pages/admin/Users.js';
|
import Users from './pages/admin/Users.js';
|
||||||
import Webhooks from './pages/admin/Webhooks.js';
|
import Webhooks from './pages/admin/Webhooks.js';
|
||||||
|
|
||||||
@@ -64,6 +65,7 @@ export default function App() {
|
|||||||
<Route path="/repairs" element={<Repairs />} />
|
<Route path="/repairs" element={<Repairs />} />
|
||||||
<Route path="/custody" element={<MyCustody />} />
|
<Route path="/custody" element={<MyCustody />} />
|
||||||
<Route path="/hosts" element={<Hosts />} />
|
<Route path="/hosts" element={<Hosts />} />
|
||||||
|
<Route path="/hosts/:id" element={<HostDetail />} />
|
||||||
<Route
|
<Route
|
||||||
path="/admin/users"
|
path="/admin/users"
|
||||||
element={
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { CreateHostRequest, UpdateHostRequest } from '@vector/shared';
|
import type { CreateHostRequest, UpdateHostRequest } from '@vector/shared';
|
||||||
import { api } from './client.js';
|
import { api } from './client.js';
|
||||||
import { getList } from './paginated.js';
|
import { getList } from './paginated.js';
|
||||||
import type { Host, Part } from './types.js';
|
import type { Host, HostTimelineEntry, Part } from './types.js';
|
||||||
|
|
||||||
export type HostListFilters = {
|
export type HostListFilters = {
|
||||||
page?: number;
|
page?: number;
|
||||||
@@ -25,6 +25,10 @@ export async function listHostDeployedParts(id: string): Promise<Part[]> {
|
|||||||
return res.data;
|
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> {
|
export async function createHost(input: CreateHostRequest): Promise<Host> {
|
||||||
const res = await api.post<Host>('/hosts', input);
|
const res = await api.post<Host>('/hosts', input);
|
||||||
return res.data;
|
return res.data;
|
||||||
|
|||||||
@@ -113,6 +113,46 @@ export interface Host {
|
|||||||
updatedAt: string;
|
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 {
|
export interface Tag {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ export const queryKeys = {
|
|||||||
[...queryKeys.hosts.all, 'list', filters ?? {}] as const,
|
[...queryKeys.hosts.all, 'list', filters ?? {}] as const,
|
||||||
detail: (id: string) => [...queryKeys.hosts.all, 'detail', id] as const,
|
detail: (id: string) => [...queryKeys.hosts.all, 'detail', id] as const,
|
||||||
deployedParts: (id: string) => [...queryKeys.hosts.all, 'deployed-parts', 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: {
|
fms: {
|
||||||
all: ['fms'] as const,
|
all: ['fms'] as const,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { ApiRequestError } from '../lib/api/client.js';
|
|||||||
import { queryKeys } from '../lib/queryKeys.js';
|
import { queryKeys } from '../lib/queryKeys.js';
|
||||||
import type { Fm } from '../lib/api/types.js';
|
import type { Fm } from '../lib/api/types.js';
|
||||||
import { PartStateBadge } from '../components/parts/PartStateBadge.js';
|
import { PartStateBadge } from '../components/parts/PartStateBadge.js';
|
||||||
|
import { HostStackBadge, HostStateBadge } from '../components/hosts/HostStateBadge.js';
|
||||||
|
|
||||||
export default function FmDetail() {
|
export default function FmDetail() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
@@ -95,16 +96,23 @@ export default function FmDetail() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="text-xs uppercase tracking-wide text-muted-foreground">Asset</span>
|
<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}
|
{fm.host.assetId}
|
||||||
</span>
|
</Link>
|
||||||
|
<HostStateBadge state={fm.host.state} />
|
||||||
|
<HostStackBadge stack={fm.host.stack} />
|
||||||
<Badge variant={closed ? 'secondary' : 'warning'}>
|
<Badge variant={closed ? 'secondary' : 'warning'}>
|
||||||
{closed ? 'Closed' : 'Open'}
|
{closed ? 'Closed' : 'Open'}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
|
<div className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
<Server className="h-3 w-3" />
|
<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>}
|
{fm.host.location && <span>· {fm.host.location}</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
import { PageHeader } from '../components/layout/PageHeader.js';
|
import { PageHeader } from '../components/layout/PageHeader.js';
|
||||||
import { DataTable } from '../components/data-table/DataTable.js';
|
import { DataTable } from '../components/data-table/DataTable.js';
|
||||||
import { FmFormDialog } from '../components/fms/FmFormDialog.js';
|
import { FmFormDialog } from '../components/fms/FmFormDialog.js';
|
||||||
|
import { HostStackBadge, HostStateBadge } from '../components/hosts/HostStateBadge.js';
|
||||||
import { ConfirmDialog } from '../components/ConfirmDialog.js';
|
import { ConfirmDialog } from '../components/ConfirmDialog.js';
|
||||||
import { deleteFm, listFms } from '../lib/api/fms.js';
|
import { deleteFm, listFms } from '../lib/api/fms.js';
|
||||||
import { ApiRequestError } from '../lib/api/client.js';
|
import { ApiRequestError } from '../lib/api/client.js';
|
||||||
@@ -78,18 +79,29 @@ export default function Fms() {
|
|||||||
id: 'assetId',
|
id: 'assetId',
|
||||||
header: 'Asset ID',
|
header: 'Asset ID',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Link
|
<div className="flex items-center gap-1.5">
|
||||||
to={`/fms/${row.original.id}`}
|
<Link
|
||||||
className="font-mono text-xs font-medium text-foreground hover:underline"
|
to={`/fms/${row.original.id}`}
|
||||||
>
|
className="font-mono text-xs font-medium text-foreground hover:underline"
|
||||||
{row.original.host.assetId}
|
>
|
||||||
</Link>
|
{row.original.host.assetId}
|
||||||
|
</Link>
|
||||||
|
<HostStateBadge state={row.original.host.state} />
|
||||||
|
<HostStackBadge stack={row.original.host.stack} />
|
||||||
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'host',
|
id: 'host',
|
||||||
header: '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',
|
id: 'problem',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import type { ColumnDef } from '@tanstack/react-table';
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
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 { toast } from 'sonner';
|
||||||
import {
|
import {
|
||||||
Badge,
|
|
||||||
Button,
|
Button,
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import { PageHeader } from '../components/layout/PageHeader.js';
|
import { PageHeader } from '../components/layout/PageHeader.js';
|
||||||
import { DataTable } from '../components/data-table/DataTable.js';
|
import { DataTable } from '../components/data-table/DataTable.js';
|
||||||
import { HostFormDialog } from '../components/hosts/HostFormDialog.js';
|
import { HostFormDialog } from '../components/hosts/HostFormDialog.js';
|
||||||
|
import { HostStackBadge, HostStateBadge } from '../components/hosts/HostStateBadge.js';
|
||||||
import { ConfirmDialog } from '../components/ConfirmDialog.js';
|
import { ConfirmDialog } from '../components/ConfirmDialog.js';
|
||||||
import { deleteHost, listHosts } from '../lib/api/hosts.js';
|
import { deleteHost, listHosts } from '../lib/api/hosts.js';
|
||||||
import { ApiRequestError } from '../lib/api/client.js';
|
import { ApiRequestError } from '../lib/api/client.js';
|
||||||
@@ -26,6 +27,7 @@ export default function Hosts() {
|
|||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const isAdmin = user?.role === 'ADMIN';
|
const isAdmin = user?.role === 'ADMIN';
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [editing, setEditing] = useState<Host | null>(null);
|
const [editing, setEditing] = useState<Host | null>(null);
|
||||||
@@ -48,32 +50,32 @@ export default function Hosts() {
|
|||||||
accessorKey: 'assetId',
|
accessorKey: 'assetId',
|
||||||
header: 'Asset ID',
|
header: 'Asset ID',
|
||||||
cell: ({ row }) => (
|
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',
|
accessorKey: 'name',
|
||||||
header: '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',
|
accessorKey: 'state',
|
||||||
header: 'State',
|
header: 'State',
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => <HostStateBadge state={row.original.state} />,
|
||||||
const s = row.original.state;
|
|
||||||
const variant =
|
|
||||||
s === 'DEPLOYED' ? 'secondary' : s === 'DEGRADED' ? 'destructive' : 'outline';
|
|
||||||
return <Badge variant={variant}>{s}</Badge>;
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'stack',
|
accessorKey: 'stack',
|
||||||
header: 'Stack',
|
header: 'Stack',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => <HostStackBadge stack={row.original.stack} />,
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
{row.original.stack}
|
|
||||||
</Badge>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'location',
|
accessorKey: 'location',
|
||||||
@@ -97,33 +99,40 @@ export default function Hosts() {
|
|||||||
id: 'actions',
|
id: 'actions',
|
||||||
header: () => <span className="sr-only">Actions</span>,
|
header: () => <span className="sr-only">Actions</span>,
|
||||||
size: 40,
|
size: 40,
|
||||||
cell: ({ row }) =>
|
cell: ({ row }) => (
|
||||||
isAdmin ? (
|
<DropdownMenu>
|
||||||
<DropdownMenu>
|
<DropdownMenuTrigger asChild>
|
||||||
<DropdownMenuTrigger asChild>
|
<Button variant="ghost" size="icon" className="h-7 w-7">
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7">
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
</Button>
|
||||||
</Button>
|
</DropdownMenuTrigger>
|
||||||
</DropdownMenuTrigger>
|
<DropdownMenuContent align="end" className="w-36">
|
||||||
<DropdownMenuContent align="end" className="w-36">
|
<DropdownMenuItem onSelect={() => navigate(`/hosts/${row.original.id}`)}>
|
||||||
<DropdownMenuItem onSelect={() => setEditing(row.original)}>
|
<Eye className="h-3.5 w-3.5" />
|
||||||
<Edit className="h-3.5 w-3.5" />
|
View
|
||||||
Edit
|
</DropdownMenuItem>
|
||||||
</DropdownMenuItem>
|
{isAdmin && (
|
||||||
<DropdownMenuSeparator />
|
<>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem onSelect={() => setEditing(row.original)}>
|
||||||
onSelect={() => setDeleting(row.original)}
|
<Edit className="h-3.5 w-3.5" />
|
||||||
className="text-destructive focus:text-destructive"
|
Edit
|
||||||
>
|
</DropdownMenuItem>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<DropdownMenuSeparator />
|
||||||
Delete
|
<DropdownMenuItem
|
||||||
</DropdownMenuItem>
|
onSelect={() => setDeleting(row.original)}
|
||||||
</DropdownMenuContent>
|
className="text-destructive focus:text-destructive"
|
||||||
</DropdownMenu>
|
>
|
||||||
) : null,
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[isAdmin],
|
[isAdmin, navigate],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
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");
|
||||||
@@ -24,6 +24,7 @@ model User {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
partEvents PartEvent[]
|
partEvents PartEvent[]
|
||||||
|
hostEvents HostEvent[]
|
||||||
refreshTokens RefreshToken[]
|
refreshTokens RefreshToken[]
|
||||||
custodyParts Part[] @relation("Custody")
|
custodyParts Part[] @relation("Custody")
|
||||||
repairs Repair[]
|
repairs Repair[]
|
||||||
@@ -198,11 +199,28 @@ model Host {
|
|||||||
parts Part[]
|
parts Part[]
|
||||||
fms Fm[]
|
fms Fm[]
|
||||||
repairs Repair[]
|
repairs Repair[]
|
||||||
|
events HostEvent[]
|
||||||
|
|
||||||
@@index([state])
|
@@index([state])
|
||||||
@@index([stack])
|
@@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 {
|
model Fm {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
hostId String
|
hostId String
|
||||||
|
|||||||
@@ -33,6 +33,14 @@ export const PartEventType = z.enum([
|
|||||||
]);
|
]);
|
||||||
export type PartEventType = z.infer<typeof PartEventType>;
|
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 const FmStatus = z.enum(['OPEN', 'CLOSED']);
|
||||||
export type FmStatus = z.infer<typeof FmStatus>;
|
export type FmStatus = z.infer<typeof FmStatus>;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import { PaginationQuery } from './pagination.js';
|
||||||
|
|
||||||
|
export const HostTimelineQuery = PaginationQuery;
|
||||||
|
export type HostTimelineQuery = z.infer<typeof HostTimelineQuery>;
|
||||||
@@ -8,6 +8,7 @@ export * from './parts.js';
|
|||||||
export * from './env.js';
|
export * from './env.js';
|
||||||
export * from './pagination.js';
|
export * from './pagination.js';
|
||||||
export * from './hosts.js';
|
export * from './hosts.js';
|
||||||
|
export * from './host-events.js';
|
||||||
export * from './fms.js';
|
export * from './fms.js';
|
||||||
export * from './repairs.js';
|
export * from './repairs.js';
|
||||||
export * from './custody.js';
|
export * from './custody.js';
|
||||||
|
|||||||
Reference in New Issue
Block a user