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.
Files changed (134) hide show
  1. {muxplex-0.3.2 → muxplex-0.3.4}/CHANGELOG.md +14 -0
  2. {muxplex-0.3.2 → muxplex-0.3.4}/PKG-INFO +1 -1
  3. {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/frontend/app.js +10 -6
  4. {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/frontend/style.css +5 -0
  5. {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/frontend/terminal.js +7 -5
  6. {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/frontend/tests/test_app.mjs +107 -2
  7. {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/main.py +37 -2
  8. {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/tests/test_api.py +228 -0
  9. {muxplex-0.3.2 → muxplex-0.3.4}/pyproject.toml +1 -1
  10. {muxplex-0.3.2 → muxplex-0.3.4}/.github/workflows/ci.yml +0 -0
  11. {muxplex-0.3.2 → muxplex-0.3.4}/.github/workflows/publish.yml +0 -0
  12. {muxplex-0.3.2 → muxplex-0.3.4}/.gitignore +0 -0
  13. {muxplex-0.3.2 → muxplex-0.3.4}/README.md +0 -0
  14. {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/DESIGN-SYSTEM.md +0 -0
  15. {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/README.md +0 -0
  16. {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/favicons/apple-touch-icon.png +0 -0
  17. {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/favicons/favicon-16.png +0 -0
  18. {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/favicons/favicon-32.png +0 -0
  19. {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/favicons/favicon-48.png +0 -0
  20. {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/favicons/favicon.ico +0 -0
  21. {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/icons/muxplex-icon-1024.png +0 -0
  22. {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/icons/muxplex-icon-128.png +0 -0
  23. {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/icons/muxplex-icon-16.png +0 -0
  24. {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/icons/muxplex-icon-192.png +0 -0
  25. {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/icons/muxplex-icon-22.png +0 -0
  26. {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/icons/muxplex-icon-24.png +0 -0
  27. {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/icons/muxplex-icon-256.png +0 -0
  28. {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/icons/muxplex-icon-32.png +0 -0
  29. {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/icons/muxplex-icon-48.png +0 -0
  30. {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/icons/muxplex-icon-512.png +0 -0
  31. {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/icons/muxplex-icon-64.png +0 -0
  32. {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/lockup/lockup-on-dark-32.png +0 -0
  33. {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/lockup/lockup-on-dark-64.png +0 -0
  34. {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/lockup/lockup-on-light-32.png +0 -0
  35. {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/lockup/lockup-on-light-64.png +0 -0
  36. {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/og/og-dark.png +0 -0
  37. {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/og/og-light.png +0 -0
  38. {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/pwa/pwa-192.png +0 -0
  39. {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/pwa/pwa-512.png +0 -0
  40. {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/svg/icon/muxplex-icon-dark.svg +0 -0
  41. {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/svg/icon/muxplex-icon-light.svg +0 -0
  42. {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/svg/lockup/lockup-on-dark.svg +0 -0
  43. {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/svg/lockup/lockup-on-light.svg +0 -0
  44. {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/svg/wordmark/wordmark-on-dark.svg +0 -0
  45. {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/svg/wordmark/wordmark-on-light.svg +0 -0
  46. {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/tokens.css +0 -0
  47. {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/tokens.json +0 -0
  48. {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/wordmark/wordmark-on-dark-32.png +0 -0
  49. {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/wordmark/wordmark-on-dark-64.png +0 -0
  50. {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/wordmark/wordmark-on-light-32.png +0 -0
  51. {muxplex-0.3.2 → muxplex-0.3.4}/assets/branding/wordmark/wordmark-on-light-64.png +0 -0
  52. {muxplex-0.3.2 → muxplex-0.3.4}/docs/how-we-built-the-muxplex-brand.md +0 -0
  53. {muxplex-0.3.2 → muxplex-0.3.4}/docs/plans/2026-03-26-web-tmux-dashboard-design.md +0 -0
  54. {muxplex-0.3.2 → muxplex-0.3.4}/docs/plans/2026-03-26-web-tmux-phase1-backend.md +0 -0
  55. {muxplex-0.3.2 → muxplex-0.3.4}/docs/plans/2026-03-26-web-tmux-phase2a-frontend-static.md +0 -0
  56. {muxplex-0.3.2 → muxplex-0.3.4}/docs/plans/2026-03-26-web-tmux-phase2b-javascript.md +0 -0
  57. {muxplex-0.3.2 → muxplex-0.3.4}/docs/plans/2026-03-27-session-sidebar-design.md +0 -0
  58. {muxplex-0.3.2 → muxplex-0.3.4}/docs/plans/2026-03-27-session-sidebar-implementation.md +0 -0
  59. {muxplex-0.3.2 → muxplex-0.3.4}/docs/plans/2026-03-28-auth-design.md +0 -0
  60. {muxplex-0.3.2 → muxplex-0.3.4}/docs/plans/2026-03-28-auth-phase1-infrastructure.md +0 -0
  61. {muxplex-0.3.2 → muxplex-0.3.4}/docs/plans/2026-03-28-auth-phase2-ui-cli.md +0 -0
  62. {muxplex-0.3.2 → muxplex-0.3.4}/docs/plans/2026-03-28-packaging-for-distribution.md +0 -0
  63. {muxplex-0.3.2 → muxplex-0.3.4}/docs/plans/2026-03-29-settings-design.md +0 -0
  64. {muxplex-0.3.2 → muxplex-0.3.4}/docs/plans/2026-03-29-settings-phase1.md +0 -0
  65. {muxplex-0.3.2 → muxplex-0.3.4}/docs/plans/2026-03-29-settings-phase2.md +0 -0
  66. {muxplex-0.3.2 → muxplex-0.3.4}/docs/plans/2026-03-30-multi-device-federation-design.md +0 -0
  67. {muxplex-0.3.2 → muxplex-0.3.4}/docs/plans/2026-03-30-multi-device-federation-phase1.md +0 -0
  68. {muxplex-0.3.2 → muxplex-0.3.4}/docs/plans/2026-03-30-multi-device-federation-phase2.md +0 -0
  69. {muxplex-0.3.2 → muxplex-0.3.4}/docs/plans/2026-03-30-multi-device-federation-phase3.md +0 -0
  70. {muxplex-0.3.2 → muxplex-0.3.4}/docs/plans/2026-03-31-cli-phase1-config-serve.md +0 -0
  71. {muxplex-0.3.2 → muxplex-0.3.4}/docs/plans/2026-03-31-cli-phase2-service-commands.md +0 -0
  72. {muxplex-0.3.2 → muxplex-0.3.4}/docs/plans/2026-03-31-cli-service-refactor-design.md +0 -0
  73. {muxplex-0.3.2 → muxplex-0.3.4}/docs/plans/2026-04-03-tls-phase1-foundation.md +0 -0
  74. {muxplex-0.3.2 → muxplex-0.3.4}/docs/plans/2026-04-03-tls-phase2-autodetect.md +0 -0
  75. {muxplex-0.3.2 → muxplex-0.3.4}/docs/plans/2026-04-03-tls-setup-design.md +0 -0
  76. {muxplex-0.3.2 → muxplex-0.3.4}/docs/plans/2026-04-04-device-selector.md +0 -0
  77. {muxplex-0.3.2 → muxplex-0.3.4}/docs/plans/2026-04-04-tls-nudge-design.md +0 -0
  78. {muxplex-0.3.2 → muxplex-0.3.4}/docs/plans/2026-04-08-federation-state-propagation-design.md +0 -0
  79. {muxplex-0.3.2 → muxplex-0.3.4}/docs/plans/2026-04-08-federation-state-propagation-plan.md +0 -0
  80. {muxplex-0.3.2 → muxplex-0.3.4}/docs/plans/2026-04-08-server-side-settings-design.md +0 -0
  81. {muxplex-0.3.2 → muxplex-0.3.4}/docs/plans/2026-04-08-server-side-settings-plan.md +0 -0
  82. {muxplex-0.3.2 → muxplex-0.3.4}/docs/plans/README.md +0 -0
  83. {muxplex-0.3.2 → muxplex-0.3.4}/docs/screenshots/settings-commands.png +0 -0
  84. {muxplex-0.3.2 → muxplex-0.3.4}/docs/screenshots/settings-display.png +0 -0
  85. {muxplex-0.3.2 → muxplex-0.3.4}/docs/screenshots/settings-multidevice.png +0 -0
  86. {muxplex-0.3.2 → muxplex-0.3.4}/docs/screenshots/settings-sessions.png +0 -0
  87. {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/__init__.py +0 -0
  88. {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/__main__.py +0 -0
  89. {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/auth.py +0 -0
  90. {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/bells.py +0 -0
  91. {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/cli.py +0 -0
  92. {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/frontend/apple-touch-icon.png +0 -0
  93. {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/frontend/favicon-32.png +0 -0
  94. {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/frontend/favicon.ico +0 -0
  95. {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/frontend/index.html +0 -0
  96. {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/frontend/login.html +0 -0
  97. {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/frontend/manifest.json +0 -0
  98. {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/frontend/pwa-192.png +0 -0
  99. {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/frontend/pwa-512.png +0 -0
  100. {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/frontend/tests/test_terminal.mjs +0 -0
  101. {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/frontend/vendor/addon-image.js +0 -0
  102. {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/frontend/vendor/xterm-addon-fit.js +0 -0
  103. {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/frontend/vendor/xterm-addon-search.js +0 -0
  104. {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/frontend/vendor/xterm-addon-web-links.js +0 -0
  105. {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/frontend/vendor/xterm.css +0 -0
  106. {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/frontend/vendor/xterm.js +0 -0
  107. {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/frontend/wordmark-on-dark.svg +0 -0
  108. {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/service.py +0 -0
  109. {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/sessions.py +0 -0
  110. {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/settings.py +0 -0
  111. {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/state.py +0 -0
  112. {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/tests/__init__.py +0 -0
  113. {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/tests/test_auth.py +0 -0
  114. {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/tests/test_bells.py +0 -0
  115. {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/tests/test_cli.py +0 -0
  116. {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/tests/test_frontend_css.py +0 -0
  117. {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/tests/test_frontend_html.py +0 -0
  118. {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/tests/test_frontend_js.py +0 -0
  119. {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/tests/test_integration.py +0 -0
  120. {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/tests/test_readme.py +0 -0
  121. {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/tests/test_service.py +0 -0
  122. {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/tests/test_sessions.py +0 -0
  123. {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/tests/test_settings.py +0 -0
  124. {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/tests/test_settings_sync_poll.py +0 -0
  125. {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/tests/test_state.py +0 -0
  126. {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/tests/test_tls.py +0 -0
  127. {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/tests/test_ttyd.py +0 -0
  128. {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/tests/test_ws_proxy.py +0 -0
  129. {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/tls.py +0 -0
  130. {muxplex-0.3.2 → muxplex-0.3.4}/muxplex/ttyd.py +0 -0
  131. {muxplex-0.3.2 → muxplex-0.3.4}/pyrightconfig.json +0 -0
  132. {muxplex-0.3.2 → muxplex-0.3.4}/scripts/render-brand-assets.py +0 -0
  133. {muxplex-0.3.2 → muxplex-0.3.4}/scripts/spike_bell_flag.py +0 -0
  134. {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.2
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.name, 'Auth required', 'auth');
762
- else if (session.status === 'unreachable') statusTilesHtml += buildStatusTileHTML(session.name, 'Offline', 'offline');
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 unreachable sessions
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.name, 'Auth required', 'auth');
805
- else if (session.status === 'unreachable') statusTilesHtml += buildStatusTileHTML(session.name, 'Offline', 'offline');
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>' +
@@ -1002,6 +1002,11 @@ body {
1002
1002
  border-style: dashed;
1003
1003
  }
1004
1004
 
1005
+ .source-tile--empty {
1006
+ opacity: 0.6;
1007
+ border-style: dashed;
1008
+ }
1009
+
1005
1010
  .source-tile__name {
1006
1011
  font-size: 15px;
1007
1012
  font-weight: 600;
@@ -534,13 +534,15 @@ function setTerminalFontSize(size) {
534
534
  window._setTerminalFontSize = setTerminalFontSize;
535
535
 
536
536
  // ---------------------------------------------------------------------------
537
- // Android touch scroll — rAF-batched WheelEvent dispatch
538
- // Android batches touchmove events irregularly; dispatching one WheelEvent
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
- // UA-gated: iOS and macOS are unaffected (they use mouse wheel natively).
540
+ // Applies to Android, iOS, and iPadOS touch devices.
541
541
  // ---------------------------------------------------------------------------
542
- ;(function initAndroidTerminalScroll() {
543
- if (!/Android/i.test(navigator.userAgent)) return;
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
- { name: 'Workstation', status: 'auth_failed' },
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
- { name: 'Dev Server', status: 'unreachable' },
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
- return [
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
+ )
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "muxplex"
7
- version = "0.3.2"
7
+ version = "0.3.4"
8
8
  description = "Web-based tmux session dashboard — access all your tmux sessions from any browser"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
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