Files
Qris-Soundbox/dist/shared/store/heartbeatStore.js

163 lines
5.1 KiB
JavaScript

import { randomUUID } from "node:crypto";
import { getPool } from "../db/pool";
function nowIso() {
return new Date().toISOString();
}
function mapHeartbeat(row) {
return {
id: row.id,
device_id: row.device_id,
timestamp: row.timestamp,
received_at: row.received_at,
firmware_version: row.firmware_version || undefined,
network_strength: row.network_strength,
battery_level: row.battery_level,
state: row.state || undefined
};
}
export async function createDeviceHeartbeat(payload) {
const now = nowIso();
const id = randomUUID();
const { rows } = await getPool().query(`INSERT INTO device_heartbeats (
id,
device_id,
timestamp,
received_at,
firmware_version,
network_strength,
battery_level,
state,
payload_json
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
RETURNING *`, [
id,
payload.device_id,
payload.timestamp,
now,
payload.firmware_version,
payload.network_strength,
payload.battery_level,
payload.state,
payload.payload_json || {}
]);
return mapHeartbeat(rows[0]);
}
export async function getLatestHeartbeatByDeviceId(deviceId) {
const { rows } = await getPool().query(`SELECT * FROM device_heartbeats
WHERE device_id = $1
ORDER BY received_at DESC
LIMIT 1`, [deviceId]);
return rows[0] ? mapHeartbeat(rows[0]) : null;
}
export async function getHeartbeatCountForDeviceLastHours(deviceId, hours = 24) {
const { rows } = await getPool().query(`SELECT COUNT(*)::INT AS cnt
FROM device_heartbeats
WHERE device_id = $1
AND (NOW() AT TIME ZONE 'utc' - timestamp) <= ($2 || ' hours')::interval`, [deviceId, hours]);
return Number(rows[0]?.cnt || 0);
}
export async function listHeartbeats(filter) {
const clauses = [];
const params = [];
let i = 1;
if (filter?.device_id) {
clauses.push(`device_id = $${i++}`);
params.push(filter.device_id);
}
if (filter?.state) {
clauses.push(`state = $${i++}`);
params.push(filter.state);
}
if (filter?.from) {
clauses.push(`timestamp >= $${i++}`);
params.push(filter.from);
}
if (filter?.to) {
clauses.push(`timestamp <= $${i++}`);
params.push(filter.to);
}
const whereSql = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "";
const limitSql = filter?.limit ? `LIMIT ${Number(filter.limit)}` : "";
const { rows } = await getPool().query(`SELECT * FROM device_heartbeats ${whereSql} ORDER BY received_at DESC ${limitSql}`, params);
return rows.map(mapHeartbeat);
}
export function deriveDeviceStatus(input) {
const now = Date.now();
const lastSeen = Date.parse(input?.last_seen_at || "");
if (!Number.isFinite(lastSeen)) {
return "offline";
}
const ageSeconds = (now - lastSeen) / 1000;
if (ageSeconds > 900) {
return "offline";
}
if (ageSeconds > 90) {
return "stale";
}
if ((typeof input?.network_strength === "number" && input.network_strength < 40) ||
(typeof input?.battery_level === "number" && input.battery_level < 20)) {
return "degraded";
}
return "online";
}
export function deriveDeviceHealthSummary(input) {
const now = Date.now();
const lastSeen = Date.parse(input?.last_seen_at || "");
const reasons = [];
if (!Number.isFinite(lastSeen)) {
return {
status: "offline",
score: 0,
age_seconds: null,
reasons: ["no_heartbeat"]
};
}
const ageSeconds = Math.max(0, Math.floor((now - lastSeen) / 1000));
const networkStrength = typeof input?.network_strength === "number" ? input.network_strength : null;
const batteryLevel = typeof input?.battery_level === "number" ? input.battery_level : null;
if (ageSeconds > 900) {
reasons.push("offline_threshold_exceeded");
}
else if (ageSeconds > 90) {
reasons.push("stale_threshold_exceeded");
}
if (typeof networkStrength === "number" && networkStrength < 40) {
reasons.push("low_signal");
}
if (typeof batteryLevel === "number" && batteryLevel < 20) {
reasons.push("low_battery");
}
let status = "online";
if (reasons.includes("offline_threshold_exceeded")) {
status = "offline";
}
else if (reasons.includes("stale_threshold_exceeded")) {
status = "stale";
}
else if (reasons.includes("low_signal") || reasons.includes("low_battery")) {
status = "degraded";
}
let score = 100;
if (ageSeconds > 900) {
score -= 80;
}
else if (ageSeconds > 90) {
score -= 35;
}
else if (ageSeconds > 60) {
score -= 10;
}
if (typeof networkStrength === "number") {
score -= Math.max(0, 40 - networkStrength);
}
if (typeof batteryLevel === "number") {
score -= Math.max(0, 20 - batteryLevel);
}
return {
status,
score: Math.max(0, Math.min(100, Math.round(score))),
age_seconds: ageSeconds,
reasons
};
}