-
Notifications
You must be signed in to change notification settings - Fork 1
feat: add built-in project dashboard #21
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 }} | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
In multi-tenant clusters without restrictive NetworkPolicies, any pod that reaches the new dashboard ClusterIP can call this unauthenticated handler. Because it uses the operator's cluster-wide Useful? React with 👍 / 👎. |
||
| 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, | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| package dashboard | ||
|
|
||
| import _ "embed" | ||
|
|
||
| //go:embed web/index.html | ||
| var indexHTML []byte |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When the generated fullname exceeds 53 characters, appending
-dashboardproduces a Service name longer than Kubernetes' 63-character limit because the fullname helper truncates only before this suffix is added. For example, an installation using a longfullnameOverrideandmetricsService.enabled=falsepreviously rendered valid resources but is now rejected for this Service; truncate the final suffixed name as the webhook service helper does.Useful? React with 👍 / 👎.