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:
Justin
2026-04-12 16:20:05 -04:00
parent 74c05e5a58
commit 0500ac171c
61 changed files with 2262 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
# TODO: Full content to be added
# This file is part of the zROC project recreation
+64
View File
@@ -0,0 +1,64 @@
# zroc-ova/Makefile
VERSION ?= 1.0.0
PACKER_DIR = packer
OUTPUT_DIR = output
OVA_NAME = zroc-appliance-$(VERSION)-ubuntu-26.04-amd64.ova
.PHONY: all init validate build build-qemu package checksum clean help
all: build package checksum
init:
cd $(PACKER_DIR) && packer init ubuntu-2604.pkr.hcl
validate: init
cd $(PACKER_DIR) && packer validate \
-var "vm_version=$(VERSION)" \
-var-file=variables.pkrvars.hcl \
ubuntu-2604.pkr.hcl
@echo "✓ Template valid"
build: init
@echo "==> Building zROC OVA v$(VERSION) with VMware builder"
cd $(PACKER_DIR) && PACKER_LOG=1 packer build \
-var "vm_version=$(VERSION)" \
-var "headless=true" \
-var-file=variables.pkrvars.hcl \
ubuntu-2604.pkr.hcl
@echo "✓ Build complete"
build-qemu: init
@echo "==> Building zROC image v$(VERSION) with QEMU builder"
cd $(PACKER_DIR) && PACKER_LOG=1 packer build \
-only="qemu.ubuntu2604" \
-var "vm_version=$(VERSION)" \
-var-file=variables.pkrvars.hcl \
ubuntu-2604.pkr.hcl
package:
@echo "==> Packaging OVF to OVA"
@OVF=$$(find $(OUTPUT_DIR)/vmware -name "*.ovf" | head -1); \
if [ -z "$$OVF" ]; then echo "No OVF found in $(OUTPUT_DIR)/vmware"; exit 1; fi; \
ovftool --compress=9 "$$OVF" "$(OUTPUT_DIR)/$(OVA_NAME)"
@echo "✓ OVA: $(OUTPUT_DIR)/$(OVA_NAME)"
checksum:
@cd $(OUTPUT_DIR) && sha256sum $(OVA_NAME) > $(OVA_NAME).sha256
@echo "✓ Checksum: $(OUTPUT_DIR)/$(OVA_NAME).sha256"
@cat $(OUTPUT_DIR)/$(OVA_NAME).sha256
verify:
@cd $(OUTPUT_DIR) && sha256sum -c $(OVA_NAME).sha256
clean:
rm -rf $(OUTPUT_DIR)
@echo "✓ Output directory cleaned"
help:
@echo ""
@echo " zroc-ova build targets"
@echo " ──────────────────────────────────────────"
@grep -E '^## ' Makefile | sed 's/## / make /'
@echo ""
@echo " VERSION=$(VERSION) (override: make build VERSION=1.1.0)"
@echo ""
+2
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
instance-id: zroc-appliance-build
local-hostname: zroc-appliance
+2
View File
@@ -0,0 +1,2 @@
# TODO: Full content to be added
# This file is part of the zROC project recreation
+2
View File
@@ -0,0 +1,2 @@
# TODO: Full content to be added
# This file is part of the zROC project recreation
+12
View File
@@ -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"
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# zroc-ova/scripts/00-base.sh
set -euo pipefail
echo "==> [00-base] Configuring base system"
while fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1; do sleep 2; done
export DEBIAN_FRONTEND=noninteractive
apt-get update -y
apt-get upgrade -y
apt-get dist-upgrade -y
timedatectl set-timezone UTC
cat > /etc/apt/apt.conf.d/50unattended-upgrades << 'EOF'
Unattended-Upgrade::Allowed-Origins {
"${distro_id}:${distro_codename}-security";
};
Unattended-Upgrade::AutoFixInterruptedDpkg "true";
Unattended-Upgrade::MinimalSteps "true";
Unattended-Upgrade::Remove-Unused-Dependencies "true";
Unattended-Upgrade::Automatic-Reboot "false";
EOF
cat >> /etc/sysctl.d/99-zroc.conf << 'EOF'
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.all.send_redirects = 0
net.ipv4.tcp_syncookies = 1
fs.suid_dumpable = 0
kernel.core_pattern = |/bin/false
EOF
sysctl --system
sed -i 's/#PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config
sed -i 's/#PasswordAuthentication.*/PasswordAuthentication yes/' /etc/ssh/sshd_config
apt-get install -y ufw
ufw --force reset
ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp comment 'SSH'
ufw allow 80/tcp comment 'HTTP redirect'
ufw allow 443/tcp comment 'HTTPS — zROC dashboard'
ufw allow 3000/tcp comment 'Grafana (optional direct access)'
ufw --force enable
echo "==> [00-base] Done"
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# zroc-ova/scripts/01-docker.sh
set -euo pipefail
echo "==> [01-docker] Installing Docker Engine"
export DEBIAN_FRONTEND=noninteractive
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
-o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
https://download.docker.com/linux/ubuntu \
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
> /etc/apt/sources.list.d/docker.list
apt-get update -y
apt-get install -y \
docker-ce \
docker-ce-cli \
containerd.io \
docker-buildx-plugin \
docker-compose-plugin
usermod -aG docker zroc
systemctl enable docker
systemctl start docker
docker --version
docker compose version
cat > /etc/docker/daemon.json << 'EOF'
{
"log-driver": "json-file",
"log-opts": {
"max-size": "50m",
"max-file": "3"
},
"storage-driver": "overlay2"
}
EOF
systemctl restart docker
echo "==> [01-docker] Done"
+24
View File
@@ -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"
+38
View File
@@ -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"
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env bash
# zroc-ova/scripts/04-systemd-service.sh
set -euo pipefail
echo "==> [04-systemd-service] Installing zroc.service"
cat > /etc/systemd/system/zroc.service << 'EOF'
[Unit]
Description=zROC Observability Stack
Documentation=https://github.com/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"
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# zroc-ova/scripts/05-cleanup.sh
set -euo pipefail
echo "==> [05-cleanup] Cleaning build artefacts"
rm -f /etc/sudoers.d/zroc-packer
apt-get autoremove -y
apt-get autoclean -y
apt-get clean
rm -rf /var/lib/apt/lists/*
journalctl --rotate
journalctl --vacuum-time=1s
find /var/log -type f -name "*.log" -delete
find /var/log -type f -name "*.gz" -delete
truncate -s 0 /var/log/wtmp /var/log/btmp /var/log/lastlog 2>/dev/null || true
unset HISTFILE
rm -f /home/zroc/.bash_history /root/.bash_history
history -c
cloud-init clean --logs 2>/dev/null || true
rm -rf /tmp/* /var/tmp/*
echo "==> [05-cleanup] Zeroing free space (this takes a moment)…"
dd if=/dev/zero of=/ZERO bs=4M status=progress 2>/dev/null || true
rm -f /ZERO
sync
SWAP_DEV=$(swapon --show=NAME --noheadings 2>/dev/null | head -1)
if [[ -n "$SWAP_DEV" ]]; then
swapoff "$SWAP_DEV"
dd if=/dev/zero of="$SWAP_DEV" bs=4M status=progress 2>/dev/null || true
mkswap "$SWAP_DEV"
fi
echo "==> [05-cleanup] Done — image ready for OVA packaging"
+60
View File
@@ -0,0 +1,60 @@
# ─────────────────────────────────────────────────────────────────────────────
# zROC Environment Variables
# Copy to .env and fill in your values.
# Generated automatically by: zroc-setup (first-boot wizard)
# ─────────────────────────────────────────────────────────────────────────────
# ── Zerto ZVM — Site 1 ────────────────────────────────────────────────────────
ZVM_HOST=192.168.50.60
ZVM_USERNAME=admin
ZVM_PASSWORD=changeme
# Optional — needed for VRA CPU/memory metrics
VCENTER_HOST=vcenter.local
VCENTER_USER=administrator@vsphere.local
VCENTER_PASSWORD=changeme
# ── Zerto ZVM — Site 2 (uncomment to enable) ─────────────────────────────────
# ZVM2_HOST=192.168.60.60
# ZVM2_USERNAME=admin
# ZVM2_PASSWORD=changeme
# VCENTER2_HOST=vcenter2.local
# VCENTER2_USER=administrator@vsphere.local
# VCENTER2_PASSWORD=changeme
# ── zROC UI ───────────────────────────────────────────────────────────────────
# Public-facing URL of the appliance (used for OIDC redirect URIs)
PUBLIC_URL=https://192.168.50.100
# Session secret — generate with: openssl rand -hex 32
SESSION_SECRET=REPLACE_WITH_RANDOM_SECRET
# ── Authentik ─────────────────────────────────────────────────────────────────
# PostgreSQL password — generate with: openssl rand -hex 24
AUTHENTIK_PG_PASS=REPLACE_WITH_PG_PASSWORD
# Authentik secret key — generate with: openssl rand -hex 48
AUTHENTIK_SECRET_KEY=REPLACE_WITH_AUTHENTIK_SECRET
# OIDC client credentials (generated by Authentik blueprint, copied here by setup wizard)
AUTHENTIK_CLIENT_ID=zroc-dashboard
AUTHENTIK_CLIENT_SECRET=REPLACE_AFTER_BLUEPRINT_RUNS
ZROC_OIDC_CLIENT_ID=zroc-dashboard
ZROC_OIDC_CLIENT_SECRET=REPLACE_AFTER_BLUEPRINT_RUNS
# Admin API token (generated by Authentik blueprint, retrieved by setup wizard)
AUTHENTIK_ADMIN_TOKEN=REPLACE_AFTER_BLUEPRINT_RUNS
# Passed into blueprint to set redirect URI
ZROC_PUBLIC_URL=https://192.168.50.100
# ── Grafana ───────────────────────────────────────────────────────────────────
GRAFANA_PASSWORD=zertodata
# Optional: Grafana OIDC (integrates Grafana login with Authentik)
GRAFANA_OIDC_ENABLED=false
# GRAFANA_CLIENT_ID=grafana
# GRAFANA_CLIENT_SECRET=
# ── Prometheus ────────────────────────────────────────────────────────────────
# Internal only — not directly accessible from outside the stack
PROMETHEUS_URL=http://prometheus:9090
+2
View File
@@ -0,0 +1,2 @@
# TODO: Full content to be added
# This file is part of the zROC project recreation
+65
View File
@@ -0,0 +1,65 @@
# zROC UI
**Zerto Resiliency Observation Console** — a purpose-built observability frontend for Zerto that replaces Zerto Analytics with a self-hosted, always-on dashboard.
## What it does
- **NOC Dashboard** — VPG health heat grid, site cards, RPO status at a glance
- **VPG Monitor** — per-VPG RPO history, throughput/IOPS charts, journal health, VM breakdown
- **VM Protection** — per-VM drill-down with RPO trends, journal gauges, encryption trends
- **VRA Infrastructure** — CPU/memory usage, workload counts, volume capacity
- **Encryption Detection** — near real-time ransomware anomaly detection
- **Storage** — datastore capacity with Zerto-attributed journal/scratch/recovery breakdown
- **User Management** — full CRUD with 2FA QR code setup, group management, enterprise IdP integration
## Authentication
This image includes a Node.js Express backend that handles:
- OIDC login via **Authentik** (bundled in the full stack)
- 2FA enforcement (TOTP with QR codes)
- Enterprise IdP integration (Azure AD, Okta, SAML, LDAP)
- Rate-limited login, `httpOnly` session cookies, zero Prometheus exposure to browser
## Quick start — full stack
```bash
git clone https://github.com/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.
+42
View File
@@ -0,0 +1,42 @@
# Stage 1: Build the React SPA
FROM node:20-alpine AS frontend-builder
WORKDIR /build/frontend
COPY package.json package-lock.json* ./
RUN npm ci --prefer-offline
COPY index.html vite.config.js tailwind.config.js postcss.config.js ./
COPY src/ ./src/
RUN npm run build
# Stage 2: Install backend production dependencies
FROM node:20-alpine AS backend-builder
WORKDIR /build/backend
COPY backend/package.json backend/package-lock.json* ./
RUN npm ci --omit=dev --prefer-offline
# Stage 3: Production image
FROM node:20-alpine AS production
RUN addgroup -S zroc && adduser -S zroc -G zroc
WORKDIR /app
COPY backend/ ./backend/
COPY --from=backend-builder /build/backend/node_modules ./backend/node_modules
COPY --from=frontend-builder /build/frontend/dist ./dist
RUN mkdir -p /app/data && chown zroc:zroc /app/data
VOLUME ["/app/data"]
USER zroc
EXPOSE 3001
HEALTHCHECK --interval=15s --timeout=5s --start-period=30s --retries=3 \
CMD wget -qO- http://localhost:3001/api/health || exit 1
CMD ["node", "backend/server.js"]
+2
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
// TODO: Full content to be added
// This file is part of the zROC project recreation
+41
View File
@@ -0,0 +1,41 @@
// backend/config.js — central configuration with validation
'use strict';
function require_env(name) {
const val = process.env[name];
if (!val) throw new Error(`Required environment variable ${name} is not set`);
return val;
}
function optional_env(name, fallback = '') {
return process.env[name] || fallback;
}
const config = {
port: parseInt(optional_env('PORT', '3001'), 10),
node_env: optional_env('NODE_ENV', 'production'),
is_dev: optional_env('NODE_ENV', 'production') === 'development',
session_secret: optional_env('SESSION_SECRET', 'CHANGE_ME_IN_PRODUCTION_' + Math.random()),
session_max_age_ms: parseInt(optional_env('SESSION_MAX_AGE_HOURS', '24'), 10) * 60 * 60 * 1000,
prometheus_url: optional_env('PROMETHEUS_URL', 'http://prometheus:9090'),
authentik_url: optional_env('AUTHENTIK_URL', 'http://authentik-server:9000'),
authentik_client_id: optional_env('AUTHENTIK_CLIENT_ID', 'zroc-dashboard'),
authentik_client_secret: optional_env('AUTHENTIK_CLIENT_SECRET', ''),
authentik_admin_token: optional_env('AUTHENTIK_ADMIN_TOKEN', ''),
public_url: optional_env('PUBLIC_URL', 'https://localhost:8443'),
admin_group: optional_env('AUTHENTIK_ADMIN_GROUP', 'zroc-admins'),
viewer_group: optional_env('AUTHENTIK_VIEWER_GROUP', 'zroc-viewers'),
redis_url: optional_env('REDIS_URL', ''),
};
if (!config.authentik_client_secret) {
console.warn('[CONFIG] AUTHENTIK_CLIENT_SECRET not set — auth will fail until configured');
}
if (!config.authentik_admin_token) {
console.warn('[CONFIG] AUTHENTIK_ADMIN_TOKEN not set — user management API will be unavailable');
}
if (config.session_secret.startsWith('CHANGE_ME')) {
console.warn('[CONFIG] SESSION_SECRET not set — using random value, sessions will not survive restart');
}
module.exports = config;
+18
View File
@@ -0,0 +1,18 @@
// backend/logger.js
'use strict';
const { createLogger, format, transports } = require('winston');
const config = require('./config');
const logger = createLogger({
level: config.is_dev ? 'debug' : 'info',
format: format.combine(
format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
format.errors({ stack: true }),
config.is_dev
? format.combine(format.colorize(), format.simple())
: format.json()
),
transports: [new transports.Console()],
});
module.exports = logger;
@@ -0,0 +1,28 @@
// backend/middleware/authenticate.js
'use strict';
/**
* Middleware: require an authenticated session.
* If the request has no valid session → 401.
* Attaches req.user = { id, username, name, email, role } for downstream use.
*/
function authenticate(req, res, next) {
if (!req.session?.user) {
return res.status(401).json({ error: 'Unauthorized', code: 'NO_SESSION' });
}
req.user = req.session.user;
next();
}
/**
* Middleware: require admin role.
* Must be used AFTER authenticate().
*/
function requireAdmin(req, res, next) {
if (req.user?.role !== 'admin') {
return res.status(403).json({ error: 'Forbidden', code: 'REQUIRES_ADMIN' });
}
next();
}
module.exports = { authenticate, requireAdmin };
+28
View File
@@ -0,0 +1,28 @@
{
"name": "zroc-ui-backend",
"version": "1.0.0",
"description": "zROC UI backend — auth, Prometheus proxy, Authentik user management",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
},
"dependencies": {
"axios": "^1.7.2",
"connect-redis": "^7.1.1",
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
"express": "^4.19.2",
"express-rate-limit": "^7.3.1",
"express-session": "^1.18.0",
"http-proxy-middleware": "^3.0.0",
"ioredis": "^5.4.1",
"openid-client": "^5.7.0",
"qrcode": "^1.5.4",
"uuid": "^10.0.0",
"winston": "^3.13.0"
},
"devDependencies": {
"nodemon": "^3.1.4"
}
}
+117
View File
@@ -0,0 +1,117 @@
// backend/routes/admin/users.js
'use strict';
const express = require('express');
const { authenticate, requireAdmin } = require('../../middleware/authenticate');
const authentik = require('../../authentik');
const logger = require('../../logger');
const router = express.Router();
router.use(authenticate, requireAdmin);
router.get('/', async (req, res) => {
try {
const { search = '', page = '1', pageSize = '50' } = req.query;
const result = await authentik.listUsers({
search,
page: parseInt(page, 10),
pageSize: parseInt(pageSize, 10),
});
res.json(result);
} catch (err) {
logger.error('[Users] List failed:', err.message);
res.status(502).json({ error: 'Failed to list users', detail: err.message });
}
});
router.get('/:id', async (req, res) => {
try {
const user = await authentik.getUser(req.params.id);
res.json(user);
} catch (err) {
const status = err.response?.status === 404 ? 404 : 502;
res.status(status).json({ error: 'User not found', detail: err.message });
}
});
router.post('/', async (req, res) => {
try {
const { username, name, email, isActive = true, groups = [], password } = req.body;
if (!username || !name || !email) {
return res.status(400).json({ error: 'username, name, and email are required' });
}
const user = await authentik.createUser({ username, name, email, isActive, groups, password });
logger.info(`[Users] ${req.user.username} created user ${username}`);
res.status(201).json(user);
} catch (err) {
const detail = err.response?.data || err.message;
logger.error('[Users] Create failed:', detail);
res.status(err.response?.status === 400 ? 400 : 502).json({ error: 'Failed to create user', detail });
}
});
router.patch('/:id', async (req, res) => {
try {
const { name, email, isActive, groups } = req.body;
const user = await authentik.updateUser(req.params.id, { name, email, isActive, groups });
logger.info(`[Users] ${req.user.username} updated user ${user.username}`);
res.json(user);
} catch (err) {
logger.error('[Users] Update failed:', err.message);
res.status(502).json({ error: 'Failed to update user', detail: err.message });
}
});
router.delete('/:id', async (req, res) => {
try {
const targetId = parseInt(req.params.id, 10);
if (String(targetId) === String(req.user.id) || req.user.username === 'akadmin') {
return res.status(400).json({ error: 'Cannot delete your own account or the akadmin account' });
}
await authentik.deleteUser(targetId);
logger.info(`[Users] ${req.user.username} deleted user ${targetId}`);
res.status(204).send();
} catch (err) {
logger.error('[Users] Delete failed:', err.message);
res.status(502).json({ error: 'Failed to delete user', detail: err.message });
}
});
router.post('/:id/set-password', async (req, res) => {
try {
const { password } = req.body;
if (!password || password.length < 8) {
return res.status(400).json({ error: 'Password must be at least 8 characters' });
}
await authentik.setPassword(req.params.id, password);
logger.info(`[Users] ${req.user.username} reset password for user ${req.params.id}`);
res.json({ success: true });
} catch (err) {
logger.error('[Users] Password reset failed:', err.message);
res.status(502).json({ error: 'Failed to set password', detail: err.message });
}
});
router.post('/:id/setup-2fa', async (req, res) => {
try {
const { setupUrl, qrDataUrl } = await authentik.generateTwoFactorSetupLink(req.params.id);
logger.info(`[Users] ${req.user.username} generated 2FA setup link for user ${req.params.id}`);
res.json({ setupUrl, qrDataUrl });
} catch (err) {
logger.error('[Users] 2FA setup failed:', err.message);
res.status(502).json({ error: 'Failed to generate 2FA setup link', detail: err.message });
}
});
router.get('/meta/groups', async (req, res) => {
try {
const groups = await authentik.listGroups();
res.json(groups);
} catch (err) {
logger.error('[Users] Groups list failed:', err.message);
res.status(502).json({ error: 'Failed to list groups', detail: err.message });
}
});
module.exports = router;
+138
View File
@@ -0,0 +1,138 @@
// backend/routes/auth.js — OIDC login / callback / logout
'use strict';
const express = require('express');
const { Issuer, generators } = require('openid-client');
const config = require('../config');
const logger = require('../logger');
const { authenticate } = require('../middleware/authenticate');
const router = express.Router();
let oidcClient = null;
async function getOidcClient() {
if (oidcClient) return oidcClient;
const issuerUrl = `${config.authentik_url}/application/o/${config.authentik_client_id}/`;
logger.info(`[Auth] Discovering OIDC issuer at ${issuerUrl}`);
const issuer = await Issuer.discover(issuerUrl);
oidcClient = new issuer.Client({
client_id: config.authentik_client_id,
client_secret: config.authentik_client_secret,
redirect_uris: [`${config.public_url}/api/auth/callback`],
response_types: ['code'],
});
logger.info('[Auth] OIDC client initialised');
return oidcClient;
}
router.get('/login', async (req, res) => {
try {
const client = await getOidcClient();
const state = generators.state();
const nonce = generators.nonce();
const verifier = generators.codeVerifier();
const challenge = generators.codeChallenge(verifier);
req.session.oidc = { state, nonce, verifier };
const redirectTo = req.query.redirect || '/';
req.session.postLoginRedirect = redirectTo;
const authUrl = client.authorizationUrl({
scope: 'openid profile email groups',
state,
nonce,
code_challenge: challenge,
code_challenge_method: 'S256',
});
res.redirect(authUrl);
} catch (err) {
logger.error('[Auth] Login redirect failed:', err);
res.status(502).json({ error: 'Identity provider unavailable' });
}
});
router.get('/callback', async (req, res) => {
try {
const client = await getOidcClient();
const { state, nonce, verifier } = req.session.oidc || {};
if (!state) {
return res.redirect('/?error=session_expired');
}
const params = client.callbackParams(req);
const tokenSet = await client.callback(
`${config.public_url}/api/auth/callback`,
params,
{ state, nonce, code_verifier: verifier }
);
const userinfo = await client.userinfo(tokenSet.access_token);
const groups = userinfo.groups ?? [];
const role = groups.includes(config.admin_group)
? 'admin'
: groups.includes(config.viewer_group)
? 'viewer'
: 'viewer';
req.session.user = {
id: userinfo.sub,
username: userinfo.preferred_username,
name: userinfo.name,
email: userinfo.email,
role,
groups,
accessToken: tokenSet.access_token,
refreshToken: tokenSet.refresh_token,
expiresAt: tokenSet.expires_at,
};
delete req.session.oidc;
const redirect = req.session.postLoginRedirect || '/';
delete req.session.postLoginRedirect;
logger.info(`[Auth] User ${userinfo.preferred_username} (${role}) logged in`);
res.redirect(redirect);
} catch (err) {
logger.error('[Auth] Callback failed:', err);
res.redirect('/?error=auth_failed');
}
});
router.post('/logout', authenticate, async (req, res) => {
const username = req.user?.username;
const idToken = req.session.user?.accessToken;
req.session.destroy(() => {
res.clearCookie('connect.sid');
logger.info(`[Auth] User ${username} logged out`);
const endSessionUrl = `${config.authentik_url}/application/o/${config.authentik_client_id}/end-session/`;
const params = new URLSearchParams({ post_logout_redirect_uri: config.public_url });
if (idToken) params.set('id_token_hint', idToken);
res.json({ redirectUrl: `${endSessionUrl}?${params}` });
});
});
router.get('/me', authenticate, (req, res) => {
const { id, username, name, email, role, groups } = req.user;
res.json({ id, username, name, email, role, groups });
});
router.get('/status', (req, res) => {
if (req.session?.user) {
const { id, username, name, email, role } = req.session.user;
res.json({ authenticated: true, user: { id, username, name, email, role } });
} else {
res.status(401).json({ authenticated: false });
}
});
module.exports = router;
+26
View File
@@ -0,0 +1,26 @@
// backend/routes/prometheus.js
'use strict';
const express = require('express');
const { createProxyMiddleware } = require('http-proxy-middleware');
const config = require('../config');
const { authenticate } = require('../middleware/authenticate');
const router = express.Router();
router.use(authenticate);
const prometheusProxy = createProxyMiddleware({
target: config.prometheus_url,
changeOrigin: true,
pathRewrite: { '^/api/prometheus': '' },
on: {
error: (err, req, res) => {
res.status(502).json({ error: 'Prometheus unreachable', detail: err.message });
},
},
});
router.use('/', prometheusProxy);
module.exports = router;
+75
View File
@@ -0,0 +1,75 @@
// backend/server.js — zROC UI backend entry point
'use strict';
const path = require('path');
const express = require('express');
const session = require('express-session');
const rateLimit = require('express-rate-limit');
const cookieParser = require('cookie-parser');
const config = require('./config');
const logger = require('./logger');
const authRoutes = require('./routes/auth');
const prometheusRoute = require('./routes/prometheus');
const adminUserRoutes = require('./routes/admin/users');
const app = express();
app.set('trust proxy', 1);
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
const sessionMiddleware = session({
secret: config.session_secret,
resave: false,
saveUninitialized: false,
cookie: {
secure: !config.is_dev,
httpOnly: true,
sameSite: 'lax',
maxAge: config.session_max_age_ms,
},
});
app.use(sessionMiddleware);
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 20,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many requests, please try again later' },
});
app.use('/api/auth', authLimiter);
app.use('/api/auth', authRoutes);
app.use('/api/prometheus', prometheusRoute);
app.use('/api/admin/users', adminUserRoutes);
app.get('/api/health', (req, res) => {
res.json({ status: 'ok', ts: new Date().toISOString() });
});
const distPath = path.join(__dirname, '..', 'dist');
app.use(express.static(distPath));
app.get('*', (req, res) => {
if (req.path.startsWith('/api/')) {
return res.status(404).json({ error: 'Not found' });
}
res.sendFile(path.join(distPath, 'index.html'));
});
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, _next) => {
logger.error('Unhandled error:', err);
res.status(500).json({ error: 'Internal server error' });
});
app.listen(config.port, () => {
logger.info(`[Server] zROC UI backend listening on port ${config.port}`);
logger.info(`[Server] Environment: ${config.node_env}`);
logger.info(`[Server] Prometheus: ${config.prometheus_url}`);
logger.info(`[Server] Authentik: ${config.authentik_url}`);
});
+47
View File
@@ -0,0 +1,47 @@
{
admin off
auto_https off
log {
format json
}
}
:443 {
tls internal
handle /auth/* {
reverse_proxy authentik-server:9000 {
header_up X-Forwarded-Proto https
header_up X-Forwarded-For {remote_host}
}
}
handle /outpost.goauthentik.io/* {
reverse_proxy authentik-server:9000 {
header_up X-Forwarded-Proto https
}
}
handle {
reverse_proxy zroc-ui:3001 {
header_up X-Forwarded-Proto https
header_up X-Forwarded-For {remote_host}
header_up X-Real-IP {remote_host}
health_uri /api/health
health_interval 15s
}
}
header {
X-Frame-Options "SAMEORIGIN"
X-Content-Type-Options "nosniff"
X-XSS-Protection "1; mode=block"
Referrer-Policy "strict-origin-when-cross-origin"
Strict-Transport-Security "max-age=31536000; includeSubDomains"
-Server
}
}
:80 {
redir https://{host}{uri} permanent
}
+2
View File
@@ -0,0 +1,2 @@
# TODO: Full content to be added
# This file is part of the zROC project recreation
+21
View File
@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>zROC — Zerto Resiliency Observation Console</title>
<!-- Fonts: IBM Plex Mono (headings) + DM Sans (body) + JetBrains Mono (data) -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=DM+Sans:wght@300;400;500;600&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
</head>
<body class="bg-canvas text-text-primary font-sans antialiased">
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+29
View File
@@ -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"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+17
View File
@@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<!-- Background -->
<rect width="32" height="32" rx="6" fill="#0d1526"/>
<!-- Border -->
<rect x="1" y="1" width="30" height="30" rx="5.5" stroke="#0ea5e9" stroke-width="1" stroke-opacity="0.4"/>
<!-- Activity line (zROC pulse) -->
<polyline
points="4,18 8,18 10,10 12,24 15,14 17,20 19,16 21,16 24,16 28,16"
stroke="#0ea5e9"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"
/>
<!-- Live dot -->
<circle cx="28" cy="16" r="2.5" fill="#10b981"/>
</svg>

After

Width:  |  Height:  |  Size: 594 B

+52
View File
@@ -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>
);
}
+191
View File
@@ -0,0 +1,191 @@
// src/api/prometheus.js
const BASE = '/api/prometheus/api/v1';
async function promFetch(endpoint, params = {}) {
const url = new URL(BASE + endpoint, window.location.origin);
Object.entries(params).forEach(([k, v]) => {
if (v !== undefined && v !== null) url.searchParams.set(k, v);
});
const res = await fetch(url.toString(), { credentials: 'include' });
if (!res.ok) throw new Error(`Prometheus error: ${res.status}`);
const json = await res.json();
if (json.status !== 'success') throw new Error(json.error || 'Prometheus query failed');
return json.data;
}
export async function instantQuery(promql, time) {
const params = { query: promql };
if (time) params.time = time;
const data = await promFetch('/query', params);
return data.result;
}
export async function rangeQuery(promql, start, end, step = '60s') {
const data = await promFetch('/query_range', { query: promql, start, end, step });
return data.result;
}
export async function labelValues(labelName, match) {
const params = {};
if (match) params.match = match;
const data = await promFetch(`/label/${labelName}/values`, params);
return data;
}
export async function querySites() {
return labelValues('SiteName', 'vpg_actual_rpo');
}
export async function queryOverviewSummary() {
const [alertVec, throughputVec, rpoVec] = await Promise.all([
instantQuery('vpg_alert_status'),
instantQuery('sum by (SiteName) (vpg_throughput_in_mb)'),
instantQuery('max by (SiteName) (vpg_actual_rpo)'),
]);
const siteMap = {};
for (const { metric, value } of alertVec) {
const site = metric.SiteName || 'Unknown';
if (!siteMap[site]) siteMap[site] = { siteName: site, ok: 0, warn: 0, crit: 0 };
const v = Number(value[1]);
if (v === 0) siteMap[site].ok++;
else if (v === 1) siteMap[site].warn++;
else siteMap[site].crit++;
}
for (const { metric, value } of throughputVec) {
const site = metric.SiteName || 'Unknown';
if (siteMap[site]) siteMap[site].throughputMb = parseFloat(value[1]);
}
for (const { metric, value } of rpoVec) {
const site = metric.SiteName || 'Unknown';
if (siteMap[site]) siteMap[site].worstRpoSec = parseFloat(value[1]);
}
return Object.values(siteMap).map((s) => ({
...s,
total: s.ok + s.warn + s.crit,
}));
}
export async function queryAllVpgs() {
const [rpoVec, configuredVec, alertVec, throughputVec, iopsVec, vmCountVec] =
await Promise.all([
instantQuery('vpg_actual_rpo'),
instantQuery('vpg_configured_rpo'),
instantQuery('vpg_alert_status'),
instantQuery('vpg_throughput_in_mb'),
instantQuery('vpg_iops'),
instantQuery('vpg_vms_count'),
]);
const byId = {};
const idx = (vec, field, transform = Number) => {
for (const { metric, value } of vec) {
const id = metric.VpgIdentifier || metric.VpgName;
if (!byId[id]) byId[id] = {
id,
name: metric.VpgName || id,
siteName: metric.SiteName || 'Unknown',
siteId: metric.SiteIdentifier,
priority: metric.VpgPriority,
};
byId[id][field] = transform(value[1]);
}
};
idx(rpoVec, 'actualRpoSec');
idx(configuredVec, 'configuredRpoSec');
idx(alertVec, 'alertStatus');
idx(throughputVec, 'throughputMb', parseFloat);
idx(iopsVec, 'iops', parseFloat);
idx(vmCountVec, 'vmCount');
return Object.values(byId);
}
export async function queryTopRpoViolators(n = 10) {
const vpgs = await queryAllVpgs();
return vpgs
.filter((v) => v.actualRpoSec && v.configuredRpoSec)
.sort((a, b) => (b.actualRpoSec / b.configuredRpoSec) - (a.actualRpoSec / a.configuredRpoSec))
.slice(0, n);
}
export async function queryVpgRpoHistory(vpgName, startOffset = '6h', step = '60s') {
const end = Math.floor(Date.now() / 1000);
const start = end - parseDuration(startOffset);
const q = `vpg_actual_rpo{VpgName="${vpgName}"}`;
const result = await rangeQuery(q, start, end, step);
if (!result.length) return [];
const configured = (await instantQuery(`vpg_configured_rpo{VpgName="${vpgName}"}`))
?.[0]?.value?.[1];
return result[0].values.map(([ts, v]) => ({
ts: ts * 1000,
rpo: parseFloat(v),
configured: configured ? parseFloat(configured) : undefined,
}));
}
export async function queryVraHealth() {
const [memVec, cpuVec, protVmsVec, recVmsVec, protVolVec, recVolVec] = await Promise.all([
instantQuery('vra_memory_usage_mb'),
instantQuery('vra_cpu_usage_mhz'),
instantQuery('vra_protected_vms'),
instantQuery('vra_recovery_vms'),
instantQuery('vra_protected_volumes'),
instantQuery('vra_recovery_volumes'),
]);
const byName = {};
const idx = (vec, field, transform = Number) => {
for (const { metric, value } of vec) {
const key = metric.VraName || metric.VraIdentifierStr;
if (!byName[key]) byName[key] = {
name: metric.VraName,
version: metric.VraVersion,
hostVersion: metric.HostVersion,
siteName: metric.SiteName,
};
byName[key][field] = transform(value[1]);
}
};
idx(memVec, 'memoryUsageMb', parseFloat);
idx(cpuVec, 'cpuUsageMhz', parseFloat);
idx(protVmsVec, 'protectedVms');
idx(recVmsVec, 'recoveryVms');
idx(protVolVec, 'protectedVolumes');
idx(recVolVec, 'recoveryVolumes');
return Object.values(byName);
}
export async function queryEncryptionOverview() {
const vec = await instantQuery('vm_PercentEncrypted > 50');
return vec.map(({ metric, value }) => ({
vmName: metric.VmName,
vpgName: metric.VpgName,
siteName: metric.SiteName,
pctEnc: parseFloat(value[1]),
trend: metric.vm_TrendChangeLevel,
})).sort((a, b) => b.pctEnc - a.pctEnc);
}
export async function queryExporterHealth() {
const vec = await instantQuery('exporter_thread_status');
return vec.map(({ metric, value }) => ({
instance: metric.ExporterInstance,
thread: metric.thread,
alive: Number(value[1]) === 1,
}));
}
function parseDuration(s) {
const match = s.match(/^(\d+)(s|m|h|d)$/);
if (!match) return 3600;
const [, n, unit] = match;
const mul = { s: 1, m: 60, h: 3600, d: 86400 };
return parseInt(n, 10) * mul[unit];
}
+248
View File
@@ -0,0 +1,248 @@
// src/api/prometheusExtended.js
import { instantQuery, rangeQuery, labelValues } from './prometheus';
export async function queryVpgDetail(vpgName) {
const esc = vpgName.replace(/"/g, '\\"');
const [rpo, cfgRpo, alert, status, throughput, iops, vmCount,
storageUsed, storageProv, histActual, histCfg, failsafeActual, failsafeCfg] =
await Promise.all([
instantQuery(`vpg_actual_rpo{VpgName="${esc}"}`),
instantQuery(`vpg_configured_rpo{VpgName="${esc}"}`),
instantQuery(`vpg_alert_status{VpgName="${esc}"}`),
instantQuery(`vpg_status{VpgName="${esc}"}`),
instantQuery(`vpg_throughput_in_mb{VpgName="${esc}"}`),
instantQuery(`vpg_iops{VpgName="${esc}"}`),
instantQuery(`vpg_vms_count{VpgName="${esc}"}`),
instantQuery(`vpg_storage_used_in_mb{VpgName="${esc}"}`),
instantQuery(`vpg_provisioned_storage_in_mb{VpgName="${esc}"}`),
instantQuery(`vpg_actual_history{VpgName="${esc}"}`),
instantQuery(`vpg_configured_history{VpgName="${esc}"}`),
instantQuery(`vpg_failsafe_actual{VpgName="${esc}"}`),
instantQuery(`vpg_failsafe_configured{VpgName="${esc}"}`),
]);
const val = (vec) => parseFloat(vec?.[0]?.value?.[1] ?? 0);
const meta = rpo?.[0]?.metric ?? {};
return {
name: vpgName,
siteName: meta.SiteName,
priority: meta.VpgPriority,
actualRpoSec: val(rpo),
configuredRpoSec: val(cfgRpo),
alertStatus: val(alert),
status: val(status),
throughputMb: val(throughput),
iops: val(iops),
vmCount: val(vmCount),
storageUsedMb: val(storageUsed),
storageProvMb: val(storageProv),
histActualMin: val(histActual),
histConfiguredMin:val(histCfg),
failsafeActualMin:val(failsafeActual),
failsafeCfgMin: val(failsafeCfg),
};
}
export async function queryVpgVms(vpgName) {
const esc = vpgName.replace(/"/g, '\\"');
const [rpo, status, throughput, iops, journalUsed, journalHard] = await Promise.all([
instantQuery(`vm_actualrpo{VpgName="${esc}"}`),
instantQuery(`vm_status{VpgName="${esc}"}`),
instantQuery(`vm_throughput_in_mb{VpgName="${esc}"}`),
instantQuery(`vm_iops{VpgName="${esc}"}`),
instantQuery(`vm_journal_used_storage_mb{VpgName="${esc}"}`),
instantQuery(`vm_journal_hard_limit{VpgName="${esc}"}`),
]);
const byId = {};
const idx = (vec, field, transform = Number) => {
for (const { metric, value } of vec) {
const id = metric.VmIdentifier || metric.VmName;
if (!byId[id]) byId[id] = {
id,
name: metric.VmName,
sourceVra: metric.VmSourceVRA,
recoveryVra: metric.VmRecoveryVRA,
priority: metric.VmPriority,
};
byId[id][field] = transform(value[1]);
}
};
idx(rpo, 'actualRpoSec');
idx(status, 'status');
idx(throughput, 'throughputMb', parseFloat);
idx(iops, 'iops', parseFloat);
idx(journalUsed, 'journalUsedMb', parseFloat);
idx(journalHard, 'journalHardLimit', parseFloat);
return Object.values(byId);
}
export async function queryAllVms() {
const [rpo, status, throughput, iops, journalUsed, bandwidth, pctEnc] = await Promise.all([
instantQuery('vm_actualrpo'),
instantQuery('vm_status'),
instantQuery('vm_throughput_in_mb'),
instantQuery('vm_iops'),
instantQuery('vm_journal_used_storage_mb'),
instantQuery('vm_outgoing_bandwidth_in_mbps'),
instantQuery('vm_PercentEncrypted'),
]);
const byId = {};
const idx = (vec, field, transform = Number) => {
for (const { metric, value } of vec) {
const id = metric.VmIdentifier || metric.VmName;
if (!byId[id]) byId[id] = {
id,
name: metric.VmName,
vpgName: metric.VpgName,
siteName: metric.SiteName,
sourceVra: metric.VmSourceVRA,
recoveryVra: metric.VmRecoveryVRA,
};
byId[id][field] = transform(value[1]);
}
};
idx(rpo, 'actualRpoSec');
idx(status, 'status');
idx(throughput, 'throughputMb', parseFloat);
idx(iops, 'iops', parseFloat);
idx(journalUsed, 'journalUsedMb', parseFloat);
idx(bandwidth, 'bandwidthMbps', parseFloat);
idx(pctEnc, 'pctEncrypted', parseFloat);
return Object.values(byId);
}
export async function queryAllVras() {
const [mem, cpu, memUsage, cpuUsage,
protVms, recVms, protVpgs, recVpgs, protVols, recVols, selfVpgs] =
await Promise.all([
instantQuery('vra_memory_in_GB'),
instantQuery('vra_vcpu_count'),
instantQuery('vra_memory_usage_mb'),
instantQuery('vra_cpu_usage_mhz'),
instantQuery('vra_protected_vms'),
instantQuery('vra_recovery_vms'),
instantQuery('vra_protected_vpgs'),
instantQuery('vra_recovery_vpgs'),
instantQuery('vra_protected_volumes'),
instantQuery('vra_recovery_volumes'),
instantQuery('vra_self_protected_vpgs'),
]);
const byName = {};
const idx = (vec, field, transform = Number) => {
for (const { metric, value } of vec) {
const key = metric.VraName || metric.VraIdentifierStr;
if (!byName[key]) byName[key] = {
id: metric.VraIdentifierStr,
name: metric.VraName,
version: metric.VraVersion,
hostVersion: metric.HostVersion,
siteName: metric.SiteName,
siteId: metric.SiteIdentifier,
};
byName[key][field] = transform(value[1]);
}
};
idx(mem, 'memoryGb', parseFloat);
idx(cpu, 'vcpuCount');
idx(memUsage, 'memUsageMb', parseFloat);
idx(cpuUsage, 'cpuUsageMhz', parseFloat);
idx(protVms, 'protectedVms');
idx(recVms, 'recoveryVms');
idx(protVpgs, 'protectedVpgs');
idx(recVpgs, 'recoveryVpgs');
idx(protVols, 'protectedVolumes');
idx(recVols, 'recoveryVolumes');
idx(selfVpgs, 'selfProtectedVpgs');
return Object.values(byName);
}
export async function queryEncryptionDetail() {
const [pctEnc, trend, encrypted, unencrypted, total, ioOps, writeCounter] =
await Promise.all([
instantQuery('vm_PercentEncrypted'),
instantQuery('vm_TrendChangeLevel'),
instantQuery('vm_EncryptedDataInLBs'),
instantQuery('vm_UnencryptedDataInLBs'),
instantQuery('vm_TotalDataInLBs'),
instantQuery('vm_IoOperationsCounter'),
instantQuery('vm_WriteCounterInMBs'),
]);
const byId = {};
const idx = (vec, field, transform = Number) => {
for (const { metric, value } of vec) {
const id = metric.VmIdentifier || metric.VmName;
if (!byId[id]) byId[id] = {
id,
name: metric.VmName,
vpgName: metric.VpgName,
siteName:metric.SiteName,
vpgId: metric.VpgIdentifier,
};
byId[id][field] = transform(value[1]);
}
};
idx(pctEnc, 'pctEncrypted', parseFloat);
idx(trend, 'trendLevel', parseFloat);
idx(encrypted, 'encryptedLbs', parseFloat);
idx(unencrypted, 'unencryptedLbs', parseFloat);
idx(total, 'totalLbs', parseFloat);
idx(ioOps, 'ioOps', parseFloat);
idx(writeCounter,'writeMb', parseFloat);
return Object.values(byId).sort((a, b) => (b.pctEncrypted ?? 0) - (a.pctEncrypted ?? 0));
}
export async function queryDatastores() {
const metrics = [
'datastore_capacity_in_bytes',
'datastore_free_in_bytes',
'datastore_used_in_bytes',
'datastore_vras',
'datastore_incoming_vms',
'datastore_outgoing_vms',
'datastore_usage_zerto_journal_used_in_bytes',
'datastore_usage_zerto_scratch_used_in_bytes',
'datastore_usage_zerto_recovery_used_in_bytes',
'datastore_usage_zerto_appliances_used_in_bytes',
];
const results = await Promise.all(metrics.map(instantQuery));
const byId = {};
metrics.forEach((metric, mi) => {
const fieldMap = {
datastore_capacity_in_bytes: 'capacityBytes',
datastore_free_in_bytes: 'freeBytes',
datastore_used_in_bytes: 'usedBytes',
datastore_vras: 'vraCount',
datastore_incoming_vms: 'incomingVms',
datastore_outgoing_vms: 'outgoingVms',
datastore_usage_zerto_journal_used_in_bytes: 'journalBytes',
datastore_usage_zerto_scratch_used_in_bytes: 'scratchBytes',
datastore_usage_zerto_recovery_used_in_bytes: 'recoveryBytes',
datastore_usage_zerto_appliances_used_in_bytes: 'applianceBytes',
};
const field = fieldMap[metric];
for (const { metric: m, value } of results[mi]) {
const id = m.datastoreIdentifier || m.DatastoreName;
if (!byId[id]) byId[id] = {
id, name: m.DatastoreName, siteName: m.SiteName,
};
byId[id][field] = parseFloat(value[1]);
}
});
return Object.values(byId).sort((a, b) => (b.capacityBytes ?? 0) - (a.capacityBytes ?? 0));
}
+46
View File
@@ -0,0 +1,46 @@
// src/api/users.js — user management API calls
const BASE = '/api/admin/users';
async function apiFetch(url, opts = {}) {
const res = await fetch(url, { credentials: 'include', ...opts });
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw Object.assign(new Error(body.error || `HTTP ${res.status}`), {
status: res.status,
detail: body.detail,
});
}
if (res.status === 204) return null;
return res.json();
}
export const usersApi = {
list: ({ search = '', page = 1, pageSize = 50 } = {}) => {
const params = new URLSearchParams({ search, page, pageSize });
return apiFetch(`${BASE}?${params}`);
},
get: (id) => apiFetch(`${BASE}/${id}`),
create: (body) =>
apiFetch(BASE, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}),
update: (id, body) =>
apiFetch(`${BASE}/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}),
delete: (id) =>
apiFetch(`${BASE}/${id}`, { method: 'DELETE' }),
setPassword: (id, password) =>
apiFetch(`${BASE}/${id}/set-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
}),
setup2fa: (id) =>
apiFetch(`${BASE}/${id}/setup-2fa`, { method: 'POST' }),
listGroups: () => apiFetch(`${BASE}/meta/groups`),
};
+63
View File
@@ -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;
}
+55
View File
@@ -0,0 +1,55 @@
// src/auth/ProtectedRoute.jsx
import { useEffect } from 'react';
import { useAuth } from './AuthContext';
function LoadingScreen() {
return (
<div className="flex items-center justify-center h-screen bg-canvas">
<div className="flex flex-col items-center gap-4">
<div className="w-8 h-8 border-2 border-border border-t-accent rounded-full animate-spin" />
<p className="font-mono text-xs text-text-muted uppercase tracking-widest">
Verifying session
</p>
</div>
</div>
);
}
export function ProtectedRoute({ children }) {
const { user, loading, login } = useAuth();
useEffect(() => {
if (!loading && !user) login();
}, [loading, user, login]);
if (loading) return <LoadingScreen />;
if (!user) return <LoadingScreen />;
return children;
}
export function AdminRoute({ children }) {
const { user, loading, login, isAdmin } = useAuth();
useEffect(() => {
if (!loading && !user) login();
}, [loading, user, login]);
if (loading) return <LoadingScreen />;
if (!user) return <LoadingScreen />;
if (!isAdmin) {
return (
<div className="flex items-center justify-center h-screen bg-canvas">
<div className="card p-10 text-center max-w-sm">
<p className="font-mono text-crit text-lg mb-2">403</p>
<p className="text-text-secondary text-sm">
This page requires administrator privileges.
</p>
</div>
</div>
);
}
return children;
}
@@ -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
+2
View File
@@ -0,0 +1,2 @@
// TODO: Full content to be added
// This file is part of the zROC project recreation
+89
View File
@@ -0,0 +1,89 @@
// src/constants/statusMaps.js
export const VPG_STATUS = {
0: { label: 'Initializing', color: 'info', dot: 'info' },
1: { label: 'Meeting SLA', color: 'ok', dot: 'ok' },
2: { label: 'Not Meeting SLA', color: 'crit', dot: 'crit' },
3: { label: 'History Not Meeting SLA',color: 'warn', dot: 'warn' },
4: { label: 'RPO Not Meeting SLA', color: 'crit', dot: 'crit' },
5: { label: 'Failing Over', color: 'info', dot: 'info' },
6: { label: 'Moving', color: 'info', dot: 'info' },
7: { label: 'Deleting', color: 'muted', dot: 'idle' },
8: { label: 'Recovering', color: 'info', dot: 'info' },
9: { label: 'Needs Configuration', color: 'warn', dot: 'warn' },
};
export const VPG_ALERT = {
0: { label: 'No Alert', color: 'ok' },
1: { label: 'Warning', color: 'warn' },
2: { label: 'Error', color: 'crit' },
};
export const VM_STATUS = {
0: { label: 'Protected', color: 'ok' },
1: { label: 'Initializing', color: 'info' },
2: { label: 'Replication Paused', color: 'warn' },
3: { label: 'Error', color: 'crit' },
4: { label: 'Empty Protection Group', color: 'muted'},
5: { label: 'Disconnected', color: 'crit' },
6: { label: 'Backing Up', color: 'info' },
7: { label: 'Preparing Failover', color: 'info' },
8: { label: 'Failing Over', color: 'info' },
9: { label: 'Move Failed', color: 'crit' },
};
export function vpgHealth(statusCode) {
const s = VPG_STATUS[statusCode] ?? { label: 'Unknown', color: 'muted', dot: 'idle' };
return s;
}
export function isVpgAlerting(statusCode) {
return [2, 4].includes(statusCode);
}
export function isVpgWarning(statusCode) {
return [3, 9].includes(statusCode);
}
export const colorToText = {
ok: 'text-ok',
warn: 'text-warn',
crit: 'text-crit',
info: 'text-info',
muted: 'text-text-muted',
};
export const colorToBg = {
ok: 'bg-ok/10',
warn: 'bg-warn/10',
crit: 'bg-crit/10',
info: 'bg-info/10',
muted: 'bg-raised',
};
export function formatRpo(seconds) {
if (seconds == null || isNaN(seconds)) return '—';
const s = Math.round(seconds);
if (s < 60) return `${s}s`;
if (s < 3600) return `${Math.floor(s / 60)}m ${String(s % 60).padStart(2,'0')}s`;
return `${Math.floor(s / 3600)}h ${String(Math.floor((s % 3600) / 60)).padStart(2,'0')}m`;
}
export function formatBytes(bytes, decimals = 1) {
if (!bytes || bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(decimals))} ${sizes[i]}`;
}
export function formatMB(mb) {
return formatBytes((mb ?? 0) * 1024 * 1024);
}
export function rpoStatus(actualSec, configuredSec) {
if (!actualSec || !configuredSec) return 'muted';
const ratio = actualSec / configuredSec;
if (ratio <= 0.75) return 'ok';
if (ratio <= 1.0) return 'warn';
return 'crit';
}
+18
View File
@@ -0,0 +1,18 @@
// src/hooks/useInstantQuery.js
import { useQuery } from '@tanstack/react-query';
import { instantQuery } from '@/api/prometheus';
export function useInstantQuery(promql, {
refreshMs = 30_000,
enabled = true,
select,
} = {}) {
return useQuery({
queryKey: ['instant', promql],
queryFn: () => instantQuery(promql),
refetchInterval: refreshMs,
enabled: enabled && !!promql,
select,
staleTime: refreshMs / 2,
});
}
+42
View File
@@ -0,0 +1,42 @@
// src/hooks/useRangeQuery.js
import { useQuery } from '@tanstack/react-query';
import { rangeQuery } from '@/api/prometheus';
const WINDOW_SECONDS = {
'1h': 3600,
'6h': 21600,
'24h': 86400,
'7d': 604800,
'30d': 2592000,
};
const STEP_FOR_WINDOW = {
'1h': '30s',
'6h': '120s',
'24h': '300s',
'7d': '900s',
'30d': '3600s',
};
export function useRangeQuery(promql, {
window = '6h',
refreshMs = 60_000,
enabled = true,
select,
} = {}) {
const windowSec = WINDOW_SECONDS[window] ?? 21600;
const step = STEP_FOR_WINDOW[window] ?? '120s';
return useQuery({
queryKey: ['range', promql, window],
queryFn: () => {
const end = Math.floor(Date.now() / 1000);
const start = end - windowSec;
return rangeQuery(promql, start, end, step);
},
refetchInterval: refreshMs,
enabled: enabled && !!promql,
select,
staleTime: refreshMs / 2,
});
}
+11
View File
@@ -0,0 +1,11 @@
// src/main.jsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import '@/styles/index.css';
import App from './App';
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>
);
+2
View File
@@ -0,0 +1,2 @@
// TODO: Full content to be added
// This file is part of the zROC project recreation
+2
View File
@@ -0,0 +1,2 @@
// TODO: Full content to be added
// This file is part of the zROC project recreation
+21
View File
@@ -0,0 +1,21 @@
// src/pages/Placeholder.jsx
// Temporary placeholder used for pages not yet built (Phase 2+).
// Displays a construction card so navigation works from day one.
import { Construction } from 'lucide-react';
export default function Placeholder({ title, description }) {
return (
<div className="flex items-center justify-center h-full animate-fade-in">
<div className="card p-12 text-center max-w-md">
<div className="w-14 h-14 rounded-xl bg-accent/10 flex items-center justify-center mx-auto mb-5">
<Construction size={24} className="text-accent" />
</div>
<h2 className="font-mono text-base font-semibold text-text-primary mb-2">{title}</h2>
<p className="text-sm text-text-muted leading-relaxed">{description}</p>
<p className="mt-4 text-xs font-mono text-text-muted border border-border rounded px-3 py-1.5 inline-block">
Phase 2 Coming next
</p>
</div>
</div>
);
}
@@ -0,0 +1,2 @@
// TODO: Full content to be added
// This file is part of the zROC project recreation
+2
View File
@@ -0,0 +1,2 @@
// TODO: Full content to be added
// This file is part of the zROC project recreation
+2
View File
@@ -0,0 +1,2 @@
// TODO: Full content to be added
// This file is part of the zROC project recreation
+2
View File
@@ -0,0 +1,2 @@
// TODO: Full content to be added
// This file is part of the zROC project recreation
+2
View File
@@ -0,0 +1,2 @@
// TODO: Full content to be added
// This file is part of the zROC project recreation
+99
View File
@@ -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;
}
}
+66
View File
@@ -0,0 +1,66 @@
// tailwind.config.js
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,jsx,ts,tsx}'],
theme: {
extend: {
colors: {
canvas: '#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: [],
};
+34
View File
@@ -0,0 +1,34 @@
// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
resolve: {
alias: { '@': path.resolve(__dirname, 'src') },
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
sourcemap: false,
rollupOptions: {
output: {
manualChunks: {
vendor: ['react', 'react-dom', 'react-router-dom'],
charts: ['recharts'],
query: ['@tanstack/react-query'],
icons: ['lucide-react'],
},
},
},
},
});