[dashboard] refactor dashboard configuration

- Refactor code for dashboard resources creation
- Move dashboard-config helm chart to dynamic dashboard controller
- Move white-label configuration to separate configmap

Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
This commit is contained in:
Andrei Kvapil
2025-09-24 22:46:22 +02:00
parent bb653e5a87
commit 9873011ebf
90 changed files with 4878 additions and 6031 deletions

View File

@@ -23,8 +23,10 @@ import (
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/builder"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller"
"sigs.k8s.io/controller-runtime/pkg/handler"
"sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/manager"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
)
@@ -39,6 +41,10 @@ type CozystackResourceDefinitionReconciler struct {
lastHandled time.Time
mem *crdmem.Memory
// Track static resources initialization
staticResourcesInitialized bool
staticResourcesMutex sync.Mutex
}
func (r *CozystackResourceDefinitionReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
@@ -70,15 +76,25 @@ func (r *CozystackResourceDefinitionReconciler) Reconcile(ctx context.Context, r
return res, derr
}
// After processing CRD, perform cleanup of orphaned resources
// This should be done after cache warming to ensure all current resources are known
if cleanupErr := mgr.CleanupOrphanedResources(ctx); cleanupErr != nil {
logger.Error(cleanupErr, "Failed to cleanup orphaned dashboard resources")
// Don't fail the reconciliation, just log the error
}
r.mu.Lock()
r.lastEvent = time.Now()
r.mu.Unlock()
return ctrl.Result{}, nil
}
if err != nil && !apierrors.IsNotFound(err) {
// Handle error cases (err is guaranteed to be non-nil here)
if !apierrors.IsNotFound(err) {
return ctrl.Result{}, err
}
if apierrors.IsNotFound(err) && r.mem != nil {
// If resource is not found, clean up from memory
if r.mem != nil {
r.mem.Delete(req.Name)
}
if req.Namespace == "cozy-system" && req.Name == "cozystack-api" {
@@ -87,6 +103,40 @@ func (r *CozystackResourceDefinitionReconciler) Reconcile(ctx context.Context, r
return ctrl.Result{}, nil
}
// initializeStaticResourcesOnce ensures static resources are created only once
func (r *CozystackResourceDefinitionReconciler) initializeStaticResourcesOnce(ctx context.Context) error {
r.staticResourcesMutex.Lock()
defer r.staticResourcesMutex.Unlock()
if r.staticResourcesInitialized {
return nil // Already initialized
}
// Create dashboard manager and initialize static resources
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 err := mgr.InitializeStaticResources(ctx); err != nil {
return err
}
r.staticResourcesInitialized = true
log.FromContext(ctx).Info("Static dashboard resources initialized successfully")
return nil
}
func (r *CozystackResourceDefinitionReconciler) SetupWithManager(mgr ctrl.Manager) error {
if r.Debounce == 0 {
r.Debounce = 5 * time.Second
@@ -97,6 +147,18 @@ func (r *CozystackResourceDefinitionReconciler) SetupWithManager(mgr ctrl.Manage
if err := r.mem.EnsurePrimingWithManager(mgr); err != nil {
return err
}
// Initialize static resources once during controller startup using manager.Runnable
if err := mgr.Add(manager.RunnableFunc(func(ctx context.Context) error {
if err := r.initializeStaticResourcesOnce(ctx); err != nil {
log.FromContext(ctx).Error(err, "Failed to initialize static resources")
return err
}
return nil
})); err != nil {
return err
}
return ctrl.NewControllerManagedBy(mgr).
Named("cozystackresource-controller").
For(&cozyv1alpha1.CozystackResourceDefinition{}, builder.WithPredicates()).
@@ -114,6 +176,9 @@ func (r *CozystackResourceDefinitionReconciler) SetupWithManager(mgr ctrl.Manage
}}
}),
).
WithOptions(controller.Options{
MaxConcurrentReconciles: 5, // Allow more concurrent reconciles with proper rate limiting
}).
Complete(r)
}

View File

@@ -0,0 +1,81 @@
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"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
)
// ensureBreadcrumb creates or updates a Breadcrumb resource for the given CRD
func (m *Manager) ensureBreadcrumb(ctx context.Context, crd *cozyv1alpha1.CozystackResourceDefinition) error {
group, version, kind := pickGVK(crd)
lowerKind := strings.ToLower(kind)
detailID := fmt.Sprintf("stock-project-factory-%s-details", lowerKind)
obj := &dashv1alpha1.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/%s/%s/%s", strings.ToLower(group), strings.ToLower(version), plural)
// If Name is set, change the first breadcrumb item to "Tenant Modules"
// TODO add parameter to this
if crd.Spec.Dashboard != nil && strings.TrimSpace(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
}
// Add dashboard labels to dynamic resources
m.addDashboardLabels(obj, crd, ResourceTypeDynamic)
b, err := json.Marshal(spec)
if err != nil {
return err
}
// Only update spec if it's different to avoid unnecessary updates
newSpec := dashv1alpha1.ArbitrarySpec{JSON: apiextv1.JSON{Raw: b}}
if !compareArbitrarySpecs(obj.Spec, newSpec) {
obj.Spec = newSpec
}
return nil
})
return err
}

View File

