Skip to content

Commit

Permalink
Merge pull request #25 from gfargo/feat/responsive-table-output
Browse files Browse the repository at this point in the history
Enhance Table Display with Dynamic Column Widths
  • Loading branch information
gfargo authored Nov 30, 2024
2 parents ab13688 + 4552d76 commit 4b950cb
Show file tree
Hide file tree
Showing 8 changed files with 211 additions and 10 deletions.
8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@
"@types/yargs": "^17.0.32",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"@semantic-release/changelog": "^6.0.3",
"@semantic-release/git": "^10.0.1",
"@semantic-release/github": "^11.0.1",
"@semantic-release/npm": "^12.0.1",
"commitizen": "^4.3.0",
"cz-conventional-changelog": "^3.3.0",
"eslint": "^8.57.0",
Expand All @@ -77,10 +81,6 @@
"typescript": "^5.4.5"
},
"dependencies": {
"@semantic-release/changelog": "^6.0.3",
"@semantic-release/git": "^10.0.1",
"@semantic-release/github": "^11.0.1",
"@semantic-release/npm": "^12.0.1",
"ajv": "^8.17.1",
"chalk": "^5.3.0",
"cli-table3": "^0.6.5",
Expand Down
2 changes: 1 addition & 1 deletion src/lib/services/FirewallService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,7 @@ export class FirewallService {
return true
}
// Rule should match our local config if unchanged
const localRule = config.ips.find((r) => r.id === remoteRule.id)
const localRule = config.ips?.find((r) => r.id === remoteRule.id)
if (!localRule) {
return true
}
Expand Down
9 changes: 8 additions & 1 deletion src/lib/ui/prompt.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import consola from 'consola'

/**
* Wrapper to exit the process if the user presses CTRL+C.
* Prompts the user with a message and options, and returns the user's response.
* If the user cancels the prompt, the process exits with code 0.
*
* @note This is a wrapper to exit the process if the user presses CTRL+C.
*
* @param message - The message to display to the user.
* @param options - The options to provide to the prompt.
* @returns The user's response.
*/
export const prompt: typeof consola.prompt = async (message, options) => {
const response = await consola.prompt(message, options)
Expand Down
99 changes: 99 additions & 0 deletions src/lib/ui/table/calculateDynamicColWidths.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/**
* Calculates dynamic column widths based on available terminal width
*
* @param terminalWidth - Current terminal width in characters
* @param minWidths - Array of minimum widths for each column
* @param maxWidths - Array of maximum widths for each column (null for unlimited)
* @param flexColumns - Array of column indices that can be resized
* @returns Array of calculated column widths
* @throws Error if configuration is invalid
*/
export const calculateDynamicColWidths = (
terminalWidth: number | undefined,
minWidths: number[],
maxWidths: (number | null)[],
flexColumns: number[],
): number[] => {
// Validate inputs
if (!minWidths.length) {
throw new Error('minWidths array cannot be empty')
}

if (flexColumns.some((col) => col >= minWidths.length)) {
throw new Error('flexColumns contains invalid column index')
}

if (minWidths.length !== maxWidths.length) {
throw new Error('minWidths and maxWidths arrays must have the same length')
}

// Validate min/max relationships
minWidths.forEach((min, index) => {
const max = maxWidths[index]
if (max !== undefined && max !== null && min > max) {
throw new Error(`Column ${index} has minimum width (${min}) greater than maximum width (${max})`)
}
})

// Default terminal width if not available
const maxWidth = terminalWidth || 400
const colWidths = [...minWidths]
const totalMinWidth = minWidths.reduce((sum, width) => sum + width, 0)
let remainingWidth = maxWidth - totalMinWidth - 28 // Leave some padding for status and padding

if (remainingWidth > 0 && flexColumns.length > 0) {
// Keep track of which columns can still grow
let availableFlexColumns = flexColumns.filter((colIndex) => {
const maxWidth = maxWidths[colIndex]
return (
maxWidth !== undefined &&
(maxWidth === null || (colWidths[colIndex] !== undefined && colWidths[colIndex] < maxWidth))
)
})

while (remainingWidth > 0 && availableFlexColumns.length > 0) {
const extraWidthPerColumn = Math.floor(remainingWidth / availableFlexColumns.length)
if (extraWidthPerColumn === 0) break

let usedWidth = 0

availableFlexColumns = availableFlexColumns.filter((colIndex) => {
const maxWidth = maxWidths[colIndex]
const currentWidth = colWidths[colIndex]

if (maxWidth === null) {
if (colWidths[colIndex] !== undefined) {
colWidths[colIndex] += extraWidthPerColumn
}
usedWidth += extraWidthPerColumn
return true
} else {
const availableSpace = (maxWidth ?? 0) - (currentWidth ?? 0)
if (availableSpace <= 0) return false

const addedWidth = Math.min(extraWidthPerColumn, availableSpace)
if (colWidths[colIndex] !== undefined) {
colWidths[colIndex] += addedWidth
}
usedWidth += addedWidth
return addedWidth === extraWidthPerColumn
}
})

remainingWidth -= usedWidth
}

// If we still have remaining width and at least one unlimited column,
// add the remainder to the last unlimited column
if (remainingWidth > 0) {
const lastUnlimitedCol = flexColumns.findLast((colIndex) => maxWidths[colIndex] === null)
if (lastUnlimitedCol !== undefined) {
if (lastUnlimitedCol !== undefined && colWidths[lastUnlimitedCol] !== undefined) {
colWidths[lastUnlimitedCol] += remainingWidth
}
}
}
}

return colWidths
}
10 changes: 10 additions & 0 deletions src/lib/ui/table/formatChangeStatus.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
import chalk from 'chalk'
import { RuleChangeStatus } from '.'

/**
* Formats the change status of a rule into a colored symbol string.
*
* @param status - The status of the rule change. Can be one of the following:
* - 'unchanged': No change in the rule.
* - 'modified': The rule has been modified.
* - 'new': A new rule has been added.
* - 'deleted': The rule has been deleted.
* @returns A string representing the formatted status with appropriate color and symbol.
*/
export function formatChangeStatus(status: RuleChangeStatus): string {
switch (status) {
case 'unchanged':
Expand Down
11 changes: 11 additions & 0 deletions src/lib/ui/table/formatIPBlockingRule.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
import chalk from 'chalk'
import { IPBlockingRule } from '../../types/configTypes'

/**
* Formats an IP blocking rule object for display in a table.
*
* @param rule - The IP blocking rule object, which includes an optional change status.
* @returns An object containing the formatted IP blocking rule properties:
* - `id`: The rule's ID or '-' if not provided.
* - `ip`: The IP address associated with the rule.
* - `hostname`: The hostname associated with the rule.
* - `notes`: Any notes associated with the rule or '-' if not provided.
* - `status`: The change status formatted in yellow if provided, otherwise 'deny' in red.
*/
export function formatIPBlockingRule(rule: IPBlockingRule & { changeStatus?: string }) {
return {
id: rule.id || '-',
Expand Down
17 changes: 17 additions & 0 deletions src/lib/ui/table/getRowColor.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,23 @@
import chalk from 'chalk'
import { RuleChangeStatus } from '.'

/**
* Returns a function that applies a color to a given text based on the provided status.
*
* @param {RuleChangeStatus} [status] - The status of the rule change which determines the color.
* @returns {(text: string) => string} - A function that takes a string and returns the colored string.
*
* @example
* const colorize = getRowColor('new');
* console.log(colorize('This is a new rule')); // Outputs green colored text
*
* @remarks
* The possible statuses and their corresponding colors are:
* - 'unchanged': white
* - 'modified': yellow
* - 'new': green
* - 'deleted': red
*/
export function getRowColor(status?: RuleChangeStatus): (text: string) => string {
switch (status) {
default:
Expand Down
65 changes: 61 additions & 4 deletions src/lib/ui/table/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import chalk from 'chalk'
import Table, { HorizontalAlignment } from 'cli-table3'
import { logger } from '../../logger'
import { FirewallConfig } from '../../types/configTypes'
import { calculateDynamicColWidths } from './calculateDynamicColWidths'
import { formatAction } from './formatAction'
import { formatChangeStatus } from './formatChangeStatus'
import { formatConditionGroups } from './formatConditionGroups'
Expand All @@ -17,6 +18,55 @@ export const RULE_STATUS_MAP = {
deleted: 'deleted' as RuleChangeStatus,
}

interface ColumnConfig {
minWidths: number[]
maxWidths: (number | null)[]
flexColumns: number[]
}

/**
* Configuration for table column widths
*/
const RuleTableConfig: ColumnConfig = {
// Minimum widths for each column [id, name, description, status, time, details]
minWidths: [10, 28, 24, 5, 8, 35],
// Maximum widths (null means unlimited)
maxWidths: [15, 36, 26, 8, 12, null],
flexColumns: [0, 1, 2, 5],
}

const IPTableConfig: ColumnConfig = {
// Minimum widths for each column [id, ip, hostname, notes, action]
minWidths: [10, 28, 24, 5, 12],
maxWidths: [20, 32, 26, null, 16],
flexColumns: [0, 1, 3],
}

/**
* Gets calculated table column widths based on terminal width
* @param variant - Table variant ('rules' or 'ip')
* @param terminalWidth - Current terminal width in characters
* @returns Array of calculated column widths
*/
const getTableColWidths = (variant: 'rules' | 'ip', terminalWidth: number | undefined): number[] => {
if (variant === 'ip') {
return calculateDynamicColWidths(
terminalWidth,
IPTableConfig.minWidths,
IPTableConfig.maxWidths,
IPTableConfig.flexColumns,
)
}

// Default to rules table config
return calculateDynamicColWidths(
terminalWidth,
RuleTableConfig.minWidths,
RuleTableConfig.maxWidths,
RuleTableConfig.flexColumns,
)
}

export type Rule = FirewallConfig['rules'][number]
interface RuleWithChangeStatus extends Rule {
changeStatus: RuleChangeStatus
Expand All @@ -36,17 +86,21 @@ export function displayRulesTable(
]
const tableColAligns = ['left', 'left', 'left', 'center', 'center', 'left'] as HorizontalAlignment[]

const currentTerminalWidth = process.stdout.columns || 400
const colWidths = getTableColWidths('rules', currentTerminalWidth)

if (showStatus) {
tableHead.unshift(chalk.bold.gray('Status'))
tableColAligns.unshift('center')
colWidths.unshift(10)
}

const table = new Table({
head: tableHead,
colAligns: tableColAligns,
wrapOnWordBoundary: true,
wordWrap: true,
truncate: '...',
colWidths: colWidths,
})

rules.forEach((rule) => {
Expand All @@ -72,7 +126,7 @@ export function displayRulesTable(
logger.log(table.toString())
}

export type IPBlockingRuleWithStatus = FirewallConfig['ips'][number] & { changeStatus?: RuleChangeStatus }
export type IPBlockingRuleWithStatus = NonNullable<FirewallConfig['ips']>[number] & { changeStatus?: RuleChangeStatus }

export function displayIPBlockingTable(
rules: IPBlockingRuleWithStatus[],
Expand All @@ -87,17 +141,20 @@ export function displayIPBlockingTable(
]
const tableColAligns = ['left', 'left', 'left', 'left', 'center'] as HorizontalAlignment[]

const currentTerminalWidth = process.stdout.columns || 400
const colWidths = getTableColWidths('ip', currentTerminalWidth)

if (showStatus) {
tableHead.unshift(chalk.bold.gray('Status'))
tableColAligns.unshift('center')
colWidths.unshift(10)
}

const table = new Table({
head: tableHead,
colAligns: tableColAligns,
wrapOnWordBoundary: true,
wordWrap: true,
truncate: '...',
colWidths,
})

rules.forEach((rule) => {
Expand Down

0 comments on commit 4b950cb

Please sign in to comment.