update
This commit is contained in:
@@ -36,6 +36,15 @@ Common mistakes to avoid:
|
|||||||
- `ts/classes/` - All class implementations
|
- `ts/classes/` - All class implementations
|
||||||
- `ts/` - Root level utilities (logging, types, plugins, cli, info)
|
- `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
|
## Debugging Tips
|
||||||
|
|
||||||
### Backend Logs
|
### Backend Logs
|
||||||
|
|||||||
BIN
.playwright-mcp/settings-acme-section.png
Normal file
BIN
.playwright-mcp/settings-acme-section.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
BIN
.playwright-mcp/settings-after-reload.png
Normal file
BIN
.playwright-mcp/settings-after-reload.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
BIN
.playwright-mcp/settings-fixed-persistence.png
Normal file
BIN
.playwright-mcp/settings-fixed-persistence.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
@@ -17,7 +17,7 @@
|
|||||||
"@std/encoding": "jsr:@std/encoding@^1.0.10",
|
"@std/encoding": "jsr:@std/encoding@^1.0.10",
|
||||||
"@db/sqlite": "jsr:@db/sqlite@0.12.0",
|
"@db/sqlite": "jsr:@db/sqlite@0.12.0",
|
||||||
"@push.rocks/smartdaemon": "npm:@push.rocks/smartdaemon@^2.1.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",
|
"@apiclient.xyz/cloudflare": "npm:@apiclient.xyz/cloudflare@6.4.3",
|
||||||
"@push.rocks/smartacme": "npm:@push.rocks/smartacme@^8.0.0"
|
"@push.rocks/smartacme": "npm:@push.rocks/smartacme@^8.0.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -373,19 +373,31 @@ export class OneboxDatabase {
|
|||||||
this.query('DELETE FROM services WHERE id = ?', [id]);
|
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 {
|
return {
|
||||||
id: Number(row[0]),
|
id: Number(row.id || row[0]),
|
||||||
name: String(row[1]),
|
name: String(row.name || row[1]),
|
||||||
image: String(row[2]),
|
image: String(row.image || row[2]),
|
||||||
registry: row[3] ? String(row[3]) : undefined,
|
registry: (row.registry || row[3]) ? String(row.registry || row[3]) : undefined,
|
||||||
envVars: JSON.parse(String(row[4])),
|
envVars,
|
||||||
port: Number(row[5]),
|
port: Number(row.port || row[5]),
|
||||||
domain: row[6] ? String(row[6]) : undefined,
|
domain: (row.domain || row[6]) ? String(row.domain || row[6]) : undefined,
|
||||||
containerID: row[7] ? String(row[7]) : undefined,
|
containerID: (row.container_id || row[7]) ? String(row.container_id || row[7]) : undefined,
|
||||||
status: String(row[8]) as IService['status'],
|
status: String(row.status || row[8]) as IService['status'],
|
||||||
createdAt: Number(row[9]),
|
createdAt: Number(row.created_at || row[9]),
|
||||||
updatedAt: Number(row[10]),
|
updatedAt: Number(row.updated_at || row[10]),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -422,13 +434,13 @@ export class OneboxDatabase {
|
|||||||
this.query('DELETE FROM registries WHERE url = ?', [url]);
|
this.query('DELETE FROM registries WHERE url = ?', [url]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private rowToRegistry(row: unknown[]): IRegistry {
|
private rowToRegistry(row: any): IRegistry {
|
||||||
return {
|
return {
|
||||||
id: Number(row[0]),
|
id: Number(row.id || row[0]),
|
||||||
url: String(row[1]),
|
url: String(row.url || row[1]),
|
||||||
username: String(row[2]),
|
username: String(row.username || row[2]),
|
||||||
passwordEncrypted: String(row[3]),
|
passwordEncrypted: String(row.password_encrypted || row[3]),
|
||||||
createdAt: Number(row[4]),
|
createdAt: Number(row.created_at || row[4]),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -552,16 +564,16 @@ export class OneboxDatabase {
|
|||||||
return rows.map((row) => this.rowToMetric(row));
|
return rows.map((row) => this.rowToMetric(row));
|
||||||
}
|
}
|
||||||
|
|
||||||
private rowToMetric(row: unknown[]): IMetric {
|
private rowToMetric(row: any): IMetric {
|
||||||
return {
|
return {
|
||||||
id: Number(row[0]),
|
id: Number(row.id || row[0]),
|
||||||
serviceId: Number(row[1]),
|
serviceId: Number(row.service_id || row[1]),
|
||||||
timestamp: Number(row[2]),
|
timestamp: Number(row.timestamp || row[2]),
|
||||||
cpuPercent: Number(row[3]),
|
cpuPercent: Number(row.cpu_percent || row[3]),
|
||||||
memoryUsed: Number(row[4]),
|
memoryUsed: Number(row.memory_used || row[4]),
|
||||||
memoryLimit: Number(row[5]),
|
memoryLimit: Number(row.memory_limit || row[5]),
|
||||||
networkRxBytes: Number(row[6]),
|
networkRxBytes: Number(row.network_rx_bytes || row[6]),
|
||||||
networkTxBytes: Number(row[7]),
|
networkTxBytes: Number(row.network_tx_bytes || row[7]),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -586,14 +598,14 @@ export class OneboxDatabase {
|
|||||||
return rows.map((row) => this.rowToLog(row));
|
return rows.map((row) => this.rowToLog(row));
|
||||||
}
|
}
|
||||||
|
|
||||||
private rowToLog(row: unknown[]): ILogEntry {
|
private rowToLog(row: any): ILogEntry {
|
||||||
return {
|
return {
|
||||||
id: Number(row[0]),
|
id: Number(row.id || row[0]),
|
||||||
serviceId: Number(row[1]),
|
serviceId: Number(row.service_id || row[1]),
|
||||||
timestamp: Number(row[2]),
|
timestamp: Number(row.timestamp || row[2]),
|
||||||
message: String(row[3]),
|
message: String(row.message || row[3]),
|
||||||
level: String(row[4]) as ILogEntry['level'],
|
level: String(row.level || row[4]) as ILogEntry['level'],
|
||||||
source: String(row[5]) as ILogEntry['source'],
|
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]);
|
this.query('DELETE FROM ssl_certificates WHERE domain = ?', [domain]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private rowToSSLCert(row: unknown[]): ISslCertificate {
|
private rowToSSLCert(row: any): ISslCertificate {
|
||||||
return {
|
return {
|
||||||
id: Number(row[0]),
|
id: Number(row.id || row[0]),
|
||||||
domain: String(row[1]),
|
domain: String(row.domain || row[1]),
|
||||||
certPath: String(row[2]),
|
certPath: String(row.cert_path || row[2]),
|
||||||
keyPath: String(row[3]),
|
keyPath: String(row.key_path || row[3]),
|
||||||
fullChainPath: String(row[4]),
|
fullChainPath: String(row.full_chain_path || row[4]),
|
||||||
expiryDate: Number(row[5]),
|
expiryDate: Number(row.expiry_date || row[5]),
|
||||||
issuer: String(row[6]),
|
issuer: String(row.issuer || row[6]),
|
||||||
createdAt: Number(row[7]),
|
createdAt: Number(row.created_at || row[7]),
|
||||||
updatedAt: Number(row[8]),
|
updatedAt: Number(row.updated_at || row[8]),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export class OneboxDockerManager {
|
|||||||
try {
|
try {
|
||||||
// Initialize Docker client (connects to /var/run/docker.sock by default)
|
// Initialize Docker client (connects to /var/run/docker.sock by default)
|
||||||
this.dockerClient = new plugins.docker.Docker({
|
this.dockerClient = new plugins.docker.Docker({
|
||||||
socketPath: '/var/run/docker.sock',
|
socketPath: 'unix:///var/run/docker.sock',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start the Docker client
|
// Start the Docker client
|
||||||
@@ -45,14 +45,25 @@ export class OneboxDockerManager {
|
|||||||
|
|
||||||
if (!existingNetwork) {
|
if (!existingNetwork) {
|
||||||
logger.info(`Creating Docker network: ${this.networkName}`);
|
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({
|
await this.dockerClient!.createNetwork({
|
||||||
Name: this.networkName,
|
Name: this.networkName,
|
||||||
Driver: 'bridge',
|
Driver: isSwarmMode ? 'overlay' : 'bridge',
|
||||||
|
Attachable: isSwarmMode ? true : undefined, // Required for overlay networks to allow standalone containers
|
||||||
Labels: {
|
Labels: {
|
||||||
'managed-by': 'onebox',
|
'managed-by': 'onebox',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
logger.success(`Docker network created: ${this.networkName}`);
|
logger.success(`Docker network created: ${this.networkName} (${isSwarmMode ? 'overlay' : 'bridge'})`);
|
||||||
} else {
|
} else {
|
||||||
logger.debug(`Docker network already exists: ${this.networkName}`);
|
logger.debug(`Docker network already exists: ${this.networkName}`);
|
||||||
}
|
}
|
||||||
@@ -66,77 +77,30 @@ export class OneboxDockerManager {
|
|||||||
* Pull an image from a registry
|
* Pull an image from a registry
|
||||||
*/
|
*/
|
||||||
async pullImage(image: string, registry?: string): Promise<void> {
|
async pullImage(image: string, registry?: string): Promise<void> {
|
||||||
try {
|
// Skip manual image pulling - Docker will automatically pull when creating container
|
||||||
logger.info(`Pulling Docker image: ${image}`);
|
const fullImage = registry ? `${registry}/${image}` : image;
|
||||||
|
logger.debug(`Skipping manual pull for ${fullImage} - Docker will auto-pull on container creation`);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create and start a container
|
* Create and start a container or service (depending on Swarm mode)
|
||||||
*/
|
*/
|
||||||
async createContainer(service: IService): Promise<string> {
|
async createContainer(service: IService): Promise<string> {
|
||||||
try {
|
try {
|
||||||
logger.info(`Creating container for service: ${service.name}`);
|
// Check if Docker is in Swarm mode
|
||||||
|
let isSwarmMode = false;
|
||||||
const fullImage = service.registry
|
try {
|
||||||
? `${service.registry}/${service.image}`
|
const swarmResponse = await this.dockerClient!.request('GET', '/swarm', {});
|
||||||
: service.image;
|
isSwarmMode = swarmResponse.statusCode === 200;
|
||||||
|
} catch (error) {
|
||||||
// Prepare environment variables
|
isSwarmMode = false;
|
||||||
const env: string[] = [];
|
|
||||||
for (const [key, value] of Object.entries(service.envVars)) {
|
|
||||||
env.push(`${key}=${value}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create container
|
if (isSwarmMode) {
|
||||||
const container = await this.dockerClient!.createContainer({
|
return await this.createSwarmService(service);
|
||||||
Image: fullImage,
|
} else {
|
||||||
name: `onebox-${service.name}`,
|
return await this.createStandaloneContainer(service);
|
||||||
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;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to create container for ${service.name}: ${error.message}`);
|
logger.error(`Failed to create container for ${service.name}: ${error.message}`);
|
||||||
throw error;
|
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<string> {
|
||||||
|
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<string> {
|
||||||
|
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<string> {
|
||||||
|
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<void> {
|
async startContainer(containerID: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
// Try service first
|
||||||
|
if (await this.isService(containerID)) {
|
||||||
|
return await this.startService(containerID);
|
||||||
|
}
|
||||||
|
|
||||||
logger.info(`Starting container: ${containerID}`);
|
logger.info(`Starting container: ${containerID}`);
|
||||||
|
|
||||||
const container = this.dockerClient!.getContainer(containerID);
|
const response = await this.dockerClient!.request('POST', `/containers/${containerID}/start`, {});
|
||||||
await container.start();
|
|
||||||
|
if (response.statusCode >= 300 && response.statusCode !== 304) {
|
||||||
|
throw new Error(`Failed to start container: HTTP ${response.statusCode}`);
|
||||||
|
}
|
||||||
|
|
||||||
logger.success(`Container started: ${containerID}`);
|
logger.success(`Container started: ${containerID}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Ignore "already started" errors
|
// Ignore "already started" errors (304 status)
|
||||||
if (error.message.includes('already started')) {
|
if (error.message.includes('304')) {
|
||||||
logger.debug(`Container already running: ${containerID}`);
|
logger.debug(`Container already running: ${containerID}`);
|
||||||
return;
|
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<void> {
|
||||||
|
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<boolean> {
|
||||||
|
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<void> {
|
async stopContainer(containerID: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
// Try service first
|
||||||
|
if (await this.isService(containerID)) {
|
||||||
|
return await this.stopService(containerID);
|
||||||
|
}
|
||||||
|
|
||||||
logger.info(`Stopping container: ${containerID}`);
|
logger.info(`Stopping container: ${containerID}`);
|
||||||
|
|
||||||
const container = this.dockerClient!.getContainer(containerID);
|
const response = await this.dockerClient!.request('POST', `/containers/${containerID}/stop`, {});
|
||||||
await container.stop();
|
|
||||||
|
if (response.statusCode >= 300 && response.statusCode !== 304) {
|
||||||
|
throw new Error(`Failed to stop container: HTTP ${response.statusCode}`);
|
||||||
|
}
|
||||||
|
|
||||||
logger.success(`Container stopped: ${containerID}`);
|
logger.success(`Container stopped: ${containerID}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Ignore "already stopped" errors
|
// Ignore "already stopped" errors (304 status)
|
||||||
if (error.message.includes('already stopped') || error.statusCode === 304) {
|
if (error.message.includes('304')) {
|
||||||
logger.debug(`Container already stopped: ${containerID}`);
|
logger.debug(`Container already stopped: ${containerID}`);
|
||||||
return;
|
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<void> {
|
||||||
|
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<void> {
|
async restartContainer(containerID: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
// Try service first
|
||||||
|
if (await this.isService(containerID)) {
|
||||||
|
return await this.restartService(containerID);
|
||||||
|
}
|
||||||
|
|
||||||
logger.info(`Restarting container: ${containerID}`);
|
logger.info(`Restarting container: ${containerID}`);
|
||||||
|
|
||||||
const container = this.dockerClient!.getContainer(containerID);
|
const response = await this.dockerClient!.request('POST', `/containers/${containerID}/restart`, {});
|
||||||
await container.restart();
|
|
||||||
|
if (response.statusCode >= 300) {
|
||||||
|
throw new Error(`Failed to restart container: HTTP ${response.statusCode}`);
|
||||||
|
}
|
||||||
|
|
||||||
logger.success(`Container restarted: ${containerID}`);
|
logger.success(`Container restarted: ${containerID}`);
|
||||||
} catch (error) {
|
} 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<void> {
|
||||||
|
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<void> {
|
async removeContainer(containerID: string, force = false): Promise<void> {
|
||||||
try {
|
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
|
// Stop first if not forced
|
||||||
if (!force) {
|
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}`);
|
logger.success(`Container removed: ${containerID}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -233,20 +467,112 @@ export class OneboxDockerManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get container status
|
* Remove a Swarm service
|
||||||
|
*/
|
||||||
|
private async removeService(serviceID: string): Promise<void> {
|
||||||
|
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<string> {
|
async getContainerStatus(containerID: string): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const container = this.dockerClient!.getContainer(containerID);
|
// Try service first
|
||||||
const info = await container.inspect();
|
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) {
|
} catch (error) {
|
||||||
logger.error(`Failed to get container status ${containerID}: ${error.message}`);
|
logger.error(`Failed to get container status ${containerID}: ${error.message}`);
|
||||||
return 'unknown';
|
return 'unknown';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Swarm service status
|
||||||
|
*/
|
||||||
|
private async getServiceStatus(serviceID: string): Promise<string> {
|
||||||
|
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)
|
* Get container stats (CPU, memory, network)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export class OneboxHttpServer {
|
|||||||
private oneboxRef: Onebox;
|
private oneboxRef: Onebox;
|
||||||
private server: Deno.HttpServer | null = null;
|
private server: Deno.HttpServer | null = null;
|
||||||
private port = 3000;
|
private port = 3000;
|
||||||
|
private wsClients: Set<WebSocket> = new Set();
|
||||||
|
|
||||||
constructor(oneboxRef: Onebox) {
|
constructor(oneboxRef: Onebox) {
|
||||||
this.oneboxRef = oneboxRef;
|
this.oneboxRef = oneboxRef;
|
||||||
@@ -70,6 +71,11 @@ export class OneboxHttpServer {
|
|||||||
logger.debug(`${req.method} ${path}`);
|
logger.debug(`${req.method} ${path}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// WebSocket upgrade
|
||||||
|
if (path === '/api/ws' && req.headers.get('upgrade') === 'websocket') {
|
||||||
|
return this.handleWebSocketUpgrade(req);
|
||||||
|
}
|
||||||
|
|
||||||
// API routes
|
// API routes
|
||||||
if (path.startsWith('/api/')) {
|
if (path.startsWith('/api/')) {
|
||||||
return await this.handleApiRequest(req, path);
|
return await this.handleApiRequest(req, path);
|
||||||
@@ -184,7 +190,7 @@ export class OneboxHttpServer {
|
|||||||
return await this.handleStatusRequest();
|
return await this.handleStatusRequest();
|
||||||
} else if (path === '/api/settings' && method === 'GET') {
|
} else if (path === '/api/settings' && method === 'GET') {
|
||||||
return await this.handleGetSettingsRequest();
|
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);
|
return await this.handleUpdateSettingsRequest(req);
|
||||||
} else if (path === '/api/services' && method === 'GET') {
|
} else if (path === '/api/services' && method === 'GET') {
|
||||||
return await this.handleListServicesRequest();
|
return await this.handleListServicesRequest();
|
||||||
@@ -268,52 +274,117 @@ export class OneboxHttpServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async handleStatusRequest(): Promise<Response> {
|
private async handleStatusRequest(): Promise<Response> {
|
||||||
const status = await this.oneboxRef.getSystemStatus();
|
try {
|
||||||
return this.jsonResponse({ success: true, data: status });
|
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<Response> {
|
private async handleListServicesRequest(): Promise<Response> {
|
||||||
const services = this.oneboxRef.services.listServices();
|
try {
|
||||||
return this.jsonResponse({ success: true, data: services });
|
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<Response> {
|
private async handleDeployServiceRequest(req: Request): Promise<Response> {
|
||||||
const body = await req.json();
|
try {
|
||||||
const service = await this.oneboxRef.services.deployService(body);
|
const body = await req.json();
|
||||||
return this.jsonResponse({ success: true, data: service });
|
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<Response> {
|
private async handleGetServiceRequest(name: string): Promise<Response> {
|
||||||
const service = this.oneboxRef.services.getService(name);
|
try {
|
||||||
if (!service) {
|
const service = this.oneboxRef.services.getService(name);
|
||||||
return this.jsonResponse({ success: false, error: 'Service not found' }, 404);
|
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<Response> {
|
private async handleDeleteServiceRequest(name: string): Promise<Response> {
|
||||||
await this.oneboxRef.services.removeService(name);
|
try {
|
||||||
return this.jsonResponse({ success: true, message: 'Service removed' });
|
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<Response> {
|
private async handleStartServiceRequest(name: string): Promise<Response> {
|
||||||
await this.oneboxRef.services.startService(name);
|
try {
|
||||||
return this.jsonResponse({ success: true, message: 'Service started' });
|
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<Response> {
|
private async handleStopServiceRequest(name: string): Promise<Response> {
|
||||||
await this.oneboxRef.services.stopService(name);
|
try {
|
||||||
return this.jsonResponse({ success: true, message: 'Service stopped' });
|
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<Response> {
|
private async handleRestartServiceRequest(name: string): Promise<Response> {
|
||||||
await this.oneboxRef.services.restartService(name);
|
try {
|
||||||
return this.jsonResponse({ success: true, message: 'Service restarted' });
|
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<Response> {
|
private async handleGetLogsRequest(name: string): Promise<Response> {
|
||||||
const logs = await this.oneboxRef.services.getServiceLogs(name);
|
try {
|
||||||
return this.jsonResponse({ success: true, data: logs });
|
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<Response> {
|
private async handleGetSettingsRequest(): Promise<Response> {
|
||||||
@@ -332,11 +403,32 @@ export class OneboxHttpServer {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update each setting
|
// Handle three formats:
|
||||||
for (const [key, value] of Object.entries(body)) {
|
// 1. Single setting: { key: "settingName", value: "settingValue" }
|
||||||
if (typeof value === 'string') {
|
// 2. Array format: [{ key: "name1", value: "val1" }, ...]
|
||||||
this.oneboxRef.database.setSetting(key, value);
|
// 3. Object format: { settingName1: "value1", settingName2: "value2", ... }
|
||||||
logger.info(`Setting updated: ${key}`);
|
|
||||||
|
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<string, any>): 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
|
* Helper to create JSON response
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -386,7 +386,15 @@ export class OneboxServicesManager {
|
|||||||
ourStatus = 'starting';
|
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) {
|
} catch (error) {
|
||||||
logger.debug(`Failed to sync status for service ${name}: ${error.message}`);
|
logger.debug(`Failed to sync status for service ${name}: ${error.message}`);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user