feat: initial SleepGuard implementation
Wake-on-demand proxy + agent system with SvelteKit dashboard. Monorepo: shared types, proxy (Hono + http-proxy), agent (monitors + locks), web (SvelteKit SPA). Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
20
packages/agent/src/config.ts
Normal file
20
packages/agent/src/config.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { requireEnv, intEnv, optionalEnv } from '@sleepguard/shared';
|
||||
|
||||
export const config = {
|
||||
port: intEnv('AGENT_PORT', 48527),
|
||||
secret: requireEnv('AGENT_SECRET'),
|
||||
|
||||
thresholds: {
|
||||
cpuPercent: intEnv('CPU_THRESHOLD_PERCENT', 15),
|
||||
gpuPercent: intEnv('GPU_THRESHOLD_PERCENT', 10),
|
||||
ramPercent: intEnv('RAM_THRESHOLD_PERCENT', 50),
|
||||
diskIoActive: true,
|
||||
},
|
||||
|
||||
watchedProcesses: optionalEnv('WATCHED_PROCESSES', 'hashcat,ffmpeg,whisper,python3,ollama')
|
||||
.split(',')
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean),
|
||||
|
||||
shutdownCommand: optionalEnv('SHUTDOWN_COMMAND', 'systemctl poweroff'),
|
||||
} as const;
|
||||
10
packages/agent/src/index.ts
Normal file
10
packages/agent/src/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { serve } from '@hono/node-server';
|
||||
import { app } from './server.js';
|
||||
import { startLockCleanup } from './services/lockManager.js';
|
||||
import { config } from './config.js';
|
||||
|
||||
startLockCleanup();
|
||||
|
||||
serve({ fetch: app.fetch, port: config.port }, (info) => {
|
||||
console.log(`[SleepGuard Agent] Running on port ${info.port}`);
|
||||
});
|
||||
37
packages/agent/src/monitors/cpu.ts
Normal file
37
packages/agent/src/monitors/cpu.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import os from 'node:os';
|
||||
import type { CpuStatus } from '@sleepguard/shared';
|
||||
|
||||
let prevIdle = 0;
|
||||
let prevTotal = 0;
|
||||
|
||||
function measureCpuUsage(): number {
|
||||
const cpus = os.cpus();
|
||||
let idle = 0;
|
||||
let total = 0;
|
||||
|
||||
for (const cpu of cpus) {
|
||||
idle += cpu.times.idle;
|
||||
total += cpu.times.user + cpu.times.nice + cpu.times.sys + cpu.times.idle + cpu.times.irq;
|
||||
}
|
||||
|
||||
const idleDelta = idle - prevIdle;
|
||||
const totalDelta = total - prevTotal;
|
||||
|
||||
prevIdle = idle;
|
||||
prevTotal = total;
|
||||
|
||||
if (totalDelta === 0) return 0;
|
||||
return Math.round((1 - idleDelta / totalDelta) * 100 * 10) / 10;
|
||||
}
|
||||
|
||||
// Initialize baseline
|
||||
measureCpuUsage();
|
||||
|
||||
export function getCpuStatus(): CpuStatus {
|
||||
const loadAvg = os.loadavg() as [number, number, number];
|
||||
return {
|
||||
usagePercent: measureCpuUsage(),
|
||||
loadAvg,
|
||||
cores: os.cpus().length,
|
||||
};
|
||||
}
|
||||
58
packages/agent/src/monitors/disk.ts
Normal file
58
packages/agent/src/monitors/disk.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import type { DiskIoStatus } from '@sleepguard/shared';
|
||||
|
||||
interface DiskStats {
|
||||
readSectors: number;
|
||||
writeSectors: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
let prevStats: DiskStats | null = null;
|
||||
|
||||
const SECTOR_SIZE_KB = 0.5; // 512 bytes
|
||||
|
||||
async function parseDiskStats(): Promise<DiskStats> {
|
||||
try {
|
||||
const content = await readFile('/proc/diskstats', 'utf-8');
|
||||
let readSectors = 0;
|
||||
let writeSectors = 0;
|
||||
|
||||
for (const line of content.trim().split('\n')) {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts.length < 14) continue;
|
||||
const device = parts[2];
|
||||
// Only count whole disks (sda, nvme0n1), not partitions
|
||||
if (/^(sd[a-z]|nvme\d+n\d+)$/.test(device)) {
|
||||
readSectors += parseInt(parts[5], 10) || 0;
|
||||
writeSectors += parseInt(parts[9], 10) || 0;
|
||||
}
|
||||
}
|
||||
|
||||
return { readSectors, writeSectors, timestamp: Date.now() };
|
||||
} catch {
|
||||
return { readSectors: 0, writeSectors: 0, timestamp: Date.now() };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDiskIoStatus(): Promise<DiskIoStatus> {
|
||||
const current = await parseDiskStats();
|
||||
|
||||
if (!prevStats) {
|
||||
prevStats = current;
|
||||
return { active: false, readKBps: 0, writeKBps: 0 };
|
||||
}
|
||||
|
||||
const elapsed = (current.timestamp - prevStats.timestamp) / 1000;
|
||||
if (elapsed <= 0) {
|
||||
return { active: false, readKBps: 0, writeKBps: 0 };
|
||||
}
|
||||
|
||||
const readKBps = Math.round(((current.readSectors - prevStats.readSectors) * SECTOR_SIZE_KB) / elapsed);
|
||||
const writeKBps = Math.round(((current.writeSectors - prevStats.writeSectors) * SECTOR_SIZE_KB) / elapsed);
|
||||
|
||||
prevStats = current;
|
||||
|
||||
const active = readKBps > 100 || writeKBps > 100; // >100 KB/s considered active
|
||||
|
||||
return { active, readKBps, writeKBps };
|
||||
}
|
||||
37
packages/agent/src/monitors/gpu.ts
Normal file
37
packages/agent/src/monitors/gpu.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import type { GpuStatus } from '@sleepguard/shared';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const EMPTY_GPU: GpuStatus = {
|
||||
available: false,
|
||||
usagePercent: 0,
|
||||
memoryUsedMB: 0,
|
||||
memoryTotalMB: 0,
|
||||
temperature: 0,
|
||||
name: 'N/A',
|
||||
};
|
||||
|
||||
export async function getGpuStatus(): Promise<GpuStatus> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('nvidia-smi', [
|
||||
'--query-gpu=utilization.gpu,memory.used,memory.total,temperature.gpu,name',
|
||||
'--format=csv,noheader,nounits',
|
||||
], { timeout: 5000 });
|
||||
|
||||
const parts = stdout.trim().split(',').map((s) => s.trim());
|
||||
if (parts.length < 5) return EMPTY_GPU;
|
||||
|
||||
return {
|
||||
available: true,
|
||||
usagePercent: parseFloat(parts[0]) || 0,
|
||||
memoryUsedMB: parseFloat(parts[1]) || 0,
|
||||
memoryTotalMB: parseFloat(parts[2]) || 0,
|
||||
temperature: parseFloat(parts[3]) || 0,
|
||||
name: parts[4],
|
||||
};
|
||||
} catch {
|
||||
return EMPTY_GPU;
|
||||
}
|
||||
}
|
||||
11
packages/agent/src/monitors/memory.ts
Normal file
11
packages/agent/src/monitors/memory.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import os from 'node:os';
|
||||
import type { MemoryStatus } from '@sleepguard/shared';
|
||||
|
||||
export function getMemoryStatus(): MemoryStatus {
|
||||
const totalMB = Math.round(os.totalmem() / 1024 / 1024);
|
||||
const freeMB = Math.round(os.freemem() / 1024 / 1024);
|
||||
const usedMB = totalMB - freeMB;
|
||||
const usedPercent = Math.round((usedMB / totalMB) * 100 * 10) / 10;
|
||||
|
||||
return { usedPercent, usedMB, totalMB };
|
||||
}
|
||||
29
packages/agent/src/monitors/process.ts
Normal file
29
packages/agent/src/monitors/process.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import type { ProcessInfo } from '@sleepguard/shared';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
async function findProcess(name: string): Promise<number[]> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('pgrep', ['-x', name], { timeout: 3000 });
|
||||
return stdout
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((pid) => parseInt(pid, 10));
|
||||
} catch {
|
||||
// pgrep exits with 1 if no processes found
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProcessStatus(watchlist: string[]): Promise<ProcessInfo[]> {
|
||||
const results = await Promise.all(
|
||||
watchlist.map(async (name) => {
|
||||
const pids = await findProcess(name);
|
||||
return { name, running: pids.length > 0, pids };
|
||||
})
|
||||
);
|
||||
return results;
|
||||
}
|
||||
66
packages/agent/src/server.ts
Normal file
66
packages/agent/src/server.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { Hono } from 'hono';
|
||||
import { cors } from 'hono/cors';
|
||||
import type { CreateLockRequest } from '@sleepguard/shared';
|
||||
import { collectStatus } from './services/collector.js';
|
||||
import { evaluateShutdown } from './services/shutdownPolicy.js';
|
||||
import { createLock, deleteLock, getAllLocks } from './services/lockManager.js';
|
||||
import { config } from './config.js';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
// CORS
|
||||
app.use('*', cors());
|
||||
|
||||
// Auth middleware for /api/*
|
||||
app.use('/api/*', async (c, next) => {
|
||||
const auth = c.req.header('Authorization');
|
||||
if (auth !== `Bearer ${config.secret}`) {
|
||||
return c.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
await next();
|
||||
});
|
||||
|
||||
// Health check
|
||||
app.get('/api/health', (c) => {
|
||||
return c.json({ ok: true, timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
// Full status
|
||||
app.get('/api/status', async (c) => {
|
||||
const status = await collectStatus();
|
||||
return c.json(status);
|
||||
});
|
||||
|
||||
// Can shutdown?
|
||||
app.get('/api/can-shutdown', async (c) => {
|
||||
const status = await collectStatus();
|
||||
const result = evaluateShutdown(status);
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
// List locks
|
||||
app.get('/api/locks', (c) => {
|
||||
return c.json(getAllLocks());
|
||||
});
|
||||
|
||||
// Create lock
|
||||
app.post('/api/locks', async (c) => {
|
||||
const body = await c.req.json<CreateLockRequest>();
|
||||
if (!body.name) {
|
||||
return c.json({ error: 'name is required' }, 400);
|
||||
}
|
||||
const lock = createLock(body.name, body.ttlSeconds, body.reason);
|
||||
return c.json(lock, 201);
|
||||
});
|
||||
|
||||
// Delete lock
|
||||
app.delete('/api/locks/:name', (c) => {
|
||||
const name = c.req.param('name');
|
||||
const deleted = deleteLock(name);
|
||||
if (!deleted) {
|
||||
return c.json({ error: 'Lock not found' }, 404);
|
||||
}
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
export { app };
|
||||
27
packages/agent/src/services/collector.ts
Normal file
27
packages/agent/src/services/collector.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import os from 'node:os';
|
||||
import type { AgentStatus } from '@sleepguard/shared';
|
||||
import { getCpuStatus } from '../monitors/cpu.js';
|
||||
import { getGpuStatus } from '../monitors/gpu.js';
|
||||
import { getMemoryStatus } from '../monitors/memory.js';
|
||||
import { getDiskIoStatus } from '../monitors/disk.js';
|
||||
import { getProcessStatus } from '../monitors/process.js';
|
||||
import { getAllLocks } from './lockManager.js';
|
||||
import { config } from '../config.js';
|
||||
|
||||
export async function collectStatus(): Promise<AgentStatus> {
|
||||
const [gpu, diskIo, processes] = await Promise.all([
|
||||
getGpuStatus(),
|
||||
getDiskIoStatus(),
|
||||
getProcessStatus(config.watchedProcesses),
|
||||
]);
|
||||
|
||||
return {
|
||||
cpu: getCpuStatus(),
|
||||
gpu,
|
||||
memory: getMemoryStatus(),
|
||||
diskIo,
|
||||
processes,
|
||||
locks: getAllLocks(),
|
||||
uptime: os.uptime(),
|
||||
};
|
||||
}
|
||||
56
packages/agent/src/services/lockManager.ts
Normal file
56
packages/agent/src/services/lockManager.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { Lock } from '@sleepguard/shared';
|
||||
|
||||
const locks = new Map<string, Lock>();
|
||||
|
||||
let cleanupInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
function cleanExpired(): void {
|
||||
const now = Date.now();
|
||||
for (const [name, lock] of locks) {
|
||||
if (lock.expiresAt && new Date(lock.expiresAt).getTime() <= now) {
|
||||
locks.delete(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function startLockCleanup(): void {
|
||||
cleanupInterval = setInterval(cleanExpired, 10_000);
|
||||
}
|
||||
|
||||
export function stopLockCleanup(): void {
|
||||
if (cleanupInterval) {
|
||||
clearInterval(cleanupInterval);
|
||||
cleanupInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function createLock(name: string, ttlSeconds?: number, reason?: string): Lock {
|
||||
const now = new Date();
|
||||
const lock: Lock = {
|
||||
name,
|
||||
reason,
|
||||
createdAt: now.toISOString(),
|
||||
expiresAt: ttlSeconds ? new Date(now.getTime() + ttlSeconds * 1000).toISOString() : undefined,
|
||||
};
|
||||
locks.set(name, lock);
|
||||
return lock;
|
||||
}
|
||||
|
||||
export function deleteLock(name: string): boolean {
|
||||
return locks.delete(name);
|
||||
}
|
||||
|
||||
export function getLock(name: string): Lock | undefined {
|
||||
cleanExpired();
|
||||
return locks.get(name);
|
||||
}
|
||||
|
||||
export function getAllLocks(): Lock[] {
|
||||
cleanExpired();
|
||||
return Array.from(locks.values());
|
||||
}
|
||||
|
||||
export function hasActiveLocks(): boolean {
|
||||
cleanExpired();
|
||||
return locks.size > 0;
|
||||
}
|
||||
41
packages/agent/src/services/shutdownPolicy.ts
Normal file
41
packages/agent/src/services/shutdownPolicy.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { AgentStatus, CanShutdownResponse } from '@sleepguard/shared';
|
||||
import { config } from '../config.js';
|
||||
|
||||
export function evaluateShutdown(status: AgentStatus): CanShutdownResponse {
|
||||
const reasons: string[] = [];
|
||||
|
||||
// Level 1: Locks (absolute blocker)
|
||||
if (status.locks.length > 0) {
|
||||
const lockNames = status.locks.map((l) => l.name).join(', ');
|
||||
reasons.push(`Active locks: ${lockNames}`);
|
||||
}
|
||||
|
||||
// Level 2: Process watchlist
|
||||
const runningProcesses = status.processes.filter((p) => p.running);
|
||||
if (runningProcesses.length > 0) {
|
||||
const names = runningProcesses.map((p) => p.name).join(', ');
|
||||
reasons.push(`Watched processes running: ${names}`);
|
||||
}
|
||||
|
||||
// Level 3: Metric thresholds
|
||||
if (status.cpu.usagePercent > config.thresholds.cpuPercent) {
|
||||
reasons.push(`CPU usage ${status.cpu.usagePercent}% > ${config.thresholds.cpuPercent}%`);
|
||||
}
|
||||
|
||||
if (status.gpu.available && status.gpu.usagePercent > config.thresholds.gpuPercent) {
|
||||
reasons.push(`GPU usage ${status.gpu.usagePercent}% > ${config.thresholds.gpuPercent}%`);
|
||||
}
|
||||
|
||||
if (status.memory.usedPercent > config.thresholds.ramPercent) {
|
||||
reasons.push(`RAM usage ${status.memory.usedPercent}% > ${config.thresholds.ramPercent}%`);
|
||||
}
|
||||
|
||||
if (config.thresholds.diskIoActive && status.diskIo.active) {
|
||||
reasons.push(`Disk I/O active (R: ${status.diskIo.readKBps} KB/s, W: ${status.diskIo.writeKBps} KB/s)`);
|
||||
}
|
||||
|
||||
return {
|
||||
canShutdown: reasons.length === 0,
|
||||
reasons,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user