UNPKG

firewalla-mcp-server

Version:

Model Context Protocol (MCP) server for Firewalla MSP API - Provides real-time network monitoring, security analysis, and firewall management through 28 specialized tools compatible with any MCP client

812 lines 41.9 kB
/** * Analytics and statistics tool handlers */ import { BaseToolHandler } from './base.js'; import { ParameterValidator, SafeAccess, ErrorType, } from '../../validation/error-handler.js'; import { unixToISOString, safeUnixToISOString, getCurrentTimestamp, } from '../../utils/timestamp.js'; import { logger } from '../../monitoring/logger.js'; import { withToolTimeout } from '../../utils/timeout-manager.js'; import { normalizeUnknownFields, sanitizeFieldValue, batchNormalize, } from '../../utils/data-normalizer.js'; import { normalizeTimestamps } from '../../utils/data-validator.js'; export class GetBoxesHandler extends BaseToolHandler { constructor() { super({ enableGeoEnrichment: false, enableFieldNormalization: false, additionalMeta: { data_source: 'flow_trends', entity_type: 'historical_flow_data', supports_geographic_enrichment: false, supports_field_normalization: false, standardization_version: '2.0.0', }, }); this.name = 'get_boxes'; this.description = 'List all managed Firewalla boxes with status and configuration details.'; this.category = 'analytics'; } async execute(_args, firewalla) { try { const groupIdValidation = ParameterValidator.validateOptionalString(_args?.group_id, 'group_id'); if (!groupIdValidation.isValid) { return this.createErrorResponse('Parameter validation failed', ErrorType.VALIDATION_ERROR, undefined, groupIdValidation.errors); } const groupId = groupIdValidation.sanitizedValue; const boxesResponse = await withToolTimeout(async () => firewalla.getBoxes(groupId), this.name); const boxResults = SafeAccess.safeArrayAccess(boxesResponse.results, (arr) => arr, []); const normalizedBoxes = batchNormalize(boxResults, { name: (v) => sanitizeFieldValue(v, 'Unknown Box').value, model: (v) => sanitizeFieldValue(v, 'unknown').value, mode: (v) => sanitizeFieldValue(v, 'unknown').value, version: (v) => sanitizeFieldValue(v, 'unknown').value, group: (v) => (v ? normalizeUnknownFields(v) : null), location: (v) => sanitizeFieldValue(v, 'unknown').value, online: (v) => Boolean(v), gid: (v) => sanitizeFieldValue(v, 'unknown').value, license: (v) => sanitizeFieldValue(v, 'unknown').value, publicIP: (v) => sanitizeFieldValue(v, 'unknown').value, deviceCount: (v) => Number(v) || 0, ruleCount: (v) => Number(v) || 0, alarmCount: (v) => Number(v) || 0, }); const startTime = Date.now(); const boxData = normalizedBoxes.map((box) => { const timestampNormalized = normalizeTimestamps(box); const finalBox = timestampNormalized.data; return { gid: SafeAccess.getNestedValue(finalBox, 'gid', 'unknown'), name: finalBox.name, model: finalBox.model, mode: finalBox.mode, version: finalBox.version, online: SafeAccess.getNestedValue(finalBox, 'online', false), last_seen: SafeAccess.getNestedValue(finalBox, 'lastSeen', 0), license: SafeAccess.getNestedValue(finalBox, 'license', null), public_ip: finalBox.publicIP || finalBox.public_ip || 'unknown', group: finalBox.group, location: finalBox.location, device_count: SafeAccess.getNestedValue(finalBox, 'deviceCount', 0), rule_count: SafeAccess.getNestedValue(finalBox, 'ruleCount', 0), alarm_count: SafeAccess.getNestedValue(finalBox, 'alarmCount', 0), }; }); // Apply geographic enrichment for public IP addresses const enrichedBoxData = await this.enrichGeoIfNeeded(boxData, [ 'public_ip', ]); const unifiedResponseData = { total_boxes: normalizedBoxes.length, boxes: enrichedBoxData, }; const executionTime = Date.now() - startTime; return this.createUnifiedResponse(unifiedResponseData, { executionTimeMs: executionTime, }); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; return this.createErrorResponse(`Failed to get boxes: ${errorMessage}`, ErrorType.API_ERROR); } } } export class GetSimpleStatisticsHandler extends BaseToolHandler { constructor() { super({ enableGeoEnrichment: false, // No IP fields in statistics enableFieldNormalization: true, additionalMeta: { data_source: 'statistics', entity_type: 'network_statistics', supports_geographic_enrichment: false, supports_field_normalization: true, standardization_version: '2.0.0', }, }); this.name = 'get_simple_statistics'; this.description = 'Get network statistics including box status, security metrics, and system health indicators.'; this.category = 'analytics'; } async execute(_args, firewalla) { try { const statsResponse = await withToolTimeout(async () => firewalla.getSimpleStatistics(), this.name); const stats = SafeAccess.safeArrayAccess(statsResponse?.results, (arr) => arr[0], {}); const startTime = Date.now(); const unifiedResponseData = { statistics: { online_boxes: SafeAccess.getNestedValue(stats, 'onlineBoxes', 0), offline_boxes: SafeAccess.getNestedValue(stats, 'offlineBoxes', 0), total_boxes: SafeAccess.getNestedValue(stats, 'onlineBoxes', 0) + SafeAccess.getNestedValue(stats, 'offlineBoxes', 0), total_alarms: SafeAccess.getNestedValue(stats, 'alarms', 0), total_rules: SafeAccess.getNestedValue(stats, 'rules', 0), box_availability: this.calculateBoxAvailability(stats), }, summary: { status: SafeAccess.getNestedValue(stats, 'onlineBoxes', 0) > 0 ? 'operational' : 'offline', health_score: this.calculateHealthScore(stats), active_monitoring: SafeAccess.getNestedValue(stats, 'onlineBoxes', 0) > 0, }, }; const executionTime = Date.now() - startTime; return this.createUnifiedResponse(unifiedResponseData, { executionTimeMs: executionTime, }); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; return this.createErrorResponse(`Failed to get simple statistics: ${errorMessage}`, ErrorType.API_ERROR); } } calculateBoxAvailability(stats) { const onlineBoxes = SafeAccess.getNestedValue(stats, 'onlineBoxes', 0); const offlineBoxes = SafeAccess.getNestedValue(stats, 'offlineBoxes', 0); const totalBoxes = onlineBoxes + offlineBoxes; return totalBoxes > 0 ? Math.round((onlineBoxes / totalBoxes) * 100) : 0; } calculateHealthScore(stats) { let score = 100; const onlineBoxes = SafeAccess.getNestedValue(stats, 'onlineBoxes', 0); const offlineBoxes = SafeAccess.getNestedValue(stats, 'offlineBoxes', 0); const alarms = SafeAccess.getNestedValue(stats, 'alarms', 0); const rules = SafeAccess.getNestedValue(stats, 'rules', 0); const totalBoxes = onlineBoxes + offlineBoxes; if (totalBoxes === 0) { return 0; } // Penalize for offline boxes (up to -40 points) const offlineRatio = offlineBoxes / totalBoxes; score -= Math.round(offlineRatio * 40); // Penalize for high alarm count (up to -30 points) const alarmPenalty = Math.min(alarms * 2, 30); score -= alarmPenalty; // Bonus for having active rules (up to +10 points) const ruleBonus = Math.min(rules / 10, 10); score += ruleBonus; return Math.max(0, Math.min(100, score)); } } export class GetStatisticsByRegionHandler extends BaseToolHandler { constructor() { super({ enableGeoEnrichment: false, // Already contains geographic data enableFieldNormalization: true, additionalMeta: { data_source: 'regional_statistics', entity_type: 'geographic_flow_statistics', supports_geographic_enrichment: false, supports_field_normalization: true, standardization_version: '2.0.0', }, }); this.name = 'get_statistics_by_region'; this.description = 'Get flow statistics grouped by country/region for geographic analysis. No required parameters. Data cached for 1 hour for performance.'; this.category = 'analytics'; } async execute(_args, firewalla) { try { const stats = await withToolTimeout(async () => firewalla.getStatisticsByRegion(), this.name); // Validate response structure with comprehensive null/undefined guards if (!stats || !SafeAccess.getNestedValue(stats, 'results') || !Array.isArray(stats.results)) { return this.createSuccessResponse({ total_regions: 0, regional_statistics: [], top_regions: [], error: 'No regional statistics available - API response missing results array', debug_info: { stats_exists: !!stats, results_exists: !!stats?.results, results_is_array: !!stats?.results && Array.isArray(stats.results), actual_structure: stats ? Object.keys(stats) : 'null', }, }); } // Calculate total flow count for percentage calculations const totalFlowCount = stats.results.reduce((sum, stat) => { return (sum + (typeof SafeAccess.getNestedValue(stat, 'value') === 'number' ? stat.value : 0)); }, 0); // Process regional statistics with defensive programming const regionalStatistics = SafeAccess.safeArrayFilter(stats.results, (stat) => stat && typeof SafeAccess.getNestedValue(stat, 'value') === 'number' && !!SafeAccess.getNestedValue(stat, 'meta')) .map((stat) => ({ country_code: SafeAccess.getNestedValue(stat, 'meta.code', 'unknown'), flow_count: SafeAccess.getNestedValue(stat, 'value', 0), percentage: totalFlowCount > 0 ? Math.round((SafeAccess.getNestedValue(stat, 'value', 0) / totalFlowCount) * 100) : 0, })) .sort((a, b) => b.flow_count - a.flow_count); // Get top 5 regions with defensive programming const topRegions = SafeAccess.safeArrayFilter(stats.results, (stat) => stat && typeof SafeAccess.getNestedValue(stat, 'value') === 'number' && SafeAccess.getNestedValue(stat, 'meta')) .sort((a, b) => SafeAccess.getNestedValue(b, 'value', 0) - SafeAccess.getNestedValue(a, 'value', 0)) .slice(0, 5) .map((stat) => ({ country_code: SafeAccess.getNestedValue(stat, 'meta.code', 'unknown'), flow_count: SafeAccess.getNestedValue(stat, 'value', 0), })); const startTime = Date.now(); const unifiedResponseData = { total_regions: stats.results.length, regional_statistics: regionalStatistics, top_regions: topRegions, total_flow_count: totalFlowCount, }; const executionTime = Date.now() - startTime; return this.createUnifiedResponse(unifiedResponseData, { executionTimeMs: executionTime, }); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; return this.createErrorResponse(`Failed to get statistics by region: ${errorMessage}`, ErrorType.API_ERROR); } } } export class GetStatisticsByBoxHandler extends BaseToolHandler { constructor() { super({ enableGeoEnrichment: false, // No IP fields in box statistics enableFieldNormalization: true, additionalMeta: { data_source: 'box_statistics', entity_type: 'firewalla_box_statistics', supports_geographic_enrichment: false, supports_field_normalization: true, standardization_version: '2.0.0', }, }); this.name = 'get_statistics_by_box'; this.description = 'Get statistics for each Firewalla box with activity scores and health monitoring. No required parameters. Data cached for 1 hour for performance.'; this.category = 'analytics'; } async execute(_args, firewalla) { try { const stats = await withToolTimeout(async () => firewalla.getStatisticsByBox(), this.name); // Validate stats response structure if (!stats || typeof stats !== 'object') { throw new Error('Invalid stats response: not an object'); } if (!SafeAccess.getNestedValue(stats, 'results') || !Array.isArray(stats.results)) { throw new Error('Invalid stats response: results is not an array'); } // Process and validate each box statistic const boxStatistics = SafeAccess.safeArrayMap(stats.results, (stat) => { const boxMeta = SafeAccess.getNestedValue(stat, 'meta', {}); return { box_id: SafeAccess.getNestedValue(boxMeta, 'gid', 'unknown'), name: SafeAccess.getNestedValue(boxMeta, 'name', 'Unknown Box'), model: SafeAccess.getNestedValue(boxMeta, 'model', 'unknown'), status: SafeAccess.getNestedValue(boxMeta, 'online', false) ? 'online' : 'offline', version: SafeAccess.getNestedValue(boxMeta, 'version', 'unknown'), location: SafeAccess.getNestedValue(boxMeta, 'location', 'unknown'), device_count: SafeAccess.getNestedValue(boxMeta, 'deviceCount', 0), rule_count: SafeAccess.getNestedValue(boxMeta, 'ruleCount', 0), alarm_count: SafeAccess.getNestedValue(boxMeta, 'alarmCount', 0), activity_score: SafeAccess.getNestedValue(stat, 'value', 0), last_seen: SafeAccess.getNestedValue(boxMeta, 'lastSeen', 0) ? unixToISOString(SafeAccess.getNestedValue(boxMeta, 'lastSeen', 0)) : 'Never', }; }).sort((a, b) => b.activity_score - a.activity_score); // Calculate summary with safe operations const onlineBoxes = SafeAccess.safeArrayFilter(stats.results, (s) => SafeAccess.getNestedValue(s, 'meta.online', false)).length; const totalDevices = stats.results.reduce((sum, s) => sum + SafeAccess.getNestedValue(s, 'meta.deviceCount', 0), 0); const totalRules = stats.results.reduce((sum, s) => sum + SafeAccess.getNestedValue(s, 'meta.ruleCount', 0), 0); const totalAlarms = stats.results.reduce((sum, s) => sum + SafeAccess.getNestedValue(s, 'meta.alarmCount', 0), 0); const startTime = Date.now(); const unifiedResponseData = { total_boxes: stats.results.length, box_statistics: boxStatistics, summary: { online_boxes: onlineBoxes, total_devices: totalDevices, total_rules: totalRules, total_alarms: totalAlarms, }, }; const executionTime = Date.now() - startTime; return this.createUnifiedResponse(unifiedResponseData, { executionTimeMs: executionTime, }); } catch (error) { logger.error('Error in get_statistics_by_box', error instanceof Error ? error : new Error(String(error))); return this.createErrorResponse(`Failed to get box statistics: ${error instanceof Error ? error.message : 'Unknown error'}`, ErrorType.API_ERROR, { total_boxes: 0, box_statistics: [], summary: { online_boxes: 0, total_devices: 0, total_rules: 0, total_alarms: 0, }, }); } } } export class GetRecentFlowActivityHandler extends BaseToolHandler { constructor() { super({ enableGeoEnrichment: false, // Disabled to stay within token limits enableFieldNormalization: true, additionalMeta: { data_source: 'recent_flow_activity', entity_type: 'current_network_snapshot', supports_geographic_enrichment: false, supports_field_normalization: true, standardization_version: '2.0.0', time_scope: 'recent_activity_only', max_flows: GetRecentFlowActivityHandler.MAX_FLOWS, }, }); this.name = 'get_recent_flow_activity'; this.description = 'Get recent network flow activity snapshot (last 10-20 minutes of traffic). Returns up to 50 most recent flows for immediate analysis. IMPORTANT: This is NOT historical trend data - it shows current activity only. Use for "what\'s happening now" questions, not daily/weekly patterns. Ideal for: current security assessment, immediate network state, recent protocol distribution.'; this.category = 'analytics'; } async execute(_args, firewalla) { try { const startTime = Date.now(); const allFlows = []; let cursor; let pagesProcessed = 0; // Fetch up to 2000 flows in 4 pages of 500 each while (pagesProcessed < GetRecentFlowActivityHandler.MAX_PAGES && allFlows.length < GetRecentFlowActivityHandler.MAX_FLOWS) { const currentCursor = cursor; const pageData = await withToolTimeout(async () => firewalla.getFlowData(undefined, // No query filter - get all recent flows undefined, // No groupBy - we want individual flows 'ts:desc', // Most recent first GetRecentFlowActivityHandler.FLOWS_PER_PAGE, currentCursor), this.name); if (!pageData?.results || !Array.isArray(pageData.results)) { break; // No more data or invalid response } allFlows.push(...pageData.results); cursor = pageData.next_cursor; pagesProcessed++; // Break if no more pages available or we hit our limit if (!cursor || allFlows.length >= GetRecentFlowActivityHandler.MAX_FLOWS) { break; } } // Limit to exactly MAX_FLOWS if we got more const flows = allFlows.slice(0, GetRecentFlowActivityHandler.MAX_FLOWS); if (flows.length === 0) { return this.createUnifiedResponse({ flows_analyzed: 0, time_span_minutes: 0, activity_summary: 'No recent flows found', flows: [], limitations: { data_scope: 'Current activity snapshot only', not_suitable_for: [ 'Historical analysis', 'Daily patterns', 'Trend analysis', ], time_frame: 'Last 10-20 minutes for high-volume networks', }, }, { executionTimeMs: Date.now() - startTime, }); } // Calculate time span of the flows const oldestFlow = flows[flows.length - 1]; const newestFlow = flows[0]; const timeSpanSeconds = (newestFlow.ts || 0) - (oldestFlow.ts || 0); const timeSpanMinutes = Math.round(timeSpanSeconds / 60); // Analyze the flows for summary statistics const protocolCounts = new Map(); const regionCounts = new Map(); const blockedCount = flows.filter(f => f.block).length; const allowedCount = flows.length - blockedCount; flows.forEach(flow => { const protocol = flow.protocol || 'unknown'; const region = flow.region || flow.country || 'unknown'; protocolCounts.set(protocol, (protocolCounts.get(protocol) || 0) + 1); regionCounts.set(region, (regionCounts.get(region) || 0) + 1); }); // Convert maps to sorted arrays for top protocols/regions const topProtocols = Array.from(protocolCounts.entries()) .sort((a, b) => b[1] - a[1]) .slice(0, 5) .map(([protocol, count]) => ({ protocol, count, percentage: Math.round((count / flows.length) * 100), })); const topRegions = Array.from(regionCounts.entries()) .sort((a, b) => b[1] - a[1]) .slice(0, 5) .map(([region, count]) => ({ region, count, percentage: Math.round((count / flows.length) * 100), })); const unifiedResponseData = { flows_analyzed: flows.length, pages_fetched: pagesProcessed, time_span_minutes: timeSpanMinutes, data_period: `Last ${timeSpanMinutes} minutes`, activity_summary: { total_flows: flows.length, blocked_flows: blockedCount, allowed_flows: allowedCount, blocked_percentage: Math.round((blockedCount / flows.length) * 100), top_protocols: topProtocols, top_regions: topRegions, }, flows: flows.map(flow => ({ timestamp: flow.ts, timestamp_iso: safeUnixToISOString(flow.ts, 'Never'), protocol: flow.protocol, direction: flow.direction, blocked: flow.block, source_ip: flow.source?.ip, destination_ip: flow.destination?.ip, region: flow.region || flow.country, category: flow.category, domain: flow.domain, bytes: flow.total || 0, block_reason: flow.blockedby, })), limitations: { data_scope: 'Recent activity snapshot only - NOT historical trends', sample_size: `${flows.length} flows from last ${timeSpanMinutes} minutes`, not_suitable_for: [ 'Daily/weekly/monthly analysis', 'Historical trend identification', 'Peak usage time analysis', 'Long-term pattern detection', ], suitable_for: [ 'Current network state assessment', 'Immediate security analysis', 'Recent protocol distribution', 'Active threat detection', 'Real-time activity monitoring', ], performance_note: flows.length >= GetRecentFlowActivityHandler.MAX_FLOWS ? `Limited to ${GetRecentFlowActivityHandler.MAX_FLOWS} flows for performance` : 'All available recent flows included', }, }; const executionTime = Date.now() - startTime; return this.createUnifiedResponse(unifiedResponseData, { executionTimeMs: executionTime, }); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; return this.createErrorResponse(`Failed to get recent flow activity: ${errorMessage}`, ErrorType.API_ERROR, { max_flows: GetRecentFlowActivityHandler.MAX_FLOWS, flows_per_page: GetRecentFlowActivityHandler.FLOWS_PER_PAGE, troubleshooting: 'Check if Firewalla API is accessible and credentials are valid', }); } } } GetRecentFlowActivityHandler.MAX_FLOWS = 50; GetRecentFlowActivityHandler.FLOWS_PER_PAGE = 50; GetRecentFlowActivityHandler.MAX_PAGES = Math.ceil(GetRecentFlowActivityHandler.MAX_FLOWS / GetRecentFlowActivityHandler.FLOWS_PER_PAGE); export class GetFlowInsightsHandler extends BaseToolHandler { constructor() { super({ enableGeoEnrichment: false, // Already contains aggregated data enableFieldNormalization: true, additionalMeta: { data_source: 'flow_insights', entity_type: 'category_flow_analysis', supports_geographic_enrichment: false, supports_field_normalization: true, standardization_version: '2.0.0', }, }); this.name = 'get_flow_insights'; this.description = 'Get category-based flow analysis including top content categories, bandwidth consumers, and blocked traffic. Replaces time-based trends with actionable insights for networks with high flow volumes. Ideal for answering questions like "what porn sites were accessed" or "what social media was used".'; this.category = 'analytics'; } async execute(_args, firewalla) { try { const periodValidation = ParameterValidator.validateEnum(_args?.period, 'period', ['1h', '24h', '7d', '30d'], false, '24h'); const categoriesValidation = ParameterValidator.validateArray(_args?.categories, 'categories', { required: false, }); // Validate allowed category values if provided const allowedCategories = [ 'ad', 'edu', 'games', 'gamble', 'intel', 'p2p', 'porn', 'private', 'social', 'shopping', 'video', 'vpn', ]; if (categoriesValidation.isValid && categoriesValidation.sanitizedValue) { const categories = categoriesValidation.sanitizedValue; const invalidCategories = categories.filter(cat => !allowedCategories.includes(cat)); if (invalidCategories.length > 0) { categoriesValidation.isValid = false; categoriesValidation.errors = [ `Invalid categories: ${invalidCategories.join(', ')}`, ]; } } const includeBlockedValidation = ParameterValidator.validateBoolean(_args?.include_blocked, 'include_blocked', false); const validationResult = ParameterValidator.combineValidationResults([ periodValidation, categoriesValidation, includeBlockedValidation, ]); if (!validationResult.isValid) { return this.createErrorResponse('Parameter validation failed', ErrorType.VALIDATION_ERROR, undefined, validationResult.errors); } const period = periodValidation.sanitizedValue; const categories = categoriesValidation.sanitizedValue; const includeBlocked = includeBlockedValidation.sanitizedValue; const startTime = Date.now(); const insights = await withToolTimeout(async () => firewalla.getFlowInsights(period, { categories, includeBlocked, }), this.name); // Format response for better readability const unifiedResponseData = { period, analysis_time: getCurrentTimestamp(), // Category breakdown with human-readable formatting content_categories: insights.categoryBreakdown.map(cat => ({ category: cat.category, flow_count: cat.count, total_bytes: cat.bytes, total_mb: Math.round((cat.bytes / 1048576) * 100) / 100, top_domains: cat.topDomains.map(dom => ({ domain: dom.domain, visits: dom.count, bandwidth_mb: Math.round((dom.bytes / 1048576) * 100) / 100, })), })), // Top bandwidth consumers top_bandwidth_devices: insights.topDevices.map(dev => ({ device: dev.device, total_bandwidth_mb: Math.round((dev.totalBytes / 1048576) * 100) / 100, category_usage: dev.categories.map(cat => ({ category: cat.category, bandwidth_mb: Math.round((cat.bytes / 1048576) * 100) / 100, })), })), // Blocked traffic summary if requested ...(insights.blockedSummary && { blocked_traffic: { total_blocked_flows: insights.blockedSummary.totalBlocked, blocked_by_category: insights.blockedSummary.byCategory, }, }), // Summary statistics summary: { total_categories: insights.categoryBreakdown.length, total_bandwidth_gb: Math.round((insights.categoryBreakdown.reduce((sum, cat) => sum + cat.bytes, 0) / 1073741824) * 100) / 100, most_active_category: insights.categoryBreakdown[0]?.category || 'none', top_bandwidth_consumer: insights.topDevices[0]?.device || 'none', }, }; const executionTime = Date.now() - startTime; return this.createUnifiedResponse(unifiedResponseData, { executionTimeMs: executionTime, }); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; return this.createErrorResponse(`Failed to get flow insights: ${errorMessage}`, ErrorType.API_ERROR, { period: _args?.period || '24h', categories: _args?.categories || 'all', troubleshooting: 'Check if Firewalla API is accessible and flow data is available', }); } } } export class GetAlarmTrendsHandler extends BaseToolHandler { constructor() { super({ enableGeoEnrichment: false, // No IP fields in alarm trends enableFieldNormalization: true, additionalMeta: { data_source: 'alarm_trends', entity_type: 'historical_alarm_data', supports_geographic_enrichment: false, supports_field_normalization: true, standardization_version: '2.0.0', }, }); this.name = 'get_alarm_trends'; this.description = 'Get historical alarm data trends over time with configurable periods. Optional period parameter. Data cached for 1 hour for performance.'; this.category = 'analytics'; } async execute(_args, firewalla) { try { const periodValidation = ParameterValidator.validateEnum(_args?.period, 'period', ['1h', '24h', '7d', '30d'], false, '24h'); if (!periodValidation.isValid) { return this.createErrorResponse('Parameter validation failed', ErrorType.VALIDATION_ERROR, undefined, periodValidation.errors); } const period = periodValidation.sanitizedValue; const trends = await withToolTimeout(async () => firewalla.getAlarmTrends(period), this.name); // Defensive programming: validate trends response structure if (!trends || !SafeAccess.getNestedValue(trends, 'results') || !Array.isArray(trends.results)) { return this.createSuccessResponse({ period, data_points: 0, trends: [], summary: { total_alarms: 0, avg_alarms_per_interval: 0, peak_alarm_count: 0, intervals_with_alarms: 0, alarm_frequency: 0, }, error: 'Invalid alarm trends data received', }); } // Validate individual trend entries const validTrends = SafeAccess.safeArrayFilter(trends.results, (trend) => trend && typeof SafeAccess.getNestedValue(trend, 'ts') === 'number' && typeof SafeAccess.getNestedValue(trend, 'value') === 'number' && SafeAccess.getNestedValue(trend, 'ts', 0) > 0 && SafeAccess.getNestedValue(trend, 'value', 0) >= 0); const startTime = Date.now(); const unifiedResponseData = { period, data_points: validTrends.length, trends: SafeAccess.safeArrayMap(validTrends, (trend) => ({ timestamp: SafeAccess.getNestedValue(trend, 'ts', 0), timestamp_iso: unixToISOString(SafeAccess.getNestedValue(trend, 'ts', 0)), alarm_count: SafeAccess.getNestedValue(trend, 'value', 0), })), summary: { total_alarms: validTrends.reduce((sum, t) => sum + SafeAccess.getNestedValue(t, 'value', 0), 0), avg_alarms_per_interval: validTrends.length > 0 ? Math.round((validTrends.reduce((sum, t) => sum + SafeAccess.getNestedValue(t, 'value', 0), 0) / validTrends.length) * 100) / 100 : 0, // Performance Buffer Strategy: Same defensive slicing as flow trends // to prevent call stack overflow with large alarm trend datasets peak_alarm_count: validTrends.length > 0 ? Math.max(...validTrends .slice(0, 1000) // Defensive limit to prevent call stack overflow .map((t) => SafeAccess.getNestedValue(t, 'value', 0))) : 0, intervals_with_alarms: SafeAccess.safeArrayFilter(validTrends, (t) => SafeAccess.getNestedValue(t, 'value', 0) > 0).length, alarm_frequency: validTrends.length > 0 ? Math.round((SafeAccess.safeArrayFilter(validTrends, (t) => SafeAccess.getNestedValue(t, 'value', 0) > 0).length / validTrends.length) * 100) : 0, }, }; const executionTime = Date.now() - startTime; return this.createUnifiedResponse(unifiedResponseData, { executionTimeMs: executionTime, }); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; return this.createErrorResponse(`Failed to get alarm trends: ${errorMessage}`, ErrorType.API_ERROR); } } } export class GetRuleTrendsHandler extends BaseToolHandler { constructor() { super({ enableGeoEnrichment: false, // No IP fields in rule trends enableFieldNormalization: true, additionalMeta: { data_source: 'rule_trends', entity_type: 'historical_rule_data', supports_geographic_enrichment: false, supports_field_normalization: true, standardization_version: '2.0.0', }, }); this.name = 'get_rule_trends'; this.description = 'Get historical rule activity trends over time with configurable periods. Optional period parameter. Data cached for 1 hour for performance.'; this.category = 'analytics'; } async execute(_args, firewalla) { try { const periodValidation = ParameterValidator.validateEnum(_args?.period, 'period', ['1h', '24h', '7d', '30d'], false, '24h'); if (!periodValidation.isValid) { return this.createErrorResponse('Parameter validation failed', ErrorType.VALIDATION_ERROR, undefined, periodValidation.errors); } const period = periodValidation.sanitizedValue; const trends = await withToolTimeout(async () => firewalla.getRuleTrends(period), this.name); // Validate trends response structure if (!trends || typeof trends !== 'object') { throw new Error('Invalid trends response: not an object'); } if (!SafeAccess.getNestedValue(trends, 'results') || !Array.isArray(trends.results)) { throw new Error('Invalid trends response: results is not an array'); } // Validate each trend item has required properties const validTrends = SafeAccess.safeArrayFilter(trends.results, (trend) => trend && typeof SafeAccess.getNestedValue(trend, 'ts') === 'number' && typeof SafeAccess.getNestedValue(trend, 'value') === 'number'); const startTime = Date.now(); const unifiedResponseData = { period, data_points: validTrends.length, trends: SafeAccess.safeArrayMap(validTrends, (trend) => ({ timestamp: SafeAccess.getNestedValue(trend, 'ts', 0), timestamp_iso: unixToISOString(SafeAccess.getNestedValue(trend, 'ts', 0)), active_rule_count: SafeAccess.getNestedValue(trend, 'value', 0), })), summary: { avg_active_rules: validTrends.length > 0 ? Math.round(validTrends.reduce((sum, t) => sum + SafeAccess.getNestedValue(t, 'value', 0), 0) / validTrends.length) : 0, max_active_rules: validTrends.length > 0 ? Math.max(...validTrends.map((t) => SafeAccess.getNestedValue(t, 'value', 0))) : 0, min_active_rules: validTrends.length > 0 ? Math.min(...validTrends.map((t) => SafeAccess.getNestedValue(t, 'value', 0))) : 0, rule_stability: this.calculateRuleStability(validTrends), }, }; const executionTime = Date.now() - startTime; return this.createUnifiedResponse(unifiedResponseData, { executionTimeMs: executionTime, }); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; return this.createErrorResponse(`Failed to get rule trends: ${errorMessage}`, ErrorType.API_ERROR, { period: _args?.period || '24h', troubleshooting: 'Check if Firewalla API is accessible and firewall rules are available', }); } } calculateRuleStability(trends) { if (trends.length < 2) { return 100; } const values = trends.map(t => SafeAccess.getNestedValue(t, 'value', 0)); const avgValue = values.reduce((sum, val) => sum + val, 0) / values.length; if (avgValue === 0) { return 100; } const variation = values.reduce((sum, val, i) => { return i > 0 ? sum + Math.abs(val - values[i - 1]) : sum; }, 0) / (values.length - 1); const variationPercent = variation / avgValue; return Math.max(0, Math.min(100, Math.round((1 - variationPercent) * 100))); } } //# sourceMappingURL=analytics.js.map