From af0c54d2b0873a1cb5ea02980fae44883caf320a Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Fri, 6 Feb 2026 01:42:40 +0100 Subject: [PATCH 1/3] fix(dashboard): update controller field names for release/1.3.0 compatibility The upstream openapi-ui release/1.3.0 renamed route params and component props. Update the dashboard controller to match: - Rename {clusterName} to {cluster} in sidebar, breadcrumb, and factory links - Rename clusterNamePartOfUrl to cluster in EnrichedTable data - Rename namespacePartOfUrl to namespace in MarketplaceCard data - Rename mpResourceName to marketplacePlural - Rename mpResourceKind to marketplaceKind These changes fix double-slash in navigation URLs and MarketplacePanels returning "No panels". Co-Authored-By: Claude Signed-off-by: Andrei Kvapil --- internal/controller/dashboard/README.md | 4 +- internal/controller/dashboard/breadcrumb.go | 4 +- internal/controller/dashboard/factory.go | 14 +++--- internal/controller/dashboard/sidebar.go | 22 ++++---- .../controller/dashboard/static_refactored.go | 50 +++++++++---------- 5 files changed, 47 insertions(+), 47 deletions(-) diff --git a/internal/controller/dashboard/README.md b/internal/controller/dashboard/README.md index c3b2152e..2efe9f45 100644 --- a/internal/controller/dashboard/README.md +++ b/internal/controller/dashboard/README.md @@ -156,7 +156,7 @@ menuItems = append(menuItems, map[string]any{ map[string]any{ "key": "{plural}", "label": "{ResourceLabel}", - "link": "/openapi-ui/{clusterName}/{namespace}/api-table/{group}/{version}/{plural}", + "link": "/openapi-ui/{cluster}/{namespace}/api-table/{group}/{version}/{plural}", }, }, }), @@ -174,7 +174,7 @@ menuItems = append(menuItems, map[string]any{ **Important Notes**: - The sidebar tag (`{lowercase-kind}-sidebar`) must match what the Factory uses -- The link format: `/openapi-ui/{clusterName}/{namespace}/api-table/{group}/{version}/{plural}` +- The link format: `/openapi-ui/{cluster}/{namespace}/api-table/{group}/{version}/{plural}` - All sidebars share the same `keysAndTags` and `menuItems`, so changes affect all sidebar instances ### Step 4: Verify Integration diff --git a/internal/controller/dashboard/breadcrumb.go b/internal/controller/dashboard/breadcrumb.go index aadcacac..6d65f2db 100644 --- a/internal/controller/dashboard/breadcrumb.go +++ b/internal/controller/dashboard/breadcrumb.go @@ -33,12 +33,12 @@ func (m *Manager) ensureBreadcrumb(ctx context.Context, crd *cozyv1alpha1.Applic key := plural // e.g., "virtualmachines" label := labelPlural - link := fmt.Sprintf("/openapi-ui/{clusterName}/{namespace}/api-table/%s/%s/%s", strings.ToLower(group), strings.ToLower(version), plural) + link := fmt.Sprintf("/openapi-ui/{cluster}/{namespace}/api-table/%s/%s/%s", strings.ToLower(group), strings.ToLower(version), plural) // If this is a module, change the first breadcrumb item to "Tenant Modules" if crd.Spec.Dashboard != nil && crd.Spec.Dashboard.Module { key = "tenantmodules" label = "Tenant Modules" - link = "/openapi-ui/{clusterName}/{namespace}/api-table/core.cozystack.io/v1alpha1/tenantmodules" + link = "/openapi-ui/{cluster}/{namespace}/api-table/core.cozystack.io/v1alpha1/tenantmodules" } items := []any{ diff --git a/internal/controller/dashboard/factory.go b/internal/controller/dashboard/factory.go index e55aedc7..55d719eb 100644 --- a/internal/controller/dashboard/factory.go +++ b/internal/controller/dashboard/factory.go @@ -162,7 +162,7 @@ func detailsTab(kind, endpoint, schemaJSON string, keysOrder [][]string) map[str "data": map[string]any{ "id": "vpc-subnets-table", "baseprefix": "/openapi-ui", - "clusterNamePartOfUrl": "{2}", + "cluster": "{2}", "customizationId": "virtualprivatecloud-subnets", "fetchUrl": "/api/clusters/{2}/k8s/api/v1/namespaces/{3}/configmaps", "fieldSelector": map[string]any{ @@ -190,7 +190,7 @@ func detailsTab(kind, endpoint, schemaJSON string, keysOrder [][]string) map[str "data": map[string]any{ "id": "resource-quotas-table", "baseprefix": "/openapi-ui", - "clusterNamePartOfUrl": "{2}", + "cluster": "{2}", "customizationId": "factory-resource-quotas", "fetchUrl": "/api/clusters/{2}/k8s/api/v1/namespaces/{reqsJsonPath[0]['.status.namespace']}/resourcequotas", "pathToItems": []any{`items`}, @@ -244,7 +244,7 @@ func detailsTab(kind, endpoint, schemaJSON string, keysOrder [][]string) map[str "data": map[string]any{ "id": "conditions-table", "fetchUrl": endpoint, - "clusterNamePartOfUrl": "{2}", + "cluster": "{2}", "customizationId": "factory-status-conditions", "baseprefix": "/openapi-ui", "withoutControls": true, @@ -266,7 +266,7 @@ func workloadsTab(kind string) map[string]any { "data": map[string]any{ "id": "workloads-table", "fetchUrl": "/api/clusters/{2}/k8s/apis/cozystack.io/v1alpha1/namespaces/{3}/workloadmonitors", - "clusterNamePartOfUrl": "{2}", + "cluster": "{2}", "baseprefix": "/openapi-ui", "customizationId": "factory-details-v1alpha1.cozystack.io.workloadmonitors", "pathToItems": []any{"items"}, @@ -291,7 +291,7 @@ func servicesTab(kind string) map[string]any { "data": map[string]any{ "id": "services-table", "fetchUrl": "/api/clusters/{2}/k8s/api/v1/namespaces/{3}/services", - "clusterNamePartOfUrl": "{2}", + "cluster": "{2}", "baseprefix": "/openapi-ui", "customizationId": "factory-details-v1.services", "pathToItems": []any{"items"}, @@ -317,7 +317,7 @@ func ingressesTab(kind string) map[string]any { "data": map[string]any{ "id": "ingresses-table", "fetchUrl": "/api/clusters/{2}/k8s/apis/networking.k8s.io/v1/namespaces/{3}/ingresses", - "clusterNamePartOfUrl": "{2}", + "cluster": "{2}", "baseprefix": "/openapi-ui", "customizationId": "factory-details-networking.k8s.io.v1.ingresses", "pathToItems": []any{"items"}, @@ -343,7 +343,7 @@ func secretsTab(kind string) map[string]any { "data": map[string]any{ "id": "secrets-table", "fetchUrl": "/api/clusters/{2}/k8s/apis/core.cozystack.io/v1alpha1/namespaces/{3}/tenantsecrets", - "clusterNamePartOfUrl": "{2}", + "cluster": "{2}", "baseprefix": "/openapi-ui", "customizationId": "factory-details-v1alpha1.core.cozystack.io.tenantsecrets", "pathToItems": []any{"items"}, diff --git a/internal/controller/dashboard/sidebar.go b/internal/controller/dashboard/sidebar.go index a6ade387..6de5537e 100644 --- a/internal/controller/dashboard/sidebar.go +++ b/internal/controller/dashboard/sidebar.go @@ -22,8 +22,8 @@ import ( // // Menu rules: // - The first section is "Marketplace" with two hardcoded entries: -// - Marketplace (/openapi-ui/{clusterName}/{namespace}/factory/marketplace) -// - Tenant Info (/openapi-ui/{clusterName}/{namespace}/factory/info-details/info) +// - Marketplace (/openapi-ui/{cluster}/{namespace}/factory/marketplace) +// - Tenant Info (/openapi-ui/{cluster}/{namespace}/factory/info-details/info) // - All other sections are built from CRDs where spec.dashboard != nil. // - Categories are ordered strictly as: // Marketplace, IaaS, PaaS, NaaS, , Resources, Backups, Administration @@ -91,7 +91,7 @@ func (m *Manager) ensureSidebar(ctx context.Context, crd *cozyv1alpha1.Applicati // Weight (default 0) weight := def.Spec.Dashboard.Weight - link := fmt.Sprintf("/openapi-ui/{clusterName}/{namespace}/api-table/%s/%s/%s", g, v, plural) + link := fmt.Sprintf("/openapi-ui/{cluster}/{namespace}/api-table/%s/%s/%s", g, v, plural) categories[cat] = append(categories[cat], item{ Key: plural, @@ -146,7 +146,7 @@ func (m *Manager) ensureSidebar(ctx context.Context, crd *cozyv1alpha1.Applicati map[string]any{ "key": "marketplace", "label": "Marketplace", - "link": "/openapi-ui/{clusterName}/{namespace}/factory/marketplace", + "link": "/openapi-ui/{cluster}/{namespace}/factory/marketplace", }, }, }, @@ -182,17 +182,17 @@ func (m *Manager) ensureSidebar(ctx context.Context, crd *cozyv1alpha1.Applicati map[string]any{ "key": "plans", "label": "Plans", - "link": "/openapi-ui/{clusterName}/{namespace}/api-table/backups.cozystack.io/v1alpha1/plans", + "link": "/openapi-ui/{cluster}/{namespace}/api-table/backups.cozystack.io/v1alpha1/plans", }, map[string]any{ "key": "backupjobs", "label": "BackupJobs", - "link": "/openapi-ui/{clusterName}/{namespace}/api-table/backups.cozystack.io/v1alpha1/backupjobs", + "link": "/openapi-ui/{cluster}/{namespace}/api-table/backups.cozystack.io/v1alpha1/backupjobs", }, map[string]any{ "key": "backups", "label": "Backups", - "link": "/openapi-ui/{clusterName}/{namespace}/api-table/backups.cozystack.io/v1alpha1/backups", + "link": "/openapi-ui/{cluster}/{namespace}/api-table/backups.cozystack.io/v1alpha1/backups", }, }, }) @@ -205,22 +205,22 @@ func (m *Manager) ensureSidebar(ctx context.Context, crd *cozyv1alpha1.Applicati map[string]any{ "key": "info", "label": "Info", - "link": "/openapi-ui/{clusterName}/{namespace}/factory/info-details/info", + "link": "/openapi-ui/{cluster}/{namespace}/factory/info-details/info", }, map[string]any{ "key": "modules", "label": "Modules", - "link": "/openapi-ui/{clusterName}/{namespace}/api-table/core.cozystack.io/v1alpha1/tenantmodules", + "link": "/openapi-ui/{cluster}/{namespace}/api-table/core.cozystack.io/v1alpha1/tenantmodules", }, map[string]any{ "key": "loadbalancer-services", "label": "External IPs", - "link": "/openapi-ui/{clusterName}/{namespace}/factory/external-ips", + "link": "/openapi-ui/{cluster}/{namespace}/factory/external-ips", }, map[string]any{ "key": "tenants", "label": "Tenants", - "link": "/openapi-ui/{clusterName}/{namespace}/api-table/apps.cozystack.io/v1alpha1/tenants", + "link": "/openapi-ui/{cluster}/{namespace}/api-table/apps.cozystack.io/v1alpha1/tenants", }, }, }) diff --git a/internal/controller/dashboard/static_refactored.go b/internal/controller/dashboard/static_refactored.go index b2ace34b..2b5e5552 100644 --- a/internal/controller/dashboard/static_refactored.go +++ b/internal/controller/dashboard/static_refactored.go @@ -17,43 +17,43 @@ func CreateAllBreadcrumbs() []*dashboardv1alpha1.Breadcrumb { return []*dashboardv1alpha1.Breadcrumb{ // Stock project factory configmap details createBreadcrumb("stock-project-factory-configmap-details", []map[string]any{ - createBreadcrumbItem("configmaps", "v1/configmaps", "/openapi-ui/{clusterName}/{namespace}/builtin-table/configmaps"), + createBreadcrumbItem("configmaps", "v1/configmaps", "/openapi-ui/{cluster}/{namespace}/builtin-table/configmaps"), createBreadcrumbItem("configmap", "{6}"), }), // Stock cluster factory namespace details createBreadcrumb("stock-cluster-factory-namespace-details", []map[string]any{ - createBreadcrumbItem("namespaces", "v1/namespaces", "/openapi-ui/{clusterName}/builtin-table/namespaces"), + createBreadcrumbItem("namespaces", "v1/namespaces", "/openapi-ui/{cluster}/builtin-table/namespaces"), createBreadcrumbItem("namespace", "{5}"), }), // Stock cluster factory node details createBreadcrumb("stock-cluster-factory-node-details", []map[string]any{ - createBreadcrumbItem("node", "v1/nodes", "/openapi-ui/{clusterName}/builtin-table/nodes"), + createBreadcrumbItem("node", "v1/nodes", "/openapi-ui/{cluster}/builtin-table/nodes"), createBreadcrumbItem("node", "{5}"), }), // Stock project factory pod details createBreadcrumb("stock-project-factory-pod-details", []map[string]any{ - createBreadcrumbItem("pods", "v1/pods", "/openapi-ui/{clusterName}/{namespace}/builtin-table/pods"), + createBreadcrumbItem("pods", "v1/pods", "/openapi-ui/{cluster}/{namespace}/builtin-table/pods"), createBreadcrumbItem("pod", "{6}"), }), // Stock project factory secret details createBreadcrumb("stock-project-factory-kube-secret-details", []map[string]any{ - createBreadcrumbItem("secrets", "v1/secrets", "/openapi-ui/{clusterName}/{namespace}/builtin-table/secrets"), + createBreadcrumbItem("secrets", "v1/secrets", "/openapi-ui/{cluster}/{namespace}/builtin-table/secrets"), createBreadcrumbItem("secret", "{6}"), }), // Stock project factory service details createBreadcrumb("stock-project-factory-kube-service-details", []map[string]any{ - createBreadcrumbItem("services", "v1/services", "/openapi-ui/{clusterName}/{namespace}/builtin-table/services"), + createBreadcrumbItem("services", "v1/services", "/openapi-ui/{cluster}/{namespace}/builtin-table/services"), createBreadcrumbItem("service", "{6}"), }), // Stock project factory ingress details createBreadcrumb("stock-project-factory-kube-ingress-details", []map[string]any{ - createBreadcrumbItem("ingresses", "networking.k8s.io/v1/ingresses", "/openapi-ui/{clusterName}/{namespace}/builtin-table/ingresses"), + createBreadcrumbItem("ingresses", "networking.k8s.io/v1/ingresses", "/openapi-ui/{cluster}/{namespace}/builtin-table/ingresses"), createBreadcrumbItem("ingress", "{6}"), }), @@ -64,13 +64,13 @@ func CreateAllBreadcrumbs() []*dashboardv1alpha1.Breadcrumb { // Stock cluster api form createBreadcrumb("stock-cluster-api-form", []map[string]any{ - createBreadcrumbItem("create-api-res-namespaced-table", "{apiGroup}/{apiVersion}/{typeName}", "/openapi-ui/{clusterName}/api-table/{apiGroup}/{apiVersion}/{typeName}"), + createBreadcrumbItem("create-api-res-namespaced-table", "{apiGroup}/{apiVersion}/{typeName}", "/openapi-ui/{cluster}/api-table/{apiGroup}/{apiVersion}/{typeName}"), createBreadcrumbItem("create-api-res-namespaced-typename", "Create"), }), // Stock cluster api form edit createBreadcrumb("stock-cluster-api-form-edit", []map[string]any{ - createBreadcrumbItem("create-api-res-namespaced-table", "{apiGroup}/{apiVersion}/{typeName}", "/openapi-ui/{clusterName}/api-table/{apiGroup}/{apiVersion}/{typeName}"), + createBreadcrumbItem("create-api-res-namespaced-table", "{apiGroup}/{apiVersion}/{typeName}", "/openapi-ui/{cluster}/api-table/{apiGroup}/{apiVersion}/{typeName}"), createBreadcrumbItem("create-api-res-namespaced-typename", "Update"), }), @@ -81,13 +81,13 @@ func CreateAllBreadcrumbs() []*dashboardv1alpha1.Breadcrumb { // Stock cluster builtin form createBreadcrumb("stock-cluster-builtin-form", []map[string]any{ - createBreadcrumbItem("create-api-res-namespaced-table", "v1/{typeName}", "/openapi-ui/{clusterName}/builtin-table/{typeName}"), + createBreadcrumbItem("create-api-res-namespaced-table", "v1/{typeName}", "/openapi-ui/{cluster}/builtin-table/{typeName}"), createBreadcrumbItem("create-api-res-namespaced-typename", "Create"), }), // Stock cluster builtin form edit createBreadcrumb("stock-cluster-builtin-form-edit", []map[string]any{ - createBreadcrumbItem("create-api-res-namespaced-table", "v1/{typeName}", "/openapi-ui/{clusterName}/builtin-table/{typeName}"), + createBreadcrumbItem("create-api-res-namespaced-table", "v1/{typeName}", "/openapi-ui/{cluster}/builtin-table/{typeName}"), createBreadcrumbItem("create-api-res-namespaced-typename", "Update"), }), @@ -98,13 +98,13 @@ func CreateAllBreadcrumbs() []*dashboardv1alpha1.Breadcrumb { // Stock project api form createBreadcrumb("stock-project-api-form", []map[string]any{ - createBreadcrumbItem("create-api-res-namespaced-table", "{apiGroup}/{apiVersion}/{typeName}", "/openapi-ui/{clusterName}/{namespace}/api-table/{apiGroup}/{apiVersion}/{typeName}"), + createBreadcrumbItem("create-api-res-namespaced-table", "{apiGroup}/{apiVersion}/{typeName}", "/openapi-ui/{cluster}/{namespace}/api-table/{apiGroup}/{apiVersion}/{typeName}"), createBreadcrumbItem("create-api-res-namespaced-typename", "Create"), }), // Stock project api form edit createBreadcrumb("stock-project-api-form-edit", []map[string]any{ - createBreadcrumbItem("create-api-res-namespaced-table", "{apiGroup}/{apiVersion}/{typeName}", "/openapi-ui/{clusterName}/{namespace}/api-table/{apiGroup}/{apiVersion}/{typeName}"), + createBreadcrumbItem("create-api-res-namespaced-table", "{apiGroup}/{apiVersion}/{typeName}", "/openapi-ui/{cluster}/{namespace}/api-table/{apiGroup}/{apiVersion}/{typeName}"), createBreadcrumbItem("create-api-res-namespaced-typename", "Update"), }), @@ -115,13 +115,13 @@ func CreateAllBreadcrumbs() []*dashboardv1alpha1.Breadcrumb { // Stock project builtin form createBreadcrumb("stock-project-builtin-form", []map[string]any{ - createBreadcrumbItem("create-api-res-namespaced-table", "v1/{typeName}", "/openapi-ui/{clusterName}/{namespace}/builtin-table/{typeName}"), + createBreadcrumbItem("create-api-res-namespaced-table", "v1/{typeName}", "/openapi-ui/{cluster}/{namespace}/builtin-table/{typeName}"), createBreadcrumbItem("create-api-res-namespaced-typename", "Create"), }), // Stock project builtin form edit createBreadcrumb("stock-project-builtin-form-edit", []map[string]any{ - createBreadcrumbItem("create-api-res-namespaced-table", "v1/{typeName}", "/openapi-ui/{clusterName}/{namespace}/builtin-table/{typeName}"), + createBreadcrumbItem("create-api-res-namespaced-table", "v1/{typeName}", "/openapi-ui/{cluster}/{namespace}/builtin-table/{typeName}"), createBreadcrumbItem("create-api-res-namespaced-typename", "Update"), }), } @@ -516,11 +516,11 @@ func CreateAllFactories() []*dashboardv1alpha1.Factory { "data": map[string]any{ "baseApiVersion": "v1alpha1", "baseprefix": "openapi-ui", - "clusterNamePartOfUrl": "{2}", + "cluster": "{2}", "id": 311, - "mpResourceKind": "MarketplacePanel", - "mpResourceName": "marketplacepanels", - "namespacePartOfUrl": "{3}", + "marketplaceKind": "MarketplacePanel", + "marketplacePlural": "marketplacepanels", + "namespace": "{3}", "baseApiGroup": "dashboard.cozystack.io", }, "type": "MarketplaceCard", @@ -1066,7 +1066,7 @@ func CreateAllFactories() []*dashboardv1alpha1.Factory { "data": map[string]any{ "id": "service-port-mapping-table", "baseprefix": "/openapi-ui", - "clusterNamePartOfUrl": "{2}", + "cluster": "{2}", "customizationId": "factory-kube-service-details-port-mapping", "fetchUrl": "/api/clusters/{2}/k8s/api/v1/namespaces/{3}/services/{6}", "pathToItems": ".spec.ports", @@ -1092,7 +1092,7 @@ func CreateAllFactories() []*dashboardv1alpha1.Factory { "data": map[string]any{ "id": "service-pod-serving-table", "baseprefix": "/openapi-ui", - "clusterNamePartOfUrl": "{2}", + "cluster": "{2}", "customizationId": "factory-kube-service-details-endpointslice", "fetchUrl": "/api/clusters/{2}/k8s/apis/discovery.k8s.io/v1/namespaces/{3}/endpointslices", "labelSelector": map[string]any{ @@ -1149,7 +1149,7 @@ func CreateAllFactories() []*dashboardv1alpha1.Factory { "data": map[string]any{ "id": "pods-table", "baseprefix": "/openapi-ui", - "clusterNamePartOfUrl": "{2}", + "cluster": "{2}", "customizationId": "factory-node-details-/v1/pods", "fetchUrl": "/api/clusters/{2}/k8s/api/v1/namespaces/{3}/pods", "labelSelectorFull": map[string]any{ @@ -1281,7 +1281,7 @@ func CreateAllFactories() []*dashboardv1alpha1.Factory { "data": map[string]any{ "id": "rules-table", "fetchUrl": "/api/clusters/{2}/k8s/apis/networking.k8s.io/v1/namespaces/{3}/ingresses/{6}", - "clusterNamePartOfUrl": "{2}", + "cluster": "{2}", "customizationId": "factory-kube-ingress-details-rules", "baseprefix": "/openapi-ui", "withoutControls": true, @@ -1433,7 +1433,7 @@ func CreateAllFactories() []*dashboardv1alpha1.Factory { "data": map[string]any{ "id": "workloads-table", "baseprefix": "/openapi-ui", - "clusterNamePartOfUrl": "{2}", + "cluster": "{2}", "customizationId": "factory-details-v1alpha1.cozystack.io.workloads", "fetchUrl": "/api/clusters/{2}/k8s/apis/cozystack.io/v1alpha1/namespaces/{3}/workloads", "labelSelector": map[string]any{ @@ -1904,7 +1904,7 @@ func CreateAllFactories() []*dashboardv1alpha1.Factory { "data": map[string]any{ "id": "external-ips-table", "fetchUrl": "/api/clusters/{2}/k8s/api/v1/namespaces/{3}/services", - "clusterNamePartOfUrl": "{2}", + "cluster": "{2}", "baseprefix": "/openapi-ui", "customizationId": "factory-details-v1.services", "pathToItems": []any{"items"}, From e62a6d2a8edbdee485268f5f961d8acfd34cf1d9 Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Fri, 6 Feb 2026 01:43:08 +0100 Subject: [PATCH 2/3] feat(dashboard): upgrade to upstream release/1.3.0 Switch from cozystack fork to PRO-Robotech upstream release/1.3.0: - openapi-k8s-toolkit: PRO-Robotech/openapi-k8s-toolkit@9f2b163 - openapi-ui: PRO-Robotech/openapi-ui@94cdf52 - openapi-ui-k8s-bff: PRO-Robotech/openapi-ui-k8s-bff@ff35916 Remove flatmap-dynamic-key.diff patch (already included upstream). Update tenantmodules.diff patch for renamed field names. Co-Authored-By: Claude Signed-off-by: Andrei Kvapil --- .../images/openapi-ui-k8s-bff/Dockerfile | 4 +- .../dashboard/images/openapi-ui/Dockerfile | 10 +-- .../patches/flatmap-dynamic-key.diff | 90 ------------------- .../patches/tenantmodules.diff | 26 ++---- 4 files changed, 15 insertions(+), 115 deletions(-) delete mode 100644 packages/system/dashboard/images/openapi-ui/openapi-k8s-toolkit/patches/flatmap-dynamic-key.diff diff --git a/packages/system/dashboard/images/openapi-ui-k8s-bff/Dockerfile b/packages/system/dashboard/images/openapi-ui-k8s-bff/Dockerfile index ef698698..1edeed2f 100644 --- a/packages/system/dashboard/images/openapi-ui-k8s-bff/Dockerfile +++ b/packages/system/dashboard/images/openapi-ui-k8s-bff/Dockerfile @@ -1,9 +1,9 @@ -# imported from https://github.com/cozystack/openapi-ui-k8s-bff +# imported from https://github.com/PRO-Robotech/openapi-ui-k8s-bff (release/1.3.0) ARG NODE_VERSION=20.18.1 FROM node:${NODE_VERSION}-alpine AS builder WORKDIR /src -ARG COMMIT_REF=183dc9dcbb0f8a1833dad642c35faa385c71e58d +ARG COMMIT_REF=ff359166163b263b51f51c57f416a9feb1a7e02a RUN wget -O- https://github.com/PRO-Robotech/openapi-ui-k8s-bff/archive/${COMMIT_REF}.tar.gz | tar xzf - --strip-components=1 ENV PATH=/src/node_modules/.bin:$PATH diff --git a/packages/system/dashboard/images/openapi-ui/Dockerfile b/packages/system/dashboard/images/openapi-ui/Dockerfile index f430f8e6..d328ea7a 100644 --- a/packages/system/dashboard/images/openapi-ui/Dockerfile +++ b/packages/system/dashboard/images/openapi-ui/Dockerfile @@ -1,12 +1,12 @@ ARG NODE_VERSION=20.18.1 # openapi-k8s-toolkit -# imported from https://github.com/cozystack/openapi-k8s-toolkit +# imported from https://github.com/PRO-Robotech/openapi-k8s-toolkit (release/1.3.0) FROM node:${NODE_VERSION}-alpine AS openapi-k8s-toolkit-builder RUN apk add git WORKDIR /src -ARG COMMIT=cb2f122caafaa2fd5455750213d9e633017ec555 -RUN wget -O- https://github.com/cozystack/openapi-k8s-toolkit/archive/${COMMIT}.tar.gz | tar -xzvf- --strip-components=1 +ARG COMMIT=9f2b163e657e932dee6c70b9ff5969e98779c5b1 +RUN wget -O- https://github.com/PRO-Robotech/openapi-k8s-toolkit/archive/${COMMIT}.tar.gz | tar -xzvf- --strip-components=1 COPY openapi-k8s-toolkit/patches /patches RUN git apply /patches/*.diff @@ -17,12 +17,12 @@ RUN npm run build # openapi-ui -# imported from https://github.com/cozystack/openapi-ui +# imported from https://github.com/PRO-Robotech/openapi-ui (release/1.3.0) FROM node:${NODE_VERSION}-alpine AS builder #RUN apk add git WORKDIR /src -ARG COMMIT_REF=3cfbbf2156b6a5e4a1f283a032019530c0c2d37d +ARG COMMIT_REF=94cdf528ff5674c34cb856ac808b99bd393268c9 RUN wget -O- https://github.com/PRO-Robotech/openapi-ui/archive/${COMMIT_REF}.tar.gz | tar xzf - --strip-components=1 #COPY openapi-ui/patches /patches diff --git a/packages/system/dashboard/images/openapi-ui/openapi-k8s-toolkit/patches/flatmap-dynamic-key.diff b/packages/system/dashboard/images/openapi-ui/openapi-k8s-toolkit/patches/flatmap-dynamic-key.diff deleted file mode 100644 index 03212014..00000000 --- a/packages/system/dashboard/images/openapi-ui/openapi-k8s-toolkit/patches/flatmap-dynamic-key.diff +++ /dev/null @@ -1,90 +0,0 @@ -diff --git a/src/components/molecules/EnrichedTable/organisms/EnrichedTableProvider/utils.ts b/src/components/molecules/EnrichedTable/organisms/EnrichedTableProvider/utils.ts -index 87a0f12..fb2e1cc 100644 ---- a/src/components/molecules/EnrichedTable/organisms/EnrichedTableProvider/utils.ts -+++ b/src/components/molecules/EnrichedTable/organisms/EnrichedTableProvider/utils.ts -@@ -134,22 +134,6 @@ export const prepare = ({ - // impossible in k8s - return {} - }) -- if (customFields.length > 0) { -- dataSource = dataSource.map((el: TJSON) => { -- const newFieldsForComplexJsonPath: Record = {} -- customFields.forEach(({ dataIndex, jsonPath }) => { -- const jpQueryResult = jp.query(el, `$${jsonPath}`) -- newFieldsForComplexJsonPath[dataIndex] = -- Array.isArray(jpQueryResult) && jpQueryResult.length === 1 ? jpQueryResult[0] : jpQueryResult -- }) -- if (typeof el === 'object') { -- return { ...el, ...newFieldsForComplexJsonPath } -- } -- // impossible in k8s -- return { ...newFieldsForComplexJsonPath } -- }) -- } -- - // Handle flatMap: expand rows for map objects - // Process all flatMap columns sequentially - if (flatMapColumns.length > 0 && dataSource) { -@@ -204,6 +188,62 @@ export const prepare = ({ - currentDataSource = expandedDataSource - }) - dataSource = currentDataSource -+ } -+ -+ if (customFields.length > 0) { -+ dataSource = dataSource.map((el: TJSON) => { -+ const newFieldsForComplexJsonPath: Record = {} -+ customFields.forEach(({ dataIndex, jsonPath }) => { -+ let fieldValue: TJSON = null -+ let handled = false -+ -+ const flatMapMatch = jsonPath.match(/^(.*)\[(_flatMap[^\]]+_Key)\](.*)$/) -+ if (flatMapMatch && el && typeof el === 'object' && !Array.isArray(el)) { -+ const basePath = flatMapMatch[1] -+ const keyField = flatMapMatch[2] -+ const tailPath = flatMapMatch[3] -+ const keyValue = (el as Record)[keyField] -+ if (keyValue !== null && keyValue !== undefined) { -+ const baseResult = jp.query(el, `$${basePath}`)[0] -+ if (baseResult && typeof baseResult === 'object' && !Array.isArray(baseResult)) { -+ const baseValue = (baseResult as Record)[String(keyValue)] -+ if (tailPath) { -+ const normalizedTailPath = -+ tailPath.startsWith('.') || tailPath.startsWith('[') ? tailPath : `.${tailPath}` -+ const tailResult = jp.query(baseValue, `$${normalizedTailPath}`) -+ fieldValue = Array.isArray(tailResult) && tailResult.length === 1 ? tailResult[0] : tailResult -+ } else { -+ fieldValue = baseValue as TJSON -+ } -+ handled = true -+ } -+ } -+ } -+ -+ if (!handled) { -+ let resolvedJsonPath = jsonPath -+ if (el && typeof el === 'object' && !Array.isArray(el)) { -+ resolvedJsonPath = jsonPath.replace(/\[(_flatMap[^\]]+_Key)\]/g, (match, keyField) => { -+ const keyValue = (el as Record)[keyField] -+ if (keyValue === null || keyValue === undefined) { -+ return match -+ } -+ const escaped = String(keyValue).replace(/'/g, "\\'") -+ return `['${escaped}']` -+ }) -+ } -+ const jpQueryResult = jp.query(el, `$${resolvedJsonPath}`) -+ fieldValue = Array.isArray(jpQueryResult) && jpQueryResult.length === 1 ? jpQueryResult[0] : jpQueryResult -+ } -+ -+ newFieldsForComplexJsonPath[dataIndex] = fieldValue -+ }) -+ if (typeof el === 'object') { -+ return { ...el, ...newFieldsForComplexJsonPath } -+ } -+ // impossible in k8s -+ return { ...newFieldsForComplexJsonPath } -+ }) - } - } else { - dataSource = dataItems.map((el: TJSON) => { diff --git a/packages/system/dashboard/images/openapi-ui/openapi-k8s-toolkit/patches/tenantmodules.diff b/packages/system/dashboard/images/openapi-ui/openapi-k8s-toolkit/patches/tenantmodules.diff index 2817a8e0..8175e208 100644 --- a/packages/system/dashboard/images/openapi-ui/openapi-k8s-toolkit/patches/tenantmodules.diff +++ b/packages/system/dashboard/images/openapi-ui/openapi-k8s-toolkit/patches/tenantmodules.diff @@ -1,11 +1,10 @@ diff --git a/src/components/molecules/EnrichedTable/organisms/EnrichedTable/utils.tsx b/src/components/molecules/EnrichedTable/organisms/EnrichedTable/utils.tsx -index 8bcef4d..2551e92 100644 --- a/src/components/molecules/EnrichedTable/organisms/EnrichedTable/utils.tsx +++ b/src/components/molecules/EnrichedTable/organisms/EnrichedTable/utils.tsx -@@ -22,6 +22,15 @@ import { TableFactory } from '../../molecules' +@@ -20,6 +20,15 @@ import { TableFactory } from '../../molecules' import { ShortenedTextWithTooltip, FilterDropdown, TrimmedTags, TextAlignContainer, TinyButton } from './atoms' import { TInternalDataForControls } from './types' - + +const getPluralForm = (singular: string): string => { + // If already ends with 's', add 'es' + if (singular.endsWith('s')) { @@ -18,33 +17,24 @@ index 8bcef4d..2551e92 100644 export const getCellRender = ({ value, record, -@@ -255,7 +264,7 @@ export const getEnrichedColumnsWithControls = ({ - key: 'controls', - className: 'controls', - width: 60, -- render: (value: TInternalDataForControls) => { -+ render: (value: TInternalDataForControls, record: unknown) => { - return ( - // - @@ -279,10 +288,19 @@ export const getEnrichedColumnsWithControls = ({ domEvent.stopPropagation() domEvent.preventDefault() if (key === 'edit') { + // Special case: redirect tenantmodules from core.cozystack.io to apps.cozystack.io with plural form + let apiGroupAndVersion = value.apiGroupAndVersion -+ let typeName = value.typeName -+ if (apiGroupAndVersion?.startsWith('core.cozystack.io/') && typeName === 'tenantmodules') { ++ let plural = value.plural ++ if (apiGroupAndVersion?.startsWith('core.cozystack.io/') && plural === 'tenantmodules') { + const appsApiVersion = apiGroupAndVersion.replace('core.cozystack.io/', 'apps.cozystack.io/') -+ const pluralTypeName = getPluralForm(value.entryName) ++ const pluralTypeName = getPluralForm(value.name) + apiGroupAndVersion = appsApiVersion -+ typeName = pluralTypeName ++ plural = pluralTypeName + } navigate( `${baseprefix}/${value.cluster}${value.namespace ? `/${value.namespace}` : ''}${ value.syntheticProject ? `/${value.syntheticProject}` : '' -- }/${value.pathPrefix}/${value.apiGroupAndVersion}/${value.typeName}/${value.entryName}?backlink=${ -+ }/${value.pathPrefix}/${apiGroupAndVersion}/${typeName}/${value.entryName}?backlink=${ +- }/${value.pathPrefix}/${value.apiGroupAndVersion}/${value.plural}/${value.name}?backlink=${ ++ }/${value.pathPrefix}/${apiGroupAndVersion}/${plural}/${value.name}?backlink=${ value.backlink }`, ) From d33630ad1ec0cd85960bddef7f91302f29d6185d Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Fri, 6 Feb 2026 01:43:16 +0100 Subject: [PATCH 3/3] feat(dashboard): add local frontend development environment Add hack/frontend-dev.sh script and dev-nginx.conf for local frontend development. The script clones upstream repos, builds patched toolkit, and starts nginx + BFF + Vite dev server with production-like routing. Co-Authored-By: Claude Signed-off-by: Andrei Kvapil --- packages/system/dashboard/Makefile | 3 + packages/system/dashboard/hack/.gitignore | 5 + packages/system/dashboard/hack/dev-nginx.conf | 90 +++++++++ .../system/dashboard/hack/frontend-dev.sh | 189 ++++++++++++++++++ 4 files changed, 287 insertions(+) create mode 100644 packages/system/dashboard/hack/.gitignore create mode 100644 packages/system/dashboard/hack/dev-nginx.conf create mode 100755 packages/system/dashboard/hack/frontend-dev.sh diff --git a/packages/system/dashboard/Makefile b/packages/system/dashboard/Makefile index a9f680a3..340e7ff9 100644 --- a/packages/system/dashboard/Makefile +++ b/packages/system/dashboard/Makefile @@ -7,6 +7,9 @@ include ../../../hack/package.mk update: update-crd update-dockerfiles image: image-openapi-ui image-openapi-ui-k8s-bff image-token-proxy update-tenant-text +frontend-dev: + hack/frontend-dev.sh + update-dockerfiles: @echo Update dockerfiles manually diff --git a/packages/system/dashboard/hack/.gitignore b/packages/system/dashboard/hack/.gitignore new file mode 100644 index 00000000..0028f830 --- /dev/null +++ b/packages/system/dashboard/hack/.gitignore @@ -0,0 +1,5 @@ +# Cloned repos (created by frontend-dev.sh) +openapi-k8s-toolkit/ +openapi-ui/ +openapi-ui-k8s-bff/ +.toolkit-patched diff --git a/packages/system/dashboard/hack/dev-nginx.conf b/packages/system/dashboard/hack/dev-nginx.conf new file mode 100644 index 00000000..442435eb --- /dev/null +++ b/packages/system/dashboard/hack/dev-nginx.conf @@ -0,0 +1,90 @@ +worker_processes 1; +daemon off; +error_log /dev/stderr warn; + +events { + worker_connections 4096; +} + +http { + access_log /dev/stderr; + + map $http_upgrade $connection_upgrade { + default upgrade; + '' close; + } + + server { + listen 8080 default_server; + + # Static cluster list + location ~ ^(/clusterlist|/api/clusters)$ { + add_header Content-Type application/json; + return 200 '[{"api":"localhost","baseDomain":"localhost","description":"local","externalDomain":"localhost","name":"default","tenant":"dev"}]'; + } + + # Strip /api/clusters/default prefix and self-proxy (same as production) + location /api/clusters/default { + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + proxy_read_timeout 86400s; + proxy_send_timeout 86400s; + + rewrite /api/clusters/default/(.*) /$1 break; + proxy_pass http://127.0.0.1:8080; + } + + # K8s API via kubectl proxy + location /k8s { + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + proxy_read_timeout 86400s; + proxy_send_timeout 86400s; + proxy_buffering off; + + rewrite /k8s/(.*) /$1 break; + proxy_pass http://127.0.0.1:8081; + } + + # BFF HTTP + location /openapi-bff { + proxy_pass http://127.0.0.1:4002; + } + + # BFF WebSocket + location /openapi-bff-ws/ { + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + proxy_read_timeout 86400s; + proxy_send_timeout 86400s; + + proxy_pass http://127.0.0.1:4002; + } + + # Redirect / to /openapi-ui + location = / { + return 301 /openapi-ui; + } + + # Frontend (Vite dev server) — catch-all with WebSocket support for HMR + location / { + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + + proxy_pass http://127.0.0.1:4001; + } + + location /healthcheck { + access_log off; + return 200 "Healthy\n"; + } + } +} diff --git a/packages/system/dashboard/hack/frontend-dev.sh b/packages/system/dashboard/hack/frontend-dev.sh new file mode 100755 index 00000000..ba2cf0a5 --- /dev/null +++ b/packages/system/dashboard/hack/frontend-dev.sh @@ -0,0 +1,189 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +DASHBOARD_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$SCRIPT_DIR" + +GREEN='\033[0;32m' +CYAN='\033[0;36m' +NC='\033[0m' + +log() { echo -e "${CYAN}==>${NC} $*"; } + +# Verify kubectl access +log "Using kubectl context: $(kubectl config current-context)" +if ! kubectl api-resources --api-group=core.cozystack.io &>/dev/null; then + echo "ERROR: Cluster does not have core.cozystack.io API group." >&2 + echo "Set KUBECONFIG to a cluster with Cozystack installed, e.g.:" >&2 + echo " KUBECONFIG=/path/to/kubeconfig $0" >&2 + exit 1 +fi + +# Cleanup on exit +cleanup() { + echo "" + log "Stopping all services..." + kill 0 2>/dev/null +} +trap cleanup EXIT + +# --- Clone repositories --- +clone_repo() { + local url="$1" dir="$2" branch="${3:-}" + if [ ! -d "$dir/.git" ]; then + log "Cloning $dir..." + git clone "$url" "$dir" + fi + if [ -n "$branch" ]; then + git -C "$dir" checkout "$branch" 2>/dev/null || true + fi +} + +log "Preparing repositories..." +clone_repo git@github.com:PRO-Robotech/openapi-ui.git openapi-ui release/1.3.0 +clone_repo git@github.com:PRO-Robotech/openapi-ui-k8s-bff.git openapi-ui-k8s-bff release/1.3.0 +clone_repo git@github.com:PRO-Robotech/openapi-k8s-toolkit.git openapi-k8s-toolkit release/1.3.0 + +# --- Install dependencies --- +log "Installing dependencies..." +for dir in openapi-ui openapi-ui-k8s-bff openapi-k8s-toolkit; do + if [ ! -d "$dir/node_modules" ]; then + log " npm install in $dir" + (cd "$dir" && npm install --silent) + fi +done + +# --- Build patched toolkit --- +PATCHES_DIR="$DASHBOARD_DIR/images/openapi-ui/openapi-k8s-toolkit/patches" +UI_TOOLKIT_DIST="openapi-ui/node_modules/@prorobotech/openapi-k8s-toolkit/dist" + +if [ ! -f .toolkit-patched ]; then + log "Building patched openapi-k8s-toolkit..." + ( + cd openapi-k8s-toolkit + git checkout -- . 2>/dev/null || true + for patch in "$PATCHES_DIR"/*.diff; do + [ -f "$patch" ] && git apply "$patch" && log " Applied $(basename "$patch")" + done + npm run build --silent + ) + log "Replacing toolkit in openapi-ui node_modules..." + rm -rf "$UI_TOOLKIT_DIST" + cp -r openapi-k8s-toolkit/dist "$UI_TOOLKIT_DIST" + touch .toolkit-patched +fi + +# --- Prepare UI --- +mkdir -p openapi-ui/public + +if [ ! -f openapi-ui/.env.options ]; then + log "Creating openapi-ui/.env.options..." + sed 's|^KUBE_API_URL=.*|KUBE_API_URL=http://localhost:8080|;s|^BFF_URL=.*|BFF_URL=http://localhost:4002|' \ + openapi-ui/.env.options.dist > openapi-ui/.env.options +fi + +if [ ! -f openapi-ui/public/env.js ]; then + log "Generating openapi-ui/public/env.js..." + cat > openapi-ui/public/env.js <<'ENVJS' +window._env_ = { + BASEPREFIX: "/openapi-ui", + HIDE_INSIDE: "true", + CUSTOMIZATION_API_GROUP: "dashboard.cozystack.io", + CUSTOMIZATION_API_VERSION: "v1alpha1", + CUSTOMIZATION_NAVIGATION_RESOURCE: "navigation", + CUSTOMIZATION_NAVIGATION_RESOURCE_NAME: "navigations", + INSTANCES_API_GROUP: "dashboard.cozystack.io", + INSTANCES_RESOURCE_NAME: "instances", + INSTANCES_VERSION: "v1alpha1", + MARKETPLACE_GROUP: "dashboard.cozystack.io", + MARKETPLACE_KIND: "MarketplacePanel", + MARKETPLACE_RESOURCE_NAME: "marketplacepanels", + MARKETPLACE_VERSION: "v1alpha1", + NAVIGATE_FROM_CLUSTERLIST: "/openapi-ui/~recordValue~/api-table/core.cozystack.io/v1alpha1/tenantnamespaces", + PROJECTS_API_GROUP: "core.cozystack.io", + PROJECTS_RESOURCE_NAME: "tenantnamespaces", + PROJECTS_VERSION: "v1alpha1", + CUSTOM_NAMESPACE_API_RESOURCE_API_GROUP: "core.cozystack.io", + CUSTOM_NAMESPACE_API_RESOURCE_API_VERSION: "v1alpha1", + CUSTOM_NAMESPACE_API_RESOURCE_RESOURCE_NAME: "tenantnamespaces", + USE_NAMESPACE_NAV: "true", + TITLE_TEXT: "Cozystack Dashboard", + FOOTER_TEXT: "Cozystack", + CUSTOM_TENANT_TEXT: "dev", + THEME_TOKENS_COLORS_DARK: { + "colorText": "rgba(232, 236, 244, 1)", + "colorTextSecondary": "rgba(167, 177, 196, 1)", + "colorTextDisabled": "rgba(109, 119, 136, 1)", + "colorTextTertiary": "rgba(15, 17, 21, 1)", + "colorBgLayout": "rgba(15, 17, 21, 1)", + "colorBgContainer": "rgba(23, 26, 32, 1)", + "colorBgSpotlight": "rgba(30, 34, 43, 1)", + "colorBgElevated": "rgba(35, 40, 50, 1)", + "colorBorder": "rgba(42, 49, 60, 1)", + "colorBgMask": "rgba(0, 0, 0, 0.55)", + "colorPrimaryHover": "rgba(37, 108, 219, 1)", + "colorPrimary": "rgba(58, 134, 255, 1)", + "colorPrimaryActive": "rgba(27, 86, 178, 1)", + "colorPrimaryBg": "rgba(54, 134, 255, 0.13)", + "colorSuccess": "rgba(61, 209, 138, 1)", + "colorWarning": "rgba(245, 165, 36, 1)", + "colorError": "rgba(240, 81, 77, 1)", + "colorInfo": "rgba(108, 166, 255, 1)" + }, + THEME_TOKENS_SIZES: {"borderRadius": 6}, + THEME_TOKENS_COMPONENTS_LIGHT: {"Layout": {}}, + THEME_TOKENS_COMPONENTS_DARK: {"Layout": {}}, + THEME_TOKENS_USE_MERGE_STRATEGY: 'false' + } +ENVJS +fi + +# --- Prepare BFF --- +if [ ! -f openapi-ui-k8s-bff/.env ]; then + log "Generating openapi-ui-k8s-bff/.env..." + cat > openapi-ui-k8s-bff/.env <<'BFFENV' +DEV_KUBE_API_URL=http://localhost:8080/k8s +BASE_API_GROUP=dashboard.cozystack.io +BASE_API_VERSION=v1alpha1 +BASE_NAVIGATION_RESOURCE_PLURAL=navigations +BASE_NAVIGATION_RESOURCE_NAME=navigation +BASE_FRONTEND_PREFIX=/openapi-ui +BASE_NAMESPACE_FULL_PATH=/apis/core.cozystack.io/v1alpha1/tenantnamespaces +BASE_FACTORY_NAMESPACED_API_KEY=base-factory-namespaced-api +BASE_FACTORY_CLUSTERSCOPED_API_KEY=base-factory-clusterscoped-api +BASE_FACTORY_NAMESPACED_BUILTIN_KEY=base-factory-namespaced-builtin +BASE_FACTORY_CLUSTERSCOPED_BUILTIN_KEY=base-factory-clusterscoped-builtin +BASE_NAMESPACE_FACTORY_KEY=base-factory-clusterscoped-builtin +BASE_ALLOWED_AUTH_HEADERS=user-agent,accept,content-type,application/json,origin,referer,accept-encoding,cookie +BFFENV +fi + +# --- Start kubectl proxy + nginx reverse proxy --- +# nginx on :8080 mirrors the production self-proxy architecture: +# /api/clusters/default/* → rewrite → self-proxy back to :8080 +# /k8s/* → kubectl proxy on :8081 +# /openapi-bff* → BFF on :4002 +# /* → Vite dev server on :4001 +log "Starting kubectl proxy on :8081..." +kubectl proxy --port=8081 & + +log "Starting nginx reverse proxy on :8080..." +nginx -c "$SCRIPT_DIR/dev-nginx.conf" -p "$SCRIPT_DIR" & + +# --- Start BFF --- +log "Starting BFF on :4002..." +(cd openapi-ui-k8s-bff && npm run dev) & + +# --- Start UI --- +log "Starting UI on :4001..." +(cd openapi-ui && npm run dev) & + +echo "" +echo -e "${GREEN}==========================================${NC}" +echo -e "${GREEN} Cozystack Dashboard is running${NC}" +echo -e "${GREEN} http://localhost:8080/openapi-ui${NC}" +echo -e "${GREEN}==========================================${NC}" +echo "" + +wait