diff --git a/configuration.php b/configuration.php index 9f937fa5..e34b876b 100644 --- a/configuration.php +++ b/configuration.php @@ -289,3 +289,4 @@ $this->provideCssFile('labels.less'); $this->provideCssFile('lists.less'); $this->provideCssFile('widgets.less'); +$this->provideCssFile('environment-widget.less'); diff --git a/library/Kubernetes/Web/CronJobDetail.php b/library/Kubernetes/Web/CronJobDetail.php index 9c70186f..c9d0f90f 100644 --- a/library/Kubernetes/Web/CronJobDetail.php +++ b/library/Kubernetes/Web/CronJobDetail.php @@ -59,7 +59,8 @@ protected function assemble(): void $this->translate('Last Schedule Time') => $lastScheduleTime ])), new Labels($this->cronJob->label), - new Annotations($this->cronJob->annotation) + new Annotations($this->cronJob->annotation), + new CronJobEnvironment($this->cronJob), ); if (Auth::getInstance()->hasPermission(Auth::SHOW_JOBS)) { diff --git a/library/Kubernetes/Web/CronJobEnvironment.php b/library/Kubernetes/Web/CronJobEnvironment.php new file mode 100644 index 00000000..f573ca13 --- /dev/null +++ b/library/Kubernetes/Web/CronJobEnvironment.php @@ -0,0 +1,46 @@ +cronJob->namespace), + Filter::equal('job.owner.owner_uuid', (string) Uuid::fromBytes($this->cronJob->uuid)) + ); + + $jobs = $this->cronJob->job + ->filter($childrenFilter) + ->limit(3); + + return (new HtmlDocument()) + ->addHtml( + new HtmlElement( + 'h2', + new Attributes(['class' => 'environment-widget-title']), + new Text($this->translate('Environment')) + ), + new Environment($this->cronJob, null, $jobs, null, $childrenFilter) + ); + } +} diff --git a/library/Kubernetes/Web/DaemonSetDetail.php b/library/Kubernetes/Web/DaemonSetDetail.php index 3ff3ffd1..1e49a2b8 100644 --- a/library/Kubernetes/Web/DaemonSetDetail.php +++ b/library/Kubernetes/Web/DaemonSetDetail.php @@ -80,7 +80,8 @@ protected function assemble(): void ])), new Labels($this->daemonSet->label), new Annotations($this->daemonSet->annotation), - new ConditionTable($this->daemonSet, (new DaemonSetCondition())->getColumnDefinitions()) + new ConditionTable($this->daemonSet, (new DaemonSetCondition())->getColumnDefinitions()), + new DaemonSetEnvironment($this->daemonSet), ); if (Auth::getInstance()->hasPermission(Auth::SHOW_PODS)) { diff --git a/library/Kubernetes/Web/DaemonSetEnvironment.php b/library/Kubernetes/Web/DaemonSetEnvironment.php new file mode 100644 index 00000000..0e3de548 --- /dev/null +++ b/library/Kubernetes/Web/DaemonSetEnvironment.php @@ -0,0 +1,48 @@ +daemonSet->namespace), + Filter::equal('pod.owner.owner_uuid', (string) Uuid::fromBytes($this->daemonSet->uuid)) + ); + + $pods = Pod::on(Database::connection()) + ->filter($childrenFilter) + ->limit(3); + + return (new HtmlDocument()) + ->addHtml( + new HtmlElement( + 'h2', + new Attributes(['class' => 'environment-widget-title']), + new Text($this->translate('Environment')) + ), + new Environment($this->daemonSet, null, $pods, null, $childrenFilter) + ); + } +} diff --git a/library/Kubernetes/Web/DeploymentDetail.php b/library/Kubernetes/Web/DeploymentDetail.php index 8ff9c833..07bcef92 100644 --- a/library/Kubernetes/Web/DeploymentDetail.php +++ b/library/Kubernetes/Web/DeploymentDetail.php @@ -82,7 +82,8 @@ protected function assemble(): void ])), new Labels($this->deployment->label), new Annotations($this->deployment->annotation), - new DeploymentConditions($this->deployment) + new DeploymentConditions($this->deployment), + new DeploymentEnvironment($this->deployment), ); if (Auth::getInstance()->hasPermission(Auth::SHOW_REPLICA_SETS)) { diff --git a/library/Kubernetes/Web/DeploymentEnvironment.php b/library/Kubernetes/Web/DeploymentEnvironment.php new file mode 100644 index 00000000..6878a475 --- /dev/null +++ b/library/Kubernetes/Web/DeploymentEnvironment.php @@ -0,0 +1,48 @@ +deployment->namespace), + Filter::equal('replica_set.owner.owner_uuid', (string) Uuid::fromBytes($this->deployment->uuid)) + ); + + $replicaSets = ReplicaSet::on(Database::connection()) + ->filter($childrenFilter) + ->limit(3); + + return (new HtmlDocument()) + ->addHtml( + new HtmlElement( + 'h2', + new Attributes(['class' => 'environment-widget-title']), + new Text($this->translate('Environment')) + ), + new Environment($this->deployment, null, $replicaSets, $childrenFilter) + ); + } +} diff --git a/library/Kubernetes/Web/Environment.php b/library/Kubernetes/Web/Environment.php new file mode 100644 index 00000000..574bf449 --- /dev/null +++ b/library/Kubernetes/Web/Environment.php @@ -0,0 +1,435 @@ + 'environment-widget']; + + /** + * Create Environment nodes + * + * @param Model $currentObject The object whose parents and children should be shown + * @param Query|null $parents The query for parents + * @param Query|null $children The query for children + * @param mixed|null $parentsFilter The filter for parents + * @param mixed|null $childrenFilter The filter for children + */ + public function __construct( + protected Model $currentObject, + protected ?Query $parents = null, + protected ?Query $children = null, + protected $parentsFilter = null, + protected $childrenFilter = null, + ) { + } + + protected function assemble(): void + { + $parents = $this->getParentsAndChildrenKind()['parents']; + $children = $this->getParentsAndChildrenKind()['children']; + + if ($this->parents !== null) { + $parentList = $this->createNodeList(true); + $svgParentList = new HtmlElement( + 'div', + Attributes::create(['class' => 'parents']), + $parentList, + $this->createSVGLines($parentList->count(), true) + ); + } else { + $svgParentList = new HtmlElement( + 'div', + null + ); + } + + if ($this->children !== null) { + $childList = $this->createNodeList(); + $svgChildrenList = new HtmlElement( + 'div', + Attributes::create(['class' => 'children']), + $this->createSVGLines($childList->count()), + $childList + ); + } else { + $svgChildrenList = new HtmlElement( + 'div', + null + ); + } + + $this->addHtml( + new HtmlElement( + 'div', + Attributes::create(['class' => 'parents-label']), + new HtmlElement('div', Attributes::create(['class' => 'label']), Text::create(t($parents))), + ), + new HtmlElement( + 'div', + Attributes::create(['class' => 'children-label']), + new HtmlElement('div', Attributes::create(['class' => 'label']), Text::create(t($children))), + ), + $svgParentList, + new HtmlElement( + 'div', + Attributes::create(['class' => 'current']), + new HtmlElement( + 'span', + Attributes::create(['class' => 'self']), + $this->renderNode($this->currentObject) + ) + ), + $svgChildrenList + ); + } + + /** + * Create the node list + * + * @param bool $forParents Whether the list is for parents or children + * + * @return HtmlElement + */ + private function createNodeList(bool $forParents = false): HtmlElement + { + $list = new HtmlElement('ul', Attributes::create(['class' => 'node-list'])); + + $query = $forParents ? $this->parents : $this->children; + + foreach ($query as $node) { + $kind = Factory::getKindFromModel($node); + $url = Factory::createDetailUrl($kind); + $url->addParams(['id' => (string) Uuid::fromBytes($node->uuid)]); + + $list->addHtml( + new HtmlElement( + 'li', + null, + new Link( + $this->renderNode($node), + $url, + Attributes::create(['class' => 'node', 'data-base-target' => '_next']) + ) + ) + ); + + $this->createSummary($node, $list, $forParents); + } + + return $list; + } + + /** + * Render the given node with link + * + * @param Model $node + * + * @return ValidHtml + */ + private function renderNode(Model $node): ValidHtml + { + $subject = new HtmlElement('span', Attributes::create(['class' => 'subject'])); + + $subject + ->setHtmlContent(Text::create($node->name)) + ->addAttributes(['title' => $node->name]); + + $kind = Factory::getKindFromModel($node); + + $icon = Factory::createIcon($kind); + + if ( + $node instanceof Service || $node instanceof Ingress || $node instanceof CronJob + || $node instanceof PersistentVolume || $node instanceof PersistentVolumeClaim + ) { + $mainBall = new StateBall('', StateBall::SIZE_LARGE); + } else { + $mainBall = new StateBall($node->icinga_state, StateBall::SIZE_LARGE); + } + + $mainBall->add($icon); + + $iconImage = HtmlElement::create('div', ['class' => 'icon-image']); + + return (new HtmlDocument())->addHtml($mainBall, $iconImage, $subject); + } + + /** + * Create the SVG lines + * + * @param $lineCount int The count of lines to draw + * @param $forParents bool Whether the svg path should be for parents or children + * + * @return HtmlElement + */ + private function createSVGLines(int $lineCount, bool $forParents = false): HtmlElement + { + $path = $forParents + ? 'M 0 %d C 50 %d 50 50 100 50' + : 'M 0 50 C 50 50 50 %d 100 %d'; + + /** + * @var $pathCoordinates array> The `key` contains the count of curves to draw. + * + * The Svg Element is a square with fixed width and has a viewBox attr `0 0 100 100`. It is centred vertically + * to the parent element. + * + * When 4 (max.) dependency nodes are present, coordinate 0/100 begins exactly in the middle of + * the first/last node, coordinate 50 is exactly the center of the parent element. This makes the curve + * calculation easier. + */ + $pathCoordinates = [ + 0 => [], + 1 => [50], + 2 => [33, 66], + 3 => [17, 50, 83], + 4 => [1, 33, 66, 99] + ]; + + $svg = new HtmlElement('svg', Attributes::create(['viewBox' => '0 0 100 100', 'class' => 'svg-lines'])); + foreach ($pathCoordinates[$lineCount] as $coordinate) { + $svg->addHtml( + new HtmlElement( + 'path', + Attributes::create([ + 'stroke' => 'black', + 'stroke-width' => 1, + 'fill' => 'none', + 'd' => sprintf($path, $coordinate, $coordinate) + ]) + ) + ); + } + + return $svg; + } + + /** + * Get the kind of parents and children + * + * @return string[] + */ + private function getParentsAndChildrenKind(): array + { + return match (true) { + $this->currentObject instanceof CronJob => [ + 'parents' => '', + 'children' => 'Jobs' + ], + $this->currentObject instanceof DaemonSet + || $this->currentObject instanceof Job + || $this->currentObject instanceof StatefulSet => + [ + 'parents' => '', + 'children' => 'Pods' + ], + $this->currentObject instanceof Deployment => [ + 'parents' => '', + 'children' => 'Replica Sets' + ], + $this->currentObject instanceof Ingress => [ + 'parents' => '', + 'children' => 'Services' + ], + $this->currentObject instanceof PersistentVolumeClaim => [ + 'parents' => 'Persistent Volumes', + 'children' => 'Pods', + ], + $this->currentObject instanceof PersistentVolume => [ + 'parents' => '', + 'children' => 'Persistent Volume Claims', + ], + $this->currentObject instanceof Pod => [ + 'parents' => 'Services', + 'children' => 'Pod Owner', + ], + $this->currentObject instanceof ReplicaSet => [ + 'parents' => 'Deployments', + 'children' => 'Pods' + ], + $this->currentObject instanceof Service => [ + 'parents' => 'Ingresses', + 'children' => 'Pods' + ], + default => [ + 'parents' => '', + 'children' => '', + ] + }; + } + + /** + * Create summary node + * + * @param Model $node + * @param HtmlElement $list + * @param bool $forParents + * + * @return void + */ + private function createSummary(Model $node, HtmlElement $list, bool $forParents = false): void + { + $kind = Factory::getKindFromModel($node); + + $kindPlural = $kind === 'ingress' ? $kind . 'es' : $kind . 's'; + + $url = Url::fromPath("kubernetes/$kindPlural"); + + $summary = new HtmlElement('footer'); + + + if ($this->currentObject instanceof Job && $node instanceof Pod) { + $pods = (new ItemCountIndicator()) + ->addIndicator('critical', $this->currentObject->failed) + ->addIndicator('pending', $this->currentObject->active) + ->addIndicator('ok', $this->currentObject->succeeded); + + $summary->addHtml( + (new HorizontalKeyValue( + new HtmlElement('i', new Attributes(['class' => 'icon kicon-pod'])), + $pods + )) + ->addAttributes([ + 'title' => sprintf( + $this->translate( + '%d %s available (%d not available)', + '%d:num_of_available_pods %s:pods_translation (%d:num_of_unavailable_pods)' + ), + $pods->getIndicator('ok'), + $this->translatePlural('pod', 'pods', $pods->getIndicator('ok')), + $pods->getIndicator('critical') + ) + ]), + ); + } elseif ( + ($this->currentObject instanceof ReplicaSet || $this->currentObject instanceof StatefulSet) + && $node instanceof Pod + ) { + $pods = (new ItemCountIndicator()) + ->addIndicator( + 'critical', + $this->currentObject->actual_replicas - $this->currentObject->available_replicas + ) + ->addIndicator( + 'pending', + $this->currentObject->desired_replicas - $this->currentObject->actual_replicas + ) + ->addIndicator('ok', $this->currentObject->available_replicas); + + $summary->addHtml( + (new HorizontalKeyValue( + new HtmlElement('i', new Attributes(['class' => 'icon kicon-pod'])), + $pods + )) + ->addAttributes([ + 'title' => sprintf( + $this->translate( + '%d %s available (%d unavailable)', + '%d:num_of_available_replicas %s:replicas_translation + (%d:num_of_unavailable_replicas)' + ), + $pods->getIndicator('ok'), + $this->translatePlural('replica', 'replicas', $pods->getIndicator('ok')), + $pods->getIndicator('critical') + ) + ]), + ); + } elseif ($this->currentObject instanceof DaemonSet && $node instanceof Pod) { + $pods = (new ItemCountIndicator()) + ->addIndicator('critical', $this->currentObject->number_unavailable) + ->addIndicator( + 'pending', + $this->currentObject->desired_number_scheduled - $this->currentObject->current_number_scheduled + ) + ->addIndicator('ok', $this->currentObject->number_available); + + $summary->addHtml( + (new HorizontalKeyValue( + new HtmlElement('i', new Attributes(['class' => 'icon kicon-pod'])), + $pods + )) + ->addAttributes([ + 'title' => sprintf( + $this->translate( + '%d %s available (%d unavailable)', + '%d:num_of_available_daemon_pods %s:daemon_pods_translation + (%d:num_of_unavailable_daemon_pods)' + ), + $pods->getIndicator('ok'), + $this->translatePlural('daemon pod', 'daemon pods', $pods->getIndicator('ok')), + $pods->getIndicator('critical') + ) + ]), + ); + } else { + $content = $forParents ? new Text($this->parents->count()) : new Text($this->children->count()); + $summary->addHtml( + new HorizontalKeyValue( + new HtmlElement('i', new Attributes(['class' => "icon kicon-$kind"])), + $content + ) + ); + } + + if ($list->count() > 2) { + if ($forParents) { + $list->addHtml( + new HtmlElement( + 'li', + null, + new Link( + $summary, + $url->setFilter($this->parentsFilter), + Attributes::create(['class' => ['summary']]) + ) + ) + ); + } else { + $list->addHtml( + new HtmlElement( + 'li', + null, + new Link( + $summary, + $url->setFilter($this->childrenFilter), + Attributes::create(['class' => ['summary']]) + ) + ) + ); + } + } + } +} diff --git a/library/Kubernetes/Web/Factory.php b/library/Kubernetes/Web/Factory.php index c3b29257..cf253f0e 100644 --- a/library/Kubernetes/Web/Factory.php +++ b/library/Kubernetes/Web/Factory.php @@ -26,6 +26,7 @@ use ipl\Html\HtmlElement; use ipl\Html\ValidHtml; use ipl\Orm\Model; +use ipl\Orm\Query; use ipl\Stdlib\Filter\Rule; use ipl\Web\Url; use ipl\Web\Widget\EmptyState; @@ -221,4 +222,58 @@ public static function createListUrl(string $kind): ?Url return null; } + + public static function getKindFromModel(Model $model): string + { + $kind = match (true) { + $model instanceof ConfigMap, + $model instanceof CronJob, + $model instanceof DaemonSet, + $model instanceof Deployment, + $model instanceof Ingress, + $model instanceof Job, + $model instanceof PersistentVolume, + $model instanceof PersistentVolumeClaim, + $model instanceof Pod, + $model instanceof ReplicaSet, + $model instanceof Secret, + $model instanceof Service, + $model instanceof StatefulSet => basename(str_replace('\\', '/', get_class($model))), + default => null + }; + + return strtolower(str_replace(['_', '-'], '', $kind)); + } + + /** + * Retrieves a resource by its kind. + * + * @param string $kind The kind of the resource + * + * @return Query|null + */ + public static function fetchResource(string $kind): ?Query + { + $kind = strtolower(str_replace(['_', '-'], '', $kind)); + + $database = Database::connection(); + + $query = match ($kind) { + 'configmap' => ConfigMap::on($database), + 'container' => Container::on($database), + 'cronjob' => CronJob::on($database), + 'daemonset' => DaemonSet::on($database), + 'deployment' => Deployment::on($database), + 'ingress' => Ingress::on($database), + 'job' => Job::on($database), + 'persistentvolumeclaim' => PersistentVolumeClaim::on($database), + 'pod' => Pod::on($database), + 'replicaset' => ReplicaSet::on($database), + 'service' => Service::on($database), + 'statefulset' => StatefulSet::on($database), + default => null, + }; + + return $query; + } } diff --git a/library/Kubernetes/Web/IngressDetail.php b/library/Kubernetes/Web/IngressDetail.php index 193823eb..eef3f603 100644 --- a/library/Kubernetes/Web/IngressDetail.php +++ b/library/Kubernetes/Web/IngressDetail.php @@ -31,7 +31,8 @@ protected function assemble(): void $this->addHtml( new Details(new ResourceDetails($this->ingress)), new Labels($this->ingress->label), - new Annotations($this->ingress->annotation) + new Annotations($this->ingress->annotation), + new IngressEnvironment($this->ingress) ); $backendServices = IngressBackendService::on(Database::connection()) diff --git a/library/Kubernetes/Web/IngressEnvironment.php b/library/Kubernetes/Web/IngressEnvironment.php new file mode 100644 index 00000000..bb5e4efd --- /dev/null +++ b/library/Kubernetes/Web/IngressEnvironment.php @@ -0,0 +1,53 @@ +filter(Filter::equal('namespace', $this->ingress->namespace)); + + $filters = []; + foreach ($this->ingress->backend_service as $backendService) { + $filters[] = Filter::all( + Filter::equal('service.name', $backendService->service_name), + Filter::equal('service.port.port', $backendService->service_port_number) + ); + } + + $services + ->filter(Filter::any(...$filters)) + ->limit(3); + + return (new HtmlDocument()) + ->addHtml( + new HtmlElement( + 'h2', + new Attributes(['class' => 'environment-widget-title']), + new Text($this->translate('Environment')) + ), + new Environment($this->ingress, null, $services, null, Filter::any(...$filters)) + ); + } +} diff --git a/library/Kubernetes/Web/JobDetail.php b/library/Kubernetes/Web/JobDetail.php index 88d8fd43..b77a64e6 100644 --- a/library/Kubernetes/Web/JobDetail.php +++ b/library/Kubernetes/Web/JobDetail.php @@ -78,7 +78,8 @@ protected function assemble(): void ])), new Labels($this->job->label), new Annotations($this->job->annotation), - new JobConditions($this->job) + new JobConditions($this->job), + new JobEnvironment($this->job), ); if (Auth::getInstance()->hasPermission(Auth::SHOW_PODS)) { diff --git a/library/Kubernetes/Web/JobEnvironment.php b/library/Kubernetes/Web/JobEnvironment.php new file mode 100644 index 00000000..5eda189a --- /dev/null +++ b/library/Kubernetes/Web/JobEnvironment.php @@ -0,0 +1,65 @@ +job = $job; + } + + public function render(): ValidHtml + { + $jobOwner = JobOwner::on(Database::connection()) + ->filter(Filter::equal('job_uuid', Uuid::fromBytes($this->job->uuid)->toString()))->first(); + + $parentsFilter = Filter::all(); + + if ($jobOwner !== null) { + $parentsFilter = Filter::equal('uuid', Uuid::fromBytes($jobOwner->owner_uuid)->toString()); + + $cronJobs = CronJob::on(Database::connection()) + ->filter($parentsFilter) + ->limit(3); + } else { + $cronJobs = null; + } + + $childrenFilter = Filter::all( + Filter::equal('namespace', $this->job->namespace), + Filter::equal('pod.owner.owner_uuid', Uuid::fromBytes($this->job->uuid)->toString()) + ); + + $pods = Pod::on(Database::connection()) + ->filter($childrenFilter) + ->limit(3); + + return (new HtmlDocument()) + ->addHtml( + new HtmlElement( + 'h2', + Attributes::create(['class' => 'environment-widget-title']), + Text::create(t('Environment')) + ), + new Environment($this->job, $cronJobs, $pods, $parentsFilter, $childrenFilter) + ); + } +} diff --git a/library/Kubernetes/Web/PersistentVolumeClaimDetail.php b/library/Kubernetes/Web/PersistentVolumeClaimDetail.php index 57c7a2cc..b7f6deca 100644 --- a/library/Kubernetes/Web/PersistentVolumeClaimDetail.php +++ b/library/Kubernetes/Web/PersistentVolumeClaimDetail.php @@ -64,7 +64,8 @@ protected function assemble(): void ])), new Labels($this->pvc->label), new Annotations($this->pvc->annotation), - new ConditionTable($this->pvc, (new PersistentVolumeClaimCondition())->getColumnDefinitions()) + new ConditionTable($this->pvc, (new PersistentVolumeClaimCondition())->getColumnDefinitions()), + new PersistentVolumeClaimEnvironment($this->pvc), ); if (Auth::getInstance()->hasPermission(Auth::SHOW_PODS)) { diff --git a/library/Kubernetes/Web/PersistentVolumeClaimEnvironment.php b/library/Kubernetes/Web/PersistentVolumeClaimEnvironment.php new file mode 100644 index 00000000..f6798f7e --- /dev/null +++ b/library/Kubernetes/Web/PersistentVolumeClaimEnvironment.php @@ -0,0 +1,42 @@ +pvc->name); + + $pods = $this->pvc->pod + ->filter($childrenFilter) + ->limit(3); + + return (new HtmlDocument()) + ->addHtml( + new HtmlElement( + 'h2', + new Attributes(['class' => 'environment-widget-title']), + new Text($this->translate('Environment')) + ), + new Environment($this->pvc, $this->pvc->persistent_volume, $pods, null, $childrenFilter) + ); + } +} diff --git a/library/Kubernetes/Web/PersistentVolumeDetail.php b/library/Kubernetes/Web/PersistentVolumeDetail.php index 6fde37a3..2abc1b15 100644 --- a/library/Kubernetes/Web/PersistentVolumeDetail.php +++ b/library/Kubernetes/Web/PersistentVolumeDetail.php @@ -59,6 +59,7 @@ protected function assemble(): void ]), new Labels($this->persistentVolume->label), new Annotations($this->persistentVolume->annotation), + new PersistentVolumeEnvironment($this->persistentVolume), ); if (Auth::getInstance()->hasPermission(Auth::SHOW_PERSISTENT_VOLUME_CLAIMS)) { diff --git a/library/Kubernetes/Web/PersistentVolumeEnvironment.php b/library/Kubernetes/Web/PersistentVolumeEnvironment.php new file mode 100644 index 00000000..4b95d1a8 --- /dev/null +++ b/library/Kubernetes/Web/PersistentVolumeEnvironment.php @@ -0,0 +1,35 @@ +addHtml( + new HtmlElement( + 'h2', + new Attributes(['class' => 'environment-widget-title']), + new Text($this->translate('Environment')) + ), + new Environment($this->persistentVolume, null, $this->persistentVolume->pvc, null, null) + ); + } +} diff --git a/library/Kubernetes/Web/PodDetail.php b/library/Kubernetes/Web/PodDetail.php index 0b3d64ef..c38f7bd1 100644 --- a/library/Kubernetes/Web/PodDetail.php +++ b/library/Kubernetes/Web/PodDetail.php @@ -13,6 +13,7 @@ use Icinga\Module\Kubernetes\Model\Container; use Icinga\Module\Kubernetes\Model\Event; use Icinga\Module\Kubernetes\Model\Pod; +use Icinga\Module\Kubernetes\Model\PodOwner; use ipl\Html\Attributes; use ipl\Html\BaseHtmlElement; use ipl\Html\HtmlDocument; @@ -23,6 +24,7 @@ use ipl\Web\Widget\EmptyState; use ipl\Web\Widget\Icon; use ipl\Web\Widget\StateBall; +use Ramsey\Uuid\Uuid; class PodDetail extends BaseHtmlElement { @@ -95,6 +97,7 @@ protected function assemble(): void new Labels($this->pod->label), new Annotations($this->pod->annotation), new PodConditions($this->pod), + new PodEnvironment($this->pod), new HtmlElement( 'section', null, diff --git a/library/Kubernetes/Web/PodEnvironment.php b/library/Kubernetes/Web/PodEnvironment.php new file mode 100644 index 00000000..caae9ae8 --- /dev/null +++ b/library/Kubernetes/Web/PodEnvironment.php @@ -0,0 +1,59 @@ +pod->namespace), + Filter::equal('service.pod.uuid', (string) Uuid::fromBytes($this->pod->uuid)) + ); + + $services = $this->pod->service + ->filter($parentsFilter) + ->limit(3); + + $podOwner = PodOwner::on(Database::connection()) + ->filter(Filter::equal('pod_uuid', (string) Uuid::fromBytes($this->pod->uuid)))->first(); + + + if ($podOwner !== null) { + $podOwnerKind = Factory::fetchResource($podOwner->kind) + ->filter(Filter::equal('uuid', (string) Uuid::fromBytes($podOwner->owner_uuid))); + } else { + $podOwnerKind = null; + } + + return (new HtmlDocument()) + ->addHtml( + new HtmlElement( + 'h2', + new Attributes(['class' => 'environment-widget-title']), + new Text($this->translate('Environment')) + ), + new Environment($this->pod, $services, $podOwnerKind, $parentsFilter, null) + ); + } +} diff --git a/library/Kubernetes/Web/ReplicaSetDetail.php b/library/Kubernetes/Web/ReplicaSetDetail.php index 1aeb9466..5db81462 100644 --- a/library/Kubernetes/Web/ReplicaSetDetail.php +++ b/library/Kubernetes/Web/ReplicaSetDetail.php @@ -73,7 +73,8 @@ protected function assemble(): void ])), new Labels($this->replicaSet->label), new Annotations($this->replicaSet->annotation), - new ReplicaSetConditions($this->replicaSet) + new ReplicaSetConditions($this->replicaSet), + new ReplicaSetEnvironment($this->replicaSet), ); if (Auth::getInstance()->hasPermission(Auth::SHOW_PODS)) { diff --git a/library/Kubernetes/Web/ReplicaSetEnvironment.php b/library/Kubernetes/Web/ReplicaSetEnvironment.php new file mode 100644 index 00000000..cc5e3326 --- /dev/null +++ b/library/Kubernetes/Web/ReplicaSetEnvironment.php @@ -0,0 +1,60 @@ +filter(Filter::equal('replica_set_uuid', (string) Uuid::fromBytes($this->replicaSet->uuid)))->first(); + + if ($replicaSetOwner !== null) { + $deployments = Deployment::on(Database::connection()) + ->filter(Filter::equal('uuid', (string) Uuid::fromBytes($replicaSetOwner->owner_uuid))); + } else { + $deployments = null; + } + + $childrenFilter = Filter::all( + Filter::equal('namespace', $this->replicaSet->namespace), + Filter::equal('pod.owner.owner_uuid', (string) Uuid::fromBytes($this->replicaSet->uuid)) + ); + + $pods = Pod::on(Database::connection()) + ->filter($childrenFilter) + ->limit(3); + + return (new HtmlDocument()) + ->addHtml( + new HtmlElement( + 'h2', + new Attributes(['class' => 'environment-widget-title']), + new Text($this->translate('Environment')) + ), + new Environment($this->replicaSet, $deployments, $pods, null, $childrenFilter) + ); + } +} diff --git a/library/Kubernetes/Web/ServiceDetail.php b/library/Kubernetes/Web/ServiceDetail.php index 8b38b442..fa4722a7 100644 --- a/library/Kubernetes/Web/ServiceDetail.php +++ b/library/Kubernetes/Web/ServiceDetail.php @@ -10,6 +10,7 @@ use Icinga\Module\Kubernetes\Common\ResourceDetails; use Icinga\Module\Kubernetes\Model\Endpoint; use Icinga\Module\Kubernetes\Model\EndpointSlice; +use Icinga\Module\Kubernetes\Model\Ingress; use Icinga\Module\Kubernetes\Model\Pod; use Icinga\Module\Kubernetes\Model\Service; use Icinga\Module\Kubernetes\Model\ServicePort; @@ -76,7 +77,8 @@ protected function assemble(): void new Labels($this->service->label), new Annotations($this->service->annotation), new PortTable($this->service->port, (new ServicePort())->getColumnDefinitions()), - new EndpointTable($endpointSlices->endpoint, (new Endpoint())->getColumnDefinitions()) + new EndpointTable($endpointSlices->endpoint, (new Endpoint())->getColumnDefinitions()), + new ServiceEnvironment($this->service) ); $selectors = $this->service->selector->execute(); diff --git a/library/Kubernetes/Web/ServiceEnvironment.php b/library/Kubernetes/Web/ServiceEnvironment.php new file mode 100644 index 00000000..107f8d42 --- /dev/null +++ b/library/Kubernetes/Web/ServiceEnvironment.php @@ -0,0 +1,65 @@ +add( + Filter::all( + Filter::equal('namespace', $this->service->namespace), + Filter::equal('ingress.backend_service.service_name', $this->service->name) + ) + ); + + foreach ($this->service->port as $servicePort) { + $parentsFilter->add(Filter::equal('ingress.backend_service.service_port_number', $servicePort->port)); + } + + $ingresses = $ingresses->setFilter($parentsFilter) + ->limit(3); + + $pods = $this->service->pod + ->filter(Filter::equal('namespace', $this->service->namespace)) + ->limit(3); + + $childrenFilter = Filter::all( + Filter::equal('namespace', $this->service->namespace), + Filter::equal('pod.service.uuid', (string) Uuid::fromBytes($this->service->uuid)) + ); + + return (new HtmlDocument()) + ->addHtml( + new HtmlElement( + 'h2', + new Attributes(['class' => 'environment-widget-title']), + new Text($this->translate('Environment')) + ), + new Environment($this->service, $ingresses, $pods, $parentsFilter, $childrenFilter) + ); + } +} diff --git a/library/Kubernetes/Web/StatefulSetDetail.php b/library/Kubernetes/Web/StatefulSetDetail.php index 76be76d3..ff272806 100644 --- a/library/Kubernetes/Web/StatefulSetDetail.php +++ b/library/Kubernetes/Web/StatefulSetDetail.php @@ -87,7 +87,8 @@ protected function assemble(): void ])), new Labels($this->statefulSet->label), new Annotations($this->statefulSet->annotation), - new ConditionTable($this->statefulSet, (new StatefulSetCondition())->getColumnDefinitions()) + new ConditionTable($this->statefulSet, (new StatefulSetCondition())->getColumnDefinitions()), + new StatefulSetEnvironment($this->statefulSet), ); if (Auth::getInstance()->hasPermission(Auth::SHOW_PODS)) { diff --git a/library/Kubernetes/Web/StatefulSetEnvironment.php b/library/Kubernetes/Web/StatefulSetEnvironment.php new file mode 100644 index 00000000..06c7d749 --- /dev/null +++ b/library/Kubernetes/Web/StatefulSetEnvironment.php @@ -0,0 +1,48 @@ +statefulSet->namespace), + Filter::equal('pod.owner.owner_uuid', (string) Uuid::fromBytes($this->statefulSet->uuid)) + ); + + $pods = Pod::on(Database::connection()) + ->filter($childrenFilter) + ->limit(3); + + return (new HtmlDocument()) + ->addHtml( + new HtmlElement( + 'h2', + new Attributes(['class' => 'environment-widget-title']), + new Text($this->translate('Environment')) + ), + new Environment($this->statefulSet, null, $pods, null, $childrenFilter) + ); + } +} diff --git a/public/css/environment-widget.less b/public/css/environment-widget.less new file mode 100644 index 00000000..b45c7bf5 --- /dev/null +++ b/public/css/environment-widget.less @@ -0,0 +1,175 @@ +.environment-widget { + display: grid; + grid-template: + "parents-label . children-label" + "parents current children" + / 8fr minmax(2.5em, 4fr) 8fr; + + border: 1px solid @gray-lighter; + .rounded-corners(.5em); + padding: 0.5em; + + .parents { + grid-area: parents; + } + + .children { + grid-area: children; + } + + .current { + grid-area: current; + } + + @object-node-height: 2.5em; + @svg-edge-length: @object-node-height * 4; // 4 = max node list items + @max-node-width: 40em; + + .parents-label { + grid-area: parents-label; + padding-right: @svg-edge-length; + + .label { + margin-left: auto; + } + } + + .children-label { + grid-area: children-label; + padding-left: @svg-edge-length; + + .label { + margin-right: auto; + } + } + + .label { + max-width: @max-node-width; + padding-left: 2em + 0.5em + 2em + 0.5em; // state-ball + margin-right + icon-image + margin-right + text-align: center; + color: @text-color-light; + } + + .parents, .children { + display: flex; + align-items: center; + + .svg-lines { + width: @svg-edge-length; + height: @svg-edge-length; + } + } + + .parents { + justify-content: end; + } + + .node-list, .current { + display: flex; + flex-direction: column; + justify-content: center; + + .icon-image { + display: flex; + align-items: center; + } + + .icon-image, .object-statistics-total { + height: 2em; + width: 2em; + line-height: 2; + flex-shrink: 0; + } + + .icon-image img { + max-height: 100%; + max-width: 100%; + height: auto; + width: auto; + } + } + + .node-list { + width: 0; + min-width: 5em; + max-width: @max-node-width; + flex: 1 1 auto; + height: 15em; + gap: 0.8333em; + + list-style-type: none; + padding: 0; + margin: 0; + + .summary .object-statistics { + width: 100%; + + & > li { + &:not(:last-child) { + margin-right: 0.5em; + } + + &:last-child { + margin: auto; + } + } + + .object-statistics-total { + text-align: left; // less gap from donut AND align it with icon-image + } + } + } + + .node-list, .current { + .node, .summary, .self { + display: flex; + align-items: center; + padding: 0.25em; + height: @object-node-height; + background-color: @gray-lighter; + .rounded-corners(); + + & > :not(:last-child) { + margin-right: 0.5em; + } + } + + .node, .self { + & > .state-ball { + flex-shrink: 0; + } + + .subject { + margin: auto; + .text-ellipsis(); + } + } + + .node:hover, .summary:hover { + background-color: @gray-light; + text-decoration: none; + } + + .self { + cursor: default; + } + + .summary .dependency-node-state-badges { + pointer-events: none; + } + + .summary, .self { + .text-ellipsis(); + } + } +} + +#layout { + &.minimal-layout, + &.poor-layout:not(.sidebar-collapsed), + &.compact-layout.twocols:not(.sidebar-collapsed) { + .environment-widget-title, .environment-widget { + display: none; + } + } +}