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:
36
.env.example
Normal file
36
.env.example
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# === Proxy (n150) ===
|
||||||
|
# Agent connection
|
||||||
|
AGENT_URL=http://192.168.1.50:48527
|
||||||
|
AGENT_SECRET=change-me-to-a-strong-secret
|
||||||
|
|
||||||
|
# UpSnap
|
||||||
|
UPSNAP_URL=http://localhost:8090
|
||||||
|
UPSNAP_USERNAME=admin
|
||||||
|
UPSNAP_PASSWORD=admin
|
||||||
|
UPSNAP_DEVICE_ID=your-device-id
|
||||||
|
|
||||||
|
# Proxy settings
|
||||||
|
PROXY_PORT=47391
|
||||||
|
IDLE_TIMEOUT_MINUTES=15
|
||||||
|
HEALTH_CHECK_INTERVAL_SECONDS=10
|
||||||
|
WAKING_TIMEOUT_SECONDS=120
|
||||||
|
|
||||||
|
# Services to proxy (JSON array)
|
||||||
|
# Each: { "name": "Scriberr", "host": "scriberr.local", "target": "http://192.168.1.50:23636" }
|
||||||
|
SERVICES='[{"name":"Scriberr","host":"scriberr.local","target":"http://192.168.1.50:23636"}]'
|
||||||
|
|
||||||
|
# === Agent (PC) ===
|
||||||
|
AGENT_PORT=48527
|
||||||
|
AGENT_SECRET=change-me-to-a-strong-secret
|
||||||
|
|
||||||
|
# Thresholds
|
||||||
|
CPU_THRESHOLD_PERCENT=15
|
||||||
|
GPU_THRESHOLD_PERCENT=10
|
||||||
|
RAM_THRESHOLD_PERCENT=50
|
||||||
|
DISK_IO_ACTIVE_THRESHOLD=true
|
||||||
|
|
||||||
|
# Process watchlist (comma-separated)
|
||||||
|
WATCHED_PROCESSES=hashcat,ffmpeg,whisper,python3,ollama
|
||||||
|
|
||||||
|
# Shutdown command
|
||||||
|
SHUTDOWN_COMMAND=systemctl poweroff
|
||||||
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.svelte-kit/
|
||||||
|
.env
|
||||||
|
*.tsbuildinfo
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/sdks
|
||||||
|
!.yarn/versions
|
||||||
|
.pnp.*
|
||||||
1
.yarnrc.yml
Normal file
1
.yarnrc.yml
Normal file
@@ -0,0 +1 @@
|
|||||||
|
nodeLinker: node-modules
|
||||||
30
CLAUDE.md
Normal file
30
CLAUDE.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# SleepGuard
|
||||||
|
|
||||||
|
Wake-on-demand система: proxy на n150 проксирует запросы к сервисам на PC, будит через UpSnap, agent на PC отслеживает нагрузку для умного выключения.
|
||||||
|
|
||||||
|
## Структура
|
||||||
|
|
||||||
|
Yarn workspaces монорепа:
|
||||||
|
- `packages/shared` — общие типы и утилиты
|
||||||
|
- `packages/proxy` — Hono сервер + reverse proxy (n150)
|
||||||
|
- `packages/proxy/web` — SvelteKit dashboard (adapter-static)
|
||||||
|
- `packages/agent` — Hono сервер мониторинга (PC)
|
||||||
|
|
||||||
|
## Разработка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn dev:agent # Agent на :3001
|
||||||
|
yarn dev:proxy # Proxy на :3000
|
||||||
|
yarn dev:web # SvelteKit dev server
|
||||||
|
```
|
||||||
|
|
||||||
|
## Деплой
|
||||||
|
|
||||||
|
Через Dokploy — 2 приложения из одного git repo:
|
||||||
|
- sleepguard-proxy: `packages/proxy/Dockerfile`, build context `.`
|
||||||
|
- sleepguard-agent: `packages/agent/Dockerfile`, build context `.`
|
||||||
|
|
||||||
|
## Стек
|
||||||
|
|
||||||
|
- TypeScript, Hono, http-proxy, SvelteKit (adapter-static)
|
||||||
|
- Docker multi-stage builds
|
||||||
31
compose.yaml
Normal file
31
compose.yaml
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
services:
|
||||||
|
proxy:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: packages/proxy/Dockerfile
|
||||||
|
ports:
|
||||||
|
- "47391:47391"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
agent:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: packages/agent/Dockerfile
|
||||||
|
ports:
|
||||||
|
- "48527:48527"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
# GPU passthrough for nvidia-smi
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
reservations:
|
||||||
|
devices:
|
||||||
|
- driver: nvidia
|
||||||
|
count: all
|
||||||
|
capabilities: [gpu]
|
||||||
|
# Access to /proc/diskstats for disk I/O monitoring
|
||||||
|
volumes:
|
||||||
|
- /proc/diskstats:/proc/diskstats:ro
|
||||||
|
restart: unless-stopped
|
||||||
51
docs/architecture.md
Normal file
51
docs/architecture.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# SleepGuard Architecture
|
||||||
|
|
||||||
|
## Обзор
|
||||||
|
|
||||||
|
Wake-on-demand система из двух компонентов:
|
||||||
|
- **Proxy** (n150, 192.168.50.199) — reverse proxy + state machine + dashboard
|
||||||
|
- **Agent** (PC, 192.168.1.50) — мониторинг нагрузки + lock manager
|
||||||
|
|
||||||
|
## State Machine
|
||||||
|
|
||||||
|
```
|
||||||
|
REQUEST_RECEIVED
|
||||||
|
OFFLINE ─────────────────────► WAKING
|
||||||
|
▲ │
|
||||||
|
│ SHUTDOWN_COMPLETE │ HEALTH_CHECK_PASSED
|
||||||
|
│ ▼
|
||||||
|
SHUTTING_DOWN ◄──── IDLE_CHECK ◄── ONLINE
|
||||||
|
AGENT_IDLE IDLE_TIMEOUT ▲
|
||||||
|
│ │
|
||||||
|
│ AGENT_BUSY / REQUEST
|
||||||
|
└────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- `OFFLINE` → запрос → wake через UpSnap → `WAKING`
|
||||||
|
- `WAKING` → health check passed → `ONLINE`
|
||||||
|
- `ONLINE` → idle timeout → `IDLE_CHECK`
|
||||||
|
- `IDLE_CHECK` → agent busy → `ONLINE`, agent idle → `SHUTTING_DOWN`
|
||||||
|
- `SHUTTING_DOWN` → agent offline → `OFFLINE`
|
||||||
|
|
||||||
|
## Shutdown Policy (Agent)
|
||||||
|
|
||||||
|
Три уровня проверки (все должны быть пройдены):
|
||||||
|
1. **Locks** — если есть активные локи, выключение блокируется
|
||||||
|
2. **Processes** — hashcat, ffmpeg, whisper, python3, ollama
|
||||||
|
3. **Metrics** — CPU >15%, GPU >10%, RAM >50%, Disk I/O active
|
||||||
|
|
||||||
|
## Проксирование
|
||||||
|
|
||||||
|
Proxy матчит сервисы по `Host` заголовку. Если PC online — http-proxy проксирует запрос. Если offline — показывает "Waking up" страницу с автополлом.
|
||||||
|
|
||||||
|
## Деплой
|
||||||
|
|
||||||
|
Два приложения в Dokploy из одного git repo:
|
||||||
|
- `sleepguard-proxy`: Dockerfile `packages/proxy/Dockerfile`, build context `.`
|
||||||
|
- `sleepguard-agent`: Dockerfile `packages/agent/Dockerfile`, build context `.`
|
||||||
|
|
||||||
|
Agent деплоится на PC как remote server в Dokploy с NVIDIA GPU passthrough.
|
||||||
|
|
||||||
|
## Env переменные
|
||||||
|
|
||||||
|
См. `.env.example` в корне репозитория.
|
||||||
20
package.json
Normal file
20
package.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "sleepguard",
|
||||||
|
"private": true,
|
||||||
|
"workspaces": [
|
||||||
|
"packages/*",
|
||||||
|
"packages/proxy/web"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"dev:agent": "yarn workspace @sleepguard/agent dev",
|
||||||
|
"dev:proxy": "yarn workspace @sleepguard/proxy dev",
|
||||||
|
"dev:web": "yarn workspace @sleepguard/web dev",
|
||||||
|
"build": "yarn workspaces foreach -At run build",
|
||||||
|
"build:shared": "yarn workspace @sleepguard/shared build",
|
||||||
|
"build:agent": "yarn workspace @sleepguard/agent build",
|
||||||
|
"build:proxy": "yarn workspace @sleepguard/proxy build",
|
||||||
|
"build:web": "yarn workspace @sleepguard/web build",
|
||||||
|
"typecheck": "yarn workspaces foreach -At run typecheck"
|
||||||
|
},
|
||||||
|
"packageManager": "yarn@4.6.0"
|
||||||
|
}
|
||||||
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"]
|
||||||
|
}
|
||||||
17
tsconfig.base.json
Normal file
17
tsconfig.base.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user