feat: add DR Capacity Planner, light/dark mode toggle, and PDF export

- Add zroc-planner UI page with VM selector, journal retention slider (1h-30d),
  WAN compression input, and live bandwidth/journal/mirror storage estimates
- Add CSV and PDF export for planning reports
- Add light/dark mode toggle in TopBar with localStorage persistence
- Wire theme via CSS custom properties for full Tailwind opacity support
- Add Planner route and sidebar entry

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Justin
2026-04-12 20:29:38 -04:00
parent 5a617fd550
commit b7b9f6191d
10 changed files with 592 additions and 10 deletions
+1
View File
@@ -11,6 +11,7 @@
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.40.0", "@tanstack/react-query": "^5.40.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"jspdf": "^4.2.1",
"lucide-react": "^0.395.0", "lucide-react": "^0.395.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
+5
View File
@@ -2,6 +2,7 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AuthProvider } from '@/auth/AuthContext'; import { AuthProvider } from '@/auth/AuthContext';
import { ThemeProvider } from '@/auth/ThemeContext';
import { ProtectedRoute, AdminRoute } from '@/auth/ProtectedRoute'; import { ProtectedRoute, AdminRoute } from '@/auth/ProtectedRoute';
import AppShell from '@/components/layout/AppShell'; import AppShell from '@/components/layout/AppShell';
import Overview from '@/pages/Overview'; import Overview from '@/pages/Overview';
@@ -12,6 +13,7 @@ import Storage from '@/pages/Storage';
import UserManagement from '@/pages/Settings/UserManagement'; import UserManagement from '@/pages/Settings/UserManagement';
import VMDetail from '@/pages/VMDetail'; import VMDetail from '@/pages/VMDetail';
import Placeholder from '@/pages/Placeholder'; import Placeholder from '@/pages/Placeholder';
import Planner from '@/pages/Planner';
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
@@ -26,6 +28,7 @@ const queryClient = new QueryClient({
export default function App() { export default function App() {
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<ThemeProvider>
<AuthProvider> <AuthProvider>
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
@@ -36,6 +39,7 @@ export default function App() {
<Route path="vras" element={<VRADashboard />} /> <Route path="vras" element={<VRADashboard />} />
<Route path="encryption" element={<EncryptionPage />} /> <Route path="encryption" element={<EncryptionPage />} />
<Route path="storage" element={<Storage />} /> <Route path="storage" element={<Storage />} />
<Route path="planner" element={<Planner />} />
<Route path="settings"> <Route path="settings">
<Route index element={<Navigate to="users" replace />} /> <Route index element={<Navigate to="users" replace />} />
<Route path="users" element={ <Route path="users" element={
@@ -47,6 +51,7 @@ export default function App() {
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
</AuthProvider> </AuthProvider>
</ThemeProvider>
</QueryClientProvider> </QueryClientProvider>
); );
} }
+37
View File
@@ -0,0 +1,37 @@
// src/api/planner.js
// Queries the vcenter_vm_disk_* metrics exposed by zroc-planner collector.
import { instantQuery } from './prometheus';
export async function queryPlannerVms() {
const [throughput, iops, latency, provisioned] = await Promise.all([
instantQuery('vcenter_vm_disk_write_throughput_mbps'),
instantQuery('vcenter_vm_disk_write_iops'),
instantQuery('vcenter_vm_disk_write_latency_ms'),
instantQuery('vcenter_vm_disk_provisioned_gb'),
]);
const byMoref = {};
const idx = (vec, field, transform = parseFloat) => {
for (const { metric, value } of vec) {
const id = metric.vm_moref || metric.vm_name;
if (!byMoref[id]) byMoref[id] = {
moref: metric.vm_moref || id,
name: metric.vm_name || id,
cluster: metric.cluster || '',
host: metric.host || '',
datacenter: metric.datacenter || '',
};
byMoref[id][field] = transform(value[1]);
}
};
idx(throughput, 'writeThroughputMbps');
idx(iops, 'writeIops');
idx(latency, 'writeLatencyMs');
idx(provisioned, 'provisionedGb');
return Object.values(byMoref).sort((a, b) =>
(b.writeThroughputMbps ?? 0) - (a.writeThroughputMbps ?? 0)
);
}
+5
View File
@@ -8,6 +8,11 @@ export function AuthProvider({ children }) {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const checkSession = useCallback(async () => { const checkSession = useCallback(async () => {
if (import.meta.env.VITE_MOCK_AUTH === 'true') {
setUser({ name: 'Demo User', email: 'demo@zroc.local', role: 'admin' });
setLoading(false);
return;
}
try { try {
const res = await fetch('/api/auth/status', { credentials: 'include' }); const res = await fetch('/api/auth/status', { credentials: 'include' });
if (res.ok) { if (res.ok) {
+29
View File
@@ -0,0 +1,29 @@
// src/auth/ThemeContext.jsx
import { createContext, useContext, useState, useEffect } from 'react';
const ThemeContext = createContext(null);
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState(() =>
localStorage.getItem('zroc-theme') || 'dark'
);
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('zroc-theme', theme);
}, [theme]);
const toggle = () => setTheme((t) => t === 'dark' ? 'light' : 'dark');
return (
<ThemeContext.Provider value={{ theme, toggle }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
return ctx;
}
+2 -1
View File
@@ -3,7 +3,7 @@ import { NavLink } from 'react-router-dom';
import { import {
LayoutDashboard, GitFork, Server, Cpu, LayoutDashboard, GitFork, Server, Cpu,
ShieldAlert, Database, Settings, ChevronLeft, ShieldAlert, Database, Settings, ChevronLeft,
ChevronRight, Activity, ChevronRight, Activity, Calculator,
} from 'lucide-react'; } from 'lucide-react';
import { useAuth } from '@/auth/AuthContext'; import { useAuth } from '@/auth/AuthContext';
import clsx from 'clsx'; import clsx from 'clsx';
@@ -41,6 +41,7 @@ const NAV_ITEMS = [
{ to: '/vras', label: 'VRAs', icon: Cpu }, { to: '/vras', label: 'VRAs', icon: Cpu },
{ to: '/encryption', label: 'Encryption', icon: ShieldAlert }, { to: '/encryption', label: 'Encryption', icon: ShieldAlert },
{ to: '/storage', label: 'Storage', icon: Database }, { to: '/storage', label: 'Storage', icon: Database },
{ to: '/planner', label: 'Planner', icon: Calculator },
]; ];
const ADMIN_ITEMS = [ const ADMIN_ITEMS = [
+8 -1
View File
@@ -1,8 +1,9 @@
// src/components/layout/TopBar.jsx // src/components/layout/TopBar.jsx
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { Menu, RefreshCw, ChevronDown, LogOut } from 'lucide-react'; import { Menu, RefreshCw, ChevronDown, LogOut, Sun, Moon } from 'lucide-react';
import { useAuth } from '@/auth/AuthContext'; import { useAuth } from '@/auth/AuthContext';
import { useTheme } from '@/auth/ThemeContext';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import clsx from 'clsx'; import clsx from 'clsx';
@@ -13,6 +14,7 @@ const PAGE_TITLES = {
'/vras': 'VRA Infrastructure', '/vras': 'VRA Infrastructure',
'/encryption': 'Encryption Detection', '/encryption': 'Encryption Detection',
'/storage': 'Storage & Datastores', '/storage': 'Storage & Datastores',
'/planner': 'DR Capacity Planner',
'/settings/users': 'User Management', '/settings/users': 'User Management',
'/settings': 'Settings', '/settings': 'Settings',
}; };
@@ -64,6 +66,7 @@ function UserMenu({ user, onLogout }) {
export default function TopBar({ sidebarOpen, onMenuToggle }) { export default function TopBar({ sidebarOpen, onMenuToggle }) {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const { theme, toggle: toggleTheme } = useTheme();
const location = useLocation(); const location = useLocation();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
@@ -91,6 +94,10 @@ export default function TopBar({ sidebarOpen, onMenuToggle }) {
className="p-1.5 rounded text-text-muted hover:text-accent hover:bg-accent/10 transition-all duration-150"> className="p-1.5 rounded text-text-muted hover:text-accent hover:bg-accent/10 transition-all duration-150">
<RefreshCw size={14} className={clsx(refreshing && 'animate-spin text-accent')} /> <RefreshCw size={14} className={clsx(refreshing && 'animate-spin text-accent')} />
</button> </button>
<button onClick={toggleTheme} title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
className="p-1.5 rounded text-text-muted hover:text-accent hover:bg-accent/10 transition-all duration-150">
{theme === 'dark' ? <Sun size={14} /> : <Moon size={14} />}
</button>
{user && <UserMenu user={user} onLogout={logout} />} {user && <UserMenu user={user} onLogout={logout} />}
</div> </div>
</header> </header>
+473
View File
@@ -0,0 +1,473 @@
// src/pages/Planner.jsx — DR capacity planner
import { useState, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Calculator, HardDrive, Wifi, Database, Download, Search, FileText } from 'lucide-react';
import { queryPlannerVms } from '@/api/planner';
import clsx from 'clsx';
import { jsPDF } from 'jspdf';
const REFRESH = 60_000;
// ── Helpers ──────────────────────────────────────────────────────────────────
function fmtGb(gb) {
if (gb == null || isNaN(gb)) return '—';
if (gb >= 1024) return `${(gb / 1024).toFixed(2)} TB`;
return `${gb.toFixed(1)} GB`;
}
function fmtMbps(mbps) {
if (mbps == null || isNaN(mbps)) return '—';
if (mbps >= 1000) return `${(mbps / 1000).toFixed(2)} Gbps`;
return `${mbps.toFixed(1)} Mbps`;
}
const JOURNAL_OPTIONS = [
{ label: '1 hour', seconds: 3600 },
{ label: '4 hours', seconds: 14400 },
{ label: '8 hours', seconds: 28800 },
...Array.from({ length: 30 }, (_, i) => ({
label: i === 0 ? '1 day' : `${i + 1} days`,
seconds: (i + 1) * 86400,
})),
];
// ── Result card ───────────────────────────────────────────────────────────────
function ResultCard({ icon: Icon, label, value, sub, color = 'accent' }) {
return (
<div className="card p-5 flex items-start gap-4">
<div className={clsx('w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0', `bg-${color}/10`)}>
<Icon size={18} className={`text-${color}`} />
</div>
<div>
<p className="section-title">{label}</p>
<p className="font-data text-2xl font-semibold text-text-primary mt-0.5 data-value">{value}</p>
{sub && <p className="text-xs text-text-muted mt-1">{sub}</p>}
</div>
</div>
);
}
// ── VM row ────────────────────────────────────────────────────────────────────
function VmRow({ vm, selected, onToggle }) {
return (
<tr
onClick={onToggle}
className={clsx(
'cursor-pointer transition-colors duration-100',
selected ? 'bg-accent/8' : 'hover:bg-raised',
)}
>
<td className="px-3 py-2.5 w-8">
<input
type="checkbox"
checked={selected}
onChange={onToggle}
onClick={(e) => e.stopPropagation()}
className="accent-accent"
/>
</td>
<td className="px-3 py-2.5 font-mono text-xs text-text-primary">{vm.name}</td>
<td className="px-3 py-2.5 font-mono text-xs text-text-secondary">{vm.cluster || '—'}</td>
<td className="px-3 py-2.5 font-mono text-xs text-text-secondary">{vm.datacenter || '—'}</td>
<td className="px-3 py-2.5 font-mono text-xs text-right data-value">{fmtGb(vm.provisionedGb)}</td>
<td className="px-3 py-2.5 font-mono text-xs text-right data-value">{fmtMbps(vm.writeThroughputMbps)}</td>
<td className="px-3 py-2.5 font-mono text-xs text-right text-text-muted data-value">
{vm.writeIops != null ? vm.writeIops.toFixed(0) : '—'}
</td>
</tr>
);
}
// ── Mock data for preview ─────────────────────────────────────────────────────
const MOCK_VMS = [
{ moref: 'vm-101', name: 'web-prod-01', cluster: 'Cluster-01', datacenter: 'DC-East', provisionedGb: 120, writeThroughputMbps: 45.2, writeIops: 1820, writeLatencyMs: 3.1 },
{ moref: 'vm-102', name: 'db-prod-01', cluster: 'Cluster-01', datacenter: 'DC-East', provisionedGb: 2048, writeThroughputMbps: 312.8, writeIops: 12400, writeLatencyMs: 1.8 },
{ moref: 'vm-103', name: 'db-prod-02', cluster: 'Cluster-01', datacenter: 'DC-East', provisionedGb: 2048, writeThroughputMbps: 287.4, writeIops: 11200, writeLatencyMs: 2.0 },
{ moref: 'vm-104', name: 'app-prod-01', cluster: 'Cluster-02', datacenter: 'DC-East', provisionedGb: 256, writeThroughputMbps: 18.6, writeIops: 640, writeLatencyMs: 4.2 },
{ moref: 'vm-105', name: 'app-prod-02', cluster: 'Cluster-02', datacenter: 'DC-East', provisionedGb: 256, writeThroughputMbps: 21.3, writeIops: 780, writeLatencyMs: 3.9 },
{ moref: 'vm-106', name: 'cache-01', cluster: 'Cluster-02', datacenter: 'DC-East', provisionedGb: 512, writeThroughputMbps: 8.1, writeIops: 310, writeLatencyMs: 5.5 },
{ moref: 'vm-107', name: 'file-srv-01', cluster: 'Cluster-03', datacenter: 'DC-West', provisionedGb: 4096, writeThroughputMbps: 92.0, writeIops: 3200, writeLatencyMs: 6.1 },
{ moref: 'vm-108', name: 'infra-dc-01', cluster: 'Cluster-03', datacenter: 'DC-West', provisionedGb: 80, writeThroughputMbps: 2.4, writeIops: 120, writeLatencyMs: 8.2 },
{ moref: 'vm-109', name: 'backup-srv-01',cluster: 'Cluster-03', datacenter: 'DC-West', provisionedGb: 8192, writeThroughputMbps: 180.0,writeIops: 5600, writeLatencyMs: 12.0 },
{ moref: 'vm-110', name: 'mon-01', cluster: 'Cluster-01', datacenter: 'DC-East', provisionedGb: 100, writeThroughputMbps: 1.2, writeIops: 55, writeLatencyMs: 9.0 },
];
// ── Main page ─────────────────────────────────────────────────────────────────
export default function Planner() {
const [selected, setSelected] = useState(new Set());
const [journalIdx, setJournalIdx] = useState(3); // default: 1 day
const [compression, setCompression] = useState(50); // default: 50%
const [search, setSearch] = useState('');
const isMock = import.meta.env.VITE_MOCK_AUTH === 'true';
const { data: liveVms = [], isLoading } = useQuery({
queryKey: ['planner-vms'],
queryFn: queryPlannerVms,
refetchInterval: REFRESH,
enabled: !isMock,
});
const vms = isMock ? MOCK_VMS : liveVms;
const filtered = useMemo(() =>
vms.filter((vm) =>
!search || vm.name.toLowerCase().includes(search.toLowerCase()) ||
vm.cluster.toLowerCase().includes(search.toLowerCase()) ||
vm.datacenter.toLowerCase().includes(search.toLowerCase())
),
[vms, search]
);
const toggle = (moref) =>
setSelected((prev) => {
const next = new Set(prev);
next.has(moref) ? next.delete(moref) : next.add(moref);
return next;
});
const toggleAll = () => {
if (selected.size === filtered.length) {
setSelected(new Set());
} else {
setSelected(new Set(filtered.map((v) => v.moref)));
}
};
const selectedVms = vms.filter((v) => selected.has(v.moref));
const journalSec = JOURNAL_OPTIONS[journalIdx].seconds;
const ratio = compression / 100;
// ── Calculations ───────────────────────────────────────────────────────────
const totalThroughputMbps = selectedVms.reduce((s, v) => s + (v.writeThroughputMbps ?? 0), 0);
const totalProvisionedGb = selectedVms.reduce((s, v) => s + (v.provisionedGb ?? 0), 0);
const bwRequiredMbps = totalThroughputMbps * (1 - ratio);
const journalStorageGb = (totalThroughputMbps * (1 - ratio)) * (journalSec / 1024); // MB/s → GB over period
const mirrorStorageGb = totalProvisionedGb;
const totalDrStorageGb = journalStorageGb + mirrorStorageGb;
// ── Export ─────────────────────────────────────────────────────────────────
const exportCsv = () => {
const rows = [
['VM Name', 'Cluster', 'Datacenter', 'Provisioned (GB)', 'Write Throughput (Mbps)', 'Write IOPS'],
...selectedVms.map((v) => [
v.name, v.cluster, v.datacenter,
(v.provisionedGb ?? 0).toFixed(1),
(v.writeThroughputMbps ?? 0).toFixed(2),
(v.writeIops ?? 0).toFixed(0),
]),
[],
['--- Summary ---'],
['Journal Retention', JOURNAL_OPTIONS[journalIdx].label],
['Compression', `${compression}%`],
['Bandwidth Required', `${fmtMbps(bwRequiredMbps)}`],
['Journal Storage', `${fmtGb(journalStorageGb)}`],
['Mirror Storage', `${fmtGb(mirrorStorageGb)}`],
['Total DR Storage', `${fmtGb(totalDrStorageGb)}`],
];
const csv = rows.map((r) => r.map((c) => `"${c}"`).join(',')).join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = 'zroc-planner-report.csv'; a.click();
URL.revokeObjectURL(url);
};
const exportPdf = () => {
const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' });
const margin = 15;
const pageW = 210;
const colW = pageW - margin * 2;
let y = margin;
// Title
doc.setFontSize(18);
doc.setFont('helvetica', 'bold');
doc.text('zROC — DR Capacity Planner Report', margin, y);
y += 8;
doc.setFontSize(9);
doc.setFont('helvetica', 'normal');
doc.setTextColor(100);
doc.text(`Generated: ${new Date().toLocaleString()}`, margin, y);
y += 10;
// Summary box
doc.setDrawColor(14, 165, 233);
doc.setFillColor(240, 248, 255);
doc.roundedRect(margin, y, colW, 40, 2, 2, 'FD');
doc.setTextColor(0);
doc.setFontSize(10);
doc.setFont('helvetica', 'bold');
doc.text('Planning Parameters', margin + 4, y + 7);
doc.setFont('helvetica', 'normal');
doc.setFontSize(9);
const params = [
['VMs selected', `${selected.size}`],
['Journal retention', JOURNAL_OPTIONS[journalIdx].label],
['WAN compression', `${compression}%`],
];
params.forEach(([label, val], i) => {
doc.setTextColor(80); doc.text(label, margin + 4, y + 15 + i * 7);
doc.setTextColor(0); doc.text(val, margin + 60, y + 15 + i * 7);
});
y += 48;
// Results
doc.setFont('helvetica', 'bold');
doc.setFontSize(11);
doc.text('Capacity Estimates', margin, y);
y += 6;
const results = [
['Bandwidth Required', fmtMbps(bwRequiredMbps), `Raw ${fmtMbps(totalThroughputMbps)} × ${100 - compression}%`],
['Journal Storage', fmtGb(journalStorageGb), `${JOURNAL_OPTIONS[journalIdx].label} at ${fmtMbps(bwRequiredMbps)}`],
['Mirror Storage', fmtGb(mirrorStorageGb), 'Full copy of selected VM disks'],
['Total DR Storage Footprint', fmtGb(totalDrStorageGb), 'Journal + Mirror combined'],
];
results.forEach(([label, val, note]) => {
doc.setFontSize(9); doc.setFont('helvetica', 'normal'); doc.setTextColor(80);
doc.text(label, margin, y);
doc.setFont('helvetica', 'bold'); doc.setTextColor(0); doc.setFontSize(12);
doc.text(val, margin + 70, y);
doc.setFont('helvetica', 'normal'); doc.setFontSize(8); doc.setTextColor(120);
doc.text(note, margin + 110, y);
y += 9;
});
y += 6;
// VM table header
doc.setFont('helvetica', 'bold');
doc.setFontSize(11);
doc.setTextColor(0);
doc.text('Selected VMs', margin, y);
y += 5;
doc.setFillColor(230, 240, 255);
doc.rect(margin, y, colW, 6, 'F');
doc.setFontSize(8);
['VM Name', 'Cluster', 'Datacenter', 'Disk (GB)', 'Write BW', 'IOPS'].forEach((h, i) => {
doc.text(h, margin + [0, 50, 85, 120, 143, 163][i], y + 4);
});
y += 7;
// VM rows
doc.setFont('helvetica', 'normal');
selectedVms.forEach((vm, idx) => {
if (y > 270) { doc.addPage(); y = margin; }
if (idx % 2 === 0) { doc.setFillColor(248, 250, 252); doc.rect(margin, y - 1, colW, 6, 'F'); }
doc.setTextColor(0);
doc.setFontSize(8);
[
vm.name.slice(0, 24),
(vm.cluster || '—').slice(0, 16),
(vm.datacenter || '—').slice(0, 14),
(vm.provisionedGb ?? 0).toFixed(1),
fmtMbps(vm.writeThroughputMbps),
(vm.writeIops ?? 0).toFixed(0),
].forEach((val, i) => doc.text(val, margin + [0, 50, 85, 120, 143, 163][i], y + 4));
y += 6;
});
doc.save('zroc-planner-report.pdf');
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Calculator size={20} className="text-accent" />
<h1 className="text-lg font-semibold text-text-primary">DR Capacity Planner</h1>
</div>
<div className="flex items-center gap-2">
<button
onClick={exportCsv}
disabled={selected.size === 0}
className={clsx(
'flex items-center gap-2 px-3 py-1.5 rounded-md text-xs font-medium border transition-colors duration-150',
selected.size > 0
? 'border-border text-text-secondary hover:bg-raised hover:text-text-primary'
: 'text-text-muted border-border cursor-not-allowed opacity-50',
)}
>
<Download size={13} />
CSV
</button>
<button
onClick={exportPdf}
disabled={selected.size === 0}
className={clsx(
'flex items-center gap-2 px-3 py-1.5 rounded-md text-xs font-medium border transition-colors duration-150',
selected.size > 0
? 'bg-accent text-canvas border-accent hover:bg-accent/80'
: 'text-text-muted border-border cursor-not-allowed opacity-50',
)}
>
<FileText size={13} />
Export PDF
</button>
</div>
</div>
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
{/* Left — VM selector */}
<div className="xl:col-span-2 card overflow-hidden">
<div className="px-4 py-3 border-b border-border flex items-center justify-between">
<p className="font-mono text-xs text-text-secondary uppercase tracking-wider">
Select VMs to model
</p>
<span className="text-xs text-text-muted">
{selected.size} / {vms.length} selected
</span>
</div>
{/* Search */}
<div className="px-4 py-2 border-b border-border">
<div className="relative">
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-text-muted" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Filter VMs…"
className="w-full bg-raised border border-border rounded-md pl-7 pr-3 py-1.5 text-xs font-mono text-text-primary placeholder:text-text-muted focus:outline-none focus:border-accent"
/>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border">
<th className="px-3 py-2 w-8">
<input
type="checkbox"
checked={filtered.length > 0 && selected.size === filtered.length}
onChange={toggleAll}
className="accent-accent"
/>
</th>
<th className="px-3 py-2 text-left section-title">VM</th>
<th className="px-3 py-2 text-left section-title">Cluster</th>
<th className="px-3 py-2 text-left section-title">Datacenter</th>
<th className="px-3 py-2 text-right section-title">Disk Size</th>
<th className="px-3 py-2 text-right section-title">Write BW</th>
<th className="px-3 py-2 text-right section-title">Write IOPS</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{isLoading && !isMock ? (
<tr><td colSpan={7} className="px-4 py-8 text-center text-text-muted">Loading VMs</td></tr>
) : filtered.length === 0 ? (
<tr><td colSpan={7} className="px-4 py-8 text-center text-text-muted">No VMs found</td></tr>
) : (
filtered.map((vm) => (
<VmRow
key={vm.moref}
vm={vm}
selected={selected.has(vm.moref)}
onToggle={() => toggle(vm.moref)}
/>
))
)}
</tbody>
</table>
</div>
</div>
{/* Right — inputs + results */}
<div className="space-y-4">
{/* Inputs */}
<div className="card p-4 space-y-5">
<p className="font-mono text-xs text-text-secondary uppercase tracking-wider border-b border-border pb-2">
Planning Inputs
</p>
{/* Journal retention */}
<div>
<div className="flex justify-between mb-2">
<label className="section-title">Journal Retention</label>
<span className="font-mono text-xs text-accent font-semibold">
{JOURNAL_OPTIONS[journalIdx].label}
</span>
</div>
<input
type="range"
min={0}
max={JOURNAL_OPTIONS.length - 1}
value={journalIdx}
onChange={(e) => setJournalIdx(Number(e.target.value))}
className="w-full accent-accent"
/>
<div className="flex justify-between text-[9px] text-text-muted font-mono mt-1">
<span>1h</span><span>8h</span><span>7d</span><span>15d</span><span>30d</span>
</div>
</div>
{/* Compression */}
<div>
<div className="flex justify-between mb-2">
<label className="section-title">WAN Compression</label>
<span className="font-mono text-xs text-accent font-semibold">{compression}%</span>
</div>
<input
type="range"
min={0}
max={80}
step={5}
value={compression}
onChange={(e) => setCompression(Number(e.target.value))}
className="w-full accent-accent"
/>
<div className="flex justify-between text-[9px] text-text-muted font-mono mt-1">
<span>0%</span><span>40%</span><span>80%</span>
</div>
</div>
</div>
{/* Results */}
<div className="space-y-3">
{selected.size === 0 && (
<p className="text-xs text-text-muted text-center py-2">
Select VMs to see estimates
</p>
)}
<ResultCard
icon={Wifi}
label="Bandwidth Required"
value={fmtMbps(bwRequiredMbps)}
sub={`Raw: ${fmtMbps(totalThroughputMbps)}${compression}% compressed`}
color="accent"
/>
<ResultCard
icon={HardDrive}
label="Journal Storage"
value={fmtGb(journalStorageGb)}
sub={`${JOURNAL_OPTIONS[journalIdx].label} at ${fmtMbps(bwRequiredMbps)} after compression`}
color="warn"
/>
<ResultCard
icon={Database}
label="Mirror Storage"
value={fmtGb(mirrorStorageGb)}
sub="Full copy of selected VM disks"
color="ok"
/>
<div className="card p-4 border-accent/20 bg-accent/5">
<p className="section-title mb-1">Total DR Storage Footprint</p>
<p className="font-data text-3xl font-semibold text-accent data-value">
{fmtGb(totalDrStorageGb)}
</p>
<p className="text-xs text-text-muted mt-1">
Journal + Mirror across {selected.size} VM{selected.size !== 1 ? 's' : ''}
</p>
</div>
</div>
</div>
</div>
</div>
);
}
+24
View File
@@ -3,6 +3,30 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
/* ── Theme tokens (space-separated RGB for Tailwind opacity support) ── */
:root,
[data-theme="dark"] {
--color-canvas: 8 13 26;
--color-surface: 13 21 38;
--color-raised: 19 31 53;
--color-border: 30 45 71;
--color-border-bright: 42 64 102;
--color-text-primary: 226 232 240;
--color-text-secondary: 124 147 181;
--color-text-muted: 74 96 128;
}
[data-theme="light"] {
--color-canvas: 240 244 248;
--color-surface: 255 255 255;
--color-raised: 248 250 252;
--color-border: 226 232 240;
--color-border-bright: 203 213 225;
--color-text-primary: 15 23 42;
--color-text-secondary: 71 85 105;
--color-text-muted: 148 163 184;
}
@layer base { @layer base {
html { @apply scroll-smooth; } html { @apply scroll-smooth; }
+8 -8
View File
@@ -5,11 +5,11 @@ export default {
theme: { theme: {
extend: { extend: {
colors: { colors: {
canvas: '#080d1a', canvas: 'rgb(var(--color-canvas) / <alpha-value>)',
surface: '#0d1526', surface: 'rgb(var(--color-surface) / <alpha-value>)',
raised: '#131f35', raised: 'rgb(var(--color-raised) / <alpha-value>)',
border: '#1e2d47', border: 'rgb(var(--color-border) / <alpha-value>)',
'border-bright': '#2a4066', 'border-bright': 'rgb(var(--color-border-bright) / <alpha-value>)',
accent: { accent: {
DEFAULT: '#0ea5e9', DEFAULT: '#0ea5e9',
dim: '#0284c7', dim: '#0284c7',
@@ -20,9 +20,9 @@ export default {
warn: '#f59e0b', warn: '#f59e0b',
crit: '#ef4444', crit: '#ef4444',
info: '#818cf8', info: '#818cf8',
'text-primary': '#e2e8f0', 'text-primary': 'rgb(var(--color-text-primary) / <alpha-value>)',
'text-secondary': '#7c93b5', 'text-secondary': 'rgb(var(--color-text-secondary) / <alpha-value>)',
'text-muted': '#4a6080', 'text-muted': 'rgb(var(--color-text-muted) / <alpha-value>)',
}, },
fontFamily: { fontFamily: {
mono: ['"IBM Plex Mono"', 'monospace'], mono: ['"IBM Plex Mono"', 'monospace'],