dsh-pve-vm 0.1.0 → 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 +66 -0
- package/README.md +34 -0
- package/client.js +535 -0
- package/index.js +9 -0
- package/lib/config.js +71 -3
- package/lib/png.js +19 -0
- package/lib/rfb.js +23 -4
- package/lib/routes.js +197 -0
- package/lib/session.js +37 -1
- package/lib/ssh.js +19 -2
- package/lib/view.js +145 -0
- package/package.json +3 -2
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) => {
|