@@ -2,8 +2,6 @@ package dashboard
import (
"context"
"crypto/sha1"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
@@ -12,7 +10,6 @@ import (
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"
)
@@ -38,11 +35,6 @@ func (m *Manager) ensureCustomColumnsOverride(ctx context.Context, crd *cozyv1al
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)
@@ -153,146 +145,24 @@ func (m *Manager) ensureCustomColumnsOverride(ctx context.Context, crd *cozyv1al
},
}
// 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
}
// Add dashboard labels to dynamic resources
m.addDashboardLabels(obj, crd, ResourceTypeDynamic)
b, err := json.Marshal(desired["spec"])
if err != nil {
return err
}
obj.Spec = dashv1alpha1.ArbitrarySpec{JSON: apiextv1.JSON{Raw: b}}
// Only update spec if it's different to avoid unnecessary updates
newSpec := dashv1alpha1.ArbitrarySpec{JSON: apiextv1.JSON{Raw: b}}
if !compareArbitrarySpecs(obj.Spec, newSpec) {
obj.Spec = newSpec
}
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,75 @@
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"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
)
// ensureCustomFormsOverride creates or updates a CustomFormsOverride resource for the given CRD
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.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...)
}
var sort []any
if crd.Spec.Dashboard != nil && len(crd.Spec.Dashboard.KeysOrder) > 0 {
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
}
// Add dashboard labels to dynamic resources
m.addDashboardLabels(obj, crd, ResourceTypeDynamic)
b, err := json.Marshal(spec)
if err != nil {
return err
}
// Only update spec if it's different to avoid unnecessary updates
newSpec := dashv1alpha1.ArbitrarySpec{JSON: apiextv1.JSON{Raw: b}}
if !compareArbitrarySpecs(obj.Spec, newSpec) {
obj.Spec = newSpec
}
return nil
})
return err
}

View File

@@ -0,0 +1,79 @@
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"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
"sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
)
// ensureCustomFormsPrefill creates or updates a CustomFormsPrefill resource for the given CRD
func (m *Manager) ensureCustomFormsPrefill(ctx context.Context, crd *cozyv1alpha1.CozystackResourceDefinition) (reconcile.Result, error) {
logger := log.FromContext(ctx)
app := crd.Spec.Application
group, version, kind := pickGVK(crd)
plural := pickPlural(kind, crd)
name := fmt.Sprintf("%s.%s.%s", group, version, plural)
customizationID := fmt.Sprintf("default-/%s/%s/%s", group, version, 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,
}
// Use json.Marshal with sorted keys to ensure consistent output
specBytes, err := json.Marshal(specMap)
if err != nil {
return reconcile.Result{}, err
}
_, err = controllerutil.CreateOrUpdate(ctx, m.client, cfp, func() error {
if err := controllerutil.SetOwnerReference(crd, cfp, m.scheme); err != nil {
return err
}
// Add dashboard labels to dynamic resources
m.addDashboardLabels(cfp, crd, ResourceTypeDynamic)
// Only update spec if it's different to avoid unnecessary updates
newSpec := dashv1alpha1.ArbitrarySpec{
JSON: apiextv1.JSON{Raw: specBytes},
}
if !compareArbitrarySpecs(cfp.Spec, newSpec) {
cfp.Spec = newSpec
}
return nil
})
if err != nil {
return reconcile.Result{}, err
}
logger.Info("Applied CustomFormsPrefill", "name", cfp.Name)
return reconcile.Result{}, nil
}

View File

