feat(proxy): add service CRUD with persistent storage

ServiceManager with JSON file persistence replaces static env var config.
CRUD API endpoints (POST/PUT/DELETE /api/services) with WebSocket broadcast.
Dashboard: add/edit/delete services via modal form.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Vadim Sobinin
2026-02-10 16:05:44 +03:00
parent 59add2a549
commit 420d75a3b7
12 changed files with 432 additions and 23 deletions

View File

@@ -7,6 +7,8 @@ services:
- "47391:47391"
env_file:
- .env
volumes:
- ./data/proxy:/app/data
restart: unless-stopped
agent:

View File

@@ -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:

View File

@@ -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"]

View File

@@ -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<ServiceConfig>();
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<Partial<Omit<ServiceConfig, 'host'>>>();
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,

View File

@@ -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<void> {
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'}`);
});
}

View File

@@ -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);

View File

@@ -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<void> {
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<void> {
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<Omit<ServiceConfig, 'host'>>): Promise<void> {
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<void> {
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<void> {
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<void> {
instance = new ServiceManager();
await instance.load(envServices);
}
export function getServiceManager(): ServiceManager {
if (!instance) {
throw new Error('ServiceManager not initialized — call initServiceManager() first');
}
return instance;
}

View File

@@ -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' });
}

View File

@@ -1,5 +1,8 @@
<script lang="ts">
import type { ServiceHealth } from '@sleepguard/shared';
import { createService, updateService, deleteService } from '../api.js';
import { refreshStatus } from '../stores/machine.js';
import ServiceModal from './ServiceModal.svelte';
interface Props {
services: ServiceHealth[];
@@ -7,6 +10,9 @@
let { services }: Props = $props();
let modalMode = $state<'create' | 'edit' | null>(null);
let editingService = $state<ServiceHealth | undefined>(undefined);
function timeAgo(iso: string | null): string {
if (!iso) return 'never';
const diff = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
@@ -14,10 +20,47 @@
if (diff < 60) return `${diff}s ago`;
return `${Math.floor(diff / 60)}m ago`;
}
function openCreate() {
editingService = undefined;
modalMode = 'create';
}
function openEdit(service: ServiceHealth) {
editingService = service;
modalMode = 'edit';
}
async function handleDelete(service: ServiceHealth) {
if (!confirm(`Delete service "${service.name}" (${service.host})?`)) return;
try {
await deleteService(service.host);
await refreshStatus();
} catch (e) {
alert(e instanceof Error ? e.message : 'Failed to delete service');
}
}
async function handleSave(data: { name: string; host: string; target: string }) {
try {
if (modalMode === 'create') {
await createService(data);
} else if (modalMode === 'edit') {
await updateService(data.host, { name: data.name, target: data.target });
}
modalMode = null;
await refreshStatus();
} catch (e) {
alert(e instanceof Error ? e.message : 'Failed to save service');
}
}
</script>
<div class="card">
<h2>Services</h2>
<div class="header">
<h2>Services</h2>
<button class="btn-add" onclick={openCreate}>+ Add</button>
</div>
{#if services.length === 0}
<p class="empty">No services configured</p>
{:else}
@@ -35,13 +78,45 @@
{/if}
</span>
</div>
<div class="service-actions">
<button class="btn-icon" title="Edit" onclick={() => openEdit(service)}>&#9998;</button>
<button class="btn-icon btn-icon-danger" title="Delete" onclick={() => handleDelete(service)}>&#10005;</button>
</div>
</li>
{/each}
</ul>
{/if}
</div>
{#if modalMode}
<ServiceModal
mode={modalMode}
service={editingService}
onSave={handleSave}
onCancel={() => { modalMode = null; }}
/>
{/if}
<style>
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.header h2 {
margin-bottom: 0;
}
.btn-add {
background: var(--accent);
color: white;
padding: 0.25rem 0.75rem;
font-size: 0.75rem;
border-radius: var(--radius);
}
.btn-add:hover {
background: var(--accent-hover);
}
.empty {
color: var(--text-dim);
font-size: 0.875rem;
@@ -73,6 +148,8 @@
.service-info {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
}
.service-name {
font-weight: 500;
@@ -88,4 +165,24 @@
color: var(--text-dim);
margin-top: 0.15rem;
}
.service-actions {
display: flex;
gap: 0.25rem;
flex-shrink: 0;
}
.btn-icon {
background: transparent;
color: var(--text-muted);
padding: 0.25rem 0.4rem;
font-size: 0.8rem;
border-radius: 4px;
line-height: 1;
}
.btn-icon:hover {
background: var(--bg-hover);
color: var(--text);
}
.btn-icon-danger:hover {
color: var(--red);
}
</style>

View File

@@ -0,0 +1,137 @@
<script lang="ts">
interface Props {
mode: 'create' | 'edit';
service?: { name: string; host: string; target: string };
onSave: (data: { name: string; host: string; target: string }) => void;
onCancel: () => void;
}
let { mode, service, onSave, onCancel }: Props = $props();
let name = $state(service?.name ?? '');
let host = $state(service?.host ?? '');
let target = $state(service?.target ?? '');
let errorMsg = $state('');
function handleSubmit(e: Event) {
e.preventDefault();
errorMsg = '';
if (!name.trim() || !host.trim() || !target.trim()) {
errorMsg = 'All fields are required';
return;
}
try {
new URL(target);
} catch {
errorMsg = 'Target must be a valid URL';
return;
}
onSave({ name: name.trim(), host: host.trim(), target: target.trim() });
}
</script>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="overlay" onclick={onCancel}>
<div class="modal" onclick={(e) => e.stopPropagation()}>
<h3>{mode === 'create' ? 'Add Service' : 'Edit Service'}</h3>
<form onsubmit={handleSubmit}>
<label>
<span>Name</span>
<input type="text" bind:value={name} placeholder="My Service" />
</label>
<label>
<span>Host</span>
<input type="text" bind:value={host} placeholder="myservice.example.com" disabled={mode === 'edit'} />
</label>
<label>
<span>Target URL</span>
<input type="text" bind:value={target} placeholder="http://192.168.1.100:8080" />
</label>
{#if errorMsg}
<p class="error">{errorMsg}</p>
{/if}
<div class="actions">
<button type="button" class="secondary" onclick={onCancel}>Cancel</button>
<button type="submit" class="primary">
{mode === 'create' ? 'Add' : 'Save'}
</button>
</div>
</form>
</div>
</div>
<style>
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.modal {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.5rem;
width: 90%;
max-width: 420px;
}
h3 {
margin-bottom: 1rem;
font-size: 1rem;
font-weight: 600;
}
label {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-bottom: 0.75rem;
}
label span {
font-size: 0.75rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
input {
padding: 0.5rem 0.75rem;
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--bg);
color: var(--text);
font-size: 0.875rem;
font-family: inherit;
}
input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
input:focus {
outline: none;
border-color: var(--accent);
}
.error {
color: var(--red);
font-size: 0.8rem;
margin-bottom: 0.5rem;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 1rem;
}
.secondary {
background: var(--bg-hover);
color: var(--text);
}
.secondary:hover {
background: var(--border);
}
</style>

View File

@@ -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();
}
}

View File

@@ -32,6 +32,7 @@ export type WsMessageType =
| 'state_change'
| 'status_update'
| 'service_health'
| 'service_list_changed'
| 'log';
export interface WsMessage {