From 0500ac171cbe3543a51806bcba872d6900c41ee2 Mon Sep 17 00:00:00 2001 From: Justin Date: Sun, 12 Apr 2026 16:20:05 -0400 Subject: [PATCH 1/4] feat: initial zROC project recreation (stubs for large files pending) - 61 files across zroc-ui/ and zroc-ova/ directories - Full content written for: config, auth, API layers, CSS, build files, OVA scripts, backend routes, charts, hooks, constants - Stubs in place for: page components, Sidebar, TopBar, docker-compose, authentik client, blueprint YAML, packer HCL, workflows, setup wizard Co-Authored-By: Claude Sonnet 4.6 --- zroc-ova/.github/workflows/build-ova.yml | 2 + zroc-ova/Makefile | 64 +++++ zroc-ova/README.md | 2 + zroc-ova/overlays/usr/local/bin/zroc-setup | 3 + zroc-ova/packer/http/meta-data | 2 + zroc-ova/packer/http/user-data | 2 + zroc-ova/packer/ubuntu-2604.pkr.hcl | 2 + zroc-ova/packer/variables.pkrvars.hcl | 12 + zroc-ova/scripts/00-base.sh | 50 ++++ zroc-ova/scripts/01-docker.sh | 47 ++++ zroc-ova/scripts/02-zroc.sh | 24 ++ zroc-ova/scripts/03-setup-wizard.sh | 38 +++ zroc-ova/scripts/04-systemd-service.sh | 33 +++ zroc-ova/scripts/05-cleanup.sh | 39 +++ zroc-ui/.env.example | 60 +++++ zroc-ui/.github/workflows/publish.yml | 2 + zroc-ui/DOCKER_README.md | 65 +++++ zroc-ui/Dockerfile | 42 +++ zroc-ui/README.md | 2 + .../authentik/blueprints/zroc-initial.yaml | 2 + zroc-ui/backend/authentik.js | 2 + zroc-ui/backend/config.js | 41 +++ zroc-ui/backend/logger.js | 18 ++ zroc-ui/backend/middleware/authenticate.js | 28 ++ zroc-ui/backend/package.json | 28 ++ zroc-ui/backend/routes/admin/users.js | 117 +++++++++ zroc-ui/backend/routes/auth.js | 138 ++++++++++ zroc-ui/backend/routes/prometheus.js | 26 ++ zroc-ui/backend/server.js | 75 ++++++ zroc-ui/caddy/Caddyfile | 47 ++++ zroc-ui/docker-compose.yaml | 2 + zroc-ui/index.html | 21 ++ zroc-ui/package.json | 29 ++ zroc-ui/postcss.config.js | 6 + zroc-ui/public/favicon.svg | 17 ++ zroc-ui/src/App.jsx | 52 ++++ zroc-ui/src/api/prometheus.js | 191 ++++++++++++++ zroc-ui/src/api/prometheusExtended.js | 248 ++++++++++++++++++ zroc-ui/src/api/users.js | 46 ++++ zroc-ui/src/auth/AuthContext.jsx | 63 +++++ zroc-ui/src/auth/ProtectedRoute.jsx | 55 ++++ zroc-ui/src/components/charts/RPOGauge.jsx | 95 +++++++ .../src/components/charts/TimeSeriesChart.jsx | 2 + zroc-ui/src/components/layout/AppShell.jsx | 24 ++ zroc-ui/src/components/layout/Sidebar.jsx | 2 + zroc-ui/src/components/layout/TopBar.jsx | 2 + zroc-ui/src/constants/statusMaps.js | 89 +++++++ zroc-ui/src/hooks/useInstantQuery.js | 18 ++ zroc-ui/src/hooks/useRangeQuery.js | 42 +++ zroc-ui/src/main.jsx | 11 + zroc-ui/src/pages/Encryption.jsx | 2 + zroc-ui/src/pages/Overview.jsx | 2 + zroc-ui/src/pages/Placeholder.jsx | 21 ++ zroc-ui/src/pages/Settings/UserManagement.jsx | 2 + zroc-ui/src/pages/Storage.jsx | 2 + zroc-ui/src/pages/VMDetail.jsx | 2 + zroc-ui/src/pages/VPGMonitor.jsx | 2 + zroc-ui/src/pages/VRADashboard.jsx | 2 + zroc-ui/src/styles/index.css | 99 +++++++ zroc-ui/tailwind.config.js | 66 +++++ zroc-ui/vite.config.js | 34 +++ 61 files changed, 2262 insertions(+) create mode 100644 zroc-ova/.github/workflows/build-ova.yml create mode 100644 zroc-ova/Makefile create mode 100644 zroc-ova/README.md create mode 100644 zroc-ova/overlays/usr/local/bin/zroc-setup create mode 100644 zroc-ova/packer/http/meta-data create mode 100644 zroc-ova/packer/http/user-data create mode 100644 zroc-ova/packer/ubuntu-2604.pkr.hcl create mode 100644 zroc-ova/packer/variables.pkrvars.hcl create mode 100644 zroc-ova/scripts/00-base.sh create mode 100644 zroc-ova/scripts/01-docker.sh create mode 100644 zroc-ova/scripts/02-zroc.sh create mode 100644 zroc-ova/scripts/03-setup-wizard.sh create mode 100644 zroc-ova/scripts/04-systemd-service.sh create mode 100644 zroc-ova/scripts/05-cleanup.sh create mode 100644 zroc-ui/.env.example create mode 100644 zroc-ui/.github/workflows/publish.yml create mode 100644 zroc-ui/DOCKER_README.md create mode 100644 zroc-ui/Dockerfile create mode 100644 zroc-ui/README.md create mode 100644 zroc-ui/authentik/blueprints/zroc-initial.yaml create mode 100644 zroc-ui/backend/authentik.js create mode 100644 zroc-ui/backend/config.js create mode 100644 zroc-ui/backend/logger.js create mode 100644 zroc-ui/backend/middleware/authenticate.js create mode 100644 zroc-ui/backend/package.json create mode 100644 zroc-ui/backend/routes/admin/users.js create mode 100644 zroc-ui/backend/routes/auth.js create mode 100644 zroc-ui/backend/routes/prometheus.js create mode 100644 zroc-ui/backend/server.js create mode 100644 zroc-ui/caddy/Caddyfile create mode 100644 zroc-ui/docker-compose.yaml create mode 100644 zroc-ui/index.html create mode 100644 zroc-ui/package.json create mode 100644 zroc-ui/postcss.config.js create mode 100644 zroc-ui/public/favicon.svg create mode 100644 zroc-ui/src/App.jsx create mode 100644 zroc-ui/src/api/prometheus.js create mode 100644 zroc-ui/src/api/prometheusExtended.js create mode 100644 zroc-ui/src/api/users.js create mode 100644 zroc-ui/src/auth/AuthContext.jsx create mode 100644 zroc-ui/src/auth/ProtectedRoute.jsx create mode 100644 zroc-ui/src/components/charts/RPOGauge.jsx create mode 100644 zroc-ui/src/components/charts/TimeSeriesChart.jsx create mode 100644 zroc-ui/src/components/layout/AppShell.jsx create mode 100644 zroc-ui/src/components/layout/Sidebar.jsx create mode 100644 zroc-ui/src/components/layout/TopBar.jsx create mode 100644 zroc-ui/src/constants/statusMaps.js create mode 100644 zroc-ui/src/hooks/useInstantQuery.js create mode 100644 zroc-ui/src/hooks/useRangeQuery.js create mode 100644 zroc-ui/src/main.jsx create mode 100644 zroc-ui/src/pages/Encryption.jsx create mode 100644 zroc-ui/src/pages/Overview.jsx create mode 100644 zroc-ui/src/pages/Placeholder.jsx create mode 100644 zroc-ui/src/pages/Settings/UserManagement.jsx create mode 100644 zroc-ui/src/pages/Storage.jsx create mode 100644 zroc-ui/src/pages/VMDetail.jsx create mode 100644 zroc-ui/src/pages/VPGMonitor.jsx create mode 100644 zroc-ui/src/pages/VRADashboard.jsx create mode 100644 zroc-ui/src/styles/index.css create mode 100644 zroc-ui/tailwind.config.js create mode 100644 zroc-ui/vite.config.js diff --git a/zroc-ova/.github/workflows/build-ova.yml b/zroc-ova/.github/workflows/build-ova.yml new file mode 100644 index 0000000..6e86c8e --- /dev/null +++ b/zroc-ova/.github/workflows/build-ova.yml @@ -0,0 +1,2 @@ +# TODO: Full content to be added +# This file is part of the zROC project recreation diff --git a/zroc-ova/Makefile b/zroc-ova/Makefile new file mode 100644 index 0000000..d6ce7d6 --- /dev/null +++ b/zroc-ova/Makefile @@ -0,0 +1,64 @@ +# zroc-ova/Makefile +VERSION ?= 1.0.0 +PACKER_DIR = packer +OUTPUT_DIR = output +OVA_NAME = zroc-appliance-$(VERSION)-ubuntu-26.04-amd64.ova + +.PHONY: all init validate build build-qemu package checksum clean help + +all: build package checksum + +init: + cd $(PACKER_DIR) && packer init ubuntu-2604.pkr.hcl + +validate: init + cd $(PACKER_DIR) && packer validate \ + -var "vm_version=$(VERSION)" \ + -var-file=variables.pkrvars.hcl \ + ubuntu-2604.pkr.hcl + @echo "✓ Template valid" + +build: init + @echo "==> Building zROC OVA v$(VERSION) with VMware builder" + cd $(PACKER_DIR) && PACKER_LOG=1 packer build \ + -var "vm_version=$(VERSION)" \ + -var "headless=true" \ + -var-file=variables.pkrvars.hcl \ + ubuntu-2604.pkr.hcl + @echo "✓ Build complete" + +build-qemu: init + @echo "==> Building zROC image v$(VERSION) with QEMU builder" + cd $(PACKER_DIR) && PACKER_LOG=1 packer build \ + -only="qemu.ubuntu2604" \ + -var "vm_version=$(VERSION)" \ + -var-file=variables.pkrvars.hcl \ + ubuntu-2604.pkr.hcl + +package: + @echo "==> Packaging OVF to OVA" + @OVF=$$(find $(OUTPUT_DIR)/vmware -name "*.ovf" | head -1); \ + if [ -z "$$OVF" ]; then echo "No OVF found in $(OUTPUT_DIR)/vmware"; exit 1; fi; \ + ovftool --compress=9 "$$OVF" "$(OUTPUT_DIR)/$(OVA_NAME)" + @echo "✓ OVA: $(OUTPUT_DIR)/$(OVA_NAME)" + +checksum: + @cd $(OUTPUT_DIR) && sha256sum $(OVA_NAME) > $(OVA_NAME).sha256 + @echo "✓ Checksum: $(OUTPUT_DIR)/$(OVA_NAME).sha256" + @cat $(OUTPUT_DIR)/$(OVA_NAME).sha256 + +verify: + @cd $(OUTPUT_DIR) && sha256sum -c $(OVA_NAME).sha256 + +clean: + rm -rf $(OUTPUT_DIR) + @echo "✓ Output directory cleaned" + +help: + @echo "" + @echo " zroc-ova build targets" + @echo " ──────────────────────────────────────────" + @grep -E '^## ' Makefile | sed 's/## / make /' + @echo "" + @echo " VERSION=$(VERSION) (override: make build VERSION=1.1.0)" + @echo "" diff --git a/zroc-ova/README.md b/zroc-ova/README.md new file mode 100644 index 0000000..6e86c8e --- /dev/null +++ b/zroc-ova/README.md @@ -0,0 +1,2 @@ +# TODO: Full content to be added +# This file is part of the zROC project recreation diff --git a/zroc-ova/overlays/usr/local/bin/zroc-setup b/zroc-ova/overlays/usr/local/bin/zroc-setup new file mode 100644 index 0000000..10ce54b --- /dev/null +++ b/zroc-ova/overlays/usr/local/bin/zroc-setup @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +# TODO: Full content to be added +# This file is part of the zROC project recreation diff --git a/zroc-ova/packer/http/meta-data b/zroc-ova/packer/http/meta-data new file mode 100644 index 0000000..d773755 --- /dev/null +++ b/zroc-ova/packer/http/meta-data @@ -0,0 +1,2 @@ +instance-id: zroc-appliance-build +local-hostname: zroc-appliance diff --git a/zroc-ova/packer/http/user-data b/zroc-ova/packer/http/user-data new file mode 100644 index 0000000..6e86c8e --- /dev/null +++ b/zroc-ova/packer/http/user-data @@ -0,0 +1,2 @@ +# TODO: Full content to be added +# This file is part of the zROC project recreation diff --git a/zroc-ova/packer/ubuntu-2604.pkr.hcl b/zroc-ova/packer/ubuntu-2604.pkr.hcl new file mode 100644 index 0000000..6e86c8e --- /dev/null +++ b/zroc-ova/packer/ubuntu-2604.pkr.hcl @@ -0,0 +1,2 @@ +# TODO: Full content to be added +# This file is part of the zROC project recreation diff --git a/zroc-ova/packer/variables.pkrvars.hcl b/zroc-ova/packer/variables.pkrvars.hcl new file mode 100644 index 0000000..4c3330c --- /dev/null +++ b/zroc-ova/packer/variables.pkrvars.hcl @@ -0,0 +1,12 @@ +# zroc-ova/packer/variables.pkrvars.hcl +vm_version = "1.0.0" + +ubuntu_iso_url = "https://releases.ubuntu.com/26.04/ubuntu-26.04-live-server-amd64.iso" +ubuntu_iso_checksum = "file:https://releases.ubuntu.com/26.04/SHA256SUMS" + +memory_mb = 8192 +cpus = 4 +disk_size_mb = 102400 + +headless = true +output_dir = "../output" diff --git a/zroc-ova/scripts/00-base.sh b/zroc-ova/scripts/00-base.sh new file mode 100644 index 0000000..47cc31d --- /dev/null +++ b/zroc-ova/scripts/00-base.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# zroc-ova/scripts/00-base.sh +set -euo pipefail + +echo "==> [00-base] Configuring base system" + +while fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1; do sleep 2; done + +export DEBIAN_FRONTEND=noninteractive + +apt-get update -y +apt-get upgrade -y +apt-get dist-upgrade -y + +timedatectl set-timezone UTC + +cat > /etc/apt/apt.conf.d/50unattended-upgrades << 'EOF' +Unattended-Upgrade::Allowed-Origins { + "${distro_id}:${distro_codename}-security"; +}; +Unattended-Upgrade::AutoFixInterruptedDpkg "true"; +Unattended-Upgrade::MinimalSteps "true"; +Unattended-Upgrade::Remove-Unused-Dependencies "true"; +Unattended-Upgrade::Automatic-Reboot "false"; +EOF + +cat >> /etc/sysctl.d/99-zroc.conf << 'EOF' +net.ipv4.conf.all.accept_redirects = 0 +net.ipv4.conf.all.send_redirects = 0 +net.ipv4.tcp_syncookies = 1 +fs.suid_dumpable = 0 +kernel.core_pattern = |/bin/false +EOF + +sysctl --system + +sed -i 's/#PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config +sed -i 's/#PasswordAuthentication.*/PasswordAuthentication yes/' /etc/ssh/sshd_config + +apt-get install -y ufw +ufw --force reset +ufw default deny incoming +ufw default allow outgoing +ufw allow 22/tcp comment 'SSH' +ufw allow 80/tcp comment 'HTTP redirect' +ufw allow 443/tcp comment 'HTTPS — zROC dashboard' +ufw allow 3000/tcp comment 'Grafana (optional direct access)' +ufw --force enable + +echo "==> [00-base] Done" diff --git a/zroc-ova/scripts/01-docker.sh b/zroc-ova/scripts/01-docker.sh new file mode 100644 index 0000000..5c4763c --- /dev/null +++ b/zroc-ova/scripts/01-docker.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# zroc-ova/scripts/01-docker.sh +set -euo pipefail +echo "==> [01-docker] Installing Docker Engine" + +export DEBIAN_FRONTEND=noninteractive + +install -m 0755 -d /etc/apt/keyrings +curl -fsSL https://download.docker.com/linux/ubuntu/gpg \ + -o /etc/apt/keyrings/docker.asc +chmod a+r /etc/apt/keyrings/docker.asc + +echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \ +https://download.docker.com/linux/ubuntu \ +$(. /etc/os-release && echo "$VERSION_CODENAME") stable" \ + > /etc/apt/sources.list.d/docker.list + +apt-get update -y +apt-get install -y \ + docker-ce \ + docker-ce-cli \ + containerd.io \ + docker-buildx-plugin \ + docker-compose-plugin + +usermod -aG docker zroc + +systemctl enable docker +systemctl start docker + +docker --version +docker compose version + +cat > /etc/docker/daemon.json << 'EOF' +{ + "log-driver": "json-file", + "log-opts": { + "max-size": "50m", + "max-file": "3" + }, + "storage-driver": "overlay2" +} +EOF + +systemctl restart docker + +echo "==> [01-docker] Done" diff --git a/zroc-ova/scripts/02-zroc.sh b/zroc-ova/scripts/02-zroc.sh new file mode 100644 index 0000000..a5c7f0a --- /dev/null +++ b/zroc-ova/scripts/02-zroc.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# zroc-ova/scripts/02-zroc.sh +set -euo pipefail +echo "==> [02-zroc] Setting up zROC installation" + +INSTALL_DIR=/opt/zroc +ZROC_REPO="https://github.com/ZertoPublic/zroc.git" + +git clone --depth=1 "$ZROC_REPO" "$INSTALL_DIR" + +mkdir -p \ + "$INSTALL_DIR/certs" \ + "$INSTALL_DIR/zvmexporter" \ + "$INSTALL_DIR/data" + +cd "$INSTALL_DIR" + +docker compose pull prometheus grafana authentik-server authentik-worker \ + || echo "[02-zroc] Some images not yet available — will pull on first start" + +chown -R zroc:zroc "$INSTALL_DIR" + +echo "==> [02-zroc] Installation directory: $INSTALL_DIR" +echo "==> [02-zroc] Done" diff --git a/zroc-ova/scripts/03-setup-wizard.sh b/zroc-ova/scripts/03-setup-wizard.sh new file mode 100644 index 0000000..f894e47 --- /dev/null +++ b/zroc-ova/scripts/03-setup-wizard.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# zroc-ova/scripts/03-setup-wizard.sh +set -euo pipefail +echo "==> [03-setup-wizard] Installing setup wizard" + +install -m 0755 /tmp/zroc-setup /usr/local/bin/zroc-setup + +cat > /etc/systemd/system/zroc-firstboot.service << 'EOF' +[Unit] +Description=zROC First-Boot Setup Wizard +After=network-online.target +Wants=network-online.target +ConditionPathExists=!/opt/zroc/.env + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=/usr/local/bin/zroc-setup +StandardInput=tty +TTYPath=/dev/tty1 +StandardOutput=journal+console +StandardError=journal+console +TimeoutStartSec=0 + +[Install] +WantedBy=multi-user.target +EOF + +systemctl daemon-reload +systemctl enable zroc-firstboot.service + +rm -f /etc/sudoers.d/zroc-packer +cat > /etc/sudoers.d/zroc << 'EOF' +zroc ALL=(ALL) NOPASSWD: /usr/bin/docker, /usr/local/bin/zroc-setup, /usr/bin/systemctl restart zroc +EOF +chmod 440 /etc/sudoers.d/zroc + +echo "==> [03-setup-wizard] Done" diff --git a/zroc-ova/scripts/04-systemd-service.sh b/zroc-ova/scripts/04-systemd-service.sh new file mode 100644 index 0000000..edec6d7 --- /dev/null +++ b/zroc-ova/scripts/04-systemd-service.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# zroc-ova/scripts/04-systemd-service.sh +set -euo pipefail +echo "==> [04-systemd-service] Installing zroc.service" + +cat > /etc/systemd/system/zroc.service << 'EOF' +[Unit] +Description=zROC Observability Stack +Documentation=https://github.com/ZertoPublic/zroc +After=docker.service network-online.target +Requires=docker.service +Wants=network-online.target + +[Service] +Type=oneshot +RemainAfterExit=yes +User=zroc +Group=zroc +WorkingDirectory=/opt/zroc +EnvironmentFile=-/opt/zroc/.env +ExecStartPre=/usr/bin/docker compose pull --quiet +ExecStart=/usr/bin/docker compose up -d --remove-orphans +ExecStop=/usr/bin/docker compose down +ExecReload=/usr/bin/docker compose up -d --remove-orphans +TimeoutStartSec=180 +TimeoutStopSec=60 + +[Install] +WantedBy=multi-user.target +EOF + +systemctl daemon-reload +echo "==> [04-systemd-service] Done" diff --git a/zroc-ova/scripts/05-cleanup.sh b/zroc-ova/scripts/05-cleanup.sh new file mode 100644 index 0000000..dcb069e --- /dev/null +++ b/zroc-ova/scripts/05-cleanup.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# zroc-ova/scripts/05-cleanup.sh +set -euo pipefail +echo "==> [05-cleanup] Cleaning build artefacts" + +rm -f /etc/sudoers.d/zroc-packer + +apt-get autoremove -y +apt-get autoclean -y +apt-get clean +rm -rf /var/lib/apt/lists/* + +journalctl --rotate +journalctl --vacuum-time=1s +find /var/log -type f -name "*.log" -delete +find /var/log -type f -name "*.gz" -delete +truncate -s 0 /var/log/wtmp /var/log/btmp /var/log/lastlog 2>/dev/null || true + +unset HISTFILE +rm -f /home/zroc/.bash_history /root/.bash_history +history -c + +cloud-init clean --logs 2>/dev/null || true + +rm -rf /tmp/* /var/tmp/* + +echo "==> [05-cleanup] Zeroing free space (this takes a moment)…" +dd if=/dev/zero of=/ZERO bs=4M status=progress 2>/dev/null || true +rm -f /ZERO +sync + +SWAP_DEV=$(swapon --show=NAME --noheadings 2>/dev/null | head -1) +if [[ -n "$SWAP_DEV" ]]; then + swapoff "$SWAP_DEV" + dd if=/dev/zero of="$SWAP_DEV" bs=4M status=progress 2>/dev/null || true + mkswap "$SWAP_DEV" +fi + +echo "==> [05-cleanup] Done — image ready for OVA packaging" diff --git a/zroc-ui/.env.example b/zroc-ui/.env.example new file mode 100644 index 0000000..e63b642 --- /dev/null +++ b/zroc-ui/.env.example @@ -0,0 +1,60 @@ +# ───────────────────────────────────────────────────────────────────────────── +# zROC Environment Variables +# Copy to .env and fill in your values. +# Generated automatically by: zroc-setup (first-boot wizard) +# ───────────────────────────────────────────────────────────────────────────── + +# ── Zerto ZVM — Site 1 ──────────────────────────────────────────────────────── +ZVM_HOST=192.168.50.60 +ZVM_USERNAME=admin +ZVM_PASSWORD=changeme +# Optional — needed for VRA CPU/memory metrics +VCENTER_HOST=vcenter.local +VCENTER_USER=administrator@vsphere.local +VCENTER_PASSWORD=changeme + +# ── Zerto ZVM — Site 2 (uncomment to enable) ───────────────────────────────── +# ZVM2_HOST=192.168.60.60 +# ZVM2_USERNAME=admin +# ZVM2_PASSWORD=changeme +# VCENTER2_HOST=vcenter2.local +# VCENTER2_USER=administrator@vsphere.local +# VCENTER2_PASSWORD=changeme + +# ── zROC UI ─────────────────────────────────────────────────────────────────── +# Public-facing URL of the appliance (used for OIDC redirect URIs) +PUBLIC_URL=https://192.168.50.100 + +# Session secret — generate with: openssl rand -hex 32 +SESSION_SECRET=REPLACE_WITH_RANDOM_SECRET + +# ── Authentik ───────────────────────────────────────────────────────────────── +# PostgreSQL password — generate with: openssl rand -hex 24 +AUTHENTIK_PG_PASS=REPLACE_WITH_PG_PASSWORD + +# Authentik secret key — generate with: openssl rand -hex 48 +AUTHENTIK_SECRET_KEY=REPLACE_WITH_AUTHENTIK_SECRET + +# OIDC client credentials (generated by Authentik blueprint, copied here by setup wizard) +AUTHENTIK_CLIENT_ID=zroc-dashboard +AUTHENTIK_CLIENT_SECRET=REPLACE_AFTER_BLUEPRINT_RUNS +ZROC_OIDC_CLIENT_ID=zroc-dashboard +ZROC_OIDC_CLIENT_SECRET=REPLACE_AFTER_BLUEPRINT_RUNS + +# Admin API token (generated by Authentik blueprint, retrieved by setup wizard) +AUTHENTIK_ADMIN_TOKEN=REPLACE_AFTER_BLUEPRINT_RUNS + +# Passed into blueprint to set redirect URI +ZROC_PUBLIC_URL=https://192.168.50.100 + +# ── Grafana ─────────────────────────────────────────────────────────────────── +GRAFANA_PASSWORD=zertodata + +# Optional: Grafana OIDC (integrates Grafana login with Authentik) +GRAFANA_OIDC_ENABLED=false +# GRAFANA_CLIENT_ID=grafana +# GRAFANA_CLIENT_SECRET= + +# ── Prometheus ──────────────────────────────────────────────────────────────── +# Internal only — not directly accessible from outside the stack +PROMETHEUS_URL=http://prometheus:9090 diff --git a/zroc-ui/.github/workflows/publish.yml b/zroc-ui/.github/workflows/publish.yml new file mode 100644 index 0000000..6e86c8e --- /dev/null +++ b/zroc-ui/.github/workflows/publish.yml @@ -0,0 +1,2 @@ +# TODO: Full content to be added +# This file is part of the zROC project recreation diff --git a/zroc-ui/DOCKER_README.md b/zroc-ui/DOCKER_README.md new file mode 100644 index 0000000..a877543 --- /dev/null +++ b/zroc-ui/DOCKER_README.md @@ -0,0 +1,65 @@ +# zROC UI + +**Zerto Resiliency Observation Console** — a purpose-built observability frontend for Zerto that replaces Zerto Analytics with a self-hosted, always-on dashboard. + +## What it does + +- **NOC Dashboard** — VPG health heat grid, site cards, RPO status at a glance +- **VPG Monitor** — per-VPG RPO history, throughput/IOPS charts, journal health, VM breakdown +- **VM Protection** — per-VM drill-down with RPO trends, journal gauges, encryption trends +- **VRA Infrastructure** — CPU/memory usage, workload counts, volume capacity +- **Encryption Detection** — near real-time ransomware anomaly detection +- **Storage** — datastore capacity with Zerto-attributed journal/scratch/recovery breakdown +- **User Management** — full CRUD with 2FA QR code setup, group management, enterprise IdP integration + +## Authentication + +This image includes a Node.js Express backend that handles: +- OIDC login via **Authentik** (bundled in the full stack) +- 2FA enforcement (TOTP with QR codes) +- Enterprise IdP integration (Azure AD, Okta, SAML, LDAP) +- Rate-limited login, `httpOnly` session cookies, zero Prometheus exposure to browser + +## Quick start — full stack + +```bash +git clone https://github.com/ZertoPublic/zroc.git +cd zroc +cp .env.example .env +# Edit .env with your ZVM credentials and secrets +docker compose up -d +``` + +Then visit `https://` — on first access run through the setup wizard. + +## Environment variables + +|Variable |Required|Description | +|-------------------------|--------|-------------------------------------------------------| +|`PROMETHEUS_URL` |No |Prometheus endpoint (default: `http://prometheus:9090`)| +|`AUTHENTIK_URL` |Yes |Authentik server URL | +|`AUTHENTIK_CLIENT_ID` |Yes |OIDC client ID registered in Authentik | +|`AUTHENTIK_CLIENT_SECRET`|Yes |OIDC client secret | +|`AUTHENTIK_ADMIN_TOKEN` |Yes |Authentik API token for user management | +|`PUBLIC_URL` |Yes |Public HTTPS URL of the appliance | +|`SESSION_SECRET` |Yes |Random secret for session signing (min 32 chars) | +|`AUTHENTIK_ADMIN_GROUP` |No |Group name for admin role (default: `zroc-admins`) | +|`AUTHENTIK_VIEWER_GROUP` |No |Group name for viewer role (default: `zroc-viewers`) | + +## Image tags + +|Tag |Description | +|--------|-----------------------------------------| +|`stable`|Latest stable release — use in production| +|`latest`|Alias for stable | +|`1.x.x` |Pinned semantic version | + +## Source + +- UI & backend: [github.com/ZertoPublic/zroc](https://github.com/ZertoPublic/zroc) +- Zerto Exporter: [github.com/recklessop/Zerto_Exporter](https://github.com/recklessop/Zerto_Exporter) +- OVA Appliance: [github.com/ZertoPublic/zroc-ova](https://github.com/ZertoPublic/zroc-ova) + +## License + +Apache 2.0 — open source, not officially supported by Zerto/HPE. diff --git a/zroc-ui/Dockerfile b/zroc-ui/Dockerfile new file mode 100644 index 0000000..7d21b13 --- /dev/null +++ b/zroc-ui/Dockerfile @@ -0,0 +1,42 @@ +# Stage 1: Build the React SPA +FROM node:20-alpine AS frontend-builder + +WORKDIR /build/frontend + +COPY package.json package-lock.json* ./ +RUN npm ci --prefer-offline + +COPY index.html vite.config.js tailwind.config.js postcss.config.js ./ +COPY src/ ./src/ +RUN npm run build + +# Stage 2: Install backend production dependencies +FROM node:20-alpine AS backend-builder + +WORKDIR /build/backend + +COPY backend/package.json backend/package-lock.json* ./ +RUN npm ci --omit=dev --prefer-offline + +# Stage 3: Production image +FROM node:20-alpine AS production + +RUN addgroup -S zroc && adduser -S zroc -G zroc + +WORKDIR /app + +COPY backend/ ./backend/ +COPY --from=backend-builder /build/backend/node_modules ./backend/node_modules +COPY --from=frontend-builder /build/frontend/dist ./dist + +RUN mkdir -p /app/data && chown zroc:zroc /app/data +VOLUME ["/app/data"] + +USER zroc + +EXPOSE 3001 + +HEALTHCHECK --interval=15s --timeout=5s --start-period=30s --retries=3 \ + CMD wget -qO- http://localhost:3001/api/health || exit 1 + +CMD ["node", "backend/server.js"] diff --git a/zroc-ui/README.md b/zroc-ui/README.md new file mode 100644 index 0000000..6e86c8e --- /dev/null +++ b/zroc-ui/README.md @@ -0,0 +1,2 @@ +# TODO: Full content to be added +# This file is part of the zROC project recreation diff --git a/zroc-ui/authentik/blueprints/zroc-initial.yaml b/zroc-ui/authentik/blueprints/zroc-initial.yaml new file mode 100644 index 0000000..6e86c8e --- /dev/null +++ b/zroc-ui/authentik/blueprints/zroc-initial.yaml @@ -0,0 +1,2 @@ +# TODO: Full content to be added +# This file is part of the zROC project recreation diff --git a/zroc-ui/backend/authentik.js b/zroc-ui/backend/authentik.js new file mode 100644 index 0000000..f54b366 --- /dev/null +++ b/zroc-ui/backend/authentik.js @@ -0,0 +1,2 @@ +// TODO: Full content to be added +// This file is part of the zROC project recreation diff --git a/zroc-ui/backend/config.js b/zroc-ui/backend/config.js new file mode 100644 index 0000000..7d18bdb --- /dev/null +++ b/zroc-ui/backend/config.js @@ -0,0 +1,41 @@ +// backend/config.js — central configuration with validation +'use strict'; + +function require_env(name) { + const val = process.env[name]; + if (!val) throw new Error(`Required environment variable ${name} is not set`); + return val; +} + +function optional_env(name, fallback = '') { + return process.env[name] || fallback; +} + +const config = { + port: parseInt(optional_env('PORT', '3001'), 10), + node_env: optional_env('NODE_ENV', 'production'), + is_dev: optional_env('NODE_ENV', 'production') === 'development', + session_secret: optional_env('SESSION_SECRET', 'CHANGE_ME_IN_PRODUCTION_' + Math.random()), + session_max_age_ms: parseInt(optional_env('SESSION_MAX_AGE_HOURS', '24'), 10) * 60 * 60 * 1000, + prometheus_url: optional_env('PROMETHEUS_URL', 'http://prometheus:9090'), + authentik_url: optional_env('AUTHENTIK_URL', 'http://authentik-server:9000'), + authentik_client_id: optional_env('AUTHENTIK_CLIENT_ID', 'zroc-dashboard'), + authentik_client_secret: optional_env('AUTHENTIK_CLIENT_SECRET', ''), + authentik_admin_token: optional_env('AUTHENTIK_ADMIN_TOKEN', ''), + public_url: optional_env('PUBLIC_URL', 'https://localhost:8443'), + admin_group: optional_env('AUTHENTIK_ADMIN_GROUP', 'zroc-admins'), + viewer_group: optional_env('AUTHENTIK_VIEWER_GROUP', 'zroc-viewers'), + redis_url: optional_env('REDIS_URL', ''), +}; + +if (!config.authentik_client_secret) { + console.warn('[CONFIG] AUTHENTIK_CLIENT_SECRET not set — auth will fail until configured'); +} +if (!config.authentik_admin_token) { + console.warn('[CONFIG] AUTHENTIK_ADMIN_TOKEN not set — user management API will be unavailable'); +} +if (config.session_secret.startsWith('CHANGE_ME')) { + console.warn('[CONFIG] SESSION_SECRET not set — using random value, sessions will not survive restart'); +} + +module.exports = config; diff --git a/zroc-ui/backend/logger.js b/zroc-ui/backend/logger.js new file mode 100644 index 0000000..df0b1bc --- /dev/null +++ b/zroc-ui/backend/logger.js @@ -0,0 +1,18 @@ +// backend/logger.js +'use strict'; +const { createLogger, format, transports } = require('winston'); +const config = require('./config'); + +const logger = createLogger({ + level: config.is_dev ? 'debug' : 'info', + format: format.combine( + format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), + format.errors({ stack: true }), + config.is_dev + ? format.combine(format.colorize(), format.simple()) + : format.json() + ), + transports: [new transports.Console()], +}); + +module.exports = logger; diff --git a/zroc-ui/backend/middleware/authenticate.js b/zroc-ui/backend/middleware/authenticate.js new file mode 100644 index 0000000..fb7b7fa --- /dev/null +++ b/zroc-ui/backend/middleware/authenticate.js @@ -0,0 +1,28 @@ +// backend/middleware/authenticate.js +'use strict'; + +/** + * Middleware: require an authenticated session. + * If the request has no valid session → 401. + * Attaches req.user = { id, username, name, email, role } for downstream use. + */ +function authenticate(req, res, next) { + if (!req.session?.user) { + return res.status(401).json({ error: 'Unauthorized', code: 'NO_SESSION' }); + } + req.user = req.session.user; + next(); +} + +/** + * Middleware: require admin role. + * Must be used AFTER authenticate(). + */ +function requireAdmin(req, res, next) { + if (req.user?.role !== 'admin') { + return res.status(403).json({ error: 'Forbidden', code: 'REQUIRES_ADMIN' }); + } + next(); +} + +module.exports = { authenticate, requireAdmin }; diff --git a/zroc-ui/backend/package.json b/zroc-ui/backend/package.json new file mode 100644 index 0000000..b6d64e3 --- /dev/null +++ b/zroc-ui/backend/package.json @@ -0,0 +1,28 @@ +{ + "name": "zroc-ui-backend", + "version": "1.0.0", + "description": "zROC UI backend — auth, Prometheus proxy, Authentik user management", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "nodemon server.js" + }, + "dependencies": { + "axios": "^1.7.2", + "connect-redis": "^7.1.1", + "cookie-parser": "^1.4.6", + "cors": "^2.8.5", + "express": "^4.19.2", + "express-rate-limit": "^7.3.1", + "express-session": "^1.18.0", + "http-proxy-middleware": "^3.0.0", + "ioredis": "^5.4.1", + "openid-client": "^5.7.0", + "qrcode": "^1.5.4", + "uuid": "^10.0.0", + "winston": "^3.13.0" + }, + "devDependencies": { + "nodemon": "^3.1.4" + } +} diff --git a/zroc-ui/backend/routes/admin/users.js b/zroc-ui/backend/routes/admin/users.js new file mode 100644 index 0000000..a1fb90b --- /dev/null +++ b/zroc-ui/backend/routes/admin/users.js @@ -0,0 +1,117 @@ +// backend/routes/admin/users.js +'use strict'; + +const express = require('express'); +const { authenticate, requireAdmin } = require('../../middleware/authenticate'); +const authentik = require('../../authentik'); +const logger = require('../../logger'); + +const router = express.Router(); + +router.use(authenticate, requireAdmin); + +router.get('/', async (req, res) => { + try { + const { search = '', page = '1', pageSize = '50' } = req.query; + const result = await authentik.listUsers({ + search, + page: parseInt(page, 10), + pageSize: parseInt(pageSize, 10), + }); + res.json(result); + } catch (err) { + logger.error('[Users] List failed:', err.message); + res.status(502).json({ error: 'Failed to list users', detail: err.message }); + } +}); + +router.get('/:id', async (req, res) => { + try { + const user = await authentik.getUser(req.params.id); + res.json(user); + } catch (err) { + const status = err.response?.status === 404 ? 404 : 502; + res.status(status).json({ error: 'User not found', detail: err.message }); + } +}); + +router.post('/', async (req, res) => { + try { + const { username, name, email, isActive = true, groups = [], password } = req.body; + if (!username || !name || !email) { + return res.status(400).json({ error: 'username, name, and email are required' }); + } + const user = await authentik.createUser({ username, name, email, isActive, groups, password }); + logger.info(`[Users] ${req.user.username} created user ${username}`); + res.status(201).json(user); + } catch (err) { + const detail = err.response?.data || err.message; + logger.error('[Users] Create failed:', detail); + res.status(err.response?.status === 400 ? 400 : 502).json({ error: 'Failed to create user', detail }); + } +}); + +router.patch('/:id', async (req, res) => { + try { + const { name, email, isActive, groups } = req.body; + const user = await authentik.updateUser(req.params.id, { name, email, isActive, groups }); + logger.info(`[Users] ${req.user.username} updated user ${user.username}`); + res.json(user); + } catch (err) { + logger.error('[Users] Update failed:', err.message); + res.status(502).json({ error: 'Failed to update user', detail: err.message }); + } +}); + +router.delete('/:id', async (req, res) => { + try { + const targetId = parseInt(req.params.id, 10); + if (String(targetId) === String(req.user.id) || req.user.username === 'akadmin') { + return res.status(400).json({ error: 'Cannot delete your own account or the akadmin account' }); + } + await authentik.deleteUser(targetId); + logger.info(`[Users] ${req.user.username} deleted user ${targetId}`); + res.status(204).send(); + } catch (err) { + logger.error('[Users] Delete failed:', err.message); + res.status(502).json({ error: 'Failed to delete user', detail: err.message }); + } +}); + +router.post('/:id/set-password', async (req, res) => { + try { + const { password } = req.body; + if (!password || password.length < 8) { + return res.status(400).json({ error: 'Password must be at least 8 characters' }); + } + await authentik.setPassword(req.params.id, password); + logger.info(`[Users] ${req.user.username} reset password for user ${req.params.id}`); + res.json({ success: true }); + } catch (err) { + logger.error('[Users] Password reset failed:', err.message); + res.status(502).json({ error: 'Failed to set password', detail: err.message }); + } +}); + +router.post('/:id/setup-2fa', async (req, res) => { + try { + const { setupUrl, qrDataUrl } = await authentik.generateTwoFactorSetupLink(req.params.id); + logger.info(`[Users] ${req.user.username} generated 2FA setup link for user ${req.params.id}`); + res.json({ setupUrl, qrDataUrl }); + } catch (err) { + logger.error('[Users] 2FA setup failed:', err.message); + res.status(502).json({ error: 'Failed to generate 2FA setup link', detail: err.message }); + } +}); + +router.get('/meta/groups', async (req, res) => { + try { + const groups = await authentik.listGroups(); + res.json(groups); + } catch (err) { + logger.error('[Users] Groups list failed:', err.message); + res.status(502).json({ error: 'Failed to list groups', detail: err.message }); + } +}); + +module.exports = router; diff --git a/zroc-ui/backend/routes/auth.js b/zroc-ui/backend/routes/auth.js new file mode 100644 index 0000000..ff79ac2 --- /dev/null +++ b/zroc-ui/backend/routes/auth.js @@ -0,0 +1,138 @@ +// backend/routes/auth.js — OIDC login / callback / logout +'use strict'; + +const express = require('express'); +const { Issuer, generators } = require('openid-client'); +const config = require('../config'); +const logger = require('../logger'); +const { authenticate } = require('../middleware/authenticate'); + +const router = express.Router(); + +let oidcClient = null; + +async function getOidcClient() { + if (oidcClient) return oidcClient; + + const issuerUrl = `${config.authentik_url}/application/o/${config.authentik_client_id}/`; + logger.info(`[Auth] Discovering OIDC issuer at ${issuerUrl}`); + + const issuer = await Issuer.discover(issuerUrl); + oidcClient = new issuer.Client({ + client_id: config.authentik_client_id, + client_secret: config.authentik_client_secret, + redirect_uris: [`${config.public_url}/api/auth/callback`], + response_types: ['code'], + }); + + logger.info('[Auth] OIDC client initialised'); + return oidcClient; +} + +router.get('/login', async (req, res) => { + try { + const client = await getOidcClient(); + const state = generators.state(); + const nonce = generators.nonce(); + const verifier = generators.codeVerifier(); + const challenge = generators.codeChallenge(verifier); + + req.session.oidc = { state, nonce, verifier }; + + const redirectTo = req.query.redirect || '/'; + req.session.postLoginRedirect = redirectTo; + + const authUrl = client.authorizationUrl({ + scope: 'openid profile email groups', + state, + nonce, + code_challenge: challenge, + code_challenge_method: 'S256', + }); + + res.redirect(authUrl); + } catch (err) { + logger.error('[Auth] Login redirect failed:', err); + res.status(502).json({ error: 'Identity provider unavailable' }); + } +}); + +router.get('/callback', async (req, res) => { + try { + const client = await getOidcClient(); + const { state, nonce, verifier } = req.session.oidc || {}; + + if (!state) { + return res.redirect('/?error=session_expired'); + } + + const params = client.callbackParams(req); + const tokenSet = await client.callback( + `${config.public_url}/api/auth/callback`, + params, + { state, nonce, code_verifier: verifier } + ); + const userinfo = await client.userinfo(tokenSet.access_token); + + const groups = userinfo.groups ?? []; + const role = groups.includes(config.admin_group) + ? 'admin' + : groups.includes(config.viewer_group) + ? 'viewer' + : 'viewer'; + + req.session.user = { + id: userinfo.sub, + username: userinfo.preferred_username, + name: userinfo.name, + email: userinfo.email, + role, + groups, + accessToken: tokenSet.access_token, + refreshToken: tokenSet.refresh_token, + expiresAt: tokenSet.expires_at, + }; + + delete req.session.oidc; + + const redirect = req.session.postLoginRedirect || '/'; + delete req.session.postLoginRedirect; + + logger.info(`[Auth] User ${userinfo.preferred_username} (${role}) logged in`); + res.redirect(redirect); + } catch (err) { + logger.error('[Auth] Callback failed:', err); + res.redirect('/?error=auth_failed'); + } +}); + +router.post('/logout', authenticate, async (req, res) => { + const username = req.user?.username; + const idToken = req.session.user?.accessToken; + + req.session.destroy(() => { + res.clearCookie('connect.sid'); + logger.info(`[Auth] User ${username} logged out`); + + const endSessionUrl = `${config.authentik_url}/application/o/${config.authentik_client_id}/end-session/`; + const params = new URLSearchParams({ post_logout_redirect_uri: config.public_url }); + if (idToken) params.set('id_token_hint', idToken); + res.json({ redirectUrl: `${endSessionUrl}?${params}` }); + }); +}); + +router.get('/me', authenticate, (req, res) => { + const { id, username, name, email, role, groups } = req.user; + res.json({ id, username, name, email, role, groups }); +}); + +router.get('/status', (req, res) => { + if (req.session?.user) { + const { id, username, name, email, role } = req.session.user; + res.json({ authenticated: true, user: { id, username, name, email, role } }); + } else { + res.status(401).json({ authenticated: false }); + } +}); + +module.exports = router; diff --git a/zroc-ui/backend/routes/prometheus.js b/zroc-ui/backend/routes/prometheus.js new file mode 100644 index 0000000..4b17889 --- /dev/null +++ b/zroc-ui/backend/routes/prometheus.js @@ -0,0 +1,26 @@ +// backend/routes/prometheus.js +'use strict'; + +const express = require('express'); +const { createProxyMiddleware } = require('http-proxy-middleware'); +const config = require('../config'); +const { authenticate } = require('../middleware/authenticate'); + +const router = express.Router(); + +router.use(authenticate); + +const prometheusProxy = createProxyMiddleware({ + target: config.prometheus_url, + changeOrigin: true, + pathRewrite: { '^/api/prometheus': '' }, + on: { + error: (err, req, res) => { + res.status(502).json({ error: 'Prometheus unreachable', detail: err.message }); + }, + }, +}); + +router.use('/', prometheusProxy); + +module.exports = router; diff --git a/zroc-ui/backend/server.js b/zroc-ui/backend/server.js new file mode 100644 index 0000000..5cb5898 --- /dev/null +++ b/zroc-ui/backend/server.js @@ -0,0 +1,75 @@ +// backend/server.js — zROC UI backend entry point +'use strict'; + +const path = require('path'); +const express = require('express'); +const session = require('express-session'); +const rateLimit = require('express-rate-limit'); +const cookieParser = require('cookie-parser'); + +const config = require('./config'); +const logger = require('./logger'); + +const authRoutes = require('./routes/auth'); +const prometheusRoute = require('./routes/prometheus'); +const adminUserRoutes = require('./routes/admin/users'); + +const app = express(); + +app.set('trust proxy', 1); + +app.use(express.json()); +app.use(express.urlencoded({ extended: false })); +app.use(cookieParser()); + +const sessionMiddleware = session({ + secret: config.session_secret, + resave: false, + saveUninitialized: false, + cookie: { + secure: !config.is_dev, + httpOnly: true, + sameSite: 'lax', + maxAge: config.session_max_age_ms, + }, +}); +app.use(sessionMiddleware); + +const authLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 20, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Too many requests, please try again later' }, +}); +app.use('/api/auth', authLimiter); + +app.use('/api/auth', authRoutes); +app.use('/api/prometheus', prometheusRoute); +app.use('/api/admin/users', adminUserRoutes); + +app.get('/api/health', (req, res) => { + res.json({ status: 'ok', ts: new Date().toISOString() }); +}); + +const distPath = path.join(__dirname, '..', 'dist'); +app.use(express.static(distPath)); +app.get('*', (req, res) => { + if (req.path.startsWith('/api/')) { + return res.status(404).json({ error: 'Not found' }); + } + res.sendFile(path.join(distPath, 'index.html')); +}); + +// eslint-disable-next-line no-unused-vars +app.use((err, req, res, _next) => { + logger.error('Unhandled error:', err); + res.status(500).json({ error: 'Internal server error' }); +}); + +app.listen(config.port, () => { + logger.info(`[Server] zROC UI backend listening on port ${config.port}`); + logger.info(`[Server] Environment: ${config.node_env}`); + logger.info(`[Server] Prometheus: ${config.prometheus_url}`); + logger.info(`[Server] Authentik: ${config.authentik_url}`); +}); diff --git a/zroc-ui/caddy/Caddyfile b/zroc-ui/caddy/Caddyfile new file mode 100644 index 0000000..ebdc847 --- /dev/null +++ b/zroc-ui/caddy/Caddyfile @@ -0,0 +1,47 @@ +{ + admin off + auto_https off + log { + format json + } +} + +:443 { + tls internal + + handle /auth/* { + reverse_proxy authentik-server:9000 { + header_up X-Forwarded-Proto https + header_up X-Forwarded-For {remote_host} + } + } + + handle /outpost.goauthentik.io/* { + reverse_proxy authentik-server:9000 { + header_up X-Forwarded-Proto https + } + } + + handle { + reverse_proxy zroc-ui:3001 { + header_up X-Forwarded-Proto https + header_up X-Forwarded-For {remote_host} + header_up X-Real-IP {remote_host} + health_uri /api/health + health_interval 15s + } + } + + header { + X-Frame-Options "SAMEORIGIN" + X-Content-Type-Options "nosniff" + X-XSS-Protection "1; mode=block" + Referrer-Policy "strict-origin-when-cross-origin" + Strict-Transport-Security "max-age=31536000; includeSubDomains" + -Server + } +} + +:80 { + redir https://{host}{uri} permanent +} diff --git a/zroc-ui/docker-compose.yaml b/zroc-ui/docker-compose.yaml new file mode 100644 index 0000000..6e86c8e --- /dev/null +++ b/zroc-ui/docker-compose.yaml @@ -0,0 +1,2 @@ +# TODO: Full content to be added +# This file is part of the zROC project recreation diff --git a/zroc-ui/index.html b/zroc-ui/index.html new file mode 100644 index 0000000..7f4d218 --- /dev/null +++ b/zroc-ui/index.html @@ -0,0 +1,21 @@ + + + + + + + zROC — Zerto Resiliency Observation Console + + + + + + + +
+ + + diff --git a/zroc-ui/package.json b/zroc-ui/package.json new file mode 100644 index 0000000..13c682b --- /dev/null +++ b/zroc-ui/package.json @@ -0,0 +1,29 @@ +{ + "name": "zroc-ui", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@tanstack/react-query": "^5.40.0", + "clsx": "^2.1.1", + "lucide-react": "^0.395.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.23.1", + "recharts": "^2.12.7" + }, + "devDependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "autoprefixer": "^10.4.19", + "postcss": "^8.4.38", + "tailwindcss": "^3.4.4", + "vite": "^5.2.13" + } +} diff --git a/zroc-ui/postcss.config.js b/zroc-ui/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/zroc-ui/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/zroc-ui/public/favicon.svg b/zroc-ui/public/favicon.svg new file mode 100644 index 0000000..60e6fc2 --- /dev/null +++ b/zroc-ui/public/favicon.svg @@ -0,0 +1,17 @@ + + + + + + + + + + diff --git a/zroc-ui/src/App.jsx b/zroc-ui/src/App.jsx new file mode 100644 index 0000000..bc60d8c --- /dev/null +++ b/zroc-ui/src/App.jsx @@ -0,0 +1,52 @@ +// src/App.jsx — final router with all pages wired up +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { AuthProvider } from '@/auth/AuthContext'; +import { ProtectedRoute, AdminRoute } from '@/auth/ProtectedRoute'; +import AppShell from '@/components/layout/AppShell'; +import Overview from '@/pages/Overview'; +import VPGMonitor from '@/pages/VPGMonitor'; +import VRADashboard from '@/pages/VRADashboard'; +import EncryptionPage from '@/pages/Encryption'; +import Storage from '@/pages/Storage'; +import UserManagement from '@/pages/Settings/UserManagement'; +import VMDetail from '@/pages/VMDetail'; +import Placeholder from '@/pages/Placeholder'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 2, + staleTime: 15_000, + refetchOnWindowFocus: true, + }, + }, +}); + +export default function App() { + return ( + + + + + }> + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + + } /> + + + } /> + + + + + ); +} diff --git a/zroc-ui/src/api/prometheus.js b/zroc-ui/src/api/prometheus.js new file mode 100644 index 0000000..aaf1b54 --- /dev/null +++ b/zroc-ui/src/api/prometheus.js @@ -0,0 +1,191 @@ +// src/api/prometheus.js +const BASE = '/api/prometheus/api/v1'; + +async function promFetch(endpoint, params = {}) { + const url = new URL(BASE + endpoint, window.location.origin); + Object.entries(params).forEach(([k, v]) => { + if (v !== undefined && v !== null) url.searchParams.set(k, v); + }); + const res = await fetch(url.toString(), { credentials: 'include' }); + if (!res.ok) throw new Error(`Prometheus error: ${res.status}`); + const json = await res.json(); + if (json.status !== 'success') throw new Error(json.error || 'Prometheus query failed'); + return json.data; +} + +export async function instantQuery(promql, time) { + const params = { query: promql }; + if (time) params.time = time; + const data = await promFetch('/query', params); + return data.result; +} + +export async function rangeQuery(promql, start, end, step = '60s') { + const data = await promFetch('/query_range', { query: promql, start, end, step }); + return data.result; +} + +export async function labelValues(labelName, match) { + const params = {}; + if (match) params.match = match; + const data = await promFetch(`/label/${labelName}/values`, params); + return data; +} + +export async function querySites() { + return labelValues('SiteName', 'vpg_actual_rpo'); +} + +export async function queryOverviewSummary() { + const [alertVec, throughputVec, rpoVec] = await Promise.all([ + instantQuery('vpg_alert_status'), + instantQuery('sum by (SiteName) (vpg_throughput_in_mb)'), + instantQuery('max by (SiteName) (vpg_actual_rpo)'), + ]); + + const siteMap = {}; + for (const { metric, value } of alertVec) { + const site = metric.SiteName || 'Unknown'; + if (!siteMap[site]) siteMap[site] = { siteName: site, ok: 0, warn: 0, crit: 0 }; + const v = Number(value[1]); + if (v === 0) siteMap[site].ok++; + else if (v === 1) siteMap[site].warn++; + else siteMap[site].crit++; + } + + for (const { metric, value } of throughputVec) { + const site = metric.SiteName || 'Unknown'; + if (siteMap[site]) siteMap[site].throughputMb = parseFloat(value[1]); + } + + for (const { metric, value } of rpoVec) { + const site = metric.SiteName || 'Unknown'; + if (siteMap[site]) siteMap[site].worstRpoSec = parseFloat(value[1]); + } + + return Object.values(siteMap).map((s) => ({ + ...s, + total: s.ok + s.warn + s.crit, + })); +} + +export async function queryAllVpgs() { + const [rpoVec, configuredVec, alertVec, throughputVec, iopsVec, vmCountVec] = + await Promise.all([ + instantQuery('vpg_actual_rpo'), + instantQuery('vpg_configured_rpo'), + instantQuery('vpg_alert_status'), + instantQuery('vpg_throughput_in_mb'), + instantQuery('vpg_iops'), + instantQuery('vpg_vms_count'), + ]); + + const byId = {}; + const idx = (vec, field, transform = Number) => { + for (const { metric, value } of vec) { + const id = metric.VpgIdentifier || metric.VpgName; + if (!byId[id]) byId[id] = { + id, + name: metric.VpgName || id, + siteName: metric.SiteName || 'Unknown', + siteId: metric.SiteIdentifier, + priority: metric.VpgPriority, + }; + byId[id][field] = transform(value[1]); + } + }; + + idx(rpoVec, 'actualRpoSec'); + idx(configuredVec, 'configuredRpoSec'); + idx(alertVec, 'alertStatus'); + idx(throughputVec, 'throughputMb', parseFloat); + idx(iopsVec, 'iops', parseFloat); + idx(vmCountVec, 'vmCount'); + + return Object.values(byId); +} + +export async function queryTopRpoViolators(n = 10) { + const vpgs = await queryAllVpgs(); + return vpgs + .filter((v) => v.actualRpoSec && v.configuredRpoSec) + .sort((a, b) => (b.actualRpoSec / b.configuredRpoSec) - (a.actualRpoSec / a.configuredRpoSec)) + .slice(0, n); +} + +export async function queryVpgRpoHistory(vpgName, startOffset = '6h', step = '60s') { + const end = Math.floor(Date.now() / 1000); + const start = end - parseDuration(startOffset); + const q = `vpg_actual_rpo{VpgName="${vpgName}"}`; + const result = await rangeQuery(q, start, end, step); + if (!result.length) return []; + const configured = (await instantQuery(`vpg_configured_rpo{VpgName="${vpgName}"}`)) + ?.[0]?.value?.[1]; + return result[0].values.map(([ts, v]) => ({ + ts: ts * 1000, + rpo: parseFloat(v), + configured: configured ? parseFloat(configured) : undefined, + })); +} + +export async function queryVraHealth() { + const [memVec, cpuVec, protVmsVec, recVmsVec, protVolVec, recVolVec] = await Promise.all([ + instantQuery('vra_memory_usage_mb'), + instantQuery('vra_cpu_usage_mhz'), + instantQuery('vra_protected_vms'), + instantQuery('vra_recovery_vms'), + instantQuery('vra_protected_volumes'), + instantQuery('vra_recovery_volumes'), + ]); + + const byName = {}; + const idx = (vec, field, transform = Number) => { + for (const { metric, value } of vec) { + const key = metric.VraName || metric.VraIdentifierStr; + if (!byName[key]) byName[key] = { + name: metric.VraName, + version: metric.VraVersion, + hostVersion: metric.HostVersion, + siteName: metric.SiteName, + }; + byName[key][field] = transform(value[1]); + } + }; + + idx(memVec, 'memoryUsageMb', parseFloat); + idx(cpuVec, 'cpuUsageMhz', parseFloat); + idx(protVmsVec, 'protectedVms'); + idx(recVmsVec, 'recoveryVms'); + idx(protVolVec, 'protectedVolumes'); + idx(recVolVec, 'recoveryVolumes'); + + return Object.values(byName); +} + +export async function queryEncryptionOverview() { + const vec = await instantQuery('vm_PercentEncrypted > 50'); + return vec.map(({ metric, value }) => ({ + vmName: metric.VmName, + vpgName: metric.VpgName, + siteName: metric.SiteName, + pctEnc: parseFloat(value[1]), + trend: metric.vm_TrendChangeLevel, + })).sort((a, b) => b.pctEnc - a.pctEnc); +} + +export async function queryExporterHealth() { + const vec = await instantQuery('exporter_thread_status'); + return vec.map(({ metric, value }) => ({ + instance: metric.ExporterInstance, + thread: metric.thread, + alive: Number(value[1]) === 1, + })); +} + +function parseDuration(s) { + const match = s.match(/^(\d+)(s|m|h|d)$/); + if (!match) return 3600; + const [, n, unit] = match; + const mul = { s: 1, m: 60, h: 3600, d: 86400 }; + return parseInt(n, 10) * mul[unit]; +} diff --git a/zroc-ui/src/api/prometheusExtended.js b/zroc-ui/src/api/prometheusExtended.js new file mode 100644 index 0000000..d27afae --- /dev/null +++ b/zroc-ui/src/api/prometheusExtended.js @@ -0,0 +1,248 @@ +// src/api/prometheusExtended.js +import { instantQuery, rangeQuery, labelValues } from './prometheus'; + +export async function queryVpgDetail(vpgName) { + const esc = vpgName.replace(/"/g, '\\"'); + const [rpo, cfgRpo, alert, status, throughput, iops, vmCount, + storageUsed, storageProv, histActual, histCfg, failsafeActual, failsafeCfg] = + await Promise.all([ + instantQuery(`vpg_actual_rpo{VpgName="${esc}"}`), + instantQuery(`vpg_configured_rpo{VpgName="${esc}"}`), + instantQuery(`vpg_alert_status{VpgName="${esc}"}`), + instantQuery(`vpg_status{VpgName="${esc}"}`), + instantQuery(`vpg_throughput_in_mb{VpgName="${esc}"}`), + instantQuery(`vpg_iops{VpgName="${esc}"}`), + instantQuery(`vpg_vms_count{VpgName="${esc}"}`), + instantQuery(`vpg_storage_used_in_mb{VpgName="${esc}"}`), + instantQuery(`vpg_provisioned_storage_in_mb{VpgName="${esc}"}`), + instantQuery(`vpg_actual_history{VpgName="${esc}"}`), + instantQuery(`vpg_configured_history{VpgName="${esc}"}`), + instantQuery(`vpg_failsafe_actual{VpgName="${esc}"}`), + instantQuery(`vpg_failsafe_configured{VpgName="${esc}"}`), + ]); + + const val = (vec) => parseFloat(vec?.[0]?.value?.[1] ?? 0); + const meta = rpo?.[0]?.metric ?? {}; + + return { + name: vpgName, + siteName: meta.SiteName, + priority: meta.VpgPriority, + actualRpoSec: val(rpo), + configuredRpoSec: val(cfgRpo), + alertStatus: val(alert), + status: val(status), + throughputMb: val(throughput), + iops: val(iops), + vmCount: val(vmCount), + storageUsedMb: val(storageUsed), + storageProvMb: val(storageProv), + histActualMin: val(histActual), + histConfiguredMin:val(histCfg), + failsafeActualMin:val(failsafeActual), + failsafeCfgMin: val(failsafeCfg), + }; +} + +export async function queryVpgVms(vpgName) { + const esc = vpgName.replace(/"/g, '\\"'); + const [rpo, status, throughput, iops, journalUsed, journalHard] = await Promise.all([ + instantQuery(`vm_actualrpo{VpgName="${esc}"}`), + instantQuery(`vm_status{VpgName="${esc}"}`), + instantQuery(`vm_throughput_in_mb{VpgName="${esc}"}`), + instantQuery(`vm_iops{VpgName="${esc}"}`), + instantQuery(`vm_journal_used_storage_mb{VpgName="${esc}"}`), + instantQuery(`vm_journal_hard_limit{VpgName="${esc}"}`), + ]); + + const byId = {}; + const idx = (vec, field, transform = Number) => { + for (const { metric, value } of vec) { + const id = metric.VmIdentifier || metric.VmName; + if (!byId[id]) byId[id] = { + id, + name: metric.VmName, + sourceVra: metric.VmSourceVRA, + recoveryVra: metric.VmRecoveryVRA, + priority: metric.VmPriority, + }; + byId[id][field] = transform(value[1]); + } + }; + + idx(rpo, 'actualRpoSec'); + idx(status, 'status'); + idx(throughput, 'throughputMb', parseFloat); + idx(iops, 'iops', parseFloat); + idx(journalUsed, 'journalUsedMb', parseFloat); + idx(journalHard, 'journalHardLimit', parseFloat); + + return Object.values(byId); +} + +export async function queryAllVms() { + const [rpo, status, throughput, iops, journalUsed, bandwidth, pctEnc] = await Promise.all([ + instantQuery('vm_actualrpo'), + instantQuery('vm_status'), + instantQuery('vm_throughput_in_mb'), + instantQuery('vm_iops'), + instantQuery('vm_journal_used_storage_mb'), + instantQuery('vm_outgoing_bandwidth_in_mbps'), + instantQuery('vm_PercentEncrypted'), + ]); + + const byId = {}; + const idx = (vec, field, transform = Number) => { + for (const { metric, value } of vec) { + const id = metric.VmIdentifier || metric.VmName; + if (!byId[id]) byId[id] = { + id, + name: metric.VmName, + vpgName: metric.VpgName, + siteName: metric.SiteName, + sourceVra: metric.VmSourceVRA, + recoveryVra: metric.VmRecoveryVRA, + }; + byId[id][field] = transform(value[1]); + } + }; + + idx(rpo, 'actualRpoSec'); + idx(status, 'status'); + idx(throughput, 'throughputMb', parseFloat); + idx(iops, 'iops', parseFloat); + idx(journalUsed, 'journalUsedMb', parseFloat); + idx(bandwidth, 'bandwidthMbps', parseFloat); + idx(pctEnc, 'pctEncrypted', parseFloat); + + return Object.values(byId); +} + +export async function queryAllVras() { + const [mem, cpu, memUsage, cpuUsage, + protVms, recVms, protVpgs, recVpgs, protVols, recVols, selfVpgs] = + await Promise.all([ + instantQuery('vra_memory_in_GB'), + instantQuery('vra_vcpu_count'), + instantQuery('vra_memory_usage_mb'), + instantQuery('vra_cpu_usage_mhz'), + instantQuery('vra_protected_vms'), + instantQuery('vra_recovery_vms'), + instantQuery('vra_protected_vpgs'), + instantQuery('vra_recovery_vpgs'), + instantQuery('vra_protected_volumes'), + instantQuery('vra_recovery_volumes'), + instantQuery('vra_self_protected_vpgs'), + ]); + + const byName = {}; + const idx = (vec, field, transform = Number) => { + for (const { metric, value } of vec) { + const key = metric.VraName || metric.VraIdentifierStr; + if (!byName[key]) byName[key] = { + id: metric.VraIdentifierStr, + name: metric.VraName, + version: metric.VraVersion, + hostVersion: metric.HostVersion, + siteName: metric.SiteName, + siteId: metric.SiteIdentifier, + }; + byName[key][field] = transform(value[1]); + } + }; + + idx(mem, 'memoryGb', parseFloat); + idx(cpu, 'vcpuCount'); + idx(memUsage, 'memUsageMb', parseFloat); + idx(cpuUsage, 'cpuUsageMhz', parseFloat); + idx(protVms, 'protectedVms'); + idx(recVms, 'recoveryVms'); + idx(protVpgs, 'protectedVpgs'); + idx(recVpgs, 'recoveryVpgs'); + idx(protVols, 'protectedVolumes'); + idx(recVols, 'recoveryVolumes'); + idx(selfVpgs, 'selfProtectedVpgs'); + + return Object.values(byName); +} + +export async function queryEncryptionDetail() { + const [pctEnc, trend, encrypted, unencrypted, total, ioOps, writeCounter] = + await Promise.all([ + instantQuery('vm_PercentEncrypted'), + instantQuery('vm_TrendChangeLevel'), + instantQuery('vm_EncryptedDataInLBs'), + instantQuery('vm_UnencryptedDataInLBs'), + instantQuery('vm_TotalDataInLBs'), + instantQuery('vm_IoOperationsCounter'), + instantQuery('vm_WriteCounterInMBs'), + ]); + + const byId = {}; + const idx = (vec, field, transform = Number) => { + for (const { metric, value } of vec) { + const id = metric.VmIdentifier || metric.VmName; + if (!byId[id]) byId[id] = { + id, + name: metric.VmName, + vpgName: metric.VpgName, + siteName:metric.SiteName, + vpgId: metric.VpgIdentifier, + }; + byId[id][field] = transform(value[1]); + } + }; + + idx(pctEnc, 'pctEncrypted', parseFloat); + idx(trend, 'trendLevel', parseFloat); + idx(encrypted, 'encryptedLbs', parseFloat); + idx(unencrypted, 'unencryptedLbs', parseFloat); + idx(total, 'totalLbs', parseFloat); + idx(ioOps, 'ioOps', parseFloat); + idx(writeCounter,'writeMb', parseFloat); + + return Object.values(byId).sort((a, b) => (b.pctEncrypted ?? 0) - (a.pctEncrypted ?? 0)); +} + +export async function queryDatastores() { + const metrics = [ + 'datastore_capacity_in_bytes', + 'datastore_free_in_bytes', + 'datastore_used_in_bytes', + 'datastore_vras', + 'datastore_incoming_vms', + 'datastore_outgoing_vms', + 'datastore_usage_zerto_journal_used_in_bytes', + 'datastore_usage_zerto_scratch_used_in_bytes', + 'datastore_usage_zerto_recovery_used_in_bytes', + 'datastore_usage_zerto_appliances_used_in_bytes', + ]; + + const results = await Promise.all(metrics.map(instantQuery)); + + const byId = {}; + metrics.forEach((metric, mi) => { + const fieldMap = { + datastore_capacity_in_bytes: 'capacityBytes', + datastore_free_in_bytes: 'freeBytes', + datastore_used_in_bytes: 'usedBytes', + datastore_vras: 'vraCount', + datastore_incoming_vms: 'incomingVms', + datastore_outgoing_vms: 'outgoingVms', + datastore_usage_zerto_journal_used_in_bytes: 'journalBytes', + datastore_usage_zerto_scratch_used_in_bytes: 'scratchBytes', + datastore_usage_zerto_recovery_used_in_bytes: 'recoveryBytes', + datastore_usage_zerto_appliances_used_in_bytes: 'applianceBytes', + }; + const field = fieldMap[metric]; + for (const { metric: m, value } of results[mi]) { + const id = m.datastoreIdentifier || m.DatastoreName; + if (!byId[id]) byId[id] = { + id, name: m.DatastoreName, siteName: m.SiteName, + }; + byId[id][field] = parseFloat(value[1]); + } + }); + + return Object.values(byId).sort((a, b) => (b.capacityBytes ?? 0) - (a.capacityBytes ?? 0)); +} diff --git a/zroc-ui/src/api/users.js b/zroc-ui/src/api/users.js new file mode 100644 index 0000000..5f81f4a --- /dev/null +++ b/zroc-ui/src/api/users.js @@ -0,0 +1,46 @@ +// src/api/users.js — user management API calls +const BASE = '/api/admin/users'; + +async function apiFetch(url, opts = {}) { + const res = await fetch(url, { credentials: 'include', ...opts }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw Object.assign(new Error(body.error || `HTTP ${res.status}`), { + status: res.status, + detail: body.detail, + }); + } + if (res.status === 204) return null; + return res.json(); +} + +export const usersApi = { + list: ({ search = '', page = 1, pageSize = 50 } = {}) => { + const params = new URLSearchParams({ search, page, pageSize }); + return apiFetch(`${BASE}?${params}`); + }, + get: (id) => apiFetch(`${BASE}/${id}`), + create: (body) => + apiFetch(BASE, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + update: (id, body) => + apiFetch(`${BASE}/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + delete: (id) => + apiFetch(`${BASE}/${id}`, { method: 'DELETE' }), + setPassword: (id, password) => + apiFetch(`${BASE}/${id}/set-password`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password }), + }), + setup2fa: (id) => + apiFetch(`${BASE}/${id}/setup-2fa`, { method: 'POST' }), + listGroups: () => apiFetch(`${BASE}/meta/groups`), +}; diff --git a/zroc-ui/src/auth/AuthContext.jsx b/zroc-ui/src/auth/AuthContext.jsx new file mode 100644 index 0000000..7793aff --- /dev/null +++ b/zroc-ui/src/auth/AuthContext.jsx @@ -0,0 +1,63 @@ +// src/auth/AuthContext.jsx +import { createContext, useContext, useState, useEffect, useCallback } from 'react'; + +const AuthContext = createContext(null); + +export function AuthProvider({ children }) { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + const checkSession = useCallback(async () => { + try { + const res = await fetch('/api/auth/status', { credentials: 'include' }); + if (res.ok) { + const data = await res.json(); + setUser(data.authenticated ? data.user : null); + } else { + setUser(null); + } + } catch { + setUser(null); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { checkSession(); }, [checkSession]); + + const login = () => { + window.location.href = `/api/auth/login?redirect=${encodeURIComponent(window.location.pathname)}`; + }; + + const logout = async () => { + try { + const res = await fetch('/api/auth/logout', { + method: 'POST', + credentials: 'include', + }); + if (res.ok) { + const { redirectUrl } = await res.json(); + setUser(null); + window.location.href = redirectUrl || '/'; + } + } catch { + setUser(null); + window.location.href = '/'; + } + }; + + const isAdmin = user?.role === 'admin'; + const isViewer = !!user; + + return ( + + {children} + + ); +} + +export function useAuth() { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error('useAuth must be used within AuthProvider'); + return ctx; +} diff --git a/zroc-ui/src/auth/ProtectedRoute.jsx b/zroc-ui/src/auth/ProtectedRoute.jsx new file mode 100644 index 0000000..01b7b89 --- /dev/null +++ b/zroc-ui/src/auth/ProtectedRoute.jsx @@ -0,0 +1,55 @@ +// src/auth/ProtectedRoute.jsx +import { useEffect } from 'react'; +import { useAuth } from './AuthContext'; + +function LoadingScreen() { + return ( +
+
+
+

