Compare commits

...

5 Commits

Author SHA1 Message Date
f8269a1cb7 feat(cli): add beautiful colored output and fix daemon exit bug
Some checks failed
CI / Type Check & Lint (push) Failing after 6s
CI / Build Test (Current Platform) (push) Successful in 6s
Release / build-and-release (push) Successful in 44s
CI / Build All Platforms (push) Successful in 50s
CI / Type Check & Lint (pull_request) Failing after 5s
CI / Build Test (Current Platform) (pull_request) Successful in 5s
CI / Build All Platforms (pull_request) Successful in 49s
Major improvements:
- Created color theme system (ts/colors.ts) with ANSI colors
- Enhanced logger with colors, table formatting, and styled boxes
- Fixed daemon exit bug - now stays running when no UPS configured
- Added config hot-reload with file watcher for live updates
- Beautified CLI help output with color-coded commands
- Added showcase test demonstrating all output features
- Fixed ANSI code handling for perfect table/box alignment

Features:
- Color-coded messages (success=green, error=red, warning=yellow, info=cyan)
- Status symbols (●○◐◯ for running/stopped/starting/unknown)
- Battery level colors (green>60%, yellow 30-60%, red<30%)
- Table formatting with auto-sizing and column alignment
- Styled boxes (success, error, warning, info styles)
- Hot-reload: daemon watches config file and reloads automatically
- Idle mode: daemon stays alive when no devices, checks periodically

Daemon improvements:
- No longer exits when no UPS devices configured
- Enters idle monitoring loop waiting for config
- File watcher detects config changes in real-time
- Auto-reloads and starts monitoring when devices added
- Logs warnings instead of errors for missing devices

Technical fixes:
- Strip ANSI codes when calculating text width for alignment
- Use visible length for padding calculations in tables and boxes
- Properly handle colored text in table cells and box lines

Breaking changes: None (backward compatible)
2025-10-19 15:08:30 +00:00
b37e1aae6c chore(release): bump version to 4.0.1
All checks were successful
CI / Type Check & Lint (push) Successful in 6s
CI / Build Test (Current Platform) (push) Successful in 5s
Release / build-and-release (push) Successful in 43s
CI / Build All Platforms (push) Successful in 51s
2025-10-19 14:50:39 +00:00
7076829747 fix(install): detect enabled services even when failed/stopped during migration
All checks were successful
CI / Type Check & Lint (push) Successful in 6s
CI / Build Test (Current Platform) (push) Successful in 5s
CI / Build All Platforms (push) Successful in 49s
Previously only checked 'is-active' which missed failed/stopped services.
Now checks 'is-enabled' OR 'is-active' to ensure service file gets updated
during v3→v4 migration regardless of service state.
2025-10-19 14:43:44 +00:00
1387ca262b fix(migration): update systemd service file during v3→v4 migration
All checks were successful
CI / Type Check & Lint (push) Successful in 6s
CI / Build Test (Current Platform) (push) Successful in 5s
CI / Build All Platforms (push) Successful in 47s
Critical fixes for v3→v4 migration:

1. install.sh: Auto-update systemd service file during migration
   - Calls 'nupst service enable' before restarting service
   - Only when migrating from v3 (OLD_NODE_INSTALL=1)
   - Ensures service file has correct v4 paths

2. ts/systemd.ts: Fix hardcoded v3 paths in service template
   - ExecStart: /opt/nupst/bin/nupst daemon-start (v3, broken)
              → /usr/local/bin/nupst service start-daemon (v4, correct)
   - Description: Updated to 'Deno-powered UPS Monitoring Tool'
   - Added RestartSec=10 (prevent rapid restart loops)
   - Removed NODE_ENV=production (not needed for Deno)
   - WorkingDirectory: /tmp → /opt/nupst

Without these fixes:
- Service fails to start after migration
- Service file points to non-existent /opt/nupst/bin/nupst
- Users must manually run 'nupst service enable'

Discovered via Docker migration testing in test/manualdocker/
2025-10-19 14:37:32 +00:00
684f034aee ci(release): auto-delete existing release before creating new one
All checks were successful
CI / Type Check & Lint (push) Successful in 5s
CI / Build Test (Current Platform) (push) Successful in 5s
CI / Build All Platforms (push) Successful in 46s
- Checks if release already exists for the tag
- Automatically deletes conflicting release if found
- Prevents duplicate/stale releases when recreating tags
- Ensures fresh binaries when tag is recreated

This fixes the issue where recreating a tag would keep old
release with outdated binaries.
2025-10-19 14:33:58 +00:00
9 changed files with 769 additions and 72 deletions

