dsh-pve-vm 0.1.1 → 0.2.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/CHANGELOG.md +29 -0
- package/README.md +34 -0
- package/client.js +535 -0
- package/index.js +9 -0
- package/lib/config.js +57 -1
- package/lib/png.js +19 -0
- package/lib/rfb.js +23 -4
- package/lib/routes.js +197 -0
- package/lib/session.js +18 -1
- package/lib/view.js +145 -0
- package/package.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,35 @@
|
|
|
3
3
|
All notable changes to this plugin are documented here. The format follows
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), versions follow [SemVer](https://semver.org/).
|
|
5
5
|
|
|
6
|
+
## [0.2.0] — 2026-10-13
|
|
7
|
+
|
|
8
|
+
Watch the agent work: a live VM screen in the right sidebar of the web UI.
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
* **The “VM screen” tab** — a right-sidebar panel that streams the frames the agent sees. The frames
|
|
13
|
+
come from the agent's own RFB session, so no second VNC session appears and the `qm vncproxy`
|
|
14
|
+
password is never reset; captures and tools share one session queue. Watch-only by design: the
|
|
15
|
+
panel injects nothing into the guest.
|
|
16
|
+
* **`viewAutoOpen`** (off by default) — reveal the tab by itself when the agent touches a console.
|
|
17
|
+
* **`viewFollowAgent`** (on by default) — the panel follows the VM the agent works with; a VM chip
|
|
18
|
+
pins it by hand. Watching never counts as agent work: frame requests suppress the activity mark
|
|
19
|
+
(`withConsole(..., { mark: false })`), so the panel cannot chase itself.
|
|
20
|
+
* **`viewFps`** (2) and **`viewScale`** (2) — frame pace and resolution divisor, both changeable on
|
|
21
|
+
the fly inside the panel.
|
|
22
|
+
* **Host routes** `/pve-vm/console/state`, `/frame` and `/release` on the harness web server,
|
|
23
|
+
registered only when the composition has a `webServer`. They carry a loopback-only fence: Host must
|
|
24
|
+
be loopback, a present Origin must match Host, `sec-fetch-site: cross-site` is refused and POST must
|
|
25
|
+
be `application/json`. A harness bound to a LAN therefore keeps the panel dark.
|
|
26
|
+
* `RfbClient.waitForUpdate({ idleOk: true })` — silence at a message boundary means "the screen did
|
|
27
|
+
not change" instead of an error, while a timeout inside a message stays fatal.
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
* **`inputChannel` and `sendkeyDelayMs` never reached the tools.** Both keys were missing from
|
|
32
|
+
`CONFIG_KEYS`, so `snapshot()` dropped them and every run silently used the built-in `auto`/80 ms
|
|
33
|
+
defaults. The form accepted the values, the code ignored them.
|
|
34
|
+
|
|
6
35
|
## [0.1.1] — 2026-10-13
|
|
7
36
|
|
|
8
37
|
Linux guest support. Everything below was found by running the plugin against a real RedOS 8
|
package/README.md
CHANGED
|
@@ -30,6 +30,9 @@ agent ──► plugin ──► ssh ──► node: qm vncproxy ──► unix
|
|
|
30
30
|
* **Clipboard** — `console_paste` writes UTF-8 text into the guest clipboard over RFB and presses
|
|
31
31
|
Ctrl+V; `console_clipboard` reads the guest clipboard back, so text can leave the VM without OCR.
|
|
32
32
|
* **A settings card** in the web UI (English and Russian labels), backed by the profile patch layer.
|
|
33
|
+
* **A live screen panel** — the “VM screen” tab in the right sidebar shows the frames the agent
|
|
34
|
+
sees, taken from the very same VNC session, while the agent works. It follows the agent's VM
|
|
35
|
+
automatically or stays on the VM you pick by hand. Watching only: the panel never clicks or types.
|
|
33
36
|
* **Zero runtime dependencies** — RFB, DES authentication, keysym tables and PNG encoding are
|
|
34
37
|
implemented in this package on top of `node:crypto` and `node:zlib`. There is nothing to install
|
|
35
38
|
on the host, and nothing to install on the node either.
|
|
@@ -95,6 +98,10 @@ layer, and the same values can be edited there by hand:
|
|
|
95
98
|
sessionIdleMs: 300000
|
|
96
99
|
guestTimeoutMs: 60000
|
|
97
100
|
guestTypeCommand: '' # script for typing non-ASCII text into a Linux guest
|
|
101
|
+
viewAutoOpen: false # open the "VM screen" tab by itself when the agent touches a console
|
|
102
|
+
viewFollowAgent: true # the panel follows the VM the agent works with
|
|
103
|
+
viewFps: 2 # frame pace in the panel, 1–4
|
|
104
|
+
viewScale: 2 # frame resolution divisor: 1 native, 2 half, 4 quarter
|
|
98
105
|
```
|
|
99
106
|
|
|
100
107
|
**First run checklist**
|
|
@@ -137,6 +144,33 @@ Every action tool accepts a common tail: `observe` (return a frame after the act
|
|
|
137
144
|
| `vm_type_text` | `vmid`, `text`, `settleMs?`, `observe?` | Type text through the best available channel: ASCII → console, non-ASCII → the user session on Windows / `xdotool`–`wtype` on Linux. |
|
|
138
145
|
| `vm_status` | `vmid?` | Is the VM allowed, does the agent answer, which OS, are there open console sessions. |
|
|
139
146
|
|
|
147
|
+
## Watch the VM screen live
|
|
148
|
+
|
|
149
|
+
The **VM screen** tab in the right sidebar shows what the agent sees while it works. Add it from the
|
|
150
|
+
sidebar guide, or let `viewAutoOpen` reveal it the moment the agent touches a console.
|
|
151
|
+
|
|
152
|
+
* The frames are taken from the agent's own RFB session — **no second VNC session appears** and the
|
|
153
|
+
`qm vncproxy` password is never reset under the agent's feet. Captures and the agent's tools share
|
|
154
|
+
one session queue, so they never tear the stream for each other.
|
|
155
|
+
* `viewFollowAgent` keeps the panel on the VM the agent is working with; clicking a VM chip pins the
|
|
156
|
+
panel to that VM instead. `viewFps` (1–4) and `viewScale` (1 / 2 / 4) control the pace and the
|
|
157
|
+
resolution, and both can be changed on the fly inside the panel.
|
|
158
|
+
* The panel is **watch-only**: no clicks, no typing, nothing is injected into the guest. A blank
|
|
159
|
+
frame is called out explicitly — the guest blanked its display or is locked.
|
|
160
|
+
* Frames are served by the plugin's own routes (`/pve-vm/console/frame`, `/state`, `/release`) on the
|
|
161
|
+
harness web server, behind a loopback-only fence: a request is answered only when the `Host` is
|
|
162
|
+
loopback (`localhost`, `127.0.0.0/8`, `[::1]`), a present `Origin` matches `Host`, and
|
|
163
|
+
`sec-fetch-site: cross-site` is refused. If you bind the harness to your LAN, the panel stays dark
|
|
164
|
+
by design — the same fence the harness applies to `/api`.
|
|
165
|
+
* **The fence is loopback, not the harness token.** These routes live outside `/api`, so the
|
|
166
|
+
harness URL token does not cover them (the browser-skill overview works the same way). Any
|
|
167
|
+
process on the same machine can therefore pull frames or call `/release`; that is the accepted
|
|
168
|
+
price of loading frames without a token on a single-user box. Serve VMs you would not mind a
|
|
169
|
+
local user seeing, and harden with a harness-issued token if that is not acceptable.
|
|
170
|
+
* The console session stays open while the panel streams (the idle reaper does not take it); the
|
|
171
|
+
panel's “disconnect console” button closes it immediately. Streaming pauses by itself when the tab
|
|
172
|
+
is hidden.
|
|
173
|
+
|
|
140
174
|
## How it works
|
|
141
175
|
|
|
142
176
|
* **Transport.** A VM has no TCP VNC port: QEMU listens on the unix socket
|
package/client.js
CHANGED
|
@@ -77,6 +77,43 @@ window.__ModuleLoader__.load({
|
|
|
77
77
|
'field.guestTypeCommand': 'Ввод не-ASCII в Linux-госте',
|
|
78
78
|
'field.guestTypeCommandHint':
|
|
79
79
|
'Скрипт ввода не-ASCII текста в Linux-госте; получает base64 текста первым аргументом.',
|
|
80
|
+
'field.viewAutoOpen': 'Открывать панель экрана сама',
|
|
81
|
+
'field.viewAutoOpenHint':
|
|
82
|
+
'Когда агент прикасается к консоли ВМ, вкладка «Экран ВМ» раскрывается сама в правом сайдбаре.',
|
|
83
|
+
'field.viewFollowAgent': 'Панель следует за агентом',
|
|
84
|
+
'field.viewFollowAgentHint':
|
|
85
|
+
'Показывать ВМ, с которой работает агент. Выключено — панель держит ВМ, выбранную вручную.',
|
|
86
|
+
'field.viewFps': 'Кадров в секунду в панели',
|
|
87
|
+
'field.viewFpsHint':
|
|
88
|
+
'1–4. Два кадра в секунду — рабочий темп: движение агента видно, узел не перегружен.',
|
|
89
|
+
'field.viewScale': 'Делитель разрешения кадра',
|
|
90
|
+
'field.viewScaleHint':
|
|
91
|
+
'1 — нативное, 2 — половина (по умолчанию), 4 — четверть. Меньше пикселей — быстрее кадр.',
|
|
92
|
+
'tab.title': 'Экран ВМ',
|
|
93
|
+
'tab.guide': 'Смотреть экран виртуальной машины, пока агент с ней работает.',
|
|
94
|
+
'view.follow': 'следовать за агентом',
|
|
95
|
+
'view.pause': 'пауза',
|
|
96
|
+
'view.play': 'смотреть',
|
|
97
|
+
'view.snap': 'кадр сейчас',
|
|
98
|
+
'view.release': 'отключить консоль',
|
|
99
|
+
'view.released': 'сессия консоли закрыта',
|
|
100
|
+
'view.releaseFailed': 'закрыть сессию не удалось',
|
|
101
|
+
'view.fps': 'кадров/с',
|
|
102
|
+
'view.scale': 'разрешение',
|
|
103
|
+
'view.scaleNative': 'нативное',
|
|
104
|
+
'view.scaleHalf': 'половина',
|
|
105
|
+
'view.scaleQuarter': 'четверть',
|
|
106
|
+
'view.empty': 'В allowVmids нет ни одной ВМ — добавьте её в настройках плагина.',
|
|
107
|
+
'view.consoleOff': 'Инструменты консоли выключены настройкой enableConsole.',
|
|
108
|
+
'view.waiting': 'Ждём первый кадр…',
|
|
109
|
+
'view.paused': 'поток на паузе',
|
|
110
|
+
'view.blank': 'Кадр почти одноцветный: гость погасил дисплей или экран заблокирован.',
|
|
111
|
+
'view.closed': 'сессия консоли закрылась — следующий кадр откроет её заново',
|
|
112
|
+
'view.status': 'ВМ {vmid} · экран {resolution} · кадр {capture} · курсор {pointer}',
|
|
113
|
+
'view.bytes': '{kb} КБ',
|
|
114
|
+
'view.hostError': 'хост ответил ошибкой: {message}',
|
|
115
|
+
'view.noHost': 'нет связи с хостом: {message}',
|
|
116
|
+
'view.agentVm': 'агент работал с ВМ {vmid}',
|
|
80
117
|
},
|
|
81
118
|
en: {
|
|
82
119
|
section: 'Proxmox VE',
|
|
@@ -120,6 +157,43 @@ window.__ModuleLoader__.load({
|
|
|
120
157
|
'field.guestTypeCommand': 'Non-ASCII typing in a Linux guest',
|
|
121
158
|
'field.guestTypeCommandHint':
|
|
122
159
|
'Script that types non-ASCII text inside a Linux guest; receives base64 text as $1.',
|
|
160
|
+
'field.viewAutoOpen': 'Open the screen panel by itself',
|
|
161
|
+
'field.viewAutoOpenHint':
|
|
162
|
+
'When the agent touches a VM console, the “VM screen” tab opens itself in the right sidebar.',
|
|
163
|
+
'field.viewFollowAgent': 'Panel follows the agent',
|
|
164
|
+
'field.viewFollowAgentHint':
|
|
165
|
+
'Show the VM the agent is working with. Off — the panel keeps the VM you picked by hand.',
|
|
166
|
+
'field.viewFps': 'Frames per second in the panel',
|
|
167
|
+
'field.viewFpsHint':
|
|
168
|
+
'1–4. Two frames per second is the working pace: the agent’s moves are visible, the node stays calm.',
|
|
169
|
+
'field.viewScale': 'Frame resolution divisor',
|
|
170
|
+
'field.viewScaleHint':
|
|
171
|
+
'1 — native, 2 — half (default), 4 — quarter. Fewer pixels — faster frame.',
|
|
172
|
+
'tab.title': 'VM screen',
|
|
173
|
+
'tab.guide': 'Watch the virtual machine screen while the agent works with it.',
|
|
174
|
+
'view.follow': 'follow the agent',
|
|
175
|
+
'view.pause': 'pause',
|
|
176
|
+
'view.play': 'watch',
|
|
177
|
+
'view.snap': 'frame now',
|
|
178
|
+
'view.release': 'disconnect console',
|
|
179
|
+
'view.released': 'console session closed',
|
|
180
|
+
'view.releaseFailed': 'could not close the session',
|
|
181
|
+
'view.fps': 'frames/s',
|
|
182
|
+
'view.scale': 'resolution',
|
|
183
|
+
'view.scaleNative': 'native',
|
|
184
|
+
'view.scaleHalf': 'half',
|
|
185
|
+
'view.scaleQuarter': 'quarter',
|
|
186
|
+
'view.empty': 'No VM is allowed yet — add one in the plugin settings.',
|
|
187
|
+
'view.consoleOff': 'Console tools are disabled by the enableConsole setting.',
|
|
188
|
+
'view.waiting': 'Waiting for the first frame…',
|
|
189
|
+
'view.paused': 'stream paused',
|
|
190
|
+
'view.blank': 'The frame is almost one colour: the guest blanked the display or the screen is locked.',
|
|
191
|
+
'view.closed': 'the console session closed — the next frame opens it again',
|
|
192
|
+
'view.status': 'VM {vmid} · screen {resolution} · frame {capture} · pointer {pointer}',
|
|
193
|
+
'view.bytes': '{kb} KB',
|
|
194
|
+
'view.hostError': 'the host answered with an error: {message}',
|
|
195
|
+
'view.noHost': 'no connection to the host: {message}',
|
|
196
|
+
'view.agentVm': 'the agent worked with VM {vmid}',
|
|
123
197
|
},
|
|
124
198
|
};
|
|
125
199
|
|
|
@@ -147,6 +221,10 @@ window.__ModuleLoader__.load({
|
|
|
147
221
|
{ name: 'sessionIdleMs', kind: 'number' },
|
|
148
222
|
{ name: 'guestTimeoutMs', kind: 'number' },
|
|
149
223
|
{ name: 'guestTypeCommand', kind: 'textarea', hintKey: 'field.guestTypeCommandHint' },
|
|
224
|
+
{ name: 'viewAutoOpen', kind: 'boolean', hintKey: 'field.viewAutoOpenHint' },
|
|
225
|
+
{ name: 'viewFollowAgent', kind: 'boolean', hintKey: 'field.viewFollowAgentHint' },
|
|
226
|
+
{ name: 'viewFps', kind: 'number', hintKey: 'field.viewFpsHint' },
|
|
227
|
+
{ name: 'viewScale', kind: 'number', hintKey: 'field.viewScaleHint' },
|
|
150
228
|
];
|
|
151
229
|
|
|
152
230
|
const STYLE_ID = 'dsh-pve-vm-styles';
|
|
@@ -167,6 +245,19 @@ window.__ModuleLoader__.load({
|
|
|
167
245
|
.pve-vm-select, .pve-vm-textarea { width: 100%; font: inherit; font-size: 12.5px; padding: 8px 10px; border-radius: 8px; border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); }
|
|
168
246
|
.pve-vm-textarea { min-height: 96px; resize: vertical; }
|
|
169
247
|
.pve-vm-select:focus, .pve-vm-textarea:focus { outline: none; border-color: var(--dsw-alias-brand-primary); }
|
|
248
|
+
.pve-vm-view { display: flex; flex-direction: column; gap: 10px; padding: 10px 10px 16px; color: var(--dsw-alias-label-primary); font-size: 12.5px; }
|
|
249
|
+
.pve-vm-view-head { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
|
|
250
|
+
.pve-vm-chip { font: inherit; font-size: 12px; padding: 3px 9px; border-radius: 999px; border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-secondary); cursor: pointer; }
|
|
251
|
+
.pve-vm-chip[data-active="1"] { border-color: var(--dsw-alias-brand-primary); color: var(--dsw-alias-brand-primary); }
|
|
252
|
+
.pve-vm-stage { display: flex; align-items: center; justify-content: center; min-height: 110px; border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px; background: var(--dsw-alias-bg-layer-2); overflow: hidden; }
|
|
253
|
+
.pve-vm-stage img { display: block; width: 100%; height: auto; }
|
|
254
|
+
.pve-vm-stage-empty { padding: 22px 14px; text-align: center; color: var(--dsw-alias-label-secondary); font-size: 12px; line-height: 1.5; }
|
|
255
|
+
.pve-vm-line { color: var(--dsw-alias-label-secondary); font-size: 11.5px; line-height: 1.45; }
|
|
256
|
+
.pve-vm-warn { border: 1px solid var(--dsw-alias-state-warn-primary); color: var(--dsw-alias-state-warn-primary); border-radius: 8px; padding: 6px 9px; font-size: 11.5px; line-height: 1.45; }
|
|
257
|
+
.pve-vm-error { border: 1px solid var(--dsw-alias-state-error-primary); color: var(--dsw-alias-state-error-primary); border-radius: 8px; padding: 6px 9px; font-size: 11.5px; line-height: 1.45; }
|
|
258
|
+
.pve-vm-ok { color: var(--dsw-alias-state-success-primary); font-size: 11.5px; }
|
|
259
|
+
.pve-vm-tools { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
|
260
|
+
.pve-vm-tools .pve-vm-select { width: auto; }
|
|
170
261
|
`;
|
|
171
262
|
|
|
172
263
|
/** Add the card styles once. */
|
|
@@ -364,6 +455,398 @@ window.__ModuleLoader__.load({
|
|
|
364
455
|
});
|
|
365
456
|
}
|
|
366
457
|
|
|
458
|
+
/** The tab type id: it is also the key in the right sidebar slots. */
|
|
459
|
+
const TAB_ID = 'dsh-pve-vm/console';
|
|
460
|
+
/** The tab type discriminator: tabs are opened by this name. */
|
|
461
|
+
const TAB_KIND = 'pve-vm-console';
|
|
462
|
+
/** Base path of the panel routes on the host (lib/routes.js). */
|
|
463
|
+
const VIEW_BASE = '/pve-vm/console';
|
|
464
|
+
/** How often the panel asks the node for its state, ms. */
|
|
465
|
+
const STATE_POLL_MS = 2500;
|
|
466
|
+
/** How often the plugin checks "the agent touched a console" for auto-open, ms. */
|
|
467
|
+
const AUTO_OPEN_POLL_MS = 3000;
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Panel settings from the same form as `settings.section`.
|
|
471
|
+
* The bounds are the host's `resolveSettings` bounds: a value typed by a human
|
|
472
|
+
* may be anything, while the frame has to stay predictable.
|
|
473
|
+
* @param {object} snapshot the configForms snapshot.
|
|
474
|
+
* @returns {{autoOpen:boolean, followAgent:boolean, fps:number, scale:number}}
|
|
475
|
+
*/
|
|
476
|
+
function viewDefaults(snapshot) {
|
|
477
|
+
const values = snapshot?.value ?? {};
|
|
478
|
+
const clamp = (value, min, max, fallback) => {
|
|
479
|
+
const parsed = Math.round(Number(value));
|
|
480
|
+
if (!Number.isFinite(parsed)) return fallback;
|
|
481
|
+
return Math.max(min, Math.min(max, parsed));
|
|
482
|
+
};
|
|
483
|
+
return {
|
|
484
|
+
autoOpen: values.viewAutoOpen === true,
|
|
485
|
+
followAgent: values.viewFollowAgent !== false,
|
|
486
|
+
fps: clamp(values.viewFps, 1, 4, 2),
|
|
487
|
+
scale: clamp(values.viewScale, 1, 4, 2),
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* What the panel shows right now. Both the tab body and its chip need it: the
|
|
493
|
+
* chip is drawn by a separate slot and knows nothing about component state.
|
|
494
|
+
*/
|
|
495
|
+
const viewStore = {
|
|
496
|
+
vmid: null,
|
|
497
|
+
listeners: new Set(),
|
|
498
|
+
set(vmid) {
|
|
499
|
+
if (this.vmid === vmid) return;
|
|
500
|
+
this.vmid = vmid;
|
|
501
|
+
for (const listener of [...this.listeners]) listener();
|
|
502
|
+
},
|
|
503
|
+
subscribe(listener) {
|
|
504
|
+
this.listeners.add(listener);
|
|
505
|
+
return () => this.listeners.delete(listener);
|
|
506
|
+
},
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
/** Frame metadata from the route response headers. */
|
|
510
|
+
function readFrameMeta(headers) {
|
|
511
|
+
return {
|
|
512
|
+
resolution: headers.get('x-pve-vm-resolution'),
|
|
513
|
+
capture: headers.get('x-pve-vm-capture'),
|
|
514
|
+
scale: Number(headers.get('x-pve-vm-scale') ?? 1),
|
|
515
|
+
pointer: headers.get('x-pve-vm-pointer'),
|
|
516
|
+
blank: headers.get('x-pve-vm-blank') === '1',
|
|
517
|
+
session: headers.get('x-pve-vm-session'),
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/** The tab chip: "VM screen · 121". */
|
|
522
|
+
function ConsoleTitle(props) {
|
|
523
|
+
const t = props.t;
|
|
524
|
+
const [vmid, setVmid] = React.useState(viewStore.vmid);
|
|
525
|
+
React.useEffect(() => viewStore.subscribe(() => setVmid(viewStore.vmid)), []);
|
|
526
|
+
return jsx('span', { children: vmid === null ? t('tab.title') : `${t('tab.title')} · ${vmid}` });
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* The observation panel: a live VM screen in the right sidebar.
|
|
531
|
+
*
|
|
532
|
+
* Frames come from the plugin's routes (`/pve-vm/console/frame`) — the very
|
|
533
|
+
* frames the agent sees, taken from its RFB session. The stream runs while the
|
|
534
|
+
* tab is visible and not paused; the next frame is requested only after the
|
|
535
|
+
* previous one arrived, so the session queue is never flooded.
|
|
536
|
+
* @param {{t:Function, ctx:object, form:object, useTabInfo?:Function}} props the slot props.
|
|
537
|
+
*/
|
|
538
|
+
function ConsolePane(props) {
|
|
539
|
+
const t = props.t;
|
|
540
|
+
const form = props.form;
|
|
541
|
+
const tab = typeof props.useTabInfo === 'function' ? props.useTabInfo() : null;
|
|
542
|
+
const visible = tab?.tab?.visible !== false;
|
|
543
|
+
|
|
544
|
+
const [settings, setSettings] = React.useState(() => viewDefaults(form.getSnapshot()));
|
|
545
|
+
const [state, setState] = React.useState(null);
|
|
546
|
+
const [stateError, setStateError] = React.useState(null);
|
|
547
|
+
const [follow, setFollow] = React.useState(null);
|
|
548
|
+
const [manualVmid, setManualVmid] = React.useState(null);
|
|
549
|
+
const [streaming, setStreaming] = React.useState(true);
|
|
550
|
+
const [fps, setFps] = React.useState(null);
|
|
551
|
+
const [scale, setScale] = React.useState(null);
|
|
552
|
+
const [frame, setFrame] = React.useState(null);
|
|
553
|
+
const [frameError, setFrameError] = React.useState(null);
|
|
554
|
+
const [note, setNote] = React.useState(null);
|
|
555
|
+
const [tick, setTick] = React.useState(0);
|
|
556
|
+
const frameUrl = React.useRef(null);
|
|
557
|
+
const [, setLocaleTick] = React.useState(0);
|
|
558
|
+
|
|
559
|
+
React.useEffect(() => form.subscribe(() => setSettings(viewDefaults(form.getSnapshot()))), [form]);
|
|
560
|
+
// A language change must repaint the panel labels, not just its title.
|
|
561
|
+
React.useEffect(() => {
|
|
562
|
+
const unsubscribe = props.ctx.locale.subscribe?.(() => setLocaleTick((value) => value + 1));
|
|
563
|
+
return typeof unsubscribe === 'function' ? unsubscribe : undefined;
|
|
564
|
+
}, [props.ctx]);
|
|
565
|
+
|
|
566
|
+
// Node state: the allowed VMs, live sessions and whom to follow.
|
|
567
|
+
React.useEffect(() => {
|
|
568
|
+
if (!visible) return undefined;
|
|
569
|
+
let cancelled = false;
|
|
570
|
+
let timer;
|
|
571
|
+
const poll = async () => {
|
|
572
|
+
try {
|
|
573
|
+
const response = await fetch(`${VIEW_BASE}/state`, {
|
|
574
|
+
headers: { accept: 'application/json' },
|
|
575
|
+
cache: 'no-store',
|
|
576
|
+
});
|
|
577
|
+
const body = await response.json().catch(() => ({}));
|
|
578
|
+
if (cancelled) return;
|
|
579
|
+
if (response.ok) {
|
|
580
|
+
setState(body);
|
|
581
|
+
setStateError(null);
|
|
582
|
+
} else {
|
|
583
|
+
setStateError(body.error ?? `HTTP ${response.status}`);
|
|
584
|
+
}
|
|
585
|
+
} catch (error) {
|
|
586
|
+
if (!cancelled) setStateError(String(error?.message ?? error));
|
|
587
|
+
} finally {
|
|
588
|
+
if (!cancelled) timer = setTimeout(poll, STATE_POLL_MS);
|
|
589
|
+
}
|
|
590
|
+
};
|
|
591
|
+
poll();
|
|
592
|
+
return () => {
|
|
593
|
+
cancelled = true;
|
|
594
|
+
clearTimeout(timer);
|
|
595
|
+
};
|
|
596
|
+
}, [visible]);
|
|
597
|
+
|
|
598
|
+
const followAgent = follow ?? settings.followAgent;
|
|
599
|
+
const fpsEff = fps ?? settings.fps;
|
|
600
|
+
const scaleEff = scale ?? settings.scale;
|
|
601
|
+
const allowed = Array.isArray(state?.allowed) ? state.allowed : [];
|
|
602
|
+
const lastVmid = state?.last?.vmid ?? null;
|
|
603
|
+
const preferred = followAgent ? lastVmid : manualVmid;
|
|
604
|
+
const vmid = allowed.includes(preferred)
|
|
605
|
+
? preferred
|
|
606
|
+
: allowed.includes(manualVmid)
|
|
607
|
+
? manualVmid
|
|
608
|
+
: (allowed[0] ?? null);
|
|
609
|
+
|
|
610
|
+
React.useEffect(() => viewStore.set(vmid), [vmid]);
|
|
611
|
+
|
|
612
|
+
// The frame stream: the next request only after the previous answer.
|
|
613
|
+
React.useEffect(() => {
|
|
614
|
+
if (!visible || !streaming || vmid === null) return undefined;
|
|
615
|
+
let cancelled = false;
|
|
616
|
+
let timer;
|
|
617
|
+
const step = async () => {
|
|
618
|
+
const started = Date.now();
|
|
619
|
+
try {
|
|
620
|
+
const response = await fetch(`${VIEW_BASE}/frame?vmid=${vmid}&scale=${scaleEff}&cursor=1`, {
|
|
621
|
+
cache: 'no-store',
|
|
622
|
+
});
|
|
623
|
+
if (!response.ok) {
|
|
624
|
+
const body = await response.json().catch(() => ({}));
|
|
625
|
+
throw new Error(body.error ?? `HTTP ${response.status}`);
|
|
626
|
+
}
|
|
627
|
+
const blob = await response.blob();
|
|
628
|
+
if (cancelled) return;
|
|
629
|
+
const next = URL.createObjectURL(blob);
|
|
630
|
+
if (frameUrl.current !== null) URL.revokeObjectURL(frameUrl.current);
|
|
631
|
+
frameUrl.current = next;
|
|
632
|
+
setFrame({ url: next, meta: readFrameMeta(response.headers), bytes: blob.size, at: Date.now() });
|
|
633
|
+
setFrameError(null);
|
|
634
|
+
} catch (error) {
|
|
635
|
+
if (!cancelled) setFrameError(String(error?.message ?? error));
|
|
636
|
+
} finally {
|
|
637
|
+
if (!cancelled) {
|
|
638
|
+
const wait = Math.max(80, Math.round(1000 / fpsEff) - (Date.now() - started));
|
|
639
|
+
timer = setTimeout(step, wait);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
};
|
|
643
|
+
step();
|
|
644
|
+
return () => {
|
|
645
|
+
cancelled = true;
|
|
646
|
+
clearTimeout(timer);
|
|
647
|
+
};
|
|
648
|
+
}, [visible, streaming, vmid, scaleEff, fpsEff, tick]);
|
|
649
|
+
|
|
650
|
+
// The last frame lives in a blob URL: it must be released when the panel unloads.
|
|
651
|
+
React.useEffect(
|
|
652
|
+
() => () => {
|
|
653
|
+
if (frameUrl.current !== null) URL.revokeObjectURL(frameUrl.current);
|
|
654
|
+
},
|
|
655
|
+
[],
|
|
656
|
+
);
|
|
657
|
+
|
|
658
|
+
/** Close the VM console session on the node. */
|
|
659
|
+
const release = async () => {
|
|
660
|
+
try {
|
|
661
|
+
const response = await fetch(`${VIEW_BASE}/release`, {
|
|
662
|
+
method: 'POST',
|
|
663
|
+
headers: { 'content-type': 'application/json' },
|
|
664
|
+
body: JSON.stringify({ vmid }),
|
|
665
|
+
});
|
|
666
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
667
|
+
setNote(t('view.released'));
|
|
668
|
+
} catch {
|
|
669
|
+
setNote(t('view.releaseFailed'));
|
|
670
|
+
}
|
|
671
|
+
setTimeout(() => setNote(null), 2500);
|
|
672
|
+
};
|
|
673
|
+
|
|
674
|
+
const chips = allowed.map((id) =>
|
|
675
|
+
jsx(
|
|
676
|
+
'button',
|
|
677
|
+
{
|
|
678
|
+
type: 'button',
|
|
679
|
+
className: 'pve-vm-chip',
|
|
680
|
+
'data-active': id === vmid ? '1' : '0',
|
|
681
|
+
onClick: () => {
|
|
682
|
+
setManualVmid(id);
|
|
683
|
+
setFollow(false);
|
|
684
|
+
},
|
|
685
|
+
children: String(id),
|
|
686
|
+
},
|
|
687
|
+
id,
|
|
688
|
+
),
|
|
689
|
+
);
|
|
690
|
+
|
|
691
|
+
const banners = [];
|
|
692
|
+
if (stateError !== null) {
|
|
693
|
+
banners.push(jsx('div', { className: 'pve-vm-error', children: t('view.noHost', { message: stateError }) }, 'state'));
|
|
694
|
+
}
|
|
695
|
+
if (state?.consoleEnabled === false) {
|
|
696
|
+
banners.push(jsx('div', { className: 'pve-vm-warn', children: t('view.consoleOff') }, 'console'));
|
|
697
|
+
}
|
|
698
|
+
if (allowed.length === 0 && state !== null) {
|
|
699
|
+
banners.push(jsx('div', { className: 'pve-vm-warn', children: t('view.empty') }, 'empty'));
|
|
700
|
+
}
|
|
701
|
+
if (frameError !== null) {
|
|
702
|
+
banners.push(jsx('div', { className: 'pve-vm-error', children: t('view.hostError', { message: frameError }) }, 'frame'));
|
|
703
|
+
}
|
|
704
|
+
if (frame?.meta?.blank === true) {
|
|
705
|
+
banners.push(jsx('div', { className: 'pve-vm-warn', children: t('view.blank') }, 'blank'));
|
|
706
|
+
}
|
|
707
|
+
if (frame?.meta?.session === 'closed') {
|
|
708
|
+
banners.push(jsx('div', { className: 'pve-vm-warn', children: t('view.closed') }, 'closed'));
|
|
709
|
+
}
|
|
710
|
+
if (!streaming) {
|
|
711
|
+
banners.push(jsx('div', { className: 'pve-vm-line', children: t('view.paused') }, 'paused'));
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const status =
|
|
715
|
+
frame === null
|
|
716
|
+
? null
|
|
717
|
+
: `${t('view.status', {
|
|
718
|
+
vmid: String(vmid ?? '—'),
|
|
719
|
+
resolution: frame.meta.resolution ?? '—',
|
|
720
|
+
capture: frame.meta.capture ?? '—',
|
|
721
|
+
pointer: frame.meta.pointer ?? '—',
|
|
722
|
+
})} · ${t('view.bytes', { kb: String(Math.max(1, Math.round(frame.bytes / 1024))) })}`;
|
|
723
|
+
|
|
724
|
+
return jsxs('div', {
|
|
725
|
+
className: 'pve-vm-view',
|
|
726
|
+
children: [
|
|
727
|
+
jsxs('div', {
|
|
728
|
+
className: 'pve-vm-view-head',
|
|
729
|
+
children: [
|
|
730
|
+
...chips,
|
|
731
|
+
lastVmid !== null
|
|
732
|
+
? jsx('span', { className: 'pve-vm-line', children: t('view.agentVm', { vmid: String(lastVmid) }) })
|
|
733
|
+
: null,
|
|
734
|
+
],
|
|
735
|
+
}),
|
|
736
|
+
jsx('div', {
|
|
737
|
+
className: 'pve-vm-stage',
|
|
738
|
+
children:
|
|
739
|
+
frame === null
|
|
740
|
+
? jsx('div', {
|
|
741
|
+
className: 'pve-vm-stage-empty',
|
|
742
|
+
children: allowed.length === 0 ? t('view.empty') : t('view.waiting'),
|
|
743
|
+
})
|
|
744
|
+
: jsx('img', { src: frame.url, alt: '' }),
|
|
745
|
+
}),
|
|
746
|
+
status === null ? null : jsx('div', { className: 'pve-vm-line', children: status }),
|
|
747
|
+
...banners,
|
|
748
|
+
jsxs('div', {
|
|
749
|
+
className: 'pve-vm-tools',
|
|
750
|
+
children: [
|
|
751
|
+
jsx(Button, {
|
|
752
|
+
variant: 'outline',
|
|
753
|
+
size: 'sm',
|
|
754
|
+
children: streaming ? t('view.pause') : t('view.play'),
|
|
755
|
+
onClick: () => setStreaming((value) => !value),
|
|
756
|
+
}),
|
|
757
|
+
jsx(Button, {
|
|
758
|
+
variant: 'ghost',
|
|
759
|
+
size: 'sm',
|
|
760
|
+
disabled: vmid === null,
|
|
761
|
+
children: t('view.snap'),
|
|
762
|
+
onClick: () => setTick((value) => value + 1),
|
|
763
|
+
}),
|
|
764
|
+
jsx(Button, {
|
|
765
|
+
variant: 'ghost',
|
|
766
|
+
size: 'sm',
|
|
767
|
+
disabled: vmid === null,
|
|
768
|
+
children: t('view.release'),
|
|
769
|
+
onClick: release,
|
|
770
|
+
}),
|
|
771
|
+
jsx(Checkbox, {
|
|
772
|
+
checked: followAgent,
|
|
773
|
+
label: t('view.follow'),
|
|
774
|
+
onChange: (next) => setFollow(next),
|
|
775
|
+
}),
|
|
776
|
+
jsx('select', {
|
|
777
|
+
className: 'pve-vm-select',
|
|
778
|
+
value: String(fpsEff),
|
|
779
|
+
title: t('view.fps'),
|
|
780
|
+
onChange: (event) => setFps(Number(event.target.value)),
|
|
781
|
+
children: [1, 2, 3, 4].map((value) =>
|
|
782
|
+
jsx('option', { value: String(value), children: `${value} ${t('view.fps')}` }, value),
|
|
783
|
+
),
|
|
784
|
+
}),
|
|
785
|
+
jsx('select', {
|
|
786
|
+
className: 'pve-vm-select',
|
|
787
|
+
value: String(scaleEff),
|
|
788
|
+
title: t('view.scale'),
|
|
789
|
+
onChange: (event) => setScale(Number(event.target.value)),
|
|
790
|
+
children: [
|
|
791
|
+
{ value: 1, key: 'view.scaleNative' },
|
|
792
|
+
{ value: 2, key: 'view.scaleHalf' },
|
|
793
|
+
{ value: 4, key: 'view.scaleQuarter' },
|
|
794
|
+
].map((item) =>
|
|
795
|
+
jsx('option', { value: String(item.value), children: `${t('view.scale')}: ${t(item.key)}` }, item.value),
|
|
796
|
+
),
|
|
797
|
+
}),
|
|
798
|
+
note === null ? null : jsx('span', { className: 'pve-vm-ok', children: note }),
|
|
799
|
+
],
|
|
800
|
+
}),
|
|
801
|
+
],
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* Tab auto-open: when the agent touches a VM console the panel reveals itself —
|
|
807
|
+
* if the owner asked for it with the `viewAutoOpen` setting.
|
|
808
|
+
*
|
|
809
|
+
* The first state read only remembers the mark: otherwise every page reload
|
|
810
|
+
* would open the tab because of the agent's older work.
|
|
811
|
+
* @param {object} ctx the client context.
|
|
812
|
+
* @returns {() => void} stops watching.
|
|
813
|
+
*/
|
|
814
|
+
function armAutoOpen(ctx) {
|
|
815
|
+
const form = ctx.configForms.get(NAMESPACE);
|
|
816
|
+
let primed = false;
|
|
817
|
+
let seenAt = 0;
|
|
818
|
+
let stopped = false;
|
|
819
|
+
const tick = async () => {
|
|
820
|
+
if (stopped) return;
|
|
821
|
+
const values = form.getSnapshot()?.value ?? {};
|
|
822
|
+
if (values.viewAutoOpen !== true) return;
|
|
823
|
+
try {
|
|
824
|
+
const response = await fetch(`${VIEW_BASE}/state`, { headers: { accept: 'application/json' }, cache: 'no-store' });
|
|
825
|
+
if (!response.ok) return;
|
|
826
|
+
const state = await response.json();
|
|
827
|
+
const at = Number(state?.last?.at ?? 0);
|
|
828
|
+
if (!primed) {
|
|
829
|
+
primed = true;
|
|
830
|
+
seenAt = at;
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
if (at <= seenAt) return;
|
|
834
|
+
seenAt = at;
|
|
835
|
+
const sessionId = ctx.sidebarRight.mounted.getSnapshot();
|
|
836
|
+
if (sessionId === undefined) return;
|
|
837
|
+
ctx.sidebarRight.openTabIn(sessionId, TAB_KIND);
|
|
838
|
+
} catch {
|
|
839
|
+
// Network and response parsing are no reason to shout in the console: the
|
|
840
|
+
// panel shows the error anyway once it is opened.
|
|
841
|
+
}
|
|
842
|
+
};
|
|
843
|
+
const timer = setInterval(tick, AUTO_OPEN_POLL_MS);
|
|
844
|
+
return () => {
|
|
845
|
+
stopped = true;
|
|
846
|
+
clearInterval(timer);
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
|
|
367
850
|
/**
|
|
368
851
|
* Attach the card to the settings section.
|
|
369
852
|
* @param {object} ctx the client context.
|
|
@@ -393,6 +876,58 @@ window.__ModuleLoader__.load({
|
|
|
393
876
|
PveVmSection,
|
|
394
877
|
),
|
|
395
878
|
);
|
|
879
|
+
// The observation panel is a second, independent contribution. Not every
|
|
880
|
+
// composition has a right sidebar, so the services are declared through
|
|
881
|
+
// ctx.inject: without them the settings card still works (see §4.4.1 of the
|
|
882
|
+
// project AGENTS.md — never turn a service read into an undeclared, optional
|
|
883
|
+
// dependency).
|
|
884
|
+
// каждой композиции, поэтому сервисы объявлены через ctx.inject: без них
|
|
885
|
+
// карточка настроек всё равно работает (см. §4.4.1 проектного AGENTS.md —
|
|
886
|
+
// не превращать чтение сервиса в необязательную, но необъявленную зависимость).
|
|
887
|
+
ctx.inject(['sidebarRight', 'sidebarRightTabs'], (scoped) => {
|
|
888
|
+
const disposers = [];
|
|
889
|
+
const own = (dispose) => {
|
|
890
|
+
if (typeof dispose === 'function') disposers.push(dispose);
|
|
891
|
+
};
|
|
892
|
+
|
|
893
|
+
own(
|
|
894
|
+
scoped.sidebarRightTabs.register({
|
|
895
|
+
id: TAB_ID,
|
|
896
|
+
kind: TAB_KIND,
|
|
897
|
+
title: () => translate()('tab.title'),
|
|
898
|
+
guide: [
|
|
899
|
+
{
|
|
900
|
+
id: 'console',
|
|
901
|
+
order: 55,
|
|
902
|
+
title: () => translate()('tab.title'),
|
|
903
|
+
description: () => translate()('tab.guide'),
|
|
904
|
+
},
|
|
905
|
+
],
|
|
906
|
+
}),
|
|
907
|
+
);
|
|
908
|
+
|
|
909
|
+
const injectPane = () => ({ ctx, form: ctx.configForms.get(NAMESPACE) });
|
|
910
|
+
own(
|
|
911
|
+
scoped.slots.inject('sidebar.right.pane.tab', () =>
|
|
912
|
+
scoped.slots.register(
|
|
913
|
+
{ name: 'sidebar.right.pane.tab', key: TAB_ID, locale: LOCALE_NS, inject: injectPane },
|
|
914
|
+
ConsolePane,
|
|
915
|
+
),
|
|
916
|
+
),
|
|
917
|
+
);
|
|
918
|
+
own(
|
|
919
|
+
scoped.slots.inject('sidebar.right.pane.tab.title', () =>
|
|
920
|
+
scoped.slots.register({ name: 'sidebar.right.pane.tab.title', key: TAB_ID, locale: LOCALE_NS }, ConsoleTitle),
|
|
921
|
+
),
|
|
922
|
+
);
|
|
923
|
+
|
|
924
|
+
// Автооткрытие — отдельный слой: он живёт и когда панель закрыта.
|
|
925
|
+
const stopAutoOpen = armAutoOpen(ctx);
|
|
926
|
+
return () => {
|
|
927
|
+
stopAutoOpen();
|
|
928
|
+
for (const dispose of disposers.reverse()) dispose();
|
|
929
|
+
};
|
|
930
|
+
});
|
|
396
931
|
}
|
|
397
932
|
|
|
398
933
|
exports.apply = apply;
|
package/index.js
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { Config, resolveSettings, snapshot } from './lib/config.js';
|
|
13
13
|
import { VmController } from './lib/session.js';
|
|
14
|
+
import { ConsoleView } from './lib/view.js';
|
|
15
|
+
import { registerViewRoutes } from './lib/routes.js';
|
|
14
16
|
import { consoleTools } from './tools/console.js';
|
|
15
17
|
import { guestTools } from './tools/guest.js';
|
|
16
18
|
|
|
@@ -35,6 +37,13 @@ export function apply(ctx, config) {
|
|
|
35
37
|
const deps = { controller, settings, attachments: ctx.attachments };
|
|
36
38
|
for (const tool of [...consoleTools(deps), ...guestTools(deps)]) ctx.tools.register(tool);
|
|
37
39
|
|
|
40
|
+
// Observation panel: the client half pulls frames from these routes. A
|
|
41
|
+
// composition may have no web server at all (headless profile) — then there is
|
|
42
|
+
// simply no panel while the tools keep working, so the dependency is declared
|
|
43
|
+
// through ctx.inject instead of the plugin's mandatory `inject` list.
|
|
44
|
+
const view = new ConsoleView({ controller, settings });
|
|
45
|
+
ctx.inject(['webServer'], (scoped) => registerViewRoutes(scoped.webServer, { view }));
|
|
46
|
+
|
|
38
47
|
// The computer-use slot is per composition: if another provider has already taken it,
|
|
39
48
|
// the plugin tools still work, so the error is only recorded.
|
|
40
49
|
ctx.inject(['computerUse'], (scoped) => {
|
package/lib/config.js
CHANGED
|
@@ -56,10 +56,16 @@ export const CONFIG_KEYS = [
|
|
|
56
56
|
'allowVmids',
|
|
57
57
|
'enableConsole',
|
|
58
58
|
'enableGuest',
|
|
59
|
+
'inputChannel',
|
|
60
|
+
'sendkeyDelayMs',
|
|
59
61
|
'consoleTimeoutMs',
|
|
60
62
|
'sessionIdleMs',
|
|
61
63
|
'guestTimeoutMs',
|
|
62
64
|
'guestTypeCommand',
|
|
65
|
+
'viewAutoOpen',
|
|
66
|
+
'viewFollowAgent',
|
|
67
|
+
'viewFps',
|
|
68
|
+
'viewScale',
|
|
63
69
|
];
|
|
64
70
|
|
|
65
71
|
/** Settings schema: the id of the entry in `cordis.patch.yml` must match `pve-vm`. */
|
|
@@ -141,6 +147,36 @@ export const Config = z.object({
|
|
|
141
147
|
'Script for typing non-ASCII text in a Linux guest: takes the base64 text as its first argument ($1).',
|
|
142
148
|
)
|
|
143
149
|
.volatile(),
|
|
150
|
+
viewAutoOpen: z
|
|
151
|
+
.boolean()
|
|
152
|
+
.default(false)
|
|
153
|
+
.description(
|
|
154
|
+
'Open the "VM screen" tab in the right sidebar by itself when the agent touches a VM console.',
|
|
155
|
+
)
|
|
156
|
+
.volatile(),
|
|
157
|
+
viewFollowAgent: z
|
|
158
|
+
.boolean()
|
|
159
|
+
.default(true)
|
|
160
|
+
.description('The panel follows the VM the agent is working with by default.')
|
|
161
|
+
.volatile(),
|
|
162
|
+
viewFps: z
|
|
163
|
+
.number()
|
|
164
|
+
.step(1)
|
|
165
|
+
.min(1)
|
|
166
|
+
.max(4)
|
|
167
|
+
.default(2)
|
|
168
|
+
.description('How many frames per second the panel asks the node for (1–4).')
|
|
169
|
+
.volatile(),
|
|
170
|
+
viewScale: z
|
|
171
|
+
.number()
|
|
172
|
+
.step(1)
|
|
173
|
+
.min(1)
|
|
174
|
+
.max(4)
|
|
175
|
+
.default(2)
|
|
176
|
+
.description(
|
|
177
|
+
'Frame resolution divisor in the panel: 1 — native, 2 — half (default), 4 — quarter.',
|
|
178
|
+
)
|
|
179
|
+
.volatile(),
|
|
144
180
|
});
|
|
145
181
|
|
|
146
182
|
/**
|
|
@@ -169,7 +205,7 @@ function expandHome(path) {
|
|
|
169
205
|
/**
|
|
170
206
|
* Reduce the settings slice to a shape convenient for the tools.
|
|
171
207
|
* @param {Record<string, unknown>} section the result of {@link snapshot}.
|
|
172
|
-
* @returns {{ssh:object,allowVmids:number[],enableConsole:boolean,enableGuest:boolean,consoleTimeoutMs:number,sessionIdleMs:number,guestTimeoutMs:number,guestTypeCommand:string}}
|
|
208
|
+
* @returns {{ssh:object,allowVmids:number[],enableConsole:boolean,enableGuest:boolean,consoleTimeoutMs:number,sessionIdleMs:number,guestTimeoutMs:number,guestTypeCommand:string,view:{autoOpen:boolean,followAgent:boolean,fps:number,scale:number}}}
|
|
173
209
|
*/
|
|
174
210
|
export function resolveSettings(section) {
|
|
175
211
|
const allowVmids = Array.isArray(section.allowVmids)
|
|
@@ -193,5 +229,25 @@ export function resolveSettings(section) {
|
|
|
193
229
|
sessionIdleMs: Number(section.sessionIdleMs ?? 300000),
|
|
194
230
|
guestTimeoutMs: Number(section.guestTimeoutMs ?? 60000),
|
|
195
231
|
guestTypeCommand: typeof section.guestTypeCommand === 'string' ? section.guestTypeCommand : DEFAULT_GUEST_TYPE_COMMAND,
|
|
232
|
+
view: {
|
|
233
|
+
autoOpen: section.viewAutoOpen === true,
|
|
234
|
+
followAgent: section.viewFollowAgent !== false,
|
|
235
|
+
fps: clampNumber(section.viewFps, 1, 4, 2),
|
|
236
|
+
scale: clampNumber(section.viewScale, 1, 4, 2),
|
|
237
|
+
},
|
|
196
238
|
};
|
|
197
239
|
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* An integer inside bounds (for the panel fields: fps and the resolution divisor).
|
|
243
|
+
* @param {unknown} value live settings value.
|
|
244
|
+
* @param {number} min lower bound.
|
|
245
|
+
* @param {number} max upper bound.
|
|
246
|
+
* @param {number} fallback default value.
|
|
247
|
+
* @returns {number} a usable number.
|
|
248
|
+
*/
|
|
249
|
+
function clampNumber(value, min, max, fallback) {
|
|
250
|
+
const parsed = Math.round(Number(value));
|
|
251
|
+
if (!Number.isFinite(parsed)) return fallback;
|
|
252
|
+
return Math.max(min, Math.min(max, parsed));
|
|
253
|
+
}
|
package/lib/png.js
CHANGED
|
@@ -106,6 +106,25 @@ export function downscaleBox(image, factor) {
|
|
|
106
106
|
return { width, height, data: out, scale: 1 / step };
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
/**
|
|
110
|
+
* An almost single-colour frame: most often a blanked display or a black lock screen.
|
|
111
|
+
* The check samples coarsely instead of scanning a million pixels. It lives here, not in
|
|
112
|
+
* the tools, because the observation panel needs the very same verdict.
|
|
113
|
+
* @param {{width:number,height:number,data:Uint8Array}} image RGBA frame.
|
|
114
|
+
* @returns {boolean} true when the frame is practically one colour.
|
|
115
|
+
*/
|
|
116
|
+
export function looksBlank(image) {
|
|
117
|
+
const step = Math.max(4, Math.floor((image.width * image.height) / 4096)) * 4;
|
|
118
|
+
let min = 255;
|
|
119
|
+
let max = 0;
|
|
120
|
+
for (let i = 0; i < image.data.length; i += step) {
|
|
121
|
+
const value = image.data[i];
|
|
122
|
+
if (value < min) min = value;
|
|
123
|
+
if (value > max) max = value;
|
|
124
|
+
}
|
|
125
|
+
return max - min <= 6;
|
|
126
|
+
}
|
|
127
|
+
|
|
109
128
|
/**
|
|
110
129
|
* Rectangle cropping; the coordinates are clipped to the frame.
|
|
111
130
|
* @param {{width:number,height:number,data:Uint8Array}} image the source RGBA frame.
|
package/lib/rfb.js
CHANGED
|
@@ -350,19 +350,38 @@ export class RfbClient extends EventEmitter {
|
|
|
350
350
|
|
|
351
351
|
/**
|
|
352
352
|
* Read messages until the next FramebufferUpdate.
|
|
353
|
-
*
|
|
354
|
-
*
|
|
353
|
+
*
|
|
354
|
+
* `idleOk` is what the observation panel needs: it asks for an incremental frame
|
|
355
|
+
* often, and "nothing changed" is a normal answer from QEMU (it simply stays
|
|
356
|
+
* silent). Silence at a message boundary is safe — the current framebuffer is
|
|
357
|
+
* returned. A timeout in the middle of a message means half a frame was lost,
|
|
358
|
+
* which is a real error and the session has to be recreated.
|
|
359
|
+
* @param {{timeoutMs?:number, idleOk?:boolean}} [options] wait timeout and tolerance for silence.
|
|
360
|
+
* @returns {Promise<{rectangles:number, bytes:number, resized:boolean, cursor:boolean, bell:boolean, idle?:boolean}>}
|
|
355
361
|
*/
|
|
356
362
|
async waitForUpdate(options = {}) {
|
|
357
363
|
const deadline = Date.now() + (options.timeoutMs ?? this.timeoutMs);
|
|
364
|
+
const idleOk = options.idleOk === true;
|
|
358
365
|
let rectangles = 0;
|
|
359
366
|
let cursorUpdated = false;
|
|
360
367
|
let bell = false;
|
|
361
368
|
let resized = false;
|
|
362
369
|
for (;;) {
|
|
363
370
|
const left = deadline - Date.now();
|
|
364
|
-
if (left <= 0)
|
|
365
|
-
|
|
371
|
+
if (left <= 0) {
|
|
372
|
+
if (idleOk) return { rectangles: 0, bytes: 0, resized: false, cursor: false, bell: false, idle: true };
|
|
373
|
+
throw new RfbError('timeout waiting for an RFB frame', 'TIMEOUT');
|
|
374
|
+
}
|
|
375
|
+
let first;
|
|
376
|
+
try {
|
|
377
|
+
first = await this.reader.exact(1, left);
|
|
378
|
+
} catch (error) {
|
|
379
|
+
if (idleOk && error?.code === 'TIMEOUT') {
|
|
380
|
+
return { rectangles: 0, bytes: 0, resized: false, cursor: false, bell: false, idle: true };
|
|
381
|
+
}
|
|
382
|
+
throw error;
|
|
383
|
+
}
|
|
384
|
+
const type = first[0];
|
|
366
385
|
if (type === 0) {
|
|
367
386
|
const header = await this.reader.exact(3, left);
|
|
368
387
|
const count = header.readUInt16BE(1);
|
package/lib/routes.js
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP routes of the observation panel: `/pve-vm/console/*`.
|
|
3
|
+
*
|
|
4
|
+
* Why HTTP and not a harness RPC. The panel is an ordinary page of the same origin,
|
|
5
|
+
* and frames are easy to pull with `fetch`; the browser-skill overview is built the
|
|
6
|
+
* same way (`/bsk-observation/*`). The routes register on the `webServer` service; a
|
|
7
|
+
* composition without a web server (a headless profile) simply never registers them,
|
|
8
|
+
* while the agent tools keep working as usual.
|
|
9
|
+
*
|
|
10
|
+
* Security. A VM screen is sensitive: passwords, chats, a working desktop. So these
|
|
11
|
+
* routes carry their own fence: we answer only when Host is a loopback authority
|
|
12
|
+
* (localhost / 127.0.0.0/8 / [::1]), a present Origin matches Host, and
|
|
13
|
+
* `sec-fetch-site: cross-site` is refused outright; POST must be `application/json`,
|
|
14
|
+
* so a cross-site simple request never reaches the handler. DSH itself applies the
|
|
15
|
+
* same order of checks to `/api` (`@deepseek-ai/dsh-client-connection`), and so does
|
|
16
|
+
* browser-skill; it is repeated here so that the published plugin depends on no
|
|
17
|
+
* internal package.
|
|
18
|
+
*/
|
|
19
|
+
import { VmError } from './session.js';
|
|
20
|
+
|
|
21
|
+
/** Base path of the panel routes. */
|
|
22
|
+
export const VIEW_BASE = '/pve-vm/console';
|
|
23
|
+
|
|
24
|
+
/** Error codes to HTTP statuses. */
|
|
25
|
+
const STATUS_BY_CODE = {
|
|
26
|
+
BAD_VMID: 400,
|
|
27
|
+
NOT_ALLOWED: 403,
|
|
28
|
+
CHANNEL_DISABLED: 403,
|
|
29
|
+
CONSOLE_CLOSED: 409,
|
|
30
|
+
CONSOLE_FAILED: 502,
|
|
31
|
+
SSH_FAILED: 502,
|
|
32
|
+
EOF: 502,
|
|
33
|
+
TIMEOUT: 504,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** Answer with JSON. */
|
|
37
|
+
function sendJson(res, status, body) {
|
|
38
|
+
const payload = Buffer.from(JSON.stringify(body), 'utf8');
|
|
39
|
+
res.writeHead(status, {
|
|
40
|
+
'content-type': 'application/json; charset=utf-8',
|
|
41
|
+
'content-length': String(payload.length),
|
|
42
|
+
'cache-control': 'no-store',
|
|
43
|
+
});
|
|
44
|
+
res.end(payload);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Turn a panel request failure into a response. */
|
|
48
|
+
function fail(res, error) {
|
|
49
|
+
if (res.headersSent) {
|
|
50
|
+
res.end();
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const code = error instanceof VmError ? error.code : undefined;
|
|
54
|
+
const status = STATUS_BY_CODE[code] ?? 500;
|
|
55
|
+
sendJson(res, status, { error: error?.message ?? String(error), ...(code ? { code } : {}) });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The fence's verdict: the reason for refusal, or undefined.
|
|
60
|
+
* @param {import('node:http').IncomingMessage} req the request.
|
|
61
|
+
* @returns {string|undefined} the reason for refusal.
|
|
62
|
+
*/
|
|
63
|
+
function fenceViolation(req) {
|
|
64
|
+
const host = req.headers.host ?? '';
|
|
65
|
+
const hostname = /^\[.*\](?::\d+)?$/.test(host) ? host.slice(1, host.indexOf(']')) : host.split(':')[0];
|
|
66
|
+
const loopback =
|
|
67
|
+
hostname === 'localhost' ||
|
|
68
|
+
hostname.endsWith('.localhost') ||
|
|
69
|
+
hostname === '::1' ||
|
|
70
|
+
/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname);
|
|
71
|
+
if (!loopback) return 'host is not a loopback authority';
|
|
72
|
+
const origin = req.headers.origin;
|
|
73
|
+
if (origin !== undefined && origin !== 'null') {
|
|
74
|
+
let originHost;
|
|
75
|
+
try {
|
|
76
|
+
originHost = new URL(origin).host;
|
|
77
|
+
} catch {
|
|
78
|
+
return 'unparseable Origin header';
|
|
79
|
+
}
|
|
80
|
+
if (originHost !== host) return 'Origin does not match Host';
|
|
81
|
+
}
|
|
82
|
+
if (req.headers['sec-fetch-site'] === 'cross-site') return 'sec-fetch-site: cross-site';
|
|
83
|
+
if (req.method === 'POST') {
|
|
84
|
+
const contentType = req.headers['content-type'] ?? '';
|
|
85
|
+
if (!/^\s*application\/json\s*(;|$)/.test(contentType)) return 'POST requires an application/json body';
|
|
86
|
+
}
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Run the fence; true means the request was already refused and answered. */
|
|
91
|
+
function fenceRejected(req, res) {
|
|
92
|
+
const violation = fenceViolation(req);
|
|
93
|
+
if (violation === undefined) return false;
|
|
94
|
+
sendJson(res, 403, { error: `forbidden: ${violation}` });
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Read the JSON body of a small request. */
|
|
99
|
+
async function readJsonBody(req) {
|
|
100
|
+
const chunks = [];
|
|
101
|
+
let size = 0;
|
|
102
|
+
for await (const chunk of req) {
|
|
103
|
+
size += chunk.length;
|
|
104
|
+
if (size > 64 * 1024) throw new VmError('request body is too large', 'BAD_BODY');
|
|
105
|
+
chunks.push(chunk);
|
|
106
|
+
}
|
|
107
|
+
if (size === 0) return {};
|
|
108
|
+
try {
|
|
109
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
110
|
+
} catch {
|
|
111
|
+
throw new VmError('request body is not JSON', 'BAD_BODY');
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Register the panel routes on the web server.
|
|
117
|
+
* @param {object} webServer the `webServer` service.
|
|
118
|
+
* @param {{view: object}} deps the observation panel.
|
|
119
|
+
* @returns {() => void} disposer removing the routes.
|
|
120
|
+
*/
|
|
121
|
+
export function registerViewRoutes(webServer, deps) {
|
|
122
|
+
const view = deps.view;
|
|
123
|
+
const disposers = [
|
|
124
|
+
webServer.register({
|
|
125
|
+
kind: 'exact',
|
|
126
|
+
path: `${VIEW_BASE}/state`,
|
|
127
|
+
handler: (req, res) => {
|
|
128
|
+
if (req.method !== 'GET') {
|
|
129
|
+
sendJson(res, 405, { error: 'method not allowed' });
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (fenceRejected(req, res)) return;
|
|
133
|
+
try {
|
|
134
|
+
sendJson(res, 200, view.state());
|
|
135
|
+
} catch (error) {
|
|
136
|
+
fail(res, error);
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
}),
|
|
140
|
+
webServer.register({
|
|
141
|
+
kind: 'exact',
|
|
142
|
+
path: `${VIEW_BASE}/frame`,
|
|
143
|
+
handler: async (req, res) => {
|
|
144
|
+
if (req.method !== 'GET') {
|
|
145
|
+
sendJson(res, 405, { error: 'method not allowed' });
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (fenceRejected(req, res)) return;
|
|
149
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
150
|
+
const rawScale = url.searchParams.get('scale');
|
|
151
|
+
try {
|
|
152
|
+
const { png, meta } = await view.frame(Number(url.searchParams.get('vmid')), {
|
|
153
|
+
scale: rawScale === null ? undefined : Number(rawScale),
|
|
154
|
+
cursor: url.searchParams.get('cursor') !== '0',
|
|
155
|
+
});
|
|
156
|
+
res.writeHead(200, {
|
|
157
|
+
'content-type': 'image/png',
|
|
158
|
+
'content-length': String(png.length),
|
|
159
|
+
'cache-control': 'no-store, max-age=0',
|
|
160
|
+
'x-pve-vm-vmid': String(meta.vmid),
|
|
161
|
+
'x-pve-vm-resolution': `${meta.width}x${meta.height}`,
|
|
162
|
+
'x-pve-vm-capture': `${meta.captureWidth}x${meta.captureHeight}`,
|
|
163
|
+
'x-pve-vm-scale': String(meta.scale),
|
|
164
|
+
'x-pve-vm-pointer': `${meta.pointerX},${meta.pointerY}`,
|
|
165
|
+
'x-pve-vm-cursor': meta.cursorDrawn ? '1' : '0',
|
|
166
|
+
'x-pve-vm-blank': meta.blank ? '1' : '0',
|
|
167
|
+
'x-pve-vm-session': meta.sessionAlive ? 'alive' : 'closed',
|
|
168
|
+
});
|
|
169
|
+
res.end(png);
|
|
170
|
+
} catch (error) {
|
|
171
|
+
fail(res, error);
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
}),
|
|
175
|
+
webServer.register({
|
|
176
|
+
kind: 'exact',
|
|
177
|
+
path: `${VIEW_BASE}/release`,
|
|
178
|
+
handler: async (req, res) => {
|
|
179
|
+
if (req.method !== 'POST') {
|
|
180
|
+
sendJson(res, 405, { error: 'method not allowed' });
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (fenceRejected(req, res)) return;
|
|
184
|
+
try {
|
|
185
|
+
const body = await readJsonBody(req);
|
|
186
|
+
const closed = view.release(Number(body.vmid));
|
|
187
|
+
sendJson(res, 200, { closed });
|
|
188
|
+
} catch (error) {
|
|
189
|
+
fail(res, error);
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
}),
|
|
193
|
+
];
|
|
194
|
+
return () => {
|
|
195
|
+
for (const dispose of disposers.reverse()) dispose();
|
|
196
|
+
};
|
|
197
|
+
}
|
package/lib/session.js
CHANGED
|
@@ -55,10 +55,20 @@ export class VmController {
|
|
|
55
55
|
this.sessions = new Map();
|
|
56
56
|
/** @type {Map<number, string>} */
|
|
57
57
|
this.osCache = new Map();
|
|
58
|
+
/** The last VM whose console the agent touched: the panel auto-opens on it. */
|
|
59
|
+
this.lastConsole = null;
|
|
58
60
|
this.reaper = setInterval(() => this.#reapIdle(), 30000);
|
|
59
61
|
this.reaper.unref?.();
|
|
60
62
|
}
|
|
61
63
|
|
|
64
|
+
/**
|
|
65
|
+
* The last console use: the observation panel shows this VM.
|
|
66
|
+
* @returns {{vmid:number, at:number}|null} vmid and time (ms), or null.
|
|
67
|
+
*/
|
|
68
|
+
lastConsoleUse() {
|
|
69
|
+
return this.lastConsole;
|
|
70
|
+
}
|
|
71
|
+
|
|
62
72
|
/** Validate the VM id against the allowlist and the channel switches. */
|
|
63
73
|
assertAllowed(vmid, channel) {
|
|
64
74
|
const settings = this.deps.settings();
|
|
@@ -275,12 +285,19 @@ export class VmController {
|
|
|
275
285
|
* Run an operation with exclusive access to the VM console.
|
|
276
286
|
* @param {number} vmid VM id.
|
|
277
287
|
* @param {(client: RfbClient, session: object) => Promise<T>} operation the action.
|
|
278
|
-
* @param {{signal?: AbortSignal}} [options]
|
|
288
|
+
* @param {{signal?: AbortSignal, mark?:boolean}} [options] cancellation and the "the agent worked with this VM" mark.
|
|
279
289
|
* @returns {Promise<T>}
|
|
280
290
|
* @template T
|
|
281
291
|
*/
|
|
282
292
|
async withConsole(vmid, operation, options = {}) {
|
|
283
293
|
const id = this.assertAllowed(vmid, 'console');
|
|
294
|
+
// A mark for the observation panel: it follows the VM the agent works with.
|
|
295
|
+
// The panel watches frames through this very method, so it has to suppress the
|
|
296
|
+
// mark (`mark: false`) — otherwise watching declares itself agent work and the
|
|
297
|
+
// "follow the agent" mode jumps between VMs (seen on a live panel 2026-10-13:
|
|
298
|
+
// right after a restart `last` named the VM the owner was watching, while no
|
|
299
|
+
// tool had touched it).
|
|
300
|
+
if (options.mark !== false) this.lastConsole = { vmid: id, at: Date.now() };
|
|
284
301
|
const session = await this.#consoleSession(id, options.signal);
|
|
285
302
|
const run = async () => {
|
|
286
303
|
if (!session.alive) {
|
package/lib/view.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The live VM screen for the observation panel.
|
|
3
|
+
*
|
|
4
|
+
* Why a separate module. The agent's tools capture a frame on demand and hand it
|
|
5
|
+
* back as an attachment; a panel needs something else: often, cheaply, and without
|
|
6
|
+
* attachments — "what is on screen right now". The frame comes from **the same**
|
|
7
|
+
* RFB session the tools use (`VmController.withConsole`), therefore:
|
|
8
|
+
*
|
|
9
|
+
* • no second VNC session to the VM appears, and the `qm vncproxy` password is
|
|
10
|
+
* not reset under the agent's feet;
|
|
11
|
+
* • there is still exactly one session queue — the panel and the agent never
|
|
12
|
+
* tear the stream for each other;
|
|
13
|
+
* • a session the panel holds open is not reaped by the idle reaper (every
|
|
14
|
+
* capture refreshes `lastUsed`).
|
|
15
|
+
*
|
|
16
|
+
* Frames are requested incrementally: when the screen did not change QEMU stays
|
|
17
|
+
* silent and the panel gets the current framebuffer from the client's memory (see
|
|
18
|
+
* `idleOk` in `RfbClient.waitForUpdate`).
|
|
19
|
+
*/
|
|
20
|
+
import { encodePng, downscaleBox, looksBlank } from './png.js';
|
|
21
|
+
|
|
22
|
+
/** Lower bound on waiting for QEMU: below it we simply cannot read the answer. */
|
|
23
|
+
const MIN_WAIT_MS = 120;
|
|
24
|
+
/**
|
|
25
|
+
* Upper bound on waiting. This is exactly how long the panel holds the session
|
|
26
|
+
* queue when the screen does not change (QEMU stays silent on an incremental
|
|
27
|
+
* request) — and therefore how long the agent waits for its own operation.
|
|
28
|
+
* 250 ms is enough: QEMU hands over changed regions right after the request, and
|
|
29
|
+
* anything that happens later is picked up by the next frame.
|
|
30
|
+
*/
|
|
31
|
+
const MAX_WAIT_MS = 250;
|
|
32
|
+
/** zlib level for panel frames: speed over size, the channel is loopback. */
|
|
33
|
+
const PNG_LEVEL = 3;
|
|
34
|
+
/** Allowed resolution divisors (1 — native). */
|
|
35
|
+
const DIVISORS = [1, 2, 3, 4];
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* How long to wait for a frame at the panel's pace.
|
|
39
|
+
* @param {number} fps frames per second from the settings.
|
|
40
|
+
* @returns {number} wait timeout, ms.
|
|
41
|
+
*/
|
|
42
|
+
function waitForFps(fps) {
|
|
43
|
+
const interval = 1000 / Math.max(1, fps);
|
|
44
|
+
return Math.max(MIN_WAIT_MS, Math.min(MAX_WAIT_MS, Math.round(interval - 50)));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* One VM's screen as the agent sees it.
|
|
49
|
+
*/
|
|
50
|
+
export class ConsoleView {
|
|
51
|
+
/**
|
|
52
|
+
* @param {{controller: object, settings: () => object}} deps the VM controller and live settings.
|
|
53
|
+
*/
|
|
54
|
+
constructor(deps) {
|
|
55
|
+
this.deps = deps;
|
|
56
|
+
/** @type {Map<string, Promise<object>>} captures already running: two clients do not queue twice. */
|
|
57
|
+
this.pending = new Map();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** State for the panel: what is allowed, what is alive, whom to follow. */
|
|
61
|
+
state() {
|
|
62
|
+
const settings = this.deps.settings();
|
|
63
|
+
const last = this.deps.controller.lastConsoleUse();
|
|
64
|
+
return {
|
|
65
|
+
allowed: settings.allowVmids,
|
|
66
|
+
consoleEnabled: settings.enableConsole,
|
|
67
|
+
sessions: this.deps.controller.listSessions(),
|
|
68
|
+
last: last ? { vmid: last.vmid, at: last.at } : null,
|
|
69
|
+
view: settings.view,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* A frame of the VM screen as PNG.
|
|
75
|
+
* @param {number} vmid VM id.
|
|
76
|
+
* @param {{scale?:number, cursor?:boolean}} [options] resolution divisor and whether to draw the cursor.
|
|
77
|
+
* @returns {Promise<{png:Buffer, meta:object}>} the PNG and its metadata.
|
|
78
|
+
*/
|
|
79
|
+
frame(vmid, options = {}) {
|
|
80
|
+
const settings = this.deps.settings();
|
|
81
|
+
const id = this.deps.controller.assertAllowed(vmid, 'console');
|
|
82
|
+
const divisor = DIVISORS.includes(Number(options.scale)) ? Number(options.scale) : settings.view.scale;
|
|
83
|
+
const drawCursor = options.cursor !== false;
|
|
84
|
+
const key = `${id}:${divisor}:${drawCursor ? 1 : 0}`;
|
|
85
|
+
const running = this.pending.get(key);
|
|
86
|
+
if (running !== undefined) return running;
|
|
87
|
+
const task = this.#capture(id, divisor, drawCursor, waitForFps(settings.view.fps)).finally(() => {
|
|
88
|
+
this.pending.delete(key);
|
|
89
|
+
});
|
|
90
|
+
this.pending.set(key, task);
|
|
91
|
+
return task;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Close the VM console session (the panel's "disconnect" button).
|
|
96
|
+
* @param {number} vmid VM id.
|
|
97
|
+
* @returns {boolean} true when a session existed and was closed.
|
|
98
|
+
*/
|
|
99
|
+
release(vmid) {
|
|
100
|
+
return this.deps.controller.close(vmid);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Capture a frame with exclusive access to the console.
|
|
105
|
+
* @param {number} vmid VM id.
|
|
106
|
+
* @param {number} divisor resolution divisor.
|
|
107
|
+
* @param {boolean} drawCursor whether to draw the cursor.
|
|
108
|
+
* @param {number} waitMs how long to wait for the incremental answer.
|
|
109
|
+
* @returns {Promise<{png:Buffer, meta:object}>}
|
|
110
|
+
*/
|
|
111
|
+
async #capture(vmid, divisor, drawCursor, waitMs) {
|
|
112
|
+
// `mark: false` — watching is not agent work: otherwise the panel would move
|
|
113
|
+
// the "whom to follow" mark on every frame.
|
|
114
|
+
return this.deps.controller.withConsole(
|
|
115
|
+
vmid,
|
|
116
|
+
async (client, session) => {
|
|
117
|
+
// The first frame of a session is requested in full: a fresh client has no
|
|
118
|
+
// "changed" regions yet, and an incremental request could stay silent.
|
|
119
|
+
client.requestUpdate({ incremental: client.bytesIn > 0 });
|
|
120
|
+
await client.waitForUpdate({ timeoutMs: waitMs, idleOk: true });
|
|
121
|
+
const frame = client.snapshot({ drawCursor });
|
|
122
|
+
const scaled = divisor > 1 ? downscaleBox(frame, divisor) : { ...frame, scale: 1 };
|
|
123
|
+
const png = encodePng(scaled, { level: PNG_LEVEL });
|
|
124
|
+
return {
|
|
125
|
+
png,
|
|
126
|
+
meta: {
|
|
127
|
+
vmid,
|
|
128
|
+
width: frame.width,
|
|
129
|
+
height: frame.height,
|
|
130
|
+
captureWidth: scaled.width,
|
|
131
|
+
captureHeight: scaled.height,
|
|
132
|
+
scale: divisor,
|
|
133
|
+
pointerX: client.pointer.x,
|
|
134
|
+
pointerY: client.pointer.y,
|
|
135
|
+
cursorDrawn: Boolean(client.cursor) && drawCursor,
|
|
136
|
+
blank: looksBlank(frame),
|
|
137
|
+
bytes: png.length,
|
|
138
|
+
sessionAlive: session.alive !== false,
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
},
|
|
142
|
+
{ mark: false },
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-pve-vm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Proxmox VE VM control for DeepSeek Harness: see the screen and click in a VM console over VNC, plus commands, files and typed text through the QEMU guest agent.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -58,6 +58,7 @@
|
|
|
58
58
|
"inject": [
|
|
59
59
|
"@deepseek-ai/dsh-client-ui-settings",
|
|
60
60
|
"@deepseek-ai/dsh-client-ui-slots",
|
|
61
|
+
"@deepseek-ai/dsh-client-ui-sidebar-right",
|
|
61
62
|
"@deepseek-ai/dsh-client-locale"
|
|
62
63
|
]
|
|
63
64
|
}
|
|
@@ -74,7 +75,7 @@
|
|
|
74
75
|
"@deepseek-ai/schemastery": "^3.18.4"
|
|
75
76
|
},
|
|
76
77
|
"scripts": {
|
|
77
|
-
"check": "node --check index.js && node --check client.js && node --check lib/config.js && node --check lib/guest.js && node --check lib/hmp.js && node --check lib/keys.js && node --check lib/png.js && node --check lib/rfb.js && node --check lib/session.js && node --check lib/ssh.js && node --check tools/console.js && node --check tools/guest.js",
|
|
78
|
+
"check": "node --check index.js && node --check client.js && node --check lib/config.js && node --check lib/guest.js && node --check lib/hmp.js && node --check lib/keys.js && node --check lib/png.js && node --check lib/rfb.js && node --check lib/routes.js && node --check lib/session.js && node --check lib/ssh.js && node --check lib/view.js && node --check tools/console.js && node --check tools/guest.js",
|
|
78
79
|
"test": "node tools/check-plugin.mjs && node tools/check-client.mjs",
|
|
79
80
|
"prepublishOnly": "npm run check",
|
|
80
81
|
"probe": "node tools/probe-rfb.mjs"
|