diff --git a/application/controllers/DashboardController.php b/application/controllers/DashboardController.php new file mode 100644 index 00000000..142eb5a3 --- /dev/null +++ b/application/controllers/DashboardController.php @@ -0,0 +1,64 @@ +addTitleTab($this->translate('Kubernetes')); + + $cluster = (new ClusterForm()) + ->populate( + [ + 'cluster_uuid' => Session::getSession() + ->getNamespace('kubernetes') + ->get('cluster_uuid', ClusterForm::ALL_CLUSTERS) + ] + ) + ->on(ClusterForm::ON_SUCCESS, function (ClusterForm $form) { + $session = Session::getSession() + ->getNamespace('kubernetes'); + $clusterUuid = $form->getElement('cluster_uuid')->getValue(); + if ($clusterUuid === ClusterForm::ALL_CLUSTERS) { + $session->set('cluster_uuid', null); + } else { + $session->set('cluster_uuid', $clusterUuid); + } + }) + ->handleRequest(ServerRequest::fromGlobals()); + + if ($this->isMultiCluster()) { + $this->addContent($cluster); + } + + $this->content->addHtml( + new ClusterManagementDashboard(), + new WorkloadsDashboard(), + new StorageDashboard(), + new NetworkingDashboard(), + new ConfigurationDashboard(), + new ObservabilityDashboard(), + ); + } + + protected function isMultiCluster(): bool + { + return Cluster::on(Database::connection())->count() > 1; + } +} diff --git a/assets/Icinga-Kubernetes.svg b/assets/Icinga-Kubernetes.svg index ae82af2d..09f5573e 100644 --- a/assets/Icinga-Kubernetes.svg +++ b/assets/Icinga-Kubernetes.svg @@ -10,7 +10,7 @@ - + @@ -18,10 +18,11 @@ - + - + + \ No newline at end of file diff --git a/assets/Icinga-Kubernetes.ttf b/assets/Icinga-Kubernetes.ttf index ede13328..2743a38e 100644 Binary files a/assets/Icinga-Kubernetes.ttf and b/assets/Icinga-Kubernetes.ttf differ diff --git a/assets/Icinga-Kubernetes.woff b/assets/Icinga-Kubernetes.woff index 1931f87b..e0a22431 100644 Binary files a/assets/Icinga-Kubernetes.woff and b/assets/Icinga-Kubernetes.woff differ diff --git a/configuration.php b/configuration.php index 4739a225..9f937fa5 100644 --- a/configuration.php +++ b/configuration.php @@ -10,7 +10,8 @@ $section = $this->menuSection( 'Kubernetes', [ - 'icon' => 'globe' + 'icon' => 'globe', + 'url' => 'kubernetes/dashboard', ] ); diff --git a/library/Kubernetes/Common/Auth.php b/library/Kubernetes/Common/Auth.php index 90d34303..1fe75d17 100644 --- a/library/Kubernetes/Common/Auth.php +++ b/library/Kubernetes/Common/Auth.php @@ -32,22 +32,22 @@ class Auth public const SHOW_STATEFUL_SETS = 'kubernetes/stateful-sets/show'; public const PERMISSIONS = [ - 'ConfigMap' => self::SHOW_CONFIG_MAPS, - 'CronJob' => self::SHOW_CRON_JOBS, - 'DaemonSet' => self::SHOW_DAEMON_SETS, - 'Deployment' => self::SHOW_DEPLOYMENTS, - 'Event' => self::SHOW_EVENTS, - 'Ingress' => self::SHOW_INGRESSES, - 'Job' => self::SHOW_JOBS, - 'Namespace' => self::SHOW_NAMESPACES, - 'Node' => self::SHOW_NODES, - 'PersistentVolume' => self::SHOW_PERSISTENT_VOLUMES, - 'PersistentVolumeClaim' => self::SHOW_PERSISTENT_VOLUME_CLAIMS, - 'Pod' => self::SHOW_PODS, - 'ReplicaSet' => self::SHOW_REPLICA_SETS, - 'Secret' => self::SHOW_SECRETS, - 'Service' => self::SHOW_SERVICES, - 'StatefulSet' => self::SHOW_STATEFUL_SETS, + 'configmap' => self::SHOW_CONFIG_MAPS, + 'cronjob' => self::SHOW_CRON_JOBS, + 'daemonset' => self::SHOW_DAEMON_SETS, + 'deployment' => self::SHOW_DEPLOYMENTS, + 'event' => self::SHOW_EVENTS, + 'ingress' => self::SHOW_INGRESSES, + 'job' => self::SHOW_JOBS, + 'namespace' => self::SHOW_NAMESPACES, + 'node' => self::SHOW_NODES, + 'persistentvolume' => self::SHOW_PERSISTENT_VOLUMES, + 'persistentvolumeclaim' => self::SHOW_PERSISTENT_VOLUME_CLAIMS, + 'pod' => self::SHOW_PODS, + 'replicaset' => self::SHOW_REPLICA_SETS, + 'secret' => self::SHOW_SECRETS, + 'service' => self::SHOW_SERVICES, + 'statefulset' => self::SHOW_STATEFUL_SETS, ]; protected IcingaAuth $auth; diff --git a/library/Kubernetes/Common/BeforeAssemble.php b/library/Kubernetes/Common/BeforeAssemble.php new file mode 100644 index 00000000..a9b7ae70 --- /dev/null +++ b/library/Kubernetes/Common/BeforeAssemble.php @@ -0,0 +1,30 @@ +hasBeenAssembled === false && ! $this->beforeAssemble) { + $this->beforeAssemble = true; + $this->beforeAssemble(); + } + + return parent::ensureAssembled(); + } + + /** + * Hook method to perform actions before assembling the object. + */ + protected function beforeAssemble(): void + { + } +} diff --git a/library/Kubernetes/Common/FormatString.php b/library/Kubernetes/Common/FormatString.php new file mode 100644 index 00000000..228b2045 --- /dev/null +++ b/library/Kubernetes/Common/FormatString.php @@ -0,0 +1,48 @@ +args = array_merge($this->args, $args); + + return $this; + } + + /** + * Convert the format string to a formatted string using the provided arguments. + * + * @return string The formatted string. + */ + public function __toString(): string + { + return ! empty($this->args) ? vsprintf(strtr($this->format, $this->args), $this->args) : $this->format; + } +} diff --git a/library/Kubernetes/Dashboard/ClusterManagementDashboard.php b/library/Kubernetes/Dashboard/ClusterManagementDashboard.php new file mode 100644 index 00000000..ed21fbe9 --- /dev/null +++ b/library/Kubernetes/Dashboard/ClusterManagementDashboard.php @@ -0,0 +1,34 @@ +translate('Cluster management'); + } + + protected function assemble(): void + { + $this->addHtml( + new KubernetesPhaseDashlet( + 'namespace', + $this->translate('Namespaces'), + $this->translate( + 'Out of {total} total Namespaces, {Active} are Active, {Terminating} are Terminating.' + ) + ), + new IcingaStateDashlet( + 'node', + $this->translate('Nodes'), + $this->translate( + 'Out of {total} total Nodes, {ok} are in OK state, {critical} are Critical, {warning} are + in Warning state, and {unknown} are Unknown.' + ) + ) + ); + } +} diff --git a/library/Kubernetes/Dashboard/ConfigurationDashboard.php b/library/Kubernetes/Dashboard/ConfigurationDashboard.php new file mode 100644 index 00000000..1097515e --- /dev/null +++ b/library/Kubernetes/Dashboard/ConfigurationDashboard.php @@ -0,0 +1,29 @@ +translate('Configuration'); + } + + protected function assemble(): void + { + $this->addHtml( + new Dashlet( + 'configmap', + $this->translate('Config Maps'), + $this->translate('Store configuration data as key-value pairs.') + ), + new Dashlet( + 'secret', + $this->translate('Secrets'), + $this->translate('Store sensitive data (e.g., passwords, tokens) in an encrypted format.') + ) + ); + } +} diff --git a/library/Kubernetes/Dashboard/Dashboard.php b/library/Kubernetes/Dashboard/Dashboard.php new file mode 100644 index 00000000..f037096f --- /dev/null +++ b/library/Kubernetes/Dashboard/Dashboard.php @@ -0,0 +1,31 @@ +setWrapper(new HtmlElement( + 'section', + new Attributes(['class' => 'kubernetes-dashboard']), + new HtmlElement('h2', null, new Text($this->getTitle())) + )); + } +} diff --git a/library/Kubernetes/Dashboard/Dashlet.php b/library/Kubernetes/Dashboard/Dashlet.php new file mode 100644 index 00000000..229a8f8d --- /dev/null +++ b/library/Kubernetes/Dashboard/Dashlet.php @@ -0,0 +1,50 @@ +url = $url !== null ? $url : Factory::createListUrl($kind); + $this->summary = new FormatString($summary); + } + + protected function assemble(): void + { + $this->addHtml(new Link( + [ + $this->title, + Factory::createIcon($this->kind), + new HtmlElement( + 'p', + null, + new Text($this->summary) + ) + ], + $this->url + )); + } +} diff --git a/library/Kubernetes/Dashboard/IcingaStateDashlet.php b/library/Kubernetes/Dashboard/IcingaStateDashlet.php new file mode 100644 index 00000000..c31efb21 --- /dev/null +++ b/library/Kubernetes/Dashboard/IcingaStateDashlet.php @@ -0,0 +1,64 @@ +withRestrictions( + Auth::PERMISSIONS[$this->kind], + Factory::createModel($this->kind)::on(Database::connection()) + ); + + $clusterUuid = Session::getSession()->getNamespace('kubernetes')->get('cluster_uuid'); + if ($clusterUuid !== null) { + $q->filter(Filter::equal('cluster_uuid', Uuid::fromString($clusterUuid)->getBytes())); + } + + $q->columns([ + 'icinga_state', + 'count' => new Expression('COUNT(*)') + ]); + + $q->getSelectBase()->groupBy('icinga_state'); + + $counts = array_fill_keys( + [ + 'ok', + 'warning', + 'critical', + 'unknown', + 'pending' + ], + 0 + ); + + foreach ($q as $count) { + $counts[$count->icinga_state] = $count->count; + } + + $counts['total'] = array_sum($counts); + + return array_combine( + array_map(fn($key) => '{' . $key . '}', array_keys($counts)), + $counts + ); + } + + protected function beforeAssemble(): void + { + $this->summary->addArgs($this->getIcingaStateCounts()); + } +} diff --git a/library/Kubernetes/Dashboard/KubernetesPhaseDashlet.php b/library/Kubernetes/Dashboard/KubernetesPhaseDashlet.php new file mode 100644 index 00000000..ab79ac4c --- /dev/null +++ b/library/Kubernetes/Dashboard/KubernetesPhaseDashlet.php @@ -0,0 +1,67 @@ +withRestrictions( + Auth::PERMISSIONS[$this->kind], + Factory::createModel($this->kind)::on(Database::connection()) + ); + + $clusterUuid = Session::getSession()->getNamespace('kubernetes')->get('cluster_uuid'); + if ($clusterUuid !== null) { + $q->filter(Filter::equal('cluster_uuid', Uuid::fromString($clusterUuid)->getBytes())); + } + + $q->columns([ + 'phase', + 'count' => new Expression('COUNT(*)') + ]); + + $q->getSelectBase()->groupBy('phase'); + + $counts = array_fill_keys( + [ + 'Bound', + 'Failed', + 'Lost', + 'Released', + 'Available', + 'Active', + 'Terminating', + 'Pending', + ], + 0 + ); + + foreach ($q as $count) { + $counts[$count->phase] = $count->count; + } + + $counts['total'] = array_sum($counts); + + return array_combine( + array_map(fn($key) => '{' . $key . '}', array_keys($counts)), + $counts + ); + } + + protected function beforeAssemble(): void + { + $this->summary->addArgs($this->getKubernetesPhaseCounts()); + } +} diff --git a/library/Kubernetes/Dashboard/NetworkingDashboard.php b/library/Kubernetes/Dashboard/NetworkingDashboard.php new file mode 100644 index 00000000..8f1e3578 --- /dev/null +++ b/library/Kubernetes/Dashboard/NetworkingDashboard.php @@ -0,0 +1,41 @@ +translate('Networking'); + } + + protected function assemble(): void + { + $this->addHtml( + new Dashlet( + 'service', + $this->translate('Services'), + $this->translate( + 'Expose Pods within or outside the cluster. Types: ClusterIP, NodePort, LoadBalancer, + ExternalName.' + ) + ), + new Dashlet( + 'service', + $this->translate('Cluster Services'), + $this->translate( + "Core components that manage and support the Kubernetes control plane, networking, and + storage, ensuring the cluster's overall health and operation." + ), + 'kubernetes/services?label.name=kubernetes.io%2Fcluster-service&label.value=true', + ), + new Dashlet( + 'ingress', + $this->translate('Ingresses'), + $this->translate('Manage HTTP/HTTPS traffic into the cluster.') + ), + ); + } +} diff --git a/library/Kubernetes/Dashboard/ObservabilityDashboard.php b/library/Kubernetes/Dashboard/ObservabilityDashboard.php new file mode 100644 index 00000000..4e57f236 --- /dev/null +++ b/library/Kubernetes/Dashboard/ObservabilityDashboard.php @@ -0,0 +1,24 @@ +translate('Observability'); + } + + protected function assemble(): void + { + $this->addHtml( + new Dashlet( + 'event', + $this->translate('Events'), + $this->translate('Record changes and issues within the cluster.') + ) + ); + } +} diff --git a/library/Kubernetes/Dashboard/StorageDashboard.php b/library/Kubernetes/Dashboard/StorageDashboard.php new file mode 100644 index 00000000..d6da8222 --- /dev/null +++ b/library/Kubernetes/Dashboard/StorageDashboard.php @@ -0,0 +1,35 @@ +translate('Storage'); + } + + protected function assemble(): void + { + $this->addHtml( + new KubernetesPhaseDashlet( + 'persistentvolume', + $this->translate('Persistent Volumes'), + $this->translate( + 'Out of {total} total Persistent Volumes, {Bound} are Bound, {Available} are Available, + {Pending} are Pending, {Released} are Released, and {Failed} are Failed.' + ) + ), + new KubernetesPhaseDashlet( + 'persistentvolumeclaim', + $this->translate('PVCs'), + $this->translate( + 'Out of {total} total Persistent Volume Claims, {Bound} are Bound, {Pending} are Pending, + {Lost} are Lost.' + ) + ) + ); + } +} diff --git a/library/Kubernetes/Dashboard/WorkloadsDashboard.php b/library/Kubernetes/Dashboard/WorkloadsDashboard.php new file mode 100644 index 00000000..db18f2ef --- /dev/null +++ b/library/Kubernetes/Dashboard/WorkloadsDashboard.php @@ -0,0 +1,72 @@ +translate('Workloads'); + } + + protected function assemble(): void + { + $this->addHtml( + new Dashlet( + 'cronjob', + $this->translate('Cron Jobs'), + $this->translate('Schedule Jobs to run at specific times.') + ), + new IcingaStateDashlet( + 'daemonset', + $this->translate('Daemon Sets'), + $this->translate( + 'Out of {total} total Daemon Sets, {ok} are in OK state, {critical} are Critical, + {warning} are in Warning state, and {unknown} are Unknown.' + ) + ), + new IcingaStateDashlet( + 'deployment', + $this->translate('Deployments'), + $this->translate( + 'Out of {total} total Deployments, {ok} are in OK state, {critical} are Critical, + {warning} are in Warning state, and {unknown} are Unknown.' + ) + ), + new IcingaStateDashlet( + 'job', + $this->translate('Jobs'), + $this->translate( + 'Out of {total} total Jobs, {ok} are in OK state, {critical} are Critical, {warning} are in + Warning state, {unknown} are Unknown and {pending} are in Pending State.' + ) + ), + new IcingaStateDashlet( + 'pod', + $this->translate('Pods'), + $this->translate( + 'Out of {total} total Pods, {ok} are in OK state, {critical} are Critical, {warning} are in + Warning state, {unknown} are Unknown, and {pending} are in Pending State.' + ) + ), + new IcingaStateDashlet( + 'replicaset', + $this->translate('Replica Sets'), + $this->translate( + 'Out of {total} total Replica Sets, {ok} are in OK state, {critical} are Critical, + {warning} are in Warning state, and {unknown} are Unknown.' + ) + ), + new IcingaStateDashlet( + 'statefulset', + $this->translate('Stateful Sets'), + $this->translate( + 'Out of {total} total Stateful Sets, {ok} are in OK state, {critical} are Critical, + {warning} are in Warning state, and {unknown} are Unknown.' + ) + ) + ); + } +} diff --git a/library/Kubernetes/Web/ClusterForm.php b/library/Kubernetes/Web/ClusterForm.php new file mode 100644 index 00000000..513aebb2 --- /dev/null +++ b/library/Kubernetes/Web/ClusterForm.php @@ -0,0 +1,44 @@ +columns(['uuid', 'name']); + + foreach ($clusters as $cluster) { + $label = $cluster->name ?? (string) Uuid::fromBytes($cluster->uuid); + + yield (string) Uuid::fromBytes($cluster->uuid) => $label; + } + } + + protected function assemble(): void + { + $this->addElement( + 'select', + 'cluster_uuid', + [ + 'required' => true, + 'class' => 'autosubmit', + 'label' => $this->translate('Cluster'), + 'options' => [ + static::ALL_CLUSTERS => $this->translate('All clusters'), + ] + iterator_to_array($this->yieldClusters()), + ], + ); + } +} diff --git a/library/Kubernetes/Web/EventListItem.php b/library/Kubernetes/Web/EventListItem.php index c3b5e2ec..8a63130c 100644 --- a/library/Kubernetes/Web/EventListItem.php +++ b/library/Kubernetes/Web/EventListItem.php @@ -49,7 +49,7 @@ protected function assembleTitle(BaseHtmlElement $title): void $kind = strtolower($this->item->reference_kind); $icon = Factory::createIcon($kind); - $url = Factory::createUrl($kind); + $url = Factory::createDetailUrl($kind); if ($url !== null) { $content = new HtmlDocument(); diff --git a/library/Kubernetes/Web/Factory.php b/library/Kubernetes/Web/Factory.php index 2cd8ad59..c3b29257 100644 --- a/library/Kubernetes/Web/Factory.php +++ b/library/Kubernetes/Web/Factory.php @@ -25,6 +25,7 @@ use ipl\Html\Attributes; use ipl\Html\HtmlElement; use ipl\Html\ValidHtml; +use ipl\Orm\Model; use ipl\Stdlib\Filter\Rule; use ipl\Web\Url; use ipl\Web\Widget\EmptyState; @@ -138,7 +139,32 @@ public static function createList(string $kind, Rule $filter): ValidHtml } } - public static function createUrl(string $kind): ?Url + public static function createModel(string $kind): ?Model + { + $kind = strtolower(str_replace(['_', '-'], '', $kind)); + + return match ($kind) { + 'configmap' => new ConfigMap(), + 'cronjob' => new CronJob(), + 'daemonset' => new DaemonSet(), + 'deployment' => new Deployment(), + 'event' => new Event(), + 'ingress' => new Ingress(), + 'job' => new Job(), + 'namespace' => new NamespaceModel(), + 'node' => new Node(), + 'persistentvolume' => new PersistentVolume(), + 'persistentvolumeclaim' => new PersistentVolumeClaim(), + 'pod' => new Pod(), + 'replicaset' => new ReplicaSet(), + 'secret' => new Secret(), + 'service' => new Service(), + 'statefulset' => new StatefulSet(), + default => null, + }; + } + + public static function createDetailUrl(string $kind): ?Url { $kind = strtolower(str_replace(['_', '-'], '', $kind)); @@ -163,4 +189,36 @@ public static function createUrl(string $kind): ?Url default => null }; } + + public static function createListUrl(string $kind): ?Url + { + $kind = strtolower(str_replace(['_', '-'], '', $kind)); + + $controller = match ($kind) { + 'configmap', + 'container', + 'cronjob', + 'daemonset', + 'deployment', + 'event', + 'job', + 'namespace', + 'node', + 'persistentvolume', + 'persistentvolumeclaim', + 'pod', + 'replicaset', + 'secret', + 'service', + 'statefulset' => "{$kind}s", + 'ingress' => 'ingresses', + default => null + }; + + if ($controller !== null) { + return Url::fromPath("kubernetes/$controller"); + } + + return null; + } } diff --git a/library/Kubernetes/Web/ListController.php b/library/Kubernetes/Web/ListController.php index 7b4f3185..d7aa8854 100644 --- a/library/Kubernetes/Web/ListController.php +++ b/library/Kubernetes/Web/ListController.php @@ -6,10 +6,13 @@ use Icinga\Module\Kubernetes\Common\Auth; use Icinga\Module\Kubernetes\TBD\ObjectSuggestions; +use Icinga\Web\Session; use ipl\Orm\Query; +use ipl\Stdlib\Filter; use ipl\Web\Compat\SearchControls; use ipl\Web\Control\LimitControl; use ipl\Web\Control\SortControl; +use Ramsey\Uuid\Uuid; abstract class ListController extends Controller { @@ -39,6 +42,13 @@ public function indexAction(): void $q = Auth::getInstance()->withRestrictions($this->getPermission(), $this->getQuery()); + $clusterUuid = Session::getSession() + ->getNamespace('kubernetes') + ->get('cluster_uuid'); + if ($clusterUuid !== null) { + $q->filter(Filter::equal('cluster_uuid', Uuid::fromString($clusterUuid)->getBytes())); + } + $limitControl = $this->createLimitControl(); $sortControl = $this->createSortControl($q, $this->getSortColumns()); $paginationControl = $this->createPaginationControl($q); diff --git a/public/css/common.less b/public/css/common.less index 83e55852..122f6604 100644 --- a/public/css/common.less +++ b/public/css/common.less @@ -29,11 +29,6 @@ pre { } .controls { - // Copied from Icinga DB Web - -moz-box-shadow: 0 0 0 1px var(--gray-lighter, #4b4b4b); - -webkit-box-shadow: 0 0 0 1px var(--gray-lighter, #4b4b4b); - box-shadow: 0 0 0 1px var(--gray-lighter, #4b4b4b); - a.subject { cursor: default; pointer-events: none; diff --git a/public/css/icons.less b/public/css/icons.less index d5baae74..03cbd1e9 100644 --- a/public/css/icons.less +++ b/public/css/icons.less @@ -58,6 +58,10 @@ } } +.kicon-event:before { + content: "\e94d"; +} + .kicon-ingress:before { content: "\e904"; } diff --git a/public/css/module.less b/public/css/module.less index 3d83db7d..c66f095b 100644 --- a/public/css/module.less +++ b/public/css/module.less @@ -30,3 +30,96 @@ padding-left: 0; } } + +.kubernetes-dashboard > ul { + margin: 0; + padding: 0; + min-width: 38em; + max-width: 80em; + + li { + list-style-type: none; + + text-align: left; + display: inline-block; + padding: 0; + clear: both; + width: 25em; + min-width: 16em; + vertical-align: top; + + a { + i { + font-size: 3em; + display: block; + float: left; + line-height: 1em; + margin-right: 0.3em; + color: @text-color-light; + } + + &.state-critical i { + color: @color-critical; + } + + &.state-warning i { + color: @color-warning; + } + + &.state-ok i { + color: @color-ok; + } + + &.state-unknown i { + color: @color-unknown; + } + + &.state-pending i { + color: @color-pending; + } + + border-left: 0.5em solid transparent; + padding: 1em; + color: @text-color; + font-weight: bold; + display: block; + text-decoration: none; + min-height: 10em; + + overflow: hidden; + + &.active { + border-color: @icinga-blue; + background-color: @tr-active-color; + } + + &:hover { + background-color: @tr-hover-color; + text-decoration: none; + } + + &:active, &:focus { + background-color: @tr-hover-color; + outline: none; + } + } + + p { + font-weight: normal; + margin-bottom: 0.5em; + padding-left: 4.5em; + color: @text-color-light; + } + } +} + +.control-label-group { + flex-direction: row; + font-size: 1.2em; + text-align: left; + width: 5em; +} + +.icinga-controls select { + max-width: 280px; +}