Skip to main content
Glama
api-client.ts47.8 kB
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios'; import { AuthToken, ApiResponse, QueryParams, UMBRELLA_ENDPOINTS } from './types.js'; import { AccountMapper, PlainSubUsersData } from './utils/account-mapper.js'; import { debugLog } from './utils/debug-logger.js'; export class UmbrellaApiClient { private baseURL: string; private frontendBaseURL: string; private axiosInstance: AxiosInstance; private _authToken: AuthToken | null = null; private _auth: any = null; // Reference to UmbrellaDualAuth for cloud context switching private _userData?: PlainSubUsersData; // Cached plain-sub-users data constructor(baseURL: string, frontendBaseURL?: string) { this.baseURL = baseURL; this.frontendBaseURL = frontendBaseURL || baseURL; this.axiosInstance = axios.create({ baseURL: this.baseURL, timeout: 60000, headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' } }); } setAuthToken(token: AuthToken): void { this._authToken = token; } setAuthHeaders(headers: AuthToken): void { this._authToken = headers; } setAuth(auth: any): void { this._auth = auth; } getAuthToken(): AuthToken | null { return this._authToken; } setUserData(userData: PlainSubUsersData): void { this._userData = userData; console.error('[API-CLIENT] User data set - type:', userData?.user_type); } async ensureUserData(): Promise<PlainSubUsersData | undefined> { // DEBUG: Log initial state before condition console.error('[API-CLIENT] 🔍 ensureUserData() called'); console.error(`[API-CLIENT] _userData exists: ${!!this._userData}`); console.error(`[API-CLIENT] _auth exists: ${!!this._auth}`); console.error(`[API-CLIENT] _auth.getUserManagementInfo exists: ${!!this._auth?.getUserManagementInfo}`); console.error(`[API-CLIENT] _auth.detectUserManagementSystem exists: ${!!this._auth?.detectUserManagementSystem}`); if (!this._userData) { console.error('[API-CLIENT] Fetching user data on first use (lazy loading)'); try { // Check if user is on Keycloak UM 2.0 // If getUserManagementInfo returns null (OAuth user, detection not complete), wait for it let umInfo = this._auth?.getUserManagementInfo?.(); console.error(`[API-CLIENT] umInfo from getUserManagementInfo: ${JSON.stringify(umInfo)}`); console.error(`[API-CLIENT] Checking condition: !umInfo=${!umInfo}, detectUserManagementSystem exists=${!!this._auth?.detectUserManagementSystem}`); if (!umInfo) { // This should NOT happen if userManagementInfo was properly set from JWT token console.error('[API-CLIENT] ⚠️ WARNING: userManagementInfo not set! This should not happen for OAuth users.'); console.error('[API-CLIENT] The JWT token should have contained userManagementInfo.'); console.error('[API-CLIENT] Defaulting to Cognito for safety (most common legacy auth method).'); // DO NOT call detectUserManagementSystem() - it's unreliable for users in migration state umInfo = { isKeycloak: false, authMethod: 'cognito' }; } const isKeycloak = umInfo?.isKeycloak || false; console.error(`[API-CLIENT] Final isKeycloak: ${isKeycloak}`); let userDataResponse; if (isKeycloak) { // For Keycloak UM 2.0 Direct customers: use /user-management/accounts console.error('[API-CLIENT] Keycloak UM 2.0 detected - using /user-management/accounts'); userDataResponse = await this.makeDirectRequest('/user-management/accounts'); } else { // For Cognito (Old UM): use /users/plain-sub-users console.error('[API-CLIENT] Cognito detected - using /users/plain-sub-users'); userDataResponse = await this.makeDirectRequest('/users/plain-sub-users'); } if (userDataResponse.success && userDataResponse.data) { this._userData = userDataResponse.data; console.error(`[API-CLIENT] Lazy loaded user data - auth mode: ${isKeycloak ? 'Keycloak UM 2.0' : 'Cognito'}`); } } catch (error: any) { console.error(`[API-CLIENT] Failed to lazy load user data: ${error.message}`); } } return this._userData; } get authToken(): AuthToken | null { return this._authToken; } private async getAuthHeaders(cloudContext?: string, customerAccountKey?: string, apiPath?: string, customerDivisionId?: string, accountId?: string): Promise<Record<string, string>> { if (!this._authToken) { throw new Error('Authentication token not set. Please authenticate first.'); } const headers: Record<string, string> = { 'Authorization': this._authToken.Authorization }; // Priority: Customer account key > Cloud context key > Default key let apiKey = null; // 1. Customer-specific account key (highest priority) if (customerAccountKey && this._auth && typeof this._auth.buildCustomerApiKey === 'function') { try { console.error(`[API-CLIENT] Building customer API key with divisionId: ${customerDivisionId} (type: ${typeof customerDivisionId})`); apiKey = await this._auth.buildCustomerApiKey(customerAccountKey, apiPath, customerDivisionId); if (apiKey) { console.error(`[API-CLIENT] Using customer-specific API key for account ${customerAccountKey}: ${apiKey}`); } } catch (error: any) { console.error(`[API-CLIENT] Failed to build customer API key: ${error.message}`); } } // 2. Cloud-specific API key (if no customer key or customer key failed) if (!apiKey && cloudContext && this._auth && typeof this._auth.buildCloudContextApiKey === 'function') { apiKey = this._auth.buildCloudContextApiKey(cloudContext); if (apiKey) { console.error(`[API-CLIENT] Using ${cloudContext.toUpperCase()} cloud context API key: ${apiKey}`); } } // 3. Dynamic API key based on accountId (for direct customers) if (!apiKey && accountId && !customerAccountKey) { // Ensure user data is loaded (lazy loading) const userData = await this.ensureUserData(); if (userData) { // For now, skip dynamic API key building as we need to implement it properly // TODO: Implement dynamic API key building based on accountId console.error(`[API-CLIENT] Dynamic API key building not implemented yet for accountId ${accountId}`); } } // 4. Default API key (fallback) - need to get from auth instance if (!apiKey && this._auth && typeof this._auth.getDefaultApiKey === 'function') { apiKey = this._auth.getDefaultApiKey(); console.error(`[API-CLIENT] Using default API key: ${apiKey}`); } if (apiKey) { headers['apikey'] = apiKey; } return headers; } async makeRequest(path: string, params?: QueryParams): Promise<ApiResponse> { try { // Special handling for MSP customers endpoint if (path === '/msp/customers') { console.error(`[API-CLIENT] MSP customers endpoint detected - using enhanced method`); return await this.getMspCustomers(); } // Special handling for v2 recommendations endpoint if (path === '/v2/recommendations/list') { console.error(`[API-CLIENT] V2 recommendations endpoint detected - using enhanced method`); return await this.getRecommendations(params); } // Special handling for accounts/sub-users endpoint - route based on auth mode if (path === '/v1/users/plain-sub-users') { console.error('[API-CLIENT] 🔍 Routing plain-sub-users endpoint - checking auth mode'); // Check if user is on Keycloak UM 2.0 // If getUserManagementInfo returns null (OAuth user, detection not complete), wait for it let umInfo = this._auth?.getUserManagementInfo?.(); console.error(`[API-CLIENT] Initial umInfo: ${JSON.stringify(umInfo)}`); if (!umInfo) { // This should NOT happen if userManagementInfo was properly set from JWT token console.error('[API-CLIENT] ⚠️ WARNING: userManagementInfo not set! This should not happen for OAuth users.'); console.error('[API-CLIENT] The JWT token should have contained userManagementInfo.'); console.error('[API-CLIENT] Defaulting to Cognito for safety (most common legacy auth method).'); // DO NOT call detectUserManagementSystem() - it's unreliable for users in migration state umInfo = { isKeycloak: false, authMethod: 'cognito' }; } else { console.error('[API-CLIENT] UM info already available or detection method not found'); } const isKeycloak = umInfo?.isKeycloak || false; console.error(`[API-CLIENT] Final isKeycloak: ${isKeycloak}`); if (isKeycloak) { console.error(`[API-CLIENT] ✅ Keycloak UM 2.0 - routing /v1/users/plain-sub-users → /v1/user-management/accounts`); return await this.makeDirectRequest('/v1/user-management/accounts', params); } else { console.error(`[API-CLIENT] ℹ️ Cognito Old UM - using /v1/users/plain-sub-users`); } } return await this.makeDirectRequest(path, params); } catch (error: any) { // Error handling is done in makeDirectRequest throw error; } } async makeDirectRequest(path: string, params?: QueryParams): Promise<ApiResponse> { try { // Extract special _requestBody parameter for custom POST bodies const customRequestBody = (params as any)?._requestBody; // Extract cloud context, customer account key, and division ID from parameters let cloudContext = params?.cloud_context; const customerAccountKey = params?.customer_account_key; const customerDivisionId = params?.customer_division_id; const accountId = params?.accountId as string | undefined; // Auto-detect cloud context if not provided if (!cloudContext && accountId) { const userData = await this.ensureUserData(); if (userData) { const detectedContext = AccountMapper.getCloudContext(userData, accountId); if (detectedContext) { cloudContext = detectedContext; console.error(`[API-CLIENT] Auto-detected cloud context for ${accountId}: ${cloudContext}`); } } } // Use backend API for ALL endpoints - simplified routing const apiUrl = this.baseURL; // Auto-detect if this is a GCP account let isGcpAccount = cloudContext === 'gcp'; if (!cloudContext && accountId) { const userData = await this.ensureUserData(); if (userData) { const detectedContext = AccountMapper.getCloudContext(userData, accountId); if (detectedContext === 'gcp') { isGcpAccount = true; console.error(`[API-CLIENT] Auto-detected GCP account for ${accountId}`); } } } // Extract isPpApplied for header (not URL parameter) const isPpApplied = params?.isPpApplied; // Convert params to handle arrays properly for axios const axiosParams: Record<string, any> = {}; if (params) { for (const [key, value] of Object.entries(params)) { // Skip internal parameters that shouldn't be sent to API if (key === 'customer_account_key' || key === 'customer_division_id' || key === 'userQuery' || key === 'cloud_context' || key === 'isPpApplied' || key === 'priceView') { continue; } // GCP-specific exclusions: Skip isUnblended and excludeFilters for GCP accounts if (isGcpAccount) { if (key === 'isUnblended') { console.error(`[API-CLIENT] Skipping isUnblended for GCP account`); continue; } if (key === 'excludeFilters') { console.error(`[API-CLIENT] Skipping excludeFilters for GCP account`); continue; } } if (Array.isArray(value)) { // Axios handles arrays by repeating the parameter axiosParams[key] = value; } else if (value !== undefined && value !== null) { axiosParams[key] = value; } } } // Don't add isPpApplied as URL parameter - it goes in commonparams header for MSP only const isMspCheck = customerAccountKey && customerDivisionId && customerDivisionId !== '0'; console.error(`[API-CLIENT] Customer Check: accountKey=${customerAccountKey}, divisionId=${customerDivisionId}, isMSP=${isMspCheck}`); if (isMspCheck) { console.error(`[API-CLIENT] ✅ MSP customer detected - will add isPpApplied to commonparams header`); } else if (customerAccountKey) { console.error(`[API-CLIENT] ℹ️ Direct customer detected (divisionId=0) - NO commonparams header`); } // Determine HTTP method based on endpoint const endpoint = UMBRELLA_ENDPOINTS.find(ep => ep.path === path); const method = endpoint?.method?.toLowerCase() || 'get'; // Use already extracted accountId for dynamic API key generation const authHeaders = await this.getAuthHeaders(cloudContext, customerAccountKey, path, customerDivisionId, accountId); // Only add commonparams for MSP customers (divisionId > 0) // Direct customers (divisionId 0) should NOT have this header const isMspCustomer = customerAccountKey && customerDivisionId && customerDivisionId !== '0'; // Log isPpApplied header value if (isPpApplied !== undefined) { console.error(`[API-CLIENT] Adding commonparams header: { isPpApplied: ${isPpApplied} }`); } const config: AxiosRequestConfig = { method: method as any, headers: { ...authHeaders, // Add commonparams header with isPpApplied value (defaults to false for customer prices) // Only add header if isPpApplied is explicitly set (true or false) ...(isPpApplied !== undefined ? { 'commonparams': JSON.stringify({ isPpApplied: isPpApplied }) } : {}) }, }; // For POST requests, send parameters in body; for GET requests, use query params if (method === 'post') { // If custom request body is provided, use it directly if (customRequestBody) { config.data = customRequestBody; console.error(`[API-CLIENT] Using custom request body for ${path}`); } else { config.data = axiosParams; } } else { config.params = axiosParams; } // Build the full URL using the appropriate API const fullUrl = `${apiUrl}${path}`; // Check if we're using a dynamic API key (contains account info) const currentApiKey = authHeaders['apikey'] || ''; const isDynamicApiKey = currentApiKey.includes(':') && accountId; if (isDynamicApiKey) { console.error(`[API-CLIENT] Dynamic API key detected - will skip accountId in URL`); } // Use the isGcpAccount value from earlier detection // Properly handle array parameters and filter parameters const searchParams = new URLSearchParams(); if (params) { for (const [key, value] of Object.entries(params)) { // Skip internal parameters that shouldn't be sent to API if (key === 'customer_account_key' || key === 'cloud_context' || key === 'customer_division_id' || key === 'userQuery' || key === 'isPpApplied' || key === 'priceView') { continue; } // Skip accountId when using dynamic API key (it's already in the API key) if (key === 'accountId' && isDynamicApiKey) { console.error(`[API-CLIENT] Skipping accountId in URL since it's in the API key`); continue; } // GCP-specific exclusions: Skip isUnblended and excludeFilters for GCP accounts if (isGcpAccount) { if (key === 'isUnblended') { console.error(`[API-CLIENT] Skipping isUnblended for GCP account`); continue; } if (key === 'excludeFilters') { console.error(`[API-CLIENT] Skipping excludeFilters for GCP account`); continue; } } // Handle excludeFilters object (e.g., excludeFilters[chargetype][]=Tax) if (key === 'excludeFilters' && typeof value === 'object' && value !== null) { for (const [filterKey, filterValue] of Object.entries(value)) { if (Array.isArray(filterValue)) { filterValue.forEach(item => { searchParams.append(`excludeFilters[${filterKey}][]`, String(item)); }); } else { searchParams.append(`excludeFilters[${filterKey}]`, String(filterValue)); } } } // Handle filter parameters (e.g., filters[service]=AmazonCloudWatch) else if (key.startsWith('filters[')) { // Keep the filter format as-is for the API searchParams.append(key, String(value)); } else if (Array.isArray(value)) { // Add each array item as separate parameter value.forEach(item => searchParams.append(key, String(item))); } else if (value !== undefined && value !== null && typeof value !== 'object') { searchParams.append(key, String(value)); } } // Note: isPpApplied is sent in commonparams header, not URL parameters } const queryString = searchParams.toString(); const finalUrl = queryString ? `${fullUrl}?${queryString}` : fullUrl; console.error('\\n🔍 API REQUEST:'); console.error(` URL: ${finalUrl}`); console.error(` Cloud Context: ${cloudContext || 'none'}`); console.error(` Customer Account: ${customerAccountKey || 'none'}`); console.error(` Division ID: ${customerDivisionId || 'none'}`); console.error(` Customer Type: ${isMspCustomer ? 'MSP' : 'Direct'}`); console.error(` Frontend API: false`); console.error(` commonparams header: ${isMspCustomer ? JSON.stringify({ isPpApplied: true }) : 'not-set (direct customer)'}`); console.error(` Headers:`, JSON.stringify(config.headers, null, 2)); // Log what axios will ACTUALLY use const actualUrl = this.baseURL + path; const actualParams = config.params || {}; const paramString = new URLSearchParams(actualParams).toString(); const fullActualUrl = paramString ? `${actualUrl}?${paramString}` : actualUrl; console.error(` ACTUAL URL (axios will use): ${fullActualUrl}`); console.error(` ACTUAL PARAMS:`, JSON.stringify(config.params || {}, null, 2)); // Add detailed RAW REQUEST logging to debug file debugLog.logState('[API-CLIENT]', '[RAW REQUEST] ===================================='); debugLog.logState('[API-CLIENT]', `[RAW REQUEST] Full URL: ${finalUrl}`); debugLog.logState('[API-CLIENT]', `[RAW REQUEST] Method: ${method.toUpperCase()}`); debugLog.logState('[API-CLIENT]', '[RAW REQUEST] Headers:', config.headers); debugLog.logState('[API-CLIENT]', '[RAW REQUEST] Query Parameters:', config.params || {}); debugLog.logState('[API-CLIENT]', `[RAW REQUEST] Timeout: ${config.timeout || 60000}ms`); debugLog.logState('[API-CLIENT]', `[RAW REQUEST] Base URL: ${this.baseURL}`); debugLog.logState('[API-CLIENT]', `[RAW REQUEST] Path: ${path}`); if (config.data) { debugLog.logState('[API-CLIENT]', '[RAW REQUEST] Body Data:', config.data); } debugLog.logState('[API-CLIENT]', '[RAW REQUEST] ===================================='); // Use axios instance for all requests (backend API only) const response = await this.axiosInstance.request({...config, url: path}); // DEBUGGING: Log the response details console.error('\\n✅ API RESPONSE:'); console.error(` Status: ${response.status}`); console.error(` Headers: ${JSON.stringify(response.headers, null, 2)}`); console.error(` Data: ${JSON.stringify(response.data, null, 2)}`); console.error(' ====================================='); // Add detailed RAW RESPONSE logging to debug file debugLog.logState('[API-CLIENT]', '[RAW RESPONSE] ===================================='); debugLog.logState('[API-CLIENT]', `[RAW RESPONSE] Status: ${response.status}`); debugLog.logState('[API-CLIENT]', `[RAW RESPONSE] Status Text: ${response.statusText}`); debugLog.logState('[API-CLIENT]', '[RAW RESPONSE] Headers:', response.headers); debugLog.logState('[API-CLIENT]', '[RAW RESPONSE] Data:', response.data); debugLog.logState('[API-CLIENT]', '[RAW RESPONSE] ===================================='); return { data: response.data, success: true, message: 'Request completed successfully' }; } catch (error: any) { // DEBUGGING: Log the error details console.error('\\n❌ API ERROR:'); if (error.response) { console.error(` Status: ${error.response.status}`); console.error(` Headers: ${JSON.stringify(error.response.headers, null, 2)}`); console.error(` Data: ${JSON.stringify(error.response.data, null, 2)}`); } else if (error.request) { console.error(` Request Error: ${error.message}`); console.error(` URL: ${error.config?.url}`); } else { console.error(` General Error: ${error.message}`); } console.error(' ====================================='); // Add detailed RAW ERROR logging to debug file debugLog.logState('[API-CLIENT]', '[RAW ERROR] ===================================='); if (error.response) { debugLog.logState('[API-CLIENT]', `[RAW ERROR] Status: ${error.response.status}`); debugLog.logState('[API-CLIENT]', `[RAW ERROR] Status Text: ${error.response.statusText}`); debugLog.logState('[API-CLIENT]', '[RAW ERROR] Headers:', error.response.headers); debugLog.logState('[API-CLIENT]', '[RAW ERROR] Data:', error.response.data); } else if (error.request) { debugLog.logState('[API-CLIENT]', `[RAW ERROR] Request Error: ${error.message}`); debugLog.logState('[API-CLIENT]', `[RAW ERROR] URL: ${error.config?.url}`); debugLog.logState('[API-CLIENT]', '[RAW ERROR] Config:', error.config); } else { debugLog.logState('[API-CLIENT]', `[RAW ERROR] General Error: ${error.message}`); debugLog.logState('[API-CLIENT]', '[RAW ERROR] Stack:', error.stack); } debugLog.logState('[API-CLIENT]', '[RAW ERROR] ===================================='); if (error.response) { const status = error.response.status; const message = error.response.data?.message || error.response.statusText || 'Request failed'; if (status === 401) { return { success: false, error: 'Authentication failed. Please check your credentials and try again.', message: message }; } else if (status === 403) { return { success: false, error: 'Access denied. You do not have permission to access this resource.', message: message }; } else if (status === 404) { return { success: false, error: 'Resource not found. Please check the endpoint path.', message: message }; } else if (status === 429) { return { success: false, error: 'Rate limit exceeded. Please wait and try again.', message: message }; } else { return { success: false, error: `Server error (${status}): ${message}`, message: message }; } } else if (error.request) { return { success: false, error: 'Network error: Unable to connect to Umbrella Cost API.', message: 'Please check your network connection and try again.' }; } else { return { success: false, error: `Request error: ${error.message}`, message: error.message }; } } } // Helper methods for common endpoints async getCostAndUsage(params?: QueryParams): Promise<ApiResponse> { return this.makeRequest('/v2/invoices/cost-and-usage', params); } async getCosts(params?: QueryParams): Promise<ApiResponse> { return this.makeRequest('/v2/invoices/cost-and-usage', params); } async getAccounts(): Promise<ApiResponse> { // DISABLED: This method corrupts session state and breaks recommendations // Returning empty response to prevent session corruption console.error('[GETACCOUNTS] Disabled to prevent session corruption and preserve recommendations'); return { success: false, error: 'getAccounts disabled to prevent session corruption' }; } async getPublicCostAndUsage(params?: QueryParams): Promise<ApiResponse> { return this.makeRequest('/v1/invoices/cost-and-usage', params); } async getRDSInstanceCosts(params?: QueryParams): Promise<ApiResponse> { return this.makeRequest('/v1/usage/rds/instance-costs', params); } async getS3BucketCosts(params?: QueryParams): Promise<ApiResponse> { return this.makeRequest('/v1/usage/s3/bucket-costs', params); } async getResourceExplorerData(params?: QueryParams): Promise<ApiResponse> { return this.makeRequest('/v1/usage/resource-explorer/resource', params); } async getDashboards(): Promise<ApiResponse> { return this.makeRequest('/v1/usage/custom-dashboard/dashboards'); } async getBudgets(params?: QueryParams): Promise<ApiResponse> { return this.makeRequest('/v1/budgets', params); } async getRecommendations(params?: QueryParams & { useNetAmortized?: boolean, cloud_context?: string }): Promise<ApiResponse> { console.error('[GETRECOMMENDATIONS] ⭐ METHOD CALLED - Starting v2 recommendations processing'); debugLog.logState('[GETRECOMMENDATIONS]', '[METHOD-ENTRY]', { params }); // Use the correct V2 recommendations API with POST method and commonParams header // DEFAULT: Use last 12 months date range to match UI exactly const today = new Date(); const last12Months = new Date(today); last12Months.setFullYear(today.getFullYear() - 1); const fromDate = last12Months.toISOString().split('T')[0]; const toDate = today.toISOString().split('T')[0]; // Parse optional filter parameters with defaults const recommendationStatus = params?.recommendation_status || 'open'; const isOpen = recommendationStatus === 'open'; const isDone = recommendationStatus === 'done'; // For done/closed recommendations, use "actual_savings", for open use "potential_savings" const statusFilter = isDone ? "actual_savings" : "potential_savings"; const categoryId = params?.categoryId; const customerTags = params?.customerTags; const costMode = params?.costMode?.toUpperCase() || 'UNBLENDED'; // Use provided dates or defaults const creationFrom = params?.creationDateFrom || fromDate; const creationTo = params?.creationDateTo || toDate; const requestBody: any = { filters: { status_filter: statusFilter, is_open: isOpen, user_status: { done: isDone, excluded: false }, open_recs_creation_date: { from: creationFrom, to: creationTo }, closed_and_done_recs_dates: { last_update_date: { from: creationFrom, to: creationTo } }, cost_mode: costMode }, sort: [{ by: "savings", order: "desc" }], page_size: 100, pagination_token: null }; // Add optional filters if provided if (categoryId) { requestBody.filters.cat_id = categoryId; } if (customerTags) { requestBody.filters.customer_tags = customerTags.split(',').map((t: string) => t.trim()); } try { // Detect if this is an MSP customer (divisionId > 0) or direct customer (divisionId = 0) const customerDivisionId = params?.customer_division_id || params?.divisionId; const customerAccountKey = params?.customer_account_key || params?.accountKey; const isMspCustomer = customerAccountKey && customerDivisionId && customerDivisionId !== '0'; console.error(`\n🔧 ========== V2 RECOMMENDATIONS SETUP ==========`); console.error(` Account Key: ${customerAccountKey}`); console.error(` Division ID: ${customerDivisionId}`); console.error(` Customer Type: ${isMspCustomer ? 'MSP' : 'Direct'}`); console.error(` Recommendation Status: ${recommendationStatus}`); console.error(` Status Filter: ${statusFilter}`); console.error(` Is Open: ${isOpen}`); console.error(` Is Done: ${isDone}`); console.error(` Date Range: ${creationFrom} to ${creationTo}`); console.error(` Cost Mode: ${costMode}`); console.error(` Category Filter: ${categoryId || 'none'}`); console.error(` Customer Tags: ${customerTags || 'none'}`); console.error(`================================================\n`); // Fetch ALL recommendations through pagination from working V2 API let allRecommendations: any[] = []; let paginationToken: string | null = null; let pageCount = 0; do { const pageRequestBody: any = { ...requestBody, pagination_token: paginationToken }; // Debug log the exact payload being sent debugLog.logState('[V2-RECOMMENDATIONS]', '[REQUEST-PAYLOAD]', pageRequestBody); const response: any = await axios.post(`${this.baseURL}/v2/recommendations/list`, pageRequestBody, { headers: { ...(await this.getAuthHeaders(params?.cloud_context, params?.customer_account_key, '/v2/recommendations/list', params?.customer_division_id)), // Only add commonParams for MSP customers (divisionId > 0), not direct customers (divisionId = 0) ...(isMspCustomer ? { 'commonParams': '{"isPpApplied":true}' } : {}), 'Content-Type': 'application/json' }, timeout: 30000 }); // V2 API returns { page: [...], paginationToken: "..." } structure const pageData = response.data.page || []; allRecommendations.push(...pageData); // API uses camelCase "paginationToken", not snake_case paginationToken = response.data.paginationToken; pageCount++; console.error(`\n📄 ========== PAGE ${pageCount} RESULTS ==========`); console.error(` Items in this page: ${pageData.length}`); console.error(` Running total: ${allRecommendations.length} recommendations`); console.error(` Pagination token: ${paginationToken || 'NULL (no more pages)'}`); console.error(` Response keys: ${Object.keys(response.data).join(', ')}`); // Log the full response structure for debugging debugLog.logState('[V2-RECOMMENDATIONS]', `[PAGE-${pageCount}-RESPONSE]`, { pageItemCount: pageData.length, totalSoFar: allRecommendations.length, paginationToken: paginationToken || null, responseKeys: Object.keys(response.data) }); // Debug: Log if we got a pagination token but it's the same as before if (paginationToken && pageCount > 1) { console.error(` ✅ Has pagination token - will continue to next page`); } else if (!paginationToken && pageData.length === 100) { console.error(` ⚠️ WARNING: Got exactly 100 items but NO pagination token!`); console.error(` ⚠️ This indicates the API stopped pagination prematurely`); } else if (!paginationToken) { console.error(` ✅ No pagination token - this is the last page`); } console.error(`================================================\n`); // Safety limit to prevent infinite loops if (pageCount > 20) { console.error(`⚠️ Hit safety limit of 20 pages`); break; } } while (paginationToken); console.error(`📊 Total fetched: ${allRecommendations.length} recommendations across ${pageCount} pages`); // Debug: Log structure of first recommendation to understand field names if (allRecommendations.length > 0) { console.error(`🔍 First recommendation structure:`, JSON.stringify(allRecommendations[0], null, 2)); } const recommendations = allRecommendations; // Filter recommendations based on cloud context (if specified) let filteredRecommendations = recommendations; if (params?.cloud_context) { const targetCloud = params.cloud_context.toUpperCase(); filteredRecommendations = recommendations.filter((rec: any) => { return rec.cloudProvider === targetCloud; }); console.error(`🎯 ${targetCloud} Filtering: ${allRecommendations.length} total -> ${filteredRecommendations.length} ${targetCloud} recommendations`); } else { // No cloud filter specified - return ALL cloud recommendations console.error(`🌐 No cloud filter: Returning all ${allRecommendations.length} recommendations`); } // DEFAULT: Use on-demand (unblended) costs unless client explicitly requests net amortized const useNetAmortized = params?.useNetAmortized === true; const savingsField = useNetAmortized ? 'netAmortized' : 'unblended'; const costField = useNetAmortized ? 'netAmortized' : 'unblended'; const totalSavings = filteredRecommendations.reduce((sum: number, rec: any) => sum + (rec.annualSavings?.[savingsField] || 0), 0); // Calculate category breakdown from filtered recommendations const categoryBreakdown: Record<string, {amount: number, count: number}> = {}; filteredRecommendations.forEach((rec: any) => { const category = rec.category || 'Unknown'; if (!categoryBreakdown[category]) categoryBreakdown[category] = {amount: 0, count: 0}; categoryBreakdown[category].amount += rec.annualSavings?.[savingsField] || 0; categoryBreakdown[category].count += 1; }); const formattedRecommendations = filteredRecommendations.slice(0, 10).map((rec: any) => ({ id: rec.recId, type: rec.typeName || rec.typeId, category: rec.category, service: rec.service, resource_id: rec.resourceId, resource_name: rec.resourceName || rec.resourceId, region: rec.region, // Include all cost types in the response annual_savings: { unblended: rec.annualSavings?.unblended || 0, amortized: rec.annualSavings?.amortized || 0, net_unblended: rec.annualSavings?.netUnblended || 0, net_amortized: rec.annualSavings?.netAmortized || 0 }, monthly_savings: { unblended: rec.monthlySavings?.unblended || 0, amortized: rec.monthlySavings?.amortized || 0, net_unblended: rec.monthlySavings?.netUnblended || 0, net_amortized: rec.monthlySavings?.netAmortized || 0 }, annual_current_cost: { unblended: rec.annualCurrentCost?.unblended || 0, amortized: rec.annualCurrentCost?.amortized || 0, net_unblended: rec.annualCurrentCost?.netUnblended || 0, net_amortized: rec.annualCurrentCost?.netAmortized || 0 }, monthly_current_cost: { unblended: rec.monthlyCurrentCost?.unblended || 0, amortized: rec.monthlyCurrentCost?.amortized || 0, net_unblended: rec.monthlyCurrentCost?.netUnblended || 0, net_amortized: rec.monthlyCurrentCost?.netAmortized || 0 }, current_instance_type: rec.instanceType, recommended_instance_type: rec.recData?.instance_type_recommended || rec.recData?.recommended_instance_type, recommended_action: rec.recommendedAction, description: rec.recData?.description || `${rec.typeName} recommendation for ${rec.service}`, status: rec.open ? 'Open' : 'Closed', age_days: rec.age, cost_mode: costMode, customer_tags: rec.customerTags || [] })); return { success: true, data: formattedRecommendations, total_count: filteredRecommendations.length, total_potential_savings: totalSavings, category_breakdown: categoryBreakdown, message: `Found ${filteredRecommendations.length} cost optimization recommendations with $${totalSavings.toLocaleString()} total potential annual savings` }; } catch (error: any) { console.error('Recommendations API Error:', error.message); debugLog.logState('[V2-RECOMMENDATIONS]', '[ERROR-DETAILS]', { message: error.message, status: error.response?.status, statusText: error.response?.statusText, data: error.response?.data, url: error.config?.url }); if (error.response?.status === 500) { return { success: false, error: 'Recommendations service temporarily unavailable', message: 'The recommendations feature may not be configured for this account' }; } throw error; } } async getAnomalyDetection(params?: QueryParams): Promise<ApiResponse> { return this.makeRequest('/v1/anomaly-detection', params); } async getCommitmentAnalysis(params?: QueryParams): Promise<ApiResponse> { return this.makeRequest('/v1/commitment', params); } async getKubernetesCosts(params?: QueryParams): Promise<ApiResponse> { return this.makeRequest('/v1/kubernetes', params); } async getUsers(): Promise<ApiResponse> { return this.makeRequest('/v1/users'); } async getCustomers(): Promise<ApiResponse> { return this.makeRequest('/v1/user-management/customers'); } async getMspCustomers(): Promise<ApiResponse> { console.error(`[API-CLIENT] MSP Customer List: Using plain-sub-users API to get actual customer names`); try { // Use the correct API endpoint to get customerDivisions const plainSubUsersResponse = await this.makeDirectRequest('/users/plain-sub-users'); if (plainSubUsersResponse.success && plainSubUsersResponse.data?.customerDivisions) { const customerDivisions = plainSubUsersResponse.data.customerDivisions; // Extract customer names from customerDivisions object keys const customerNames = Object.keys(customerDivisions); console.error(`[API-CLIENT] MSP Customer List: Found ${customerNames.length} actual customers from customerDivisions`); // Convert to customer format const customers = customerNames.map((customerName, index) => { const divisionData = customerDivisions[customerName]; const firstDivision = Array.isArray(divisionData) && divisionData.length > 0 ? divisionData[0] : {}; return { customerName: customerName, customerDisplayName: customerName, // Add this field for server.ts customerCode: `CUST-${index + 1}`, customerId: index + 1, divisionId: firstDivision.divisionId || null, accountKey: firstDivision.accountKey || null, accountName: firstDivision.accountName || null, linkedAccountIds: [String(firstDivision.accountId || firstDivision.accountKey || '')], customerNameId: customerName, divisionData: divisionData // Include full division data for multi-account selection }; }); return { data: customers, success: true, message: `Found ${customers.length} MSP customers from customerDivisions API` }; } else { console.error(`[API-CLIENT] MSP Customer List: No customerDivisions in plain-sub-users response`); // Fallback to auth-based approach return await this.getMspCustomersFromAuth(); } } catch (error: any) { console.error(`[API-CLIENT] MSP Customer List: plain-sub-users API failed: ${error.message}`); // Fallback to auth-based approach try { return await this.getMspCustomersFromAuth(); } catch (fallbackError: any) { return { success: false, error: 'Unable to retrieve MSP customer list from both customerDivisions and auth data', message: `Both methods failed: ${error.message}, ${fallbackError.message}` }; } } } private async getMspCustomersFromAuth(): Promise<ApiResponse> { console.error(`[API-CLIENT] MSP Customer List: Using auth-based approach as fallback`); if (this._auth && typeof this._auth.getAvailableAccounts === 'function') { const availableAccounts = this._auth.getAvailableAccounts(); console.error(`[API-CLIENT] MSP Customer List: Found ${availableAccounts.length} available accounts from auth`); if (availableAccounts.length > 5) { // Convert ALL available accounts to customer format (less filtering for maximum results) const customerAccounts = availableAccounts .filter((acc: any) => { const name = acc.accountName || acc.account_name || acc.name || ''; const accountKey = acc.accountKey || acc.account_key || acc.accountId || acc.account_id || ''; // Minimal filtering - only exclude truly invalid entries return name.trim() !== '' && accountKey !== '' && accountKey !== 'undefined'; }) .map((acc: any, index: number) => ({ customerName: acc.accountName || acc.account_name || acc.name || `Account ${acc.accountKey}`, customerCode: `MSP-${acc.accountKey || index}`, customerId: index + 1, linkedAccountIds: [String(acc.accountKey || acc.account_key || acc.accountId || acc.account_id)], customerNameId: acc.accountName || acc.account_name || acc.name || `Account ${acc.accountKey}`, accountKey: acc.accountKey || acc.account_key || acc.accountId || acc.account_id })); console.error(`[API-CLIENT] MSP Customer List: Converted ${availableAccounts.length} auth accounts to ${customerAccounts.length} customer records`); return { data: customerAccounts, success: true, message: `Found ${customerAccounts.length} MSP customers from authentication data (fallback method)` }; } } return { success: false, error: 'Unable to retrieve MSP customer list - pagination and auth fallback both failed', message: 'MSP customer data not available' }; } async getDistinctServiceNames(): Promise<ApiResponse> { return this.makeRequest('/v1/invoices/service-names/distinct'); } async getDistinctServiceCosts(): Promise<ApiResponse> { return this.makeRequest('/v1/invoices/service-costs/distinct'); } /** * Get heatmap summary with BOTH potential and actual savings * This makes TWO separate API calls and combines the results */ async getHeatmapSummary(params?: QueryParams): Promise<ApiResponse> { console.error('[HEATMAP] Making dual API calls for potential + actual savings'); const today = new Date().toISOString().split('T')[0]; const yearAgo = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; const baseFilters = { "user_status": { "excluded": false }, "open_recs_creation_date": { "from": yearAgo, "to": today }, "closed_and_done_recs_dates": { "last_update_date": { "from": yearAgo, "to": today } }, "cost_mode": "UNBLENDED" }; try { // Call 1: Potential savings (open recommendations, not done) const potentialResponse = await this.makeDirectRequest('/v1/recommendationsNew/heatmap/summary', { ...params, _requestBody: { filters: { ...baseFilters, "status_filter": "potential_savings", "is_open": true, "user_status": { "done": false, "excluded": false } } } } as any); // Call 2: Actual savings (same flags as UI: is_open: true, done: false) const actualResponse = await this.makeDirectRequest('/v1/recommendationsNew/heatmap/summary', { ...params, _requestBody: { filters: { ...baseFilters, "status_filter": "actual_savings", "is_open": true, "user_status": { "done": false, "excluded": false } } } } as any); // Combine results const combinedData = { potentialAnnualSavings: potentialResponse.data?.potentialAnnualSavings || 0, potentialSavingsRecommendationCount: potentialResponse.data?.potentialSavingsRecommendationCount || 0, expectedSavingsRatePercent: potentialResponse.data?.expectedSavingsRatePercent || 0, actualAnnualSavings: actualResponse.data?.actualAnnualSavings || 0, actualSavingsRecommendationCount: actualResponse.data?.actualSavingsRecommendationCount || 0, effectiveSavingsRatePercent: actualResponse.data?.effectiveSavingsRatePercent || 0, totalSavings: (potentialResponse.data?.totalSavings || 0) + (actualResponse.data?.totalSavings || 0), totalCount: (potentialResponse.data?.totalCount || 0) + (actualResponse.data?.totalCount || 0), excludedRecommendationsSavings: (potentialResponse.data?.excludedRecommendationsSavings || 0) + (actualResponse.data?.excludedRecommendationsSavings || 0), excludedRecommendationsCount: (potentialResponse.data?.excludedRecommendationsCount || 0) + (actualResponse.data?.excludedRecommendationsCount || 0), // Include breakdowns from potential savings call (they're usually the same) service: potentialResponse.data?.service, type_id: potentialResponse.data?.type_id, linked_account_id: potentialResponse.data?.linked_account_id, cat_id: potentialResponse.data?.cat_id, instance_type: potentialResponse.data?.instance_type }; console.error('[HEATMAP] Combined results:'); console.error(` Potential: $${combinedData.potentialAnnualSavings} (${combinedData.potentialSavingsRecommendationCount} recs)`); console.error(` Actual: $${combinedData.actualAnnualSavings} (${combinedData.actualSavingsRecommendationCount} recs)`); return { success: true, data: combinedData }; } catch (error: any) { console.error('[HEATMAP] Error getting heatmap summary:', error.message); return { success: false, error: error.message, data: null }; } } // Generic method to get available endpoints getAvailableEndpoints() { return UMBRELLA_ENDPOINTS; } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/daviddraiumbrella/invoice-monitoring'

If you have feedback or need assistance with the MCP directory API, please join our Discord server