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:
@@ -7,6 +7,8 @@ services:
|
|||||||
- "47391:47391"
|
- "47391:47391"
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
|
volumes:
|
||||||
|
- ./data/proxy:/app/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
agent:
|
agent:
|
||||||
|
|||||||
@@ -38,6 +38,18 @@ Wake-on-demand система из двух компонентов:
|
|||||||
|
|
||||||
Proxy матчит сервисы по `Host` заголовку. Если PC online — http-proxy проксирует запрос. Если offline — показывает "Waking up" страницу с автополлом.
|
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:
|
Два приложения в Dokploy из одного git repo:
|
||||||
|
|||||||
@@ -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
|
COPY --from=build-web /app/packages/proxy/web/build packages/proxy/public/dashboard
|
||||||
|
|
||||||
WORKDIR /app/packages/proxy
|
WORKDIR /app/packages/proxy
|
||||||
|
RUN mkdir -p /app/data
|
||||||
EXPOSE 47391
|
EXPOSE 47391
|
||||||
CMD ["node", "dist/index.js"]
|
CMD ["node", "dist/index.js"]
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import type { ProxyStatus, ServiceHealth } from '@sleepguard/shared';
|
import type { ProxyStatus, ServiceHealth, ServiceConfig } from '@sleepguard/shared';
|
||||||
import {
|
import {
|
||||||
getState,
|
getState,
|
||||||
getLogs,
|
getLogs,
|
||||||
@@ -10,22 +10,34 @@ import {
|
|||||||
} from '../services/orchestrator.js';
|
} from '../services/orchestrator.js';
|
||||||
import { getLastActivity, getRemainingSeconds } from '../services/idleTimer.js';
|
import { getLastActivity, getRemainingSeconds } from '../services/idleTimer.js';
|
||||||
import { config } from '../config.js';
|
import { config } from '../config.js';
|
||||||
|
import { getServiceManager } from '../services/serviceManager.js';
|
||||||
|
import { broadcast } from '../server/ws.js';
|
||||||
|
|
||||||
const api = new Hono();
|
const api = new Hono();
|
||||||
|
|
||||||
api.get('/status', async (c) => {
|
function buildServiceHealthList(): ServiceHealth[] {
|
||||||
const agentStatus = getLastAgentStatus();
|
return getServiceManager().getAll().map((s) => ({
|
||||||
const services: ServiceHealth[] = config.services.map((s) => ({
|
|
||||||
name: s.name,
|
name: s.name,
|
||||||
host: s.host,
|
host: s.host,
|
||||||
target: s.target,
|
target: s.target,
|
||||||
healthy: getState() === 'ONLINE',
|
healthy: getState() === 'ONLINE',
|
||||||
lastCheck: new Date().toISOString(),
|
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 = {
|
const status: ProxyStatus = {
|
||||||
state: getState(),
|
state: getState(),
|
||||||
services,
|
services: buildServiceHealthList(),
|
||||||
config: {
|
config: {
|
||||||
idleTimeoutMinutes: config.idleTimeoutMinutes,
|
idleTimeoutMinutes: config.idleTimeoutMinutes,
|
||||||
healthCheckIntervalSeconds: config.healthCheckIntervalSeconds,
|
healthCheckIntervalSeconds: config.healthCheckIntervalSeconds,
|
||||||
@@ -57,14 +69,41 @@ api.post('/shutdown', async (c) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
api.get('/services', (c) => {
|
api.get('/services', (c) => {
|
||||||
const services: ServiceHealth[] = config.services.map((s) => ({
|
return c.json(buildServiceHealthList());
|
||||||
name: s.name,
|
});
|
||||||
host: s.host,
|
|
||||||
target: s.target,
|
api.post('/services', async (c) => {
|
||||||
healthy: getState() === 'ONLINE',
|
try {
|
||||||
lastCheck: new Date().toISOString(),
|
const body = await c.req.json<ServiceConfig>();
|
||||||
}));
|
await getServiceManager().create(body);
|
||||||
return c.json(services);
|
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) => {
|
api.get('/logs', (c) => {
|
||||||
@@ -72,8 +111,6 @@ api.get('/logs', (c) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
api.patch('/config', async (c) => {
|
api.patch('/config', async (c) => {
|
||||||
// Runtime config updates could be added here
|
|
||||||
// For now, return current config
|
|
||||||
return c.json({
|
return c.json({
|
||||||
idleTimeoutMinutes: config.idleTimeoutMinutes,
|
idleTimeoutMinutes: config.idleTimeoutMinutes,
|
||||||
healthCheckIntervalSeconds: config.healthCheckIntervalSeconds,
|
healthCheckIntervalSeconds: config.healthCheckIntervalSeconds,
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ import { proxyRequest, proxyWebSocket } from './server/proxy.js';
|
|||||||
import { initOrchestrator, getState, onRequest } from './services/orchestrator.js';
|
import { initOrchestrator, getState, onRequest } from './services/orchestrator.js';
|
||||||
import { addClient, removeClient } from './server/ws.js';
|
import { addClient, removeClient } from './server/ws.js';
|
||||||
import { config } from './config.js';
|
import { config } from './config.js';
|
||||||
|
import { initServiceManager, getServiceManager } from './services/serviceManager.js';
|
||||||
import { WebSocketServer } from 'ws';
|
import { WebSocketServer } from 'ws';
|
||||||
|
|
||||||
function findServiceByHost(host: string | undefined): ServiceConfig | undefined {
|
function findServiceByHost(host: string | undefined): ServiceConfig | undefined {
|
||||||
if (!host) return undefined;
|
if (!host) return undefined;
|
||||||
const hostname = host.split(':')[0];
|
return getServiceManager().findByHost(host);
|
||||||
return config.services.find((s) => s.host === hostname);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the base HTTP server with Hono
|
// Create the base HTTP server with Hono
|
||||||
@@ -65,10 +65,11 @@ server.on('upgrade', (req, socket, head) => {
|
|||||||
|
|
||||||
// Start
|
// Start
|
||||||
async function start(): Promise<void> {
|
async function start(): Promise<void> {
|
||||||
|
await initServiceManager(config.services);
|
||||||
await initOrchestrator();
|
await initOrchestrator();
|
||||||
server.listen(config.port, () => {
|
server.listen(config.port, () => {
|
||||||
console.log(`[SleepGuard Proxy] Running on port ${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'}`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { serveStatic } from '@hono/node-server/serve-static';
|
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 { corsMiddleware } from '../api/middleware.js';
|
||||||
import { api } from '../api/routes.js';
|
import { api } from '../api/routes.js';
|
||||||
import { getState, onRequest } from '../services/orchestrator.js';
|
import { getState, onRequest } from '../services/orchestrator.js';
|
||||||
import { getWakingPageHtml } from './wakingPage.js';
|
import { getWakingPageHtml } from './wakingPage.js';
|
||||||
import { config } from '../config.js';
|
import { getServiceManager } from '../services/serviceManager.js';
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ app.all('*', async (c) => {
|
|||||||
return c.text('Bad Request: no Host header', 400);
|
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) {
|
if (!service) {
|
||||||
// Not a proxied service — maybe the dashboard on the main host
|
// Not a proxied service — maybe the dashboard on the main host
|
||||||
return c.text('Not Found', 404);
|
return c.text('Not Found', 404);
|
||||||
|
|||||||
109
packages/proxy/src/services/serviceManager.ts
Normal file
109
packages/proxy/src/services/serviceManager.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -32,3 +32,15 @@ export function wake(): Promise<{ ok: boolean; state: string }> {
|
|||||||
export function shutdown(): Promise<{ ok: boolean; state: string }> {
|
export function shutdown(): Promise<{ ok: boolean; state: string }> {
|
||||||
return request('/shutdown', { method: 'POST' });
|
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">
|
<script lang="ts">
|
||||||
import type { ServiceHealth } from '@sleepguard/shared';
|
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 {
|
interface Props {
|
||||||
services: ServiceHealth[];
|
services: ServiceHealth[];
|
||||||
@@ -7,6 +10,9 @@
|
|||||||
|
|
||||||
let { services }: Props = $props();
|
let { services }: Props = $props();
|
||||||
|
|
||||||
|
let modalMode = $state<'create' | 'edit' | null>(null);
|
||||||
|
let editingService = $state<ServiceHealth | undefined>(undefined);
|
||||||
|
|
||||||
function timeAgo(iso: string | null): string {
|
function timeAgo(iso: string | null): string {
|
||||||
if (!iso) return 'never';
|
if (!iso) return 'never';
|
||||||
const diff = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
|
const diff = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
|
||||||
@@ -14,10 +20,47 @@
|
|||||||
if (diff < 60) return `${diff}s ago`;
|
if (diff < 60) return `${diff}s ago`;
|
||||||
return `${Math.floor(diff / 60)}m 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>
|
</script>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
<div class="header">
|
||||||
<h2>Services</h2>
|
<h2>Services</h2>
|
||||||
|
<button class="btn-add" onclick={openCreate}>+ Add</button>
|
||||||
|
</div>
|
||||||
{#if services.length === 0}
|
{#if services.length === 0}
|
||||||
<p class="empty">No services configured</p>
|
<p class="empty">No services configured</p>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -35,13 +78,45 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if modalMode}
|
||||||
|
<ServiceModal
|
||||||
|
mode={modalMode}
|
||||||
|
service={editingService}
|
||||||
|
onSave={handleSave}
|
||||||
|
onCancel={() => { modalMode = null; }}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<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 {
|
.empty {
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
@@ -73,6 +148,8 @@
|
|||||||
.service-info {
|
.service-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.service-name {
|
.service-name {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -88,4 +165,24 @@
|
|||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
margin-top: 0.15rem;
|
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>
|
</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 {
|
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();
|
refreshStatus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export type WsMessageType =
|
|||||||
| 'state_change'
|
| 'state_change'
|
||||||
| 'status_update'
|
| 'status_update'
|
||||||
| 'service_health'
|
| 'service_health'
|
||||||
|
| 'service_list_changed'
|
||||||
| 'log';
|
| 'log';
|
||||||
|
|
||||||
export interface WsMessage {
|
export interface WsMessage {
|
||||||
|
|||||||
Reference in New Issue
Block a user