@@ -11,20 +11,10 @@ import (
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 ------------------
// ensureFactory creates or updates a Factory resource for the given CRD
func (m *Manager) ensureFactory(ctx context.Context, crd *cozyv1alpha1.CozystackResourceDefinition) error {
g, v, kind := pickGVK(crd)
plural := pickPlural(kind, crd)
@@ -56,85 +46,37 @@ func (m *Manager) ensureFactory(ctx context.Context, crd *cozyv1alpha1.Cozystack
}
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",
},
},
},
},
// Use unified factory creation
config := UnifiedResourceConfig{
Name: factoryName,
ResourceType: "factory",
Kind: kind,
Plural: plural,
Title: strings.ToLower(plural),
Size: BadgeSizeLarge,
}
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,
},
},
},
}
spec := createUnifiedFactory(config, tabs, []any{resourceFetch})
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
}
// Add dashboard labels to dynamic resources
m.addDashboardLabels(obj, crd, ResourceTypeDynamic)
b, err := json.Marshal(spec)
if err != nil {
return err
}
obj.Spec = dashv1alpha1.ArbitrarySpec{JSON: apiextv1.JSON{Raw: b}}
// Only update spec if it's different to avoid unnecessary updates
newSpec := dashv1alpha1.ArbitrarySpec{JSON: apiextv1.JSON{Raw: b}}
if !compareArbitrarySpecs(obj.Spec, newSpec) {
obj.Spec = newSpec
}
return nil
})
return err
@@ -173,30 +115,10 @@ func detailsTab(kind, endpoint, schemaJSON string, keysOrder [][]string) map[str
"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",
},
},
},
createUnifiedBadgeFromKind("ns-badge", "Namespace", "namespace", BadgeSizeMedium),
antdLink("namespace-link",
"{reqsJsonPath[0]['.metadata.namespace']['-']}",
"/openapi-ui/{2}/factory/namespace-details/{reqsJsonPath[0]['.metadata.namespace']['-']}",
"/openapi-ui/{2}/{reqsJsonPath[0]['.metadata.namespace']['-']}/factory/marketplace",
),
},
},
@@ -407,131 +329,6 @@ func yamlTab(plural string) map[string]any {
}
}
// ---------------- 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 {
@@ -576,7 +373,7 @@ func sortFieldsByKeysOrder(fields []fieldInfo, keysOrder [][]string) []fieldInfo
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
@@ -666,7 +463,7 @@ func collectOpenAPILeafFields(schemaJSON string, maxDepth, maxFields int) []fiel
}
return
}
// Arrays: try to render item if its scalar and depth limit allows
// Arrays: try to render item if it's 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)
@@ -693,74 +490,6 @@ func collectOpenAPILeafFields(schemaJSON string, maxDepth, maxFields int) []fiel
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 {

View File

@@ -0,0 +1,553 @@
package dashboard
import (
"crypto/sha1"
"encoding/hex"
"encoding/json"
"fmt"
"reflect"
"regexp"
"sort"
"strings"
dashv1alpha1 "github.com/cozystack/cozystack/api/dashboard/v1alpha1"
cozyv1alpha1 "github.com/cozystack/cozystack/api/v1alpha1"
)
// ---------------- 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 ------------------
// 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
}
// Parse crd.APIVersion to get group and version (format: "group/version")
if crd.APIVersion != "" {
parts := strings.Split(crd.APIVersion, "/")
if len(parts) == 2 {
group = parts[0]
version = parts[1]
}
}
// 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])
}
// ----------------------- 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, true)
return values, nil
}
// processSchemaProperties recursively processes OpenAPI schema properties and extracts default values
func processSchemaProperties(props map[string]interface{}, path []string, values *[]interface{}, topLevel bool) {
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, false)
}
default:
// For primitive types, use default if present, otherwise zero value
val := defaultOrZero(sub)
// Only emit zero-value entries when at top level
if val != nil || topLevel {
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)
}
// --- 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, " / ")
}
// titleFromKindPlural returns a presentable plural label, e.g.:
// kind="VirtualMachine", plural="virtualmachines" => "VirtualMachines"
func titleFromKindPlural(kind, plural string) string {
return kind + "s"
}
// 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"},
}
}
// compareArbitrarySpecs compares two ArbitrarySpec objects by comparing their JSON content
func compareArbitrarySpecs(spec1, spec2 dashv1alpha1.ArbitrarySpec) bool {
// If both are empty, they're equal
if len(spec1.JSON.Raw) == 0 && len(spec2.JSON.Raw) == 0 {
return true
}
// If one is empty and the other is not, they're different
if len(spec1.JSON.Raw) == 0 || len(spec2.JSON.Raw) == 0 {
return false
}
// Parse and normalize both specs
norm1, err := normalizeJSONForComparison(spec1.JSON.Raw)
if err != nil {
return false
}
norm2, err := normalizeJSONForComparison(spec2.JSON.Raw)
if err != nil {
return false
}
// Compare normalized JSON
equal := string(norm1) == string(norm2)
return equal
}
// normalizeJSONForComparison normalizes JSON by sorting arrays and objects
func normalizeJSONForComparison(data []byte) ([]byte, error) {
var obj interface{}
if err := json.Unmarshal(data, &obj); err != nil {
return nil, err
}
// Recursively normalize the object
normalized := normalizeObject(obj)
// Re-marshal to get normalized JSON
return json.Marshal(normalized)
}
// normalizeObject recursively normalizes objects and arrays
func normalizeObject(obj interface{}) interface{} {
switch v := obj.(type) {
case map[string]interface{}:
// For maps, we don't need to sort keys as json.Marshal handles that
result := make(map[string]interface{})
for k, val := range v {
result[k] = normalizeObject(val)
}
return result
case []interface{}:
// For arrays, we need to sort them if they contain objects with comparable fields
if len(v) == 0 {
return v
}
// Check if this is an array of objects that can be sorted
if canSortArray(v) {
// Sort the array
sorted := make([]interface{}, len(v))
copy(sorted, v)
sortArray(sorted)
return sorted
}
// If we can't sort, just normalize each element
result := make([]interface{}, len(v))
for i, val := range v {
result[i] = normalizeObject(val)
}
return result
default:
return v
}
}
// canSortArray checks if an array can be sorted (contains objects with comparable fields)
func canSortArray(arr []interface{}) bool {
if len(arr) == 0 {
return false
}
// Check if all elements are objects
for _, item := range arr {
if _, ok := item.(map[string]interface{}); !ok {
return false
}
}
// Check if objects have comparable fields (like "path" for CustomFormsPrefill values)
firstObj, ok := arr[0].(map[string]interface{})
if !ok {
return false
}
// Look for "path" field which is used in CustomFormsPrefill values
if _, hasPath := firstObj["path"]; hasPath {
return true
}
return false
}
// sortArray sorts an array of objects by their "path" field
func sortArray(arr []interface{}) {
sort.Slice(arr, func(i, j int) bool {
objI, okI := arr[i].(map[string]interface{})
objJ, okJ := arr[j].(map[string]interface{})
if !okI || !okJ {
return false
}
pathI, hasPathI := objI["path"]
pathJ, hasPathJ := objJ["path"]
if !hasPathI || !hasPathJ {
return false
}
// Convert paths to strings for comparison
pathIStr := fmt.Sprintf("%v", pathI)
pathJStr := fmt.Sprintf("%v", pathJ)
return pathIStr < pathJStr
})
}

View File

