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/Tiltfile b/Tiltfile
index 7ba2746..7e8cb8c 100644
--- a/Tiltfile
+++ b/Tiltfile
@@ -122,6 +122,16 @@ local_resource(
labels=['docs'],
)
+# --- Drop Control Center UI (live reload) ---
+local_resource(
+ 'drop-ui',
+ serve_cmd='go run ./cmd/ui --bind-address :8888',
+ deps=['cmd/ui', 'internal/ui'],
+ links=['http://localhost:8888/'],
+ labels=['ui'],
+ resource_deps=['drop'],
+)
+
# --- Dev Sample Resources ---
# Deploy sample CRs to exercise the operator
k8s_yaml('hack/dev-samples.yaml')
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/static/images/4b3a8c15-f526-456f-bc1c-3a2c0740cdfb.png b/docs/static/images/4b3a8c15-f526-456f-bc1c-3a2c0740cdfb.png
new file mode 100644
index 0000000..20bfc1b
Binary files /dev/null and b/docs/static/images/4b3a8c15-f526-456f-bc1c-3a2c0740cdfb.png differ
diff --git a/docs/static/images/50b97ec9-eee7-4d8b-8ab1-ef92e903bffc.png b/docs/static/images/50b97ec9-eee7-4d8b-8ab1-ef92e903bffc.png
new file mode 100644
index 0000000..49fb6ef
Binary files /dev/null and b/docs/static/images/50b97ec9-eee7-4d8b-8ab1-ef92e903bffc.png differ
diff --git a/docs/static/images/69b226a0-6c50-4934-bdfb-6798c3bb5c73.png b/docs/static/images/69b226a0-6c50-4934-bdfb-6798c3bb5c73.png
new file mode 100644
index 0000000..c1602e5
Binary files /dev/null and b/docs/static/images/69b226a0-6c50-4934-bdfb-6798c3bb5c73.png differ
diff --git a/docs/static/images/bla.png b/docs/static/images/bla.png
new file mode 100644
index 0000000..49fb6ef
Binary files /dev/null and b/docs/static/images/bla.png differ
diff --git a/docs/static/images/blabla.png b/docs/static/images/blabla.png
new file mode 100644
index 0000000..1409dda
Binary files /dev/null and b/docs/static/images/blabla.png differ
diff --git a/docs/static/images/drop-icons.png b/docs/static/images/drop-icons.png
new file mode 100644
index 0000000..0ed999e
Binary files /dev/null and b/docs/static/images/drop-icons.png differ
diff --git a/docs/static/images/droppod.png b/docs/static/images/droppod.png
new file mode 100644
index 0000000..ab0f8af
Binary files /dev/null and b/docs/static/images/droppod.png differ
diff --git a/docs/static/images/droppod2.png b/docs/static/images/droppod2.png
new file mode 100644
index 0000000..0d5755c
Binary files /dev/null and b/docs/static/images/droppod2.png differ
diff --git a/docs/static/images/dropship.png b/docs/static/images/dropship.png
new file mode 100644
index 0000000..2551ab4
Binary files /dev/null and b/docs/static/images/dropship.png differ
diff --git a/docs/static/images/newground1.png b/docs/static/images/newground1.png
new file mode 100644
index 0000000..7a0bd00
Binary files /dev/null and b/docs/static/images/newground1.png differ
diff --git a/docs/static/images/newground2.png b/docs/static/images/newground2.png
new file mode 100644
index 0000000..38aaa61
Binary files /dev/null and b/docs/static/images/newground2.png differ
diff --git a/docs/static/images/newground3.png b/docs/static/images/newground3.png
new file mode 100644
index 0000000..a3895df
Binary files /dev/null and b/docs/static/images/newground3.png differ
diff --git a/docs/static/images/planet.png b/docs/static/images/planet.png
new file mode 100644
index 0000000..fb57354
Binary files /dev/null and b/docs/static/images/planet.png differ
diff --git a/docs/static/images/ship.png b/docs/static/images/ship.png
new file mode 100644
index 0000000..5c0ee28
Binary files /dev/null and b/docs/static/images/ship.png differ
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/images/drop-deployed.png b/internal/ui/static/images/drop-deployed.png
new file mode 100644
index 0000000..531ce8f
Binary files /dev/null and b/internal/ui/static/images/drop-deployed.png differ
diff --git a/internal/ui/static/images/drop-descent.gif b/internal/ui/static/images/drop-descent.gif
new file mode 100644
index 0000000..c6632a7
Binary files /dev/null and b/internal/ui/static/images/drop-descent.gif differ
diff --git a/internal/ui/static/images/drop-descent.png b/internal/ui/static/images/drop-descent.png
new file mode 100644
index 0000000..97a36f7
Binary files /dev/null and b/internal/ui/static/images/drop-descent.png differ
diff --git a/internal/ui/static/images/drop-landing.png b/internal/ui/static/images/drop-landing.png
new file mode 100644
index 0000000..86c5843
Binary files /dev/null and b/internal/ui/static/images/drop-landing.png differ
diff --git a/internal/ui/static/images/drop-open.gif b/internal/ui/static/images/drop-open.gif
new file mode 100644
index 0000000..6e9c4cf
Binary files /dev/null and b/internal/ui/static/images/drop-open.gif differ
diff --git a/internal/ui/static/images/droppod.png b/internal/ui/static/images/droppod.png
new file mode 100644
index 0000000..ab0f8af
Binary files /dev/null and b/internal/ui/static/images/droppod.png differ
diff --git a/internal/ui/static/images/droppod2.png b/internal/ui/static/images/droppod2.png
new file mode 100644
index 0000000..0d5755c
Binary files /dev/null and b/internal/ui/static/images/droppod2.png differ
diff --git a/internal/ui/static/images/dropship.png b/internal/ui/static/images/dropship.png
new file mode 100644
index 0000000..6f2adfb
Binary files /dev/null and b/internal/ui/static/images/dropship.png differ
diff --git a/internal/ui/static/images/ground1.png b/internal/ui/static/images/ground1.png
new file mode 100644
index 0000000..a244696
Binary files /dev/null and b/internal/ui/static/images/ground1.png differ
diff --git a/internal/ui/static/images/ground2.png b/internal/ui/static/images/ground2.png
new file mode 100644
index 0000000..fe0c9fe
Binary files /dev/null and b/internal/ui/static/images/ground2.png differ
diff --git a/internal/ui/static/images/ground3.png b/internal/ui/static/images/ground3.png
new file mode 100644
index 0000000..d9c3828
Binary files /dev/null and b/internal/ui/static/images/ground3.png differ
diff --git a/internal/ui/static/images/planet.png b/internal/ui/static/images/planet.png
new file mode 100644
index 0000000..fb57354
Binary files /dev/null and b/internal/ui/static/images/planet.png differ
diff --git a/internal/ui/static/images/ship.png b/internal/ui/static/images/ship.png
new file mode 100644
index 0000000..5c0ee28
Binary files /dev/null and b/internal/ui/static/images/ship.png differ
diff --git a/internal/ui/static/index.html b/internal/ui/static/index.html
new file mode 100644
index 0000000..f7b5e15
--- /dev/null
+++ b/internal/ui/static/index.html
@@ -0,0 +1,938 @@
+
+
+
+
+
+DROP // CONTROL CENTER
+
+
+
+
+
+
+ ██████╗ ██████╗ ██████╗ ██████╗
+ ██╔══██╗██╔══██╗██╔═══██╗██╔══██╗
+ ██║ ██║██████╔╝██║ ██║██████╔╝
+ ██║ ██║██╔══██╗██║ ██║██╔═══╝
+ ██████╔╝██║ ██║╚██████╔╝██║
+ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝
+
+ CONTROL CENTER // v0.1.0
+
+
CONNECTING TO NEXUS…
+
+
+
+
+
+
+ DROP//CC
+
+
+
+
+
+ --:--:--
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 100%
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Image ↕ |
+ Status ↕ |
+ Progress ↕ |
+ Cached Nodes |
+ Set ↕ |
+ Age ↕ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
⚙ SPRITE DEBUG
+
+
+
+
+
+
+
+ Frame 0/36
+
+
+
+
+
+
+
Sheet: descent | Frame: 0
+
+
+
+
+
CSS background-position method
+
+
+
+
+
FULL SPRITE SHEETS
+
+
+
Sheet 1: DESCENT (new1.png) — 4×3, 12 frames
+

+
+
+
+
Sheet 2: LANDING (new2.png) — 4×3, 12 frames
+

+
+
+
+
Sheet 3: DEPLOYED (new3.png) — 4×3, 12 frames
+

+
+
+
+
+
+
GROUND ASSETS
+
+
+
+
+
+
+
+
+ Frame 0/24
+
+
+
+
+
+
+
+
+
ground1.png — 4×6, 24 frames
+

+
+
+
+
ground2.png — 5×6, 30 frames
+

+
+
+
+
ground3.png — 5×6, 30 frames
+

+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/usr/local/rustup/settings.toml b/usr/local/rustup/settings.toml
new file mode 100644
index 0000000..e34067a
--- /dev/null
+++ b/usr/local/rustup/settings.toml
@@ -0,0 +1,3 @@
+version = "12"
+
+[overrides]