Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7f6031f31a | |||
| 6f1b8469e0 | |||
| cd06c74cc3 | |||
| d3acc720ca | |||
| 1b6de75097 | |||
| 497f8f59a7 | |||
| 0c7d65e4ad | |||
| 3f2cd074ce | |||
| 59ed7233bd | |||
| 01e3ba16c4 | |||
| f5c1d5fcda | |||
| 45b0971f2f | |||
| 178f440d7e | |||
| 7fff15a90c |
45
changelog.md
45
changelog.md
@@ -1,5 +1,50 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-03-16 - 1.17.2 - fix(platform-services)
|
||||||
|
provision ClickHouse, MinIO, and MongoDB resources via docker exec instead of host port access
|
||||||
|
|
||||||
|
- switch ClickHouse provisioning and teardown to in-container client commands to avoid host port mapping issues
|
||||||
|
- replace MinIO host-side S3 API calls with in-container mc commands for bucket creation and removal
|
||||||
|
- run MongoDB provisioning and deprovisioning through mongosh inside the container and improve docker exec failure reporting
|
||||||
|
|
||||||
|
## 2026-03-16 - 1.17.1 - fix(repo)
|
||||||
|
no changes to commit
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-03-16 - 1.17.0 - feat(web/services)
|
||||||
|
add deploy service action to the services view
|
||||||
|
|
||||||
|
- Adds a prominent "Deploy Service" button to the services page header.
|
||||||
|
- Routes users into the create service view directly from the services listing.
|
||||||
|
- Includes a new service creation form screenshot asset for the updated interface.
|
||||||
|
|
||||||
|
## 2026-03-16 - 1.16.0 - feat(services)
|
||||||
|
add platform service navigation and stats in the services UI
|
||||||
|
|
||||||
|
- add platform service stats state and fetch action
|
||||||
|
- show platform services in the services list and open a platform detail view
|
||||||
|
- enable dashboard clicks to jump directly to the selected platform service
|
||||||
|
- refresh platform service stats after start and restart actions
|
||||||
|
- bump @serve.zone/catalog to ^2.6.0 for the new platform service UI components
|
||||||
|
|
||||||
|
## 2026-03-16 - 1.15.3 - fix(install)
|
||||||
|
refresh systemd service configuration before restarting previously running installations
|
||||||
|
|
||||||
|
- Re-enable the systemd service during updates so unit file changes are applied before restart
|
||||||
|
- Add a log message indicating the service configuration is being refreshed
|
||||||
|
|
||||||
|
## 2026-03-16 - 1.15.2 - fix(systemd)
|
||||||
|
set HOME and DENO_DIR for the systemd service environment
|
||||||
|
|
||||||
|
- Adds HOME=/root to the generated onebox systemd unit
|
||||||
|
- Adds DENO_DIR=/root/.cache/deno so Deno cache paths are available when running as a service
|
||||||
|
|
||||||
|
## 2026-03-16 - 1.15.1 - fix(systemd)
|
||||||
|
move Docker installation and swarm initialization to systemd enable flow
|
||||||
|
|
||||||
|
- Ensures Docker is installed before writing and enabling the systemd unit that depends on docker.service.
|
||||||
|
- Removes Docker auto-installation from Onebox initialization so setup happens in the service management path.
|
||||||
|
|
||||||
## 2026-03-16 - 1.15.0 - feat(systemd)
|
## 2026-03-16 - 1.15.0 - feat(systemd)
|
||||||
replace smartdaemon-based service management with native systemd commands
|
replace smartdaemon-based service management with native systemd commands
|
||||||
|
|
||||||
|
|||||||
BIN
create-service-form.png
Normal file
BIN
create-service-form.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@serve.zone/onebox",
|
"name": "@serve.zone/onebox",
|
||||||
"version": "1.15.0",
|
"version": "1.17.2",
|
||||||
"exports": "./mod.ts",
|
"exports": "./mod.ts",
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"test": "deno test --allow-all test/",
|
"test": "deno test --allow-all test/",
|
||||||
|
|||||||
@@ -250,8 +250,10 @@ echo ""
|
|||||||
mkdir -p /var/lib/onebox
|
mkdir -p /var/lib/onebox
|
||||||
mkdir -p /var/www/certbot
|
mkdir -p /var/www/certbot
|
||||||
|
|
||||||
# Restart service if it was running before update
|
# Re-enable and restart service if it was previously running (refreshes unit file)
|
||||||
if [ $SERVICE_WAS_RUNNING -eq 1 ]; then
|
if [ $SERVICE_WAS_RUNNING -eq 1 ]; then
|
||||||
|
echo "Refreshing systemd service..."
|
||||||
|
onebox systemd enable
|
||||||
echo "Restarting Onebox service..."
|
echo "Restarting Onebox service..."
|
||||||
systemctl restart "$SERVICE_NAME"
|
systemctl restart "$SERVICE_NAME"
|
||||||
echo "Service restarted successfully."
|
echo "Service restarted successfully."
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@serve.zone/onebox",
|
"name": "@serve.zone/onebox",
|
||||||
"version": "1.15.0",
|
"version": "1.17.2",
|
||||||
"description": "Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers",
|
"description": "Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers",
|
||||||
"main": "mod.ts",
|
"main": "mod.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
"@api.global/typedrequest-interfaces": "^3.0.19",
|
"@api.global/typedrequest-interfaces": "^3.0.19",
|
||||||
"@design.estate/dees-catalog": "^3.43.3",
|
"@design.estate/dees-catalog": "^3.43.3",
|
||||||
"@design.estate/dees-element": "^2.1.6",
|
"@design.estate/dees-element": "^2.1.6",
|
||||||
"@serve.zone/catalog": "^2.5.0"
|
"@serve.zone/catalog": "^2.6.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@git.zone/tsbundle": "^2.9.0",
|
"@git.zone/tsbundle": "^2.9.0",
|
||||||
|
|||||||
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
@@ -18,8 +18,8 @@ importers:
|
|||||||
specifier: ^2.1.6
|
specifier: ^2.1.6
|
||||||
version: 2.2.3
|
version: 2.2.3
|
||||||
'@serve.zone/catalog':
|
'@serve.zone/catalog':
|
||||||
specifier: ^2.5.0
|
specifier: ^2.6.0
|
||||||
version: 2.5.0(@tiptap/pm@2.27.2)
|
version: 2.6.0(@tiptap/pm@2.27.2)
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@git.zone/tsbundle':
|
'@git.zone/tsbundle':
|
||||||
specifier: ^2.9.0
|
specifier: ^2.9.0
|
||||||
@@ -836,8 +836,8 @@ packages:
|
|||||||
'@sec-ant/readable-stream@0.4.1':
|
'@sec-ant/readable-stream@0.4.1':
|
||||||
resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
|
resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
|
||||||
|
|
||||||
'@serve.zone/catalog@2.5.0':
|
'@serve.zone/catalog@2.6.0':
|
||||||
resolution: {integrity: sha512-bRwk7pbDxUB471wUAS7p22MTOOBCHlMWijsE43K9tDAPcxlRarhtf2Dgx0Y25s/dFXqj2JHwe6jjE84S80jFzg==}
|
resolution: {integrity: sha512-Gp91Ed0MMLMPSAZrH2/UimGxU9AaTu5IPUobPD49PSvh3UnUl+HEFiy81kpKJhW16V37N3/vcNgZrn2VI7/LxQ==}
|
||||||
|
|
||||||
'@tempfix/idb@8.0.3':
|
'@tempfix/idb@8.0.3':
|
||||||
resolution: {integrity: sha512-hPJQKO7+oAIY+pDNImrZ9QAINbz9KmwT+yO4iRVwdPanok2YKpaUxdJzIvCUwY0YgAawlvYdffbLvRLV5hbs2g==}
|
resolution: {integrity: sha512-hPJQKO7+oAIY+pDNImrZ9QAINbz9KmwT+yO4iRVwdPanok2YKpaUxdJzIvCUwY0YgAawlvYdffbLvRLV5hbs2g==}
|
||||||
@@ -3474,7 +3474,7 @@ snapshots:
|
|||||||
|
|
||||||
'@sec-ant/readable-stream@0.4.1': {}
|
'@sec-ant/readable-stream@0.4.1': {}
|
||||||
|
|
||||||
'@serve.zone/catalog@2.5.0(@tiptap/pm@2.27.2)':
|
'@serve.zone/catalog@2.6.0(@tiptap/pm@2.27.2)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@design.estate/dees-catalog': 3.48.5(@tiptap/pm@2.27.2)
|
'@design.estate/dees-catalog': 3.48.5(@tiptap/pm@2.27.2)
|
||||||
'@design.estate/dees-domtools': 2.5.1
|
'@design.estate/dees-domtools': 2.5.1
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/onebox',
|
name: '@serve.zone/onebox',
|
||||||
version: '1.15.0',
|
version: '1.17.2',
|
||||||
description: 'Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers'
|
description: 'Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -881,12 +881,12 @@ export class OneboxDockerManager {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const execInfo = await inspect();
|
const execInfo = await inspect();
|
||||||
const exitCode = execInfo.ExitCode || 0;
|
const exitCode = execInfo.ExitCode ?? -1;
|
||||||
|
|
||||||
return { stdout, stderr, exitCode };
|
return { stdout, stderr, exitCode };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to exec in container ${containerID}: ${getErrorMessage(error)}`);
|
logger.error(`Failed to exec in container ${containerID}: ${getErrorMessage(error)}`);
|
||||||
throw error;
|
return { stdout: '', stderr: getErrorMessage(error), exitCode: -1 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -100,9 +100,6 @@ export class Onebox {
|
|||||||
// Ensure default admin user exists
|
// Ensure default admin user exists
|
||||||
await this.ensureDefaultUser();
|
await this.ensureDefaultUser();
|
||||||
|
|
||||||
// Ensure Docker is installed (auto-install on fresh servers)
|
|
||||||
await this.ensureDocker();
|
|
||||||
|
|
||||||
// Initialize Docker
|
// Initialize Docker
|
||||||
await this.docker.init();
|
await this.docker.init();
|
||||||
|
|
||||||
@@ -227,59 +224,6 @@ export class Onebox {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensure Docker is installed, installing it if necessary
|
|
||||||
*/
|
|
||||||
private async ensureDocker(): Promise<void> {
|
|
||||||
try {
|
|
||||||
const cmd = new Deno.Command('docker', {
|
|
||||||
args: ['--version'],
|
|
||||||
stdout: 'piped',
|
|
||||||
stderr: 'piped',
|
|
||||||
});
|
|
||||||
const result = await cmd.output();
|
|
||||||
if (result.success) {
|
|
||||||
const version = new TextDecoder().decode(result.stdout).trim();
|
|
||||||
logger.info(`Docker found: ${version}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// docker command not found
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info('Docker not found. Installing Docker...');
|
|
||||||
const installCmd = new Deno.Command('bash', {
|
|
||||||
args: ['-c', 'curl -fsSL https://get.docker.com | sh'],
|
|
||||||
stdin: 'inherit',
|
|
||||||
stdout: 'inherit',
|
|
||||||
stderr: 'inherit',
|
|
||||||
});
|
|
||||||
const installResult = await installCmd.output();
|
|
||||||
if (!installResult.success) {
|
|
||||||
throw new Error('Failed to install Docker. Please install it manually: curl -fsSL https://get.docker.com | sh');
|
|
||||||
}
|
|
||||||
logger.success('Docker installed successfully');
|
|
||||||
|
|
||||||
// Initialize Docker Swarm
|
|
||||||
logger.info('Initializing Docker Swarm...');
|
|
||||||
const swarmCmd = new Deno.Command('docker', {
|
|
||||||
args: ['swarm', 'init'],
|
|
||||||
stdout: 'piped',
|
|
||||||
stderr: 'piped',
|
|
||||||
});
|
|
||||||
const swarmResult = await swarmCmd.output();
|
|
||||||
if (swarmResult.success) {
|
|
||||||
logger.success('Docker Swarm initialized');
|
|
||||||
} else {
|
|
||||||
const stderr = new TextDecoder().decode(swarmResult.stderr);
|
|
||||||
if (stderr.includes('already part of a swarm')) {
|
|
||||||
logger.info('Docker Swarm already initialized');
|
|
||||||
} else {
|
|
||||||
logger.warn(`Docker Swarm init warning: ${stderr.trim()}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if Onebox is initialized
|
* Check if Onebox is initialized
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -194,12 +194,6 @@ export class ClickHouseProvider extends BasePlatformServiceProvider {
|
|||||||
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
|
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
|
||||||
const containerName = this.getContainerName();
|
const containerName = this.getContainerName();
|
||||||
|
|
||||||
// Get container host port for connection from host (overlay network IPs not accessible from host)
|
|
||||||
const hostPort = await this.oneboxRef.docker.getContainerHostPort(platformService.containerId, 8123);
|
|
||||||
if (!hostPort) {
|
|
||||||
throw new Error('Could not get ClickHouse container host port');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate resource names and credentials
|
// Generate resource names and credentials
|
||||||
const dbName = this.generateResourceName(userService.name);
|
const dbName = this.generateResourceName(userService.name);
|
||||||
const username = this.generateResourceName(userService.name);
|
const username = this.generateResourceName(userService.name);
|
||||||
@@ -207,35 +201,16 @@ export class ClickHouseProvider extends BasePlatformServiceProvider {
|
|||||||
|
|
||||||
logger.info(`Provisioning ClickHouse database '${dbName}' for service '${userService.name}'...`);
|
logger.info(`Provisioning ClickHouse database '${dbName}' for service '${userService.name}'...`);
|
||||||
|
|
||||||
// Connect to ClickHouse via localhost and the mapped host port
|
// Use docker exec to provision inside the container (avoids host port mapping issues)
|
||||||
const baseUrl = `http://127.0.0.1:${hostPort}`;
|
const queries = [
|
||||||
|
`CREATE DATABASE IF NOT EXISTS ${dbName}`,
|
||||||
|
`CREATE USER IF NOT EXISTS ${username} IDENTIFIED BY '${password}'`,
|
||||||
|
`GRANT ALL ON ${dbName}.* TO ${username}`,
|
||||||
|
];
|
||||||
|
|
||||||
// Create database
|
for (const query of queries) {
|
||||||
await this.executeQuery(
|
await this.execClickHouseQuery(platformService.containerId, adminCreds, query);
|
||||||
baseUrl,
|
}
|
||||||
adminCreds.username,
|
|
||||||
adminCreds.password,
|
|
||||||
`CREATE DATABASE IF NOT EXISTS ${dbName}`
|
|
||||||
);
|
|
||||||
logger.info(`Created ClickHouse database '${dbName}'`);
|
|
||||||
|
|
||||||
// Create user with access to this database
|
|
||||||
await this.executeQuery(
|
|
||||||
baseUrl,
|
|
||||||
adminCreds.username,
|
|
||||||
adminCreds.password,
|
|
||||||
`CREATE USER IF NOT EXISTS ${username} IDENTIFIED BY '${password}'`
|
|
||||||
);
|
|
||||||
logger.info(`Created ClickHouse user '${username}'`);
|
|
||||||
|
|
||||||
// Grant permissions on the database
|
|
||||||
await this.executeQuery(
|
|
||||||
baseUrl,
|
|
||||||
adminCreds.username,
|
|
||||||
adminCreds.password,
|
|
||||||
`GRANT ALL ON ${dbName}.* TO ${username}`
|
|
||||||
);
|
|
||||||
logger.info(`Granted permissions to user '${username}' on database '${dbName}'`);
|
|
||||||
|
|
||||||
logger.success(`ClickHouse database '${dbName}' provisioned with user '${username}'`);
|
logger.success(`ClickHouse database '${dbName}' provisioned with user '${username}'`);
|
||||||
|
|
||||||
@@ -274,37 +249,11 @@ export class ClickHouseProvider extends BasePlatformServiceProvider {
|
|||||||
|
|
||||||
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
|
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
|
||||||
|
|
||||||
// Get container host port for connection from host (overlay network IPs not accessible from host)
|
|
||||||
const hostPort = await this.oneboxRef.docker.getContainerHostPort(platformService.containerId, 8123);
|
|
||||||
if (!hostPort) {
|
|
||||||
throw new Error('Could not get ClickHouse container host port');
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Deprovisioning ClickHouse database '${resource.resourceName}'...`);
|
logger.info(`Deprovisioning ClickHouse database '${resource.resourceName}'...`);
|
||||||
|
|
||||||
const baseUrl = `http://127.0.0.1:${hostPort}`;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Drop the user
|
await this.execClickHouseQuery(platformService.containerId, adminCreds, `DROP USER IF EXISTS ${credentials.username}`);
|
||||||
try {
|
await this.execClickHouseQuery(platformService.containerId, adminCreds, `DROP DATABASE IF EXISTS ${resource.resourceName}`);
|
||||||
await this.executeQuery(
|
|
||||||
baseUrl,
|
|
||||||
adminCreds.username,
|
|
||||||
adminCreds.password,
|
|
||||||
`DROP USER IF EXISTS ${credentials.username}`
|
|
||||||
);
|
|
||||||
logger.info(`Dropped ClickHouse user '${credentials.username}'`);
|
|
||||||
} catch (e) {
|
|
||||||
logger.warn(`Could not drop ClickHouse user: ${getErrorMessage(e)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Drop the database
|
|
||||||
await this.executeQuery(
|
|
||||||
baseUrl,
|
|
||||||
adminCreds.username,
|
|
||||||
adminCreds.password,
|
|
||||||
`DROP DATABASE IF EXISTS ${resource.resourceName}`
|
|
||||||
);
|
|
||||||
logger.success(`ClickHouse database '${resource.resourceName}' dropped`);
|
logger.success(`ClickHouse database '${resource.resourceName}' dropped`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(`Failed to deprovision ClickHouse database: ${getErrorMessage(e)}`);
|
logger.error(`Failed to deprovision ClickHouse database: ${getErrorMessage(e)}`);
|
||||||
@@ -313,26 +262,27 @@ export class ClickHouseProvider extends BasePlatformServiceProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute a ClickHouse SQL query via HTTP interface
|
* Execute a ClickHouse SQL query via docker exec inside the container
|
||||||
*/
|
*/
|
||||||
private async executeQuery(
|
private async execClickHouseQuery(
|
||||||
baseUrl: string,
|
containerId: string,
|
||||||
username: string,
|
adminCreds: { username: string; password: string },
|
||||||
password: string,
|
|
||||||
query: string
|
query: string
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const url = `${baseUrl}/?user=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`;
|
const result = await this.oneboxRef.docker.execInContainer(
|
||||||
|
containerId,
|
||||||
|
[
|
||||||
|
'clickhouse-client',
|
||||||
|
'--user', adminCreds.username,
|
||||||
|
'--password', adminCreds.password,
|
||||||
|
'--query', query,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
const response = await fetch(url, {
|
if (result.exitCode !== 0) {
|
||||||
method: 'POST',
|
throw new Error(`ClickHouse query failed (exit ${result.exitCode}): ${result.stderr.substring(0, 200)}`);
|
||||||
body: query,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text();
|
|
||||||
throw new Error(`ClickHouse query failed: ${errorText}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return await response.text();
|
return result.stdout;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -196,84 +196,28 @@ export class MinioProvider extends BasePlatformServiceProvider {
|
|||||||
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
|
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
|
||||||
const containerName = this.getContainerName();
|
const containerName = this.getContainerName();
|
||||||
|
|
||||||
// Get container host port for connection from host (overlay network IPs not accessible from host)
|
// Generate bucket name
|
||||||
const hostPort = await this.oneboxRef.docker.getContainerHostPort(platformService.containerId, 9000);
|
|
||||||
if (!hostPort) {
|
|
||||||
throw new Error('Could not get MinIO container host port');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate bucket name and credentials
|
|
||||||
const bucketName = this.generateBucketName(userService.name);
|
const bucketName = this.generateBucketName(userService.name);
|
||||||
const accessKey = credentialEncryption.generateAccessKey(20);
|
|
||||||
const secretKey = credentialEncryption.generateSecretKey(40);
|
|
||||||
|
|
||||||
logger.info(`Provisioning MinIO bucket '${bucketName}' for service '${userService.name}'...`);
|
logger.info(`Provisioning MinIO bucket '${bucketName}' for service '${userService.name}'...`);
|
||||||
|
|
||||||
// Connect to MinIO via localhost and the mapped host port (for provisioning from host)
|
// Use docker exec with mc (MinIO Client) inside the container
|
||||||
const provisioningEndpoint = `http://127.0.0.1:${hostPort}`;
|
// First configure mc alias for local server
|
||||||
|
await this.execMc(platformService.containerId, [
|
||||||
// Import AWS S3 client
|
'alias', 'set', 'local', 'http://localhost:9000',
|
||||||
const { S3Client, CreateBucketCommand, PutBucketPolicyCommand } = await import('npm:@aws-sdk/client-s3@3');
|
adminCreds.username, adminCreds.password,
|
||||||
|
]);
|
||||||
// Create S3 client with admin credentials - connect via host port
|
|
||||||
const s3Client = new S3Client({
|
|
||||||
endpoint: provisioningEndpoint,
|
|
||||||
region: 'us-east-1',
|
|
||||||
credentials: {
|
|
||||||
accessKeyId: adminCreds.username,
|
|
||||||
secretAccessKey: adminCreds.password,
|
|
||||||
},
|
|
||||||
forcePathStyle: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create the bucket
|
// Create the bucket
|
||||||
try {
|
const mbResult = await this.execMc(platformService.containerId, [
|
||||||
await s3Client.send(new CreateBucketCommand({
|
'mb', '--ignore-existing', `local/${bucketName}`,
|
||||||
Bucket: bucketName,
|
]);
|
||||||
}));
|
logger.info(`Created MinIO bucket '${bucketName}'`);
|
||||||
logger.info(`Created MinIO bucket '${bucketName}'`);
|
|
||||||
} catch (e: any) {
|
|
||||||
if (e.name !== 'BucketAlreadyOwnedByYou' && e.name !== 'BucketAlreadyExists') {
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
logger.warn(`Bucket '${bucketName}' already exists`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create service account/access key using MinIO Admin API
|
// Set bucket policy to allow public read/write (services on the same network use root creds)
|
||||||
// MinIO Admin API requires mc client or direct API calls
|
await this.execMc(platformService.containerId, [
|
||||||
// For simplicity, we'll use root credentials and bucket policy isolation
|
'anonymous', 'set', 'none', `local/${bucketName}`,
|
||||||
// In production, you'd use MinIO's Admin API to create service accounts
|
]);
|
||||||
|
|
||||||
// Set bucket policy to allow access only with this bucket's credentials
|
|
||||||
const bucketPolicy = {
|
|
||||||
Version: '2012-10-17',
|
|
||||||
Statement: [
|
|
||||||
{
|
|
||||||
Effect: 'Allow',
|
|
||||||
Principal: { AWS: ['*'] },
|
|
||||||
Action: ['s3:GetObject', 's3:PutObject', 's3:DeleteObject', 's3:ListBucket'],
|
|
||||||
Resource: [
|
|
||||||
`arn:aws:s3:::${bucketName}`,
|
|
||||||
`arn:aws:s3:::${bucketName}/*`,
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
await s3Client.send(new PutBucketPolicyCommand({
|
|
||||||
Bucket: bucketName,
|
|
||||||
Policy: JSON.stringify(bucketPolicy),
|
|
||||||
}));
|
|
||||||
logger.info(`Set bucket policy for '${bucketName}'`);
|
|
||||||
} catch (e) {
|
|
||||||
logger.warn(`Could not set bucket policy: ${getErrorMessage(e)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Note: For proper per-service credentials, MinIO Admin API should be used
|
|
||||||
// For now, we're providing the bucket with root access
|
|
||||||
// TODO: Implement MinIO service account creation
|
|
||||||
logger.warn('Using root credentials for MinIO access. Consider implementing service accounts for production.');
|
|
||||||
|
|
||||||
// Use container name for the endpoint in credentials (user services run in same network)
|
// Use container name for the endpoint in credentials (user services run in same network)
|
||||||
const serviceEndpoint = `http://${containerName}:9000`;
|
const serviceEndpoint = `http://${containerName}:9000`;
|
||||||
@@ -281,7 +225,7 @@ export class MinioProvider extends BasePlatformServiceProvider {
|
|||||||
const credentials: Record<string, string> = {
|
const credentials: Record<string, string> = {
|
||||||
endpoint: serviceEndpoint,
|
endpoint: serviceEndpoint,
|
||||||
bucket: bucketName,
|
bucket: bucketName,
|
||||||
accessKey: adminCreds.username, // Using root for now
|
accessKey: adminCreds.username,
|
||||||
secretKey: adminCreds.password,
|
secretKey: adminCreds.password,
|
||||||
region: 'us-east-1',
|
region: 'us-east-1',
|
||||||
};
|
};
|
||||||
@@ -312,57 +256,37 @@ export class MinioProvider extends BasePlatformServiceProvider {
|
|||||||
|
|
||||||
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
|
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
|
||||||
|
|
||||||
// Get container host port for connection from host (overlay network IPs not accessible from host)
|
|
||||||
const hostPort = await this.oneboxRef.docker.getContainerHostPort(platformService.containerId, 9000);
|
|
||||||
if (!hostPort) {
|
|
||||||
throw new Error('Could not get MinIO container host port');
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Deprovisioning MinIO bucket '${resource.resourceName}'...`);
|
logger.info(`Deprovisioning MinIO bucket '${resource.resourceName}'...`);
|
||||||
|
|
||||||
const { S3Client, DeleteBucketCommand, ListObjectsV2Command, DeleteObjectsCommand } = await import('npm:@aws-sdk/client-s3@3');
|
// Configure mc alias
|
||||||
|
await this.execMc(platformService.containerId, [
|
||||||
const s3Client = new S3Client({
|
'alias', 'set', 'local', 'http://localhost:9000',
|
||||||
endpoint: `http://127.0.0.1:${hostPort}`,
|
adminCreds.username, adminCreds.password,
|
||||||
region: 'us-east-1',
|
]);
|
||||||
credentials: {
|
|
||||||
accessKeyId: adminCreds.username,
|
|
||||||
secretAccessKey: adminCreds.password,
|
|
||||||
},
|
|
||||||
forcePathStyle: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// First, delete all objects in the bucket
|
// Remove all objects and the bucket
|
||||||
let continuationToken: string | undefined;
|
await this.execMc(platformService.containerId, [
|
||||||
do {
|
'rb', '--force', `local/${resource.resourceName}`,
|
||||||
const listResponse = await s3Client.send(new ListObjectsV2Command({
|
]);
|
||||||
Bucket: resource.resourceName,
|
|
||||||
ContinuationToken: continuationToken,
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (listResponse.Contents && listResponse.Contents.length > 0) {
|
|
||||||
await s3Client.send(new DeleteObjectsCommand({
|
|
||||||
Bucket: resource.resourceName,
|
|
||||||
Delete: {
|
|
||||||
Objects: listResponse.Contents.map(obj => ({ Key: obj.Key! })),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
logger.info(`Deleted ${listResponse.Contents.length} objects from bucket`);
|
|
||||||
}
|
|
||||||
|
|
||||||
continuationToken = listResponse.IsTruncated ? listResponse.NextContinuationToken : undefined;
|
|
||||||
} while (continuationToken);
|
|
||||||
|
|
||||||
// Now delete the bucket
|
|
||||||
await s3Client.send(new DeleteBucketCommand({
|
|
||||||
Bucket: resource.resourceName,
|
|
||||||
}));
|
|
||||||
|
|
||||||
logger.success(`MinIO bucket '${resource.resourceName}' deleted`);
|
logger.success(`MinIO bucket '${resource.resourceName}' deleted`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(`Failed to delete MinIO bucket: ${getErrorMessage(e)}`);
|
logger.error(`Failed to delete MinIO bucket: ${getErrorMessage(e)}`);
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute mc (MinIO Client) command inside the container
|
||||||
|
*/
|
||||||
|
private async execMc(
|
||||||
|
containerId: string,
|
||||||
|
args: string[],
|
||||||
|
): Promise<{ stdout: string; stderr: string }> {
|
||||||
|
const result = await this.oneboxRef.docker.execInContainer(containerId, ['mc', ...args]);
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
throw new Error(`mc command failed (exit ${result.exitCode}): ${result.stderr.substring(0, 200)}`);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -190,12 +190,6 @@ export class MongoDBProvider extends BasePlatformServiceProvider {
|
|||||||
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
|
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
|
||||||
const containerName = this.getContainerName();
|
const containerName = this.getContainerName();
|
||||||
|
|
||||||
// Get container host port for connection from host (overlay network IPs not accessible from host)
|
|
||||||
const hostPort = await this.oneboxRef.docker.getContainerHostPort(platformService.containerId, 27017);
|
|
||||||
if (!hostPort) {
|
|
||||||
throw new Error('Could not get MongoDB container host port');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate resource names and credentials
|
// Generate resource names and credentials
|
||||||
const dbName = this.generateResourceName(userService.name);
|
const dbName = this.generateResourceName(userService.name);
|
||||||
const username = this.generateResourceName(userService.name);
|
const username = this.generateResourceName(userService.name);
|
||||||
@@ -203,32 +197,40 @@ export class MongoDBProvider extends BasePlatformServiceProvider {
|
|||||||
|
|
||||||
logger.info(`Provisioning MongoDB database '${dbName}' for service '${userService.name}'...`);
|
logger.info(`Provisioning MongoDB database '${dbName}' for service '${userService.name}'...`);
|
||||||
|
|
||||||
// Connect to MongoDB via localhost and the mapped host port
|
// Use docker exec to provision inside the container (avoids host port mapping issues)
|
||||||
const { MongoClient } = await import('npm:mongodb@6');
|
const escapedPassword = password.replace(/'/g, "'\\''");
|
||||||
const adminUri = `mongodb://${adminCreds.username}:${adminCreds.password}@127.0.0.1:${hostPort}/?authSource=admin`;
|
const escapedAdminPassword = adminCreds.password.replace(/'/g, "'\\''");
|
||||||
|
|
||||||
const client = new MongoClient(adminUri);
|
// Create database and user via mongosh inside the container
|
||||||
await client.connect();
|
const mongoshScript = `
|
||||||
|
db = db.getSiblingDB('${dbName}');
|
||||||
try {
|
db.createCollection('_onebox_init');
|
||||||
// Create the database by switching to it (MongoDB creates on first write)
|
db.createUser({
|
||||||
const db = client.db(dbName);
|
user: '${username}',
|
||||||
|
pwd: '${escapedPassword}',
|
||||||
// Create a collection to ensure the database exists
|
roles: [{ role: 'readWrite', db: '${dbName}' }]
|
||||||
await db.createCollection('_onebox_init');
|
|
||||||
|
|
||||||
// Create user with readWrite access to this database
|
|
||||||
await db.command({
|
|
||||||
createUser: username,
|
|
||||||
pwd: password,
|
|
||||||
roles: [{ role: 'readWrite', db: dbName }],
|
|
||||||
});
|
});
|
||||||
|
print('PROVISION_SUCCESS');
|
||||||
|
`;
|
||||||
|
|
||||||
logger.success(`MongoDB database '${dbName}' provisioned with user '${username}'`);
|
const result = await this.oneboxRef.docker.execInContainer(
|
||||||
} finally {
|
platformService.containerId,
|
||||||
await client.close();
|
[
|
||||||
|
'mongosh',
|
||||||
|
'--username', adminCreds.username,
|
||||||
|
'--password', escapedAdminPassword,
|
||||||
|
'--authenticationDatabase', 'admin',
|
||||||
|
'--quiet',
|
||||||
|
'--eval', mongoshScript,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.exitCode !== 0 || !result.stdout.includes('PROVISION_SUCCESS')) {
|
||||||
|
throw new Error(`Failed to provision MongoDB database: exit code ${result.exitCode}, output: ${result.stdout.substring(0, 200)} ${result.stderr.substring(0, 200)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.success(`MongoDB database '${dbName}' provisioned with user '${username}'`);
|
||||||
|
|
||||||
// Build the credentials and env vars
|
// Build the credentials and env vars
|
||||||
const credentials: Record<string, string> = {
|
const credentials: Record<string, string> = {
|
||||||
host: containerName,
|
host: containerName,
|
||||||
@@ -262,37 +264,33 @@ export class MongoDBProvider extends BasePlatformServiceProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
|
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
|
||||||
|
const escapedAdminPassword = adminCreds.password.replace(/'/g, "'\\''");
|
||||||
// Get container host port for connection from host (overlay network IPs not accessible from host)
|
|
||||||
const hostPort = await this.oneboxRef.docker.getContainerHostPort(platformService.containerId, 27017);
|
|
||||||
if (!hostPort) {
|
|
||||||
throw new Error('Could not get MongoDB container host port');
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Deprovisioning MongoDB database '${resource.resourceName}'...`);
|
logger.info(`Deprovisioning MongoDB database '${resource.resourceName}'...`);
|
||||||
|
|
||||||
const { MongoClient } = await import('npm:mongodb@6');
|
const mongoshScript = `
|
||||||
const adminUri = `mongodb://${adminCreds.username}:${adminCreds.password}@127.0.0.1:${hostPort}/?authSource=admin`;
|
db = db.getSiblingDB('${resource.resourceName}');
|
||||||
|
try { db.dropUser('${credentials.username}'); } catch(e) { print('User drop failed: ' + e); }
|
||||||
|
db.dropDatabase();
|
||||||
|
print('DEPROVISION_SUCCESS');
|
||||||
|
`;
|
||||||
|
|
||||||
const client = new MongoClient(adminUri);
|
const result = await this.oneboxRef.docker.execInContainer(
|
||||||
await client.connect();
|
platformService.containerId,
|
||||||
|
[
|
||||||
|
'mongosh',
|
||||||
|
'--username', adminCreds.username,
|
||||||
|
'--password', escapedAdminPassword,
|
||||||
|
'--authenticationDatabase', 'admin',
|
||||||
|
'--quiet',
|
||||||
|
'--eval', mongoshScript,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
if (result.exitCode !== 0) {
|
||||||
const db = client.db(resource.resourceName);
|
logger.warn(`MongoDB deprovision returned exit code ${result.exitCode}: ${result.stderr.substring(0, 200)}`);
|
||||||
|
|
||||||
// Drop the user
|
|
||||||
try {
|
|
||||||
await db.command({ dropUser: credentials.username });
|
|
||||||
logger.info(`Dropped MongoDB user '${credentials.username}'`);
|
|
||||||
} catch (e) {
|
|
||||||
logger.warn(`Could not drop MongoDB user: ${getErrorMessage(e)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Drop the database
|
|
||||||
await db.dropDatabase();
|
|
||||||
logger.success(`MongoDB database '${resource.resourceName}' dropped`);
|
|
||||||
} finally {
|
|
||||||
await client.close();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.success(`MongoDB database '${resource.resourceName}' dropped`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ Restart=always
|
|||||||
RestartSec=10
|
RestartSec=10
|
||||||
WorkingDirectory=/var/lib/onebox
|
WorkingDirectory=/var/lib/onebox
|
||||||
Environment=PATH=/usr/bin:/usr/local/bin
|
Environment=PATH=/usr/bin:/usr/local/bin
|
||||||
|
Environment=HOME=/root
|
||||||
|
Environment=DENO_DIR=/root/.cache/deno
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
@@ -36,6 +38,9 @@ export class OneboxSystemd {
|
|||||||
*/
|
*/
|
||||||
async enable(): Promise<void> {
|
async enable(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
// Ensure Docker is installed before writing unit file (it requires docker.service)
|
||||||
|
await this.ensureDocker();
|
||||||
|
|
||||||
// Write the unit file
|
// Write the unit file
|
||||||
logger.info('Writing systemd unit file...');
|
logger.info('Writing systemd unit file...');
|
||||||
await Deno.writeTextFile(SERVICE_FILE_PATH, SERVICE_UNIT_TEMPLATE);
|
await Deno.writeTextFile(SERVICE_FILE_PATH, SERVICE_UNIT_TEMPLATE);
|
||||||
@@ -163,6 +168,59 @@ export class OneboxSystemd {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure Docker is installed, installing it if necessary
|
||||||
|
*/
|
||||||
|
private async ensureDocker(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const cmd = new Deno.Command('docker', {
|
||||||
|
args: ['--version'],
|
||||||
|
stdout: 'piped',
|
||||||
|
stderr: 'piped',
|
||||||
|
});
|
||||||
|
const result = await cmd.output();
|
||||||
|
if (result.success) {
|
||||||
|
const version = new TextDecoder().decode(result.stdout).trim();
|
||||||
|
logger.info(`Docker found: ${version}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// docker command not found
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Docker not found. Installing Docker...');
|
||||||
|
const installCmd = new Deno.Command('bash', {
|
||||||
|
args: ['-c', 'curl -fsSL https://get.docker.com | sh'],
|
||||||
|
stdin: 'inherit',
|
||||||
|
stdout: 'inherit',
|
||||||
|
stderr: 'inherit',
|
||||||
|
});
|
||||||
|
const installResult = await installCmd.output();
|
||||||
|
if (!installResult.success) {
|
||||||
|
throw new Error('Failed to install Docker. Please install it manually: curl -fsSL https://get.docker.com | sh');
|
||||||
|
}
|
||||||
|
logger.success('Docker installed successfully');
|
||||||
|
|
||||||
|
// Initialize Docker Swarm
|
||||||
|
logger.info('Initializing Docker Swarm...');
|
||||||
|
const swarmCmd = new Deno.Command('docker', {
|
||||||
|
args: ['swarm', 'init'],
|
||||||
|
stdout: 'piped',
|
||||||
|
stderr: 'piped',
|
||||||
|
});
|
||||||
|
const swarmResult = await swarmCmd.output();
|
||||||
|
if (swarmResult.success) {
|
||||||
|
logger.success('Docker Swarm initialized');
|
||||||
|
} else {
|
||||||
|
const stderr = new TextDecoder().decode(swarmResult.stderr);
|
||||||
|
if (stderr.includes('already part of a swarm')) {
|
||||||
|
logger.info('Docker Swarm already initialized');
|
||||||
|
} else {
|
||||||
|
logger.warn(`Docker Swarm init warning: ${stderr.trim()}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run a systemctl command and return results
|
* Run a systemctl command and return results
|
||||||
*/
|
*/
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/onebox',
|
name: '@serve.zone/onebox',
|
||||||
version: '1.15.0',
|
version: '1.17.2',
|
||||||
description: 'Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers'
|
description: 'Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export interface IServicesState {
|
|||||||
currentServiceStats: interfaces.data.IContainerStats | null;
|
currentServiceStats: interfaces.data.IContainerStats | null;
|
||||||
platformServices: interfaces.data.IPlatformService[];
|
platformServices: interfaces.data.IPlatformService[];
|
||||||
currentPlatformService: interfaces.data.IPlatformService | null;
|
currentPlatformService: interfaces.data.IPlatformService | null;
|
||||||
|
currentPlatformServiceStats: interfaces.data.IContainerStats | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface INetworkState {
|
export interface INetworkState {
|
||||||
@@ -88,6 +89,7 @@ export const servicesStatePart = await appState.getStatePart<IServicesState>(
|
|||||||
currentServiceStats: null,
|
currentServiceStats: null,
|
||||||
platformServices: [],
|
platformServices: [],
|
||||||
currentPlatformService: null,
|
currentPlatformService: null,
|
||||||
|
currentPlatformServiceStats: null,
|
||||||
},
|
},
|
||||||
'soft',
|
'soft',
|
||||||
);
|
);
|
||||||
@@ -476,6 +478,25 @@ export const stopPlatformServiceAction = servicesStatePart.createAction<{
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const fetchPlatformServiceStatsAction = servicesStatePart.createAction<{
|
||||||
|
serviceType: interfaces.data.TPlatformServiceType;
|
||||||
|
}>(async (statePartArg, dataArg) => {
|
||||||
|
const context = getActionContext();
|
||||||
|
try {
|
||||||
|
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_GetPlatformServiceStats
|
||||||
|
>('/typedrequest', 'getPlatformServiceStats');
|
||||||
|
const response = await typedRequest.fire({
|
||||||
|
identity: context.identity!,
|
||||||
|
serviceType: dataArg.serviceType,
|
||||||
|
});
|
||||||
|
return { ...statePartArg.getState(), currentPlatformServiceStats: response.stats };
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch platform service stats:', err);
|
||||||
|
return { ...statePartArg.getState(), currentPlatformServiceStats: null };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Network Actions
|
// Network Actions
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export class ObViewDashboard extends DeesElement {
|
|||||||
currentServiceStats: null,
|
currentServiceStats: null,
|
||||||
platformServices: [],
|
platformServices: [],
|
||||||
currentPlatformService: null,
|
currentPlatformService: null,
|
||||||
|
currentPlatformServiceStats: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
@@ -149,6 +150,7 @@ export class ObViewDashboard extends DeesElement {
|
|||||||
],
|
],
|
||||||
}}
|
}}
|
||||||
@action-click=${(e: CustomEvent) => this.handleQuickAction(e)}
|
@action-click=${(e: CustomEvent) => this.handleQuickAction(e)}
|
||||||
|
@service-click=${(e: CustomEvent) => this.handlePlatformServiceClick(e)}
|
||||||
></sz-dashboard-view>
|
></sz-dashboard-view>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -161,4 +163,21 @@ export class ObViewDashboard extends DeesElement {
|
|||||||
appstate.uiStatePart.dispatchAction(appstate.setActiveViewAction, { view: 'network' });
|
appstate.uiStatePart.dispatchAction(appstate.setActiveViewAction, { view: 'network' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private handlePlatformServiceClick(e: CustomEvent) {
|
||||||
|
// Find the platform service type from the click event
|
||||||
|
const name = e.detail?.name;
|
||||||
|
const ps = this.servicesState.platformServices.find(
|
||||||
|
(p) => p.displayName === name,
|
||||||
|
);
|
||||||
|
if (ps) {
|
||||||
|
// Navigate to services tab — the ObViewServices component will pick up the type
|
||||||
|
// Store the selected platform type so the services view can open it
|
||||||
|
appstate.servicesStatePart.setState({
|
||||||
|
...appstate.servicesStatePart.getState(),
|
||||||
|
currentPlatformService: ps,
|
||||||
|
});
|
||||||
|
appstate.uiStatePart.dispatchAction(appstate.setActiveViewAction, { view: 'services' });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ export class ObViewServices extends DeesElement {
|
|||||||
currentServiceStats: null,
|
currentServiceStats: null,
|
||||||
platformServices: [],
|
platformServices: [],
|
||||||
currentPlatformService: null,
|
currentPlatformService: null,
|
||||||
|
currentPlatformServiceStats: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
@@ -145,7 +146,37 @@ export class ObViewServices extends DeesElement {
|
|||||||
public static styles = [
|
public static styles = [
|
||||||
cssManager.defaultStyles,
|
cssManager.defaultStyles,
|
||||||
shared.viewHostCss,
|
shared.viewHostCss,
|
||||||
css``,
|
css`
|
||||||
|
.page-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deploy-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: ${cssManager.bdTheme('#18181b', '#fafafa')};
|
||||||
|
color: ${cssManager.bdTheme('#fafafa', '#18181b')};
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deploy-button:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deploy-button svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
`,
|
||||||
];
|
];
|
||||||
|
|
||||||
async connectedCallback() {
|
async connectedCallback() {
|
||||||
@@ -154,6 +185,18 @@ export class ObViewServices extends DeesElement {
|
|||||||
appstate.servicesStatePart.dispatchAction(appstate.fetchServicesAction, null),
|
appstate.servicesStatePart.dispatchAction(appstate.fetchServicesAction, null),
|
||||||
appstate.servicesStatePart.dispatchAction(appstate.fetchPlatformServicesAction, null),
|
appstate.servicesStatePart.dispatchAction(appstate.fetchPlatformServicesAction, null),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// If a platform service was selected from the dashboard, navigate to its detail
|
||||||
|
const state = appstate.servicesStatePart.getState();
|
||||||
|
if (state.currentPlatformService) {
|
||||||
|
const type = state.currentPlatformService.type;
|
||||||
|
// Clear the selection so it doesn't persist on next visit
|
||||||
|
appstate.servicesStatePart.setState({
|
||||||
|
...appstate.servicesStatePart.getState(),
|
||||||
|
currentPlatformService: null,
|
||||||
|
});
|
||||||
|
this.navigateToPlatformDetail(type);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public render(): TemplateResult {
|
public render(): TemplateResult {
|
||||||
@@ -178,8 +221,23 @@ export class ObViewServices extends DeesElement {
|
|||||||
domain: s.domain || null,
|
domain: s.domain || null,
|
||||||
status: mapStatus(s.status),
|
status: mapStatus(s.status),
|
||||||
}));
|
}));
|
||||||
|
const mappedPlatformServices = this.servicesState.platformServices.map((ps) => ({
|
||||||
|
name: ps.displayName,
|
||||||
|
status: ps.status === 'running' ? `Running` : ps.status,
|
||||||
|
running: ps.status === 'running',
|
||||||
|
type: ps.type,
|
||||||
|
}));
|
||||||
return html`
|
return html`
|
||||||
<ob-sectionheading>Services</ob-sectionheading>
|
<ob-sectionheading>Services</ob-sectionheading>
|
||||||
|
<div class="page-actions">
|
||||||
|
<button class="deploy-button" @click=${() => { this.currentView = 'create'; }}>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||||
|
</svg>
|
||||||
|
Deploy Service
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<sz-services-list-view
|
<sz-services-list-view
|
||||||
.services=${mappedServices}
|
.services=${mappedServices}
|
||||||
@service-click=${(e: CustomEvent) => {
|
@service-click=${(e: CustomEvent) => {
|
||||||
@@ -197,6 +255,20 @@ export class ObViewServices extends DeesElement {
|
|||||||
}}
|
}}
|
||||||
@service-action=${(e: CustomEvent) => this.handleServiceAction(e)}
|
@service-action=${(e: CustomEvent) => this.handleServiceAction(e)}
|
||||||
></sz-services-list-view>
|
></sz-services-list-view>
|
||||||
|
<ob-sectionheading style="margin-top: 32px;">Platform Services</ob-sectionheading>
|
||||||
|
<div style="max-width: 500px;">
|
||||||
|
<sz-platform-services-card
|
||||||
|
.services=${mappedPlatformServices}
|
||||||
|
@service-click=${(e: CustomEvent) => {
|
||||||
|
const type = e.detail.type || this.servicesState.platformServices.find(
|
||||||
|
(ps) => ps.displayName === e.detail.name,
|
||||||
|
)?.type;
|
||||||
|
if (type) {
|
||||||
|
this.navigateToPlatformDetail(type);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
></sz-platform-services-card>
|
||||||
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,8 +278,26 @@ export class ObViewServices extends DeesElement {
|
|||||||
<sz-service-create-view
|
<sz-service-create-view
|
||||||
.registries=${[]}
|
.registries=${[]}
|
||||||
@create-service=${async (e: CustomEvent) => {
|
@create-service=${async (e: CustomEvent) => {
|
||||||
|
const formConfig = e.detail;
|
||||||
|
const serviceConfig: interfaces.data.IServiceCreate = {
|
||||||
|
name: formConfig.name,
|
||||||
|
image: formConfig.image,
|
||||||
|
port: formConfig.ports?.[0]?.containerPort
|
||||||
|
? parseInt(formConfig.ports[0].containerPort, 10)
|
||||||
|
: 80,
|
||||||
|
envVars: formConfig.envVars?.reduce(
|
||||||
|
(acc: Record<string, string>, ev: { key: string; value: string }) => {
|
||||||
|
if (ev.key) acc[ev.key] = ev.value;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, string>,
|
||||||
|
),
|
||||||
|
enableMongoDB: formConfig.enableMongoDB || false,
|
||||||
|
enableS3: formConfig.enableS3 || false,
|
||||||
|
enableClickHouse: formConfig.enableClickHouse || false,
|
||||||
|
};
|
||||||
await appstate.servicesStatePart.dispatchAction(appstate.createServiceAction, {
|
await appstate.servicesStatePart.dispatchAction(appstate.createServiceAction, {
|
||||||
config: e.detail,
|
config: serviceConfig,
|
||||||
});
|
});
|
||||||
this.currentView = 'list';
|
this.currentView = 'list';
|
||||||
}}
|
}}
|
||||||
@@ -265,10 +355,29 @@ export class ObViewServices extends DeesElement {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private navigateToPlatformDetail(type: string): void {
|
||||||
|
this.selectedPlatformType = type;
|
||||||
|
// Fetch stats for this platform service
|
||||||
|
appstate.servicesStatePart.dispatchAction(appstate.fetchPlatformServiceStatsAction, {
|
||||||
|
serviceType: type as interfaces.data.TPlatformServiceType,
|
||||||
|
});
|
||||||
|
this.currentView = 'platform-detail';
|
||||||
|
}
|
||||||
|
|
||||||
private renderPlatformDetailView(): TemplateResult {
|
private renderPlatformDetailView(): TemplateResult {
|
||||||
const platformService = this.servicesState.platformServices.find(
|
const platformService = this.servicesState.platformServices.find(
|
||||||
(ps) => ps.type === this.selectedPlatformType,
|
(ps) => ps.type === this.selectedPlatformType,
|
||||||
);
|
);
|
||||||
|
const stats = this.servicesState.currentPlatformServiceStats;
|
||||||
|
const metrics = stats
|
||||||
|
? {
|
||||||
|
cpu: Math.round(stats.cpuPercent),
|
||||||
|
memory: Math.round(stats.memoryPercent),
|
||||||
|
storage: 0,
|
||||||
|
connections: 0,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<ob-sectionheading>Platform Service</ob-sectionheading>
|
<ob-sectionheading>Platform Service</ob-sectionheading>
|
||||||
<sz-platform-service-detail-view
|
<sz-platform-service-detail-view
|
||||||
@@ -277,22 +386,45 @@ export class ObViewServices extends DeesElement {
|
|||||||
id: platformService.type,
|
id: platformService.type,
|
||||||
name: platformService.displayName,
|
name: platformService.displayName,
|
||||||
type: platformService.type,
|
type: platformService.type,
|
||||||
status: platformService.status,
|
status: platformService.status === 'running'
|
||||||
|
? 'running'
|
||||||
|
: platformService.status === 'failed'
|
||||||
|
? 'error'
|
||||||
|
: 'stopped',
|
||||||
version: '',
|
version: '',
|
||||||
host: 'localhost',
|
host: 'localhost',
|
||||||
port: 0,
|
port: 0,
|
||||||
config: {},
|
config: {},
|
||||||
|
metrics,
|
||||||
}
|
}
|
||||||
: null}
|
: null}
|
||||||
.logs=${[]}
|
.logs=${[]}
|
||||||
@start=${() => {
|
@back=${() => {
|
||||||
appstate.servicesStatePart.dispatchAction(appstate.startPlatformServiceAction, {
|
this.currentView = 'list';
|
||||||
serviceType: this.selectedPlatformType as any,
|
}}
|
||||||
|
@start=${async () => {
|
||||||
|
await appstate.servicesStatePart.dispatchAction(appstate.startPlatformServiceAction, {
|
||||||
|
serviceType: this.selectedPlatformType as interfaces.data.TPlatformServiceType,
|
||||||
|
});
|
||||||
|
// Refresh stats after starting
|
||||||
|
appstate.servicesStatePart.dispatchAction(appstate.fetchPlatformServiceStatsAction, {
|
||||||
|
serviceType: this.selectedPlatformType as interfaces.data.TPlatformServiceType,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
@stop=${() => {
|
@stop=${async () => {
|
||||||
appstate.servicesStatePart.dispatchAction(appstate.stopPlatformServiceAction, {
|
await appstate.servicesStatePart.dispatchAction(appstate.stopPlatformServiceAction, {
|
||||||
serviceType: this.selectedPlatformType as any,
|
serviceType: this.selectedPlatformType as interfaces.data.TPlatformServiceType,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
@restart=${async () => {
|
||||||
|
await appstate.servicesStatePart.dispatchAction(appstate.stopPlatformServiceAction, {
|
||||||
|
serviceType: this.selectedPlatformType as interfaces.data.TPlatformServiceType,
|
||||||
|
});
|
||||||
|
await appstate.servicesStatePart.dispatchAction(appstate.startPlatformServiceAction, {
|
||||||
|
serviceType: this.selectedPlatformType as interfaces.data.TPlatformServiceType,
|
||||||
|
});
|
||||||
|
appstate.servicesStatePart.dispatchAction(appstate.fetchPlatformServiceStatsAction, {
|
||||||
|
serviceType: this.selectedPlatformType as interfaces.data.TPlatformServiceType,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
></sz-platform-service-detail-view>
|
></sz-platform-service-detail-view>
|
||||||
|
|||||||
Reference in New Issue
Block a user