fix(cli,daemon,snmp): normalize CLI argument parsing and extract daemon monitoring helpers with stronger SNMP typing
This commit is contained in:
@@ -229,10 +229,10 @@ 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(`Current Version: ${theme.dim('5.5.0')}`);
|
||||
logger.logBoxLine(`Latest Version: ${theme.highlight('5.5.1')}`);
|
||||
logger.logBoxLine('');
|
||||
logger.logBoxLine(`Run ${theme.command('sudo nupst update')} to update`);
|
||||
logger.logBoxLine(`Run ${theme.command('sudo nupst upgrade')} to update`);
|
||||
logger.logBoxLine('');
|
||||
logger.logBoxEnd();
|
||||
|
||||
|
||||
+430
@@ -2,9 +2,27 @@ import { assert, assertEquals, assertExists } from 'jsr:@std/assert@^1.0.0';
|
||||
import { NupstSnmp } from '../ts/snmp/manager.ts';
|
||||
import { UpsOidSets } from '../ts/snmp/oid-sets.ts';
|
||||
import type { IOidSet, ISnmpConfig, TUpsModel } from '../ts/snmp/types.ts';
|
||||
import {
|
||||
analyzeConfigReload,
|
||||
shouldRefreshPauseState,
|
||||
shouldReloadConfig,
|
||||
} from '../ts/config-watch.ts';
|
||||
import { type IPauseState, loadPauseSnapshot } from '../ts/pause-state.ts';
|
||||
import { shortId } from '../ts/helpers/shortid.ts';
|
||||
import { HTTP_SERVER, SNMP, THRESHOLDS, TIMING, UI } from '../ts/constants.ts';
|
||||
import { Action, type IActionContext } from '../ts/actions/base-action.ts';
|
||||
import { buildUpsActionContext, decideUpsActionExecution } from '../ts/action-orchestration.ts';
|
||||
import {
|
||||
buildShutdownErrorRow,
|
||||
buildShutdownStatusRow,
|
||||
selectEmergencyCandidate,
|
||||
} from '../ts/shutdown-monitoring.ts';
|
||||
import {
|
||||
buildFailedUpsPollSnapshot,
|
||||
buildSuccessfulUpsPollSnapshot,
|
||||
hasThresholdViolation,
|
||||
} from '../ts/ups-monitoring.ts';
|
||||
import { createInitialUpsStatus } from '../ts/ups-status.ts';
|
||||
|
||||
import * as qenv from 'npm:@push.rocks/qenv@^6.0.0';
|
||||
const testQenv = new qenv.Qenv('./', '.nogit/');
|
||||
@@ -82,6 +100,418 @@ Deno.test('UI constants: box widths are ascending', () => {
|
||||
assert(UI.WIDE_BOX_WIDTH < UI.EXTRA_WIDE_BOX_WIDTH);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Pause State Tests
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
Deno.test('loadPauseSnapshot: reports paused state for valid pause file', async () => {
|
||||
const tempDir = await Deno.makeTempDir();
|
||||
const pauseFilePath = `${tempDir}/pause.json`;
|
||||
const pauseState: IPauseState = {
|
||||
pausedAt: 1000,
|
||||
pausedBy: 'cli',
|
||||
reason: 'maintenance',
|
||||
resumeAt: 5000,
|
||||
};
|
||||
|
||||
try {
|
||||
await Deno.writeTextFile(pauseFilePath, JSON.stringify(pauseState));
|
||||
|
||||
const snapshot = loadPauseSnapshot(pauseFilePath, false, 2000);
|
||||
|
||||
assertEquals(snapshot.isPaused, true);
|
||||
assertEquals(snapshot.pauseState, pauseState);
|
||||
assertEquals(snapshot.transition, 'paused');
|
||||
} finally {
|
||||
await Deno.remove(tempDir, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test('loadPauseSnapshot: auto-resumes expired pause file', async () => {
|
||||
const tempDir = await Deno.makeTempDir();
|
||||
const pauseFilePath = `${tempDir}/pause.json`;
|
||||
const pauseState: IPauseState = {
|
||||
pausedAt: 1000,
|
||||
pausedBy: 'cli',
|
||||
resumeAt: 1500,
|
||||
};
|
||||
|
||||
try {
|
||||
await Deno.writeTextFile(pauseFilePath, JSON.stringify(pauseState));
|
||||
|
||||
const snapshot = loadPauseSnapshot(pauseFilePath, true, 2000);
|
||||
|
||||
assertEquals(snapshot.isPaused, false);
|
||||
assertEquals(snapshot.pauseState, null);
|
||||
assertEquals(snapshot.transition, 'autoResumed');
|
||||
|
||||
let fileExists = true;
|
||||
try {
|
||||
await Deno.stat(pauseFilePath);
|
||||
} catch {
|
||||
fileExists = false;
|
||||
}
|
||||
assertEquals(fileExists, false);
|
||||
} finally {
|
||||
await Deno.remove(tempDir, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test('loadPauseSnapshot: reports resumed when pause file disappears', async () => {
|
||||
const tempDir = await Deno.makeTempDir();
|
||||
|
||||
try {
|
||||
const snapshot = loadPauseSnapshot(`${tempDir}/pause.json`, true, 2000);
|
||||
|
||||
assertEquals(snapshot.isPaused, false);
|
||||
assertEquals(snapshot.pauseState, null);
|
||||
assertEquals(snapshot.transition, 'resumed');
|
||||
} finally {
|
||||
await Deno.remove(tempDir, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Config Watch Tests
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
Deno.test('shouldReloadConfig: matches modify events for config.json', () => {
|
||||
assertEquals(
|
||||
shouldReloadConfig({ kind: 'modify', paths: ['/etc/nupst/config.json'] }),
|
||||
true,
|
||||
);
|
||||
assertEquals(
|
||||
shouldReloadConfig({ kind: 'create', paths: ['/etc/nupst/config.json'] }),
|
||||
false,
|
||||
);
|
||||
assertEquals(
|
||||
shouldReloadConfig({ kind: 'modify', paths: ['/etc/nupst/other.json'] }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test('shouldRefreshPauseState: matches create/modify/remove pause events', () => {
|
||||
assertEquals(
|
||||
shouldRefreshPauseState({ kind: 'create', paths: ['/etc/nupst/pause'] }),
|
||||
true,
|
||||
);
|
||||
assertEquals(
|
||||
shouldRefreshPauseState({ kind: 'remove', paths: ['/etc/nupst/pause'] }),
|
||||
true,
|
||||
);
|
||||
assertEquals(
|
||||
shouldRefreshPauseState({ kind: 'modify', paths: ['/etc/nupst/config.json'] }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test('analyzeConfigReload: detects monitoring start and device count changes', () => {
|
||||
assertEquals(analyzeConfigReload(0, 2), {
|
||||
transition: 'monitoringWillStart',
|
||||
message: 'Configuration reloaded! Found 2 UPS device(s)',
|
||||
shouldInitializeUpsStatus: false,
|
||||
shouldLogMonitoringStart: true,
|
||||
});
|
||||
|
||||
assertEquals(analyzeConfigReload(2, 3), {
|
||||
transition: 'deviceCountChanged',
|
||||
message: 'Configuration reloaded! UPS devices: 2 -> 3',
|
||||
shouldInitializeUpsStatus: true,
|
||||
shouldLogMonitoringStart: false,
|
||||
});
|
||||
|
||||
assertEquals(analyzeConfigReload(2, 2), {
|
||||
transition: 'reloaded',
|
||||
message: 'Configuration reloaded successfully',
|
||||
shouldInitializeUpsStatus: false,
|
||||
shouldLogMonitoringStart: false,
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// UPS Status Tests
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
Deno.test('createInitialUpsStatus: creates default daemon UPS status shape', () => {
|
||||
assertEquals(createInitialUpsStatus({ id: 'ups-1', name: 'Main UPS' }, 1234), {
|
||||
id: 'ups-1',
|
||||
name: 'Main UPS',
|
||||
powerStatus: 'unknown',
|
||||
batteryCapacity: 100,
|
||||
batteryRuntime: 999,
|
||||
outputLoad: 0,
|
||||
outputPower: 0,
|
||||
outputVoltage: 0,
|
||||
outputCurrent: 0,
|
||||
lastStatusChange: 1234,
|
||||
lastCheckTime: 0,
|
||||
consecutiveFailures: 0,
|
||||
unreachableSince: 0,
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Action Orchestration Tests
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
Deno.test('buildUpsActionContext: includes previous power status and timestamp', () => {
|
||||
const status = {
|
||||
...createInitialUpsStatus({ id: 'ups-1', name: 'Main UPS' }, 1000),
|
||||
powerStatus: 'onBattery' as const,
|
||||
batteryCapacity: 42,
|
||||
batteryRuntime: 15,
|
||||
};
|
||||
const previousStatus = {
|
||||
...createInitialUpsStatus({ id: 'ups-1', name: 'Main UPS' }, 500),
|
||||
powerStatus: 'online' as const,
|
||||
};
|
||||
|
||||
assertEquals(
|
||||
buildUpsActionContext(
|
||||
{ id: 'ups-1', name: 'Main UPS' },
|
||||
status,
|
||||
previousStatus,
|
||||
'thresholdViolation',
|
||||
9999,
|
||||
),
|
||||
{
|
||||
upsId: 'ups-1',
|
||||
upsName: 'Main UPS',
|
||||
powerStatus: 'onBattery',
|
||||
batteryCapacity: 42,
|
||||
batteryRuntime: 15,
|
||||
previousPowerStatus: 'online',
|
||||
timestamp: 9999,
|
||||
triggerReason: 'thresholdViolation',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test('decideUpsActionExecution: suppresses actions while paused', () => {
|
||||
const decision = decideUpsActionExecution(
|
||||
true,
|
||||
{ id: 'ups-1', name: 'Main UPS', actions: [{ type: 'shutdown' }] },
|
||||
createInitialUpsStatus({ id: 'ups-1', name: 'Main UPS' }, 1000),
|
||||
undefined,
|
||||
'powerStatusChange',
|
||||
9999,
|
||||
);
|
||||
|
||||
assertEquals(decision, {
|
||||
type: 'suppressed',
|
||||
message: '[PAUSED] Actions suppressed for UPS Main UPS (trigger: powerStatusChange)',
|
||||
});
|
||||
});
|
||||
|
||||
Deno.test('decideUpsActionExecution: falls back to legacy shutdown without actions', () => {
|
||||
const decision = decideUpsActionExecution(
|
||||
false,
|
||||
{ id: 'ups-1', name: 'Main UPS' },
|
||||
createInitialUpsStatus({ id: 'ups-1', name: 'Main UPS' }, 1000),
|
||||
undefined,
|
||||
'thresholdViolation',
|
||||
9999,
|
||||
);
|
||||
|
||||
assertEquals(decision, {
|
||||
type: 'legacyShutdown',
|
||||
reason: 'UPS "Main UPS" battery or runtime below threshold',
|
||||
});
|
||||
});
|
||||
|
||||
Deno.test('decideUpsActionExecution: returns executable action plan when actions exist', () => {
|
||||
const decision = decideUpsActionExecution(
|
||||
false,
|
||||
{ id: 'ups-1', name: 'Main UPS', actions: [{ type: 'shutdown' }] },
|
||||
{
|
||||
...createInitialUpsStatus({ id: 'ups-1', name: 'Main UPS' }, 1000),
|
||||
powerStatus: 'onBattery',
|
||||
batteryCapacity: 55,
|
||||
batteryRuntime: 18,
|
||||
},
|
||||
{
|
||||
...createInitialUpsStatus({ id: 'ups-1', name: 'Main UPS' }, 500),
|
||||
powerStatus: 'online',
|
||||
},
|
||||
'powerStatusChange',
|
||||
9999,
|
||||
);
|
||||
|
||||
assertEquals(decision, {
|
||||
type: 'execute',
|
||||
actions: [{ type: 'shutdown' }],
|
||||
context: {
|
||||
upsId: 'ups-1',
|
||||
upsName: 'Main UPS',
|
||||
powerStatus: 'onBattery',
|
||||
batteryCapacity: 55,
|
||||
batteryRuntime: 18,
|
||||
previousPowerStatus: 'online',
|
||||
timestamp: 9999,
|
||||
triggerReason: 'powerStatusChange',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Shutdown Monitoring Tests
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
Deno.test('buildShutdownStatusRow: marks critical rows below emergency runtime threshold', () => {
|
||||
const snapshot = buildShutdownStatusRow(
|
||||
'Main UPS',
|
||||
{
|
||||
powerStatus: 'onBattery',
|
||||
batteryCapacity: 25,
|
||||
batteryRuntime: 4,
|
||||
outputLoad: 15,
|
||||
outputPower: 100,
|
||||
outputVoltage: 230,
|
||||
outputCurrent: 0.4,
|
||||
raw: {},
|
||||
},
|
||||
5,
|
||||
{
|
||||
battery: (value) => `B:${value}`,
|
||||
runtime: (value) => `R:${value}`,
|
||||
ok: (text) => `ok:${text}`,
|
||||
critical: (text) => `critical:${text}`,
|
||||
error: (text) => `error:${text}`,
|
||||
},
|
||||
);
|
||||
|
||||
assertEquals(snapshot.isCritical, true);
|
||||
assertEquals(snapshot.row, {
|
||||
name: 'Main UPS',
|
||||
battery: 'B:25',
|
||||
runtime: 'R:4',
|
||||
status: 'critical:CRITICAL!',
|
||||
});
|
||||
});
|
||||
|
||||
Deno.test('buildShutdownErrorRow: builds shutdown error table row', () => {
|
||||
assertEquals(buildShutdownErrorRow('Main UPS', (text) => `error:${text}`), {
|
||||
name: 'Main UPS',
|
||||
battery: 'error:N/A',
|
||||
runtime: 'error:N/A',
|
||||
status: 'error:ERROR',
|
||||
});
|
||||
});
|
||||
|
||||
Deno.test('selectEmergencyCandidate: keeps first critical UPS candidate', () => {
|
||||
const firstCandidate = selectEmergencyCandidate(
|
||||
null,
|
||||
{ id: 'ups-1', name: 'UPS 1' },
|
||||
{
|
||||
powerStatus: 'onBattery',
|
||||
batteryCapacity: 40,
|
||||
batteryRuntime: 4,
|
||||
outputLoad: 10,
|
||||
outputPower: 60,
|
||||
outputVoltage: 230,
|
||||
outputCurrent: 0.3,
|
||||
raw: {},
|
||||
},
|
||||
5,
|
||||
);
|
||||
|
||||
const secondCandidate = selectEmergencyCandidate(
|
||||
firstCandidate,
|
||||
{ id: 'ups-2', name: 'UPS 2' },
|
||||
{
|
||||
powerStatus: 'onBattery',
|
||||
batteryCapacity: 30,
|
||||
batteryRuntime: 3,
|
||||
outputLoad: 15,
|
||||
outputPower: 70,
|
||||
outputVoltage: 230,
|
||||
outputCurrent: 0.4,
|
||||
raw: {},
|
||||
},
|
||||
5,
|
||||
);
|
||||
|
||||
assertEquals(secondCandidate, firstCandidate);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// UPS Monitoring Tests
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
Deno.test('buildSuccessfulUpsPollSnapshot: marks recovery from unreachable', () => {
|
||||
const currentStatus = {
|
||||
...createInitialUpsStatus({ id: 'ups-1', name: 'Main UPS' }, 1000),
|
||||
powerStatus: 'unreachable' as const,
|
||||
unreachableSince: 2000,
|
||||
consecutiveFailures: 3,
|
||||
};
|
||||
|
||||
const snapshot = buildSuccessfulUpsPollSnapshot(
|
||||
{ id: 'ups-1', name: 'Main UPS' },
|
||||
{
|
||||
powerStatus: 'online',
|
||||
batteryCapacity: 95,
|
||||
batteryRuntime: 40,
|
||||
outputLoad: 10,
|
||||
outputPower: 50,
|
||||
outputVoltage: 230,
|
||||
outputCurrent: 0.5,
|
||||
raw: {},
|
||||
},
|
||||
currentStatus,
|
||||
8000,
|
||||
);
|
||||
|
||||
assertEquals(snapshot.transition, 'recovered');
|
||||
assertEquals(snapshot.downtimeSeconds, 6);
|
||||
assertEquals(snapshot.updatedStatus.powerStatus, 'online');
|
||||
assertEquals(snapshot.updatedStatus.consecutiveFailures, 0);
|
||||
assertEquals(snapshot.updatedStatus.lastStatusChange, 8000);
|
||||
});
|
||||
|
||||
Deno.test('buildFailedUpsPollSnapshot: marks UPS unreachable at failure threshold', () => {
|
||||
const currentStatus = {
|
||||
...createInitialUpsStatus({ id: 'ups-1', name: 'Main UPS' }, 1000),
|
||||
powerStatus: 'onBattery' as const,
|
||||
consecutiveFailures: 2,
|
||||
};
|
||||
|
||||
const snapshot = buildFailedUpsPollSnapshot(
|
||||
{ id: 'ups-1', name: 'Main UPS' },
|
||||
currentStatus,
|
||||
9000,
|
||||
);
|
||||
|
||||
assertEquals(snapshot.transition, 'unreachable');
|
||||
assertEquals(snapshot.failures, 3);
|
||||
assertEquals(snapshot.updatedStatus.powerStatus, 'unreachable');
|
||||
assertEquals(snapshot.updatedStatus.unreachableSince, 9000);
|
||||
assertEquals(snapshot.updatedStatus.lastStatusChange, 9000);
|
||||
});
|
||||
|
||||
Deno.test('hasThresholdViolation: only fires on battery when any action threshold is exceeded', () => {
|
||||
assertEquals(
|
||||
hasThresholdViolation('online', 40, 10, [
|
||||
{ type: 'shutdown', thresholds: { battery: 50, runtime: 20 } },
|
||||
]),
|
||||
false,
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
hasThresholdViolation('onBattery', 40, 10, [
|
||||
{ type: 'shutdown', thresholds: { battery: 50, runtime: 20 } },
|
||||
]),
|
||||
true,
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
hasThresholdViolation('onBattery', 90, 60, [
|
||||
{ type: 'shutdown', thresholds: { battery: 50, runtime: 20 } },
|
||||
]),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// UpsOidSets Tests
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user