summaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-22 18:26:22 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-22 18:26:22 +0200
commit8dc11dc05a35a5d64ba4d2c892ccc01c7bfae3da (patch)
treee172bb4d596bde53c48ce2ca3ef2d59c98968147 /packages
parentb4baa4770d517ea7d1d25bb0ac50fc7c989531d6 (diff)
downloadmeshbay-8dc11dc05a35a5d64ba4d2c892ccc01c7bfae3da.tar.gz
feat(node): systemd service panel on the Node page, and a clean CLI restart
Add a status panel at the top of the Node page — always visible, even before an MNP connection exists — showing the meshbay-node systemd unit's own state (via `systemctl --user show`, main process only) with Start/Stop/Restart controls. This is the piece the rest of the page cannot provide: it has to work while the daemon is stopped or crash- looping, which the MNP-based sections require the daemon to already answer. While touching node lifecycle: `reload` and `restart-daemon` in the CLI shelled out to pgrep + SIGTERM/SIGHUP and respawned the process by hand, logging to a hardcoded /tmp path. That pattern already SIGHUPed a developer's own running node by accident once (see the old test_cli_dispatch.py comment). Both now delegate to `systemctl --user reload|restart meshbay-node`, which the unit already supports correctly (ExecReload=, Restart=on-failure). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016SF6RKNBKg9qejmoMJ9ybA
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-client/src/main.js57
-rw-r--r--packages/meshbay-client/src/preload.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js90
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js14
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js14
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js14
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js14
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js14
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js14
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js14
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js14
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js14
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js14
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/platform.js22
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css21
-rw-r--r--packages/meshbay-node/src/meshbay_node/daemon.py113
-rw-r--r--packages/meshbay-node/tests/test_cli_dispatch.py60
17 files changed, 438 insertions, 72 deletions
diff --git a/packages/meshbay-client/src/main.js b/packages/meshbay-client/src/main.js
index 24c1b6a..2bdf550 100644
--- a/packages/meshbay-client/src/main.js
+++ b/packages/meshbay-client/src/main.js
@@ -738,6 +738,63 @@ function registerBridge() {
return { installed: Boolean(bin) };
});
+ // The systemd unit's own view of the node, for the status panel at the top
+ // of the Node page. Deliberately not `probeNode()`: that asks the daemon's
+ // own HTTP API, which cannot answer while the daemon is stopped or crash-
+ // looping — exactly the states this panel exists to show and act on.
+ ipcMain.handle('node:service-status', async () => {
+ if (process.platform !== 'linux') return { supported: false };
+ return new Promise((resolve) => {
+ execFile('systemctl', ['--user', 'show', 'meshbay-node.service',
+ '--property=LoadState,ActiveState,SubState'], (err, stdout) => {
+ if (err) {
+ resolve({ supported: true, installed: false, activeState: 'unknown',
+ subState: '' });
+ return;
+ }
+ const props = {};
+ for (const line of stdout.split('\n')) {
+ const i = line.indexOf('=');
+ if (i > 0) props[line.slice(0, i)] = line.slice(i + 1);
+ }
+ resolve({
+ supported: true,
+ installed: props.LoadState === 'loaded',
+ activeState: props.ActiveState || 'unknown',
+ subState: props.SubState || '',
+ });
+ });
+ });
+ });
+
+ ipcMain.handle('node:service-stop', async () => {
+ if (process.platform !== 'linux') {
+ throw new Error('Service control is only supported on Linux');
+ }
+ await new Promise((resolve, reject) => {
+ execFile('systemctl', ['--user', 'stop', 'meshbay-node'],
+ (err, _stdout, stderr) => {
+ if (err) return reject(new Error(stderr.trim() || err.message));
+ resolve();
+ });
+ });
+ return { stopped: true };
+ });
+
+ ipcMain.handle('node:service-restart', async () => {
+ if (process.platform !== 'linux') {
+ throw new Error('Service control is only supported on Linux');
+ }
+ await new Promise((resolve, reject) => {
+ execFile('systemctl', ['--user', 'restart', 'meshbay-node'],
+ (err, _stdout, stderr) => {
+ if (err) return reject(new Error(stderr.trim() || err.message));
+ resolve();
+ });
+ });
+ return { restarted: true };
+ });
+
async function probeNode() {
const nc = readNodeConfig();
const dataDir = nc ? nc.dataDir
diff --git a/packages/meshbay-client/src/preload.js b/packages/meshbay-client/src/preload.js
index f240995..7ffdf66 100644
--- a/packages/meshbay-client/src/preload.js
+++ b/packages/meshbay-client/src/preload.js
@@ -91,6 +91,13 @@ contextBridge.exposeInMainWorld('meshbay', {
call: (method, path, body) => ipcRenderer.invoke('node:call', method, path, body),
pairingCode: () => ipcRenderer.invoke('node:pairing-code'),
setPairingCode: (code) => ipcRenderer.invoke('node:set-pairing-code', code),
+ // The systemd unit's own state — reachable even while the daemon itself
+ // is stopped or crash-looping, which `call()` above is not.
+ service: {
+ status: () => ipcRenderer.invoke('node:service-status'),
+ stop: () => ipcRenderer.invoke('node:service-stop'),
+ restart: () => ipcRenderer.invoke('node:service-restart'),
+ },
},
// LAN cast relay. The main process runs a local HTTP server and the
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index b863460..36df0cd 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -5633,6 +5633,90 @@ function BlocklistForm({ onAdd }) {
// ── Node management (D5) ────────────────────────────────────────────────────
+/**
+ * The systemd unit's own state, independent of the MNP connection below it.
+ *
+ * The rest of NodePage talks to the daemon over a signed MNP session, which
+ * requires the daemon to already be up and answering — no use at all for
+ * "the node is stopped, start it" or "it is crash-looping, tell me". This
+ * asks systemd directly (main process → `systemctl --user`), the same way
+ * `node:installed` and the wizard's `node:start` already do, so it works
+ * from every state the connection below can be in.
+ */
+function NodeServicePanel({ onChanged }) {
+ const [info, setInfo] = useState(null);
+ const [busy, setBusy] = useState('');
+ const [err, setErr] = useState('');
+
+ const refresh = useCallback(async () => {
+ try {
+ const r = await platform.node.service.status();
+ setInfo(r);
+ setErr('');
+ } catch (e) {
+ setErr(platform.bridgeMessage(e));
+ }
+ }, []);
+
+ useEffect(() => {
+ if (!platform.node.service.available) return;
+ refresh();
+ const timer = setInterval(refresh, 5000);
+ return () => clearInterval(timer);
+ }, [refresh]);
+
+ const act = useCallback(async (name, fn) => {
+ setBusy(name);
+ setErr('');
+ try {
+ await fn();
+ await refresh();
+ if (onChanged) onChanged();
+ } catch (e) {
+ setErr(platform.bridgeMessage(e));
+ } finally {
+ setBusy('');
+ }
+ }, [refresh, onChanged]);
+
+ if (!platform.node.service.available) return null;
+ if (!info || info.supported === false) {
+ return html`<div class="node-service">
+ <span class="spinner"></span>${' '}${t('node.service_checking')}
+ </div>`;
+ }
+
+ const KNOWN_STATES = ['active', 'inactive', 'failed', 'activating', 'deactivating'];
+ const stateKey = KNOWN_STATES.includes(info.activeState) ? info.activeState : 'unknown';
+ const running = info.activeState === 'active' || info.activeState === 'activating';
+ const dot = info.activeState === 'active' ? 'online'
+ : info.activeState === 'failed' ? 'offline' : 'unknown';
+ const label = info.installed ? t('node.service_state_' + stateKey)
+ : t('node.service_not_installed');
+
+ return html`
+ <div class="node-service">
+ <div class="node-service-status">
+ <span class="presence presence-${dot}" title="${label}" aria-label="${label}"></span>
+ <span>${label}</span>
+ </div>
+ ${err && html`<div class="error-msg">${err}</div>`}
+ <div class="node-service-actions">
+ <button class="btn btn-small btn-secondary" disabled=${!!busy || running}
+ onClick=${() => act('start', () => platform.node.start())}>
+ ${busy === 'start' ? t('node.service_starting') : t('node.service_start')}</button>
+ ${info.installed && html`
+ <button class="btn btn-small btn-secondary" disabled=${!!busy || !running}
+ onClick=${() => act('stop', () => platform.node.service.stop())}>
+ ${busy === 'stop' ? t('node.service_stopping') : t('node.service_stop')}</button>
+ <button class="btn btn-small btn-secondary" disabled=${!!busy}
+ onClick=${() => act('restart', () => platform.node.service.restart())}>
+ ${busy === 'restart' ? t('node.service_restarting') : t('node.service_restart')}</button>
+ `}
+ </div>
+ </div>`;
+}
+
function NodePage({ token, username, userId, groups }) {
const [status, setStatus] = useState('idle');
const [error, setError] = useState('');
@@ -5918,17 +6002,22 @@ function NodePage({ token, username, userId, groups }) {
if (status === 'idle' || status === 'connecting') {
return html`<div class="page-content">
+ <h2>${t('node.title')}</h2>
+ <${NodeServicePanel} onChanged=${connectAndFetch} />
<p class="page-message"><span class="spinner"></span>${' '}${t('status.connecting')}</p>
</div>`;
}
if (status === 'no_groups') {
return html`<div class="page-content">
+ <h2>${t('node.title')}</h2>
+ <${NodeServicePanel} onChanged=${connectAndFetch} />
<p class="page-message">${t('node.no_groups')}</p>
</div>`;
}
if (status === 'error') {
return html`<div class="page-content">
<h2>${t('node.title')}</h2>
+ <${NodeServicePanel} onChanged=${connectAndFetch} />
<p class="error-msg">${error}</p>
<button class="btn btn-primary" onClick=${connectAndFetch}>
${t('node.retry')}</button>
@@ -5943,6 +6032,7 @@ function NodePage({ token, username, userId, groups }) {
onClick=${reloadConfig}>
${t('node.reload')}</button>
</div>
+ <${NodeServicePanel} onChanged=${connectAndFetch} />
${actionMsg && html`<div class="node-message">${actionMsg}</div>`}
${(() => {
const hubIds = new Set((groups || []).map(g => g.id));
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
index 4df7781..1a151e2 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -490,6 +490,20 @@ export default {
// Node admin
'node.title': 'Node',
+ 'node.service_checking': 'Node-Dienst wird überprüft…',
+ 'node.service_not_installed': 'Nicht als Dienst installiert',
+ 'node.service_state_active': 'Wird ausgeführt',
+ 'node.service_state_inactive': 'Angehalten',
+ 'node.service_state_failed': 'Fehlgeschlagen',
+ 'node.service_state_activating': 'Wird gestartet…',
+ 'node.service_state_deactivating': 'Wird angehalten…',
+ 'node.service_state_unknown': 'Unbekannt',
+ 'node.service_start': 'Starten',
+ 'node.service_starting': 'Wird gestartet…',
+ 'node.service_stop': 'Anhalten',
+ 'node.service_stopping': 'Wird angehalten…',
+ 'node.service_restart': 'Neu starten',
+ 'node.service_restarting': 'Wird neu gestartet…',
'node.not_operator': 'Ihr Node konnte nicht erreicht werden. Stellen Sie sicher, dass er läuft.',
'node.offline': 'Node ist offline',
'node.retry': 'Erneut versuchen',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
index 3ddd7f1..dfd400b 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -501,6 +501,20 @@ export default {
// Node management (D5)
'sidebar.node': 'Node',
'node.title': 'Node',
+ 'node.service_checking': 'Checking node service…',
+ 'node.service_not_installed': 'Not installed as a service',
+ 'node.service_state_active': 'Running',
+ 'node.service_state_inactive': 'Stopped',
+ 'node.service_state_failed': 'Failed',
+ 'node.service_state_activating': 'Starting…',
+ 'node.service_state_deactivating': 'Stopping…',
+ 'node.service_state_unknown': 'Unknown',
+ 'node.service_start': 'Start',
+ 'node.service_starting': 'Starting…',
+ 'node.service_stop': 'Stop',
+ 'node.service_stopping': 'Stopping…',
+ 'node.service_restart': 'Restart',
+ 'node.service_restarting': 'Restarting…',
'node.offline': 'Node is offline',
'node.no_groups': 'No groups configured on this node.',
'node.retry': 'Retry',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
index 093b1e7..498fc38 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -485,6 +485,20 @@ export default {
// Node admin
'node.title': 'Node',
+ 'node.service_checking': 'Comprobando el servicio del nodo…',
+ 'node.service_not_installed': 'No instalado como servicio',
+ 'node.service_state_active': 'En ejecución',
+ 'node.service_state_inactive': 'Detenido',
+ 'node.service_state_failed': 'Fallido',
+ 'node.service_state_activating': 'Iniciando…',
+ 'node.service_state_deactivating': 'Deteniendo…',
+ 'node.service_state_unknown': 'Desconocido',
+ 'node.service_start': 'Iniciar',
+ 'node.service_starting': 'Iniciando…',
+ 'node.service_stop': 'Detener',
+ 'node.service_stopping': 'Deteniendo…',
+ 'node.service_restart': 'Reiniciar',
+ 'node.service_restarting': 'Reiniciando…',
'node.not_operator': 'No se pudo contactar con su node. Asegúrese de que esté en ejecución.',
'node.offline': 'Node sin conexión',
'node.retry': 'Reintentar',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
index 43036dd..9e21135 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -489,6 +489,20 @@ export default {
// Node admin
'node.title': 'Node',
+ 'node.service_checking': 'Vérification du service du nœud…',
+ 'node.service_not_installed': 'Non installé en tant que service',
+ 'node.service_state_active': 'En cours d’exécution',
+ 'node.service_state_inactive': 'Arrêté',
+ 'node.service_state_failed': 'Échec',
+ 'node.service_state_activating': 'Démarrage…',
+ 'node.service_state_deactivating': 'Arrêt en cours…',
+ 'node.service_state_unknown': 'Inconnu',
+ 'node.service_start': 'Démarrer',
+ 'node.service_starting': 'Démarrage…',
+ 'node.service_stop': 'Arrêter',
+ 'node.service_stopping': 'Arrêt…',
+ 'node.service_restart': 'Redémarrer',
+ 'node.service_restarting': 'Redémarrage…',
'node.not_operator': 'Impossible de joindre votre node. Vérifiez qu\'il est en cours d\'exécution.',
'node.offline': 'Node hors ligne',
'node.retry': 'Réessayer',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
index af22add..7e2b100 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -487,6 +487,20 @@ export default {
// Node admin
'node.title': 'Node',
+ 'node.service_checking': 'Verifica del servizio del nodo…',
+ 'node.service_not_installed': 'Non installato come servizio',
+ 'node.service_state_active': 'In esecuzione',
+ 'node.service_state_inactive': 'Arrestato',
+ 'node.service_state_failed': 'Non riuscito',
+ 'node.service_state_activating': 'Avvio…',
+ 'node.service_state_deactivating': 'Arresto…',
+ 'node.service_state_unknown': 'Sconosciuto',
+ 'node.service_start': 'Avvia',
+ 'node.service_starting': 'Avvio…',
+ 'node.service_stop': 'Arresta',
+ 'node.service_stopping': 'Arresto…',
+ 'node.service_restart': 'Riavvia',
+ 'node.service_restarting': 'Riavvio…',
'node.not_operator': 'Impossibile raggiungere il suo node. Si assicuri che sia in esecuzione.',
'node.offline': 'Node non in linea',
'node.retry': 'Riprova',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
index 2aa2ab5..ee55fc2 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -475,6 +475,20 @@ export default {
// Node admin
'node.title': 'Node',
+ 'node.service_checking': 'ノードサービスを確認中…',
+ 'node.service_not_installed': 'サービスとしてインストールされていません',
+ 'node.service_state_active': '実行中',
+ 'node.service_state_inactive': '停止',
+ 'node.service_state_failed': '失敗',
+ 'node.service_state_activating': '起動中…',
+ 'node.service_state_deactivating': '停止中…',
+ 'node.service_state_unknown': '不明',
+ 'node.service_start': '開始',
+ 'node.service_starting': '起動中…',
+ 'node.service_stop': '停止',
+ 'node.service_stopping': '停止中…',
+ 'node.service_restart': '再起動',
+ 'node.service_restarting': '再起動中…',
'node.not_operator': 'node に接続できませんでした。node が実行中であることをご確認ください。',
'node.offline': 'Node はオフラインです',
'node.retry': '再試行',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
index 5a9a303..986d653 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -489,6 +489,20 @@ export default {
// Node admin
'node.title': 'Node',
+ 'node.service_checking': 'Node-service controleren…',
+ 'node.service_not_installed': 'Niet als service geïnstalleerd',
+ 'node.service_state_active': 'Actief',
+ 'node.service_state_inactive': 'Gestopt',
+ 'node.service_state_failed': 'Mislukt',
+ 'node.service_state_activating': 'Starten…',
+ 'node.service_state_deactivating': 'Stoppen…',
+ 'node.service_state_unknown': 'Onbekend',
+ 'node.service_start': 'Starten',
+ 'node.service_starting': 'Starten…',
+ 'node.service_stop': 'Stoppen',
+ 'node.service_stopping': 'Stoppen…',
+ 'node.service_restart': 'Herstarten',
+ 'node.service_restarting': 'Herstarten…',
'node.not_operator': 'Uw node is niet bereikbaar. Controleer of hij draait.',
'node.offline': 'Node is offline',
'node.retry': 'Opnieuw proberen',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
index c8c0315..ca3f60c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -502,6 +502,20 @@ export default {
// Node admin
'node.title': 'Node',
+ 'node.service_checking': 'Sprawdzanie usługi węzła…',
+ 'node.service_not_installed': 'Nie zainstalowano jako usługi',
+ 'node.service_state_active': 'Uruchomiona',
+ 'node.service_state_inactive': 'Zatrzymana',
+ 'node.service_state_failed': 'Niepowodzenie',
+ 'node.service_state_activating': 'Uruchamianie…',
+ 'node.service_state_deactivating': 'Zatrzymywanie…',
+ 'node.service_state_unknown': 'Nieznany',
+ 'node.service_start': 'Uruchom',
+ 'node.service_starting': 'Uruchamianie…',
+ 'node.service_stop': 'Zatrzymaj',
+ 'node.service_stopping': 'Zatrzymywanie…',
+ 'node.service_restart': 'Uruchom ponownie',
+ 'node.service_restarting': 'Ponowne uruchamianie…',
'node.not_operator': 'Nie udało się połączyć z Pana/Pani node. Upewnij się, że działa.',
'node.offline': 'Node jest niedostępny',
'node.retry': 'Ponów',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
index 1f7bee2..8231daa 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
@@ -486,6 +486,20 @@ export default {
// Node admin
'node.title': 'Node',
+ 'node.service_checking': 'Verificando o serviço do nó…',
+ 'node.service_not_installed': 'Não instalado como serviço',
+ 'node.service_state_active': 'Em execução',
+ 'node.service_state_inactive': 'Parado',
+ 'node.service_state_failed': 'Falhou',
+ 'node.service_state_activating': 'Iniciando…',
+ 'node.service_state_deactivating': 'Parando…',
+ 'node.service_state_unknown': 'Desconhecido',
+ 'node.service_start': 'Iniciar',
+ 'node.service_starting': 'Iniciando…',
+ 'node.service_stop': 'Parar',
+ 'node.service_stopping': 'Parando…',
+ 'node.service_restart': 'Reiniciar',
+ 'node.service_restarting': 'Reiniciando…',
'node.not_operator': 'Não foi possível alcançar seu node. Verifique se ele está em execução.',
'node.offline': 'Node está off-line',
'node.retry': 'Tentar novamente',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
index 162fadb..48122d3 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
@@ -461,6 +461,20 @@ export default {
// Node admin
'node.title': 'Node',
+ 'node.service_checking': '正在检查节点服务…',
+ 'node.service_not_installed': '未安装为服务',
+ 'node.service_state_active': '运行中',
+ 'node.service_state_inactive': '已停止',
+ 'node.service_state_failed': '失败',
+ 'node.service_state_activating': '正在启动…',
+ 'node.service_state_deactivating': '正在停止…',
+ 'node.service_state_unknown': '未知',
+ 'node.service_start': '启动',
+ 'node.service_starting': '正在启动…',
+ 'node.service_stop': '停止',
+ 'node.service_stopping': '正在停止…',
+ 'node.service_restart': '重启',
+ 'node.service_restarting': '正在重启…',
'node.not_operator': '无法连接到您的 node。请确保它正在运行。',
'node.offline': 'Node 已离线',
'node.retry': '重试',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/platform.js b/packages/meshbay-hub/src/meshbay_hub/static/platform.js
index a8e17bf..30df80e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/platform.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/platform.js
@@ -241,6 +241,28 @@ export const node = {
async setPairingCode(code) {
return bridge && bridge.node ? bridge.node.setPairingCode(code) : false;
},
+ /**
+ * The systemd unit's own state — start, stop and restart the node as a
+ * service, independent of whether the daemon itself is answering. Absent
+ * in a browser, same as the rest of `node`.
+ */
+ service: {
+ available: Boolean(bridge && bridge.node && bridge.node.service),
+ async status() {
+ if (!bridge || !bridge.node || !bridge.node.service) return { supported: false };
+ return bridge.node.service.status();
+ },
+ async stop() {
+ if (!bridge || !bridge.node || !bridge.node.service)
+ throw new Error('Node bridge not available');
+ return bridge.node.service.stop();
+ },
+ async restart() {
+ if (!bridge || !bridge.node || !bridge.node.service)
+ throw new Error('Node bridge not available');
+ return bridge.node.service.restart();
+ },
+ },
};
/**
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
index ee51a8a..20b9f8c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -2181,6 +2181,27 @@ a.transfer-name {
}
.node-header h2 { margin-bottom: 0; }
+.node-service {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ gap: 10px;
+ padding: 10px 12px;
+ margin-bottom: 16px;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: var(--bg-raised);
+}
+.node-service-status {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 0.9em;
+}
+.node-service-actions { display: flex; gap: 8px; }
+.node-service .error-msg { flex-basis: 100%; margin: 0; }
+
.node-section { margin-top: 16px; }
.node-section-header {
display: flex;
diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py
index 5fc70fe..b34e710 100644
--- a/packages/meshbay-node/src/meshbay_node/daemon.py
+++ b/packages/meshbay-node/src/meshbay_node/daemon.py
@@ -888,6 +888,32 @@ def _resolve_group(cfg: Config, group: str | None) -> str:
sys.exit(1)
+def _systemctl_user(verb: str, unit: str, *, not_running_hint: str,
+ success: str, watch: str | None) -> None:
+ """
+ Run `systemctl --user <verb> <unit>` and report the result.
+
+ The lifecycle authority is the unit, not this process: systemd already
+ knows which PID it started, restarts it on failure (`Restart=on-failure`
+ in the unit) and reloads it correctly (`ExecReload=`). Anything this CLI
+ did instead — finding a process by pattern-matching its command line,
+ signalling it, respawning it — is a second, worse implementation of what
+ systemd is already doing, and pattern-matching a process list has already
+ hit a real developer's real running node by accident.
+ """
+ import subprocess
+
+ result = subprocess.run(["systemctl", "--user", verb, unit],
+ capture_output=True, text=True)
+ if result.returncode != 0:
+ detail = (result.stderr or result.stdout).strip()
+ print(detail or not_running_hint)
+ sys.exit(1)
+ print(success)
+ if watch:
+ print(watch)
+
+
# ── Entry point ───────────────────────────────────────────────────────────────
def main() -> None:
@@ -904,7 +930,8 @@ def main() -> None:
"browser with this node | member list|invite|revoke|unpin "
"| group list|add|remove | gek init|rotate | file list|rm "
"| denylist show|clear | reload: re-read node.toml "
- "| restart-daemon: full stop + start "
+ "(systemctl --user reload) | restart-daemon: restart "
+ "the systemd unit (systemctl --user restart) "
"| calibrate-argon2: benchmark")
parser.add_argument("subcommand", nargs="?",
help="'pair' for operator; list|invite|revoke|unpin for "
@@ -1159,69 +1186,33 @@ def main() -> None:
if args.command == "reload":
# Milestone 14.8. The daemon re-reads node.toml; groups that appeared or
# whose roots changed are picked up without dropping live connections.
- cfg = load_config(args.config or DEFAULT_CONFIG_PATH)
- import os as _os
- import signal as _signal
- import subprocess as _subprocess
- # `--` is pgrep's own end-of-options marker and must be its own argument;
- # folded into the pattern it searches for a process literally called
- # "-- -m …". Anchored to the end of the command line so it matches the
- # daemon and never a shell that merely mentions it — the same trap
- # deploy-node.sh documents, where an unanchored pattern kills the script.
- pid_out = _subprocess.run(
- ["pgrep", "-f", "--", r"-m meshbay_node\.daemon$"],
- capture_output=True, text=True)
- pids = [int(x) for x in pid_out.stdout.split()]
- if not pids:
- print("Node is not running — start it with: meshbay-node")
- sys.exit(1)
- for pid in pids:
- _os.kill(pid, _signal.SIGHUP)
- print(f"sent SIGHUP to {len(pids)} daemon process(es)")
- print("watch the result: tail -f /tmp/meshbay-node.log")
+ #
+ # Delegated to systemd rather than hunting a PID with pgrep and signalling
+ # it directly: an unanchored (or merely unlucky) pattern match there has
+ # already SIGHUPed a developer's own running node by accident — see the
+ # comment this replaced, and test_cli_dispatch.py's stub_daemon fixture,
+ # which had to stub os.kill for exactly that reason. The unit already
+ # declares `ExecReload=/bin/kill -HUP $MAINPID`, so systemd sends the
+ # signal to the one process it actually started.
+ _systemctl_user(
+ "reload", "meshbay-node",
+ not_running_hint="Node is not running as a systemd unit — start it "
+ "with: systemctl --user start meshbay-node",
+ success="sent reload to meshbay-node",
+ watch="watch the result: journalctl --user -u meshbay-node -f")
return
if args.command == "restart-daemon":
- import os as _os
- import signal as _signal
- import subprocess as _subprocess
- cfg = load_config(args.config or DEFAULT_CONFIG_PATH)
- pid_out = _subprocess.run(
- ["pgrep", "-f", "--", r"-m meshbay_node\.daemon$"],
- capture_output=True, text=True)
- pids = [int(x) for x in pid_out.stdout.split()]
- if pids:
- for pid in pids:
- _os.kill(pid, _signal.SIGTERM)
- print(f"stopped {len(pids)} daemon process(es)")
- for pid in pids:
- try:
- _os.waitpid(pid, 0)
- except ChildProcessError:
- import time as _time
- _time.sleep(2)
- else:
- print("no running daemon found — starting fresh")
- log_path = "/tmp/meshbay-node.log"
- config_flag = ["--config", str(args.config)] if args.config else []
- _subprocess.Popen(
- [sys.executable, "-m", "meshbay_node.daemon"] + config_flag,
- stdout=open(log_path, "a"),
- stderr=_subprocess.STDOUT,
- start_new_session=True,
- )
- import time as _time
- _time.sleep(3)
- pid_out2 = _subprocess.run(
- ["pgrep", "-f", "--", r"-m meshbay_node\.daemon$"],
- capture_output=True, text=True)
- new_pids = [int(x) for x in pid_out2.stdout.split()]
- if new_pids:
- print(f"daemon started (PID {new_pids[0]})")
- print(f"log: tail -f {log_path}")
- else:
- print(f"daemon may have failed to start — check {log_path}")
- sys.exit(1)
+ # Same reasoning as reload: no PID hunting, no manual respawn — systemd
+ # already knows how to stop and start this unit, and does not need this
+ # process to guess where its log file is.
+ _systemctl_user(
+ "restart", "meshbay-node",
+ not_running_hint="meshbay-node is not installed as a systemd unit — "
+ "see packaging/systemd/",
+ success="meshbay-node restarted via systemd",
+ watch="check status: systemctl --user status meshbay-node\n"
+ "watch logs: journalctl --user -u meshbay-node -f")
return
if args.command == "denylist":
diff --git a/packages/meshbay-node/tests/test_cli_dispatch.py b/packages/meshbay-node/tests/test_cli_dispatch.py
index fdcd82e..d9edb17 100644
--- a/packages/meshbay-node/tests/test_cli_dispatch.py
+++ b/packages/meshbay-node/tests/test_cli_dispatch.py
@@ -77,19 +77,21 @@ def stub_daemon(monkeypatch, tmp_path):
import getpass
monkeypatch.setattr(getpass, "getpass", lambda *a, **kw: "test-password")
- # `reload` looks for a real daemon and signals it. Without this the test
- # SIGHUPs whatever node happens to be running on the machine — which it did,
- # once, before this was added. A test must not reach outside itself.
- import os
+ # `reload` and `restart-daemon` shell out to `systemctl --user`. Without
+ # this the test would run that against whatever session bus is actually
+ # available — a test must not reach outside itself, which is exactly what
+ # the pgrep/os.kill version of this fixture existed to prevent before
+ # those commands were rewritten to delegate to systemd.
import subprocess
- signalled: list[int] = []
- monkeypatch.setattr(
- subprocess, "run",
- lambda *a, **k: subprocess.CompletedProcess(a[0], 0, stdout="4242\n",
- stderr=""))
- monkeypatch.setattr(os, "kill", lambda pid, sig: signalled.append(pid))
- calls.append(("_signalled", signalled))
+ systemctl_calls: list[list[str]] = []
+
+ def fake_run(argv, **kw):
+ systemctl_calls.append(argv)
+ return subprocess.CompletedProcess(argv, 0, stdout="", stderr="")
+
+ monkeypatch.setattr(subprocess, "run", fake_run)
+ calls.append(("_systemctl_calls", systemctl_calls))
return calls
@@ -130,3 +132,39 @@ def test_the_verb_list_here_matches_the_parser():
assert not untested, (
f"CLI verbs with no dispatch test: {sorted(untested)} — add them to "
f"VERBS above")
+
+
+@pytest.mark.parametrize("argv,verb", [
+ (["reload"], "reload"),
+ (["restart-daemon"], "restart"),
+])
+def test_lifecycle_commands_delegate_to_systemctl_user(
+ argv, verb, stub_daemon, monkeypatch, capsys):
+ """
+ `reload` and `restart-daemon` must ask systemd to do it, not hunt a PID
+ with pgrep and signal it directly — that pattern-matched a developer's own
+ running node by accident once, which is why it was replaced.
+ """
+ monkeypatch.setattr(sys, "argv", ["meshbay-node", *argv])
+ daemon_mod.main()
+
+ systemctl_calls = dict(stub_daemon)["_systemctl_calls"]
+ assert systemctl_calls == [["systemctl", "--user", verb, "meshbay-node"]]
+ assert not capsys.readouterr().err
+
+
+@pytest.mark.parametrize("argv", [["reload"], ["restart-daemon"]])
+def test_lifecycle_commands_report_systemctl_failure(
+ argv, stub_daemon, monkeypatch, capsys):
+ """A unit that refuses (not installed, not running) must exit non-zero."""
+ import subprocess
+
+ monkeypatch.setattr(
+ subprocess, "run",
+ lambda a, **k: subprocess.CompletedProcess(
+ a, 1, stdout="", stderr="Unit meshbay-node.service not loaded.\n"))
+ monkeypatch.setattr(sys, "argv", ["meshbay-node", *argv])
+ with pytest.raises(SystemExit) as exc:
+ daemon_mod.main()
+ assert exc.value.code == 1
+ assert "not loaded" in capsys.readouterr().out