@flame0510/project-aether 1.7.0 ā 1.9.0
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 +3 -2
- package/app/agents/PageClient.tsx +3 -0
- package/app/agents/create/loading.tsx +51 -0
- package/app/agents/loading.tsx +2 -11
- package/app/api/assistant/route.ts +9 -2
- package/app/api/metrics/route.ts +126 -23
- package/app/api/system-health/route.ts +24 -20
- package/app/components/Sidebar.tsx +10 -0
- package/app/components/Skeleton.tsx +49 -1
- package/app/components/SystemCockpit.tsx +82 -2
- package/app/components/ui/Meter.tsx +34 -0
- package/app/components/ui/TimeSeriesChart.tsx +226 -0
- package/app/components/ui/index.ts +2 -0
- package/app/config/loading.tsx +4 -9
- package/app/containers/ContainersClient.tsx +4 -2
- package/app/containers/loading.tsx +2 -18
- package/app/containers/terminal/[id]/TerminalClient.tsx +230 -276
- package/app/containers/terminal/[id]/loading.tsx +20 -0
- package/app/crons/loading.tsx +4 -9
- package/app/gateway/loading.tsx +3 -7
- package/app/globals.css +51 -0
- package/app/lineage/loading.tsx +4 -9
- package/app/memory/loading.tsx +4 -9
- package/app/plugins/loading.tsx +4 -9
- package/app/skills/loading.tsx +4 -9
- package/app/system/PageClient.tsx +263 -0
- package/app/system/SystemSkeleton.tsx +115 -0
- package/app/system/loading.tsx +13 -0
- package/app/system/page.tsx +5 -0
- package/app/tools/loading.tsx +4 -9
- package/bin/rev4a.js +8 -4
- package/daemon.js +271 -190
- package/docs/ARCHITECTURE.md +49 -46
- package/docs/CONTAINER-TERMINAL.md +169 -261
- package/docs/DESIGN-SYSTEM.md +1 -0
- package/docs/FRONTEND-ARCHITECTURE.md +7 -2
- package/docs/REV4A.md +5 -3
- package/docs/dev/API-REFERENCE.md +61 -16
- package/docs/dev/DATABASE.md +65 -25
- package/docs/rag/DATA-FRESHNESS.md +13 -8
- package/docs/rag/GLOSSARY.md +7 -4
- package/docs/rag/REV4A-OVERVIEW.md +5 -2
- package/docs/rag/WHAT-I-CAN-ANSWER.md +1 -0
- package/lib/db-bootstrap.mjs +0 -11
- package/lib/metrics-db.ts +48 -0
- package/package.json +5 -1
- package/scripts/backup.sh +3 -3
- package/server.js +183 -0
- package/terminal-ws-server.js +329 -96
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
|
|
73
73
|
**Credentials vault:** The `/credentials` page stores third-party service tokens in `credentials.db` and installs them into containers on explicit sync. Provider API keys live separately in `provider-keys.json`, managed from the Gateway UI.
|
|
74
74
|
|
|
75
|
-
**Container management UI:** The `/containers` page lists all
|
|
75
|
+
**Container management UI:** The `/containers` page lists all Docker containers, running or stopped, with status, image, IP and ports, and opens a web terminal into any running one. Fully functional. (Machine CPU, RAM and disk are on `/system`.)
|
|
76
76
|
|
|
77
77
|
**Workspace API:** Lazy-loaded file tree explorer with real-time reads (no caching), supports both host and container workspaces via `docker exec`.
|
|
78
78
|
|
|
@@ -81,7 +81,7 @@
|
|
|
81
81
|
| Feature | Status | Notes |
|
|
82
82
|
|---|---|---|
|
|
83
83
|
| Credentials (UI + API) | š¢ Implemented | SQLite, no encryption yet |
|
|
84
|
-
| Container page | š¢ Functional | Lists all containers,
|
|
84
|
+
| Container page | š¢ Functional | Lists all containers, web terminal (xterm.js, no proxy needed) |
|
|
85
85
|
| Agent creation wizard | š¢ Implemented | One-click create with model/template selection |
|
|
86
86
|
| Provider proxy | š¢ Implemented | Agents route through Rev4a provider gateway |
|
|
87
87
|
| Shared volumes | š¢ Implemented | Skills + repos mounted on all agents |
|
|
@@ -575,15 +575,16 @@ The Rev4a daemon (`daemon.js`) is a standalone Node.js process that bridges the
|
|
|
575
575
|
1. Poll `openclaw sessions --json --all-agents` every 30 s
|
|
576
576
|
2. Upsert session rows into `events.db`
|
|
577
577
|
3. Emit `spawn` / `complete` / `fail` / `spawn_timeout` events into the `events` table
|
|
578
|
-
4.
|
|
579
|
-
|
|
580
|
-
|
|
578
|
+
4. Sample the host machine (CPU, RAM, swap, storage) every 30 s into `metrics.db`, on a
|
|
579
|
+
timer of its own
|
|
580
|
+
5. Detect anomalies (CPU > 85%, RAM > 90%, a disk > 90%) and record them as events
|
|
581
|
+
6. Manage DB lifecycle (WAL mode; events.db checkpointed after each poll cycle)
|
|
581
582
|
|
|
582
583
|
### Poll Interval
|
|
583
584
|
|
|
584
585
|
A fixed 30 s timer, whether or not sessions are working. When the OpenClaw CLI is not on
|
|
585
|
-
the host, the
|
|
586
|
-
|
|
586
|
+
the host, the session poll logs it once and stays idle; the machine metrics keep being
|
|
587
|
+
sampled, since they have their own timer.
|
|
587
588
|
|
|
588
589
|
### Cost Estimation
|
|
589
590
|
|
|
@@ -625,29 +626,41 @@ The daemon uses `INSERT ⦠ON CONFLICT DO UPDATE` with these rules:
|
|
|
625
626
|
|
|
626
627
|
### System Metrics
|
|
627
628
|
|
|
628
|
-
|
|
629
|
+
Every 30 s (`METRICS_INTERVAL_MS`) the daemon samples the machine it runs on and writes a
|
|
630
|
+
`system_metrics` row plus one `system_disks` row per filesystem into `metrics.db` ā its
|
|
631
|
+
own database next to `events.db` (schema in `docs/dev/DATABASE.md`). Machine-wide sources:
|
|
632
|
+
|
|
633
|
+
- **CPU**: busy share of all cores since the previous sample, from host ticks
|
|
634
|
+
(`os.cpus()`, i.e. `/proc/stat` on Linux) ā the whole machine, not Rev4a's cgroup
|
|
635
|
+
- **RAM / swap**: `/proc/meminfo` on Linux, used = `MemTotal ā MemAvailable` (what `free`
|
|
636
|
+
shows); on macOS (development) active + wired + compressed pages from `vm_stat` and
|
|
637
|
+
`vm.swapusage`; elsewhere `os.totalmem()` / `os.freemem()` and no swap
|
|
638
|
+
- **Storage**: `fs.statfsSync()` on `/`, Docker's data root (`docker info`, asked once and
|
|
639
|
+
retried every 10 min while Docker is down) and Rev4a's data directory, each device once;
|
|
640
|
+
a path that cannot be read (Docker Desktop's root inside its VM) is skipped
|
|
641
|
+
- **Load**: `os.loadavg()` 1 / 5 / 15 min
|
|
642
|
+
|
|
643
|
+
The first sample comes about 5 s after start (the CPU figure needs a delta from the baseline
|
|
644
|
+
taken at load), then one every 30 s. Samples older than 30 days are pruned at every sample.
|
|
645
|
+
`metrics.db` is opened in a try: if it cannot be (corrupt, unwritable), sampling is off ā
|
|
646
|
+
`[METRICS] disabled` in the log ā and the session poll keeps running. The API side reads
|
|
647
|
+
it through `lib/metrics-db.ts` (read-only, `null` when there is nothing to read);
|
|
648
|
+
`GET /api/metrics` serves the System page (`/system`) and the dashboard's Machine card.
|
|
629
649
|
|
|
630
|
-
|
|
631
|
-
- **RAM**: `os.totalmem()` / `os.freemem()`
|
|
632
|
-
- **Disk**: `fs.statfsSync()` on the directory holding the events database ā the
|
|
633
|
-
filesystem Rev4a's own data lives on; works the same on Linux and macOS
|
|
634
|
-
- **Load**: `os.loadavg()[0]`
|
|
650
|
+
### Anomaly Detection
|
|
635
651
|
|
|
636
|
-
|
|
652
|
+
After each sample the daemon inserts a `system_anomaly` event into `events.db`
|
|
653
|
+
(`{ metric, values, threshold, message }`) and logs `[ANOMALY] <message>` when:
|
|
637
654
|
|
|
638
|
-
|
|
655
|
+
| Metric | Condition | Repeats |
|
|
656
|
+
|---|---|---|
|
|
657
|
+
| CPU | > 85% on two consecutive samples | at most once per 5 min |
|
|
658
|
+
| RAM | > 90% on two consecutive samples | at most once per 5 min |
|
|
659
|
+
| Disk (each filesystem) | ā„ 90% | once when it reaches it, no cooldown ā again only after it drops back under, or after the daemon restarts |
|
|
639
660
|
|
|
640
|
-
|
|
641
|
-
cooldown (5 min per metric) has passed, it inserts a `system_anomaly` event
|
|
642
|
-
(`{ metric, values, threshold, message }`) and logs `[ANOMALY] CPU high: N%` or
|
|
643
|
-
`[ANOMALY] RAM high: N%`. No feature consumes `system_anomaly` events yet; they only pass through the generic
|
|
661
|
+
No feature consumes `system_anomaly` events yet; they only pass through the generic
|
|
644
662
|
event feeds.
|
|
645
663
|
|
|
646
|
-
| Metric | Threshold (two consecutive samples) |
|
|
647
|
-
|---|---|
|
|
648
|
-
| CPU | > 85% |
|
|
649
|
-
| RAM | > 90% |
|
|
650
|
-
|
|
651
664
|
### DB Safety
|
|
652
665
|
|
|
653
666
|
- WAL mode + `PRAGMA synchronous = NORMAL` for concurrent read safety
|
|
@@ -736,39 +749,29 @@ ALTER TABLE sessions ADD COLUMN ended_at INTEGER;
|
|
|
736
749
|
|
|
737
750
|
## 11. Container Terminal
|
|
738
751
|
|
|
739
|
-
Rev4a provides a real, interactive terminal
|
|
740
|
-
|
|
752
|
+
Rev4a provides a real, interactive terminal (xterm.js in the browser, a PTY via
|
|
753
|
+
`node-pty` on the host) for any running container. Details in
|
|
741
754
|
[docs/CONTAINER-TERMINAL.md](CONTAINER-TERMINAL.md).
|
|
742
755
|
|
|
743
|
-
### Two-process architecture
|
|
744
|
-
|
|
745
756
|
| Process | Port | Role |
|
|
746
757
|
|---|---|---|
|
|
747
|
-
|
|
|
748
|
-
| `terminal-ws-server.js` | 3741 (127.0.0.1) |
|
|
749
|
-
|
|
750
|
-
Both are started by `rev4a serve`; the terminal server is a process of its own to avoid
|
|
751
|
-
event-loop contention with Next.js during high-throughput I/O. It has no authentication
|
|
752
|
-
of its own and is reachable only through a reverse proxy route (see
|
|
753
|
-
[CONTAINER-TERMINAL.md](CONTAINER-TERMINAL.md#routing)).
|
|
754
|
-
|
|
755
|
-
### Why custom DOM over xterm.js
|
|
756
|
-
|
|
757
|
-
The initial implementation used xterm.js (both canvas and DOM renderers).
|
|
758
|
-
Both suffered from black-screen-on-large-output and broken scrolling due to
|
|
759
|
-
CSS conflicts. The current implementation uses a plain `<div>` with native
|
|
760
|
-
browser scrolling and a hidden `<textarea>` for input ā stable under any
|
|
761
|
-
output volume.
|
|
758
|
+
| `server.js` | 3740 | Next.js (pages, API) plus the WebSocket upgrade on `/api/terminal-ws` |
|
|
759
|
+
| `terminal-ws-server.js` | 3741 (127.0.0.1) | The PTYs and their sessions |
|
|
762
760
|
|
|
763
|
-
|
|
764
|
-
|
|
761
|
+
Both are started by `rev4a serve`. The browser opens the terminal on the dashboard's own
|
|
762
|
+
port, so no reverse proxy is needed. `server.js` checks the session cookie, the Origin and
|
|
763
|
+
the container name, then forwards the handshake with a per-boot secret the terminal server
|
|
764
|
+
requires; the terminal server is a process of its own so heavy shell output never stalls
|
|
765
|
+
the dashboard. A shell survives a dropped connection for 2 minutes and is resumed by the
|
|
766
|
+
same session id.
|
|
765
767
|
|
|
766
768
|
## 12. Technology Stack
|
|
767
769
|
|
|
768
770
|
| Layer | Technology | Notes |
|
|
769
771
|
|---|---|---|
|
|
770
772
|
| Orchestrator | Docker / Docker Compose | Already in use |
|
|
771
|
-
| Dashboard | Next.js 16 + React 19 | Current Rev4a |
|
|
773
|
+
| Dashboard | Next.js 16 + React 19 | Current Rev4a, served by `server.js` (Next's programmatic API) |
|
|
774
|
+
| Container terminal | xterm.js 6 (browser) + node-pty + `ws` | `docker exec` PTYs, sessions resumable for 2 minutes ā `docs/CONTAINER-TERMINAL.md` |
|
|
772
775
|
| Provider Gateway | Express/Fastify (Node.js) | To implement in Rev4a |
|
|
773
776
|
| Database | SQLite (WAL mode) | Current `events.db`, may need to scale |
|
|
774
777
|
| Memory | Per-agent SQLite + central index | New |
|
|
@@ -1,289 +1,197 @@
|
|
|
1
1
|
# Container Terminal ā WebSocket PTY
|
|
2
2
|
|
|
3
3
|
> **Status:** Active ā `main` branch
|
|
4
|
-
> **Last updated:** 2026-
|
|
4
|
+
> **Last updated:** 2026-09-26
|
|
5
5
|
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
## Overview
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
`/containers/terminal/<name>` opens an interactive shell in a running Docker container ā
|
|
11
|
+
any container on the host (the Containers page links every running one). It is a real
|
|
12
|
+
terminal: raw keystrokes, Tab completion and bash history, Ctrl-keys, cursor movement,
|
|
13
|
+
full-screen programs (`top`, `vi`, `less`), resize, progress bars.
|
|
13
14
|
|
|
14
15
|
```
|
|
15
|
-
Browser (xterm
|
|
16
|
-
ā
|
|
16
|
+
Browser xterm.js (@xterm/xterm)
|
|
17
|
+
ā ws(s)://<dashboard host>/api/terminal-ws?id=<container>&session=<uuid>[&resume=1]
|
|
17
18
|
ā¼
|
|
18
|
-
|
|
19
|
-
ā
|
|
19
|
+
server.js (port 3740 ā the dashboard's own port)
|
|
20
|
+
ā checks the Origin, the session cookie and the container name,
|
|
21
|
+
ā then forwards the handshake with the per-boot secret (no cookie)
|
|
20
22
|
ā¼
|
|
21
|
-
|
|
22
|
-
ā
|
|
23
|
+
terminal-ws-server.js (127.0.0.1:3741 only)
|
|
24
|
+
ā node-pty
|
|
23
25
|
ā¼
|
|
24
|
-
|
|
25
|
-
```
|
|
26
|
-
|
|
27
|
-
## Architecture
|
|
28
|
-
|
|
29
|
-
### Two-process model
|
|
30
|
-
|
|
31
|
-
| Process | Port | Role |
|
|
32
|
-
|------------------|-------|------|
|
|
33
|
-
| Next.js (`next start`) | 3740 | Next.js app (pages, API routes, auth) |
|
|
34
|
-
| `terminal-ws-server.js` | 3741 | Standalone WebSocket server for terminal sessions, bound to 127.0.0.1 |
|
|
35
|
-
|
|
36
|
-
Both are child processes of `rev4a serve` (with `daemon.js`), under the single
|
|
37
|
-
`rev4a.service` unit in production. The terminal server is its own process, isolated from
|
|
38
|
-
the Next.js app to avoid event-loop contention during high-throughput I/O (fast
|
|
39
|
-
`seq`, `cat` on large files, interactive shell sessions).
|
|
40
|
-
|
|
41
|
-
### Routing
|
|
42
|
-
|
|
26
|
+
docker exec -it -e REV4A_TERMINAL_SESSION=<uuid> <container> env TERM=xterm-256color <bash | sh>
|
|
43
27
|
```
|
|
44
|
-
/containers/terminal/{id} ā Next.js (page serving TerminalClient)
|
|
45
|
-
/api/terminal-ws?id={containerId} ā reverse proxy ā ws://127.0.0.1:3741
|
|
46
|
-
```
|
|
47
|
-
|
|
48
|
-
The browser opens the socket on the dashboard's own host (`/api/terminal-ws`). Next.js
|
|
49
|
-
has no route for that path, so the terminal works only when a reverse proxy in front of
|
|
50
|
-
Rev4a forwards it to port 3741; without one the socket gets a 404.
|
|
51
|
-
|
|
52
|
-
**No authentication of its own.** `terminal-ws-server.js` checks neither the session
|
|
53
|
-
cookie nor the token: whoever reaches it gets a shell in the container named by `id`.
|
|
54
|
-
It listens on 127.0.0.1 only, so the exposure is exactly what the proxy route above
|
|
55
|
-
opens. An open issue.
|
|
56
|
-
|
|
57
|
-
### Data flow
|
|
58
|
-
|
|
59
|
-
1. User opens `/containers/terminal/openclaw-atlas`
|
|
60
|
-
2. `page.tsx` renders `<TerminalClient containerId="openclaw-atlas" />`
|
|
61
|
-
3. `TerminalClient` opens a WebSocket to `/api/terminal-ws?id=openclaw-atlas`
|
|
62
|
-
4. `terminal-ws-server.js` receives connection, spawns a PTY via node-pty:
|
|
63
|
-
```
|
|
64
|
-
docker exec -it openclaw-atlas env TERM=xterm-256color bash
|
|
65
|
-
```
|
|
66
|
-
5. PTY output streams ā WebSocket ā browser (DOM terminal)
|
|
67
|
-
6. User keystrokes ā WebSocket ā PTY stdin
|
|
68
28
|
|
|
69
|
-
|
|
29
|
+
It needs **no reverse proxy**: the browser talks to the dashboard's own host and port,
|
|
30
|
+
locally or over a plain-IP VPS. Behind a proxy, see [Reverse proxies](#reverse-proxies).
|
|
70
31
|
|
|
71
|
-
|
|
32
|
+
## Processes
|
|
72
33
|
|
|
73
|
-
|
|
74
|
-
**DOM** renderers. Both suffered from:
|
|
34
|
+
`rev4a serve` starts three processes under one systemd unit (`rev4a.service`):
|
|
75
35
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
wheel scrolling often failed or produced visual artifacts.
|
|
82
|
-
- **CSS conflicts** ā the parent app sets `html, body { overflow: hidden }`,
|
|
83
|
-
which interfered with xterm.js viewport calculation. Multiple workarounds
|
|
84
|
-
(`position: fixed`, `minHeight: 0`, `height: 100dvh`) didn't fully resolve
|
|
85
|
-
layout instability.
|
|
86
|
-
- **Selection issues** ā xterm.js intercepts mouse events for its own
|
|
87
|
-
selection system, making it hard to copy text natively.
|
|
88
|
-
|
|
89
|
-
### Custom DOM terminal (current)
|
|
90
|
-
|
|
91
|
-
The current terminal replaces xterm.js entirely with a plain HTML structure:
|
|
92
|
-
|
|
93
|
-
```
|
|
94
|
-
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
95
|
-
ā Top bar (44px, container info) ā
|
|
96
|
-
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā¤
|
|
97
|
-
ā ā
|
|
98
|
-
ā Output area (div, overflow:autoā
|
|
99
|
-
ā white-space: pre-wrap) ā
|
|
100
|
-
ā ā
|
|
101
|
-
ā root@hostname:~# ls -la ā
|
|
102
|
-
ā total 12 ā
|
|
103
|
-
ā drwxr-xr-x ... ā
|
|
104
|
-
ā -rw-r--r-- ... ā
|
|
105
|
-
ā ā
|
|
106
|
-
ā root@hostname:~# ā ā
|
|
107
|
-
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā¤
|
|
108
|
-
ā (hidden <textarea> for input) ā
|
|
109
|
-
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
110
|
-
```
|
|
111
|
-
|
|
112
|
-
Key design decisions:
|
|
113
|
-
|
|
114
|
-
| Decision | Rationale |
|
|
115
|
-
|---|---|
|
|
116
|
-
| **`<textarea>` hidden off-screen** | Captures all keystrokes including Tab, Arrows, Ctrl+letter. No focus/IME issues. Single-line only (no Enter for newlines). |
|
|
117
|
-
| **`<div>` output area** | Native browser scrolling (`overflow: auto`). Normal text selection (copy/paste with Cmd+C). Render millions of lines without black screen. |
|
|
118
|
-
| **`white-space: pre-wrap`** | Preserves ANSI-visible indentation and spacing while wrapping long lines. |
|
|
119
|
-
| **Last output line + input inline** | The cursor and text being typed appear on the same line as the last prompt from the server, simulating a real terminal feel. |
|
|
120
|
-
| **`requestAnimationFrame` auto-scroll** | After every buffer update, scrolls to bottom. Smooth, native scroll behavior. |
|
|
121
|
-
|
|
122
|
-
### Input handling
|
|
123
|
-
|
|
124
|
-
- **Hidden `<textarea>`** captures all keyboard input
|
|
125
|
-
- `value` + `onChange` ā React state `currentInput`
|
|
126
|
-
- On `Enter` ā command sent to WebSocket, input cleared
|
|
127
|
-
- `Arrows` ā local command history (client-side array)
|
|
128
|
-
- `Tab` ā `\t` sent to server (bash autocomplete)
|
|
129
|
-
- `Ctrl+C/L/D/U` ā respective control characters sent to server
|
|
130
|
-
- **Multi-line paste** ā split by `\n`, each line sent as a separate command
|
|
131
|
-
|
|
132
|
-
### Pending command flash fix
|
|
133
|
-
|
|
134
|
-
When the user presses Enter, `currentInput` is immediately cleared.
|
|
135
|
-
However, the server echoes the command back with a newline + new prompt.
|
|
136
|
-
Between the clear and the server echo, there is a visible frame where the
|
|
137
|
-
input line appears blank.
|
|
138
|
-
|
|
139
|
-
**Fix:** A `pendingCmdRef` ref holds the last submitted command string.
|
|
140
|
-
The rendering logic uses:
|
|
141
|
-
|
|
142
|
-
```ts
|
|
143
|
-
const showInput = pendingCmdRef.current ? pendingCmdRef.current : currentInput;
|
|
144
|
-
```
|
|
36
|
+
| Process | Listens on | Role |
|
|
37
|
+
|---|---|---|
|
|
38
|
+
| `server.js` | 3740, all interfaces | Next.js (pages, API, `proxy.ts`) through its programmatic API, plus the WebSocket upgrade on `/api/terminal-ws` |
|
|
39
|
+
| `daemon.js` | ā | Session poll and machine metrics |
|
|
40
|
+
| `terminal-ws-server.js` | 127.0.0.1:3741 | The PTYs and their sessions |
|
|
145
41
|
|
|
146
|
-
|
|
147
|
-
|
|
42
|
+
The terminal server is its own process so a flood of shell output never stalls the
|
|
43
|
+
dashboard's event loop. `server.js` replaces `next start`; every HTTP request goes to
|
|
44
|
+
Next.js unchanged. `npm run dev` (`next dev`) has no terminal: use `rev4a serve`.
|
|
148
45
|
|
|
149
|
-
|
|
46
|
+
## Security
|
|
150
47
|
|
|
151
|
-
|
|
152
|
-
SGR (Select Graphic Rendition) escape sequences into styled `<span>`
|
|
153
|
-
elements:
|
|
48
|
+
At the upgrade, in `server.js`, in this order:
|
|
154
49
|
|
|
155
|
-
|
|
|
50
|
+
| Check | Refused with |
|
|
156
51
|
|---|---|
|
|
157
|
-
|
|
|
158
|
-
|
|
|
159
|
-
|
|
|
160
|
-
|
|
|
161
|
-
|
|
|
162
|
-
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
52
|
+
| The path is `/api/terminal-ws` (no other path takes an upgrade) | `404` |
|
|
53
|
+
| `Origin` equals the host the browser addressed: `Host`, or `X-Forwarded-Host` from a proxy; case and the scheme's default port are ignored. Blocks a page on another site from opening a shell with the user's cookie (cross-site WebSocket hijacking); a browser cannot set `X-Forwarded-Host` on a WebSocket, so accepting it opens nothing | `403` |
|
|
54
|
+
| The `rev4a_token` cookie verifies ā the JWT the dashboard pages require (`REV4A_JWT_SECRET` from the environment or `.env`); a bearer token is not accepted here | `401` |
|
|
55
|
+
| The container name matches Docker's pattern (`[a-zA-Z0-9][a-zA-Z0-9_.-]*`, ā¤128 characters) | `400` |
|
|
56
|
+
| The per-boot secret is set (Rev4a started with `rev4a serve`) | `503` |
|
|
57
|
+
| The terminal server answers (refused ā `502`, silent for 10 s ā `504`) | `502` / `504` |
|
|
58
|
+
|
|
59
|
+
The handshake is then replayed to 127.0.0.1:3741 **without the cookie** and with
|
|
60
|
+
`X-Rev4a-Terminal-Secret` (any copy the client sent is dropped): a random value
|
|
61
|
+
`rev4a serve` generates at every start and passes to both processes in the environment
|
|
62
|
+
(`REV4A_TERMINAL_SECRET`, never in argv). The terminal server refuses any handshake
|
|
63
|
+
without it (`401`, constant-time comparison), so another local user who reaches the
|
|
64
|
+
loopback port gets nothing. Started without the variable, both sides say so in the log.
|
|
65
|
+
|
|
66
|
+
The terminal server then checks the container exists and runs (`docker inspect`) and
|
|
67
|
+
picks `bash`, or `sh` when the image has none. Every host-side Docker call passes argv ā
|
|
68
|
+
no host shell. Inside the container two fixed `sh -c` scripts run (the shell lookup and
|
|
69
|
+
the clean-up below); the session id reaches the clean-up as an argument, never
|
|
70
|
+
interpolated.
|
|
71
|
+
|
|
72
|
+
What an authenticated user can do is the single-admin model: open a shell in any running
|
|
73
|
+
container. Sessions are not bound to the login that opened them ā any valid cookie can
|
|
74
|
+
resume a session id it knows ā and an open socket is not re-checked when the login
|
|
75
|
+
expires; closing the tab or **Close** ends it. The session id travels in the WebSocket
|
|
76
|
+
URL, so a reverse proxy's access log records it.
|
|
77
|
+
|
|
78
|
+
## Sessions
|
|
79
|
+
|
|
80
|
+
The browser names its session: a UUID kept in `sessionStorage` per container, sent as
|
|
81
|
+
`&session=`. The shell belongs to that id, not to the socket:
|
|
82
|
+
|
|
83
|
+
- **Drop** (network, laptop sleep): the shell keeps running for **2 minutes**, its output
|
|
84
|
+
queued (the newest 64 K characters, cut at a line start). The page reconnects with
|
|
85
|
+
`&resume=1` and is attached to it, receiving the queued output ā a command started
|
|
86
|
+
before the drop finishes and is seen. Past 2 minutes (or after a Rev4a restart) the
|
|
87
|
+
shell is gone and the resume is answered with `4002` and the reason; **Reconnect**
|
|
88
|
+
starts a new one.
|
|
89
|
+
- **Reload** resumes the same shell while it lives (same tab, same `sessionStorage`); a
|
|
90
|
+
reload after it ended simply starts a new one.
|
|
91
|
+
- **Close** (the button) ends it at once: the page sends close code `1000` ā or, while it
|
|
92
|
+
is reconnecting, briefly attaches only to send it ā the id is forgotten, and the next
|
|
93
|
+
visit starts a new shell.
|
|
94
|
+
- **The same session in two tabs** (a duplicated tab copies `sessionStorage`): the newer
|
|
95
|
+
takes it, the older is closed with `4003`. Two sockets starting the same new id at once
|
|
96
|
+
get one shell: the second waits for the first.
|
|
97
|
+
- **Idle**: no input and no output for 30 minutes ends the shell (`4002`).
|
|
98
|
+
- **Heartbeat**: the server pings every 25 s; a socket that misses a ping is dropped
|
|
99
|
+
(its shell then waits the 2 minutes). Keeps idle links open through NAT and finds dead peers.
|
|
100
|
+
- **Limits**: at most 20 shells at once (`4429`); keystrokes typed before the shell starts
|
|
101
|
+
are kept up to 64 K characters; a message is at most 1 MB.
|
|
102
|
+
- **Backpressure**: when 1 MB of output waits for a slow client the shell is paused, and
|
|
103
|
+
resumed below 256 KB ā `yes` or a huge `cat` cannot grow the server's memory.
|
|
104
|
+
|
|
105
|
+
**Ending a session ends the shell inside the container.** Killing the host's `docker exec`
|
|
106
|
+
client alone does not: Docker leaves an exec running when its client goes away, so every
|
|
107
|
+
closed terminal used to leave a shell (and anything running in it) in the container. The
|
|
108
|
+
shell is started with `REV4A_TERMINAL_SESSION=<id>` in its environment, which every
|
|
109
|
+
process it starts inherits; ending the session runs, in the container, a scan of
|
|
110
|
+
`/proc/*/environ` for that marker ā `SIGHUP` to what carries it (a terminal hangup), then
|
|
111
|
+
`SIGKILL` a second later to what ignored it. Needs `sh`, `tr` and `grep` in the image
|
|
112
|
+
(BusyBox and Debian/Ubuntu images have them).
|
|
113
|
+
|
|
114
|
+
Sizes: the page sends `{"type":"resize","cols":n,"rows":n}` on connect and whenever the
|
|
115
|
+
terminal's columns or rows change; a size or keystrokes sent while the server still
|
|
116
|
+
checks the container are kept and applied when the shell starts. A resume keeps the
|
|
117
|
+
shell's size until the page sends its own.
|
|
118
|
+
|
|
119
|
+
### Close codes
|
|
120
|
+
|
|
121
|
+
| Code | Meaning | Client |
|
|
197
122
|
|---|---|---|
|
|
198
|
-
|
|
|
199
|
-
|
|
|
200
|
-
|
|
|
201
|
-
|
|
|
202
|
-
|
|
|
203
|
-
|
|
|
204
|
-
|
|
|
205
|
-
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
The
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
123
|
+
| `1000` | Closed on purpose by the page | Stops, shows **Reconnect** |
|
|
124
|
+
| `4001` | The shell exited (`exit`, Ctrl-D) | Stops, shows the reason, **Reconnect** starts a new shell |
|
|
125
|
+
| `4002` | Idle for 30 minutes, or a resume found the shell already gone | Same |
|
|
126
|
+
| `4003` | The session was opened in another tab | Same |
|
|
127
|
+
| `4404` | No such container / invalid name | Same |
|
|
128
|
+
| `4409` | The container is stopped | Same |
|
|
129
|
+
| `4410` | No `bash` or `sh` in the container | Same |
|
|
130
|
+
| `4429` | 20 shells already open | Same |
|
|
131
|
+
| `4500` | The PTY could not be started on this host | Same |
|
|
132
|
+
| other (1006ā¦) | The connection dropped | Reconnects: 1 s, 2 s, 5 s, then every 10 s |
|
|
133
|
+
|
|
134
|
+
Six handshakes in a row that fail before opening (about 40 s: not logged in, Rev4a down
|
|
135
|
+
or restarting, a proxy refusing the upgrade) stop the retries with a message and the
|
|
136
|
+
**Reconnect** button.
|
|
137
|
+
|
|
138
|
+
## Reverse proxies
|
|
139
|
+
|
|
140
|
+
Rev4a needs none, but works behind one that:
|
|
141
|
+
|
|
142
|
+
- forwards WebSocket upgrades on `/api/terminal-ws` **to port 3740** (the dashboard), like
|
|
143
|
+
every other path. A route sending `/api/terminal-ws` to **3741** ā what older versions
|
|
144
|
+
asked for ā must be removed: 3741 now answers `401` to anything but `server.js`;
|
|
145
|
+
- keeps the host the browser used, in `Host` or `X-Forwarded-Host`. nginx replaces `Host`
|
|
146
|
+
with the upstream address by default: set `proxy_set_header Host $host;` (or
|
|
147
|
+
`X-Forwarded-Host $host`), plus `proxy_http_version 1.1`, `Upgrade $http_upgrade` and
|
|
148
|
+
`Connection "upgrade"`. Otherwise the Origin check answers `403`.
|
|
149
|
+
|
|
150
|
+
## Terminal client (browser)
|
|
151
|
+
|
|
152
|
+
`app/containers/terminal/[id]/TerminalClient.tsx`: **xterm.js 6** (`@xterm/xterm`) with
|
|
153
|
+
`@xterm/addon-fit` (sizes the terminal to its box, refitted by a `ResizeObserver`) and
|
|
154
|
+
`@xterm/addon-web-links` (http/https URLs open in a new tab). Keystrokes go to the socket
|
|
155
|
+
as they are typed (`onData`, and `onBinary` for legacy mouse reports); output is written
|
|
156
|
+
as it arrives. 5 000 lines of scrollback, its own scrolling and selection. Copy with the
|
|
157
|
+
system shortcut where it is not Ctrl-C (Cmd-C on macOS); in a terminal Ctrl-C belongs to
|
|
158
|
+
the shell. Loaded in the browser only (dynamic import). The palette comes from the design
|
|
159
|
+
tokens. The session id is a random UUID from `crypto.getRandomValues` ā
|
|
160
|
+
`crypto.randomUUID()` exists only in secure contexts, and a dashboard served over plain
|
|
161
|
+
HTTP by IP is not one.
|
|
162
|
+
|
|
163
|
+
The page is full screen: a bar (`ui-kicker`, the container name, a status `Badge` ā
|
|
164
|
+
Connecting, Connected, Reconnecting, Closed ā and `Button`s **Reconnect** / **Close**),
|
|
165
|
+
a notice line when the session stopped, and the terminal. Styles are the `terminal-page`
|
|
166
|
+
classes in `globals.css`.
|
|
167
|
+
|
|
168
|
+
### Why xterm.js again
|
|
169
|
+
|
|
170
|
+
The first version (June 2026) used xterm.js 5.3 and was replaced the same day by a
|
|
171
|
+
custom DOM renderer after a black screen on large output, broken scrolling and selection
|
|
172
|
+
problems ā layout issues under `html, body { overflow: hidden }` with a viewport that
|
|
173
|
+
never sized. The custom renderer could not be a terminal: it sent whole lines on Enter
|
|
174
|
+
(no Tab completion, no bash history), never sent a size, and stripped `\r` and cursor
|
|
175
|
+
movement (no progress bars, no `top`/`vi`/`less`). xterm.js 6 now lives in a fixed-size
|
|
176
|
+
box sized by `addon-fit`. Before it replaced the custom renderer it was checked in a real
|
|
177
|
+
browser (Playwright, 2026-09-26) against those failures: 20 000 lines of `seq` render to
|
|
178
|
+
the last line, the alternate screen and cursor addressing work (BusyBox `top` renders),
|
|
179
|
+
resize reaches the PTY (`tput cols`).
|
|
180
|
+
|
|
181
|
+
## Troubleshooting
|
|
182
|
+
|
|
183
|
+
| Symptom | Cause |
|
|
184
|
+
|---|---|
|
|
185
|
+
| "Cannot open the terminal" after about 40 s | Not logged in (session expired); Rev4a down; or a reverse proxy not forwarding the upgrade to 3740 or rewriting `Host` (see [Reverse proxies](#reverse-proxies)) |
|
|
186
|
+
| "No container named ā¦" / "⦠is not running" | The container is gone or stopped: an agent is started from the Agents page, any other container with `docker start` |
|
|
187
|
+
| "The previous shell has ended" | The connection was down for more than 2 minutes, or Rev4a restarted: **Reconnect** opens a new shell |
|
|
188
|
+
| "Could not start the shell: posix_spawnp failed" (macOS) | node-pty's `spawn-helper` lost its execute bit; `terminal-ws-server.js` restores it at start ā restart Rev4a |
|
|
262
189
|
|
|
263
|
-
##
|
|
190
|
+
## Files
|
|
264
191
|
|
|
265
192
|
```
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
terminal-ws-server.js # WebSocket PTY server, started by `rev4a serve`
|
|
193
|
+
server.js # HTTP + terminal upgrade on the dashboard port
|
|
194
|
+
terminal-ws-server.js # PTYs, sessions, clean-up, heartbeat (127.0.0.1)
|
|
195
|
+
app/containers/terminal/[id]/page.tsx # the page (auth-protected by proxy.ts)
|
|
196
|
+
app/containers/terminal/[id]/TerminalClient.tsx
|
|
271
197
|
```
|
|
272
|
-
|
|
273
|
-
## Known Limitations
|
|
274
|
-
|
|
275
|
-
- **No full-screen TUI support** ā `vim`, `top`, `nano`, `htop` will
|
|
276
|
-
not render correctly because cursor positioning sequences are stripped.
|
|
277
|
-
These tools require a proper xterm-compatible terminal.
|
|
278
|
-
- **Single session per connection** ā no multiplexing, no tabs.
|
|
279
|
-
Each WebSocket connection spawns one PTY.
|
|
280
|
-
- **No WebGL renderer** ā DOM rendering is CPU-bound for very fast output.
|
|
281
|
-
In practice, up to ~500 lines/sec is smooth.
|
|
282
|
-
|
|
283
|
-
## Future Improvements
|
|
284
|
-
|
|
285
|
-
- [ ] Detect TUI applications and fall back to xterm.js WebGL renderer
|
|
286
|
-
- [ ] Terminal multiplexer (multiple tabs, split panes)
|
|
287
|
-
- [ ] Download session log as text file
|
|
288
|
-
- [ ] Paste confirmation dialog for multi-line pastes
|
|
289
|
-
- [ ] Dark/light theme toggle
|
package/docs/DESIGN-SYSTEM.md
CHANGED
|
@@ -82,6 +82,7 @@ then use it ā a one-off copy of markup is the thing this section exists to pre
|
|
|
82
82
|
5. **CSS vars over hardcoded colors.** Never inline `#22c55e`, `#ef4444`, `#888` ā use the CSS token.
|
|
83
83
|
6. **Loading feedback.** Use `loading` prop on `Button` (shows inline spinner). Do not render separate loader elements.
|
|
84
84
|
7. **Minimal animation.** Transitions on hover/active (0.1sā0.12s), spin on loading spinner. No bounce, shake, or decorative animations.
|
|
85
|
+
8. **Data display: violet for the data, status colours for levels.** A chart series is `var(--violet)` (2px line, 10% wash), grid and axis text are `var(--border)` / `var(--text-dim)`, never the series colour. A `Meter` fill is violet when fine and `var(--yellow)` / `var(--red)` at its warning / critical level, with the level also written out ā status never rests on colour alone. Focus on a chart is a 1px `outline` in `var(--violet-dim)`, not a shadow. See `TimeSeriesChart` and `Meter` in `docs/FRONTEND-ARCHITECTURE.md`.
|
|
85
86
|
|
|
86
87
|
### React hook
|
|
87
88
|
|