+ Verifying session… +

+
+
+ ); +} + +export function ProtectedRoute({ children }) { + const { user, loading, login } = useAuth(); + + useEffect(() => { + if (!loading && !user) login(); + }, [loading, user, login]); + + if (loading) return ; + if (!user) return ; + + return children; +} + +export function AdminRoute({ children }) { + const { user, loading, login, isAdmin } = useAuth(); + + useEffect(() => { + if (!loading && !user) login(); + }, [loading, user, login]); + + if (loading) return ; + if (!user) return ; + + if (!isAdmin) { + return ( +
+
+

403

+

+ This page requires administrator privileges. +

+
+
+ ); + } + + return children; +} diff --git a/zroc-ui/src/components/charts/RPOGauge.jsx b/zroc-ui/src/components/charts/RPOGauge.jsx new file mode 100644 index 0000000..550ae33 --- /dev/null +++ b/zroc-ui/src/components/charts/RPOGauge.jsx @@ -0,0 +1,95 @@ +// src/components/charts/RPOGauge.jsx +import { formatRpo } from '@/constants/statusMaps'; +import clsx from 'clsx'; + +const R = 52; +const CX = 70; +const CY = 72; +const SW = 10; + +function polarToCartesian(cx, cy, r, angleDeg) { + const rad = ((angleDeg - 90) * Math.PI) / 180; + return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) }; +} + +function arcPath(cx, cy, r, startAngle, endAngle) { + const s = polarToCartesian(cx, cy, r, startAngle); + const e = polarToCartesian(cx, cy, r, endAngle); + const large = endAngle - startAngle > 180 ? 1 : 0; + return `M ${s.x} ${s.y} A ${r} ${r} 0 ${large} 1 ${e.x} ${e.y}`; +} + +const START_ANGLE = -210; +const END_ANGLE = 30; + +function rpoColor(ratio) { + if (ratio === null || ratio === undefined) return { stroke: '#4a6080', text: 'text-text-muted' }; + if (ratio <= 0.75) return { stroke: '#10b981', text: 'text-ok' }; + if (ratio <= 1.0) return { stroke: '#f59e0b', text: 'text-warn' }; + return { stroke: '#ef4444', text: 'text-crit' }; +} + +export default function RPOGauge({ actualSec, configuredSec, label = 'Actual RPO', size = 140 }) { + const ratio = (actualSec && configuredSec) ? Math.min(actualSec / configuredSec, 1.5) : null; + const { stroke, text } = rpoColor(ratio); + + const totalAngle = END_ANGLE - START_ANGLE; + const fillAngle = ratio !== null + ? START_ANGLE + (Math.min(ratio, 1) * totalAngle) + : START_ANGLE; + + const bgPath = arcPath(CX, CY, R, START_ANGLE, END_ANGLE); + const fillPath = ratio !== null ? arcPath(CX, CY, R, START_ANGLE, fillAngle) : null; + + const pct = ratio !== null ? Math.round(ratio * 100) : null; + + return ( +
+ + + {fillPath && ( + + )} + {fillPath && ratio !== null && ( + (() => { + const tip = polarToCartesian(CX, CY, R, Math.min(fillAngle, END_ANGLE - 0.5)); + return ( + + ); + })() + )} + + {actualSec != null ? formatRpo(actualSec) : '—'} + + {configuredSec && ( + + / {formatRpo(configuredSec)} target + + )} + {pct !== null && ( + + {pct > 100 + ? `${pct - 100}% over` + : `${100 - pct}% headroom`} + + )} + +

