feat(remote): host-unreachable banner, manual wake, and native MAC wake-on-LAN

The reactive wake (typing into a session whose host slept) left the state invisible:
nothing told the user the machine was asleep, and with no wake target configured
there was nothing to do about it. Adds:

- RemoteHost.wakeMac (comma-separated) - Codeman builds and broadcasts the magic
  packet itself (UDP port 9), so the common case needs no external script. The
  existing wakeCommand stays as the explicit override.
- GET /api/sessions/:id/reachability - probes (throttled, cached, and it never
  wakes) and reports HOW the host can be woken, or that nothing is configured.
- POST /api/sessions/:id/wake - wakes, waits, reattaches the pane and flushes
  buffered input; 400 with a routable message when no target is configured.
- The amber host-unreachable banner + its 'Wake' / 'Configure WoL' action, and a
  small config dialog that saves via PUT /api/remote-hosts/:id.
- RemoteWakeDeps.resolveRemote: host config is re-resolved for LIVE sessions
  (throttled + cached), so saving the dialog takes effect without a restart.
This commit is contained in:
Randalix
2026-09-15 14:20:31 +02:00
parent 3f0bfde54a
commit 8b5a13435a
17 changed files with 959 additions and 77 deletions
+307
View File
@@ -0,0 +1,307 @@
/**
* @fileoverview Remote-host wake-on-LAN: the "host unreachable" banner + its config dialog.
*
* A sleeping remote host does not fail loudly. The local tmux pane runs `ssh`, and when
* the machine suspends, that ssh child stalls: `tmux send-keys` still SUCCEEDS, so typed
* input disappears with no error and the pane looks alive. The server side
* (`src/remote-wake.ts`) buffers input and wakes the host when the user types; this
* module makes the state VISIBLE and gives it a button, which is what turns "why is
* nothing happening" into one click.
*
* Behavior:
* - Polls `GET /api/sessions/:id/reachability` for the ACTIVE remote session only
* (on tab activation and every `POLL_MS` while the tab is visible). The endpoint
* shares the server's probe cache with the input path, so opening the tab also
* primes the wake path.
* - Unreachable + a configured wake target → "Wake" button → `POST /api/sessions/:id/wake`
* (which wakes, waits, reattaches the pane and flushes buffered input).
* - Unreachable + NO wake target → "Configure WoL" → `#wakeConfigModal`, a small form
* for this host's MAC/command that saves via `PUT /api/remote-hosts/:id`. The server
* re-resolves host config while the session is live, so saving takes effect without
* restarting the session.
* - SSE (`remote:hostWaking`, `remote:hostWakeFailed`, `remote:sessionReconnected`)
* keeps the banner in sync while a wake is running.
*
* @mixin Extends CodemanApp.prototype via Object.assign
*/
const HOST_WAKE_POLL_MS = 30_000;
Object.assign(CodemanApp.prototype, {
/** Per-tab banner state (single active session at a time). */
_hostWake: null,
/** Fresh state for a session we just switched to. */
_hostWakeState() {
return {
sessionId: null,
timer: null,
/** Last reachability answer, or null before the first poll. */
reachable: null,
/** 'command' | 'mac' | 'none' — what the banner action should do. */
wakeConfigured: 'none',
host: '',
label: '',
/** True between clicking Wake and the answer coming back. */
waking: false,
/** Set when the last wake attempt or poll failed. */
error: '',
};
},
/**
* Entry point from the session switcher — called for every active session, remote or
* not, so it must be cheap and must clear the banner for local sessions.
*/
refreshHostWakeBanner(sessionId) {
const state = (this._hostWake = this._hostWakeState());
if (state.timer) clearInterval(state.timer);
state.sessionId = sessionId || null;
const session = sessionId && this.sessions ? this.sessions.get(sessionId) : null;
if (!session || !session.remote) {
this._renderHostWakeBanner();
return;
}
state.host = session.remote.host || '';
state.label = session.remote.label || 'Remote host';
// Text from the session payload first (instant, no round trip), corrected by the
// poll — a session whose wake config was added after launch only knows it after
// the server resolves host config.
state.wakeConfigured = session.remote.wakeMac || session.remote.wakeCommand ? 'mac' : 'none';
this._renderHostWakeBanner();
this._pollHostReachability();
if (state.timer) clearInterval(state.timer);
state.timer = setInterval(() => {
if (document.visibilityState === 'hidden') return;
if (this.activeSessionId !== state.sessionId) return;
this._pollHostReachability();
}, HOST_WAKE_POLL_MS);
},
/** One reachability check for the active remote session. */
async _pollHostReachability(force = false) {
const state = this._hostWake;
if (!state || !state.sessionId) return;
const sessionId = state.sessionId;
try {
const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/reachability${force ? '?force=1' : ''}`);
const data = await res.json();
if (!data.success) return;
// The tab may have changed while this was in flight.
if (this._hostWake !== state || state.sessionId !== sessionId) return;
state.reachable = data.data.reachable !== false;
state.wakeConfigured = data.data.wakeConfigured || 'none';
if (data.data.host) state.host = data.data.host;
if (data.data.label) state.label = data.data.label;
if (state.reachable) {
state.waking = false;
state.error = '';
}
this._renderHostWakeBanner();
} catch {
/* A failed poll is not a state change: leave the banner as it was. */
}
},
/** Draw the banner from `_hostWake`. */
_renderHostWakeBanner() {
const state = this._hostWake;
const banner = this.$('hostWakeBanner');
const text = this.$('hostWakeBannerText');
const detail = this.$('hostWakeBannerDetail');
const action = this.$('hostWakeBannerAction');
if (!banner || !text || !action) return;
const visible = Boolean(state && state.sessionId && state.reachable === false);
banner.hidden = !visible;
if (!visible) return;
const hasTarget = state.wakeConfigured !== 'none';
const target = state.label || state.host || 'Remote host';
if (state.waking) {
text.textContent = `Waking ${target} …`;
} else if (state.error) {
text.textContent = `${target} did not wake up`;
} else {
text.textContent = `${target} is not reachable`;
}
if (detail) {
detail.textContent = state.waking
? 'input is queued until it is back'
: hasTarget
? `ssh ${state.host}`
: 'no wake-on-LAN configured';
}
action.textContent = state.waking ? 'Waking …' : hasTarget ? 'Wake' : 'Configure WoL';
action.disabled = state.waking;
},
/** Banner button: wake the host, or open the setup dialog when nothing is configured. */
hostWakeAction() {
const state = this._hostWake;
if (!state || !state.sessionId || state.waking) return;
if (state.wakeConfigured === 'none') {
this.openWakeConfigDialog();
return;
}
this.wakeRemoteHost();
},
/** POST the manual wake for the active session and follow the result. */
async wakeRemoteHost() {
const state = this._hostWake;
if (!state || !state.sessionId) return;
const sessionId = state.sessionId;
state.waking = true;
state.error = '';
this._renderHostWakeBanner();
try {
const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/wake`, { method: 'POST' });
const data = await res.json();
if (this._hostWake !== state || state.sessionId !== sessionId) return;
state.waking = false;
if (!data.success) {
// Most likely: no wake target configured after all (the route is the authority).
state.error = data.error || 'Wake failed';
if (String(data.error || '').includes('No wake-on-LAN target')) state.wakeConfigured = 'none';
this._renderHostWakeBanner();
return;
}
state.reachable = data.data.reachable !== false;
state.wakeConfigured = data.data.wakeConfigured || state.wakeConfigured;
if (state.reachable) {
this.showToast(`${state.label || 'Remote host'} is awake`, 'success');
} else {
state.error = 'timeout';
}
this._renderHostWakeBanner();
} catch (err) {
if (this._hostWake !== state) return;
state.waking = false;
state.error = err && err.message ? err.message : 'Wake failed';
this._renderHostWakeBanner();
}
},
/** Open the small WoL dialog for the banner's host, pre-filled from the host config. */
async openWakeConfigDialog() {
const state = this._hostWake;
const session = state && state.sessionId && this.sessions ? this.sessions.get(state.sessionId) : null;
if (!session || !session.remote) return;
const hostId = session.remote.hostId;
const label = this.$('wakeConfigHostLabel');
const mac = this.$('wakeConfigMac');
const command = this.$('wakeConfigCommand');
const status = this.$('wakeConfigStatus');
if (!mac || !command) return;
mac.value = session.remote.wakeMac || '';
command.value = session.remote.wakeCommand || '';
if (label) label.textContent = session.remote.label || hostId;
if (status) status.textContent = '';
this._wakeConfigHostId = hostId;
const modal = this.$('wakeConfigModal');
if (modal) modal.classList.add('active');
// Read the saved host so the dialog shows what is actually persisted (the session
// payload may predate a change made in another tab).
try {
const res = await fetch('/api/remote-hosts');
const data = await res.json();
const hosts = data.success ? data.data : [];
const host = Array.isArray(hosts) ? hosts.find((item) => item.id === hostId) : null;
if (host && this._wakeConfigHostId === hostId) {
mac.value = host.wakeMac || '';
command.value = host.wakeCommand || '';
}
} catch {
/* The form is already usable from the session payload. */
}
},
closeWakeConfigDialog() {
const modal = this.$('wakeConfigModal');
if (modal) modal.classList.remove('active');
this._wakeConfigHostId = null;
},
/** Save MAC/command for the host, then re-check whether the session can wake now. */
async saveWakeConfig() {
const hostId = this._wakeConfigHostId;
const mac = this.$('wakeConfigMac');
const command = this.$('wakeConfigCommand');
const status = this.$('wakeConfigStatus');
const save = this.$('wakeConfigSave');
if (!hostId || !mac || !command) return;
const macValue = mac.value.trim();
const commandValue = command.value.trim();
if (
macValue &&
!/^[0-9a-fA-F]{2}([:-][0-9a-fA-F]{2}){5}(\s*,\s*[0-9a-fA-F]{2}([:-][0-9a-fA-F]{2}){5})*$/.test(macValue)
) {
if (status) status.textContent = 'MAC must look like 04:d9:f5:80:c6:58 (comma-separated for several).';
return;
}
if (commandValue && /\s/.test(commandValue)) {
if (status) status.textContent = 'The wake command must be a single executable path (no arguments).';
return;
}
if (save) save.disabled = true;
if (status) status.textContent = 'Saving …';
try {
const listRes = await fetch('/api/remote-hosts');
const listData = await listRes.json();
const hosts = listData.success ? listData.data : [];
const host = Array.isArray(hosts) ? hosts.find((item) => item.id === hostId) : null;
if (!host) throw new Error('Remote host not found');
// PUT takes the whole host (schema-validated), so send back everything we know and
// only replace the wake fields. `undefined` drops the key entirely.
const payload = {
...host,
wakeMac: macValue || undefined,
wakeCommand: commandValue || undefined,
};
const res = await fetch(`/api/remote-hosts/${encodeURIComponent(hostId)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await res.json();
if (!data.success) throw new Error(data.error || 'Save failed');
this.showToast('Wake settings saved', 'success');
this.closeWakeConfigDialog();
// The server re-resolves host config for live sessions, so the banner can offer
// the wake right away — probe fresh instead of waiting out the poll interval.
await this._pollHostReachability(true);
} catch (err) {
if (status) status.textContent = err && err.message ? err.message : 'Save failed';
} finally {
if (save) save.disabled = false;
}
},
/** SSE `remote:hostWaking` — a wake is running (ours or one started by typing). */
_onRemoteHostWaking(data) {
const state = this._hostWake;
if (!state || !data || state.sessionId !== data.sessionId) return;
state.waking = true;
state.error = '';
if (data.label) state.label = data.label;
this._renderHostWakeBanner();
},
/** SSE `remote:hostWakeFailed` — the host did not come back in time. */
_onRemoteHostWakeFailed(data) {
const state = this._hostWake;
if (!state || !data || state.sessionId !== data.sessionId) return;
state.waking = false;
state.error = 'timeout';
state.reachable = false;
this._renderHostWakeBanner();
},
});