New dashboard based on OpenAPI schema (#1269)

A new dashboard based on https://github.com/PRO-Robotech/openapi-ui
project

<img width="1720" height="1373" alt="Screenshot 2025-08-01 at 09-01-00
OpenAPI UI"
src="https://github.com/user-attachments/assets/7ae04789-24ec-4e4b-830b-6f16e96513eb"
/>
<img width="1720" height="1373" alt="Screenshot 2025-08-01 at 09-01-14
OpenAPI UI"
src="https://github.com/user-attachments/assets/ca5aa85d-43f0-4b5b-b87a-3bc237834f10"
/>
<img width="1720" height="1373" alt="Screenshot 2025-08-01 at 09-02-05
OpenAPI UI"
src="https://github.com/user-attachments/assets/ebee7bfa-c3ac-4fe6-b5e1-43e9e7042c6a"
/>

<!-- 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.
-->

<!--  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
[cozystack-api] Implement TenantNamespace, TenantModules, TenantSecret and TenantSecretsTable resources
[cozystack-controller] Introduce new dashboard-controller
[dashboard] Introduce new dashboard based on openapi-ui
```

Signed-off-by: Timofei Larkin <lllamnyp@gmail.com>
This commit is contained in:
Andrei Kvapil
2025-09-24 20:11:41 +02:00
committed by Timofei Larkin
372 changed files with 15227 additions and 24794 deletions

View File

@@ -9,6 +9,7 @@ import (
"sync"
"time"
"github.com/cozystack/cozystack/internal/controller/dashboard"
"github.com/cozystack/cozystack/internal/shared/crdmem"
cozyv1alpha1 "github.com/cozystack/cozystack/api/v1alpha1"
@@ -50,6 +51,25 @@ func (r *CozystackResourceDefinitionReconciler) Reconcile(ctx context.Context, r
r.mem.Upsert(crd)
}
mgr := dashboard.NewManager(
r.Client,
r.Scheme,
dashboard.WithCRDListFunc(func(c context.Context) ([]cozyv1alpha1.CozystackResourceDefinition, error) {
if r.mem != nil {
return r.mem.ListFromCacheOrAPI(c, r.Client)
}
var list cozyv1alpha1.CozystackResourceDefinitionList
if err := r.Client.List(c, &list); err != nil {
return nil, err
}
return list.Items, nil
}),
)
if res, derr := mgr.EnsureForCRD(ctx, crd); derr != nil || res.Requeue || res.RequeueAfter > 0 {
return res, derr
}
r.mu.Lock()
r.lastEvent = time.Now()
r.mu.Unlock()
@@ -102,17 +122,23 @@ type crdHashView struct {
Spec cozyv1alpha1.CozystackResourceDefinitionSpec `json:"spec"`
}
func (r *CozystackResourceDefinitionReconciler) computeConfigHash() (string, error) {
if r.mem == nil {
return "", nil
func (r *CozystackResourceDefinitionReconciler) computeConfigHash(ctx context.Context) (string, error) {
var items []cozyv1alpha1.CozystackResourceDefinition
if r.mem != nil {
list, err := r.mem.ListFromCacheOrAPI(ctx, r.Client)
if err != nil {
return "", err
}
items = list
}
snapshot := r.mem.Snapshot()
sort.Slice(snapshot, func(i, j int) bool { return snapshot[i].Name < snapshot[j].Name })
views := make([]crdHashView, 0, len(snapshot))
for i := range snapshot {
sort.Slice(items, func(i, j int) bool { return items[i].Name < items[j].Name })
views := make([]crdHashView, 0, len(items))
for i := range items {
views = append(views, crdHashView{
Name: snapshot[i].Name,
Spec: snapshot[i].Spec,
Name: items[i].Name,
Spec: items[i].Spec,
})
}
b, err := json.Marshal(views)
@@ -143,7 +169,7 @@ func (r *CozystackResourceDefinitionReconciler) debouncedRestart(ctx context.Con
return ctrl.Result{}, nil
}
newHash, err := r.computeConfigHash()
newHash, err := r.computeConfigHash(ctx)
if err != nil {
return ctrl.Result{}, err
}

View File

@@ -0,0 +1,298 @@
package dashboard
import (
"context"
"crypto/sha1"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
dashv1alpha1 "github.com/cozystack/cozystack/api/dashboard/v1alpha1"
cozyv1alpha1 "github.com/cozystack/cozystack/api/v1alpha1"
apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
)
// ensureCustomColumnsOverride creates or updates a CustomColumnsOverride that
// renders a header row with a colored badge and resource name link, plus a few
// useful columns (Ready, Created, Version).
//
// Naming convention mirrors your example:
//
// metadata.name: stock-namespace-<group>.<version>.<plural>
// spec.id: stock-namespace-/<group>/<version>/<plural>
func (m *Manager) ensureCustomColumnsOverride(ctx context.Context, crd *cozyv1alpha1.CozystackResourceDefinition) (controllerutil.OperationResult, error) {
g, v, kind := pickGVK(crd)
plural := pickPlural(kind, crd)
// Details page segment uses lowercase kind, mirroring your example
detailsSegment := strings.ToLower(kind) + "-details"
name := fmt.Sprintf("stock-namespace-%s.%s.%s", g, v, plural)
id := fmt.Sprintf("stock-namespace-/%s/%s/%s", g, v, plural)
// Badge content & color derived from kind
badgeText := initialsFromKind(kind) // e.g., "VirtualMachine" -> "VM", "Bucket" -> "B"
badgeColor := hexColorForKind(kind) // deterministic, dark enough for white text
obj := &dashv1alpha1.CustomColumnsOverride{}
obj.SetGroupVersionKind(schema.GroupVersionKind{
Group: "dashboard.cozystack.io",
Version: "v1alpha1",
Kind: "CustomColumnsOverride",
})
obj.SetName(name)
href := fmt.Sprintf("/openapi-ui/{2}/{reqsJsonPath[0]['.metadata.namespace']['-']}/factory/%s/{reqsJsonPath[0]['.metadata.name']['-']}", detailsSegment)
if g == "apps.cozystack.io" && kind == "Tenant" && plural == "tenants" {
href = "/openapi-ui/{2}/{reqsJsonPath[0]['.status.namespace']['-']}/api-table/core.cozystack.io/v1alpha1/tenantmodules"
}
desired := map[string]any{
"spec": map[string]any{
"id": id,
"additionalPrinterColumns": []any{
map[string]any{
"name": "Name",
"type": "factory",
"jsonPath": ".metadata.name",
"customProps": map[string]any{
"disableEventBubbling": true,
"items": []any{
map[string]any{
"type": "antdFlex",
"data": map[string]any{
"id": "header-row",
"align": "center",
"gap": 6,
},
"children": []any{
map[string]any{
"type": "antdText",
"data": map[string]any{
"id": "header-badge",
"text": badgeText,
"title": strings.ToLower(kind), // optional tooltip
"style": map[string]any{
"backgroundColor": badgeColor,
"borderRadius": "20px",
"color": "#fff",
"display": "inline-block",
"fontFamily": "RedHatDisplay, Overpass, overpass, helvetica, arial, sans-serif",
"fontSize": "15px",
"fontWeight": 400,
"lineHeight": "24px",
"minWidth": 24,
"padding": "0 9px",
"textAlign": "center",
"whiteSpace": "nowrap",
},
},
},
map[string]any{
"type": "antdLink",
"data": map[string]any{
"id": "name-link",
"text": "{reqsJsonPath[0]['.metadata.name']['-']}",
"href": href,
},
},
},
},
},
},
},
map[string]any{
"name": "Ready",
"type": "Boolean",
"jsonPath": `.status.conditions[?(@.type=="Ready")].status`,
},
map[string]any{
"name": "Created",
"type": "factory",
"jsonPath": ".metadata.creationTimestamp",
"customProps": map[string]any{
"disableEventBubbling": true,
"items": []any{
map[string]any{
"type": "antdFlex",
"data": map[string]any{
"id": "time-block",
"align": "center",
"gap": 6,
},
"children": []any{
map[string]any{
"type": "antdText",
"data": map[string]any{
"id": "time-icon",
"text": "🌐",
},
},
map[string]any{
"type": "parsedText",
"data": map[string]any{
"id": "time-value",
"text": "{reqsJsonPath[0]['.metadata.creationTimestamp']['-']}",
"formatter": "timestamp",
},
},
},
},
},
},
},
map[string]any{
"name": "Version",
"type": "string",
"jsonPath": ".status.version",
},
},
},
}
// CreateOrUpdate using typed resource
_, err := controllerutil.CreateOrUpdate(ctx, m.client, obj, func() error {
if err := controllerutil.SetOwnerReference(crd, obj, m.scheme); err != nil {
return err
}
b, err := json.Marshal(desired["spec"])
if err != nil {
return err
}
obj.Spec = dashv1alpha1.ArbitrarySpec{JSON: apiextv1.JSON{Raw: b}}
return nil
})
// Return OperationResultCreated/Updated is not available here with unstructured; we can mimic Updated when no error.
return controllerutil.OperationResultNone, err
}
// --- helpers ---
// pickGVK tries to read group/version/kind from the CRD. We prefer the "application" section,
// falling back to other likely fields if your schema differs.
func pickGVK(crd *cozyv1alpha1.CozystackResourceDefinition) (group, version, kind string) {
// Best guess based on your examples:
if crd.Spec.Application.Kind != "" {
kind = crd.Spec.Application.Kind
}
// Reasonable fallbacks if any are empty:
if group == "" {
group = "apps.cozystack.io"
}
if version == "" {
version = "v1alpha1"
}
if kind == "" {
kind = "Resource"
}
return
}
// pickPlural prefers a field on the CRD if you have it; otherwise do a simple lowercase + "s".
func pickPlural(kind string, crd *cozyv1alpha1.CozystackResourceDefinition) string {
// If you have crd.Spec.Application.Plural, prefer it. Example:
if crd.Spec.Application.Plural != "" {
return crd.Spec.Application.Plural
}
// naive pluralization
k := strings.ToLower(kind)
if strings.HasSuffix(k, "s") {
return k
}
return k + "s"
}
// initialsFromKind splits CamelCase and returns the first letters in upper case.
// "VirtualMachine" -> "VM"; "Bucket" -> "B".
func initialsFromKind(kind string) string {
parts := splitCamel(kind)
if len(parts) == 0 {
return strings.ToUpper(kind)
}
var b strings.Builder
for _, p := range parts {
if p == "" {
continue
}
b.WriteString(strings.ToUpper(string(p[0])))
// Limit to 3 chars to keep the badge compact (VM, PVC, etc.)
if b.Len() >= 3 {
break
}
}
return b.String()
}
// hexColorForKind returns a dark, saturated color (hex) derived from a stable hash of the kind.
// We map the hash to an HSL hue; fix S/L for consistent readability with white text.
func hexColorForKind(kind string) string {
// Stable short hash (sha1 → bytes → hue)
sum := sha1.Sum([]byte(kind))
// Use first two bytes for hue [0..359]
hue := int(sum[0])<<8 | int(sum[1])
hue = hue % 360
// Fixed S/L chosen to contrast with white text:
// S = 80%, L = 35% (dark enough so #fff is readable)
r, g, b := hslToRGB(float64(hue), 0.80, 0.35)
return fmt.Sprintf("#%02x%02x%02x", r, g, b)
}
// hslToRGB converts HSL (0..360, 0..1, 0..1) to sRGB (0..255).
func hslToRGB(h float64, s float64, l float64) (uint8, uint8, uint8) {
c := (1 - absFloat(2*l-1)) * s
hp := h / 60.0
x := c * (1 - absFloat(modFloat(hp, 2)-1))
var r1, g1, b1 float64
switch {
case 0 <= hp && hp < 1:
r1, g1, b1 = c, x, 0
case 1 <= hp && hp < 2:
r1, g1, b1 = x, c, 0
case 2 <= hp && hp < 3:
r1, g1, b1 = 0, c, x
case 3 <= hp && hp < 4:
r1, g1, b1 = 0, x, c
case 4 <= hp && hp < 5:
r1, g1, b1 = x, 0, c
default:
r1, g1, b1 = c, 0, x
}
m := l - c/2
r := uint8(clamp01(r1+m) * 255.0)
g := uint8(clamp01(g1+m) * 255.0)
b := uint8(clamp01(b1+m) * 255.0)
return r, g, b
}
func absFloat(v float64) float64 {
if v < 0 {
return -v
}
return v
}
func modFloat(a, b float64) float64 {
return a - b*float64(int(a/b))
}
func clamp01(v float64) float64 {
if v < 0 {
return 0
}
if v > 1 {
return 1
}
return v
}
// optional: tiny helper to expose the compact color hash (useful for debugging)
func shortHashHex(s string) string {
sum := sha1.Sum([]byte(s))
return hex.EncodeToString(sum[:4])
}

View File

@@ -0,0 +1,784 @@
package dashboard
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
dashv1alpha1 "github.com/cozystack/cozystack/api/dashboard/v1alpha1"
cozyv1alpha1 "github.com/cozystack/cozystack/api/v1alpha1"
apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
)
// ---------------- Types used by OpenAPI parsing ----------------
type fieldInfo struct {
JSONPathSpec string // dotted path under .spec (e.g., "systemDisk.image")
Label string // "System Disk / Image" or "systemDisk.image"
Description string
}
// ---------------- Public entry: ensure Factory ------------------
func (m *Manager) ensureFactory(ctx context.Context, crd *cozyv1alpha1.CozystackResourceDefinition) error {
g, v, kind := pickGVK(crd)
plural := pickPlural(kind, crd)
lowerKind := strings.ToLower(kind)
factoryName := fmt.Sprintf("%s-details", lowerKind)
resourceFetch := fmt.Sprintf("/api/clusters/{2}/k8s/apis/%s/%s/namespaces/{3}/%s/{6}", g, v, plural)
flags := factoryFeatureFlags(crd)
var keysOrder [][]string
if crd.Spec.Dashboard != nil {
keysOrder = crd.Spec.Dashboard.KeysOrder
}
tabs := []any{
detailsTab(kind, resourceFetch, crd.Spec.Application.OpenAPISchema, keysOrder),
}
if flags.Workloads {
tabs = append(tabs, workloadsTab(kind))
}
if flags.Ingresses {
tabs = append(tabs, ingressesTab(kind))
}
if flags.Services {
tabs = append(tabs, servicesTab(kind))
}
if flags.Secrets {
tabs = append(tabs, secretsTab(kind))
}
tabs = append(tabs, yamlTab(plural))
badgeText := initialsFromKind(kind)
badgeColor := hexColorForKind(kind)
header := map[string]any{
"type": "antdFlex",
"data": map[string]any{
"id": "header-row",
"align": "center",
"gap": float64(6),
"style": map[string]any{"marginBottom": float64(24)},
},
"children": []any{
map[string]any{
"type": "antdText",
"data": map[string]any{
"id": "badge-" + lowerKind,
"text": badgeText,
"title": strings.ToLower(plural),
"style": map[string]any{
"backgroundColor": badgeColor,
"borderRadius": "20px",
"color": "#fff",
"display": "inline-block",
"fontFamily": "RedHatDisplay, Overpass, overpass, helvetica, arial, sans-serif",
"fontSize": float64(20),
"fontWeight": float64(400),
"lineHeight": "24px",
"minWidth": float64(24),
"padding": "0 9px",
"textAlign": "center",
"whiteSpace": "nowrap",
},
},
},
map[string]any{
"type": "parsedText",
"data": map[string]any{
"id": lowerKind + "-name",
"text": "{reqsJsonPath[0]['.metadata.name']['-']}",
"style": map[string]any{
"fontFamily": "RedHatDisplay, Overpass, overpass, helvetica, arial, sans-serif",
"fontSize": float64(20),
"lineHeight": "24px",
},
},
},
},
}
spec := map[string]any{
"key": factoryName,
"sidebarTags": []any{fmt.Sprintf("%s-sidebar", lowerKind)},
"withScrollableMainContentCard": true,
"urlsToFetch": []any{resourceFetch},
"data": []any{
header,
map[string]any{
"type": "antdTabs",
"data": map[string]any{
"id": lowerKind + "-tabs",
"defaultActiveKey": "details",
"items": tabs,
},
},
},
}
obj := &dashv1alpha1.Factory{}
obj.SetGroupVersionKind(schema.GroupVersionKind{Group: "dashboard.cozystack.io", Version: "v1alpha1", Kind: "Factory"})
obj.SetName(factoryName)
_, err := controllerutil.CreateOrUpdate(ctx, m.client, obj, func() error {
if err := controllerutil.SetOwnerReference(crd, obj, m.scheme); err != nil {
return err
}
b, err := json.Marshal(spec)
if err != nil {
return err
}
obj.Spec = dashv1alpha1.ArbitrarySpec{JSON: apiextv1.JSON{Raw: b}}
return nil
})
return err
}
// ---------------- Tabs builders ----------------
func detailsTab(kind, endpoint, schemaJSON string, keysOrder [][]string) map[string]any {
paramsBlocks := buildOpenAPIParamsBlocks(schemaJSON, keysOrder)
paramsList := map[string]any{
"type": "antdFlex",
"data": map[string]any{
"id": "params-list",
"vertical": true,
"gap": float64(24),
},
"children": paramsBlocks,
}
leftColStack := []any{
antdText("details-title", true, kind, map[string]any{
"fontSize": float64(20),
"marginBottom": float64(12),
}),
antdFlexVertical("meta-name-block", 4, []any{
antdText("meta-name-label", true, "Name", nil),
parsedText("meta-name-value", "{reqsJsonPath[0]['.metadata.name']['-']}", nil),
}),
antdFlexVertical("meta-namespace-block", 8, []any{
antdText("meta-namespace-label", true, "Namespace", nil),
map[string]any{
"type": "antdFlex",
"data": map[string]any{
"id": "namespace-row",
"align": "center",
"gap": float64(6),
},
"children": []any{
map[string]any{
"type": "antdText",
"data": map[string]any{
"id": "ns-badge",
"text": "NS",
"style": map[string]any{
"backgroundColor": "#a25792ff",
"borderRadius": "20px",
"color": "#fff",
"display": "inline-block",
"fontFamily": "RedHatDisplay, Overpass, overpass, helvetica, arial, sans-serif",
"fontSize": float64(15),
"fontWeight": float64(400),
"lineHeight": "24px",
"minWidth": float64(24),
"padding": "0 9px",
"textAlign": "center",
"whiteSpace": "nowrap",
},
},
},
antdLink("namespace-link",
"{reqsJsonPath[0]['.metadata.namespace']['-']}",
"/openapi-ui/{2}/factory/namespace-details/{reqsJsonPath[0]['.metadata.namespace']['-']}",
),
},
},
}),
antdFlexVertical("meta-created-block", 4, []any{
antdText("time-label", true, "Created", nil),
antdFlex("time-block", 6, []any{
antdText("time-icon", false, "🌐", nil),
parsedTextWithFormatter("time-value", "{reqsJsonPath[0]['.metadata.creationTimestamp']['-']}", "timestamp"),
}),
}),
antdFlexVertical("meta-version-block", 4, []any{
antdText("version-label", true, "Version", nil),
parsedText("version-value", "{reqsJsonPath[0]['.status.version']['-']}", nil),
}),
antdFlexVertical("meta-released-block", 4, []any{
antdText("released-label", true, "Released", nil),
parsedText("released-value", "{reqsJsonPath[0]['.status.conditions[?(@.type==\"Released\")].status']['-']}", nil),
}),
antdFlexVertical("meta-ready-block", 4, []any{
antdText("ready-label", true, "Ready", nil),
parsedText("ready-value", "{reqsJsonPath[0]['.status.conditions[?(@.type==\"Ready\")].status']['-']}", nil),
}),
}
rightColStack := []any{
antdText("params-title", true, "Parameters", map[string]any{
"fontSize": float64(20),
"marginBottom": float64(12),
}),
paramsList,
}
return map[string]any{
"key": "details",
"label": "Details",
"children": []any{
contentCard("details-card", map[string]any{"marginBottom": float64(24)}, []any{
map[string]any{
"type": "antdRow",
"data": map[string]any{
"id": "details-grid",
"gutter": []any{float64(48), float64(12)},
},
"children": []any{
map[string]any{
"type": "antdCol",
"data": map[string]any{"id": "col-left", "span": float64(12)},
"children": []any{
map[string]any{
"type": "antdFlex",
"data": map[string]any{"id": "col-left-stack", "vertical": true, "gap": float64(24)},
"children": leftColStack,
},
},
},
map[string]any{
"type": "antdCol",
"data": map[string]any{"id": "col-right", "span": float64(12)},
"children": []any{
map[string]any{
"type": "antdFlex",
"data": map[string]any{"id": "col-right-stack", "vertical": true, "gap": float64(24)},
"children": rightColStack,
},
},
},
},
},
spacer("conditions-top-spacer", float64(16)),
antdText("conditions-title", true, "Conditions", map[string]any{"fontSize": float64(20)}),
spacer("conditions-spacer", float64(8)),
map[string]any{
"type": "EnrichedTable",
"data": map[string]any{
"id": "conditions-table",
"fetchUrl": endpoint,
"clusterNamePartOfUrl": "{2}",
"customizationId": "factory-status-conditions",
"baseprefix": "/openapi-ui",
"withoutControls": true,
"pathToItems": []any{"status", "conditions"},
},
},
}),
},
}
}
func workloadsTab(kind string) map[string]any {
return map[string]any{
"key": "workloads",
"label": "Workloads",
"children": []any{
map[string]any{
"type": "EnrichedTable",
"data": map[string]any{
"id": "workloads-table",
"fetchUrl": "/api/clusters/{2}/k8s/apis/cozystack.io/v1alpha1/namespaces/{3}/workloadmonitors",
"clusterNamePartOfUrl": "{2}",
"baseprefix": "/openapi-ui",
"customizationId": "factory-details-v1alpha1.cozystack.io.workloadmonitors",
"pathToItems": []any{"items"},
"labelsSelector": map[string]any{
"apps.cozystack.io/application.group": "apps.cozystack.io",
"apps.cozystack.io/application.kind": kind,
"apps.cozystack.io/application.name": "{reqs[0]['metadata','name']}",
},
},
},
},
}
}
func servicesTab(kind string) map[string]any {
return map[string]any{
"key": "services",
"label": "Services",
"children": []any{
map[string]any{
"type": "EnrichedTable",
"data": map[string]any{
"id": "services-table",
"fetchUrl": "/api/clusters/{2}/k8s/api/v1/namespaces/{3}/services",
"clusterNamePartOfUrl": "{2}",
"baseprefix": "/openapi-ui",
"customizationId": "factory-details-v1.services",
"pathToItems": []any{"items"},
"labelsSelector": map[string]any{
"apps.cozystack.io/application.group": "apps.cozystack.io",
"apps.cozystack.io/application.kind": kind,
"apps.cozystack.io/application.name": "{reqs[0]['metadata','name']}",
},
},
},
},
}
}
func ingressesTab(kind string) map[string]any {
return map[string]any{
"key": "ingresses",
"label": "Ingresses",
"children": []any{
map[string]any{
"type": "EnrichedTable",
"data": map[string]any{
"id": "ingresses-table",
"fetchUrl": "/api/clusters/{2}/k8s/apis/networking.k8s.io/v1/namespaces/{3}/ingresses",
"clusterNamePartOfUrl": "{2}",
"baseprefix": "/openapi-ui",
"customizationId": "factory-details-networking.k8s.io.v1.ingresses",
"pathToItems": []any{"items"},
"labelsSelector": map[string]any{
"apps.cozystack.io/application.group": "apps.cozystack.io",
"apps.cozystack.io/application.kind": kind,
"apps.cozystack.io/application.name": "{reqs[0]['metadata','name']}",
},
},
},
},
}
}
func secretsTab(kind string) map[string]any {
return map[string]any{
"key": "secrets",
"label": "Secrets",
"children": []any{
map[string]any{
"type": "EnrichedTable",
"data": map[string]any{
"id": "secrets-table",
"fetchUrl": "/api/clusters/{2}/k8s/apis/core.cozystack.io/v1alpha1/namespaces/{3}/tenantsecretstables",
"clusterNamePartOfUrl": "{2}",
"baseprefix": "/openapi-ui",
"customizationId": "factory-details-v1alpha1.core.cozystack.io.tenantsecretstables",
"pathToItems": []any{"items"},
"labelsSelector": map[string]any{
"apps.cozystack.io/application.group": "apps.cozystack.io",
"apps.cozystack.io/application.kind": kind,
"apps.cozystack.io/application.name": "{reqs[0]['metadata','name']}",
},
},
},
},
}
}
func yamlTab(plural string) map[string]any {
return map[string]any{
"key": "yaml",
"label": "YAML",
"children": []any{
map[string]any{
"type": "YamlEditorSingleton",
"data": map[string]any{
"id": "yaml-editor",
"cluster": "{2}",
"isNameSpaced": true,
"type": "builtin",
"typeName": plural,
"prefillValuesRequestIndex": float64(0),
"substractHeight": float64(400),
},
},
},
}
}
// ---------------- UI helpers (use float64 for numeric fields) ----------------
func contentCard(id string, style map[string]any, children []any) map[string]any {
return map[string]any{
"type": "ContentCard",
"data": map[string]any{
"id": id,
"style": style,
},
"children": children,
}
}
func antdText(id string, strong bool, text string, style map[string]any) map[string]any {
data := map[string]any{
"id": id,
"text": text,
"strong": strong,
}
if style != nil {
data["style"] = style
}
return map[string]any{"type": "antdText", "data": data}
}
func parsedText(id, text string, style map[string]any) map[string]any {
data := map[string]any{
"id": id,
"text": text,
}
if style != nil {
data["style"] = style
}
return map[string]any{"type": "parsedText", "data": data}
}
func parsedTextWithFormatter(id, text, formatter string) map[string]any {
return map[string]any{
"type": "parsedText",
"data": map[string]any{
"id": id,
"text": text,
"formatter": formatter,
},
}
}
func spacer(id string, space float64) map[string]any {
return map[string]any{
"type": "Spacer",
"data": map[string]any{
"id": id,
"$space": space,
},
}
}
func antdFlex(id string, gap float64, children []any) map[string]any {
return map[string]any{
"type": "antdFlex",
"data": map[string]any{
"id": id,
"align": "center",
"gap": gap,
},
"children": children,
}
}
func antdFlexVertical(id string, gap float64, children []any) map[string]any {
return map[string]any{
"type": "antdFlex",
"data": map[string]any{
"id": id,
"vertical": true,
"gap": gap,
},
"children": children,
}
}
func antdRow(id string, gutter []any, children []any) map[string]any {
return map[string]any{
"type": "antdRow",
"data": map[string]any{
"id": id,
"gutter": gutter,
},
"children": children,
}
}
func antdCol(id string, span float64, children []any) map[string]any {
return map[string]any{
"type": "antdCol",
"data": map[string]any{
"id": id,
"span": span,
},
"children": children,
}
}
func antdColWithStyle(id string, style map[string]any, children []any) map[string]any {
return map[string]any{
"type": "antdCol",
"data": map[string]any{
"id": id,
"style": style,
},
"children": children,
}
}
func antdLink(id, text, href string) map[string]any {
return map[string]any{
"type": "antdLink",
"data": map[string]any{
"id": id,
"text": text,
"href": href,
},
}
}
// ---------------- OpenAPI → Right column ----------------
func buildOpenAPIParamsBlocks(schemaJSON string, keysOrder [][]string) []any {
var blocks []any
fields := collectOpenAPILeafFields(schemaJSON, 2, 20)
// Sort fields according to keysOrder if provided
if len(keysOrder) > 0 {
fields = sortFieldsByKeysOrder(fields, keysOrder)
}
for idx, f := range fields {
id := fmt.Sprintf("param-%d", idx)
blocks = append(blocks,
antdFlexVertical(id, 4, []any{
antdText(id+"-label", true, f.Label, nil),
parsedText(id+"-value", fmt.Sprintf("{reqsJsonPath[0]['.spec.%s']['-']}", f.JSONPathSpec), nil),
}),
)
}
if len(fields) == 0 {
blocks = append(blocks,
antdText("params-empty", false, "No scalar parameters detected in schema (see YAML tab for full spec).", map[string]any{"opacity": float64(0.7)}),
)
}
return blocks
}
// sortFieldsByKeysOrder sorts fields according to the provided keysOrder
func sortFieldsByKeysOrder(fields []fieldInfo, keysOrder [][]string) []fieldInfo {
// Create a map for quick lookup of field positions
orderMap := make(map[string]int)
for i, path := range keysOrder {
// Convert path to dot notation (e.g., ["spec", "systemDisk", "image"] -> "systemDisk.image")
if len(path) > 1 && path[0] == "spec" {
dotPath := strings.Join(path[1:], ".")
orderMap[dotPath] = i
}
}
// Sort fields based on their position in keysOrder
sort.Slice(fields, func(i, j int) bool {
posI, existsI := orderMap[fields[i].JSONPathSpec]
posJ, existsJ := orderMap[fields[j].JSONPathSpec]
// If both exist in orderMap, sort by position
if existsI && existsJ {
return posI < posJ
}
// If only one exists, prioritize the one that exists
if existsI {
return true
}
if existsJ {
return false
}
// If neither exists, maintain original order (stable sort)
return i < j
})
return fields
}
func collectOpenAPILeafFields(schemaJSON string, maxDepth, maxFields int) []fieldInfo {
type node = map[string]any
if strings.TrimSpace(schemaJSON) == "" {
return nil
}
var root any
if err := json.Unmarshal([]byte(schemaJSON), &root); err != nil {
// invalid JSON — skip
return nil
}
props := map[string]any{}
if m, ok := root.(node); ok {
if p, ok := m["properties"].(node); ok {
props = p
}
}
if len(props) == 0 {
return nil
}
var out []fieldInfo
var visit func(prefix []string, n node, depth int)
addField := func(path []string, schema node) {
// Skip excluded paths (backup/bootstrap/password)
if shouldExcludeParamPath(path) {
return
}
// build label "Foo Bar / Baz"
label := humanizePath(path)
desc := getString(schema, "description")
out = append(out, fieldInfo{
JSONPathSpec: strings.Join(path, "."),
Label: label,
Description: desc,
})
}
visit = func(prefix []string, n node, depth int) {
if len(out) >= maxFields {
return
}
// Scalar?
if isScalarType(n) || isIntOrString(n) || hasEnum(n) {
addField(prefix, n)
return
}
// Object with properties
if props, ok := n["properties"].(node); ok {
if depth >= maxDepth {
// too deep — stop
return
}
// deterministic ordering
keys := make([]string, 0, len(props))
for k := range props {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
child, _ := props[k].(node)
visit(append(prefix, k), child, depth+1)
if len(out) >= maxFields {
return
}
}
return
}
// Arrays: try to render item if its scalar and depth limit allows
if n["type"] == "array" {
if items, ok := n["items"].(node); ok && (isScalarType(items) || isIntOrString(items) || hasEnum(items)) {
addField(prefix, items)
}
return
}
// Otherwise skip (unknown/complex)
}
// top-level: iterate properties
keys := make([]string, 0, len(props))
for k := range props {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
if child, ok := props[k].(node); ok {
visit([]string{k}, child, 1)
if len(out) >= maxFields {
break
}
}
}
return out
}
// --- helpers for schema inspection ---
func isScalarType(n map[string]any) bool {
switch getString(n, "type") {
case "string", "integer", "number", "boolean":
return true
default:
return false
}
}
func isIntOrString(n map[string]any) bool {
// Kubernetes extension: x-kubernetes-int-or-string: true
if v, ok := n["x-kubernetes-int-or-string"]; ok {
if b, ok := v.(bool); ok && b {
return true
}
}
// anyOf: integer|string
if anyOf, ok := n["anyOf"].([]any); ok {
hasInt := false
hasStr := false
for _, it := range anyOf {
if m, ok := it.(map[string]any); ok {
switch getString(m, "type") {
case "integer":
hasInt = true
case "string":
hasStr = true
}
}
}
return hasInt && hasStr
}
return false
}
func hasEnum(n map[string]any) bool {
_, ok := n["enum"]
return ok
}
func getString(n map[string]any, key string) string {
if v, ok := n[key]; ok {
if s, ok := v.(string); ok {
return s
}
}
return ""
}
// shouldExcludeParamPath returns true if any part of the path contains
// backup / bootstrap / password (case-insensitive)
func shouldExcludeParamPath(parts []string) bool {
for _, p := range parts {
lp := strings.ToLower(p)
if strings.Contains(lp, "backup") || strings.Contains(lp, "bootstrap") || strings.Contains(lp, "password") || strings.Contains(lp, "cloudInit") {
return true
}
}
return false
}
func humanizePath(parts []string) string {
// "systemDisk.image" -> "System Disk / Image"
return strings.Join(parts, " / ")
}
// ---------------- Feature flags ----------------
type factoryFlags struct {
Workloads bool
Ingresses bool
Services bool
Secrets bool
}
// factoryFeatureFlags tries several conventional locations so you can evolve the API
// without breaking the controller. Defaults are false (hidden).
func factoryFeatureFlags(crd *cozyv1alpha1.CozystackResourceDefinition) factoryFlags {
var f factoryFlags
f.Workloads = true
f.Ingresses = true
f.Services = true
f.Secrets = true
return f
}

View File

@@ -0,0 +1,343 @@
package dashboard
import (
"context"
"encoding/json"
"fmt"
"reflect"
"regexp"
"strings"
dashv1alpha1 "github.com/cozystack/cozystack/api/dashboard/v1alpha1"
cozyv1alpha1 "github.com/cozystack/cozystack/api/v1alpha1"
apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
"sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
)
// AddToScheme exposes dashboard types registration for controller setup.
func AddToScheme(s *runtime.Scheme) error {
return dashv1alpha1.AddToScheme(s)
}
// Manager owns logic for creating/updating dashboard resources derived from CRDs.
// Its easy to extend: add new ensure* methods and wire them into EnsureForCRD.
type Manager struct {
client client.Client
scheme *runtime.Scheme
crdListFn func(context.Context) ([]cozyv1alpha1.CozystackResourceDefinition, error)
}
// Option pattern so callers can inject a custom lister.
type Option func(*Manager)
// WithCRDListFunc overrides how Manager lists all CozystackResourceDefinitions.
func WithCRDListFunc(fn func(context.Context) ([]cozyv1alpha1.CozystackResourceDefinition, error)) Option {
return func(m *Manager) { m.crdListFn = fn }
}
// NewManager constructs a dashboard Manager.
func NewManager(c client.Client, scheme *runtime.Scheme, opts ...Option) *Manager {
m := &Manager{client: c, scheme: scheme}
for _, o := range opts {
o(m)
}
return m
}
// EnsureForCRD is the single entry-point used by the controller.
// Add more ensure* calls here as you implement support for other resources:
//
// - ensureBreadcrumb (implemented)
// - ensureCustomColumnsOverride (implemented)
// - ensureCustomFormsOverride (implemented)
// - ensureCustomFormsPrefill (implemented)
// - ensureFactory
// - ensureMarketplacePanel (implemented)
// - ensureSidebar (implemented)
// - ensureTableUriMapping (implemented)
func (m *Manager) EnsureForCRD(ctx context.Context, crd *cozyv1alpha1.CozystackResourceDefinition) (reconcile.Result, error) {
// MarketplacePanel
if res, err := m.ensureMarketplacePanel(ctx, crd); err != nil || res.Requeue || res.RequeueAfter > 0 {
return res, err
}
// CustomFormsPrefill
if res, err := m.ensureCustomFormsPrefill(ctx, crd); err != nil || res.Requeue || res.RequeueAfter > 0 {
return res, err
}
// CustomColumnsOverride
if _, err := m.ensureCustomColumnsOverride(ctx, crd); err != nil {
return reconcile.Result{}, err
}
if err := m.ensureTableUriMapping(ctx, crd); err != nil {
return reconcile.Result{}, err
}
if err := m.ensureBreadcrumb(ctx, crd); err != nil {
return reconcile.Result{}, err
}
if err := m.ensureCustomFormsOverride(ctx, crd); err != nil {
return reconcile.Result{}, err
}
if err := m.ensureSidebar(ctx, crd); err != nil {
return reconcile.Result{}, err
}
if err := m.ensureFactory(ctx, crd); err != nil {
return reconcile.Result{}, err
}
return reconcile.Result{}, nil
}
// ----------------------- MarketplacePanel -----------------------
func (m *Manager) ensureMarketplacePanel(ctx context.Context, crd *cozyv1alpha1.CozystackResourceDefinition) (reconcile.Result, error) {
logger := log.FromContext(ctx)
mp := &dashv1alpha1.MarketplacePanel{}
mp.Name = crd.Name // cluster-scoped resource, name mirrors CRD name
// If dashboard is not set, delete the panel if it exists.
if crd.Spec.Dashboard == nil {
err := m.client.Get(ctx, client.ObjectKey{Name: mp.Name}, mp)
if apierrors.IsNotFound(err) {
return reconcile.Result{}, nil
}
if err != nil {
return reconcile.Result{}, err
}
if err := m.client.Delete(ctx, mp); err != nil && !apierrors.IsNotFound(err) {
return reconcile.Result{}, err
}
logger.Info("Deleted MarketplacePanel because dashboard is not set", "name", mp.Name)
return reconcile.Result{}, nil
}
// Skip resources with non-empty spec.dashboard.name
if strings.TrimSpace(crd.Spec.Dashboard.Name) != "" {
err := m.client.Get(ctx, client.ObjectKey{Name: mp.Name}, mp)
if apierrors.IsNotFound(err) {
return reconcile.Result{}, nil
}
if err != nil {
return reconcile.Result{}, err
}
if err := m.client.Delete(ctx, mp); err != nil && !apierrors.IsNotFound(err) {
return reconcile.Result{}, err
}
logger.Info("Deleted MarketplacePanel because spec.dashboard.name is set", "name", mp.Name)
return reconcile.Result{}, nil
}
// Build desired spec from CRD fields
d := crd.Spec.Dashboard
app := crd.Spec.Application
displayName := d.Singular
if displayName == "" {
displayName = app.Kind
}
tags := make([]any, len(d.Tags))
for i, t := range d.Tags {
tags[i] = t
}
specMap := map[string]any{
"description": d.Description,
"name": displayName,
"type": "nonCrd",
"apiGroup": "apps.cozystack.io",
"apiVersion": "v1alpha1",
"typeName": app.Plural, // e.g., "buckets"
"disabled": false,
"hidden": false,
"tags": tags,
"icon": d.Icon,
}
specBytes, err := json.Marshal(specMap)
if err != nil {
return reconcile.Result{}, err
}
mutate := func() error {
if err := controllerutil.SetOwnerReference(crd, mp, m.scheme); err != nil {
return err
}
// Inline JSON payload (the ArbitrarySpec type inlines apiextv1.JSON)
mp.Spec = dashv1alpha1.ArbitrarySpec{
JSON: apiextv1.JSON{Raw: specBytes},
}
return nil
}
op, err := controllerutil.CreateOrUpdate(ctx, m.client, mp, mutate)
if err != nil {
return reconcile.Result{}, err
}
switch op {
case controllerutil.OperationResultCreated:
logger.Info("Created MarketplacePanel", "name", mp.Name)
case controllerutil.OperationResultUpdated:
logger.Info("Updated MarketplacePanel", "name", mp.Name)
}
return reconcile.Result{}, nil
}
// ----------------------- Helpers (OpenAPI → values) -----------------------
// defaultOrZero returns the schema default if present; otherwise a reasonable zero value.
func defaultOrZero(sub map[string]interface{}) interface{} {
if v, ok := sub["default"]; ok {
return v
}
typ, _ := sub["type"].(string)
switch typ {
case "string":
return ""
case "boolean":
return false
case "array":
return []interface{}{}
case "integer", "number":
return 0
case "object":
return map[string]interface{}{}
default:
return nil
}
}
// toIfaceSlice converts []string -> []interface{}.
func toIfaceSlice(ss []string) []interface{} {
out := make([]interface{}, len(ss))
for i, s := range ss {
out[i] = s
}
return out
}
// buildPrefillValues converts an OpenAPI schema (JSON string) into a []interface{} "values" list
// suitable for CustomFormsPrefill.spec.values.
// Rules:
// - For top-level primitive/array fields: emit an entry, using default if present, otherwise zero.
// - For top-level objects: recursively process nested objects and emit entries for all default values
// found at any nesting level.
func buildPrefillValues(openAPISchema string) ([]interface{}, error) {
var root map[string]interface{}
if err := json.Unmarshal([]byte(openAPISchema), &root); err != nil {
return nil, fmt.Errorf("cannot parse openAPISchema: %w", err)
}
props, _ := root["properties"].(map[string]interface{})
if props == nil {
return []interface{}{}, nil
}
var values []interface{}
processSchemaProperties(props, []string{"spec"}, &values)
return values, nil
}
// processSchemaProperties recursively processes OpenAPI schema properties and extracts default values
func processSchemaProperties(props map[string]interface{}, path []string, values *[]interface{}) {
for pname, raw := range props {
sub, _ := raw.(map[string]interface{})
if sub == nil {
continue
}
typ, _ := sub["type"].(string)
currentPath := append(path, pname)
switch typ {
case "object":
// Check if this object has a default value
if objDefault, ok := sub["default"].(map[string]interface{}); ok {
// Process the default object recursively
processDefaultObject(objDefault, currentPath, values)
}
// Also process child properties for their individual defaults
if childProps, ok := sub["properties"].(map[string]interface{}); ok {
processSchemaProperties(childProps, currentPath, values)
}
default:
// For primitive types, use default if present, otherwise zero value
val := defaultOrZero(sub)
if val != nil {
entry := map[string]interface{}{
"path": toIfaceSlice(currentPath),
"value": val,
}
*values = append(*values, entry)
}
}
}
}
// processDefaultObject recursively processes a default object and creates entries for all nested values
func processDefaultObject(obj map[string]interface{}, path []string, values *[]interface{}) {
for key, value := range obj {
currentPath := append(path, key)
// If the value is a map, process it recursively
if nestedObj, ok := value.(map[string]interface{}); ok {
processDefaultObject(nestedObj, currentPath, values)
} else {
// For primitive values, create an entry
entry := map[string]interface{}{
"path": toIfaceSlice(currentPath),
"value": value,
}
*values = append(*values, entry)
}
}
}
// normalizeJSON makes maps/slices JSON-safe for k8s Unstructured:
// - converts all int/int32/... to float64
// - leaves strings, bools, nil as-is
func normalizeJSON(v any) any {
switch t := v.(type) {
case map[string]any:
out := make(map[string]any, len(t))
for k, val := range t {
out[k] = normalizeJSON(val)
}
return out
case []any:
out := make([]any, len(t))
for i := range t {
out[i] = normalizeJSON(t[i])
}
return out
case int:
return float64(t)
case int8:
return float64(t)
case int16:
return float64(t)
case int32:
return float64(t)
case int64:
return float64(t)
case uint, uint8, uint16, uint32, uint64:
return float64(reflect.ValueOf(t).Convert(reflect.TypeOf(uint64(0))).Uint())
case float32:
return float64(t)
default:
return v
}
}
var camelSplitter = regexp.MustCompile(`(?m)([A-Z]+[a-z0-9]*|[a-z0-9]+)`)
func splitCamel(s string) []string {
return camelSplitter.FindAllString(s, -1)
}

View File

@@ -0,0 +1,310 @@
package dashboard
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
dashv1alpha1 "github.com/cozystack/cozystack/api/dashboard/v1alpha1"
cozyv1alpha1 "github.com/cozystack/cozystack/api/v1alpha1"
apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
)
// ensureSidebar creates/updates multiple Sidebar resources that share the same menu:
// - The "details" sidebar tied to the current kind (stock-project-factory-<kind>-details)
// - The stock-instance sidebars: api-form, api-table, builtin-form, builtin-table
// - The stock-project sidebars: api-form, api-table, builtin-form, builtin-table, crd-form, crd-table
//
// 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)
// - All other sections are built from CRDs where spec.dashboard != nil.
// - Categories are ordered strictly as:
// Marketplace, IaaS, PaaS, NaaS, <others A→Z>, Resources, Administration
// - Items within each category: sort by Weight (desc), then Label (A→Z).
func (m *Manager) ensureSidebar(ctx context.Context, crd *cozyv1alpha1.CozystackResourceDefinition) error {
// Build the full menu once.
// 1) Fetch all CRDs
var all []cozyv1alpha1.CozystackResourceDefinition
if m.crdListFn != nil {
s, err := m.crdListFn(ctx)
if err != nil {
return err
}
all = s
} else {
var crdList cozyv1alpha1.CozystackResourceDefinitionList
if err := m.client.List(ctx, &crdList, &client.ListOptions{}); err != nil {
return err
}
all = crdList.Items
}
// 2) Build category -> []item map (only for CRDs with spec.dashboard != nil)
type item struct {
Key string
Label string
Link string
Weight int
}
categories := map[string][]item{} // category label -> children
keysAndTags := map[string]any{} // plural -> []string{ "<lower(kind)>-sidebar" }
for i := range all {
def := &all[i]
// Include ONLY when spec.dashboard != nil
if def.Spec.Dashboard == nil {
continue
}
// Skip resources with non-empty spec.dashboard.name
if strings.TrimSpace(def.Spec.Dashboard.Name) != "" {
continue
}
g, v, kind := pickGVK(def)
plural := pickPlural(kind, def)
cat := safeCategory(def) // falls back to "Resources" if empty
// Label: prefer dashboard.Plural if provided
label := titleFromKindPlural(kind, plural)
if def.Spec.Dashboard.Plural != "" {
label = def.Spec.Dashboard.Plural
}
// Weight (default 0)
weight := def.Spec.Dashboard.Weight
link := fmt.Sprintf("/openapi-ui/{clusterName}/{namespace}/api-table/%s/%s/%s", g, v, plural)
categories[cat] = append(categories[cat], item{
Key: plural,
Label: label,
Link: link,
Weight: weight,
})
// keysAndTags: plural -> [ "<lower(kind)>-sidebar" ]
keysAndTags[plural] = []any{fmt.Sprintf("%s-sidebar", strings.ToLower(kind))}
}
// 3) Sort items within each category by Weight (desc), then Label (A→Z)
for cat := range categories {
sort.Slice(categories[cat], func(i, j int) bool {
if categories[cat][i].Weight != categories[cat][j].Weight {
return categories[cat][i].Weight < categories[cat][j].Weight // lower weight first
}
return strings.ToLower(categories[cat][i].Label) < strings.ToLower(categories[cat][j].Label)
})
}
// 4) Order categories strictly:
// Marketplace (hardcoded), IaaS, PaaS, NaaS, <others A→Z>, Resources, Administration
orderedCats := orderCategoryLabels(categories)
// 5) Build menuItems (hardcode "Marketplace"; then dynamic categories; then hardcode "Administration")
menuItems := []any{
map[string]any{
"key": "marketplace",
"label": "Marketplace",
"children": []any{
map[string]any{
"key": "marketplace",
"label": "Marketplace",
"link": "/openapi-ui/{clusterName}/{namespace}/factory/marketplace",
},
},
},
}
for _, cat := range orderedCats {
// Skip "Marketplace" and "Administration" here since they're hardcoded
if strings.EqualFold(cat, "Marketplace") || strings.EqualFold(cat, "Administration") {
continue
}
children := []any{}
for _, it := range categories[cat] {
children = append(children, map[string]any{
"key": it.Key,
"label": it.Label,
"link": it.Link,
})
}
if len(children) > 0 {
menuItems = append(menuItems, map[string]any{
"key": slugify(cat),
"label": cat,
"children": children,
})
}
}
// Add hardcoded Administration section
menuItems = append(menuItems, map[string]any{
"key": "administration",
"label": "Administration",
"children": []any{
map[string]any{
"key": "info",
"label": "Info",
"link": "/openapi-ui/{clusterName}/{namespace}/factory/info-details/info",
},
map[string]any{
"key": "modules",
"label": "Modules",
"link": "/openapi-ui/{clusterName}/{namespace}/api-table/core.cozystack.io/v1alpha1/tenantmodules",
},
map[string]any{
"key": "tenants",
"label": "Tenants",
"link": "/openapi-ui/{clusterName}/{namespace}/api-table/apps.cozystack.io/v1alpha1/tenants",
},
},
})
// 6) Prepare the list of Sidebar IDs to upsert with the SAME content
_, _, thisKind := pickGVK(crd)
lowerThisKind := strings.ToLower(thisKind)
detailsID := fmt.Sprintf("stock-project-factory-%s-details", lowerThisKind)
targetIDs := []string{
// original details sidebar
detailsID,
// stock-instance sidebars
"stock-instance-api-form",
"stock-instance-api-table",
"stock-instance-builtin-form",
"stock-instance-builtin-table",
// stock-project sidebars
"stock-project-factory-marketplace",
"stock-project-factory-workloadmonitor-details",
"stock-project-api-form",
"stock-project-api-table",
"stock-project-builtin-form",
"stock-project-builtin-table",
"stock-project-crd-form",
"stock-project-crd-table",
}
// 7) Upsert all target sidebars with identical menuItems and keysAndTags
return m.upsertMultipleSidebars(ctx, crd, targetIDs, keysAndTags, menuItems)
}
// upsertMultipleSidebars creates/updates several Sidebar resources with the same menu spec.
func (m *Manager) upsertMultipleSidebars(
ctx context.Context,
crd *cozyv1alpha1.CozystackResourceDefinition,
ids []string,
keysAndTags map[string]any,
menuItems []any,
) error {
for _, id := range ids {
spec := map[string]any{
"id": id,
"keysAndTags": keysAndTags,
"menuItems": menuItems,
}
obj := &dashv1alpha1.Sidebar{}
obj.SetGroupVersionKind(schema.GroupVersionKind{
Group: "dashboard.cozystack.io",
Version: "v1alpha1",
Kind: "Sidebar",
})
obj.SetName(id)
if _, err := controllerutil.CreateOrUpdate(ctx, m.client, obj, func() error {
if err := controllerutil.SetOwnerReference(crd, obj, m.scheme); err != nil {
return err
}
b, err := json.Marshal(spec)
if err != nil {
return err
}
obj.Spec = dashv1alpha1.ArbitrarySpec{JSON: apiextv1.JSON{Raw: b}}
return nil
}); err != nil {
return err
}
}
return nil
}
// orderCategoryLabels returns category labels ordered strictly as:
//
// Marketplace, IaaS, PaaS, NaaS, <others A→Z>, Resources, Administration.
//
// It only returns labels that exist in `cats` (except "Marketplace" which is hardcoded by caller).
func orderCategoryLabels[T any](cats map[string][]T) []string {
if len(cats) == 0 {
return []string{"Marketplace", "IaaS", "PaaS", "NaaS", "Resources", "Administration"}
}
head := []string{"Marketplace", "IaaS", "PaaS", "NaaS"}
tail := []string{"Resources", "Administration"}
present := make(map[string]struct{}, len(cats))
for k := range cats {
present[k] = struct{}{}
}
var result []string
// Add head anchors (keep "Marketplace" in the order signature for the caller)
for _, h := range head {
result = append(result, h)
delete(present, h)
}
// Collect "others": exclude tail
var others []string
for k := range present {
if k == "Resources" || k == "Administration" {
continue
}
others = append(others, k)
}
sort.Slice(others, func(i, j int) bool { return strings.ToLower(others[i]) < strings.ToLower(others[j]) })
// Append others, then tail (always in fixed order)
result = append(result, others...)
result = append(result, tail...)
return result
}
// safeCategory returns spec.dashboard.category or "Resources" if not set.
func safeCategory(def *cozyv1alpha1.CozystackResourceDefinition) string {
if def == nil || def.Spec.Dashboard == nil {
return "Resources"
}
if def.Spec.Dashboard.Category != "" {
return def.Spec.Dashboard.Category
}
return "Resources"
}
// slugify converts a category label to a key-friendly identifier.
// "User Management" -> "usermanagement", "PaaS" -> "paas".
func slugify(s string) string {
s = strings.TrimSpace(strings.ToLower(s))
out := make([]byte, 0, len(s))
for i := 0; i < len(s); i++ {
c := s[i]
if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') {
out = append(out, c)
}
}
return string(out)
}

View File

@@ -0,0 +1,266 @@
package dashboard
import (
"context"
"encoding/json"
"fmt"
"strings"
dashv1alpha1 "github.com/cozystack/cozystack/api/dashboard/v1alpha1"
cozyv1alpha1 "github.com/cozystack/cozystack/api/v1alpha1"
apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
"sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
)
// Ensure the three additional dashboard/frontend resources exist:
// - TableUriMapping (dashboard.cozystack.io/v1alpha1)
// - Breadcrumb (dashboard.cozystack.io/v1alpha1)
// - CustomFormsOverride (dashboard.cozystack.io/v1alpha1)
//
// Call these from Manager.EnsureForCRD() after ensureCustomColumnsOverride.
// --------------------------- TableUriMapping -----------------------------
func (m *Manager) ensureTableUriMapping(ctx context.Context, crd *cozyv1alpha1.CozystackResourceDefinition) error {
// Links are fully managed by the CustomColumnsOverride.
return nil
}
// ------------------------------- Breadcrumb -----------------------------
func (m *Manager) ensureBreadcrumb(ctx context.Context, crd *cozyv1alpha1.CozystackResourceDefinition) error {
_, _, kind := pickGVK(crd)
lowerKind := strings.ToLower(kind)
detailID := fmt.Sprintf("stock-project-factory-%s-details", lowerKind)
obj := &dashv1alpha1.Breadcrumb{}
obj.SetGroupVersionKind(schema.GroupVersionKind{
Group: "dashboard.cozystack.io",
Version: "v1alpha1",
Kind: "Breadcrumb",
})
obj.SetName(detailID)
plural := pickPlural(kind, crd)
// Prefer dashboard.Plural for UI label if provided
labelPlural := titleFromKindPlural(kind, plural)
if crd != nil && crd.Spec.Dashboard != nil && crd.Spec.Dashboard.Plural != "" {
labelPlural = crd.Spec.Dashboard.Plural
}
key := plural // e.g., "virtualmachines"
label := labelPlural
link := fmt.Sprintf("/openapi-ui/{clusterName}/{namespace}/api-table/apps.cozystack.io/v1alpha1/%s", plural)
// If Name is set, change the first breadcrumb item to "Tenant Modules"
// TODO add parameter to this
if crd.Spec.Dashboard.Name != "" {
key = "tenantmodules"
label = "Tenant Modules"
link = "/openapi-ui/{clusterName}/{namespace}/api-table/core.cozystack.io/v1alpha1/tenantmodules"
}
items := []any{
map[string]any{
"key": key,
"label": label,
"link": link,
},
map[string]any{
"key": strings.ToLower(kind), // "etcd"
"label": "{6}", // literal, as in your example
},
}
spec := map[string]any{
"id": detailID,
"breadcrumbItems": items,
}
_, err := controllerutil.CreateOrUpdate(ctx, m.client, obj, func() error {
if err := controllerutil.SetOwnerReference(crd, obj, m.scheme); err != nil {
return err
}
b, err := json.Marshal(spec)
if err != nil {
return err
}
obj.Spec = dashv1alpha1.ArbitrarySpec{JSON: apiextv1.JSON{Raw: b}}
return nil
})
return err
}
// --------------------------- CustomFormsOverride ------------------------
func (m *Manager) ensureCustomFormsOverride(ctx context.Context, crd *cozyv1alpha1.CozystackResourceDefinition) error {
g, v, kind := pickGVK(crd)
plural := pickPlural(kind, crd)
name := fmt.Sprintf("%s.%s.%s", g, v, plural)
customizationID := fmt.Sprintf("default-/%s/%s/%s", g, v, plural)
obj := &dashv1alpha1.CustomFormsOverride{}
obj.SetGroupVersionKind(schema.GroupVersionKind{
Group: "dashboard.cozystack.io",
Version: "v1alpha1",
Kind: "CustomFormsOverride",
})
obj.SetName(name)
// Replicates your Helm includes (system metadata + api + status).
hidden := []any{}
hidden = append(hidden, hiddenMetadataSystem()...)
hidden = append(hidden, hiddenMetadataAPI()...)
hidden = append(hidden, hiddenStatus()...)
// If Name is set, hide metadata
if crd.Spec.Dashboard != nil && strings.TrimSpace(crd.Spec.Dashboard.Name) != "" {
hidden = append([]interface{}{
[]any{"metadata"},
}, hidden...)
}
sort := make([]any, len(crd.Spec.Dashboard.KeysOrder))
for i, v := range crd.Spec.Dashboard.KeysOrder {
sort[i] = v
}
spec := map[string]any{
"customizationId": customizationID,
"hidden": hidden,
"sort": sort,
"schema": map[string]any{}, // {}
"strategy": "merge",
}
_, err := controllerutil.CreateOrUpdate(ctx, m.client, obj, func() error {
if err := controllerutil.SetOwnerReference(crd, obj, m.scheme); err != nil {
return err
}
b, err := json.Marshal(spec)
if err != nil {
return err
}
obj.Spec = dashv1alpha1.ArbitrarySpec{JSON: apiextv1.JSON{Raw: b}}
return nil
})
return err
}
// ----------------------- CustomFormsPrefill -----------------------
func (m *Manager) ensureCustomFormsPrefill(ctx context.Context, crd *cozyv1alpha1.CozystackResourceDefinition) (reconcile.Result, error) {
logger := log.FromContext(ctx)
app := crd.Spec.Application
group := "apps.cozystack.io"
version := "v1alpha1"
name := fmt.Sprintf("%s.%s.%s", group, version, app.Plural)
customizationID := fmt.Sprintf("default-/%s/%s/%s", group, version, app.Plural)
values, err := buildPrefillValues(app.OpenAPISchema)
if err != nil {
return reconcile.Result{}, err
}
// If Name is set, prefill metadata.name
if crd.Spec.Dashboard != nil && strings.TrimSpace(crd.Spec.Dashboard.Name) != "" {
values = append([]interface{}{
map[string]interface{}{
"path": toIfaceSlice([]string{"metadata", "name"}),
"value": crd.Spec.Dashboard.Name,
},
}, values...)
}
cfp := &dashv1alpha1.CustomFormsPrefill{}
cfp.Name = name // cluster-scoped
specMap := map[string]any{
"customizationId": customizationID,
"values": values,
}
specBytes, err := json.Marshal(specMap)
if err != nil {
return reconcile.Result{}, err
}
mutate := func() error {
if err := controllerutil.SetOwnerReference(crd, cfp, m.scheme); err != nil {
return err
}
cfp.Spec = dashv1alpha1.ArbitrarySpec{
JSON: apiextv1.JSON{Raw: specBytes},
}
return nil
}
op, err := controllerutil.CreateOrUpdate(ctx, m.client, cfp, mutate)
if err != nil {
return reconcile.Result{}, err
}
switch op {
case controllerutil.OperationResultCreated:
logger.Info("Created CustomFormsPrefill", "name", cfp.Name)
case controllerutil.OperationResultUpdated:
logger.Info("Updated CustomFormsPrefill", "name", cfp.Name)
}
return reconcile.Result{}, nil
}
// ------------------------------ Helpers ---------------------------------
// titleFromKindPlural returns a presentable plural label, e.g.:
// kind="VirtualMachine", plural="virtualmachines" => "VirtualMachines"
func titleFromKindPlural(kind, plural string) string {
label := kind
if !strings.HasSuffix(strings.ToLower(plural), "s") || !strings.HasSuffix(strings.ToLower(plural), "S") {
label += "s"
} else {
label += "s"
}
return label
}
// The hidden lists below mirror the Helm templates you shared.
// Each entry is a path as nested string array, e.g. ["metadata","creationTimestamp"].
func hiddenMetadataSystem() []any {
return []any{
[]any{"metadata", "annotations"},
[]any{"metadata", "labels"},
[]any{"metadata", "namespace"},
[]any{"metadata", "creationTimestamp"},
[]any{"metadata", "deletionGracePeriodSeconds"},
[]any{"metadata", "deletionTimestamp"},
[]any{"metadata", "finalizers"},
[]any{"metadata", "generateName"},
[]any{"metadata", "generation"},
[]any{"metadata", "managedFields"},
[]any{"metadata", "ownerReferences"},
[]any{"metadata", "resourceVersion"},
[]any{"metadata", "selfLink"},
[]any{"metadata", "uid"},
}
}
func hiddenMetadataAPI() []any {
return []any{
[]any{"kind"},
[]any{"apiVersion"},
[]any{"appVersion"},
}
}
func hiddenStatus() []any {
return []any{
[]any{"status"},
}
}

View File

@@ -25,7 +25,10 @@ type runtimeConfig struct {
func (l *LineageControllerWebhook) initConfig() {
l.initOnce.Do(func() {
if l.config.Load() == nil {
l.config.Store(&runtimeConfig{chartAppMap: make(map[chartRef]*cozyv1alpha1.CozystackResourceDefinition)})
l.config.Store(&runtimeConfig{
chartAppMap: make(map[chartRef]*cozyv1alpha1.CozystackResourceDefinition),
appCRDMap: make(map[appRef]*cozyv1alpha1.CozystackResourceDefinition),
})
}
})
}

View File

@@ -5,11 +5,10 @@ import (
cozyv1alpha1 "github.com/cozystack/cozystack/api/v1alpha1"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/log"
)
// +kubebuilder:rbac:groups=cozystack.io,resources=cozystackresourcedefinitions,verbs=list;watch
// +kubebuilder:rbac:groups=cozystack.io,resources=cozystackresourcedefinitions,verbs=list;watch;get
func (c *LineageControllerWebhook) SetupWithManagerAsController(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
@@ -20,7 +19,7 @@ func (c *LineageControllerWebhook) SetupWithManagerAsController(mgr ctrl.Manager
func (c *LineageControllerWebhook) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
l := log.FromContext(ctx)
crds := &cozyv1alpha1.CozystackResourceDefinitionList{}
if err := c.List(ctx, crds, &client.ListOptions{Namespace: "cozy-system"}); err != nil {
if err := c.List(ctx, crds); err != nil {
l.Error(err, "failed reading CozystackResourceDefinitions")
return ctrl.Result{}, err
}

View File

@@ -135,18 +135,18 @@ func (h *LineageControllerWebhook) computeLabels(ctx context.Context, o *unstruc
}
cfg := h.config.Load().(*runtimeConfig)
crd := cfg.appCRDMap[appRef{gv.Group, obj.GetKind()}]
if matchLabelsToExcludeInclude(o.GetLabels(), crd.Spec.Secrets.Exclude, crd.Spec.Secrets.Include) {
labels["internal.cozystack.io/tenantsecret"] = ""
}
// TODO: expand this to work with other resources than Secrets
labels["apps.cozystack.io/tenantresource"] = func(b bool) string {
if b {
return "true"
}
return "false"
}(matchLabelsToExcludeInclude(o.GetLabels(), crd.Spec.Secrets.Exclude, crd.Spec.Secrets.Include))
return labels, err
}
func (h *LineageControllerWebhook) applyLabels(o *unstructured.Unstructured, labels map[string]string) {
if o.GetAPIVersion() == "operator.victoriametrics.com/v1beta1" && o.GetKind() == "VMCluster" {
unstructured.SetNestedStringMap(o.Object, labels, "spec", "managedMetadata", "labels")
return
}
existing := o.GetLabels()
if existing == nil {
existing = make(map[string]string)