Universal, fast, beautiful UI for any etcd cluster.
388
Universal, fast, beautiful UI for any etcd cluster — Kubernetes, Patroni, standalone or DIY. Ships as a single Docker image with a microservice backend.
K8s edit round-trip: open a Pod under /registry/pods/... → server
decodes the protobuf through k8s.io/api into structured JSON → click
Edit, change the container image to v9.9.9-DEMO → Done editing
→ Save → server validates the edited JSON against the typed Go
struct (catches field-name typos), re-marshals to protobuf, wraps in
runtime.Unknown, writes to etcd under a CAS guard. The whole flow is
~14 seconds.

Real screenshots against a live etcd 3.5.15 seeded with realistic
Kubernetes objects (Pods, Deployments, Services, ConfigMaps, Jobs). The
capture script lives at scripts/screenshots/capture.mjs
— Playwright + Chromium, idempotent, points at any running etcd-ui.
| Dashboard — every cluster you've connected with live health, leader, db size, key count, revision sparkline | ![]() |
Browser tree + Pod fully decoded — kv links k8s.io/api, so the binary protobuf in /registry/pods/... round-trips into pretty JSON in the editor with proper Prism syntax highlighting. Edit it, hit Save — server re-encodes back to protobuf under a CAS guard. | ![]() |
Cluster — members, raft term, leader, db size, key count + a Make leader button on every follower with a confirmation modal (routes to the current leader endpoint automatically) | ![]() |
| Live watch — every PUT/DELETE under a prefix in real time, with per-event K8s preview chips, filter pills, expandable raw body | ![]() |
Metrics — Grafana-style chart hover with vertical guide + value-at-cursor; leader-change log with from → to member IDs and a "why we can't tell the cause" explainer | ![]() |
| Heatmap — write activity aggregated by prefix bucket, live | ![]() |
Distributed locks — acquire and queue lease-bound locks under a prefix; same primitive clientv3/concurrency.Mutex uses; open two tabs to watch fair queuing | ![]() |
| Maintenance — snapshot, restore, compact, defrag, etcdutl snapshot validation, K8s-enriched leases with holder identity + renew time | ![]() |
| Audit — every mutation through the gateway, JSONL-persisted, SSE-tailable | ![]() |
Permissions — matrix view + inline editor with three-column diff preview before commit; federation principals (system:peer:*, peer:*/*) get a globe badge and a "federation only" filter | ![]() |
| Federation hub — aggregate remote etcd-ui peers, each card surfaces the ACL rules governing that peer at this hub + a "manage" deep-link into the matrix scoped to that peer | ![]() |
| Settings — cluster registry editor, themes, language, version footer | ![]() |
Existing etcd UIs are either tied to one platform (kubectl, Patroni admin) or look like 2014. This is one polished UI for any etcd, regardless of where it lives. Connect once — get cluster health, KV browser, live watch streams, snapshots, lease management, members, alarms, RBAC, audit log and a command palette to do anything in two keystrokes.
Single Docker image, five Go microservices behind a tiny supervisor (pid 1). Each owns its concerns and can be split into its own pod tomorrow.
flowchart LR
browser([Browser]) -->|HTTPS · WebSocket · SSE :8080| gw
subgraph image["single Docker image (+ etcdctl + etcdutl)"]
sup["supervisor<br/>(PID 1)"] -. supervises .-> gw & cluster & kv & ops & audit
gw["gateway :8080<br/>SPA + REST + WS/SSE multiplex<br/>audit-emit · OIDC / Basic / Session<br/>federation hub fan-out"]
gw -->|HTTP/JSON · WS over 127.0.0.1| cluster
gw --> kv
gw --> ops
gw --> audit
cluster["cluster :7001<br/>discovery (k8s/Patroni/SRV/file)<br/>conn pool · health · members<br/>alert manager · /alerts/log"]
kv["kv :7002<br/>range/put/delete/bulk<br/>txn · watch (WS+SSE) · history · diff<br/>put-cas (3-way merge)<br/>k8sproto + full k8s.io/api decode"]
ops["ops :7003<br/>snapshot · restore · leases (K8s-enriched)<br/>defrag · compact · alarms · RBAC · metrics<br/>etcdctl shell · etcdutl validate<br/>locks playground · move-leader"]
audit["audit :7004<br/>JSONL ring + SSE tail<br/>compaction (size+age)<br/>ACL history snapshots"]
end
cluster -->|gRPC v3 / HTTPv2| etcd[(etcd cluster<br/>k8s · Patroni · Vitess<br/>Vault · standalone · …)]
kv --> etcd
ops --> etcd
gw -. OTLP traces/metrics .-> otel[(OpenTelemetry<br/>collector)]
gw -. peer→peer .-> peers[("remote etcd-ui<br/>peers (federation)")]
ops -. SigV4 chunked upload .-> s3[(S3/MinIO/GCS<br/>snapshot offload)]
ASCII fallback if Mermaid isn't rendered:
┌──────────┐ pid 1 — restarts crashed services
│supervisor│
└────┬─────┘
▼
┌─────────┐ :8080 HTTPS + WebSocket + SSE + static SPA
│ gateway │◀── browser (only port exposed; WS preferred, SSE fallback)
└────┬────┘
│ HTTP/JSON · WS over 127.0.0.1
├──────────────┬───────────────────┬──────────────┐
▼ ▼ ▼ ▼
cluster:7001 kv:7002 ops:7003 audit:7004
discovery range/put/delete snapshot/restore JSONL ring
pool, health bulk, txn, watch leases (k8s) SSE tail
/alerts/log put-cas (CRDT) compact/defrag ACL history
k8s.io/api decode locks, move-leader
history, diff etcdctl, etcdutl
│
▼
etcd cluster(s) gRPC v3 — auto-detected vs v2
gateway → S3/MinIO/GCS (snapshot offload, SigV4 chunked)
gateway ⇄ remote etcd-ui peers (federation hub mode)
gateway → OpenTelemetry collector (OTLP traces + metrics)
| Service | Port | Responsibility |
|---|---|---|
supervisor | — | Boots & supervises the others; PID 1; forwards signals; restart on crash |
gateway | 8080 | SPA + REST + WS/SSE multiplex; fans out to internals; emits audit events; OIDC/Basic/Session; federation hub fan-out |
cluster | 7001 | Auto-discovery (12 sources), connection pool, member health, alert manager, persisted /alerts/log |
kv | 7002 | Range/Put/Delete, bulk, txn, watch (WS+SSE), put-cas (3-way merge), put-k8s (typed edit round-trip), history, diff, K8s protobuf decode+encode via k8s.io/api (519 Kinds), /range/counts for tree truncation hints |
ops | 7003 | Snapshot, JSON restore, leases (K8s-enriched holder identity + renew time), compact, defrag, alarms, RBAC, metrics, etcdctl shell, etcdutl snapshot validate, locks playground, move-leader (auto-routes to current leader endpoint) |
audit | 7004 | Append-only JSONL log + in-memory ring + SSE live tail + ACL history snapshots |
etcd is just a key-value store, and lots of systems use it. etcd-ui doesn't care which one — it finds them all.
Five discovery sources run in parallel; each is opt-in via env, all coexist:
| Source | How |
|---|---|
| env | ETCD_ENDPOINTS=https://etcd:2379 for a single cluster |
| file | CLUSTERS_FILE=/etc/etcd-ui/clusters.yaml, many clusters, hot-reloaded every 30 s |
| dns-srv | ETCD_UI_DNS_SRV="prod=Prod=_etcd-client._tcp.prod.example.com,https;…" |
| patroni | PATRONI_URLS=http://pg-1:8008,… — pulls DCS info from Patroni REST |
| kubernetes | Multiple label selectors out of the box: control-plane, Vitess, Cilium, KubeEdge, Karmada, APISIX, M3DB, generic app=etcd, plus your own via ETCD_UI_K8S_SOURCES JSON |
For step-by-step "how do I get etcd endpoints from <my system>?" instructions covering Kubernetes (stacked / external / managed), Patroni, Vitess, HashiCorp Vault, Apache APISIX, Cilium, KubeEdge, Karmada, Talos Linux, OpenStack tooz, M3DB, CoreDNS, Calico, SkyDNS — see docs/CONNECT.md.
The UI also has a built-in Add Cluster wizard (Settings → Add cluster…) with presets and concrete commands inline for each of these systems.
Once discovered, every cluster looks identical to the UI.
.db), JSON export, defrag, compact, alarm disarm, lease managementif / then / else over etcd Txn; supports compare on value / createRev / modRev / version.json export back to the cluster, with optional "wipe prefix before import"⌘K) — jump between pages, switch clusters, change theme, download snapshot, etc.docs/PERMISSIONS.md.etcdctl baked into the image with an allow-list (member list, move-leader, endpoint hashkv, …).etcdutl — also bundled. Validate a .db snapshot before halting your quorum: upload, get back hash/revision/total-keys/size, decide whether the restore is safe (Maintenance → "Validate snapshot")./locks page mints lease-bound keys under a chosen prefix, exactly like clientv3/concurrency.Mutex. Watch holder + waiters live, force-release on demand, debug leader-election scenarios without writing a Go program.↑ Make leader button on every follower, with a confirm modal. Routes the call to the current leader endpoint automatically so a 3-node cluster doesn't return "not leader" half the time.kv links k8s.io/api (519 Kinds across 19 groups). Pod / Service / Deployment / ConfigMap / Secret / etc. round-trip through their generated Go structs into pretty JSON — no more hex dumps for the bulk of your registry. Edit the structured JSON in place, hit Save: server validates the edit against the Go type (DisallowUnknownFields catches spec.imag vs spec.image typos before they reach etcd), re-marshals to protobuf, wraps in runtime.Unknown, writes under a CAS guard. Round-trip is provably lossless — same bits in, same bits out. CRDs stored as JSON are pretty-printed and edited directly; CRDs stored as proto without a typed shim fall back to metadata-only preview + raw hex./version on first contact.prefers-reduced-motion honoured# from-source
docker build -t etcd-ui:dev .
docker run --rm -p 8080:8080 -e ETCD_ENDPOINTS=http://your-etcd:2379 etcd-ui:dev
Open http://localhost:8080.
docker run --rm -p 8080:8080 \
-e ETCD_ENDPOINTS=https://etcd:2379 \
-e ETCD_CA_FILE=/certs/ca.pem \
-e ETCD_CERT_FILE=/certs/client.pem \
-e ETCD_KEY_FILE=/certs/client-key.pem \
-v /path/to/certs:/certs:ro \
-v etcd-ui-data:/app/data \
etcd-ui:dev
The /app/data volume persists the audit log across restarts.
Minimal — in-cluster discovery, ephemeral state:
helm install etcd-ui ./deploy/helm/etcd-ui \
--namespace kube-system \
--set discovery.kubernetes=true
Production — discovery, persistence, auth, scheduled snapshots, monitoring:
# 1) create the auth secret (bcrypt: htpasswd -nbB admin password)
kubectl create namespace etcd-ui
kubectl -n etcd-ui create secret generic etcd-ui-auth \
--from-literal=AUTH_USERS='admin:bcrypt:$2a$10$xxxxxxxxxxxxxxxxxxxxxxxxxxxxx' \
--from-literal=AUTH_SESSION_SECRET="$(openssl rand -base64 48)"
# 2) install
helm install etcd-ui ./deploy/helm/etcd-ui -n etcd-ui \
--set discovery.kubernetes=true \
--set persistence.enabled=true \
--set persistence.size=10Gi \
--set auth.existingSecretName=etcd-ui-auth \
--set snapshotSchedule="kube-system:hourly:24" \
--set hardening.corsOrigins="https://etcd-ui.example.com" \
--set metrics.serviceMonitor.enabled=true \
--set ingress.enabled=true \
--set ingress.host=etcd-ui.example.com \
--set ingress.tls=true \
--set ingress.tlsSecretName=etcd-ui-tls
All knobs live in deploy/helm/etcd-ui/values.yaml. Quick map:
| Block | What it controls |
|---|---|
clusters | Static profiles. One entry → env vars; many → ConfigMap with clusters.yaml mounted at /etc/etcd-ui/ and read by the file discovery source. |
discovery.* | k8s pod selectors (kubernetesSources JSON), Patroni REST URLs, DNS SRV records. |
auth.* / oidc.* | Basic users + signed session secret; OIDC issuer / audience / username claim. Set oidc.clientID + redirectURL to enable the full PKCE login flow with refresh-token rotation. |
acl.* | Per-cluster-per-user RBAC. Inline rules or file/ConfigMap (hot-reloaded via inotify on Linux, mtime-poll elsewhere). __acl__ synthetic cluster gates the editor; bootstrapAdmin seeds it. Edits flow through PUT /api/acl and land in the audit log with a JSON diff. |
alerts.* | Slack/Discord/Teams/MS-Teams webhook URLs. Per-(cluster, kind, recipient) throttling — each webhook URL has its own bucket. |
audit.* | JSONL retention: hourly compaction by maxBytes (default 256 MiB) and maxAge (default 720h). |
snapshotOffload.* | S3 / MinIO / GCS / R2 / Wasabi / B2. < 4.5 GB → single PUT; ≥ 4.5 GB → multipart with 4× parallel workers; streaming SigV4 path for non-seekable sources. |
federation.* | Hub aggregates clusters from remote peers. Peer→peer auth via shared token or OIDC service-account. peerClientIDs lets a peer recognise federation hops as system:peer:<id>; the hub propagates the calling human via X-Etcd-UI-On-Behalf so peer ACL can apply real per-user rules. |
gatewayTLS.* | Native HTTPS listener — mount a Secret with tls.crt + tls.key. |
hardening.* | CORS allowlist, per-user rate limit, custom CSP, pprof toggle. |
readonlyClusters | Cluster IDs that kv/ops refuse to mutate (cluster-wide block, complements acl.*). |
snapshotSchedule | default:hourly:24,prod:daily:30 etc. Files land in /app/data/snapshots/<cluster>/. |
persistence.* | PVC for /app/data (audit log + UI clusters + snapshots). |
networkPolicy.* | Ingress source allowlist; egress permits DNS, k8s API, etcd ports. |
metrics.serviceMonitor.* | Prometheus Operator ServiceMonitor. |
pdb.* | PodDisruptionBudget — only created when replicaCount > 1. |
containerSecurityContext | non-root, drop all caps, no privilege escalation by default. |
Both traces and metrics are exported via OTLP/HTTP when an endpoint is set:
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
OTEL_EXPORTER_OTLP_METRICS_ENDPOINT=http://otel-collector:4318 # optional, separate pipeline
OTEL_TRACES_SAMPLER_ARG=0.1 # 10% sampling in high-traffic clusters
traceparent propagates across all internal hops (peer.Syncer, audit-emit, metrics-scrape, federation, OIDC). Inside handlers each cli.Get/Put/Delete/Txn is wrapped in a child etcd.* span so the trace tree shows the actual etcd round-trip.
Watch streams + audit + alerts ride a WebSocket-first, SSE-fallback transport. The pill in the top-right corner shows WS · N (green), SSE · N (proxy) (amber, means a reverse proxy is buffering and rejecting the upgrade), or reconnect in 4s (clock) with a per-second countdown.
WebSockets carry bearer tokens via Sec-WebSocket-Protocol: etcd-ui.bearer, <jwt>, etcd-ui.v1 — works in Safari ITP / embedded webviews where cookies are dropped from the upgrade handshake. Client also sends a {"type":"ping"} text frame every 25s to keep enterprise proxies from idle-killing the connection; the server reads + responds with a Pong.
Settings → Live transport panel exposes a rolling 200-event ring of every WS/SSE open/close/reconnect/fallback (persisted to localStorage). "Copy diagnostic JSON" attaches the full trace to a bug report.
Edit __acl__ rules straight from the SPA — /permissions has matrix view + inline editor with three-column diff preview before commit. Every save snapshots before/after to $ETCD_UI_DATA_DIR/acl-history/; the History drawer lets you restore any prior snapshot (Undo). Audit log rows for action=acl.edit get a Snapshot button that deep-links into that drawer.
Server validates the proposed ruleset (POST /api/acl/validate) before any commit so typos in access values or malformed prefixes surface before the diff dialog.
/federation lists every remote etcd-ui peer with per-peer health (healthy / degraded / down / empty), reachable flag, last-checked timestamp and a foldable cluster roster. Cluster IDs are namespaced <peer>/<cluster> so they don't collide with locally-defined ones.
Each peer card also shows the policy at this hub — how many ACL rules govern that peer's traffic, with a manage deep-link into /permissions?peer=<id> that filters the matrix to just that peer's system:peer:<id> and peer:<id>/<user> rules. The Permissions matrix renders federation principals with a globe badge and a "federation principals only" toggle, so cross-hub access is auditable in isolation from local users.
Two affordances kick in when somebody else writes to a key you're editing:
modRevision-based, zero overhead, no merge yet.Save doesn't blindly PUT. It hits POST /api/clusters/{id}/put-cas which:
baseRev (the revision you opened),Txn(If ModRevision(key) == baseRev, Then Put) for the no-contention path,internal/threewaymerge, zero deps) when ours and theirs both diverged from base. Non-overlapping line edits merge cleanly; overlapping ones produce a 3-pane resolver dialog (Base / Yours / Theirs + per-block "use ours / use theirs / both" buttons).baseRev was compacted away, the live value is treated as base.See docs/features/CRDT.md for the full algorithm.
OIDC supports both modes:
oidc.issuer / audience.oidc.clientID + redirectURL. Refresh tokens are stored in an HttpOnly cookie and rotated on every /api/auth/oidc/refresh. The SPA refreshes proactively 60s before session expiry; on cookie revocation it falls through to a prompt=none iframe (silent re-auth) before bouncing to /login.Single-file manifest is in deploy/k8s.yaml for clusters without Helm.
make dev # backend (4 services) + Vite dev server with proxy
make build # build all 5 Go binaries into ./bin
make build-image # docker build -t etcd-ui:dev .
make test # go test ./... + (cd web && npm test)
Backend on :8080, Vite dev on :5173 (proxied to backend for /api).
Content type
Image
Digest
sha256:afe55f7e6…
Size
53.3 MB
Last updated
4 months ago
docker pull beztebya666/etcd-ui