<?phpnamespace App\Form;use App\Feature\Organize\OrganizeCommands;use App\Feature\Organize\OrganizeTree;use App\Gui\Banner;use App\Gui\DataGrid;use App\Gui\Icons;use App\Gui\LineChart;use App\Gui\MenuBar;use App\Gui\PropertySheet;use App\Gui\Rule;use App\Gui\TabStrip;use App\Gui\ToolButton;use App\Gui\TreeView;use App\Model\Connection;use App\Model\LogEntry;use App\Model\ServerProfile;use App\Model\Session;use App\View\Commands;use App\View\Page;use App\View\Pages;use App\View\TreeBuilder;use App\Xapi\Rrd;use App\Xapi\XapiError;use JumPHP\Gui\Button;use JumPHP\Gui\Container;use JumPHP\Gui\Form;use JumPHP\Gui\Label;use JumPHP\Gui\TextBox;use JumPHP\Kernel\Platform;/** * The main window of XenCenter: forms/MainForm.form.yaml. The tree on the left (TreeBuilder), the tabs of the selected object on * the right (Pages), the commands of the menus, the right clicks and the toolbar (Commands) run here on the selected connection. * * @property MenuBar $menu * @property Container $toolbar * @property ToolButton $back * @property ToolButton $forward * @property ToolButton $addServer * @property ToolButton $newVm * @property ToolButton $newStorage * @property ToolButton $shutDown * @property ToolButton $reboot * @property ToolButton $startResume * @property ToolButton $suspend * @property Button $paneBack * @property \JumPHP\Runtime\RuntimeBadge $runtime * @property Container $left * @property Rule $split * @property TextBox $search * @property TreeView $tree * @property ToolButton $navInfrastructure * @property ToolButton $navObjects * @property ToolButton $navNotifications * @property ToolButton $navFolders * @property ToolButton $navTags * @property Container $right * @property Banner $banner * @property TabStrip $tabs * @property Container $actions * @property PropertySheet $sheet * @property DataGrid $grid * @property Container $charts * @property LineChart $chart0 * @property LineChart $chart1 * @property LineChart $chart2 * @property LineChart $chart3 * @property Container $note * @property Label $noteText * @property Container $home * @property Button $homeAdd * @property Button $homeDemo * @property Label $homeHelp * @property Label $homeStats * @property Container $body * @property Container $console * @property Button $consoleCad * @property Button $consoleReconnect * @property \JumPHP\Gui\CheckBox $consoleScale * @property Button $consoleFull * @property Label $consoleStatus * @property \App\Gui\VncView $vnc * @property \App\Gui\DashboardView $dashboard * @property Container $statusBar * @property Label $status * @property Label $transport */final class MainForm extends Form{ public string $mode = 'infrastructure'; public bool $showTemplates = false; public bool $showNetworks = false; public string $selected = TreeBuilder::ROOT; /** @var array<string, string> the tab last chosen for each kind of object */ private array $tabOf = []; /** @var list<string> */ private array $history = []; private int $historyAt = -1; private int $version = -1; private float $refreshIn = 0; /** @var array<int, Rrd> the last performance figures of each connection (by its index) */ public array $rrd = []; private float $rrdIn = 0; /** @var array<int, bool> a request running */ private array $rrdBusy = []; private string $actionsKey = ''; private bool $demo = false; private float $demoClock = 0; private int $demoStep = 0; private Pages $pages; /** the VM whose console is open, and its client */ private ?string $consoleVm = null; private ?\App\Vnc\RfbClient $rfb = null; private float $consoleClock = 0; /** the console over the whole window: the widgets hidden for it and their visibility before, the scale before */ private bool $consoleFullscreen = false; /** a narrow window (a phone): one pane at a time, 'tree' or 'details' */ public string $pane = 'tree'; /** the window width (dp) under which the tree and the tabs no longer sit side by side */ public const COMPACT_WIDTH = 720; /** @var list<array{0: \JumPHP\Gui\Widget, 1: bool}> */ private array $hiddenForConsole = []; /** @var list<array{0: \JumPHP\Gui\Container, 1: mixed}> */ private array $paddingForConsole = []; private bool $scaleBeforeFullscreen = true; /** browser: the page went fullscreen for the console (its Escape leaving it ends the console's fullscreen too) */ private bool $pageFullscreenSeen = false; public function __construct(public readonly Session $session) { parent::__construct(); $this->pages = new Pages($session); } public function pages(): Pages { return $this->pages; } protected function loaded(array $params = []): void { // phones get their own window (src/Form/MobileForm.php): one pane at a time instead of a tree beside tabs if (empty($params['desktop']) && (!empty($params['mobile']) || Platform::isMobile()) && class_exists(MobileForm::class)) { $this->scenes()->switchTo(MobileForm::class, $params, false); return; } // the form file's `padding: 0` is the window's edge; inside the controls (buttons, fields) the theme keeps some room $this->theme->padding = 11; \App\Gui\Units::useLocalTime(); \App\Gui\Units::$tzOffset = (int)date('Z'); // the logs in the computer's time $this->session->load(); $this->tabOf = []; $this->history = []; $this->historyAt = -1; $this->version = -1; $this->rrd = []; $this->rrdBusy = []; $this->actionsKey = ''; // the widgets are new at each enter(): the console starts in the window $this->consoleFullscreen = false; $this->pane = ($params['pane'] ?? 'tree') === 'details' ? 'details' : 'tree'; // a phone starts on the tree (shots: 'details') $this->hiddenForConsole = $this->paddingForConsole = []; $this->closeConsole(); $this->selected = TreeBuilder::ROOT; $this->mode = 'infrastructure'; // View > Show Templates / Show Networks: remembered (var/options.json, IndexedDB in a browser), like XenCenter's settings $this->showTemplates = (bool)$this->engine->options->get('show_templates', false); $this->showNetworks = (bool)$this->engine->options->get('show_networks', false); $this->menu->itemsFor = fn (string $menu) => Commands::menu($menu, ...$this->selectionFor($menu)); $this->menu->onPick = fn (array $item) => $this->command((string)$item['id']); $this->demo = !empty($params['demo']); $this->demoClock = 0; $this->demoStep = 0; if ($this->demo || !empty($params['connectDemo'])) { $this->openDemo(); } elseif (MasterPasswordForm::askAtStart($this)) { // the saved passwords are locked by a master password: asked first, the servers reconnect once it is given (resume) } else { $this->reconnectSaved(); } $this->refresh(true); $this->navigate($this->selected); } /** XenCenter reconnects the saved servers at start (those whose password could be read). */ public function reconnectSaved(): void { foreach ($this->session->connections as $c) { if ($c->profile->remember && $c->profile->password !== '' && $c->state === Connection::DISCONNECTED) { $c->connect(); } } } public function exit(): void { // the connections stay: the dialogs come back to this window } // the frame ----------------------------------------------------------------------------------------------------------- protected function tick(float $dt): void { $this->applyPanes(); $this->session->tick($dt); $this->refreshIn -= $dt; $version = $this->session->version(); if ($version !== $this->version && $this->refreshIn <= 0) { $this->refresh(); $this->refreshIn = 0.2; } $this->rrdIn -= $dt; if ($this->rrdIn <= 0) { $this->rrdIn = 5; $this->fetchRrd(); } if ($this->demo) { $this->playDemo($dt); } $this->followPageFullscreen(); $this->consoleClock += $dt; if ($this->rfb !== null && $this->consoleClock > 0.5) { $this->consoleClock = 0; $this->consoleStatus->text = $this->rfb->state === \App\Vnc\RfbClient::READY ? $this->rfb->fb->width . ' × ' . $this->rfb->fb->height . ' · ' . \App\Gui\Units::bytes($this->rfb->bytesIn) . ' received' : ucfirst($this->rfb->state); } } /** The tree, the toolbar, the status bar and the current page follow the connections. */ public function refresh(bool $force = false): void { $this->version = $this->session->version(); $builder = new TreeBuilder($this->showTemplates, $this->showNetworks, $this->search->text); if ($this->mode === 'notifications') { $alerts = count($this->pages->alerts(null, null)->rows); $nodes = [['id' => 'notif:alerts', 'text' => 'Alerts', 'suffix' => " ($alerts)", 'icon' => $alerts > 0 ? 'warning' : 'info'], ['id' => 'notif:events', 'text' => 'Events', 'suffix' => ' (' . count($this->session->log) . ')', 'icon' => 'info']]; } else { $nodes = match ($this->mode) { 'objects' => $builder->objects($this->session), 'folders' => $builder->folders($this->session), 'tags' => $builder->tags($this->session), default => $builder->infrastructure($this->session), }; $nodes = \App\Feature\Updates\Updates::markTree($nodes, $this->session); // "(3 updates)", "(reboot required)" after servers } $this->tree->setNodes($nodes); // a server that just connected: its node is now the pool (or the host) if (str_starts_with($this->selected, 'conn:')) { $c = $this->session->connections[(int)substr($this->selected, 5)] ?? null; if ($c !== null && $c->isConnected()) { $this->selected = (string)($c->cache->isRealPool() ? $c->cache->poolRef() : $c->cache->master()); } } if ($this->tree->node($this->selected) === null && !str_starts_with($this->selected, 'notif:')) { $this->selected = $this->mode === 'notifications' ? 'notif:alerts' : TreeBuilder::ROOT; } if ($this->tree->selected !== $this->selected) { $this->tree->select($this->selected, false); } $this->showSelection($force); $this->updateToolbar(); $this->updateStatus(); } // selection -------------------------------------------------------------------------------------------------------------- /** @return array{0: ?Connection, 1: string, 2: string} the connection, the ref, the kind of the selection */ public function selection(): array { $id = $this->selected; if (str_starts_with($id, 'notif:')) { return [null, $id, 'notifications']; } if (str_starts_with($id, 'conn:')) { $c = $this->session->connections[(int)substr($id, 5)] ?? null; return [$c, $id, $c === null ? 'root' : 'disconnected']; } if (($organized = OrganizeTree::selection($this->session, $id)) !== null) { return $organized; // a folder, a tag, an object under a tag } if ($id === TreeBuilder::ROOT || str_starts_with($id, 'type:')) { return [null, $id, Pages::kind(null, $id)]; } $c = $this->session->connectionOf($id); return [$c, $id, Pages::kind($c, $id)]; } /** @return array{0: ?Connection, 1: string, 2: string, 3: array<string, mixed>} for Commands::menu */ private function selectionFor(string $menu): array { [$c, $ref, $kind] = $this->selection(); if ($c === null && $menu !== 'File') { // menus act on the only connected server when the root is selected foreach ($this->session->connections as $conn) { if ($conn->isConnected()) { $c = $conn; break; } } } return [$c, $ref, $kind, ['mode' => $this->mode, 'templates' => $this->showTemplates, 'networks' => $this->showNetworks]]; } public function navigate(string $id, bool $remember = true): void { if ($remember && ($this->history[$this->historyAt] ?? null) !== $id) { $this->history = array_slice($this->history, 0, $this->historyAt + 1); $this->history[] = $id; $this->historyAt = count($this->history) - 1; } $this->selected = $id; $this->tree->select($id, false); $this->grid->selected = null; $this->sheet->scrollY = 0; $this->grid->scrollY = 0; $this->showSelection(true); $this->updateToolbar(); $this->updateStatus(); } private function showSelection(bool $force = false): void { [$c, $ref, $kind] = $this->selection(); $tabs = Pages::tabs($kind); if ($kind === 'notifications') { $tab = $ref === 'notif:events' ? 'Events' : 'Alerts'; } else { $tab = $this->tabOf[$kind] ?? $tabs[0]; if (!in_array($tab, $tabs, true)) { $tab = $tabs[0]; } } if ($this->tabs->tabs !== $tabs) { $this->tabs->tabs = $tabs; } $this->tabs->props['selected'] = (int)array_search($tab, $tabs, true); $this->showBanner($c, $ref, $kind); $index = $c === null ? null : array_search($c, $this->session->connections, true); $rrd = $index === false || $index === null ? $this->anyRrd() : ($this->rrd[$index] ?? null); $this->showPage(OrganizeCommands::page($this->session, $ref) ?? $this->pages->page($c, $ref, $tab, $rrd)); } private function anyRrd(): ?Rrd { // the root's Search tab: the figures of every connection in one (their legends do not overlap: they hold uuids) if ($this->rrd === []) { return null; } $all = new Rrd(); foreach ($this->rrd as $rrd) { $all->columns += $rrd->columns; } return $all; } private function showBanner(?Connection $c, string $ref, string $kind): void { [$text, $detail, $icon] = match ($kind) { 'root' => ['XenCenter', '', 'xencenter'], 'group' => [(string)($this->tree->node($ref)['text'] ?? ''), '', 'folder'], 'notifications' => [$ref === 'notif:events' ? 'Events' : 'Alerts', 'Notifications', $ref === 'notif:events' ? 'info' : 'warning'], 'disconnected' => [$c?->name() ?? '', match ($c?->state) { Connection::CONNECTING => 'Connecting…', Connection::FAILED => 'Disconnected (failed)', default => 'Disconnected'}, $c?->state === Connection::CONNECTING ? 'connecting' : 'disconnected'], default => $this->objectBanner($c, $ref, $kind), }; $this->banner->text = $text; $this->banner->detail = $detail; $this->banner->icon = $icon; } /** @return array{0: string, 1: string, 2: string} */ private function objectBanner(?Connection $c, string $ref, string $kind): array { if ($c === null) { return ['', '', 'folder']; } $cache = $c->cache; $record = $cache->get($ref) ?? []; $class = (string)$cache->classOf($ref); $where = match ($kind) { 'vm' => ($h = (string)($record['resident_on'] ?? '')) !== '' && $cache->get($h) !== null ? "on '" . $cache->name($h) . "'" : "in '" . $c->name() . "'", 'host' => $cache->isRealPool() ? "in '" . $c->name() . "'" : '', 'snapshot' => "snapshot of '" . $cache->name((string)($record['snapshot_of'] ?? '')) . "'", 'sr', 'network', 'template' => "in '" . $c->name() . "'", default => '', }; return [$cache->name($ref), $where, Icons::of($class, $record, $cache)]; } public function showPage(Page $page): void { $this->sheet->visible = $page->kind === 'sheet'; $this->grid->visible = $page->kind === 'grid'; $this->charts->visible = $page->kind === 'charts'; $this->note->visible = $page->kind === 'text'; $this->home->visible = $page->kind === 'home'; $this->console->visible = $page->kind === 'console'; $this->dashboard->visible = $page->kind === 'dashboard'; if ($page->kind === 'dashboard') { $cards = \App\Feature\Pools\Dashboard::cards($this->session, $this->rrd); $this->dashboard->setCards($cards, \App\Feature\Pools\Dashboard::summary($cards)); } if ($page->kind === 'console') { $this->openConsole($page->text); } else { $this->setConsoleFullscreen(false); $this->closeConsole(); } switch ($page->kind) { case 'sheet': $this->sheet->setSections($page->sections); break; case 'grid': $this->grid->empty = $page->empty; $this->grid->set($page->columns, $page->rows); break; case 'charts': foreach ([$this->chart0, $this->chart1, $this->chart2, $this->chart3] as $i => $chart) { $spec = $page->charts[$i] ?? null; $chart->visible = $spec !== null; if ($spec !== null) { $chart->title = $spec['title']; $chart->format = $spec['format']; $chart->max = $spec['max'] ?? null; $chart->setSeries($spec['series']); } } break; case 'text': $this->noteText->text = $page->text; break; case 'home': $connected = count(array_filter($this->session->connections, fn (Connection $c) => $c->isConnected())); $vms = 0; $running = 0; foreach ($this->session->connections as $c) { foreach ($c->cache->vms() as $vm) { $vms++; $running += $vm['power_state'] === 'Running' ? 1 : 0; } } $this->homeStats->text = count($this->session->connections) === 0 ? 'No server yet.' : "$connected of " . count($this->session->connections) . " servers connected · $vms VMs ($running running)"; break; } $this->setActions($page->actions); } /** @param list<array<string, mixed>> $actions the buttons over the page */ private function setActions(array $actions): void { $row = $this->grid->visible ? $this->grid->selectedRow() : null; $key = json_encode([$actions, $row['id'] ?? null, $row['running'] ?? null]); if ($key === $this->actionsKey) { return; } $this->actionsKey = (string)$key; $this->actions->clear(); foreach ($actions as $a) { // needsRow: a line must be selected; 'running' / 'done': and that flag of the line set (Cancel a running task, Dismiss a finished one) $need = $a['needsRow'] ?? false; $ok = $need === false || ($row !== null && (!is_string($need) || !empty($row[$need]))); $button = new Button(['id' => 'action:' . $a['id'], 'text' => $a['text'], 'style' => $a['style'] ?? 'default', 'enabled' => ($a['enabled'] ?? true) && $ok, 'height' => 28]); $id = (string)$a['id']; $button->on('click', fn () => $this->command($id, !empty($a['needsRow']) ? $this->grid->selected : null)); $this->actions->add($button); } $this->actions->visible = $actions !== []; } private function updateToolbar(): void { [$c, $ref, $kind] = $this->selection(); foreach (Commands::toolbar($c, $ref, $kind) as $id => $spec) { /** @var ToolButton $button */ $button = $this->widget($id); $button->enabled = $spec['enabled']; $button->text = $spec['text']; $button->props['command'] = $spec['id']; } $this->startResume->icon = $this->startResume->text === 'Resume' ? 'resume' : 'power-on'; $this->back->enabled = $this->historyAt > 0; $this->forward->enabled = $this->historyAt < count($this->history) - 1; foreach (['navInfrastructure' => 'infrastructure', 'navObjects' => 'objects', 'navFolders' => 'folders', 'navTags' => 'tags', 'navNotifications' => 'notifications'] as $id => $mode) { /** @var ToolButton $nav */ $nav = $this->widget($id); $nav->props['active'] = $this->mode === $mode; } } private function updateStatus(): void { [$c] = $this->selection(); $running = array_values(array_filter($this->session->log, fn (LogEntry $e) => $e->status === LogEntry::RUNNING)); $last = end($this->session->log) ?: null; $this->status->text = $running !== [] ? $running[0]->title . '… ' . round($running[0]->progress * 100) . '%' . (count($running) > 1 ? ' (+' . (count($running) - 1) . ' more)' : '') : ($last !== null ? $last->title . ($last->status === LogEntry::FAILED ? ': ' . $last->detail : '') : 'Ready'); $this->transport->text = $c?->client?->transport->describe() ?? (Platform::isWasm() ? 'Browser' : 'Desktop'); } // events of the widgets ---------------------------------------------------------------------------------------------------- public function onTreeChange(TreeView $sender, string $id): void { $this->navigate($id); } /** A row of the tree tapped: on a phone its tabs take the screen (the "‹ Servers" button comes back to the tree). */ public function onTreePick(TreeView $sender, string $id): void { $this->pane = 'details'; $this->applyPanes(); } public function onPaneBackClick(Button $sender): void { $this->pane = 'tree'; $this->applyPanes(); } /** A window narrower than COMPACT_WIDTH dp (a phone held upright): the tree OR the selected object's tabs, the whole width. */ public function isCompact(): bool { return $this->viewWidth() > 0 && $this->viewWidth() / max(0.01, $this->theme->scale) < self::COMPACT_WIDTH; } /** Shows the panes the window's width allows (each frame: the window may turn; only what changes is touched). */ private function applyPanes(): void { if ($this->consoleFullscreen || !isset($this->left)) { return; } $compact = $this->isCompact(); $tree = !$compact || $this->pane === 'tree'; $details = !$compact || $this->pane === 'details'; $set = static function (\JumPHP\Gui\Widget $w, string $prop, mixed $value): void { if ($w->props[$prop] !== $value) { $w->{$prop} = $value; } }; $set($this->left, 'visible', $tree); $set($this->left, 'width', $compact ? 'fill' : 300); $set($this->split, 'visible', !$compact); $set($this->right, 'visible', $details); $set($this->paneBack, 'visible', $compact && $this->pane === 'details'); if (isset($this->runtime)) { $set($this->runtime, 'visible', $tree); // a phone: the network badge on the tree's screen, one line less over the tabs } } public function onTreeActivate(TreeView $sender, string $id): void { [$c, , $kind] = $this->selection(); if ($kind === 'disconnected' && $c !== null) { $this->connect($c); } } public function onTreeMenu(TreeView $sender, float $x, float $y): void { [$c, $ref, $kind] = $this->selection(); // the object the menu was opened on, whatever is selected when an item is chosen $sender->popup(OrganizeCommands::context($ref, $kind, Commands::context($c, $ref, $kind)), $x, $y, fn (array $item) => $this->command((string)$item['id'], $ref)); } public function onTabChange(TabStrip $sender, int $index, string $tab): void { [, $ref, $kind] = $this->selection(); if ($kind === 'notifications') { $this->navigate($tab === 'Events' ? 'notif:events' : 'notif:alerts'); return; } $this->tabOf[$kind] = $tab; $this->grid->selected = null; $this->grid->scrollY = 0; $this->sheet->scrollY = 0; if ($tab === 'Performance' || $tab === 'Search' || $tab === 'Dashboard') { $this->rrdIn = 0; // the figures at once } $this->showSelection(true); } public function onGridChange(DataGrid $sender, ?string $id): void { $this->showSelection(); } public function onGridActivate(DataGrid $sender, string $id): void { // a double click on an object of a list: go to it if ($this->session->connectionOf($id) !== null && $this->tree->node($id) !== null) { $this->navigate($id); } } public function onGridMenu(DataGrid $sender, float $x, float $y): void { $id = $sender->selected; if ($id === null) { return; } $c = $this->session->connectionOf($id); $items = $c !== null ? Commands::context($c, $id, Pages::kind($c, $id)) : []; [, , $kind] = $this->selection(); if ($this->tabs->value() === 'Snapshots') { $items = [Commands::item('snapshot.revert', 'Revert To…'), Commands::item('snapshot.delete', 'Delete Snapshot…')]; } elseif (str_starts_with($id, 'log:') || str_starts_with($id, 'msg:')) { $running = !empty($sender->selectedRow()['running']); $items = [Commands::item('log.cancel', 'Cancel', $running), Commands::item('log.dismiss', 'Dismiss', !$running)]; } $sender->popup($items, $x, $y, fn (array $item) => $this->command((string)$item['id'], $id)); } public function onSearchChange(TextBox $sender, string $text): void { $this->refresh(true); } public function onToolClick(ToolButton $sender): void { match ((string)$sender->id) { 'back' => $this->goBack(), 'forward' => $this->goForward(), 'addServer' => $this->command('server.add'), default => $this->command((string)($sender->props['command'] ?? '')), }; } public function onNavClick(ToolButton $sender): void { $this->command(match ((string)$sender->id) { 'navObjects' => 'view.objects', 'navNotifications' => 'view.notifications', 'navFolders' => 'view.folders', 'navTags' => 'view.tags', default => 'view.infrastructure'}); } public function onHomeAddClick(Button $sender): void { $this->command('server.add'); } public function onHomeDemoClick(Button $sender): void { $this->command('server.demo'); } public function goBack(): void { $this->travel(-1); } public function goForward(): void { $this->travel(1); } /** Back / Forward: the next place of the history that still exists (a deleted VM is skipped; a server since connected is its pool). */ private function travel(int $step): void { for ($at = $this->historyAt + $step; $at >= 0 && $at < count($this->history); $at += $step) { $id = $this->history[$at]; if (str_starts_with($id, 'conn:')) { $c = $this->session->connections[(int)substr($id, 5)] ?? null; if ($c !== null && $c->isConnected()) { $id = $this->history[$at] = (string)($c->cache->isRealPool() ? $c->cache->poolRef() : $c->cache->master()); } } if ($this->tree->node($id) !== null || str_starts_with($id, 'notif:')) { $this->historyAt = $at; $this->navigate($id, false); return; } } } // commands --------------------------------------------------------------------------------------------------------------- /** * Runs a command of a menu, a button or a right click on the selection (or on $target: a row of a list). * The ones XenCenter asks about first open a confirmation; the answer comes back in resume(). */ public function command(string $id, ?string $target = null, bool $confirmed = false): void { [$c, $ref, $kind] = $this->selection(); $target ??= $ref; $tc = $this->session->connectionOf($target) ?? $c; if ($tc === null && !str_starts_with($id, 'server.')) { // the root selected: the menus offer the only (first) connected server's commands (VM > New VM...), they act on it foreach ($this->session->connections as $conn) { if ($conn->isConnected()) { $tc = $conn; break; } } } if (str_starts_with($id, 'organize.')) { OrganizeCommands::run($this, $id, $tc, $target, $confirmed); // folders and tags (src/Feature/Organize) return; } // storage, networks, disks, cross-pool migration: src/Feature/Storage if (\App\Feature\Storage\Router::command($this, $id, $tc, $target, $confirmed)) { return; } if (!$confirmed && ($question = Commands::confirmation($id, $this->nameOf($tc, $target, $id), $tc?->cache)) !== null) { $this->scenes()->push(ConfirmForm::class, ['message' => $question, 'command' => $id, 'target' => $target, 'ok' => match (true) { str_contains($id, 'delete') => 'Delete', str_contains($id, 'remove') => 'Remove', str_contains($id, 'revert') => 'Revert', default => 'Yes'}]); return; } if (\App\Feature\Pools\PoolCommands::handles($id)) { // pools, Connection Addresses…: src/Feature/Pools/PoolActions.php if (($dialog = \App\Feature\Pools\PoolActions::command($this, $id, $tc, $target)) !== null) { $this->scenes()->push(...$dialog); } $this->refresh(true); return; } if (\App\Feature\FeatureCommands::run($this, $id, $tc, $target, $confirmed)) { return; // updates, XVA export / import, alerts: src/Feature } if (($power = Commands::power($id)) !== null && $tc !== null) { [$title, $method, $extra] = $power; $tc->run($title . " '" . $tc->cache->name($target) . "'", $method, [$target, ...$extra], $target)->then(fn () => null, fn () => null); $this->refresh(); return; } if (preg_match('/^vm\.(migrate|startOn):(.+)$/', $id, $m) && $tc !== null) { $host = $m[2]; $m[1] === 'migrate' ? $tc->run("Migrating VM '" . $tc->cache->name($target) . "' to '" . $tc->cache->name($host) . "'", 'VM.pool_migrate', [$target, $host, ['live' => 'true']], $target) : $tc->run("Starting VM '" . $tc->cache->name($target) . "' on '" . $tc->cache->name($host) . "'", 'VM.start_on', [$target, $host, false, false], $target); $this->refresh(); return; } switch ($id) { case 'server.add': $this->scenes()->push(AddServerForm::class); return; case 'server.demo': $this->openDemo(); return; case 'server.connect': if ($c !== null) { $this->connect($c); } return; case 'server.reconnect': if ($c !== null) { $c->disconnect(); $this->connect($c); } return; case 'server.disconnect': if ($c !== null) { $c->disconnect(); $this->session->log('Disconnected from ' . $c->name(), $c->name()); $this->selected = 'conn:' . array_search($c, $this->session->connections, true); $this->refresh(true); } return; case 'server.remove': if ($c !== null) { $this->forgetConnection($c); $this->navigate(TreeBuilder::ROOT); $this->refresh(true); } return; case 'server.removeDemo': foreach ($this->session->connections as $conn) { if ($conn->profile->method === 'demo') { $this->forgetConnection($conn); } } $this->session->log('Demo pool removed', 'XenCenter'); $this->navigate(TreeBuilder::ROOT); $this->refresh(true); return; case 'server.connectAll': foreach ($this->session->connections as $conn) { if (!$conn->isConnected() && $conn->state !== Connection::CONNECTING) { $this->connect($conn); } } return; case 'server.disconnectAll': foreach ($this->session->connections as $conn) { $conn->disconnect(); } $this->refresh(true); return; case 'view.infrastructure': case 'view.objects': case 'view.notifications': case 'view.folders': case 'view.tags': $this->mode = substr($id, 5); $this->selected = $this->mode === 'notifications' ? 'notif:alerts' : TreeBuilder::ROOT; $this->refresh(true); $this->navigate($this->selected); return; case 'view.templates': $this->showTemplates = !$this->showTemplates; $this->engine->options->set('show_templates', $this->showTemplates); $this->refresh(true); return; case 'view.networks': $this->showNetworks = !$this->showNetworks; $this->engine->options->set('show_networks', $this->showNetworks); $this->refresh(true); return; case 'view.refresh': $this->rrdIn = 0; $this->refresh(true); return; case 'tools.masterPassword': $this->scenes()->push(MasterPasswordForm::class, ['mode' => 'manage']); return; case 'help.about': $this->scenes()->push(AboutForm::class); return; case 'help.xcp': if (\JumPHP\Native\Native::supports('openUrl')) { \JumPHP\Native\Shell::openUrl('https://docs.xcp-ng.org/'); } return; case 'app.exit': $this->engine->quit(); return; case 'log.cancel': case 'event.cancel': $entry = $this->logEntry((string)$target); if ($entry !== null) { foreach ($this->session->connections as $conn) { $conn->cancel($entry); } } return; case 'log.dismiss': case 'event.dismiss': case 'alert.dismiss': $this->dismiss((string)$target); $this->grid->selected = null; $this->refresh(true); return; case 'log.dismissAll': case 'event.dismissAll': case 'alert.dismissAll': // the lines this list shows, not the others (the Logs of one VM leave the pool's alone) foreach (array_column($this->grid->rows, 'id') as $line) { $this->dismiss((string)$line); } $this->grid->selected = null; $this->refresh(true); return; } if ($tc === null || !$tc->isConnected()) { return; } $name = $tc->cache->name($target); switch ($id) { case 'vm.new': case 'template.newVm': $this->scenes()->push(NewVmForm::class, NewVmForm::paramsFor($tc, $id === 'template.newVm' ? $target : null, $tc->cache->classOf($target) === 'host' ? $target : null)); return; case 'vm.snapshot': $vm = $kind === 'vm' ? $ref : $target; $this->scenes()->push(NameForm::class, ['title' => 'Take Snapshot', 'label' => 'Name of the new snapshot:', 'name' => $tc->cache->name($vm) . ' ' . gmdate('Y-m-d H:i'), 'description' => '', 'ok' => 'Take Snapshot', 'command' => 'vm.snapshot', 'target' => $vm]); return; case 'vm.copy': $this->scenes()->push(NameForm::class, ['title' => 'Copy VM', 'label' => 'Name of the copy:', 'name' => 'Copy of ' . $name, 'description' => '', 'ok' => 'Copy', 'command' => 'vm.copy', 'target' => $target]); return; case 'vm.template': $tc->run("Converting VM '$name' to a template", 'VM.set_is_a_template', [$target, true], $target); return; case 'vm.delete': case 'template.delete': case 'snapshot.delete': // its disks go with it (XAPI's VM.destroy leaves them): every disk only this VM (or snapshot) uses, not the CDs, not a shared one $disks = $id === 'template.delete' ? [] : array_values(array_filter(array_map(function ($d) use ($tc, $target): ?string { if ($d['vdi'] === null || ($d['vbd']['type'] ?? '') === 'CD') { return null; } foreach ((array)($d['vdi']['VBDs'] ?? []) as $vbd) { if ((string)$tc->cache->field((string)$vbd, 'VM', $target) !== $target) { return null; } } return (string)$d['vbd']['VDI']; }, $tc->cache->disksOf($target)))); $tc->run(match ($id) { 'vm.delete' => "Deleting VM '$name'", 'template.delete' => "Deleting template '$name'", default => "Deleting snapshot '$name'"}, 'VM.destroy', [$target], $target)->then(function () use ($tc, $disks): void { foreach ($disks as $vdi) { $tc->run("Deleting virtual disk '" . $tc->cache->name($vdi) . "'", 'VDI.destroy', [$vdi], $vdi); } }, fn () => null); if ($this->selected === $target) { $this->navigate((string)($tc->cache->isRealPool() ? $tc->cache->poolRef() : $tc->cache->master())); } return; case 'snapshot.revert': $tc->run("Reverting to snapshot '$name'", 'VM.revert', [$target], $target); return; case 'host.maintenance': if ($tc->cache->field($target, 'enabled', true)) { $tc->run("Entering maintenance mode on '$name'", 'host.disable', [$target], $target) ->then(fn () => $tc->run("Migrating VMs away from '$name'", 'host.evacuate', [$target], $target), fn () => null); } else { $tc->run("Exiting maintenance mode on '$name'", 'host.enable', [$target], $target); } return; case 'host.reboot': case 'host.shutdown': $verb = $id === 'host.reboot' ? 'Rebooting' : 'Shutting down'; $method = $id === 'host.reboot' ? 'host.reboot' : 'host.shutdown'; $tc->run("Disabling '$name'", 'host.disable', [$target], $target) ->then(fn () => $tc->run("$verb server '$name'", $method, [$target], $target), fn () => null); return; case 'sr.rescan': $sr = $tc->cache->classOf($target) === 'sr' ? $target : $ref; $tc->run("Rescanning '" . $tc->cache->name($sr) . "'", 'SR.scan', [$sr], $sr); return; case 'vm.memory': case 'object.properties': $this->scenes()->push(PropertiesForm::class, PropertiesForm::paramsFor($tc, $target, $id === 'vm.memory')); return; } } /** The event of a line "log:<index>" of the lists. */ private function logEntry(string $line): ?LogEntry { if (!str_starts_with($line, 'log:')) { return null; } foreach ($this->session->log as $e) { if ($e->id === (int)substr($line, 4)) { return $e; } } return null; } /** A line of the logs goes: an event of this session (not while it runs), or an alert of the server (message.destroy). */ private function dismiss(string $line): void { if (str_starts_with($line, 'msg:')) { $message = substr($line, 4); $this->session->connectionOf($message)?->call('message.destroy', $message); return; } $entry = $this->logEntry($line); if ($entry !== null && $entry->status !== LogEntry::RUNNING) { $this->session->log = array_values(array_filter($this->session->log, fn (LogEntry $e) => $e !== $entry)); } } private function nameOf(?Connection $c, string $target, string $command): string { if (str_starts_with($command, 'server.')) { return $c?->name() ?? ''; } return $c?->cache->name($target) ?? ''; } /** What a dialog answered. */ public function resume(mixed $result = null): void { parent::resume($result); if (!is_array($result) || \App\Feature\Storage\Router::resume($this, $result)) { return; } if (\App\Feature\Pools\PoolActions::resume($this, $result)) { $this->refresh(true); return; } if (isset($result['confirm'])) { $this->command((string)$result['confirm'], (string)$result['target'], true); } elseif (!empty($result['demo'])) { $this->openDemo(); // "Use the demo pool": the one there is, not one more each time } elseif (isset($result['profile']) && $result['profile'] instanceof ServerProfile) { $c = $this->session->add($result['profile']); $this->connect($c); } elseif (isset($result['name'], $result['command'])) { $c = $this->session->connectionOf((string)$result['target']); if ($c === null) { return; } $vm = (string)$result['target']; if ($result['command'] === 'vm.snapshot') { $c->run("Taking a snapshot of '" . $c->cache->name($vm) . "'", 'VM.snapshot', [$vm, (string)$result['name']], $vm)->then(function (mixed $snap) use ($c, $result): void { if (is_string($snap) && $snap !== '' && trim((string)$result['description']) !== '') { $c->call('VM.set_name_description', $snap, (string)$result['description']); } }, fn () => null); } elseif ($result['command'] === 'vm.copy') { $c->run("Copying VM '" . $c->cache->name($vm) . "'", 'VM.clone', [$vm, (string)$result['name']], $vm)->then(function (mixed $copy) use ($c, $result): void { if (is_string($copy) && $copy !== '' && trim((string)$result['description']) !== '') { $c->call('VM.set_name_description', $copy, (string)$result['description']); } }, fn () => null); } } elseif (isset($result['properties'])) { PropertiesForm::apply($this->session, $result['properties']); } elseif (isset($result['newVm'])) { NewVmForm::create($this->session, $result['newVm']); } elseif (isset($result['organize'])) { OrganizeCommands::apply($this->session, (array)$result['organize']); } elseif (isset($result['keyring'])) { $this->reconnectSaved(); // the master password given: the saved servers can connect } elseif (\App\Feature\FeatureCommands::result($this, $result)) { // updates, XVA export / import, alerts: src/Feature } $this->refresh(true); } // the console --------------------------------------------------------------------------------------------------------- /** Opens the VNC console of a VM (once: the page is built again at each change of the pool). */ public function openConsole(string $vm, int $attempt = 0): void { if ($attempt === 0 && $this->consoleVm === $vm && $this->rfb !== null && $this->rfb->state !== \App\Vnc\RfbClient::CLOSED) { return; } if ($attempt === 0 && $this->consoleVm === $vm && $this->rfb !== null) { return; // closed: Reconnect opens it again } $this->closeConsole(); $this->consoleVm = $vm; $c = $this->session->connectionOf($vm); $this->vnc->message = ''; try { if ($c === null) { throw new \RuntimeException('Not connected.'); } $rfb = $this->rfb = $c->openConsole($vm, $attempt); $this->vnc->attach($rfb); $tries = count($c->consoleTargets($vm)); $rfb->on('error', function (string $m) use ($c, $vm, $attempt, $tries, $rfb): void { $ready = $rfb->fb->width > 0; if (!$ready && $attempt + 1 < $tries && $this->rfb === $rfb) { // this address does not answer: the next one (the pool master's, which hands the console over) $this->session->log("Console of '" . $c->cache->name($vm) . "': trying another address", $c->name(), $vm, LogEntry::DONE, $m); \JumPHP\Kernel\Engine::get()->gameLoop->addTimer(0.01, fn () => $this->rfb === $rfb ? $this->openConsole($vm, $attempt + 1) : null); return; } $this->session->log("Console of '" . $c->cache->name($vm) . "'", $c->name(), $vm, LogEntry::FAILED, $m); }); } catch (\Throwable $e) { $this->vnc->attach(null); $this->vnc->message = $e->getMessage(); } $this->consoleStatus->text = ''; } public function closeConsole(): void { $this->rfb?->close(); $this->rfb = null; $this->consoleVm = null; if (isset($this->vnc)) { $this->vnc->attach(null); $this->vnc->message = ''; if ($this->focused() === $this->vnc) { $this->setFocus(null); } } } public function console(): ?\App\Vnc\RfbClient { return $this->rfb; } public function onConsoleCadClick(Button $sender): void { $this->rfb?->ctrlAltDel(); $this->setFocus($this->vnc); } public function onConsoleReconnectClick(Button $sender): void { $vm = $this->consoleVm; $this->closeConsole(); if ($vm !== null) { $this->openConsole($vm); } } public function onConsoleScaleChange(\JumPHP\Gui\CheckBox $sender, bool $checked): void { $this->vnc->scale = $checked; } public function onConsoleFullClick(Button $sender): void { $this->setConsoleFullscreen(!$this->consoleFullscreen); } /** Ctrl+Alt+F in the console. */ public function onVncFullscreen(\App\Gui\VncView $sender): void { $this->setConsoleFullscreen(!$this->consoleFullscreen); } public function isConsoleFullscreen(): bool { return $this->consoleFullscreen; } /** * The console over the whole window: every widget but the console's column (its buttons + the screen) is hidden, the * picture scaled to fit, the keyboard given to the VM. Left with the "Exit Fullscreen" button or Ctrl+Alt+F (Escape is * the VM's). In a browser the page is asked to go fullscreen too (a browser leaves it on Escape: the console follows). */ public function setConsoleFullscreen(bool $on): void { if (!isset($this->console) || $on === $this->consoleFullscreen || ($on && !$this->console->visible)) { return; } $this->consoleFullscreen = $on; if ($on) { $this->hiddenForConsole = []; $this->paddingForConsole = []; for ($w = $this->console; $w->parent !== null; $w = $w->parent) { foreach ($w->parent->children as $sibling) { if ($sibling !== $w) { $this->hiddenForConsole[] = [$sibling, (bool)$sibling->visible]; $sibling->visible = false; } } $this->paddingForConsole[] = [$w->parent, $w->parent->props['padding'] ?? 0]; $w->parent->padding = 0; } $this->paddingForConsole[] = [$this->console, $this->console->props['padding'] ?? 0]; $this->console->padding = 6; $this->scaleBeforeFullscreen = (bool)$this->vnc->scale; $this->vnc->scale = true; $this->consoleFull->text = 'Exit Fullscreen'; $this->consoleFull->tooltip = 'Back to the window (Ctrl+Alt+F)'; if ($this->rfb !== null) { $this->setFocus($this->vnc); } } else { foreach ($this->hiddenForConsole as [$widget, $visible]) { $widget->visible = $visible; } foreach ($this->paddingForConsole as [$container, $padding]) { $container->padding = $padding; } $this->hiddenForConsole = []; $this->paddingForConsole = []; $this->vnc->scale = $this->scaleBeforeFullscreen; $this->consoleFull->text = 'Fullscreen'; $this->consoleFull->tooltip = 'The console over the whole window (Ctrl+Alt+F; Escape goes to the VM)'; } if (Platform::isWasm()) { $device = $this->engine->device(); if ($on || $device->isFullscreen()) { $device->setFullscreen($on); // a browser wants a gesture: the click on the button is one (else at the next key / tap) } $this->pageFullscreenSeen = false; } } /** Browser: the page left fullscreen (its Escape, F11) while the console was fullscreen -> the console leaves it too. */ private function followPageFullscreen(): void { if (!$this->consoleFullscreen || !Platform::isWasm()) { return; } $page = $this->engine->device()->isFullscreen(); if ($page) { $this->pageFullscreenSeen = true; } elseif ($this->pageFullscreenSeen) { $this->setConsoleFullscreen(false); } } // connections ------------------------------------------------------------------------------------------------------------ public function connect(Connection $c): void { $index = array_search($c, $this->session->connections, true); $this->selected = 'conn:' . $index; $c->connect()->then(function () use ($c): void { if ($this->selected === 'conn:' . array_search($c, $this->session->connections, true)) { $this->navigate((string)($c->cache->isRealPool() ? $c->cache->poolRef() : $c->cache->master())); } $this->rrdIn = 0; }, function (\Throwable $e) use ($c): void { $this->session->log('Connecting to ' . $c->profile->host . ' failed', $c->profile->host, null, LogEntry::FAILED, $e->getMessage()); $this->refresh(true); }); $this->refresh(true); } public function openDemo(): Connection { foreach ($this->session->connections as $c) { if ($c->profile->method === 'demo') { if (!$c->isConnected() && $c->state !== Connection::CONNECTING) { $this->connect($c); } return $c; } } $c = $this->session->add(ServerProfile::demo()); $this->connect($c); return $c; } /** A server leaves XenCenter (removed, or joined a pool): what was kept by the position of a server (figures, history of conn:N lines) points elsewhere now. */ public function forgetConnection(Connection $c): void { $this->session->remove($c); $this->rrd = []; $this->rrdBusy = []; $this->history = []; $this->historyAt = -1; $this->refresh(true); } /** The performance figures again at once (Connection Addresses changed). */ public function refreshFigures(): void { $this->rrdIn = 0; } public function onDashboardSelect(\App\Gui\DashboardView $sender, string $id): void { $this->navigate($id); } public function onDashboardConnect(\App\Gui\DashboardView $sender, int $index): void { if (($c = $this->session->connections[$index] ?? null) !== null) { $this->connect($c); } } /** * The performance figures (Performance, Search and Dashboard tabs), every 5 s: from the pool master of each connection, and from * each other host of its pool (each XAPI host only has its own figures: Feature\Pools\HostLinks), merged by uuid. */ private function fetchRrd(): void { $tab = $this->tabs->value(); if ($tab !== 'Performance' && $tab !== 'Search' && $tab !== 'Dashboard') { return; } foreach ($this->session->connections as $i => $c) { if (!$c->isConnected() || $c->client === null || !empty($this->rrdBusy[$i])) { continue; } $this->rrdBusy[$i] = true; $c->client->rrdUpdates(time() - 600, true)->then(function (Rrd $rrd) use ($i, $c): void { $this->rrdBusy[$i] = false; $this->rrd[$i] = $c->hostLinks()->merged($rrd); $this->showSelection(); }, function (\Throwable $e) use ($i): void { $this->rrdBusy[$i] = false; }); $c->hostLinks()->fetch($c, function () use ($i, $c): void { if (($this->session->connections[$i] ?? null) === $c) { $this->rrd[$i] = $c->hostLinks()->merged(); $this->showSelection(); } }); } } // the demo (smoke test, screenshots) ------------------------------------------------------------------------------------------ /** A self-playing tour: connect the demo pool, open a VM, its tabs, start a halted VM. */ private function playDemo(float $dt): void { $this->demoClock += $dt; $demo = null; foreach ($this->session->connections as $c) { if ($c->profile->method === 'demo') { $demo = $c; } } if ($demo === null || !$demo->isConnected()) { return; } $steps = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.5]; while ($this->demoStep < count($steps) && $this->demoClock >= $steps[$this->demoStep]) { $vm = null; foreach ($demo->cache->vms() as $ref => $record) { if ($record['name_label'] === 'db-primary') { $vm = $ref; } } match ($this->demoStep) { 0 => $vm !== null ? $this->navigate($vm) : null, 1 => $this->tabs->selectTab('Storage'), 2 => $this->tabs->selectTab('Networking'), 3 => $this->tabs->selectTab('Performance'), 4 => $this->tabs->selectTab('Snapshots'), 5 => $this->tabs->selectTab('General'), 6 => $this->startHalted($demo), 7 => $this->navigate((string)$demo->cache->poolRef()), default => null, }; $this->demoStep++; } } private function startHalted(Connection $c): void { foreach ($c->cache->vms() as $ref => $vm) { if ($vm['power_state'] === 'Halted') { $this->command('vm.start', $ref, true); return; } } } /** For tests: the error text of a failed connection. */ public static function failure(\Throwable $e): string { return $e instanceof XapiError ? $e->getMessage() : $e->getMessage(); }}