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:
42
packages/agent/Dockerfile
Normal file
42
packages/agent/Dockerfile
Normal file
@@ -0,0 +1,42 @@
|
||||
FROM node:22-slim AS base
|
||||
RUN corepack enable
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
procps \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# --- Dependencies ---
|
||||
FROM base AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json yarn.lock .yarnrc.yml ./
|
||||
COPY packages/shared/package.json packages/shared/
|
||||
COPY packages/agent/package.json packages/agent/
|
||||
RUN yarn install --immutable
|
||||
|
||||
# --- Build shared ---
|
||||
FROM deps AS build-shared
|
||||
WORKDIR /app
|
||||
COPY packages/shared packages/shared
|
||||
RUN yarn workspace @sleepguard/shared build
|
||||
|
||||
# --- Build agent ---
|
||||
FROM build-shared AS build-agent
|
||||
WORKDIR /app
|
||||
COPY packages/agent/tsconfig.json packages/agent/
|
||||
COPY packages/agent/src packages/agent/src
|
||||
RUN yarn workspace @sleepguard/agent build
|
||||
|
||||
# --- Production ---
|
||||
FROM base AS production
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json yarn.lock .yarnrc.yml ./
|
||||
COPY packages/shared/package.json packages/shared/
|
||||
COPY packages/agent/package.json packages/agent/
|
||||
RUN yarn workspaces focus @sleepguard/agent --production
|
||||
|
||||
COPY --from=build-shared /app/packages/shared/dist packages/shared/dist
|
||||
COPY --from=build-agent /app/packages/agent/dist packages/agent/dist
|
||||
|
||||
WORKDIR /app/packages/agent
|
||||
EXPOSE 48527
|
||||
CMD ["node", "dist/index.js"]
|
||||
22
packages/agent/package.json
Normal file
22
packages/agent/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "@sleepguard/agent",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.14.0",
|
||||
"@sleepguard/shared": "workspace:*",
|
||||
"hono": "^4.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
11
packages/agent/tsconfig.json
Normal file
11
packages/agent/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../shared" }
|
||||
]
|
||||
}
|
||||
48
packages/proxy/Dockerfile
Normal file
48
packages/proxy/Dockerfile
Normal file
@@ -0,0 +1,48 @@
|
||||
FROM node:22-alpine AS base
|
||||
RUN corepack enable
|
||||
|
||||
# --- Dependencies ---
|
||||
FROM base AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json yarn.lock .yarnrc.yml ./
|
||||
COPY packages/shared/package.json packages/shared/
|
||||
COPY packages/proxy/package.json packages/proxy/
|
||||
COPY packages/proxy/web/package.json packages/proxy/web/
|
||||
RUN yarn install --immutable
|
||||
|
||||
# --- Build shared ---
|
||||
FROM deps AS build-shared
|
||||
WORKDIR /app
|
||||
COPY packages/shared packages/shared
|
||||
RUN yarn workspace @sleepguard/shared build
|
||||
|
||||
# --- Build web ---
|
||||
FROM build-shared AS build-web
|
||||
WORKDIR /app
|
||||
COPY packages/proxy/web packages/proxy/web
|
||||
RUN yarn workspace @sleepguard/web build
|
||||
|
||||
# --- Build proxy ---
|
||||
FROM build-shared AS build-proxy
|
||||
WORKDIR /app
|
||||
COPY packages/proxy/tsconfig.json packages/proxy/
|
||||
COPY packages/proxy/src packages/proxy/src
|
||||
RUN yarn workspace @sleepguard/proxy build
|
||||
|
||||
# --- Production ---
|
||||
FROM base AS production
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json yarn.lock .yarnrc.yml ./
|
||||
COPY packages/shared/package.json packages/shared/
|
||||
COPY packages/proxy/package.json packages/proxy/
|
||||
COPY packages/proxy/web/package.json packages/proxy/web/
|
||||
RUN yarn workspaces focus @sleepguard/proxy --production
|
||||
|
||||
COPY --from=build-shared /app/packages/shared/dist packages/shared/dist
|
||||
COPY --from=build-proxy /app/packages/proxy/dist packages/proxy/dist
|
||||
COPY --from=build-web /app/packages/proxy/web/build packages/proxy/public/dashboard
|
||||
|
||||
WORKDIR /app/packages/proxy
|
||||
EXPOSE 47391
|
||||
CMD ["node", "dist/index.js"]
|
||||
26
packages/proxy/package.json
Normal file
26
packages/proxy/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@sleepguard/proxy",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.14.0",
|
||||
"@sleepguard/shared": "workspace:*",
|
||||
"hono": "^4.7.0",
|
||||
"http-proxy": "^1.18.1",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/http-proxy": "^1.17.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/ws": "^8.5.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
7
packages/proxy/src/api/middleware.ts
Normal file
7
packages/proxy/src/api/middleware.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { cors } from 'hono/cors';
|
||||
|
||||
export const corsMiddleware = cors({
|
||||
origin: '*',
|
||||
allowMethods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
allowHeaders: ['Content-Type', 'Authorization'],
|
||||
});
|
||||
92
packages/proxy/src/api/routes.ts
Normal file
92
packages/proxy/src/api/routes.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { ProxyStatus, ServiceHealth } from '@sleepguard/shared';
|
||||
import {
|
||||
getState,
|
||||
getLogs,
|
||||
getLastAgentStatus,
|
||||
manualWake,
|
||||
manualShutdown,
|
||||
refreshAgentStatus,
|
||||
} from '../services/orchestrator.js';
|
||||
import { getLastActivity, getRemainingSeconds } from '../services/idleTimer.js';
|
||||
import { config } from '../config.js';
|
||||
|
||||
const api = new Hono();
|
||||
|
||||
api.get('/status', async (c) => {
|
||||
const agentStatus = getLastAgentStatus();
|
||||
const services: ServiceHealth[] = config.services.map((s) => ({
|
||||
name: s.name,
|
||||
host: s.host,
|
||||
target: s.target,
|
||||
healthy: getState() === 'ONLINE',
|
||||
lastCheck: new Date().toISOString(),
|
||||
}));
|
||||
|
||||
const status: ProxyStatus = {
|
||||
state: getState(),
|
||||
services,
|
||||
config: {
|
||||
idleTimeoutMinutes: config.idleTimeoutMinutes,
|
||||
healthCheckIntervalSeconds: config.healthCheckIntervalSeconds,
|
||||
wakingTimeoutSeconds: config.wakingTimeoutSeconds,
|
||||
},
|
||||
idleTimer:
|
||||
getState() === 'ONLINE'
|
||||
? {
|
||||
lastActivity: getLastActivity().toISOString(),
|
||||
remainingSeconds: getRemainingSeconds(),
|
||||
}
|
||||
: null,
|
||||
agent: agentStatus,
|
||||
locks: agentStatus?.locks ?? [],
|
||||
logs: getLogs().slice(-50),
|
||||
};
|
||||
|
||||
return c.json(status);
|
||||
});
|
||||
|
||||
api.post('/wake', async (c) => {
|
||||
await manualWake();
|
||||
return c.json({ ok: true, state: getState() });
|
||||
});
|
||||
|
||||
api.post('/shutdown', async (c) => {
|
||||
await manualShutdown();
|
||||
return c.json({ ok: true, state: getState() });
|
||||
});
|
||||
|
||||
api.get('/services', (c) => {
|
||||
const services: ServiceHealth[] = config.services.map((s) => ({
|
||||
name: s.name,
|
||||
host: s.host,
|
||||
target: s.target,
|
||||
healthy: getState() === 'ONLINE',
|
||||
lastCheck: new Date().toISOString(),
|
||||
}));
|
||||
return c.json(services);
|
||||
});
|
||||
|
||||
api.get('/logs', (c) => {
|
||||
return c.json(getLogs().slice(-100));
|
||||
});
|
||||
|
||||
api.patch('/config', async (c) => {
|
||||
// Runtime config updates could be added here
|
||||
// For now, return current config
|
||||
return c.json({
|
||||
idleTimeoutMinutes: config.idleTimeoutMinutes,
|
||||
healthCheckIntervalSeconds: config.healthCheckIntervalSeconds,
|
||||
wakingTimeoutSeconds: config.wakingTimeoutSeconds,
|
||||
});
|
||||
});
|
||||
|
||||
api.get('/agent/status', async (c) => {
|
||||
const status = await refreshAgentStatus();
|
||||
if (!status) {
|
||||
return c.json({ error: 'Agent unreachable' }, 503);
|
||||
}
|
||||
return c.json(status);
|
||||
});
|
||||
|
||||
export { api };
|
||||
34
packages/proxy/src/config.ts
Normal file
34
packages/proxy/src/config.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { requireEnv, intEnv, optionalEnv, type ServiceConfig } from '@sleepguard/shared';
|
||||
|
||||
function parseServices(): ServiceConfig[] {
|
||||
const raw = optionalEnv('SERVICES', '[]');
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as ServiceConfig[];
|
||||
return parsed;
|
||||
} catch {
|
||||
console.error('[Config] Failed to parse SERVICES env var');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export const config = {
|
||||
port: intEnv('PROXY_PORT', 47391),
|
||||
|
||||
agent: {
|
||||
url: requireEnv('AGENT_URL'),
|
||||
secret: requireEnv('AGENT_SECRET'),
|
||||
},
|
||||
|
||||
upsnap: {
|
||||
url: requireEnv('UPSNAP_URL'),
|
||||
username: requireEnv('UPSNAP_USERNAME'),
|
||||
password: requireEnv('UPSNAP_PASSWORD'),
|
||||
deviceId: requireEnv('UPSNAP_DEVICE_ID'),
|
||||
},
|
||||
|
||||
idleTimeoutMinutes: intEnv('IDLE_TIMEOUT_MINUTES', 15),
|
||||
healthCheckIntervalSeconds: intEnv('HEALTH_CHECK_INTERVAL_SECONDS', 10),
|
||||
wakingTimeoutSeconds: intEnv('WAKING_TIMEOUT_SECONDS', 120),
|
||||
|
||||
services: parseServices(),
|
||||
} as const;
|
||||
75
packages/proxy/src/index.ts
Normal file
75
packages/proxy/src/index.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { createServer } from 'node:http';
|
||||
import { createAdaptorServer } from '@hono/node-server';
|
||||
import { MachineState, type ServiceConfig } from '@sleepguard/shared';
|
||||
import { app } from './server/app.js';
|
||||
import { proxyRequest, proxyWebSocket } from './server/proxy.js';
|
||||
import { initOrchestrator, getState, onRequest } from './services/orchestrator.js';
|
||||
import { addClient, removeClient } from './server/ws.js';
|
||||
import { config } from './config.js';
|
||||
import { WebSocketServer } from 'ws';
|
||||
|
||||
function findServiceByHost(host: string | undefined): ServiceConfig | undefined {
|
||||
if (!host) return undefined;
|
||||
const hostname = host.split(':')[0];
|
||||
return config.services.find((s) => s.host === hostname);
|
||||
}
|
||||
|
||||
// Create the base HTTP server with Hono
|
||||
const server = createServer(async (req, res) => {
|
||||
const service = findServiceByHost(req.headers.host);
|
||||
|
||||
// If it's a proxied service and machine is online, proxy directly
|
||||
if (service) {
|
||||
onRequest();
|
||||
const state = getState();
|
||||
|
||||
if (state === MachineState.ONLINE || state === MachineState.IDLE_CHECK) {
|
||||
proxyRequest(req, res, service);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise let Hono handle (API, dashboard, waking page)
|
||||
const honoHandler = createAdaptorServer(app);
|
||||
honoHandler.emit('request', req, res);
|
||||
});
|
||||
|
||||
// Handle WebSocket upgrades
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
|
||||
server.on('upgrade', (req, socket, head) => {
|
||||
const service = findServiceByHost(req.headers.host);
|
||||
|
||||
if (service) {
|
||||
onRequest();
|
||||
const state = getState();
|
||||
if (state === MachineState.ONLINE || state === MachineState.IDLE_CHECK) {
|
||||
proxyWebSocket(req, socket, head, service);
|
||||
return;
|
||||
}
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
// Dashboard WebSocket at /api/ws
|
||||
if (req.url === '/api/ws') {
|
||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
addClient(ws);
|
||||
ws.on('close', () => removeClient(ws));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
socket.destroy();
|
||||
});
|
||||
|
||||
// Start
|
||||
async function start(): Promise<void> {
|
||||
await initOrchestrator();
|
||||
server.listen(config.port, () => {
|
||||
console.log(`[SleepGuard Proxy] Running on port ${config.port}`);
|
||||
console.log(`[SleepGuard Proxy] Services: ${config.services.map((s) => s.name).join(', ') || 'none configured'}`);
|
||||
});
|
||||
}
|
||||
|
||||
start();
|
||||
57
packages/proxy/src/server/app.ts
Normal file
57
packages/proxy/src/server/app.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Hono } from 'hono';
|
||||
import { serveStatic } from '@hono/node-server/serve-static';
|
||||
import { MachineState, type ServiceConfig } from '@sleepguard/shared';
|
||||
import { corsMiddleware } from '../api/middleware.js';
|
||||
import { api } from '../api/routes.js';
|
||||
import { getState, onRequest } from '../services/orchestrator.js';
|
||||
import { getWakingPageHtml } from './wakingPage.js';
|
||||
import { config } from '../config.js';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
// CORS for API
|
||||
app.use('/api/*', corsMiddleware);
|
||||
|
||||
// API routes
|
||||
app.route('/api', api);
|
||||
|
||||
// Dashboard static files (in Docker: ./public/dashboard, in dev: ../web/build)
|
||||
app.use('/dashboard/*', serveStatic({ root: './public' }));
|
||||
app.get('/dashboard', serveStatic({ root: './public', path: '/dashboard/index.html' }));
|
||||
// SPA fallback for dashboard subroutes
|
||||
app.get('/dashboard/*', serveStatic({ root: './public', path: '/dashboard/index.html' }));
|
||||
|
||||
// Service proxy — match by Host header
|
||||
app.all('*', async (c) => {
|
||||
const host = c.req.header('host')?.split(':')[0];
|
||||
if (!host) {
|
||||
return c.text('Bad Request: no Host header', 400);
|
||||
}
|
||||
|
||||
const service = config.services.find((s) => s.host === host);
|
||||
if (!service) {
|
||||
// Not a proxied service — maybe the dashboard on the main host
|
||||
return c.text('Not Found', 404);
|
||||
}
|
||||
|
||||
// Track activity
|
||||
onRequest();
|
||||
|
||||
const state = getState();
|
||||
|
||||
if (state === MachineState.OFFLINE || state === MachineState.WAKING) {
|
||||
return c.html(getWakingPageHtml(service.name));
|
||||
}
|
||||
|
||||
if (state === MachineState.SHUTTING_DOWN) {
|
||||
return c.html(getWakingPageHtml(service.name));
|
||||
}
|
||||
|
||||
// ONLINE or IDLE_CHECK — proxy the request
|
||||
// We'll handle actual proxying at the Node.js http level, not via Hono
|
||||
// Mark this request for proxy pass-through
|
||||
c.set('proxyTarget' as never, service as never);
|
||||
return c.text('__PROXY__', 200);
|
||||
});
|
||||
|
||||
export { app };
|
||||
39
packages/proxy/src/server/proxy.ts
Normal file
39
packages/proxy/src/server/proxy.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import httpProxy from 'http-proxy';
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import type { ServiceConfig } from '@sleepguard/shared';
|
||||
|
||||
const proxyServer = httpProxy.createProxyServer({
|
||||
ws: true,
|
||||
changeOrigin: true,
|
||||
xfwd: true,
|
||||
});
|
||||
|
||||
proxyServer.on('error', (err, _req, res) => {
|
||||
console.error('[Proxy] Error:', err.message);
|
||||
if (res && 'writeHead' in res) {
|
||||
const serverRes = res as ServerResponse;
|
||||
if (!serverRes.headersSent) {
|
||||
serverRes.writeHead(502, { 'Content-Type': 'text/plain' });
|
||||
serverRes.end('Bad Gateway');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export function proxyRequest(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
service: ServiceConfig
|
||||
): void {
|
||||
proxyServer.web(req, res, { target: service.target });
|
||||
}
|
||||
|
||||
export function proxyWebSocket(
|
||||
req: IncomingMessage,
|
||||
socket: unknown,
|
||||
head: Buffer,
|
||||
service: ServiceConfig
|
||||
): void {
|
||||
proxyServer.ws(req, socket as import('node:net').Socket, head, { target: service.target });
|
||||
}
|
||||
|
||||
export { proxyServer };
|
||||
68
packages/proxy/src/server/wakingPage.ts
Normal file
68
packages/proxy/src/server/wakingPage.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
export function getWakingPageHtml(serviceName: string): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Waking up — ${serviceName}</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.container {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
.spinner {
|
||||
width: 48px; height: 48px;
|
||||
border: 4px solid #334155;
|
||||
border-top-color: #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 1.5rem;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
|
||||
p { color: #94a3b8; font-size: 0.95rem; }
|
||||
.status { margin-top: 1rem; font-size: 0.85rem; color: #64748b; }
|
||||
.status.ready { color: #22c55e; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="spinner" id="spinner"></div>
|
||||
<h1>Waking up ${serviceName}...</h1>
|
||||
<p>The server is starting. This page will reload automatically.</p>
|
||||
<p class="status" id="status">Waiting for response...</p>
|
||||
</div>
|
||||
<script>
|
||||
const CHECK_INTERVAL = 3000;
|
||||
let attempt = 0;
|
||||
async function check() {
|
||||
attempt++;
|
||||
const el = document.getElementById('status');
|
||||
el.textContent = 'Checking... (attempt ' + attempt + ')';
|
||||
try {
|
||||
const res = await fetch(window.location.href, { redirect: 'follow' });
|
||||
const text = await res.text();
|
||||
if (!text.includes('Waking up')) {
|
||||
el.textContent = 'Ready! Reloading...';
|
||||
el.className = 'status ready';
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
} catch (e) {}
|
||||
setTimeout(check, CHECK_INTERVAL);
|
||||
}
|
||||
setTimeout(check, CHECK_INTERVAL);
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
33
packages/proxy/src/server/ws.ts
Normal file
33
packages/proxy/src/server/ws.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { WebSocket } from 'ws';
|
||||
import type { WsMessage } from '@sleepguard/shared';
|
||||
|
||||
type WsClient = WebSocket;
|
||||
|
||||
const clients = new Set<WsClient>();
|
||||
|
||||
export function addClient(ws: WsClient): void {
|
||||
clients.add(ws);
|
||||
}
|
||||
|
||||
export function removeClient(ws: WsClient): void {
|
||||
clients.delete(ws);
|
||||
}
|
||||
|
||||
export function broadcast(message: WsMessage): void {
|
||||
const data = JSON.stringify(message);
|
||||
for (const client of clients) {
|
||||
try {
|
||||
if (client.readyState === 1) {
|
||||
client.send(data);
|
||||
} else {
|
||||
clients.delete(client);
|
||||
}
|
||||
} catch {
|
||||
clients.delete(client);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getClientCount(): number {
|
||||
return clients.size;
|
||||
}
|
||||
32
packages/proxy/src/services/healthCheck.ts
Normal file
32
packages/proxy/src/services/healthCheck.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { config } from '../config.js';
|
||||
|
||||
export type HealthCallback = (healthy: boolean) => void;
|
||||
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
export async function checkHealth(): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`${config.agent.url}/api/health`, {
|
||||
headers: { Authorization: `Bearer ${config.agent.secret}` },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function startHealthCheck(callback: HealthCallback): void {
|
||||
stopHealthCheck();
|
||||
intervalId = setInterval(async () => {
|
||||
const healthy = await checkHealth();
|
||||
callback(healthy);
|
||||
}, config.healthCheckIntervalSeconds * 1000);
|
||||
}
|
||||
|
||||
export function stopHealthCheck(): void {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
intervalId = null;
|
||||
}
|
||||
}
|
||||
42
packages/proxy/src/services/idleTimer.ts
Normal file
42
packages/proxy/src/services/idleTimer.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
export type IdleCallback = () => void;
|
||||
|
||||
let lastActivity = Date.now();
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
let idleTimeoutMs: number;
|
||||
let onIdle: IdleCallback | null = null;
|
||||
|
||||
export function initIdleTimer(timeoutMinutes: number, callback: IdleCallback): void {
|
||||
idleTimeoutMs = timeoutMinutes * 60 * 1000;
|
||||
onIdle = callback;
|
||||
resetIdleTimer();
|
||||
}
|
||||
|
||||
export function resetIdleTimer(): void {
|
||||
lastActivity = Date.now();
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
if (onIdle && idleTimeoutMs > 0) {
|
||||
timeoutId = setTimeout(() => {
|
||||
onIdle?.();
|
||||
}, idleTimeoutMs);
|
||||
}
|
||||
}
|
||||
|
||||
export function stopIdleTimer(): void {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getLastActivity(): Date {
|
||||
return new Date(lastActivity);
|
||||
}
|
||||
|
||||
export function getRemainingSeconds(): number {
|
||||
if (!idleTimeoutMs) return 0;
|
||||
const elapsed = Date.now() - lastActivity;
|
||||
const remaining = Math.max(0, idleTimeoutMs - elapsed);
|
||||
return Math.round(remaining / 1000);
|
||||
}
|
||||
234
packages/proxy/src/services/orchestrator.ts
Normal file
234
packages/proxy/src/services/orchestrator.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import { MachineState, type AgentStatus, type CanShutdownResponse, type LogEntry } from '@sleepguard/shared';
|
||||
import { wakeDevice } from './upsnap.js';
|
||||
import { checkHealth, startHealthCheck, stopHealthCheck } from './healthCheck.js';
|
||||
import { initIdleTimer, resetIdleTimer, stopIdleTimer } from './idleTimer.js';
|
||||
import { config } from '../config.js';
|
||||
import { broadcast } from '../server/ws.js';
|
||||
|
||||
let state: MachineState = MachineState.OFFLINE;
|
||||
let wakingTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let lastAgentStatus: AgentStatus | null = null;
|
||||
const logs: LogEntry[] = [];
|
||||
let pendingWake = false;
|
||||
|
||||
function log(level: LogEntry['level'], message: string, details?: string): void {
|
||||
const entry: LogEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
level,
|
||||
message,
|
||||
details,
|
||||
};
|
||||
logs.push(entry);
|
||||
if (logs.length > 200) logs.shift();
|
||||
broadcast({ type: 'log', data: entry, timestamp: entry.timestamp });
|
||||
}
|
||||
|
||||
function setState(newState: MachineState): void {
|
||||
const prev = state;
|
||||
state = newState;
|
||||
log('info', `State: ${prev} → ${newState}`);
|
||||
broadcast({ type: 'state_change', data: { from: prev, to: newState }, timestamp: new Date().toISOString() });
|
||||
}
|
||||
|
||||
async function fetchAgentStatus(): Promise<AgentStatus | null> {
|
||||
try {
|
||||
const res = await fetch(`${config.agent.url}/api/status`, {
|
||||
headers: { Authorization: `Bearer ${config.agent.secret}` },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = (await res.json()) as AgentStatus;
|
||||
lastAgentStatus = data;
|
||||
return data;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCanShutdown(): Promise<CanShutdownResponse | null> {
|
||||
try {
|
||||
const res = await fetch(`${config.agent.url}/api/can-shutdown`, {
|
||||
headers: { Authorization: `Bearer ${config.agent.secret}` },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as CanShutdownResponse;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function doWake(): Promise<void> {
|
||||
setState(MachineState.WAKING);
|
||||
try {
|
||||
await wakeDevice();
|
||||
log('info', 'WoL packet sent via UpSnap');
|
||||
} catch (err) {
|
||||
log('error', 'Failed to wake device', String(err));
|
||||
}
|
||||
|
||||
// Start polling health
|
||||
startHealthCheck((healthy) => {
|
||||
if (healthy && state === MachineState.WAKING) {
|
||||
onOnline();
|
||||
}
|
||||
});
|
||||
|
||||
// Waking timeout
|
||||
wakingTimeout = setTimeout(() => {
|
||||
if (state === MachineState.WAKING) {
|
||||
log('error', 'Waking timeout exceeded');
|
||||
setState(MachineState.OFFLINE);
|
||||
stopHealthCheck();
|
||||
}
|
||||
}, config.wakingTimeoutSeconds * 1000);
|
||||
}
|
||||
|
||||
function onOnline(): void {
|
||||
if (wakingTimeout) {
|
||||
clearTimeout(wakingTimeout);
|
||||
wakingTimeout = null;
|
||||
}
|
||||
setState(MachineState.ONLINE);
|
||||
initIdleTimer(config.idleTimeoutMinutes, onIdleTimeout);
|
||||
|
||||
// Keep health check running to detect crashes
|
||||
stopHealthCheck();
|
||||
startHealthCheck((healthy) => {
|
||||
if (!healthy && (state === MachineState.ONLINE || state === MachineState.IDLE_CHECK)) {
|
||||
log('warn', 'Agent became unreachable');
|
||||
setState(MachineState.OFFLINE);
|
||||
stopIdleTimer();
|
||||
stopHealthCheck();
|
||||
if (pendingWake) {
|
||||
pendingWake = false;
|
||||
doWake();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch initial status
|
||||
fetchAgentStatus();
|
||||
}
|
||||
|
||||
async function onIdleTimeout(): Promise<void> {
|
||||
if (state !== MachineState.ONLINE) return;
|
||||
setState(MachineState.IDLE_CHECK);
|
||||
|
||||
const result = await fetchCanShutdown();
|
||||
if (!result) {
|
||||
log('warn', 'Could not reach agent for idle check, returning to ONLINE');
|
||||
setState(MachineState.ONLINE);
|
||||
initIdleTimer(config.idleTimeoutMinutes, onIdleTimeout);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.canShutdown) {
|
||||
log('info', 'Agent is busy, returning to ONLINE', result.reasons.join('; '));
|
||||
setState(MachineState.ONLINE);
|
||||
initIdleTimer(config.idleTimeoutMinutes, onIdleTimeout);
|
||||
return;
|
||||
}
|
||||
|
||||
// Proceed with shutdown
|
||||
await doShutdown();
|
||||
}
|
||||
|
||||
async function doShutdown(): Promise<void> {
|
||||
setState(MachineState.SHUTTING_DOWN);
|
||||
stopIdleTimer();
|
||||
|
||||
try {
|
||||
// Ask agent to shutdown the machine via UpSnap
|
||||
// UpSnap uses its own shutdown mechanism (SSH/script)
|
||||
const { shutdownDevice: upSnapShutdown } = await import('./upsnap.js');
|
||||
await upSnapShutdown();
|
||||
log('info', 'Shutdown command sent');
|
||||
} catch (err) {
|
||||
log('error', 'Shutdown failed', String(err));
|
||||
}
|
||||
|
||||
// Wait for agent to go offline
|
||||
stopHealthCheck();
|
||||
startHealthCheck((healthy) => {
|
||||
if (!healthy && state === MachineState.SHUTTING_DOWN) {
|
||||
setState(MachineState.OFFLINE);
|
||||
stopHealthCheck();
|
||||
if (pendingWake) {
|
||||
pendingWake = false;
|
||||
doWake();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Safety timeout — if machine doesn't go offline in 2 min, mark offline anyway
|
||||
setTimeout(() => {
|
||||
if (state === MachineState.SHUTTING_DOWN) {
|
||||
log('warn', 'Shutdown timeout, marking as offline');
|
||||
setState(MachineState.OFFLINE);
|
||||
stopHealthCheck();
|
||||
}
|
||||
}, 120_000);
|
||||
}
|
||||
|
||||
// --- Public API ---
|
||||
|
||||
export function getState(): MachineState {
|
||||
return state;
|
||||
}
|
||||
|
||||
export function getLogs(): LogEntry[] {
|
||||
return [...logs];
|
||||
}
|
||||
|
||||
export function getLastAgentStatus(): AgentStatus | null {
|
||||
return lastAgentStatus;
|
||||
}
|
||||
|
||||
export function onRequest(): void {
|
||||
if (state === MachineState.ONLINE) {
|
||||
resetIdleTimer();
|
||||
} else if (state === MachineState.IDLE_CHECK) {
|
||||
// Cancel idle check, back to ONLINE
|
||||
setState(MachineState.ONLINE);
|
||||
resetIdleTimer();
|
||||
} else if (state === MachineState.OFFLINE) {
|
||||
doWake();
|
||||
} else if (state === MachineState.SHUTTING_DOWN) {
|
||||
pendingWake = true;
|
||||
log('info', 'Request during shutdown, will wake after shutdown completes');
|
||||
}
|
||||
// WAKING — do nothing, already waking
|
||||
}
|
||||
|
||||
export async function manualWake(): Promise<void> {
|
||||
if (state === MachineState.OFFLINE) {
|
||||
await doWake();
|
||||
} else {
|
||||
log('info', 'Manual wake ignored — not in OFFLINE state');
|
||||
}
|
||||
}
|
||||
|
||||
export async function manualShutdown(): Promise<void> {
|
||||
if (state === MachineState.ONLINE || state === MachineState.IDLE_CHECK) {
|
||||
await doShutdown();
|
||||
} else {
|
||||
log('info', 'Manual shutdown ignored — not in ONLINE/IDLE_CHECK state');
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshAgentStatus(): Promise<AgentStatus | null> {
|
||||
return fetchAgentStatus();
|
||||
}
|
||||
|
||||
// Initialize: check if PC is already online
|
||||
export async function initOrchestrator(): Promise<void> {
|
||||
log('info', 'Orchestrator starting');
|
||||
const healthy = await checkHealth();
|
||||
if (healthy) {
|
||||
log('info', 'Agent is already online');
|
||||
onOnline();
|
||||
} else {
|
||||
log('info', 'Agent is offline');
|
||||
}
|
||||
}
|
||||
84
packages/proxy/src/services/upsnap.ts
Normal file
84
packages/proxy/src/services/upsnap.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import type { UpSnapAuthResponse } from '@sleepguard/shared';
|
||||
import { config } from '../config.js';
|
||||
|
||||
let token: string | null = null;
|
||||
|
||||
async function authenticate(): Promise<string> {
|
||||
const res = await fetch(
|
||||
`${config.upsnap.url}/api/collections/users/auth-with-password`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
identity: config.upsnap.username,
|
||||
password: config.upsnap.password,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`UpSnap auth failed: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as UpSnapAuthResponse;
|
||||
token = data.token;
|
||||
return token;
|
||||
}
|
||||
|
||||
async function getToken(): Promise<string> {
|
||||
if (!token) {
|
||||
return authenticate();
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
async function upSnapFetch(path: string, options: RequestInit = {}): Promise<Response> {
|
||||
let authToken = await getToken();
|
||||
|
||||
const doFetch = (t: string) =>
|
||||
fetch(`${config.upsnap.url}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: t,
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
let res = await doFetch(authToken);
|
||||
|
||||
// Token expired — re-auth once
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
authToken = await authenticate();
|
||||
res = await doFetch(authToken);
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
export async function wakeDevice(): Promise<void> {
|
||||
const res = await upSnapFetch(
|
||||
`/api/collections/devices/records/${config.upsnap.deviceId}`,
|
||||
{ method: 'PATCH', body: JSON.stringify({ status: 'on' }) }
|
||||
);
|
||||
|
||||
// UpSnap may also have a dedicated wake endpoint — try both approaches
|
||||
if (!res.ok) {
|
||||
// Fallback: POST to wake endpoint
|
||||
const res2 = await upSnapFetch(`/api/upsnap/wake/${config.upsnap.deviceId}`, {
|
||||
method: 'GET',
|
||||
});
|
||||
if (!res2.ok) {
|
||||
throw new Error(`UpSnap wake failed: ${res2.status}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function shutdownDevice(): Promise<void> {
|
||||
const res = await upSnapFetch(`/api/upsnap/shutdown/${config.upsnap.deviceId}`, {
|
||||
method: 'GET',
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`UpSnap shutdown failed: ${res.status}`);
|
||||
}
|
||||
}
|
||||
11
packages/proxy/tsconfig.json
Normal file
11
packages/proxy/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../shared" }
|
||||
]
|
||||
}
|
||||
23
packages/proxy/web/package.json
Normal file
23
packages/proxy/web/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@sleepguard/web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"typecheck": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sleepguard/shared": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-static": "^3.0.0",
|
||||
"@sveltejs/kit": "^2.15.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
78
packages/proxy/web/src/app.css
Normal file
78
packages/proxy/web/src/app.css
Normal file
@@ -0,0 +1,78 @@
|
||||
:root {
|
||||
--bg: #0f172a;
|
||||
--bg-card: #1e293b;
|
||||
--bg-hover: #334155;
|
||||
--text: #e2e8f0;
|
||||
--text-muted: #94a3b8;
|
||||
--text-dim: #64748b;
|
||||
--accent: #3b82f6;
|
||||
--accent-hover: #2563eb;
|
||||
--green: #22c55e;
|
||||
--yellow: #eab308;
|
||||
--red: #ef4444;
|
||||
--orange: #f97316;
|
||||
--border: #334155;
|
||||
--radius: 8px;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
button.primary:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
button.danger {
|
||||
background: var(--red);
|
||||
color: white;
|
||||
}
|
||||
|
||||
button.danger:hover {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
12
packages/proxy/web/src/app.html
Normal file
12
packages/proxy/web/src/app.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
34
packages/proxy/web/src/lib/api.ts
Normal file
34
packages/proxy/web/src/lib/api.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { ProxyStatus, ServiceHealth, LogEntry } from '@sleepguard/shared';
|
||||
|
||||
const BASE = '/api';
|
||||
|
||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...options,
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`API error: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export function fetchStatus(): Promise<ProxyStatus> {
|
||||
return request<ProxyStatus>('/status');
|
||||
}
|
||||
|
||||
export function fetchServices(): Promise<ServiceHealth[]> {
|
||||
return request<ServiceHealth[]>('/services');
|
||||
}
|
||||
|
||||
export function fetchLogs(): Promise<LogEntry[]> {
|
||||
return request<LogEntry[]>('/logs');
|
||||
}
|
||||
|
||||
export function wake(): Promise<{ ok: boolean; state: string }> {
|
||||
return request('/wake', { method: 'POST' });
|
||||
}
|
||||
|
||||
export function shutdown(): Promise<{ ok: boolean; state: string }> {
|
||||
return request('/shutdown', { method: 'POST' });
|
||||
}
|
||||
85
packages/proxy/web/src/lib/components/ActivityLog.svelte
Normal file
85
packages/proxy/web/src/lib/components/ActivityLog.svelte
Normal file
@@ -0,0 +1,85 @@
|
||||
<script lang="ts">
|
||||
import type { LogEntry } from '@sleepguard/shared';
|
||||
|
||||
interface Props {
|
||||
logs: LogEntry[];
|
||||
}
|
||||
|
||||
let { logs }: Props = $props();
|
||||
|
||||
const levelColors: Record<string, string> = {
|
||||
info: 'var(--accent)',
|
||||
warn: 'var(--yellow)',
|
||||
error: 'var(--red)',
|
||||
};
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
return new Date(iso).toLocaleTimeString();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="card">
|
||||
<h2>Activity Log</h2>
|
||||
<div class="log-container">
|
||||
{#if logs.length === 0}
|
||||
<p class="empty">No activity yet</p>
|
||||
{:else}
|
||||
{#each logs.toReversed() as entry}
|
||||
<div class="log-entry">
|
||||
<span class="log-time">{formatTime(entry.timestamp)}</span>
|
||||
<span class="log-level" style:color={levelColors[entry.level]}>{entry.level}</span>
|
||||
<span class="log-message">{entry.message}</span>
|
||||
{#if entry.details}
|
||||
<span class="log-details">{entry.details}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.log-container {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.empty {
|
||||
color: var(--text-dim);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.log-entry {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem 0;
|
||||
font-size: 0.8rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.log-entry:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.log-time {
|
||||
color: var(--text-dim);
|
||||
font-variant-numeric: tabular-nums;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.log-level {
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.7rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.log-message {
|
||||
color: var(--text);
|
||||
}
|
||||
.log-details {
|
||||
width: 100%;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
padding-left: 1rem;
|
||||
}
|
||||
</style>
|
||||
53
packages/proxy/web/src/lib/components/IdleTimer.svelte
Normal file
53
packages/proxy/web/src/lib/components/IdleTimer.svelte
Normal file
@@ -0,0 +1,53 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
lastActivity: string;
|
||||
remainingSeconds: number;
|
||||
}
|
||||
|
||||
let { lastActivity, remainingSeconds }: Props = $props();
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleTimeString();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="card">
|
||||
<h2>Idle Timer</h2>
|
||||
<div class="timer-display">
|
||||
<span class="time">{formatTime(remainingSeconds)}</span>
|
||||
<span class="label">until idle check</span>
|
||||
</div>
|
||||
<p class="last-activity">Last activity: {formatDate(lastActivity)}</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.timer-display {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.time {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.last-activity {
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-dim);
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
70
packages/proxy/web/src/lib/components/LocksList.svelte
Normal file
70
packages/proxy/web/src/lib/components/LocksList.svelte
Normal file
@@ -0,0 +1,70 @@
|
||||
<script lang="ts">
|
||||
import type { Lock } from '@sleepguard/shared';
|
||||
|
||||
interface Props {
|
||||
locks: Lock[];
|
||||
}
|
||||
|
||||
let { locks }: Props = $props();
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleString();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="card">
|
||||
<h2>Locks</h2>
|
||||
{#if locks.length === 0}
|
||||
<p class="empty">No active locks</p>
|
||||
{:else}
|
||||
<ul class="lock-list">
|
||||
{#each locks as lock}
|
||||
<li class="lock-item">
|
||||
<span class="lock-name">{lock.name}</span>
|
||||
{#if lock.reason}
|
||||
<span class="lock-reason">{lock.reason}</span>
|
||||
{/if}
|
||||
{#if lock.expiresAt}
|
||||
<span class="lock-expires">Expires: {formatDate(lock.expiresAt)}</span>
|
||||
{:else}
|
||||
<span class="lock-expires">No expiry</span>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.empty {
|
||||
color: var(--text-dim);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.lock-list {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.lock-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg);
|
||||
}
|
||||
.lock-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
color: var(--yellow);
|
||||
}
|
||||
.lock-reason {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.lock-expires {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
</style>
|
||||
71
packages/proxy/web/src/lib/components/ServiceList.svelte
Normal file
71
packages/proxy/web/src/lib/components/ServiceList.svelte
Normal file
@@ -0,0 +1,71 @@
|
||||
<script lang="ts">
|
||||
import type { ServiceHealth } from '@sleepguard/shared';
|
||||
|
||||
interface Props {
|
||||
services: ServiceHealth[];
|
||||
}
|
||||
|
||||
let { services }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="card">
|
||||
<h2>Services</h2>
|
||||
{#if services.length === 0}
|
||||
<p class="empty">No services configured</p>
|
||||
{:else}
|
||||
<ul class="service-list">
|
||||
{#each services as service}
|
||||
<li class="service-item">
|
||||
<span class="dot" class:healthy={service.healthy}></span>
|
||||
<div class="service-info">
|
||||
<span class="service-name">{service.name}</span>
|
||||
<span class="service-host">{service.host}</span>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.empty {
|
||||
color: var(--text-dim);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.service-list {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.service-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg);
|
||||
}
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dot.healthy {
|
||||
background: var(--green);
|
||||
}
|
||||
.service-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.service-name {
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.service-host {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
49
packages/proxy/web/src/lib/components/StateIndicator.svelte
Normal file
49
packages/proxy/web/src/lib/components/StateIndicator.svelte
Normal file
@@ -0,0 +1,49 @@
|
||||
<script lang="ts">
|
||||
import { MachineState } from '@sleepguard/shared';
|
||||
|
||||
interface Props {
|
||||
state: MachineState;
|
||||
}
|
||||
|
||||
let { state }: Props = $props();
|
||||
|
||||
const stateConfig: Record<MachineState, { label: string; color: string; pulse: boolean }> = {
|
||||
[MachineState.OFFLINE]: { label: 'Offline', color: 'var(--text-dim)', pulse: false },
|
||||
[MachineState.WAKING]: { label: 'Waking...', color: 'var(--yellow)', pulse: true },
|
||||
[MachineState.ONLINE]: { label: 'Online', color: 'var(--green)', pulse: false },
|
||||
[MachineState.IDLE_CHECK]: { label: 'Idle Check', color: 'var(--orange)', pulse: true },
|
||||
[MachineState.SHUTTING_DOWN]: { label: 'Shutting Down', color: 'var(--red)', pulse: true },
|
||||
};
|
||||
|
||||
let config = $derived(stateConfig[state]);
|
||||
</script>
|
||||
|
||||
<div class="state-indicator">
|
||||
<span class="dot" class:pulse={config.pulse} style:background-color={config.color}></span>
|
||||
<span class="label">{config.label}</span>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.state-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dot.pulse {
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
.label {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
</style>
|
||||
104
packages/proxy/web/src/lib/components/StatusCard.svelte
Normal file
104
packages/proxy/web/src/lib/components/StatusCard.svelte
Normal file
@@ -0,0 +1,104 @@
|
||||
<script lang="ts">
|
||||
import type { ProxyStatus } from '@sleepguard/shared';
|
||||
import { MachineState } from '@sleepguard/shared';
|
||||
import StateIndicator from './StateIndicator.svelte';
|
||||
import { wake, shutdown } from '../api.js';
|
||||
import { refreshStatus } from '../stores/machine.js';
|
||||
|
||||
interface Props {
|
||||
status: ProxyStatus;
|
||||
}
|
||||
|
||||
let { status }: Props = $props();
|
||||
let loading = $state(false);
|
||||
|
||||
async function handleWake() {
|
||||
loading = true;
|
||||
try {
|
||||
await wake();
|
||||
await refreshStatus();
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleShutdown() {
|
||||
loading = true;
|
||||
try {
|
||||
await shutdown();
|
||||
await refreshStatus();
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
let canWake = $derived(status.state === MachineState.OFFLINE);
|
||||
let canShutdown = $derived(status.state === MachineState.ONLINE || status.state === MachineState.IDLE_CHECK);
|
||||
</script>
|
||||
|
||||
<div class="card">
|
||||
<h2>Machine Status</h2>
|
||||
<StateIndicator state={status.state} />
|
||||
|
||||
<div class="controls">
|
||||
<button class="primary" onclick={handleWake} disabled={!canWake || loading}>
|
||||
Wake
|
||||
</button>
|
||||
<button class="danger" onclick={handleShutdown} disabled={!canShutdown || loading}>
|
||||
Shutdown
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if status.agent}
|
||||
<div class="metrics">
|
||||
<div class="metric">
|
||||
<span class="metric-label">CPU</span>
|
||||
<span class="metric-value">{status.agent.cpu.usagePercent}%</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">RAM</span>
|
||||
<span class="metric-value">{status.agent.memory.usedPercent}%</span>
|
||||
</div>
|
||||
{#if status.agent.gpu.available}
|
||||
<div class="metric">
|
||||
<span class="metric-label">GPU</span>
|
||||
<span class="metric-value">{status.agent.gpu.usagePercent}%</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="metric">
|
||||
<span class="metric-label">Disk I/O</span>
|
||||
<span class="metric-value">{status.agent.diskIo.active ? 'Active' : 'Idle'}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.metric {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.metric-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.metric-value {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
88
packages/proxy/web/src/lib/stores/machine.ts
Normal file
88
packages/proxy/web/src/lib/stores/machine.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { writable, derived } from 'svelte/store';
|
||||
import type { ProxyStatus, WsMessage } from '@sleepguard/shared';
|
||||
import { MachineState } from '@sleepguard/shared';
|
||||
import { fetchStatus } from '../api.js';
|
||||
|
||||
export const status = writable<ProxyStatus | null>(null);
|
||||
export const connected = writable(false);
|
||||
export const error = writable<string | null>(null);
|
||||
|
||||
export const machineState = derived(status, ($s) => $s?.state ?? MachineState.OFFLINE);
|
||||
|
||||
let ws: WebSocket | null = null;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function getWsUrl(): string {
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
return `${proto}//${window.location.host}/api/ws`;
|
||||
}
|
||||
|
||||
export function connectWs(): void {
|
||||
if (ws) return;
|
||||
|
||||
try {
|
||||
ws = new WebSocket(getWsUrl());
|
||||
|
||||
ws.onopen = () => {
|
||||
connected.set(true);
|
||||
error.set(null);
|
||||
// Fetch full status on connect
|
||||
refreshStatus();
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data) as WsMessage;
|
||||
handleMessage(msg);
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
connected.set(false);
|
||||
ws = null;
|
||||
scheduleReconnect();
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
error.set('WebSocket connection failed');
|
||||
ws?.close();
|
||||
};
|
||||
} catch {
|
||||
scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleReconnect(): void {
|
||||
if (reconnectTimer) return;
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
connectWs();
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
function handleMessage(msg: WsMessage): void {
|
||||
if (msg.type === 'state_change' || msg.type === 'status_update') {
|
||||
refreshStatus();
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshStatus(): Promise<void> {
|
||||
try {
|
||||
const s = await fetchStatus();
|
||||
status.set(s);
|
||||
error.set(null);
|
||||
} catch (e) {
|
||||
error.set(e instanceof Error ? e.message : 'Failed to fetch status');
|
||||
}
|
||||
}
|
||||
|
||||
export function disconnectWs(): void {
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
ws?.close();
|
||||
ws = null;
|
||||
}
|
||||
78
packages/proxy/web/src/routes/+layout.svelte
Normal file
78
packages/proxy/web/src/routes/+layout.svelte
Normal file
@@ -0,0 +1,78 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { connectWs, disconnectWs } from '$lib/stores/machine.js';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let { children }: { children: Snippet } = $props();
|
||||
|
||||
onMount(() => {
|
||||
connectWs();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
disconnectWs();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="app">
|
||||
<header>
|
||||
<nav>
|
||||
<a href="/dashboard" class="logo">SleepGuard</a>
|
||||
<div class="nav-links">
|
||||
<a href="/dashboard">Dashboard</a>
|
||||
<a href="/dashboard/settings">Settings</a>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
{@render children()}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.app {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
header {
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 0 1.5rem;
|
||||
}
|
||||
nav {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 56px;
|
||||
}
|
||||
.logo {
|
||||
font-weight: 700;
|
||||
font-size: 1.125rem;
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
.nav-links {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
.nav-links a {
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.nav-links a:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
main {
|
||||
flex: 1;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
88
packages/proxy/web/src/routes/+page.svelte
Normal file
88
packages/proxy/web/src/routes/+page.svelte
Normal file
@@ -0,0 +1,88 @@
|
||||
<script lang="ts">
|
||||
import { status, error, connected } from '$lib/stores/machine.js';
|
||||
import StatusCard from '$lib/components/StatusCard.svelte';
|
||||
import ServiceList from '$lib/components/ServiceList.svelte';
|
||||
import IdleTimer from '$lib/components/IdleTimer.svelte';
|
||||
import LocksList from '$lib/components/LocksList.svelte';
|
||||
import ActivityLog from '$lib/components/ActivityLog.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>SleepGuard Dashboard</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="dashboard">
|
||||
{#if $error}
|
||||
<div class="error-banner">{$error}</div>
|
||||
{/if}
|
||||
|
||||
{#if !$connected}
|
||||
<div class="warning-banner">Connecting to server...</div>
|
||||
{/if}
|
||||
|
||||
{#if $status}
|
||||
<div class="grid">
|
||||
<div class="col-main">
|
||||
<StatusCard status={$status} />
|
||||
{#if $status.idleTimer}
|
||||
<IdleTimer
|
||||
lastActivity={$status.idleTimer.lastActivity}
|
||||
remainingSeconds={$status.idleTimer.remainingSeconds}
|
||||
/>
|
||||
{/if}
|
||||
<ActivityLog logs={$status.logs} />
|
||||
</div>
|
||||
<div class="col-side">
|
||||
<ServiceList services={$status.services} />
|
||||
<LocksList locks={$status.locks} />
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="loading">Loading...</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.dashboard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 320px;
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
.col-main, .col-side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.error-banner {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
border: 1px solid var(--red);
|
||||
color: var(--red);
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.warning-banner {
|
||||
background: rgba(234, 179, 8, 0.15);
|
||||
border: 1px solid var(--yellow);
|
||||
color: var(--yellow);
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.loading {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
padding: 3rem;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
157
packages/proxy/web/src/routes/settings/+page.svelte
Normal file
157
packages/proxy/web/src/routes/settings/+page.svelte
Normal file
@@ -0,0 +1,157 @@
|
||||
<script lang="ts">
|
||||
import { status } from '$lib/stores/machine.js';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Settings — SleepGuard</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="settings">
|
||||
<h1>Settings</h1>
|
||||
|
||||
{#if $status}
|
||||
<div class="card">
|
||||
<h2>Current Configuration</h2>
|
||||
<div class="config-grid">
|
||||
<div class="config-item">
|
||||
<label>Idle Timeout</label>
|
||||
<span>{$status.config.idleTimeoutMinutes} minutes</span>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<label>Health Check Interval</label>
|
||||
<span>{$status.config.healthCheckIntervalSeconds} seconds</span>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<label>Waking Timeout</label>
|
||||
<span>{$status.config.wakingTimeoutSeconds} seconds</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if $status.agent}
|
||||
<div class="card">
|
||||
<h2>Agent Details</h2>
|
||||
<div class="config-grid">
|
||||
<div class="config-item">
|
||||
<label>CPU Cores</label>
|
||||
<span>{$status.agent.cpu.cores}</span>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<label>Load Average</label>
|
||||
<span>{$status.agent.cpu.loadAvg.map(v => v.toFixed(2)).join(' / ')}</span>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<label>Total Memory</label>
|
||||
<span>{Math.round($status.agent.memory.totalMB / 1024)} GB</span>
|
||||
</div>
|
||||
{#if $status.agent.gpu.available}
|
||||
<div class="config-item">
|
||||
<label>GPU</label>
|
||||
<span>{$status.agent.gpu.name}</span>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<label>GPU Memory</label>
|
||||
<span>{$status.agent.gpu.memoryUsedMB} / {$status.agent.gpu.memoryTotalMB} MB</span>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<label>GPU Temp</label>
|
||||
<span>{$status.agent.gpu.temperature}°C</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="config-item">
|
||||
<label>Disk I/O</label>
|
||||
<span>R: {$status.agent.diskIo.readKBps} KB/s | W: {$status.agent.diskIo.writeKBps} KB/s</span>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<label>Uptime</label>
|
||||
<span>{Math.round($status.agent.uptime / 3600)} hours</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Watched Processes</h2>
|
||||
<div class="process-list">
|
||||
{#each $status.agent.processes as proc}
|
||||
<div class="process-item">
|
||||
<span class="dot" class:running={proc.running}></span>
|
||||
<span class="process-name">{proc.name}</span>
|
||||
{#if proc.running}
|
||||
<span class="process-pids">PIDs: {proc.pids.join(', ')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="loading">Loading configuration...</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.settings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.config-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.config-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
.config-item label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.config-item span {
|
||||
font-weight: 500;
|
||||
}
|
||||
.process-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.process-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem 0.5rem;
|
||||
background: var(--bg);
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dot.running {
|
||||
background: var(--green);
|
||||
}
|
||||
.process-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
.process-pids {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
.loading {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
5
packages/proxy/web/static/favicon.svg
Normal file
5
packages/proxy/web/static/favicon.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<circle cx="16" cy="16" r="14" fill="#1e293b" stroke="#3b82f6" stroke-width="2"/>
|
||||
<circle cx="16" cy="14" r="4" fill="#3b82f6"/>
|
||||
<path d="M10 22 Q16 18 22 22" stroke="#3b82f6" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 304 B |
19
packages/proxy/web/svelte.config.js
Normal file
19
packages/proxy/web/svelte.config.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import adapter from '@sveltejs/adapter-static';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
preprocess: vitePreprocess(),
|
||||
kit: {
|
||||
adapter: adapter({
|
||||
pages: 'build',
|
||||
assets: 'build',
|
||||
fallback: 'index.html',
|
||||
}),
|
||||
paths: {
|
||||
base: '/dashboard',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
14
packages/proxy/web/tsconfig.json
Normal file
14
packages/proxy/web/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
}
|
||||
11
packages/proxy/web/vite.config.ts
Normal file
11
packages/proxy/web/vite.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:3000',
|
||||
},
|
||||
},
|
||||
});
|
||||
23
packages/shared/package.json
Normal file
23
packages/shared/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@sleepguard/shared",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"dev": "tsc --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
5
packages/shared/src/index.ts
Normal file
5
packages/shared/src/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './types/common.js';
|
||||
export * from './types/agent.js';
|
||||
export * from './types/proxy.js';
|
||||
export * from './types/upsnap.js';
|
||||
export * from './utils/env.js';
|
||||
60
packages/shared/src/types/agent.ts
Normal file
60
packages/shared/src/types/agent.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
export interface CpuStatus {
|
||||
usagePercent: number;
|
||||
loadAvg: [number, number, number];
|
||||
cores: number;
|
||||
}
|
||||
|
||||
export interface GpuStatus {
|
||||
available: boolean;
|
||||
usagePercent: number;
|
||||
memoryUsedMB: number;
|
||||
memoryTotalMB: number;
|
||||
temperature: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface MemoryStatus {
|
||||
usedPercent: number;
|
||||
usedMB: number;
|
||||
totalMB: number;
|
||||
}
|
||||
|
||||
export interface DiskIoStatus {
|
||||
active: boolean;
|
||||
readKBps: number;
|
||||
writeKBps: number;
|
||||
}
|
||||
|
||||
export interface ProcessInfo {
|
||||
name: string;
|
||||
running: boolean;
|
||||
pids: number[];
|
||||
}
|
||||
|
||||
export interface Lock {
|
||||
name: string;
|
||||
reason?: string;
|
||||
createdAt: string;
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
export interface CreateLockRequest {
|
||||
name: string;
|
||||
ttlSeconds?: number;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface AgentStatus {
|
||||
cpu: CpuStatus;
|
||||
gpu: GpuStatus;
|
||||
memory: MemoryStatus;
|
||||
diskIo: DiskIoStatus;
|
||||
processes: ProcessInfo[];
|
||||
locks: Lock[];
|
||||
uptime: number;
|
||||
}
|
||||
|
||||
export interface CanShutdownResponse {
|
||||
canShutdown: boolean;
|
||||
reasons: string[];
|
||||
}
|
||||
20
packages/shared/src/types/common.ts
Normal file
20
packages/shared/src/types/common.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export enum MachineState {
|
||||
OFFLINE = 'OFFLINE',
|
||||
WAKING = 'WAKING',
|
||||
ONLINE = 'ONLINE',
|
||||
IDLE_CHECK = 'IDLE_CHECK',
|
||||
SHUTTING_DOWN = 'SHUTTING_DOWN',
|
||||
}
|
||||
|
||||
export interface ServiceConfig {
|
||||
name: string;
|
||||
host: string;
|
||||
target: string;
|
||||
}
|
||||
|
||||
export interface LogEntry {
|
||||
timestamp: string;
|
||||
level: 'info' | 'warn' | 'error';
|
||||
message: string;
|
||||
details?: string;
|
||||
}
|
||||
41
packages/shared/src/types/proxy.ts
Normal file
41
packages/shared/src/types/proxy.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { MachineState, ServiceConfig, LogEntry } from './common.js';
|
||||
import type { AgentStatus, Lock } from './agent.js';
|
||||
|
||||
export interface ProxyConfig {
|
||||
idleTimeoutMinutes: number;
|
||||
healthCheckIntervalSeconds: number;
|
||||
wakingTimeoutSeconds: number;
|
||||
}
|
||||
|
||||
export interface ServiceHealth {
|
||||
name: string;
|
||||
host: string;
|
||||
target: string;
|
||||
healthy: boolean;
|
||||
lastCheck: string | null;
|
||||
}
|
||||
|
||||
export interface ProxyStatus {
|
||||
state: MachineState;
|
||||
services: ServiceHealth[];
|
||||
config: ProxyConfig;
|
||||
idleTimer: {
|
||||
lastActivity: string;
|
||||
remainingSeconds: number;
|
||||
} | null;
|
||||
agent: AgentStatus | null;
|
||||
locks: Lock[];
|
||||
logs: LogEntry[];
|
||||
}
|
||||
|
||||
export type WsMessageType =
|
||||
| 'state_change'
|
||||
| 'status_update'
|
||||
| 'service_health'
|
||||
| 'log';
|
||||
|
||||
export interface WsMessage {
|
||||
type: WsMessageType;
|
||||
data: unknown;
|
||||
timestamp: string;
|
||||
}
|
||||
18
packages/shared/src/types/upsnap.ts
Normal file
18
packages/shared/src/types/upsnap.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export interface UpSnapAuthResponse {
|
||||
token: string;
|
||||
record: {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface UpSnapDevice {
|
||||
id: string;
|
||||
name: string;
|
||||
ip: string;
|
||||
mac: string;
|
||||
status: string;
|
||||
collectionId: string;
|
||||
collectionName: string;
|
||||
}
|
||||
27
packages/shared/src/utils/env.ts
Normal file
27
packages/shared/src/utils/env.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export function requireEnv(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (!value) {
|
||||
throw new Error(`Missing required environment variable: ${name}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function optionalEnv(name: string, defaultValue: string): string {
|
||||
return process.env[name] ?? defaultValue;
|
||||
}
|
||||
|
||||
export function intEnv(name: string, defaultValue: number): number {
|
||||
const raw = process.env[name];
|
||||
if (!raw) return defaultValue;
|
||||
const parsed = parseInt(raw, 10);
|
||||
if (isNaN(parsed)) {
|
||||
throw new Error(`Environment variable ${name} must be a number, got: ${raw}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function boolEnv(name: string, defaultValue: boolean): boolean {
|
||||
const raw = process.env[name];
|
||||
if (!raw) return defaultValue;
|
||||
return raw === 'true' || raw === '1';
|
||||
}
|
||||
9
packages/shared/tsconfig.json
Normal file
9
packages/shared/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user