diff --git a/.gitignore b/.gitignore index 06989d0..a9c48b0 100644 --- a/.gitignore +++ b/.gitignore @@ -38,4 +38,8 @@ docs/.hugo_build.lock # Generated docs-gen binary /gen-ai-docs + +# Generated drop-ui binary +/ui +/drop-ui .kubeconfig diff --git a/Makefile b/Makefile index de99a67..f5c4315 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,6 @@ # Image URL to use all building/pushing image targets IMG ?= controller:latest +IMG_UI ?= drop-ui:latest # Get the currently used golang install path (in GOPATH/bin, unless GOBIN is set) ifeq (,$(shell go env GOBIN)) @@ -27,10 +28,18 @@ help: ## Display this help. build: ## Build manager binary. go build -o bin/manager cmd/main.go +.PHONY: build-ui +build-ui: ## Build Drop Control Center UI binary. + go build -o bin/drop-ui ./cmd/ui/ + .PHONY: run run: ## Run controller from your host. go run ./cmd/main.go +.PHONY: run-ui +run-ui: ## Run Drop Control Center UI from your host (requires kubeconfig). + go run ./cmd/ui/ + .PHONY: fmt fmt: ## Run go fmt. go fmt ./... diff --git a/README.md b/README.md index 308e612..568230a 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,46 @@ go build ./... # compile tilt up ``` +## Drop Control Center UI + +A cyberpunk-themed tactical web UI for visualising the drop operator in real-time. + +### Views + +| View | Description | +|------|-------------| +| **TACTICAL** | Animated canvas radar showing nodes as stations, live "drop pod" ships flying from the central nexus to nodes during pulls, particle bursts on success | +| **MATRIX** | Searchable & sortable table of all `CachedImage` resources with progress bars, cached-node chips, and phase filters | +| **RECON** | Discovery policy inspector with YAML viewer, sync metadata, and a ranked list of auto-discovered images | + +### Run locally + +```bash +make build-ui +./bin/drop-ui --bind-address :8888 +# or directly: +go run ./cmd/ui/ --bind-address :8888 +``` + +Then open **http://localhost:8888** in your browser. + +### Deploy via Helm + +```bash +helm install drop charts/drop -n drop-system --create-namespace \ + --set ui.enabled=true \ + --set ui.image.repository=ghcr.io/breee/drop-ui +``` + +To expose the UI with a `NodePort`: + +```bash +helm upgrade drop charts/drop -n drop-system \ + --set ui.enabled=true \ + --set ui.service.type=NodePort \ + --set ui.service.nodePort=30888 +``` + ## Docs Full documentation at **[breee.github.io/drop/](https://breee.github.io/drop/)** (GitHub Pages). diff --git a/charts/drop/templates/ui-clusterrole.yaml b/charts/drop/templates/ui-clusterrole.yaml new file mode 100644 index 0000000..307990c --- /dev/null +++ b/charts/drop/templates/ui-clusterrole.yaml @@ -0,0 +1,19 @@ +{{- if .Values.ui.enabled }} +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: {{ include "drop.fullname" . }}-ui + labels: + {{- include "drop.labels" . | nindent 4 }} + app.kubernetes.io/component: ui +rules: + - apiGroups: ["drop.corewire.io"] + resources: ["cachedimages", "cachedimagesets", "discoverypolicies", "pullpolicies"] + verbs: ["get", "list", "watch"] + - apiGroups: ["drop.corewire.io"] + resources: ["cachedimages/status", "cachedimagesets/status", "discoverypolicies/status"] + verbs: ["get"] + - apiGroups: [""] + resources: ["nodes"] + verbs: ["get", "list", "watch"] +{{- end }} diff --git a/charts/drop/templates/ui-clusterrolebinding.yaml b/charts/drop/templates/ui-clusterrolebinding.yaml new file mode 100644 index 0000000..19ad695 --- /dev/null +++ b/charts/drop/templates/ui-clusterrolebinding.yaml @@ -0,0 +1,17 @@ +{{- if .Values.ui.enabled }} +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: {{ include "drop.fullname" . }}-ui + labels: + {{- include "drop.labels" . | nindent 4 }} + app.kubernetes.io/component: ui +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: {{ include "drop.fullname" . }}-ui +subjects: + - kind: ServiceAccount + name: {{ include "drop.fullname" . }}-ui + namespace: {{ .Release.Namespace }} +{{- end }} diff --git a/charts/drop/templates/ui-deployment.yaml b/charts/drop/templates/ui-deployment.yaml new file mode 100644 index 0000000..d152b69 --- /dev/null +++ b/charts/drop/templates/ui-deployment.yaml @@ -0,0 +1,66 @@ +{{- if .Values.ui.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "drop.fullname" . }}-ui + labels: + {{- include "drop.labels" . | nindent 4 }} + app.kubernetes.io/component: ui +spec: + replicas: 1 + selector: + matchLabels: + {{- include "drop.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: ui + template: + metadata: + labels: + {{- include "drop.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: ui + spec: + serviceAccountName: {{ include "drop.fullname" . }}-ui + securityContext: + runAsNonRoot: true + containers: + - name: ui + image: "{{ .Values.ui.image.repository }}:{{ .Values.ui.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.ui.image.pullPolicy }} + args: + - --bind-address=:{{ .Values.ui.port }} + - --poll-interval={{ .Values.ui.pollInterval }} + ports: + - name: http + containerPort: {{ .Values.ui.port }} + protocol: TCP + livenessProbe: + httpGet: + path: /healthz + port: http + initialDelaySeconds: 5 + periodSeconds: 20 + readinessProbe: + httpGet: + path: /healthz + port: http + initialDelaySeconds: 3 + periodSeconds: 10 + resources: + {{- toYaml .Values.ui.resources | nindent 12 }} + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} +{{- end }} diff --git a/charts/drop/templates/ui-service.yaml b/charts/drop/templates/ui-service.yaml new file mode 100644 index 0000000..a894b17 --- /dev/null +++ b/charts/drop/templates/ui-service.yaml @@ -0,0 +1,22 @@ +{{- if .Values.ui.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "drop.fullname" . }}-ui + labels: + {{- include "drop.labels" . | nindent 4 }} + app.kubernetes.io/component: ui +spec: + type: {{ .Values.ui.service.type }} + ports: + - port: {{ .Values.ui.service.port }} + targetPort: http + protocol: TCP + name: http + {{- if and (eq .Values.ui.service.type "NodePort") .Values.ui.service.nodePort }} + nodePort: {{ .Values.ui.service.nodePort }} + {{- end }} + selector: + {{- include "drop.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: ui +{{- end }} diff --git a/charts/drop/templates/ui-serviceaccount.yaml b/charts/drop/templates/ui-serviceaccount.yaml new file mode 100644 index 0000000..9e083fb --- /dev/null +++ b/charts/drop/templates/ui-serviceaccount.yaml @@ -0,0 +1,9 @@ +{{- if .Values.ui.enabled }} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "drop.fullname" . }}-ui + labels: + {{- include "drop.labels" . | nindent 4 }} + app.kubernetes.io/component: ui +{{- end }} diff --git a/charts/drop/values.yaml b/charts/drop/values.yaml index 19429a4..4edeeb7 100644 --- a/charts/drop/values.yaml +++ b/charts/drop/values.yaml @@ -43,3 +43,27 @@ certManager: nodeSelector: {} tolerations: [] affinity: {} + +# Drop Control Center UI +ui: + # Set to true to deploy the Drop Control Center UI alongside the operator. + enabled: false + image: + repository: ghcr.io/breee/drop-ui + pullPolicy: IfNotPresent + tag: "" # Defaults to Chart appVersion + # Port the UI container listens on. + port: 8888 + # How often the UI polls Kubernetes for live SSE updates. + pollInterval: 10s + service: + type: ClusterIP + port: 8888 + # nodePort: 30888 # Uncomment when type=NodePort + resources: + limits: + cpu: 100m + memory: 64Mi + requests: + cpu: 10m + memory: 32Mi diff --git a/cmd/ui/main.go b/cmd/ui/main.go new file mode 100644 index 0000000..7470034 --- /dev/null +++ b/cmd/ui/main.go @@ -0,0 +1,82 @@ +/* +Copyright (c) 2026 Breee + +SPDX-License-Identifier: MIT +*/ + +// drop-ui is a standalone web server that serves the Drop Control Center UI. +// It connects to the Kubernetes API to read drop.corewire.io CRDs and +// exposes a REST + SSE API consumed by the browser. +// +// Usage: +// +// drop-ui --bind-address :8888 +// drop-ui --kubeconfig ~/.kube/config --bind-address :8888 +package main + +import ( + "flag" + "net/http" + "os" + "time" + + _ "k8s.io/client-go/plugin/pkg/client/auth" + + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + + dropv1alpha1 "github.com/Breee/drop/api/v1alpha1" + "github.com/Breee/drop/internal/ui" +) + +var scheme = runtime.NewScheme() + +func init() { + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + utilruntime.Must(dropv1alpha1.AddToScheme(scheme)) +} + +func main() { + var bindAddr string + var pollInterval time.Duration + + opts := zap.Options{Development: true} + opts.BindFlags(flag.CommandLine) + flag.StringVar(&bindAddr, "bind-address", ":8888", "Address the UI server listens on.") + flag.DurationVar(&pollInterval, "poll-interval", 10*time.Second, "How often to poll Kubernetes for live SSE updates.") + flag.Parse() + + ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) + logger := ctrl.Log.WithName("drop-ui") + + cfg, err := ctrl.GetConfig() + if err != nil { + logger.Error(err, "failed to get kubeconfig") + os.Exit(1) + } + + c, err := client.New(cfg, client.Options{Scheme: scheme}) + if err != nil { + logger.Error(err, "failed to create Kubernetes client") + os.Exit(1) + } + + srv := ui.NewServer(c, pollInterval) + httpSrv := &http.Server{ + Addr: bindAddr, + Handler: srv.Handler(), + ReadTimeout: 5 * time.Second, + WriteTimeout: 0, // SSE streams need no write timeout + IdleTimeout: 120 * time.Second, + } + + logger.Info("Drop Control Center UI starting", "address", bindAddr) + if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + logger.Error(err, "server exited with error") + os.Exit(1) + } +} diff --git a/docs/go.mod b/docs/go.mod index c0e3c3f..2bcc3cb 100644 --- a/docs/go.mod +++ b/docs/go.mod @@ -1,5 +1,3 @@ module github.com/Breee/drop/docs go 1.26.0 - -require github.com/imfing/hextra v0.12.3 // indirect diff --git a/docs/go.sum b/docs/go.sum index afa8680..e69de29 100644 --- a/docs/go.sum +++ b/docs/go.sum @@ -1,2 +0,0 @@ -github.com/imfing/hextra v0.12.3 h1:DZHY2rUWYteyzjlHi9r4n7Bb5e2Q+6LXe4C1Dqn0ZjM= -github.com/imfing/hextra v0.12.3/go.mod h1:vi+yhpq8YPp/aghvJlNKVnJKcPJ/VyAEcfC1BSV9ARo= diff --git a/go.sum b/go.sum index 06ca73e..760283c 100644 --- a/go.sum +++ b/go.sum @@ -66,8 +66,6 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= -github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oXJCETMO23COyaKGP6fHVpkpWpg= github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -107,14 +105,8 @@ github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFd github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/onsi/ginkgo/v2 v2.27.4 h1:fcEcQW/A++6aZAZQNUmNjvA9PSOzefMJBerHJ4t8v8Y= -github.com/onsi/ginkgo/v2 v2.27.4/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= github.com/onsi/ginkgo/v2 v2.29.0 h1:rfh+ZFjgJhYWRoIqVf3Uwx/W20yLrcrE2h2GmYVRaag= github.com/onsi/ginkgo/v2 v2.29.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= -github.com/onsi/gomega v1.39.0 h1:y2ROC3hKFmQZJNFeGAMeHZKkjBL65mIZcvrLQBF9k6Q= -github.com/onsi/gomega v1.39.0/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4= -github.com/onsi/gomega v1.40.0 h1:Vtol0e1MghCD2ZVIilPDIg44XSL9l2QAn8ZNaljWcJc= -github.com/onsi/gomega v1.40.0/go.mod h1:M/Uqpu/8qTjtzCLUA2zJHX9Iilrau25x1PdoSRbWh5A= github.com/onsi/gomega v1.41.0 h1:OwKp4pXNgVxf6sCplzYo794OFNuoL2q2SBMU5NSWOjA= github.com/onsi/gomega v1.41.0/go.mod h1:M/Uqpu/8qTjtzCLUA2zJHX9Iilrau25x1PdoSRbWh5A= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -192,36 +184,22 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0= golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU= -golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= -golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= -golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= -golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= -golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= diff --git a/internal/ui/server.go b/internal/ui/server.go new file mode 100644 index 0000000..633ac72 --- /dev/null +++ b/internal/ui/server.go @@ -0,0 +1,478 @@ +/* +Copyright (c) 2026 Breee + +SPDX-License-Identifier: MIT +*/ + +// Package ui provides the HTTP server for the Drop Control Center UI. +package ui + +import ( + "context" + "embed" + "encoding/json" + "fmt" + "io/fs" + "net/http" + "time" + + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" + + dropv1alpha1 "github.com/Breee/drop/api/v1alpha1" +) + +//go:embed static +var staticFiles embed.FS + +// NodeSummary is a simplified node for the UI. +type NodeSummary struct { + Name string `json:"name"` + Ready bool `json:"ready"` + Labels map[string]string `json:"labels"` + Arch string `json:"arch,omitempty"` + OS string `json:"os,omitempty"` +} + +// CachedImageSummary is a simplified CachedImage for the UI. +type CachedImageSummary struct { + Name string `json:"name"` + Image string `json:"image"` + Tag string `json:"tag,omitempty"` + Digest string `json:"digest,omitempty"` + Phase string `json:"phase"` + Ready string `json:"ready"` + NodesReady int32 `json:"nodesReady"` + NodesTargeted int32 `json:"nodesTargeted"` + NodesPulling int32 `json:"nodesPulling"` + CachedNodes []string `json:"cachedNodes"` + SetName string `json:"setName,omitempty"` + PolicyRef string `json:"policyRef,omitempty"` + Age string `json:"age"` +} + +// CachedImageSetSummary is a simplified CachedImageSet for the UI. +type CachedImageSetSummary struct { + Name string `json:"name"` + Phase string `json:"phase"` + ImagesManaged int32 `json:"imagesManaged"` + ImagesReady int32 `json:"imagesReady"` + DiscoveryRef string `json:"discoveryRef,omitempty"` + Age string `json:"age"` +} + +// DiscoveredImageEntry is a single image from a discovery source. +type DiscoveredImageEntry struct { + Image string `json:"image"` + Score int64 `json:"score"` + Source string `json:"source"` +} + +// DiscoveryPolicySummary is a simplified DiscoveryPolicy for the UI. +type DiscoveryPolicySummary struct { + Name string `json:"name"` + Phase string `json:"phase"` + ImageCount int32 `json:"imageCount"` + SourceCount int32 `json:"sourceCount"` + SyncInterval string `json:"syncInterval,omitempty"` + MaxImages int32 `json:"maxImages"` + LastSync string `json:"lastSync,omitempty"` + DiscoveredImages []DiscoveredImageEntry `json:"discoveredImages,omitempty"` + Spec interface{} `json:"spec"` + Age string `json:"age"` +} + +// StatusSummary provides overall cluster-level status for the UI. +type StatusSummary struct { + TotalNodes int `json:"totalNodes"` + ReadyNodes int `json:"readyNodes"` + TotalImages int `json:"totalImages"` + ReadyImages int `json:"readyImages"` + PullingImages int `json:"pullingImages"` + PendingImages int `json:"pendingImages"` + DegradedImages int `json:"degradedImages"` + TotalSets int `json:"totalSets"` + TotalPolicies int `json:"totalPolicies"` +} + +// FullPayload is the combined response for SSE updates. +type FullPayload struct { + Nodes []NodeSummary `json:"nodes"` + CachedImages []CachedImageSummary `json:"cachedImages"` + CachedImageSets []CachedImageSetSummary `json:"cachedImageSets"` + DiscoveryPolicies []DiscoveryPolicySummary `json:"discoveryPolicies"` + Status StatusSummary `json:"status"` + Timestamp string `json:"timestamp"` +} + +// Server is the Drop UI HTTP server. +type Server struct { + client client.Client + pollInterval time.Duration +} + +// NewServer creates a new UI server backed by the provided Kubernetes client. +// A pollInterval <= 0 is replaced with the default of 10 seconds. +func NewServer(c client.Client, pollInterval time.Duration) *Server { + if pollInterval <= 0 { + pollInterval = 10 * time.Second + } + return &Server{client: c, pollInterval: pollInterval} +} + +// Handler returns the HTTP handler for the UI server. +func (s *Server) Handler() http.Handler { + mux := http.NewServeMux() + + staticFS, err := fs.Sub(staticFiles, "static") + if err != nil { + panic(fmt.Sprintf("ui: failed to sub static FS: %v", err)) + } + mux.Handle("/", http.FileServer(http.FS(staticFS))) + + // Lightweight health check used by the Helm readiness/liveness probes. + mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + mux.HandleFunc("/api/v1/nodes", s.withCORS(s.handleNodes)) + mux.HandleFunc("/api/v1/cachedimages", s.withCORS(s.handleCachedImages)) + mux.HandleFunc("/api/v1/cachedimagesets", s.withCORS(s.handleCachedImageSets)) + mux.HandleFunc("/api/v1/discoverypolicies", s.withCORS(s.handleDiscoveryPolicies)) + mux.HandleFunc("/api/v1/status", s.withCORS(s.handleStatus)) + mux.HandleFunc("/api/v1/all", s.withCORS(s.handleAll)) + mux.HandleFunc("/events", s.handleSSE) + + return mux +} + +// withCORS adds CORS headers. The UI is designed to be served in-cluster +// (accessed via port-forward or NodePort); the wildcard origin is acceptable +// for this internal tooling use-case. +func (s *Server) withCORS(h http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + h(w, r) + } +} + +func writeJSON(w http.ResponseWriter, v interface{}) { + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(v); err != nil { + http.Error(w, "encode error", http.StatusInternalServerError) + } +} + +func (s *Server) handleNodes(w http.ResponseWriter, r *http.Request) { + nodes, err := s.fetchNodes(r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, nodes) +} + +func (s *Server) handleCachedImages(w http.ResponseWriter, r *http.Request) { + images, err := s.fetchCachedImages(r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, images) +} + +func (s *Server) handleCachedImageSets(w http.ResponseWriter, r *http.Request) { + sets, err := s.fetchCachedImageSets(r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, sets) +} + +func (s *Server) handleDiscoveryPolicies(w http.ResponseWriter, r *http.Request) { + policies, err := s.fetchDiscoveryPolicies(r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, policies) +} + +func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { + payload, err := s.buildFullPayload(r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, payload.Status) +} + +func (s *Server) handleAll(w http.ResponseWriter, r *http.Request) { + payload, err := s.buildFullPayload(r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, payload) +} + +// handleSSE streams live updates to the browser via Server-Sent Events. +func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) { + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming unsupported", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("Access-Control-Allow-Origin", "*") + + logger := log.FromContext(r.Context()).WithName("ui-sse") + + send := func() { + payload, err := s.buildFullPayload(r.Context()) + if err != nil { + logger.V(1).Info("SSE fetch error", "error", err) + return + } + data, err := json.Marshal(payload) + if err != nil { + return + } + fmt.Fprintf(w, "data: %s\n\n", data) + flusher.Flush() + } + + send() // immediate first event + + ticker := time.NewTicker(s.pollInterval) + defer ticker.Stop() + + for { + select { + case <-r.Context().Done(): + return + case <-ticker.C: + send() + } + } +} + +// --- Kubernetes fetch helpers --- + +func (s *Server) fetchNodes(ctx context.Context) ([]NodeSummary, error) { + var nodeList corev1.NodeList + if err := s.client.List(ctx, &nodeList); err != nil { + return nil, fmt.Errorf("list nodes: %w", err) + } + + result := make([]NodeSummary, 0, len(nodeList.Items)) + for i := range nodeList.Items { + n := &nodeList.Items[i] + ready := false + for _, c := range n.Status.Conditions { + if c.Type == corev1.NodeReady && c.Status == corev1.ConditionTrue { + ready = true + break + } + } + result = append(result, NodeSummary{ + Name: n.Name, + Ready: ready, + Labels: n.Labels, + Arch: n.Status.NodeInfo.Architecture, + OS: n.Status.NodeInfo.OperatingSystem, + }) + } + return result, nil +} + +func (s *Server) fetchCachedImages(ctx context.Context) ([]CachedImageSummary, error) { + var list dropv1alpha1.CachedImageList + if err := s.client.List(ctx, &list); err != nil { + return nil, fmt.Errorf("list cachedimages: %w", err) + } + + result := make([]CachedImageSummary, 0, len(list.Items)) + for i := range list.Items { + ci := &list.Items[i] + setName := ci.Labels["drop.corewire.io/imageset"] + policyRef := "" + if ci.Spec.PolicyRef != nil { + policyRef = ci.Spec.PolicyRef.Name + } + cachedNodes := ci.Status.CachedNodes + if cachedNodes == nil { + cachedNodes = []string{} + } + result = append(result, CachedImageSummary{ + Name: ci.Name, + Image: ci.Spec.Image, + Tag: ci.Spec.Tag, + Digest: ci.Spec.Digest, + Phase: ci.Status.Phase, + Ready: ci.Status.Ready, + NodesReady: ci.Status.NodesReady, + NodesTargeted: ci.Status.NodesTargeted, + NodesPulling: ci.Status.NodesPulling, + CachedNodes: cachedNodes, + SetName: setName, + PolicyRef: policyRef, + Age: formatAge(ci.CreationTimestamp.Time), + }) + } + return result, nil +} + +func (s *Server) fetchCachedImageSets(ctx context.Context) ([]CachedImageSetSummary, error) { + var list dropv1alpha1.CachedImageSetList + if err := s.client.List(ctx, &list); err != nil { + return nil, fmt.Errorf("list cachedimageset: %w", err) + } + + result := make([]CachedImageSetSummary, 0, len(list.Items)) + for i := range list.Items { + cis := &list.Items[i] + discRef := "" + if cis.Spec.DiscoveryPolicyRef != nil { + discRef = cis.Spec.DiscoveryPolicyRef.Name + } + result = append(result, CachedImageSetSummary{ + Name: cis.Name, + Phase: cis.Status.Phase, + ImagesManaged: cis.Status.ImagesManaged, + ImagesReady: cis.Status.ImagesReady, + DiscoveryRef: discRef, + Age: formatAge(cis.CreationTimestamp.Time), + }) + } + return result, nil +} + +func (s *Server) fetchDiscoveryPolicies(ctx context.Context) ([]DiscoveryPolicySummary, error) { + var list dropv1alpha1.DiscoveryPolicyList + if err := s.client.List(ctx, &list); err != nil { + return nil, fmt.Errorf("list discoverypolicies: %w", err) + } + + result := make([]DiscoveryPolicySummary, 0, len(list.Items)) + for i := range list.Items { + dp := &list.Items[i] + + phase := "" + for _, c := range dp.Status.Conditions { + if c.Type == "Ready" { + phase = c.Reason + break + } + } + + lastSync := "" + if dp.Status.LastSyncTime != nil { + lastSync = formatAge(dp.Status.LastSyncTime.Time) + } + + discovered := make([]DiscoveredImageEntry, 0, len(dp.Status.DiscoveredImages)) + for _, img := range dp.Status.DiscoveredImages { + discovered = append(discovered, DiscoveredImageEntry{ + Image: img.Image, + Score: img.Score, + Source: img.Source, + }) + } + + result = append(result, DiscoveryPolicySummary{ + Name: dp.Name, + Phase: phase, + ImageCount: dp.Status.ImageCount, + SourceCount: dp.Status.SourceCount, + SyncInterval: dp.Spec.SyncInterval.Duration.String(), + MaxImages: dp.Spec.MaxImages, + LastSync: lastSync, + DiscoveredImages: discovered, + Spec: dp.Spec, + Age: formatAge(dp.CreationTimestamp.Time), + }) + } + return result, nil +} + +func (s *Server) buildFullPayload(ctx context.Context) (*FullPayload, error) { + nodes, err := s.fetchNodes(ctx) + if err != nil { + return nil, err + } + images, err := s.fetchCachedImages(ctx) + if err != nil { + return nil, err + } + sets, err := s.fetchCachedImageSets(ctx) + if err != nil { + return nil, err + } + policies, err := s.fetchDiscoveryPolicies(ctx) + if err != nil { + return nil, err + } + + status := StatusSummary{ + TotalNodes: len(nodes), + TotalImages: len(images), + TotalSets: len(sets), + TotalPolicies: len(policies), + } + for _, n := range nodes { + if n.Ready { + status.ReadyNodes++ + } + } + for _, img := range images { + switch img.Phase { + case "Ready": + status.ReadyImages++ + case "Pulling": + status.PullingImages++ + case "Pending": + status.PendingImages++ + case "Degraded": + status.DegradedImages++ + } + } + + return &FullPayload{ + Nodes: nodes, + CachedImages: images, + CachedImageSets: sets, + DiscoveryPolicies: policies, + Status: status, + Timestamp: time.Now().UTC().Format(time.RFC3339), + }, nil +} + +// formatAge returns a human-readable duration since t. +func formatAge(t time.Time) string { + if t.IsZero() { + return "" + } + d := time.Since(t) + switch { + case d < time.Minute: + return fmt.Sprintf("%ds", int(d.Seconds())) + case d < time.Hour: + return fmt.Sprintf("%dm", int(d.Minutes())) + case d < 24*time.Hour: + return fmt.Sprintf("%dh", int(d.Hours())) + default: + return fmt.Sprintf("%dd", int(d.Hours()/24)) + } +} diff --git a/internal/ui/static/index.html b/internal/ui/static/index.html new file mode 100644 index 0000000..eb4a732 --- /dev/null +++ b/internal/ui/static/index.html @@ -0,0 +1,1220 @@ + + + + + +DROP // CONTROL CENTER + + + + +
+
+  ██████╗ ██████╗  ██████╗ ██████╗
+  ██╔══██╗██╔══██╗██╔═══██╗██╔══██╗
+  ██║  ██║██████╔╝██║   ██║██████╔╝
+  ██║  ██║██╔══██╗██║   ██║██╔═══╝
+  ██████╔╝██║  ██║╚██████╔╝██║
+  ╚═════╝ ╚═╝  ╚═╝ ╚═════╝ ╚═╝
+
+  CONTROL CENTER // v0.1.0
+
+
CONNECTING TO NEXUS…
+
+ + + +
+ + + +