[dashboard] Add external ips count to Tenant details page (#1938)

<!-- Thank you for making a contribution! Here are some tips for you:
- Start the PR title with the [label] of Cozystack component:
- For system components: [platform], [system], [linstor], [cilium],
[kube-ovn], [dashboard], [cluster-api], etc.
- For managed apps: [apps], [tenant], [kubernetes], [postgres],
[virtual-machine] etc.
- For development and maintenance: [tests], [ci], [docs], [maintenance].
- If it's a work in progress, consider creating this PR as a draft.
- Don't hesistate to ask for opinion and review in the community chats,
even if it's still a draft.
- Add the label `backport` if it's a bugfix that needs to be backported
to a previous version.
-->

## What this PR does

Add external ips (LoadBalancer services with assigned IP address) count
to the Tenant details page
<img width="2560" height="1330" alt="external ip count on tenant details
page"
src="https://github.com/user-attachments/assets/f2256629-3815-4892-b80d-e150d0fd3908"
/>



### Release note

<!--  Write a release note:
- Explain what has changed internally and for users.
- Start with the same [label] as in the PR title
- Follow the guidelines at
https://github.com/kubernetes/community/blob/master/contributors/guide/release-notes.md.
-->

```release-note
[dashboard] add external ips count (count of services of type LoadBalancer with assigned ip) to the Tenant details page
```

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* External IPs count now shown in the Tenant application Details tab — a
left-column item displays the number of external IPs for LoadBalancer
services.
* Application status now exposes an External IPs Count metric for Tenant
applications, so UI and APIs can surface the number of LoadBalancer
external IPs without changing other behaviors.

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Andrei Kvapil 2026-02-02 09:58:40 +01:00 committed by GitHub
commit 855aa76b29
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 62 additions and 8 deletions

View file

@ -175,6 +175,10 @@ func detailsTab(kind, endpoint, schemaJSON string, keysOrder [][]string) map[str
)
}
if kind == "Tenant" {
leftColStack = append(leftColStack, antdFlexVertical("tenant-external-ip-count", 4, []any{
antdText("tenant-external-ip-count-label", true, "External IPs count", nil),
parsedText("tenant-external-ip-count-value", `{reqsJsonPath[0]['.status.externalIPsCount']['0']}`, nil),
}))
rightColStack = append(rightColStack,
antdFlexVertical("resource-quotas-block", 4, []any{
antdText("resource-quotas-label", true, "Resource Quotas", map[string]any{

View file

@ -47,6 +47,9 @@ type ApplicationStatus struct {
// Namespace holds the computed namespace for Tenant applications.
// +optional
Namespace string `json:"namespace,omitempty"`
// ExternalIPsCount holds the number of LoadBalancer services with assigned external IPs for Tenant applications.
// +optional
ExternalIPsCount int32 `json:"externalIPsCount,omitempty"`
}
// GetConditions returns the status conditions of the object.

View file

@ -149,6 +149,17 @@ func (c completedConfig) New() (*CozyServer, error) {
return nil, fmt.Errorf("failed to build manager: %w", err)
}
if err := mgr.GetFieldIndexer().IndexField(
context.Background(),
&corev1.Service{},
"spec.type",
func(rawObj client.Object) []string {
svc := rawObj.(*corev1.Service)
return []string{string(svc.Spec.Type)}
}); err != nil {
return nil, fmt.Errorf("failed to index service spec.type field: %w", err)
}
ctx := ctrl.SetupSignalHandler()
if err = mustGetInformers(ctx, mgr,

View file

@ -27,6 +27,7 @@ import (
"time"
helmv2 "github.com/fluxcd/helm-controller/api/v2"
corev1 "k8s.io/api/core/v1"
metainternalversion "k8s.io/apimachinery/pkg/apis/meta/internalversion"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/fields"
@ -186,7 +187,7 @@ func (r *REST) Create(ctx context.Context, obj runtime.Object, createValidation
}
// Convert the created HelmRelease back to Application
convertedApp, err := r.ConvertHelmReleaseToApplication(helmRelease)
convertedApp, err := r.ConvertHelmReleaseToApplication(ctx, helmRelease)
if err != nil {
klog.Errorf("Conversion error from HelmRelease to Application for resource %s: %v", helmRelease.GetName(), err)
return nil, fmt.Errorf("conversion error: %v", err)
@ -233,7 +234,7 @@ func (r *REST) Get(ctx context.Context, name string, options *metav1.GetOptions)
}
// Convert HelmRelease to Application
convertedApp, err := r.ConvertHelmReleaseToApplication(helmRelease)
convertedApp, err := r.ConvertHelmReleaseToApplication(ctx, helmRelease)
if err != nil {
klog.Errorf("Conversion error from HelmRelease to Application for resource %s: %v", name, err)
return nil, fmt.Errorf("conversion error: %v", err)
@ -360,7 +361,7 @@ func (r *REST) List(ctx context.Context, options *metainternalversion.ListOption
continue
}
app, err := r.ConvertHelmReleaseToApplication(hr)
app, err := r.ConvertHelmReleaseToApplication(ctx, hr)
if err != nil {
klog.Errorf("Error converting HelmRelease %s to Application: %v", hr.GetName(), err)
continue
@ -514,7 +515,7 @@ func (r *REST) Update(ctx context.Context, name string, objInfo rest.UpdatedObje
}
// Convert the updated HelmRelease back to Application
convertedApp, err := r.ConvertHelmReleaseToApplication(helmRelease)
convertedApp, err := r.ConvertHelmReleaseToApplication(ctx, helmRelease)
if err != nil {
klog.Errorf("Conversion error from HelmRelease to Application for resource %s: %v", helmRelease.GetName(), err)
return nil, false, fmt.Errorf("conversion error: %v", err)
@ -796,7 +797,7 @@ func (r *REST) Watch(ctx context.Context, options *metainternalversion.ListOptio
// Note: All HelmReleases already match the required labels due to server-side label selector filtering
// Convert HelmRelease to Application
app, err := r.ConvertHelmReleaseToApplication(hr)
app, err := r.ConvertHelmReleaseToApplication(ctx, hr)
if err != nil {
klog.Errorf("Error converting HelmRelease to Application: %v", err)
continue
@ -967,11 +968,11 @@ func filterPrefixedMap(original map[string]string, prefix string) map[string]str
}
// ConvertHelmReleaseToApplication converts a HelmRelease to an Application
func (r *REST) ConvertHelmReleaseToApplication(hr *helmv2.HelmRelease) (appsv1alpha1.Application, error) {
func (r *REST) ConvertHelmReleaseToApplication(ctx context.Context, hr *helmv2.HelmRelease) (appsv1alpha1.Application, error) {
klog.V(6).Infof("Converting HelmRelease to Application for resource %s", hr.GetName())
// Convert HelmRelease struct to Application struct
app, err := r.convertHelmReleaseToApplication(hr)
app, err := r.convertHelmReleaseToApplication(ctx, hr)
if err != nil {
klog.Errorf("Error converting from HelmRelease to Application: %v", err)
return appsv1alpha1.Application{}, err
@ -1029,7 +1030,7 @@ func validateNoInternalKeys(values *apiextv1.JSON) error {
}
// convertHelmReleaseToApplication implements the actual conversion logic
func (r *REST) convertHelmReleaseToApplication(hr *helmv2.HelmRelease) (appsv1alpha1.Application, error) {
func (r *REST) convertHelmReleaseToApplication(ctx context.Context, hr *helmv2.HelmRelease) (appsv1alpha1.Application, error) {
// Filter out internal keys (starting with "_") from spec
filteredSpec := filterInternalKeys(hr.Spec.Values)
@ -1071,6 +1072,12 @@ func (r *REST) convertHelmReleaseToApplication(hr *helmv2.HelmRelease) (appsv1al
// Add namespace field for Tenant applications
if r.kindName == "Tenant" {
app.Status.Namespace = r.computeTenantNamespace(hr.Namespace, app.Name)
externalIPsCount, err := r.countTenantExternalIPs(ctx, app.Status.Namespace)
if err != nil {
klog.Warningf("Failed to count external IPs for tenant %s/%s: %v", hr.Namespace, app.Name, err)
} else {
app.Status.ExternalIPsCount = externalIPsCount
}
}
return app, nil
@ -1265,6 +1272,35 @@ func (r *REST) computeTenantNamespace(currentNamespace, tenantName string) strin
}
}
func (r *REST) countTenantExternalIPs(ctx context.Context, namespace string) (int32, error) {
if namespace == "" {
return 0, nil
}
var services corev1.ServiceList
if err := r.c.List(
ctx,
&services,
client.InNamespace(namespace),
client.MatchingFields{"spec.type": string(corev1.ServiceTypeLoadBalancer)},
); err != nil {
return 0, err
}
var count int32
for i := range services.Items {
svc := &services.Items[i]
for _, ingress := range svc.Status.LoadBalancer.Ingress {
if ingress.IP != "" {
count++
break
}
}
}
return count, nil
}
// Destroy releases resources associated with REST
func (r *REST) Destroy() {
// No additional actions needed to release resources.