Compare commits

...
Sign in to create a new pull request.

3 commits

Author SHA1 Message Date
Andrei Kvapil
d33630ad1e
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 <noreply@anthropic.com>
Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
2026-02-06 01:43:16 +01:00
Andrei Kvapil
e62a6d2a8e
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 <noreply@anthropic.com>
Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
2026-02-06 01:43:08 +01:00
Andrei Kvapil
af0c54d2b0
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 <noreply@anthropic.com>
Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
2026-02-06 01:42:40 +01:00
13 changed files with 349 additions and 162 deletions

View file

@ -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

View file

@ -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{

View file

@ -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"},

View file

@ -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, <others A→Z>, 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",
},
},
})

View file

@ -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"},

View file

@ -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

View file

@ -0,0 +1,5 @@
# Cloned repos (created by frontend-dev.sh)
openapi-k8s-toolkit/
openapi-ui/
openapi-ui-k8s-bff/
.toolkit-patched

View file

@ -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";
}
}
}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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<string, TJSON> = {}
- 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<string, TJSON> = {}
+ 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<string, unknown>)[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, unknown>)[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<string, unknown>)[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) => {

View file

@ -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 (
// <TextAlignContainer $align="right" className="hideable">
<TextAlignContainer $align="center">
@@ -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
}`,
)