From 7a1c9b88edfbe7f152a875236f20aa0d60a2a5bd Mon Sep 17 00:00:00 2001 From: Tushar Verma Date: Tue, 3 Feb 2026 16:23:14 +0530 Subject: [PATCH 01/53] Add AssetsService and utility functions for asset calculations and insights Signed-off-by: Tushar Verma --- src/services/assets.js | 305 +++++++++++++++++++++++++++++++++ src/utils/assetCalculations.js | 247 ++++++++++++++++++++++++++ src/utils/assetInsights.js | 221 ++++++++++++++++++++++++ 3 files changed, 773 insertions(+) create mode 100644 src/services/assets.js create mode 100644 src/utils/assetCalculations.js create mode 100644 src/utils/assetInsights.js diff --git a/src/services/assets.js b/src/services/assets.js new file mode 100644 index 00000000..ad78c8d7 --- /dev/null +++ b/src/services/assets.js @@ -0,0 +1,305 @@ +/** + * AssetsService - Handles API calls to fetch asset data and provides mock data fallback + * + * Usage: + * const data = await AssetsService.fetchAssets("30d"); + * const mockData = AssetsService.getMockData(); + */ + +import client from "./api_client"; + +const API_BASE_URL = process.env.REACT_APP_API_BASE_URL || "http://localhost:9003"; + +const AssetsService = { + /** + * Fetch assets from OpenCost backend API + * @param {string} timeWindow - Time window (e.g., "7d", "30d", "120d") + * @param {object} options - Additional query parameters + * @returns {Promise} - Asset data with window information + */ + fetchAssets: async (timeWindow = "30d", options = {}) => { + try { + const params = new URLSearchParams({ + window: timeWindow, + aggregate: "type", + accumulate: true, + ...options, + }); + + const response = await client.get(`/model/assets?${params}`, { + headers: { + "Content-Type": "application/json", + }, + }); + + // Transform API response + return { + data: response.data || {}, + window: { + start: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(), + end: new Date().toISOString(), + days: 30, + }, + }; + } catch (error) { + console.error("Error fetching assets from API:", error); + throw new Error(`Could not connect to the OpenCost API: ${error.message}`); + } + }, + + /** + * Get mock data for development/testing + * Simulates real asset data structure + * @returns {object} - Mock asset data with window information + */ + getMockData: () => { + const now = new Date(); + const start = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); + + return { + data: { + "default-cluster": { + nodes: { + "control-plane": { + type: "Disk", + category: "Storage", + providerID: "control-plane", + storageClass: "__local__", + local: 1, + window: { + start: "2026-01-27T00:00:00Z", + end: "2026-02-03T00:00:00Z", + }, + minutes: 10080, + bytes: 536870912000, + byteHours: 91268055040000, + byteHoursUsed: 36507222016000, + byteUsageMax: 214748364800, + breakdown: { + idle: 0.55, + system: 0.45, + user: 0, + other: 0, + }, + totalCost: 12.75, + }, + "worker-node-1": { + type: "Disk", + category: "Storage", + providerID: "worker-node-1", + storageClass: "__local__", + local: 1, + window: { + start: "2026-01-27T00:00:00Z", + end: "2026-02-03T00:00:00Z", + }, + minutes: 10080, + bytes: 429496729600, + byteHours: 72155450572800, + byteHoursUsed: 32469952757760, + byteUsageMax: 171798691840, + breakdown: { + idle: 0.58, + system: 0.42, + user: 0, + other: 0, + }, + totalCost: 10.25, + }, + }, + pvc: { + "pvc-mysql-data": { + type: "Disk", + category: "Storage", + providerID: "pvc-mysql-data", + storageClass: "fast-ssd", + volumeName: "pvc-mysql-data", + claimName: "mysql-data", + claimNamespace: "database", + local: 0, + window: { + start: "2026-01-27T00:00:00Z", + end: "2026-02-03T00:00:00Z", + }, + minutes: 10080, + bytes: 107374182400, + byteHours: 10737418240000, + byteHoursUsed: 7516192768000, + byteUsageMax: 75161927680, + breakdown: { + idle: 0.3, + system: 0, + user: 0.7, + other: 0, + }, + totalCost: 24.55, + }, + "pvc-redis-cache": { + type: "Disk", + category: "Storage", + providerID: "pvc-redis-cache", + storageClass: "fast-ssd", + volumeName: "pvc-redis-cache", + claimName: "redis-data", + claimNamespace: "cache", + local: 0, + window: { + start: "2026-01-27T00:00:00Z", + end: "2026-02-03T00:00:00Z", + }, + minutes: 10080, + bytes: 53687091200, + byteHours: 5368709120000, + byteHoursUsed: 4295767296000, + byteUsageMax: 42957672960, + breakdown: { + idle: 0.2, + system: 0, + user: 0.8, + other: 0, + }, + totalCost: 15.50, + }, + "pvc-elasticsearch": { + type: "Disk", + category: "Storage", + providerID: "pvc-elasticsearch", + storageClass: "standard", + volumeName: "pvc-elasticsearch", + claimName: "elasticsearch-data", + claimNamespace: "logging", + local: 0, + window: { + start: "2026-01-27T00:00:00Z", + end: "2026-02-03T00:00:00Z", + }, + minutes: 10080, + bytes: 536870912000, + byteHours: 53687091200000, + byteHoursUsed: 26843545600000, + byteUsageMax: 268435456000, + breakdown: { + idle: 0.5, + system: 0, + user: 0.5, + other: 0, + }, + totalCost: 18.75, + }, + }, + }, + "prod-cluster": { + nodes: { + "prod-master": { + type: "Disk", + category: "Storage", + providerID: "prod-master", + storageClass: "__local__", + local: 1, + window: { + start: "2026-01-27T00:00:00Z", + end: "2026-02-03T00:00:00Z", + }, + minutes: 10080, + bytes: 644245094400, + byteHours: 109392867206400, + byteHoursUsed: 43757146882560, + byteUsageMax: 257698037760, + breakdown: { + idle: 0.6, + system: 0.4, + user: 0, + other: 0, + }, + totalCost: 28.50, + }, + "prod-worker-1": { + type: "Disk", + category: "Storage", + providerID: "prod-worker-1", + storageClass: "__local__", + local: 1, + window: { + start: "2026-01-27T00:00:00Z", + end: "2026-02-03T00:00:00Z", + }, + minutes: 10080, + bytes: 537346129920, + byteHours: 91397042503680, + byteHoursUsed: 36558817001472, + byteUsageMax: 215198607360, + breakdown: { + idle: 0.6, + system: 0.4, + user: 0, + other: 0, + }, + totalCost: 26.75, + }, + }, + pvc: { + "pvc-postgres-prod": { + type: "Disk", + category: "Storage", + providerID: "pvc-postgres-prod", + storageClass: "fast-ssd", + volumeName: "pvc-postgres-prod", + claimName: "postgres-data", + claimNamespace: "database", + local: 0, + window: { + start: "2026-01-27T00:00:00Z", + end: "2026-02-03T00:00:00Z", + }, + minutes: 10080, + bytes: 214748364800, + byteHours: 21474836480000, + byteHoursUsed: 16106127360000, + byteUsageMax: 161061273600, + breakdown: { + idle: 0.25, + system: 0, + user: 0.75, + other: 0, + }, + totalCost: 45.80, + }, + "pvc-backup-storage": { + type: "Disk", + category: "Storage", + providerID: "pvc-backup-storage", + storageClass: "standard", + volumeName: "pvc-backup-storage", + claimName: "backup-storage", + claimNamespace: "backup", + local: 0, + window: { + start: "2026-01-27T00:00:00Z", + end: "2026-02-03T00:00:00Z", + }, + minutes: 10080, + bytes: 1099511627776, + byteHours: 109951162777600, + byteHoursUsed: 54975581388800, + byteUsageMax: 549755813888, + breakdown: { + idle: 0.5, + system: 0, + user: 0.5, + other: 0, + }, + totalCost: 32.25, + }, + }, + }, + }, + window: { + start: start.toISOString(), + end: now.toISOString(), + days: 7, + }, + }; + }, +}; + +export default AssetsService; diff --git a/src/utils/assetCalculations.js b/src/utils/assetCalculations.js new file mode 100644 index 00000000..638cbe50 --- /dev/null +++ b/src/utils/assetCalculations.js @@ -0,0 +1,247 @@ +/** + * Asset Calculations - Utility functions for asset data transformations + * + * Provides functions for: + * - Converting bytes to human-readable formats + * - Calculating costs and waste + * - Computing efficiency scores + * - Determining asset status + */ + +/** + * Convert bytes to GB with fixed decimal places + * @param {number} bytes - Number of bytes + * @param {number} decimals - Number of decimal places (default: 2) + * @returns {number} - Size in GB + */ +export const bytesToGB = (bytes, decimals = 2) => { + if (!bytes || bytes === 0) return 0; + return parseFloat((bytes / Math.pow(1024, 3)).toFixed(decimals)); +}; + +/** + * Get idle percentage from asset breakdown + * @param {object} asset - Asset object with breakdown data + * @returns {number} - Idle percentage (0-100) + */ +export const getIdlePercentage = (asset) => { + if (!asset || !asset.breakdown) return 0; + return parseFloat(((asset.breakdown.idle || 0) * 100).toFixed(1)); +}; + +/** + * Get used percentage from asset breakdown + * @param {object} asset - Asset object + * @returns {number} - Used percentage (0-100) + */ +export const getUsedPercentage = (asset) => { + if (!asset) return 0; + const idle = getIdlePercentage(asset) / 100; + return parseFloat(((1 - idle) * 100).toFixed(1)); +}; + +/** + * Calculate total cost from array of assets + * @param {array} assets - Array of asset objects + * @returns {number} - Total cost in dollars + */ +export const getTotalCost = (assets) => { + if (!assets || assets.length === 0) return 0; + return assets.reduce((sum, asset) => sum + (asset.totalCost || 0), 0); +}; + +/** + * Calculate wasted cost (idle storage cost) + * Assumes $0.1 per GB-month for idle storage + * @param {object} asset - Asset object + * @param {number} costPerGBMonth - Cost per GB per month (default: 0.1) + * @returns {number} - Wasted cost in dollars + */ +export const getWastedCostForAsset = (asset, costPerGBMonth = 0.1) => { + if (!asset) return 0; + const idlePercent = (asset.breakdown?.idle || 0); + const idleBytes = (asset.bytes || 0) * idlePercent; + const idleGB = bytesToGB(idleBytes); + return parseFloat((idleGB * costPerGBMonth).toFixed(2)); +}; + +/** + * Calculate total wasted cost for all assets + * @param {array} assets - Array of asset objects + * @returns {number} - Total wasted cost + */ +export const getTotalWastedCost = (assets) => { + if (!assets || assets.length === 0) return 0; + return assets.reduce((sum, asset) => sum + getWastedCostForAsset(asset), 0); +}; + +/** + * Calculate total provisioned storage in GB + * @param {array} assets - Array of asset objects + * @returns {number} - Total provisioned storage in GB + */ +export const getTotalProvisioned = (assets) => { + if (!assets || assets.length === 0) return 0; + const totalBytes = assets.reduce((sum, asset) => sum + (asset.bytes || 0), 0); + return bytesToGB(totalBytes); +}; + +/** + * Calculate average idle percentage across assets + * @param {array} assets - Array of asset objects + * @returns {number} - Average idle percentage (0-100) + */ +export const getAverageIdle = (assets) => { + if (!assets || assets.length === 0) return 0; + const totalIdle = assets.reduce((sum, asset) => { + return sum + (asset.breakdown?.idle || 0); + }, 0); + return parseFloat(((totalIdle / assets.length) * 100).toFixed(1)); +}; + +/** + * Calculate efficiency score (0-100) + * 100 = all storage is used, 0 = all storage is idle + * @param {array} assets - Array of asset objects + * @returns {number} - Efficiency score (0-100) + */ +export const calculateEfficiencyScore = (assets) => { + if (!assets || assets.length === 0) return 100; + const avgIdle = getAverageIdle(assets) / 100; + const score = Math.round((1 - avgIdle) * 100); + return Math.max(0, Math.min(100, score)); // Clamp between 0-100 +}; + +/** + * Get asset status based on idle percentage + * Status determines color coding and warnings + * @param {object} asset - Asset object + * @returns {object} - Status object with label and type + */ +export const getAssetStatus = (asset) => { + if (!asset) return { label: "UNKNOWN", type: "gray" }; + + const idlePercent = getIdlePercentage(asset); + + if (idlePercent >= 80) { + return { label: "WASTE", type: "red", severity: "high" }; + } + if (idlePercent >= 40) { + return { label: "REVIEW", type: "orange", severity: "medium" }; + } + return { label: "OK", type: "green", severity: "low" }; +}; + +/** + * Categorize assets into node disks and PVCs + * @param {array} assets - Array of asset objects + * @returns {object} - Object with nodeDisks and pvcs arrays + */ +export const categorizeAssets = (assets) => { + const nodeDisks = assets.filter(asset => asset.local === 1); + const pvcs = assets.filter(asset => asset.local !== 1); + return { nodeDisks, pvcs }; +}; + +/** + * Calculate detailed usage breakdown for an asset + * @param {object} asset - Asset object + * @returns {object} - Usage breakdown with all calculations + */ +export const calculateUsage = (asset) => { + if (!asset) { + return { + used: 0, + idle: 0, + usedGB: 0, + idleGB: 0, + totalGB: 0, + idlePercentage: 0, + usedPercentage: 0, + }; + } + + const totalBytes = asset.bytes || 0; + const breakdown = asset.breakdown || {}; + const idlePercentage = breakdown.idle || 0; + const idleBytes = totalBytes * idlePercentage; + const usedBytes = totalBytes * (1 - idlePercentage); + + return { + used: usedBytes, + idle: idleBytes, + usedGB: bytesToGB(usedBytes), + idleGB: bytesToGB(idleBytes), + totalGB: bytesToGB(totalBytes), + idlePercentage: (idlePercentage * 100).toFixed(1), + usedPercentage: ((1 - idlePercentage) * 100).toFixed(1), + }; +}; + +/** + * Format currency for display + * @param {number} amount - Amount in dollars + * @param {boolean} showCents - Include cents (default: true) + * @returns {string} - Formatted currency string + */ +export const formatCurrency = (amount, showCents = true) => { + if (amount === undefined || amount === null) return "$0.00"; + + const formatted = parseFloat(amount).toLocaleString("en-US", { + style: "currency", + currency: "USD", + minimumFractionDigits: showCents ? 2 : 0, + maximumFractionDigits: showCents ? 2 : 0, + }); + + return formatted; +}; + +/** + * Get trend indicator for cost changes + * @param {number} current - Current value + * @param {number} previous - Previous value + * @returns {object} - Trend object with direction and percentage + */ +export const getTrendIndicator = (current, previous) => { + if (previous === 0) return { direction: "→", percentage: 0, change: 0 }; + + const change = current - previous; + const percentage = parseFloat(((change / previous) * 100).toFixed(1)); + const direction = change > 0 ? "↑" : change < 0 ? "↓" : "→"; + + return { direction, percentage, change }; +}; + +/** + * Sort assets by specified column + * @param {array} assets - Array of assets + * @param {string} column - Column to sort by + * @param {string} direction - Sort direction ("ASC" or "DESC") + * @returns {array} - Sorted assets + */ +export const sortAssets = (assets, column, direction = "ASC") => { + const sorted = [...assets]; + + sorted.sort((a, b) => { + let aVal = a[column]; + let bVal = b[column]; + + // Handle numeric comparisons + if (typeof aVal === "number" && typeof bVal === "number") { + return direction === "ASC" ? aVal - bVal : bVal - aVal; + } + + // Handle string comparisons + const aStr = String(aVal || "").toLowerCase(); + const bStr = String(bVal || "").toLowerCase(); + + if (direction === "ASC") { + return aStr.localeCompare(bStr); + } else { + return bStr.localeCompare(aStr); + } + }); + + return sorted; +}; diff --git a/src/utils/assetInsights.js b/src/utils/assetInsights.js new file mode 100644 index 00000000..79f3cdd9 --- /dev/null +++ b/src/utils/assetInsights.js @@ -0,0 +1,221 @@ +/** + * Asset Insights - Generate actionable recommendations + * + * Analyzes asset data and provides ranked insights to help users + * identify cost-saving opportunities + */ + +import { + getWastedCostForAsset, + getTotalWastedCost, + getIdlePercentage, + calculateUsage, +} from "./assetCalculations"; + +/** + * Generate ranked insights from asset data + * Insights are sorted by potential monthly savings + * + * @param {array} assets - Array of asset objects + * @returns {array} - Array of insight objects, sorted by savings + */ +export const generateInsights = (assets) => { + if (!assets || assets.length === 0) return []; + + const insights = []; + + // Insight 1: Unused PVCs (100% idle) + const unusedPvcs = assets.filter( + (asset) => asset.local !== 1 && (asset.breakdown?.idle || 0) === 1 + ); + + if (unusedPvcs.length > 0) { + const savings = unusedPvcs.reduce((sum, asset) => sum + getWastedCostForAsset(asset), 0); + insights.push({ + id: "unused-pvcs", + type: "warning", + severity: "high", + title: `Delete ${unusedPvcs.length} Unused PVC${unusedPvcs.length > 1 ? "s" : ""}`, + subtitle: "These persistent volume claims are 100% idle and have no usage", + savings, + confidence: 95, + action: "Delete", + affectedAssets: unusedPvcs.length, + description: `Deleting these ${unusedPvcs.length} unused PVC${unusedPvcs.length > 1 ? "s" : ""} would save approximately $${savings.toFixed(2)} per month.`, + }); + } + + // Insight 2: High idle node disks (>50% idle) + const highIdleNodes = assets.filter( + (asset) => asset.local === 1 && (asset.breakdown?.idle || 0) > 0.5 + ); + + if (highIdleNodes.length > 0) { + const savings = highIdleNodes.reduce((sum, asset) => sum + getWastedCostForAsset(asset), 0); + insights.push({ + id: "high-idle-nodes", + type: "info", + severity: "medium", + title: `Review ${highIdleNodes.length} Oversized Node Disk${highIdleNodes.length > 1 ? "s" : ""}`, + subtitle: `Over 50% idle capacity could be resized`, + savings, + confidence: 70, + action: "Review", + affectedAssets: highIdleNodes.length, + description: `Resizing these disks to match actual usage could save approximately $${savings.toFixed(2)} per month.`, + }); + } + + // Insight 3: Fast-SSD disks with low usage + const lowUsageFastSsd = assets.filter((asset) => { + if (asset.storageClass !== "fast-ssd") return false; + const idle = asset.breakdown?.idle || 0; + return idle > 0.6; // More than 60% idle + }); + + if (lowUsageFastSsd.length > 0) { + const savings = lowUsageFastSsd.reduce((sum, asset) => sum + getWastedCostForAsset(asset), 0); + insights.push({ + id: "fast-ssd-downgrade", + type: "info", + severity: "low", + title: `Downgrade ${lowUsageFastSsd.length} Fast-SSD to Standard Storage`, + subtitle: "These fast disks have low usage and could use cheaper storage", + savings, + confidence: 60, + action: "Downgrade", + affectedAssets: lowUsageFastSsd.length, + description: `Downgrading from fast-SSD to standard storage could save approximately $${savings.toFixed(2)} per month while maintaining performance.`, + }); + } + + // Insight 4: Large disks with minimal usage + const oversizedDisks = assets.filter((asset) => { + const usage = calculateUsage(asset); + const totalGB = usage.totalGB; + const usedGB = usage.usedGB; + // Disk larger than 100GB with less than 20% usage + return totalGB > 100 && usedGB < totalGB * 0.2; + }); + + if (oversizedDisks.length > 0) { + const savings = oversizedDisks.reduce((sum, asset) => sum + getWastedCostForAsset(asset), 0); + insights.push({ + id: "oversized-disks", + type: "warning", + severity: "medium", + title: `Resize ${oversizedDisks.length} Oversized Disk${oversizedDisks.length > 1 ? "s" : ""}`, + subtitle: "Significant wasted allocated space", + savings, + confidence: 75, + action: "Resize", + affectedAssets: oversizedDisks.length, + description: `These disks are significantly oversized. Resizing to match actual usage patterns could save approximately $${savings.toFixed(2)} per month.`, + }); + } + + // Sort by potential savings (highest first) + insights.sort((a, b) => b.savings - a.savings); + + return insights; +}; + +/** + * Get insight category color + * @param {string} type - Insight type ("warning", "info", "success") + * @returns {string} - Color code + */ +export const getInsightColor = (type) => { + const colors = { + warning: "#da1e28", // Red + info: "#0043ce", // Blue + success: "#24a148", // Green + }; + return colors[type] || "#525252"; // Gray default +}; + +/** + * Format savings amount for display + * @param {number} savings - Monthly savings in dollars + * @returns {string} - Formatted savings string + */ +export const formatSavings = (savings) => { + if (savings > 1000) { + return `$${(savings / 1000).toFixed(1)}k`; + } + return `$${savings.toFixed(2)}`; +}; + +/** + * Get insights summary statistics + * @param {array} insights - Array of insights + * @returns {object} - Summary statistics + */ +export const getInsightsSummary = (insights) => { + if (!insights || insights.length === 0) { + return { + totalSavings: 0, + highSeverity: 0, + mediumSeverity: 0, + lowSeverity: 0, + }; + } + + const totalSavings = insights.reduce((sum, insight) => sum + (insight.savings || 0), 0); + const highSeverity = insights.filter((i) => i.severity === "high").length; + const mediumSeverity = insights.filter((i) => i.severity === "medium").length; + const lowSeverity = insights.filter((i) => i.severity === "low").length; + + return { + totalSavings, + highSeverity, + mediumSeverity, + lowSeverity, + }; +}; + +/** + * Check if an asset has critical issues + * @param {object} asset - Asset object + * @returns {boolean} - True if asset needs attention + */ +export const hasAssetIssue = (asset) => { + if (!asset) return false; + + // Check if 100% idle + if (asset.breakdown?.idle === 1) return true; + + // Check if >80% idle + if ((asset.breakdown?.idle || 0) > 0.8) return true; + + // Check if oversized + const usage = calculateUsage(asset); + if (usage.totalGB > 100 && usage.usedGB < usage.totalGB * 0.2) return true; + + return false; +}; + +/** + * Get issue recommendation for an asset + * @param {object} asset - Asset object + * @returns {string} - Recommendation text + */ +export const getAssetRecommendation = (asset) => { + if (!asset) return "No recommendation"; + + const idle = asset.breakdown?.idle || 0; + + if (idle === 1) { + return "Delete this unused asset"; + } + + if (idle > 0.8) { + return `Resize or delete (${(idle * 100).toFixed(0)}% idle)`; + } + + if (idle > 0.5) { + return `Review for rightsizing (${(idle * 100).toFixed(0)}% idle)`; + } + + return "No action needed"; +}; From 0b1d5aa14aebd59e705ca490ee43ad3281c714bf Mon Sep 17 00:00:00 2001 From: Tushar Verma Date: Tue, 3 Feb 2026 16:57:26 +0530 Subject: [PATCH 02/53] Add KPICards component to display key performance indicators for asset costs Signed-off-by: Tushar Verma --- src/components/assets/KPICards.js | 124 ++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 src/components/assets/KPICards.js diff --git a/src/components/assets/KPICards.js b/src/components/assets/KPICards.js new file mode 100644 index 00000000..211d4290 --- /dev/null +++ b/src/components/assets/KPICards.js @@ -0,0 +1,124 @@ +/** + * KPICards - Display key performance indicators for asset costs + * + * Shows: + * - Total Storage Cost + * - Wasted Cost (idle storage) + * - Efficiency Score + * - Asset Count + */ + +import React from "react"; +import PropTypes from "prop-types"; +import { + getTotalCost, + getTotalWastedCost, + calculateEfficiencyScore, + formatCurrency, + getTotalProvisioned, +} from "./../../utils/assetCalculations"; + +const KPICards = ({ assets }) => { + // Calculate KPI values + const totalCost = getTotalCost(assets); + const wastedCost = getTotalWastedCost(assets); + const efficiencyScore = calculateEfficiencyScore(assets); + const totalProvisioned = getTotalProvisioned(assets); + const assetCount = assets.length; + + // Get efficiency status color + const getEfficiencyColor = (score) => { + if (score >= 80) return "#24a148"; // Green + if (score >= 50) return "#ff832b"; // Orange + return "#da1e28"; // Red + }; + + return ( +
+ {/* Total Cost Card */} +
+
💰
+
Total Storage Cost
+
{formatCurrency(totalCost)}
+
Monthly (30 days)
+
+ + {/* Wasted Cost Card */} +
+
⚠️
+
Wasted Cost
+
+ {formatCurrency(wastedCost)} +
+
From idle storage
+
+ + {/* Efficiency Score Card */} +
+
📊
+
Efficiency Score
+
+ {efficiencyScore}% +
+
+ {efficiencyScore >= 80 + ? "Excellent" + : efficiencyScore >= 50 + ? "Good" + : "Needs Review"} +
+
+ + {/* Total Provisioned Card */} +
+
📦
+
Total Provisioned
+
{totalProvisioned.toFixed(1)} GB
+
Allocated storage
+
+ + {/* Asset Count Card */} +
+
🗂️
+
Total Assets
+
{assetCount}
+
Nodes & PVCs
+
+ + {/* Savings Potential Card */} +
+
💵
+
Potential Savings
+
+ {formatCurrency(wastedCost)} +
+
If optimized
+
+
+ ); +}; + +KPICards.propTypes = { + assets: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.string.isRequired, + totalCost: PropTypes.number, + bytes: PropTypes.number, + breakdown: PropTypes.shape({ + idle: PropTypes.number, + system: PropTypes.number, + user: PropTypes.number, + other: PropTypes.number, + }), + }) + ).isRequired, +}; + +KPICards.defaultProps = { + assets: [], +}; + +export default KPICards; From a98daa1d3b565e3ca6ff3e5912b94fd2669b7e41 Mon Sep 17 00:00:00 2001 From: Tushar Verma Date: Tue, 3 Feb 2026 16:57:42 +0530 Subject: [PATCH 03/53] Add CostBreakdownChart component to visualize cost distribution by cluster, asset type, and storage class Signed-off-by: Tushar Verma --- src/components/assets/CostBreakdownChart.js | 171 ++++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 src/components/assets/CostBreakdownChart.js diff --git a/src/components/assets/CostBreakdownChart.js b/src/components/assets/CostBreakdownChart.js new file mode 100644 index 00000000..2d3aa62f --- /dev/null +++ b/src/components/assets/CostBreakdownChart.js @@ -0,0 +1,171 @@ +/** + * CostBreakdownChart - Display cost distribution across dimensions + * + * Shows: + * - Cost by Cluster + * - Cost by Asset Type (Nodes vs PVCs) + * - Cost by Storage Class + */ + +import React, { useMemo } from "react"; +import PropTypes from "prop-types"; + +/** + * Calculate cost aggregation + * @param {array} assets - Asset array + * @param {string} groupBy - Field to group by (cluster, assetType, storageClass) + * @returns {object} - Aggregated costs by group + */ +const aggregateCosts = (assets, groupBy) => { + const aggregated = {}; + + assets.forEach((asset) => { + const key = asset[groupBy] || "Unknown"; + if (!aggregated[key]) { + aggregated[key] = 0; + } + aggregated[key] += asset.totalCost || 0; + }); + + return aggregated; +}; + +/** + * Create chart data from aggregated costs + * @param {object} aggregated - Aggregated costs + * @returns {array} - Array of {name, value} objects sorted by value descending + */ +const createChartData = (aggregated) => { + return Object.entries(aggregated) + .map(([name, value]) => ({ name, value })) + .sort((a, b) => b.value - a.value); +}; + +const CostBreakdownChart = ({ assets }) => { + // Prepare chart data + const clusterCosts = useMemo(() => { + const aggregated = aggregateCosts(assets, "cluster"); + return createChartData(aggregated); + }, [assets]); + + const assetTypeCosts = useMemo(() => { + const aggregated = aggregateCosts(assets, "assetType"); + return createChartData(aggregated); + }, [assets]); + + const storageClassCosts = useMemo(() => { + const aggregated = aggregateCosts(assets, "storageClass"); + return createChartData(aggregated); + }, [assets]); + + // Get max value for scaling + const allValues = [ + ...clusterCosts.map((c) => c.value), + ...assetTypeCosts.map((c) => c.value), + ...storageClassCosts.map((c) => c.value), + ]; + const maxValue = Math.max(...allValues, 1); + + return ( +
+
+

Cost by Cluster

+
+ {clusterCosts.length > 0 ? ( +
+ {clusterCosts.map((item) => ( +
+
{item.name}
+
+
+
+
${item.value.toFixed(2)}
+
+ ))} +
+ ) : ( +

No cluster data

+ )} +
+
+ +
+

Cost by Asset Type

+
+ {assetTypeCosts.length > 0 ? ( +
+ {assetTypeCosts.map((item) => ( +
+
{item.name}
+
+
+
+
${item.value.toFixed(2)}
+
+ ))} +
+ ) : ( +

No asset type data

+ )} +
+
+ +
+

Cost by Storage Class

+
+ {storageClassCosts.length > 0 ? ( +
+ {storageClassCosts.map((item) => ( +
+
{item.name || "Unspecified"}
+
+
+
+
${item.value.toFixed(2)}
+
+ ))} +
+ ) : ( +

No storage class data

+ )} +
+
+
+ ); +}; + +CostBreakdownChart.propTypes = { + assets: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.string.isRequired, + cluster: PropTypes.string, + assetType: PropTypes.string, + storageClass: PropTypes.string, + totalCost: PropTypes.number, + }) + ).isRequired, +}; + +CostBreakdownChart.defaultProps = { + assets: [], +}; + +export default CostBreakdownChart; From 08175a1c81a786dd1a633c56af5532a8a34f0f98 Mon Sep 17 00:00:00 2001 From: Tushar Verma Date: Tue, 3 Feb 2026 16:57:54 +0530 Subject: [PATCH 04/53] Add FilterPanel component for multi-filtering assets by status, type, storage class, cluster, and search term Signed-off-by: Tushar Verma --- src/components/assets/FilterPanel.js | 225 +++++++++++++++++++++++++++ 1 file changed, 225 insertions(+) create mode 100644 src/components/assets/FilterPanel.js diff --git a/src/components/assets/FilterPanel.js b/src/components/assets/FilterPanel.js new file mode 100644 index 00000000..864a0ca7 --- /dev/null +++ b/src/components/assets/FilterPanel.js @@ -0,0 +1,225 @@ +/** + * FilterPanel - Multi-filter system for assets + * + * Allows filtering by: + * - Status (OK, Review, Waste) + * - Asset Type (Nodes, PVCs) + * - Storage Class + * - Cluster + * - Search term + */ + +import React, { useState } from "react"; +import PropTypes from "prop-types"; + +const FilterPanel = ({ + filters, + onFiltersChange, + filterOptions, + useMockData, + onMockDataToggle, +}) => { + const [expanded, setExpanded] = useState(false); + + const handleStatusChange = (status) => { + const updated = filters.status.includes(status) + ? filters.status.filter((s) => s !== status) + : [...filters.status, status]; + onFiltersChange({ ...filters, status: updated }); + }; + + const handleAssetTypeChange = (type) => { + const updated = filters.assetType.includes(type) + ? filters.assetType.filter((t) => t !== type) + : [...filters.assetType, type]; + onFiltersChange({ ...filters, assetType: updated }); + }; + + const handleStorageClassChange = (sc) => { + const updated = filters.storageClass.includes(sc) + ? filters.storageClass.filter((s) => s !== sc) + : [...filters.storageClass, sc]; + onFiltersChange({ ...filters, storageClass: updated }); + }; + + const handleClusterChange = (cluster) => { + const updated = filters.cluster.includes(cluster) + ? filters.cluster.filter((c) => c !== cluster) + : [...filters.cluster, cluster]; + onFiltersChange({ ...filters, cluster: updated }); + }; + + const handleSearchChange = (e) => { + onFiltersChange({ ...filters, search: e.target.value }); + }; + + const handleClearFilters = () => { + onFiltersChange({ + status: [], + assetType: [], + storageClass: [], + cluster: [], + search: "", + }); + }; + + const activeFilterCount = + filters.status.length + + filters.assetType.length + + filters.storageClass.length + + filters.cluster.length + + (filters.search ? 1 : 0); + + return ( +
+
+

Filters

+ +
+ + {/* Search Bar */} +
+ +
+ + {/* Expanded Filters */} + {expanded && ( +
+ {/* Status Filter */} +
+ +
+ {["ok", "review", "waste"].map((status) => ( + + ))} +
+
+ + {/* Asset Type Filter */} + {filterOptions.assetTypes && filterOptions.assetTypes.length > 0 && ( +
+ +
+ {filterOptions.assetTypes.map((type) => ( + + ))} +
+
+ )} + + {/* Storage Class Filter */} + {filterOptions.storageClasses && filterOptions.storageClasses.length > 0 && ( +
+ +
+ {filterOptions.storageClasses.map((sc) => ( + + ))} +
+
+ )} + + {/* Cluster Filter */} + {filterOptions.clusters && filterOptions.clusters.length > 0 && ( +
+ +
+ {filterOptions.clusters.map((cluster) => ( + + ))} +
+
+ )} + + {/* Mock Data Toggle */} +
+ +
+ + {/* Clear Filters Button */} + {activeFilterCount > 0 && ( + + )} +
+ )} +
+ ); +}; + +FilterPanel.propTypes = { + filters: PropTypes.shape({ + status: PropTypes.array, + assetType: PropTypes.array, + storageClass: PropTypes.array, + cluster: PropTypes.array, + search: PropTypes.string, + }).isRequired, + onFiltersChange: PropTypes.func.isRequired, + filterOptions: PropTypes.shape({ + clusters: PropTypes.array, + storageClasses: PropTypes.array, + assetTypes: PropTypes.array, + }).isRequired, + useMockData: PropTypes.bool, + onMockDataToggle: PropTypes.func, +}; + +FilterPanel.defaultProps = { + useMockData: false, + onMockDataToggle: () => {}, +}; + +export default FilterPanel; From 6ee0f84b5bc9879575ed9d77080b510f68ee31af Mon Sep 17 00:00:00 2001 From: Tushar Verma Date: Tue, 3 Feb 2026 16:58:02 +0530 Subject: [PATCH 05/53] Add InsightsPanel component to display actionable recommendations for cost optimization Signed-off-by: Tushar Verma --- src/components/assets/InsightsPanel.js | 93 ++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 src/components/assets/InsightsPanel.js diff --git a/src/components/assets/InsightsPanel.js b/src/components/assets/InsightsPanel.js new file mode 100644 index 00000000..32de1812 --- /dev/null +++ b/src/components/assets/InsightsPanel.js @@ -0,0 +1,93 @@ +/** + * InsightsPanel - Display actionable recommendations + * + * Shows: + * - Ranked insights by potential savings + * - Action items for cost optimization + * - Confidence levels for each recommendation + */ + +import React, { useMemo } from "react"; +import PropTypes from "prop-types"; +import { generateInsights } from "./../../utils/assetInsights"; +import { formatCurrency } from "./../../utils/assetCalculations"; + +const InsightsPanel = ({ assets }) => { + // Generate insights + const insights = useMemo(() => { + return generateInsights(assets); + }, [assets]); + + if (insights.length === 0) { + return ( +
+

Actionable Insights

+
+

✓ All assets are optimized. No recommendations at this time.

+
+
+ ); + } + + return ( +
+

Actionable Insights

+

+ Ranked by potential monthly savings +

+ +
+ {insights.map((insight, index) => ( +
+
+
#{index + 1}
+
{insight.title}
+
+ Save {formatCurrency(insight.savings)}/month +
+
+ +
+

{insight.subtitle}

+

{insight.description}

+
+ +
+
+ + {insight.affectedAssets} asset + {insight.affectedAssets > 1 ? "s" : ""} affected + + + {insight.confidence}% confidence + +
+ +
+
+ ))} +
+
+ ); +}; + +InsightsPanel.propTypes = { + assets: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.string.isRequired, + totalCost: PropTypes.number, + bytes: PropTypes.number, + breakdown: PropTypes.shape({ + idle: PropTypes.number, + }), + storageClass: PropTypes.string, + local: PropTypes.number, + }) + ).isRequired, +}; + +InsightsPanel.defaultProps = { + assets: [], +}; + +export default InsightsPanel; From 10de95c8f1d836a98e23210e10a79e95908bd2aa Mon Sep 17 00:00:00 2001 From: Tushar Verma Date: Tue, 3 Feb 2026 16:58:24 +0530 Subject: [PATCH 06/53] Add AssetTable component for searchable, sortable, and paginated asset display Signed-off-by: Tushar Verma --- src/components/assets/AssetTable.js | 243 ++++++++++++++++++++++++++++ 1 file changed, 243 insertions(+) create mode 100644 src/components/assets/AssetTable.js diff --git a/src/components/assets/AssetTable.js b/src/components/assets/AssetTable.js new file mode 100644 index 00000000..2e2d16d4 --- /dev/null +++ b/src/components/assets/AssetTable.js @@ -0,0 +1,243 @@ +/** + * AssetTable - Searchable, sortable, paginated table of assets + * + * Features: + * - Sortable columns + * - Pagination + * - Status indicators + * - Trend indicators + * - Cost information + */ + +import React, { useState, useMemo } from "react"; +import PropTypes from "prop-types"; +import { + calculateUsage, + getAssetStatus, + formatCurrency, +} from "./../../utils/assetCalculations"; + +const ITEMS_PER_PAGE = 10; + +const AssetTable = ({ assets, totalAssets, filteredAssets }) => { + const [sortColumn, setSortColumn] = useState("totalCost"); + const [sortDirection, setSortDirection] = useState("DESC"); + const [currentPage, setCurrentPage] = useState(1); + + /** + * Sort assets by column + */ + const sortedAssets = useMemo(() => { + const sorted = [...assets]; + + sorted.sort((a, b) => { + let aVal = a[sortColumn]; + let bVal = b[sortColumn]; + + // Handle numeric columns + if (typeof aVal === "number" && typeof bVal === "number") { + return sortDirection === "DESC" ? bVal - aVal : aVal - bVal; + } + + // Handle string columns + const aStr = String(aVal || "").toLowerCase(); + const bStr = String(bVal || "").toLowerCase(); + + if (sortDirection === "DESC") { + return bStr.localeCompare(aStr); + } else { + return aStr.localeCompare(bStr); + } + }); + + return sorted; + }, [assets, sortColumn, sortDirection]); + + /** + * Paginate sorted assets + */ + const paginatedAssets = useMemo(() => { + const startIndex = (currentPage - 1) * ITEMS_PER_PAGE; + return sortedAssets.slice(startIndex, startIndex + ITEMS_PER_PAGE); + }, [sortedAssets, currentPage]); + + /** + * Handle column sort + */ + const handleSort = (column) => { + if (sortColumn === column) { + // Toggle direction if same column + setSortDirection(sortDirection === "ASC" ? "DESC" : "ASC"); + } else { + // New column, default to DESC + setSortColumn(column); + setSortDirection("DESC"); + } + // Reset to first page + setCurrentPage(1); + }; + + /** + * Get trend indicator for cost + */ + const getTrendIndicator = (asset) => { + const idle = (asset.breakdown?.idle || 0) * 100; + if (idle > 70) return "↑ High Idle"; + if (idle > 40) return "→ Medium Idle"; + return "↓ Low Idle"; + }; + + const totalPages = Math.ceil(sortedAssets.length / ITEMS_PER_PAGE); + + return ( +
+
+

Asset Details

+
+ Showing {paginatedAssets.length} of {filteredAssets} assets + {totalAssets > filteredAssets && ` (filtered from ${totalAssets})`} +
+
+ + {paginatedAssets.length === 0 ? ( +
+

No assets to display

+
+ ) : ( + <> +
+ + + + + + + + + + + + + + {paginatedAssets.map((asset) => { + const usage = calculateUsage(asset); + const status = getAssetStatus(asset); + + return ( + + + + + + + + + + ); + })} + +
handleSort("name")} className="sortable"> + Name {sortColumn === "name" && (sortDirection === "ASC" ? "↑" : "↓")} + handleSort("cluster")} className="sortable"> + Cluster {sortColumn === "cluster" && (sortDirection === "ASC" ? "↑" : "↓")} + handleSort("assetType")} className="sortable"> + Type {sortColumn === "assetType" && (sortDirection === "ASC" ? "↑" : "↓")} + Storage Class handleSort("totalCost")} className="sortable"> + Cost {sortColumn === "totalCost" && (sortDirection === "ASC" ? "↑" : "↓")} + UsageStatus
+ {asset.name} + {asset.claimNamespace && ( +
ns: {asset.claimNamespace}
+ )} +
{asset.cluster} + + {asset.assetType} + + {asset.storageClass || "-"} +
+ {formatCurrency(asset.totalCost || 0)} +
+
+ {getTrendIndicator(asset)} +
+
+
+
+
+
+ {usage.usedPercentage}% used +
+
+ + {status.label} + +
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ + +
+ Page {currentPage} of {totalPages} +
+ + +
+ )} + + )} +
+ ); +}; + +AssetTable.propTypes = { + assets: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + cluster: PropTypes.string.isRequired, + assetType: PropTypes.string.isRequired, + storageClass: PropTypes.string, + claimNamespace: PropTypes.string, + totalCost: PropTypes.number, + bytes: PropTypes.number, + breakdown: PropTypes.shape({ + idle: PropTypes.number, + system: PropTypes.number, + user: PropTypes.number, + }), + }) + ).isRequired, + totalAssets: PropTypes.number, + filteredAssets: PropTypes.number, +}; + +AssetTable.defaultProps = { + assets: [], + totalAssets: 0, + filteredAssets: 0, +}; + +export default AssetTable; From e42f42221ab82ca5e3b6147d585ac7ff20448fe6 Mon Sep 17 00:00:00 2001 From: Tushar Verma Date: Tue, 3 Feb 2026 16:58:55 +0530 Subject: [PATCH 07/53] Add AssetsDashboard Page Signed-off-by: Tushar Verma --- src/pages/Assets.js | 354 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 354 insertions(+) create mode 100644 src/pages/Assets.js diff --git a/src/pages/Assets.js b/src/pages/Assets.js new file mode 100644 index 00000000..1244c73e --- /dev/null +++ b/src/pages/Assets.js @@ -0,0 +1,354 @@ +/** + * Assets Page - Main page component for infrastructure asset cost management + * + * Features: + * - Display KPI cards with total cost, waste, and efficiency + * - Cost breakdown charts by cluster, type, and storage class + * - Searchable and filterable asset table + * - Mock data support for development + * - Error handling and loading states + */ + +import React, { useState, useEffect, useMemo } from "react"; +import { Loading, InlineNotification } from "@carbon/react"; +import Page from "../components/Page"; +import Header from "../components/Header"; +import Footer from "../components/Footer"; +import AssetsService from "../services/assets"; +import KPICards from "../components/assets/KPICards"; +import CostBreakdownChart from "../components/assets/CostBreakdownChart"; +import AssetTable from "../components/assets/AssetTable"; +import FilterPanel from "../components/assets/FilterPanel"; +import InsightsPanel from "../components/assets/InsightsPanel"; +import "./../styles/assets/dashboard.css"; + +/** + * Transform raw API data into flattened asset array + * @param {object} rawData - Raw API response data + * @returns {array} - Flattened array of assets + */ +const transformAssetData = (rawData) => { + const assets = []; + + if (!rawData || typeof rawData !== "object") { + return assets; + } + + // Iterate through clusters + Object.entries(rawData).forEach(([clusterKey, clusterData]) => { + if (!clusterData || typeof clusterData !== "object") { + return; + } + + // Process nodes + if (clusterData.nodes && typeof clusterData.nodes === "object") { + Object.entries(clusterData.nodes).forEach(([nodeKey, nodeData]) => { + assets.push({ + id: `${clusterKey}-node-${nodeKey}`, + name: nodeKey, + cluster: clusterKey, + assetType: "Node Disk", + ...nodeData, + }); + }); + } + + // Process PVCs + if (clusterData.pvc && typeof clusterData.pvc === "object") { + Object.entries(clusterData.pvc).forEach(([pvcKey, pvcData]) => { + assets.push({ + id: `${clusterKey}-pvc-${pvcKey}`, + name: pvcData.claimName || pvcKey, + cluster: clusterKey, + assetType: "PVC", + ...pvcData, + }); + }); + } + + // Handle flat asset structure (if any) + if (!clusterData.nodes && !clusterData.pvc && clusterData.type) { + assets.push({ + id: clusterKey, + name: clusterKey, + cluster: clusterKey, + assetType: "Unknown", + ...clusterData, + }); + } + }); + + return assets; +}; + +/** + * Apply filters to assets array + * @param {array} assets - Asset array + * @param {object} filters - Filter criteria + * @returns {array} - Filtered assets + */ +const applyFilters = (assets, filters) => { + let filtered = [...assets]; + + // Status filter + if (filters.status && filters.status.length > 0) { + filtered = filtered.filter((asset) => { + const idle = (asset.breakdown?.idle || 0) * 100; + if (filters.status.includes("ok") && idle < 40) return true; + if (filters.status.includes("review") && idle >= 40 && idle < 80) return true; + if (filters.status.includes("waste") && idle >= 80) return true; + return false; + }); + } + + // Asset type filter + if (filters.assetType && filters.assetType.length > 0) { + filtered = filtered.filter((asset) => + filters.assetType.includes(asset.assetType) + ); + } + + // Storage class filter + if (filters.storageClass && filters.storageClass.length > 0) { + filtered = filtered.filter((asset) => + filters.storageClass.includes(asset.storageClass) + ); + } + + // Cluster filter + if (filters.cluster && filters.cluster.length > 0) { + filtered = filtered.filter((asset) => + filters.cluster.includes(asset.cluster) + ); + } + + // Search filter + if (filters.search && filters.search.trim()) { + const searchTerm = filters.search.toLowerCase(); + filtered = filtered.filter( + (asset) => + asset.name.toLowerCase().includes(searchTerm) || + (asset.claimNamespace && asset.claimNamespace.toLowerCase().includes(searchTerm)) || + asset.cluster.toLowerCase().includes(searchTerm) + ); + } + + return filtered; +}; + +const AssetsDashboard = () => { + // State management + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [assets, setAssets] = useState([]); + const [useMockData, setUseMockData] = useState(false); + const [filters, setFilters] = useState({ + status: [], + assetType: [], + storageClass: [], + cluster: [], + search: "", + }); + + /** + * Fetch assets from API or use mock data + */ + const fetchAssets = async () => { + try { + setLoading(true); + setError(null); + + let result; + + if (useMockData) { + console.log("Using mock data for development"); + result = AssetsService.getMockData(); + // Simulate network delay + await new Promise((resolve) => setTimeout(resolve, 500)); + } else { + result = await AssetsService.fetchAssets("30d"); + } + + if (result && result.data) { + const transformedAssets = transformAssetData(result.data); + + if (transformedAssets.length === 0) { + setError("No assets found in the data"); + setAssets([]); + } else { + setAssets(transformedAssets); + } + } else { + setError("No data received from API"); + setAssets([]); + } + } catch (err) { + console.error("Error fetching assets:", err); + setError(err.message || "Failed to fetch assets. Please try again."); + setAssets([]); + } finally { + setLoading(false); + } + }; + + /** + * Fetch assets on component mount + */ + useEffect(() => { + fetchAssets(); + }, [useMockData]); + + /** + * Apply filters to assets + */ + const filteredAssets = useMemo(() => { + return applyFilters(assets, filters); + }, [assets, filters]); + + /** + * Get unique values for filter options + */ + const filterOptions = useMemo( + () => ({ + clusters: [...new Set(assets.map((a) => a.cluster))], + storageClasses: [...new Set(assets.map((a) => a.storageClass).filter(Boolean))], + assetTypes: [...new Set(assets.map((a) => a.assetType))], + }), + [assets] + ); + + // Loading state + if (loading) { + return ( + +
+
+ +
+