feat: add SSH metrics collection with ssh2 library and auto-scheduler
Replace sshpass with ssh2 Node.js library for reliable SSH connections. Add all 6 servers (CWP, EasyPanel, MCP Hub, Meet, WhatsApp, WhatSMS). Add 5-minute auto-collection scheduler in production mode. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@ import diagnosticRouter from './routes/diagnostic.js'
|
||||
import hetznerRouter from './routes/hetzner.js'
|
||||
import wpMonitorRouter from './routes/wp-monitor.js'
|
||||
import serverMetricsRouter from './routes/server-metrics.js'
|
||||
import { collectAllServerMetrics } from './services/server-metrics.js'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
@@ -65,10 +66,29 @@ app.use((err: any, _req: express.Request, res: express.Response, _next: express.
|
||||
// Start server
|
||||
app.listen(PORT, () => {
|
||||
console.log('='.repeat(50))
|
||||
console.log(`🚀 API Server running on http://localhost:${PORT}`)
|
||||
console.log(`📊 Dashboard: http://localhost:${PORT}/api/dashboard`)
|
||||
console.log(`🔍 Monitor: http://localhost:${PORT}/api/monitor`)
|
||||
console.log(`🔧 Diagnostic: http://localhost:${PORT}/api/diagnostic`)
|
||||
console.log(`☁️ Hetzner: http://localhost:${PORT}/api/hetzner`)
|
||||
console.log(`API Server running on http://localhost:${PORT}`)
|
||||
console.log(`Dashboard: http://localhost:${PORT}/api/dashboard`)
|
||||
console.log(`Monitor: http://localhost:${PORT}/api/monitor`)
|
||||
console.log(`Hetzner: http://localhost:${PORT}/api/hetzner`)
|
||||
console.log('='.repeat(50))
|
||||
|
||||
// Auto-collect server metrics every 5 minutes
|
||||
if (isProduction) {
|
||||
const INTERVAL = 5 * 60 * 1000
|
||||
console.log('[SCHEDULER] Server metrics collection every 5min')
|
||||
|
||||
// Initial collection after 30s (let server stabilize)
|
||||
setTimeout(() => {
|
||||
collectAllServerMetrics().catch(err =>
|
||||
console.error('[SCHEDULER] Initial collection failed:', err)
|
||||
)
|
||||
}, 30000)
|
||||
|
||||
// Recurring collection
|
||||
setInterval(() => {
|
||||
collectAllServerMetrics().catch(err =>
|
||||
console.error('[SCHEDULER] Collection failed:', err)
|
||||
)
|
||||
}, INTERVAL)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
/**
|
||||
* Server Metrics Collector Service
|
||||
* Recolhe métricas de todos os servidores (Hetzner API + SSH)
|
||||
* Recolhe métricas de todos os servidores via SSH (ssh2 library)
|
||||
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||
*/
|
||||
import db from '../db.js'
|
||||
import { collectAllMetrics as collectHetznerMetrics } from './hetzner.js'
|
||||
import { Client } from 'ssh2'
|
||||
|
||||
// Hetzner API Configuration (used by hetzner.ts service)
|
||||
|
||||
// SSH Configuration (from MCP ssh-unified)
|
||||
interface SSHServer {
|
||||
name: string
|
||||
monitorName: string
|
||||
host: string
|
||||
port: number
|
||||
user: string
|
||||
pass: string
|
||||
}
|
||||
@@ -22,6 +20,7 @@ const SSH_SERVERS: SSHServer[] = [
|
||||
name: 'server',
|
||||
monitorName: 'CWP Server',
|
||||
host: process.env.SERVER_HOST || '176.9.3.158',
|
||||
port: parseInt(process.env.SERVER_PORT || '9443'),
|
||||
user: process.env.SERVER_USER || 'root',
|
||||
pass: process.env.SERVER_PASS || ''
|
||||
},
|
||||
@@ -29,19 +28,44 @@ const SSH_SERVERS: SSHServer[] = [
|
||||
name: 'easy',
|
||||
monitorName: 'EasyPanel',
|
||||
host: process.env.EASY_HOST || '178.63.18.51',
|
||||
port: 22,
|
||||
user: process.env.EASY_USER || 'root',
|
||||
pass: process.env.EASY_PASS || ''
|
||||
},
|
||||
{
|
||||
name: 'mcp-hub',
|
||||
monitorName: 'MCP Hub',
|
||||
host: process.env.MCPHUB_HOST || 'mcp-hub.descomplicar.pt',
|
||||
port: 22,
|
||||
user: process.env.MCPHUB_USER || 'root',
|
||||
pass: process.env.MCPHUB_PASS || ''
|
||||
},
|
||||
{
|
||||
name: 'meet',
|
||||
monitorName: 'Meet',
|
||||
host: process.env.MEET_HOST || 'meet.descomplicar.pt',
|
||||
port: 22,
|
||||
user: process.env.MEET_USER || 'root',
|
||||
pass: process.env.MEET_PASS || ''
|
||||
},
|
||||
{
|
||||
name: 'whatsapp',
|
||||
monitorName: 'WhatsApp',
|
||||
host: process.env.WHATSAPP_HOST || 'whatsapp.descomplicar.pt',
|
||||
port: 22,
|
||||
user: process.env.WHATSAPP_USER || 'root',
|
||||
pass: process.env.WHATSAPP_PASS || ''
|
||||
},
|
||||
{
|
||||
name: 'whatsms',
|
||||
monitorName: 'WhatSMS',
|
||||
host: process.env.WHATSMS_HOST || 'whatsms.descomplicar.pt',
|
||||
port: 22,
|
||||
user: process.env.WHATSMS_USER || 'root',
|
||||
pass: process.env.WHATSMS_PASS || ''
|
||||
}
|
||||
]
|
||||
|
||||
// Hetzner server mapping to monitoring table
|
||||
const HETZNER_MAPPING: Record<string, string> = {
|
||||
'gateway': 'MCP Hub',
|
||||
'meet': 'Meet',
|
||||
'whatsapp.descomplicar.pt': 'WhatsApp',
|
||||
'whatsms': 'WhatSMS'
|
||||
}
|
||||
|
||||
interface ServerMetrics {
|
||||
cpu: number
|
||||
ram: number
|
||||
@@ -50,9 +74,6 @@ interface ServerMetrics {
|
||||
containers?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse SSH metrics output
|
||||
*/
|
||||
function parseSSHMetrics(output: string): ServerMetrics {
|
||||
const lines = output.split('\n')
|
||||
const metrics: ServerMetrics = { cpu: 0, ram: 0, disk: 0, load: 0 }
|
||||
@@ -69,29 +90,67 @@ function parseSSHMetrics(output: string): ServerMetrics {
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute SSH command via subprocess (since we can't use MCP from here)
|
||||
* This is a placeholder - in production, use the MCP or a proper SSH library
|
||||
* Execute SSH command via ssh2 library
|
||||
*/
|
||||
async function executeSSH(server: SSHServer, command: string): Promise<string> {
|
||||
// For now, we'll use the database values or call an external endpoint
|
||||
// In production, you'd use ssh2 library or call the MCP endpoint
|
||||
const { exec } = await import('child_process')
|
||||
const { promisify } = await import('util')
|
||||
const execAsync = promisify(exec)
|
||||
function executeSSH(server: SSHServer, command: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const conn = new Client()
|
||||
let output = ''
|
||||
const timeout = setTimeout(() => {
|
||||
conn.end()
|
||||
reject(new Error(`SSH timeout for ${server.name}`))
|
||||
}, 20000)
|
||||
|
||||
try {
|
||||
// Use sshpass for password-based auth (not ideal but works for internal servers)
|
||||
const sshCommand = `sshpass -p '${server.pass}' ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 ${server.user}@${server.host} "${command}"`
|
||||
const { stdout } = await execAsync(sshCommand, { timeout: 30000 })
|
||||
return stdout
|
||||
} catch (error) {
|
||||
console.error(`SSH error for ${server.name}:`, error)
|
||||
return ''
|
||||
}
|
||||
conn.on('ready', () => {
|
||||
conn.exec(command, (err, stream) => {
|
||||
if (err) {
|
||||
clearTimeout(timeout)
|
||||
conn.end()
|
||||
reject(err)
|
||||
return
|
||||
}
|
||||
stream.on('data', (data: Buffer) => {
|
||||
output += data.toString()
|
||||
})
|
||||
stream.on('close', () => {
|
||||
clearTimeout(timeout)
|
||||
conn.end()
|
||||
resolve(output)
|
||||
})
|
||||
stream.stderr.on('data', () => {
|
||||
// ignore stderr
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
conn.on('error', (err) => {
|
||||
clearTimeout(timeout)
|
||||
reject(err)
|
||||
})
|
||||
|
||||
conn.connect({
|
||||
host: server.host,
|
||||
port: server.port,
|
||||
username: server.user,
|
||||
password: server.pass,
|
||||
readyTimeout: 15000,
|
||||
algorithms: {
|
||||
kex: [
|
||||
'ecdh-sha2-nistp256',
|
||||
'ecdh-sha2-nistp384',
|
||||
'ecdh-sha2-nistp521',
|
||||
'diffie-hellman-group-exchange-sha256',
|
||||
'diffie-hellman-group14-sha256',
|
||||
'diffie-hellman-group14-sha1',
|
||||
'diffie-hellman-group1-sha1'
|
||||
]
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect metrics from SSH servers (CWP, EasyPanel)
|
||||
* Collect metrics from all SSH servers
|
||||
*/
|
||||
export async function collectSSHMetrics(): Promise<{ success: number; failed: number }> {
|
||||
let success = 0
|
||||
@@ -100,6 +159,12 @@ export async function collectSSHMetrics(): Promise<{ success: number; failed: nu
|
||||
const metricsCommand = `echo "CPU:$(top -bn1 | grep 'Cpu(s)' | awk '{print $2}')"; echo "MEM:$(free -m | awk '/Mem:/ {printf "%.1f", $3/$2*100}')"; echo "DISK:$(df -h / | awk 'NR==2 {print $5}' | tr -d '%')"; echo "LOAD:$(cat /proc/loadavg | awk '{print $1}')"; echo "CONTAINERS:$(docker ps -q 2>/dev/null | wc -l || echo 0)"`
|
||||
|
||||
for (const server of SSH_SERVERS) {
|
||||
if (!server.pass) {
|
||||
console.log(`[SSH] Skipping ${server.name}: no password configured`)
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const output = await executeSSH(server, metricsCommand)
|
||||
if (!output) {
|
||||
@@ -127,27 +192,30 @@ export async function collectSSHMetrics(): Promise<{ success: number; failed: nu
|
||||
|
||||
// Update containers if EasyPanel
|
||||
if (server.name === 'easy' && metrics.containers !== undefined) {
|
||||
// Get current container stats
|
||||
const containerOutput = await executeSSH(server, 'docker ps -a --format "{{.Status}}" | grep -c "Up" || echo 0; docker ps -aq | wc -l')
|
||||
const [up, total] = containerOutput.trim().split('\n').map(n => parseInt(n) || 0)
|
||||
const down = total - up
|
||||
try {
|
||||
const containerOutput = await executeSSH(server, 'docker ps -a --format "{{.Status}}" | grep -c "Up" || echo 0; docker ps -aq | wc -l')
|
||||
const [up, total] = containerOutput.trim().split('\n').map(n => parseInt(n) || 0)
|
||||
const down = total - up
|
||||
|
||||
await db.query(
|
||||
`UPDATE tbl_eal_monitoring
|
||||
SET details = ?, status = ?, last_check = NOW()
|
||||
WHERE category = 'container' AND name = 'EasyPanel Containers'`,
|
||||
[
|
||||
JSON.stringify({ total, up, down, restarting: 0 }),
|
||||
down > 0 ? 'warning' : 'ok'
|
||||
]
|
||||
)
|
||||
await db.query(
|
||||
`UPDATE tbl_eal_monitoring
|
||||
SET details = ?, status = ?, last_check = NOW()
|
||||
WHERE category = 'container' AND name = 'EasyPanel Containers'`,
|
||||
[
|
||||
JSON.stringify({ total, up, down, restarting: 0 }),
|
||||
down > 0 ? 'warning' : 'ok'
|
||||
]
|
||||
)
|
||||
} catch {
|
||||
// Container stats are optional
|
||||
}
|
||||
}
|
||||
|
||||
success++
|
||||
console.log(`✅ ${server.monitorName}: CPU=${metrics.cpu}%, RAM=${metrics.ram}%, Disk=${metrics.disk}%`)
|
||||
console.log(`[SSH] ${server.monitorName}: CPU=${metrics.cpu}%, RAM=${metrics.ram}%, Disk=${metrics.disk}%`)
|
||||
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to collect metrics from ${server.name}:`, error)
|
||||
console.error(`[SSH] Failed ${server.name}:`, (error as Error).message)
|
||||
failed++
|
||||
}
|
||||
}
|
||||
@@ -156,62 +224,20 @@ export async function collectSSHMetrics(): Promise<{ success: number; failed: nu
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync Hetzner metrics to monitoring table
|
||||
*/
|
||||
export async function syncHetznerToMonitoring(): Promise<number> {
|
||||
// First collect fresh Hetzner metrics
|
||||
await collectHetznerMetrics()
|
||||
|
||||
// Then sync to monitoring table
|
||||
let synced = 0
|
||||
|
||||
for (const [hetznerName, monitorName] of Object.entries(HETZNER_MAPPING)) {
|
||||
const namePattern = hetznerName.includes('.') ? hetznerName : `${hetznerName}%`
|
||||
|
||||
const [result] = await db.query<any[]>(`
|
||||
UPDATE tbl_eal_monitoring m
|
||||
JOIN v_eal_hetzner_latest h ON h.name LIKE ?
|
||||
SET m.details = JSON_OBJECT(
|
||||
'cpu', ROUND(h.cpu_percent, 1),
|
||||
'ram', 0,
|
||||
'disk', 0,
|
||||
'load', ROUND(h.cpu_percent / 25, 2),
|
||||
'network_in', h.network_in_bps,
|
||||
'network_out', h.network_out_bps,
|
||||
'hetzner_id', h.hetzner_id
|
||||
),
|
||||
m.status = 'up',
|
||||
m.last_check = NOW()
|
||||
WHERE m.category = 'server' AND m.name = ?
|
||||
`, [namePattern, monitorName])
|
||||
|
||||
if ((result as any).affectedRows > 0) synced++
|
||||
}
|
||||
|
||||
return synced
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all server metrics (Hetzner + SSH)
|
||||
* Collect all server metrics (SSH only - replaces Hetzner sync)
|
||||
*/
|
||||
export async function collectAllServerMetrics(): Promise<{
|
||||
hetzner: { success: number; failed: number }
|
||||
ssh: { success: number; failed: number }
|
||||
synced: number
|
||||
}> {
|
||||
console.log('[METRICS] Starting server metrics collection...')
|
||||
|
||||
// Collect SSH metrics (CWP, EasyPanel)
|
||||
console.log('[METRICS] Collecting server metrics via SSH...')
|
||||
const ssh = await collectSSHMetrics()
|
||||
console.log(`[SSH] ${ssh.success} OK, ${ssh.failed} failed`)
|
||||
|
||||
// Collect and sync Hetzner metrics
|
||||
const synced = await syncHetznerToMonitoring()
|
||||
console.log(`[HETZNER] ${synced} servers synced to monitoring`)
|
||||
|
||||
return {
|
||||
hetzner: { success: synced, failed: 4 - synced },
|
||||
ssh,
|
||||
synced
|
||||
}
|
||||
console.log(`[METRICS] Done: ${ssh.success} OK, ${ssh.failed} failed`)
|
||||
return { ssh }
|
||||
}
|
||||
|
||||
// Keep for backward compatibility with routes
|
||||
export async function syncHetznerToMonitoring(): Promise<number> {
|
||||
// Now handled by SSH collection
|
||||
const result = await collectSSHMetrics()
|
||||
return result.success
|
||||
}
|
||||
|
||||
100
package-lock.json
generated
100
package-lock.json
generated
@@ -22,6 +22,7 @@
|
||||
"react-oidc-context": "^3.1.1",
|
||||
"react-router-dom": "^7.13.0",
|
||||
"recharts": "^3.7.0",
|
||||
"ssh2": "^1.17.0",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -32,6 +33,7 @@
|
||||
"@types/node": "^24.10.10",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/ssh2": "^1.15.5",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"autoprefixer": "^10.4.24",
|
||||
"concurrently": "^9.1.2",
|
||||
@@ -1971,6 +1973,33 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ssh2": {
|
||||
"version": "1.15.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.5.tgz",
|
||||
"integrity": "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "^18.11.18"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ssh2/node_modules/@types/node": {
|
||||
"version": "18.19.130",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz",
|
||||
"integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~5.26.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ssh2/node_modules/undici-types": {
|
||||
"version": "5.26.5",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
|
||||
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
@@ -2370,6 +2399,15 @@
|
||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/asn1": {
|
||||
"version": "0.2.6",
|
||||
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
|
||||
"integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": "~2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/autoprefixer": {
|
||||
"version": "10.4.24",
|
||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.24.tgz",
|
||||
@@ -2453,6 +2491,15 @@
|
||||
"baseline-browser-mapping": "dist/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/bcrypt-pbkdf": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
|
||||
"integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"tweetnacl": "^0.14.3"
|
||||
}
|
||||
},
|
||||
"node_modules/bignumber.js": {
|
||||
"version": "9.3.1",
|
||||
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
|
||||
@@ -2553,6 +2600,15 @@
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/buildcheck": {
|
||||
"version": "0.0.7",
|
||||
"resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz",
|
||||
"integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
@@ -2795,6 +2851,20 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/cpu-features": {
|
||||
"version": "0.0.10",
|
||||
"resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz",
|
||||
"integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==",
|
||||
"hasInstallScript": true,
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"buildcheck": "~0.0.6",
|
||||
"nan": "^2.19.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -4750,6 +4820,13 @@
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nan": {
|
||||
"version": "2.25.0",
|
||||
"resolved": "https://registry.npmjs.org/nan/-/nan-2.25.0.tgz",
|
||||
"integrity": "sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
@@ -5578,6 +5655,23 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/ssh2": {
|
||||
"version": "1.17.0",
|
||||
"resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz",
|
||||
"integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"asn1": "^0.2.6",
|
||||
"bcrypt-pbkdf": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.16.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"cpu-features": "~0.0.10",
|
||||
"nan": "^2.23.0"
|
||||
}
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
@@ -5760,6 +5854,12 @@
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/tweetnacl": {
|
||||
"version": "0.14.5",
|
||||
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
|
||||
"integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==",
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"react-oidc-context": "^3.1.1",
|
||||
"react-router-dom": "^7.13.0",
|
||||
"recharts": "^3.7.0",
|
||||
"ssh2": "^1.17.0",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -37,6 +38,7 @@
|
||||
"@types/node": "^24.10.10",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/ssh2": "^1.15.5",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"autoprefixer": "^10.4.24",
|
||||
"concurrently": "^9.1.2",
|
||||
|
||||
Reference in New Issue
Block a user