feat(devices): compute effective online status with stale heartbeat ttl
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
DATABASE_URL=postgres://username:password@localhost:5432/database_name
|
||||
BETTER_AUTH_SECRET=replace_with_a_long_random_secret
|
||||
BETTER_AUTH_SECRET=7bXC7LC/1IWRG3XfojTR+m9c/mN2kXLzN+g1j+cB96VuMFXml2DG8p4/3RvpMH8Tax5FXN2Bv4tjR+8/Qca0Jg
|
||||
BETTER_AUTH_BASE_URL=http://localhost:3000
|
||||
BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:5173
|
||||
PORT=3000
|
||||
DEVICE_ONLINE_STALE_SECONDS=30
|
||||
MINIO_ENDPOINT=localhost
|
||||
MINIO_PORT=9000
|
||||
MINIO_USE_SSL=false
|
||||
|
||||
@@ -34,6 +34,7 @@ Required env vars:
|
||||
| `BETTER_AUTH_BASE_URL` | Public base URL for the backend (e.g., `http://localhost:3000`) |
|
||||
| `BETTER_AUTH_TRUSTED_ORIGINS` | Comma-separated list of allowed frontend origins |
|
||||
| `PORT` | HTTP port (default `3000`) |
|
||||
| `DEVICE_ONLINE_STALE_SECONDS` | Presence TTL in seconds before an `online` device is reported as `offline` (default `30`) |
|
||||
| `MEDIA_MODE` | Media runtime mode (`legacy` default, `single_server_sfu` scaffold mode) |
|
||||
| `MEDIA_PROVIDER` | Media backend provider (`mock` by default) |
|
||||
| `TURN_URLS` / `TURN_USERNAME` / `TURN_CREDENTIAL` | TURN/STUN configuration used by single-server SFU mode |
|
||||
|
||||
@@ -7,6 +7,8 @@ import { deviceLinks, devices } from '../db/schema';
|
||||
import { requireAuth } from '../middleware/auth';
|
||||
import { requireDeviceAuth } from '../middleware/device-auth';
|
||||
import { createDeviceToken } from '../utils/device-token';
|
||||
import { getEffectiveDeviceStatus } from '../utils/device-status';
|
||||
import { getDeviceOnlineStaleSeconds } from '../utils/env';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -126,7 +128,19 @@ router.get('/', requireAuth, async (req, res) => {
|
||||
orderBy: [desc(devices.updatedAt)],
|
||||
});
|
||||
|
||||
res.json({ count: result.length, devices: result });
|
||||
const now = new Date();
|
||||
const staleAfterSeconds = getDeviceOnlineStaleSeconds();
|
||||
const effectiveDevices = result.map((device) => ({
|
||||
...device,
|
||||
status: getEffectiveDeviceStatus({
|
||||
status: device.status,
|
||||
lastSeenAt: device.lastSeenAt,
|
||||
now,
|
||||
staleAfterSeconds,
|
||||
}),
|
||||
}));
|
||||
|
||||
res.json({ count: effectiveDevices.length, devices: effectiveDevices });
|
||||
});
|
||||
|
||||
router.patch('/:deviceId', requireAuth, async (req, res) => {
|
||||
|
||||
60
Backend/tests/device-status.test.ts
Normal file
60
Backend/tests/device-status.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { getEffectiveDeviceStatus } from '../utils/device-status';
|
||||
|
||||
describe('device status helper', () => {
|
||||
test('reports online when status is online and heartbeat is fresh', () => {
|
||||
const now = new Date('2026-02-25T12:00:00.000Z');
|
||||
const lastSeenAt = new Date('2026-02-25T11:59:45.000Z');
|
||||
|
||||
const status = getEffectiveDeviceStatus({
|
||||
status: 'online',
|
||||
lastSeenAt,
|
||||
now,
|
||||
staleAfterSeconds: 30,
|
||||
});
|
||||
|
||||
expect(status).toBe('online');
|
||||
});
|
||||
|
||||
test('reports offline when heartbeat is stale', () => {
|
||||
const now = new Date('2026-02-25T12:00:00.000Z');
|
||||
const lastSeenAt = new Date('2026-02-25T11:59:00.000Z');
|
||||
|
||||
const status = getEffectiveDeviceStatus({
|
||||
status: 'online',
|
||||
lastSeenAt,
|
||||
now,
|
||||
staleAfterSeconds: 30,
|
||||
});
|
||||
|
||||
expect(status).toBe('offline');
|
||||
});
|
||||
|
||||
test('reports offline when stored status is not online', () => {
|
||||
const now = new Date('2026-02-25T12:00:00.000Z');
|
||||
const lastSeenAt = new Date('2026-02-25T11:59:55.000Z');
|
||||
|
||||
const status = getEffectiveDeviceStatus({
|
||||
status: 'offline',
|
||||
lastSeenAt,
|
||||
now,
|
||||
staleAfterSeconds: 30,
|
||||
});
|
||||
|
||||
expect(status).toBe('offline');
|
||||
});
|
||||
|
||||
test('reports offline when lastSeenAt is missing', () => {
|
||||
const now = new Date('2026-02-25T12:00:00.000Z');
|
||||
|
||||
const status = getEffectiveDeviceStatus({
|
||||
status: 'online',
|
||||
lastSeenAt: null,
|
||||
now,
|
||||
staleAfterSeconds: 30,
|
||||
});
|
||||
|
||||
expect(status).toBe('offline');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
|
||||
import { getBetterAuthBaseUrl, getFirstDefinedEnv } from '../utils/env';
|
||||
import { getBetterAuthBaseUrl, getDeviceOnlineStaleSeconds, getFirstDefinedEnv } from '../utils/env';
|
||||
|
||||
const ORIGINAL_ENV = { ...process.env };
|
||||
|
||||
@@ -22,4 +22,25 @@ describe('env helpers', () => {
|
||||
|
||||
expect(getBetterAuthBaseUrl()).toBe('http://base-url:4000');
|
||||
});
|
||||
|
||||
test('getDeviceOnlineStaleSeconds defaults to 30', () => {
|
||||
delete process.env.DEVICE_ONLINE_STALE_SECONDS;
|
||||
expect(getDeviceOnlineStaleSeconds()).toBe(30);
|
||||
});
|
||||
|
||||
test('getDeviceOnlineStaleSeconds parses valid positive integer values', () => {
|
||||
process.env.DEVICE_ONLINE_STALE_SECONDS = '45';
|
||||
expect(getDeviceOnlineStaleSeconds()).toBe(45);
|
||||
});
|
||||
|
||||
test('getDeviceOnlineStaleSeconds falls back to default on invalid values', () => {
|
||||
process.env.DEVICE_ONLINE_STALE_SECONDS = '0';
|
||||
expect(getDeviceOnlineStaleSeconds()).toBe(30);
|
||||
|
||||
process.env.DEVICE_ONLINE_STALE_SECONDS = '-2';
|
||||
expect(getDeviceOnlineStaleSeconds()).toBe(30);
|
||||
|
||||
process.env.DEVICE_ONLINE_STALE_SECONDS = 'abc';
|
||||
expect(getDeviceOnlineStaleSeconds()).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
32
Backend/utils/device-status.ts
Normal file
32
Backend/utils/device-status.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { getDeviceOnlineStaleSeconds } from './env';
|
||||
|
||||
export type EffectiveDeviceStatus = 'online' | 'offline';
|
||||
|
||||
type EffectiveDeviceStatusParams = {
|
||||
status: string | null | undefined;
|
||||
lastSeenAt: Date | null | undefined;
|
||||
now?: Date;
|
||||
staleAfterSeconds?: number;
|
||||
};
|
||||
|
||||
export const getEffectiveDeviceStatus = ({
|
||||
status,
|
||||
lastSeenAt,
|
||||
now = new Date(),
|
||||
staleAfterSeconds = getDeviceOnlineStaleSeconds(),
|
||||
}: EffectiveDeviceStatusParams): EffectiveDeviceStatus => {
|
||||
if (status !== 'online') {
|
||||
return 'offline';
|
||||
}
|
||||
|
||||
if (!(lastSeenAt instanceof Date) || Number.isNaN(lastSeenAt.getTime())) {
|
||||
return 'offline';
|
||||
}
|
||||
|
||||
const elapsedMs = now.getTime() - lastSeenAt.getTime();
|
||||
if (elapsedMs < 0) {
|
||||
return 'online';
|
||||
}
|
||||
|
||||
return elapsedMs <= staleAfterSeconds * 1000 ? 'online' : 'offline';
|
||||
};
|
||||
@@ -32,3 +32,19 @@ export const getRequiredEnv = (name: string): string => {
|
||||
export const getBetterAuthBaseUrl = (): string => {
|
||||
return getFirstDefinedEnv('BETTER_AUTH_BASE_URL', 'BETTER_AUTH_URL') ?? `http://localhost:${process.env.PORT ?? '3000'}`;
|
||||
};
|
||||
|
||||
const DEFAULT_DEVICE_ONLINE_STALE_SECONDS = 30;
|
||||
|
||||
export const getDeviceOnlineStaleSeconds = (): number => {
|
||||
const value = getFirstDefinedEnv('DEVICE_ONLINE_STALE_SECONDS');
|
||||
if (!value) {
|
||||
return DEFAULT_DEVICE_ONLINE_STALE_SECONDS;
|
||||
}
|
||||
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
return DEFAULT_DEVICE_ONLINE_STALE_SECONDS;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user