@@ -2,25 +2,35 @@ 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"
)
const (
// Label keys for dashboard resource management
LabelManagedBy = "dashboard.cozystack.io/managed-by"
LabelResourceType = "dashboard.cozystack.io/resource-type"
LabelCRDName = "dashboard.cozystack.io/crd-name"
LabelCRDGroup = "dashboard.cozystack.io/crd-group"
LabelCRDVersion = "dashboard.cozystack.io/crd-version"
LabelCRDKind = "dashboard.cozystack.io/crd-kind"
LabelCRDPlural = "dashboard.cozystack.io/crd-plural"
// Label values
ManagedByValue = "cozystack-dashboard-controller"
ResourceTypeStatic = "static"
ResourceTypeDynamic = "dynamic"
)
// AddToScheme exposes dashboard types registration for controller setup.
func AddToScheme(s *runtime.Scheme) error {
return dashv1alpha1.AddToScheme(s)
@@ -63,281 +73,376 @@ func NewManager(c client.Client, scheme *runtime.Scheme, opts ...Option) *Manage
// - ensureSidebar (implemented)
// - ensureTableUriMapping (implemented)
func (m *Manager) EnsureForCRD(ctx context.Context, crd *cozyv1alpha1.CozystackResourceDefinition) (reconcile.Result, error) {
// Early return if crd.Spec.Dashboard is nil to prevent oscillation
if crd.Spec.Dashboard == nil {
return reconcile.Result{}, nil
}
// 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 -----------------------
// InitializeStaticResources creates all static dashboard resources once during controller startup
func (m *Manager) InitializeStaticResources(ctx context.Context) error {
return m.ensureStaticResources(ctx)
}
func (m *Manager) ensureMarketplacePanel(ctx context.Context, crd *cozyv1alpha1.CozystackResourceDefinition) (reconcile.Result, error) {
logger := log.FromContext(ctx)
// addDashboardLabels adds standard dashboard management labels to a resource
func (m *Manager) addDashboardLabels(obj client.Object, crd *cozyv1alpha1.CozystackResourceDefinition, resourceType string) {
labels := obj.GetLabels()
if labels == nil {
labels = make(map[string]string)
}
mp := &dashv1alpha1.MarketplacePanel{}
mp.Name = crd.Name // cluster-scoped resource, name mirrors CRD name
labels[LabelManagedBy] = ManagedByValue
labels[LabelResourceType] = resourceType
// 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 crd != nil {
g, v, kind := pickGVK(crd)
plural := pickPlural(kind, crd)
labels[LabelCRDName] = crd.Name
labels[LabelCRDGroup] = g
labels[LabelCRDVersion] = v
labels[LabelCRDKind] = kind
labels[LabelCRDPlural] = plural
}
obj.SetLabels(labels)
}
// getDashboardResourceSelector returns a label selector for dashboard-managed resources
func (m *Manager) getDashboardResourceSelector() client.MatchingLabels {
return client.MatchingLabels{
LabelManagedBy: ManagedByValue,
}
}
// getDynamicResourceSelector returns a label selector for dynamic dashboard resources
func (m *Manager) getDynamicResourceSelector() client.MatchingLabels {
return client.MatchingLabels{
LabelManagedBy: ManagedByValue,
LabelResourceType: ResourceTypeDynamic,
}
}
// getStaticResourceSelector returns a label selector for static dashboard resources
func (m *Manager) getStaticResourceSelector() client.MatchingLabels {
return client.MatchingLabels{
LabelManagedBy: ManagedByValue,
LabelResourceType: ResourceTypeStatic,
}
}
// CleanupOrphanedResources removes dashboard resources that are no longer needed
// This should be called after cache warming to ensure all current resources are known
func (m *Manager) CleanupOrphanedResources(ctx context.Context) error {
// Get all current CRDs to determine which resources should exist
var allCRDs []cozyv1alpha1.CozystackResourceDefinition
if m.crdListFn != nil {
s, err := m.crdListFn(ctx)
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},
allCRDs = s
} else {
var crdList cozyv1alpha1.CozystackResourceDefinitionList
if err := m.client.List(ctx, &crdList, &client.ListOptions{}); err != nil {
return err
}
return nil
allCRDs = crdList.Items
}
op, err := controllerutil.CreateOrUpdate(ctx, m.client, mp, mutate)
if err != nil {
return reconcile.Result{}, err
// Build a set of expected resource names for each type
expectedResources := m.buildExpectedResourceSet(allCRDs)
// Clean up each resource type
resourceTypes := []client.Object{
&dashv1alpha1.CustomColumnsOverride{},
&dashv1alpha1.CustomFormsOverride{},
&dashv1alpha1.CustomFormsPrefill{},
&dashv1alpha1.MarketplacePanel{},
&dashv1alpha1.Sidebar{},
&dashv1alpha1.TableUriMapping{},
&dashv1alpha1.Breadcrumb{},
&dashv1alpha1.Factory{},
}
switch op {
case controllerutil.OperationResultCreated:
logger.Info("Created MarketplacePanel", "name", mp.Name)
case controllerutil.OperationResultUpdated:
logger.Info("Updated MarketplacePanel", "name", mp.Name)
for _, resourceType := range resourceTypes {
if err := m.cleanupResourceType(ctx, resourceType, expectedResources); err != nil {
return err
}
}
return reconcile.Result{}, nil
return nil
}
// ----------------------- Helpers (OpenAPI → values) -----------------------
// buildExpectedResourceSet creates a map of expected resource names by type
func (m *Manager) buildExpectedResourceSet(crds []cozyv1alpha1.CozystackResourceDefinition) map[string]map[string]bool {
expected := make(map[string]map[string]bool)
// 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
// Initialize maps for each resource type
resourceTypes := []string{
"CustomColumnsOverride",
"CustomFormsOverride",
"CustomFormsPrefill",
"MarketplacePanel",
"Sidebar",
"TableUriMapping",
"Breadcrumb",
"Factory",
}
var values []interface{}
processSchemaProperties(props, []string{"spec"}, &values)
return values, nil
}
for _, rt := range resourceTypes {
expected[rt] = make(map[string]bool)
}
// 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 {
// Add static resources (these should always exist)
staticResources := CreateAllStaticResources()
for _, resource := range staticResources {
resourceType := resource.GetObjectKind().GroupVersionKind().Kind
if expected[resourceType] != nil {
expected[resourceType][resource.GetName()] = true
}
}
// Add dynamic resources based on current CRDs
for _, crd := range crds {
if crd.Spec.Dashboard == 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)
}
// Skip resources with non-empty spec.dashboard.name (tenant modules)
if strings.TrimSpace(crd.Spec.Dashboard.Name) != "" {
continue
}
g, v, kind := pickGVK(&crd)
plural := pickPlural(kind, &crd)
// CustomColumnsOverride
name := fmt.Sprintf("stock-namespace-%s.%s.%s", g, v, plural)
expected["CustomColumnsOverride"][name] = true
// CustomFormsOverride
name = fmt.Sprintf("%s.%s.%s", g, v, plural)
expected["CustomFormsOverride"][name] = true
// CustomFormsPrefill
expected["CustomFormsPrefill"][name] = true
// MarketplacePanel (name matches CRD name)
expected["MarketplacePanel"][crd.Name] = true
// Sidebar resources (multiple per CRD)
lowerKind := strings.ToLower(kind)
detailsID := fmt.Sprintf("stock-project-factory-%s-details", lowerKind)
expected["Sidebar"][detailsID] = true
// Add other stock sidebars that are created for each CRD
stockSidebars := []string{
"stock-instance-api-form",
"stock-instance-api-table",
"stock-instance-builtin-form",
"stock-instance-builtin-table",
"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",
}
for _, sidebarID := range stockSidebars {
expected["Sidebar"][sidebarID] = true
}
// TableUriMapping
name = fmt.Sprintf("stock-namespace-%s.%s.%s", g, v, plural)
expected["TableUriMapping"][name] = true
// Breadcrumb
detailID := fmt.Sprintf("stock-project-factory-%s-details", lowerKind)
expected["Breadcrumb"][detailID] = true
// Factory
factoryName := fmt.Sprintf("%s-details", lowerKind)
expected["Factory"][factoryName] = true
}
return expected
}
// 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)
// cleanupResourceType removes orphaned resources of a specific type
func (m *Manager) cleanupResourceType(ctx context.Context, resourceType client.Object, expectedResources map[string]map[string]bool) error {
var (
list client.ObjectList
resourceKind string
)
switch resourceType.(type) {
case *dashv1alpha1.CustomColumnsOverride:
list = &dashv1alpha1.CustomColumnsOverrideList{}
resourceKind = "CustomColumnsOverride"
case *dashv1alpha1.CustomFormsOverride:
list = &dashv1alpha1.CustomFormsOverrideList{}
resourceKind = "CustomFormsOverride"
case *dashv1alpha1.CustomFormsPrefill:
list = &dashv1alpha1.CustomFormsPrefillList{}
resourceKind = "CustomFormsPrefill"
case *dashv1alpha1.MarketplacePanel:
list = &dashv1alpha1.MarketplacePanelList{}
resourceKind = "MarketplacePanel"
case *dashv1alpha1.Sidebar:
list = &dashv1alpha1.SidebarList{}
resourceKind = "Sidebar"
case *dashv1alpha1.TableUriMapping:
list = &dashv1alpha1.TableUriMappingList{}
resourceKind = "TableUriMapping"
case *dashv1alpha1.Breadcrumb:
list = &dashv1alpha1.BreadcrumbList{}
resourceKind = "Breadcrumb"
case *dashv1alpha1.Factory:
list = &dashv1alpha1.FactoryList{}
resourceKind = "Factory"
default:
return v
return nil // Unknown type
}
}
var camelSplitter = regexp.MustCompile(`(?m)([A-Z]+[a-z0-9]*|[a-z0-9]+)`)
expected := expectedResources[resourceKind]
if expected == nil {
return nil // No expected resources for this type
}
func splitCamel(s string) []string {
return camelSplitter.FindAllString(s, -1)
// List with dashboard labels
if err := m.client.List(ctx, list, m.getDashboardResourceSelector()); err != nil {
return err
}
// Delete resources that are not in the expected set
switch l := list.(type) {
case *dashv1alpha1.CustomColumnsOverrideList:
for _, item := range l.Items {
if !expected[item.Name] {
if err := m.client.Delete(ctx, &item); err != nil {
if !apierrors.IsNotFound(err) {
return err
}
// Resource already deleted, continue
}
}
}
case *dashv1alpha1.CustomFormsOverrideList:
for _, item := range l.Items {
if !expected[item.Name] {
if err := m.client.Delete(ctx, &item); err != nil {
if !apierrors.IsNotFound(err) {
return err
}
// Resource already deleted, continue
}
}
}
case *dashv1alpha1.CustomFormsPrefillList:
for _, item := range l.Items {
if !expected[item.Name] {
if err := m.client.Delete(ctx, &item); err != nil {
if !apierrors.IsNotFound(err) {
return err
}
// Resource already deleted, continue
}
}
}
case *dashv1alpha1.MarketplacePanelList:
for _, item := range l.Items {
if !expected[item.Name] {
if err := m.client.Delete(ctx, &item); err != nil {
if !apierrors.IsNotFound(err) {
return err
}
// Resource already deleted, continue
}
}
}
case *dashv1alpha1.SidebarList:
for _, item := range l.Items {
if !expected[item.Name] {
if err := m.client.Delete(ctx, &item); err != nil {
if !apierrors.IsNotFound(err) {
return err
}
// Resource already deleted, continue
}
}
}
case *dashv1alpha1.TableUriMappingList:
for _, item := range l.Items {
if !expected[item.Name] {
if err := m.client.Delete(ctx, &item); err != nil {
if !apierrors.IsNotFound(err) {
return err
}
// Resource already deleted, continue
}
}
}
case *dashv1alpha1.BreadcrumbList:
for _, item := range l.Items {
if !expected[item.Name] {
if err := m.client.Delete(ctx, &item); err != nil {
if !apierrors.IsNotFound(err) {
return err
}
// Resource already deleted, continue
}
}
}
case *dashv1alpha1.FactoryList:
for _, item := range l.Items {
if !expected[item.Name] {
if err := m.client.Delete(ctx, &item); err != nil {
if !apierrors.IsNotFound(err) {
return err
}
// Resource already deleted, continue
}
}
}
}
return nil
}

