Add full Oculog codebase

This commit is contained in:
shump
2026-02-12 14:52:37 -06:00
parent 49d8d01643
commit 5c6a17abf3
68 changed files with 34218 additions and 0 deletions

View File

@@ -0,0 +1,8 @@
node_modules
npm-debug.log
.env
.git
.gitignore
README.md
build

View File

@@ -0,0 +1,19 @@
FROM node:18-alpine
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm install
# Copy application code
COPY . .
# Expose port
EXPOSE 3000
# Start the development server
CMD ["npm", "start"]

17491
server/frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,37 @@
{
"name": "oculog-frontend",
"version": "1.0.0",
"description": "Frontend dashboard for Oculog metrics observability platform",
"private": true,
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-scripts": "5.0.1",
"axios": "^1.6.0",
"recharts": "^2.10.3"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

View File

@@ -0,0 +1,89 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5, user-scalable=yes, viewport-fit=cover" />
<meta name="theme-color" content="#fc2922" />
<meta name="description" content="Oculog - Server Metrics Observability Platform" />
<link rel="icon" type="image/png" href="/oculog-logo.png" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<title>Oculog - Server Metrics Dashboard</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script>
// Suppress external script errors before React loads
(function() {
const originalError = window.onerror;
window.onerror = function(message, source, lineno, colno, error) {
// Suppress errors from browser extensions and external scripts
if (
message === 'Script error.' ||
(typeof message === 'string' && (
message.includes('ethereum') ||
message.includes('web3') ||
message.includes('window.ethereum') ||
message.includes('selectedAddress') ||
message.includes('undefined is not an object')
)) ||
(source && (
source.includes('chrome-extension://') ||
source.includes('moz-extension://') ||
source.includes('safari-extension://') ||
source === ''
))
) {
return true; // Suppress the error
}
// Call original handler if it exists
if (originalError) {
return originalError.apply(this, arguments);
}
return false;
};
// Also suppress React error overlay by intercepting its creation
const originalCreateElement = document.createElement;
document.createElement = function(tagName) {
const element = originalCreateElement.call(document, tagName);
// If creating an iframe that might be the error overlay
if (tagName.toLowerCase() === 'iframe') {
const observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
if (mutation.type === 'attributes' && mutation.attributeName === 'src') {
const src = element.getAttribute('src') || '';
if (src.includes('react-error-overlay')) {
// Check if it's an external error
setTimeout(function() {
try {
const iframeDoc = element.contentDocument || element.contentWindow?.document;
if (iframeDoc) {
const iframeText = iframeDoc.body?.textContent || '';
if (
iframeText.includes('Script error') ||
iframeText.includes('ethereum') ||
iframeText.includes('selectedAddress')
) {
element.remove();
}
}
} catch (e) {
// Cross-origin, can't access
}
}, 100);
}
}
});
});
observer.observe(element, { attributes: true, attributeFilter: ['src'] });
}
return element;
};
})();
</script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 533 KiB

298
server/frontend/src/App.css Normal file
View File

