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
+2524
-114
No files matched your search
@@ -35,4 +35,114 @@ describe('ingest-api', () => {
|
|||||||
it('rejects invalid telemetry', async () => {
|
it('rejects invalid telemetry', async () => {
|
||||||
await request(app.callback()).post('/telemetry').send({ deviceId: '' }).expect(400);
|
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 cors from '@koa/cors';
|
||||||
import Router from '@koa/router';
|
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 Koa from 'koa';
|
||||||
import { koaBody } from 'koa-body';
|
import { koaBody } from 'koa-body';
|
||||||
import { createTelemetryWriter, type TelemetryWriter } from './stores.js';
|
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 {
|
export function createApp(writer: TelemetryWriter = createTelemetryWriter()): Koa {
|
||||||
const app = new Koa();
|
const app = new Koa();
|
||||||
const router = new Router();
|
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(cors());
|
||||||
app.use(koaBody());
|
app.use(koaBody());
|
||||||
@@ -21,7 +150,7 @@ export function createApp(writer: TelemetryWriter = createTelemetryWriter()): Ko
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.post('/telemetry', async (ctx) => {
|
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) {
|
if (!parsed.success) {
|
||||||
ctx.status = 400;
|
ctx.status = 400;
|
||||||
ctx.body = { error: parsed.error.flatten() };
|
ctx.body = { error: parsed.error.flatten() };
|
||||||
@@ -29,7 +158,313 @@ export function createApp(writer: TelemetryWriter = createTelemetryWriter()): Ko
|
|||||||
}
|
}
|
||||||
|
|
||||||
const writes = await writer.write(parsed.data);
|
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());
|
app.use(router.routes());
|
||||||
|
|||||||
+665
-32
@@ -1,9 +1,35 @@
|
|||||||
import { PRESENCE_TTL_SECONDS, type TelemetryIngest } from 'contracts';
|
import {
|
||||||
import Redis from 'ioredis';
|
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 { MongoClient } from 'mongodb';
|
||||||
import pg from 'pg';
|
import pg from 'pg';
|
||||||
import { env } from './env.js';
|
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 = {
|
export type WriteReport = {
|
||||||
mongo: boolean;
|
mongo: boolean;
|
||||||
redis: boolean;
|
redis: boolean;
|
||||||
@@ -11,7 +37,22 @@ export type WriteReport = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type TelemetryWriter = {
|
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>;
|
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>;
|
close(): Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -22,47 +63,133 @@ function redisKey(deviceId: string): string {
|
|||||||
export function createTelemetryWriter(): TelemetryWriter {
|
export function createTelemetryWriter(): TelemetryWriter {
|
||||||
const mongo = new MongoClient(env.mongoUrl);
|
const mongo = new MongoClient(env.mongoUrl);
|
||||||
const redis = new Redis(env.redisUrl, { maxRetriesPerRequest: 1, lazyConnect: true });
|
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 });
|
const pool = new pg.Pool({ connectionString: env.databaseUrl });
|
||||||
let mongoReady: Promise<MongoClient> | null = null;
|
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> {
|
function mongoClient(): Promise<MongoClient> {
|
||||||
mongoReady ??= mongo.connect();
|
mongoReady ??= mongo.connect();
|
||||||
return mongoReady;
|
return mongoReady;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
function ensurePostgresSchema(): Promise<void> {
|
||||||
async write(event: TelemetryIngest): Promise<WriteReport> {
|
postgresSchemaReady ??= (async () => {
|
||||||
const report: WriteReport = { mongo: false, redis: false, postgres: false };
|
await pool.query(`
|
||||||
const lastSeen = new Date(event.sentAt);
|
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()
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
try {
|
await pool.query(`
|
||||||
const client = await mongoClient();
|
CREATE TABLE IF NOT EXISTS device_status_snapshots (
|
||||||
await client.db().collection('telemetry').insertOne({
|
id BIGSERIAL PRIMARY KEY,
|
||||||
deviceId: event.deviceId,
|
device_id TEXT NOT NULL,
|
||||||
sentAt: lastSeen,
|
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
payload: event.payload,
|
last_seen TIMESTAMPTZ NOT NULL,
|
||||||
ingestedAt: new Date(),
|
changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
});
|
)
|
||||||
report.mongo = true;
|
`);
|
||||||
} catch (error) {
|
|
||||||
console.error('mongo ingest write failed', error);
|
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 {
|
try {
|
||||||
if (redis.status === 'wait') {
|
await queue.xgroup('CREATE', streamKey, streamGroup, '$', 'MKSTREAM');
|
||||||
await redis.connect();
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
if (!message.includes('BUSYGROUP')) {
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
await redis.set(
|
|
||||||
redisKey(event.deviceId),
|
|
||||||
JSON.stringify({ lastSeen: lastSeen.toISOString(), payload: event.payload }),
|
|
||||||
'EX',
|
|
||||||
PRESENCE_TTL_SECONDS,
|
|
||||||
);
|
|
||||||
report.redis = true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('redis ingest write failed', 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);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = await mongoClient();
|
||||||
|
await client.db().collection('telemetry').insertOne({
|
||||||
|
deviceId: event.deviceId,
|
||||||
|
sentAt: lastSeen,
|
||||||
|
payload: event.payload,
|
||||||
|
ingestedAt: new Date(),
|
||||||
|
});
|
||||||
|
report.mongo = true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('mongo ingest write failed', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (redis.status === 'wait') {
|
||||||
|
await redis.connect();
|
||||||
|
}
|
||||||
|
await redis.set(
|
||||||
|
redisKey(event.deviceId),
|
||||||
|
JSON.stringify({ lastSeen: lastSeen.toISOString(), payload: event.payload }),
|
||||||
|
'EX',
|
||||||
|
PRESENCE_TTL_SECONDS,
|
||||||
|
);
|
||||||
|
report.redis = true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('redis ingest write failed', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ensurePostgresSchema();
|
||||||
|
await pool.query('BEGIN');
|
||||||
try {
|
try {
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`INSERT INTO device_current_state (device_id, payload, last_seen, updated_at)
|
`INSERT INTO device_current_state (device_id, payload, last_seen, updated_at)
|
||||||
@@ -73,26 +200,532 @@ export function createTelemetryWriter(): TelemetryWriter {
|
|||||||
updated_at = NOW()`,
|
updated_at = NOW()`,
|
||||||
[event.deviceId, JSON.stringify(event.payload), lastSeen],
|
[event.deviceId, JSON.stringify(event.payload), lastSeen],
|
||||||
);
|
);
|
||||||
report.postgres = true;
|
|
||||||
|
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) {
|
} catch (error) {
|
||||||
console.error('postgres ingest write failed', error);
|
await pool.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
return report;
|
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() {
|
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 {
|
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 {
|
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) {
|
async write(event: TelemetryIngest) {
|
||||||
writes.push(event);
|
writes.push(event);
|
||||||
return { mongo: true, redis: true, postgres: true };
|
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() {
|
async close() {
|
||||||
return;
|
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 { DevicesPage } from './pages/DevicesPage'
|
||||||
import { GreetingPage } from './pages/GreetingPage'
|
import { GreetingPage } from './pages/GreetingPage'
|
||||||
|
|
||||||
@@ -8,10 +8,17 @@ const linkClass = ({ isActive }: { isActive: boolean }) =>
|
|||||||
}`
|
}`
|
||||||
|
|
||||||
export default function App() {
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-slate-100">
|
<div className="min-h-screen bg-slate-100">
|
||||||
<header className="border-b border-slate-200 bg-white">
|
<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>
|
<span className="font-semibold text-slate-900">marco-trest-test</span>
|
||||||
<nav className="flex gap-2">
|
<nav className="flex gap-2">
|
||||||
<NavLink to="/greeting" className={linkClass}>
|
<NavLink to="/greeting" className={linkClass}>
|
||||||
@@ -23,7 +30,7 @@ export default function App() {
|
|||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main className="mx-auto max-w-3xl px-4 py-8">
|
<main className={containerClass}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Navigate to="/devices" replace />} />
|
<Route path="/" element={<Navigate to="/devices" replace />} />
|
||||||
<Route path="/greeting" element={<GreetingPage />} />
|
<Route path="/greeting" element={<GreetingPage />} />
|
||||||
|
|||||||
@@ -1,104 +1,594 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import type { DeviceStatus } from 'contracts'
|
import type { CameraListItem } from 'contracts'
|
||||||
import { PRESENCE_TTL_SECONDS } from 'contracts'
|
import {
|
||||||
import { fetchDeviceStatus, pingDevice } from '../store/device-slice'
|
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'
|
import { useAppDispatch, useAppSelector } from '../store/hooks'
|
||||||
|
|
||||||
function StatusCard({ title, status }: { title: string; status: DeviceStatus | undefined }) {
|
function statusPillClass(health: string) {
|
||||||
if (!status) {
|
if (health === 'online') {
|
||||||
return (
|
return 'bg-emerald-50 text-emerald-700 ring-1 ring-emerald-200'
|
||||||
<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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
if (health === 'offline') {
|
||||||
return (
|
return 'bg-rose-50 text-rose-700 ring-1 ring-rose-200'
|
||||||
<article className="rounded-xl border border-slate-200 bg-white p-4">
|
}
|
||||||
<h2 className="font-semibold text-slate-900">{title}</h2>
|
return 'bg-slate-100 text-slate-600 ring-1 ring-slate-200'
|
||||||
<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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DevicesPage() {
|
export function DevicesPage() {
|
||||||
const dispatch = useAppDispatch()
|
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(() => {
|
useEffect(() => {
|
||||||
void dispatch(fetchDeviceStatus(deviceId))
|
void dispatch(fetchCameras())
|
||||||
|
|
||||||
const timer = window.setInterval(() => {
|
const timer = window.setInterval(() => {
|
||||||
void dispatch(fetchDeviceStatus(deviceId))
|
void dispatch(fetchCameras())
|
||||||
}, 1000)
|
}, 1000)
|
||||||
|
|
||||||
return () => window.clearInterval(timer)
|
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 (
|
return (
|
||||||
<section className="space-y-6">
|
<section className="space-y-6">
|
||||||
<div className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
<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">
|
<p className="mt-2 text-sm text-slate-600">
|
||||||
Product UI reads Express → Postgres. Redis is only a TTL presence copy. After{' '}
|
Manage camera status in one table, auto-refreshed every second.
|
||||||
{PRESENCE_TTL_SECONDS}s without a ping, Redis goes empty; Postgres keeps the last row.
|
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 text-xs font-medium uppercase tracking-wide text-amber-700">
|
<p className="mt-1 text-xs font-medium uppercase tracking-wide text-emerald-700">
|
||||||
Ping device talks to ingest-api — demo only. Production UI never calls the device API.
|
Auto ingest is running every second.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<div className="mt-2 flex items-center justify-between gap-3">
|
||||||
type="button"
|
<p className="text-xs text-slate-500">Total cameras: {cameras.length}</p>
|
||||||
disabled={pinging}
|
<button
|
||||||
onClick={() => void dispatch(pingDevice(deviceId))}
|
type="button"
|
||||||
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}`}
|
>
|
||||||
</button>
|
Create camera
|
||||||
{error ? (
|
</button>
|
||||||
<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}
|
) : 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>
|
||||||
|
|
||||||
<div className="overflow-x-auto rounded-xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-700">
|
{lendModalCamera ? (
|
||||||
<p className="mb-2 font-medium text-slate-900">Data flow</p>
|
<div className="fixed inset-0 z-40 flex items-center justify-center bg-slate-950/50 p-4">
|
||||||
<pre className="font-mono text-xs leading-6">{`Device ping → Koa ingest :3001
|
<div className="w-full max-w-lg rounded-xl bg-white p-6 shadow-2xl">
|
||||||
→ Mongo (raw event)
|
<div className="flex items-start justify-between gap-4">
|
||||||
→ Redis SET device:id EX ${PRESENCE_TTL_SECONDS}s
|
<div>
|
||||||
→ Postgres UPSERT device_current_state
|
<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>
|
||||||
|
|
||||||
React poll 1s → Express crud :3000
|
<form
|
||||||
→ SELECT postgres snapshot ← near-real-time product path
|
onSubmit={(event) => {
|
||||||
→ GET redis key ← optional presence`}</pre>
|
event.preventDefault()
|
||||||
</div>
|
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>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<label className="block text-sm text-slate-700">
|
||||||
<StatusCard title="Postgres (Express product path)" status={pair?.postgres} />
|
Due date
|
||||||
<StatusCard title="Redis (TTL presence)" status={pair?.redis} />
|
<input
|
||||||
</div>
|
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>
|
</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 { configureStore } from '@reduxjs/toolkit'
|
||||||
|
import cameraPageReducer from './camera-slice'
|
||||||
import deviceReducer from './device-slice'
|
import deviceReducer from './device-slice'
|
||||||
import greetingReducer from './greeting-slice'
|
import greetingReducer from './greeting-slice'
|
||||||
|
|
||||||
@@ -6,6 +7,7 @@ export const store = configureStore({
|
|||||||
reducer: {
|
reducer: {
|
||||||
greeting: greetingReducer,
|
greeting: greetingReducer,
|
||||||
device: deviceReducer,
|
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,
|
last_seen TIMESTAMPTZ NOT NULL,
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
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 './device-status.js';
|
||||||
|
export * from './camera.js';
|
||||||
export * from './greeting.js';
|
export * from './greeting.js';
|
||||||
export * from './presence.js';
|
export * from './presence.js';
|
||||||
export * from './telemetry.js';
|
export * from './telemetry.js';
|
||||||
Reference in new issue
Block a user