Accedi
IT
games/xencenter-xenadmin-xcp-jumphp / src / Form / MainForm.php · commit fb1b7c9
<?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();    }}