diff --git a/compose.yaml b/compose.yaml index 04e2fb7..3fe7686 100644 --- a/compose.yaml +++ b/compose.yaml @@ -7,6 +7,8 @@ services: - "47391:47391" env_file: - .env + volumes: + - ./data/proxy:/app/data restart: unless-stopped agent: diff --git a/docs/architecture.md b/docs/architecture.md index 20ccf6d..f28b405 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -38,6 +38,18 @@ Wake-on-demand система из двух компонентов: Proxy матчит сервисы по `Host` заголовку. Если PC online — http-proxy проксирует запрос. Если offline — показывает "Waking up" страницу с автополлом. +## Service Management + +Сервисы хранятся в `${DATA_DIR}/services.json` (по умолчанию `./data/services.json`). При первом запуске берутся из env var `SERVICES`, затем управляются через CRUD API: +- `GET /api/services` — список +- `POST /api/services` — создание +- `PUT /api/services/:host` — обновление (host неизменяем) +- `DELETE /api/services/:host` — удаление + +`ServiceManager` (`packages/proxy/src/services/serviceManager.ts`) — in-memory + атомарная запись (tmp + rename). Изменения уведомляют dashboard через WebSocket (`service_list_changed`). + +В Docker volume монтируется: `./data/proxy:/app/data`. + ## Деплой Два приложения в Dokploy из одного git repo: diff --git a/packages/proxy/Dockerfile b/packages/proxy/Dockerfile index b91e07f..ac2ad2b 100644 --- a/packages/proxy/Dockerfile +++ b/packages/proxy/Dockerfile @@ -46,5 +46,6 @@ 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 +RUN mkdir -p /app/data EXPOSE 47391 CMD ["node", "dist/index.js"] diff --git a/packages/proxy/src/api/routes.ts b/packages/proxy/src/api/routes.ts index 9000877..36b48b7 100644 --- a/packages/proxy/src/api/routes.ts +++ b/packages/proxy/src/api/routes.ts @@ -1,5 +1,5 @@ import { Hono } from 'hono'; -import type { ProxyStatus, ServiceHealth } from '@sleepguard/shared'; +import type { ProxyStatus, ServiceHealth, ServiceConfig } from '@sleepguard/shared'; import { getState, getLogs, @@ -10,22 +10,34 @@ import { } from '../services/orchestrator.js'; import { getLastActivity, getRemainingSeconds } from '../services/idleTimer.js'; import { config } from '../config.js'; +import { getServiceManager } from '../services/serviceManager.js'; +import { broadcast } from '../server/ws.js'; const api = new Hono(); -api.get('/status', async (c) => { - const agentStatus = getLastAgentStatus(); - const services: ServiceHealth[] = config.services.map((s) => ({ +function buildServiceHealthList(): ServiceHealth[] { + return getServiceManager().getAll().map((s) => ({ name: s.name, host: s.host, target: s.target, healthy: getState() === 'ONLINE', lastCheck: new Date().toISOString(), })); +} +function broadcastServiceListChanged(): void { + broadcast({ + type: 'service_list_changed', + data: null, + timestamp: new Date().toISOString(), + }); +} + +api.get('/status', async (c) => { + const agentStatus = getLastAgentStatus(); const status: ProxyStatus = { state: getState(), - services, + services: buildServiceHealthList(), config: { idleTimeoutMinutes: config.idleTimeoutMinutes, healthCheckIntervalSeconds: config.healthCheckIntervalSeconds, @@ -57,14 +69,41 @@ api.post('/shutdown', async (c) => { }); 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); + return c.json(buildServiceHealthList()); +}); + +api.post('/services', async (c) => { + try { + const body = await c.req.json(); + await getServiceManager().create(body); + broadcastServiceListChanged(); + return c.json({ ok: true }, 201); + } catch (e) { + return c.json({ error: e instanceof Error ? e.message : 'Unknown error' }, 400); + } +}); + +api.put('/services/:host', async (c) => { + try { + const host = c.req.param('host'); + const body = await c.req.json>>(); + await getServiceManager().update(host, body); + broadcastServiceListChanged(); + return c.json({ ok: true }); + } catch (e) { + return c.json({ error: e instanceof Error ? e.message : 'Unknown error' }, 400); + } +}); + +api.delete('/services/:host', async (c) => { + try { + const host = c.req.param('host'); + await getServiceManager().delete(host); + broadcastServiceListChanged(); + return c.json({ ok: true }); + } catch (e) { + return c.json({ error: e instanceof Error ? e.message : 'Unknown error' }, 400); + } }); api.get('/logs', (c) => { @@ -72,8 +111,6 @@ api.get('/logs', (c) => { }); 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, diff --git a/packages/proxy/src/index.ts b/packages/proxy/src/index.ts index bb7df2d..4b70683 100644 --- a/packages/proxy/src/index.ts +++ b/packages/proxy/src/index.ts @@ -6,12 +6,12 @@ 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 { initServiceManager, getServiceManager } from './services/serviceManager.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); + return getServiceManager().findByHost(host); } // Create the base HTTP server with Hono @@ -65,10 +65,11 @@ server.on('upgrade', (req, socket, head) => { // Start async function start(): Promise { + await initServiceManager(config.services); 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'}`); + console.log(`[SleepGuard Proxy] Services: ${getServiceManager().getAll().map((s) => s.name).join(', ') || 'none configured'}`); }); } diff --git a/packages/proxy/src/server/app.ts b/packages/proxy/src/server/app.ts index f07ada2..cd0a70a 100644 --- a/packages/proxy/src/server/app.ts +++ b/packages/proxy/src/server/app.ts @@ -1,11 +1,11 @@ import { Hono } from 'hono'; import { serveStatic } from '@hono/node-server/serve-static'; -import { MachineState, type ServiceConfig } from '@sleepguard/shared'; +import { MachineState } 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'; +import { getServiceManager } from '../services/serviceManager.js'; const app = new Hono(); @@ -28,7 +28,7 @@ app.all('*', async (c) => { return c.text('Bad Request: no Host header', 400); } - const service = config.services.find((s) => s.host === host); + const service = getServiceManager().findByHost(host); if (!service) { // Not a proxied service — maybe the dashboard on the main host return c.text('Not Found', 404); diff --git a/packages/proxy/src/services/serviceManager.ts b/packages/proxy/src/services/serviceManager.ts new file mode 100644 index 0000000..bd2455e --- /dev/null +++ b/packages/proxy/src/services/serviceManager.ts @@ -0,0 +1,109 @@ +import { writeFile, readFile, rename, mkdir } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import type { ServiceConfig } from '@sleepguard/shared'; + +const DATA_DIR = process.env.DATA_DIR ?? './data'; +const SERVICES_FILE = path.join(DATA_DIR, 'services.json'); + +class ServiceManager { + private services: ServiceConfig[] = []; + + async load(envServices: ServiceConfig[]): Promise { + await mkdir(DATA_DIR, { recursive: true }); + + if (existsSync(SERVICES_FILE)) { + try { + const raw = await readFile(SERVICES_FILE, 'utf-8'); + this.services = JSON.parse(raw) as ServiceConfig[]; + console.log(`[ServiceManager] Loaded ${this.services.length} services from ${SERVICES_FILE}`); + return; + } catch (e) { + console.error('[ServiceManager] Failed to read services file, falling back to env', e); + } + } + + this.services = [...envServices]; + if (this.services.length > 0) { + await this.persist(); + console.log(`[ServiceManager] Initialized ${this.services.length} services from env`); + } else { + console.log('[ServiceManager] No services configured'); + } + } + + getAll(): ServiceConfig[] { + return [...this.services]; + } + + findByHost(host: string): ServiceConfig | undefined { + const hostname = host.split(':')[0]; + return this.services.find((s) => s.host === hostname); + } + + async create(service: ServiceConfig): Promise { + this.validate(service); + if (this.services.some((s) => s.host === service.host)) { + throw new Error(`Service with host '${service.host}' already exists`); + } + this.services.push({ ...service }); + await this.persist(); + } + + async update(host: string, updates: Partial>): Promise { + const index = this.services.findIndex((s) => s.host === host); + if (index === -1) { + throw new Error(`Service with host '${host}' not found`); + } + const updated = { ...this.services[index], ...updates }; + this.validate(updated); + this.services[index] = updated; + await this.persist(); + } + + async delete(host: string): Promise { + const index = this.services.findIndex((s) => s.host === host); + if (index === -1) { + throw new Error(`Service with host '${host}' not found`); + } + this.services.splice(index, 1); + await this.persist(); + } + + private validate(service: ServiceConfig): void { + if (!service.name?.trim()) { + throw new Error('Service name is required'); + } + if (!service.host?.trim()) { + throw new Error('Service host is required'); + } + if (!service.target?.trim()) { + throw new Error('Service target is required'); + } + try { + new URL(service.target); + } catch { + throw new Error(`Invalid target URL: ${service.target}`); + } + } + + private async persist(): Promise { + const tmp = SERVICES_FILE + '.tmp'; + await writeFile(tmp, JSON.stringify(this.services, null, 2), 'utf-8'); + await rename(tmp, SERVICES_FILE); + } +} + +let instance: ServiceManager | null = null; + +export async function initServiceManager(envServices: ServiceConfig[]): Promise { + instance = new ServiceManager(); + await instance.load(envServices); +} + +export function getServiceManager(): ServiceManager { + if (!instance) { + throw new Error('ServiceManager not initialized — call initServiceManager() first'); + } + return instance; +} diff --git a/packages/proxy/web/src/lib/api.ts b/packages/proxy/web/src/lib/api.ts index 7210f21..30bde2f 100644 --- a/packages/proxy/web/src/lib/api.ts +++ b/packages/proxy/web/src/lib/api.ts @@ -32,3 +32,15 @@ export function wake(): Promise<{ ok: boolean; state: string }> { export function shutdown(): Promise<{ ok: boolean; state: string }> { return request('/shutdown', { method: 'POST' }); } + +export function createService(service: { name: string; host: string; target: string }): Promise<{ ok: boolean }> { + return request('/services', { method: 'POST', body: JSON.stringify(service) }); +} + +export function updateService(host: string, updates: { name?: string; target?: string }): Promise<{ ok: boolean }> { + return request(`/services/${encodeURIComponent(host)}`, { method: 'PUT', body: JSON.stringify(updates) }); +} + +export function deleteService(host: string): Promise<{ ok: boolean }> { + return request(`/services/${encodeURIComponent(host)}`, { method: 'DELETE' }); +} diff --git a/packages/proxy/web/src/lib/components/ServiceList.svelte b/packages/proxy/web/src/lib/components/ServiceList.svelte index 2e1706d..ce429a3 100644 --- a/packages/proxy/web/src/lib/components/ServiceList.svelte +++ b/packages/proxy/web/src/lib/components/ServiceList.svelte @@ -1,5 +1,8 @@
-

