Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cf71a06638 | |||
| 450f50ddf4 | |||
| fd9a5926c0 | |||
| b7b9f6191d | |||
| 5a617fd550 | |||
| ec794996bb | |||
| 0500ac171c | |||
| 74c05e5a58 | |||
| fe93c84d6b | |||
| 40ec3be00e | |||
| fa6f5d4e6d | |||
| fcc640dbfc | |||
| 4094867c4d | |||
| 5c023cca9d | |||
| d9293f4e13 | |||
| 554da3c0bc | |||
| d16ce8e8d1 | |||
| 2d5c459d85 | |||
| c99ccfcdb3 | |||
| 30eb343c0b | |||
| 0b4d87fe30 | |||
| 2bc42048b1 | |||
| 41a6d2e6c2 |
@@ -0,0 +1,6 @@
|
||||
##########################################
|
||||
# code ownership
|
||||
##########################################
|
||||
|
||||
# default ownership: default owners for everything in the repo (Unless a later match takes precedence)
|
||||
* @recklessop
|
||||
@@ -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
|
||||

|
||||
|
||||
### 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.
|
||||

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