diff --git a/CHANGELOG.md b/CHANGELOG.md index 273b7314..79c9c638 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +- **Added:** Centralized observability dashboard tracking 24-hour historical metrics and aggregating global tail logs across all running containers. - **Added:** Live Container Logs viewer using Server-Sent Events (SSE) for real-time terminal output. - **Added:** Pre-deploy folder collision check to prevent silent configuration overwrites in the App Store. - **Added:** UI subtitle during deployment to reassure users during long image downloads. diff --git a/backend/src/index.ts b/backend/src/index.ts index 9d7fe69a..d7919996 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -762,6 +762,59 @@ app.get('/api/stats', async (req: Request, res: Response) => { } }); +app.get('/api/metrics/historical', async (req: Request, res: Response) => { + try { + const metrics = DatabaseService.getInstance().getContainerMetrics(24); + res.json(metrics); + } catch (error) { + res.status(500).json({ error: 'Failed to fetch metrics' }); + } +}); + +app.get('/api/logs/global', async (req: Request, res: Response) => { + try { + const dockerController = DockerController.getInstance(); + const containers = await dockerController.getRunningContainers(); + const allLogs: any[] = []; + + await Promise.all(containers.map(async (c) => { + const stackName = c.Labels?.['com.docker.compose.project'] || 'system'; + const containerName = c.Names?.[0]?.replace(/^\//, '') || c.Id.substring(0, 12); + try { + const container = dockerController.getDocker().getContainer(c.Id); + // Fetch last 50 lines with timestamps + const logsBuffer = await container.logs({ stdout: true, stderr: true, tail: 50, timestamps: true }); + + // Strip docker multiplex headers (non-tty) + const logsString = logsBuffer.toString('utf-8').replace(/[\u0000-\u0008\u000B-\u001F\u007F-\u009F]/g, ""); + const lines = logsString.split('\n').filter(l => l.trim().length > 0); + + lines.forEach(line => { + let level = 'INFO'; + const lowerLine = line.toLowerCase(); + if (lowerLine.includes('error') || lowerLine.includes('err') || lowerLine.includes('fail') || lowerLine.includes('fatal')) level = 'ERROR'; + else if (lowerLine.includes('warn')) level = 'WARN'; + + // Extract Docker timestamp if present (usually ISO format at start of line) + const timeMatch = line.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z)\s+(.*)/); + const timestamp = timeMatch ? new Date(timeMatch[1]).getTime() : Date.now(); + const cleanMessage = timeMatch ? timeMatch[2] : line; + + allLogs.push({ stackName, containerName, level, message: cleanMessage, timestamp }); + }); + } catch (err) { + // Ignore individual container fetch errors + } + })); + + // Sort globally by timestamp descending and limit to 1000 lines to prevent payload bloat + allLogs.sort((a, b) => b.timestamp - a.timestamp); + res.json(allLogs.slice(0, 1000)); + } catch (error) { + res.status(500).json({ error: 'Failed to fetch global logs' }); + } +}); + // Get host system stats app.get('/api/system/stats', async (req: Request, res: Response) => { try { diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 90fc1d8a..e6b01535 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -96,6 +96,20 @@ export class DatabaseService { timestamp INTEGER NOT NULL, is_read INTEGER DEFAULT 0 ); + + CREATE TABLE IF NOT EXISTS container_metrics ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + container_id TEXT NOT NULL, + stack_name TEXT NOT NULL, + cpu_percent REAL NOT NULL, + memory_mb REAL NOT NULL, + net_rx_mb REAL NOT NULL, + net_tx_mb REAL NOT NULL, + timestamp INTEGER NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_metrics_timestamp ON container_metrics(timestamp); + CREATE INDEX IF NOT EXISTS idx_metrics_container ON container_metrics(container_id); `); // Initialize default global settings if they don't exist @@ -251,4 +265,25 @@ export class DatabaseService { const stmt = this.db.prepare('DELETE FROM notification_history'); stmt.run(); } + + // --- Container Metrics --- + + public addContainerMetric(metric: Omit): void { + const stmt = this.db.prepare( + 'INSERT INTO container_metrics (container_id, stack_name, cpu_percent, memory_mb, net_rx_mb, net_tx_mb, timestamp) VALUES (?, ?, ?, ?, ?, ?, ?)' + ); + stmt.run(metric.container_id, metric.stack_name, metric.cpu_percent, metric.memory_mb, metric.net_rx_mb, metric.net_tx_mb, metric.timestamp); + } + + public getContainerMetrics(hoursLookback = 24): any[] { + const cutoff = Date.now() - (hoursLookback * 60 * 60 * 1000); + const stmt = this.db.prepare('SELECT * FROM container_metrics WHERE timestamp >= ? ORDER BY timestamp ASC'); + return stmt.all(cutoff); + } + + public cleanupOldMetrics(hoursToKeep = 24): void { + const cutoff = Date.now() - (hoursToKeep * 60 * 60 * 1000); + const stmt = this.db.prepare('DELETE FROM container_metrics WHERE timestamp < ?'); + stmt.run(cutoff); + } } diff --git a/backend/src/services/MonitorService.ts b/backend/src/services/MonitorService.ts index d076267c..793f60b4 100644 --- a/backend/src/services/MonitorService.ts +++ b/backend/src/services/MonitorService.ts @@ -203,92 +203,92 @@ export class MonitorService { private async evaluateStackAlerts(db: DatabaseService) { const alerts = db.getStackAlerts(); - if (alerts.length === 0) return; - - // Group alerts by stack so we only fetch stats for stacks we care about - const stacksToMonitor = new Set(alerts.map(a => a.stack_name)); - const docker = DockerController.getInstance(); - for (const stackName of stacksToMonitor) { - const stackAlerts = alerts.filter(a => a.stack_name === stackName); - if (stackAlerts.length === 0) continue; + try { + const containers = await docker.getRunningContainers(); + for (const container of containers) { + const stackName = container.Labels?.['com.docker.compose.project'] || 'system'; - try { - const containers = await docker.getContainersByStack(stackName); - if (containers.length === 0) continue; + try { + const rawStats = await docker.getContainerStatsStream(container.Id); + const stats = JSON.parse(rawStats); - // Fetch stats for all containers in this stack - for (const container of containers) { - if (container.State !== 'running') continue; + const metrics = { + cpu_percent: this.calculateCpuPercent(stats), + memory_percent: this.calculateMemoryPercent(stats), + memory_mb: (stats.memory_stats?.usage || 0) / (1024 * 1024), + net_rx: this.calculateNetwork(stats, 'rx'), + net_tx: this.calculateNetwork(stats, 'tx'), + restart_count: 0 // Simplification since ContainerInfo doesn't have it natively + }; - try { - const rawStats = await docker.getContainerStatsStream(container.Id); - const stats = JSON.parse(rawStats); + db.addContainerMetric({ + container_id: container.Id, + stack_name: stackName, + cpu_percent: metrics.cpu_percent || 0, + memory_mb: metrics.memory_mb || 0, + net_rx_mb: metrics.net_rx || 0, + net_tx_mb: metrics.net_tx || 0, + timestamp: Date.now() + }); - const metrics = { - cpu_percent: this.calculateCpuPercent(stats), - memory_percent: this.calculateMemoryPercent(stats), - memory_mb: (stats.memory_stats?.usage || 0) / (1024 * 1024), - net_rx: this.calculateNetwork(stats, 'rx'), // KB/s (naive sum for now, proper net_rx requires time delta but we can do total MB for simple alert or just use what fits) - net_tx: this.calculateNetwork(stats, 'tx'), - restart_count: container.RestartCount || 0 - }; + const stackAlerts = alerts.filter(a => a.stack_name === stackName); + for (const rule of stackAlerts) { + const ruleId = rule.id!; + const currentValue = metrics[rule.metric as keyof typeof metrics]; - for (const rule of stackAlerts) { - const ruleId = rule.id!; - const currentValue = metrics[rule.metric as keyof typeof metrics]; + if (currentValue === undefined) continue; - if (currentValue === undefined) continue; + const isBreaching = this.evaluateCondition(currentValue, rule.operator, rule.threshold); - const isBreaching = this.evaluateCondition(currentValue, rule.operator, rule.threshold); + if (isBreaching) { + if (!this.activeBreaches.has(ruleId)) { + this.activeBreaches.set(ruleId, { breachStartedAt: Date.now() }); + } - if (isBreaching) { - if (!this.activeBreaches.has(ruleId)) { - this.activeBreaches.set(ruleId, { breachStartedAt: Date.now() }); - } + const breachState = this.activeBreaches.get(ruleId)!; + const durationMs = Date.now() - breachState.breachStartedAt; + const requiredDurationMs = rule.duration_mins * 60 * 1000; - const breachState = this.activeBreaches.get(ruleId)!; - const durationMs = Date.now() - breachState.breachStartedAt; - const requiredDurationMs = rule.duration_mins * 60 * 1000; + if (durationMs >= requiredDurationMs) { + // Duration met! Check cooldown + const timeSinceLastFired = Date.now() - (rule.last_fired_at || 0); + const requiredCooldownMs = rule.cooldown_mins * 60 * 1000; - if (durationMs >= requiredDurationMs) { - // Duration met! Check cooldown - const timeSinceLastFired = Date.now() - (rule.last_fired_at || 0); - const requiredCooldownMs = rule.cooldown_mins * 60 * 1000; + if (timeSinceLastFired >= requiredCooldownMs) { + // Formatted Alert Message + const { name: metricName, unit } = getMetricDetails(rule.metric); + const operatorPhrase = getOperatorPhrase(rule.operator); - if (timeSinceLastFired >= requiredCooldownMs) { - // Formatted Alert Message - const { name: metricName, unit } = getMetricDetails(rule.metric); - const operatorPhrase = getOperatorPhrase(rule.operator); + const safeCurrent = typeof currentValue === 'number' ? Number(currentValue.toFixed(2)) : currentValue; + const safeThreshold = typeof rule.threshold === 'number' ? Number(rule.threshold.toFixed(2)) : rule.threshold; - const safeCurrent = typeof currentValue === 'number' ? Number(currentValue.toFixed(2)) : currentValue; - const safeThreshold = typeof rule.threshold === 'number' ? Number(rule.threshold.toFixed(2)) : rule.threshold; + const message = `The **${metricName}** for **${rule.stack_name}** ${operatorPhrase} **${safeThreshold}${unit}** (Currently: ${safeCurrent}${unit}).`; - const message = `The **${metricName}** for **${rule.stack_name}** ${operatorPhrase} **${safeThreshold}${unit}** (Currently: ${safeCurrent}${unit}).`; + await NotificationService.getInstance().dispatchAlert( + 'warning', + message + ); - await NotificationService.getInstance().dispatchAlert( - 'warning', - message - ); - - // Update last fired - db.updateStackAlertLastFired(ruleId, Date.now()); - } - } - } else { - // Rule isn't breaching anymore, reset tracker - if (this.activeBreaches.has(ruleId)) { - this.activeBreaches.delete(ruleId); + // Update last fired + db.updateStackAlertLastFired(ruleId, Date.now()); } } + } else { + // Rule isn't breaching anymore, reset tracker + if (this.activeBreaches.has(ruleId)) { + this.activeBreaches.delete(ruleId); + } } - } catch (e) { - console.error(`Error parsing stats for container ${container.Id}`, e); } + } catch (e) { + console.error(`Error parsing stats for container ${container.Id}`, e); } - } catch (e) { } - } + } + + db.cleanupOldMetrics(24); + } catch (e) { } } private evaluateCondition(actual: number, operator: string, threshold: number): boolean { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index bd8e36aa..c219840f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -34,7 +34,7 @@ "next-themes": "^0.4.6", "react": "^19.2.0", "react-dom": "^19.2.0", - "recharts": "^3.7.0", + "recharts": "^2.15.4", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tailwindcss-animate": "^1.0.7", @@ -293,6 +293,15 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -2248,42 +2257,6 @@ "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", "license": "MIT" }, - "node_modules/@reduxjs/toolkit": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", - "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.0.0", - "@standard-schema/utils": "^0.3.0", - "immer": "^11.0.0", - "redux": "^5.0.1", - "redux-thunk": "^3.1.0", - "reselect": "^5.1.0" - }, - "peerDependencies": { - "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", - "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - }, - "react-redux": { - "optional": true - } - } - }, - "node_modules/@reduxjs/toolkit/node_modules/immer": { - "version": "11.1.4", - "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", - "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/immer" - } - }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.3", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", @@ -2641,18 +2614,6 @@ "win32" ] }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "license": "MIT" - }, - "node_modules/@standard-schema/utils": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", - "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", - "license": "MIT" - }, "node_modules/@tailwindcss/node": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz", @@ -3087,12 +3048,6 @@ "license": "MIT", "optional": true }, - "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", - "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", - "license": "MIT" - }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.56.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.0.tgz", @@ -3704,7 +3659,6 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, "license": "MIT" }, "node_modules/d3-array": { @@ -3875,6 +3829,16 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, "node_modules/dompurify": { "version": "3.2.7", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", @@ -3905,16 +3869,6 @@ "node": ">=10.13.0" } }, - "node_modules/es-toolkit": { - "version": "1.45.1", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz", - "integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==", - "license": "MIT", - "workspaces": [ - "docs", - "benchmarks" - ] - }, "node_modules/esbuild": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", @@ -4166,9 +4120,9 @@ } }, "node_modules/eventemitter3": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", - "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", "license": "MIT" }, "node_modules/fast-deep-equal": { @@ -4178,6 +4132,15 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-equals": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", + "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -4365,16 +4328,6 @@ "node": ">= 4" } }, - "node_modules/immer": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", - "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/immer" - } - }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -4455,7 +4408,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -4819,6 +4771,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -4826,6 +4784,18 @@ "dev": true, "license": "MIT" }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -4941,6 +4911,15 @@ "dev": true, "license": "MIT" }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -5084,6 +5063,23 @@ "node": ">= 0.8.0" } }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -5118,35 +5114,10 @@ } }, "node_modules/react-is": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz", - "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==", - "license": "MIT", - "peer": true - }, - "node_modules/react-redux": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", - "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", - "license": "MIT", - "peer": true, - "dependencies": { - "@types/use-sync-external-store": "^0.0.6", - "use-sync-external-store": "^1.4.0" - }, - "peerDependencies": { - "@types/react": "^18.2.25 || ^19", - "react": "^18.0 || ^19", - "redux": "^5.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "redux": { - "optional": true - } - } + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" }, "node_modules/react-refresh": { "version": "0.18.0", @@ -5205,6 +5176,21 @@ } } }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react-style-singleton": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", @@ -5227,58 +5213,54 @@ } } }, - "node_modules/recharts": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz", - "integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==", - "license": "MIT", - "workspaces": [ - "www" - ], + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", "dependencies": { - "@reduxjs/toolkit": "1.x.x || 2.x.x", - "clsx": "^2.1.1", - "decimal.js-light": "^2.5.1", - "es-toolkit": "^1.39.3", - "eventemitter3": "^5.0.1", - "immer": "^10.1.1", - "react-redux": "8.x.x || 9.x.x", - "reselect": "5.1.1", - "tiny-invariant": "^1.3.3", - "use-sync-external-store": "^1.2.2", - "victory-vendor": "^37.0.2" + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/recharts": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" }, "engines": { - "node": ">=18" + "node": ">=14" }, "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/redux": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", - "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", "license": "MIT", - "peer": true - }, - "node_modules/redux-thunk": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", - "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", - "license": "MIT", - "peerDependencies": { - "redux": "^5.0.0" + "dependencies": { + "decimal.js-light": "^2.4.1" } }, - "node_modules/reselect": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", - "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", - "license": "MIT" - }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -5650,19 +5632,10 @@ } } }, - "node_modules/use-sync-external-store": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", - "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", - "license": "MIT", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, "node_modules/victory-vendor": { - "version": "37.3.6", - "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", - "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", "license": "MIT AND ISC", "dependencies": { "@types/d3-array": "^3.0.3", diff --git a/frontend/package.json b/frontend/package.json index 0ce09878..167971e3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -36,7 +36,7 @@ "next-themes": "^0.4.6", "react": "^19.2.0", "react-dom": "^19.2.0", - "recharts": "^3.7.0", + "recharts": "^2.15.4", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tailwindcss-animate": "^1.0.7", diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 75f6b61b..da13bd4e 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -13,7 +13,7 @@ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, import { Tabs, TabsList, TabsTrigger } from './ui/tabs'; import { Card, CardContent, CardHeader, CardTitle } from './ui/card'; import { Badge } from './ui/badge'; -import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, LogOut, ExternalLink, Bell, Settings, MoreVertical, BellRing, Rocket, HardDrive, ScrollText } from 'lucide-react'; +import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, LogOut, ExternalLink, Bell, Settings, MoreVertical, BellRing, Rocket, HardDrive, ScrollText, Activity } from 'lucide-react'; import { useAuth } from '@/context/AuthContext'; import { apiFetch } from '@/lib/api'; import { toast } from 'sonner'; @@ -30,6 +30,7 @@ import { SettingsModal } from './SettingsModal'; import { StackAlertSheet } from './StackAlertSheet'; import { AppStoreView } from './AppStoreView'; import { LogViewer } from './LogViewer'; +import { GlobalObservabilityView } from './GlobalObservabilityView'; interface ContainerInfo { Id: string; @@ -79,7 +80,7 @@ export default function EditorLayout() { } return true; // Default to dark mode }); - const [activeView, setActiveView] = useState<'dashboard' | 'editor' | 'host-console' | 'resources' | 'templates'>('dashboard'); + const [activeView, setActiveView] = useState<'dashboard' | 'editor' | 'host-console' | 'resources' | 'templates' | 'global-observability'>('dashboard'); const [isEditing, setIsEditing] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [stackStatuses, setStackStatuses] = useState({}); @@ -793,6 +794,17 @@ export default function EditorLayout() { App Store + {/* Global Observability Toggle */} + {/* Settings Modal Toggle */} + + + {/* Charts Section */} +
+ + + + + Aggregate CPU Usage + + Total CPU percentage across all containers. + + + {chartData.length > 0 ? ( + + + + + `${val}%`} /> + } /> + + + + ) : ( +
+ No historical CPU data available. +
+ )} +
+
+ + + + + + Aggregate Memory Usage + + Total Memory (MB) allocated across all containers. + + + {chartData.length > 0 ? ( + + + + + `${val}MB`} /> + } /> + + + + ) : ( +
+ No historical Memory data available. +
+ )} +
+
+
+ + {/* Global Logs Section */} + + +
+ + + Unified Global Logs + + +
+ + + +
+
+
+ + + {filteredLogs.length > 0 ? ( + filteredLogs.map((log, idx) => ( +
+ [{new Date(log.timestamp).toLocaleTimeString()}] + [{log.stackName}/{log.containerName}] + + {log.level}: + + {log.message} +
+ )) + ) : ( +
No logs found matching criteria.
+ )} +
+
+
+ + ); +} diff --git a/frontend/src/components/ui/chart.tsx b/frontend/src/components/ui/chart.tsx new file mode 100644 index 00000000..23dc1c1b --- /dev/null +++ b/frontend/src/components/ui/chart.tsx @@ -0,0 +1,367 @@ +import * as React from "react" +import * as RechartsPrimitive from "recharts" + +import { cn } from "@/lib/utils" + +// Format: { THEME_NAME: CSS_SELECTOR } +const THEMES = { light: "", dark: ".dark" } as const + +export type ChartConfig = { + [k in string]: { + label?: React.ReactNode + icon?: React.ComponentType + } & ( + | { color?: string; theme?: never } + | { color?: never; theme: Record } + ) +} + +type ChartContextProps = { + config: ChartConfig +} + +const ChartContext = React.createContext(null) + +function useChart() { + const context = React.useContext(ChartContext) + + if (!context) { + throw new Error("useChart must be used within a ") + } + + return context +} + +const ChartContainer = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { + config: ChartConfig + children: React.ComponentProps< + typeof RechartsPrimitive.ResponsiveContainer + >["children"] + } +>(({ id, className, children, config, ...props }, ref) => { + const uniqueId = React.useId() + const chartId = `chart-${id || uniqueId.replace(/:/g, "")}` + + return ( + +
+ + + {children} + +
+
+ ) +}) +ChartContainer.displayName = "Chart" + +const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { + const colorConfig = Object.entries(config).filter( + ([, config]) => config.theme || config.color + ) + + if (!colorConfig.length) { + return null + } + + return ( +