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
+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;
}
}