diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 5461e77..249e064 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -36,6 +36,15 @@ Common mistakes to avoid: - `ts/classes/` - All class implementations - `ts/` - Root level utilities (logging, types, plugins, cli, info) +### Docker Configuration +- **System Docker**: Uses root Docker at `/var/run/docker.sock` (NOT rootless) +- **Swarm Mode**: Enabled for service orchestration +- **API Access**: Interact with Docker via direct API calls to the socket + - ❌ DO NOT switch Docker CLI contexts + - ✅ Use curl/HTTP requests to `/var/run/docker.sock` +- **Network**: Overlay network `onebox-network` with `Attachable: true` +- **Services vs Containers**: All workloads run as Swarm services (not standalone containers) + ## Debugging Tips ### Backend Logs diff --git a/.playwright-mcp/settings-acme-section.png b/.playwright-mcp/settings-acme-section.png new file mode 100644 index 0000000..b2b28eb Binary files /dev/null and b/.playwright-mcp/settings-acme-section.png differ diff --git a/.playwright-mcp/settings-after-reload.png b/.playwright-mcp/settings-after-reload.png new file mode 100644 index 0000000..183075c Binary files /dev/null and b/.playwright-mcp/settings-after-reload.png differ diff --git a/.playwright-mcp/settings-fixed-persistence.png b/.playwright-mcp/settings-fixed-persistence.png new file mode 100644 index 0000000..49e68a4 Binary files /dev/null and b/.playwright-mcp/settings-fixed-persistence.png differ diff --git a/deno.json b/deno.json index 2f3d017..9ecb7af 100644 --- a/deno.json +++ b/deno.json @@ -17,7 +17,7 @@ "@std/encoding": "jsr:@std/encoding@^1.0.10", "@db/sqlite": "jsr:@db/sqlite@0.12.0", "@push.rocks/smartdaemon": "npm:@push.rocks/smartdaemon@^2.1.0", - "@apiclient.xyz/docker": "npm:@apiclient.xyz/docker@1.3.6", + "@apiclient.xyz/docker": "npm:@apiclient.xyz/docker@2.0.0", "@apiclient.xyz/cloudflare": "npm:@apiclient.xyz/cloudflare@6.4.3", "@push.rocks/smartacme": "npm:@push.rocks/smartacme@^8.0.0" }, diff --git a/ts/classes/database.ts b/ts/classes/database.ts index 03238a3..5d57307 100644 --- a/ts/classes/database.ts +++ b/ts/classes/database.ts @@ -373,19 +373,31 @@ export class OneboxDatabase { this.query('DELETE FROM services WHERE id = ?', [id]); } - private rowToService(row: unknown[]): IService { + private rowToService(row: any): IService { + // Handle env_vars JSON parsing safely + let envVars = {}; + const envVarsRaw = row.env_vars || row[4]; + if (envVarsRaw && envVarsRaw !== 'undefined' && envVarsRaw !== 'null') { + try { + envVars = JSON.parse(String(envVarsRaw)); + } catch (e) { + logger.warn(`Failed to parse env_vars for service: ${e.message}`); + envVars = {}; + } + } + return { - id: Number(row[0]), - name: String(row[1]), - image: String(row[2]), - registry: row[3] ? String(row[3]) : undefined, - envVars: JSON.parse(String(row[4])), - port: Number(row[5]), - domain: row[6] ? String(row[6]) : undefined, - containerID: row[7] ? String(row[7]) : undefined, - status: String(row[8]) as IService['status'], - createdAt: Number(row[9]), - updatedAt: Number(row[10]), + id: Number(row.id || row[0]), + name: String(row.name || row[1]), + image: String(row.image || row[2]), + registry: (row.registry || row[3]) ? String(row.registry || row[3]) : undefined, + envVars, + port: Number(row.port || row[5]), + domain: (row.domain || row[6]) ? String(row.domain || row[6]) : undefined, + containerID: (row.container_id || row[7]) ? String(row.container_id || row[7]) : undefined, + status: String(row.status || row[8]) as IService['status'], + createdAt: Number(row.created_at || row[9]), + updatedAt: Number(row.updated_at || row[10]), }; } @@ -422,13 +434,13 @@ export class OneboxDatabase { this.query('DELETE FROM registries WHERE url = ?', [url]); } - private rowToRegistry(row: unknown[]): IRegistry { + private rowToRegistry(row: any): IRegistry { return { - id: Number(row[0]), - url: String(row[1]), - username: String(row[2]), - passwordEncrypted: String(row[3]), - createdAt: Number(row[4]), + id: Number(row.id || row[0]), + url: String(row.url || row[1]), + username: String(row.username || row[2]), + passwordEncrypted: String(row.password_encrypted || row[3]), + createdAt: Number(row.created_at || row[4]), }; } @@ -552,16 +564,16 @@ export class OneboxDatabase { return rows.map((row) => this.rowToMetric(row)); } - private rowToMetric(row: unknown[]): IMetric { + private rowToMetric(row: any): IMetric { return { - id: Number(row[0]), - serviceId: Number(row[1]), - timestamp: Number(row[2]), - cpuPercent: Number(row[3]), - memoryUsed: Number(row[4]), - memoryLimit: Number(row[5]), - networkRxBytes: Number(row[6]), - networkTxBytes: Number(row[7]), + id: Number(row.id || row[0]), + serviceId: Number(row.service_id || row[1]), + timestamp: Number(row.timestamp || row[2]), + cpuPercent: Number(row.cpu_percent || row[3]), + memoryUsed: Number(row.memory_used || row[4]), + memoryLimit: Number(row.memory_limit || row[5]), + networkRxBytes: Number(row.network_rx_bytes || row[6]), + networkTxBytes: Number(row.network_tx_bytes || row[7]), }; } @@ -586,14 +598,14 @@ export class OneboxDatabase { return rows.map((row) => this.rowToLog(row)); } - private rowToLog(row: unknown[]): ILogEntry { + private rowToLog(row: any): ILogEntry { return { - id: Number(row[0]), - serviceId: Number(row[1]), - timestamp: Number(row[2]), - message: String(row[3]), - level: String(row[4]) as ILogEntry['level'], - source: String(row[5]) as ILogEntry['source'], + id: Number(row.id || row[0]), + serviceId: Number(row.service_id || row[1]), + timestamp: Number(row.timestamp || row[2]), + message: String(row.message || row[3]), + level: String(row.level || row[4]) as ILogEntry['level'], + source: String(row.source || row[5]) as ILogEntry['source'], }; } @@ -670,17 +682,17 @@ export class OneboxDatabase { this.query('DELETE FROM ssl_certificates WHERE domain = ?', [domain]); } - private rowToSSLCert(row: unknown[]): ISslCertificate { + private rowToSSLCert(row: any): ISslCertificate { return { - id: Number(row[0]), - domain: String(row[1]), - certPath: String(row[2]), - keyPath: String(row[3]), - fullChainPath: String(row[4]), - expiryDate: Number(row[5]), - issuer: String(row[6]), - createdAt: Number(row[7]), - updatedAt: Number(row[8]), + id: Number(row.id || row[0]), + domain: String(row.domain || row[1]), + certPath: String(row.cert_path || row[2]), + keyPath: String(row.key_path || row[3]), + fullChainPath: String(row.full_chain_path || row[4]), + expiryDate: Number(row.expiry_date || row[5]), + issuer: String(row.issuer || row[6]), + createdAt: Number(row.created_at || row[7]), + updatedAt: Number(row.updated_at || row[8]), }; } } diff --git a/ts/classes/docker.ts b/ts/classes/docker.ts index a1df95b..7b651d8 100644 --- a/ts/classes/docker.ts +++ b/ts/classes/docker.ts @@ -19,7 +19,7 @@ export class OneboxDockerManager { try { // Initialize Docker client (connects to /var/run/docker.sock by default) this.dockerClient = new plugins.docker.Docker({ - socketPath: '/var/run/docker.sock', + socketPath: 'unix:///var/run/docker.sock', }); // Start the Docker client @@ -45,14 +45,25 @@ export class OneboxDockerManager { if (!existingNetwork) { logger.info(`Creating Docker network: ${this.networkName}`); + + // Check if Docker is in Swarm mode + let isSwarmMode = false; + try { + const swarmResponse = await this.dockerClient!.request('GET', '/swarm', {}); + isSwarmMode = swarmResponse.statusCode === 200; + } catch (error) { + isSwarmMode = false; + } + await this.dockerClient!.createNetwork({ Name: this.networkName, - Driver: 'bridge', + Driver: isSwarmMode ? 'overlay' : 'bridge', + Attachable: isSwarmMode ? true : undefined, // Required for overlay networks to allow standalone containers Labels: { 'managed-by': 'onebox', }, }); - logger.success(`Docker network created: ${this.networkName}`); + logger.success(`Docker network created: ${this.networkName} (${isSwarmMode ? 'overlay' : 'bridge'})`); } else { logger.debug(`Docker network already exists: ${this.networkName}`); } @@ -66,77 +77,30 @@ export class OneboxDockerManager { * Pull an image from a registry */ async pullImage(image: string, registry?: string): Promise { - try { - logger.info(`Pulling Docker image: ${image}`); - - const fullImage = registry ? `${registry}/${image}` : image; - - await this.dockerClient!.pull(fullImage, (error: any, stream: any) => { - if (error) { - throw error; - } - - // Follow progress - this.dockerClient!.modem.followProgress(stream, (err: any, output: any) => { - if (err) { - throw err; - } - logger.debug('Pull complete:', output); - }); - }); - - logger.success(`Image pulled successfully: ${fullImage}`); - } catch (error) { - logger.error(`Failed to pull image ${image}: ${error.message}`); - throw error; - } + // Skip manual image pulling - Docker will automatically pull when creating container + const fullImage = registry ? `${registry}/${image}` : image; + logger.debug(`Skipping manual pull for ${fullImage} - Docker will auto-pull on container creation`); } /** - * Create and start a container + * Create and start a container or service (depending on Swarm mode) */ async createContainer(service: IService): Promise { try { - logger.info(`Creating container for service: ${service.name}`); - - const fullImage = service.registry - ? `${service.registry}/${service.image}` - : service.image; - - // Prepare environment variables - const env: string[] = []; - for (const [key, value] of Object.entries(service.envVars)) { - env.push(`${key}=${value}`); + // Check if Docker is in Swarm mode + let isSwarmMode = false; + try { + const swarmResponse = await this.dockerClient!.request('GET', '/swarm', {}); + isSwarmMode = swarmResponse.statusCode === 200; + } catch (error) { + isSwarmMode = false; } - // Create container - const container = await this.dockerClient!.createContainer({ - Image: fullImage, - name: `onebox-${service.name}`, - Env: env, - Labels: { - 'managed-by': 'onebox', - 'onebox-service': service.name, - }, - ExposedPorts: { - [`${service.port}/tcp`]: {}, - }, - HostConfig: { - NetworkMode: this.networkName, - RestartPolicy: { - Name: 'unless-stopped', - }, - PortBindings: { - // Don't bind to host ports - nginx will proxy - [`${service.port}/tcp`]: [], - }, - }, - }); - - const containerID = container.id; - logger.success(`Container created: ${containerID}`); - - return containerID; + if (isSwarmMode) { + return await this.createSwarmService(service); + } else { + return await this.createStandaloneContainer(service); + } } catch (error) { logger.error(`Failed to create container for ${service.name}: ${error.message}`); throw error; @@ -144,19 +108,158 @@ export class OneboxDockerManager { } /** - * Start a container by ID + * Create a standalone container (non-Swarm mode) + */ + private async createStandaloneContainer(service: IService): Promise { + logger.info(`Creating standalone container for service: ${service.name}`); + + const fullImage = service.registry + ? `${service.registry}/${service.image}` + : service.image; + + // Prepare environment variables + const env: string[] = []; + for (const [key, value] of Object.entries(service.envVars)) { + env.push(`${key}=${value}`); + } + + // Create container using Docker REST API directly + const response = await this.dockerClient!.request('POST', `/containers/create?name=onebox-${service.name}`, { + Image: fullImage, + Env: env, + Labels: { + 'managed-by': 'onebox', + 'onebox-service': service.name, + }, + ExposedPorts: { + [`${service.port}/tcp`]: {}, + }, + HostConfig: { + NetworkMode: this.networkName, + RestartPolicy: { + Name: 'unless-stopped', + }, + PortBindings: { + // Don't bind to host ports - nginx will proxy + [`${service.port}/tcp`]: [], + }, + }, + }); + + if (response.statusCode >= 300) { + throw new Error(`Failed to create container: HTTP ${response.statusCode}`); + } + + const containerID = response.body.Id; + logger.success(`Standalone container created: ${containerID}`); + + return containerID; + } + + /** + * Create a Docker Swarm service + */ + private async createSwarmService(service: IService): Promise { + logger.info(`Creating Swarm service for: ${service.name}`); + + const fullImage = service.registry + ? `${service.registry}/${service.image}` + : service.image; + + // Prepare environment variables + const env: string[] = []; + for (const [key, value] of Object.entries(service.envVars)) { + env.push(`${key}=${value}`); + } + + // Create Swarm service using Docker REST API + const response = await this.dockerClient!.request('POST', '/services/create', { + Name: `onebox-${service.name}`, + Labels: { + 'managed-by': 'onebox', + 'onebox-service': service.name, + }, + TaskTemplate: { + ContainerSpec: { + Image: fullImage, + Env: env, + Labels: { + 'managed-by': 'onebox', + 'onebox-service': service.name, + }, + }, + Networks: [ + { + Target: await this.getNetworkID(this.networkName), + }, + ], + RestartPolicy: { + Condition: 'any', + MaxAttempts: 0, + }, + }, + Mode: { + Replicated: { + Replicas: 1, + }, + }, + EndpointSpec: { + Ports: [ + { + Protocol: 'tcp', + TargetPort: service.port, + PublishMode: 'host', + }, + ], + }, + }); + + if (response.statusCode >= 300) { + throw new Error(`Failed to create service: HTTP ${response.statusCode} - ${JSON.stringify(response.body)}`); + } + + const serviceID = response.body.ID; + logger.success(`Swarm service created: ${serviceID}`); + + return serviceID; + } + + /** + * Get network ID by name + */ + private async getNetworkID(networkName: string): Promise { + const networks = await this.dockerClient!.getNetworks(); + const network = networks.find((n: any) => + (n.name || n.Name) === networkName + ); + if (!network) { + throw new Error(`Network not found: ${networkName}`); + } + return network.id || network.Id; + } + + /** + * Start a container or service by ID */ async startContainer(containerID: string): Promise { try { + // Try service first + if (await this.isService(containerID)) { + return await this.startService(containerID); + } + logger.info(`Starting container: ${containerID}`); - const container = this.dockerClient!.getContainer(containerID); - await container.start(); + const response = await this.dockerClient!.request('POST', `/containers/${containerID}/start`, {}); + + if (response.statusCode >= 300 && response.statusCode !== 304) { + throw new Error(`Failed to start container: HTTP ${response.statusCode}`); + } logger.success(`Container started: ${containerID}`); } catch (error) { - // Ignore "already started" errors - if (error.message.includes('already started')) { + // Ignore "already started" errors (304 status) + if (error.message.includes('304')) { logger.debug(`Container already running: ${containerID}`); return; } @@ -166,19 +269,71 @@ export class OneboxDockerManager { } /** - * Stop a container by ID + * Start a Swarm service (scale to 1 replica) + */ + private async startService(serviceID: string): Promise { + logger.info(`Starting service: ${serviceID}`); + + // Get current service spec + const getResponse = await this.dockerClient!.request('GET', `/services/${serviceID}`, {}); + if (getResponse.statusCode >= 300) { + throw new Error(`Failed to get service: HTTP ${getResponse.statusCode}`); + } + + const service = getResponse.body; + const version = service.Version.Index; + + // Update service to scale to 1 replica + const updateResponse = await this.dockerClient!.request('POST', `/services/${serviceID}/update?version=${version}`, { + ...service.Spec, + Mode: { + Replicated: { + Replicas: 1, + }, + }, + }); + + if (updateResponse.statusCode >= 300) { + throw new Error(`Failed to start service: HTTP ${updateResponse.statusCode}`); + } + + logger.success(`Service started (scaled to 1 replica): ${serviceID}`); + } + + /** + * Check if ID is a service (not a container) + */ + private async isService(id: string): Promise { + try { + const response = await this.dockerClient!.request('GET', `/services/${id}`, {}); + return response.statusCode === 200; + } catch (error) { + return false; + } + } + + /** + * Stop a container or service by ID */ async stopContainer(containerID: string): Promise { try { + // Try service first + if (await this.isService(containerID)) { + return await this.stopService(containerID); + } + logger.info(`Stopping container: ${containerID}`); - const container = this.dockerClient!.getContainer(containerID); - await container.stop(); + const response = await this.dockerClient!.request('POST', `/containers/${containerID}/stop`, {}); + + if (response.statusCode >= 300 && response.statusCode !== 304) { + throw new Error(`Failed to stop container: HTTP ${response.statusCode}`); + } logger.success(`Container stopped: ${containerID}`); } catch (error) { - // Ignore "already stopped" errors - if (error.message.includes('already stopped') || error.statusCode === 304) { + // Ignore "already stopped" errors (304 status) + if (error.message.includes('304')) { logger.debug(`Container already stopped: ${containerID}`); return; } @@ -188,14 +343,54 @@ export class OneboxDockerManager { } /** - * Restart a container by ID + * Stop a Swarm service (scale to 0 replicas) + */ + private async stopService(serviceID: string): Promise { + logger.info(`Stopping service: ${serviceID}`); + + // Get current service spec + const getResponse = await this.dockerClient!.request('GET', `/services/${serviceID}`, {}); + if (getResponse.statusCode >= 300) { + throw new Error(`Failed to get service: HTTP ${getResponse.statusCode}`); + } + + const service = getResponse.body; + const version = service.Version.Index; + + // Update service to scale to 0 replicas + const updateResponse = await this.dockerClient!.request('POST', `/services/${serviceID}/update?version=${version}`, { + ...service.Spec, + Mode: { + Replicated: { + Replicas: 0, + }, + }, + }); + + if (updateResponse.statusCode >= 300) { + throw new Error(`Failed to stop service: HTTP ${updateResponse.statusCode}`); + } + + logger.success(`Service stopped (scaled to 0 replicas): ${serviceID}`); + } + + /** + * Restart a container or service by ID */ async restartContainer(containerID: string): Promise { try { + // Try service first + if (await this.isService(containerID)) { + return await this.restartService(containerID); + } + logger.info(`Restarting container: ${containerID}`); - const container = this.dockerClient!.getContainer(containerID); - await container.restart(); + const response = await this.dockerClient!.request('POST', `/containers/${containerID}/restart`, {}); + + if (response.statusCode >= 300) { + throw new Error(`Failed to restart container: HTTP ${response.statusCode}`); + } logger.success(`Container restarted: ${containerID}`); } catch (error) { @@ -205,13 +400,47 @@ export class OneboxDockerManager { } /** - * Remove a container by ID + * Restart a Swarm service (force update with same spec) + */ + private async restartService(serviceID: string): Promise { + logger.info(`Restarting service: ${serviceID}`); + + // Get current service spec + const getResponse = await this.dockerClient!.request('GET', `/services/${serviceID}`, {}); + if (getResponse.statusCode >= 300) { + throw new Error(`Failed to get service: HTTP ${getResponse.statusCode}`); + } + + const service = getResponse.body; + const version = service.Version.Index; + + // Force update to trigger restart + const updateResponse = await this.dockerClient!.request('POST', `/services/${serviceID}/update?version=${version}`, { + ...service.Spec, + TaskTemplate: { + ...service.Spec.TaskTemplate, + ForceUpdate: (service.Spec.TaskTemplate.ForceUpdate || 0) + 1, + }, + }); + + if (updateResponse.statusCode >= 300) { + throw new Error(`Failed to restart service: HTTP ${updateResponse.statusCode}`); + } + + logger.success(`Service restarted: ${serviceID}`); + } + + /** + * Remove a container or service by ID */ async removeContainer(containerID: string, force = false): Promise { try { - logger.info(`Removing container: ${containerID}`); + // Try service first + if (await this.isService(containerID)) { + return await this.removeService(containerID); + } - const container = this.dockerClient!.getContainer(containerID); + logger.info(`Removing container: ${containerID}`); // Stop first if not forced if (!force) { @@ -223,7 +452,12 @@ export class OneboxDockerManager { } } - await container.remove({ force }); + const url = force ? `/containers/${containerID}?force=true` : `/containers/${containerID}`; + const response = await this.dockerClient!.request('DELETE', url, {}); + + if (response.statusCode >= 300) { + throw new Error(`Failed to remove container: HTTP ${response.statusCode}`); + } logger.success(`Container removed: ${containerID}`); } catch (error) { @@ -233,20 +467,112 @@ export class OneboxDockerManager { } /** - * Get container status + * Remove a Swarm service + */ + private async removeService(serviceID: string): Promise { + logger.info(`Removing service: ${serviceID}`); + + const response = await this.dockerClient!.request('DELETE', `/services/${serviceID}`, {}); + + if (response.statusCode >= 300) { + throw new Error(`Failed to remove service: HTTP ${response.statusCode}`); + } + + logger.success(`Service removed: ${serviceID}`); + } + + /** + * Get container or service status */ async getContainerStatus(containerID: string): Promise { try { - const container = this.dockerClient!.getContainer(containerID); - const info = await container.inspect(); + // Try service first + if (await this.isService(containerID)) { + return await this.getServiceStatus(containerID); + } - return info.State.Status; + const response = await this.dockerClient!.request('GET', `/containers/${containerID}/json`, {}); + + if (response.statusCode >= 300) { + return 'unknown'; + } + + return response.body.State?.Status || 'unknown'; } catch (error) { logger.error(`Failed to get container status ${containerID}: ${error.message}`); return 'unknown'; } } + /** + * Get Swarm service status + */ + private async getServiceStatus(serviceID: string): Promise { + try { + // Get service details + const serviceResponse = await this.dockerClient!.request('GET', `/services/${serviceID}`, {}); + if (serviceResponse.statusCode >= 300) { + return 'unknown'; + } + + const service = serviceResponse.body; + const replicas = service.Spec?.Mode?.Replicated?.Replicas || 0; + + if (replicas === 0) { + return 'stopped'; + } + + // Get tasks for this service to check if they're running + const tasksResponse = await this.dockerClient!.request('GET', `/tasks?filters=${encodeURIComponent(JSON.stringify({service: [serviceID]}))}`, {}); + if (tasksResponse.statusCode >= 300) { + return 'unknown'; + } + + const tasks = tasksResponse.body; + if (tasks.length === 0) { + return 'starting'; + } + + // Check if any task is running + const hasRunning = tasks.some((task: any) => task.Status?.State === 'running'); + if (hasRunning) { + return 'running'; + } + + // Check task states + const latestTask = tasks[0]; + const taskState = latestTask?.Status?.State || 'unknown'; + + // Map Swarm task states to container-like states + switch (taskState) { + case 'new': + case 'allocated': + case 'pending': + case 'assigned': + case 'accepted': + case 'preparing': + case 'ready': + case 'starting': + return 'starting'; + case 'running': + return 'running'; + case 'complete': + return 'exited'; + case 'failed': + case 'shutdown': + case 'rejected': + case 'orphaned': + case 'remove': + return 'stopped'; + default: + return 'unknown'; + } + } catch (error) { + logger.error(`Failed to get service status ${serviceID}: ${error.message}`); + return 'unknown'; + } + } + /** * Get container stats (CPU, memory, network) */ diff --git a/ts/classes/httpserver.ts b/ts/classes/httpserver.ts index 13a1674..fec7c16 100644 --- a/ts/classes/httpserver.ts +++ b/ts/classes/httpserver.ts @@ -13,6 +13,7 @@ export class OneboxHttpServer { private oneboxRef: Onebox; private server: Deno.HttpServer | null = null; private port = 3000; + private wsClients: Set = new Set(); constructor(oneboxRef: Onebox) { this.oneboxRef = oneboxRef; @@ -70,6 +71,11 @@ export class OneboxHttpServer { logger.debug(`${req.method} ${path}`); try { + // WebSocket upgrade + if (path === '/api/ws' && req.headers.get('upgrade') === 'websocket') { + return this.handleWebSocketUpgrade(req); + } + // API routes if (path.startsWith('/api/')) { return await this.handleApiRequest(req, path); @@ -184,7 +190,7 @@ export class OneboxHttpServer { return await this.handleStatusRequest(); } else if (path === '/api/settings' && method === 'GET') { return await this.handleGetSettingsRequest(); - } else if (path === '/api/settings' && method === 'PUT') { + } else if (path === '/api/settings' && (method === 'PUT' || method === 'POST')) { return await this.handleUpdateSettingsRequest(req); } else if (path === '/api/services' && method === 'GET') { return await this.handleListServicesRequest(); @@ -268,52 +274,117 @@ export class OneboxHttpServer { } private async handleStatusRequest(): Promise { - const status = await this.oneboxRef.getSystemStatus(); - return this.jsonResponse({ success: true, data: status }); + try { + const status = await this.oneboxRef.getSystemStatus(); + return this.jsonResponse({ success: true, data: status }); + } catch (error) { + logger.error(`Failed to get system status: ${error.message}`); + return this.jsonResponse({ success: false, error: error.message || 'Failed to get system status' }, 500); + } } private async handleListServicesRequest(): Promise { - const services = this.oneboxRef.services.listServices(); - return this.jsonResponse({ success: true, data: services }); + try { + const services = this.oneboxRef.services.listServices(); + return this.jsonResponse({ success: true, data: services }); + } catch (error) { + logger.error(`Failed to list services: ${error.message}`); + return this.jsonResponse({ success: false, error: error.message || 'Failed to list services' }, 500); + } } private async handleDeployServiceRequest(req: Request): Promise { - const body = await req.json(); - const service = await this.oneboxRef.services.deployService(body); - return this.jsonResponse({ success: true, data: service }); + try { + const body = await req.json(); + const service = await this.oneboxRef.services.deployService(body); + + // Broadcast service created + this.broadcastServiceUpdate(service.name, 'created', service); + + return this.jsonResponse({ success: true, data: service }); + } catch (error) { + logger.error(`Failed to deploy service: ${error.message}`); + return this.jsonResponse({ success: false, error: error.message || 'Failed to deploy service' }, 500); + } } private async handleGetServiceRequest(name: string): Promise { - const service = this.oneboxRef.services.getService(name); - if (!service) { - return this.jsonResponse({ success: false, error: 'Service not found' }, 404); + try { + const service = this.oneboxRef.services.getService(name); + if (!service) { + return this.jsonResponse({ success: false, error: 'Service not found' }, 404); + } + return this.jsonResponse({ success: true, data: service }); + } catch (error) { + logger.error(`Failed to get service ${name}: ${error.message}`); + return this.jsonResponse({ success: false, error: error.message || 'Failed to get service' }, 500); } - return this.jsonResponse({ success: true, data: service }); } private async handleDeleteServiceRequest(name: string): Promise { - await this.oneboxRef.services.removeService(name); - return this.jsonResponse({ success: true, message: 'Service removed' }); + try { + await this.oneboxRef.services.removeService(name); + + // Broadcast service deleted + this.broadcastServiceUpdate(name, 'deleted'); + + return this.jsonResponse({ success: true, message: 'Service removed' }); + } catch (error) { + logger.error(`Failed to delete service ${name}: ${error.message}`); + return this.jsonResponse({ success: false, error: error.message || 'Failed to delete service' }, 500); + } } private async handleStartServiceRequest(name: string): Promise { - await this.oneboxRef.services.startService(name); - return this.jsonResponse({ success: true, message: 'Service started' }); + try { + await this.oneboxRef.services.startService(name); + + // Broadcast service started + this.broadcastServiceUpdate(name, 'started'); + + return this.jsonResponse({ success: true, message: 'Service started' }); + } catch (error) { + logger.error(`Failed to start service ${name}: ${error.message}`); + return this.jsonResponse({ success: false, error: error.message || 'Failed to start service' }, 500); + } } private async handleStopServiceRequest(name: string): Promise { - await this.oneboxRef.services.stopService(name); - return this.jsonResponse({ success: true, message: 'Service stopped' }); + try { + await this.oneboxRef.services.stopService(name); + + // Broadcast service stopped + this.broadcastServiceUpdate(name, 'stopped'); + + return this.jsonResponse({ success: true, message: 'Service stopped' }); + } catch (error) { + logger.error(`Failed to stop service ${name}: ${error.message}`); + return this.jsonResponse({ success: false, error: error.message || 'Failed to stop service' }, 500); + } } private async handleRestartServiceRequest(name: string): Promise { - await this.oneboxRef.services.restartService(name); - return this.jsonResponse({ success: true, message: 'Service restarted' }); + try { + await this.oneboxRef.services.restartService(name); + + // Broadcast service updated + this.broadcastServiceUpdate(name, 'updated'); + + return this.jsonResponse({ success: true, message: 'Service restarted' }); + } catch (error) { + logger.error(`Failed to restart service ${name}: ${error.message}`); + return this.jsonResponse({ success: false, error: error.message || 'Failed to restart service' }, 500); + } } private async handleGetLogsRequest(name: string): Promise { - const logs = await this.oneboxRef.services.getServiceLogs(name); - return this.jsonResponse({ success: true, data: logs }); + try { + const logs = await this.oneboxRef.services.getServiceLogs(name); + return this.jsonResponse({ success: true, data: logs }); + } catch (error) { + logger.error(`Failed to get logs for service ${name}: ${error.message}`); + return this.jsonResponse({ success: false, error: error.message || 'Failed to get logs' }, 500); + } } private async handleGetSettingsRequest(): Promise { @@ -332,11 +403,32 @@ export class OneboxHttpServer { ); } - // Update each setting - for (const [key, value] of Object.entries(body)) { - if (typeof value === 'string') { - this.oneboxRef.database.setSetting(key, value); - logger.info(`Setting updated: ${key}`); + // Handle three formats: + // 1. Single setting: { key: "settingName", value: "settingValue" } + // 2. Array format: [{ key: "name1", value: "val1" }, ...] + // 3. Object format: { settingName1: "value1", settingName2: "value2", ... } + + if (Array.isArray(body)) { + // Array format from UI + for (const item of body) { + if (item.key && typeof item.value === 'string') { + this.oneboxRef.database.setSetting(item.key, item.value); + logger.info(`Setting updated: ${item.key} = ${item.value}`); + } + } + } else if (body.key && body.value !== undefined) { + // Single setting format: { key: "name", value: "val" } + if (typeof body.value === 'string') { + this.oneboxRef.database.setSetting(body.key, body.value); + logger.info(`Setting updated: ${body.key} = ${body.value}`); + } + } else { + // Object format: { name1: "val1", name2: "val2", ... } + for (const [key, value] of Object.entries(body)) { + if (typeof value === 'string') { + this.oneboxRef.database.setSetting(key, value); + logger.info(`Setting updated: ${key} = ${value}`); + } } } @@ -350,6 +442,102 @@ export class OneboxHttpServer { } } + /** + * Handle WebSocket upgrade + */ + private handleWebSocketUpgrade(req: Request): Response { + const { socket, response } = Deno.upgradeWebSocket(req); + + socket.onopen = () => { + this.wsClients.add(socket); + logger.info(`WebSocket client connected (${this.wsClients.size} total)`); + + // Send initial connection message + socket.send(JSON.stringify({ + type: 'connected', + message: 'Connected to Onebox server', + timestamp: Date.now(), + })); + }; + + socket.onclose = () => { + this.wsClients.delete(socket); + logger.info(`WebSocket client disconnected (${this.wsClients.size} remaining)`); + }; + + socket.onerror = (error) => { + logger.error(`WebSocket error: ${error}`); + this.wsClients.delete(socket); + }; + + return response; + } + + /** + * Broadcast message to all connected WebSocket clients + */ + broadcast(message: Record): void { + const data = JSON.stringify(message); + let successCount = 0; + let failCount = 0; + + for (const client of this.wsClients) { + try { + if (client.readyState === WebSocket.OPEN) { + client.send(data); + successCount++; + } else { + this.wsClients.delete(client); + failCount++; + } + } catch (error) { + logger.error(`Failed to send to WebSocket client: ${error.message}`); + this.wsClients.delete(client); + failCount++; + } + } + + if (successCount > 0) { + logger.debug(`Broadcast to ${successCount} clients (${failCount} failed)`); + } + } + + /** + * Broadcast service update + */ + broadcastServiceUpdate(serviceName: string, action: 'created' | 'updated' | 'deleted' | 'started' | 'stopped', data?: any): void { + this.broadcast({ + type: 'service_update', + action, + serviceName, + data, + timestamp: Date.now(), + }); + } + + /** + * Broadcast service status update + */ + broadcastServiceStatus(serviceName: string, status: string): void { + this.broadcast({ + type: 'service_status', + serviceName, + status, + timestamp: Date.now(), + }); + } + + /** + * Broadcast system status update + */ + broadcastSystemStatus(status: any): void { + this.broadcast({ + type: 'system_status', + data: status, + timestamp: Date.now(), + }); + } + /** * Helper to create JSON response */ diff --git a/ts/classes/services.ts b/ts/classes/services.ts index 50847ff..2d7daf0 100644 --- a/ts/classes/services.ts +++ b/ts/classes/services.ts @@ -386,7 +386,15 @@ export class OneboxServicesManager { ourStatus = 'starting'; } - this.database.updateService(service.id!, { status: ourStatus }); + // Only update and broadcast if status changed + if (service.status !== ourStatus) { + this.database.updateService(service.id!, { status: ourStatus }); + + // Broadcast status change via WebSocket + if (this.oneboxRef.httpServer) { + this.oneboxRef.httpServer.broadcastServiceStatus(name, ourStatus); + } + } } catch (error) { logger.debug(`Failed to sync status for service ${name}: ${error.message}`); }