View File

@@ -152,6 +152,29 @@ jobs:
echo "Release notes:"
cat /tmp/release_notes.md
- name: Delete existing release if it exists
run: |
VERSION="${{ steps.version.outputs.version }}"
echo "Checking for existing release $VERSION..."
# Try to get existing release by tag
EXISTING_RELEASE_ID=$(curl -s \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
"https://code.foss.global/api/v1/repos/serve.zone/nupst/releases/tags/$VERSION" \
| jq -r '.id // empty')
if [ -n "$EXISTING_RELEASE_ID" ]; then
echo "Found existing release (ID: $EXISTING_RELEASE_ID), deleting..."
curl -X DELETE -s \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
"https://code.foss.global/api/v1/repos/serve.zone/nupst/releases/$EXISTING_RELEASE_ID"
echo "Existing release deleted"
sleep 2
else
echo "No existing release found, proceeding with creation"
fi
- name: Create Gitea Release
run: |
VERSION="${{ steps.version.outputs.version }}"

View File

@@ -1,6 +1,6 @@
{
"name": "@serve.zone/nupst",
"version": "4.0.0",
"version": "4.0.2",
"exports": "./mod.ts",
"tasks": {
"dev": "deno run --allow-all mod.ts",

View File

@@ -243,11 +243,15 @@ if [ -d "$INSTALL_DIR" ]; then
echo "Updating existing installation at $INSTALL_DIR..."
# Check if service is running and stop it
# Check if service exists (enabled or running) and stop it if active
if systemctl is-enabled --quiet nupst 2>/dev/null || systemctl is-active --quiet nupst 2>/dev/null; then
SERVICE_WAS_RUNNING=1
if systemctl is-active --quiet nupst 2>/dev/null; then
echo "Stopping NUPST service..."
systemctl stop nupst
SERVICE_WAS_RUNNING=1
else
echo "Service is installed but not currently running (will be updated)..."
fi
fi
# Clean up old Node.js installation files
@@ -340,6 +344,14 @@ fi
echo ""
# Update systemd service file if migrating from v3
if [ $SERVICE_WAS_RUNNING -eq 1 ] && [ $OLD_NODE_INSTALL -eq 1 ]; then
echo "Updating systemd service file for v4..."
$BINARY_PATH service enable > /dev/null 2>&1
echo "Service file updated."
echo ""
fi
# Restart service if it was running before update
if [ $SERVICE_WAS_RUNNING -eq 1 ]; then
echo "Restarting NUPST service..."

233
test/showcase.ts Normal file
View File

@@ -0,0 +1,233 @@
/**
* Showcase test for NUPST CLI outputs
* Demonstrates all the beautiful colored output features
*
* Run with: deno run --allow-all test/showcase.ts
*/
import { logger, type ITableColumn } from '../ts/logger.ts';
import { theme, symbols, getBatteryColor, formatPowerStatus } from '../ts/colors.ts';
console.log('');
console.log('═'.repeat(80));
logger.highlight('NUPST CLI OUTPUT SHOWCASE');
logger.dim('Demonstrating beautiful, colored terminal output');
console.log('═'.repeat(80));
console.log('');
// === 1. Basic Logging Methods ===
logger.logBoxTitle('Basic Logging Methods', 60, 'info');
logger.logBoxLine('');
logger.log('Normal log message (default color)');
logger.success('Success message with ✓ symbol');
logger.error('Error message with ✗ symbol');
logger.warn('Warning message with ⚠ symbol');
logger.info('Info message with symbol');
logger.dim('Dim/secondary text for less important info');
logger.highlight('Highlighted/bold text for emphasis');
logger.logBoxLine('');
logger.logBoxEnd();
console.log('');
// === 2. Colored Boxes ===
logger.logBoxTitle('Colored Box Styles', 60);
logger.logBoxLine('');
logger.logBoxLine('Boxes can be styled with different colors:');
logger.logBoxEnd();
console.log('');
logger.logBox('Success Box (Green)', [
'Used for successful operations',
'Installation complete, service started, etc.',
], 60, 'success');
console.log('');
logger.logBox('Error Box (Red)', [
'Used for critical errors and failures',
'Configuration errors, connection failures, etc.',
], 60, 'error');
console.log('');
logger.logBox('Warning Box (Yellow)', [
'Used for warnings and deprecations',
'Old command format, missing config, etc.',
], 60, 'warning');
console.log('');
logger.logBox('Info Box (Cyan)', [
'Used for informational messages',
'Version info, update available, etc.',
], 60, 'info');
console.log('');
// === 3. Status Symbols ===
logger.logBoxTitle('Status Symbols', 60, 'info');
logger.logBoxLine('');
logger.logBoxLine(`${symbols.running} Service Running`);
logger.logBoxLine(`${symbols.stopped} Service Stopped`);
logger.logBoxLine(`${symbols.starting} Service Starting`);
logger.logBoxLine(`${symbols.unknown} Status Unknown`);
logger.logBoxLine('');
logger.logBoxLine(`${symbols.success} Operation Successful`);
logger.logBoxLine(`${symbols.error} Operation Failed`);
logger.logBoxLine(`${symbols.warning} Warning Condition`);
logger.logBoxLine(`${symbols.info} Information`);
logger.logBoxLine('');
logger.logBoxEnd();
console.log('');
// === 4. Battery Level Colors ===
logger.logBoxTitle('Battery Level Color Coding', 60, 'info');
logger.logBoxLine('');
logger.logBoxLine('Battery levels are color-coded:');
logger.logBoxLine('');
logger.logBoxLine(` ${getBatteryColor(85)('85%')} - Good (green, ≥60%)`);
logger.logBoxLine(` ${getBatteryColor(45)('45%')} - Medium (yellow, 30-60%)`);
logger.logBoxLine(` ${getBatteryColor(15)('15%')} - Critical (red, <30%)`);
logger.logBoxLine('');
logger.logBoxEnd();
console.log('');
// === 5. Power Status Formatting ===
logger.logBoxTitle('Power Status Formatting', 60, 'info');
logger.logBoxLine('');
logger.logBoxLine(`Status: ${formatPowerStatus('online')}`);
logger.logBoxLine(`Status: ${formatPowerStatus('onBattery')}`);
logger.logBoxLine(`Status: ${formatPowerStatus('unknown')}`);
logger.logBoxLine('');
logger.logBoxEnd();
console.log('');
// === 6. Table Formatting ===
const upsColumns: ITableColumn[] = [
{ header: 'ID', key: 'id' },
{ header: 'Name', key: 'name' },
{ header: 'Host', key: 'host' },
{ header: 'Status', key: 'status', color: (v) => {
if (v.includes('Online')) return theme.success(v);
if (v.includes('Battery')) return theme.warning(v);
return theme.dim(v);
}},
{ header: 'Battery', key: 'battery', align: 'right', color: (v) => {
const pct = parseInt(v);
return getBatteryColor(pct)(v);
}},
{ header: 'Runtime', key: 'runtime', align: 'right' },
];
const upsData = [
{
id: 'ups-1',
name: 'Main UPS',
host: '192.168.1.10',
status: 'Online',
battery: '95%',
runtime: '45 min',
},
{
id: 'ups-2',
name: 'Backup UPS',
host: '192.168.1.11',
status: 'On Battery',
battery: '42%',
runtime: '12 min',
},
{
id: 'ups-3',
name: 'Critical UPS',
host: '192.168.1.12',
status: 'On Battery',
battery: '18%',
runtime: '5 min',
},
];
logger.logTable(upsColumns, upsData, 'UPS Devices');
console.log('');
// === 7. Group Table ===
const groupColumns: ITableColumn[] = [
{ header: 'ID', key: 'id' },
{ header: 'Name', key: 'name' },
{ header: 'Mode', key: 'mode' },
{ header: 'UPS Count', key: 'count', align: 'right' },
];
const groupData = [
{ id: 'dc-1', name: 'Data Center 1', mode: 'redundant', count: '3' },
{ id: 'office', name: 'Office Servers', mode: 'nonRedundant', count: '2' },
];
logger.logTable(groupColumns, groupData, 'UPS Groups');
console.log('');
// === 8. Service Status Example ===
logger.logBoxTitle('Service Status', 70, 'success');
logger.logBoxLine('');
logger.logBoxLine(`Status: ${symbols.running} ${theme.statusActive('Active (Running)')}`);
logger.logBoxLine(`Enabled: ${symbols.success} ${theme.success('Yes')}`);
logger.logBoxLine(`Uptime: 2 days, 5 hours, 23 minutes`);
logger.logBoxLine(`PID: ${theme.dim('12345')}`);
logger.logBoxLine(`Memory: ${theme.dim('45.2 MB')}`);
logger.logBoxLine('');
logger.logBoxEnd();
console.log('');
// === 9. Configuration Example ===
logger.logBoxTitle('Configuration', 70);
logger.logBoxLine('');
logger.logBoxLine(`UPS Devices: ${theme.highlight('3')}`);
logger.logBoxLine(`Groups: ${theme.highlight('2')}`);
logger.logBoxLine(`Check Interval: ${theme.dim('30 seconds')}`);
logger.logBoxLine(`Config File: ${theme.path('/etc/nupst/config.json')}`);
logger.logBoxLine('');
logger.logBoxEnd();
console.log('');
// === 10. Update Available Example ===
logger.logBoxTitle('Update Available', 70, 'warning');
logger.logBoxLine('');
logger.logBoxLine(`Current Version: ${theme.dim('4.0.1')}`);
logger.logBoxLine(`Latest Version: ${theme.highlight('4.0.2')}`);
logger.logBoxLine('');
logger.logBoxLine(`Run ${theme.command('sudo nupst update')} to update`);
logger.logBoxLine('');
logger.logBoxEnd();
console.log('');
// === 11. Error Example ===
logger.logBoxTitle('Error Example', 70, 'error');
logger.logBoxLine('');
logger.logBoxLine(`${symbols.error} Failed to connect to UPS at 192.168.1.10`);
logger.logBoxLine('');
logger.logBoxLine('Possible causes:');
logger.logBoxLine(` ${theme.dim('• UPS is offline or unreachable')}`);
logger.logBoxLine(` ${theme.dim('• Incorrect SNMP community string')}`);
logger.logBoxLine(` ${theme.dim('• Firewall blocking port 161')}`);
logger.logBoxLine('');
logger.logBoxLine(`Try: ${theme.command('nupst ups test --debug')}`);
logger.logBoxLine('');
logger.logBoxEnd();
console.log('');
// === Final Summary ===
console.log('═'.repeat(80));
logger.success('CLI Output Showcase Complete!');
logger.dim('All color and formatting features demonstrated');
console.log('═'.repeat(80));
console.log('');

114
ts/cli.ts
View File

@@ -1,6 +1,7 @@
import { execSync } from 'node:child_process';
import { Nupst } from './nupst.ts';
import { logger } from './logger.ts';
import { theme, symbols } from './colors.ts';
/**
* Class for handling CLI commands
@@ -475,58 +476,83 @@ export class NupstCli {
* Display help message
*/
private showHelp(): void {
logger.log(`
NUPST - UPS Shutdown Tool
console.log('');
logger.highlight('NUPST - UPS Shutdown Tool');
logger.dim('Deno-powered UPS monitoring and shutdown automation');
console.log('');
Usage:
nupst <command> [options]
// Usage section
logger.log(theme.info('Usage:'));
logger.log(` ${theme.command('nupst')} ${theme.dim('<command> [options]')}`);
console.log('');
Commands:
service <subcommand> - Manage systemd service
ups <subcommand> - Manage UPS devices
group <subcommand> - Manage UPS groups
config [show] - Display current configuration
update - Update NUPST from repository (requires root)
uninstall - Completely remove NUPST from system (requires root)
help, --help, -h - Show this help message
--version, -v - Show version information
// Main commands section
logger.log(theme.info('Commands:'));
this.printCommand('service <subcommand>', 'Manage systemd service');
this.printCommand('ups <subcommand>', 'Manage UPS devices');
this.printCommand('group <subcommand>', 'Manage UPS groups');
this.printCommand('config [show]', 'Display current configuration');
this.printCommand('update', 'Update NUPST from repository', theme.dim('(requires root)'));
this.printCommand('uninstall', 'Completely remove NUPST', theme.dim('(requires root)'));
this.printCommand('help, --help, -h', 'Show this help message');
this.printCommand('--version, -v', 'Show version information');
console.log('');
Service Subcommands:
nupst service enable - Install and enable systemd service (requires root)
nupst service disable - Stop and disable systemd service (requires root)
nupst service start - Start the systemd service
nupst service stop - Stop the systemd service
nupst service restart - Restart the systemd service
nupst service status - Show service and UPS status
nupst service logs - Show service logs in real-time
nupst service start-daemon - Start daemon process directly
// Service subcommands
logger.log(theme.info('Service Subcommands:'));
this.printCommand('nupst service enable', 'Install and enable systemd service', theme.dim('(requires root)'));
this.printCommand('nupst service disable', 'Stop and disable systemd service', theme.dim('(requires root)'));
this.printCommand('nupst service start', 'Start the systemd service');
this.printCommand('nupst service stop', 'Stop the systemd service');
this.printCommand('nupst service restart', 'Restart the systemd service');
this.printCommand('nupst service status', 'Show service and UPS status');
this.printCommand('nupst service logs', 'Show service logs in real-time');
this.printCommand('nupst service start-daemon', 'Start daemon process directly');
console.log('');
UPS Subcommands:
nupst ups add - Add a new UPS device
nupst ups edit [id] - Edit a UPS device (default if no ID)
nupst ups remove <id> - Remove a UPS device by ID
nupst ups list (or ls) - List all configured UPS devices
nupst ups test - Test UPS connections
// UPS subcommands
logger.log(theme.info('UPS Subcommands:'));
this.printCommand('nupst ups add', 'Add a new UPS device');
this.printCommand('nupst ups edit [id]', 'Edit a UPS device (default if no ID)');
this.printCommand('nupst ups remove <id>', 'Remove a UPS device by ID');
this.printCommand('nupst ups list (or ls)', 'List all configured UPS devices');
this.printCommand('nupst ups test', 'Test UPS connections');
console.log('');
Group Subcommands:
nupst group add - Add a new UPS group
nupst group edit <id> - Edit an existing UPS group
nupst group remove <id> - Remove a UPS group by ID
nupst group list (or ls) - List all UPS groups
// Group subcommands
logger.log(theme.info('Group Subcommands:'));
this.printCommand('nupst group add', 'Add a new UPS group');
this.printCommand('nupst group edit <id>', 'Edit an existing UPS group');
this.printCommand('nupst group remove <id>', 'Remove a UPS group by ID');
this.printCommand('nupst group list (or ls)', 'List all UPS groups');
console.log('');
Options:
--debug, -d - Enable debug mode for detailed SNMP logging
(Example: nupst ups test --debug)
// Options
logger.log(theme.info('Options:'));
this.printCommand('--debug, -d', 'Enable debug mode for detailed SNMP logging');
logger.dim(' (Example: nupst ups test --debug)');
console.log('');
Examples:
nupst service enable - Install and start the service
nupst ups add - Add a new UPS interactively
nupst group list - Show all configured groups
nupst config - Display current configuration
// Examples
logger.log(theme.info('Examples:'));
logger.dim(' nupst service enable # Install and start the service');
logger.dim(' nupst ups add # Add a new UPS interactively');
logger.dim(' nupst group list # Show all configured groups');
logger.dim(' nupst config # Display current configuration');
console.log('');
Note: Old command format (e.g., 'nupst add') still works but is deprecated.
Use the new format (e.g., 'nupst ups add') going forward.
`);
// Note about deprecated commands
logger.warn('Note: Old command format (e.g., \'nupst add\') still works but is deprecated.');
logger.dim(' Use the new format (e.g., \'nupst ups add\') going forward.');
console.log('');
}
/**
* Helper to print a command with description
*/
private printCommand(command: string, description: string, extra?: string): void {
const paddedCommand = command.padEnd(30);
logger.log(` ${theme.command(paddedCommand)} ${description}${extra ? ' ' + extra : ''}`);
}
/**

89
ts/colors.ts Normal file
View File

@@ -0,0 +1,89 @@
/**
* Color theme and styling utilities for NUPST CLI
* Uses Deno standard library colors module
*/
import * as colors from '@std/fmt/colors';
/**
* Color theme for consistent CLI styling
*/
export const theme = {
// Message types
error: colors.red,
warning: colors.yellow,
success: colors.green,
info: colors.cyan,
dim: colors.dim,
highlight: colors.bold,
bright: colors.bright,
// Status indicators
statusActive: (text: string) => colors.green(colors.bold(text)),
statusInactive: (text: string) => colors.red(text),
statusWarning: (text: string) => colors.yellow(text),
statusUnknown: (text: string) => colors.dim(text),
// Battery level colors
batteryGood: colors.green, // > 60%
batteryMedium: colors.yellow, // 30-60%
batteryCritical: colors.red, // < 30%
// Box borders
borderSuccess: colors.green,
borderError: colors.red,
borderWarning: colors.yellow,
borderInfo: colors.cyan,
borderDefault: (text: string) => text, // No color
// Command/code highlighting
command: colors.cyan,
code: colors.dim,
path: colors.blue,
};
/**
* Status symbols with colors
*/
export const symbols = {
success: colors.green('✓'),
error: colors.red('✗'),
warning: colors.yellow('⚠'),
info: colors.cyan(''),
running: colors.green('●'),
stopped: colors.red('○'),
starting: colors.yellow('◐'),
unknown: colors.dim('◯'),
};
/**
* Get color for battery level
*/
export function getBatteryColor(percentage: number): (text: string) => string {
if (percentage >= 60) return theme.batteryGood;
if (percentage >= 30) return theme.batteryMedium;
return theme.batteryCritical;
}
/**
* Get color for runtime remaining
*/
export function getRuntimeColor(minutes: number): (text: string) => string {
if (minutes >= 20) return theme.batteryGood;
if (minutes >= 10) return theme.batteryMedium;
return theme.batteryCritical;
}
/**
* Format UPS power status with color
*/
export function formatPowerStatus(status: 'online' | 'onBattery' | 'unknown'): string {
switch (status) {
case 'online':
return theme.success('Online');
case 'onBattery':
return theme.warning('On Battery');
case 'unknown':
default:
return theme.dim('Unknown');
}
}

View File

@@ -353,8 +353,9 @@ export class NupstDaemon {
logger.log('Starting UPS monitoring...');
if (!this.config.upsDevices || this.config.upsDevices.length === 0) {
logger.error('No UPS devices found in configuration. Monitoring stopped.');
this.isRunning = false;
logger.warn('No UPS devices found in configuration. Daemon will remain idle...');
// Don't exit - enter idle monitoring mode instead
await this.idleMonitoring();
return;
}
@@ -890,6 +891,133 @@ export class NupstDaemon {
}
}
/**
* Idle monitoring loop when no UPS devices are configured
* Watches for config changes and reloads when detected
*/
private async idleMonitoring(): Promise<void> {
const IDLE_CHECK_INTERVAL = 60000; // Check every 60 seconds
let lastConfigCheck = Date.now();
const CONFIG_CHECK_INTERVAL = 60000; // Check config every minute
logger.log('Entering idle monitoring mode...');
logger.log('Daemon will check for config changes every 60 seconds');
// Start file watcher for hot-reload
this.watchConfigFile();
while (this.isRunning) {
try {
const currentTime = Date.now();
// Periodically check if config has been updated
if (currentTime - lastConfigCheck >= CONFIG_CHECK_INTERVAL) {
try {
// Try to load config
const newConfig = await this.loadConfig();
// Check if we now have UPS devices configured
if (newConfig.upsDevices && newConfig.upsDevices.length > 0) {
logger.success('Configuration updated! UPS devices found. Starting monitoring...');
this.initializeUpsStatus();
// Exit idle mode and start monitoring
await this.monitor();
return;
}
} catch (error) {
// Config still doesn't exist or invalid, continue waiting
}
lastConfigCheck = currentTime;
}
await this.sleep(IDLE_CHECK_INTERVAL);
} catch (error) {
logger.error(
`Error during idle monitoring: ${error instanceof Error ? error.message : String(error)}`,
);
await this.sleep(IDLE_CHECK_INTERVAL);
}
}
logger.log('Idle monitoring stopped');
}
/**
* Watch config file for changes and reload automatically
*/
private watchConfigFile(): void {
try {
// Use Deno's file watcher to monitor config file
const configDir = path.dirname(this.CONFIG_PATH);
// Spawn a background watcher (non-blocking)
(async () => {
try {
const watcher = Deno.watchFs(configDir);
logger.log('Config file watcher started');
for await (const event of watcher) {
// Only respond to modify events on the config file
if (
event.kind === 'modify' &&
event.paths.some((p) => p.includes('config.json'))
) {
logger.info('Config file changed, reloading...');
await this.reloadConfig();
}
// Stop watching if daemon stopped
if (!this.isRunning) {
break;
}
}
} catch (error) {
// Watcher error - not critical, just log it
logger.dim(
`Config watcher stopped: ${error instanceof Error ? error.message : String(error)}`,
);
}
})();
} catch (error) {
// If we can't start the watcher, just log and continue
// The periodic check will still work
logger.dim('Could not start config file watcher, using periodic checks only');
}
}
/**
* Reload configuration and restart monitoring if needed
*/
private async reloadConfig(): Promise<void> {
try {
const oldDeviceCount = this.config.upsDevices?.length || 0;
// Load the new configuration
await this.loadConfig();
const newDeviceCount = this.config.upsDevices?.length || 0;
if (newDeviceCount > 0 && oldDeviceCount === 0) {
logger.success(`Configuration reloaded! Found ${newDeviceCount} UPS device(s)`);
logger.info('Monitoring will start automatically...');
} else if (newDeviceCount !== oldDeviceCount) {
logger.success(
`Configuration reloaded! UPS devices: ${oldDeviceCount}${newDeviceCount}`,
);
// Reinitialize UPS status tracking
this.initializeUpsStatus();
} else {
logger.success('Configuration reloaded successfully');
}
} catch (error) {
logger.warn(
`Failed to reload config: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
/**
* Sleep for the specified milliseconds
*/

View File

@@ -1,9 +1,38 @@
import { theme, symbols } from './colors.ts';
/**
* Table column alignment options
*/
export type TColumnAlign = 'left' | 'right' | 'center';
/**
* Table column definition
*/
export interface ITableColumn {
/** Column header text */
header: string;
/** Column key in data object */
key: string;
/** Column alignment (default: left) */
align?: TColumnAlign;
/** Column width (auto-calculated if not specified) */
width?: number;
/** Color function to apply to cell values */
color?: (value: string) => string;
}
/**
* Box style types with colors
*/
export type TBoxStyle = 'default' | 'success' | 'error' | 'warning' | 'info';
/**
* A simple logger class that provides consistent formatting for log messages
* including support for logboxes with title, lines, and closing
*/
export class Logger {
private currentBoxWidth: number | null = null;
private currentBoxStyle: TBoxStyle = 'default';
private static instance: Logger;
/** Default width to use when no width is specified */
@@ -36,36 +65,83 @@ export class Logger {
}
/**
* Log an error message
* Log an error message (red with ✗ symbol)
* @param message Error message to log
*/
public error(message: string): void {
console.error(message);
console.error(`${symbols.error} ${theme.error(message)}`);
}
/**
* Log a warning message with a warning emoji
* Log a warning message (yellow with ⚠ symbol)
* @param message Warning message to log
*/
public warn(message: string): void {
console.warn(`⚠️ ${message}`);
console.warn(`${symbols.warning} ${theme.warning(message)}`);
}
/**
* Log a success message with a checkmark
* Log a success message (green with ✓ symbol)
* @param message Success message to log
*/
public success(message: string): void {
console.log(`${message}`);
console.log(`${symbols.success} ${theme.success(message)}`);
}
/**
* Log an info message (cyan with symbol)
* @param message Info message to log
*/
public info(message: string): void {
console.log(`${symbols.info} ${theme.info(message)}`);
}
/**
* Log a dim/secondary message
* @param message Message to log in dim style
*/
public dim(message: string): void {
console.log(theme.dim(message));
}
/**
* Log a highlighted/bold message
* @param message Message to highlight
*/
public highlight(message: string): void {
console.log(theme.highlight(message));
}
/**
* Get color function for box based on style
*/
private getBoxColor(style: TBoxStyle): (text: string) => string {
switch (style) {
case 'success':
return theme.borderSuccess;
case 'error':
return theme.borderError;
case 'warning':
return theme.borderWarning;
case 'info':
return theme.borderInfo;
case 'default':
default:
return theme.borderDefault;
}
}
/**
* Log a logbox title and set the current box width
* @param title Title of the logbox
* @param width Width of the logbox (including borders), defaults to DEFAULT_WIDTH
* @param style Box style for coloring (default, success, error, warning, info)
*/
public logBoxTitle(title: string, width?: number): void {
public logBoxTitle(title: string, width?: number, style?: TBoxStyle): void {
this.currentBoxWidth = width || this.DEFAULT_WIDTH;
this.currentBoxStyle = style || 'default';
const colorFn = this.getBoxColor(this.currentBoxStyle);
// Create the title line with appropriate padding
const paddedTitle = ` ${title} `;
@@ -74,7 +150,7 @@ export class Logger {
// Title line: ┌─ Title ───┐
const titleLine = `┌─${paddedTitle}${'─'.repeat(Math.max(0, remainingSpace))}`;
console.log(titleLine);
console.log(colorFn(titleLine));
}
/**
@@ -89,17 +165,21 @@ export class Logger {
}
const boxWidth = width || this.currentBoxWidth || this.DEFAULT_WIDTH;
const colorFn = this.getBoxColor(this.currentBoxStyle);
// Calculate the available space for content
// Calculate the available space for content (use visible length)
const availableSpace = boxWidth - 2; // Account for left and right borders
const visibleLen = this.visibleLength(content);
if (content.length <= availableSpace - 1) {
if (visibleLen <= availableSpace - 1) {
// If content fits with at least one space for the right border stripe
const padding = availableSpace - content.length - 1;
console.log(`${content}${' '.repeat(padding)}`);
const padding = availableSpace - visibleLen - 1;
const line = `${content}${' '.repeat(padding)}`;
console.log(colorFn(line));
} else {
// Content is too long, let it flow out of boundaries.
console.log(`${content}`);
const line = `${content}`;
console.log(colorFn(line));
}
}
@@ -109,12 +189,15 @@ export class Logger {
*/
public logBoxEnd(width?: number): void {
const boxWidth = width || this.currentBoxWidth || this.DEFAULT_WIDTH;
const colorFn = this.getBoxColor(this.currentBoxStyle);
// Create the bottom border: └────────┘
console.log(`${'─'.repeat(boxWidth - 2)}`);
const bottomLine = `${'─'.repeat(boxWidth - 2)}`;
console.log(colorFn(bottomLine));
// Reset the current box width
// Reset the current box width and style
this.currentBoxWidth = null;
this.currentBoxStyle = 'default';
}
/**
@@ -122,9 +205,10 @@ export class Logger {
* @param title Title of the logbox
* @param lines Array of content lines
* @param width Width of the logbox, defaults to DEFAULT_WIDTH
* @param style Box style for coloring
*/
public logBox(title: string, lines: string[], width?: number): void {
this.logBoxTitle(title, width || this.DEFAULT_WIDTH);
public logBox(title: string, lines: string[], width?: number, style?: TBoxStyle): void {
this.logBoxTitle(title, width || this.DEFAULT_WIDTH, style);
for (const line of lines) {
this.logBoxLine(line);
@@ -141,6 +225,108 @@ export class Logger {
public logDivider(width?: number, character: string = '─'): void {
console.log(character.repeat(width || this.DEFAULT_WIDTH));
}
/**
* Strip ANSI color codes from string for accurate length calculation
*/
private stripAnsi(text: string): string {
// Remove ANSI escape codes
return text.replace(/\x1b\[[0-9;]*m/g, '');
}
/**
* Get visible length of string (excluding ANSI codes)
*/
private visibleLength(text: string): number {
return this.stripAnsi(text).length;
}
/**
* Align text within a column (handles ANSI color codes correctly)
*/
private alignText(text: string, width: number, align: TColumnAlign = 'left'): string {
const visibleLen = this.visibleLength(text);
if (visibleLen >= width) {
// Text is too long, truncate the visible part
const stripped = this.stripAnsi(text);
return stripped.substring(0, width);
}
const padding = width - visibleLen;
switch (align) {
case 'right':
return ' '.repeat(padding) + text;
case 'center': {
const leftPad = Math.floor(padding / 2);
const rightPad = padding - leftPad;
return ' '.repeat(leftPad) + text + ' '.repeat(rightPad);
}
case 'left':
default:
return text + ' '.repeat(padding);
}
}
/**
* Log a formatted table
* @param columns Column definitions
* @param rows Array of data objects
* @param title Optional table title
*/
public logTable(columns: ITableColumn[], rows: Record<string, string>[], title?: string): void {
if (rows.length === 0) {
this.dim('No data to display');
return;
}
// Calculate column widths
const columnWidths = columns.map((col) => {
if (col.width) return col.width;
// Auto-calculate width based on header and data (use visible length)
let maxWidth = this.visibleLength(col.header);
for (const row of rows) {
const value = String(row[col.key] || '');
maxWidth = Math.max(maxWidth, this.visibleLength(value));
}
return maxWidth;
});
// Calculate total table width
const totalWidth = columnWidths.reduce((sum, w) => sum + w, 0) + (columns.length * 3) + 1;
// Print title if provided
if (title) {
this.logBoxTitle(title, totalWidth);
} else {
// Print top border
console.log('┌' + columnWidths.map((w) => '─'.repeat(w + 2)).join('┬') + '┐');
}
// Print header row
const headerCells = columns.map((col, i) =>
theme.highlight(this.alignText(col.header, columnWidths[i], col.align))
);
console.log('│ ' + headerCells.join(' │ ') + ' │');
// Print separator
console.log('├' + columnWidths.map((w) => '─'.repeat(w + 2)).join('┼') + '┤');
// Print data rows
for (const row of rows) {
const cells = columns.map((col, i) => {
const value = String(row[col.key] || '');
const aligned = this.alignText(value, columnWidths[i], col.align);
return col.color ? col.color(aligned) : aligned;
});
console.log('│ ' + cells.join(' │ ') + ' │');
}
// Print bottom border
console.log('└' + columnWidths.map((w) => '─'.repeat(w + 2)).join('┴') + '┘');
}
}
// Export a singleton instance for easy use

View File

@@ -15,17 +15,17 @@ export class NupstSystemd {
/** Template for the systemd service file */
private readonly serviceTemplate = `[Unit]
Description=Node.js UPS Shutdown Tool for Multiple UPS Devices
Description=NUPST - Deno-powered UPS Monitoring Tool
After=network.target
[Service]
ExecStart=/opt/nupst/bin/nupst daemon-start
ExecStart=/usr/local/bin/nupst service start-daemon
Restart=always
RestartSec=10
User=root
Group=root
Environment=PATH=/usr/bin:/usr/local/bin
Environment=NODE_ENV=production
WorkingDirectory=/tmp
WorkingDirectory=/opt/nupst
[Install]
WantedBy=multi-user.target