mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
health service and panel
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -11,7 +11,7 @@
|
|||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
||||||
<title>Multi Roomba Rover</title>
|
<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">
|
<link rel="stylesheet" crossorigin href="/assets/index-DPKQJjsh.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -295,14 +295,18 @@ function resolveReplaySources(query) {
|
|||||||
return { sources };
|
return { sources };
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildReplayCaption(requester, sources = []) {
|
function buildReplayCaption(requester, sources = [], missingSources = []) {
|
||||||
const requesterLabel = requester || 'unknown';
|
const requesterLabel = requester || 'unknown';
|
||||||
const sourceLabel = sources.length
|
const sourceLabel = sources.length
|
||||||
? `Sources: ${sources.map((source) => source.label || `${source.type}:${source.id}`).join(', ')}.`
|
? `Sources: ${sources.map((source) => source.label || `${source.type}:${source.id}`).join(', ')}.`
|
||||||
: 'No sources.';
|
: 'No sources.';
|
||||||
|
const missingLabel = missingSources.length
|
||||||
|
? `Missing: ${missingSources.map((source) => source.label || `${source.type}:${source.id}`).join(', ')}.`
|
||||||
|
: null;
|
||||||
return [
|
return [
|
||||||
`Replay requested by ${requesterLabel}.`,
|
`Replay requested by ${requesterLabel}.`,
|
||||||
sourceLabel,
|
sourceLabel,
|
||||||
|
missingLabel,
|
||||||
buildDriverCaption(),
|
buildDriverCaption(),
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
@@ -313,9 +317,9 @@ async function sendReplayToChannel(channelId, requester, sources = []) {
|
|||||||
if (!channelId) {
|
if (!channelId) {
|
||||||
throw new Error('Replay channel not configured');
|
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 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: [] });
|
await sendToChannel(channelId, caption, { files: [attachment] }, { parse: [] });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,9 +355,9 @@ async function handleReplayCommand(message, query) {
|
|||||||
const requester =
|
const requester =
|
||||||
message.member?.nickname || message.author?.globalName || message.author?.username || 'Discord';
|
message.member?.nickname || message.author?.globalName || message.author?.username || 'Discord';
|
||||||
try {
|
try {
|
||||||
const buffer = await buildReplayVideo({ sources });
|
const { buffer, usedSources, missingSources } = await buildReplayVideo({ sources });
|
||||||
const attachment = new AttachmentBuilder(buffer, { name: 'replay.mp4' });
|
const attachment = new AttachmentBuilder(buffer, { name: 'replay.mp4' });
|
||||||
const caption = buildReplayCaption(requester, sources);
|
const caption = buildReplayCaption(requester, usedSources, missingSources);
|
||||||
await message.reply({
|
await message.reply({
|
||||||
content: sanitizeMentions(caption),
|
content: sanitizeMentions(caption),
|
||||||
files: [attachment],
|
files: [attachment],
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -87,22 +87,29 @@ async function buildReplayVideo({ sources = [] } = {}) {
|
|||||||
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'rover-replay-'));
|
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'rover-replay-'));
|
||||||
try {
|
try {
|
||||||
const clipPaths = [];
|
const clipPaths = [];
|
||||||
|
const usedSources = [];
|
||||||
|
const missingSources = [];
|
||||||
for (let i = 0; i < sources.length; i += 1) {
|
for (let i = 0; i < sources.length; i += 1) {
|
||||||
const source = sources[i];
|
const source = sources[i];
|
||||||
const key = `${source.type}__${source.id}`;
|
const key = `${source.type}__${source.id}`;
|
||||||
let segmentPaths;
|
let segmentPaths;
|
||||||
try {
|
try {
|
||||||
segmentPaths = await listLatestSegments(key, segmentCount);
|
segmentPaths = await listLatestSegments(key, segmentCount);
|
||||||
} catch {
|
} catch (err) {
|
||||||
throw new Error(`Missing replay segments for ${source.type}:${source.id}`);
|
missingSources.push({ ...source, reason: err.message || 'missing segments' });
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
if (segmentPaths.length < segmentCount) {
|
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');
|
const concatBody = segmentPaths.map((file) => `file '${file}'`).join('\n');
|
||||||
await fsp.writeFile(concatPath, concatBody);
|
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', [
|
await execFileAsync('ffmpeg', [
|
||||||
'-hide_banner',
|
'-hide_banner',
|
||||||
'-loglevel',
|
'-loglevel',
|
||||||
@@ -118,6 +125,15 @@ async function buildReplayVideo({ sources = [] } = {}) {
|
|||||||
clipPath,
|
clipPath,
|
||||||
]);
|
]);
|
||||||
clipPaths.push(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);
|
const { maxWidth, maxHeight } = await probeMaxFrameSize(clipPaths);
|
||||||
@@ -188,7 +204,7 @@ async function buildReplayVideo({ sources = [] } = {}) {
|
|||||||
outPath,
|
outPath,
|
||||||
]);
|
]);
|
||||||
const buffer = await fsp.readFile(outPath);
|
const buffer = await fsp.readFile(outPath);
|
||||||
return buffer;
|
return { buffer, usedSources, missingSources };
|
||||||
} finally {
|
} finally {
|
||||||
try {
|
try {
|
||||||
await fsp.rm(tmpDir, { recursive: true, force: true });
|
await fsp.rm(tmpDir, { recursive: true, force: true });
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const { getState: getHomeAssistantState, homeAssistantEvents } = require('./home
|
|||||||
const { getNickname, nicknameEvents } = require('./nicknameService');
|
const { getNickname, nicknameEvents } = require('./nicknameService');
|
||||||
const { getReplayState, replayEvents } = require('./replayService');
|
const { getReplayState, replayEvents } = require('./replayService');
|
||||||
const { getReplaySources } = require('./replaySourceService');
|
const { getReplaySources } = require('./replaySourceService');
|
||||||
|
const { getHealthSnapshot } = require('./healthService');
|
||||||
const { loadConfig } = require('../helpers/configLoader');
|
const { loadConfig } = require('../helpers/configLoader');
|
||||||
|
|
||||||
const discordInvite = loadConfig().discord?.invite || null;
|
const discordInvite = loadConfig().discord?.invite || null;
|
||||||
@@ -51,6 +52,7 @@ function buildSession(socket) {
|
|||||||
homeAssistant: getHomeAssistantState(),
|
homeAssistant: getHomeAssistantState(),
|
||||||
replay: getReplayState(),
|
replay: getReplayState(),
|
||||||
replaySources: getReplaySources(),
|
replaySources: getReplaySources(),
|
||||||
|
health: getHealthSnapshot(),
|
||||||
users,
|
users,
|
||||||
discord: {
|
discord: {
|
||||||
invite: discordInvite,
|
invite: discordInvite,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export default function AdminPanel() {
|
|||||||
const { session, lockRover, setMode, requestControl } = useSession();
|
const { session, lockRover, setMode, requestControl } = useSession();
|
||||||
const roster = useMemo(() => session?.roster ?? [], [session?.roster]);
|
const roster = useMemo(() => session?.roster ?? [], [session?.roster]);
|
||||||
const [lockStates, setLockStates] = useState({});
|
const [lockStates, setLockStates] = useState({});
|
||||||
|
const health = session?.health || null;
|
||||||
|
|
||||||
const isAdmin =
|
const isAdmin =
|
||||||
session?.role === 'admin' ||
|
session?.role === 'admin' ||
|
||||||
@@ -87,6 +88,69 @@ export default function AdminPanel() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<ReplaySnapshotHealth health={health} />
|
||||||
</section>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user