feat: initial zROC project recreation (stubs for large files pending)
- 61 files across zroc-ui/ and zroc-ova/ directories - Full content written for: config, auth, API layers, CSS, build files, OVA scripts, backend routes, charts, hooks, constants - Stubs in place for: page components, Sidebar, TopBar, docker-compose, authentik client, blueprint YAML, packer HCL, workflows, setup wizard Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+2
@@ -0,0 +1,2 @@
|
||||
# TODO: Full content to be added
|
||||
# This file is part of the zROC project recreation
|
||||
@@ -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,2 @@
|
||||
# TODO: Full content to be added
|
||||
# This file is part of the zROC project recreation
|
||||
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env bash
|
||||
# TODO: Full content to be added
|
||||
# This file is part of the zROC project recreation
|
||||
@@ -0,0 +1,2 @@
|
||||
instance-id: zroc-appliance-build
|
||||
local-hostname: zroc-appliance
|
||||
@@ -0,0 +1,2 @@
|
||||
# TODO: Full content to be added
|
||||
# This file is part of the zROC project recreation
|
||||
@@ -0,0 +1,2 @@
|
||||
# TODO: Full content to be added
|
||||
# This file is part of the zROC project recreation
|
||||
@@ -0,0 +1,12 @@
|
||||
# zroc-ova/packer/variables.pkrvars.hcl
|
||||
vm_version = "1.0.0"
|
||||
|
||||
ubuntu_iso_url = "https://releases.ubuntu.com/26.04/ubuntu-26.04-live-server-amd64.iso"
|
||||
ubuntu_iso_checksum = "file:https://releases.ubuntu.com/26.04/SHA256SUMS"
|
||||
|
||||
memory_mb = 8192
|
||||
cpus = 4
|
||||
disk_size_mb = 102400
|
||||
|
||||
headless = true
|
||||
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,24 @@
|
||||
#!/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/ZertoPublic/zroc.git"
|
||||
|
||||
git clone --depth=1 "$ZROC_REPO" "$INSTALL_DIR"
|
||||
|
||||
mkdir -p \
|
||||
"$INSTALL_DIR/certs" \
|
||||
"$INSTALL_DIR/zvmexporter" \
|
||||
"$INSTALL_DIR/data"
|
||||
|
||||
cd "$INSTALL_DIR"
|
||||
|
||||
docker compose pull prometheus grafana authentik-server authentik-worker \
|
||||
|| echo "[02-zroc] Some images not yet available — 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,38 @@
|
||||
#!/usr/bin/env bash
|
||||
# zroc-ova/scripts/03-setup-wizard.sh
|
||||
set -euo pipefail
|
||||
echo "==> [03-setup-wizard] Installing setup wizard"
|
||||
|
||||
install -m 0755 /tmp/zroc-setup /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/ZertoPublic/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"
|
||||
@@ -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
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
# TODO: Full content to be added
|
||||
# This file is part of the zROC project recreation
|
||||
@@ -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/ZertoPublic/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/ZertoPublic/zroc](https://github.com/ZertoPublic/zroc)
|
||||
- Zerto Exporter: [github.com/recklessop/Zerto_Exporter](https://github.com/recklessop/Zerto_Exporter)
|
||||
- OVA Appliance: [github.com/ZertoPublic/zroc-ova](https://github.com/ZertoPublic/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,2 @@
|
||||
# TODO: Full content to be added
|
||||
# This file is part of the zROC project recreation
|
||||
@@ -0,0 +1,2 @@
|
||||
# TODO: Full content to be added
|
||||
# This file is part of the zROC project recreation
|
||||
@@ -0,0 +1,2 @@
|
||||
// TODO: Full content to be added
|
||||
// This file is part of the zROC project recreation
|
||||
@@ -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,2 @@
|
||||
# TODO: Full content to be added
|
||||
# This file is part of the zROC project recreation
|
||||
@@ -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,29 @@
|
||||
{
|
||||
"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",
|
||||
"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,52 @@
|
||||
// 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 { 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';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 2,
|
||||
staleTime: 15_000,
|
||||
refetchOnWindowFocus: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<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="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>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -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,63 @@
|
||||
// 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 () => {
|
||||
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,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,2 @@
|
||||
// TODO: Full content to be added
|
||||
// This file is part of the zROC project recreation
|
||||
@@ -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,2 @@
|
||||
// TODO: Full content to be added
|
||||
// This file is part of the zROC project recreation
|
||||
@@ -0,0 +1,2 @@
|
||||
// TODO: Full content to be added
|
||||
// This file is part of the zROC project recreation
|
||||
@@ -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,2 @@
|
||||
// TODO: Full content to be added
|
||||
// This file is part of the zROC project recreation
|
||||
@@ -0,0 +1,2 @@
|
||||
// TODO: Full content to be added
|
||||
// This file is part of the zROC project recreation
|
||||
@@ -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,2 @@
|
||||
// TODO: Full content to be added
|
||||
// This file is part of the zROC project recreation
|
||||
@@ -0,0 +1,2 @@
|
||||
// TODO: Full content to be added
|
||||
// This file is part of the zROC project recreation
|
||||
@@ -0,0 +1,2 @@
|
||||
// TODO: Full content to be added
|
||||
// This file is part of the zROC project recreation
|
||||
@@ -0,0 +1,2 @@
|
||||
// TODO: Full content to be added
|
||||
// This file is part of the zROC project recreation
|
||||
@@ -0,0 +1,2 @@
|
||||
// TODO: Full content to be added
|
||||
// This file is part of the zROC project recreation
|
||||
@@ -0,0 +1,99 @@
|
||||
/* src/styles/index.css */
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@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: '#080d1a',
|
||||
surface: '#0d1526',
|
||||
raised: '#131f35',
|
||||
border: '#1e2d47',
|
||||
'border-bright': '#2a4066',
|
||||
accent: {
|
||||
DEFAULT: '#0ea5e9',
|
||||
dim: '#0284c7',
|
||||
bright: '#38bdf8',
|
||||
glow: 'rgba(14,165,233,0.15)',
|
||||
},
|
||||
ok: '#10b981',
|
||||
warn: '#f59e0b',
|
||||
crit: '#ef4444',
|
||||
info: '#818cf8',
|
||||
'text-primary': '#e2e8f0',
|
||||
'text-secondary': '#7c93b5',
|
||||
'text-muted': '#4a6080',
|
||||
},
|
||||
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