webssh2_client 0.2.31-alpha.3 → 1.0.0-alpha.1

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.
package/README.md CHANGED
@@ -1,5 +1,8 @@
1
1
  # WebSSH2 Client - Web SSH Client
2
2
 
3
+ [![CI](https://github.com/billchurch/webssh2_client/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/billchurch/webssh2_client/actions/workflows/ci.yml)
4
+ [![Release](https://github.com/billchurch/webssh2_client/actions/workflows/release.yml/badge.svg?branch=main)](https://github.com/billchurch/webssh2_client/actions/workflows/release.yml)
5
+
3
6
  ![Orthrus Mascot](images/orthrus.png)
4
7
 
5
8
  WebSSH2 Client is an HTML5 web-based terminal emulator and SSH client component. It uses WebSockets to communicate with a WebSSH2 server, which in turn uses SSH2 to connect to SSH servers.
@@ -7,9 +10,11 @@ WebSSH2 Client is an HTML5 web-based terminal emulator and SSH client component.
7
10
  ![WebSSH2 demo](https://user-images.githubusercontent.com/1668075/182425293-acc8741e-cc92-4105-afdc-9538e1685d4b.gif)
8
11
 
9
12
  # Important Notice
13
+
10
14
  This package contains only the browser-side client component of WebSSH2. It requires a compatible WebSSH2 server to function. The server component is available at [webssh2 server](https://github.com/billchurch/webssh2/tree/bigip-server). This package is intended for advanced users who want to customize or integrate the client component independently.
11
15
 
12
16
  # Status
17
+
13
18
  This is an experimental refactor of the WebSSH2 v0.2.x client to function as a standalone component. It has been separated from the server-side code to facilitate customization and integration with different frameworks.
14
19
 
15
20
  ## Requirements
@@ -21,17 +26,20 @@ This is an experimental refactor of the WebSSH2 v0.2.x client to function as a s
21
26
  ## Installation
22
27
 
23
28
  1. Clone the repository:
29
+
24
30
  ```
25
31
  git clone https://github.com/billchurch/webssh2_client.git
26
32
  cd webssh2_client
27
33
  ```
28
34
 
29
35
  2. Install dependencies:
36
+
30
37
  ```
31
38
  npm install
32
39
  ```
33
40
 
34
41
  3. Build the client:
42
+
35
43
  ```
36
44
  npm run build
37
45
  ```
@@ -65,6 +73,15 @@ For server setup instructions, refer to the [WebSSH2 server documentation](https
65
73
  - Multi-factor authentication support (when supported by server)
66
74
  - Support for credential replay and reauthentication
67
75
 
76
+ ## Security and Lint Rules
77
+
78
+ - No innerHTML: The client never uses `innerHTML` for user content. All text uses `textContent` and safe DOM building helpers.
79
+ - CSP: Strict `script-src 'self'` (no inline scripts). Inline styles allowed for xterm DOM renderer and safe color updates.
80
+ - ESLint guardrails:
81
+ - `no-unsanitized` plugin blocks unsanitized DOM sinks (`innerHTML`, `outerHTML`, `insertAdjacentHTML`, `document.write`).
82
+ - Additional bans via `no-restricted-properties` for those sinks, and `no-restricted-syntax` for string-based timers and `new Function`.
83
+ - Xterm integration: Terminal output is rendered with `xterm.write()`; no HTML rendering of remote data.
84
+
68
85
  ## Configuration
69
86
 
70
87
  The client can be configured through:
@@ -90,12 +107,12 @@ You can configure the client by setting `window.webssh2Config`:
90
107
  ```javascript
91
108
  window.webssh2Config = {
92
109
  socket: {
93
- url: null, // WebSocket URL (auto-detected if null)
94
- path: '/ssh/socket.io' // Socket.IO path
110
+ url: null, // WebSocket URL (auto-detected if null)
111
+ path: '/ssh/socket.io' // Socket.IO path
95
112
  },
96
113
  ssh: {
97
- host: null, // SSH server hostname
98
- port: 22, // SSH server port
114
+ host: null, // SSH server hostname
115
+ port: 22, // SSH server port
99
116
  username: null,
100
117
  sshterm: 'xterm-color'
101
118
  },
@@ -104,12 +121,12 @@ window.webssh2Config = {
104
121
  background: 'green'
105
122
  },
106
123
  autoConnect: false
107
- };
124
+ }
108
125
  ```
109
126
 
110
127
  ## Development
111
128
 
112
- - For development information see [DEVELOPMENT.md](./DEVELOPMENT.md).
129
+ See [DEVELOPMENT.md](./DEVELOPMENT.md).
113
130
 
114
131
  ## Support
115
132
 
@@ -125,4 +142,6 @@ This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md
125
142
 
126
143
  - [Xterm.js](https://xtermjs.org/) for terminal emulation
127
144
  - [Socket.IO](https://socket.io/) for WebSocket communication
128
- - [Webpack](https://webpack.js.org/) for module bundling
145
+ - [Vite](https://vitejs.dev/) for development and bundling
146
+ - [ESLint](https://eslint.org/) + [Prettier](https://prettier.io/) for code quality
147
+ - [lucide-static](https://github.com/lucide-icons/lucide) for SVG icons
package/client/index.js CHANGED
@@ -1,16 +1,12 @@
1
1
  // client
2
- // client/index.js
2
+ // client/index.ts
3
3
  import path from 'path';
4
4
  import { readFileSync } from 'fs';
5
5
  import { fileURLToPath } from 'url';
6
-
7
6
  const __filename = fileURLToPath(import.meta.url);
8
7
  const __dirname = path.dirname(__filename);
9
-
10
- // Read package.json synchronously for version
11
8
  const packageJson = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
12
-
13
9
  export default {
14
- getPublicPath: () => path.join(__dirname, 'public'),
15
- version: packageJson.version
16
- };
10
+ getPublicPath: () => path.join(__dirname, 'public'),
11
+ version: packageJson.version
12
+ };
@@ -1,136 +1,148 @@
1
- <!-- Version Version 0.2.31-alpha.3 - 2025-09-04T11:00:55.601Z - 3b608bc -->
1
+ <!-- Version Version 1.0.0-alpha.1 - 2025-09-05T15:41:27.852Z - 200680b -->
2
2
  <!-- webssh2-client -->
3
3
  <!-- /client/src/client.htm -->
4
4
  <!DOCTYPE html>
5
- <html>
5
+ <html lang="en" class="h-dvh">
6
6
  <head>
7
7
  <title>WebSSH2</title>
8
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content">
8
9
  <link rel="icon" type="image/x-icon" href="./favicon.ico">
9
- <script>
10
- window.webssh2Config = null;
11
- </script>
12
10
  <script type="module" crossorigin src="./webssh2.bundle.js"></script>
13
11
  <link rel="stylesheet" crossorigin href="./webssh2.css">
14
12
  </head>
15
- <body>
13
+ <body class="h-dvh overflow-hidden bg-black text-neutral-100">
16
14
  <dialog id="loginDialog" class="modal">
17
- <div class="modal-content">
18
- <h2>WebSSH2 Login</h2>
19
- <form id="loginForm" class="pure-form">
20
- <input type="text" id="hostInput" name="host" placeholder="Host" required>
21
- <input type="text" id="portInput" name="port" placeholder="Port" value="22">
22
- <input type="text" id="usernameInput" name="username" placeholder="Username" required>
23
-
15
+ <div class="modal-content relative bg-white text-slate-800 border border-neutral-300 rounded-md shadow-md p-6 w-80 sm:w-[28rem]">
16
+ <h2 class="text-lg font-semibold text-slate-900 mb-2">WebSSH2 Login</h2>
17
+ <form id="loginForm" class="space-y-3">
18
+ <label for="hostInput" class="sr-only">Host</label>
19
+ <input type="text" id="hostInput" name="host" placeholder="Host" required autocomplete="off" autocapitalize="off" spellcheck="false" enterkeyhint="next" class="block w-full rounded-md border border-slate-300 bg-white text-slate-900 placeholder-slate-400 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
20
+ <label for="portInput" class="sr-only">Port</label>
21
+ <input type="text" id="portInput" name="port" placeholder="Port" value="22" autocomplete="off" autocapitalize="off" spellcheck="false" enterkeyhint="next" inputmode="numeric" pattern="[0-9]*" class="block w-full rounded-md border border-slate-300 bg-white text-slate-900 placeholder-slate-400 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
22
+ <label for="usernameInput" class="sr-only">Username</label>
23
+ <input type="text" id="usernameInput" name="username" placeholder="Username" required autocomplete="username" autocapitalize="off" spellcheck="false" enterkeyhint="next" class="block w-full rounded-md border border-slate-300 bg-white text-slate-900 placeholder-slate-400 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
24
+
24
25
  <!-- Password section - always visible -->
25
- <div class="password-wrapper">
26
- <input type="password" id="passwordInput" name="password" placeholder="Password">
27
- <span id="capsLockIcon">⇪</span>
26
+ <div class="password-wrapper relative w-full">
27
+ <label for="passwordInput" class="sr-only">Password</label>
28
+ <input type="password" id="passwordInput" name="password" placeholder="Password" autocomplete="current-password" autocapitalize="off" spellcheck="false" enterkeyhint="go" class="block w-full rounded-md border border-slate-300 bg-white text-slate-900 placeholder-slate-400 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
29
+ <span id="capsLockIcon" class="hidden absolute right-2 top-1/2 -translate-y-1/2 text-red-500 pointer-events-none">⇪</span>
28
30
  </div>
29
-
30
- <!-- Private key toggle button -->
31
- <div class="private-key-toggle">
32
- <button type="button" id="privateKeyToggle" class="pure-button">
33
- <i data-icon="key" class="icon-fw"></i> Add SSH Key
34
- </button>
31
+
32
+ <!-- Options row: Add SSH Key + Options -->
33
+ <div class="flex items-center justify-between gap-2">
34
+ <div class="private-key-toggle">
35
+ <button type="button" id="privateKeyToggle" class="inline-flex items-center justify-center rounded-md border border-transparent px-3 py-2 text-sm font-medium bg-slate-600 text-white hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-slate-500 disabled:opacity-50 disabled:pointer-events-none shadow-sm">
36
+ <i data-icon="key" class="icon-fw"></i> Add SSH Key
37
+ </button>
38
+ </div>
39
+ <div>
40
+ <button type="button" id="loginSettingsBtn" class="inline-flex items-center justify-center rounded-md border border-transparent px-3 py-2 text-sm font-medium bg-slate-700 text-white hover:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-slate-500 disabled:opacity-50 disabled:pointer-events-none shadow-sm" aria-label="Options" title="Options">
41
+ <i data-icon="settings" class="icon-fw"></i> Options
42
+ </button>
43
+ </div>
35
44
  </div>
36
-
45
+
37
46
  <!-- Private key section (initially hidden) -->
38
47
  <div id="privateKeySection" class="hidden">
39
- <div class="private-key-input">
40
- <textarea id="privateKeyText" name="privateKey"
41
- placeholder="Paste your private key here" rows="3"></textarea>
48
+ <div class="private-key-input mt-2 p-3 rounded border border-neutral-300 bg-neutral-50 text-neutral-800">
49
+ <label for="privateKeyText" class="sr-only">Private Key</label>
50
+ <textarea id="privateKeyText" name="privateKey" autocomplete="off" autocapitalize="off" spellcheck="false"
51
+ placeholder="Paste your private key here" rows="3" class="block w-full rounded-md border border-slate-300 bg-white text-slate-900 placeholder-slate-400 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"></textarea>
42
52
  <div class="file-upload">
43
- <input type="file" id="privateKeyFile" accept=".pem,.key">
44
- <label for="privateKeyFile" class="pure-button">
53
+ <input type="file" id="privateKeyFile" class="sr-only">
54
+ <label for="privateKeyFile" class="inline-flex items-center justify-center rounded-md border border-transparent px-3 py-2 text-sm font-medium bg-slate-600 text-white hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-slate-500 disabled:opacity-50 disabled:pointer-events-none shadow-sm">
45
55
  <i data-icon="upload" class="icon-fw"></i> Upload Key File
46
56
  </label>
47
57
  </div>
48
- <input type="password" id="passphraseInput" name="passphrase"
49
- placeholder="Key password (if encrypted)" class="optional">
58
+ <label for="passphraseInput" class="sr-only">Key Passphrase</label>
59
+ <input type="password" id="passphraseInput" name="passphrase" autocomplete="off" autocapitalize="off" spellcheck="false" enterkeyhint="go"
60
+ placeholder="Key password (if encrypted)" class="optional block w-full rounded-md border border-slate-300 bg-white text-slate-900 placeholder-slate-400 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
50
61
  </div>
51
62
  </div>
52
-
53
- <div class="login-buttons">
54
- <button type="submit" class="pure-button pure-button-primary">Connect</button>
55
- <button type="button" id="loginSettingsBtn" class="pure-button" aria-label="Settings" title="Settings">
56
- <i data-icon="settings"></i>
57
- </button>
63
+
64
+ <div class="login-buttons mt-2">
65
+ <button type="submit" class="inline-flex w-full items-center justify-center rounded-md border border-transparent px-3 py-2 text-sm font-medium bg-blue-600 text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:pointer-events-none">Connect</button>
58
66
  </div>
59
67
  </form>
60
68
  </div>
61
69
  </dialog>
62
- <dialog id="errorDialog" class="modal" >
63
- <div class="modal-content error-modal">
64
- <button autofocus class="close-button">&times;</button>
65
- <h2>Error</h2>
70
+ <dialog id="errorDialog" class="modal">
71
+ <div class="modal-content relative error-modal bg-red-50 border border-red-400 rounded-md shadow-md p-5 w-80 sm:w-96">
72
+ <button type="button" autofocus class="close-button absolute top-2 right-2 text-neutral-400 hover:text-neutral-600 text-xl leading-none p-0 bg-transparent border-0">&times;</button>
73
+ <h2 class="text-red-700">Error</h2>
66
74
  <p id="errorMessage"></p>
67
75
  </div>
68
76
  </dialog>
69
77
  <dialog id="promptDialog" class="modal">
70
- <div class="modal-content prompt-modal">
71
- <button autofocus class="close-button">&times;</button>
72
- <h2 id="promptMessage"></h2>
78
+ <div class="modal-content relative prompt-modal bg-white text-slate-800 border border-neutral-300 rounded-md shadow-md p-5 w-80 sm:w-96">
79
+ <button type="button" autofocus class="close-button absolute top-2 right-2 text-neutral-400 hover:text-neutral-600 text-xl leading-none p-0 bg-transparent border-0">&times;</button>
80
+ <h2 id="promptMessage">Authentication Required</h2>
73
81
  <form>
74
- <div id="promptInputContainer"></div>
75
- <button type="submit" class="pure-button pure-button-primary">Submit</button>
82
+ <div id="promptInputContainer" class="mb-4 space-y-2"></div>
83
+ <button type="submit" class="inline-flex items-center justify-center rounded-md border border-transparent px-3 py-2 text-sm font-medium bg-blue-600 text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:pointer-events-none">Submit</button>
76
84
  </form>
77
85
  </div>
78
86
  </dialog>
79
87
  <dialog id="terminalSettingsDialog" class="modal">
80
- <div class="modal-content">
81
- <h2>Terminal Settings</h2>
82
- <form id="terminalSettingsForm" class="pure-form pure-form-stacked">
83
- <fieldset>
84
- <label for="fontSize">Font Size</label>
85
- <input type="number" id="fontSize" name="fontSize" min="8" max="72" required>
86
-
87
- <label for="fontFamily">Font Family</label>
88
- <input type="text" id="fontFamily" name="fontFamily" required>
89
-
90
- <label for="cursorBlink">Cursor Blink</label>
91
- <select id="cursorBlink" name="cursorBlink">
88
+ <div class="modal-content relative bg-white text-slate-800 border border-neutral-300 rounded-md shadow-md p-6 w-80 sm:w-[36rem]">
89
+ <h2 class="text-lg font-semibold text-slate-900 mb-2">Terminal Settings</h2>
90
+ <form id="terminalSettingsForm" class="space-y-2">
91
+ <fieldset class="grid grid-cols-1 sm:grid-cols-[auto,1fr] gap-x-4 gap-y-3 items-center">
92
+ <legend class="sr-only">Terminal Options</legend>
93
+ <label for="fontSize" class="text-sm font-medium text-slate-700 sm:text-right pr-3 whitespace-nowrap">Font Size</label>
94
+ <input type="number" id="fontSize" name="fontSize" min="8" max="72" required autocapitalize="off" spellcheck="false" class="block w-full rounded-md border border-slate-300 bg-white text-slate-900 placeholder-slate-400 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
95
+
96
+ <label for="fontFamily" class="text-sm font-medium text-slate-700 sm:text-right pr-3 whitespace-nowrap">Font Family</label>
97
+ <input type="text" id="fontFamily" name="fontFamily" required autocapitalize="off" spellcheck="false" class="block w-full rounded-md border border-slate-300 bg-white text-slate-900 placeholder-slate-400 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
98
+
99
+ <label for="cursorBlink" class="text-sm font-medium text-slate-700 sm:text-right pr-3 whitespace-nowrap">Cursor Blink</label>
100
+ <select id="cursorBlink" name="cursorBlink" class="block w-full rounded-md border border-slate-300 bg-white text-slate-900 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
92
101
  <option value="true">On</option>
93
102
  <option value="false">Off</option>
94
103
  </select>
95
-
96
- <label for="scrollback">Scrollback</label>
97
- <input type="number" id="scrollback" name="scrollback" min="1" max="200000" required>
98
-
99
- <label for="tabStopWidth">Tab Stop Width</label>
100
- <input type="number" id="tabStopWidth" name="tabStopWidth" min="1" max="100" required>
101
-
102
- <label for="bellStyle">Bell Style</label>
103
- <select id="bellStyle" name="bellStyle">
104
+
105
+ <label for="scrollback" class="text-sm font-medium text-slate-700 sm:text-right pr-3 whitespace-nowrap">Scrollback</label>
106
+ <input type="number" id="scrollback" name="scrollback" min="1" max="200000" required autocapitalize="off" spellcheck="false" class="block w-full rounded-md border border-slate-300 bg-white text-slate-900 placeholder-slate-400 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
107
+
108
+ <label for="tabStopWidth" class="text-sm font-medium text-slate-700 sm:text-right pr-3 whitespace-nowrap">Tab Stop Width</label>
109
+ <input type="number" id="tabStopWidth" name="tabStopWidth" min="1" max="100" required autocapitalize="off" spellcheck="false" class="block w-full rounded-md border border-slate-300 bg-white text-slate-900 placeholder-slate-400 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
110
+
111
+ <label for="bellStyle" class="text-sm font-medium text-slate-700 sm:text-right pr-3 whitespace-nowrap">Bell Style</label>
112
+ <select id="bellStyle" name="bellStyle" class="block w-full rounded-md border border-slate-300 bg-white text-slate-900 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
104
113
  <option value="sound">Sound</option>
105
114
  <option value="none">None</option>
106
115
  </select>
107
-
108
- <button type="submit" class="pure-button pure-button-primary">Save</button>
109
- <button type="button" id="closeterminalSettingsBtn" class="pure-button">Cancel</button>
110
116
  </fieldset>
117
+ <div class="flex gap-2 pt-4 justify-end">
118
+ <button type="submit" class="inline-flex items-center justify-center rounded-md border border-transparent px-3 py-2 text-sm font-medium bg-blue-600 text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:pointer-events-none">Save</button>
119
+ <button type="button" id="closeterminalSettingsBtn" class="inline-flex items-center justify-center rounded-md border border-transparent px-3 py-2 text-sm font-medium bg-slate-700 text-white hover:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-slate-500 disabled:opacity-50 disabled:pointer-events-none">Cancel</button>
120
+ </div>
111
121
  </form>
112
122
  </div>
113
123
  </dialog>
114
- <div id="backdrop" class="backdrop"></div>
115
- <button id="reconnectButton">Reconnect</button>
124
+ <div id="backdrop" class="backdrop hidden"></div>
125
+ <button type="button" id="reconnectButton" class="hidden fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-[1001] bg-blue-600 hover:bg-blue-700 text-white text-sm px-5 py-2 rounded shadow focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">Reconnect</button>
116
126
  <div class="box">
117
- <div id="header"></div>
118
- <div id="terminalContainer" class="terminal"></div>
119
- <div id="bottomdiv">
120
- <div class="dropup" id="menu">
121
- <i data-icon="menu" class="icon-fw"></i> Menu
122
- <div id="dropupContent">
123
- <button id="clearLogBtn" class="menu-button"><i data-icon="trash-can" class="icon-fw"></i> Clear Log</button>
124
- <button id="stopLogBtn" class="menu-button"><i data-icon="settings" class="icon-spin icon-fw"></i> Stop Log</button>
125
- <button id="startLogBtn" class="menu-button"><i data-icon="clipboard" class="icon-fw"></i> Start Log</button>
126
- <button id="downloadLogBtn" class="menu-button"><i data-icon="download" class="icon-fw"></i> Download Log</button>
127
- <button id="replayCredentialsBtn" class="menu-button"><i data-icon="key" class="icon-fw"></i> Credentials</button>
128
- <button id="reauthBtn" class="menu-button"><i data-icon="key" class="icon-fw"></i> Switch User</button>
129
- <button id="terminalSettingsBtn" class="menu-button"><i data-icon="settings" class="icon-fw"></i> Settings</button>
127
+ <div id="header" class="hidden w-full text-center z-[99] h-6 leading-6 bg-green-600 text-white border-b border-neutral-200 shrink-0"></div>
128
+ <div id="terminalContainer" class="terminal hidden flex-1 min-h-0 w-[calc(100%-1px)] max-w-[100vw] mx-auto p-[2px] pb-[env(safe-area-inset-bottom)] overscroll-contain touch-pan-y overflow-hidden"></div>
129
+ <div id="bottomdiv" class="z-[99] h-6 flex items-center bg-neutral-800 text-neutral-100 border-t border-neutral-200 shrink-0">
130
+ <div id="menu" class="relative group px-2">
131
+ <button id="menuToggle" type="button" aria-controls="dropupContent" aria-expanded="false" class="inline-flex items-center gap-1 select-none">
132
+ <i data-icon="menu" class="w-5 h-5 inline-block"></i> Menu
133
+ </button>
134
+ <div id="dropupContent" class="hidden group-hover:block absolute bottom-full left-0 min-w-56 bg-neutral-50 text-neutral-700 text-base shadow-md border border-neutral-200 z-[101]">
135
+ <button type="button" id="clearLogBtn" class="hidden w-full text-left px-4 py-3 hover:bg-neutral-200 whitespace-nowrap inline-flex items-center gap-3"><i data-icon="trash-can" class="w-5 h-5 inline-block"></i> Clear Log</button>
136
+ <button type="button" id="stopLogBtn" class="hidden w-full text-left px-4 py-3 hover:bg-neutral-200 whitespace-nowrap inline-flex items-center gap-3"><i data-icon="settings" class="animate-spin origin-center w-5 h-5 inline-block"></i> Stop Log</button>
137
+ <button type="button" id="startLogBtn" class="w-full text-left px-4 py-3 hover:bg-neutral-200 whitespace-nowrap inline-flex items-center gap-3"><i data-icon="clipboard" class="w-5 h-5 inline-block"></i> Start Log</button>
138
+ <button type="button" id="downloadLogBtn" class="hidden w-full text-left px-4 py-3 hover:bg-neutral-200 whitespace-nowrap inline-flex items-center gap-3"><i data-icon="download" class="w-5 h-5 inline-block"></i> Download Log</button>
139
+ <button type="button" id="replayCredentialsBtn" class="w-full text-left px-4 py-3 hover:bg-neutral-200 whitespace-nowrap inline-flex items-center gap-3"><i data-icon="key" class="w-5 h-5 inline-block"></i> Credentials</button>
140
+ <button type="button" id="reauthBtn" class="w-full text-left px-4 py-3 hover:bg-neutral-200 whitespace-nowrap inline-flex items-center gap-3"><i data-icon="key" class="w-5 h-5 inline-block"></i> Switch User</button>
141
+ <button type="button" id="terminalSettingsBtn" class="w-full text-left px-4 py-3 hover:bg-neutral-200 whitespace-nowrap inline-flex items-center gap-3"><i data-icon="settings" class="w-5 h-5 inline-block"></i> Settings</button>
130
142
  </div>
131
143
  </div>
132
- <div id="footer"></div>
133
- <div id="status"></div>
144
+ <div id="footer" class="inline-block text-left px-[10px] border-l border-neutral-200"></div>
145
+ <div id="status" role="status" aria-live="polite" aria-atomic="true" class="inline-block text-left px-[10px] z-[100] border-x border-neutral-200"></div>
134
146
  </div>
135
147
  </div>
136
148
  </body>