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
|
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_BASE_URL=http://localhost:3000
|
||||||
BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:5173
|
BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:5173
|
||||||
PORT=3000
|
PORT=3000
|
||||||
|
DEVICE_ONLINE_STALE_SECONDS=30
|
||||||
MINIO_ENDPOINT=localhost
|
MINIO_ENDPOINT=localhost
|
||||||
MINIO_PORT=9000
|
MINIO_PORT=9000
|
||||||
MINIO_USE_SSL=false
|
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_BASE_URL` | Public base URL for the backend (e.g., `http://localhost:3000`) |
|
||||||
| `BETTER_AUTH_TRUSTED_ORIGINS` | Comma-separated list of allowed frontend origins |
|
| `BETTER_AUTH_TRUSTED_ORIGINS` | Comma-separated list of allowed frontend origins |
|
||||||
| `PORT` | HTTP port (default `3000`) |
|
| `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_MODE` | Media runtime mode (`legacy` default, `single_server_sfu` scaffold mode) |
|
||||||
| `MEDIA_PROVIDER` | Media backend provider (`mock` by default) |
|
| `MEDIA_PROVIDER` | Media backend provider (`mock` by default) |
|
||||||
| `TURN_URLS` / `TURN_USERNAME` / `TURN_CREDENTIAL` | TURN/STUN configuration used by single-server SFU mode |
|
| `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 { requireAuth } from '../middleware/auth';
|
||||||
import { requireDeviceAuth } from '../middleware/device-auth';
|
import { requireDeviceAuth } from '../middleware/device-auth';
|
||||||
import { createDeviceToken } from '../utils/device-token';
|
import { createDeviceToken } from '../utils/device-token';
|
||||||
|
import { getEffectiveDeviceStatus } from '../utils/device-status';
|
||||||
|
import { getDeviceOnlineStaleSeconds } from '../utils/env';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -126,7 +128,19 @@ router.get('/', requireAuth, async (req, res) => {
|
|||||||
orderBy: [desc(devices.updatedAt)],
|
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) => {
|
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 { 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 };
|
const ORIGINAL_ENV = { ...process.env };
|
||||||
|
|
||||||
@@ -22,4 +22,25 @@ describe('env helpers', () => {
|
|||||||
|
|
||||||
expect(getBetterAuthBaseUrl()).toBe('http://base-url:4000');
|
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 => {
|
export const getBetterAuthBaseUrl = (): string => {
|
||||||
return getFirstDefinedEnv('BETTER_AUTH_BASE_URL', 'BETTER_AUTH_URL') ?? `http://localhost:${process.env.PORT ?? '3000'}`;
|
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