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

+ Verifying session… +

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

403

+

+ This page requires administrator privileges. +

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

{label}

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

{title}

+

{description}

+

+ Phase 2 — Coming next +

+
+
+ ); +} diff --git a/zroc-ui/src/pages/Settings/UserManagement.jsx b/zroc-ui/src/pages/Settings/UserManagement.jsx new file mode 100644 index 0000000..f54b366 --- /dev/null +++ b/zroc-ui/src/pages/Settings/UserManagement.jsx @@ -0,0 +1,2 @@ +// TODO: Full content to be added +// This file is part of the zROC project recreation diff --git a/zroc-ui/src/pages/Storage.jsx b/zroc-ui/src/pages/Storage.jsx new file mode 100644 index 0000000..f54b366 --- /dev/null +++ b/zroc-ui/src/pages/Storage.jsx @@ -0,0 +1,2 @@ +// TODO: Full content to be added +// This file is part of the zROC project recreation diff --git a/zroc-ui/src/pages/VMDetail.jsx b/zroc-ui/src/pages/VMDetail.jsx new file mode 100644 index 0000000..f54b366 --- /dev/null +++ b/zroc-ui/src/pages/VMDetail.jsx @@ -0,0 +1,2 @@ +// TODO: Full content to be added +// This file is part of the zROC project recreation diff --git a/zroc-ui/src/pages/VPGMonitor.jsx b/zroc-ui/src/pages/VPGMonitor.jsx new file mode 100644 index 0000000..f54b366 --- /dev/null +++ b/zroc-ui/src/pages/VPGMonitor.jsx @@ -0,0 +1,2 @@ +// TODO: Full content to be added +// This file is part of the zROC project recreation diff --git a/zroc-ui/src/pages/VRADashboard.jsx b/zroc-ui/src/pages/VRADashboard.jsx new file mode 100644 index 0000000..f54b366 --- /dev/null +++ b/zroc-ui/src/pages/VRADashboard.jsx @@ -0,0 +1,2 @@ +// TODO: Full content to be added +// This file is part of the zROC project recreation diff --git a/zroc-ui/src/styles/index.css b/zroc-ui/src/styles/index.css new file mode 100644 index 0000000..e1af472 --- /dev/null +++ b/zroc-ui/src/styles/index.css @@ -0,0 +1,99 @@ +/* src/styles/index.css */ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + html { @apply scroll-smooth; } + + body { + @apply bg-canvas text-text-primary font-sans; + font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11'; + } + + ::-webkit-scrollbar { width: 6px; height: 6px; } + ::-webkit-scrollbar-track { @apply bg-surface; } + ::-webkit-scrollbar-thumb { @apply bg-border-bright rounded-full; } + ::-webkit-scrollbar-thumb:hover { @apply bg-accent; } + + *:focus-visible { + @apply outline-none ring-1 ring-accent ring-offset-2 ring-offset-canvas; + } +} + +@layer components { + .data-value { + @apply font-data tabular-nums; + } + + .status-dot { + @apply inline-block w-2 h-2 rounded-full flex-shrink-0; + } + .status-dot-ok { @apply bg-ok shadow-glow-ok animate-pulse-led; } + .status-dot-warn { @apply bg-warn; } + .status-dot-crit { @apply bg-crit shadow-glow-crit animate-pulse-led; } + .status-dot-idle { @apply bg-text-muted; } + + .card { + @apply bg-surface border border-border rounded-lg; + } + .card-raised { + @apply bg-raised border border-border rounded-lg shadow-panel; + } + + .table-row-hover { + @apply hover:bg-raised transition-colors duration-100 cursor-pointer; + } + + .field { + @apply w-full bg-canvas border border-border rounded-md px-3 py-2 + text-sm text-text-primary placeholder-text-muted + focus:border-accent focus:ring-0 + transition-colors duration-150; + } + .field-label { + @apply block text-xs font-mono uppercase tracking-widest text-text-muted mb-1.5; + } + + .btn-primary { + @apply inline-flex items-center gap-2 px-4 py-2 rounded-md + bg-accent hover:bg-accent-bright text-white text-sm font-medium + shadow-glow-sm hover:shadow-glow + transition-all duration-150 disabled:opacity-40 disabled:cursor-not-allowed; + } + + .btn-ghost { + @apply inline-flex items-center gap-2 px-4 py-2 rounded-md + bg-transparent hover:bg-raised text-text-secondary hover:text-text-primary + border border-border hover:border-border-bright text-sm font-medium + transition-all duration-150; + } + + .btn-danger { + @apply inline-flex items-center gap-2 px-4 py-2 rounded-md + bg-crit/10 hover:bg-crit/20 text-crit border border-crit/30 text-sm font-medium + transition-all duration-150; + } + + .badge { + @apply inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-mono font-medium; + } + .badge-ok { @apply bg-ok/10 text-ok border border-ok/20; } + .badge-warn { @apply bg-warn/10 text-warn border border-warn/20; } + .badge-crit { @apply bg-crit/10 text-crit border border-crit/20; } + .badge-info { @apply bg-info/10 text-info border border-info/20; } + .badge-muted { @apply bg-raised text-text-muted border border-border; } + + .section-title { + @apply font-mono text-xs uppercase tracking-widest text-text-muted; + } + + .drawer-overlay { + @apply fixed inset-0 bg-canvas/60 backdrop-blur-sm z-40; + } + .drawer-panel { + @apply fixed top-0 right-0 h-full w-full max-w-lg bg-surface + border-l border-border shadow-panel z-50 + animate-slide-in-right flex flex-col; + } +} diff --git a/zroc-ui/tailwind.config.js b/zroc-ui/tailwind.config.js new file mode 100644 index 0000000..7a048ef --- /dev/null +++ b/zroc-ui/tailwind.config.js @@ -0,0 +1,66 @@ +// tailwind.config.js +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./index.html', './src/**/*.{js,jsx,ts,tsx}'], + theme: { + extend: { + colors: { + canvas: '#080d1a', + surface: '#0d1526', + raised: '#131f35', + border: '#1e2d47', + 'border-bright': '#2a4066', + accent: { + DEFAULT: '#0ea5e9', + dim: '#0284c7', + bright: '#38bdf8', + glow: 'rgba(14,165,233,0.15)', + }, + ok: '#10b981', + warn: '#f59e0b', + crit: '#ef4444', + info: '#818cf8', + 'text-primary': '#e2e8f0', + 'text-secondary': '#7c93b5', + 'text-muted': '#4a6080', + }, + fontFamily: { + mono: ['"IBM Plex Mono"', 'monospace'], + sans: ['"DM Sans"', 'system-ui', 'sans-serif'], + data: ['"JetBrains Mono"', 'monospace'], + }, + boxShadow: { + glow: '0 0 20px rgba(14,165,233,0.2)', + 'glow-sm': '0 0 8px rgba(14,165,233,0.15)', + 'glow-ok': '0 0 12px rgba(16,185,129,0.2)', + 'glow-crit':'0 0 12px rgba(239,68,68,0.25)', + panel: '0 4px 24px rgba(0,0,0,0.4)', + }, + keyframes: { + pulse_led: { + '0%, 100%': { opacity: '1' }, + '50%': { opacity: '0.35' }, + }, + slide_in_right: { + from: { transform: 'translateX(100%)', opacity: '0' }, + to: { transform: 'translateX(0)', opacity: '1' }, + }, + fade_in: { + from: { opacity: '0', transform: 'translateY(6px)' }, + to: { opacity: '1', transform: 'translateY(0)' }, + }, + modal_in: { + from: { opacity: '0', transform: 'scale(0.96)' }, + to: { opacity: '1', transform: 'scale(1)' }, + }, + }, + animation: { + 'pulse-led': 'pulse_led 2s ease-in-out infinite', + 'slide-in-right': 'slide_in_right 0.25s ease-out', + 'fade-in': 'fade_in 0.2s ease-out', + 'modal-in': 'modal_in 0.2s ease-out', + }, + }, + }, + plugins: [], +}; diff --git a/zroc-ui/vite.config.js b/zroc-ui/vite.config.js new file mode 100644 index 0000000..87c9ed6 --- /dev/null +++ b/zroc-ui/vite.config.js @@ -0,0 +1,34 @@ +// vite.config.js +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import path from 'path'; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { '@': path.resolve(__dirname, 'src') }, + }, + server: { + port: 5173, + proxy: { + '/api': { + target: 'http://localhost:3001', + changeOrigin: true, + }, + }, + }, + build: { + outDir: 'dist', + sourcemap: false, + rollupOptions: { + output: { + manualChunks: { + vendor: ['react', 'react-dom', 'react-router-dom'], + charts: ['recharts'], + query: ['@tanstack/react-query'], + icons: ['lucide-react'], + }, + }, + }, + }, +});