Merge pull request #25 from dkwstas/prettier

prettier format
This commit is contained in:
Kostas Drakontidis
2026-06-01 02:31:28 +03:00
committed by GitHub
22 changed files with 634 additions and 568 deletions
@@ -1,5 +1,10 @@
import type { Request, Response, NextFunction } from "express"; import type { Request, Response, NextFunction } from "express";
import { getActiveUserData, getRedisRoomData, type RedisRoomData, type RedisUserData } from "./livedata.repository.js"; import {
getActiveUserData,
getRedisRoomData,
type RedisRoomData,
type RedisUserData,
} from "./livedata.repository.js";
import { prisma } from "../../lib/prisma.js"; import { prisma } from "../../lib/prisma.js";
interface Params { interface Params {
@@ -44,11 +49,11 @@ function convertToNGSIUser(user: RedisUserData): User {
type: "User", type: "User",
name: { name: {
type: "Property", type: "Property",
value: user.name value: user.name,
}, },
locatedIn: { locatedIn: {
type: "Relationship", type: "Relationship",
object: user.room object: user.room,
}, },
rssi: { rssi: {
type: "Property", type: "Property",
@@ -56,20 +61,20 @@ function convertToNGSIUser(user: RedisUserData): User {
}, },
authenticationStatus: { authenticationStatus: {
type: "Property", type: "Property",
value: user.verified ? "verified" : "unverified" value: user.verified ? "verified" : "unverified",
}, },
observedAt: { observedAt: {
type: "Property", type: "Property",
value: (new Date(user.timestamp)).toISOString() value: new Date(user.timestamp).toISOString(),
} },
} };
} }
async function convertToNGSIRoom(room: RedisRoomData): Promise<Room | null> { async function convertToNGSIRoom(room: RedisRoomData): Promise<Room | null> {
const roomName = await prisma.room.findUnique({ const roomName = await prisma.room.findUnique({
where: { id: room.roomID }, where: { id: room.roomID },
select: { name: true } select: { name: true },
}) });
if (!roomName?.name) return null; if (!roomName?.name) return null;
@@ -78,13 +83,13 @@ async function convertToNGSIRoom(room: RedisRoomData): Promise<Room | null> {
type: "Room", type: "Room",
name: { name: {
type: "Property", type: "Property",
value: roomName.name value: roomName.name,
}, },
occupancy: { occupancy: {
type: "Property", type: "Property",
value: room.occupancy value: room.occupancy,
} },
} };
} }
async function getNGSIUsers(): Promise<User[]> { async function getNGSIUsers(): Promise<User[]> {
@@ -121,8 +126,8 @@ export async function getEntities(req: Request, res: Response, next: NextFunctio
let entities: Entity[] = []; let entities: Entity[] = [];
entities.push(...await getNGSIUsers()); entities.push(...(await getNGSIUsers()));
entities.push(...await getNGSIRooms()); entities.push(...(await getNGSIRooms()));
return res.status(200).send(entities); return res.status(200).send(entities);
} }
@@ -131,8 +136,10 @@ export async function getEntitiesOfType(req: Request, res: Response) {
const { type } = req.query; const { type } = req.query;
switch (type) { switch (type) {
case "user": return res.status(200).send(await getNGSIUsers()); case "user":
case "room": return res.status(200).send(await getNGSIRooms()); return res.status(200).send(await getNGSIUsers());
case "room":
return res.status(200).send(await getNGSIRooms());
default: default:
return res.sendStatus(400); return res.sendStatus(400);
} }
@@ -167,6 +174,5 @@ export async function getEntity(req: Request<Params>, res: Response) {
if (!data.length) return res.sendStatus(404); if (!data.length) return res.sendStatus(404);
return res.status(200).send(convertToNGSIUser(data[0]!)); return res.status(200).send(convertToNGSIUser(data[0]!));
} else } else return res.sendStatus(400);
return res.sendStatus(400);
} }
@@ -30,15 +30,9 @@ export async function getActiveUserData(userID?: string): Promise<RedisUserData[
cursor = res.cursor; cursor = res.cursor;
const values = await Promise.all( const values = await Promise.all(res.keys.map((key) => redis.get(key)));
res.keys.map((key) => redis.get(key))
);
result.push(...values
.filter((v): v is string => v !== null)
.map((v) => JSON.parse(v))
);
result.push(...values.filter((v): v is string => v !== null).map((v) => JSON.parse(v)));
} while (cursor !== "0"); } while (cursor !== "0");
return result; return result;
@@ -79,14 +73,14 @@ export async function getRedisRoomData(roomID?: string): Promise<RedisRoomData[]
})) }))
); );
result.push(...values result.push(
...values
.filter((v) => v.occupancy !== null) .filter((v) => v.occupancy !== null)
.map((v) => ({ .map((v) => ({
roomID: v.roomID.slice(5), roomID: v.roomID.slice(5),
occupancy: Number(v.occupancy), occupancy: Number(v.occupancy),
})) }))
); );
} while (cursor !== "0"); } while (cursor !== "0");
return result; return result;
@@ -99,8 +93,8 @@ export async function getRedisRoomData(roomID?: string): Promise<RedisRoomData[]
return [{ roomID: roomID, occupancy: Number(res) }]; return [{ roomID: roomID, occupancy: Number(res) }];
} }
export async function getRoomIDs(): Promise<{ id: string; }[]> { export async function getRoomIDs(): Promise<{ id: string }[]> {
return await prisma.room.findMany({ return await prisma.room.findMany({
select: { id: true } select: { id: true },
}); });
} }
@@ -2,7 +2,7 @@ import { prisma } from "../../lib/prisma.js";
export async function existsByUUID(uuid: string): Promise<boolean> { export async function existsByUUID(uuid: string): Promise<boolean> {
const tag = await prisma.tag.findUnique({ const tag = await prisma.tag.findUnique({
where: { id: uuid } where: { id: uuid },
}); });
return !!tag; return !!tag;
@@ -2,7 +2,7 @@ import AdminJS from "adminjs";
import AdminJSExpress from "@adminjs/express"; import AdminJSExpress from "@adminjs/express";
import * as AdminJSPrisma from "@adminjs/prisma"; import * as AdminJSPrisma from "@adminjs/prisma";
import session from "express-session"; import session from "express-session";
import { componentLoader, Components } from './components.js' import { componentLoader, Components } from "./components.js";
import userResource from "./resources/adminjs.user.resource.js"; import userResource from "./resources/adminjs.user.resource.js";
import tagResource from "./resources/adminjs.tag.resource.js"; import tagResource from "./resources/adminjs.tag.resource.js";
@@ -11,7 +11,7 @@ import roomResource from "./resources/adminjs.room.resource.js";
AdminJS.registerAdapter({ AdminJS.registerAdapter({
Database: AdminJSPrisma.Database, Database: AdminJSPrisma.Database,
Resource: AdminJSPrisma.Resource, Resource: AdminJSPrisma.Resource,
}) });
async function createAdmin() { async function createAdmin() {
const admin = new AdminJS({ const admin = new AdminJS({
@@ -28,26 +28,23 @@ async function createAdmin() {
admin, admin,
{ {
authenticate: async (email, password) => { authenticate: async (email, password) => {
if ( if (email === process.env.ADMIN_EMAIL && password === process.env.ADMIN_PASSWORD) {
email === process.env.ADMIN_EMAIL && return { email };
password === process.env.ADMIN_PASSWORD
) {
return { email }
} }
return null return null;
}, },
cookieName: 'adminjs', cookieName: "adminjs",
cookiePassword: process.env.ADMIN_COOKIE_SECRET ?? 'change-me', cookiePassword: process.env.ADMIN_COOKIE_SECRET ?? "change-me",
}, },
null, null,
{ {
resave: false, resave: false,
saveUninitialized: false, saveUninitialized: false,
secret: process.env.ADMIN_COOKIE_SECRET ?? 'change-me', secret: process.env.ADMIN_COOKIE_SECRET ?? "change-me",
} }
) );
return { admin, router } return { admin, router };
} }
const sessionMiddleware = session({ const sessionMiddleware = session({
@@ -56,4 +53,4 @@ const sessionMiddleware = session({
saveUninitialized: false, saveUninitialized: false,
}); });
export { createAdmin, sessionMiddleware } export { createAdmin, sessionMiddleware };
@@ -1,13 +1,25 @@
import { ComponentLoader } from 'adminjs' import { ComponentLoader } from "adminjs";
import { fileURLToPath } from 'url' import { fileURLToPath } from "url";
import path from 'path' import path from "path";
const __dirname = path.dirname(fileURLToPath(import.meta.url)) const __dirname = path.dirname(fileURLToPath(import.meta.url));
export const componentLoader = new ComponentLoader() export const componentLoader = new ComponentLoader();
export const Components = { export const Components = {
Dashboard: componentLoader.add('Dashboard', path.join(__dirname, './components/Dashboard.tsx'), 'components'), Dashboard: componentLoader.add(
UploadFace: componentLoader.add('UploadFace', path.join(__dirname, './components/UploadFace.tsx'), 'components'), "Dashboard",
FaceEmbeddingField: componentLoader.add('FaceEmbeddingField', path.join(__dirname, './components/FaceEmbeddingField.tsx'), 'components'), path.join(__dirname, "./components/Dashboard.tsx"),
} "components"
),
UploadFace: componentLoader.add(
"UploadFace",
path.join(__dirname, "./components/UploadFace.tsx"),
"components"
),
FaceEmbeddingField: componentLoader.add(
"FaceEmbeddingField",
path.join(__dirname, "./components/FaceEmbeddingField.tsx"),
"components"
),
};
@@ -1,61 +1,68 @@
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from "react";
import { Box, H2, Text, Loader } from '@adminjs/design-system' import { Box, H2, Text, Loader } from "@adminjs/design-system";
import { ApiClient } from 'adminjs' import { ApiClient } from "adminjs";
type Session = { type Session = {
realUserId: string realUserId: string;
firstName: string firstName: string;
lastName: string lastName: string;
pseudoId: string pseudoId: string;
pseudoName: string pseudoName: string;
} };
const Dashboard = () => { const Dashboard = () => {
const [sessions, setSessions] = useState<Session[]>([]) const [sessions, setSessions] = useState<Session[]>([]);
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true);
const [updated, setUpdated] = useState<string>('') const [updated, setUpdated] = useState<string>("");
const load = async () => { const load = async () => {
try { try {
const res = await fetch('/admin/map', { credentials: 'include' }) const res = await fetch("/admin/map", { credentials: "include" });
const data = await res.json() const data = await res.json();
setSessions(data) setSessions(data);
setUpdated(new Date().toLocaleTimeString()) setUpdated(new Date().toLocaleTimeString());
} catch (e) { } catch (e) {
console.error(e) console.error(e);
} finally { } finally {
setLoading(false) setLoading(false);
}
} }
};
useEffect(() => { useEffect(() => {
load() load();
const interval = setInterval(load, 10000) const interval = setInterval(load, 10000);
return () => clearInterval(interval) return () => clearInterval(interval);
}, []) }, []);
const initials = (first: string, last: string) => const initials = (first: string, last: string) =>
((first?.[0] ?? '') + (last?.[0] ?? '')).toUpperCase() || '?' ((first?.[0] ?? "") + (last?.[0] ?? "")).toUpperCase() || "?";
return ( return (
<Box padding="xl"> <Box padding="xl">
<Box display="flex" alignItems="center" justifyContent="space-between" marginBottom="xl"> <Box
display="flex"
alignItems="center"
justifyContent="space-between"
marginBottom="xl"
>
<Box> <Box>
<H2>OfficeSense Admin Dashboard</H2> <H2>OfficeSense Admin Dashboard</H2>
<Text color="grey60">Live Redis sessions mapped to registered users</Text> <Text color="grey60">Live Redis sessions mapped to registered users</Text>
</Box> </Box>
<Box display="flex" alignItems="center"> <Box display="flex" alignItems="center">
<Text color="grey60" fontSize="sm" marginRight="md">Updated {updated}</Text> <Text color="grey60" fontSize="sm" marginRight="md">
Updated {updated}
</Text>
<Box <Box
as="button" as="button"
onClick={load} onClick={load}
style={{ style={{
padding: '6px 14px', padding: "6px 14px",
borderRadius: '6px', borderRadius: "6px",
border: '1px solid #e5e7eb', border: "1px solid #e5e7eb",
background: '#fff', background: "#fff",
cursor: 'pointer', cursor: "pointer",
fontSize: '13px', fontSize: "13px",
}} }}
> >
Refresh Refresh
@@ -70,44 +77,89 @@ const Dashboard = () => {
) : ( ) : (
<Box <Box
style={{ style={{
background: '#fff', background: "#fff",
border: '1px solid #e5e7eb', border: "1px solid #e5e7eb",
borderRadius: '12px', borderRadius: "12px",
overflow: 'hidden', overflow: "hidden",
}} }}
> >
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '13px' }}> <table style={{ width: "100%", borderCollapse: "collapse", fontSize: "13px" }}>
<thead> <thead>
<tr style={{ background: '#fafafa' }}> <tr style={{ background: "#fafafa" }}>
{['User', 'Real UUID', 'Pseudo UUID', 'Pseudo name'].map(h => ( {["User", "Real UUID", "Pseudo UUID", "Pseudo name"].map((h) => (
<th key={h} style={{ <th
textAlign: 'left', padding: '10px 16px', key={h}
fontSize: '11px', color: '#9ca3af', style={{
textTransform: 'uppercase', letterSpacing: '0.05em', textAlign: "left",
borderBottom: '1px solid #f3f4f6', fontWeight: 500, padding: "10px 16px",
}}>{h}</th> fontSize: "11px",
color: "#9ca3af",
textTransform: "uppercase",
letterSpacing: "0.05em",
borderBottom: "1px solid #f3f4f6",
fontWeight: 500,
}}
>
{h}
</th>
))} ))}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{sessions.map(s => ( {sessions.map((s) => (
<tr key={s.realUserId} style={{ borderBottom: '1px solid #f9fafb' }}> <tr
<td style={{ padding: '12px 16px' }}> key={s.realUserId}
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}> style={{ borderBottom: "1px solid #f9fafb" }}
<div style={{ >
width: 28, height: 28, borderRadius: '50%', <td style={{ padding: "12px 16px" }}>
background: '#eff6ff', color: '#2563eb', <div
display: 'flex', alignItems: 'center', justifyContent: 'center', style={{
fontSize: '11px', fontWeight: 600, flexShrink: 0, display: "flex",
}}> alignItems: "center",
gap: "8px",
}}
>
<div
style={{
width: 28,
height: 28,
borderRadius: "50%",
background: "#eff6ff",
color: "#2563eb",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "11px",
fontWeight: 600,
flexShrink: 0,
}}
>
{initials(s.firstName, s.lastName)} {initials(s.firstName, s.lastName)}
</div> </div>
{s.firstName} {s.lastName} {s.firstName} {s.lastName}
</div> </div>
</td> </td>
<td style={{ padding: '12px 16px', fontFamily: 'monospace', fontSize: '12px', color: '#6b7280' }}>{s.realUserId}</td> <td
<td style={{ padding: '12px 16px', fontFamily: 'monospace', fontSize: '12px', color: '#6b7280' }}>{s.pseudoId}</td> style={{
<td style={{ padding: '12px 16px' }}>{s.pseudoName ?? '—'}</td> padding: "12px 16px",
fontFamily: "monospace",
fontSize: "12px",
color: "#6b7280",
}}
>
{s.realUserId}
</td>
<td
style={{
padding: "12px 16px",
fontFamily: "monospace",
fontSize: "12px",
color: "#6b7280",
}}
>
{s.pseudoId}
</td>
<td style={{ padding: "12px 16px" }}>{s.pseudoName ?? "—"}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
@@ -115,7 +167,7 @@ const Dashboard = () => {
</Box> </Box>
)} )}
</Box> </Box>
) );
} };
export default Dashboard export default Dashboard;
@@ -1,26 +1,28 @@
import React from 'react' import React from "react";
import { BasePropertyProps } from 'adminjs' import { BasePropertyProps } from "adminjs";
import { Label } from '@adminjs/design-system' import { Label } from "@adminjs/design-system";
const FaceEmbeddingField: React.FC<BasePropertyProps> = ({ record, property }) => { const FaceEmbeddingField: React.FC<BasePropertyProps> = ({ record, property }) => {
const has = !!record?.params?.faceEmbedding const has = !!record?.params?.faceEmbedding;
return ( return (
<div style={{ marginBottom: '16px' }}> <div style={{ marginBottom: "16px" }}>
<Label>{property.label}</Label> <Label>{property.label}</Label>
<div style={{ marginTop: 6 }}> <div style={{ marginTop: 6 }}>
<span style={{ <span
fontSize: '12px', style={{
fontSize: "12px",
fontWeight: 600, fontWeight: 600,
padding: '3px 10px', padding: "3px 10px",
borderRadius: '4px', borderRadius: "4px",
background: has ? '#dcfce7' : '#fee2e2', background: has ? "#dcfce7" : "#fee2e2",
color: has ? '#16a34a' : '#dc2626', color: has ? "#16a34a" : "#dc2626",
}}> }}
{has ? 'SET' : 'UNSET'} >
{has ? "SET" : "UNSET"}
</span> </span>
</div> </div>
</div> </div>
) );
} };
export default FaceEmbeddingField export default FaceEmbeddingField;
@@ -1,48 +1,50 @@
import React, { useState, useRef } from 'react' import React, { useState, useRef } from "react";
import { Box, H3, Text, Button, MessageBox } from '@adminjs/design-system' import { Box, H3, Text, Button, MessageBox } from "@adminjs/design-system";
import { ActionProps, useRecord } from 'adminjs' import { ActionProps, useRecord } from "adminjs";
const UploadFace: React.FC<ActionProps> = ({ record, action }) => { const UploadFace: React.FC<ActionProps> = ({ record, action }) => {
const [preview, setPreview] = useState<string | null>(null) const [preview, setPreview] = useState<string | null>(null);
const [base64, setBase64] = useState<string | null>(null) const [base64, setBase64] = useState<string | null>(null);
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false);
const [notice, setNotice] = useState<{ message: string, type: 'success' | 'error' } | null>(null) const [notice, setNotice] = useState<{ message: string; type: "success" | "error" } | null>(
const inputRef = useRef<HTMLInputElement>(null) null
);
const inputRef = useRef<HTMLInputElement>(null);
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => { const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] const file = e.target.files?.[0];
if (!file) return if (!file) return;
const reader = new FileReader() const reader = new FileReader();
reader.onload = () => { reader.onload = () => {
const result = reader.result as string const result = reader.result as string;
setPreview(result) setPreview(result);
setBase64(result.split(',')[1]) // strip data:image/jpeg;base64, setBase64(result.split(",")[1]); // strip data:image/jpeg;base64,
} };
reader.readAsDataURL(file) reader.readAsDataURL(file);
} };
const handleSubmit = async () => { const handleSubmit = async () => {
if (!base64) return if (!base64) return;
setLoading(true) setLoading(true);
setNotice(null) setNotice(null);
try { try {
const res = await fetch( const res = await fetch(
`/admin/api/resources/User/records/${record?.params.id}/uploadFace`, `/admin/api/resources/User/records/${record?.params.id}/uploadFace`,
{ {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
credentials: 'include', credentials: "include",
body: JSON.stringify({ base64 }), body: JSON.stringify({ base64 }),
} }
) );
const data = await res.json() const data = await res.json();
setNotice(data.notice) setNotice(data.notice);
} catch (e: any) { } catch (e: any) {
setNotice({ message: e.message, type: 'error' }) setNotice({ message: e.message, type: "error" });
} finally { } finally {
setLoading(false) setLoading(false);
}
} }
};
return ( return (
<Box padding="xl"> <Box padding="xl">
@@ -54,7 +56,7 @@ const UploadFace: React.FC<ActionProps> = ({ record, action }) => {
{notice && ( {notice && (
<MessageBox <MessageBox
message={notice.message} message={notice.message}
variant={notice.type === 'success' ? 'success' : 'danger'} variant={notice.type === "success" ? "success" : "danger"}
marginBottom="lg" marginBottom="lg"
/> />
)} )}
@@ -65,7 +67,7 @@ const UploadFace: React.FC<ActionProps> = ({ record, action }) => {
type="file" type="file"
accept="image/jpeg" accept="image/jpeg"
onChange={handleFile} onChange={handleFile}
style={{ display: 'none' }} style={{ display: "none" }}
/> />
<Button onClick={() => inputRef.current?.click()} variant="outlined"> <Button onClick={() => inputRef.current?.click()} variant="outlined">
Choose JPEG Choose JPEG
@@ -77,20 +79,22 @@ const UploadFace: React.FC<ActionProps> = ({ record, action }) => {
<img <img
src={preview} src={preview}
alt="Preview" alt="Preview"
style={{ width: 160, height: 160, objectFit: 'cover', borderRadius: 8, border: '1px solid #e5e7eb' }} style={{
width: 160,
height: 160,
objectFit: "cover",
borderRadius: 8,
border: "1px solid #e5e7eb",
}}
/> />
</Box> </Box>
)} )}
<Button <Button onClick={handleSubmit} disabled={!base64 || loading} variant="contained">
onClick={handleSubmit} {loading ? "Processing…" : "Save embedding"}
disabled={!base64 || loading}
variant="contained"
>
{loading ? 'Processing…' : 'Save embedding'}
</Button> </Button>
</Box> </Box>
) );
} };
export default UploadFace export default UploadFace;
@@ -13,4 +13,4 @@ export default {
editProperties: ["name"], editProperties: ["name"],
filterProperties: ["name"], filterProperties: ["name"],
}, },
} };
@@ -18,4 +18,4 @@ export default {
}, },
}, },
}, },
} };
@@ -1,48 +1,48 @@
import { prisma } from "../../../lib/prisma.js"; import { prisma } from "../../../lib/prisma.js";
import { getModelByName } from "@adminjs/prisma"; import { getModelByName } from "@adminjs/prisma";
import { spawn } from "child_process"; import { spawn } from "child_process";
import { Components } from '../components.js' import { Components } from "../components.js";
import { fileURLToPath } from 'url' import { fileURLToPath } from "url";
import path from 'path' import path from "path";
const __dirname = path.dirname(fileURLToPath(import.meta.url)) const __dirname = path.dirname(fileURLToPath(import.meta.url));
const execPath = '../../../../bin'; const execPath = "../../../../bin";
const binaryPath = path.join(__dirname, execPath, 'extractEmbeddings') const binaryPath = path.join(__dirname, execPath, "extractEmbeddings");
async function extractEmbedding(base64: string): Promise<string> { async function extractEmbedding(base64: string): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const proc = spawn(binaryPath, [], { const proc = spawn(binaryPath, [], {
cwd: path.join(__dirname, execPath) cwd: path.join(__dirname, execPath),
}) });
let stdout = '' let stdout = "";
let stderr = '' let stderr = "";
proc.stdout.on('data', (d) => stdout += d) proc.stdout.on("data", (d) => (stdout += d));
proc.stderr.on('data', (d) => stderr += d) proc.stderr.on("data", (d) => (stderr += d));
proc.stdin.on('error', (err) => { }); proc.stdin.on("error", (err) => {});
proc.on('error', (err) => { proc.on("error", (err) => {
reject(new Error(`Failed to start binary: ${err.message}`)) reject(new Error(`Failed to start binary: ${err.message}`));
}) });
proc.on('close', (code) => { proc.on("close", (code) => {
console.log('extractEmbeddings exited with code', code) console.log("extractEmbeddings exited with code", code);
console.log('stdout:', stdout) console.log("stdout:", stdout);
console.log('stderr:', stderr) console.log("stderr:", stderr);
if (code !== 0) return reject(new Error(stderr || `exited with code ${code}`)) if (code !== 0) return reject(new Error(stderr || `exited with code ${code}`));
try { try {
const result = JSON.parse(stdout) const result = JSON.parse(stdout);
if (!result.success) return reject(new Error(result.error)) if (!result.success) return reject(new Error(result.error));
resolve(JSON.stringify(result.descriptor)) resolve(JSON.stringify(result.descriptor));
} catch (e) { } catch (e) {
reject(new Error(`Failed to parse output: ${stdout}`)) reject(new Error(`Failed to parse output: ${stdout}`));
} }
}) });
proc.stdin.write(base64) proc.stdin.write(base64);
proc.stdin.end() proc.stdin.end();
}) });
} }
export default { export default {
@@ -69,41 +69,41 @@ export default {
}, },
actions: { actions: {
uploadFace: { uploadFace: {
actionType: 'record', actionType: "record",
icon: 'Camera', icon: "Camera",
label: 'Upload face', label: "Upload face",
showInDrawer: true, showInDrawer: true,
handler: async (request: any, response: any, context: any) => { handler: async (request: any, response: any, context: any) => {
const { record, currentAdmin } = context const { record, currentAdmin } = context;
if (request.method === 'post') { if (request.method === "post") {
const { base64 } = request.payload const { base64 } = request.payload;
if (!base64) { if (!base64) {
return { return {
record: record.toJSON(currentAdmin), record: record.toJSON(currentAdmin),
notice: { message: 'No image provided', type: 'error' }, notice: { message: "No image provided", type: "error" },
} };
} }
try { try {
const embedding = await extractEmbedding(base64) const embedding = await extractEmbedding(base64);
await prisma.user.update({ await prisma.user.update({
where: { id: record.params.id }, where: { id: record.params.id },
data: { faceEmbedding: embedding }, data: { faceEmbedding: embedding },
}) });
return { return {
record: record.toJSON(currentAdmin), record: record.toJSON(currentAdmin),
notice: { message: 'Face embedding saved!', type: 'success' }, notice: { message: "Face embedding saved!", type: "success" },
} };
} catch (e: any) { } catch (e: any) {
return { return {
record: record.toJSON(currentAdmin), record: record.toJSON(currentAdmin),
notice: { message: `Failed: ${e.message}`, type: 'error' }, notice: { message: `Failed: ${e.message}`, type: "error" },
};
} }
} }
} return { record: record.toJSON(currentAdmin) };
return { record: record.toJSON(currentAdmin) }
}, },
component: Components.UploadFace, component: Components.UploadFace,
}, },
}, },
}, },
} };
@@ -3,7 +3,7 @@ import { generateMap } from "./mapper.repository.js";
export function checkSession(req: Request, res: Response, next: NextFunction) { export function checkSession(req: Request, res: Response, next: NextFunction) {
if (!(req.session as any)?.adminUser) { if (!(req.session as any)?.adminUser) {
return res.status(401).json({ error: 'Unauthorized' }) return res.status(401).json({ error: "Unauthorized" });
} }
next(); next();
} }
@@ -12,6 +12,6 @@ export async function getRedisMapping(req: Request, res: Response) {
try { try {
res.status(200).send(await generateMap()); res.status(200).send(await generateMap());
} catch (err) { } catch (err) {
res.status(500).json({ error: 'Failed to fetch sessions' }) res.status(500).json({ error: "Failed to fetch sessions" });
} }
} }
@@ -4,26 +4,28 @@ import { prisma } from "../../lib/prisma.js";
export async function generateMap() { export async function generateMap() {
const redis = getRedis(); const redis = getRedis();
const keys = await redis.keys('user:*') const keys = await redis.keys("user:*");
const sessions = await Promise.all(keys.map(async (key) => { const sessions = await Promise.all(
const raw = await redis.get(key) keys.map(async (key) => {
const session = JSON.parse(raw!) const raw = await redis.get(key);
const realUserId = key.replace('user:', '') const session = JSON.parse(raw!);
const realUserId = key.replace("user:", "");
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { id: realUserId }, where: { id: realUserId },
select: { id: true, firstName: true, lastName: true } select: { id: true, firstName: true, lastName: true },
}) });
return { return {
realUserId, realUserId,
firstName: user?.firstName ?? 'Unknown', firstName: user?.firstName ?? "Unknown",
lastName: user?.lastName ?? 'Unknown', lastName: user?.lastName ?? "Unknown",
pseudoId: session.userID, pseudoId: session.userID,
pseudoName: session.name pseudoName: session.name,
} };
})) })
);
return sessions; return sessions;
} }
+2 -2
View File
@@ -52,8 +52,8 @@ const config: Config = {
transitionCleanupInterval: 60 * 1000, transitionCleanupInterval: 60 * 1000,
lossThreshold: 5000, lossThreshold: 5000,
userTTL: 3 * 60 * 1000, userTTL: 3 * 60 * 1000,
verifyTimeout: 30 * 1000 verifyTimeout: 30 * 1000,
}, },
} };
export default config; export default config;
+32 -41
View File
@@ -4,25 +4,9 @@ import { RoomTransition, transitions } from "./transition.js";
import config from "../config/config.js"; import config from "../config/config.js";
import { type RedisUserData } from "../api/livedata/livedata.repository.js"; import { type RedisUserData } from "../api/livedata/livedata.repository.js";
const adjectives = [ const adjectives = ["Crazy", "Silent", "Dark", "Fast", "Lucky", "Wild", "Epic"];
"Crazy",
"Silent",
"Dark",
"Fast",
"Lucky",
"Wild",
"Epic"
];
const nouns = [ const nouns = ["Tiger", "Wolf", "Falcon", "Shadow", "Ninja", "Dragon", "Phoenix"];
"Tiger",
"Wolf",
"Falcon",
"Shadow",
"Ninja",
"Dragon",
"Phoenix"
];
const running = new Set<string>(); const running = new Set<string>();
const userRooms = new Map<string, string>(); const userRooms = new Map<string, string>();
@@ -38,7 +22,7 @@ export async function updateRoomOccupancyListener(message: string, channel: stri
const redis = getRedis(); const redis = getRedis();
if (!lastRoomID) { if (!lastRoomID) {
console.log("[!] Key not found in local map") console.log("[!] Key not found in local map");
} else { } else {
await redis.decr(`room:${lastRoomID}`); await redis.decr(`room:${lastRoomID}`);
} }
@@ -50,28 +34,29 @@ export async function updateRoomOccupancyListener(message: string, channel: stri
} }
function generateNickname() { function generateNickname() {
const adjective = const adjective = adjectives[Math.floor(Math.random() * adjectives.length)];
adjectives[Math.floor(Math.random() * adjectives.length)];
const noun = const noun = nouns[Math.floor(Math.random() * nouns.length)];
nouns[Math.floor(Math.random() * nouns.length)];
const number = Math.floor(Math.random() * 1000); const number = Math.floor(Math.random() * 1000);
return `${adjective}${noun}${number}`; return `${adjective}${noun}${number}`;
} }
function newUser(): { pseudoID: string, psuedoName: string } { function newUser(): { pseudoID: string; psuedoName: string } {
return { return {
pseudoID: crypto.randomUUID(), pseudoID: crypto.randomUUID(),
psuedoName: generateNickname() psuedoName: generateNickname(),
}; };
} }
export async function analyzeData(roomID: string, metrics: { export async function analyzeData(
tagID: string, roomID: string,
rssi: number metrics: {
}) { tagID: string;
rssi: number;
}
) {
if (running.has(metrics.tagID)) { if (running.has(metrics.tagID)) {
console.log(`[!] Skipping TAG_ID: ${metrics.tagID} already in process.`); console.log(`[!] Skipping TAG_ID: ${metrics.tagID} already in process.`);
return; return;
@@ -80,9 +65,11 @@ export async function analyzeData(roomID: string, metrics: {
running.add(metrics.tagID); running.add(metrics.tagID);
try { try {
console.log(`\n=======================================================\n` + console.log(
`\n=======================================================\n` +
`ROOM_ID: ${roomID}\nTAG_ID: ${metrics.tagID}\nRSSI: ${metrics.rssi}\n` + `ROOM_ID: ${roomID}\nTAG_ID: ${metrics.tagID}\nRSSI: ${metrics.rssi}\n` +
`=======================================================\n`); `=======================================================\n`
);
const userID = ( const userID = (
await prisma.tag.findUnique({ await prisma.tag.findUnique({
@@ -93,8 +80,7 @@ export async function analyzeData(roomID: string, metrics: {
console.log(`[SQLite] Resolved USER_ID: ${userID}`); console.log(`[SQLite] Resolved USER_ID: ${userID}`);
if (!userID) if (!userID) return;
return;
const redis = getRedis(); const redis = getRedis();
const res = await redis.get(`user:${userID}`); const res = await redis.get(`user:${userID}`);
@@ -103,14 +89,18 @@ export async function analyzeData(roomID: string, metrics: {
const user = newUser(); const user = newUser();
console.log(`[Redis] Created USER: ${user.psuedoName} USER_ID: ${user.pseudoID}`); console.log(`[Redis] Created USER: ${user.psuedoName} USER_ID: ${user.pseudoID}`);
await redis.set(`user:${userID}`, JSON.stringify({ await redis.set(
`user:${userID}`,
JSON.stringify({
userID: user.pseudoID, userID: user.pseudoID,
name: user.psuedoName, name: user.psuedoName,
rssi: metrics.rssi, rssi: metrics.rssi,
room: roomID, room: roomID,
verified: false, verified: false,
timestamp: Date.now() timestamp: Date.now(),
}), { PX: config.core.userTTL }); }),
{ PX: config.core.userTTL }
);
await redis.set(`_user:${user.pseudoID}`, userID); await redis.set(`_user:${user.pseudoID}`, userID);
@@ -130,7 +120,7 @@ export async function analyzeData(roomID: string, metrics: {
if (roomID == resObj["room"]) { if (roomID == resObj["room"]) {
console.log("[Redis] Same room, updating redis..."); console.log("[Redis] Same room, updating redis...");
resObj["rssi"] = metrics.rssi; resObj["rssi"] = metrics.rssi;
resObj["timestamp"] = Date.now() resObj["timestamp"] = Date.now();
await redis.set(`user:${userID}`, JSON.stringify(resObj), { PX: config.core.userTTL }); await redis.set(`user:${userID}`, JSON.stringify(resObj), { PX: config.core.userTTL });
@@ -145,7 +135,9 @@ export async function analyzeData(roomID: string, metrics: {
transitions.set(userID, transition); transitions.set(userID, transition);
} }
if (transition.shouldTransitionTo(roomID, metrics.rssi, resObj["rssi"], resObj["timestamp"])) { if (
transition.shouldTransitionTo(roomID, metrics.rssi, resObj["rssi"], resObj["timestamp"])
) {
console.log(`[!] Transition done ${resObj["room"]} -> ${roomID}`); console.log(`[!] Transition done ${resObj["room"]} -> ${roomID}`);
await redis.decr(`room:${resObj["room"]}`); await redis.decr(`room:${resObj["room"]}`);
@@ -155,12 +147,11 @@ export async function analyzeData(roomID: string, metrics: {
resObj["rssi"] = metrics.rssi; resObj["rssi"] = metrics.rssi;
resObj["room"] = roomID; resObj["room"] = roomID;
resObj["timestamp"] = Date.now() resObj["timestamp"] = Date.now();
resObj["verified"] = false; resObj["verified"] = false;
await redis.set(`user:${userID}`, JSON.stringify(resObj), { PX: config.core.userTTL }); await redis.set(`user:${userID}`, JSON.stringify(resObj), { PX: config.core.userTTL });
} else } else console.log("[!] Transition declined");
console.log("[!] Transition declined");
} finally { } finally {
running.delete(metrics.tagID); running.delete(metrics.tagID);
} }
+1 -2
View File
@@ -12,8 +12,7 @@ export async function bootstrap() {
console.log(`[!] Flushed Redis.`); console.log(`[!] Flushed Redis.`);
const rooms = await getRoomIDs(); const rooms = await getRoomIDs();
for (const { id } of rooms) for (const { id } of rooms) await redis.set(`room:${id}`, 0);
await redis.set(`room:${id}`, 0);
await initSubRedis(); await initSubRedis();
await mqtt.start(); await mqtt.start();
+81 -75
View File
@@ -1,43 +1,47 @@
import { fileURLToPath } from 'url' import { fileURLToPath } from "url";
import path from 'path' import path from "path";
import { spawn } from "child_process"; import { spawn } from "child_process";
import { getRedis } from '../redis/redis.js'; import { getRedis } from "../redis/redis.js";
import { prisma } from '../lib/prisma.js'; import { prisma } from "../lib/prisma.js";
import config from '../config/config.js'; import config from "../config/config.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url)) const __dirname = path.dirname(fileURLToPath(import.meta.url));
const execPath = '../../bin'; const execPath = "../../bin";
const binaryPath = path.join(__dirname, execPath, 'cameraSession') const binaryPath = path.join(__dirname, execPath, "cameraSession");
type InputPacket = { type InputPacket = {
uuid: string uuid: string;
descriptor: number[] descriptor: number[];
} };
type ResultPacket = { type ResultPacket = {
uuid: string uuid: string;
verified: boolean verified: boolean;
} };
export async function getUnverifiedUserKeys(): Promise<string[]> { export async function getUnverifiedUserKeys(): Promise<string[]> {
const redis = getRedis() const redis = getRedis();
const keys = await redis.keys('user:*') const keys = await redis.keys("user:*");
const unverified: string[] = [] const unverified: string[] = [];
await Promise.all(keys.map(async (key) => { await Promise.all(
const raw = await redis.get(key) keys.map(async (key) => {
if (!raw) return const raw = await redis.get(key);
const session = JSON.parse(raw) if (!raw) return;
if (!session.verified) unverified.push(key.replace('user:', '')) const session = JSON.parse(raw);
})) if (!session.verified) unverified.push(key.replace("user:", ""));
})
);
return unverified return unverified;
} }
export async function getUnverifiedUsersWithEmbeddings(): Promise<{ uuid: string, descriptor: number[] }[]> { export async function getUnverifiedUsersWithEmbeddings(): Promise<
const userIds = await getUnverifiedUserKeys() { uuid: string; descriptor: number[] }[]
> {
const userIds = await getUnverifiedUserKeys();
const users = await prisma.user.findMany({ const users = await prisma.user.findMany({
where: { where: {
@@ -45,88 +49,90 @@ export async function getUnverifiedUsersWithEmbeddings(): Promise<{ uuid: string
faceEmbedding: { not: null }, faceEmbedding: { not: null },
}, },
select: { id: true, faceEmbedding: true }, select: { id: true, faceEmbedding: true },
}) });
return users.map((user) => ({ return users.map((user) => ({
uuid: user.id, uuid: user.id,
descriptor: JSON.parse(user.faceEmbedding!), descriptor: JSON.parse(user.faceEmbedding!),
})) }));
} }
async function cameraSession(packets: InputPacket[]): Promise<ResultPacket[] | null> { async function cameraSession(packets: InputPacket[]): Promise<ResultPacket[] | null> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const proc = spawn(binaryPath, [], { const proc = spawn(binaryPath, [], {
cwd: path.join(__dirname, execPath) cwd: path.join(__dirname, execPath),
}) });
let stdout = '' let stdout = "";
let stderr = '' let stderr = "";
proc.stdout.on('data', (d) => stdout += d) proc.stdout.on("data", (d) => (stdout += d));
proc.stderr.on('data', (d) => stderr += d) proc.stderr.on("data", (d) => (stderr += d));
proc.stdin.on('error', () => { }) proc.stdin.on("error", () => {});
proc.on('error', (err) => { proc.on("error", (err) => {
reject(new Error(`Failed to start binary: ${err.message}`)) reject(new Error(`Failed to start binary: ${err.message}`));
}) });
proc.on('close', (code) => { proc.on("close", (code) => {
console.log('cameraSession exited with code', code) console.log("cameraSession exited with code", code);
const trimmed = stdout.trim() const trimmed = stdout.trim();
if (trimmed === 'no face found.') return resolve(null) if (trimmed === "no face found.") return resolve(null);
if (code !== 0) return reject(new Error(stderr || `exited with code ${code}`)) if (code !== 0) return reject(new Error(stderr || `exited with code ${code}`));
try { try {
const results: ResultPacket[] = JSON.parse(trimmed) const results: ResultPacket[] = JSON.parse(trimmed);
resolve(results) resolve(results);
} catch (e) { } catch (e) {
reject(new Error(`Failed to parse output: ${trimmed}`)) reject(new Error(`Failed to parse output: ${trimmed}`));
} }
}) });
for (const packet of packets) { for (const packet of packets) {
proc.stdin.write(JSON.stringify(packet) + '\n') proc.stdin.write(JSON.stringify(packet) + "\n");
} }
proc.stdin.end() proc.stdin.end();
}) });
} }
const setUnverified = async (uuid: string) => { const setUnverified = async (uuid: string) => {
const redis = getRedis() const redis = getRedis();
const key = `user:${uuid}` const key = `user:${uuid}`;
const raw = await redis.get(key) const raw = await redis.get(key);
if (!raw) return if (!raw) return;
const session = JSON.parse(raw) const session = JSON.parse(raw);
session.verified = false session.verified = false;
await redis.set(key, JSON.stringify(session)) await redis.set(key, JSON.stringify(session));
console.log(`[Camera] Unverified user ${uuid} due to timeout`) console.log(`[Camera] Unverified user ${uuid} due to timeout`);
} };
const run = async () => { const run = async () => {
const redis = getRedis(); const redis = getRedis();
try { try {
const packets = await getUnverifiedUsersWithEmbeddings() const packets = await getUnverifiedUsersWithEmbeddings();
if (packets.length > 0) { if (packets.length > 0) {
const results = await cameraSession(packets) const results = await cameraSession(packets);
if (results) { if (results) {
await Promise.all(results.map(async (r) => { await Promise.all(
if (!r.verified) return results.map(async (r) => {
const key = `user:${r.uuid}` if (!r.verified) return;
const raw = await redis.get(key) const key = `user:${r.uuid}`;
if (!raw) return const raw = await redis.get(key);
const session = JSON.parse(raw) if (!raw) return;
session.verified = true const session = JSON.parse(raw);
await redis.set(key, JSON.stringify(session)) session.verified = true;
await redis.set(key, JSON.stringify(session));
setTimeout(() => setUnverified(r.uuid), config.core.verifyTimeout) setTimeout(() => setUnverified(r.uuid), config.core.verifyTimeout);
console.log(`[Camera] Verified user ${r.uuid}`) console.log(`[Camera] Verified user ${r.uuid}`);
})) })
);
} }
} }
} catch (e) { } catch (e) {
console.error('camera run error:', e) console.error("camera run error:", e);
} }
setTimeout(run, 1000) setTimeout(run, 1000);
} };
export { run as initCamera }; export { run as initCamera };
+4 -7
View File
@@ -4,13 +4,10 @@ import { analyzeData } from "./analyze.js";
function initMqtt(): Promise<MqttClient> { function initMqtt(): Promise<MqttClient> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const client = mqtt.connect( const client = mqtt.connect(`mqtt://${config.mqtt.host}:${config.mqtt.port}`, {
`mqtt://${config.mqtt.host}:${config.mqtt.port}`,
{
username: config.mqtt.username, username: config.mqtt.username,
password: config.mqtt.password password: config.mqtt.password,
} });
);
client.once("connect", () => { client.once("connect", () => {
console.log("[MQTT] MQTT connected."); console.log("[MQTT] MQTT connected.");
@@ -41,7 +38,7 @@ export async function start() {
mqttClient.on("error", (err) => { mqttClient.on("error", (err) => {
console.log(err.message); console.log(err.message);
}) });
} catch (err: any) { } catch (err: any) {
console.log("[MQTT] MQTT connection failed:", err.message); console.log("[MQTT] MQTT connection failed:", err.message);
} }
+9 -4
View File
@@ -9,8 +9,7 @@ export function cleanupWorker() {
for (const [userID, transition] of transitions) { for (const [userID, transition] of transitions) {
const inactiveFor = now - transition.lastSeen; const inactiveFor = now - transition.lastSeen;
if (inactiveFor > 5 * config.core.transitionTTL) if (inactiveFor > 5 * config.core.transitionTTL) transitions.delete(userID);
transitions.delete(userID);
} }
}, config.core.transitionCleanupInterval); }, config.core.transitionCleanupInterval);
} }
@@ -31,7 +30,12 @@ export class RoomTransition {
this.candidate = { roomID: null, since: null, samples: 0, rssi: null }; this.candidate = { roomID: null, since: null, samples: 0, rssi: null };
} }
shouldTransitionTo(roomID: string, candidateRSSI: number, currRSSI: number, lastCurrentSeen: number) { shouldTransitionTo(
roomID: string,
candidateRSSI: number,
currRSSI: number,
lastCurrentSeen: number
) {
const now = Date.now(); const now = Date.now();
this.lastSeen = now; this.lastSeen = now;
@@ -48,7 +52,8 @@ export class RoomTransition {
} }
const noCandidate = this.candidate.roomID == null; const noCandidate = this.candidate.roomID == null;
const betterCandidate = this.candidate.roomID != roomID && const betterCandidate =
this.candidate.roomID != roomID &&
this.candidate.rssi !== null && this.candidate.rssi !== null &&
candidateRSSI > this.candidate.rssi + config.core.candidateHysteresis; candidateRSSI > this.candidate.rssi + config.core.candidateHysteresis;
+4 -5
View File
@@ -1,8 +1,8 @@
import express from "express"; import express from "express";
import lookupRouter from "./api/lookup/lookup.routes.js"; import lookupRouter from "./api/lookup/lookup.routes.js";
import livedataRouter from "./api/livedata/livadata.routes.js" import livedataRouter from "./api/livedata/livadata.routes.js";
import mapperRouter from "./api/mapper/mapper.routes.js" import mapperRouter from "./api/mapper/mapper.routes.js";
import { createAdmin, sessionMiddleware } from './api/management/adminjs.routes.js' import { createAdmin, sessionMiddleware } from "./api/management/adminjs.routes.js";
import config from "./config/config.js"; import config from "./config/config.js";
import { bootstrap } from "./core/bootstrap.js"; import { bootstrap } from "./core/bootstrap.js";
@@ -23,5 +23,4 @@ import { bootstrap } from "./core/bootstrap.js";
}); });
bootstrap(); bootstrap();
})();
})()
+2 -2
View File
@@ -1,5 +1,5 @@
import { PrismaClient } from '@prisma/client' import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient() const prisma = new PrismaClient();
export { prisma }; export { prisma };
+2 -2
View File
@@ -7,7 +7,7 @@ let subRedisClient: RedisClientType;
export async function initSubRedis(): Promise<void> { export async function initSubRedis(): Promise<void> {
subRedisClient = createClient({ subRedisClient = createClient({
url: `redis://${config.redis.host}:${config.redis.port}` url: `redis://${config.redis.host}:${config.redis.port}`,
}); });
subRedisClient.on("error", (err) => { subRedisClient.on("error", (err) => {
@@ -25,7 +25,7 @@ export async function initSubRedis(): Promise<void> {
export async function initRedis(): Promise<RedisClientType> { export async function initRedis(): Promise<RedisClientType> {
redisClient = createClient({ redisClient = createClient({
url: `redis://${config.redis.host}:${config.redis.port}` url: `redis://${config.redis.host}:${config.redis.port}`,
}); });
redisClient.on("error", (err) => { redisClient.on("error", (err) => {