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

{ts}

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

{title}

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

Query failed

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

No data

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

{s.label}

+

{s.value}

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

VM Encryption Status

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

No encryption stats available

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

{label}

+

{value ?? '—'}

+ {sub &&

{sub}

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

{site.siteName}

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

{site.ok}

OK

+

{site.warn}

Warn

+

{site.crit}

Crit

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

Sites

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

VPG RPO Heat Grid

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

Top RPO Violators

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

Collector Health

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

No exporter data

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

{t.thread}

+

{t.instance}

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

Delete user

This cannot be undone

+
+

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

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

Set up 2FA

{user.name}

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

Generating setup link…

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

Failed to generate setup link.

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

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

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

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

+ +
+
+
+

Identity

+
+
+
+
+
+
+
+

Groups

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

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

+ + {errors.password &&

{errors.password}

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

+ User Management +

+

{total} users

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

{u.name}

{u.username}

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

{ds.name}

+

{ds.siteName}

+
+
+
+

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

+

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

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

Zerto Usage ({formatBytes(zertoUsed)})

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

{s.label}

+

{s.value}

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

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

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

No datastore data available

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

{vm.name}

+

{vm.vpgName} · {vm.siteName}

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

{label}

{value}

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

{vpgName}

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

{s.value}

+

{s.label}

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

Protected VMs

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

{vra.name}

+

{vra.siteName}

+
+
+
+

{vra.vcpuCount} vCPU

+

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

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

Workload

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

{s.value}

+

{s.label}

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

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

+
+ {siteVras.map((vra) => )} +
+
+ ))} +
+ ); +}