diff --git a/CLAUDE.md b/CLAUDE.md index c23c082da..c3307a470 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -623,3 +623,63 @@ docker exec awf-squid cat /var/log/squid/access.log - SNI is captured via CONNECT method for HTTPS (no SSL inspection) - iptables logs go to kernel buffer (view with `dmesg`) - PID not directly available (UID can be used for correlation) + +## Log Analysis Commands + +The CLI includes built-in commands for aggregating and summarizing firewall logs. + +### Commands + +**`awf logs stats`** - Show aggregated statistics from firewall logs +- Default format: `pretty` (colorized terminal output) +- Outputs: total requests, allowed/denied counts, unique domains, per-domain breakdown + +**`awf logs summary`** - Generate summary report (optimized for GitHub Actions) +- Default format: `markdown` (GitHub-flavored markdown) +- Designed for piping directly to `$GITHUB_STEP_SUMMARY` + +### Output Formats + +Both commands support `--format `: +- `pretty` - Colorized terminal output with percentages and aligned columns +- `markdown` - GitHub-flavored markdown with collapsible details section +- `json` - Structured JSON for programmatic consumption + +### Key Files + +- `src/logs/log-aggregator.ts` - Aggregation logic (`aggregateLogs()`, `loadAllLogs()`, `loadAndAggregate()`) +- `src/logs/stats-formatter.ts` - Format output (`formatStatsJson()`, `formatStatsMarkdown()`, `formatStatsPretty()`) +- `src/commands/logs-stats.ts` - Stats command handler +- `src/commands/logs-summary.ts` - Summary command handler + +### Data Structures + +```typescript +// Per-domain statistics +interface DomainStats { + domain: string; + allowed: number; + denied: number; + total: number; +} + +// Aggregated statistics +interface AggregatedStats { + totalRequests: number; + allowedRequests: number; + deniedRequests: number; + uniqueDomains: number; + byDomain: Map; + timeRange: { start: number; end: number } | null; +} +``` + +### GitHub Actions Usage + +```yaml +- name: Generate firewall summary + if: always() + run: awf logs summary >> $GITHUB_STEP_SUMMARY +``` + +This replaces 150+ lines of custom JavaScript parsing with a single command. diff --git a/src/cli.ts b/src/cli.ts index 9bcbad72d..92f0c9e24 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -642,10 +642,24 @@ program } }); +/** + * Validates that a format string is one of the allowed values + * + * @param format - Format string to validate + * @param validFormats - Array of valid format options + * @throws Exits process with error if format is invalid + */ +function validateFormat(format: string, validFormats: string[]): void { + if (!validFormats.includes(format)) { + logger.error(`Invalid format: ${format}. Must be one of: ${validFormats.join(', ')}`); + process.exit(1); + } +} + // Logs subcommand - view Squid proxy logs -program +const logsCmd = program .command('logs') - .description('View Squid proxy logs from current or previous runs') + .description('View and analyze Squid proxy logs from current or previous runs') .option('-f, --follow', 'Follow log output in real-time (like tail -f)', false) .option( '--format ', @@ -657,10 +671,7 @@ program .action(async (options) => { // Validate format option const validFormats: OutputFormat[] = ['raw', 'pretty', 'json']; - if (!validFormats.includes(options.format)) { - logger.error(`Invalid format: ${options.format}. Must be one of: ${validFormats.join(', ')}`); - process.exit(1); - } + validateFormat(options.format, validFormats); // Dynamic import to avoid circular dependencies const { logsCommand } = await import('./commands/logs'); @@ -672,6 +683,53 @@ program }); }); +// Logs stats subcommand - show aggregated statistics +logsCmd + .command('stats') + .description('Show aggregated statistics from firewall logs') + .option( + '--format ', + 'Output format: json, markdown, pretty', + 'pretty' + ) + .option('--source ', 'Path to log directory or "running" for live container') + .action(async (options) => { + // Validate format option + const validFormats = ['json', 'markdown', 'pretty']; + if (!validFormats.includes(options.format)) { + logger.error(`Invalid format: ${options.format}. Must be one of: ${validFormats.join(', ')}`); + process.exit(1); + } + + const { statsCommand } = await import('./commands/logs-stats'); + await statsCommand({ + format: options.format as 'json' | 'markdown' | 'pretty', + source: options.source, + }); + }); + +// Logs summary subcommand - generate summary report (optimized for GitHub Actions) +logsCmd + .command('summary') + .description('Generate summary report (defaults to markdown for GitHub Actions)') + .option( + '--format ', + 'Output format: json, markdown, pretty', + 'markdown' + ) + .option('--source ', 'Path to log directory or "running" for live container') + .action(async (options) => { + // Validate format option + const validFormats = ['json', 'markdown', 'pretty']; + validateFormat(options.format, validFormats); + + const { summaryCommand } = await import('./commands/logs-summary'); + await summaryCommand({ + format: options.format as 'json' | 'markdown' | 'pretty', + source: options.source, + }); + }); + // Only parse arguments if this file is run directly (not imported as a module) if (require.main === module) { program.parse(); diff --git a/src/commands/logs-command-helpers.ts b/src/commands/logs-command-helpers.ts new file mode 100644 index 000000000..7a9b74c35 --- /dev/null +++ b/src/commands/logs-command-helpers.ts @@ -0,0 +1,97 @@ +/** + * Shared helper functions for log commands (stats and summary) + */ + +import { logger } from '../logger'; +import type { LogSource } from '../types'; +import { + discoverLogSources, + selectMostRecent, + validateSource, +} from '../logs/log-discovery'; +import { loadAndAggregate } from '../logs/log-aggregator'; +import type { AggregatedStats } from '../logs/log-aggregator'; + +/** + * Options for determining which logs to show (based on log level) + */ +export interface LoggingOptions { + /** The output format being used */ + format: string; + /** Callback to determine if info logs should be shown */ + shouldLog: (format: string) => boolean; +} + +/** + * Discovers and selects a log source based on user input or auto-discovery. + * Handles validation, error messages, and optional logging. + * + * @param sourceOption - User-specified source path or "running", or undefined for auto-discovery + * @param loggingOptions - Options controlling when to emit log messages + * @returns Selected log source + */ +export async function discoverAndSelectSource( + sourceOption: string | undefined, + loggingOptions: LoggingOptions +): Promise { + // Discover log sources + const sources = await discoverLogSources(); + + // Determine which source to use + let source: LogSource; + + if (sourceOption) { + // User specified a source + try { + source = await validateSource(sourceOption); + logger.debug(`Using specified source: ${sourceOption}`); + } catch (error) { + logger.error( + `Invalid log source: ${error instanceof Error ? error.message : error}` + ); + process.exit(1); + } + } else if (sources.length === 0) { + logger.error('No log sources found. Run awf with a command first to generate logs.'); + process.exit(1); + } else { + // Select most recent source + const selected = selectMostRecent(sources); + if (!selected) { + logger.error('No log sources found.'); + process.exit(1); + } + source = selected; + + // Log which source we're using (conditionally based on format) + if (loggingOptions.shouldLog(loggingOptions.format)) { + if (source.type === 'running') { + logger.info(`Using live logs from running container: ${source.containerName}`); + } else { + logger.info(`Using preserved logs from: ${source.path}`); + if (source.dateStr) { + logger.info(`Log timestamp: ${source.dateStr}`); + } + } + } + } + + return source; +} + +/** + * Loads and aggregates logs from a source, handling errors gracefully. + * + * @param source - Log source to load from + * @returns Aggregated statistics + */ +export async function loadLogsWithErrorHandling( + source: LogSource +): Promise { + try { + return await loadAndAggregate(source); + } catch (error) { + logger.error(`Failed to load logs: ${error instanceof Error ? error.message : error}`); + process.exit(1); + } +} diff --git a/src/commands/logs-stats.test.ts b/src/commands/logs-stats.test.ts new file mode 100644 index 000000000..52fa7779e --- /dev/null +++ b/src/commands/logs-stats.test.ts @@ -0,0 +1,184 @@ +/** + * Tests for logs-stats command + */ + +import { statsCommand, StatsCommandOptions } from './logs-stats'; +import * as logDiscovery from '../logs/log-discovery'; +import * as logAggregator from '../logs/log-aggregator'; +import * as statsFormatter from '../logs/stats-formatter'; +import { LogSource } from '../types'; + +// Mock dependencies +jest.mock('../logs/log-discovery'); +jest.mock('../logs/log-aggregator'); +jest.mock('../logs/stats-formatter'); +jest.mock('../logger', () => ({ + logger: { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, +})); + +const mockedDiscovery = logDiscovery as jest.Mocked; +const mockedAggregator = logAggregator as jest.Mocked; +const mockedFormatter = statsFormatter as jest.Mocked; + +describe('logs-stats command', () => { + let mockExit: jest.SpyInstance; + let mockConsoleLog: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + mockExit = jest.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + mockConsoleLog = jest.spyOn(console, 'log').mockImplementation(); + }); + + afterEach(() => { + mockExit.mockRestore(); + mockConsoleLog.mockRestore(); + }); + + it('should discover and use most recent log source', async () => { + const mockSource: LogSource = { + type: 'preserved', + path: '/tmp/squid-logs-123', + timestamp: Date.now(), + dateStr: new Date().toLocaleString(), + }; + + mockedDiscovery.discoverLogSources.mockResolvedValue([mockSource]); + mockedDiscovery.selectMostRecent.mockReturnValue(mockSource); + mockedAggregator.loadAndAggregate.mockResolvedValue({ + totalRequests: 10, + allowedRequests: 8, + deniedRequests: 2, + uniqueDomains: 3, + byDomain: new Map(), + timeRange: { start: 1000, end: 2000 }, + }); + mockedFormatter.formatStats.mockReturnValue('formatted output'); + + const options: StatsCommandOptions = { + format: 'pretty', + }; + + await statsCommand(options); + + expect(mockedDiscovery.discoverLogSources).toHaveBeenCalled(); + expect(mockedDiscovery.selectMostRecent).toHaveBeenCalled(); + expect(mockedAggregator.loadAndAggregate).toHaveBeenCalledWith(mockSource); + expect(mockedFormatter.formatStats).toHaveBeenCalled(); + expect(mockConsoleLog).toHaveBeenCalledWith('formatted output'); + }); + + it('should use specified source when provided', async () => { + const mockSource: LogSource = { + type: 'preserved', + path: '/custom/path', + }; + + mockedDiscovery.discoverLogSources.mockResolvedValue([]); + mockedDiscovery.validateSource.mockResolvedValue(mockSource); + mockedAggregator.loadAndAggregate.mockResolvedValue({ + totalRequests: 5, + allowedRequests: 5, + deniedRequests: 0, + uniqueDomains: 2, + byDomain: new Map(), + timeRange: null, + }); + mockedFormatter.formatStats.mockReturnValue('formatted'); + + const options: StatsCommandOptions = { + format: 'json', + source: '/custom/path', + }; + + await statsCommand(options); + + expect(mockedDiscovery.validateSource).toHaveBeenCalledWith('/custom/path'); + expect(mockedAggregator.loadAndAggregate).toHaveBeenCalledWith(mockSource); + }); + + it('should exit with error if no sources found', async () => { + mockedDiscovery.discoverLogSources.mockResolvedValue([]); + + const options: StatsCommandOptions = { + format: 'pretty', + }; + + await expect(statsCommand(options)).rejects.toThrow('process.exit called'); + expect(mockExit).toHaveBeenCalledWith(1); + }); + + it('should exit with error if specified source is invalid', async () => { + mockedDiscovery.discoverLogSources.mockResolvedValue([]); + mockedDiscovery.validateSource.mockRejectedValue(new Error('Source not found')); + + const options: StatsCommandOptions = { + format: 'pretty', + source: '/invalid/path', + }; + + await expect(statsCommand(options)).rejects.toThrow('process.exit called'); + expect(mockExit).toHaveBeenCalledWith(1); + }); + + it('should pass correct format to formatter', async () => { + const mockSource: LogSource = { type: 'running', containerName: 'awf-squid' }; + + mockedDiscovery.discoverLogSources.mockResolvedValue([mockSource]); + mockedDiscovery.selectMostRecent.mockReturnValue(mockSource); + mockedAggregator.loadAndAggregate.mockResolvedValue({ + totalRequests: 0, + allowedRequests: 0, + deniedRequests: 0, + uniqueDomains: 0, + byDomain: new Map(), + timeRange: null, + }); + mockedFormatter.formatStats.mockReturnValue('{}'); + + await statsCommand({ format: 'json' }); + expect(mockedFormatter.formatStats).toHaveBeenCalledWith( + expect.anything(), + 'json', + expect.any(Boolean) + ); + + mockedFormatter.formatStats.mockClear(); + await statsCommand({ format: 'markdown' }); + expect(mockedFormatter.formatStats).toHaveBeenCalledWith( + expect.anything(), + 'markdown', + expect.any(Boolean) + ); + + mockedFormatter.formatStats.mockClear(); + await statsCommand({ format: 'pretty' }); + expect(mockedFormatter.formatStats).toHaveBeenCalledWith( + expect.anything(), + 'pretty', + expect.any(Boolean) + ); + }); + + it('should handle aggregation errors gracefully', async () => { + const mockSource: LogSource = { type: 'running', containerName: 'awf-squid' }; + + mockedDiscovery.discoverLogSources.mockResolvedValue([mockSource]); + mockedDiscovery.selectMostRecent.mockReturnValue(mockSource); + mockedAggregator.loadAndAggregate.mockRejectedValue(new Error('Failed to load')); + + const options: StatsCommandOptions = { + format: 'pretty', + }; + + await expect(statsCommand(options)).rejects.toThrow('process.exit called'); + expect(mockExit).toHaveBeenCalledWith(1); + }); +}); diff --git a/src/commands/logs-stats.ts b/src/commands/logs-stats.ts new file mode 100644 index 000000000..71f745e8f --- /dev/null +++ b/src/commands/logs-stats.ts @@ -0,0 +1,50 @@ +/** + * Command handler for `awf logs stats` subcommand + */ + +import type { LogStatsFormat } from '../types'; +import { formatStats } from '../logs/stats-formatter'; +import { + discoverAndSelectSource, + loadLogsWithErrorHandling, +} from './logs-command-helpers'; + +/** + * Output format type for stats command (alias for shared type) + */ +export type StatsFormat = LogStatsFormat; + +/** + * Options for the stats command + */ +export interface StatsCommandOptions { + /** Output format: json, markdown, pretty */ + format: StatsFormat; + /** Specific path to log directory or "running" for live container */ + source?: string; +} + +/** + * Main handler for the `awf logs stats` subcommand + * + * Loads logs from the specified source (or auto-discovered source), + * aggregates statistics, and outputs in the requested format. + * + * @param options - Command options + */ +export async function statsCommand(options: StatsCommandOptions): Promise { + // Discover and select log source + // For stats command: show info logs for all non-JSON formats + const source = await discoverAndSelectSource(options.source, { + format: options.format, + shouldLog: (format) => format !== 'json', + }); + + // Load and aggregate logs + const stats = await loadLogsWithErrorHandling(source); + + // Format and output + const colorize = !!(process.stdout.isTTY && options.format === 'pretty'); + const output = formatStats(stats, options.format, colorize); + console.log(output); +} diff --git a/src/commands/logs-summary.test.ts b/src/commands/logs-summary.test.ts new file mode 100644 index 000000000..850272e99 --- /dev/null +++ b/src/commands/logs-summary.test.ts @@ -0,0 +1,212 @@ +/** + * Tests for logs-summary command + */ + +import { summaryCommand, SummaryCommandOptions } from './logs-summary'; +import * as logDiscovery from '../logs/log-discovery'; +import * as logAggregator from '../logs/log-aggregator'; +import * as statsFormatter from '../logs/stats-formatter'; +import { LogSource } from '../types'; + +// Mock dependencies +jest.mock('../logs/log-discovery'); +jest.mock('../logs/log-aggregator'); +jest.mock('../logs/stats-formatter'); +jest.mock('../logger', () => ({ + logger: { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, +})); + +const mockedDiscovery = logDiscovery as jest.Mocked; +const mockedAggregator = logAggregator as jest.Mocked; +const mockedFormatter = statsFormatter as jest.Mocked; + +describe('logs-summary command', () => { + let mockExit: jest.SpyInstance; + let mockConsoleLog: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + mockExit = jest.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + mockConsoleLog = jest.spyOn(console, 'log').mockImplementation(); + }); + + afterEach(() => { + mockExit.mockRestore(); + mockConsoleLog.mockRestore(); + }); + + it('should discover and use most recent log source', async () => { + const mockSource: LogSource = { + type: 'preserved', + path: '/tmp/squid-logs-123', + timestamp: Date.now(), + dateStr: new Date().toLocaleString(), + }; + + mockedDiscovery.discoverLogSources.mockResolvedValue([mockSource]); + mockedDiscovery.selectMostRecent.mockReturnValue(mockSource); + mockedAggregator.loadAndAggregate.mockResolvedValue({ + totalRequests: 10, + allowedRequests: 8, + deniedRequests: 2, + uniqueDomains: 3, + byDomain: new Map(), + timeRange: { start: 1000, end: 2000 }, + }); + mockedFormatter.formatStats.mockReturnValue('markdown summary'); + + const options: SummaryCommandOptions = { + format: 'markdown', + }; + + await summaryCommand(options); + + expect(mockedDiscovery.discoverLogSources).toHaveBeenCalled(); + expect(mockedDiscovery.selectMostRecent).toHaveBeenCalled(); + expect(mockedAggregator.loadAndAggregate).toHaveBeenCalledWith(mockSource); + expect(mockedFormatter.formatStats).toHaveBeenCalled(); + expect(mockConsoleLog).toHaveBeenCalledWith('markdown summary'); + }); + + it('should default to markdown format', async () => { + const mockSource: LogSource = { type: 'running', containerName: 'awf-squid' }; + + mockedDiscovery.discoverLogSources.mockResolvedValue([mockSource]); + mockedDiscovery.selectMostRecent.mockReturnValue(mockSource); + mockedAggregator.loadAndAggregate.mockResolvedValue({ + totalRequests: 0, + allowedRequests: 0, + deniedRequests: 0, + uniqueDomains: 0, + byDomain: new Map(), + timeRange: null, + }); + mockedFormatter.formatStats.mockReturnValue('### Summary'); + + // Note: default format is 'markdown' for summary command + await summaryCommand({ format: 'markdown' }); + + expect(mockedFormatter.formatStats).toHaveBeenCalledWith( + expect.anything(), + 'markdown', + expect.any(Boolean) + ); + }); + + it('should use specified source when provided', async () => { + const mockSource: LogSource = { + type: 'preserved', + path: '/custom/path', + }; + + mockedDiscovery.discoverLogSources.mockResolvedValue([]); + mockedDiscovery.validateSource.mockResolvedValue(mockSource); + mockedAggregator.loadAndAggregate.mockResolvedValue({ + totalRequests: 5, + allowedRequests: 5, + deniedRequests: 0, + uniqueDomains: 2, + byDomain: new Map(), + timeRange: null, + }); + mockedFormatter.formatStats.mockReturnValue('formatted'); + + const options: SummaryCommandOptions = { + format: 'markdown', + source: '/custom/path', + }; + + await summaryCommand(options); + + expect(mockedDiscovery.validateSource).toHaveBeenCalledWith('/custom/path'); + expect(mockedAggregator.loadAndAggregate).toHaveBeenCalledWith(mockSource); + }); + + it('should exit with error if no sources found', async () => { + mockedDiscovery.discoverLogSources.mockResolvedValue([]); + + const options: SummaryCommandOptions = { + format: 'markdown', + }; + + await expect(summaryCommand(options)).rejects.toThrow('process.exit called'); + expect(mockExit).toHaveBeenCalledWith(1); + }); + + it('should exit with error if specified source is invalid', async () => { + mockedDiscovery.discoverLogSources.mockResolvedValue([]); + mockedDiscovery.validateSource.mockRejectedValue(new Error('Source not found')); + + const options: SummaryCommandOptions = { + format: 'markdown', + source: '/invalid/path', + }; + + await expect(summaryCommand(options)).rejects.toThrow('process.exit called'); + expect(mockExit).toHaveBeenCalledWith(1); + }); + + it('should support all output formats', async () => { + const mockSource: LogSource = { type: 'running', containerName: 'awf-squid' }; + + mockedDiscovery.discoverLogSources.mockResolvedValue([mockSource]); + mockedDiscovery.selectMostRecent.mockReturnValue(mockSource); + mockedAggregator.loadAndAggregate.mockResolvedValue({ + totalRequests: 0, + allowedRequests: 0, + deniedRequests: 0, + uniqueDomains: 0, + byDomain: new Map(), + timeRange: null, + }); + mockedFormatter.formatStats.mockReturnValue('output'); + + // Test JSON format + await summaryCommand({ format: 'json' }); + expect(mockedFormatter.formatStats).toHaveBeenCalledWith( + expect.anything(), + 'json', + expect.any(Boolean) + ); + + // Test markdown format + mockedFormatter.formatStats.mockClear(); + await summaryCommand({ format: 'markdown' }); + expect(mockedFormatter.formatStats).toHaveBeenCalledWith( + expect.anything(), + 'markdown', + expect.any(Boolean) + ); + + // Test pretty format + mockedFormatter.formatStats.mockClear(); + await summaryCommand({ format: 'pretty' }); + expect(mockedFormatter.formatStats).toHaveBeenCalledWith( + expect.anything(), + 'pretty', + expect.any(Boolean) + ); + }); + + it('should handle aggregation errors gracefully', async () => { + const mockSource: LogSource = { type: 'running', containerName: 'awf-squid' }; + + mockedDiscovery.discoverLogSources.mockResolvedValue([mockSource]); + mockedDiscovery.selectMostRecent.mockReturnValue(mockSource); + mockedAggregator.loadAndAggregate.mockRejectedValue(new Error('Failed to load')); + + const options: SummaryCommandOptions = { + format: 'markdown', + }; + + await expect(summaryCommand(options)).rejects.toThrow('process.exit called'); + expect(mockExit).toHaveBeenCalledWith(1); + }); +}); diff --git a/src/commands/logs-summary.ts b/src/commands/logs-summary.ts new file mode 100644 index 000000000..36116a61a --- /dev/null +++ b/src/commands/logs-summary.ts @@ -0,0 +1,61 @@ +/** + * Command handler for `awf logs summary` subcommand + * + * This command is designed specifically for generating GitHub Actions step summaries. + * It defaults to markdown output format for easy piping to $GITHUB_STEP_SUMMARY. + */ + +import type { LogStatsFormat } from '../types'; +import { formatStats } from '../logs/stats-formatter'; +import { + discoverAndSelectSource, + loadLogsWithErrorHandling, +} from './logs-command-helpers'; + +/** + * Output format type for summary command (alias for shared type) + */ +export type SummaryFormat = LogStatsFormat; + +/** + * Options for the summary command + */ +export interface SummaryCommandOptions { + /** Output format: json, markdown, pretty (default: markdown) */ + format: SummaryFormat; + /** Specific path to log directory or "running" for live container */ + source?: string; +} + +/** + * Main handler for the `awf logs summary` subcommand + * + * Loads logs from the specified source (or auto-discovered source), + * aggregates statistics, and outputs a summary in the requested format. + * + * Designed for GitHub Actions: + * ```bash + * awf logs summary >> $GITHUB_STEP_SUMMARY + * ``` + * + * @param options - Command options + */ +export async function summaryCommand(options: SummaryCommandOptions): Promise { + // Discover and select log source + // For summary command: only show info logs in pretty format + // This differs intentionally from `logs-stats` which logs for all non-JSON formats. + // The stricter approach here keeps markdown output (the default, intended for + // GitHub Actions step summaries) free of extra lines that would pollute $GITHUB_STEP_SUMMARY. + const source = await discoverAndSelectSource(options.source, { + format: options.format, + shouldLog: (format) => format === 'pretty', + }); + + // Load and aggregate logs + const stats = await loadLogsWithErrorHandling(source); + + // Format and output + const colorize = !!(process.stdout.isTTY && options.format === 'pretty'); + const output = formatStats(stats, options.format, colorize); + console.log(output); +} diff --git a/src/logs/index.ts b/src/logs/index.ts index 3f89e04ab..f7458b91f 100644 --- a/src/logs/index.ts +++ b/src/logs/index.ts @@ -12,3 +12,16 @@ export { listLogSources, } from './log-discovery'; export { streamLogs, StreamOptions } from './log-streamer'; +export { + aggregateLogs, + loadAllLogs, + loadAndAggregate, + AggregatedStats, + DomainStats, +} from './log-aggregator'; +export { + formatStats, + formatStatsJson, + formatStatsMarkdown, + formatStatsPretty, +} from './stats-formatter'; diff --git a/src/logs/log-aggregator.test.ts b/src/logs/log-aggregator.test.ts new file mode 100644 index 000000000..13c63397c --- /dev/null +++ b/src/logs/log-aggregator.test.ts @@ -0,0 +1,258 @@ +/** + * Tests for log-aggregator module + */ + +import { aggregateLogs, loadAllLogs, loadAndAggregate } from './log-aggregator'; +import { ParsedLogEntry, LogSource } from '../types'; +import execa from 'execa'; +import * as fs from 'fs'; + +// Mock dependencies +jest.mock('execa'); +jest.mock('fs'); +jest.mock('../logger', () => ({ + logger: { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, +})); + +const mockedExeca = execa as jest.MockedFunction; +const mockedFs = fs as jest.Mocked; + +describe('log-aggregator', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('aggregateLogs', () => { + it('should return empty stats for empty array', () => { + const stats = aggregateLogs([]); + + expect(stats.totalRequests).toBe(0); + expect(stats.allowedRequests).toBe(0); + expect(stats.deniedRequests).toBe(0); + expect(stats.uniqueDomains).toBe(0); + expect(stats.byDomain.size).toBe(0); + expect(stats.timeRange).toBeNull(); + }); + + it('should count allowed and denied requests correctly', () => { + const entries: ParsedLogEntry[] = [ + createLogEntry({ domain: 'github.com', isAllowed: true }), + createLogEntry({ domain: 'github.com', isAllowed: true }), + createLogEntry({ domain: 'evil.com', isAllowed: false }), + ]; + + const stats = aggregateLogs(entries); + + expect(stats.totalRequests).toBe(3); + expect(stats.allowedRequests).toBe(2); + expect(stats.deniedRequests).toBe(1); + }); + + it('should group by domain correctly', () => { + const entries: ParsedLogEntry[] = [ + createLogEntry({ domain: 'github.com', isAllowed: true }), + createLogEntry({ domain: 'github.com', isAllowed: true }), + createLogEntry({ domain: 'github.com', isAllowed: false }), + createLogEntry({ domain: 'npmjs.org', isAllowed: true }), + ]; + + const stats = aggregateLogs(entries); + + expect(stats.uniqueDomains).toBe(2); + expect(stats.byDomain.get('github.com')).toEqual({ + domain: 'github.com', + allowed: 2, + denied: 1, + total: 3, + }); + expect(stats.byDomain.get('npmjs.org')).toEqual({ + domain: 'npmjs.org', + allowed: 1, + denied: 0, + total: 1, + }); + }); + + it('should calculate time range correctly', () => { + const entries: ParsedLogEntry[] = [ + createLogEntry({ timestamp: 1000.5 }), + createLogEntry({ timestamp: 2000.5 }), + createLogEntry({ timestamp: 1500.5 }), + ]; + + const stats = aggregateLogs(entries); + + expect(stats.timeRange).toEqual({ + start: 1000.5, + end: 2000.5, + }); + }); + + it('should handle entries with missing domain', () => { + const entries: ParsedLogEntry[] = [ + createLogEntry({ domain: '-', isAllowed: true }), + createLogEntry({ domain: 'github.com', isAllowed: true }), + ]; + + const stats = aggregateLogs(entries); + + expect(stats.uniqueDomains).toBe(2); + expect(stats.byDomain.has('-')).toBe(true); + expect(stats.byDomain.has('github.com')).toBe(true); + }); + }); + + describe('loadAllLogs', () => { + it('should load logs from a running container', async () => { + const mockLogContent = [ + '1761074374.646 172.30.0.20:39748 api.github.com:443 140.82.114.22:443 1.1 CONNECT 200 TCP_TUNNEL:HIER_DIRECT api.github.com:443 "-"', + '1761074375.123 172.30.0.20:39749 evil.com:443 -:- 1.1 CONNECT 403 TCP_DENIED:HIER_NONE evil.com:443 "curl/7.81.0"', + ].join('\n'); + + mockedExeca.mockResolvedValue({ + stdout: mockLogContent, + stderr: '', + exitCode: 0, + } as never); + + const source: LogSource = { + type: 'running', + containerName: 'awf-squid', + }; + + const entries = await loadAllLogs(source); + + expect(entries).toHaveLength(2); + expect(entries[0].domain).toBe('api.github.com'); + expect(entries[0].isAllowed).toBe(true); + expect(entries[1].domain).toBe('evil.com'); + expect(entries[1].isAllowed).toBe(false); + }); + + it('should load logs from a file', async () => { + const mockLogContent = [ + '1761074374.646 172.30.0.20:39748 api.github.com:443 140.82.114.22:443 1.1 CONNECT 200 TCP_TUNNEL:HIER_DIRECT api.github.com:443 "-"', + ].join('\n'); + + mockedFs.existsSync.mockReturnValue(true); + mockedFs.readFileSync.mockReturnValue(mockLogContent); + + const source: LogSource = { + type: 'preserved', + path: '/tmp/squid-logs-123', + }; + + const entries = await loadAllLogs(source); + + expect(entries).toHaveLength(1); + expect(entries[0].domain).toBe('api.github.com'); + expect(mockedFs.readFileSync).toHaveBeenCalledWith( + '/tmp/squid-logs-123/access.log', + 'utf-8' + ); + }); + + it('should return empty array if file does not exist', async () => { + mockedFs.existsSync.mockReturnValue(false); + + const source: LogSource = { + type: 'preserved', + path: '/tmp/squid-logs-missing', + }; + + const entries = await loadAllLogs(source); + + expect(entries).toHaveLength(0); + }); + + it('should return empty array if container command fails', async () => { + mockedExeca.mockRejectedValue(new Error('Container not found')); + + const source: LogSource = { + type: 'running', + containerName: 'awf-squid', + }; + + const entries = await loadAllLogs(source); + + expect(entries).toHaveLength(0); + }); + + it('should skip unparseable lines', async () => { + const mockLogContent = [ + '1761074374.646 172.30.0.20:39748 api.github.com:443 140.82.114.22:443 1.1 CONNECT 200 TCP_TUNNEL:HIER_DIRECT api.github.com:443 "-"', + 'invalid line that cannot be parsed', + '', + '1761074375.123 172.30.0.20:39749 npmjs.org:443 104.16.0.0:443 1.1 CONNECT 200 TCP_TUNNEL:HIER_DIRECT npmjs.org:443 "-"', + ].join('\n'); + + mockedFs.existsSync.mockReturnValue(true); + mockedFs.readFileSync.mockReturnValue(mockLogContent); + + const source: LogSource = { + type: 'preserved', + path: '/tmp/squid-logs-123', + }; + + const entries = await loadAllLogs(source); + + expect(entries).toHaveLength(2); + expect(entries[0].domain).toBe('api.github.com'); + expect(entries[1].domain).toBe('npmjs.org'); + }); + }); + + describe('loadAndAggregate', () => { + it('should load and aggregate logs in one call', async () => { + const mockLogContent = [ + '1761074374.646 172.30.0.20:39748 api.github.com:443 140.82.114.22:443 1.1 CONNECT 200 TCP_TUNNEL:HIER_DIRECT api.github.com:443 "-"', + '1761074375.123 172.30.0.20:39749 api.github.com:443 140.82.114.22:443 1.1 CONNECT 200 TCP_TUNNEL:HIER_DIRECT api.github.com:443 "-"', + '1761074376.456 172.30.0.20:39750 evil.com:443 -:- 1.1 CONNECT 403 TCP_DENIED:HIER_NONE evil.com:443 "curl/7.81.0"', + ].join('\n'); + + mockedFs.existsSync.mockReturnValue(true); + mockedFs.readFileSync.mockReturnValue(mockLogContent); + + const source: LogSource = { + type: 'preserved', + path: '/tmp/squid-logs-123', + }; + + const stats = await loadAndAggregate(source); + + expect(stats.totalRequests).toBe(3); + expect(stats.allowedRequests).toBe(2); + expect(stats.deniedRequests).toBe(1); + expect(stats.uniqueDomains).toBe(2); + }); + }); +}); + +/** + * Helper function to create a mock ParsedLogEntry with default values + */ +function createLogEntry(overrides: Partial = {}): ParsedLogEntry { + return { + timestamp: 1761074374.646, + clientIp: '172.30.0.20', + clientPort: '39748', + host: 'api.github.com:443', + destIp: '140.82.114.22', + destPort: '443', + protocol: '1.1', + method: 'CONNECT', + statusCode: 200, + decision: 'TCP_TUNNEL:HIER_DIRECT', + url: 'api.github.com:443', + userAgent: '-', + domain: 'api.github.com', + isAllowed: true, + isHttps: true, + ...overrides, + }; +} diff --git a/src/logs/log-aggregator.ts b/src/logs/log-aggregator.ts new file mode 100644 index 000000000..ad578d313 --- /dev/null +++ b/src/logs/log-aggregator.ts @@ -0,0 +1,180 @@ +/** + * Log aggregation module for computing statistics from parsed log entries + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import execa from 'execa'; +import { LogSource, ParsedLogEntry } from '../types'; +import { parseLogLine } from './log-parser'; +import { logger } from '../logger'; + +/** + * Statistics for a single domain + */ +export interface DomainStats { + /** Domain name */ + domain: string; + /** Number of allowed requests */ + allowed: number; + /** Number of denied requests */ + denied: number; + /** Total number of requests */ + total: number; +} + +/** + * Aggregated statistics from log entries + */ +export interface AggregatedStats { + /** Total number of requests */ + totalRequests: number; + /** Number of allowed requests */ + allowedRequests: number; + /** Number of denied requests */ + deniedRequests: number; + /** Number of unique domains */ + uniqueDomains: number; + /** Statistics grouped by domain */ + byDomain: Map; + /** Time range of the logs (null if no entries) */ + timeRange: { start: number; end: number } | null; +} + +/** + * Aggregates parsed log entries into statistics + * + * @param entries - Array of parsed log entries + * @returns Aggregated statistics + */ +export function aggregateLogs(entries: ParsedLogEntry[]): AggregatedStats { + const byDomain = new Map(); + let allowedRequests = 0; + let deniedRequests = 0; + let minTimestamp = Infinity; + let maxTimestamp = -Infinity; + + for (const entry of entries) { + // Track time range + if (entry.timestamp < minTimestamp) { + minTimestamp = entry.timestamp; + } + if (entry.timestamp > maxTimestamp) { + maxTimestamp = entry.timestamp; + } + + // Count allowed/denied + if (entry.isAllowed) { + allowedRequests++; + } else { + deniedRequests++; + } + + // Group by domain + const domain = entry.domain || '-'; + let domainStats = byDomain.get(domain); + if (!domainStats) { + domainStats = { + domain, + allowed: 0, + denied: 0, + total: 0, + }; + byDomain.set(domain, domainStats); + } + + domainStats.total++; + if (entry.isAllowed) { + domainStats.allowed++; + } else { + domainStats.denied++; + } + } + + const totalRequests = entries.length; + const uniqueDomains = byDomain.size; + const timeRange = + entries.length > 0 ? { start: minTimestamp, end: maxTimestamp } : null; + + return { + totalRequests, + allowedRequests, + deniedRequests, + uniqueDomains, + byDomain, + timeRange, + }; +} + +/** + * Loads all log entries from a source + * + * @param source - Log source (running container or preserved file) + * @returns Array of parsed log entries + */ +export async function loadAllLogs(source: LogSource): Promise { + let content: string; + + if (source.type === 'running') { + // Read from running container + if (!source.containerName) { + throw new Error('Container name is required for running log source'); + } + logger.debug(`Loading logs from container: ${source.containerName}`); + try { + const result = await execa('docker', [ + 'exec', + source.containerName, + 'cat', + '/var/log/squid/access.log', + ]); + content = result.stdout; + } catch (error) { + logger.debug(`Failed to read from container: ${error}`); + return []; + } + } else { + // Read from file + if (!source.path) { + throw new Error('Path is required for preserved log source'); + } + const filePath = path.join(source.path, 'access.log'); + logger.debug(`Loading logs from file: ${filePath}`); + + if (!fs.existsSync(filePath)) { + logger.debug(`Log file not found: ${filePath}`); + return []; + } + + content = fs.readFileSync(filePath, 'utf-8'); + } + + // Parse all lines + const entries: ParsedLogEntry[] = []; + const lines = content.split('\n'); + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + + const entry = parseLogLine(trimmed); + if (entry) { + entries.push(entry); + } else { + logger.debug(`Failed to parse log line: ${trimmed}`); + } + } + + return entries; +} + +/** + * Loads logs from a source and aggregates them into statistics + * + * @param source - Log source + * @returns Aggregated statistics + */ +export async function loadAndAggregate(source: LogSource): Promise { + const entries = await loadAllLogs(source); + return aggregateLogs(entries); +} diff --git a/src/logs/stats-formatter.test.ts b/src/logs/stats-formatter.test.ts new file mode 100644 index 000000000..b84de8374 --- /dev/null +++ b/src/logs/stats-formatter.test.ts @@ -0,0 +1,248 @@ +/** + * Tests for stats-formatter module + */ + +import { + formatStatsJson, + formatStatsMarkdown, + formatStatsPretty, + formatStats, +} from './stats-formatter'; +import { AggregatedStats, DomainStats } from './log-aggregator'; + +describe('stats-formatter', () => { + describe('formatStatsJson', () => { + it('should format empty stats as JSON', () => { + const stats = createEmptyStats(); + const output = formatStatsJson(stats); + const parsed = JSON.parse(output); + + expect(parsed.totalRequests).toBe(0); + expect(parsed.allowedRequests).toBe(0); + expect(parsed.deniedRequests).toBe(0); + expect(parsed.uniqueDomains).toBe(0); + expect(parsed.timeRange).toBeNull(); + expect(parsed.byDomain).toEqual({}); + }); + + it('should format stats with domains as JSON', () => { + const stats = createSampleStats(); + const output = formatStatsJson(stats); + const parsed = JSON.parse(output); + + expect(parsed.totalRequests).toBe(10); + expect(parsed.allowedRequests).toBe(8); + expect(parsed.deniedRequests).toBe(2); + expect(parsed.uniqueDomains).toBe(2); + expect(parsed.byDomain['github.com']).toEqual({ + allowed: 5, + denied: 0, + total: 5, + }); + expect(parsed.byDomain['evil.com']).toEqual({ + allowed: 3, + denied: 2, + total: 5, + }); + }); + + it('should include time range in JSON output', () => { + const stats = createSampleStats(); + const output = formatStatsJson(stats); + const parsed = JSON.parse(output); + + expect(parsed.timeRange).toEqual({ + start: 1000, + end: 2000, + }); + }); + }); + + describe('formatStatsMarkdown', () => { + it('should format empty stats as markdown', () => { + const stats = createEmptyStats(); + const output = formatStatsMarkdown(stats); + + expect(output).toContain('### Firewall Activity'); + expect(output).toContain('0 requests'); + expect(output).toContain('0 allowed'); + expect(output).toContain('0 blocked'); + expect(output).toContain('0 unique domains'); + }); + + it('should format stats with domains as markdown', () => { + const stats = createSampleStats(); + const output = formatStatsMarkdown(stats); + + expect(output).toContain('### Firewall Activity'); + expect(output).toContain('10 requests'); + expect(output).toContain('8 allowed'); + expect(output).toContain('2 blocked'); + expect(output).toContain('2 unique domains'); + expect(output).toContain('| Domain | Allowed | Denied |'); + expect(output).toContain('| github.com |'); + expect(output).toContain('| evil.com |'); + }); + + it('should use collapsible details section', () => { + const stats = createSampleStats(); + const output = formatStatsMarkdown(stats); + + expect(output).toContain('
'); + expect(output).toContain(''); + expect(output).toContain(''); + expect(output).toContain('
'); + }); + + it('should filter out "-" domain from table', () => { + const stats = createEmptyStats(); + stats.byDomain.set('-', { + domain: '-', + allowed: 1, + denied: 0, + total: 1, + }); + stats.byDomain.set('github.com', { + domain: 'github.com', + allowed: 2, + denied: 0, + total: 2, + }); + stats.totalRequests = 3; + stats.uniqueDomains = 2; + + const output = formatStatsMarkdown(stats); + + expect(output).toContain('github.com'); + expect(output).not.toContain('| - |'); + }); + + it('should handle singular/plural correctly', () => { + const singleRequestStats = createEmptyStats(); + singleRequestStats.totalRequests = 1; + singleRequestStats.uniqueDomains = 1; + + const output = formatStatsMarkdown(singleRequestStats); + + expect(output).toContain('1 request |'); + expect(output).toContain('1 unique domain'); + }); + }); + + describe('formatStatsPretty', () => { + it('should format empty stats for terminal', () => { + const stats = createEmptyStats(); + const output = formatStatsPretty(stats, false); + + expect(output).toContain('Firewall Statistics'); + expect(output).toContain('Total Requests: 0'); + expect(output).toContain('Unique Domains: 0'); + }); + + it('should format stats with percentages', () => { + const stats = createSampleStats(); + const output = formatStatsPretty(stats, false); + + expect(output).toContain('Total Requests: 10'); + expect(output).toContain('Allowed: 8 (80.0%)'); + expect(output).toContain('Denied: 2 (20.0%)'); + }); + + it('should include domain breakdown', () => { + const stats = createSampleStats(); + const output = formatStatsPretty(stats, false); + + expect(output).toContain('Domains:'); + expect(output).toContain('github.com'); + expect(output).toContain('5 allowed'); + expect(output).toContain('evil.com'); + expect(output).toContain('2 denied'); + }); + + it('should include time range when available', () => { + const stats = createSampleStats(); + const output = formatStatsPretty(stats, false); + + expect(output).toContain('Time Range:'); + }); + + it('should work with colorize enabled', () => { + const stats = createSampleStats(); + // Just verify it doesn't throw with colorize enabled + const output = formatStatsPretty(stats, true); + expect(output).toBeTruthy(); + }); + }); + + describe('formatStats', () => { + it('should route to JSON formatter', () => { + const stats = createSampleStats(); + const output = formatStats(stats, 'json'); + + expect(() => JSON.parse(output)).not.toThrow(); + }); + + it('should route to markdown formatter', () => { + const stats = createSampleStats(); + const output = formatStats(stats, 'markdown'); + + expect(output).toContain('### Firewall Activity'); + }); + + it('should route to pretty formatter', () => { + const stats = createSampleStats(); + const output = formatStats(stats, 'pretty'); + + expect(output).toContain('Firewall Statistics'); + }); + + it('should default to pretty format', () => { + const stats = createSampleStats(); + const output = formatStats(stats, 'pretty'); + + expect(output).toContain('Firewall Statistics'); + }); + }); +}); + +/** + * Helper function to create empty stats + */ +function createEmptyStats(): AggregatedStats { + return { + totalRequests: 0, + allowedRequests: 0, + deniedRequests: 0, + uniqueDomains: 0, + byDomain: new Map(), + timeRange: null, + }; +} + +/** + * Helper function to create sample stats with data + */ +function createSampleStats(): AggregatedStats { + const byDomain = new Map(); + byDomain.set('github.com', { + domain: 'github.com', + allowed: 5, + denied: 0, + total: 5, + }); + byDomain.set('evil.com', { + domain: 'evil.com', + allowed: 3, + denied: 2, + total: 5, + }); + + return { + totalRequests: 10, + allowedRequests: 8, + deniedRequests: 2, + uniqueDomains: 2, + byDomain, + timeRange: { start: 1000, end: 2000 }, + }; +} diff --git a/src/logs/stats-formatter.ts b/src/logs/stats-formatter.ts new file mode 100644 index 000000000..8fb5ffca3 --- /dev/null +++ b/src/logs/stats-formatter.ts @@ -0,0 +1,197 @@ +/** + * Formatter for log statistics output in various formats + */ + +import chalk from 'chalk'; +import { AggregatedStats, DomainStats } from './log-aggregator'; + +/** + * Formats aggregated stats as JSON + * + * @param stats - Aggregated statistics + * @returns JSON string + */ +export function formatStatsJson(stats: AggregatedStats): string { + // Convert Map to object for JSON serialization + const byDomain: Record> = {}; + for (const [domain, domainStats] of stats.byDomain) { + byDomain[domain] = { + allowed: domainStats.allowed, + denied: domainStats.denied, + total: domainStats.total, + }; + } + + const output = { + totalRequests: stats.totalRequests, + allowedRequests: stats.allowedRequests, + deniedRequests: stats.deniedRequests, + uniqueDomains: stats.uniqueDomains, + timeRange: stats.timeRange, + byDomain, + }; + + return JSON.stringify(output, null, 2); +} + +/** + * Formats aggregated stats as markdown (suitable for GitHub Actions step summary) + * + * @param stats - Aggregated statistics + * @returns Markdown string + */ +export function formatStatsMarkdown(stats: AggregatedStats): string { + const lines: string[] = []; + + lines.push('### Firewall Activity\n'); + + // Summary line + const requestWord = stats.totalRequests === 1 ? 'request' : 'requests'; + const domainWord = stats.uniqueDomains === 1 ? 'domain' : 'domains'; + + // Filter out "-" domain for valid domain count + const validDomains = Array.from(stats.byDomain.values()).filter(d => d.domain !== '-'); + const validDomainCount = validDomains.length; + + // Show both counts if there are invalid domains + const domainCountText = + validDomainCount === stats.uniqueDomains + ? `${stats.uniqueDomains} unique ${domainWord}` + : `${stats.uniqueDomains} unique ${domainWord} (${validDomainCount} valid)`; + + lines.push('
'); + lines.push( + `${stats.totalRequests} ${requestWord} | ` + + `${stats.allowedRequests} allowed | ` + + `${stats.deniedRequests} blocked | ` + + `${domainCountText}\n` + ); + + // Domain breakdown table + if (stats.uniqueDomains > 0) { + // Sort domains: by total requests descending + const sortedDomains = validDomains.sort((a, b) => b.total - a.total); + + if (sortedDomains.length > 0) { + lines.push('| Domain | Allowed | Denied |'); + lines.push('|--------|---------|--------|'); + + for (const domainStats of sortedDomains) { + lines.push( + `| ${domainStats.domain} | ${domainStats.allowed} | ${domainStats.denied} |` + ); + } + } else { + lines.push('No valid domain activity detected.'); + } + } else { + lines.push('No firewall activity detected.'); + } + + lines.push('\n
\n'); + + return lines.join('\n'); +} + +/** + * Formats aggregated stats for terminal display (with colors) + * + * @param stats - Aggregated statistics + * @param colorize - Whether to use colors (default: true) + * @returns Formatted string + */ +export function formatStatsPretty( + stats: AggregatedStats, + colorize: boolean = true +): string { + const lines: string[] = []; + + // Helper for conditional coloring - use Proxy for clean no-op fallback + const c = colorize + ? chalk + : (new Proxy({}, { get: () => (s: string) => s }) as typeof chalk); + + lines.push(c.bold('Firewall Statistics')); + lines.push(c.gray('─'.repeat(40))); + lines.push(''); + + // Overall stats + const allowedPct = + stats.totalRequests > 0 + ? ((stats.allowedRequests / stats.totalRequests) * 100).toFixed(1) + : '0.0'; + const deniedPct = + stats.totalRequests > 0 + ? ((stats.deniedRequests / stats.totalRequests) * 100).toFixed(1) + : '0.0'; + + lines.push(`Total Requests: ${stats.totalRequests}`); + lines.push( + `Allowed: ${c.green(String(stats.allowedRequests))} (${allowedPct}%)` + ); + lines.push( + `Denied: ${c.red(String(stats.deniedRequests))} (${deniedPct}%)` + ); + lines.push(`Unique Domains: ${stats.uniqueDomains}`); + + // Time range if available + if (stats.timeRange) { + const startDate = new Date(stats.timeRange.start * 1000); + const endDate = new Date(stats.timeRange.end * 1000); + lines.push(''); + lines.push(c.gray(`Time Range: ${startDate.toISOString()} - ${endDate.toISOString()}`)); + } + + // Domain breakdown + if (stats.uniqueDomains > 0) { + lines.push(''); + lines.push(c.bold('Domains:')); + + // Sort by total requests descending, filter out "-" + const sortedDomains = Array.from(stats.byDomain.values()) + .filter(d => d.domain !== '-') + .sort((a, b) => b.total - a.total); + + // Calculate max domain length for alignment (guard against empty array) + const maxDomainLen = sortedDomains.length > 0 + ? Math.max(...sortedDomains.map(d => d.domain.length)) + : 0; + + for (const domainStats of sortedDomains) { + const padded = domainStats.domain.padEnd(maxDomainLen + 2); + const allowedStr = c.green(`${domainStats.allowed} allowed`); + const deniedStr = + domainStats.denied > 0 + ? c.red(`${domainStats.denied} denied`) + : c.gray(`${domainStats.denied} denied`); + lines.push(` ${padded}${allowedStr}, ${deniedStr}`); + } + } + + lines.push(''); + return lines.join('\n'); +} + +/** + * Formats aggregated stats based on the specified format + * + * @param stats - Aggregated statistics + * @param format - Output format (json, markdown, pretty) + * @param colorize - Whether to use colors for pretty format + * @returns Formatted string + */ +export function formatStats( + stats: AggregatedStats, + format: 'json' | 'markdown' | 'pretty', + colorize: boolean = true +): string { + switch (format) { + case 'json': + return formatStatsJson(stats); + case 'markdown': + return formatStatsMarkdown(stats); + case 'pretty': + default: + return formatStatsPretty(stats, colorize); + } +} diff --git a/src/types.ts b/src/types.ts index 8bf6af97b..35e9b89e3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -741,6 +741,11 @@ export interface ParsedLogEntry { */ export type OutputFormat = 'raw' | 'pretty' | 'json'; +/** + * Output format for log stats and summary commands + */ +export type LogStatsFormat = 'json' | 'markdown' | 'pretty'; + /** * Source of log data (running container or preserved log files) */