feat(devices): compute effective online status with stale heartbeat ttl

This commit is contained in:
2026-02-23 14:35:00 +00:00
parent 46c6294e48
commit 53ad0adead
7 changed files with 148 additions and 3 deletions

View File

@@ -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

View File

@@ -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 |

View File

@@ -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) => {

View 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');
});
});

View File

@@ -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);
});
}); });

View 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';
};

View File

@@ -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;
};