feat: complete zROC project recreation — all 61 files populated

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Justin
2026-04-12 17:12:19 -04:00
parent ec794996bb
commit 5a617fd550
17 changed files with 2265 additions and 34 deletions
@@ -1,2 +1,152 @@
// TODO: Full content to be added
// This file is part of the zROC project recreation
// src/components/charts/TimeSeriesChart.jsx
import { useState, useCallback } from 'react';
import {
ComposedChart, Area, Line,
XAxis, YAxis, CartesianGrid, Tooltip, Legend,
ResponsiveContainer, ReferenceLine,
} from 'recharts';
import { useRangeQuery } from '@/hooks/useRangeQuery';
import { Loader2 } from 'lucide-react';
import clsx from 'clsx';
const WINDOWS = ['1h', '6h', '24h', '7d', '30d'];
function CustomTooltip({ active, payload, label, formatter, timeFormat }) {
if (!active || !payload?.length) return null;
const ts = typeof label === 'number' ? new Date(label).toLocaleString(undefined, timeFormat) : label;
return (
<div className="bg-raised border border-border-bright rounded-lg px-3 py-2 shadow-panel text-xs">
<p className="text-text-muted font-mono mb-2">{ts}</p>
{payload.map((p) => (
<div key={p.dataKey} className="flex items-center gap-2 mb-0.5">
<span className="w-2 h-2 rounded-full flex-shrink-0" style={{ background: p.color }} />
<span className="text-text-secondary">{p.name}:</span>
<span className="font-mono font-semibold text-text-primary">
{formatter ? formatter(p.value, p.dataKey) : p.value}
</span>
</div>
))}
</div>
);
}
function WindowSelector({ value, onChange }) {
return (
<div className="flex items-center gap-0.5 bg-canvas rounded-md p-0.5 border border-border">
{WINDOWS.map((w) => (
<button key={w} onClick={() => onChange(w)}
className={clsx('px-2.5 py-1 rounded text-xs font-mono transition-all duration-150',
value === w ? 'bg-accent/20 text-accent border border-accent/30' : 'text-text-muted hover:text-text-primary hover:bg-raised')}>
{w}
</button>
))}
</div>
);
}
const SERIES_COLORS = {
ok: '#10b981', warn: '#f59e0b', crit: '#ef4444', accent: '#0ea5e9',
info: '#818cf8', 0: '#0ea5e9', 1: '#10b981', 2: '#f59e0b', 3: '#818cf8', 4: '#ef4444',
};
export default function TimeSeriesChart({
promql, title, yFormatter, yLabel, refLines = [],
showWindow = true, defaultWindow = '6h', height = 200, transform, series: seriesDef,
}) {
const [window, setWindow] = useState(defaultWindow);
const seriesArr = seriesDef ? seriesDef
: Array.isArray(promql) ? promql
: [{ promql, name: title || 'value', color: 'accent' }];
const primaryPromql = typeof promql === 'string' ? promql : seriesArr[0]?.promql;
const { data: rawData, isLoading, error } = useRangeQuery(primaryPromql, {
window, enabled: !!primaryPromql,
});
const chartData = useCallback(() => {
if (!rawData?.length) return [];
if (transform) return transform(rawData, window);
const merged = {};
rawData.forEach((series, si) => {
const key = series.metric.VpgName || series.metric.VmName || series.metric.VraName || seriesArr[si]?.name || `series${si}`;
series.values.forEach(([ts, v]) => {
const ms = ts * 1000;
if (!merged[ms]) merged[ms] = { ts: ms };
merged[ms][key] = parseFloat(v);
});
});
return Object.values(merged).sort((a, b) => a.ts - b.ts);
}, [rawData, transform, window, seriesArr]);
const data = chartData();
const seriesKeys = data.length > 0 ? Object.keys(data[0]).filter((k) => k !== 'ts') : [];
const makeTimeTick = (w) => (ts) => {
const d = new Date(ts);
if (w === '30d' || w === '7d') return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
};
const makeTooltipTimeFormat = (w) => {
if (w === '30d' || w === '7d') return { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' };
return { hour: '2-digit', minute: '2-digit', second: '2-digit' };
};
return (
<div className="card p-4 flex flex-col gap-3">
<div className="flex items-center justify-between flex-wrap gap-2">
{title && <p className="section-title">{title}</p>}
{showWindow && <WindowSelector value={window} onChange={setWindow} />}
</div>
<div style={{ height }} className="relative">
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center">
<Loader2 size={18} className="animate-spin text-text-muted" />
</div>
)}
{error && (
<div className="absolute inset-0 flex items-center justify-center">
<p className="text-xs text-crit font-mono">Query failed</p>
</div>
)}
{!isLoading && !error && data.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center">
<p className="text-xs text-text-muted font-mono">No data</p>
</div>
)}
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={data} margin={{ top: 4, right: 4, left: yLabel ? 16 : 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(30,45,71,0.8)" vertical={false} />
<XAxis dataKey="ts" type="number" domain={['dataMin', 'dataMax']}
tickFormatter={makeTimeTick(window)}
tick={{ fontSize: 10, fill: '#4a6080', fontFamily: 'JetBrains Mono' }}
axisLine={{ stroke: '#1e2d47' }} tickLine={false} scale="time" />
<YAxis tickFormatter={(v) => yFormatter ? yFormatter(v) : v}
tick={{ fontSize: 10, fill: '#4a6080', fontFamily: 'JetBrains Mono' }}
axisLine={false} tickLine={false} width={yLabel ? 60 : 40} />
<Tooltip content={<CustomTooltip formatter={yFormatter} timeFormat={makeTooltipTimeFormat(window)} />}
cursor={{ stroke: '#2a4066', strokeWidth: 1, strokeDasharray: '4 2' }} />
{refLines.map((rl) => (
<ReferenceLine key={rl.label} y={rl.value}
stroke={SERIES_COLORS[rl.color] || rl.color || '#f59e0b'}
strokeDasharray="6 3" strokeWidth={1.5} />
))}
{(seriesArr.length > 1 ? seriesArr : seriesKeys.map((k, i) => ({
name: k, color: i, type: 'area',
}))).map((s, i) => {
const key = s.name || s.promql || seriesKeys[i] || `s${i}`;
const color = SERIES_COLORS[s.color] || SERIES_COLORS[i] || '#0ea5e9';
return (
<Area key={key} type="monotone" dataKey={key} name={s.name || key}
stroke={color} strokeWidth={2} fill={color} fillOpacity={0.08}
dot={false} activeDot={{ r: 3 }} connectNulls />
);
})}
</ComposedChart>
</ResponsiveContainer>
</div>
</div>
);
}
+130 -2
View File
@@ -1,2 +1,130 @@
// TODO: Full content to be added
// This file is part of the zROC project recreation
// src/pages/Encryption.jsx
import { useQuery } from '@tanstack/react-query';
import { ShieldAlert, ShieldCheck, TrendingUp, AlertTriangle, Loader2 } from 'lucide-react';
import { queryEncryptionDetail } from '@/api/prometheusExtended';
import TimeSeriesChart from '@/components/charts/TimeSeriesChart';
import clsx from 'clsx';
const REFRESH = 30_000;
function EncryptionBar({ pct }) {
const enc = Math.min(pct ?? 0, 100);
const color = enc > 80 ? 'bg-crit' : enc > 50 ? 'bg-warn' : 'bg-ok';
const textC = enc > 80 ? 'text-crit' : enc > 50 ? 'text-warn' : 'text-ok';
return (
<div className="flex items-center gap-2 w-full">
<div className="flex-1 h-2 bg-border rounded-full overflow-hidden flex">
<div className={clsx('h-full transition-all duration-500', color)} style={{ width: `${enc}%` }} />
</div>
<span className={clsx('font-mono text-xs data-value w-12 text-right flex-shrink-0', textC)}>
{enc.toFixed(1)}%
</span>
</div>
);
}
function TrendBadge({ level }) {
const l = level ?? 0;
if (l === 0) return <span className="badge badge-ok">Stable</span>;
if (l === 1) return <span className="badge badge-warn">Rising</span>;
return <span className="badge badge-crit">Spike</span>;
}
export default function EncryptionPage() {
const { data: vms = [], isLoading } = useQuery({
queryKey: ['encryption-detail'],
queryFn: queryEncryptionDetail,
refetchInterval: REFRESH,
});
const anomalies = vms.filter((v) => (v.pctEncrypted ?? 0) > 50);
const highAlert = vms.filter((v) => (v.pctEncrypted ?? 0) > 80);
const avgPct = vms.length ? vms.reduce((s, v) => s + (v.pctEncrypted ?? 0), 0) / vms.length : 0;
return (
<div className="space-y-6 animate-fade-in">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[
{ label: 'VMs Monitored', value: vms.length, icon: ShieldAlert, color: 'accent' },
{ label: 'Anomalies (>50%)', value: anomalies.length, icon: AlertTriangle, color: anomalies.length ? 'warn' : 'ok' },
{ label: 'High Alert (>80%)', value: highAlert.length, icon: TrendingUp, color: highAlert.length ? 'crit' : 'ok' },
{ label: 'Avg Encryption', value: `${avgPct.toFixed(1)}%`, icon: ShieldCheck, color: avgPct > 60 ? 'warn' : 'ok' },
].map((s) => (
<div key={s.label} className="card p-4 flex items-start gap-3">
<div className={clsx('w-9 h-9 rounded-lg flex items-center justify-center',
s.color === 'ok' && 'bg-ok/10', s.color === 'warn' && 'bg-warn/10',
s.color === 'crit' && 'bg-crit/10', s.color === 'accent' && 'bg-accent/10')}>
<s.icon size={16} className={clsx(
s.color === 'ok' && 'text-ok', s.color === 'warn' && 'text-warn',
s.color === 'crit' && 'text-crit', s.color === 'accent' && 'text-accent')} />
</div>
<div>
<p className="section-title">{s.label}</p>
<p className="font-data text-xl font-semibold text-text-primary data-value mt-0.5">{s.value}</p>
</div>
</div>
))}
</div>
{anomalies.length > 0 && (
<TimeSeriesChart
title="Encryption % Over Time — Top Anomalies"
promql={`vm_PercentEncrypted{VmName="${anomalies[0]?.name?.replace(/"/g, '\\"')}"}`}
yFormatter={(v) => `${v.toFixed(1)}%`}
refLines={[{ value: 80, label: 'High alert', color: 'crit' }, { value: 50, label: 'Warning', color: 'warn' }]}
transform={(result) => result[0]?.values.map(([ts, v]) => ({ ts: ts * 1000, 'Encrypted %': parseFloat(v) })) ?? []}
height={180}
/>
)}
<section>
<p className="section-title mb-3">VM Encryption Status</p>
<div className="card overflow-hidden">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border">
<th className="px-4 py-2.5 text-left section-title">VM</th>
<th className="px-4 py-2.5 text-left section-title">VPG</th>
<th className="px-4 py-2.5 text-left section-title">Encryption %</th>
<th className="px-4 py-2.5 text-left section-title hidden md:table-cell">Trend</th>
<th className="px-4 py-2.5 text-right section-title hidden lg:table-cell">IO Ops</th>
<th className="px-4 py-2.5 text-right section-title hidden lg:table-cell">Write</th>
</tr>
</thead>
<tbody>
{isLoading && (
<tr><td colSpan={6} className="py-10 text-center">
<Loader2 size={16} className="animate-spin text-text-muted mx-auto" />
</td></tr>
)}
{!isLoading && vms.map((vm) => (
<tr key={vm.id} className="border-b border-border/40 last:border-0 hover:bg-raised transition-colors">
<td className="px-4 py-2.5 font-medium text-text-primary">{vm.name}</td>
<td className="px-4 py-2.5 text-text-muted">{vm.vpgName}</td>
<td className="px-4 py-2.5 min-w-[160px]"><EncryptionBar pct={vm.pctEncrypted} /></td>
<td className="px-4 py-2.5 hidden md:table-cell"><TrendBadge level={vm.trendLevel} /></td>
<td className="px-4 py-2.5 text-right hidden lg:table-cell">
<span className="font-mono data-value text-text-secondary">
{vm.ioOps != null ? Math.round(vm.ioOps).toLocaleString() : '—'}
</span>
</td>
<td className="px-4 py-2.5 text-right hidden lg:table-cell">
<span className="font-mono data-value text-text-secondary">
{vm.writeMb != null ? `${vm.writeMb.toFixed(2)} MB` : '—'}
</span>
</td>
</tr>
))}
{!isLoading && vms.length === 0 && (
<tr><td colSpan={6} className="py-12 text-center">
<ShieldCheck size={24} className="text-ok mx-auto mb-2" />
<p className="text-text-muted">No encryption stats available</p>
</td></tr>
)}
</tbody>
</table>
</div>
</section>
</div>
);
}
+187 -2
View File
@@ -1,2 +1,187 @@
// TODO: Full content to be added
// This file is part of the zROC project recreation
// src/pages/Overview.jsx
import { useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { CheckCircle2, AlertTriangle, XCircle, Activity } from 'lucide-react';
import { queryOverviewSummary, queryAllVpgs, queryTopRpoViolators, queryExporterHealth } from '@/api/prometheus';
import { rpoStatus, formatRpo, colorToText } from '@/constants/statusMaps';
import clsx from 'clsx';
const REFRESH = 30_000;
function StatCard({ label, value, sub, color = 'accent', icon: Icon }) {
return (
<div className="card p-4 flex items-start gap-4">
<div className={clsx('w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0',
`bg-${color}/10`)}>
<Icon size={18} className={`text-${color}`} />
</div>
<div className="min-w-0">
<p className="section-title">{label}</p>
<p className="font-data text-2xl font-semibold text-text-primary mt-0.5 data-value">{value ?? '—'}</p>
{sub && <p className="text-xs text-text-muted mt-0.5">{sub}</p>}
</div>
</div>
);
}
function SiteCard({ site }) {
const hasCrit = site.crit > 0;
return (
<div className={clsx('card p-4 border transition-colors duration-300',
hasCrit ? 'border-crit/30' : site.warn > 0 ? 'border-warn/30' : 'border-border')}>
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2">
<span className={clsx('status-dot', hasCrit ? 'status-dot-crit' : site.warn > 0 ? 'status-dot-warn' : 'status-dot-ok')} />
<p className="font-mono text-sm font-semibold text-text-primary">{site.siteName}</p>
</div>
<span className={clsx('badge text-xs', hasCrit ? 'badge-crit' : site.warn > 0 ? 'badge-warn' : 'badge-ok')}>
{hasCrit ? 'Alert' : site.warn > 0 ? 'Warning' : 'Healthy'}
</span>
</div>
<div className="grid grid-cols-3 gap-2 text-center">
<div><p className="font-data text-xl font-semibold text-ok data-value">{site.ok}</p><p className="section-title">OK</p></div>
<div><p className="font-data text-xl font-semibold text-warn data-value">{site.warn}</p><p className="section-title">Warn</p></div>
<div><p className="font-data text-xl font-semibold text-crit data-value">{site.crit}</p><p className="section-title">Crit</p></div>
</div>
</div>
);
}
function VpgTile({ vpg, onClick }) {
const status = rpoStatus(vpg.actualRpoSec, vpg.configuredRpoSec);
return (
<button onClick={onClick}
title={`${vpg.name}\nRPO: ${formatRpo(vpg.actualRpoSec)}`}
className={clsx('relative p-2 rounded-md border text-left transition-all duration-200 hover:scale-105 hover:z-10',
status === 'ok' && 'bg-ok/8 border-ok/20',
status === 'warn' && 'bg-warn/8 border-warn/20',
status === 'crit' && 'bg-crit/8 border-crit/20',
status === 'muted' && 'bg-raised border-border')}>
<p className={clsx('text-[10px] font-mono font-semibold truncate leading-tight',
status === 'ok' && 'text-ok', status === 'warn' && 'text-warn',
status === 'crit' && 'text-crit', status === 'muted' && 'text-text-muted')}>
{vpg.name}
</p>
<p className="text-[9px] text-text-muted font-mono data-value mt-0.5">{formatRpo(vpg.actualRpoSec)}</p>
</button>
);
}
export default function Overview() {
const navigate = useNavigate();
const { data: sites = [] } = useQuery({
queryKey: ['overview-summary'], queryFn: queryOverviewSummary, refetchInterval: REFRESH,
});
const { data: vpgs = [], isLoading: vpgsLoading } = useQuery({
queryKey: ['all-vpgs'], queryFn: queryAllVpgs, refetchInterval: REFRESH,
});
const { data: violators = [] } = useQuery({
queryKey: ['top-violators'], queryFn: () => queryTopRpoViolators(10), refetchInterval: REFRESH,
});
const { data: exporterHealth = [] } = useQuery({
queryKey: ['exporter-health'], queryFn: queryExporterHealth, refetchInterval: REFRESH,
});
const totalOk = sites.reduce((s, x) => s + x.ok, 0);
const totalWarn = sites.reduce((s, x) => s + x.warn, 0);
const totalCrit = sites.reduce((s, x) => s + x.crit, 0);
const totalMbps = sites.reduce((s, x) => s + (x.throughputMb ?? 0), 0);
return (
<div className="space-y-6 animate-fade-in">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard label="Meeting SLA" value={totalOk} sub="VPGs within RPO target" color="ok" icon={CheckCircle2} />
<StatCard label="Warnings" value={totalWarn} sub="Approaching RPO limit" color="warn" icon={AlertTriangle} />
<StatCard label="Violations" value={totalCrit} sub="Exceeding RPO target" color="crit" icon={XCircle} />
<StatCard label="Replication" value={`${totalMbps.toFixed(1)} MB/s`} sub="Total throughput" color="accent" icon={Activity} />
</div>
{sites.length > 0 && (
<section>
<p className="section-title mb-3">Sites</p>
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
{sites.map((s) => <SiteCard key={s.siteName} site={s} />)}
</div>
</section>
)}
<section>
<p className="section-title mb-3">VPG RPO Heat Grid</p>
{vpgsLoading ? (
<div className="card p-8 text-center text-text-muted text-xs font-mono">Loading VPGs</div>
) : (
<div className="card p-4">
<div className="grid gap-2" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(110px, 1fr))' }}>
{vpgs.sort((a, b) => {
const sev = (v) => { const s = rpoStatus(v.actualRpoSec, v.configuredRpoSec); return s === 'crit' ? 0 : s === 'warn' ? 1 : 2; };
return sev(a) - sev(b);
}).map((vpg) => (
<VpgTile key={vpg.id} vpg={vpg} onClick={() => navigate(`/vpgs?name=${encodeURIComponent(vpg.name)}`)} />
))}
</div>
</div>
)}
</section>
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
<div className="xl:col-span-2">
<p className="section-title mb-3">Top RPO Violators</p>
<div className="card overflow-hidden">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border">
<th className="px-3 py-2 text-left section-title">VPG</th>
<th className="px-3 py-2 text-left section-title">Site</th>
<th className="px-3 py-2 text-right section-title">Actual RPO</th>
<th className="px-3 py-2 text-right section-title">Target</th>
<th className="px-3 py-2 text-right section-title">Ratio</th>
</tr>
</thead>
<tbody>
{violators.map((v) => {
const ratio = v.configuredRpoSec ? v.actualRpoSec / v.configuredRpoSec : 0;
const status = rpoStatus(v.actualRpoSec, v.configuredRpoSec);
return (
<tr key={v.id} className="table-row-hover border-b border-border/40 last:border-0"
onClick={() => navigate(`/vpgs?name=${encodeURIComponent(v.name)}`)}>
<td className="px-3 py-2 font-mono font-semibold text-text-primary">{v.name}</td>
<td className="px-3 py-2 text-text-muted">{v.siteName}</td>
<td className={clsx('px-3 py-2 text-right font-data data-value', colorToText[status])}>{formatRpo(v.actualRpoSec)}</td>
<td className="px-3 py-2 text-right font-data data-value text-text-muted">{formatRpo(v.configuredRpoSec)}</td>
<td className="px-3 py-2 text-right"><span className={clsx('badge', `badge-${status}`)}>{ratio.toFixed(1)}x</span></td>
</tr>
);
})}
{violators.length === 0 && (
<tr><td colSpan={5} className="px-3 py-8 text-center text-text-muted">
<CheckCircle2 size={20} className="text-ok mx-auto mb-1" />All VPGs within RPO targets
</td></tr>
)}
</tbody>
</table>
</div>
</div>
<div>
<p className="section-title mb-3">Collector Health</p>
<div className="card p-4 space-y-3">
{exporterHealth.length === 0 && <p className="text-xs text-text-muted italic">No exporter data</p>}
{exporterHealth.map((t) => (
<div key={`${t.instance}-${t.thread}`} className="flex items-center justify-between py-1.5 border-b border-border/40 last:border-0">
<div>
<p className="text-xs font-mono font-medium text-text-primary">{t.thread}</p>
<p className="text-[10px] text-text-muted">{t.instance}</p>
</div>
<span className={clsx('badge', t.alive ? 'badge-ok' : 'badge-crit')}>
<span className={clsx('status-dot', t.alive ? 'status-dot-ok' : 'status-dot-crit')} />
{t.alive ? 'Running' : 'Down'}
</span>
</div>
))}
</div>
</div>
</div>
</div>
);
}
+291 -2
View File
@@ -1,2 +1,291 @@
// TODO: Full content to be added
// This file is part of the zROC project recreation
// src/pages/Settings/UserManagement.jsx
import { useState, useEffect, useCallback, useRef } from 'react';
import { Users, UserPlus, Search, Shield, ShieldOff, ShieldCheck, Pencil, Trash2, QrCode, X, Check, Loader2, AlertTriangle, Copy, ExternalLink } from 'lucide-react';
import { usersApi } from '@/api/users';
import clsx from 'clsx';
function Avatar({ name, size = 'md' }) {
const initials = name ? name.split(' ').map((w) => w[0]).slice(0, 2).join('').toUpperCase() : '?';
const colors = ['bg-accent/20 text-accent', 'bg-ok/20 text-ok', 'bg-info/20 text-info', 'bg-warn/20 text-warn'];
const color = colors[(name?.charCodeAt(0) ?? 0) % colors.length];
const sz = size === 'lg' ? 'w-10 h-10 text-sm' : 'w-8 h-8 text-xs';
return <div className={clsx('rounded-full flex items-center justify-center font-mono font-semibold flex-shrink-0', sz, color)}>{initials}</div>;
}
function Toast({ message, type = 'ok', onDismiss }) {
useEffect(() => { const t = setTimeout(onDismiss, 3500); return () => clearTimeout(t); }, [onDismiss]);
return (
<div className={clsx('fixed bottom-6 right-6 z-[100] flex items-center gap-3 px-4 py-3 rounded-lg border shadow-panel animate-fade-in',
type === 'ok' && 'bg-surface border-ok/30 text-ok', type === 'error' && 'bg-surface border-crit/30 text-crit')}>
{type === 'ok' && <Check size={14} />}{type === 'error' && <AlertTriangle size={14} />}
<span className="text-sm font-medium">{message}</span>
<button onClick={onDismiss} className="ml-2 text-text-muted hover:text-text-primary"><X size={12} /></button>
</div>
);
}
function DeleteModal({ user, onConfirm, onCancel, loading }) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="drawer-overlay" onClick={onCancel} />
<div className="card-raised p-6 w-full max-w-sm z-10 animate-modal-in">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-full bg-crit/10 flex items-center justify-center"><Trash2 size={18} className="text-crit" /></div>
<div><p className="font-medium text-text-primary">Delete user</p><p className="text-xs text-text-muted">This cannot be undone</p></div>
</div>
<p className="text-sm text-text-secondary mb-6">Delete <span className="text-text-primary font-medium">{user.name}</span> ({user.username})?</p>
<div className="flex justify-end gap-3">
<button className="btn-ghost" onClick={onCancel} disabled={loading}>Cancel</button>
<button className="btn-danger" onClick={onConfirm} disabled={loading}>
{loading ? <Loader2 size={14} className="animate-spin" /> : <Trash2 size={14} />} Delete
</button>
</div>
</div>
</div>
);
}
function TwoFactorModal({ user, onClose }) {
const [state, setState] = useState('idle');
const [result, setResult] = useState(null);
const generate = useCallback(async () => {
setState('loading');
try { const data = await usersApi.setup2fa(user.id); setResult(data); setState('done'); }
catch { setState('error'); }
}, [user.id]);
useEffect(() => { generate(); }, [generate]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="drawer-overlay" onClick={onClose} />
<div className="card-raised p-6 w-full max-w-md z-10 animate-modal-in">
<div className="flex items-start justify-between mb-5">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-accent/10 flex items-center justify-center"><QrCode size={18} className="text-accent" /></div>
<div><p className="font-medium text-text-primary">Set up 2FA</p><p className="text-xs text-text-muted">{user.name}</p></div>
</div>
<button onClick={onClose} className="text-text-muted hover:text-text-primary"><X size={16} /></button>
</div>
{state === 'loading' && <div className="flex flex-col items-center py-10 gap-3"><Loader2 size={28} className="animate-spin text-accent" /><p className="text-sm text-text-muted">Generating setup link</p></div>}
{state === 'error' && <div className="flex flex-col items-center py-8 gap-3 text-crit"><AlertTriangle size={28} /><p className="text-sm">Failed to generate setup link.</p><button className="btn-ghost mt-2" onClick={generate}>Retry</button></div>}
{state === 'done' && result && (
<>
<div className="bg-canvas rounded-lg p-1 flex justify-center mb-4 border border-border">
<img src={result.qrDataUrl} alt="2FA setup QR code" className="w-56 h-56 rounded" />
</div>
<p className="text-sm text-text-secondary mb-5">Share this QR code with {user.name} to enroll their authenticator app.</p>
<button className="btn-ghost w-full" onClick={onClose}>Done</button>
</>
)}
</div>
</div>
);
}
function UserDrawer({ mode, user, groups, onSave, onClose }) {
const isEdit = mode === 'edit';
const [form, setForm] = useState(isEdit ? {
username: user.username, name: user.name, email: user.email,
isActive: user.isActive, groups: user.groups.map((g) => g.id), password: '',
} : { username: '', name: '', email: '', isActive: true, groups: [], password: '' });
const [errors, setErrors] = useState({});
const [saving, setSaving] = useState(false);
const set = (field) => (e) => setForm((f) => ({ ...f, [field]: e.target.type === 'checkbox' ? e.target.checked : e.target.value }));
const toggleGroup = (id) => setForm((f) => ({ ...f, groups: f.groups.includes(id) ? f.groups.filter((g) => g !== id) : [...f.groups, id] }));
const handleSubmit = async () => {
const e = {};
if (!form.username.trim()) e.username = 'Required';
if (!form.name.trim()) e.name = 'Required';
if (!form.email.trim()) e.email = 'Required';
if (!isEdit && !form.password) e.password = 'Required';
if (form.password && form.password.length < 8) e.password = 'Min 8 chars';
setErrors(e);
if (Object.keys(e).length > 0) return;
setSaving(true);
try {
const payload = { username: form.username, name: form.name, email: form.email, isActive: form.isActive, groups: form.groups };
if (form.password) payload.password = form.password;
await onSave(payload);
} finally { setSaving(false); }
};
return (
<>
<div className="drawer-overlay" onClick={onClose} />
<div className="drawer-panel">
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
<p className="font-medium text-text-primary">{isEdit ? 'Edit user' : 'Add user'}</p>
<button onClick={onClose} className="text-text-muted hover:text-text-primary"><X size={18} /></button>
</div>
<div className="flex-1 overflow-y-auto px-6 py-5 space-y-5">
<section>
<p className="section-title mb-4">Identity</p>
<div className="space-y-4">
<div><label className="field-label">Username</label><input className={clsx('field', errors.username && 'border-crit')} value={form.username} onChange={set('username')} disabled={isEdit} /></div>
<div><label className="field-label">Full name</label><input className={clsx('field', errors.name && 'border-crit')} value={form.name} onChange={set('name')} /></div>
<div><label className="field-label">Email</label><input className={clsx('field', errors.email && 'border-crit')} type="email" value={form.email} onChange={set('email')} /></div>
</div>
</section>
<section>
<p className="section-title mb-3">Groups</p>
<div className="space-y-2">
{groups.map((g) => (
<label key={g.id} className="flex items-center gap-3 cursor-pointer py-2 px-3 rounded-md hover:bg-canvas transition-colors">
<input type="checkbox" className="sr-only" checked={form.groups.includes(g.id)} onChange={() => toggleGroup(g.id)} />
<div className={clsx('w-4 h-4 rounded border flex items-center justify-center',
form.groups.includes(g.id) ? 'bg-accent border-accent' : 'border-border')}>
{form.groups.includes(g.id) && <Check size={10} className="text-white" />}
</div>
<span className="text-sm text-text-primary">{g.name}</span>
</label>
))}
</div>
</section>
<section>
<p className="section-title mb-3">{isEdit ? 'Reset Password' : 'Password'}</p>
<input className={clsx('field', errors.password && 'border-crit')} type="password" value={form.password} onChange={set('password')}
placeholder={isEdit ? 'Leave blank to keep' : 'Min. 8 characters'} />
{errors.password && <p className="text-xs text-crit mt-1">{errors.password}</p>}
</section>
</div>
<div className="flex justify-end gap-3 px-6 py-4 border-t border-border flex-shrink-0">
<button className="btn-ghost" onClick={onClose} disabled={saving}>Cancel</button>
<button className="btn-primary" onClick={handleSubmit} disabled={saving}>
{saving ? <Loader2 size={14} className="animate-spin" /> : isEdit ? <Check size={14} /> : <UserPlus size={14} />}
{isEdit ? 'Save' : 'Create'}
</button>
</div>
</div>
</>
);
}
export default function UserManagement() {
const [users, setUsers] = useState([]);
const [groups, setGroups] = useState([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [searchInput, setSearchInput] = useState('');
const [drawer, setDrawer] = useState(null);
const [deleteTarget, setDeleteTarget] = useState(null);
const [twoFaTarget, setTwoFaTarget] = useState(null);
const [toast, setToast] = useState(null);
const searchTimer = useRef(null);
const showToast = (message, type = 'ok') => setToast({ message, type });
const loadUsers = useCallback(async (q = '') => {
setLoading(true);
try { const result = await usersApi.list({ search: q }); setUsers(result.users); setTotal(result.count); }
catch (err) { showToast(`Failed to load users: ${err.message}`, 'error'); }
finally { setLoading(false); }
}, []);
const loadGroups = useCallback(async () => {
try { const g = await usersApi.listGroups(); setGroups(g); } catch {}
}, []);
useEffect(() => { loadUsers(); loadGroups(); }, []);
const handleSearchChange = (e) => {
const val = e.target.value;
setSearchInput(val);
clearTimeout(searchTimer.current);
searchTimer.current = setTimeout(() => loadUsers(val), 350);
};
const handleSave = async (payload) => {
try {
if (drawer.mode === 'create') {
const newUser = await usersApi.create(payload);
setUsers((u) => [newUser, ...u]); setTotal((t) => t + 1);
showToast(`User ${newUser.username} created`);
} else {
const updated = await usersApi.update(drawer.user.id, payload);
setUsers((u) => u.map((x) => (x.id === updated.id ? updated : x)));
showToast(`User ${updated.username} updated`);
}
setDrawer(null);
} catch (err) { showToast(err.message, 'error'); throw err; }
};
const handleDelete = async () => {
try {
await usersApi.delete(deleteTarget.id);
setUsers((u) => u.filter((x) => x.id !== deleteTarget.id)); setTotal((t) => t - 1);
showToast(`User ${deleteTarget.username} deleted`); setDeleteTarget(null);
} catch (err) { showToast(err.message, 'error'); }
};
return (
<div className="flex flex-col h-full animate-fade-in">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="font-mono text-lg font-semibold text-text-primary flex items-center gap-2">
<Users size={20} className="text-accent" /> User Management
</h1>
<p className="text-xs text-text-muted mt-1">{total} users</p>
</div>
<button className="btn-primary" onClick={() => setDrawer({ mode: 'create' })}><UserPlus size={15} /> Add User</button>
</div>
<div className="relative mb-4">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted pointer-events-none" />
<input className="field pl-9" placeholder="Search…" value={searchInput} onChange={handleSearchChange} />
</div>
<div className="card flex-1 overflow-auto">
<table className="w-full text-sm">
<thead><tr className="border-b border-border">
<th className="px-4 py-3 text-left section-title">User</th>
<th className="px-4 py-3 text-left section-title hidden md:table-cell">Groups</th>
<th className="px-4 py-3 text-left section-title">Status</th>
<th className="px-4 py-3 text-left section-title hidden lg:table-cell">2FA</th>
<th className="px-4 py-3 text-right section-title">Actions</th>
</tr></thead>
<tbody>
{loading && <tr><td colSpan={5} className="px-4 py-16 text-center"><Loader2 size={20} className="animate-spin text-text-muted mx-auto" /></td></tr>}
{!loading && users.length === 0 && <tr><td colSpan={5} className="px-4 py-16 text-center text-text-muted">No users found</td></tr>}
{!loading && users.map((u) => (
<tr key={u.id} className="table-row-hover border-b border-border/50 last:border-0">
<td className="px-4 py-3">
<div className="flex items-center gap-3">
<Avatar name={u.name} />
<div><p className="font-medium text-text-primary">{u.name}</p><p className="text-xs text-text-muted font-mono">{u.username}</p></div>
</div>
</td>
<td className="px-4 py-3 hidden md:table-cell">
<div className="flex flex-wrap gap-1">
{u.groups.length === 0 ? <span className="text-xs text-text-muted"></span> : u.groups.map((g) => (
<span key={g.id} className={clsx('badge', g.name.includes('admin') ? 'badge-info' : 'badge-muted')}>{g.name}</span>
))}
</div>
</td>
<td className="px-4 py-3">
{u.isActive ? <span className="badge badge-ok">Active</span> : <span className="badge badge-muted">Inactive</span>}
</td>
<td className="px-4 py-3 hidden lg:table-cell">
{u.totpEnrolled ? <span className="badge badge-ok"><ShieldCheck size={10} />2FA On</span> : <span className="badge badge-warn"><ShieldOff size={10} />No 2FA</span>}
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-1">
<button title="Edit" onClick={() => setDrawer({ mode: 'edit', user: u })} className="p-1.5 rounded text-text-muted hover:text-accent hover:bg-accent/10 transition-colors"><Pencil size={13} /></button>
<button title="2FA" onClick={() => setTwoFaTarget(u)} className="p-1.5 rounded text-text-muted hover:text-accent hover:bg-accent/10 transition-colors"><Shield size={13} /></button>
<button title="Delete" onClick={() => setDeleteTarget(u)} className="p-1.5 rounded text-text-muted hover:text-crit hover:bg-crit/10 transition-colors"><Trash2 size={13} /></button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{drawer && <UserDrawer mode={drawer.mode} user={drawer.user} groups={groups} onSave={handleSave} onClose={() => setDrawer(null)} />}
{deleteTarget && <DeleteModal user={deleteTarget} onConfirm={handleDelete} onCancel={() => setDeleteTarget(null)} />}
{twoFaTarget && <TwoFactorModal user={twoFaTarget} onClose={() => { setTwoFaTarget(null); loadUsers(); }} />}
{toast && <Toast message={toast.message} type={toast.type} onDismiss={() => setToast(null)} />}
</div>
);
}
+151 -2
View File
@@ -1,2 +1,151 @@
// TODO: Full content to be added
// This file is part of the zROC project recreation
// src/pages/Storage.jsx
import { useQuery } from '@tanstack/react-query';
import { Database, HardDrive, Loader2 } from 'lucide-react';
import { queryDatastores } from '@/api/prometheusExtended';
import { formatBytes } from '@/constants/statusMaps';
import clsx from 'clsx';
const REFRESH = 60_000;
function CapacityBar({ label, usedBytes, totalBytes, color = 'bg-accent' }) {
const pct = totalBytes > 0 ? Math.min(usedBytes / totalBytes, 1) : 0;
const pctN = Math.round(pct * 100);
const barColor = pct >= 0.9 ? 'bg-crit' : pct >= 0.75 ? 'bg-warn' : color;
const textC = pct >= 0.9 ? 'text-crit' : pct >= 0.75 ? 'text-warn' : 'text-text-secondary';
return (
<div className="space-y-1">
<div className="flex justify-between text-[10px]">
<span className="text-text-muted">{label}</span>
<span className={clsx('font-mono data-value', textC)}>
{formatBytes(usedBytes)} / {formatBytes(totalBytes)} ({pctN}%)
</span>
</div>
<div className="h-2 bg-border rounded-full overflow-hidden">
<div className={clsx('h-full rounded-full transition-all duration-500', barColor)} style={{ width: `${pct * 100}%` }} />
</div>
</div>
);
}
function ZertoUsageRow({ label, bytes, color }) {
return bytes > 0 ? (
<div className="flex items-center justify-between text-[10px]">
<div className="flex items-center gap-1.5">
<span className={clsx('w-2 h-2 rounded-sm flex-shrink-0', color)} />
<span className="text-text-muted">{label}</span>
</div>
<span className="font-mono data-value text-text-secondary">{formatBytes(bytes)}</span>
</div>
) : null;
}
function DatastoreCard({ ds }) {
const usePct = ds.capacityBytes > 0 ? ds.usedBytes / ds.capacityBytes : 0;
const alerting = usePct >= 0.9;
const warning = usePct >= 0.75;
const zertoUsed = (ds.journalBytes ?? 0) + (ds.scratchBytes ?? 0) + (ds.recoveryBytes ?? 0) + (ds.applianceBytes ?? 0);
return (
<div className={clsx('card p-4 space-y-4 transition-colors duration-300',
alerting ? 'border-crit/30' : warning ? 'border-warn/20' : '')}>
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<div className={clsx('w-8 h-8 rounded-md flex items-center justify-center',
alerting ? 'bg-crit/10' : warning ? 'bg-warn/10' : 'bg-accent/10')}>
<Database size={14} className={alerting ? 'text-crit' : warning ? 'text-warn' : 'text-accent'} />
</div>
<div>
<p className="text-sm font-mono font-semibold text-text-primary truncate max-w-[180px]">{ds.name}</p>
<p className="text-[10px] text-text-muted">{ds.siteName}</p>
</div>
</div>
<div className="text-right text-[10px] text-text-muted font-mono">
<p>{ds.vraCount ?? 0} VRA{(ds.vraCount ?? 0) !== 1 ? 's' : ''}</p>
<p>{ds.incomingVms ?? 0} in / {ds.outgoingVms ?? 0} out</p>
</div>
</div>
<CapacityBar label="Capacity" usedBytes={ds.usedBytes} totalBytes={ds.capacityBytes} />
{zertoUsed > 0 && (
<div className="space-y-1.5 pt-2 border-t border-border">
<p className="section-title mb-2">Zerto Usage ({formatBytes(zertoUsed)})</p>
<ZertoUsageRow label="Journal" bytes={ds.journalBytes} color="bg-accent" />
<ZertoUsageRow label="Scratch" bytes={ds.scratchBytes} color="bg-info" />
<ZertoUsageRow label="Recovery" bytes={ds.recoveryBytes} color="bg-ok" />
<ZertoUsageRow label="Appliances" bytes={ds.applianceBytes} color="bg-text-muted" />
</div>
)}
<div className="flex justify-between text-[10px] text-text-muted pt-1 border-t border-border">
<span>Free</span>
<span className="font-mono data-value text-text-secondary">{formatBytes(ds.freeBytes)}</span>
</div>
</div>
);
}
export default function Storage() {
const { data: datastores = [], isLoading } = useQuery({
queryKey: ['datastores'], queryFn: queryDatastores, refetchInterval: REFRESH,
});
const totalCapacity = datastores.reduce((s, d) => s + (d.capacityBytes ?? 0), 0);
const totalUsed = datastores.reduce((s, d) => s + (d.usedBytes ?? 0), 0);
const totalJournal = datastores.reduce((s, d) => s + (d.journalBytes ?? 0), 0);
const bySite = {};
for (const d of datastores) {
const s = d.siteName || 'Unknown';
if (!bySite[s]) bySite[s] = [];
bySite[s].push(d);
}
return (
<div className="space-y-6 animate-fade-in">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[
{ label: 'Datastores', value: datastores.length, icon: Database },
{ label: 'Total Capacity', value: formatBytes(totalCapacity), icon: HardDrive },
{ label: 'Used', value: formatBytes(totalUsed), icon: HardDrive },
{ label: 'Journal Usage', value: formatBytes(totalJournal), icon: Database },
].map((s) => (
<div key={s.label} className="card p-4 flex items-start gap-3">
<div className="w-9 h-9 rounded-lg bg-accent/10 flex items-center justify-center">
<s.icon size={16} className="text-accent" />
</div>
<div>
<p className="section-title">{s.label}</p>
<p className="font-mono text-lg font-semibold text-text-primary mt-0.5 data-value">{s.value}</p>
</div>
</div>
))}
</div>
{totalCapacity > 0 && (
<div className="card p-4">
<CapacityBar label="Aggregate Capacity (all datastores)" usedBytes={totalUsed} totalBytes={totalCapacity} />
</div>
)}
{isLoading && (
<div className="flex justify-center py-16">
<Loader2 size={24} className="animate-spin text-text-muted" />
</div>
)}
{Object.entries(bySite).map(([site, siteDs]) => (
<section key={site}>
<p className="section-title mb-3">{site} · {siteDs.length} datastore{siteDs.length !== 1 ? 's' : ''}</p>
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
{siteDs.map((ds) => <DatastoreCard key={ds.id || ds.name} ds={ds} />)}
</div>
</section>
))}
{!isLoading && datastores.length === 0 && (
<div className="card p-12 text-center">
<Database size={28} className="text-text-muted mx-auto mb-3 opacity-40" />
<p className="text-text-muted text-sm">No datastore data available</p>
</div>
)}
</div>
);
}
+169 -2
View File
@@ -1,2 +1,169 @@
// TODO: Full content to be added
// This file is part of the zROC project recreation
// src/pages/VMDetail.jsx
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Search, Server, X, Activity, Loader2 } from 'lucide-react';
import { queryAllVms } from '@/api/prometheusExtended';
import TimeSeriesChart from '@/components/charts/TimeSeriesChart';
import RPOGauge from '@/components/charts/RPOGauge';
import { VM_STATUS, formatRpo, formatMB } from '@/constants/statusMaps';
import clsx from 'clsx';
const REFRESH = 30_000;
function JournalGauge({ usedMb, hardLimitMb }) {
if (!hardLimitMb || hardLimitMb <= 0) return <span className="text-xs text-text-muted"></span>;
const pct = Math.min(usedMb / hardLimitMb, 1);
const color = pct > 0.85 ? 'bg-crit' : pct > 0.65 ? 'bg-warn' : 'bg-ok';
return (
<div className="flex items-center gap-2 min-w-[100px]">
<div className="flex-1 h-1.5 bg-border rounded-full overflow-hidden">
<div className={clsx('h-full rounded-full', color)} style={{ width: `${pct * 100}%` }} />
</div>
<span className="text-[10px] font-mono data-value text-text-muted whitespace-nowrap">{formatMB(usedMb)}</span>
</div>
);
}
function VmDrawer({ vm, onClose }) {
const esc = vm.name.replace(/"/g, '\\"');
return (
<>
<div className="drawer-overlay" onClick={onClose} />
<div className="drawer-panel">
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-accent/10 flex items-center justify-center">
<Server size={18} className="text-accent" />
</div>
<div>
<p className="font-mono text-sm font-semibold text-text-primary">{vm.name}</p>
<p className="text-xs text-text-muted">{vm.vpgName} · {vm.siteName}</p>
</div>
</div>
<button onClick={onClose} className="p-1.5 rounded text-text-muted hover:text-text-primary hover:bg-raised transition-colors"><X size={16} /></button>
</div>
<div className="flex-1 overflow-y-auto px-6 py-5 space-y-5">
<div className="card p-4 flex items-start gap-6">
<RPOGauge actualSec={vm.actualRpoSec} size={120} label="Current RPO" />
<div className="flex-1 space-y-2 pt-2">
{[
{ label: 'Throughput', value: `${(vm.throughputMb ?? 0).toFixed(2)} MB/s` },
{ label: 'IOPS', value: Math.round(vm.iops ?? 0).toLocaleString() },
{ label: 'Bandwidth', value: `${(vm.bandwidthMbps ?? 0).toFixed(2)} Mbps` },
{ label: 'Journal', value: formatMB(vm.journalUsedMb) },
{ label: 'Encryption', value: vm.pctEncrypted != null ? `${vm.pctEncrypted.toFixed(1)}%` : '—' },
].map(({ label, value }) => (
<div key={label} className="flex justify-between text-xs">
<span className="text-text-muted">{label}</span>
<span className="font-mono data-value text-text-primary">{value}</span>
</div>
))}
</div>
</div>
<TimeSeriesChart title="RPO History" promql={`vm_actualrpo{VmName="${esc}"}`}
yFormatter={formatRpo}
transform={(result) => result[0]?.values.map(([ts, v]) => ({ ts: ts * 1000, 'RPO': parseFloat(v) })) ?? []}
height={170} />
<TimeSeriesChart title="Throughput" promql={`vm_throughput_in_mb{VmName="${esc}"}`}
yFormatter={(v) => `${v.toFixed(1)} MB/s`}
transform={(result) => result[0]?.values.map(([ts, v]) => ({ ts: ts * 1000, 'MB/s': parseFloat(v) })) ?? []}
height={150} />
</div>
</div>
</>
);
}
function VmStatusBadge({ code }) {
const s = VM_STATUS[code] ?? { label: 'Unknown', color: 'muted' };
return <span className={clsx('badge', `badge-${s.color === 'muted' ? 'muted' : s.color}`)}>{s.label}</span>;
}
export default function VMDetail() {
const [search, setSearch] = useState('');
const [sort, setSort] = useState('rpo-desc');
const [selected, setSelected] = useState(null);
const { data: vms = [], isLoading } = useQuery({
queryKey: ['all-vms'], queryFn: queryAllVms, refetchInterval: REFRESH,
});
const filtered = vms.filter((v) => {
const q = search.toLowerCase();
return !q || v.name?.toLowerCase().includes(q) || v.vpgName?.toLowerCase().includes(q);
});
const sorted = [...filtered].sort((a, b) => {
switch (sort) {
case 'rpo-desc': return (b.actualRpoSec ?? 0) - (a.actualRpoSec ?? 0);
case 'rpo-asc': return (a.actualRpoSec ?? 0) - (b.actualRpoSec ?? 0);
case 'name-asc': return (a.name ?? '').localeCompare(b.name ?? '');
default: return 0;
}
});
return (
<div className="flex flex-col h-full space-y-4 animate-fade-in">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[
{ label: 'Total VMs', value: vms.length, color: 'accent' },
{ label: 'RPO OK', value: vms.filter((v) => (v.actualRpoSec ?? 0) <= 300).length, color: 'ok' },
{ label: 'RPO Warning', value: vms.filter((v) => (v.actualRpoSec ?? 0) > 300 && (v.actualRpoSec ?? 0) <= 600).length, color: 'warn' },
{ label: 'RPO Critical', value: vms.filter((v) => (v.actualRpoSec ?? 0) > 600).length, color: 'crit' },
].map(({ label, value, color }) => (
<div key={label} className="card p-4 flex items-start gap-3">
<div className={clsx('w-9 h-9 rounded-lg flex items-center justify-center flex-shrink-0', `bg-${color}/10`)}>
<Activity size={16} className={`text-${color}`} />
</div>
<div><p className="section-title">{label}</p><p className="font-data text-2xl font-semibold text-text-primary data-value">{value}</p></div>
</div>
))}
</div>
<div className="flex items-center gap-3 flex-wrap">
<div className="relative flex-1 min-w-[200px]">
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-text-muted pointer-events-none" />
<input className="field pl-8 text-sm" placeholder="Search VMs or VPGs…" value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
<select className="field w-auto text-sm" value={sort} onChange={(e) => setSort(e.target.value)}>
<option value="rpo-desc">RPO (worst first)</option>
<option value="rpo-asc">RPO (best first)</option>
<option value="name-asc">Name A-Z</option>
</select>
<span className="text-xs text-text-muted">{sorted.length} / {vms.length} VMs</span>
</div>
<div className="card flex-1 overflow-auto">
<table className="w-full text-sm">
<thead><tr className="border-b border-border">
<th className="px-4 py-3 text-left section-title">VM Name</th>
<th className="px-4 py-3 text-left section-title hidden md:table-cell">VPG</th>
<th className="px-4 py-3 text-right section-title">RPO</th>
<th className="px-4 py-3 text-left section-title hidden md:table-cell">Journal</th>
<th className="px-4 py-3 text-right section-title hidden lg:table-cell">Throughput</th>
<th className="px-4 py-3 text-left section-title hidden xl:table-cell">Status</th>
</tr></thead>
<tbody>
{isLoading && <tr><td colSpan={6} className="py-16 text-center"><Loader2 size={20} className="animate-spin text-text-muted mx-auto" /></td></tr>}
{!isLoading && sorted.length === 0 && <tr><td colSpan={6} className="py-16 text-center text-text-muted">No VMs</td></tr>}
{!isLoading && sorted.map((vm) => {
const rpoColor = vm.actualRpoSec > 600 ? 'text-crit' : vm.actualRpoSec > 300 ? 'text-warn' : 'text-ok';
return (
<tr key={vm.id} onClick={() => setSelected(vm)} className="table-row-hover border-b border-border/40 last:border-0">
<td className="px-4 py-3"><span className="font-medium text-text-primary truncate">{vm.name}</span></td>
<td className="px-4 py-3 hidden md:table-cell"><span className="text-text-muted text-xs">{vm.vpgName}</span></td>
<td className="px-4 py-3 text-right"><span className={clsx('font-mono font-semibold text-xs data-value', rpoColor)}>{formatRpo(vm.actualRpoSec)}</span></td>
<td className="px-4 py-3 hidden md:table-cell"><JournalGauge usedMb={vm.journalUsedMb} hardLimitMb={vm.journalHardLimit} /></td>
<td className="px-4 py-3 text-right hidden lg:table-cell"><span className="font-mono text-xs data-value text-text-secondary">{(vm.throughputMb ?? 0).toFixed(2)} MB/s</span></td>
<td className="px-4 py-3 hidden xl:table-cell"><VmStatusBadge code={vm.status} /></td>
</tr>
);
})}
</tbody>
</table>
</div>
{selected && <VmDrawer vm={selected} onClose={() => setSelected(null)} />}
</div>
);
}
+154 -2
View File
@@ -1,2 +1,154 @@
// TODO: Full content to be added
// This file is part of the zROC project recreation
// src/pages/VPGMonitor.jsx
import { useState, useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { Search, ChevronRight, Loader2 } from 'lucide-react';
import { queryAllVpgs } from '@/api/prometheus';
import { queryVpgDetail, queryVpgVms } from '@/api/prometheusExtended';
import TimeSeriesChart from '@/components/charts/TimeSeriesChart';
import RPOGauge from '@/components/charts/RPOGauge';
import { VPG_ALERT, vpgHealth, rpoStatus, formatRpo, formatMB, colorToText } from '@/constants/statusMaps';
import clsx from 'clsx';
const REFRESH = 30_000;
function VpgListItem({ vpg, selected, onClick }) {
const status = rpoStatus(vpg.actualRpoSec, vpg.configuredRpoSec);
return (
<button onClick={onClick} className={clsx('w-full text-left px-3 py-2.5 rounded-md transition-all duration-150 group',
selected ? 'bg-accent/15 border border-accent/25' : 'hover:bg-raised border border-transparent')}>
<div className="flex items-center gap-2">
<span className={clsx('status-dot flex-shrink-0',
status === 'ok' ? 'status-dot-ok' : status === 'warn' ? 'status-dot-warn' : status === 'crit' ? 'status-dot-crit' : 'status-dot-idle')} />
<span className={clsx('text-sm font-medium truncate flex-1', selected ? 'text-accent' : 'text-text-primary')}>{vpg.name}</span>
{selected && <ChevronRight size={12} className="text-accent flex-shrink-0" />}
</div>
<div className="flex items-center gap-3 mt-0.5 pl-4">
<span className="text-[10px] text-text-muted">{vpg.siteName}</span>
<span className={clsx('text-[10px] font-mono data-value', colorToText[status])}>{formatRpo(vpg.actualRpoSec)}</span>
</div>
</button>
);
}
function VpgDetail({ vpgName }) {
const { data: detail, isLoading } = useQuery({
queryKey: ['vpg-detail', vpgName], queryFn: () => queryVpgDetail(vpgName), refetchInterval: REFRESH, enabled: !!vpgName,
});
const { data: vms = [], isLoading: vmsLoading } = useQuery({
queryKey: ['vpg-vms', vpgName], queryFn: () => queryVpgVms(vpgName), refetchInterval: REFRESH, enabled: !!vpgName,
});
if (isLoading) return <div className="flex-1 flex items-center justify-center"><Loader2 size={24} className="animate-spin text-text-muted" /></div>;
if (!detail) return null;
const alertInfo = VPG_ALERT[detail.alertStatus] ?? VPG_ALERT[0];
const esc = vpgName.replace(/"/g, '\\"');
return (
<div className="flex-1 min-w-0 overflow-y-auto p-4 space-y-4 animate-fade-in">
<div className="flex items-center gap-2 mb-1">
<span className={clsx('status-dot', alertInfo.color === 'ok' ? 'status-dot-ok' : alertInfo.color === 'warn' ? 'status-dot-warn' : 'status-dot-crit')} />
<h2 className="font-mono text-base font-semibold text-text-primary">{vpgName}</h2>
<span className={clsx('badge', `badge-${alertInfo.color}`)}>{alertInfo.label}</span>
<span className="text-xs text-text-muted">{detail.siteName} · {detail.vmCount} VMs</span>
</div>
<div className="card overflow-hidden flex flex-wrap">
<div className="flex items-center justify-center p-4 border-r border-border">
<RPOGauge actualSec={detail.actualRpoSec} configuredSec={detail.configuredRpoSec} size={150} />
</div>
<div className="flex flex-wrap flex-1">
{[
{ label: 'Throughput', value: `${(detail.throughputMb ?? 0).toFixed(2)} MB/s` },
{ label: 'IOPS', value: Math.round(detail.iops ?? 0) },
{ label: 'Storage', value: formatMB(detail.storageUsedMb) },
].map((s) => (
<div key={s.label} className="text-center px-4 py-3 border-r border-border last:border-0">
<p className="text-lg font-semibold font-data data-value text-text-primary">{s.value}</p>
<p className="section-title mt-0.5">{s.label}</p>
</div>
))}
</div>
</div>
<TimeSeriesChart title="RPO Over Time" promql={`vpg_actual_rpo{VpgName="${esc}"}`}
yFormatter={(v) => formatRpo(v)}
refLines={detail.configuredRpoSec ? [{ value: detail.configuredRpoSec, label: 'Target', color: 'warn' }] : []}
transform={(result) => result[0]?.values.map(([ts, v]) => ({ ts: ts * 1000, 'RPO (s)': parseFloat(v) })) ?? []}
height={180} />
<div className="card overflow-hidden">
<div className="px-4 py-3 border-b border-border"><p className="section-title">Protected VMs</p></div>
<table className="w-full text-xs">
<thead><tr className="border-b border-border/60">
<th className="px-4 py-2 text-left section-title">VM Name</th>
<th className="px-4 py-2 text-right section-title">RPO</th>
<th className="px-4 py-2 text-right section-title hidden sm:table-cell">Throughput</th>
<th className="px-4 py-2 text-right section-title hidden md:table-cell">IOPS</th>
</tr></thead>
<tbody>
{vmsLoading && <tr><td colSpan={4} className="py-8 text-center"><Loader2 size={16} className="animate-spin text-text-muted mx-auto" /></td></tr>}
{!vmsLoading && vms.map((vm) => (
<tr key={vm.id} className="border-b border-border/40 last:border-0 hover:bg-raised transition-colors">
<td className="px-4 py-2 font-medium text-text-primary">{vm.name}</td>
<td className="px-4 py-2 text-right font-mono data-value">{formatRpo(vm.actualRpoSec)}</td>
<td className="px-4 py-2 text-right text-text-secondary font-mono data-value hidden sm:table-cell">{vm.throughputMb?.toFixed(2)} MB/s</td>
<td className="px-4 py-2 text-right text-text-secondary font-mono data-value hidden md:table-cell">{Math.round(vm.iops ?? 0)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
export default function VPGMonitor() {
const [params] = useSearchParams();
const [search, setSearch] = useState('');
const initialName = params.get('name');
const { data: vpgs = [], isLoading } = useQuery({
queryKey: ['all-vpgs'], queryFn: queryAllVpgs, refetchInterval: REFRESH,
});
const [selected, setSelected] = useState(initialName || null);
useEffect(() => {
if (!selected && vpgs.length > 0) setSelected(vpgs[0].name);
}, [vpgs, selected]);
const filtered = vpgs.filter((v) => !search || v.name.toLowerCase().includes(search.toLowerCase()));
const bySite = {};
for (const v of filtered) { const s = v.siteName || 'Unknown'; if (!bySite[s]) bySite[s] = []; bySite[s].push(v); }
return (
<div className="flex h-full -m-6 overflow-hidden">
<aside className="w-64 flex-shrink-0 border-r border-border flex flex-col bg-surface overflow-hidden">
<div className="p-3 border-b border-border flex-shrink-0">
<div className="relative">
<Search size={12} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-text-muted pointer-events-none" />
<input className="field pl-8 text-xs py-1.5" placeholder="Filter VPGs…" value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
<p className="text-[10px] text-text-muted mt-2 font-mono">{filtered.length} VPGs</p>
</div>
<div className="flex-1 overflow-y-auto p-2">
{isLoading && <div className="flex justify-center py-8"><Loader2 size={16} className="animate-spin text-text-muted" /></div>}
{Object.entries(bySite).map(([site, siteVpgs]) => (
<div key={site} className="mb-3">
<p className="section-title px-2 mb-1">{site}</p>
{siteVpgs.map((v) => <VpgListItem key={v.id} vpg={v} selected={selected === v.name} onClick={() => setSelected(v.name)} />)}
</div>
))}
</div>
</aside>
<div className="flex-1 flex flex-col overflow-hidden">
{selected ? <VpgDetail vpgName={selected} /> : (
<div className="flex-1 flex items-center justify-center text-text-muted text-sm">Select a VPG to view details</div>
)}
</div>
</div>
);
}
+147 -2
View File
@@ -1,2 +1,147 @@
// TODO: Full content to be added
// This file is part of the zROC project recreation
// src/pages/VRADashboard.jsx
import { useQuery } from '@tanstack/react-query';
import { Cpu, Server, HardDrive, Layers, Loader2 } from 'lucide-react';
import { queryAllVras } from '@/api/prometheusExtended';
import clsx from 'clsx';
const REFRESH = 30_000;
const VRA_MAX_VMS = 100;
const VRA_MAX_VOL = 2048;
function UsageBar({ label, used, total, max, unit = '', warnAt = 0.75, critAt = 0.9 }) {
const pct = total > 0 ? Math.min(used / total, 1) : max > 0 ? Math.min(used / max, 1) : 0;
const color = pct >= critAt ? 'bg-crit' : pct >= warnAt ? 'bg-warn' : 'bg-ok';
const textC = pct >= critAt ? 'text-crit' : pct >= warnAt ? 'text-warn' : 'text-ok';
return (
<div className="space-y-1">
<div className="flex justify-between items-center text-[10px]">
<span className="text-text-muted">{label}</span>
<span className={clsx('font-mono data-value', textC)}>
{typeof used === 'number' ? `${Math.round(used)}${unit}` : '—'}
{total > 0 ? ` / ${Math.round(total)}${unit}` : max ? ` / ${max}${unit}` : ''}
</span>
</div>
<div className="h-1 bg-border rounded-full overflow-hidden">
<div className={clsx('h-full rounded-full transition-all duration-500', color)} style={{ width: `${pct * 100}%` }} />
</div>
</div>
);
}
function WorkloadBadge({ label, value, icon: Icon, color = 'text-text-secondary' }) {
return (
<div className="flex flex-col items-center p-2 bg-canvas rounded-md border border-border min-w-0">
<Icon size={12} className={clsx('mb-1', color)} />
<span className={clsx('font-data text-base font-semibold data-value', color)}>{value ?? '—'}</span>
<span className="section-title mt-0.5 text-center leading-tight">{label}</span>
</div>
);
}
function VraCard({ vra }) {
const protPct = vra.protectedVms / VRA_MAX_VMS;
const recPct = vra.recoveryVms / VRA_MAX_VMS;
const alerting = protPct >= 0.9 || recPct >= 0.9;
const warning = protPct >= 0.75 || recPct >= 0.75;
return (
<div className={clsx('card p-4 flex flex-col gap-4 transition-colors duration-300',
alerting ? 'border-crit/30' : warning ? 'border-warn/30' : '')}>
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<div className={clsx('w-8 h-8 rounded-md flex items-center justify-center',
alerting ? 'bg-crit/10' : warning ? 'bg-warn/10' : 'bg-accent/10')}>
<Server size={14} className={alerting ? 'text-crit' : warning ? 'text-warn' : 'text-accent'} />
</div>
<div>
<p className="text-sm font-mono font-semibold text-text-primary truncate max-w-[140px]">{vra.name}</p>
<p className="text-[10px] text-text-muted">{vra.siteName}</p>
</div>
</div>
<div className="text-right">
<p className="text-[10px] text-text-muted font-mono">{vra.vcpuCount} vCPU</p>
<p className="text-[10px] text-text-muted font-mono">{vra.memoryGb?.toFixed(0)} GB RAM</p>
</div>
</div>
{(vra.cpuUsageMhz !== undefined || vra.memUsageMb !== undefined) && (
<div className="space-y-2">
{vra.cpuUsageMhz !== undefined && (
<UsageBar label="CPU" used={vra.cpuUsageMhz} unit=" MHz" max={vra.vcpuCount * 2600} warnAt={0.7} critAt={0.9} />
)}
{vra.memUsageMb !== undefined && (
<UsageBar label="Memory" used={vra.memUsageMb} total={(vra.memoryGb ?? 0) * 1024} unit=" MB" warnAt={0.8} critAt={0.92} />
)}
</div>
)}
<div>
<p className="section-title mb-2">Workload</p>
<div className="grid grid-cols-3 gap-1.5 mb-2">
<WorkloadBadge label="Prot VMs" value={vra.protectedVms} icon={Server}
color={protPct >= 0.9 ? 'text-crit' : protPct >= 0.75 ? 'text-warn' : 'text-ok'} />
<WorkloadBadge label="Rec VMs" value={vra.recoveryVms} icon={Server}
color={recPct >= 0.9 ? 'text-crit' : recPct >= 0.75 ? 'text-warn' : 'text-accent'} />
<WorkloadBadge label="Self VPGs" value={vra.selfProtectedVpgs} icon={Layers} color="text-text-secondary" />
</div>
<div className="grid grid-cols-2 gap-1.5">
<WorkloadBadge label="Prot Vols" value={vra.protectedVolumes} icon={HardDrive}
color={vra.protectedVolumes / VRA_MAX_VOL >= 0.85 ? 'text-crit' : vra.protectedVolumes / VRA_MAX_VOL >= 0.7 ? 'text-warn' : 'text-text-secondary'} />
<WorkloadBadge label="Rec Vols" value={vra.recoveryVolumes} icon={HardDrive}
color={vra.recoveryVolumes / VRA_MAX_VOL >= 0.85 ? 'text-crit' : vra.recoveryVolumes / VRA_MAX_VOL >= 0.7 ? 'text-warn' : 'text-text-secondary'} />
</div>
</div>
<div className="flex items-center justify-between text-[10px] text-text-muted font-mono pt-3 border-t border-border">
<span>VRA {vra.version ?? '—'}</span>
<span>ESXi {vra.hostVersion ?? '—'}</span>
</div>
</div>
);
}
export default function VRADashboard() {
const { data: vras = [], isLoading } = useQuery({
queryKey: ['all-vras'], queryFn: queryAllVras, refetchInterval: REFRESH,
});
const bySite = {};
for (const v of vras) {
const s = v.siteName || 'Unknown';
if (!bySite[s]) bySite[s] = [];
bySite[s].push(v);
}
const totalProt = vras.reduce((s, v) => s + (v.protectedVms ?? 0), 0);
const totalRec = vras.reduce((s, v) => s + (v.recoveryVms ?? 0), 0);
return (
<div className="space-y-6 animate-fade-in">
<div className="grid grid-cols-3 gap-4">
{[
{ label: 'Total VRAs', value: vras.length },
{ label: 'Protected VMs', value: totalProt },
{ label: 'Recovery VMs', value: totalRec },
].map((s) => (
<div key={s.label} className="card p-4 text-center">
<p className="font-data text-2xl font-semibold text-text-primary data-value">{s.value}</p>
<p className="section-title mt-1">{s.label}</p>
</div>
))}
</div>
{isLoading && (
<div className="flex justify-center py-16"><Loader2 size={24} className="animate-spin text-text-muted" /></div>
)}
{Object.entries(bySite).map(([site, siteVras]) => (
<section key={site}>
<p className="section-title mb-3">{site} · {siteVras.length} VRA{siteVras.length !== 1 ? 's' : ''}</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{siteVras.map((vra) => <VraCard key={vra.id || vra.name} vra={vra} />)}
</div>
</section>
))}
</div>
);
}