Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cf71a06638 | |||
| 450f50ddf4 | |||
| fd9a5926c0 | |||
| b7b9f6191d | |||
| 5a617fd550 | |||
| ec794996bb | |||
| 0500ac171c |
@@ -0,0 +1,47 @@
|
||||
{
|
||||
admin off
|
||||
auto_https off
|
||||
log {
|
||||
format json
|
||||
}
|
||||
}
|
||||
|
||||
:443 {
|
||||
tls internal
|
||||
|
||||
handle /auth/* {
|
||||
reverse_proxy authentik-server:9000 {
|
||||
header_up X-Forwarded-Proto https
|
||||
header_up X-Forwarded-For {remote_host}
|
||||
}
|
||||
}
|
||||
|
||||
handle /outpost.goauthentik.io/* {
|
||||
reverse_proxy authentik-server:9000 {
|
||||
header_up X-Forwarded-Proto https
|
||||
}
|
||||
}
|
||||
|
||||
handle {
|
||||
reverse_proxy zroc-ui:3001 {
|
||||
header_up X-Forwarded-Proto https
|
||||
header_up X-Forwarded-For {remote_host}
|
||||
header_up X-Real-IP {remote_host}
|
||||
health_uri /api/health
|
||||
health_interval 15s
|
||||
}
|
||||
}
|
||||
|
||||
header {
|
||||
X-Frame-Options "SAMEORIGIN"
|
||||
X-Content-Type-Options "nosniff"
|
||||
X-XSS-Protection "1; mode=block"
|
||||
Referrer-Policy "strict-origin-when-cross-origin"
|
||||
Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||
-Server
|
||||
}
|
||||
}
|
||||
|
||||
:80 {
|
||||
redir https://{host}{uri} permanent
|
||||
}
|
||||
+256
-78
@@ -1,111 +1,289 @@
|
||||
version: '3.7'
|
||||
---
|
||||
# zROC — Zerto Resiliency Observation Console
|
||||
# Full stack: Caddy (TLS), zroc-ui (React dashboard + Node backend), Authentik (SSO),
|
||||
# Prometheus, Grafana, Zerto Exporter, Watchtower (auto-updates).
|
||||
#
|
||||
# Configuration is driven entirely by /opt/zroc/.env — see zroc-setup wizard.
|
||||
|
||||
version: '3.8'
|
||||
|
||||
networks:
|
||||
front-tier:
|
||||
back-tier:
|
||||
auth-tier:
|
||||
|
||||
volumes:
|
||||
prometheus_data: {}
|
||||
grafana_data: {}
|
||||
prometheus_data: {}
|
||||
grafana_data: {}
|
||||
zroc_ui_data: {}
|
||||
authentik_postgres: {}
|
||||
authentik_redis: {}
|
||||
authentik_media: {}
|
||||
caddy_data: {}
|
||||
|
||||
services:
|
||||
|
||||
# Exporter for ZVM/vCenter site 1
|
||||
zertoexporter:
|
||||
container_name: zvmexporter1
|
||||
hostname: zvmexporter1 # this hostname will need to be set in the prometheus.yaml file as well
|
||||
image: recklessop/zerto-exporter:stable
|
||||
command: python python-node-exporter.py
|
||||
# ── Reverse proxy / TLS termination ───────────────────────────────────────
|
||||
caddy:
|
||||
image: caddy:2-alpine
|
||||
container_name: zroc-caddy
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "9999:9999"
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./zvmexporter/:/usr/src/app/logs/
|
||||
- ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- ./certs:/certs:ro
|
||||
- caddy_data:/data
|
||||
networks:
|
||||
- front-tier
|
||||
depends_on:
|
||||
- zroc-ui
|
||||
- authentik-server
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:80"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
# ── zROC React UI + Node backend ──────────────────────────────────────────
|
||||
zroc-ui:
|
||||
image: recklessop/zroc-ui:stable
|
||||
container_name: zroc-ui
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# Site 1 configuration settings
|
||||
- VERIFY_SSL=False
|
||||
- ZVM_HOST=192.168.50.60
|
||||
- ZVM_PORT=443
|
||||
- SCRAPE_SPEED=20 #how often should the exporter scrape the Zerto API
|
||||
- CLIENT_ID=api-script
|
||||
- CLIENT_SECRET=js51tDM8oappYUGRJBhF7bcsedNoHA5j
|
||||
- LOGLEVEL=DEBUG
|
||||
- VCENTER_HOST=vcenter.local
|
||||
- VCENTER_USER=administrator@vsphere.local
|
||||
- VCENTER_PASSWORD=password
|
||||
networks:
|
||||
- back-tier
|
||||
restart: always
|
||||
|
||||
# This is used for a second ZVM / vCenter (maybe your DR site?)
|
||||
#zertoexporter2:
|
||||
# container_name: zvmexporter2
|
||||
# hostname: zvmexporter2
|
||||
# image: recklessop/zerto-exporter:stable
|
||||
# command: python python-node-exporter.py
|
||||
# ports:
|
||||
# - "9998:9999" # if you add a third or more exporters change the port number before the :
|
||||
# volumes:
|
||||
# - ./zvmexporter/:/usr/src/app/logs/
|
||||
# environment:
|
||||
# # Site 2 configuration settings
|
||||
# - VERIFY_SSL=False
|
||||
# - ZVM_HOST=192.168.50.30
|
||||
# - ZVM_PORT=443
|
||||
# - SCRAPE_SPEED=20 #how often should the exporter scrape the Zerto API
|
||||
# - CLIENT_ID=api-script
|
||||
# - CLIENT_SECRET=x2aokKGPyS1O6LCW2uNqm2tbko2PLUSn
|
||||
# - LOGLEVEL=DEBUG
|
||||
# - VCENTER_HOST=192.168.50.20
|
||||
# - VCENTER_USER=administrator@vsphere.local
|
||||
# - VCENTER_PASSWORD=password
|
||||
# networks:
|
||||
# - back-tier
|
||||
# restart: always
|
||||
|
||||
prometheus:
|
||||
image: prom/prometheus:v2.40.6
|
||||
NODE_ENV: production
|
||||
PORT: "3001"
|
||||
PROMETHEUS_URL: http://zroc-prometheus:9090
|
||||
AUTHENTIK_URL: http://authentik-server:9000
|
||||
AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID}
|
||||
AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_CLIENT_SECRET}
|
||||
AUTHENTIK_ADMIN_TOKEN: ${AUTHENTIK_ADMIN_TOKEN}
|
||||
PUBLIC_URL: ${PUBLIC_URL}
|
||||
SESSION_SECRET: ${SESSION_SECRET}
|
||||
JWT_EXPIRY_HOURS: "24"
|
||||
AUTHENTIK_ADMIN_GROUP: zroc-admins
|
||||
AUTHENTIK_VIEWER_GROUP: zroc-viewers
|
||||
volumes:
|
||||
- ./prometheus/:/etc/prometheus/
|
||||
- prometheus_data:/prometheus
|
||||
command:
|
||||
- '--config.file=/etc/prometheus/prometheus.yml'
|
||||
- '--storage.tsdb.path=/prometheus'
|
||||
- '--web.console.libraries=/usr/share/prometheus/console_libraries'
|
||||
- '--web.console.templates=/usr/share/prometheus/consoles'
|
||||
ports:
|
||||
- 9090:9090
|
||||
- zroc_ui_data:/app/data
|
||||
networks:
|
||||
- front-tier
|
||||
- back-tier
|
||||
- auth-tier
|
||||
depends_on:
|
||||
- zroc-prometheus
|
||||
- authentik-server
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:3001/api/health"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
|
||||
# ── SSO — Authentik ───────────────────────────────────────────────────────
|
||||
authentik-postgresql:
|
||||
image: postgres:16-alpine
|
||||
container_name: authentik-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: authentik
|
||||
POSTGRES_USER: authentik
|
||||
POSTGRES_PASSWORD: ${AUTHENTIK_PG_PASS}
|
||||
volumes:
|
||||
- authentik_postgres:/var/lib/postgresql/data
|
||||
networks:
|
||||
- auth-tier
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U authentik"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
authentik-redis:
|
||||
image: redis:7-alpine
|
||||
container_name: authentik-redis
|
||||
restart: unless-stopped
|
||||
command: --save 60 1 --loglevel warning
|
||||
volumes:
|
||||
- authentik_redis:/data
|
||||
networks:
|
||||
- auth-tier
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
authentik-server:
|
||||
image: ghcr.io/goauthentik/server:latest
|
||||
container_name: authentik-server
|
||||
restart: unless-stopped
|
||||
command: server
|
||||
environment:
|
||||
AUTHENTIK_REDIS__HOST: authentik-redis
|
||||
AUTHENTIK_POSTGRESQL__HOST: authentik-postgresql
|
||||
AUTHENTIK_POSTGRESQL__USER: authentik
|
||||
AUTHENTIK_POSTGRESQL__NAME: authentik
|
||||
AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_PG_PASS}
|
||||
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
|
||||
AUTHENTIK_DISABLE_STARTUP_ANALYTICS: "true"
|
||||
AUTHENTIK_ERROR_REPORTING__ENABLED: "false"
|
||||
ZROC_OIDC_CLIENT_ID: ${ZROC_OIDC_CLIENT_ID}
|
||||
ZROC_OIDC_CLIENT_SECRET: ${ZROC_OIDC_CLIENT_SECRET}
|
||||
ZROC_PUBLIC_URL: ${ZROC_PUBLIC_URL}
|
||||
volumes:
|
||||
- authentik_media:/media
|
||||
- ./zroc-ui/authentik/blueprints:/blueprints/custom:ro
|
||||
networks:
|
||||
- auth-tier
|
||||
- front-tier
|
||||
depends_on:
|
||||
authentik-postgresql:
|
||||
condition: service_healthy
|
||||
authentik-redis:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "ak healthcheck || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 60s
|
||||
|
||||
authentik-worker:
|
||||
image: ghcr.io/goauthentik/server:latest
|
||||
container_name: authentik-worker
|
||||
restart: unless-stopped
|
||||
command: worker
|
||||
environment:
|
||||
AUTHENTIK_REDIS__HOST: authentik-redis
|
||||
AUTHENTIK_POSTGRESQL__HOST: authentik-postgresql
|
||||
AUTHENTIK_POSTGRESQL__USER: authentik
|
||||
AUTHENTIK_POSTGRESQL__NAME: authentik
|
||||
AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_PG_PASS}
|
||||
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
|
||||
AUTHENTIK_DISABLE_STARTUP_ANALYTICS: "true"
|
||||
volumes:
|
||||
- authentik_media:/media
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
networks:
|
||||
- auth-tier
|
||||
depends_on:
|
||||
- authentik-server
|
||||
user: root
|
||||
|
||||
# ── Metrics — Zerto exporter ──────────────────────────────────────────────
|
||||
zertoexporter:
|
||||
image: recklessop/zerto-exporter:stable
|
||||
container_name: zvmexporter1
|
||||
hostname: zvmexporter1
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./zvmexporter:/usr/src/app/logs
|
||||
environment:
|
||||
VERIFY_SSL: "False"
|
||||
ZVM_HOST: ${ZVM_HOST}
|
||||
ZVM_PORT: "443"
|
||||
ZVM_USERNAME: ${ZVM_USERNAME}
|
||||
ZVM_PASSWORD: ${ZVM_PASSWORD}
|
||||
SCRAPE_SPEED: "20"
|
||||
CLIENT_ID: ${ZVM_CLIENT_ID:-api-script}
|
||||
CLIENT_SECRET: ${ZVM_CLIENT_SECRET}
|
||||
LOGLEVEL: INFO
|
||||
VCENTER_HOST: ${VCENTER_HOST:-}
|
||||
VCENTER_USER: ${VCENTER_USER:-administrator@vsphere.local}
|
||||
VCENTER_PASSWORD: ${VCENTER_PASSWORD:-}
|
||||
networks:
|
||||
- back-tier
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:9999/metrics"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
# Optional second ZVM/vCenter site — uncomment and set ZVM2_* env vars
|
||||
# zertoexporter2:
|
||||
# image: recklessop/zerto-exporter:stable
|
||||
# container_name: zvmexporter2
|
||||
# hostname: zvmexporter2
|
||||
# restart: unless-stopped
|
||||
# ports:
|
||||
# - "9998:9999"
|
||||
# volumes:
|
||||
# - ./zvmexporter:/usr/src/app/logs
|
||||
# environment:
|
||||
# VERIFY_SSL: "False"
|
||||
# ZVM_HOST: ${ZVM2_HOST}
|
||||
# ZVM_PORT: "443"
|
||||
# ZVM_USERNAME: ${ZVM2_USERNAME}
|
||||
# ZVM_PASSWORD: ${ZVM2_PASSWORD}
|
||||
# SCRAPE_SPEED: "20"
|
||||
# LOGLEVEL: INFO
|
||||
# networks:
|
||||
# - back-tier
|
||||
|
||||
# ── Metrics — Prometheus ──────────────────────────────────────────────────
|
||||
zroc-prometheus:
|
||||
image: prom/prometheus:v2.51.0
|
||||
container_name: zroc-prometheus
|
||||
restart: unless-stopped
|
||||
command:
|
||||
- --config.file=/etc/prometheus/prometheus.yml
|
||||
- --storage.tsdb.path=/prometheus
|
||||
- --storage.tsdb.retention.time=30d
|
||||
- --storage.tsdb.retention.size=20GB
|
||||
- --web.listen-address=0.0.0.0:9090
|
||||
- --web.enable-lifecycle
|
||||
volumes:
|
||||
- ./prometheus:/etc/prometheus:ro
|
||||
- prometheus_data:/prometheus
|
||||
networks:
|
||||
- back-tier
|
||||
restart: always
|
||||
depends_on:
|
||||
- zertoexporter
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:9090/-/healthy"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
# ── Dashboards — Grafana ──────────────────────────────────────────────────
|
||||
grafana:
|
||||
image: grafana/grafana
|
||||
image: grafana/grafana:10.4.2
|
||||
container_name: zroc-grafana
|
||||
restart: unless-stopped
|
||||
user: "472"
|
||||
depends_on:
|
||||
- prometheus
|
||||
ports:
|
||||
- 3000:3000
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- grafana_data:/var/lib/grafana
|
||||
- ./grafana/provisioning/:/etc/grafana/provisioning/
|
||||
- ./grafana/provisioning:/etc/grafana/provisioning:ro
|
||||
environment:
|
||||
- GF_SECURITY_ADMIN_PASSWORD=zertodata
|
||||
- GF_USERS_ALLOW_SIGN_UP=false
|
||||
- data-source-url=http://prometheus:9090
|
||||
- name=Prometheus
|
||||
- type=prometheus
|
||||
- update-interval=10
|
||||
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_PASSWORD:-zertodata}
|
||||
GF_USERS_ALLOW_SIGN_UP: "false"
|
||||
GF_SERVER_ROOT_URL: "%(protocol)s://%(domain)s:%(http_port)s/grafana/"
|
||||
GF_AUTH_GENERIC_OAUTH_ENABLED: ${GRAFANA_OIDC_ENABLED:-false}
|
||||
GF_AUTH_GENERIC_OAUTH_NAME: Authentik
|
||||
GF_AUTH_GENERIC_OAUTH_CLIENT_ID: ${GRAFANA_CLIENT_ID:-}
|
||||
GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET: ${GRAFANA_CLIENT_SECRET:-}
|
||||
GF_AUTH_GENERIC_OAUTH_SCOPES: openid profile email
|
||||
GF_AUTH_GENERIC_OAUTH_AUTH_URL: ${PUBLIC_URL:-}/auth/application/o/authorize/
|
||||
GF_AUTH_GENERIC_OAUTH_TOKEN_URL: http://authentik-server:9000/application/o/token/
|
||||
GF_AUTH_GENERIC_OAUTH_API_URL: http://authentik-server:9000/application/o/userinfo/
|
||||
networks:
|
||||
- back-tier
|
||||
- front-tier
|
||||
restart: always
|
||||
depends_on:
|
||||
- zroc-prometheus
|
||||
|
||||
# ── Auto-updates — Watchtower ─────────────────────────────────────────────
|
||||
watchtower:
|
||||
image: containrrr/watchtower
|
||||
container_name: zroc-watchtower
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
environment:
|
||||
- WATCHTOWER_POLL_INTERVAL=360 # 1 hour
|
||||
restart: always
|
||||
WATCHTOWER_POLL_INTERVAL: "3600"
|
||||
WATCHTOWER_CLEANUP: "true"
|
||||
WATCHTOWER_INCLUDE_STOPPED: "false"
|
||||
command: --label-enable
|
||||
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
name: Build & Release OVA
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v[0-9]+.[0-9]+.[0-9]+'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version string (e.g. 1.0.0)'
|
||||
required: true
|
||||
default: '1.0.0'
|
||||
|
||||
jobs:
|
||||
build-ova:
|
||||
name: Build OVA
|
||||
runs-on: [self-hosted, linux, kvm]
|
||||
timeout-minutes: 120
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Resolve version
|
||||
id: ver
|
||||
run: |
|
||||
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
||||
VERSION="${{ github.event.inputs.version }}"
|
||||
else
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "tag=v$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "ova_name=zroc-appliance-${VERSION}-ubuntu-24.04-amd64.ova" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Install Packer
|
||||
run: |
|
||||
curl -fsSL https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp.gpg
|
||||
echo "deb [signed-by=/usr/share/keyrings/hashicorp.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" \
|
||||
| sudo tee /etc/apt/sources.list.d/hashicorp.list
|
||||
sudo apt-get update -y && sudo apt-get install -y packer
|
||||
|
||||
- name: Packer init
|
||||
working-directory: packer
|
||||
run: packer init ubuntu-2404.pkr.hcl
|
||||
|
||||
- name: Validate
|
||||
working-directory: packer
|
||||
run: |
|
||||
packer validate \
|
||||
-var "vm_version=${{ steps.ver.outputs.version }}" \
|
||||
-var-file=variables.pkrvars.hcl \
|
||||
ubuntu-2404.pkr.hcl
|
||||
|
||||
- name: Build OVA
|
||||
working-directory: packer
|
||||
env:
|
||||
PACKER_LOG: 1
|
||||
PACKER_LOG_PATH: packer-build.log
|
||||
run: |
|
||||
packer build \
|
||||
-var "vm_version=${{ steps.ver.outputs.version }}" \
|
||||
-var "headless=true" \
|
||||
-var-file=variables.pkrvars.hcl \
|
||||
ubuntu-2404.pkr.hcl
|
||||
|
||||
- name: Locate OVA
|
||||
id: ova
|
||||
run: |
|
||||
OVA_PATH=$(find output -name "*.ova" | head -1)
|
||||
echo "path=$OVA_PATH" >> $GITHUB_OUTPUT
|
||||
ls -lh "$OVA_PATH"
|
||||
|
||||
- name: Checksum
|
||||
run: |
|
||||
sha256sum "${{ steps.ova.outputs.path }}" \
|
||||
> "${{ steps.ova.outputs.path }}.sha256"
|
||||
cat "${{ steps.ova.outputs.path }}.sha256"
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ steps.ver.outputs.tag }}
|
||||
name: "zROC Appliance ${{ steps.ver.outputs.tag }}"
|
||||
draft: false
|
||||
prerelease: false
|
||||
files: |
|
||||
${{ steps.ova.outputs.path }}
|
||||
${{ steps.ova.outputs.path }}.sha256
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
|
||||
- name: Upload build log (on failure)
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: packer-build-log
|
||||
path: packer/packer-build.log
|
||||
@@ -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 ""
|
||||
@@ -0,0 +1,47 @@
|
||||
# zroc-ova — zROC Appliance Builder
|
||||
|
||||
Packer build definitions and provisioner scripts for the **zROC Ubuntu 24.04 LTS OVA appliance**.
|
||||
|
||||
## What you get
|
||||
|
||||
A 100 GB thin-provisioned VMware OVA containing:
|
||||
- Ubuntu Server 24.04 LTS
|
||||
- Docker Engine + Compose plugin
|
||||
- Full zROC stack (cloned from recklessop/zroc)
|
||||
- Interactive first-boot setup wizard (`zroc-setup`)
|
||||
- UFW firewall pre-configured (22, 80, 443, 3000)
|
||||
- VMware guest tools (`open-vm-tools`)
|
||||
- Automatic security patches (`unattended-upgrades`)
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
git clone https://github.com/recklessop/zroc-ova.git
|
||||
cd zroc-ova
|
||||
make init
|
||||
make validate
|
||||
make build VERSION=1.0.0
|
||||
make package VERSION=1.0.0
|
||||
make checksum VERSION=1.0.0
|
||||
```
|
||||
|
||||
## Deploy
|
||||
|
||||
1. Import the OVA into vSphere
|
||||
2. Allocate: 4 vCPU, 8 GB RAM, 100 GB thin datastore
|
||||
3. Power on — setup wizard launches automatically
|
||||
4. Follow the 6-step wizard
|
||||
5. Access: `https://<appliance-ip>`
|
||||
|
||||
## VM Requirements
|
||||
|
||||
| | Minimum | Recommended |
|
||||
|---|---|---|
|
||||
| vCPU | 2 | 4 |
|
||||
| RAM | 6 GB | 8 GB |
|
||||
| Disk | 100 GB thin | 100 GB thin |
|
||||
| vSphere | 7.0+ | 8.x |
|
||||
|
||||
## License
|
||||
|
||||
Apache 2.0
|
||||
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env bash
|
||||
# /usr/local/bin/zroc-setup
|
||||
# Interactive first-boot configuration wizard for the zROC appliance.
|
||||
set -euo pipefail
|
||||
|
||||
INSTALL_DIR=/opt/zroc
|
||||
ENV_FILE="$INSTALL_DIR/.env"
|
||||
CERTS_DIR="$INSTALL_DIR/certs"
|
||||
|
||||
CYAN='\033[0;36m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'; BOLD='\033[1m'; RESET='\033[0m'
|
||||
|
||||
header() { echo -e "\n${CYAN}${BOLD}$*${RESET}"; }
|
||||
ok() { echo -e "${GREEN}✓ $*${RESET}"; }
|
||||
warn() { echo -e "${YELLOW}⚠ $*${RESET}"; }
|
||||
err() { echo -e "${RED}✗ $*${RESET}"; }
|
||||
step() { echo -e "\n${BOLD}Step $*${RESET}"; echo "$(printf '─%.0s' {1..55})"; }
|
||||
|
||||
clear
|
||||
echo -e "${CYAN}"
|
||||
cat << 'BANNER'
|
||||
███████╗██████╗ ██████╗ ██████╗
|
||||
╚══███╔╝██╔══██╗██╔═══██╗██╔════╝
|
||||
███╔╝ ██████╔╝██║ ██║██║
|
||||
███╔╝ ██╔══██╗██║ ██║██║
|
||||
███████╗██║ ██║╚██████╔╝╚██████╗
|
||||
╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═════╝
|
||||
|
||||
Setup Wizard — Zerto Resiliency Observation Console
|
||||
BANNER
|
||||
echo -e "${RESET}"
|
||||
|
||||
# Step 1: Network
|
||||
step "1/6 Network Configuration"
|
||||
CURRENT_IP=$(hostname -I | awk '{print $1}')
|
||||
echo "Current IP: ${BOLD}$CURRENT_IP${RESET} (DHCP)"
|
||||
read -rp "Keep DHCP? [Y/n]: " NET_CHOICE
|
||||
NET_CHOICE="${NET_CHOICE:-Y}"
|
||||
PUBLIC_URL="https://$CURRENT_IP"
|
||||
ok "Using $CURRENT_IP"
|
||||
|
||||
# Step 2: TLS
|
||||
step "2/6 HTTPS / TLS Certificate"
|
||||
echo "Using self-signed certificate (default)"
|
||||
TLS_MODE="internal"
|
||||
ok "Self-signed certificate will be generated by Caddy"
|
||||
|
||||
# Step 3: Admin password
|
||||
step "3/6 zROC Admin Account"
|
||||
while true; do
|
||||
read -rsp "Admin password (min 12 chars): " ADMIN_PASS; echo
|
||||
read -rsp "Confirm password: " ADMIN_PASS2; echo
|
||||
if [[ "$ADMIN_PASS" != "$ADMIN_PASS2" ]]; then err "Passwords do not match.";
|
||||
elif [[ ${#ADMIN_PASS} -lt 12 ]]; then err "Password must be at least 12 characters.";
|
||||
else ok "Admin password set"; break; fi
|
||||
done
|
||||
|
||||
# Step 4: ZVM Site 1
|
||||
step "4/6 Zerto ZVM Configuration — Site 1"
|
||||
read -rp "ZVM Hostname or IP: " ZVM_HOST
|
||||
read -rp "ZVM Username [admin]: " ZVM_USER; ZVM_USER="${ZVM_USER:-admin}"
|
||||
read -rsp "ZVM Password: " ZVM_PASS; echo
|
||||
read -rp "vCenter Hostname (optional): " VCENTER_HOST
|
||||
|
||||
# Step 5: Second site
|
||||
step "5/6 Second ZVM Site (optional)"
|
||||
read -rp "Monitor a second site? [y/N]: " SITE2; SITE2="${SITE2:-N}"
|
||||
|
||||
# Step 6: Enterprise IdP
|
||||
step "6/6 Enterprise Identity Provider (optional)"
|
||||
echo "Using local Authentik accounts (default)"
|
||||
|
||||
# Generate secrets
|
||||
SESSION_SECRET=$(openssl rand -hex 32)
|
||||
AUTHENTIK_PG_PASS=$(openssl rand -hex 24)
|
||||
AUTHENTIK_SECRET_KEY=$(openssl rand -hex 48)
|
||||
OIDC_CLIENT_ID="zroc-dashboard"
|
||||
OIDC_CLIENT_SECRET=$(openssl rand -hex 32)
|
||||
|
||||
# Write .env
|
||||
cat > "$ENV_FILE" << EOF
|
||||
PUBLIC_URL=$PUBLIC_URL
|
||||
ZVM_HOST=$ZVM_HOST
|
||||
ZVM_USERNAME=$ZVM_USER
|
||||
ZVM_PASSWORD=$ZVM_PASS
|
||||
VCENTER_HOST=${VCENTER_HOST:-}
|
||||
SESSION_SECRET=$SESSION_SECRET
|
||||
AUTHENTIK_PG_PASS=$AUTHENTIK_PG_PASS
|
||||
AUTHENTIK_SECRET_KEY=$AUTHENTIK_SECRET_KEY
|
||||
AUTHENTIK_CLIENT_ID=$OIDC_CLIENT_ID
|
||||
AUTHENTIK_CLIENT_SECRET=$OIDC_CLIENT_SECRET
|
||||
ZROC_OIDC_CLIENT_ID=$OIDC_CLIENT_ID
|
||||
ZROC_OIDC_CLIENT_SECRET=$OIDC_CLIENT_SECRET
|
||||
ZROC_PUBLIC_URL=$PUBLIC_URL
|
||||
AUTHENTIK_ADMIN_TOKEN=PENDING_FIRST_START
|
||||
GRAFANA_PASSWORD=$ADMIN_PASS
|
||||
PROMETHEUS_URL=http://prometheus:9090
|
||||
EOF
|
||||
|
||||
chmod 600 "$ENV_FILE"
|
||||
ok ".env written to $ENV_FILE"
|
||||
|
||||
# Start services
|
||||
echo "Starting zROC services..."
|
||||
cd "$INSTALL_DIR"
|
||||
docker compose up -d 2>&1 | tail -20
|
||||
|
||||
systemctl disable zroc-firstboot.service 2>/dev/null || true
|
||||
|
||||
echo -e "${GREEN}${BOLD}"
|
||||
echo " ✅ zROC is ready!"
|
||||
echo " Dashboard: $PUBLIC_URL"
|
||||
echo -e "${RESET}"
|
||||
@@ -0,0 +1,2 @@
|
||||
instance-id: zroc-appliance-build
|
||||
local-hostname: zroc-appliance
|
||||
@@ -0,0 +1,82 @@
|
||||
#cloud-config
|
||||
autoinstall:
|
||||
version: 1
|
||||
|
||||
locale: en_US.UTF-8
|
||||
keyboard:
|
||||
layout: us
|
||||
|
||||
source:
|
||||
id: ubuntu-server-minimal
|
||||
|
||||
storage:
|
||||
layout:
|
||||
name: direct
|
||||
config:
|
||||
- type: disk
|
||||
id: disk0
|
||||
match:
|
||||
size: largest
|
||||
ptable: gpt
|
||||
wipe: superblock-recursive
|
||||
preserve: false
|
||||
grub_device: true
|
||||
- type: partition
|
||||
id: part-efi
|
||||
device: disk0
|
||||
size: 512M
|
||||
flag: boot
|
||||
number: 1
|
||||
preserve: false
|
||||
- type: format
|
||||
id: fmt-efi
|
||||
volume: part-efi
|
||||
fstype: fat32
|
||||
preserve: false
|
||||
- type: partition
|
||||
id: part-root
|
||||
device: disk0
|
||||
size: -1
|
||||
number: 2
|
||||
preserve: false
|
||||
- type: format
|
||||
id: fmt-root
|
||||
volume: part-root
|
||||
fstype: ext4
|
||||
preserve: false
|
||||
- type: mount
|
||||
id: mnt-root
|
||||
device: fmt-root
|
||||
path: /
|
||||
- type: mount
|
||||
id: mnt-efi
|
||||
device: fmt-efi
|
||||
path: /boot/efi
|
||||
|
||||
identity:
|
||||
hostname: zroc-appliance
|
||||
username: zroc
|
||||
password: "$6$rounds=4096$packer$xKDMK6dLB2.8PZnJnXGpYQ9o0CWbEe4s7T5JY3bVq1ZQ2RQ6y7dAjH4wqpVLBkHPHU/CuW7.8SsLQ6TYe1"
|
||||
|
||||
ssh:
|
||||
install-server: true
|
||||
allow-pw: true
|
||||
|
||||
packages:
|
||||
- curl
|
||||
- wget
|
||||
- git
|
||||
- vim
|
||||
- htop
|
||||
- net-tools
|
||||
- open-vm-tools
|
||||
- ca-certificates
|
||||
- gnupg
|
||||
- lsb-release
|
||||
- unattended-upgrades
|
||||
- apt-transport-https
|
||||
|
||||
late-commands:
|
||||
- echo 'zroc ALL=(ALL) NOPASSWD:ALL' > /target/etc/sudoers.d/zroc-packer
|
||||
- chmod 440 /target/etc/sudoers.d/zroc-packer
|
||||
- echo 'net.ipv6.conf.all.disable_ipv6 = 1' >> /target/etc/sysctl.d/99-zroc.conf
|
||||
@@ -0,0 +1,134 @@
|
||||
packer {
|
||||
required_version = ">= 1.10.0"
|
||||
required_plugins {
|
||||
qemu = {
|
||||
source = "github.com/hashicorp/qemu"
|
||||
version = "~> 1.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
variable "ubuntu_iso_url" {
|
||||
type = string
|
||||
default = "https://releases.ubuntu.com/24.04/ubuntu-24.04.4-live-server-amd64.iso"
|
||||
}
|
||||
|
||||
variable "ubuntu_iso_checksum" {
|
||||
type = string
|
||||
default = "sha256:e907d92eeec9df64163a7e454cbc8d7755e8ddc7ed42f99dbc80c40f1a138433"
|
||||
}
|
||||
|
||||
variable "vm_name" {
|
||||
type = string
|
||||
default = "zroc-appliance"
|
||||
}
|
||||
|
||||
variable "vm_version" {
|
||||
type = string
|
||||
default = "1.0.0"
|
||||
}
|
||||
|
||||
variable "disk_size_mb" {
|
||||
type = number
|
||||
default = 102400
|
||||
}
|
||||
|
||||
variable "memory_mb" {
|
||||
type = number
|
||||
default = 8192
|
||||
}
|
||||
|
||||
variable "cpus" {
|
||||
type = number
|
||||
default = 4
|
||||
}
|
||||
|
||||
variable "output_dir" {
|
||||
type = string
|
||||
default = "../output"
|
||||
}
|
||||
|
||||
source "qemu" "ubuntu2404" {
|
||||
vm_name = "${var.vm_name}-${var.vm_version}"
|
||||
iso_url = var.ubuntu_iso_url
|
||||
iso_checksum = var.ubuntu_iso_checksum
|
||||
disk_size = "${var.disk_size_mb}M"
|
||||
disk_interface = "virtio"
|
||||
format = "qcow2"
|
||||
memory = var.memory_mb
|
||||
cpus = var.cpus
|
||||
accelerator = "kvm"
|
||||
headless = true
|
||||
http_directory = "http"
|
||||
http_port_min = 8100
|
||||
http_port_max = 8199
|
||||
boot_wait = "5s"
|
||||
boot_command = [
|
||||
"e<wait>",
|
||||
"<down><down><down><end>",
|
||||
" autoinstall ds=nocloud-net;seedfrom=http://{{.HTTPIP}}:{{.HTTPPort}}/",
|
||||
"<f10><wait60s>",
|
||||
]
|
||||
ssh_username = "zroc"
|
||||
ssh_password = "zroc-setup-temp"
|
||||
ssh_timeout = "45m"
|
||||
shutdown_command = "echo 'zroc-setup-temp' | sudo -S shutdown -P now"
|
||||
output_directory = "${var.output_dir}/qemu"
|
||||
}
|
||||
|
||||
build {
|
||||
name = "zroc-appliance"
|
||||
sources = ["source.qemu.ubuntu2404"]
|
||||
|
||||
# Copy overlay files (setup wizard binary, etc.) into the VM
|
||||
provisioner "file" {
|
||||
source = "../overlays/"
|
||||
destination = "/tmp/overlays/"
|
||||
}
|
||||
|
||||
provisioner "shell" {
|
||||
script = "../scripts/00-base.sh"
|
||||
execute_command = "echo 'zroc-setup-temp' | sudo -S bash {{.Path}}"
|
||||
expect_disconnect = true
|
||||
}
|
||||
|
||||
provisioner "shell" {
|
||||
script = "../scripts/01-docker.sh"
|
||||
execute_command = "echo 'zroc-setup-temp' | sudo -S bash {{.Path}}"
|
||||
pause_before = "15s"
|
||||
}
|
||||
|
||||
provisioner "shell" {
|
||||
script = "../scripts/02-zroc.sh"
|
||||
execute_command = "echo 'zroc-setup-temp' | sudo -S bash {{.Path}}"
|
||||
}
|
||||
|
||||
provisioner "shell" {
|
||||
script = "../scripts/03-setup-wizard.sh"
|
||||
execute_command = "echo 'zroc-setup-temp' | sudo -S bash {{.Path}}"
|
||||
}
|
||||
|
||||
provisioner "shell" {
|
||||
script = "../scripts/04-systemd-service.sh"
|
||||
execute_command = "echo 'zroc-setup-temp' | sudo -S bash {{.Path}}"
|
||||
}
|
||||
|
||||
provisioner "shell" {
|
||||
script = "../scripts/05-cleanup.sh"
|
||||
execute_command = "echo 'zroc-setup-temp' | sudo -S bash {{.Path}}"
|
||||
}
|
||||
|
||||
# Convert qcow2 → VMDK → OVA (no ovftool required)
|
||||
post-processor "shell-local" {
|
||||
inline = [
|
||||
"bash ../scripts/qcow2-to-ova.sh ${var.output_dir}/qemu/${var.vm_name}-${var.vm_version} ${var.output_dir}/${var.vm_name}-${var.vm_version}-ubuntu-24.04-amd64.ova ${var.vm_name} ${var.vm_version}",
|
||||
]
|
||||
}
|
||||
|
||||
# Produce a KVM/libvirt/Proxmox-compatible qcow2 artifact
|
||||
post-processor "shell-local" {
|
||||
inline = [
|
||||
"bash ../scripts/qcow2-to-kvm.sh ${var.output_dir}/qemu/${var.vm_name}-${var.vm_version} ${var.output_dir}/${var.vm_name}-${var.vm_version}-ubuntu-24.04-amd64.qcow2",
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
# zroc-ova/packer/variables.pkrvars.hcl
|
||||
vm_version = "1.0.0"
|
||||
|
||||
ubuntu_iso_url = "https://releases.ubuntu.com/24.04/ubuntu-24.04.4-live-server-amd64.iso"
|
||||
ubuntu_iso_checksum = "sha256:e907d92eeec9df64163a7e454cbc8d7755e8ddc7ed42f99dbc80c40f1a138433"
|
||||
|
||||
memory_mb = 8192
|
||||
cpus = 4
|
||||
disk_size_mb = 102400
|
||||
|
||||
output_dir = "../output"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env bash
|
||||
# zroc-ova/scripts/02-zroc.sh
|
||||
set -euo pipefail
|
||||
echo "==> [02-zroc] Setting up zROC installation"
|
||||
|
||||
INSTALL_DIR=/opt/zroc
|
||||
ZROC_REPO="https://github.com/recklessop/zroc.git"
|
||||
|
||||
git clone --depth=1 "$ZROC_REPO" "$INSTALL_DIR"
|
||||
|
||||
# Ensure expected directories exist
|
||||
mkdir -p \
|
||||
"$INSTALL_DIR/certs" \
|
||||
"$INSTALL_DIR/zvmexporter" \
|
||||
"$INSTALL_DIR/data"
|
||||
|
||||
cd "$INSTALL_DIR"
|
||||
|
||||
# Pre-pull all container images into the OVA image layer so first-boot is fast.
|
||||
# Failures are non-fatal — any missing images will be pulled on first docker compose up.
|
||||
echo "==> [02-zroc] Pre-pulling container images (this may take a while)…"
|
||||
docker compose pull \
|
||||
caddy \
|
||||
zroc-ui \
|
||||
authentik-postgresql \
|
||||
authentik-redis \
|
||||
authentik-server \
|
||||
authentik-worker \
|
||||
zertoexporter \
|
||||
zroc-prometheus \
|
||||
grafana \
|
||||
watchtower \
|
||||
|| echo "[02-zroc] Warning: some images could not be pre-pulled — they will pull on first start"
|
||||
|
||||
chown -R zroc:zroc "$INSTALL_DIR"
|
||||
|
||||
echo "==> [02-zroc] Installation directory: $INSTALL_DIR"
|
||||
echo "==> [02-zroc] Done"
|
||||
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env bash
|
||||
# zroc-ova/scripts/03-setup-wizard.sh
|
||||
set -euo pipefail
|
||||
echo "==> [03-setup-wizard] Installing setup wizard"
|
||||
|
||||
# The Packer file provisioner copies overlays/ to /tmp/overlays/
|
||||
# Mirror the full directory tree into place
|
||||
cp -r /tmp/overlays/usr /
|
||||
chmod 0755 /usr/local/bin/zroc-setup
|
||||
|
||||
cat > /etc/systemd/system/zroc-firstboot.service << 'EOF'
|
||||
[Unit]
|
||||
Description=zROC First-Boot Setup Wizard
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
ConditionPathExists=!/opt/zroc/.env
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
ExecStart=/usr/local/bin/zroc-setup
|
||||
StandardInput=tty
|
||||
TTYPath=/dev/tty1
|
||||
StandardOutput=journal+console
|
||||
StandardError=journal+console
|
||||
TimeoutStartSec=0
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable zroc-firstboot.service
|
||||
|
||||
rm -f /etc/sudoers.d/zroc-packer
|
||||
cat > /etc/sudoers.d/zroc << 'EOF'
|
||||
zroc ALL=(ALL) NOPASSWD: /usr/bin/docker, /usr/local/bin/zroc-setup, /usr/bin/systemctl restart zroc
|
||||
EOF
|
||||
chmod 440 /etc/sudoers.d/zroc
|
||||
|
||||
echo "==> [03-setup-wizard] Done"
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
# zroc-ova/scripts/04-systemd-service.sh
|
||||
set -euo pipefail
|
||||
echo "==> [04-systemd-service] Installing zroc.service"
|
||||
|
||||
cat > /etc/systemd/system/zroc.service << 'EOF'
|
||||
[Unit]
|
||||
Description=zROC Observability Stack
|
||||
Documentation=https://github.com/recklessop/zroc
|
||||
After=docker.service network-online.target
|
||||
Requires=docker.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
User=zroc
|
||||
Group=zroc
|
||||
WorkingDirectory=/opt/zroc
|
||||
EnvironmentFile=-/opt/zroc/.env
|
||||
ExecStartPre=/usr/bin/docker compose pull --quiet
|
||||
ExecStart=/usr/bin/docker compose up -d --remove-orphans
|
||||
ExecStop=/usr/bin/docker compose down
|
||||
ExecReload=/usr/bin/docker compose up -d --remove-orphans
|
||||
TimeoutStartSec=180
|
||||
TimeoutStopSec=60
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
systemctl daemon-reload
|
||||
echo "==> [04-systemd-service] Done"
|
||||
@@ -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"
|
||||
Executable
+50
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env bash
|
||||
# qcow2-to-kvm.sh — Package a QEMU qcow2 image as a KVM/libvirt/Proxmox artifact.
|
||||
#
|
||||
# Usage: qcow2-to-kvm.sh <qemu_output_dir/vm_name> <output.qcow2>
|
||||
#
|
||||
# Example:
|
||||
# qcow2-to-kvm.sh ../output/qemu/zroc-appliance-1.0.0 \
|
||||
# ../output/zroc-appliance-1.0.0-ubuntu-24.04-amd64.qcow2
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
QEMU_VM_PATH="$1" # path to qcow2 (without extension) or directory
|
||||
QCOW2_OUT="$2" # destination .qcow2 file
|
||||
|
||||
# ── Locate the source qcow2 ───────────────────────────────────────────────────
|
||||
if [[ -f "${QEMU_VM_PATH}.qcow2" ]]; then
|
||||
QCOW2_SRC="${QEMU_VM_PATH}.qcow2"
|
||||
elif [[ -d "$QEMU_VM_PATH" ]]; then
|
||||
QCOW2_SRC=$(find "$QEMU_VM_PATH" -name "*.qcow2" | head -1)
|
||||
else
|
||||
QCOW2_SRC="$QEMU_VM_PATH"
|
||||
fi
|
||||
|
||||
if [[ -z "$QCOW2_SRC" || ! -f "$QCOW2_SRC" ]]; then
|
||||
echo "ERROR: could not find qcow2 image at ${QEMU_VM_PATH}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> [qcow2-to-kvm] Source qcow2: $QCOW2_SRC"
|
||||
echo "==> [qcow2-to-kvm] Output qcow2: $QCOW2_OUT"
|
||||
|
||||
mkdir -p "$(dirname "$QCOW2_OUT")"
|
||||
|
||||
# Re-encode with qemu-img to compact/sparsify and ensure compatibility.
|
||||
# subformat=compressed produces a space-efficient image suitable for distribution.
|
||||
echo "==> [qcow2-to-kvm] Compacting qcow2 for distribution…"
|
||||
qemu-img convert \
|
||||
-f qcow2 \
|
||||
-O qcow2 \
|
||||
-o compression_type=zlib,preallocation=off \
|
||||
"$QCOW2_SRC" \
|
||||
"$QCOW2_OUT"
|
||||
|
||||
SIZE=$(du -sh "$QCOW2_OUT" | cut -f1)
|
||||
SHA=$(sha256sum "$QCOW2_OUT" | awk '{print $1}')
|
||||
|
||||
echo "==> [qcow2-to-kvm] qcow2 complete: $QCOW2_OUT ($SIZE)"
|
||||
echo "==> [qcow2-to-kvm] SHA256: $SHA"
|
||||
echo "$SHA $(basename "$QCOW2_OUT")" > "${QCOW2_OUT}.sha256"
|
||||
echo "==> [qcow2-to-kvm] Done"
|
||||
Executable
+177
@@ -0,0 +1,177 @@
|
||||
#!/usr/bin/env bash
|
||||
# qcow2-to-ova.sh — Convert a QEMU qcow2 disk image to a VMware-compatible OVA
|
||||
# without requiring ovftool.
|
||||
#
|
||||
# Usage: qcow2-to-ova.sh <qemu_output_dir/vm_name> <output.ova> <vm_display_name> <version>
|
||||
#
|
||||
# Example:
|
||||
# qcow2-to-ova.sh ../output/qemu/zroc-appliance-1.0.0 \
|
||||
# ../output/zroc-appliance-1.0.0-ubuntu-24.04-amd64.ova \
|
||||
# zroc-appliance 1.0.0
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
QEMU_VM_PATH="$1" # path to the qcow2 file (without extension) or directory
|
||||
OVA_OUT="$2" # destination .ova file
|
||||
VM_NAME="$3" # display name inside the OVF
|
||||
VM_VERSION="$4" # version string
|
||||
|
||||
# ── Locate the qcow2 ──────────────────────────────────────────────────────────
|
||||
if [[ -f "${QEMU_VM_PATH}.qcow2" ]]; then
|
||||
QCOW2="${QEMU_VM_PATH}.qcow2"
|
||||
elif [[ -d "$QEMU_VM_PATH" ]]; then
|
||||
QCOW2=$(find "$QEMU_VM_PATH" -name "*.qcow2" | head -1)
|
||||
else
|
||||
# Packer QEMU plugin writes <vm_name> (no extension) as the output file
|
||||
QCOW2="$QEMU_VM_PATH"
|
||||
fi
|
||||
|
||||
if [[ -z "$QCOW2" || ! -f "$QCOW2" ]]; then
|
||||
echo "ERROR: could not find qcow2 image at ${QEMU_VM_PATH}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> [qcow2-to-ova] Source qcow2: $QCOW2"
|
||||
echo "==> [qcow2-to-ova] Output OVA: $OVA_OUT"
|
||||
|
||||
WORK_DIR=$(mktemp -d)
|
||||
trap 'rm -rf "$WORK_DIR"' EXIT
|
||||
|
||||
VMDK_NAME="${VM_NAME}-disk1.vmdk"
|
||||
OVF_NAME="${VM_NAME}.ovf"
|
||||
MF_NAME="${VM_NAME}.mf"
|
||||
|
||||
# ── 1. Convert qcow2 → stream-optimised VMDK ─────────────────────────────────
|
||||
echo "==> [qcow2-to-ova] Converting qcow2 → VMDK (stream-optimised)…"
|
||||
qemu-img convert \
|
||||
-f qcow2 \
|
||||
-O vmdk \
|
||||
-o subformat=streamOptimized,adapter_type=lsilogic,compat6 \
|
||||
"$QCOW2" \
|
||||
"${WORK_DIR}/${VMDK_NAME}"
|
||||
|
||||
DISK_SIZE_BYTES=$(qemu-img info --output=json "${WORK_DIR}/${VMDK_NAME}" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['virtual-size'])")
|
||||
DISK_SIZE_GB=$(( (DISK_SIZE_BYTES + 1073741823) / 1073741824 ))
|
||||
DISK_FILE_BYTES=$(stat -c%s "${WORK_DIR}/${VMDK_NAME}")
|
||||
|
||||
echo "==> [qcow2-to-ova] VMDK: virtual=${DISK_SIZE_GB}GB, file=${DISK_FILE_BYTES} bytes"
|
||||
|
||||
# ── 2. Generate OVF descriptor ────────────────────────────────────────────────
|
||||
echo "==> [qcow2-to-ova] Generating OVF descriptor…"
|
||||
cat > "${WORK_DIR}/${OVF_NAME}" << OVFEOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Envelope xmlns="http://schemas.dmtf.org/ovf/envelope/1"
|
||||
xmlns:cim="http://schemas.dmtf.org/wbem/wscim/1/common"
|
||||
xmlns:ovf="http://schemas.dmtf.org/ovf/envelope/1"
|
||||
xmlns:rasd="http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_ResourceAllocationSettingData"
|
||||
xmlns:vmw="http://www.vmware.com/schema/ovf"
|
||||
xmlns:vssd="http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_VirtualSystemSettingData"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
||||
<References>
|
||||
<File ovf:href="${VMDK_NAME}" ovf:id="file1" ovf:size="${DISK_FILE_BYTES}"/>
|
||||
</References>
|
||||
<DiskSection>
|
||||
<Info>Virtual disk information</Info>
|
||||
<Disk ovf:capacity="${DISK_SIZE_GB}" ovf:capacityAllocationUnits="byte * 2^30"
|
||||
ovf:diskId="vmdisk1" ovf:fileRef="file1"
|
||||
ovf:format="http://www.vmware.com/interfaces/specifications/vmdk.html#streamOptimized"
|
||||
ovf:populatedSize="${DISK_FILE_BYTES}"/>
|
||||
</DiskSection>
|
||||
<NetworkSection>
|
||||
<Info>The list of logical networks</Info>
|
||||
<Network ovf:name="VM Network">
|
||||
<Description>VM Network</Description>
|
||||
</Network>
|
||||
</NetworkSection>
|
||||
<VirtualSystem ovf:id="${VM_NAME}">
|
||||
<Info>zROC Observability Console Appliance v${VM_VERSION}</Info>
|
||||
<Name>${VM_NAME}</Name>
|
||||
<AnnotationSection>
|
||||
<Info>A human-readable annotation</Info>
|
||||
<Annotation>zROC Appliance v${VM_VERSION} — https://github.com/recklessop/zroc</Annotation>
|
||||
</AnnotationSection>
|
||||
<OperatingSystemSection ovf:id="94" vmw:osType="ubuntu64Guest">
|
||||
<Info>The kind of installed guest operating system</Info>
|
||||
<Description>Ubuntu Linux (64-bit)</Description>
|
||||
</OperatingSystemSection>
|
||||
<VirtualHardwareSection>
|
||||
<Info>Virtual hardware requirements</Info>
|
||||
<System>
|
||||
<vssd:ElementName>Virtual Hardware Family</vssd:ElementName>
|
||||
<vssd:InstanceID>0</vssd:InstanceID>
|
||||
<vssd:VirtualSystemIdentifier>${VM_NAME}</vssd:VirtualSystemIdentifier>
|
||||
<vssd:VirtualSystemType>vmx-19</vssd:VirtualSystemType>
|
||||
</System>
|
||||
<Item>
|
||||
<rasd:AllocationUnits>hertz * 10^6</rasd:AllocationUnits>
|
||||
<rasd:Description>Number of virtual CPUs</rasd:Description>
|
||||
<rasd:ElementName>4 virtual CPU(s)</rasd:ElementName>
|
||||
<rasd:InstanceID>1</rasd:InstanceID>
|
||||
<rasd:ResourceType>3</rasd:ResourceType>
|
||||
<rasd:VirtualQuantity>4</rasd:VirtualQuantity>
|
||||
</Item>
|
||||
<Item>
|
||||
<rasd:AllocationUnits>byte * 2^20</rasd:AllocationUnits>
|
||||
<rasd:Description>Memory Size</rasd:Description>
|
||||
<rasd:ElementName>8192 MB of memory</rasd:ElementName>
|
||||
<rasd:InstanceID>2</rasd:InstanceID>
|
||||
<rasd:ResourceType>4</rasd:ResourceType>
|
||||
<rasd:VirtualQuantity>8192</rasd:VirtualQuantity>
|
||||
</Item>
|
||||
<Item>
|
||||
<rasd:Address>0</rasd:Address>
|
||||
<rasd:Description>SCSI Controller</rasd:Description>
|
||||
<rasd:ElementName>SCSI Controller 0</rasd:ElementName>
|
||||
<rasd:InstanceID>3</rasd:InstanceID>
|
||||
<rasd:ResourceSubType>lsilogic</rasd:ResourceSubType>
|
||||
<rasd:ResourceType>6</rasd:ResourceType>
|
||||
</Item>
|
||||
<Item>
|
||||
<rasd:AddressOnParent>0</rasd:AddressOnParent>
|
||||
<rasd:ElementName>Hard Disk 1</rasd:ElementName>
|
||||
<rasd:HostResource>ovf:/disk/vmdisk1</rasd:HostResource>
|
||||
<rasd:InstanceID>4</rasd:InstanceID>
|
||||
<rasd:Parent>3</rasd:Parent>
|
||||
<rasd:ResourceType>17</rasd:ResourceType>
|
||||
</Item>
|
||||
<Item>
|
||||
<rasd:AddressOnParent>7</rasd:AddressOnParent>
|
||||
<rasd:AutomaticAllocation>true</rasd:AutomaticAllocation>
|
||||
<rasd:Connection>VM Network</rasd:Connection>
|
||||
<rasd:Description>VmxNet3 ethernet adapter</rasd:Description>
|
||||
<rasd:ElementName>Network Adapter 1</rasd:ElementName>
|
||||
<rasd:InstanceID>5</rasd:InstanceID>
|
||||
<rasd:ResourceSubType>VmxNet3</rasd:ResourceSubType>
|
||||
<rasd:ResourceType>10</rasd:ResourceType>
|
||||
</Item>
|
||||
</VirtualHardwareSection>
|
||||
</VirtualSystem>
|
||||
</Envelope>
|
||||
OVFEOF
|
||||
|
||||
# ── 3. Generate manifest (.mf) with SHA256 checksums ─────────────────────────
|
||||
echo "==> [qcow2-to-ova] Generating manifest…"
|
||||
OVF_SHA=$(sha256sum "${WORK_DIR}/${OVF_NAME}" | awk '{print $1}')
|
||||
VMDK_SHA=$(sha256sum "${WORK_DIR}/${VMDK_NAME}" | awk '{print $1}')
|
||||
cat > "${WORK_DIR}/${MF_NAME}" << MFEOF
|
||||
SHA256(${OVF_NAME})= ${OVF_SHA}
|
||||
SHA256(${VMDK_NAME})= ${VMDK_SHA}
|
||||
MFEOF
|
||||
|
||||
# ── 4. Package as OVA (tar, OVF first per spec) ───────────────────────────────
|
||||
echo "==> [qcow2-to-ova] Packaging OVA…"
|
||||
mkdir -p "$(dirname "$OVA_OUT")"
|
||||
tar -C "$WORK_DIR" \
|
||||
--format=ustar \
|
||||
-cf "$OVA_OUT" \
|
||||
"${OVF_NAME}" \
|
||||
"${VMDK_NAME}" \
|
||||
"${MF_NAME}"
|
||||
|
||||
OVA_SIZE=$(du -sh "$OVA_OUT" | cut -f1)
|
||||
OVA_SHA=$(sha256sum "$OVA_OUT" | awk '{print $1}')
|
||||
|
||||
echo "==> [qcow2-to-ova] OVA complete: $OVA_OUT ($OVA_SIZE)"
|
||||
echo "==> [qcow2-to-ova] SHA256: $OVA_SHA"
|
||||
echo "$OVA_SHA $(basename "$OVA_OUT")" > "${OVA_OUT}.sha256"
|
||||
echo "==> [qcow2-to-ova] Done"
|
||||
@@ -0,0 +1,60 @@
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# zROC Environment Variables
|
||||
# Copy to .env and fill in your values.
|
||||
# Generated automatically by: zroc-setup (first-boot wizard)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# ── Zerto ZVM — Site 1 ────────────────────────────────────────────────────────
|
||||
ZVM_HOST=192.168.50.60
|
||||
ZVM_USERNAME=admin
|
||||
ZVM_PASSWORD=changeme
|
||||
# Optional — needed for VRA CPU/memory metrics
|
||||
VCENTER_HOST=vcenter.local
|
||||
VCENTER_USER=administrator@vsphere.local
|
||||
VCENTER_PASSWORD=changeme
|
||||
|
||||
# ── Zerto ZVM — Site 2 (uncomment to enable) ─────────────────────────────────
|
||||
# ZVM2_HOST=192.168.60.60
|
||||
# ZVM2_USERNAME=admin
|
||||
# ZVM2_PASSWORD=changeme
|
||||
# VCENTER2_HOST=vcenter2.local
|
||||
# VCENTER2_USER=administrator@vsphere.local
|
||||
# VCENTER2_PASSWORD=changeme
|
||||
|
||||
# ── zROC UI ───────────────────────────────────────────────────────────────────
|
||||
# Public-facing URL of the appliance (used for OIDC redirect URIs)
|
||||
PUBLIC_URL=https://192.168.50.100
|
||||
|
||||
# Session secret — generate with: openssl rand -hex 32
|
||||
SESSION_SECRET=REPLACE_WITH_RANDOM_SECRET
|
||||
|
||||
# ── Authentik ─────────────────────────────────────────────────────────────────
|
||||
# PostgreSQL password — generate with: openssl rand -hex 24
|
||||
AUTHENTIK_PG_PASS=REPLACE_WITH_PG_PASSWORD
|
||||
|
||||
# Authentik secret key — generate with: openssl rand -hex 48
|
||||
AUTHENTIK_SECRET_KEY=REPLACE_WITH_AUTHENTIK_SECRET
|
||||
|
||||
# OIDC client credentials (generated by Authentik blueprint, copied here by setup wizard)
|
||||
AUTHENTIK_CLIENT_ID=zroc-dashboard
|
||||
AUTHENTIK_CLIENT_SECRET=REPLACE_AFTER_BLUEPRINT_RUNS
|
||||
ZROC_OIDC_CLIENT_ID=zroc-dashboard
|
||||
ZROC_OIDC_CLIENT_SECRET=REPLACE_AFTER_BLUEPRINT_RUNS
|
||||
|
||||
# Admin API token (generated by Authentik blueprint, retrieved by setup wizard)
|
||||
AUTHENTIK_ADMIN_TOKEN=REPLACE_AFTER_BLUEPRINT_RUNS
|
||||
|
||||
# Passed into blueprint to set redirect URI
|
||||
ZROC_PUBLIC_URL=https://192.168.50.100
|
||||
|
||||
# ── Grafana ───────────────────────────────────────────────────────────────────
|
||||
GRAFANA_PASSWORD=zertodata
|
||||
|
||||
# Optional: Grafana OIDC (integrates Grafana login with Authentik)
|
||||
GRAFANA_OIDC_ENABLED=false
|
||||
# GRAFANA_CLIENT_ID=grafana
|
||||
# GRAFANA_CLIENT_SECRET=
|
||||
|
||||
# ── Prometheus ────────────────────────────────────────────────────────────────
|
||||
# Internal only — not directly accessible from outside the stack
|
||||
PROMETHEUS_URL=http://prometheus:9090
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
name: Build & Publish Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v[0-9]+.[0-9]+.[0-9]+'
|
||||
|
||||
env:
|
||||
IMAGE: recklessop/zroc-ui
|
||||
CONTEXT: ./zroc-ui
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
name: Build & Push
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract version from tag
|
||||
id: meta
|
||||
run: |
|
||||
VERSION=${GITHUB_REF_NAME#v}
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "tag=$GITHUB_REF_NAME" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ${{ env.CONTEXT }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.IMAGE }}:${{ steps.meta.outputs.version }}
|
||||
${{ env.IMAGE }}:stable
|
||||
${{ env.IMAGE }}:latest
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
build-args: |
|
||||
BUILD_VERSION=${{ steps.meta.outputs.version }}
|
||||
BUILD_DATE=${{ github.event.repository.updated_at }}
|
||||
GIT_SHA=${{ github.sha }}
|
||||
|
||||
- name: Update Docker Hub description
|
||||
uses: peter-evans/dockerhub-description@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
repository: ${{ env.IMAGE }}
|
||||
readme-filepath: ./zroc-ui/DOCKER_README.md
|
||||
short-description: "zROC UI — Zerto Resiliency Observation Console frontend"
|
||||
@@ -0,0 +1,65 @@
|
||||
# zROC UI
|
||||
|
||||
**Zerto Resiliency Observation Console** — a purpose-built observability frontend for Zerto that replaces Zerto Analytics with a self-hosted, always-on dashboard.
|
||||
|
||||
## What it does
|
||||
|
||||
- **NOC Dashboard** — VPG health heat grid, site cards, RPO status at a glance
|
||||
- **VPG Monitor** — per-VPG RPO history, throughput/IOPS charts, journal health, VM breakdown
|
||||
- **VM Protection** — per-VM drill-down with RPO trends, journal gauges, encryption trends
|
||||
- **VRA Infrastructure** — CPU/memory usage, workload counts, volume capacity
|
||||
- **Encryption Detection** — near real-time ransomware anomaly detection
|
||||
- **Storage** — datastore capacity with Zerto-attributed journal/scratch/recovery breakdown
|
||||
- **User Management** — full CRUD with 2FA QR code setup, group management, enterprise IdP integration
|
||||
|
||||
## Authentication
|
||||
|
||||
This image includes a Node.js Express backend that handles:
|
||||
- OIDC login via **Authentik** (bundled in the full stack)
|
||||
- 2FA enforcement (TOTP with QR codes)
|
||||
- Enterprise IdP integration (Azure AD, Okta, SAML, LDAP)
|
||||
- Rate-limited login, `httpOnly` session cookies, zero Prometheus exposure to browser
|
||||
|
||||
## Quick start — full stack
|
||||
|
||||
```bash
|
||||
git clone https://github.com/recklessop/zroc.git
|
||||
cd zroc
|
||||
cp .env.example .env
|
||||
# Edit .env with your ZVM credentials and secrets
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Then visit `https://<your-host>` — on first access run through the setup wizard.
|
||||
|
||||
## Environment variables
|
||||
|
||||
|Variable |Required|Description |
|
||||
|-------------------------|--------|-------------------------------------------------------|
|
||||
|`PROMETHEUS_URL` |No |Prometheus endpoint (default: `http://prometheus:9090`)|
|
||||
|`AUTHENTIK_URL` |Yes |Authentik server URL |
|
||||
|`AUTHENTIK_CLIENT_ID` |Yes |OIDC client ID registered in Authentik |
|
||||
|`AUTHENTIK_CLIENT_SECRET`|Yes |OIDC client secret |
|
||||
|`AUTHENTIK_ADMIN_TOKEN` |Yes |Authentik API token for user management |
|
||||
|`PUBLIC_URL` |Yes |Public HTTPS URL of the appliance |
|
||||
|`SESSION_SECRET` |Yes |Random secret for session signing (min 32 chars) |
|
||||
|`AUTHENTIK_ADMIN_GROUP` |No |Group name for admin role (default: `zroc-admins`) |
|
||||
|`AUTHENTIK_VIEWER_GROUP` |No |Group name for viewer role (default: `zroc-viewers`) |
|
||||
|
||||
## Image tags
|
||||
|
||||
|Tag |Description |
|
||||
|--------|-----------------------------------------|
|
||||
|`stable`|Latest stable release — use in production|
|
||||
|`latest`|Alias for stable |
|
||||
|`1.x.x` |Pinned semantic version |
|
||||
|
||||
## Source
|
||||
|
||||
- UI & backend: [github.com/recklessop/zroc](https://github.com/recklessop/zroc)
|
||||
- Zerto Exporter: [github.com/recklessop/Zerto_Exporter](https://github.com/recklessop/Zerto_Exporter)
|
||||
- OVA Appliance: [github.com/recklessop/zroc-ova](https://github.com/recklessop/zroc-ova)
|
||||
|
||||
## License
|
||||
|
||||
Apache 2.0 — open source, not officially supported by Zerto/HPE.
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,50 @@
|
||||
# zROC — Zerto Resiliency Observation Console
|
||||
|
||||
> A self-hosted, purpose-built observability dashboard for Zerto — replaces Zerto Analytics with a fast, always-on UI.
|
||||
|
||||
## Overview
|
||||
|
||||
zROC is a Docker Compose stack that collects Zerto metrics via the ZVM REST API and presents them in a polished web interface.
|
||||
|
||||
| Component | Role |
|
||||
|---|---|
|
||||
| Zerto Exporter | Scrapes ZVM & vCenter APIs, exposes Prometheus metrics |
|
||||
| Prometheus | Stores metrics with 30-day retention |
|
||||
| zROC UI | React + Express — authenticated dashboard |
|
||||
| Authentik | Identity provider — login, 2FA, SSO, user management |
|
||||
| Caddy | TLS termination |
|
||||
| Grafana | Legacy dashboards |
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
git clone https://github.com/recklessop/zroc.git
|
||||
cd zroc
|
||||
cp .env.example .env
|
||||
# Edit .env with your ZVM credentials and secrets
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Pages
|
||||
|
||||
| Page | Path | Description |
|
||||
|---|---|---|
|
||||
| Overview | `/` | NOC dashboard — health bar, site cards, VPG heat grid |
|
||||
| VPG Monitor | `/vpgs` | Per-VPG: RPO gauge, throughput/IOPS charts, journal health |
|
||||
| VM Protection | `/vms` | All VMs — RPO, journal usage, encryption %, drill-down |
|
||||
| VRA Infrastructure | `/vras` | CPU/memory usage, protected/recovery workload |
|
||||
| Encryption Detection | `/encryption` | Encryption % per VM, anomaly table |
|
||||
| Storage | `/storage` | Datastore capacity with Zerto usage breakdown |
|
||||
| User Management | `/settings/users` | Admin only — full CRUD + 2FA QR setup |
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Browser → Caddy (TLS) → zroc-ui (Express + React SPA)
|
||||
→ Authentik (OIDC auth)
|
||||
zroc-ui → Prometheus → Zerto Exporter → ZVM API
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
Apache 2.0
|
||||
@@ -0,0 +1,110 @@
|
||||
version: 1
|
||||
metadata:
|
||||
name: zROC Initial Configuration
|
||||
labels:
|
||||
blueprints.goauthentik.io/instantiate: "true"
|
||||
|
||||
entries:
|
||||
- model: authentik_core.group
|
||||
state: present
|
||||
identifiers:
|
||||
name: zroc-admins
|
||||
attrs:
|
||||
name: zroc-admins
|
||||
|
||||
- model: authentik_core.group
|
||||
state: present
|
||||
identifiers:
|
||||
name: zroc-viewers
|
||||
attrs:
|
||||
name: zroc-viewers
|
||||
|
||||
- model: authentik_providers_oauth2.scopemapping
|
||||
state: present
|
||||
identifiers:
|
||||
managed: goauthentik.io/providers/oauth2/scope-zroc-groups
|
||||
attrs:
|
||||
managed: goauthentik.io/providers/oauth2/scope-zroc-groups
|
||||
name: "zROC Groups Scope"
|
||||
scope_name: groups
|
||||
expression: |
|
||||
return [group.name for group in request.user.ak_groups.all()]
|
||||
|
||||
- model: authentik_providers_oauth2.oauth2provider
|
||||
state: present
|
||||
identifiers:
|
||||
name: zROC Dashboard Provider
|
||||
attrs:
|
||||
name: zROC Dashboard Provider
|
||||
client_type: confidential
|
||||
client_id: !Env ZROC_OIDC_CLIENT_ID
|
||||
client_secret: !Env ZROC_OIDC_CLIENT_SECRET
|
||||
authorization_flow: !Find [authentik_flows.flow, [name, default-provider-authorization-implicit-consent]]
|
||||
redirect_uris: !Env ZROC_PUBLIC_URL
|
||||
signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]]
|
||||
access_code_validity: minutes=1
|
||||
access_token_validity: hours=1
|
||||
refresh_token_validity: days=30
|
||||
property_mappings:
|
||||
- !Find [authentik_providers_oauth2.scopemapping, [scope_name, openid]]
|
||||
- !Find [authentik_providers_oauth2.scopemapping, [scope_name, email]]
|
||||
- !Find [authentik_providers_oauth2.scopemapping, [scope_name, profile]]
|
||||
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-zroc-groups]]
|
||||
|
||||
- model: authentik_core.application
|
||||
state: present
|
||||
identifiers:
|
||||
slug: zroc-dashboard
|
||||
attrs:
|
||||
name: zROC Dashboard
|
||||
slug: zroc-dashboard
|
||||
provider: !Find [authentik_providers_oauth2.oauth2provider, [name, zROC Dashboard Provider]]
|
||||
meta_launch_url: !Env ZROC_PUBLIC_URL
|
||||
meta_description: Zerto Resiliency Observation Console
|
||||
policy_engine_mode: any
|
||||
|
||||
- model: authentik_stages_authenticator_validate.authenticatorvalidatestage
|
||||
state: present
|
||||
identifiers:
|
||||
name: zroc-totp-validation
|
||||
attrs:
|
||||
name: zroc-totp-validation
|
||||
device_classes:
|
||||
- totp
|
||||
- static
|
||||
not_configured_action: configure
|
||||
configuration_stages:
|
||||
- !Find [authentik_stages_authenticator_totp.authenticatortotpstage, [name, default-authenticator-totp-setup]]
|
||||
|
||||
- model: authentik_flows.flowstagebinding
|
||||
state: present
|
||||
identifiers:
|
||||
target: !Find [authentik_flows.flow, [slug, default-authentication-flow]]
|
||||
stage: !Find [authentik_stages_authenticator_validate.authenticatorvalidatestage, [name, zroc-totp-validation]]
|
||||
attrs:
|
||||
target: !Find [authentik_flows.flow, [slug, default-authentication-flow]]
|
||||
stage: !Find [authentik_stages_authenticator_validate.authenticatorvalidatestage, [name, zroc-totp-validation]]
|
||||
order: 30
|
||||
evaluate_on_plan: true
|
||||
re_evaluate_policies: false
|
||||
|
||||
- model: authentik_core.user
|
||||
state: present
|
||||
identifiers:
|
||||
username: zroc-service-account
|
||||
attrs:
|
||||
username: zroc-service-account
|
||||
name: zROC Service Account
|
||||
type: service_account
|
||||
is_active: true
|
||||
|
||||
- model: authentik_core.token
|
||||
state: present
|
||||
identifiers:
|
||||
identifier: zroc-ui-admin-token
|
||||
attrs:
|
||||
identifier: zroc-ui-admin-token
|
||||
user: !Find [authentik_core.user, [username, zroc-service-account]]
|
||||
intent: api
|
||||
description: "Used by zROC UI backend for user management"
|
||||
expiring: false
|
||||
@@ -0,0 +1,148 @@
|
||||
// backend/authentik.js
|
||||
'use strict';
|
||||
|
||||
const axios = require('axios');
|
||||
const QRCode = require('qrcode');
|
||||
const config = require('./config');
|
||||
const logger = require('./logger');
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: `${config.authentik_url}/api/v3`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.authentik_admin_token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
api.interceptors.response.use(
|
||||
(res) => res,
|
||||
(err) => {
|
||||
const status = err.response?.status;
|
||||
const detail = err.response?.data?.detail || err.message;
|
||||
logger.error(`[Authentik API] ${err.config?.method?.toUpperCase()} ${err.config?.url} → ${status}: ${detail}`);
|
||||
return Promise.reject(err);
|
||||
}
|
||||
);
|
||||
|
||||
async function listUsers({ search = '', page = 1, pageSize = 50 } = {}) {
|
||||
const params = { page, page_size: pageSize };
|
||||
if (search) params.search = search;
|
||||
|
||||
const { data } = await api.get('/core/users/', { params });
|
||||
|
||||
const totpDevices = await listAllTotpDevices();
|
||||
const totpByUser = new Map();
|
||||
for (const d of totpDevices) {
|
||||
totpByUser.set(d.user, true);
|
||||
}
|
||||
|
||||
const users = data.results.map((u) => ({
|
||||
id: u.pk,
|
||||
username: u.username,
|
||||
name: u.name,
|
||||
email: u.email,
|
||||
isActive: u.is_active,
|
||||
isSuperuser: u.is_superuser,
|
||||
groups: u.groups_obj?.map((g) => ({ id: g.pk, name: g.name })) ?? [],
|
||||
avatar: u.avatar,
|
||||
lastLogin: u.last_login,
|
||||
dateJoined: u.date_joined,
|
||||
totpEnrolled: totpByUser.has(u.pk),
|
||||
type: u.type,
|
||||
}));
|
||||
|
||||
return { users, count: data.count, page, pageSize };
|
||||
}
|
||||
|
||||
async function getUser(userId) {
|
||||
const { data: u } = await api.get(`/core/users/${userId}/`);
|
||||
return {
|
||||
id: u.pk,
|
||||
username: u.username,
|
||||
name: u.name,
|
||||
email: u.email,
|
||||
isActive: u.is_active,
|
||||
isSuperuser: u.is_superuser,
|
||||
groups: u.groups_obj?.map((g) => ({ id: g.pk, name: g.name })) ?? [],
|
||||
avatar: u.avatar,
|
||||
lastLogin: u.last_login,
|
||||
dateJoined: u.date_joined,
|
||||
type: u.type,
|
||||
};
|
||||
}
|
||||
|
||||
async function createUser({ username, name, email, isActive = true, groups = [], password }) {
|
||||
const payload = {
|
||||
username, name, email, is_active: isActive, groups, type: 'internal',
|
||||
};
|
||||
const { data: u } = await api.post('/core/users/', payload);
|
||||
if (password) { await setPassword(u.pk, password); }
|
||||
return getUser(u.pk);
|
||||
}
|
||||
|
||||
async function updateUser(userId, { name, email, isActive, groups }) {
|
||||
const payload = {};
|
||||
if (name !== undefined) payload.name = name;
|
||||
if (email !== undefined) payload.email = email;
|
||||
if (isActive !== undefined) payload.is_active = isActive;
|
||||
if (groups !== undefined) payload.groups = groups;
|
||||
await api.patch(`/core/users/${userId}/`, payload);
|
||||
return getUser(userId);
|
||||
}
|
||||
|
||||
async function deleteUser(userId) {
|
||||
await api.delete(`/core/users/${userId}/`);
|
||||
}
|
||||
|
||||
async function setPassword(userId, password) {
|
||||
await api.post(`/core/users/${userId}/set_password/`, { password });
|
||||
}
|
||||
|
||||
async function listGroups({ search = '' } = {}) {
|
||||
const params = { page_size: 100 };
|
||||
if (search) params.search = search;
|
||||
const { data } = await api.get('/core/groups/', { params });
|
||||
return data.results.map((g) => ({
|
||||
id: g.pk, name: g.name, userCount: g.num_pk ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
async function listAllTotpDevices() {
|
||||
const { data } = await api.get('/authenticators/totp/', { params: { page_size: 1000 } });
|
||||
return data.results;
|
||||
}
|
||||
|
||||
async function revokeTotpForUser(userId) {
|
||||
const { data } = await api.get('/authenticators/totp/', {
|
||||
params: { user: userId, page_size: 100 },
|
||||
});
|
||||
await Promise.all(data.results.map((d) => api.delete(`/authenticators/totp/${d.pk}/`)));
|
||||
return data.results.length;
|
||||
}
|
||||
|
||||
async function generateTwoFactorSetupLink(userId) {
|
||||
await revokeTotpForUser(userId);
|
||||
const { data } = await api.post(`/core/users/${userId}/recovery/`);
|
||||
const setupUrl = data.link;
|
||||
const qrDataUrl = await QRCode.toDataURL(setupUrl, {
|
||||
width: 280, margin: 2,
|
||||
color: { dark: '#0ea5e9', light: '#0a0f1e' },
|
||||
errorCorrectionLevel: 'M',
|
||||
});
|
||||
return { setupUrl, qrDataUrl };
|
||||
}
|
||||
|
||||
async function validateAdminToken() {
|
||||
try {
|
||||
await api.get('/core/users/me/');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
listUsers, getUser, createUser, updateUser, deleteUser, setPassword,
|
||||
listGroups, revokeTotpForUser, generateTwoFactorSetupLink, validateAdminToken,
|
||||
};
|
||||
@@ -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;
|
||||
@@ -0,0 +1,18 @@
|
||||
// backend/logger.js
|
||||
'use strict';
|
||||
const { createLogger, format, transports } = require('winston');
|
||||
const config = require('./config');
|
||||
|
||||
const logger = createLogger({
|
||||
level: config.is_dev ? 'debug' : 'info',
|
||||
format: format.combine(
|
||||
format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
||||
format.errors({ stack: true }),
|
||||
config.is_dev
|
||||
? format.combine(format.colorize(), format.simple())
|
||||
: format.json()
|
||||
),
|
||||
transports: [new transports.Console()],
|
||||
});
|
||||
|
||||
module.exports = logger;
|
||||
@@ -0,0 +1,28 @@
|
||||
// backend/middleware/authenticate.js
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Middleware: require an authenticated session.
|
||||
* If the request has no valid session → 401.
|
||||
* Attaches req.user = { id, username, name, email, role } for downstream use.
|
||||
*/
|
||||
function authenticate(req, res, next) {
|
||||
if (!req.session?.user) {
|
||||
return res.status(401).json({ error: 'Unauthorized', code: 'NO_SESSION' });
|
||||
}
|
||||
req.user = req.session.user;
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware: require admin role.
|
||||
* Must be used AFTER authenticate().
|
||||
*/
|
||||
function requireAdmin(req, res, next) {
|
||||
if (req.user?.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'Forbidden', code: 'REQUIRES_ADMIN' });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = { authenticate, requireAdmin };
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
version: '3.8'
|
||||
|
||||
networks:
|
||||
front-tier:
|
||||
back-tier:
|
||||
auth-tier:
|
||||
|
||||
volumes:
|
||||
prometheus_data: {}
|
||||
grafana_data: {}
|
||||
zroc_ui_data: {}
|
||||
authentik_postgres: {}
|
||||
authentik_redis: {}
|
||||
authentik_media: {}
|
||||
caddy_data: {}
|
||||
|
||||
services:
|
||||
|
||||
caddy:
|
||||
image: caddy:2-alpine
|
||||
container_name: zroc-caddy
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./zroc-ui/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- ./certs:/certs:ro
|
||||
- caddy_data:/data
|
||||
networks:
|
||||
- front-tier
|
||||
depends_on:
|
||||
- zroc-ui
|
||||
- authentik-server
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:80"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
authentik-postgresql:
|
||||
image: postgres:16-alpine
|
||||
container_name: authentik-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: authentik
|
||||
POSTGRES_USER: authentik
|
||||
POSTGRES_PASSWORD: ${AUTHENTIK_PG_PASS}
|
||||
volumes:
|
||||
- authentik_postgres:/var/lib/postgresql/data
|
||||
networks:
|
||||
- auth-tier
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U authentik"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
authentik-redis:
|
||||
image: redis:7-alpine
|
||||
container_name: authentik-redis
|
||||
restart: unless-stopped
|
||||
command: --save 60 1 --loglevel warning
|
||||
volumes:
|
||||
- authentik_redis:/data
|
||||
networks:
|
||||
- auth-tier
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
authentik-server:
|
||||
image: ghcr.io/goauthentik/server:latest
|
||||
container_name: authentik-server
|
||||
restart: unless-stopped
|
||||
command: server
|
||||
environment:
|
||||
AUTHENTIK_REDIS__HOST: authentik-redis
|
||||
AUTHENTIK_POSTGRESQL__HOST: authentik-postgresql
|
||||
AUTHENTIK_POSTGRESQL__USER: authentik
|
||||
AUTHENTIK_POSTGRESQL__NAME: authentik
|
||||
AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_PG_PASS}
|
||||
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
|
||||
AUTHENTIK_DISABLE_STARTUP_ANALYTICS: "true"
|
||||
AUTHENTIK_ERROR_REPORTING__ENABLED: "false"
|
||||
ZROC_OIDC_CLIENT_ID: ${ZROC_OIDC_CLIENT_ID}
|
||||
ZROC_OIDC_CLIENT_SECRET: ${ZROC_OIDC_CLIENT_SECRET}
|
||||
ZROC_PUBLIC_URL: ${ZROC_PUBLIC_URL}
|
||||
volumes:
|
||||
- authentik_media:/media
|
||||
- ./authentik/blueprints:/blueprints/custom:ro
|
||||
networks:
|
||||
- auth-tier
|
||||
- front-tier
|
||||
depends_on:
|
||||
authentik-postgresql:
|
||||
condition: service_healthy
|
||||
authentik-redis:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "ak healthcheck || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 60s
|
||||
|
||||
authentik-worker:
|
||||
image: ghcr.io/goauthentik/server:latest
|
||||
container_name: authentik-worker
|
||||
restart: unless-stopped
|
||||
command: worker
|
||||
environment:
|
||||
AUTHENTIK_REDIS__HOST: authentik-redis
|
||||
AUTHENTIK_POSTGRESQL__HOST: authentik-postgresql
|
||||
AUTHENTIK_POSTGRESQL__USER: authentik
|
||||
AUTHENTIK_POSTGRESQL__NAME: authentik
|
||||
AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_PG_PASS}
|
||||
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
|
||||
AUTHENTIK_DISABLE_STARTUP_ANALYTICS: "true"
|
||||
volumes:
|
||||
- authentik_media:/media
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
networks:
|
||||
- auth-tier
|
||||
depends_on:
|
||||
- authentik-server
|
||||
user: root
|
||||
|
||||
zroc-ui:
|
||||
image: recklessop/zroc-ui:stable
|
||||
container_name: zroc-ui
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: "3001"
|
||||
PROMETHEUS_URL: http://prometheus:9090
|
||||
AUTHENTIK_URL: http://authentik-server:9000
|
||||
AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID}
|
||||
AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_CLIENT_SECRET}
|
||||
AUTHENTIK_ADMIN_TOKEN: ${AUTHENTIK_ADMIN_TOKEN}
|
||||
PUBLIC_URL: ${PUBLIC_URL}
|
||||
SESSION_SECRET: ${SESSION_SECRET}
|
||||
JWT_EXPIRY_HOURS: "24"
|
||||
AUTHENTIK_ADMIN_GROUP: zroc-admins
|
||||
AUTHENTIK_VIEWER_GROUP: zroc-viewers
|
||||
volumes:
|
||||
- zroc_ui_data:/app/data
|
||||
networks:
|
||||
- front-tier
|
||||
- back-tier
|
||||
- auth-tier
|
||||
depends_on:
|
||||
- prometheus
|
||||
- authentik-server
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:3001/api/health"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
|
||||
zertoexporter:
|
||||
image: recklessop/zerto-exporter:stable
|
||||
container_name: zvmexporter1
|
||||
hostname: zvmexporter1
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./zvmexporter:/usr/src/app/logs
|
||||
environment:
|
||||
VERIFY_SSL: "False"
|
||||
ZVM_HOST: ${ZVM_HOST}
|
||||
ZVM_PORT: "443"
|
||||
ZVM_USERNAME: ${ZVM_USERNAME}
|
||||
ZVM_PASSWORD: ${ZVM_PASSWORD}
|
||||
SCRAPE_SPEED: "20"
|
||||
LOGLEVEL: INFO
|
||||
VCENTER_HOST: ${VCENTER_HOST:-}
|
||||
VCENTER_USER: ${VCENTER_USER:-administrator@vsphere.local}
|
||||
VCENTER_PASSWORD: ${VCENTER_PASSWORD:-}
|
||||
networks:
|
||||
- back-tier
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:9999/metrics"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
prometheus:
|
||||
image: prom/prometheus:v2.51.0
|
||||
container_name: zroc-prometheus
|
||||
restart: unless-stopped
|
||||
command:
|
||||
- --config.file=/etc/prometheus/prometheus.yml
|
||||
- --storage.tsdb.path=/prometheus
|
||||
- --storage.tsdb.retention.time=30d
|
||||
- --storage.tsdb.retention.size=20GB
|
||||
- --web.listen-address=0.0.0.0:9090
|
||||
- --web.enable-lifecycle
|
||||
volumes:
|
||||
- ./prometheus:/etc/prometheus:ro
|
||||
- prometheus_data:/prometheus
|
||||
networks:
|
||||
- back-tier
|
||||
depends_on:
|
||||
- zertoexporter
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:9090/-/healthy"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
grafana:
|
||||
image: grafana/grafana:10.4.2
|
||||
container_name: zroc-grafana
|
||||
restart: unless-stopped
|
||||
user: "472"
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- grafana_data:/var/lib/grafana
|
||||
- ./grafana/provisioning:/etc/grafana/provisioning:ro
|
||||
environment:
|
||||
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_PASSWORD:-zertodata}
|
||||
GF_USERS_ALLOW_SIGN_UP: "false"
|
||||
GF_SERVER_ROOT_URL: "%(protocol)s://%(domain)s:%(http_port)s/grafana/"
|
||||
GF_AUTH_GENERIC_OAUTH_ENABLED: ${GRAFANA_OIDC_ENABLED:-false}
|
||||
GF_AUTH_GENERIC_OAUTH_NAME: Authentik
|
||||
GF_AUTH_GENERIC_OAUTH_CLIENT_ID: ${GRAFANA_CLIENT_ID:-}
|
||||
GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET:${GRAFANA_CLIENT_SECRET:-}
|
||||
GF_AUTH_GENERIC_OAUTH_SCOPES: openid profile email
|
||||
GF_AUTH_GENERIC_OAUTH_AUTH_URL: ${PUBLIC_URL}/auth/application/o/authorize/
|
||||
GF_AUTH_GENERIC_OAUTH_TOKEN_URL: http://authentik-server:9000/application/o/token/
|
||||
GF_AUTH_GENERIC_OAUTH_API_URL: http://authentik-server:9000/application/o/userinfo/
|
||||
networks:
|
||||
- back-tier
|
||||
- front-tier
|
||||
depends_on:
|
||||
- prometheus
|
||||
|
||||
watchtower:
|
||||
image: containrrr/watchtower
|
||||
container_name: zroc-watchtower
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
environment:
|
||||
WATCHTOWER_POLL_INTERVAL: "3600"
|
||||
WATCHTOWER_CLEANUP: "true"
|
||||
WATCHTOWER_INCLUDE_STOPPED: "false"
|
||||
command: --label-enable
|
||||
@@ -0,0 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>zROC — Zerto Resiliency Observation Console</title>
|
||||
|
||||
<!-- Fonts: IBM Plex Mono (headings) + DM Sans (body) + JetBrains Mono (data) -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=DM+Sans:wght@300;400;500;600&family=JetBrains+Mono:wght@400;500&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body class="bg-canvas text-text-primary font-sans antialiased">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "zroc-ui",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.40.0",
|
||||
"clsx": "^2.1.1",
|
||||
"jspdf": "^4.2.1",
|
||||
"lucide-react": "^0.395.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.23.1",
|
||||
"recharts": "^2.12.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"postcss": "^8.4.38",
|
||||
"tailwindcss": "^3.4.4",
|
||||
"vite": "^5.2.13"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||
<!-- Background -->
|
||||
<rect width="32" height="32" rx="6" fill="#0d1526"/>
|
||||
<!-- Border -->
|
||||
<rect x="1" y="1" width="30" height="30" rx="5.5" stroke="#0ea5e9" stroke-width="1" stroke-opacity="0.4"/>
|
||||
<!-- Activity line (zROC pulse) -->
|
||||
<polyline
|
||||
points="4,18 8,18 10,10 12,24 15,14 17,20 19,16 21,16 24,16 28,16"
|
||||
stroke="#0ea5e9"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<!-- Live dot -->
|
||||
<circle cx="28" cy="16" r="2.5" fill="#10b981"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 594 B |
@@ -0,0 +1,57 @@
|
||||
// src/App.jsx — final router with all pages wired up
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { AuthProvider } from '@/auth/AuthContext';
|
||||
import { ThemeProvider } from '@/auth/ThemeContext';
|
||||
import { ProtectedRoute, AdminRoute } from '@/auth/ProtectedRoute';
|
||||
import AppShell from '@/components/layout/AppShell';
|
||||
import Overview from '@/pages/Overview';
|
||||
import VPGMonitor from '@/pages/VPGMonitor';
|
||||
import VRADashboard from '@/pages/VRADashboard';
|
||||
import EncryptionPage from '@/pages/Encryption';
|
||||
import Storage from '@/pages/Storage';
|
||||
import UserManagement from '@/pages/Settings/UserManagement';
|
||||
import VMDetail from '@/pages/VMDetail';
|
||||
import Placeholder from '@/pages/Placeholder';
|
||||
import Planner from '@/pages/Planner';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 2,
|
||||
staleTime: 15_000,
|
||||
refetchOnWindowFocus: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route element={<ProtectedRoute><AppShell /></ProtectedRoute>}>
|
||||
<Route index element={<Overview />} />
|
||||
<Route path="vpgs" element={<VPGMonitor />} />
|
||||
<Route path="vms" element={<VMDetail />} />
|
||||
<Route path="vras" element={<VRADashboard />} />
|
||||
<Route path="encryption" element={<EncryptionPage />} />
|
||||
<Route path="storage" element={<Storage />} />
|
||||
<Route path="planner" element={<Planner />} />
|
||||
<Route path="settings">
|
||||
<Route index element={<Navigate to="users" replace />} />
|
||||
<Route path="users" element={
|
||||
<AdminRoute><UserManagement /></AdminRoute>
|
||||
} />
|
||||
</Route>
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// src/api/planner.js
|
||||
// Queries the vcenter_vm_disk_* metrics exposed by zroc-planner collector.
|
||||
import { instantQuery } from './prometheus';
|
||||
|
||||
export async function queryPlannerVms() {
|
||||
const [throughput, iops, latency, provisioned] = await Promise.all([
|
||||
instantQuery('vcenter_vm_disk_write_throughput_mbps'),
|
||||
instantQuery('vcenter_vm_disk_write_iops'),
|
||||
instantQuery('vcenter_vm_disk_write_latency_ms'),
|
||||
instantQuery('vcenter_vm_disk_provisioned_gb'),
|
||||
]);
|
||||
|
||||
const byMoref = {};
|
||||
|
||||
const idx = (vec, field, transform = parseFloat) => {
|
||||
for (const { metric, value } of vec) {
|
||||
const id = metric.vm_moref || metric.vm_name;
|
||||
if (!byMoref[id]) byMoref[id] = {
|
||||
moref: metric.vm_moref || id,
|
||||
name: metric.vm_name || id,
|
||||
cluster: metric.cluster || '',
|
||||
host: metric.host || '',
|
||||
datacenter: metric.datacenter || '',
|
||||
};
|
||||
byMoref[id][field] = transform(value[1]);
|
||||
}
|
||||
};
|
||||
|
||||
idx(throughput, 'writeThroughputMbps');
|
||||
idx(iops, 'writeIops');
|
||||
idx(latency, 'writeLatencyMs');
|
||||
idx(provisioned, 'provisionedGb');
|
||||
|
||||
return Object.values(byMoref).sort((a, b) =>
|
||||
(b.writeThroughputMbps ?? 0) - (a.writeThroughputMbps ?? 0)
|
||||
);
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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`),
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
// src/auth/AuthContext.jsx
|
||||
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const checkSession = useCallback(async () => {
|
||||
if (import.meta.env.VITE_MOCK_AUTH === 'true') {
|
||||
setUser({ name: 'Demo User', email: 'demo@zroc.local', role: 'admin' });
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch('/api/auth/status', { credentials: 'include' });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setUser(data.authenticated ? data.user : null);
|
||||
} else {
|
||||
setUser(null);
|
||||
}
|
||||
} catch {
|
||||
setUser(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { checkSession(); }, [checkSession]);
|
||||
|
||||
const login = () => {
|
||||
window.location.href = `/api/auth/login?redirect=${encodeURIComponent(window.location.pathname)}`;
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/auth/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (res.ok) {
|
||||
const { redirectUrl } = await res.json();
|
||||
setUser(null);
|
||||
window.location.href = redirectUrl || '/';
|
||||
}
|
||||
} catch {
|
||||
setUser(null);
|
||||
window.location.href = '/';
|
||||
}
|
||||
};
|
||||
|
||||
const isAdmin = user?.role === 'admin';
|
||||
const isViewer = !!user;
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, login, logout, isAdmin, isViewer }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// src/auth/ProtectedRoute.jsx
|
||||
import { useEffect } from 'react';
|
||||
import { useAuth } from './AuthContext';
|
||||
|
||||
function LoadingScreen() {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-canvas">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-8 h-8 border-2 border-border border-t-accent rounded-full animate-spin" />
|
||||
<p className="font-mono text-xs text-text-muted uppercase tracking-widest">
|
||||
Verifying session…
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProtectedRoute({ children }) {
|
||||
const { user, loading, login } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !user) login();
|
||||
}, [loading, user, login]);
|
||||
|
||||
if (loading) return <LoadingScreen />;
|
||||
if (!user) return <LoadingScreen />;
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
export function AdminRoute({ children }) {
|
||||
const { user, loading, login, isAdmin } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !user) login();
|
||||
}, [loading, user, login]);
|
||||
|
||||
if (loading) return <LoadingScreen />;
|
||||
if (!user) return <LoadingScreen />;
|
||||
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-canvas">
|
||||
<div className="card p-10 text-center max-w-sm">
|
||||
<p className="font-mono text-crit text-lg mb-2">403</p>
|
||||
<p className="text-text-secondary text-sm">
|
||||
This page requires administrator privileges.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// src/auth/ThemeContext.jsx
|
||||
import { createContext, useContext, useState, useEffect } from 'react';
|
||||
|
||||
const ThemeContext = createContext(null);
|
||||
|
||||
export function ThemeProvider({ children }) {
|
||||
const [theme, setTheme] = useState(() =>
|
||||
localStorage.getItem('zroc-theme') || 'dark'
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
localStorage.setItem('zroc-theme', theme);
|
||||
}, [theme]);
|
||||
|
||||
const toggle = () => setTheme((t) => t === 'dark' ? 'light' : 'dark');
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, toggle }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const ctx = useContext(ThemeContext);
|
||||
if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// src/components/charts/RPOGauge.jsx
|
||||
import { formatRpo } from '@/constants/statusMaps';
|
||||
import clsx from 'clsx';
|
||||
|
||||
const R = 52;
|
||||
const CX = 70;
|
||||
const CY = 72;
|
||||
const SW = 10;
|
||||
|
||||
function polarToCartesian(cx, cy, r, angleDeg) {
|
||||
const rad = ((angleDeg - 90) * Math.PI) / 180;
|
||||
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) };
|
||||
}
|
||||
|
||||
function arcPath(cx, cy, r, startAngle, endAngle) {
|
||||
const s = polarToCartesian(cx, cy, r, startAngle);
|
||||
const e = polarToCartesian(cx, cy, r, endAngle);
|
||||
const large = endAngle - startAngle > 180 ? 1 : 0;
|
||||
return `M ${s.x} ${s.y} A ${r} ${r} 0 ${large} 1 ${e.x} ${e.y}`;
|
||||
}
|
||||
|
||||
const START_ANGLE = -210;
|
||||
const END_ANGLE = 30;
|
||||
|
||||
function rpoColor(ratio) {
|
||||
if (ratio === null || ratio === undefined) return { stroke: '#4a6080', text: 'text-text-muted' };
|
||||
if (ratio <= 0.75) return { stroke: '#10b981', text: 'text-ok' };
|
||||
if (ratio <= 1.0) return { stroke: '#f59e0b', text: 'text-warn' };
|
||||
return { stroke: '#ef4444', text: 'text-crit' };
|
||||
}
|
||||
|
||||
export default function RPOGauge({ actualSec, configuredSec, label = 'Actual RPO', size = 140 }) {
|
||||
const ratio = (actualSec && configuredSec) ? Math.min(actualSec / configuredSec, 1.5) : null;
|
||||
const { stroke, text } = rpoColor(ratio);
|
||||
|
||||
const totalAngle = END_ANGLE - START_ANGLE;
|
||||
const fillAngle = ratio !== null
|
||||
? START_ANGLE + (Math.min(ratio, 1) * totalAngle)
|
||||
: START_ANGLE;
|
||||
|
||||
const bgPath = arcPath(CX, CY, R, START_ANGLE, END_ANGLE);
|
||||
const fillPath = ratio !== null ? arcPath(CX, CY, R, START_ANGLE, fillAngle) : null;
|
||||
|
||||
const pct = ratio !== null ? Math.round(ratio * 100) : null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center" style={{ width: size }}>
|
||||
<svg
|
||||
viewBox="0 0 140 100"
|
||||
width={size}
|
||||
height={size * (100 / 140)}
|
||||
className="overflow-visible"
|
||||
>
|
||||
<path d={bgPath} fill="none" stroke="#1e2d47" strokeWidth={SW} strokeLinecap="round" />
|
||||
{fillPath && (
|
||||
<path d={fillPath} fill="none" stroke={stroke} strokeWidth={SW} strokeLinecap="round"
|
||||
style={{ filter: `drop-shadow(0 0 4px ${stroke}60)`, transition: 'all 0.6s ease-out' }}
|
||||
/>
|
||||
)}
|
||||
{fillPath && ratio !== null && (
|
||||
(() => {
|
||||
const tip = polarToCartesian(CX, CY, R, Math.min(fillAngle, END_ANGLE - 0.5));
|
||||
return (
|
||||
<circle cx={tip.x} cy={tip.y} r={4} fill={stroke}
|
||||
style={{ filter: `drop-shadow(0 0 6px ${stroke})` }} />
|
||||
);
|
||||
})()
|
||||
)}
|
||||
<text x={CX} y={CY - 6} textAnchor="middle"
|
||||
fill={stroke}
|
||||
fontSize={actualSec != null ? 18 : 14}
|
||||
fontFamily="JetBrains Mono, monospace"
|
||||
fontWeight="600"
|
||||
>
|
||||
{actualSec != null ? formatRpo(actualSec) : '—'}
|
||||
</text>
|
||||
{configuredSec && (
|
||||
<text x={CX} y={CY + 10} textAnchor="middle"
|
||||
fill="#4a6080" fontSize={8} fontFamily="JetBrains Mono, monospace">
|
||||
/ {formatRpo(configuredSec)} target
|
||||
</text>
|
||||
)}
|
||||
{pct !== null && (
|
||||
<text x={CX} y={CY + 22} textAnchor="middle"
|
||||
fill={stroke} fontSize={9} fontFamily="JetBrains Mono, monospace">
|
||||
{pct > 100
|
||||
? `${pct - 100}% over`
|
||||
: `${100 - pct}% headroom`}
|
||||
</text>
|
||||
)}
|
||||
</svg>
|
||||
<p className="section-title mt-1">{label}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
// src/components/charts/TimeSeriesChart.jsx
|
||||
import { useState, useCallback } from 'react';
|
||||
import {
|
||||
ComposedChart, Area, Line,
|
||||
XAxis, YAxis, CartesianGrid, Tooltip, Legend,
|
||||
ResponsiveContainer, ReferenceLine,
|
||||
} from 'recharts';
|
||||
import { useRangeQuery } from '@/hooks/useRangeQuery';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
const WINDOWS = ['1h', '6h', '24h', '7d', '30d'];
|
||||
|
||||
function CustomTooltip({ active, payload, label, formatter, timeFormat }) {
|
||||
if (!active || !payload?.length) return null;
|
||||
const ts = typeof label === 'number' ? new Date(label).toLocaleString(undefined, timeFormat) : label;
|
||||
return (
|
||||
<div className="bg-raised border border-border-bright rounded-lg px-3 py-2 shadow-panel text-xs">
|
||||
<p className="text-text-muted font-mono mb-2">{ts}</p>
|
||||
{payload.map((p) => (
|
||||
<div key={p.dataKey} className="flex items-center gap-2 mb-0.5">
|
||||
<span className="w-2 h-2 rounded-full flex-shrink-0" style={{ background: p.color }} />
|
||||
<span className="text-text-secondary">{p.name}:</span>
|
||||
<span className="font-mono font-semibold text-text-primary">
|
||||
{formatter ? formatter(p.value, p.dataKey) : p.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WindowSelector({ value, onChange }) {
|
||||
return (
|
||||
<div className="flex items-center gap-0.5 bg-canvas rounded-md p-0.5 border border-border">
|
||||
{WINDOWS.map((w) => (
|
||||
<button key={w} onClick={() => onChange(w)}
|
||||
className={clsx('px-2.5 py-1 rounded text-xs font-mono transition-all duration-150',
|
||||
value === w ? 'bg-accent/20 text-accent border border-accent/30' : 'text-text-muted hover:text-text-primary hover:bg-raised')}>
|
||||
{w}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SERIES_COLORS = {
|
||||
ok: '#10b981', warn: '#f59e0b', crit: '#ef4444', accent: '#0ea5e9',
|
||||
info: '#818cf8', 0: '#0ea5e9', 1: '#10b981', 2: '#f59e0b', 3: '#818cf8', 4: '#ef4444',
|
||||
};
|
||||
|
||||
export default function TimeSeriesChart({
|
||||
promql, title, yFormatter, yLabel, refLines = [],
|
||||
showWindow = true, defaultWindow = '6h', height = 200, transform, series: seriesDef,
|
||||
}) {
|
||||
const [window, setWindow] = useState(defaultWindow);
|
||||
|
||||
const seriesArr = seriesDef ? seriesDef
|
||||
: Array.isArray(promql) ? promql
|
||||
: [{ promql, name: title || 'value', color: 'accent' }];
|
||||
|
||||
const primaryPromql = typeof promql === 'string' ? promql : seriesArr[0]?.promql;
|
||||
|
||||
const { data: rawData, isLoading, error } = useRangeQuery(primaryPromql, {
|
||||
window, enabled: !!primaryPromql,
|
||||
});
|
||||
|
||||
const chartData = useCallback(() => {
|
||||
if (!rawData?.length) return [];
|
||||
if (transform) return transform(rawData, window);
|
||||
const merged = {};
|
||||
rawData.forEach((series, si) => {
|
||||
const key = series.metric.VpgName || series.metric.VmName || series.metric.VraName || seriesArr[si]?.name || `series${si}`;
|
||||
series.values.forEach(([ts, v]) => {
|
||||
const ms = ts * 1000;
|
||||
if (!merged[ms]) merged[ms] = { ts: ms };
|
||||
merged[ms][key] = parseFloat(v);
|
||||
});
|
||||
});
|
||||
return Object.values(merged).sort((a, b) => a.ts - b.ts);
|
||||
}, [rawData, transform, window, seriesArr]);
|
||||
|
||||
const data = chartData();
|
||||
const seriesKeys = data.length > 0 ? Object.keys(data[0]).filter((k) => k !== 'ts') : [];
|
||||
|
||||
const makeTimeTick = (w) => (ts) => {
|
||||
const d = new Date(ts);
|
||||
if (w === '30d' || w === '7d') return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||
return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||
};
|
||||
|
||||
const makeTooltipTimeFormat = (w) => {
|
||||
if (w === '30d' || w === '7d') return { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' };
|
||||
return { hour: '2-digit', minute: '2-digit', second: '2-digit' };
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card p-4 flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
{title && <p className="section-title">{title}</p>}
|
||||
{showWindow && <WindowSelector value={window} onChange={setWindow} />}
|
||||
</div>
|
||||
<div style={{ height }} className="relative">
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Loader2 size={18} className="animate-spin text-text-muted" />
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<p className="text-xs text-crit font-mono">Query failed</p>
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && !error && data.length === 0 && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<p className="text-xs text-text-muted font-mono">No data</p>
|
||||
</div>
|
||||
)}
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart data={data} margin={{ top: 4, right: 4, left: yLabel ? 16 : 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(30,45,71,0.8)" vertical={false} />
|
||||
<XAxis dataKey="ts" type="number" domain={['dataMin', 'dataMax']}
|
||||
tickFormatter={makeTimeTick(window)}
|
||||
tick={{ fontSize: 10, fill: '#4a6080', fontFamily: 'JetBrains Mono' }}
|
||||
axisLine={{ stroke: '#1e2d47' }} tickLine={false} scale="time" />
|
||||
<YAxis tickFormatter={(v) => yFormatter ? yFormatter(v) : v}
|
||||
tick={{ fontSize: 10, fill: '#4a6080', fontFamily: 'JetBrains Mono' }}
|
||||
axisLine={false} tickLine={false} width={yLabel ? 60 : 40} />
|
||||
<Tooltip content={<CustomTooltip formatter={yFormatter} timeFormat={makeTooltipTimeFormat(window)} />}
|
||||
cursor={{ stroke: '#2a4066', strokeWidth: 1, strokeDasharray: '4 2' }} />
|
||||
{refLines.map((rl) => (
|
||||
<ReferenceLine key={rl.label} y={rl.value}
|
||||
stroke={SERIES_COLORS[rl.color] || rl.color || '#f59e0b'}
|
||||
strokeDasharray="6 3" strokeWidth={1.5} />
|
||||
))}
|
||||
{(seriesArr.length > 1 ? seriesArr : seriesKeys.map((k, i) => ({
|
||||
name: k, color: i, type: 'area',
|
||||
}))).map((s, i) => {
|
||||
const key = s.name || s.promql || seriesKeys[i] || `s${i}`;
|
||||
const color = SERIES_COLORS[s.color] || SERIES_COLORS[i] || '#0ea5e9';
|
||||
return (
|
||||
<Area key={key} type="monotone" dataKey={key} name={s.name || key}
|
||||
stroke={color} strokeWidth={2} fill={color} fillOpacity={0.08}
|
||||
dot={false} activeDot={{ r: 3 }} connectNulls />
|
||||
);
|
||||
})}
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// src/components/layout/AppShell.jsx
|
||||
import { useState } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import Sidebar from './Sidebar';
|
||||
import TopBar from './TopBar';
|
||||
|
||||
export default function AppShell() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-canvas">
|
||||
{/* Sidebar */}
|
||||
<Sidebar open={sidebarOpen} onToggle={() => setSidebarOpen((v) => !v)} />
|
||||
|
||||
{/* Main area */}
|
||||
<div className="flex flex-col flex-1 min-w-0 overflow-hidden">
|
||||
<TopBar sidebarOpen={sidebarOpen} onMenuToggle={() => setSidebarOpen((v) => !v)} />
|
||||
<main className="flex-1 overflow-y-auto p-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// src/components/layout/Sidebar.jsx
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import {
|
||||
LayoutDashboard, GitFork, Server, Cpu,
|
||||
ShieldAlert, Database, Settings, ChevronLeft,
|
||||
ChevronRight, Activity, Calculator,
|
||||
} from 'lucide-react';
|
||||
import { useAuth } from '@/auth/AuthContext';
|
||||
import clsx from 'clsx';
|
||||
|
||||
function ZrocLogo({ collapsed }) {
|
||||
return (
|
||||
<div className={clsx(
|
||||
'flex items-center gap-2.5 px-4 h-14 border-b border-border flex-shrink-0',
|
||||
collapsed && 'justify-center px-0',
|
||||
)}>
|
||||
<div className="relative flex-shrink-0">
|
||||
<div className="w-7 h-7 border border-accent rounded-sm flex items-center justify-center bg-accent/10 shadow-glow-sm">
|
||||
<Activity size={14} className="text-accent" />
|
||||
</div>
|
||||
<span className="absolute -top-0.5 -right-0.5 w-2 h-2 bg-ok rounded-full shadow-glow-ok animate-pulse-led" />
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div>
|
||||
<p className="font-mono text-sm font-semibold text-text-primary leading-none">
|
||||
z<span className="text-accent">ROC</span>
|
||||
</p>
|
||||
<p className="font-mono text-[9px] text-text-muted leading-none mt-0.5 uppercase tracking-widest">
|
||||
Observability Console
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ to: '/', label: 'Overview', icon: LayoutDashboard, exact: true },
|
||||
{ to: '/vpgs', label: 'VPGs', icon: GitFork },
|
||||
{ to: '/vms', label: 'VMs', icon: Server },
|
||||
{ to: '/vras', label: 'VRAs', icon: Cpu },
|
||||
{ to: '/encryption', label: 'Encryption', icon: ShieldAlert },
|
||||
{ to: '/storage', label: 'Storage', icon: Database },
|
||||
{ to: '/planner', label: 'Planner', icon: Calculator },
|
||||
];
|
||||
|
||||
const ADMIN_ITEMS = [
|
||||
{ to: '/settings/users', label: 'Users', icon: Settings },
|
||||
];
|
||||
|
||||
function NavItem({ to, label, icon: Icon, collapsed, exact }) {
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
end={exact}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-all duration-150 group relative',
|
||||
collapsed ? 'justify-center' : '',
|
||||
isActive
|
||||
? 'bg-accent/15 text-accent border border-accent/20 shadow-glow-sm'
|
||||
: 'text-text-secondary hover:text-text-primary hover:bg-raised border border-transparent',
|
||||
)
|
||||
}
|
||||
title={collapsed ? label : undefined}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<Icon size={16} className={clsx('flex-shrink-0 transition-colors', isActive ? 'text-accent' : 'group-hover:text-text-primary')} />
|
||||
{!collapsed && <span className="font-medium">{label}</span>}
|
||||
{isActive && !collapsed && <span className="ml-auto w-1 h-1 rounded-full bg-accent" />}
|
||||
{collapsed && (
|
||||
<span className="absolute left-full ml-2 px-2 py-1 bg-raised border border-border rounded text-xs text-text-primary whitespace-nowrap opacity-0 group-hover:opacity-100 pointer-events-none z-50 transition-opacity duration-150 shadow-panel">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Sidebar({ open, onToggle }) {
|
||||
const { isAdmin } = useAuth();
|
||||
const collapsed = !open;
|
||||
|
||||
return (
|
||||
<aside className={clsx(
|
||||
'flex flex-col bg-surface border-r border-border flex-shrink-0 transition-all duration-200 ease-in-out',
|
||||
collapsed ? 'w-14' : 'w-56',
|
||||
)}>
|
||||
<ZrocLogo collapsed={collapsed} />
|
||||
|
||||
<nav className="flex-1 px-2 py-3 space-y-0.5 overflow-y-auto overflow-x-hidden">
|
||||
<div className={clsx(!collapsed && 'mb-1')}>
|
||||
{!collapsed && <p className="section-title px-3 mb-2">Monitor</p>}
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<NavItem key={item.to} {...item} collapsed={collapsed} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isAdmin && (
|
||||
<div className={clsx(!collapsed && 'pt-3 mt-3 border-t border-border')}>
|
||||
{collapsed && <div className="border-t border-border my-2 mx-2" />}
|
||||
{!collapsed && <p className="section-title px-3 mb-2">Admin</p>}
|
||||
{ADMIN_ITEMS.map((item) => (
|
||||
<NavItem key={item.to} {...item} collapsed={collapsed} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="flex items-center justify-center h-10 border-t border-border text-text-muted hover:text-text-primary hover:bg-raised transition-colors duration-150 flex-shrink-0"
|
||||
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
>
|
||||
{collapsed ? <ChevronRight size={14} /> : <ChevronLeft size={14} />}
|
||||
</button>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// src/components/layout/TopBar.jsx
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Menu, RefreshCw, ChevronDown, LogOut, Sun, Moon } from 'lucide-react';
|
||||
import { useAuth } from '@/auth/AuthContext';
|
||||
import { useTheme } from '@/auth/ThemeContext';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import clsx from 'clsx';
|
||||
|
||||
const PAGE_TITLES = {
|
||||
'/': 'Overview',
|
||||
'/vpgs': 'VPG Monitor',
|
||||
'/vms': 'VM Protection',
|
||||
'/vras': 'VRA Infrastructure',
|
||||
'/encryption': 'Encryption Detection',
|
||||
'/storage': 'Storage & Datastores',
|
||||
'/planner': 'DR Capacity Planner',
|
||||
'/settings/users': 'User Management',
|
||||
'/settings': 'Settings',
|
||||
};
|
||||
|
||||
function UserMenu({ user, onLogout }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, []);
|
||||
|
||||
const initials = user.name
|
||||
? user.name.split(' ').map((w) => w[0]).slice(0, 2).join('').toUpperCase()
|
||||
: user.username.slice(0, 2).toUpperCase();
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button onClick={() => setOpen((v) => !v)}
|
||||
className="flex items-center gap-2 pl-3 pr-2 py-1.5 rounded-md hover:bg-raised border border-transparent hover:border-border transition-all duration-150">
|
||||
<div className="w-7 h-7 rounded-full bg-accent/20 text-accent flex items-center justify-center font-mono text-xs font-semibold">
|
||||
{initials}
|
||||
</div>
|
||||
<div className="hidden sm:block text-left">
|
||||
<p className="text-xs font-medium text-text-primary leading-none">{user.name || user.username}</p>
|
||||
<p className="text-[10px] text-text-muted font-mono leading-none mt-0.5 capitalize">{user.role}</p>
|
||||
</div>
|
||||
<ChevronDown size={12} className="text-text-muted" />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute right-0 top-full mt-1 w-52 card-raised shadow-panel z-50 py-1 animate-fade-in">
|
||||
<div className="px-3 py-2 border-b border-border">
|
||||
<p className="text-xs font-medium text-text-primary">{user.name}</p>
|
||||
<p className="text-[10px] text-text-muted font-mono">{user.email}</p>
|
||||
</div>
|
||||
<button onClick={() => { setOpen(false); onLogout(); }}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-sm text-text-secondary hover:text-crit hover:bg-crit/5 transition-colors">
|
||||
<LogOut size={13} />
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TopBar({ sidebarOpen, onMenuToggle }) {
|
||||
const { user, logout } = useAuth();
|
||||
const { theme, toggle: toggleTheme } = useTheme();
|
||||
const location = useLocation();
|
||||
const queryClient = useQueryClient();
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const title = PAGE_TITLES[location.pathname] ?? 'zROC';
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
await queryClient.invalidateQueries();
|
||||
setTimeout(() => setRefreshing(false), 800);
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="h-14 flex items-center justify-between px-4 border-b border-border bg-surface flex-shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={onMenuToggle}
|
||||
className="p-1.5 rounded text-text-muted hover:text-text-primary hover:bg-raised transition-colors md:hidden">
|
||||
<Menu size={16} />
|
||||
</button>
|
||||
<h2 className="font-mono text-sm font-semibold text-text-primary">{title}</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={handleRefresh} title="Refresh all data"
|
||||
className="p-1.5 rounded text-text-muted hover:text-accent hover:bg-accent/10 transition-all duration-150">
|
||||
<RefreshCw size={14} className={clsx(refreshing && 'animate-spin text-accent')} />
|
||||
</button>
|
||||
<button onClick={toggleTheme} title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
className="p-1.5 rounded text-text-muted hover:text-accent hover:bg-accent/10 transition-all duration-150">
|
||||
{theme === 'dark' ? <Sun size={14} /> : <Moon size={14} />}
|
||||
</button>
|
||||
{user && <UserMenu user={user} onLogout={logout} />}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// src/main.jsx
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import '@/styles/index.css';
|
||||
import App from './App';
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,130 @@
|
||||
// src/pages/Encryption.jsx
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ShieldAlert, ShieldCheck, TrendingUp, AlertTriangle, Loader2 } from 'lucide-react';
|
||||
import { queryEncryptionDetail } from '@/api/prometheusExtended';
|
||||
import TimeSeriesChart from '@/components/charts/TimeSeriesChart';
|
||||
import clsx from 'clsx';
|
||||
|
||||
const REFRESH = 30_000;
|
||||
|
||||
function EncryptionBar({ pct }) {
|
||||
const enc = Math.min(pct ?? 0, 100);
|
||||
const color = enc > 80 ? 'bg-crit' : enc > 50 ? 'bg-warn' : 'bg-ok';
|
||||
const textC = enc > 80 ? 'text-crit' : enc > 50 ? 'text-warn' : 'text-ok';
|
||||
return (
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<div className="flex-1 h-2 bg-border rounded-full overflow-hidden flex">
|
||||
<div className={clsx('h-full transition-all duration-500', color)} style={{ width: `${enc}%` }} />
|
||||
</div>
|
||||
<span className={clsx('font-mono text-xs data-value w-12 text-right flex-shrink-0', textC)}>
|
||||
{enc.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TrendBadge({ level }) {
|
||||
const l = level ?? 0;
|
||||
if (l === 0) return <span className="badge badge-ok">Stable</span>;
|
||||
if (l === 1) return <span className="badge badge-warn">Rising</span>;
|
||||
return <span className="badge badge-crit">Spike</span>;
|
||||
}
|
||||
|
||||
export default function EncryptionPage() {
|
||||
const { data: vms = [], isLoading } = useQuery({
|
||||
queryKey: ['encryption-detail'],
|
||||
queryFn: queryEncryptionDetail,
|
||||
refetchInterval: REFRESH,
|
||||
});
|
||||
|
||||
const anomalies = vms.filter((v) => (v.pctEncrypted ?? 0) > 50);
|
||||
const highAlert = vms.filter((v) => (v.pctEncrypted ?? 0) > 80);
|
||||
const avgPct = vms.length ? vms.reduce((s, v) => s + (v.pctEncrypted ?? 0), 0) / vms.length : 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: 'VMs Monitored', value: vms.length, icon: ShieldAlert, color: 'accent' },
|
||||
{ label: 'Anomalies (>50%)', value: anomalies.length, icon: AlertTriangle, color: anomalies.length ? 'warn' : 'ok' },
|
||||
{ label: 'High Alert (>80%)', value: highAlert.length, icon: TrendingUp, color: highAlert.length ? 'crit' : 'ok' },
|
||||
{ label: 'Avg Encryption', value: `${avgPct.toFixed(1)}%`, icon: ShieldCheck, color: avgPct > 60 ? 'warn' : 'ok' },
|
||||
].map((s) => (
|
||||
<div key={s.label} className="card p-4 flex items-start gap-3">
|
||||
<div className={clsx('w-9 h-9 rounded-lg flex items-center justify-center',
|
||||
s.color === 'ok' && 'bg-ok/10', s.color === 'warn' && 'bg-warn/10',
|
||||
s.color === 'crit' && 'bg-crit/10', s.color === 'accent' && 'bg-accent/10')}>
|
||||
<s.icon size={16} className={clsx(
|
||||
s.color === 'ok' && 'text-ok', s.color === 'warn' && 'text-warn',
|
||||
s.color === 'crit' && 'text-crit', s.color === 'accent' && 'text-accent')} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="section-title">{s.label}</p>
|
||||
<p className="font-data text-xl font-semibold text-text-primary data-value mt-0.5">{s.value}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{anomalies.length > 0 && (
|
||||
<TimeSeriesChart
|
||||
title="Encryption % Over Time — Top Anomalies"
|
||||
promql={`vm_PercentEncrypted{VmName="${anomalies[0]?.name?.replace(/"/g, '\\"')}"}`}
|
||||
yFormatter={(v) => `${v.toFixed(1)}%`}
|
||||
refLines={[{ value: 80, label: 'High alert', color: 'crit' }, { value: 50, label: 'Warning', color: 'warn' }]}
|
||||
transform={(result) => result[0]?.values.map(([ts, v]) => ({ ts: ts * 1000, 'Encrypted %': parseFloat(v) })) ?? []}
|
||||
height={180}
|
||||
/>
|
||||
)}
|
||||
|
||||
<section>
|
||||
<p className="section-title mb-3">VM Encryption Status</p>
|
||||
<div className="card overflow-hidden">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<th className="px-4 py-2.5 text-left section-title">VM</th>
|
||||
<th className="px-4 py-2.5 text-left section-title">VPG</th>
|
||||
<th className="px-4 py-2.5 text-left section-title">Encryption %</th>
|
||||
<th className="px-4 py-2.5 text-left section-title hidden md:table-cell">Trend</th>
|
||||
<th className="px-4 py-2.5 text-right section-title hidden lg:table-cell">IO Ops</th>
|
||||
<th className="px-4 py-2.5 text-right section-title hidden lg:table-cell">Write</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading && (
|
||||
<tr><td colSpan={6} className="py-10 text-center">
|
||||
<Loader2 size={16} className="animate-spin text-text-muted mx-auto" />
|
||||
</td></tr>
|
||||
)}
|
||||
{!isLoading && vms.map((vm) => (
|
||||
<tr key={vm.id} className="border-b border-border/40 last:border-0 hover:bg-raised transition-colors">
|
||||
<td className="px-4 py-2.5 font-medium text-text-primary">{vm.name}</td>
|
||||
<td className="px-4 py-2.5 text-text-muted">{vm.vpgName}</td>
|
||||
<td className="px-4 py-2.5 min-w-[160px]"><EncryptionBar pct={vm.pctEncrypted} /></td>
|
||||
<td className="px-4 py-2.5 hidden md:table-cell"><TrendBadge level={vm.trendLevel} /></td>
|
||||
<td className="px-4 py-2.5 text-right hidden lg:table-cell">
|
||||
<span className="font-mono data-value text-text-secondary">
|
||||
{vm.ioOps != null ? Math.round(vm.ioOps).toLocaleString() : '—'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right hidden lg:table-cell">
|
||||
<span className="font-mono data-value text-text-secondary">
|
||||
{vm.writeMb != null ? `${vm.writeMb.toFixed(2)} MB` : '—'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!isLoading && vms.length === 0 && (
|
||||
<tr><td colSpan={6} className="py-12 text-center">
|
||||
<ShieldCheck size={24} className="text-ok mx-auto mb-2" />
|
||||
<p className="text-text-muted">No encryption stats available</p>
|
||||
</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
// src/pages/Overview.jsx
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { CheckCircle2, AlertTriangle, XCircle, Activity } from 'lucide-react';
|
||||
import { queryOverviewSummary, queryAllVpgs, queryTopRpoViolators, queryExporterHealth } from '@/api/prometheus';
|
||||
import { rpoStatus, formatRpo, colorToText } from '@/constants/statusMaps';
|
||||
import clsx from 'clsx';
|
||||
|
||||
const REFRESH = 30_000;
|
||||
|
||||
function StatCard({ label, value, sub, color = 'accent', icon: Icon }) {
|
||||
return (
|
||||
<div className="card p-4 flex items-start gap-4">
|
||||
<div className={clsx('w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0',
|
||||
`bg-${color}/10`)}>
|
||||
<Icon size={18} className={`text-${color}`} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="section-title">{label}</p>
|
||||
<p className="font-data text-2xl font-semibold text-text-primary mt-0.5 data-value">{value ?? '—'}</p>
|
||||
{sub && <p className="text-xs text-text-muted mt-0.5">{sub}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SiteCard({ site }) {
|
||||
const hasCrit = site.crit > 0;
|
||||
return (
|
||||
<div className={clsx('card p-4 border transition-colors duration-300',
|
||||
hasCrit ? 'border-crit/30' : site.warn > 0 ? 'border-warn/30' : 'border-border')}>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={clsx('status-dot', hasCrit ? 'status-dot-crit' : site.warn > 0 ? 'status-dot-warn' : 'status-dot-ok')} />
|
||||
<p className="font-mono text-sm font-semibold text-text-primary">{site.siteName}</p>
|
||||
</div>
|
||||
<span className={clsx('badge text-xs', hasCrit ? 'badge-crit' : site.warn > 0 ? 'badge-warn' : 'badge-ok')}>
|
||||
{hasCrit ? 'Alert' : site.warn > 0 ? 'Warning' : 'Healthy'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-center">
|
||||
<div><p className="font-data text-xl font-semibold text-ok data-value">{site.ok}</p><p className="section-title">OK</p></div>
|
||||
<div><p className="font-data text-xl font-semibold text-warn data-value">{site.warn}</p><p className="section-title">Warn</p></div>
|
||||
<div><p className="font-data text-xl font-semibold text-crit data-value">{site.crit}</p><p className="section-title">Crit</p></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VpgTile({ vpg, onClick }) {
|
||||
const status = rpoStatus(vpg.actualRpoSec, vpg.configuredRpoSec);
|
||||
return (
|
||||
<button onClick={onClick}
|
||||
title={`${vpg.name}\nRPO: ${formatRpo(vpg.actualRpoSec)}`}
|
||||
className={clsx('relative p-2 rounded-md border text-left transition-all duration-200 hover:scale-105 hover:z-10',
|
||||
status === 'ok' && 'bg-ok/8 border-ok/20',
|
||||
status === 'warn' && 'bg-warn/8 border-warn/20',
|
||||
status === 'crit' && 'bg-crit/8 border-crit/20',
|
||||
status === 'muted' && 'bg-raised border-border')}>
|
||||
<p className={clsx('text-[10px] font-mono font-semibold truncate leading-tight',
|
||||
status === 'ok' && 'text-ok', status === 'warn' && 'text-warn',
|
||||
status === 'crit' && 'text-crit', status === 'muted' && 'text-text-muted')}>
|
||||
{vpg.name}
|
||||
</p>
|
||||
<p className="text-[9px] text-text-muted font-mono data-value mt-0.5">{formatRpo(vpg.actualRpoSec)}</p>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Overview() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data: sites = [] } = useQuery({
|
||||
queryKey: ['overview-summary'], queryFn: queryOverviewSummary, refetchInterval: REFRESH,
|
||||
});
|
||||
const { data: vpgs = [], isLoading: vpgsLoading } = useQuery({
|
||||
queryKey: ['all-vpgs'], queryFn: queryAllVpgs, refetchInterval: REFRESH,
|
||||
});
|
||||
const { data: violators = [] } = useQuery({
|
||||
queryKey: ['top-violators'], queryFn: () => queryTopRpoViolators(10), refetchInterval: REFRESH,
|
||||
});
|
||||
const { data: exporterHealth = [] } = useQuery({
|
||||
queryKey: ['exporter-health'], queryFn: queryExporterHealth, refetchInterval: REFRESH,
|
||||
});
|
||||
|
||||
const totalOk = sites.reduce((s, x) => s + x.ok, 0);
|
||||
const totalWarn = sites.reduce((s, x) => s + x.warn, 0);
|
||||
const totalCrit = sites.reduce((s, x) => s + x.crit, 0);
|
||||
const totalMbps = sites.reduce((s, x) => s + (x.throughputMb ?? 0), 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard label="Meeting SLA" value={totalOk} sub="VPGs within RPO target" color="ok" icon={CheckCircle2} />
|
||||
<StatCard label="Warnings" value={totalWarn} sub="Approaching RPO limit" color="warn" icon={AlertTriangle} />
|
||||
<StatCard label="Violations" value={totalCrit} sub="Exceeding RPO target" color="crit" icon={XCircle} />
|
||||
<StatCard label="Replication" value={`${totalMbps.toFixed(1)} MB/s`} sub="Total throughput" color="accent" icon={Activity} />
|
||||
</div>
|
||||
|
||||
{sites.length > 0 && (
|
||||
<section>
|
||||
<p className="section-title mb-3">Sites</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{sites.map((s) => <SiteCard key={s.siteName} site={s} />)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section>
|
||||
<p className="section-title mb-3">VPG RPO Heat Grid</p>
|
||||
{vpgsLoading ? (
|
||||
<div className="card p-8 text-center text-text-muted text-xs font-mono">Loading VPGs…</div>
|
||||
) : (
|
||||
<div className="card p-4">
|
||||
<div className="grid gap-2" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(110px, 1fr))' }}>
|
||||
{vpgs.sort((a, b) => {
|
||||
const sev = (v) => { const s = rpoStatus(v.actualRpoSec, v.configuredRpoSec); return s === 'crit' ? 0 : s === 'warn' ? 1 : 2; };
|
||||
return sev(a) - sev(b);
|
||||
}).map((vpg) => (
|
||||
<VpgTile key={vpg.id} vpg={vpg} onClick={() => navigate(`/vpgs?name=${encodeURIComponent(vpg.name)}`)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||
<div className="xl:col-span-2">
|
||||
<p className="section-title mb-3">Top RPO Violators</p>
|
||||
<div className="card overflow-hidden">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<th className="px-3 py-2 text-left section-title">VPG</th>
|
||||
<th className="px-3 py-2 text-left section-title">Site</th>
|
||||
<th className="px-3 py-2 text-right section-title">Actual RPO</th>
|
||||
<th className="px-3 py-2 text-right section-title">Target</th>
|
||||
<th className="px-3 py-2 text-right section-title">Ratio</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{violators.map((v) => {
|
||||
const ratio = v.configuredRpoSec ? v.actualRpoSec / v.configuredRpoSec : 0;
|
||||
const status = rpoStatus(v.actualRpoSec, v.configuredRpoSec);
|
||||
return (
|
||||
<tr key={v.id} className="table-row-hover border-b border-border/40 last:border-0"
|
||||
onClick={() => navigate(`/vpgs?name=${encodeURIComponent(v.name)}`)}>
|
||||
<td className="px-3 py-2 font-mono font-semibold text-text-primary">{v.name}</td>
|
||||
<td className="px-3 py-2 text-text-muted">{v.siteName}</td>
|
||||
<td className={clsx('px-3 py-2 text-right font-data data-value', colorToText[status])}>{formatRpo(v.actualRpoSec)}</td>
|
||||
<td className="px-3 py-2 text-right font-data data-value text-text-muted">{formatRpo(v.configuredRpoSec)}</td>
|
||||
<td className="px-3 py-2 text-right"><span className={clsx('badge', `badge-${status}`)}>{ratio.toFixed(1)}x</span></td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{violators.length === 0 && (
|
||||
<tr><td colSpan={5} className="px-3 py-8 text-center text-text-muted">
|
||||
<CheckCircle2 size={20} className="text-ok mx-auto mb-1" />All VPGs within RPO targets
|
||||
</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="section-title mb-3">Collector Health</p>
|
||||
<div className="card p-4 space-y-3">
|
||||
{exporterHealth.length === 0 && <p className="text-xs text-text-muted italic">No exporter data</p>}
|
||||
{exporterHealth.map((t) => (
|
||||
<div key={`${t.instance}-${t.thread}`} className="flex items-center justify-between py-1.5 border-b border-border/40 last:border-0">
|
||||
<div>
|
||||
<p className="text-xs font-mono font-medium text-text-primary">{t.thread}</p>
|
||||
<p className="text-[10px] text-text-muted">{t.instance}</p>
|
||||
</div>
|
||||
<span className={clsx('badge', t.alive ? 'badge-ok' : 'badge-crit')}>
|
||||
<span className={clsx('status-dot', t.alive ? 'status-dot-ok' : 'status-dot-crit')} />
|
||||
{t.alive ? 'Running' : 'Down'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// src/pages/Placeholder.jsx
|
||||
// Temporary placeholder used for pages not yet built (Phase 2+).
|
||||
// Displays a construction card so navigation works from day one.
|
||||
import { Construction } from 'lucide-react';
|
||||
|
||||
export default function Placeholder({ title, description }) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full animate-fade-in">
|
||||
<div className="card p-12 text-center max-w-md">
|
||||
<div className="w-14 h-14 rounded-xl bg-accent/10 flex items-center justify-center mx-auto mb-5">
|
||||
<Construction size={24} className="text-accent" />
|
||||
</div>
|
||||
<h2 className="font-mono text-base font-semibold text-text-primary mb-2">{title}</h2>
|
||||
<p className="text-sm text-text-muted leading-relaxed">{description}</p>
|
||||
<p className="mt-4 text-xs font-mono text-text-muted border border-border rounded px-3 py-1.5 inline-block">
|
||||
Phase 2 — Coming next
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
// src/pages/Planner.jsx — DR capacity planner
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Calculator, HardDrive, Wifi, Database, Download, Search, FileText } from 'lucide-react';
|
||||
import { queryPlannerVms } from '@/api/planner';
|
||||
import clsx from 'clsx';
|
||||
import { jsPDF } from 'jspdf';
|
||||
|
||||
const REFRESH = 60_000;
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function fmtGb(gb) {
|
||||
if (gb == null || isNaN(gb)) return '—';
|
||||
if (gb >= 1024) return `${(gb / 1024).toFixed(2)} TB`;
|
||||
return `${gb.toFixed(1)} GB`;
|
||||
}
|
||||
|
||||
function fmtMbps(mbps) {
|
||||
if (mbps == null || isNaN(mbps)) return '—';
|
||||
if (mbps >= 1000) return `${(mbps / 1000).toFixed(2)} Gbps`;
|
||||
return `${mbps.toFixed(1)} Mbps`;
|
||||
}
|
||||
|
||||
const JOURNAL_OPTIONS = [
|
||||
{ label: '1 hour', seconds: 3600 },
|
||||
{ label: '4 hours', seconds: 14400 },
|
||||
{ label: '8 hours', seconds: 28800 },
|
||||
...Array.from({ length: 30 }, (_, i) => ({
|
||||
label: i === 0 ? '1 day' : `${i + 1} days`,
|
||||
seconds: (i + 1) * 86400,
|
||||
})),
|
||||
];
|
||||
|
||||
// ── Result card ───────────────────────────────────────────────────────────────
|
||||
|
||||
function ResultCard({ icon: Icon, label, value, sub, color = 'accent' }) {
|
||||
return (
|
||||
<div className="card p-5 flex items-start gap-4">
|
||||
<div className={clsx('w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0', `bg-${color}/10`)}>
|
||||
<Icon size={18} className={`text-${color}`} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="section-title">{label}</p>
|
||||
<p className="font-data text-2xl font-semibold text-text-primary mt-0.5 data-value">{value}</p>
|
||||
{sub && <p className="text-xs text-text-muted mt-1">{sub}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── VM row ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function VmRow({ vm, selected, onToggle }) {
|
||||
return (
|
||||
<tr
|
||||
onClick={onToggle}
|
||||
className={clsx(
|
||||
'cursor-pointer transition-colors duration-100',
|
||||
selected ? 'bg-accent/8' : 'hover:bg-raised',
|
||||
)}
|
||||
>
|
||||
<td className="px-3 py-2.5 w-8">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
onChange={onToggle}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="accent-accent"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 font-mono text-xs text-text-primary">{vm.name}</td>
|
||||
<td className="px-3 py-2.5 font-mono text-xs text-text-secondary">{vm.cluster || '—'}</td>
|
||||
<td className="px-3 py-2.5 font-mono text-xs text-text-secondary">{vm.datacenter || '—'}</td>
|
||||
<td className="px-3 py-2.5 font-mono text-xs text-right data-value">{fmtGb(vm.provisionedGb)}</td>
|
||||
<td className="px-3 py-2.5 font-mono text-xs text-right data-value">{fmtMbps(vm.writeThroughputMbps)}</td>
|
||||
<td className="px-3 py-2.5 font-mono text-xs text-right text-text-muted data-value">
|
||||
{vm.writeIops != null ? vm.writeIops.toFixed(0) : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Mock data for preview ─────────────────────────────────────────────────────
|
||||
|
||||
const MOCK_VMS = [
|
||||
{ moref: 'vm-101', name: 'web-prod-01', cluster: 'Cluster-01', datacenter: 'DC-East', provisionedGb: 120, writeThroughputMbps: 45.2, writeIops: 1820, writeLatencyMs: 3.1 },
|
||||
{ moref: 'vm-102', name: 'db-prod-01', cluster: 'Cluster-01', datacenter: 'DC-East', provisionedGb: 2048, writeThroughputMbps: 312.8, writeIops: 12400, writeLatencyMs: 1.8 },
|
||||
{ moref: 'vm-103', name: 'db-prod-02', cluster: 'Cluster-01', datacenter: 'DC-East', provisionedGb: 2048, writeThroughputMbps: 287.4, writeIops: 11200, writeLatencyMs: 2.0 },
|
||||
{ moref: 'vm-104', name: 'app-prod-01', cluster: 'Cluster-02', datacenter: 'DC-East', provisionedGb: 256, writeThroughputMbps: 18.6, writeIops: 640, writeLatencyMs: 4.2 },
|
||||
{ moref: 'vm-105', name: 'app-prod-02', cluster: 'Cluster-02', datacenter: 'DC-East', provisionedGb: 256, writeThroughputMbps: 21.3, writeIops: 780, writeLatencyMs: 3.9 },
|
||||
{ moref: 'vm-106', name: 'cache-01', cluster: 'Cluster-02', datacenter: 'DC-East', provisionedGb: 512, writeThroughputMbps: 8.1, writeIops: 310, writeLatencyMs: 5.5 },
|
||||
{ moref: 'vm-107', name: 'file-srv-01', cluster: 'Cluster-03', datacenter: 'DC-West', provisionedGb: 4096, writeThroughputMbps: 92.0, writeIops: 3200, writeLatencyMs: 6.1 },
|
||||
{ moref: 'vm-108', name: 'infra-dc-01', cluster: 'Cluster-03', datacenter: 'DC-West', provisionedGb: 80, writeThroughputMbps: 2.4, writeIops: 120, writeLatencyMs: 8.2 },
|
||||
{ moref: 'vm-109', name: 'backup-srv-01',cluster: 'Cluster-03', datacenter: 'DC-West', provisionedGb: 8192, writeThroughputMbps: 180.0,writeIops: 5600, writeLatencyMs: 12.0 },
|
||||
{ moref: 'vm-110', name: 'mon-01', cluster: 'Cluster-01', datacenter: 'DC-East', provisionedGb: 100, writeThroughputMbps: 1.2, writeIops: 55, writeLatencyMs: 9.0 },
|
||||
];
|
||||
|
||||
// ── Main page ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function Planner() {
|
||||
const [selected, setSelected] = useState(new Set());
|
||||
const [journalIdx, setJournalIdx] = useState(3); // default: 1 day
|
||||
const [compression, setCompression] = useState(50); // default: 50%
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const isMock = import.meta.env.VITE_MOCK_AUTH === 'true';
|
||||
|
||||
const { data: liveVms = [], isLoading } = useQuery({
|
||||
queryKey: ['planner-vms'],
|
||||
queryFn: queryPlannerVms,
|
||||
refetchInterval: REFRESH,
|
||||
enabled: !isMock,
|
||||
});
|
||||
|
||||
const vms = isMock ? MOCK_VMS : liveVms;
|
||||
|
||||
const filtered = useMemo(() =>
|
||||
vms.filter((vm) =>
|
||||
!search || vm.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
vm.cluster.toLowerCase().includes(search.toLowerCase()) ||
|
||||
vm.datacenter.toLowerCase().includes(search.toLowerCase())
|
||||
),
|
||||
[vms, search]
|
||||
);
|
||||
|
||||
const toggle = (moref) =>
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(moref) ? next.delete(moref) : next.add(moref);
|
||||
return next;
|
||||
});
|
||||
|
||||
const toggleAll = () => {
|
||||
if (selected.size === filtered.length) {
|
||||
setSelected(new Set());
|
||||
} else {
|
||||
setSelected(new Set(filtered.map((v) => v.moref)));
|
||||
}
|
||||
};
|
||||
|
||||
const selectedVms = vms.filter((v) => selected.has(v.moref));
|
||||
const journalSec = JOURNAL_OPTIONS[journalIdx].seconds;
|
||||
const ratio = compression / 100;
|
||||
|
||||
// ── Calculations ───────────────────────────────────────────────────────────
|
||||
const totalThroughputMbps = selectedVms.reduce((s, v) => s + (v.writeThroughputMbps ?? 0), 0);
|
||||
const totalProvisionedGb = selectedVms.reduce((s, v) => s + (v.provisionedGb ?? 0), 0);
|
||||
|
||||
const bwRequiredMbps = totalThroughputMbps * (1 - ratio);
|
||||
const journalStorageGb = (totalThroughputMbps * (1 - ratio)) * (journalSec / 1024); // MB/s → GB over period
|
||||
const mirrorStorageGb = totalProvisionedGb;
|
||||
const totalDrStorageGb = journalStorageGb + mirrorStorageGb;
|
||||
|
||||
// ── Export ─────────────────────────────────────────────────────────────────
|
||||
const exportCsv = () => {
|
||||
const rows = [
|
||||
['VM Name', 'Cluster', 'Datacenter', 'Provisioned (GB)', 'Write Throughput (Mbps)', 'Write IOPS'],
|
||||
...selectedVms.map((v) => [
|
||||
v.name, v.cluster, v.datacenter,
|
||||
(v.provisionedGb ?? 0).toFixed(1),
|
||||
(v.writeThroughputMbps ?? 0).toFixed(2),
|
||||
(v.writeIops ?? 0).toFixed(0),
|
||||
]),
|
||||
[],
|
||||
['--- Summary ---'],
|
||||
['Journal Retention', JOURNAL_OPTIONS[journalIdx].label],
|
||||
['Compression', `${compression}%`],
|
||||
['Bandwidth Required', `${fmtMbps(bwRequiredMbps)}`],
|
||||
['Journal Storage', `${fmtGb(journalStorageGb)}`],
|
||||
['Mirror Storage', `${fmtGb(mirrorStorageGb)}`],
|
||||
['Total DR Storage', `${fmtGb(totalDrStorageGb)}`],
|
||||
];
|
||||
const csv = rows.map((r) => r.map((c) => `"${c}"`).join(',')).join('\n');
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = 'zroc-planner-report.csv'; a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const exportPdf = () => {
|
||||
const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' });
|
||||
const margin = 15;
|
||||
const pageW = 210;
|
||||
const colW = pageW - margin * 2;
|
||||
let y = margin;
|
||||
|
||||
// Title
|
||||
doc.setFontSize(18);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('zROC — DR Capacity Planner Report', margin, y);
|
||||
y += 8;
|
||||
doc.setFontSize(9);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(100);
|
||||
doc.text(`Generated: ${new Date().toLocaleString()}`, margin, y);
|
||||
y += 10;
|
||||
|
||||
// Summary box
|
||||
doc.setDrawColor(14, 165, 233);
|
||||
doc.setFillColor(240, 248, 255);
|
||||
doc.roundedRect(margin, y, colW, 40, 2, 2, 'FD');
|
||||
doc.setTextColor(0);
|
||||
doc.setFontSize(10);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('Planning Parameters', margin + 4, y + 7);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setFontSize(9);
|
||||
const params = [
|
||||
['VMs selected', `${selected.size}`],
|
||||
['Journal retention', JOURNAL_OPTIONS[journalIdx].label],
|
||||
['WAN compression', `${compression}%`],
|
||||
];
|
||||
params.forEach(([label, val], i) => {
|
||||
doc.setTextColor(80); doc.text(label, margin + 4, y + 15 + i * 7);
|
||||
doc.setTextColor(0); doc.text(val, margin + 60, y + 15 + i * 7);
|
||||
});
|
||||
y += 48;
|
||||
|
||||
// Results
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setFontSize(11);
|
||||
doc.text('Capacity Estimates', margin, y);
|
||||
y += 6;
|
||||
const results = [
|
||||
['Bandwidth Required', fmtMbps(bwRequiredMbps), `Raw ${fmtMbps(totalThroughputMbps)} × ${100 - compression}%`],
|
||||
['Journal Storage', fmtGb(journalStorageGb), `${JOURNAL_OPTIONS[journalIdx].label} at ${fmtMbps(bwRequiredMbps)}`],
|
||||
['Mirror Storage', fmtGb(mirrorStorageGb), 'Full copy of selected VM disks'],
|
||||
['Total DR Storage Footprint', fmtGb(totalDrStorageGb), 'Journal + Mirror combined'],
|
||||
];
|
||||
results.forEach(([label, val, note]) => {
|
||||
doc.setFontSize(9); doc.setFont('helvetica', 'normal'); doc.setTextColor(80);
|
||||
doc.text(label, margin, y);
|
||||
doc.setFont('helvetica', 'bold'); doc.setTextColor(0); doc.setFontSize(12);
|
||||
doc.text(val, margin + 70, y);
|
||||
doc.setFont('helvetica', 'normal'); doc.setFontSize(8); doc.setTextColor(120);
|
||||
doc.text(note, margin + 110, y);
|
||||
y += 9;
|
||||
});
|
||||
y += 6;
|
||||
|
||||
// VM table header
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setFontSize(11);
|
||||
doc.setTextColor(0);
|
||||
doc.text('Selected VMs', margin, y);
|
||||
y += 5;
|
||||
doc.setFillColor(230, 240, 255);
|
||||
doc.rect(margin, y, colW, 6, 'F');
|
||||
doc.setFontSize(8);
|
||||
['VM Name', 'Cluster', 'Datacenter', 'Disk (GB)', 'Write BW', 'IOPS'].forEach((h, i) => {
|
||||
doc.text(h, margin + [0, 50, 85, 120, 143, 163][i], y + 4);
|
||||
});
|
||||
y += 7;
|
||||
|
||||
// VM rows
|
||||
doc.setFont('helvetica', 'normal');
|
||||
selectedVms.forEach((vm, idx) => {
|
||||
if (y > 270) { doc.addPage(); y = margin; }
|
||||
if (idx % 2 === 0) { doc.setFillColor(248, 250, 252); doc.rect(margin, y - 1, colW, 6, 'F'); }
|
||||
doc.setTextColor(0);
|
||||
doc.setFontSize(8);
|
||||
[
|
||||
vm.name.slice(0, 24),
|
||||
(vm.cluster || '—').slice(0, 16),
|
||||
(vm.datacenter || '—').slice(0, 14),
|
||||
(vm.provisionedGb ?? 0).toFixed(1),
|
||||
fmtMbps(vm.writeThroughputMbps),
|
||||
(vm.writeIops ?? 0).toFixed(0),
|
||||
].forEach((val, i) => doc.text(val, margin + [0, 50, 85, 120, 143, 163][i], y + 4));
|
||||
y += 6;
|
||||
});
|
||||
|
||||
doc.save('zroc-planner-report.pdf');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Calculator size={20} className="text-accent" />
|
||||
<h1 className="text-lg font-semibold text-text-primary">DR Capacity Planner</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={exportCsv}
|
||||
disabled={selected.size === 0}
|
||||
className={clsx(
|
||||
'flex items-center gap-2 px-3 py-1.5 rounded-md text-xs font-medium border transition-colors duration-150',
|
||||
selected.size > 0
|
||||
? 'border-border text-text-secondary hover:bg-raised hover:text-text-primary'
|
||||
: 'text-text-muted border-border cursor-not-allowed opacity-50',
|
||||
)}
|
||||
>
|
||||
<Download size={13} />
|
||||
CSV
|
||||
</button>
|
||||
<button
|
||||
onClick={exportPdf}
|
||||
disabled={selected.size === 0}
|
||||
className={clsx(
|
||||
'flex items-center gap-2 px-3 py-1.5 rounded-md text-xs font-medium border transition-colors duration-150',
|
||||
selected.size > 0
|
||||
? 'bg-accent text-canvas border-accent hover:bg-accent/80'
|
||||
: 'text-text-muted border-border cursor-not-allowed opacity-50',
|
||||
)}
|
||||
>
|
||||
<FileText size={13} />
|
||||
Export PDF
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||
{/* Left — VM selector */}
|
||||
<div className="xl:col-span-2 card overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-border flex items-center justify-between">
|
||||
<p className="font-mono text-xs text-text-secondary uppercase tracking-wider">
|
||||
Select VMs to model
|
||||
</p>
|
||||
<span className="text-xs text-text-muted">
|
||||
{selected.size} / {vms.length} selected
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="px-4 py-2 border-b border-border">
|
||||
<div className="relative">
|
||||
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-text-muted" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Filter VMs…"
|
||||
className="w-full bg-raised border border-border rounded-md pl-7 pr-3 py-1.5 text-xs font-mono text-text-primary placeholder:text-text-muted focus:outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<th className="px-3 py-2 w-8">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filtered.length > 0 && selected.size === filtered.length}
|
||||
onChange={toggleAll}
|
||||
className="accent-accent"
|
||||
/>
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left section-title">VM</th>
|
||||
<th className="px-3 py-2 text-left section-title">Cluster</th>
|
||||
<th className="px-3 py-2 text-left section-title">Datacenter</th>
|
||||
<th className="px-3 py-2 text-right section-title">Disk Size</th>
|
||||
<th className="px-3 py-2 text-right section-title">Write BW</th>
|
||||
<th className="px-3 py-2 text-right section-title">Write IOPS</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{isLoading && !isMock ? (
|
||||
<tr><td colSpan={7} className="px-4 py-8 text-center text-text-muted">Loading VMs…</td></tr>
|
||||
) : filtered.length === 0 ? (
|
||||
<tr><td colSpan={7} className="px-4 py-8 text-center text-text-muted">No VMs found</td></tr>
|
||||
) : (
|
||||
filtered.map((vm) => (
|
||||
<VmRow
|
||||
key={vm.moref}
|
||||
vm={vm}
|
||||
selected={selected.has(vm.moref)}
|
||||
onToggle={() => toggle(vm.moref)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right — inputs + results */}
|
||||
<div className="space-y-4">
|
||||
{/* Inputs */}
|
||||
<div className="card p-4 space-y-5">
|
||||
<p className="font-mono text-xs text-text-secondary uppercase tracking-wider border-b border-border pb-2">
|
||||
Planning Inputs
|
||||
</p>
|
||||
|
||||
{/* Journal retention */}
|
||||
<div>
|
||||
<div className="flex justify-between mb-2">
|
||||
<label className="section-title">Journal Retention</label>
|
||||
<span className="font-mono text-xs text-accent font-semibold">
|
||||
{JOURNAL_OPTIONS[journalIdx].label}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={JOURNAL_OPTIONS.length - 1}
|
||||
value={journalIdx}
|
||||
onChange={(e) => setJournalIdx(Number(e.target.value))}
|
||||
className="w-full accent-accent"
|
||||
/>
|
||||
<div className="flex justify-between text-[9px] text-text-muted font-mono mt-1">
|
||||
<span>1h</span><span>8h</span><span>7d</span><span>15d</span><span>30d</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Compression */}
|
||||
<div>
|
||||
<div className="flex justify-between mb-2">
|
||||
<label className="section-title">WAN Compression</label>
|
||||
<span className="font-mono text-xs text-accent font-semibold">{compression}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={80}
|
||||
step={5}
|
||||
value={compression}
|
||||
onChange={(e) => setCompression(Number(e.target.value))}
|
||||
className="w-full accent-accent"
|
||||
/>
|
||||
<div className="flex justify-between text-[9px] text-text-muted font-mono mt-1">
|
||||
<span>0%</span><span>40%</span><span>80%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div className="space-y-3">
|
||||
{selected.size === 0 && (
|
||||
<p className="text-xs text-text-muted text-center py-2">
|
||||
Select VMs to see estimates
|
||||
</p>
|
||||
)}
|
||||
<ResultCard
|
||||
icon={Wifi}
|
||||
label="Bandwidth Required"
|
||||
value={fmtMbps(bwRequiredMbps)}
|
||||
sub={`Raw: ${fmtMbps(totalThroughputMbps)} → ${compression}% compressed`}
|
||||
color="accent"
|
||||
/>
|
||||
<ResultCard
|
||||
icon={HardDrive}
|
||||
label="Journal Storage"
|
||||
value={fmtGb(journalStorageGb)}
|
||||
sub={`${JOURNAL_OPTIONS[journalIdx].label} at ${fmtMbps(bwRequiredMbps)} after compression`}
|
||||
color="warn"
|
||||
/>
|
||||
<ResultCard
|
||||
icon={Database}
|
||||
label="Mirror Storage"
|
||||
value={fmtGb(mirrorStorageGb)}
|
||||
sub="Full copy of selected VM disks"
|
||||
color="ok"
|
||||
/>
|
||||
<div className="card p-4 border-accent/20 bg-accent/5">
|
||||
<p className="section-title mb-1">Total DR Storage Footprint</p>
|
||||
<p className="font-data text-3xl font-semibold text-accent data-value">
|
||||
{fmtGb(totalDrStorageGb)}
|
||||
</p>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
Journal + Mirror across {selected.size} VM{selected.size !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
// src/pages/Settings/UserManagement.jsx
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Users, UserPlus, Search, Shield, ShieldOff, ShieldCheck, Pencil, Trash2, QrCode, X, Check, Loader2, AlertTriangle, Copy, ExternalLink } from 'lucide-react';
|
||||
import { usersApi } from '@/api/users';
|
||||
import clsx from 'clsx';
|
||||
|
||||
function Avatar({ name, size = 'md' }) {
|
||||
const initials = name ? name.split(' ').map((w) => w[0]).slice(0, 2).join('').toUpperCase() : '?';
|
||||
const colors = ['bg-accent/20 text-accent', 'bg-ok/20 text-ok', 'bg-info/20 text-info', 'bg-warn/20 text-warn'];
|
||||
const color = colors[(name?.charCodeAt(0) ?? 0) % colors.length];
|
||||
const sz = size === 'lg' ? 'w-10 h-10 text-sm' : 'w-8 h-8 text-xs';
|
||||
return <div className={clsx('rounded-full flex items-center justify-center font-mono font-semibold flex-shrink-0', sz, color)}>{initials}</div>;
|
||||
}
|
||||
|
||||
function Toast({ message, type = 'ok', onDismiss }) {
|
||||
useEffect(() => { const t = setTimeout(onDismiss, 3500); return () => clearTimeout(t); }, [onDismiss]);
|
||||
return (
|
||||
<div className={clsx('fixed bottom-6 right-6 z-[100] flex items-center gap-3 px-4 py-3 rounded-lg border shadow-panel animate-fade-in',
|
||||
type === 'ok' && 'bg-surface border-ok/30 text-ok', type === 'error' && 'bg-surface border-crit/30 text-crit')}>
|
||||
{type === 'ok' && <Check size={14} />}{type === 'error' && <AlertTriangle size={14} />}
|
||||
<span className="text-sm font-medium">{message}</span>
|
||||
<button onClick={onDismiss} className="ml-2 text-text-muted hover:text-text-primary"><X size={12} /></button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteModal({ user, onConfirm, onCancel, loading }) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="drawer-overlay" onClick={onCancel} />
|
||||
<div className="card-raised p-6 w-full max-w-sm z-10 animate-modal-in">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-full bg-crit/10 flex items-center justify-center"><Trash2 size={18} className="text-crit" /></div>
|
||||
<div><p className="font-medium text-text-primary">Delete user</p><p className="text-xs text-text-muted">This cannot be undone</p></div>
|
||||
</div>
|
||||
<p className="text-sm text-text-secondary mb-6">Delete <span className="text-text-primary font-medium">{user.name}</span> ({user.username})?</p>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button className="btn-ghost" onClick={onCancel} disabled={loading}>Cancel</button>
|
||||
<button className="btn-danger" onClick={onConfirm} disabled={loading}>
|
||||
{loading ? <Loader2 size={14} className="animate-spin" /> : <Trash2 size={14} />} Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TwoFactorModal({ user, onClose }) {
|
||||
const [state, setState] = useState('idle');
|
||||
const [result, setResult] = useState(null);
|
||||
const generate = useCallback(async () => {
|
||||
setState('loading');
|
||||
try { const data = await usersApi.setup2fa(user.id); setResult(data); setState('done'); }
|
||||
catch { setState('error'); }
|
||||
}, [user.id]);
|
||||
useEffect(() => { generate(); }, [generate]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="drawer-overlay" onClick={onClose} />
|
||||
<div className="card-raised p-6 w-full max-w-md z-10 animate-modal-in">
|
||||
<div className="flex items-start justify-between mb-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-accent/10 flex items-center justify-center"><QrCode size={18} className="text-accent" /></div>
|
||||
<div><p className="font-medium text-text-primary">Set up 2FA</p><p className="text-xs text-text-muted">{user.name}</p></div>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-text-muted hover:text-text-primary"><X size={16} /></button>
|
||||
</div>
|
||||
{state === 'loading' && <div className="flex flex-col items-center py-10 gap-3"><Loader2 size={28} className="animate-spin text-accent" /><p className="text-sm text-text-muted">Generating setup link…</p></div>}
|
||||
{state === 'error' && <div className="flex flex-col items-center py-8 gap-3 text-crit"><AlertTriangle size={28} /><p className="text-sm">Failed to generate setup link.</p><button className="btn-ghost mt-2" onClick={generate}>Retry</button></div>}
|
||||
{state === 'done' && result && (
|
||||
<>
|
||||
<div className="bg-canvas rounded-lg p-1 flex justify-center mb-4 border border-border">
|
||||
<img src={result.qrDataUrl} alt="2FA setup QR code" className="w-56 h-56 rounded" />
|
||||
</div>
|
||||
<p className="text-sm text-text-secondary mb-5">Share this QR code with {user.name} to enroll their authenticator app.</p>
|
||||
<button className="btn-ghost w-full" onClick={onClose}>Done</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserDrawer({ mode, user, groups, onSave, onClose }) {
|
||||
const isEdit = mode === 'edit';
|
||||
const [form, setForm] = useState(isEdit ? {
|
||||
username: user.username, name: user.name, email: user.email,
|
||||
isActive: user.isActive, groups: user.groups.map((g) => g.id), password: '',
|
||||
} : { username: '', name: '', email: '', isActive: true, groups: [], password: '' });
|
||||
const [errors, setErrors] = useState({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const set = (field) => (e) => setForm((f) => ({ ...f, [field]: e.target.type === 'checkbox' ? e.target.checked : e.target.value }));
|
||||
const toggleGroup = (id) => setForm((f) => ({ ...f, groups: f.groups.includes(id) ? f.groups.filter((g) => g !== id) : [...f.groups, id] }));
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const e = {};
|
||||
if (!form.username.trim()) e.username = 'Required';
|
||||
if (!form.name.trim()) e.name = 'Required';
|
||||
if (!form.email.trim()) e.email = 'Required';
|
||||
if (!isEdit && !form.password) e.password = 'Required';
|
||||
if (form.password && form.password.length < 8) e.password = 'Min 8 chars';
|
||||
setErrors(e);
|
||||
if (Object.keys(e).length > 0) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = { username: form.username, name: form.name, email: form.email, isActive: form.isActive, groups: form.groups };
|
||||
if (form.password) payload.password = form.password;
|
||||
await onSave(payload);
|
||||
} finally { setSaving(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="drawer-overlay" onClick={onClose} />
|
||||
<div className="drawer-panel">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
|
||||
<p className="font-medium text-text-primary">{isEdit ? 'Edit user' : 'Add user'}</p>
|
||||
<button onClick={onClose} className="text-text-muted hover:text-text-primary"><X size={18} /></button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-6 py-5 space-y-5">
|
||||
<section>
|
||||
<p className="section-title mb-4">Identity</p>
|
||||
<div className="space-y-4">
|
||||
<div><label className="field-label">Username</label><input className={clsx('field', errors.username && 'border-crit')} value={form.username} onChange={set('username')} disabled={isEdit} /></div>
|
||||
<div><label className="field-label">Full name</label><input className={clsx('field', errors.name && 'border-crit')} value={form.name} onChange={set('name')} /></div>
|
||||
<div><label className="field-label">Email</label><input className={clsx('field', errors.email && 'border-crit')} type="email" value={form.email} onChange={set('email')} /></div>
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<p className="section-title mb-3">Groups</p>
|
||||
<div className="space-y-2">
|
||||
{groups.map((g) => (
|
||||
<label key={g.id} className="flex items-center gap-3 cursor-pointer py-2 px-3 rounded-md hover:bg-canvas transition-colors">
|
||||
<input type="checkbox" className="sr-only" checked={form.groups.includes(g.id)} onChange={() => toggleGroup(g.id)} />
|
||||
<div className={clsx('w-4 h-4 rounded border flex items-center justify-center',
|
||||
form.groups.includes(g.id) ? 'bg-accent border-accent' : 'border-border')}>
|
||||
{form.groups.includes(g.id) && <Check size={10} className="text-white" />}
|
||||
</div>
|
||||
<span className="text-sm text-text-primary">{g.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<p className="section-title mb-3">{isEdit ? 'Reset Password' : 'Password'}</p>
|
||||
<input className={clsx('field', errors.password && 'border-crit')} type="password" value={form.password} onChange={set('password')}
|
||||
placeholder={isEdit ? 'Leave blank to keep' : 'Min. 8 characters'} />
|
||||
{errors.password && <p className="text-xs text-crit mt-1">{errors.password}</p>}
|
||||
</section>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 px-6 py-4 border-t border-border flex-shrink-0">
|
||||
<button className="btn-ghost" onClick={onClose} disabled={saving}>Cancel</button>
|
||||
<button className="btn-primary" onClick={handleSubmit} disabled={saving}>
|
||||
{saving ? <Loader2 size={14} className="animate-spin" /> : isEdit ? <Check size={14} /> : <UserPlus size={14} />}
|
||||
{isEdit ? 'Save' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function UserManagement() {
|
||||
const [users, setUsers] = useState([]);
|
||||
const [groups, setGroups] = useState([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [drawer, setDrawer] = useState(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||
const [twoFaTarget, setTwoFaTarget] = useState(null);
|
||||
const [toast, setToast] = useState(null);
|
||||
const searchTimer = useRef(null);
|
||||
|
||||
const showToast = (message, type = 'ok') => setToast({ message, type });
|
||||
|
||||
const loadUsers = useCallback(async (q = '') => {
|
||||
setLoading(true);
|
||||
try { const result = await usersApi.list({ search: q }); setUsers(result.users); setTotal(result.count); }
|
||||
catch (err) { showToast(`Failed to load users: ${err.message}`, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
const loadGroups = useCallback(async () => {
|
||||
try { const g = await usersApi.listGroups(); setGroups(g); } catch {}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadUsers(); loadGroups(); }, []);
|
||||
|
||||
const handleSearchChange = (e) => {
|
||||
const val = e.target.value;
|
||||
setSearchInput(val);
|
||||
clearTimeout(searchTimer.current);
|
||||
searchTimer.current = setTimeout(() => loadUsers(val), 350);
|
||||
};
|
||||
|
||||
const handleSave = async (payload) => {
|
||||
try {
|
||||
if (drawer.mode === 'create') {
|
||||
const newUser = await usersApi.create(payload);
|
||||
setUsers((u) => [newUser, ...u]); setTotal((t) => t + 1);
|
||||
showToast(`User ${newUser.username} created`);
|
||||
} else {
|
||||
const updated = await usersApi.update(drawer.user.id, payload);
|
||||
setUsers((u) => u.map((x) => (x.id === updated.id ? updated : x)));
|
||||
showToast(`User ${updated.username} updated`);
|
||||
}
|
||||
setDrawer(null);
|
||||
} catch (err) { showToast(err.message, 'error'); throw err; }
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await usersApi.delete(deleteTarget.id);
|
||||
setUsers((u) => u.filter((x) => x.id !== deleteTarget.id)); setTotal((t) => t - 1);
|
||||
showToast(`User ${deleteTarget.username} deleted`); setDeleteTarget(null);
|
||||
} catch (err) { showToast(err.message, 'error'); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full animate-fade-in">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="font-mono text-lg font-semibold text-text-primary flex items-center gap-2">
|
||||
<Users size={20} className="text-accent" /> User Management
|
||||
</h1>
|
||||
<p className="text-xs text-text-muted mt-1">{total} users</p>
|
||||
</div>
|
||||
<button className="btn-primary" onClick={() => setDrawer({ mode: 'create' })}><UserPlus size={15} /> Add User</button>
|
||||
</div>
|
||||
|
||||
<div className="relative mb-4">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted pointer-events-none" />
|
||||
<input className="field pl-9" placeholder="Search…" value={searchInput} onChange={handleSearchChange} />
|
||||
</div>
|
||||
|
||||
<div className="card flex-1 overflow-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="border-b border-border">
|
||||
<th className="px-4 py-3 text-left section-title">User</th>
|
||||
<th className="px-4 py-3 text-left section-title hidden md:table-cell">Groups</th>
|
||||
<th className="px-4 py-3 text-left section-title">Status</th>
|
||||
<th className="px-4 py-3 text-left section-title hidden lg:table-cell">2FA</th>
|
||||
<th className="px-4 py-3 text-right section-title">Actions</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{loading && <tr><td colSpan={5} className="px-4 py-16 text-center"><Loader2 size={20} className="animate-spin text-text-muted mx-auto" /></td></tr>}
|
||||
{!loading && users.length === 0 && <tr><td colSpan={5} className="px-4 py-16 text-center text-text-muted">No users found</td></tr>}
|
||||
{!loading && users.map((u) => (
|
||||
<tr key={u.id} className="table-row-hover border-b border-border/50 last:border-0">
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar name={u.name} />
|
||||
<div><p className="font-medium text-text-primary">{u.name}</p><p className="text-xs text-text-muted font-mono">{u.username}</p></div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 hidden md:table-cell">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{u.groups.length === 0 ? <span className="text-xs text-text-muted">—</span> : u.groups.map((g) => (
|
||||
<span key={g.id} className={clsx('badge', g.name.includes('admin') ? 'badge-info' : 'badge-muted')}>{g.name}</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{u.isActive ? <span className="badge badge-ok">Active</span> : <span className="badge badge-muted">Inactive</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 hidden lg:table-cell">
|
||||
{u.totpEnrolled ? <span className="badge badge-ok"><ShieldCheck size={10} />2FA On</span> : <span className="badge badge-warn"><ShieldOff size={10} />No 2FA</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button title="Edit" onClick={() => setDrawer({ mode: 'edit', user: u })} className="p-1.5 rounded text-text-muted hover:text-accent hover:bg-accent/10 transition-colors"><Pencil size={13} /></button>
|
||||
<button title="2FA" onClick={() => setTwoFaTarget(u)} className="p-1.5 rounded text-text-muted hover:text-accent hover:bg-accent/10 transition-colors"><Shield size={13} /></button>
|
||||
<button title="Delete" onClick={() => setDeleteTarget(u)} className="p-1.5 rounded text-text-muted hover:text-crit hover:bg-crit/10 transition-colors"><Trash2 size={13} /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{drawer && <UserDrawer mode={drawer.mode} user={drawer.user} groups={groups} onSave={handleSave} onClose={() => setDrawer(null)} />}
|
||||
{deleteTarget && <DeleteModal user={deleteTarget} onConfirm={handleDelete} onCancel={() => setDeleteTarget(null)} />}
|
||||
{twoFaTarget && <TwoFactorModal user={twoFaTarget} onClose={() => { setTwoFaTarget(null); loadUsers(); }} />}
|
||||
{toast && <Toast message={toast.message} type={toast.type} onDismiss={() => setToast(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
// src/pages/Storage.jsx
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Database, HardDrive, Loader2 } from 'lucide-react';
|
||||
import { queryDatastores } from '@/api/prometheusExtended';
|
||||
import { formatBytes } from '@/constants/statusMaps';
|
||||
import clsx from 'clsx';
|
||||
|
||||
const REFRESH = 60_000;
|
||||
|
||||
function CapacityBar({ label, usedBytes, totalBytes, color = 'bg-accent' }) {
|
||||
const pct = totalBytes > 0 ? Math.min(usedBytes / totalBytes, 1) : 0;
|
||||
const pctN = Math.round(pct * 100);
|
||||
const barColor = pct >= 0.9 ? 'bg-crit' : pct >= 0.75 ? 'bg-warn' : color;
|
||||
const textC = pct >= 0.9 ? 'text-crit' : pct >= 0.75 ? 'text-warn' : 'text-text-secondary';
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-[10px]">
|
||||
<span className="text-text-muted">{label}</span>
|
||||
<span className={clsx('font-mono data-value', textC)}>
|
||||
{formatBytes(usedBytes)} / {formatBytes(totalBytes)} ({pctN}%)
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-border rounded-full overflow-hidden">
|
||||
<div className={clsx('h-full rounded-full transition-all duration-500', barColor)} style={{ width: `${pct * 100}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ZertoUsageRow({ label, bytes, color }) {
|
||||
return bytes > 0 ? (
|
||||
<div className="flex items-center justify-between text-[10px]">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={clsx('w-2 h-2 rounded-sm flex-shrink-0', color)} />
|
||||
<span className="text-text-muted">{label}</span>
|
||||
</div>
|
||||
<span className="font-mono data-value text-text-secondary">{formatBytes(bytes)}</span>
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
|
||||
function DatastoreCard({ ds }) {
|
||||
const usePct = ds.capacityBytes > 0 ? ds.usedBytes / ds.capacityBytes : 0;
|
||||
const alerting = usePct >= 0.9;
|
||||
const warning = usePct >= 0.75;
|
||||
const zertoUsed = (ds.journalBytes ?? 0) + (ds.scratchBytes ?? 0) + (ds.recoveryBytes ?? 0) + (ds.applianceBytes ?? 0);
|
||||
|
||||
return (
|
||||
<div className={clsx('card p-4 space-y-4 transition-colors duration-300',
|
||||
alerting ? 'border-crit/30' : warning ? 'border-warn/20' : '')}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={clsx('w-8 h-8 rounded-md flex items-center justify-center',
|
||||
alerting ? 'bg-crit/10' : warning ? 'bg-warn/10' : 'bg-accent/10')}>
|
||||
<Database size={14} className={alerting ? 'text-crit' : warning ? 'text-warn' : 'text-accent'} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-mono font-semibold text-text-primary truncate max-w-[180px]">{ds.name}</p>
|
||||
<p className="text-[10px] text-text-muted">{ds.siteName}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right text-[10px] text-text-muted font-mono">
|
||||
<p>{ds.vraCount ?? 0} VRA{(ds.vraCount ?? 0) !== 1 ? 's' : ''}</p>
|
||||
<p>{ds.incomingVms ?? 0} in / {ds.outgoingVms ?? 0} out</p>
|
||||
</div>
|
||||
</div>
|
||||
<CapacityBar label="Capacity" usedBytes={ds.usedBytes} totalBytes={ds.capacityBytes} />
|
||||
{zertoUsed > 0 && (
|
||||
<div className="space-y-1.5 pt-2 border-t border-border">
|
||||
<p className="section-title mb-2">Zerto Usage ({formatBytes(zertoUsed)})</p>
|
||||
<ZertoUsageRow label="Journal" bytes={ds.journalBytes} color="bg-accent" />
|
||||
<ZertoUsageRow label="Scratch" bytes={ds.scratchBytes} color="bg-info" />
|
||||
<ZertoUsageRow label="Recovery" bytes={ds.recoveryBytes} color="bg-ok" />
|
||||
<ZertoUsageRow label="Appliances" bytes={ds.applianceBytes} color="bg-text-muted" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between text-[10px] text-text-muted pt-1 border-t border-border">
|
||||
<span>Free</span>
|
||||
<span className="font-mono data-value text-text-secondary">{formatBytes(ds.freeBytes)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Storage() {
|
||||
const { data: datastores = [], isLoading } = useQuery({
|
||||
queryKey: ['datastores'], queryFn: queryDatastores, refetchInterval: REFRESH,
|
||||
});
|
||||
|
||||
const totalCapacity = datastores.reduce((s, d) => s + (d.capacityBytes ?? 0), 0);
|
||||
const totalUsed = datastores.reduce((s, d) => s + (d.usedBytes ?? 0), 0);
|
||||
const totalJournal = datastores.reduce((s, d) => s + (d.journalBytes ?? 0), 0);
|
||||
|
||||
const bySite = {};
|
||||
for (const d of datastores) {
|
||||
const s = d.siteName || 'Unknown';
|
||||
if (!bySite[s]) bySite[s] = [];
|
||||
bySite[s].push(d);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: 'Datastores', value: datastores.length, icon: Database },
|
||||
{ label: 'Total Capacity', value: formatBytes(totalCapacity), icon: HardDrive },
|
||||
{ label: 'Used', value: formatBytes(totalUsed), icon: HardDrive },
|
||||
{ label: 'Journal Usage', value: formatBytes(totalJournal), icon: Database },
|
||||
].map((s) => (
|
||||
<div key={s.label} className="card p-4 flex items-start gap-3">
|
||||
<div className="w-9 h-9 rounded-lg bg-accent/10 flex items-center justify-center">
|
||||
<s.icon size={16} className="text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="section-title">{s.label}</p>
|
||||
<p className="font-mono text-lg font-semibold text-text-primary mt-0.5 data-value">{s.value}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{totalCapacity > 0 && (
|
||||
<div className="card p-4">
|
||||
<CapacityBar label="Aggregate Capacity (all datastores)" usedBytes={totalUsed} totalBytes={totalCapacity} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex justify-center py-16">
|
||||
<Loader2 size={24} className="animate-spin text-text-muted" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{Object.entries(bySite).map(([site, siteDs]) => (
|
||||
<section key={site}>
|
||||
<p className="section-title mb-3">{site} · {siteDs.length} datastore{siteDs.length !== 1 ? 's' : ''}</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{siteDs.map((ds) => <DatastoreCard key={ds.id || ds.name} ds={ds} />)}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
{!isLoading && datastores.length === 0 && (
|
||||
<div className="card p-12 text-center">
|
||||
<Database size={28} className="text-text-muted mx-auto mb-3 opacity-40" />
|
||||
<p className="text-text-muted text-sm">No datastore data available</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
// src/pages/VMDetail.jsx
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Search, Server, X, Activity, Loader2 } from 'lucide-react';
|
||||
import { queryAllVms } from '@/api/prometheusExtended';
|
||||
import TimeSeriesChart from '@/components/charts/TimeSeriesChart';
|
||||
import RPOGauge from '@/components/charts/RPOGauge';
|
||||
import { VM_STATUS, formatRpo, formatMB } from '@/constants/statusMaps';
|
||||
import clsx from 'clsx';
|
||||
|
||||
const REFRESH = 30_000;
|
||||
|
||||
function JournalGauge({ usedMb, hardLimitMb }) {
|
||||
if (!hardLimitMb || hardLimitMb <= 0) return <span className="text-xs text-text-muted">—</span>;
|
||||
const pct = Math.min(usedMb / hardLimitMb, 1);
|
||||
const color = pct > 0.85 ? 'bg-crit' : pct > 0.65 ? 'bg-warn' : 'bg-ok';
|
||||
return (
|
||||
<div className="flex items-center gap-2 min-w-[100px]">
|
||||
<div className="flex-1 h-1.5 bg-border rounded-full overflow-hidden">
|
||||
<div className={clsx('h-full rounded-full', color)} style={{ width: `${pct * 100}%` }} />
|
||||
</div>
|
||||
<span className="text-[10px] font-mono data-value text-text-muted whitespace-nowrap">{formatMB(usedMb)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VmDrawer({ vm, onClose }) {
|
||||
const esc = vm.name.replace(/"/g, '\\"');
|
||||
return (
|
||||
<>
|
||||
<div className="drawer-overlay" onClick={onClose} />
|
||||
<div className="drawer-panel">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-accent/10 flex items-center justify-center">
|
||||
<Server size={18} className="text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-mono text-sm font-semibold text-text-primary">{vm.name}</p>
|
||||
<p className="text-xs text-text-muted">{vm.vpgName} · {vm.siteName}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1.5 rounded text-text-muted hover:text-text-primary hover:bg-raised transition-colors"><X size={16} /></button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-6 py-5 space-y-5">
|
||||
<div className="card p-4 flex items-start gap-6">
|
||||
<RPOGauge actualSec={vm.actualRpoSec} size={120} label="Current RPO" />
|
||||
<div className="flex-1 space-y-2 pt-2">
|
||||
{[
|
||||
{ label: 'Throughput', value: `${(vm.throughputMb ?? 0).toFixed(2)} MB/s` },
|
||||
{ label: 'IOPS', value: Math.round(vm.iops ?? 0).toLocaleString() },
|
||||
{ label: 'Bandwidth', value: `${(vm.bandwidthMbps ?? 0).toFixed(2)} Mbps` },
|
||||
{ label: 'Journal', value: formatMB(vm.journalUsedMb) },
|
||||
{ label: 'Encryption', value: vm.pctEncrypted != null ? `${vm.pctEncrypted.toFixed(1)}%` : '—' },
|
||||
].map(({ label, value }) => (
|
||||
<div key={label} className="flex justify-between text-xs">
|
||||
<span className="text-text-muted">{label}</span>
|
||||
<span className="font-mono data-value text-text-primary">{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<TimeSeriesChart title="RPO History" promql={`vm_actualrpo{VmName="${esc}"}`}
|
||||
yFormatter={formatRpo}
|
||||
transform={(result) => result[0]?.values.map(([ts, v]) => ({ ts: ts * 1000, 'RPO': parseFloat(v) })) ?? []}
|
||||
height={170} />
|
||||
<TimeSeriesChart title="Throughput" promql={`vm_throughput_in_mb{VmName="${esc}"}`}
|
||||
yFormatter={(v) => `${v.toFixed(1)} MB/s`}
|
||||
transform={(result) => result[0]?.values.map(([ts, v]) => ({ ts: ts * 1000, 'MB/s': parseFloat(v) })) ?? []}
|
||||
height={150} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function VmStatusBadge({ code }) {
|
||||
const s = VM_STATUS[code] ?? { label: 'Unknown', color: 'muted' };
|
||||
return <span className={clsx('badge', `badge-${s.color === 'muted' ? 'muted' : s.color}`)}>{s.label}</span>;
|
||||
}
|
||||
|
||||
export default function VMDetail() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [sort, setSort] = useState('rpo-desc');
|
||||
const [selected, setSelected] = useState(null);
|
||||
|
||||
const { data: vms = [], isLoading } = useQuery({
|
||||
queryKey: ['all-vms'], queryFn: queryAllVms, refetchInterval: REFRESH,
|
||||
});
|
||||
|
||||
const filtered = vms.filter((v) => {
|
||||
const q = search.toLowerCase();
|
||||
return !q || v.name?.toLowerCase().includes(q) || v.vpgName?.toLowerCase().includes(q);
|
||||
});
|
||||
|
||||
const sorted = [...filtered].sort((a, b) => {
|
||||
switch (sort) {
|
||||
case 'rpo-desc': return (b.actualRpoSec ?? 0) - (a.actualRpoSec ?? 0);
|
||||
case 'rpo-asc': return (a.actualRpoSec ?? 0) - (b.actualRpoSec ?? 0);
|
||||
case 'name-asc': return (a.name ?? '').localeCompare(b.name ?? '');
|
||||
default: return 0;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full space-y-4 animate-fade-in">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: 'Total VMs', value: vms.length, color: 'accent' },
|
||||
{ label: 'RPO OK', value: vms.filter((v) => (v.actualRpoSec ?? 0) <= 300).length, color: 'ok' },
|
||||
{ label: 'RPO Warning', value: vms.filter((v) => (v.actualRpoSec ?? 0) > 300 && (v.actualRpoSec ?? 0) <= 600).length, color: 'warn' },
|
||||
{ label: 'RPO Critical', value: vms.filter((v) => (v.actualRpoSec ?? 0) > 600).length, color: 'crit' },
|
||||
].map(({ label, value, color }) => (
|
||||
<div key={label} className="card p-4 flex items-start gap-3">
|
||||
<div className={clsx('w-9 h-9 rounded-lg flex items-center justify-center flex-shrink-0', `bg-${color}/10`)}>
|
||||
<Activity size={16} className={`text-${color}`} />
|
||||
</div>
|
||||
<div><p className="section-title">{label}</p><p className="font-data text-2xl font-semibold text-text-primary data-value">{value}</p></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="relative flex-1 min-w-[200px]">
|
||||
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-text-muted pointer-events-none" />
|
||||
<input className="field pl-8 text-sm" placeholder="Search VMs or VPGs…" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
</div>
|
||||
<select className="field w-auto text-sm" value={sort} onChange={(e) => setSort(e.target.value)}>
|
||||
<option value="rpo-desc">RPO (worst first)</option>
|
||||
<option value="rpo-asc">RPO (best first)</option>
|
||||
<option value="name-asc">Name A-Z</option>
|
||||
</select>
|
||||
<span className="text-xs text-text-muted">{sorted.length} / {vms.length} VMs</span>
|
||||
</div>
|
||||
|
||||
<div className="card flex-1 overflow-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="border-b border-border">
|
||||
<th className="px-4 py-3 text-left section-title">VM Name</th>
|
||||
<th className="px-4 py-3 text-left section-title hidden md:table-cell">VPG</th>
|
||||
<th className="px-4 py-3 text-right section-title">RPO</th>
|
||||
<th className="px-4 py-3 text-left section-title hidden md:table-cell">Journal</th>
|
||||
<th className="px-4 py-3 text-right section-title hidden lg:table-cell">Throughput</th>
|
||||
<th className="px-4 py-3 text-left section-title hidden xl:table-cell">Status</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{isLoading && <tr><td colSpan={6} className="py-16 text-center"><Loader2 size={20} className="animate-spin text-text-muted mx-auto" /></td></tr>}
|
||||
{!isLoading && sorted.length === 0 && <tr><td colSpan={6} className="py-16 text-center text-text-muted">No VMs</td></tr>}
|
||||
{!isLoading && sorted.map((vm) => {
|
||||
const rpoColor = vm.actualRpoSec > 600 ? 'text-crit' : vm.actualRpoSec > 300 ? 'text-warn' : 'text-ok';
|
||||
return (
|
||||
<tr key={vm.id} onClick={() => setSelected(vm)} className="table-row-hover border-b border-border/40 last:border-0">
|
||||
<td className="px-4 py-3"><span className="font-medium text-text-primary truncate">{vm.name}</span></td>
|
||||
<td className="px-4 py-3 hidden md:table-cell"><span className="text-text-muted text-xs">{vm.vpgName}</span></td>
|
||||
<td className="px-4 py-3 text-right"><span className={clsx('font-mono font-semibold text-xs data-value', rpoColor)}>{formatRpo(vm.actualRpoSec)}</span></td>
|
||||
<td className="px-4 py-3 hidden md:table-cell"><JournalGauge usedMb={vm.journalUsedMb} hardLimitMb={vm.journalHardLimit} /></td>
|
||||
<td className="px-4 py-3 text-right hidden lg:table-cell"><span className="font-mono text-xs data-value text-text-secondary">{(vm.throughputMb ?? 0).toFixed(2)} MB/s</span></td>
|
||||
<td className="px-4 py-3 hidden xl:table-cell"><VmStatusBadge code={vm.status} /></td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{selected && <VmDrawer vm={selected} onClose={() => setSelected(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
// src/pages/VPGMonitor.jsx
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Search, ChevronRight, Loader2 } from 'lucide-react';
|
||||
import { queryAllVpgs } from '@/api/prometheus';
|
||||
import { queryVpgDetail, queryVpgVms } from '@/api/prometheusExtended';
|
||||
import TimeSeriesChart from '@/components/charts/TimeSeriesChart';
|
||||
import RPOGauge from '@/components/charts/RPOGauge';
|
||||
import { VPG_ALERT, vpgHealth, rpoStatus, formatRpo, formatMB, colorToText } from '@/constants/statusMaps';
|
||||
import clsx from 'clsx';
|
||||
|
||||
const REFRESH = 30_000;
|
||||
|
||||
function VpgListItem({ vpg, selected, onClick }) {
|
||||
const status = rpoStatus(vpg.actualRpoSec, vpg.configuredRpoSec);
|
||||
return (
|
||||
<button onClick={onClick} className={clsx('w-full text-left px-3 py-2.5 rounded-md transition-all duration-150 group',
|
||||
selected ? 'bg-accent/15 border border-accent/25' : 'hover:bg-raised border border-transparent')}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={clsx('status-dot flex-shrink-0',
|
||||
status === 'ok' ? 'status-dot-ok' : status === 'warn' ? 'status-dot-warn' : status === 'crit' ? 'status-dot-crit' : 'status-dot-idle')} />
|
||||
<span className={clsx('text-sm font-medium truncate flex-1', selected ? 'text-accent' : 'text-text-primary')}>{vpg.name}</span>
|
||||
{selected && <ChevronRight size={12} className="text-accent flex-shrink-0" />}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-0.5 pl-4">
|
||||
<span className="text-[10px] text-text-muted">{vpg.siteName}</span>
|
||||
<span className={clsx('text-[10px] font-mono data-value', colorToText[status])}>{formatRpo(vpg.actualRpoSec)}</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function VpgDetail({ vpgName }) {
|
||||
const { data: detail, isLoading } = useQuery({
|
||||
queryKey: ['vpg-detail', vpgName], queryFn: () => queryVpgDetail(vpgName), refetchInterval: REFRESH, enabled: !!vpgName,
|
||||
});
|
||||
const { data: vms = [], isLoading: vmsLoading } = useQuery({
|
||||
queryKey: ['vpg-vms', vpgName], queryFn: () => queryVpgVms(vpgName), refetchInterval: REFRESH, enabled: !!vpgName,
|
||||
});
|
||||
|
||||
if (isLoading) return <div className="flex-1 flex items-center justify-center"><Loader2 size={24} className="animate-spin text-text-muted" /></div>;
|
||||
if (!detail) return null;
|
||||
|
||||
const alertInfo = VPG_ALERT[detail.alertStatus] ?? VPG_ALERT[0];
|
||||
const esc = vpgName.replace(/"/g, '\\"');
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-w-0 overflow-y-auto p-4 space-y-4 animate-fade-in">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className={clsx('status-dot', alertInfo.color === 'ok' ? 'status-dot-ok' : alertInfo.color === 'warn' ? 'status-dot-warn' : 'status-dot-crit')} />
|
||||
<h2 className="font-mono text-base font-semibold text-text-primary">{vpgName}</h2>
|
||||
<span className={clsx('badge', `badge-${alertInfo.color}`)}>{alertInfo.label}</span>
|
||||
<span className="text-xs text-text-muted">{detail.siteName} · {detail.vmCount} VMs</span>
|
||||
</div>
|
||||
|
||||
<div className="card overflow-hidden flex flex-wrap">
|
||||
<div className="flex items-center justify-center p-4 border-r border-border">
|
||||
<RPOGauge actualSec={detail.actualRpoSec} configuredSec={detail.configuredRpoSec} size={150} />
|
||||
</div>
|
||||
<div className="flex flex-wrap flex-1">
|
||||
{[
|
||||
{ label: 'Throughput', value: `${(detail.throughputMb ?? 0).toFixed(2)} MB/s` },
|
||||
{ label: 'IOPS', value: Math.round(detail.iops ?? 0) },
|
||||
{ label: 'Storage', value: formatMB(detail.storageUsedMb) },
|
||||
].map((s) => (
|
||||
<div key={s.label} className="text-center px-4 py-3 border-r border-border last:border-0">
|
||||
<p className="text-lg font-semibold font-data data-value text-text-primary">{s.value}</p>
|
||||
<p className="section-title mt-0.5">{s.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TimeSeriesChart title="RPO Over Time" promql={`vpg_actual_rpo{VpgName="${esc}"}`}
|
||||
yFormatter={(v) => formatRpo(v)}
|
||||
refLines={detail.configuredRpoSec ? [{ value: detail.configuredRpoSec, label: 'Target', color: 'warn' }] : []}
|
||||
transform={(result) => result[0]?.values.map(([ts, v]) => ({ ts: ts * 1000, 'RPO (s)': parseFloat(v) })) ?? []}
|
||||
height={180} />
|
||||
|
||||
<div className="card overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-border"><p className="section-title">Protected VMs</p></div>
|
||||
<table className="w-full text-xs">
|
||||
<thead><tr className="border-b border-border/60">
|
||||
<th className="px-4 py-2 text-left section-title">VM Name</th>
|
||||
<th className="px-4 py-2 text-right section-title">RPO</th>
|
||||
<th className="px-4 py-2 text-right section-title hidden sm:table-cell">Throughput</th>
|
||||
<th className="px-4 py-2 text-right section-title hidden md:table-cell">IOPS</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{vmsLoading && <tr><td colSpan={4} className="py-8 text-center"><Loader2 size={16} className="animate-spin text-text-muted mx-auto" /></td></tr>}
|
||||
{!vmsLoading && vms.map((vm) => (
|
||||
<tr key={vm.id} className="border-b border-border/40 last:border-0 hover:bg-raised transition-colors">
|
||||
<td className="px-4 py-2 font-medium text-text-primary">{vm.name}</td>
|
||||
<td className="px-4 py-2 text-right font-mono data-value">{formatRpo(vm.actualRpoSec)}</td>
|
||||
<td className="px-4 py-2 text-right text-text-secondary font-mono data-value hidden sm:table-cell">{vm.throughputMb?.toFixed(2)} MB/s</td>
|
||||
<td className="px-4 py-2 text-right text-text-secondary font-mono data-value hidden md:table-cell">{Math.round(vm.iops ?? 0)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function VPGMonitor() {
|
||||
const [params] = useSearchParams();
|
||||
const [search, setSearch] = useState('');
|
||||
const initialName = params.get('name');
|
||||
|
||||
const { data: vpgs = [], isLoading } = useQuery({
|
||||
queryKey: ['all-vpgs'], queryFn: queryAllVpgs, refetchInterval: REFRESH,
|
||||
});
|
||||
|
||||
const [selected, setSelected] = useState(initialName || null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected && vpgs.length > 0) setSelected(vpgs[0].name);
|
||||
}, [vpgs, selected]);
|
||||
|
||||
const filtered = vpgs.filter((v) => !search || v.name.toLowerCase().includes(search.toLowerCase()));
|
||||
|
||||
const bySite = {};
|
||||
for (const v of filtered) { const s = v.siteName || 'Unknown'; if (!bySite[s]) bySite[s] = []; bySite[s].push(v); }
|
||||
|
||||
return (
|
||||
<div className="flex h-full -m-6 overflow-hidden">
|
||||
<aside className="w-64 flex-shrink-0 border-r border-border flex flex-col bg-surface overflow-hidden">
|
||||
<div className="p-3 border-b border-border flex-shrink-0">
|
||||
<div className="relative">
|
||||
<Search size={12} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-text-muted pointer-events-none" />
|
||||
<input className="field pl-8 text-xs py-1.5" placeholder="Filter VPGs…" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
</div>
|
||||
<p className="text-[10px] text-text-muted mt-2 font-mono">{filtered.length} VPGs</p>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{isLoading && <div className="flex justify-center py-8"><Loader2 size={16} className="animate-spin text-text-muted" /></div>}
|
||||
{Object.entries(bySite).map(([site, siteVpgs]) => (
|
||||
<div key={site} className="mb-3">
|
||||
<p className="section-title px-2 mb-1">{site}</p>
|
||||
{siteVpgs.map((v) => <VpgListItem key={v.id} vpg={v} selected={selected === v.name} onClick={() => setSelected(v.name)} />)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{selected ? <VpgDetail vpgName={selected} /> : (
|
||||
<div className="flex-1 flex items-center justify-center text-text-muted text-sm">Select a VPG to view details</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
// src/pages/VRADashboard.jsx
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Cpu, Server, HardDrive, Layers, Loader2 } from 'lucide-react';
|
||||
import { queryAllVras } from '@/api/prometheusExtended';
|
||||
import clsx from 'clsx';
|
||||
|
||||
const REFRESH = 30_000;
|
||||
const VRA_MAX_VMS = 100;
|
||||
const VRA_MAX_VOL = 2048;
|
||||
|
||||
function UsageBar({ label, used, total, max, unit = '', warnAt = 0.75, critAt = 0.9 }) {
|
||||
const pct = total > 0 ? Math.min(used / total, 1) : max > 0 ? Math.min(used / max, 1) : 0;
|
||||
const color = pct >= critAt ? 'bg-crit' : pct >= warnAt ? 'bg-warn' : 'bg-ok';
|
||||
const textC = pct >= critAt ? 'text-crit' : pct >= warnAt ? 'text-warn' : 'text-ok';
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between items-center text-[10px]">
|
||||
<span className="text-text-muted">{label}</span>
|
||||
<span className={clsx('font-mono data-value', textC)}>
|
||||
{typeof used === 'number' ? `${Math.round(used)}${unit}` : '—'}
|
||||
{total > 0 ? ` / ${Math.round(total)}${unit}` : max ? ` / ${max}${unit}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1 bg-border rounded-full overflow-hidden">
|
||||
<div className={clsx('h-full rounded-full transition-all duration-500', color)} style={{ width: `${pct * 100}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkloadBadge({ label, value, icon: Icon, color = 'text-text-secondary' }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center p-2 bg-canvas rounded-md border border-border min-w-0">
|
||||
<Icon size={12} className={clsx('mb-1', color)} />
|
||||
<span className={clsx('font-data text-base font-semibold data-value', color)}>{value ?? '—'}</span>
|
||||
<span className="section-title mt-0.5 text-center leading-tight">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VraCard({ vra }) {
|
||||
const protPct = vra.protectedVms / VRA_MAX_VMS;
|
||||
const recPct = vra.recoveryVms / VRA_MAX_VMS;
|
||||
const alerting = protPct >= 0.9 || recPct >= 0.9;
|
||||
const warning = protPct >= 0.75 || recPct >= 0.75;
|
||||
|
||||
return (
|
||||
<div className={clsx('card p-4 flex flex-col gap-4 transition-colors duration-300',
|
||||
alerting ? 'border-crit/30' : warning ? 'border-warn/30' : '')}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={clsx('w-8 h-8 rounded-md flex items-center justify-center',
|
||||
alerting ? 'bg-crit/10' : warning ? 'bg-warn/10' : 'bg-accent/10')}>
|
||||
<Server size={14} className={alerting ? 'text-crit' : warning ? 'text-warn' : 'text-accent'} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-mono font-semibold text-text-primary truncate max-w-[140px]">{vra.name}</p>
|
||||
<p className="text-[10px] text-text-muted">{vra.siteName}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-[10px] text-text-muted font-mono">{vra.vcpuCount} vCPU</p>
|
||||
<p className="text-[10px] text-text-muted font-mono">{vra.memoryGb?.toFixed(0)} GB RAM</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(vra.cpuUsageMhz !== undefined || vra.memUsageMb !== undefined) && (
|
||||
<div className="space-y-2">
|
||||
{vra.cpuUsageMhz !== undefined && (
|
||||
<UsageBar label="CPU" used={vra.cpuUsageMhz} unit=" MHz" max={vra.vcpuCount * 2600} warnAt={0.7} critAt={0.9} />
|
||||
)}
|
||||
{vra.memUsageMb !== undefined && (
|
||||
<UsageBar label="Memory" used={vra.memUsageMb} total={(vra.memoryGb ?? 0) * 1024} unit=" MB" warnAt={0.8} critAt={0.92} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<p className="section-title mb-2">Workload</p>
|
||||
<div className="grid grid-cols-3 gap-1.5 mb-2">
|
||||
<WorkloadBadge label="Prot VMs" value={vra.protectedVms} icon={Server}
|
||||
color={protPct >= 0.9 ? 'text-crit' : protPct >= 0.75 ? 'text-warn' : 'text-ok'} />
|
||||
<WorkloadBadge label="Rec VMs" value={vra.recoveryVms} icon={Server}
|
||||
color={recPct >= 0.9 ? 'text-crit' : recPct >= 0.75 ? 'text-warn' : 'text-accent'} />
|
||||
<WorkloadBadge label="Self VPGs" value={vra.selfProtectedVpgs} icon={Layers} color="text-text-secondary" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
<WorkloadBadge label="Prot Vols" value={vra.protectedVolumes} icon={HardDrive}
|
||||
color={vra.protectedVolumes / VRA_MAX_VOL >= 0.85 ? 'text-crit' : vra.protectedVolumes / VRA_MAX_VOL >= 0.7 ? 'text-warn' : 'text-text-secondary'} />
|
||||
<WorkloadBadge label="Rec Vols" value={vra.recoveryVolumes} icon={HardDrive}
|
||||
color={vra.recoveryVolumes / VRA_MAX_VOL >= 0.85 ? 'text-crit' : vra.recoveryVolumes / VRA_MAX_VOL >= 0.7 ? 'text-warn' : 'text-text-secondary'} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-[10px] text-text-muted font-mono pt-3 border-t border-border">
|
||||
<span>VRA {vra.version ?? '—'}</span>
|
||||
<span>ESXi {vra.hostVersion ?? '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function VRADashboard() {
|
||||
const { data: vras = [], isLoading } = useQuery({
|
||||
queryKey: ['all-vras'], queryFn: queryAllVras, refetchInterval: REFRESH,
|
||||
});
|
||||
|
||||
const bySite = {};
|
||||
for (const v of vras) {
|
||||
const s = v.siteName || 'Unknown';
|
||||
if (!bySite[s]) bySite[s] = [];
|
||||
bySite[s].push(v);
|
||||
}
|
||||
|
||||
const totalProt = vras.reduce((s, v) => s + (v.protectedVms ?? 0), 0);
|
||||
const totalRec = vras.reduce((s, v) => s + (v.recoveryVms ?? 0), 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{[
|
||||
{ label: 'Total VRAs', value: vras.length },
|
||||
{ label: 'Protected VMs', value: totalProt },
|
||||
{ label: 'Recovery VMs', value: totalRec },
|
||||
].map((s) => (
|
||||
<div key={s.label} className="card p-4 text-center">
|
||||
<p className="font-data text-2xl font-semibold text-text-primary data-value">{s.value}</p>
|
||||
<p className="section-title mt-1">{s.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex justify-center py-16"><Loader2 size={24} className="animate-spin text-text-muted" /></div>
|
||||
)}
|
||||
|
||||
{Object.entries(bySite).map(([site, siteVras]) => (
|
||||
<section key={site}>
|
||||
<p className="section-title mb-3">{site} · {siteVras.length} VRA{siteVras.length !== 1 ? 's' : ''}</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{siteVras.map((vra) => <VraCard key={vra.id || vra.name} vra={vra} />)}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/* src/styles/index.css */
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* ── Theme tokens (space-separated RGB for Tailwind opacity support) ── */
|
||||
:root,
|
||||
[data-theme="dark"] {
|
||||
--color-canvas: 8 13 26;
|
||||
--color-surface: 13 21 38;
|
||||
--color-raised: 19 31 53;
|
||||
--color-border: 30 45 71;
|
||||
--color-border-bright: 42 64 102;
|
||||
--color-text-primary: 226 232 240;
|
||||
--color-text-secondary: 124 147 181;
|
||||
--color-text-muted: 74 96 128;
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--color-canvas: 240 244 248;
|
||||
--color-surface: 255 255 255;
|
||||
--color-raised: 248 250 252;
|
||||
--color-border: 226 232 240;
|
||||
--color-border-bright: 203 213 225;
|
||||
--color-text-primary: 15 23 42;
|
||||
--color-text-secondary: 71 85 105;
|
||||
--color-text-muted: 148 163 184;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html { @apply scroll-smooth; }
|
||||
|
||||
body {
|
||||
@apply bg-canvas text-text-primary font-sans;
|
||||
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
|
||||
}
|
||||
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { @apply bg-surface; }
|
||||
::-webkit-scrollbar-thumb { @apply bg-border-bright rounded-full; }
|
||||
::-webkit-scrollbar-thumb:hover { @apply bg-accent; }
|
||||
|
||||
*:focus-visible {
|
||||
@apply outline-none ring-1 ring-accent ring-offset-2 ring-offset-canvas;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.data-value {
|
||||
@apply font-data tabular-nums;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
@apply inline-block w-2 h-2 rounded-full flex-shrink-0;
|
||||
}
|
||||
.status-dot-ok { @apply bg-ok shadow-glow-ok animate-pulse-led; }
|
||||
.status-dot-warn { @apply bg-warn; }
|
||||
.status-dot-crit { @apply bg-crit shadow-glow-crit animate-pulse-led; }
|
||||
.status-dot-idle { @apply bg-text-muted; }
|
||||
|
||||
.card {
|
||||
@apply bg-surface border border-border rounded-lg;
|
||||
}
|
||||
.card-raised {
|
||||
@apply bg-raised border border-border rounded-lg shadow-panel;
|
||||
}
|
||||
|
||||
.table-row-hover {
|
||||
@apply hover:bg-raised transition-colors duration-100 cursor-pointer;
|
||||
}
|
||||
|
||||
.field {
|
||||
@apply w-full bg-canvas border border-border rounded-md px-3 py-2
|
||||
text-sm text-text-primary placeholder-text-muted
|
||||
focus:border-accent focus:ring-0
|
||||
transition-colors duration-150;
|
||||
}
|
||||
.field-label {
|
||||
@apply block text-xs font-mono uppercase tracking-widest text-text-muted mb-1.5;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply inline-flex items-center gap-2 px-4 py-2 rounded-md
|
||||
bg-accent hover:bg-accent-bright text-white text-sm font-medium
|
||||
shadow-glow-sm hover:shadow-glow
|
||||
transition-all duration-150 disabled:opacity-40 disabled:cursor-not-allowed;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
@apply inline-flex items-center gap-2 px-4 py-2 rounded-md
|
||||
bg-transparent hover:bg-raised text-text-secondary hover:text-text-primary
|
||||
border border-border hover:border-border-bright text-sm font-medium
|
||||
transition-all duration-150;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply inline-flex items-center gap-2 px-4 py-2 rounded-md
|
||||
bg-crit/10 hover:bg-crit/20 text-crit border border-crit/30 text-sm font-medium
|
||||
transition-all duration-150;
|
||||
}
|
||||
|
||||
.badge {
|
||||
@apply inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-mono font-medium;
|
||||
}
|
||||
.badge-ok { @apply bg-ok/10 text-ok border border-ok/20; }
|
||||
.badge-warn { @apply bg-warn/10 text-warn border border-warn/20; }
|
||||
.badge-crit { @apply bg-crit/10 text-crit border border-crit/20; }
|
||||
.badge-info { @apply bg-info/10 text-info border border-info/20; }
|
||||
.badge-muted { @apply bg-raised text-text-muted border border-border; }
|
||||
|
||||
.section-title {
|
||||
@apply font-mono text-xs uppercase tracking-widest text-text-muted;
|
||||
}
|
||||
|
||||
.drawer-overlay {
|
||||
@apply fixed inset-0 bg-canvas/60 backdrop-blur-sm z-40;
|
||||
}
|
||||
.drawer-panel {
|
||||
@apply fixed top-0 right-0 h-full w-full max-w-lg bg-surface
|
||||
border-l border-border shadow-panel z-50
|
||||
animate-slide-in-right flex flex-col;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// tailwind.config.js
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{js,jsx,ts,tsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
canvas: 'rgb(var(--color-canvas) / <alpha-value>)',
|
||||
surface: 'rgb(var(--color-surface) / <alpha-value>)',
|
||||
raised: 'rgb(var(--color-raised) / <alpha-value>)',
|
||||
border: 'rgb(var(--color-border) / <alpha-value>)',
|
||||
'border-bright': 'rgb(var(--color-border-bright) / <alpha-value>)',
|
||||
accent: {
|
||||
DEFAULT: '#0ea5e9',
|
||||
dim: '#0284c7',
|
||||
bright: '#38bdf8',
|
||||
glow: 'rgba(14,165,233,0.15)',
|
||||
},
|
||||
ok: '#10b981',
|
||||
warn: '#f59e0b',
|
||||
crit: '#ef4444',
|
||||
info: '#818cf8',
|
||||
'text-primary': 'rgb(var(--color-text-primary) / <alpha-value>)',
|
||||
'text-secondary': 'rgb(var(--color-text-secondary) / <alpha-value>)',
|
||||
'text-muted': 'rgb(var(--color-text-muted) / <alpha-value>)',
|
||||
},
|
||||
fontFamily: {
|
||||
mono: ['"IBM Plex Mono"', 'monospace'],
|
||||
sans: ['"DM Sans"', 'system-ui', 'sans-serif'],
|
||||
data: ['"JetBrains Mono"', 'monospace'],
|
||||
},
|
||||
boxShadow: {
|
||||
glow: '0 0 20px rgba(14,165,233,0.2)',
|
||||
'glow-sm': '0 0 8px rgba(14,165,233,0.15)',
|
||||
'glow-ok': '0 0 12px rgba(16,185,129,0.2)',
|
||||
'glow-crit':'0 0 12px rgba(239,68,68,0.25)',
|
||||
panel: '0 4px 24px rgba(0,0,0,0.4)',
|
||||
},
|
||||
keyframes: {
|
||||
pulse_led: {
|
||||
'0%, 100%': { opacity: '1' },
|
||||
'50%': { opacity: '0.35' },
|
||||
},
|
||||
slide_in_right: {
|
||||
from: { transform: 'translateX(100%)', opacity: '0' },
|
||||
to: { transform: 'translateX(0)', opacity: '1' },
|
||||
},
|
||||
fade_in: {
|
||||
from: { opacity: '0', transform: 'translateY(6px)' },
|
||||
to: { opacity: '1', transform: 'translateY(0)' },
|
||||
},
|
||||
modal_in: {
|
||||
from: { opacity: '0', transform: 'scale(0.96)' },
|
||||
to: { opacity: '1', transform: 'scale(1)' },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
'pulse-led': 'pulse_led 2s ease-in-out infinite',
|
||||
'slide-in-right': 'slide_in_right 0.25s ease-out',
|
||||
'fade-in': 'fade_in 0.2s ease-out',
|
||||
'modal-in': 'modal_in 0.2s ease-out',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
@@ -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'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user