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:
@@ -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' });
|
||||
}
|
||||
|
||||
@@ -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)}>✎</button>
|
||||
<button class="btn-icon btn-icon-danger" title="Delete" onclick={() => handleDelete(service)}>✕</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>
|
||||
|
||||
137
packages/proxy/web/src/lib/components/ServiceModal.svelte
Normal file
137
packages/proxy/web/src/lib/components/ServiceModal.svelte
Normal 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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user