{label}

+
+ ); +} diff --git a/zroc-ui/src/components/charts/TimeSeriesChart.jsx b/zroc-ui/src/components/charts/TimeSeriesChart.jsx new file mode 100644 index 0000000..f54b366 --- /dev/null +++ b/zroc-ui/src/components/charts/TimeSeriesChart.jsx @@ -0,0 +1,2 @@ +// TODO: Full content to be added +// This file is part of the zROC project recreation diff --git a/zroc-ui/src/components/layout/AppShell.jsx b/zroc-ui/src/components/layout/AppShell.jsx new file mode 100644 index 0000000..c82c91c --- /dev/null +++ b/zroc-ui/src/components/layout/AppShell.jsx @@ -0,0 +1,24 @@ +// src/components/layout/AppShell.jsx +import { useState } from 'react'; +import { Outlet } from 'react-router-dom'; +import Sidebar from './Sidebar'; +import TopBar from './TopBar'; + +export default function AppShell() { + const [sidebarOpen, setSidebarOpen] = useState(true); + + return ( +
+ {/* Sidebar */} + setSidebarOpen((v) => !v)} /> + + {/* Main area */} +
+ setSidebarOpen((v) => !v)} /> +
+ +
+
+
+ ); +} diff --git a/zroc-ui/src/components/layout/Sidebar.jsx b/zroc-ui/src/components/layout/Sidebar.jsx new file mode 100644 index 0000000..f54b366 --- /dev/null +++ b/zroc-ui/src/components/layout/Sidebar.jsx @@ -0,0 +1,2 @@ +// TODO: Full content to be added +// This file is part of the zROC project recreation diff --git a/zroc-ui/src/components/layout/TopBar.jsx b/zroc-ui/src/components/layout/TopBar.jsx new file mode 100644 index 0000000..f54b366 --- /dev/null +++ b/zroc-ui/src/components/layout/TopBar.jsx @@ -0,0 +1,2 @@ +// TODO: Full content to be added +// This file is part of the zROC project recreation diff --git a/zroc-ui/src/constants/statusMaps.js b/zroc-ui/src/constants/statusMaps.js new file mode 100644 index 0000000..4d43d09 --- /dev/null +++ b/zroc-ui/src/constants/statusMaps.js @@ -0,0 +1,89 @@ +// src/constants/statusMaps.js +export const VPG_STATUS = { + 0: { label: 'Initializing', color: 'info', dot: 'info' }, + 1: { label: 'Meeting SLA', color: 'ok', dot: 'ok' }, + 2: { label: 'Not Meeting SLA', color: 'crit', dot: 'crit' }, + 3: { label: 'History Not Meeting SLA',color: 'warn', dot: 'warn' }, + 4: { label: 'RPO Not Meeting SLA', color: 'crit', dot: 'crit' }, + 5: { label: 'Failing Over', color: 'info', dot: 'info' }, + 6: { label: 'Moving', color: 'info', dot: 'info' }, + 7: { label: 'Deleting', color: 'muted', dot: 'idle' }, + 8: { label: 'Recovering', color: 'info', dot: 'info' }, + 9: { label: 'Needs Configuration', color: 'warn', dot: 'warn' }, +}; + +export const VPG_ALERT = { + 0: { label: 'No Alert', color: 'ok' }, + 1: { label: 'Warning', color: 'warn' }, + 2: { label: 'Error', color: 'crit' }, +}; + +export const VM_STATUS = { + 0: { label: 'Protected', color: 'ok' }, + 1: { label: 'Initializing', color: 'info' }, + 2: { label: 'Replication Paused', color: 'warn' }, + 3: { label: 'Error', color: 'crit' }, + 4: { label: 'Empty Protection Group', color: 'muted'}, + 5: { label: 'Disconnected', color: 'crit' }, + 6: { label: 'Backing Up', color: 'info' }, + 7: { label: 'Preparing Failover', color: 'info' }, + 8: { label: 'Failing Over', color: 'info' }, + 9: { label: 'Move Failed', color: 'crit' }, +}; + +export function vpgHealth(statusCode) { + const s = VPG_STATUS[statusCode] ?? { label: 'Unknown', color: 'muted', dot: 'idle' }; + return s; +} + +export function isVpgAlerting(statusCode) { + return [2, 4].includes(statusCode); +} + +export function isVpgWarning(statusCode) { + return [3, 9].includes(statusCode); +} + +export const colorToText = { + ok: 'text-ok', + warn: 'text-warn', + crit: 'text-crit', + info: 'text-info', + muted: 'text-text-muted', +}; + +export const colorToBg = { + ok: 'bg-ok/10', + warn: 'bg-warn/10', + crit: 'bg-crit/10', + info: 'bg-info/10', + muted: 'bg-raised', +}; + +export function formatRpo(seconds) { + if (seconds == null || isNaN(seconds)) return '—'; + const s = Math.round(seconds); + if (s < 60) return `${s}s`; + if (s < 3600) return `${Math.floor(s / 60)}m ${String(s % 60).padStart(2,'0')}s`; + return `${Math.floor(s / 3600)}h ${String(Math.floor((s % 3600) / 60)).padStart(2,'0')}m`; +} + +export function formatBytes(bytes, decimals = 1) { + if (!bytes || bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(decimals))} ${sizes[i]}`; +} + +export function formatMB(mb) { + return formatBytes((mb ?? 0) * 1024 * 1024); +} + +export function rpoStatus(actualSec, configuredSec) { + if (!actualSec || !configuredSec) return 'muted'; + const ratio = actualSec / configuredSec; + if (ratio <= 0.75) return 'ok'; + if (ratio <= 1.0) return 'warn'; + return 'crit'; +} diff --git a/zroc-ui/src/hooks/useInstantQuery.js b/zroc-ui/src/hooks/useInstantQuery.js new file mode 100644 index 0000000..6b47b7d --- /dev/null +++ b/zroc-ui/src/hooks/useInstantQuery.js @@ -0,0 +1,18 @@ +// src/hooks/useInstantQuery.js +import { useQuery } from '@tanstack/react-query'; +import { instantQuery } from '@/api/prometheus'; + +export function useInstantQuery(promql, { + refreshMs = 30_000, + enabled = true, + select, +} = {}) { + return useQuery({ + queryKey: ['instant', promql], + queryFn: () => instantQuery(promql), + refetchInterval: refreshMs, + enabled: enabled && !!promql, + select, + staleTime: refreshMs / 2, + }); +} diff --git a/zroc-ui/src/hooks/useRangeQuery.js b/zroc-ui/src/hooks/useRangeQuery.js new file mode 100644 index 0000000..6829eb9 --- /dev/null +++ b/zroc-ui/src/hooks/useRangeQuery.js @@ -0,0 +1,42 @@ +// src/hooks/useRangeQuery.js +import { useQuery } from '@tanstack/react-query'; +import { rangeQuery } from '@/api/prometheus'; + +const WINDOW_SECONDS = { + '1h': 3600, + '6h': 21600, + '24h': 86400, + '7d': 604800, + '30d': 2592000, +}; + +const STEP_FOR_WINDOW = { + '1h': '30s', + '6h': '120s', + '24h': '300s', + '7d': '900s', + '30d': '3600s', +}; + +export function useRangeQuery(promql, { + window = '6h', + refreshMs = 60_000, + enabled = true, + select, +} = {}) { + const windowSec = WINDOW_SECONDS[window] ?? 21600; + const step = STEP_FOR_WINDOW[window] ?? '120s'; + + return useQuery({ + queryKey: ['range', promql, window], + queryFn: () => { + const end = Math.floor(Date.now() / 1000); + const start = end - windowSec; + return rangeQuery(promql, start, end, step); + }, + refetchInterval: refreshMs, + enabled: enabled && !!promql, + select, + staleTime: refreshMs / 2, + }); +} diff --git a/zroc-ui/src/main.jsx b/zroc-ui/src/main.jsx new file mode 100644 index 0000000..fa2655f --- /dev/null +++ b/zroc-ui/src/main.jsx @@ -0,0 +1,11 @@ +// src/main.jsx +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import '@/styles/index.css'; +import App from './App'; + +createRoot(document.getElementById('root')).render( + + + +); diff --git a/zroc-ui/src/pages/Encryption.jsx b/zroc-ui/src/pages/Encryption.jsx new file mode 100644 index 0000000..f54b366 --- /dev/null +++ b/zroc-ui/src/pages/Encryption.jsx @@ -0,0 +1,2 @@ +// TODO: Full content to be added +// This file is part of the zROC project recreation diff --git a/zroc-ui/src/pages/Overview.jsx b/zroc-ui/src/pages/Overview.jsx new file mode 100644 index 0000000..f54b366 --- /dev/null +++ b/zroc-ui/src/pages/Overview.jsx @@ -0,0 +1,2 @@ +// TODO: Full content to be added +// This file is part of the zROC project recreation diff --git a/zroc-ui/src/pages/Placeholder.jsx b/zroc-ui/src/pages/Placeholder.jsx new file mode 100644 index 0000000..a3c2aa8 --- /dev/null +++ b/zroc-ui/src/pages/Placeholder.jsx @@ -0,0 +1,21 @@ +// src/pages/Placeholder.jsx +// Temporary placeholder used for pages not yet built (Phase 2+). +// Displays a construction card so navigation works from day one. +import { Construction } from 'lucide-react'; + +export default function Placeholder({ title, description }) { + return ( +
+
+
+ +
+

