23 Commits

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 20:39:36 -04:00
Justin fd9a5926c0 chore: update repo references from ZertoPublic to recklessop
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 20:33:02 -04:00
Justin b7b9f6191d feat: add DR Capacity Planner, light/dark mode toggle, and PDF export
- Add zroc-planner UI page with VM selector, journal retention slider (1h-30d),
  WAN compression input, and live bandwidth/journal/mirror storage estimates
- Add CSV and PDF export for planning reports
- Add light/dark mode toggle in TopBar with localStorage persistence
- Wire theme via CSS custom properties for full Tailwind opacity support
- Add Planner route and sidebar entry

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 20:29:38 -04:00
Justin 5a617fd550 feat: complete zROC project recreation — all 61 files populated
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 17:12:19 -04:00
Justin ec794996bb feat: populate Sidebar, TopBar, docker-compose, and more full content
44/61 files now have full content. 17 large files remain as stubs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 16:28:08 -04:00
Justin 0500ac171c feat: initial zROC project recreation (stubs for large files pending)
- 61 files across zroc-ui/ and zroc-ova/ directories
- Full content written for: config, auth, API layers, CSS, build files,
  OVA scripts, backend routes, charts, hooks, constants
- Stubs in place for: page components, Sidebar, TopBar, docker-compose,
  authentik client, blueprint YAML, packer HCL, workflows, setup wizard

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 16:20:05 -04:00
justin 74c05e5a58 Update README.md 2025-02-11 14:37:47 -05:00
justin fe93c84d6b Merge pull request #7 from ZertoPublic/recklessop-patch-1
Update protectedvms.json
2024-05-29 16:38:05 -04:00
justin 40ec3be00e Update protectedvms.json 2024-05-29 16:37:17 -04:00
justin fa6f5d4e6d Update exporterhealth.json 2023-12-26 18:10:45 -05:00
justin fcc640dbfc Update README.md 2023-04-18 18:34:31 -04:00
justin 4094867c4d Update README.md 2023-04-18 18:33:16 -04:00
justin 5c023cca9d Update README.md 2023-04-18 18:23:28 -04:00
justin d9293f4e13 Create CODEOWNERS 2023-04-18 18:16:50 -04:00
justin 554da3c0bc Update README.md 2023-04-18 18:12:02 -04:00
justin d16ce8e8d1 Update zertometrics.json 2023-04-17 17:12:33 -04:00
justin 2d5c459d85 Update README.md 2023-04-17 16:40:08 -04:00
justin c99ccfcdb3 Update zertoencryption.json 2023-04-11 20:42:50 -04:00
justin 30eb343c0b Create encryptionpervm.json 2023-04-05 22:59:48 -04:00
justin 0b4d87fe30 Update getupdates.sh 2023-04-01 18:51:20 -04:00
justin 2bc42048b1 Create getupdates.sh 2023-04-01 18:49:35 -04:00
justin 41a6d2e6c2 Merge pull request #1 from recklessop/add-license-1
Create LICENSE
2023-04-01 14:57:11 -04:00
76 changed files with 6559 additions and 211 deletions
+6
View File
@@ -0,0 +1,6 @@
##########################################
# code ownership
##########################################
# default ownership: default owners for everything in the repo (Unless a later match takes precedence)
* @recklessop
+34 -7
View File
@@ -1,8 +1,24 @@
# Zerto Resiliency Observation Console
zROC for short, is a docker-compose based stack that allows you to observe Zerto API data in a visual format using Prometheus and Grafana.
zROC for short, is a docker-compose based software stack that allows you to observe standard Zerto API data in a visual format using Prometheus and Grafana.
The custom part of this stack is the Prometheus exporter code which is developed separately [here.](https://github.com/recklessop/Zerto_Exporter)
The rest of the stack is a standard Prometheus container and a standard Grafana container. The additional configuration files in this repo will help to configure both Prometheus and Grafana so that it can be used out of the box.
The rest of the stack is a standard Prometheus container and a standard Grafana container. The additional configuration files in this repo help to configure both Prometheus and Grafana to deliver customer value out of the box.
While some dashboards are pre-build the project maintainers encourage you to build your own custom dashboards or copy and modify existing dashboards to what best suits your needs.
# Legal Disclaimer
This script is open-source and is not supported under any Zerto support program or service. The author and Zerto further disclaim all implied warranties including, without limitation, any implied warranties of merchantability or of fitness for a particular purpose.
In no event shall Zerto, its authors or anyone else involved in the creation, production or delivery of the scripts be liable for any damages whatsoever (including, without limitation, damages for loss of business profits, business interruption, loss of business information, or other pecuniary loss) arising out of the use of or the inability to use the sample scripts or documentation, even if the author or Zerto has been advised of the possibility of such damages. The entire risk arising out of the use or performance of the sample scripts and documentation remains with you.
## What it does and does not do
### Supported
- ZVM Appliance for VMware (Linux based ZVM)
### Not Supported
- Windows-based ZVM
- Zerto Cloud Appliance for Azure or AWS
## Requirements
- Docker host (I like using Ubuntu with docker.io and docker-compose installed)
@@ -22,12 +38,12 @@ Edit docker-compose.yml, and provide values for the following variables for the
- ZVM client id (generate this in keycloak)
- ZVM client secret (also generated in keycloak)
- VCenter ip or hostname
- VCenter username
- VCenter username (Optional - Also this can be a read only user. As it is just used to pull VRA CPU/Memory stats from vCenter)
- VCenter password
```yaml
zertoexporter:
image: recklessop/zerto-exporter:latest
image: recklessop/zerto-exporter:stable
command: python python-node-exporter.py
ports:
- "9999:9999" # edit the port for each additional exporter, in this case it was changed to 9998
@@ -60,7 +76,7 @@ For each site you want to monitor you need to have an exporter configured like t
zertoexporter:
container_name: zvmexporter1
hostname: zvmexporter1 # this hostname will need to be set in the prometheus.yaml file as well
image: recklessop/zerto-exporter:latest
image: recklessop/zerto-exporter:stable
command: python python-node-exporter.py
ports:
- "9999:9999"
@@ -140,7 +156,18 @@ docker-compose up -d
http://<IP_Address_of_Docker_Host>:3000
Login credentials are admin / metricdata
Login credentials are admin / zertodata
(you can change this by changing the grafana environment variable in the docker-compose.yaml file.
```
grafana:
image: grafana/grafana
...
environment:
- GF_SECURITY_ADMIN_PASSWORD=zertodata
...
```
There will be several dashboards provisioned out of the box that will help monitor most metrics. Custom graphs and custom dashboards can be added too.
@@ -165,5 +192,5 @@ The goal of this dashboard is to help you understand the workload on each of you
![VRA Metrics](/images/vra-dashboard.jpg)
### Encryption Detection
This feature is in private preview. If you would like to learn more contact your Zerto account team. With Zerto Encryption Detection enabled, Zerto looks at each write that is being protected to determine if it is encrypted data or unencrypted data. The goal is to help customers detect anomalies caused by ransomware attacks in near real time.
With Zerto Encryption Detection enabled, Zerto looks at each write that is being protected to determine if it is encrypted data or unencrypted data. The goal is to help customers detect anomalies caused by ransomware attacks in near real time.
![Encryption Detection](/images/encryption-detection.jpg)
+47
View File
@@ -0,0 +1,47 @@
{
admin off
auto_https off
log {
format json
}
}
:443 {
tls internal
handle /auth/* {
reverse_proxy authentik-server:9000 {
header_up X-Forwarded-Proto https
header_up X-Forwarded-For {remote_host}
}
}
handle /outpost.goauthentik.io/* {
reverse_proxy authentik-server:9000 {
header_up X-Forwarded-Proto https
}
}
handle {
reverse_proxy zroc-ui:3001 {
header_up X-Forwarded-Proto https
header_up X-Forwarded-For {remote_host}
header_up X-Real-IP {remote_host}
health_uri /api/health
health_interval 15s
}
}
header {
X-Frame-Options "SAMEORIGIN"
X-Content-Type-Options "nosniff"
X-XSS-Protection "1; mode=block"
Referrer-Policy "strict-origin-when-cross-origin"
Strict-Transport-Security "max-age=31536000; includeSubDomains"
-Server
}
}
:80 {
redir https://{host}{uri} permanent
}
+15
View File
@@ -0,0 +1,15 @@
#!/bin/bash
# change to the zroc directory
cd /home/zroc/zroc
# pull any updates from the git repository
git pull
# check if there are any changes
if [[ $(git diff HEAD@{1} HEAD) ]]; then
# if getupdates.sh scripts is update it make it executable
chmod +x ./cron/getupdates.sh
# if there are changes, run docker-compose up with force-restart
docker-compose up -d --force-restart
fi
+256 -78
View File
@@ -1,111 +1,289 @@
version: '3.7'
---
# zROC — Zerto Resiliency Observation Console
# Full stack: Caddy (TLS), zroc-ui (React dashboard + Node backend), Authentik (SSO),
# Prometheus, Grafana, Zerto Exporter, Watchtower (auto-updates).
#
# Configuration is driven entirely by /opt/zroc/.env — see zroc-setup wizard.
version: '3.8'
networks:
front-tier:
back-tier:
auth-tier:
volumes:
prometheus_data: {}
grafana_data: {}
prometheus_data: {}
grafana_data: {}
zroc_ui_data: {}
authentik_postgres: {}
authentik_redis: {}
authentik_media: {}
caddy_data: {}
services:
# Exporter for ZVM/vCenter site 1
zertoexporter:
container_name: zvmexporter1
hostname: zvmexporter1 # this hostname will need to be set in the prometheus.yaml file as well
image: recklessop/zerto-exporter:stable
command: python python-node-exporter.py
# ── Reverse proxy / TLS termination ───────────────────────────────────────
caddy:
image: caddy:2-alpine
container_name: zroc-caddy
restart: unless-stopped
ports:
- "9999:9999"
- "80:80"
- "443:443"
volumes:
- ./zvmexporter/:/usr/src/app/logs/
- ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
- ./certs:/certs:ro
- caddy_data:/data
networks:
- front-tier
depends_on:
- zroc-ui
- authentik-server
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:80"]
interval: 30s
timeout: 5s
retries: 3
# ── zROC React UI + Node backend ──────────────────────────────────────────
zroc-ui:
image: recklessop/zroc-ui:stable
container_name: zroc-ui
restart: unless-stopped
environment:
# Site 1 configuration settings
- VERIFY_SSL=False
- ZVM_HOST=192.168.50.60
- ZVM_PORT=443
- SCRAPE_SPEED=20 #how often should the exporter scrape the Zerto API
- CLIENT_ID=api-script
- CLIENT_SECRET=js51tDM8oappYUGRJBhF7bcsedNoHA5j
- LOGLEVEL=DEBUG
- VCENTER_HOST=vcenter.local
- VCENTER_USER=administrator@vsphere.local
- VCENTER_PASSWORD=password
networks:
- back-tier
restart: always
# This is used for a second ZVM / vCenter (maybe your DR site?)
#zertoexporter2:
# container_name: zvmexporter2
# hostname: zvmexporter2
# image: recklessop/zerto-exporter:stable
# command: python python-node-exporter.py
# ports:
# - "9998:9999" # if you add a third or more exporters change the port number before the :
# volumes:
# - ./zvmexporter/:/usr/src/app/logs/
# environment:
# # Site 2 configuration settings
# - VERIFY_SSL=False
# - ZVM_HOST=192.168.50.30
# - ZVM_PORT=443
# - SCRAPE_SPEED=20 #how often should the exporter scrape the Zerto API
# - CLIENT_ID=api-script
# - CLIENT_SECRET=x2aokKGPyS1O6LCW2uNqm2tbko2PLUSn
# - LOGLEVEL=DEBUG
# - VCENTER_HOST=192.168.50.20
# - VCENTER_USER=administrator@vsphere.local
# - VCENTER_PASSWORD=password
# networks:
# - back-tier
# restart: always
prometheus:
image: prom/prometheus:v2.40.6
NODE_ENV: production
PORT: "3001"
PROMETHEUS_URL: http://zroc-prometheus:9090
AUTHENTIK_URL: http://authentik-server:9000
AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID}
AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_CLIENT_SECRET}
AUTHENTIK_ADMIN_TOKEN: ${AUTHENTIK_ADMIN_TOKEN}
PUBLIC_URL: ${PUBLIC_URL}
SESSION_SECRET: ${SESSION_SECRET}
JWT_EXPIRY_HOURS: "24"
AUTHENTIK_ADMIN_GROUP: zroc-admins
AUTHENTIK_VIEWER_GROUP: zroc-viewers
volumes:
- ./prometheus/:/etc/prometheus/
- prometheus_data:/prometheus
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.path=/prometheus'
- '--web.console.libraries=/usr/share/prometheus/console_libraries'
- '--web.console.templates=/usr/share/prometheus/consoles'
ports:
- 9090:9090
- zroc_ui_data:/app/data
networks:
- front-tier
- back-tier
- auth-tier
depends_on:
- zroc-prometheus
- authentik-server
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3001/api/health"]
interval: 15s
timeout: 5s
retries: 3
start_period: 20s
# ── SSO — Authentik ───────────────────────────────────────────────────────
authentik-postgresql:
image: postgres:16-alpine
container_name: authentik-db
restart: unless-stopped
environment:
POSTGRES_DB: authentik
POSTGRES_USER: authentik
POSTGRES_PASSWORD: ${AUTHENTIK_PG_PASS}
volumes:
- authentik_postgres:/var/lib/postgresql/data
networks:
- auth-tier
healthcheck:
test: ["CMD-SHELL", "pg_isready -U authentik"]
interval: 10s
timeout: 5s
retries: 5
authentik-redis:
image: redis:7-alpine
container_name: authentik-redis
restart: unless-stopped
command: --save 60 1 --loglevel warning
volumes:
- authentik_redis:/data
networks:
- auth-tier
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
authentik-server:
image: ghcr.io/goauthentik/server:latest
container_name: authentik-server
restart: unless-stopped
command: server
environment:
AUTHENTIK_REDIS__HOST: authentik-redis
AUTHENTIK_POSTGRESQL__HOST: authentik-postgresql
AUTHENTIK_POSTGRESQL__USER: authentik
AUTHENTIK_POSTGRESQL__NAME: authentik
AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_PG_PASS}
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
AUTHENTIK_DISABLE_STARTUP_ANALYTICS: "true"
AUTHENTIK_ERROR_REPORTING__ENABLED: "false"
ZROC_OIDC_CLIENT_ID: ${ZROC_OIDC_CLIENT_ID}
ZROC_OIDC_CLIENT_SECRET: ${ZROC_OIDC_CLIENT_SECRET}
ZROC_PUBLIC_URL: ${ZROC_PUBLIC_URL}
volumes:
- authentik_media:/media
- ./zroc-ui/authentik/blueprints:/blueprints/custom:ro
networks:
- auth-tier
- front-tier
depends_on:
authentik-postgresql:
condition: service_healthy
authentik-redis:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "ak healthcheck || exit 1"]
interval: 30s
timeout: 10s
retries: 5
start_period: 60s
authentik-worker:
image: ghcr.io/goauthentik/server:latest
container_name: authentik-worker
restart: unless-stopped
command: worker
environment:
AUTHENTIK_REDIS__HOST: authentik-redis
AUTHENTIK_POSTGRESQL__HOST: authentik-postgresql
AUTHENTIK_POSTGRESQL__USER: authentik
AUTHENTIK_POSTGRESQL__NAME: authentik
AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_PG_PASS}
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
AUTHENTIK_DISABLE_STARTUP_ANALYTICS: "true"
volumes:
- authentik_media:/media
- /var/run/docker.sock:/var/run/docker.sock
networks:
- auth-tier
depends_on:
- authentik-server
user: root
# ── Metrics — Zerto exporter ──────────────────────────────────────────────
zertoexporter:
image: recklessop/zerto-exporter:stable
container_name: zvmexporter1
hostname: zvmexporter1
restart: unless-stopped
volumes:
- ./zvmexporter:/usr/src/app/logs
environment:
VERIFY_SSL: "False"
ZVM_HOST: ${ZVM_HOST}
ZVM_PORT: "443"
ZVM_USERNAME: ${ZVM_USERNAME}
ZVM_PASSWORD: ${ZVM_PASSWORD}
SCRAPE_SPEED: "20"
CLIENT_ID: ${ZVM_CLIENT_ID:-api-script}
CLIENT_SECRET: ${ZVM_CLIENT_SECRET}
LOGLEVEL: INFO
VCENTER_HOST: ${VCENTER_HOST:-}
VCENTER_USER: ${VCENTER_USER:-administrator@vsphere.local}
VCENTER_PASSWORD: ${VCENTER_PASSWORD:-}
networks:
- back-tier
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:9999/metrics"]
interval: 30s
timeout: 10s
retries: 3
# Optional second ZVM/vCenter site — uncomment and set ZVM2_* env vars
# zertoexporter2:
# image: recklessop/zerto-exporter:stable
# container_name: zvmexporter2
# hostname: zvmexporter2
# restart: unless-stopped
# ports:
# - "9998:9999"
# volumes:
# - ./zvmexporter:/usr/src/app/logs
# environment:
# VERIFY_SSL: "False"
# ZVM_HOST: ${ZVM2_HOST}
# ZVM_PORT: "443"
# ZVM_USERNAME: ${ZVM2_USERNAME}
# ZVM_PASSWORD: ${ZVM2_PASSWORD}
# SCRAPE_SPEED: "20"
# LOGLEVEL: INFO
# networks:
# - back-tier
# ── Metrics — Prometheus ──────────────────────────────────────────────────
zroc-prometheus:
image: prom/prometheus:v2.51.0
container_name: zroc-prometheus
restart: unless-stopped
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.path=/prometheus
- --storage.tsdb.retention.time=30d
- --storage.tsdb.retention.size=20GB
- --web.listen-address=0.0.0.0:9090
- --web.enable-lifecycle
volumes:
- ./prometheus:/etc/prometheus:ro
- prometheus_data:/prometheus
networks:
- back-tier
restart: always
depends_on:
- zertoexporter
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:9090/-/healthy"]
interval: 30s
timeout: 5s
retries: 3
# ── Dashboards — Grafana ──────────────────────────────────────────────────
grafana:
image: grafana/grafana
image: grafana/grafana:10.4.2
container_name: zroc-grafana
restart: unless-stopped
user: "472"
depends_on:
- prometheus
ports:
- 3000:3000
- "3000:3000"
volumes:
- grafana_data:/var/lib/grafana
- ./grafana/provisioning/:/etc/grafana/provisioning/
- ./grafana/provisioning:/etc/grafana/provisioning:ro
environment:
- GF_SECURITY_ADMIN_PASSWORD=zertodata
- GF_USERS_ALLOW_SIGN_UP=false
- data-source-url=http://prometheus:9090
- name=Prometheus
- type=prometheus
- update-interval=10
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_PASSWORD:-zertodata}
GF_USERS_ALLOW_SIGN_UP: "false"
GF_SERVER_ROOT_URL: "%(protocol)s://%(domain)s:%(http_port)s/grafana/"
GF_AUTH_GENERIC_OAUTH_ENABLED: ${GRAFANA_OIDC_ENABLED:-false}
GF_AUTH_GENERIC_OAUTH_NAME: Authentik
GF_AUTH_GENERIC_OAUTH_CLIENT_ID: ${GRAFANA_CLIENT_ID:-}
GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET: ${GRAFANA_CLIENT_SECRET:-}
GF_AUTH_GENERIC_OAUTH_SCOPES: openid profile email
GF_AUTH_GENERIC_OAUTH_AUTH_URL: ${PUBLIC_URL:-}/auth/application/o/authorize/
GF_AUTH_GENERIC_OAUTH_TOKEN_URL: http://authentik-server:9000/application/o/token/
GF_AUTH_GENERIC_OAUTH_API_URL: http://authentik-server:9000/application/o/userinfo/
networks:
- back-tier
- front-tier
restart: always
depends_on:
- zroc-prometheus
# ── Auto-updates — Watchtower ─────────────────────────────────────────────
watchtower:
image: containrrr/watchtower
container_name: zroc-watchtower
restart: unless-stopped
volumes:
- /var/run/docker.sock:/var/run/docker.sock
environment:
- WATCHTOWER_POLL_INTERVAL=360 # 1 hour
restart: always
WATCHTOWER_POLL_INTERVAL: "3600"
WATCHTOWER_CLEANUP: "true"
WATCHTOWER_INCLUDE_STOPPED: "false"
command: --label-enable
@@ -0,0 +1,224 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"target": {
"limit": 100,
"matchAny": false,
"tags": [],
"type": "dashboard"
},
"type": "dashboard"
}
]
},
"description": "",
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": 8,
"links": [],
"liveNow": false,
"panels": [
{
"datasource": {
"type": "prometheus",
"uid": "PBFA97CFB590B2093"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 25,
"w": 24,
"x": 0,
"y": 0
},
"id": 2,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "PBFA97CFB590B2093"
},
"editorMode": "builder",
"expr": "vm_TotalDataInLBs{VmName=\"$VMName\", SiteName=\"$SiteName\"}",
"interval": "",
"legendFormat": "Total Blocks",
"range": true,
"refId": "A"
},
{
"datasource": {
"type": "prometheus",
"uid": "PBFA97CFB590B2093"
},
"editorMode": "builder",
"expr": "vm_UnencryptedDataInLBs{VmName=\"$VMName\", SiteName=\"$SiteName\"}",
"hide": false,
"interval": "",
"legendFormat": "Unencrypted Blocks",
"range": true,
"refId": "B"
},
{
"datasource": {
"type": "prometheus",
"uid": "PBFA97CFB590B2093"
},
"editorMode": "builder",
"expr": "vm_EncryptedDataInLBs{VmName=\"$VMName\", SiteName=\"$SiteName\"}",
"hide": false,
"legendFormat": "Encrypted Blocks",
"range": true,
"refId": "C"
}
],
"title": "Sum of All VMs - Overlayed Stats",
"type": "timeseries"
}
],
"refresh": "",
"revision": 1,
"schemaVersion": 38,
"style": "dark",
"tags": [],
"templating": {
"list": [
{
"current": {
"selected": true,
"text": "General Utils/rdp licence",
"value": "General Utils/rdp licence"
},
"datasource": {
"type": "prometheus",
"uid": "PBFA97CFB590B2093"
},
"definition": "query_result(vm_EncryptedDataInLBs{})",
"hide": 0,
"includeAll": false,
"label": "VM Name",
"multi": false,
"name": "VMName",
"options": [],
"query": {
"query": "query_result(vm_EncryptedDataInLBs{})",
"refId": "StandardVariableQuery"
},
"refresh": 1,
"regex": "/.*VmName=\"([^\"]+)\".*/",
"skipUrlSync": false,
"sort": 0,
"type": "query"
},
{
"current": {
"selected": true,
"text": "Sunrise Main",
"value": "Sunrise Main"
},
"datasource": {
"type": "prometheus",
"uid": "PBFA97CFB590B2093"
},
"definition": "query_result(vm_actualrpo{})",
"hide": 0,
"includeAll": false,
"label": "Site Name",
"multi": false,
"name": "SiteName",
"options": [],
"query": {
"query": "query_result(vm_actualrpo{})",
"refId": "StandardVariableQuery"
},
"refresh": 1,
"regex": "/.*SiteName=\"([^\"]+)\".*/",
"skipUrlSync": false,
"sort": 0,
"type": "query"
}
]
},
"time": {
"from": "now-7d",
"to": "now"
},
"timepicker": {},
"timezone": "",
"title": "Encryption per VM",
"uid": "oLVf6cY4z",
"version": 5,
"weekStart": ""
}
@@ -24,7 +24,6 @@
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": 8,
"links": [],
"liveNow": false,
"panels": [
@@ -84,6 +83,7 @@
},
"tooltip": {
"show": true,
"showColorScale": false,
"yHistogram": false
},
"yAxis": {
@@ -91,7 +91,7 @@
"reverse": false
}
},
"pluginVersion": "9.4.7",
"pluginVersion": "10.2.3",
"targets": [
{
"datasource": {
@@ -107,18 +107,117 @@
],
"title": "Data Exporter Thread Status",
"type": "heatmap"
},
{
"datasource": {
"type": "prometheus",
"uid": "PBFA97CFB590B2093"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
},
"unit": "m"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 0
},
"id": 3,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "PBFA97CFB590B2093"
},
"disableTextWrap": false,
"editorMode": "builder",
"expr": "exporter_uptime",
"fullMetaSearch": false,
"includeNullMetadata": true,
"instant": false,
"legendFormat": "__auto",
"range": true,
"refId": "A",
"useBackend": false
}
],
"title": "Exporter Uptime",
"type": "timeseries"
}
],
"refresh": "30s",
"revision": 1,
"schemaVersion": 38,
"style": "dark",
"schemaVersion": 39,
"tags": [],
"templating": {
"list": []
},
"time": {
"from": "now-30m",
"from": "now-7d",
"to": "now"
},
"timepicker": {
@@ -136,6 +235,6 @@
"timezone": "",
"title": "Exporter Health",
"uid": "Z23A-Ma4k",
"version": 6,
"version": 1,
"weekStart": ""
}
}
@@ -38,6 +38,7 @@
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
@@ -51,6 +52,7 @@
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
@@ -100,6 +102,7 @@
"showLegend": true
},
"tooltip": {
"maxHeight": 600,
"mode": "single",
"sort": "none"
}
@@ -132,6 +135,7 @@
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
@@ -145,6 +149,7 @@
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
@@ -195,6 +200,7 @@
"showLegend": true
},
"tooltip": {
"maxHeight": 600,
"mode": "single",
"sort": "none"
}
@@ -219,22 +225,21 @@
],
"refresh": "30s",
"revision": 1,
"schemaVersion": 38,
"style": "dark",
"schemaVersion": 39,
"tags": [],
"templating": {
"list": [
{
"current": {
"selected": true,
"text": "Sunrise DR",
"value": "Sunrise DR"
"selected": false,
"text": "JP-Lab",
"value": "JP-Lab"
},
"datasource": {
"type": "prometheus",
"uid": "PBFA97CFB590B2093"
},
"definition": "query_result(vm_actualrpo{})",
"definition": "query_result(vm_iops{})",
"hide": 0,
"includeAll": false,
"label": "Site Name",
@@ -242,8 +247,9 @@
"name": "SiteName",
"options": [],
"query": {
"query": "query_result(vm_actualrpo{})",
"refId": "StandardVariableQuery"
"qryType": 3,
"query": "query_result(vm_iops{})",
"refId": "PrometheusVariableQueryEditor-VariableQuery"
},
"refresh": 1,
"regex": "/.*SiteName=\"([^\"]+)\".*/",
@@ -257,6 +263,7 @@
"from": "now-24h",
"to": "now"
},
"timeRangeUpdatedDuringEditOrView": false,
"timepicker": {
"refresh_intervals": [
"30s",
@@ -214,7 +214,7 @@
"uid": "PBFA97CFB590B2093"
},
"editorMode": "builder",
"expr": "sum(vm_UnencryptedDataInLBs)",
"expr": "sum(vm_UnencryptedDataInLBs{SiteName=\"$SiteName\"})",
"hide": false,
"interval": "",
"legendFormat": "Unencrypted Logical Blocks",
@@ -227,7 +227,7 @@
"uid": "PBFA97CFB590B2093"
},
"editorMode": "builder",
"expr": "sum(vm_EncryptedDataInLBs)",
"expr": "sum(vm_EncryptedDataInLBs{SiteName=\"$SiteName\"})",
"hide": false,
"legendFormat": "Encrypted Logical Blocks",
"range": true,
@@ -653,6 +653,6 @@
"timezone": "",
"title": "Encryption Analyzer Dashboard",
"uid": "DqUCTVcVz",
"version": 1,
"version": 2,
"weekStart": ""
}
+109 -108
View File
@@ -34,6 +34,7 @@
},
"fieldConfig": {
"defaults": {
"decimals": 0,
"mappings": [],
"thresholds": {
"mode": "absolute",
@@ -182,7 +183,7 @@
"overrides": []
},
"gridPos": {
"h": 16,
"h": 25,
"w": 12,
"x": 12,
"y": 0
@@ -236,13 +237,13 @@
"expr": "vpg_actual_rpo{SiteName=\"$SiteName\"}",
"format": "time_series",
"instant": false,
"interval": "",
"interval": "1",
"legendFormat": "{{VpgName}}",
"range": true,
"refId": "A"
}
],
"title": "RPO History Map",
"title": "VPG RPO History Map",
"type": "heatmap"
},
{
@@ -304,7 +305,7 @@
"overrides": []
},
"gridPos": {
"h": 8,
"h": 9,
"w": 12,
"x": 0,
"y": 8
@@ -402,7 +403,7 @@
"h": 8,
"w": 12,
"x": 0,
"y": 16
"y": 17
},
"id": 10,
"options": {
@@ -447,100 +448,6 @@
"title": "VPG Journal History Length",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "PBFA97CFB590B2093"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
},
"unit": "decmbytes"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 16
},
"id": 4,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "PBFA97CFB590B2093"
},
"editorMode": "builder",
"expr": "vm_journal_used_storage_mb{SiteName=\"$SiteName\"}",
"interval": "",
"legendFormat": "{{VmName}}",
"range": true,
"refId": "A"
}
],
"title": "Journal Storage Size",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
@@ -603,7 +510,7 @@
"h": 8,
"w": 12,
"x": 0,
"y": 24
"y": 25
},
"id": 6,
"options": {
@@ -689,7 +596,7 @@
}
]
},
"unit": "MBs"
"unit": "decmbytes"
},
"overrides": []
},
@@ -697,9 +604,9 @@
"h": 8,
"w": 12,
"x": 12,
"y": 24
"y": 25
},
"id": 12,
"id": 4,
"options": {
"legend": {
"calcs": [],
@@ -719,14 +626,14 @@
"uid": "PBFA97CFB590B2093"
},
"editorMode": "builder",
"expr": "vm_NetworkTrafficCounterInMBs{SiteName=\"$SiteName\"}",
"expr": "vm_journal_used_storage_mb{SiteName=\"$SiteName\"}",
"interval": "",
"legendFormat": "{{VmName}}",
"range": true,
"refId": "A"
}
],
"title": "Network Throughput",
"title": "Journal Storage Size",
"type": "timeseries"
},
{
@@ -792,7 +699,7 @@
"h": 8,
"w": 12,
"x": 0,
"y": 32
"y": 33
},
"id": 8,
"options": {
@@ -823,6 +730,100 @@
],
"title": "IOps",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "PBFA97CFB590B2093"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
},
"unit": "MBs"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 33
},
"id": 12,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "PBFA97CFB590B2093"
},
"editorMode": "builder",
"expr": "vm_NetworkTrafficCounterInMBs{SiteName=\"$SiteName\"}",
"interval": "",
"legendFormat": "{{VmName}}",
"range": true,
"refId": "A"
}
],
"title": "Network Throughput",
"type": "timeseries"
}
],
"refresh": "30s",
@@ -835,8 +836,8 @@
{
"current": {
"selected": false,
"text": "Sunrise Main",
"value": "Sunrise Main"
"text": "Kalida",
"value": "Kalida"
},
"datasource": {
"type": "prometheus",
+98
View File
@@ -0,0 +1,98 @@
name: Build & Release OVA
on:
push:
tags:
- 'v[0-9]+.[0-9]+.[0-9]+'
workflow_dispatch:
inputs:
version:
description: 'Version string (e.g. 1.0.0)'
required: true
default: '1.0.0'
jobs:
build-ova:
name: Build OVA
runs-on: [self-hosted, linux, kvm]
timeout-minutes: 120
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Resolve version
id: ver
run: |
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
VERSION="${{ github.event.inputs.version }}"
else
VERSION="${GITHUB_REF_NAME#v}"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "tag=v$VERSION" >> $GITHUB_OUTPUT
echo "ova_name=zroc-appliance-${VERSION}-ubuntu-24.04-amd64.ova" >> $GITHUB_OUTPUT
- name: Install Packer
run: |
curl -fsSL https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp.gpg
echo "deb [signed-by=/usr/share/keyrings/hashicorp.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" \
| sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt-get update -y && sudo apt-get install -y packer
- name: Packer init
working-directory: packer
run: packer init ubuntu-2404.pkr.hcl
- name: Validate
working-directory: packer
run: |
packer validate \
-var "vm_version=${{ steps.ver.outputs.version }}" \
-var-file=variables.pkrvars.hcl \
ubuntu-2404.pkr.hcl
- name: Build OVA
working-directory: packer
env:
PACKER_LOG: 1
PACKER_LOG_PATH: packer-build.log
run: |
packer build \
-var "vm_version=${{ steps.ver.outputs.version }}" \
-var "headless=true" \
-var-file=variables.pkrvars.hcl \
ubuntu-2404.pkr.hcl
- name: Locate OVA
id: ova
run: |
OVA_PATH=$(find output -name "*.ova" | head -1)
echo "path=$OVA_PATH" >> $GITHUB_OUTPUT
ls -lh "$OVA_PATH"
- name: Checksum
run: |
sha256sum "${{ steps.ova.outputs.path }}" \
> "${{ steps.ova.outputs.path }}.sha256"
cat "${{ steps.ova.outputs.path }}.sha256"
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.ver.outputs.tag }}
name: "zROC Appliance ${{ steps.ver.outputs.tag }}"
draft: false
prerelease: false
files: |
${{ steps.ova.outputs.path }}
${{ steps.ova.outputs.path }}.sha256
env:
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
- name: Upload build log (on failure)
if: failure()
uses: actions/upload-artifact@v4
with:
name: packer-build-log
path: packer/packer-build.log
+64
View File
@@ -0,0 +1,64 @@
# zroc-ova/Makefile
VERSION ?= 1.0.0
PACKER_DIR = packer
OUTPUT_DIR = output
OVA_NAME = zroc-appliance-$(VERSION)-ubuntu-26.04-amd64.ova
.PHONY: all init validate build build-qemu package checksum clean help
all: build package checksum
init:
cd $(PACKER_DIR) && packer init ubuntu-2604.pkr.hcl
validate: init
cd $(PACKER_DIR) && packer validate \
-var "vm_version=$(VERSION)" \
-var-file=variables.pkrvars.hcl \
ubuntu-2604.pkr.hcl
@echo "✓ Template valid"
build: init
@echo "==> Building zROC OVA v$(VERSION) with VMware builder"
cd $(PACKER_DIR) && PACKER_LOG=1 packer build \
-var "vm_version=$(VERSION)" \
-var "headless=true" \
-var-file=variables.pkrvars.hcl \
ubuntu-2604.pkr.hcl
@echo "✓ Build complete"
build-qemu: init
@echo "==> Building zROC image v$(VERSION) with QEMU builder"
cd $(PACKER_DIR) && PACKER_LOG=1 packer build \
-only="qemu.ubuntu2604" \
-var "vm_version=$(VERSION)" \
-var-file=variables.pkrvars.hcl \
ubuntu-2604.pkr.hcl
package:
@echo "==> Packaging OVF to OVA"
@OVF=$$(find $(OUTPUT_DIR)/vmware -name "*.ovf" | head -1); \
if [ -z "$$OVF" ]; then echo "No OVF found in $(OUTPUT_DIR)/vmware"; exit 1; fi; \
ovftool --compress=9 "$$OVF" "$(OUTPUT_DIR)/$(OVA_NAME)"
@echo "✓ OVA: $(OUTPUT_DIR)/$(OVA_NAME)"
checksum:
@cd $(OUTPUT_DIR) && sha256sum $(OVA_NAME) > $(OVA_NAME).sha256
@echo "✓ Checksum: $(OUTPUT_DIR)/$(OVA_NAME).sha256"
@cat $(OUTPUT_DIR)/$(OVA_NAME).sha256
verify:
@cd $(OUTPUT_DIR) && sha256sum -c $(OVA_NAME).sha256
clean:
rm -rf $(OUTPUT_DIR)
@echo "✓ Output directory cleaned"
help:
@echo ""
@echo " zroc-ova build targets"
@echo " ──────────────────────────────────────────"
@grep -E '^## ' Makefile | sed 's/## / make /'
@echo ""
@echo " VERSION=$(VERSION) (override: make build VERSION=1.1.0)"
@echo ""
+47
View File
@@ -0,0 +1,47 @@
# zroc-ova — zROC Appliance Builder
Packer build definitions and provisioner scripts for the **zROC Ubuntu 24.04 LTS OVA appliance**.
## What you get
A 100 GB thin-provisioned VMware OVA containing:
- Ubuntu Server 24.04 LTS
- Docker Engine + Compose plugin
- Full zROC stack (cloned from recklessop/zroc)
- Interactive first-boot setup wizard (`zroc-setup`)
- UFW firewall pre-configured (22, 80, 443, 3000)
- VMware guest tools (`open-vm-tools`)
- Automatic security patches (`unattended-upgrades`)
## Build
```bash
git clone https://github.com/recklessop/zroc-ova.git
cd zroc-ova
make init
make validate
make build VERSION=1.0.0
make package VERSION=1.0.0
make checksum VERSION=1.0.0
```
## Deploy
1. Import the OVA into vSphere
2. Allocate: 4 vCPU, 8 GB RAM, 100 GB thin datastore
3. Power on — setup wizard launches automatically
4. Follow the 6-step wizard
5. Access: `https://<appliance-ip>`
## VM Requirements
| | Minimum | Recommended |
|---|---|---|
| vCPU | 2 | 4 |
| RAM | 6 GB | 8 GB |
| Disk | 100 GB thin | 100 GB thin |
| vSphere | 7.0+ | 8.x |
## License
Apache 2.0
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env bash
# /usr/local/bin/zroc-setup
# Interactive first-boot configuration wizard for the zROC appliance.
set -euo pipefail
INSTALL_DIR=/opt/zroc
ENV_FILE="$INSTALL_DIR/.env"
CERTS_DIR="$INSTALL_DIR/certs"
CYAN='\033[0;36m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
RED='\033[0;31m'; BOLD='\033[1m'; RESET='\033[0m'
header() { echo -e "\n${CYAN}${BOLD}$*${RESET}"; }
ok() { echo -e "${GREEN}✓ $*${RESET}"; }
warn() { echo -e "${YELLOW}⚠ $*${RESET}"; }
err() { echo -e "${RED}✗ $*${RESET}"; }
step() { echo -e "\n${BOLD}Step $*${RESET}"; echo "$(printf '─%.0s' {1..55})"; }
clear
echo -e "${CYAN}"
cat << 'BANNER'
███████╗██████╗ ██████╗ ██████╗
╚══███╔╝██╔══██╗██╔═══██╗██╔════╝
███╔╝ ██████╔╝██║ ██║██║
███╔╝ ██╔══██╗██║ ██║██║
███████╗██║ ██║╚██████╔╝╚██████╗
╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═════╝
Setup Wizard — Zerto Resiliency Observation Console
BANNER
echo -e "${RESET}"
# Step 1: Network
step "1/6 Network Configuration"
CURRENT_IP=$(hostname -I | awk '{print $1}')
echo "Current IP: ${BOLD}$CURRENT_IP${RESET} (DHCP)"
read -rp "Keep DHCP? [Y/n]: " NET_CHOICE
NET_CHOICE="${NET_CHOICE:-Y}"
PUBLIC_URL="https://$CURRENT_IP"
ok "Using $CURRENT_IP"
# Step 2: TLS
step "2/6 HTTPS / TLS Certificate"
echo "Using self-signed certificate (default)"
TLS_MODE="internal"
ok "Self-signed certificate will be generated by Caddy"
# Step 3: Admin password
step "3/6 zROC Admin Account"
while true; do
read -rsp "Admin password (min 12 chars): " ADMIN_PASS; echo
read -rsp "Confirm password: " ADMIN_PASS2; echo
if [[ "$ADMIN_PASS" != "$ADMIN_PASS2" ]]; then err "Passwords do not match.";
elif [[ ${#ADMIN_PASS} -lt 12 ]]; then err "Password must be at least 12 characters.";
else ok "Admin password set"; break; fi
done
# Step 4: ZVM Site 1
step "4/6 Zerto ZVM Configuration — Site 1"
read -rp "ZVM Hostname or IP: " ZVM_HOST
read -rp "ZVM Username [admin]: " ZVM_USER; ZVM_USER="${ZVM_USER:-admin}"
read -rsp "ZVM Password: " ZVM_PASS; echo
read -rp "vCenter Hostname (optional): " VCENTER_HOST
# Step 5: Second site
step "5/6 Second ZVM Site (optional)"
read -rp "Monitor a second site? [y/N]: " SITE2; SITE2="${SITE2:-N}"
# Step 6: Enterprise IdP
step "6/6 Enterprise Identity Provider (optional)"
echo "Using local Authentik accounts (default)"
# Generate secrets
SESSION_SECRET=$(openssl rand -hex 32)
AUTHENTIK_PG_PASS=$(openssl rand -hex 24)
AUTHENTIK_SECRET_KEY=$(openssl rand -hex 48)
OIDC_CLIENT_ID="zroc-dashboard"
OIDC_CLIENT_SECRET=$(openssl rand -hex 32)
# Write .env
cat > "$ENV_FILE" << EOF
PUBLIC_URL=$PUBLIC_URL
ZVM_HOST=$ZVM_HOST
ZVM_USERNAME=$ZVM_USER
ZVM_PASSWORD=$ZVM_PASS
VCENTER_HOST=${VCENTER_HOST:-}
SESSION_SECRET=$SESSION_SECRET
AUTHENTIK_PG_PASS=$AUTHENTIK_PG_PASS
AUTHENTIK_SECRET_KEY=$AUTHENTIK_SECRET_KEY
AUTHENTIK_CLIENT_ID=$OIDC_CLIENT_ID
AUTHENTIK_CLIENT_SECRET=$OIDC_CLIENT_SECRET
ZROC_OIDC_CLIENT_ID=$OIDC_CLIENT_ID
ZROC_OIDC_CLIENT_SECRET=$OIDC_CLIENT_SECRET
ZROC_PUBLIC_URL=$PUBLIC_URL
AUTHENTIK_ADMIN_TOKEN=PENDING_FIRST_START
GRAFANA_PASSWORD=$ADMIN_PASS
PROMETHEUS_URL=http://prometheus:9090
EOF
chmod 600 "$ENV_FILE"
ok ".env written to $ENV_FILE"
# Start services
echo "Starting zROC services..."
cd "$INSTALL_DIR"
docker compose up -d 2>&1 | tail -20
systemctl disable zroc-firstboot.service 2>/dev/null || true
echo -e "${GREEN}${BOLD}"
echo " ✅ zROC is ready!"
echo " Dashboard: $PUBLIC_URL"
echo -e "${RESET}"
+2
View File
@@ -0,0 +1,2 @@
instance-id: zroc-appliance-build
local-hostname: zroc-appliance
+82
View File
@@ -0,0 +1,82 @@
#cloud-config
autoinstall:
version: 1
locale: en_US.UTF-8
keyboard:
layout: us
source:
id: ubuntu-server-minimal
storage:
layout:
name: direct
config:
- type: disk
id: disk0
match:
size: largest
ptable: gpt
wipe: superblock-recursive
preserve: false
grub_device: true
- type: partition
id: part-efi
device: disk0
size: 512M
flag: boot
number: 1
preserve: false
- type: format
id: fmt-efi
volume: part-efi
fstype: fat32
preserve: false
- type: partition
id: part-root
device: disk0
size: -1
number: 2
preserve: false
- type: format
id: fmt-root
volume: part-root
fstype: ext4
preserve: false
- type: mount
id: mnt-root
device: fmt-root
path: /
- type: mount
id: mnt-efi
device: fmt-efi
path: /boot/efi
identity:
hostname: zroc-appliance
username: zroc
password: "$6$rounds=4096$packer$xKDMK6dLB2.8PZnJnXGpYQ9o0CWbEe4s7T5JY3bVq1ZQ2RQ6y7dAjH4wqpVLBkHPHU/CuW7.8SsLQ6TYe1"
ssh:
install-server: true
allow-pw: true
packages:
- curl
- wget
- git
- vim
- htop
- net-tools
- open-vm-tools
- ca-certificates
- gnupg
- lsb-release
- unattended-upgrades
- apt-transport-https
late-commands:
- echo 'zroc ALL=(ALL) NOPASSWD:ALL' > /target/etc/sudoers.d/zroc-packer
- chmod 440 /target/etc/sudoers.d/zroc-packer
- echo 'net.ipv6.conf.all.disable_ipv6 = 1' >> /target/etc/sysctl.d/99-zroc.conf
+134
View File
@@ -0,0 +1,134 @@
packer {
required_version = ">= 1.10.0"
required_plugins {
qemu = {
source = "github.com/hashicorp/qemu"
version = "~> 1.0"
}
}
}
variable "ubuntu_iso_url" {
type = string
default = "https://releases.ubuntu.com/24.04/ubuntu-24.04.4-live-server-amd64.iso"
}
variable "ubuntu_iso_checksum" {
type = string
default = "sha256:e907d92eeec9df64163a7e454cbc8d7755e8ddc7ed42f99dbc80c40f1a138433"
}
variable "vm_name" {
type = string
default = "zroc-appliance"
}
variable "vm_version" {
type = string
default = "1.0.0"
}
variable "disk_size_mb" {
type = number
default = 102400
}
variable "memory_mb" {
type = number
default = 8192
}
variable "cpus" {
type = number
default = 4
}
variable "output_dir" {
type = string
default = "../output"
}
source "qemu" "ubuntu2404" {
vm_name = "${var.vm_name}-${var.vm_version}"
iso_url = var.ubuntu_iso_url
iso_checksum = var.ubuntu_iso_checksum
disk_size = "${var.disk_size_mb}M"
disk_interface = "virtio"
format = "qcow2"
memory = var.memory_mb
cpus = var.cpus
accelerator = "kvm"
headless = true
http_directory = "http"
http_port_min = 8100
http_port_max = 8199
boot_wait = "5s"
boot_command = [
"e<wait>",
"<down><down><down><end>",
" autoinstall ds=nocloud-net;seedfrom=http://{{.HTTPIP}}:{{.HTTPPort}}/",
"<f10><wait60s>",
]
ssh_username = "zroc"
ssh_password = "zroc-setup-temp"
ssh_timeout = "45m"
shutdown_command = "echo 'zroc-setup-temp' | sudo -S shutdown -P now"
output_directory = "${var.output_dir}/qemu"
}
build {
name = "zroc-appliance"
sources = ["source.qemu.ubuntu2404"]
# Copy overlay files (setup wizard binary, etc.) into the VM
provisioner "file" {
source = "../overlays/"
destination = "/tmp/overlays/"
}
provisioner "shell" {
script = "../scripts/00-base.sh"
execute_command = "echo 'zroc-setup-temp' | sudo -S bash {{.Path}}"
expect_disconnect = true
}
provisioner "shell" {
script = "../scripts/01-docker.sh"
execute_command = "echo 'zroc-setup-temp' | sudo -S bash {{.Path}}"
pause_before = "15s"
}
provisioner "shell" {
script = "../scripts/02-zroc.sh"
execute_command = "echo 'zroc-setup-temp' | sudo -S bash {{.Path}}"
}
provisioner "shell" {
script = "../scripts/03-setup-wizard.sh"
execute_command = "echo 'zroc-setup-temp' | sudo -S bash {{.Path}}"
}
provisioner "shell" {
script = "../scripts/04-systemd-service.sh"
execute_command = "echo 'zroc-setup-temp' | sudo -S bash {{.Path}}"
}
provisioner "shell" {
script = "../scripts/05-cleanup.sh"
execute_command = "echo 'zroc-setup-temp' | sudo -S bash {{.Path}}"
}
# Convert qcow2 → VMDK → OVA (no ovftool required)
post-processor "shell-local" {
inline = [
"bash ../scripts/qcow2-to-ova.sh ${var.output_dir}/qemu/${var.vm_name}-${var.vm_version} ${var.output_dir}/${var.vm_name}-${var.vm_version}-ubuntu-24.04-amd64.ova ${var.vm_name} ${var.vm_version}",
]
}
# Produce a KVM/libvirt/Proxmox-compatible qcow2 artifact
post-processor "shell-local" {
inline = [
"bash ../scripts/qcow2-to-kvm.sh ${var.output_dir}/qemu/${var.vm_name}-${var.vm_version} ${var.output_dir}/${var.vm_name}-${var.vm_version}-ubuntu-24.04-amd64.qcow2",
]
}
}
+11
View File
@@ -0,0 +1,11 @@
# zroc-ova/packer/variables.pkrvars.hcl
vm_version = "1.0.0"
ubuntu_iso_url = "https://releases.ubuntu.com/24.04/ubuntu-24.04.4-live-server-amd64.iso"
ubuntu_iso_checksum = "sha256:e907d92eeec9df64163a7e454cbc8d7755e8ddc7ed42f99dbc80c40f1a138433"
memory_mb = 8192
cpus = 4
disk_size_mb = 102400
output_dir = "../output"
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# zroc-ova/scripts/00-base.sh
set -euo pipefail
echo "==> [00-base] Configuring base system"
while fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1; do sleep 2; done
export DEBIAN_FRONTEND=noninteractive
apt-get update -y
apt-get upgrade -y
apt-get dist-upgrade -y
timedatectl set-timezone UTC
cat > /etc/apt/apt.conf.d/50unattended-upgrades << 'EOF'
Unattended-Upgrade::Allowed-Origins {
"${distro_id}:${distro_codename}-security";
};
Unattended-Upgrade::AutoFixInterruptedDpkg "true";
Unattended-Upgrade::MinimalSteps "true";
Unattended-Upgrade::Remove-Unused-Dependencies "true";
Unattended-Upgrade::Automatic-Reboot "false";
EOF
cat >> /etc/sysctl.d/99-zroc.conf << 'EOF'
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.all.send_redirects = 0
net.ipv4.tcp_syncookies = 1
fs.suid_dumpable = 0
kernel.core_pattern = |/bin/false
EOF
sysctl --system
sed -i 's/#PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config
sed -i 's/#PasswordAuthentication.*/PasswordAuthentication yes/' /etc/ssh/sshd_config
apt-get install -y ufw
ufw --force reset
ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp comment 'SSH'
ufw allow 80/tcp comment 'HTTP redirect'
ufw allow 443/tcp comment 'HTTPS — zROC dashboard'
ufw allow 3000/tcp comment 'Grafana (optional direct access)'
ufw --force enable
echo "==> [00-base] Done"
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# zroc-ova/scripts/01-docker.sh
set -euo pipefail
echo "==> [01-docker] Installing Docker Engine"
export DEBIAN_FRONTEND=noninteractive
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
-o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
https://download.docker.com/linux/ubuntu \
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
> /etc/apt/sources.list.d/docker.list
apt-get update -y
apt-get install -y \
docker-ce \
docker-ce-cli \
containerd.io \
docker-buildx-plugin \
docker-compose-plugin
usermod -aG docker zroc
systemctl enable docker
systemctl start docker
docker --version
docker compose version
cat > /etc/docker/daemon.json << 'EOF'
{
"log-driver": "json-file",
"log-opts": {
"max-size": "50m",
"max-file": "3"
},
"storage-driver": "overlay2"
}
EOF
systemctl restart docker
echo "==> [01-docker] Done"
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# zroc-ova/scripts/02-zroc.sh
set -euo pipefail
echo "==> [02-zroc] Setting up zROC installation"
INSTALL_DIR=/opt/zroc
ZROC_REPO="https://github.com/recklessop/zroc.git"
git clone --depth=1 "$ZROC_REPO" "$INSTALL_DIR"
# Ensure expected directories exist
mkdir -p \
"$INSTALL_DIR/certs" \
"$INSTALL_DIR/zvmexporter" \
"$INSTALL_DIR/data"
cd "$INSTALL_DIR"
# Pre-pull all container images into the OVA image layer so first-boot is fast.
# Failures are non-fatal — any missing images will be pulled on first docker compose up.
echo "==> [02-zroc] Pre-pulling container images (this may take a while)…"
docker compose pull \
caddy \
zroc-ui \
authentik-postgresql \
authentik-redis \
authentik-server \
authentik-worker \
zertoexporter \
zroc-prometheus \
grafana \
watchtower \
|| echo "[02-zroc] Warning: some images could not be pre-pulled — they will pull on first start"
chown -R zroc:zroc "$INSTALL_DIR"
echo "==> [02-zroc] Installation directory: $INSTALL_DIR"
echo "==> [02-zroc] Done"
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
# zroc-ova/scripts/03-setup-wizard.sh
set -euo pipefail
echo "==> [03-setup-wizard] Installing setup wizard"
# The Packer file provisioner copies overlays/ to /tmp/overlays/
# Mirror the full directory tree into place
cp -r /tmp/overlays/usr /
chmod 0755 /usr/local/bin/zroc-setup
cat > /etc/systemd/system/zroc-firstboot.service << 'EOF'
[Unit]
Description=zROC First-Boot Setup Wizard
After=network-online.target
Wants=network-online.target
ConditionPathExists=!/opt/zroc/.env
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/bin/zroc-setup
StandardInput=tty
TTYPath=/dev/tty1
StandardOutput=journal+console
StandardError=journal+console
TimeoutStartSec=0
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable zroc-firstboot.service
rm -f /etc/sudoers.d/zroc-packer
cat > /etc/sudoers.d/zroc << 'EOF'
zroc ALL=(ALL) NOPASSWD: /usr/bin/docker, /usr/local/bin/zroc-setup, /usr/bin/systemctl restart zroc
EOF
chmod 440 /etc/sudoers.d/zroc
echo "==> [03-setup-wizard] Done"
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env bash
# zroc-ova/scripts/04-systemd-service.sh
set -euo pipefail
echo "==> [04-systemd-service] Installing zroc.service"
cat > /etc/systemd/system/zroc.service << 'EOF'
[Unit]
Description=zROC Observability Stack
Documentation=https://github.com/recklessop/zroc
After=docker.service network-online.target
Requires=docker.service
Wants=network-online.target
[Service]
Type=oneshot
RemainAfterExit=yes
User=zroc
Group=zroc
WorkingDirectory=/opt/zroc
EnvironmentFile=-/opt/zroc/.env
ExecStartPre=/usr/bin/docker compose pull --quiet
ExecStart=/usr/bin/docker compose up -d --remove-orphans
ExecStop=/usr/bin/docker compose down
ExecReload=/usr/bin/docker compose up -d --remove-orphans
TimeoutStartSec=180
TimeoutStopSec=60
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
echo "==> [04-systemd-service] Done"
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# zroc-ova/scripts/05-cleanup.sh
set -euo pipefail
echo "==> [05-cleanup] Cleaning build artefacts"
rm -f /etc/sudoers.d/zroc-packer
apt-get autoremove -y
apt-get autoclean -y
apt-get clean
rm -rf /var/lib/apt/lists/*
journalctl --rotate
journalctl --vacuum-time=1s
find /var/log -type f -name "*.log" -delete
find /var/log -type f -name "*.gz" -delete
truncate -s 0 /var/log/wtmp /var/log/btmp /var/log/lastlog 2>/dev/null || true
unset HISTFILE
rm -f /home/zroc/.bash_history /root/.bash_history
history -c
cloud-init clean --logs 2>/dev/null || true
rm -rf /tmp/* /var/tmp/*
echo "==> [05-cleanup] Zeroing free space (this takes a moment)…"
dd if=/dev/zero of=/ZERO bs=4M status=progress 2>/dev/null || true
rm -f /ZERO
sync
SWAP_DEV=$(swapon --show=NAME --noheadings 2>/dev/null | head -1)
if [[ -n "$SWAP_DEV" ]]; then
swapoff "$SWAP_DEV"
dd if=/dev/zero of="$SWAP_DEV" bs=4M status=progress 2>/dev/null || true
mkswap "$SWAP_DEV"
fi
echo "==> [05-cleanup] Done — image ready for OVA packaging"
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# qcow2-to-kvm.sh — Package a QEMU qcow2 image as a KVM/libvirt/Proxmox artifact.
#
# Usage: qcow2-to-kvm.sh <qemu_output_dir/vm_name> <output.qcow2>
#
# Example:
# qcow2-to-kvm.sh ../output/qemu/zroc-appliance-1.0.0 \
# ../output/zroc-appliance-1.0.0-ubuntu-24.04-amd64.qcow2
set -euo pipefail
QEMU_VM_PATH="$1" # path to qcow2 (without extension) or directory
QCOW2_OUT="$2" # destination .qcow2 file
# ── Locate the source qcow2 ───────────────────────────────────────────────────
if [[ -f "${QEMU_VM_PATH}.qcow2" ]]; then
QCOW2_SRC="${QEMU_VM_PATH}.qcow2"
elif [[ -d "$QEMU_VM_PATH" ]]; then
QCOW2_SRC=$(find "$QEMU_VM_PATH" -name "*.qcow2" | head -1)
else
QCOW2_SRC="$QEMU_VM_PATH"
fi
if [[ -z "$QCOW2_SRC" || ! -f "$QCOW2_SRC" ]]; then
echo "ERROR: could not find qcow2 image at ${QEMU_VM_PATH}" >&2
exit 1
fi
echo "==> [qcow2-to-kvm] Source qcow2: $QCOW2_SRC"
echo "==> [qcow2-to-kvm] Output qcow2: $QCOW2_OUT"
mkdir -p "$(dirname "$QCOW2_OUT")"
# Re-encode with qemu-img to compact/sparsify and ensure compatibility.
# subformat=compressed produces a space-efficient image suitable for distribution.
echo "==> [qcow2-to-kvm] Compacting qcow2 for distribution…"
qemu-img convert \
-f qcow2 \
-O qcow2 \
-o compression_type=zlib,preallocation=off \
"$QCOW2_SRC" \
"$QCOW2_OUT"
SIZE=$(du -sh "$QCOW2_OUT" | cut -f1)
SHA=$(sha256sum "$QCOW2_OUT" | awk '{print $1}')
echo "==> [qcow2-to-kvm] qcow2 complete: $QCOW2_OUT ($SIZE)"
echo "==> [qcow2-to-kvm] SHA256: $SHA"
echo "$SHA $(basename "$QCOW2_OUT")" > "${QCOW2_OUT}.sha256"
echo "==> [qcow2-to-kvm] Done"
+177
View File
@@ -0,0 +1,177 @@
#!/usr/bin/env bash
# qcow2-to-ova.sh — Convert a QEMU qcow2 disk image to a VMware-compatible OVA
# without requiring ovftool.
#
# Usage: qcow2-to-ova.sh <qemu_output_dir/vm_name> <output.ova> <vm_display_name> <version>
#
# Example:
# qcow2-to-ova.sh ../output/qemu/zroc-appliance-1.0.0 \
# ../output/zroc-appliance-1.0.0-ubuntu-24.04-amd64.ova \
# zroc-appliance 1.0.0
set -euo pipefail
QEMU_VM_PATH="$1" # path to the qcow2 file (without extension) or directory
OVA_OUT="$2" # destination .ova file
VM_NAME="$3" # display name inside the OVF
VM_VERSION="$4" # version string
# ── Locate the qcow2 ──────────────────────────────────────────────────────────
if [[ -f "${QEMU_VM_PATH}.qcow2" ]]; then
QCOW2="${QEMU_VM_PATH}.qcow2"
elif [[ -d "$QEMU_VM_PATH" ]]; then
QCOW2=$(find "$QEMU_VM_PATH" -name "*.qcow2" | head -1)
else
# Packer QEMU plugin writes <vm_name> (no extension) as the output file
QCOW2="$QEMU_VM_PATH"
fi
if [[ -z "$QCOW2" || ! -f "$QCOW2" ]]; then
echo "ERROR: could not find qcow2 image at ${QEMU_VM_PATH}" >&2
exit 1
fi
echo "==> [qcow2-to-ova] Source qcow2: $QCOW2"
echo "==> [qcow2-to-ova] Output OVA: $OVA_OUT"
WORK_DIR=$(mktemp -d)
trap 'rm -rf "$WORK_DIR"' EXIT
VMDK_NAME="${VM_NAME}-disk1.vmdk"
OVF_NAME="${VM_NAME}.ovf"
MF_NAME="${VM_NAME}.mf"
# ── 1. Convert qcow2 → stream-optimised VMDK ─────────────────────────────────
echo "==> [qcow2-to-ova] Converting qcow2 → VMDK (stream-optimised)…"
qemu-img convert \
-f qcow2 \
-O vmdk \
-o subformat=streamOptimized,adapter_type=lsilogic,compat6 \
"$QCOW2" \
"${WORK_DIR}/${VMDK_NAME}"
DISK_SIZE_BYTES=$(qemu-img info --output=json "${WORK_DIR}/${VMDK_NAME}" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['virtual-size'])")
DISK_SIZE_GB=$(( (DISK_SIZE_BYTES + 1073741823) / 1073741824 ))
DISK_FILE_BYTES=$(stat -c%s "${WORK_DIR}/${VMDK_NAME}")
echo "==> [qcow2-to-ova] VMDK: virtual=${DISK_SIZE_GB}GB, file=${DISK_FILE_BYTES} bytes"
# ── 2. Generate OVF descriptor ────────────────────────────────────────────────
echo "==> [qcow2-to-ova] Generating OVF descriptor…"
cat > "${WORK_DIR}/${OVF_NAME}" << OVFEOF
<?xml version="1.0" encoding="UTF-8"?>
<Envelope xmlns="http://schemas.dmtf.org/ovf/envelope/1"
xmlns:cim="http://schemas.dmtf.org/wbem/wscim/1/common"
xmlns:ovf="http://schemas.dmtf.org/ovf/envelope/1"
xmlns:rasd="http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_ResourceAllocationSettingData"
xmlns:vmw="http://www.vmware.com/schema/ovf"
xmlns:vssd="http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_VirtualSystemSettingData"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<References>
<File ovf:href="${VMDK_NAME}" ovf:id="file1" ovf:size="${DISK_FILE_BYTES}"/>
</References>
<DiskSection>
<Info>Virtual disk information</Info>
<Disk ovf:capacity="${DISK_SIZE_GB}" ovf:capacityAllocationUnits="byte * 2^30"
ovf:diskId="vmdisk1" ovf:fileRef="file1"
ovf:format="http://www.vmware.com/interfaces/specifications/vmdk.html#streamOptimized"
ovf:populatedSize="${DISK_FILE_BYTES}"/>
</DiskSection>
<NetworkSection>
<Info>The list of logical networks</Info>
<Network ovf:name="VM Network">
<Description>VM Network</Description>
</Network>
</NetworkSection>
<VirtualSystem ovf:id="${VM_NAME}">
<Info>zROC Observability Console Appliance v${VM_VERSION}</Info>
<Name>${VM_NAME}</Name>
<AnnotationSection>
<Info>A human-readable annotation</Info>
<Annotation>zROC Appliance v${VM_VERSION} — https://github.com/recklessop/zroc</Annotation>
</AnnotationSection>
<OperatingSystemSection ovf:id="94" vmw:osType="ubuntu64Guest">
<Info>The kind of installed guest operating system</Info>
<Description>Ubuntu Linux (64-bit)</Description>
</OperatingSystemSection>
<VirtualHardwareSection>
<Info>Virtual hardware requirements</Info>
<System>
<vssd:ElementName>Virtual Hardware Family</vssd:ElementName>
<vssd:InstanceID>0</vssd:InstanceID>
<vssd:VirtualSystemIdentifier>${VM_NAME}</vssd:VirtualSystemIdentifier>
<vssd:VirtualSystemType>vmx-19</vssd:VirtualSystemType>
</System>
<Item>
<rasd:AllocationUnits>hertz * 10^6</rasd:AllocationUnits>
<rasd:Description>Number of virtual CPUs</rasd:Description>
<rasd:ElementName>4 virtual CPU(s)</rasd:ElementName>
<rasd:InstanceID>1</rasd:InstanceID>
<rasd:ResourceType>3</rasd:ResourceType>
<rasd:VirtualQuantity>4</rasd:VirtualQuantity>
</Item>
<Item>
<rasd:AllocationUnits>byte * 2^20</rasd:AllocationUnits>
<rasd:Description>Memory Size</rasd:Description>
<rasd:ElementName>8192 MB of memory</rasd:ElementName>
<rasd:InstanceID>2</rasd:InstanceID>
<rasd:ResourceType>4</rasd:ResourceType>
<rasd:VirtualQuantity>8192</rasd:VirtualQuantity>
</Item>
<Item>
<rasd:Address>0</rasd:Address>
<rasd:Description>SCSI Controller</rasd:Description>
<rasd:ElementName>SCSI Controller 0</rasd:ElementName>
<rasd:InstanceID>3</rasd:InstanceID>
<rasd:ResourceSubType>lsilogic</rasd:ResourceSubType>
<rasd:ResourceType>6</rasd:ResourceType>
</Item>
<Item>
<rasd:AddressOnParent>0</rasd:AddressOnParent>
<rasd:ElementName>Hard Disk 1</rasd:ElementName>
<rasd:HostResource>ovf:/disk/vmdisk1</rasd:HostResource>
<rasd:InstanceID>4</rasd:InstanceID>
<rasd:Parent>3</rasd:Parent>
<rasd:ResourceType>17</rasd:ResourceType>
</Item>
<Item>
<rasd:AddressOnParent>7</rasd:AddressOnParent>
<rasd:AutomaticAllocation>true</rasd:AutomaticAllocation>
<rasd:Connection>VM Network</rasd:Connection>
<rasd:Description>VmxNet3 ethernet adapter</rasd:Description>
<rasd:ElementName>Network Adapter 1</rasd:ElementName>
<rasd:InstanceID>5</rasd:InstanceID>
<rasd:ResourceSubType>VmxNet3</rasd:ResourceSubType>
<rasd:ResourceType>10</rasd:ResourceType>
</Item>
</VirtualHardwareSection>
</VirtualSystem>
</Envelope>
OVFEOF
# ── 3. Generate manifest (.mf) with SHA256 checksums ─────────────────────────
echo "==> [qcow2-to-ova] Generating manifest…"
OVF_SHA=$(sha256sum "${WORK_DIR}/${OVF_NAME}" | awk '{print $1}')
VMDK_SHA=$(sha256sum "${WORK_DIR}/${VMDK_NAME}" | awk '{print $1}')
cat > "${WORK_DIR}/${MF_NAME}" << MFEOF
SHA256(${OVF_NAME})= ${OVF_SHA}
SHA256(${VMDK_NAME})= ${VMDK_SHA}
MFEOF
# ── 4. Package as OVA (tar, OVF first per spec) ───────────────────────────────
echo "==> [qcow2-to-ova] Packaging OVA…"
mkdir -p "$(dirname "$OVA_OUT")"
tar -C "$WORK_DIR" \
--format=ustar \
-cf "$OVA_OUT" \
"${OVF_NAME}" \
"${VMDK_NAME}" \
"${MF_NAME}"
OVA_SIZE=$(du -sh "$OVA_OUT" | cut -f1)
OVA_SHA=$(sha256sum "$OVA_OUT" | awk '{print $1}')
echo "==> [qcow2-to-ova] OVA complete: $OVA_OUT ($OVA_SIZE)"
echo "==> [qcow2-to-ova] SHA256: $OVA_SHA"
echo "$OVA_SHA $(basename "$OVA_OUT")" > "${OVA_OUT}.sha256"
echo "==> [qcow2-to-ova] Done"
+60
View File
@@ -0,0 +1,60 @@
# ─────────────────────────────────────────────────────────────────────────────
# zROC Environment Variables
# Copy to .env and fill in your values.
# Generated automatically by: zroc-setup (first-boot wizard)
# ─────────────────────────────────────────────────────────────────────────────
# ── Zerto ZVM — Site 1 ────────────────────────────────────────────────────────
ZVM_HOST=192.168.50.60
ZVM_USERNAME=admin
ZVM_PASSWORD=changeme
# Optional — needed for VRA CPU/memory metrics
VCENTER_HOST=vcenter.local
VCENTER_USER=administrator@vsphere.local
VCENTER_PASSWORD=changeme
# ── Zerto ZVM — Site 2 (uncomment to enable) ─────────────────────────────────
# ZVM2_HOST=192.168.60.60
# ZVM2_USERNAME=admin
# ZVM2_PASSWORD=changeme
# VCENTER2_HOST=vcenter2.local
# VCENTER2_USER=administrator@vsphere.local
# VCENTER2_PASSWORD=changeme
# ── zROC UI ───────────────────────────────────────────────────────────────────
# Public-facing URL of the appliance (used for OIDC redirect URIs)
PUBLIC_URL=https://192.168.50.100
# Session secret — generate with: openssl rand -hex 32
SESSION_SECRET=REPLACE_WITH_RANDOM_SECRET
# ── Authentik ─────────────────────────────────────────────────────────────────
# PostgreSQL password — generate with: openssl rand -hex 24
AUTHENTIK_PG_PASS=REPLACE_WITH_PG_PASSWORD
# Authentik secret key — generate with: openssl rand -hex 48
AUTHENTIK_SECRET_KEY=REPLACE_WITH_AUTHENTIK_SECRET
# OIDC client credentials (generated by Authentik blueprint, copied here by setup wizard)
AUTHENTIK_CLIENT_ID=zroc-dashboard
AUTHENTIK_CLIENT_SECRET=REPLACE_AFTER_BLUEPRINT_RUNS
ZROC_OIDC_CLIENT_ID=zroc-dashboard
ZROC_OIDC_CLIENT_SECRET=REPLACE_AFTER_BLUEPRINT_RUNS
# Admin API token (generated by Authentik blueprint, retrieved by setup wizard)
AUTHENTIK_ADMIN_TOKEN=REPLACE_AFTER_BLUEPRINT_RUNS
# Passed into blueprint to set redirect URI
ZROC_PUBLIC_URL=https://192.168.50.100
# ── Grafana ───────────────────────────────────────────────────────────────────
GRAFANA_PASSWORD=zertodata
# Optional: Grafana OIDC (integrates Grafana login with Authentik)
GRAFANA_OIDC_ENABLED=false
# GRAFANA_CLIENT_ID=grafana
# GRAFANA_CLIENT_SECRET=
# ── Prometheus ────────────────────────────────────────────────────────────────
# Internal only — not directly accessible from outside the stack
PROMETHEUS_URL=http://prometheus:9090
+64
View File
@@ -0,0 +1,64 @@
name: Build & Publish Docker Image
on:
push:
tags:
- 'v[0-9]+.[0-9]+.[0-9]+'
env:
IMAGE: recklessop/zroc-ui
CONTEXT: ./zroc-ui
jobs:
build-and-push:
name: Build & Push
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract version from tag
id: meta
run: |
VERSION=${GITHUB_REF_NAME#v}
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "tag=$GITHUB_REF_NAME" >> $GITHUB_OUTPUT
- name: Build and push
uses: docker/build-push-action@v5
with:
context: ${{ env.CONTEXT }}
platforms: linux/amd64,linux/arm64
push: true
tags: |
${{ env.IMAGE }}:${{ steps.meta.outputs.version }}
${{ env.IMAGE }}:stable
${{ env.IMAGE }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
BUILD_VERSION=${{ steps.meta.outputs.version }}
BUILD_DATE=${{ github.event.repository.updated_at }}
GIT_SHA=${{ github.sha }}
- name: Update Docker Hub description
uses: peter-evans/dockerhub-description@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
repository: ${{ env.IMAGE }}
readme-filepath: ./zroc-ui/DOCKER_README.md
short-description: "zROC UI — Zerto Resiliency Observation Console frontend"
+65
View File
@@ -0,0 +1,65 @@
# zROC UI
**Zerto Resiliency Observation Console** — a purpose-built observability frontend for Zerto that replaces Zerto Analytics with a self-hosted, always-on dashboard.
## What it does
- **NOC Dashboard** — VPG health heat grid, site cards, RPO status at a glance
- **VPG Monitor** — per-VPG RPO history, throughput/IOPS charts, journal health, VM breakdown
- **VM Protection** — per-VM drill-down with RPO trends, journal gauges, encryption trends
- **VRA Infrastructure** — CPU/memory usage, workload counts, volume capacity
- **Encryption Detection** — near real-time ransomware anomaly detection
- **Storage** — datastore capacity with Zerto-attributed journal/scratch/recovery breakdown
- **User Management** — full CRUD with 2FA QR code setup, group management, enterprise IdP integration
## Authentication
This image includes a Node.js Express backend that handles:
- OIDC login via **Authentik** (bundled in the full stack)
- 2FA enforcement (TOTP with QR codes)
- Enterprise IdP integration (Azure AD, Okta, SAML, LDAP)
- Rate-limited login, `httpOnly` session cookies, zero Prometheus exposure to browser
## Quick start — full stack
```bash
git clone https://github.com/recklessop/zroc.git
cd zroc
cp .env.example .env
# Edit .env with your ZVM credentials and secrets
docker compose up -d
```
Then visit `https://<your-host>` — on first access run through the setup wizard.
## Environment variables
|Variable |Required|Description |
|-------------------------|--------|-------------------------------------------------------|
|`PROMETHEUS_URL` |No |Prometheus endpoint (default: `http://prometheus:9090`)|
|`AUTHENTIK_URL` |Yes |Authentik server URL |
|`AUTHENTIK_CLIENT_ID` |Yes |OIDC client ID registered in Authentik |
|`AUTHENTIK_CLIENT_SECRET`|Yes |OIDC client secret |
|`AUTHENTIK_ADMIN_TOKEN` |Yes |Authentik API token for user management |
|`PUBLIC_URL` |Yes |Public HTTPS URL of the appliance |
|`SESSION_SECRET` |Yes |Random secret for session signing (min 32 chars) |
|`AUTHENTIK_ADMIN_GROUP` |No |Group name for admin role (default: `zroc-admins`) |
|`AUTHENTIK_VIEWER_GROUP` |No |Group name for viewer role (default: `zroc-viewers`) |
## Image tags
|Tag |Description |
|--------|-----------------------------------------|
|`stable`|Latest stable release — use in production|
|`latest`|Alias for stable |
|`1.x.x` |Pinned semantic version |
## Source
- UI & backend: [github.com/recklessop/zroc](https://github.com/recklessop/zroc)
- Zerto Exporter: [github.com/recklessop/Zerto_Exporter](https://github.com/recklessop/Zerto_Exporter)
- OVA Appliance: [github.com/recklessop/zroc-ova](https://github.com/recklessop/zroc-ova)
## License
Apache 2.0 — open source, not officially supported by Zerto/HPE.
+42
View File
@@ -0,0 +1,42 @@
# Stage 1: Build the React SPA
FROM node:20-alpine AS frontend-builder
WORKDIR /build/frontend
COPY package.json package-lock.json* ./
RUN npm ci --prefer-offline
COPY index.html vite.config.js tailwind.config.js postcss.config.js ./
COPY src/ ./src/
RUN npm run build
# Stage 2: Install backend production dependencies
FROM node:20-alpine AS backend-builder
WORKDIR /build/backend
COPY backend/package.json backend/package-lock.json* ./
RUN npm ci --omit=dev --prefer-offline
# Stage 3: Production image
FROM node:20-alpine AS production
RUN addgroup -S zroc && adduser -S zroc -G zroc
WORKDIR /app
COPY backend/ ./backend/
COPY --from=backend-builder /build/backend/node_modules ./backend/node_modules
COPY --from=frontend-builder /build/frontend/dist ./dist
RUN mkdir -p /app/data && chown zroc:zroc /app/data
VOLUME ["/app/data"]
USER zroc
EXPOSE 3001
HEALTHCHECK --interval=15s --timeout=5s --start-period=30s --retries=3 \
CMD wget -qO- http://localhost:3001/api/health || exit 1
CMD ["node", "backend/server.js"]
+50
View File
@@ -0,0 +1,50 @@
# zROC — Zerto Resiliency Observation Console
> A self-hosted, purpose-built observability dashboard for Zerto — replaces Zerto Analytics with a fast, always-on UI.
## Overview
zROC is a Docker Compose stack that collects Zerto metrics via the ZVM REST API and presents them in a polished web interface.
| Component | Role |
|---|---|
| Zerto Exporter | Scrapes ZVM & vCenter APIs, exposes Prometheus metrics |
| Prometheus | Stores metrics with 30-day retention |
| zROC UI | React + Express — authenticated dashboard |
| Authentik | Identity provider — login, 2FA, SSO, user management |
| Caddy | TLS termination |
| Grafana | Legacy dashboards |
## Quick Start
```bash
git clone https://github.com/recklessop/zroc.git
cd zroc
cp .env.example .env
# Edit .env with your ZVM credentials and secrets
docker compose up -d
```
## Pages
| Page | Path | Description |
|---|---|---|
| Overview | `/` | NOC dashboard — health bar, site cards, VPG heat grid |
| VPG Monitor | `/vpgs` | Per-VPG: RPO gauge, throughput/IOPS charts, journal health |
| VM Protection | `/vms` | All VMs — RPO, journal usage, encryption %, drill-down |
| VRA Infrastructure | `/vras` | CPU/memory usage, protected/recovery workload |
| Encryption Detection | `/encryption` | Encryption % per VM, anomaly table |
| Storage | `/storage` | Datastore capacity with Zerto usage breakdown |
| User Management | `/settings/users` | Admin only — full CRUD + 2FA QR setup |
## Architecture
```
Browser → Caddy (TLS) → zroc-ui (Express + React SPA)
→ Authentik (OIDC auth)
zroc-ui → Prometheus → Zerto Exporter → ZVM API
```
## License
Apache 2.0
@@ -0,0 +1,110 @@
version: 1
metadata:
name: zROC Initial Configuration
labels:
blueprints.goauthentik.io/instantiate: "true"
entries:
- model: authentik_core.group
state: present
identifiers:
name: zroc-admins
attrs:
name: zroc-admins
- model: authentik_core.group
state: present
identifiers:
name: zroc-viewers
attrs:
name: zroc-viewers
- model: authentik_providers_oauth2.scopemapping
state: present
identifiers:
managed: goauthentik.io/providers/oauth2/scope-zroc-groups
attrs:
managed: goauthentik.io/providers/oauth2/scope-zroc-groups
name: "zROC Groups Scope"
scope_name: groups
expression: |
return [group.name for group in request.user.ak_groups.all()]
- model: authentik_providers_oauth2.oauth2provider
state: present
identifiers:
name: zROC Dashboard Provider
attrs:
name: zROC Dashboard Provider
client_type: confidential
client_id: !Env ZROC_OIDC_CLIENT_ID
client_secret: !Env ZROC_OIDC_CLIENT_SECRET
authorization_flow: !Find [authentik_flows.flow, [name, default-provider-authorization-implicit-consent]]
redirect_uris: !Env ZROC_PUBLIC_URL
signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]]
access_code_validity: minutes=1
access_token_validity: hours=1
refresh_token_validity: days=30
property_mappings:
- !Find [authentik_providers_oauth2.scopemapping, [scope_name, openid]]
- !Find [authentik_providers_oauth2.scopemapping, [scope_name, email]]
- !Find [authentik_providers_oauth2.scopemapping, [scope_name, profile]]
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-zroc-groups]]
- model: authentik_core.application
state: present
identifiers:
slug: zroc-dashboard
attrs:
name: zROC Dashboard
slug: zroc-dashboard
provider: !Find [authentik_providers_oauth2.oauth2provider, [name, zROC Dashboard Provider]]
meta_launch_url: !Env ZROC_PUBLIC_URL
meta_description: Zerto Resiliency Observation Console
policy_engine_mode: any
- model: authentik_stages_authenticator_validate.authenticatorvalidatestage
state: present
identifiers:
name: zroc-totp-validation
attrs:
name: zroc-totp-validation
device_classes:
- totp
- static
not_configured_action: configure
configuration_stages:
- !Find [authentik_stages_authenticator_totp.authenticatortotpstage, [name, default-authenticator-totp-setup]]
- model: authentik_flows.flowstagebinding
state: present
identifiers:
target: !Find [authentik_flows.flow, [slug, default-authentication-flow]]
stage: !Find [authentik_stages_authenticator_validate.authenticatorvalidatestage, [name, zroc-totp-validation]]
attrs:
target: !Find [authentik_flows.flow, [slug, default-authentication-flow]]
stage: !Find [authentik_stages_authenticator_validate.authenticatorvalidatestage, [name, zroc-totp-validation]]
order: 30
evaluate_on_plan: true
re_evaluate_policies: false
- model: authentik_core.user
state: present
identifiers:
username: zroc-service-account
attrs:
username: zroc-service-account
name: zROC Service Account
type: service_account
is_active: true
- model: authentik_core.token
state: present
identifiers:
identifier: zroc-ui-admin-token
attrs:
identifier: zroc-ui-admin-token
user: !Find [authentik_core.user, [username, zroc-service-account]]
intent: api
description: "Used by zROC UI backend for user management"
expiring: false
+148
View File
@@ -0,0 +1,148 @@
// backend/authentik.js
'use strict';
const axios = require('axios');
const QRCode = require('qrcode');
const config = require('./config');
const logger = require('./logger');
const api = axios.create({
baseURL: `${config.authentik_url}/api/v3`,
headers: {
Authorization: `Bearer ${config.authentik_admin_token}`,
'Content-Type': 'application/json',
},
timeout: 10_000,
});
api.interceptors.response.use(
(res) => res,
(err) => {
const status = err.response?.status;
const detail = err.response?.data?.detail || err.message;
logger.error(`[Authentik API] ${err.config?.method?.toUpperCase()} ${err.config?.url}${status}: ${detail}`);
return Promise.reject(err);
}
);
async function listUsers({ search = '', page = 1, pageSize = 50 } = {}) {
const params = { page, page_size: pageSize };
if (search) params.search = search;
const { data } = await api.get('/core/users/', { params });
const totpDevices = await listAllTotpDevices();
const totpByUser = new Map();
for (const d of totpDevices) {
totpByUser.set(d.user, true);
}
const users = data.results.map((u) => ({
id: u.pk,
username: u.username,
name: u.name,
email: u.email,
isActive: u.is_active,
isSuperuser: u.is_superuser,
groups: u.groups_obj?.map((g) => ({ id: g.pk, name: g.name })) ?? [],
avatar: u.avatar,
lastLogin: u.last_login,
dateJoined: u.date_joined,
totpEnrolled: totpByUser.has(u.pk),
type: u.type,
}));
return { users, count: data.count, page, pageSize };
}
async function getUser(userId) {
const { data: u } = await api.get(`/core/users/${userId}/`);
return {
id: u.pk,
username: u.username,
name: u.name,
email: u.email,
isActive: u.is_active,
isSuperuser: u.is_superuser,
groups: u.groups_obj?.map((g) => ({ id: g.pk, name: g.name })) ?? [],
avatar: u.avatar,
lastLogin: u.last_login,
dateJoined: u.date_joined,
type: u.type,
};
}
async function createUser({ username, name, email, isActive = true, groups = [], password }) {
const payload = {
username, name, email, is_active: isActive, groups, type: 'internal',
};
const { data: u } = await api.post('/core/users/', payload);
if (password) { await setPassword(u.pk, password); }
return getUser(u.pk);
}
async function updateUser(userId, { name, email, isActive, groups }) {
const payload = {};
if (name !== undefined) payload.name = name;
if (email !== undefined) payload.email = email;
if (isActive !== undefined) payload.is_active = isActive;
if (groups !== undefined) payload.groups = groups;
await api.patch(`/core/users/${userId}/`, payload);
return getUser(userId);
}
async function deleteUser(userId) {
await api.delete(`/core/users/${userId}/`);
}
async function setPassword(userId, password) {
await api.post(`/core/users/${userId}/set_password/`, { password });
}
async function listGroups({ search = '' } = {}) {
const params = { page_size: 100 };
if (search) params.search = search;
const { data } = await api.get('/core/groups/', { params });
return data.results.map((g) => ({
id: g.pk, name: g.name, userCount: g.num_pk ?? 0,
}));
}
async function listAllTotpDevices() {
const { data } = await api.get('/authenticators/totp/', { params: { page_size: 1000 } });
return data.results;
}
async function revokeTotpForUser(userId) {
const { data } = await api.get('/authenticators/totp/', {
params: { user: userId, page_size: 100 },
});
await Promise.all(data.results.map((d) => api.delete(`/authenticators/totp/${d.pk}/`)));
return data.results.length;
}
async function generateTwoFactorSetupLink(userId) {
await revokeTotpForUser(userId);
const { data } = await api.post(`/core/users/${userId}/recovery/`);
const setupUrl = data.link;
const qrDataUrl = await QRCode.toDataURL(setupUrl, {
width: 280, margin: 2,
color: { dark: '#0ea5e9', light: '#0a0f1e' },
errorCorrectionLevel: 'M',
});
return { setupUrl, qrDataUrl };
}
async function validateAdminToken() {
try {
await api.get('/core/users/me/');
return true;
} catch {
return false;
}
}
module.exports = {
listUsers, getUser, createUser, updateUser, deleteUser, setPassword,
listGroups, revokeTotpForUser, generateTwoFactorSetupLink, validateAdminToken,
};
+41
View File
@@ -0,0 +1,41 @@
// backend/config.js — central configuration with validation
'use strict';
function require_env(name) {
const val = process.env[name];
if (!val) throw new Error(`Required environment variable ${name} is not set`);
return val;
}
function optional_env(name, fallback = '') {
return process.env[name] || fallback;
}
const config = {
port: parseInt(optional_env('PORT', '3001'), 10),
node_env: optional_env('NODE_ENV', 'production'),
is_dev: optional_env('NODE_ENV', 'production') === 'development',
session_secret: optional_env('SESSION_SECRET', 'CHANGE_ME_IN_PRODUCTION_' + Math.random()),
session_max_age_ms: parseInt(optional_env('SESSION_MAX_AGE_HOURS', '24'), 10) * 60 * 60 * 1000,
prometheus_url: optional_env('PROMETHEUS_URL', 'http://prometheus:9090'),
authentik_url: optional_env('AUTHENTIK_URL', 'http://authentik-server:9000'),
authentik_client_id: optional_env('AUTHENTIK_CLIENT_ID', 'zroc-dashboard'),
authentik_client_secret: optional_env('AUTHENTIK_CLIENT_SECRET', ''),
authentik_admin_token: optional_env('AUTHENTIK_ADMIN_TOKEN', ''),
public_url: optional_env('PUBLIC_URL', 'https://localhost:8443'),
admin_group: optional_env('AUTHENTIK_ADMIN_GROUP', 'zroc-admins'),
viewer_group: optional_env('AUTHENTIK_VIEWER_GROUP', 'zroc-viewers'),
redis_url: optional_env('REDIS_URL', ''),
};
if (!config.authentik_client_secret) {
console.warn('[CONFIG] AUTHENTIK_CLIENT_SECRET not set — auth will fail until configured');
}
if (!config.authentik_admin_token) {
console.warn('[CONFIG] AUTHENTIK_ADMIN_TOKEN not set — user management API will be unavailable');
}
if (config.session_secret.startsWith('CHANGE_ME')) {
console.warn('[CONFIG] SESSION_SECRET not set — using random value, sessions will not survive restart');
}
module.exports = config;
+18
View File
@@ -0,0 +1,18 @@
// backend/logger.js
'use strict';
const { createLogger, format, transports } = require('winston');
const config = require('./config');
const logger = createLogger({
level: config.is_dev ? 'debug' : 'info',
format: format.combine(
format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
format.errors({ stack: true }),
config.is_dev
? format.combine(format.colorize(), format.simple())
: format.json()
),
transports: [new transports.Console()],
});
module.exports = logger;
@@ -0,0 +1,28 @@
// backend/middleware/authenticate.js
'use strict';
/**
* Middleware: require an authenticated session.
* If the request has no valid session → 401.
* Attaches req.user = { id, username, name, email, role } for downstream use.
*/
function authenticate(req, res, next) {
if (!req.session?.user) {
return res.status(401).json({ error: 'Unauthorized', code: 'NO_SESSION' });
}
req.user = req.session.user;
next();
}
/**
* Middleware: require admin role.
* Must be used AFTER authenticate().
*/
function requireAdmin(req, res, next) {
if (req.user?.role !== 'admin') {
return res.status(403).json({ error: 'Forbidden', code: 'REQUIRES_ADMIN' });
}
next();
}
module.exports = { authenticate, requireAdmin };
+28
View File
@@ -0,0 +1,28 @@
{
"name": "zroc-ui-backend",
"version": "1.0.0",
"description": "zROC UI backend — auth, Prometheus proxy, Authentik user management",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
},
"dependencies": {
"axios": "^1.7.2",
"connect-redis": "^7.1.1",
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
"express": "^4.19.2",
"express-rate-limit": "^7.3.1",
"express-session": "^1.18.0",
"http-proxy-middleware": "^3.0.0",
"ioredis": "^5.4.1",
"openid-client": "^5.7.0",
"qrcode": "^1.5.4",
"uuid": "^10.0.0",
"winston": "^3.13.0"
},
"devDependencies": {
"nodemon": "^3.1.4"
}
}
+117
View File
@@ -0,0 +1,117 @@
// backend/routes/admin/users.js
'use strict';
const express = require('express');
const { authenticate, requireAdmin } = require('../../middleware/authenticate');
const authentik = require('../../authentik');
const logger = require('../../logger');
const router = express.Router();
router.use(authenticate, requireAdmin);
router.get('/', async (req, res) => {
try {
const { search = '', page = '1', pageSize = '50' } = req.query;
const result = await authentik.listUsers({
search,
page: parseInt(page, 10),
pageSize: parseInt(pageSize, 10),
});
res.json(result);
} catch (err) {
logger.error('[Users] List failed:', err.message);
res.status(502).json({ error: 'Failed to list users', detail: err.message });
}
});
router.get('/:id', async (req, res) => {
try {
const user = await authentik.getUser(req.params.id);
res.json(user);
} catch (err) {
const status = err.response?.status === 404 ? 404 : 502;
res.status(status).json({ error: 'User not found', detail: err.message });
}
});
router.post('/', async (req, res) => {
try {
const { username, name, email, isActive = true, groups = [], password } = req.body;
if (!username || !name || !email) {
return res.status(400).json({ error: 'username, name, and email are required' });
}
const user = await authentik.createUser({ username, name, email, isActive, groups, password });
logger.info(`[Users] ${req.user.username} created user ${username}`);
res.status(201).json(user);
} catch (err) {
const detail = err.response?.data || err.message;
logger.error('[Users] Create failed:', detail);
res.status(err.response?.status === 400 ? 400 : 502).json({ error: 'Failed to create user', detail });
}
});
router.patch('/:id', async (req, res) => {
try {
const { name, email, isActive, groups } = req.body;
const user = await authentik.updateUser(req.params.id, { name, email, isActive, groups });
logger.info(`[Users] ${req.user.username} updated user ${user.username}`);
res.json(user);
} catch (err) {
logger.error('[Users] Update failed:', err.message);
res.status(502).json({ error: 'Failed to update user', detail: err.message });
}
});
router.delete('/:id', async (req, res) => {
try {
const targetId = parseInt(req.params.id, 10);
if (String(targetId) === String(req.user.id) || req.user.username === 'akadmin') {
return res.status(400).json({ error: 'Cannot delete your own account or the akadmin account' });
}
await authentik.deleteUser(targetId);
logger.info(`[Users] ${req.user.username} deleted user ${targetId}`);
res.status(204).send();
} catch (err) {
logger.error('[Users] Delete failed:', err.message);
res.status(502).json({ error: 'Failed to delete user', detail: err.message });
}
});
router.post('/:id/set-password', async (req, res) => {
try {
const { password } = req.body;
if (!password || password.length < 8) {
return res.status(400).json({ error: 'Password must be at least 8 characters' });
}
await authentik.setPassword(req.params.id, password);
logger.info(`[Users] ${req.user.username} reset password for user ${req.params.id}`);
res.json({ success: true });
} catch (err) {
logger.error('[Users] Password reset failed:', err.message);
res.status(502).json({ error: 'Failed to set password', detail: err.message });
}
});
router.post('/:id/setup-2fa', async (req, res) => {
try {
const { setupUrl, qrDataUrl } = await authentik.generateTwoFactorSetupLink(req.params.id);
logger.info(`[Users] ${req.user.username} generated 2FA setup link for user ${req.params.id}`);
res.json({ setupUrl, qrDataUrl });
} catch (err) {
logger.error('[Users] 2FA setup failed:', err.message);
res.status(502).json({ error: 'Failed to generate 2FA setup link', detail: err.message });
}
});
router.get('/meta/groups', async (req, res) => {
try {
const groups = await authentik.listGroups();
res.json(groups);
} catch (err) {
logger.error('[Users] Groups list failed:', err.message);
res.status(502).json({ error: 'Failed to list groups', detail: err.message });
}
});
module.exports = router;
+138
View File
@@ -0,0 +1,138 @@
// backend/routes/auth.js — OIDC login / callback / logout
'use strict';
const express = require('express');
const { Issuer, generators } = require('openid-client');
const config = require('../config');
const logger = require('../logger');
const { authenticate } = require('../middleware/authenticate');
const router = express.Router();
let oidcClient = null;
async function getOidcClient() {
if (oidcClient) return oidcClient;
const issuerUrl = `${config.authentik_url}/application/o/${config.authentik_client_id}/`;
logger.info(`[Auth] Discovering OIDC issuer at ${issuerUrl}`);
const issuer = await Issuer.discover(issuerUrl);
oidcClient = new issuer.Client({
client_id: config.authentik_client_id,
client_secret: config.authentik_client_secret,
redirect_uris: [`${config.public_url}/api/auth/callback`],
response_types: ['code'],
});
logger.info('[Auth] OIDC client initialised');
return oidcClient;
}
router.get('/login', async (req, res) => {
try {
const client = await getOidcClient();
const state = generators.state();
const nonce = generators.nonce();
const verifier = generators.codeVerifier();
const challenge = generators.codeChallenge(verifier);
req.session.oidc = { state, nonce, verifier };
const redirectTo = req.query.redirect || '/';
req.session.postLoginRedirect = redirectTo;
const authUrl = client.authorizationUrl({
scope: 'openid profile email groups',
state,
nonce,
code_challenge: challenge,
code_challenge_method: 'S256',
});
res.redirect(authUrl);
} catch (err) {
logger.error('[Auth] Login redirect failed:', err);
res.status(502).json({ error: 'Identity provider unavailable' });
}
});
router.get('/callback', async (req, res) => {
try {
const client = await getOidcClient();
const { state, nonce, verifier } = req.session.oidc || {};
if (!state) {
return res.redirect('/?error=session_expired');
}
const params = client.callbackParams(req);
const tokenSet = await client.callback(
`${config.public_url}/api/auth/callback`,
params,
{ state, nonce, code_verifier: verifier }
);
const userinfo = await client.userinfo(tokenSet.access_token);
const groups = userinfo.groups ?? [];
const role = groups.includes(config.admin_group)
? 'admin'
: groups.includes(config.viewer_group)
? 'viewer'
: 'viewer';
req.session.user = {
id: userinfo.sub,
username: userinfo.preferred_username,
name: userinfo.name,
email: userinfo.email,
role,
groups,
accessToken: tokenSet.access_token,
refreshToken: tokenSet.refresh_token,
expiresAt: tokenSet.expires_at,
};
delete req.session.oidc;
const redirect = req.session.postLoginRedirect || '/';
delete req.session.postLoginRedirect;
logger.info(`[Auth] User ${userinfo.preferred_username} (${role}) logged in`);
res.redirect(redirect);
} catch (err) {
logger.error('[Auth] Callback failed:', err);
res.redirect('/?error=auth_failed');
}
});
router.post('/logout', authenticate, async (req, res) => {
const username = req.user?.username;
const idToken = req.session.user?.accessToken;
req.session.destroy(() => {
res.clearCookie('connect.sid');
logger.info(`[Auth] User ${username} logged out`);
const endSessionUrl = `${config.authentik_url}/application/o/${config.authentik_client_id}/end-session/`;
const params = new URLSearchParams({ post_logout_redirect_uri: config.public_url });
if (idToken) params.set('id_token_hint', idToken);
res.json({ redirectUrl: `${endSessionUrl}?${params}` });
});
});
router.get('/me', authenticate, (req, res) => {
const { id, username, name, email, role, groups } = req.user;
res.json({ id, username, name, email, role, groups });
});
router.get('/status', (req, res) => {
if (req.session?.user) {
const { id, username, name, email, role } = req.session.user;
res.json({ authenticated: true, user: { id, username, name, email, role } });
} else {
res.status(401).json({ authenticated: false });
}
});
module.exports = router;
+26
View File
@@ -0,0 +1,26 @@
// backend/routes/prometheus.js
'use strict';
const express = require('express');
const { createProxyMiddleware } = require('http-proxy-middleware');
const config = require('../config');
const { authenticate } = require('../middleware/authenticate');
const router = express.Router();
router.use(authenticate);
const prometheusProxy = createProxyMiddleware({
target: config.prometheus_url,
changeOrigin: true,
pathRewrite: { '^/api/prometheus': '' },
on: {
error: (err, req, res) => {
res.status(502).json({ error: 'Prometheus unreachable', detail: err.message });
},
},
});
router.use('/', prometheusProxy);
module.exports = router;
+75
View File
@@ -0,0 +1,75 @@
// backend/server.js — zROC UI backend entry point
'use strict';
const path = require('path');
const express = require('express');
const session = require('express-session');
const rateLimit = require('express-rate-limit');
const cookieParser = require('cookie-parser');
const config = require('./config');
const logger = require('./logger');
const authRoutes = require('./routes/auth');
const prometheusRoute = require('./routes/prometheus');
const adminUserRoutes = require('./routes/admin/users');
const app = express();
app.set('trust proxy', 1);
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
const sessionMiddleware = session({
secret: config.session_secret,
resave: false,
saveUninitialized: false,
cookie: {
secure: !config.is_dev,
httpOnly: true,
sameSite: 'lax',
maxAge: config.session_max_age_ms,
},
});
app.use(sessionMiddleware);
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 20,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many requests, please try again later' },
});
app.use('/api/auth', authLimiter);
app.use('/api/auth', authRoutes);
app.use('/api/prometheus', prometheusRoute);
app.use('/api/admin/users', adminUserRoutes);
app.get('/api/health', (req, res) => {
res.json({ status: 'ok', ts: new Date().toISOString() });
});
const distPath = path.join(__dirname, '..', 'dist');
app.use(express.static(distPath));
app.get('*', (req, res) => {
if (req.path.startsWith('/api/')) {
return res.status(404).json({ error: 'Not found' });
}
res.sendFile(path.join(distPath, 'index.html'));
});
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, _next) => {
logger.error('Unhandled error:', err);
res.status(500).json({ error: 'Internal server error' });
});
app.listen(config.port, () => {
logger.info(`[Server] zROC UI backend listening on port ${config.port}`);
logger.info(`[Server] Environment: ${config.node_env}`);
logger.info(`[Server] Prometheus: ${config.prometheus_url}`);
logger.info(`[Server] Authentik: ${config.authentik_url}`);
});
+47
View File
@@ -0,0 +1,47 @@
{
admin off
auto_https off
log {
format json
}
}
:443 {
tls internal
handle /auth/* {
reverse_proxy authentik-server:9000 {
header_up X-Forwarded-Proto https
header_up X-Forwarded-For {remote_host}
}
}
handle /outpost.goauthentik.io/* {
reverse_proxy authentik-server:9000 {
header_up X-Forwarded-Proto https
}
}
handle {
reverse_proxy zroc-ui:3001 {
header_up X-Forwarded-Proto https
header_up X-Forwarded-For {remote_host}
header_up X-Real-IP {remote_host}
health_uri /api/health
health_interval 15s
}
}
header {
X-Frame-Options "SAMEORIGIN"
X-Content-Type-Options "nosniff"
X-XSS-Protection "1; mode=block"
Referrer-Policy "strict-origin-when-cross-origin"
Strict-Transport-Security "max-age=31536000; includeSubDomains"
-Server
}
}
:80 {
redir https://{host}{uri} permanent
}
+252
View File
@@ -0,0 +1,252 @@
version: '3.8'
networks:
front-tier:
back-tier:
auth-tier:
volumes:
prometheus_data: {}
grafana_data: {}
zroc_ui_data: {}
authentik_postgres: {}
authentik_redis: {}
authentik_media: {}
caddy_data: {}
services:
caddy:
image: caddy:2-alpine
container_name: zroc-caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./zroc-ui/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
- ./certs:/certs:ro
- caddy_data:/data
networks:
- front-tier
depends_on:
- zroc-ui
- authentik-server
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:80"]
interval: 30s
timeout: 5s
retries: 3
authentik-postgresql:
image: postgres:16-alpine
container_name: authentik-db
restart: unless-stopped
environment:
POSTGRES_DB: authentik
POSTGRES_USER: authentik
POSTGRES_PASSWORD: ${AUTHENTIK_PG_PASS}
volumes:
- authentik_postgres:/var/lib/postgresql/data
networks:
- auth-tier
healthcheck:
test: ["CMD-SHELL", "pg_isready -U authentik"]
interval: 10s
timeout: 5s
retries: 5
authentik-redis:
image: redis:7-alpine
container_name: authentik-redis
restart: unless-stopped
command: --save 60 1 --loglevel warning
volumes:
- authentik_redis:/data
networks:
- auth-tier
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
authentik-server:
image: ghcr.io/goauthentik/server:latest
container_name: authentik-server
restart: unless-stopped
command: server
environment:
AUTHENTIK_REDIS__HOST: authentik-redis
AUTHENTIK_POSTGRESQL__HOST: authentik-postgresql
AUTHENTIK_POSTGRESQL__USER: authentik
AUTHENTIK_POSTGRESQL__NAME: authentik
AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_PG_PASS}
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
AUTHENTIK_DISABLE_STARTUP_ANALYTICS: "true"
AUTHENTIK_ERROR_REPORTING__ENABLED: "false"
ZROC_OIDC_CLIENT_ID: ${ZROC_OIDC_CLIENT_ID}
ZROC_OIDC_CLIENT_SECRET: ${ZROC_OIDC_CLIENT_SECRET}
ZROC_PUBLIC_URL: ${ZROC_PUBLIC_URL}
volumes:
- authentik_media:/media
- ./authentik/blueprints:/blueprints/custom:ro
networks:
- auth-tier
- front-tier
depends_on:
authentik-postgresql:
condition: service_healthy
authentik-redis:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "ak healthcheck || exit 1"]
interval: 30s
timeout: 10s
retries: 5
start_period: 60s
authentik-worker:
image: ghcr.io/goauthentik/server:latest
container_name: authentik-worker
restart: unless-stopped
command: worker
environment:
AUTHENTIK_REDIS__HOST: authentik-redis
AUTHENTIK_POSTGRESQL__HOST: authentik-postgresql
AUTHENTIK_POSTGRESQL__USER: authentik
AUTHENTIK_POSTGRESQL__NAME: authentik
AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_PG_PASS}
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
AUTHENTIK_DISABLE_STARTUP_ANALYTICS: "true"
volumes:
- authentik_media:/media
- /var/run/docker.sock:/var/run/docker.sock
networks:
- auth-tier
depends_on:
- authentik-server
user: root
zroc-ui:
image: recklessop/zroc-ui:stable
container_name: zroc-ui
restart: unless-stopped
environment:
NODE_ENV: production
PORT: "3001"
PROMETHEUS_URL: http://prometheus:9090
AUTHENTIK_URL: http://authentik-server:9000
AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID}
AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_CLIENT_SECRET}
AUTHENTIK_ADMIN_TOKEN: ${AUTHENTIK_ADMIN_TOKEN}
PUBLIC_URL: ${PUBLIC_URL}
SESSION_SECRET: ${SESSION_SECRET}
JWT_EXPIRY_HOURS: "24"
AUTHENTIK_ADMIN_GROUP: zroc-admins
AUTHENTIK_VIEWER_GROUP: zroc-viewers
volumes:
- zroc_ui_data:/app/data
networks:
- front-tier
- back-tier
- auth-tier
depends_on:
- prometheus
- authentik-server
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3001/api/health"]
interval: 15s
timeout: 5s
retries: 3
start_period: 20s
zertoexporter:
image: recklessop/zerto-exporter:stable
container_name: zvmexporter1
hostname: zvmexporter1
restart: unless-stopped
volumes:
- ./zvmexporter:/usr/src/app/logs
environment:
VERIFY_SSL: "False"
ZVM_HOST: ${ZVM_HOST}
ZVM_PORT: "443"
ZVM_USERNAME: ${ZVM_USERNAME}
ZVM_PASSWORD: ${ZVM_PASSWORD}
SCRAPE_SPEED: "20"
LOGLEVEL: INFO
VCENTER_HOST: ${VCENTER_HOST:-}
VCENTER_USER: ${VCENTER_USER:-administrator@vsphere.local}
VCENTER_PASSWORD: ${VCENTER_PASSWORD:-}
networks:
- back-tier
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:9999/metrics"]
interval: 30s
timeout: 10s
retries: 3
prometheus:
image: prom/prometheus:v2.51.0
container_name: zroc-prometheus
restart: unless-stopped
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.path=/prometheus
- --storage.tsdb.retention.time=30d
- --storage.tsdb.retention.size=20GB
- --web.listen-address=0.0.0.0:9090
- --web.enable-lifecycle
volumes:
- ./prometheus:/etc/prometheus:ro
- prometheus_data:/prometheus
networks:
- back-tier
depends_on:
- zertoexporter
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:9090/-/healthy"]
interval: 30s
timeout: 5s
retries: 3
grafana:
image: grafana/grafana:10.4.2
container_name: zroc-grafana
restart: unless-stopped
user: "472"
ports:
- "3000:3000"
volumes:
- grafana_data:/var/lib/grafana
- ./grafana/provisioning:/etc/grafana/provisioning:ro
environment:
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_PASSWORD:-zertodata}
GF_USERS_ALLOW_SIGN_UP: "false"
GF_SERVER_ROOT_URL: "%(protocol)s://%(domain)s:%(http_port)s/grafana/"
GF_AUTH_GENERIC_OAUTH_ENABLED: ${GRAFANA_OIDC_ENABLED:-false}
GF_AUTH_GENERIC_OAUTH_NAME: Authentik
GF_AUTH_GENERIC_OAUTH_CLIENT_ID: ${GRAFANA_CLIENT_ID:-}
GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET:${GRAFANA_CLIENT_SECRET:-}
GF_AUTH_GENERIC_OAUTH_SCOPES: openid profile email
GF_AUTH_GENERIC_OAUTH_AUTH_URL: ${PUBLIC_URL}/auth/application/o/authorize/
GF_AUTH_GENERIC_OAUTH_TOKEN_URL: http://authentik-server:9000/application/o/token/
GF_AUTH_GENERIC_OAUTH_API_URL: http://authentik-server:9000/application/o/userinfo/
networks:
- back-tier
- front-tier
depends_on:
- prometheus
watchtower:
image: containrrr/watchtower
container_name: zroc-watchtower
restart: unless-stopped
volumes:
- /var/run/docker.sock:/var/run/docker.sock
environment:
WATCHTOWER_POLL_INTERVAL: "3600"
WATCHTOWER_CLEANUP: "true"
WATCHTOWER_INCLUDE_STOPPED: "false"
command: --label-enable
+21
View File
@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>zROC — Zerto Resiliency Observation Console</title>
<!-- Fonts: IBM Plex Mono (headings) + DM Sans (body) + JetBrains Mono (data) -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=DM+Sans:wght@300;400;500;600&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
</head>
<body class="bg-canvas text-text-primary font-sans antialiased">
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+30
View File
@@ -0,0 +1,30 @@
{
"name": "zroc-ui",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-query": "^5.40.0",
"clsx": "^2.1.1",
"jspdf": "^4.2.1",
"lucide-react": "^0.395.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.23.1",
"recharts": "^2.12.7"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.4",
"vite": "^5.2.13"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+17
View File
@@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<!-- Background -->
<rect width="32" height="32" rx="6" fill="#0d1526"/>
<!-- Border -->
<rect x="1" y="1" width="30" height="30" rx="5.5" stroke="#0ea5e9" stroke-width="1" stroke-opacity="0.4"/>
<!-- Activity line (zROC pulse) -->
<polyline
points="4,18 8,18 10,10 12,24 15,14 17,20 19,16 21,16 24,16 28,16"
stroke="#0ea5e9"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"
/>
<!-- Live dot -->
<circle cx="28" cy="16" r="2.5" fill="#10b981"/>
</svg>

After

Width:  |  Height:  |  Size: 594 B

+57
View File
@@ -0,0 +1,57 @@
// src/App.jsx — final router with all pages wired up
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AuthProvider } from '@/auth/AuthContext';
import { ThemeProvider } from '@/auth/ThemeContext';
import { ProtectedRoute, AdminRoute } from '@/auth/ProtectedRoute';
import AppShell from '@/components/layout/AppShell';
import Overview from '@/pages/Overview';
import VPGMonitor from '@/pages/VPGMonitor';
import VRADashboard from '@/pages/VRADashboard';
import EncryptionPage from '@/pages/Encryption';
import Storage from '@/pages/Storage';
import UserManagement from '@/pages/Settings/UserManagement';
import VMDetail from '@/pages/VMDetail';
import Placeholder from '@/pages/Placeholder';
import Planner from '@/pages/Planner';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 2,
staleTime: 15_000,
refetchOnWindowFocus: true,
},
},
});
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<AuthProvider>
<BrowserRouter>
<Routes>
<Route element={<ProtectedRoute><AppShell /></ProtectedRoute>}>
<Route index element={<Overview />} />
<Route path="vpgs" element={<VPGMonitor />} />
<Route path="vms" element={<VMDetail />} />
<Route path="vras" element={<VRADashboard />} />
<Route path="encryption" element={<EncryptionPage />} />
<Route path="storage" element={<Storage />} />
<Route path="planner" element={<Planner />} />
<Route path="settings">
<Route index element={<Navigate to="users" replace />} />
<Route path="users" element={
<AdminRoute><UserManagement /></AdminRoute>
} />
</Route>
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>
</AuthProvider>
</ThemeProvider>
</QueryClientProvider>
);
}
+37
View File
@@ -0,0 +1,37 @@
// src/api/planner.js
// Queries the vcenter_vm_disk_* metrics exposed by zroc-planner collector.
import { instantQuery } from './prometheus';
export async function queryPlannerVms() {
const [throughput, iops, latency, provisioned] = await Promise.all([
instantQuery('vcenter_vm_disk_write_throughput_mbps'),
instantQuery('vcenter_vm_disk_write_iops'),
instantQuery('vcenter_vm_disk_write_latency_ms'),
instantQuery('vcenter_vm_disk_provisioned_gb'),
]);
const byMoref = {};
const idx = (vec, field, transform = parseFloat) => {
for (const { metric, value } of vec) {
const id = metric.vm_moref || metric.vm_name;
if (!byMoref[id]) byMoref[id] = {
moref: metric.vm_moref || id,
name: metric.vm_name || id,
cluster: metric.cluster || '',
host: metric.host || '',
datacenter: metric.datacenter || '',
};
byMoref[id][field] = transform(value[1]);
}
};
idx(throughput, 'writeThroughputMbps');
idx(iops, 'writeIops');
idx(latency, 'writeLatencyMs');
idx(provisioned, 'provisionedGb');
return Object.values(byMoref).sort((a, b) =>
(b.writeThroughputMbps ?? 0) - (a.writeThroughputMbps ?? 0)
);
}
+191
View File
@@ -0,0 +1,191 @@
// src/api/prometheus.js
const BASE = '/api/prometheus/api/v1';
async function promFetch(endpoint, params = {}) {
const url = new URL(BASE + endpoint, window.location.origin);
Object.entries(params).forEach(([k, v]) => {
if (v !== undefined && v !== null) url.searchParams.set(k, v);
});
const res = await fetch(url.toString(), { credentials: 'include' });
if (!res.ok) throw new Error(`Prometheus error: ${res.status}`);
const json = await res.json();
if (json.status !== 'success') throw new Error(json.error || 'Prometheus query failed');
return json.data;
}
export async function instantQuery(promql, time) {
const params = { query: promql };
if (time) params.time = time;
const data = await promFetch('/query', params);
return data.result;
}
export async function rangeQuery(promql, start, end, step = '60s') {
const data = await promFetch('/query_range', { query: promql, start, end, step });
return data.result;
}
export async function labelValues(labelName, match) {
const params = {};
if (match) params.match = match;
const data = await promFetch(`/label/${labelName}/values`, params);
return data;
}
export async function querySites() {
return labelValues('SiteName', 'vpg_actual_rpo');
}
export async function queryOverviewSummary() {
const [alertVec, throughputVec, rpoVec] = await Promise.all([
instantQuery('vpg_alert_status'),
instantQuery('sum by (SiteName) (vpg_throughput_in_mb)'),
instantQuery('max by (SiteName) (vpg_actual_rpo)'),
]);
const siteMap = {};
for (const { metric, value } of alertVec) {
const site = metric.SiteName || 'Unknown';
if (!siteMap[site]) siteMap[site] = { siteName: site, ok: 0, warn: 0, crit: 0 };
const v = Number(value[1]);
if (v === 0) siteMap[site].ok++;
else if (v === 1) siteMap[site].warn++;
else siteMap[site].crit++;
}
for (const { metric, value } of throughputVec) {
const site = metric.SiteName || 'Unknown';
if (siteMap[site]) siteMap[site].throughputMb = parseFloat(value[1]);
}
for (const { metric, value } of rpoVec) {
const site = metric.SiteName || 'Unknown';
if (siteMap[site]) siteMap[site].worstRpoSec = parseFloat(value[1]);
}
return Object.values(siteMap).map((s) => ({
...s,
total: s.ok + s.warn + s.crit,
}));
}
export async function queryAllVpgs() {
const [rpoVec, configuredVec, alertVec, throughputVec, iopsVec, vmCountVec] =
await Promise.all([
instantQuery('vpg_actual_rpo'),
instantQuery('vpg_configured_rpo'),
instantQuery('vpg_alert_status'),
instantQuery('vpg_throughput_in_mb'),
instantQuery('vpg_iops'),
instantQuery('vpg_vms_count'),
]);
const byId = {};
const idx = (vec, field, transform = Number) => {
for (const { metric, value } of vec) {
const id = metric.VpgIdentifier || metric.VpgName;
if (!byId[id]) byId[id] = {
id,
name: metric.VpgName || id,
siteName: metric.SiteName || 'Unknown',
siteId: metric.SiteIdentifier,
priority: metric.VpgPriority,
};
byId[id][field] = transform(value[1]);
}
};
idx(rpoVec, 'actualRpoSec');
idx(configuredVec, 'configuredRpoSec');
idx(alertVec, 'alertStatus');
idx(throughputVec, 'throughputMb', parseFloat);
idx(iopsVec, 'iops', parseFloat);
idx(vmCountVec, 'vmCount');
return Object.values(byId);
}
export async function queryTopRpoViolators(n = 10) {
const vpgs = await queryAllVpgs();
return vpgs
.filter((v) => v.actualRpoSec && v.configuredRpoSec)
.sort((a, b) => (b.actualRpoSec / b.configuredRpoSec) - (a.actualRpoSec / a.configuredRpoSec))
.slice(0, n);
}
export async function queryVpgRpoHistory(vpgName, startOffset = '6h', step = '60s') {
const end = Math.floor(Date.now() / 1000);
const start = end - parseDuration(startOffset);
const q = `vpg_actual_rpo{VpgName="${vpgName}"}`;
const result = await rangeQuery(q, start, end, step);
if (!result.length) return [];
const configured = (await instantQuery(`vpg_configured_rpo{VpgName="${vpgName}"}`))
?.[0]?.value?.[1];
return result[0].values.map(([ts, v]) => ({
ts: ts * 1000,
rpo: parseFloat(v),
configured: configured ? parseFloat(configured) : undefined,
}));
}
export async function queryVraHealth() {
const [memVec, cpuVec, protVmsVec, recVmsVec, protVolVec, recVolVec] = await Promise.all([
instantQuery('vra_memory_usage_mb'),
instantQuery('vra_cpu_usage_mhz'),
instantQuery('vra_protected_vms'),
instantQuery('vra_recovery_vms'),
instantQuery('vra_protected_volumes'),
instantQuery('vra_recovery_volumes'),
]);
const byName = {};
const idx = (vec, field, transform = Number) => {
for (const { metric, value } of vec) {
const key = metric.VraName || metric.VraIdentifierStr;
if (!byName[key]) byName[key] = {
name: metric.VraName,
version: metric.VraVersion,
hostVersion: metric.HostVersion,
siteName: metric.SiteName,
};
byName[key][field] = transform(value[1]);
}
};
idx(memVec, 'memoryUsageMb', parseFloat);
idx(cpuVec, 'cpuUsageMhz', parseFloat);
idx(protVmsVec, 'protectedVms');
idx(recVmsVec, 'recoveryVms');
idx(protVolVec, 'protectedVolumes');
idx(recVolVec, 'recoveryVolumes');
return Object.values(byName);
}
export async function queryEncryptionOverview() {
const vec = await instantQuery('vm_PercentEncrypted > 50');
return vec.map(({ metric, value }) => ({
vmName: metric.VmName,
vpgName: metric.VpgName,
siteName: metric.SiteName,
pctEnc: parseFloat(value[1]),
trend: metric.vm_TrendChangeLevel,
})).sort((a, b) => b.pctEnc - a.pctEnc);
}
export async function queryExporterHealth() {
const vec = await instantQuery('exporter_thread_status');
return vec.map(({ metric, value }) => ({
instance: metric.ExporterInstance,
thread: metric.thread,
alive: Number(value[1]) === 1,
}));
}
function parseDuration(s) {
const match = s.match(/^(\d+)(s|m|h|d)$/);
if (!match) return 3600;
const [, n, unit] = match;
const mul = { s: 1, m: 60, h: 3600, d: 86400 };
return parseInt(n, 10) * mul[unit];
}
+248
View File
@@ -0,0 +1,248 @@
// src/api/prometheusExtended.js
import { instantQuery, rangeQuery, labelValues } from './prometheus';
export async function queryVpgDetail(vpgName) {
const esc = vpgName.replace(/"/g, '\\"');
const [rpo, cfgRpo, alert, status, throughput, iops, vmCount,
storageUsed, storageProv, histActual, histCfg, failsafeActual, failsafeCfg] =
await Promise.all([
instantQuery(`vpg_actual_rpo{VpgName="${esc}"}`),
instantQuery(`vpg_configured_rpo{VpgName="${esc}"}`),
instantQuery(`vpg_alert_status{VpgName="${esc}"}`),
instantQuery(`vpg_status{VpgName="${esc}"}`),
instantQuery(`vpg_throughput_in_mb{VpgName="${esc}"}`),
instantQuery(`vpg_iops{VpgName="${esc}"}`),
instantQuery(`vpg_vms_count{VpgName="${esc}"}`),
instantQuery(`vpg_storage_used_in_mb{VpgName="${esc}"}`),
instantQuery(`vpg_provisioned_storage_in_mb{VpgName="${esc}"}`),
instantQuery(`vpg_actual_history{VpgName="${esc}"}`),
instantQuery(`vpg_configured_history{VpgName="${esc}"}`),
instantQuery(`vpg_failsafe_actual{VpgName="${esc}"}`),
instantQuery(`vpg_failsafe_configured{VpgName="${esc}"}`),
]);
const val = (vec) => parseFloat(vec?.[0]?.value?.[1] ?? 0);
const meta = rpo?.[0]?.metric ?? {};
return {
name: vpgName,
siteName: meta.SiteName,
priority: meta.VpgPriority,
actualRpoSec: val(rpo),
configuredRpoSec: val(cfgRpo),
alertStatus: val(alert),
status: val(status),
throughputMb: val(throughput),
iops: val(iops),
vmCount: val(vmCount),
storageUsedMb: val(storageUsed),
storageProvMb: val(storageProv),
histActualMin: val(histActual),
histConfiguredMin:val(histCfg),
failsafeActualMin:val(failsafeActual),
failsafeCfgMin: val(failsafeCfg),
};
}
export async function queryVpgVms(vpgName) {
const esc = vpgName.replace(/"/g, '\\"');
const [rpo, status, throughput, iops, journalUsed, journalHard] = await Promise.all([
instantQuery(`vm_actualrpo{VpgName="${esc}"}`),
instantQuery(`vm_status{VpgName="${esc}"}`),
instantQuery(`vm_throughput_in_mb{VpgName="${esc}"}`),
instantQuery(`vm_iops{VpgName="${esc}"}`),
instantQuery(`vm_journal_used_storage_mb{VpgName="${esc}"}`),
instantQuery(`vm_journal_hard_limit{VpgName="${esc}"}`),
]);
const byId = {};
const idx = (vec, field, transform = Number) => {
for (const { metric, value } of vec) {
const id = metric.VmIdentifier || metric.VmName;
if (!byId[id]) byId[id] = {
id,
name: metric.VmName,
sourceVra: metric.VmSourceVRA,
recoveryVra: metric.VmRecoveryVRA,
priority: metric.VmPriority,
};
byId[id][field] = transform(value[1]);
}
};
idx(rpo, 'actualRpoSec');
idx(status, 'status');
idx(throughput, 'throughputMb', parseFloat);
idx(iops, 'iops', parseFloat);
idx(journalUsed, 'journalUsedMb', parseFloat);
idx(journalHard, 'journalHardLimit', parseFloat);
return Object.values(byId);
}
export async function queryAllVms() {
const [rpo, status, throughput, iops, journalUsed, bandwidth, pctEnc] = await Promise.all([
instantQuery('vm_actualrpo'),
instantQuery('vm_status'),
instantQuery('vm_throughput_in_mb'),
instantQuery('vm_iops'),
instantQuery('vm_journal_used_storage_mb'),
instantQuery('vm_outgoing_bandwidth_in_mbps'),
instantQuery('vm_PercentEncrypted'),
]);
const byId = {};
const idx = (vec, field, transform = Number) => {
for (const { metric, value } of vec) {
const id = metric.VmIdentifier || metric.VmName;
if (!byId[id]) byId[id] = {
id,
name: metric.VmName,
vpgName: metric.VpgName,
siteName: metric.SiteName,
sourceVra: metric.VmSourceVRA,
recoveryVra: metric.VmRecoveryVRA,
};
byId[id][field] = transform(value[1]);
}
};
idx(rpo, 'actualRpoSec');
idx(status, 'status');
idx(throughput, 'throughputMb', parseFloat);
idx(iops, 'iops', parseFloat);
idx(journalUsed, 'journalUsedMb', parseFloat);
idx(bandwidth, 'bandwidthMbps', parseFloat);
idx(pctEnc, 'pctEncrypted', parseFloat);
return Object.values(byId);
}
export async function queryAllVras() {
const [mem, cpu, memUsage, cpuUsage,
protVms, recVms, protVpgs, recVpgs, protVols, recVols, selfVpgs] =
await Promise.all([
instantQuery('vra_memory_in_GB'),
instantQuery('vra_vcpu_count'),
instantQuery('vra_memory_usage_mb'),
instantQuery('vra_cpu_usage_mhz'),
instantQuery('vra_protected_vms'),
instantQuery('vra_recovery_vms'),
instantQuery('vra_protected_vpgs'),
instantQuery('vra_recovery_vpgs'),
instantQuery('vra_protected_volumes'),
instantQuery('vra_recovery_volumes'),
instantQuery('vra_self_protected_vpgs'),
]);
const byName = {};
const idx = (vec, field, transform = Number) => {
for (const { metric, value } of vec) {
const key = metric.VraName || metric.VraIdentifierStr;
if (!byName[key]) byName[key] = {
id: metric.VraIdentifierStr,
name: metric.VraName,
version: metric.VraVersion,
hostVersion: metric.HostVersion,
siteName: metric.SiteName,
siteId: metric.SiteIdentifier,
};
byName[key][field] = transform(value[1]);
}
};
idx(mem, 'memoryGb', parseFloat);
idx(cpu, 'vcpuCount');
idx(memUsage, 'memUsageMb', parseFloat);
idx(cpuUsage, 'cpuUsageMhz', parseFloat);
idx(protVms, 'protectedVms');
idx(recVms, 'recoveryVms');
idx(protVpgs, 'protectedVpgs');
idx(recVpgs, 'recoveryVpgs');
idx(protVols, 'protectedVolumes');
idx(recVols, 'recoveryVolumes');
idx(selfVpgs, 'selfProtectedVpgs');
return Object.values(byName);
}
export async function queryEncryptionDetail() {
const [pctEnc, trend, encrypted, unencrypted, total, ioOps, writeCounter] =
await Promise.all([
instantQuery('vm_PercentEncrypted'),
instantQuery('vm_TrendChangeLevel'),
instantQuery('vm_EncryptedDataInLBs'),
instantQuery('vm_UnencryptedDataInLBs'),
instantQuery('vm_TotalDataInLBs'),
instantQuery('vm_IoOperationsCounter'),
instantQuery('vm_WriteCounterInMBs'),
]);
const byId = {};
const idx = (vec, field, transform = Number) => {
for (const { metric, value } of vec) {
const id = metric.VmIdentifier || metric.VmName;
if (!byId[id]) byId[id] = {
id,
name: metric.VmName,
vpgName: metric.VpgName,
siteName:metric.SiteName,
vpgId: metric.VpgIdentifier,
};
byId[id][field] = transform(value[1]);
}
};
idx(pctEnc, 'pctEncrypted', parseFloat);
idx(trend, 'trendLevel', parseFloat);
idx(encrypted, 'encryptedLbs', parseFloat);
idx(unencrypted, 'unencryptedLbs', parseFloat);
idx(total, 'totalLbs', parseFloat);
idx(ioOps, 'ioOps', parseFloat);
idx(writeCounter,'writeMb', parseFloat);
return Object.values(byId).sort((a, b) => (b.pctEncrypted ?? 0) - (a.pctEncrypted ?? 0));
}
export async function queryDatastores() {
const metrics = [
'datastore_capacity_in_bytes',
'datastore_free_in_bytes',
'datastore_used_in_bytes',
'datastore_vras',
'datastore_incoming_vms',
'datastore_outgoing_vms',
'datastore_usage_zerto_journal_used_in_bytes',
'datastore_usage_zerto_scratch_used_in_bytes',
'datastore_usage_zerto_recovery_used_in_bytes',
'datastore_usage_zerto_appliances_used_in_bytes',
];
const results = await Promise.all(metrics.map(instantQuery));
const byId = {};
metrics.forEach((metric, mi) => {
const fieldMap = {
datastore_capacity_in_bytes: 'capacityBytes',
datastore_free_in_bytes: 'freeBytes',
datastore_used_in_bytes: 'usedBytes',
datastore_vras: 'vraCount',
datastore_incoming_vms: 'incomingVms',
datastore_outgoing_vms: 'outgoingVms',
datastore_usage_zerto_journal_used_in_bytes: 'journalBytes',
datastore_usage_zerto_scratch_used_in_bytes: 'scratchBytes',
datastore_usage_zerto_recovery_used_in_bytes: 'recoveryBytes',
datastore_usage_zerto_appliances_used_in_bytes: 'applianceBytes',
};
const field = fieldMap[metric];
for (const { metric: m, value } of results[mi]) {
const id = m.datastoreIdentifier || m.DatastoreName;
if (!byId[id]) byId[id] = {
id, name: m.DatastoreName, siteName: m.SiteName,
};
byId[id][field] = parseFloat(value[1]);
}
});
return Object.values(byId).sort((a, b) => (b.capacityBytes ?? 0) - (a.capacityBytes ?? 0));
}
+46
View File
@@ -0,0 +1,46 @@
// src/api/users.js — user management API calls
const BASE = '/api/admin/users';
async function apiFetch(url, opts = {}) {
const res = await fetch(url, { credentials: 'include', ...opts });
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw Object.assign(new Error(body.error || `HTTP ${res.status}`), {
status: res.status,
detail: body.detail,
});
}
if (res.status === 204) return null;
return res.json();
}
export const usersApi = {
list: ({ search = '', page = 1, pageSize = 50 } = {}) => {
const params = new URLSearchParams({ search, page, pageSize });
return apiFetch(`${BASE}?${params}`);
},
get: (id) => apiFetch(`${BASE}/${id}`),
create: (body) =>
apiFetch(BASE, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}),
update: (id, body) =>
apiFetch(`${BASE}/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}),
delete: (id) =>
apiFetch(`${BASE}/${id}`, { method: 'DELETE' }),
setPassword: (id, password) =>
apiFetch(`${BASE}/${id}/set-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
}),
setup2fa: (id) =>
apiFetch(`${BASE}/${id}/setup-2fa`, { method: 'POST' }),
listGroups: () => apiFetch(`${BASE}/meta/groups`),
};
+68
View File
@@ -0,0 +1,68 @@
// src/auth/AuthContext.jsx
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const checkSession = useCallback(async () => {
if (import.meta.env.VITE_MOCK_AUTH === 'true') {
setUser({ name: 'Demo User', email: 'demo@zroc.local', role: 'admin' });
setLoading(false);
return;
}
try {
const res = await fetch('/api/auth/status', { credentials: 'include' });
if (res.ok) {
const data = await res.json();
setUser(data.authenticated ? data.user : null);
} else {
setUser(null);
}
} catch {
setUser(null);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { checkSession(); }, [checkSession]);
const login = () => {
window.location.href = `/api/auth/login?redirect=${encodeURIComponent(window.location.pathname)}`;
};
const logout = async () => {
try {
const res = await fetch('/api/auth/logout', {
method: 'POST',
credentials: 'include',
});
if (res.ok) {
const { redirectUrl } = await res.json();
setUser(null);
window.location.href = redirectUrl || '/';
}
} catch {
setUser(null);
window.location.href = '/';
}
};
const isAdmin = user?.role === 'admin';
const isViewer = !!user;
return (
<AuthContext.Provider value={{ user, loading, login, logout, isAdmin, isViewer }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
return ctx;
}
+55
View File
@@ -0,0 +1,55 @@
// src/auth/ProtectedRoute.jsx
import { useEffect } from 'react';
import { useAuth } from './AuthContext';
function LoadingScreen() {
return (
<div className="flex items-center justify-center h-screen bg-canvas">
<div className="flex flex-col items-center gap-4">
<div className="w-8 h-8 border-2 border-border border-t-accent rounded-full animate-spin" />
<p className="font-mono text-xs text-text-muted uppercase tracking-widest">
Verifying session
</p>
</div>
</div>
);
}
export function ProtectedRoute({ children }) {
const { user, loading, login } = useAuth();
useEffect(() => {
if (!loading && !user) login();
}, [loading, user, login]);
if (loading) return <LoadingScreen />;
if (!user) return <LoadingScreen />;
return children;
}
export function AdminRoute({ children }) {
const { user, loading, login, isAdmin } = useAuth();
useEffect(() => {
if (!loading && !user) login();
}, [loading, user, login]);
if (loading) return <LoadingScreen />;
if (!user) return <LoadingScreen />;
if (!isAdmin) {
return (
<div className="flex items-center justify-center h-screen bg-canvas">
<div className="card p-10 text-center max-w-sm">
<p className="font-mono text-crit text-lg mb-2">403</p>
<p className="text-text-secondary text-sm">
This page requires administrator privileges.
</p>
</div>
</div>
);
}
return children;
}
+29
View File
@@ -0,0 +1,29 @@
// src/auth/ThemeContext.jsx
import { createContext, useContext, useState, useEffect } from 'react';
const ThemeContext = createContext(null);
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState(() =>
localStorage.getItem('zroc-theme') || 'dark'
);
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('zroc-theme', theme);
}, [theme]);
const toggle = () => setTheme((t) => t === 'dark' ? 'light' : 'dark');
return (
<ThemeContext.Provider value={{ theme, toggle }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
return ctx;
}
@@ -0,0 +1,95 @@
// src/components/charts/RPOGauge.jsx
import { formatRpo } from '@/constants/statusMaps';
import clsx from 'clsx';
const R = 52;
const CX = 70;
const CY = 72;
const SW = 10;
function polarToCartesian(cx, cy, r, angleDeg) {
const rad = ((angleDeg - 90) * Math.PI) / 180;
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) };
}
function arcPath(cx, cy, r, startAngle, endAngle) {
const s = polarToCartesian(cx, cy, r, startAngle);
const e = polarToCartesian(cx, cy, r, endAngle);
const large = endAngle - startAngle > 180 ? 1 : 0;
return `M ${s.x} ${s.y} A ${r} ${r} 0 ${large} 1 ${e.x} ${e.y}`;
}
const START_ANGLE = -210;
const END_ANGLE = 30;
function rpoColor(ratio) {
if (ratio === null || ratio === undefined) return { stroke: '#4a6080', text: 'text-text-muted' };
if (ratio <= 0.75) return { stroke: '#10b981', text: 'text-ok' };
if (ratio <= 1.0) return { stroke: '#f59e0b', text: 'text-warn' };
return { stroke: '#ef4444', text: 'text-crit' };
}
export default function RPOGauge({ actualSec, configuredSec, label = 'Actual RPO', size = 140 }) {
const ratio = (actualSec && configuredSec) ? Math.min(actualSec / configuredSec, 1.5) : null;
const { stroke, text } = rpoColor(ratio);
const totalAngle = END_ANGLE - START_ANGLE;
const fillAngle = ratio !== null
? START_ANGLE + (Math.min(ratio, 1) * totalAngle)
: START_ANGLE;
const bgPath = arcPath(CX, CY, R, START_ANGLE, END_ANGLE);
const fillPath = ratio !== null ? arcPath(CX, CY, R, START_ANGLE, fillAngle) : null;
const pct = ratio !== null ? Math.round(ratio * 100) : null;
return (
<div className="flex flex-col items-center" style={{ width: size }}>
<svg
viewBox="0 0 140 100"
width={size}
height={size * (100 / 140)}
className="overflow-visible"
>
<path d={bgPath} fill="none" stroke="#1e2d47" strokeWidth={SW} strokeLinecap="round" />
{fillPath && (
<path d={fillPath} fill="none" stroke={stroke} strokeWidth={SW} strokeLinecap="round"
style={{ filter: `drop-shadow(0 0 4px ${stroke}60)`, transition: 'all 0.6s ease-out' }}
/>
)}
{fillPath && ratio !== null && (
(() => {
const tip = polarToCartesian(CX, CY, R, Math.min(fillAngle, END_ANGLE - 0.5));
return (
<circle cx={tip.x} cy={tip.y} r={4} fill={stroke}
style={{ filter: `drop-shadow(0 0 6px ${stroke})` }} />
);
})()
)}
<text x={CX} y={CY - 6} textAnchor="middle"
fill={stroke}
fontSize={actualSec != null ? 18 : 14}
fontFamily="JetBrains Mono, monospace"
fontWeight="600"
>
{actualSec != null ? formatRpo(actualSec) : '—'}
</text>
{configuredSec && (
<text x={CX} y={CY + 10} textAnchor="middle"
fill="#4a6080" fontSize={8} fontFamily="JetBrains Mono, monospace">
/ {formatRpo(configuredSec)} target
</text>
)}
{pct !== null && (
<text x={CX} y={CY + 22} textAnchor="middle"
fill={stroke} fontSize={9} fontFamily="JetBrains Mono, monospace">
{pct > 100
? `${pct - 100}% over`
: `${100 - pct}% headroom`}
</text>
)}
</svg>
<p className="section-title mt-1">{label}</p>
</div>
);
}
@@ -0,0 +1,152 @@
// src/components/charts/TimeSeriesChart.jsx
import { useState, useCallback } from 'react';
import {
ComposedChart, Area, Line,
XAxis, YAxis, CartesianGrid, Tooltip, Legend,
ResponsiveContainer, ReferenceLine,
} from 'recharts';
import { useRangeQuery } from '@/hooks/useRangeQuery';
import { Loader2 } from 'lucide-react';
import clsx from 'clsx';
const WINDOWS = ['1h', '6h', '24h', '7d', '30d'];
function CustomTooltip({ active, payload, label, formatter, timeFormat }) {
if (!active || !payload?.length) return null;
const ts = typeof label === 'number' ? new Date(label).toLocaleString(undefined, timeFormat) : label;
return (
<div className="bg-raised border border-border-bright rounded-lg px-3 py-2 shadow-panel text-xs">
<p className="text-text-muted font-mono mb-2">{ts}</p>
{payload.map((p) => (
<div key={p.dataKey} className="flex items-center gap-2 mb-0.5">
<span className="w-2 h-2 rounded-full flex-shrink-0" style={{ background: p.color }} />
<span className="text-text-secondary">{p.name}:</span>
<span className="font-mono font-semibold text-text-primary">
{formatter ? formatter(p.value, p.dataKey) : p.value}
</span>
</div>
))}
</div>
);
}
function WindowSelector({ value, onChange }) {
return (
<div className="flex items-center gap-0.5 bg-canvas rounded-md p-0.5 border border-border">
{WINDOWS.map((w) => (
<button key={w} onClick={() => onChange(w)}
className={clsx('px-2.5 py-1 rounded text-xs font-mono transition-all duration-150',
value === w ? 'bg-accent/20 text-accent border border-accent/30' : 'text-text-muted hover:text-text-primary hover:bg-raised')}>
{w}
</button>
))}
</div>
);
}
const SERIES_COLORS = {
ok: '#10b981', warn: '#f59e0b', crit: '#ef4444', accent: '#0ea5e9',
info: '#818cf8', 0: '#0ea5e9', 1: '#10b981', 2: '#f59e0b', 3: '#818cf8', 4: '#ef4444',
};
export default function TimeSeriesChart({
promql, title, yFormatter, yLabel, refLines = [],
showWindow = true, defaultWindow = '6h', height = 200, transform, series: seriesDef,
}) {
const [window, setWindow] = useState(defaultWindow);
const seriesArr = seriesDef ? seriesDef
: Array.isArray(promql) ? promql
: [{ promql, name: title || 'value', color: 'accent' }];
const primaryPromql = typeof promql === 'string' ? promql : seriesArr[0]?.promql;
const { data: rawData, isLoading, error } = useRangeQuery(primaryPromql, {
window, enabled: !!primaryPromql,
});
const chartData = useCallback(() => {
if (!rawData?.length) return [];
if (transform) return transform(rawData, window);
const merged = {};
rawData.forEach((series, si) => {
const key = series.metric.VpgName || series.metric.VmName || series.metric.VraName || seriesArr[si]?.name || `series${si}`;
series.values.forEach(([ts, v]) => {
const ms = ts * 1000;
if (!merged[ms]) merged[ms] = { ts: ms };
merged[ms][key] = parseFloat(v);
});
});
return Object.values(merged).sort((a, b) => a.ts - b.ts);
}, [rawData, transform, window, seriesArr]);
const data = chartData();
const seriesKeys = data.length > 0 ? Object.keys(data[0]).filter((k) => k !== 'ts') : [];
const makeTimeTick = (w) => (ts) => {
const d = new Date(ts);
if (w === '30d' || w === '7d') return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
};
const makeTooltipTimeFormat = (w) => {
if (w === '30d' || w === '7d') return { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' };
return { hour: '2-digit', minute: '2-digit', second: '2-digit' };
};
return (
<div className="card p-4 flex flex-col gap-3">
<div className="flex items-center justify-between flex-wrap gap-2">
{title && <p className="section-title">{title}</p>}
{showWindow && <WindowSelector value={window} onChange={setWindow} />}
</div>
<div style={{ height }} className="relative">
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center">
<Loader2 size={18} className="animate-spin text-text-muted" />
</div>
)}
{error && (
<div className="absolute inset-0 flex items-center justify-center">
<p className="text-xs text-crit font-mono">Query failed</p>
</div>
)}
{!isLoading && !error && data.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center">
<p className="text-xs text-text-muted font-mono">No data</p>
</div>
)}
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={data} margin={{ top: 4, right: 4, left: yLabel ? 16 : 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(30,45,71,0.8)" vertical={false} />
<XAxis dataKey="ts" type="number" domain={['dataMin', 'dataMax']}
tickFormatter={makeTimeTick(window)}
tick={{ fontSize: 10, fill: '#4a6080', fontFamily: 'JetBrains Mono' }}
axisLine={{ stroke: '#1e2d47' }} tickLine={false} scale="time" />
<YAxis tickFormatter={(v) => yFormatter ? yFormatter(v) : v}
tick={{ fontSize: 10, fill: '#4a6080', fontFamily: 'JetBrains Mono' }}
axisLine={false} tickLine={false} width={yLabel ? 60 : 40} />
<Tooltip content={<CustomTooltip formatter={yFormatter} timeFormat={makeTooltipTimeFormat(window)} />}
cursor={{ stroke: '#2a4066', strokeWidth: 1, strokeDasharray: '4 2' }} />
{refLines.map((rl) => (
<ReferenceLine key={rl.label} y={rl.value}
stroke={SERIES_COLORS[rl.color] || rl.color || '#f59e0b'}
strokeDasharray="6 3" strokeWidth={1.5} />
))}
{(seriesArr.length > 1 ? seriesArr : seriesKeys.map((k, i) => ({
name: k, color: i, type: 'area',
}))).map((s, i) => {
const key = s.name || s.promql || seriesKeys[i] || `s${i}`;
const color = SERIES_COLORS[s.color] || SERIES_COLORS[i] || '#0ea5e9';
return (
<Area key={key} type="monotone" dataKey={key} name={s.name || key}
stroke={color} strokeWidth={2} fill={color} fillOpacity={0.08}
dot={false} activeDot={{ r: 3 }} connectNulls />
);
})}
</ComposedChart>
</ResponsiveContainer>
</div>
</div>
);
}
@@ -0,0 +1,24 @@
// src/components/layout/AppShell.jsx
import { useState } from 'react';
import { Outlet } from 'react-router-dom';
import Sidebar from './Sidebar';
import TopBar from './TopBar';
export default function AppShell() {
const [sidebarOpen, setSidebarOpen] = useState(true);
return (
<div className="flex h-screen overflow-hidden bg-canvas">
{/* Sidebar */}
<Sidebar open={sidebarOpen} onToggle={() => setSidebarOpen((v) => !v)} />
{/* Main area */}
<div className="flex flex-col flex-1 min-w-0 overflow-hidden">
<TopBar sidebarOpen={sidebarOpen} onMenuToggle={() => setSidebarOpen((v) => !v)} />
<main className="flex-1 overflow-y-auto p-6">
<Outlet />
</main>
</div>
</div>
);
}
+122
View File
@@ -0,0 +1,122 @@
// src/components/layout/Sidebar.jsx
import { NavLink } from 'react-router-dom';
import {
LayoutDashboard, GitFork, Server, Cpu,
ShieldAlert, Database, Settings, ChevronLeft,
ChevronRight, Activity, Calculator,
} from 'lucide-react';
import { useAuth } from '@/auth/AuthContext';
import clsx from 'clsx';
function ZrocLogo({ collapsed }) {
return (
<div className={clsx(
'flex items-center gap-2.5 px-4 h-14 border-b border-border flex-shrink-0',
collapsed && 'justify-center px-0',
)}>
<div className="relative flex-shrink-0">
<div className="w-7 h-7 border border-accent rounded-sm flex items-center justify-center bg-accent/10 shadow-glow-sm">
<Activity size={14} className="text-accent" />
</div>
<span className="absolute -top-0.5 -right-0.5 w-2 h-2 bg-ok rounded-full shadow-glow-ok animate-pulse-led" />
</div>
{!collapsed && (
<div>
<p className="font-mono text-sm font-semibold text-text-primary leading-none">
z<span className="text-accent">ROC</span>
</p>
<p className="font-mono text-[9px] text-text-muted leading-none mt-0.5 uppercase tracking-widest">
Observability Console
</p>
</div>
)}
</div>
);
}
const NAV_ITEMS = [
{ to: '/', label: 'Overview', icon: LayoutDashboard, exact: true },
{ to: '/vpgs', label: 'VPGs', icon: GitFork },
{ to: '/vms', label: 'VMs', icon: Server },
{ to: '/vras', label: 'VRAs', icon: Cpu },
{ to: '/encryption', label: 'Encryption', icon: ShieldAlert },
{ to: '/storage', label: 'Storage', icon: Database },
{ to: '/planner', label: 'Planner', icon: Calculator },
];
const ADMIN_ITEMS = [
{ to: '/settings/users', label: 'Users', icon: Settings },
];
function NavItem({ to, label, icon: Icon, collapsed, exact }) {
return (
<NavLink
to={to}
end={exact}
className={({ isActive }) =>
clsx(
'flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-all duration-150 group relative',
collapsed ? 'justify-center' : '',
isActive
? 'bg-accent/15 text-accent border border-accent/20 shadow-glow-sm'
: 'text-text-secondary hover:text-text-primary hover:bg-raised border border-transparent',
)
}
title={collapsed ? label : undefined}
>
{({ isActive }) => (
<>
<Icon size={16} className={clsx('flex-shrink-0 transition-colors', isActive ? 'text-accent' : 'group-hover:text-text-primary')} />
{!collapsed && <span className="font-medium">{label}</span>}
{isActive && !collapsed && <span className="ml-auto w-1 h-1 rounded-full bg-accent" />}
{collapsed && (
<span className="absolute left-full ml-2 px-2 py-1 bg-raised border border-border rounded text-xs text-text-primary whitespace-nowrap opacity-0 group-hover:opacity-100 pointer-events-none z-50 transition-opacity duration-150 shadow-panel">
{label}
</span>
)}
</>
)}
</NavLink>
);
}
export default function Sidebar({ open, onToggle }) {
const { isAdmin } = useAuth();
const collapsed = !open;
return (
<aside className={clsx(
'flex flex-col bg-surface border-r border-border flex-shrink-0 transition-all duration-200 ease-in-out',
collapsed ? 'w-14' : 'w-56',
)}>
<ZrocLogo collapsed={collapsed} />
<nav className="flex-1 px-2 py-3 space-y-0.5 overflow-y-auto overflow-x-hidden">
<div className={clsx(!collapsed && 'mb-1')}>
{!collapsed && <p className="section-title px-3 mb-2">Monitor</p>}
{NAV_ITEMS.map((item) => (
<NavItem key={item.to} {...item} collapsed={collapsed} />
))}
</div>
{isAdmin && (
<div className={clsx(!collapsed && 'pt-3 mt-3 border-t border-border')}>
{collapsed && <div className="border-t border-border my-2 mx-2" />}
{!collapsed && <p className="section-title px-3 mb-2">Admin</p>}
{ADMIN_ITEMS.map((item) => (
<NavItem key={item.to} {...item} collapsed={collapsed} />
))}
</div>
)}
</nav>
<button
onClick={onToggle}
className="flex items-center justify-center h-10 border-t border-border text-text-muted hover:text-text-primary hover:bg-raised transition-colors duration-150 flex-shrink-0"
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
{collapsed ? <ChevronRight size={14} /> : <ChevronLeft size={14} />}
</button>
</aside>
);
}
+105
View File
@@ -0,0 +1,105 @@
// src/components/layout/TopBar.jsx
import { useState, useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';
import { Menu, RefreshCw, ChevronDown, LogOut, Sun, Moon } from 'lucide-react';
import { useAuth } from '@/auth/AuthContext';
import { useTheme } from '@/auth/ThemeContext';
import { useQueryClient } from '@tanstack/react-query';
import clsx from 'clsx';
const PAGE_TITLES = {
'/': 'Overview',
'/vpgs': 'VPG Monitor',
'/vms': 'VM Protection',
'/vras': 'VRA Infrastructure',
'/encryption': 'Encryption Detection',
'/storage': 'Storage & Datastores',
'/planner': 'DR Capacity Planner',
'/settings/users': 'User Management',
'/settings': 'Settings',
};
function UserMenu({ user, onLogout }) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
const handler = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
const initials = user.name
? user.name.split(' ').map((w) => w[0]).slice(0, 2).join('').toUpperCase()
: user.username.slice(0, 2).toUpperCase();
return (
<div ref={ref} className="relative">
<button onClick={() => setOpen((v) => !v)}
className="flex items-center gap-2 pl-3 pr-2 py-1.5 rounded-md hover:bg-raised border border-transparent hover:border-border transition-all duration-150">
<div className="w-7 h-7 rounded-full bg-accent/20 text-accent flex items-center justify-center font-mono text-xs font-semibold">
{initials}
</div>
<div className="hidden sm:block text-left">
<p className="text-xs font-medium text-text-primary leading-none">{user.name || user.username}</p>
<p className="text-[10px] text-text-muted font-mono leading-none mt-0.5 capitalize">{user.role}</p>
</div>
<ChevronDown size={12} className="text-text-muted" />
</button>
{open && (
<div className="absolute right-0 top-full mt-1 w-52 card-raised shadow-panel z-50 py-1 animate-fade-in">
<div className="px-3 py-2 border-b border-border">
<p className="text-xs font-medium text-text-primary">{user.name}</p>
<p className="text-[10px] text-text-muted font-mono">{user.email}</p>
</div>
<button onClick={() => { setOpen(false); onLogout(); }}
className="flex w-full items-center gap-2 px-3 py-2 text-sm text-text-secondary hover:text-crit hover:bg-crit/5 transition-colors">
<LogOut size={13} />
Sign out
</button>
</div>
)}
</div>
);
}
export default function TopBar({ sidebarOpen, onMenuToggle }) {
const { user, logout } = useAuth();
const { theme, toggle: toggleTheme } = useTheme();
const location = useLocation();
const queryClient = useQueryClient();
const [refreshing, setRefreshing] = useState(false);
const title = PAGE_TITLES[location.pathname] ?? 'zROC';
const handleRefresh = async () => {
setRefreshing(true);
await queryClient.invalidateQueries();
setTimeout(() => setRefreshing(false), 800);
};
return (
<header className="h-14 flex items-center justify-between px-4 border-b border-border bg-surface flex-shrink-0">
<div className="flex items-center gap-3">
<button onClick={onMenuToggle}
className="p-1.5 rounded text-text-muted hover:text-text-primary hover:bg-raised transition-colors md:hidden">
<Menu size={16} />
</button>
<h2 className="font-mono text-sm font-semibold text-text-primary">{title}</h2>
</div>
<div className="flex items-center gap-2">
<button onClick={handleRefresh} title="Refresh all data"
className="p-1.5 rounded text-text-muted hover:text-accent hover:bg-accent/10 transition-all duration-150">
<RefreshCw size={14} className={clsx(refreshing && 'animate-spin text-accent')} />
</button>
<button onClick={toggleTheme} title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
className="p-1.5 rounded text-text-muted hover:text-accent hover:bg-accent/10 transition-all duration-150">
{theme === 'dark' ? <Sun size={14} /> : <Moon size={14} />}
</button>
{user && <UserMenu user={user} onLogout={logout} />}
</div>
</header>
);
}
+89
View File
@@ -0,0 +1,89 @@
// src/constants/statusMaps.js
export const VPG_STATUS = {
0: { label: 'Initializing', color: 'info', dot: 'info' },
1: { label: 'Meeting SLA', color: 'ok', dot: 'ok' },
2: { label: 'Not Meeting SLA', color: 'crit', dot: 'crit' },
3: { label: 'History Not Meeting SLA',color: 'warn', dot: 'warn' },
4: { label: 'RPO Not Meeting SLA', color: 'crit', dot: 'crit' },
5: { label: 'Failing Over', color: 'info', dot: 'info' },
6: { label: 'Moving', color: 'info', dot: 'info' },
7: { label: 'Deleting', color: 'muted', dot: 'idle' },
8: { label: 'Recovering', color: 'info', dot: 'info' },
9: { label: 'Needs Configuration', color: 'warn', dot: 'warn' },
};
export const VPG_ALERT = {
0: { label: 'No Alert', color: 'ok' },
1: { label: 'Warning', color: 'warn' },
2: { label: 'Error', color: 'crit' },
};
export const VM_STATUS = {
0: { label: 'Protected', color: 'ok' },
1: { label: 'Initializing', color: 'info' },
2: { label: 'Replication Paused', color: 'warn' },
3: { label: 'Error', color: 'crit' },
4: { label: 'Empty Protection Group', color: 'muted'},
5: { label: 'Disconnected', color: 'crit' },
6: { label: 'Backing Up', color: 'info' },
7: { label: 'Preparing Failover', color: 'info' },
8: { label: 'Failing Over', color: 'info' },
9: { label: 'Move Failed', color: 'crit' },
};
export function vpgHealth(statusCode) {
const s = VPG_STATUS[statusCode] ?? { label: 'Unknown', color: 'muted', dot: 'idle' };
return s;
}
export function isVpgAlerting(statusCode) {
return [2, 4].includes(statusCode);
}
export function isVpgWarning(statusCode) {
return [3, 9].includes(statusCode);
}
export const colorToText = {
ok: 'text-ok',
warn: 'text-warn',
crit: 'text-crit',
info: 'text-info',
muted: 'text-text-muted',
};
export const colorToBg = {
ok: 'bg-ok/10',
warn: 'bg-warn/10',
crit: 'bg-crit/10',
info: 'bg-info/10',
muted: 'bg-raised',
};
export function formatRpo(seconds) {
if (seconds == null || isNaN(seconds)) return '—';
const s = Math.round(seconds);
if (s < 60) return `${s}s`;
if (s < 3600) return `${Math.floor(s / 60)}m ${String(s % 60).padStart(2,'0')}s`;
return `${Math.floor(s / 3600)}h ${String(Math.floor((s % 3600) / 60)).padStart(2,'0')}m`;
}
export function formatBytes(bytes, decimals = 1) {
if (!bytes || bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(decimals))} ${sizes[i]}`;
}
export function formatMB(mb) {
return formatBytes((mb ?? 0) * 1024 * 1024);
}
export function rpoStatus(actualSec, configuredSec) {
if (!actualSec || !configuredSec) return 'muted';
const ratio = actualSec / configuredSec;
if (ratio <= 0.75) return 'ok';
if (ratio <= 1.0) return 'warn';
return 'crit';
}
+18
View File
@@ -0,0 +1,18 @@
// src/hooks/useInstantQuery.js
import { useQuery } from '@tanstack/react-query';
import { instantQuery } from '@/api/prometheus';
export function useInstantQuery(promql, {
refreshMs = 30_000,
enabled = true,
select,
} = {}) {
return useQuery({
queryKey: ['instant', promql],
queryFn: () => instantQuery(promql),
refetchInterval: refreshMs,
enabled: enabled && !!promql,
select,
staleTime: refreshMs / 2,
});
}
+42
View File
@@ -0,0 +1,42 @@
// src/hooks/useRangeQuery.js
import { useQuery } from '@tanstack/react-query';
import { rangeQuery } from '@/api/prometheus';
const WINDOW_SECONDS = {
'1h': 3600,
'6h': 21600,
'24h': 86400,
'7d': 604800,
'30d': 2592000,
};
const STEP_FOR_WINDOW = {
'1h': '30s',
'6h': '120s',
'24h': '300s',
'7d': '900s',
'30d': '3600s',
};
export function useRangeQuery(promql, {
window = '6h',
refreshMs = 60_000,
enabled = true,
select,
} = {}) {
const windowSec = WINDOW_SECONDS[window] ?? 21600;
const step = STEP_FOR_WINDOW[window] ?? '120s';
return useQuery({
queryKey: ['range', promql, window],
queryFn: () => {
const end = Math.floor(Date.now() / 1000);
const start = end - windowSec;
return rangeQuery(promql, start, end, step);
},
refetchInterval: refreshMs,
enabled: enabled && !!promql,
select,
staleTime: refreshMs / 2,
});
}
+11
View File
@@ -0,0 +1,11 @@
// src/main.jsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import '@/styles/index.css';
import App from './App';
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>
);
+130
View File
@@ -0,0 +1,130 @@
// src/pages/Encryption.jsx
import { useQuery } from '@tanstack/react-query';
import { ShieldAlert, ShieldCheck, TrendingUp, AlertTriangle, Loader2 } from 'lucide-react';
import { queryEncryptionDetail } from '@/api/prometheusExtended';
import TimeSeriesChart from '@/components/charts/TimeSeriesChart';
import clsx from 'clsx';
const REFRESH = 30_000;
function EncryptionBar({ pct }) {
const enc = Math.min(pct ?? 0, 100);
const color = enc > 80 ? 'bg-crit' : enc > 50 ? 'bg-warn' : 'bg-ok';
const textC = enc > 80 ? 'text-crit' : enc > 50 ? 'text-warn' : 'text-ok';
return (
<div className="flex items-center gap-2 w-full">
<div className="flex-1 h-2 bg-border rounded-full overflow-hidden flex">
<div className={clsx('h-full transition-all duration-500', color)} style={{ width: `${enc}%` }} />
</div>
<span className={clsx('font-mono text-xs data-value w-12 text-right flex-shrink-0', textC)}>
{enc.toFixed(1)}%
</span>
</div>
);
}
function TrendBadge({ level }) {
const l = level ?? 0;
if (l === 0) return <span className="badge badge-ok">Stable</span>;
if (l === 1) return <span className="badge badge-warn">Rising</span>;
return <span className="badge badge-crit">Spike</span>;
}
export default function EncryptionPage() {
const { data: vms = [], isLoading } = useQuery({
queryKey: ['encryption-detail'],
queryFn: queryEncryptionDetail,
refetchInterval: REFRESH,
});
const anomalies = vms.filter((v) => (v.pctEncrypted ?? 0) > 50);
const highAlert = vms.filter((v) => (v.pctEncrypted ?? 0) > 80);
const avgPct = vms.length ? vms.reduce((s, v) => s + (v.pctEncrypted ?? 0), 0) / vms.length : 0;
return (
<div className="space-y-6 animate-fade-in">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[
{ label: 'VMs Monitored', value: vms.length, icon: ShieldAlert, color: 'accent' },
{ label: 'Anomalies (>50%)', value: anomalies.length, icon: AlertTriangle, color: anomalies.length ? 'warn' : 'ok' },
{ label: 'High Alert (>80%)', value: highAlert.length, icon: TrendingUp, color: highAlert.length ? 'crit' : 'ok' },
{ label: 'Avg Encryption', value: `${avgPct.toFixed(1)}%`, icon: ShieldCheck, color: avgPct > 60 ? 'warn' : 'ok' },
].map((s) => (
<div key={s.label} className="card p-4 flex items-start gap-3">
<div className={clsx('w-9 h-9 rounded-lg flex items-center justify-center',
s.color === 'ok' && 'bg-ok/10', s.color === 'warn' && 'bg-warn/10',
s.color === 'crit' && 'bg-crit/10', s.color === 'accent' && 'bg-accent/10')}>
<s.icon size={16} className={clsx(
s.color === 'ok' && 'text-ok', s.color === 'warn' && 'text-warn',
s.color === 'crit' && 'text-crit', s.color === 'accent' && 'text-accent')} />
</div>
<div>
<p className="section-title">{s.label}</p>
<p className="font-data text-xl font-semibold text-text-primary data-value mt-0.5">{s.value}</p>
</div>
</div>
))}
</div>
{anomalies.length > 0 && (
<TimeSeriesChart
title="Encryption % Over Time — Top Anomalies"
promql={`vm_PercentEncrypted{VmName="${anomalies[0]?.name?.replace(/"/g, '\\"')}"}`}
yFormatter={(v) => `${v.toFixed(1)}%`}
refLines={[{ value: 80, label: 'High alert', color: 'crit' }, { value: 50, label: 'Warning', color: 'warn' }]}
transform={(result) => result[0]?.values.map(([ts, v]) => ({ ts: ts * 1000, 'Encrypted %': parseFloat(v) })) ?? []}
height={180}
/>
)}
<section>
<p className="section-title mb-3">VM Encryption Status</p>
<div className="card overflow-hidden">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border">
<th className="px-4 py-2.5 text-left section-title">VM</th>
<th className="px-4 py-2.5 text-left section-title">VPG</th>
<th className="px-4 py-2.5 text-left section-title">Encryption %</th>
<th className="px-4 py-2.5 text-left section-title hidden md:table-cell">Trend</th>
<th className="px-4 py-2.5 text-right section-title hidden lg:table-cell">IO Ops</th>
<th className="px-4 py-2.5 text-right section-title hidden lg:table-cell">Write</th>
</tr>
</thead>
<tbody>
{isLoading && (
<tr><td colSpan={6} className="py-10 text-center">
<Loader2 size={16} className="animate-spin text-text-muted mx-auto" />
</td></tr>
)}
{!isLoading && vms.map((vm) => (
<tr key={vm.id} className="border-b border-border/40 last:border-0 hover:bg-raised transition-colors">
<td className="px-4 py-2.5 font-medium text-text-primary">{vm.name}</td>
<td className="px-4 py-2.5 text-text-muted">{vm.vpgName}</td>
<td className="px-4 py-2.5 min-w-[160px]"><EncryptionBar pct={vm.pctEncrypted} /></td>
<td className="px-4 py-2.5 hidden md:table-cell"><TrendBadge level={vm.trendLevel} /></td>
<td className="px-4 py-2.5 text-right hidden lg:table-cell">
<span className="font-mono data-value text-text-secondary">
{vm.ioOps != null ? Math.round(vm.ioOps).toLocaleString() : '—'}
</span>
</td>
<td className="px-4 py-2.5 text-right hidden lg:table-cell">
<span className="font-mono data-value text-text-secondary">
{vm.writeMb != null ? `${vm.writeMb.toFixed(2)} MB` : '—'}
</span>
</td>
</tr>
))}
{!isLoading && vms.length === 0 && (
<tr><td colSpan={6} className="py-12 text-center">
<ShieldCheck size={24} className="text-ok mx-auto mb-2" />
<p className="text-text-muted">No encryption stats available</p>
</td></tr>
)}
</tbody>
</table>
</div>
</section>
</div>
);
}
+187
View File
@@ -0,0 +1,187 @@
// src/pages/Overview.jsx
import { useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { CheckCircle2, AlertTriangle, XCircle, Activity } from 'lucide-react';
import { queryOverviewSummary, queryAllVpgs, queryTopRpoViolators, queryExporterHealth } from '@/api/prometheus';
import { rpoStatus, formatRpo, colorToText } from '@/constants/statusMaps';
import clsx from 'clsx';
const REFRESH = 30_000;
function StatCard({ label, value, sub, color = 'accent', icon: Icon }) {
return (
<div className="card p-4 flex items-start gap-4">
<div className={clsx('w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0',
`bg-${color}/10`)}>
<Icon size={18} className={`text-${color}`} />
</div>
<div className="min-w-0">
<p className="section-title">{label}</p>
<p className="font-data text-2xl font-semibold text-text-primary mt-0.5 data-value">{value ?? '—'}</p>
{sub && <p className="text-xs text-text-muted mt-0.5">{sub}</p>}
</div>
</div>
);
}
function SiteCard({ site }) {
const hasCrit = site.crit > 0;
return (
<div className={clsx('card p-4 border transition-colors duration-300',
hasCrit ? 'border-crit/30' : site.warn > 0 ? 'border-warn/30' : 'border-border')}>
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2">
<span className={clsx('status-dot', hasCrit ? 'status-dot-crit' : site.warn > 0 ? 'status-dot-warn' : 'status-dot-ok')} />
<p className="font-mono text-sm font-semibold text-text-primary">{site.siteName}</p>
</div>
<span className={clsx('badge text-xs', hasCrit ? 'badge-crit' : site.warn > 0 ? 'badge-warn' : 'badge-ok')}>
{hasCrit ? 'Alert' : site.warn > 0 ? 'Warning' : 'Healthy'}
</span>
</div>
<div className="grid grid-cols-3 gap-2 text-center">
<div><p className="font-data text-xl font-semibold text-ok data-value">{site.ok}</p><p className="section-title">OK</p></div>
<div><p className="font-data text-xl font-semibold text-warn data-value">{site.warn}</p><p className="section-title">Warn</p></div>
<div><p className="font-data text-xl font-semibold text-crit data-value">{site.crit}</p><p className="section-title">Crit</p></div>
</div>
</div>
);
}
function VpgTile({ vpg, onClick }) {
const status = rpoStatus(vpg.actualRpoSec, vpg.configuredRpoSec);
return (
<button onClick={onClick}
title={`${vpg.name}\nRPO: ${formatRpo(vpg.actualRpoSec)}`}
className={clsx('relative p-2 rounded-md border text-left transition-all duration-200 hover:scale-105 hover:z-10',
status === 'ok' && 'bg-ok/8 border-ok/20',
status === 'warn' && 'bg-warn/8 border-warn/20',
status === 'crit' && 'bg-crit/8 border-crit/20',
status === 'muted' && 'bg-raised border-border')}>
<p className={clsx('text-[10px] font-mono font-semibold truncate leading-tight',
status === 'ok' && 'text-ok', status === 'warn' && 'text-warn',
status === 'crit' && 'text-crit', status === 'muted' && 'text-text-muted')}>
{vpg.name}
</p>
<p className="text-[9px] text-text-muted font-mono data-value mt-0.5">{formatRpo(vpg.actualRpoSec)}</p>
</button>
);
}
export default function Overview() {
const navigate = useNavigate();
const { data: sites = [] } = useQuery({
queryKey: ['overview-summary'], queryFn: queryOverviewSummary, refetchInterval: REFRESH,
});
const { data: vpgs = [], isLoading: vpgsLoading } = useQuery({
queryKey: ['all-vpgs'], queryFn: queryAllVpgs, refetchInterval: REFRESH,
});
const { data: violators = [] } = useQuery({
queryKey: ['top-violators'], queryFn: () => queryTopRpoViolators(10), refetchInterval: REFRESH,
});
const { data: exporterHealth = [] } = useQuery({
queryKey: ['exporter-health'], queryFn: queryExporterHealth, refetchInterval: REFRESH,
});
const totalOk = sites.reduce((s, x) => s + x.ok, 0);
const totalWarn = sites.reduce((s, x) => s + x.warn, 0);
const totalCrit = sites.reduce((s, x) => s + x.crit, 0);
const totalMbps = sites.reduce((s, x) => s + (x.throughputMb ?? 0), 0);
return (
<div className="space-y-6 animate-fade-in">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard label="Meeting SLA" value={totalOk} sub="VPGs within RPO target" color="ok" icon={CheckCircle2} />
<StatCard label="Warnings" value={totalWarn} sub="Approaching RPO limit" color="warn" icon={AlertTriangle} />
<StatCard label="Violations" value={totalCrit} sub="Exceeding RPO target" color="crit" icon={XCircle} />
<StatCard label="Replication" value={`${totalMbps.toFixed(1)} MB/s`} sub="Total throughput" color="accent" icon={Activity} />
</div>
{sites.length > 0 && (
<section>
<p className="section-title mb-3">Sites</p>
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
{sites.map((s) => <SiteCard key={s.siteName} site={s} />)}
</div>
</section>
)}
<section>
<p className="section-title mb-3">VPG RPO Heat Grid</p>
{vpgsLoading ? (
<div className="card p-8 text-center text-text-muted text-xs font-mono">Loading VPGs</div>
) : (
<div className="card p-4">
<div className="grid gap-2" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(110px, 1fr))' }}>
{vpgs.sort((a, b) => {
const sev = (v) => { const s = rpoStatus(v.actualRpoSec, v.configuredRpoSec); return s === 'crit' ? 0 : s === 'warn' ? 1 : 2; };
return sev(a) - sev(b);
}).map((vpg) => (
<VpgTile key={vpg.id} vpg={vpg} onClick={() => navigate(`/vpgs?name=${encodeURIComponent(vpg.name)}`)} />
))}
</div>
</div>
)}
</section>
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
<div className="xl:col-span-2">
<p className="section-title mb-3">Top RPO Violators</p>
<div className="card overflow-hidden">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border">
<th className="px-3 py-2 text-left section-title">VPG</th>
<th className="px-3 py-2 text-left section-title">Site</th>
<th className="px-3 py-2 text-right section-title">Actual RPO</th>
<th className="px-3 py-2 text-right section-title">Target</th>
<th className="px-3 py-2 text-right section-title">Ratio</th>
</tr>
</thead>
<tbody>
{violators.map((v) => {
const ratio = v.configuredRpoSec ? v.actualRpoSec / v.configuredRpoSec : 0;
const status = rpoStatus(v.actualRpoSec, v.configuredRpoSec);
return (
<tr key={v.id} className="table-row-hover border-b border-border/40 last:border-0"
onClick={() => navigate(`/vpgs?name=${encodeURIComponent(v.name)}`)}>
<td className="px-3 py-2 font-mono font-semibold text-text-primary">{v.name}</td>
<td className="px-3 py-2 text-text-muted">{v.siteName}</td>
<td className={clsx('px-3 py-2 text-right font-data data-value', colorToText[status])}>{formatRpo(v.actualRpoSec)}</td>
<td className="px-3 py-2 text-right font-data data-value text-text-muted">{formatRpo(v.configuredRpoSec)}</td>
<td className="px-3 py-2 text-right"><span className={clsx('badge', `badge-${status}`)}>{ratio.toFixed(1)}x</span></td>
</tr>
);
})}
{violators.length === 0 && (
<tr><td colSpan={5} className="px-3 py-8 text-center text-text-muted">
<CheckCircle2 size={20} className="text-ok mx-auto mb-1" />All VPGs within RPO targets
</td></tr>
)}
</tbody>
</table>
</div>
</div>
<div>
<p className="section-title mb-3">Collector Health</p>
<div className="card p-4 space-y-3">
{exporterHealth.length === 0 && <p className="text-xs text-text-muted italic">No exporter data</p>}
{exporterHealth.map((t) => (
<div key={`${t.instance}-${t.thread}`} className="flex items-center justify-between py-1.5 border-b border-border/40 last:border-0">
<div>
<p className="text-xs font-mono font-medium text-text-primary">{t.thread}</p>
<p className="text-[10px] text-text-muted">{t.instance}</p>
</div>
<span className={clsx('badge', t.alive ? 'badge-ok' : 'badge-crit')}>
<span className={clsx('status-dot', t.alive ? 'status-dot-ok' : 'status-dot-crit')} />
{t.alive ? 'Running' : 'Down'}
</span>
</div>
))}
</div>
</div>
</div>
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
// src/pages/Placeholder.jsx
// Temporary placeholder used for pages not yet built (Phase 2+).
// Displays a construction card so navigation works from day one.
import { Construction } from 'lucide-react';
export default function Placeholder({ title, description }) {
return (
<div className="flex items-center justify-center h-full animate-fade-in">
<div className="card p-12 text-center max-w-md">
<div className="w-14 h-14 rounded-xl bg-accent/10 flex items-center justify-center mx-auto mb-5">
<Construction size={24} className="text-accent" />
</div>
<h2 className="font-mono text-base font-semibold text-text-primary mb-2">{title}</h2>
<p className="text-sm text-text-muted leading-relaxed">{description}</p>
<p className="mt-4 text-xs font-mono text-text-muted border border-border rounded px-3 py-1.5 inline-block">
Phase 2 Coming next
</p>
</div>
</div>
);
}
+473
View File
@@ -0,0 +1,473 @@
// src/pages/Planner.jsx — DR capacity planner
import { useState, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Calculator, HardDrive, Wifi, Database, Download, Search, FileText } from 'lucide-react';
import { queryPlannerVms } from '@/api/planner';
import clsx from 'clsx';
import { jsPDF } from 'jspdf';
const REFRESH = 60_000;
// ── Helpers ──────────────────────────────────────────────────────────────────
function fmtGb(gb) {
if (gb == null || isNaN(gb)) return '—';
if (gb >= 1024) return `${(gb / 1024).toFixed(2)} TB`;
return `${gb.toFixed(1)} GB`;
}
function fmtMbps(mbps) {
if (mbps == null || isNaN(mbps)) return '—';
if (mbps >= 1000) return `${(mbps / 1000).toFixed(2)} Gbps`;
return `${mbps.toFixed(1)} Mbps`;
}
const JOURNAL_OPTIONS = [
{ label: '1 hour', seconds: 3600 },
{ label: '4 hours', seconds: 14400 },
{ label: '8 hours', seconds: 28800 },
...Array.from({ length: 30 }, (_, i) => ({
label: i === 0 ? '1 day' : `${i + 1} days`,
seconds: (i + 1) * 86400,
})),
];
// ── Result card ───────────────────────────────────────────────────────────────
function ResultCard({ icon: Icon, label, value, sub, color = 'accent' }) {
return (
<div className="card p-5 flex items-start gap-4">
<div className={clsx('w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0', `bg-${color}/10`)}>
<Icon size={18} className={`text-${color}`} />
</div>
<div>
<p className="section-title">{label}</p>
<p className="font-data text-2xl font-semibold text-text-primary mt-0.5 data-value">{value}</p>
{sub && <p className="text-xs text-text-muted mt-1">{sub}</p>}
</div>
</div>
);
}
// ── VM row ────────────────────────────────────────────────────────────────────
function VmRow({ vm, selected, onToggle }) {
return (
<tr
onClick={onToggle}
className={clsx(
'cursor-pointer transition-colors duration-100',
selected ? 'bg-accent/8' : 'hover:bg-raised',
)}
>
<td className="px-3 py-2.5 w-8">
<input
type="checkbox"
checked={selected}
onChange={onToggle}
onClick={(e) => e.stopPropagation()}
className="accent-accent"
/>
</td>
<td className="px-3 py-2.5 font-mono text-xs text-text-primary">{vm.name}</td>
<td className="px-3 py-2.5 font-mono text-xs text-text-secondary">{vm.cluster || '—'}</td>
<td className="px-3 py-2.5 font-mono text-xs text-text-secondary">{vm.datacenter || '—'}</td>
<td className="px-3 py-2.5 font-mono text-xs text-right data-value">{fmtGb(vm.provisionedGb)}</td>
<td className="px-3 py-2.5 font-mono text-xs text-right data-value">{fmtMbps(vm.writeThroughputMbps)}</td>
<td className="px-3 py-2.5 font-mono text-xs text-right text-text-muted data-value">
{vm.writeIops != null ? vm.writeIops.toFixed(0) : '—'}
</td>
</tr>
);
}
// ── Mock data for preview ─────────────────────────────────────────────────────
const MOCK_VMS = [
{ moref: 'vm-101', name: 'web-prod-01', cluster: 'Cluster-01', datacenter: 'DC-East', provisionedGb: 120, writeThroughputMbps: 45.2, writeIops: 1820, writeLatencyMs: 3.1 },
{ moref: 'vm-102', name: 'db-prod-01', cluster: 'Cluster-01', datacenter: 'DC-East', provisionedGb: 2048, writeThroughputMbps: 312.8, writeIops: 12400, writeLatencyMs: 1.8 },
{ moref: 'vm-103', name: 'db-prod-02', cluster: 'Cluster-01', datacenter: 'DC-East', provisionedGb: 2048, writeThroughputMbps: 287.4, writeIops: 11200, writeLatencyMs: 2.0 },
{ moref: 'vm-104', name: 'app-prod-01', cluster: 'Cluster-02', datacenter: 'DC-East', provisionedGb: 256, writeThroughputMbps: 18.6, writeIops: 640, writeLatencyMs: 4.2 },
{ moref: 'vm-105', name: 'app-prod-02', cluster: 'Cluster-02', datacenter: 'DC-East', provisionedGb: 256, writeThroughputMbps: 21.3, writeIops: 780, writeLatencyMs: 3.9 },
{ moref: 'vm-106', name: 'cache-01', cluster: 'Cluster-02', datacenter: 'DC-East', provisionedGb: 512, writeThroughputMbps: 8.1, writeIops: 310, writeLatencyMs: 5.5 },
{ moref: 'vm-107', name: 'file-srv-01', cluster: 'Cluster-03', datacenter: 'DC-West', provisionedGb: 4096, writeThroughputMbps: 92.0, writeIops: 3200, writeLatencyMs: 6.1 },
{ moref: 'vm-108', name: 'infra-dc-01', cluster: 'Cluster-03', datacenter: 'DC-West', provisionedGb: 80, writeThroughputMbps: 2.4, writeIops: 120, writeLatencyMs: 8.2 },
{ moref: 'vm-109', name: 'backup-srv-01',cluster: 'Cluster-03', datacenter: 'DC-West', provisionedGb: 8192, writeThroughputMbps: 180.0,writeIops: 5600, writeLatencyMs: 12.0 },
{ moref: 'vm-110', name: 'mon-01', cluster: 'Cluster-01', datacenter: 'DC-East', provisionedGb: 100, writeThroughputMbps: 1.2, writeIops: 55, writeLatencyMs: 9.0 },
];
// ── Main page ─────────────────────────────────────────────────────────────────
export default function Planner() {
const [selected, setSelected] = useState(new Set());
const [journalIdx, setJournalIdx] = useState(3); // default: 1 day
const [compression, setCompression] = useState(50); // default: 50%
const [search, setSearch] = useState('');
const isMock = import.meta.env.VITE_MOCK_AUTH === 'true';
const { data: liveVms = [], isLoading } = useQuery({
queryKey: ['planner-vms'],
queryFn: queryPlannerVms,
refetchInterval: REFRESH,
enabled: !isMock,
});
const vms = isMock ? MOCK_VMS : liveVms;
const filtered = useMemo(() =>
vms.filter((vm) =>
!search || vm.name.toLowerCase().includes(search.toLowerCase()) ||
vm.cluster.toLowerCase().includes(search.toLowerCase()) ||
vm.datacenter.toLowerCase().includes(search.toLowerCase())
),
[vms, search]
);
const toggle = (moref) =>
setSelected((prev) => {
const next = new Set(prev);
next.has(moref) ? next.delete(moref) : next.add(moref);
return next;
});
const toggleAll = () => {
if (selected.size === filtered.length) {
setSelected(new Set());
} else {
setSelected(new Set(filtered.map((v) => v.moref)));
}
};
const selectedVms = vms.filter((v) => selected.has(v.moref));
const journalSec = JOURNAL_OPTIONS[journalIdx].seconds;
const ratio = compression / 100;
// ── Calculations ───────────────────────────────────────────────────────────
const totalThroughputMbps = selectedVms.reduce((s, v) => s + (v.writeThroughputMbps ?? 0), 0);
const totalProvisionedGb = selectedVms.reduce((s, v) => s + (v.provisionedGb ?? 0), 0);
const bwRequiredMbps = totalThroughputMbps * (1 - ratio);
const journalStorageGb = (totalThroughputMbps * (1 - ratio)) * (journalSec / 1024); // MB/s → GB over period
const mirrorStorageGb = totalProvisionedGb;
const totalDrStorageGb = journalStorageGb + mirrorStorageGb;
// ── Export ─────────────────────────────────────────────────────────────────
const exportCsv = () => {
const rows = [
['VM Name', 'Cluster', 'Datacenter', 'Provisioned (GB)', 'Write Throughput (Mbps)', 'Write IOPS'],
...selectedVms.map((v) => [
v.name, v.cluster, v.datacenter,
(v.provisionedGb ?? 0).toFixed(1),
(v.writeThroughputMbps ?? 0).toFixed(2),
(v.writeIops ?? 0).toFixed(0),
]),
[],
['--- Summary ---'],
['Journal Retention', JOURNAL_OPTIONS[journalIdx].label],
['Compression', `${compression}%`],
['Bandwidth Required', `${fmtMbps(bwRequiredMbps)}`],
['Journal Storage', `${fmtGb(journalStorageGb)}`],
['Mirror Storage', `${fmtGb(mirrorStorageGb)}`],
['Total DR Storage', `${fmtGb(totalDrStorageGb)}`],
];
const csv = rows.map((r) => r.map((c) => `"${c}"`).join(',')).join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = 'zroc-planner-report.csv'; a.click();
URL.revokeObjectURL(url);
};
const exportPdf = () => {
const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' });
const margin = 15;
const pageW = 210;
const colW = pageW - margin * 2;
let y = margin;
// Title
doc.setFontSize(18);
doc.setFont('helvetica', 'bold');
doc.text('zROC — DR Capacity Planner Report', margin, y);
y += 8;
doc.setFontSize(9);
doc.setFont('helvetica', 'normal');
doc.setTextColor(100);
doc.text(`Generated: ${new Date().toLocaleString()}`, margin, y);
y += 10;
// Summary box
doc.setDrawColor(14, 165, 233);
doc.setFillColor(240, 248, 255);
doc.roundedRect(margin, y, colW, 40, 2, 2, 'FD');
doc.setTextColor(0);
doc.setFontSize(10);
doc.setFont('helvetica', 'bold');
doc.text('Planning Parameters', margin + 4, y + 7);
doc.setFont('helvetica', 'normal');
doc.setFontSize(9);
const params = [
['VMs selected', `${selected.size}`],
['Journal retention', JOURNAL_OPTIONS[journalIdx].label],
['WAN compression', `${compression}%`],
];
params.forEach(([label, val], i) => {
doc.setTextColor(80); doc.text(label, margin + 4, y + 15 + i * 7);
doc.setTextColor(0); doc.text(val, margin + 60, y + 15 + i * 7);
});
y += 48;
// Results
doc.setFont('helvetica', 'bold');
doc.setFontSize(11);
doc.text('Capacity Estimates', margin, y);
y += 6;
const results = [
['Bandwidth Required', fmtMbps(bwRequiredMbps), `Raw ${fmtMbps(totalThroughputMbps)} × ${100 - compression}%`],
['Journal Storage', fmtGb(journalStorageGb), `${JOURNAL_OPTIONS[journalIdx].label} at ${fmtMbps(bwRequiredMbps)}`],
['Mirror Storage', fmtGb(mirrorStorageGb), 'Full copy of selected VM disks'],
['Total DR Storage Footprint', fmtGb(totalDrStorageGb), 'Journal + Mirror combined'],
];
results.forEach(([label, val, note]) => {
doc.setFontSize(9); doc.setFont('helvetica', 'normal'); doc.setTextColor(80);
doc.text(label, margin, y);
doc.setFont('helvetica', 'bold'); doc.setTextColor(0); doc.setFontSize(12);
doc.text(val, margin + 70, y);
doc.setFont('helvetica', 'normal'); doc.setFontSize(8); doc.setTextColor(120);
doc.text(note, margin + 110, y);
y += 9;
});
y += 6;
// VM table header
doc.setFont('helvetica', 'bold');
doc.setFontSize(11);
doc.setTextColor(0);
doc.text('Selected VMs', margin, y);
y += 5;
doc.setFillColor(230, 240, 255);
doc.rect(margin, y, colW, 6, 'F');
doc.setFontSize(8);
['VM Name', 'Cluster', 'Datacenter', 'Disk (GB)', 'Write BW', 'IOPS'].forEach((h, i) => {
doc.text(h, margin + [0, 50, 85, 120, 143, 163][i], y + 4);
});
y += 7;
// VM rows
doc.setFont('helvetica', 'normal');
selectedVms.forEach((vm, idx) => {
if (y > 270) { doc.addPage(); y = margin; }
if (idx % 2 === 0) { doc.setFillColor(248, 250, 252); doc.rect(margin, y - 1, colW, 6, 'F'); }
doc.setTextColor(0);
doc.setFontSize(8);
[
vm.name.slice(0, 24),
(vm.cluster || '—').slice(0, 16),
(vm.datacenter || '—').slice(0, 14),
(vm.provisionedGb ?? 0).toFixed(1),
fmtMbps(vm.writeThroughputMbps),
(vm.writeIops ?? 0).toFixed(0),
].forEach((val, i) => doc.text(val, margin + [0, 50, 85, 120, 143, 163][i], y + 4));
y += 6;
});
doc.save('zroc-planner-report.pdf');
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Calculator size={20} className="text-accent" />
<h1 className="text-lg font-semibold text-text-primary">DR Capacity Planner</h1>
</div>
<div className="flex items-center gap-2">
<button
onClick={exportCsv}
disabled={selected.size === 0}
className={clsx(
'flex items-center gap-2 px-3 py-1.5 rounded-md text-xs font-medium border transition-colors duration-150',
selected.size > 0
? 'border-border text-text-secondary hover:bg-raised hover:text-text-primary'
: 'text-text-muted border-border cursor-not-allowed opacity-50',
)}
>
<Download size={13} />
CSV
</button>
<button
onClick={exportPdf}
disabled={selected.size === 0}
className={clsx(
'flex items-center gap-2 px-3 py-1.5 rounded-md text-xs font-medium border transition-colors duration-150',
selected.size > 0
? 'bg-accent text-canvas border-accent hover:bg-accent/80'
: 'text-text-muted border-border cursor-not-allowed opacity-50',
)}
>
<FileText size={13} />
Export PDF
</button>
</div>
</div>
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
{/* Left — VM selector */}
<div className="xl:col-span-2 card overflow-hidden">
<div className="px-4 py-3 border-b border-border flex items-center justify-between">
<p className="font-mono text-xs text-text-secondary uppercase tracking-wider">
Select VMs to model
</p>
<span className="text-xs text-text-muted">
{selected.size} / {vms.length} selected
</span>
</div>
{/* Search */}
<div className="px-4 py-2 border-b border-border">
<div className="relative">
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-text-muted" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Filter VMs…"
className="w-full bg-raised border border-border rounded-md pl-7 pr-3 py-1.5 text-xs font-mono text-text-primary placeholder:text-text-muted focus:outline-none focus:border-accent"
/>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border">
<th className="px-3 py-2 w-8">
<input
type="checkbox"
checked={filtered.length > 0 && selected.size === filtered.length}
onChange={toggleAll}
className="accent-accent"
/>
</th>
<th className="px-3 py-2 text-left section-title">VM</th>
<th className="px-3 py-2 text-left section-title">Cluster</th>
<th className="px-3 py-2 text-left section-title">Datacenter</th>
<th className="px-3 py-2 text-right section-title">Disk Size</th>
<th className="px-3 py-2 text-right section-title">Write BW</th>
<th className="px-3 py-2 text-right section-title">Write IOPS</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{isLoading && !isMock ? (
<tr><td colSpan={7} className="px-4 py-8 text-center text-text-muted">Loading VMs</td></tr>
) : filtered.length === 0 ? (
<tr><td colSpan={7} className="px-4 py-8 text-center text-text-muted">No VMs found</td></tr>
) : (
filtered.map((vm) => (
<VmRow
key={vm.moref}
vm={vm}
selected={selected.has(vm.moref)}
onToggle={() => toggle(vm.moref)}
/>
))
)}
</tbody>
</table>
</div>
</div>
{/* Right — inputs + results */}
<div className="space-y-4">
{/* Inputs */}
<div className="card p-4 space-y-5">
<p className="font-mono text-xs text-text-secondary uppercase tracking-wider border-b border-border pb-2">
Planning Inputs
</p>
{/* Journal retention */}
<div>
<div className="flex justify-between mb-2">
<label className="section-title">Journal Retention</label>
<span className="font-mono text-xs text-accent font-semibold">
{JOURNAL_OPTIONS[journalIdx].label}
</span>
</div>
<input
type="range"
min={0}
max={JOURNAL_OPTIONS.length - 1}
value={journalIdx}
onChange={(e) => setJournalIdx(Number(e.target.value))}
className="w-full accent-accent"
/>
<div className="flex justify-between text-[9px] text-text-muted font-mono mt-1">
<span>1h</span><span>8h</span><span>7d</span><span>15d</span><span>30d</span>
</div>
</div>
{/* Compression */}
<div>
<div className="flex justify-between mb-2">
<label className="section-title">WAN Compression</label>
<span className="font-mono text-xs text-accent font-semibold">{compression}%</span>
</div>
<input
type="range"
min={0}
max={80}
step={5}
value={compression}
onChange={(e) => setCompression(Number(e.target.value))}
className="w-full accent-accent"
/>
<div className="flex justify-between text-[9px] text-text-muted font-mono mt-1">
<span>0%</span><span>40%</span><span>80%</span>
</div>
</div>
</div>
{/* Results */}
<div className="space-y-3">
{selected.size === 0 && (
<p className="text-xs text-text-muted text-center py-2">
Select VMs to see estimates
</p>
)}
<ResultCard
icon={Wifi}
label="Bandwidth Required"
value={fmtMbps(bwRequiredMbps)}
sub={`Raw: ${fmtMbps(totalThroughputMbps)}${compression}% compressed`}
color="accent"
/>
<ResultCard
icon={HardDrive}
label="Journal Storage"
value={fmtGb(journalStorageGb)}
sub={`${JOURNAL_OPTIONS[journalIdx].label} at ${fmtMbps(bwRequiredMbps)} after compression`}
color="warn"
/>
<ResultCard
icon={Database}
label="Mirror Storage"
value={fmtGb(mirrorStorageGb)}
sub="Full copy of selected VM disks"
color="ok"
/>
<div className="card p-4 border-accent/20 bg-accent/5">
<p className="section-title mb-1">Total DR Storage Footprint</p>
<p className="font-data text-3xl font-semibold text-accent data-value">
{fmtGb(totalDrStorageGb)}
</p>
<p className="text-xs text-text-muted mt-1">
Journal + Mirror across {selected.size} VM{selected.size !== 1 ? 's' : ''}
</p>
</div>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,291 @@
// src/pages/Settings/UserManagement.jsx
import { useState, useEffect, useCallback, useRef } from 'react';
import { Users, UserPlus, Search, Shield, ShieldOff, ShieldCheck, Pencil, Trash2, QrCode, X, Check, Loader2, AlertTriangle, Copy, ExternalLink } from 'lucide-react';
import { usersApi } from '@/api/users';
import clsx from 'clsx';
function Avatar({ name, size = 'md' }) {
const initials = name ? name.split(' ').map((w) => w[0]).slice(0, 2).join('').toUpperCase() : '?';
const colors = ['bg-accent/20 text-accent', 'bg-ok/20 text-ok', 'bg-info/20 text-info', 'bg-warn/20 text-warn'];
const color = colors[(name?.charCodeAt(0) ?? 0) % colors.length];
const sz = size === 'lg' ? 'w-10 h-10 text-sm' : 'w-8 h-8 text-xs';
return <div className={clsx('rounded-full flex items-center justify-center font-mono font-semibold flex-shrink-0', sz, color)}>{initials}</div>;
}
function Toast({ message, type = 'ok', onDismiss }) {
useEffect(() => { const t = setTimeout(onDismiss, 3500); return () => clearTimeout(t); }, [onDismiss]);
return (
<div className={clsx('fixed bottom-6 right-6 z-[100] flex items-center gap-3 px-4 py-3 rounded-lg border shadow-panel animate-fade-in',
type === 'ok' && 'bg-surface border-ok/30 text-ok', type === 'error' && 'bg-surface border-crit/30 text-crit')}>
{type === 'ok' && <Check size={14} />}{type === 'error' && <AlertTriangle size={14} />}
<span className="text-sm font-medium">{message}</span>
<button onClick={onDismiss} className="ml-2 text-text-muted hover:text-text-primary"><X size={12} /></button>
</div>
);
}
function DeleteModal({ user, onConfirm, onCancel, loading }) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="drawer-overlay" onClick={onCancel} />
<div className="card-raised p-6 w-full max-w-sm z-10 animate-modal-in">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-full bg-crit/10 flex items-center justify-center"><Trash2 size={18} className="text-crit" /></div>
<div><p className="font-medium text-text-primary">Delete user</p><p className="text-xs text-text-muted">This cannot be undone</p></div>
</div>
<p className="text-sm text-text-secondary mb-6">Delete <span className="text-text-primary font-medium">{user.name}</span> ({user.username})?</p>
<div className="flex justify-end gap-3">
<button className="btn-ghost" onClick={onCancel} disabled={loading}>Cancel</button>
<button className="btn-danger" onClick={onConfirm} disabled={loading}>
{loading ? <Loader2 size={14} className="animate-spin" /> : <Trash2 size={14} />} Delete
</button>
</div>
</div>
</div>
);
}
function TwoFactorModal({ user, onClose }) {
const [state, setState] = useState('idle');
const [result, setResult] = useState(null);
const generate = useCallback(async () => {
setState('loading');
try { const data = await usersApi.setup2fa(user.id); setResult(data); setState('done'); }
catch { setState('error'); }
}, [user.id]);
useEffect(() => { generate(); }, [generate]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="drawer-overlay" onClick={onClose} />
<div className="card-raised p-6 w-full max-w-md z-10 animate-modal-in">
<div className="flex items-start justify-between mb-5">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-accent/10 flex items-center justify-center"><QrCode size={18} className="text-accent" /></div>
<div><p className="font-medium text-text-primary">Set up 2FA</p><p className="text-xs text-text-muted">{user.name}</p></div>
</div>
<button onClick={onClose} className="text-text-muted hover:text-text-primary"><X size={16} /></button>
</div>
{state === 'loading' && <div className="flex flex-col items-center py-10 gap-3"><Loader2 size={28} className="animate-spin text-accent" /><p className="text-sm text-text-muted">Generating setup link</p></div>}
{state === 'error' && <div className="flex flex-col items-center py-8 gap-3 text-crit"><AlertTriangle size={28} /><p className="text-sm">Failed to generate setup link.</p><button className="btn-ghost mt-2" onClick={generate}>Retry</button></div>}
{state === 'done' && result && (
<>
<div className="bg-canvas rounded-lg p-1 flex justify-center mb-4 border border-border">
<img src={result.qrDataUrl} alt="2FA setup QR code" className="w-56 h-56 rounded" />
</div>
<p className="text-sm text-text-secondary mb-5">Share this QR code with {user.name} to enroll their authenticator app.</p>
<button className="btn-ghost w-full" onClick={onClose}>Done</button>
</>
)}
</div>
</div>
);
}
function UserDrawer({ mode, user, groups, onSave, onClose }) {
const isEdit = mode === 'edit';
const [form, setForm] = useState(isEdit ? {
username: user.username, name: user.name, email: user.email,
isActive: user.isActive, groups: user.groups.map((g) => g.id), password: '',
} : { username: '', name: '', email: '', isActive: true, groups: [], password: '' });
const [errors, setErrors] = useState({});
const [saving, setSaving] = useState(false);
const set = (field) => (e) => setForm((f) => ({ ...f, [field]: e.target.type === 'checkbox' ? e.target.checked : e.target.value }));
const toggleGroup = (id) => setForm((f) => ({ ...f, groups: f.groups.includes(id) ? f.groups.filter((g) => g !== id) : [...f.groups, id] }));
const handleSubmit = async () => {
const e = {};
if (!form.username.trim()) e.username = 'Required';
if (!form.name.trim()) e.name = 'Required';
if (!form.email.trim()) e.email = 'Required';
if (!isEdit && !form.password) e.password = 'Required';
if (form.password && form.password.length < 8) e.password = 'Min 8 chars';
setErrors(e);
if (Object.keys(e).length > 0) return;
setSaving(true);
try {
const payload = { username: form.username, name: form.name, email: form.email, isActive: form.isActive, groups: form.groups };
if (form.password) payload.password = form.password;
await onSave(payload);
} finally { setSaving(false); }
};
return (
<>
<div className="drawer-overlay" onClick={onClose} />
<div className="drawer-panel">
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
<p className="font-medium text-text-primary">{isEdit ? 'Edit user' : 'Add user'}</p>
<button onClick={onClose} className="text-text-muted hover:text-text-primary"><X size={18} /></button>
</div>
<div className="flex-1 overflow-y-auto px-6 py-5 space-y-5">
<section>
<p className="section-title mb-4">Identity</p>
<div className="space-y-4">
<div><label className="field-label">Username</label><input className={clsx('field', errors.username && 'border-crit')} value={form.username} onChange={set('username')} disabled={isEdit} /></div>
<div><label className="field-label">Full name</label><input className={clsx('field', errors.name && 'border-crit')} value={form.name} onChange={set('name')} /></div>
<div><label className="field-label">Email</label><input className={clsx('field', errors.email && 'border-crit')} type="email" value={form.email} onChange={set('email')} /></div>
</div>
</section>
<section>
<p className="section-title mb-3">Groups</p>
<div className="space-y-2">
{groups.map((g) => (
<label key={g.id} className="flex items-center gap-3 cursor-pointer py-2 px-3 rounded-md hover:bg-canvas transition-colors">
<input type="checkbox" className="sr-only" checked={form.groups.includes(g.id)} onChange={() => toggleGroup(g.id)} />
<div className={clsx('w-4 h-4 rounded border flex items-center justify-center',
form.groups.includes(g.id) ? 'bg-accent border-accent' : 'border-border')}>
{form.groups.includes(g.id) && <Check size={10} className="text-white" />}
</div>
<span className="text-sm text-text-primary">{g.name}</span>
</label>
))}
</div>
</section>
<section>
<p className="section-title mb-3">{isEdit ? 'Reset Password' : 'Password'}</p>
<input className={clsx('field', errors.password && 'border-crit')} type="password" value={form.password} onChange={set('password')}
placeholder={isEdit ? 'Leave blank to keep' : 'Min. 8 characters'} />
{errors.password && <p className="text-xs text-crit mt-1">{errors.password}</p>}
</section>
</div>
<div className="flex justify-end gap-3 px-6 py-4 border-t border-border flex-shrink-0">
<button className="btn-ghost" onClick={onClose} disabled={saving}>Cancel</button>
<button className="btn-primary" onClick={handleSubmit} disabled={saving}>
{saving ? <Loader2 size={14} className="animate-spin" /> : isEdit ? <Check size={14} /> : <UserPlus size={14} />}
{isEdit ? 'Save' : 'Create'}
</button>
</div>
</div>
</>
);
}
export default function UserManagement() {
const [users, setUsers] = useState([]);
const [groups, setGroups] = useState([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [searchInput, setSearchInput] = useState('');
const [drawer, setDrawer] = useState(null);
const [deleteTarget, setDeleteTarget] = useState(null);
const [twoFaTarget, setTwoFaTarget] = useState(null);
const [toast, setToast] = useState(null);
const searchTimer = useRef(null);
const showToast = (message, type = 'ok') => setToast({ message, type });
const loadUsers = useCallback(async (q = '') => {
setLoading(true);
try { const result = await usersApi.list({ search: q }); setUsers(result.users); setTotal(result.count); }
catch (err) { showToast(`Failed to load users: ${err.message}`, 'error'); }
finally { setLoading(false); }
}, []);
const loadGroups = useCallback(async () => {
try { const g = await usersApi.listGroups(); setGroups(g); } catch {}
}, []);
useEffect(() => { loadUsers(); loadGroups(); }, []);
const handleSearchChange = (e) => {
const val = e.target.value;
setSearchInput(val);
clearTimeout(searchTimer.current);
searchTimer.current = setTimeout(() => loadUsers(val), 350);
};
const handleSave = async (payload) => {
try {
if (drawer.mode === 'create') {
const newUser = await usersApi.create(payload);
setUsers((u) => [newUser, ...u]); setTotal((t) => t + 1);
showToast(`User ${newUser.username} created`);
} else {
const updated = await usersApi.update(drawer.user.id, payload);
setUsers((u) => u.map((x) => (x.id === updated.id ? updated : x)));
showToast(`User ${updated.username} updated`);
}
setDrawer(null);
} catch (err) { showToast(err.message, 'error'); throw err; }
};
const handleDelete = async () => {
try {
await usersApi.delete(deleteTarget.id);
setUsers((u) => u.filter((x) => x.id !== deleteTarget.id)); setTotal((t) => t - 1);
showToast(`User ${deleteTarget.username} deleted`); setDeleteTarget(null);
} catch (err) { showToast(err.message, 'error'); }
};
return (
<div className="flex flex-col h-full animate-fade-in">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="font-mono text-lg font-semibold text-text-primary flex items-center gap-2">
<Users size={20} className="text-accent" /> User Management
</h1>
<p className="text-xs text-text-muted mt-1">{total} users</p>
</div>
<button className="btn-primary" onClick={() => setDrawer({ mode: 'create' })}><UserPlus size={15} /> Add User</button>
</div>
<div className="relative mb-4">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted pointer-events-none" />
<input className="field pl-9" placeholder="Search…" value={searchInput} onChange={handleSearchChange} />
</div>
<div className="card flex-1 overflow-auto">
<table className="w-full text-sm">
<thead><tr className="border-b border-border">
<th className="px-4 py-3 text-left section-title">User</th>
<th className="px-4 py-3 text-left section-title hidden md:table-cell">Groups</th>
<th className="px-4 py-3 text-left section-title">Status</th>
<th className="px-4 py-3 text-left section-title hidden lg:table-cell">2FA</th>
<th className="px-4 py-3 text-right section-title">Actions</th>
</tr></thead>
<tbody>
{loading && <tr><td colSpan={5} className="px-4 py-16 text-center"><Loader2 size={20} className="animate-spin text-text-muted mx-auto" /></td></tr>}
{!loading && users.length === 0 && <tr><td colSpan={5} className="px-4 py-16 text-center text-text-muted">No users found</td></tr>}
{!loading && users.map((u) => (
<tr key={u.id} className="table-row-hover border-b border-border/50 last:border-0">
<td className="px-4 py-3">
<div className="flex items-center gap-3">
<Avatar name={u.name} />
<div><p className="font-medium text-text-primary">{u.name}</p><p className="text-xs text-text-muted font-mono">{u.username}</p></div>
</div>
</td>
<td className="px-4 py-3 hidden md:table-cell">
<div className="flex flex-wrap gap-1">
{u.groups.length === 0 ? <span className="text-xs text-text-muted"></span> : u.groups.map((g) => (
<span key={g.id} className={clsx('badge', g.name.includes('admin') ? 'badge-info' : 'badge-muted')}>{g.name}</span>
))}
</div>
</td>
<td className="px-4 py-3">
{u.isActive ? <span className="badge badge-ok">Active</span> : <span className="badge badge-muted">Inactive</span>}
</td>
<td className="px-4 py-3 hidden lg:table-cell">
{u.totpEnrolled ? <span className="badge badge-ok"><ShieldCheck size={10} />2FA On</span> : <span className="badge badge-warn"><ShieldOff size={10} />No 2FA</span>}
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-1">
<button title="Edit" onClick={() => setDrawer({ mode: 'edit', user: u })} className="p-1.5 rounded text-text-muted hover:text-accent hover:bg-accent/10 transition-colors"><Pencil size={13} /></button>
<button title="2FA" onClick={() => setTwoFaTarget(u)} className="p-1.5 rounded text-text-muted hover:text-accent hover:bg-accent/10 transition-colors"><Shield size={13} /></button>
<button title="Delete" onClick={() => setDeleteTarget(u)} className="p-1.5 rounded text-text-muted hover:text-crit hover:bg-crit/10 transition-colors"><Trash2 size={13} /></button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{drawer && <UserDrawer mode={drawer.mode} user={drawer.user} groups={groups} onSave={handleSave} onClose={() => setDrawer(null)} />}
{deleteTarget && <DeleteModal user={deleteTarget} onConfirm={handleDelete} onCancel={() => setDeleteTarget(null)} />}
{twoFaTarget && <TwoFactorModal user={twoFaTarget} onClose={() => { setTwoFaTarget(null); loadUsers(); }} />}
{toast && <Toast message={toast.message} type={toast.type} onDismiss={() => setToast(null)} />}
</div>
);
}
+151
View File
@@ -0,0 +1,151 @@
// src/pages/Storage.jsx
import { useQuery } from '@tanstack/react-query';
import { Database, HardDrive, Loader2 } from 'lucide-react';
import { queryDatastores } from '@/api/prometheusExtended';
import { formatBytes } from '@/constants/statusMaps';
import clsx from 'clsx';
const REFRESH = 60_000;
function CapacityBar({ label, usedBytes, totalBytes, color = 'bg-accent' }) {
const pct = totalBytes > 0 ? Math.min(usedBytes / totalBytes, 1) : 0;
const pctN = Math.round(pct * 100);
const barColor = pct >= 0.9 ? 'bg-crit' : pct >= 0.75 ? 'bg-warn' : color;
const textC = pct >= 0.9 ? 'text-crit' : pct >= 0.75 ? 'text-warn' : 'text-text-secondary';
return (
<div className="space-y-1">
<div className="flex justify-between text-[10px]">
<span className="text-text-muted">{label}</span>
<span className={clsx('font-mono data-value', textC)}>
{formatBytes(usedBytes)} / {formatBytes(totalBytes)} ({pctN}%)
</span>
</div>
<div className="h-2 bg-border rounded-full overflow-hidden">
<div className={clsx('h-full rounded-full transition-all duration-500', barColor)} style={{ width: `${pct * 100}%` }} />
</div>
</div>
);
}
function ZertoUsageRow({ label, bytes, color }) {
return bytes > 0 ? (
<div className="flex items-center justify-between text-[10px]">
<div className="flex items-center gap-1.5">
<span className={clsx('w-2 h-2 rounded-sm flex-shrink-0', color)} />
<span className="text-text-muted">{label}</span>
</div>
<span className="font-mono data-value text-text-secondary">{formatBytes(bytes)}</span>
</div>
) : null;
}
function DatastoreCard({ ds }) {
const usePct = ds.capacityBytes > 0 ? ds.usedBytes / ds.capacityBytes : 0;
const alerting = usePct >= 0.9;
const warning = usePct >= 0.75;
const zertoUsed = (ds.journalBytes ?? 0) + (ds.scratchBytes ?? 0) + (ds.recoveryBytes ?? 0) + (ds.applianceBytes ?? 0);
return (
<div className={clsx('card p-4 space-y-4 transition-colors duration-300',
alerting ? 'border-crit/30' : warning ? 'border-warn/20' : '')}>
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<div className={clsx('w-8 h-8 rounded-md flex items-center justify-center',
alerting ? 'bg-crit/10' : warning ? 'bg-warn/10' : 'bg-accent/10')}>
<Database size={14} className={alerting ? 'text-crit' : warning ? 'text-warn' : 'text-accent'} />
</div>
<div>
<p className="text-sm font-mono font-semibold text-text-primary truncate max-w-[180px]">{ds.name}</p>
<p className="text-[10px] text-text-muted">{ds.siteName}</p>
</div>
</div>
<div className="text-right text-[10px] text-text-muted font-mono">
<p>{ds.vraCount ?? 0} VRA{(ds.vraCount ?? 0) !== 1 ? 's' : ''}</p>
<p>{ds.incomingVms ?? 0} in / {ds.outgoingVms ?? 0} out</p>
</div>
</div>
<CapacityBar label="Capacity" usedBytes={ds.usedBytes} totalBytes={ds.capacityBytes} />
{zertoUsed > 0 && (
<div className="space-y-1.5 pt-2 border-t border-border">
<p className="section-title mb-2">Zerto Usage ({formatBytes(zertoUsed)})</p>
<ZertoUsageRow label="Journal" bytes={ds.journalBytes} color="bg-accent" />
<ZertoUsageRow label="Scratch" bytes={ds.scratchBytes} color="bg-info" />
<ZertoUsageRow label="Recovery" bytes={ds.recoveryBytes} color="bg-ok" />
<ZertoUsageRow label="Appliances" bytes={ds.applianceBytes} color="bg-text-muted" />
</div>
)}
<div className="flex justify-between text-[10px] text-text-muted pt-1 border-t border-border">
<span>Free</span>
<span className="font-mono data-value text-text-secondary">{formatBytes(ds.freeBytes)}</span>
</div>
</div>
);
}
export default function Storage() {
const { data: datastores = [], isLoading } = useQuery({
queryKey: ['datastores'], queryFn: queryDatastores, refetchInterval: REFRESH,
});
const totalCapacity = datastores.reduce((s, d) => s + (d.capacityBytes ?? 0), 0);
const totalUsed = datastores.reduce((s, d) => s + (d.usedBytes ?? 0), 0);
const totalJournal = datastores.reduce((s, d) => s + (d.journalBytes ?? 0), 0);
const bySite = {};
for (const d of datastores) {
const s = d.siteName || 'Unknown';
if (!bySite[s]) bySite[s] = [];
bySite[s].push(d);
}
return (
<div className="space-y-6 animate-fade-in">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[
{ label: 'Datastores', value: datastores.length, icon: Database },
{ label: 'Total Capacity', value: formatBytes(totalCapacity), icon: HardDrive },
{ label: 'Used', value: formatBytes(totalUsed), icon: HardDrive },
{ label: 'Journal Usage', value: formatBytes(totalJournal), icon: Database },
].map((s) => (
<div key={s.label} className="card p-4 flex items-start gap-3">
<div className="w-9 h-9 rounded-lg bg-accent/10 flex items-center justify-center">
<s.icon size={16} className="text-accent" />
</div>
<div>
<p className="section-title">{s.label}</p>
<p className="font-mono text-lg font-semibold text-text-primary mt-0.5 data-value">{s.value}</p>
</div>
</div>
))}
</div>
{totalCapacity > 0 && (
<div className="card p-4">
<CapacityBar label="Aggregate Capacity (all datastores)" usedBytes={totalUsed} totalBytes={totalCapacity} />
</div>
)}
{isLoading && (
<div className="flex justify-center py-16">
<Loader2 size={24} className="animate-spin text-text-muted" />
</div>
)}
{Object.entries(bySite).map(([site, siteDs]) => (
<section key={site}>
<p className="section-title mb-3">{site} · {siteDs.length} datastore{siteDs.length !== 1 ? 's' : ''}</p>
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
{siteDs.map((ds) => <DatastoreCard key={ds.id || ds.name} ds={ds} />)}
</div>
</section>
))}
{!isLoading && datastores.length === 0 && (
<div className="card p-12 text-center">
<Database size={28} className="text-text-muted mx-auto mb-3 opacity-40" />
<p className="text-text-muted text-sm">No datastore data available</p>
</div>
)}
</div>
);
}
+169
View File
@@ -0,0 +1,169 @@
// src/pages/VMDetail.jsx
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Search, Server, X, Activity, Loader2 } from 'lucide-react';
import { queryAllVms } from '@/api/prometheusExtended';
import TimeSeriesChart from '@/components/charts/TimeSeriesChart';
import RPOGauge from '@/components/charts/RPOGauge';
import { VM_STATUS, formatRpo, formatMB } from '@/constants/statusMaps';
import clsx from 'clsx';
const REFRESH = 30_000;
function JournalGauge({ usedMb, hardLimitMb }) {
if (!hardLimitMb || hardLimitMb <= 0) return <span className="text-xs text-text-muted"></span>;
const pct = Math.min(usedMb / hardLimitMb, 1);
const color = pct > 0.85 ? 'bg-crit' : pct > 0.65 ? 'bg-warn' : 'bg-ok';
return (
<div className="flex items-center gap-2 min-w-[100px]">
<div className="flex-1 h-1.5 bg-border rounded-full overflow-hidden">
<div className={clsx('h-full rounded-full', color)} style={{ width: `${pct * 100}%` }} />
</div>
<span className="text-[10px] font-mono data-value text-text-muted whitespace-nowrap">{formatMB(usedMb)}</span>
</div>
);
}
function VmDrawer({ vm, onClose }) {
const esc = vm.name.replace(/"/g, '\\"');
return (
<>
<div className="drawer-overlay" onClick={onClose} />
<div className="drawer-panel">
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-accent/10 flex items-center justify-center">
<Server size={18} className="text-accent" />
</div>
<div>
<p className="font-mono text-sm font-semibold text-text-primary">{vm.name}</p>
<p className="text-xs text-text-muted">{vm.vpgName} · {vm.siteName}</p>
</div>
</div>
<button onClick={onClose} className="p-1.5 rounded text-text-muted hover:text-text-primary hover:bg-raised transition-colors"><X size={16} /></button>
</div>
<div className="flex-1 overflow-y-auto px-6 py-5 space-y-5">
<div className="card p-4 flex items-start gap-6">
<RPOGauge actualSec={vm.actualRpoSec} size={120} label="Current RPO" />
<div className="flex-1 space-y-2 pt-2">
{[
{ label: 'Throughput', value: `${(vm.throughputMb ?? 0).toFixed(2)} MB/s` },
{ label: 'IOPS', value: Math.round(vm.iops ?? 0).toLocaleString() },
{ label: 'Bandwidth', value: `${(vm.bandwidthMbps ?? 0).toFixed(2)} Mbps` },
{ label: 'Journal', value: formatMB(vm.journalUsedMb) },
{ label: 'Encryption', value: vm.pctEncrypted != null ? `${vm.pctEncrypted.toFixed(1)}%` : '—' },
].map(({ label, value }) => (
<div key={label} className="flex justify-between text-xs">
<span className="text-text-muted">{label}</span>
<span className="font-mono data-value text-text-primary">{value}</span>
</div>
))}
</div>
</div>
<TimeSeriesChart title="RPO History" promql={`vm_actualrpo{VmName="${esc}"}`}
yFormatter={formatRpo}
transform={(result) => result[0]?.values.map(([ts, v]) => ({ ts: ts * 1000, 'RPO': parseFloat(v) })) ?? []}
height={170} />
<TimeSeriesChart title="Throughput" promql={`vm_throughput_in_mb{VmName="${esc}"}`}
yFormatter={(v) => `${v.toFixed(1)} MB/s`}
transform={(result) => result[0]?.values.map(([ts, v]) => ({ ts: ts * 1000, 'MB/s': parseFloat(v) })) ?? []}
height={150} />
</div>
</div>
</>
);
}
function VmStatusBadge({ code }) {
const s = VM_STATUS[code] ?? { label: 'Unknown', color: 'muted' };
return <span className={clsx('badge', `badge-${s.color === 'muted' ? 'muted' : s.color}`)}>{s.label}</span>;
}
export default function VMDetail() {
const [search, setSearch] = useState('');
const [sort, setSort] = useState('rpo-desc');
const [selected, setSelected] = useState(null);
const { data: vms = [], isLoading } = useQuery({
queryKey: ['all-vms'], queryFn: queryAllVms, refetchInterval: REFRESH,
});
const filtered = vms.filter((v) => {
const q = search.toLowerCase();
return !q || v.name?.toLowerCase().includes(q) || v.vpgName?.toLowerCase().includes(q);
});
const sorted = [...filtered].sort((a, b) => {
switch (sort) {
case 'rpo-desc': return (b.actualRpoSec ?? 0) - (a.actualRpoSec ?? 0);
case 'rpo-asc': return (a.actualRpoSec ?? 0) - (b.actualRpoSec ?? 0);
case 'name-asc': return (a.name ?? '').localeCompare(b.name ?? '');
default: return 0;
}
});
return (
<div className="flex flex-col h-full space-y-4 animate-fade-in">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[
{ label: 'Total VMs', value: vms.length, color: 'accent' },
{ label: 'RPO OK', value: vms.filter((v) => (v.actualRpoSec ?? 0) <= 300).length, color: 'ok' },
{ label: 'RPO Warning', value: vms.filter((v) => (v.actualRpoSec ?? 0) > 300 && (v.actualRpoSec ?? 0) <= 600).length, color: 'warn' },
{ label: 'RPO Critical', value: vms.filter((v) => (v.actualRpoSec ?? 0) > 600).length, color: 'crit' },
].map(({ label, value, color }) => (
<div key={label} className="card p-4 flex items-start gap-3">
<div className={clsx('w-9 h-9 rounded-lg flex items-center justify-center flex-shrink-0', `bg-${color}/10`)}>
<Activity size={16} className={`text-${color}`} />
</div>
<div><p className="section-title">{label}</p><p className="font-data text-2xl font-semibold text-text-primary data-value">{value}</p></div>
</div>
))}
</div>
<div className="flex items-center gap-3 flex-wrap">
<div className="relative flex-1 min-w-[200px]">
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-text-muted pointer-events-none" />
<input className="field pl-8 text-sm" placeholder="Search VMs or VPGs…" value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
<select className="field w-auto text-sm" value={sort} onChange={(e) => setSort(e.target.value)}>
<option value="rpo-desc">RPO (worst first)</option>
<option value="rpo-asc">RPO (best first)</option>
<option value="name-asc">Name A-Z</option>
</select>
<span className="text-xs text-text-muted">{sorted.length} / {vms.length} VMs</span>
</div>
<div className="card flex-1 overflow-auto">
<table className="w-full text-sm">
<thead><tr className="border-b border-border">
<th className="px-4 py-3 text-left section-title">VM Name</th>
<th className="px-4 py-3 text-left section-title hidden md:table-cell">VPG</th>
<th className="px-4 py-3 text-right section-title">RPO</th>
<th className="px-4 py-3 text-left section-title hidden md:table-cell">Journal</th>
<th className="px-4 py-3 text-right section-title hidden lg:table-cell">Throughput</th>
<th className="px-4 py-3 text-left section-title hidden xl:table-cell">Status</th>
</tr></thead>
<tbody>
{isLoading && <tr><td colSpan={6} className="py-16 text-center"><Loader2 size={20} className="animate-spin text-text-muted mx-auto" /></td></tr>}
{!isLoading && sorted.length === 0 && <tr><td colSpan={6} className="py-16 text-center text-text-muted">No VMs</td></tr>}
{!isLoading && sorted.map((vm) => {
const rpoColor = vm.actualRpoSec > 600 ? 'text-crit' : vm.actualRpoSec > 300 ? 'text-warn' : 'text-ok';
return (
<tr key={vm.id} onClick={() => setSelected(vm)} className="table-row-hover border-b border-border/40 last:border-0">
<td className="px-4 py-3"><span className="font-medium text-text-primary truncate">{vm.name}</span></td>
<td className="px-4 py-3 hidden md:table-cell"><span className="text-text-muted text-xs">{vm.vpgName}</span></td>
<td className="px-4 py-3 text-right"><span className={clsx('font-mono font-semibold text-xs data-value', rpoColor)}>{formatRpo(vm.actualRpoSec)}</span></td>
<td className="px-4 py-3 hidden md:table-cell"><JournalGauge usedMb={vm.journalUsedMb} hardLimitMb={vm.journalHardLimit} /></td>
<td className="px-4 py-3 text-right hidden lg:table-cell"><span className="font-mono text-xs data-value text-text-secondary">{(vm.throughputMb ?? 0).toFixed(2)} MB/s</span></td>
<td className="px-4 py-3 hidden xl:table-cell"><VmStatusBadge code={vm.status} /></td>
</tr>
);
})}
</tbody>
</table>
</div>
{selected && <VmDrawer vm={selected} onClose={() => setSelected(null)} />}
</div>
);
}
+154
View File
@@ -0,0 +1,154 @@
// src/pages/VPGMonitor.jsx
import { useState, useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { Search, ChevronRight, Loader2 } from 'lucide-react';
import { queryAllVpgs } from '@/api/prometheus';
import { queryVpgDetail, queryVpgVms } from '@/api/prometheusExtended';
import TimeSeriesChart from '@/components/charts/TimeSeriesChart';
import RPOGauge from '@/components/charts/RPOGauge';
import { VPG_ALERT, vpgHealth, rpoStatus, formatRpo, formatMB, colorToText } from '@/constants/statusMaps';
import clsx from 'clsx';
const REFRESH = 30_000;
function VpgListItem({ vpg, selected, onClick }) {
const status = rpoStatus(vpg.actualRpoSec, vpg.configuredRpoSec);
return (
<button onClick={onClick} className={clsx('w-full text-left px-3 py-2.5 rounded-md transition-all duration-150 group',
selected ? 'bg-accent/15 border border-accent/25' : 'hover:bg-raised border border-transparent')}>
<div className="flex items-center gap-2">
<span className={clsx('status-dot flex-shrink-0',
status === 'ok' ? 'status-dot-ok' : status === 'warn' ? 'status-dot-warn' : status === 'crit' ? 'status-dot-crit' : 'status-dot-idle')} />
<span className={clsx('text-sm font-medium truncate flex-1', selected ? 'text-accent' : 'text-text-primary')}>{vpg.name}</span>
{selected && <ChevronRight size={12} className="text-accent flex-shrink-0" />}
</div>
<div className="flex items-center gap-3 mt-0.5 pl-4">
<span className="text-[10px] text-text-muted">{vpg.siteName}</span>
<span className={clsx('text-[10px] font-mono data-value', colorToText[status])}>{formatRpo(vpg.actualRpoSec)}</span>
</div>
</button>
);
}
function VpgDetail({ vpgName }) {
const { data: detail, isLoading } = useQuery({
queryKey: ['vpg-detail', vpgName], queryFn: () => queryVpgDetail(vpgName), refetchInterval: REFRESH, enabled: !!vpgName,
});
const { data: vms = [], isLoading: vmsLoading } = useQuery({
queryKey: ['vpg-vms', vpgName], queryFn: () => queryVpgVms(vpgName), refetchInterval: REFRESH, enabled: !!vpgName,
});
if (isLoading) return <div className="flex-1 flex items-center justify-center"><Loader2 size={24} className="animate-spin text-text-muted" /></div>;
if (!detail) return null;
const alertInfo = VPG_ALERT[detail.alertStatus] ?? VPG_ALERT[0];
const esc = vpgName.replace(/"/g, '\\"');
return (
<div className="flex-1 min-w-0 overflow-y-auto p-4 space-y-4 animate-fade-in">
<div className="flex items-center gap-2 mb-1">
<span className={clsx('status-dot', alertInfo.color === 'ok' ? 'status-dot-ok' : alertInfo.color === 'warn' ? 'status-dot-warn' : 'status-dot-crit')} />
<h2 className="font-mono text-base font-semibold text-text-primary">{vpgName}</h2>
<span className={clsx('badge', `badge-${alertInfo.color}`)}>{alertInfo.label}</span>
<span className="text-xs text-text-muted">{detail.siteName} · {detail.vmCount} VMs</span>
</div>
<div className="card overflow-hidden flex flex-wrap">
<div className="flex items-center justify-center p-4 border-r border-border">
<RPOGauge actualSec={detail.actualRpoSec} configuredSec={detail.configuredRpoSec} size={150} />
</div>
<div className="flex flex-wrap flex-1">
{[
{ label: 'Throughput', value: `${(detail.throughputMb ?? 0).toFixed(2)} MB/s` },
{ label: 'IOPS', value: Math.round(detail.iops ?? 0) },
{ label: 'Storage', value: formatMB(detail.storageUsedMb) },
].map((s) => (
<div key={s.label} className="text-center px-4 py-3 border-r border-border last:border-0">
<p className="text-lg font-semibold font-data data-value text-text-primary">{s.value}</p>
<p className="section-title mt-0.5">{s.label}</p>
</div>
))}
</div>
</div>
<TimeSeriesChart title="RPO Over Time" promql={`vpg_actual_rpo{VpgName="${esc}"}`}
yFormatter={(v) => formatRpo(v)}
refLines={detail.configuredRpoSec ? [{ value: detail.configuredRpoSec, label: 'Target', color: 'warn' }] : []}
transform={(result) => result[0]?.values.map(([ts, v]) => ({ ts: ts * 1000, 'RPO (s)': parseFloat(v) })) ?? []}
height={180} />
<div className="card overflow-hidden">
<div className="px-4 py-3 border-b border-border"><p className="section-title">Protected VMs</p></div>
<table className="w-full text-xs">
<thead><tr className="border-b border-border/60">
<th className="px-4 py-2 text-left section-title">VM Name</th>
<th className="px-4 py-2 text-right section-title">RPO</th>
<th className="px-4 py-2 text-right section-title hidden sm:table-cell">Throughput</th>
<th className="px-4 py-2 text-right section-title hidden md:table-cell">IOPS</th>
</tr></thead>
<tbody>
{vmsLoading && <tr><td colSpan={4} className="py-8 text-center"><Loader2 size={16} className="animate-spin text-text-muted mx-auto" /></td></tr>}
{!vmsLoading && vms.map((vm) => (
<tr key={vm.id} className="border-b border-border/40 last:border-0 hover:bg-raised transition-colors">
<td className="px-4 py-2 font-medium text-text-primary">{vm.name}</td>
<td className="px-4 py-2 text-right font-mono data-value">{formatRpo(vm.actualRpoSec)}</td>
<td className="px-4 py-2 text-right text-text-secondary font-mono data-value hidden sm:table-cell">{vm.throughputMb?.toFixed(2)} MB/s</td>
<td className="px-4 py-2 text-right text-text-secondary font-mono data-value hidden md:table-cell">{Math.round(vm.iops ?? 0)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
export default function VPGMonitor() {
const [params] = useSearchParams();
const [search, setSearch] = useState('');
const initialName = params.get('name');
const { data: vpgs = [], isLoading } = useQuery({
queryKey: ['all-vpgs'], queryFn: queryAllVpgs, refetchInterval: REFRESH,
});
const [selected, setSelected] = useState(initialName || null);
useEffect(() => {
if (!selected && vpgs.length > 0) setSelected(vpgs[0].name);
}, [vpgs, selected]);
const filtered = vpgs.filter((v) => !search || v.name.toLowerCase().includes(search.toLowerCase()));
const bySite = {};
for (const v of filtered) { const s = v.siteName || 'Unknown'; if (!bySite[s]) bySite[s] = []; bySite[s].push(v); }
return (
<div className="flex h-full -m-6 overflow-hidden">
<aside className="w-64 flex-shrink-0 border-r border-border flex flex-col bg-surface overflow-hidden">
<div className="p-3 border-b border-border flex-shrink-0">
<div className="relative">
<Search size={12} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-text-muted pointer-events-none" />
<input className="field pl-8 text-xs py-1.5" placeholder="Filter VPGs…" value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
<p className="text-[10px] text-text-muted mt-2 font-mono">{filtered.length} VPGs</p>
</div>
<div className="flex-1 overflow-y-auto p-2">
{isLoading && <div className="flex justify-center py-8"><Loader2 size={16} className="animate-spin text-text-muted" /></div>}
{Object.entries(bySite).map(([site, siteVpgs]) => (
<div key={site} className="mb-3">
<p className="section-title px-2 mb-1">{site}</p>
{siteVpgs.map((v) => <VpgListItem key={v.id} vpg={v} selected={selected === v.name} onClick={() => setSelected(v.name)} />)}
</div>
))}
</div>
</aside>
<div className="flex-1 flex flex-col overflow-hidden">
{selected ? <VpgDetail vpgName={selected} /> : (
<div className="flex-1 flex items-center justify-center text-text-muted text-sm">Select a VPG to view details</div>
)}
</div>
</div>
);
}
+147
View File
@@ -0,0 +1,147 @@
// src/pages/VRADashboard.jsx
import { useQuery } from '@tanstack/react-query';
import { Cpu, Server, HardDrive, Layers, Loader2 } from 'lucide-react';
import { queryAllVras } from '@/api/prometheusExtended';
import clsx from 'clsx';
const REFRESH = 30_000;
const VRA_MAX_VMS = 100;
const VRA_MAX_VOL = 2048;
function UsageBar({ label, used, total, max, unit = '', warnAt = 0.75, critAt = 0.9 }) {
const pct = total > 0 ? Math.min(used / total, 1) : max > 0 ? Math.min(used / max, 1) : 0;
const color = pct >= critAt ? 'bg-crit' : pct >= warnAt ? 'bg-warn' : 'bg-ok';
const textC = pct >= critAt ? 'text-crit' : pct >= warnAt ? 'text-warn' : 'text-ok';
return (
<div className="space-y-1">
<div className="flex justify-between items-center text-[10px]">
<span className="text-text-muted">{label}</span>
<span className={clsx('font-mono data-value', textC)}>
{typeof used === 'number' ? `${Math.round(used)}${unit}` : '—'}
{total > 0 ? ` / ${Math.round(total)}${unit}` : max ? ` / ${max}${unit}` : ''}
</span>
</div>
<div className="h-1 bg-border rounded-full overflow-hidden">
<div className={clsx('h-full rounded-full transition-all duration-500', color)} style={{ width: `${pct * 100}%` }} />
</div>
</div>
);
}
function WorkloadBadge({ label, value, icon: Icon, color = 'text-text-secondary' }) {
return (
<div className="flex flex-col items-center p-2 bg-canvas rounded-md border border-border min-w-0">
<Icon size={12} className={clsx('mb-1', color)} />
<span className={clsx('font-data text-base font-semibold data-value', color)}>{value ?? '—'}</span>
<span className="section-title mt-0.5 text-center leading-tight">{label}</span>
</div>
);
}
function VraCard({ vra }) {
const protPct = vra.protectedVms / VRA_MAX_VMS;
const recPct = vra.recoveryVms / VRA_MAX_VMS;
const alerting = protPct >= 0.9 || recPct >= 0.9;
const warning = protPct >= 0.75 || recPct >= 0.75;
return (
<div className={clsx('card p-4 flex flex-col gap-4 transition-colors duration-300',
alerting ? 'border-crit/30' : warning ? 'border-warn/30' : '')}>
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<div className={clsx('w-8 h-8 rounded-md flex items-center justify-center',
alerting ? 'bg-crit/10' : warning ? 'bg-warn/10' : 'bg-accent/10')}>
<Server size={14} className={alerting ? 'text-crit' : warning ? 'text-warn' : 'text-accent'} />
</div>
<div>
<p className="text-sm font-mono font-semibold text-text-primary truncate max-w-[140px]">{vra.name}</p>
<p className="text-[10px] text-text-muted">{vra.siteName}</p>
</div>
</div>
<div className="text-right">
<p className="text-[10px] text-text-muted font-mono">{vra.vcpuCount} vCPU</p>
<p className="text-[10px] text-text-muted font-mono">{vra.memoryGb?.toFixed(0)} GB RAM</p>
</div>
</div>
{(vra.cpuUsageMhz !== undefined || vra.memUsageMb !== undefined) && (
<div className="space-y-2">
{vra.cpuUsageMhz !== undefined && (
<UsageBar label="CPU" used={vra.cpuUsageMhz} unit=" MHz" max={vra.vcpuCount * 2600} warnAt={0.7} critAt={0.9} />
)}
{vra.memUsageMb !== undefined && (
<UsageBar label="Memory" used={vra.memUsageMb} total={(vra.memoryGb ?? 0) * 1024} unit=" MB" warnAt={0.8} critAt={0.92} />
)}
</div>
)}
<div>
<p className="section-title mb-2">Workload</p>
<div className="grid grid-cols-3 gap-1.5 mb-2">
<WorkloadBadge label="Prot VMs" value={vra.protectedVms} icon={Server}
color={protPct >= 0.9 ? 'text-crit' : protPct >= 0.75 ? 'text-warn' : 'text-ok'} />
<WorkloadBadge label="Rec VMs" value={vra.recoveryVms} icon={Server}
color={recPct >= 0.9 ? 'text-crit' : recPct >= 0.75 ? 'text-warn' : 'text-accent'} />
<WorkloadBadge label="Self VPGs" value={vra.selfProtectedVpgs} icon={Layers} color="text-text-secondary" />
</div>
<div className="grid grid-cols-2 gap-1.5">
<WorkloadBadge label="Prot Vols" value={vra.protectedVolumes} icon={HardDrive}
color={vra.protectedVolumes / VRA_MAX_VOL >= 0.85 ? 'text-crit' : vra.protectedVolumes / VRA_MAX_VOL >= 0.7 ? 'text-warn' : 'text-text-secondary'} />
<WorkloadBadge label="Rec Vols" value={vra.recoveryVolumes} icon={HardDrive}
color={vra.recoveryVolumes / VRA_MAX_VOL >= 0.85 ? 'text-crit' : vra.recoveryVolumes / VRA_MAX_VOL >= 0.7 ? 'text-warn' : 'text-text-secondary'} />
</div>
</div>
<div className="flex items-center justify-between text-[10px] text-text-muted font-mono pt-3 border-t border-border">
<span>VRA {vra.version ?? '—'}</span>
<span>ESXi {vra.hostVersion ?? '—'}</span>
</div>
</div>
);
}
export default function VRADashboard() {
const { data: vras = [], isLoading } = useQuery({
queryKey: ['all-vras'], queryFn: queryAllVras, refetchInterval: REFRESH,
});
const bySite = {};
for (const v of vras) {
const s = v.siteName || 'Unknown';
if (!bySite[s]) bySite[s] = [];
bySite[s].push(v);
}
const totalProt = vras.reduce((s, v) => s + (v.protectedVms ?? 0), 0);
const totalRec = vras.reduce((s, v) => s + (v.recoveryVms ?? 0), 0);
return (
<div className="space-y-6 animate-fade-in">
<div className="grid grid-cols-3 gap-4">
{[
{ label: 'Total VRAs', value: vras.length },
{ label: 'Protected VMs', value: totalProt },
{ label: 'Recovery VMs', value: totalRec },
].map((s) => (
<div key={s.label} className="card p-4 text-center">
<p className="font-data text-2xl font-semibold text-text-primary data-value">{s.value}</p>
<p className="section-title mt-1">{s.label}</p>
</div>
))}
</div>
{isLoading && (
<div className="flex justify-center py-16"><Loader2 size={24} className="animate-spin text-text-muted" /></div>
)}
{Object.entries(bySite).map(([site, siteVras]) => (
<section key={site}>
<p className="section-title mb-3">{site} · {siteVras.length} VRA{siteVras.length !== 1 ? 's' : ''}</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{siteVras.map((vra) => <VraCard key={vra.id || vra.name} vra={vra} />)}
</div>
</section>
))}
</div>
);
}
+123
View File
@@ -0,0 +1,123 @@
/* src/styles/index.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ── Theme tokens (space-separated RGB for Tailwind opacity support) ── */
:root,
[data-theme="dark"] {
--color-canvas: 8 13 26;
--color-surface: 13 21 38;
--color-raised: 19 31 53;
--color-border: 30 45 71;
--color-border-bright: 42 64 102;
--color-text-primary: 226 232 240;
--color-text-secondary: 124 147 181;
--color-text-muted: 74 96 128;
}
[data-theme="light"] {
--color-canvas: 240 244 248;
--color-surface: 255 255 255;
--color-raised: 248 250 252;
--color-border: 226 232 240;
--color-border-bright: 203 213 225;
--color-text-primary: 15 23 42;
--color-text-secondary: 71 85 105;
--color-text-muted: 148 163 184;
}
@layer base {
html { @apply scroll-smooth; }
body {
@apply bg-canvas text-text-primary font-sans;
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
}
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { @apply bg-surface; }
::-webkit-scrollbar-thumb { @apply bg-border-bright rounded-full; }
::-webkit-scrollbar-thumb:hover { @apply bg-accent; }
*:focus-visible {
@apply outline-none ring-1 ring-accent ring-offset-2 ring-offset-canvas;
}
}
@layer components {
.data-value {
@apply font-data tabular-nums;
}
.status-dot {
@apply inline-block w-2 h-2 rounded-full flex-shrink-0;
}
.status-dot-ok { @apply bg-ok shadow-glow-ok animate-pulse-led; }
.status-dot-warn { @apply bg-warn; }
.status-dot-crit { @apply bg-crit shadow-glow-crit animate-pulse-led; }
.status-dot-idle { @apply bg-text-muted; }
.card {
@apply bg-surface border border-border rounded-lg;
}
.card-raised {
@apply bg-raised border border-border rounded-lg shadow-panel;
}
.table-row-hover {
@apply hover:bg-raised transition-colors duration-100 cursor-pointer;
}
.field {
@apply w-full bg-canvas border border-border rounded-md px-3 py-2
text-sm text-text-primary placeholder-text-muted
focus:border-accent focus:ring-0
transition-colors duration-150;
}
.field-label {
@apply block text-xs font-mono uppercase tracking-widest text-text-muted mb-1.5;
}
.btn-primary {
@apply inline-flex items-center gap-2 px-4 py-2 rounded-md
bg-accent hover:bg-accent-bright text-white text-sm font-medium
shadow-glow-sm hover:shadow-glow
transition-all duration-150 disabled:opacity-40 disabled:cursor-not-allowed;
}
.btn-ghost {
@apply inline-flex items-center gap-2 px-4 py-2 rounded-md
bg-transparent hover:bg-raised text-text-secondary hover:text-text-primary
border border-border hover:border-border-bright text-sm font-medium
transition-all duration-150;
}
.btn-danger {
@apply inline-flex items-center gap-2 px-4 py-2 rounded-md
bg-crit/10 hover:bg-crit/20 text-crit border border-crit/30 text-sm font-medium
transition-all duration-150;
}
.badge {
@apply inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-mono font-medium;
}
.badge-ok { @apply bg-ok/10 text-ok border border-ok/20; }
.badge-warn { @apply bg-warn/10 text-warn border border-warn/20; }
.badge-crit { @apply bg-crit/10 text-crit border border-crit/20; }
.badge-info { @apply bg-info/10 text-info border border-info/20; }
.badge-muted { @apply bg-raised text-text-muted border border-border; }
.section-title {
@apply font-mono text-xs uppercase tracking-widest text-text-muted;
}
.drawer-overlay {
@apply fixed inset-0 bg-canvas/60 backdrop-blur-sm z-40;
}
.drawer-panel {
@apply fixed top-0 right-0 h-full w-full max-w-lg bg-surface
border-l border-border shadow-panel z-50
animate-slide-in-right flex flex-col;
}
}
+66
View File
@@ -0,0 +1,66 @@
// tailwind.config.js
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,jsx,ts,tsx}'],
theme: {
extend: {
colors: {
canvas: 'rgb(var(--color-canvas) / <alpha-value>)',
surface: 'rgb(var(--color-surface) / <alpha-value>)',
raised: 'rgb(var(--color-raised) / <alpha-value>)',
border: 'rgb(var(--color-border) / <alpha-value>)',
'border-bright': 'rgb(var(--color-border-bright) / <alpha-value>)',
accent: {
DEFAULT: '#0ea5e9',
dim: '#0284c7',
bright: '#38bdf8',
glow: 'rgba(14,165,233,0.15)',
},
ok: '#10b981',
warn: '#f59e0b',
crit: '#ef4444',
info: '#818cf8',
'text-primary': 'rgb(var(--color-text-primary) / <alpha-value>)',
'text-secondary': 'rgb(var(--color-text-secondary) / <alpha-value>)',
'text-muted': 'rgb(var(--color-text-muted) / <alpha-value>)',
},
fontFamily: {
mono: ['"IBM Plex Mono"', 'monospace'],
sans: ['"DM Sans"', 'system-ui', 'sans-serif'],
data: ['"JetBrains Mono"', 'monospace'],
},
boxShadow: {
glow: '0 0 20px rgba(14,165,233,0.2)',
'glow-sm': '0 0 8px rgba(14,165,233,0.15)',
'glow-ok': '0 0 12px rgba(16,185,129,0.2)',
'glow-crit':'0 0 12px rgba(239,68,68,0.25)',
panel: '0 4px 24px rgba(0,0,0,0.4)',
},
keyframes: {
pulse_led: {
'0%, 100%': { opacity: '1' },
'50%': { opacity: '0.35' },
},
slide_in_right: {
from: { transform: 'translateX(100%)', opacity: '0' },
to: { transform: 'translateX(0)', opacity: '1' },
},
fade_in: {
from: { opacity: '0', transform: 'translateY(6px)' },
to: { opacity: '1', transform: 'translateY(0)' },
},
modal_in: {
from: { opacity: '0', transform: 'scale(0.96)' },
to: { opacity: '1', transform: 'scale(1)' },
},
},
animation: {
'pulse-led': 'pulse_led 2s ease-in-out infinite',
'slide-in-right': 'slide_in_right 0.25s ease-out',
'fade-in': 'fade_in 0.2s ease-out',
'modal-in': 'modal_in 0.2s ease-out',
},
},
},
plugins: [],
};
+34
View File
@@ -0,0 +1,34 @@
// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
resolve: {
alias: { '@': path.resolve(__dirname, 'src') },
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
sourcemap: false,
rollupOptions: {
output: {
manualChunks: {
vendor: ['react', 'react-dom', 'react-router-dom'],
charts: ['recharts'],
query: ['@tanstack/react-query'],
icons: ['lucide-react'],
},
},
},
},
});