Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3221e95d0e | ||
|
|
a7974c78f3 |
@@ -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(
|
||||||
.filter((v) => v.occupancy !== null)
|
...values
|
||||||
.map((v) => ({
|
.filter((v) => v.occupancy !== null)
|
||||||
roomID: v.roomID.slice(5),
|
.map((v) => ({
|
||||||
occupancy: Number(v.occupancy),
|
roomID: v.roomID.slice(5),
|
||||||
}))
|
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,58 +2,55 @@ 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";
|
||||||
import roomResource from "./resources/adminjs.room.resource.js";
|
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({
|
||||||
rootPath: "/admin",
|
rootPath: "/admin",
|
||||||
resources: [userResource, tagResource, roomResource],
|
resources: [userResource, tagResource, roomResource],
|
||||||
branding: { companyName: "OfficeSense", logo: false },
|
branding: { companyName: "OfficeSense", logo: false },
|
||||||
dashboard: { component: Components.Dashboard },
|
dashboard: { component: Components.Dashboard },
|
||||||
componentLoader,
|
componentLoader,
|
||||||
});
|
});
|
||||||
|
|
||||||
await admin.watch();
|
await admin.watch();
|
||||||
|
|
||||||
const router = AdminJSExpress.buildAuthenticatedRouter(
|
const router = AdminJSExpress.buildAuthenticatedRouter(
|
||||||
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 null;
|
||||||
return { email }
|
},
|
||||||
|
cookieName: "adminjs",
|
||||||
|
cookiePassword: process.env.ADMIN_COOKIE_SECRET ?? "change-me",
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
resave: false,
|
||||||
|
saveUninitialized: false,
|
||||||
|
secret: process.env.ADMIN_COOKIE_SECRET ?? "change-me",
|
||||||
}
|
}
|
||||||
return null
|
);
|
||||||
},
|
|
||||||
cookieName: 'adminjs',
|
|
||||||
cookiePassword: process.env.ADMIN_COOKIE_SECRET ?? 'change-me',
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
{
|
|
||||||
resave: false,
|
|
||||||
saveUninitialized: false,
|
|
||||||
secret: process.env.ADMIN_COOKIE_SECRET ?? 'change-me',
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return { admin, router }
|
return { admin, router };
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionMiddleware = session({
|
const sessionMiddleware = session({
|
||||||
secret: process.env.ADMIN_COOKIE_SECRET ?? "change-me",
|
secret: process.env.ADMIN_COOKIE_SECRET ?? "change-me",
|
||||||
resave: false,
|
resave: false,
|
||||||
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={{
|
||||||
fontWeight: 600,
|
fontSize: "12px",
|
||||||
padding: '3px 10px',
|
fontWeight: 600,
|
||||||
borderRadius: '4px',
|
padding: "3px 10px",
|
||||||
background: has ? '#dcfce7' : '#fee2e2',
|
borderRadius: "4px",
|
||||||
color: has ? '#16a34a' : '#dc2626',
|
background: has ? "#dcfce7" : "#fee2e2",
|
||||||
}}>
|
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 {
|
||||||
|
realUserId,
|
||||||
|
firstName: user?.firstName ?? "Unknown",
|
||||||
|
lastName: user?.lastName ?? "Unknown",
|
||||||
|
pseudoId: session.userID,
|
||||||
|
pseudoName: session.name,
|
||||||
|
};
|
||||||
})
|
})
|
||||||
|
);
|
||||||
return {
|
|
||||||
realUserId,
|
|
||||||
firstName: user?.firstName ?? 'Unknown',
|
|
||||||
lastName: user?.lastName ?? 'Unknown',
|
|
||||||
pseudoId: session.userID,
|
|
||||||
pseudoName: session.name
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
|
|
||||||
return sessions;
|
return sessions;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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(
|
||||||
`ROOM_ID: ${roomID}\nTAG_ID: ${metrics.tagID}\nRSSI: ${metrics.rssi}\n` +
|
`\n=======================================================\n` +
|
||||||
`=======================================================\n`);
|
`ROOM_ID: ${roomID}\nTAG_ID: ${metrics.tagID}\nRSSI: ${metrics.rssi}\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(
|
||||||
userID: user.pseudoID,
|
`user:${userID}`,
|
||||||
name: user.psuedoName,
|
JSON.stringify({
|
||||||
rssi: metrics.rssi,
|
userID: user.pseudoID,
|
||||||
room: roomID,
|
name: user.psuedoName,
|
||||||
verified: false,
|
rssi: metrics.rssi,
|
||||||
timestamp: Date.now()
|
room: roomID,
|
||||||
}), { PX: config.core.userTTL });
|
verified: false,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
}),
|
||||||
|
{ 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,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,
|
||||||
{
|
password: config.mqtt.password,
|
||||||
username: config.mqtt.username,
|
});
|
||||||
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,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;
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
})();
|
||||||
})()
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user