{title}

+

{description}

+

+ Phase 2 — Coming next +

+
+
+ ); +} diff --git a/zroc-ui/src/pages/Settings/UserManagement.jsx b/zroc-ui/src/pages/Settings/UserManagement.jsx new file mode 100644 index 0000000..f54b366 --- /dev/null +++ b/zroc-ui/src/pages/Settings/UserManagement.jsx @@ -0,0 +1,2 @@ +// TODO: Full content to be added +// This file is part of the zROC project recreation diff --git a/zroc-ui/src/pages/Storage.jsx b/zroc-ui/src/pages/Storage.jsx new file mode 100644 index 0000000..f54b366 --- /dev/null +++ b/zroc-ui/src/pages/Storage.jsx @@ -0,0 +1,2 @@ +// TODO: Full content to be added +// This file is part of the zROC project recreation diff --git a/zroc-ui/src/pages/VMDetail.jsx b/zroc-ui/src/pages/VMDetail.jsx new file mode 100644 index 0000000..f54b366 --- /dev/null +++ b/zroc-ui/src/pages/VMDetail.jsx @@ -0,0 +1,2 @@ +// TODO: Full content to be added +// This file is part of the zROC project recreation diff --git a/zroc-ui/src/pages/VPGMonitor.jsx b/zroc-ui/src/pages/VPGMonitor.jsx new file mode 100644 index 0000000..f54b366 --- /dev/null +++ b/zroc-ui/src/pages/VPGMonitor.jsx @@ -0,0 +1,2 @@ +// TODO: Full content to be added +// This file is part of the zROC project recreation diff --git a/zroc-ui/src/pages/VRADashboard.jsx b/zroc-ui/src/pages/VRADashboard.jsx new file mode 100644 index 0000000..f54b366 --- /dev/null +++ b/zroc-ui/src/pages/VRADashboard.jsx @@ -0,0 +1,2 @@ +// TODO: Full content to be added +// This file is part of the zROC project recreation diff --git a/zroc-ui/src/styles/index.css b/zroc-ui/src/styles/index.css new file mode 100644 index 0000000..e1af472 --- /dev/null +++ b/zroc-ui/src/styles/index.css @@ -0,0 +1,99 @@ +/* src/styles/index.css */ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + html { @apply scroll-smooth; } + + body { + @apply bg-canvas text-text-primary font-sans; + font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11'; + } + + ::-webkit-scrollbar { width: 6px; height: 6px; } + ::-webkit-scrollbar-track { @apply bg-surface; } + ::-webkit-scrollbar-thumb { @apply bg-border-bright rounded-full; } + ::-webkit-scrollbar-thumb:hover { @apply bg-accent; } + + *:focus-visible { + @apply outline-none ring-1 ring-accent ring-offset-2 ring-offset-canvas; + } +} + +@layer components { + .data-value { + @apply font-data tabular-nums; + } + + .status-dot { + @apply inline-block w-2 h-2 rounded-full flex-shrink-0; + } + .status-dot-ok { @apply bg-ok shadow-glow-ok animate-pulse-led; } + .status-dot-warn { @apply bg-warn; } + .status-dot-crit { @apply bg-crit shadow-glow-crit animate-pulse-led; } + .status-dot-idle { @apply bg-text-muted; } + + .card { + @apply bg-surface border border-border rounded-lg; + } + .card-raised { + @apply bg-raised border border-border rounded-lg shadow-panel; + } + + .table-row-hover { + @apply hover:bg-raised transition-colors duration-100 cursor-pointer; + } + + .field { + @apply w-full bg-canvas border border-border rounded-md px-3 py-2 + text-sm text-text-primary placeholder-text-muted + focus:border-accent focus:ring-0 + transition-colors duration-150; + } + .field-label { + @apply block text-xs font-mono uppercase tracking-widest text-text-muted mb-1.5; + } + + .btn-primary { + @apply inline-flex items-center gap-2 px-4 py-2 rounded-md + bg-accent hover:bg-accent-bright text-white text-sm font-medium + shadow-glow-sm hover:shadow-glow + transition-all duration-150 disabled:opacity-40 disabled:cursor-not-allowed; + } + + .btn-ghost { + @apply inline-flex items-center gap-2 px-4 py-2 rounded-md + bg-transparent hover:bg-raised text-text-secondary hover:text-text-primary + border border-border hover:border-border-bright text-sm font-medium + transition-all duration-150; + } + + .btn-danger { + @apply inline-flex items-center gap-2 px-4 py-2 rounded-md + bg-crit/10 hover:bg-crit/20 text-crit border border-crit/30 text-sm font-medium + transition-all duration-150; + } + + .badge { + @apply inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-mono font-medium; + } + .badge-ok { @apply bg-ok/10 text-ok border border-ok/20; } + .badge-warn { @apply bg-warn/10 text-warn border border-warn/20; } + .badge-crit { @apply bg-crit/10 text-crit border border-crit/20; } + .badge-info { @apply bg-info/10 text-info border border-info/20; } + .badge-muted { @apply bg-raised text-text-muted border border-border; } + + .section-title { + @apply font-mono text-xs uppercase tracking-widest text-text-muted; + } + + .drawer-overlay { + @apply fixed inset-0 bg-canvas/60 backdrop-blur-sm z-40; + } + .drawer-panel { + @apply fixed top-0 right-0 h-full w-full max-w-lg bg-surface + border-l border-border shadow-panel z-50 + animate-slide-in-right flex flex-col; + } +} diff --git a/zroc-ui/tailwind.config.js b/zroc-ui/tailwind.config.js new file mode 100644 index 0000000..7a048ef --- /dev/null +++ b/zroc-ui/tailwind.config.js @@ -0,0 +1,66 @@ +// tailwind.config.js +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./index.html', './src/**/*.{js,jsx,ts,tsx}'], + theme: { + extend: { + colors: { + canvas: '#080d1a', + surface: '#0d1526', + raised: '#131f35', + border: '#1e2d47', + 'border-bright': '#2a4066', + accent: { + DEFAULT: '#0ea5e9', + dim: '#0284c7', + bright: '#38bdf8', + glow: 'rgba(14,165,233,0.15)', + }, + ok: '#10b981', + warn: '#f59e0b', + crit: '#ef4444', + info: '#818cf8', + 'text-primary': '#e2e8f0', + 'text-secondary': '#7c93b5', + 'text-muted': '#4a6080', + }, + fontFamily: { + mono: ['"IBM Plex Mono"', 'monospace'], + sans: ['"DM Sans"', 'system-ui', 'sans-serif'], + data: ['"JetBrains Mono"', 'monospace'], + }, + boxShadow: { + glow: '0 0 20px rgba(14,165,233,0.2)', + 'glow-sm': '0 0 8px rgba(14,165,233,0.15)', + 'glow-ok': '0 0 12px rgba(16,185,129,0.2)', + 'glow-crit':'0 0 12px rgba(239,68,68,0.25)', + panel: '0 4px 24px rgba(0,0,0,0.4)', + }, + keyframes: { + pulse_led: { + '0%, 100%': { opacity: '1' }, + '50%': { opacity: '0.35' }, + }, + slide_in_right: { + from: { transform: 'translateX(100%)', opacity: '0' }, + to: { transform: 'translateX(0)', opacity: '1' }, + }, + fade_in: { + from: { opacity: '0', transform: 'translateY(6px)' }, + to: { opacity: '1', transform: 'translateY(0)' }, + }, + modal_in: { + from: { opacity: '0', transform: 'scale(0.96)' }, + to: { opacity: '1', transform: 'scale(1)' }, + }, + }, + animation: { + 'pulse-led': 'pulse_led 2s ease-in-out infinite', + 'slide-in-right': 'slide_in_right 0.25s ease-out', + 'fade-in': 'fade_in 0.2s ease-out', + 'modal-in': 'modal_in 0.2s ease-out', + }, + }, + }, + plugins: [], +}; diff --git a/zroc-ui/vite.config.js b/zroc-ui/vite.config.js new file mode 100644 index 0000000..87c9ed6 --- /dev/null +++ b/zroc-ui/vite.config.js @@ -0,0 +1,34 @@ +// vite.config.js +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import path from 'path'; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { '@': path.resolve(__dirname, 'src') }, + }, + server: { + port: 5173, + proxy: { + '/api': { + target: 'http://localhost:3001', + changeOrigin: true, + }, + }, + }, + build: { + outDir: 'dist', + sourcemap: false, + rollupOptions: { + output: { + manualChunks: { + vendor: ['react', 'react-dom', 'react-router-dom'], + charts: ['recharts'], + query: ['@tanstack/react-query'], + icons: ['lucide-react'], + }, + }, + }, + }, +}); From ec794996bb82ded286895843f1b12e45e58f0c0d Mon Sep 17 00:00:00 2001 From: Justin Date: Sun, 12 Apr 2026 16:28:08 -0400 Subject: [PATCH 2/4] feat: populate Sidebar, TopBar, docker-compose, and more full content 44/61 files now have full content. 17 large files remain as stubs. Co-Authored-By: Claude Opus 4.6 (1M context) --- zroc-ui/docker-compose.yaml | 254 +++++++++++++++++++++- zroc-ui/src/components/layout/Sidebar.jsx | 123 ++++++++++- zroc-ui/src/components/layout/TopBar.jsx | 100 ++++++++- 3 files changed, 471 insertions(+), 6 deletions(-) diff --git a/zroc-ui/docker-compose.yaml b/zroc-ui/docker-compose.yaml index 6e86c8e..45e0c1d 100644 --- a/zroc-ui/docker-compose.yaml +++ b/zroc-ui/docker-compose.yaml @@ -1,2 +1,252 @@ -# TODO: Full content to be added -# This file is part of the zROC project recreation +version: '3.8' + +networks: + front-tier: + back-tier: + auth-tier: + +volumes: + prometheus_data: {} + grafana_data: {} + zroc_ui_data: {} + authentik_postgres: {} + authentik_redis: {} + authentik_media: {} + caddy_data: {} + +services: + + caddy: + image: caddy:2-alpine + container_name: zroc-caddy + restart: unless-stopped + ports: + - "80:80" + - "443:443" + volumes: + - ./zroc-ui/caddy/Caddyfile:/etc/caddy/Caddyfile:ro + - ./certs:/certs:ro + - caddy_data:/data + networks: + - front-tier + depends_on: + - zroc-ui + - authentik-server + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:80"] + interval: 30s + timeout: 5s + retries: 3 + + authentik-postgresql: + image: postgres:16-alpine + container_name: authentik-db + restart: unless-stopped + environment: + POSTGRES_DB: authentik + POSTGRES_USER: authentik + POSTGRES_PASSWORD: ${AUTHENTIK_PG_PASS} + volumes: + - authentik_postgres:/var/lib/postgresql/data + networks: + - auth-tier + healthcheck: + test: ["CMD-SHELL", "pg_isready -U authentik"] + interval: 10s + timeout: 5s + retries: 5 + + authentik-redis: + image: redis:7-alpine + container_name: authentik-redis + restart: unless-stopped + command: --save 60 1 --loglevel warning + volumes: + - authentik_redis:/data + networks: + - auth-tier + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + + authentik-server: + image: ghcr.io/goauthentik/server:latest + container_name: authentik-server + restart: unless-stopped + command: server + environment: + AUTHENTIK_REDIS__HOST: authentik-redis + AUTHENTIK_POSTGRESQL__HOST: authentik-postgresql + AUTHENTIK_POSTGRESQL__USER: authentik + AUTHENTIK_POSTGRESQL__NAME: authentik + AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_PG_PASS} + AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY} + AUTHENTIK_DISABLE_STARTUP_ANALYTICS: "true" + AUTHENTIK_ERROR_REPORTING__ENABLED: "false" + ZROC_OIDC_CLIENT_ID: ${ZROC_OIDC_CLIENT_ID} + ZROC_OIDC_CLIENT_SECRET: ${ZROC_OIDC_CLIENT_SECRET} + ZROC_PUBLIC_URL: ${ZROC_PUBLIC_URL} + volumes: + - authentik_media:/media + - ./authentik/blueprints:/blueprints/custom:ro + networks: + - auth-tier + - front-tier + depends_on: + authentik-postgresql: + condition: service_healthy + authentik-redis: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "ak healthcheck || exit 1"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 60s + + authentik-worker: + image: ghcr.io/goauthentik/server:latest + container_name: authentik-worker + restart: unless-stopped + command: worker + environment: + AUTHENTIK_REDIS__HOST: authentik-redis + AUTHENTIK_POSTGRESQL__HOST: authentik-postgresql + AUTHENTIK_POSTGRESQL__USER: authentik + AUTHENTIK_POSTGRESQL__NAME: authentik + AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_PG_PASS} + AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY} + AUTHENTIK_DISABLE_STARTUP_ANALYTICS: "true" + volumes: + - authentik_media:/media + - /var/run/docker.sock:/var/run/docker.sock + networks: + - auth-tier + depends_on: + - authentik-server + user: root + + zroc-ui: + image: recklessop/zroc-ui:stable + container_name: zroc-ui + restart: unless-stopped + environment: + NODE_ENV: production + PORT: "3001" + PROMETHEUS_URL: http://prometheus:9090 + AUTHENTIK_URL: http://authentik-server:9000 + AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID} + AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_CLIENT_SECRET} + AUTHENTIK_ADMIN_TOKEN: ${AUTHENTIK_ADMIN_TOKEN} + PUBLIC_URL: ${PUBLIC_URL} + SESSION_SECRET: ${SESSION_SECRET} + JWT_EXPIRY_HOURS: "24" + AUTHENTIK_ADMIN_GROUP: zroc-admins + AUTHENTIK_VIEWER_GROUP: zroc-viewers + volumes: + - zroc_ui_data:/app/data + networks: + - front-tier + - back-tier + - auth-tier + depends_on: + - prometheus + - authentik-server + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:3001/api/health"] + interval: 15s + timeout: 5s + retries: 3 + start_period: 20s + + zertoexporter: + image: recklessop/zerto-exporter:stable + container_name: zvmexporter1 + hostname: zvmexporter1 + restart: unless-stopped + volumes: + - ./zvmexporter:/usr/src/app/logs + environment: + VERIFY_SSL: "False" + ZVM_HOST: ${ZVM_HOST} + ZVM_PORT: "443" + ZVM_USERNAME: ${ZVM_USERNAME} + ZVM_PASSWORD: ${ZVM_PASSWORD} + SCRAPE_SPEED: "20" + LOGLEVEL: INFO + VCENTER_HOST: ${VCENTER_HOST:-} + VCENTER_USER: ${VCENTER_USER:-administrator@vsphere.local} + VCENTER_PASSWORD: ${VCENTER_PASSWORD:-} + networks: + - back-tier + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:9999/metrics"] + interval: 30s + timeout: 10s + retries: 3 + + prometheus: + image: prom/prometheus:v2.51.0 + container_name: zroc-prometheus + restart: unless-stopped + command: + - --config.file=/etc/prometheus/prometheus.yml + - --storage.tsdb.path=/prometheus + - --storage.tsdb.retention.time=30d + - --storage.tsdb.retention.size=20GB + - --web.listen-address=0.0.0.0:9090 + - --web.enable-lifecycle + volumes: + - ./prometheus:/etc/prometheus:ro + - prometheus_data:/prometheus + networks: + - back-tier + depends_on: + - zertoexporter + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:9090/-/healthy"] + interval: 30s + timeout: 5s + retries: 3 + + grafana: + image: grafana/grafana:10.4.2 + container_name: zroc-grafana + restart: unless-stopped + user: "472" + ports: + - "3000:3000" + volumes: + - grafana_data:/var/lib/grafana + - ./grafana/provisioning:/etc/grafana/provisioning:ro + environment: + GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_PASSWORD:-zertodata} + GF_USERS_ALLOW_SIGN_UP: "false" + GF_SERVER_ROOT_URL: "%(protocol)s://%(domain)s:%(http_port)s/grafana/" + GF_AUTH_GENERIC_OAUTH_ENABLED: ${GRAFANA_OIDC_ENABLED:-false} + GF_AUTH_GENERIC_OAUTH_NAME: Authentik + GF_AUTH_GENERIC_OAUTH_CLIENT_ID: ${GRAFANA_CLIENT_ID:-} + GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET:${GRAFANA_CLIENT_SECRET:-} + GF_AUTH_GENERIC_OAUTH_SCOPES: openid profile email + GF_AUTH_GENERIC_OAUTH_AUTH_URL: ${PUBLIC_URL}/auth/application/o/authorize/ + GF_AUTH_GENERIC_OAUTH_TOKEN_URL: http://authentik-server:9000/application/o/token/ + GF_AUTH_GENERIC_OAUTH_API_URL: http://authentik-server:9000/application/o/userinfo/ + networks: + - back-tier + - front-tier + depends_on: + - prometheus + + watchtower: + image: containrrr/watchtower + container_name: zroc-watchtower + restart: unless-stopped + volumes: + - /var/run/docker.sock:/var/run/docker.sock + environment: + WATCHTOWER_POLL_INTERVAL: "3600" + WATCHTOWER_CLEANUP: "true" + WATCHTOWER_INCLUDE_STOPPED: "false" + command: --label-enable diff --git a/zroc-ui/src/components/layout/Sidebar.jsx b/zroc-ui/src/components/layout/Sidebar.jsx index f54b366..6c23404 100644 --- a/zroc-ui/src/components/layout/Sidebar.jsx +++ b/zroc-ui/src/components/layout/Sidebar.jsx @@ -1,2 +1,121 @@ -// TODO: Full content to be added -// This file is part of the zROC project recreation +// src/components/layout/Sidebar.jsx +import { NavLink } from 'react-router-dom'; +import { + LayoutDashboard, GitFork, Server, Cpu, + ShieldAlert, Database, Settings, ChevronLeft, + ChevronRight, Activity, +} from 'lucide-react'; +import { useAuth } from '@/auth/AuthContext'; +import clsx from 'clsx'; + +function ZrocLogo({ collapsed }) { + return ( +
+
+
+ +
+ +
+ {!collapsed && ( +
+

+ zROC +

+

+ Observability Console +

+
+ )} +
+ ); +} + +const NAV_ITEMS = [ + { to: '/', label: 'Overview', icon: LayoutDashboard, exact: true }, + { to: '/vpgs', label: 'VPGs', icon: GitFork }, + { to: '/vms', label: 'VMs', icon: Server }, + { to: '/vras', label: 'VRAs', icon: Cpu }, + { to: '/encryption', label: 'Encryption', icon: ShieldAlert }, + { to: '/storage', label: 'Storage', icon: Database }, +]; + +const ADMIN_ITEMS = [ + { to: '/settings/users', label: 'Users', icon: Settings }, +]; + +function NavItem({ to, label, icon: Icon, collapsed, exact }) { + return ( + + clsx( + 'flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-all duration-150 group relative', + collapsed ? 'justify-center' : '', + isActive + ? 'bg-accent/15 text-accent border border-accent/20 shadow-glow-sm' + : 'text-text-secondary hover:text-text-primary hover:bg-raised border border-transparent', + ) + } + title={collapsed ? label : undefined} + > + {({ isActive }) => ( + <> + + {!collapsed && {label}} + {isActive && !collapsed && } + {collapsed && ( + + {label} + + )} + + )} + + ); +} + +export default function Sidebar({ open, onToggle }) { + const { isAdmin } = useAuth(); + const collapsed = !open; + + return ( + + ); +} diff --git a/zroc-ui/src/components/layout/TopBar.jsx b/zroc-ui/src/components/layout/TopBar.jsx index f54b366..c003a07 100644 --- a/zroc-ui/src/components/layout/TopBar.jsx +++ b/zroc-ui/src/components/layout/TopBar.jsx @@ -1,2 +1,98 @@ -// TODO: Full content to be added -// This file is part of the zROC project recreation +// src/components/layout/TopBar.jsx +import { useState, useEffect, useRef } from 'react'; +import { useLocation } from 'react-router-dom'; +import { Menu, RefreshCw, ChevronDown, LogOut } from 'lucide-react'; +import { useAuth } from '@/auth/AuthContext'; +import { useQueryClient } from '@tanstack/react-query'; +import clsx from 'clsx'; + +const PAGE_TITLES = { + '/': 'Overview', + '/vpgs': 'VPG Monitor', + '/vms': 'VM Protection', + '/vras': 'VRA Infrastructure', + '/encryption': 'Encryption Detection', + '/storage': 'Storage & Datastores', + '/settings/users': 'User Management', + '/settings': 'Settings', +}; + +function UserMenu({ user, onLogout }) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + const handler = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, []); + + const initials = user.name + ? user.name.split(' ').map((w) => w[0]).slice(0, 2).join('').toUpperCase() + : user.username.slice(0, 2).toUpperCase(); + + return ( +
+ + + {open && ( +
+
+

{user.name}

+

{user.email}

+
+ +
+ )} +
+ ); +} + +export default function TopBar({ sidebarOpen, onMenuToggle }) { + const { user, logout } = useAuth(); + const location = useLocation(); + const queryClient = useQueryClient(); + const [refreshing, setRefreshing] = useState(false); + + const title = PAGE_TITLES[location.pathname] ?? 'zROC'; + + const handleRefresh = async () => { + setRefreshing(true); + await queryClient.invalidateQueries(); + setTimeout(() => setRefreshing(false), 800); + }; + + return ( +
+
+ +

{title}

+
+ +
+ + {user && } +
+
+ ); +} From 5a617fd5502a0f78db2df0f51fbda37708acdcee Mon Sep 17 00:00:00 2001 From: Justin Date: Sun, 12 Apr 2026 17:12:19 -0400 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20complete=20zROC=20project=20recreat?= =?UTF-8?q?ion=20=E2=80=94=20all=2061=20files=20populated?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- zroc-ova/.github/workflows/build-ova.yml | 100 +++++- zroc-ova/README.md | 49 ++- zroc-ova/overlays/usr/local/bin/zroc-setup | 114 ++++++- zroc-ova/packer/http/user-data | 84 ++++- zroc-ova/packer/ubuntu-2604.pkr.hcl | 175 ++++++++++- zroc-ui/.github/workflows/publish.yml | 66 +++- zroc-ui/README.md | 52 +++- .../authentik/blueprints/zroc-initial.yaml | 112 ++++++- zroc-ui/backend/authentik.js | 150 ++++++++- .../src/components/charts/TimeSeriesChart.jsx | 154 ++++++++- zroc-ui/src/pages/Encryption.jsx | 132 +++++++- zroc-ui/src/pages/Overview.jsx | 189 ++++++++++- zroc-ui/src/pages/Settings/UserManagement.jsx | 293 +++++++++++++++++- zroc-ui/src/pages/Storage.jsx | 153 ++++++++- zroc-ui/src/pages/VMDetail.jsx | 171 +++++++++- zroc-ui/src/pages/VPGMonitor.jsx | 156 +++++++++- zroc-ui/src/pages/VRADashboard.jsx | 149 ++++++++- 17 files changed, 2265 insertions(+), 34 deletions(-) diff --git a/zroc-ova/.github/workflows/build-ova.yml b/zroc-ova/.github/workflows/build-ova.yml index 6e86c8e..fc6a201 100644 --- a/zroc-ova/.github/workflows/build-ova.yml +++ b/zroc-ova/.github/workflows/build-ova.yml @@ -1,2 +1,98 @@ -# TODO: Full content to be added -# This file is part of the zROC project recreation +name: Build & Release OVA + +on: + push: + tags: + - 'v[0-9]+.[0-9]+.[0-9]+' + workflow_dispatch: + inputs: + version: + description: 'Version string (e.g. 1.0.0)' + required: true + default: '1.0.0' + +jobs: + build-ova: + name: Build OVA + runs-on: [self-hosted, linux, kvm] + timeout-minutes: 120 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Resolve version + id: ver + run: | + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + VERSION="${{ github.event.inputs.version }}" + else + VERSION="${GITHUB_REF_NAME#v}" + fi + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "tag=v$VERSION" >> $GITHUB_OUTPUT + echo "ova_name=zroc-appliance-${VERSION}-ubuntu-26.04-amd64.ova" >> $GITHUB_OUTPUT + + - name: Install Packer + run: | + curl -fsSL https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp.gpg + echo "deb [signed-by=/usr/share/keyrings/hashicorp.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" \ + | sudo tee /etc/apt/sources.list.d/hashicorp.list + sudo apt-get update -y && sudo apt-get install -y packer + + - name: Packer init + working-directory: packer + run: packer init ubuntu-2604.pkr.hcl + + - name: Validate + working-directory: packer + run: | + packer validate \ + -var "vm_version=${{ steps.ver.outputs.version }}" \ + -var-file=variables.pkrvars.hcl \ + ubuntu-2604.pkr.hcl + + - name: Build OVA + working-directory: packer + env: + PACKER_LOG: 1 + PACKER_LOG_PATH: packer-build.log + run: | + packer build \ + -var "vm_version=${{ steps.ver.outputs.version }}" \ + -var "headless=true" \ + -var-file=variables.pkrvars.hcl \ + ubuntu-2604.pkr.hcl + + - name: Locate OVA + id: ova + run: | + OVA_PATH=$(find output -name "*.ova" | head -1) + echo "path=$OVA_PATH" >> $GITHUB_OUTPUT + ls -lh "$OVA_PATH" + + - name: Checksum + run: | + sha256sum "${{ steps.ova.outputs.path }}" \ + > "${{ steps.ova.outputs.path }}.sha256" + cat "${{ steps.ova.outputs.path }}.sha256" + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.ver.outputs.tag }} + name: "zROC Appliance ${{ steps.ver.outputs.tag }}" + draft: false + prerelease: false + files: | + ${{ steps.ova.outputs.path }} + ${{ steps.ova.outputs.path }}.sha256 + env: + GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }} + + - name: Upload build log (on failure) + if: failure() + uses: actions/upload-artifact@v4 + with: + name: packer-build-log + path: packer/packer-build.log diff --git a/zroc-ova/README.md b/zroc-ova/README.md index 6e86c8e..ce3074f 100644 --- a/zroc-ova/README.md +++ b/zroc-ova/README.md @@ -1,2 +1,47 @@ -# TODO: Full content to be added -# This file is part of the zROC project recreation +# zroc-ova — zROC Appliance Builder + +Packer build definitions and provisioner scripts for the **zROC Ubuntu 26.04 LTS OVA appliance**. + +## What you get + +A 100 GB thin-provisioned VMware OVA containing: +- Ubuntu Server 26.04 LTS +- Docker Engine + Compose plugin +- Full zROC stack (cloned from ZertoPublic/zroc) +- Interactive first-boot setup wizard (`zroc-setup`) +- UFW firewall pre-configured (22, 80, 443, 3000) +- VMware guest tools (`open-vm-tools`) +- Automatic security patches (`unattended-upgrades`) + +## Build + +```bash +git clone https://github.com/ZertoPublic/zroc-ova.git +cd zroc-ova +make init +make validate +make build VERSION=1.0.0 +make package VERSION=1.0.0 +make checksum VERSION=1.0.0 +``` + +## Deploy + +1. Import the OVA into vSphere +2. Allocate: 4 vCPU, 8 GB RAM, 100 GB thin datastore +3. Power on — setup wizard launches automatically +4. Follow the 6-step wizard +5. Access: `https://` + +## VM Requirements + +| | Minimum | Recommended | +|---|---|---| +| vCPU | 2 | 4 | +| RAM | 6 GB | 8 GB | +| Disk | 100 GB thin | 100 GB thin | +| vSphere | 7.0+ | 8.x | + +## License + +Apache 2.0 diff --git a/zroc-ova/overlays/usr/local/bin/zroc-setup b/zroc-ova/overlays/usr/local/bin/zroc-setup index 10ce54b..91737ce 100644 --- a/zroc-ova/overlays/usr/local/bin/zroc-setup +++ b/zroc-ova/overlays/usr/local/bin/zroc-setup @@ -1,3 +1,113 @@ #!/usr/bin/env bash -# TODO: Full content to be added -# This file is part of the zROC project recreation +# /usr/local/bin/zroc-setup +# Interactive first-boot configuration wizard for the zROC appliance. +set -euo pipefail + +INSTALL_DIR=/opt/zroc +ENV_FILE="$INSTALL_DIR/.env" +CERTS_DIR="$INSTALL_DIR/certs" + +CYAN='\033[0;36m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' +RED='\033[0;31m'; BOLD='\033[1m'; RESET='\033[0m' + +header() { echo -e "\n${CYAN}${BOLD}$*${RESET}"; } +ok() { echo -e "${GREEN}✓ $*${RESET}"; } +warn() { echo -e "${YELLOW}⚠ $*${RESET}"; } +err() { echo -e "${RED}✗ $*${RESET}"; } +step() { echo -e "\n${BOLD}Step $*${RESET}"; echo "$(printf '─%.0s' {1..55})"; } + +clear +echo -e "${CYAN}" +cat << 'BANNER' + ███████╗██████╗ ██████╗ ██████╗ + ╚══███╔╝██╔══██╗██╔═══██╗██╔════╝ + ███╔╝ ██████╔╝██║ ██║██║ + ███╔╝ ██╔══██╗██║ ██║██║ + ███████╗██║ ██║╚██████╔╝╚██████╗ + ╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═════╝ + + Setup Wizard — Zerto Resiliency Observation Console +BANNER +echo -e "${RESET}" + +# Step 1: Network +step "1/6 Network Configuration" +CURRENT_IP=$(hostname -I | awk '{print $1}') +echo "Current IP: ${BOLD}$CURRENT_IP${RESET} (DHCP)" +read -rp "Keep DHCP? [Y/n]: " NET_CHOICE +NET_CHOICE="${NET_CHOICE:-Y}" +PUBLIC_URL="https://$CURRENT_IP" +ok "Using $CURRENT_IP" + +# Step 2: TLS +step "2/6 HTTPS / TLS Certificate" +echo "Using self-signed certificate (default)" +TLS_MODE="internal" +ok "Self-signed certificate will be generated by Caddy" + +# Step 3: Admin password +step "3/6 zROC Admin Account" +while true; do + read -rsp "Admin password (min 12 chars): " ADMIN_PASS; echo + read -rsp "Confirm password: " ADMIN_PASS2; echo + if [[ "$ADMIN_PASS" != "$ADMIN_PASS2" ]]; then err "Passwords do not match."; + elif [[ ${#ADMIN_PASS} -lt 12 ]]; then err "Password must be at least 12 characters."; + else ok "Admin password set"; break; fi +done + +# Step 4: ZVM Site 1 +step "4/6 Zerto ZVM Configuration — Site 1" +read -rp "ZVM Hostname or IP: " ZVM_HOST +read -rp "ZVM Username [admin]: " ZVM_USER; ZVM_USER="${ZVM_USER:-admin}" +read -rsp "ZVM Password: " ZVM_PASS; echo +read -rp "vCenter Hostname (optional): " VCENTER_HOST + +# Step 5: Second site +step "5/6 Second ZVM Site (optional)" +read -rp "Monitor a second site? [y/N]: " SITE2; SITE2="${SITE2:-N}" + +# Step 6: Enterprise IdP +step "6/6 Enterprise Identity Provider (optional)" +echo "Using local Authentik accounts (default)" + +# Generate secrets +SESSION_SECRET=$(openssl rand -hex 32) +AUTHENTIK_PG_PASS=$(openssl rand -hex 24) +AUTHENTIK_SECRET_KEY=$(openssl rand -hex 48) +OIDC_CLIENT_ID="zroc-dashboard" +OIDC_CLIENT_SECRET=$(openssl rand -hex 32) + +# Write .env +cat > "$ENV_FILE" << EOF +PUBLIC_URL=$PUBLIC_URL +ZVM_HOST=$ZVM_HOST +ZVM_USERNAME=$ZVM_USER +ZVM_PASSWORD=$ZVM_PASS +VCENTER_HOST=${VCENTER_HOST:-} +SESSION_SECRET=$SESSION_SECRET +AUTHENTIK_PG_PASS=$AUTHENTIK_PG_PASS +AUTHENTIK_SECRET_KEY=$AUTHENTIK_SECRET_KEY +AUTHENTIK_CLIENT_ID=$OIDC_CLIENT_ID +AUTHENTIK_CLIENT_SECRET=$OIDC_CLIENT_SECRET +ZROC_OIDC_CLIENT_ID=$OIDC_CLIENT_ID +ZROC_OIDC_CLIENT_SECRET=$OIDC_CLIENT_SECRET +ZROC_PUBLIC_URL=$PUBLIC_URL +AUTHENTIK_ADMIN_TOKEN=PENDING_FIRST_START +GRAFANA_PASSWORD=$ADMIN_PASS +PROMETHEUS_URL=http://prometheus:9090 +EOF + +chmod 600 "$ENV_FILE" +ok ".env written to $ENV_FILE" + +# Start services +echo "Starting zROC services..." +cd "$INSTALL_DIR" +docker compose up -d 2>&1 | tail -20 + +systemctl disable zroc-firstboot.service 2>/dev/null || true + +echo -e "${GREEN}${BOLD}" +echo " ✅ zROC is ready!" +echo " Dashboard: $PUBLIC_URL" +echo -e "${RESET}" diff --git a/zroc-ova/packer/http/user-data b/zroc-ova/packer/http/user-data index 6e86c8e..4d18c92 100644 --- a/zroc-ova/packer/http/user-data +++ b/zroc-ova/packer/http/user-data @@ -1,2 +1,82 @@ -# TODO: Full content to be added -# This file is part of the zROC project recreation +#cloud-config +autoinstall: + version: 1 + + locale: en_US.UTF-8 + keyboard: + layout: us + + source: + id: ubuntu-server-minimal + + storage: + layout: + name: direct + config: + - type: disk + id: disk0 + match: + size: largest + ptable: gpt + wipe: superblock-recursive + preserve: false + grub_device: true + - type: partition + id: part-efi + device: disk0 + size: 512M + flag: boot + number: 1 + preserve: false + - type: format + id: fmt-efi + volume: part-efi + fstype: fat32 + preserve: false + - type: partition + id: part-root + device: disk0 + size: -1 + number: 2 + preserve: false + - type: format + id: fmt-root + volume: part-root + fstype: ext4 + preserve: false + - type: mount + id: mnt-root + device: fmt-root + path: / + - type: mount + id: mnt-efi + device: fmt-efi + path: /boot/efi + + identity: + hostname: zroc-appliance + username: zroc + password: "$6$rounds=4096$packer$xKDMK6dLB2.8PZnJnXGpYQ9o0CWbEe4s7T5JY3bVq1ZQ2RQ6y7dAjH4wqpVLBkHPHU/CuW7.8SsLQ6TYe1" + + ssh: + install-server: true + allow-pw: true + + packages: + - curl + - wget + - git + - vim + - htop + - net-tools + - open-vm-tools + - ca-certificates + - gnupg + - lsb-release + - unattended-upgrades + - apt-transport-https + + late-commands: + - echo 'zroc ALL=(ALL) NOPASSWD:ALL' > /target/etc/sudoers.d/zroc-packer + - chmod 440 /target/etc/sudoers.d/zroc-packer + - echo 'net.ipv6.conf.all.disable_ipv6 = 1' >> /target/etc/sysctl.d/99-zroc.conf diff --git a/zroc-ova/packer/ubuntu-2604.pkr.hcl b/zroc-ova/packer/ubuntu-2604.pkr.hcl index 6e86c8e..669a310 100644 --- a/zroc-ova/packer/ubuntu-2604.pkr.hcl +++ b/zroc-ova/packer/ubuntu-2604.pkr.hcl @@ -1,2 +1,173 @@ -# TODO: Full content to be added -# This file is part of the zROC project recreation +packer { + required_version = ">= 1.10.0" + required_plugins { + vmware = { + source = "github.com/hashicorp/vmware" + version = "~> 1.0" + } + qemu = { + source = "github.com/hashicorp/qemu" + version = "~> 1.0" + } + } +} + +variable "ubuntu_iso_url" { + type = string + default = "https://releases.ubuntu.com/26.04/ubuntu-26.04-live-server-amd64.iso" +} + +variable "ubuntu_iso_checksum" { + type = string + default = "file:https://releases.ubuntu.com/26.04/SHA256SUMS" +} + +variable "vm_name" { + type = string + default = "zroc-appliance" +} + +variable "vm_version" { + type = string + default = "1.0.0" +} + +variable "disk_size_mb" { + type = number + default = 102400 +} + +variable "memory_mb" { + type = number + default = 8192 +} + +variable "cpus" { + type = number + default = 4 +} + +variable "output_dir" { + type = string + default = "../output" +} + +variable "headless" { + type = bool + default = true +} + +source "vmware-iso" "ubuntu2604" { + vm_name = "${var.vm_name}-${var.vm_version}" + guest_os_type = "ubuntu-64" + headless = var.headless + iso_url = var.ubuntu_iso_url + iso_checksum = var.ubuntu_iso_checksum + disk_size = var.disk_size_mb + disk_adapter_type = "pvscsi" + memory = var.memory_mb + cpus = var.cpus + network_adapter_type = "vmxnet3" + network = "nat" + disk_type_id = 0 + http_directory = "http" + http_port_min = 8100 + http_port_max = 8199 + boot_wait = "5s" + boot_command = [ + "e", + "", + " autoinstall ds=nocloud-net;seedfrom=http://{{.HTTPIP}}:{{.HTTPPort}}/", + "", + ] + ssh_username = "zroc" + ssh_password = "zroc-setup-temp" + ssh_timeout = "30m" + ssh_port = 22 + shutdown_command = "echo 'zroc-setup-temp' | sudo -S shutdown -P now" + output_directory = "${var.output_dir}/vmware" + skip_export = false + format = "ovf" + vmx_data = { + "virtualHW.version" = "19" + "tools.syncTime" = "TRUE" + "annotation" = "zROC Appliance v${var.vm_version}" + "guestOS" = "ubuntu-64" + } +} + +source "qemu" "ubuntu2604" { + vm_name = "${var.vm_name}-${var.vm_version}" + iso_url = var.ubuntu_iso_url + iso_checksum = var.ubuntu_iso_checksum + disk_size = "${var.disk_size_mb}M" + disk_interface = "virtio" + memory = var.memory_mb + cpus = var.cpus + accelerator = "kvm" + headless = true + http_directory = "http" + http_port_min = 8100 + http_port_max = 8199 + boot_wait = "5s" + boot_command = [ + "e", + "", + " autoinstall ds=nocloud-net;seedfrom=http://{{.HTTPIP}}:{{.HTTPPort}}/", + "", + ] + ssh_username = "zroc" + ssh_password = "zroc-setup-temp" + ssh_timeout = "45m" + shutdown_command = "echo 'zroc-setup-temp' | sudo -S shutdown -P now" + output_directory = "${var.output_dir}/qemu" + format = "qcow2" +} + +build { + name = "zroc-appliance" + sources = ["source.vmware-iso.ubuntu2604"] + + provisioner "shell" { + script = "../scripts/00-base.sh" + execute_command = "echo 'zroc-setup-temp' | sudo -S bash {{.Path}}" + expect_disconnect = true + } + + provisioner "shell" { + script = "../scripts/01-docker.sh" + execute_command = "echo 'zroc-setup-temp' | sudo -S bash {{.Path}}" + pause_before = "15s" + } + + provisioner "shell" { + script = "../scripts/02-zroc.sh" + execute_command = "echo 'zroc-setup-temp' | sudo -S bash {{.Path}}" + } + + provisioner "shell" { + script = "../scripts/03-setup-wizard.sh" + execute_command = "echo 'zroc-setup-temp' | sudo -S bash {{.Path}}" + } + + provisioner "shell" { + script = "../scripts/04-systemd-service.sh" + execute_command = "echo 'zroc-setup-temp' | sudo -S bash {{.Path}}" + } + + provisioner "shell" { + script = "../scripts/05-cleanup.sh" + execute_command = "echo 'zroc-setup-temp' | sudo -S bash {{.Path}}" + } + + post-processor "shell-local" { + only = ["vmware-iso.ubuntu2604"] + inline = [ + "cd ${var.output_dir}/vmware", + "ovftool --compress=9 *.ovf ../${var.vm_name}-${var.vm_version}-ubuntu-26.04-amd64.ova", + "cd ..", + "sha256sum ${var.vm_name}-${var.vm_version}-ubuntu-26.04-amd64.ova > ${var.vm_name}-${var.vm_version}-ubuntu-26.04-amd64.ova.sha256", + "echo 'OVA packaged successfully'", + ] + } +} diff --git a/zroc-ui/.github/workflows/publish.yml b/zroc-ui/.github/workflows/publish.yml index 6e86c8e..0bdceff 100644 --- a/zroc-ui/.github/workflows/publish.yml +++ b/zroc-ui/.github/workflows/publish.yml @@ -1,2 +1,64 @@ -# TODO: Full content to be added -# This file is part of the zROC project recreation +name: Build & Publish Docker Image + +on: + push: + tags: + - 'v[0-9]+.[0-9]+.[0-9]+' + +env: + IMAGE: recklessop/zroc-ui + CONTEXT: ./zroc-ui + +jobs: + build-and-push: + name: Build & Push + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Extract version from tag + id: meta + run: | + VERSION=${GITHUB_REF_NAME#v} + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "tag=$GITHUB_REF_NAME" >> $GITHUB_OUTPUT + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: ${{ env.CONTEXT }} + platforms: linux/amd64,linux/arm64 + push: true + tags: | + ${{ env.IMAGE }}:${{ steps.meta.outputs.version }} + ${{ env.IMAGE }}:stable + ${{ env.IMAGE }}:latest + cache-from: type=gha + cache-to: type=gha,mode=max + build-args: | + BUILD_VERSION=${{ steps.meta.outputs.version }} + BUILD_DATE=${{ github.event.repository.updated_at }} + GIT_SHA=${{ github.sha }} + + - name: Update Docker Hub description + uses: peter-evans/dockerhub-description@v4 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + repository: ${{ env.IMAGE }} + readme-filepath: ./zroc-ui/DOCKER_README.md + short-description: "zROC UI — Zerto Resiliency Observation Console frontend" diff --git a/zroc-ui/README.md b/zroc-ui/README.md index 6e86c8e..f085a1e 100644 --- a/zroc-ui/README.md +++ b/zroc-ui/README.md @@ -1,2 +1,50 @@ -# TODO: Full content to be added -# This file is part of the zROC project recreation +# zROC — Zerto Resiliency Observation Console + +> A self-hosted, purpose-built observability dashboard for Zerto — replaces Zerto Analytics with a fast, always-on UI. + +## Overview + +zROC is a Docker Compose stack that collects Zerto metrics via the ZVM REST API and presents them in a polished web interface. + +| Component | Role | +|---|---| +| Zerto Exporter | Scrapes ZVM & vCenter APIs, exposes Prometheus metrics | +| Prometheus | Stores metrics with 30-day retention | +| zROC UI | React + Express — authenticated dashboard | +| Authentik | Identity provider — login, 2FA, SSO, user management | +| Caddy | TLS termination | +| Grafana | Legacy dashboards | + +## Quick Start + +```bash +git clone https://github.com/ZertoPublic/zroc.git +cd zroc +cp .env.example .env +# Edit .env with your ZVM credentials and secrets +docker compose up -d +``` + +## Pages + +| Page | Path | Description | +|---|---|---| +| Overview | `/` | NOC dashboard — health bar, site cards, VPG heat grid | +| VPG Monitor | `/vpgs` | Per-VPG: RPO gauge, throughput/IOPS charts, journal health | +| VM Protection | `/vms` | All VMs — RPO, journal usage, encryption %, drill-down | +| VRA Infrastructure | `/vras` | CPU/memory usage, protected/recovery workload | +| Encryption Detection | `/encryption` | Encryption % per VM, anomaly table | +| Storage | `/storage` | Datastore capacity with Zerto usage breakdown | +| User Management | `/settings/users` | Admin only — full CRUD + 2FA QR setup | + +## Architecture + +``` +Browser → Caddy (TLS) → zroc-ui (Express + React SPA) + → Authentik (OIDC auth) +zroc-ui → Prometheus → Zerto Exporter → ZVM API +``` + +## License + +Apache 2.0 diff --git a/zroc-ui/authentik/blueprints/zroc-initial.yaml b/zroc-ui/authentik/blueprints/zroc-initial.yaml index 6e86c8e..d03a422 100644 --- a/zroc-ui/authentik/blueprints/zroc-initial.yaml +++ b/zroc-ui/authentik/blueprints/zroc-initial.yaml @@ -1,2 +1,110 @@ -# TODO: Full content to be added -# This file is part of the zROC project recreation +version: 1 +metadata: + name: zROC Initial Configuration + labels: + blueprints.goauthentik.io/instantiate: "true" + +entries: + - model: authentik_core.group + state: present + identifiers: + name: zroc-admins + attrs: + name: zroc-admins + + - model: authentik_core.group + state: present + identifiers: + name: zroc-viewers + attrs: + name: zroc-viewers + + - model: authentik_providers_oauth2.scopemapping + state: present + identifiers: + managed: goauthentik.io/providers/oauth2/scope-zroc-groups + attrs: + managed: goauthentik.io/providers/oauth2/scope-zroc-groups + name: "zROC Groups Scope" + scope_name: groups + expression: | + return [group.name for group in request.user.ak_groups.all()] + + - model: authentik_providers_oauth2.oauth2provider + state: present + identifiers: + name: zROC Dashboard Provider + attrs: + name: zROC Dashboard Provider + client_type: confidential + client_id: !Env ZROC_OIDC_CLIENT_ID + client_secret: !Env ZROC_OIDC_CLIENT_SECRET + authorization_flow: !Find [authentik_flows.flow, [name, default-provider-authorization-implicit-consent]] + redirect_uris: !Env ZROC_PUBLIC_URL + signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]] + access_code_validity: minutes=1 + access_token_validity: hours=1 + refresh_token_validity: days=30 + property_mappings: + - !Find [authentik_providers_oauth2.scopemapping, [scope_name, openid]] + - !Find [authentik_providers_oauth2.scopemapping, [scope_name, email]] + - !Find [authentik_providers_oauth2.scopemapping, [scope_name, profile]] + - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-zroc-groups]] + + - model: authentik_core.application + state: present + identifiers: + slug: zroc-dashboard + attrs: + name: zROC Dashboard + slug: zroc-dashboard + provider: !Find [authentik_providers_oauth2.oauth2provider, [name, zROC Dashboard Provider]] + meta_launch_url: !Env ZROC_PUBLIC_URL + meta_description: Zerto Resiliency Observation Console + policy_engine_mode: any + + - model: authentik_stages_authenticator_validate.authenticatorvalidatestage + state: present + identifiers: + name: zroc-totp-validation + attrs: + name: zroc-totp-validation + device_classes: + - totp + - static + not_configured_action: configure + configuration_stages: + - !Find [authentik_stages_authenticator_totp.authenticatortotpstage, [name, default-authenticator-totp-setup]] + + - model: authentik_flows.flowstagebinding + state: present + identifiers: + target: !Find [authentik_flows.flow, [slug, default-authentication-flow]] + stage: !Find [authentik_stages_authenticator_validate.authenticatorvalidatestage, [name, zroc-totp-validation]] + attrs: + target: !Find [authentik_flows.flow, [slug, default-authentication-flow]] + stage: !Find [authentik_stages_authenticator_validate.authenticatorvalidatestage, [name, zroc-totp-validation]] + order: 30 + evaluate_on_plan: true + re_evaluate_policies: false + + - model: authentik_core.user + state: present + identifiers: + username: zroc-service-account + attrs: + username: zroc-service-account + name: zROC Service Account + type: service_account + is_active: true + + - model: authentik_core.token + state: present + identifiers: + identifier: zroc-ui-admin-token + attrs: + identifier: zroc-ui-admin-token + user: !Find [authentik_core.user, [username, zroc-service-account]] + intent: api + description: "Used by zROC UI backend for user management" + expiring: false diff --git a/zroc-ui/backend/authentik.js b/zroc-ui/backend/authentik.js index f54b366..c87f62e 100644 --- a/zroc-ui/backend/authentik.js +++ b/zroc-ui/backend/authentik.js @@ -1,2 +1,148 @@ -// TODO: Full content to be added -// This file is part of the zROC project recreation +// backend/authentik.js +'use strict'; + +const axios = require('axios'); +const QRCode = require('qrcode'); +const config = require('./config'); +const logger = require('./logger'); + +const api = axios.create({ + baseURL: `${config.authentik_url}/api/v3`, + headers: { + Authorization: `Bearer ${config.authentik_admin_token}`, + 'Content-Type': 'application/json', + }, + timeout: 10_000, +}); + +api.interceptors.response.use( + (res) => res, + (err) => { + const status = err.response?.status; + const detail = err.response?.data?.detail || err.message; + logger.error(`[Authentik API] ${err.config?.method?.toUpperCase()} ${err.config?.url} → ${status}: ${detail}`); + return Promise.reject(err); + } +); + +async function listUsers({ search = '', page = 1, pageSize = 50 } = {}) { + const params = { page, page_size: pageSize }; + if (search) params.search = search; + + const { data } = await api.get('/core/users/', { params }); + + const totpDevices = await listAllTotpDevices(); + const totpByUser = new Map(); + for (const d of totpDevices) { + totpByUser.set(d.user, true); + } + + const users = data.results.map((u) => ({ + id: u.pk, + username: u.username, + name: u.name, + email: u.email, + isActive: u.is_active, + isSuperuser: u.is_superuser, + groups: u.groups_obj?.map((g) => ({ id: g.pk, name: g.name })) ?? [], + avatar: u.avatar, + lastLogin: u.last_login, + dateJoined: u.date_joined, + totpEnrolled: totpByUser.has(u.pk), + type: u.type, + })); + + return { users, count: data.count, page, pageSize }; +} + +async function getUser(userId) { + const { data: u } = await api.get(`/core/users/${userId}/`); + return { + id: u.pk, + username: u.username, + name: u.name, + email: u.email, + isActive: u.is_active, + isSuperuser: u.is_superuser, + groups: u.groups_obj?.map((g) => ({ id: g.pk, name: g.name })) ?? [], + avatar: u.avatar, + lastLogin: u.last_login, + dateJoined: u.date_joined, + type: u.type, + }; +} + +async function createUser({ username, name, email, isActive = true, groups = [], password }) { + const payload = { + username, name, email, is_active: isActive, groups, type: 'internal', + }; + const { data: u } = await api.post('/core/users/', payload); + if (password) { await setPassword(u.pk, password); } + return getUser(u.pk); +} + +async function updateUser(userId, { name, email, isActive, groups }) { + const payload = {}; + if (name !== undefined) payload.name = name; + if (email !== undefined) payload.email = email; + if (isActive !== undefined) payload.is_active = isActive; + if (groups !== undefined) payload.groups = groups; + await api.patch(`/core/users/${userId}/`, payload); + return getUser(userId); +} + +async function deleteUser(userId) { + await api.delete(`/core/users/${userId}/`); +} + +async function setPassword(userId, password) { + await api.post(`/core/users/${userId}/set_password/`, { password }); +} + +async function listGroups({ search = '' } = {}) { + const params = { page_size: 100 }; + if (search) params.search = search; + const { data } = await api.get('/core/groups/', { params }); + return data.results.map((g) => ({ + id: g.pk, name: g.name, userCount: g.num_pk ?? 0, + })); +} + +async function listAllTotpDevices() { + const { data } = await api.get('/authenticators/totp/', { params: { page_size: 1000 } }); + return data.results; +} + +async function revokeTotpForUser(userId) { + const { data } = await api.get('/authenticators/totp/', { + params: { user: userId, page_size: 100 }, + }); + await Promise.all(data.results.map((d) => api.delete(`/authenticators/totp/${d.pk}/`))); + return data.results.length; +} + +async function generateTwoFactorSetupLink(userId) { + await revokeTotpForUser(userId); + const { data } = await api.post(`/core/users/${userId}/recovery/`); + const setupUrl = data.link; + const qrDataUrl = await QRCode.toDataURL(setupUrl, { + width: 280, margin: 2, + color: { dark: '#0ea5e9', light: '#0a0f1e' }, + errorCorrectionLevel: 'M', + }); + return { setupUrl, qrDataUrl }; +} + +async function validateAdminToken() { + try { + await api.get('/core/users/me/'); + return true; + } catch { + return false; + } +} + +module.exports = { + listUsers, getUser, createUser, updateUser, deleteUser, setPassword, + listGroups, revokeTotpForUser, generateTwoFactorSetupLink, validateAdminToken, +}; diff --git a/zroc-ui/src/components/charts/TimeSeriesChart.jsx b/zroc-ui/src/components/charts/TimeSeriesChart.jsx index f54b366..5ef4aec 100644 --- a/zroc-ui/src/components/charts/TimeSeriesChart.jsx +++ b/zroc-ui/src/components/charts/TimeSeriesChart.jsx @@ -1,2 +1,152 @@ -// TODO: Full content to be added -// This file is part of the zROC project recreation +// src/components/charts/TimeSeriesChart.jsx +import { useState, useCallback } from 'react'; +import { + ComposedChart, Area, Line, + XAxis, YAxis, CartesianGrid, Tooltip, Legend, + ResponsiveContainer, ReferenceLine, +} from 'recharts'; +import { useRangeQuery } from '@/hooks/useRangeQuery'; +import { Loader2 } from 'lucide-react'; +import clsx from 'clsx'; + +const WINDOWS = ['1h', '6h', '24h', '7d', '30d']; + +function CustomTooltip({ active, payload, label, formatter, timeFormat }) { + if (!active || !payload?.length) return null; + const ts = typeof label === 'number' ? new Date(label).toLocaleString(undefined, timeFormat) : label; + return ( +
+