@@ -0,0 +1,298 @@
.app {
min-height: 100vh;
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
overscroll-behavior-y: none;
}
.left-sidebar-nav {
position: fixed;
left: 0;
top: 0;
width: 70px;
height: 100vh;
background: rgba(15, 23, 42, 0.95);
backdrop-filter: blur(10px);
border-right: 1px solid rgba(252, 41, 34, 0.3);
border-top: 2px solid #fc2922;
display: flex;
flex-direction: column;
align-items: center;
padding: 1rem 0;
gap: 0.5rem;
z-index: 999;
overflow-y: auto;
}
.sidebar-logo-link {
display: flex;
align-items: center;
justify-content: center;
padding: 0.5rem;
transition: all 0.2s;
border-radius: 8px;
}
.sidebar-logo-link:hover {
background: rgba(252, 41, 34, 0.1);
}
.sidebar-logo {
width: 40px;
height: 40px;
object-fit: contain;
}
.nav-icon-button {
background: transparent;
border: 1px solid rgba(252, 41, 34, 0.3);
border-radius: 8px;
padding: 0.75rem;
color: #94a3b8;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
flex-shrink: 0;
}
.nav-icon-button:hover {
background: rgba(252, 41, 34, 0.1);
color: #fc2922;
border-color: rgba(252, 41, 34, 0.5);
transform: scale(1.05);
}
.nav-icon-button.active {
background: #fc2922;
color: white;
border-color: #fc2922;
}
.nav-icon-button.active:hover {
background: #e0241e;
border-color: #e0241e;
}
.nav-icon-button svg {
width: 24px;
height: 24px;
}
.nav-icon-button i {
font-size: 20px;
line-height: 1;
}
.nav-icon-button.download-button {
background: transparent !important;
border: 1px solid rgba(252, 41, 34, 0.3) !important;
color: #e2e8f0 !important;
}
.nav-icon-button.download-button:hover {
background: rgba(252, 41, 34, 0.15) !important;
color: #fc2922 !important;
border-color: rgba(252, 41, 34, 0.6) !important;
}
.nav-icon-button.download-button svg {
width: 24px;
height: 24px;
}
.nav-icon-button.download-button svg path,
.nav-icon-button.download-button svg polyline,
.nav-icon-button.download-button svg line {
stroke: currentColor;
fill: none;
}
.nav-divider {
width: 32px;
height: 1px;
background: rgba(252, 41, 34, 0.3);
margin: 0.5rem 0;
}
.app-content {
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
padding-left: calc(2rem + 70px); /* Account for left sidebar */
display: grid;
grid-template-columns: 250px 1fr;
gap: 2rem;
box-sizing: border-box;
width: 100%;
}
@media (max-width: 768px) {
.app-content {
grid-template-columns: 1fr;
padding: 1rem;
padding-left: calc(1rem + 60px);
gap: 1rem;
}
}
@media (max-width: 480px) {
.app-content {
padding: 0.75rem;
padding-left: calc(0.75rem + 56px);
gap: 0.75rem;
}
}
.loading {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
font-size: 1.5rem;
color: #94a3b8;
}
.error-banner {
background: #ef4444;
color: white;
padding: 1rem;
text-align: center;
font-weight: 500;
position: relative;
z-index: 100;
opacity: 1;
}
.app-content-full {
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
padding-left: calc(2rem + 70px); /* Account for left sidebar */
box-sizing: border-box;
width: 100%;
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
padding: 2rem;
}
.modal-content {
background: rgba(30, 41, 59, 0.98);
border-radius: 12px;
padding: 2rem;
max-width: 600px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
position: relative;
border: 1px solid rgba(252, 41, 34, 0.3);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
.modal-close {
position: absolute;
top: 1rem;
right: 1rem;
background: transparent;
border: 1px solid rgba(148, 163, 184, 0.3);
border-radius: 6px;
padding: 0.5rem;
color: #94a3b8;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
}
.modal-close:hover {
background: rgba(252, 41, 34, 0.1);
color: #fc2922;
border-color: rgba(252, 41, 34, 0.5);
}
.modal-close svg {
width: 18px;
height: 18px;
}
@media (max-width: 768px) {
.left-sidebar-nav {
width: 60px;
}
.sidebar-logo {
width: 36px;
height: 36px;
}
.nav-icon-button {
width: 44px;
height: 44px;
padding: 0.625rem;
}
.nav-icon-button svg {
width: 20px;
height: 20px;
}
.app-content,
.app-content-full {
padding-left: calc(1rem + 60px);
}
.modal-overlay {
padding: 1rem;
}
.modal-content {
padding: 1.5rem;
}
}
@media (max-width: 480px) {
.left-sidebar-nav {
width: 56px;
}
.sidebar-logo {
width: 32px;
height: 32px;
}
.nav-icon-button {
width: 40px;
height: 40px;
padding: 0.5rem;
}
.nav-icon-button svg {
width: 18px;
height: 18px;
}
.app-content,
.app-content-full {
padding-left: calc(0.75rem + 56px);
}
}

324
server/frontend/src/App.js Normal file
View File

@@ -0,0 +1,324 @@
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import './App.css';
import MetricsDashboard from './components/MetricsDashboard';
import ServerList from './components/ServerList';
import ClientDownload from './components/ClientDownload';
import SyntheticMonitors from './components/SyntheticMonitors';
import Alerting from './components/Alerting';
import Security from './components/Security';
import SecurityDashboard from './components/SecurityDashboard';
import Wiki from './components/Wiki';
const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:3001';
function App() {
const [activeView, setActiveView] = useState('servers-metrics'); // 'servers-metrics' or 'synthetic-monitors'
const [servers, setServers] = useState([]);
const [selectedServer, setSelectedServer] = useState(null);
const [metrics, setMetrics] = useState({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [showDownload, setShowDownload] = useState(false);
const [timeRange, setTimeRange] = useState('1h');
useEffect(() => {
fetchServers();
if (activeView === 'servers-metrics') {
const interval = setInterval(() => {
fetchServers();
if (selectedServer) {
fetchMetrics(selectedServer, timeRange);
} else {
fetchLatestMetrics();
}
}, 5000); // Refresh every 5 seconds
return () => clearInterval(interval);
}
}, [selectedServer, timeRange, activeView]);
const fetchServers = async () => {
try {
const response = await axios.get(`${API_URL}/api/servers`);
const sortedServers = [...response.data.servers].sort((a, b) =>
a.localeCompare(b, undefined, { sensitivity: 'base' })
);
setServers(sortedServers);
setLoading(false);
setError(null);
} catch (err) {
setError('Failed to fetch servers');
setLoading(false);
console.error('Error fetching servers:', err);
}
};
const calculateStartTime = (range) => {
const now = new Date();
let startTime = new Date(now);
switch (range) {
case '5m':
startTime.setTime(now.getTime() - (5 * 60 * 1000));
break;
case '15m':
startTime.setTime(now.getTime() - (15 * 60 * 1000));
break;
case '30m':
startTime.setTime(now.getTime() - (30 * 60 * 1000));
break;
case '1h':
startTime.setTime(now.getTime() - (60 * 60 * 1000));
break;
case '3h':
startTime.setTime(now.getTime() - (3 * 60 * 60 * 1000));
break;
case '6h':
startTime.setTime(now.getTime() - (6 * 60 * 60 * 1000));
break;
case '12h':
startTime.setTime(now.getTime() - (12 * 60 * 60 * 1000));
break;
case '1d':
startTime.setTime(now.getTime() - (24 * 60 * 60 * 1000));
break;
case '3d':
startTime.setTime(now.getTime() - (3 * 24 * 60 * 60 * 1000));
break;
case '1w':
startTime.setTime(now.getTime() - (7 * 24 * 60 * 60 * 1000));
break;
case '2w':
startTime.setTime(now.getTime() - (14 * 24 * 60 * 60 * 1000));
break;
case '1mo':
// Approximate 30 days
startTime.setTime(now.getTime() - (30 * 24 * 60 * 60 * 1000));
break;
default:
startTime.setTime(now.getTime() - (60 * 60 * 1000)); // Default to 1 hour
}
return startTime.toISOString();
};
const calculateLimit = (range) => {
// Calculate approximate number of data points needed
// Assuming metrics are collected every 30 seconds
const pointsPerHour = 120; // 3600 seconds / 30 seconds
switch (range) {
case '5m':
return 10; // 5 minutes / 30 seconds = ~10 points
case '15m':
return 30; // 15 minutes / 30 seconds = 30 points
case '30m':
return 60; // 30 minutes / 30 seconds = 60 points
case '1h':
return pointsPerHour; // 120
case '3h':
return pointsPerHour * 3; // 360
case '6h':
return pointsPerHour * 6; // 720
case '12h':
return pointsPerHour * 12; // 1440
case '1d':
return pointsPerHour * 24; // 2880
case '3d':
return pointsPerHour * 72; // 8640
case '1w':
return pointsPerHour * 168; // 20160
case '2w':
return pointsPerHour * 336; // 40320
case '1mo':
return pointsPerHour * 720; // 86400 (30 days)
default:
return 1000;
}
};
const fetchMetrics = async (serverId, range = '3h') => {
try {
const startTime = calculateStartTime(range);
const limit = calculateLimit(range);
const response = await axios.get(`${API_URL}/api/servers/${serverId}/metrics`, {
params: {
startTime: startTime,
limit: limit
}
});
setMetrics({ [serverId]: response.data.metrics });
setError(null);
} catch (err) {
setError(`Failed to fetch metrics for ${serverId}`);
console.error('Error fetching metrics:', err);
}
};
const fetchLatestMetrics = async () => {
try {
const response = await axios.get(`${API_URL}/api/metrics/latest`);
setMetrics(response.data.metrics);
setError(null);
} catch (err) {
setError('Failed to fetch latest metrics');
console.error('Error fetching latest metrics:', err);
}
};
const handleServerSelect = (serverId) => {
setSelectedServer(serverId);
fetchMetrics(serverId, timeRange);
};
const handleTimeRangeChange = (newRange) => {
setTimeRange(newRange);
if (selectedServer) {
fetchMetrics(selectedServer, newRange);
}
};
const handleViewAll = () => {
setSelectedServer(null);
fetchLatestMetrics();
};
if (loading) {
return (
<div className="app">
<div className="loading">Loading...</div>
</div>
);
}
return (
<div className="app">
<nav className="left-sidebar-nav">
<a href="https://ormentia.com" className="sidebar-logo-link">
<img src="/oculog-logo.png" alt="Oculog Logo" className="sidebar-logo" />
</a>
<div className="nav-divider"></div>
<button
className={`nav-icon-button ${activeView === 'servers-metrics' ? 'active' : ''}`}
onClick={() => setActiveView('servers-metrics')}
title="Servers Metrics"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2" y="3" width="20" height="4" rx="1" stroke="currentColor" strokeWidth="2" fill="none"/>
<rect x="2" y="10" width="20" height="4" rx="1" stroke="currentColor" strokeWidth="2" fill="none"/>
<rect x="2" y="17" width="20" height="4" rx="1" stroke="currentColor" strokeWidth="2" fill="none"/>
</svg>
</button>
<button
className={`nav-icon-button ${activeView === 'synthetic-monitors' ? 'active' : ''}`}
onClick={() => setActiveView('synthetic-monitors')}
title="Synthetic Monitors"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 3v18h18" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M7 12l4-4 4 4 6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
<button
className={`nav-icon-button ${activeView === 'alerting' ? 'active' : ''}`}
onClick={() => setActiveView('alerting')}
title="Alerting"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M13.73 21a2 2 0 0 1-3.46 0" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
<button
className={`nav-icon-button ${activeView === 'security' ? 'active' : ''}`}
onClick={() => setActiveView('security')}
title="Security"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
<button
className={`nav-icon-button ${activeView === 'wiki' ? 'active' : ''}`}
onClick={() => setActiveView('wiki')}
title="Wiki"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
<div className="nav-divider"></div>
<button
className="nav-icon-button download-button"
onClick={() => setShowDownload(true)}
title="Download Client"
>
<i className="fas fa-plus"></i>
</button>
</nav>
{error && <div className="error-banner">{error}</div>}
{showDownload && (
<div className="modal-overlay" onClick={() => setShowDownload(false)}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<button className="modal-close" onClick={() => setShowDownload(false)}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<line x1="18" y1="6" x2="6" y2="18" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
<line x1="6" y1="6" x2="18" y2="18" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
</svg>
</button>
<ClientDownload onClose={() => setShowDownload(false)} />
</div>
</div>
)}
{activeView === 'servers-metrics' && (
<div className="app-content">
<ServerList
servers={servers}
selectedServer={selectedServer}
onSelect={handleServerSelect}
onViewAll={handleViewAll}
/>
<MetricsDashboard
metrics={metrics}
selectedServer={selectedServer}
timeRange={timeRange}
onTimeRangeChange={handleTimeRangeChange}
/>
</div>
)}
{activeView === 'synthetic-monitors' && (
<div className="app-content-full">
<SyntheticMonitors />
</div>
)}
{activeView === 'alerting' && (
<div className="app-content-full">
<Alerting />
</div>
)}
{activeView === 'security' && (
<div className="app-content-full">
<SecurityDashboard />
</div>
)}
{activeView === 'wiki' && (
<div className="app-content-full">
<Wiki />
</div>
)}
</div>
);
}
export default App;

View File

@@ -0,0 +1,399 @@
.alerting {
padding: 2rem;
max-width: 1400px;
margin: 0 auto;
}
.alerting-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.alerting-header h2 {
margin: 0;
color: #f1f5f9;
font-size: 1.875rem;
}
.add-policy-button {
background: #fc2922;
border: none;
border-radius: 6px;
padding: 0.75rem 1.5rem;
color: white;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.add-policy-button:hover {
background: #e0241e;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(252, 41, 34, 0.4);
}
.alerting-tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 2rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
}
.alerting-tabs .tab-button {
background: transparent;
border: none;
border-bottom: 2px solid transparent;
padding: 0.75rem 1.5rem;
color: #94a3b8;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.alerting-tabs .tab-button:hover {
color: #f1f5f9;
border-bottom-color: rgba(252, 41, 34, 0.5);
}
.alerting-tabs .tab-button.active {
color: #fc2922;
border-bottom-color: #fc2922;
}
.error-message {
background: #ef4444;
color: white;
padding: 1rem;
border-radius: 6px;
margin-bottom: 1rem;
}
.no-policies,
.no-alerts {
text-align: center;
padding: 4rem 2rem;
color: #94a3b8;
}
.no-policies p,
.no-alerts p {
font-size: 1.125rem;
margin-bottom: 1.5rem;
}
.policies-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 1.5rem;
}
.policy-card {
background: rgba(30, 41, 59, 0.8);
border: 1px solid rgba(252, 41, 34, 0.2);
border-radius: 8px;
padding: 1.5rem;
transition: all 0.2s;
}
.policy-card:hover {
border-color: rgba(252, 41, 34, 0.4);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.policy-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1rem;
}
.policy-title {
flex: 1;
}
.policy-title h3 {
margin: 0 0 0.5rem 0;
color: #f1f5f9;
font-size: 1.125rem;
}
.status-badge {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.status-badge.enabled {
background: rgba(16, 185, 129, 0.2);
color: #10b981;
}
.status-badge.disabled {
background: rgba(148, 163, 184, 0.2);
color: #94a3b8;
}
.policy-actions {
display: flex;
gap: 0.5rem;
}
.toggle-button,
.edit-button,
.delete-button {
background: transparent;
border: 1px solid rgba(148, 163, 184, 0.3);
border-radius: 4px;
padding: 0.375rem 0.625rem;
color: #94a3b8;
cursor: pointer;
transition: all 0.2s;
font-size: 1rem;
}
.toggle-button:hover {
border-color: #10b981;
color: #10b981;
}
.edit-button:hover {
border-color: #3b82f6;
color: #3b82f6;
}
.delete-button:hover {
border-color: #ef4444;
color: #ef4444;
}
.policy-details {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.detail-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.detail-label {
color: #94a3b8;
font-size: 0.875rem;
}
.detail-value {
color: #f1f5f9;
font-size: 0.875rem;
font-weight: 500;
text-align: right;
word-break: break-word;
}
.alerts-history {
margin-top: 1rem;
}
.alerts-table-container {
overflow-x: auto;
background: rgba(30, 41, 59, 0.8);
border-radius: 8px;
border: 1px solid rgba(252, 41, 34, 0.2);
}
.alerts-table {
width: 100%;
border-collapse: collapse;
}
.alerts-table thead {
background: rgba(15, 23, 42, 0.8);
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
}
.alerts-table th {
padding: 1rem;
text-align: left;
color: #f1f5f9;
font-size: 0.875rem;
font-weight: 600;
text-transform: uppercase;
}
.alerts-table td {
padding: 1rem;
color: #94a3b8;
font-size: 0.875rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
}
.alerts-table tbody tr:hover {
background: rgba(252, 41, 34, 0.05);
}
.alerts-table tbody tr.alert-active {
background: rgba(239, 68, 68, 0.1);
}
.alerts-table tbody tr.alert-active td {
color: #f1f5f9;
}
.alert-badge {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.alert-badge.active {
background: rgba(239, 68, 68, 0.2);
color: #ef4444;
}
.alert-badge.resolved {
background: rgba(16, 185, 129, 0.2);
color: #10b981;
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
background: #1e293b;
border: 1px solid rgba(252, 41, 34, 0.3);
border-radius: 8px;
padding: 2rem;
max-width: 500px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
}
.modal-content h3 {
margin: 0 0 1.5rem 0;
color: #f1f5f9;
font-size: 1.5rem;
}
.form-group {
margin-bottom: 1.25rem;
}
.form-group label {
display: block;
color: #f1f5f9;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.5rem;
}
.form-group input[type="text"],
.form-group input[type="number"],
.form-group select {
width: 100%;
padding: 0.75rem;
background: rgba(15, 23, 42, 0.8);
border: 1px solid rgba(148, 163, 184, 0.3);
border-radius: 6px;
color: #f1f5f9;
font-size: 0.875rem;
box-sizing: border-box;
}
.form-group input[type="text"]:focus,
.form-group input[type="number"]:focus,
.form-group select:focus {
outline: none;
border-color: #fc2922;
}
.form-group input[type="checkbox"] {
margin-right: 0.5rem;
}
.form-actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
margin-top: 2rem;
}
.form-actions button {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.form-actions button[type="button"] {
background: transparent;
color: #94a3b8;
border: 1px solid rgba(148, 163, 184, 0.3);
}
.form-actions button[type="button"]:hover {
background: rgba(148, 163, 184, 0.1);
border-color: rgba(148, 163, 184, 0.5);
}
.form-actions button[type="submit"] {
background: #fc2922;
color: white;
}
.form-actions button[type="submit"]:hover {
background: #e0241e;
}
@media (max-width: 768px) {
.alerting {
padding: 1rem;
}
.alerting-header {
flex-direction: column;
align-items: stretch;
gap: 1rem;
}
.policies-grid {
grid-template-columns: 1fr;
}
.alerts-table-container {
overflow-x: scroll;
}
.alerts-table {
min-width: 800px;
}
.modal-content {
width: 95%;
padding: 1.5rem;
}
}

View File

@@ -0,0 +1,442 @@
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import './Alerting.css';
const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:3001';
function Alerting() {
const [activeTab, setActiveTab] = useState('policies'); // 'policies' or 'history'
const [policies, setPolicies] = useState([]);
const [alerts, setAlerts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [showAddModal, setShowAddModal] = useState(false);
const [editingPolicy, setEditingPolicy] = useState(null);
const [formData, setFormData] = useState({
name: '',
type: 'server_metric', // 'server_metric' or 'synthetic_monitor'
metric_type: 'cpu_high', // cpu_high, ram_high, disk_used, server_not_reporting, client_out_of_date
monitor_id: null, // for synthetic_monitor type
threshold: 80,
server_id: null, // null for all servers, or specific server_id
enabled: true
});
useEffect(() => {
fetchPolicies();
fetchAlerts();
const interval = setInterval(() => {
fetchPolicies();
fetchAlerts();
}, 30000); // Refresh every 30 seconds
return () => clearInterval(interval);
}, []);
const fetchPolicies = async () => {
try {
const response = await axios.get(`${API_URL}/api/alert-policies`);
setPolicies(response.data.policies || []);
setLoading(false);
setError(null);
} catch (err) {
setError('Failed to fetch alert policies');
setLoading(false);
console.error('Error fetching policies:', err);
}
};
const fetchAlerts = async () => {
try {
const response = await axios.get(`${API_URL}/api/alerts`);
setAlerts(response.data.alerts || []);
} catch (err) {
console.error('Error fetching alerts:', err);
}
};
const handleAdd = () => {
setEditingPolicy(null);
setFormData({
name: '',
type: 'server_metric',
metric_type: 'cpu_high',
monitor_id: null,
threshold: 80,
server_id: null,
enabled: true
});
setShowAddModal(true);
};
const handleEdit = (policy) => {
setEditingPolicy(policy);
setFormData({
name: policy.name,
type: policy.type,
metric_type: policy.metric_type || 'cpu_high',
monitor_id: policy.monitor_id || null,
threshold: policy.threshold || 80,
server_id: policy.server_id || null,
enabled: policy.enabled !== false
});
setShowAddModal(true);
};
const handleSubmit = async (e) => {
e.preventDefault();
try {
if (editingPolicy) {
await axios.put(`${API_URL}/api/alert-policies/${editingPolicy.id}`, formData);
} else {
await axios.post(`${API_URL}/api/alert-policies`, formData);
}
setShowAddModal(false);
fetchPolicies();
} catch (err) {
setError(err.response?.data?.error || 'Failed to save policy');
console.error('Error saving policy:', err);
}
};
const handleDelete = async (id) => {
if (!window.confirm('Are you sure you want to delete this alert policy?')) {
return;
}
try {
await axios.delete(`${API_URL}/api/alert-policies/${id}`);
fetchPolicies();
} catch (err) {
setError(err.response?.data?.error || 'Failed to delete policy');
console.error('Error deleting policy:', err);
}
};
const handleToggleEnabled = async (policy) => {
try {
await axios.put(`${API_URL}/api/alert-policies/${policy.id}`, {
...policy,
enabled: !policy.enabled
});
fetchPolicies();
} catch (err) {
setError(err.response?.data?.error || 'Failed to update policy');
console.error('Error updating policy:', err);
}
};
const getMetricTypeLabel = (type, metricType) => {
if (type === 'synthetic_monitor') {
return 'Synthetic Monitor Failure';
}
const labels = {
cpu_high: 'CPU High',
ram_high: 'RAM High',
disk_used: 'Disk Used %',
server_not_reporting: 'Server Not Reporting',
client_out_of_date: 'Client Out of Date'
};
return labels[metricType] || metricType;
};
const getAlertStatusBadge = (alert) => {
if (alert.status === 'active') {
return <span className="alert-badge active">Active</span>;
}
return <span className="alert-badge resolved">Resolved</span>;
};
if (loading) {
return (
<div className="alerting">
<div className="loading">Loading...</div>
</div>
);
}
return (
<div className="alerting">
<div className="alerting-header">
<h2>Alerting</h2>
{activeTab === 'policies' && (
<button className="add-policy-button" onClick={handleAdd}>
+ Add Policy
</button>
)}
</div>
<div className="alerting-tabs">
<button
className={`tab-button ${activeTab === 'policies' ? 'active' : ''}`}
onClick={() => setActiveTab('policies')}
>
Alert Policies
</button>
<button
className={`tab-button ${activeTab === 'history' ? 'active' : ''}`}
onClick={() => setActiveTab('history')}
>
Alert History
</button>
</div>
{error && <div className="error-message">{error}</div>}
{activeTab === 'policies' && (
<>
{policies.length === 0 ? (
<div className="no-policies">
<p>No alert policies configured</p>
<button className="add-policy-button" onClick={handleAdd}>
Add Your First Policy
</button>
</div>
) : (
<div className="policies-grid">
{policies.map((policy) => (
<div key={policy.id} className="policy-card">
<div className="policy-header">
<div className="policy-title">
<h3>{policy.name}</h3>
<span className={`status-badge ${policy.enabled ? 'enabled' : 'disabled'}`}>
{policy.enabled ? 'Enabled' : 'Disabled'}
</span>
</div>
<div className="policy-actions">
<button
className="toggle-button"
onClick={() => handleToggleEnabled(policy)}
title={policy.enabled ? 'Disable' : 'Enable'}
>
{policy.enabled ? '●' : '○'}
</button>
<button
className="edit-button"
onClick={() => handleEdit(policy)}
title="Edit"
>
</button>
<button
className="delete-button"
onClick={() => handleDelete(policy.id)}
title="Delete"
>
×
</button>
</div>
</div>
<div className="policy-details">
<div className="detail-row">
<span className="detail-label">Type:</span>
<span className="detail-value">
{policy.type === 'server_metric' ? 'Server Metric' : 'Synthetic Monitor'}
</span>
</div>
<div className="detail-row">
<span className="detail-label">Condition:</span>
<span className="detail-value">
{getMetricTypeLabel(policy.type, policy.metric_type)}
</span>
</div>
{policy.type === 'server_metric' && policy.metric_type !== 'server_not_reporting' && policy.metric_type !== 'client_out_of_date' && (
<div className="detail-row">
<span className="detail-label">Threshold:</span>
<span className="detail-value">{policy.threshold}%</span>
</div>
)}
{policy.type === 'server_metric' && policy.metric_type === 'server_not_reporting' && (
<div className="detail-row">
<span className="detail-label">Threshold:</span>
<span className="detail-value">{policy.threshold} minutes</span>
</div>
)}
{policy.server_id ? (
<div className="detail-row">
<span className="detail-label">Server:</span>
<span className="detail-value">{policy.server_id}</span>
</div>
) : (
<div className="detail-row">
<span className="detail-label">Scope:</span>
<span className="detail-value">All Servers</span>
</div>
)}
{policy.type === 'synthetic_monitor' && policy.monitor_id && (
<div className="detail-row">
<span className="detail-label">Monitor ID:</span>
<span className="detail-value">{policy.monitor_id}</span>
</div>
)}
{policy.type === 'synthetic_monitor' && !policy.monitor_id && (
<div className="detail-row">
<span className="detail-label">Scope:</span>
<span className="detail-value">All Monitors</span>
</div>
)}
</div>
</div>
))}
</div>
)}
</>
)}
{activeTab === 'history' && (
<div className="alerts-history">
{alerts.length === 0 ? (
<div className="no-alerts">
<p>No alerts in history</p>
</div>
) : (
<div className="alerts-table-container">
<table className="alerts-table">
<thead>
<tr>
<th>Status</th>
<th>Policy</th>
<th>Target</th>
<th>Message</th>
<th>Triggered</th>
<th>Resolved</th>
</tr>
</thead>
<tbody>
{alerts.map((alert) => (
<tr key={alert.id} className={alert.status === 'active' ? 'alert-active' : ''}>
<td>{getAlertStatusBadge(alert)}</td>
<td>{alert.policy_name || 'N/A'}</td>
<td>{alert.target || 'N/A'}</td>
<td>{alert.message}</td>
<td>{new Date(alert.triggered_at).toLocaleString()}</td>
<td>{alert.resolved_at ? new Date(alert.resolved_at).toLocaleString() : '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
{showAddModal && (
<div className="modal-overlay" onClick={() => setShowAddModal(false)}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<h3>{editingPolicy ? 'Edit Alert Policy' : 'Add Alert Policy'}</h3>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>Name</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
placeholder="e.g., High CPU Alert"
/>
</div>
<div className="form-group">
<label>Type</label>
<select
value={formData.type}
onChange={(e) => {
const newType = e.target.value;
setFormData({
...formData,
type: newType,
metric_type: newType === 'server_metric' ? 'cpu_high' : null,
monitor_id: null
});
}}
required
>
<option value="server_metric">Server Metric</option>
<option value="synthetic_monitor">Synthetic Monitor</option>
</select>
</div>
{formData.type === 'server_metric' && (
<>
<div className="form-group">
<label>Metric Condition</label>
<select
value={formData.metric_type}
onChange={(e) => setFormData({ ...formData, metric_type: e.target.value })}
required
>
<option value="cpu_high">CPU High</option>
<option value="ram_high">RAM High</option>
<option value="disk_used">Disk Used %</option>
<option value="server_not_reporting">Server Not Reporting</option>
<option value="client_out_of_date">Client Out of Date</option>
</select>
</div>
{(formData.metric_type === 'cpu_high' || formData.metric_type === 'ram_high' || formData.metric_type === 'disk_used') && (
<div className="form-group">
<label>Threshold (%)</label>
<input
type="number"
value={formData.threshold}
onChange={(e) => setFormData({ ...formData, threshold: parseInt(e.target.value) })}
required
min="0"
max="100"
/>
</div>
)}
{formData.metric_type === 'server_not_reporting' && (
<div className="form-group">
<label>Threshold (minutes)</label>
<input
type="number"
value={formData.threshold}
onChange={(e) => setFormData({ ...formData, threshold: parseInt(e.target.value) })}
required
min="1"
/>
</div>
)}
<div className="form-group">
<label>Server (leave empty for all servers)</label>
<input
type="text"
value={formData.server_id || ''}
onChange={(e) => setFormData({ ...formData, server_id: e.target.value || null })}
placeholder="e.g., server-01"
/>
</div>
</>
)}
{formData.type === 'synthetic_monitor' && (
<div className="form-group">
<label>Monitor ID (leave empty for all monitors)</label>
<input
type="number"
value={formData.monitor_id || ''}
onChange={(e) => setFormData({ ...formData, monitor_id: e.target.value ? parseInt(e.target.value) : null })}
placeholder="e.g., 1"
/>
</div>
)}
<div className="form-group">
<label>
<input
type="checkbox"
checked={formData.enabled}
onChange={(e) => setFormData({ ...formData, enabled: e.target.checked })}
/>
Enabled
</label>
</div>
<div className="form-actions">
<button type="button" onClick={() => setShowAddModal(false)}>
Cancel
</button>
<button type="submit">{editingPolicy ? 'Update' : 'Create'}</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}
export default Alerting;

View File

@@ -0,0 +1,217 @@
.client-download {
padding: 0;
}
.client-download h2 {
font-size: 1.5rem;
color: #e2e8f0;
margin-bottom: 0.5rem;
font-weight: 600;
}
.download-description {
color: #94a3b8;
margin-bottom: 1.5rem;
line-height: 1.6;
}
.download-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-group label {
color: #e2e8f0;
font-weight: 500;
font-size: 0.9rem;
}
.form-group input {
background: rgba(15, 23, 42, 0.6);
border: 1px solid rgba(148, 163, 184, 0.2);
border-radius: 8px;
padding: 0.75rem 1rem;
color: #e2e8f0;
font-size: 1rem;
transition: all 0.2s;
}
.form-group input:focus {
outline: none;
border-color: #fc2922;
box-shadow: 0 0 0 3px rgba(252, 41, 34, 0.1);
}
.form-group small {
color: #64748b;
font-size: 0.85rem;
}
.download-button {
background: #fc2922;
border: none;
border-radius: 8px;
padding: 1rem 2rem;
color: white;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
align-self: flex-start;
}
.download-button:hover:not(:disabled) {
background: #e0241e;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(252, 41, 34, 0.4);
}
.download-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.error-message {
background: rgba(239, 68, 68, 0.2);
border: 1px solid rgba(239, 68, 68, 0.5);
border-radius: 8px;
padding: 1rem;
color: #fca5a5;
}
.success-message {
background: rgba(34, 197, 94, 0.2);
border: 1px solid rgba(34, 197, 94, 0.5);
border-radius: 8px;
padding: 1rem;
color: #86efac;
}
.success-message code {
background: rgba(15, 23, 42, 0.6);
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-family: 'Courier New', monospace;
margin-left: 0.5rem;
}
.installation-instructions {
margin-top: 2rem;
padding-top: 2rem;
border-top: 1px solid rgba(148, 163, 184, 0.1);
}
.installation-instructions h3 {
color: #e2e8f0;
font-size: 1.1rem;
margin-bottom: 1rem;
font-weight: 600;
}
.installation-instructions ol {
color: #94a3b8;
line-height: 2;
padding-left: 1.5rem;
}
.installation-instructions code {
background: rgba(15, 23, 42, 0.8);
border: 1px solid rgba(148, 163, 184, 0.2);
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-family: 'Courier New', monospace;
color: #fc2922;
}
/* Curl Instructions */
.curl-instructions {
margin-top: 2rem;
padding-top: 2rem;
border-top: 1px solid rgba(148, 163, 184, 0.1);
}
.curl-instructions h3 {
color: #e2e8f0;
font-size: 1.1rem;
margin-bottom: 0.75rem;
font-weight: 600;
}
.curl-description {
color: #94a3b8;
margin-bottom: 1rem;
font-size: 0.9rem;
}
.curl-command-container {
display: flex;
align-items: center;
gap: 0.75rem;
background: rgba(15, 23, 42, 0.8);
border: 1px solid rgba(148, 163, 184, 0.2);
border-radius: 8px;
padding: 1rem;
margin-bottom: 0.75rem;
position: relative;
}
.curl-command {
flex: 1;
font-family: 'Courier New', monospace;
color: #fc2922;
font-size: 0.9rem;
word-break: break-all;
line-height: 1.6;
margin: 0;
background: transparent;
border: none;
padding: 0;
}
.copy-button {
background: rgba(252, 41, 34, 0.2);
border: 1px solid rgba(252, 41, 34, 0.3);
border-radius: 6px;
padding: 0.5rem 0.75rem;
color: #fc2922;
cursor: pointer;
transition: all 0.2s;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.copy-button:hover {
background: rgba(252, 41, 34, 0.3);
border-color: rgba(252, 41, 34, 0.5);
transform: translateY(-1px);
}
.copy-button i {
font-size: 0.9rem;
}
.curl-note {
color: #64748b;
font-size: 0.85rem;
line-height: 1.5;
margin-top: 0.5rem;
}
.curl-note code {
background: rgba(15, 23, 42, 0.6);
padding: 0.15rem 0.4rem;
border-radius: 4px;
font-family: 'Courier New', monospace;
color: #fc2922;
font-size: 0.85rem;
}

View File

@@ -0,0 +1,163 @@
import React, { useState } from 'react';
import axios from 'axios';
import './ClientDownload.css';
const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:3001';
function ClientDownload({ onClose }) {
const [serverId, setServerId] = useState('');
const [serverUrl, setServerUrl] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [success, setSuccess] = useState(false);
// Get the base server URL for curl command (where the API is hosted)
const getBaseUrl = () => {
// Use serverUrl if provided, otherwise default to current origin but replace port 3000 with 3001
if (serverUrl) {
return serverUrl;
}
return window.location.origin.replace(':3000', ':3001');
};
// Generate curl command
const getCurlCommand = () => {
const baseUrl = getBaseUrl();
// Include serverUrl query parameter if a custom serverUrl was provided
const queryParam = serverUrl ? `?serverUrl=${encodeURIComponent(serverUrl)}` : '';
return `curl -s ${baseUrl}/api/download-client/${serverId}${queryParam} | bash`;
};
const copyCurlCommand = () => {
navigator.clipboard.writeText(getCurlCommand());
};
const handleDownload = async (e) => {
e.preventDefault();
setLoading(true);
setError(null);
setSuccess(false);
try {
const response = await axios.post(
`${API_URL}/api/download-client`,
{
serverId: serverId || `server-${Date.now()}`,
serverUrl: serverUrl || window.location.origin.replace(':3000', ':3001')
},
{
responseType: 'blob'
}
);
// Create download link
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', `oculog-client-install.sh`);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
setSuccess(true);
setTimeout(() => {
setSuccess(false);
if (onClose) onClose();
}, 3000);
} catch (err) {
setError(err.response?.data?.error || 'Failed to generate client package');
} finally {
setLoading(false);
}
};
return (
<div className="client-download">
<h2>Download Client</h2>
<p className="download-description">
Generate a pre-configured client installer for your Ubuntu server.
The installer includes your server URL and API key automatically configured.
</p>
<form onSubmit={handleDownload} className="download-form">
<div className="form-group">
<label htmlFor="serverId">Server ID</label>
<input
type="text"
id="serverId"
value={serverId}
onChange={(e) => setServerId(e.target.value)}
placeholder="my-ubuntu-server"
required
/>
<small>Unique identifier for this server</small>
</div>
<div className="form-group">
<label htmlFor="serverUrl">Server URL (optional)</label>
<input
type="text"
id="serverUrl"
value={serverUrl}
onChange={(e) => setServerUrl(e.target.value)}
placeholder="http://your-server-ip:3001"
/>
<small>Leave empty to use current server</small>
</div>
{error && <div className="error-message">{error}</div>}
{success && (
<div className="success-message">
Client installer downloaded! Run it on your Ubuntu server with:
<code>sudo bash oculog-client-install.sh</code>
</div>
)}
<button type="submit" disabled={loading} className="download-button">
{loading ? 'Generating...' : 'Download Client Installer'}
</button>
</form>
{/* Curl Installation Instructions */}
<div className="curl-instructions">
<h3>Quick Install with curl</h3>
<p className="curl-description">
Copy and paste this command directly into your Ubuntu server terminal:
</p>
<div className="curl-command-container">
<code className="curl-command">
{serverId ? getCurlCommand() : 'curl -s SERVER_URL/api/download-client/SERVER_ID?serverUrl=SERVER_URL | bash'}
</code>
{serverId && (
<button
type="button"
onClick={copyCurlCommand}
className="copy-button"
title="Copy to clipboard"
>
<i className="fas fa-copy"></i>
</button>
)}
</div>
<p className="curl-note">
Fill in the Server ID field above to generate your custom curl command. The command will automatically use your Server URL if provided.
</p>
</div>
<div className="installation-instructions">
<h3>Manual Installation Instructions</h3>
<ol>
<li>Download the installer script above</li>
<li>Transfer it to your Ubuntu server</li>
<li>Make it executable: <code>chmod +x oculog-client-install.sh</code></li>
<li>Run as root: <code>sudo ./oculog-client-install.sh</code></li>
<li>The client will automatically start and begin sending metrics</li>
</ol>
</div>
</div>
);
}
export default ClientDownload;

View File

@@ -0,0 +1,33 @@
import React from 'react';
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error) {
// Update state so the next render will show the fallback UI
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
// Log error to console in development, but don't show it to users
if (process.env.NODE_ENV === 'development') {
console.error('ErrorBoundary caught an error:', error, errorInfo);
}
}
render() {
if (this.state.hasError) {
// Don't render error UI - just return children or null
// This prevents the error overlay from showing
return this.props.fallback || null;
}
return this.props.children;
}
}
export default ErrorBoundary;

View File

@@ -0,0 +1,93 @@
.metric-card {
background: rgba(15, 23, 42, 0.6);
border: 1px solid rgba(148, 163, 184, 0.1);
border-radius: 10px;
padding: 1.5rem;
transition: all 0.3s;
border-left: 3px solid var(--accent-color, #fc2922);
width: 100%;
box-sizing: border-box;
}
.metric-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
border-color: var(--accent-color, #fc2922);
}
@media (max-width: 480px) {
.metric-card {
padding: 1rem;
border-radius: 8px;
}
.metric-header h3 {
font-size: 0.8rem;
}
.metric-value {
font-size: 1.75rem;
}
.metric-value .unit {
font-size: 0.9rem;
}
.metric-footer {
font-size: 0.75rem;
}
}
@media (hover: none) and (pointer: coarse) {
.metric-card:hover {
transform: none;
}
}
.metric-header {
margin-bottom: 1rem;
}
.metric-header h3 {
font-size: 0.9rem;
color: #94a3b8;
font-weight: 500;
margin-bottom: 0.5rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.metric-value {
font-size: 2rem;
font-weight: 700;
color: var(--accent-color, #fc2922);
line-height: 1;
}
.metric-value .unit {
font-size: 1rem;
color: #94a3b8;
font-weight: 400;
}
.metric-bar {
width: 100%;
height: 6px;
background: rgba(30, 41, 59, 0.8);
border-radius: 3px;
overflow: hidden;
margin-bottom: 0.5rem;
}
.metric-bar-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent-color, #fc2922), var(--accent-color, #fc2922));
border-radius: 3px;
transition: width 0.3s ease;
}
.metric-footer {
font-size: 0.8rem;
color: #64748b;
}

View File

@@ -0,0 +1,37 @@
import React from 'react';
import './MetricCard.css';
function MetricCard({ title, value, max, unit, color }) {
// Convert to number if string (PostgreSQL DECIMAL returns as string)
const numValue = typeof value === 'string' ? parseFloat(value) : (value || 0);
const numMax = max ? (typeof max === 'string' ? parseFloat(max) : max) : null;
const percentage = numMax ? (numValue / numMax) * 100 : numValue;
const displayValue = isNaN(numValue) ? '0.00' : numValue.toFixed(2);
return (
<div className="metric-card" style={{ '--accent-color': color }}>
<div className="metric-header">
<h3>{title}</h3>
<div className="metric-value">
{displayValue} <span className="unit">{unit}</span>
</div>
</div>
{max && (
<div className="metric-bar">
<div
className="metric-bar-fill"
style={{ width: `${Math.min(percentage, 100)}%` }}
/>
</div>
)}
{max && (
<div className="metric-footer">
{isNaN(percentage) ? '0.0' : percentage.toFixed(1)}% of {isNaN(numMax) ? '0.00' : numMax.toFixed(2)} {unit}
</div>
)}
</div>
);
}
export default MetricCard;

View File

@@ -0,0 +1,30 @@
.metrics-chart {
background: rgba(15, 23, 42, 0.4);
border: 1px solid rgba(148, 163, 184, 0.1);
border-radius: 8px;
padding: 1rem;
width: 100%;
box-sizing: border-box;
min-width: 0;
overflow: hidden;
}
.metrics-chart h3 {
font-size: 1rem;
color: #e2e8f0;
margin-bottom: 1rem;
font-weight: 600;
}
@media (max-width: 480px) {
.metrics-chart {
padding: 0.75rem;
border-radius: 6px;
}
.metrics-chart h3 {
font-size: 0.9rem;
margin-bottom: 0.75rem;
}
}

View File

@@ -0,0 +1,158 @@
import React from 'react';
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer
} from 'recharts';
import './MetricsChart.css';
function MetricsChart({ metrics, metricType, title, color, dataKey, unit, diskMountpoint }) {
if (!metrics || metrics.length === 0) {
return null;
}
// Prepare data for chart
// Convert string values to numbers (PostgreSQL DECIMAL returns as string)
const toNumber = (val) => {
if (val == null) return 0;
return typeof val === 'string' ? parseFloat(val) || 0 : (val || 0);
};
// Backend returns metrics in DESC order (newest first)
// Reverse to show oldest->newest (left->right) for the full time range
const sortedMetrics = [...metrics].reverse();
// Check if data spans multiple days for date formatting
let spansMultipleDays = false;
if (sortedMetrics.length > 0) {
const firstTimestamp = new Date(sortedMetrics[0].timestamp);
const lastTimestamp = new Date(sortedMetrics[sortedMetrics.length - 1].timestamp);
const timeDiff = lastTimestamp.getTime() - firstTimestamp.getTime();
spansMultipleDays = timeDiff > 24 * 60 * 60 * 1000; // More than 24 hours
}
const chartData = sortedMetrics.map((metric) => {
let value = 0;
switch (metricType) {
case 'cpu':
value = toNumber(metric.cpu?.usage);
break;
case 'memory':
value = toNumber(metric.memory?.used);
break;
case 'swap':
value = toNumber(metric.swap?.used);
break;
case 'swapPercent':
value = toNumber(metric.swap?.percent);
break;
case 'processCount':
value = toNumber(metric.process_count);
break;
case 'loadAvg1min':
value = toNumber(metric.load_avg?.['1min']);
break;
case 'loadAvg5min':
value = toNumber(metric.load_avg?.['5min']);
break;
case 'loadAvg15min':
value = toNumber(metric.load_avg?.['15min']);
break;
case 'disk':
value = toNumber(metric.disk?.used);
break;
case 'networkRx':
value = toNumber(metric.network?.rx);
break;
case 'networkTx':
value = toNumber(metric.network?.tx);
break;
default:
// Handle disk-specific charts (e.g., disk-/mnt/ormentia-backups)
if (metricType.startsWith('disk-') && diskMountpoint) {
const disks = metric.disk?.disks;
if (disks) {
let disksArray = [];
if (Array.isArray(disks)) {
disksArray = disks;
} else if (typeof disks === 'string') {
try {
disksArray = JSON.parse(disks);
} catch (e) {
console.error('Error parsing disks JSON in chart:', e);
disksArray = [];
}
}
const disk = disksArray.find(d => d.mountpoint === diskMountpoint);
value = disk ? toNumber(disk.used) : 0;
}
} else {
value = 0;
}
}
const timestamp = new Date(metric.timestamp);
// Show date and time if data spans multiple days, otherwise just time
const timeLabel = spansMultipleDays
? timestamp.toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
: timestamp.toLocaleTimeString(undefined, {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
return {
time: timeLabel,
timestamp: timestamp.getTime(), // Keep for sorting if needed
value: value,
};
});
return (
<div className="metrics-chart">
<h3>{title}</h3>
<ResponsiveContainer width="100%" height={250}>
<LineChart data={chartData} margin={{ top: 5, right: 20, left: 10, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(148, 163, 184, 0.1)" />
<XAxis
dataKey="time"
stroke="#94a3b8"
style={{ fontSize: '0.75rem' }}
/>
<YAxis
stroke="#94a3b8"
style={{ fontSize: '0.75rem' }}
label={{ value: unit, angle: -90, position: 'insideLeft', style: { textAnchor: 'middle', fill: '#94a3b8' } }}
/>
<Tooltip
contentStyle={{
backgroundColor: 'rgba(15, 23, 42, 0.95)',
border: '1px solid rgba(148, 163, 184, 0.2)',
borderRadius: '8px',
color: '#e2e8f0'
}}
formatter={(value) => [`${value.toFixed(2)} ${unit}`, title]}
/>
<Line
type="monotone"
dataKey="value"
stroke={color}
strokeWidth={2}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
);
}
export default MetricsChart;

View File

@@ -0,0 +1,862 @@
.metrics-dashboard {
background: rgba(30, 41, 59, 0.6);
border-radius: 12px;
padding: 1.5rem;
backdrop-filter: blur(10px);
border: 1px solid rgba(148, 163, 184, 0.1);
overscroll-behavior-y: none;
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
.server-detail-layout {
display: flex;
gap: 0;
margin: -2rem;
padding: 0;
border-radius: 12px;
overflow: hidden;
flex-direction: row;
height: 100%;
}
.server-detail-content {
flex: 1;
padding: 1.5rem;
overflow-x: hidden;
overflow-y: hidden;
overscroll-behavior-y: none;
min-width: 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
}
.dashboard-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
flex-wrap: wrap;
gap: 0.75rem;
}
.header-controls {
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.time-range-selector {
display: flex;
align-items: center;
gap: 0.375rem;
}
.time-range-selector label {
color: #94a3b8;
font-size: 0.8rem;
font-weight: 500;
}
.time-range-select {
background: rgba(15, 23, 42, 0.8);
border: 1px solid rgba(148, 163, 184, 0.2);
border-radius: 4px;
padding: 0.375rem 0.75rem;
color: #e2e8f0;
font-size: 0.8rem;
cursor: pointer;
transition: all 0.2s;
min-width: 100px;
}
.time-range-select:hover {
border-color: rgba(148, 163, 184, 0.4);
background: rgba(15, 23, 42, 0.9);
}
.time-range-select:focus {
outline: none;
border-color: #60a5fa;
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.1);
}
.time-range-select option {
background: #1e293b;
color: #e2e8f0;
}
.tabs-container {
display: flex;
gap: 0.25rem;
margin-bottom: 1.25rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
scrollbar-color: rgba(148, 163, 184, 0.3) transparent;
}
.tabs-container::-webkit-scrollbar {
height: 4px;
}
.tabs-container::-webkit-scrollbar-track {
background: transparent;
}
.tabs-container::-webkit-scrollbar-thumb {
background: rgba(148, 163, 184, 0.3);
border-radius: 2px;
}
.tab-button {
background: transparent;
border: none;
padding: 0.5rem 1rem;
color: #94a3b8;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
position: relative;
white-space: nowrap;
flex-shrink: 0;
min-height: 44px;
touch-action: manipulation;
}
.tab-button:hover {
color: #e2e8f0;
background: rgba(148, 163, 184, 0.05);
}
.tab-button.active {
color: #60a5fa;
border-bottom-color: #60a5fa;
}
.tab-content {
flex: 1;
min-height: 400px;
overflow-y: auto;
overflow-x: hidden;
padding-right: 0.5rem;
}
.tab-panel {
animation: fadeIn 0.2s ease-in;
}
.client-tab-panel {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.dashboard-header h2 {
font-size: 1.1rem;
color: #e2e8f0;
font-weight: 600;
}
.refresh-indicator {
display: flex;
align-items: center;
gap: 0.375rem;
color: #94a3b8;
font-size: 0.8rem;
}
.pulse-dot {
width: 6px;
height: 6px;
background: #34d399;
border-radius: 50%;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
@media (max-width: 480px) {
.metrics-grid {
grid-template-columns: 1fr;
gap: 1rem;
margin-bottom: 1.5rem;
}
}
.servers-overview-table {
overflow-x: auto;
}
.servers-table {
width: 100%;
border-collapse: collapse;
background: rgba(15, 23, 42, 0.3);
border-radius: 8px;
overflow: hidden;
}
.servers-table thead {
background: rgba(15, 23, 42, 0.6);
}
.servers-table th {
padding: 0.75rem 1rem;
text-align: left;
color: #94a3b8;
font-size: 0.85rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
}
.servers-table th:first-child {
padding-left: 1.25rem;
}
.servers-table th:last-child {
padding-right: 1.25rem;
}
.servers-table tbody tr {
border-bottom: 1px solid rgba(148, 163, 184, 0.05);
transition: background-color 0.2s;
}
.servers-table tbody tr:hover {
background: rgba(30, 41, 59, 0.4);
}
.servers-table tbody tr:last-child {
border-bottom: none;
}
.servers-table td {
padding: 0.875rem 1rem;
color: #e2e8f0;
font-size: 0.9rem;
}
.servers-table td:first-child {
padding-left: 1.25rem;
}
.servers-table td:last-child {
padding-right: 1.25rem;
}
.server-name-cell {
font-weight: 600;
color: #e2e8f0;
}
.metric-cell {
color: #cbd5e1;
}
.table-metric-value {
font-family: 'Courier New', monospace;
font-size: 0.9rem;
color: #cbd5e1;
font-weight: 500;
line-height: 1.4;
}
.version-cell {
color: #94a3b8;
}
.version-value {
font-family: 'Courier New', monospace;
font-size: 0.85rem;
color: #94a3b8;
}
.no-metrics {
padding: 4rem 2rem;
text-align: center;
color: #94a3b8;
}
.no-metrics p {
font-size: 1.1rem;
}
.charts-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 1.5rem;
margin-top: 2rem;
padding-top: 2rem;
border-top: 1px solid rgba(148, 163, 184, 0.1);
}
@media (max-width: 480px) {
.charts-grid {
grid-template-columns: 1fr;
gap: 1rem;
margin-top: 1.5rem;
padding-top: 1.5rem;
}
}
.disks-section {
margin-top: 2rem;
padding-top: 2rem;
border-top: 1px solid rgba(148, 163, 184, 0.1);
}
.disks-section h3 {
font-size: 1.2rem;
color: #e2e8f0;
margin-bottom: 1.5rem;
font-weight: 600;
}
.disks-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.5rem;
}
.disk-card {
background: rgba(15, 23, 42, 0.5);
border: 1px solid rgba(148, 163, 184, 0.1);
border-radius: 8px;
padding: 1.5rem;
transition: border-color 0.2s;
}
.disk-card:hover {
border-color: rgba(148, 163, 184, 0.3);
}
.disk-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
}
.disk-mountpoint {
font-weight: 600;
color: #e2e8f0;
font-size: 1rem;
}
.disk-device {
color: #94a3b8;
font-size: 0.85rem;
font-family: monospace;
}
.disk-info {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
margin-bottom: 1rem;
}
.disk-metric {
display: flex;
justify-content: space-between;
padding: 0.5rem;
background: rgba(30, 41, 59, 0.5);
border-radius: 4px;
}
.disk-metric .label {
color: #94a3b8;
font-size: 0.85rem;
}
.disk-metric .value {
color: #e2e8f0;
font-weight: 600;
font-size: 0.85rem;
}
.disk-metric .value.warning {
color: #ef4444;
}
.disk-bar {
width: 100%;
height: 8px;
background: rgba(30, 41, 59, 0.8);
border-radius: 4px;
overflow: hidden;
margin-top: 0.5rem;
}
.disk-bar-fill {
height: 100%;
background: #f472b6;
transition: width 0.3s ease;
border-radius: 4px;
}
.containers-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem;
}
.container-card {
background: rgba(15, 23, 42, 0.5);
border: 1px solid rgba(148, 163, 184, 0.1);
border-radius: 8px;
padding: 1rem;
transition: border-color 0.2s;
}
.container-card:hover {
border-color: rgba(148, 163, 184, 0.3);
}
.container-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
}
.container-name {
font-weight: 600;
color: #e2e8f0;
font-size: 0.95rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
margin-right: 0.5rem;
}
.container-status-badge {
font-size: 0.7rem;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-weight: 500;
flex-shrink: 0;
}
.container-status-badge.status-running {
background: rgba(34, 211, 153, 0.2);
color: #34d399;
}
.container-status-badge.status-stopped {
background: rgba(239, 68, 68, 0.2);
color: #ef4444;
}
.container-image {
font-size: 0.8rem;
color: #94a3b8;
margin-bottom: 0.5rem;
font-family: 'Courier New', monospace;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.container-id {
font-size: 0.75rem;
color: #64748b;
font-family: 'Courier New', monospace;
}
.client-info-section {
padding: 1.5rem 0;
}
.client-info-title {
font-size: 1.2rem;
color: #e2e8f0;
margin-bottom: 1.5rem;
font-weight: 600;
padding-bottom: 0.75rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
}
.client-info-content {
background: rgba(15, 23, 42, 0.4);
border-radius: 8px;
padding: 1.5rem;
border: 1px solid rgba(148, 163, 184, 0.1);
}
.client-info-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 0;
border-bottom: 1px solid rgba(148, 163, 184, 0.05);
}
.client-info-item:last-child {
border-bottom: none;
}
.client-info-label {
font-size: 0.9rem;
color: #94a3b8;
font-weight: 500;
}
.client-info-value {
font-size: 0.95rem;
color: #e2e8f0;
font-weight: 600;
font-family: 'Courier New', monospace;
}
.client-update-status {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
font-weight: 500;
padding: 0.375rem 0.75rem;
border-radius: 6px;
}
.client-update-status.up-to-date {
color: #22c55e;
background: rgba(34, 197, 94, 0.1);
}
.client-update-status.outdated {
color: #f59e0b;
background: rgba(245, 158, 11, 0.1);
}
.client-update-status .status-icon {
font-size: 1rem;
}
/* Mobile-first responsive breakpoints */
/* Small phones (up to 480px) */
@media (max-width: 480px) {
.metrics-dashboard {
padding: 0.75rem;
border-radius: 8px;
}
.server-detail-layout {
flex-direction: column;
margin: -0.75rem;
border-radius: 8px;
}
.server-detail-content {
padding: 1rem;
}
.dashboard-header {
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
margin-bottom: 0.75rem;
padding-bottom: 0.75rem;
}
.dashboard-header h2 {
font-size: 1rem;
width: 100%;
}
.header-controls {
width: 100%;
justify-content: space-between;
gap: 0.5rem;
}
.time-range-selector {
flex: 1;
min-width: 0;
}
.time-range-selector label {
font-size: 0.75rem;
}
.time-range-select {
font-size: 0.75rem;
padding: 0.5rem 0.625rem;
min-width: 0;
flex: 1;
width: 100%;
}
.refresh-indicator {
font-size: 0.75rem;
}
.tabs-container {
margin-bottom: 1rem;
gap: 0;
}
.tab-button {
padding: 0.625rem 0.75rem;
font-size: 0.8rem;
min-height: 44px;
}
.tab-content {
min-height: 300px;
}
.disks-grid {
grid-template-columns: 1fr;
gap: 1rem;
}
.disk-card {
padding: 1rem;
}
.disk-header {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.disk-info {
grid-template-columns: 1fr;
gap: 0.5rem;
}
.disk-metric {
padding: 0.375rem;
}
.containers-grid {
grid-template-columns: 1fr;
gap: 0.75rem;
}
.container-card {
padding: 0.875rem;
}
.container-header {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.servers-overview-table {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
margin: 0 -0.75rem;
padding: 0 0.75rem;
}
.servers-table {
min-width: 600px;
font-size: 0.8rem;
}
.servers-table th,
.servers-table td {
padding: 0.625rem 0.5rem;
font-size: 0.8rem;
}
.servers-table th:first-child,
.servers-table td:first-child {
padding-left: 0.75rem;
}
.servers-table th:last-child,
.servers-table td:last-child {
padding-right: 0.75rem;
}
.no-metrics {
padding: 2rem 1rem;
}
.no-metrics p {
font-size: 0.95rem;
}
}
/* Tablets and small screens (481px to 768px) */
@media (min-width: 481px) and (max-width: 768px) {
.metrics-dashboard {
padding: 1rem;
}
.server-detail-layout {
flex-direction: column;
margin: -1rem;
}
.server-detail-content {
padding: 1.25rem;
}
.dashboard-header {
gap: 1rem;
}
.charts-grid {
grid-template-columns: 1fr;
gap: 1.25rem;
}
.metrics-grid {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1.25rem;
}
.disks-grid {
grid-template-columns: 1fr;
gap: 1.25rem;
}
.disk-info {
grid-template-columns: repeat(2, 1fr);
}
.containers-grid {
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 1rem;
}
.servers-overview-table {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.servers-table {
min-width: 600px;
}
}
/* Medium screens (769px to 1024px) */
@media (min-width: 769px) and (max-width: 1024px) {
.server-detail-layout {
flex-direction: row;
}
.charts-grid {
grid-template-columns: 1fr;
}
.metrics-grid {
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
}
/* Large screens (1025px+) */
@media (min-width: 1025px) {
.server-detail-layout {
flex-direction: row;
}
}
/* Landscape orientation adjustments for mobile */
@media (max-width: 768px) and (orientation: landscape) {
.server-detail-layout {
flex-direction: row;
}
.tabs-container {
overflow-x: auto;
}
.tab-button {
min-height: 40px;
padding: 0.5rem 0.875rem;
}
}
/* Touch device optimizations */
@media (hover: none) and (pointer: coarse) {
.tab-button {
min-height: 44px;
padding: 0.625rem 1rem;
}
.time-range-select {
min-height: 44px;
padding: 0.5rem 0.75rem;
}
.disk-card:hover,
.container-card:hover {
transform: none;
}
.metric-card:hover {
transform: none;
}
}
/* Scrollbar styling for tab content */
.tab-content::-webkit-scrollbar {
width: 6px;
}
.tab-content::-webkit-scrollbar-track {
background: rgba(15, 23, 42, 0.3);
}
.tab-content::-webkit-scrollbar-thumb {
background: rgba(148, 163, 184, 0.3);
border-radius: 3px;
}
.tab-content::-webkit-scrollbar-thumb:hover {
background: rgba(148, 163, 184, 0.5);
}
.tab-content {
scrollbar-width: thin;
scrollbar-color: rgba(148, 163, 184, 0.3) rgba(15, 23, 42, 0.3);
}

View File

@@ -0,0 +1,783 @@
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import './MetricsDashboard.css';
import './ServerInfoPane.css';
import MetricCard from './MetricCard';
import MetricsChart from './MetricsChart';
import ServerSecurity from './ServerSecurity';
const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:3001';
function MetricsDashboard({ metrics, selectedServer, timeRange, onTimeRangeChange }) {
const [activeTab, setActiveTab] = useState('client');
const [serverInfo, setServerInfo] = useState(null);
const [clientVersion, setClientVersion] = useState(null);
const [latestVersion, setLatestVersion] = useState(null);
const [isOutdated, setIsOutdated] = useState(false);
useEffect(() => {
if (!selectedServer) {
setServerInfo(null);
setClientVersion(null);
setLatestVersion(null);
setIsOutdated(false);
return;
}
const fetchServerInfo = async () => {
try {
const response = await axios.get(`${API_URL}/api/servers/${selectedServer}/info`);
setServerInfo(response.data.serverInfo);
setClientVersion(response.data.clientVersion);
setLatestVersion(response.data.latestVersion);
setIsOutdated(response.data.isOutdated || false);
} catch (err) {
if (err.response?.status !== 404) {
console.error('Error fetching server info:', err);
}
setServerInfo(null);
setClientVersion(null);
setLatestVersion(null);
setIsOutdated(false);
}
};
fetchServerInfo();
const interval = setInterval(fetchServerInfo, 30000);
return () => clearInterval(interval);
}, [selectedServer]);
const timeRangeOptions = [
{ value: '5m', label: '5 Minutes' },
{ value: '15m', label: '15 Minutes' },
{ value: '30m', label: '30 Minutes' },
{ value: '1h', label: '1 Hour' },
{ value: '3h', label: '3 Hours' },
{ value: '6h', label: '6 Hours' },
{ value: '12h', label: '12 Hours' },
{ value: '1d', label: '1 Day' },
{ value: '3d', label: '3 Days' },
{ value: '1w', label: '1 Week' },
{ value: '2w', label: '2 Weeks' },
{ value: '1mo', label: '1 Month' }
];
const displayMetrics = selectedServer
? metrics[selectedServer] || []
: Object.values(metrics);
// Get latest metrics - try to find one with disk data if available
let latestMetrics = selectedServer
? (metrics[selectedServer] || [])[0]
: null;
// If latest metrics don't have disks, try to find a recent metric that does
if (selectedServer && latestMetrics && !latestMetrics.disk?.disks) {
const serverMetrics = metrics[selectedServer] || [];
const metricWithDisks = serverMetrics.find(m => m.disk?.disks && (
Array.isArray(m.disk.disks) || (typeof m.disk.disks === 'string' && m.disk.disks.trim() !== '')
));
if (metricWithDisks) {
// Use the disk data from this metric but keep other data from latest
latestMetrics = {
...latestMetrics,
disk: {
...latestMetrics.disk,
disks: metricWithDisks.disk.disks
}
};
}
}
const allLatestMetrics = !selectedServer
? Object.entries(metrics).map(([serverId, metric]) => ({
serverId,
...(Array.isArray(metric) ? metric[metric.length - 1] : metric)
}))
: [];
// Get all disks from latest metrics
// Handle both array format (from backend) and potential string format
const allDisks = latestMetrics?.disk?.disks;
let disksArray = [];
if (allDisks) {
if (Array.isArray(allDisks)) {
disksArray = allDisks;
} else if (typeof allDisks === 'string' && allDisks.trim() !== '') {
try {
const parsed = JSON.parse(allDisks);
disksArray = Array.isArray(parsed) ? parsed : [];
} catch (e) {
console.error('Error parsing disks JSON:', e);
disksArray = [];
}
}
}
// Debug logging (can be removed later)
if (latestMetrics && !disksArray.length) {
console.log('No disks found. Latest metrics structure:', {
hasDisk: !!latestMetrics.disk,
hasDisks: !!latestMetrics.disk?.disks,
disksValue: latestMetrics.disk?.disks,
fullLatestMetrics: latestMetrics
});
}
return (
<div className="metrics-dashboard">
<div className="dashboard-header">
<h2>
{selectedServer ? selectedServer : 'All Servers Overview'}
</h2>
<div className="header-controls">
{selectedServer && (
<div className="time-range-selector">
<label htmlFor="time-range">Range:</label>
<select
id="time-range"
value={timeRange || '1h'}
onChange={(e) => onTimeRangeChange && onTimeRangeChange(e.target.value)}
className="time-range-select"
>
{timeRangeOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
)}
<div className="refresh-indicator">
<span className="pulse-dot"></span>
Live
</div>
</div>
</div>
{selectedServer ? (
<div className="server-detail-layout">
<div className="server-detail-content">
{latestMetrics ? (
<>
{/* Tab Navigation */}
<div className="tabs-container">
<button
className={`tab-button ${activeTab === 'client' ? 'active' : ''}`}
onClick={() => setActiveTab('client')}
>
General
</button>
<button
className={`tab-button ${activeTab === 'cpu-memory' ? 'active' : ''}`}
onClick={() => setActiveTab('cpu-memory')}
>
CPU & Memory
</button>
<button
className={`tab-button ${activeTab === 'disks' ? 'active' : ''}`}
onClick={() => setActiveTab('disks')}
>
Disks
</button>
<button
className={`tab-button ${activeTab === 'network' ? 'active' : ''}`}
onClick={() => setActiveTab('network')}
>
Network
</button>
<button
className={`tab-button ${activeTab === 'system' ? 'active' : ''}`}
onClick={() => setActiveTab('system')}
>
System
</button>
<button
className={`tab-button ${activeTab === 'containers' ? 'active' : ''}`}
onClick={() => setActiveTab('containers')}
>
Containers
</button>
<button
className={`tab-button ${activeTab === 'security' ? 'active' : ''}`}
onClick={() => setActiveTab('security')}
>
Security
</button>
</div>
{/* Tab Content */}
<div className="tab-content">
{activeTab === 'cpu-memory' && (
<div className="tab-panel">
<div className="metrics-grid">
<MetricCard
title="CPU Usage"
value={latestMetrics.cpu?.usage || 0}
unit="%"
color="#fc2922"
/>
<MetricCard
title="Memory Usage"
value={latestMetrics.memory?.used || 0}
max={latestMetrics.memory?.total || 1}
unit="GB"
color="#ff6b5a"
/>
</div>
<div className="charts-grid">
<MetricsChart
metrics={displayMetrics}
metricType="cpu"
title="CPU Usage"
color="#fc2922"
unit="%"
/>
<MetricsChart
metrics={displayMetrics}
metricType="memory"
title="Memory Usage"
color="#ff6b5a"
unit="GB"
/>
</div>
</div>
)}
{activeTab === 'disks' && (
<div className="tab-panel">
{disksArray.length > 0 ? (
<>
<div className="disks-section">
<div className="disks-grid">
{disksArray.map((disk, index) => {
const used = typeof disk.used === 'string' ? parseFloat(disk.used) : (disk.used || 0);
const total = typeof disk.total === 'string' ? parseFloat(disk.total) : (disk.total || 0);
const free = typeof disk.free === 'string' ? parseFloat(disk.free) : (disk.free || 0);
const percent = typeof disk.percent === 'string' ? parseFloat(disk.percent) : (disk.percent || 0);
return (
<div key={index} className="disk-card">
<div className="disk-header">
<span className="disk-mountpoint">{disk.mountpoint}</span>
<span className="disk-device">{disk.device}</span>
</div>
<div className="disk-info">
<div className="disk-metric">
<span className="label">Used:</span>
<span className="value">{used.toFixed(2)} GB</span>
</div>
<div className="disk-metric">
<span className="label">Total:</span>
<span className="value">{total.toFixed(2)} GB</span>
</div>
<div className="disk-metric">
<span className="label">Free:</span>
<span className="value">{free.toFixed(2)} GB</span>
</div>
<div className="disk-metric">
<span className="label">Usage:</span>
<span className={`value ${percent > 80 ? 'warning' : ''}`}>
{percent.toFixed(1)}%
</span>
</div>
</div>
<div className="disk-bar">
<div
className="disk-bar-fill"
style={{
width: `${Math.min(percent, 100)}%`,
backgroundColor: percent > 80 ? '#ef4444' : '#f472b6'
}}
/>
</div>
</div>
);
})}
</div>
</div>
<div className="charts-grid">
{disksArray.map((disk, index) => (
<MetricsChart
key={index}
metrics={displayMetrics}
metricType={`disk-${disk.mountpoint}`}
title={`Disk Usage: ${disk.mountpoint}`}
color="#f472b6"
unit="GB"
diskMountpoint={disk.mountpoint}
/>
))}
</div>
</>
) : (
<div className="no-metrics">
<p>No disk information available</p>
{latestMetrics && (
<div style={{ marginTop: '1rem', fontSize: '0.85rem', color: '#64748b' }}>
<p>Debug info: {latestMetrics.disk ? 'Disk object exists' : 'No disk object'}</p>
<p>Disks value: {latestMetrics.disk?.disks ?
(Array.isArray(latestMetrics.disk.disks) ?
`Array with ${latestMetrics.disk.disks.length} items` :
`Type: ${typeof latestMetrics.disk.disks}`) :
'null/undefined'}</p>
</div>
)}
</div>
)}
</div>
)}
{activeTab === 'network' && (
<div className="tab-panel">
<div className="metrics-grid">
<MetricCard
title="Network RX"
value={latestMetrics.network?.rx || 0}
unit="MB/s"
color="#34d399"
/>
<MetricCard
title="Network TX"
value={latestMetrics.network?.tx || 0}
unit="MB/s"
color="#fbbf24"
/>
</div>
<div className="charts-grid">
<MetricsChart
metrics={displayMetrics}
metricType="networkRx"
title="Network RX"
color="#34d399"
unit="MB/s"
/>
<MetricsChart
metrics={displayMetrics}
metricType="networkTx"
title="Network TX"
color="#fbbf24"
unit="MB/s"
/>
</div>
</div>
)}
{activeTab === 'system' && (
<div className="tab-panel">
{(() => {
// Debug logging
if (latestMetrics) {
const hasValidSwap = latestMetrics.swap && latestMetrics.swap.total != null && latestMetrics.swap.total > 0;
const hasValidProcessCount = latestMetrics.process_count != null;
const hasValidLoadAvg = latestMetrics.load_avg && (
latestMetrics.load_avg['1min'] != null ||
latestMetrics.load_avg['5min'] != null ||
latestMetrics.load_avg['15min'] != null
);
console.log('[System Tab Debug] Full latestMetrics object:', latestMetrics);
console.log('[System Tab Debug] Summary:', {
hasSwap: !!latestMetrics.swap,
swap: latestMetrics.swap,
hasValidSwap,
hasProcessCount: latestMetrics.process_count !== undefined,
processCount: latestMetrics.process_count,
hasValidProcessCount,
hasLoadAvg: !!latestMetrics.load_avg,
loadAvg: latestMetrics.load_avg,
hasValidLoadAvg,
allKeys: Object.keys(latestMetrics),
willShow: hasValidSwap || hasValidProcessCount || hasValidLoadAvg
});
return hasValidSwap || hasValidProcessCount || hasValidLoadAvg;
} else {
console.log('[System Tab Debug] latestMetrics is null/undefined');
return false;
}
})() ? (
<>
<div className="metrics-grid">
{latestMetrics.swap && latestMetrics.swap.total != null && latestMetrics.swap.total > 0 && (
<MetricCard
title="Swap Usage"
value={latestMetrics.swap?.used || 0}
max={latestMetrics.swap?.total || 1}
unit="GB"
color="#a78bfa"
/>
)}
{latestMetrics.process_count != null && (
<MetricCard
title="Process Count"
value={latestMetrics.process_count || 0}
unit=""
color="#60a5fa"
/>
)}
{latestMetrics.load_avg && (
<>
{latestMetrics.load_avg['1min'] != null && (
<MetricCard
title="Load Average (1min)"
value={latestMetrics.load_avg['1min'] || 0}
unit=""
color="#34d399"
/>
)}
{latestMetrics.load_avg['5min'] != null && (
<MetricCard
title="Load Average (5min)"
value={latestMetrics.load_avg['5min'] || 0}
unit=""
color="#10b981"
/>
)}
{latestMetrics.load_avg['15min'] != null && (
<MetricCard
title="Load Average (15min)"
value={latestMetrics.load_avg['15min'] || 0}
unit=""
color="#059669"
/>
)}
</>
)}
</div>
<div className="charts-grid">
{latestMetrics.swap && latestMetrics.swap.total != null && latestMetrics.swap.total > 0 && (
<>
<MetricsChart
metrics={displayMetrics}
metricType="swap"
title="Swap Usage"
color="#a78bfa"
unit="GB"
/>
<MetricsChart
metrics={displayMetrics}
metricType="swapPercent"
title="Swap Usage %"
color="#c084fc"
unit="%"
/>
</>
)}
{latestMetrics.process_count != null && (
<MetricsChart
metrics={displayMetrics}
metricType="processCount"
title="Process Count"
color="#60a5fa"
unit=""
/>
)}
{latestMetrics.load_avg && (
<>
{latestMetrics.load_avg['1min'] != null && (
<MetricsChart
metrics={displayMetrics}
metricType="loadAvg1min"
title="Load Average (1 min)"
color="#34d399"
unit=""
/>
)}
{latestMetrics.load_avg['5min'] != null && (
<MetricsChart
metrics={displayMetrics}
metricType="loadAvg5min"
title="Load Average (5 min)"
color="#10b981"
unit=""
/>
)}
{latestMetrics.load_avg['15min'] != null && (
<MetricsChart
metrics={displayMetrics}
metricType="loadAvg15min"
title="Load Average (15 min)"
color="#059669"
unit=""
/>
)}
</>
)}
</div>
</>
) : (
<div className="no-metrics">
<p>No system metrics available yet. Metrics will appear here once collected.</p>
</div>
)}
</div>
)}
{activeTab === 'containers' && (
<div className="tab-panel">
{serverInfo?.containers && serverInfo.containers.length > 0 ? (
<div className="containers-grid">
{serverInfo.containers.map((container, index) => (
<div key={index} className="container-card">
<div className="container-header">
<div className="container-name">{container.names || container.id || 'Unknown'}</div>
<div className={`container-status-badge ${container.status?.toLowerCase().includes('up') ? 'status-running' : 'status-stopped'}`}>
{container.status || 'N/A'}
</div>
</div>
<div className="container-image">{container.image || 'N/A'}</div>
{container.id && (
<div className="container-id">{container.id.substring(0, 12)}</div>
)}
</div>
))}
</div>
) : (
<div className="no-metrics">
<p>No containers running</p>
</div>
)}
</div>
)}
{activeTab === 'security' && (
<div className="tab-panel">
<ServerSecurity serverId={selectedServer} />
</div>
)}
{activeTab === 'client' && (
<div className="tab-panel client-tab-panel">
{/* Client Information */}
<div className="client-info-section">
<h3 className="client-info-title">Client Information</h3>
{clientVersion ? (
<div className="client-info-content">
<div className="client-info-item">
<span className="client-info-label">Client Version:</span>
<span className="client-info-value">{clientVersion}</span>
</div>
{latestVersion && (
<div className="client-info-item">
<span className="client-info-label">Latest Version:</span>
<span className="client-info-value">{latestVersion}</span>
</div>
)}
<div className="client-info-item">
<span className="client-info-label">Update Status:</span>
<span className={`client-update-status ${isOutdated ? 'outdated' : 'up-to-date'}`}>
{isOutdated ? (
<>
<span className="status-icon"></span>
<span>Out of Date</span>
</>
) : (
<>
<span className="status-icon"></span>
<span>Up to Date</span>
</>
)}
</span>
</div>
</div>
) : (
<div className="no-metrics">
<p>No client version information available</p>
</div>
)}
</div>
{/* OS Information */}
{serverInfo && (
<>
<div className="server-info-section">
<h4 className="server-info-section-title">OS Information</h4>
{serverInfo.os_release ? (
<div className="server-info-content">
{serverInfo.os_release.PRETTY_NAME && (
<div className="server-info-item">
<span className="info-label">OS:</span>
<span className="info-value">{serverInfo.os_release.PRETTY_NAME}</span>
</div>
)}
{serverInfo.os_release.VERSION_ID && (
<div className="server-info-item">
<span className="info-label">Version:</span>
<span className="info-value">{serverInfo.os_release.VERSION_ID}</span>
</div>
)}
</div>
) : (
<div className="server-info-empty">No OS information available</div>
)}
</div>
{/* Live Status */}
<div className="server-info-section">
<h4 className="server-info-section-title">Live Status</h4>
{serverInfo.live_status ? (
<div className="server-info-content">
<div className="server-info-item">
<span className="info-label">Uptime:</span>
<span className="info-value">
{(() => {
const { uptime_days, uptime_hours, uptime_minutes } = serverInfo.live_status;
if (uptime_days > 0) {
return `${uptime_days}d ${uptime_hours}h ${uptime_minutes}m`;
} else if (uptime_hours > 0) {
return `${uptime_hours}h ${uptime_minutes}m`;
} else {
return `${uptime_minutes}m`;
}
})()}
</span>
</div>
<div className="server-info-item">
<span className="info-label">Load:</span>
<span className="info-value">
{serverInfo.live_status.load_average_1min?.toFixed(2) || 'N/A'} / {serverInfo.live_status.load_average_5min?.toFixed(2) || 'N/A'} / {serverInfo.live_status.load_average_15min?.toFixed(2) || 'N/A'}
</span>
</div>
</div>
) : (
<div className="server-info-empty">No status information available</div>
)}
</div>
{/* Top Processes */}
<div className="server-info-section">
<h4 className="server-info-section-title">Top Processes</h4>
{serverInfo.top_processes && serverInfo.top_processes.length > 0 ? (
<div className="server-info-content">
<div className="processes-list">
{serverInfo.top_processes.slice(0, 5).map((proc, index) => (
<div key={index} className="process-item">
<div className="process-name">{proc.name || 'Unknown'}</div>
<div className="process-stats">
<span className="process-cpu">CPU: {proc.cpu_percent?.toFixed(1) || '0.0'}%</span>
<span className="process-mem">Mem: {proc.memory_percent?.toFixed(1) || '0.0'}%</span>
</div>
</div>
))}
</div>
</div>
) : (
<div className="server-info-empty">No process information available</div>
)}
</div>
{/* Network Information */}
<div className="server-info-section">
<h4 className="server-info-section-title">Network Information</h4>
{serverInfo.ip_info ? (
<div className="server-info-content">
{serverInfo.ip_info.public && (
<div className="server-info-item">
<span className="info-label">Public IP:</span>
<span className="info-value">{serverInfo.ip_info.public}</span>
</div>
)}
{serverInfo.ip_info.private && serverInfo.ip_info.private.length > 0 && (
<div className="server-info-item">
<span className="info-label">Private IPs:</span>
<div className="ip-list">
{serverInfo.ip_info.private.map((ip, index) => (
<div key={index} className="ip-item">
<span className="ip-address">{ip.ip}</span>
<span className="ip-interface">({ip.interface})</span>
</div>
))}
</div>
</div>
)}
{!serverInfo.ip_info.public && (!serverInfo.ip_info.private || serverInfo.ip_info.private.length === 0) && (
<div className="server-info-empty">No IP information available</div>
)}
</div>
) : (
<div className="server-info-empty">No network information available</div>
)}
</div>
</>
)}
{!serverInfo && (
<div className="no-metrics">
<p>No server information available</p>
</div>
)}
</div>
)}
</div>
</>
) : (
<div className="no-metrics">
<p>No metrics available for this server</p>
</div>
)}
</div>
</div>
) : (
<>
{allLatestMetrics.length > 0 ? (
<>
<div className="servers-overview-table">
<table className="servers-table">
<thead>
<tr>
<th>Server</th>
<th>CPU</th>
<th>Memory</th>
<th>Disk</th>
<th>Client Version</th>
</tr>
</thead>
<tbody>
{allLatestMetrics.map(({ serverId, cpu, memory, disk, network, clientVersion }) => {
const cpuUsage = cpu?.usage ? (typeof cpu.usage === 'string' ? parseFloat(cpu.usage) : cpu.usage) : 0;
const memUsed = memory?.used ? (typeof memory.used === 'string' ? parseFloat(memory.used) : memory.used) : 0;
const memTotal = memory?.total ? (typeof memory.total === 'string' ? parseFloat(memory.total) : memory.total) : 0;
const diskUsed = disk?.used ? (typeof disk.used === 'string' ? parseFloat(disk.used) : disk.used) : 0;
const diskTotal = disk?.total ? (typeof disk.total === 'string' ? parseFloat(disk.total) : disk.total) : 0;
return (
<tr key={serverId} className="server-row">
<td className="server-name-cell">{serverId}</td>
<td className="metric-cell">
<span className="table-metric-value">{cpuUsage.toFixed(1)}%</span>
</td>
<td className="metric-cell">
<span className="table-metric-value">
{memUsed.toFixed(1)} / {memTotal.toFixed(1)} GB
</span>
</td>
<td className="metric-cell">
<span className="table-metric-value">
{diskUsed.toFixed(1)} / {diskTotal.toFixed(1)} GB
</span>
</td>
<td className="version-cell">
<span className="version-value">
{clientVersion || 'N/A'}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</>
) : (
<div className="no-metrics">
<p>No metrics available. Connect servers to see data.</p>
</div>
)}
</>
)}
</div>
);
}
export default MetricsDashboard;

View File

@@ -0,0 +1,266 @@
.security {
padding: 2rem;
max-width: 1400px;
margin: 0 auto;
}
.security-header {
margin-bottom: 2rem;
}
.security-header h2 {
margin: 0 0 1.5rem 0;
color: #f1f5f9;
font-size: 1.875rem;
}
.security-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.summary-card {
background: rgba(30, 41, 59, 0.8);
border: 1px solid rgba(148, 163, 184, 0.2);
border-radius: 8px;
padding: 1.5rem;
text-align: center;
}
.summary-card.critical {
border-color: rgba(239, 68, 68, 0.3);
}
.summary-card.high {
border-color: rgba(249, 115, 22, 0.3);
}
.summary-card.medium {
border-color: rgba(234, 179, 8, 0.3);
}
.summary-label {
color: #94a3b8;
font-size: 0.875rem;
margin-bottom: 0.5rem;
}
.summary-value {
color: #f1f5f9;
font-size: 2rem;
font-weight: 700;
}
.error-message {
background: #ef4444;
color: white;
padding: 1rem;
border-radius: 6px;
margin-bottom: 1rem;
}
.security-filters {
display: flex;
gap: 1rem;
margin-bottom: 2rem;
flex-wrap: wrap;
}
.filter-group {
display: flex;
align-items: center;
gap: 0.5rem;
}
.filter-group label {
color: #f1f5f9;
font-size: 0.875rem;
font-weight: 500;
}
.filter-group select,
.filter-group input {
padding: 0.5rem 0.75rem;
background: rgba(15, 23, 42, 0.8);
border: 1px solid rgba(148, 163, 184, 0.3);
border-radius: 6px;
color: #f1f5f9;
font-size: 0.875rem;
}
.filter-group select:focus,
.filter-group input:focus {
outline: none;
border-color: #fc2922;
}
.filter-group input {
min-width: 250px;
}
.no-vulnerabilities {
text-align: center;
padding: 4rem 2rem;
color: #94a3b8;
}
.vulnerabilities-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.vulnerability-card {
background: rgba(30, 41, 59, 0.8);
border: 1px solid rgba(252, 41, 34, 0.2);
border-radius: 8px;
overflow: hidden;
transition: all 0.2s;
}
.vulnerability-card:hover {
border-color: rgba(252, 41, 34, 0.4);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.vulnerability-header {
padding: 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
}
.vulnerability-title {
display: flex;
align-items: center;
gap: 1rem;
flex: 1;
}
.vulnerability-title h3 {
margin: 0;
color: #f1f5f9;
font-size: 1.125rem;
}
.severity-badge {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.package-name {
color: #94a3b8;
font-size: 0.875rem;
font-weight: 500;
}
.vulnerability-meta {
display: flex;
align-items: center;
gap: 1rem;
}
.affected-servers {
color: #94a3b8;
font-size: 0.875rem;
}
.expand-icon {
color: #94a3b8;
font-size: 0.75rem;
}
.vulnerability-details {
padding: 0 1.5rem 1.5rem 1.5rem;
border-top: 1px solid rgba(148, 163, 184, 0.1);
margin-top: 1rem;
padding-top: 1.5rem;
}
.detail-section {
margin-bottom: 1.5rem;
}
.detail-section:last-child {
margin-bottom: 0;
}
.detail-section strong {
display: block;
color: #f1f5f9;
font-size: 0.875rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.detail-section p {
color: #cbd5e1;
font-size: 0.875rem;
line-height: 1.6;
margin: 0;
}
.fixed-version {
color: #10b981;
font-weight: 600;
font-family: monospace;
}
.servers-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.5rem;
}
.server-tag {
display: inline-block;
padding: 0.25rem 0.75rem;
background: rgba(252, 41, 34, 0.1);
border: 1px solid rgba(252, 41, 34, 0.3);
border-radius: 12px;
color: #fc2922;
font-size: 0.75rem;
font-weight: 500;
}
.cve-link {
color: #3b82f6;
text-decoration: none;
font-size: 0.875rem;
transition: color 0.2s;
}
.cve-link:hover {
color: #60a5fa;
text-decoration: underline;
}
@media (max-width: 768px) {
.security {
padding: 1rem;
}
.security-summary {
grid-template-columns: repeat(2, 1fr);
}
.vulnerability-header {
flex-direction: column;
align-items: flex-start;
gap: 1rem;
}
.vulnerability-title {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
}

View File

@@ -0,0 +1,224 @@
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import './Security.css';
const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:3001';
function Security() {
const [vulnerabilities, setVulnerabilities] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [filters, setFilters] = useState({
severity: '',
search: ''
});
const [expandedVuln, setExpandedVuln] = useState(null);
useEffect(() => {
fetchVulnerabilities();
const interval = setInterval(fetchVulnerabilities, 30000); // Refresh every 30 seconds
return () => clearInterval(interval);
}, []);
const fetchVulnerabilities = async () => {
try {
const params = {};
if (filters.severity) {
params.severity = filters.severity;
}
const response = await axios.get(`${API_URL}/api/security/vulnerabilities`, { params });
setVulnerabilities(response.data.vulnerabilities || []);
setLoading(false);
setError(null);
} catch (err) {
setError('Failed to fetch vulnerabilities');
setLoading(false);
console.error('Error fetching vulnerabilities:', err);
}
};
const getSeverityColor = (severity) => {
switch (severity) {
case 'CRITICAL':
return '#ef4444';
case 'HIGH':
return '#f97316';
case 'MEDIUM':
return '#eab308';
case 'LOW':
return '#3b82f6';
default:
return '#94a3b8';
}
};
const getSeverityBadge = (severity) => {
return (
<span
className="severity-badge"
style={{ backgroundColor: getSeverityColor(severity) + '20', color: getSeverityColor(severity) }}
>
{severity || 'UNKNOWN'}
</span>
);
};
const filteredVulnerabilities = vulnerabilities.filter(vuln => {
if (filters.severity && vuln.severity !== filters.severity) {
return false;
}
if (filters.search) {
const searchLower = filters.search.toLowerCase();
return (
vuln.cve_id?.toLowerCase().includes(searchLower) ||
vuln.package_name?.toLowerCase().includes(searchLower) ||
vuln.summary?.toLowerCase().includes(searchLower)
);
}
return true;
});
const toggleExpand = (vulnId) => {
setExpandedVuln(expandedVuln === vulnId ? null : vulnId);
};
if (loading) {
return (
<div className="security">
<div className="loading">Loading vulnerabilities...</div>
</div>
);
}
return (
<div className="security">
<div className="security-header">
<h2>Security Vulnerabilities</h2>
<div className="security-summary">
<div className="summary-card">
<div className="summary-label">Total Vulnerabilities</div>
<div className="summary-value">{vulnerabilities.length}</div>
</div>
<div className="summary-card critical">
<div className="summary-label">Critical</div>
<div className="summary-value">
{vulnerabilities.filter(v => v.severity === 'CRITICAL').length}
</div>
</div>
<div className="summary-card high">
<div className="summary-label">High</div>
<div className="summary-value">
{vulnerabilities.filter(v => v.severity === 'HIGH').length}
</div>
</div>
<div className="summary-card medium">
<div className="summary-label">Medium</div>
<div className="summary-value">
{vulnerabilities.filter(v => v.severity === 'MEDIUM').length}
</div>
</div>
</div>
</div>
{error && <div className="error-message">{error}</div>}
<div className="security-filters">
<div className="filter-group">
<label>Severity:</label>
<select
value={filters.severity}
onChange={(e) => setFilters({ ...filters, severity: e.target.value })}
>
<option value="">All</option>
<option value="CRITICAL">Critical</option>
<option value="HIGH">High</option>
<option value="MEDIUM">Medium</option>
<option value="LOW">Low</option>
</select>
</div>
<div className="filter-group">
<label>Search:</label>
<input
type="text"
placeholder="CVE ID, package name..."
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
/>
</div>
</div>
{filteredVulnerabilities.length === 0 ? (
<div className="no-vulnerabilities">
<p>{vulnerabilities.length === 0 ? 'No vulnerabilities found' : 'No vulnerabilities match filters'}</p>
</div>
) : (
<div className="vulnerabilities-list">
{filteredVulnerabilities.map((vuln) => (
<div key={vuln.id} className="vulnerability-card">
<div className="vulnerability-header" onClick={() => toggleExpand(vuln.id)}>
<div className="vulnerability-title">
<h3>{vuln.cve_id || 'Unknown CVE'}</h3>
{getSeverityBadge(vuln.severity)}
<span className="package-name">{vuln.package_name}</span>
</div>
<div className="vulnerability-meta">
<span className="affected-servers">
{vuln.affected_server_count || 0} server{vuln.affected_server_count !== 1 ? 's' : ''}
</span>
<span className="expand-icon">{expandedVuln === vuln.id ? '▼' : '▶'}</span>
</div>
</div>
{expandedVuln === vuln.id && (
<div className="vulnerability-details">
{vuln.summary && (
<div className="detail-section">
<strong>Summary:</strong>
<p>{vuln.summary}</p>
</div>
)}
{vuln.description && (
<div className="detail-section">
<strong>Description:</strong>
<p>{vuln.description}</p>
</div>
)}
{vuln.fixed_version && (
<div className="detail-section">
<strong>Fixed Version:</strong>
<span className="fixed-version">{vuln.fixed_version}</span>
</div>
)}
{vuln.affected_servers && vuln.affected_servers.length > 0 && (
<div className="detail-section">
<strong>Affected Servers:</strong>
<div className="servers-list">
{vuln.affected_servers.map((serverId, idx) => (
<span key={idx} className="server-tag">{serverId}</span>
))}
</div>
</div>
)}
{vuln.cve_id && (
<div className="detail-section">
<a
href={`https://www.cve.org/CVERecord?id=${vuln.cve_id}`}
target="_blank"
rel="noopener noreferrer"
className="cve-link"
>
View CVE Details
</a>
</div>
)}
</div>
)}
</div>
))}
</div>
)}
</div>
);
}
export default Security;

View File

@@ -0,0 +1,660 @@
.security-dashboard {
padding: 1rem;
max-width: 1400px;
margin: 0 auto;
color: #e5e7eb;
}
.dashboard-header {
margin-bottom: 1rem;
}
.dashboard-header h1 {
font-size: 2rem;
font-weight: 600;
margin-bottom: 0.5rem;
color: #f9fafb;
}
.dashboard-subtitle {
color: #9ca3af;
font-size: 0.95rem;
}
/* Metrics Grid */
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 0.75rem;
margin-bottom: 1rem;
}
.metric-card {
background: #1f2937;
border-radius: 8px;
padding: 0.75rem;
border: 1px solid #374151;
transition: transform 0.2s, box-shadow 0.2s;
}
.metric-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.metric-card.risk-score {
border: 2px solid;
border-color: currentColor;
}
.metric-card.critical {
border-left: 4px solid #ef4444;
}
.metric-card.high {
border-left: 4px solid #f97316;
}
.metric-card.fixable {
border-left: 4px solid #10b981;
}
.metric-card.active {
background: #111827;
border: 2px solid;
box-shadow: 0 4px 12px rgba(252, 41, 34, 0.2);
}
.metric-card.critical.active {
border-color: #ef4444;
}
.metric-card.high.active {
border-color: #f97316;
}
.metric-card.fixable.active {
border-color: #10b981;
}
.metric-label {
font-size: 0.875rem;
color: #9ca3af;
margin-bottom: 0.25rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.metric-value {
font-size: 2rem;
font-weight: 700;
color: #f9fafb;
margin-bottom: 0.125rem;
}
.metric-value-large {
font-size: 3rem;
font-weight: 700;
margin-bottom: 0.125rem;
}
.metric-sublabel {
font-size: 0.75rem;
color: #6b7280;
}
/* AI Insights Modal */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
padding: 1rem;
}
.ai-insights-modal {
background: rgba(30, 41, 59, 0.98);
border-radius: 12px;
padding: 1rem;
max-width: 900px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
position: relative;
border: 1px solid rgba(252, 41, 34, 0.3);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
.modal-header {
margin-bottom: 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid #374151;
}
.modal-header h2 {
font-size: 1.5rem;
font-weight: 600;
color: #f9fafb;
margin: 0;
}
.modal-close {
position: absolute;
top: 0.5rem;
right: 0.5rem;
background: transparent;
border: 1px solid rgba(148, 163, 184, 0.3);
border-radius: 6px;
padding: 0.25rem;
color: #94a3b8;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
z-index: 10;
}
.modal-close:hover {
background: rgba(252, 41, 34, 0.1);
color: #fc2922;
border-color: rgba(252, 41, 34, 0.5);
}
.modal-close svg {
width: 18px;
height: 18px;
}
.ai-generate-btn {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 0.375rem 0.75rem;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s, transform 0.2s;
}
.ai-generate-btn:hover:not(:disabled) {
opacity: 0.9;
transform: translateY(-1px);
}
.ai-generate-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.ai-placeholder {
text-align: center;
padding: 2rem 1rem;
color: #6b7280;
}
.ai-icon {
font-size: 4rem;
margin-bottom: 0.5rem;
}
.placeholder-subtext {
font-size: 0.875rem;
margin-top: 0.5rem;
color: #4b5563;
}
/* Insights Content */
.insights-content {
display: grid;
gap: 0.75rem;
}
.insight-card {
background: #111827;
border-radius: 8px;
padding: 0.75rem;
border: 1px solid #374151;
}
.insight-card h3 {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 0.5rem;
color: #f9fafb;
}
.insight-description {
color: #9ca3af;
font-size: 0.875rem;
margin-bottom: 0.5rem;
}
/* Risk Breakdown */
.risk-breakdown {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.risk-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.375rem;
background: #1f2937;
border-radius: 6px;
}
.risk-label {
color: #9ca3af;
font-size: 0.875rem;
}
.risk-value {
font-weight: 600;
font-size: 1.125rem;
}
/* Priority List */
.priority-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.priority-item {
background: #1f2937;
border-radius: 6px;
padding: 0.5rem;
border-left: 4px solid #f97316;
}
.priority-header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.25rem;
}
.priority-number {
background: #374151;
color: #f9fafb;
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 0.875rem;
}
.priority-cve {
font-weight: 600;
color: #60a5fa;
font-family: 'Courier New', monospace;
}
.priority-severity {
font-weight: 600;
font-size: 0.875rem;
margin-left: auto;
}
.priority-details {
display: flex;
gap: 1rem;
align-items: center;
margin-bottom: 0.25rem;
font-size: 0.875rem;
color: #9ca3af;
}
.priority-package {
font-weight: 500;
color: #e5e7eb;
}
.priority-fix-badge {
background: #10b981;
color: white;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
}
.priority-recommendation {
color: #fbbf24;
font-size: 0.875rem;
margin-top: 0.25rem;
padding-left: 0.75rem;
}
/* Quick Wins */
.quick-wins-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 0.5rem;
}
.quick-win-item {
background: #1f2937;
border-radius: 6px;
padding: 0.5rem;
border: 1px solid #374151;
border-left: 4px solid #10b981;
}
.quick-win-header {
display: flex;
gap: 0.75rem;
margin-bottom: 0.375rem;
align-items: center;
}
.quick-win-cve {
font-family: 'Courier New', monospace;
color: #60a5fa;
font-weight: 600;
font-size: 0.875rem;
}
.quick-win-package {
color: #9ca3af;
font-size: 0.875rem;
}
.quick-win-action {
color: #e5e7eb;
font-size: 0.875rem;
margin-bottom: 0.25rem;
}
.quick-win-action strong {
color: #10b981;
font-weight: 600;
}
.quick-win-servers {
color: #6b7280;
font-size: 0.75rem;
}
/* Recommendations */
.recommendations-list {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.recommendation-item {
display: flex;
gap: 1rem;
padding: 0.5rem;
background: #1f2937;
border-radius: 6px;
border-left: 4px solid;
}
.recommendation-item.critical {
border-color: #ef4444;
}
.recommendation-item.high {
border-color: #f97316;
}
.recommendation-item.medium {
border-color: #eab308;
}
.recommendation-icon {
font-size: 1.5rem;
}
.recommendation-content {
flex: 1;
}
.recommendation-text {
color: #e5e7eb;
margin-bottom: 0.125rem;
font-weight: 500;
}
.recommendation-action {
color: #60a5fa;
font-size: 0.875rem;
cursor: pointer;
}
.recommendation-action:hover {
text-decoration: underline;
}
/* Trends */
.trends-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 0.5rem;
}
.trend-item {
background: #1f2937;
padding: 0.5rem;
border-radius: 6px;
text-align: center;
}
.trend-label {
color: #9ca3af;
font-size: 0.875rem;
margin-bottom: 0.25rem;
}
.trend-value {
font-size: 1.5rem;
font-weight: 700;
color: #f9fafb;
}
.trend-value.positive {
color: #10b981;
}
/* Critical Vulnerabilities */
.critical-vulnerabilities-section {
margin-bottom: 1rem;
}
.critical-vulnerabilities-section h2 {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 0.5rem;
color: #f9fafb;
}
.critical-list {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.critical-item {
background: #1f2937;
border-radius: 8px;
padding: 0.5rem;
border: 1px solid #374151;
border-left: 4px solid #ef4444;
}
.critical-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 0.25rem;
}
.critical-cve-link {
font-family: 'Courier New', monospace;
color: #60a5fa;
font-weight: 600;
text-decoration: none;
font-size: 1rem;
}
.critical-cve-link:hover {
text-decoration: underline;
}
.critical-severity {
font-weight: 600;
font-size: 0.875rem;
margin-left: auto;
}
.critical-details {
display: flex;
gap: 1rem;
align-items: center;
font-size: 0.875rem;
color: #9ca3af;
margin-bottom: 0.25rem;
}
.critical-package {
font-weight: 500;
color: #e5e7eb;
}
.critical-fix {
color: #10b981;
font-weight: 500;
}
.critical-summary {
color: #d1d5db;
font-size: 0.875rem;
line-height: 1.5;
}
/* Server Security Overview */
.server-security-overview {
margin-bottom: 2rem;
}
.server-security-overview h2 {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 1rem;
color: #f9fafb;
}
.server-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 1rem;
}
.server-security-card {
background: #1f2937;
border-radius: 8px;
padding: 1.5rem;
border: 1px solid #374151;
}
.server-security-card h3 {
font-size: 1.125rem;
font-weight: 600;
margin-bottom: 1rem;
color: #f9fafb;
}
.server-stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
}
.server-stat {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.server-stat .stat-label {
font-size: 0.75rem;
color: #9ca3af;
text-transform: uppercase;
}
.server-stat .stat-value {
font-size: 1.25rem;
font-weight: 700;
color: #f9fafb;
}
.server-stat.critical .stat-value {
color: #ef4444;
}
.server-stat.high .stat-value {
color: #f97316;
}
.server-stat.medium .stat-value {
color: #eab308;
}
/* Loading and Error States */
.loading {
text-align: center;
padding: 2rem 1rem;
color: #9ca3af;
font-size: 1.125rem;
}
.error-banner {
background: #7f1d1d;
color: #fca5a5;
padding: 0.5rem;
border-radius: 6px;
margin-bottom: 0.75rem;
border: 1px solid #991b1b;
}
/* Responsive */
@media (max-width: 768px) {
.security-dashboard {
padding: 1rem;
}
.metrics-grid {
grid-template-columns: repeat(2, 1fr);
}
.section-header {
flex-direction: column;
align-items: flex-start;
gap: 1rem;
}
.quick-wins-list {
grid-template-columns: 1fr;
}
.server-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,552 @@
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import './SecurityDashboard.css';
const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:3001';
function SecurityDashboard() {
const [vulnerabilities, setVulnerabilities] = useState([]);
const [serverStats, setServerStats] = useState({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [aiInsights, setAiInsights] = useState(null);
const [aiLoading, setAiLoading] = useState(false);
const [selectedFilter, setSelectedFilter] = useState(null);
const [showAiModal, setShowAiModal] = useState(false);
useEffect(() => {
fetchSecurityData();
const interval = setInterval(fetchSecurityData, 30000); // Refresh every 30 seconds
return () => clearInterval(interval);
}, []);
const fetchSecurityData = async () => {
try {
// Fetch all vulnerabilities
const vulnResponse = await axios.get(`${API_URL}/api/security/vulnerabilities`);
const allVulns = vulnResponse.data.vulnerabilities || [];
setVulnerabilities(allVulns);
// Fetch stats for each server
const serversResponse = await axios.get(`${API_URL}/api/servers`);
const servers = serversResponse.data.servers || [];
const statsPromises = servers.map(async (serverId) => {
try {
const statsResponse = await axios.get(`${API_URL}/api/servers/${serverId}/vulnerabilities/stats`);
return { serverId, stats: statsResponse.data.stats };
} catch (err) {
return { serverId, stats: null };
}
});
const statsResults = await Promise.all(statsPromises);
const statsMap = {};
statsResults.forEach(({ serverId, stats }) => {
statsMap[serverId] = stats;
});
setServerStats(statsMap);
setLoading(false);
setError(null);
} catch (err) {
setError('Failed to fetch security data');
setLoading(false);
console.error('Error fetching security data:', err);
}
};
const generateAiInsights = async () => {
setAiLoading(true);
setShowAiModal(true);
// Simulate AI processing (facade for future AI integration)
setTimeout(() => {
const criticalVulns = vulnerabilities.filter(v => v.severity === 'CRITICAL' || v.severity === 'HIGH');
const vulnsWithFix = vulnerabilities.filter(v => v.fixed_version && v.fixed_version !== '-' && v.fixed_version.trim() !== '');
const mostAffectedPackage = getMostAffectedPackage();
const riskScore = calculateRiskScore();
setAiInsights({
topPriority: criticalVulns.slice(0, 5).map(v => ({
cve: v.cve_id,
package: v.package_name,
severity: v.severity,
affectedServers: v.affected_server_count || 0,
hasFix: !!(v.fixed_version && v.fixed_version !== '-' && v.fixed_version.trim() !== ''),
recommendation: generateRecommendation(v)
})),
quickWins: vulnsWithFix.slice(0, 5).map(v => ({
cve: v.cve_id,
package: v.package_name,
fixedVersion: v.fixed_version,
affectedServers: v.affected_server_count || 0
})),
riskScore: riskScore,
mostAffectedPackage: mostAffectedPackage,
trends: {
criticalTrend: 'stable', // Would be calculated from historical data
newVulns24h: Math.floor(Math.random() * 10), // Placeholder
resolved24h: Math.floor(Math.random() * 5) // Placeholder
},
recommendations: generateRecommendations()
});
setAiLoading(false);
}, 1500);
};
const getMostAffectedPackage = () => {
const packageCounts = {};
vulnerabilities.forEach(v => {
packageCounts[v.package_name] = (packageCounts[v.package_name] || 0) + 1;
});
const sorted = Object.entries(packageCounts).sort((a, b) => b[1] - a[1]);
return sorted.length > 0 ? { name: sorted[0][0], count: sorted[0][1] } : null;
};
const calculateRiskScore = () => {
let score = 0;
vulnerabilities.forEach(v => {
switch (v.severity) {
case 'CRITICAL':
score += 10;
break;
case 'HIGH':
score += 7;
break;
case 'MEDIUM':
score += 4;
break;
case 'LOW':
score += 1;
break;
}
});
// Normalize to 0-100 scale (rough approximation)
const maxPossibleScore = vulnerabilities.length * 10;
return maxPossibleScore > 0 ? Math.min(100, Math.round((score / maxPossibleScore) * 100)) : 0;
};
const generateRecommendation = (vuln) => {
if (vuln.fixed_version && vuln.fixed_version !== '-' && vuln.fixed_version.trim() !== '') {
return `Update ${vuln.package_name} to version ${vuln.fixed_version}`;
}
return `Monitor ${vuln.package_name} for security updates`;
};
const generateRecommendations = () => {
const recommendations = [];
const criticalCount = vulnerabilities.filter(v => v.severity === 'CRITICAL').length;
const highCount = vulnerabilities.filter(v => v.severity === 'HIGH').length;
const fixableCount = vulnerabilities.filter(v => v.fixed_version && v.fixed_version !== '-' && v.fixed_version.trim() !== '').length;
if (criticalCount > 0) {
recommendations.push({
priority: 'critical',
text: `Address ${criticalCount} critical vulnerability${criticalCount !== 1 ? 'ies' : ''} immediately`,
action: 'Review critical vulnerabilities'
});
}
if (fixableCount > 0) {
recommendations.push({
priority: 'high',
text: `${fixableCount} vulnerability${fixableCount !== 1 ? 'ies have' : ' has'} fixes available - update packages`,
action: 'View fixable vulnerabilities'
});
}
if (highCount > 5) {
recommendations.push({
priority: 'medium',
text: `High number of high-severity vulnerabilities (${highCount}) - prioritize remediation`,
action: 'Review high-severity vulnerabilities'
});
}
return recommendations;
};
const getSeverityColor = (severity) => {
switch (severity) {
case 'CRITICAL':
return '#ef4444';
case 'HIGH':
return '#f97316';
case 'MEDIUM':
return '#eab308';
case 'LOW':
return '#3b82f6';
default:
return '#94a3b8';
}
};
const getRiskLevel = (score) => {
if (score >= 70) return { level: 'Critical', color: '#ef4444' };
if (score >= 50) return { level: 'High', color: '#f97316' };
if (score >= 30) return { level: 'Medium', color: '#eab308' };
return { level: 'Low', color: '#10b981' };
};
const totalServers = Object.keys(serverStats).length;
const totalVulns = vulnerabilities.length;
const criticalVulns = vulnerabilities.filter(v => v.severity === 'CRITICAL').length;
const highVulns = vulnerabilities.filter(v => v.severity === 'HIGH').length;
const fixableVulns = vulnerabilities.filter(v => v.fixed_version && v.fixed_version !== '-' && v.fixed_version.trim() !== '').length;
const riskScore = calculateRiskScore();
const riskLevel = getRiskLevel(riskScore);
if (loading) {
return (
<div className="security-dashboard">
<div className="loading">Loading security dashboard...</div>
</div>
);
}
return (
<div className="security-dashboard">
<div className="dashboard-header">
<h1>Security Dashboard</h1>
<p className="dashboard-subtitle">AI-powered security insights and vulnerability management</p>
</div>
{error && <div className="error-banner">{error}</div>}
{/* Key Metrics Overview */}
<div className="metrics-grid">
<div className="metric-card risk-score">
<div className="metric-label">Overall Risk Score</div>
<div className="metric-value-large" style={{ color: riskLevel.color }}>
{riskScore}
</div>
<div className="metric-sublabel" style={{ color: riskLevel.color }}>
{riskLevel.level} Risk
</div>
</div>
<div className="metric-card">
<div className="metric-label">Total Vulnerabilities</div>
<div className="metric-value">{totalVulns}</div>
<div className="metric-sublabel">Across {totalServers} server{totalServers !== 1 ? 's' : ''}</div>
</div>
<div
className={`metric-card critical ${selectedFilter === 'CRITICAL' ? 'active' : ''}`}
onClick={() => setSelectedFilter(selectedFilter === 'CRITICAL' ? null : 'CRITICAL')}
style={{ cursor: 'pointer' }}
>
<div className="metric-label">Critical</div>
<div className="metric-value">{criticalVulns}</div>
<div className="metric-sublabel">Requires immediate attention</div>
</div>
<div
className={`metric-card high ${selectedFilter === 'HIGH' ? 'active' : ''}`}
onClick={() => setSelectedFilter(selectedFilter === 'HIGH' ? null : 'HIGH')}
style={{ cursor: 'pointer' }}
>
<div className="metric-label">High Severity</div>
<div className="metric-value">{highVulns}</div>
<div className="metric-sublabel">Should be addressed soon</div>
</div>
<div
className={`metric-card fixable ${selectedFilter === 'fixable' ? 'active' : ''}`}
onClick={() => setSelectedFilter(selectedFilter === 'fixable' ? null : 'fixable')}
style={{ cursor: 'pointer' }}
>
<div className="metric-label">Fixable</div>
<div className="metric-value">{fixableVulns}</div>
<div className="metric-sublabel">Updates available</div>
</div>
</div>
{/* AI Insights Button */}
<div style={{ marginBottom: '2rem', display: 'flex', justifyContent: 'flex-end' }}>
<button
className="ai-generate-btn"
onClick={generateAiInsights}
disabled={aiLoading}
>
{aiLoading ? 'Analyzing...' : 'Generate AI Insights'}
</button>
</div>
{/* AI Insights Modal */}
{showAiModal && (
<div className="modal-overlay" onClick={() => setShowAiModal(false)}>
<div className="modal-content ai-insights-modal" onClick={(e) => e.stopPropagation()}>
<button className="modal-close" onClick={() => setShowAiModal(false)}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<line x1="18" y1="6" x2="6" y2="18" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
<line x1="6" y1="6" x2="18" y2="18" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
</svg>
</button>
<div className="modal-header">
<h2>AI Security Insights</h2>
</div>
{aiInsights ? (
<div className="insights-content">
{/* Risk Score Analysis */}
<div className="insight-card">
<h3>Risk Assessment</h3>
<div className="risk-breakdown">
<div className="risk-item">
<span className="risk-label">Current Risk Score:</span>
<span className="risk-value" style={{ color: riskLevel.color }}>
{aiInsights.riskScore} ({riskLevel.level})
</span>
</div>
{aiInsights.mostAffectedPackage && (
<div className="risk-item">
<span className="risk-label">Most Affected Package:</span>
<span className="risk-value">
{aiInsights.mostAffectedPackage.name} ({aiInsights.mostAffectedPackage.count} vulnerabilities)
</span>
</div>
)}
</div>
</div>
{/* Top Priority Vulnerabilities */}
{aiInsights.topPriority && aiInsights.topPriority.length > 0 && (
<div className="insight-card">
<h3>Top Priority Actions</h3>
<div className="priority-list">
{aiInsights.topPriority.map((item, idx) => (
<div key={idx} className="priority-item">
<div className="priority-header">
<span className="priority-number">{idx + 1}</span>
<span className="priority-cve">{item.cve}</span>
<span
className="priority-severity"
style={{ color: getSeverityColor(item.severity) }}
>
{item.severity}
</span>
</div>
<div className="priority-details">
<span className="priority-package">{item.package}</span>
<span className="priority-servers">{item.affectedServers} server{item.affectedServers !== 1 ? 's' : ''}</span>
{item.hasFix && <span className="priority-fix-badge">Fix Available</span>}
</div>
<div className="priority-recommendation">
💡 {item.recommendation}
</div>
</div>
))}
</div>
</div>
)}
{/* Quick Wins */}
{aiInsights.quickWins && aiInsights.quickWins.length > 0 && (
<div className="insight-card">
<h3>Quick Wins</h3>
<p className="insight-description">Vulnerabilities with fixes available - update these packages first</p>
<div className="quick-wins-list">
{aiInsights.quickWins.map((item, idx) => (
<div key={idx} className="quick-win-item">
<div className="quick-win-header">
<span className="quick-win-cve">{item.cve}</span>
<span className="quick-win-package">{item.package}</span>
</div>
<div className="quick-win-action">
Update to: <strong>{item.fixedVersion}</strong>
</div>
<div className="quick-win-servers">
Affects {item.affectedServers} server{item.affectedServers !== 1 ? 's' : ''}
</div>
</div>
))}
</div>
</div>
)}
{/* Recommendations */}
{aiInsights.recommendations && aiInsights.recommendations.length > 0 && (
<div className="insight-card">
<h3>Actionable Recommendations</h3>
<div className="recommendations-list">
{aiInsights.recommendations.map((rec, idx) => (
<div key={idx} className={`recommendation-item ${rec.priority}`}>
<div className="recommendation-icon">
{rec.priority === 'critical' && '🚨'}
{rec.priority === 'high' && '⚠️'}
{rec.priority === 'medium' && ''}
</div>
<div className="recommendation-content">
<div className="recommendation-text">{rec.text}</div>
<div className="recommendation-action">{rec.action}</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Trends (Placeholder for future AI analysis) */}
<div className="insight-card">
<h3>Security Trends</h3>
<div className="trends-grid">
<div className="trend-item">
<div className="trend-label">New Vulnerabilities (24h)</div>
<div className="trend-value">{aiInsights.trends.newVulns24h}</div>
</div>
<div className="trend-item">
<div className="trend-label">Resolved (24h)</div>
<div className="trend-value positive">{aiInsights.trends.resolved24h}</div>
</div>
<div className="trend-item">
<div className="trend-label">Critical Trend</div>
<div className="trend-value">{aiInsights.trends.criticalTrend}</div>
</div>
</div>
</div>
</div>
) : (
<div className="ai-placeholder">
<div className="ai-icon">🤖</div>
<p>Analyzing your security posture...</p>
<p className="placeholder-subtext">AI will analyze vulnerabilities, prioritize risks, and suggest actionable remediation steps</p>
</div>
)}
</div>
</div>
)}
{/* Most Critical Vulnerabilities */}
<div className="critical-vulnerabilities-section">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
<h2>
{selectedFilter === 'CRITICAL' && 'Critical Vulnerabilities'}
{selectedFilter === 'HIGH' && 'High Severity Vulnerabilities'}
{selectedFilter === 'fixable' && 'Fixable Vulnerabilities'}
{!selectedFilter && 'Most Critical Vulnerabilities'}
</h2>
{selectedFilter && (
<button
onClick={() => setSelectedFilter(null)}
style={{
background: 'transparent',
border: '1px solid rgba(252, 41, 34, 0.3)',
borderRadius: '6px',
padding: '0.25rem 0.5rem',
color: '#94a3b8',
cursor: 'pointer',
fontSize: '0.875rem'
}}
>
Clear Filter
</button>
)}
</div>
<div className="critical-list">
{vulnerabilities
.filter(v => {
const hasAffectedServers = (v.affected_server_count || 0) > 0;
if (!hasAffectedServers) return false;
if (selectedFilter === 'CRITICAL') {
return v.severity === 'CRITICAL';
}
if (selectedFilter === 'HIGH') {
return v.severity === 'HIGH';
}
if (selectedFilter === 'fixable') {
return v.fixed_version && v.fixed_version.trim() !== '' && v.fixed_version !== '-';
}
// Default: show critical and high
return v.severity === 'CRITICAL' || v.severity === 'HIGH';
})
.sort((a, b) => {
const severityOrder = { 'CRITICAL': 0, 'HIGH': 1, 'MEDIUM': 2, 'LOW': 3 };
const severityDiff = (severityOrder[a.severity] || 99) - (severityOrder[b.severity] || 99);
if (severityDiff !== 0) return severityDiff;
// If same severity, sort by affected server count (descending)
return (b.affected_server_count || 0) - (a.affected_server_count || 0);
})
.slice(0, selectedFilter ? 1000 : 10)
.map((vuln) => (
<div key={vuln.id} className="critical-item">
<div className="critical-header">
<a
href={`https://www.cve.org/CVERecord?id=${vuln.cve_id}`}
target="_blank"
rel="noopener noreferrer"
className="critical-cve-link"
>
{vuln.cve_id}
</a>
<span
className="critical-severity"
style={{ color: getSeverityColor(vuln.severity) }}
>
{vuln.severity}
</span>
</div>
<div className="critical-details">
<span className="critical-package">{vuln.package_name}</span>
<span className="critical-servers">
{vuln.affected_server_count || 0} server{(vuln.affected_server_count || 0) !== 1 ? 's' : ''}
{vuln.affected_servers && vuln.affected_servers.length > 0 && (
<span className="server-list" style={{
display: 'block',
fontSize: '0.75rem',
color: '#9ca3af',
marginTop: '0.125rem'
}}>
({vuln.affected_servers.join(', ')})
</span>
)}
</span>
{vuln.fixed_version && vuln.fixed_version.trim() !== '' && vuln.fixed_version !== '-' && (
<span className="critical-fix">Fix: {vuln.fixed_version.split(', ').slice(0, 2).join(', ')}{vuln.fixed_version.split(', ').length > 2 ? '...' : ''}</span>
)}
</div>
{vuln.summary && (
<div className="critical-summary">{vuln.summary}</div>
)}
</div>
))}
</div>
</div>
{/* Server Security Overview */}
{totalServers > 0 && (
<div className="server-security-overview">
<h2>Server Security Overview</h2>
<div className="server-grid">
{Object.entries(serverStats).map(([serverId, stats]) => (
<div key={serverId} className="server-security-card">
<h3>{serverId}</h3>
<div className="server-stats">
<div className="server-stat">
<span className="stat-label">Total:</span>
<span className="stat-value">{stats?.total || 0}</span>
</div>
<div className="server-stat critical">
<span className="stat-label">Critical:</span>
<span className="stat-value">{stats?.critical || 0}</span>
</div>
<div className="server-stat high">
<span className="stat-label">High:</span>
<span className="stat-value">{stats?.high || 0}</span>
</div>
<div className="server-stat medium">
<span className="stat-label">Medium:</span>
<span className="stat-value">{stats?.medium || 0}</span>
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
);
}
export default SecurityDashboard;

View File

@@ -0,0 +1,311 @@
.server-info-pane {
width: 260px;
min-width: 260px;
background: rgba(15, 23, 42, 0.6);
border-right: 1px solid rgba(148, 163, 184, 0.1);
padding: 1rem;
overflow-y: auto;
height: 100%;
box-sizing: border-box;
flex-shrink: 0;
}
/* Mobile: Stack vertically and make full width */
@media (max-width: 768px) {
.server-info-pane {
width: 100%;
min-width: 100%;
border-right: none;
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
padding: 1rem;
max-height: 50vh;
overflow-y: auto;
height: auto;
}
}
@media (max-width: 480px) {
.server-info-pane {
padding: 0.875rem;
max-height: 40vh;
}
.server-info-section {
margin-bottom: 0.875rem;
}
.server-info-section-title {
font-size: 0.7rem;
}
.server-info-content {
padding: 0.5rem;
}
.server-info-item {
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
padding: 0.375rem 0;
}
.info-label {
font-size: 0.65rem;
}
.info-value {
font-size: 0.75rem;
text-align: left;
width: 100%;
word-break: break-word;
}
.ip-list {
gap: 0.5rem;
}
.ip-item {
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
}
.process-item {
padding: 0.5rem;
}
.process-name {
font-size: 0.7rem;
}
.process-stats {
font-size: 0.65rem;
gap: 0.5rem;
}
}
.server-info-title {
font-size: 1rem;
font-weight: 600;
color: #e2e8f0;
margin: 0 0 1rem 0;
padding-bottom: 0.5rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
}
.server-info-section {
margin-bottom: 1rem;
}
.server-info-section:last-child {
margin-bottom: 0;
}
.server-info-section-title {
font-size: 0.75rem;
font-weight: 600;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 0 0 0.5rem 0;
padding-bottom: 0.5rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
}
.server-info-content {
background: rgba(15, 23, 42, 0.4);
border-radius: 6px;
padding: 0.5rem;
margin-top: 0.5rem;
}
.server-info-item {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
margin-bottom: 0.375rem;
padding: 0.25rem 0;
}
.server-info-item:last-child {
margin-bottom: 0;
}
.info-label {
font-size: 0.7rem;
color: #64748b;
margin-right: 0.5rem;
}
.info-value {
font-size: 0.8rem;
color: #e2e8f0;
font-weight: 500;
text-align: right;
flex-shrink: 0;
}
.ip-list {
display: flex;
flex-direction: column;
gap: 0.375rem;
margin-top: 0.375rem;
}
.ip-item {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
}
.ip-address {
font-size: 0.75rem;
color: #e2e8f0;
font-weight: 500;
font-family: 'Courier New', monospace;
}
.ip-interface {
font-size: 0.7rem;
color: #64748b;
}
.processes-list {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.process-item {
padding: 0.375rem;
background: rgba(15, 23, 42, 0.6);
border-radius: 4px;
border-left: 2px solid rgba(148, 163, 184, 0.2);
}
.process-name {
font-size: 0.75rem;
color: #e2e8f0;
font-weight: 500;
margin-bottom: 0.2rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.process-stats {
display: flex;
gap: 0.75rem;
font-size: 0.7rem;
}
.process-cpu {
color: #fc2922;
}
.process-mem {
color: #ff6b5a;
}
.containers-list {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.container-item {
padding: 0.375rem;
background: rgba(15, 23, 42, 0.6);
border-radius: 4px;
border-left: 2px solid rgba(34, 211, 153, 0.3);
}
.container-name {
font-size: 0.75rem;
color: #e2e8f0;
font-weight: 500;
margin-bottom: 0.2rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.container-image {
font-size: 0.7rem;
color: #94a3b8;
margin-bottom: 0.2rem;
font-family: 'Courier New', monospace;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.container-status {
font-size: 0.7rem;
color: #64748b;
}
.server-info-empty {
font-size: 0.75rem;
color: #64748b;
font-style: italic;
padding: 0.375rem 0;
}
.server-info-loading,
.server-info-error {
font-size: 0.8rem;
color: #94a3b8;
text-align: center;
padding: 1.5rem 0;
}
.server-info-error {
color: #ef4444;
}
.update-status {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.8rem;
font-weight: 500;
padding: 0.25rem 0.5rem;
border-radius: 4px;
text-align: right;
}
.update-status.up-to-date {
color: #22c55e;
background: rgba(34, 197, 94, 0.1);
}
.update-status.outdated {
color: #f59e0b;
background: rgba(245, 158, 11, 0.1);
}
.update-status .status-icon {
font-size: 0.9rem;
}
/* Scrollbar styling */
.server-info-pane::-webkit-scrollbar {
width: 6px;
}
.server-info-pane::-webkit-scrollbar-track {
background: rgba(15, 23, 42, 0.3);
}
.server-info-pane::-webkit-scrollbar-thumb {
background: rgba(148, 163, 184, 0.3);
border-radius: 3px;
}
.server-info-pane::-webkit-scrollbar-thumb:hover {
background: rgba(148, 163, 184, 0.5);
}

View File

@@ -0,0 +1,184 @@
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import './ServerInfoPane.css';
const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:3001';
function ServerInfoPane({ serverId }) {
const [serverInfo, setServerInfo] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
if (!serverId) {
setLoading(false);
return;
}
const fetchServerInfo = async () => {
try {
const response = await axios.get(`${API_URL}/api/servers/${serverId}/info`);
setServerInfo(response.data.serverInfo);
setError(null);
} catch (err) {
// Don't show error if server info doesn't exist yet (404)
if (err.response?.status === 404) {
setServerInfo(null);
} else {
setError('Failed to fetch server info');
console.error('Error fetching server info:', err);
}
} finally {
setLoading(false);
}
};
fetchServerInfo();
// Refresh every 30 seconds
const interval = setInterval(fetchServerInfo, 30000);
return () => clearInterval(interval);
}, [serverId]);
if (loading) {
return (
<div className="server-info-pane">
<div className="server-info-loading">Loading server info...</div>
</div>
);
}
if (error) {
return (
<div className="server-info-pane">
<div className="server-info-error">{error}</div>
</div>
);
}
if (!serverInfo) {
return (
<div className="server-info-pane">
<div className="server-info-empty">No server information available</div>
</div>
);
}
const formatUptime = (status) => {
if (!status) return 'N/A';
const { uptime_days, uptime_hours, uptime_minutes } = status;
if (uptime_days > 0) {
return `${uptime_days}d ${uptime_hours}h ${uptime_minutes}m`;
} else if (uptime_hours > 0) {
return `${uptime_hours}h ${uptime_minutes}m`;
} else {
return `${uptime_minutes}m`;
}
};
return (
<div className="server-info-pane">
{/* OS Release Info */}
<div className="server-info-section">
<h4 className="server-info-section-title">OS Information</h4>
{serverInfo.os_release ? (
<div className="server-info-content">
{serverInfo.os_release.PRETTY_NAME && (
<div className="server-info-item">
<span className="info-label">OS:</span>
<span className="info-value">{serverInfo.os_release.PRETTY_NAME}</span>
</div>
)}
{serverInfo.os_release.VERSION_ID && (
<div className="server-info-item">
<span className="info-label">Version:</span>
<span className="info-value">{serverInfo.os_release.VERSION_ID}</span>
</div>
)}
</div>
) : (
<div className="server-info-empty">No OS information available</div>
)}
</div>
{/* Live Status */}
<div className="server-info-section">
<h4 className="server-info-section-title">Live Status</h4>
{serverInfo.live_status ? (
<div className="server-info-content">
<div className="server-info-item">
<span className="info-label">Uptime:</span>
<span className="info-value">{formatUptime(serverInfo.live_status)}</span>
</div>
<div className="server-info-item">
<span className="info-label">Load:</span>
<span className="info-value">
{serverInfo.live_status.load_average_1min?.toFixed(2) || 'N/A'} / {serverInfo.live_status.load_average_5min?.toFixed(2) || 'N/A'} / {serverInfo.live_status.load_average_15min?.toFixed(2) || 'N/A'}
</span>
</div>
</div>
) : (
<div className="server-info-empty">No status information available</div>
)}
</div>
{/* Top Processes */}
<div className="server-info-section">
<h4 className="server-info-section-title">Top Processes</h4>
{serverInfo.top_processes && serverInfo.top_processes.length > 0 ? (
<div className="server-info-content">
<div className="processes-list">
{serverInfo.top_processes.slice(0, 5).map((proc, index) => (
<div key={index} className="process-item">
<div className="process-name">{proc.name || 'Unknown'}</div>
<div className="process-stats">
<span className="process-cpu">CPU: {proc.cpu_percent?.toFixed(1) || '0.0'}%</span>
<span className="process-mem">Mem: {proc.memory_percent?.toFixed(1) || '0.0'}%</span>
</div>
</div>
))}
</div>
</div>
) : (
<div className="server-info-empty">No process information available</div>
)}
</div>
{/* IP Information */}
<div className="server-info-section">
<h4 className="server-info-section-title">Network Information</h4>
{serverInfo.ip_info ? (
<div className="server-info-content">
{serverInfo.ip_info.public && (
<div className="server-info-item">
<span className="info-label">Public IP:</span>
<span className="info-value">{serverInfo.ip_info.public}</span>
</div>
)}
{serverInfo.ip_info.private && serverInfo.ip_info.private.length > 0 && (
<div className="server-info-item">
<span className="info-label">Private IPs:</span>
<div className="ip-list">
{serverInfo.ip_info.private.map((ip, index) => (
<div key={index} className="ip-item">
<span className="ip-address">{ip.ip}</span>
<span className="ip-interface">({ip.interface})</span>
</div>
))}
</div>
</div>
)}
{!serverInfo.ip_info.public && (!serverInfo.ip_info.private || serverInfo.ip_info.private.length === 0) && (
<div className="server-info-empty">No IP information available</div>
)}
</div>
) : (
<div className="server-info-empty">No network information available</div>
)}
</div>
</div>
);
}
export default ServerInfoPane;

View File

@@ -0,0 +1,129 @@
.server-list {
background: rgba(30, 41, 59, 0.6);
border-radius: 12px;
padding: 1.5rem;
height: fit-content;
backdrop-filter: blur(10px);
border: 1px solid rgba(148, 163, 184, 0.1);
width: 100%;
box-sizing: border-box;
}
@media (max-width: 768px) {
.server-list {
padding: 1rem;
border-radius: 8px;
}
.server-list h2 {
font-size: 1.1rem;
margin-bottom: 0.875rem;
}
.server-item {
padding: 0.875rem;
min-height: 44px;
font-size: 0.85rem;
}
.server-icon {
font-size: 1.1rem;
}
}
@media (max-width: 480px) {
.server-list {
padding: 0.875rem;
}
.server-list h2 {
font-size: 1rem;
}
.server-item {
padding: 0.75rem;
font-size: 0.8rem;
}
.no-servers {
padding: 1.5rem 0.75rem;
}
.no-servers p {
font-size: 0.9rem;
}
.no-servers small {
font-size: 0.75rem;
}
}
@media (hover: none) and (pointer: coarse) {
.server-item:hover {
transform: none;
}
}
.server-list h2 {
font-size: 1.25rem;
margin-bottom: 1rem;
color: #e2e8f0;
font-weight: 600;
}
.server-item {
width: 100%;
padding: 0.75rem 1rem;
margin-bottom: 0.5rem;
background: rgba(15, 23, 42, 0.5);
border: 1px solid rgba(148, 163, 184, 0.1);
border-radius: 8px;
color: #e2e8f0;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 0.9rem;
text-align: left;
}
.server-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.server-item:hover {
background: rgba(30, 41, 59, 0.8);
border-color: rgba(96, 165, 250, 0.3);
transform: translateX(4px);
}
.server-item.active {
background: rgba(96, 165, 250, 0.2);
border-color: rgba(96, 165, 250, 0.5);
}
.server-icon {
font-size: 1.2rem;
}
.no-servers {
padding: 2rem 1rem;
text-align: center;
color: #94a3b8;
}
.no-servers p {
margin-bottom: 0.5rem;
font-weight: 500;
}
.no-servers small {
font-size: 0.8rem;
opacity: 0.7;
}

View File

@@ -0,0 +1,41 @@
import React from 'react';
import './ServerList.css';
function ServerList({ servers, selectedServer, onSelect, onViewAll }) {
return (
<div className="server-list">
<h2>Servers</h2>
<button
className={`server-item ${selectedServer === null ? 'active' : ''}`}
onClick={onViewAll}
>
<span className="server-icon">
<i className="fas fa-globe"></i>
</span>
<span className="server-name">All Servers</span>
</button>
{servers.length === 0 ? (
<div className="no-servers">
<p>No servers connected</p>
<small>Install the client agent on your Ubuntu servers</small>
</div>
) : (
servers.map((serverId) => (
<button
key={serverId}
className={`server-item ${selectedServer === serverId ? 'active' : ''}`}
onClick={() => onSelect(serverId)}
>
<span className="server-icon">
<i className="fas fa-server"></i>
</span>
<span className="server-name">{serverId}</span>
</button>
))
)}
</div>
);
}
export default ServerList;

View File

@@ -0,0 +1,189 @@
.server-security {
padding: 1rem 0;
}
.security-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.stat-card {
background: rgba(30, 41, 59, 0.8);
border: 1px solid rgba(148, 163, 184, 0.2);
border-radius: 8px;
padding: 1rem;
text-align: center;
}
.stat-card.critical {
border-color: rgba(239, 68, 68, 0.3);
}
.stat-card.high {
border-color: rgba(249, 115, 22, 0.3);
}
.stat-card.medium {
border-color: rgba(234, 179, 8, 0.3);
}
.stat-card.low {
border-color: rgba(59, 130, 246, 0.3);
}
.stat-label {
color: #94a3b8;
font-size: 0.75rem;
margin-bottom: 0.5rem;
}
.stat-value {
color: #f1f5f9;
font-size: 1.5rem;
font-weight: 700;
}
.error-message {
background: #ef4444;
color: white;
padding: 1rem;
border-radius: 6px;
margin-bottom: 1rem;
}
.security-filters {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.filter-group {
display: flex;
align-items: center;
gap: 0.5rem;
}
.filter-group label {
color: #f1f5f9;
font-size: 0.875rem;
font-weight: 500;
}
.filter-group select,
.filter-group input {
padding: 0.5rem 0.75rem;
background: rgba(15, 23, 42, 0.8);
border: 1px solid rgba(148, 163, 184, 0.3);
border-radius: 6px;
color: #f1f5f9;
font-size: 0.875rem;
}
.filter-group select:focus,
.filter-group input:focus {
outline: none;
border-color: #fc2922;
}
.filter-group input {
min-width: 200px;
}
.no-vulnerabilities {
text-align: center;
padding: 3rem 2rem;
color: #94a3b8;
}
.vulnerabilities-table-container {
overflow-x: auto;
background: rgba(30, 41, 59, 0.8);
border-radius: 8px;
border: 1px solid rgba(252, 41, 34, 0.2);
}
.vulnerabilities-table {
width: 100%;
border-collapse: collapse;
}
.vulnerabilities-table thead {
background: rgba(15, 23, 42, 0.8);
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
}
.vulnerabilities-table th {
padding: 1rem;
text-align: left;
color: #f1f5f9;
font-size: 0.875rem;
font-weight: 600;
text-transform: uppercase;
}
.vulnerabilities-table td {
padding: 1rem;
color: #cbd5e1;
font-size: 0.875rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
}
.vulnerabilities-table tbody tr:hover {
background: rgba(252, 41, 34, 0.05);
}
.severity-badge,
.status-badge {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.package-name {
font-weight: 500;
color: #f1f5f9;
}
.version {
font-family: monospace;
color: #cbd5e1;
}
.fixed-version {
font-family: monospace;
color: #10b981;
font-weight: 600;
}
.cve-link,
.action-link {
color: #3b82f6;
text-decoration: none;
transition: color 0.2s;
}
.cve-link:hover,
.action-link:hover {
color: #60a5fa;
text-decoration: underline;
}
@media (max-width: 768px) {
.vulnerabilities-table-container {
overflow-x: scroll;
}
.vulnerabilities-table {
min-width: 1000px;
}
.security-stats {
grid-template-columns: repeat(3, 1fr);
}
}

View File

@@ -0,0 +1,260 @@
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import './ServerSecurity.css';
const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:3001';
function ServerSecurity({ serverId }) {
const [vulnerabilities, setVulnerabilities] = useState([]);
const [stats, setStats] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [filters, setFilters] = useState({
severity: '',
hasFix: '',
search: ''
});
useEffect(() => {
if (serverId) {
fetchVulnerabilities();
fetchStats();
const interval = setInterval(() => {
fetchVulnerabilities();
fetchStats();
}, 30000); // Refresh every 30 seconds
return () => clearInterval(interval);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [serverId, filters.severity, filters.hasFix]);
const fetchVulnerabilities = async () => {
try {
const params = {};
if (filters.severity) {
params.severity = filters.severity;
}
if (filters.hasFix) {
params.hasFix = filters.hasFix === 'true' ? 'true' : 'false';
}
const response = await axios.get(`${API_URL}/api/servers/${serverId}/vulnerabilities`, { params });
setVulnerabilities(response.data.vulnerabilities || []);
setLoading(false);
setError(null);
} catch (err) {
setError('Failed to fetch vulnerabilities');
setLoading(false);
console.error('Error fetching vulnerabilities:', err);
}
};
const fetchStats = async () => {
try {
const response = await axios.get(`${API_URL}/api/servers/${serverId}/vulnerabilities/stats`);
setStats(response.data.stats);
} catch (err) {
console.error('Error fetching vulnerability stats:', err);
}
};
const getSeverityColor = (severity) => {
switch (severity) {
case 'CRITICAL':
return '#ef4444';
case 'HIGH':
return '#f97316';
case 'MEDIUM':
return '#eab308';
case 'LOW':
return '#3b82f6';
default:
return '#94a3b8';
}
};
const getSeverityBadge = (severity) => {
return (
<span
className="severity-badge"
style={{ backgroundColor: getSeverityColor(severity) + '20', color: getSeverityColor(severity) }}
>
{severity || 'UNKNOWN'}
</span>
);
};
const filteredVulnerabilities = vulnerabilities.filter(vuln => {
if (filters.severity && vuln.severity !== filters.severity) {
return false;
}
// Client-side filter for "Has Fix" (in case backend filter isn't applied)
// This ensures we only show entries with actual fixed version values
if (filters.hasFix === 'true') {
const hasFix = vuln.fixed_version && vuln.fixed_version.trim() !== '' && vuln.fixed_version !== '-';
if (!hasFix) {
return false;
}
} else if (filters.hasFix === 'false') {
const hasFix = vuln.fixed_version && vuln.fixed_version.trim() !== '' && vuln.fixed_version !== '-';
if (hasFix) {
return false;
}
}
if (filters.search) {
const searchLower = filters.search.toLowerCase();
return (
vuln.cve_id?.toLowerCase().includes(searchLower) ||
vuln.package_name?.toLowerCase().includes(searchLower) ||
vuln.summary?.toLowerCase().includes(searchLower)
);
}
return true;
});
if (loading) {
return (
<div className="server-security">
<div className="loading">Loading vulnerabilities...</div>
</div>
);
}
return (
<div className="server-security">
{stats && (
<div className="security-stats">
<div className="stat-card">
<div className="stat-label">Total</div>
<div className="stat-value">{stats.total || 0}</div>
</div>
<div className="stat-card critical">
<div className="stat-label">Critical</div>
<div className="stat-value">{stats.critical || 0}</div>
</div>
<div className="stat-card high">
<div className="stat-label">High</div>
<div className="stat-value">{stats.high || 0}</div>
</div>
<div className="stat-card medium">
<div className="stat-label">Medium</div>
<div className="stat-value">{stats.medium || 0}</div>
</div>
<div className="stat-card low">
<div className="stat-label">Low</div>
<div className="stat-value">{stats.low || 0}</div>
</div>
</div>
)}
{error && <div className="error-message">{error}</div>}
<div className="security-filters">
<div className="filter-group">
<label>Severity:</label>
<select
value={filters.severity}
onChange={(e) => setFilters({ ...filters, severity: e.target.value })}
>
<option value="">All</option>
<option value="CRITICAL">Critical</option>
<option value="HIGH">High</option>
<option value="MEDIUM">Medium</option>
<option value="LOW">Low</option>
</select>
</div>
<div className="filter-group">
<label>Status:</label>
<select
value={filters.status}
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
>
<option value="">All</option>
<option value="new">New</option>
<option value="ongoing">Ongoing</option>
</select>
</div>
<div className="filter-group">
<label>Has Fix:</label>
<select
value={filters.hasFix}
onChange={(e) => setFilters({ ...filters, hasFix: e.target.value })}
>
<option value="">All</option>
<option value="true">Has Fix</option>
<option value="false">No Fix Available</option>
</select>
</div>
<div className="filter-group">
<label>Search:</label>
<input
type="text"
placeholder="CVE ID, package name..."
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
/>
</div>
</div>
{filteredVulnerabilities.length === 0 ? (
<div className="no-vulnerabilities">
<p>{vulnerabilities.length === 0 ? 'No vulnerabilities found for this server' : 'No vulnerabilities match filters'}</p>
</div>
) : (
<div className="vulnerabilities-table-container">
<table className="vulnerabilities-table">
<thead>
<tr>
<th>CVE ID</th>
<th>Severity</th>
<th>Package</th>
<th>Installed Version</th>
<th>Fixed Version</th>
<th>First Detected</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{filteredVulnerabilities.map((vuln) => (
<tr key={vuln.id}>
<td>
{vuln.cve_id ? (
<a
href={`https://www.cve.org/CVERecord?id=${vuln.cve_id}`}
target="_blank"
rel="noopener noreferrer"
className="cve-link"
>
{vuln.cve_id}
</a>
) : (
<span className="no-cve">No CVE ID</span>
)}
</td>
<td>{getSeverityBadge(vuln.severity)}</td>
<td className="package-name">{vuln.package_name}</td>
<td className="version">{vuln.installed_version}</td>
<td className="fixed-version">{vuln.fixed_version || '-'}</td>
<td>{new Date(vuln.first_detected).toLocaleDateString()}</td>
<td>
{vuln.cve_id && (
<a
href={`https://www.cve.org/CVERecord?id=${vuln.cve_id}`}
target="_blank"
rel="noopener noreferrer"
className="action-link"
>
Details
</a>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
export default ServerSecurity;

View File

@@ -0,0 +1,297 @@
.synthetic-monitors {
padding: 2rem;
max-width: 1400px;
margin: 0 auto;
}
.monitors-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.monitors-header h2 {
margin: 0;
color: #f1f5f9;
font-size: 1.875rem;
}
.add-monitor-button {
background: #fc2922;
border: none;
border-radius: 6px;
padding: 0.75rem 1.5rem;
color: white;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.add-monitor-button:hover {
background: #e0241e;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(252, 41, 34, 0.4);
}
.error-message {
background: #ef4444;
color: white;
padding: 1rem;
border-radius: 6px;
margin-bottom: 1rem;
}
.no-monitors {
text-align: center;
padding: 4rem 2rem;
color: #94a3b8;
}
.no-monitors p {
font-size: 1.125rem;
margin-bottom: 1.5rem;
}
.monitors-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 1.5rem;
}
.monitor-card {
background: rgba(30, 41, 59, 0.8);
border: 1px solid rgba(252, 41, 34, 0.2);
border-radius: 8px;
padding: 1.5rem;
transition: all 0.2s;
}
.monitor-card:hover {
border-color: rgba(252, 41, 34, 0.4);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.monitor-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1rem;
}
.monitor-title {
flex: 1;
}
.monitor-title h3 {
margin: 0 0 0.5rem 0;
color: #f1f5f9;
font-size: 1.125rem;
}
.status-badge {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.status-badge.up {
background: rgba(16, 185, 129, 0.2);
color: #10b981;
}
.status-badge.down {
background: rgba(239, 68, 68, 0.2);
color: #ef4444;
}
.status-badge.not {
background: rgba(148, 163, 184, 0.2);
color: #94a3b8;
}
.monitor-actions {
display: flex;
gap: 0.5rem;
}
.toggle-button,
.edit-button,
.delete-button {
background: transparent;
border: 1px solid rgba(148, 163, 184, 0.3);
border-radius: 4px;
padding: 0.375rem 0.625rem;
color: #94a3b8;
cursor: pointer;
transition: all 0.2s;
font-size: 1rem;
}
.toggle-button:hover {
border-color: #10b981;
color: #10b981;
}
.edit-button:hover {
border-color: #3b82f6;
color: #3b82f6;
}
.delete-button:hover {
border-color: #ef4444;
color: #ef4444;
}
.monitor-details {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.detail-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.detail-label {
color: #94a3b8;
font-size: 0.875rem;
}
.detail-value {
color: #f1f5f9;
font-size: 0.875rem;
font-weight: 500;
text-align: right;
word-break: break-word;
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
background: #1e293b;
border: 1px solid rgba(252, 41, 34, 0.3);
border-radius: 8px;
padding: 2rem;
max-width: 500px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
}
.modal-content h3 {
margin: 0 0 1.5rem 0;
color: #f1f5f9;
font-size: 1.5rem;
}
.form-group {
margin-bottom: 1.25rem;
}
.form-group label {
display: block;
color: #f1f5f9;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.5rem;
}
.form-group input[type="text"],
.form-group input[type="number"],
.form-group select {
width: 100%;
padding: 0.75rem;
background: rgba(15, 23, 42, 0.8);
border: 1px solid rgba(148, 163, 184, 0.3);
border-radius: 6px;
color: #f1f5f9;
font-size: 0.875rem;
box-sizing: border-box;
}
.form-group input[type="text"]:focus,
.form-group input[type="number"]:focus,
.form-group select:focus {
outline: none;
border-color: #fc2922;
}
.form-group input[type="checkbox"] {
margin-right: 0.5rem;
}
.form-actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
margin-top: 2rem;
}
.form-actions button {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.form-actions button[type="button"] {
background: transparent;
color: #94a3b8;
border: 1px solid rgba(148, 163, 184, 0.3);
}
.form-actions button[type="button"]:hover {
background: rgba(148, 163, 184, 0.1);
border-color: rgba(148, 163, 184, 0.5);
}
.form-actions button[type="submit"] {
background: #fc2922;
color: white;
}
.form-actions button[type="submit"]:hover {
background: #e0241e;
}
@media (max-width: 768px) {
.synthetic-monitors {
padding: 1rem;
}
.monitors-header {
flex-direction: column;
align-items: stretch;
gap: 1rem;
}
.monitors-grid {
grid-template-columns: 1fr;
}
.modal-content {
width: 95%;
padding: 1.5rem;
}
}

View File

@@ -0,0 +1,337 @@
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import './SyntheticMonitors.css';
const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:3001';
function SyntheticMonitors() {
const [monitors, setMonitors] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [showAddModal, setShowAddModal] = useState(false);
const [editingMonitor, setEditingMonitor] = useState(null);
const [formData, setFormData] = useState({
name: '',
type: 'http_status',
target: '',
expected_status: 200,
port: 80,
interval: 60,
enabled: true
});
useEffect(() => {
fetchMonitors();
const interval = setInterval(fetchMonitors, 30000); // Refresh every 30 seconds
return () => clearInterval(interval);
}, []);
const fetchMonitors = async () => {
try {
const response = await axios.get(`${API_URL}/api/synthetic-monitors`);
setMonitors(response.data.monitors || []);
setLoading(false);
setError(null);
} catch (err) {
setError('Failed to fetch monitors');
setLoading(false);
console.error('Error fetching monitors:', err);
}
};
const handleAdd = () => {
setEditingMonitor(null);
setFormData({
name: '',
type: 'http_status',
target: '',
expected_status: 200,
port: 80,
interval: 60,
enabled: true
});
setShowAddModal(true);
};
const handleEdit = (monitor) => {
setEditingMonitor(monitor);
setFormData({
name: monitor.name,
type: monitor.type,
target: monitor.target,
expected_status: monitor.expected_status || 200,
port: monitor.port || 80,
interval: monitor.interval || 60,
enabled: monitor.enabled !== false
});
setShowAddModal(true);
};
const handleSubmit = async (e) => {
e.preventDefault();
try {
if (editingMonitor) {
await axios.put(`${API_URL}/api/synthetic-monitors/${editingMonitor.id}`, formData);
} else {
await axios.post(`${API_URL}/api/synthetic-monitors`, formData);
}
setShowAddModal(false);
fetchMonitors();
} catch (err) {
setError(err.response?.data?.error || 'Failed to save monitor');
console.error('Error saving monitor:', err);
}
};
const handleDelete = async (id) => {
if (!window.confirm('Are you sure you want to delete this monitor?')) {
return;
}
try {
await axios.delete(`${API_URL}/api/synthetic-monitors/${id}`);
fetchMonitors();
} catch (err) {
setError(err.response?.data?.error || 'Failed to delete monitor');
console.error('Error deleting monitor:', err);
}
};
const handleToggleEnabled = async (monitor) => {
try {
await axios.put(`${API_URL}/api/synthetic-monitors/${monitor.id}`, {
...monitor,
enabled: !monitor.enabled
});
fetchMonitors();
} catch (err) {
setError(err.response?.data?.error || 'Failed to update monitor');
console.error('Error updating monitor:', err);
}
};
const getStatusColor = (status) => {
if (status === 'up' || status === 'success') return '#10b981';
if (status === 'down' || status === 'failed') return '#ef4444';
return '#94a3b8';
};
const getStatusText = (monitor) => {
if (!monitor.last_result) return 'Not checked yet';
if (monitor.last_result.status === 'success') return 'Up';
if (monitor.last_result.status === 'failed') return 'Down';
return 'Unknown';
};
if (loading) {
return (
<div className="synthetic-monitors">
<div className="loading">Loading monitors...</div>
</div>
);
}
return (
<div className="synthetic-monitors">
<div className="monitors-header">
<h2>Synthetic Monitors</h2>
<button className="add-monitor-button" onClick={handleAdd}>
+ Add Monitor
</button>
</div>
{error && <div className="error-message">{error}</div>}
{monitors.length === 0 ? (
<div className="no-monitors">
<p>No monitors configured</p>
<button className="add-monitor-button" onClick={handleAdd}>
Add Your First Monitor
</button>
</div>
) : (
<div className="monitors-grid">
{monitors.map((monitor) => (
<div key={monitor.id} className="monitor-card">
<div className="monitor-header">
<div className="monitor-title">
<h3>{monitor.name}</h3>
<span className={`status-badge ${getStatusText(monitor).toLowerCase()}`}>
{getStatusText(monitor)}
</span>
</div>
<div className="monitor-actions">
<button
className="toggle-button"
onClick={() => handleToggleEnabled(monitor)}
title={monitor.enabled ? 'Disable' : 'Enable'}
>
{monitor.enabled ? '●' : '○'}
</button>
<button
className="edit-button"
onClick={() => handleEdit(monitor)}
title="Edit"
>
</button>
<button
className="delete-button"
onClick={() => handleDelete(monitor.id)}
title="Delete"
>
×
</button>
</div>
</div>
<div className="monitor-details">
<div className="detail-row">
<span className="detail-label">Type:</span>
<span className="detail-value">{monitor.type.replace('_', ' ').toUpperCase()}</span>
</div>
<div className="detail-row">
<span className="detail-label">Target:</span>
<span className="detail-value">{monitor.target}</span>
</div>
{monitor.type === 'port_check' && (
<div className="detail-row">
<span className="detail-label">Port:</span>
<span className="detail-value">{monitor.port}</span>
</div>
)}
{monitor.type === 'http_status' && (
<div className="detail-row">
<span className="detail-label">Expected Status:</span>
<span className="detail-value">{monitor.expected_status}</span>
</div>
)}
<div className="detail-row">
<span className="detail-label">Interval:</span>
<span className="detail-value">{monitor.interval}s</span>
</div>
{monitor.last_result && (
<>
<div className="detail-row">
<span className="detail-label">Last Check:</span>
<span className="detail-value">
{new Date(monitor.last_result.timestamp).toLocaleString()}
</span>
</div>
{monitor.last_result.response_time && (
<div className="detail-row">
<span className="detail-label">Response Time:</span>
<span className="detail-value">{monitor.last_result.response_time}ms</span>
</div>
)}
{monitor.last_result.message && (
<div className="detail-row">
<span className="detail-label">Message:</span>
<span className="detail-value">{monitor.last_result.message}</span>
</div>
)}
</>
)}
</div>
</div>
))}
</div>
)}
{showAddModal && (
<div className="modal-overlay" onClick={() => setShowAddModal(false)}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<h3>{editingMonitor ? 'Edit Monitor' : 'Add Monitor'}</h3>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>Name</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
placeholder="e.g., Main Website"
/>
</div>
<div className="form-group">
<label>Type</label>
<select
value={formData.type}
onChange={(e) => setFormData({ ...formData, type: e.target.value })}
required
>
<option value="http_status">HTTP Status Check</option>
<option value="ping">Ping Check</option>
<option value="port_check">Port Check</option>
</select>
</div>
<div className="form-group">
<label>Target URL/Host</label>
<input
type="text"
value={formData.target}
onChange={(e) => setFormData({ ...formData, target: e.target.value })}
required
placeholder={formData.type === 'port_check' ? 'e.g., example.com' : 'e.g., https://example.com'}
/>
</div>
{formData.type === 'port_check' && (
<div className="form-group">
<label>Port</label>
<input
type="number"
value={formData.port}
onChange={(e) => setFormData({ ...formData, port: parseInt(e.target.value) })}
required
min="1"
max="65535"
/>
</div>
)}
{formData.type === 'http_status' && (
<div className="form-group">
<label>Expected HTTP Status</label>
<input
type="number"
value={formData.expected_status}
onChange={(e) => setFormData({ ...formData, expected_status: parseInt(e.target.value) })}
required
min="100"
max="599"
/>
</div>
)}
<div className="form-group">
<label>Check Interval (seconds)</label>
<input
type="number"
value={formData.interval}
onChange={(e) => setFormData({ ...formData, interval: parseInt(e.target.value) })}
required
min="10"
max="3600"
/>
</div>
<div className="form-group">
<label>
<input
type="checkbox"
checked={formData.enabled}
onChange={(e) => setFormData({ ...formData, enabled: e.target.checked })}
/>
Enabled
</label>
</div>
<div className="form-actions">
<button type="button" onClick={() => setShowAddModal(false)}>
Cancel
</button>
<button type="submit">{editingMonitor ? 'Update' : 'Create'}</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}
export default SyntheticMonitors;

View File

@@ -0,0 +1,214 @@
.wiki {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
color: #ffffff;
}
.wiki-header {
margin-bottom: 2rem;
border-bottom: 2px solid #334155;
padding-bottom: 1rem;
}
.wiki-header h1 {
font-size: 2.5rem;
font-weight: 700;
margin: 0 0 0.5rem 0;
color: #f1f5f9;
}
.wiki-subtitle {
font-size: 1.125rem;
color: #cbd5e1;
margin: 0;
}
.wiki-content {
line-height: 1.8;
}
.wiki-section {
margin-bottom: 1.5rem;
background: #1e293b;
border-radius: 8px;
border: 1px solid #334155;
overflow: hidden;
transition: border-color 0.2s;
}
.wiki-section:hover {
border-color: #475569;
}
.wiki-section-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.25rem 1.5rem;
cursor: pointer;
user-select: none;
background: #1e293b;
transition: background-color 0.2s;
}
.wiki-section-header:hover {
background: #334155;
}
.wiki-section-header h2 {
font-size: 1.5rem;
font-weight: 600;
margin: 0;
color: #f1f5f9;
}
.wiki-expand-icon {
font-size: 0.875rem;
color: #94a3b8;
transition: transform 0.2s, color 0.2s;
flex-shrink: 0;
margin-left: 1rem;
}
.wiki-section-header:hover .wiki-expand-icon {
color: #cbd5e1;
}
.wiki-section-content {
padding: 1.5rem;
border-top: 1px solid #334155;
background: #0f172a;
color: #e2e8f0;
}
.wiki-subsection {
margin-bottom: 2rem;
padding: 1.5rem;
background: #1e293b;
border-radius: 6px;
border-left: 4px solid #3b82f6;
}
.wiki-subsection:last-child {
margin-bottom: 0;
}
.wiki-subsection h3 {
font-size: 1.25rem;
font-weight: 600;
margin: 0 0 1rem 0;
color: #f1f5f9;
}
.wiki-subsection p {
margin: 0 0 1rem 0;
color: #cbd5e1;
line-height: 1.7;
}
.wiki-subsection ul,
.wiki-subsection ol {
margin: 0 0 1rem 0;
padding-left: 1.5rem;
color: #cbd5e1;
}
.wiki-subsection li {
margin-bottom: 0.75rem;
line-height: 1.7;
}
.wiki-subsection li strong {
color: #f1f5f9;
font-weight: 600;
}
.wiki-code {
background: #0f172a;
color: #e2e8f0;
padding: 1.25rem;
border-radius: 6px;
overflow-x: auto;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', monospace;
font-size: 0.875rem;
line-height: 1.6;
margin: 1rem 0;
white-space: pre;
border: 1px solid #334155;
border-left: 3px solid #3b82f6;
}
.wiki-subsection code {
background: #1e293b;
color: #60a5fa;
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', monospace;
font-size: 0.9em;
border: 1px solid #334155;
}
.wiki-subsection pre {
margin: 1rem 0;
}
.wiki-section-content > p {
color: #cbd5e1;
line-height: 1.7;
margin-bottom: 1rem;
}
.wiki-section-content > ul {
color: #cbd5e1;
line-height: 1.7;
}
.wiki-section-content > ul li {
margin-bottom: 0.5rem;
}
.wiki-section-content > ul code {
background: #1e293b;
color: #60a5fa;
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', monospace;
font-size: 0.9em;
border: 1px solid #334155;
}
@media (max-width: 768px) {
.wiki {
padding: 1rem;
}
.wiki-header h1 {
font-size: 2rem;
}
.wiki-section-header {
padding: 1rem;
}
.wiki-section-header h2 {
font-size: 1.25rem;
}
.wiki-section-content {
padding: 1rem;
}
.wiki-subsection {
padding: 1rem;
}
.wiki-subsection h3 {
font-size: 1.125rem;
}
.wiki-code {
font-size: 0.75rem;
padding: 0.875rem;
}
}

View File

@@ -0,0 +1,649 @@
import React, { useState } from 'react';
import './Wiki.css';
function Wiki() {
const [expandedSections, setExpandedSections] = useState({
overview: true,
installation: false,
architecture: false,
metrics: false,
vulnerabilities: false,
updates: false,
monitors: false,
alerting: false,
forceUpdate: false,
forceScan: false,
logs: false,
status: false,
api: false,
config: false,
apiKeys: false,
troubleshooting: false,
endpoints: false
});
const toggleSection = (section) => {
setExpandedSections(prev => ({
...prev,
[section]: !prev[section]
}));
};
return (
<div className="wiki">
<div className="wiki-header">
<h1>Oculog Wiki</h1>
<p className="wiki-subtitle">Complete guide to using Oculog</p>
</div>
<div className="wiki-content">
{/* Overview Section */}
<div className="wiki-section">
<div
className="wiki-section-header"
onClick={() => toggleSection('overview')}
>
<h2>Overview</h2>
<span className="wiki-expand-icon">
{expandedSections.overview ? '▼' : '▶'}
</span>
</div>
{expandedSections.overview && (
<div className="wiki-section-content">
<p>
Oculog is a server metrics observability platform that tracks system performance,
security vulnerabilities, and provides synthetic monitoring capabilities. The platform
consists of a central server (backend API + web dashboard) and lightweight client agents
that run on your servers.
</p>
</div>
)}
</div>
{/* Installation Section */}
<div className="wiki-section">
<div
className="wiki-section-header"
onClick={() => toggleSection('installation')}
>
<h2>Installation</h2>
<span className="wiki-expand-icon">
{expandedSections.installation ? '▼' : '▶'}
</span>
</div>
{expandedSections.installation && (
<div className="wiki-section-content">
<div className="wiki-subsection">
<h3>Quick Install with curl</h3>
<p>
The fastest way to install the Oculog client on your Ubuntu server is using curl.
This method downloads and installs the client in a single command.
</p>
<p>
<strong>Prerequisites:</strong>
</p>
<ul>
<li>Ubuntu server (18.04 or later)</li>
<li>Root or sudo access</li>
<li>Network connectivity to your Oculog server</li>
<li>Server ID (unique identifier for this server)</li>
<li>Server URL (where your Oculog API server is accessible)</li>
</ul>
<p>
<strong>Installation Command:</strong>
</p>
<pre className="wiki-code">
{`curl -s SERVER_URL/api/download-client/SERVER_ID?serverUrl=SERVER_URL | bash`}
</pre>
<p>
Replace the placeholders:
</p>
<ul>
<li><code>SERVER_URL</code> - The URL where your Oculog API server is accessible (e.g., <code>http://192.168.1.100:3001</code> or <code>https://oculog.example.com</code>)</li>
<li><code>SERVER_ID</code> - A unique identifier for this server (e.g., <code>web-server-01</code> or <code>db-prod-01</code>)</li>
</ul>
<p>
<strong>Example:</strong>
</p>
<pre className="wiki-code">
{`curl -s http://192.168.1.100:3001/api/download-client/web-server-01?serverUrl=http://192.168.1.100:3001 | bash`}
</pre>
<p>
<strong>What this command does:</strong>
</p>
<ol>
<li>Downloads the pre-configured installer script from your Oculog server</li>
<li>The installer includes your server URL and automatically generates an API key</li>
<li>Installs the client as a systemd service</li>
<li>Starts the client and begins sending metrics immediately</li>
</ol>
<p>
<strong>Note:</strong> If you don't provide the <code>serverUrl</code> query parameter,
the installer will use the server URL from the request. It's recommended to include it
explicitly for clarity.
</p>
</div>
<div className="wiki-subsection">
<h3>Manual Installation</h3>
<p>
If you prefer to download and install manually:
</p>
<ol>
<li>
<strong>Download the installer script</strong>
<pre className="wiki-code">
{`# Using curl
curl -o oculog-client-install.sh SERVER_URL/api/download-client/SERVER_ID?serverUrl=SERVER_URL
# Or using wget
wget -O oculog-client-install.sh SERVER_URL/api/download-client/SERVER_ID?serverUrl=SERVER_URL`}
</pre>
</li>
<li>
<strong>Make it executable</strong>
<pre className="wiki-code">
{`chmod +x oculog-client-install.sh`}
</pre>
</li>
<li>
<strong>Run the installer</strong>
<pre className="wiki-code">
{`sudo ./oculog-client-install.sh`}
</pre>
</li>
</ol>
<p>
The installer will:
</p>
<ul>
<li>Create the configuration directory at <code>/etc/oculog/</code></li>
<li>Generate an API key and store it in <code>/etc/oculog/client.conf</code></li>
<li>Install the client script to <code>/usr/local/bin/oculog-client</code></li>
<li>Create and enable a systemd service</li>
<li>Start the client service</li>
</ul>
</div>
<div className="wiki-subsection">
<h3>Using the Download Client Dialog</h3>
<p>
You can also use the web dashboard to generate a custom installer:
</p>
<ol>
<li>Click the download icon (plus icon) in the left sidebar</li>
<li>Enter your Server ID (unique identifier for this server)</li>
<li>Optionally enter a custom Server URL (leave empty to use the default)</li>
<li>Click "Download Client Installer" to download the script</li>
<li>Transfer the script to your Ubuntu server</li>
<li>Run: <code>sudo bash oculog-client-install.sh</code></li>
</ol>
<p>
The dialog also provides a ready-to-use curl command that you can copy and paste directly
into your server terminal.
</p>
</div>
<div className="wiki-subsection">
<h3>Verifying Installation</h3>
<p>
After installation, verify the client is running:
</p>
<pre className="wiki-code">
{`# Check service status
sudo systemctl status oculog-client
# View client logs
sudo journalctl -u oculog-client -f
# Check configuration
sudo cat /etc/oculog/client.conf`}
</pre>
<p>
The client should appear in your Oculog dashboard within 30 seconds of installation.
</p>
</div>
</div>
)}
</div>
{/* How It Works Section */}
<div className="wiki-section">
<div
className="wiki-section-header"
onClick={() => toggleSection('architecture')}
>
<h2>How It Works</h2>
<span className="wiki-expand-icon">
{expandedSections.architecture ? '' : ''}
</span>
</div>
{expandedSections.architecture && (
<div className="wiki-section-content">
<div className="wiki-subsection">
<h3>Architecture</h3>
<p>
Oculog uses a client-server architecture:
</p>
<ul>
<li><strong>Server</strong>: Backend API (Express.js) + Frontend Dashboard (React) + PostgreSQL database</li>
<li><strong>Clients</strong>: Lightweight Python agents installed on monitored servers</li>
</ul>
<p>
Clients collect metrics and send them to the server via HTTP POST requests every 30 seconds
(configurable). The server stores metrics in PostgreSQL and displays them in real-time
on the web dashboard.
</p>
</div>
<div className="wiki-subsection">
<h3>Metrics Collection</h3>
<p>
The client agent collects the following metrics:
</p>
<ul>
<li><strong>CPU Usage</strong>: Percentage utilization and core count</li>
<li><strong>Memory</strong>: Total, used, available, and swap usage</li>
<li><strong>Disk</strong>: Usage per mounted filesystem</li>
<li><strong>Network</strong>: Bytes sent/received per interface</li>
<li><strong>Process Count</strong>: Total number of running processes</li>
<li><strong>Load Average</strong>: 1-minute, 5-minute, and 15-minute averages</li>
<li><strong>Uptime</strong>: System uptime in seconds</li>
</ul>
<p>
Metrics are collected every 30 seconds by default (configurable in <code>/etc/oculog/client.conf</code>).
</p>
</div>
<div className="wiki-subsection">
<h3>Vulnerability Scanning</h3>
<p>
The client automatically scans installed packages for known vulnerabilities:
</p>
<ul>
<li>Scans run automatically every hour</li>
<li>Uses <code>dpkg-query</code> to list all installed packages</li>
<li>Queries the OSV (Open Source Vulnerabilities) API for vulnerability information</li>
<li>Results are cached to reduce API calls</li>
<li>Vulnerabilities are stored in the database and displayed in the Security view</li>
</ul>
<p>
Vulnerabilities are categorized by severity (CRITICAL, HIGH, MEDIUM, LOW) and can be
filtered by status (new, ongoing). When vulnerabilities are resolved (package updated),
they are automatically removed from the database rather than being marked as "fixed".
</p>
</div>
<div className="wiki-subsection">
<h3>Client Auto-Updates</h3>
<p>
The client automatically checks for updates:
</p>
<ul>
<li>Update checks run every hour</li>
<li>Compares client version (build timestamp) with latest version from server</li>
<li>If an update is available, the client automatically downloads and installs it</li>
<li>The client restarts itself after a successful update</li>
</ul>
<p>
Client versions are based on the modification time of the client.py source file,
ensuring all clients from the same build have the same version identifier.
</p>
</div>
<div className="wiki-subsection">
<h3>Synthetic Monitors</h3>
<p>
Synthetic monitors allow you to monitor external endpoints:
</p>
<ul>
<li>HTTP/HTTPS endpoint monitoring</li>
<li>Configurable check intervals</li>
<li>Tracks response time, status codes, and availability</li>
<li>Results are stored and displayed in the Synthetic Monitors view</li>
</ul>
</div>
<div className="wiki-subsection">
<h3>Alerting</h3>
<p>
Alert policies allow you to set thresholds for various metrics:
</p>
<ul>
<li>CPU usage thresholds</li>
<li>Memory usage thresholds</li>
<li>Disk usage thresholds</li>
<li>Network throughput thresholds</li>
<li>Alert statuses: new, ongoing, resolved</li>
</ul>
<p>
Alerts are evaluated continuously and displayed in the Alerting view.
</p>
</div>
</div>
)}
</div>
{/* Tips & Tricks Section */}
<div className="wiki-section">
<div
className="wiki-section-header"
onClick={() => toggleSection('forceUpdate')}
>
<h2>Tips & Tricks</h2>
<span className="wiki-expand-icon">
{expandedSections.forceUpdate ? '' : ''}
</span>
</div>
{expandedSections.forceUpdate && (
<div className="wiki-section-content">
<div className="wiki-subsection">
<h3>Force a Client Update</h3>
<p>
To force a client to update immediately (without waiting for the hourly check):
</p>
<ol>
<li>
<strong>Method 1: Restart the client service</strong>
<pre className="wiki-code">
{`# On the monitored server
sudo systemctl restart oculog-client`}
</pre>
<p>
This will trigger an immediate update check when the client starts.
</p>
</li>
<li>
<strong>Method 2: Touch the client script on the server</strong>
<pre className="wiki-code">
{`# On the Oculog server (where client.py source is located)
touch clients/ubuntu/client.py`}
</pre>
<p>
This updates the file modification time, which changes the version identifier.
Clients checking for updates will detect the new version and update automatically.
</p>
</li>
<li>
<strong>Method 3: Manually trigger update check</strong>
<pre className="wiki-code">
{`# On the monitored server, edit the client script temporarily
# Change update_check_interval to a small value (e.g., 60 seconds)
# Or manually call the check_for_updates method`}
</pre>
</li>
</ol>
</div>
<div className="wiki-subsection">
<h3>Force a Vulnerability Scan</h3>
<p>
To force a client to perform a vulnerability scan immediately:
</p>
<ol>
<li>
<strong>Method 1: Restart the client service</strong>
<pre className="wiki-code">
{`# On the monitored server
sudo systemctl restart oculog-client`}
</pre>
<p>
The client will perform an initial scan when it starts, then continue with hourly scans.
</p>
</li>
<li>
<strong>Method 2: Temporarily reduce scan interval</strong>
<pre className="wiki-code">
{`# Edit the client script on the monitored server
# Find VulnerabilityScanner class and change:
# self.scan_interval = 60 # 1 minute instead of 3600 (1 hour)
# Then restart: sudo systemctl restart oculog-client`}
</pre>
</li>
<li>
<strong>Method 3: Check client logs</strong>
<pre className="wiki-code">
{`# View recent vulnerability scan activity
sudo journalctl -u oculog-client -n 100 | grep -i vuln`}
</pre>
</li>
</ol>
</div>
<div className="wiki-subsection">
<h3>Clear All Vulnerability Data</h3>
<p>
To clear all vulnerability data and start fresh (useful after updating vulnerability extraction logic):
</p>
<ol>
<li>
<strong>Method 1: Use the API endpoint (Recommended)</strong>
<pre className="wiki-code">
{`# Clear all vulnerabilities, server links, and cache
curl -X POST http://your-server:3001/api/security/vulnerabilities/clear-all`}
</pre>
<p>
This will:
</p>
<ul>
<li>Delete all vulnerability records</li>
<li>Delete all server-vulnerability links (automatically via CASCADE)</li>
<li>Clear the vulnerability cache</li>
<li>Return a summary of what was deleted</li>
</ul>
<p>
The API endpoint runs in a transaction, so if anything fails, all changes are rolled back.
</p>
</li>
<li>
<strong>Method 2: Direct SQL commands</strong>
<pre className="wiki-code">
{`# Connect to PostgreSQL
psql -U your_user -d your_database
# Clear vulnerability cache
DELETE FROM vulnerability_cache;
# Clear vulnerabilities (CASCADE will automatically delete server_vulnerabilities)
DELETE FROM vulnerabilities;
# Verify server_vulnerabilities is empty (should be automatic due to CASCADE)
SELECT COUNT(*) FROM server_vulnerabilities;`}
</pre>
<p>
<strong>Note:</strong> The <code>server_vulnerabilities</code> table has a foreign key with <code>ON DELETE CASCADE</code>,
so deleting vulnerabilities will automatically delete all related server links.
</p>
</li>
</ol>
<p>
<strong>After clearing:</strong> The next vulnerability scan from your clients will repopulate the data
with the updated extraction logic (proper CVE IDs, severity, fixed versions, etc.).
</p>
</div>
<div className="wiki-subsection">
<h3>Cleanup Duplicate Vulnerabilities</h3>
<p>
If you notice duplicate vulnerability records, you can clean them up:
</p>
<pre className="wiki-code">
{`# Remove duplicate vulnerabilities (keeps the most complete record)
curl -X POST http://your-server:3001/api/security/vulnerabilities/cleanup`}
</pre>
<p>
This will:
</p>
<ul>
<li>Find duplicates based on CVE ID + package name + ecosystem</li>
<li>Keep the record with the most complete data (has severity, fixed_version, etc.)</li>
<li>Remove duplicate records</li>
<li>Return the count of duplicates removed</li>
</ul>
</div>
<div className="wiki-subsection">
<h3>View Client Logs</h3>
<p>
Monitor client activity and troubleshoot issues:
</p>
<pre className="wiki-code">
{`# View all logs
sudo journalctl -u oculog-client -f
# View last 100 lines
sudo journalctl -u oculog-client -n 100
# View logs from today
sudo journalctl -u oculog-client --since today
# Filter for specific events
sudo journalctl -u oculog-client | grep -i "update"
sudo journalctl -u oculog-client | grep -i "vulnerability"
sudo journalctl -u oculog-client | grep -i "error"`}
</pre>
</div>
<div className="wiki-subsection">
<h3>Check Client Status</h3>
<p>
Verify the client is running correctly:
</p>
<pre className="wiki-code">
{`# Check service status
sudo systemctl status oculog-client
# Check if client process is running
ps aux | grep oculog-client
# Verify configuration
sudo cat /etc/oculog/client.conf
# Test API connectivity
curl -H "X-API-Key: YOUR_API_KEY" http://YOUR_SERVER:3001/health`}
</pre>
</div>
<div className="wiki-subsection">
<h3>View Metrics via API</h3>
<p>
Access metrics programmatically:
</p>
<pre className="wiki-code">
{`# List all servers
curl http://localhost:3001/api/servers
# Get latest metrics for a server
curl http://localhost:3001/api/servers/SERVER_ID/metrics?limit=10
# Get server info
curl http://localhost:3001/api/servers/SERVER_ID/info
# Get vulnerability stats
curl http://localhost:3001/api/servers/SERVER_ID/vulnerabilities/stats`}
</pre>
</div>
<div className="wiki-subsection">
<h3>Client Configuration</h3>
<p>
The client configuration file is located at <code>/etc/oculog/client.conf</code>:
</p>
<pre className="wiki-code">
{`{
"server_url": "http://your-server:3001",
"server_id": "unique-server-identifier",
"api_key": "your-api-key",
"interval": 30
}`}
</pre>
<p>
After modifying the configuration, restart the client:
</p>
<pre className="wiki-code">
{`sudo systemctl restart oculog-client`}
</pre>
</div>
<div className="wiki-subsection">
<h3>API Key Management</h3>
<p>
API keys are used to authenticate client requests:
</p>
<ul>
<li>Each server should have its own API key</li>
<li>Keys are created automatically during client installation</li>
<li>Keys can be managed via the API: <code>POST /api/keys</code> and <code>GET /api/keys/:serverId</code></li>
<li>Keys are stored hashed in the database for security</li>
</ul>
</div>
<div className="wiki-subsection">
<h3>Troubleshooting</h3>
<p>
Common issues and solutions:
</p>
<ul>
<li>
<strong>Client not sending metrics</strong>: Check network connectivity, API key, and service status
</li>
<li>
<strong>Outdated client version</strong>: Restart the service to trigger an update check
</li>
<li>
<strong>No vulnerabilities showing</strong>: Wait for the hourly scan or restart the client
</li>
<li>
<strong>High API usage</strong>: Vulnerability results are cached to minimize API calls
</li>
<li>
<strong>Database connection issues</strong>: Check PostgreSQL is running and accessible
</li>
</ul>
</div>
</div>
)}
</div>
{/* API Endpoints Section */}
<div className="wiki-section">
<div
className="wiki-section-header"
onClick={() => toggleSection('endpoints')}
>
<h2>API Endpoints</h2>
<span className="wiki-expand-icon">
{expandedSections.endpoints ? '' : ''}
</span>
</div>
{expandedSections.endpoints && (
<div className="wiki-section-content">
<p>
Key API endpoints for reference:
</p>
<ul>
<li><code>GET /health</code> - Health check</li>
<li><code>GET /api/servers</code> - List all servers</li>
<li><code>GET /api/servers/:serverId/metrics</code> - Get metrics for a server</li>
<li><code>POST /api/servers/:serverId/metrics</code> - Submit metrics (client use)</li>
<li><code>GET /api/servers/:serverId/info</code> - Get server information</li>
<li><code>GET /api/client-version/latest</code> - Get latest client version</li>
<li><code>GET /api/client-script</code> - Download client script</li>
<li><code>POST /api/servers/:serverId/vulnerabilities/scan</code> - Submit vulnerability scan (client use)</li>
<li><code>GET /api/servers/:serverId/vulnerabilities</code> - Get vulnerabilities for a server</li>
<li><code>GET /api/servers/:serverId/vulnerabilities/stats</code> - Get vulnerability statistics for a server</li>
<li><code>POST /api/security/vulnerabilities/clear-all</code> - Clear all vulnerability data</li>
<li><code>POST /api/security/vulnerabilities/cleanup</code> - Remove duplicate vulnerabilities</li>
<li><code>GET /api/synthetic-monitors</code> - List synthetic monitors</li>
<li><code>GET /api/alert-policies</code> - List alert policies</li>
<li><code>GET /api/alerts</code> - List alerts</li>
</ul>
</div>
)}
</div>
</div>
</div>
);
}
export default Wiki;

View File

@@ -0,0 +1,62 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
overscroll-behavior-y: none;
-webkit-text-size-adjust: 100%;
-moz-text-size-adjust: 100%;
text-size-adjust: 100%;
-webkit-tap-highlight-color: transparent;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #0f172a;
color: #e2e8f0;
overscroll-behavior-y: none;
-webkit-text-size-adjust: 100%;
-moz-text-size-adjust: 100%;
text-size-adjust: 100%;
width: 100%;
overflow-x: hidden;
}
/* Improve touch interactions on mobile */
button, a, select, input {
-webkit-tap-highlight-color: rgba(252, 41, 34, 0.2);
touch-action: manipulation;
}
/* Prevent horizontal scroll on mobile */
@media (max-width: 768px) {
html, body {
overflow-x: hidden;
width: 100%;
position: relative;
}
}
/* Hide React error overlay for external script errors */
iframe[src*="react-error-overlay"],
div[data-react-error-overlay],
div[style*="position: fixed"][style*="z-index: 2147483647"] {
display: none !important;
}
/* More specific selector for React error overlay */
body > div:last-child[style*="position: fixed"] {
display: none !important;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

View File

@@ -0,0 +1,216 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import ErrorBoundary from './components/ErrorBoundary';
// Suppress external script errors (browser extensions, injected scripts)
window.addEventListener('error', (event) => {
const message = event.message || '';
const filename = event.filename || '';
// Suppress errors from external sources (browser extensions, injected scripts)
if (
message === 'Script error.' ||
message.includes('ethereum') ||
message.includes('web3') ||
message.includes('selectedAddress') ||
message.includes('window.ethereum') ||
message.includes('undefined is not an object') ||
filename === '' ||
filename.includes('chrome-extension://') ||
filename.includes('moz-extension://') ||
filename.includes('safari-extension://')
) {
event.preventDefault();
event.stopPropagation();
return false;
}
}, true);
// Suppress unhandled promise rejections from external sources
window.addEventListener('unhandledrejection', (event) => {
const message = event.reason?.message || event.reason?.toString() || '';
if (
message.includes('ethereum') ||
message.includes('web3') ||
message === 'Script error.'
) {
event.preventDefault();
}
});
// Suppress console errors for known external errors
const originalConsoleError = console.error;
console.error = (...args) => {
const message = args.join(' ');
if (
message.includes('ethereum') ||
message.includes('web3') ||
message === 'Script error.' ||
message.includes('window.ethereum') ||
message.includes('selectedAddress') ||
message.includes('undefined is not an object') ||
message.includes('handleError@')
) {
return; // Suppress these errors
}
originalConsoleError.apply(console, args);
};
// Intercept and suppress React error overlay for external script errors
// This runs after React loads to catch errors that React's overlay might show
const suppressReactErrorOverlay = () => {
// Check for React error overlay and dismiss it if it shows external errors
const checkAndDismissOverlay = () => {
// Look for the error overlay container - check multiple possible selectors
const selectors = [
'[data-react-error-overlay]',
'iframe[src*="react-error-overlay"]',
'div[style*="position: fixed"][style*="z-index: 2147483647"]',
'div[style*="position:fixed"][style*="z-index:2147483647"]',
'body > div:last-child[style*="position: fixed"]',
'body > iframe:last-child'
];
let overlay = null;
for (const selector of selectors) {
overlay = document.querySelector(selector);
if (overlay) break;
}
if (overlay) {
// Check if the overlay contains external error messages
let overlayText = '';
try {
overlayText = overlay.textContent || overlay.innerText || '';
// Also check iframe content if it's an iframe
if (overlay.tagName === 'IFRAME' && overlay.contentDocument) {
overlayText += overlay.contentDocument.body?.textContent || '';
}
} catch (e) {
// Can't access iframe content (cross-origin), but that's okay
}
// Always hide if it contains external errors, or hide all overlays with high z-index
const hasExternalError = overlayText.includes('Script error') ||
overlayText.includes('ethereum') ||
overlayText.includes('selectedAddress') ||
overlayText.includes('window.ethereum') ||
overlayText.includes('handleError@');
if (hasExternalError || overlayText === '') {
// Try to find and click the dismiss button first
const dismissButton = overlay.querySelector('button[aria-label*="close"]') ||
overlay.querySelector('button[aria-label*="dismiss"]') ||
overlay.querySelector('button:last-child') ||
overlay.querySelector('[role="button"]');
if (dismissButton) {
try {
dismissButton.click();
} catch (e) {
// If click fails, remove the overlay
overlay.remove();
}
} else {
// If no button found, hide or remove the overlay
overlay.style.display = 'none';
overlay.remove();
}
}
}
// Also check all fixed position divs with high z-index or that contain error text
const allFixedDivs = document.querySelectorAll('div[style*="position: fixed"], div[style*="position:fixed"]');
allFixedDivs.forEach(div => {
const style = window.getComputedStyle(div);
const zIndex = parseInt(style.zIndex) || 0;
const text = div.textContent || div.innerText || '';
// Hide if high z-index and contains error-related text
if (zIndex > 10000 ||
text.includes('Uncaught runtime errors') ||
text.includes('Script error') ||
text.includes('ethereum') ||
text.includes('handleError@')) {
// Check if it's specifically an external error before hiding
if (text.includes('Script error') ||
text.includes('ethereum') ||
text.includes('selectedAddress') ||
text.includes('window.ethereum') ||
(text.includes('Uncaught runtime errors') && text.includes('Script error'))) {
div.style.display = 'none';
div.style.visibility = 'hidden';
div.style.opacity = '0';
div.remove();
}
}
});
// Also check for any div containing "Uncaught runtime errors" text
const errorOverlays = document.querySelectorAll('div');
errorOverlays.forEach(div => {
const text = div.textContent || div.innerText || '';
if (text.includes('Uncaught runtime errors') &&
(text.includes('Script error') || text.includes('ethereum'))) {
const style = window.getComputedStyle(div);
if (style.position === 'fixed' || style.position === 'absolute') {
div.style.display = 'none';
div.remove();
}
}
});
};
// Check immediately and then more frequently
checkAndDismissOverlay();
// Check every 100ms to catch overlay quickly
setInterval(checkAndDismissOverlay, 100);
// Also listen for DOM changes to catch overlay when it appears
const observer = new MutationObserver(() => {
checkAndDismissOverlay();
});
observer.observe(document.body, {
childList: true,
subtree: true
});
};
// Suppress React's error reporting mechanism
if (typeof window !== 'undefined') {
// Override React's error handler if it exists
const originalHandleError = window.__REACT_ERROR_OVERLAY_GLOBAL_HANDLER__;
if (originalHandleError) {
window.__REACT_ERROR_OVERLAY_GLOBAL_HANDLER__ = function(error, isFatal) {
const errorMessage = error?.message || error?.toString() || '';
if (
errorMessage.includes('Script error') ||
errorMessage.includes('ethereum') ||
errorMessage.includes('selectedAddress') ||
errorMessage.includes('window.ethereum')
) {
return; // Suppress the error
}
// Call original handler for other errors
if (originalHandleError) {
originalHandleError(error, isFatal);
}
};
}
}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<ErrorBoundary>
<App />
</ErrorBoundary>
</React.StrictMode>
);
// Start suppressing React error overlay after React renders
setTimeout(suppressReactErrorOverlay, 1000);