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
+2492 -82

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());
+638 -5
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,16 +63,99 @@ function redisKey(deviceId: string): string {
export function createTelemetryWriter(): TelemetryWriter {
const mongo = new MongoClient(env.mongoUrl);
const redis = new Redis(env.redisUrl, { maxRetriesPerRequest: 1, lazyConnect: true });
const queue = new Redis(env.redisUrl, { maxRetriesPerRequest: 1, lazyConnect: true });
const pool = new pg.Pool({ connectionString: env.databaseUrl });
let mongoReady: Promise<MongoClient> | null = null;
let postgresSchemaReady: Promise<void> | null = null;
let workerReady: Promise<void> | null = null;
let workerTask: Promise<void> | null = null;
let stopWorker = false;
const streamKey = 'telemetry:stream';
const streamDlqKey = 'telemetry:stream:dlq';
const streamGroup = 'ingest-writers';
const consumerName = `ingest-${process.pid}`;
const streamMaxLen = 50_000;
const streamDlqMaxLen = 5_000;
function mongoClient(): Promise<MongoClient> {
mongoReady ??= mongo.connect();
return mongoReady;
}
return {
async write(event: TelemetryIngest): Promise<WriteReport> {
function ensurePostgresSchema(): Promise<void> {
postgresSchemaReady ??= (async () => {
await pool.query(`
CREATE TABLE IF NOT EXISTS devices (
camera_id TEXT PRIMARY KEY,
name TEXT NOT NULL,
location TEXT NOT NULL,
model TEXT,
notes TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS device_status_snapshots (
id BIGSERIAL PRIMARY KEY,
device_id TEXT NOT NULL,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
last_seen TIMESTAMPTZ NOT NULL,
changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS camera_lending (
camera_id TEXT PRIMARY KEY,
borrower TEXT NOT NULL,
due_date TIMESTAMPTZ NOT NULL,
purpose TEXT,
lent_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS camera_audit_logs (
id BIGSERIAL PRIMARY KEY,
camera_id TEXT NOT NULL,
action TEXT NOT NULL,
actor TEXT NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`);
})();
return postgresSchemaReady;
}
async function ensureQueueWorker(): Promise<void> {
workerReady ??= (async () => {
if (queue.status === 'wait') {
await queue.connect();
}
try {
await queue.xgroup('CREATE', streamKey, streamGroup, '$', 'MKSTREAM');
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (!message.includes('BUSYGROUP')) {
throw error;
}
}
workerTask ??= runWorkerLoop();
})();
return workerReady;
}
async function persistTelemetry(event: TelemetryIngest): Promise<WriteReport> {
const report: WriteReport = { mongo: false, redis: false, postgres: false };
const lastSeen = new Date(event.sentAt);
@@ -63,6 +187,9 @@ export function createTelemetryWriter(): TelemetryWriter {
console.error('redis ingest write failed', error);
}
try {
await ensurePostgresSchema();
await pool.query('BEGIN');
try {
await pool.query(
`INSERT INTO device_current_state (device_id, payload, last_seen, updated_at)
@@ -73,26 +200,532 @@ export function createTelemetryWriter(): TelemetryWriter {
updated_at = NOW()`,
[event.deviceId, JSON.stringify(event.payload), lastSeen],
);
await pool.query(
`INSERT INTO device_status_snapshots (device_id, payload, last_seen, changed_at)
SELECT $1, $2::jsonb, $3, NOW()
WHERE NOT EXISTS (
SELECT 1
FROM device_status_snapshots
WHERE device_id = $1
AND changed_at >= NOW() - INTERVAL '5 seconds'
)`,
[event.deviceId, JSON.stringify(event.payload), lastSeen],
);
await pool.query('COMMIT');
} catch (error) {
await pool.query('ROLLBACK');
throw error;
}
report.postgres = true;
} catch (error) {
console.error('postgres ingest write failed', error);
}
return report;
}
async function handleStreamMessage(messageId: string, fields: string[]): Promise<void> {
const fieldMap = new Map<string, string>();
for (let index = 0; index < fields.length; index += 2) {
const key = fields[index];
const value = fields[index + 1];
if (key !== undefined && value !== undefined) {
fieldMap.set(key, value);
}
}
const raw = fieldMap.get('event');
if (!raw) {
await queue.xadd(
streamDlqKey,
'MAXLEN',
'~',
streamDlqMaxLen,
'*',
'sourceMessageId',
messageId,
'reason',
'missing_event_field',
'failedAt',
new Date().toISOString(),
);
await queue.xack(streamKey, streamGroup, messageId);
return;
}
let envelope: TelemetryQueueEnvelope;
try {
envelope = JSON.parse(raw) as TelemetryQueueEnvelope;
} catch (error) {
await queue.xadd(
streamDlqKey,
'MAXLEN',
'~',
streamDlqMaxLen,
'*',
'sourceMessageId',
messageId,
'reason',
'invalid_json',
'error',
error instanceof Error ? error.message : String(error),
'failedAt',
new Date().toISOString(),
);
await queue.xack(streamKey, streamGroup, messageId);
return;
}
let success = false;
let lastError = '';
for (let attempt = 1; attempt <= 3; attempt += 1) {
try {
const report = await persistTelemetry(envelope.event);
if (report.mongo && report.redis && report.postgres) {
success = true;
break;
}
lastError = `partial_write mongo=${report.mongo} redis=${report.redis} postgres=${report.postgres}`;
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
}
}
if (!success) {
await queue.xadd(
streamDlqKey,
'MAXLEN',
'~',
streamDlqMaxLen,
'*',
'sourceMessageId',
messageId,
'reason',
'write_failed',
'error',
lastError,
'event',
JSON.stringify(envelope),
'failedAt',
new Date().toISOString(),
);
}
await queue.xack(streamKey, streamGroup, messageId);
}
async function runWorkerLoop(): Promise<void> {
while (!stopWorker) {
try {
const streams = (await queue.xreadgroup(
'GROUP',
streamGroup,
consumerName,
'COUNT',
20,
'BLOCK',
1000,
'STREAMS',
streamKey,
'>',
)) as [string, [string, string[]][]][] | null;
if (!streams) {
continue;
}
for (const stream of streams) {
const entries = stream[1] ?? [];
for (const entry of entries) {
const messageId = entry[0];
const fields = entry[1] ?? [];
await handleStreamMessage(messageId, fields);
}
}
} catch (error) {
console.error('telemetry worker loop failed', error);
}
}
}
return {
async ensureDevices(seed: DeviceSeed[]): Promise<void> {
await ensurePostgresSchema();
if (seed.length === 0) {
return;
}
await Promise.all(
seed.map((device) =>
pool.query(
`INSERT INTO devices (camera_id, name, location, updated_at)
VALUES ($1, $2, $3, NOW())
ON CONFLICT (camera_id) DO NOTHING`,
[device.cameraId, device.name, device.location],
),
),
);
},
async listDevices(): Promise<CameraDevice[]> {
await ensurePostgresSchema();
const result = await pool.query<{
camera_id: string;
name: string;
location: string;
model: string | null;
notes: string | null;
is_active: boolean;
}>(
`SELECT camera_id, name, location, model, notes, is_active
FROM devices
ORDER BY camera_id ASC`,
);
return result.rows.map((row) => ({
cameraId: row.camera_id,
name: row.name,
location: row.location,
model: row.model,
notes: row.notes,
isActive: row.is_active,
}));
},
async getDevice(cameraId: string): Promise<CameraDevice | null> {
await ensurePostgresSchema();
const result = await pool.query<{
camera_id: string;
name: string;
location: string;
model: string | null;
notes: string | null;
is_active: boolean;
}>(
`SELECT camera_id, name, location, model, notes, is_active
FROM devices
WHERE camera_id = $1
LIMIT 1`,
[cameraId],
);
const row = result.rows[0];
if (!row) {
return null;
}
return {
cameraId: row.camera_id,
name: row.name,
location: row.location,
model: row.model,
notes: row.notes,
isActive: row.is_active,
};
},
async createDevice(input: CameraCreateRequest): Promise<CameraDevice | null> {
await ensurePostgresSchema();
const result = await pool.query<{
camera_id: string;
}>(
`INSERT INTO devices (camera_id, name, location, model, notes, is_active, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW())
ON CONFLICT (camera_id) DO NOTHING
RETURNING camera_id`,
[
input.cameraId,
input.name,
input.location,
input.model ?? null,
input.notes ?? null,
input.isActive ?? true,
],
);
if (result.rowCount !== 1) {
return null;
}
return this.getDevice(input.cameraId);
},
async updateDevice(cameraId: string, patch: CameraDeviceUpdate): Promise<CameraDevice | null> {
await ensurePostgresSchema();
const setClauses: string[] = [];
const values: unknown[] = [];
if (patch.name !== undefined) {
values.push(patch.name);
setClauses.push(`name = $${values.length}`);
}
if (patch.location !== undefined) {
values.push(patch.location);
setClauses.push(`location = $${values.length}`);
}
if (patch.model !== undefined) {
values.push(patch.model);
setClauses.push(`model = $${values.length}`);
}
if (patch.notes !== undefined) {
values.push(patch.notes);
setClauses.push(`notes = $${values.length}`);
}
if (patch.isActive !== undefined) {
values.push(patch.isActive);
setClauses.push(`is_active = $${values.length}`);
}
if (setClauses.length === 0) {
return this.getDevice(cameraId);
}
values.push(cameraId);
const cameraIdPosition = values.length;
await pool.query(
`UPDATE devices
SET ${setClauses.join(', ')}, updated_at = NOW()
WHERE camera_id = $${cameraIdPosition}`,
values,
);
return this.getDevice(cameraId);
},
async write(event: TelemetryIngest): Promise<WriteReport> {
await ensureQueueWorker();
const envelope: TelemetryQueueEnvelope = {
event,
queuedAt: new Date().toISOString(),
};
try {
await queue.xadd(
streamKey,
'MAXLEN',
'~',
streamMaxLen,
'*',
'event',
JSON.stringify(envelope),
);
return { mongo: true, redis: true, postgres: true };
} catch (error) {
console.error('telemetry queue enqueue failed', error);
return { mongo: false, redis: false, postgres: false };
}
},
async readLend(cameraId: string): Promise<CameraLendRecord | null> {
try {
await ensurePostgresSchema();
const result = await pool.query<{
borrower: string;
due_date: Date;
purpose: string | null;
lent_at: Date;
}>(
`SELECT borrower, due_date, purpose, lent_at
FROM camera_lending
WHERE camera_id = $1
LIMIT 1`,
[cameraId],
);
const row = result.rows[0];
if (!row) {
return null;
}
return {
borrower: row.borrower,
dueDate: row.due_date.toISOString(),
purpose: row.purpose ?? undefined,
lentAt: row.lent_at.toISOString(),
};
} catch (error) {
console.error('postgres lend read failed', error);
return null;
}
},
async saveLend(cameraId: string, lend: CameraLendRecord): Promise<boolean> {
try {
await ensurePostgresSchema();
const result = await pool.query(
`INSERT INTO camera_lending (camera_id, borrower, due_date, purpose, lent_at, updated_at)
VALUES ($1, $2, $3, $4, $5, NOW())
ON CONFLICT (camera_id) DO NOTHING`,
[cameraId, lend.borrower, lend.dueDate, lend.purpose ?? null, lend.lentAt],
);
return result.rowCount === 1;
} catch (error) {
console.error('postgres lend write failed', error);
return false;
}
},
async returnLend(cameraId: string): Promise<boolean> {
try {
await ensurePostgresSchema();
const result = await pool.query('DELETE FROM camera_lending WHERE camera_id = $1', [cameraId]);
return result.rowCount === 1;
} catch (error) {
console.error('postgres lend return failed', error);
return false;
}
},
async logAudit(
cameraId: string,
action: CameraAuditAction,
actor: string,
metadata: Record<string, unknown> = {},
): Promise<void> {
try {
await ensurePostgresSchema();
await pool.query(
`INSERT INTO camera_audit_logs (camera_id, action, actor, metadata, created_at)
VALUES ($1, $2, $3, $4::jsonb, NOW())`,
[cameraId, action, actor, JSON.stringify(metadata)],
);
} catch (error) {
console.error('postgres audit write failed', error);
}
},
async listAudit(cameraId: string, limit = 20): Promise<CameraAuditLog[]> {
try {
await ensurePostgresSchema();
const boundedLimit = Math.max(1, Math.min(100, limit));
const result = await pool.query<{
id: string;
camera_id: string;
action: CameraAuditAction;
actor: string;
metadata: Record<string, unknown>;
created_at: Date;
}>(
`SELECT id, camera_id, action, actor, metadata, created_at
FROM camera_audit_logs
WHERE camera_id = $1
ORDER BY created_at DESC
LIMIT $2`,
[cameraId, boundedLimit],
);
return result.rows.map((row) => ({
id: Number(row.id),
cameraId: row.camera_id,
action: row.action,
actor: row.actor,
metadata: row.metadata ?? {},
createdAt: row.created_at.toISOString(),
}));
} catch (error) {
console.error('postgres audit read failed', error);
return [];
}
},
async close() {
await Promise.allSettled([mongo.close(), redis.quit(), pool.end()]);
stopWorker = true;
await Promise.allSettled([workerTask ?? Promise.resolve(), mongo.close(), redis.quit(), queue.quit(), pool.end()]);
},
};
}
export function createMemoryWriter(writes: TelemetryIngest[] = []): TelemetryWriter {
const lends = new Map<string, CameraLendRecord>();
const devices = new Map<string, CameraDevice>();
const audit: CameraAuditLog[] = [];
let nextAuditId = 1;
return {
async ensureDevices(seed: DeviceSeed[]) {
for (const item of seed) {
if (!devices.has(item.cameraId)) {
devices.set(item.cameraId, {
cameraId: item.cameraId,
name: item.name,
location: item.location,
model: null,
notes: null,
isActive: true,
});
}
}
},
async listDevices() {
return Array.from(devices.values()).sort((a, b) => a.cameraId.localeCompare(b.cameraId));
},
async getDevice(cameraId: string) {
return devices.get(cameraId) ?? null;
},
async createDevice(input: CameraCreateRequest) {
if (devices.has(input.cameraId)) {
return null;
}
const created: CameraDevice = {
cameraId: input.cameraId,
name: input.name,
location: input.location,
model: input.model ?? null,
notes: input.notes ?? null,
isActive: input.isActive ?? true,
};
devices.set(created.cameraId, created);
return created;
},
async updateDevice(cameraId: string, patch: CameraDeviceUpdate) {
const current = devices.get(cameraId);
if (!current) {
return null;
}
const updated: CameraDevice = {
...current,
...patch,
};
devices.set(cameraId, updated);
return updated;
},
async write(event: TelemetryIngest) {
writes.push(event);
return { mongo: true, redis: true, postgres: true };
},
async readLend(cameraId: string) {
return lends.get(cameraId) ?? null;
},
async saveLend(cameraId: string, lend: CameraLendRecord) {
if (lends.has(cameraId)) {
return false;
}
lends.set(cameraId, lend);
return true;
},
async returnLend(cameraId: string) {
return lends.delete(cameraId);
},
async logAudit(cameraId, action, actor, metadata = {}) {
audit.unshift({
id: nextAuditId,
cameraId,
action,
actor,
metadata,
createdAt: new Date().toISOString(),
});
nextAuditId += 1;
},
async listAudit(cameraId: string, limit = 20) {
const boundedLimit = Math.max(1, Math.min(100, limit));
return audit.filter((item) => item.cameraId === cameraId).slice(0, boundedLimit);
},
async close() {
return;
},
+10 -3
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 />} />
+561 -71
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>
<div className="mt-2 flex items-center justify-between gap-3">
<p className="text-xs text-slate-500">Total cameras: {cameras.length}</p>
<button
type="button"
disabled={pinging}
onClick={() => void dispatch(pingDevice(deviceId))}
className="mt-4 rounded-md bg-slate-900 px-4 py-2 text-sm font-medium text-white disabled:opacity-60"
onClick={() => dispatch(openCreateModal())}
className="rounded-md bg-indigo-600 px-3 py-1.5 text-xs font-semibold text-white"
>
{pinging ? 'Pinging…' : `Ping ${deviceId}`}
Create camera
</button>
{error ? (
<p className="mt-4 rounded-md bg-red-50 px-3 py-2 text-sm text-red-700">{error}</p>
</div>
{error ? <p className="mt-3 rounded-md bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</p> : null}
{message ? (
<p className="mt-3 rounded-md bg-emerald-50 px-3 py-2 text-sm text-emerald-700">{message}</p>
) : null}
<div className="mt-5 overflow-hidden rounded-xl border border-slate-200">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-slate-200 bg-white text-sm">
<thead className="bg-slate-50">
<tr className="text-left text-xs uppercase tracking-wide text-slate-500">
<th className="px-4 py-3 font-semibold">Camera</th>
<th className="px-4 py-3 font-semibold">Location</th>
<th className="px-4 py-3 font-semibold">Health</th>
<th className="px-4 py-3 font-semibold">Last Seen</th>
<th className="px-4 py-3 font-semibold">Latest Payload</th>
<th className="px-4 py-3 font-semibold">Lending</th>
<th className="px-4 py-3 font-semibold">State</th>
<th className="px-4 py-3 font-semibold">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{loading && cameras.length === 0 ? (
<tr>
<td colSpan={8} className="px-4 py-8 text-center text-slate-500">
Loading cameras...
</td>
</tr>
) : null}
{!loading && cameras.length === 0 ? (
<tr>
<td colSpan={8} className="px-4 py-8 text-center text-slate-500">
No cameras found.
</td>
</tr>
) : null}
{cameras.map((camera) => (
<tr key={camera.cameraId} className="align-top">
<td className="px-4 py-3">
<p className="font-semibold text-slate-900">{camera.name}</p>
<p className="font-mono text-xs text-slate-500">{camera.cameraId}</p>
<p className="text-xs text-slate-500">{camera.model ?? 'Model n/a'}</p>
</td>
<td className="px-4 py-3 text-slate-700">{camera.location}</td>
<td className="px-4 py-3">
<span
className={`inline-flex rounded-full px-2.5 py-1 text-xs font-medium ${statusPillClass(camera.latest.health)}`}
>
{camera.latest.health}
</span>
</td>
<td className="px-4 py-3 text-slate-700">{camera.latest.lastSeen ?? '—'}</td>
<td className="px-4 py-3">
<pre className="max-w-xs overflow-auto rounded bg-slate-50 p-2 font-mono text-xs text-slate-700">
{JSON.stringify(camera.latest.payload ?? {}, null, 0)}
</pre>
</td>
<td className="px-4 py-3 text-slate-700">
{camera.lend ? (
<div>
<p className="font-medium">{camera.lend.borrower}</p>
<p className="text-xs">Due {new Date(camera.lend.dueDate).toLocaleString()}</p>
</div>
) : (
<span className="text-slate-500">Available</span>
)}
</td>
<td className="px-4 py-3">
{camera.isActive ? (
<span className="rounded-full bg-emerald-50 px-2.5 py-1 text-xs font-medium text-emerald-700 ring-1 ring-emerald-200">
Activated
</span>
) : (
<span className="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-600 ring-1 ring-slate-200">
Deactivated
</span>
)}
</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => void dispatch(simulateCameraIngest(camera.cameraId))}
disabled={simulatingCameraId === camera.cameraId || !camera.isActive}
className="rounded-md bg-slate-900 px-3 py-1.5 text-xs font-medium text-white disabled:opacity-60"
>
{simulatingCameraId === camera.cameraId ? 'Simulating...' : 'Simulate'}
</button>
<button
type="button"
onClick={() => dispatch(openLendModal(camera.cameraId))}
disabled={Boolean(camera.lend) || !camera.isActive}
className="rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-medium text-white disabled:cursor-not-allowed disabled:opacity-50"
>
{camera.lend ? 'Lent' : 'Lend'}
</button>
<button
type="button"
onClick={() => void dispatch(returnCamera(camera.cameraId))}
disabled={!camera.lend || isReturning === camera.cameraId}
className="rounded-md bg-cyan-700 px-3 py-1.5 text-xs font-medium text-white disabled:cursor-not-allowed disabled:opacity-50"
>
{isReturning === camera.cameraId ? 'Returning...' : 'Return'}
</button>
<button
type="button"
onClick={() => dispatch(openEditModal(camera))}
className="rounded-md bg-amber-600 px-3 py-1.5 text-xs font-medium text-white"
>
Edit
</button>
<button
type="button"
onClick={() =>
void dispatch(
toggleCameraActivation({ cameraId: camera.cameraId, isActive: camera.isActive }),
)
}
disabled={isTogglingActivation === camera.cameraId}
className="rounded-md bg-rose-600 px-3 py-1.5 text-xs font-medium text-white disabled:cursor-not-allowed disabled:opacity-50"
>
{isTogglingActivation === camera.cameraId
? 'Saving...'
: camera.isActive
? 'Deactivate'
: 'Activate'}
</button>
<button
type="button"
onClick={() => void dispatch(fetchCameraAudit(camera.cameraId))}
className="rounded-md border border-slate-300 px-3 py-1.5 text-xs font-medium text-slate-700"
>
Audit
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
<div className="overflow-x-auto rounded-xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-700">
<p className="mb-2 font-medium text-slate-900">Data flow</p>
<pre className="font-mono text-xs leading-6">{`Device ping → Koa ingest :3001
→ Mongo (raw event)
→ Redis SET device:id EX ${PRESENCE_TTL_SECONDS}s
→ Postgres UPSERT device_current_state
React poll 1s → Express crud :3000
→ SELECT postgres snapshot ← near-real-time product path
→ GET redis key ← optional presence`}</pre>
{lendModalCamera ? (
<div className="fixed inset-0 z-40 flex items-center justify-center bg-slate-950/50 p-4">
<div className="w-full max-w-lg rounded-xl bg-white p-6 shadow-2xl">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-lg font-semibold text-slate-900">Lend camera</h2>
<p className="mt-1 text-sm text-slate-600">
{lendModalCamera.name} ({lendModalCamera.cameraId})
</p>
</div>
<button
type="button"
onClick={() => dispatch(closeLendModal())}
className="rounded-md px-2 py-1 text-sm text-slate-500 hover:bg-slate-100"
>
Close
</button>
</div>
<div className="grid gap-4 md:grid-cols-2">
<StatusCard title="Postgres (Express product path)" status={pair?.postgres} />
<StatusCard title="Redis (TTL presence)" status={pair?.redis} />
<form
onSubmit={(event) => {
event.preventDefault()
submitLend(lendModalCamera)
}}
className="mt-4 space-y-3"
>
<label className="block text-sm text-slate-700">
Borrower
<input
required
value={lendForm.borrower}
onChange={(event) => dispatch(setLendBorrower(event.target.value))}
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2"
/>
</label>
<label className="block text-sm text-slate-700">
Due date
<input
required
type="datetime-local"
value={lendForm.dueDate}
onChange={(event) => dispatch(setLendDueDate(event.target.value))}
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2"
/>
</label>
<label className="block text-sm text-slate-700">
Purpose (optional)
<input
value={lendForm.purpose}
onChange={(event) => dispatch(setLendPurpose(event.target.value))}
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2"
/>
</label>
<div className="flex items-center justify-end gap-2 pt-2">
<button
type="button"
onClick={() => dispatch(closeLendModal())}
className="rounded-md border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700"
>
Cancel
</button>
<button
type="submit"
disabled={isLending}
className="rounded-md bg-emerald-600 px-4 py-2 text-sm font-medium text-white disabled:opacity-60"
>
{isLending ? 'Submitting...' : 'Confirm lend'}
</button>
</div>
</form>
</div>
</div>
) : null}
{createModalOpen ? (
<div className="fixed inset-0 z-40 flex items-center justify-center bg-slate-950/50 p-4">
<div className="w-full max-w-lg rounded-xl bg-white p-6 shadow-2xl">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-lg font-semibold text-slate-900">Create camera</h2>
<p className="mt-1 text-sm text-slate-600">Register a new camera device in the catalog.</p>
</div>
<button
type="button"
onClick={() => dispatch(closeCreateModal())}
className="rounded-md px-2 py-1 text-sm text-slate-500 hover:bg-slate-100"
>
Close
</button>
</div>
<form
onSubmit={(event) => {
event.preventDefault()
void dispatch(createCamera())
}}
className="mt-4 space-y-3"
>
<label className="block text-sm text-slate-700">
Camera ID
<input
required
value={createForm.cameraId}
onChange={(event) => dispatch(setCreateCameraId(event.target.value))}
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2"
placeholder="cam-entrance-04"
/>
</label>
<label className="block text-sm text-slate-700">
Name
<input
required
value={createForm.name}
onChange={(event) => dispatch(setCreateName(event.target.value))}
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2"
/>
</label>
<label className="block text-sm text-slate-700">
Location
<input
required
value={createForm.location}
onChange={(event) => dispatch(setCreateLocation(event.target.value))}
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2"
/>
</label>
<label className="block text-sm text-slate-700">
Model
<input
value={createForm.model}
onChange={(event) => dispatch(setCreateModel(event.target.value))}
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2"
/>
</label>
<label className="block text-sm text-slate-700">
Notes
<input
value={createForm.notes}
onChange={(event) => dispatch(setCreateNotes(event.target.value))}
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2"
/>
</label>
<label className="flex items-center gap-2 text-sm text-slate-700">
<input
type="checkbox"
checked={createForm.isActive}
onChange={(event) => dispatch(setCreateIsActive(event.target.checked))}
/>
Activated
</label>
<div className="flex items-center justify-end gap-2 pt-2">
<button
type="button"
onClick={() => dispatch(closeCreateModal())}
className="rounded-md border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700"
>
Cancel
</button>
<button
type="submit"
disabled={isCreating}
className="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white disabled:opacity-60"
>
{isCreating ? 'Creating...' : 'Create'}
</button>
</div>
</form>
</div>
</div>
) : null}
{editModalCamera ? (
<div className="fixed inset-0 z-40 flex items-center justify-center bg-slate-950/50 p-4">
<div className="w-full max-w-lg rounded-xl bg-white p-6 shadow-2xl">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-lg font-semibold text-slate-900">Update camera</h2>
<p className="mt-1 text-sm text-slate-600">
{editModalCamera.name} ({editModalCamera.cameraId})
</p>
</div>
<button
type="button"
onClick={() => dispatch(closeEditModal())}
className="rounded-md px-2 py-1 text-sm text-slate-500 hover:bg-slate-100"
>
Close
</button>
</div>
<form
onSubmit={(event) => {
event.preventDefault()
submitUpdate(editModalCamera)
}}
className="mt-4 space-y-3"
>
<label className="block text-sm text-slate-700">
Name
<input
required
value={editForm.name}
onChange={(event) => dispatch(setEditName(event.target.value))}
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2"
/>
</label>
<label className="block text-sm text-slate-700">
Location
<input
required
value={editForm.location}
onChange={(event) => dispatch(setEditLocation(event.target.value))}
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2"
/>
</label>
<label className="block text-sm text-slate-700">
Model
<input
value={editForm.model}
onChange={(event) => dispatch(setEditModel(event.target.value))}
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2"
/>
</label>
<label className="block text-sm text-slate-700">
Notes
<input
value={editForm.notes}
onChange={(event) => dispatch(setEditNotes(event.target.value))}
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2"
/>
</label>
<label className="flex items-center gap-2 text-sm text-slate-700">
<input
type="checkbox"
checked={editForm.isActive}
onChange={(event) => dispatch(setEditIsActive(event.target.checked))}
/>
Activated
</label>
<div className="flex items-center justify-end gap-2 pt-2">
<button
type="button"
onClick={() => dispatch(closeEditModal())}
className="rounded-md border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700"
>
Cancel
</button>
<button
type="submit"
disabled={isUpdating}
className="rounded-md bg-amber-600 px-4 py-2 text-sm font-medium text-white disabled:opacity-60"
>
{isUpdating ? 'Saving...' : 'Save changes'}
</button>
</div>
</form>
</div>
</div>
) : null}
{auditModalCamera ? (
<div className="fixed inset-0 z-40 flex items-center justify-center bg-slate-950/50 p-4">
<div className="w-full max-w-3xl rounded-xl bg-white p-6 shadow-2xl">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-lg font-semibold text-slate-900">Audit log</h2>
<p className="mt-1 text-sm text-slate-600">
{auditModalCamera.name} ({auditModalCamera.cameraId})
</p>
</div>
<button
type="button"
onClick={() => dispatch(closeAuditModal())}
className="rounded-md px-2 py-1 text-sm text-slate-500 hover:bg-slate-100"
>
Close
</button>
</div>
<div className="mt-4 max-h-96 overflow-auto rounded-lg border border-slate-200">
<table className="min-w-full divide-y divide-slate-200 text-sm">
<thead className="bg-slate-50">
<tr className="text-left text-xs uppercase tracking-wide text-slate-500">
<th className="px-3 py-2 font-semibold">Time</th>
<th className="px-3 py-2 font-semibold">Action</th>
<th className="px-3 py-2 font-semibold">Actor</th>
<th className="px-3 py-2 font-semibold">Metadata</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{isAuditLoading ? (
<tr>
<td colSpan={4} className="px-3 py-6 text-center text-slate-500">
Loading audit logs...
</td>
</tr>
) : null}
{!isAuditLoading && auditLogs.length === 0 ? (
<tr>
<td colSpan={4} className="px-3 py-6 text-center text-slate-500">
No audit logs found.
</td>
</tr>
) : null}
{auditLogs.map((log) => (
<tr key={log.id} className="align-top">
<td className="px-3 py-2 text-slate-700">{new Date(log.createdAt).toLocaleString()}</td>
<td className="px-3 py-2 font-medium text-slate-900">{log.action}</td>
<td className="px-3 py-2 text-slate-700">{log.actor}</td>
<td className="px-3 py-2">
<pre className="max-w-md overflow-auto rounded bg-slate-50 p-2 font-mono text-xs text-slate-700">
{JSON.stringify(log.metadata)}
</pre>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
) : null}
</section>
)
}
+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,
},
})
+100
View File
@@ -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
```
+38
View File
@@ -4,3 +4,41 @@ CREATE TABLE IF NOT EXISTS device_current_state (
last_seen TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS devices (
camera_id TEXT PRIMARY KEY,
name TEXT NOT NULL,
location TEXT NOT NULL,
model TEXT,
notes TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS device_status_snapshots (
id BIGSERIAL PRIMARY KEY,
device_id TEXT NOT NULL,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
last_seen TIMESTAMPTZ NOT NULL,
changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS camera_lending (
camera_id TEXT PRIMARY KEY,
borrower TEXT NOT NULL,
due_date TIMESTAMPTZ NOT NULL,
purpose TEXT,
lent_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS camera_audit_logs (
id BIGSERIAL PRIMARY KEY,
camera_id TEXT NOT NULL,
action TEXT NOT NULL,
actor TEXT NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
+128
View File
@@ -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
View File
@@ -1,4 +1,5 @@
export * from './device-status.js';
export * from './camera.js';
export * from './greeting.js';
export * from './presence.js';
export * from './telemetry.js';