Services

+
+

Services

+ +
{#if services.length === 0}

No services configured

{:else} @@ -35,13 +78,45 @@ {/if}
+
+ + +
{/each} {/if} +{#if modalMode} + { modalMode = null; }} + /> +{/if} + diff --git a/packages/proxy/web/src/lib/components/ServiceModal.svelte b/packages/proxy/web/src/lib/components/ServiceModal.svelte new file mode 100644 index 0000000..09b7273 --- /dev/null +++ b/packages/proxy/web/src/lib/components/ServiceModal.svelte @@ -0,0 +1,137 @@ + + + + +
+ +
+ + diff --git a/packages/proxy/web/src/lib/stores/machine.ts b/packages/proxy/web/src/lib/stores/machine.ts index bb0bd66..733c748 100644 --- a/packages/proxy/web/src/lib/stores/machine.ts +++ b/packages/proxy/web/src/lib/stores/machine.ts @@ -65,7 +65,7 @@ function scheduleReconnect(): void { } function handleMessage(msg: WsMessage): void { - if (msg.type === 'state_change' || msg.type === 'status_update') { + if (msg.type === 'state_change' || msg.type === 'status_update' || msg.type === 'service_list_changed') { refreshStatus(); } } diff --git a/packages/shared/src/types/proxy.ts b/packages/shared/src/types/proxy.ts index 3e11bf7..9afc279 100644 --- a/packages/shared/src/types/proxy.ts +++ b/packages/shared/src/types/proxy.ts @@ -32,6 +32,7 @@ export type WsMessageType = | 'state_change' | 'status_update' | 'service_health' + | 'service_list_changed' | 'log'; export interface WsMessage {