<?phpnamespace App\Gui;use App\Model\Connection;use JumPHP\Gui\Pointer;use JumPHP\Gui\Theme;use JumPHP\Renderer\Renderer;/** * The Dashboard tab of the root node: one card per connection (Feature\Pools\Dashboard::cards()), in a grid that wraps to the width * (cards at least MIN_WIDTH dp wide), a summary line above, a vertical scroll. A click on a card emits select (the tree id of that * pool or server); on the Connect button of a disconnected one, connect (the connection's index). */final class DashboardView extends Canvas{ public const DEFAULTS = ['height' => 'fill', 'width' => 'fill', 'background' => '#ffffff', 'border' => false]; public const MIN_WIDTH = 280; public const CARD_HEIGHT = 176; public const GAP = 14; public const TOP = 34; /** @var list<array<string, mixed>> */ public array $cards = []; public string $summary = ''; public int $hover = -1; public static function events(): array { return ['select', 'connect', 'contextmenu']; } /** @param list<array<string, mixed>> $cards */ public function setCards(array $cards, string $summary): void { $this->cards = $cards; $this->summary = $summary; } /** @return array{0: int, 1: float} columns, card width (pixels) */ private function grid(Theme $t): array { $gap = $t->px(self::GAP); $columns = max(1, (int)floor(($this->w - $t->px(8) + $gap) / ($t->px(self::MIN_WIDTH) + $gap))); return [$columns, ($this->w - $t->px(8) - $gap * ($columns - 1)) / $columns]; } /** @return array{0: float, 1: float, 2: float, 3: float} a card's box in view coordinates (scroll applied) */ public function cardRect(Theme $t, int $i): array { [$columns, $w] = $this->grid($t); $gap = $t->px(self::GAP); $h = $t->px(self::CARD_HEIGHT); return [$this->x + $t->px(4) + ($i % $columns) * ($w + $gap), $this->y + $t->px(self::TOP) + intdiv($i, $columns) * ($h + $gap) - $this->scrollY, $w, $h]; } /** @return array{0: float, 1: float, 2: float, 3: float} the Connect button of a card */ public function connectRect(Theme $t, int $i): array { [$x, $y, $w, $h] = $this->cardRect($t, $i); return [$x + $w - $t->px(104), $y + $h - $t->px(42), $t->px(92), $t->px(30)]; } protected function contentHeight(Theme $t): float { [$columns] = $this->grid($t); $rows = (int)ceil(count($this->cards) / $columns); return $t->px(self::TOP) + $rows * ($t->px(self::CARD_HEIGHT) + $t->px(self::GAP)); } /** The card under a point, or -1. */ public function cardAt(Theme $t, float $px, float $py): int { foreach (array_keys($this->cards) as $i) { [$x, $y, $w, $h] = $this->cardRect($t, $i); if ($px >= $x && $px < $x + $w && $py >= $y && $py < $y + $h && $py >= $this->y + $t->px(self::TOP) - $t->px(4)) { return $i; } } return -1; } protected function paint(Renderer $r, Theme $t): void { $font = $t->font(); $small = $t->font($t->fontSize * 0.85); $bold = $t->font($t->fontSize * 1.1, true); $small->draw($r, self::fit($small, \JumPHP\Util\Lang::t($this->summary), $this->w - $t->px(8)), $this->x + $t->px(4), $this->y + $t->px(8), 1, '#57606a'); if ($this->cards === []) { return; } $mouse = $this->form?->engineOrNull()?->input->getMouse(); $this->hover = $mouse === null ? -1 : $this->cardAt($t, $mouse->x, $mouse->y); foreach ($this->cards as $i => $card) { [$x, $y, $w, $h] = $this->cardRect($t, $i); if ($y > $this->y + $this->h || $y + $h < $this->y) { continue; } $connected = $card['state'] === Connection::CONNECTED; $t->frame($r, $x, $y, $w, $h, $this->hover === $i ? '#f5f8fd' : '#ffffff', $this->hover === $i ? '#9dbbe8' : '#dde2ea', $t->px(8)); $pad = $t->px(14); $icon = $connected ? ($card['pool'] ? 'pool' : 'host') : ($card['state'] === Connection::CONNECTING ? 'connecting' : 'disconnected'); Icons::draw($r, $icon, $x + $pad, $y + $pad, $t->px(24)); [$stateText, $stateColor] = match ($card['state']) { Connection::CONNECTED => ['Connected', Icons::RUNNING], Connection::CONNECTING => ['Connecting…', Icons::PAUSED], Connection::FAILED => ['Failed', Icons::HALTED], default => ['Disconnected', '#8a919c'], }; $stateText = \JumPHP\Util\Lang::t($stateText); $pillW = $small->width($stateText) + $t->px(16); $t->box($r, $x + $w - $pad - $pillW, $y + $pad, $pillW, $t->px(22), $stateColor, $t->px(11)); $small->draw($r, $stateText, $x + $w - $pad - $pillW / 2, $y + $pad + ($t->px(22) - $small->height()) / 2, 1, '#ffffff', 'center'); $textX = $x + $pad + $t->px(34); $bold->draw($r, self::fit($bold, (string)$card['name'], $x + $w - $pad - $pillW - $t->px(8) - $textX), $textX, $y + $pad - $t->px(1), 1, '#1d3c6e'); $small->draw($r, self::fit($small, (string)$card['detail'], $x + $w - $pad - $pillW - $t->px(8) - $textX), $textX, $y + $pad + $bold->height(), 1, '#6b7480'); $line = $y + $pad + $t->px(46); if (!$connected) { $small->draw($r, self::fit($small, \JumPHP\Util\Lang::t($card['state'] === Connection::CONNECTING ? 'Logging in to XAPI…' : 'Not connected: its figures show once connected.'), $w - 2 * $pad), $x + $pad, $line, 1, '#57606a'); if ($card['state'] !== Connection::CONNECTING) { [$bx, $by, $bw, $bh] = $this->connectRect($t, $i); $t->box($r, $bx, $by, $bw, $bh, '#2f6fd6', $t->px(5)); $font->draw($r, \JumPHP\Util\Lang::t('Connect'), $bx + $bw / 2, $by + ($bh - $font->height()) / 2, 1, '#ffffff', 'center'); } continue; } $font->draw($r, self::fit($font, ($card['pool'] ? $card['hosts'] . ($card['hosts'] === 1 ? ' server' : ' servers') : 'Standalone server') . ' · ' . $card['running'] . ' of ' . $card['vms'] . ' VMs running', $w - 2 * $pad), $x + $pad, $line, 1, '#24292f'); $line += $font->lineHeight() + $t->px(8); $this->bar($r, $t, $x + $pad, $line, $w - 2 * $pad, 'CPU', $card['cpu'], $card['cpu'] === null ? 'waiting for figures…' : round($card['cpu'] * 100) . '%'); $line += $t->px(30); $this->bar($r, $t, $x + $pad, $line, $w - 2 * $pad, 'Memory', $card['memory'], (string)$card['memoryText']); $line += $t->px(30); $alerts = (int)$card['alerts']; Icons::draw($r, $alerts > 0 ? 'warning' : 'info', $x + $pad, $line, $t->px(16)); $small->draw($r, $alerts === 0 ? \JumPHP\Util\Lang::t('No alerts') : $alerts . ($alerts === 1 ? ' alert' : ' alerts'), $x + $pad + $t->px(22), $line + ($t->px(16) - $small->height()) / 2, 1, $alerts > 0 ? '#9a6700' : '#57606a'); } } /** A labelled usage bar: green, orange above 60 %, red above 85 %. */ private function bar(Renderer $r, Theme $t, float $x, float $y, float $w, string $label, ?float $ratio, string $text): void { $small = $t->font($t->fontSize * 0.85); $labelW = $t->px(62); $small->draw($r, \JumPHP\Util\Lang::t($label), $x, $y + ($t->px(14) - $small->height()) / 2, 1, '#57606a'); $bw = max($t->px(20), $w - $labelW); $t->box($r, $x + $labelW, $y, $bw, $t->px(14), '#e8ecf1', $t->px(4)); if ($ratio !== null) { $ratio = max(0.0, min(1.0, $ratio)); $color = $ratio > 0.85 ? Icons::HALTED : ($ratio > 0.6 ? Icons::PAUSED : Icons::RUNNING); if ($ratio > 0) { $t->box($r, $x + $labelW, $y, max($t->px(4), $bw * $ratio), $t->px(14), $color, $t->px(4)); } } $small->draw($r, self::fit($small, $text, $bw - $t->px(8)), $x + $labelW + $bw - $t->px(4), $y + $t->px(16), 1, '#6b7480', 'right'); } public function pointerUp(Pointer $p, bool $inside): void { if ($this->popupUp($p) || $this->barUp() || !$inside || $this->form === null) { return; } $t = $this->form->theme; $i = $this->cardAt($t, $p->x, $p->y); if ($i < 0) { return; } $card = $this->cards[$i]; [$bx, $by, $bw, $bh] = $this->connectRect($t, $i); if (!in_array($card['state'], [Connection::CONNECTED, Connection::CONNECTING], true) && $p->x >= $bx && $p->x < $bx + $bw && $p->y >= $by && $p->y < $by + $bh) { $this->emit('connect', (int)$card['index']); return; } $this->emit('select', (string)$card['id']); }}