Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ff5b51072f | |||
| 633cbe696e | |||
| 0247ab45c7 | |||
| 0d932239d2 | |||
| 38b5462b09 | |||
| 9d7f132f6d | |||
| 3574a94e38 | |||
| be92d93f3f |
39
changelog.md
39
changelog.md
@@ -1,5 +1,44 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2025-11-26 - 1.4.0 - feat(platform-services)
|
||||||
|
Add ClickHouse platform service support and improve related healthchecks and tooling
|
||||||
|
|
||||||
|
- Add ClickHouse as a first-class platform service: register provider, provision/cleanup support and env var injection
|
||||||
|
- Expose ClickHouse endpoints in the HTTP API routing (list/get/start/stop/stats) and map default port (8123)
|
||||||
|
- Enable services to request ClickHouse as a platform requirement (enableClickHouse / platformRequirements) during deploy/provision flows
|
||||||
|
- Fix ClickHouse container health check to use absolute wget path (/usr/bin/wget) for more reliable in-container checks
|
||||||
|
- Add VS Code workspace launch/tasks/extensions configs for the UI (ui/.vscode/*) to improve local dev experience
|
||||||
|
|
||||||
|
## 2025-11-26 - 1.3.0 - feat(platform-services)
|
||||||
|
Add ClickHouse platform service support (provider, types, provisioning, UI and port mappings)
|
||||||
|
|
||||||
|
- Introduce ClickHouse as a first-class platform service: added ClickHouseProvider and registered it in PlatformServicesManager
|
||||||
|
- Support provisioning ClickHouse resources for user services and storing encrypted credentials in platform_resources
|
||||||
|
- Add ClickHouse to core types (TPlatformServiceType, IPlatformRequirements, IServiceDeployOptions) and service DB handling so services can request ClickHouse
|
||||||
|
- Inject ClickHouse-related environment variables into deployed services (CLICKHOUSE_* mappings) when provisioning resources
|
||||||
|
- Expose ClickHouse default port (8123) in platform port mappings / network targets
|
||||||
|
- UI: add checkbox and description for enabling ClickHouse during service creation; form now submits enableClickHouse
|
||||||
|
- Add VS Code recommendations and launch/tasks for the UI development workflow
|
||||||
|
|
||||||
|
## 2025-11-26 - 1.2.1 - fix(platform-services/minio)
|
||||||
|
Improve MinIO provider: reuse existing data and credentials, use host-bound port for provisioning, and safer provisioning/deprovisioning
|
||||||
|
|
||||||
|
- MinIO provider now detects existing data directory and will reuse stored admin credentials when available instead of regenerating them.
|
||||||
|
- If data exists but no credentials are stored, MinIO deployment will wipe the data directory to avoid credential mismatch and fail early with a clear error if wiping fails.
|
||||||
|
- Provisioning and deprovisioning now connect to MinIO via the container's host-mapped port (127.0.0.1:<hostPort>) instead of relying on overlay network addresses; an error is thrown when the host port mapping cannot be determined.
|
||||||
|
- Bucket provisioning creates policies and returns environment variables using container network hostnames for in-network access; a warning notes that per-service MinIO accounts are TODO and root credentials are used for now.
|
||||||
|
- Added logging improvements around MinIO deploy/provision/deprovision steps for easier debugging.
|
||||||
|
- Added VSCode workspace files (extensions, launch, tasks) for the ui project to improve developer experience.
|
||||||
|
|
||||||
|
## 2025-11-26 - 1.2.0 - feat(ui)
|
||||||
|
Sync UI tab state with URL and update routes/links
|
||||||
|
|
||||||
|
- Add VSCode workspace recommendations, launch and tasks configs for the UI (ui/.vscode/*)
|
||||||
|
- Update Angular routes to support tab URL segments and default redirects for services, network and registries
|
||||||
|
- Change service detail route to use explicit 'detail/:name' path and update links accordingly
|
||||||
|
- Make ServicesList, Registries and Network components read tab from route params and navigate on tab changes; add ngOnDestroy to unsubscribe
|
||||||
|
- Update Domain detail template link to point to the new services detail route
|
||||||
|
|
||||||
## 2025-11-26 - 1.1.0 - feat(platform-services)
|
## 2025-11-26 - 1.1.0 - feat(platform-services)
|
||||||
Add platform service log streaming, improve health checks and provisioning robustness
|
Add platform service log streaming, improve health checks and provisioning robustness
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@serve.zone/onebox",
|
"name": "@serve.zone/onebox",
|
||||||
"version": "1.1.0",
|
"version": "1.4.0",
|
||||||
"exports": "./mod.ts",
|
"exports": "./mod.ts",
|
||||||
"nodeModulesDir": "auto",
|
"nodeModulesDir": "auto",
|
||||||
"tasks": {
|
"tasks": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@serve.zone/onebox",
|
"name": "@serve.zone/onebox",
|
||||||
"version": "1.1.0",
|
"version": "1.4.0",
|
||||||
"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",
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/onebox',
|
name: '@serve.zone/onebox',
|
||||||
version: '1.1.0',
|
version: '1.4.0',
|
||||||
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'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -297,16 +297,16 @@ export class OneboxHttpServer {
|
|||||||
// Platform Services endpoints
|
// Platform Services endpoints
|
||||||
} else if (path === '/api/platform-services' && method === 'GET') {
|
} else if (path === '/api/platform-services' && method === 'GET') {
|
||||||
return await this.handleListPlatformServicesRequest();
|
return await this.handleListPlatformServicesRequest();
|
||||||
} else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq|caddy)$/) && method === 'GET') {
|
} else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq|caddy|clickhouse)$/) && method === 'GET') {
|
||||||
const type = path.split('/').pop()! as TPlatformServiceType;
|
const type = path.split('/').pop()! as TPlatformServiceType;
|
||||||
return await this.handleGetPlatformServiceRequest(type);
|
return await this.handleGetPlatformServiceRequest(type);
|
||||||
} else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq|caddy)\/start$/) && method === 'POST') {
|
} else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq|caddy|clickhouse)\/start$/) && method === 'POST') {
|
||||||
const type = path.split('/')[3] as TPlatformServiceType;
|
const type = path.split('/')[3] as TPlatformServiceType;
|
||||||
return await this.handleStartPlatformServiceRequest(type);
|
return await this.handleStartPlatformServiceRequest(type);
|
||||||
} else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq|caddy)\/stop$/) && method === 'POST') {
|
} else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq|caddy|clickhouse)\/stop$/) && method === 'POST') {
|
||||||
const type = path.split('/')[3] as TPlatformServiceType;
|
const type = path.split('/')[3] as TPlatformServiceType;
|
||||||
return await this.handleStopPlatformServiceRequest(type);
|
return await this.handleStopPlatformServiceRequest(type);
|
||||||
} else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq|caddy)\/stats$/) && method === 'GET') {
|
} else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq|caddy|clickhouse)\/stats$/) && method === 'GET') {
|
||||||
const type = path.split('/')[3] as TPlatformServiceType;
|
const type = path.split('/')[3] as TPlatformServiceType;
|
||||||
return await this.handleGetPlatformServiceStatsRequest(type);
|
return await this.handleGetPlatformServiceStatsRequest(type);
|
||||||
} else if (path.match(/^\/api\/services\/[^/]+\/platform-resources$/) && method === 'GET') {
|
} else if (path.match(/^\/api\/services\/[^/]+\/platform-resources$/) && method === 'GET') {
|
||||||
@@ -1323,6 +1323,7 @@ export class OneboxHttpServer {
|
|||||||
postgresql: 5432,
|
postgresql: 5432,
|
||||||
rabbitmq: 5672,
|
rabbitmq: 5672,
|
||||||
caddy: 80,
|
caddy: 80,
|
||||||
|
clickhouse: 8123,
|
||||||
};
|
};
|
||||||
return ports[type] || 0;
|
return ports[type] || 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type { IPlatformServiceProvider } from './providers/base.ts';
|
|||||||
import { MongoDBProvider } from './providers/mongodb.ts';
|
import { MongoDBProvider } from './providers/mongodb.ts';
|
||||||
import { MinioProvider } from './providers/minio.ts';
|
import { MinioProvider } from './providers/minio.ts';
|
||||||
import { CaddyProvider } from './providers/caddy.ts';
|
import { CaddyProvider } from './providers/caddy.ts';
|
||||||
|
import { ClickHouseProvider } from './providers/clickhouse.ts';
|
||||||
import { logger } from '../../logging.ts';
|
import { logger } from '../../logging.ts';
|
||||||
import { getErrorMessage } from '../../utils/error.ts';
|
import { getErrorMessage } from '../../utils/error.ts';
|
||||||
import { credentialEncryption } from '../encryption.ts';
|
import { credentialEncryption } from '../encryption.ts';
|
||||||
@@ -39,6 +40,7 @@ export class PlatformServicesManager {
|
|||||||
this.registerProvider(new MongoDBProvider(this.oneboxRef));
|
this.registerProvider(new MongoDBProvider(this.oneboxRef));
|
||||||
this.registerProvider(new MinioProvider(this.oneboxRef));
|
this.registerProvider(new MinioProvider(this.oneboxRef));
|
||||||
this.registerProvider(new CaddyProvider(this.oneboxRef));
|
this.registerProvider(new CaddyProvider(this.oneboxRef));
|
||||||
|
this.registerProvider(new ClickHouseProvider(this.oneboxRef));
|
||||||
|
|
||||||
logger.info(`Platform services manager initialized with ${this.providers.size} providers`);
|
logger.info(`Platform services manager initialized with ${this.providers.size} providers`);
|
||||||
}
|
}
|
||||||
@@ -275,6 +277,33 @@ export class PlatformServicesManager {
|
|||||||
logger.success(`S3 storage provisioned for service '${service.name}'`);
|
logger.success(`S3 storage provisioned for service '${service.name}'`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Provision ClickHouse if requested
|
||||||
|
if (requirements.clickhouse) {
|
||||||
|
logger.info(`Provisioning ClickHouse for service '${service.name}'...`);
|
||||||
|
|
||||||
|
// Ensure ClickHouse is running
|
||||||
|
const clickhouseService = await this.ensureRunning('clickhouse');
|
||||||
|
const provider = this.providers.get('clickhouse')!;
|
||||||
|
|
||||||
|
// Provision database
|
||||||
|
const result = await provider.provisionResource(service);
|
||||||
|
|
||||||
|
// Store resource record
|
||||||
|
const encryptedCreds = await credentialEncryption.encrypt(result.credentials);
|
||||||
|
this.oneboxRef.database.createPlatformResource({
|
||||||
|
platformServiceId: clickhouseService.id!,
|
||||||
|
serviceId: service.id!,
|
||||||
|
resourceType: result.type,
|
||||||
|
resourceName: result.name,
|
||||||
|
credentialsEncrypted: encryptedCreds,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Merge env vars
|
||||||
|
Object.assign(allEnvVars, result.envVars);
|
||||||
|
logger.success(`ClickHouse provisioned for service '${service.name}'`);
|
||||||
|
}
|
||||||
|
|
||||||
return allEnvVars;
|
return allEnvVars;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
338
ts/classes/platform-services/providers/clickhouse.ts
Normal file
338
ts/classes/platform-services/providers/clickhouse.ts
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
/**
|
||||||
|
* ClickHouse Platform Service Provider
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { BasePlatformServiceProvider } from './base.ts';
|
||||||
|
import type {
|
||||||
|
IService,
|
||||||
|
IPlatformResource,
|
||||||
|
IPlatformServiceConfig,
|
||||||
|
IProvisionedResource,
|
||||||
|
IEnvVarMapping,
|
||||||
|
TPlatformServiceType,
|
||||||
|
TPlatformResourceType,
|
||||||
|
} from '../../../types.ts';
|
||||||
|
import { logger } from '../../../logging.ts';
|
||||||
|
import { getErrorMessage } from '../../../utils/error.ts';
|
||||||
|
import { credentialEncryption } from '../../encryption.ts';
|
||||||
|
import type { Onebox } from '../../onebox.ts';
|
||||||
|
|
||||||
|
export class ClickHouseProvider extends BasePlatformServiceProvider {
|
||||||
|
readonly type: TPlatformServiceType = 'clickhouse';
|
||||||
|
readonly displayName = 'ClickHouse';
|
||||||
|
readonly resourceTypes: TPlatformResourceType[] = ['database'];
|
||||||
|
|
||||||
|
constructor(oneboxRef: Onebox) {
|
||||||
|
super(oneboxRef);
|
||||||
|
}
|
||||||
|
|
||||||
|
getDefaultConfig(): IPlatformServiceConfig {
|
||||||
|
return {
|
||||||
|
image: 'clickhouse/clickhouse-server:latest',
|
||||||
|
port: 8123, // HTTP interface
|
||||||
|
volumes: ['/var/lib/onebox/clickhouse:/var/lib/clickhouse'],
|
||||||
|
environment: {
|
||||||
|
CLICKHOUSE_DB: 'default',
|
||||||
|
// Password will be generated and stored encrypted
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
getEnvVarMappings(): IEnvVarMapping[] {
|
||||||
|
return [
|
||||||
|
{ envVar: 'CLICKHOUSE_HOST', credentialPath: 'host' },
|
||||||
|
{ envVar: 'CLICKHOUSE_PORT', credentialPath: 'port' },
|
||||||
|
{ envVar: 'CLICKHOUSE_HTTP_PORT', credentialPath: 'httpPort' },
|
||||||
|
{ envVar: 'CLICKHOUSE_DATABASE', credentialPath: 'database' },
|
||||||
|
{ envVar: 'CLICKHOUSE_USER', credentialPath: 'username' },
|
||||||
|
{ envVar: 'CLICKHOUSE_PASSWORD', credentialPath: 'password' },
|
||||||
|
{ envVar: 'CLICKHOUSE_URL', credentialPath: 'connectionUrl' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
async deployContainer(): Promise<string> {
|
||||||
|
const config = this.getDefaultConfig();
|
||||||
|
const containerName = this.getContainerName();
|
||||||
|
const dataDir = '/var/lib/onebox/clickhouse';
|
||||||
|
|
||||||
|
logger.info(`Deploying ClickHouse platform service as ${containerName}...`);
|
||||||
|
|
||||||
|
// Check if we have existing data and stored credentials
|
||||||
|
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
|
||||||
|
let adminCredentials: { username: string; password: string };
|
||||||
|
let dataExists = false;
|
||||||
|
|
||||||
|
// Check if data directory has existing ClickHouse data
|
||||||
|
// ClickHouse creates 'metadata' directory on first startup
|
||||||
|
try {
|
||||||
|
const stat = await Deno.stat(`${dataDir}/metadata`);
|
||||||
|
dataExists = stat.isDirectory;
|
||||||
|
logger.info(`ClickHouse data directory exists with metadata folder`);
|
||||||
|
} catch {
|
||||||
|
// metadata directory doesn't exist, this is a fresh install
|
||||||
|
dataExists = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dataExists && platformService?.adminCredentialsEncrypted) {
|
||||||
|
// Reuse existing credentials from database
|
||||||
|
logger.info('Reusing existing ClickHouse credentials (data directory already initialized)');
|
||||||
|
adminCredentials = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
|
||||||
|
} else {
|
||||||
|
// Generate new credentials for fresh deployment
|
||||||
|
logger.info('Generating new ClickHouse admin credentials');
|
||||||
|
adminCredentials = {
|
||||||
|
username: 'default',
|
||||||
|
password: credentialEncryption.generatePassword(32),
|
||||||
|
};
|
||||||
|
|
||||||
|
// If data exists but we don't have credentials, we need to wipe the data
|
||||||
|
if (dataExists) {
|
||||||
|
logger.warn('ClickHouse data exists but no credentials in database - wiping data directory');
|
||||||
|
try {
|
||||||
|
await Deno.remove(dataDir, { recursive: true });
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(`Failed to wipe ClickHouse data directory: ${getErrorMessage(e)}`);
|
||||||
|
throw new Error('Cannot deploy ClickHouse: data directory exists without credentials');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure data directory exists
|
||||||
|
try {
|
||||||
|
await Deno.mkdir(dataDir, { recursive: true });
|
||||||
|
} catch (e) {
|
||||||
|
// Directory might already exist
|
||||||
|
if (!(e instanceof Deno.errors.AlreadyExists)) {
|
||||||
|
logger.warn(`Could not create ClickHouse data directory: ${getErrorMessage(e)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create container using Docker API
|
||||||
|
// ClickHouse uses environment variables for initial setup
|
||||||
|
const envVars = [
|
||||||
|
`CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT=1`,
|
||||||
|
`CLICKHOUSE_USER=${adminCredentials.username}`,
|
||||||
|
`CLICKHOUSE_PASSWORD=${adminCredentials.password}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
const containerId = await this.oneboxRef.docker.createPlatformContainer({
|
||||||
|
name: containerName,
|
||||||
|
image: config.image,
|
||||||
|
port: config.port,
|
||||||
|
env: envVars,
|
||||||
|
volumes: config.volumes,
|
||||||
|
network: this.getNetworkName(),
|
||||||
|
exposePorts: [8123, 9000], // HTTP and native TCP ports
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store encrypted admin credentials (only update if new or changed)
|
||||||
|
const encryptedCreds = await credentialEncryption.encrypt(adminCredentials);
|
||||||
|
if (platformService) {
|
||||||
|
this.oneboxRef.database.updatePlatformService(platformService.id!, {
|
||||||
|
containerId,
|
||||||
|
adminCredentialsEncrypted: encryptedCreds,
|
||||||
|
status: 'starting',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success(`ClickHouse container created: ${containerId}`);
|
||||||
|
return containerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async stopContainer(containerId: string): Promise<void> {
|
||||||
|
logger.info(`Stopping ClickHouse container ${containerId}...`);
|
||||||
|
await this.oneboxRef.docker.stopContainer(containerId);
|
||||||
|
logger.success('ClickHouse container stopped');
|
||||||
|
}
|
||||||
|
|
||||||
|
async healthCheck(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
logger.info('ClickHouse health check: starting...');
|
||||||
|
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
|
||||||
|
if (!platformService) {
|
||||||
|
logger.info('ClickHouse health check: platform service not found in database');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!platformService.adminCredentialsEncrypted) {
|
||||||
|
logger.info('ClickHouse health check: no admin credentials stored');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!platformService.containerId) {
|
||||||
|
logger.info('ClickHouse health check: no container ID in database record');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`ClickHouse health check: using container ID ${platformService.containerId.substring(0, 12)}...`);
|
||||||
|
|
||||||
|
// Use docker exec to run health check inside the container
|
||||||
|
// This avoids network issues with overlay networks
|
||||||
|
// Note: ClickHouse image has wget but not curl - use full path for reliability
|
||||||
|
const result = await this.oneboxRef.docker.execInContainer(
|
||||||
|
platformService.containerId,
|
||||||
|
['/usr/bin/wget', '-q', '-O', '-', 'http://localhost:8123/ping']
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.exitCode === 0) {
|
||||||
|
logger.info('ClickHouse health check: success');
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
logger.info(`ClickHouse health check failed: exit code ${result.exitCode}, stderr: ${result.stderr.substring(0, 200)}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.info(`ClickHouse health check exception: ${getErrorMessage(error)}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async provisionResource(userService: IService): Promise<IProvisionedResource> {
|
||||||
|
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
|
||||||
|
if (!platformService || !platformService.adminCredentialsEncrypted || !platformService.containerId) {
|
||||||
|
throw new Error('ClickHouse platform service not found or not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
|
||||||
|
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
|
||||||
|
const dbName = this.generateResourceName(userService.name);
|
||||||
|
const username = this.generateResourceName(userService.name);
|
||||||
|
const password = credentialEncryption.generatePassword(32);
|
||||||
|
|
||||||
|
logger.info(`Provisioning ClickHouse database '${dbName}' for service '${userService.name}'...`);
|
||||||
|
|
||||||
|
// Connect to ClickHouse via localhost and the mapped host port
|
||||||
|
const baseUrl = `http://127.0.0.1:${hostPort}`;
|
||||||
|
|
||||||
|
// Create database
|
||||||
|
await this.executeQuery(
|
||||||
|
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}'`);
|
||||||
|
|
||||||
|
// Build the credentials and env vars
|
||||||
|
const credentials: Record<string, string> = {
|
||||||
|
host: containerName,
|
||||||
|
port: '9000', // Native TCP port
|
||||||
|
httpPort: '8123',
|
||||||
|
database: dbName,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
connectionUrl: `http://${username}:${password}@${containerName}:8123/?database=${dbName}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Map credentials to env vars
|
||||||
|
const envVars: Record<string, string> = {};
|
||||||
|
for (const mapping of this.getEnvVarMappings()) {
|
||||||
|
if (credentials[mapping.credentialPath]) {
|
||||||
|
envVars[mapping.envVar] = credentials[mapping.credentialPath];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'database',
|
||||||
|
name: dbName,
|
||||||
|
credentials,
|
||||||
|
envVars,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async deprovisionResource(resource: IPlatformResource, credentials: Record<string, string>): Promise<void> {
|
||||||
|
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
|
||||||
|
if (!platformService || !platformService.adminCredentialsEncrypted || !platformService.containerId) {
|
||||||
|
throw new Error('ClickHouse platform service not found or not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
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}'...`);
|
||||||
|
|
||||||
|
const baseUrl = `http://127.0.0.1:${hostPort}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Drop the user
|
||||||
|
try {
|
||||||
|
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`);
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(`Failed to deprovision ClickHouse database: ${getErrorMessage(e)}`);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a ClickHouse SQL query via HTTP interface
|
||||||
|
*/
|
||||||
|
private async executeQuery(
|
||||||
|
baseUrl: string,
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
query: string
|
||||||
|
): Promise<string> {
|
||||||
|
const url = `${baseUrl}/?user=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`;
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: query,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(`ClickHouse query failed: ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.text();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,22 +57,55 @@ export class MinioProvider extends BasePlatformServiceProvider {
|
|||||||
async deployContainer(): Promise<string> {
|
async deployContainer(): Promise<string> {
|
||||||
const config = this.getDefaultConfig();
|
const config = this.getDefaultConfig();
|
||||||
const containerName = this.getContainerName();
|
const containerName = this.getContainerName();
|
||||||
|
const dataDir = '/var/lib/onebox/minio';
|
||||||
// Generate admin credentials
|
|
||||||
const adminUser = 'admin';
|
|
||||||
const adminPassword = credentialEncryption.generatePassword(32);
|
|
||||||
|
|
||||||
const adminCredentials = {
|
|
||||||
username: adminUser,
|
|
||||||
password: adminPassword,
|
|
||||||
};
|
|
||||||
|
|
||||||
logger.info(`Deploying MinIO platform service as ${containerName}...`);
|
logger.info(`Deploying MinIO platform service as ${containerName}...`);
|
||||||
|
|
||||||
|
// Check if we have existing data and stored credentials
|
||||||
|
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
|
||||||
|
let adminCredentials: { username: string; password: string };
|
||||||
|
let dataExists = false;
|
||||||
|
|
||||||
|
// Check if data directory has existing MinIO data
|
||||||
|
// MinIO creates .minio.sys directory on first startup
|
||||||
|
try {
|
||||||
|
const stat = await Deno.stat(`${dataDir}/.minio.sys`);
|
||||||
|
dataExists = stat.isDirectory;
|
||||||
|
logger.info(`MinIO data directory exists with .minio.sys folder`);
|
||||||
|
} catch {
|
||||||
|
// .minio.sys doesn't exist, this is a fresh install
|
||||||
|
dataExists = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dataExists && platformService?.adminCredentialsEncrypted) {
|
||||||
|
// Reuse existing credentials from database
|
||||||
|
logger.info('Reusing existing MinIO credentials (data directory already initialized)');
|
||||||
|
adminCredentials = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
|
||||||
|
} else {
|
||||||
|
// Generate new credentials for fresh deployment
|
||||||
|
logger.info('Generating new MinIO admin credentials');
|
||||||
|
adminCredentials = {
|
||||||
|
username: 'admin',
|
||||||
|
password: credentialEncryption.generatePassword(32),
|
||||||
|
};
|
||||||
|
|
||||||
|
// If data exists but we don't have credentials, we need to wipe the data
|
||||||
|
if (dataExists) {
|
||||||
|
logger.warn('MinIO data exists but no credentials in database - wiping data directory');
|
||||||
|
try {
|
||||||
|
await Deno.remove(dataDir, { recursive: true });
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(`Failed to wipe MinIO data directory: ${getErrorMessage(e)}`);
|
||||||
|
throw new Error('Cannot deploy MinIO: data directory exists without credentials');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure data directory exists
|
// Ensure data directory exists
|
||||||
try {
|
try {
|
||||||
await Deno.mkdir('/var/lib/onebox/minio', { recursive: true });
|
await Deno.mkdir(dataDir, { recursive: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
// Directory might already exist
|
||||||
if (!(e instanceof Deno.errors.AlreadyExists)) {
|
if (!(e instanceof Deno.errors.AlreadyExists)) {
|
||||||
logger.warn(`Could not create MinIO data directory: ${getErrorMessage(e)}`);
|
logger.warn(`Could not create MinIO data directory: ${getErrorMessage(e)}`);
|
||||||
}
|
}
|
||||||
@@ -95,9 +128,8 @@ export class MinioProvider extends BasePlatformServiceProvider {
|
|||||||
exposePorts: [9000, 9001], // API and Console ports
|
exposePorts: [9000, 9001], // API and Console ports
|
||||||
});
|
});
|
||||||
|
|
||||||
// Store encrypted admin credentials
|
// Store encrypted admin credentials (only update if new or changed)
|
||||||
const encryptedCreds = await credentialEncryption.encrypt(adminCredentials);
|
const encryptedCreds = await credentialEncryption.encrypt(adminCredentials);
|
||||||
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
|
|
||||||
if (platformService) {
|
if (platformService) {
|
||||||
this.oneboxRef.database.updatePlatformService(platformService.id!, {
|
this.oneboxRef.database.updatePlatformService(platformService.id!, {
|
||||||
containerId,
|
containerId,
|
||||||
@@ -118,41 +150,58 @@ export class MinioProvider extends BasePlatformServiceProvider {
|
|||||||
|
|
||||||
async healthCheck(): Promise<boolean> {
|
async healthCheck(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
|
logger.info('MinIO health check: starting...');
|
||||||
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
|
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
|
||||||
if (!platformService || !platformService.containerId) {
|
if (!platformService) {
|
||||||
|
logger.info('MinIO health check: platform service not found in database');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!platformService.adminCredentialsEncrypted) {
|
||||||
|
logger.info('MinIO health check: no admin credentials stored');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!platformService.containerId) {
|
||||||
|
logger.info('MinIO health check: no container ID in database record');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get container IP for health check (hostname won't resolve from host)
|
logger.info(`MinIO health check: using container ID ${platformService.containerId.substring(0, 12)}...`);
|
||||||
const containerIP = await this.oneboxRef.docker.getContainerIP(platformService.containerId);
|
|
||||||
if (!containerIP) {
|
// Use docker exec to run health check inside the container
|
||||||
logger.debug('MinIO health check: could not get container IP');
|
// This avoids network issues with overlay networks
|
||||||
|
const result = await this.oneboxRef.docker.execInContainer(
|
||||||
|
platformService.containerId,
|
||||||
|
['curl', '-sf', 'http://localhost:9000/minio/health/live']
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.exitCode === 0) {
|
||||||
|
logger.info('MinIO health check: success');
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
logger.info(`MinIO health check failed: exit code ${result.exitCode}, stderr: ${result.stderr.substring(0, 200)}`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const endpoint = `http://${containerIP}:9000/minio/health/live`;
|
|
||||||
|
|
||||||
const response = await fetch(endpoint, {
|
|
||||||
method: 'GET',
|
|
||||||
signal: AbortSignal.timeout(5000),
|
|
||||||
});
|
|
||||||
|
|
||||||
return response.ok;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.debug(`MinIO health check failed: ${getErrorMessage(error)}`);
|
logger.info(`MinIO health check exception: ${getErrorMessage(error)}`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async provisionResource(userService: IService): Promise<IProvisionedResource> {
|
async provisionResource(userService: IService): Promise<IProvisionedResource> {
|
||||||
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
|
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
|
||||||
if (!platformService || !platformService.adminCredentialsEncrypted) {
|
if (!platformService || !platformService.adminCredentialsEncrypted || !platformService.containerId) {
|
||||||
throw new Error('MinIO platform service not found or not configured');
|
throw new Error('MinIO platform service not found or not configured');
|
||||||
}
|
}
|
||||||
|
|
||||||
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, 9000);
|
||||||
|
if (!hostPort) {
|
||||||
|
throw new Error('Could not get MinIO container host port');
|
||||||
|
}
|
||||||
|
|
||||||
// Generate bucket name and credentials
|
// Generate bucket name and credentials
|
||||||
const bucketName = this.generateBucketName(userService.name);
|
const bucketName = this.generateBucketName(userService.name);
|
||||||
const accessKey = credentialEncryption.generateAccessKey(20);
|
const accessKey = credentialEncryption.generateAccessKey(20);
|
||||||
@@ -160,14 +209,15 @@ export class MinioProvider extends BasePlatformServiceProvider {
|
|||||||
|
|
||||||
logger.info(`Provisioning MinIO bucket '${bucketName}' for service '${userService.name}'...`);
|
logger.info(`Provisioning MinIO bucket '${bucketName}' for service '${userService.name}'...`);
|
||||||
|
|
||||||
const endpoint = `http://${containerName}:9000`;
|
// Connect to MinIO via localhost and the mapped host port (for provisioning from host)
|
||||||
|
const provisioningEndpoint = `http://127.0.0.1:${hostPort}`;
|
||||||
|
|
||||||
// Import AWS S3 client
|
// Import AWS S3 client
|
||||||
const { S3Client, CreateBucketCommand, PutBucketPolicyCommand } = await import('npm:@aws-sdk/client-s3@3');
|
const { S3Client, CreateBucketCommand, PutBucketPolicyCommand } = await import('npm:@aws-sdk/client-s3@3');
|
||||||
|
|
||||||
// Create S3 client with admin credentials
|
// Create S3 client with admin credentials - connect via host port
|
||||||
const s3Client = new S3Client({
|
const s3Client = new S3Client({
|
||||||
endpoint,
|
endpoint: provisioningEndpoint,
|
||||||
region: 'us-east-1',
|
region: 'us-east-1',
|
||||||
credentials: {
|
credentials: {
|
||||||
accessKeyId: adminCreds.username,
|
accessKeyId: adminCreds.username,
|
||||||
@@ -225,8 +275,11 @@ export class MinioProvider extends BasePlatformServiceProvider {
|
|||||||
// TODO: Implement MinIO service account creation
|
// TODO: Implement MinIO service account creation
|
||||||
logger.warn('Using root credentials for MinIO access. Consider implementing service accounts for production.');
|
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)
|
||||||
|
const serviceEndpoint = `http://${containerName}:9000`;
|
||||||
|
|
||||||
const credentials: Record<string, string> = {
|
const credentials: Record<string, string> = {
|
||||||
endpoint,
|
endpoint: serviceEndpoint,
|
||||||
bucket: bucketName,
|
bucket: bucketName,
|
||||||
accessKey: adminCreds.username, // Using root for now
|
accessKey: adminCreds.username, // Using root for now
|
||||||
secretKey: adminCreds.password,
|
secretKey: adminCreds.password,
|
||||||
@@ -253,20 +306,24 @@ export class MinioProvider extends BasePlatformServiceProvider {
|
|||||||
|
|
||||||
async deprovisionResource(resource: IPlatformResource, credentials: Record<string, string>): Promise<void> {
|
async deprovisionResource(resource: IPlatformResource, credentials: Record<string, string>): Promise<void> {
|
||||||
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
|
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
|
||||||
if (!platformService || !platformService.adminCredentialsEncrypted) {
|
if (!platformService || !platformService.adminCredentialsEncrypted || !platformService.containerId) {
|
||||||
throw new Error('MinIO platform service not found or not configured');
|
throw new Error('MinIO platform service not found or not configured');
|
||||||
}
|
}
|
||||||
|
|
||||||
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
|
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
|
||||||
const containerName = this.getContainerName();
|
|
||||||
const endpoint = `http://${containerName}:9000`;
|
// 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');
|
const { S3Client, DeleteBucketCommand, ListObjectsV2Command, DeleteObjectsCommand } = await import('npm:@aws-sdk/client-s3@3');
|
||||||
|
|
||||||
const s3Client = new S3Client({
|
const s3Client = new S3Client({
|
||||||
endpoint,
|
endpoint: `http://127.0.0.1:${hostPort}`,
|
||||||
region: 'us-east-1',
|
region: 'us-east-1',
|
||||||
credentials: {
|
credentials: {
|
||||||
accessKeyId: adminCreds.username,
|
accessKeyId: adminCreds.username,
|
||||||
|
|||||||
@@ -49,10 +49,11 @@ export class OneboxServicesManager {
|
|||||||
|
|
||||||
// Build platform requirements
|
// Build platform requirements
|
||||||
const platformRequirements: IPlatformRequirements | undefined =
|
const platformRequirements: IPlatformRequirements | undefined =
|
||||||
(options.enableMongoDB || options.enableS3)
|
(options.enableMongoDB || options.enableS3 || options.enableClickHouse)
|
||||||
? {
|
? {
|
||||||
mongodb: options.enableMongoDB,
|
mongodb: options.enableMongoDB,
|
||||||
s3: options.enableS3,
|
s3: options.enableS3,
|
||||||
|
clickhouse: options.enableClickHouse,
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export interface ITokenCreatedResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Platform service types
|
// Platform service types
|
||||||
export type TPlatformServiceType = 'mongodb' | 'minio' | 'redis' | 'postgresql' | 'rabbitmq' | 'caddy';
|
export type TPlatformServiceType = 'mongodb' | 'minio' | 'redis' | 'postgresql' | 'rabbitmq' | 'caddy' | 'clickhouse';
|
||||||
export type TPlatformResourceType = 'database' | 'bucket' | 'cache' | 'queue';
|
export type TPlatformResourceType = 'database' | 'bucket' | 'cache' | 'queue';
|
||||||
export type TPlatformServiceStatus = 'stopped' | 'starting' | 'running' | 'stopping' | 'failed';
|
export type TPlatformServiceStatus = 'stopped' | 'starting' | 'running' | 'stopping' | 'failed';
|
||||||
|
|
||||||
@@ -110,6 +110,7 @@ export interface IPlatformResource {
|
|||||||
export interface IPlatformRequirements {
|
export interface IPlatformRequirements {
|
||||||
mongodb?: boolean;
|
mongodb?: boolean;
|
||||||
s3?: boolean;
|
s3?: boolean;
|
||||||
|
clickhouse?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IProvisionedResource {
|
export interface IProvisionedResource {
|
||||||
@@ -287,6 +288,7 @@ export interface IServiceDeployOptions {
|
|||||||
// Platform service requirements
|
// Platform service requirements
|
||||||
enableMongoDB?: boolean;
|
enableMongoDB?: boolean;
|
||||||
enableS3?: boolean;
|
enableS3?: boolean;
|
||||||
|
enableClickHouse?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// HTTP API request/response types
|
// HTTP API request/response types
|
||||||
|
|||||||
@@ -32,10 +32,8 @@ export const routes: Routes = [
|
|||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
loadComponent: () =>
|
redirectTo: 'user',
|
||||||
import('./features/services/services-list.component').then(
|
pathMatch: 'full',
|
||||||
(m) => m.ServicesListComponent
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'create',
|
path: 'create',
|
||||||
@@ -52,12 +50,19 @@ export const routes: Routes = [
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: ':name',
|
path: 'detail/:name',
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./features/services/service-detail.component').then(
|
import('./features/services/service-detail.component').then(
|
||||||
(m) => m.ServiceDetailComponent
|
(m) => m.ServiceDetailComponent
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: ':tab',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./features/services/services-list.component').then(
|
||||||
|
(m) => m.ServicesListComponent
|
||||||
|
),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -65,10 +70,8 @@ export const routes: Routes = [
|
|||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
loadComponent: () =>
|
redirectTo: 'proxy',
|
||||||
import('./features/network/network.component').then(
|
pathMatch: 'full',
|
||||||
(m) => m.NetworkComponent
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'domains/:domain',
|
path: 'domains/:domain',
|
||||||
@@ -77,14 +80,31 @@ export const routes: Routes = [
|
|||||||
(m) => m.DomainDetailComponent
|
(m) => m.DomainDetailComponent
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: ':tab',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./features/network/network.component').then(
|
||||||
|
(m) => m.NetworkComponent
|
||||||
|
),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'registries',
|
path: 'registries',
|
||||||
loadComponent: () =>
|
children: [
|
||||||
import('./features/registries/registries.component').then(
|
{
|
||||||
(m) => m.RegistriesComponent
|
path: '',
|
||||||
),
|
redirectTo: 'onebox',
|
||||||
|
pathMatch: 'full',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ':tab',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./features/registries/registries.component').then(
|
||||||
|
(m) => m.RegistriesComponent
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'tokens',
|
path: 'tokens',
|
||||||
|
|||||||
@@ -16,13 +16,14 @@ export interface ILoginResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Platform Service Types (defined early for use in ISystemStatus)
|
// Platform Service Types (defined early for use in ISystemStatus)
|
||||||
export type TPlatformServiceType = 'mongodb' | 'minio' | 'redis' | 'postgresql' | 'rabbitmq' | 'caddy';
|
export type TPlatformServiceType = 'mongodb' | 'minio' | 'redis' | 'postgresql' | 'rabbitmq' | 'caddy' | 'clickhouse';
|
||||||
export type TPlatformServiceStatus = 'not-deployed' | 'stopped' | 'starting' | 'running' | 'stopping' | 'failed';
|
export type TPlatformServiceStatus = 'not-deployed' | 'stopped' | 'starting' | 'running' | 'stopping' | 'failed';
|
||||||
export type TPlatformResourceType = 'database' | 'bucket' | 'cache' | 'queue';
|
export type TPlatformResourceType = 'database' | 'bucket' | 'cache' | 'queue';
|
||||||
|
|
||||||
export interface IPlatformRequirements {
|
export interface IPlatformRequirements {
|
||||||
mongodb?: boolean;
|
mongodb?: boolean;
|
||||||
s3?: boolean;
|
s3?: boolean;
|
||||||
|
clickhouse?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IService {
|
export interface IService {
|
||||||
@@ -56,6 +57,7 @@ export interface IServiceCreate {
|
|||||||
autoUpdateOnPush?: boolean;
|
autoUpdateOnPush?: boolean;
|
||||||
enableMongoDB?: boolean;
|
enableMongoDB?: boolean;
|
||||||
enableS3?: boolean;
|
enableS3?: boolean;
|
||||||
|
enableClickHouse?: boolean; // ClickHouse analytics database
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IServiceUpdate {
|
export interface IServiceUpdate {
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
|
|||||||
</ui-badge>
|
</ui-badge>
|
||||||
</ui-table-cell>
|
</ui-table-cell>
|
||||||
<ui-table-cell class="text-right">
|
<ui-table-cell class="text-right">
|
||||||
<a [routerLink]="['/services', svc.name]">
|
<a [routerLink]="['/services/detail', svc.name]">
|
||||||
<button uiButton variant="outline" size="sm">View</button>
|
<button uiButton variant="outline" size="sm">View</button>
|
||||||
</a>
|
</a>
|
||||||
</ui-table-cell>
|
</ui-table-cell>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { Component, inject, signal, OnInit, OnDestroy, ViewChild, ElementRef, effect } from '@angular/core';
|
import { Component, inject, signal, OnInit, OnDestroy, ViewChild, ElementRef, effect } from '@angular/core';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { Subscription } from 'rxjs';
|
||||||
import { ApiService } from '../../core/services/api.service';
|
import { ApiService } from '../../core/services/api.service';
|
||||||
import { ToastService } from '../../core/services/toast.service';
|
import { ToastService } from '../../core/services/toast.service';
|
||||||
import { NetworkLogStreamService } from '../../core/services/network-log-stream.service';
|
import { NetworkLogStreamService } from '../../core/services/network-log-stream.service';
|
||||||
@@ -294,6 +296,9 @@ type TNetworkTab = 'proxy' | 'dns' | 'domains';
|
|||||||
export class NetworkComponent implements OnInit, OnDestroy {
|
export class NetworkComponent implements OnInit, OnDestroy {
|
||||||
private api = inject(ApiService);
|
private api = inject(ApiService);
|
||||||
private toast = inject(ToastService);
|
private toast = inject(ToastService);
|
||||||
|
private route = inject(ActivatedRoute);
|
||||||
|
private router = inject(Router);
|
||||||
|
private routeSub?: Subscription;
|
||||||
networkLogStream = inject(NetworkLogStreamService);
|
networkLogStream = inject(NetworkLogStreamService);
|
||||||
|
|
||||||
@ViewChild('logContainer') logContainer!: ElementRef<HTMLDivElement>;
|
@ViewChild('logContainer') logContainer!: ElementRef<HTMLDivElement>;
|
||||||
@@ -321,15 +326,24 @@ export class NetworkComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
|
// Subscribe to route params to sync tab state with URL
|
||||||
|
this.routeSub = this.route.paramMap.subscribe((params) => {
|
||||||
|
const tab = params.get('tab') as TNetworkTab;
|
||||||
|
if (tab && ['proxy', 'dns', 'domains'].includes(tab)) {
|
||||||
|
this.activeTab.set(tab);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
this.loadData();
|
this.loadData();
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
|
this.routeSub?.unsubscribe();
|
||||||
this.networkLogStream.disconnect();
|
this.networkLogStream.disconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
setTab(tab: TNetworkTab): void {
|
setTab(tab: TNetworkTab): void {
|
||||||
this.activeTab.set(tab);
|
this.router.navigate(['/network', tab]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadData(): Promise<void> {
|
async loadData(): Promise<void> {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
import { Component, inject, signal, OnInit, OnDestroy } from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { RouterLink } from '@angular/router';
|
import { RouterLink, ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { Subscription } from 'rxjs';
|
||||||
import { ApiService } from '../../core/services/api.service';
|
import { ApiService } from '../../core/services/api.service';
|
||||||
import { ToastService } from '../../core/services/toast.service';
|
import { ToastService } from '../../core/services/toast.service';
|
||||||
import { IRegistry, IRegistryCreate } from '../../core/types/api.types';
|
import { IRegistry, IRegistryCreate } from '../../core/types/api.types';
|
||||||
@@ -242,9 +243,12 @@ type TRegistriesTab = 'onebox' | 'external';
|
|||||||
</ui-dialog>
|
</ui-dialog>
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
export class RegistriesComponent implements OnInit {
|
export class RegistriesComponent implements OnInit, OnDestroy {
|
||||||
private api = inject(ApiService);
|
private api = inject(ApiService);
|
||||||
private toast = inject(ToastService);
|
private toast = inject(ToastService);
|
||||||
|
private route = inject(ActivatedRoute);
|
||||||
|
private router = inject(Router);
|
||||||
|
private routeSub?: Subscription;
|
||||||
|
|
||||||
activeTab = signal<TRegistriesTab>('onebox');
|
activeTab = signal<TRegistriesTab>('onebox');
|
||||||
registries = signal<IRegistry[]>([]);
|
registries = signal<IRegistry[]>([]);
|
||||||
@@ -256,13 +260,25 @@ export class RegistriesComponent implements OnInit {
|
|||||||
form: IRegistryCreate = { url: '', username: '', password: '' };
|
form: IRegistryCreate = { url: '', username: '', password: '' };
|
||||||
|
|
||||||
setTab(tab: TRegistriesTab): void {
|
setTab(tab: TRegistriesTab): void {
|
||||||
this.activeTab.set(tab);
|
this.router.navigate(['/registries', tab]);
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
|
// Subscribe to route params to sync tab state with URL
|
||||||
|
this.routeSub = this.route.paramMap.subscribe((params) => {
|
||||||
|
const tab = params.get('tab') as TRegistriesTab;
|
||||||
|
if (tab && ['onebox', 'external'].includes(tab)) {
|
||||||
|
this.activeTab.set(tab);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
this.loadRegistries();
|
this.loadRegistries();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.routeSub?.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
async loadRegistries(): Promise<void> {
|
async loadRegistries(): Promise<void> {
|
||||||
this.loading.set(true);
|
this.loading.set(true);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Component, inject, signal, OnInit, OnDestroy, effect, ViewChild, ElementRef } from '@angular/core';
|
import { Component, inject, signal, OnInit, OnDestroy, effect, ViewChild, ElementRef } from '@angular/core';
|
||||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
import { Location } from '@angular/common';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { ApiService } from '../../core/services/api.service';
|
import { ApiService } from '../../core/services/api.service';
|
||||||
import { ToastService } from '../../core/services/toast.service';
|
import { ToastService } from '../../core/services/toast.service';
|
||||||
@@ -22,7 +23,6 @@ import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
|
|||||||
selector: 'app-platform-service-detail',
|
selector: 'app-platform-service-detail',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [
|
||||||
RouterLink,
|
|
||||||
FormsModule,
|
FormsModule,
|
||||||
CardComponent,
|
CardComponent,
|
||||||
CardHeaderComponent,
|
CardHeaderComponent,
|
||||||
@@ -38,7 +38,7 @@ import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
|
|||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div>
|
<div>
|
||||||
<a routerLink="/services" class="text-sm text-muted-foreground hover:text-foreground inline-flex items-center gap-1 mb-2">
|
<a (click)="goBack()" class="cursor-pointer text-sm text-muted-foreground hover:text-foreground inline-flex items-center gap-1 mb-2">
|
||||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -235,6 +235,7 @@ import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
|
|||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
export class PlatformServiceDetailComponent implements OnInit, OnDestroy {
|
export class PlatformServiceDetailComponent implements OnInit, OnDestroy {
|
||||||
|
private location = inject(Location);
|
||||||
private route = inject(ActivatedRoute);
|
private route = inject(ActivatedRoute);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
private api = inject(ApiService);
|
private api = inject(ApiService);
|
||||||
@@ -281,6 +282,10 @@ export class PlatformServiceDetailComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
goBack(): void {
|
||||||
|
this.location.back();
|
||||||
|
}
|
||||||
|
|
||||||
async loadService(type: TPlatformServiceType): Promise<void> {
|
async loadService(type: TPlatformServiceType): Promise<void> {
|
||||||
this.loading.set(true);
|
this.loading.set(true);
|
||||||
try {
|
try {
|
||||||
@@ -352,6 +357,7 @@ export class PlatformServiceDetailComponent implements OnInit, OnDestroy {
|
|||||||
postgresql: 'PostgreSQL is a powerful, open-source object-relational database system with over 35 years of active development.',
|
postgresql: 'PostgreSQL is a powerful, open-source object-relational database system with over 35 years of active development.',
|
||||||
rabbitmq: 'RabbitMQ is a message broker that enables applications to communicate with each other using messages through queues.',
|
rabbitmq: 'RabbitMQ is a message broker that enables applications to communicate with each other using messages through queues.',
|
||||||
caddy: 'Caddy is a powerful, enterprise-ready, open-source web server with automatic HTTPS. It serves as the reverse proxy for Onebox.',
|
caddy: 'Caddy is a powerful, enterprise-ready, open-source web server with automatic HTTPS. It serves as the reverse proxy for Onebox.',
|
||||||
|
clickhouse: 'ClickHouse is a fast, open-source columnar database management system optimized for real-time analytics and data warehousing.',
|
||||||
};
|
};
|
||||||
return descriptions[type] || 'A platform service managed by Onebox.';
|
return descriptions[type] || 'A platform service managed by Onebox.';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -215,9 +215,20 @@ interface EnvVar {
|
|||||||
<p class="text-xs text-muted-foreground">A dedicated bucket will be created and credentials injected as S3_* and AWS_* env vars</p>
|
<p class="text-xs text-muted-foreground">A dedicated bucket will be created and credentials injected as S3_* and AWS_* env vars</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<ui-checkbox
|
||||||
|
[checked]="form.enableClickHouse ?? false"
|
||||||
|
(checkedChange)="form.enableClickHouse = $event"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<label uiLabel class="cursor-pointer">ClickHouse Database</label>
|
||||||
|
<p class="text-xs text-muted-foreground">A dedicated database will be created and credentials injected as CLICKHOUSE_* env vars</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (form.enableMongoDB || form.enableS3) {
|
@if (form.enableMongoDB || form.enableS3 || form.enableClickHouse) {
|
||||||
<ui-alert variant="default">
|
<ui-alert variant="default">
|
||||||
<ui-alert-description>
|
<ui-alert-description>
|
||||||
Platform services will be auto-deployed if not already running. Credentials are automatically injected as environment variables.
|
Platform services will be auto-deployed if not already running. Credentials are automatically injected as environment variables.
|
||||||
@@ -301,6 +312,7 @@ export class ServiceCreateComponent implements OnInit {
|
|||||||
autoUpdateOnPush: false,
|
autoUpdateOnPush: false,
|
||||||
enableMongoDB: false,
|
enableMongoDB: false,
|
||||||
enableS3: false,
|
enableS3: false,
|
||||||
|
enableClickHouse: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
envVars = signal<EnvVar[]>([]);
|
envVars = signal<EnvVar[]>([]);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Component, inject, signal, computed, OnInit, OnDestroy, ViewChild, ElementRef, effect } from '@angular/core';
|
import { Component, inject, signal, computed, OnInit, OnDestroy, ViewChild, ElementRef, effect } from '@angular/core';
|
||||||
|
import { Location } from '@angular/common';
|
||||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { ApiService } from '../../core/services/api.service';
|
import { ApiService } from '../../core/services/api.service';
|
||||||
@@ -58,7 +59,7 @@ import {
|
|||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div>
|
<div>
|
||||||
<a routerLink="/services" class="text-sm text-muted-foreground hover:text-foreground inline-flex items-center gap-1 mb-2">
|
<a (click)="goBack()" class="cursor-pointer text-sm text-muted-foreground hover:text-foreground inline-flex items-center gap-1 mb-2">
|
||||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -422,6 +423,7 @@ import {
|
|||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
export class ServiceDetailComponent implements OnInit, OnDestroy {
|
export class ServiceDetailComponent implements OnInit, OnDestroy {
|
||||||
|
private location = inject(Location);
|
||||||
private route = inject(ActivatedRoute);
|
private route = inject(ActivatedRoute);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
private api = inject(ApiService);
|
private api = inject(ApiService);
|
||||||
@@ -479,6 +481,10 @@ export class ServiceDetailComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
goBack(): void {
|
||||||
|
this.location.back();
|
||||||
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.logStream.disconnect();
|
this.logStream.disconnect();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Component, inject, signal, effect, OnInit } from '@angular/core';
|
import { Component, inject, signal, effect, OnInit, OnDestroy } from '@angular/core';
|
||||||
import { RouterLink } from '@angular/router';
|
import { RouterLink, ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { Subscription } from 'rxjs';
|
||||||
import { ApiService } from '../../core/services/api.service';
|
import { ApiService } from '../../core/services/api.service';
|
||||||
import { WebSocketService } from '../../core/services/websocket.service';
|
import { WebSocketService } from '../../core/services/websocket.service';
|
||||||
import { ToastService } from '../../core/services/toast.service';
|
import { ToastService } from '../../core/services/toast.service';
|
||||||
@@ -69,7 +70,7 @@ type TServicesTab = 'user' | 'system';
|
|||||||
<p class="text-muted-foreground">Manage your deployed and system services</p>
|
<p class="text-muted-foreground">Manage your deployed and system services</p>
|
||||||
</div>
|
</div>
|
||||||
@if (activeTab() === 'user') {
|
@if (activeTab() === 'user') {
|
||||||
<a routerLink="/services/create">
|
<a [routerLink]="['/services/create']">
|
||||||
<button uiButton>
|
<button uiButton>
|
||||||
<svg class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
<svg class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
|
||||||
@@ -105,7 +106,7 @@ type TServicesTab = 'user' | 'system';
|
|||||||
</svg>
|
</svg>
|
||||||
<h3 class="mt-4 text-lg font-semibold">No services</h3>
|
<h3 class="mt-4 text-lg font-semibold">No services</h3>
|
||||||
<p class="mt-2 text-sm text-muted-foreground">Get started by deploying your first service.</p>
|
<p class="mt-2 text-sm text-muted-foreground">Get started by deploying your first service.</p>
|
||||||
<a routerLink="/services/create" class="mt-4 inline-block">
|
<a [routerLink]="['/services/create']" class="mt-4 inline-block">
|
||||||
<button uiButton>Deploy Service</button>
|
<button uiButton>Deploy Service</button>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -124,7 +125,7 @@ type TServicesTab = 'user' | 'system';
|
|||||||
@for (service of services(); track service.name) {
|
@for (service of services(); track service.name) {
|
||||||
<ui-table-row>
|
<ui-table-row>
|
||||||
<ui-table-cell>
|
<ui-table-cell>
|
||||||
<a [routerLink]="['/services', service.name]" class="font-medium hover:underline">
|
<a [routerLink]="['/services/detail', service.name]" class="font-medium hover:underline">
|
||||||
{{ service.name }}
|
{{ service.name }}
|
||||||
</a>
|
</a>
|
||||||
</ui-table-cell>
|
</ui-table-cell>
|
||||||
@@ -299,10 +300,13 @@ type TServicesTab = 'user' | 'system';
|
|||||||
</ui-dialog>
|
</ui-dialog>
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
export class ServicesListComponent implements OnInit {
|
export class ServicesListComponent implements OnInit, OnDestroy {
|
||||||
private api = inject(ApiService);
|
private api = inject(ApiService);
|
||||||
private ws = inject(WebSocketService);
|
private ws = inject(WebSocketService);
|
||||||
private toast = inject(ToastService);
|
private toast = inject(ToastService);
|
||||||
|
private route = inject(ActivatedRoute);
|
||||||
|
private router = inject(Router);
|
||||||
|
private routeSub?: Subscription;
|
||||||
|
|
||||||
// Tab state
|
// Tab state
|
||||||
activeTab = signal<TServicesTab>('user');
|
activeTab = signal<TServicesTab>('user');
|
||||||
@@ -331,12 +335,24 @@ export class ServicesListComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
|
// Subscribe to route params to sync tab state with URL
|
||||||
|
this.routeSub = this.route.paramMap.subscribe((params) => {
|
||||||
|
const tab = params.get('tab') as TServicesTab;
|
||||||
|
if (tab && ['user', 'system'].includes(tab)) {
|
||||||
|
this.activeTab.set(tab);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
this.loadServices();
|
this.loadServices();
|
||||||
this.loadPlatformServices();
|
this.loadPlatformServices();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.routeSub?.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
setTab(tab: TServicesTab): void {
|
setTab(tab: TServicesTab): void {
|
||||||
this.activeTab.set(tab);
|
this.router.navigate(['/services', tab]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadServices(): Promise<void> {
|
async loadServices(): Promise<void> {
|
||||||
|
|||||||
Reference in New Issue
Block a user