Add full Oculog codebase
This commit is contained in:
8
server/frontend/.dockerignore
Normal file
8
server/frontend/.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
.env
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
build
|
||||
|
||||
19
server/frontend/Dockerfile
Normal file
19
server/frontend/Dockerfile
Normal 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
17491
server/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
server/frontend/package.json
Normal file
37
server/frontend/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
89
server/frontend/public/index.html
Normal file
89
server/frontend/public/index.html
Normal 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>
|
||||
|
||||
BIN
server/frontend/public/oculog-logo.png
Normal file
BIN
server/frontend/public/oculog-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 533 KiB |
298
server/frontend/src/App.css
Normal file
298
server/frontend/src/App.css
Normal 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
324
server/frontend/src/App.js
Normal 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;
|
||||
|
||||
399
server/frontend/src/components/Alerting.css
Normal file
399
server/frontend/src/components/Alerting.css
Normal 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;
|
||||
}
|
||||
}
|
||||
442
server/frontend/src/components/Alerting.js
Normal file
442
server/frontend/src/components/Alerting.js
Normal 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;
|
||||
217
server/frontend/src/components/ClientDownload.css
Normal file
217
server/frontend/src/components/ClientDownload.css
Normal 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;
|
||||
}
|
||||
|
||||
163
server/frontend/src/components/ClientDownload.js
Normal file
163
server/frontend/src/components/ClientDownload.js
Normal 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;
|
||||
|
||||
33
server/frontend/src/components/ErrorBoundary.js
Normal file
33
server/frontend/src/components/ErrorBoundary.js
Normal 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;
|
||||
|
||||
93
server/frontend/src/components/MetricCard.css
Normal file
93
server/frontend/src/components/MetricCard.css
Normal 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;
|
||||
}
|
||||
|
||||
37
server/frontend/src/components/MetricCard.js
Normal file
37
server/frontend/src/components/MetricCard.js
Normal 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;
|
||||
|
||||
30
server/frontend/src/components/MetricsChart.css
Normal file
30
server/frontend/src/components/MetricsChart.css
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
158
server/frontend/src/components/MetricsChart.js
Normal file
158
server/frontend/src/components/MetricsChart.js
Normal 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;
|
||||
|
||||
862
server/frontend/src/components/MetricsDashboard.css
Normal file
862
server/frontend/src/components/MetricsDashboard.css
Normal 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);
|
||||
}
|
||||
|
||||
783
server/frontend/src/components/MetricsDashboard.js
Normal file
783
server/frontend/src/components/MetricsDashboard.js
Normal 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;
|
||||
266
server/frontend/src/components/Security.css
Normal file
266
server/frontend/src/components/Security.css
Normal 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;
|
||||
}
|
||||
}
|
||||
224
server/frontend/src/components/Security.js
Normal file
224
server/frontend/src/components/Security.js
Normal 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;
|
||||
660
server/frontend/src/components/SecurityDashboard.css
Normal file
660
server/frontend/src/components/SecurityDashboard.css
Normal 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;
|
||||
}
|
||||
}
|
||||
552
server/frontend/src/components/SecurityDashboard.js
Normal file
552
server/frontend/src/components/SecurityDashboard.js
Normal 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;
|
||||
311
server/frontend/src/components/ServerInfoPane.css
Normal file
311
server/frontend/src/components/ServerInfoPane.css
Normal 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);
|
||||
}
|
||||
|
||||
184
server/frontend/src/components/ServerInfoPane.js
Normal file
184
server/frontend/src/components/ServerInfoPane.js
Normal 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;
|
||||
|
||||
129
server/frontend/src/components/ServerList.css
Normal file
129
server/frontend/src/components/ServerList.css
Normal 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;
|
||||
}
|
||||
|
||||
41
server/frontend/src/components/ServerList.js
Normal file
41
server/frontend/src/components/ServerList.js
Normal 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;
|
||||
|
||||
189
server/frontend/src/components/ServerSecurity.css
Normal file
189
server/frontend/src/components/ServerSecurity.css
Normal 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);
|
||||
}
|
||||
}
|
||||
260
server/frontend/src/components/ServerSecurity.js
Normal file
260
server/frontend/src/components/ServerSecurity.js
Normal 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;
|
||||
297
server/frontend/src/components/SyntheticMonitors.css
Normal file
297
server/frontend/src/components/SyntheticMonitors.css
Normal 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;
|
||||
}
|
||||
}
|
||||
337
server/frontend/src/components/SyntheticMonitors.js
Normal file
337
server/frontend/src/components/SyntheticMonitors.js
Normal 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;
|
||||
214
server/frontend/src/components/Wiki.css
Normal file
214
server/frontend/src/components/Wiki.css
Normal 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;
|
||||
}
|
||||
}
|
||||
649
server/frontend/src/components/Wiki.js
Normal file
649
server/frontend/src/components/Wiki.js
Normal 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;
|
||||
62
server/frontend/src/index.css
Normal file
62
server/frontend/src/index.css
Normal 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;
|
||||
}
|
||||
|
||||
216
server/frontend/src/index.js
Normal file
216
server/frontend/src/index.js
Normal 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);
|
||||
|
||||
Reference in New Issue
Block a user