Add full Oculog codebase

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

View File

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

19
server/backend/Dockerfile Normal file
View File

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

125
server/backend/db/init.sql Normal file
View File

@@ -0,0 +1,125 @@
-- Create metrics table
CREATE TABLE IF NOT EXISTS metrics (
id SERIAL PRIMARY KEY,
server_id VARCHAR(255) NOT NULL,
timestamp TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
cpu_usage DECIMAL(5,2),
cpu_cores INTEGER,
memory_total DECIMAL(10,2),
memory_used DECIMAL(10,2),
memory_available DECIMAL(10,2),
memory_percent DECIMAL(5,2),
disk_total DECIMAL(10,2),
disk_used DECIMAL(10,2),
disk_free DECIMAL(10,2),
disk_percent DECIMAL(5,2),
disks JSONB,
network_rx DECIMAL(10,2),
network_tx DECIMAL(10,2),
network_rx_total DECIMAL(10,2),
network_tx_total DECIMAL(10,2),
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- Create indexes for better query performance
CREATE INDEX IF NOT EXISTS idx_metrics_server_id ON metrics(server_id);
CREATE INDEX IF NOT EXISTS idx_metrics_timestamp ON metrics(timestamp);
CREATE INDEX IF NOT EXISTS idx_metrics_server_timestamp ON metrics(server_id, timestamp DESC);
-- Migration: Add disks column if it doesn't exist (for existing databases)
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'metrics' AND column_name = 'disks'
) THEN
ALTER TABLE metrics ADD COLUMN disks JSONB;
CREATE INDEX IF NOT EXISTS idx_metrics_disks ON metrics USING GIN (disks);
END IF;
END $$;
-- Create index for disks column (will be created if column exists)
CREATE INDEX IF NOT EXISTS idx_metrics_disks ON metrics USING GIN (disks);
-- Migration: Add load average columns if they don't exist
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'metrics' AND column_name = 'load_avg_1min'
) THEN
ALTER TABLE metrics ADD COLUMN load_avg_1min DECIMAL(5,2);
ALTER TABLE metrics ADD COLUMN load_avg_5min DECIMAL(5,2);
ALTER TABLE metrics ADD COLUMN load_avg_15min DECIMAL(5,2);
END IF;
END $$;
-- Migration: Add swap columns if they don't exist
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'metrics' AND column_name = 'swap_total'
) THEN
ALTER TABLE metrics ADD COLUMN swap_total DECIMAL(10,2);
ALTER TABLE metrics ADD COLUMN swap_used DECIMAL(10,2);
ALTER TABLE metrics ADD COLUMN swap_free DECIMAL(10,2);
ALTER TABLE metrics ADD COLUMN swap_percent DECIMAL(5,2);
END IF;
END $$;
-- Migration: Add process_count and uptime columns if they don't exist
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'metrics' AND column_name = 'process_count'
) THEN
ALTER TABLE metrics ADD COLUMN process_count INTEGER;
ALTER TABLE metrics ADD COLUMN uptime_seconds DECIMAL(10,2);
END IF;
END $$;
-- Create servers table to track server metadata
CREATE TABLE IF NOT EXISTS servers (
server_id VARCHAR(255) PRIMARY KEY,
hostname VARCHAR(255),
first_seen TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
last_seen TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
total_metrics_count INTEGER DEFAULT 0
);
-- Create function to update server last_seen and metrics count
CREATE OR REPLACE FUNCTION update_server_stats()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO servers (server_id, hostname, first_seen, last_seen, total_metrics_count)
VALUES (NEW.server_id, NEW.server_id, NOW(), NOW(), 1)
ON CONFLICT (server_id) DO UPDATE
SET last_seen = NOW(),
total_metrics_count = servers.total_metrics_count + 1;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Create trigger to automatically update server stats
DROP TRIGGER IF EXISTS trigger_update_server_stats ON metrics;
CREATE TRIGGER trigger_update_server_stats
AFTER INSERT ON metrics
FOR EACH ROW
EXECUTE FUNCTION update_server_stats();
-- Create API keys table for client authentication
CREATE TABLE IF NOT EXISTS api_keys (
id SERIAL PRIMARY KEY,
key_hash VARCHAR(255) NOT NULL UNIQUE,
server_id VARCHAR(255),
name VARCHAR(255),
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
last_used TIMESTAMP WITH TIME ZONE,
is_active BOOLEAN DEFAULT TRUE
);
CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash);
CREATE INDEX IF NOT EXISTS idx_api_keys_server_id ON api_keys(server_id);

View File

@@ -0,0 +1,8 @@
-- Migration: Add disks column to metrics table
-- This allows storing multiple disk metrics as JSON
ALTER TABLE metrics ADD COLUMN IF NOT EXISTS disks JSONB;
-- Create index on disks column for better query performance
CREATE INDEX IF NOT EXISTS idx_metrics_disks ON metrics USING GIN (disks);

View File

@@ -0,0 +1,17 @@
-- Migration: Add server_info column to servers table
-- This column stores server metadata: OS info, live status, top processes, containers, IP info
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'servers' AND column_name = 'server_info'
) THEN
ALTER TABLE servers ADD COLUMN server_info JSONB;
CREATE INDEX IF NOT EXISTS idx_servers_server_info ON servers USING GIN (server_info);
RAISE NOTICE 'server_info column added to servers table';
ELSE
RAISE NOTICE 'server_info column already exists';
END IF;
END $$;

View File

