diff --git a/app/kubernetes/components/datatables/applications-datatable-details/applications-datatable-details.controller.js b/app/kubernetes/components/datatables/applications-datatable-details/applications-datatable-details.controller.js deleted file mode 100644 index 93a1467d0..000000000 --- a/app/kubernetes/components/datatables/applications-datatable-details/applications-datatable-details.controller.js +++ /dev/null @@ -1,12 +0,0 @@ -import { KubernetesConfigurationKinds } from 'Kubernetes/models/configuration/models'; - -export default class { - $onInit() { - const secrets = (this.configurations || []) - .filter((config) => config.Data && config.Kind === KubernetesConfigurationKinds.SECRET) - .flatMap((config) => Object.entries(config.Data)) - .map(([key, value]) => ({ key, value })); - - this.state = { secrets }; - } -} diff --git a/app/kubernetes/components/datatables/applications-datatable-details/applications-datatable-details.html b/app/kubernetes/components/datatables/applications-datatable-details/applications-datatable-details.html deleted file mode 100644 index f56be4059..000000000 --- a/app/kubernetes/components/datatables/applications-datatable-details/applications-datatable-details.html +++ /dev/null @@ -1,10 +0,0 @@ -
Secrets
- - - - - - -
- -
diff --git a/app/kubernetes/components/datatables/applications-datatable-details/applications-datatable-details.js b/app/kubernetes/components/datatables/applications-datatable-details/applications-datatable-details.js deleted file mode 100644 index 410563560..000000000 --- a/app/kubernetes/components/datatables/applications-datatable-details/applications-datatable-details.js +++ /dev/null @@ -1,10 +0,0 @@ -import angular from 'angular'; -import controller from './applications-datatable-details.controller'; - -angular.module('portainer.kubernetes').component('kubernetesApplicationsDatatableDetails', { - templateUrl: './applications-datatable-details.html', - controller, - bindings: { - configurations: '<', - }, -}); diff --git a/app/kubernetes/components/datatables/applications-datatable/applicationsDatatable.css b/app/kubernetes/components/datatables/applications-datatable/applicationsDatatable.css deleted file mode 100644 index 89cda522c..000000000 --- a/app/kubernetes/components/datatables/applications-datatable/applicationsDatatable.css +++ /dev/null @@ -1,22 +0,0 @@ -.secondary-heading { - background-color: transparent; -} - -.secondary-body { - background-color: transparent; -} - -.datatable-wide { - width: 55px; -} - -.published-url-container { - display: grid; - grid-template-columns: 1fr 1fr 3fr; - padding-top: 10px; - padding-bottom: 5px; -} - -.publish-url-link { - width: min-content; -} diff --git a/app/kubernetes/components/datatables/applications-datatable/applicationsDatatable.html b/app/kubernetes/components/datatables/applications-datatable/applicationsDatatable.html deleted file mode 100644 index 7ca1eac52..000000000 --- a/app/kubernetes/components/datatables/applications-datatable/applicationsDatatable.html +++ /dev/null @@ -1,394 +0,0 @@ -
- -
-
-
-
- -
- Applications -
- -
-
- - - - - - -
- -
- - - -
- - -
-
-
-
-
-
- -
- - Namespace -
-
- -
-
- -
-
- - - System resources are hidden, this can be changed in the table settings. - -
-
- -
-
-
-
-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
- - - - -
- - -
-
-
- - - - - - - - -
- -
- Filters - - -
- -
-
- - - - - - - -
- - - - -
- - -
-
- {{ item.Name }} - - {{ item.Name }} - - system - external - {{ item.StackName || '-' }} - {{ item.ResourcePool }} - {{ item.Image | truncate: 64 }} + {{ item.Containers.length - 1 }}{{ item.ApplicationType }} - - Replicated - Global - {{ item.RunningPodsCount }} / {{ item.TotalPodsCount }} - {{ item.Status }} - {{ item.Pods[0].Status }} - {{ item.Services.length === 0 ? 'No' : 'Yes' }} - {{ item.CreationDate | getisodate }} {{ item.ApplicationOwner ? 'by ' + item.ApplicationOwner : '' }}
- - - - - -
-
-
Published URL(s)
-
- -
- -
-
Loading...
No application available.
-
- -
diff --git a/app/kubernetes/components/datatables/applications-datatable/applicationsDatatable.js b/app/kubernetes/components/datatables/applications-datatable/applicationsDatatable.js deleted file mode 100644 index 63fb3aacc..000000000 --- a/app/kubernetes/components/datatables/applications-datatable/applicationsDatatable.js +++ /dev/null @@ -1,27 +0,0 @@ -import './applicationsDatatable.css'; - -angular.module('portainer.kubernetes').component('kubernetesApplicationsDatatable', { - templateUrl: './applicationsDatatable.html', - controller: 'KubernetesApplicationsDatatableController', - bindings: { - titleText: '@', - titleIcon: '@', - dataset: '<', - tableKey: '@', - settingsKey: '@', - orderBy: '@', - reverseOrder: '<', - removeAction: '<', - refreshCallback: '<', - onPublishingModeClick: '<', - isPrimary: '<', - namespaces: '<', - namespace: '<', - onChangeNamespaceDropdown: '<', - isAppsLoading: '<', - isSystemResources: '<', - isVisible: '<', - setSystemResources: '<', - hideStacksFunctionality: '<', - }, -}); diff --git a/app/kubernetes/components/datatables/applications-datatable/applicationsDatatableController.js b/app/kubernetes/components/datatables/applications-datatable/applicationsDatatableController.js deleted file mode 100644 index 86b4b34c0..000000000 --- a/app/kubernetes/components/datatables/applications-datatable/applicationsDatatableController.js +++ /dev/null @@ -1,251 +0,0 @@ -import _ from 'lodash-es'; -import KubernetesApplicationHelper from 'Kubernetes/helpers/application'; -import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper'; -import { KubernetesConfigurationKinds } from 'Kubernetes/models/configuration/models'; -import { KubernetesApplicationDeploymentTypes, KubernetesApplicationTypes } from 'Kubernetes/models/application/models/appConstants'; - -import { getSchemeFromPort } from '@/react/common/network-utils'; - -angular.module('portainer.kubernetes').controller('KubernetesApplicationsDatatableController', [ - '$scope', - '$controller', - 'DatatableService', - 'Authentication', - function ($scope, $controller, DatatableService, Authentication) { - angular.extend(this, $controller('GenericDatatableController', { $scope: $scope })); - - const ctrl = this; - - this.settings = Object.assign(this.settings, { - showSystem: false, - }); - - this.state = Object.assign(this.state, { - expandAll: false, - expandedItems: [], - namespace: '', - namespaces: [], - }); - - this.filters = { - state: { - open: false, - enabled: false, - values: [], - }, - }; - - this.expandAll = function () { - this.state.expandAll = !this.state.expandAll; - this.state.filteredDataSet.forEach((item) => this.expandItem(item, this.state.expandAll)); - }; - - this.isItemExpanded = function (item) { - return this.state.expandedItems.includes(item.Id); - }; - - this.isExpandable = function (item) { - return item.KubernetesApplications || this.hasConfigurationSecrets(item) || !!this.getPublishedUrls(item).length; - }; - - this.expandItem = function (item, expanded) { - // collapse item - if (!expanded) { - this.state.expandedItems = this.state.expandedItems.filter((id) => id !== item.Id); - // expanded item - } else if (expanded && !this.state.expandedItems.includes(item.Id)) { - this.state.expandedItems = [...this.state.expandedItems, item.Id]; - } - DatatableService.setDataTableExpandedItems(this.tableKey, this.state.expandedItems); - }; - - this.expandItems = function (storedExpandedItems) { - this.state.expandedItems = storedExpandedItems; - if (this.state.expandedItems.length === this.dataset.length) { - this.state.expandAll = true; - } - }; - - this.onDataRefresh = function () { - const storedExpandedItems = DatatableService.getDataTableExpandedItems(this.tableKey); - if (storedExpandedItems !== null) { - this.expandItems(storedExpandedItems); - } - }; - - this.onSettingsShowSystemChange = function () { - this.updateNamespace(); - this.setSystemResources(this.settings.showSystem); - DatatableService.setDataTableSettings(this.tableKey, this.settings); - }; - - this.isExternalApplication = function (item) { - return KubernetesApplicationHelper.isExternalApplication(item); - }; - - this.isSystemNamespace = function (item) { - // if all charts in a helm app/release are in the system namespace - if (item.KubernetesApplications && item.KubernetesApplications.length > 0) { - return item.KubernetesApplications.some((app) => KubernetesNamespaceHelper.isSystemNamespace(app.ResourcePool)); - } - return KubernetesNamespaceHelper.isSystemNamespace(item.ResourcePool); - }; - - this.isDisplayed = function (item) { - return !ctrl.isSystemNamespace(item) || ctrl.settings.showSystem; - }; - - this.getPublishedUrls = function (item) { - // Map all ingress rules in published ports to their respective URLs - const ingressUrls = item.PublishedPorts.flatMap((pp) => pp.IngressRules) - .filter(({ Host, IP }) => Host || IP) - .map(({ Host, IP, Path, TLS }) => { - let scheme = TLS && TLS.filter((tls) => tls.hosts && tls.hosts.includes(Host)).length > 0 ? 'https' : 'http'; - return `${scheme}://${Host || IP}${Path}`; - }); - - // Map all load balancer service ports to ip address - let loadBalancerURLs = []; - if (item.LoadBalancerIPAddress) { - loadBalancerURLs = item.PublishedPorts.map((pp) => { - const scheme = getSchemeFromPort(pp.Port); - return `${scheme}://${item.LoadBalancerIPAddress}:${pp.Port}`; - }); - } - - // combine ingress urls - const publishedUrls = [...ingressUrls, ...loadBalancerURLs]; - - // Return the first URL - priority given to ingress urls, then services (load balancers) - return publishedUrls.length > 0 ? publishedUrls : ''; - }; - - this.hasConfigurationSecrets = function (item) { - return item.Configurations && item.Configurations.some((config) => config.Data && config.Kind === KubernetesConfigurationKinds.SECRET); - }; - - /** - * Do not allow applications in system namespaces to be selected - */ - this.allowSelection = function (item) { - return !this.isSystemNamespace(item); - }; - - this.applyFilters = function (item) { - return ctrl.filters.state.values.some((filter) => item.ApplicationType === filter.type && filter.display); - }; - - this.onStateFilterChange = function () { - this.filters.state.enabled = this.filters.state.values.some((filter) => !filter.display); - }; - - this.prepareTableFromDataset = function () { - const availableTypeFilters = this.dataset.map((item) => ({ type: item.ApplicationType, display: true })); - this.filters.state.values = _.uniqBy(availableTypeFilters, 'type'); - }; - - this.onChangeNamespace = function () { - this.onChangeNamespaceDropdown(this.state.namespace); - }; - - this.updateNamespace = function () { - if (this.namespaces && this.settingsLoaded) { - const allNamespacesOption = { Name: 'All namespaces', Value: '', IsSystem: false }; - const visibleNamespaceOptions = this.namespaces - .filter((ns) => { - if (!this.settings.showSystem && ns.IsSystem) { - return false; - } - return true; - }) - .map((ns) => ({ Name: ns.Name, Value: ns.Name, IsSystem: ns.IsSystem })); - this.state.namespaces = [allNamespacesOption, ...visibleNamespaceOptions]; - - if (this.state.namespace && !this.state.namespaces.find((ns) => ns.Name === this.state.namespace)) { - if (this.state.namespaces.length > 1) { - let defaultNS = this.state.namespaces.find((ns) => ns.Name === 'default'); - defaultNS = defaultNS || this.state.namespaces[1]; - this.state.namespace = defaultNS.Value; - } else { - this.state.namespace = this.state.namespaces[0].Value; - } - } - } - }; - - this.$onChanges = function (changes) { - if (this.settingsLoaded) { - // when the table is visible, sync the show system setting with the stack show system setting - if (changes.isVisible && changes.isVisible.currentValue) { - const storedStacksSettings = DatatableService.getDataTableSettings('kubernetes.applications.stacks'); - if (storedStacksSettings && storedStacksSettings.state) { - this.settings.showSystem = storedStacksSettings.state.showSystemResources; - DatatableService.setDataTableSettings(this.settingsKey, this.settings); - } - } else if (typeof this.isSystemResources !== 'undefined') { - this.settings.showSystem = this.isSystemResources; - DatatableService.setDataTableSettings(this.settingsKey, this.settings); - } - this.state.namespace = this.namespace; - this.updateNamespace(); - this.prepareTableFromDataset(); - } - }; - - this.$onInit = function () { - this.isAdmin = Authentication.isAdmin(); - this.KubernetesApplicationDeploymentTypes = KubernetesApplicationDeploymentTypes; - this.KubernetesApplicationTypes = KubernetesApplicationTypes; - this.setDefaults(); - this.prepareTableFromDataset(); - - this.state.orderBy = this.orderBy; - const storedOrder = DatatableService.getDataTableOrder(this.tableKey); - if (storedOrder !== null) { - this.state.reverseOrder = storedOrder.reverse; - this.state.orderBy = storedOrder.orderBy; - } - - const textFilter = DatatableService.getDataTableTextFilters(this.tableKey); - if (textFilter !== null) { - this.state.textFilter = textFilter; - this.onTextFilterChange(); - } - - const storedFilters = DatatableService.getDataTableFilters(this.tableKey); - if (storedFilters !== null) { - this.filters = storedFilters; - } - if (this.filters && this.filters.state) { - this.filters.state.open = false; - } - - const storedExpandedItems = DatatableService.getDataTableExpandedItems(this.tableKey); - if (storedExpandedItems !== null) { - this.expandItems(storedExpandedItems); - } - - const storedSettings = DatatableService.getDataTableSettings(this.settingsKey); - if (storedSettings !== null) { - this.settings = storedSettings; - this.settings.open = false; - - // make show system in sync with the stack show system settings - const storedStacksSettings = DatatableService.getDataTableSettings('kubernetes.applications.stacks'); - if (storedStacksSettings && storedStacksSettings.state) { - this.settings.showSystem = storedStacksSettings.state.showSystemResources || this.settings.showSystem; - } - - this.setSystemResources && this.setSystemResources(this.settings.showSystem); - } - this.settingsLoaded = true; - // Set the default selected namespace - if (!this.state.namespace) { - this.state.namespace = this.namespace; - } - - this.updateNamespace(); - this.onSettingsRepeaterChange(); - }; - }, -]); diff --git a/app/kubernetes/react/components/applications.ts b/app/kubernetes/react/components/applications.ts index eb5dd6db2..cf303a35e 100644 --- a/app/kubernetes/react/components/applications.ts +++ b/app/kubernetes/react/components/applications.ts @@ -1,6 +1,25 @@ import angular from 'angular'; -export const applicationsModule = angular.module( - 'portainer.kubernetes.react.components.applications', - [] -).name; +import { r2a } from '@/react-tools/react2angular'; +import { withUIRouter } from '@/react-tools/withUIRouter'; +import { withCurrentUser } from '@/react-tools/withCurrentUser'; +import { ApplicationsDatatable } from '@/react/kubernetes/applications/ListView/ApplicationsDatatable/ApplicationsDatatable'; + +export const applicationsModule = angular + .module('portainer.kubernetes.react.components.applications', []) + + .component( + 'kubernetesApplicationsDatatable', + r2a(withUIRouter(withCurrentUser(ApplicationsDatatable)), [ + 'dataset', + 'isLoading', + 'namespace', + 'namespaces', + 'onNamespaceChange', + 'onRefresh', + 'showSystem', + 'onShowSystemChange', + 'onRemove', + 'hideStacks', + ]) + ).name; diff --git a/app/kubernetes/react/components/index.ts b/app/kubernetes/react/components/index.ts index 0aea1b55e..e6351ee68 100644 --- a/app/kubernetes/react/components/index.ts +++ b/app/kubernetes/react/components/index.ts @@ -59,7 +59,6 @@ import { deploymentTypeValidation } from '@/react/kubernetes/applications/compon import { AppDeploymentTypeFormSection } from '@/react/kubernetes/applications/components/AppDeploymentTypeFormSection/AppDeploymentTypeFormSection'; import { EnvironmentVariablesFormSection } from '@/react/kubernetes/applications/components/EnvironmentVariablesFormSection/EnvironmentVariablesFormSection'; import { kubeEnvVarValidationSchema } from '@/react/kubernetes/applications/components/EnvironmentVariablesFormSection/kubeEnvVarValidationSchema'; -import { HelmInsightsBox } from '@/react/kubernetes/applications/ListView/ApplicationsDatatable/HelmInsightsBox'; import { IntegratedAppsDatatable } from '@/react/kubernetes/components/IntegratedAppsDatatable/IntegratedAppsDatatable'; import { applicationsModule } from './applications'; @@ -101,7 +100,6 @@ export const ngModule = angular 'value', ]) ) - .component('helmInsightsBox', r2a(HelmInsightsBox, [])) .component( 'namespaceAccessUsersSelector', r2a(NamespaceAccessUsersSelector, [ diff --git a/app/kubernetes/views/applications/applications.html b/app/kubernetes/views/applications/applications.html index b8935f2b9..9b076efb0 100644 --- a/app/kubernetes/views/applications/applications.html +++ b/app/kubernetes/views/applications/applications.html @@ -10,23 +10,18 @@ Applications + @@ -50,20 +45,15 @@ diff --git a/app/kubernetes/views/applications/applicationsController.js b/app/kubernetes/views/applications/applicationsController.js index f125cac8b..1ab2b90c4 100644 --- a/app/kubernetes/views/applications/applicationsController.js +++ b/app/kubernetes/views/applications/applicationsController.js @@ -5,7 +5,6 @@ import KubernetesApplicationHelper from 'Kubernetes/helpers/application'; import KubernetesConfigurationHelper from 'Kubernetes/helpers/configurationHelper'; import { KubernetesApplicationTypes } from 'Kubernetes/models/application/models/appConstants'; import { KubernetesPortainerApplicationStackNameLabel } from 'Kubernetes/models/application/models'; -import { confirmDelete } from '@@/modals/confirm'; import { getDeploymentOptions } from '@/react/portainer/environments/environment.service'; class KubernetesApplicationsController { @@ -118,11 +117,7 @@ class KubernetesApplicationsController { } removeAction(selectedItems) { - confirmDelete('Do you want to remove the selected application(s)?').then((confirmed) => { - if (confirmed) { - return this.$async(this.removeActionAsync, selectedItems); - } - }); + this.$async(() => this.removeActionAsync(selectedItems)); } onPublishingModeClick(application) { @@ -169,7 +164,9 @@ class KubernetesApplicationsController { } setSystemResources(flag) { - this.state.isSystemResources = flag; + return this.$scope.$applyAsync(() => { + this.state.isSystemResources = flag; + }); } getApplications() { diff --git a/app/kubernetes/views/applications/helm/helm.html b/app/kubernetes/views/applications/helm/helm.html index 39c866af0..54bea49f8 100644 --- a/app/kubernetes/views/applications/helm/helm.html +++ b/app/kubernetes/views/applications/helm/helm.html @@ -25,7 +25,7 @@ Name - + {{ $ctrl.state.release.name }} diff --git a/app/portainer/components/copy-button/copy-button.controller.js b/app/portainer/components/copy-button/copy-button.controller.js deleted file mode 100644 index 1cd049a64..000000000 --- a/app/portainer/components/copy-button/copy-button.controller.js +++ /dev/null @@ -1,13 +0,0 @@ -export default class CopyButtonController { - /* @ngInject */ - constructor(clipboard) { - this.clipboard = clipboard; - this.state = { isFading: false }; - } - - copyValueText() { - this.clipboard.copyText(this.value); - this.state.isFading = true; - setTimeout(() => (this.state.isFading = false), 1000); - } -} diff --git a/app/portainer/components/copy-button/copy-button.css b/app/portainer/components/copy-button/copy-button.css deleted file mode 100644 index e412bbbc0..000000000 --- a/app/portainer/components/copy-button/copy-button.css +++ /dev/null @@ -1,39 +0,0 @@ -@-webkit-keyframes fadeOut { - 0% { - opacity: 1; - } - 50% { - opacity: 0.8; - } - 99% { - opacity: 0.01; - } - 100% { - opacity: 0; - } -} -@keyframes fadeOut { - 0% { - opacity: 1; - } - 50% { - opacity: 0.8; - } - 99% { - opacity: 0.01; - } - 100% { - opacity: 0; - } -} - -.copy-button-fadeout { - animation: fadeOut 2.5s; - animation-fill-mode: forwards; -} - -.copy-button-copy-text { - opacity: 0; - margin-left: 7px; - color: #23ae89; -} diff --git a/app/portainer/components/copy-button/copy-button.html b/app/portainer/components/copy-button/copy-button.html deleted file mode 100644 index 2f25f7577..000000000 --- a/app/portainer/components/copy-button/copy-button.html +++ /dev/null @@ -1,10 +0,0 @@ - - - - - copied - - diff --git a/app/portainer/components/copy-button/copy-button.js b/app/portainer/components/copy-button/copy-button.js deleted file mode 100644 index 09a417f3f..000000000 --- a/app/portainer/components/copy-button/copy-button.js +++ /dev/null @@ -1,11 +0,0 @@ -import angular from 'angular'; -import controller from './copy-button.controller'; -import './copy-button.css'; - -angular.module('portainer.app').component('copyButton', { - templateUrl: './copy-button.html', - controller, - bindings: { - value: '<', - }, -}); diff --git a/app/portainer/components/sensitive-details/sensitive-details.html b/app/portainer/components/sensitive-details/sensitive-details.html deleted file mode 100644 index 7d4f565e8..000000000 --- a/app/portainer/components/sensitive-details/sensitive-details.html +++ /dev/null @@ -1,5 +0,0 @@ -
-
{{ $ctrl.key }}
- - -
diff --git a/app/portainer/components/sensitive-details/sensitive-details.js b/app/portainer/components/sensitive-details/sensitive-details.js deleted file mode 100644 index 5167eea41..000000000 --- a/app/portainer/components/sensitive-details/sensitive-details.js +++ /dev/null @@ -1,10 +0,0 @@ -import angular from 'angular'; -import './sensitive-details.css'; - -angular.module('portainer.app').component('sensitiveDetails', { - templateUrl: './sensitive-details.html', - bindings: { - key: '@', - value: '@', - }, -}); diff --git a/app/portainer/components/show-hide/show-hide.html b/app/portainer/components/show-hide/show-hide.html deleted file mode 100644 index b961cb7a8..000000000 --- a/app/portainer/components/show-hide/show-hide.html +++ /dev/null @@ -1,15 +0,0 @@ -
- ******** - {{ $ctrl.value }} -
- - diff --git a/app/portainer/components/show-hide/show-hide.js b/app/portainer/components/show-hide/show-hide.js deleted file mode 100644 index 8c938ea1e..000000000 --- a/app/portainer/components/show-hide/show-hide.js +++ /dev/null @@ -1,9 +0,0 @@ -import angular from 'angular'; - -angular.module('portainer.app').component('showHide', { - templateUrl: './show-hide.html', - bindings: { - value: '<', - useAsterisk: '<', - }, -}); diff --git a/app/portainer/components/status-indicator/index.js b/app/portainer/components/status-indicator/index.js deleted file mode 100644 index 5b2cc7659..000000000 --- a/app/portainer/components/status-indicator/index.js +++ /dev/null @@ -1,12 +0,0 @@ -import angular from 'angular'; - -import './status-indicator.css'; - -export const statusIndicator = { - templateUrl: './status-indicator.html', - bindings: { - ok: '<', - }, -}; - -angular.module('portainer.app').component('statusIndicator', statusIndicator); diff --git a/app/portainer/components/status-indicator/status-indicator.html b/app/portainer/components/status-indicator/status-indicator.html deleted file mode 100644 index e459de370..000000000 --- a/app/portainer/components/status-indicator/status-indicator.html +++ /dev/null @@ -1 +0,0 @@ - diff --git a/app/react/kubernetes/applications/ListView/ApplicationsDatatable/ApplicationsDatatable.tsx b/app/react/kubernetes/applications/ListView/ApplicationsDatatable/ApplicationsDatatable.tsx new file mode 100644 index 000000000..5ca79a6cf --- /dev/null +++ b/app/react/kubernetes/applications/ListView/ApplicationsDatatable/ApplicationsDatatable.tsx @@ -0,0 +1,166 @@ +import { useEffect } from 'react'; +import { BoxIcon } from 'lucide-react'; + +import { useKubeStore } from '@/react/kubernetes/datatables/default-kube-datatable-store'; +import { DefaultDatatableSettings } from '@/react/kubernetes/datatables/DefaultDatatableSettings'; +import { SystemResourceDescription } from '@/react/kubernetes/datatables/SystemResourceDescription'; +import { CreateFromManifestButton } from '@/react/kubernetes/components/CreateFromManifestButton'; +import { useNamespacesQuery } from '@/react/kubernetes/namespaces/queries/useNamespacesQuery'; +import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; +import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment'; +import { useAuthorizations } from '@/react/hooks/useUser'; + +import { TableSettingsMenu } from '@@/datatables'; +import { useRepeater } from '@@/datatables/useRepeater'; +import { DeleteButton } from '@@/buttons/DeleteButton'; +import { AddButton } from '@@/buttons'; +import { ExpandableDatatable } from '@@/datatables/ExpandableDatatable'; + +import { NamespaceFilter } from '../ApplicationsStacksDatatable/NamespaceFilter'; +import { Namespace } from '../ApplicationsStacksDatatable/types'; + +import { Application, ConfigKind } from './types'; +import { useColumns } from './useColumns'; +import { getPublishedUrls } from './PublishedPorts'; +import { SubRow } from './SubRow'; +import { HelmInsightsBox } from './HelmInsightsBox'; + +export function ApplicationsDatatable({ + dataset, + onRefresh, + isLoading, + onRemove, + namespace = '', + namespaces, + onNamespaceChange, + showSystem, + onShowSystemChange, + hideStacks, +}: { + dataset: Array; + onRefresh: () => void; + isLoading: boolean; + onRemove: (selectedItems: Application[]) => void; + namespace?: string; + namespaces: Array; + onNamespaceChange(namespace: string): void; + showSystem?: boolean; + onShowSystemChange(showSystem: boolean): void; + hideStacks: boolean; +}) { + const envId = useEnvironmentId(); + const envQuery = useCurrentEnvironment(); + const namespaceMetaListQuery = useNamespacesQuery(envId); + + const tableState = useKubeStore('kubernetes.applications', 'Name'); + useRepeater(tableState.autoRefreshRate, onRefresh); + + const hasWriteAuthQuery = useAuthorizations( + 'K8sApplicationsW', + undefined, + true + ); + + const { setShowSystemResources } = tableState; + + useEffect(() => { + setShowSystemResources(showSystem || false); + }, [showSystem, setShowSystemResources]); + + const columns = useColumns(hideStacks); + + const filteredDataset = !showSystem + ? dataset.filter( + (item) => !namespaceMetaListQuery.data?.[item.ResourcePool]?.IsSystem + ) + : dataset; + + return ( + + !namespaceMetaListQuery.data?.[row.original.ResourcePool]?.IsSystem + } + getRowCanExpand={(row) => isExpandable(row.original)} + renderSubRow={(row) => ( + + )} + renderTableActions={(selectedItems) => + hasWriteAuthQuery.authorized && ( + <> + onRemove(selectedItems)} + /> + + + Add with form + + + + + ) + } + renderTableSettings={() => ( + + + + )} + description={ +
+
+ +
+ +
+ + +
+ +
+
+
+ } + /> + ); +} + +function isExpandable(item: Application) { + return ( + !!item.KubernetesApplications || + !!getPublishedUrls(item).length || + hasConfigurationSecrets(item) + ); +} + +function hasConfigurationSecrets(item: Application) { + return !!item.Configurations?.some( + (config) => config.Data && config.Kind === ConfigKind.Secret + ); +} diff --git a/app/react/kubernetes/applications/ListView/ApplicationsDatatable/ConfigurationDetails.tsx b/app/react/kubernetes/applications/ListView/ApplicationsDatatable/ConfigurationDetails.tsx new file mode 100644 index 000000000..fa0355467 --- /dev/null +++ b/app/react/kubernetes/applications/ListView/ApplicationsDatatable/ConfigurationDetails.tsx @@ -0,0 +1,56 @@ +import { useAuthorizations } from '@/react/hooks/useUser'; + +import { SensitiveDetails } from './SensitiveDetails'; +import { Application, ConfigKind } from './types'; + +export function ConfigurationDetails({ + item, + areSecretsRestricted, + username, +}: { + item: Application; + areSecretsRestricted: boolean; + username: string; +}) { + const isEnvironmentAdminQuery = useAuthorizations(['K8sResourcePoolsW']); + + const secrets = item.Configurations?.filter( + (config) => config.Data && config.Kind === ConfigKind.Secret + ); + + if (isEnvironmentAdminQuery.isLoading || !secrets || secrets.length === 0) { + return null; + } + + return ( + <> +
Secrets
+ + + + + + +
+ {secrets.map((secret) => + Object.entries(secret.Data || {}).map(([key, value]) => ( + + )) + )} +
+ + ); + + function canSeeValue(secret: { ConfigurationOwner: string }) { + return ( + !areSecretsRestricted || + isEnvironmentAdminQuery.authorized || + secret.ConfigurationOwner === username + ); + } +} diff --git a/app/react/kubernetes/applications/ListView/ApplicationsDatatable/InnerTable.tsx b/app/react/kubernetes/applications/ListView/ApplicationsDatatable/InnerTable.tsx new file mode 100644 index 000000000..03c5cb206 --- /dev/null +++ b/app/react/kubernetes/applications/ListView/ApplicationsDatatable/InnerTable.tsx @@ -0,0 +1,22 @@ +import { NestedDatatable } from '@@/datatables/NestedDatatable'; + +import { Application } from './types'; +import { useBaseColumns } from './useColumns'; + +export function InnerTable({ + dataset, + hideStacks, +}: { + dataset: Array; + hideStacks: boolean; +}) { + const columns = useBaseColumns(hideStacks); + + return ( + + ); +} diff --git a/app/react/kubernetes/applications/ListView/ApplicationsDatatable/PublishedPorts.tsx b/app/react/kubernetes/applications/ListView/ApplicationsDatatable/PublishedPorts.tsx new file mode 100644 index 000000000..190f94e98 --- /dev/null +++ b/app/react/kubernetes/applications/ListView/ApplicationsDatatable/PublishedPorts.tsx @@ -0,0 +1,70 @@ +import { ExternalLinkIcon } from 'lucide-react'; + +import { getSchemeFromPort } from '@/react/common/network-utils'; + +import { Icon } from '@@/Icon'; + +import { Application } from './types'; + +export function PublishedPorts({ item }: { item: Application }) { + const urls = getPublishedUrls(item); + + if (urls.length === 0) { + return null; + } + + return ( +
+
+
Published URL(s)
+
+
+ {urls.map((url) => ( + + ))} +
+
+ ); +} + +export function getPublishedUrls(item: Application) { + // Map all ingress rules in published ports to their respective URLs + const ingressUrls = + item.PublishedPorts?.flatMap((pp) => pp.IngressRules) + .filter(({ Host, IP }) => Host || IP) + .map(({ Host, IP, Path, TLS }) => { + const scheme = + TLS && + TLS.filter((tls) => tls.hosts && tls.hosts.includes(Host)).length > 0 + ? 'https' + : 'http'; + return `${scheme}://${Host || IP}${Path}`; + }) || []; + + // Map all load balancer service ports to ip address + const loadBalancerURLs = + (item.LoadBalancerIPAddress && + item.PublishedPorts?.map( + (pp) => + `${getSchemeFromPort(pp.Port)}://${item.LoadBalancerIPAddress}:${ + pp.Port + }` + )) || + []; + + // combine ingress urls + const publishedUrls = [...ingressUrls, ...loadBalancerURLs]; + + // Return the first URL - priority given to ingress urls, then services (load balancers) + return publishedUrls.length > 0 ? publishedUrls : []; +} diff --git a/app/portainer/components/sensitive-details/sensitive-details.css b/app/react/kubernetes/applications/ListView/ApplicationsDatatable/SensitiveDetails.module.css similarity index 100% rename from app/portainer/components/sensitive-details/sensitive-details.css rename to app/react/kubernetes/applications/ListView/ApplicationsDatatable/SensitiveDetails.module.css diff --git a/app/react/kubernetes/applications/ListView/ApplicationsDatatable/SensitiveDetails.tsx b/app/react/kubernetes/applications/ListView/ApplicationsDatatable/SensitiveDetails.tsx new file mode 100644 index 000000000..15f8b42c4 --- /dev/null +++ b/app/react/kubernetes/applications/ListView/ApplicationsDatatable/SensitiveDetails.tsx @@ -0,0 +1,61 @@ +import { useState } from 'react'; +import { EyeIcon, EyeOffIcon } from 'lucide-react'; + +import { RestrictedSecretBadge } from '@/react/kubernetes/configs/RestrictedSecretBadge'; + +import { Button, CopyButton } from '@@/buttons'; + +import styles from './SensitiveDetails.module.css'; + +export function SensitiveDetails({ + name, + value, + canSeeValue, +}: { + name: string; + value: string; + canSeeValue?: boolean; +}) { + return ( +
+
{name}
+ {canSeeValue ? ( + <> + + + + ) : ( + + )} +
+ ); +} + +function ShowHide({ + value, + useAsterisk, +}: { + value: string; + useAsterisk: boolean; +}) { + const [show, setShow] = useState(false); + + return ( +
+
+ {show ? value : useAsterisk && '********'} +
+ + +
+ ); +} diff --git a/app/react/kubernetes/applications/ListView/ApplicationsDatatable/SubRow.tsx b/app/react/kubernetes/applications/ListView/ApplicationsDatatable/SubRow.tsx new file mode 100644 index 000000000..8b1a9d2d3 --- /dev/null +++ b/app/react/kubernetes/applications/ListView/ApplicationsDatatable/SubRow.tsx @@ -0,0 +1,45 @@ +import clsx from 'clsx'; + +import { useCurrentUser } from '@/react/hooks/useUser'; + +import { ConfigurationDetails } from './ConfigurationDetails'; +import { InnerTable } from './InnerTable'; +import { PublishedPorts } from './PublishedPorts'; +import { Application } from './types'; + +export function SubRow({ + item, + hideStacks, + areSecretsRestricted, +}: { + item: Application; + hideStacks: boolean; + areSecretsRestricted: boolean; +}) { + const { + user: { Username: username }, + } = useCurrentUser(); + + return ( + + + + {item.KubernetesApplications ? ( + + ) : ( + <> + + + + )} + + + ); +} diff --git a/app/react/kubernetes/applications/ListView/ApplicationsDatatable/columns.helper.tsx b/app/react/kubernetes/applications/ListView/ApplicationsDatatable/columns.helper.tsx new file mode 100644 index 000000000..8ab8671b0 --- /dev/null +++ b/app/react/kubernetes/applications/ListView/ApplicationsDatatable/columns.helper.tsx @@ -0,0 +1,5 @@ +import { createColumnHelper } from '@tanstack/react-table'; + +import { Application } from './types'; + +export const helper = createColumnHelper(); diff --git a/app/react/kubernetes/applications/ListView/ApplicationsDatatable/columns.name.tsx b/app/react/kubernetes/applications/ListView/ApplicationsDatatable/columns.name.tsx new file mode 100644 index 000000000..00c1029e2 --- /dev/null +++ b/app/react/kubernetes/applications/ListView/ApplicationsDatatable/columns.name.tsx @@ -0,0 +1,47 @@ +import { CellContext } from '@tanstack/react-table'; + +import { useIsSystemNamespace } from '@/react/kubernetes/namespaces/queries/useIsSystemNamespace'; + +import { Link } from '@@/Link'; +import { SystemBadge } from '@@/Badge/SystemBadge'; +import { ExternalBadge } from '@@/Badge/ExternalBadge'; + +import { helper } from './columns.helper'; +import { Application } from './types'; + +export const name = helper.accessor('Name', { + header: 'Name', + cell: Cell, +}); + +function Cell({ row: { original: item } }: CellContext) { + const isSystem = useIsSystemNamespace(item.ResourcePool); + + return ( +
+ {item.KubernetesApplications ? ( + + {item.Name} + + ) : ( + + {item.Name} + + )} + + {isSystem ? : !item.ApplicationOwner && } +
+ ); +} diff --git a/app/portainer/components/status-indicator/status-indicator.css b/app/react/kubernetes/applications/ListView/ApplicationsDatatable/columns.status.module.css similarity index 90% rename from app/portainer/components/status-indicator/status-indicator.css rename to app/react/kubernetes/applications/ListView/ApplicationsDatatable/columns.status.module.css index 2b0345266..7dcd8595c 100644 --- a/app/portainer/components/status-indicator/status-indicator.css +++ b/app/react/kubernetes/applications/ListView/ApplicationsDatatable/columns.status.module.css @@ -8,6 +8,6 @@ display: inline-block; } -.ok { +.status-indicator.ok { background-color: var(--green-3); } diff --git a/app/react/kubernetes/applications/ListView/ApplicationsDatatable/columns.status.tsx b/app/react/kubernetes/applications/ListView/ApplicationsDatatable/columns.status.tsx new file mode 100644 index 000000000..a099b48de --- /dev/null +++ b/app/react/kubernetes/applications/ListView/ApplicationsDatatable/columns.status.tsx @@ -0,0 +1,62 @@ +import { CellContext } from '@tanstack/react-table'; +import clsx from 'clsx'; + +import { + KubernetesApplicationDeploymentTypes, + KubernetesApplicationTypes, +} from '@/kubernetes/models/application/models/appConstants'; + +import styles from './columns.status.module.css'; +import { helper } from './columns.helper'; +import { Application } from './types'; + +export const status = helper.accessor('Status', { + header: 'Status', + cell: Cell, + enableSorting: false, +}); + +function Cell({ row: { original: item } }: CellContext) { + if ( + item.ApplicationType === KubernetesApplicationTypes.Pod && + item.Pods && + item.Pods.length > 0 + ) { + return item.Pods[0].Status; + } + + return ( + <> + 0 && + item.TotalPodsCount === item.RunningPodsCount) || + item.Status === 'Ready', + }, + ])} + /> + {item.DeploymentType === + KubernetesApplicationDeploymentTypes.Replicated && ( + Replicated + )} + {item.DeploymentType === KubernetesApplicationDeploymentTypes.Global && ( + Global + )} + {item.RunningPodsCount >= 0 && item.TotalPodsCount >= 0 && ( + + + {item.RunningPodsCount} + {' '} + /{' '} + + {item.TotalPodsCount} + + + )} + {item.KubernetesApplications && {item.Status}} + + ); +} diff --git a/app/react/kubernetes/applications/ListView/ApplicationsDatatable/columns.tsx b/app/react/kubernetes/applications/ListView/ApplicationsDatatable/columns.tsx new file mode 100644 index 000000000..a52e1c7d1 --- /dev/null +++ b/app/react/kubernetes/applications/ListView/ApplicationsDatatable/columns.tsx @@ -0,0 +1,48 @@ +import { isoDate, truncate } from '@/portainer/filters/filters'; + +import { helper } from './columns.helper'; + +export const stackName = helper.accessor('StackName', { + header: 'Stack', + cell: ({ getValue }) => getValue() || '-', +}); + +export const namespace = helper.accessor('ResourcePool', { + header: 'Namespace', + cell: ({ getValue }) => getValue() || '-', +}); + +export const image = helper.accessor('Image', { + header: 'Image', + cell: ({ row: { original: item } }) => ( + <> + {truncate(item.Image, 64)} + {item.Containers && item.Containers?.length > 1 && ( + <>+ {item.Containers.length - 1} + )} + + ), +}); + +export const appType = helper.accessor('ApplicationType', { + header: 'Application Type', +}); + +export const published = helper.accessor('Services', { + header: 'Published', + cell: ({ row: { original: item } }) => + item.Services?.length === 0 ? 'No' : 'Yes', + enableSorting: false, +}); + +export const created = helper.accessor('CreationDate', { + header: 'Created', + cell({ row: { original: item } }) { + return ( + <> + {isoDate(item.CreationDate)}{' '} + {item.ApplicationOwner ? ` by ${item.ApplicationOwner}` : ''} + + ); + }, +}); diff --git a/app/react/kubernetes/applications/ListView/ApplicationsDatatable/types.ts b/app/react/kubernetes/applications/ListView/ApplicationsDatatable/types.ts new file mode 100644 index 000000000..00d52e009 --- /dev/null +++ b/app/react/kubernetes/applications/ListView/ApplicationsDatatable/types.ts @@ -0,0 +1,47 @@ +import { AppType, DeploymentType } from '../../types'; + +export interface Application { + Id: string; + Name: string; + Image: string; + Containers?: Array; + Services?: Array; + CreationDate: string; + ApplicationOwner?: string; + StackName?: string; + ResourcePool: string; + ApplicationType: AppType; + KubernetesApplications?: Array; + Metadata?: { + labels: Record; + }; + Status: 'Ready' | string; + TotalPodsCount: number; + RunningPodsCount: number; + DeploymentType: DeploymentType; + Pods?: Array<{ + Status: string; + }>; + Configurations?: Array<{ + Data?: object; + Kind: ConfigKind; + ConfigurationOwner: string; + }>; + LoadBalancerIPAddress?: string; + PublishedPorts?: Array<{ + IngressRules: Array<{ + Host: string; + IP: string; + Path: string; + TLS: Array<{ + hosts: Array; + }>; + }>; + Port: number; + }>; +} + +export enum ConfigKind { + ConfigMap = 1, + Secret, +} diff --git a/app/react/kubernetes/applications/ListView/ApplicationsDatatable/useColumns.tsx b/app/react/kubernetes/applications/ListView/ApplicationsDatatable/useColumns.tsx new file mode 100644 index 000000000..541479fb4 --- /dev/null +++ b/app/react/kubernetes/applications/ListView/ApplicationsDatatable/useColumns.tsx @@ -0,0 +1,34 @@ +import _ from 'lodash'; + +import { buildExpandColumn } from '@@/datatables/expand-column'; + +import { name } from './columns.name'; +import { status } from './columns.status'; +import { + appType, + created, + image, + namespace, + published, + stackName, +} from './columns'; +import { Application } from './types'; + +export function useColumns(hideStacks: boolean) { + const baseColumns = useBaseColumns(hideStacks); + + return _.compact([buildExpandColumn(), ...baseColumns]); +} + +export function useBaseColumns(hideStacks: boolean) { + return _.compact([ + name, + !hideStacks && stackName, + namespace, + image, + appType, + status, + published, + created, + ]); +} diff --git a/app/react/kubernetes/applications/ListView/ApplicationsStacksDatatable/ApplicationsStacksDatatable.tsx b/app/react/kubernetes/applications/ListView/ApplicationsStacksDatatable/ApplicationsStacksDatatable.tsx index 74f41b95e..51e354da7 100644 --- a/app/react/kubernetes/applications/ListView/ApplicationsStacksDatatable/ApplicationsStacksDatatable.tsx +++ b/app/react/kubernetes/applications/ListView/ApplicationsStacksDatatable/ApplicationsStacksDatatable.tsx @@ -47,10 +47,11 @@ export function ApplicationsStacksDatatable({ }: Props) { const tableState = useTableState(settingsStore, storageKey); + const { setShowSystemResources } = tableState; + useEffect(() => { - tableState.setShowSystemResources(showSystem || false); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [showSystem]); + setShowSystemResources(showSystem || false); + }, [showSystem, setShowSystemResources]); const { authorized } = useAuthorizations('K8sApplicationsW'); useRepeater(tableState.autoRefreshRate, onRefresh); diff --git a/app/react/kubernetes/configs/RestrictedSecretBadge.tsx b/app/react/kubernetes/configs/RestrictedSecretBadge.tsx new file mode 100644 index 000000000..3b33fc609 --- /dev/null +++ b/app/react/kubernetes/configs/RestrictedSecretBadge.tsx @@ -0,0 +1,12 @@ +import { Badge } from '@@/Badge'; +import { TooltipWithChildren } from '@@/Tip/TooltipWithChildren'; + +export function RestrictedSecretBadge() { + return ( + +
+ Restricted +
+
+ ); +} diff --git a/app/react/kubernetes/datatables/DefaultDatatableSettings.tsx b/app/react/kubernetes/datatables/DefaultDatatableSettings.tsx index 9ccf503d4..b2fe5f795 100644 --- a/app/react/kubernetes/datatables/DefaultDatatableSettings.tsx +++ b/app/react/kubernetes/datatables/DefaultDatatableSettings.tsx @@ -16,14 +16,19 @@ export interface TableSettings export function DefaultDatatableSettings({ settings, + onShowSystemChange, }: { settings: TableSettings; + onShowSystemChange?(showSystem: boolean): void; }) { return ( <> settings.setShowSystemResources(value)} + onChange={(value) => { + settings.setShowSystemResources(value); + onShowSystemChange?.(value); + }} /> ( storageKey: string, - initialSortBy: string | { id: string; desc: boolean } = 'name' + initialSortBy?: string | { id: string; desc: boolean }, + create: (set: ZustandSetFunc) => Omit = () => + ({}) as T ) { - return createPersistedStore( + return createPersistedStore( storageKey, initialSortBy, - (set) => ({ - ...refreshableSettings(set), - ...systemResourcesSettings(set), - }) + (set) => + ({ + ...refreshableSettings(set), + ...systemResourcesSettings(set), + ...create(set), + }) as T ); } + +export function useKubeStore( + ...args: Parameters> +) { + const [store] = useState(() => createStore(...args)); + return useTableState(store, args[0]); +} diff --git a/app/react/portainer/environments/types.ts b/app/react/portainer/environments/types.ts index 5e6b5272f..88af0b511 100644 --- a/app/react/portainer/environments/types.ts +++ b/app/react/portainer/environments/types.ts @@ -60,6 +60,7 @@ export interface KubernetesConfiguration { EnableResourceOverCommit?: boolean; ResourceOverCommitPercentage?: number; RestrictDefaultNamespace?: boolean; + RestrictSecrets?: boolean; RestrictStandardUserIngressW?: boolean; IngressClasses: IngressClass[]; IngressAvailabilityPerNamespace: boolean;