v1.6.0 #221
@@ -687,7 +687,14 @@
|
|||||||
"failedToFetchHostConfig": "Failed to fetch host configuration",
|
"failedToFetchHostConfig": "Failed to fetch host configuration",
|
||||||
"failedToFetchStatus": "Failed to fetch server status",
|
"failedToFetchStatus": "Failed to fetch server status",
|
||||||
"failedToFetchMetrics": "Failed to fetch server metrics",
|
"failedToFetchMetrics": "Failed to fetch server metrics",
|
||||||
"failedToFetchHomeData": "Failed to fetch home data"
|
"failedToFetchHomeData": "Failed to fetch home data",
|
||||||
|
"loadingMetrics": "Loading metrics...",
|
||||||
|
"refreshing": "Refreshing...",
|
||||||
|
"serverOffline": "Server Offline",
|
||||||
|
"cannotFetchMetrics": "Cannot fetch metrics from offline server",
|
||||||
|
"load": "Load",
|
||||||
|
"free": "Free",
|
||||||
|
"available": "Available"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"loginTitle": "Login to Termix",
|
"loginTitle": "Login to Termix",
|
||||||
|
|||||||
@@ -701,7 +701,14 @@
|
|||||||
"memoryUsage": "内存使用率",
|
"memoryUsage": "内存使用率",
|
||||||
"rootStorageSpace": "根目录存储空间",
|
"rootStorageSpace": "根目录存储空间",
|
||||||
"of": "的",
|
"of": "的",
|
||||||
"feedbackMessage": "对服务器管理的下一步功能有想法?在这里分享吧"
|
"feedbackMessage": "对服务器管理的下一步功能有想法?在这里分享吧",
|
||||||
|
"loadingMetrics": "正在加载指标...",
|
||||||
|
"refreshing": "正在刷新...",
|
||||||
|
"serverOffline": "服务器离线",
|
||||||
|
"cannotFetchMetrics": "无法从离线服务器获取指标",
|
||||||
|
"load": "负载",
|
||||||
|
"free": "空闲",
|
||||||
|
"available": "可用"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"loginTitle": "登录 Termix",
|
"loginTitle": "登录 Termix",
|
||||||
|
|||||||
@@ -8,6 +8,237 @@ import {sshData, sshCredentials} from '../database/db/schema.js';
|
|||||||
import {eq, and} from 'drizzle-orm';
|
import {eq, and} from 'drizzle-orm';
|
||||||
import { statsLogger } from '../utils/logger.js';
|
import { statsLogger } from '../utils/logger.js';
|
||||||
|
|
||||||
|
// Rate limiting
|
||||||
|
const requestCounts = new Map<string, { count: number; resetTime: number }>();
|
||||||
|
const RATE_LIMIT_WINDOW = 15 * 60 * 1000; // 15 minutes
|
||||||
|
const RATE_LIMIT_MAX = 100; // 100 requests per window
|
||||||
|
|
||||||
|
// Connection pooling
|
||||||
|
interface PooledConnection {
|
||||||
|
client: Client;
|
||||||
|
lastUsed: number;
|
||||||
|
inUse: boolean;
|
||||||
|
hostKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class SSHConnectionPool {
|
||||||
|
private connections = new Map<string, PooledConnection[]>();
|
||||||
|
private maxConnectionsPerHost = 3;
|
||||||
|
private connectionTimeout = 30000;
|
||||||
|
private cleanupInterval: NodeJS.Timeout;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.cleanupInterval = setInterval(() => {
|
||||||
|
this.cleanup();
|
||||||
|
}, 5 * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getHostKey(host: SSHHostWithCredentials): string {
|
||||||
|
return `${host.ip}:${host.port}:${host.username}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getConnection(host: SSHHostWithCredentials): Promise<Client> {
|
||||||
|
const hostKey = this.getHostKey(host);
|
||||||
|
const connections = this.connections.get(hostKey) || [];
|
||||||
|
|
||||||
|
const available = connections.find(conn => !conn.inUse);
|
||||||
|
if (available) {
|
||||||
|
available.inUse = true;
|
||||||
|
available.lastUsed = Date.now();
|
||||||
|
return available.client;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (connections.length < this.maxConnectionsPerHost) {
|
||||||
|
const client = await this.createConnection(host);
|
||||||
|
const pooled: PooledConnection = {
|
||||||
|
client,
|
||||||
|
lastUsed: Date.now(),
|
||||||
|
inUse: true,
|
||||||
|
hostKey
|
||||||
|
};
|
||||||
|
connections.push(pooled);
|
||||||
|
this.connections.set(hostKey, connections);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const checkAvailable = () => {
|
||||||
|
const available = connections.find(conn => !conn.inUse);
|
||||||
|
if (available) {
|
||||||
|
available.inUse = true;
|
||||||
|
available.lastUsed = Date.now();
|
||||||
|
resolve(available.client);
|
||||||
|
} else {
|
||||||
|
setTimeout(checkAvailable, 100);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
checkAvailable();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createConnection(host: SSHHostWithCredentials): Promise<Client> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const client = new Client();
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
client.end();
|
||||||
|
reject(new Error('SSH connection timeout'));
|
||||||
|
}, this.connectionTimeout);
|
||||||
|
|
||||||
|
client.on('ready', () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve(client);
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on('error', (err) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
client.connect(buildSshConfig(host));
|
||||||
|
} catch (err) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
releaseConnection(host: SSHHostWithCredentials, client: Client): void {
|
||||||
|
const hostKey = this.getHostKey(host);
|
||||||
|
const connections = this.connections.get(hostKey) || [];
|
||||||
|
const pooled = connections.find(conn => conn.client === client);
|
||||||
|
if (pooled) {
|
||||||
|
pooled.inUse = false;
|
||||||
|
pooled.lastUsed = Date.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanup(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
const maxAge = 10 * 60 * 1000; // 10 minutes
|
||||||
|
|
||||||
|
for (const [hostKey, connections] of this.connections.entries()) {
|
||||||
|
const activeConnections = connections.filter(conn => {
|
||||||
|
if (!conn.inUse && (now - conn.lastUsed) > maxAge) {
|
||||||
|
try {
|
||||||
|
conn.client.end();
|
||||||
|
} catch {
|
||||||
|
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (activeConnections.length === 0) {
|
||||||
|
this.connections.delete(hostKey);
|
||||||
|
} else {
|
||||||
|
this.connections.set(hostKey, activeConnections);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
clearInterval(this.cleanupInterval);
|
||||||
|
for (const connections of this.connections.values()) {
|
||||||
|
for (const conn of connections) {
|
||||||
|
try {
|
||||||
|
conn.client.end();
|
||||||
|
} catch {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.connections.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request queuing to prevent race conditions
|
||||||
|
class RequestQueue {
|
||||||
|
private queues = new Map<number, Array<() => Promise<any>>>();
|
||||||
|
private processing = new Set<number>();
|
||||||
|
|
||||||
|
async queueRequest<T>(hostId: number, request: () => Promise<T>): Promise<T> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const queue = this.queues.get(hostId) || [];
|
||||||
|
queue.push(async () => {
|
||||||
|
try {
|
||||||
|
const result = await request();
|
||||||
|
resolve(result);
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.queues.set(hostId, queue);
|
||||||
|
this.processQueue(hostId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async processQueue(hostId: number): Promise<void> {
|
||||||
|
if (this.processing.has(hostId)) return;
|
||||||
|
|
||||||
|
this.processing.add(hostId);
|
||||||
|
const queue = this.queues.get(hostId) || [];
|
||||||
|
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const request = queue.shift();
|
||||||
|
if (request) {
|
||||||
|
try {
|
||||||
|
await request();
|
||||||
|
} catch (error) {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.processing.delete(hostId);
|
||||||
|
if (queue.length > 0) {
|
||||||
|
this.processQueue(hostId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metrics caching
|
||||||
|
interface CachedMetrics {
|
||||||
|
data: any;
|
||||||
|
timestamp: number;
|
||||||
|
hostId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class MetricsCache {
|
||||||
|
private cache = new Map<number, CachedMetrics>();
|
||||||
|
private ttl = 30000; // 30 seconds
|
||||||
|
|
||||||
|
get(hostId: number): any | null {
|
||||||
|
const cached = this.cache.get(hostId);
|
||||||
|
if (cached && (Date.now() - cached.timestamp) < this.ttl) {
|
||||||
|
return cached.data;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
set(hostId: number, data: any): void {
|
||||||
|
this.cache.set(hostId, {
|
||||||
|
data,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
hostId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(hostId?: number): void {
|
||||||
|
if (hostId) {
|
||||||
|
this.cache.delete(hostId);
|
||||||
|
} else {
|
||||||
|
this.cache.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global instances
|
||||||
|
const connectionPool = new SSHConnectionPool();
|
||||||
|
const requestQueue = new RequestQueue();
|
||||||
|
const metricsCache = new MetricsCache();
|
||||||
|
|
||||||
type HostStatus = 'online' | 'offline';
|
type HostStatus = 'online' | 'offline';
|
||||||
|
|
||||||
interface SSHHostWithCredentials {
|
interface SSHHostWithCredentials {
|
||||||
@@ -40,6 +271,37 @@ type StatusEntry = {
|
|||||||
lastChecked: string;
|
lastChecked: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Rate limiting middleware
|
||||||
|
function rateLimitMiddleware(req: express.Request, res: express.Response, next: express.NextFunction) {
|
||||||
|
const clientId = req.ip || 'unknown';
|
||||||
|
const now = Date.now();
|
||||||
|
const clientData = requestCounts.get(clientId);
|
||||||
|
|
||||||
|
if (!clientData || now > clientData.resetTime) {
|
||||||
|
requestCounts.set(clientId, { count: 1, resetTime: now + RATE_LIMIT_WINDOW });
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clientData.count >= RATE_LIMIT_MAX) {
|
||||||
|
return res.status(429).json({
|
||||||
|
error: 'Too many requests',
|
||||||
|
retryAfter: Math.ceil((clientData.resetTime - now) / 1000)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
clientData.count++;
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Input validation middleware
|
||||||
|
function validateHostId(req: express.Request, res: express.Response, next: express.NextFunction) {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
if (!id || !Number.isInteger(id) || id <= 0) {
|
||||||
|
return res.status(400).json({ error: 'Invalid host ID' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(cors({
|
app.use(cors({
|
||||||
origin: '*',
|
origin: '*',
|
||||||
@@ -55,7 +317,8 @@ app.use((req, res, next) => {
|
|||||||
}
|
}
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
app.use(express.json());
|
app.use(express.json({ limit: '1mb' })); // Add request size limit
|
||||||
|
app.use(rateLimitMiddleware);
|
||||||
|
|
||||||
|
|
||||||
const hostStatuses: Map<number, StatusEntry> = new Map();
|
const hostStatuses: Map<number, StatusEntry> = new Map();
|
||||||
@@ -219,45 +482,13 @@ function buildSshConfig(host: SSHHostWithCredentials): ConnectConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function withSshConnection<T>(host: SSHHostWithCredentials, fn: (client: Client) => Promise<T>): Promise<T> {
|
async function withSshConnection<T>(host: SSHHostWithCredentials, fn: (client: Client) => Promise<T>): Promise<T> {
|
||||||
return new Promise<T>((resolve, reject) => {
|
const client = await connectionPool.getConnection(host);
|
||||||
const client = new Client();
|
try {
|
||||||
let settled = false;
|
const result = await fn(client);
|
||||||
|
return result;
|
||||||
const onError = (err: Error) => {
|
} finally {
|
||||||
if (!settled) {
|
connectionPool.releaseConnection(host, client);
|
||||||
settled = true;
|
}
|
||||||
try {
|
|
||||||
client.end();
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
reject(err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
client.on('ready', async () => {
|
|
||||||
try {
|
|
||||||
const result = await fn(client);
|
|
||||||
if (!settled) {
|
|
||||||
settled = true;
|
|
||||||
try {
|
|
||||||
client.end();
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
resolve(result);
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
onError(err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
client.on('error', onError);
|
|
||||||
client.on('timeout', () => onError(new Error('SSH connection timeout')));
|
|
||||||
try {
|
|
||||||
client.connect(buildSshConfig(host));
|
|
||||||
} catch (err: any) {
|
|
||||||
onError(err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function execCommand(client: Client, command: string): Promise<{
|
function execCommand(client: Client, command: string): Promise<{
|
||||||
@@ -307,106 +538,129 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
|
|||||||
memory: { percent: number | null; usedGiB: number | null; totalGiB: number | null };
|
memory: { percent: number | null; usedGiB: number | null; totalGiB: number | null };
|
||||||
disk: { percent: number | null; usedHuman: string | null; totalHuman: string | null };
|
disk: { percent: number | null; usedHuman: string | null; totalHuman: string | null };
|
||||||
}> {
|
}> {
|
||||||
return withSshConnection(host, async (client) => {
|
// Check cache first
|
||||||
let cpuPercent: number | null = null;
|
const cached = metricsCache.get(host.id);
|
||||||
let cores: number | null = null;
|
if (cached) {
|
||||||
let loadTriplet: [number, number, number] | null = null;
|
return cached;
|
||||||
try {
|
}
|
||||||
const stat1 = await execCommand(client, 'cat /proc/stat');
|
|
||||||
await new Promise(r => setTimeout(r, 500));
|
|
||||||
const stat2 = await execCommand(client, 'cat /proc/stat');
|
|
||||||
const loadAvgOut = await execCommand(client, 'cat /proc/loadavg');
|
|
||||||
const coresOut = await execCommand(client, 'nproc 2>/dev/null || grep -c ^processor /proc/cpuinfo');
|
|
||||||
|
|
||||||
const cpuLine1 = (stat1.stdout.split('\n').find(l => l.startsWith('cpu ')) || '').trim();
|
return requestQueue.queueRequest(host.id, async () => {
|
||||||
const cpuLine2 = (stat2.stdout.split('\n').find(l => l.startsWith('cpu ')) || '').trim();
|
return withSshConnection(host, async (client) => {
|
||||||
const a = parseCpuLine(cpuLine1);
|
let cpuPercent: number | null = null;
|
||||||
const b = parseCpuLine(cpuLine2);
|
let cores: number | null = null;
|
||||||
if (a && b) {
|
let loadTriplet: [number, number, number] | null = null;
|
||||||
const totalDiff = b.total - a.total;
|
|
||||||
const idleDiff = b.idle - a.idle;
|
try {
|
||||||
const used = totalDiff - idleDiff;
|
// Execute all commands in parallel for better performance
|
||||||
if (totalDiff > 0) cpuPercent = Math.max(0, Math.min(100, (used / totalDiff) * 100));
|
const [stat1, loadAvgOut, coresOut] = await Promise.all([
|
||||||
}
|
execCommand(client, 'cat /proc/stat'),
|
||||||
|
execCommand(client, 'cat /proc/loadavg'),
|
||||||
|
execCommand(client, 'nproc 2>/dev/null || grep -c ^processor /proc/cpuinfo')
|
||||||
|
]);
|
||||||
|
|
||||||
const laParts = loadAvgOut.stdout.trim().split(/\s+/);
|
// Wait for CPU calculation
|
||||||
if (laParts.length >= 3) {
|
await new Promise(r => setTimeout(r, 500));
|
||||||
loadTriplet = [Number(laParts[0]), Number(laParts[1]), Number(laParts[2])].map(v => Number.isFinite(v) ? Number(v) : 0) as [number, number, number];
|
const stat2 = await execCommand(client, 'cat /proc/stat');
|
||||||
}
|
|
||||||
|
|
||||||
const coresNum = Number((coresOut.stdout || '').trim());
|
const cpuLine1 = (stat1.stdout.split('\n').find(l => l.startsWith('cpu ')) || '').trim();
|
||||||
cores = Number.isFinite(coresNum) && coresNum > 0 ? coresNum : null;
|
const cpuLine2 = (stat2.stdout.split('\n').find(l => l.startsWith('cpu ')) || '').trim();
|
||||||
} catch (e) {
|
const a = parseCpuLine(cpuLine1);
|
||||||
cpuPercent = null;
|
const b = parseCpuLine(cpuLine2);
|
||||||
cores = null;
|
if (a && b) {
|
||||||
loadTriplet = null;
|
const totalDiff = b.total - a.total;
|
||||||
}
|
const idleDiff = b.idle - a.idle;
|
||||||
|
const used = totalDiff - idleDiff;
|
||||||
let memPercent: number | null = null;
|
if (totalDiff > 0) cpuPercent = Math.max(0, Math.min(100, (used / totalDiff) * 100));
|
||||||
let usedGiB: number | null = null;
|
|
||||||
let totalGiB: number | null = null;
|
|
||||||
try {
|
|
||||||
const memInfo = await execCommand(client, 'cat /proc/meminfo');
|
|
||||||
const lines = memInfo.stdout.split('\n');
|
|
||||||
const getVal = (key: string) => {
|
|
||||||
const line = lines.find(l => l.startsWith(key));
|
|
||||||
if (!line) return null;
|
|
||||||
const m = line.match(/\d+/);
|
|
||||||
return m ? Number(m[0]) : null;
|
|
||||||
};
|
|
||||||
const totalKb = getVal('MemTotal:');
|
|
||||||
const availKb = getVal('MemAvailable:');
|
|
||||||
if (totalKb && availKb && totalKb > 0) {
|
|
||||||
const usedKb = totalKb - availKb;
|
|
||||||
memPercent = Math.max(0, Math.min(100, (usedKb / totalKb) * 100));
|
|
||||||
usedGiB = kibToGiB(usedKb);
|
|
||||||
totalGiB = kibToGiB(totalKb);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
memPercent = null;
|
|
||||||
usedGiB = null;
|
|
||||||
totalGiB = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let diskPercent: number | null = null;
|
|
||||||
let usedHuman: string | null = null;
|
|
||||||
let totalHuman: string | null = null;
|
|
||||||
try {
|
|
||||||
const diskOutHuman = await execCommand(client, 'df -h -P / | tail -n +2');
|
|
||||||
const diskOutBytes = await execCommand(client, 'df -B1 -P / | tail -n +2');
|
|
||||||
|
|
||||||
const humanLine = diskOutHuman.stdout.split('\n').map(l => l.trim()).filter(Boolean)[0] || '';
|
|
||||||
const bytesLine = diskOutBytes.stdout.split('\n').map(l => l.trim()).filter(Boolean)[0] || '';
|
|
||||||
|
|
||||||
const humanParts = humanLine.split(/\s+/);
|
|
||||||
const bytesParts = bytesLine.split(/\s+/);
|
|
||||||
|
|
||||||
if (humanParts.length >= 6 && bytesParts.length >= 6) {
|
|
||||||
totalHuman = humanParts[1] || null;
|
|
||||||
usedHuman = humanParts[2] || null;
|
|
||||||
|
|
||||||
const totalBytes = Number(bytesParts[1]);
|
|
||||||
const usedBytes = Number(bytesParts[2]);
|
|
||||||
|
|
||||||
if (Number.isFinite(totalBytes) && Number.isFinite(usedBytes) && totalBytes > 0) {
|
|
||||||
diskPercent = Math.max(0, Math.min(100, (usedBytes / totalBytes) * 100));
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
diskPercent = null;
|
|
||||||
usedHuman = null;
|
|
||||||
totalHuman = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
const laParts = loadAvgOut.stdout.trim().split(/\s+/);
|
||||||
cpu: {percent: toFixedNum(cpuPercent, 0), cores, load: loadTriplet},
|
if (laParts.length >= 3) {
|
||||||
memory: {
|
loadTriplet = [Number(laParts[0]), Number(laParts[1]), Number(laParts[2])].map(v => Number.isFinite(v) ? Number(v) : 0) as [number, number, number];
|
||||||
percent: toFixedNum(memPercent, 0),
|
}
|
||||||
usedGiB: usedGiB ? toFixedNum(usedGiB, 2) : null,
|
|
||||||
totalGiB: totalGiB ? toFixedNum(totalGiB, 2) : null
|
const coresNum = Number((coresOut.stdout || '').trim());
|
||||||
},
|
cores = Number.isFinite(coresNum) && coresNum > 0 ? coresNum : null;
|
||||||
disk: {percent: toFixedNum(diskPercent, 0), usedHuman, totalHuman},
|
} catch (e) {
|
||||||
};
|
statsLogger.warn(`Failed to collect CPU metrics for host ${host.id}`, e);
|
||||||
|
cpuPercent = null;
|
||||||
|
cores = null;
|
||||||
|
loadTriplet = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let memPercent: number | null = null;
|
||||||
|
let usedGiB: number | null = null;
|
||||||
|
let totalGiB: number | null = null;
|
||||||
|
try {
|
||||||
|
const memInfo = await execCommand(client, 'cat /proc/meminfo');
|
||||||
|
const lines = memInfo.stdout.split('\n');
|
||||||
|
const getVal = (key: string) => {
|
||||||
|
const line = lines.find(l => l.startsWith(key));
|
||||||
|
if (!line) return null;
|
||||||
|
const m = line.match(/\d+/);
|
||||||
|
return m ? Number(m[0]) : null;
|
||||||
|
};
|
||||||
|
const totalKb = getVal('MemTotal:');
|
||||||
|
const availKb = getVal('MemAvailable:');
|
||||||
|
if (totalKb && availKb && totalKb > 0) {
|
||||||
|
const usedKb = totalKb - availKb;
|
||||||
|
memPercent = Math.max(0, Math.min(100, (usedKb / totalKb) * 100));
|
||||||
|
usedGiB = kibToGiB(usedKb);
|
||||||
|
totalGiB = kibToGiB(totalKb);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
statsLogger.warn(`Failed to collect memory metrics for host ${host.id}`, e);
|
||||||
|
memPercent = null;
|
||||||
|
usedGiB = null;
|
||||||
|
totalGiB = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let diskPercent: number | null = null;
|
||||||
|
let usedHuman: string | null = null;
|
||||||
|
let totalHuman: string | null = null;
|
||||||
|
try {
|
||||||
|
const [diskOutHuman, diskOutBytes] = await Promise.all([
|
||||||
|
execCommand(client, 'df -h -P / | tail -n +2'),
|
||||||
|
execCommand(client, 'df -B1 -P / | tail -n +2')
|
||||||
|
]);
|
||||||
|
|
||||||
|
const humanLine = diskOutHuman.stdout.split('\n').map(l => l.trim()).filter(Boolean)[0] || '';
|
||||||
|
const bytesLine = diskOutBytes.stdout.split('\n').map(l => l.trim()).filter(Boolean)[0] || '';
|
||||||
|
|
||||||
|
const humanParts = humanLine.split(/\s+/);
|
||||||
|
const bytesParts = bytesLine.split(/\s+/);
|
||||||
|
|
||||||
|
if (humanParts.length >= 6 && bytesParts.length >= 6) {
|
||||||
|
totalHuman = humanParts[1] || null;
|
||||||
|
usedHuman = humanParts[2] || null;
|
||||||
|
|
||||||
|
const totalBytes = Number(bytesParts[1]);
|
||||||
|
const usedBytes = Number(bytesParts[2]);
|
||||||
|
|
||||||
|
if (Number.isFinite(totalBytes) && Number.isFinite(usedBytes) && totalBytes > 0) {
|
||||||
|
diskPercent = Math.max(0, Math.min(100, (usedBytes / totalBytes) * 100));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
statsLogger.warn(`Failed to collect disk metrics for host ${host.id}`, e);
|
||||||
|
diskPercent = null;
|
||||||
|
usedHuman = null;
|
||||||
|
totalHuman = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
cpu: {percent: toFixedNum(cpuPercent, 0), cores, load: loadTriplet},
|
||||||
|
memory: {
|
||||||
|
percent: toFixedNum(memPercent, 0),
|
||||||
|
usedGiB: usedGiB ? toFixedNum(usedGiB, 2) : null,
|
||||||
|
totalGiB: totalGiB ? toFixedNum(totalGiB, 2) : null
|
||||||
|
},
|
||||||
|
disk: {percent: toFixedNum(diskPercent, 0), usedHuman, totalHuman},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cache the result
|
||||||
|
metricsCache.set(host.id, result);
|
||||||
|
return result;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -468,11 +722,8 @@ app.get('/status', async (req, res) => {
|
|||||||
res.json(result);
|
res.json(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/status/:id', async (req, res) => {
|
app.get('/status/:id', validateHostId, async (req, res) => {
|
||||||
const id = Number(req.params.id);
|
const id = Number(req.params.id);
|
||||||
if (!id) {
|
|
||||||
return res.status(400).json({error: 'Invalid id'});
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const host = await fetchHostById(id);
|
const host = await fetchHostById(id);
|
||||||
@@ -497,21 +748,45 @@ app.post('/refresh', async (req, res) => {
|
|||||||
res.json({message: 'Refreshed'});
|
res.json({message: 'Refreshed'});
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/metrics/:id', async (req, res) => {
|
app.get('/metrics/:id', validateHostId, async (req, res) => {
|
||||||
const id = Number(req.params.id);
|
const id = Number(req.params.id);
|
||||||
if (!id) {
|
|
||||||
return res.status(400).json({error: 'Invalid id'});
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const host = await fetchHostById(id);
|
const host = await fetchHostById(id);
|
||||||
if (!host) {
|
if (!host) {
|
||||||
return res.status(404).json({error: 'Host not found'});
|
return res.status(404).json({error: 'Host not found'});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if host is online first
|
||||||
|
const isOnline = await tcpPing(host.ip, host.port, 5000);
|
||||||
|
if (!isOnline) {
|
||||||
|
return res.status(503).json({
|
||||||
|
error: 'Host is offline',
|
||||||
|
cpu: {percent: null, cores: null, load: null},
|
||||||
|
memory: {percent: null, usedGiB: null, totalGiB: null},
|
||||||
|
disk: {percent: null, usedHuman: null, totalHuman: null},
|
||||||
|
lastChecked: new Date().toISOString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const metrics = await collectMetrics(host);
|
const metrics = await collectMetrics(host);
|
||||||
res.json({...metrics, lastChecked: new Date().toISOString()});
|
res.json({...metrics, lastChecked: new Date().toISOString()});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
statsLogger.error('Failed to collect metrics', err);
|
statsLogger.error('Failed to collect metrics', err);
|
||||||
return res.json({
|
|
||||||
|
// Return proper error response instead of empty data
|
||||||
|
if (err instanceof Error && err.message.includes('timeout')) {
|
||||||
|
return res.status(504).json({
|
||||||
|
error: 'Metrics collection timeout',
|
||||||
|
cpu: {percent: null, cores: null, load: null},
|
||||||
|
memory: {percent: null, usedGiB: null, totalGiB: null},
|
||||||
|
disk: {percent: null, usedHuman: null, totalHuman: null},
|
||||||
|
lastChecked: new Date().toISOString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(500).json({
|
||||||
|
error: 'Failed to collect metrics',
|
||||||
cpu: {percent: null, cores: null, load: null},
|
cpu: {percent: null, cores: null, load: null},
|
||||||
memory: {percent: null, usedGiB: null, totalGiB: null},
|
memory: {percent: null, usedGiB: null, totalGiB: null},
|
||||||
disk: {percent: null, usedHuman: null, totalHuman: null},
|
disk: {percent: null, usedHuman: null, totalHuman: null},
|
||||||
@@ -520,6 +795,19 @@ app.get('/metrics/:id', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
process.on('SIGINT', () => {
|
||||||
|
statsLogger.info('Received SIGINT, shutting down gracefully');
|
||||||
|
connectionPool.destroy();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('SIGTERM', () => {
|
||||||
|
statsLogger.info('Received SIGTERM, shutting down gracefully');
|
||||||
|
connectionPool.destroy();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
const PORT = 8085;
|
const PORT = 8085;
|
||||||
app.listen(PORT, async () => {
|
app.listen(PORT, async () => {
|
||||||
statsLogger.success('Server Stats API server started', { operation: 'server_start', port: PORT });
|
statsLogger.success('Server Stats API server started', { operation: 'server_start', port: PORT });
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ export function Server({
|
|||||||
const [serverStatus, setServerStatus] = React.useState<'online' | 'offline'>('offline');
|
const [serverStatus, setServerStatus] = React.useState<'online' | 'offline'>('offline');
|
||||||
const [metrics, setMetrics] = React.useState<ServerMetrics | null>(null);
|
const [metrics, setMetrics] = React.useState<ServerMetrics | null>(null);
|
||||||
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
|
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
|
||||||
|
const [isLoadingMetrics, setIsLoadingMetrics] = React.useState(false);
|
||||||
|
const [isRefreshing, setIsRefreshing] = React.useState(false);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setCurrentHostConfig(hostConfig);
|
setCurrentHostConfig(hostConfig);
|
||||||
@@ -98,6 +100,7 @@ export function Server({
|
|||||||
const fetchMetrics = async () => {
|
const fetchMetrics = async () => {
|
||||||
if (!currentHostConfig?.id) return;
|
if (!currentHostConfig?.id) return;
|
||||||
try {
|
try {
|
||||||
|
setIsLoadingMetrics(true);
|
||||||
const data = await getServerMetricsById(currentHostConfig.id);
|
const data = await getServerMetricsById(currentHostConfig.id);
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setMetrics(data);
|
setMetrics(data);
|
||||||
@@ -108,6 +111,10 @@ export function Server({
|
|||||||
setMetrics(null);
|
setMetrics(null);
|
||||||
toast.error(t('serverStats.failedToFetchMetrics'));
|
toast.error(t('serverStats.failedToFetchMetrics'));
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setIsLoadingMetrics(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -160,21 +167,25 @@ export function Server({
|
|||||||
<div className="h-full w-full flex flex-col">
|
<div className="h-full w-full flex flex-col">
|
||||||
|
|
||||||
{/* Top Header */}
|
{/* Top Header */}
|
||||||
<div className="flex items-center justify-between px-3 pt-2 pb-2">
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between px-4 pt-3 pb-3 gap-3">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4 min-w-0">
|
||||||
<h1 className="font-bold text-lg">
|
<div className="min-w-0">
|
||||||
{currentHostConfig?.folder} / {title}
|
<h1 className="font-bold text-lg truncate">
|
||||||
</h1>
|
{currentHostConfig?.folder} / {title}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
<Status status={serverStatus} className="!bg-transparent !p-0.75 flex-shrink-0">
|
<Status status={serverStatus} className="!bg-transparent !p-0.75 flex-shrink-0">
|
||||||
<StatusIndicator/>
|
<StatusIndicator/>
|
||||||
</Status>
|
</Status>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
disabled={isRefreshing}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (currentHostConfig?.id) {
|
if (currentHostConfig?.id) {
|
||||||
try {
|
try {
|
||||||
|
setIsRefreshing(true);
|
||||||
const res = await getServerStatusById(currentHostConfig.id);
|
const res = await getServerStatusById(currentHostConfig.id);
|
||||||
setServerStatus(res?.status === 'online' ? 'online' : 'offline');
|
setServerStatus(res?.status === 'online' ? 'online' : 'offline');
|
||||||
const data = await getServerMetricsById(currentHostConfig.id);
|
const data = await getServerMetricsById(currentHostConfig.id);
|
||||||
@@ -182,12 +193,21 @@ export function Server({
|
|||||||
} catch {
|
} catch {
|
||||||
setServerStatus('offline');
|
setServerStatus('offline');
|
||||||
setMetrics(null);
|
setMetrics(null);
|
||||||
|
} finally {
|
||||||
|
setIsRefreshing(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
title={t('serverStats.refreshStatusAndMetrics')}
|
title={t('serverStats.refreshStatusAndMetrics')}
|
||||||
>
|
>
|
||||||
{t('serverStats.refreshStatus')}
|
{isRefreshing ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-4 h-4 border-2 border-gray-300 border-t-transparent rounded-full animate-spin"></div>
|
||||||
|
{t('serverStats.refreshing')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
t('serverStats.refreshStatus')
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
{currentHostConfig?.enableFileManager && (
|
{currentHostConfig?.enableFileManager && (
|
||||||
<Button
|
<Button
|
||||||
@@ -215,66 +235,151 @@ export function Server({
|
|||||||
<Separator className="p-0.25 w-full"/>
|
<Separator className="p-0.25 w-full"/>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker flex flex-row items-stretch">
|
<div className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker p-4">
|
||||||
{/* CPU */}
|
{isLoadingMetrics && !metrics ? (
|
||||||
<div className="flex-1 min-w-0 px-2 py-2">
|
<div className="flex items-center justify-center py-8">
|
||||||
<h1 className="font-bold xt-lg flex flex-row gap-2 mb-2">
|
<div className="flex items-center gap-3">
|
||||||
<Cpu/>
|
<div className="w-6 h-6 border-2 border-blue-400 border-t-transparent rounded-full animate-spin"></div>
|
||||||
{(() => {
|
<span className="text-gray-300">{t('serverStats.loadingMetrics')}</span>
|
||||||
const pct = metrics?.cpu?.percent;
|
</div>
|
||||||
const cores = metrics?.cpu?.cores;
|
</div>
|
||||||
const la = metrics?.cpu?.load;
|
) : !metrics && serverStatus === 'offline' ? (
|
||||||
const pctText = (typeof pct === 'number') ? `${pct}%` : 'N/A';
|
<div className="flex items-center justify-center py-8">
|
||||||
const coresText = (typeof cores === 'number') ? t('serverStats.cpuCores', {count: cores}) : t('serverStats.naCpus');
|
<div className="text-center">
|
||||||
const laText = (la && la.length === 3)
|
<div className="w-12 h-12 mx-auto mb-3 rounded-full bg-red-500/20 flex items-center justify-center">
|
||||||
? t('serverStats.loadAverage', {avg1: la[0].toFixed(2), avg5: la[1].toFixed(2), avg15: la[2].toFixed(2)})
|
<div className="w-6 h-6 border-2 border-red-400 rounded-full"></div>
|
||||||
: t('serverStats.loadAverageNA');
|
</div>
|
||||||
return `${t('serverStats.cpuUsage')} - ${pctText} ${t('serverStats.of')} ${coresText} (${laText})`;
|
<p className="text-gray-300 mb-1">{t('serverStats.serverOffline')}</p>
|
||||||
})()}
|
<p className="text-sm text-gray-500">{t('serverStats.cannotFetchMetrics')}</p>
|
||||||
</h1>
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 lg:gap-6">
|
||||||
|
{/* CPU Stats */}
|
||||||
|
<div className="space-y-3 p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Cpu className="h-5 w-5 text-blue-400" />
|
||||||
|
<h3 className="font-semibold text-lg text-white">{t('serverStats.cpuUsage')}</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-sm text-gray-300">
|
||||||
|
{(() => {
|
||||||
|
const pct = metrics?.cpu?.percent;
|
||||||
|
const cores = metrics?.cpu?.cores;
|
||||||
|
const pctText = (typeof pct === 'number') ? `${pct}%` : 'N/A';
|
||||||
|
const coresText = (typeof cores === 'number') ? t('serverStats.cpuCores', {count: cores}) : t('serverStats.naCpus');
|
||||||
|
return `${pctText} ${t('serverStats.of')} ${coresText}`;
|
||||||
|
})()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<Progress
|
||||||
|
value={typeof metrics?.cpu?.percent === 'number' ? metrics!.cpu!.percent! : 0}
|
||||||
|
className="h-2"
|
||||||
|
/>
|
||||||
|
{typeof metrics?.cpu?.percent === 'number' && metrics.cpu.percent > 80 && (
|
||||||
|
<div className="absolute -top-1 -right-1 w-3 h-3 bg-red-500 rounded-full animate-pulse"></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
{metrics?.cpu?.load ?
|
||||||
|
`Load: ${metrics.cpu.load[0].toFixed(2)}, ${metrics.cpu.load[1].toFixed(2)}, ${metrics.cpu.load[2].toFixed(2)}` :
|
||||||
|
'Load: N/A'
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Progress value={typeof metrics?.cpu?.percent === 'number' ? metrics!.cpu!.percent! : 0}/>
|
{/* Memory Stats */}
|
||||||
</div>
|
<div className="space-y-3 p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<Separator className="p-0.5 self-stretch" orientation="vertical"/>
|
<MemoryStick className="h-5 w-5 text-green-400" />
|
||||||
|
<h3 className="font-semibold text-lg text-white">{t('serverStats.memoryUsage')}</h3>
|
||||||
{/* Memory */}
|
</div>
|
||||||
<div className="flex-1 min-w-0 px-2 py-2">
|
|
||||||
<h1 className="font-bold xt-lg flex flex-row gap-2 mb-2">
|
<div className="space-y-2">
|
||||||
<MemoryStick/>
|
<div className="flex justify-between items-center">
|
||||||
{(() => {
|
<span className="text-sm text-gray-300">
|
||||||
const pct = metrics?.memory?.percent;
|
{(() => {
|
||||||
const used = metrics?.memory?.usedGiB;
|
const pct = metrics?.memory?.percent;
|
||||||
const total = metrics?.memory?.totalGiB;
|
const used = metrics?.memory?.usedGiB;
|
||||||
const pctText = (typeof pct === 'number') ? `${pct}%` : 'N/A';
|
const total = metrics?.memory?.totalGiB;
|
||||||
const usedText = (typeof used === 'number') ? `${used} GiB` : 'N/A';
|
const pctText = (typeof pct === 'number') ? `${pct}%` : 'N/A';
|
||||||
const totalText = (typeof total === 'number') ? `${total} GiB` : 'N/A';
|
const usedText = (typeof used === 'number') ? `${used.toFixed(1)} GiB` : 'N/A';
|
||||||
return `${t('serverStats.memoryUsage')} - ${pctText} (${usedText} ${t('serverStats.of')} ${totalText})`;
|
const totalText = (typeof total === 'number') ? `${total.toFixed(1)} GiB` : 'N/A';
|
||||||
})()}
|
return `${pctText} (${usedText} ${t('serverStats.of')} ${totalText})`;
|
||||||
</h1>
|
})()}
|
||||||
|
</span>
|
||||||
<Progress value={typeof metrics?.memory?.percent === 'number' ? metrics!.memory!.percent! : 0}/>
|
</div>
|
||||||
</div>
|
|
||||||
|
<div className="relative">
|
||||||
<Separator className="p-0.5 self-stretch" orientation="vertical"/>
|
<Progress
|
||||||
|
value={typeof metrics?.memory?.percent === 'number' ? metrics!.memory!.percent! : 0}
|
||||||
{/* Root Storage */}
|
className="h-2"
|
||||||
<div className="flex-1 min-w-0 px-2 py-2">
|
/>
|
||||||
<h1 className="font-bold xt-lg flex flex-row gap-2 mb-2">
|
{typeof metrics?.memory?.percent === 'number' && metrics.memory.percent > 85 && (
|
||||||
<HardDrive/>
|
<div className="absolute -top-1 -right-1 w-3 h-3 bg-red-500 rounded-full animate-pulse"></div>
|
||||||
{(() => {
|
)}
|
||||||
const pct = metrics?.disk?.percent;
|
</div>
|
||||||
const used = metrics?.disk?.usedHuman;
|
|
||||||
const total = metrics?.disk?.totalHuman;
|
<div className="text-xs text-gray-500">
|
||||||
const pctText = (typeof pct === 'number') ? `${pct}%` : 'N/A';
|
{(() => {
|
||||||
const usedText = used ?? 'N/A';
|
const used = metrics?.memory?.usedGiB;
|
||||||
const totalText = total ?? 'N/A';
|
const total = metrics?.memory?.totalGiB;
|
||||||
return `${t('serverStats.rootStorageSpace')} - ${pctText} (${usedText} ${t('serverStats.of')} ${totalText})`;
|
const free = (typeof used === 'number' && typeof total === 'number') ? (total - used).toFixed(1) : 'N/A';
|
||||||
})()}
|
return `Free: ${free} GiB`;
|
||||||
</h1>
|
})()}
|
||||||
|
</div>
|
||||||
<Progress value={typeof metrics?.disk?.percent === 'number' ? metrics!.disk!.percent! : 0}/>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Disk Stats */}
|
||||||
|
<div className="space-y-3 p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<HardDrive className="h-5 w-5 text-orange-400" />
|
||||||
|
<h3 className="font-semibold text-lg text-white">{t('serverStats.rootStorageSpace')}</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-sm text-gray-300">
|
||||||
|
{(() => {
|
||||||
|
const pct = metrics?.disk?.percent;
|
||||||
|
const used = metrics?.disk?.usedHuman;
|
||||||
|
const total = metrics?.disk?.totalHuman;
|
||||||
|
const pctText = (typeof pct === 'number') ? `${pct}%` : 'N/A';
|
||||||
|
const usedText = used ?? 'N/A';
|
||||||
|
const totalText = total ?? 'N/A';
|
||||||
|
return `${pctText} (${usedText} ${t('serverStats.of')} ${totalText})`;
|
||||||
|
})()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<Progress
|
||||||
|
value={typeof metrics?.disk?.percent === 'number' ? metrics!.disk!.percent! : 0}
|
||||||
|
className="h-2"
|
||||||
|
/>
|
||||||
|
{typeof metrics?.disk?.percent === 'number' && metrics.disk.percent > 90 && (
|
||||||
|
<div className="absolute -top-1 -right-1 w-3 h-3 bg-red-500 rounded-full animate-pulse"></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
{(() => {
|
||||||
|
const used = metrics?.disk?.usedHuman;
|
||||||
|
const total = metrics?.disk?.totalHuman;
|
||||||
|
return used && total ? `Available: ${total}` : 'Available: N/A';
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* SSH Tunnels */}
|
{/* SSH Tunnels */}
|
||||||
|
|||||||
Reference in New Issue
Block a user