{ts}

+ {payload.map((p) => ( +
+ + {p.name}: + + {formatter ? formatter(p.value, p.dataKey) : p.value} + +
+ ))} +
+ ); +} + +function WindowSelector({ value, onChange }) { + return ( +
+ {WINDOWS.map((w) => ( + + ))} +
+ ); +} + +const SERIES_COLORS = { + ok: '#10b981', warn: '#f59e0b', crit: '#ef4444', accent: '#0ea5e9', + info: '#818cf8', 0: '#0ea5e9', 1: '#10b981', 2: '#f59e0b', 3: '#818cf8', 4: '#ef4444', +}; + +export default function TimeSeriesChart({ + promql, title, yFormatter, yLabel, refLines = [], + showWindow = true, defaultWindow = '6h', height = 200, transform, series: seriesDef, +}) { + const [window, setWindow] = useState(defaultWindow); + + const seriesArr = seriesDef ? seriesDef + : Array.isArray(promql) ? promql + : [{ promql, name: title || 'value', color: 'accent' }]; + + const primaryPromql = typeof promql === 'string' ? promql : seriesArr[0]?.promql; + + const { data: rawData, isLoading, error } = useRangeQuery(primaryPromql, { + window, enabled: !!primaryPromql, + }); + + const chartData = useCallback(() => { + if (!rawData?.length) return []; + if (transform) return transform(rawData, window); + const merged = {}; + rawData.forEach((series, si) => { + const key = series.metric.VpgName || series.metric.VmName || series.metric.VraName || seriesArr[si]?.name || `series${si}`; + series.values.forEach(([ts, v]) => { + const ms = ts * 1000; + if (!merged[ms]) merged[ms] = { ts: ms }; + merged[ms][key] = parseFloat(v); + }); + }); + return Object.values(merged).sort((a, b) => a.ts - b.ts); + }, [rawData, transform, window, seriesArr]); + + const data = chartData(); + const seriesKeys = data.length > 0 ? Object.keys(data[0]).filter((k) => k !== 'ts') : []; + + const makeTimeTick = (w) => (ts) => { + const d = new Date(ts); + if (w === '30d' || w === '7d') return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); + }; + + const makeTooltipTimeFormat = (w) => { + if (w === '30d' || w === '7d') return { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }; + return { hour: '2-digit', minute: '2-digit', second: '2-digit' }; + }; + + return ( +
+
+ {title &&

{title}

} + {showWindow && } +
+
+ {isLoading && ( +
+ +
+ )} + {error && ( +
+

Query failed

+
+ )} + {!isLoading && !error && data.length === 0 && ( +
+

No data

+
+ )} + + + + + yFormatter ? yFormatter(v) : v} + tick={{ fontSize: 10, fill: '#4a6080', fontFamily: 'JetBrains Mono' }} + axisLine={false} tickLine={false} width={yLabel ? 60 : 40} /> + } + cursor={{ stroke: '#2a4066', strokeWidth: 1, strokeDasharray: '4 2' }} /> + {refLines.map((rl) => ( + + ))} + {(seriesArr.length > 1 ? seriesArr : seriesKeys.map((k, i) => ({ + name: k, color: i, type: 'area', + }))).map((s, i) => { + const key = s.name || s.promql || seriesKeys[i] || `s${i}`; + const color = SERIES_COLORS[s.color] || SERIES_COLORS[i] || '#0ea5e9'; + return ( + + ); + })} + + +
+
+ ); +} diff --git a/zroc-ui/src/pages/Encryption.jsx b/zroc-ui/src/pages/Encryption.jsx index f54b366..be41546 100644 --- a/zroc-ui/src/pages/Encryption.jsx +++ b/zroc-ui/src/pages/Encryption.jsx @@ -1,2 +1,130 @@ -// TODO: Full content to be added -// This file is part of the zROC project recreation +// src/pages/Encryption.jsx +import { useQuery } from '@tanstack/react-query'; +import { ShieldAlert, ShieldCheck, TrendingUp, AlertTriangle, Loader2 } from 'lucide-react'; +import { queryEncryptionDetail } from '@/api/prometheusExtended'; +import TimeSeriesChart from '@/components/charts/TimeSeriesChart'; +import clsx from 'clsx'; + +const REFRESH = 30_000; + +function EncryptionBar({ pct }) { + const enc = Math.min(pct ?? 0, 100); + const color = enc > 80 ? 'bg-crit' : enc > 50 ? 'bg-warn' : 'bg-ok'; + const textC = enc > 80 ? 'text-crit' : enc > 50 ? 'text-warn' : 'text-ok'; + return ( +
+
+
+
+ + {enc.toFixed(1)}% + +
+ ); +} + +function TrendBadge({ level }) { + const l = level ?? 0; + if (l === 0) return Stable; + if (l === 1) return Rising; + return Spike; +} + +export default function EncryptionPage() { + const { data: vms = [], isLoading } = useQuery({ + queryKey: ['encryption-detail'], + queryFn: queryEncryptionDetail, + refetchInterval: REFRESH, + }); + + const anomalies = vms.filter((v) => (v.pctEncrypted ?? 0) > 50); + const highAlert = vms.filter((v) => (v.pctEncrypted ?? 0) > 80); + const avgPct = vms.length ? vms.reduce((s, v) => s + (v.pctEncrypted ?? 0), 0) / vms.length : 0; + + return ( +
+
+ {[ + { label: 'VMs Monitored', value: vms.length, icon: ShieldAlert, color: 'accent' }, + { label: 'Anomalies (>50%)', value: anomalies.length, icon: AlertTriangle, color: anomalies.length ? 'warn' : 'ok' }, + { label: 'High Alert (>80%)', value: highAlert.length, icon: TrendingUp, color: highAlert.length ? 'crit' : 'ok' }, + { label: 'Avg Encryption', value: `${avgPct.toFixed(1)}%`, icon: ShieldCheck, color: avgPct > 60 ? 'warn' : 'ok' }, + ].map((s) => ( +
+
+ +
+
+

{s.label}

+

{s.value}

+
+
+ ))} +
+ + {anomalies.length > 0 && ( + `${v.toFixed(1)}%`} + refLines={[{ value: 80, label: 'High alert', color: 'crit' }, { value: 50, label: 'Warning', color: 'warn' }]} + transform={(result) => result[0]?.values.map(([ts, v]) => ({ ts: ts * 1000, 'Encrypted %': parseFloat(v) })) ?? []} + height={180} + /> + )} + +
+

VM Encryption Status

+
+ + + + + + + + + + + + + {isLoading && ( + + )} + {!isLoading && vms.map((vm) => ( + + + + + + + + + ))} + {!isLoading && vms.length === 0 && ( + + )} + +
VMVPGEncryption %TrendIO OpsWrite
+ +
{vm.name}{vm.vpgName} + + {vm.ioOps != null ? Math.round(vm.ioOps).toLocaleString() : '—'} + + + + {vm.writeMb != null ? `${vm.writeMb.toFixed(2)} MB` : '—'} + +
+ +

No encryption stats available

+
+
+
+
+ ); +} diff --git a/zroc-ui/src/pages/Overview.jsx b/zroc-ui/src/pages/Overview.jsx index f54b366..9bc0864 100644 --- a/zroc-ui/src/pages/Overview.jsx +++ b/zroc-ui/src/pages/Overview.jsx @@ -1,2 +1,187 @@ -// TODO: Full content to be added -// This file is part of the zROC project recreation +// src/pages/Overview.jsx +import { useNavigate } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; +import { CheckCircle2, AlertTriangle, XCircle, Activity } from 'lucide-react'; +import { queryOverviewSummary, queryAllVpgs, queryTopRpoViolators, queryExporterHealth } from '@/api/prometheus'; +import { rpoStatus, formatRpo, colorToText } from '@/constants/statusMaps'; +import clsx from 'clsx'; + +const REFRESH = 30_000; + +function StatCard({ label, value, sub, color = 'accent', icon: Icon }) { + return ( +
+
+ +
+
+

