7 Commits

Author SHA1 Message Date
Justin cf71a06638 ova: fix packer validate errors; add KVM qcow2 artifact output
- Update ISO to ubuntu-24.04.4, hardcode SHA256 checksum (24.04.2 removed from mirrors)
- Remove headless variable (not declared in QEMU-only HCL, QEMU is always headless)
- Add qcow2-to-kvm.sh post-processor for KVM/libvirt/Proxmox deployments
- Add qcow2-to-ova.sh (converts qcow2 → stream-optimized VMDK → OVA without ovftool)
- packer validate now passes cleanly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 20:50:14 -04:00
Justin 450f50ddf4 fix: close OVA build gaps — 24.04, overlay copy, full compose stack
- Replace ubuntu-26.04 (unreleased) with ubuntu-24.04 LTS throughout
- Add file provisioner to Packer HCL to copy overlays/ into VM before
  provisioning (fixes missing zroc-setup binary in 03-setup-wizard.sh)
- Rebuild root docker-compose.yaml: full stack with env vars — Caddy,
  zroc-ui, Authentik (server + worker + postgres + redis), Prometheus,
  Grafana, Zerto exporter, Watchtower; no hardcoded credentials
- Add caddy/Caddyfile to repo root for reverse proxy / TLS
- Update 02-zroc.sh to pre-pull all service images during OVA build
- Update GitHub Actions workflow to reference ubuntu-2404.pkr.hcl

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 20:39:36 -04:00
Justin fd9a5926c0 chore: update repo references from ZertoPublic to recklessop
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 20:33:02 -04:00
Justin b7b9f6191d 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 <noreply@anthropic.com>
2026-04-12 20:29:38 -04:00
Justin 5a617fd550 feat: complete zROC project recreation — all 61 files populated
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 17:12:19 -04:00
Justin ec794996bb 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) <noreply@anthropic.com>
2026-04-12 16:28:08 -04:00
Justin 0500ac171c 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 <noreply@anthropic.com>
2026-04-12 16:20:05 -04:00
68 changed files with 6047 additions and 78 deletions
+47
View File
@@ -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
}
+256 -78
View File
@@ -1,111 +1,289 @@
version: '3.7'
---
# zROC — Zerto Resiliency Observation Console
# Full stack: Caddy (TLS), zroc-ui (React dashboard + Node backend), Authentik (SSO),
# Prometheus, Grafana, Zerto Exporter, Watchtower (auto-updates).
#
# Configuration is driven entirely by /opt/zroc/.env — see zroc-setup wizard.
version: '3.8'
networks:
front-tier:
back-tier:
auth-tier:
volumes:
prometheus_data: {}
grafana_data: {}
prometheus_data: {}
grafana_data: {}
zroc_ui_data: {}
authentik_postgres: {}
authentik_redis: {}
authentik_media: {}
caddy_data: {}
services:
# Exporter for ZVM/vCenter site 1
zertoexporter:
container_name: zvmexporter1
hostname: zvmexporter1 # this hostname will need to be set in the prometheus.yaml file as well
image: recklessop/zerto-exporter:stable
command: python python-node-exporter.py
# ── Reverse proxy / TLS termination ───────────────────────────────────────
caddy:
image: caddy:2-alpine
container_name: zroc-caddy
restart: unless-stopped
ports:
- "9999:9999"
- "80:80"
- "443:443"
volumes:
- ./zvmexporter/:/usr/src/app/logs/
- ./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
# ── zROC React UI + Node backend ──────────────────────────────────────────
zroc-ui:
image: recklessop/zroc-ui:stable
container_name: zroc-ui
restart: unless-stopped
environment:
# Site 1 configuration settings
- VERIFY_SSL=False
- ZVM_HOST=192.168.50.60
- ZVM_PORT=443
- SCRAPE_SPEED=20 #how often should the exporter scrape the Zerto API
- CLIENT_ID=api-script
- CLIENT_SECRET=js51tDM8oappYUGRJBhF7bcsedNoHA5j
- LOGLEVEL=DEBUG
- VCENTER_HOST=vcenter.local
- VCENTER_USER=administrator@vsphere.local
- VCENTER_PASSWORD=password
networks:
- back-tier
restart: always
# This is used for a second ZVM / vCenter (maybe your DR site?)
#zertoexporter2:
# container_name: zvmexporter2
# hostname: zvmexporter2
# image: recklessop/zerto-exporter:stable
# command: python python-node-exporter.py
# ports:
# - "9998:9999" # if you add a third or more exporters change the port number before the :
# volumes:
# - ./zvmexporter/:/usr/src/app/logs/
# environment:
# # Site 2 configuration settings
# - VERIFY_SSL=False
# - ZVM_HOST=192.168.50.30
# - ZVM_PORT=443
# - SCRAPE_SPEED=20 #how often should the exporter scrape the Zerto API
# - CLIENT_ID=api-script
# - CLIENT_SECRET=x2aokKGPyS1O6LCW2uNqm2tbko2PLUSn
# - LOGLEVEL=DEBUG
# - VCENTER_HOST=192.168.50.20
# - VCENTER_USER=administrator@vsphere.local
# - VCENTER_PASSWORD=password
# networks:
# - back-tier
# restart: always
prometheus:
image: prom/prometheus:v2.40.6
NODE_ENV: production
PORT: "3001"
PROMETHEUS_URL: http://zroc-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:
- ./prometheus/:/etc/prometheus/
- prometheus_data:/prometheus
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.path=/prometheus'
- '--web.console.libraries=/usr/share/prometheus/console_libraries'
- '--web.console.templates=/usr/share/prometheus/consoles'
ports:
- 9090:9090
- zroc_ui_data:/app/data
networks:
- front-tier
- back-tier
- auth-tier
depends_on:
- zroc-prometheus
- authentik-server
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3001/api/health"]
interval: 15s
timeout: 5s
retries: 3
start_period: 20s
# ── SSO — Authentik ───────────────────────────────────────────────────────
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
- ./zroc-ui/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
# ── Metrics — Zerto exporter ──────────────────────────────────────────────
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"
CLIENT_ID: ${ZVM_CLIENT_ID:-api-script}
CLIENT_SECRET: ${ZVM_CLIENT_SECRET}
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
# Optional second ZVM/vCenter site — uncomment and set ZVM2_* env vars
# zertoexporter2:
# image: recklessop/zerto-exporter:stable
# container_name: zvmexporter2
# hostname: zvmexporter2
# restart: unless-stopped
# ports:
# - "9998:9999"
# volumes:
# - ./zvmexporter:/usr/src/app/logs
# environment:
# VERIFY_SSL: "False"
# ZVM_HOST: ${ZVM2_HOST}
# ZVM_PORT: "443"
# ZVM_USERNAME: ${ZVM2_USERNAME}
# ZVM_PASSWORD: ${ZVM2_PASSWORD}
# SCRAPE_SPEED: "20"
# LOGLEVEL: INFO
# networks:
# - back-tier
# ── Metrics — Prometheus ──────────────────────────────────────────────────
zroc-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
restart: always
depends_on:
- zertoexporter
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:9090/-/healthy"]
interval: 30s
timeout: 5s
retries: 3
# ── Dashboards — Grafana ──────────────────────────────────────────────────
grafana:
image: grafana/grafana
image: grafana/grafana:10.4.2
container_name: zroc-grafana
restart: unless-stopped
user: "472"
depends_on:
- prometheus
ports:
- 3000:3000
- "3000:3000"
volumes:
- grafana_data:/var/lib/grafana
- ./grafana/provisioning/:/etc/grafana/provisioning/
- ./grafana/provisioning:/etc/grafana/provisioning:ro
environment:
- GF_SECURITY_ADMIN_PASSWORD=zertodata
- GF_USERS_ALLOW_SIGN_UP=false
- data-source-url=http://prometheus:9090
- name=Prometheus
- type=prometheus
- update-interval=10
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
restart: always
depends_on:
- zroc-prometheus
# ── Auto-updates — Watchtower ─────────────────────────────────────────────
watchtower:
image: containrrr/watchtower
container_name: zroc-watchtower
restart: unless-stopped
volumes:
- /var/run/docker.sock:/var/run/docker.sock
environment:
- WATCHTOWER_POLL_INTERVAL=360 # 1 hour
restart: always
WATCHTOWER_POLL_INTERVAL: "3600"
WATCHTOWER_CLEANUP: "true"
WATCHTOWER_INCLUDE_STOPPED: "false"
command: --label-enable
+98
View File
@@ -0,0 +1,98 @@
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-24.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-2404.pkr.hcl
- name: Validate
working-directory: packer
run: |
packer validate \
-var "vm_version=${{ steps.ver.outputs.version }}" \
-var-file=variables.pkrvars.hcl \
ubuntu-2404.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-2404.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
+64
View File
@@ -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 ""
+47
View File
@@ -0,0 +1,47 @@
# zroc-ova — zROC Appliance Builder
Packer build definitions and provisioner scripts for the **zROC Ubuntu 24.04 LTS OVA appliance**.
## What you get
A 100 GB thin-provisioned VMware OVA containing:
- Ubuntu Server 24.04 LTS
- Docker Engine + Compose plugin
- Full zROC stack (cloned from recklessop/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/recklessop/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://<appliance-ip>`
## 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
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env bash
# /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}"
+2
View File
@@ -0,0 +1,2 @@
instance-id: zroc-appliance-build
local-hostname: zroc-appliance
+82
View File
@@ -0,0 +1,82 @@
#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
+134
View File
@@ -0,0 +1,134 @@
packer {
required_version = ">= 1.10.0"
required_plugins {
qemu = {
source = "github.com/hashicorp/qemu"
version = "~> 1.0"
}
}
}
variable "ubuntu_iso_url" {
type = string
default = "https://releases.ubuntu.com/24.04/ubuntu-24.04.4-live-server-amd64.iso"
}
variable "ubuntu_iso_checksum" {
type = string
default = "sha256:e907d92eeec9df64163a7e454cbc8d7755e8ddc7ed42f99dbc80c40f1a138433"
}
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"
}
source "qemu" "ubuntu2404" {
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"
format = "qcow2"
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<wait>",
"<down><down><down><end>",
" autoinstall ds=nocloud-net;seedfrom=http://{{.HTTPIP}}:{{.HTTPPort}}/",
"<f10><wait60s>",
]
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"
}
build {
name = "zroc-appliance"
sources = ["source.qemu.ubuntu2404"]
# Copy overlay files (setup wizard binary, etc.) into the VM
provisioner "file" {
source = "../overlays/"
destination = "/tmp/overlays/"
}
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}}"
}
# Convert qcow2 → VMDK → OVA (no ovftool required)
post-processor "shell-local" {
inline = [
"bash ../scripts/qcow2-to-ova.sh ${var.output_dir}/qemu/${var.vm_name}-${var.vm_version} ${var.output_dir}/${var.vm_name}-${var.vm_version}-ubuntu-24.04-amd64.ova ${var.vm_name} ${var.vm_version}",
]
}
# Produce a KVM/libvirt/Proxmox-compatible qcow2 artifact
post-processor "shell-local" {
inline = [
"bash ../scripts/qcow2-to-kvm.sh ${var.output_dir}/qemu/${var.vm_name}-${var.vm_version} ${var.output_dir}/${var.vm_name}-${var.vm_version}-ubuntu-24.04-amd64.qcow2",
]
}
}
+11
View File
@@ -0,0 +1,11 @@
# zroc-ova/packer/variables.pkrvars.hcl
vm_version = "1.0.0"
ubuntu_iso_url = "https://releases.ubuntu.com/24.04/ubuntu-24.04.4-live-server-amd64.iso"
ubuntu_iso_checksum = "sha256:e907d92eeec9df64163a7e454cbc8d7755e8ddc7ed42f99dbc80c40f1a138433"
memory_mb = 8192
cpus = 4
disk_size_mb = 102400
output_dir = "../output"
+50
View File
@@ -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"
+47
View File
@@ -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"
+38
View File
@@ -0,0 +1,38 @@
#!/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/recklessop/zroc.git"
git clone --depth=1 "$ZROC_REPO" "$INSTALL_DIR"
# Ensure expected directories exist
mkdir -p \
"$INSTALL_DIR/certs" \
"$INSTALL_DIR/zvmexporter" \
"$INSTALL_DIR/data"
cd "$INSTALL_DIR"
# Pre-pull all container images into the OVA image layer so first-boot is fast.
# Failures are non-fatal — any missing images will be pulled on first docker compose up.
echo "==> [02-zroc] Pre-pulling container images (this may take a while)…"
docker compose pull \
caddy \
zroc-ui \
authentik-postgresql \
authentik-redis \
authentik-server \
authentik-worker \
zertoexporter \
zroc-prometheus \
grafana \
watchtower \
|| echo "[02-zroc] Warning: some images could not be pre-pulled — they will pull on first start"
chown -R zroc:zroc "$INSTALL_DIR"
echo "==> [02-zroc] Installation directory: $INSTALL_DIR"
echo "==> [02-zroc] Done"
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
# zroc-ova/scripts/03-setup-wizard.sh
set -euo pipefail
echo "==> [03-setup-wizard] Installing setup wizard"
# The Packer file provisioner copies overlays/ to /tmp/overlays/
# Mirror the full directory tree into place
cp -r /tmp/overlays/usr /
chmod 0755 /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"
+33
View File
@@ -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/recklessop/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"
+39
View File
@@ -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"
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# qcow2-to-kvm.sh — Package a QEMU qcow2 image as a KVM/libvirt/Proxmox artifact.
#
# Usage: qcow2-to-kvm.sh <qemu_output_dir/vm_name> <output.qcow2>
#
# Example:
# qcow2-to-kvm.sh ../output/qemu/zroc-appliance-1.0.0 \
# ../output/zroc-appliance-1.0.0-ubuntu-24.04-amd64.qcow2
set -euo pipefail
QEMU_VM_PATH="$1" # path to qcow2 (without extension) or directory
QCOW2_OUT="$2" # destination .qcow2 file
# ── Locate the source qcow2 ───────────────────────────────────────────────────
if [[ -f "${QEMU_VM_PATH}.qcow2" ]]; then
QCOW2_SRC="${QEMU_VM_PATH}.qcow2"
elif [[ -d "$QEMU_VM_PATH" ]]; then
QCOW2_SRC=$(find "$QEMU_VM_PATH" -name "*.qcow2" | head -1)
else
QCOW2_SRC="$QEMU_VM_PATH"
fi
if [[ -z "$QCOW2_SRC" || ! -f "$QCOW2_SRC" ]]; then
echo "ERROR: could not find qcow2 image at ${QEMU_VM_PATH}" >&2
exit 1
fi
echo "==> [qcow2-to-kvm] Source qcow2: $QCOW2_SRC"
echo "==> [qcow2-to-kvm] Output qcow2: $QCOW2_OUT"
mkdir -p "$(dirname "$QCOW2_OUT")"
# Re-encode with qemu-img to compact/sparsify and ensure compatibility.
# subformat=compressed produces a space-efficient image suitable for distribution.
echo "==> [qcow2-to-kvm] Compacting qcow2 for distribution…"
qemu-img convert \
-f qcow2 \
-O qcow2 \
-o compression_type=zlib,preallocation=off \
"$QCOW2_SRC" \
"$QCOW2_OUT"
SIZE=$(du -sh "$QCOW2_OUT" | cut -f1)
SHA=$(sha256sum "$QCOW2_OUT" | awk '{print $1}')
echo "==> [qcow2-to-kvm] qcow2 complete: $QCOW2_OUT ($SIZE)"
echo "==> [qcow2-to-kvm] SHA256: $SHA"
echo "$SHA $(basename "$QCOW2_OUT")" > "${QCOW2_OUT}.sha256"
echo "==> [qcow2-to-kvm] Done"
+177
View File
@@ -0,0 +1,177 @@
#!/usr/bin/env bash
# qcow2-to-ova.sh — Convert a QEMU qcow2 disk image to a VMware-compatible OVA
# without requiring ovftool.
#
# Usage: qcow2-to-ova.sh <qemu_output_dir/vm_name> <output.ova> <vm_display_name> <version>
#
# Example:
# qcow2-to-ova.sh ../output/qemu/zroc-appliance-1.0.0 \
# ../output/zroc-appliance-1.0.0-ubuntu-24.04-amd64.ova \
# zroc-appliance 1.0.0
set -euo pipefail
QEMU_VM_PATH="$1" # path to the qcow2 file (without extension) or directory
OVA_OUT="$2" # destination .ova file
VM_NAME="$3" # display name inside the OVF
VM_VERSION="$4" # version string
# ── Locate the qcow2 ──────────────────────────────────────────────────────────
if [[ -f "${QEMU_VM_PATH}.qcow2" ]]; then
QCOW2="${QEMU_VM_PATH}.qcow2"
elif [[ -d "$QEMU_VM_PATH" ]]; then
QCOW2=$(find "$QEMU_VM_PATH" -name "*.qcow2" | head -1)
else
# Packer QEMU plugin writes <vm_name> (no extension) as the output file
QCOW2="$QEMU_VM_PATH"
fi
if [[ -z "$QCOW2" || ! -f "$QCOW2" ]]; then
echo "ERROR: could not find qcow2 image at ${QEMU_VM_PATH}" >&2
exit 1
fi
echo "==> [qcow2-to-ova] Source qcow2: $QCOW2"
echo "==> [qcow2-to-ova] Output OVA: $OVA_OUT"
WORK_DIR=$(mktemp -d)
trap 'rm -rf "$WORK_DIR"' EXIT
VMDK_NAME="${VM_NAME}-disk1.vmdk"
OVF_NAME="${VM_NAME}.ovf"
MF_NAME="${VM_NAME}.mf"
# ── 1. Convert qcow2 → stream-optimised VMDK ─────────────────────────────────
echo "==> [qcow2-to-ova] Converting qcow2 → VMDK (stream-optimised)…"
qemu-img convert \
-f qcow2 \
-O vmdk \
-o subformat=streamOptimized,adapter_type=lsilogic,compat6 \
"$QCOW2" \
"${WORK_DIR}/${VMDK_NAME}"
DISK_SIZE_BYTES=$(qemu-img info --output=json "${WORK_DIR}/${VMDK_NAME}" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['virtual-size'])")
DISK_SIZE_GB=$(( (DISK_SIZE_BYTES + 1073741823) / 1073741824 ))
DISK_FILE_BYTES=$(stat -c%s "${WORK_DIR}/${VMDK_NAME}")
echo "==> [qcow2-to-ova] VMDK: virtual=${DISK_SIZE_GB}GB, file=${DISK_FILE_BYTES} bytes"
# ── 2. Generate OVF descriptor ────────────────────────────────────────────────
echo "==> [qcow2-to-ova] Generating OVF descriptor…"
cat > "${WORK_DIR}/${OVF_NAME}" << OVFEOF
<?xml version="1.0" encoding="UTF-8"?>
<Envelope xmlns="http://schemas.dmtf.org/ovf/envelope/1"
xmlns:cim="http://schemas.dmtf.org/wbem/wscim/1/common"
xmlns:ovf="http://schemas.dmtf.org/ovf/envelope/1"
xmlns:rasd="http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_ResourceAllocationSettingData"
xmlns:vmw="http://www.vmware.com/schema/ovf"
xmlns:vssd="http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_VirtualSystemSettingData"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<References>
<File ovf:href="${VMDK_NAME}" ovf:id="file1" ovf:size="${DISK_FILE_BYTES}"/>
</References>
<DiskSection>
<Info>Virtual disk information</Info>
<Disk ovf:capacity="${DISK_SIZE_GB}" ovf:capacityAllocationUnits="byte * 2^30"
ovf:diskId="vmdisk1" ovf:fileRef="file1"
ovf:format="http://www.vmware.com/interfaces/specifications/vmdk.html#streamOptimized"
ovf:populatedSize="${DISK_FILE_BYTES}"/>
</DiskSection>
<NetworkSection>
<Info>The list of logical networks</Info>
<Network ovf:name="VM Network">
<Description>VM Network</Description>
</Network>
</NetworkSection>
<VirtualSystem ovf:id="${VM_NAME}">
<Info>zROC Observability Console Appliance v${VM_VERSION}</Info>
<Name>${VM_NAME}</Name>
<AnnotationSection>
<Info>A human-readable annotation</Info>
<Annotation>zROC Appliance v${VM_VERSION} — https://github.com/recklessop/zroc</Annotation>
</AnnotationSection>
<OperatingSystemSection ovf:id="94" vmw:osType="ubuntu64Guest">
<Info>The kind of installed guest operating system</Info>
<Description>Ubuntu Linux (64-bit)</Description>
</OperatingSystemSection>
<VirtualHardwareSection>
<Info>Virtual hardware requirements</Info>
<System>
<vssd:ElementName>Virtual Hardware Family</vssd:ElementName>
<vssd:InstanceID>0</vssd:InstanceID>
<vssd:VirtualSystemIdentifier>${VM_NAME}</vssd:VirtualSystemIdentifier>
<vssd:VirtualSystemType>vmx-19</vssd:VirtualSystemType>
</System>
<Item>
<rasd:AllocationUnits>hertz * 10^6</rasd:AllocationUnits>
<rasd:Description>Number of virtual CPUs</rasd:Description>
<rasd:ElementName>4 virtual CPU(s)</rasd:ElementName>
<rasd:InstanceID>1</rasd:InstanceID>
<rasd:ResourceType>3</rasd:ResourceType>
<rasd:VirtualQuantity>4</rasd:VirtualQuantity>
</Item>
<Item>
<rasd:AllocationUnits>byte * 2^20</rasd:AllocationUnits>
<rasd:Description>Memory Size</rasd:Description>
<rasd:ElementName>8192 MB of memory</rasd:ElementName>
<rasd:InstanceID>2</rasd:InstanceID>
<rasd:ResourceType>4</rasd:ResourceType>
<rasd:VirtualQuantity>8192</rasd:VirtualQuantity>
</Item>
<Item>
<rasd:Address>0</rasd:Address>
<rasd:Description>SCSI Controller</rasd:Description>
<rasd:ElementName>SCSI Controller 0</rasd:ElementName>
<rasd:InstanceID>3</rasd:InstanceID>
<rasd:ResourceSubType>lsilogic</rasd:ResourceSubType>
<rasd:ResourceType>6</rasd:ResourceType>
</Item>
<Item>
<rasd:AddressOnParent>0</rasd:AddressOnParent>
<rasd:ElementName>Hard Disk 1</rasd:ElementName>
<rasd:HostResource>ovf:/disk/vmdisk1</rasd:HostResource>
<rasd:InstanceID>4</rasd:InstanceID>
<rasd:Parent>3</rasd:Parent>
<rasd:ResourceType>17</rasd:ResourceType>
</Item>
<Item>
<rasd:AddressOnParent>7</rasd:AddressOnParent>
<rasd:AutomaticAllocation>true</rasd:AutomaticAllocation>
<rasd:Connection>VM Network</rasd:Connection>
<rasd:Description>VmxNet3 ethernet adapter</rasd:Description>
<rasd:ElementName>Network Adapter 1</rasd:ElementName>
<rasd:InstanceID>5</rasd:InstanceID>
<rasd:ResourceSubType>VmxNet3</rasd:ResourceSubType>
<rasd:ResourceType>10</rasd:ResourceType>
</Item>
</VirtualHardwareSection>
</VirtualSystem>
</Envelope>
OVFEOF
# ── 3. Generate manifest (.mf) with SHA256 checksums ─────────────────────────
echo "==> [qcow2-to-ova] Generating manifest…"
OVF_SHA=$(sha256sum "${WORK_DIR}/${OVF_NAME}" | awk '{print $1}')
VMDK_SHA=$(sha256sum "${WORK_DIR}/${VMDK_NAME}" | awk '{print $1}')
cat > "${WORK_DIR}/${MF_NAME}" << MFEOF
SHA256(${OVF_NAME})= ${OVF_SHA}
SHA256(${VMDK_NAME})= ${VMDK_SHA}
MFEOF
# ── 4. Package as OVA (tar, OVF first per spec) ───────────────────────────────
echo "==> [qcow2-to-ova] Packaging OVA…"
mkdir -p "$(dirname "$OVA_OUT")"
tar -C "$WORK_DIR" \
--format=ustar \
-cf "$OVA_OUT" \
"${OVF_NAME}" \
"${VMDK_NAME}" \
"${MF_NAME}"
OVA_SIZE=$(du -sh "$OVA_OUT" | cut -f1)
OVA_SHA=$(sha256sum "$OVA_OUT" | awk '{print $1}')
echo "==> [qcow2-to-ova] OVA complete: $OVA_OUT ($OVA_SIZE)"
echo "==> [qcow2-to-ova] SHA256: $OVA_SHA"
echo "$OVA_SHA $(basename "$OVA_OUT")" > "${OVA_OUT}.sha256"
echo "==> [qcow2-to-ova] Done"
+60
View File
@@ -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
+64
View File
@@ -0,0 +1,64 @@
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"
+65
View File
@@ -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/recklessop/zroc.git
cd zroc
cp .env.example .env
# Edit .env with your ZVM credentials and secrets
docker compose up -d
```
Then visit `https://<your-host>` — 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/recklessop/zroc](https://github.com/recklessop/zroc)
- Zerto Exporter: [github.com/recklessop/Zerto_Exporter](https://github.com/recklessop/Zerto_Exporter)
- OVA Appliance: [github.com/recklessop/zroc-ova](https://github.com/recklessop/zroc-ova)
## License
Apache 2.0 — open source, not officially supported by Zerto/HPE.
+42
View File
@@ -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"]
+50
View File
@@ -0,0 +1,50 @@
# 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/recklessop/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
@@ -0,0 +1,110 @@
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
+148
View File
@@ -0,0 +1,148 @@
// 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,
};
+41
View File
@@ -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;
+18
View File
@@ -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;
@@ -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 };
+28
View File
@@ -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"
}
}
+117
View File
@@ -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;
+138
View File
@@ -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;
+26
View File
@@ -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;
+75
View File
@@ -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}`);
});
+47
View File
@@ -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
}
+252
View File
@@ -0,0 +1,252 @@
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
+21
View File
@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>zROC — Zerto Resiliency Observation Console</title>
<!-- Fonts: IBM Plex Mono (headings) + DM Sans (body) + JetBrains Mono (data) -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=DM+Sans:wght@300;400;500;600&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
</head>
<body class="bg-canvas text-text-primary font-sans antialiased">
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+30
View File
@@ -0,0 +1,30 @@
{
"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",
"jspdf": "^4.2.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"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+17
View File
@@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<!-- Background -->
<rect width="32" height="32" rx="6" fill="#0d1526"/>
<!-- Border -->
<rect x="1" y="1" width="30" height="30" rx="5.5" stroke="#0ea5e9" stroke-width="1" stroke-opacity="0.4"/>
<!-- Activity line (zROC pulse) -->
<polyline
points="4,18 8,18 10,10 12,24 15,14 17,20 19,16 21,16 24,16 28,16"
stroke="#0ea5e9"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"
/>
<!-- Live dot -->
<circle cx="28" cy="16" r="2.5" fill="#10b981"/>
</svg>

After

Width:  |  Height:  |  Size: 594 B

+57
View File
@@ -0,0 +1,57 @@
// 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 { ThemeProvider } from '@/auth/ThemeContext';
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';
import Planner from '@/pages/Planner';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 2,
staleTime: 15_000,
refetchOnWindowFocus: true,
},
},
});
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<AuthProvider>
<BrowserRouter>
<Routes>
<Route element={<ProtectedRoute><AppShell /></ProtectedRoute>}>
<Route index element={<Overview />} />
<Route path="vpgs" element={<VPGMonitor />} />
<Route path="vms" element={<VMDetail />} />
<Route path="vras" element={<VRADashboard />} />
<Route path="encryption" element={<EncryptionPage />} />
<Route path="storage" element={<Storage />} />
<Route path="planner" element={<Planner />} />
<Route path="settings">
<Route index element={<Navigate to="users" replace />} />
<Route path="users" element={
<AdminRoute><UserManagement /></AdminRoute>
} />
</Route>
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>
</AuthProvider>
</ThemeProvider>
</QueryClientProvider>
);
}
+37
View File
@@ -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)
);
}
+191
View File
@@ -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];
}
+248
View File
@@ -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));
}
+46
View File
@@ -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`),
};
+68
View File
@@ -0,0 +1,68 @@
// 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 () => {
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) {
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 (
<AuthContext.Provider value={{ user, loading, login, logout, isAdmin, isViewer }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
return ctx;
}
+55
View File
@@ -0,0 +1,55 @@
// src/auth/ProtectedRoute.jsx
import { useEffect } from 'react';
import { useAuth } from './AuthContext';
function LoadingScreen() {
return (
<div className="flex items-center justify-center h-screen bg-canvas">
<div className="flex flex-col items-center gap-4">
<div className="w-8 h-8 border-2 border-border border-t-accent rounded-full animate-spin" />
<p className="font-mono text-xs text-text-muted uppercase tracking-widest">
Verifying session
</p>
</div>
</div>
);
}
export function ProtectedRoute({ children }) {
const { user, loading, login } = useAuth();
useEffect(() => {
if (!loading && !user) login();
}, [loading, user, login]);
if (loading) return <LoadingScreen />;
if (!user) return <LoadingScreen />;
return children;
}
export function AdminRoute({ children }) {
const { user, loading, login, isAdmin } = useAuth();
useEffect(() => {
if (!loading && !user) login();
}, [loading, user, login]);
if (loading) return <LoadingScreen />;
if (!user) return <LoadingScreen />;
if (!isAdmin) {
return (
<div className="flex items-center justify-center h-screen bg-canvas">
<div className="card p-10 text-center max-w-sm">
<p className="font-mono text-crit text-lg mb-2">403</p>
<p className="text-text-secondary text-sm">
This page requires administrator privileges.
</p>
</div>
</div>
);
}
return children;
}
+29
View File
@@ -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 (
<ThemeContext.Provider value={{ theme, toggle }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
return ctx;
}
@@ -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 (
<div className="flex flex-col items-center" style={{ width: size }}>
<svg
viewBox="0 0 140 100"
width={size}
height={size * (100 / 140)}
className="overflow-visible"
>
<path d={bgPath} fill="none" stroke="#1e2d47" strokeWidth={SW} strokeLinecap="round" />
{fillPath && (
<path d={fillPath} fill="none" stroke={stroke} strokeWidth={SW} strokeLinecap="round"
style={{ filter: `drop-shadow(0 0 4px ${stroke}60)`, transition: 'all 0.6s ease-out' }}
/>
)}
{fillPath && ratio !== null && (
(() => {
const tip = polarToCartesian(CX, CY, R, Math.min(fillAngle, END_ANGLE - 0.5));
return (
<circle cx={tip.x} cy={tip.y} r={4} fill={stroke}
style={{ filter: `drop-shadow(0 0 6px ${stroke})` }} />
);
})()
)}
<text x={CX} y={CY - 6} textAnchor="middle"
fill={stroke}
fontSize={actualSec != null ? 18 : 14}
fontFamily="JetBrains Mono, monospace"
fontWeight="600"
>
{actualSec != null ? formatRpo(actualSec) : '—'}
</text>
{configuredSec && (
<text x={CX} y={CY + 10} textAnchor="middle"
fill="#4a6080" fontSize={8} fontFamily="JetBrains Mono, monospace">
/ {formatRpo(configuredSec)} target
</text>
)}
{pct !== null && (
<text x={CX} y={CY + 22} textAnchor="middle"
fill={stroke} fontSize={9} fontFamily="JetBrains Mono, monospace">
{pct > 100
? `${pct - 100}% over`
: `${100 - pct}% headroom`}
</text>
)}
</svg>
<p className="section-title mt-1">{label}</p>
</div>
);
}
@@ -0,0 +1,152 @@
// 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 (
<div className="bg-raised border border-border-bright rounded-lg px-3 py-2 shadow-panel text-xs">
<p className="text-text-muted font-mono mb-2">{ts}</p>
{payload.map((p) => (
<div key={p.dataKey} className="flex items-center gap-2 mb-0.5">
<span className="w-2 h-2 rounded-full flex-shrink-0" style={{ background: p.color }} />
<span className="text-text-secondary">{p.name}:</span>
<span className="font-mono font-semibold text-text-primary">
{formatter ? formatter(p.value, p.dataKey) : p.value}
</span>
</div>
))}
</div>
);
}
function WindowSelector({ value, onChange }) {
return (
<div className="flex items-center gap-0.5 bg-canvas rounded-md p-0.5 border border-border">
{WINDOWS.map((w) => (
<button key={w} onClick={() => onChange(w)}
className={clsx('px-2.5 py-1 rounded text-xs font-mono transition-all duration-150',
value === w ? 'bg-accent/20 text-accent border border-accent/30' : 'text-text-muted hover:text-text-primary hover:bg-raised')}>
{w}
</button>
))}
</div>
);
}
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 (
<div className="card p-4 flex flex-col gap-3">
<div className="flex items-center justify-between flex-wrap gap-2">
{title && <p className="section-title">{title}</p>}
{showWindow && <WindowSelector value={window} onChange={setWindow} />}
</div>
<div style={{ height }} className="relative">
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center">
<Loader2 size={18} className="animate-spin text-text-muted" />
</div>
)}
{error && (
<div className="absolute inset-0 flex items-center justify-center">
<p className="text-xs text-crit font-mono">Query failed</p>
</div>
)}
{!isLoading && !error && data.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center">
<p className="text-xs text-text-muted font-mono">No data</p>
</div>
)}
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={data} margin={{ top: 4, right: 4, left: yLabel ? 16 : 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(30,45,71,0.8)" vertical={false} />
<XAxis dataKey="ts" type="number" domain={['dataMin', 'dataMax']}
tickFormatter={makeTimeTick(window)}
tick={{ fontSize: 10, fill: '#4a6080', fontFamily: 'JetBrains Mono' }}
axisLine={{ stroke: '#1e2d47' }} tickLine={false} scale="time" />
<YAxis tickFormatter={(v) => yFormatter ? yFormatter(v) : v}
tick={{ fontSize: 10, fill: '#4a6080', fontFamily: 'JetBrains Mono' }}
axisLine={false} tickLine={false} width={yLabel ? 60 : 40} />
<Tooltip content={<CustomTooltip formatter={yFormatter} timeFormat={makeTooltipTimeFormat(window)} />}
cursor={{ stroke: '#2a4066', strokeWidth: 1, strokeDasharray: '4 2' }} />
{refLines.map((rl) => (
<ReferenceLine key={rl.label} y={rl.value}
stroke={SERIES_COLORS[rl.color] || rl.color || '#f59e0b'}
strokeDasharray="6 3" strokeWidth={1.5} />
))}
{(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 (
<Area key={key} type="monotone" dataKey={key} name={s.name || key}
stroke={color} strokeWidth={2} fill={color} fillOpacity={0.08}
dot={false} activeDot={{ r: 3 }} connectNulls />
);
})}
</ComposedChart>
</ResponsiveContainer>
</div>
</div>
);
}
@@ -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 (
<div className="flex h-screen overflow-hidden bg-canvas">
{/* Sidebar */}
<Sidebar open={sidebarOpen} onToggle={() => setSidebarOpen((v) => !v)} />
{/* Main area */}
<div className="flex flex-col flex-1 min-w-0 overflow-hidden">
<TopBar sidebarOpen={sidebarOpen} onMenuToggle={() => setSidebarOpen((v) => !v)} />
<main className="flex-1 overflow-y-auto p-6">
<Outlet />
</main>
</div>
</div>
);
}
+122
View File
@@ -0,0 +1,122 @@
// src/components/layout/Sidebar.jsx
import { NavLink } from 'react-router-dom';
import {
LayoutDashboard, GitFork, Server, Cpu,
ShieldAlert, Database, Settings, ChevronLeft,
ChevronRight, Activity, Calculator,
} from 'lucide-react';
import { useAuth } from '@/auth/AuthContext';
import clsx from 'clsx';
function ZrocLogo({ collapsed }) {
return (
<div className={clsx(
'flex items-center gap-2.5 px-4 h-14 border-b border-border flex-shrink-0',
collapsed && 'justify-center px-0',
)}>
<div className="relative flex-shrink-0">
<div className="w-7 h-7 border border-accent rounded-sm flex items-center justify-center bg-accent/10 shadow-glow-sm">
<Activity size={14} className="text-accent" />
</div>
<span className="absolute -top-0.5 -right-0.5 w-2 h-2 bg-ok rounded-full shadow-glow-ok animate-pulse-led" />
</div>
{!collapsed && (
<div>
<p className="font-mono text-sm font-semibold text-text-primary leading-none">
z<span className="text-accent">ROC</span>
</p>
<p className="font-mono text-[9px] text-text-muted leading-none mt-0.5 uppercase tracking-widest">
Observability Console
</p>
</div>
)}
</div>
);
}
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 },
{ to: '/planner', label: 'Planner', icon: Calculator },
];
const ADMIN_ITEMS = [
{ to: '/settings/users', label: 'Users', icon: Settings },
];
function NavItem({ to, label, icon: Icon, collapsed, exact }) {
return (
<NavLink
to={to}
end={exact}
className={({ isActive }) =>
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 }) => (
<>
<Icon size={16} className={clsx('flex-shrink-0 transition-colors', isActive ? 'text-accent' : 'group-hover:text-text-primary')} />
{!collapsed && <span className="font-medium">{label}</span>}
{isActive && !collapsed && <span className="ml-auto w-1 h-1 rounded-full bg-accent" />}
{collapsed && (
<span className="absolute left-full ml-2 px-2 py-1 bg-raised border border-border rounded text-xs text-text-primary whitespace-nowrap opacity-0 group-hover:opacity-100 pointer-events-none z-50 transition-opacity duration-150 shadow-panel">
{label}
</span>
)}
</>
)}
</NavLink>
);
}
export default function Sidebar({ open, onToggle }) {
const { isAdmin } = useAuth();
const collapsed = !open;
return (
<aside className={clsx(
'flex flex-col bg-surface border-r border-border flex-shrink-0 transition-all duration-200 ease-in-out',
collapsed ? 'w-14' : 'w-56',
)}>
<ZrocLogo collapsed={collapsed} />
<nav className="flex-1 px-2 py-3 space-y-0.5 overflow-y-auto overflow-x-hidden">
<div className={clsx(!collapsed && 'mb-1')}>
{!collapsed && <p className="section-title px-3 mb-2">Monitor</p>}
{NAV_ITEMS.map((item) => (
<NavItem key={item.to} {...item} collapsed={collapsed} />
))}
</div>
{isAdmin && (
<div className={clsx(!collapsed && 'pt-3 mt-3 border-t border-border')}>
{collapsed && <div className="border-t border-border my-2 mx-2" />}
{!collapsed && <p className="section-title px-3 mb-2">Admin</p>}
{ADMIN_ITEMS.map((item) => (
<NavItem key={item.to} {...item} collapsed={collapsed} />
))}
</div>
)}
</nav>
<button
onClick={onToggle}
className="flex items-center justify-center h-10 border-t border-border text-text-muted hover:text-text-primary hover:bg-raised transition-colors duration-150 flex-shrink-0"
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
{collapsed ? <ChevronRight size={14} /> : <ChevronLeft size={14} />}
</button>
</aside>
);
}
+105
View File
@@ -0,0 +1,105 @@
// src/components/layout/TopBar.jsx
import { useState, useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';
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';
const PAGE_TITLES = {
'/': 'Overview',
'/vpgs': 'VPG Monitor',
'/vms': 'VM Protection',
'/vras': 'VRA Infrastructure',
'/encryption': 'Encryption Detection',
'/storage': 'Storage & Datastores',
'/planner': 'DR Capacity Planner',
'/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 (
<div ref={ref} className="relative">
<button onClick={() => setOpen((v) => !v)}
className="flex items-center gap-2 pl-3 pr-2 py-1.5 rounded-md hover:bg-raised border border-transparent hover:border-border transition-all duration-150">
<div className="w-7 h-7 rounded-full bg-accent/20 text-accent flex items-center justify-center font-mono text-xs font-semibold">
{initials}
</div>
<div className="hidden sm:block text-left">
<p className="text-xs font-medium text-text-primary leading-none">{user.name || user.username}</p>
<p className="text-[10px] text-text-muted font-mono leading-none mt-0.5 capitalize">{user.role}</p>
</div>
<ChevronDown size={12} className="text-text-muted" />
</button>
{open && (
<div className="absolute right-0 top-full mt-1 w-52 card-raised shadow-panel z-50 py-1 animate-fade-in">
<div className="px-3 py-2 border-b border-border">
<p className="text-xs font-medium text-text-primary">{user.name}</p>
<p className="text-[10px] text-text-muted font-mono">{user.email}</p>
</div>
<button onClick={() => { setOpen(false); onLogout(); }}
className="flex w-full items-center gap-2 px-3 py-2 text-sm text-text-secondary hover:text-crit hover:bg-crit/5 transition-colors">
<LogOut size={13} />
Sign out
</button>
</div>
)}
</div>
);
}
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);
const title = PAGE_TITLES[location.pathname] ?? 'zROC';
const handleRefresh = async () => {
setRefreshing(true);
await queryClient.invalidateQueries();
setTimeout(() => setRefreshing(false), 800);
};
return (
<header className="h-14 flex items-center justify-between px-4 border-b border-border bg-surface flex-shrink-0">
<div className="flex items-center gap-3">
<button onClick={onMenuToggle}
className="p-1.5 rounded text-text-muted hover:text-text-primary hover:bg-raised transition-colors md:hidden">
<Menu size={16} />
</button>
<h2 className="font-mono text-sm font-semibold text-text-primary">{title}</h2>
</div>
<div className="flex items-center gap-2">
<button onClick={handleRefresh} title="Refresh all data"
className="p-1.5 rounded text-text-muted hover:text-accent hover:bg-accent/10 transition-all duration-150">
<RefreshCw size={14} className={clsx(refreshing && 'animate-spin text-accent')} />
</button>
<button onClick={toggleTheme} title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
className="p-1.5 rounded text-text-muted hover:text-accent hover:bg-accent/10 transition-all duration-150">
{theme === 'dark' ? <Sun size={14} /> : <Moon size={14} />}
</button>
{user && <UserMenu user={user} onLogout={logout} />}
</div>
</header>
);
}
+89
View File
@@ -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';
}
+18
View File
@@ -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,
});
}
+42
View File
@@ -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,
});
}
+11
View File
@@ -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(
<StrictMode>
<App />
</StrictMode>
);
+130
View File
@@ -0,0 +1,130 @@
// 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 (
<div className="flex items-center gap-2 w-full">
<div className="flex-1 h-2 bg-border rounded-full overflow-hidden flex">
<div className={clsx('h-full transition-all duration-500', color)} style={{ width: `${enc}%` }} />
</div>
<span className={clsx('font-mono text-xs data-value w-12 text-right flex-shrink-0', textC)}>
{enc.toFixed(1)}%
</span>
</div>
);
}
function TrendBadge({ level }) {
const l = level ?? 0;
if (l === 0) return <span className="badge badge-ok">Stable</span>;
if (l === 1) return <span className="badge badge-warn">Rising</span>;
return <span className="badge badge-crit">Spike</span>;
}
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 (
<div className="space-y-6 animate-fade-in">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[
{ 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) => (
<div key={s.label} className="card p-4 flex items-start gap-3">
<div className={clsx('w-9 h-9 rounded-lg flex items-center justify-center',
s.color === 'ok' && 'bg-ok/10', s.color === 'warn' && 'bg-warn/10',
s.color === 'crit' && 'bg-crit/10', s.color === 'accent' && 'bg-accent/10')}>
<s.icon size={16} className={clsx(
s.color === 'ok' && 'text-ok', s.color === 'warn' && 'text-warn',
s.color === 'crit' && 'text-crit', s.color === 'accent' && 'text-accent')} />
</div>
<div>
<p className="section-title">{s.label}</p>
<p className="font-data text-xl font-semibold text-text-primary data-value mt-0.5">{s.value}</p>
</div>
</div>
))}
</div>
{anomalies.length > 0 && (
<TimeSeriesChart
title="Encryption % Over Time — Top Anomalies"
promql={`vm_PercentEncrypted{VmName="${anomalies[0]?.name?.replace(/"/g, '\\"')}"}`}
yFormatter={(v) => `${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}
/>
)}
<section>
<p className="section-title mb-3">VM Encryption Status</p>
<div className="card overflow-hidden">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border">
<th className="px-4 py-2.5 text-left section-title">VM</th>
<th className="px-4 py-2.5 text-left section-title">VPG</th>
<th className="px-4 py-2.5 text-left section-title">Encryption %</th>
<th className="px-4 py-2.5 text-left section-title hidden md:table-cell">Trend</th>
<th className="px-4 py-2.5 text-right section-title hidden lg:table-cell">IO Ops</th>
<th className="px-4 py-2.5 text-right section-title hidden lg:table-cell">Write</th>
</tr>
</thead>
<tbody>
{isLoading && (
<tr><td colSpan={6} className="py-10 text-center">
<Loader2 size={16} className="animate-spin text-text-muted mx-auto" />
</td></tr>
)}
{!isLoading && vms.map((vm) => (
<tr key={vm.id} className="border-b border-border/40 last:border-0 hover:bg-raised transition-colors">
<td className="px-4 py-2.5 font-medium text-text-primary">{vm.name}</td>
<td className="px-4 py-2.5 text-text-muted">{vm.vpgName}</td>
<td className="px-4 py-2.5 min-w-[160px]"><EncryptionBar pct={vm.pctEncrypted} /></td>
<td className="px-4 py-2.5 hidden md:table-cell"><TrendBadge level={vm.trendLevel} /></td>
<td className="px-4 py-2.5 text-right hidden lg:table-cell">
<span className="font-mono data-value text-text-secondary">
{vm.ioOps != null ? Math.round(vm.ioOps).toLocaleString() : '—'}
</span>
</td>
<td className="px-4 py-2.5 text-right hidden lg:table-cell">
<span className="font-mono data-value text-text-secondary">
{vm.writeMb != null ? `${vm.writeMb.toFixed(2)} MB` : '—'}
</span>
</td>
</tr>
))}
{!isLoading && vms.length === 0 && (
<tr><td colSpan={6} className="py-12 text-center">
<ShieldCheck size={24} className="text-ok mx-auto mb-2" />
<p className="text-text-muted">No encryption stats available</p>
</td></tr>
)}
</tbody>
</table>
</div>
</section>
</div>
);
}
+187
View File
@@ -0,0 +1,187 @@
// 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 (
<div className="card p-4 flex items-start gap-4">
<div className={clsx('w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0',
`bg-${color}/10`)}>
<Icon size={18} className={`text-${color}`} />
</div>
<div className="min-w-0">
<p className="section-title">{label}</p>
<p className="font-data text-2xl font-semibold text-text-primary mt-0.5 data-value">{value ?? '—'}</p>
{sub && <p className="text-xs text-text-muted mt-0.5">{sub}</p>}
</div>
</div>
);
}
function SiteCard({ site }) {
const hasCrit = site.crit > 0;
return (
<div className={clsx('card p-4 border transition-colors duration-300',
hasCrit ? 'border-crit/30' : site.warn > 0 ? 'border-warn/30' : 'border-border')}>
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2">
<span className={clsx('status-dot', hasCrit ? 'status-dot-crit' : site.warn > 0 ? 'status-dot-warn' : 'status-dot-ok')} />
<p className="font-mono text-sm font-semibold text-text-primary">{site.siteName}</p>
</div>
<span className={clsx('badge text-xs', hasCrit ? 'badge-crit' : site.warn > 0 ? 'badge-warn' : 'badge-ok')}>
{hasCrit ? 'Alert' : site.warn > 0 ? 'Warning' : 'Healthy'}
</span>
</div>
<div className="grid grid-cols-3 gap-2 text-center">
<div><p className="font-data text-xl font-semibold text-ok data-value">{site.ok}</p><p className="section-title">OK</p></div>
<div><p className="font-data text-xl font-semibold text-warn data-value">{site.warn}</p><p className="section-title">Warn</p></div>
<div><p className="font-data text-xl font-semibold text-crit data-value">{site.crit}</p><p className="section-title">Crit</p></div>
</div>
</div>
);
}
function VpgTile({ vpg, onClick }) {
const status = rpoStatus(vpg.actualRpoSec, vpg.configuredRpoSec);
return (
<button onClick={onClick}
title={`${vpg.name}\nRPO: ${formatRpo(vpg.actualRpoSec)}`}
className={clsx('relative p-2 rounded-md border text-left transition-all duration-200 hover:scale-105 hover:z-10',
status === 'ok' && 'bg-ok/8 border-ok/20',
status === 'warn' && 'bg-warn/8 border-warn/20',
status === 'crit' && 'bg-crit/8 border-crit/20',
status === 'muted' && 'bg-raised border-border')}>
<p className={clsx('text-[10px] font-mono font-semibold truncate leading-tight',
status === 'ok' && 'text-ok', status === 'warn' && 'text-warn',
status === 'crit' && 'text-crit', status === 'muted' && 'text-text-muted')}>
{vpg.name}
</p>
<p className="text-[9px] text-text-muted font-mono data-value mt-0.5">{formatRpo(vpg.actualRpoSec)}</p>
</button>
);
}
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 (
<div className="space-y-6 animate-fade-in">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard label="Meeting SLA" value={totalOk} sub="VPGs within RPO target" color="ok" icon={CheckCircle2} />
<StatCard label="Warnings" value={totalWarn} sub="Approaching RPO limit" color="warn" icon={AlertTriangle} />
<StatCard label="Violations" value={totalCrit} sub="Exceeding RPO target" color="crit" icon={XCircle} />
<StatCard label="Replication" value={`${totalMbps.toFixed(1)} MB/s`} sub="Total throughput" color="accent" icon={Activity} />
</div>
{sites.length > 0 && (
<section>
<p className="section-title mb-3">Sites</p>
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
{sites.map((s) => <SiteCard key={s.siteName} site={s} />)}
</div>
</section>
)}
<section>
<p className="section-title mb-3">VPG RPO Heat Grid</p>
{vpgsLoading ? (
<div className="card p-8 text-center text-text-muted text-xs font-mono">Loading VPGs</div>
) : (
<div className="card p-4">
<div className="grid gap-2" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(110px, 1fr))' }}>
{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) => (
<VpgTile key={vpg.id} vpg={vpg} onClick={() => navigate(`/vpgs?name=${encodeURIComponent(vpg.name)}`)} />
))}
</div>
</div>
)}
</section>
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
<div className="xl:col-span-2">
<p className="section-title mb-3">Top RPO Violators</p>
<div className="card overflow-hidden">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border">
<th className="px-3 py-2 text-left section-title">VPG</th>
<th className="px-3 py-2 text-left section-title">Site</th>
<th className="px-3 py-2 text-right section-title">Actual RPO</th>
<th className="px-3 py-2 text-right section-title">Target</th>
<th className="px-3 py-2 text-right section-title">Ratio</th>
</tr>
</thead>
<tbody>
{violators.map((v) => {
const ratio = v.configuredRpoSec ? v.actualRpoSec / v.configuredRpoSec : 0;
const status = rpoStatus(v.actualRpoSec, v.configuredRpoSec);
return (
<tr key={v.id} className="table-row-hover border-b border-border/40 last:border-0"
onClick={() => navigate(`/vpgs?name=${encodeURIComponent(v.name)}`)}>
<td className="px-3 py-2 font-mono font-semibold text-text-primary">{v.name}</td>
<td className="px-3 py-2 text-text-muted">{v.siteName}</td>
<td className={clsx('px-3 py-2 text-right font-data data-value', colorToText[status])}>{formatRpo(v.actualRpoSec)}</td>
<td className="px-3 py-2 text-right font-data data-value text-text-muted">{formatRpo(v.configuredRpoSec)}</td>
<td className="px-3 py-2 text-right"><span className={clsx('badge', `badge-${status}`)}>{ratio.toFixed(1)}x</span></td>
</tr>
);
})}
{violators.length === 0 && (
<tr><td colSpan={5} className="px-3 py-8 text-center text-text-muted">
<CheckCircle2 size={20} className="text-ok mx-auto mb-1" />All VPGs within RPO targets
</td></tr>
)}
</tbody>
</table>
</div>
</div>
<div>
<p className="section-title mb-3">Collector Health</p>
<div className="card p-4 space-y-3">
{exporterHealth.length === 0 && <p className="text-xs text-text-muted italic">No exporter data</p>}
{exporterHealth.map((t) => (
<div key={`${t.instance}-${t.thread}`} className="flex items-center justify-between py-1.5 border-b border-border/40 last:border-0">
<div>
<p className="text-xs font-mono font-medium text-text-primary">{t.thread}</p>
<p className="text-[10px] text-text-muted">{t.instance}</p>
</div>
<span className={clsx('badge', t.alive ? 'badge-ok' : 'badge-crit')}>
<span className={clsx('status-dot', t.alive ? 'status-dot-ok' : 'status-dot-crit')} />
{t.alive ? 'Running' : 'Down'}
</span>
</div>
))}
</div>
</div>
</div>
</div>
);
}
+21
View File
@@ -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 (
<div className="flex items-center justify-center h-full animate-fade-in">
<div className="card p-12 text-center max-w-md">
<div className="w-14 h-14 rounded-xl bg-accent/10 flex items-center justify-center mx-auto mb-5">
<Construction size={24} className="text-accent" />
</div>
<h2 className="font-mono text-base font-semibold text-text-primary mb-2">{title}</h2>
<p className="text-sm text-text-muted leading-relaxed">{description}</p>
<p className="mt-4 text-xs font-mono text-text-muted border border-border rounded px-3 py-1.5 inline-block">
Phase 2 Coming next
</p>
</div>
</div>
);
}
+473
View File
@@ -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 (
<div className="card p-5 flex items-start gap-4">
<div className={clsx('w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0', `bg-${color}/10`)}>
<Icon size={18} className={`text-${color}`} />
</div>
<div>
<p className="section-title">{label}</p>
<p className="font-data text-2xl font-semibold text-text-primary mt-0.5 data-value">{value}</p>
{sub && <p className="text-xs text-text-muted mt-1">{sub}</p>}
</div>
</div>
);
}
// ── VM row ────────────────────────────────────────────────────────────────────
function VmRow({ vm, selected, onToggle }) {
return (
<tr
onClick={onToggle}
className={clsx(
'cursor-pointer transition-colors duration-100',
selected ? 'bg-accent/8' : 'hover:bg-raised',
)}
>
<td className="px-3 py-2.5 w-8">
<input
type="checkbox"
checked={selected}
onChange={onToggle}
onClick={(e) => e.stopPropagation()}
className="accent-accent"
/>
</td>
<td className="px-3 py-2.5 font-mono text-xs text-text-primary">{vm.name}</td>
<td className="px-3 py-2.5 font-mono text-xs text-text-secondary">{vm.cluster || '—'}</td>
<td className="px-3 py-2.5 font-mono text-xs text-text-secondary">{vm.datacenter || '—'}</td>
<td className="px-3 py-2.5 font-mono text-xs text-right data-value">{fmtGb(vm.provisionedGb)}</td>
<td className="px-3 py-2.5 font-mono text-xs text-right data-value">{fmtMbps(vm.writeThroughputMbps)}</td>
<td className="px-3 py-2.5 font-mono text-xs text-right text-text-muted data-value">
{vm.writeIops != null ? vm.writeIops.toFixed(0) : '—'}
</td>
</tr>
);
}
// ── 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 (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Calculator size={20} className="text-accent" />
<h1 className="text-lg font-semibold text-text-primary">DR Capacity Planner</h1>
</div>
<div className="flex items-center gap-2">
<button
onClick={exportCsv}
disabled={selected.size === 0}
className={clsx(
'flex items-center gap-2 px-3 py-1.5 rounded-md text-xs font-medium border transition-colors duration-150',
selected.size > 0
? 'border-border text-text-secondary hover:bg-raised hover:text-text-primary'
: 'text-text-muted border-border cursor-not-allowed opacity-50',
)}
>
<Download size={13} />
CSV
</button>
<button
onClick={exportPdf}
disabled={selected.size === 0}
className={clsx(
'flex items-center gap-2 px-3 py-1.5 rounded-md text-xs font-medium border transition-colors duration-150',
selected.size > 0
? 'bg-accent text-canvas border-accent hover:bg-accent/80'
: 'text-text-muted border-border cursor-not-allowed opacity-50',
)}
>
<FileText size={13} />
Export PDF
</button>
</div>
</div>
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
{/* Left — VM selector */}
<div className="xl:col-span-2 card overflow-hidden">
<div className="px-4 py-3 border-b border-border flex items-center justify-between">
<p className="font-mono text-xs text-text-secondary uppercase tracking-wider">
Select VMs to model
</p>
<span className="text-xs text-text-muted">
{selected.size} / {vms.length} selected
</span>
</div>
{/* Search */}
<div className="px-4 py-2 border-b border-border">
<div className="relative">
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-text-muted" />
<input
value={search}
onChange={(e) => 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"
/>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border">
<th className="px-3 py-2 w-8">
<input
type="checkbox"
checked={filtered.length > 0 && selected.size === filtered.length}
onChange={toggleAll}
className="accent-accent"
/>
</th>
<th className="px-3 py-2 text-left section-title">VM</th>
<th className="px-3 py-2 text-left section-title">Cluster</th>
<th className="px-3 py-2 text-left section-title">Datacenter</th>
<th className="px-3 py-2 text-right section-title">Disk Size</th>
<th className="px-3 py-2 text-right section-title">Write BW</th>
<th className="px-3 py-2 text-right section-title">Write IOPS</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{isLoading && !isMock ? (
<tr><td colSpan={7} className="px-4 py-8 text-center text-text-muted">Loading VMs</td></tr>
) : filtered.length === 0 ? (
<tr><td colSpan={7} className="px-4 py-8 text-center text-text-muted">No VMs found</td></tr>
) : (
filtered.map((vm) => (
<VmRow
key={vm.moref}
vm={vm}
selected={selected.has(vm.moref)}
onToggle={() => toggle(vm.moref)}
/>
))
)}
</tbody>
</table>
</div>
</div>
{/* Right — inputs + results */}
<div className="space-y-4">
{/* Inputs */}
<div className="card p-4 space-y-5">
<p className="font-mono text-xs text-text-secondary uppercase tracking-wider border-b border-border pb-2">
Planning Inputs
</p>
{/* Journal retention */}
<div>
<div className="flex justify-between mb-2">
<label className="section-title">Journal Retention</label>
<span className="font-mono text-xs text-accent font-semibold">
{JOURNAL_OPTIONS[journalIdx].label}
</span>
</div>
<input
type="range"
min={0}
max={JOURNAL_OPTIONS.length - 1}
value={journalIdx}
onChange={(e) => setJournalIdx(Number(e.target.value))}
className="w-full accent-accent"
/>
<div className="flex justify-between text-[9px] text-text-muted font-mono mt-1">
<span>1h</span><span>8h</span><span>7d</span><span>15d</span><span>30d</span>
</div>
</div>
{/* Compression */}
<div>
<div className="flex justify-between mb-2">
<label className="section-title">WAN Compression</label>
<span className="font-mono text-xs text-accent font-semibold">{compression}%</span>
</div>
<input
type="range"
min={0}
max={80}
step={5}
value={compression}
onChange={(e) => setCompression(Number(e.target.value))}
className="w-full accent-accent"
/>
<div className="flex justify-between text-[9px] text-text-muted font-mono mt-1">
<span>0%</span><span>40%</span><span>80%</span>
</div>
</div>
</div>
{/* Results */}
<div className="space-y-3">
{selected.size === 0 && (
<p className="text-xs text-text-muted text-center py-2">
Select VMs to see estimates
</p>
)}
<ResultCard
icon={Wifi}
label="Bandwidth Required"
value={fmtMbps(bwRequiredMbps)}
sub={`Raw: ${fmtMbps(totalThroughputMbps)}${compression}% compressed`}
color="accent"
/>
<ResultCard
icon={HardDrive}
label="Journal Storage"
value={fmtGb(journalStorageGb)}
sub={`${JOURNAL_OPTIONS[journalIdx].label} at ${fmtMbps(bwRequiredMbps)} after compression`}
color="warn"
/>
<ResultCard
icon={Database}
label="Mirror Storage"
value={fmtGb(mirrorStorageGb)}
sub="Full copy of selected VM disks"
color="ok"
/>
<div className="card p-4 border-accent/20 bg-accent/5">
<p className="section-title mb-1">Total DR Storage Footprint</p>
<p className="font-data text-3xl font-semibold text-accent data-value">
{fmtGb(totalDrStorageGb)}
</p>
<p className="text-xs text-text-muted mt-1">
Journal + Mirror across {selected.size} VM{selected.size !== 1 ? 's' : ''}
</p>
</div>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,291 @@
// 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 <div className={clsx('rounded-full flex items-center justify-center font-mono font-semibold flex-shrink-0', sz, color)}>{initials}</div>;
}
function Toast({ message, type = 'ok', onDismiss }) {
useEffect(() => { const t = setTimeout(onDismiss, 3500); return () => clearTimeout(t); }, [onDismiss]);
return (
<div className={clsx('fixed bottom-6 right-6 z-[100] flex items-center gap-3 px-4 py-3 rounded-lg border shadow-panel animate-fade-in',
type === 'ok' && 'bg-surface border-ok/30 text-ok', type === 'error' && 'bg-surface border-crit/30 text-crit')}>
{type === 'ok' && <Check size={14} />}{type === 'error' && <AlertTriangle size={14} />}
<span className="text-sm font-medium">{message}</span>
<button onClick={onDismiss} className="ml-2 text-text-muted hover:text-text-primary"><X size={12} /></button>
</div>
);
}
function DeleteModal({ user, onConfirm, onCancel, loading }) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="drawer-overlay" onClick={onCancel} />
<div className="card-raised p-6 w-full max-w-sm z-10 animate-modal-in">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-full bg-crit/10 flex items-center justify-center"><Trash2 size={18} className="text-crit" /></div>
<div><p className="font-medium text-text-primary">Delete user</p><p className="text-xs text-text-muted">This cannot be undone</p></div>
</div>
<p className="text-sm text-text-secondary mb-6">Delete <span className="text-text-primary font-medium">{user.name}</span> ({user.username})?</p>
<div className="flex justify-end gap-3">
<button className="btn-ghost" onClick={onCancel} disabled={loading}>Cancel</button>
<button className="btn-danger" onClick={onConfirm} disabled={loading}>
{loading ? <Loader2 size={14} className="animate-spin" /> : <Trash2 size={14} />} Delete
</button>
</div>
</div>
</div>
);
}
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 (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="drawer-overlay" onClick={onClose} />
<div className="card-raised p-6 w-full max-w-md z-10 animate-modal-in">
<div className="flex items-start justify-between mb-5">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-accent/10 flex items-center justify-center"><QrCode size={18} className="text-accent" /></div>
<div><p className="font-medium text-text-primary">Set up 2FA</p><p className="text-xs text-text-muted">{user.name}</p></div>
</div>
<button onClick={onClose} className="text-text-muted hover:text-text-primary"><X size={16} /></button>
</div>
{state === 'loading' && <div className="flex flex-col items-center py-10 gap-3"><Loader2 size={28} className="animate-spin text-accent" /><p className="text-sm text-text-muted">Generating setup link</p></div>}
{state === 'error' && <div className="flex flex-col items-center py-8 gap-3 text-crit"><AlertTriangle size={28} /><p className="text-sm">Failed to generate setup link.</p><button className="btn-ghost mt-2" onClick={generate}>Retry</button></div>}
{state === 'done' && result && (
<>
<div className="bg-canvas rounded-lg p-1 flex justify-center mb-4 border border-border">
<img src={result.qrDataUrl} alt="2FA setup QR code" className="w-56 h-56 rounded" />
</div>
<p className="text-sm text-text-secondary mb-5">Share this QR code with {user.name} to enroll their authenticator app.</p>
<button className="btn-ghost w-full" onClick={onClose}>Done</button>
</>
)}
</div>
</div>
);
}
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 (
<>
<div className="drawer-overlay" onClick={onClose} />
<div className="drawer-panel">
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
<p className="font-medium text-text-primary">{isEdit ? 'Edit user' : 'Add user'}</p>
<button onClick={onClose} className="text-text-muted hover:text-text-primary"><X size={18} /></button>
</div>
<div className="flex-1 overflow-y-auto px-6 py-5 space-y-5">
<section>
<p className="section-title mb-4">Identity</p>
<div className="space-y-4">
<div><label className="field-label">Username</label><input className={clsx('field', errors.username && 'border-crit')} value={form.username} onChange={set('username')} disabled={isEdit} /></div>
<div><label className="field-label">Full name</label><input className={clsx('field', errors.name && 'border-crit')} value={form.name} onChange={set('name')} /></div>
<div><label className="field-label">Email</label><input className={clsx('field', errors.email && 'border-crit')} type="email" value={form.email} onChange={set('email')} /></div>
</div>
</section>
<section>
<p className="section-title mb-3">Groups</p>
<div className="space-y-2">
{groups.map((g) => (
<label key={g.id} className="flex items-center gap-3 cursor-pointer py-2 px-3 rounded-md hover:bg-canvas transition-colors">
<input type="checkbox" className="sr-only" checked={form.groups.includes(g.id)} onChange={() => toggleGroup(g.id)} />
<div className={clsx('w-4 h-4 rounded border flex items-center justify-center',
form.groups.includes(g.id) ? 'bg-accent border-accent' : 'border-border')}>
{form.groups.includes(g.id) && <Check size={10} className="text-white" />}
</div>
<span className="text-sm text-text-primary">{g.name}</span>
</label>
))}
</div>
</section>
<section>
<p className="section-title mb-3">{isEdit ? 'Reset Password' : 'Password'}</p>
<input className={clsx('field', errors.password && 'border-crit')} type="password" value={form.password} onChange={set('password')}
placeholder={isEdit ? 'Leave blank to keep' : 'Min. 8 characters'} />
{errors.password && <p className="text-xs text-crit mt-1">{errors.password}</p>}
</section>
</div>
<div className="flex justify-end gap-3 px-6 py-4 border-t border-border flex-shrink-0">
<button className="btn-ghost" onClick={onClose} disabled={saving}>Cancel</button>
<button className="btn-primary" onClick={handleSubmit} disabled={saving}>
{saving ? <Loader2 size={14} className="animate-spin" /> : isEdit ? <Check size={14} /> : <UserPlus size={14} />}
{isEdit ? 'Save' : 'Create'}
</button>
</div>
</div>
</>
);
}
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 (
<div className="flex flex-col h-full animate-fade-in">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="font-mono text-lg font-semibold text-text-primary flex items-center gap-2">
<Users size={20} className="text-accent" /> User Management
</h1>
<p className="text-xs text-text-muted mt-1">{total} users</p>
</div>
<button className="btn-primary" onClick={() => setDrawer({ mode: 'create' })}><UserPlus size={15} /> Add User</button>
</div>
<div className="relative mb-4">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted pointer-events-none" />
<input className="field pl-9" placeholder="Search…" value={searchInput} onChange={handleSearchChange} />
</div>
<div className="card flex-1 overflow-auto">
<table className="w-full text-sm">
<thead><tr className="border-b border-border">
<th className="px-4 py-3 text-left section-title">User</th>
<th className="px-4 py-3 text-left section-title hidden md:table-cell">Groups</th>
<th className="px-4 py-3 text-left section-title">Status</th>
<th className="px-4 py-3 text-left section-title hidden lg:table-cell">2FA</th>
<th className="px-4 py-3 text-right section-title">Actions</th>
</tr></thead>
<tbody>
{loading && <tr><td colSpan={5} className="px-4 py-16 text-center"><Loader2 size={20} className="animate-spin text-text-muted mx-auto" /></td></tr>}
{!loading && users.length === 0 && <tr><td colSpan={5} className="px-4 py-16 text-center text-text-muted">No users found</td></tr>}
{!loading && users.map((u) => (
<tr key={u.id} className="table-row-hover border-b border-border/50 last:border-0">
<td className="px-4 py-3">
<div className="flex items-center gap-3">
<Avatar name={u.name} />
<div><p className="font-medium text-text-primary">{u.name}</p><p className="text-xs text-text-muted font-mono">{u.username}</p></div>
</div>
</td>
<td className="px-4 py-3 hidden md:table-cell">
<div className="flex flex-wrap gap-1">
{u.groups.length === 0 ? <span className="text-xs text-text-muted"></span> : u.groups.map((g) => (
<span key={g.id} className={clsx('badge', g.name.includes('admin') ? 'badge-info' : 'badge-muted')}>{g.name}</span>
))}
</div>
</td>
<td className="px-4 py-3">
{u.isActive ? <span className="badge badge-ok">Active</span> : <span className="badge badge-muted">Inactive</span>}
</td>
<td className="px-4 py-3 hidden lg:table-cell">
{u.totpEnrolled ? <span className="badge badge-ok"><ShieldCheck size={10} />2FA On</span> : <span className="badge badge-warn"><ShieldOff size={10} />No 2FA</span>}
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-1">
<button title="Edit" onClick={() => setDrawer({ mode: 'edit', user: u })} className="p-1.5 rounded text-text-muted hover:text-accent hover:bg-accent/10 transition-colors"><Pencil size={13} /></button>
<button title="2FA" onClick={() => setTwoFaTarget(u)} className="p-1.5 rounded text-text-muted hover:text-accent hover:bg-accent/10 transition-colors"><Shield size={13} /></button>
<button title="Delete" onClick={() => setDeleteTarget(u)} className="p-1.5 rounded text-text-muted hover:text-crit hover:bg-crit/10 transition-colors"><Trash2 size={13} /></button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{drawer && <UserDrawer mode={drawer.mode} user={drawer.user} groups={groups} onSave={handleSave} onClose={() => setDrawer(null)} />}
{deleteTarget && <DeleteModal user={deleteTarget} onConfirm={handleDelete} onCancel={() => setDeleteTarget(null)} />}
{twoFaTarget && <TwoFactorModal user={twoFaTarget} onClose={() => { setTwoFaTarget(null); loadUsers(); }} />}
{toast && <Toast message={toast.message} type={toast.type} onDismiss={() => setToast(null)} />}
</div>
);
}
+151
View File
@@ -0,0 +1,151 @@
// 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 (
<div className="space-y-1">
<div className="flex justify-between text-[10px]">
<span className="text-text-muted">{label}</span>
<span className={clsx('font-mono data-value', textC)}>
{formatBytes(usedBytes)} / {formatBytes(totalBytes)} ({pctN}%)
</span>
</div>
<div className="h-2 bg-border rounded-full overflow-hidden">
<div className={clsx('h-full rounded-full transition-all duration-500', barColor)} style={{ width: `${pct * 100}%` }} />
</div>
</div>
);
}
function ZertoUsageRow({ label, bytes, color }) {
return bytes > 0 ? (
<div className="flex items-center justify-between text-[10px]">
<div className="flex items-center gap-1.5">
<span className={clsx('w-2 h-2 rounded-sm flex-shrink-0', color)} />
<span className="text-text-muted">{label}</span>
</div>
<span className="font-mono data-value text-text-secondary">{formatBytes(bytes)}</span>
</div>
) : 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 (
<div className={clsx('card p-4 space-y-4 transition-colors duration-300',
alerting ? 'border-crit/30' : warning ? 'border-warn/20' : '')}>
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<div className={clsx('w-8 h-8 rounded-md flex items-center justify-center',
alerting ? 'bg-crit/10' : warning ? 'bg-warn/10' : 'bg-accent/10')}>
<Database size={14} className={alerting ? 'text-crit' : warning ? 'text-warn' : 'text-accent'} />
</div>
<div>
<p className="text-sm font-mono font-semibold text-text-primary truncate max-w-[180px]">{ds.name}</p>
<p className="text-[10px] text-text-muted">{ds.siteName}</p>
</div>
</div>
<div className="text-right text-[10px] text-text-muted font-mono">
<p>{ds.vraCount ?? 0} VRA{(ds.vraCount ?? 0) !== 1 ? 's' : ''}</p>
<p>{ds.incomingVms ?? 0} in / {ds.outgoingVms ?? 0} out</p>
</div>
</div>
<CapacityBar label="Capacity" usedBytes={ds.usedBytes} totalBytes={ds.capacityBytes} />
{zertoUsed > 0 && (
<div className="space-y-1.5 pt-2 border-t border-border">
<p className="section-title mb-2">Zerto Usage ({formatBytes(zertoUsed)})</p>
<ZertoUsageRow label="Journal" bytes={ds.journalBytes} color="bg-accent" />
<ZertoUsageRow label="Scratch" bytes={ds.scratchBytes} color="bg-info" />
<ZertoUsageRow label="Recovery" bytes={ds.recoveryBytes} color="bg-ok" />
<ZertoUsageRow label="Appliances" bytes={ds.applianceBytes} color="bg-text-muted" />
</div>
)}
<div className="flex justify-between text-[10px] text-text-muted pt-1 border-t border-border">
<span>Free</span>
<span className="font-mono data-value text-text-secondary">{formatBytes(ds.freeBytes)}</span>
</div>
</div>
);
}
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 (
<div className="space-y-6 animate-fade-in">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[
{ 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) => (
<div key={s.label} className="card p-4 flex items-start gap-3">
<div className="w-9 h-9 rounded-lg bg-accent/10 flex items-center justify-center">
<s.icon size={16} className="text-accent" />
</div>
<div>
<p className="section-title">{s.label}</p>
<p className="font-mono text-lg font-semibold text-text-primary mt-0.5 data-value">{s.value}</p>
</div>
</div>
))}
</div>
{totalCapacity > 0 && (
<div className="card p-4">
<CapacityBar label="Aggregate Capacity (all datastores)" usedBytes={totalUsed} totalBytes={totalCapacity} />
</div>
)}
{isLoading && (
<div className="flex justify-center py-16">
<Loader2 size={24} className="animate-spin text-text-muted" />
</div>
)}
{Object.entries(bySite).map(([site, siteDs]) => (
<section key={site}>
<p className="section-title mb-3">{site} · {siteDs.length} datastore{siteDs.length !== 1 ? 's' : ''}</p>
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
{siteDs.map((ds) => <DatastoreCard key={ds.id || ds.name} ds={ds} />)}
</div>
</section>
))}
{!isLoading && datastores.length === 0 && (
<div className="card p-12 text-center">
<Database size={28} className="text-text-muted mx-auto mb-3 opacity-40" />
<p className="text-text-muted text-sm">No datastore data available</p>
</div>
)}
</div>
);
}
+169
View File
@@ -0,0 +1,169 @@
// 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 <span className="text-xs text-text-muted"></span>;
const pct = Math.min(usedMb / hardLimitMb, 1);
const color = pct > 0.85 ? 'bg-crit' : pct > 0.65 ? 'bg-warn' : 'bg-ok';
return (
<div className="flex items-center gap-2 min-w-[100px]">
<div className="flex-1 h-1.5 bg-border rounded-full overflow-hidden">
<div className={clsx('h-full rounded-full', color)} style={{ width: `${pct * 100}%` }} />
</div>
<span className="text-[10px] font-mono data-value text-text-muted whitespace-nowrap">{formatMB(usedMb)}</span>
</div>
);
}
function VmDrawer({ vm, onClose }) {
const esc = vm.name.replace(/"/g, '\\"');
return (
<>
<div className="drawer-overlay" onClick={onClose} />
<div className="drawer-panel">
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-accent/10 flex items-center justify-center">
<Server size={18} className="text-accent" />
</div>
<div>
<p className="font-mono text-sm font-semibold text-text-primary">{vm.name}</p>
<p className="text-xs text-text-muted">{vm.vpgName} · {vm.siteName}</p>
</div>
</div>
<button onClick={onClose} className="p-1.5 rounded text-text-muted hover:text-text-primary hover:bg-raised transition-colors"><X size={16} /></button>
</div>
<div className="flex-1 overflow-y-auto px-6 py-5 space-y-5">
<div className="card p-4 flex items-start gap-6">
<RPOGauge actualSec={vm.actualRpoSec} size={120} label="Current RPO" />
<div className="flex-1 space-y-2 pt-2">
{[
{ 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 }) => (
<div key={label} className="flex justify-between text-xs">
<span className="text-text-muted">{label}</span>
<span className="font-mono data-value text-text-primary">{value}</span>
</div>
))}
</div>
</div>
<TimeSeriesChart title="RPO History" promql={`vm_actualrpo{VmName="${esc}"}`}
yFormatter={formatRpo}
transform={(result) => result[0]?.values.map(([ts, v]) => ({ ts: ts * 1000, 'RPO': parseFloat(v) })) ?? []}
height={170} />
<TimeSeriesChart title="Throughput" promql={`vm_throughput_in_mb{VmName="${esc}"}`}
yFormatter={(v) => `${v.toFixed(1)} MB/s`}
transform={(result) => result[0]?.values.map(([ts, v]) => ({ ts: ts * 1000, 'MB/s': parseFloat(v) })) ?? []}
height={150} />
</div>
</div>
</>
);
}
function VmStatusBadge({ code }) {
const s = VM_STATUS[code] ?? { label: 'Unknown', color: 'muted' };
return <span className={clsx('badge', `badge-${s.color === 'muted' ? 'muted' : s.color}`)}>{s.label}</span>;
}
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 (
<div className="flex flex-col h-full space-y-4 animate-fade-in">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[
{ 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 }) => (
<div key={label} className="card p-4 flex items-start gap-3">
<div className={clsx('w-9 h-9 rounded-lg flex items-center justify-center flex-shrink-0', `bg-${color}/10`)}>
<Activity size={16} className={`text-${color}`} />
</div>
<div><p className="section-title">{label}</p><p className="font-data text-2xl font-semibold text-text-primary data-value">{value}</p></div>
</div>
))}
</div>
<div className="flex items-center gap-3 flex-wrap">
<div className="relative flex-1 min-w-[200px]">
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-text-muted pointer-events-none" />
<input className="field pl-8 text-sm" placeholder="Search VMs or VPGs…" value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
<select className="field w-auto text-sm" value={sort} onChange={(e) => setSort(e.target.value)}>
<option value="rpo-desc">RPO (worst first)</option>
<option value="rpo-asc">RPO (best first)</option>
<option value="name-asc">Name A-Z</option>
</select>
<span className="text-xs text-text-muted">{sorted.length} / {vms.length} VMs</span>
</div>
<div className="card flex-1 overflow-auto">
<table className="w-full text-sm">
<thead><tr className="border-b border-border">
<th className="px-4 py-3 text-left section-title">VM Name</th>
<th className="px-4 py-3 text-left section-title hidden md:table-cell">VPG</th>
<th className="px-4 py-3 text-right section-title">RPO</th>
<th className="px-4 py-3 text-left section-title hidden md:table-cell">Journal</th>
<th className="px-4 py-3 text-right section-title hidden lg:table-cell">Throughput</th>
<th className="px-4 py-3 text-left section-title hidden xl:table-cell">Status</th>
</tr></thead>
<tbody>
{isLoading && <tr><td colSpan={6} className="py-16 text-center"><Loader2 size={20} className="animate-spin text-text-muted mx-auto" /></td></tr>}
{!isLoading && sorted.length === 0 && <tr><td colSpan={6} className="py-16 text-center text-text-muted">No VMs</td></tr>}
{!isLoading && sorted.map((vm) => {
const rpoColor = vm.actualRpoSec > 600 ? 'text-crit' : vm.actualRpoSec > 300 ? 'text-warn' : 'text-ok';
return (
<tr key={vm.id} onClick={() => setSelected(vm)} className="table-row-hover border-b border-border/40 last:border-0">
<td className="px-4 py-3"><span className="font-medium text-text-primary truncate">{vm.name}</span></td>
<td className="px-4 py-3 hidden md:table-cell"><span className="text-text-muted text-xs">{vm.vpgName}</span></td>
<td className="px-4 py-3 text-right"><span className={clsx('font-mono font-semibold text-xs data-value', rpoColor)}>{formatRpo(vm.actualRpoSec)}</span></td>
<td className="px-4 py-3 hidden md:table-cell"><JournalGauge usedMb={vm.journalUsedMb} hardLimitMb={vm.journalHardLimit} /></td>
<td className="px-4 py-3 text-right hidden lg:table-cell"><span className="font-mono text-xs data-value text-text-secondary">{(vm.throughputMb ?? 0).toFixed(2)} MB/s</span></td>
<td className="px-4 py-3 hidden xl:table-cell"><VmStatusBadge code={vm.status} /></td>
</tr>
);
})}
</tbody>
</table>
</div>
{selected && <VmDrawer vm={selected} onClose={() => setSelected(null)} />}
</div>
);
}
+154
View File
@@ -0,0 +1,154 @@
// 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 (
<button onClick={onClick} className={clsx('w-full text-left px-3 py-2.5 rounded-md transition-all duration-150 group',
selected ? 'bg-accent/15 border border-accent/25' : 'hover:bg-raised border border-transparent')}>
<div className="flex items-center gap-2">
<span className={clsx('status-dot flex-shrink-0',
status === 'ok' ? 'status-dot-ok' : status === 'warn' ? 'status-dot-warn' : status === 'crit' ? 'status-dot-crit' : 'status-dot-idle')} />
<span className={clsx('text-sm font-medium truncate flex-1', selected ? 'text-accent' : 'text-text-primary')}>{vpg.name}</span>
{selected && <ChevronRight size={12} className="text-accent flex-shrink-0" />}
</div>
<div className="flex items-center gap-3 mt-0.5 pl-4">
<span className="text-[10px] text-text-muted">{vpg.siteName}</span>
<span className={clsx('text-[10px] font-mono data-value', colorToText[status])}>{formatRpo(vpg.actualRpoSec)}</span>
</div>
</button>
);
}
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 <div className="flex-1 flex items-center justify-center"><Loader2 size={24} className="animate-spin text-text-muted" /></div>;
if (!detail) return null;
const alertInfo = VPG_ALERT[detail.alertStatus] ?? VPG_ALERT[0];
const esc = vpgName.replace(/"/g, '\\"');
return (
<div className="flex-1 min-w-0 overflow-y-auto p-4 space-y-4 animate-fade-in">
<div className="flex items-center gap-2 mb-1">
<span className={clsx('status-dot', alertInfo.color === 'ok' ? 'status-dot-ok' : alertInfo.color === 'warn' ? 'status-dot-warn' : 'status-dot-crit')} />
<h2 className="font-mono text-base font-semibold text-text-primary">{vpgName}</h2>
<span className={clsx('badge', `badge-${alertInfo.color}`)}>{alertInfo.label}</span>
<span className="text-xs text-text-muted">{detail.siteName} · {detail.vmCount} VMs</span>
</div>
<div className="card overflow-hidden flex flex-wrap">
<div className="flex items-center justify-center p-4 border-r border-border">
<RPOGauge actualSec={detail.actualRpoSec} configuredSec={detail.configuredRpoSec} size={150} />
</div>
<div className="flex flex-wrap flex-1">
{[
{ 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) => (
<div key={s.label} className="text-center px-4 py-3 border-r border-border last:border-0">
<p className="text-lg font-semibold font-data data-value text-text-primary">{s.value}</p>
<p className="section-title mt-0.5">{s.label}</p>
</div>
))}
</div>
</div>
<TimeSeriesChart title="RPO Over Time" promql={`vpg_actual_rpo{VpgName="${esc}"}`}
yFormatter={(v) => 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} />
<div className="card overflow-hidden">
<div className="px-4 py-3 border-b border-border"><p className="section-title">Protected VMs</p></div>
<table className="w-full text-xs">
<thead><tr className="border-b border-border/60">
<th className="px-4 py-2 text-left section-title">VM Name</th>
<th className="px-4 py-2 text-right section-title">RPO</th>
<th className="px-4 py-2 text-right section-title hidden sm:table-cell">Throughput</th>
<th className="px-4 py-2 text-right section-title hidden md:table-cell">IOPS</th>
</tr></thead>
<tbody>
{vmsLoading && <tr><td colSpan={4} className="py-8 text-center"><Loader2 size={16} className="animate-spin text-text-muted mx-auto" /></td></tr>}
{!vmsLoading && vms.map((vm) => (
<tr key={vm.id} className="border-b border-border/40 last:border-0 hover:bg-raised transition-colors">
<td className="px-4 py-2 font-medium text-text-primary">{vm.name}</td>
<td className="px-4 py-2 text-right font-mono data-value">{formatRpo(vm.actualRpoSec)}</td>
<td className="px-4 py-2 text-right text-text-secondary font-mono data-value hidden sm:table-cell">{vm.throughputMb?.toFixed(2)} MB/s</td>
<td className="px-4 py-2 text-right text-text-secondary font-mono data-value hidden md:table-cell">{Math.round(vm.iops ?? 0)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
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 (
<div className="flex h-full -m-6 overflow-hidden">
<aside className="w-64 flex-shrink-0 border-r border-border flex flex-col bg-surface overflow-hidden">
<div className="p-3 border-b border-border flex-shrink-0">
<div className="relative">
<Search size={12} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-text-muted pointer-events-none" />
<input className="field pl-8 text-xs py-1.5" placeholder="Filter VPGs…" value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
<p className="text-[10px] text-text-muted mt-2 font-mono">{filtered.length} VPGs</p>
</div>
<div className="flex-1 overflow-y-auto p-2">
{isLoading && <div className="flex justify-center py-8"><Loader2 size={16} className="animate-spin text-text-muted" /></div>}
{Object.entries(bySite).map(([site, siteVpgs]) => (
<div key={site} className="mb-3">
<p className="section-title px-2 mb-1">{site}</p>
{siteVpgs.map((v) => <VpgListItem key={v.id} vpg={v} selected={selected === v.name} onClick={() => setSelected(v.name)} />)}
</div>
))}
</div>
</aside>
<div className="flex-1 flex flex-col overflow-hidden">
{selected ? <VpgDetail vpgName={selected} /> : (
<div className="flex-1 flex items-center justify-center text-text-muted text-sm">Select a VPG to view details</div>
)}
</div>
</div>
);
}
+147
View File
@@ -0,0 +1,147 @@
// 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 (
<div className="space-y-1">
<div className="flex justify-between items-center text-[10px]">
<span className="text-text-muted">{label}</span>
<span className={clsx('font-mono data-value', textC)}>
{typeof used === 'number' ? `${Math.round(used)}${unit}` : '—'}
{total > 0 ? ` / ${Math.round(total)}${unit}` : max ? ` / ${max}${unit}` : ''}
</span>
</div>
<div className="h-1 bg-border rounded-full overflow-hidden">
<div className={clsx('h-full rounded-full transition-all duration-500', color)} style={{ width: `${pct * 100}%` }} />
</div>
</div>
);
}
function WorkloadBadge({ label, value, icon: Icon, color = 'text-text-secondary' }) {
return (
<div className="flex flex-col items-center p-2 bg-canvas rounded-md border border-border min-w-0">
<Icon size={12} className={clsx('mb-1', color)} />
<span className={clsx('font-data text-base font-semibold data-value', color)}>{value ?? '—'}</span>
<span className="section-title mt-0.5 text-center leading-tight">{label}</span>
</div>
);
}
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 (
<div className={clsx('card p-4 flex flex-col gap-4 transition-colors duration-300',
alerting ? 'border-crit/30' : warning ? 'border-warn/30' : '')}>
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<div className={clsx('w-8 h-8 rounded-md flex items-center justify-center',
alerting ? 'bg-crit/10' : warning ? 'bg-warn/10' : 'bg-accent/10')}>
<Server size={14} className={alerting ? 'text-crit' : warning ? 'text-warn' : 'text-accent'} />
</div>
<div>
<p className="text-sm font-mono font-semibold text-text-primary truncate max-w-[140px]">{vra.name}</p>
<p className="text-[10px] text-text-muted">{vra.siteName}</p>
</div>
</div>
<div className="text-right">
<p className="text-[10px] text-text-muted font-mono">{vra.vcpuCount} vCPU</p>
<p className="text-[10px] text-text-muted font-mono">{vra.memoryGb?.toFixed(0)} GB RAM</p>
</div>
</div>
{(vra.cpuUsageMhz !== undefined || vra.memUsageMb !== undefined) && (
<div className="space-y-2">
{vra.cpuUsageMhz !== undefined && (
<UsageBar label="CPU" used={vra.cpuUsageMhz} unit=" MHz" max={vra.vcpuCount * 2600} warnAt={0.7} critAt={0.9} />
)}
{vra.memUsageMb !== undefined && (
<UsageBar label="Memory" used={vra.memUsageMb} total={(vra.memoryGb ?? 0) * 1024} unit=" MB" warnAt={0.8} critAt={0.92} />
)}
</div>
)}
<div>
<p className="section-title mb-2">Workload</p>
<div className="grid grid-cols-3 gap-1.5 mb-2">
<WorkloadBadge label="Prot VMs" value={vra.protectedVms} icon={Server}
color={protPct >= 0.9 ? 'text-crit' : protPct >= 0.75 ? 'text-warn' : 'text-ok'} />
<WorkloadBadge label="Rec VMs" value={vra.recoveryVms} icon={Server}
color={recPct >= 0.9 ? 'text-crit' : recPct >= 0.75 ? 'text-warn' : 'text-accent'} />
<WorkloadBadge label="Self VPGs" value={vra.selfProtectedVpgs} icon={Layers} color="text-text-secondary" />
</div>
<div className="grid grid-cols-2 gap-1.5">
<WorkloadBadge label="Prot Vols" value={vra.protectedVolumes} icon={HardDrive}
color={vra.protectedVolumes / VRA_MAX_VOL >= 0.85 ? 'text-crit' : vra.protectedVolumes / VRA_MAX_VOL >= 0.7 ? 'text-warn' : 'text-text-secondary'} />
<WorkloadBadge label="Rec Vols" value={vra.recoveryVolumes} icon={HardDrive}
color={vra.recoveryVolumes / VRA_MAX_VOL >= 0.85 ? 'text-crit' : vra.recoveryVolumes / VRA_MAX_VOL >= 0.7 ? 'text-warn' : 'text-text-secondary'} />
</div>
</div>
<div className="flex items-center justify-between text-[10px] text-text-muted font-mono pt-3 border-t border-border">
<span>VRA {vra.version ?? '—'}</span>
<span>ESXi {vra.hostVersion ?? '—'}</span>
</div>
</div>
);
}
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 (
<div className="space-y-6 animate-fade-in">
<div className="grid grid-cols-3 gap-4">
{[
{ label: 'Total VRAs', value: vras.length },
{ label: 'Protected VMs', value: totalProt },
{ label: 'Recovery VMs', value: totalRec },
].map((s) => (
<div key={s.label} className="card p-4 text-center">
<p className="font-data text-2xl font-semibold text-text-primary data-value">{s.value}</p>
<p className="section-title mt-1">{s.label}</p>
</div>
))}
</div>
{isLoading && (
<div className="flex justify-center py-16"><Loader2 size={24} className="animate-spin text-text-muted" /></div>
)}
{Object.entries(bySite).map(([site, siteVras]) => (
<section key={site}>
<p className="section-title mb-3">{site} · {siteVras.length} VRA{siteVras.length !== 1 ? 's' : ''}</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{siteVras.map((vra) => <VraCard key={vra.id || vra.name} vra={vra} />)}
</div>
</section>
))}
</div>
);
}
+123
View File
@@ -0,0 +1,123 @@
/* src/styles/index.css */
@tailwind base;
@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; }
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;
}
}
+66
View File
@@ -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: 'rgb(var(--color-canvas) / <alpha-value>)',
surface: 'rgb(var(--color-surface) / <alpha-value>)',
raised: 'rgb(var(--color-raised) / <alpha-value>)',
border: 'rgb(var(--color-border) / <alpha-value>)',
'border-bright': 'rgb(var(--color-border-bright) / <alpha-value>)',
accent: {
DEFAULT: '#0ea5e9',
dim: '#0284c7',
bright: '#38bdf8',
glow: 'rgba(14,165,233,0.15)',
},
ok: '#10b981',
warn: '#f59e0b',
crit: '#ef4444',
info: '#818cf8',
'text-primary': 'rgb(var(--color-text-primary) / <alpha-value>)',
'text-secondary': 'rgb(var(--color-text-secondary) / <alpha-value>)',
'text-muted': 'rgb(var(--color-text-muted) / <alpha-value>)',
},
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: [],
};
+34
View File
@@ -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'],
},
},
},
},
});