From 1bacf2d2cd1bcdfad1702d08616c32dd4ac7b8c5 Mon Sep 17 00:00:00 2001 From: Zhiqiang ZHOU Date: Sun, 26 Jul 2026 11:22:33 -0700 Subject: [PATCH] feat: add built-in project dashboard --- .dockerignore | 1 + cmd/main.go | 20 + .../templates/dashboard-service.yaml | 18 + .../templates/deployment.yaml | 8 +- helm/supabase-operator/values.yaml | 4 + internal/dashboard/server.go | 123 ++++++ internal/dashboard/web.go | 6 + internal/dashboard/web/index.html | 365 ++++++++++++++++++ 8 files changed, 543 insertions(+), 2 deletions(-) create mode 100644 helm/supabase-operator/templates/dashboard-service.yaml create mode 100644 internal/dashboard/server.go create mode 100644 internal/dashboard/web.go create mode 100644 internal/dashboard/web/index.html diff --git a/.dockerignore b/.dockerignore index 883f3e1..b5fbdea 100644 --- a/.dockerignore +++ b/.dockerignore @@ -12,3 +12,4 @@ # Re-include embedded SQL migration files !internal/database/migrations/sql/*.sql +!internal/dashboard/web/*.html diff --git a/cmd/main.go b/cmd/main.go index 5676655..fc1ef77 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -3,7 +3,9 @@ package main import ( "crypto/tls" "flag" + "net/http" "os" + "time" // Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.) // to ensure that exec-entrypoint and run can make use of them. @@ -15,12 +17,14 @@ import ( ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/healthz" "sigs.k8s.io/controller-runtime/pkg/log/zap" + "sigs.k8s.io/controller-runtime/pkg/manager" "sigs.k8s.io/controller-runtime/pkg/metrics/filters" metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" "sigs.k8s.io/controller-runtime/pkg/webhook" supabasev1alpha1 "github.com/strrl/supabase-operator/api/v1alpha1" "github.com/strrl/supabase-operator/internal/controller" + "github.com/strrl/supabase-operator/internal/dashboard" internalwebhook "github.com/strrl/supabase-operator/internal/webhook" // +kubebuilder:scaffold:imports ) @@ -44,12 +48,14 @@ func main() { var webhookCertPath, webhookCertName, webhookCertKey string var enableLeaderElection bool var probeAddr string + var dashboardAddr string var secureMetrics bool var enableHTTP2 bool var tlsOpts []func(*tls.Config) flag.StringVar(&metricsAddr, "metrics-bind-address", "0", "The address the metrics endpoint binds to. "+ "Use :8443 for HTTPS or :8080 for HTTP, or leave as 0 to disable the metrics service.") flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") + flag.StringVar(&dashboardAddr, "dashboard-bind-address", ":8080", "The address the dashboard binds to.") flag.BoolVar(&enableLeaderElection, "leader-elect", false, "Enable leader election for controller manager. "+ "Enabling this will ensure there is only one active controller manager.") @@ -178,6 +184,20 @@ func main() { } // +kubebuilder:scaffold:builder + dashboardShutdownTimeout := 5 * time.Second + if err := mgr.Add(&manager.Server{ + Name: "dashboard", + Server: &http.Server{ + Addr: dashboardAddr, + Handler: dashboard.NewHandler(mgr.GetAPIReader()), + ReadHeaderTimeout: 5 * time.Second, + }, + ShutdownTimeout: &dashboardShutdownTimeout, + }); err != nil { + setupLog.Error(err, "unable to create dashboard") + os.Exit(1) + } + if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { setupLog.Error(err, "unable to set up health check") os.Exit(1) diff --git a/helm/supabase-operator/templates/dashboard-service.yaml b/helm/supabase-operator/templates/dashboard-service.yaml new file mode 100644 index 0000000..a3ed824 --- /dev/null +++ b/helm/supabase-operator/templates/dashboard-service.yaml @@ -0,0 +1,18 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ printf "%s-dashboard" (include "supabase-operator.fullname" .) }} + labels: + {{- include "supabase-operator.labels" . | nindent 4 }} + {{- with .Values.dashboard.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + ports: + - name: http + port: {{ .Values.dashboard.port }} + protocol: TCP + targetPort: dashboard + selector: + {{- include "supabase-operator.selectorLabels" . | nindent 4 }} diff --git a/helm/supabase-operator/templates/deployment.yaml b/helm/supabase-operator/templates/deployment.yaml index 47f1d12..b396029 100644 --- a/helm/supabase-operator/templates/deployment.yaml +++ b/helm/supabase-operator/templates/deployment.yaml @@ -47,6 +47,7 @@ spec: {{- else }} - --metrics-bind-address=0 {{- end }} + - --dashboard-bind-address=:{{ .Values.dashboard.port }} {{- range .Values.extraArgs }} - {{ . | quote }} {{- end }} @@ -56,12 +57,15 @@ spec: {{- tpl (toYaml .) $ | nindent 12 }} {{- end }} {{- end }} - {{- if .Values.webhook.enabled }} ports: + - containerPort: {{ .Values.dashboard.port }} + name: dashboard + protocol: TCP + {{- if .Values.webhook.enabled }} - containerPort: {{ .Values.webhook.targetPort }} name: webhook-server protocol: TCP - {{- end }} + {{- end }} livenessProbe: httpGet: path: /healthz diff --git a/helm/supabase-operator/values.yaml b/helm/supabase-operator/values.yaml index a0abec4..85a8d29 100644 --- a/helm/supabase-operator/values.yaml +++ b/helm/supabase-operator/values.yaml @@ -55,6 +55,10 @@ metricsService: annotations: {} port: 8443 +dashboard: + annotations: {} + port: 8080 + priorityClassName: "" extraArgs: [] extraEnv: [] diff --git a/internal/dashboard/server.go b/internal/dashboard/server.go new file mode 100644 index 0000000..9d0c2bd --- /dev/null +++ b/internal/dashboard/server.go @@ -0,0 +1,123 @@ +package dashboard + +import ( + "encoding/json" + "net/http" + "sort" + + "sigs.k8s.io/controller-runtime/pkg/client" + + supabasev1alpha1 "github.com/strrl/supabase-operator/api/v1alpha1" +) + +type projectListResponse struct { + Projects []projectResponse `json:"projects"` +} + +type projectResponse struct { + Namespace string `json:"namespace"` + Name string `json:"name"` + ProjectID string `json:"projectId"` + Phase string `json:"phase"` + Message string `json:"message"` + ReadyComponents int `json:"readyComponents"` + TotalComponents int `json:"totalComponents"` + Components []componentResponse `json:"components"` + UpdatedAt string `json:"updatedAt,omitempty"` +} + +type componentResponse struct { + Name string `json:"name"` + Phase string `json:"phase"` + Ready bool `json:"ready"` + ReadyReplicas int32 `json:"readyReplicas"` + Replicas int32 `json:"replicas"` +} + +type handler struct { + reader client.Reader +} + +func NewHandler(reader client.Reader) http.Handler { + dashboardHandler := &handler{reader: reader} + mux := http.NewServeMux() + mux.HandleFunc("GET /api/projects", dashboardHandler.listProjects) + mux.HandleFunc("GET /", dashboardHandler.index) + return mux +} + +func (h *handler) listProjects(responseWriter http.ResponseWriter, request *http.Request) { + projects := &supabasev1alpha1.SupabaseProjectList{} + if err := h.reader.List(request.Context(), projects); err != nil { + http.Error(responseWriter, "failed to list projects", http.StatusInternalServerError) + return + } + + response := projectListResponse{ + Projects: make([]projectResponse, 0, len(projects.Items)), + } + for index := range projects.Items { + response.Projects = append(response.Projects, newProjectResponse(&projects.Items[index])) + } + sort.Slice(response.Projects, func(left, right int) bool { + leftName := response.Projects[left].Namespace + "/" + response.Projects[left].Name + rightName := response.Projects[right].Namespace + "/" + response.Projects[right].Name + return leftName < rightName + }) + + responseWriter.Header().Set("Cache-Control", "no-store") + responseWriter.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(responseWriter).Encode(response); err != nil { + return + } +} + +func (h *handler) index(responseWriter http.ResponseWriter, _ *http.Request) { + responseWriter.Header().Set("Cache-Control", "no-cache") + responseWriter.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = responseWriter.Write(indexHTML) +} + +func newProjectResponse(project *supabasev1alpha1.SupabaseProject) projectResponse { + components := []componentResponse{ + newComponentResponse("Kong", project.Status.Components.Kong), + newComponentResponse("Auth", project.Status.Components.Auth), + newComponentResponse("Realtime", project.Status.Components.Realtime), + newComponentResponse("PostgREST", project.Status.Components.PostgREST), + newComponentResponse("Storage", project.Status.Components.StorageAPI), + newComponentResponse("Meta", project.Status.Components.Meta), + newComponentResponse("Studio", project.Status.Components.Studio), + } + + readyComponents := 0 + for _, component := range components { + if component.Ready { + readyComponents++ + } + } + + response := projectResponse{ + Namespace: project.Namespace, + Name: project.Name, + ProjectID: project.Spec.ProjectID, + Phase: project.Status.Phase, + Message: project.Status.Message, + ReadyComponents: readyComponents, + TotalComponents: len(components), + Components: components, + } + if project.Status.LastReconcileTime != nil { + response.UpdatedAt = project.Status.LastReconcileTime.UTC().Format("2006-01-02T15:04:05Z") + } + return response +} + +func newComponentResponse(name string, component supabasev1alpha1.ComponentStatus) componentResponse { + return componentResponse{ + Name: name, + Phase: component.Phase, + Ready: component.Ready, + ReadyReplicas: component.ReadyReplicas, + Replicas: component.Replicas, + } +} diff --git a/internal/dashboard/web.go b/internal/dashboard/web.go new file mode 100644 index 0000000..c64d28e --- /dev/null +++ b/internal/dashboard/web.go @@ -0,0 +1,6 @@ +package dashboard + +import _ "embed" + +//go:embed web/index.html +var indexHTML []byte diff --git a/internal/dashboard/web/index.html b/internal/dashboard/web/index.html new file mode 100644 index 0000000..69636de --- /dev/null +++ b/internal/dashboard/web/index.html @@ -0,0 +1,365 @@ + + + + + + Supabase Operator + + + +
+
+
+ +
+

Supabase Operator

+

Kubernetes project control plane

+
+
+ +
+ +
+

Projects

+

Loading projects

+
+ +
+
Loading projects
+
+
+ + + +