{label}

+

{value ?? '—'}

+ {sub &&

{sub}

} +
+
+ ); +} + +function SiteCard({ site }) { + const hasCrit = site.crit > 0; + return ( +
0 ? 'border-warn/30' : 'border-border')}> +
+
+ 0 ? 'status-dot-warn' : 'status-dot-ok')} /> +

{site.siteName}

+
+ 0 ? 'badge-warn' : 'badge-ok')}> + {hasCrit ? 'Alert' : site.warn > 0 ? 'Warning' : 'Healthy'} + +
+
+

{site.ok}

OK

+

{site.warn}

Warn

+

{site.crit}

Crit

+
+
+ ); +} + +function VpgTile({ vpg, onClick }) { + const status = rpoStatus(vpg.actualRpoSec, vpg.configuredRpoSec); + return ( + + ); +} + +export default function Overview() { + const navigate = useNavigate(); + + const { data: sites = [] } = useQuery({ + queryKey: ['overview-summary'], queryFn: queryOverviewSummary, refetchInterval: REFRESH, + }); + const { data: vpgs = [], isLoading: vpgsLoading } = useQuery({ + queryKey: ['all-vpgs'], queryFn: queryAllVpgs, refetchInterval: REFRESH, + }); + const { data: violators = [] } = useQuery({ + queryKey: ['top-violators'], queryFn: () => queryTopRpoViolators(10), refetchInterval: REFRESH, + }); + const { data: exporterHealth = [] } = useQuery({ + queryKey: ['exporter-health'], queryFn: queryExporterHealth, refetchInterval: REFRESH, + }); + + const totalOk = sites.reduce((s, x) => s + x.ok, 0); + const totalWarn = sites.reduce((s, x) => s + x.warn, 0); + const totalCrit = sites.reduce((s, x) => s + x.crit, 0); + const totalMbps = sites.reduce((s, x) => s + (x.throughputMb ?? 0), 0); + + return ( +
+
+ + + + +
+ + {sites.length > 0 && ( +
+

Sites

+
+ {sites.map((s) => )} +
+
+ )} + +
+

VPG RPO Heat Grid

+ {vpgsLoading ? ( +
Loading VPGs…
+ ) : ( +
+
+ {vpgs.sort((a, b) => { + const sev = (v) => { const s = rpoStatus(v.actualRpoSec, v.configuredRpoSec); return s === 'crit' ? 0 : s === 'warn' ? 1 : 2; }; + return sev(a) - sev(b); + }).map((vpg) => ( + navigate(`/vpgs?name=${encodeURIComponent(vpg.name)}`)} /> + ))} +
+
+ )} +
+ +
+
+

Top RPO Violators

+
+ + + + + + + + + + + + {violators.map((v) => { + const ratio = v.configuredRpoSec ? v.actualRpoSec / v.configuredRpoSec : 0; + const status = rpoStatus(v.actualRpoSec, v.configuredRpoSec); + return ( + navigate(`/vpgs?name=${encodeURIComponent(v.name)}`)}> + + + + + + + ); + })} + {violators.length === 0 && ( + + )} + +
VPGSiteActual RPOTargetRatio
{v.name}{v.siteName}{formatRpo(v.actualRpoSec)}{formatRpo(v.configuredRpoSec)}{ratio.toFixed(1)}x
+ All VPGs within RPO targets +
+
+
+ +
+

Collector Health

