mirror of https://github.com/portainer/portainer
feat(kubernetes): list all kube services screen [EE-1571] (#8524)
* port services from ee * fix external link * post review improvements * remove applications-ports-datatable * minor post review updates * add services help url * post review update * more post review updates * post review updates * rename index to component * fix external ip display and sorting * fix external apps tag * fix ingress screen time format * use uid for row id. Prevent blank link * fix some missing bits ported from EE * match ee * fix display of show system resources * remove icon next to service typepull/8591/head
parent
8d6797dc9f
commit
ac47649631
|
@ -37,7 +37,15 @@ func (handler *Handler) getKubernetesServices(w http.ResponseWriter, r *http.Req
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
services, err := cli.GetServices(namespace)
|
lookup, err := request.RetrieveBooleanQueryParameter(r, "lookupapplications", true)
|
||||||
|
if err != nil {
|
||||||
|
return httperror.BadRequest(
|
||||||
|
"Invalid lookupapplications query parameter",
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
services, err := cli.GetServices(namespace, lookup)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return httperror.InternalServerError(
|
return httperror.InternalServerError(
|
||||||
"Unable to retrieve services",
|
"Unable to retrieve services",
|
||||||
|
|
|
@ -0,0 +1,11 @@
|
||||||
|
package kubernetes
|
||||||
|
|
||||||
|
type (
|
||||||
|
K8sApplication struct {
|
||||||
|
UID string `json:",omitempty"`
|
||||||
|
Name string `json:""`
|
||||||
|
Namespace string `json:",omitempty"`
|
||||||
|
Kind string `json:",omitempty"`
|
||||||
|
Labels map[string]string `json:",omitempty"`
|
||||||
|
}
|
||||||
|
)
|
|
@ -3,6 +3,7 @@ package kubernetes
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type (
|
type (
|
||||||
|
@ -18,15 +19,17 @@ type (
|
||||||
K8sIngressControllers []K8sIngressController
|
K8sIngressControllers []K8sIngressController
|
||||||
|
|
||||||
K8sIngressInfo struct {
|
K8sIngressInfo struct {
|
||||||
Name string `json:"Name"`
|
Name string `json:"Name"`
|
||||||
UID string `json:"UID"`
|
UID string `json:"UID"`
|
||||||
Type string `json:"Type"`
|
Type string `json:"Type"`
|
||||||
Namespace string `json:"Namespace"`
|
Namespace string `json:"Namespace"`
|
||||||
ClassName string `json:"ClassName"`
|
ClassName string `json:"ClassName"`
|
||||||
Annotations map[string]string `json:"Annotations"`
|
Annotations map[string]string `json:"Annotations"`
|
||||||
Hosts []string `json:"Hosts"`
|
Hosts []string `json:"Hosts"`
|
||||||
Paths []K8sIngressPath `json:"Paths"`
|
Paths []K8sIngressPath `json:"Paths"`
|
||||||
TLS []K8sIngressTLS `json:"TLS"`
|
TLS []K8sIngressTLS `json:"TLS"`
|
||||||
|
Labels map[string]string `json:"Labels,omitempty"`
|
||||||
|
CreationDate time.Time `json:"CreationDate"`
|
||||||
}
|
}
|
||||||
|
|
||||||
K8sIngressTLS struct {
|
K8sIngressTLS struct {
|
||||||
|
|
|
@ -7,17 +7,23 @@ import (
|
||||||
|
|
||||||
type (
|
type (
|
||||||
K8sServiceInfo struct {
|
K8sServiceInfo struct {
|
||||||
Name string `json:"Name"`
|
Name string
|
||||||
UID string `json:"UID"`
|
UID string
|
||||||
Type string `json:"Type"`
|
Type string
|
||||||
Namespace string `json:"Namespace"`
|
Namespace string
|
||||||
Annotations map[string]string `json:"Annotations"`
|
Annotations map[string]string
|
||||||
CreationTimestamp string `json:"CreationTimestamp"`
|
CreationTimestamp string
|
||||||
Labels map[string]string `json:"Labels"`
|
Labels map[string]string
|
||||||
AllocateLoadBalancerNodePorts *bool `json:"AllocateLoadBalancerNodePorts,omitempty"`
|
AllocateLoadBalancerNodePorts *bool `json:",omitempty"`
|
||||||
Ports []K8sServicePort `json:"Ports"`
|
Ports []K8sServicePort
|
||||||
Selector map[string]string `json:"Selector"`
|
Selector map[string]string
|
||||||
IngressStatus []K8sServiceIngress `json:"IngressStatus"`
|
IngressStatus []K8sServiceIngress `json:",omitempty"`
|
||||||
|
|
||||||
|
// serviceList screen
|
||||||
|
Applications []K8sApplication `json:",omitempty"`
|
||||||
|
ClusterIPs []string `json:",omitempty"`
|
||||||
|
ExternalName string `json:",omitempty"`
|
||||||
|
ExternalIPs []string `json:",omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
K8sServicePort struct {
|
K8sServicePort struct {
|
||||||
|
@ -25,7 +31,7 @@ type (
|
||||||
NodePort int `json:"NodePort"`
|
NodePort int `json:"NodePort"`
|
||||||
Port int `json:"Port"`
|
Port int `json:"Port"`
|
||||||
Protocol string `json:"Protocol"`
|
Protocol string `json:"Protocol"`
|
||||||
TargetPort int `json:"TargetPort"`
|
TargetPort string `json:"TargetPort"`
|
||||||
}
|
}
|
||||||
|
|
||||||
K8sServiceIngress struct {
|
K8sServiceIngress struct {
|
||||||
|
|
|
@ -102,6 +102,8 @@ func (kcl *KubeClient) GetIngresses(namespace string) ([]models.K8sIngressInfo,
|
||||||
}
|
}
|
||||||
info.Type = classes[info.ClassName]
|
info.Type = classes[info.ClassName]
|
||||||
info.Annotations = ingress.Annotations
|
info.Annotations = ingress.Annotations
|
||||||
|
info.Labels = ingress.Labels
|
||||||
|
info.CreationDate = ingress.CreationTimestamp.Time
|
||||||
|
|
||||||
// Gather TLS information.
|
// Gather TLS information.
|
||||||
for _, v := range ingress.Spec.TLS {
|
for _, v := range ingress.Spec.TLS {
|
||||||
|
|
|
@ -6,11 +6,12 @@ import (
|
||||||
models "github.com/portainer/portainer/api/http/models/kubernetes"
|
models "github.com/portainer/portainer/api/http/models/kubernetes"
|
||||||
v1 "k8s.io/api/core/v1"
|
v1 "k8s.io/api/core/v1"
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
labels "k8s.io/apimachinery/pkg/labels"
|
||||||
"k8s.io/apimachinery/pkg/util/intstr"
|
"k8s.io/apimachinery/pkg/util/intstr"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetServices gets all the services for a given namespace in a k8s endpoint.
|
// GetServices gets all the services for a given namespace in a k8s endpoint.
|
||||||
func (kcl *KubeClient) GetServices(namespace string) ([]models.K8sServiceInfo, error) {
|
func (kcl *KubeClient) GetServices(namespace string, lookupApplications bool) ([]models.K8sServiceInfo, error) {
|
||||||
client := kcl.cli.CoreV1().Services(namespace)
|
client := kcl.cli.CoreV1().Services(namespace)
|
||||||
|
|
||||||
services, err := client.List(context.Background(), metav1.ListOptions{})
|
services, err := client.List(context.Background(), metav1.ListOptions{})
|
||||||
|
@ -28,7 +29,7 @@ func (kcl *KubeClient) GetServices(namespace string) ([]models.K8sServiceInfo, e
|
||||||
NodePort: int(port.NodePort),
|
NodePort: int(port.NodePort),
|
||||||
Port: int(port.Port),
|
Port: int(port.Port),
|
||||||
Protocol: string(port.Protocol),
|
Protocol: string(port.Protocol),
|
||||||
TargetPort: port.TargetPort.IntValue(),
|
TargetPort: port.TargetPort.String(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -40,6 +41,11 @@ func (kcl *KubeClient) GetServices(namespace string) ([]models.K8sServiceInfo, e
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var applications []models.K8sApplication
|
||||||
|
if lookupApplications {
|
||||||
|
applications, _ = kcl.getOwningApplication(namespace, service.Spec.Selector)
|
||||||
|
}
|
||||||
|
|
||||||
result = append(result, models.K8sServiceInfo{
|
result = append(result, models.K8sServiceInfo{
|
||||||
Name: service.Name,
|
Name: service.Name,
|
||||||
UID: string(service.GetUID()),
|
UID: string(service.GetUID()),
|
||||||
|
@ -51,6 +57,10 @@ func (kcl *KubeClient) GetServices(namespace string) ([]models.K8sServiceInfo, e
|
||||||
IngressStatus: ingressStatus,
|
IngressStatus: ingressStatus,
|
||||||
Labels: service.GetLabels(),
|
Labels: service.GetLabels(),
|
||||||
Annotations: service.GetAnnotations(),
|
Annotations: service.GetAnnotations(),
|
||||||
|
ClusterIPs: service.Spec.ClusterIPs,
|
||||||
|
ExternalName: service.Spec.ExternalName,
|
||||||
|
ExternalIPs: service.Spec.ExternalIPs,
|
||||||
|
Applications: applications,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -77,7 +87,7 @@ func (kcl *KubeClient) CreateService(namespace string, info models.K8sServiceInf
|
||||||
port.NodePort = int32(p.NodePort)
|
port.NodePort = int32(p.NodePort)
|
||||||
port.Port = int32(p.Port)
|
port.Port = int32(p.Port)
|
||||||
port.Protocol = v1.Protocol(p.Protocol)
|
port.Protocol = v1.Protocol(p.Protocol)
|
||||||
port.TargetPort = intstr.FromInt(p.TargetPort)
|
port.TargetPort = intstr.FromString(p.TargetPort)
|
||||||
service.Spec.Ports = append(service.Spec.Ports, port)
|
service.Spec.Ports = append(service.Spec.Ports, port)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -133,7 +143,7 @@ func (kcl *KubeClient) UpdateService(namespace string, info models.K8sServiceInf
|
||||||
port.NodePort = int32(p.NodePort)
|
port.NodePort = int32(p.NodePort)
|
||||||
port.Port = int32(p.Port)
|
port.Port = int32(p.Port)
|
||||||
port.Protocol = v1.Protocol(p.Protocol)
|
port.Protocol = v1.Protocol(p.Protocol)
|
||||||
port.TargetPort = intstr.FromInt(p.TargetPort)
|
port.TargetPort = intstr.FromString(p.TargetPort)
|
||||||
service.Spec.Ports = append(service.Spec.Ports, port)
|
service.Spec.Ports = append(service.Spec.Ports, port)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -151,3 +161,54 @@ func (kcl *KubeClient) UpdateService(namespace string, info models.K8sServiceInf
|
||||||
_, err := ServiceClient.Update(context.Background(), &service, metav1.UpdateOptions{})
|
_, err := ServiceClient.Update(context.Background(), &service, metav1.UpdateOptions{})
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getOwningApplication gets the application that owns the given service selector.
|
||||||
|
func (kcl *KubeClient) getOwningApplication(namespace string, selector map[string]string) ([]models.K8sApplication, error) {
|
||||||
|
if len(selector) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
selectorLabels := labels.SelectorFromSet(selector).String()
|
||||||
|
|
||||||
|
// look for replicasets first, limit 1 (we only support one owner)
|
||||||
|
replicasets, err := kcl.cli.AppsV1().ReplicaSets(namespace).List(context.TODO(), metav1.ListOptions{LabelSelector: selectorLabels, Limit: 1})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var meta metav1.Object
|
||||||
|
if replicasets != nil && len(replicasets.Items) > 0 {
|
||||||
|
meta = replicasets.Items[0].GetObjectMeta()
|
||||||
|
} else {
|
||||||
|
// otherwise look for matching pods, limit 1 (we only support one owner)
|
||||||
|
pods, err := kcl.cli.CoreV1().Pods(namespace).List(context.TODO(), metav1.ListOptions{LabelSelector: selectorLabels, Limit: 1})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if pods == nil || len(pods.Items) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
meta = pods.Items[0].GetObjectMeta()
|
||||||
|
}
|
||||||
|
|
||||||
|
return makeApplication(meta), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeApplication(meta metav1.Object) []models.K8sApplication {
|
||||||
|
ownerReferences := meta.GetOwnerReferences()
|
||||||
|
if len(ownerReferences) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Currently, we only support one owner reference
|
||||||
|
ownerReference := ownerReferences[0]
|
||||||
|
return []models.K8sApplication{
|
||||||
|
{
|
||||||
|
// Only the name is used right now, but we can add more fields in the future
|
||||||
|
Name: ownerReference.Name,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
|
@ -1429,7 +1429,7 @@ type (
|
||||||
DeleteIngresses(reqs models.K8sIngressDeleteRequests) error
|
DeleteIngresses(reqs models.K8sIngressDeleteRequests) error
|
||||||
CreateService(namespace string, service models.K8sServiceInfo) error
|
CreateService(namespace string, service models.K8sServiceInfo) error
|
||||||
UpdateService(namespace string, service models.K8sServiceInfo) error
|
UpdateService(namespace string, service models.K8sServiceInfo) error
|
||||||
GetServices(namespace string) ([]models.K8sServiceInfo, error)
|
GetServices(namespace string, lookupApplications bool) ([]models.K8sServiceInfo, error)
|
||||||
DeleteServices(reqs models.K8sServiceDeleteRequests) error
|
DeleteServices(reqs models.K8sServiceDeleteRequests) error
|
||||||
GetNodesLimits() (K8sNodesLimits, error)
|
GetNodesLimits() (K8sNodesLimits, error)
|
||||||
GetNamespaceAccessPolicies() (map[string]K8sNamespaceAccessPolicy, error)
|
GetNamespaceAccessPolicies() (map[string]K8sNamespaceAccessPolicy, error)
|
||||||
|
|
|
@ -66,6 +66,16 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const services = {
|
||||||
|
name: 'kubernetes.services',
|
||||||
|
url: '/services',
|
||||||
|
views: {
|
||||||
|
'content@': {
|
||||||
|
component: 'kubernetesServicesView',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const ingresses = {
|
const ingresses = {
|
||||||
name: 'kubernetes.ingresses',
|
name: 'kubernetes.ingresses',
|
||||||
url: '/ingresses',
|
url: '/ingresses',
|
||||||
|
@ -406,6 +416,7 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
|
||||||
$stateRegistryProvider.register(endpointKubernetesConfiguration);
|
$stateRegistryProvider.register(endpointKubernetesConfiguration);
|
||||||
$stateRegistryProvider.register(endpointKubernetesSecurityConstraint);
|
$stateRegistryProvider.register(endpointKubernetesSecurityConstraint);
|
||||||
|
|
||||||
|
$stateRegistryProvider.register(services);
|
||||||
$stateRegistryProvider.register(ingresses);
|
$stateRegistryProvider.register(ingresses);
|
||||||
$stateRegistryProvider.register(ingressesCreate);
|
$stateRegistryProvider.register(ingressesCreate);
|
||||||
$stateRegistryProvider.register(ingressesEdit);
|
$stateRegistryProvider.register(ingressesEdit);
|
||||||
|
|
|
@ -1,282 +0,0 @@
|
||||||
<div class="datatable">
|
|
||||||
<!-- table title and action menu -->
|
|
||||||
<div class="toolBar !flex-col gap-1">
|
|
||||||
<div class="toolBar vertical-center w-full flex-wrap !gap-x-5 !gap-y-1 !p-0">
|
|
||||||
<!-- title -->
|
|
||||||
<div class="toolBarTitle vertical-center">
|
|
||||||
<div class="widget-icon space-right">
|
|
||||||
<pr-icon icon="'svg-dataflow'" class-name="'[&>*]:mr-0.5'"></pr-icon>
|
|
||||||
</div>
|
|
||||||
Port mappings
|
|
||||||
</div>
|
|
||||||
<div class="searchBar vertical-center !mr-0 min-w-[300px]">
|
|
||||||
<pr-icon icon="'search'" class-name="'icon !h-3'"></pr-icon>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="searchInput"
|
|
||||||
ng-model="$ctrl.state.textFilter"
|
|
||||||
ng-change="$ctrl.onTextFilterChange()"
|
|
||||||
placeholder="Search for a port mapping..."
|
|
||||||
auto-focus
|
|
||||||
ng-model-options="{ debounce: 300 }"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<!-- actions -->
|
|
||||||
<div data-cy="k8sApp-portTableSettings" class="settings">
|
|
||||||
<span class="setting" ng-class="{ 'setting-active': $ctrl.settings.open }" uib-dropdown dropdown-append-to-body auto-close="disabled" is-open="$ctrl.settings.open">
|
|
||||||
<span uib-dropdown-toggle aria-label="Settings">
|
|
||||||
<pr-icon icon="'more-vertical'" class-name="'icon !mr-0 !h-4'"></pr-icon>
|
|
||||||
</span>
|
|
||||||
<div class="dropdown-menu dropdown-menu-right" uib-dropdown-menu>
|
|
||||||
<div class="tableMenu">
|
|
||||||
<div class="menuHeader"> Table settings </div>
|
|
||||||
<div class="menuContent">
|
|
||||||
<div>
|
|
||||||
<div class="md-checkbox" ng-if="$ctrl.isAdmin">
|
|
||||||
<input id="applications_setting_show_system" type="checkbox" ng-model="$ctrl.settings.showSystem" ng-change="$ctrl.onSettingsShowSystemChange()" />
|
|
||||||
<label for="applications_setting_show_system">Show system resources</label>
|
|
||||||
</div>
|
|
||||||
<div class="md-checkbox">
|
|
||||||
<input
|
|
||||||
id="setting_auto_refresh"
|
|
||||||
type="checkbox"
|
|
||||||
ng-model="$ctrl.settings.repeater.autoRefresh"
|
|
||||||
ng-change="$ctrl.onSettingsRepeaterChange()"
|
|
||||||
data-cy="k8sApp-autoRefreshCheckbox-port"
|
|
||||||
/>
|
|
||||||
<label for="setting_auto_refresh">Auto refresh</label>
|
|
||||||
</div>
|
|
||||||
<div ng-if="$ctrl.settings.repeater.autoRefresh">
|
|
||||||
<label for="settings_refresh_rate"> Refresh rate </label>
|
|
||||||
<select
|
|
||||||
id="settings_refresh_rate"
|
|
||||||
ng-model="$ctrl.settings.repeater.refreshRate"
|
|
||||||
ng-change="$ctrl.onSettingsRepeaterChange()"
|
|
||||||
class="small-select"
|
|
||||||
data-cy="k8sApp-refreshRateDropdown-port"
|
|
||||||
>
|
|
||||||
<option value="10">10s</option>
|
|
||||||
<option value="30">30s</option>
|
|
||||||
<option value="60">1min</option>
|
|
||||||
<option value="120">2min</option>
|
|
||||||
<option value="300">5min</option>
|
|
||||||
</select>
|
|
||||||
<span>
|
|
||||||
<pr-icon id="refreshRateChange" icon="'check'" mode="'success'" style="display: none"></pr-icon>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<a type="button" class="btn btn-sm btn-default btn-sm" ng-click="$ctrl.settings.open = false;" data-cy="k8sApp-tableSettingsCloseButton-port">Close</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- info text -->
|
|
||||||
<div class="flex w-full flex-row">
|
|
||||||
<span class="small text-muted vertical-center mt-1" ng-if="$ctrl.isAdmin && !$ctrl.settings.showSystem">
|
|
||||||
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
|
|
||||||
System resources are hidden, this can be changed in the table settings.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- table -->
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table-hover nowrap-cells table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>
|
|
||||||
<div class="vertical-center cursor-pointer" ng-click="$ctrl.expandAll()" ng-if="$ctrl.hasExpandableItems()" class="no-wrap flex min-w-max">
|
|
||||||
<pr-icon ng-if="$ctrl.state.expandAll" icon="'chevron-down'" class-name="'icon'"></pr-icon>
|
|
||||||
<pr-icon ng-if="!$ctrl.state.expandAll" icon="'chevron-right'" class-name="'icon'"></pr-icon>
|
|
||||||
</div>
|
|
||||||
</th>
|
|
||||||
<th>
|
|
||||||
<table-column-header
|
|
||||||
col-title="'Application'"
|
|
||||||
can-sort="true"
|
|
||||||
is-sorted="$ctrl.state.orderBy === 'Name'"
|
|
||||||
is-sorted-desc="$ctrl.state.orderBy === 'Name' && $ctrl.state.reverseOrder"
|
|
||||||
ng-click="$ctrl.changeOrderBy('Name')"
|
|
||||||
></table-column-header>
|
|
||||||
</th>
|
|
||||||
<th>
|
|
||||||
<table-column-header col-title="'Publishing mode'" can-sort="false"></table-column-header>
|
|
||||||
</th>
|
|
||||||
<th>
|
|
||||||
<table-column-header
|
|
||||||
col-title="'Exposed port'"
|
|
||||||
can-sort="true"
|
|
||||||
is-sorted="$ctrl.state.orderBy === 'TargetPort'"
|
|
||||||
is-sorted-desc="$ctrl.state.orderBy === 'TargetPort' && $ctrl.state.reverseOrder"
|
|
||||||
ng-click="$ctrl.changeOrderBy('TargetPort')"
|
|
||||||
></table-column-header>
|
|
||||||
</th>
|
|
||||||
<th>
|
|
||||||
<table-column-header
|
|
||||||
col-title="'Container port'"
|
|
||||||
can-sort="true"
|
|
||||||
is-sorted="$ctrl.state.orderBy === 'Port'"
|
|
||||||
is-sorted-desc="$ctrl.state.orderBy === 'Port' && $ctrl.state.reverseOrder"
|
|
||||||
ng-click="$ctrl.changeOrderBy('Port')"
|
|
||||||
></table-column-header>
|
|
||||||
</th>
|
|
||||||
<th>
|
|
||||||
<table-column-header col-title="'HTTP route'" can-sort="false"></table-column-header>
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<!-- main rows -->
|
|
||||||
<!-- dir-paginate-start track by $index -->
|
|
||||||
<tr
|
|
||||||
dir-paginate-start="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter:$ctrl.state.textFilter | filter: $ctrl.isDisplayed | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit: $ctrl.tableKey))"
|
|
||||||
ng-class="{ active: item.Checked, 'datatable-highlighted': item.Highlighted }"
|
|
||||||
ng-click="$ctrl.expandItem(item, !item.Expanded)"
|
|
||||||
pagination-id="$ctrl.tableKey"
|
|
||||||
>
|
|
||||||
<!-- expandable -->
|
|
||||||
<td>
|
|
||||||
<div ng-if="$ctrl.itemCanExpand(item)">
|
|
||||||
<pr-icon ng-if="item.Expanded" icon="'chevron-down'" class-name="'icon'"></pr-icon>
|
|
||||||
<pr-icon ng-if="!item.Expanded" icon="'chevron-right'" class-name="'icon'"></pr-icon>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<!-- Application -->
|
|
||||||
<td>
|
|
||||||
<a ui-sref="kubernetes.applications.application({ name: item.Name, namespace: item.ResourcePool })">{{ item.Name }}</a>
|
|
||||||
<span class="label label-info image-tag label-margins" ng-if="$ctrl.isSystemNamespace(item)">system</span>
|
|
||||||
<span class="label label-primary image-tag label-margins" ng-if="!$ctrl.isSystemNamespace(item) && $ctrl.isExternalApplication(item)">external</span>
|
|
||||||
</td>
|
|
||||||
<!-- Publishing mode -->
|
|
||||||
<td>
|
|
||||||
<!-- LB -->
|
|
||||||
<span ng-if="item.ServiceType === $ctrl.KubernetesServiceTypes.LOAD_BALANCER">
|
|
||||||
<span><pr-icon icon="'share-2'" class-name="'icon'"></pr-icon> LoadBalancer </span>
|
|
||||||
<span class="text-muted small ml-5">
|
|
||||||
<span ng-if="item.LoadBalancerIPAddress">{{ item.LoadBalancerIPAddress }}</span>
|
|
||||||
<span ng-if="!item.LoadBalancerIPAddress">pending</span>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<!-- Internal -->
|
|
||||||
<span ng-if="item.ServiceType === $ctrl.KubernetesServiceTypes.CLUSTER_IP"> <pr-icon icon="'list'"></pr-icon> ClusterIP </span>
|
|
||||||
<!-- Cluster -->
|
|
||||||
<span ng-if="item.ServiceType === $ctrl.KubernetesServiceTypes.NODE_PORT"> <pr-icon icon="'list'" class-name="'icon'"></pr-icon> NodePort </span>
|
|
||||||
</td>
|
|
||||||
<!-- Exposed port -->
|
|
||||||
<td>
|
|
||||||
<span ng-if="!$ctrl.itemCanExpand(item)">
|
|
||||||
{{ item.Ports[0].Port }}
|
|
||||||
<a class="vertical-center" ng-if="item.LoadBalancerIPAddress" ng-href="http://{{ item.LoadBalancerIPAddress }}:{{ item.Ports[0].Port }}" target="_blank" class="ml-1">
|
|
||||||
<pr-icon icon="'external-link'" class-name="'icon'"></pr-icon> access
|
|
||||||
</a>
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<!-- Container port -->
|
|
||||||
<td>
|
|
||||||
<span ng-if="!$ctrl.itemCanExpand(item)"> {{ item.Ports[0].TargetPort }}/{{ item.Ports[0].Protocol }} </span>
|
|
||||||
</td>
|
|
||||||
<!-- HTTP route -->
|
|
||||||
<td>
|
|
||||||
<span ng-if="!$ctrl.itemCanExpand(item)">
|
|
||||||
<span ng-if="!$ctrl.portHasIngressRules(item.Ports[0])">-</span>
|
|
||||||
<span ng-if="$ctrl.portHasIngressRules(item.Ports[0])">
|
|
||||||
<span
|
|
||||||
ng-if="!$ctrl.ruleCanBeDisplayed(item.Ports[0].IngressRules[0])"
|
|
||||||
class="text-muted cursor-pointer"
|
|
||||||
tooltip-append-to-body="true"
|
|
||||||
tooltip-placement="bottom"
|
|
||||||
tooltip-class="portainer-tooltip"
|
|
||||||
uib-tooltip="Ingress controller IP address not available yet"
|
|
||||||
>pending
|
|
||||||
</span>
|
|
||||||
<span ng-if="$ctrl.ruleCanBeDisplayed(item.Ports[0].IngressRules[0])">
|
|
||||||
<a ng-href="{{ $ctrl.buildIngressRuleURL(item.Ports[0].IngressRules[0]) }}" target="_blank">
|
|
||||||
{{ $ctrl.buildIngressRuleURL(item.Ports[0].IngressRules[0]) | stripprotocol }}
|
|
||||||
</a>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<!-- sub rows -->
|
|
||||||
<tr ng-show="item.Expanded" ng-repeat-start="port in item.Ports" ng-class="{ 'datatable-highlighted': item.Highlighted, 'datatable-unhighlighted': !item.Highlighted }">
|
|
||||||
<td ng-if="!$ctrl.portHasIngressRules(port)"></td>
|
|
||||||
<td ng-if="!$ctrl.portHasIngressRules(port)">-</td>
|
|
||||||
<td ng-if="!$ctrl.portHasIngressRules(port)">-</td>
|
|
||||||
<td ng-if="!$ctrl.portHasIngressRules(port)">
|
|
||||||
{{ port.Port }}
|
|
||||||
<a ng-if="item.LoadBalancerIPAddress" ng-href="http://{{ item.LoadBalancerIPAddress }}:{{ port.Port }}" target="_blank" class="ml-1">
|
|
||||||
<pr-icon icon="'external-link'"></pr-icon> access
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
<td ng-if="!$ctrl.portHasIngressRules(port)">{{ port.TargetPort }}/{{ port.Protocol }}</td>
|
|
||||||
<td ng-if="!$ctrl.portHasIngressRules(port)">-</td>
|
|
||||||
</tr>
|
|
||||||
<tr
|
|
||||||
ng-show="item.Expanded"
|
|
||||||
ng-repeat-end
|
|
||||||
ng-repeat="rule in port.IngressRules"
|
|
||||||
ng-class="{ 'datatable-highlighted': item.Highlighted, 'datatable-unhighlighted': !item.Highlighted }"
|
|
||||||
>
|
|
||||||
<td></td>
|
|
||||||
<td>-</td>
|
|
||||||
<td>-</td>
|
|
||||||
<td>
|
|
||||||
{{ port.Port }}
|
|
||||||
<a ng-if="item.LoadBalancerIPAddress" ng-href="http://{{ item.LoadBalancerIPAddress }}:{{ port.Port }}" target="_blank" class="ml-1">
|
|
||||||
<pr-icon icon="'external-link'"></pr-icon>access
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
<td>{{ port.TargetPort }}/{{ port.Protocol }}</td>
|
|
||||||
<td>
|
|
||||||
<span
|
|
||||||
ng-if="!$ctrl.ruleCanBeDisplayed(rule)"
|
|
||||||
class="text-muted cursor-pointer"
|
|
||||||
tooltip-append-to-body="true"
|
|
||||||
tooltip-placement="bottom"
|
|
||||||
tooltip-class="portainer-tooltip"
|
|
||||||
uib-tooltip="Ingress controller IP address not available yet"
|
|
||||||
>pending
|
|
||||||
</span>
|
|
||||||
<span ng-if="$ctrl.ruleCanBeDisplayed(rule)">
|
|
||||||
<a ng-href="{{ $ctrl.buildIngressRuleURL(rule) }}" target="_blank">
|
|
||||||
{{ $ctrl.buildIngressRuleURL(rule) | stripprotocol }}
|
|
||||||
</a>
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr class="!h-0" dir-paginate-end></tr>
|
|
||||||
<!-- no dataset -->
|
|
||||||
<tr ng-if="!$ctrl.dataset">
|
|
||||||
<td colspan="6" class="text-muted text-center">Loading...</td>
|
|
||||||
</tr>
|
|
||||||
<!-- no values in filtered dataset -->
|
|
||||||
<tr ng-if="$ctrl.state.filteredDataSet.length === 0">
|
|
||||||
<td colspan="6" class="text-muted text-center">No application port mapping available.</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div class="footer pl-5" ng-if="$ctrl.dataset">
|
|
||||||
<div class="infoBar !ml-0" ng-if="$ctrl.state.selectedItemCount !== 0"> {{ $ctrl.state.selectedItemCount }} item(s) selected </div>
|
|
||||||
<div class="paginationControls">
|
|
||||||
<form class="form-inline">
|
|
||||||
<span class="limitSelector">
|
|
||||||
<span class="mr-1"> Items per page </span>
|
|
||||||
<select class="form-control" ng-model="$ctrl.state.paginatedItemLimit" ng-change="$ctrl.changePaginationLimit()" data-cy="component-paginationSelect">
|
|
||||||
<option value="0">All</option>
|
|
||||||
<option value="10">10</option>
|
|
||||||
<option value="25">25</option>
|
|
||||||
<option value="50">50</option>
|
|
||||||
<option value="100">100</option>
|
|
||||||
</select>
|
|
||||||
</span>
|
|
||||||
<dir-pagination-controls max-size="5" pagination-id="$ctrl.tableKey"></dir-pagination-controls>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
|
@ -1,13 +0,0 @@
|
||||||
angular.module('portainer.kubernetes').component('kubernetesApplicationsPortsDatatable', {
|
|
||||||
templateUrl: './applicationsPortsDatatable.html',
|
|
||||||
controller: 'KubernetesApplicationsPortsDatatableController',
|
|
||||||
bindings: {
|
|
||||||
titleText: '@',
|
|
||||||
titleIcon: '@',
|
|
||||||
dataset: '<',
|
|
||||||
tableKey: '@',
|
|
||||||
orderBy: '@',
|
|
||||||
reverseOrder: '<',
|
|
||||||
refreshCallback: '<',
|
|
||||||
},
|
|
||||||
});
|
|
|
@ -1,118 +0,0 @@
|
||||||
import _ from 'lodash-es';
|
|
||||||
import { KubernetesApplicationDeploymentTypes } from 'Kubernetes/models/application/models';
|
|
||||||
import KubernetesApplicationHelper from 'Kubernetes/helpers/application';
|
|
||||||
import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper';
|
|
||||||
import { KubernetesServiceTypes } from 'Kubernetes/models/service/models';
|
|
||||||
|
|
||||||
angular.module('portainer.docker').controller('KubernetesApplicationsPortsDatatableController', [
|
|
||||||
'$scope',
|
|
||||||
'$controller',
|
|
||||||
'DatatableService',
|
|
||||||
'Authentication',
|
|
||||||
function ($scope, $controller, DatatableService, Authentication) {
|
|
||||||
angular.extend(this, $controller('GenericDatatableController', { $scope: $scope }));
|
|
||||||
this.state = Object.assign(this.state, {
|
|
||||||
expandedItems: [],
|
|
||||||
expandAll: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
var ctrl = this;
|
|
||||||
this.KubernetesServiceTypes = KubernetesServiceTypes;
|
|
||||||
|
|
||||||
this.settings = Object.assign(this.settings, {
|
|
||||||
showSystem: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.onSettingsShowSystemChange = function () {
|
|
||||||
DatatableService.setDataTableSettings(this.tableKey, this.settings);
|
|
||||||
};
|
|
||||||
|
|
||||||
this.isExternalApplication = function (item) {
|
|
||||||
return KubernetesApplicationHelper.isExternalApplication(item);
|
|
||||||
};
|
|
||||||
|
|
||||||
this.isSystemNamespace = function (item) {
|
|
||||||
return KubernetesNamespaceHelper.isSystemNamespace(item.ResourcePool);
|
|
||||||
};
|
|
||||||
|
|
||||||
this.isDisplayed = function (item) {
|
|
||||||
return !ctrl.isSystemNamespace(item) || ctrl.settings.showSystem;
|
|
||||||
};
|
|
||||||
|
|
||||||
this.expandItem = function (item, expanded) {
|
|
||||||
if (!this.itemCanExpand(item)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
item.Expanded = expanded;
|
|
||||||
if (!expanded) {
|
|
||||||
item.Highlighted = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
this.itemCanExpand = function (item) {
|
|
||||||
return item.Ports.length > 1 || item.Ports[0].IngressRules.length > 1;
|
|
||||||
};
|
|
||||||
|
|
||||||
this.buildIngressRuleURL = function (rule) {
|
|
||||||
const hostname = rule.Host ? rule.Host : rule.IP;
|
|
||||||
return 'http://' + hostname + rule.Path;
|
|
||||||
};
|
|
||||||
|
|
||||||
this.portHasIngressRules = function (port) {
|
|
||||||
return port.IngressRules.length > 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
this.ruleCanBeDisplayed = function (rule) {
|
|
||||||
return !rule.Host && !rule.IP ? false : true;
|
|
||||||
};
|
|
||||||
|
|
||||||
this.hasExpandableItems = function () {
|
|
||||||
return _.filter(this.state.filteredDataSet, (item) => this.itemCanExpand(item)).length;
|
|
||||||
};
|
|
||||||
|
|
||||||
this.expandAll = function () {
|
|
||||||
this.state.expandAll = !this.state.expandAll;
|
|
||||||
_.forEach(this.state.filteredDataSet, (item) => {
|
|
||||||
if (this.itemCanExpand(item)) {
|
|
||||||
this.expandItem(item, this.state.expandAll);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
this.$onInit = function () {
|
|
||||||
this.isAdmin = Authentication.isAdmin();
|
|
||||||
this.KubernetesApplicationDeploymentTypes = KubernetesApplicationDeploymentTypes;
|
|
||||||
this.setDefaults();
|
|
||||||
this.prepareTableFromDataset();
|
|
||||||
|
|
||||||
this.state.orderBy = this.orderBy;
|
|
||||||
var storedOrder = DatatableService.getDataTableOrder(this.tableKey);
|
|
||||||
if (storedOrder !== null) {
|
|
||||||
this.state.reverseOrder = storedOrder.reverse;
|
|
||||||
this.state.orderBy = storedOrder.orderBy;
|
|
||||||
}
|
|
||||||
|
|
||||||
var textFilter = DatatableService.getDataTableTextFilters(this.tableKey);
|
|
||||||
if (textFilter !== null) {
|
|
||||||
this.state.textFilter = textFilter;
|
|
||||||
this.onTextFilterChange();
|
|
||||||
}
|
|
||||||
|
|
||||||
var storedFilters = DatatableService.getDataTableFilters(this.tableKey);
|
|
||||||
if (storedFilters !== null) {
|
|
||||||
this.filters = storedFilters;
|
|
||||||
}
|
|
||||||
if (this.filters && this.filters.state) {
|
|
||||||
this.filters.state.open = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
var storedSettings = DatatableService.getDataTableSettings(this.tableKey);
|
|
||||||
if (storedSettings !== null) {
|
|
||||||
this.settings = storedSettings;
|
|
||||||
this.settings.open = false;
|
|
||||||
}
|
|
||||||
this.onSettingsRepeaterChange();
|
|
||||||
};
|
|
||||||
},
|
|
||||||
]);
|
|
|
@ -6,9 +6,14 @@ import { withReactQuery } from '@/react-tools/withReactQuery';
|
||||||
import { withUIRouter } from '@/react-tools/withUIRouter';
|
import { withUIRouter } from '@/react-tools/withUIRouter';
|
||||||
import { IngressesDatatableView } from '@/react/kubernetes/ingresses/IngressDatatable';
|
import { IngressesDatatableView } from '@/react/kubernetes/ingresses/IngressDatatable';
|
||||||
import { CreateIngressView } from '@/react/kubernetes/ingresses/CreateIngressView';
|
import { CreateIngressView } from '@/react/kubernetes/ingresses/CreateIngressView';
|
||||||
|
import { ServicesView } from '@/react/kubernetes/ServicesView';
|
||||||
|
|
||||||
export const viewsModule = angular
|
export const viewsModule = angular
|
||||||
.module('portainer.kubernetes.react.views', [])
|
.module('portainer.kubernetes.react.views', [])
|
||||||
|
.component(
|
||||||
|
'kubernetesServicesView',
|
||||||
|
r2a(withUIRouter(withReactQuery(withCurrentUser(ServicesView))), [])
|
||||||
|
)
|
||||||
.component(
|
.component(
|
||||||
'kubernetesIngressesView',
|
'kubernetesIngressesView',
|
||||||
r2a(
|
r2a(
|
||||||
|
|
|
@ -22,11 +22,6 @@
|
||||||
>
|
>
|
||||||
</kubernetes-applications-datatable>
|
</kubernetes-applications-datatable>
|
||||||
</uib-tab>
|
</uib-tab>
|
||||||
<uib-tab index="1" classes="btn-sm" select="ctrl.selectTab(1)">
|
|
||||||
<uib-tab-heading class="vertical-center"> <pr-icon icon="'svg-dataflow'"></pr-icon> Port mappings </uib-tab-heading>
|
|
||||||
<kubernetes-applications-ports-datatable dataset="ctrl.state.ports" table-key="kubernetes.applications.ports" order-by="Name" refresh-callback="ctrl.getApplications">
|
|
||||||
</kubernetes-applications-ports-datatable>
|
|
||||||
</uib-tab>
|
|
||||||
<uib-tab index="2" classes="btn-sm" select="ctrl.selectTab(2)">
|
<uib-tab index="2" classes="btn-sm" select="ctrl.selectTab(2)">
|
||||||
<uib-tab-heading class="vertical-center"> <pr-icon icon="'list'"></pr-icon> Stacks </uib-tab-heading>
|
<uib-tab-heading class="vertical-center"> <pr-icon icon="'list'"></pr-icon> Stacks </uib-tab-heading>
|
||||||
<kubernetes-applications-stacks-datatable
|
<kubernetes-applications-stacks-datatable
|
||||||
|
|
|
@ -51,6 +51,10 @@ export function parseIsoDate(date) {
|
||||||
return moment(date, TIME_FORMAT).toDate();
|
return moment(date, TIME_FORMAT).toDate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatDate(date, strFormat = 'YYYY-MM-DD HH:mm:ss Z') {
|
||||||
|
return moment(date, strFormat).format(TIME_FORMAT);
|
||||||
|
}
|
||||||
|
|
||||||
export function getPairKey(pair, separator) {
|
export function getPairKey(pair, separator) {
|
||||||
if (!pair.includes(separator)) {
|
if (!pair.includes(separator)) {
|
||||||
return pair;
|
return pair;
|
||||||
|
|
|
@ -163,6 +163,12 @@ const docURLs = [
|
||||||
locationRegex: /#!\/\d+\/kubernetes\/applications/,
|
locationRegex: /#!\/\d+\/kubernetes\/applications/,
|
||||||
examples: ['#!/1/kubernetes/applications', '#!/1/kubernetes/applications/new', '#!/1/kubernetes/deploy?templateId=', '#!/1/kubernetes/applications/metallb-system/controller'],
|
examples: ['#!/1/kubernetes/applications', '#!/1/kubernetes/applications/new', '#!/1/kubernetes/deploy?templateId=', '#!/1/kubernetes/applications/metallb-system/controller'],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
desc: 'Kubernetes / Services',
|
||||||
|
docURL: 'https://docs.portainer.io/user/kubernetes/services',
|
||||||
|
locationRegex: /#!\/\d+\/kubernetes\/services/,
|
||||||
|
examples: ['#!/1/kubernetes/services'],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
desc: 'Kubernetes / Ingresses',
|
desc: 'Kubernetes / Ingresses',
|
||||||
docURL: 'https://docs.portainer.io/user/kubernetes/ingresses',
|
docURL: 'https://docs.portainer.io/user/kubernetes/ingresses',
|
||||||
|
|
|
@ -20,7 +20,7 @@ export function TextTip({
|
||||||
}: PropsWithChildren<Props>) {
|
}: PropsWithChildren<Props>) {
|
||||||
return (
|
return (
|
||||||
<p className={clsx('small inline-flex items-center gap-1', className)}>
|
<p className={clsx('small inline-flex items-center gap-1', className)}>
|
||||||
<Icon icon={icon} mode={getMode(color)} />
|
<Icon icon={icon} mode={getMode(color)} className="shrink-0" />
|
||||||
<span className="text-muted">{children}</span>
|
<span className="text-muted">{children}</span>
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
|
|
|
@ -8,7 +8,7 @@ export interface PaginationTableSettings {
|
||||||
setPageSize: (pageSize: number) => void;
|
setPageSize: (pageSize: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ZustandSetFunc<T> = (
|
export type ZustandSetFunc<T> = (
|
||||||
partial: T | Partial<T> | ((state: T) => T | Partial<T>),
|
partial: T | Partial<T> | ((state: T) => T | Partial<T>),
|
||||||
replace?: boolean | undefined
|
replace?: boolean | undefined
|
||||||
) => void;
|
) => void;
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
import { openDialog, DialogOptions } from './Dialog';
|
import { openDialog, DialogOptions } from './Dialog';
|
||||||
import { OnSubmit, ModalType } from './Modal';
|
import { OnSubmit, ModalType } from './Modal';
|
||||||
import { ButtonOptions } from './types';
|
import { ButtonOptions } from './types';
|
||||||
|
@ -45,7 +47,7 @@ export function confirmWebEditorDiscard() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function confirmDelete(message: string) {
|
export function confirmDelete(message: ReactNode) {
|
||||||
return confirmDestructive({
|
return confirmDestructive({
|
||||||
title: 'Are you sure?',
|
title: 'Are you sure?',
|
||||||
message,
|
message,
|
||||||
|
|
|
@ -0,0 +1,190 @@
|
||||||
|
import { Row, TableRowProps } from 'react-table';
|
||||||
|
import { Shuffle, Trash2 } from 'lucide-react';
|
||||||
|
import { useStore } from 'zustand';
|
||||||
|
import { useRouter } from '@uirouter/react';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||||
|
import {
|
||||||
|
Authorized,
|
||||||
|
useAuthorizations,
|
||||||
|
useCurrentUser,
|
||||||
|
} from '@/react/hooks/useUser';
|
||||||
|
import KubernetesNamespaceHelper from '@/kubernetes/helpers/namespaceHelper';
|
||||||
|
import { notifyError, notifySuccess } from '@/portainer/services/notifications';
|
||||||
|
|
||||||
|
import { Datatable, Table, TableSettingsMenu } from '@@/datatables';
|
||||||
|
import { confirmDelete } from '@@/modals/confirm';
|
||||||
|
import { useSearchBarState } from '@@/datatables/SearchBar';
|
||||||
|
import { Button } from '@@/buttons';
|
||||||
|
import { Link } from '@@/Link';
|
||||||
|
|
||||||
|
import { useMutationDeleteServices, useServices } from '../service';
|
||||||
|
import { Service } from '../types';
|
||||||
|
import { DefaultDatatableSettings } from '../../datatables/DefaultDatatableSettings';
|
||||||
|
|
||||||
|
import { useColumns } from './columns';
|
||||||
|
import { createStore } from './datatable-store';
|
||||||
|
import { ServicesDatatableDescription } from './ServicesDatatableDescription';
|
||||||
|
|
||||||
|
const storageKey = 'k8sServicesDatatable';
|
||||||
|
const settingsStore = createStore(storageKey);
|
||||||
|
|
||||||
|
export function ServicesDatatable() {
|
||||||
|
const environmentId = useEnvironmentId();
|
||||||
|
const servicesQuery = useServices(environmentId);
|
||||||
|
|
||||||
|
const settings = useStore(settingsStore);
|
||||||
|
|
||||||
|
const [search, setSearch] = useSearchBarState(storageKey);
|
||||||
|
const columns = useColumns();
|
||||||
|
const readOnly = !useAuthorizations(['K8sServiceW']);
|
||||||
|
const { isAdmin } = useCurrentUser();
|
||||||
|
|
||||||
|
const filteredServices = servicesQuery.data?.filter(
|
||||||
|
(service) =>
|
||||||
|
(isAdmin && settings.showSystemResources) ||
|
||||||
|
!KubernetesNamespaceHelper.isSystemNamespace(service.Namespace)
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Datatable
|
||||||
|
dataset={filteredServices || []}
|
||||||
|
columns={columns}
|
||||||
|
isLoading={servicesQuery.isLoading}
|
||||||
|
emptyContentLabel="No services found"
|
||||||
|
title="Services"
|
||||||
|
titleIcon={Shuffle}
|
||||||
|
getRowId={(row) => row.UID}
|
||||||
|
isRowSelectable={(row) =>
|
||||||
|
!KubernetesNamespaceHelper.isSystemNamespace(row.values.namespace)
|
||||||
|
}
|
||||||
|
disableSelect={readOnly}
|
||||||
|
renderTableActions={(selectedRows) => (
|
||||||
|
<TableActions selectedItems={selectedRows} />
|
||||||
|
)}
|
||||||
|
initialPageSize={settings.pageSize}
|
||||||
|
onPageSizeChange={settings.setPageSize}
|
||||||
|
initialSortBy={settings.sortBy}
|
||||||
|
onSortByChange={settings.setSortBy}
|
||||||
|
searchValue={search}
|
||||||
|
onSearchChange={setSearch}
|
||||||
|
renderTableSettings={() => (
|
||||||
|
<TableSettingsMenu>
|
||||||
|
<DefaultDatatableSettings
|
||||||
|
settings={settings}
|
||||||
|
hideShowSystemResources={!isAdmin}
|
||||||
|
/>
|
||||||
|
</TableSettingsMenu>
|
||||||
|
)}
|
||||||
|
description={
|
||||||
|
<ServicesDatatableDescription
|
||||||
|
showSystemResources={settings.showSystemResources || !isAdmin}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
renderRow={servicesRenderRow}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// needed to apply custom styling to the row cells and not globally.
|
||||||
|
// required in the AC's for this ticket.
|
||||||
|
function servicesRenderRow<D extends Record<string, unknown>>(
|
||||||
|
row: Row<D>,
|
||||||
|
rowProps: TableRowProps,
|
||||||
|
highlightedItemId?: string
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<Table.Row<D>
|
||||||
|
key={rowProps.key}
|
||||||
|
cells={row.cells}
|
||||||
|
className={clsx('[&>td]:!py-4 [&>td]:!align-top', rowProps.className, {
|
||||||
|
active: highlightedItemId === row.id,
|
||||||
|
})}
|
||||||
|
role={rowProps.role}
|
||||||
|
style={rowProps.style}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SelectedService {
|
||||||
|
Namespace: string;
|
||||||
|
Name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TableActionsProps = {
|
||||||
|
selectedItems: Service[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function TableActions({ selectedItems }: TableActionsProps) {
|
||||||
|
const environmentId = useEnvironmentId();
|
||||||
|
const deleteServicesMutation = useMutationDeleteServices(environmentId);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
async function handleRemoveClick(services: SelectedService[]) {
|
||||||
|
const confirmed = await confirmDelete(
|
||||||
|
<>
|
||||||
|
<p>Are you sure you want to delete the selected service(s)?</p>
|
||||||
|
<ul className="pl-6">
|
||||||
|
{services.map((s, index) => (
|
||||||
|
<li key={index}>
|
||||||
|
{s.Namespace}/{s.Name}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
if (!confirmed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: Record<string, string[]> = {};
|
||||||
|
services.forEach((service) => {
|
||||||
|
payload[service.Namespace] = payload[service.Namespace] || [];
|
||||||
|
payload[service.Namespace].push(service.Name);
|
||||||
|
});
|
||||||
|
|
||||||
|
deleteServicesMutation.mutate(
|
||||||
|
{ environmentId, data: payload },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
notifySuccess(
|
||||||
|
'Services successfully removed',
|
||||||
|
services.map((s) => `${s.Namespace}/${s.Name}`).join(', ')
|
||||||
|
);
|
||||||
|
router.stateService.reload();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
notifyError(
|
||||||
|
'Unable to delete service(s)',
|
||||||
|
error as Error,
|
||||||
|
services.map((s) => `${s.Namespace}/${s.Name}`).join(', ')
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="servicesDatatable-actions">
|
||||||
|
<Authorized authorizations="K8sServiceW">
|
||||||
|
<Button
|
||||||
|
className="btn-wrapper"
|
||||||
|
color="dangerlight"
|
||||||
|
disabled={selectedItems.length === 0}
|
||||||
|
onClick={() => handleRemoveClick(selectedItems)}
|
||||||
|
icon={Trash2}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Link to="kubernetes.deploy" className="space-left">
|
||||||
|
<Button className="btn-wrapper" color="primary" icon="plus">
|
||||||
|
Create from manifest
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</Authorized>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { TextTip } from '@@/Tip/TextTip';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
showSystemResources: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ServicesDatatableDescription({ showSystemResources }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
{!showSystemResources && (
|
||||||
|
<TextTip color="blue" className="!mb-0">
|
||||||
|
System resources are hidden, this can be changed in the table settings
|
||||||
|
</TextTip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { CellProps, Column } from 'react-table';
|
||||||
|
|
||||||
|
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||||
|
|
||||||
|
import { Link } from '@@/Link';
|
||||||
|
|
||||||
|
import { Service } from '../../types';
|
||||||
|
|
||||||
|
export const application: Column<Service> = {
|
||||||
|
Header: 'Application',
|
||||||
|
accessor: (row) => (row.Applications ? row.Applications[0].Name : ''),
|
||||||
|
id: 'application',
|
||||||
|
|
||||||
|
Cell: ({ row, value: appname }: CellProps<Service, string>) => {
|
||||||
|
const environmentId = useEnvironmentId();
|
||||||
|
return appname ? (
|
||||||
|
<Link
|
||||||
|
to="kubernetes.applications.application"
|
||||||
|
params={{
|
||||||
|
endpointId: environmentId,
|
||||||
|
namespace: row.original.Namespace,
|
||||||
|
name: appname,
|
||||||
|
}}
|
||||||
|
title={appname}
|
||||||
|
>
|
||||||
|
{appname}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
'-'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
canHide: true,
|
||||||
|
disableFilters: true,
|
||||||
|
};
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { CellProps, Column } from 'react-table';
|
||||||
|
|
||||||
|
import { Service } from '../../types';
|
||||||
|
|
||||||
|
export const clusterIP: Column<Service> = {
|
||||||
|
Header: 'Cluster IP',
|
||||||
|
accessor: 'ClusterIPs',
|
||||||
|
id: 'clusterIP',
|
||||||
|
Cell: ({ value: clusterIPs }: CellProps<Service, Service['ClusterIPs']>) => {
|
||||||
|
if (!clusterIPs?.length) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
return clusterIPs.map((ip) => <div key={ip}>{ip}</div>);
|
||||||
|
},
|
||||||
|
disableFilters: true,
|
||||||
|
canHide: true,
|
||||||
|
sortType: (rowA, rowB) => {
|
||||||
|
const a = rowA.original.ClusterIPs;
|
||||||
|
const b = rowB.original.ClusterIPs;
|
||||||
|
|
||||||
|
const ipA = a?.[0];
|
||||||
|
const ipB = b?.[0];
|
||||||
|
|
||||||
|
// no ip's at top, followed by 'None', then ordered by ip
|
||||||
|
if (!ipA) return 1;
|
||||||
|
if (!ipB) return -1;
|
||||||
|
if (ipA === ipB) return 0;
|
||||||
|
if (ipA === 'None') return 1;
|
||||||
|
if (ipB === 'None') return -1;
|
||||||
|
|
||||||
|
// natural sort of the ip
|
||||||
|
return ipA.localeCompare(
|
||||||
|
ipB,
|
||||||
|
navigator.languages[0] || navigator.language,
|
||||||
|
{
|
||||||
|
numeric: true,
|
||||||
|
ignorePunctuation: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { CellProps, Column } from 'react-table';
|
||||||
|
|
||||||
|
import { formatDate } from '@/portainer/filters/filters';
|
||||||
|
|
||||||
|
import { Service } from '../../types';
|
||||||
|
|
||||||
|
export const created: Column<Service> = {
|
||||||
|
Header: 'Created',
|
||||||
|
id: 'created',
|
||||||
|
accessor: (row) => row.CreationTimestamp,
|
||||||
|
Cell: ({ row }: CellProps<Service>) => {
|
||||||
|
const owner =
|
||||||
|
row.original.Labels?.['io.portainer.kubernetes.application.owner'];
|
||||||
|
|
||||||
|
if (owner) {
|
||||||
|
return `${formatDate(row.original.CreationTimestamp)} by ${owner}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatDate(row.original.CreationTimestamp);
|
||||||
|
},
|
||||||
|
disableFilters: true,
|
||||||
|
canHide: true,
|
||||||
|
};
|
|
@ -0,0 +1,136 @@
|
||||||
|
import { CellProps, Column } from 'react-table';
|
||||||
|
|
||||||
|
import { Service } from '../../types';
|
||||||
|
|
||||||
|
import { ExternalIPLink } from './externalIPLink';
|
||||||
|
|
||||||
|
// calculate the scheme based on the ports of the service
|
||||||
|
// favour https over http.
|
||||||
|
function getSchemeAndPort(svc: Service): [string, number] {
|
||||||
|
let scheme = '';
|
||||||
|
let servicePort = 0;
|
||||||
|
|
||||||
|
svc.Ports?.forEach((port) => {
|
||||||
|
if (port.Protocol === 'TCP') {
|
||||||
|
switch (port.TargetPort) {
|
||||||
|
case '443':
|
||||||
|
case '8443':
|
||||||
|
case 'https':
|
||||||
|
scheme = 'https';
|
||||||
|
servicePort = port.Port;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case '80':
|
||||||
|
case '8080':
|
||||||
|
case 'http':
|
||||||
|
if (scheme !== 'https') {
|
||||||
|
scheme = 'http';
|
||||||
|
servicePort = port.Port;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return [scheme, servicePort];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const externalIP: Column<Service> = {
|
||||||
|
Header: 'External IP',
|
||||||
|
id: 'externalIP',
|
||||||
|
accessor: (row) => {
|
||||||
|
if (row.Type === 'ExternalName') {
|
||||||
|
return row.ExternalName;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.ExternalIPs?.length) {
|
||||||
|
return row.ExternalIPs?.slice(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return row.IngressStatus?.slice(0);
|
||||||
|
},
|
||||||
|
Cell: ({ row }: CellProps<Service>) => {
|
||||||
|
if (row.original.Type === 'ExternalName') {
|
||||||
|
if (row.original.ExternalName) {
|
||||||
|
const linkto = `http://${row.original.ExternalName}`;
|
||||||
|
return <ExternalIPLink to={linkto} text={row.original.ExternalName} />;
|
||||||
|
}
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
const [scheme, port] = getSchemeAndPort(row.original);
|
||||||
|
if (row.original.ExternalIPs?.length) {
|
||||||
|
return row.original.ExternalIPs?.map((ip, index) => {
|
||||||
|
// some ips come through blank
|
||||||
|
if (ip.length === 0) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scheme) {
|
||||||
|
let linkto = `${scheme}://${ip}`;
|
||||||
|
if (port !== 80 && port !== 443) {
|
||||||
|
linkto = `${linkto}:${port}`;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div key={index}>
|
||||||
|
<ExternalIPLink to={linkto} text={ip} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <div key={index}>{ip}</div>;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = row.original.IngressStatus;
|
||||||
|
if (status) {
|
||||||
|
return status?.map((status, index) => {
|
||||||
|
// some ips come through blank
|
||||||
|
if (status.IP.length === 0) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scheme) {
|
||||||
|
let linkto = `${scheme}://${status.IP}`;
|
||||||
|
if (port !== 80 && port !== 443) {
|
||||||
|
linkto = `${linkto}:${port}`;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div key={index}>
|
||||||
|
<ExternalIPLink to={linkto} text={status.IP} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <div key={index}>{status.IP}</div>;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return '-';
|
||||||
|
},
|
||||||
|
disableFilters: true,
|
||||||
|
canHide: true,
|
||||||
|
sortType: (rowA, rowB) => {
|
||||||
|
const a = rowA.original.IngressStatus;
|
||||||
|
const b = rowB.original.IngressStatus;
|
||||||
|
const aExternal = rowA.original.ExternalIPs;
|
||||||
|
const bExternal = rowB.original.ExternalIPs;
|
||||||
|
|
||||||
|
const ipA = a?.[0].IP || aExternal?.[0] || rowA.original.ExternalName;
|
||||||
|
const ipB = b?.[0].IP || bExternal?.[0] || rowA.original.ExternalName;
|
||||||
|
|
||||||
|
if (!ipA) return 1;
|
||||||
|
if (!ipB) return -1;
|
||||||
|
|
||||||
|
// use a nat sort order for ip addresses
|
||||||
|
return ipA.localeCompare(
|
||||||
|
ipB,
|
||||||
|
navigator.languages[0] || navigator.language,
|
||||||
|
{
|
||||||
|
numeric: true,
|
||||||
|
ignorePunctuation: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { ExternalLink } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Icon } from '@@/Icon';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
to: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExternalIPLink({ to, text }: Props) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={to}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Icon icon={ExternalLink} />
|
||||||
|
<span>{text}</span>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { name } from './name';
|
||||||
|
import { type } from './type';
|
||||||
|
import { namespace } from './namespace';
|
||||||
|
import { ports } from './ports';
|
||||||
|
import { clusterIP } from './clusterIP';
|
||||||
|
import { externalIP } from './externalIP';
|
||||||
|
import { targetPorts } from './targetPorts';
|
||||||
|
import { application } from './application';
|
||||||
|
import { created } from './created';
|
||||||
|
|
||||||
|
export function useColumns() {
|
||||||
|
return [
|
||||||
|
name,
|
||||||
|
application,
|
||||||
|
namespace,
|
||||||
|
type,
|
||||||
|
ports,
|
||||||
|
targetPorts,
|
||||||
|
clusterIP,
|
||||||
|
externalIP,
|
||||||
|
created,
|
||||||
|
];
|
||||||
|
}
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { CellProps, Column } from 'react-table';
|
||||||
|
|
||||||
|
import { Authorized } from '@/react/hooks/useUser';
|
||||||
|
import KubernetesNamespaceHelper from '@/kubernetes/helpers/namespaceHelper';
|
||||||
|
|
||||||
|
import { Service } from '../../types';
|
||||||
|
|
||||||
|
export const name: Column<Service> = {
|
||||||
|
Header: 'Name',
|
||||||
|
id: 'Name',
|
||||||
|
accessor: (row) => row.Name,
|
||||||
|
Cell: ({ row }: CellProps<Service>) => {
|
||||||
|
const isSystem = KubernetesNamespaceHelper.isSystemNamespace(
|
||||||
|
row.original.Namespace
|
||||||
|
);
|
||||||
|
|
||||||
|
const isExternal =
|
||||||
|
!row.original.Labels ||
|
||||||
|
!row.original.Labels['io.portainer.kubernetes.application.owner'];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Authorized
|
||||||
|
authorizations="K8sServiceW"
|
||||||
|
childrenUnauthorized={row.original.Name}
|
||||||
|
>
|
||||||
|
{row.original.Name}
|
||||||
|
|
||||||
|
{isSystem && (
|
||||||
|
<span className="label label-info image-tag label-margins">
|
||||||
|
system
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isExternal && !isSystem && (
|
||||||
|
<span className="label label-primary image-tag label-margins">
|
||||||
|
external
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Authorized>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
disableFilters: true,
|
||||||
|
canHide: true,
|
||||||
|
};
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { CellProps, Column, Row } from 'react-table';
|
||||||
|
|
||||||
|
import { filterHOC } from '@/react/components/datatables/Filter';
|
||||||
|
|
||||||
|
import { Link } from '@@/Link';
|
||||||
|
|
||||||
|
import { Service } from '../../types';
|
||||||
|
|
||||||
|
export const namespace: Column<Service> = {
|
||||||
|
Header: 'Namespace',
|
||||||
|
id: 'namespace',
|
||||||
|
accessor: 'Namespace',
|
||||||
|
Cell: ({ row }: CellProps<Service>) => (
|
||||||
|
<Link
|
||||||
|
to="kubernetes.resourcePools.resourcePool"
|
||||||
|
params={{
|
||||||
|
id: row.original.Namespace,
|
||||||
|
}}
|
||||||
|
title={row.original.Namespace}
|
||||||
|
>
|
||||||
|
{row.original.Namespace}
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
canHide: true,
|
||||||
|
disableFilters: false,
|
||||||
|
Filter: filterHOC('Filter by namespace'),
|
||||||
|
filter: (rows: Row<Service>[], _filterValue, filters) => {
|
||||||
|
if (filters.length === 0) {
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
return rows.filter((r) => filters.includes(r.original.Namespace));
|
||||||
|
},
|
||||||
|
};
|
|
@ -0,0 +1,76 @@
|
||||||
|
import { CellProps, Column } from 'react-table';
|
||||||
|
|
||||||
|
import { Tooltip } from '@@/Tip/Tooltip';
|
||||||
|
|
||||||
|
import { Service } from '../../types';
|
||||||
|
|
||||||
|
export const ports: Column<Service> = {
|
||||||
|
Header: () => (
|
||||||
|
<>
|
||||||
|
Ports
|
||||||
|
<Tooltip message="The format of Ports is port[:nodePort]/protocol. Protocol is either TCP, UDP or SCTP." />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
|
||||||
|
id: 'ports',
|
||||||
|
accessor: (row) => {
|
||||||
|
const ports = row.Ports;
|
||||||
|
return ports.map(
|
||||||
|
(port) => `${port.Port}:${port.NodePort}/${port.Protocol}`
|
||||||
|
);
|
||||||
|
},
|
||||||
|
Cell: ({ row }: CellProps<Service>) => {
|
||||||
|
if (!row.original.Ports.length) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{row.original.Ports.map((port, index) => {
|
||||||
|
if (port.NodePort !== 0) {
|
||||||
|
return (
|
||||||
|
<div key={index}>
|
||||||
|
{port.Port}:{port.NodePort}/{port.Protocol}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={index}>
|
||||||
|
{port.Port}/{port.Protocol}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
disableFilters: true,
|
||||||
|
canHide: true,
|
||||||
|
|
||||||
|
sortType: (rowA, rowB) => {
|
||||||
|
const a = rowA.original.Ports;
|
||||||
|
const b = rowB.original.Ports;
|
||||||
|
|
||||||
|
if (!a.length && !b.length) return 0;
|
||||||
|
|
||||||
|
if (!a.length) return 1;
|
||||||
|
if (!b.length) return -1;
|
||||||
|
|
||||||
|
// sort order based on first port
|
||||||
|
const portA = a[0].Port;
|
||||||
|
const portB = b[0].Port;
|
||||||
|
|
||||||
|
if (portA === portB) {
|
||||||
|
// longer list of ports is considered "greater"
|
||||||
|
if (a.length < b.length) return -1;
|
||||||
|
if (a.length > b.length) return 1;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// now do a regular number sort
|
||||||
|
if (portA < portB) return -1;
|
||||||
|
if (portA > portB) return 1;
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
};
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { CellProps, Column } from 'react-table';
|
||||||
|
|
||||||
|
import { Service } from '../../types';
|
||||||
|
|
||||||
|
export const targetPorts: Column<Service> = {
|
||||||
|
Header: 'Target Ports',
|
||||||
|
id: 'targetPorts',
|
||||||
|
accessor: (row) => {
|
||||||
|
const ports = row.Ports;
|
||||||
|
if (!ports.length) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
return ports.map((port) => `${port.TargetPort}`);
|
||||||
|
},
|
||||||
|
Cell: ({ row }: CellProps<Service>) => {
|
||||||
|
const ports = row.original.Ports;
|
||||||
|
if (!ports.length) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
return ports.map((port, index) => <div key={index}>{port.TargetPort}</div>);
|
||||||
|
},
|
||||||
|
disableFilters: true,
|
||||||
|
canHide: true,
|
||||||
|
|
||||||
|
sortType: (rowA, rowB) => {
|
||||||
|
const a = rowA.original.Ports;
|
||||||
|
const b = rowB.original.Ports;
|
||||||
|
|
||||||
|
if (!a.length && !b.length) return 0;
|
||||||
|
if (!a.length) return 1;
|
||||||
|
if (!b.length) return -1;
|
||||||
|
|
||||||
|
const portA = a[0].TargetPort;
|
||||||
|
const portB = b[0].TargetPort;
|
||||||
|
|
||||||
|
if (portA === portB) {
|
||||||
|
if (a.length < b.length) return -1;
|
||||||
|
if (a.length > b.length) return 1;
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// natural sort of the port
|
||||||
|
return portA.localeCompare(
|
||||||
|
portB,
|
||||||
|
navigator.languages[0] || navigator.language,
|
||||||
|
{
|
||||||
|
numeric: true,
|
||||||
|
ignorePunctuation: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { CellProps, Column, Row } from 'react-table';
|
||||||
|
|
||||||
|
import { filterHOC } from '@@/datatables/Filter';
|
||||||
|
|
||||||
|
import { Service } from '../../types';
|
||||||
|
|
||||||
|
export const type: Column<Service> = {
|
||||||
|
Header: 'Type',
|
||||||
|
id: 'type',
|
||||||
|
accessor: (row) => row.Type,
|
||||||
|
Cell: ({ row }: CellProps<Service>) => <div>{row.original.Type}</div>,
|
||||||
|
canHide: true,
|
||||||
|
|
||||||
|
disableFilters: false,
|
||||||
|
Filter: filterHOC('Filter by type'),
|
||||||
|
filter: (rows: Row<Service>[], _filterValue, filters) => {
|
||||||
|
if (filters.length === 0) {
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
return rows.filter((r) => filters.includes(r.original.Type));
|
||||||
|
},
|
||||||
|
};
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { refreshableSettings, createPersistedStore } from '@@/datatables/types';
|
||||||
|
|
||||||
|
import {
|
||||||
|
systemResourcesSettings,
|
||||||
|
TableSettings,
|
||||||
|
} from '../../datatables/DefaultDatatableSettings';
|
||||||
|
|
||||||
|
export function createStore(storageKey: string) {
|
||||||
|
return createPersistedStore<TableSettings>(storageKey, 'Name', (set) => ({
|
||||||
|
...refreshableSettings(set),
|
||||||
|
...systemResourcesSettings(set),
|
||||||
|
}));
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
export { ServicesDatatable } from './ServicesDatatable';
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { PageHeader } from '@@/PageHeader';
|
||||||
|
|
||||||
|
import { ServicesDatatable } from './ServicesDatatable';
|
||||||
|
|
||||||
|
export function ServicesView() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="Service List" breadcrumbs="Services" reload />
|
||||||
|
<ServicesDatatable />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
export { ServicesView } from './ServicesView';
|
|
@ -0,0 +1,84 @@
|
||||||
|
import { useMutation, useQuery, useQueryClient } from 'react-query';
|
||||||
|
import { compact } from 'lodash';
|
||||||
|
|
||||||
|
import { withError } from '@/react-tools/react-query';
|
||||||
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
|
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
|
|
||||||
|
import { getNamespaces } from '../namespaces/service';
|
||||||
|
|
||||||
|
export const queryKeys = {
|
||||||
|
list: (environmentId: EnvironmentId) =>
|
||||||
|
['environments', environmentId, 'kubernetes', 'services'] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
async function getServices(
|
||||||
|
environmentId: EnvironmentId,
|
||||||
|
namespace: string,
|
||||||
|
lookupApps: boolean
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { data: services } = await axios.get(
|
||||||
|
`kubernetes/${environmentId}/namespaces/${namespace}/services`,
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
lookupapplications: lookupApps,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return services;
|
||||||
|
} catch (e) {
|
||||||
|
throw parseAxiosError(e as Error, 'Unable to retrieve services');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useServices(environmentId: EnvironmentId) {
|
||||||
|
return useQuery(
|
||||||
|
queryKeys.list(environmentId),
|
||||||
|
async () => {
|
||||||
|
const namespaces = await getNamespaces(environmentId);
|
||||||
|
const settledServicesPromise = await Promise.allSettled(
|
||||||
|
Object.keys(namespaces).map((namespace) =>
|
||||||
|
getServices(environmentId, namespace, true)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return compact(
|
||||||
|
settledServicesPromise.filter(isFulfilled).flatMap((i) => i.value)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
withError('Unable to get services.')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFulfilled<T>(
|
||||||
|
input: PromiseSettledResult<T>
|
||||||
|
): input is PromiseFulfilledResult<T> {
|
||||||
|
return input.status === 'fulfilled';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMutationDeleteServices(environmentId: EnvironmentId) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation(deleteServices, {
|
||||||
|
onSuccess: () =>
|
||||||
|
// use the exact same query keys as the useServices hook to invalidate the services list
|
||||||
|
queryClient.invalidateQueries(queryKeys.list(environmentId)),
|
||||||
|
...withError('Unable to delete service(s)'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteServices({
|
||||||
|
environmentId,
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
environmentId: EnvironmentId;
|
||||||
|
data: Record<string, string[]>;
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
return await axios.post(
|
||||||
|
`kubernetes/${environmentId}/services/delete`,
|
||||||
|
data
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
throw parseAxiosError(e as Error, 'Unable to delete service(s)');
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,41 @@
|
||||||
|
type ServicePort = {
|
||||||
|
Name: string;
|
||||||
|
NodePort: number;
|
||||||
|
Port: number;
|
||||||
|
Protocol: string;
|
||||||
|
TargetPort: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type IngressStatus = {
|
||||||
|
Hostname: string;
|
||||||
|
IP: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Application = {
|
||||||
|
UID: string;
|
||||||
|
Name: string;
|
||||||
|
Type: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ServiceType =
|
||||||
|
| 'ClusterIP'
|
||||||
|
| 'ExternalName'
|
||||||
|
| 'NodePort'
|
||||||
|
| 'LoadBalancer';
|
||||||
|
|
||||||
|
export type Service = {
|
||||||
|
Name: string;
|
||||||
|
UID: string;
|
||||||
|
Namespace: string;
|
||||||
|
Annotations?: Record<string, string>;
|
||||||
|
Labels?: Record<string, string>;
|
||||||
|
Type: ServiceType;
|
||||||
|
Ports: Array<ServicePort>;
|
||||||
|
Selector?: Record<string, string>;
|
||||||
|
ClusterIPs?: Array<string>;
|
||||||
|
IngressStatus?: Array<IngressStatus>;
|
||||||
|
ExternalName?: string;
|
||||||
|
ExternalIPs?: Array<string>;
|
||||||
|
CreationTimestamp: string;
|
||||||
|
Applications?: Application[];
|
||||||
|
};
|
|
@ -0,0 +1,61 @@
|
||||||
|
import { TableSettingsMenuAutoRefresh } from '@@/datatables/TableSettingsMenuAutoRefresh';
|
||||||
|
import { Checkbox } from '@@/form-components/Checkbox';
|
||||||
|
import {
|
||||||
|
BasicTableSettings,
|
||||||
|
RefreshableTableSettings,
|
||||||
|
ZustandSetFunc,
|
||||||
|
} from '@@/datatables/types';
|
||||||
|
|
||||||
|
interface SystemResourcesTableSettings {
|
||||||
|
showSystemResources: boolean;
|
||||||
|
setShowSystemResources: (value: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TableSettings
|
||||||
|
extends BasicTableSettings,
|
||||||
|
RefreshableTableSettings,
|
||||||
|
SystemResourcesTableSettings {}
|
||||||
|
|
||||||
|
export function systemResourcesSettings(
|
||||||
|
set: ZustandSetFunc<SystemResourcesTableSettings>
|
||||||
|
): SystemResourcesTableSettings {
|
||||||
|
return {
|
||||||
|
showSystemResources: false,
|
||||||
|
setShowSystemResources(showSystemResources: boolean) {
|
||||||
|
set({
|
||||||
|
showSystemResources,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
settings: TableSettings;
|
||||||
|
hideShowSystemResources?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DefaultDatatableSettings({
|
||||||
|
settings,
|
||||||
|
hideShowSystemResources = false,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{!hideShowSystemResources && (
|
||||||
|
<Checkbox
|
||||||
|
id="show-system-resources"
|
||||||
|
label="Show system resources"
|
||||||
|
checked={settings.showSystemResources}
|
||||||
|
onChange={(e) => settings.setShowSystemResources(e.target.checked)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<TableSettingsMenuAutoRefresh
|
||||||
|
value={settings.autoRefreshRate}
|
||||||
|
onChange={handleRefreshRateChange}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleRefreshRateChange(autoRefreshRate: number) {
|
||||||
|
settings.setAutoRefreshRate(autoRefreshRate);
|
||||||
|
}
|
||||||
|
}
|
|
@ -17,7 +17,7 @@ import { useSearchBarState } from '@@/datatables/SearchBar';
|
||||||
import { DeleteIngressesRequest, Ingress } from '../types';
|
import { DeleteIngressesRequest, Ingress } from '../types';
|
||||||
import { useDeleteIngresses, useIngresses } from '../queries';
|
import { useDeleteIngresses, useIngresses } from '../queries';
|
||||||
|
|
||||||
import { useColumns } from './columns';
|
import { columns } from './columns';
|
||||||
|
|
||||||
import '../style.css';
|
import '../style.css';
|
||||||
|
|
||||||
|
@ -38,7 +38,6 @@ export function IngressDatatable() {
|
||||||
Object.keys(nsResult?.data || {})
|
Object.keys(nsResult?.data || {})
|
||||||
);
|
);
|
||||||
|
|
||||||
const columns = useColumns();
|
|
||||||
const deleteIngressesMutation = useDeleteIngresses();
|
const deleteIngressesMutation = useDeleteIngresses();
|
||||||
const settings = useStore(settingsStore);
|
const settings = useStore(settingsStore);
|
||||||
const [search, setSearch] = useSearchBarState(storageKey);
|
const [search, setSearch] = useSearchBarState(storageKey);
|
||||||
|
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { CellProps, Column } from 'react-table';
|
||||||
|
|
||||||
|
import { formatDate } from '@/portainer/filters/filters';
|
||||||
|
|
||||||
|
import { Ingress } from '../../types';
|
||||||
|
|
||||||
|
export const created: Column<Ingress> = {
|
||||||
|
Header: 'Created',
|
||||||
|
id: 'created',
|
||||||
|
accessor: (row) => row.CreationDate,
|
||||||
|
Cell: ({ row }: CellProps<Ingress>) => {
|
||||||
|
const owner =
|
||||||
|
row.original.Labels?.['io.portainer.kubernetes.ingress.owner'];
|
||||||
|
|
||||||
|
if (owner) {
|
||||||
|
return `${formatDate(row.original.CreationDate)} by ${owner}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatDate(row.original.CreationDate);
|
||||||
|
},
|
||||||
|
disableFilters: true,
|
||||||
|
canHide: true,
|
||||||
|
};
|
|
@ -1,11 +1,15 @@
|
||||||
import { useMemo } from 'react';
|
|
||||||
|
|
||||||
import { name } from './name';
|
import { name } from './name';
|
||||||
import { type } from './type';
|
import { type } from './type';
|
||||||
import { namespace } from './namespace';
|
import { namespace } from './namespace';
|
||||||
import { className } from './className';
|
import { className } from './className';
|
||||||
import { ingressRules } from './ingressRules';
|
import { ingressRules } from './ingressRules';
|
||||||
|
import { created } from './created';
|
||||||
|
|
||||||
export function useColumns() {
|
export const columns = [
|
||||||
return useMemo(() => [name, namespace, className, type, ingressRules], []);
|
name,
|
||||||
}
|
namespace,
|
||||||
|
className,
|
||||||
|
type,
|
||||||
|
ingressRules,
|
||||||
|
created,
|
||||||
|
];
|
||||||
|
|
|
@ -33,6 +33,8 @@ export type Ingress = {
|
||||||
Paths: Path[];
|
Paths: Path[];
|
||||||
TLS?: TLS[];
|
TLS?: TLS[];
|
||||||
Type?: string;
|
Type?: string;
|
||||||
|
Labels?: Record<string, string>;
|
||||||
|
CreationDate?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface DeleteIngressesRequest {
|
export interface DeleteIngressesRequest {
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { Box, Edit, Layers, Lock, Server } from 'lucide-react';
|
import { Box, Edit, Layers, Lock, Server, Shuffle } from 'lucide-react';
|
||||||
|
|
||||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
import { Authorized } from '@/react/hooks/useUser';
|
import { Authorized } from '@/react/hooks/useUser';
|
||||||
|
@ -70,6 +70,14 @@ export function KubernetesSidebar({ environmentId }: Props) {
|
||||||
data-cy="k8sSidebar-applications"
|
data-cy="k8sSidebar-applications"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<SidebarItem
|
||||||
|
to="kubernetes.services"
|
||||||
|
params={{ endpointId: environmentId }}
|
||||||
|
label="Services"
|
||||||
|
data-cy="k8sSidebar-services"
|
||||||
|
icon={Shuffle}
|
||||||
|
/>
|
||||||
|
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
to="kubernetes.ingresses"
|
to="kubernetes.ingresses"
|
||||||
params={{ endpointId: environmentId }}
|
params={{ endpointId: environmentId }}
|
||||||
|
|
Loading…
Reference in New Issue