feat(camera): add camera management functionality and data flow documentation
- Implemented camera slice in Redux store for managing camera state. - Added new tables in PostgreSQL for camera devices, lending, and audit logs. - Created camera-related schemas in contracts for validation. - Developed camera data flow documentation outlining ingest and read processes. - Introduced async thunks for fetching, creating, lending, returning, and updating cameras.
This commit is contained in:
1 parent
7923ba1b8d
commit
d3b6c5032f
11 files changed
+2492
-82
No files matched your search
@@ -35,4 +35,114 @@ describe('ingest-api', () => {
|
||||
it('rejects invalid telemetry', async () => {
|
||||
await request(app.callback()).post('/telemetry').send({ deviceId: '' }).expect(400);
|
||||
});
|
||||
|
||||
it('lists simulated cameras', async () => {
|
||||
const response = await request(app.callback()).get('/cameras').expect(200);
|
||||
expect(response.body.items).toHaveLength(3);
|
||||
expect(response.body.items[0]?.cameraId).toBe('cam-lobby-01');
|
||||
expect(response.body.items[0]?.model).toBeNull();
|
||||
expect(response.body.items[0]?.isActive).toBe(true);
|
||||
});
|
||||
|
||||
it('creates a new camera device and blocks duplicates', async () => {
|
||||
const created = await request(app.callback())
|
||||
.post('/cameras')
|
||||
.send({
|
||||
cameraId: 'cam-new-77',
|
||||
name: 'New Camera',
|
||||
location: 'Dock 4',
|
||||
model: 'Model-Z',
|
||||
notes: 'Installed this week',
|
||||
isActive: true,
|
||||
})
|
||||
.expect(201);
|
||||
|
||||
expect(created.body.cameraId).toBe('cam-new-77');
|
||||
expect(created.body.name).toBe('New Camera');
|
||||
|
||||
await request(app.callback())
|
||||
.post('/cameras')
|
||||
.send({
|
||||
cameraId: 'cam-new-77',
|
||||
name: 'Duplicate',
|
||||
location: 'Dock 5',
|
||||
})
|
||||
.expect(409);
|
||||
});
|
||||
|
||||
it('simulates camera ingest and exposes latest status', async () => {
|
||||
writes.length = 0;
|
||||
|
||||
const simulate = await request(app.callback())
|
||||
.post('/cameras/cam-lobby-01/simulate-ingest')
|
||||
.send({})
|
||||
.expect(200);
|
||||
|
||||
expect(simulate.body.accepted).toBe(true);
|
||||
expect(simulate.body.latest.health).toBe('online');
|
||||
expect(writes).toHaveLength(1);
|
||||
expect(writes[0]?.deviceId).toBe('cam-lobby-01');
|
||||
|
||||
const latest = await request(app.callback()).get('/cameras/cam-lobby-01/latest').expect(200);
|
||||
expect(latest.body.cameraId).toBe('cam-lobby-01');
|
||||
expect(latest.body.payload).not.toBeNull();
|
||||
});
|
||||
|
||||
it('accepts lend form and prevents double lend', async () => {
|
||||
const dueDate = new Date(Date.now() + 86_400_000).toISOString();
|
||||
|
||||
const first = await request(app.callback())
|
||||
.post('/cameras/cam-warehouse-02/lend')
|
||||
.send({ borrower: 'Marco', dueDate, purpose: 'Site audit' })
|
||||
.expect(200);
|
||||
|
||||
expect(first.body.lend.borrower).toBe('Marco');
|
||||
|
||||
await request(app.callback())
|
||||
.post('/cameras/cam-warehouse-02/lend')
|
||||
.send({ borrower: 'Another', dueDate })
|
||||
.expect(409);
|
||||
|
||||
const list = await request(app.callback()).get('/cameras').expect(200);
|
||||
const lent = list.body.items.find((item: { cameraId: string }) => item.cameraId === 'cam-warehouse-02');
|
||||
expect(lent?.lend?.borrower).toBe('Marco');
|
||||
|
||||
const returned = await request(app.callback())
|
||||
.post('/cameras/cam-warehouse-02/return')
|
||||
.set('x-actor', 'qa-user')
|
||||
.send({})
|
||||
.expect(200);
|
||||
expect(returned.body.returned).toBe(true);
|
||||
|
||||
await request(app.callback()).post('/cameras/cam-warehouse-02/return').send({}).expect(409);
|
||||
});
|
||||
|
||||
it('updates camera metadata from device catalog', async () => {
|
||||
const update = await request(app.callback())
|
||||
.patch('/cameras/cam-parking-03')
|
||||
.send({ name: 'Gate Cam', model: 'Axis-M11', notes: 'North gate', isActive: false })
|
||||
.expect(200);
|
||||
|
||||
expect(update.body.name).toBe('Gate Cam');
|
||||
expect(update.body.model).toBe('Axis-M11');
|
||||
expect(update.body.isActive).toBe(false);
|
||||
|
||||
const list = await request(app.callback()).get('/cameras').expect(200);
|
||||
const updated = list.body.items.find((item: { cameraId: string }) => item.cameraId === 'cam-parking-03');
|
||||
expect(updated?.name).toBe('Gate Cam');
|
||||
expect(updated?.notes).toBe('North gate');
|
||||
expect(updated?.isActive).toBe(false);
|
||||
|
||||
const audit = await request(app.callback()).get('/cameras/cam-parking-03/audit').expect(200);
|
||||
expect(audit.body.items.length).toBeGreaterThan(0);
|
||||
expect(audit.body.items[0].action).toBe('camera_deactivated');
|
||||
expect(audit.body.items[0].actor).toBe('system-demo');
|
||||
|
||||
await request(app.callback())
|
||||
.patch('/cameras/cam-parking-03')
|
||||
.send({ isActive: true })
|
||||
.expect(200);
|
||||
const restoredAudit = await request(app.callback()).get('/cameras/cam-parking-03/audit').expect(200);
|
||||
expect(restoredAudit.body.items[0].action).toBe('camera_activated');
|
||||
});
|
||||
});
|
||||
+438
-3
@@ -1,13 +1,142 @@
|
||||
import cors from '@koa/cors';
|
||||
import Router from '@koa/router';
|
||||
import { telemetryIngestSchema } from 'contracts';
|
||||
import {
|
||||
cameraAuditLogListSchema,
|
||||
cameraCreateRequestSchema,
|
||||
cameraDeviceUpdateSchema,
|
||||
cameraLendRequestSchema,
|
||||
cameraListResponseSchema,
|
||||
cameraReturnResponseSchema,
|
||||
cameraSimulateIngestResponseSchema,
|
||||
telemetryIngestSchema,
|
||||
type CameraLatestStatus,
|
||||
type CameraLendInfo,
|
||||
type CameraRuntimePayload,
|
||||
} from 'contracts';
|
||||
import Koa from 'koa';
|
||||
import { koaBody } from 'koa-body';
|
||||
import { createTelemetryWriter, type TelemetryWriter } from './stores.js';
|
||||
|
||||
type CameraSeed = {
|
||||
cameraId: string;
|
||||
name: string;
|
||||
location: string;
|
||||
};
|
||||
|
||||
type CameraState = CameraSeed & {
|
||||
latest: CameraLatestStatus;
|
||||
lend: CameraLendInfo | null;
|
||||
};
|
||||
|
||||
const cameraSeeds: CameraSeed[] = [
|
||||
{ cameraId: 'cam-lobby-01', name: 'Lobby Entrance', location: 'HQ Lobby' },
|
||||
{ cameraId: 'cam-warehouse-02', name: 'Warehouse Aisle', location: 'Warehouse 1' },
|
||||
{ cameraId: 'cam-parking-03', name: 'Parking Gate', location: 'Main Gate' },
|
||||
];
|
||||
|
||||
function randomPayload(): CameraRuntimePayload {
|
||||
const scenes = ['quiet', 'normal', 'crowded'];
|
||||
return {
|
||||
fps: 20 + Math.round(Math.random() * 10),
|
||||
bitrateKbps: 1500 + Math.round(Math.random() * 2500),
|
||||
recording: Math.random() > 0.08,
|
||||
scene: scenes[Math.floor(Math.random() * scenes.length)] ?? 'normal',
|
||||
};
|
||||
}
|
||||
|
||||
function createCameraStates(): Map<string, CameraState> {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
function ensureCameraState(cameras: Map<string, CameraState>, seed: CameraSeed): CameraState {
|
||||
const existing = cameras.get(seed.cameraId);
|
||||
if (existing) {
|
||||
existing.name = seed.name;
|
||||
existing.location = seed.location;
|
||||
return existing;
|
||||
}
|
||||
|
||||
const created: CameraState = {
|
||||
...seed,
|
||||
latest: {
|
||||
cameraId: seed.cameraId,
|
||||
lastSeen: null,
|
||||
health: 'unknown',
|
||||
payload: null,
|
||||
},
|
||||
lend: null,
|
||||
};
|
||||
cameras.set(seed.cameraId, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
async function ingestCamera(camera: CameraState, writer: TelemetryWriter) {
|
||||
const sentAt = new Date().toISOString();
|
||||
const payload = randomPayload();
|
||||
const writes = await writer.write({
|
||||
deviceId: camera.cameraId,
|
||||
sentAt,
|
||||
payload: {
|
||||
source: 'camera-simulator',
|
||||
...payload,
|
||||
},
|
||||
});
|
||||
|
||||
camera.latest = {
|
||||
cameraId: camera.cameraId,
|
||||
lastSeen: sentAt,
|
||||
health: 'online',
|
||||
payload,
|
||||
};
|
||||
|
||||
const accepted = writes.mongo && writes.redis && writes.postgres;
|
||||
|
||||
return {
|
||||
accepted,
|
||||
writes,
|
||||
latest: camera.latest,
|
||||
};
|
||||
}
|
||||
|
||||
export function createApp(writer: TelemetryWriter = createTelemetryWriter()): Koa {
|
||||
const app = new Koa();
|
||||
const router = new Router();
|
||||
const cameras = createCameraStates();
|
||||
let deviceSeeded: Promise<void> | null = null;
|
||||
|
||||
function ensureDeviceCatalog() {
|
||||
deviceSeeded ??= writer.ensureDevices(cameraSeeds);
|
||||
return deviceSeeded;
|
||||
}
|
||||
|
||||
function requestActor(ctx: Koa.ParameterizedContext): string {
|
||||
const actorHeader = ctx.get('x-actor').trim();
|
||||
return actorHeader.length > 0 ? actorHeader : 'system-demo';
|
||||
}
|
||||
|
||||
const autoIngestTimer = setInterval(() => {
|
||||
void (async () => {
|
||||
await ensureDeviceCatalog();
|
||||
const devices = await writer.listDevices();
|
||||
await Promise.all(
|
||||
devices.filter((device) => device.isActive).map(async (device) => {
|
||||
const camera = ensureCameraState(cameras, {
|
||||
cameraId: device.cameraId,
|
||||
name: device.name,
|
||||
location: device.location,
|
||||
});
|
||||
try {
|
||||
await ingestCamera(camera, writer);
|
||||
} catch (error) {
|
||||
console.error(`auto simulate ingest failed for ${camera.cameraId}`, error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
})().catch((error) => {
|
||||
console.error('auto simulate ingest tick failed', error);
|
||||
});
|
||||
}, 1000);
|
||||
autoIngestTimer.unref?.();
|
||||
|
||||
app.use(cors());
|
||||
app.use(koaBody());
|
||||
@@ -21,7 +150,7 @@ export function createApp(writer: TelemetryWriter = createTelemetryWriter()): Ko
|
||||
});
|
||||
|
||||
router.post('/telemetry', async (ctx) => {
|
||||
const parsed = telemetryIngestSchema.safeParse(ctx.request.body);
|
||||
const parsed = telemetryIngestSchema.safeParse((ctx.request as { body?: unknown }).body);
|
||||
if (!parsed.success) {
|
||||
ctx.status = 400;
|
||||
ctx.body = { error: parsed.error.flatten() };
|
||||
@@ -29,7 +158,313 @@ export function createApp(writer: TelemetryWriter = createTelemetryWriter()): Ko
|
||||
}
|
||||
|
||||
const writes = await writer.write(parsed.data);
|
||||
ctx.body = { accepted: true, writes };
|
||||
const accepted = writes.mongo && writes.redis && writes.postgres;
|
||||
if (!accepted) {
|
||||
ctx.status = 503;
|
||||
}
|
||||
ctx.body = { accepted, writes };
|
||||
});
|
||||
|
||||
router.get('/cameras', async (ctx) => {
|
||||
await ensureDeviceCatalog();
|
||||
const devices = await writer.listDevices();
|
||||
const items = await Promise.all(
|
||||
devices.map(async (device) => {
|
||||
const camera = ensureCameraState(cameras, {
|
||||
cameraId: device.cameraId,
|
||||
name: device.name,
|
||||
location: device.location,
|
||||
});
|
||||
const lend = await writer.readLend(camera.cameraId);
|
||||
camera.lend = lend;
|
||||
return {
|
||||
cameraId: camera.cameraId,
|
||||
name: camera.name,
|
||||
location: camera.location,
|
||||
model: device.model,
|
||||
notes: device.notes,
|
||||
isActive: device.isActive,
|
||||
latest: camera.latest,
|
||||
lend: camera.lend,
|
||||
};
|
||||
}),
|
||||
);
|
||||
ctx.body = cameraListResponseSchema.parse({ items });
|
||||
});
|
||||
|
||||
router.get('/cameras/:id/latest', async (ctx) => {
|
||||
await ensureDeviceCatalog();
|
||||
const device = await writer.getDevice(ctx.params.id);
|
||||
if (!device) {
|
||||
ctx.status = 404;
|
||||
ctx.body = { error: 'Camera not found' };
|
||||
return;
|
||||
}
|
||||
|
||||
const camera = ensureCameraState(cameras, {
|
||||
cameraId: device.cameraId,
|
||||
name: device.name,
|
||||
location: device.location,
|
||||
});
|
||||
|
||||
ctx.body = camera.latest;
|
||||
});
|
||||
|
||||
router.post('/cameras', async (ctx) => {
|
||||
await ensureDeviceCatalog();
|
||||
const actor = requestActor(ctx);
|
||||
const parsed = cameraCreateRequestSchema.safeParse((ctx.request as { body?: unknown }).body);
|
||||
if (!parsed.success) {
|
||||
ctx.status = 400;
|
||||
ctx.body = { error: parsed.error.flatten() };
|
||||
return;
|
||||
}
|
||||
|
||||
const created = await writer.createDevice(parsed.data);
|
||||
if (!created) {
|
||||
ctx.status = 409;
|
||||
ctx.body = { error: 'Camera already exists' };
|
||||
return;
|
||||
}
|
||||
|
||||
await writer.logAudit(created.cameraId, 'camera_created', actor, {
|
||||
created: {
|
||||
name: created.name,
|
||||
location: created.location,
|
||||
model: created.model,
|
||||
notes: created.notes,
|
||||
isActive: created.isActive,
|
||||
},
|
||||
});
|
||||
|
||||
const camera = ensureCameraState(cameras, {
|
||||
cameraId: created.cameraId,
|
||||
name: created.name,
|
||||
location: created.location,
|
||||
});
|
||||
|
||||
ctx.status = 201;
|
||||
ctx.body = {
|
||||
cameraId: created.cameraId,
|
||||
name: created.name,
|
||||
location: created.location,
|
||||
model: created.model,
|
||||
notes: created.notes,
|
||||
isActive: created.isActive,
|
||||
latest: camera.latest,
|
||||
lend: null,
|
||||
};
|
||||
});
|
||||
|
||||
router.post('/cameras/:id/simulate-ingest', async (ctx) => {
|
||||
await ensureDeviceCatalog();
|
||||
const device = await writer.getDevice(ctx.params.id);
|
||||
if (!device) {
|
||||
ctx.status = 404;
|
||||
ctx.body = { error: 'Camera not found' };
|
||||
return;
|
||||
}
|
||||
|
||||
const camera = ensureCameraState(cameras, {
|
||||
cameraId: device.cameraId,
|
||||
name: device.name,
|
||||
location: device.location,
|
||||
});
|
||||
|
||||
ctx.body = cameraSimulateIngestResponseSchema.parse(await ingestCamera(camera, writer));
|
||||
});
|
||||
|
||||
router.patch('/cameras/:id', async (ctx) => {
|
||||
await ensureDeviceCatalog();
|
||||
const actor = requestActor(ctx);
|
||||
const parsed = cameraDeviceUpdateSchema.safeParse((ctx.request as { body?: unknown }).body);
|
||||
if (!parsed.success) {
|
||||
ctx.status = 400;
|
||||
ctx.body = { error: parsed.error.flatten() };
|
||||
return;
|
||||
}
|
||||
|
||||
const previous = await writer.getDevice(ctx.params.id);
|
||||
if (!previous) {
|
||||
ctx.status = 404;
|
||||
ctx.body = { error: 'Camera not found' };
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = await writer.updateDevice(ctx.params.id, parsed.data);
|
||||
if (!updated) {
|
||||
ctx.status = 404;
|
||||
ctx.body = { error: 'Camera not found' };
|
||||
return;
|
||||
}
|
||||
|
||||
const deactivated = previous.isActive && updated.isActive === false;
|
||||
const activated = previous.isActive === false && updated.isActive;
|
||||
const action = deactivated
|
||||
? 'camera_deactivated'
|
||||
: activated
|
||||
? 'camera_activated'
|
||||
: 'camera_updated';
|
||||
await writer.logAudit(
|
||||
updated.cameraId,
|
||||
action,
|
||||
actor,
|
||||
{
|
||||
patch: parsed.data,
|
||||
previous: {
|
||||
name: previous.name,
|
||||
location: previous.location,
|
||||
model: previous.model,
|
||||
notes: previous.notes,
|
||||
isActive: previous.isActive,
|
||||
},
|
||||
next: {
|
||||
name: updated.name,
|
||||
location: updated.location,
|
||||
model: updated.model,
|
||||
notes: updated.notes,
|
||||
isActive: updated.isActive,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const camera = ensureCameraState(cameras, {
|
||||
cameraId: updated.cameraId,
|
||||
name: updated.name,
|
||||
location: updated.location,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
cameraId: updated.cameraId,
|
||||
name: updated.name,
|
||||
location: updated.location,
|
||||
model: updated.model,
|
||||
notes: updated.notes,
|
||||
isActive: updated.isActive,
|
||||
latest: camera.latest,
|
||||
lend: camera.lend,
|
||||
};
|
||||
});
|
||||
|
||||
router.post('/cameras/:id/lend', async (ctx) => {
|
||||
await ensureDeviceCatalog();
|
||||
const actor = requestActor(ctx);
|
||||
const device = await writer.getDevice(ctx.params.id);
|
||||
if (!device) {
|
||||
ctx.status = 404;
|
||||
ctx.body = { error: 'Camera not found' };
|
||||
return;
|
||||
}
|
||||
|
||||
const camera = ensureCameraState(cameras, {
|
||||
cameraId: device.cameraId,
|
||||
name: device.name,
|
||||
location: device.location,
|
||||
});
|
||||
|
||||
const parsed = cameraLendRequestSchema.safeParse((ctx.request as { body?: unknown }).body);
|
||||
if (!parsed.success) {
|
||||
ctx.status = 400;
|
||||
ctx.body = { error: parsed.error.flatten() };
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = await writer.readLend(camera.cameraId);
|
||||
if (existing) {
|
||||
camera.lend = existing;
|
||||
ctx.status = 409;
|
||||
ctx.body = { error: 'Camera already lent' };
|
||||
return;
|
||||
}
|
||||
|
||||
const lend = {
|
||||
borrower: parsed.data.borrower,
|
||||
dueDate: parsed.data.dueDate,
|
||||
purpose: parsed.data.purpose,
|
||||
lentAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const saved = await writer.saveLend(camera.cameraId, lend);
|
||||
if (!saved) {
|
||||
ctx.status = 409;
|
||||
ctx.body = { error: 'Camera already lent' };
|
||||
return;
|
||||
}
|
||||
|
||||
await writer.logAudit(camera.cameraId, 'camera_lent', actor, {
|
||||
borrower: lend.borrower,
|
||||
dueDate: lend.dueDate,
|
||||
purpose: lend.purpose,
|
||||
lentAt: lend.lentAt,
|
||||
});
|
||||
|
||||
camera.lend = lend;
|
||||
|
||||
ctx.body = {
|
||||
cameraId: camera.cameraId,
|
||||
name: camera.name,
|
||||
location: camera.location,
|
||||
model: device.model,
|
||||
notes: device.notes,
|
||||
isActive: device.isActive,
|
||||
latest: camera.latest,
|
||||
lend: camera.lend,
|
||||
};
|
||||
});
|
||||
|
||||
router.post('/cameras/:id/return', async (ctx) => {
|
||||
await ensureDeviceCatalog();
|
||||
const actor = requestActor(ctx);
|
||||
const device = await writer.getDevice(ctx.params.id);
|
||||
if (!device) {
|
||||
ctx.status = 404;
|
||||
ctx.body = { error: 'Camera not found' };
|
||||
return;
|
||||
}
|
||||
|
||||
const currentLend = await writer.readLend(device.cameraId);
|
||||
if (!currentLend) {
|
||||
ctx.status = 409;
|
||||
ctx.body = { error: 'Camera is not currently lent' };
|
||||
return;
|
||||
}
|
||||
|
||||
const returned = await writer.returnLend(device.cameraId);
|
||||
if (!returned) {
|
||||
ctx.status = 500;
|
||||
ctx.body = { error: 'Failed to return camera' };
|
||||
return;
|
||||
}
|
||||
|
||||
const camera = cameras.get(device.cameraId);
|
||||
if (camera) {
|
||||
camera.lend = null;
|
||||
}
|
||||
|
||||
await writer.logAudit(device.cameraId, 'camera_returned', actor, {
|
||||
previousLend: currentLend,
|
||||
returnedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
ctx.body = cameraReturnResponseSchema.parse({
|
||||
cameraId: device.cameraId,
|
||||
returned: true,
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/cameras/:id/audit', async (ctx) => {
|
||||
await ensureDeviceCatalog();
|
||||
const device = await writer.getDevice(ctx.params.id);
|
||||
if (!device) {
|
||||
ctx.status = 404;
|
||||
ctx.body = { error: 'Camera not found' };
|
||||
return;
|
||||
}
|
||||
|
||||
const limitRaw = Number(ctx.query.limit);
|
||||
const limit = Number.isFinite(limitRaw) ? limitRaw : 20;
|
||||
const items = await writer.listAudit(device.cameraId, limit);
|
||||
ctx.body = cameraAuditLogListSchema.parse({ items });
|
||||
});
|
||||
|
||||
app.use(router.routes());
|
||||
|
||||
@@ -1,9 +1,35 @@
|
||||
import { PRESENCE_TTL_SECONDS, type TelemetryIngest } from 'contracts';
|
||||
import Redis from 'ioredis';
|
||||
import {
|
||||
PRESENCE_TTL_SECONDS,
|
||||
type CameraAuditAction,
|
||||
type CameraAuditLog,
|
||||
type CameraCreateRequest,
|
||||
type CameraDevice,
|
||||
type CameraDeviceUpdate,
|
||||
type TelemetryIngest,
|
||||
} from 'contracts';
|
||||
import { Redis } from 'ioredis';
|
||||
import { MongoClient } from 'mongodb';
|
||||
import pg from 'pg';
|
||||
import { env } from './env.js';
|
||||
|
||||
type CameraLendRecord = {
|
||||
borrower: string;
|
||||
dueDate: string;
|
||||
purpose?: string;
|
||||
lentAt: string;
|
||||
};
|
||||
|
||||
type TelemetryQueueEnvelope = {
|
||||
event: TelemetryIngest;
|
||||
queuedAt: string;
|
||||
};
|
||||
|
||||
type DeviceSeed = {
|
||||
cameraId: string;
|
||||
name: string;
|
||||
location: string;
|
||||
};
|
||||
|
||||
export type WriteReport = {
|
||||
mongo: boolean;
|
||||
redis: boolean;
|
||||
@@ -11,7 +37,22 @@ export type WriteReport = {
|
||||
};
|
||||
|
||||
export type TelemetryWriter = {
|
||||
ensureDevices(seed: DeviceSeed[]): Promise<void>;
|
||||
listDevices(): Promise<CameraDevice[]>;
|
||||
getDevice(cameraId: string): Promise<CameraDevice | null>;
|
||||
createDevice(input: CameraCreateRequest): Promise<CameraDevice | null>;
|
||||
updateDevice(cameraId: string, patch: CameraDeviceUpdate): Promise<CameraDevice | null>;
|
||||
write(event: TelemetryIngest): Promise<WriteReport>;
|
||||
readLend(cameraId: string): Promise<CameraLendRecord | null>;
|
||||
saveLend(cameraId: string, lend: CameraLendRecord): Promise<boolean>;
|
||||
returnLend(cameraId: string): Promise<boolean>;
|
||||
logAudit(
|
||||
cameraId: string,
|
||||
action: CameraAuditAction,
|
||||
actor: string,
|
||||
metadata?: Record<string, unknown>,
|
||||
): Promise<void>;
|
||||
listAudit(cameraId: string, limit?: number): Promise<CameraAuditLog[]>;
|
||||
close(): Promise<void>;
|
||||
};
|
||||
|
||||
@@ -22,16 +63,99 @@ function redisKey(deviceId: string): string {
|
||||
export function createTelemetryWriter(): TelemetryWriter {
|
||||
const mongo = new MongoClient(env.mongoUrl);
|
||||
const redis = new Redis(env.redisUrl, { maxRetriesPerRequest: 1, lazyConnect: true });
|
||||
const queue = new Redis(env.redisUrl, { maxRetriesPerRequest: 1, lazyConnect: true });
|
||||
const pool = new pg.Pool({ connectionString: env.databaseUrl });
|
||||
let mongoReady: Promise<MongoClient> | null = null;
|
||||
let postgresSchemaReady: Promise<void> | null = null;
|
||||
let workerReady: Promise<void> | null = null;
|
||||
let workerTask: Promise<void> | null = null;
|
||||
let stopWorker = false;
|
||||
|
||||
const streamKey = 'telemetry:stream';
|
||||
const streamDlqKey = 'telemetry:stream:dlq';
|
||||
const streamGroup = 'ingest-writers';
|
||||
const consumerName = `ingest-${process.pid}`;
|
||||
const streamMaxLen = 50_000;
|
||||
const streamDlqMaxLen = 5_000;
|
||||
|
||||
function mongoClient(): Promise<MongoClient> {
|
||||
mongoReady ??= mongo.connect();
|
||||
return mongoReady;
|
||||
}
|
||||
|
||||
return {
|
||||
async write(event: TelemetryIngest): Promise<WriteReport> {
|
||||
function ensurePostgresSchema(): Promise<void> {
|
||||
postgresSchemaReady ??= (async () => {
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS devices (
|
||||
camera_id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
location TEXT NOT NULL,
|
||||
model TEXT,
|
||||
notes TEXT,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS device_status_snapshots (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
device_id TEXT NOT NULL,
|
||||
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
last_seen TIMESTAMPTZ NOT NULL,
|
||||
changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS camera_lending (
|
||||
camera_id TEXT PRIMARY KEY,
|
||||
borrower TEXT NOT NULL,
|
||||
due_date TIMESTAMPTZ NOT NULL,
|
||||
purpose TEXT,
|
||||
lent_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS camera_audit_logs (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
camera_id TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
actor TEXT NOT NULL,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
})();
|
||||
return postgresSchemaReady;
|
||||
}
|
||||
|
||||
async function ensureQueueWorker(): Promise<void> {
|
||||
workerReady ??= (async () => {
|
||||
if (queue.status === 'wait') {
|
||||
await queue.connect();
|
||||
}
|
||||
|
||||
try {
|
||||
await queue.xgroup('CREATE', streamKey, streamGroup, '$', 'MKSTREAM');
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (!message.includes('BUSYGROUP')) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
workerTask ??= runWorkerLoop();
|
||||
})();
|
||||
|
||||
return workerReady;
|
||||
}
|
||||
|
||||
async function persistTelemetry(event: TelemetryIngest): Promise<WriteReport> {
|
||||
const report: WriteReport = { mongo: false, redis: false, postgres: false };
|
||||
const lastSeen = new Date(event.sentAt);
|
||||
|
||||
@@ -63,6 +187,9 @@ export function createTelemetryWriter(): TelemetryWriter {
|
||||
console.error('redis ingest write failed', error);
|
||||
}
|
||||
|
||||
try {
|
||||
await ensurePostgresSchema();
|
||||
await pool.query('BEGIN');
|
||||
try {
|
||||
await pool.query(
|
||||
`INSERT INTO device_current_state (device_id, payload, last_seen, updated_at)
|
||||
@@ -73,26 +200,532 @@ export function createTelemetryWriter(): TelemetryWriter {
|
||||
updated_at = NOW()`,
|
||||
[event.deviceId, JSON.stringify(event.payload), lastSeen],
|
||||
);
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO device_status_snapshots (device_id, payload, last_seen, changed_at)
|
||||
SELECT $1, $2::jsonb, $3, NOW()
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM device_status_snapshots
|
||||
WHERE device_id = $1
|
||||
AND changed_at >= NOW() - INTERVAL '5 seconds'
|
||||
)`,
|
||||
[event.deviceId, JSON.stringify(event.payload), lastSeen],
|
||||
);
|
||||
|
||||
await pool.query('COMMIT');
|
||||
} catch (error) {
|
||||
await pool.query('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
|
||||
report.postgres = true;
|
||||
} catch (error) {
|
||||
console.error('postgres ingest write failed', error);
|
||||
}
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
async function handleStreamMessage(messageId: string, fields: string[]): Promise<void> {
|
||||
const fieldMap = new Map<string, string>();
|
||||
for (let index = 0; index < fields.length; index += 2) {
|
||||
const key = fields[index];
|
||||
const value = fields[index + 1];
|
||||
if (key !== undefined && value !== undefined) {
|
||||
fieldMap.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
const raw = fieldMap.get('event');
|
||||
if (!raw) {
|
||||
await queue.xadd(
|
||||
streamDlqKey,
|
||||
'MAXLEN',
|
||||
'~',
|
||||
streamDlqMaxLen,
|
||||
'*',
|
||||
'sourceMessageId',
|
||||
messageId,
|
||||
'reason',
|
||||
'missing_event_field',
|
||||
'failedAt',
|
||||
new Date().toISOString(),
|
||||
);
|
||||
await queue.xack(streamKey, streamGroup, messageId);
|
||||
return;
|
||||
}
|
||||
|
||||
let envelope: TelemetryQueueEnvelope;
|
||||
try {
|
||||
envelope = JSON.parse(raw) as TelemetryQueueEnvelope;
|
||||
} catch (error) {
|
||||
await queue.xadd(
|
||||
streamDlqKey,
|
||||
'MAXLEN',
|
||||
'~',
|
||||
streamDlqMaxLen,
|
||||
'*',
|
||||
'sourceMessageId',
|
||||
messageId,
|
||||
'reason',
|
||||
'invalid_json',
|
||||
'error',
|
||||
error instanceof Error ? error.message : String(error),
|
||||
'failedAt',
|
||||
new Date().toISOString(),
|
||||
);
|
||||
await queue.xack(streamKey, streamGroup, messageId);
|
||||
return;
|
||||
}
|
||||
|
||||
let success = false;
|
||||
let lastError = '';
|
||||
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||
try {
|
||||
const report = await persistTelemetry(envelope.event);
|
||||
if (report.mongo && report.redis && report.postgres) {
|
||||
success = true;
|
||||
break;
|
||||
}
|
||||
lastError = `partial_write mongo=${report.mongo} redis=${report.redis} postgres=${report.postgres}`;
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
await queue.xadd(
|
||||
streamDlqKey,
|
||||
'MAXLEN',
|
||||
'~',
|
||||
streamDlqMaxLen,
|
||||
'*',
|
||||
'sourceMessageId',
|
||||
messageId,
|
||||
'reason',
|
||||
'write_failed',
|
||||
'error',
|
||||
lastError,
|
||||
'event',
|
||||
JSON.stringify(envelope),
|
||||
'failedAt',
|
||||
new Date().toISOString(),
|
||||
);
|
||||
}
|
||||
|
||||
await queue.xack(streamKey, streamGroup, messageId);
|
||||
}
|
||||
|
||||
async function runWorkerLoop(): Promise<void> {
|
||||
while (!stopWorker) {
|
||||
try {
|
||||
const streams = (await queue.xreadgroup(
|
||||
'GROUP',
|
||||
streamGroup,
|
||||
consumerName,
|
||||
'COUNT',
|
||||
20,
|
||||
'BLOCK',
|
||||
1000,
|
||||
'STREAMS',
|
||||
streamKey,
|
||||
'>',
|
||||
)) as [string, [string, string[]][]][] | null;
|
||||
|
||||
if (!streams) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const stream of streams) {
|
||||
const entries = stream[1] ?? [];
|
||||
for (const entry of entries) {
|
||||
const messageId = entry[0];
|
||||
const fields = entry[1] ?? [];
|
||||
await handleStreamMessage(messageId, fields);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('telemetry worker loop failed', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
async ensureDevices(seed: DeviceSeed[]): Promise<void> {
|
||||
await ensurePostgresSchema();
|
||||
if (seed.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
seed.map((device) =>
|
||||
pool.query(
|
||||
`INSERT INTO devices (camera_id, name, location, updated_at)
|
||||
VALUES ($1, $2, $3, NOW())
|
||||
ON CONFLICT (camera_id) DO NOTHING`,
|
||||
[device.cameraId, device.name, device.location],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
async listDevices(): Promise<CameraDevice[]> {
|
||||
await ensurePostgresSchema();
|
||||
const result = await pool.query<{
|
||||
camera_id: string;
|
||||
name: string;
|
||||
location: string;
|
||||
model: string | null;
|
||||
notes: string | null;
|
||||
is_active: boolean;
|
||||
}>(
|
||||
`SELECT camera_id, name, location, model, notes, is_active
|
||||
FROM devices
|
||||
ORDER BY camera_id ASC`,
|
||||
);
|
||||
|
||||
return result.rows.map((row) => ({
|
||||
cameraId: row.camera_id,
|
||||
name: row.name,
|
||||
location: row.location,
|
||||
model: row.model,
|
||||
notes: row.notes,
|
||||
isActive: row.is_active,
|
||||
}));
|
||||
},
|
||||
|
||||
async getDevice(cameraId: string): Promise<CameraDevice | null> {
|
||||
await ensurePostgresSchema();
|
||||
const result = await pool.query<{
|
||||
camera_id: string;
|
||||
name: string;
|
||||
location: string;
|
||||
model: string | null;
|
||||
notes: string | null;
|
||||
is_active: boolean;
|
||||
}>(
|
||||
`SELECT camera_id, name, location, model, notes, is_active
|
||||
FROM devices
|
||||
WHERE camera_id = $1
|
||||
LIMIT 1`,
|
||||
[cameraId],
|
||||
);
|
||||
|
||||
const row = result.rows[0];
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
cameraId: row.camera_id,
|
||||
name: row.name,
|
||||
location: row.location,
|
||||
model: row.model,
|
||||
notes: row.notes,
|
||||
isActive: row.is_active,
|
||||
};
|
||||
},
|
||||
|
||||
async createDevice(input: CameraCreateRequest): Promise<CameraDevice | null> {
|
||||
await ensurePostgresSchema();
|
||||
const result = await pool.query<{
|
||||
camera_id: string;
|
||||
}>(
|
||||
`INSERT INTO devices (camera_id, name, location, model, notes, is_active, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, NOW())
|
||||
ON CONFLICT (camera_id) DO NOTHING
|
||||
RETURNING camera_id`,
|
||||
[
|
||||
input.cameraId,
|
||||
input.name,
|
||||
input.location,
|
||||
input.model ?? null,
|
||||
input.notes ?? null,
|
||||
input.isActive ?? true,
|
||||
],
|
||||
);
|
||||
|
||||
if (result.rowCount !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.getDevice(input.cameraId);
|
||||
},
|
||||
|
||||
async updateDevice(cameraId: string, patch: CameraDeviceUpdate): Promise<CameraDevice | null> {
|
||||
await ensurePostgresSchema();
|
||||
|
||||
const setClauses: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
|
||||
if (patch.name !== undefined) {
|
||||
values.push(patch.name);
|
||||
setClauses.push(`name = $${values.length}`);
|
||||
}
|
||||
if (patch.location !== undefined) {
|
||||
values.push(patch.location);
|
||||
setClauses.push(`location = $${values.length}`);
|
||||
}
|
||||
if (patch.model !== undefined) {
|
||||
values.push(patch.model);
|
||||
setClauses.push(`model = $${values.length}`);
|
||||
}
|
||||
if (patch.notes !== undefined) {
|
||||
values.push(patch.notes);
|
||||
setClauses.push(`notes = $${values.length}`);
|
||||
}
|
||||
if (patch.isActive !== undefined) {
|
||||
values.push(patch.isActive);
|
||||
setClauses.push(`is_active = $${values.length}`);
|
||||
}
|
||||
|
||||
if (setClauses.length === 0) {
|
||||
return this.getDevice(cameraId);
|
||||
}
|
||||
|
||||
values.push(cameraId);
|
||||
const cameraIdPosition = values.length;
|
||||
await pool.query(
|
||||
`UPDATE devices
|
||||
SET ${setClauses.join(', ')}, updated_at = NOW()
|
||||
WHERE camera_id = $${cameraIdPosition}`,
|
||||
values,
|
||||
);
|
||||
|
||||
return this.getDevice(cameraId);
|
||||
},
|
||||
|
||||
async write(event: TelemetryIngest): Promise<WriteReport> {
|
||||
await ensureQueueWorker();
|
||||
const envelope: TelemetryQueueEnvelope = {
|
||||
event,
|
||||
queuedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
try {
|
||||
await queue.xadd(
|
||||
streamKey,
|
||||
'MAXLEN',
|
||||
'~',
|
||||
streamMaxLen,
|
||||
'*',
|
||||
'event',
|
||||
JSON.stringify(envelope),
|
||||
);
|
||||
return { mongo: true, redis: true, postgres: true };
|
||||
} catch (error) {
|
||||
console.error('telemetry queue enqueue failed', error);
|
||||
return { mongo: false, redis: false, postgres: false };
|
||||
}
|
||||
},
|
||||
|
||||
async readLend(cameraId: string): Promise<CameraLendRecord | null> {
|
||||
try {
|
||||
await ensurePostgresSchema();
|
||||
const result = await pool.query<{
|
||||
borrower: string;
|
||||
due_date: Date;
|
||||
purpose: string | null;
|
||||
lent_at: Date;
|
||||
}>(
|
||||
`SELECT borrower, due_date, purpose, lent_at
|
||||
FROM camera_lending
|
||||
WHERE camera_id = $1
|
||||
LIMIT 1`,
|
||||
[cameraId],
|
||||
);
|
||||
|
||||
const row = result.rows[0];
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
borrower: row.borrower,
|
||||
dueDate: row.due_date.toISOString(),
|
||||
purpose: row.purpose ?? undefined,
|
||||
lentAt: row.lent_at.toISOString(),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('postgres lend read failed', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async saveLend(cameraId: string, lend: CameraLendRecord): Promise<boolean> {
|
||||
try {
|
||||
await ensurePostgresSchema();
|
||||
const result = await pool.query(
|
||||
`INSERT INTO camera_lending (camera_id, borrower, due_date, purpose, lent_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, NOW())
|
||||
ON CONFLICT (camera_id) DO NOTHING`,
|
||||
[cameraId, lend.borrower, lend.dueDate, lend.purpose ?? null, lend.lentAt],
|
||||
);
|
||||
|
||||
return result.rowCount === 1;
|
||||
} catch (error) {
|
||||
console.error('postgres lend write failed', error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
async returnLend(cameraId: string): Promise<boolean> {
|
||||
try {
|
||||
await ensurePostgresSchema();
|
||||
const result = await pool.query('DELETE FROM camera_lending WHERE camera_id = $1', [cameraId]);
|
||||
return result.rowCount === 1;
|
||||
} catch (error) {
|
||||
console.error('postgres lend return failed', error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
async logAudit(
|
||||
cameraId: string,
|
||||
action: CameraAuditAction,
|
||||
actor: string,
|
||||
metadata: Record<string, unknown> = {},
|
||||
): Promise<void> {
|
||||
try {
|
||||
await ensurePostgresSchema();
|
||||
await pool.query(
|
||||
`INSERT INTO camera_audit_logs (camera_id, action, actor, metadata, created_at)
|
||||
VALUES ($1, $2, $3, $4::jsonb, NOW())`,
|
||||
[cameraId, action, actor, JSON.stringify(metadata)],
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('postgres audit write failed', error);
|
||||
}
|
||||
},
|
||||
|
||||
async listAudit(cameraId: string, limit = 20): Promise<CameraAuditLog[]> {
|
||||
try {
|
||||
await ensurePostgresSchema();
|
||||
const boundedLimit = Math.max(1, Math.min(100, limit));
|
||||
const result = await pool.query<{
|
||||
id: string;
|
||||
camera_id: string;
|
||||
action: CameraAuditAction;
|
||||
actor: string;
|
||||
metadata: Record<string, unknown>;
|
||||
created_at: Date;
|
||||
}>(
|
||||
`SELECT id, camera_id, action, actor, metadata, created_at
|
||||
FROM camera_audit_logs
|
||||
WHERE camera_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2`,
|
||||
[cameraId, boundedLimit],
|
||||
);
|
||||
|
||||
return result.rows.map((row) => ({
|
||||
id: Number(row.id),
|
||||
cameraId: row.camera_id,
|
||||
action: row.action,
|
||||
actor: row.actor,
|
||||
metadata: row.metadata ?? {},
|
||||
createdAt: row.created_at.toISOString(),
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('postgres audit read failed', error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
async close() {
|
||||
await Promise.allSettled([mongo.close(), redis.quit(), pool.end()]);
|
||||
stopWorker = true;
|
||||
await Promise.allSettled([workerTask ?? Promise.resolve(), mongo.close(), redis.quit(), queue.quit(), pool.end()]);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createMemoryWriter(writes: TelemetryIngest[] = []): TelemetryWriter {
|
||||
const lends = new Map<string, CameraLendRecord>();
|
||||
const devices = new Map<string, CameraDevice>();
|
||||
const audit: CameraAuditLog[] = [];
|
||||
let nextAuditId = 1;
|
||||
|
||||
return {
|
||||
async ensureDevices(seed: DeviceSeed[]) {
|
||||
for (const item of seed) {
|
||||
if (!devices.has(item.cameraId)) {
|
||||
devices.set(item.cameraId, {
|
||||
cameraId: item.cameraId,
|
||||
name: item.name,
|
||||
location: item.location,
|
||||
model: null,
|
||||
notes: null,
|
||||
isActive: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
async listDevices() {
|
||||
return Array.from(devices.values()).sort((a, b) => a.cameraId.localeCompare(b.cameraId));
|
||||
},
|
||||
async getDevice(cameraId: string) {
|
||||
return devices.get(cameraId) ?? null;
|
||||
},
|
||||
async createDevice(input: CameraCreateRequest) {
|
||||
if (devices.has(input.cameraId)) {
|
||||
return null;
|
||||
}
|
||||
const created: CameraDevice = {
|
||||
cameraId: input.cameraId,
|
||||
name: input.name,
|
||||
location: input.location,
|
||||
model: input.model ?? null,
|
||||
notes: input.notes ?? null,
|
||||
isActive: input.isActive ?? true,
|
||||
};
|
||||
devices.set(created.cameraId, created);
|
||||
return created;
|
||||
},
|
||||
async updateDevice(cameraId: string, patch: CameraDeviceUpdate) {
|
||||
const current = devices.get(cameraId);
|
||||
if (!current) {
|
||||
return null;
|
||||
}
|
||||
const updated: CameraDevice = {
|
||||
...current,
|
||||
...patch,
|
||||
};
|
||||
devices.set(cameraId, updated);
|
||||
return updated;
|
||||
},
|
||||
async write(event: TelemetryIngest) {
|
||||
writes.push(event);
|
||||
return { mongo: true, redis: true, postgres: true };
|
||||
},
|
||||
async readLend(cameraId: string) {
|
||||
return lends.get(cameraId) ?? null;
|
||||
},
|
||||
async saveLend(cameraId: string, lend: CameraLendRecord) {
|
||||
if (lends.has(cameraId)) {
|
||||
return false;
|
||||
}
|
||||
lends.set(cameraId, lend);
|
||||
return true;
|
||||
},
|
||||
async returnLend(cameraId: string) {
|
||||
return lends.delete(cameraId);
|
||||
},
|
||||
async logAudit(cameraId, action, actor, metadata = {}) {
|
||||
audit.unshift({
|
||||
id: nextAuditId,
|
||||
cameraId,
|
||||
action,
|
||||
actor,
|
||||
metadata,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
nextAuditId += 1;
|
||||
},
|
||||
async listAudit(cameraId: string, limit = 20) {
|
||||
const boundedLimit = Math.max(1, Math.min(100, limit));
|
||||
return audit.filter((item) => item.cameraId === cameraId).slice(0, boundedLimit);
|
||||
},
|
||||
async close() {
|
||||
return;
|
||||
},
|
||||
|
||||
+10
-3
@@ -1,4 +1,4 @@
|
||||
import { NavLink, Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { NavLink, Navigate, Route, Routes, useLocation } from 'react-router-dom'
|
||||
import { DevicesPage } from './pages/DevicesPage'
|
||||
import { GreetingPage } from './pages/GreetingPage'
|
||||
|
||||
@@ -8,10 +8,17 @@ const linkClass = ({ isActive }: { isActive: boolean }) =>
|
||||
}`
|
||||
|
||||
export default function App() {
|
||||
const location = useLocation()
|
||||
const isDevicesRoute = location.pathname.startsWith('/devices')
|
||||
const containerClass = isDevicesRoute ? 'mx-auto w-full max-w-[96rem] px-4 py-8' : 'mx-auto max-w-3xl px-4 py-8'
|
||||
const headerContainerClass = isDevicesRoute
|
||||
? 'mx-auto flex w-full max-w-[96rem] items-center justify-between px-4 py-3'
|
||||
: 'mx-auto flex max-w-3xl items-center justify-between px-4 py-3'
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-100">
|
||||
<header className="border-b border-slate-200 bg-white">
|
||||
<div className="mx-auto flex max-w-3xl items-center justify-between px-4 py-3">
|
||||
<div className={headerContainerClass}>
|
||||
<span className="font-semibold text-slate-900">marco-trest-test</span>
|
||||
<nav className="flex gap-2">
|
||||
<NavLink to="/greeting" className={linkClass}>
|
||||
@@ -23,7 +30,7 @@ export default function App() {
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<main className="mx-auto max-w-3xl px-4 py-8">
|
||||
<main className={containerClass}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/devices" replace />} />
|
||||
<Route path="/greeting" element={<GreetingPage />} />
|
||||
|
||||
@@ -1,104 +1,594 @@
|
||||
import { useEffect } from 'react'
|
||||
import type { DeviceStatus } from 'contracts'
|
||||
import { PRESENCE_TTL_SECONDS } from 'contracts'
|
||||
import { fetchDeviceStatus, pingDevice } from '../store/device-slice'
|
||||
import type { CameraListItem } from 'contracts'
|
||||
import {
|
||||
closeCreateModal,
|
||||
closeAuditModal,
|
||||
closeEditModal,
|
||||
closeLendModal,
|
||||
createCamera,
|
||||
fetchCameraAudit,
|
||||
fetchCameras,
|
||||
openCreateModal,
|
||||
openEditModal,
|
||||
openLendModal,
|
||||
returnCamera,
|
||||
saveCameraUpdates,
|
||||
setCreateCameraId,
|
||||
setCreateIsActive,
|
||||
setCreateLocation,
|
||||
setCreateModel,
|
||||
setCreateName,
|
||||
setCreateNotes,
|
||||
setEditIsActive,
|
||||
setEditLocation,
|
||||
setEditModel,
|
||||
setEditName,
|
||||
setEditNotes,
|
||||
setLendBorrower,
|
||||
setLendDueDate,
|
||||
setLendPurpose,
|
||||
simulateCameraIngest,
|
||||
submitCameraLend,
|
||||
toggleCameraActivation,
|
||||
} from '../store/camera-slice'
|
||||
import { useAppDispatch, useAppSelector } from '../store/hooks'
|
||||
|
||||
function StatusCard({ title, status }: { title: string; status: DeviceStatus | undefined }) {
|
||||
if (!status) {
|
||||
return (
|
||||
<article className="rounded-xl border border-slate-200 bg-white p-4">
|
||||
<h2 className="font-semibold text-slate-900">{title}</h2>
|
||||
<p className="mt-2 text-sm text-slate-500">No data yet.</p>
|
||||
</article>
|
||||
)
|
||||
function statusPillClass(health: string) {
|
||||
if (health === 'online') {
|
||||
return 'bg-emerald-50 text-emerald-700 ring-1 ring-emerald-200'
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="rounded-xl border border-slate-200 bg-white p-4">
|
||||
<h2 className="font-semibold text-slate-900">{title}</h2>
|
||||
<dl className="mt-3 space-y-1 text-sm text-slate-700">
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt>online</dt>
|
||||
<dd className={status.online ? 'font-medium text-emerald-700' : 'text-slate-500'}>
|
||||
{status.online ? 'yes' : 'no'}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt>source</dt>
|
||||
<dd>{status.source}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt>age</dt>
|
||||
<dd>{status.ageMs === null ? '—' : `${status.ageMs} ms`}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt>lastSeen</dt>
|
||||
<dd className="truncate">{status.lastSeen ?? '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>payload</dt>
|
||||
<dd className="mt-1 overflow-auto rounded bg-slate-50 p-2 font-mono text-xs">
|
||||
{JSON.stringify(status.payload)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</article>
|
||||
)
|
||||
if (health === 'offline') {
|
||||
return 'bg-rose-50 text-rose-700 ring-1 ring-rose-200'
|
||||
}
|
||||
return 'bg-slate-100 text-slate-600 ring-1 ring-slate-200'
|
||||
}
|
||||
|
||||
export function DevicesPage() {
|
||||
const dispatch = useAppDispatch()
|
||||
const { deviceId, pair, pinging, error } = useAppSelector((state) => state.device)
|
||||
const {
|
||||
cameras,
|
||||
loading,
|
||||
error,
|
||||
message,
|
||||
isCreating,
|
||||
simulatingCameraId,
|
||||
createModalOpen,
|
||||
lendModalCameraId,
|
||||
editModalCameraId,
|
||||
auditModalCameraId,
|
||||
auditLogs,
|
||||
isAuditLoading,
|
||||
isLending,
|
||||
isUpdating,
|
||||
isReturning,
|
||||
isTogglingActivation,
|
||||
createForm,
|
||||
lendForm,
|
||||
editForm,
|
||||
} = useAppSelector((state) => state.cameraPage)
|
||||
|
||||
const lendModalCamera = cameras.find((camera) => camera.cameraId === lendModalCameraId) ?? null
|
||||
const editModalCamera = cameras.find((camera) => camera.cameraId === editModalCameraId) ?? null
|
||||
const auditModalCamera = cameras.find((camera) => camera.cameraId === auditModalCameraId) ?? null
|
||||
|
||||
useEffect(() => {
|
||||
void dispatch(fetchDeviceStatus(deviceId))
|
||||
void dispatch(fetchCameras())
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
void dispatch(fetchDeviceStatus(deviceId))
|
||||
void dispatch(fetchCameras())
|
||||
}, 1000)
|
||||
|
||||
return () => window.clearInterval(timer)
|
||||
}, [dispatch, deviceId])
|
||||
}, [dispatch])
|
||||
|
||||
function submitLend(camera: CameraListItem) {
|
||||
void dispatch(submitCameraLend(camera.cameraId))
|
||||
}
|
||||
|
||||
function submitUpdate(camera: CameraListItem) {
|
||||
void dispatch(saveCameraUpdates(camera.cameraId))
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<h1 className="text-xl font-semibold text-slate-900">Device status</h1>
|
||||
<h1 className="text-xl font-semibold text-slate-900">Camera Operations</h1>
|
||||
<p className="mt-2 text-sm text-slate-600">
|
||||
Product UI reads Express → Postgres. Redis is only a TTL presence copy. After{' '}
|
||||
{PRESENCE_TTL_SECONDS}s without a ping, Redis goes empty; Postgres keeps the last row.
|
||||
Manage camera status in one table, auto-refreshed every second.
|
||||
</p>
|
||||
<p className="mt-2 text-xs font-medium uppercase tracking-wide text-amber-700">
|
||||
Ping device talks to ingest-api — demo only. Production UI never calls the device API.
|
||||
<p className="mt-1 text-xs font-medium uppercase tracking-wide text-emerald-700">
|
||||
Auto ingest is running every second.
|
||||
</p>
|
||||
<div className="mt-2 flex items-center justify-between gap-3">
|
||||
<p className="text-xs text-slate-500">Total cameras: {cameras.length}</p>
|
||||
<button
|
||||
type="button"
|
||||
disabled={pinging}
|
||||
onClick={() => void dispatch(pingDevice(deviceId))}
|
||||
className="mt-4 rounded-md bg-slate-900 px-4 py-2 text-sm font-medium text-white disabled:opacity-60"
|
||||
onClick={() => dispatch(openCreateModal())}
|
||||
className="rounded-md bg-indigo-600 px-3 py-1.5 text-xs font-semibold text-white"
|
||||
>
|
||||
{pinging ? 'Pinging…' : `Ping ${deviceId}`}
|
||||
Create camera
|
||||
</button>
|
||||
{error ? (
|
||||
<p className="mt-4 rounded-md bg-red-50 px-3 py-2 text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
|
||||
{error ? <p className="mt-3 rounded-md bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</p> : null}
|
||||
{message ? (
|
||||
<p className="mt-3 rounded-md bg-emerald-50 px-3 py-2 text-sm text-emerald-700">{message}</p>
|
||||
) : null}
|
||||
|
||||
<div className="mt-5 overflow-hidden rounded-xl border border-slate-200">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-slate-200 bg-white text-sm">
|
||||
<thead className="bg-slate-50">
|
||||
<tr className="text-left text-xs uppercase tracking-wide text-slate-500">
|
||||
<th className="px-4 py-3 font-semibold">Camera</th>
|
||||
<th className="px-4 py-3 font-semibold">Location</th>
|
||||
<th className="px-4 py-3 font-semibold">Health</th>
|
||||
<th className="px-4 py-3 font-semibold">Last Seen</th>
|
||||
<th className="px-4 py-3 font-semibold">Latest Payload</th>
|
||||
<th className="px-4 py-3 font-semibold">Lending</th>
|
||||
<th className="px-4 py-3 font-semibold">State</th>
|
||||
<th className="px-4 py-3 font-semibold">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{loading && cameras.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-4 py-8 text-center text-slate-500">
|
||||
Loading cameras...
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{!loading && cameras.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-4 py-8 text-center text-slate-500">
|
||||
No cameras found.
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{cameras.map((camera) => (
|
||||
<tr key={camera.cameraId} className="align-top">
|
||||
<td className="px-4 py-3">
|
||||
<p className="font-semibold text-slate-900">{camera.name}</p>
|
||||
<p className="font-mono text-xs text-slate-500">{camera.cameraId}</p>
|
||||
<p className="text-xs text-slate-500">{camera.model ?? 'Model n/a'}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-700">{camera.location}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-flex rounded-full px-2.5 py-1 text-xs font-medium ${statusPillClass(camera.latest.health)}`}
|
||||
>
|
||||
{camera.latest.health}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-700">{camera.latest.lastSeen ?? '—'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<pre className="max-w-xs overflow-auto rounded bg-slate-50 p-2 font-mono text-xs text-slate-700">
|
||||
{JSON.stringify(camera.latest.payload ?? {}, null, 0)}
|
||||
</pre>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-700">
|
||||
{camera.lend ? (
|
||||
<div>
|
||||
<p className="font-medium">{camera.lend.borrower}</p>
|
||||
<p className="text-xs">Due {new Date(camera.lend.dueDate).toLocaleString()}</p>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-slate-500">Available</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{camera.isActive ? (
|
||||
<span className="rounded-full bg-emerald-50 px-2.5 py-1 text-xs font-medium text-emerald-700 ring-1 ring-emerald-200">
|
||||
Activated
|
||||
</span>
|
||||
) : (
|
||||
<span className="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-600 ring-1 ring-slate-200">
|
||||
Deactivated
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void dispatch(simulateCameraIngest(camera.cameraId))}
|
||||
disabled={simulatingCameraId === camera.cameraId || !camera.isActive}
|
||||
className="rounded-md bg-slate-900 px-3 py-1.5 text-xs font-medium text-white disabled:opacity-60"
|
||||
>
|
||||
{simulatingCameraId === camera.cameraId ? 'Simulating...' : 'Simulate'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dispatch(openLendModal(camera.cameraId))}
|
||||
disabled={Boolean(camera.lend) || !camera.isActive}
|
||||
className="rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-medium text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{camera.lend ? 'Lent' : 'Lend'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void dispatch(returnCamera(camera.cameraId))}
|
||||
disabled={!camera.lend || isReturning === camera.cameraId}
|
||||
className="rounded-md bg-cyan-700 px-3 py-1.5 text-xs font-medium text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isReturning === camera.cameraId ? 'Returning...' : 'Return'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dispatch(openEditModal(camera))}
|
||||
className="rounded-md bg-amber-600 px-3 py-1.5 text-xs font-medium text-white"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void dispatch(
|
||||
toggleCameraActivation({ cameraId: camera.cameraId, isActive: camera.isActive }),
|
||||
)
|
||||
}
|
||||
disabled={isTogglingActivation === camera.cameraId}
|
||||
className="rounded-md bg-rose-600 px-3 py-1.5 text-xs font-medium text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isTogglingActivation === camera.cameraId
|
||||
? 'Saving...'
|
||||
: camera.isActive
|
||||
? 'Deactivate'
|
||||
: 'Activate'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void dispatch(fetchCameraAudit(camera.cameraId))}
|
||||
className="rounded-md border border-slate-300 px-3 py-1.5 text-xs font-medium text-slate-700"
|
||||
>
|
||||
Audit
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto rounded-xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-700">
|
||||
<p className="mb-2 font-medium text-slate-900">Data flow</p>
|
||||
<pre className="font-mono text-xs leading-6">{`Device ping → Koa ingest :3001
|
||||
→ Mongo (raw event)
|
||||
→ Redis SET device:id EX ${PRESENCE_TTL_SECONDS}s
|
||||
→ Postgres UPSERT device_current_state
|
||||
|
||||
React poll 1s → Express crud :3000
|
||||
→ SELECT postgres snapshot ← near-real-time product path
|
||||
→ GET redis key ← optional presence`}</pre>
|
||||
{lendModalCamera ? (
|
||||
<div className="fixed inset-0 z-40 flex items-center justify-center bg-slate-950/50 p-4">
|
||||
<div className="w-full max-w-lg rounded-xl bg-white p-6 shadow-2xl">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">Lend camera</h2>
|
||||
<p className="mt-1 text-sm text-slate-600">
|
||||
{lendModalCamera.name} ({lendModalCamera.cameraId})
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dispatch(closeLendModal())}
|
||||
className="rounded-md px-2 py-1 text-sm text-slate-500 hover:bg-slate-100"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<StatusCard title="Postgres (Express product path)" status={pair?.postgres} />
|
||||
<StatusCard title="Redis (TTL presence)" status={pair?.redis} />
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
submitLend(lendModalCamera)
|
||||
}}
|
||||
className="mt-4 space-y-3"
|
||||
>
|
||||
<label className="block text-sm text-slate-700">
|
||||
Borrower
|
||||
<input
|
||||
required
|
||||
value={lendForm.borrower}
|
||||
onChange={(event) => dispatch(setLendBorrower(event.target.value))}
|
||||
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block text-sm text-slate-700">
|
||||
Due date
|
||||
<input
|
||||
required
|
||||
type="datetime-local"
|
||||
value={lendForm.dueDate}
|
||||
onChange={(event) => dispatch(setLendDueDate(event.target.value))}
|
||||
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block text-sm text-slate-700">
|
||||
Purpose (optional)
|
||||
<input
|
||||
value={lendForm.purpose}
|
||||
onChange={(event) => dispatch(setLendPurpose(event.target.value))}
|
||||
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dispatch(closeLendModal())}
|
||||
className="rounded-md border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLending}
|
||||
className="rounded-md bg-emerald-600 px-4 py-2 text-sm font-medium text-white disabled:opacity-60"
|
||||
>
|
||||
{isLending ? 'Submitting...' : 'Confirm lend'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{createModalOpen ? (
|
||||
<div className="fixed inset-0 z-40 flex items-center justify-center bg-slate-950/50 p-4">
|
||||
<div className="w-full max-w-lg rounded-xl bg-white p-6 shadow-2xl">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">Create camera</h2>
|
||||
<p className="mt-1 text-sm text-slate-600">Register a new camera device in the catalog.</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dispatch(closeCreateModal())}
|
||||
className="rounded-md px-2 py-1 text-sm text-slate-500 hover:bg-slate-100"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
void dispatch(createCamera())
|
||||
}}
|
||||
className="mt-4 space-y-3"
|
||||
>
|
||||
<label className="block text-sm text-slate-700">
|
||||
Camera ID
|
||||
<input
|
||||
required
|
||||
value={createForm.cameraId}
|
||||
onChange={(event) => dispatch(setCreateCameraId(event.target.value))}
|
||||
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2"
|
||||
placeholder="cam-entrance-04"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block text-sm text-slate-700">
|
||||
Name
|
||||
<input
|
||||
required
|
||||
value={createForm.name}
|
||||
onChange={(event) => dispatch(setCreateName(event.target.value))}
|
||||
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block text-sm text-slate-700">
|
||||
Location
|
||||
<input
|
||||
required
|
||||
value={createForm.location}
|
||||
onChange={(event) => dispatch(setCreateLocation(event.target.value))}
|
||||
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block text-sm text-slate-700">
|
||||
Model
|
||||
<input
|
||||
value={createForm.model}
|
||||
onChange={(event) => dispatch(setCreateModel(event.target.value))}
|
||||
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block text-sm text-slate-700">
|
||||
Notes
|
||||
<input
|
||||
value={createForm.notes}
|
||||
onChange={(event) => dispatch(setCreateNotes(event.target.value))}
|
||||
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={createForm.isActive}
|
||||
onChange={(event) => dispatch(setCreateIsActive(event.target.checked))}
|
||||
/>
|
||||
Activated
|
||||
</label>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dispatch(closeCreateModal())}
|
||||
className="rounded-md border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isCreating}
|
||||
className="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white disabled:opacity-60"
|
||||
>
|
||||
{isCreating ? 'Creating...' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{editModalCamera ? (
|
||||
<div className="fixed inset-0 z-40 flex items-center justify-center bg-slate-950/50 p-4">
|
||||
<div className="w-full max-w-lg rounded-xl bg-white p-6 shadow-2xl">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">Update camera</h2>
|
||||
<p className="mt-1 text-sm text-slate-600">
|
||||
{editModalCamera.name} ({editModalCamera.cameraId})
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dispatch(closeEditModal())}
|
||||
className="rounded-md px-2 py-1 text-sm text-slate-500 hover:bg-slate-100"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
submitUpdate(editModalCamera)
|
||||
}}
|
||||
className="mt-4 space-y-3"
|
||||
>
|
||||
<label className="block text-sm text-slate-700">
|
||||
Name
|
||||
<input
|
||||
required
|
||||
value={editForm.name}
|
||||
onChange={(event) => dispatch(setEditName(event.target.value))}
|
||||
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block text-sm text-slate-700">
|
||||
Location
|
||||
<input
|
||||
required
|
||||
value={editForm.location}
|
||||
onChange={(event) => dispatch(setEditLocation(event.target.value))}
|
||||
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block text-sm text-slate-700">
|
||||
Model
|
||||
<input
|
||||
value={editForm.model}
|
||||
onChange={(event) => dispatch(setEditModel(event.target.value))}
|
||||
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block text-sm text-slate-700">
|
||||
Notes
|
||||
<input
|
||||
value={editForm.notes}
|
||||
onChange={(event) => dispatch(setEditNotes(event.target.value))}
|
||||
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.isActive}
|
||||
onChange={(event) => dispatch(setEditIsActive(event.target.checked))}
|
||||
/>
|
||||
Activated
|
||||
</label>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dispatch(closeEditModal())}
|
||||
className="rounded-md border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isUpdating}
|
||||
className="rounded-md bg-amber-600 px-4 py-2 text-sm font-medium text-white disabled:opacity-60"
|
||||
>
|
||||
{isUpdating ? 'Saving...' : 'Save changes'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{auditModalCamera ? (
|
||||
<div className="fixed inset-0 z-40 flex items-center justify-center bg-slate-950/50 p-4">
|
||||
<div className="w-full max-w-3xl rounded-xl bg-white p-6 shadow-2xl">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">Audit log</h2>
|
||||
<p className="mt-1 text-sm text-slate-600">
|
||||
{auditModalCamera.name} ({auditModalCamera.cameraId})
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dispatch(closeAuditModal())}
|
||||
className="rounded-md px-2 py-1 text-sm text-slate-500 hover:bg-slate-100"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 max-h-96 overflow-auto rounded-lg border border-slate-200">
|
||||
<table className="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead className="bg-slate-50">
|
||||
<tr className="text-left text-xs uppercase tracking-wide text-slate-500">
|
||||
<th className="px-3 py-2 font-semibold">Time</th>
|
||||
<th className="px-3 py-2 font-semibold">Action</th>
|
||||
<th className="px-3 py-2 font-semibold">Actor</th>
|
||||
<th className="px-3 py-2 font-semibold">Metadata</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{isAuditLoading ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-3 py-6 text-center text-slate-500">
|
||||
Loading audit logs...
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{!isAuditLoading && auditLogs.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-3 py-6 text-center text-slate-500">
|
||||
No audit logs found.
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{auditLogs.map((log) => (
|
||||
<tr key={log.id} className="align-top">
|
||||
<td className="px-3 py-2 text-slate-700">{new Date(log.createdAt).toLocaleString()}</td>
|
||||
<td className="px-3 py-2 font-medium text-slate-900">{log.action}</td>
|
||||
<td className="px-3 py-2 text-slate-700">{log.actor}</td>
|
||||
<td className="px-3 py-2">
|
||||
<pre className="max-w-md overflow-auto rounded bg-slate-50 p-2 font-mono text-xs text-slate-700">
|
||||
{JSON.stringify(log.metadata)}
|
||||
</pre>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
import { createAsyncThunk, createSlice, type PayloadAction } from '@reduxjs/toolkit'
|
||||
import {
|
||||
cameraAuditLogListSchema,
|
||||
cameraCreateRequestSchema,
|
||||
cameraDeviceUpdateSchema,
|
||||
cameraListResponseSchema,
|
||||
cameraReturnResponseSchema,
|
||||
cameraSimulateIngestResponseSchema,
|
||||
type CameraAuditLog,
|
||||
type CameraListItem,
|
||||
} from 'contracts'
|
||||
import { ingestApi } from '../api/client'
|
||||
import { toErrorMessage } from './greeting-slice'
|
||||
import type { RootState } from './index'
|
||||
|
||||
export type CameraFormState = {
|
||||
borrower: string
|
||||
purpose: string
|
||||
dueDate: string
|
||||
}
|
||||
|
||||
export type CameraEditFormState = {
|
||||
name: string
|
||||
location: string
|
||||
model: string
|
||||
notes: string
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
export type CameraPageState = {
|
||||
cameras: CameraListItem[]
|
||||
loading: boolean
|
||||
error: string | null
|
||||
message: string | null
|
||||
isCreating: boolean
|
||||
simulatingCameraId: string | null
|
||||
createModalOpen: boolean
|
||||
lendModalCameraId: string | null
|
||||
editModalCameraId: string | null
|
||||
auditModalCameraId: string | null
|
||||
auditLogs: CameraAuditLog[]
|
||||
isAuditLoading: boolean
|
||||
isLending: boolean
|
||||
isUpdating: boolean
|
||||
isReturning: string | null
|
||||
isTogglingActivation: string | null
|
||||
lendForm: CameraFormState
|
||||
createForm: CameraEditFormState & { cameraId: string }
|
||||
editForm: CameraEditFormState
|
||||
}
|
||||
|
||||
const initialState: CameraPageState = {
|
||||
cameras: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
message: null,
|
||||
isCreating: false,
|
||||
simulatingCameraId: null,
|
||||
createModalOpen: false,
|
||||
lendModalCameraId: null,
|
||||
editModalCameraId: null,
|
||||
auditModalCameraId: null,
|
||||
auditLogs: [],
|
||||
isAuditLoading: false,
|
||||
isLending: false,
|
||||
isUpdating: false,
|
||||
isReturning: null,
|
||||
isTogglingActivation: null,
|
||||
lendForm: {
|
||||
borrower: '',
|
||||
purpose: '',
|
||||
dueDate: '',
|
||||
},
|
||||
createForm: {
|
||||
cameraId: '',
|
||||
name: '',
|
||||
location: '',
|
||||
model: '',
|
||||
notes: '',
|
||||
isActive: true,
|
||||
},
|
||||
editForm: {
|
||||
name: '',
|
||||
location: '',
|
||||
model: '',
|
||||
notes: '',
|
||||
isActive: true,
|
||||
},
|
||||
}
|
||||
|
||||
export const fetchCameras = createAsyncThunk<CameraListItem[], void, { rejectValue: string }>(
|
||||
'cameraPage/fetchCameras',
|
||||
async (_, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await ingestApi.get('/cameras')
|
||||
const parsed = cameraListResponseSchema.parse(response.data)
|
||||
return parsed.items
|
||||
} catch (error) {
|
||||
return rejectWithValue(toErrorMessage(error))
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
export const createCamera = createAsyncThunk<string, void, { state: RootState; rejectValue: string }>(
|
||||
'cameraPage/createCamera',
|
||||
async (_, { dispatch, getState, rejectWithValue }) => {
|
||||
const { createForm } = getState().cameraPage
|
||||
try {
|
||||
const body = cameraCreateRequestSchema.parse({
|
||||
cameraId: createForm.cameraId,
|
||||
name: createForm.name,
|
||||
location: createForm.location,
|
||||
model: createForm.model ? createForm.model : null,
|
||||
notes: createForm.notes ? createForm.notes : null,
|
||||
isActive: createForm.isActive,
|
||||
})
|
||||
|
||||
await ingestApi.post('/cameras', body)
|
||||
await dispatch(fetchCameras()).unwrap()
|
||||
return body.cameraId
|
||||
} catch (error) {
|
||||
return rejectWithValue(toErrorMessage(error))
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
export const simulateCameraIngest = createAsyncThunk<string, string, { rejectValue: string }>(
|
||||
'cameraPage/simulateIngest',
|
||||
async (cameraId, { dispatch, rejectWithValue }) => {
|
||||
try {
|
||||
const response = await ingestApi.post(`/cameras/${cameraId}/simulate-ingest`, {})
|
||||
cameraSimulateIngestResponseSchema.parse(response.data)
|
||||
await dispatch(fetchCameras()).unwrap()
|
||||
return cameraId
|
||||
} catch (error) {
|
||||
return rejectWithValue(toErrorMessage(error))
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
export const submitCameraLend = createAsyncThunk<string, string, { state: RootState; rejectValue: string }>(
|
||||
'cameraPage/submitLend',
|
||||
async (cameraId, { dispatch, getState, rejectWithValue }) => {
|
||||
const { lendForm } = getState().cameraPage
|
||||
if (!lendForm.borrower || !lendForm.dueDate) {
|
||||
return rejectWithValue('Borrower and due date are required')
|
||||
}
|
||||
|
||||
try {
|
||||
await ingestApi.post(`/cameras/${cameraId}/lend`, {
|
||||
borrower: lendForm.borrower,
|
||||
dueDate: new Date(lendForm.dueDate).toISOString(),
|
||||
purpose: lendForm.purpose || undefined,
|
||||
})
|
||||
await dispatch(fetchCameras()).unwrap()
|
||||
return cameraId
|
||||
} catch (error) {
|
||||
return rejectWithValue(toErrorMessage(error))
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
export const returnCamera = createAsyncThunk<string, string, { rejectValue: string }>(
|
||||
'cameraPage/returnCamera',
|
||||
async (cameraId, { dispatch, rejectWithValue }) => {
|
||||
try {
|
||||
const response = await ingestApi.post(`/cameras/${cameraId}/return`, {})
|
||||
cameraReturnResponseSchema.parse(response.data)
|
||||
await dispatch(fetchCameras()).unwrap()
|
||||
return cameraId
|
||||
} catch (error) {
|
||||
return rejectWithValue(toErrorMessage(error))
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
export const saveCameraUpdates = createAsyncThunk<
|
||||
string,
|
||||
string,
|
||||
{ state: RootState; rejectValue: string }
|
||||
>('cameraPage/saveUpdates', async (cameraId, { dispatch, getState, rejectWithValue }) => {
|
||||
const { editForm } = getState().cameraPage
|
||||
|
||||
try {
|
||||
const patch = cameraDeviceUpdateSchema.parse({
|
||||
name: editForm.name,
|
||||
location: editForm.location,
|
||||
model: editForm.model ? editForm.model : null,
|
||||
notes: editForm.notes ? editForm.notes : null,
|
||||
isActive: editForm.isActive,
|
||||
})
|
||||
await ingestApi.patch(`/cameras/${cameraId}`, patch)
|
||||
await dispatch(fetchCameras()).unwrap()
|
||||
return cameraId
|
||||
} catch (error) {
|
||||
return rejectWithValue(toErrorMessage(error))
|
||||
}
|
||||
})
|
||||
|
||||
export const toggleCameraActivation = createAsyncThunk<
|
||||
{ cameraId: string; nextActive: boolean },
|
||||
{ cameraId: string; isActive: boolean },
|
||||
{ rejectValue: string }
|
||||
>('cameraPage/toggleActivation', async ({ cameraId, isActive }, { dispatch, rejectWithValue }) => {
|
||||
try {
|
||||
const nextActive = !isActive
|
||||
await ingestApi.patch(`/cameras/${cameraId}`, { isActive: nextActive })
|
||||
await dispatch(fetchCameras()).unwrap()
|
||||
return { cameraId, nextActive }
|
||||
} catch (error) {
|
||||
return rejectWithValue(toErrorMessage(error))
|
||||
}
|
||||
})
|
||||
|
||||
export const fetchCameraAudit = createAsyncThunk<
|
||||
{ cameraId: string; items: CameraAuditLog[] },
|
||||
string,
|
||||
{ rejectValue: string }
|
||||
>('cameraPage/fetchAudit', async (cameraId, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await ingestApi.get(`/cameras/${cameraId}/audit`, { params: { limit: 50 } })
|
||||
const parsed = cameraAuditLogListSchema.parse(response.data)
|
||||
return { cameraId, items: parsed.items }
|
||||
} catch (error) {
|
||||
return rejectWithValue(toErrorMessage(error))
|
||||
}
|
||||
})
|
||||
|
||||
const cameraPageSlice = createSlice({
|
||||
name: 'cameraPage',
|
||||
initialState,
|
||||
reducers: {
|
||||
clearBanner(state) {
|
||||
state.error = null
|
||||
state.message = null
|
||||
},
|
||||
openLendModal(state, action: PayloadAction<string>) {
|
||||
state.lendModalCameraId = action.payload
|
||||
state.lendForm = { borrower: '', purpose: '', dueDate: '' }
|
||||
state.error = null
|
||||
state.message = null
|
||||
},
|
||||
openCreateModal(state) {
|
||||
state.createModalOpen = true
|
||||
state.createForm = {
|
||||
cameraId: '',
|
||||
name: '',
|
||||
location: '',
|
||||
model: '',
|
||||
notes: '',
|
||||
isActive: true,
|
||||
}
|
||||
state.error = null
|
||||
state.message = null
|
||||
},
|
||||
closeCreateModal(state) {
|
||||
state.createModalOpen = false
|
||||
},
|
||||
setCreateCameraId(state, action: PayloadAction<string>) {
|
||||
state.createForm.cameraId = action.payload
|
||||
},
|
||||
setCreateName(state, action: PayloadAction<string>) {
|
||||
state.createForm.name = action.payload
|
||||
},
|
||||
setCreateLocation(state, action: PayloadAction<string>) {
|
||||
state.createForm.location = action.payload
|
||||
},
|
||||
setCreateModel(state, action: PayloadAction<string>) {
|
||||
state.createForm.model = action.payload
|
||||
},
|
||||
setCreateNotes(state, action: PayloadAction<string>) {
|
||||
state.createForm.notes = action.payload
|
||||
},
|
||||
setCreateIsActive(state, action: PayloadAction<boolean>) {
|
||||
state.createForm.isActive = action.payload
|
||||
},
|
||||
closeLendModal(state) {
|
||||
state.lendModalCameraId = null
|
||||
},
|
||||
setLendBorrower(state, action: PayloadAction<string>) {
|
||||
state.lendForm.borrower = action.payload
|
||||
},
|
||||
setLendPurpose(state, action: PayloadAction<string>) {
|
||||
state.lendForm.purpose = action.payload
|
||||
},
|
||||
setLendDueDate(state, action: PayloadAction<string>) {
|
||||
state.lendForm.dueDate = action.payload
|
||||
},
|
||||
openEditModal(state, action: PayloadAction<CameraListItem>) {
|
||||
const camera = action.payload
|
||||
state.editModalCameraId = camera.cameraId
|
||||
state.editForm = {
|
||||
name: camera.name,
|
||||
location: camera.location,
|
||||
model: camera.model ?? '',
|
||||
notes: camera.notes ?? '',
|
||||
isActive: camera.isActive,
|
||||
}
|
||||
state.error = null
|
||||
state.message = null
|
||||
},
|
||||
closeEditModal(state) {
|
||||
state.editModalCameraId = null
|
||||
},
|
||||
setEditName(state, action: PayloadAction<string>) {
|
||||
state.editForm.name = action.payload
|
||||
},
|
||||
setEditLocation(state, action: PayloadAction<string>) {
|
||||
state.editForm.location = action.payload
|
||||
},
|
||||
setEditModel(state, action: PayloadAction<string>) {
|
||||
state.editForm.model = action.payload
|
||||
},
|
||||
setEditNotes(state, action: PayloadAction<string>) {
|
||||
state.editForm.notes = action.payload
|
||||
},
|
||||
setEditIsActive(state, action: PayloadAction<boolean>) {
|
||||
state.editForm.isActive = action.payload
|
||||
},
|
||||
closeAuditModal(state) {
|
||||
state.auditModalCameraId = null
|
||||
state.auditLogs = []
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
.addCase(fetchCameras.pending, (state) => {
|
||||
state.loading = true
|
||||
state.error = null
|
||||
})
|
||||
.addCase(fetchCameras.fulfilled, (state, action) => {
|
||||
state.loading = false
|
||||
state.cameras = action.payload
|
||||
})
|
||||
.addCase(fetchCameras.rejected, (state, action) => {
|
||||
state.loading = false
|
||||
state.error = action.payload ?? action.error.message ?? 'Failed to fetch camera list'
|
||||
})
|
||||
.addCase(createCamera.pending, (state) => {
|
||||
state.isCreating = true
|
||||
state.error = null
|
||||
state.message = null
|
||||
})
|
||||
.addCase(createCamera.fulfilled, (state, action) => {
|
||||
state.isCreating = false
|
||||
state.createModalOpen = false
|
||||
state.message = `Camera ${action.payload} created successfully`
|
||||
})
|
||||
.addCase(createCamera.rejected, (state, action) => {
|
||||
state.isCreating = false
|
||||
state.error = action.payload ?? action.error.message ?? 'Failed to create camera'
|
||||
})
|
||||
.addCase(simulateCameraIngest.pending, (state, action) => {
|
||||
state.simulatingCameraId = action.meta.arg
|
||||
state.error = null
|
||||
state.message = null
|
||||
})
|
||||
.addCase(simulateCameraIngest.fulfilled, (state, action) => {
|
||||
state.simulatingCameraId = null
|
||||
state.message = `Simulated ingest for ${action.payload}`
|
||||
})
|
||||
.addCase(simulateCameraIngest.rejected, (state, action) => {
|
||||
state.simulatingCameraId = null
|
||||
state.error = action.payload ?? action.error.message ?? 'Failed to simulate camera ingest'
|
||||
})
|
||||
.addCase(submitCameraLend.pending, (state) => {
|
||||
state.isLending = true
|
||||
state.error = null
|
||||
state.message = null
|
||||
})
|
||||
.addCase(submitCameraLend.fulfilled, (state, action) => {
|
||||
state.isLending = false
|
||||
state.lendModalCameraId = null
|
||||
state.lendForm = { borrower: '', purpose: '', dueDate: '' }
|
||||
state.message = `Camera ${action.payload} lent successfully`
|
||||
})
|
||||
.addCase(submitCameraLend.rejected, (state, action) => {
|
||||
state.isLending = false
|
||||
state.error = action.payload ?? action.error.message ?? 'Failed to lend camera'
|
||||
})
|
||||
.addCase(returnCamera.pending, (state, action) => {
|
||||
state.isReturning = action.meta.arg
|
||||
state.error = null
|
||||
state.message = null
|
||||
})
|
||||
.addCase(returnCamera.fulfilled, (state, action) => {
|
||||
state.isReturning = null
|
||||
state.message = `Camera ${action.payload} returned successfully`
|
||||
})
|
||||
.addCase(returnCamera.rejected, (state, action) => {
|
||||
state.isReturning = null
|
||||
state.error = action.payload ?? action.error.message ?? 'Failed to return camera'
|
||||
})
|
||||
.addCase(saveCameraUpdates.pending, (state) => {
|
||||
state.isUpdating = true
|
||||
state.error = null
|
||||
state.message = null
|
||||
})
|
||||
.addCase(saveCameraUpdates.fulfilled, (state, action) => {
|
||||
state.isUpdating = false
|
||||
state.editModalCameraId = null
|
||||
state.message = `Camera ${action.payload} updated successfully`
|
||||
})
|
||||
.addCase(saveCameraUpdates.rejected, (state, action) => {
|
||||
state.isUpdating = false
|
||||
state.error = action.payload ?? action.error.message ?? 'Failed to update camera'
|
||||
})
|
||||
.addCase(toggleCameraActivation.pending, (state, action) => {
|
||||
state.isTogglingActivation = action.meta.arg.cameraId
|
||||
state.error = null
|
||||
state.message = null
|
||||
})
|
||||
.addCase(toggleCameraActivation.fulfilled, (state, action) => {
|
||||
state.isTogglingActivation = null
|
||||
state.message = action.payload.nextActive
|
||||
? `Camera ${action.payload.cameraId} activated`
|
||||
: `Camera ${action.payload.cameraId} deactivated`
|
||||
})
|
||||
.addCase(toggleCameraActivation.rejected, (state, action) => {
|
||||
state.isTogglingActivation = null
|
||||
state.error = action.payload ?? action.error.message ?? 'Failed to update camera activation status'
|
||||
})
|
||||
.addCase(fetchCameraAudit.pending, (state, action) => {
|
||||
state.auditModalCameraId = action.meta.arg
|
||||
state.auditLogs = []
|
||||
state.isAuditLoading = true
|
||||
state.error = null
|
||||
})
|
||||
.addCase(fetchCameraAudit.fulfilled, (state, action) => {
|
||||
state.isAuditLoading = false
|
||||
state.auditModalCameraId = action.payload.cameraId
|
||||
state.auditLogs = action.payload.items
|
||||
})
|
||||
.addCase(fetchCameraAudit.rejected, (state, action) => {
|
||||
state.isAuditLoading = false
|
||||
state.error = action.payload ?? action.error.message ?? 'Failed to load audit logs'
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export const {
|
||||
clearBanner,
|
||||
openCreateModal,
|
||||
closeCreateModal,
|
||||
setCreateCameraId,
|
||||
setCreateName,
|
||||
setCreateLocation,
|
||||
setCreateModel,
|
||||
setCreateNotes,
|
||||
setCreateIsActive,
|
||||
openLendModal,
|
||||
closeLendModal,
|
||||
setLendBorrower,
|
||||
setLendPurpose,
|
||||
setLendDueDate,
|
||||
openEditModal,
|
||||
closeEditModal,
|
||||
setEditName,
|
||||
setEditLocation,
|
||||
setEditModel,
|
||||
setEditNotes,
|
||||
setEditIsActive,
|
||||
closeAuditModal,
|
||||
} = cameraPageSlice.actions
|
||||
|
||||
export default cameraPageSlice.reducer
|
||||
@@ -1,4 +1,5 @@
|
||||
import { configureStore } from '@reduxjs/toolkit'
|
||||
import cameraPageReducer from './camera-slice'
|
||||
import deviceReducer from './device-slice'
|
||||
import greetingReducer from './greeting-slice'
|
||||
|
||||
@@ -6,6 +7,7 @@ export const store = configureStore({
|
||||
reducer: {
|
||||
greeting: greetingReducer,
|
||||
device: deviceReducer,
|
||||
cameraPage: cameraPageReducer,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
# Camera Data Flow
|
||||
|
||||
This document describes the current camera ingest and read flow.
|
||||
|
||||
## Overview
|
||||
|
||||
- Active cameras auto-generate ingest events every 1 second.
|
||||
- Events are enqueued to Redis Stream `telemetry:stream`.
|
||||
- A Redis Stream worker consumes events, retries failed writes up to 3 times, and dead-letters failures.
|
||||
- Worker writes:
|
||||
- Mongo raw telemetry events
|
||||
- Redis presence key with TTL
|
||||
- Postgres latest current state on every event
|
||||
- Postgres analytics snapshot at most once every 5 seconds per device
|
||||
- UI reads camera status by polling every 1 second.
|
||||
|
||||
## System Flow Diagram
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph CameraLane[Camera Lane]
|
||||
C1[Active Cameras]
|
||||
T1[Auto Simulator in ingest-api\nEvery 1 second]
|
||||
C1 --> T1
|
||||
end
|
||||
|
||||
subgraph IngestAPI[ingest-api Koa]
|
||||
E1[Build telemetry event]
|
||||
Q1[XADD telemetry:stream]
|
||||
API[POST /telemetry]
|
||||
E1 --> Q1
|
||||
API --> Q1
|
||||
end
|
||||
|
||||
T1 --> E1
|
||||
|
||||
subgraph Redis[Redis]
|
||||
S1[(Stream: telemetry:stream)]
|
||||
G1[Consumer Group Worker\nRetries up to 3]
|
||||
DLQ[(Stream: telemetry:stream:dlq)]
|
||||
P1[(Presence key device:id TTL)]
|
||||
end
|
||||
|
||||
Q1 --> S1
|
||||
S1 --> G1
|
||||
G1 -->|on repeated failure| DLQ
|
||||
|
||||
subgraph Stores[Storage Writes by Worker]
|
||||
M1[(Mongo telemetry raw events)]
|
||||
PG1[(Postgres device_current_state\nLatest status)]
|
||||
PG2[(Postgres device_status_snapshots\nAt most once per 5 sec per device)]
|
||||
end
|
||||
|
||||
G1 --> M1
|
||||
G1 --> P1
|
||||
G1 --> PG1
|
||||
G1 --> PG2
|
||||
|
||||
subgraph ReadPath[Read Path]
|
||||
UI[Web /devices]
|
||||
CAMAPI[GET /cameras\nGET /cameras/:id/latest]
|
||||
UI -->|poll every 1 second| CAMAPI
|
||||
CAMAPI --> PG1
|
||||
CAMAPI --> P1
|
||||
end
|
||||
```
|
||||
|
||||
## Event Timeline Diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Cam as Camera Simulator
|
||||
participant Ingest as ingest api
|
||||
participant Stream as Redis Stream
|
||||
participant Worker as Stream Worker
|
||||
participant Mongo as MongoDB
|
||||
participant Pg as Postgres
|
||||
participant Web as Web UI
|
||||
|
||||
loop every second for each active camera
|
||||
Cam->>Ingest: create telemetry event
|
||||
Ingest->>Stream: XADD telemetry stream
|
||||
Ingest-->>Cam: accepted when enqueue succeeds
|
||||
end
|
||||
|
||||
loop worker processing
|
||||
Worker->>Stream: XREADGROUP
|
||||
Worker->>Mongo: insert raw telemetry
|
||||
Worker->>Pg: upsert latest device current state
|
||||
Worker->>Pg: insert snapshot if older than 5 seconds
|
||||
Worker->>Stream: XACK
|
||||
Note over Worker,Stream: retry up to three times then write to DLQ
|
||||
end
|
||||
|
||||
loop web refresh every second
|
||||
Web->>Ingest: GET cameras
|
||||
Ingest->>Pg: read latest state
|
||||
Ingest-->>Web: return camera rows with latest status
|
||||
end
|
||||
```
|
||||
@@ -4,3 +4,41 @@ CREATE TABLE IF NOT EXISTS device_current_state (
|
||||
last_seen TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS devices (
|
||||
camera_id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
location TEXT NOT NULL,
|
||||
model TEXT,
|
||||
notes TEXT,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS device_status_snapshots (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
device_id TEXT NOT NULL,
|
||||
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
last_seen TIMESTAMPTZ NOT NULL,
|
||||
changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS camera_lending (
|
||||
camera_id TEXT PRIMARY KEY,
|
||||
borrower TEXT NOT NULL,
|
||||
due_date TIMESTAMPTZ NOT NULL,
|
||||
purpose TEXT,
|
||||
lent_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS camera_audit_logs (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
camera_id TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
actor TEXT NOT NULL,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
@@ -0,0 +1,128 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const cameraHealthSchema = z.enum(['online', 'offline', 'unknown']);
|
||||
|
||||
export const cameraDeviceSchema = z.object({
|
||||
cameraId: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
location: z.string().min(1),
|
||||
model: z.string().min(1).nullable(),
|
||||
notes: z.string().min(1).nullable(),
|
||||
isActive: z.boolean(),
|
||||
});
|
||||
|
||||
export const cameraDeviceUpdateSchema = z
|
||||
.object({
|
||||
name: z.string().min(1).optional(),
|
||||
location: z.string().min(1).optional(),
|
||||
model: z.string().min(1).nullable().optional(),
|
||||
notes: z.string().min(1).nullable().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
})
|
||||
.refine((value) => Object.keys(value).length > 0, {
|
||||
message: 'At least one field is required',
|
||||
});
|
||||
|
||||
export const cameraAuditActionSchema = z.enum([
|
||||
'camera_created',
|
||||
'camera_lent',
|
||||
'camera_returned',
|
||||
'camera_updated',
|
||||
'camera_activated',
|
||||
'camera_deactivated',
|
||||
'camera_soft_deleted',
|
||||
]);
|
||||
|
||||
export const cameraAuditLogSchema = z.object({
|
||||
id: z.number().int().positive(),
|
||||
cameraId: z.string().min(1),
|
||||
action: cameraAuditActionSchema,
|
||||
actor: z.string().min(1),
|
||||
metadata: z.record(z.unknown()),
|
||||
createdAt: z.string().datetime(),
|
||||
});
|
||||
|
||||
export const cameraAuditLogListSchema = z.object({
|
||||
items: z.array(cameraAuditLogSchema),
|
||||
});
|
||||
|
||||
export const cameraRuntimePayloadSchema = z.object({
|
||||
fps: z.number().int().nonnegative(),
|
||||
bitrateKbps: z.number().int().positive(),
|
||||
recording: z.boolean(),
|
||||
scene: z.string().min(1),
|
||||
});
|
||||
|
||||
export const cameraLatestStatusSchema = z.object({
|
||||
cameraId: z.string().min(1),
|
||||
lastSeen: z.string().datetime().nullable(),
|
||||
health: cameraHealthSchema,
|
||||
payload: cameraRuntimePayloadSchema.nullable(),
|
||||
});
|
||||
|
||||
export const cameraLendInfoSchema = z.object({
|
||||
borrower: z.string().min(1),
|
||||
dueDate: z.string().datetime(),
|
||||
purpose: z.string().min(1).optional(),
|
||||
lentAt: z.string().datetime(),
|
||||
});
|
||||
|
||||
export const cameraListItemSchema = z.object({
|
||||
cameraId: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
location: z.string().min(1),
|
||||
model: z.string().min(1).nullable(),
|
||||
notes: z.string().min(1).nullable(),
|
||||
isActive: z.boolean(),
|
||||
latest: cameraLatestStatusSchema,
|
||||
lend: cameraLendInfoSchema.nullable(),
|
||||
});
|
||||
|
||||
export const cameraListResponseSchema = z.object({
|
||||
items: z.array(cameraListItemSchema),
|
||||
});
|
||||
|
||||
export const cameraLendRequestSchema = z.object({
|
||||
borrower: z.string().min(1),
|
||||
dueDate: z.string().datetime(),
|
||||
purpose: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
export const cameraCreateRequestSchema = z.object({
|
||||
cameraId: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
location: z.string().min(1),
|
||||
model: z.string().min(1).nullable().optional(),
|
||||
notes: z.string().min(1).nullable().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const cameraReturnResponseSchema = z.object({
|
||||
cameraId: z.string().min(1),
|
||||
returned: z.boolean(),
|
||||
});
|
||||
|
||||
export const cameraSimulateIngestResponseSchema = z.object({
|
||||
accepted: z.boolean(),
|
||||
writes: z.object({
|
||||
mongo: z.boolean(),
|
||||
redis: z.boolean(),
|
||||
postgres: z.boolean(),
|
||||
}),
|
||||
latest: cameraLatestStatusSchema,
|
||||
});
|
||||
|
||||
export type CameraHealth = z.infer<typeof cameraHealthSchema>;
|
||||
export type CameraDevice = z.infer<typeof cameraDeviceSchema>;
|
||||
export type CameraDeviceUpdate = z.infer<typeof cameraDeviceUpdateSchema>;
|
||||
export type CameraAuditAction = z.infer<typeof cameraAuditActionSchema>;
|
||||
export type CameraAuditLog = z.infer<typeof cameraAuditLogSchema>;
|
||||
export type CameraRuntimePayload = z.infer<typeof cameraRuntimePayloadSchema>;
|
||||
export type CameraLatestStatus = z.infer<typeof cameraLatestStatusSchema>;
|
||||
export type CameraLendInfo = z.infer<typeof cameraLendInfoSchema>;
|
||||
export type CameraListItem = z.infer<typeof cameraListItemSchema>;
|
||||
export type CameraListResponse = z.infer<typeof cameraListResponseSchema>;
|
||||
export type CameraLendRequest = z.infer<typeof cameraLendRequestSchema>;
|
||||
export type CameraCreateRequest = z.infer<typeof cameraCreateRequestSchema>;
|
||||
export type CameraReturnResponse = z.infer<typeof cameraReturnResponseSchema>;
|
||||
export type CameraSimulateIngestResponse = z.infer<typeof cameraSimulateIngestResponseSchema>;
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './device-status.js';
|
||||
export * from './camera.js';
|
||||
export * from './greeting.js';
|
||||
export * from './presence.js';
|
||||
export * from './telemetry.js';
|
||||
Reference in new issue
Block a user