+
+ {exporterHealth.length === 0 &&

No exporter data

} + {exporterHealth.map((t) => ( +
+
+

{t.thread}

+

{t.instance}

+
+ + + {t.alive ? 'Running' : 'Down'} + +
+ ))} +
+
+
+
+ ); +} diff --git a/zroc-ui/src/pages/Settings/UserManagement.jsx b/zroc-ui/src/pages/Settings/UserManagement.jsx index f54b366..5e011a2 100644 --- a/zroc-ui/src/pages/Settings/UserManagement.jsx +++ b/zroc-ui/src/pages/Settings/UserManagement.jsx @@ -1,2 +1,291 @@ -// TODO: Full content to be added -// This file is part of the zROC project recreation +// src/pages/Settings/UserManagement.jsx +import { useState, useEffect, useCallback, useRef } from 'react'; +import { Users, UserPlus, Search, Shield, ShieldOff, ShieldCheck, Pencil, Trash2, QrCode, X, Check, Loader2, AlertTriangle, Copy, ExternalLink } from 'lucide-react'; +import { usersApi } from '@/api/users'; +import clsx from 'clsx'; + +function Avatar({ name, size = 'md' }) { + const initials = name ? name.split(' ').map((w) => w[0]).slice(0, 2).join('').toUpperCase() : '?'; + const colors = ['bg-accent/20 text-accent', 'bg-ok/20 text-ok', 'bg-info/20 text-info', 'bg-warn/20 text-warn']; + const color = colors[(name?.charCodeAt(0) ?? 0) % colors.length]; + const sz = size === 'lg' ? 'w-10 h-10 text-sm' : 'w-8 h-8 text-xs'; + return
{initials}
; +} + +function Toast({ message, type = 'ok', onDismiss }) { + useEffect(() => { const t = setTimeout(onDismiss, 3500); return () => clearTimeout(t); }, [onDismiss]); + return ( +
+ {type === 'ok' && }{type === 'error' && } + {message} + +
+ ); +} + +function DeleteModal({ user, onConfirm, onCancel, loading }) { + return ( +
+
+
+
+
+

Delete user

This cannot be undone

+
+

Delete {user.name} ({user.username})?

+
+ + +
+
+
+ ); +} + +function TwoFactorModal({ user, onClose }) { + const [state, setState] = useState('idle'); + const [result, setResult] = useState(null); + const generate = useCallback(async () => { + setState('loading'); + try { const data = await usersApi.setup2fa(user.id); setResult(data); setState('done'); } + catch { setState('error'); } + }, [user.id]); + useEffect(() => { generate(); }, [generate]); + + return ( +
+
+
+
+
+
+

Set up 2FA

{user.name}

+
+ +
+ {state === 'loading' &&

Generating setup link…

} + {state === 'error' &&

Failed to generate setup link.

} + {state === 'done' && result && ( + <> +
+ 2FA setup QR code +
+

Share this QR code with {user.name} to enroll their authenticator app.

+ + + )} +
+
+ ); +} + +function UserDrawer({ mode, user, groups, onSave, onClose }) { + const isEdit = mode === 'edit'; + const [form, setForm] = useState(isEdit ? { + username: user.username, name: user.name, email: user.email, + isActive: user.isActive, groups: user.groups.map((g) => g.id), password: '', + } : { username: '', name: '', email: '', isActive: true, groups: [], password: '' }); + const [errors, setErrors] = useState({}); + const [saving, setSaving] = useState(false); + + const set = (field) => (e) => setForm((f) => ({ ...f, [field]: e.target.type === 'checkbox' ? e.target.checked : e.target.value })); + const toggleGroup = (id) => setForm((f) => ({ ...f, groups: f.groups.includes(id) ? f.groups.filter((g) => g !== id) : [...f.groups, id] })); + + const handleSubmit = async () => { + const e = {}; + if (!form.username.trim()) e.username = 'Required'; + if (!form.name.trim()) e.name = 'Required'; + if (!form.email.trim()) e.email = 'Required'; + if (!isEdit && !form.password) e.password = 'Required'; + if (form.password && form.password.length < 8) e.password = 'Min 8 chars'; + setErrors(e); + if (Object.keys(e).length > 0) return; + setSaving(true); + try { + const payload = { username: form.username, name: form.name, email: form.email, isActive: form.isActive, groups: form.groups }; + if (form.password) payload.password = form.password; + await onSave(payload); + } finally { setSaving(false); } + }; + + return ( + <> +
+
+
+

{isEdit ? 'Edit user' : 'Add user'}

+ +
+
+
+

Identity

+
+
+
+
+
+
+
+

Groups

+
+ {groups.map((g) => ( + + ))} +
+
+
+

{isEdit ? 'Reset Password' : 'Password'}

+ + {errors.password &&

{errors.password}

} +
+
+
+ + +
+
+ + ); +} + +export default function UserManagement() { + const [users, setUsers] = useState([]); + const [groups, setGroups] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [searchInput, setSearchInput] = useState(''); + const [drawer, setDrawer] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + const [twoFaTarget, setTwoFaTarget] = useState(null); + const [toast, setToast] = useState(null); + const searchTimer = useRef(null); + + const showToast = (message, type = 'ok') => setToast({ message, type }); + + const loadUsers = useCallback(async (q = '') => { + setLoading(true); + try { const result = await usersApi.list({ search: q }); setUsers(result.users); setTotal(result.count); } + catch (err) { showToast(`Failed to load users: ${err.message}`, 'error'); } + finally { setLoading(false); } + }, []); + + const loadGroups = useCallback(async () => { + try { const g = await usersApi.listGroups(); setGroups(g); } catch {} + }, []); + + useEffect(() => { loadUsers(); loadGroups(); }, []); + + const handleSearchChange = (e) => { + const val = e.target.value; + setSearchInput(val); + clearTimeout(searchTimer.current); + searchTimer.current = setTimeout(() => loadUsers(val), 350); + }; + + const handleSave = async (payload) => { + try { + if (drawer.mode === 'create') { + const newUser = await usersApi.create(payload); + setUsers((u) => [newUser, ...u]); setTotal((t) => t + 1); + showToast(`User ${newUser.username} created`); + } else { + const updated = await usersApi.update(drawer.user.id, payload); + setUsers((u) => u.map((x) => (x.id === updated.id ? updated : x))); + showToast(`User ${updated.username} updated`); + } + setDrawer(null); + } catch (err) { showToast(err.message, 'error'); throw err; } + }; + + const handleDelete = async () => { + try { + await usersApi.delete(deleteTarget.id); + setUsers((u) => u.filter((x) => x.id !== deleteTarget.id)); setTotal((t) => t - 1); + showToast(`User ${deleteTarget.username} deleted`); setDeleteTarget(null); + } catch (err) { showToast(err.message, 'error'); } + }; + + return ( +
+
+
+

+ User Management +

+

{total} users

+
+ +
+ +
+ + +
+ +
+ + + + + + + + + + {loading && } + {!loading && users.length === 0 && } + {!loading && users.map((u) => ( + + + + + + + + ))} + +
UserGroupsStatus2FAActions
No users found
+
+ +

{u.name}

{u.username}

+
+
+
+ {u.groups.length === 0 ? : u.groups.map((g) => ( + {g.name} + ))} +
+
+ {u.isActive ? Active : Inactive} + + {u.totpEnrolled ? 2FA On : No 2FA} + +
+ + + +
+
+
+ + {drawer && setDrawer(null)} />} + {deleteTarget && setDeleteTarget(null)} />} + {twoFaTarget && { setTwoFaTarget(null); loadUsers(); }} />} + {toast && setToast(null)} />} +
+ ); +} diff --git a/zroc-ui/src/pages/Storage.jsx b/zroc-ui/src/pages/Storage.jsx index f54b366..54e6cba 100644 --- a/zroc-ui/src/pages/Storage.jsx +++ b/zroc-ui/src/pages/Storage.jsx @@ -1,2 +1,151 @@ -// TODO: Full content to be added -// This file is part of the zROC project recreation +// src/pages/Storage.jsx +import { useQuery } from '@tanstack/react-query'; +import { Database, HardDrive, Loader2 } from 'lucide-react'; +import { queryDatastores } from '@/api/prometheusExtended'; +import { formatBytes } from '@/constants/statusMaps'; +import clsx from 'clsx'; + +const REFRESH = 60_000; + +function CapacityBar({ label, usedBytes, totalBytes, color = 'bg-accent' }) { + const pct = totalBytes > 0 ? Math.min(usedBytes / totalBytes, 1) : 0; + const pctN = Math.round(pct * 100); + const barColor = pct >= 0.9 ? 'bg-crit' : pct >= 0.75 ? 'bg-warn' : color; + const textC = pct >= 0.9 ? 'text-crit' : pct >= 0.75 ? 'text-warn' : 'text-text-secondary'; + return ( +
+
+ {label} + + {formatBytes(usedBytes)} / {formatBytes(totalBytes)} ({pctN}%) + +
+
+
+
+
+ ); +} + +function ZertoUsageRow({ label, bytes, color }) { + return bytes > 0 ? ( +
+
+ + {label} +
+ {formatBytes(bytes)} +
+ ) : null; +} + +function DatastoreCard({ ds }) { + const usePct = ds.capacityBytes > 0 ? ds.usedBytes / ds.capacityBytes : 0; + const alerting = usePct >= 0.9; + const warning = usePct >= 0.75; + const zertoUsed = (ds.journalBytes ?? 0) + (ds.scratchBytes ?? 0) + (ds.recoveryBytes ?? 0) + (ds.applianceBytes ?? 0); + + return ( +
+
+
+
+ +
+
+

{ds.name}

+

{ds.siteName}

+
+
+
+

{ds.vraCount ?? 0} VRA{(ds.vraCount ?? 0) !== 1 ? 's' : ''}

+

{ds.incomingVms ?? 0} in / {ds.outgoingVms ?? 0} out

+
+
+ + {zertoUsed > 0 && ( +
+

Zerto Usage ({formatBytes(zertoUsed)})

+ + + + +
+ )} +
+ Free + {formatBytes(ds.freeBytes)} +
+
+ ); +} + +export default function Storage() { + const { data: datastores = [], isLoading } = useQuery({ + queryKey: ['datastores'], queryFn: queryDatastores, refetchInterval: REFRESH, + }); + + const totalCapacity = datastores.reduce((s, d) => s + (d.capacityBytes ?? 0), 0); + const totalUsed = datastores.reduce((s, d) => s + (d.usedBytes ?? 0), 0); + const totalJournal = datastores.reduce((s, d) => s + (d.journalBytes ?? 0), 0); + + const bySite = {}; + for (const d of datastores) { + const s = d.siteName || 'Unknown'; + if (!bySite[s]) bySite[s] = []; + bySite[s].push(d); + } + + return ( +
+
+ {[ + { label: 'Datastores', value: datastores.length, icon: Database }, + { label: 'Total Capacity', value: formatBytes(totalCapacity), icon: HardDrive }, + { label: 'Used', value: formatBytes(totalUsed), icon: HardDrive }, + { label: 'Journal Usage', value: formatBytes(totalJournal), icon: Database }, + ].map((s) => ( +
+
+ +
+
+

{s.label}

+

{s.value}

+
+
+ ))} +
+ + {totalCapacity > 0 && ( +
+ +
+ )} + + {isLoading && ( +
+ +
+ )} + + {Object.entries(bySite).map(([site, siteDs]) => ( +
+

{site} · {siteDs.length} datastore{siteDs.length !== 1 ? 's' : ''}

+
+ {siteDs.map((ds) => )} +
+
+ ))} + + {!isLoading && datastores.length === 0 && ( +
+ +

No datastore data available

+
+ )} +
+ ); +} diff --git a/zroc-ui/src/pages/VMDetail.jsx b/zroc-ui/src/pages/VMDetail.jsx index f54b366..f9a79c9 100644 --- a/zroc-ui/src/pages/VMDetail.jsx +++ b/zroc-ui/src/pages/VMDetail.jsx @@ -1,2 +1,169 @@ -// TODO: Full content to be added -// This file is part of the zROC project recreation +// src/pages/VMDetail.jsx +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { Search, Server, X, Activity, Loader2 } from 'lucide-react'; +import { queryAllVms } from '@/api/prometheusExtended'; +import TimeSeriesChart from '@/components/charts/TimeSeriesChart'; +import RPOGauge from '@/components/charts/RPOGauge'; +import { VM_STATUS, formatRpo, formatMB } from '@/constants/statusMaps'; +import clsx from 'clsx'; + +const REFRESH = 30_000; + +function JournalGauge({ usedMb, hardLimitMb }) { + if (!hardLimitMb || hardLimitMb <= 0) return ; + const pct = Math.min(usedMb / hardLimitMb, 1); + const color = pct > 0.85 ? 'bg-crit' : pct > 0.65 ? 'bg-warn' : 'bg-ok'; + return ( +
+
+
+
+ {formatMB(usedMb)} +
+ ); +} + +function VmDrawer({ vm, onClose }) { + const esc = vm.name.replace(/"/g, '\\"'); + return ( + <> +
+
+
+
+
+ +
+
+

{vm.name}

+

{vm.vpgName} · {vm.siteName}

+
+
+ +
+
+
+ +
+ {[ + { label: 'Throughput', value: `${(vm.throughputMb ?? 0).toFixed(2)} MB/s` }, + { label: 'IOPS', value: Math.round(vm.iops ?? 0).toLocaleString() }, + { label: 'Bandwidth', value: `${(vm.bandwidthMbps ?? 0).toFixed(2)} Mbps` }, + { label: 'Journal', value: formatMB(vm.journalUsedMb) }, + { label: 'Encryption', value: vm.pctEncrypted != null ? `${vm.pctEncrypted.toFixed(1)}%` : '—' }, + ].map(({ label, value }) => ( +
+ {label} + {value} +
+ ))} +
+
+ result[0]?.values.map(([ts, v]) => ({ ts: ts * 1000, 'RPO': parseFloat(v) })) ?? []} + height={170} /> + `${v.toFixed(1)} MB/s`} + transform={(result) => result[0]?.values.map(([ts, v]) => ({ ts: ts * 1000, 'MB/s': parseFloat(v) })) ?? []} + height={150} /> +
+
+ + ); +} + +function VmStatusBadge({ code }) { + const s = VM_STATUS[code] ?? { label: 'Unknown', color: 'muted' }; + return {s.label}; +} + +export default function VMDetail() { + const [search, setSearch] = useState(''); + const [sort, setSort] = useState('rpo-desc'); + const [selected, setSelected] = useState(null); + + const { data: vms = [], isLoading } = useQuery({ + queryKey: ['all-vms'], queryFn: queryAllVms, refetchInterval: REFRESH, + }); + + const filtered = vms.filter((v) => { + const q = search.toLowerCase(); + return !q || v.name?.toLowerCase().includes(q) || v.vpgName?.toLowerCase().includes(q); + }); + + const sorted = [...filtered].sort((a, b) => { + switch (sort) { + case 'rpo-desc': return (b.actualRpoSec ?? 0) - (a.actualRpoSec ?? 0); + case 'rpo-asc': return (a.actualRpoSec ?? 0) - (b.actualRpoSec ?? 0); + case 'name-asc': return (a.name ?? '').localeCompare(b.name ?? ''); + default: return 0; + } + }); + + return ( +
+
+ {[ + { label: 'Total VMs', value: vms.length, color: 'accent' }, + { label: 'RPO OK', value: vms.filter((v) => (v.actualRpoSec ?? 0) <= 300).length, color: 'ok' }, + { label: 'RPO Warning', value: vms.filter((v) => (v.actualRpoSec ?? 0) > 300 && (v.actualRpoSec ?? 0) <= 600).length, color: 'warn' }, + { label: 'RPO Critical', value: vms.filter((v) => (v.actualRpoSec ?? 0) > 600).length, color: 'crit' }, + ].map(({ label, value, color }) => ( +
+
+ +
+

{label}

{value}

+
+ ))} +
+ +
+
+ + setSearch(e.target.value)} /> +
+ + {sorted.length} / {vms.length} VMs +
+ +
+ + + + + + + + + + + {isLoading && } + {!isLoading && sorted.length === 0 && } + {!isLoading && sorted.map((vm) => { + const rpoColor = vm.actualRpoSec > 600 ? 'text-crit' : vm.actualRpoSec > 300 ? 'text-warn' : 'text-ok'; + return ( + setSelected(vm)} className="table-row-hover border-b border-border/40 last:border-0"> + + + + + + + + ); + })} + +
VM NameVPGRPOJournalThroughputStatus
No VMs
{vm.name}{vm.vpgName}{formatRpo(vm.actualRpoSec)}{(vm.throughputMb ?? 0).toFixed(2)} MB/s
+
+ + {selected && setSelected(null)} />} +
+ ); +} diff --git a/zroc-ui/src/pages/VPGMonitor.jsx b/zroc-ui/src/pages/VPGMonitor.jsx index f54b366..996a256 100644 --- a/zroc-ui/src/pages/VPGMonitor.jsx +++ b/zroc-ui/src/pages/VPGMonitor.jsx @@ -1,2 +1,154 @@ -// TODO: Full content to be added -// This file is part of the zROC project recreation +// src/pages/VPGMonitor.jsx +import { useState, useEffect } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; +import { Search, ChevronRight, Loader2 } from 'lucide-react'; +import { queryAllVpgs } from '@/api/prometheus'; +import { queryVpgDetail, queryVpgVms } from '@/api/prometheusExtended'; +import TimeSeriesChart from '@/components/charts/TimeSeriesChart'; +import RPOGauge from '@/components/charts/RPOGauge'; +import { VPG_ALERT, vpgHealth, rpoStatus, formatRpo, formatMB, colorToText } from '@/constants/statusMaps'; +import clsx from 'clsx'; + +const REFRESH = 30_000; + +function VpgListItem({ vpg, selected, onClick }) { + const status = rpoStatus(vpg.actualRpoSec, vpg.configuredRpoSec); + return ( + + ); +} + +function VpgDetail({ vpgName }) { + const { data: detail, isLoading } = useQuery({ + queryKey: ['vpg-detail', vpgName], queryFn: () => queryVpgDetail(vpgName), refetchInterval: REFRESH, enabled: !!vpgName, + }); + const { data: vms = [], isLoading: vmsLoading } = useQuery({ + queryKey: ['vpg-vms', vpgName], queryFn: () => queryVpgVms(vpgName), refetchInterval: REFRESH, enabled: !!vpgName, + }); + + if (isLoading) return
; + if (!detail) return null; + + const alertInfo = VPG_ALERT[detail.alertStatus] ?? VPG_ALERT[0]; + const esc = vpgName.replace(/"/g, '\\"'); + + return ( +
+
+ +

{vpgName}

+ {alertInfo.label} + {detail.siteName} · {detail.vmCount} VMs +
+ +
+
+ +
+
+ {[ + { label: 'Throughput', value: `${(detail.throughputMb ?? 0).toFixed(2)} MB/s` }, + { label: 'IOPS', value: Math.round(detail.iops ?? 0) }, + { label: 'Storage', value: formatMB(detail.storageUsedMb) }, + ].map((s) => ( +
+

{s.value}

+

{s.label}

+
+ ))} +
+
+ + formatRpo(v)} + refLines={detail.configuredRpoSec ? [{ value: detail.configuredRpoSec, label: 'Target', color: 'warn' }] : []} + transform={(result) => result[0]?.values.map(([ts, v]) => ({ ts: ts * 1000, 'RPO (s)': parseFloat(v) })) ?? []} + height={180} /> + +
+

Protected VMs

