mirror of
https://github.com/outbackdingo/cozystack.git
synced 2026-01-27 10:18:39 +00:00
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:
@@ -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
|
||||
}
|
||||
|
||||
298
internal/controller/dashboard/customcolumns.go
Normal file
298
internal/controller/dashboard/customcolumns.go
Normal 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])
|
||||
}
|
||||
784
internal/controller/dashboard/factory.go
Normal file
784
internal/controller/dashboard/factory.go
Normal 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 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)
|
||||
}
|
||||
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
|
||||
}
|
||||
343
internal/controller/dashboard/manager.go
Normal file
343
internal/controller/dashboard/manager.go
Normal 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.
|
||||
// It’s 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)
|
||||
}
|
||||
310
internal/controller/dashboard/sidebar.go
Normal file
310
internal/controller/dashboard/sidebar.go
Normal 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)
|
||||
}
|
||||
266
internal/controller/dashboard/webextras.go
Normal file
266
internal/controller/dashboard/webextras.go
Normal 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"},
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user