health service and panel

This commit is contained in:
legop3
2026-01-13 20:16:11 -05:00
parent c2ca951fe8
commit e666b4c195
7 changed files with 232 additions and 20 deletions
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -11,7 +11,7 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
<title>Multi Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-Cvk-2BVU.js"></script>
<script type="module" crossorigin src="/assets/index-BL3lHV6I.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DPKQJjsh.css">
</head>
<body>
+9 -5
View File
@@ -295,14 +295,18 @@ function resolveReplaySources(query) {
return { sources };
}
function buildReplayCaption(requester, sources = []) {
function buildReplayCaption(requester, sources = [], missingSources = []) {
const requesterLabel = requester || 'unknown';
const sourceLabel = sources.length
? `Sources: ${sources.map((source) => source.label || `${source.type}:${source.id}`).join(', ')}.`
: 'No sources.';
const missingLabel = missingSources.length
? `Missing: ${missingSources.map((source) => source.label || `${source.type}:${source.id}`).join(', ')}.`
: null;
return [
`Replay requested by ${requesterLabel}.`,
sourceLabel,
missingLabel,
buildDriverCaption(),
]
.filter(Boolean)
@@ -313,9 +317,9 @@ async function sendReplayToChannel(channelId, requester, sources = []) {
if (!channelId) {
throw new Error('Replay channel not configured');
}
const buffer = await buildReplayVideo({ sources });
const { buffer, usedSources, missingSources } = await buildReplayVideo({ sources });
const attachment = new AttachmentBuilder(buffer, { name: 'replay.mp4' });
const caption = buildReplayCaption(requester, sources);
const caption = buildReplayCaption(requester, usedSources, missingSources);
await sendToChannel(channelId, caption, { files: [attachment] }, { parse: [] });
}
@@ -351,9 +355,9 @@ async function handleReplayCommand(message, query) {
const requester =
message.member?.nickname || message.author?.globalName || message.author?.username || 'Discord';
try {
const buffer = await buildReplayVideo({ sources });
const { buffer, usedSources, missingSources } = await buildReplayVideo({ sources });
const attachment = new AttachmentBuilder(buffer, { name: 'replay.mp4' });
const caption = buildReplayCaption(requester, sources);
const caption = buildReplayCaption(requester, usedSources, missingSources);
await message.reply({
content: sanitizeMentions(caption),
files: [attachment],
+126
View File
@@ -0,0 +1,126 @@
const fsp = require('fs/promises');
const path = require('path');
const roverManager = require('./roverManager');
const { getRoomCameras } = require('./roomCameraService');
const { getRoomCameraState } = require('./roomCameraSnapshotService');
const { getReplaySources } = require('./replaySourceService');
const { replaySegmentsDir, segmentSeconds, bufferSeconds } = require('./replaySegmentManager');
const ROVER_SNAPSHOT_DIR = process.env.ROVER_SNAPSHOT_DIR || '/var/lib/rover-snapshots';
const HEALTH_INTERVAL_MS = 5000;
const ROOM_CAMERA_STALE_MS = 5000;
const ROVER_SNAPSHOT_STALE_MS = 5000;
let latest = {
updatedAt: Date.now(),
replay: { sources: [], readyCount: 0, totalCount: 0 },
snapshots: { rovers: [], rooms: [] },
};
async function collectReplayHealth(now) {
const neededCount = Math.max(1, Math.ceil(20000 / (segmentSeconds * 1000)));
const sources = getReplaySources();
const list = [];
let readyCount = 0;
for (const source of sources) {
const key = `${source.type}__${source.id}`;
const dir = path.join(replaySegmentsDir, key);
let lastSegmentAt = null;
let recentCount = 0;
try {
const entries = await fsp.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith('.mp4')) continue;
const stat = await fsp.stat(path.join(dir, entry.name));
if (stat.mtimeMs > (lastSegmentAt || 0)) {
lastSegmentAt = stat.mtimeMs;
}
if (now - stat.mtimeMs <= bufferSeconds * 1000) {
recentCount += 1;
}
}
} catch {
// directory missing or unreadable
}
const ready = recentCount >= neededCount;
if (ready) readyCount += 1;
list.push({
type: source.type,
id: source.id,
label: source.label || `${source.type}:${source.id}`,
recentCount,
neededCount,
lastSegmentAt,
ready,
});
}
return { sources: list, readyCount, totalCount: list.length };
}
async function collectSnapshotHealth(now) {
const rovers = roverManager.getRoster().map((rover) => ({
id: String(rover.id),
name: rover.name || rover.id,
}));
const roverSnapshots = [];
for (const rover of rovers) {
const filePath = path.join(ROVER_SNAPSHOT_DIR, `${rover.id}.jpg`);
let exists = false;
let size = 0;
let updatedAt = null;
try {
const stat = await fsp.stat(filePath);
exists = true;
size = stat.size;
updatedAt = stat.mtimeMs;
} catch {
// missing snapshot
}
const ageMs = updatedAt ? now - updatedAt : null;
const stale = ageMs != null ? ageMs > ROVER_SNAPSHOT_STALE_MS : true;
roverSnapshots.push({
id: rover.id,
name: rover.name,
exists,
size,
updatedAt,
ageMs,
stale,
});
}
const roomSnapshots = getRoomCameras().map((camera) => {
const state = getRoomCameraState(camera.id);
const updatedAt = state?.ts || null;
const ageMs = updatedAt ? now - updatedAt : null;
const stale = ageMs != null ? ageMs > ROOM_CAMERA_STALE_MS : true;
return {
id: camera.id,
name: camera.name || camera.id,
updatedAt,
ageMs,
error: state?.error || null,
stale,
};
});
return { rovers: roverSnapshots, rooms: roomSnapshots };
}
async function refreshHealth() {
const now = Date.now();
const replay = await collectReplayHealth(now);
const snapshots = await collectSnapshotHealth(now);
latest = { updatedAt: now, replay, snapshots };
}
refreshHealth();
setInterval(refreshHealth, HEALTH_INTERVAL_MS);
function getHealthSnapshot() {
return latest;
}
module.exports = {
getHealthSnapshot,
};
+22 -6
View File
@@ -87,22 +87,29 @@ async function buildReplayVideo({ sources = [] } = {}) {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'rover-replay-'));
try {
const clipPaths = [];
const usedSources = [];
const missingSources = [];
for (let i = 0; i < sources.length; i += 1) {
const source = sources[i];
const key = `${source.type}__${source.id}`;
let segmentPaths;
try {
segmentPaths = await listLatestSegments(key, segmentCount);
} catch {
throw new Error(`Missing replay segments for ${source.type}:${source.id}`);
} catch (err) {
missingSources.push({ ...source, reason: err.message || 'missing segments' });
continue;
}
if (segmentPaths.length < segmentCount) {
throw new Error(`Not enough replay segments for ${source.type}:${source.id}`);
missingSources.push({
...source,
reason: `only ${segmentPaths.length}/${segmentCount} segments available`,
});
continue;
}
const concatPath = path.join(tmpDir, `concat-${i}.txt`);
const concatPath = path.join(tmpDir, `concat-${clipPaths.length}.txt`);
const concatBody = segmentPaths.map((file) => `file '${file}'`).join('\n');
await fsp.writeFile(concatPath, concatBody);
const clipPath = path.join(tmpDir, `clip-${i}.mp4`);
const clipPath = path.join(tmpDir, `clip-${clipPaths.length}.mp4`);
await execFileAsync('ffmpeg', [
'-hide_banner',
'-loglevel',
@@ -118,6 +125,15 @@ async function buildReplayVideo({ sources = [] } = {}) {
clipPath,
]);
clipPaths.push(clipPath);
usedSources.push(source);
}
if (!clipPaths.length) {
throw new Error('No replay segments available for selected sources');
}
if (missingSources.length) {
logger.warn('Replay sources missing segments', {
missing: missingSources.map((source) => `${source.type}:${source.id}`),
});
}
const { maxWidth, maxHeight } = await probeMaxFrameSize(clipPaths);
@@ -188,7 +204,7 @@ async function buildReplayVideo({ sources = [] } = {}) {
outPath,
]);
const buffer = await fsp.readFile(outPath);
return buffer;
return { buffer, usedSources, missingSources };
} finally {
try {
await fsp.rm(tmpDir, { recursive: true, force: true });
+2
View File
@@ -11,6 +11,7 @@ const { getState: getHomeAssistantState, homeAssistantEvents } = require('./home
const { getNickname, nicknameEvents } = require('./nicknameService');
const { getReplayState, replayEvents } = require('./replayService');
const { getReplaySources } = require('./replaySourceService');
const { getHealthSnapshot } = require('./healthService');
const { loadConfig } = require('../helpers/configLoader');
const discordInvite = loadConfig().discord?.invite || null;
@@ -51,6 +52,7 @@ function buildSession(socket) {
homeAssistant: getHomeAssistantState(),
replay: getReplayState(),
replaySources: getReplaySources(),
health: getHealthSnapshot(),
users,
discord: {
invite: discordInvite,
+64
View File
@@ -13,6 +13,7 @@ export default function AdminPanel() {
const { session, lockRover, setMode, requestControl } = useSession();
const roster = useMemo(() => session?.roster ?? [], [session?.roster]);
const [lockStates, setLockStates] = useState({});
const health = session?.health || null;
const isAdmin =
session?.role === 'admin' ||
@@ -87,6 +88,69 @@ export default function AdminPanel() {
</div>
)}
/>
<ReplaySnapshotHealth health={health} />
</section>
);
}
function ReplaySnapshotHealth({ health }) {
if (!health) return null;
const replay = health.replay || { sources: [], readyCount: 0, totalCount: 0 };
const snapshots = health.snapshots || { rovers: [], rooms: [] };
const replaySummary = `${replay.readyCount}/${replay.totalCount} sources ready`;
const roverStale = snapshots.rovers.filter((entry) => entry.stale).length;
const roomStale = snapshots.rooms.filter((entry) => entry.stale).length;
return (
<div className="space-y-0.5">
<div className="panel-muted text-xs uppercase">Health</div>
<div className="surface space-y-0.5 text-xs text-slate-200">
<div className="flex items-center justify-between">
<span>Replay segments</span>
<span className="text-slate-400">{replaySummary}</span>
</div>
<div className="flex items-center justify-between">
<span>Rover snapshots</span>
<span className={roverStale ? 'text-amber-300' : 'text-emerald-300'}>
{snapshots.rovers.length - roverStale}/{snapshots.rovers.length} ok
</span>
</div>
<div className="flex items-center justify-between">
<span>Room cameras</span>
<span className={roomStale ? 'text-amber-300' : 'text-emerald-300'}>
{snapshots.rooms.length - roomStale}/{snapshots.rooms.length} ok
</span>
</div>
</div>
<div className="space-y-0.25 text-xs text-slate-300">
{replay.sources.map((source) => (
<div key={`${source.type}:${source.id}`} className="flex items-center justify-between">
<span>{source.label}</span>
<span className={source.ready ? 'text-emerald-300' : 'text-amber-300'}>
{source.recentCount}/{source.neededCount}
</span>
</div>
))}
</div>
<div className="space-y-0.25 text-xs text-slate-300">
{snapshots.rovers.map((entry) => (
<div key={`rover:${entry.id}`} className="flex items-center justify-between">
<span>{entry.name}</span>
<span className={entry.stale ? 'text-amber-300' : 'text-emerald-300'}>
{entry.stale ? 'stale' : 'ok'}
</span>
</div>
))}
</div>
<div className="space-y-0.25 text-xs text-slate-300">
{snapshots.rooms.map((entry) => (
<div key={`room:${entry.id}`} className="flex items-center justify-between">
<span>{entry.name}</span>
<span className={entry.stale ? 'text-amber-300' : 'text-emerald-300'}>
{entry.error ? 'error' : entry.stale ? 'stale' : 'ok'}
</span>
</div>
))}
</div>
</div>
);
}