+ + + + + + + + + {vmsLoading && } + {!vmsLoading && vms.map((vm) => ( + + + + + + + ))} + +
VM NameRPOThroughputIOPS
{vm.name}{formatRpo(vm.actualRpoSec)}{vm.throughputMb?.toFixed(2)} MB/s{Math.round(vm.iops ?? 0)}
+
+
+ ); +} + +export default function VPGMonitor() { + const [params] = useSearchParams(); + const [search, setSearch] = useState(''); + const initialName = params.get('name'); + + const { data: vpgs = [], isLoading } = useQuery({ + queryKey: ['all-vpgs'], queryFn: queryAllVpgs, refetchInterval: REFRESH, + }); + + const [selected, setSelected] = useState(initialName || null); + + useEffect(() => { + if (!selected && vpgs.length > 0) setSelected(vpgs[0].name); + }, [vpgs, selected]); + + const filtered = vpgs.filter((v) => !search || v.name.toLowerCase().includes(search.toLowerCase())); + + const bySite = {}; + for (const v of filtered) { const s = v.siteName || 'Unknown'; if (!bySite[s]) bySite[s] = []; bySite[s].push(v); } + + return ( +
+ +
+ {selected ? : ( +
Select a VPG to view details
+ )} +
+
+ ); +} diff --git a/zroc-ui/src/pages/VRADashboard.jsx b/zroc-ui/src/pages/VRADashboard.jsx index f54b366..8e17083 100644 --- a/zroc-ui/src/pages/VRADashboard.jsx +++ b/zroc-ui/src/pages/VRADashboard.jsx @@ -1,2 +1,147 @@ -// TODO: Full content to be added -// This file is part of the zROC project recreation +// src/pages/VRADashboard.jsx +import { useQuery } from '@tanstack/react-query'; +import { Cpu, Server, HardDrive, Layers, Loader2 } from 'lucide-react'; +import { queryAllVras } from '@/api/prometheusExtended'; +import clsx from 'clsx'; + +const REFRESH = 30_000; +const VRA_MAX_VMS = 100; +const VRA_MAX_VOL = 2048; + +function UsageBar({ label, used, total, max, unit = '', warnAt = 0.75, critAt = 0.9 }) { + const pct = total > 0 ? Math.min(used / total, 1) : max > 0 ? Math.min(used / max, 1) : 0; + const color = pct >= critAt ? 'bg-crit' : pct >= warnAt ? 'bg-warn' : 'bg-ok'; + const textC = pct >= critAt ? 'text-crit' : pct >= warnAt ? 'text-warn' : 'text-ok'; + return ( +
+
+ {label} + + {typeof used === 'number' ? `${Math.round(used)}${unit}` : '—'} + {total > 0 ? ` / ${Math.round(total)}${unit}` : max ? ` / ${max}${unit}` : ''} + +
+
+
+
+
+ ); +} + +function WorkloadBadge({ label, value, icon: Icon, color = 'text-text-secondary' }) { + return ( +
+ + {value ?? '—'} + {label} +
+ ); +} + +function VraCard({ vra }) { + const protPct = vra.protectedVms / VRA_MAX_VMS; + const recPct = vra.recoveryVms / VRA_MAX_VMS; + const alerting = protPct >= 0.9 || recPct >= 0.9; + const warning = protPct >= 0.75 || recPct >= 0.75; + + return ( +
+
+
+
+ +
+
+

{vra.name}

+

{vra.siteName}

+
+
+
+

{vra.vcpuCount} vCPU

+

{vra.memoryGb?.toFixed(0)} GB RAM

+
+
+ + {(vra.cpuUsageMhz !== undefined || vra.memUsageMb !== undefined) && ( +
+ {vra.cpuUsageMhz !== undefined && ( + + )} + {vra.memUsageMb !== undefined && ( + + )} +
+ )} + +
+

Workload

+
+ = 0.9 ? 'text-crit' : protPct >= 0.75 ? 'text-warn' : 'text-ok'} /> + = 0.9 ? 'text-crit' : recPct >= 0.75 ? 'text-warn' : 'text-accent'} /> + +
+
+ = 0.85 ? 'text-crit' : vra.protectedVolumes / VRA_MAX_VOL >= 0.7 ? 'text-warn' : 'text-text-secondary'} /> + = 0.85 ? 'text-crit' : vra.recoveryVolumes / VRA_MAX_VOL >= 0.7 ? 'text-warn' : 'text-text-secondary'} /> +
+
+ +
+ VRA {vra.version ?? '—'} + ESXi {vra.hostVersion ?? '—'} +
+
+ ); +} + +export default function VRADashboard() { + const { data: vras = [], isLoading } = useQuery({ + queryKey: ['all-vras'], queryFn: queryAllVras, refetchInterval: REFRESH, + }); + + const bySite = {}; + for (const v of vras) { + const s = v.siteName || 'Unknown'; + if (!bySite[s]) bySite[s] = []; + bySite[s].push(v); + } + + const totalProt = vras.reduce((s, v) => s + (v.protectedVms ?? 0), 0); + const totalRec = vras.reduce((s, v) => s + (v.recoveryVms ?? 0), 0); + + return ( +
+
+ {[ + { label: 'Total VRAs', value: vras.length }, + { label: 'Protected VMs', value: totalProt }, + { label: 'Recovery VMs', value: totalRec }, + ].map((s) => ( +
+

{s.value}

+

{s.label}

+
+ ))} +
+ + {isLoading && ( +
+ )} + + {Object.entries(bySite).map(([site, siteVras]) => ( +
+

{site} · {siteVras.length} VRA{siteVras.length !== 1 ? 's' : ''}

+
+ {siteVras.map((vra) => )} +
+
+ ))} +
+ ); +} From b7b9f6191d508fa33e0fff7b3bfc255d8da3f8db Mon Sep 17 00:00:00 2001 From: Justin Date: Sun, 12 Apr 2026 20:29:38 -0400 Subject: [PATCH 4/4] feat: add DR Capacity Planner, light/dark mode toggle, and PDF export - Add zroc-planner UI page with VM selector, journal retention slider (1h-30d), WAN compression input, and live bandwidth/journal/mirror storage estimates - Add CSV and PDF export for planning reports - Add light/dark mode toggle in TopBar with localStorage persistence - Wire theme via CSS custom properties for full Tailwind opacity support - Add Planner route and sidebar entry Co-Authored-By: Claude Sonnet 4.6 --- zroc-ui/package.json | 1 + zroc-ui/src/App.jsx | 5 + zroc-ui/src/api/planner.js | 37 ++ zroc-ui/src/auth/AuthContext.jsx | 5 + zroc-ui/src/auth/ThemeContext.jsx | 29 ++ zroc-ui/src/components/layout/Sidebar.jsx | 3 +- zroc-ui/src/components/layout/TopBar.jsx | 9 +- zroc-ui/src/pages/Planner.jsx | 473 ++++++++++++++++++++++ zroc-ui/src/styles/index.css | 24 ++ zroc-ui/tailwind.config.js | 16 +- 10 files changed, 592 insertions(+), 10 deletions(-) create mode 100644 zroc-ui/src/api/planner.js create mode 100644 zroc-ui/src/auth/ThemeContext.jsx create mode 100644 zroc-ui/src/pages/Planner.jsx diff --git a/zroc-ui/package.json b/zroc-ui/package.json index 13c682b..3c05be9 100644 --- a/zroc-ui/package.json +++ b/zroc-ui/package.json @@ -11,6 +11,7 @@ "dependencies": { "@tanstack/react-query": "^5.40.0", "clsx": "^2.1.1", + "jspdf": "^4.2.1", "lucide-react": "^0.395.0", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/zroc-ui/src/App.jsx b/zroc-ui/src/App.jsx index bc60d8c..8d253e3 100644 --- a/zroc-ui/src/App.jsx +++ b/zroc-ui/src/App.jsx @@ -2,6 +2,7 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { AuthProvider } from '@/auth/AuthContext'; +import { ThemeProvider } from '@/auth/ThemeContext'; import { ProtectedRoute, AdminRoute } from '@/auth/ProtectedRoute'; import AppShell from '@/components/layout/AppShell'; import Overview from '@/pages/Overview'; @@ -12,6 +13,7 @@ import Storage from '@/pages/Storage'; import UserManagement from '@/pages/Settings/UserManagement'; import VMDetail from '@/pages/VMDetail'; import Placeholder from '@/pages/Placeholder'; +import Planner from '@/pages/Planner'; const queryClient = new QueryClient({ defaultOptions: { @@ -26,6 +28,7 @@ const queryClient = new QueryClient({ export default function App() { return ( + @@ -36,6 +39,7 @@ export default function App() { } /> } /> } /> + } /> } /> + ); } diff --git a/zroc-ui/src/api/planner.js b/zroc-ui/src/api/planner.js new file mode 100644 index 0000000..39096ad --- /dev/null +++ b/zroc-ui/src/api/planner.js @@ -0,0 +1,37 @@ +// src/api/planner.js +// Queries the vcenter_vm_disk_* metrics exposed by zroc-planner collector. +import { instantQuery } from './prometheus'; + +export async function queryPlannerVms() { + const [throughput, iops, latency, provisioned] = await Promise.all([ + instantQuery('vcenter_vm_disk_write_throughput_mbps'), + instantQuery('vcenter_vm_disk_write_iops'), + instantQuery('vcenter_vm_disk_write_latency_ms'), + instantQuery('vcenter_vm_disk_provisioned_gb'), + ]); + + const byMoref = {}; + + const idx = (vec, field, transform = parseFloat) => { + for (const { metric, value } of vec) { + const id = metric.vm_moref || metric.vm_name; + if (!byMoref[id]) byMoref[id] = { + moref: metric.vm_moref || id, + name: metric.vm_name || id, + cluster: metric.cluster || '', + host: metric.host || '', + datacenter: metric.datacenter || '', + }; + byMoref[id][field] = transform(value[1]); + } + }; + + idx(throughput, 'writeThroughputMbps'); + idx(iops, 'writeIops'); + idx(latency, 'writeLatencyMs'); + idx(provisioned, 'provisionedGb'); + + return Object.values(byMoref).sort((a, b) => + (b.writeThroughputMbps ?? 0) - (a.writeThroughputMbps ?? 0) + ); +} diff --git a/zroc-ui/src/auth/AuthContext.jsx b/zroc-ui/src/auth/AuthContext.jsx index 7793aff..f36753c 100644 --- a/zroc-ui/src/auth/AuthContext.jsx +++ b/zroc-ui/src/auth/AuthContext.jsx @@ -8,6 +8,11 @@ export function AuthProvider({ children }) { const [loading, setLoading] = useState(true); const checkSession = useCallback(async () => { + if (import.meta.env.VITE_MOCK_AUTH === 'true') { + setUser({ name: 'Demo User', email: 'demo@zroc.local', role: 'admin' }); + setLoading(false); + return; + } try { const res = await fetch('/api/auth/status', { credentials: 'include' }); if (res.ok) { diff --git a/zroc-ui/src/auth/ThemeContext.jsx b/zroc-ui/src/auth/ThemeContext.jsx new file mode 100644 index 0000000..020c89f --- /dev/null +++ b/zroc-ui/src/auth/ThemeContext.jsx @@ -0,0 +1,29 @@ +// src/auth/ThemeContext.jsx +import { createContext, useContext, useState, useEffect } from 'react'; + +const ThemeContext = createContext(null); + +export function ThemeProvider({ children }) { + const [theme, setTheme] = useState(() => + localStorage.getItem('zroc-theme') || 'dark' + ); + + useEffect(() => { + document.documentElement.setAttribute('data-theme', theme); + localStorage.setItem('zroc-theme', theme); + }, [theme]); + + const toggle = () => setTheme((t) => t === 'dark' ? 'light' : 'dark'); + + return ( + + {children} + + ); +} + +export function useTheme() { + const ctx = useContext(ThemeContext); + if (!ctx) throw new Error('useTheme must be used within ThemeProvider'); + return ctx; +} diff --git a/zroc-ui/src/components/layout/Sidebar.jsx b/zroc-ui/src/components/layout/Sidebar.jsx index 6c23404..1c27e63 100644 --- a/zroc-ui/src/components/layout/Sidebar.jsx +++ b/zroc-ui/src/components/layout/Sidebar.jsx @@ -3,7 +3,7 @@ import { NavLink } from 'react-router-dom'; import { LayoutDashboard, GitFork, Server, Cpu, ShieldAlert, Database, Settings, ChevronLeft, - ChevronRight, Activity, + ChevronRight, Activity, Calculator, } from 'lucide-react'; import { useAuth } from '@/auth/AuthContext'; import clsx from 'clsx'; @@ -41,6 +41,7 @@ const NAV_ITEMS = [ { to: '/vras', label: 'VRAs', icon: Cpu }, { to: '/encryption', label: 'Encryption', icon: ShieldAlert }, { to: '/storage', label: 'Storage', icon: Database }, + { to: '/planner', label: 'Planner', icon: Calculator }, ]; const ADMIN_ITEMS = [ diff --git a/zroc-ui/src/components/layout/TopBar.jsx b/zroc-ui/src/components/layout/TopBar.jsx index c003a07..86cec39 100644 --- a/zroc-ui/src/components/layout/TopBar.jsx +++ b/zroc-ui/src/components/layout/TopBar.jsx @@ -1,8 +1,9 @@ // src/components/layout/TopBar.jsx import { useState, useEffect, useRef } from 'react'; import { useLocation } from 'react-router-dom'; -import { Menu, RefreshCw, ChevronDown, LogOut } from 'lucide-react'; +import { Menu, RefreshCw, ChevronDown, LogOut, Sun, Moon } from 'lucide-react'; import { useAuth } from '@/auth/AuthContext'; +import { useTheme } from '@/auth/ThemeContext'; import { useQueryClient } from '@tanstack/react-query'; import clsx from 'clsx'; @@ -13,6 +14,7 @@ const PAGE_TITLES = { '/vras': 'VRA Infrastructure', '/encryption': 'Encryption Detection', '/storage': 'Storage & Datastores', + '/planner': 'DR Capacity Planner', '/settings/users': 'User Management', '/settings': 'Settings', }; @@ -64,6 +66,7 @@ function UserMenu({ user, onLogout }) { export default function TopBar({ sidebarOpen, onMenuToggle }) { const { user, logout } = useAuth(); + const { theme, toggle: toggleTheme } = useTheme(); const location = useLocation(); const queryClient = useQueryClient(); const [refreshing, setRefreshing] = useState(false); @@ -91,6 +94,10 @@ export default function TopBar({ sidebarOpen, onMenuToggle }) { className="p-1.5 rounded text-text-muted hover:text-accent hover:bg-accent/10 transition-all duration-150"> + {user && }
diff --git a/zroc-ui/src/pages/Planner.jsx b/zroc-ui/src/pages/Planner.jsx new file mode 100644 index 0000000..bb282fe --- /dev/null +++ b/zroc-ui/src/pages/Planner.jsx @@ -0,0 +1,473 @@ +// src/pages/Planner.jsx — DR capacity planner +import { useState, useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { Calculator, HardDrive, Wifi, Database, Download, Search, FileText } from 'lucide-react'; +import { queryPlannerVms } from '@/api/planner'; +import clsx from 'clsx'; +import { jsPDF } from 'jspdf'; + +const REFRESH = 60_000; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function fmtGb(gb) { + if (gb == null || isNaN(gb)) return '—'; + if (gb >= 1024) return `${(gb / 1024).toFixed(2)} TB`; + return `${gb.toFixed(1)} GB`; +} + +function fmtMbps(mbps) { + if (mbps == null || isNaN(mbps)) return '—'; + if (mbps >= 1000) return `${(mbps / 1000).toFixed(2)} Gbps`; + return `${mbps.toFixed(1)} Mbps`; +} + +const JOURNAL_OPTIONS = [ + { label: '1 hour', seconds: 3600 }, + { label: '4 hours', seconds: 14400 }, + { label: '8 hours', seconds: 28800 }, + ...Array.from({ length: 30 }, (_, i) => ({ + label: i === 0 ? '1 day' : `${i + 1} days`, + seconds: (i + 1) * 86400, + })), +]; + +// ── Result card ─────────────────────────────────────────────────────────────── + +function ResultCard({ icon: Icon, label, value, sub, color = 'accent' }) { + return ( +
+
+ +
+
+

{label}

+

{value}

+ {sub &&

{sub}

} +
+
+ ); +} + +// ── VM row ──────────────────────────────────────────────────────────────────── + +function VmRow({ vm, selected, onToggle }) { + return ( + + + e.stopPropagation()} + className="accent-accent" + /> + + {vm.name} + {vm.cluster || '—'} + {vm.datacenter || '—'} + {fmtGb(vm.provisionedGb)} + {fmtMbps(vm.writeThroughputMbps)} + + {vm.writeIops != null ? vm.writeIops.toFixed(0) : '—'} + + + ); +} + +// ── Mock data for preview ───────────────────────────────────────────────────── + +const MOCK_VMS = [ + { moref: 'vm-101', name: 'web-prod-01', cluster: 'Cluster-01', datacenter: 'DC-East', provisionedGb: 120, writeThroughputMbps: 45.2, writeIops: 1820, writeLatencyMs: 3.1 }, + { moref: 'vm-102', name: 'db-prod-01', cluster: 'Cluster-01', datacenter: 'DC-East', provisionedGb: 2048, writeThroughputMbps: 312.8, writeIops: 12400, writeLatencyMs: 1.8 }, + { moref: 'vm-103', name: 'db-prod-02', cluster: 'Cluster-01', datacenter: 'DC-East', provisionedGb: 2048, writeThroughputMbps: 287.4, writeIops: 11200, writeLatencyMs: 2.0 }, + { moref: 'vm-104', name: 'app-prod-01', cluster: 'Cluster-02', datacenter: 'DC-East', provisionedGb: 256, writeThroughputMbps: 18.6, writeIops: 640, writeLatencyMs: 4.2 }, + { moref: 'vm-105', name: 'app-prod-02', cluster: 'Cluster-02', datacenter: 'DC-East', provisionedGb: 256, writeThroughputMbps: 21.3, writeIops: 780, writeLatencyMs: 3.9 }, + { moref: 'vm-106', name: 'cache-01', cluster: 'Cluster-02', datacenter: 'DC-East', provisionedGb: 512, writeThroughputMbps: 8.1, writeIops: 310, writeLatencyMs: 5.5 }, + { moref: 'vm-107', name: 'file-srv-01', cluster: 'Cluster-03', datacenter: 'DC-West', provisionedGb: 4096, writeThroughputMbps: 92.0, writeIops: 3200, writeLatencyMs: 6.1 }, + { moref: 'vm-108', name: 'infra-dc-01', cluster: 'Cluster-03', datacenter: 'DC-West', provisionedGb: 80, writeThroughputMbps: 2.4, writeIops: 120, writeLatencyMs: 8.2 }, + { moref: 'vm-109', name: 'backup-srv-01',cluster: 'Cluster-03', datacenter: 'DC-West', provisionedGb: 8192, writeThroughputMbps: 180.0,writeIops: 5600, writeLatencyMs: 12.0 }, + { moref: 'vm-110', name: 'mon-01', cluster: 'Cluster-01', datacenter: 'DC-East', provisionedGb: 100, writeThroughputMbps: 1.2, writeIops: 55, writeLatencyMs: 9.0 }, +]; + +// ── Main page ───────────────────────────────────────────────────────────────── + +export default function Planner() { + const [selected, setSelected] = useState(new Set()); + const [journalIdx, setJournalIdx] = useState(3); // default: 1 day + const [compression, setCompression] = useState(50); // default: 50% + const [search, setSearch] = useState(''); + + const isMock = import.meta.env.VITE_MOCK_AUTH === 'true'; + + const { data: liveVms = [], isLoading } = useQuery({ + queryKey: ['planner-vms'], + queryFn: queryPlannerVms, + refetchInterval: REFRESH, + enabled: !isMock, + }); + + const vms = isMock ? MOCK_VMS : liveVms; + + const filtered = useMemo(() => + vms.filter((vm) => + !search || vm.name.toLowerCase().includes(search.toLowerCase()) || + vm.cluster.toLowerCase().includes(search.toLowerCase()) || + vm.datacenter.toLowerCase().includes(search.toLowerCase()) + ), + [vms, search] + ); + + const toggle = (moref) => + setSelected((prev) => { + const next = new Set(prev); + next.has(moref) ? next.delete(moref) : next.add(moref); + return next; + }); + + const toggleAll = () => { + if (selected.size === filtered.length) { + setSelected(new Set()); + } else { + setSelected(new Set(filtered.map((v) => v.moref))); + } + }; + + const selectedVms = vms.filter((v) => selected.has(v.moref)); + const journalSec = JOURNAL_OPTIONS[journalIdx].seconds; + const ratio = compression / 100; + + // ── Calculations ─────────────────────────────────────────────────────────── + const totalThroughputMbps = selectedVms.reduce((s, v) => s + (v.writeThroughputMbps ?? 0), 0); + const totalProvisionedGb = selectedVms.reduce((s, v) => s + (v.provisionedGb ?? 0), 0); + + const bwRequiredMbps = totalThroughputMbps * (1 - ratio); + const journalStorageGb = (totalThroughputMbps * (1 - ratio)) * (journalSec / 1024); // MB/s → GB over period + const mirrorStorageGb = totalProvisionedGb; + const totalDrStorageGb = journalStorageGb + mirrorStorageGb; + + // ── Export ───────────────────────────────────────────────────────────────── + const exportCsv = () => { + const rows = [ + ['VM Name', 'Cluster', 'Datacenter', 'Provisioned (GB)', 'Write Throughput (Mbps)', 'Write IOPS'], + ...selectedVms.map((v) => [ + v.name, v.cluster, v.datacenter, + (v.provisionedGb ?? 0).toFixed(1), + (v.writeThroughputMbps ?? 0).toFixed(2), + (v.writeIops ?? 0).toFixed(0), + ]), + [], + ['--- Summary ---'], + ['Journal Retention', JOURNAL_OPTIONS[journalIdx].label], + ['Compression', `${compression}%`], + ['Bandwidth Required', `${fmtMbps(bwRequiredMbps)}`], + ['Journal Storage', `${fmtGb(journalStorageGb)}`], + ['Mirror Storage', `${fmtGb(mirrorStorageGb)}`], + ['Total DR Storage', `${fmtGb(totalDrStorageGb)}`], + ]; + const csv = rows.map((r) => r.map((c) => `"${c}"`).join(',')).join('\n'); + const blob = new Blob([csv], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; a.download = 'zroc-planner-report.csv'; a.click(); + URL.revokeObjectURL(url); + }; + + const exportPdf = () => { + const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' }); + const margin = 15; + const pageW = 210; + const colW = pageW - margin * 2; + let y = margin; + + // Title + doc.setFontSize(18); + doc.setFont('helvetica', 'bold'); + doc.text('zROC — DR Capacity Planner Report', margin, y); + y += 8; + doc.setFontSize(9); + doc.setFont('helvetica', 'normal'); + doc.setTextColor(100); + doc.text(`Generated: ${new Date().toLocaleString()}`, margin, y); + y += 10; + + // Summary box + doc.setDrawColor(14, 165, 233); + doc.setFillColor(240, 248, 255); + doc.roundedRect(margin, y, colW, 40, 2, 2, 'FD'); + doc.setTextColor(0); + doc.setFontSize(10); + doc.setFont('helvetica', 'bold'); + doc.text('Planning Parameters', margin + 4, y + 7); + doc.setFont('helvetica', 'normal'); + doc.setFontSize(9); + const params = [ + ['VMs selected', `${selected.size}`], + ['Journal retention', JOURNAL_OPTIONS[journalIdx].label], + ['WAN compression', `${compression}%`], + ]; + params.forEach(([label, val], i) => { + doc.setTextColor(80); doc.text(label, margin + 4, y + 15 + i * 7); + doc.setTextColor(0); doc.text(val, margin + 60, y + 15 + i * 7); + }); + y += 48; + + // Results + doc.setFont('helvetica', 'bold'); + doc.setFontSize(11); + doc.text('Capacity Estimates', margin, y); + y += 6; + const results = [ + ['Bandwidth Required', fmtMbps(bwRequiredMbps), `Raw ${fmtMbps(totalThroughputMbps)} × ${100 - compression}%`], + ['Journal Storage', fmtGb(journalStorageGb), `${JOURNAL_OPTIONS[journalIdx].label} at ${fmtMbps(bwRequiredMbps)}`], + ['Mirror Storage', fmtGb(mirrorStorageGb), 'Full copy of selected VM disks'], + ['Total DR Storage Footprint', fmtGb(totalDrStorageGb), 'Journal + Mirror combined'], + ]; + results.forEach(([label, val, note]) => { + doc.setFontSize(9); doc.setFont('helvetica', 'normal'); doc.setTextColor(80); + doc.text(label, margin, y); + doc.setFont('helvetica', 'bold'); doc.setTextColor(0); doc.setFontSize(12); + doc.text(val, margin + 70, y); + doc.setFont('helvetica', 'normal'); doc.setFontSize(8); doc.setTextColor(120); + doc.text(note, margin + 110, y); + y += 9; + }); + y += 6; + + // VM table header + doc.setFont('helvetica', 'bold'); + doc.setFontSize(11); + doc.setTextColor(0); + doc.text('Selected VMs', margin, y); + y += 5; + doc.setFillColor(230, 240, 255); + doc.rect(margin, y, colW, 6, 'F'); + doc.setFontSize(8); + ['VM Name', 'Cluster', 'Datacenter', 'Disk (GB)', 'Write BW', 'IOPS'].forEach((h, i) => { + doc.text(h, margin + [0, 50, 85, 120, 143, 163][i], y + 4); + }); + y += 7; + + // VM rows + doc.setFont('helvetica', 'normal'); + selectedVms.forEach((vm, idx) => { + if (y > 270) { doc.addPage(); y = margin; } + if (idx % 2 === 0) { doc.setFillColor(248, 250, 252); doc.rect(margin, y - 1, colW, 6, 'F'); } + doc.setTextColor(0); + doc.setFontSize(8); + [ + vm.name.slice(0, 24), + (vm.cluster || '—').slice(0, 16), + (vm.datacenter || '—').slice(0, 14), + (vm.provisionedGb ?? 0).toFixed(1), + fmtMbps(vm.writeThroughputMbps), + (vm.writeIops ?? 0).toFixed(0), + ].forEach((val, i) => doc.text(val, margin + [0, 50, 85, 120, 143, 163][i], y + 4)); + y += 6; + }); + + doc.save('zroc-planner-report.pdf'); + }; + + return ( +
+ {/* Header */} +
+
+ +

DR Capacity Planner

+
+
+ + +
+
+ +
+ {/* Left — VM selector */} +
+
+

+ Select VMs to model +

+ + {selected.size} / {vms.length} selected + +
+ + {/* Search */} +
+
+ + setSearch(e.target.value)} + placeholder="Filter VMs…" + className="w-full bg-raised border border-border rounded-md pl-7 pr-3 py-1.5 text-xs font-mono text-text-primary placeholder:text-text-muted focus:outline-none focus:border-accent" + /> +
+
+ +
+ + + + + + + + + + + + + + {isLoading && !isMock ? ( + + ) : filtered.length === 0 ? ( + + ) : ( + filtered.map((vm) => ( + toggle(vm.moref)} + /> + )) + )} + +
+ 0 && selected.size === filtered.length} + onChange={toggleAll} + className="accent-accent" + /> + VMClusterDatacenterDisk SizeWrite BWWrite IOPS
Loading VMs…
No VMs found
+
+
+ + {/* Right — inputs + results */} +
+ {/* Inputs */} +
+

+ Planning Inputs +

+ + {/* Journal retention */} +
+
+ + + {JOURNAL_OPTIONS[journalIdx].label} + +
+ setJournalIdx(Number(e.target.value))} + className="w-full accent-accent" + /> +
+ 1h8h7d15d30d +
+
+ + {/* Compression */} +
+
+ + {compression}% +
+ setCompression(Number(e.target.value))} + className="w-full accent-accent" + /> +
+ 0%40%80% +
+
+
+ + {/* Results */} +
+ {selected.size === 0 && ( +

+ Select VMs to see estimates +

+ )} + + + +
+

Total DR Storage Footprint

+

+ {fmtGb(totalDrStorageGb)} +

+

+ Journal + Mirror across {selected.size} VM{selected.size !== 1 ? 's' : ''} +

+
+
+
+
+
+ ); +} diff --git a/zroc-ui/src/styles/index.css b/zroc-ui/src/styles/index.css index e1af472..efa8a9e 100644 --- a/zroc-ui/src/styles/index.css +++ b/zroc-ui/src/styles/index.css @@ -3,6 +3,30 @@ @tailwind components; @tailwind utilities; +/* ── Theme tokens (space-separated RGB for Tailwind opacity support) ── */ +:root, +[data-theme="dark"] { + --color-canvas: 8 13 26; + --color-surface: 13 21 38; + --color-raised: 19 31 53; + --color-border: 30 45 71; + --color-border-bright: 42 64 102; + --color-text-primary: 226 232 240; + --color-text-secondary: 124 147 181; + --color-text-muted: 74 96 128; +} + +[data-theme="light"] { + --color-canvas: 240 244 248; + --color-surface: 255 255 255; + --color-raised: 248 250 252; + --color-border: 226 232 240; + --color-border-bright: 203 213 225; + --color-text-primary: 15 23 42; + --color-text-secondary: 71 85 105; + --color-text-muted: 148 163 184; +} + @layer base { html { @apply scroll-smooth; } diff --git a/zroc-ui/tailwind.config.js b/zroc-ui/tailwind.config.js index 7a048ef..58e5b4a 100644 --- a/zroc-ui/tailwind.config.js +++ b/zroc-ui/tailwind.config.js @@ -5,11 +5,11 @@ export default { theme: { extend: { colors: { - canvas: '#080d1a', - surface: '#0d1526', - raised: '#131f35', - border: '#1e2d47', - 'border-bright': '#2a4066', + canvas: 'rgb(var(--color-canvas) / )', + surface: 'rgb(var(--color-surface) / )', + raised: 'rgb(var(--color-raised) / )', + border: 'rgb(var(--color-border) / )', + 'border-bright': 'rgb(var(--color-border-bright) / )', accent: { DEFAULT: '#0ea5e9', dim: '#0284c7', @@ -20,9 +20,9 @@ export default { warn: '#f59e0b', crit: '#ef4444', info: '#818cf8', - 'text-primary': '#e2e8f0', - 'text-secondary': '#7c93b5', - 'text-muted': '#4a6080', + 'text-primary': 'rgb(var(--color-text-primary) / )', + 'text-secondary': 'rgb(var(--color-text-secondary) / )', + 'text-muted': 'rgb(var(--color-text-muted) / )', }, fontFamily: { mono: ['"IBM Plex Mono"', 'monospace'],