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

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