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:
|
networks:
|
||||||
front-tier:
|
front-tier:
|
||||||
back-tier:
|
back-tier:
|
||||||
|
auth-tier:
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
prometheus_data: {}
|
prometheus_data: {}
|
||||||
grafana_data: {}
|
grafana_data: {}
|
||||||
|
zroc_ui_data: {}
|
||||||
|
authentik_postgres: {}
|
||||||
|
authentik_redis: {}
|
||||||
|
authentik_media: {}
|
||||||
|
caddy_data: {}
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
|
||||||
# Exporter for ZVM/vCenter site 1
|
# ── Reverse proxy / TLS termination ───────────────────────────────────────
|
||||||
zertoexporter:
|
caddy:
|
||||||
container_name: zvmexporter1
|
image: caddy:2-alpine
|
||||||
hostname: zvmexporter1 # this hostname will need to be set in the prometheus.yaml file as well
|
container_name: zroc-caddy
|
||||||
image: recklessop/zerto-exporter:stable
|
restart: unless-stopped
|
||||||
command: python python-node-exporter.py
|
|
||||||
ports:
|
ports:
|
||||||
- "9999:9999"
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
volumes:
|
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:
|
environment:
|
||||||
# Site 1 configuration settings
|
NODE_ENV: production
|
||||||
- VERIFY_SSL=False
|
PORT: "3001"
|
||||||
- ZVM_HOST=192.168.50.60
|
PROMETHEUS_URL: http://zroc-prometheus:9090
|
||||||
- ZVM_PORT=443
|
AUTHENTIK_URL: http://authentik-server:9000
|
||||||
- SCRAPE_SPEED=20 #how often should the exporter scrape the Zerto API
|
AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID}
|
||||||
- CLIENT_ID=api-script
|
AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_CLIENT_SECRET}
|
||||||
- CLIENT_SECRET=js51tDM8oappYUGRJBhF7bcsedNoHA5j
|
AUTHENTIK_ADMIN_TOKEN: ${AUTHENTIK_ADMIN_TOKEN}
|
||||||
- LOGLEVEL=DEBUG
|
PUBLIC_URL: ${PUBLIC_URL}
|
||||||
- VCENTER_HOST=vcenter.local
|
SESSION_SECRET: ${SESSION_SECRET}
|
||||||
- VCENTER_USER=administrator@vsphere.local
|
JWT_EXPIRY_HOURS: "24"
|
||||||
- VCENTER_PASSWORD=password
|
AUTHENTIK_ADMIN_GROUP: zroc-admins
|
||||||
networks:
|
AUTHENTIK_VIEWER_GROUP: zroc-viewers
|
||||||
- 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
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./prometheus/:/etc/prometheus/
|
- zroc_ui_data:/app/data
|
||||||
- prometheus_data:/prometheus
|
networks:
|
||||||
command:
|
- front-tier
|
||||||
- '--config.file=/etc/prometheus/prometheus.yml'
|
- back-tier
|
||||||
- '--storage.tsdb.path=/prometheus'
|
- auth-tier
|
||||||
- '--web.console.libraries=/usr/share/prometheus/console_libraries'
|
depends_on:
|
||||||
- '--web.console.templates=/usr/share/prometheus/consoles'
|
- zroc-prometheus
|
||||||
ports:
|
- authentik-server
|
||||||
- 9090:9090
|
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:
|
networks:
|
||||||
- back-tier
|
- back-tier
|
||||||
restart: always
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- zertoexporter
|
- zertoexporter
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://localhost:9090/-/healthy"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
# ── Dashboards — Grafana ──────────────────────────────────────────────────
|
||||||
grafana:
|
grafana:
|
||||||
image: grafana/grafana
|
image: grafana/grafana:10.4.2
|
||||||
|
container_name: zroc-grafana
|
||||||
|
restart: unless-stopped
|
||||||
user: "472"
|
user: "472"
|
||||||
depends_on:
|
|
||||||
- prometheus
|
|
||||||
ports:
|
ports:
|
||||||
- 3000:3000
|
- "3000:3000"
|
||||||
volumes:
|
volumes:
|
||||||
- grafana_data:/var/lib/grafana
|
- grafana_data:/var/lib/grafana
|
||||||
- ./grafana/provisioning/:/etc/grafana/provisioning/
|
- ./grafana/provisioning:/etc/grafana/provisioning:ro
|
||||||
environment:
|
environment:
|
||||||
- GF_SECURITY_ADMIN_PASSWORD=zertodata
|
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_PASSWORD:-zertodata}
|
||||||
- GF_USERS_ALLOW_SIGN_UP=false
|
GF_USERS_ALLOW_SIGN_UP: "false"
|
||||||
- data-source-url=http://prometheus:9090
|
GF_SERVER_ROOT_URL: "%(protocol)s://%(domain)s:%(http_port)s/grafana/"
|
||||||
- name=Prometheus
|
GF_AUTH_GENERIC_OAUTH_ENABLED: ${GRAFANA_OIDC_ENABLED:-false}
|
||||||
- type=prometheus
|
GF_AUTH_GENERIC_OAUTH_NAME: Authentik
|
||||||
- update-interval=10
|
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:
|
networks:
|
||||||
- back-tier
|
- back-tier
|
||||||
- front-tier
|
- front-tier
|
||||||
restart: always
|
depends_on:
|
||||||
|
- zroc-prometheus
|
||||||
|
|
||||||
|
# ── Auto-updates — Watchtower ─────────────────────────────────────────────
|
||||||
watchtower:
|
watchtower:
|
||||||
image: containrrr/watchtower
|
image: containrrr/watchtower
|
||||||
|
container_name: zroc-watchtower
|
||||||
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
environment:
|
environment:
|
||||||
- WATCHTOWER_POLL_INTERVAL=360 # 1 hour
|
WATCHTOWER_POLL_INTERVAL: "3600"
|
||||||
restart: always
|
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