@@ -0,0 +1,44 @@
-- Create synthetic_monitors table
CREATE TABLE IF NOT EXISTS synthetic_monitors (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
type VARCHAR(50) NOT NULL CHECK (type IN ('http_status', 'ping', 'port_check')),
target VARCHAR(500) NOT NULL,
expected_status INTEGER,
port INTEGER,
interval INTEGER NOT NULL DEFAULT 60,
enabled BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- Create monitor_results table
CREATE TABLE IF NOT EXISTS monitor_results (
id SERIAL PRIMARY KEY,
monitor_id INTEGER NOT NULL REFERENCES synthetic_monitors(id) ON DELETE CASCADE,
status VARCHAR(20) NOT NULL CHECK (status IN ('success', 'failed')),
response_time INTEGER,
message TEXT,
timestamp TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- Create indexes for better query performance
CREATE INDEX IF NOT EXISTS idx_monitor_results_monitor_id ON monitor_results(monitor_id);
CREATE INDEX IF NOT EXISTS idx_monitor_results_timestamp ON monitor_results(timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_synthetic_monitors_enabled ON synthetic_monitors(enabled) WHERE enabled = TRUE;
-- Create function to update updated_at timestamp
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Create trigger to automatically update updated_at
DROP TRIGGER IF EXISTS trigger_update_synthetic_monitors_updated_at ON synthetic_monitors;
CREATE TRIGGER trigger_update_synthetic_monitors_updated_at
BEFORE UPDATE ON synthetic_monitors
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();

1500
server/backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,26 @@
{
"name": "oculog-backend",
"version": "1.0.0",
"description": "Backend API for Oculog metrics observability platform",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "nodemon src/index.js",
"migrate": "node src/migrate-cli.js"
},
"keywords": ["metrics", "observability", "monitoring"],
"author": "",
"license": "MIT",
"dependencies": {
"express": "^4.18.2",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"body-parser": "^1.20.2",
"pg": "^8.11.3",
"axios": "^1.6.0"
},
"devDependencies": {
"nodemon": "^3.0.1"
}
}

View File

@@ -0,0 +1,271 @@
/**
* Alert Evaluator
* Evaluates alert policies against current server metrics and synthetic monitor results
*/
const db = require('./db');
const fs = require('fs').promises;
const path = require('path');
/**
* Evaluate server metric alerts
*/
async function evaluateServerMetricAlerts() {
try {
const policies = await db.getEnabledAlertPolicies();
const serverMetricPolicies = policies.filter(p => p.type === 'server_metric');
if (serverMetricPolicies.length === 0) {
return;
}
// Get latest metrics for all servers
const latestMetrics = await db.getLatestMetrics();
const servers = await db.getServers();
for (const policy of serverMetricPolicies) {
const serversToCheck = policy.server_id
? servers.filter(s => s.server_id === policy.server_id)
: servers;
for (const server of serversToCheck) {
const metrics = latestMetrics[server.server_id];
if (!metrics) {
// Check for server_not_reporting
if (policy.metric_type === 'server_not_reporting') {
const lastSeen = new Date(server.last_seen);
const now = new Date();
const minutesSinceLastSeen = (now - lastSeen) / (1000 * 60);
if (minutesSinceLastSeen >= policy.threshold) {
await triggerAlert(policy, server.server_id,
`Server ${server.server_id} has not reported metrics for ${Math.round(minutesSinceLastSeen)} minutes`);
} else {
await resolveAlertIfExists(policy, server.server_id);
}
}
continue;
}
// Evaluate based on metric type
let shouldAlert = false;
let message = '';
switch (policy.metric_type) {
case 'cpu_high':
const cpuUsage = metrics.cpu?.usage || 0;
shouldAlert = cpuUsage >= policy.threshold;
if (shouldAlert) {
message = `CPU usage is ${cpuUsage.toFixed(1)}% (threshold: ${policy.threshold}%)`;
}
break;
case 'ram_high':
const memPercent = metrics.memory?.percent || 0;
shouldAlert = memPercent >= policy.threshold;
if (shouldAlert) {
message = `RAM usage is ${memPercent.toFixed(1)}% (threshold: ${policy.threshold}%)`;
}
break;
case 'disk_used':
const diskPercent = metrics.disk?.percent || 0;
shouldAlert = diskPercent >= policy.threshold;
if (shouldAlert) {
message = `Disk usage is ${diskPercent.toFixed(1)}% (threshold: ${policy.threshold}%)`;
}
break;
case 'server_not_reporting':
const lastSeen = new Date(server.last_seen);
const now = new Date();
const minutesSinceLastSeen = (now - lastSeen) / (1000 * 60);
shouldAlert = minutesSinceLastSeen >= policy.threshold;
if (shouldAlert) {
message = `Server has not reported metrics for ${Math.round(minutesSinceLastSeen)} minutes`;
}
break;
case 'client_out_of_date':
const clientVersion = await db.getClientVersion(server.server_id);
if (clientVersion) {
// Get latest client version (same logic as /api/servers/:serverId/info)
let latestVersion = null;
try {
const possiblePaths = [
path.join(__dirname, '../../../../clients/ubuntu/client.py'),
path.join(__dirname, '../../../clients/ubuntu/client.py'),
path.join(process.cwd(), 'clients/ubuntu/client.py'),
'/app/clients/ubuntu/client.py'
];
for (const p of possiblePaths) {
try {
const stats = await fs.stat(p);
const mtime = new Date(stats.mtime);
latestVersion = mtime.toISOString().slice(0, 16).replace('T', '-').replace(':', '-');
break;
} catch (e) {
continue;
}
}
if (latestVersion && clientVersion < latestVersion) {
shouldAlert = true;
message = `Client version ${clientVersion} is out of date (latest: ${latestVersion})`;
}
} catch (e) {
// If we can't check, don't alert
shouldAlert = false;
}
}
break;
}
if (shouldAlert) {
await triggerAlert(policy, server.server_id, message);
} else {
await resolveAlertIfExists(policy, server.server_id);
}
}
}
} catch (error) {
console.error('[Alert Evaluator] Error evaluating server metric alerts:', {
message: error.message,
stack: error.stack
});
}
}
/**
* Evaluate synthetic monitor alerts
*/
async function evaluateSyntheticMonitorAlerts() {
try {
const policies = await db.getEnabledAlertPolicies();
const monitorPolicies = policies.filter(p => p.type === 'synthetic_monitor');
if (monitorPolicies.length === 0) {
return;
}
// Get all monitors with their last results
const monitors = await db.getSyntheticMonitors();
for (const policy of monitorPolicies) {
const monitorsToCheck = policy.monitor_id
? monitors.filter(m => m.id === policy.monitor_id)
: monitors;
for (const monitor of monitorsToCheck) {
if (!monitor.last_result) {
continue; // No results yet
}
// Check if monitor failed
if (monitor.last_result.status === 'failed') {
await triggerAlert(policy, `Monitor ${monitor.id}`,
`Synthetic monitor "${monitor.name}" failed: ${monitor.last_result.message || 'Check failed'}`);
} else {
// Monitor is healthy, resolve any active alerts
await resolveAlertIfExists(policy, `Monitor ${monitor.id}`);
}
}
}
} catch (error) {
console.error('[Alert Evaluator] Error evaluating synthetic monitor alerts:', {
message: error.message,
stack: error.stack
});
}
}
/**
* Trigger an alert if one doesn't already exist
*/
async function triggerAlert(policy, target, message) {
try {
// Check if alert already exists
const existingAlert = await db.getActiveAlert(policy.id, target);
if (existingAlert) {
// Alert already exists, don't create duplicate
return;
}
// Create new alert
await db.createAlert({
policy_id: policy.id,
policy_name: policy.name,
target: target,
message: message,
status: 'active'
});
} catch (error) {
console.error('[Alert Evaluator] Error triggering alert:', {
message: error.message,
stack: error.stack,
policyId: policy.id,
policyName: policy.name,
target,
alertMessage: message
});
}
}
/**
* Resolve alert if it exists
*/
async function resolveAlertIfExists(policy, target) {
try {
const existingAlert = await db.getActiveAlert(policy.id, target);
if (existingAlert) {
await db.resolveAlert({
alert_id: existingAlert.id
});
}
} catch (error) {
console.error('[Alert Evaluator] Error resolving alert:', {
message: error.message,
stack: error.stack,
policyId: policy.id,
policyName: policy.name,
target,
alertId: existingAlert?.id
});
}
}
/**
* Run alert evaluation
*/
async function evaluateAlerts() {
try {
await evaluateServerMetricAlerts();
await evaluateSyntheticMonitorAlerts();
} catch (error) {
console.error('[Alert Evaluator] Error in alert evaluation:', {
message: error.message,
stack: error.stack
});
}
}
/**
* Start the alert evaluator
*/
function startAlertEvaluator() {
// Run evaluation immediately on startup
evaluateAlerts();
// Then run evaluation every 60 seconds
setInterval(evaluateAlerts, 60000);
}
module.exports = {
evaluateAlerts,
evaluateServerMetricAlerts,
evaluateSyntheticMonitorAlerts,
startAlertEvaluator
};

2016
server/backend/src/db.js Normal file

File diff suppressed because it is too large Load Diff

1067
server/backend/src/index.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,27 @@
const db = require('../db');
/**
* Middleware to authenticate API key
*/
async function authenticateApiKey(req, res, next) {
const apiKey = req.headers['x-api-key'];
if (!apiKey) {
return res.status(401).json({ error: 'API key required' });
}
const keyInfo = await db.validateApiKey(apiKey);
if (!keyInfo) {
return res.status(403).json({ error: 'Invalid or inactive API key' });
}
// Attach key info to request for use in routes
req.apiKeyInfo = keyInfo;
next();
}
module.exports = {
authenticateApiKey
};

View File

@@ -0,0 +1,28 @@
#!/usr/bin/env node
/**
* Standalone migration runner
* Run this script manually to execute database migrations
*
* Usage:
* node src/migrate-cli.js
* npm run migrate
*/
require('dotenv').config();
const { runMigrations } = require('./migrate');
async function main() {
console.log('Running database migrations manually...\n');
try {
await runMigrations();
console.log('\n✓ Migrations completed successfully');
process.exit(0);
} catch (error) {
console.error('\n✗ Migration failed:', error);
process.exit(1);
}
}
main();

View File

@@ -0,0 +1,419 @@
/**
* Database migration runner
* Runs migrations on startup to ensure database schema is up to date
*/
const db = require('./db');
async function runMigrations() {
try {
// Check if disks column exists
const checkColumnQuery = `
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'metrics' AND column_name = 'disks'
`;
const result = await db.pool.query(checkColumnQuery);
if (result.rows.length === 0) {
try {
// Add the disks column
await db.pool.query(`
ALTER TABLE metrics ADD COLUMN disks JSONB
`);
} catch (error) {
// Column might have been added between check and alter
if (error.code !== '42701') { // duplicate column error
throw error;
}
}
// Create index if it doesn't exist
try {
await db.pool.query(`
CREATE INDEX IF NOT EXISTS idx_metrics_disks ON metrics USING GIN (disks)
`);
} catch (error) {
console.error('[Migration] Warning: Could not create disks index:', {
message: error.message,
code: error.code
});
}
}
// Check if server_info column exists
const checkServerInfoQuery = `
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'servers' AND column_name = 'server_info'
`;
const serverInfoResult = await db.pool.query(checkServerInfoQuery);
if (serverInfoResult.rows.length === 0) {
try {
// Add the server_info column
await db.pool.query(`
ALTER TABLE servers ADD COLUMN server_info JSONB
`);
} catch (error) {
// Column might have been added between check and alter
if (error.code !== '42701') { // duplicate column error
throw error;
}
}
// Create index if it doesn't exist
try {
await db.pool.query(`
CREATE INDEX IF NOT EXISTS idx_servers_server_info ON servers USING GIN (server_info)
`);
} catch (error) {
console.error('[Migration] Warning: Could not create server_info index:', {
message: error.message,
code: error.code
});
}
}
// Check if client_version column exists
const checkClientVersionQuery = `
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'servers' AND column_name = 'client_version'
`;
const clientVersionResult = await db.pool.query(checkClientVersionQuery);
if (clientVersionResult.rows.length === 0) {
try {
// Add the client_version column
await db.pool.query(`
ALTER TABLE servers ADD COLUMN client_version VARCHAR(50)
`);
} catch (error) {
// Column might have been added between check and alter
if (error.code !== '42701') { // duplicate column error
throw error;
}
}
}
// Check and add swap columns if they don't exist
const checkSwapQuery = `
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'metrics' AND column_name = 'swap_total'
`;
const swapResult = await db.pool.query(checkSwapQuery);
if (swapResult.rows.length === 0) {
try {
await db.pool.query(`
ALTER TABLE metrics ADD COLUMN swap_total DECIMAL(10,2);
ALTER TABLE metrics ADD COLUMN swap_used DECIMAL(10,2);
ALTER TABLE metrics ADD COLUMN swap_free DECIMAL(10,2);
ALTER TABLE metrics ADD COLUMN swap_percent DECIMAL(5,2);
`);
} catch (error) {
if (error.code !== '42701') {
throw error;
}
}
}
// Check and add process_count and uptime_seconds columns if they don't exist
const checkProcessCountQuery = `
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'metrics' AND column_name = 'process_count'
`;
const processCountResult = await db.pool.query(checkProcessCountQuery);
if (processCountResult.rows.length === 0) {
try {
await db.pool.query(`
ALTER TABLE metrics ADD COLUMN process_count INTEGER;
ALTER TABLE metrics ADD COLUMN uptime_seconds DECIMAL(10,2);
`);
} catch (error) {
if (error.code !== '42701') {
throw error;
}
}
}
// Check and add load_avg columns if they don't exist
const checkLoadAvgQuery = `
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'metrics' AND column_name = 'load_avg_1min'
`;
const loadAvgResult = await db.pool.query(checkLoadAvgQuery);
if (loadAvgResult.rows.length === 0) {
try {
await db.pool.query(`
ALTER TABLE metrics ADD COLUMN load_avg_1min DECIMAL(5,2);
ALTER TABLE metrics ADD COLUMN load_avg_5min DECIMAL(5,2);
ALTER TABLE metrics ADD COLUMN load_avg_15min DECIMAL(5,2);
`);
} catch (error) {
if (error.code !== '42701') {
throw error;
}
}
}
// Check if synthetic_monitors table exists
const checkSyntheticMonitorsQuery = `
SELECT table_name
FROM information_schema.tables
WHERE table_name = 'synthetic_monitors'
`;
const syntheticMonitorsResult = await db.pool.query(checkSyntheticMonitorsQuery);
if (syntheticMonitorsResult.rows.length === 0) {
try {
// Create synthetic_monitors table
await db.pool.query(`
CREATE TABLE synthetic_monitors (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
type VARCHAR(50) NOT NULL CHECK (type IN ('http_status', 'ping', 'port_check')),
target VARCHAR(500) NOT NULL,
expected_status INTEGER,
port INTEGER,
interval INTEGER NOT NULL DEFAULT 60,
enabled BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
)
`);
// Create monitor_results table
await db.pool.query(`
CREATE TABLE monitor_results (
id SERIAL PRIMARY KEY,
monitor_id INTEGER NOT NULL REFERENCES synthetic_monitors(id) ON DELETE CASCADE,
status VARCHAR(20) NOT NULL CHECK (status IN ('success', 'failed')),
response_time INTEGER,
message TEXT,
timestamp TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
)
`);
// Create indexes
await db.pool.query(`
CREATE INDEX idx_monitor_results_monitor_id ON monitor_results(monitor_id);
CREATE INDEX idx_monitor_results_timestamp ON monitor_results(timestamp DESC);
CREATE INDEX idx_synthetic_monitors_enabled ON synthetic_monitors(enabled) WHERE enabled = TRUE
`);
// Create trigger function for updated_at
await db.pool.query(`
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql
`);
// Create trigger
await db.pool.query(`
CREATE TRIGGER trigger_update_synthetic_monitors_updated_at
BEFORE UPDATE ON synthetic_monitors
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column()
`);
} catch (error) {
console.error('[Migration] Error creating synthetic monitors tables:', {
message: error.message,
code: error.code,
stack: error.stack
});
throw error;
}
}
// Check if alert_policies table exists
const checkAlertPoliciesQuery = `
SELECT table_name
FROM information_schema.tables
WHERE table_name = 'alert_policies'
`;
const alertPoliciesResult = await db.pool.query(checkAlertPoliciesQuery);
if (alertPoliciesResult.rows.length === 0) {
try {
// Create alert_policies table
await db.pool.query(`
CREATE TABLE alert_policies (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
type VARCHAR(50) NOT NULL CHECK (type IN ('server_metric', 'synthetic_monitor')),
metric_type VARCHAR(50) CHECK (metric_type IN ('cpu_high', 'ram_high', 'disk_used', 'server_not_reporting', 'client_out_of_date')),
monitor_id INTEGER REFERENCES synthetic_monitors(id) ON DELETE CASCADE,
threshold DECIMAL(10,2),
server_id VARCHAR(255),
enabled BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
)
`);
// Create alerts table
await db.pool.query(`
CREATE TABLE alerts (
id SERIAL PRIMARY KEY,
policy_id INTEGER NOT NULL REFERENCES alert_policies(id) ON DELETE CASCADE,
policy_name VARCHAR(255) NOT NULL,
target VARCHAR(255),
message TEXT NOT NULL,
status VARCHAR(20) NOT NULL CHECK (status IN ('active', 'resolved')) DEFAULT 'active',
triggered_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
resolved_at TIMESTAMP WITH TIME ZONE
)
`);
// Create indexes
await db.pool.query(`
CREATE INDEX idx_alerts_policy_id ON alerts(policy_id);
CREATE INDEX idx_alerts_status ON alerts(status);
CREATE INDEX idx_alerts_triggered_at ON alerts(triggered_at DESC);
CREATE INDEX idx_alert_policies_enabled ON alert_policies(enabled) WHERE enabled = TRUE;
CREATE INDEX idx_alert_policies_type ON alert_policies(type)
`);
// Create trigger function for updated_at (if not exists)
await db.pool.query(`
CREATE OR REPLACE FUNCTION update_alert_policy_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql
`);
// Create trigger
await db.pool.query(`
CREATE TRIGGER trigger_update_alert_policies_updated_at
BEFORE UPDATE ON alert_policies
FOR EACH ROW
EXECUTE FUNCTION update_alert_policy_updated_at()
`);
} catch (error) {
console.error('[Migration] Error creating alert policies tables:', {
message: error.message,
code: error.code,
stack: error.stack
});
throw error;
}
}
// Check if vulnerabilities table exists
const checkVulnerabilitiesQuery = `
SELECT table_name
FROM information_schema.tables
WHERE table_name = 'vulnerabilities'
`;
const vulnerabilitiesResult = await db.pool.query(checkVulnerabilitiesQuery);
if (vulnerabilitiesResult.rows.length === 0) {
try {
// Create vulnerabilities table (vulnerability-centric)
await db.pool.query(`
CREATE TABLE vulnerabilities (
id SERIAL PRIMARY KEY,
cve_id VARCHAR(50) NOT NULL,
package_name VARCHAR(255) NOT NULL,
ecosystem VARCHAR(50) NOT NULL DEFAULT 'Ubuntu',
severity VARCHAR(20),
summary TEXT,
description TEXT,
fixed_version VARCHAR(255),
affected_version_range TEXT,
published_at TIMESTAMP WITH TIME ZONE,
modified_at TIMESTAMP WITH TIME ZONE,
first_detected TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
last_seen TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
UNIQUE(cve_id, package_name, ecosystem)
)
`);
// Create server_vulnerabilities table (many-to-many)
await db.pool.query(`
CREATE TABLE server_vulnerabilities (
id SERIAL PRIMARY KEY,
vulnerability_id INTEGER NOT NULL REFERENCES vulnerabilities(id) ON DELETE CASCADE,
server_id VARCHAR(255) NOT NULL,
installed_version VARCHAR(255) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'new' CHECK (status IN ('new', 'fixed', 'ongoing')),
first_detected TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
last_seen TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
UNIQUE(vulnerability_id, server_id)
)
`);
// Create vulnerability_cache table for OSV API results
await db.pool.query(`
CREATE TABLE vulnerability_cache (
id SERIAL PRIMARY KEY,
package_name VARCHAR(255) NOT NULL,
package_version VARCHAR(255) NOT NULL,
ecosystem VARCHAR(50) NOT NULL DEFAULT 'Ubuntu',
vulnerabilities JSONB,
cached_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
UNIQUE(package_name, package_version, ecosystem)
)
`);
// Create indexes
await db.pool.query(`
CREATE INDEX idx_vulnerabilities_cve_id ON vulnerabilities(cve_id);
CREATE INDEX idx_vulnerabilities_package_name ON vulnerabilities(package_name);
CREATE INDEX idx_vulnerabilities_severity ON vulnerabilities(severity);
CREATE INDEX idx_vulnerabilities_last_seen ON vulnerabilities(last_seen DESC);
CREATE INDEX idx_server_vulnerabilities_server_id ON server_vulnerabilities(server_id);
CREATE INDEX idx_server_vulnerabilities_status ON server_vulnerabilities(status);
CREATE INDEX idx_server_vulnerabilities_vulnerability_id ON server_vulnerabilities(vulnerability_id);
CREATE INDEX idx_vulnerability_cache_expires_at ON vulnerability_cache(expires_at);
CREATE INDEX idx_vulnerability_cache_lookup ON vulnerability_cache(package_name, package_version, ecosystem)
`);
} catch (error) {
console.error('[Migration] Error creating vulnerabilities tables:', {
message: error.message,
code: error.code,
stack: error.stack
});
throw error;
}
}
} catch (error) {
console.error('[Migration] Error running migrations:', {
message: error.message,
code: error.code,
stack: error.stack
});
// Don't throw - allow server to start even if migration fails
// (column might already exist or there might be a permission issue)
}
}
module.exports = { runMigrations };

View File

@@ -0,0 +1,282 @@
/**
* Synthetic Monitor Runner
* Periodically executes checks for enabled synthetic monitors
*/
const db = require('./db');
const http = require('http');
const https = require('https');
const { promisify } = require('util');
const dns = require('dns');
const net = require('net');
const dnsLookup = promisify(dns.lookup);
/**
* Execute HTTP status check
*/
async function checkHttpStatus(monitor) {
const startTime = Date.now();
const url = monitor.target;
return new Promise((resolve) => {
const client = url.startsWith('https') ? https : http;
const req = client.get(url, { timeout: 10000 }, (res) => {
const responseTime = Date.now() - startTime;
const statusCode = res.statusCode;
const expectedStatus = monitor.expected_status || 200;
// Consume response to free up resources
res.on('data', () => {});
res.on('end', () => {
if (statusCode === expectedStatus) {
resolve({
status: 'success',
response_time: responseTime,
message: `HTTP ${statusCode} (expected ${expectedStatus})`
});
} else {
resolve({
status: 'failed',
response_time: responseTime,
message: `HTTP ${statusCode} (expected ${expectedStatus})`
});
}
});
});
req.on('error', (error) => {
const responseTime = Date.now() - startTime;
resolve({
status: 'failed',
response_time: responseTime,
message: `Connection error: ${error.message}`
});
});
req.on('timeout', () => {
req.destroy();
const responseTime = Date.now() - startTime;
resolve({
status: 'failed',
response_time: responseTime,
message: 'Request timeout'
});
});
});
}
/**
* Execute ping check (ICMP-like check using DNS lookup + TCP connection)
*/
async function checkPing(monitor) {
const startTime = Date.now();
const hostname = monitor.target.replace(/^https?:\/\//, '').replace(/\/.*$/, '');
try {
// Resolve DNS
const dnsStart = Date.now();
await dnsLookup(hostname);
const dnsTime = Date.now() - dnsStart;
// Try to establish a TCP connection to port 80 or 443
const port = monitor.target.startsWith('https') ? 443 : 80;
const connectStart = Date.now();
return new Promise((resolve) => {
const socket = new net.Socket();
socket.setTimeout(5000);
socket.on('connect', () => {
socket.destroy();
const responseTime = Date.now() - startTime;
resolve({
status: 'success',
response_time: responseTime,
message: `Host reachable (DNS: ${dnsTime}ms, TCP: ${Date.now() - connectStart}ms)`
});
});
socket.on('error', (error) => {
const responseTime = Date.now() - startTime;
resolve({
status: 'failed',
response_time: responseTime,
message: `Connection failed: ${error.message}`
});
});
socket.on('timeout', () => {
socket.destroy();
const responseTime = Date.now() - startTime;
resolve({
status: 'failed',
response_time: responseTime,
message: 'Connection timeout'
});
});
socket.connect(port, hostname);
});
} catch (error) {
const responseTime = Date.now() - startTime;
return {
status: 'failed',
response_time: responseTime,
message: `DNS lookup failed: ${error.message}`
};
}
}
/**
* Execute port check
*/
async function checkPort(monitor) {
const startTime = Date.now();
const hostname = monitor.target;
const port = monitor.port || 80;
return new Promise((resolve) => {
const socket = new net.Socket();
socket.setTimeout(5000);
socket.on('connect', () => {
socket.destroy();
const responseTime = Date.now() - startTime;
resolve({
status: 'success',
response_time: responseTime,
message: `Port ${port} is open`
});
});
socket.on('error', (error) => {
const responseTime = Date.now() - startTime;
resolve({
status: 'failed',
response_time: responseTime,
message: `Port ${port} is closed or unreachable: ${error.message}`
});
});
socket.on('timeout', () => {
socket.destroy();
const responseTime = Date.now() - startTime;
resolve({
status: 'failed',
response_time: responseTime,
message: `Port ${port} connection timeout`
});
});
socket.connect(port, hostname);
});
}
/**
* Execute a monitor check based on its type
*/
async function executeMonitorCheck(monitor) {
try {
let result;
switch (monitor.type) {
case 'http_status':
result = await checkHttpStatus(monitor);
break;
case 'ping':
result = await checkPing(monitor);
break;
case 'port_check':
result = await checkPort(monitor);
break;
default:
result = {
status: 'failed',
message: `Unknown monitor type: ${monitor.type}`
};
}
// Save result to database
await db.saveMonitorResult(monitor.id, result);
// Update monitor's updated_at timestamp to track last check time
await db.pool.query(
'UPDATE synthetic_monitors SET updated_at = NOW() WHERE id = $1',
[monitor.id]
);
return result;
} catch (error) {
console.error(`[Monitor Runner] Error executing check for monitor ${monitor.id}:`, {
message: error.message,
stack: error.stack,
monitorId: monitor.id,
monitorName: monitor.name,
monitorType: monitor.type
});
await db.saveMonitorResult(monitor.id, {
status: 'failed',
message: `Check execution error: ${error.message}`
});
return {
status: 'failed',
message: `Check execution error: ${error.message}`
};
}
}
/**
* Check if a monitor needs to be executed based on its interval
*/
function shouldExecuteMonitor(monitor) {
const now = new Date();
const lastCheck = monitor.updated_at ? new Date(monitor.updated_at) : new Date(0);
const intervalMs = (monitor.interval || 60) * 1000;
return (now - lastCheck) >= intervalMs;
}
/**
* Run checks for all enabled monitors that need checking
*/
async function runMonitorChecks() {
try {
const monitors = await db.getEnabledMonitors();
const monitorsToCheck = monitors.filter(shouldExecuteMonitor);
if (monitorsToCheck.length === 0) {
return;
}
// Execute checks in parallel (with limit to avoid overwhelming the system)
const batchSize = 10;
for (let i = 0; i < monitorsToCheck.length; i += batchSize) {
const batch = monitorsToCheck.slice(i, i + batchSize);
await Promise.all(batch.map(executeMonitorCheck));
}
} catch (error) {
console.error('[Monitor Runner] Error running monitor checks:', {
message: error.message,
stack: error.stack
});
}
}
/**
* Start the monitor runner
*/
function startMonitorRunner() {
// Run checks immediately on startup
runMonitorChecks();
// Then run checks every 30 seconds
setInterval(runMonitorChecks, 30000);
}
module.exports = {
runMonitorChecks,
startMonitorRunner,
executeMonitorCheck
};

View File

@@ -0,0 +1,504 @@
/**
* OSV (Open Source Vulnerabilities) API Client
* Queries the OSV API for vulnerability information
*/
const axios = require('axios');
const OSV_API_URL = 'https://api.osv.dev/v1';
/**
* Query OSV API for vulnerabilities affecting a package version
* @param {string} packageName - Package name
* @param {string} version - Package version
* @param {string} ecosystem - Ecosystem (e.g., 'Ubuntu', 'Debian')
* @returns {Promise<Array>} Array of vulnerability objects
*/
async function queryPackage(packageName, version, ecosystem = 'Ubuntu') {
try {
const response = await axios.post(
`${OSV_API_URL}/query`,
{
package: {
name: packageName,
ecosystem: ecosystem
},
version: version
},
{
timeout: 30000 // 30 second timeout
}
);
if (response.data && response.data.vulns) {
return response.data.vulns;
}
return [];
} catch (error) {
if (error.response) {
console.error(`[OSV Client] API error for ${packageName}@${version}:`, {
status: error.response.status,
statusText: error.response.statusText,
data: error.response.data
});
} else if (error.request) {
console.error(`[OSV Client] Network error for ${packageName}@${version}:`, {
message: error.message
});
} else {
console.error(`[OSV Client] Error querying ${packageName}@${version}:`, {
message: error.message
});
}
throw error;
}
}
/**
* Fetch full vulnerability details by ID
* @param {string} vulnId - OSV vulnerability ID
* @returns {Promise<Object|null>} Full vulnerability object or null
*/
async function getVulnerabilityDetails(vulnId) {
try {
const response = await axios.get(
`${OSV_API_URL}/vulns/${encodeURIComponent(vulnId)}`,
{
timeout: 30000
}
);
return response.data || null;
} catch (error) {
if (error.response && error.response.status === 404) {
console.error(`[OSV Client] Vulnerability not found: ${vulnId}`);
return null;
}
console.error(`[OSV Client] Error fetching vulnerability ${vulnId}:`, {
message: error.message,
status: error.response?.status
});
return null;
}
}
/**
* Batch fetch full vulnerability details
* @param {Array<string>} vulnIds - Array of vulnerability IDs
* @returns {Promise<Array>} Array of full vulnerability objects
*/
async function batchGetVulnerabilityDetails(vulnIds) {
if (!vulnIds || vulnIds.length === 0) {
return [];
}
// Fetch in parallel batches to avoid overwhelming the API
const batchSize = 10;
const results = [];
for (let i = 0; i < vulnIds.length; i += batchSize) {
const batch = vulnIds.slice(i, i + batchSize);
const batchPromises = batch.map(id => getVulnerabilityDetails(id));
const batchResults = await Promise.all(batchPromises);
results.push(...batchResults.filter(v => v !== null));
// Small delay between batches to be respectful to the API
if (i + batchSize < vulnIds.length) {
await new Promise(resolve => setTimeout(resolve, 100));
}
}
return results;
}
/**
* Batch query OSV API for multiple packages
* @param {Array} packages - Array of {name, version, ecosystem} objects
* @returns {Promise<Array>} Array of {package, vulnerabilities} objects with full vulnerability details
*/
async function queryBatch(packages) {
if (!packages || packages.length === 0) {
return [];
}
try {
const queries = packages.map(pkg => ({
package: {
name: pkg.name,
ecosystem: pkg.ecosystem || 'Ubuntu'
},
version: pkg.version
}));
const response = await axios.post(
`${OSV_API_URL}/querybatch`,
{
queries: queries
},
{
timeout: 60000 // 60 second timeout for batch queries
}
);
if (response.data && response.data.results) {
// Collect all unique vulnerability IDs
const vulnIdSet = new Set();
const packageVulnMap = new Map();
response.data.results.forEach((result, index) => {
const vulnIds = (result.vulns || []).map(v => v.id || v).filter(Boolean);
packageVulnMap.set(index, vulnIds);
vulnIds.forEach(id => vulnIdSet.add(id));
});
// Fetch full vulnerability details for all unique IDs
const uniqueVulnIds = Array.from(vulnIdSet);
console.log(`[OSV Client] Fetching full details for ${uniqueVulnIds.length} unique vulnerabilities...`);
const fullVulns = await batchGetVulnerabilityDetails(uniqueVulnIds);
// Create a map of ID to full vulnerability object
const vulnMap = new Map();
fullVulns.forEach(vuln => {
if (vuln.id) {
vulnMap.set(vuln.id, vuln);
}
});
// Map back to packages with full vulnerability objects
return response.data.results.map((result, index) => {
const vulnIds = packageVulnMap.get(index) || [];
const vulnerabilities = vulnIds
.map(id => vulnMap.get(id))
.filter(v => v !== undefined);
return {
package: packages[index],
vulnerabilities: vulnerabilities
};
});
}
return [];
} catch (error) {
if (error.response) {
console.error('[OSV Client] Batch API error:', {
status: error.response.status,
statusText: error.response.statusText,
url: `${OSV_API_URL}/querybatch`,
packageCount: packages.length,
responseData: error.response.data,
sampleQueries: queries.slice(0, 3) // Show first 3 queries for debugging
});
} else if (error.request) {
console.error('[OSV Client] Batch network error:', {
message: error.message,
url: `${OSV_API_URL}/querybatch`,
packageCount: packages.length
});
} else {
console.error('[OSV Client] Batch query error:', {
message: error.message,
url: `${OSV_API_URL}/querybatch`,
packageCount: packages.length
});
}
throw error;
}
}
/**
* Parse CVSS vector string to extract numeric score
* CVSS vectors are in format: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
* We'll extract the base score by parsing the vector components
* @param {string} vectorString - CVSS vector string
* @returns {number|null} Numeric CVSS score or null
*/
function parseCvssScore(vectorString) {
if (!vectorString || typeof vectorString !== 'string') {
return null;
}
// Try to extract numeric score if present (some vectors include score)
const scoreMatch = vectorString.match(/CVSS:\d+\.\d+\/([^/]+)/);
if (!scoreMatch) return null;
// For now, we'll use a simplified approach:
// Parse the vector components to estimate severity
// C (Confidentiality), I (Integrity), A (Availability)
const components = vectorString.split('/');
let hasHighImpact = false;
for (const comp of components) {
if (comp.startsWith('C:') || comp.startsWith('I:') || comp.startsWith('A:')) {
const value = comp.split(':')[1];
if (value === 'H') {
hasHighImpact = true;
break;
}
}
}
// This is a simplified estimation - in production, you'd want a full CVSS calculator
// For now, return null and let the Ubuntu priority handle it
return null;
}
/**
* Parse CVSS vector string to extract base score
* CVSS vectors are in format: CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:C/C:H/I:H/A:H
* We'll parse the vector components to estimate severity
* @param {string} vectorString - CVSS vector string
* @returns {number|null} Estimated CVSS score or null
*/
function parseCvssVector(vectorString) {
if (!vectorString || typeof vectorString !== 'string') {
return null;
}
// Extract components from vector string
const components = vectorString.split('/');
let av = null, ac = null, pr = null, ui = null, s = null, c = null, i = null, a = null;
for (const comp of components) {
if (comp.startsWith('AV:')) av = comp.split(':')[1];
else if (comp.startsWith('AC:')) ac = comp.split(':')[1];
else if (comp.startsWith('PR:')) pr = comp.split(':')[1];
else if (comp.startsWith('UI:')) ui = comp.split(':')[1];
else if (comp.startsWith('S:')) s = comp.split(':')[1];
else if (comp.startsWith('C:')) c = comp.split(':')[1];
else if (comp.startsWith('I:')) i = comp.split(':')[1];
else if (comp.startsWith('A:')) a = comp.split(':')[1];
}
// Simplified scoring based on impact (C, I, A)
// H = High, L = Low, N = None
const impactScore =
(c === 'H' ? 0.66 : c === 'L' ? 0.22 : 0) +
(i === 'H' ? 0.66 : i === 'L' ? 0.22 : 0) +
(a === 'H' ? 0.66 : a === 'L' ? 0.22 : 0);
// Base score approximation (simplified)
if (impactScore >= 1.8) return 9.0; // Critical
if (impactScore >= 1.2) return 7.0; // High
if (impactScore >= 0.4) return 4.0; // Medium
if (impactScore > 0) return 2.0; // Low
return null;
}
/**
* Extract severity from OSV vulnerability object
* @param {Object} vuln - OSV vulnerability object
* @returns {string} Severity level (CRITICAL, HIGH, MEDIUM, LOW) or null
*/
function extractSeverity(vuln) {
// Priority 1: Check Ubuntu-specific priority (most reliable for Ubuntu CVEs)
if (vuln.severity && Array.isArray(vuln.severity)) {
for (const sev of vuln.severity) {
if (sev.type === 'Ubuntu' && sev.score) {
const ubuntuPriority = sev.score.toLowerCase();
// Map Ubuntu priorities to our severity levels
if (ubuntuPriority === 'critical') return 'CRITICAL';
if (ubuntuPriority === 'high') return 'HIGH';
if (ubuntuPriority === 'medium') return 'MEDIUM';
if (ubuntuPriority === 'low' || ubuntuPriority === 'negligible') return 'LOW';
}
}
}
// Priority 2: Parse CVSS vectors from severity array
if (vuln.severity && Array.isArray(vuln.severity)) {
for (const sev of vuln.severity) {
if (sev.type && (sev.type.startsWith('CVSS_V3') || sev.type.startsWith('CVSS_V4')) && sev.score) {
// Parse CVSS vector string to estimate score
const estimatedScore = parseCvssVector(sev.score);
if (estimatedScore !== null) {
if (estimatedScore >= 9.0) return 'CRITICAL';
if (estimatedScore >= 7.0) return 'HIGH';
if (estimatedScore >= 4.0) return 'MEDIUM';
if (estimatedScore > 0) return 'LOW';
}
}
}
}
// Priority 3: Check database_specific for Ubuntu severity
if (vuln.database_specific) {
if (vuln.database_specific.severity) {
const sev = vuln.database_specific.severity.toLowerCase();
if (sev === 'critical') return 'CRITICAL';
if (sev === 'high') return 'HIGH';
if (sev === 'medium') return 'MEDIUM';
if (sev === 'low' || sev === 'negligible') return 'LOW';
}
// Check for numeric CVSS score in database_specific
if (vuln.database_specific.cvss_score !== undefined) {
const score = parseFloat(vuln.database_specific.cvss_score);
if (score >= 9.0) return 'CRITICAL';
if (score >= 7.0) return 'HIGH';
if (score >= 4.0) return 'MEDIUM';
if (score > 0) return 'LOW';
}
}
return null;
}
/**
* Extract CVE ID from OSV vulnerability object
* OSV vulnerabilities have the actual CVE ID in the "upstream" array
* @param {Object} vuln - OSV vulnerability object
* @returns {string|null} Standard CVE ID or null
*/
function extractCveId(vuln) {
if (!vuln) return null;
// First, try to get CVE from upstream array (most reliable)
if (vuln.upstream && Array.isArray(vuln.upstream)) {
const cveId = vuln.upstream.find(id => id.startsWith('CVE-'));
if (cveId) {
return cveId;
}
}
// Fallback: try to extract from OSV ID
const osvId = vuln.id || '';
// Handle Ubuntu-specific format: UBUNTU-CVE-2022-49737 -> CVE-2022-49737
if (osvId.startsWith('UBUNTU-CVE-')) {
return osvId.replace('UBUNTU-CVE-', 'CVE-');
}
// Handle standard CVE format
if (osvId.startsWith('CVE-')) {
return osvId;
}
// For other formats (GHSA, etc.), return as-is but note it's not a CVE
return osvId;
}
/**
* Normalize OSV vulnerability to our format
* @param {Object} vuln - OSV vulnerability object
* @param {string} packageName - Package name
* @param {string} ecosystem - Ecosystem
* @param {string} installedVersion - Installed package version (optional)
* @returns {Object} Normalized vulnerability object
*/
function normalizeVulnerability(vuln, packageName, ecosystem, installedVersion = null) {
// Extract proper CVE ID from vulnerability object (checks upstream array first)
const cveId = extractCveId(vuln);
return {
cve_id: cveId, // Store the normalized CVE ID
osv_id: vuln.id, // Keep original OSV ID for reference
package_name: packageName,
ecosystem: ecosystem,
severity: extractSeverity(vuln),
summary: vuln.summary || null,
description: vuln.details || vuln.summary || null,
fixed_version: extractFixedVersion(vuln, ecosystem, installedVersion),
affected_version_range: JSON.stringify(vuln.affected || []),
published_at: vuln.published ? new Date(vuln.published) : null,
modified_at: vuln.modified ? new Date(vuln.modified) : null
};
}
/**
* Extract fixed version from OSV vulnerability
* @param {Object} vuln - OSV vulnerability object
* @param {string} ecosystem - Ecosystem (e.g., 'Ubuntu:24.04:LTS')
* @param {string} installedVersion - Installed package version
* @returns {string|null} Fixed version or null
*/
function extractFixedVersion(vuln, ecosystem = null, installedVersion = null) {
if (!vuln.affected || vuln.affected.length === 0) {
return null;
}
// Find the affected entry that matches our ecosystem
let matchingAffected = null;
for (const affected of vuln.affected) {
if (affected.package && affected.package.ecosystem) {
const affectedEco = affected.package.ecosystem;
// Match ecosystem (e.g., "Ubuntu:24.04:LTS")
if (ecosystem && affectedEco === ecosystem) {
matchingAffected = affected;
break;
}
// Also check if ecosystem starts with our base (for fallback)
if (ecosystem && affectedEco.startsWith(ecosystem.split(':')[0])) {
if (!matchingAffected) {
matchingAffected = affected;
}
}
}
}
// If no exact match, use first affected entry
if (!matchingAffected && vuln.affected.length > 0) {
matchingAffected = vuln.affected[0];
}
if (!matchingAffected) {
return null;
}
// Check ranges for fixed events
if (matchingAffected.ranges && matchingAffected.ranges.length > 0) {
for (const range of matchingAffected.ranges) {
if (range.type === 'ECOSYSTEM' && range.events) {
// Look for fixed events (most recent fixed version)
let fixedVersion = null;
for (const event of range.events) {
if (event.fixed) {
fixedVersion = event.fixed;
}
}
if (fixedVersion) {
return fixedVersion;
}
}
}
}
// For Ubuntu, check if installed version is in the affected versions list
// If it is, and there are newer versions, the newest version might be the fix
if (matchingAffected.versions && Array.isArray(matchingAffected.versions) && installedVersion) {
const versions = matchingAffected.versions;
const installedIndex = versions.indexOf(installedVersion);
// If installed version is in the list and not the last one, return the last version as potential fix
// Note: This is a heuristic - Ubuntu might not have fixed it yet
if (installedIndex >= 0 && installedIndex < versions.length - 1) {
// Check if there's a newer version (potential fix)
const lastVersion = versions[versions.length - 1];
// Only return if it's clearly newer (simple string comparison might not work for all version formats)
// For now, return null - we need better version comparison
// return lastVersion;
}
}
// Check database_specific for Ubuntu-specific fixed version info
if (matchingAffected.database_specific && matchingAffected.database_specific.fixed_version) {
return matchingAffected.database_specific.fixed_version;
}
if (vuln.database_specific && vuln.database_specific.fixed_version) {
return vuln.database_specific.fixed_version;
}
return null;
}
module.exports = {
queryPackage,
queryBatch,
getVulnerabilityDetails,
batchGetVulnerabilityDetails,
normalizeVulnerability,
extractSeverity,
extractCveId
};

View File

@@ -0,0 +1,90 @@
const { exec } = require('child_process');
const fs = require('fs').promises;
const path = require('path');
const { promisify } = require('util');
const execAsync = promisify(exec);
/**
* Build a debian package with embedded configuration
*/
async function buildDebPackage(serverUrl, serverId, apiKey, outputDir) {
const clientDir = path.join(__dirname, '../../../../clients/ubuntu');
const buildDir = path.join(outputDir, 'build');
const packageDir = path.join(buildDir, 'oculog-client');
try {
// Create build directory
await fs.mkdir(buildDir, { recursive: true });
await fs.mkdir(packageDir, { recursive: true });
// Copy client files
await execAsync(`cp -r ${clientDir}/* ${packageDir}/`, { cwd: clientDir });
// Create embedded config file
const config = {
server_url: serverUrl,
server_id: serverId,
api_key: apiKey,
interval: 30
};
const configPath = path.join(packageDir, 'client.conf');
await fs.writeFile(configPath, JSON.stringify(config, null, 2));
// Update postinst to copy the embedded config
const postinstPath = path.join(packageDir, 'debian/postinst');
let postinst = await fs.readFile(postinstPath, 'utf8');
postinst = postinst.replace(
'echo "Please configure /etc/oculog/client.conf before starting the service."',
`# Copy embedded configuration
if [ -f /opt/oculog/client.conf ]; then
cp /opt/oculog/client.conf /etc/oculog/client.conf
chmod 600 /etc/oculog/client.conf
echo "Configuration installed from package."
fi
# Enable and start service
systemctl daemon-reload
systemctl enable oculog-client.service 2>/dev/null || true
systemctl start oculog-client.service 2>/dev/null || true
echo "Oculog client installed and started successfully!"`
);
await fs.writeFile(postinstPath, postinst);
// Update debian/rules to include config file
const rulesPath = path.join(packageDir, 'debian/rules');
let rules = await fs.readFile(rulesPath, 'utf8');
if (!rules.includes('client.conf')) {
rules = rules.replace(
'install -m 644 oculog-client.service',
`install -m 644 client.conf $(CURDIR)/debian/oculog-client/opt/oculog/
\tinstall -m 644 oculog-client.service`
);
}
await fs.writeFile(rulesPath, rules);
// Build the package
const { stdout, stderr } = await execAsync(
'dpkg-buildpackage -b -us -uc',
{ cwd: packageDir }
);
// Find the generated .deb file
const debFiles = await fs.readdir(buildDir);
const debFile = debFiles.find(f => f.endsWith('.deb'));
if (!debFile) {
throw new Error('Failed to build debian package');
}
return path.join(buildDir, debFile);
} catch (error) {
console.error('Error building deb package:', error);
throw error;
}
}
module.exports = {
buildDebPackage
};

View File

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

View File

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

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 533 KiB

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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