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:
marco.chan committed 2026-09-04 12:05:52 +08:00
1 parent 7923ba1b8d
commit d3b6c5032f
11 files changed
+2524 -114

No files matched your search

+110
View File
@@ -35,4 +35,114 @@ describe('ingest-api', () => {
it('rejects invalid telemetry', async () => {
await request(app.callback()).post('/telemetry').send({ deviceId: '' }).expect(400);
});
it('lists simulated cameras', async () => {
const response = await request(app.callback()).get('/cameras').expect(200);
expect(response.body.items).toHaveLength(3);
expect(response.body.items[0]?.cameraId).toBe('cam-lobby-01');
expect(response.body.items[0]?.model).toBeNull();
expect(response.body.items[0]?.isActive).toBe(true);
});
it('creates a new camera device and blocks duplicates', async () => {
const created = await request(app.callback())
.post('/cameras')
.send({
cameraId: 'cam-new-77',
name: 'New Camera',
location: 'Dock 4',
model: 'Model-Z',
notes: 'Installed this week',
isActive: true,
})
.expect(201);
expect(created.body.cameraId).toBe('cam-new-77');
expect(created.body.name).toBe('New Camera');
await request(app.callback())
.post('/cameras')
.send({
cameraId: 'cam-new-77',
name: 'Duplicate',
location: 'Dock 5',
})
.expect(409);
});
it('simulates camera ingest and exposes latest status', async () => {
writes.length = 0;
const simulate = await request(app.callback())
.post('/cameras/cam-lobby-01/simulate-ingest')
.send({})
.expect(200);
expect(simulate.body.accepted).toBe(true);
expect(simulate.body.latest.health).toBe('online');
expect(writes).toHaveLength(1);
expect(writes[0]?.deviceId).toBe('cam-lobby-01');
const latest = await request(app.callback()).get('/cameras/cam-lobby-01/latest').expect(200);
expect(latest.body.cameraId).toBe('cam-lobby-01');
expect(latest.body.payload).not.toBeNull();
});
it('accepts lend form and prevents double lend', async () => {
const dueDate = new Date(Date.now() + 86_400_000).toISOString();
const first = await request(app.callback())
.post('/cameras/cam-warehouse-02/lend')
.send({ borrower: 'Marco', dueDate, purpose: 'Site audit' })
.expect(200);
expect(first.body.lend.borrower).toBe('Marco');
await request(app.callback())
.post('/cameras/cam-warehouse-02/lend')
.send({ borrower: 'Another', dueDate })
.expect(409);
const list = await request(app.callback()).get('/cameras').expect(200);
const lent = list.body.items.find((item: { cameraId: string }) => item.cameraId === 'cam-warehouse-02');
expect(lent?.lend?.borrower).toBe('Marco');
const returned = await request(app.callback())
.post('/cameras/cam-warehouse-02/return')
.set('x-actor', 'qa-user')
.send({})
.expect(200);
expect(returned.body.returned).toBe(true);
await request(app.callback()).post('/cameras/cam-warehouse-02/return').send({}).expect(409);
});
it('updates camera metadata from device catalog', async () => {
const update = await request(app.callback())
.patch('/cameras/cam-parking-03')
.send({ name: 'Gate Cam', model: 'Axis-M11', notes: 'North gate', isActive: false })
.expect(200);
expect(update.body.name).toBe('Gate Cam');
expect(update.body.model).toBe('Axis-M11');
expect(update.body.isActive).toBe(false);
const list = await request(app.callback()).get('/cameras').expect(200);
const updated = list.body.items.find((item: { cameraId: string }) => item.cameraId === 'cam-parking-03');
expect(updated?.name).toBe('Gate Cam');
expect(updated?.notes).toBe('North gate');
expect(updated?.isActive).toBe(false);
const audit = await request(app.callback()).get('/cameras/cam-parking-03/audit').expect(200);
expect(audit.body.items.length).toBeGreaterThan(0);
expect(audit.body.items[0].action).toBe('camera_deactivated');
expect(audit.body.items[0].actor).toBe('system-demo');
await request(app.callback())
.patch('/cameras/cam-parking-03')
.send({ isActive: true })
.expect(200);
const restoredAudit = await request(app.callback()).get('/cameras/cam-parking-03/audit').expect(200);
expect(restoredAudit.body.items[0].action).toBe('camera_activated');
});
});
+438 -3
View File
@@ -1,13 +1,142 @@
import cors from '@koa/cors';
import Router from '@koa/router';
import { telemetryIngestSchema } from 'contracts';
import {
cameraAuditLogListSchema,
cameraCreateRequestSchema,
cameraDeviceUpdateSchema,
cameraLendRequestSchema,
cameraListResponseSchema,
cameraReturnResponseSchema,
cameraSimulateIngestResponseSchema,
telemetryIngestSchema,
type CameraLatestStatus,
type CameraLendInfo,
type CameraRuntimePayload,
} from 'contracts';
import Koa from 'koa';
import { koaBody } from 'koa-body';
import { createTelemetryWriter, type TelemetryWriter } from './stores.js';
type CameraSeed = {
cameraId: string;
name: string;
location: string;
};
type CameraState = CameraSeed & {
latest: CameraLatestStatus;
lend: CameraLendInfo | null;
};
const cameraSeeds: CameraSeed[] = [
{ cameraId: 'cam-lobby-01', name: 'Lobby Entrance', location: 'HQ Lobby' },
{ cameraId: 'cam-warehouse-02', name: 'Warehouse Aisle', location: 'Warehouse 1' },
{ cameraId: 'cam-parking-03', name: 'Parking Gate', location: 'Main Gate' },
];
function randomPayload(): CameraRuntimePayload {
const scenes = ['quiet', 'normal', 'crowded'];
return {
fps: 20 + Math.round(Math.random() * 10),
bitrateKbps: 1500 + Math.round(Math.random() * 2500),
recording: Math.random() > 0.08,
scene: scenes[Math.floor(Math.random() * scenes.length)] ?? 'normal',
};
}
function createCameraStates(): Map<string, CameraState> {
return new Map();
}
function ensureCameraState(cameras: Map<string, CameraState>, seed: CameraSeed): CameraState {
const existing = cameras.get(seed.cameraId);
if (existing) {
existing.name = seed.name;
existing.location = seed.location;
return existing;
}
const created: CameraState = {
...seed,
latest: {
cameraId: seed.cameraId,
lastSeen: null,
health: 'unknown',
payload: null,
},
lend: null,
};
cameras.set(seed.cameraId, created);
return created;
}
async function ingestCamera(camera: CameraState, writer: TelemetryWriter) {
const sentAt = new Date().toISOString();
const payload = randomPayload();
const writes = await writer.write({
deviceId: camera.cameraId,
sentAt,
payload: {
source: 'camera-simulator',
...payload,
},
});
camera.latest = {
cameraId: camera.cameraId,
lastSeen: sentAt,
health: 'online',
payload,
};
const accepted = writes.mongo && writes.redis && writes.postgres;
return {
accepted,
writes,
latest: camera.latest,
};
}
export function createApp(writer: TelemetryWriter = createTelemetryWriter()): Koa {
const app = new Koa();
const router = new Router();
const cameras = createCameraStates();
let deviceSeeded: Promise<void> | null = null;
function ensureDeviceCatalog() {
deviceSeeded ??= writer.ensureDevices(cameraSeeds);
return deviceSeeded;
}
function requestActor(ctx: Koa.ParameterizedContext): string {
const actorHeader = ctx.get('x-actor').trim();
return actorHeader.length > 0 ? actorHeader : 'system-demo';
}
const autoIngestTimer = setInterval(() => {
void (async () => {
await ensureDeviceCatalog();
const devices = await writer.listDevices();
await Promise.all(
devices.filter((device) => device.isActive).map(async (device) => {
const camera = ensureCameraState(cameras, {
cameraId: device.cameraId,
name: device.name,
location: device.location,
});
try {
await ingestCamera(camera, writer);
} catch (error) {
console.error(`auto simulate ingest failed for ${camera.cameraId}`, error);
}
}),
);
})().catch((error) => {
console.error('auto simulate ingest tick failed', error);
});
}, 1000);
autoIngestTimer.unref?.();
app.use(cors());
app.use(koaBody());
@@ -21,7 +150,7 @@ export function createApp(writer: TelemetryWriter = createTelemetryWriter()): Ko
});
router.post('/telemetry', async (ctx) => {
const parsed = telemetryIngestSchema.safeParse(ctx.request.body);
const parsed = telemetryIngestSchema.safeParse((ctx.request as { body?: unknown }).body);
if (!parsed.success) {
ctx.status = 400;
ctx.body = { error: parsed.error.flatten() };
@@ -29,7 +158,313 @@ export function createApp(writer: TelemetryWriter = createTelemetryWriter()): Ko
}
const writes = await writer.write(parsed.data);
ctx.body = { accepted: true, writes };
const accepted = writes.mongo && writes.redis && writes.postgres;
if (!accepted) {
ctx.status = 503;
}
ctx.body = { accepted, writes };
});
router.get('/cameras', async (ctx) => {
await ensureDeviceCatalog();
const devices = await writer.listDevices();
const items = await Promise.all(
devices.map(async (device) => {
const camera = ensureCameraState(cameras, {
cameraId: device.cameraId,
name: device.name,
location: device.location,
});
const lend = await writer.readLend(camera.cameraId);
camera.lend = lend;
return {
cameraId: camera.cameraId,
name: camera.name,
location: camera.location,
model: device.model,
notes: device.notes,
isActive: device.isActive,
latest: camera.latest,
lend: camera.lend,
};
}),
);
ctx.body = cameraListResponseSchema.parse({ items });
});
router.get('/cameras/:id/latest', async (ctx) => {
await ensureDeviceCatalog();
const device = await writer.getDevice(ctx.params.id);
if (!device) {
ctx.status = 404;
ctx.body = { error: 'Camera not found' };
return;
}
const camera = ensureCameraState(cameras, {
cameraId: device.cameraId,
name: device.name,
location: device.location,
});
ctx.body = camera.latest;
});
router.post('/cameras', async (ctx) => {
await ensureDeviceCatalog();
const actor = requestActor(ctx);
const parsed = cameraCreateRequestSchema.safeParse((ctx.request as { body?: unknown }).body);
if (!parsed.success) {
ctx.status = 400;
ctx.body = { error: parsed.error.flatten() };
return;
}
const created = await writer.createDevice(parsed.data);
if (!created) {
ctx.status = 409;
ctx.body = { error: 'Camera already exists' };
return;
}
await writer.logAudit(created.cameraId, 'camera_created', actor, {
created: {
name: created.name,
location: created.location,
model: created.model,
notes: created.notes,
isActive: created.isActive,
},
});
const camera = ensureCameraState(cameras, {
cameraId: created.cameraId,
name: created.name,
location: created.location,
});
ctx.status = 201;
ctx.body = {
cameraId: created.cameraId,
name: created.name,
location: created.location,
model: created.model,
notes: created.notes,
isActive: created.isActive,
latest: camera.latest,
lend: null,
};
});
router.post('/cameras/:id/simulate-ingest', async (ctx) => {
await ensureDeviceCatalog();
const device = await writer.getDevice(ctx.params.id);
if (!device) {
ctx.status = 404;
ctx.body = { error: 'Camera not found' };
return;
}
const camera = ensureCameraState(cameras, {
cameraId: device.cameraId,
name: device.name,
location: device.location,
});
ctx.body = cameraSimulateIngestResponseSchema.parse(await ingestCamera(camera, writer));
});
router.patch('/cameras/:id', async (ctx) => {
await ensureDeviceCatalog();
const actor = requestActor(ctx);
const parsed = cameraDeviceUpdateSchema.safeParse((ctx.request as { body?: unknown }).body);
if (!parsed.success) {
ctx.status = 400;
ctx.body = { error: parsed.error.flatten() };
return;
}
const previous = await writer.getDevice(ctx.params.id);
if (!previous) {
ctx.status = 404;
ctx.body = { error: 'Camera not found' };
return;
}
const updated = await writer.updateDevice(ctx.params.id, parsed.data);
if (!updated) {
ctx.status = 404;
ctx.body = { error: 'Camera not found' };
return;
}
const deactivated = previous.isActive && updated.isActive === false;
const activated = previous.isActive === false && updated.isActive;
const action = deactivated
? 'camera_deactivated'
: activated
? 'camera_activated'
: 'camera_updated';
await writer.logAudit(
updated.cameraId,
action,
actor,
{
patch: parsed.data,
previous: {
name: previous.name,
location: previous.location,
model: previous.model,
notes: previous.notes,
isActive: previous.isActive,
},
next: {
name: updated.name,
location: updated.location,
model: updated.model,
notes: updated.notes,
isActive: updated.isActive,
},
},
);
const camera = ensureCameraState(cameras, {
cameraId: updated.cameraId,
name: updated.name,
location: updated.location,
});
ctx.body = {
cameraId: updated.cameraId,
name: updated.name,
location: updated.location,
model: updated.model,
notes: updated.notes,
isActive: updated.isActive,
latest: camera.latest,
lend: camera.lend,
};
});
router.post('/cameras/:id/lend', async (ctx) => {
await ensureDeviceCatalog();
const actor = requestActor(ctx);
const device = await writer.getDevice(ctx.params.id);
if (!device) {
ctx.status = 404;
ctx.body = { error: 'Camera not found' };
return;
}
const camera = ensureCameraState(cameras, {
cameraId: device.cameraId,
name: device.name,
location: device.location,
});
const parsed = cameraLendRequestSchema.safeParse((ctx.request as { body?: unknown }).body);
if (!parsed.success) {
ctx.status = 400;
ctx.body = { error: parsed.error.flatten() };
return;
}
const existing = await writer.readLend(camera.cameraId);
if (existing) {
camera.lend = existing;
ctx.status = 409;
ctx.body = { error: 'Camera already lent' };
return;
}
const lend = {
borrower: parsed.data.borrower,
dueDate: parsed.data.dueDate,
purpose: parsed.data.purpose,
lentAt: new Date().toISOString(),
};
const saved = await writer.saveLend(camera.cameraId, lend);
if (!saved) {
ctx.status = 409;
ctx.body = { error: 'Camera already lent' };
return;
}
await writer.logAudit(camera.cameraId, 'camera_lent', actor, {
borrower: lend.borrower,
dueDate: lend.dueDate,
purpose: lend.purpose,
lentAt: lend.lentAt,
});
camera.lend = lend;
ctx.body = {
cameraId: camera.cameraId,
name: camera.name,
location: camera.location,
model: device.model,
notes: device.notes,
isActive: device.isActive,
latest: camera.latest,
lend: camera.lend,
};
});
router.post('/cameras/:id/return', async (ctx) => {
await ensureDeviceCatalog();
const actor = requestActor(ctx);
const device = await writer.getDevice(ctx.params.id);
if (!device) {
ctx.status = 404;
ctx.body = { error: 'Camera not found' };
return;
}
const currentLend = await writer.readLend(device.cameraId);
if (!currentLend) {
ctx.status = 409;
ctx.body = { error: 'Camera is not currently lent' };
return;
}
const returned = await writer.returnLend(device.cameraId);
if (!returned) {
ctx.status = 500;
ctx.body = { error: 'Failed to return camera' };
return;
}
const camera = cameras.get(device.cameraId);
if (camera) {
camera.lend = null;
}
await writer.logAudit(device.cameraId, 'camera_returned', actor, {
previousLend: currentLend,
returnedAt: new Date().toISOString(),
});
ctx.body = cameraReturnResponseSchema.parse({
cameraId: device.cameraId,
returned: true,
});
});
router.get('/cameras/:id/audit', async (ctx) => {
await ensureDeviceCatalog();
const device = await writer.getDevice(ctx.params.id);
if (!device) {
ctx.status = 404;
ctx.body = { error: 'Camera not found' };
return;
}
const limitRaw = Number(ctx.query.limit);
const limit = Number.isFinite(limitRaw) ? limitRaw : 20;
const items = await writer.listAudit(device.cameraId, limit);
ctx.body = cameraAuditLogListSchema.parse({ items });
});
app.use(router.routes());
+665 -32
View File
@@ -1,9 +1,35 @@
import { PRESENCE_TTL_SECONDS, type TelemetryIngest } from 'contracts';
import Redis from 'ioredis';
import {
PRESENCE_TTL_SECONDS,
type CameraAuditAction,
type CameraAuditLog,
type CameraCreateRequest,
type CameraDevice,
type CameraDeviceUpdate,
type TelemetryIngest,
} from 'contracts';
import { Redis } from 'ioredis';
import { MongoClient } from 'mongodb';
import pg from 'pg';
import { env } from './env.js';
type CameraLendRecord = {
borrower: string;
dueDate: string;
purpose?: string;
lentAt: string;
};
type TelemetryQueueEnvelope = {
event: TelemetryIngest;
queuedAt: string;
};
type DeviceSeed = {
cameraId: string;
name: string;
location: string;
};
export type WriteReport = {
mongo: boolean;
redis: boolean;
@@ -11,7 +37,22 @@ export type WriteReport = {
};
export type TelemetryWriter = {
ensureDevices(seed: DeviceSeed[]): Promise<void>;
listDevices(): Promise<CameraDevice[]>;
getDevice(cameraId: string): Promise<CameraDevice | null>;
createDevice(input: CameraCreateRequest): Promise<CameraDevice | null>;
updateDevice(cameraId: string, patch: CameraDeviceUpdate): Promise<CameraDevice | null>;
write(event: TelemetryIngest): Promise<WriteReport>;
readLend(cameraId: string): Promise<CameraLendRecord | null>;
saveLend(cameraId: string, lend: CameraLendRecord): Promise<boolean>;
returnLend(cameraId: string): Promise<boolean>;
logAudit(
cameraId: string,
action: CameraAuditAction,
actor: string,
metadata?: Record<string, unknown>,
): Promise<void>;
listAudit(cameraId: string, limit?: number): Promise<CameraAuditLog[]>;
close(): Promise<void>;
};
@@ -22,47 +63,133 @@ function redisKey(deviceId: string): string {
export function createTelemetryWriter(): TelemetryWriter {
const mongo = new MongoClient(env.mongoUrl);
const redis = new Redis(env.redisUrl, { maxRetriesPerRequest: 1, lazyConnect: true });
const queue = new Redis(env.redisUrl, { maxRetriesPerRequest: 1, lazyConnect: true });
const pool = new pg.Pool({ connectionString: env.databaseUrl });
let mongoReady: Promise<MongoClient> | null = null;
let postgresSchemaReady: Promise<void> | null = null;
let workerReady: Promise<void> | null = null;
let workerTask: Promise<void> | null = null;
let stopWorker = false;
const streamKey = 'telemetry:stream';
const streamDlqKey = 'telemetry:stream:dlq';
const streamGroup = 'ingest-writers';
const consumerName = `ingest-${process.pid}`;
const streamMaxLen = 50_000;
const streamDlqMaxLen = 5_000;
function mongoClient(): Promise<MongoClient> {
mongoReady ??= mongo.connect();
return mongoReady;
}
return {
async write(event: TelemetryIngest): Promise<WriteReport> {
const report: WriteReport = { mongo: false, redis: false, postgres: false };
const lastSeen = new Date(event.sentAt);
function ensurePostgresSchema(): Promise<void> {
postgresSchemaReady ??= (async () => {
await pool.query(`
CREATE TABLE IF NOT EXISTS devices (
camera_id TEXT PRIMARY KEY,
name TEXT NOT NULL,
location TEXT NOT NULL,
model TEXT,
notes TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`);
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);
await pool.query(`
CREATE TABLE IF NOT EXISTS device_status_snapshots (
id BIGSERIAL PRIMARY KEY,
device_id TEXT NOT NULL,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
last_seen TIMESTAMPTZ NOT NULL,
changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS camera_lending (
camera_id TEXT PRIMARY KEY,
borrower TEXT NOT NULL,
due_date TIMESTAMPTZ NOT NULL,
purpose TEXT,
lent_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS camera_audit_logs (
id BIGSERIAL PRIMARY KEY,
camera_id TEXT NOT NULL,
action TEXT NOT NULL,
actor TEXT NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`);
})();
return postgresSchemaReady;
}
async function ensureQueueWorker(): Promise<void> {
workerReady ??= (async () => {
if (queue.status === 'wait') {
await queue.connect();
}
try {
if (redis.status === 'wait') {
await redis.connect();
await queue.xgroup('CREATE', streamKey, streamGroup, '$', 'MKSTREAM');
} 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 {
await pool.query(
`INSERT INTO device_current_state (device_id, payload, last_seen, updated_at)
@@ -73,26 +200,532 @@ export function createTelemetryWriter(): TelemetryWriter {
updated_at = NOW()`,
[event.deviceId, JSON.stringify(event.payload), lastSeen],
);
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) {
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() {
await Promise.allSettled([mongo.close(), redis.quit(), pool.end()]);
stopWorker = true;
await Promise.allSettled([workerTask ?? Promise.resolve(), mongo.close(), redis.quit(), queue.quit(), pool.end()]);
},
};
}
export function createMemoryWriter(writes: TelemetryIngest[] = []): TelemetryWriter {
const lends = new Map<string, CameraLendRecord>();
const devices = new Map<string, CameraDevice>();
const audit: CameraAuditLog[] = [];
let nextAuditId = 1;
return {
async ensureDevices(seed: DeviceSeed[]) {
for (const item of seed) {
if (!devices.has(item.cameraId)) {
devices.set(item.cameraId, {
cameraId: item.cameraId,
name: item.name,
location: item.location,
model: null,
notes: null,
isActive: true,
});
}
}
},
async listDevices() {
return Array.from(devices.values()).sort((a, b) => a.cameraId.localeCompare(b.cameraId));
},
async getDevice(cameraId: string) {
return devices.get(cameraId) ?? null;
},
async createDevice(input: CameraCreateRequest) {
if (devices.has(input.cameraId)) {
return null;
}
const created: CameraDevice = {
cameraId: input.cameraId,
name: input.name,
location: input.location,
model: input.model ?? null,
notes: input.notes ?? null,
isActive: input.isActive ?? true,
};
devices.set(created.cameraId, created);
return created;
},
async updateDevice(cameraId: string, patch: CameraDeviceUpdate) {
const current = devices.get(cameraId);
if (!current) {
return null;
}
const updated: CameraDevice = {
...current,
...patch,
};
devices.set(cameraId, updated);
return updated;
},
async write(event: TelemetryIngest) {
writes.push(event);
return { mongo: true, redis: true, postgres: true };
},
async readLend(cameraId: string) {
return lends.get(cameraId) ?? null;
},
async saveLend(cameraId: string, lend: CameraLendRecord) {
if (lends.has(cameraId)) {
return false;
}
lends.set(cameraId, lend);
return true;
},
async returnLend(cameraId: string) {
return lends.delete(cameraId);
},
async logAudit(cameraId, action, actor, metadata = {}) {
audit.unshift({
id: nextAuditId,
cameraId,
action,
actor,
metadata,
createdAt: new Date().toISOString(),
});
nextAuditId += 1;
},
async listAudit(cameraId: string, limit = 20) {
const boundedLimit = Math.max(1, Math.min(100, limit));
return audit.filter((item) => item.cameraId === cameraId).slice(0, boundedLimit);
},
async close() {
return;
},
+10 -3
View File
@@ -1,4 +1,4 @@
import { NavLink, Navigate, Route, Routes } from 'react-router-dom'
import { NavLink, Navigate, Route, Routes, useLocation } from 'react-router-dom'
import { DevicesPage } from './pages/DevicesPage'
import { GreetingPage } from './pages/GreetingPage'
@@ -8,10 +8,17 @@ const linkClass = ({ isActive }: { isActive: boolean }) =>
}`
export default function App() {
const location = useLocation()
const isDevicesRoute = location.pathname.startsWith('/devices')
const containerClass = isDevicesRoute ? 'mx-auto w-full max-w-[96rem] px-4 py-8' : 'mx-auto max-w-3xl px-4 py-8'
const headerContainerClass = isDevicesRoute
? 'mx-auto flex w-full max-w-[96rem] items-center justify-between px-4 py-3'
: 'mx-auto flex max-w-3xl items-center justify-between px-4 py-3'
return (
<div className="min-h-screen bg-slate-100">
<header className="border-b border-slate-200 bg-white">
<div className="mx-auto flex max-w-3xl items-center justify-between px-4 py-3">
<div className={headerContainerClass}>
<span className="font-semibold text-slate-900">marco-trest-test</span>
<nav className="flex gap-2">
<NavLink to="/greeting" className={linkClass}>
@@ -23,7 +30,7 @@ export default function App() {
</nav>
</div>
</header>
<main className="mx-auto max-w-3xl px-4 py-8">
<main className={containerClass}>
<Routes>
<Route path="/" element={<Navigate to="/devices" replace />} />
<Route path="/greeting" element={<GreetingPage />} />
+566 -76
View File
@@ -1,104 +1,594 @@
import { useEffect } from 'react'
import type { DeviceStatus } from 'contracts'
import { PRESENCE_TTL_SECONDS } from 'contracts'
import { fetchDeviceStatus, pingDevice } from '../store/device-slice'
import type { CameraListItem } from 'contracts'
import {
closeCreateModal,
closeAuditModal,
closeEditModal,
closeLendModal,
createCamera,
fetchCameraAudit,
fetchCameras,
openCreateModal,
openEditModal,
openLendModal,
returnCamera,
saveCameraUpdates,
setCreateCameraId,
setCreateIsActive,
setCreateLocation,
setCreateModel,
setCreateName,
setCreateNotes,
setEditIsActive,
setEditLocation,
setEditModel,
setEditName,
setEditNotes,
setLendBorrower,
setLendDueDate,
setLendPurpose,
simulateCameraIngest,
submitCameraLend,
toggleCameraActivation,
} from '../store/camera-slice'
import { useAppDispatch, useAppSelector } from '../store/hooks'
function StatusCard({ title, status }: { title: string; status: DeviceStatus | undefined }) {
if (!status) {
return (
<article className="rounded-xl border border-slate-200 bg-white p-4">
<h2 className="font-semibold text-slate-900">{title}</h2>
<p className="mt-2 text-sm text-slate-500">No data yet.</p>
</article>
)
function statusPillClass(health: string) {
if (health === 'online') {
return 'bg-emerald-50 text-emerald-700 ring-1 ring-emerald-200'
}
return (
<article className="rounded-xl border border-slate-200 bg-white p-4">
<h2 className="font-semibold text-slate-900">{title}</h2>
<dl className="mt-3 space-y-1 text-sm text-slate-700">
<div className="flex justify-between gap-4">
<dt>online</dt>
<dd className={status.online ? 'font-medium text-emerald-700' : 'text-slate-500'}>
{status.online ? 'yes' : 'no'}
</dd>
</div>
<div className="flex justify-between gap-4">
<dt>source</dt>
<dd>{status.source}</dd>
</div>
<div className="flex justify-between gap-4">
<dt>age</dt>
<dd>{status.ageMs === null ? '—' : `${status.ageMs} ms`}</dd>
</div>
<div className="flex justify-between gap-4">
<dt>lastSeen</dt>
<dd className="truncate">{status.lastSeen ?? '—'}</dd>
</div>
<div>
<dt>payload</dt>
<dd className="mt-1 overflow-auto rounded bg-slate-50 p-2 font-mono text-xs">
{JSON.stringify(status.payload)}
</dd>
</div>
</dl>
</article>
)
if (health === 'offline') {
return 'bg-rose-50 text-rose-700 ring-1 ring-rose-200'
}
return 'bg-slate-100 text-slate-600 ring-1 ring-slate-200'
}
export function DevicesPage() {
const dispatch = useAppDispatch()
const { deviceId, pair, pinging, error } = useAppSelector((state) => state.device)
const {
cameras,
loading,
error,
message,
isCreating,
simulatingCameraId,
createModalOpen,
lendModalCameraId,
editModalCameraId,
auditModalCameraId,
auditLogs,
isAuditLoading,
isLending,
isUpdating,
isReturning,
isTogglingActivation,
createForm,
lendForm,
editForm,
} = useAppSelector((state) => state.cameraPage)
const lendModalCamera = cameras.find((camera) => camera.cameraId === lendModalCameraId) ?? null
const editModalCamera = cameras.find((camera) => camera.cameraId === editModalCameraId) ?? null
const auditModalCamera = cameras.find((camera) => camera.cameraId === auditModalCameraId) ?? null
useEffect(() => {
void dispatch(fetchDeviceStatus(deviceId))
void dispatch(fetchCameras())
const timer = window.setInterval(() => {
void dispatch(fetchDeviceStatus(deviceId))
void dispatch(fetchCameras())
}, 1000)
return () => window.clearInterval(timer)
}, [dispatch, deviceId])
}, [dispatch])
function submitLend(camera: CameraListItem) {
void dispatch(submitCameraLend(camera.cameraId))
}
function submitUpdate(camera: CameraListItem) {
void dispatch(saveCameraUpdates(camera.cameraId))
}
return (
<section className="space-y-6">
<div className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
<h1 className="text-xl font-semibold text-slate-900">Device status</h1>
<h1 className="text-xl font-semibold text-slate-900">Camera Operations</h1>
<p className="mt-2 text-sm text-slate-600">
Product UI reads Express → Postgres. Redis is only a TTL presence copy. After{' '}
{PRESENCE_TTL_SECONDS}s without a ping, Redis goes empty; Postgres keeps the last row.
Manage camera status in one table, auto-refreshed every second.
</p>
<p className="mt-2 text-xs font-medium uppercase tracking-wide text-amber-700">
Ping device talks to ingest-api — demo only. Production UI never calls the device API.
<p className="mt-1 text-xs font-medium uppercase tracking-wide text-emerald-700">
Auto ingest is running every second.
</p>
<button
type="button"
disabled={pinging}
onClick={() => void dispatch(pingDevice(deviceId))}
className="mt-4 rounded-md bg-slate-900 px-4 py-2 text-sm font-medium text-white disabled:opacity-60"
>
{pinging ? 'Pinging…' : `Ping ${deviceId}`}
</button>
{error ? (
<p className="mt-4 rounded-md bg-red-50 px-3 py-2 text-sm text-red-700">{error}</p>
<div className="mt-2 flex items-center justify-between gap-3">
<p className="text-xs text-slate-500">Total cameras: {cameras.length}</p>
<button
type="button"
onClick={() => dispatch(openCreateModal())}
className="rounded-md bg-indigo-600 px-3 py-1.5 text-xs font-semibold text-white"
>
Create camera
</button>
</div>
{error ? <p className="mt-3 rounded-md bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</p> : null}
{message ? (
<p className="mt-3 rounded-md bg-emerald-50 px-3 py-2 text-sm text-emerald-700">{message}</p>
) : null}
<div className="mt-5 overflow-hidden rounded-xl border border-slate-200">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-slate-200 bg-white text-sm">
<thead className="bg-slate-50">
<tr className="text-left text-xs uppercase tracking-wide text-slate-500">
<th className="px-4 py-3 font-semibold">Camera</th>
<th className="px-4 py-3 font-semibold">Location</th>
<th className="px-4 py-3 font-semibold">Health</th>
<th className="px-4 py-3 font-semibold">Last Seen</th>
<th className="px-4 py-3 font-semibold">Latest Payload</th>
<th className="px-4 py-3 font-semibold">Lending</th>
<th className="px-4 py-3 font-semibold">State</th>
<th className="px-4 py-3 font-semibold">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{loading && cameras.length === 0 ? (
<tr>
<td colSpan={8} className="px-4 py-8 text-center text-slate-500">
Loading cameras...
</td>
</tr>
) : null}
{!loading && cameras.length === 0 ? (
<tr>
<td colSpan={8} className="px-4 py-8 text-center text-slate-500">
No cameras found.
</td>
</tr>
) : null}
{cameras.map((camera) => (
<tr key={camera.cameraId} className="align-top">
<td className="px-4 py-3">
<p className="font-semibold text-slate-900">{camera.name}</p>
<p className="font-mono text-xs text-slate-500">{camera.cameraId}</p>
<p className="text-xs text-slate-500">{camera.model ?? 'Model n/a'}</p>
</td>
<td className="px-4 py-3 text-slate-700">{camera.location}</td>
<td className="px-4 py-3">
<span
className={`inline-flex rounded-full px-2.5 py-1 text-xs font-medium ${statusPillClass(camera.latest.health)}`}
>
{camera.latest.health}
</span>
</td>
<td className="px-4 py-3 text-slate-700">{camera.latest.lastSeen ?? '—'}</td>
<td className="px-4 py-3">
<pre className="max-w-xs overflow-auto rounded bg-slate-50 p-2 font-mono text-xs text-slate-700">
{JSON.stringify(camera.latest.payload ?? {}, null, 0)}
</pre>
</td>
<td className="px-4 py-3 text-slate-700">
{camera.lend ? (
<div>
<p className="font-medium">{camera.lend.borrower}</p>
<p className="text-xs">Due {new Date(camera.lend.dueDate).toLocaleString()}</p>
</div>
) : (
<span className="text-slate-500">Available</span>
)}
</td>
<td className="px-4 py-3">
{camera.isActive ? (
<span className="rounded-full bg-emerald-50 px-2.5 py-1 text-xs font-medium text-emerald-700 ring-1 ring-emerald-200">
Activated
</span>
) : (
<span className="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-600 ring-1 ring-slate-200">
Deactivated
</span>
)}
</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => void dispatch(simulateCameraIngest(camera.cameraId))}
disabled={simulatingCameraId === camera.cameraId || !camera.isActive}
className="rounded-md bg-slate-900 px-3 py-1.5 text-xs font-medium text-white disabled:opacity-60"
>
{simulatingCameraId === camera.cameraId ? 'Simulating...' : 'Simulate'}
</button>
<button
type="button"
onClick={() => dispatch(openLendModal(camera.cameraId))}
disabled={Boolean(camera.lend) || !camera.isActive}
className="rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-medium text-white disabled:cursor-not-allowed disabled:opacity-50"
>
{camera.lend ? 'Lent' : 'Lend'}
</button>
<button
type="button"
onClick={() => void dispatch(returnCamera(camera.cameraId))}
disabled={!camera.lend || isReturning === camera.cameraId}
className="rounded-md bg-cyan-700 px-3 py-1.5 text-xs font-medium text-white disabled:cursor-not-allowed disabled:opacity-50"
>
{isReturning === camera.cameraId ? 'Returning...' : 'Return'}
</button>
<button
type="button"
onClick={() => dispatch(openEditModal(camera))}
className="rounded-md bg-amber-600 px-3 py-1.5 text-xs font-medium text-white"
>
Edit
</button>
<button
type="button"
onClick={() =>
void dispatch(
toggleCameraActivation({ cameraId: camera.cameraId, isActive: camera.isActive }),
)
}
disabled={isTogglingActivation === camera.cameraId}
className="rounded-md bg-rose-600 px-3 py-1.5 text-xs font-medium text-white disabled:cursor-not-allowed disabled:opacity-50"
>
{isTogglingActivation === camera.cameraId
? 'Saving...'
: camera.isActive
? 'Deactivate'
: 'Activate'}
</button>
<button
type="button"
onClick={() => void dispatch(fetchCameraAudit(camera.cameraId))}
className="rounded-md border border-slate-300 px-3 py-1.5 text-xs font-medium text-slate-700"
>
Audit
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
<div className="overflow-x-auto rounded-xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-700">
<p className="mb-2 font-medium text-slate-900">Data flow</p>
<pre className="font-mono text-xs leading-6">{`Device ping → Koa ingest :3001
→ Mongo (raw event)
→ Redis SET device:id EX ${PRESENCE_TTL_SECONDS}s
→ Postgres UPSERT device_current_state
{lendModalCamera ? (
<div className="fixed inset-0 z-40 flex items-center justify-center bg-slate-950/50 p-4">
<div className="w-full max-w-lg rounded-xl bg-white p-6 shadow-2xl">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-lg font-semibold text-slate-900">Lend camera</h2>
<p className="mt-1 text-sm text-slate-600">
{lendModalCamera.name} ({lendModalCamera.cameraId})
</p>
</div>
<button
type="button"
onClick={() => dispatch(closeLendModal())}
className="rounded-md px-2 py-1 text-sm text-slate-500 hover:bg-slate-100"
>
Close
</button>
</div>
React poll 1s → Express crud :3000
→ SELECT postgres snapshot ← near-real-time product path
→ GET redis key ← optional presence`}</pre>
</div>
<form
onSubmit={(event) => {
event.preventDefault()
submitLend(lendModalCamera)
}}
className="mt-4 space-y-3"
>
<label className="block text-sm text-slate-700">
Borrower
<input
required
value={lendForm.borrower}
onChange={(event) => dispatch(setLendBorrower(event.target.value))}
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2"
/>
</label>
<div className="grid gap-4 md:grid-cols-2">
<StatusCard title="Postgres (Express product path)" status={pair?.postgres} />
<StatusCard title="Redis (TTL presence)" status={pair?.redis} />
</div>
<label className="block text-sm text-slate-700">
Due date
<input
required
type="datetime-local"
value={lendForm.dueDate}
onChange={(event) => dispatch(setLendDueDate(event.target.value))}
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2"
/>
</label>
<label className="block text-sm text-slate-700">
Purpose (optional)
<input
value={lendForm.purpose}
onChange={(event) => dispatch(setLendPurpose(event.target.value))}
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2"
/>
</label>
<div className="flex items-center justify-end gap-2 pt-2">
<button
type="button"
onClick={() => dispatch(closeLendModal())}
className="rounded-md border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700"
>
Cancel
</button>
<button
type="submit"
disabled={isLending}
className="rounded-md bg-emerald-600 px-4 py-2 text-sm font-medium text-white disabled:opacity-60"
>
{isLending ? 'Submitting...' : 'Confirm lend'}
</button>
</div>
</form>
</div>
</div>
) : null}
{createModalOpen ? (
<div className="fixed inset-0 z-40 flex items-center justify-center bg-slate-950/50 p-4">
<div className="w-full max-w-lg rounded-xl bg-white p-6 shadow-2xl">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-lg font-semibold text-slate-900">Create camera</h2>
<p className="mt-1 text-sm text-slate-600">Register a new camera device in the catalog.</p>
</div>
<button
type="button"
onClick={() => dispatch(closeCreateModal())}
className="rounded-md px-2 py-1 text-sm text-slate-500 hover:bg-slate-100"
>
Close
</button>
</div>
<form
onSubmit={(event) => {
event.preventDefault()
void dispatch(createCamera())
}}
className="mt-4 space-y-3"
>
<label className="block text-sm text-slate-700">
Camera ID
<input
required
value={createForm.cameraId}
onChange={(event) => dispatch(setCreateCameraId(event.target.value))}
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2"
placeholder="cam-entrance-04"
/>
</label>
<label className="block text-sm text-slate-700">
Name
<input
required
value={createForm.name}
onChange={(event) => dispatch(setCreateName(event.target.value))}
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2"
/>
</label>
<label className="block text-sm text-slate-700">
Location
<input
required
value={createForm.location}
onChange={(event) => dispatch(setCreateLocation(event.target.value))}
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2"
/>
</label>
<label className="block text-sm text-slate-700">
Model
<input
value={createForm.model}
onChange={(event) => dispatch(setCreateModel(event.target.value))}
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2"
/>
</label>
<label className="block text-sm text-slate-700">
Notes
<input
value={createForm.notes}
onChange={(event) => dispatch(setCreateNotes(event.target.value))}
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2"
/>
</label>
<label className="flex items-center gap-2 text-sm text-slate-700">
<input
type="checkbox"
checked={createForm.isActive}
onChange={(event) => dispatch(setCreateIsActive(event.target.checked))}
/>
Activated
</label>
<div className="flex items-center justify-end gap-2 pt-2">
<button
type="button"
onClick={() => dispatch(closeCreateModal())}
className="rounded-md border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700"
>
Cancel
</button>
<button
type="submit"
disabled={isCreating}
className="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white disabled:opacity-60"
>
{isCreating ? 'Creating...' : 'Create'}
</button>
</div>
</form>
</div>
</div>
) : null}
{editModalCamera ? (
<div className="fixed inset-0 z-40 flex items-center justify-center bg-slate-950/50 p-4">
<div className="w-full max-w-lg rounded-xl bg-white p-6 shadow-2xl">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-lg font-semibold text-slate-900">Update camera</h2>
<p className="mt-1 text-sm text-slate-600">
{editModalCamera.name} ({editModalCamera.cameraId})
</p>
</div>
<button
type="button"
onClick={() => dispatch(closeEditModal())}
className="rounded-md px-2 py-1 text-sm text-slate-500 hover:bg-slate-100"
>
Close
</button>
</div>
<form
onSubmit={(event) => {
event.preventDefault()
submitUpdate(editModalCamera)
}}
className="mt-4 space-y-3"
>
<label className="block text-sm text-slate-700">
Name
<input
required
value={editForm.name}
onChange={(event) => dispatch(setEditName(event.target.value))}
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2"
/>
</label>
<label className="block text-sm text-slate-700">
Location
<input
required
value={editForm.location}
onChange={(event) => dispatch(setEditLocation(event.target.value))}
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2"
/>
</label>
<label className="block text-sm text-slate-700">
Model
<input
value={editForm.model}
onChange={(event) => dispatch(setEditModel(event.target.value))}
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2"
/>
</label>
<label className="block text-sm text-slate-700">
Notes
<input
value={editForm.notes}
onChange={(event) => dispatch(setEditNotes(event.target.value))}
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2"
/>
</label>
<label className="flex items-center gap-2 text-sm text-slate-700">
<input
type="checkbox"
checked={editForm.isActive}
onChange={(event) => dispatch(setEditIsActive(event.target.checked))}
/>
Activated
</label>
<div className="flex items-center justify-end gap-2 pt-2">
<button
type="button"
onClick={() => dispatch(closeEditModal())}
className="rounded-md border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700"
>
Cancel
</button>
<button
type="submit"
disabled={isUpdating}
className="rounded-md bg-amber-600 px-4 py-2 text-sm font-medium text-white disabled:opacity-60"
>
{isUpdating ? 'Saving...' : 'Save changes'}
</button>
</div>
</form>
</div>
</div>
) : null}
{auditModalCamera ? (
<div className="fixed inset-0 z-40 flex items-center justify-center bg-slate-950/50 p-4">
<div className="w-full max-w-3xl rounded-xl bg-white p-6 shadow-2xl">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-lg font-semibold text-slate-900">Audit log</h2>
<p className="mt-1 text-sm text-slate-600">
{auditModalCamera.name} ({auditModalCamera.cameraId})
</p>
</div>
<button
type="button"
onClick={() => dispatch(closeAuditModal())}
className="rounded-md px-2 py-1 text-sm text-slate-500 hover:bg-slate-100"
>
Close
</button>
</div>
<div className="mt-4 max-h-96 overflow-auto rounded-lg border border-slate-200">
<table className="min-w-full divide-y divide-slate-200 text-sm">
<thead className="bg-slate-50">
<tr className="text-left text-xs uppercase tracking-wide text-slate-500">
<th className="px-3 py-2 font-semibold">Time</th>
<th className="px-3 py-2 font-semibold">Action</th>
<th className="px-3 py-2 font-semibold">Actor</th>
<th className="px-3 py-2 font-semibold">Metadata</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{isAuditLoading ? (
<tr>
<td colSpan={4} className="px-3 py-6 text-center text-slate-500">
Loading audit logs...
</td>
</tr>
) : null}
{!isAuditLoading && auditLogs.length === 0 ? (
<tr>
<td colSpan={4} className="px-3 py-6 text-center text-slate-500">
No audit logs found.
</td>
</tr>
) : null}
{auditLogs.map((log) => (
<tr key={log.id} className="align-top">
<td className="px-3 py-2 text-slate-700">{new Date(log.createdAt).toLocaleString()}</td>
<td className="px-3 py-2 font-medium text-slate-900">{log.action}</td>
<td className="px-3 py-2 text-slate-700">{log.actor}</td>
<td className="px-3 py-2">
<pre className="max-w-md overflow-auto rounded bg-slate-50 p-2 font-mono text-xs text-slate-700">
{JSON.stringify(log.metadata)}
</pre>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
) : null}
</section>
)
}
+466
View File
@@ -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
+2
View File
@@ -1,4 +1,5 @@
import { configureStore } from '@reduxjs/toolkit'
import cameraPageReducer from './camera-slice'
import deviceReducer from './device-slice'
import greetingReducer from './greeting-slice'
@@ -6,6 +7,7 @@ export const store = configureStore({
reducer: {
greeting: greetingReducer,
device: deviceReducer,
cameraPage: cameraPageReducer,
},
})