muxplex 0.3.2__tar.gz → 0.3.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {muxplex-0.3.2 → muxplex-0.3.4}/CHANGELOG.md +14 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/PKG-INFO +1 -1
- {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/frontend/app.js +10 -6
- {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/frontend/style.css +5 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/frontend/terminal.js +7 -5
- {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/frontend/tests/test_app.mjs +107 -2
- {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/main.py +37 -2
- {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/tests/test_api.py +228 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/pyproject.toml +1 -1
- {muxplex-0.3.2 → muxplex-0.3.4}/.github/workflows/ci.yml +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/.github/workflows/publish.yml +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/.gitignore +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/README.md +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/DESIGN-SYSTEM.md +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/README.md +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/favicons/apple-touch-icon.png +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/favicons/favicon-16.png +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/favicons/favicon-32.png +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/favicons/favicon-48.png +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/favicons/favicon.ico +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/icons/muxplex-icon-1024.png +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/icons/muxplex-icon-128.png +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/icons/muxplex-icon-16.png +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/icons/muxplex-icon-192.png +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/icons/muxplex-icon-22.png +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/icons/muxplex-icon-24.png +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/icons/muxplex-icon-256.png +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/icons/muxplex-icon-32.png +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/icons/muxplex-icon-48.png +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/icons/muxplex-icon-512.png +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/icons/muxplex-icon-64.png +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/lockup/lockup-on-dark-32.png +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/lockup/lockup-on-dark-64.png +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/lockup/lockup-on-light-32.png +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/lockup/lockup-on-light-64.png +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/og/og-dark.png +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/og/og-light.png +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/pwa/pwa-192.png +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/pwa/pwa-512.png +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/svg/icon/muxplex-icon-dark.svg +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/svg/icon/muxplex-icon-light.svg +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/svg/lockup/lockup-on-dark.svg +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/svg/lockup/lockup-on-light.svg +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/svg/wordmark/wordmark-on-dark.svg +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/svg/wordmark/wordmark-on-light.svg +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/tokens.css +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/tokens.json +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/wordmark/wordmark-on-dark-32.png +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/wordmark/wordmark-on-dark-64.png +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/wordmark/wordmark-on-light-32.png +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/wordmark/wordmark-on-light-64.png +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/docs/how-we-built-the-muxplex-brand.md +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/docs/plans/2026-03-26-web-tmux-dashboard-design.md +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/docs/plans/2026-03-26-web-tmux-phase1-backend.md +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/docs/plans/2026-03-26-web-tmux-phase2a-frontend-static.md +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/docs/plans/2026-03-26-web-tmux-phase2b-javascript.md +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/docs/plans/2026-03-27-session-sidebar-design.md +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/docs/plans/2026-03-27-session-sidebar-implementation.md +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/docs/plans/2026-03-28-auth-design.md +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/docs/plans/2026-03-28-auth-phase1-infrastructure.md +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/docs/plans/2026-03-28-auth-phase2-ui-cli.md +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/docs/plans/2026-03-28-packaging-for-distribution.md +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/docs/plans/2026-03-29-settings-design.md +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/docs/plans/2026-03-29-settings-phase1.md +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/docs/plans/2026-03-29-settings-phase2.md +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/docs/plans/2026-03-30-multi-device-federation-design.md +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/docs/plans/2026-03-30-multi-device-federation-phase1.md +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/docs/plans/2026-03-30-multi-device-federation-phase2.md +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/docs/plans/2026-03-30-multi-device-federation-phase3.md +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/docs/plans/2026-03-31-cli-phase1-config-serve.md +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/docs/plans/2026-03-31-cli-phase2-service-commands.md +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/docs/plans/2026-03-31-cli-service-refactor-design.md +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/docs/plans/2026-04-03-tls-phase1-foundation.md +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/docs/plans/2026-04-03-tls-phase2-autodetect.md +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/docs/plans/2026-04-03-tls-setup-design.md +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/docs/plans/2026-04-04-device-selector.md +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/docs/plans/2026-04-04-tls-nudge-design.md +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/docs/plans/2026-04-08-federation-state-propagation-design.md +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/docs/plans/2026-04-08-federation-state-propagation-plan.md +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/docs/plans/2026-04-08-server-side-settings-design.md +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/docs/plans/2026-04-08-server-side-settings-plan.md +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/docs/plans/README.md +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/docs/screenshots/settings-commands.png +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/docs/screenshots/settings-display.png +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/docs/screenshots/settings-multidevice.png +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/docs/screenshots/settings-sessions.png +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/__init__.py +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/__main__.py +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/auth.py +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/bells.py +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/cli.py +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/frontend/apple-touch-icon.png +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/frontend/favicon-32.png +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/frontend/favicon.ico +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/frontend/index.html +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/frontend/login.html +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/frontend/manifest.json +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/frontend/pwa-192.png +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/frontend/pwa-512.png +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/frontend/tests/test_terminal.mjs +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/frontend/vendor/addon-image.js +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/frontend/vendor/xterm-addon-fit.js +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/frontend/vendor/xterm-addon-search.js +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/frontend/vendor/xterm-addon-web-links.js +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/frontend/vendor/xterm.css +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/frontend/vendor/xterm.js +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/frontend/wordmark-on-dark.svg +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/service.py +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/sessions.py +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/settings.py +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/state.py +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/tests/__init__.py +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/tests/test_auth.py +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/tests/test_bells.py +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/tests/test_cli.py +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/tests/test_frontend_css.py +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/tests/test_frontend_html.py +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/tests/test_frontend_js.py +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/tests/test_integration.py +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/tests/test_readme.py +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/tests/test_service.py +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/tests/test_sessions.py +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/tests/test_settings.py +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/tests/test_settings_sync_poll.py +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/tests/test_state.py +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/tests/test_tls.py +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/tests/test_ttyd.py +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/tests/test_ws_proxy.py +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/tls.py +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/ttyd.py +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/pyrightconfig.json +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/scripts/render-brand-assets.py +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/scripts/spike_bell_flag.py +0 -0
- {muxplex-0.3.2 → muxplex-0.3.4}/uv.lock +0 -0
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v0.3.4 (2026-04-13)
|
|
4
|
+
|
|
5
|
+
### Bug Fixes
|
|
6
|
+
- **Zero-session devices visible** — devices with no tmux sessions now show a "No sessions" status tile instead of being invisible
|
|
7
|
+
- **Flapping prevention** — server-side cache of last-known-good federation results per remote; returns cached sessions for up to 3 consecutive failures before marking unreachable
|
|
8
|
+
- **Status tiles show device name** — offline/unreachable tiles display the device name instead of blank (was passing session.name which is undefined for status entries)
|
|
9
|
+
- **Status entries filtered from session list** — unreachable/auth_failed entries no longer render as blank session tiles in dashboard or sidebar
|
|
10
|
+
- **remoteId=0 falsy bug in mobile sheet** — first remote instance (index 0) now works correctly in the mobile bottom sheet session switcher
|
|
11
|
+
|
|
12
|
+
## v0.3.3 (2026-04-13)
|
|
13
|
+
|
|
14
|
+
### Bug Fixes
|
|
15
|
+
- **iOS/iPadOS touch scrolling** — fix touch scroll handling for Safari on iOS and iPadOS devices (PR #4, @samueljklee)
|
|
16
|
+
|
|
3
17
|
## v0.3.2 (2026-04-09)
|
|
4
18
|
|
|
5
19
|
### Bug Fixes
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: muxplex
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.4
|
|
4
4
|
Summary: Web-based tmux session dashboard — access all your tmux sessions from any browser
|
|
5
5
|
Project-URL: Repository, https://github.com/bkrabach/muxplex
|
|
6
6
|
Project-URL: Issues, https://github.com/bkrabach/muxplex/issues
|
|
@@ -530,6 +530,8 @@ function buildStatusTileHTML(deviceName, statusText, statusClass) {
|
|
|
530
530
|
function getVisibleSessions(sessions) {
|
|
531
531
|
var hidden = (_serverSettings && _serverSettings.hidden_sessions) || [];
|
|
532
532
|
return (sessions || []).filter(function(s) {
|
|
533
|
+
// Skip status entries (unreachable, auth_failed) — rendered separately as status tiles
|
|
534
|
+
if (s.status) return false;
|
|
533
535
|
if (hidden.length > 0 && hidden.includes(s.name)) {
|
|
534
536
|
return false;
|
|
535
537
|
}
|
|
@@ -758,8 +760,9 @@ function renderGrid(sessions) {
|
|
|
758
760
|
// Build status tiles for auth_failed/unreachable sessions even when no regular sessions exist
|
|
759
761
|
var statusTilesHtml = '';
|
|
760
762
|
(sessions || []).forEach(function(session) {
|
|
761
|
-
if (session.status === 'auth_failed') statusTilesHtml += buildStatusTileHTML(session.
|
|
762
|
-
else if (session.status === 'unreachable') statusTilesHtml += buildStatusTileHTML(session.
|
|
763
|
+
if (session.status === 'auth_failed') statusTilesHtml += buildStatusTileHTML(session.deviceName, 'Auth required', 'auth');
|
|
764
|
+
else if (session.status === 'unreachable') statusTilesHtml += buildStatusTileHTML(session.deviceName, 'Offline', 'offline');
|
|
765
|
+
else if (session.status === 'empty') statusTilesHtml += buildStatusTileHTML(session.deviceName, 'No sessions', 'empty');
|
|
763
766
|
});
|
|
764
767
|
if (grid) grid.innerHTML = statusTilesHtml;
|
|
765
768
|
// Only show empty-state when there are truly no tiles at all
|
|
@@ -798,11 +801,12 @@ function renderGrid(sessions) {
|
|
|
798
801
|
html = ordered.map(function(session, index) { return buildTileHTML(session, index, mobile); }).join('');
|
|
799
802
|
}
|
|
800
803
|
|
|
801
|
-
// Append status tiles for auth_failed and
|
|
804
|
+
// Append status tiles for auth_failed, unreachable, and empty sessions
|
|
802
805
|
var statusTilesHtml = '';
|
|
803
806
|
(sessions || []).forEach(function(session) {
|
|
804
|
-
if (session.status === 'auth_failed') statusTilesHtml += buildStatusTileHTML(session.
|
|
805
|
-
else if (session.status === 'unreachable') statusTilesHtml += buildStatusTileHTML(session.
|
|
807
|
+
if (session.status === 'auth_failed') statusTilesHtml += buildStatusTileHTML(session.deviceName, 'Auth required', 'auth');
|
|
808
|
+
else if (session.status === 'unreachable') statusTilesHtml += buildStatusTileHTML(session.deviceName, 'Offline', 'offline');
|
|
809
|
+
else if (session.status === 'empty') statusTilesHtml += buildStatusTileHTML(session.deviceName, 'No sessions', 'empty');
|
|
806
810
|
});
|
|
807
811
|
if (grid) grid.innerHTML = html + statusTilesHtml;
|
|
808
812
|
|
|
@@ -1847,7 +1851,7 @@ function renderSheetList() {
|
|
|
1847
1851
|
(s.bell.seen_at === null || s.bell.last_fired_at > s.bell.seen_at);
|
|
1848
1852
|
var isActive = s.name === _viewingSession;
|
|
1849
1853
|
var escapedName = escapeHtml(s.name || '');
|
|
1850
|
-
var remoteIdAttr = s.remoteId ? ' data-remote-id="' + escapeHtml(s.remoteId) + '"' : '';
|
|
1854
|
+
var remoteIdAttr = s.remoteId != null ? ' data-remote-id="' + escapeHtml(s.remoteId) + '"' : '';
|
|
1851
1855
|
return '<li class="sheet-item' + (isActive ? ' sheet-item--active' : '') + '"' +
|
|
1852
1856
|
' data-session="' + escapedName + '"' + remoteIdAttr + ' role="option">' +
|
|
1853
1857
|
'<span class="sheet-item__name">' + escapedName + '</span>' +
|
|
@@ -534,13 +534,15 @@ function setTerminalFontSize(size) {
|
|
|
534
534
|
window._setTerminalFontSize = setTerminalFontSize;
|
|
535
535
|
|
|
536
536
|
// ---------------------------------------------------------------------------
|
|
537
|
-
//
|
|
538
|
-
//
|
|
537
|
+
// Mobile touch scroll — rAF-batched WheelEvent dispatch
|
|
538
|
+
// Mobile devices batch touchmove events irregularly; dispatching one WheelEvent
|
|
539
539
|
// per frame (via requestAnimationFrame) smooths over burst delivery.
|
|
540
|
-
//
|
|
540
|
+
// Applies to Android, iOS, and iPadOS touch devices.
|
|
541
541
|
// ---------------------------------------------------------------------------
|
|
542
|
-
;(function
|
|
543
|
-
|
|
542
|
+
;(function initMobileTerminalScroll() {
|
|
543
|
+
var isTouchDevice = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent) ||
|
|
544
|
+
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
|
545
|
+
if (!isTouchDevice) return;
|
|
544
546
|
|
|
545
547
|
var container = document.getElementById('terminal-container');
|
|
546
548
|
if (!container) return;
|
|
@@ -626,7 +626,7 @@ test('renderGrid includes auth tile HTML when a session has auth_failed status',
|
|
|
626
626
|
|
|
627
627
|
const sessions = [
|
|
628
628
|
{ name: 'my-session', snapshot: 'hello' },
|
|
629
|
-
{
|
|
629
|
+
{ deviceName: 'Workstation', status: 'auth_failed' },
|
|
630
630
|
];
|
|
631
631
|
app.renderGrid(sessions);
|
|
632
632
|
|
|
@@ -651,7 +651,7 @@ test('renderGrid includes offline tile HTML when a session has unreachable statu
|
|
|
651
651
|
|
|
652
652
|
const sessions = [
|
|
653
653
|
{ name: 'my-session', snapshot: 'hello' },
|
|
654
|
-
{
|
|
654
|
+
{ deviceName: 'Dev Server', status: 'unreachable' },
|
|
655
655
|
];
|
|
656
656
|
app.renderGrid(sessions);
|
|
657
657
|
|
|
@@ -1604,6 +1604,20 @@ test('renderSheetList escapes HTML special chars in sheet-item__name span', () =
|
|
|
1604
1604
|
globalThis.document.getElementById = origGetById;
|
|
1605
1605
|
});
|
|
1606
1606
|
|
|
1607
|
+
// --- Fix 3: renderSheetList uses null check for remoteId (not falsy) ---
|
|
1608
|
+
|
|
1609
|
+
test('renderSheetList uses null check for remoteId (not falsy)', () => {
|
|
1610
|
+
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
|
|
1611
|
+
const fnStart = source.indexOf('function renderSheetList');
|
|
1612
|
+
const fnEnd = source.indexOf('\n}\n', fnStart + 100);
|
|
1613
|
+
const fnBody = source.substring(fnStart, fnEnd);
|
|
1614
|
+
// Must use != null check, not truthy check
|
|
1615
|
+
assert.ok(fnBody.includes('remoteId != null') || fnBody.includes('remoteId !== null'),
|
|
1616
|
+
'renderSheetList must use null check for remoteId (0 is valid)');
|
|
1617
|
+
assert.ok(!fnBody.match(/s\.remoteId\s*\?[^=]/),
|
|
1618
|
+
'renderSheetList must NOT use truthy check for remoteId (0 is falsy)');
|
|
1619
|
+
});
|
|
1620
|
+
|
|
1607
1621
|
// --- Fix 2: openBottomSheet/closeBottomSheet use static backdrop binding ---
|
|
1608
1622
|
|
|
1609
1623
|
test('openBottomSheet does not dynamically add click listener to sheet-backdrop', () => {
|
|
@@ -1769,6 +1783,37 @@ test('renderSidebar does nothing when view is not fullscreen', () => {
|
|
|
1769
1783
|
globalThis.document.getElementById = origGetById;
|
|
1770
1784
|
});
|
|
1771
1785
|
|
|
1786
|
+
// --- getVisibleSessions ---
|
|
1787
|
+
|
|
1788
|
+
test('getVisibleSessions filters out entries with unreachable status (no name)', () => {
|
|
1789
|
+
const sessions = [
|
|
1790
|
+
{ name: 'real-session', snapshot: '' },
|
|
1791
|
+
{ status: 'unreachable', remoteId: 0, deviceName: 'alienware-r13' },
|
|
1792
|
+
];
|
|
1793
|
+
const result = app.getVisibleSessions(sessions);
|
|
1794
|
+
assert.strictEqual(result.length, 1, 'should return only the real session');
|
|
1795
|
+
assert.strictEqual(result[0].name, 'real-session');
|
|
1796
|
+
});
|
|
1797
|
+
|
|
1798
|
+
test('getVisibleSessions filters out entries with auth_failed status', () => {
|
|
1799
|
+
const sessions = [
|
|
1800
|
+
{ name: 'real-session', snapshot: '' },
|
|
1801
|
+
{ name: 'Workstation', status: 'auth_failed' },
|
|
1802
|
+
];
|
|
1803
|
+
const result = app.getVisibleSessions(sessions);
|
|
1804
|
+
assert.strictEqual(result.length, 1, 'auth_failed entry should be filtered out');
|
|
1805
|
+
assert.strictEqual(result[0].name, 'real-session');
|
|
1806
|
+
});
|
|
1807
|
+
|
|
1808
|
+
test('getVisibleSessions passes through sessions with no status field', () => {
|
|
1809
|
+
const sessions = [
|
|
1810
|
+
{ name: 'alpha', snapshot: '' },
|
|
1811
|
+
{ name: 'beta', snapshot: '' },
|
|
1812
|
+
];
|
|
1813
|
+
const result = app.getVisibleSessions(sessions);
|
|
1814
|
+
assert.strictEqual(result.length, 2, 'normal sessions should all pass through');
|
|
1815
|
+
});
|
|
1816
|
+
|
|
1772
1817
|
// ─── initSidebar ─────────────────────────────────────────────────────────────
|
|
1773
1818
|
|
|
1774
1819
|
test('initSidebar defaults to open (removes sidebar--collapsed) on wide screens when no stored value', () => {
|
|
@@ -4699,3 +4744,63 @@ test('updateFaviconBadge does not show activity for only-hidden sessions with be
|
|
|
4699
4744
|
app._setServerSettings(null);
|
|
4700
4745
|
app._setCurrentSessions([]);
|
|
4701
4746
|
});
|
|
4747
|
+
|
|
4748
|
+
// --- renderGrid: status tiles use deviceName not name ---
|
|
4749
|
+
|
|
4750
|
+
test('renderGrid status tiles use session.deviceName not session.name for offline devices', () => {
|
|
4751
|
+
// Status entries (unreachable/auth_failed) have deviceName but no name.
|
|
4752
|
+
// buildStatusTileHTML must receive session.deviceName so the tile shows the device label.
|
|
4753
|
+
const grid = { innerHTML: '' };
|
|
4754
|
+
const emptyState = { style: {}, classList: { add() {}, remove() {} } };
|
|
4755
|
+
const origGetById = globalThis.document.getElementById;
|
|
4756
|
+
globalThis.document.getElementById = (id) => {
|
|
4757
|
+
if (id === 'session-grid') return grid;
|
|
4758
|
+
if (id === 'empty-state') return emptyState;
|
|
4759
|
+
return null;
|
|
4760
|
+
};
|
|
4761
|
+
|
|
4762
|
+
// An unreachable device: has deviceName but no name (as federation returns it)
|
|
4763
|
+
app.renderGrid([{ status: 'unreachable', deviceName: 'my-server', remoteId: 1 }]);
|
|
4764
|
+
|
|
4765
|
+
assert.ok(grid.innerHTML.includes('my-server'),
|
|
4766
|
+
'offline status tile HTML must include the deviceName "my-server"');
|
|
4767
|
+
|
|
4768
|
+
// Also verify an auth_failed device shows its deviceName
|
|
4769
|
+
app.renderGrid([{ status: 'auth_failed', deviceName: 'auth-box', remoteId: 2 }]);
|
|
4770
|
+
assert.ok(grid.innerHTML.includes('auth-box'),
|
|
4771
|
+
'auth_failed status tile HTML must include the deviceName "auth-box"');
|
|
4772
|
+
|
|
4773
|
+
globalThis.document.getElementById = origGetById;
|
|
4774
|
+
});
|
|
4775
|
+
|
|
4776
|
+
// --- renderGrid: status=empty shows "No sessions" tile ---
|
|
4777
|
+
|
|
4778
|
+
test('renderGrid shows "No sessions" status tile for status=empty devices', () => {
|
|
4779
|
+
// A device that is online but has zero tmux sessions returns
|
|
4780
|
+
// {status: 'empty', deviceName: '...'} from the federation endpoint.
|
|
4781
|
+
// renderGrid must render a status tile with the text "No sessions" (not blank).
|
|
4782
|
+
//
|
|
4783
|
+
// Before implementation: fails because neither status loop handles status === 'empty',
|
|
4784
|
+
// so the tile is never rendered and grid.innerHTML stays empty.
|
|
4785
|
+
const grid = { innerHTML: '' };
|
|
4786
|
+
const emptyState = { style: {}, classList: { add() {}, remove() {} } };
|
|
4787
|
+
const origGetById = globalThis.document.getElementById;
|
|
4788
|
+
globalThis.document.getElementById = (id) => {
|
|
4789
|
+
if (id === 'session-grid') return grid;
|
|
4790
|
+
if (id === 'empty-state') return emptyState;
|
|
4791
|
+
return null;
|
|
4792
|
+
};
|
|
4793
|
+
|
|
4794
|
+
app.renderGrid([{ status: 'empty', deviceName: 'quiet-box', remoteId: 3 }]);
|
|
4795
|
+
|
|
4796
|
+
assert.ok(
|
|
4797
|
+
grid.innerHTML.includes('No sessions'),
|
|
4798
|
+
`renderGrid must include "No sessions" text for status=empty device, got: ${grid.innerHTML}`
|
|
4799
|
+
);
|
|
4800
|
+
assert.ok(
|
|
4801
|
+
grid.innerHTML.includes('quiet-box'),
|
|
4802
|
+
`renderGrid must include the deviceName "quiet-box" in the status tile, got: ${grid.innerHTML}`
|
|
4803
|
+
);
|
|
4804
|
+
|
|
4805
|
+
globalThis.document.getElementById = origGetById;
|
|
4806
|
+
});
|
|
@@ -1182,6 +1182,13 @@ async def auth_mode_endpoint():
|
|
|
1182
1182
|
return {"mode": _auth_mode, "user": username}
|
|
1183
1183
|
|
|
1184
1184
|
|
|
1185
|
+
# Module-level cache: remote_id → {"sessions": [...], "fail_count": int}
|
|
1186
|
+
# Populated by fetch_remote() on every successful poll; returned on transient failures
|
|
1187
|
+
# so a single slow/dropped request doesn't immediately evict a device from the UI.
|
|
1188
|
+
_federation_cache: dict[int, dict] = {}
|
|
1189
|
+
_FEDERATION_GRACE_FAILURES = 3 # consecutive failures before marking unreachable
|
|
1190
|
+
|
|
1191
|
+
|
|
1185
1192
|
@app.get("/api/federation/sessions")
|
|
1186
1193
|
async def federation_sessions(request: Request) -> list[dict]:
|
|
1187
1194
|
"""Fetch sessions from all instances (local + remotes) and merge.
|
|
@@ -1220,7 +1227,12 @@ async def federation_sessions(request: Request) -> list[dict]:
|
|
|
1220
1227
|
http_client: httpx.AsyncClient = request.app.state.federation_client
|
|
1221
1228
|
|
|
1222
1229
|
async def fetch_remote(i: int, remote: dict) -> list[dict]:
|
|
1223
|
-
"""Fetch /api/sessions from a remote instance, returning session dicts or a status entry.
|
|
1230
|
+
"""Fetch /api/sessions from a remote instance, returning session dicts or a status entry.
|
|
1231
|
+
|
|
1232
|
+
On success: cache the result and return tagged sessions (or {status: 'empty'} if none).
|
|
1233
|
+
On transient failure: return cached sessions for up to _FEDERATION_GRACE_FAILURES
|
|
1234
|
+
consecutive failures before promoting to {status: 'unreachable'}.
|
|
1235
|
+
"""
|
|
1224
1236
|
url: str = remote.get("url", "")
|
|
1225
1237
|
key: str = remote.get("key", "")
|
|
1226
1238
|
remote_name: str = remote.get("name", url)
|
|
@@ -1231,6 +1243,8 @@ async def federation_sessions(request: Request) -> list[dict]:
|
|
|
1231
1243
|
headers={"Authorization": f"Bearer {key}"} if key else {},
|
|
1232
1244
|
)
|
|
1233
1245
|
if resp.status_code in (401, 403):
|
|
1246
|
+
# Auth failure — clear cache so stale data is not served
|
|
1247
|
+
_federation_cache.pop(remote_id, None)
|
|
1234
1248
|
return [
|
|
1235
1249
|
{
|
|
1236
1250
|
"status": "auth_failed",
|
|
@@ -1241,7 +1255,7 @@ async def federation_sessions(request: Request) -> list[dict]:
|
|
|
1241
1255
|
resp.raise_for_status()
|
|
1242
1256
|
sessions = resp.json()
|
|
1243
1257
|
# Tag each session with deviceName, remoteId, and unique sessionKey
|
|
1244
|
-
|
|
1258
|
+
tagged = [
|
|
1245
1259
|
{
|
|
1246
1260
|
**s,
|
|
1247
1261
|
"deviceName": remote_name,
|
|
@@ -1250,7 +1264,24 @@ async def federation_sessions(request: Request) -> list[dict]:
|
|
|
1250
1264
|
}
|
|
1251
1265
|
for s in sessions
|
|
1252
1266
|
]
|
|
1267
|
+
# Update cache on every successful poll (even empty)
|
|
1268
|
+
_federation_cache[remote_id] = {"sessions": tagged, "fail_count": 0}
|
|
1269
|
+
if not tagged:
|
|
1270
|
+
# Device is online but has zero tmux sessions — show a status tile
|
|
1271
|
+
# rather than making the device completely invisible.
|
|
1272
|
+
return [
|
|
1273
|
+
{
|
|
1274
|
+
"status": "empty",
|
|
1275
|
+
"remoteId": remote_id,
|
|
1276
|
+
"deviceName": remote_name,
|
|
1277
|
+
}
|
|
1278
|
+
]
|
|
1279
|
+
return tagged
|
|
1253
1280
|
except httpx.HTTPStatusError:
|
|
1281
|
+
cached = _federation_cache.get(remote_id)
|
|
1282
|
+
if cached and cached["fail_count"] < _FEDERATION_GRACE_FAILURES:
|
|
1283
|
+
cached["fail_count"] += 1
|
|
1284
|
+
return cached["sessions"]
|
|
1254
1285
|
return [
|
|
1255
1286
|
{
|
|
1256
1287
|
"status": "unreachable",
|
|
@@ -1260,6 +1291,10 @@ async def federation_sessions(request: Request) -> list[dict]:
|
|
|
1260
1291
|
]
|
|
1261
1292
|
except Exception as exc:
|
|
1262
1293
|
_log.warning("Unexpected error fetching remote %s: %s", url, exc)
|
|
1294
|
+
cached = _federation_cache.get(remote_id)
|
|
1295
|
+
if cached and cached["fail_count"] < _FEDERATION_GRACE_FAILURES:
|
|
1296
|
+
cached["fail_count"] += 1
|
|
1297
|
+
return cached["sessions"]
|
|
1263
1298
|
return [
|
|
1264
1299
|
{
|
|
1265
1300
|
"status": "unreachable",
|
|
@@ -41,6 +41,22 @@ def patch_startup_and_state(tmp_path, monkeypatch):
|
|
|
41
41
|
monkeypatch.setattr("muxplex.main._poll_loop", noop_poll_loop)
|
|
42
42
|
|
|
43
43
|
|
|
44
|
+
@pytest.fixture(autouse=True)
|
|
45
|
+
def reset_federation_cache():
|
|
46
|
+
"""Clear _federation_cache before and after each test.
|
|
47
|
+
|
|
48
|
+
The module-level _federation_cache persists across tests in the same process,
|
|
49
|
+
causing cross-test contamination: a test that populates the cache for remoteId=0
|
|
50
|
+
causes a later test (also using remoteId=0) to get stale cached data instead of
|
|
51
|
+
the expected unreachable status.
|
|
52
|
+
"""
|
|
53
|
+
import muxplex.main as main_mod
|
|
54
|
+
|
|
55
|
+
main_mod._federation_cache.clear()
|
|
56
|
+
yield
|
|
57
|
+
main_mod._federation_cache.clear()
|
|
58
|
+
|
|
59
|
+
|
|
44
60
|
# ---------------------------------------------------------------------------
|
|
45
61
|
# Client fixture — TestClient with lifespan enabled
|
|
46
62
|
# ---------------------------------------------------------------------------
|
|
@@ -3036,3 +3052,215 @@ def test_put_settings_sync_ignores_nonsyncable_keys(client, tmp_path, monkeypatc
|
|
|
3036
3052
|
assert local["host"] == "127.0.0.1", (
|
|
3037
3053
|
f"Non-syncable key 'host' must remain '127.0.0.1', got: {local['host']}"
|
|
3038
3054
|
)
|
|
3055
|
+
|
|
3056
|
+
|
|
3057
|
+
# ---------------------------------------------------------------------------
|
|
3058
|
+
# fetch_remote: zero-session visibility and flapping grace period
|
|
3059
|
+
# ---------------------------------------------------------------------------
|
|
3060
|
+
|
|
3061
|
+
|
|
3062
|
+
def test_fetch_remote_returns_empty_status_for_zero_sessions(
|
|
3063
|
+
client, monkeypatch, tmp_path
|
|
3064
|
+
):
|
|
3065
|
+
"""When remote /api/sessions returns [], federation returns {status: 'empty'} entry.
|
|
3066
|
+
|
|
3067
|
+
A device that is online but has zero tmux sessions must not vanish silently.
|
|
3068
|
+
Instead, the endpoint must include a {status: 'empty', deviceName: ...} entry
|
|
3069
|
+
so the frontend can render a 'No sessions' tile.
|
|
3070
|
+
|
|
3071
|
+
Before implementation: fails because the list comprehension returns [] for empty
|
|
3072
|
+
session lists, and the empty list is flattened into nothing.
|
|
3073
|
+
"""
|
|
3074
|
+
import json
|
|
3075
|
+
from unittest.mock import MagicMock
|
|
3076
|
+
|
|
3077
|
+
import httpx
|
|
3078
|
+
|
|
3079
|
+
import muxplex.settings as settings_mod
|
|
3080
|
+
|
|
3081
|
+
settings_path = tmp_path / "settings.json"
|
|
3082
|
+
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path)
|
|
3083
|
+
settings_path.write_text(
|
|
3084
|
+
json.dumps(
|
|
3085
|
+
{
|
|
3086
|
+
"remote_instances": [
|
|
3087
|
+
{
|
|
3088
|
+
"url": "http://empty-host:8088",
|
|
3089
|
+
"key": "secret",
|
|
3090
|
+
"name": "empty-host",
|
|
3091
|
+
}
|
|
3092
|
+
]
|
|
3093
|
+
}
|
|
3094
|
+
)
|
|
3095
|
+
)
|
|
3096
|
+
monkeypatch.setattr("muxplex.main.get_session_list", lambda: [])
|
|
3097
|
+
monkeypatch.setattr("muxplex.main.get_snapshots", lambda: {})
|
|
3098
|
+
|
|
3099
|
+
mock_resp = MagicMock(spec=httpx.Response)
|
|
3100
|
+
mock_resp.status_code = 200
|
|
3101
|
+
mock_resp.json.return_value = []
|
|
3102
|
+
mock_resp.raise_for_status.return_value = None
|
|
3103
|
+
|
|
3104
|
+
async def mock_get(*args, **kwargs):
|
|
3105
|
+
return mock_resp
|
|
3106
|
+
|
|
3107
|
+
mock_fed_client = MagicMock()
|
|
3108
|
+
mock_fed_client.get = mock_get
|
|
3109
|
+
monkeypatch.setattr(client.app.state, "federation_client", mock_fed_client)
|
|
3110
|
+
|
|
3111
|
+
response = client.get("/api/federation/sessions")
|
|
3112
|
+
assert response.status_code == 200
|
|
3113
|
+
data = response.json()
|
|
3114
|
+
assert len(data) == 1, (
|
|
3115
|
+
f"Expected exactly one status entry for empty remote, got {len(data)} entries: {data}"
|
|
3116
|
+
)
|
|
3117
|
+
assert data[0].get("status") == "empty", (
|
|
3118
|
+
f"Expected status='empty' for zero-session remote, got: {data[0].get('status')!r}"
|
|
3119
|
+
)
|
|
3120
|
+
assert data[0].get("deviceName") == "empty-host", (
|
|
3121
|
+
f"Expected deviceName='empty-host', got: {data[0].get('deviceName')!r}"
|
|
3122
|
+
)
|
|
3123
|
+
|
|
3124
|
+
|
|
3125
|
+
def test_fetch_remote_uses_cache_on_transient_failure(client, monkeypatch, tmp_path):
|
|
3126
|
+
"""When remote fails after a prior success, cached sessions are returned (grace period).
|
|
3127
|
+
|
|
3128
|
+
A single failed HTTP request must not immediately evict the device from the UI.
|
|
3129
|
+
The server keeps the last-known-good result and returns it for up to
|
|
3130
|
+
_FEDERATION_GRACE_FAILURES consecutive failures.
|
|
3131
|
+
|
|
3132
|
+
Before implementation: fails because fetch_remote has no cache — transient failure
|
|
3133
|
+
immediately returns {status: 'unreachable'}.
|
|
3134
|
+
"""
|
|
3135
|
+
import json
|
|
3136
|
+
from unittest.mock import MagicMock
|
|
3137
|
+
|
|
3138
|
+
import httpx
|
|
3139
|
+
|
|
3140
|
+
import muxplex.main as main_mod
|
|
3141
|
+
import muxplex.settings as settings_mod
|
|
3142
|
+
|
|
3143
|
+
# Reset module-level cache so this test starts clean
|
|
3144
|
+
monkeypatch.setattr(main_mod, "_federation_cache", {})
|
|
3145
|
+
|
|
3146
|
+
settings_path = tmp_path / "settings.json"
|
|
3147
|
+
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path)
|
|
3148
|
+
settings_path.write_text(
|
|
3149
|
+
json.dumps(
|
|
3150
|
+
{
|
|
3151
|
+
"remote_instances": [
|
|
3152
|
+
{"url": "http://remote:8088", "key": "k", "name": "cache-host"}
|
|
3153
|
+
]
|
|
3154
|
+
}
|
|
3155
|
+
)
|
|
3156
|
+
)
|
|
3157
|
+
monkeypatch.setattr("muxplex.main.get_session_list", lambda: [])
|
|
3158
|
+
monkeypatch.setattr("muxplex.main.get_snapshots", lambda: {})
|
|
3159
|
+
|
|
3160
|
+
call_count = [0]
|
|
3161
|
+
|
|
3162
|
+
async def mock_get_stateful(*args, **kwargs):
|
|
3163
|
+
call_count[0] += 1
|
|
3164
|
+
if call_count[0] == 1:
|
|
3165
|
+
mock_resp = MagicMock(spec=httpx.Response)
|
|
3166
|
+
mock_resp.status_code = 200
|
|
3167
|
+
mock_resp.json.return_value = [{"name": "sess1"}, {"name": "sess2"}]
|
|
3168
|
+
mock_resp.raise_for_status.return_value = None
|
|
3169
|
+
return mock_resp
|
|
3170
|
+
raise httpx.TimeoutException("timeout", request=MagicMock())
|
|
3171
|
+
|
|
3172
|
+
mock_fed_client = MagicMock()
|
|
3173
|
+
mock_fed_client.get = mock_get_stateful
|
|
3174
|
+
monkeypatch.setattr(client.app.state, "federation_client", mock_fed_client)
|
|
3175
|
+
|
|
3176
|
+
# First call: succeeds and populates cache
|
|
3177
|
+
r1 = client.get("/api/federation/sessions")
|
|
3178
|
+
assert r1.status_code == 200
|
|
3179
|
+
d1 = [s for s in r1.json() if s.get("deviceName") == "cache-host"]
|
|
3180
|
+
assert len(d1) == 2, f"First call must return 2 sessions, got {d1}"
|
|
3181
|
+
|
|
3182
|
+
# Second call: remote times out — cache should return the 2 cached sessions
|
|
3183
|
+
r2 = client.get("/api/federation/sessions")
|
|
3184
|
+
assert r2.status_code == 200
|
|
3185
|
+
d2 = [s for s in r2.json() if s.get("deviceName") == "cache-host"]
|
|
3186
|
+
assert len(d2) == 2, f"Within grace period, must return 2 cached sessions, got {d2}"
|
|
3187
|
+
assert not any(s.get("status") == "unreachable" for s in d2), (
|
|
3188
|
+
"Within grace period, cached sessions must be returned, not 'unreachable'"
|
|
3189
|
+
)
|
|
3190
|
+
|
|
3191
|
+
|
|
3192
|
+
def test_fetch_remote_marks_unreachable_after_grace_period(
|
|
3193
|
+
client, monkeypatch, tmp_path
|
|
3194
|
+
):
|
|
3195
|
+
"""After _FEDERATION_GRACE_FAILURES consecutive failures, device is marked unreachable.
|
|
3196
|
+
|
|
3197
|
+
The grace period prevents flapping, but must not hide a genuinely offline device
|
|
3198
|
+
indefinitely. After 3 consecutive failures the next poll must return
|
|
3199
|
+
{status: 'unreachable'}.
|
|
3200
|
+
|
|
3201
|
+
Before implementation: fails because there is no cache at all — unreachable is
|
|
3202
|
+
returned immediately on first failure.
|
|
3203
|
+
"""
|
|
3204
|
+
import json
|
|
3205
|
+
from unittest.mock import MagicMock
|
|
3206
|
+
|
|
3207
|
+
import httpx
|
|
3208
|
+
|
|
3209
|
+
import muxplex.main as main_mod
|
|
3210
|
+
import muxplex.settings as settings_mod
|
|
3211
|
+
|
|
3212
|
+
# Reset module-level cache so this test starts clean
|
|
3213
|
+
monkeypatch.setattr(main_mod, "_federation_cache", {})
|
|
3214
|
+
|
|
3215
|
+
settings_path = tmp_path / "settings.json"
|
|
3216
|
+
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path)
|
|
3217
|
+
settings_path.write_text(
|
|
3218
|
+
json.dumps(
|
|
3219
|
+
{
|
|
3220
|
+
"remote_instances": [
|
|
3221
|
+
{"url": "http://remote:8088", "key": "k", "name": "grace-host"}
|
|
3222
|
+
]
|
|
3223
|
+
}
|
|
3224
|
+
)
|
|
3225
|
+
)
|
|
3226
|
+
monkeypatch.setattr("muxplex.main.get_session_list", lambda: [])
|
|
3227
|
+
monkeypatch.setattr("muxplex.main.get_snapshots", lambda: {})
|
|
3228
|
+
|
|
3229
|
+
call_count = [0]
|
|
3230
|
+
|
|
3231
|
+
async def mock_get_stateful(*args, **kwargs):
|
|
3232
|
+
call_count[0] += 1
|
|
3233
|
+
if call_count[0] == 1:
|
|
3234
|
+
mock_resp = MagicMock(spec=httpx.Response)
|
|
3235
|
+
mock_resp.status_code = 200
|
|
3236
|
+
mock_resp.json.return_value = [{"name": "sess1"}]
|
|
3237
|
+
mock_resp.raise_for_status.return_value = None
|
|
3238
|
+
return mock_resp
|
|
3239
|
+
raise httpx.TimeoutException("timeout", request=MagicMock())
|
|
3240
|
+
|
|
3241
|
+
mock_fed_client = MagicMock()
|
|
3242
|
+
mock_fed_client.get = mock_get_stateful
|
|
3243
|
+
monkeypatch.setattr(client.app.state, "federation_client", mock_fed_client)
|
|
3244
|
+
|
|
3245
|
+
# Call 1: success — populates cache
|
|
3246
|
+
r = client.get("/api/federation/sessions")
|
|
3247
|
+
d = r.json()
|
|
3248
|
+
assert any(s.get("name") == "sess1" for s in d), "Call 1 must return sess1"
|
|
3249
|
+
|
|
3250
|
+
# Calls 2-4 (failures 1-3): within grace period — must return cached sessions
|
|
3251
|
+
for i in range(3):
|
|
3252
|
+
r = client.get("/api/federation/sessions")
|
|
3253
|
+
d = r.json()
|
|
3254
|
+
host_entries = [s for s in d if s.get("deviceName") == "grace-host"]
|
|
3255
|
+
assert not any(s.get("status") == "unreachable" for s in host_entries), (
|
|
3256
|
+
f"Call {i + 2}: fail_count={i + 1} is within grace period — "
|
|
3257
|
+
f"must return cached sessions, not 'unreachable'. Got: {host_entries}"
|
|
3258
|
+
)
|
|
3259
|
+
|
|
3260
|
+
# Call 5 (failure 4): exceeds grace period — must return unreachable
|
|
3261
|
+
r = client.get("/api/federation/sessions")
|
|
3262
|
+
d = r.json()
|
|
3263
|
+
host_entries = [s for s in d if s.get("deviceName") == "grace-host"]
|
|
3264
|
+
assert any(s.get("status") == "unreachable" for s in host_entries), (
|
|
3265
|
+
f"After exceeding grace period, device must be marked 'unreachable'. Got: {host_entries}"
|
|
3266
|
+
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{muxplex-0.3.2 → muxplex-0.3.4}/docs/plans/2026-04-08-federation-state-propagation-design.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|