P1 — Scheduled Backups - BackupService.createBackup() now accepts a `type` param (manual/scheduled) - PollerService: cronMatchesNow() parser + runScheduledBackups() + Redis 1h gate - Settings page: backup schedule toggle + cron expression input with examples P2 — Uptime/Availability Tracking - DB: device_availability table (went_offline_at, came_back_online_at, duration_seconds) - PollerService: inserts outage rows on offline detection, closes them on recovery - GET /api/metrics/device/:id/availability?range=30d endpoint - /api/metrics/summary now includes fleet 30-day uptime % - DeviceDetailPage: 30-day availability card (uptime %, outage count, longest outage) - DashboardPage HealthBar: Uptime 30d mini-stat P3 — PoE Power Draw Metrics - DeviceCollector.collectPoePower() queries /interface/ethernet/poe/monitor for switches - Writes poe_power measurement to InfluxDB (tags: device_id, port; fields: watts, current_ma, voltage_v) - GET /api/metrics/device/:id/poe endpoint (current per-port + 1h history) - SwitchPortDiagram: amber watt badge on PoE-active ports - HardwareTab: PoE budget section showing active ports and total watts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
218 lines
6.2 KiB
TypeScript
218 lines
6.2 KiB
TypeScript
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import { Client as SSHClient } from 'ssh2';
|
|
import { query } from '../config/database';
|
|
import { decrypt } from '../utils/crypto';
|
|
|
|
const BACKUPS_DIR = process.env.BACKUPS_DIR || '/app/backups';
|
|
|
|
export interface BackupDevice {
|
|
id: number;
|
|
name: string;
|
|
ip_address: string;
|
|
ssh_port: number;
|
|
ssh_username?: string;
|
|
ssh_password_encrypted?: string;
|
|
api_username: string;
|
|
api_password_encrypted: string;
|
|
}
|
|
|
|
export class BackupService {
|
|
constructor() {
|
|
// Ensure backups directory exists
|
|
if (!fs.existsSync(BACKUPS_DIR)) {
|
|
fs.mkdirSync(BACKUPS_DIR, { recursive: true });
|
|
}
|
|
}
|
|
|
|
async createBackup(device: BackupDevice, notes?: string, type: string = 'manual'): Promise<number> {
|
|
const sshUser = device.ssh_username || device.api_username;
|
|
const sshPass = device.ssh_password_encrypted
|
|
? decrypt(device.ssh_password_encrypted)
|
|
: decrypt(device.api_password_encrypted);
|
|
|
|
const exportContent = await this.sshExport(
|
|
device.ip_address,
|
|
device.ssh_port || 22,
|
|
sshUser,
|
|
sshPass
|
|
);
|
|
|
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
const filename = `${device.name.replace(/[^a-z0-9]/gi, '_')}_${timestamp}.rsc`;
|
|
const filePath = path.join(BACKUPS_DIR, String(device.id), filename);
|
|
|
|
// Ensure device backup directory exists
|
|
const deviceDir = path.join(BACKUPS_DIR, String(device.id));
|
|
if (!fs.existsSync(deviceDir)) {
|
|
fs.mkdirSync(deviceDir, { recursive: true });
|
|
}
|
|
|
|
fs.writeFileSync(filePath, exportContent, 'utf8');
|
|
const stats = fs.statSync(filePath);
|
|
|
|
const rows = await query<{ id: number }>(
|
|
`INSERT INTO backups (device_id, filename, file_path, size_bytes, backup_type, notes)
|
|
VALUES ($1,$2,$3,$4,$5,$6) RETURNING id`,
|
|
[device.id, filename, filePath, stats.size, type, notes || null]
|
|
);
|
|
|
|
return rows[0].id;
|
|
}
|
|
|
|
async restoreBackup(backupId: number): Promise<void> {
|
|
const backup = await query<{
|
|
file_path: string;
|
|
device_id: number;
|
|
filename: string;
|
|
}>(`SELECT b.*, d.ip_address, d.ssh_port, d.ssh_username, d.ssh_password_encrypted, d.api_username, d.api_password_encrypted
|
|
FROM backups b JOIN devices d ON d.id = b.device_id
|
|
WHERE b.id = $1`, [backupId]);
|
|
|
|
if (!backup[0]) throw new Error('Backup not found');
|
|
|
|
const b = backup[0] as unknown as BackupDevice & { file_path: string };
|
|
const content = fs.readFileSync(b.file_path, 'utf8');
|
|
|
|
const sshUser = b.ssh_username || b.api_username;
|
|
const sshPass = b.ssh_password_encrypted
|
|
? decrypt(b.ssh_password_encrypted)
|
|
: decrypt(b.api_password_encrypted);
|
|
|
|
await this.sshImport(b.ip_address, b.ssh_port || 22, sshUser, sshPass, content);
|
|
}
|
|
|
|
async deleteBackup(backupId: number): Promise<void> {
|
|
const rows = await query<{ file_path: string }>(
|
|
`DELETE FROM backups WHERE id = $1 RETURNING file_path`,
|
|
[backupId]
|
|
);
|
|
if (rows[0]?.file_path && fs.existsSync(rows[0].file_path)) {
|
|
fs.unlinkSync(rows[0].file_path);
|
|
}
|
|
}
|
|
|
|
getBackupFilePath(backupId: number, deviceId: number): string | null {
|
|
const rows = fs.readdirSync(path.join(BACKUPS_DIR, String(deviceId))).filter(
|
|
(f) => f.includes(String(backupId))
|
|
);
|
|
if (!rows.length) return null;
|
|
return path.join(BACKUPS_DIR, String(deviceId), rows[0]);
|
|
}
|
|
|
|
private sshExport(
|
|
host: string,
|
|
port: number,
|
|
username: string,
|
|
password: string
|
|
): Promise<string> {
|
|
return new Promise((resolve, reject) => {
|
|
const conn = new SSHClient();
|
|
let output = '';
|
|
const timeout = setTimeout(() => {
|
|
conn.end();
|
|
reject(new Error('SSH timeout during backup'));
|
|
}, 30_000);
|
|
|
|
conn.on('ready', () => {
|
|
conn.exec('/export compact', (err, stream) => {
|
|
if (err) {
|
|
clearTimeout(timeout);
|
|
conn.end();
|
|
return reject(err);
|
|
}
|
|
|
|
stream.on('data', (data: Buffer) => {
|
|
output += data.toString();
|
|
});
|
|
|
|
stream.stderr.on('data', (data: Buffer) => {
|
|
console.warn('SSH stderr:', data.toString());
|
|
});
|
|
|
|
stream.on('close', () => {
|
|
clearTimeout(timeout);
|
|
conn.end();
|
|
resolve(output);
|
|
});
|
|
});
|
|
});
|
|
|
|
conn.on('error', (err) => {
|
|
clearTimeout(timeout);
|
|
reject(err);
|
|
});
|
|
|
|
conn.connect({ host, port, username, password, readyTimeout: 10_000 });
|
|
});
|
|
}
|
|
|
|
private sshImport(
|
|
host: string,
|
|
port: number,
|
|
username: string,
|
|
password: string,
|
|
content: string
|
|
): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
const conn = new SSHClient();
|
|
const timeout = setTimeout(() => {
|
|
conn.end();
|
|
reject(new Error('SSH timeout during restore'));
|
|
}, 60_000);
|
|
|
|
conn.on('ready', () => {
|
|
// Upload via SFTP then execute
|
|
conn.sftp((err, sftp) => {
|
|
if (err) {
|
|
clearTimeout(timeout);
|
|
conn.end();
|
|
return reject(err);
|
|
}
|
|
|
|
const remoteFile = '/restore_config.rsc';
|
|
const writeStream = sftp.createWriteStream(remoteFile);
|
|
|
|
writeStream.on('close', () => {
|
|
// Execute the import
|
|
conn.exec(`/import file-name=${remoteFile}`, (err2, stream) => {
|
|
if (err2) {
|
|
clearTimeout(timeout);
|
|
conn.end();
|
|
return reject(err2);
|
|
}
|
|
|
|
stream.on('close', () => {
|
|
clearTimeout(timeout);
|
|
conn.end();
|
|
resolve();
|
|
});
|
|
|
|
stream.on('error', (e: Error) => {
|
|
clearTimeout(timeout);
|
|
conn.end();
|
|
reject(e);
|
|
});
|
|
});
|
|
});
|
|
|
|
writeStream.on('error', (e: Error) => {
|
|
clearTimeout(timeout);
|
|
conn.end();
|
|
reject(e);
|
|
});
|
|
|
|
writeStream.end(Buffer.from(content, 'utf8'));
|
|
});
|
|
});
|
|
|
|
conn.on('error', (err) => {
|
|
clearTimeout(timeout);
|
|
reject(err);
|
|
});
|
|
|
|
conn.connect({ host, port, username, password, readyTimeout: 10_000 });
|
|
});
|
|
}
|
|
}
|