v0.21.0:0 - matrix-bridge bot tile (status, update, restart, logs)
This commit is contained in:
+142
-2
@@ -13,6 +13,7 @@ const state = {
|
||||
swap_progress: 0, // 0–1
|
||||
services: {},
|
||||
service_action_in_flight: null, // e.g. "parakeet:restart"
|
||||
mb_update_in_flight: false, // matrix-bridge update job running
|
||||
hardware: {},
|
||||
config: {},
|
||||
configured: true,
|
||||
@@ -438,8 +439,13 @@ function classifyService(s) {
|
||||
if (s.docker_state === 'missing') return 'missing';
|
||||
if (s.docker_state === 'restarting') return 'unhealthy';
|
||||
if (s.docker_state === 'exited') return 'unhealthy';
|
||||
if (s.docker_state === 'running' && !s.http_ready) return 'starting';
|
||||
if (s.docker_state === 'running' && s.http_ready) return 'running';
|
||||
if (s.docker_state === 'running') {
|
||||
// http_ready === false means an HTTP probe is expected but failing → still
|
||||
// warming up. null means the service has no HTTP surface (e.g. the bot), so
|
||||
// a running container is simply healthy.
|
||||
if (s.http_ready === false) return 'starting';
|
||||
return 'running';
|
||||
}
|
||||
return s.docker_state || 'unknown';
|
||||
}
|
||||
|
||||
@@ -471,6 +477,11 @@ async function renderServices() {
|
||||
grid.innerHTML = '';
|
||||
for (const [name, s] of entries) {
|
||||
const cls = classifyService(s);
|
||||
const isBot = s.kind === 'bot';
|
||||
// The bot tile is opt-in: it only belongs to deployments that actually run
|
||||
// matrix-bridge. When the container is absent (missing) or the host isn't
|
||||
// configured, hide the tile entirely rather than show a stray red card.
|
||||
if (isBot && (cls === 'missing' || cls === 'unconfigured')) continue;
|
||||
const card = document.createElement('div');
|
||||
card.className = `service-card ${cls}`;
|
||||
const inFlight = state.service_action_in_flight && state.service_action_in_flight.startsWith(name + ':');
|
||||
@@ -537,9 +548,11 @@ async function renderServices() {
|
||||
${restartsRow}
|
||||
${deepRow}
|
||||
<div class="service-actions">
|
||||
${isBot ? `<button class="btn primary" data-mb-update title="Pull latest code, rebuild, and recreate the bot" ${inFlight || state.mb_update_in_flight ? 'disabled' : ''}>Update</button>` : ''}
|
||||
<button class="btn" data-svc-action="${name}:start" ${disable('start') ? 'disabled' : ''}>Start</button>
|
||||
<button class="btn" data-svc-action="${name}:restart" ${disable('restart') ? 'disabled' : ''}>Restart</button>
|
||||
<button class="btn danger" data-svc-action="${name}:stop" ${disable('stop') ? 'disabled' : ''}>Stop</button>
|
||||
${isBot ? `<button class="btn" data-mb-logs title="Show the last 100 log lines">View logs</button>` : ''}
|
||||
</div>
|
||||
`;
|
||||
grid.appendChild(card);
|
||||
@@ -547,6 +560,10 @@ async function renderServices() {
|
||||
for (const btn of grid.querySelectorAll('.btn[data-svc-action]')) {
|
||||
btn.addEventListener('click', () => onServiceAction(btn.dataset.svcAction));
|
||||
}
|
||||
const mbUpdateBtn = grid.querySelector('[data-mb-update]');
|
||||
if (mbUpdateBtn) mbUpdateBtn.addEventListener('click', onMatrixBridgeUpdate);
|
||||
const mbLogsBtn = grid.querySelector('[data-mb-logs]');
|
||||
if (mbLogsBtn) mbLogsBtn.addEventListener('click', openMatrixBridgeLogs);
|
||||
for (const btn of grid.querySelectorAll('[data-dh-run]')) {
|
||||
btn.addEventListener('click', () => onDeepHealthRun(btn.dataset.dhRun, btn));
|
||||
}
|
||||
@@ -725,6 +742,118 @@ async function onServiceAction(key) {
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== matrix-bridge bot (update + logs) =====================
|
||||
|
||||
const mbState = { job_id: null, eventsource: null, timer: null, started_at: null };
|
||||
|
||||
function mbTimerStart(at) {
|
||||
mbState.started_at = at;
|
||||
if (mbState.timer) clearInterval(mbState.timer);
|
||||
const tick = () => {
|
||||
if (!mbState.started_at) return;
|
||||
const sec = Math.max(0, Math.floor((Date.now() - mbState.started_at) / 1000));
|
||||
el('#mb-update-elapsed').textContent = `${Math.floor(sec / 60)}:${(sec % 60).toString().padStart(2, '0')}`;
|
||||
};
|
||||
tick();
|
||||
mbState.timer = setInterval(tick, 500);
|
||||
}
|
||||
|
||||
async function onMatrixBridgeUpdate() {
|
||||
if (state.mb_update_in_flight) return;
|
||||
if (!confirm('Update the matrix-bridge bot?\n\nThis pulls the latest code, rebuilds the container image, and recreates the container. The first build after a base-image change can take several minutes. The bot is briefly offline while it restarts.')) return;
|
||||
state.mb_update_in_flight = true;
|
||||
renderServices();
|
||||
try {
|
||||
const r = await fetchJSON('/api/matrix-bridge/update', { method: 'POST' });
|
||||
attachMbUpdateProgress(r.job_id);
|
||||
} catch (e) {
|
||||
state.mb_update_in_flight = false;
|
||||
renderServices();
|
||||
alert('Update failed to start: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function attachMbUpdateProgress(jobId) {
|
||||
mbState.job_id = jobId;
|
||||
el('#mb-update-log').textContent = '';
|
||||
el('#mb-update-title').textContent = 'Updating matrix-bridge…';
|
||||
el('#mb-update-phase').textContent = 'Starting…';
|
||||
el('#mb-update-dialog').showModal();
|
||||
try {
|
||||
const snap = await fetchJSON(`/api/matrix-bridge/update/${jobId}`);
|
||||
mbTimerStart(Date.parse(snap.started_at));
|
||||
el('#mb-update-phase').textContent = snap.phase || 'Working…';
|
||||
el('#mb-update-log').textContent = (snap.lines || []).join('\n');
|
||||
if (snap.returncode !== null) { onMbUpdateDone(snap); return; }
|
||||
} catch { mbTimerStart(Date.now()); }
|
||||
const es = new EventSource(`/api/matrix-bridge/update/${jobId}/stream`);
|
||||
mbState.eventsource = es;
|
||||
es.onmessage = ev => {
|
||||
try {
|
||||
const d = JSON.parse(ev.data);
|
||||
if (d.line !== undefined) {
|
||||
const log = el('#mb-update-log');
|
||||
log.textContent += d.line + '\n';
|
||||
log.scrollTop = log.scrollHeight;
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
es.addEventListener('phase', ev => {
|
||||
try { el('#mb-update-phase').textContent = JSON.parse(ev.data).phase; } catch {}
|
||||
});
|
||||
es.addEventListener('done', ev => {
|
||||
let d = {}; try { d = JSON.parse(ev.data); } catch {}
|
||||
onMbUpdateDone(d);
|
||||
});
|
||||
es.onerror = () => {
|
||||
// Don't leave the Update button wedged-disabled on a dropped stream. The
|
||||
// job keeps running server-side; re-clicking Update returns a clean 409.
|
||||
es.close();
|
||||
mbState.eventsource = null;
|
||||
state.mb_update_in_flight = false;
|
||||
el('#mb-update-phase').textContent = 'Lost connection to the update stream — reopen or check logs.';
|
||||
renderServices();
|
||||
};
|
||||
}
|
||||
|
||||
function onMbUpdateDone(d) {
|
||||
if (mbState.eventsource) { mbState.eventsource.close(); mbState.eventsource = null; }
|
||||
if (mbState.timer) { clearInterval(mbState.timer); mbState.timer = null; }
|
||||
state.mb_update_in_flight = false;
|
||||
if (d.state === 'failed') {
|
||||
el('#mb-update-title').textContent = `Update failed (rc=${d.returncode})`;
|
||||
el('#mb-update-phase').textContent = 'Failed — see the log above.';
|
||||
} else {
|
||||
el('#mb-update-title').textContent = 'Update complete';
|
||||
el('#mb-update-phase').textContent = 'Done ✓';
|
||||
}
|
||||
// Refresh the tile's badge.
|
||||
(async () => { try { state.services = await fetchJSON('/api/services'); } catch {} renderServices(); })();
|
||||
}
|
||||
|
||||
async function openMatrixBridgeLogs() {
|
||||
const pre = el('#mb-logs-pre');
|
||||
el('#mb-logs-title').textContent = 'matrix-bridge logs';
|
||||
pre.textContent = 'Loading…';
|
||||
el('#mb-logs-dialog').showModal();
|
||||
await loadMatrixBridgeLogs();
|
||||
}
|
||||
|
||||
async function loadMatrixBridgeLogs() {
|
||||
const pre = el('#mb-logs-pre');
|
||||
const btn = el('#mb-logs-refresh');
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
const r = await fetchJSON('/api/matrix-bridge/logs?tail=100');
|
||||
pre.textContent = r.output || '(no output)';
|
||||
pre.scrollTop = pre.scrollHeight;
|
||||
} catch (e) {
|
||||
pre.textContent = 'Could not read logs: ' + e.message;
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function renderEndpoint(status) {
|
||||
const v = status.vllm || {};
|
||||
const panel = el('#endpoint-panel');
|
||||
@@ -1883,6 +2012,17 @@ async function init() {
|
||||
el('#nim-cancel').addEventListener('click', () => el('#nim-dialog').close());
|
||||
el('#nim-form').addEventListener('submit', submitNim);
|
||||
el('#nim-prog-close').addEventListener('click', () => el('#nim-progress-dialog').close());
|
||||
el('#mb-update-close').addEventListener('click', () => el('#mb-update-dialog').close());
|
||||
// Dismissing the modal (Close or Esc) stops streaming; the job runs on
|
||||
// server-side and re-clicking Update returns a 409 if still in progress.
|
||||
el('#mb-update-dialog').addEventListener('close', () => {
|
||||
if (mbState.eventsource) { mbState.eventsource.close(); mbState.eventsource = null; }
|
||||
if (mbState.timer) { clearInterval(mbState.timer); mbState.timer = null; }
|
||||
state.mb_update_in_flight = false;
|
||||
renderServices();
|
||||
});
|
||||
el('#mb-logs-close').addEventListener('click', () => el('#mb-logs-dialog').close());
|
||||
el('#mb-logs-refresh').addEventListener('click', loadMatrixBridgeLogs);
|
||||
el('#open-connectivity').addEventListener('click', openConnectivityDialog);
|
||||
el('#connectivity-close').addEventListener('click', () => el('#connectivity-dialog').close());
|
||||
// Hardware-card buttons (Wake-on-LAN on unreachable cards; SSH-key copy on
|
||||
|
||||
Reference in New Issue
Block a user