mirror of
https://github.com/recklessop/zroc.git
synced 2026-07-04 13:43:13 -04:00
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:
@@ -0,0 +1,52 @@
|
||||
// src/App.jsx — final router with all pages wired up
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { AuthProvider } from '@/auth/AuthContext';
|
||||
import { ProtectedRoute, AdminRoute } from '@/auth/ProtectedRoute';
|
||||
import AppShell from '@/components/layout/AppShell';
|
||||
import Overview from '@/pages/Overview';
|
||||
import VPGMonitor from '@/pages/VPGMonitor';
|
||||
import VRADashboard from '@/pages/VRADashboard';
|
||||
import EncryptionPage from '@/pages/Encryption';
|
||||
import Storage from '@/pages/Storage';
|
||||
import UserManagement from '@/pages/Settings/UserManagement';
|
||||
import VMDetail from '@/pages/VMDetail';
|
||||
import Placeholder from '@/pages/Placeholder';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 2,
|
||||
staleTime: 15_000,
|
||||
refetchOnWindowFocus: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route element={<ProtectedRoute><AppShell /></ProtectedRoute>}>
|
||||
<Route index element={<Overview />} />
|
||||
<Route path="vpgs" element={<VPGMonitor />} />
|
||||
<Route path="vms" element={<VMDetail />} />
|
||||
<Route path="vras" element={<VRADashboard />} />
|
||||
<Route path="encryption" element={<EncryptionPage />} />
|
||||
<Route path="storage" element={<Storage />} />
|
||||
<Route path="settings">
|
||||
<Route index element={<Navigate to="users" replace />} />
|
||||
<Route path="users" element={
|
||||
<AdminRoute><UserManagement /></AdminRoute>
|
||||
} />
|
||||
</Route>
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
// src/api/prometheus.js
|
||||
const BASE = '/api/prometheus/api/v1';
|
||||
|
||||
async function promFetch(endpoint, params = {}) {
|
||||
const url = new URL(BASE + endpoint, window.location.origin);
|
||||
Object.entries(params).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== null) url.searchParams.set(k, v);
|
||||
});
|
||||
const res = await fetch(url.toString(), { credentials: 'include' });
|
||||
if (!res.ok) throw new Error(`Prometheus error: ${res.status}`);
|
||||
const json = await res.json();
|
||||
if (json.status !== 'success') throw new Error(json.error || 'Prometheus query failed');
|
||||
return json.data;
|
||||
}
|
||||
|
||||
export async function instantQuery(promql, time) {
|
||||
const params = { query: promql };
|
||||
if (time) params.time = time;
|
||||
const data = await promFetch('/query', params);
|
||||
return data.result;
|
||||
}
|
||||
|
||||
export async function rangeQuery(promql, start, end, step = '60s') {
|
||||
const data = await promFetch('/query_range', { query: promql, start, end, step });
|
||||
return data.result;
|
||||
}
|
||||
|
||||
export async function labelValues(labelName, match) {
|
||||
const params = {};
|
||||
if (match) params.match = match;
|
||||
const data = await promFetch(`/label/${labelName}/values`, params);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function querySites() {
|
||||
return labelValues('SiteName', 'vpg_actual_rpo');
|
||||
}
|
||||
|
||||
export async function queryOverviewSummary() {
|
||||
const [alertVec, throughputVec, rpoVec] = await Promise.all([
|
||||
instantQuery('vpg_alert_status'),
|
||||
instantQuery('sum by (SiteName) (vpg_throughput_in_mb)'),
|
||||
instantQuery('max by (SiteName) (vpg_actual_rpo)'),
|
||||
]);
|
||||
|
||||
const siteMap = {};
|
||||
for (const { metric, value } of alertVec) {
|
||||
const site = metric.SiteName || 'Unknown';
|
||||
if (!siteMap[site]) siteMap[site] = { siteName: site, ok: 0, warn: 0, crit: 0 };
|
||||
const v = Number(value[1]);
|
||||
if (v === 0) siteMap[site].ok++;
|
||||
else if (v === 1) siteMap[site].warn++;
|
||||
else siteMap[site].crit++;
|
||||
}
|
||||
|
||||
for (const { metric, value } of throughputVec) {
|
||||
const site = metric.SiteName || 'Unknown';
|
||||
if (siteMap[site]) siteMap[site].throughputMb = parseFloat(value[1]);
|
||||
}
|
||||
|
||||
for (const { metric, value } of rpoVec) {
|
||||
const site = metric.SiteName || 'Unknown';
|
||||
if (siteMap[site]) siteMap[site].worstRpoSec = parseFloat(value[1]);
|
||||
}
|
||||
|
||||
return Object.values(siteMap).map((s) => ({
|
||||
...s,
|
||||
total: s.ok + s.warn + s.crit,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function queryAllVpgs() {
|
||||
const [rpoVec, configuredVec, alertVec, throughputVec, iopsVec, vmCountVec] =
|
||||
await Promise.all([
|
||||
instantQuery('vpg_actual_rpo'),
|
||||
instantQuery('vpg_configured_rpo'),
|
||||
instantQuery('vpg_alert_status'),
|
||||
instantQuery('vpg_throughput_in_mb'),
|
||||
instantQuery('vpg_iops'),
|
||||
instantQuery('vpg_vms_count'),
|
||||
]);
|
||||
|
||||
const byId = {};
|
||||
const idx = (vec, field, transform = Number) => {
|
||||
for (const { metric, value } of vec) {
|
||||
const id = metric.VpgIdentifier || metric.VpgName;
|
||||
if (!byId[id]) byId[id] = {
|
||||
id,
|
||||
name: metric.VpgName || id,
|
||||
siteName: metric.SiteName || 'Unknown',
|
||||
siteId: metric.SiteIdentifier,
|
||||
priority: metric.VpgPriority,
|
||||
};
|
||||
byId[id][field] = transform(value[1]);
|
||||
}
|
||||
};
|
||||
|
||||
idx(rpoVec, 'actualRpoSec');
|
||||
idx(configuredVec, 'configuredRpoSec');
|
||||
idx(alertVec, 'alertStatus');
|
||||
idx(throughputVec, 'throughputMb', parseFloat);
|
||||
idx(iopsVec, 'iops', parseFloat);
|
||||
idx(vmCountVec, 'vmCount');
|
||||
|
||||
return Object.values(byId);
|
||||
}
|
||||
|
||||
export async function queryTopRpoViolators(n = 10) {
|
||||
const vpgs = await queryAllVpgs();
|
||||
return vpgs
|
||||
.filter((v) => v.actualRpoSec && v.configuredRpoSec)
|
||||
.sort((a, b) => (b.actualRpoSec / b.configuredRpoSec) - (a.actualRpoSec / a.configuredRpoSec))
|
||||
.slice(0, n);
|
||||
}
|
||||
|
||||
export async function queryVpgRpoHistory(vpgName, startOffset = '6h', step = '60s') {
|
||||
const end = Math.floor(Date.now() / 1000);
|
||||
const start = end - parseDuration(startOffset);
|
||||
const q = `vpg_actual_rpo{VpgName="${vpgName}"}`;
|
||||
const result = await rangeQuery(q, start, end, step);
|
||||
if (!result.length) return [];
|
||||
const configured = (await instantQuery(`vpg_configured_rpo{VpgName="${vpgName}"}`))
|
||||
?.[0]?.value?.[1];
|
||||
return result[0].values.map(([ts, v]) => ({
|
||||
ts: ts * 1000,
|
||||
rpo: parseFloat(v),
|
||||
configured: configured ? parseFloat(configured) : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function queryVraHealth() {
|
||||
const [memVec, cpuVec, protVmsVec, recVmsVec, protVolVec, recVolVec] = await Promise.all([
|
||||
instantQuery('vra_memory_usage_mb'),
|
||||
instantQuery('vra_cpu_usage_mhz'),
|
||||
instantQuery('vra_protected_vms'),
|
||||
instantQuery('vra_recovery_vms'),
|
||||
instantQuery('vra_protected_volumes'),
|
||||
instantQuery('vra_recovery_volumes'),
|
||||
]);
|
||||
|
||||
const byName = {};
|
||||
const idx = (vec, field, transform = Number) => {
|
||||
for (const { metric, value } of vec) {
|
||||
const key = metric.VraName || metric.VraIdentifierStr;
|
||||
if (!byName[key]) byName[key] = {
|
||||
name: metric.VraName,
|
||||
version: metric.VraVersion,
|
||||
hostVersion: metric.HostVersion,
|
||||
siteName: metric.SiteName,
|
||||
};
|
||||
byName[key][field] = transform(value[1]);
|
||||
}
|
||||
};
|
||||
|
||||
idx(memVec, 'memoryUsageMb', parseFloat);
|
||||
idx(cpuVec, 'cpuUsageMhz', parseFloat);
|
||||
idx(protVmsVec, 'protectedVms');
|
||||
idx(recVmsVec, 'recoveryVms');
|
||||
idx(protVolVec, 'protectedVolumes');
|
||||
idx(recVolVec, 'recoveryVolumes');
|
||||
|
||||
return Object.values(byName);
|
||||
}
|
||||
|
||||
export async function queryEncryptionOverview() {
|
||||
const vec = await instantQuery('vm_PercentEncrypted > 50');
|
||||
return vec.map(({ metric, value }) => ({
|
||||
vmName: metric.VmName,
|
||||
vpgName: metric.VpgName,
|
||||
siteName: metric.SiteName,
|
||||
pctEnc: parseFloat(value[1]),
|
||||
trend: metric.vm_TrendChangeLevel,
|
||||
})).sort((a, b) => b.pctEnc - a.pctEnc);
|
||||
}
|
||||
|
||||
export async function queryExporterHealth() {
|
||||
const vec = await instantQuery('exporter_thread_status');
|
||||
return vec.map(({ metric, value }) => ({
|
||||
instance: metric.ExporterInstance,
|
||||
thread: metric.thread,
|
||||
alive: Number(value[1]) === 1,
|
||||
}));
|
||||
}
|
||||
|
||||
function parseDuration(s) {
|
||||
const match = s.match(/^(\d+)(s|m|h|d)$/);
|
||||
if (!match) return 3600;
|
||||
const [, n, unit] = match;
|
||||
const mul = { s: 1, m: 60, h: 3600, d: 86400 };
|
||||
return parseInt(n, 10) * mul[unit];
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
// src/api/prometheusExtended.js
|
||||
import { instantQuery, rangeQuery, labelValues } from './prometheus';
|
||||
|
||||
export async function queryVpgDetail(vpgName) {
|
||||
const esc = vpgName.replace(/"/g, '\\"');
|
||||
const [rpo, cfgRpo, alert, status, throughput, iops, vmCount,
|
||||
storageUsed, storageProv, histActual, histCfg, failsafeActual, failsafeCfg] =
|
||||
await Promise.all([
|
||||
instantQuery(`vpg_actual_rpo{VpgName="${esc}"}`),
|
||||
instantQuery(`vpg_configured_rpo{VpgName="${esc}"}`),
|
||||
instantQuery(`vpg_alert_status{VpgName="${esc}"}`),
|
||||
instantQuery(`vpg_status{VpgName="${esc}"}`),
|
||||
instantQuery(`vpg_throughput_in_mb{VpgName="${esc}"}`),
|
||||
instantQuery(`vpg_iops{VpgName="${esc}"}`),
|
||||
instantQuery(`vpg_vms_count{VpgName="${esc}"}`),
|
||||
instantQuery(`vpg_storage_used_in_mb{VpgName="${esc}"}`),
|
||||
instantQuery(`vpg_provisioned_storage_in_mb{VpgName="${esc}"}`),
|
||||
instantQuery(`vpg_actual_history{VpgName="${esc}"}`),
|
||||
instantQuery(`vpg_configured_history{VpgName="${esc}"}`),
|
||||
instantQuery(`vpg_failsafe_actual{VpgName="${esc}"}`),
|
||||
instantQuery(`vpg_failsafe_configured{VpgName="${esc}"}`),
|
||||
]);
|
||||
|
||||
const val = (vec) => parseFloat(vec?.[0]?.value?.[1] ?? 0);
|
||||
const meta = rpo?.[0]?.metric ?? {};
|
||||
|
||||
return {
|
||||
name: vpgName,
|
||||
siteName: meta.SiteName,
|
||||
priority: meta.VpgPriority,
|
||||
actualRpoSec: val(rpo),
|
||||
configuredRpoSec: val(cfgRpo),
|
||||
alertStatus: val(alert),
|
||||
status: val(status),
|
||||
throughputMb: val(throughput),
|
||||
iops: val(iops),
|
||||
vmCount: val(vmCount),
|
||||
storageUsedMb: val(storageUsed),
|
||||
storageProvMb: val(storageProv),
|
||||
histActualMin: val(histActual),
|
||||
histConfiguredMin:val(histCfg),
|
||||
failsafeActualMin:val(failsafeActual),
|
||||
failsafeCfgMin: val(failsafeCfg),
|
||||
};
|
||||
}
|
||||
|
||||
export async function queryVpgVms(vpgName) {
|
||||
const esc = vpgName.replace(/"/g, '\\"');
|
||||
const [rpo, status, throughput, iops, journalUsed, journalHard] = await Promise.all([
|
||||
instantQuery(`vm_actualrpo{VpgName="${esc}"}`),
|
||||
instantQuery(`vm_status{VpgName="${esc}"}`),
|
||||
instantQuery(`vm_throughput_in_mb{VpgName="${esc}"}`),
|
||||
instantQuery(`vm_iops{VpgName="${esc}"}`),
|
||||
instantQuery(`vm_journal_used_storage_mb{VpgName="${esc}"}`),
|
||||
instantQuery(`vm_journal_hard_limit{VpgName="${esc}"}`),
|
||||
]);
|
||||
|
||||
const byId = {};
|
||||
const idx = (vec, field, transform = Number) => {
|
||||
for (const { metric, value } of vec) {
|
||||
const id = metric.VmIdentifier || metric.VmName;
|
||||
if (!byId[id]) byId[id] = {
|
||||
id,
|
||||
name: metric.VmName,
|
||||
sourceVra: metric.VmSourceVRA,
|
||||
recoveryVra: metric.VmRecoveryVRA,
|
||||
priority: metric.VmPriority,
|
||||
};
|
||||
byId[id][field] = transform(value[1]);
|
||||
}
|
||||
};
|
||||
|
||||
idx(rpo, 'actualRpoSec');
|
||||
idx(status, 'status');
|
||||
idx(throughput, 'throughputMb', parseFloat);
|
||||
idx(iops, 'iops', parseFloat);
|
||||
idx(journalUsed, 'journalUsedMb', parseFloat);
|
||||
idx(journalHard, 'journalHardLimit', parseFloat);
|
||||
|
||||
return Object.values(byId);
|
||||
}
|
||||
|
||||
export async function queryAllVms() {
|
||||
const [rpo, status, throughput, iops, journalUsed, bandwidth, pctEnc] = await Promise.all([
|
||||
instantQuery('vm_actualrpo'),
|
||||
instantQuery('vm_status'),
|
||||
instantQuery('vm_throughput_in_mb'),
|
||||
instantQuery('vm_iops'),
|
||||
instantQuery('vm_journal_used_storage_mb'),
|
||||
instantQuery('vm_outgoing_bandwidth_in_mbps'),
|
||||
instantQuery('vm_PercentEncrypted'),
|
||||
]);
|
||||
|
||||
const byId = {};
|
||||
const idx = (vec, field, transform = Number) => {
|
||||
for (const { metric, value } of vec) {
|
||||
const id = metric.VmIdentifier || metric.VmName;
|
||||
if (!byId[id]) byId[id] = {
|
||||
id,
|
||||
name: metric.VmName,
|
||||
vpgName: metric.VpgName,
|
||||
siteName: metric.SiteName,
|
||||
sourceVra: metric.VmSourceVRA,
|
||||
recoveryVra: metric.VmRecoveryVRA,
|
||||
};
|
||||
byId[id][field] = transform(value[1]);
|
||||
}
|
||||
};
|
||||
|
||||
idx(rpo, 'actualRpoSec');
|
||||
idx(status, 'status');
|
||||
idx(throughput, 'throughputMb', parseFloat);
|
||||
idx(iops, 'iops', parseFloat);
|
||||
idx(journalUsed, 'journalUsedMb', parseFloat);
|
||||
idx(bandwidth, 'bandwidthMbps', parseFloat);
|
||||
idx(pctEnc, 'pctEncrypted', parseFloat);
|
||||
|
||||
return Object.values(byId);
|
||||
}
|
||||
|
||||
export async function queryAllVras() {
|
||||
const [mem, cpu, memUsage, cpuUsage,
|
||||
protVms, recVms, protVpgs, recVpgs, protVols, recVols, selfVpgs] =
|
||||
await Promise.all([
|
||||
instantQuery('vra_memory_in_GB'),
|
||||
instantQuery('vra_vcpu_count'),
|
||||
instantQuery('vra_memory_usage_mb'),
|
||||
instantQuery('vra_cpu_usage_mhz'),
|
||||
instantQuery('vra_protected_vms'),
|
||||
instantQuery('vra_recovery_vms'),
|
||||
instantQuery('vra_protected_vpgs'),
|
||||
instantQuery('vra_recovery_vpgs'),
|
||||
instantQuery('vra_protected_volumes'),
|
||||
instantQuery('vra_recovery_volumes'),
|
||||
instantQuery('vra_self_protected_vpgs'),
|
||||
]);
|
||||
|
||||
const byName = {};
|
||||
const idx = (vec, field, transform = Number) => {
|
||||
for (const { metric, value } of vec) {
|
||||
const key = metric.VraName || metric.VraIdentifierStr;
|
||||
if (!byName[key]) byName[key] = {
|
||||
id: metric.VraIdentifierStr,
|
||||
name: metric.VraName,
|
||||
version: metric.VraVersion,
|
||||
hostVersion: metric.HostVersion,
|
||||
siteName: metric.SiteName,
|
||||
siteId: metric.SiteIdentifier,
|
||||
};
|
||||
byName[key][field] = transform(value[1]);
|
||||
}
|
||||
};
|
||||
|
||||
idx(mem, 'memoryGb', parseFloat);
|
||||
idx(cpu, 'vcpuCount');
|
||||
idx(memUsage, 'memUsageMb', parseFloat);
|
||||
idx(cpuUsage, 'cpuUsageMhz', parseFloat);
|
||||
idx(protVms, 'protectedVms');
|
||||
idx(recVms, 'recoveryVms');
|
||||
idx(protVpgs, 'protectedVpgs');
|
||||
idx(recVpgs, 'recoveryVpgs');
|
||||
idx(protVols, 'protectedVolumes');
|
||||
idx(recVols, 'recoveryVolumes');
|
||||
idx(selfVpgs, 'selfProtectedVpgs');
|
||||
|
||||
return Object.values(byName);
|
||||
}
|
||||
|
||||
export async function queryEncryptionDetail() {
|
||||
const [pctEnc, trend, encrypted, unencrypted, total, ioOps, writeCounter] =
|
||||
await Promise.all([
|
||||
instantQuery('vm_PercentEncrypted'),
|
||||
instantQuery('vm_TrendChangeLevel'),
|
||||
instantQuery('vm_EncryptedDataInLBs'),
|
||||
instantQuery('vm_UnencryptedDataInLBs'),
|
||||
instantQuery('vm_TotalDataInLBs'),
|
||||
instantQuery('vm_IoOperationsCounter'),
|
||||
instantQuery('vm_WriteCounterInMBs'),
|
||||
]);
|
||||
|
||||
const byId = {};
|
||||
const idx = (vec, field, transform = Number) => {
|
||||
for (const { metric, value } of vec) {
|
||||
const id = metric.VmIdentifier || metric.VmName;
|
||||
if (!byId[id]) byId[id] = {
|
||||
id,
|
||||
name: metric.VmName,
|
||||
vpgName: metric.VpgName,
|
||||
siteName:metric.SiteName,
|
||||
vpgId: metric.VpgIdentifier,
|
||||
};
|
||||
byId[id][field] = transform(value[1]);
|
||||
}
|
||||
};
|
||||
|
||||
idx(pctEnc, 'pctEncrypted', parseFloat);
|
||||
idx(trend, 'trendLevel', parseFloat);
|
||||
idx(encrypted, 'encryptedLbs', parseFloat);
|
||||
idx(unencrypted, 'unencryptedLbs', parseFloat);
|
||||
idx(total, 'totalLbs', parseFloat);
|
||||
idx(ioOps, 'ioOps', parseFloat);
|
||||
idx(writeCounter,'writeMb', parseFloat);
|
||||
|
||||
return Object.values(byId).sort((a, b) => (b.pctEncrypted ?? 0) - (a.pctEncrypted ?? 0));
|
||||
}
|
||||
|
||||
export async function queryDatastores() {
|
||||
const metrics = [
|
||||
'datastore_capacity_in_bytes',
|
||||
'datastore_free_in_bytes',
|
||||
'datastore_used_in_bytes',
|
||||
'datastore_vras',
|
||||
'datastore_incoming_vms',
|
||||
'datastore_outgoing_vms',
|
||||
'datastore_usage_zerto_journal_used_in_bytes',
|
||||
'datastore_usage_zerto_scratch_used_in_bytes',
|
||||
'datastore_usage_zerto_recovery_used_in_bytes',
|
||||
'datastore_usage_zerto_appliances_used_in_bytes',
|
||||
];
|
||||
|
||||
const results = await Promise.all(metrics.map(instantQuery));
|
||||
|
||||
const byId = {};
|
||||
metrics.forEach((metric, mi) => {
|
||||
const fieldMap = {
|
||||
datastore_capacity_in_bytes: 'capacityBytes',
|
||||
datastore_free_in_bytes: 'freeBytes',
|
||||
datastore_used_in_bytes: 'usedBytes',
|
||||
datastore_vras: 'vraCount',
|
||||
datastore_incoming_vms: 'incomingVms',
|
||||
datastore_outgoing_vms: 'outgoingVms',
|
||||
datastore_usage_zerto_journal_used_in_bytes: 'journalBytes',
|
||||
datastore_usage_zerto_scratch_used_in_bytes: 'scratchBytes',
|
||||
datastore_usage_zerto_recovery_used_in_bytes: 'recoveryBytes',
|
||||
datastore_usage_zerto_appliances_used_in_bytes: 'applianceBytes',
|
||||
};
|
||||
const field = fieldMap[metric];
|
||||
for (const { metric: m, value } of results[mi]) {
|
||||
const id = m.datastoreIdentifier || m.DatastoreName;
|
||||
if (!byId[id]) byId[id] = {
|
||||
id, name: m.DatastoreName, siteName: m.SiteName,
|
||||
};
|
||||
byId[id][field] = parseFloat(value[1]);
|
||||
}
|
||||
});
|
||||
|
||||
return Object.values(byId).sort((a, b) => (b.capacityBytes ?? 0) - (a.capacityBytes ?? 0));
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// src/api/users.js — user management API calls
|
||||
const BASE = '/api/admin/users';
|
||||
|
||||
async function apiFetch(url, opts = {}) {
|
||||
const res = await fetch(url, { credentials: 'include', ...opts });
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw Object.assign(new Error(body.error || `HTTP ${res.status}`), {
|
||||
status: res.status,
|
||||
detail: body.detail,
|
||||
});
|
||||
}
|
||||
if (res.status === 204) return null;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export const usersApi = {
|
||||
list: ({ search = '', page = 1, pageSize = 50 } = {}) => {
|
||||
const params = new URLSearchParams({ search, page, pageSize });
|
||||
return apiFetch(`${BASE}?${params}`);
|
||||
},
|
||||
get: (id) => apiFetch(`${BASE}/${id}`),
|
||||
create: (body) =>
|
||||
apiFetch(BASE, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
update: (id, body) =>
|
||||
apiFetch(`${BASE}/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
delete: (id) =>
|
||||
apiFetch(`${BASE}/${id}`, { method: 'DELETE' }),
|
||||
setPassword: (id, password) =>
|
||||
apiFetch(`${BASE}/${id}/set-password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password }),
|
||||
}),
|
||||
setup2fa: (id) =>
|
||||
apiFetch(`${BASE}/${id}/setup-2fa`, { method: 'POST' }),
|
||||
listGroups: () => apiFetch(`${BASE}/meta/groups`),
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
// src/auth/AuthContext.jsx
|
||||
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const checkSession = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/auth/status', { credentials: 'include' });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setUser(data.authenticated ? data.user : null);
|
||||
} else {
|
||||
setUser(null);
|
||||
}
|
||||
} catch {
|
||||
setUser(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { checkSession(); }, [checkSession]);
|
||||
|
||||
const login = () => {
|
||||
window.location.href = `/api/auth/login?redirect=${encodeURIComponent(window.location.pathname)}`;
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/auth/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (res.ok) {
|
||||
const { redirectUrl } = await res.json();
|
||||
setUser(null);
|
||||
window.location.href = redirectUrl || '/';
|
||||
}
|
||||
} catch {
|
||||
setUser(null);
|
||||
window.location.href = '/';
|
||||
}
|
||||
};
|
||||
|
||||
const isAdmin = user?.role === 'admin';
|
||||
const isViewer = !!user;
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, login, logout, isAdmin, isViewer }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// src/auth/ProtectedRoute.jsx
|
||||
import { useEffect } from 'react';
|
||||
import { useAuth } from './AuthContext';
|
||||
|
||||
function LoadingScreen() {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-canvas">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-8 h-8 border-2 border-border border-t-accent rounded-full animate-spin" />
|
||||
<p className="font-mono text-xs text-text-muted uppercase tracking-widest">
|
||||
Verifying session…
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProtectedRoute({ children }) {
|
||||
const { user, loading, login } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !user) login();
|
||||
}, [loading, user, login]);
|
||||
|
||||
if (loading) return <LoadingScreen />;
|
||||
if (!user) return <LoadingScreen />;
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
export function AdminRoute({ children }) {
|
||||
const { user, loading, login, isAdmin } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !user) login();
|
||||
}, [loading, user, login]);
|
||||
|
||||
if (loading) return <LoadingScreen />;
|
||||
if (!user) return <LoadingScreen />;
|
||||
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-canvas">
|
||||
<div className="card p-10 text-center max-w-sm">
|
||||
<p className="font-mono text-crit text-lg mb-2">403</p>
|
||||
<p className="text-text-secondary text-sm">
|
||||
This page requires administrator privileges.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// src/components/charts/RPOGauge.jsx
|
||||
import { formatRpo } from '@/constants/statusMaps';
|
||||
import clsx from 'clsx';
|
||||
|
||||
const R = 52;
|
||||
const CX = 70;
|
||||
const CY = 72;
|
||||
const SW = 10;
|
||||
|
||||
function polarToCartesian(cx, cy, r, angleDeg) {
|
||||
const rad = ((angleDeg - 90) * Math.PI) / 180;
|
||||
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) };
|
||||
}
|
||||
|
||||
function arcPath(cx, cy, r, startAngle, endAngle) {
|
||||
const s = polarToCartesian(cx, cy, r, startAngle);
|
||||
const e = polarToCartesian(cx, cy, r, endAngle);
|
||||
const large = endAngle - startAngle > 180 ? 1 : 0;
|
||||
return `M ${s.x} ${s.y} A ${r} ${r} 0 ${large} 1 ${e.x} ${e.y}`;
|
||||
}
|
||||
|
||||
const START_ANGLE = -210;
|
||||
const END_ANGLE = 30;
|
||||
|
||||
function rpoColor(ratio) {
|
||||
if (ratio === null || ratio === undefined) return { stroke: '#4a6080', text: 'text-text-muted' };
|
||||
if (ratio <= 0.75) return { stroke: '#10b981', text: 'text-ok' };
|
||||
if (ratio <= 1.0) return { stroke: '#f59e0b', text: 'text-warn' };
|
||||
return { stroke: '#ef4444', text: 'text-crit' };
|
||||
}
|
||||
|
||||
export default function RPOGauge({ actualSec, configuredSec, label = 'Actual RPO', size = 140 }) {
|
||||
const ratio = (actualSec && configuredSec) ? Math.min(actualSec / configuredSec, 1.5) : null;
|
||||
const { stroke, text } = rpoColor(ratio);
|
||||
|
||||
const totalAngle = END_ANGLE - START_ANGLE;
|
||||
const fillAngle = ratio !== null
|
||||
? START_ANGLE + (Math.min(ratio, 1) * totalAngle)
|
||||
: START_ANGLE;
|
||||
|
||||
const bgPath = arcPath(CX, CY, R, START_ANGLE, END_ANGLE);
|
||||
const fillPath = ratio !== null ? arcPath(CX, CY, R, START_ANGLE, fillAngle) : null;
|
||||
|
||||
const pct = ratio !== null ? Math.round(ratio * 100) : null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center" style={{ width: size }}>
|
||||
<svg
|
||||
viewBox="0 0 140 100"
|
||||
width={size}
|
||||
height={size * (100 / 140)}
|
||||
className="overflow-visible"
|
||||
>
|
||||
<path d={bgPath} fill="none" stroke="#1e2d47" strokeWidth={SW} strokeLinecap="round" />
|
||||
{fillPath && (
|
||||
<path d={fillPath} fill="none" stroke={stroke} strokeWidth={SW} strokeLinecap="round"
|
||||
style={{ filter: `drop-shadow(0 0 4px ${stroke}60)`, transition: 'all 0.6s ease-out' }}
|
||||
/>
|
||||
)}
|
||||
{fillPath && ratio !== null && (
|
||||
(() => {
|
||||
const tip = polarToCartesian(CX, CY, R, Math.min(fillAngle, END_ANGLE - 0.5));
|
||||
return (
|
||||
<circle cx={tip.x} cy={tip.y} r={4} fill={stroke}
|
||||
style={{ filter: `drop-shadow(0 0 6px ${stroke})` }} />
|
||||
);
|
||||
})()
|
||||
)}
|
||||
<text x={CX} y={CY - 6} textAnchor="middle"
|
||||
fill={stroke}
|
||||
fontSize={actualSec != null ? 18 : 14}
|
||||
fontFamily="JetBrains Mono, monospace"
|
||||
fontWeight="600"
|
||||
>
|
||||
{actualSec != null ? formatRpo(actualSec) : '—'}
|
||||
</text>
|
||||
{configuredSec && (
|
||||
<text x={CX} y={CY + 10} textAnchor="middle"
|
||||
fill="#4a6080" fontSize={8} fontFamily="JetBrains Mono, monospace">
|
||||
/ {formatRpo(configuredSec)} target
|
||||
</text>
|
||||
)}
|
||||
{pct !== null && (
|
||||
<text x={CX} y={CY + 22} textAnchor="middle"
|
||||
fill={stroke} fontSize={9} fontFamily="JetBrains Mono, monospace">
|
||||
{pct > 100
|
||||
? `${pct - 100}% over`
|
||||
: `${100 - pct}% headroom`}
|
||||
</text>
|
||||
)}
|
||||
</svg>
|
||||
<p className="section-title mt-1">{label}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// TODO: Full content to be added
|
||||
// This file is part of the zROC project recreation
|
||||
@@ -0,0 +1,24 @@
|
||||
// src/components/layout/AppShell.jsx
|
||||
import { useState } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import Sidebar from './Sidebar';
|
||||
import TopBar from './TopBar';
|
||||
|
||||
export default function AppShell() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-canvas">
|
||||
{/* Sidebar */}
|
||||
<Sidebar open={sidebarOpen} onToggle={() => setSidebarOpen((v) => !v)} />
|
||||
|
||||
{/* Main area */}
|
||||
<div className="flex flex-col flex-1 min-w-0 overflow-hidden">
|
||||
<TopBar sidebarOpen={sidebarOpen} onMenuToggle={() => setSidebarOpen((v) => !v)} />
|
||||
<main className="flex-1 overflow-y-auto p-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// TODO: Full content to be added
|
||||
// This file is part of the zROC project recreation
|
||||
@@ -0,0 +1,2 @@
|
||||
// TODO: Full content to be added
|
||||
// This file is part of the zROC project recreation
|
||||
@@ -0,0 +1,89 @@
|
||||
// src/constants/statusMaps.js
|
||||
export const VPG_STATUS = {
|
||||
0: { label: 'Initializing', color: 'info', dot: 'info' },
|
||||
1: { label: 'Meeting SLA', color: 'ok', dot: 'ok' },
|
||||
2: { label: 'Not Meeting SLA', color: 'crit', dot: 'crit' },
|
||||
3: { label: 'History Not Meeting SLA',color: 'warn', dot: 'warn' },
|
||||
4: { label: 'RPO Not Meeting SLA', color: 'crit', dot: 'crit' },
|
||||
5: { label: 'Failing Over', color: 'info', dot: 'info' },
|
||||
6: { label: 'Moving', color: 'info', dot: 'info' },
|
||||
7: { label: 'Deleting', color: 'muted', dot: 'idle' },
|
||||
8: { label: 'Recovering', color: 'info', dot: 'info' },
|
||||
9: { label: 'Needs Configuration', color: 'warn', dot: 'warn' },
|
||||
};
|
||||
|
||||
export const VPG_ALERT = {
|
||||
0: { label: 'No Alert', color: 'ok' },
|
||||
1: { label: 'Warning', color: 'warn' },
|
||||
2: { label: 'Error', color: 'crit' },
|
||||
};
|
||||
|
||||
export const VM_STATUS = {
|
||||
0: { label: 'Protected', color: 'ok' },
|
||||
1: { label: 'Initializing', color: 'info' },
|
||||
2: { label: 'Replication Paused', color: 'warn' },
|
||||
3: { label: 'Error', color: 'crit' },
|
||||
4: { label: 'Empty Protection Group', color: 'muted'},
|
||||
5: { label: 'Disconnected', color: 'crit' },
|
||||
6: { label: 'Backing Up', color: 'info' },
|
||||
7: { label: 'Preparing Failover', color: 'info' },
|
||||
8: { label: 'Failing Over', color: 'info' },
|
||||
9: { label: 'Move Failed', color: 'crit' },
|
||||
};
|
||||
|
||||
export function vpgHealth(statusCode) {
|
||||
const s = VPG_STATUS[statusCode] ?? { label: 'Unknown', color: 'muted', dot: 'idle' };
|
||||
return s;
|
||||
}
|
||||
|
||||
export function isVpgAlerting(statusCode) {
|
||||
return [2, 4].includes(statusCode);
|
||||
}
|
||||
|
||||
export function isVpgWarning(statusCode) {
|
||||
return [3, 9].includes(statusCode);
|
||||
}
|
||||
|
||||
export const colorToText = {
|
||||
ok: 'text-ok',
|
||||
warn: 'text-warn',
|
||||
crit: 'text-crit',
|
||||
info: 'text-info',
|
||||
muted: 'text-text-muted',
|
||||
};
|
||||
|
||||
export const colorToBg = {
|
||||
ok: 'bg-ok/10',
|
||||
warn: 'bg-warn/10',
|
||||
crit: 'bg-crit/10',
|
||||
info: 'bg-info/10',
|
||||
muted: 'bg-raised',
|
||||
};
|
||||
|
||||
export function formatRpo(seconds) {
|
||||
if (seconds == null || isNaN(seconds)) return '—';
|
||||
const s = Math.round(seconds);
|
||||
if (s < 60) return `${s}s`;
|
||||
if (s < 3600) return `${Math.floor(s / 60)}m ${String(s % 60).padStart(2,'0')}s`;
|
||||
return `${Math.floor(s / 3600)}h ${String(Math.floor((s % 3600) / 60)).padStart(2,'0')}m`;
|
||||
}
|
||||
|
||||
export function formatBytes(bytes, decimals = 1) {
|
||||
if (!bytes || bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(decimals))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
export function formatMB(mb) {
|
||||
return formatBytes((mb ?? 0) * 1024 * 1024);
|
||||
}
|
||||
|
||||
export function rpoStatus(actualSec, configuredSec) {
|
||||
if (!actualSec || !configuredSec) return 'muted';
|
||||
const ratio = actualSec / configuredSec;
|
||||
if (ratio <= 0.75) return 'ok';
|
||||
if (ratio <= 1.0) return 'warn';
|
||||
return 'crit';
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// src/hooks/useInstantQuery.js
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { instantQuery } from '@/api/prometheus';
|
||||
|
||||
export function useInstantQuery(promql, {
|
||||
refreshMs = 30_000,
|
||||
enabled = true,
|
||||
select,
|
||||
} = {}) {
|
||||
return useQuery({
|
||||
queryKey: ['instant', promql],
|
||||
queryFn: () => instantQuery(promql),
|
||||
refetchInterval: refreshMs,
|
||||
enabled: enabled && !!promql,
|
||||
select,
|
||||
staleTime: refreshMs / 2,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// src/hooks/useRangeQuery.js
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { rangeQuery } from '@/api/prometheus';
|
||||
|
||||
const WINDOW_SECONDS = {
|
||||
'1h': 3600,
|
||||
'6h': 21600,
|
||||
'24h': 86400,
|
||||
'7d': 604800,
|
||||
'30d': 2592000,
|
||||
};
|
||||
|
||||
const STEP_FOR_WINDOW = {
|
||||
'1h': '30s',
|
||||
'6h': '120s',
|
||||
'24h': '300s',
|
||||
'7d': '900s',
|
||||
'30d': '3600s',
|
||||
};
|
||||
|
||||
export function useRangeQuery(promql, {
|
||||
window = '6h',
|
||||
refreshMs = 60_000,
|
||||
enabled = true,
|
||||
select,
|
||||
} = {}) {
|
||||
const windowSec = WINDOW_SECONDS[window] ?? 21600;
|
||||
const step = STEP_FOR_WINDOW[window] ?? '120s';
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['range', promql, window],
|
||||
queryFn: () => {
|
||||
const end = Math.floor(Date.now() / 1000);
|
||||
const start = end - windowSec;
|
||||
return rangeQuery(promql, start, end, step);
|
||||
},
|
||||
refetchInterval: refreshMs,
|
||||
enabled: enabled && !!promql,
|
||||
select,
|
||||
staleTime: refreshMs / 2,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// src/main.jsx
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import '@/styles/index.css';
|
||||
import App from './App';
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,2 @@
|
||||
// TODO: Full content to be added
|
||||
// This file is part of the zROC project recreation
|
||||
@@ -0,0 +1,2 @@
|
||||
// TODO: Full content to be added
|
||||
// This file is part of the zROC project recreation
|
||||
@@ -0,0 +1,21 @@
|
||||
// src/pages/Placeholder.jsx
|
||||
// Temporary placeholder used for pages not yet built (Phase 2+).
|
||||
// Displays a construction card so navigation works from day one.
|
||||
import { Construction } from 'lucide-react';
|
||||
|
||||
export default function Placeholder({ title, description }) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full animate-fade-in">
|
||||
<div className="card p-12 text-center max-w-md">
|
||||
<div className="w-14 h-14 rounded-xl bg-accent/10 flex items-center justify-center mx-auto mb-5">
|
||||
<Construction size={24} className="text-accent" />
|
||||
</div>
|
||||
<h2 className="font-mono text-base font-semibold text-text-primary mb-2">{title}</h2>
|
||||
<p className="text-sm text-text-muted leading-relaxed">{description}</p>
|
||||
<p className="mt-4 text-xs font-mono text-text-muted border border-border rounded px-3 py-1.5 inline-block">
|
||||
Phase 2 — Coming next
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// TODO: Full content to be added
|
||||
// This file is part of the zROC project recreation
|
||||
@@ -0,0 +1,2 @@
|
||||
// TODO: Full content to be added
|
||||
// This file is part of the zROC project recreation
|
||||
@@ -0,0 +1,2 @@
|
||||
// TODO: Full content to be added
|
||||
// This file is part of the zROC project recreation
|
||||
@@ -0,0 +1,2 @@
|
||||
// TODO: Full content to be added
|
||||
// This file is part of the zROC project recreation
|
||||
@@ -0,0 +1,2 @@
|
||||
// TODO: Full content to be added
|
||||
// This file is part of the zROC project recreation
|
||||
@@ -0,0 +1,99 @@
|
||||
/* src/styles/index.css */
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
html { @apply scroll-smooth; }
|
||||
|
||||
body {
|
||||
@apply bg-canvas text-text-primary font-sans;
|
||||
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
|
||||
}
|
||||
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { @apply bg-surface; }
|
||||
::-webkit-scrollbar-thumb { @apply bg-border-bright rounded-full; }
|
||||
::-webkit-scrollbar-thumb:hover { @apply bg-accent; }
|
||||
|
||||
*:focus-visible {
|
||||
@apply outline-none ring-1 ring-accent ring-offset-2 ring-offset-canvas;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.data-value {
|
||||
@apply font-data tabular-nums;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
@apply inline-block w-2 h-2 rounded-full flex-shrink-0;
|
||||
}
|
||||
.status-dot-ok { @apply bg-ok shadow-glow-ok animate-pulse-led; }
|
||||
.status-dot-warn { @apply bg-warn; }
|
||||
.status-dot-crit { @apply bg-crit shadow-glow-crit animate-pulse-led; }
|
||||
.status-dot-idle { @apply bg-text-muted; }
|
||||
|
||||
.card {
|
||||
@apply bg-surface border border-border rounded-lg;
|
||||
}
|
||||
.card-raised {
|
||||
@apply bg-raised border border-border rounded-lg shadow-panel;
|
||||
}
|
||||
|
||||
.table-row-hover {
|
||||
@apply hover:bg-raised transition-colors duration-100 cursor-pointer;
|
||||
}
|
||||
|
||||
.field {
|
||||
@apply w-full bg-canvas border border-border rounded-md px-3 py-2
|
||||
text-sm text-text-primary placeholder-text-muted
|
||||
focus:border-accent focus:ring-0
|
||||
transition-colors duration-150;
|
||||
}
|
||||
.field-label {
|
||||
@apply block text-xs font-mono uppercase tracking-widest text-text-muted mb-1.5;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply inline-flex items-center gap-2 px-4 py-2 rounded-md
|
||||
bg-accent hover:bg-accent-bright text-white text-sm font-medium
|
||||
shadow-glow-sm hover:shadow-glow
|
||||
transition-all duration-150 disabled:opacity-40 disabled:cursor-not-allowed;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
@apply inline-flex items-center gap-2 px-4 py-2 rounded-md
|
||||
bg-transparent hover:bg-raised text-text-secondary hover:text-text-primary
|
||||
border border-border hover:border-border-bright text-sm font-medium
|
||||
transition-all duration-150;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply inline-flex items-center gap-2 px-4 py-2 rounded-md
|
||||
bg-crit/10 hover:bg-crit/20 text-crit border border-crit/30 text-sm font-medium
|
||||
transition-all duration-150;
|
||||
}
|
||||
|
||||
.badge {
|
||||
@apply inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-mono font-medium;
|
||||
}
|
||||
.badge-ok { @apply bg-ok/10 text-ok border border-ok/20; }
|
||||
.badge-warn { @apply bg-warn/10 text-warn border border-warn/20; }
|
||||
.badge-crit { @apply bg-crit/10 text-crit border border-crit/20; }
|
||||
.badge-info { @apply bg-info/10 text-info border border-info/20; }
|
||||
.badge-muted { @apply bg-raised text-text-muted border border-border; }
|
||||
|
||||
.section-title {
|
||||
@apply font-mono text-xs uppercase tracking-widest text-text-muted;
|
||||
}
|
||||
|
||||
.drawer-overlay {
|
||||
@apply fixed inset-0 bg-canvas/60 backdrop-blur-sm z-40;
|
||||
}
|
||||
.drawer-panel {
|
||||
@apply fixed top-0 right-0 h-full w-full max-w-lg bg-surface
|
||||
border-l border-border shadow-panel z-50
|
||||
animate-slide-in-right flex flex-col;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user