登录
ZH
# xencenter-xenadmin-xcp-jumphp — notes for agentsXenCenter (xenadmin) rewritten as a **JumPHP graphic app** (`kind: gui`, `view: window`): it administrates XCP-ng / XenServerpools through **XAPI JSON-RPC**. **Read `../../AGENTS.md` first** (§17 graphic apps, §9 Tcp / Ssh / runtime).```bashbin/jumphp test xencenter-xenadmin-xcp-jumphp      # php -l, phpstan, 37 tests (unit + tests that use the window), smokeJUMPHP_SCENE='App\Form\MainForm' JUMPHP_SCENE_PARAMS='{"demo":true}' bin/jumphp shot xencenter-xenadmin-xcp-jumphp 290 var/screenshots/perf.pngbin/jumphp run xencenter-xenadmin-xcp-jumphp       # the window (File > Open the Demo Pool needs no server)```## Layers (src/)| Folder | What ||---|---|| `Xapi/` | `HttpStreamTransfer` (one big HTTP transfer on its own stream: download body → file chunk by chunk (length / chunked / until close), upload file in 64 KiB chunks, a byte budget per frame + `buffered()` backpressure; polled by `Connection::$transfers`), `Xva` (tar + ova.xml writer / header reader, gzip too), `Perfmon` (other_config "perfmon" XML, ALARM bodies), `Fake/` (traits of FakeXapi: `UpdatesOps`, `XvaOps` + `FakeHttpStream` = the demo pool's HTTP handlers via `connector()`, `AlertsOps` = perfmon raising ALARM messages from the fake RRD), `Transport` (how bytes reach XAPI), `HttpChannel` (HTTP/1.1 keep-alive over any stream with on/write/close: Content-Length, chunked, read-until-close), `StreamTransport` (one channel per name: `calls`, `events` (long poll), `rrd`), `TlsStream` (desktop HTTPS, self-signed accepted), `XapiClient` (JSON-RPC, session, `event.from`, `/rrd_updates`), `Rrd` (XML parser), `XapiError`, **`FakeXapi`** (a whole XCP-ng 8.3 pool in memory: records, Async tasks that progress, event.from, RRD) || `Model/` | `Cache` (records by class/ref + questions: VMs of a host, disks of a VM...), `Connection` (one server: login, HOST_IS_SLAVE redirect, event.from loop, `run()` = `Async.*` task tracked to the end, `call()`), `Session` (service: the connections, var/servers.json, the Events log), `ServerProfile`, `LogEntry`, `Chain` (sequential Tasks) || `View/` | pure code, testable: `TreeBuilder` (Infrastructure / Objects trees as XenCenter shows them), `Pages` (each tab's content as a `Page`: sheet / grid / charts / text / home), `Commands` (menus, right clicks, toolbar states, confirmations, power calls) || `Gui/` | custom widgets: `Canvas` (clipped drawing into a texture + scroll + right-click popup), `TreeView`, `TabStrip`, `DataGrid` (sortable, usage bars), `PropertySheet` (General tab sections), `LineChart`, `MenuBar`, `PopupMenu`, `ToolButton`, `Banner`, `IconBox`, `Icons` (every icon drawn with rectangles: no image file), `Units` || `Vnc/` | the console: `RfbClient` (RFB 3.3/3.7/3.8 in PHP: XAPI's `CONNECT /console?ref=…&session_id=…` then RFB, security None, Raw + CopyRect + DesktopSize, keys / pointer / wheel), `Framebuffer` (rows of B G R 0 bytes, dirty rectangle, `bmp()`), `Keysym`, `FakeVnc` (a text console server of the demo pool: login, a few commands) || `Feature/Organize/` | XenCenter's Organization views: `Organizer` (folders = other_config `folder` "/Production/Web", empty folders = the pool's other_config `folders` JSON list, tags = the `tags` field; setFolder / setTags / new, rename, delete folder, rename tag as XAPI calls), `OrganizeTree` (Folders / Tags nodes; ids `folder:/Path`, `tag:name`, `tag:name\|<ref>` for an object under a tag), `OrganizeCommands` (`organize.*` commands, right-click items, the folder / tag page), `OrganizeProperties` (Tags and Folder of the Properties dialog) || `Feature/` | kept apart from the shared files (MainForm / Commands / Pages / FakeXapi only call in, a few grouped lines): `FeatureCommands` (menu items, confirmations, `run()` from `MainForm::command()`, `result()` from `resume()`), `Updates/` (`Updates`: `host.call_plugin(host, 'updater.py', 'check_update'\|'update', {})`, per connection in a WeakMap, tree marks; `RollingUpdate`: master first, disable → evacuate → update → reboot if kernel/xen/xapi → wait back → enable; `UpdatesPage`: the Updates tab), `Xva/XvaTransfers` (task.create, then GET /export or PUT /import on a stream of its own, followed with `Connection::follow()`) || `Form/` | `MainForm` (the window: wires everything, runs commands), dialogs `AddServerForm`, `ConfirmForm`, `NameForm` (snapshot / copy), `PropertiesForm`, `NewVmForm` (+ the XAPI sequence of the New VM wizard), `AboutForm`, `NewPoolForm`, `HostAddressesForm`, `ExportForm` / `ImportForm` (XVA), `AlertsForm` (perfmon alarms, pool e-mail: mail-destination, ssmtp-mailhub; from Properties > Alerts… too) |## Connections (ServerProfile::METHODS)- `http`: `$engine->tcp()` to port 80 (desktop socket; browser: JumPHP runtime = user's network, else the server's TCP bridge, which only allows DB ports: port 80 there needs the runtime or the SSH route).- `https`: `TlsStream`, desktop only.- `ssh`: `$engine->ssh()->via([...])->connect('127.0.0.1', 80)`: `ssh -W` through the host's own sshd (system ssh on desktop, ssh.wasm in the browser; needs the app's SSH right on jumphp.net).- `demo`: `FakeXapi`.## Saved passwords (Model/Keyring)- var/servers.json never holds a clear password: `ServerProfile::toArray($keyring)` writes `secret` = the password sealed by `Keyring`  (`sb1:` libsodium secretbox, `gcm1:` OpenSSL AES-256-GCM, `hm1:` pure-PHP HMAC-SHA256 stream + HMAC for the browser, whose PHP has neither).- No master password: a random key in var/keyring.key (0600; only against casual reading of servers.json). Master password  (Tools > Master Password…, `MasterPasswordForm`): key = Argon2id (sodium) or PBKDF2-SHA256 of it + a salt, var/keyring.json keeps the salt and  a sealed check text. At start `MasterPasswordForm::askAtStart()` asks it when saved passwords are locked; `MainForm::reconnectSaved()` runs  once it is given (resume `['keyring' => ...]`). Wrong: error, retry, "Forget Saved Passwords" (`Keyring::forgetAll`).- An older servers.json with clear passwords is rewritten sealed by `Keyring::restore()` on load. While locked, a server added with  "remember" is not saved with its password.## Non-obvious- VNC keyboard layout: XCP-ng's consoles are QEMU, which types a keysym on a US keyboard (an AZERTY VM got "q" for "a"). The client asks for the QEMU Extended Key Event (encoding -258) and, when the server says yes, sends physical keys (`App\Vnc\Scancodes`: SDL scancode -> XT) from `Keyboard::physicalHeld()` / `physicalPressed()` (JumPHP 0.5.52: the browser's KeyboardEvent.code); text with no key behind it (phone keyboard, paste) still goes as characters. Tests: `FakeVnc::$extendedKeys`, `FakeVnc::$layout = 'fr'`, `VncView::$keyState` / `$keyTaps`.- **Phones** (window < `MainForm::COMPACT_WIDTH` = 720 dp, `isCompact()`): one pane at a time (`MainForm::applyPanes()`, each frame):  the tree full width, a tap on a row (TreeView event `pick`, user taps only) shows the tabs full width with a "‹ Servers" button  (`paneBack`) in the toolbar; the network badge stays on the tree's screen. MenuBar and TabStrip put what does not fit on more  lines, the toolbar and the actions row wrap. Test: tests/MobileLayoutTest.php (the headless window is zoomed with `gui_zoom` to  look 390 dp wide). Phone shots: set project.yaml width 390 / height 844 for the shot, params `{"demo":true,"pane":"details"}`.  The tables (DataGrid) still squeeze their columns at that width.- The console picture grows by whole factors only (x1, x2...): at x1.25 the letters looked smudged.- The browser's PHP has no `filter`, `openssl`, `sodium`, `xml` or `iconv` extension: no `filter_var()` (it killed the VNC console in the browser), use `App\Model\Address::isIp()` / `isEmail()`.- **Local servers in the browser** (`Model/LocalNetwork`, tests/LocalNetworkTest.php): the JumPHP server's bridge refuses private addresses, so a  local address (`Address::isLocal()`: 10/8, 172.16/12, 192.168/16, 127/8, 169.254/16, 100.64/10, ::1, fc00::/7, fe80::/10, names without a dot or  in .local / .lan / .home / .internal...) needs the runtime `ready` AND the badge's switch on "Local". Otherwise Add New Server shows the  "Local network required" panel (text by step: install / allow / switch, its button = RuntimeGuide / pair() / setUseServer(false)) and refuses  Add; `Connection::connect()` fails at once with the same explanation (no bridge attempt). Demo and desktop: never. `LocalNetwork::$wasm = true`  fakes the browser in tests (Platform::isWasm() is a constant).- File > Remove the Demo Pool (`server.removeDemo`); a connected pool also has Remove from XenCenter in its right click menu.- Saved: `var/servers.json` (Remember the password is ticked by default, sealed by the Keyring), View > Show Templates / Show Networks in options (`show_templates`, `show_networks`).- No clipping in SDL's binding: every custom list draws into a target texture (`Canvas::render`).- Right click: the engine only routes it to an `Overlay` widget with `openMenu()`: `Canvas` is one; the form then fills the popup (`onTreeMenu`, `onGridMenu`).- The window refreshes at most every 0.2 s when `Session::version()` changes (event.from bumps the caches): tests wait ~20 frames after a task ends.- Performance figures come every 5 s while Performance, Search or Dashboard is open: the master's `/rrd_updates?host=true` and each other host's (HostLinks).- The console (`Gui/VncView`): the changed rectangle of the framebuffer becomes a BMP in memory (`SDL_RWFromConstMem` + `SDL_LoadBMP_RW`, ~1 ms for 1024×768) copied into one target texture; `Connection::openConsole()` reaches the console's host by the same road as the API (TCP / runtime, TLS, or `ssh -W <host>:80` through the master). Keys are sent as press + release (no held keys: games inside a VM will not see auto-repeat).- **Console keyboard** (`Gui/VncView::update()`): `JumPHP\Gui\Form::setFocus()` calls `SDL_StopTextInput()` for every widget that is not a  `TextBox`, and once one text event came (any field typed into before) the Form takes characters from SDL_TEXTINPUT only: the letters  typed into the console were lost (browser above all: SDL's emscripten port also cancels `keypress` then), only Return / arrows went  through. VncView turns the text input back on while it has the focus (tests/ConsoleTest.php types through the real Keyboard).  **Fullscreen** console (`consoleFull` button, Ctrl+Alt+F; Escape is sent to the VM): `MainForm::setConsoleFullscreen()` hides every  widget off the path to the console column, padding 0, scale to fit; the browser page goes fullscreen too, and when the browser  leaves it (its own Escape) the console follows. `FakeXapi::lcg()` keeps the uuid generator in 32-bit integers (browser warnings).- The window's `padding: 0` is also the theme's control padding: `MainForm::loaded()` sets `theme->padding` back.- Not done yet: HA, RBAC. Folders / tags: no drag and drop; a new empty folder goes to the  first connected pool; folders of several pools are merged by path (rename / delete act on all of them).- **Updates / XVA / alerts** (tests/UpdatesXvaAlertsTest.php): the Updates tab asks the servers the first time it is shown. `check_update` may not give the  installed version on a real host (`current_version` / `current_release` are read when present: the column then shows "→ new"). A real master's reboot  cuts the connection: the rolling update waits (RollingUpdate::BACK_IN, 20 min) for the reconnection, not tried on a real pool. The file choosers  (`FilePicker::save/open`) only exist in a desktop (Tauri) app and, for open, in the browser: the SDL window types the path; exporting in a browser is  refused with a message. Perfmon levels: ratios for CPU / SR use, B/s for network / disk, KiB for memory_free_kib (sense low), MiB/s for SR throughput.- Browser: `bin/jumphp build` + `webshot` pass (the `ws://127.0.0.1:4782x` errors in the console are the engine looking for the JumPHP runtime: expected). Below 1100 dp the toolbar shows icons only (text in the tooltip).- Tests share one `Session` service per process: `XenCenterTest::fresh()` empties it; `OrganizeKeyringTest` also removes var/keyring.* in tearDown.## Pools, Connection Addresses, Dashboard (src/Feature/Pools, tests/PoolsTest.php)- `PoolCommands` (menu items, enabled rules, confirmations; hooked into `View\Commands`), `PoolActions` (the XAPI calls; hooked into  `MainForm::command()` / `resume()`), dialogs `NewPoolForm` (modes `new` / `add`, check boxes made in code) and `HostAddressesForm`.- New Pool = `pool.set_name_label` on the master's own pool, then for each other server `Async.pool.join(master_address, user, password)`  **through that server's own Connection**; once joined, its connection is removed (`MainForm::forgetConnection`): it shows under the pool.  Eject = `Async.pool.eject` (strong confirmation), new master = `Async.pool.designate_new_master` (real servers: reconnect after 8 s,  following HOST_IS_SLAVE). A server with running / suspended VMs cannot join (as XAPI's JOINING_HOST_CANNOT_HAVE_RUNNING_OR_SUSPENDED_VMS).- Demo: `Xapi/Fake/PoolOps` (trait of FakeXapi). Any demo address but `xcp-demo.local` is a standalone host (`xcp-demo-N.local` =  xcp-ng-0(N+1), its refs offset by 0x100000·N so they never meet the pool's). A static registry by address lets `pool.join` on one fake  move the host, its local SR and VMs into the master's (networks mapped by device, PBDs for the shared SRs); the joined fake then answers  HOST_IS_SLAVE and drops its sessions 1.5 s later; `FakeXapi::open()` keeps a joined (slave) instance, anything else starts afresh;  `FakeXapi::forget()` empties the registry (tests). The fake's `/rrd_updates` now answers only for the host it is asked on (the master,  or `xc_host=<uuid>` added by `Fake\FakeHostTransport`), like real XAPI.- `ServerProfile::$hostAddresses` (host uuid => address, in var/servers.json). `HostAddresses::reachable()` = user-set, else (master) the  typed address, else `sameNaming`, else XAPI's; `Connection::consoleTargets()` puts the user-set address first.- `HostLinks` (one per Connection, `hostLinks()`, closed on disconnect): a transport per non-master host, same method as the master's  (HTTP / HTTPS / `ssh -W host:80` through the master / demo), the pool's session reused; `MainForm::fetchRrd()` merges every host's  figures with the master's (`merged()`: union of uuid-keyed columns).- Dashboard: first tab of the root (`Pages::tabs('root')`), page kind `dashboard`, widget `Gui\DashboardView` (cards in a wrapping grid,  `cardRect()` / `connectRect()` for tests, events select / connect), data `Feature\Pools\Dashboard::cards()`. Figures every 5 s.- Limits: after designate_new_master the demo keeps answering at the same address (one fake per pool); re-adding a joined demo  host redirects to a fresh copy of its master's address; per-host addresses are not applied to the HOST_IS_SLAVE redirect.## Storage, networks, disks, cross-pool migration (src/Feature/Storage)- **Hooks** (one call each, keep them so): `Commands::context()` / `menu()` / `toolbar()` and `Pages::page()` pass what they build  through `Feature\Storage\Menus`; `MainForm::command()` asks `Feature\Storage\Router::command()` first, `MainForm::resume()` asks  `Router::resume()`; FakeXapi `use`s `Xapi/Fake/{StorageOps,NetworkOps,MigrationOps}` (its constructor calls `buildDemo()`, `operation()`  asks the traits first). The XAPI sequences are `Feature\Storage\Actions` (empty maps go as `{}`: `Actions::map()`).- **New Storage** (`NewSrForm`, toolbar / Storage menu / pool or server right click): NFS, SMB, ISO (NFS / SMB), iSCSI, local EXT / LVM ->  `Async.SR.create(host, device_config, 0, name, desc, type, content_type, shared, sm_config)`. iSCSI: `SR.probe` answers inside the  failure as XAPI does (96 = the target's IQNs, 107 = its LUNs, XML in `failure[3]`): `Actions::probe()` reads it.- **SR actions**: Detach / Reattach (`PBD.unplug` / `plug` per host, master last / first), Forget / Destroy (`StrongConfirmForm`: a box to  tick; detached first, then `SR.forget` / `SR.destroy`), Set as Default (`pool.set_default_SR` + suspend image + crash dump).- **New Network** (`NewNetworkForm`): `network.create`, then External = `pool.create_VLAN(device, network, VLAN)` (VLAN empty = 0),  Bonded = `Bond.create` per host (the demo hosts have spare eth2 / eth3), private = nothing more. `NetworkPropertiesForm` (name,  description, `network.set_MTU`). Remove: bonds (`Bond.destroy`) and VLAN PIFs (`PIF.destroy`) then `network.destroy`; a network VMs  use goes straight to `network.destroy` (XAPI refuses: `NETWORK_CONTAINS_VIF`) so its VLANs are kept.- **Disks** (VM Storage tab; Move also on the SR Storage tab): Add (`VDI.create` + `VBD.create` + `VBD.plug` if running), Attach, Detach,  Delete, Move (`MoveDiskForm`): running VM -> `VDI.pool_migrate`; else `VDI.copy`, `VBD.destroy` + `VBD.create` (same userdevice,  bootable, mode), `VDI.destroy`. Rows get flags (`disk`, `removable`) for the buttons' `needsRow`.- **Cross-pool** (`CrossPoolMigrateForm`, VM menu when 2+ pools are connected): target `host.migrate_receive` -> source  `VM.assert_can_migrate` + `Async.VM.migrate_send(vm, dest, live, vdi_map, vif_map, {})`. **File > Open the Second Demo Pool** (or Add  Server, method demo, address `xcp-lab.local`; `xcp-demo-N.local` are standalone hosts) builds "Demo pool 2" (`xcp-lab-01`); demo pools have refs of their own and are found by  address (`FakeXapi::at()`), so `migrate_send` moves the records from one instance to the other.- Tests: `tests/StorageNetworkTest.php`. A button of a panel just made visible needs one frame of layout before `click()`.## Learnt on a real pool (2026-11-10)- **Consoles behind a VPN / NAT**: XAPI's console `location` is the management address of the VM's host (often a public IP), which the  user's computer may not route; the master does NOT hand over the console of another host's VM (`Console.Failure`). So  `Connection::consoleTargets()` tries, in order: the typed address (VM on the master), the user's naming applied to the VM's host  (typed `host01.example.prod` for the master `host01…` -> `host02.example.prod`, `Connection::sameNaming()`), XAPI's location, the  host's other PIF addresses, the typed address. `MainForm::openConsole()` moves to the next one when a target fails before the RFB  handshake; `RfbClient::$target` names the address in the errors.- **XAPI alarm bodies are several lines of text + XML** (`value: 0.95 / config: <variable>…`): `Pages::messageText()` writes them as a  sentence, and `DataGrid::oneLine()` keeps every cell on one line (a multi-line cell overlapped the rows below).- **Sizes** stay floats (2 GiB and more do not fit the browser's 32-bit integers); JSON is encoded without `JSON_PRESERVE_ZERO_FRACTION`  so whole floats go to XAPI's int64 parameters as integers.- **Dialogs** tick `Session::$current` (the scene manager only updates the scene on top): the network goes on while one is open.- **Never run several Claude sessions on this folder at once**: on 2026-11-10 up to four resumed copies of one conversation applied the  same edits concurrently (duplicated methods, a fatal error).