View File

@@ -0,0 +1,112 @@
package dashboard
import (
"context"
"encoding/json"
"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"
"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"
)
// ensureMarketplacePanel creates or updates a MarketplacePanel resource for the given CRD
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
}
_, err = controllerutil.CreateOrUpdate(ctx, m.client, mp, func() error {
if err := controllerutil.SetOwnerReference(crd, mp, m.scheme); err != nil {
return err
}
// Add dashboard labels to dynamic resources
m.addDashboardLabels(mp, crd, ResourceTypeDynamic)
// Only update spec if it's different to avoid unnecessary updates
newSpec := dashv1alpha1.ArbitrarySpec{
JSON: apiextv1.JSON{Raw: specBytes},
}
if !compareArbitrarySpecs(mp.Spec, newSpec) {
mp.Spec = newSpec
}
return nil
})
if err != nil {
return reconcile.Result{}, err
}
logger.Info("Applied MarketplacePanel", "name", mp.Name)
return reconcile.Result{}, nil
}

View File

@@ -11,7 +11,6 @@ import (
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"
)
@@ -217,22 +216,24 @@ func (m *Manager) upsertMultipleSidebars(
}
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
}
// Add dashboard labels to dynamic resources
m.addDashboardLabels(obj, crd, ResourceTypeDynamic)
b, err := json.Marshal(spec)
if err != nil {
return err
}
obj.Spec = dashv1alpha1.ArbitrarySpec{JSON: apiextv1.JSON{Raw: b}}
// Only update spec if it's different to avoid unnecessary updates
newSpec := dashv1alpha1.ArbitrarySpec{JSON: apiextv1.JSON{Raw: b}}
if !compareArbitrarySpecs(obj.Spec, newSpec) {
obj.Spec = newSpec
}
return nil
}); err != nil {
return err

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,59 @@
package dashboard
import (
"context"
dashv1alpha1 "github.com/cozystack/cozystack/api/dashboard/v1alpha1"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
)
// ensureStaticResources ensures all static dashboard resources are created
func (m *Manager) ensureStaticResources(ctx context.Context) error {
// Use refactored resources from static_refactored.go
// This replaces the old static variables with dynamic creation using helper functions
staticResources := CreateAllStaticResources()
// Create or update each static resource
for _, resource := range staticResources {
if err := m.ensureStaticResource(ctx, resource); err != nil {
return err
}
}
return nil
}
// ensureStaticResource creates or updates a single static resource
func (m *Manager) ensureStaticResource(ctx context.Context, obj client.Object) error {
// Create a copy to avoid modifying the original
resource := obj.DeepCopyObject().(client.Object)
// Add dashboard labels to static resources
m.addDashboardLabels(resource, nil, ResourceTypeStatic)
_, err := controllerutil.CreateOrUpdate(ctx, m.client, resource, func() error {
// For static resources, we don't need to set owner references
// as they are meant to be persistent across CRD changes
// Copy Spec from the original object to the live object
switch o := obj.(type) {
case *dashv1alpha1.CustomColumnsOverride:
resource.(*dashv1alpha1.CustomColumnsOverride).Spec = o.Spec
case *dashv1alpha1.Breadcrumb:
resource.(*dashv1alpha1.Breadcrumb).Spec = o.Spec
case *dashv1alpha1.CustomFormsOverride:
resource.(*dashv1alpha1.CustomFormsOverride).Spec = o.Spec
case *dashv1alpha1.Factory:
resource.(*dashv1alpha1.Factory).Spec = o.Spec
case *dashv1alpha1.Navigation:
resource.(*dashv1alpha1.Navigation).Spec = o.Spec
case *dashv1alpha1.TableUriMapping:
resource.(*dashv1alpha1.TableUriMapping).Spec = o.Spec
}
// Ensure labels are always set
m.addDashboardLabels(resource, nil, ResourceTypeStatic)
return nil
})
return err
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,13 @@
package dashboard
import (
"context"
cozyv1alpha1 "github.com/cozystack/cozystack/api/v1alpha1"
)
// ensureTableUriMapping creates or updates a TableUriMapping resource for the given CRD
func (m *Manager) ensureTableUriMapping(ctx context.Context, crd *cozyv1alpha1.CozystackResourceDefinition) error {
// Links are fully managed by the CustomColumnsOverride.
return nil
}

View File

@@ -0,0 +1,209 @@
package dashboard
import "strings"
// ---------------- UI helpers (use float64 for numeric fields) ----------------
func contentCard(id string, style map[string]any, children []any) map[string]any {
return contentCardWithTitle(id, "", style, children)
}
func contentCardWithTitle(id any, title string, style map[string]any, children []any) map[string]any {
data := map[string]any{
"id": id,
"style": style,
}
if title != "" {
data["title"] = title
}
return map[string]any{
"type": "ContentCard",
"data": data,
"children": children,
}
}
func antdText(id string, strong bool, text string, style map[string]any) map[string]any {
// Auto-generate ID if not provided
if id == "" {
id = generateTextID("auto", "antd")
}
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 {
// Auto-generate ID if not provided
if id == "" {
id = generateTextID("auto", "parsed")
}
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 {
// Auto-generate ID if not provided
if id == "" {
id = generateTextID("auto", "formatted")
}
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 {
// Auto-generate ID if not provided
if id == "" {
id = generateContainerID("auto", "spacer")
}
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 {
// Auto-generate ID if not provided
if id == "" {
id = generateContainerID("auto", "flex")
}
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 {
// Auto-generate ID if not provided
if id == "" {
id = generateContainerID("auto", "flex-vertical")
}
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 {
// Auto-generate ID if not provided
if id == "" {
id = generateContainerID("auto", "row")
}
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,
},
}
}
// ---------------- Badge helpers ----------------
// createBadge creates a badge element with the given text, color, and title
func createBadge(id, text, color, title string) map[string]any {
return map[string]any{
"type": "antdText",
"data": map[string]any{
"id": id,
"text": text,
"title": title,
"style": map[string]any{
"whiteSpace": "nowrap",
"backgroundColor": color,
"fontWeight": 400,
"lineHeight": "24px",
"minWidth": 24,
"textAlign": "center",
"borderRadius": "20px",
"color": "#fff",
"display": "inline-block",
"fontFamily": "RedHatDisplay, Overpass, overpass, helvetica, arial, sans-serif",
"fontSize": "15px",
"padding": "0 9px",
},
},
}
}
// createBadgeFromKind creates a badge using the existing badge generation functions
func createBadgeFromKind(id, kind, title string) map[string]any {
return createUnifiedBadgeFromKind(id, kind, title, BadgeSizeMedium)
}
// createHeaderBadge creates a badge specifically for headers with consistent styling
func createHeaderBadge(id, kind, plural string) map[string]any {
return createUnifiedBadgeFromKind(id, kind, strings.ToLower(plural), BadgeSizeLarge)
}

View File

@@ -0,0 +1,407 @@
package dashboard
import (
"crypto/sha1"
"fmt"
"strings"
)
// ---------------- Unified ID generation helpers ----------------
// generateID creates a unique ID based on the provided components
func generateID(components ...string) string {
if len(components) == 0 {
return ""
}
// Join components with hyphens and convert to lowercase
id := strings.ToLower(strings.Join(components, "-"))
// Remove any special characters that might cause issues
id = strings.ReplaceAll(id, ".", "-")
id = strings.ReplaceAll(id, "/", "-")
id = strings.ReplaceAll(id, " ", "-")
// Remove multiple consecutive hyphens
for strings.Contains(id, "--") {
id = strings.ReplaceAll(id, "--", "-")
}
// Remove leading/trailing hyphens
id = strings.Trim(id, "-")
return id
}
// generateSpecID creates a spec.id from metadata.name and other components
func generateSpecID(metadataName string, components ...string) string {
allComponents := append([]string{metadataName}, components...)
return generateID(allComponents...)
}
// generateMetadataName creates metadata.name from spec.id
func generateMetadataName(specID string) string {
// Convert ID format to metadata.name format
// Replace / with . for metadata.name
name := strings.ReplaceAll(specID, "/", ".")
// Clean up the name to be RFC 1123 compliant
// Remove any leading/trailing dots and ensure it starts/ends with alphanumeric
name = strings.Trim(name, ".")
// Replace multiple consecutive dots with single dot
for strings.Contains(name, "..") {
name = strings.ReplaceAll(name, "..", ".")
}
// Replace any remaining problematic patterns
// Handle cases like "stock-namespace-.v1" -> "stock-namespace-v1"
name = strings.ReplaceAll(name, "-.", "-")
name = strings.ReplaceAll(name, ".-", "-")
// Ensure it starts with alphanumeric character
if len(name) > 0 && !isAlphanumeric(name[0]) {
name = "a" + name
}
// Ensure it ends with alphanumeric character
if len(name) > 0 && !isAlphanumeric(name[len(name)-1]) {
name = name + "a"
}
return name
}
// isAlphanumeric checks if a character is alphanumeric
func isAlphanumeric(c byte) bool {
return (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')
}
// ---------------- Unified badge generation helpers ----------------
// BadgeConfig holds configuration for badge generation
type BadgeConfig struct {
Text string
Color string
Title string
Size BadgeSize
}
// BadgeSize represents the size of the badge
type BadgeSize int
const (
BadgeSizeSmall BadgeSize = iota
BadgeSizeMedium
BadgeSizeLarge
)
// generateBadgeConfig creates a BadgeConfig from kind and optional custom values
func generateBadgeConfig(kind string, customText, customColor, customTitle string) BadgeConfig {
config := BadgeConfig{
Text: initialsFromKind(kind),
Color: hexColorForKind(kind),
Title: strings.ToLower(kind),
Size: BadgeSizeMedium,
}
// Override with custom values if provided
if customText != "" {
config.Text = customText
}
if customColor != "" {
config.Color = customColor
}
if customTitle != "" {
config.Title = customTitle
}
return config
}
// createUnifiedBadge creates a badge using the unified BadgeConfig
func createUnifiedBadge(id string, config BadgeConfig) map[string]any {
fontSize := "15px"
if config.Size == BadgeSizeLarge {
fontSize = "20px"
} else if config.Size == BadgeSizeSmall {
fontSize = "12px"
}
return map[string]any{
"type": "antdText",
"data": map[string]any{
"id": id,
"text": config.Text,
"title": config.Title,
"style": map[string]any{
"backgroundColor": config.Color,
"borderRadius": "20px",
"color": "#fff",
"display": "inline-block",
"fontFamily": "RedHatDisplay, Overpass, overpass, helvetica, arial, sans-serif",
"fontSize": fontSize,
"fontWeight": float64(400),
"lineHeight": "24px",
"minWidth": float64(24),
"padding": "0 9px",
"textAlign": "center",
"whiteSpace": "nowrap",
},
},
}
}
// createUnifiedBadgeFromKind creates a badge from kind with automatic color generation
func createUnifiedBadgeFromKind(id, kind, title string, size BadgeSize) map[string]any {
config := BadgeConfig{
Text: initialsFromKind(kind),
Color: hexColorForKind(kind),
Title: title,
Size: size,
}
return createUnifiedBadge(id, config)
}
// ---------------- Resource creation helpers with unified approach ----------------
// ResourceConfig holds configuration for resource creation
type ResourceConfig struct {
SpecID string
MetadataName string
Kind string
Title string
BadgeConfig BadgeConfig
}
// createResourceConfig creates a ResourceConfig from components
func createResourceConfig(components []string, kind, title string) ResourceConfig {
// Generate spec.id from components
specID := generateID(components...)
// Generate metadata.name from spec.id
metadataName := generateMetadataName(specID)
// Generate badge config
badgeConfig := generateBadgeConfig(kind, "", "", title)
return ResourceConfig{
SpecID: specID,
MetadataName: metadataName,
Kind: kind,
Title: title,
BadgeConfig: badgeConfig,
}
}
// ---------------- Enhanced color generation ----------------
// getColorForKind returns a color for a specific kind with improved distribution
func getColorForKind(kind string) string {
// Use existing hexColorForKind function
return hexColorForKind(kind)
}
// getColorForType returns a color for a specific type (like "namespace", "service", etc.)
func getColorForType(typeName string) string {
// Map common types to specific colors for consistency
colorMap := map[string]string{
"namespace": "#a25792ff",
"service": "#6ca100",
"pod": "#009596",
"node": "#8476d1",
"secret": "#c46100",
"configmap": "#b48c78ff",
"ingress": "#2e7dff",
"workloadmonitor": "#c46100",
"module": "#8b5cf6",
}
if color, exists := colorMap[strings.ToLower(typeName)]; exists {
return color
}
// Fall back to hash-based color generation
return hexColorForKind(typeName)
}
// ---------------- Automatic ID generation for UI elements ----------------
// generateElementID creates an ID for UI elements based on context and type
func generateElementID(elementType, context string, components ...string) string {
allComponents := append([]string{elementType, context}, components...)
return generateID(allComponents...)
}
// generateBadgeID creates an ID for badge elements
func generateBadgeID(context string, kind string) string {
return generateElementID("badge", context, kind)
}
// generateLinkID creates an ID for link elements
func generateLinkID(context string, linkType string) string {
return generateElementID("link", context, linkType)
}
// generateTextID creates an ID for text elements
func generateTextID(context string, textType string) string {
return generateElementID("text", context, textType)
}
// generateContainerID creates an ID for container elements
func generateContainerID(context string, containerType string) string {
return generateElementID("container", context, containerType)
}
// generateTableID creates an ID for table elements
func generateTableID(context string, tableType string) string {
return generateElementID("table", context, tableType)
}
// ---------------- Enhanced resource creation with automatic IDs ----------------
// createResourceWithAutoID creates a resource with automatically generated IDs
func createResourceWithAutoID(resourceType, name string, spec map[string]any) map[string]any {
// Generate spec.id from name
specID := generateSpecID(name)
// Add the spec.id to the spec
spec["id"] = specID
return spec
}
// ---------------- Unified resource creation helpers ----------------
// UnifiedResourceConfig holds configuration for unified resource creation
type UnifiedResourceConfig struct {
Name string
ResourceType string
Kind string
Plural string
Title string
Color string
BadgeText string
Size BadgeSize
}
// createUnifiedFactory creates a factory using unified approach
func createUnifiedFactory(config UnifiedResourceConfig, tabs []any, urlsToFetch []any) map[string]any {
// Generate spec.id from name
specID := generateSpecID(config.Name)
// Create header with unified badge
badgeConfig := BadgeConfig{
Text: config.BadgeText,
Color: config.Color,
Title: config.Title,
Size: config.Size,
}
if badgeConfig.Text == "" {
badgeConfig.Text = initialsFromKind(config.Kind)
}
if badgeConfig.Color == "" {
badgeConfig.Color = getColorForKind(config.Kind)
}
badge := createUnifiedBadge(generateBadgeID("header", config.Kind), badgeConfig)
nameText := parsedText(generateTextID("header", "name"), "{reqsJsonPath[0]['.metadata.name']['-']}", map[string]any{
"fontFamily": "RedHatDisplay, Overpass, overpass, helvetica, arial, sans-serif",
"fontSize": float64(20),
"lineHeight": "24px",
})
header := antdFlex(generateContainerID("header", "row"), float64(6), []any{
badge,
nameText,
})
// Add marginBottom style to header
if headerData, ok := header["data"].(map[string]any); ok {
if headerData["style"] == nil {
headerData["style"] = map[string]any{}
}
if style, ok := headerData["style"].(map[string]any); ok {
style["marginBottom"] = float64(24)
}
}
return map[string]any{
"key": config.Name,
"id": specID,
"sidebarTags": []any{fmt.Sprintf("%s-sidebar", strings.ToLower(config.Kind))},
"withScrollableMainContentCard": true,
"urlsToFetch": urlsToFetch,
"data": []any{
header,
map[string]any{
"type": "antdTabs",
"data": map[string]any{
"id": generateContainerID("tabs", strings.ToLower(config.Kind)),
"defaultActiveKey": "details",
"items": tabs,
},
},
},
}
}
// createUnifiedCustomColumn creates a custom column using unified approach
func createUnifiedCustomColumn(name, jsonPath, kind, title, href string) map[string]any {
badgeConfig := generateBadgeConfig(kind, "", "", title)
badge := createUnifiedBadge(generateBadgeID("column", kind), badgeConfig)
linkID := generateLinkID("column", "name")
if jsonPath == ".metadata.namespace" {
linkID = generateLinkID("column", "namespace")
}
link := antdLink(linkID, "{reqsJsonPath[0]['"+jsonPath+"']['-']}", href)
return map[string]any{
"name": name,
"type": "factory",
"jsonPath": jsonPath,
"customProps": map[string]any{
"disableEventBubbling": true,
"items": []any{
map[string]any{
"type": "antdFlex",
"data": map[string]any{
"id": generateContainerID("column", "header"),
"align": "center",
"gap": float64(6),
},
"children": []any{badge, link},
},
},
},
}
}
// ---------------- Utility functions ----------------
// hashString creates a short hash from a string for ID generation
func hashString(s string) string {
hash := sha1.Sum([]byte(s))
return fmt.Sprintf("%x", hash[:4])
}
// sanitizeForID removes characters that shouldn't be in IDs
func sanitizeForID(s string) string {
// Replace problematic characters
s = strings.ReplaceAll(s, ".", "-")
s = strings.ReplaceAll(s, "/", "-")
s = strings.ReplaceAll(s, " ", "-")
s = strings.ReplaceAll(s, "_", "-")
// Remove multiple consecutive hyphens
for strings.Contains(s, "--") {
s = strings.ReplaceAll(s, "--", "-")
}
// Remove leading/trailing hyphens
s = strings.Trim(s, "-")
return strings.ToLower(s)
}

View File

@@ -1,266 +0,0 @@
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"},
}
}